Learning from the Ising model!
Boltzmann machine learning!
Boltzmann machine learning
Boltzmann machines are deeply connected to concepts from statistical mechanics, where equilibrium distributions and energy landscapes play central roles. By leveraging these connections, Boltzmann machines can model complex distributions, offering insights into both machine learning and physical systems. To understand the foundation of Boltzmann learning, we first explore the Ising model, a cornerstone in statistical physics.
The Ising Model
The Ising model provides a framework to study the collective behavior of spins arranged on a lattice. Each spin, \(s_i\), can take one of two values, \(+1\) (up) or \(-1\) (down). Spins interact with their neighbors via pairwise couplings \(J_{ij}\), and the system may also be influenced by external fields \(h_i\). The Hamiltonian for a generalized spin glass system is:
\[\mathcal{H}(\{s_i\}) = -\sum_{j > i} J_{ij} s_i s_j - \sum_{i} h_i s_i\]Here:
- \(J_{ij}\): Interaction strength between spins \(s_i\) and \(s_j\).
- \(h_i\): External field acting on spin \(s_i\).
This energy-based model encapsulates the interplay of spin alignments and external influences.
Boltzmann Distribution and Partition Function
The Ising system’s equilibrium probability distribution is given by the Boltzmann distribution:
\[P_{J_{ij}, h_i}(\{s_i\}) = \frac{e^{-\mathcal{H}(\{s_i\})}}{\mathcal{Z}}\]where the partition function, \(\mathcal{Z}\), ensures normalization:
\[\mathcal{Z} = \sum_{\{s_i\}} e^{-\mathcal{H}(\{s_i\})}\]This distribution forms the basis for statistical inference, as it describes the likelihood of any spin configuration \(\{s_i\}\).
Kullback-Leibler (KL) Divergence
To measure the difference between the model distribution \(P_\theta(S)\) (parameterized by \(\theta = \{J_{ij}, h_i\}\)) and the empirical distribution \(P_0(S)\), we use the Kullback-Leibler divergence:
\[D_{KL}(P_0 || P_\theta) = \sum_{\{s_i\}} P_0(\{s_i\}) \log \left( \frac{P_0(\{s_i\})}{P_\theta(\{s_i\})} \right)\]Minimizing the KL divergence aligns the model distribution with the empirical data distribution.
Deriving the Loss Function
The negative log-likelihood loss function measures how well the model \(P_\theta(\{s_i\})\) approximates the empirical distribution \(P_0(\{s_i\})\). It is derived as follows:
The likelihood of the observed data under the model is:
\[\mathcal{L}(\theta) = -\frac{1}{M} \sum_{k=1}^M \log P_\theta(\{s_i^{(k)}\})\]Substituting the Boltzmann distribution for \(P_\theta\), we get:
\[P_\theta(\{s_i\}) = \frac{e^{-\mathcal{H}(\{s_i\})}}{\mathcal{Z}}\] \[\log P_\theta(\{s_i\}) = -\mathcal{H}(\{s_i\}) - \log \mathcal{Z}\]Thus, the negative log-likelihood becomes:
\[\mathcal{L}(\theta) = \frac{1}{M} \sum_{k=1}^M \mathcal{H}(\{s_i^{(k)}\}) + \log \mathcal{Z}\]Here:
- The first term accounts for the energy of observed configurations.
- The second term, \(\log \mathcal{Z}\), is the log of the partition function, which depends on \(\theta\) and involves summing over all possible spin configurations.
Gradient of the Loss Function
To optimize \(\mathcal{L}(\theta)\), we calculate the gradients with respect to \(h_i\) and \(J_{ij}\). For simplicity, denote \(\mathcal{L}(\theta)\) as \(\mathcal{L}\).
Gradient w.r.t \(H_{i}\),
\[\frac{\partial \mathcal{L}}{\partial h_i} = \frac{\partial}{\partial h_i} \left( \frac{1}{M} \sum_{k=1}^M \mathcal{H}(\{s_i^{(k)}\}) + \log \mathcal{Z} \right)\]Using \(\mathcal{H}(\{s_i\}) = -\sum_{j > i} J_{ij} s_i s_j - \sum_i h_i s_i\), the derivative becomes:
\[\frac{\partial \mathcal{H}}{\partial h_i} = -s_i\]Thus:
\[\frac{\partial \mathcal{L}}{\partial h_i} = -\frac{1}{M} \sum_{k=1}^M s_i^{(k)} + \frac{\partial \log \mathcal{Z}}{\partial h_i}\]The partition function gradient is:
\(\frac{\partial \log \mathcal{Z}}{\partial h_i} = \langle s_i \rangle_\theta\) Combining terms:
\[\frac{\partial \mathcal{L}}{\partial h_i} = \langle s_i \rangle_\theta - \langle s_i \rangle^D\]Gradient w.r.t \(J_{ij}\)
\[\frac{\partial \mathcal{L}}{\partial J_{ij}} = -\frac{1}{M} \sum_{k=1}^M s_i^{(k)} s_j^{(k)} + \langle s_i s_j \rangle_\theta\]Parameter Update Rules
Using gradient descent to minimize \(\mathcal{L}(\theta)\), the updates for \(h_i\) and \(J_{ij}\) are:
\[h_i^{n+1} = h_i^n + \eta \left( \langle s_i \rangle^D - \langle s_i \rangle_\theta \right)\] \[J_{ij}^{n+1} = J_{ij}^n + \eta \left( \langle s_i s_j \rangle^D - \langle s_i s_j \rangle_\theta \right)\]where:
- \(\langle s_i \rangle^D\): Empirical average of \(s_i\) from data.
- \(\langle s_i \rangle_\theta\): Model average of \(s_i\) under the current parameters.
- \(\eta\): Learning rate controlling the update step size.
The learning, in this case can be summarized as follows,
- Initialization: Start with random parameters \(h_i\) and \(J_{ij}\).
- Sampling: Generate model samples \(\{s_i\}\) using techniques like Gibbs sampling or contrastive divergence to approximate \(\langle s_i \rangle_\theta\) and \(\langle s_i s_j \rangle_\theta\).
- Compute Gradients: Use the empirical data to compute \(\langle s_i \rangle^D\) and \(\langle s_i s_j \rangle^D\), and calculate the gradients.
- Update Parameters: Adjust \(h_i\) and \(J_{ij}\) using the update rules.
- Iterate: Repeat until, where \(P_\theta(\{s_i\})\) closely matches the empirical \(P_0(\{s_i\})\).
Implementation in Python
Using the update rules mentioned above we can infer the target parameters. First we import neccessary libraries.
#import necessary packages.
import scienceplots #better plots
import numpy as np
import matplotlib.pyplot as plt ; import matplotlib
import pandas as pd
from tqdm import tqdm
matplotlib.rcParams['figure.dpi']=200
plt.style.use(['science','grid','no-latex'])Define the one dimensional lattice and assign spins to it accordingly.
#Create random spin 1D chains
def init_lattice(L):
'''
args : L = lattice size (L)
returns : ndarray shape L
'''
lattice = 2 * np.random.randint(0, 2, L) - 1 ##arrange [-1,+1] spins accordingly
return latticeNow we need to create the target parameters that we are going to infer. To this end, we first create a function to create asymmetric and symmetric coupling matrix.
#Create target parameters that will be later inferred.
def create_target_params(N,assym=False):
'''
args : N = int, size of the interaction matrix , NxN and Nx1
assym : flag = bool, set true for assymetric couplings.
returns : ndarray with shapes NxN and Nx1
'''
if assym==False:
temp_matrix = np.random.normal(0,1/N,(N,N))
J = 0.5*(temp_matrix + temp_matrix.T) ; np.fill_diagonal(J,0) # symmetric matrix with zero diagonal
h = np.random.normal(0,1,size=N)
else:
J = np.random.normal(0,1/N,(N,N)) ; # Assymetric matrix
np.fill_diagonal(J,0)
h = np.random.normal(0,1,size=N)
return J,hNow we need to create a system which is thermalized. To do this, we thermalize the system using a Markov Chain - Monte Carlo (MCMC). A metropolis-Hastings step is performed in each markov step.
#Calculate energy difference.
def calculate_energy_diff(temp_index,configuration,J,h):
'''
args : temp_index = int, temporary index for placeholder
configuration = ndarray Nx1, spin configuration
J = ndarray NxN, interaction matrix
h = ndarray Nx1, external field.
return : E, float, energy difference between spin flip
'''
E = 2*configuration[temp_index] * (np.dot(J[temp_index,:],configuration) + h[temp_index])
return E
#Metropolis dynamics.
def metropolis_dynamics(lattice,J,h):
'''
args : lattice = ndarray Nx1 , spin configuration
J = ndarray NxN, interaction matrix
h = ndarray Nx1, external field.
returns : configuration = ndarray Nx1, updated configuration
'''
configuration = lattice.copy() ; L = int(len(configuration))
for _ in range(len(lattice)):
random_index = np.random.randint(0,L)
delta_E = calculate_energy_diff(random_index,configuration,J,h)
prob = np.exp(-delta_E)
if np.random.uniform(0,1) < prob:
configuration[random_index] *= -1
return configurationNow we perform MCMC,
#Perform markov chain monte carlo.
def markovchain_montecarlo(lattice,J,h,sweeps,burn_in):
'''
args : lattice = ndarray Nx1 , spin configuration
J = ndarray NxN, interaction matrix
h = ndarray Nx1, external field
sweeps = int, number of sweeps
burn_in = int, number of burn_in sweeps
returns : mag = ndarray Nx1 , Mean Magnetisations
C_ij = ndarray NxN, correlation matrix
states = ndarray Nxsweeps, Final spins states.
'''
configuration = lattice.copy() ; L = len(lattice)
C_ij = np.zeros((L,L))
states = []
for _ in range(burn_in):
configuration = metropolis_dynamics(configuration,J,h)
for sweep in range(sweeps):
configuration = metropolis_dynamics(configuration,J,h)
C_ij += np.outer(configuration,configuration)
states.append(configuration)
mag = np.mean(states,axis=0)
C_ij /= sweeps
return mag,C_ij,statesNote that we are saving both Magnetisations, and Spin-Spin correlations. The states will be used to find the empirical probability distribution and in turn to find the negative log likelihood.
To check if the system thermalizes and to calculate the ‘burn-in’ time, we let the system runs with a range of values.
J,h = create_target_params(5)
lattice = init_lattice(5)
sweeps = 10000
burn_in_steps = np.arange(20) # Make a list of burn in values
burn_ins = 2 ** burn_in_steps #
L = len(lattice)
M = np.zeros((len(burn_ins), L ))
C = np.zeros((len(burn_ins), L**2))
for i in range(len(burn_ins)):
mag,C_ij,states = markovchain_montecarlo(lattice,J,h,burn_ins[i],0)
M[i,:] = mag
C[i,:] = C_ij.flatten()
#Take their differences so that it can be plotted.
Mags = abs(np.diff(M,axis=0))
Corrs = abs(np.diff(C,axis=0))If we plot the results we see it clearly.
Now we create neccessary functions to calculate some key parameters. First we need to observe how the learning process. To do this, we can calculate the negative log likelihood (NLL). To do this, we use the previously calculated spin configuration vectors.
#Calculate Negative log likelihood
def calculate_NLL(configs):
'''
args : configs, list, list of final states obtained.
returns : NLL, float, Negative log likelihood
'''
M = len(configs)
unique_samples, sample_counts = np.unique(configs, axis=0, return_counts=True)
Prob_distr = sample_counts / M
NLL = -(1/M) * (np.sum(sample_counts*np.log(Prob_distr)))
return NLLNow we define the update steps for \(h\) and \(J\).
# Update schemes
def h_update(h,mag_model,mag_train,eta):
'''
args : h = ndarray Nx1, external fields
mag_model = ndarray Nx1, Model magnetistaions
mag_train = ndarray Nx1, training data
eta = float, learning rate.
returns : h_up = ndarray Nx1, updated parameter values
'''
h_up = h + eta * (mag_train - mag_model)
return h_up
def J_update(J,corr_model,corr_train,eta):
'''
args : J = ndarray NxN, interaction matrix
corr_model = ndarray NxN, Model correlations
corr_train = ndarray NxN, training data
eta = float, learning rate.
returns : J_up = ndarray NxN, updated parameter values
'''
J_up = J + eta * (corr_train - corr_model)
np.fill_diagonal(J_up,0)
return J_upAfter the update schemes are defined, the final learning loop can be constructed.
#Main learning block
def boltzmann_machine(L,iterations,sweeps,burn_in,mag_train,corr_train,eta):
'''
args :
L = int, size of the spin configuration chain
iterations = int, number of iterations
sweeps = int, number of markov sweeps
burn_in = int, number of burn_in sweeps
mag_train = ndarray Nx1, training data
corr_train = ndarray NxN, training data
eta = float, learning rate.
returns : J = ndarray NxN, inferred interaction matrix
h = ndarray Nx1, inferred field
log_likelihoods = list, Negative log likelihoods
'''
J,h = create_target_params(L)
log_likelihoods = []
for iter in tqdm(range(iterations)):
mag_model,corr_model,states = markovchain_montecarlo(init_lattice(L),J,h,sweeps,burn_in)
h = h_update(h,mag_model,mag_train,eta)
J = J_update(J,corr_model,corr_train,eta)
NLL = calculate_NLL(states)
log_likelihoods.append(NLL)
return J,h,log_likelihoodsDone! now all we have to do see if all of this works, is to generaete some sample values for \(h\) and \(J\), thermalize the system and we can infer those generated values. Here I am plotting the learning process by varying \(\eta\), the learning rate.
The inferred parameters look like this,
Note that \(J\) was a 5x5 matrix, so it is flattened to look at the inference.
Thumbnail image credits:
By HeMath - Own work, CC BY-SA 4.0, Link