Agent-based Simulation, part 2 modeling with Mesa


Introduction

Mesa is an Agent-based Modeling module in Python and an alternative to NetLogo or other applications for agent-based modeling Agent-based modeling has been extensively used in domains such as social, biology, and network sciences. As mentioned in the previous post agent-based modeling relies on simulating autonomous agents and their interactions with and within the environment to evaluate emergent phenomena and projections within complex system. Therefore, the aim of ABM is to obtain explanatory insight on how the agents will behave given a particular set of rules. This article covers the first steps to start an agent-based modeling project using an open-source python module called Mesa.

Schelling Segregation Model

This tutorial first will focus the Schelling Segregation model, a use-case of agent-based modeling to explain why racial segregation issue is difficult to be eradicated. Although the actual model is quite simple, it provides explanatory insights at how individuals might self-segregate even though when they have no explicit desire to do so. Let’s have a look at the explanation for this model provided by the Mesa official github page:

The Schelling segregation model is a classic agent-based model, demonstrating how even a mild preference for similar neighbors can lead to a much higher degree of segregation than we would intuitively expect. The model consists of agents on a square grid, where each grid cell can contain at most one agent. Agents come in two colors: red and blue. They are happy if a certain number of their eight possible neighbors are of the same color, and unhappy otherwise. Unhappy agents will pick a random empty cell to move to each step, until they are happy. The model keeps running until there are no unhappy agents. By default, the number of similar neighbors the agents need to be happy is set to 3. That means the agents would be perfectly happy with a majority of their neighbors being of a different color (e.g. a Blue agent would be happy with five Red neighbors and three Blue ones). Despite this, the model consistently leads to a high degree of segregation, with most agents ending up with no neighbors of a different color.

The code for this model is simple we will go through each part one-by-one to have a clearer understanding of how agent-based modeling works. The procedure includes steps such as Initializing the agent class, creating a step function, extracting the number of similar neighbors and moving the agent to an empty location if the agent is unhappy.

First we upload the necessary modules

from mesa import Model, Agent
from mesa.time import RandomActivation
from mesa.space import SingleGrid
from mesa.datacollection import DataCollector
import matplotlib.pyplot as plt
%matplotlib inline

The system will consists of at least a basic agent class and a model class.

Let’s start by writing the agent class first. We will need to define 2 main variables of position and agent type and a dependency variable to model to get access to the variables specified in the model.

class SchellingAgent(Agent):
    """
    Schelling segregation agent
    """

    def __init__(self, pos, model, agent_type):
        """
        Create a new Schelling agent.
        Args:
           unique_id: Unique identifier for the agent.
           x, y: Agent initial location.
           agent_type: Indicator for the agent's type (minority=1, majority=0)
        """
        super().__init__(pos, model)
        self.pos = pos
        self.type = agent_type

    def step(self):
        similar = 0
        for neighbor in self.model.grid.neighbor_iter(self.pos):
            if neighbor.type == self.type:
                similar += 1

        # If unhappy, move:
        if similar < self.model.homophily:
            self.model.grid.move_to_empty(self)
        else:
            self.model.happy += 1

Then we develop model class, here we will need to define 5 main variables:

Width: Horizontal axis of the grid which is used together with Height to define the total number of agents in the system.

Height: Vertical axis of the grid which is used together with Width to define the total number of agents in the system.

Density: Define the population density of agent in the system. Floating value from 0 to 1.

Fraction minority: The ratio between blue and red. Blue is represented as the minority while red is represented as the majority. Floating value from 0 to 1. If the value is higher than 0.5, blue will become the majority instead.

Homophily: Define the number of similar neighbors required for the agents to be happy. Integer value range from 0 to 8 since you can only be surrounded by 8 neighbors.

class Schelling(Model):
    """
    Model class for the Schelling segregation model.
    """

    def __init__(self, height=20, width=20, density=0.8, minority_pc=0.2, homophily=3):
        """ """

        self.height = height
        self.width = width
        self.density = density
        self.minority_pc = minority_pc
        self.homophily = homophily

        self.schedule = RandomActivation(self)
        self.grid = SingleGrid(width, height, torus=True)

        self.happy = 0
        self.datacollector = DataCollector(
            {"happy": "happy"},  # Model-level count of happy agents
            # For testing purposes, agent's individual x and y
            {"x": lambda a: a.pos[0], "y": lambda a: a.pos[1], "type": lambda a: a.type , "happ": lambda a: a.model.happy},
        )

        # Set up agents
        # We use a grid iterator that returns
        # the coordinates of a cell as well as
        # its contents. (coord_iter)
        for cell in self.grid.coord_iter():
            x = cell[1]
            y = cell[2]
            if self.random.random() < self.density:
                if self.random.random() < self.minority_pc:
                    agent_type = 1
                else:
                    agent_type = 0

                agent = SchellingAgent((x, y), self, agent_type)
                self.grid.position_agent(agent, (x, y))
                self.schedule.add(agent)

        self.running = True
        self.datacollector.collect(self)

    def step(self):
        """
        Run one step of the model. If All agents are happy, halt the model.
        """
        self.happy = 0  # Reset counter of happy agents
        self.schedule.step()
        # collect data
        self.datacollector.collect(self)

        if self.happy == self.schedule.get_agent_count():
            self.running = False

Thereafter we will need to set the model specification, this can be only for one model or a batch of models.

model = Schelling(10, 10, 0.8, 0.2, 3)

Scheduler: Next up, we will need to have a scheduler. The scheduler is a special model component which controls the order in which agents are activated. The most common scheduler is the random activation which activates all the agents once per step, in random order. There is also another type called Simultaneous activation. Check out the API reference to find out more.

while model.running and model.schedule.steps < 100:
    model.step()
print(model.schedule.steps)  # Show how many steps have actually run

Data Collection: Data collection is essential to ensure that we obtained the necessary data after each step of the simulation. You can use the built-in data collection module. In this case, we only need to know whether the agent is happy or not.

model_out = model.datacollector.get_model_vars_dataframe()
model_out.head()

This option is also available for agents.

agent_df = model.datacollector.get_agent_vars_dataframe()
agent_df.head()

Infection model

Mesa Infection model provides the basic building blocks for an Agent Based free movement, contagion, and epidemic model. Three version of this model are up on my github and hope is others will improve upon it aid in understanding and decision making for any kind of movement/connection/contagion. For more advanced COVID related module you can also see Mesa SIR module.

Here we present the most simple model including free movement and interaction, as above first we load required packages

%matplotlib notebook
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from mesa import Agent, Model
from mesa.time import RandomActivation
from mesa.space import ContinuousSpace
import mesa
from mesa.datacollection import DataCollector
import numpy as np
from statistics import mean 
import pandas as pd
import collections

This is function to be used by the agents.

def flatten(x):
    if isinstance(x, collections.Iterable):
        return [a for i in x for a in flatten(i)]
    else:
        return [x]

Again we start by writing the agent class first. We will need to define main variables including name, position, and neighbors at each step as well as a dependency variable to model to get access to the variables specified in the model.

class MyAgent(Agent):
    
    def __init__(self, name, model,pos,dest=None, choi= None):
        super().__init__(name, model)
        self.name = name
        self.pos= pos
        self.neigh=None
        self.neigh_names=None
        self.heading=None
        self.dest=dest
        self.choices = choi
        self.posl1=None
        
    def neigh_namer(self, neigh):
        me = self.name
        them = (n.name for n in neigh)
        Names = []
        for other in them:
            Names.append(other)
        return Names  
    def dest_adjuster(self):
        m1 = self.heading
        if m1==(0,0):
            kk=np.array([[10,90],[55,55],[90,10],[10,10]])
            jj=kk[np.random.choice(len(kk),1)]
            m2=np.array((jj[0][0],jj[0][1]))
        else:
            m2=self.dest
        return m2

    def step(self):
        neigh = self.model.space.get_neighbors(self.pos, 3, False)
        self.neigh=neigh
        self.neigh_names=self.neigh_namer(neigh)
        self.heading=self.model.space.get_heading(self.pos, self.dest)
        self.posl1=self.pos
        self.dest=self.dest_adjuster()
        new_step =[round(x/y) for x, y in zip([ x for x in self.heading], [ abs(x)+0.000001 for x in self.heading])]
        new_pos = (self.pos[0]+new_step[0],self.pos[1]+new_step[1])
        print("{} activated".format(self.name))
        self.model.space.move_agent(self, new_pos)

Then in the model class we specify the grid size, agent headings, and he information to be collected in data collectors.

class MyModel(Model):
    
    def __init__(self, n_agents,xylim=200, prob=0.05,):
        super().__init__()
        self.schedule = RandomActivation(self)
        self.space = ContinuousSpace(xylim, xylim, torus=False)
        for i in range(n_agents):
            pos = (self.random.randrange(0, 100), self.random.randrange(0, 100))
            choices=np.array([[90,90],[55,55],[90,10],[10,10]])
            choi=choices[np.random.choice(len(choices),1)]
            dest=np.array((choi[0][0],choi[0][1]))
            a = MyAgent(i, self,pos, dest,choi)
            self.schedule.add(a)
            self.space.place_agent(a, pos)
            self.dest=dest
            self.choices=choi
            self.dc = DataCollector(model_reporters={"agent_count":
                                    lambda m: m.schedule.get_agent_count()},
                                #agent_reporters={"neigh": lambda a: [a.neigh, a.name] }
                                agent_reporters={"neigh": lambda a: a.neigh_names,
                                                "name": lambda a: a.name,
                                                "pos": lambda a: a.pos,
                                                "posl1": lambda a: a.posl1,
                                                "heading": lambda a: a.heading,
                                                "choice": lambda a: a.choices}
                                   )

    def step(self):
        self.schedule.step()
        self.dc.collect(self)

Here we specify the number of agents and steps.

nagent=30
nstep=200

Here instead of using scheduler with random activation which activates all the agents once per step, in random order we just call the step function in a loop.

model = MyModel(nagent)
for t in range(nstep):
    model.step()
model_df = model.dc.get_model_vars_dataframe()
agent_df = model.dc.get_agent_vars_dataframe()


model_df

Similar to above you can use the built-in data collection module to get access to model level or agent level data.

model_df = model.dc.get_model_vars_dataframe()
agent_df = model.dc.get_agent_vars_dataframe()


model_df

References

The Forest Fire Model

Mesa Packages

Mesa SIR

ABM Infection model with Mesa