28. Basic Agent-Based Modeling

Section author: Almond Heil <almondheil@gmail.com>

28.1. Motivation, Prerequisites, and Plan

In this chapter, we will learn the basics of agent-based modeling by creating and customizing a simple model using the Mesa framework. While following along, you should keep in mind that the Mesa framework is one of many ways to approach agent-based modeling in Python.

An agent-based model takes a bottom-up approach to solving a problem, by considering the smallest indiviual members (or agents) that make up a larger system and examining how they interact with each other.

Before you start, make sure you fulfill the following prerequisites.

  • The 10-hour “serious programming” course

  • Installing the required packages with pip

    $ pip3 install --user --upgrade mesa
    

Now that you’re ready, here’s what we’ll be doing in today’s course!

  1. Learn about the basics of agent-based modeling and object-oriented programming

  2. Create the classes for a simple infection model

  3. Place agents in space and move them

  4. Create a visualization of the model

  5. Manage infection spread among agents

  6. Gather and plot data

28.2. Conceptualizing the model

When building an agent-based model, it’s important to consider the basic building blocks that will make up our model. Based on the broad idea of how diseases spread, we will narrow our focus to how diseases spread by direct contact.

To understand our model as it develops, it’s important to understand a few terms, both from agent-based modeling and object-oriented programming. Some short definitions appear below.

28.2.1. Agent-Based Modeling Concepts

model

An abstraction of reality seeking to distill the behaviors of a complex system so we can understand it more easily.

In Mesa, the model is specifically the structure that manages setting up and running your program, including the agents inside of it.

agent

One of the entities within an agent-based model (wow, what a circular definition!) It can interact with other agents and the world in various ways.

step

A single unit of time in the model. Can also refer to a method an agent follows every time unit.

28.2.2. Object-Oriented Programming Concepts

class

An object-oriented programming concept which refers to a structure containing the framework for making objects–what they can do, what data they hold, etc.

object

One instance of a class, which inherets the structure that’s been set up for it. We’ll take advantage of this to create many agent objects based on a single class.

method

A function that belongs to a class, and can be called by any object based on that class. For instance, we might expect each agent to be able to move around with an agent.move() method.

28.3. Classes and steps

To implement these concepts, we’ll create a model. Create a file called direct_contact.py and enter this code.

Listing 28.3.1 direct_contact.py
#!/usr/bin/python3

from mesa import Agent, Model
from mesa.time import RandomActivation

class InfectionAgent(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)
        self.infected = False
    
    def step(self):
        print(f"agent {self.unique_id}; infected {self.infected}")

        
class InfectionModel(Model):
    def __init__(self, N):
        self.num_agents = N
        self.schedule = RandomActivation(self)
        
        for i in range(self.num_agents):
            a = InfectionAgent(i, self)
            self.schedule.add(a)
    def step(self):
        self.schedule.step()

Above, we define two classes. InfectionAgent is based on Mesa’s agent class, and we define how it acts when it initializes and when it steps. By using super().__init__(unique_id, model) in the InfectionAgent’s intialization code, we tell it to take arguments for those two variables from whatever created it. In this case, that means the model that the user defines. We also set the agent’s infected status to false, which we’ll edit down the line to start an infection.

InfectionModel is based on Mesa’s model class. When it initializes, it creates a schedule to run the model with and adds agents to it, passing them the unique_id and model parameters.

Now, it’s time to see the code in action! In your terminal, open a live Python session by typing python3 and enter the following.

>>> from direct_contact import *
>>> model = InfectionModel(10)
>>> model.step()

You will see the program output something like this.

agent: 1 infection: False
agent: 5 infection: False
agent: 9 infection: False
agent: 8 infection: False
agent: 4 infection: False
agent: 3 infection: False
agent: 7 infection: False
agent: 2 infection: False
agent: 6 infection: False
agent: 0 infection: False

If you repeat model.step(), you will notice that the order the agents call out is different each time. This is because of the RandomActivation we added which tells the model how to progress when it takes a step.

Exercise 28.1: Scheduling methods

To see the difference the scheduling method makes, switch out the random activation we added in favor of BaseScheduler or another activation method from the Mesa time module. Notice how this affects the order in which agents act when model.step() is called. If you do this, you will also need to import BaseScheduler instead of RandomActivation at the top of the program.

When you want to test changes you’ve made to your model, make sure to exit your python interpreter with Control+D or by typing exit(). Then, start a new session and import the code again to see your most recent changes take effect.

28.4. Space and movement

Now that each agent is able to take steps, we will add space and movement to our model. For this example we will be using a grid for simplicity, as well as Mesa’s built-in support for grid visualization.

First, we import the necessary components to our project. In this case we want to use a MultiGrid, so that multiple agents can be on top of each other in the same grid cell.

Listing 28.4.1 direct_contact.py
from mesa.space import MultiGrid

Then, we can edit out model’s __init__ method to let it take width and height parameters. We also change our method of placing agents to give them random positions using the model’s random number generator. This generator functions just like the normal Python random module, but it allows us to easily set seeds if we want to reproduce our results down the line.

Listing 28.4.2 direct_contact.py > InfectionModel
class InfectionModel(Model):
    def __init__(self, N, width, height):
        self.num_agents = N
        self.schedule = RandomActivation(self)
        self.grid = MultiGrid(width, height, torus=True)

        for i in range(self.num_agents):
            a = InfectionAgent(i, self)
            x = self.random.randrange(self.grid.width)
            y = self.random.randrange(self.grid.height)
            self.grid.place_agent(a, (x, y))
            self.schedule.add(a)

The third argument of MultiGrid represents whether the space is toroidal, meaning that agents who walk of one edge of the grid will reappear on the other side. This emulates an infinite space, and helps us avoid issues with agents hiding in corners or being trapped and unable to move.

To add movement, we need to change what happens when an agent takes a step. First, add the move method, which tells the agent to move to a random cell near itself:

Listing 28.4.3 direct_conract.py > InfectionAgent
def move(self):
    x, y = self.pos
    x_offset = self.random.randint(-1, 1)
    y_offset = self.random.randint(-1, 1)
    new_position = (x + x_offset, y + y_offset)
    self.model.grid.move_agent(self, new_position)

Now we’ve defined how the agent moves, but we don’t tell it to do that when step() gets called. Go ahead and add the instruction to move, and also update the print statement to tell us the agent’s position–right now nothing’s going to show up onscreen.

Listing 28.4.4 direct_contact.py > InfectionAgent
def step(self):
    self.move()
    print(f"agent {self.unique_id}; pos {self.pos}; infected {self.infected}")

Now, try running the code again–with one difference. Now that the model takes parameters for its width and height, we need to provide those when we create it like so.

>>> from direct_contact import *
>>> model = InfectionModel(10, 30, 20)
>>> model.step()

If you’re having any issues, go ahead and check your work so far against this example. You can do so using the diff tool in the command line. Of course, I’m not going to stop you from copy-pasting this working example, but c’mon.

Listing 28.4.5 direct_contact.py
#!/usr/bin/python3

from mesa import Agent, Model
from mesa.time import RandomActivation
from mesa.space import MultiGrid

class InfectionAgent(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)
        self.infected = False

    def move(self):
        x, y = self.pos
        x_offset = self.random.randint(-1, 1)
        y_offset = self.random.randint(-1, 1)
        new_position = (x + x_offset, y + y_offset)
        self.model.grid.move_agent(self, new_position)

    def step(self):
        self.move()
        print(f"agent {self.unique_id}; pos {self.pos}; infected {self.infected}")
        
class InfectionModel(Model):
    def __init__(self, N, width, height):
        self.num_agents = N
        self.schedule = RandomActivation(self)
        self.grid = MultiGrid(width, height, torus=True)
        
        for i in range(self.num_agents):
            a = InfectionAgent(i, self)
            x = self.random.randrange(self.grid.width)
            y = self.random.randrange(self.grid.height)
            self.grid.place_agent(a, (x, y))
            self.schedule.add(a)

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

28.5. Visualization

Now we are able to move the agents, but we have no idea of where they are going. Of course, you could add the print statement back in, but with the constant movement and random schedule order it becomes difficult to keep track of what’s going on. To make this easier, we want to add visualization.

First, we need to add one instruction to our main direct_contact.py file. In the init method for InfectionModel, add the line self.running = True. It should now match the code below.

Listing 28.5.1 direct_contact.py > InfectionModel
def __init__(self, N, width, height):
     self.num_agents = N
     self.schedule = RandomActivation(self)
     self.grid = MultiGrid(width, height, torus=True)
     self.running = True

Now, we need to create a visualizer which will display our model and let us interact with it. In this case, we’ll be using Mesa’s built-in visualization tools because they’re accessible and work well for our purposes. Create a new file called visualization.py and add the following to it.

Listing 28.5.2 visualization.py
#!/usr/bin/python3

from mesa.visualization.modules import CanvasGrid
from mesa.visualization.ModularVisualization import ModularServer

# change this to match your file name if it's not direct_contact.py!
from direct_contact import *

# The parameters we run the model with.
# Feel free to change these!
params = {"N": 30,
          "width": 50,
          "height": 40}

def agent_portrayal(agent):
    portrayal = {"Shape": "circle",
                 "Color": "grey",
                 "Filled": "true",
                 "Layer": 0,
                 "r": 0.75}
    if agent.infected:
        portrayal["Color"] = "LimeGreen"
        portrayal["Layer"] = 1
    return portrayal

grid = CanvasGrid(agent_portrayal,
                  params["width"],
                  params["height"],
                  20 * params["width"],
                  20 * params["height"])

server = ModularServer(InfectionModel,
                       [grid],
                       "Infection Model",
                       params)
server.launch()

There’s a lot to unpack in this block of code, because a lot is going on under the hood with Mesa’s modules. First, we create a dictionary called params. It holds the names and values for each parameter the model takes in its __init__. Under the hood, Mesa is unpacking this dictionary to use the values as keyword arguments or kwargs, which are used in "name": value pairs to initialize the model.

Next, we define the function agent_portrayal. It takes an individual agent from the model as input, and outputs the necessary information to draw the agents. Mesa takes care of its visualization with a web browser window, which handles graphics and user interaction with JavaScript.

Luckily, we don’t have to deal with the JavaScript side of the equation because Mesa’s CanvasGrid module takes care of it—all you will notice is a new tab in your browser pop up. We only need to pass it the portrayal method to use, the dimensions of the grid, and the pixel size of the grid to be displayed.

Finally, we define the server. It unites several data structures we’ve already created. The first term is the model to use. The second holds a list of the display methods to use (such as the grid we just defined). The third is simply the title to display, and the fourth is the parameters to run the model with.

See also

If you’re interested in how this system works or want to write your own module you can learn more about it in the Mesa documentation.

The python module CanvasGridVisualization.py feeds our data into the JavaScript module CanvasModule.js, which draws everything in the web server.

Finally, the python module ModularVisualization.py creates a webserver and passes the relevant config data to your model through it.

With all this done, we can run the model with a single command from the terminal!

$ python3 visualization.py

Once the server has started it will open a browser window and you can click the “Start” button in the top right to run your model, or the “Step” button move forward incrementaly.

With the server running, you will see a display like this. At this point, you’ll only see the agents wandering around, but we’ll have them spread infection to each other in the next step.

Grid with several grey agents populating it.

Note

The server won’t automatically quit when you click “Stop” or close the browser tab that is displaying it. To stop the model fully, you have to go to the terminal running the model and press Control+C.

28.6. Interactions between agents

First, let’s add a method for the InfectionAgent class that allows agents to infect each other. In this method, we use Mesa’s built-in get_neighbors method to collect a list of all the agents next to a given point. The parameters “moore” and “include_center” specify what counts as a neighboring space. Moore means that diagonal spaces are included, and include_center counts the space that an agent is on.

Listing 28.6.1 direct_contact.py > InfectionAgent
def infect_neighbors(self):
    neighbors = self.model.grid.get_neighbors(self.pos,
                                              moore=True,
                                              include_center=True)
    for neighbor in neighbors:
        if self.random.random() < 0.25:
            neighbor.infected = True

Next, we’ll edit the agent step method to infect any neighbors only if it is infected.

Listing 28.6.2 direct_contact.py > InfectionAgent
def step(self):
    self.move()
    if self.infected:
        self.infect_neighbors()
    print(f"agent {self.unique_id}; pos {self.pos}; infected {self.infected}")
Exercise 28.1: Routes of infection

As you’ll notice, we take an extremely simple aproach to infection: For each of our direct neighbors, we have a 25% chance of infecting them.

What other ways might this disease spread (for instance, only by touch when we stood on the same grid cell as another person)? How might you change the code to reflect these differences?

Finally, we need to add a way for agents to start off infected. Right now we set all agents to be uninfected when initializing the model no matter what, but this means an infection can never start in the model.

First, we’ll change the agent’s initialization method. Originally we automatically set the agent’s infection to False, but now we will take input on whether or not the agent is infected.

Listing 28.6.3 direct_contact.py > InfectionAgent
def __init__(self, unique_id, model, infected):
    super().__init__(unique_id, model)
    self.infected = infected

We’ll decide whether the agent is infected when creating it in the model. To do this, we just make the first agent infected by default.

Listing 28.6.4 direct_contact.py > InfectionModel > __init__
for i in range(self.num_agents):
    infected = True if (i == 0) else False
    a = InfectionAgent(i, self, infected)
    x = self.random.randrange(self.grid.width)
    y = self.random.randrange(self.grid.height)
    self.grid.place_agent(a, (x, y))
    self.schedule.add(a)

When you run the model now, it will look something like this. This screenshot was taken after 161 steps, and the infection has spread to about half of the population.

Grid populated with agents, some normal and some infected.

If something isn’t running properly, make sure your code matches what’s below by running diff or using another method.

Listing 28.6.5 direct_contact.py
#!/usr/bin/python3

from mesa import Agent, Model
from mesa.time import RandomActivation
from mesa.space import MultiGrid

class InfectionAgent(Agent):
    def __init__(self, unique_id, model, infected):
        super().__init__(unique_id, model)
        self.infected = infected

    def move(self):
        x, y = self.pos
        x_offset = self.random.randint(-1, 1)
        y_offset = self.random.randint(-1, 1)
        new_position = (x + x_offset, y + y_offset)
        self.model.grid.move_agent(self, new_position)

    def infect_neighbors(self):
        neighbors = self.model.grid.get_neighbors(self.pos,
                                                  moore=True,
                                                  include_center=True)
        for neighbor in neighbors:
            if self.random.random() < 0.25:
                neighbor.infected = True

    def step(self):
        self.move()
        if self.infected:
            self.infect_neighbors()
        
class InfectionModel(Model):
    def __init__(self, N, width, height):
        self.num_agents = N
        self.schedule = RandomActivation(self)
        self.grid = MultiGrid(width, height, torus=True)
        self.running = True
        
        for i in range(self.num_agents):
            infected = True if (i == 0) else False
            a = InfectionAgent(i, self, infected)
            x = self.random.randrange(self.grid.width)
            y = self.random.randrange(self.grid.height)
            self.grid.place_agent(a, (x, y))
            self.schedule.add(a)

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

28.7. Data collection & plotting

Now, we’re going to use Mesa’s built in DataCollector module, which can automatically collect the data for us as we run our model. Of course, we could also collect the data ourselves just by saving it as we run the model, but here we’ll use tools from Mesa instead.

28.7.1. Collecting data from the code

To start off, import the data collector into direct_contact.py.

Listing 28.7.1 direct_contact.py
from mesa.datacollection import DataCollector

Then, we’ll create a function separate from the agent and model classes which will let us collect the number of infected agents in a model. Next, within the model class, we will initialize a DataCollector and point it to the function we just defined so it can collect data.

Listing 28.7.2 direct_contact.py
def compute_infected(model):
    infected = 0
    for agent in model.schedule.agents:
        if agent.infected:
            infected += 1
    return infected


class InfectionModel(Model):
    def __init__(self, N, width, height):
        self.num_agents = N
        self.schedule = RandomActivation(self)
        self.grid = MultiGrid(width, height, True)
        self.running = True
        self.datacollector = DataCollector(
            model_reporters = {"Infected": compute_infected})

    for i in range(self.num_agents):
        infected = True if (i == 0) else False
        a = InfectionAgent(i, self, infected)
        x = self.random.randrange(self.grid.width)
        y = self.random.randrange(self.grid.height)
        self.grid.place_agent(a, (x, y))
        self.schedule.add(a)

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

The data collector can also collect two other types of variables, agent-level variables and tables. Model-level variables are like the total number of infected agent’s we’re now collecting–summaries across the whole model. On the other hand, agent-level variables are unique to each agent. Tables are a bit of a catch-all, and they let you track things that don’t match either of those two categories.

28.7.2. Plotting from the command line

Now, we’ll collect data from the model–first with a command-line approach. To start off, once again type python3 in the terminal an import the model as shown. Then, use a loop to step the model up to a certain point.

>>> from direct_contact import *
>>> model = InfectionModel(30, 40, 50)
>>> for i in range(400):
...      model.step()

Note

In this course, we use a small-scale solution that becomes cumbersome if you try to scale it up. If you want to run several instances and collect data from all of them, you can write your own code to handle the problem or use Mesa’s BatchRunner module.

As per usual, your choice! The BatchRunner does a lot of the work for you, but it’s worth it to understand what’s going on too.

Once the loop has finished running, we want to collect data from the model. By running the code below, we can use the datacollector to generate a Pandas dataframe of the collected data.

>>> data = model.datacollector.get_model_vars_dataframe()

Once you have the dataframe, you can do anything you want with it, including plotting it with matplotlib or doing data analysis on the spot. Today, we’ll export it to a CSV file and plot it with gnuplot.

>>> data.to_csv("model_data.csv", index_label="Steps")

Now, exit the python prompt and start a gnuplot session by typing gnuplot into your terminal. Enter these lines to generate a graph of the model. It will look something like the graph below.

set datafile separator ","
set key autotitle columnhead
set key left top
set xlabel "Time (steps)"
set ylabel "Infected"
set title "Agents infected over time"
plot "model_data.csv" with lines
Gnuplot of infected agents over time.

28.7.3. Plotting as the live model runs

If you care less about having workable data and more about getting an idea of the numbers as the model runs, it makes sense to add a graph component to your live visualization. To do this, first import the relevant module in your visualization.py file.

Listing 28.7.3 visualization.py
from mesa.visualization.modules import ChartModule

Then, create an instance of the ChartModule that will use the data collector to keep track of the agents alive. It’s important that the definition of infected_chart goes after the rest of the code but before we define the server, so that it will be loaded when the server starts. Also, make sure to add infected_chart to the list of modules that the server will draw!

Listing 28.7.4 visualization.py
infected_chart = ChartModule([{"Label": "Infected",
                               "Color": "LimeGreen"}],
                             data_collector_name='datacollector')

server = ModularServer(InfectionModel,
                       [grid, infected_chart],
                       "Infection Model",
                       params)

Now, when you run the model you will se the usual grid, but there will also be a graph of the number of infected agents. Below is a graph that the model generated after 150 steps.

Line graph showing the number of infected agents in green.

28.8. Source code

Here are the completed versions of the two files we have used:

direct_contact.py

visualization.py

Remember to run

$ python3 visualization.py

to have the code run and visualize in your browser.

28.9. Making an SIR model

Here are files with a partial implementation of an SIR model based on the simple infection model above.

Download these files:

sir_model.py

sir_vis.py

Remember to run

$ python3 sir_vis.py

to have the code run and visualize in your browser.

28.10. Further reading

You’ve completed this course, but there’s more to look into in this book and elsewhere if you’re interested in agent-based modeling and how agents behave together! Feel free to check out some of the resources below.

  • The mini-course in Section 29, which covers emergent behavior or how complex behavior emerges from simple rules.

  • This webpage about SIR and SEIR models for disease. While it uses equations to describe the trends of disease spread, it is possible to extend the model we made here to create a basic SIR model as well!

  • The Mesa example boid_flockers, which models bird movement in a flock in continuous space based on Craig Reynolds’ boids

  • The Wikipedia page and related materials covering Conway’s game of life, an excellent example of emergent behavior

  • This journal article going in-depth about modeling the dynamics of disease spread. It’s particularly interesting to see what they decided to model and what they didn’t!

Exercise 28.1: A real SIR model!

If you feel up to it, see if you can build on the model we made here to make an actual SIR model, where agents only stay infected for a limited amount of time before being removed from the population (or recovered, if you want to put a positive spin on it.)

There are some ordinary differential equations behind SIR models, so based on your mathematical experience you might feel more or less comfortable dealing with them (I’ll admit, they definitely can be scary).

If you’re looking for a place to start, try adding a way for agents to become no longer infected by the disease, and see what questions and problems stem off of that.