Probabilistic spin glass - Part III

Marton Trencseni - Sat 25 December 2021 - Physics

Introduction

In the previous articles (Part I, Part II), I looked at various properties of probabilistic spin glasses by simulating ensembles of many samples and computing various statistics, while in the case of entropy I computed probabilities directly. Here I will take a different route, and instead start with a starting grid, and let it evolve over "time" by changing spins one by one, and see how it behaves. The ipython notebook is on Github.

Evolution rule

Let's see what happens if we apply a dynamical probabilistic evolution operator to a probabilistic spin glass. The approach we will follow is simple:

  1. Generate an initial grid per the (symmetrized) method described in the previous articles.
  2. Pick a random (non-edge) spin, and given the four neighbours, pick a new alignment with the appropriate probability.

First, we have to arrive at the evolution probabilities: for a spin glass defined by $P(s=1 | n=1)=p$, what is the correct evolution probability given 4, already set neighbours? Instead of actually deriving the proability (which seems non-trivial to me), I cheat, and generate a large number of spin glasses and count frequencies to arrive at the probabilities. Since for 4 neighbours there are only $2^4=16$ possibilities, it's relatively easy to get good statistics.

def make_star_conditional(rows, cols, p0, p, num_simulations=1000):
    joint_frequencies, total = defaultdict(int), 0
    for simulation in range(num_simulations):
        pct = int((simulation / num_simulations) * 10000) / 100
        print(f'Computing conditionals for the {rows}x{cols} spin glass, progress {pct}% ', end='\r')
        sys.stdout.flush()
        grid = create_grid(rows, cols, p0, p)
        for i in range(1, rows-1):
            for j in range(1, cols-1):
                pattern = '%d%d%d%d%d' % (grid[i, j], grid[i, j-1], grid[i, j+1], grid[i-1, j], grid[i+1, j])
                joint_frequencies[pattern] += 1
                total += 1
    joint_probabilities = {} # joint_probabilities
    for pattern, freq in joint_frequencies.items():
        joint_probabilities[pattern] = freq/total
    return lambda up, down, left, right: ...

Visualization

Now we can run this probabilistic evolution, and on every steps_per_frame step, draw the frame on the screen:

def save_animation(filename, rows, cols, p0, starting_p, p, seconds=100, frames_per_second=5, steps_per_frame=6*1000):
    conditional_set_four_neighbours = make_star_conditional(rows, cols, p0, p)
    num_steps = seconds * frames_per_second * steps_per_frame
    grid, frames = create_grid(rows, cols, p0, starting_p), []
    for step in range(num_steps):
        if step % steps_per_frame == 0:
            frames.append(grid.copy())
        i, j = randint(1, rows-2), randint(1, cols-2)
        grid[i, j] = conditional_set_four_neighbours(grid[i, j-1], grid[i, j+1], grid[i-1, j], grid[i+1, j])
    fig = plt.figure()
    fig.suptitle(f'starting_p={starting_p:.2f}, p={p:0.2f}')
    im = plt.imshow(frames[0], cmap='Greys', vmin=0, vmax=1)
    plt.axis('off')
    def animate_func(i):
        im.set_array(frames[i])
        return [im]
    anim = animation.FuncAnimation(fig, animate_func,
        frames=seconds*frames_per_second,
        interval=1000/frames_per_second)
    anim.save(filename, fps=frames_per_second)

Previously, spin glasses had 4 parameters: rows, cols, p0, p. Here there's a fifth one, starting_p. This is in case we want the initial starting grid to be generated with a different p then in the evolution steps. This is interesting because we can check whether and how quickly the system forgets its initial configuration.

This is what a rows=50, cols=50, p0=0.5, starting_p=0.9, p=0.9 spin glass looks like:

Probabilistic spin glass

And here is the same, but with starting_p=0.5, so it starts from random noise:

Probabilistic spin glass

Note: the spins at the edges of the grid are not changed in the simulation.

The main takeaways:

  1. On the first simulation, the overall pattern doesn't change much. This is because at p=0.9, if all 4 neighbours are the same spin alignment, the spin is very likely to align. So the spins that tend to change are the ones on the edges of the patterns, where at least 1 or 2 of the spins are not aligned.
  2. On the second simulation with starting_p=0.5 we start out with a p=0.5 grid, which is just random noise. But then, very quickly, since we're evolving with the p=0.9 probabilities, out of the original randomnesss, a typical p=0.9 patterned grid forms, which then seemingly behaves like described in the previous point.

Visualizations of other parameters:

Same, but starting from starting_p=0.5 noise:

Convergence behaviour

We can check the convergence behaviour more systematically, ie. how quickly does the system forget the starting_p. Let's look at multiple trajectories, with different starting_ps, and let's use the majority fraction of spins that are aligned as the measure of order (so this is the y-axis):

def fraction(grid):
    r = np.sum(grid)/grid.size
    r = max(r, 1-r)
    return r

def get_trajectories(rows, cols, p0, starting_p, p, num_trajectories=3, seconds=100, frames_per_second = 5, steps_per_frame=6*1000):
    cache_key = str((rows, cols, p0, p))
    if cache_key not in conditional_set_four_neighbours_cached:
        conditional_set_four_neighbours_cached[cache_key] = make_star_conditional(rows, cols, p0, p)
    conditional_set_four_neighbours = conditional_set_four_neighbours_cached[cache_key]
    num_steps = seconds * frames_per_second * steps_per_frame
    trajectories = []
    starting_grid = create_grid(rows, cols, p0, starting_p)
    for t in range(num_trajectories):
        fs = []
        grid = starting_grid.copy()
        fs.append(fraction(grid))
        for step in range(num_steps):
            i, j = randint(1, rows-2), randint(1, cols-2)
            grid[i, j] = conditional_set_four_neighbours(grid[i, j-1], grid[i, j+1], grid[i-1, j], grid[i+1, j])
            if step % steps_per_frame == 0:
                fs.append(fraction(grid))
                pct = int((step / num_steps) * 10000) / 100
                print(f'Doing trajectory {t+1}/{num_trajectories} on the {rows}x{cols} spin glass, progress {pct}% ', end='\r')
                sys.stdout.flush()
        trajectories.append(fs)
    return trajectories

rows, cols, p0 = 50, 50, 0.5
mts = []
for p in [0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.975]:
    for starting_p in [0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.975]:
        trajectories = get_trajectories(
            rows=rows, cols=cols, p0=p0, starting_p=starting_p, p=p, num_trajectories=1)
        mts.append((p0, starting_p, p, trajectories[0]))

Yields:

Probabilistic spin glass

What this suggests is that it doesn't matter what the starting grid is, this initial condition is quickly washed out, and the grid behaves as if it always was running at its evoluationary p.

But why is the fraction so different in different simulation runs at higher p? To make sense of this, let's look at the fraction-curve from Part I again, but this time let's also plot the standard deviation, specifically for a $50 \times 50$ grids:

Probabilistic spin glass

This is in agreement with what we see in the simulation runs: at p higher than 0.7, the standard deviation of the majority spin alignment is quite high, so there is a lot of deviation from the average fraction.

Conclusion

In the next and final piece I will make the system periodic and look at how behaviour changes with grid size.