Monday, April 21st, 2025

In [1]:
import numpy as np
import matplotlib.pyplot as plt

Conway's Game of Life

Conway's Game of Life is a cellular automaton created by John Conway in 1970. Similar to the Mayfly model, it is a deterministic process to update the state of a population of cells.

We will use 2D NumPy arrays to represent the population of cells aranged in an $n \times n$ grid. A value of 1 will signify that a cell is alive while a value of 0 will signify that a cell is dead.

Starting configuration

Lets begin with an $ n\times n $ array of all 0s with a small three-block column (3$\times$1) of 1s in the middle. Use an integer datatype (dtype=int) when defining your array.

Exercise: Write a function starting_state(n) that returns the array described above.

In [2]:
def starting_state(n):
    cells = np.zeros((n,n), dtype=int)
    cells[n//2-1:n//2+2, n//2] = 1

    return cells

Let's visualize this configuration with plt.imshow:

In [3]:
n = 10

cells = starting_state(n)

plt.imshow(cells)
Out[3]:
<matplotlib.image.AxesImage at 0x18a99c09460>

Rules of Life

We will use the current state of the population to determine the next state. In the Game of Life, each cell interacts with its eight neighbors (i.e. the horizontally, vertically, or diagonally adjacent cells).

neighbors

The rules of the Game of Life can be summarized as follows:

  1. Any live cell with two or three live neighbors survives.
  2. Any dead cell with with three live neighbors becomes a live cell.
  3. All other live cells die in the next generation, and all other dead cells stay dead.

Counting the number of live neighbors

In order to update our array from one state to the next, we need to be able to count the number of live neighbors of the $(i,j)$th cell for any choice of $i,j$.

Exercise: Write a function count_live_neighbors(cells,i,j) that counts the number of living neighbors of the $(i,j)$th cell.

  • We handled a similar problem with the Image Denoising project.
  • How can we handle cells on the edge of the grid?
  • The np.sum function will add all values in an array.
  • We want to exclude (or remove from the sum) the $(i,j)$th cell when counting the number of living neighbors.

For image denoising, we discussed padding our array before constructing 3 by 3 grids centered at each pixel:

In [7]:
def count_live_neighbors(cells,i,j):
    nrows, ncols = cells.shape
    padded_cells = np.zeros((nrows+2, ncols+2), dtype=int)
    padded_cells[1:-1, 1:-1] = cells

    # Recall: The [i,j]th index of cells corresponds to
    # the [i+1, j+1]st index of padded_cells
    
    # We want a 3 by 3 grid centered at the [i,j]th cell of cells
    grid = padded_cells[i:i+3, j:j+3]

    # We can subtract cells[i,j] from the sum
    # to either remove a wrongly counted live neighbor
    # or do nothing if cells[i,j] is not alive
    live_neighbors = np.sum(grid) - cells[i,j]
    
    return live_neighbors

Let's test our function on the starting configuration previously defined:

In [10]:
print(cells)
[[0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 1 0 0 0 0]
 [0 0 0 0 0 1 0 0 0 0]
 [0 0 0 0 0 1 0 0 0 0]
 [0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0 0]]
In [12]:
print(count_live_neighbors(cells, 3, 5))
print(count_live_neighbors(cells, 4, 5))
print(count_live_neighbors(cells, 5, 5))
print(count_live_neighbors(cells, 5, 6))
1
1
2
3

Updating the cells population

We can now update the cells array according to the rules. We have to update every entry of the array, so we will need to loop through all the entries.

Exercise: Write a function update_cells(cells) that takes in a population array cells, applies the Rules of Life to update the population, and returns the updated population.

In [13]:
def update_cells(cells):
    new_cells = cells.copy()

    nrows, ncols = cells.shape
    for i in range(nrows):
        for j in range(ncols):
            # Get the number of live neighbors, excluding the cell itself
            live_neighbors = count_live_neighbors(cells, i, j)
            
            # If the is alive...
            if cells[i,j] == 1:
                # and if the cell has exactly 2 or 3 live neighbors...
                if live_neighbors == 2 or live_neighbors == 3:
                    # then the cell lives
                    new_cells[i,j] = 1
                else:
                    # otherwise the cell dies
                    new_cells[i,j] = 0
            # If the cell is not alive...
            else:
                # and if it has exactly 3 live neighbors...
                if live_neighbors == 3:
                    # then the cell becomes alive
                    new_cells[i,j] = 1
                else:
                    # otherwise the cell remains dead
                    new_cells[i,j] = 0
    return new_cells
In [15]:
new_cells = update_cells(cells)

plt.figure(figsize=(10,5))

plt.subplot(1,2,1)
plt.imshow(cells)
plt.title('Old cells')

plt.subplot(1,2,2)
plt.imshow(new_cells)
plt.title('New cells')
Out[15]:
Text(0.5, 1.0, 'New cells')

Animating the dynamics

The FuncAnimation function from matplotlib.animation can be used to create animations. It takes in a figure fig and function animate. The animate function should take in a frame index i and perform any desired updates to the figure.

In [16]:
%matplotlib notebook
#%matplotlib qt
from matplotlib.animation import FuncAnimation

Exercise: Modify the code below to animate the Game of Life.

Instead of visualizing the array x, we want to visualize cells. For each frame of animation, we want to update the cells array, i.e. cells = update_cells(cells).

In [19]:
#x = np.zeros((200,200), dtype = int)
n = 10
cells = starting_state(n)

fig = plt.figure()
im = plt.imshow(cells,vmin=0,vmax=1)               # Generate the initial plot

def animate(i):
    #x[:,:]= (np.random.random(x.shape) > .5)   # Update the x array with random data
    cells[:,:] = update_cells(cells)
    im.set_data(cells)                             # Update the figure with new x array
    return im

anim = FuncAnimation(fig, animate, cache_frame_data=False)
plt.show()

Exercise: Use np.random.random to randomly select an initial cells array of 0s and 1s.

In [20]:
def random_state(n, ratio_live_cells):
    cells = np.zeros((n,n))

    live_cell_mask = np.random.random((n,n)) < ratio_live_cells
    cells[live_cell_mask] = 1
    
    return cells
In [28]:
n = 200
cells = random_state(n, .1)

fig = plt.figure()
im = plt.imshow(cells,vmin=0,vmax=1)               # Generate the initial plot

def animate(i):
    cells[:,:] = update_cells(cells)
    im.set_data(cells)                             # Update the figure with new x array
    return im

anim = FuncAnimation(fig, animate, cache_frame_data=False)
plt.show()

What can we do to make our code run more efficiently? At a 200 by 200 grid, it is very slow to update.

Let's look at the count_live_neighbors function, which will run $n^2$ times for an $n$ by $n$ grid. Let's rewrite count_live_neighbors to take padded_cells in as an input.

In [30]:
def count_live_neighbors(cells,padded_cells,i,j):
    # Recall: The [i,j]th index of cells corresponds to
    # the [i+1, j+1]st index of padded_cells
    
    # We want a 3 by 3 grid centered at the [i,j]th cell of cells
    grid = padded_cells[i:i+3, j:j+3]

    # We can subtract cells[i,j] from the sum
    # to either remove a wrongly counted live neighbor
    # or do nothing if cells[i,j] is not alive
    live_neighbors = np.sum(grid) - cells[i,j]
    
    return live_neighbors

We'll then need to update the update_cells function to pass in padded_cells:

In [31]:
def update_cells(cells):
    new_cells = cells.copy()

    nrows, ncols = cells.shape
    padded_cells = np.zeros((nrows+2, ncols+2), dtype=int)
    padded_cells[1:-1, 1:-1] = cells
    for i in range(nrows):
        for j in range(ncols):
            # Get the number of live neighbors, excluding the cell itself
            live_neighbors = count_live_neighbors(cells, padded_cells, i, j)
            
            # If the is alive...
            if cells[i,j] == 1:
                # and if the cell has exactly 2 or 3 live neighbors...
                if live_neighbors == 2 or live_neighbors == 3:
                    # then the cell lives
                    new_cells[i,j] = 1
                else:
                    # otherwise the cell dies
                    new_cells[i,j] = 0
            # If the cell is not alive...
            else:
                # and if it has exactly 3 live neighbors...
                if live_neighbors == 3:
                    # then the cell becomes alive
                    new_cells[i,j] = 1
                else:
                    # otherwise the cell remains dead
                    new_cells[i,j] = 0
    return new_cells

Let's see if that helps:

In [33]:
n = 200
cells = random_state(n, .1)

fig = plt.figure()
im = plt.imshow(cells,vmin=0,vmax=1)               # Generate the initial plot

def animate(i):
    cells[:,:] = update_cells(cells)
    im.set_data(cells)                             # Update the figure with new x array
    return im

anim = FuncAnimation(fig, animate, cache_frame_data=False)
plt.show()

Wednesday, April 23rd

In [1]:
import matplotlib.pyplot as plt
import numpy as np
In [8]:
from matplotlib.animation import FuncAnimation
In [9]:
%matplotlib notebook

From Monday's class:

In [10]:
def count_live_neighbors(cells,padded_cells,i,j):
    # Recall: The [i,j]th index of cells corresponds to
    # the [i+1, j+1]st index of padded_cells
    
    # We want a 3 by 3 grid centered at the [i,j]th cell of cells
    grid = padded_cells[i:i+3, j:j+3]

    # We can subtract cells[i,j] from the sum
    # to either remove a wrongly counted live neighbor
    # or do nothing if cells[i,j] is not alive
    live_neighbors = np.sum(grid) - cells[i,j]
    
    return live_neighbors
In [11]:
def update_cells(cells):
    new_cells = cells.copy()

    nrows, ncols = cells.shape
    padded_cells = np.zeros((nrows+2, ncols+2), dtype=int)
    padded_cells[1:-1, 1:-1] = cells
    for i in range(nrows):
        for j in range(ncols):
            # Get the number of live neighbors, excluding the cell itself
            live_neighbors = count_live_neighbors(cells, padded_cells, i, j)
            
            # If the is alive...
            if cells[i,j] == 1:
                # and if the cell has exactly 2 or 3 live neighbors...
                if live_neighbors == 2 or live_neighbors == 3:
                    # then the cell lives
                    new_cells[i,j] = 1
                else:
                    # otherwise the cell dies
                    new_cells[i,j] = 0
            # If the cell is not alive...
            else:
                # and if it has exactly 3 live neighbors...
                if live_neighbors == 3:
                    # then the cell becomes alive
                    new_cells[i,j] = 1
                else:
                    # otherwise the cell remains dead
                    new_cells[i,j] = 0
    return new_cells
In [12]:
def random_state(n, ratio_live_cells):
    cells = np.zeros((n,n))

    live_cell_mask = np.random.random((n,n)) < ratio_live_cells
    cells[live_cell_mask] = 1
    
    return cells
In [14]:
n = 200
cells = random_state(n, .1)

fig = plt.figure()
im = plt.imshow(cells,vmin=0,vmax=1)               # Generate the initial plot

def animate(i):
    cells[:,:] = update_cells(cells)
    im.set_data(cells)                             # Update the figure with new x array
    return im

anim = FuncAnimation(fig, animate, cache_frame_data=False)
plt.show()

We would like be able to distill this animation into some data that we can more easily describe.

With our current code, we're constantly overwriting the cells array to store the current state only. It would be useful to maintain a history of all states of cells.

Let's a new variable, cells_history that will contain each iteration of cells. Let's also set a specific number of time steps that we will carry out.

In [30]:
T = 200    # Set the number of time steps
n = 100   # Set the grid size, i.e. n x n

cells = random_state(n, .1)        # Initialize the starting configuration
cells_history = np.zeros((n,n,T))  # Initialize the array containing all configurations
cells_history[:,:,0] = cells       # Set the t=0 slice to match the initial configuarion

Now, we want to iterate through the time steps and update our cells array and place its updated contents into the appropriate slice of cells_history.

In [31]:
for t in range(1,T):
    cells = update_cells(cells)    # Update the `cells` array
    cells_history[:,:,t] = cells   # store the update cells array in slice t

For each choice of t (i.e. the last index) of cells_history, we get another view of the cells array at time t.

Let's turn off interactive plotting for now.

In [32]:
%matplotlib inline
In [33]:
plt.imshow(cells_history[:,:,10])
Out[33]:
<matplotlib.image.AxesImage at 0x16c4d757ac0>

We could setup a 2x2 grid of subplots with different time slices:

In [34]:
t_list = [0, 10, 20, 30]

fig = plt.figure(figsize=(6,6))

for i, t in enumerate(t_list):
    plt.subplot(2,2,i+1)
    plt.imshow(cells_history[:,:,t])
    plt.axis('off')
    plt.title('t = {}'.format(t))
    
plt.tight_layout()

Note: We see that matplotlib has artificially introduced some green into this plot, but interpolation between yellow pixels and blue/purple pixels. We can fix this by using the keyword argument interpolation='nearest' within plt.imshow.

In [35]:
t_list = [0, 10, 20, 30]

fig = plt.figure(figsize=(6,6))

for i, t in enumerate(t_list):
    plt.subplot(2,2,i+1)
    plt.imshow(cells_history[:,:,t], interpolation='nearest')
    plt.axis('off')
    plt.title('t = {}'.format(t))
    
plt.tight_layout()

It would be useful if we could condense the geometric information (i.e. the information in the first two dimensions of cells_history into a single number so that we could plot this quantity against time (i.e. against the third dimension).

One idea is to count the number of live cells during each time step.

In [36]:
number_of_live_cells = []

for t in range(T):
    cells = cells_history[:,:,t]
    count = 0
    for i in range(n):
        for j in range(n):
            if cells[i,j] == 1:
                count += 1
    number_of_live_cells.append(count)

Note: We can use NumPy features to calculate these counts in a more "slick" way:

In [ ]:
number_of_live_cells = []

for t in range(T):
    cells = cells_history[:,:,t]
    
    count = cells.sum()   # Since live cells are 1s and dead cells are 0s, the sum is the count
    
    number_of_live_cells.append(count)

We could also use a Boolean array to identify locations of live cells

In [ ]:
number_of_live_cells = []

for t in range(T):
    cells = cells_history[:,:,t]
    
    count = (cells == 1).sum()           # Add up each True, where we have True for each live cell
    
    number_of_live_cells.append(count)

We could go one step further and avoid the outer for loop:

In [41]:
number_of_live_cells = np.sum(cells_history == 1, axis=(0,1))
In [43]:
t_list = np.arange(T)

plt.plot(t_list, number_of_live_cells, '-')

plt.title('Game of Life')
plt.xlabel('Time')
plt.ylabel('Number of live cells')
Out[43]:
Text(0, 0.5, 'Number of live cells')
In [ ]: