Schelling Segregation Tutorial

Last updated on 22nd April 2024

Model overview

In this tutorial we will present an implementation of the classic Schelling model of segregation. This model illustrates how the interaction of people with "mild" in-group preference towards their own group can lead to highly segregated societies.

This model is embedded in a 2-dimensional grid forming the model environment. This model environment is inhabited by two types of agents (blue and red agents). Every agent occupies one cell in the 2-d grid. All agents have a preference for living in a neighborhood with agents of the same type, with a threshold parameter defining the level of similarity needed for an agent to be satisfied. If there are not enough similar agents (i.e. agents of the same colour) in a neighborhood, agents can decide to move to a cell where the neighborhood has more agents of similar type.

The model simulates the segregation of neighborhoods over time based on the agents' preferences for similarity.

The code for this tutorial is available as a zip download, containing a complete model as a Maven project. This project should be able to be run from any Maven or Java IDE environment.

Download Tutorial Files

Summary of the model implementation

In the Simudyne SDK implementation, the model has the following core components:

  • Schelling agents: blue and red agents
  • Environment: saving information about the 2-d grid environment (e.g. which cells are occupied) and communicating these information to the Schelling agents
  • Evaluation of neighborhoods with respect to similarity between agents
  • Agents' decision to move to a different cell if the neighborhood is not similar enough
  • Assignment of new cells for agents that decided to move

Globals

The following global variables are defined in the Schelling model.

SchellingModel.java

  public static final class Globals extends GlobalState {  
    @Input(name = "Grid Size")  
    public int gridSize = 40;  
    @Input(name = "Empty Cells Proportion")  
    public double emptyCellsPcg = 0.1;  
    @Input(name = "Similarity Threshold")  
    public double similarityThreshold = 0.4;  
    @Input(name = "Data Export Tick")  
    public int dataExportTick = 49;  
    public GridParameters gridParameters;}
  • The variable gridSize defines the length of the dimension of the grid environment. For example, a gridSize of 40 will result in a 40*40 environment, i.e. creating 1600 cells.
  • The variable emptyCellsPcg defines the percentage of empty cells overall. For instance, a emptyCellsPcg of 0.1 will result in a 10% empty cells.
  • The variable similarityThreshold defines the threshold of percentage of similar agents needed in a neighborhood to satisfy the agents. If the similarity in a neighborhood drops below this threshold the agent will decide to move neighborhoods. For example, if the similarity threshold is 0.4 and an agents neighborhood has 3 agents of the same color and 5 agents of the opposite type (i.e. 37.5% similarity) the agent will decide to move.
  • The variable dataExportTick defines the tick on which the data should be exported. Please ensure here that this tick is the last tick of the iteration.
  • The variable gridParameters initializes an object of class GridParameters, which defines the 2-d grid.

Setup

In the setup, the environment, all agents and all links are initialized to setup the initial state of the model. Creating the initial agents is done through the concept of a topology of groups. A group represents a set of agents, where each group has a type of agent, a number of agents in that group, and optionally an initialiser, which can customise the state for each agent.

In this model, we have a group of Schelling agents and an environment.

SchellingModel.java

     Group<BlueAgent> blueAgentGroup = generateGroup(BlueAgent.class, getGlobals().gridParameters.nbBlue,  
         blueAgent -> blueAgent.similarityThreshold = getGlobals().similarityThreshold);  
        Group<RedAgent> redAgentGroup = generateGroup(RedAgent.class, getGlobals().gridParameters.nbRed,  
         redAgent -> redAgent.similarityThreshold = getGlobals().similarityThreshold);

The Schelling agents are further divided into a group of blue and red agents, whereby both groups have the same number of agents which is defined by the number of cells minus the percentage of empty cells. On initialization, each agents similarityThreshold is equated to the variable similarityThreshold defined in Globals.

SchellingModel.java

getGlobals().gridParameters = new GridParameters(getGlobals().gridSize, getGlobals().emptyCellsPcg);  
Group<Environment> environmentGroup = generateGroup(Environment.class, 1, Environment::initEnvironment);  

In addition to the agent groups, there is also the environment which saves the information about the 2-d grid, the type of agent occupying a cell, the similarity of neighborhood and a list of empty cells. the The environment can provide the Schelling agents with information about their surrounding and point towards empty cells in case an agent decides to move.

SchellingModel.java

environmentGroup.fullyConnected(blueAgentGroup, Links.EnvironmentToSchellingLink.class);  
environmentGroup.fullyConnected(redAgentGroup, Links.EnvironmentToSchellingLink.class);  
blueAgentGroup.fullyConnected(environmentGroup, Links.SchellingToEnvironmentLink.class);  
redAgentGroup.fullyConnected(environmentGroup, Links.SchellingToEnvironmentLink.class);

To enable the interaction between agents and environment, all agents display a connection to the environment and conversely the environment has a connection will all agents.

Step

The step takes care of the logic that happens between each tick, in an ABM this is mostly concerned with running behavioural sequences over the system to move the system forwards through time.

In this model, the main components of each step are evaluating an agents environment and moving cells in case an agent is unhappy with its neighborhood.

The line run(Environment.updateAgentStates(), SchellingAgent.updateState()); runs an evaluation of the agents environment based on movement that occurred in the previous step. After this process, each agent has an updated measure of the percentage of similar agents in its neighborhood.

After the update, each agent will evaluate whether its neighborhood is similar enough, and if not move to a different cell. This evaluation is conducted in the SchellingAgent.step() function. If an agent decides to move, the movement to a new empty cell is coordinated by the Environment.moveAgents() function.

Moreover, on the first step, the function run(SchellingAgent.registerToEnvironment(), Environment.receiveRegistrations()) is executed in order to assign an initial location for each agent in the 2-d grid.

Each of these behaviors will be explained in more detail in the following section.

Behaviors

We'll run through the actions in this sequence in order, to see how they progress. When we see each agent for the first time, we'll also have a quick look at how it's defined.

SchellingAgent#registerToEnvironment

SchellingAgent.java

   public abstract class SchellingAgent extends Agent<SchellingModel.Globals> {  
        public final AgentState.AgentRace race;  
        public double similarityThreshold;  
        public AgentState state;
        public SchellingAgent(AgentState.AgentRace race) {  
            this.race = race;  
  }  

Each Schelling Agent has a race which describes whether the agent is of type blue or red. Moreover, the Schelling agents have a similarityThreshold which is the same for all Schelling agents and is defined in the Globals. Moreover, each agent has a state which describes whether this agent is happy or unhappy in its environment (i.e. whether the agents wants to move or not), its current location and the similarity of its neighborhood.

SchellingAgent.java

     public static Action<SchellingAgent> registerToEnvironment() {
    	 return Action.create(SchellingAgent.class, schellingAgent -> {  
	         schellingAgent.state = new AgentState(schellingAgent.race);  
			 AgentState state = new AgentState(schellingAgent.race);
			 schellingAgent.getLinks(Links.SchellingToEnvironmentLink.class)
			  .send(Messages.StateMessage.class,  
	            (msg, link) -> msg.state = state);  
         });  
      }

On the first tick of each simulation, each Schelling agent registers to the environment by sending a StateMessage to the environment.

Messages.java

   public static class StateMessage extends Message {  
        public AgentState state;}

Such StateMessage entails a state object which encorporates all relevant information about the agent, such as whether the agent is happy or not and its type.

Environment#receiveRegistrations

The environment stores all information about the 2-d grid and the agents such as their position and state.

Environment.java

public class Environmenenter link description heret extends Agent<SchellingModel.Globals> {  
     public HashMap<Long, AgentState> agentMap;  
     public Random random;  
     public Grid grid;  
     public DataOutput dataOutput;  
     @Variable  
     public double averageSimilarity;  
     @Variable  
     public int unhappyAgentCount;

In the field agentMap the environment saves a hashmap with each agents identifier as key and their state as value. Moreover, the environment saves the grid and overall statistics such as averageSimilarity and unhappyAgentCount which are displayed as outputs in the console.

public static Action<Environment> receiveRegistrations() {  
	return Action.create(Environment.class, environment ->  
        environment.getMessagesOfType(Messages.StateMessage.class).forEach(stateMessage ->  
                environment.agentMap.put(stateMessage.getSender(), stateMessage.state)));}

When the agents register to the environment, each agent is added to the agentMap saving their identifier and their state.

Environment#initPositions

During the first step, the environment also assigns an initial location to each agent to create the starting conditions of the 2-d grid.

Environment.java

public static Action<Environment> initPositions() {  
    return Action.create(Environment.class, environment -> environment.agentMap.forEach((agentID, 				 agentState) -> {  
        Optional<Cell> optionalCell = environment.grid.cellList.stream()  
                .filter(x -> x.sameAgentState(agentState.race) && !x.occupied).findFirst();  
		if (!optionalCell.isPresent())  
            throw new IllegalArgumentException("No position found.");  
  
  Cell cell = optionalCell.get();  
  cell.switchState(agentState.race);  
  agentState.position = cell;  
  }));  
}

Here, the environment searches for empty cells that are also in line with the agents preferences (i.e. enough similar agents in the surrounding neighborhood) and if a cell exists that satisfies these constraints the agent is assigned to this cell.

Environment#updateAgentStates

After the initialization, the first action in each tick is to update the state associated with each agent.

Environment.java

public static Action<Environment> updateAgentStates() {  
    return Action.create(Environment.class, environment -> {  
        environment.averageSimilarity = 0;  
	    environment.agentMap.forEach((agentID, agentState) -> {  
            double similarityMetric = environment.calculateSimilarityMetric(agentState.position.coordinates, agentState.race);  
		    agentState.changeSimilarityMetric(similarityMetric);  
		    environment.averageSimilarity += similarityMetric;  
		    environment.getLinksTo(agentID).send(Messages.StateMessage.class, (msg, link) ->  
                    msg.state = agentState);  });  
	   environment.averageSimilarity = environment.averageSimilarity / environment.agentMap.size();  
  });  
}

For each agent, the similarity of agents in the neighborhood is calculated and each agent is informed about the new similarity of its surrounding by the environment. This is done by sending a StateMessage entailing an altered state object which incorporates the new similarityMetric for the agent's cell.

SchellingAgent#updateState

SchellingAgent.java

 public static Action<SchellingAgent> updateState() {  
        return Action.create(SchellingAgent.class, schellingAgent ->  
              schellingAgent.state.update(schellingAgent.getMessageOfType(Messages.StateMessage.class).state));  
    }

Based on the newly calculated similarity of each agents' neighborhood the agents' update their states to incorporate this new information about their environment.

SchellingAgent#step

SchellingAgent.java

 public static Action<SchellingAgent> step() {  
            return Action.create(SchellingAgent.class, schellingAgent -> {  
                schellingAgent.state.changeSatisfaction(schellingAgent.state.similarityMetric < schellingAgent.similarityThreshold ? AgentState.Satisfaction.UNHAPPY : AgentState.Satisfaction.HAPPY);  
 if (schellingAgent.state.satisfaction == AgentState.Satisfaction.UNHAPPY) {  
                    schellingAgent.getLinks(Links.SchellingToEnvironmentLink.class).send(Messages.UnhappyMessage.class);  
				    }  
				});   
			}

After updating the similarity of its neighborhood, each agent needs to decide whether to move or not. For this, the agent compares its neighborhood similarity to its similarity threshold and decides whether it is still happy in the current cell. If the agent is unhappy, it will send a message to the environment indicating that it would like to move.

Messages.java

   public static class UnhappyMessage extends Message.Empty {}

The UnhappyMessage is an empty message, as no additional information is needed besides the agent's wish to move cells.

Environment#moveAgents

Environment.java

public static Action<Environment> moveAgents() {  
    return Action.create(Environment.class, environment -> {  
        ArrayList<Long> unhappyAgents = new ArrayList<>();  
	    environment.getMessagesOfType(Messages.UnhappyMessage.class).forEach(msg -> unhappyAgents.add(msg.getSender()));  
	    Collections.sort(unhappyAgents);  
	    Collections.shuffle(unhappyAgents, environment.random);  
	    environment.unhappyAgentCount = unhappyAgents.size();  
        unhappyAgents.forEach(agentID -> {  
                    AgentState.AgentRace race = environment.agentMap.get(agentID).race;  
				    Optional<Cell> optionalCell = environment.grid.cellList.stream().filter(x ->  
                            !x.occupied && environment.calculateSimilarityMetric(x.coordinates, race)>= environment.getGlobals().similarityThreshold).findAny();  
				    if (!optionalCell.isPresent())  
                        return;  
				    Cell oldCell = environment.agentMap.get(agentID).position;  
				    oldCell.vacate();  
 				    Cell newCell = optionalCell.get();  
				    newCell.switchState(race);  
				    environment.agentMap.get(agentID).changePosition(newCell);  
			    }  
        );  
  });  
}

The environment will receive messages from all agents that wish to move their cell. Based on this list of agents, the environment aims to find an appropriate cell available for each agent. This means the environment goes through the list of empty cells and evaluates whether this empty cell is in line with the similarity requirements of the agent. If a suitable cell is available, the environment indicates this to the agent and the agent moves to the new location.

Environment#writeData

At the end of each tick, the configuration of the 2-d grid is saved to enable exporting data for additional analysis outside the SDK.

   public static Action<Environment> writeData() {  
        return Action.create(Environment.class, environment -> {  
            environment.dataOutput.addDataValue(environment.getContext().getTick(), new DataValue(environment.grid.cells));  
      });  
    }

Environment#exportJSONOutput

At the end of the simulation (i.e. on the last tick), the data is exported as JSON file.

Environment.java

  public static Action<Environment> exportJSONOutput() {  
        return Action.create(Environment.class, environment -> {  
            try {  
                PrintWriter pw = new PrintWriter("Grid_History.json");  
			    pw.write(environment.dataOutput.gridHistory.toString());  
		      pw.flush();  
		      pw.close();  
		      } catch (FileNotFoundException e) {  
                e.printStackTrace();  
		      }  
        });  
    }

The data export enables to further analyze the simulation data in any data science tool of choice.

Analyzing data output in Jupyter Notebook

After having exported the simulation data as JSON file, this provides us with a great opportunity to exemplify how to conduct more detailed analysis of the simulation by pulling the data into an external analysis tool. In this example, we will use a Jupyter Notebook to analysis and visualize this data.

The JSON file contains information about the 2-d grid at every tick. In the Jupyter Notebook we load in the JSON file and animate the change in the position of agents over time.

    import numpy as np
    import pprint
    import matplotlib.pyplot as plt
    import matplotlib
    from IPython.display import HTML
    import matplotlib.patches as mpatches
    import matplotlib.animation as animation
   
    with open('GridHistory.json') as f:
    	data = json.load(f)
    time_steps = [int(step) for step in data.keys()]
    grid_idx = [int(idx) for idx in data[str(time_steps[0])].keys()]
    grid_history = np.array([[[0 for k in range(len(grid_idx))] for j in range(len(grid_idx))] for i in range(len(time_steps))]) 
    
    for t in time_steps:
        for i in grid_idx:
            for j in grid_idx:
                grid_history[t, i, j] = int(data[str(t)][str(i)][str(j)])
    
    im1=grid_history.shape
    values = np.unique(grid_history.ravel())
    
    fig=plt.figure(figsize=(8, 8))
    
    cmap = matplotlib.colors.ListedColormap(['royalblue', 'lightgrey', 'red'])
    schelling =plt.imshow(grid_history[0,:,:], cmap=cmap, interpolation='none')
    colors = [schelling.cmap(schelling.norm(value)) for value in values]
    patches = [mpatches.Patch(color=colors[0], label="blue agents"), mpatches.Patch(color=colors[1], label="empty cells") , mpatches.Patch(color=colors[2], label="red agents")]
    plt.legend(handles=patches, bbox_to_anchor=(0.8, 0),
              ncol=3, fancybox=True, shadow=True)
    plt.grid(True)
    plt.axis('off')
    
    def animate_noise(i):
        schelling.set_array(grid_history[i,:,:])
        return [schelling]
    anim = animation.FuncAnimation(fig, animate_noise, frames=dim1[0], interval=500, blit=True)
    plt.close(fig)
    
    HTML(anim.to_html5_video())

Summary

In this tutorial we have walked through an implementation of the classic Schelling model. This is an example of how a geospatial component (2-d grid) can be incorporated within the SDK. In this implementation, information about the 2-d grid is saved in the environment which communicates information about this grid to the Schelling agents. Based on this information, the Schelling agents decide whether to move cells or remain in their current cell. Through multiple iterations the model will show how people segregate based on their preferences for similarity. enter link description here