Schelling Model of Segregation

Background

A recent article in the 1 November 2024, Daily Skimm discussed an article in the New York Times which analysed the movement of voters in the United States. The article found that voters are increasingly moving to neighbourhoods where the majority of residents share their political views.


A screenshot of the skimm article

This change leading to increased polarisation (not to mention that some do not think being an adjudicated rapist is a barrier to being elected) are in the United States.

There are a number of models to look at how segregation may appear with only mild preferences in the individuals in the system. One of these is Schelling Model which is an agent-based model where:

By default, the number of similar neighbours the agents need to be happy is set to 3 and they have up to 8 possible neighbours (Moore neighbourhood). That means the agents would be perfectly happy with a majority of their neighbours being of a different color (e.g. a Blue agent would be happy with five Red neighbours and three Blue ones). Despite this, the model consistently leads to a high degree of segregation, with most agents ending up with no neighbours of a different color.

Before we start implementing the model, you should seriously have a look at the blog entry created by Vi Hart and Nicky Case. Vi Hart produces amazing content and well worth following


Vi Hart and Nicky Case's Parable of the Polygons https://ncase.me/polygons

Finally, if you are interested in reading the original paper on this see Schelling, Thomas C. Dynamic Models of Segregation. Journal of Mathematical Sociology. 1971, Vol. 1, pp 143-186.

Implementation

The Schelling model traditionally talked about agents having different colours. I switched to state so my implementation is more similar to the Game of Life and Fire model. The Agent class has a state attribute that is either JETS or SHARKS. (I did now want to use RED and BLUE.)

There are two differences between this model and the previous models:

To move an agent to a random empty location we can use the following code:

1
    self.model.grid.move_to_empty(self)

to move an agent to a random location with at least one neighbour of the same state we could do something like the following:

1
2
3
4
loop over, say 5, attempts to place the agent
    move agent to a random empty location
    if has at least one neighbour of the same color
        break

Outline of core classes Agent and Model

Agent class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Agent(mesa.Agent):


    def __init__(self, model):
        super().__init__(model)


    def get_neighbors(self): ...


    def is_happy(self): ...


    def step(self): ...

The Model class

The Model class stores the model parameters, the model geometry (space/grid), and the agents in the model.

The basic structure of this class is

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Model(mesa.Model):


    def __init__(self, seed=None):
        super().__init__(seed=seed)

        # store model parameters

        # create the model space/grid

        # create agents in the model

        # setup data collector

        # init simulation


    def step(self):
        self.agents.shuffle_do("step")        

Implementing the Schelling Model

Agent class

The get_neighbors method:

The is_happy method:

The step method:

Model class

Store model parameters

The Model class has parameters width, height, density, and pr_same. The density parameter is the proportion of cells that are occupied by agents. The pr_minority is the size of the minority class, JETS, and the pr_same parameter is the proportion of neighbours that need to have the same state as the agent for the agent to be happy.

Create the model space/grid

The model space is a SingleGrid with toroidal boundary conditions. This the same as that used in the Game of Life.

Create agents in the model

Creating the agents is done using a loop similar to the fire model by iterating over the grid locations and create an agent (with probability density). When creating agent we set its stateto JETS based on pr_minority.

Init simulation

We will add stuff here later, when we add data collection facilities.

Visualisation

The visualisation and animation is practically the same as in the Fire Model, the only addition is the letter X for agents that are not happy.

Data Collector

The mesa library has a DataCollector class that can be used to collect data during the simulation. We will add a data collector to collect the number of happy agents at each step. This will allow us to plot the number of happy agents over time.

Adding running flag to Model class

1
2
3
4
5
6
    def step(self):
        if not self.running: return 

        self.agents.shuffle_do("step")

        self.running = sum([not a.is_happy() for a in self.agents])>0

Now, with running flag implemented we can run a simulation as follows

1
2
3
    model = Model(width=20, height=30, density=0.5, seed=667)
    while model.running:
        model.step()

Adding move counter variable to Model class

Instead of (or in addition to) counting the number of unhappy agents at each step, we can add a counter variable to the Model class that is incremented each time an agent is moves. This counter is reset at start of each Model.step call. So:

1
    self.running = (self.moved!=0)

Setting up the data collector

To use the DataCollector class, we create an instance of it in the Model.__init__ method. Where we list the information we want to collect. We can use the DataCollector to collect the number of steps, the number of agents that have moved, and the number of unhappy agents at each step.

1
2
3
4
5
6
7
    self.datacollector = mesa.DataCollector(
        model_reporters = {
            "steps": "steps",
            "moved": "moved",
            "unhappy": lambda m: sum(1 for a in m.agents if not a.is_happy()),
        },
    )

Then in Model.__init__ we add the following line to collect the data:

1
        self.datacollector.collect(self)

Finally, in the Model.step method we add the following line to collect the data at each step:

1
        self.datacollector.collect(self)

Getting data from the data collector

The DataCollector class collects the data in a pandas DataFrame. We can access the data using the get_model_vars_dataframe method. This method returns a DataFrame with the data collected.

1
2
    df = model.datacollector.get_model_vars_dataframe()
    df.head()
steps   moved   unhappy  
0 0 0 48
1 1 43 31
2 2 31 23
3 3 22 14
4 4 17 13

Plotting the data

We can plot the data using the matplotlib library. For example, we can plot the number of unhappy agents and the number moved over time using the following code:

1
2
3
4
5
plt.plot(df.steps, df.unhappy, label='unhappy')
plt.plot(df.steps, df.moved, label='moved')
plt.legend()
plt.title(f"Agents behaviour step (pop={len(model.agents)})")
plt.show()

This will produce a plot similar to the following:

Batch Running

The mesa library also has a BatchRunner class that can be used to run multiple simulations with different parameters. For example, we can use the BatchRunner to run the Schelling model with different values of the density parameter. So we could write

1
params = { "width": 10, "height": 10, "density": np.linspace(0.1,0.6,11) }

and then run the simulations using the BatchRunner class using:

1
2
3
4
5
6
7
8
results = mesa.batchrunner.batch_run(
        Model,
        parameters=params,
        iterations=50,
        max_steps=1000,
        display_progress=True,
    )
df = pd.DataFrame(results)

This code creates a DataFrame with the results of the simulations. The DataFrame has a row for each simulation and a column for each parameter and model variable that was collected.

1
df.head()
RunId   iteration   Step   width   height   density   steps   moved   unhappy
0 0 0 1 10 10 0.1 1 3 0
1 1 0 1 10 10 0.15 1 1 0
2 2 0 3 10 10 0.2 3 1 0
3 3 0 6 10 10 0.25 6 1 0
4 4 0 4 10 10 0.3 4 2 0

We could generate a boxplot of the number of steps in the simulation by density using the following code:

1
2
3
4
5
6
7
df.boxplot(column='steps', by='density')

positions = [1+p for p in range(df.density.nunique())]
labels = [f"{v:.2}" for v in  df.density.unique()]
plt.suptitle("Number of steps in simulation by density")
plt.xticks(positions, labels)
plt.show()

This will produce a plot similar to the following:

As the population density increases the number of steps in the simulation needed before agents are all happy increases.