# Agentpy - Agent-based modeling in Python¶

Agentpy is an open-source library for the development and analysis of agent-based models in Python. The framework integrates the tasks of model design, numerical experiments, and data analysis within a single environment, and is optimized for interactive computing with IPython and Jupyter. If you have questions or ideas for improvements, please visit the discussion forum or subscribe to the agentpy mailing list.

Quick orientation

To get started, please take a look at Installation and Overview.

For a simple demonstration, check out the Wealth transfer tutorial in the Model Library.

For a detailled description of all classes and functions, refer to API Reference.

To learn how agentpy compares with other frameworks, take a look at Comparison.

Example

*A screenshot of Jupyter Lab with two interactive tutorials from the model library:*

Table of contents

## Installation¶

To install the latest release of agentpy, run the following command on your console:

```
$ pip install agentpy
```

### Dependencies¶

Agentpy supports Python 3.6, 3.7, 3.8, and 3.9. The installation includes the following packages:

numpy, for scientific computing

matplotlib, for visualization

pandas, for output dataframes

networkx, for network analysis

IPython and ipywidgets, for interactive computing

SALib, for sensitivity analysis

These optional packages can further be useful in combination with agentpy, and are required in some of the tutorials:

### Development¶

The most recent version of agentpy can be cloned from Github:

```
$ git clone https://github.com/JoelForamitti/agentpy.git
```

Once you have a copy of the source, you can install it with:

```
$ pip install -e
```

To include all necessary packages for development, you can use:

```
$ pip install -e .['dev']
```

## Overview¶

This section aims to provide a rough overview over the main classes and functions of agentpy and how they are meant to be used. For a more detailed description of each element, please refer to the API Reference. Throughout this documentation, agentpy is imported as follows:

```
import agentpy as ap
```

### Creating models¶

The basic framework for agent-based models consists of three levels:

`Model`

, which contains agents, environments, parameters, & procedures`Environment`

,`Grid`

, and`Network`

, which contain agents`Agent`

, the basic building blocks of the model

All of these classes are designed to be customized through the creation of sub-classes with their own variables and methods. A custom agent type could be defined as follows:

```
class MyAgentType(ap.Agent):
def setup(self):
# Initialize an attribute with a parameter
self.my_attribute = self.p.my_parameter
def agent_method(self):
# Define custom actions here
pass
```

The method `Agent.setup()`

is meant to be overwritten
and will be called after an agents’ creation.
All variables of an agents should be initialized in this method.
Other methods can represent actions that the agent will be able to take during a simulation.

We can further see that the agent comes with a built-in attribute `p`

that
allows it to access the models’ parameters.
All model objects (i.e. agents, environments, and the model itself)
are equipped with such properties to access different parts of the model:

`model`

returns the model instance`model.t`

returns the model’s time-step`id`

returns a unique identifier number for each object`p`

returns an`AttrDict`

of the models’ parameters`envs`

returns an`EnvList`

of the objects’ environments`agents`

(not for agents) returns an`AgentList`

of the objects’ agents`log`

returns a`dict`

of the objects’ recorded variables

Using the new agent type defined above, here is how a basic model could look like:

```
class MyModel(ap.Model):
def setup(self):
""" Called at the start of the simulation """
self.add_agents(self.p.agents, MyAgentType) # Add new agents
def step(self):
""" Called at every simulation step """
self.agents.agent_method() # Call a method for every agent
def update(self):
""" Called after setup as well as after each step """
self.agents.record('my_attribute') # Record a dynamic variable
def end(self):
""" Called at the end of the simulation """
self.measure('my_measure', 1) # Record an evaluation measure
```

This custom model is defined by four special methods that will be used automatically during different parts of a simulation. If you want to see a basic model like this in action, take a look at the Wealth transfer demonstration in the Model Library.

### Using agents¶

Agentpy comes with various tools to create, manipulate, and delete agents.
The method `Model.add_agents()`

can be used to initialize new agents.
A list of all agents in a model can be accessed through `Model.agents`

.
Lists of agents are returned as an `AgentList`

,
which provides special features to access and manipulate the whole group of agents.

For example, when the model defined above calls `self.agents.agent_method()`

,
it will call the method `MyAgentType.agent_method()`

for every agent in the model.
Similar commands can be used to set and access variables, or select subsets
of agents with boolean operators.
The following command, for example, would select all agents with an id above one:

```
self.agents.select(self.agents.id > 1)
```

Further examples can be found in the `AgentList`

reference
or the Virus spread model.

### Using environments¶

Environments can contain agents just like the main model,
and are useful if one wants to regard particular topologies for interaction
or multiple environments that can hold seperate populations of agents.
Agents can be moved between environments with the methods
`Agent.enter()`

and `Agent.exit()`

.

New environments can be created with `Model.add_env()`

.
Similar to agents, the attribute `envs`

returns an `EnvList`

with special features to deal with groups of environments.
There are three different types of environments:

`Environment`

, which simply contain agents without any topology.`Network`

, in which agents can be connected via a networkx graph.`Grid`

, in which agents occupy a position on a x-dimensional space.

Applied examples of networks can be found in the demonstration models Virus spread and Button network, while a spatial grid is used in Forest fire.

### Recording data¶

As can be seen in the model defined above,
there are two main types of data in agentpy.
The first are dynamic variables,
which can be stored for each object (agent, environment, or model) and time-step.
They are useful to look at the dynamics of individual or aggregate objects over time
and can be recorded by calling the method `record()`

for the respective object.

The other type of recordable data are evaluation measures.
These, in contrast, can be stored only for the model as a whole and only once per run.
They are useful as summary statistics that can be compared over multiple runs,
and can be recorded with the method `Model.measure()`

.

### Running a simulation¶

To perform a simulation, we have to initialize a new instance of our model type
with a dictionary of parameters, after which we use the function `Model.run()`

.
This will return a `DataDict`

with recorded data from the simulation.
A simple run could be prepared and executed as follows:

```
parameters = {'my_parameter':42,
'agents':10,
'steps':10, }
model = MyModel(parameters)
results = model.run()
```

The procedure of a simulation is as follows:

The model initializes with the time-step

`Model.t = 0`

.`Model.setup()`

and`Model.update()`

are called.The model’s time-step is increased by 1.

`Model.step()`

and`Model.update()`

are called.Step 2 and 3 are repeated until the simulation is stopped.

`Model.end()`

is called.

The simulation of a model can be stopped by one of the following three ways:

Calling the

`Model.stop()`

during the simulation.Reaching the time-limit, which be defined as follows:

Defining

`steps`

in the paramater dictionary.Passing

`steps`

as an argument to`Model.run()`

.

### Multi-run experiments¶

The class `Experiment`

can be used to run a model multiple times
with repeated iterations, varied parameters, and distinct scenarios.
To prepare a sample of parameters for an experiment, one can use one of the
sampling functions `sample()`

, `sample_saltelli()`

, or `sample_discrete()`

.
Here is an example of an experiment with the model defined above:

```
parameter_ranges = {'my_parameter': 42,
'agents': (10, 20, int),
'steps': (10, 20, int)}
sample = ap.sample(parameter_ranges, n=5)
exp = ap.Experiment(MyModel, sample, iterations=2,
scenarios=('sc1','sc2'))
results = exp.run()
```

In this experiment, we use a sample where one parameter is kept fixed while the other two are varied 5 times from 10 to 20 and set to integer. Every possible combination is repeated 2 times, which results in 50 runs. Each run further has one result for each of the two scenarios sc1 and sc2. For more applied examples of experiments, check out the demonstration models Virus spread, Button network, and Forest fire.

### Output and analysis¶

Both `Model`

and `Experiment`

can be used to run a simulation,
which will return a `DataDict`

with output data.
The output from the experiment defined above looks as follows:

```
>>> results
DataDict {
'log': Dictionary with 5 keys
'parameters':
'fixed': Dictionary with 1 key
'varied': DataFrame with 2 variables and 25 rows
'measures': DataFrame with 1 variable and 50 rows
'variables':
'MyAgentType': DataFrame with 1 variable and 10500 rows
}
```

The output can contain the following categories of data:

`log`

holds meta-data about the model and simulation performance.`parameters`

holds the parameter values that have been used for the experiment.`variables`

holds dynamic variables, which can be recorded at multiple time-steps.`measures`

holds evaluation measures that are recoreded only once per simulation.

This data can be stored with `DataDict.save()`

and `load()`

.
`DataDict.arrange()`

can further be used to generate a specific
dataframe for analysis or visualization. All data is given in a `pandas.DataFrame`

and
formatted as long-form data,
which makes it compatible to use with statistical packages like seaborn.
Agentpy further provides the following functions for analysis:

`sensitivity_sobol()`

performs a Sobol sensitivity analysis.`Experiment.interactive()`

generates an interactive widget for parameter variation.`animate()`

generates an animation that can display output over time.`gridplot()`

visualizes agent positions on a spatial`Grid`

.

To see applied examples of these functions, please check out the Model Library.

## User Guide¶

Welcome to the agentpy user guide. This section contains various articles to help with specific problems and applications. Some of these articles are provided as interactive Jupyter Notebooks that can be downloaded and experimented with.

If you are interested to add a new article to this guide, please visit Contribute. If you are looking for examples of complete models, take a look at Model Library.

Note

You can download this demonstration as a Jupyter Notebook
`here`

### Stochastic processes and reproducibility¶

Random numbers and stochastic processes are essential to many agent-based models.
In Python, we can use the pseudo-random number generator from the built-in library `random`

.

Pseudo-random means that this module generates numbers in a sequence that appears random but is actually deterministic, based on an initial seed value. In other words, the generator will produce the same pseudo-random sequence over multiple runs if it is given the same seed at the beginning. We can define this seed to receive reproducible results from a model with stochastic processes.

#### Generating random numbers¶

```
[1]:
```

```
import agentpy as ap
import random
```

To illustrate, let us define a model that generates a list of ten pseudo-random numbers:

```
[2]:
```

```
class RandomModel(ap.Model):
def setup(self):
self.random_numbers = [random.randint(0, 9) for _ in range(10)]
print(f"Model {self.p.n} generated the numbers {self.random_numbers}")
```

Now if we run this model multiple times, we will get a different series of numbers:

```
[3]:
```

```
for i in range(2):
parameters = {'steps':0, 'n':i}
model = RandomModel(parameters)
results = model.run(display=False)
```

```
Model 0 generated the numbers [9, 3, 3, 8, 8, 0, 1, 9, 4, 7]
Model 1 generated the numbers [0, 5, 9, 4, 6, 5, 3, 2, 2, 0]
```

If we want the results to be reproducible,
we can define a parameter `seed`

that will be used automatically at the beginning of `Model.run()`

.
Now, we get the same series of numbers:

```
[4]:
```

```
for i in range(2):
parameters = {'seed':1, 'steps':0, 'n':i}
model = RandomModel(parameters)
model.run(display=False)
```

```
Model 0 generated the numbers [2, 9, 1, 4, 1, 7, 7, 7, 6, 3]
Model 1 generated the numbers [2, 9, 1, 4, 1, 7, 7, 7, 6, 3]
```

#### Using multiple generators¶

The automatic use of the `seed`

parameter calls the method `random.seed()`

, which affects the default number generator that is created as a hidden instance by the `random`

module.
For more advanced applications, we can create seperate generators for each object, using `random.Random`

.
We can ensure that the seeds of each object follow a controlled pseudo-random sequence by using also using seperate generator in the main model.
Note that we use a different parameter name model_seed to avoid the automatic use of the parameter `seed`

in this case.

```
[5]:
```

```
class RandomAgent2(ap.Agent):
def setup(self):
seed = model.seed_generator.getrandbits(128) # Get seed from model
self.random = random.Random(seed) # Create generator for this agent
self.random_numbers = [self.random.randint(0, 9) for _ in range(10)]
print(f"{self} generated the numbers {self.random_numbers}")
class RandomModel2(ap.Model):
def setup(self):
self.seed_generator = random.Random(self.p.model_seed)
self.add_agents(2, RandomAgent2)
for i in range(2):
print(f"Model {i}:")
parameters = {'model_seed': 1, 'steps': 0}
model = RandomModel2(parameters)
results = model.run(display=False)
print()
```

```
Model 0:
RandomAgent2 (Obj 1) generated the numbers [8, 7, 0, 1, 2, 3, 9, 4, 5, 0]
RandomAgent2 (Obj 2) generated the numbers [8, 1, 4, 6, 6, 3, 4, 3, 5, 1]
Model 1:
RandomAgent2 (Obj 1) generated the numbers [8, 7, 0, 1, 2, 3, 9, 4, 5, 0]
RandomAgent2 (Obj 2) generated the numbers [8, 1, 4, 6, 6, 3, 4, 3, 5, 1]
```

Alternatively, we could also have each agent start from the same seed:

```
[6]:
```

```
class RandomAgent3(ap.Agent):
def setup(self):
self.random = random.Random(self.p.agent_seed)
self.random_numbers = [self.random.randint(0, 9) for _ in range(10)]
print(f"{self} generated the numbers {self.random_numbers}")
class RandomModel3(ap.Model):
def setup(self):
self.add_agents(2, RandomAgent3)
for i in range(2):
print(f"\nModel {i}:")
parameters = {'agent_seed': 1, 'steps':0, 'n':i}
model = RandomModel3(parameters)
results = model.run(display=False)
```

```
Model 0:
RandomAgent3 (Obj 1) generated the numbers [2, 9, 1, 4, 1, 7, 7, 7, 6, 3]
RandomAgent3 (Obj 2) generated the numbers [2, 9, 1, 4, 1, 7, 7, 7, 6, 3]
Model 1:
RandomAgent3 (Obj 1) generated the numbers [2, 9, 1, 4, 1, 7, 7, 7, 6, 3]
RandomAgent3 (Obj 2) generated the numbers [2, 9, 1, 4, 1, 7, 7, 7, 6, 3]
```

#### Modeling stochastic processes¶

This section presents some stochastic operations that are often used in agent-based models. To start, we prepare a generic model with ten agents:

```
[7]:
```

```
model = ap.Model()
agents = model.add_agents(10)
agents
```

```
[7]:
```

```
AgentList [10 agents]
```

If we look at the agent’s ids, we see that they have been created in order:

```
[8]:
```

```
agents.id
```

```
[8]:
```

```
AttrList of attribute 'id': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
```

We can shuffle this list with `AgentList.shuffle()`

:

```
[9]:
```

```
agents.shuffle().id
```

```
[9]:
```

```
AttrList of attribute 'id': [5, 10, 3, 9, 6, 4, 7, 1, 8, 2]
```

Or create a random subset with `AgentList.random()`

:

```
[10]:
```

```
agents.random(5).id
```

```
[10]:
```

```
AttrList of attribute 'id': [6, 9, 10, 3, 5]
```

Both `AgentList.shuffle()`

and `AgentList.random()`

can take a custom generator as an argument:

```
[11]:
```

```
for _ in range(2):
custom_generator = random.Random(1)
print(agents.random(5, custom_generator).id)
```

```
AttrList of attribute 'id': [3, 10, 6, 5, 9]
AttrList of attribute 'id': [3, 10, 6, 5, 9]
```

Note that the above selection is without repetition, i.e. every agent can only be selected once.
Outside these built-in functions of agentpy, there are many other tools that can be used for stochastic processes.
For example, we can use the methods `random.choices()`

to make a selection with repetition and probability weights.
In the following example, agents with a higher id are more likely to be chosen:

```
[12]:
```

```
choices = random.choices(agents, k=5, weights=agents.id)
```

If needed, the resulting list from such selections can be converted back into an `AgentList`

:

```
[13]:
```

```
ap.AgentList(choices).id
```

```
[13]:
```

```
AttrList of attribute 'id': [5, 4, 5, 8, 7]
```

#### Further reading¶

Random number generation in Python: https://realpython.com/python-random/

Stochasticity in agent-based models: http://www2.econ.iastate.edu/tesfatsi/ace.htm#Stochasticity

Pseudo-random number generators: https://en.wikipedia.org/wiki/Pseudorandom_number_generator

What is random: https://www.youtube.com/watch?v=9rIy0xY99a0

## Model Library¶

Welcome to the agentpy model library. Below you can find a set of demonstrations on how the package can be used. All of the models are provided as interactive Jupyter Notebooks that can be downloaded and experimented with.

Note

You can download this demonstration as a Jupyter Notebook
`here`

### Wealth transfer¶

This is a tutorial for beginners on how to create a simple agent-based model with the agentpy package. It shows the how to create a basic model with a custom agent type, run a simulation, record data, and visualize results.

#### About the model¶

The model explores the distribution of wealth under a trading population of agents. We will see that their random interaction will create an inequality of wealth that follows a Boltzmann distribution. The original version of this model been written in MESA and can be found here.

#### Getting started¶

To install the latest version of agentpy, run the following command:

```
[1]:
```

```
# !pip install agentpy
```

Once installed, the recommended way to import the package is as follows:

```
[2]:
```

```
import agentpy as ap
```

We also import two other libraries that will be used in this demonstration.

```
[3]:
```

```
import numpy as np # Scientific computing tools
import matplotlib.pyplot as plt # Visualization
```

#### Model definition¶

We start by defining a new type of agent as a subcluss of `Agent`

.
Each agent starts with one unit of `wealth`

.
When `wealth_transfer()`

is called, the agent selects another agent at random
and gives them one unit of their own wealth if they have one to spare.

```
[4]:
```

```
class WealthAgent(ap.Agent):
""" An agent with wealth """
def setup(self):
self.wealth = 1
def wealth_transfer(self):
if self.wealth > 0:
partner = self.model.agents.random()
partner.wealth += 1
self.wealth -= 1
```

Next, we define a method to calculate the Gini Coefficient, which will measure the inequality among our agents.

```
[5]:
```

```
def gini(x):
""" Calculate Gini Coefficient """
# By Warren Weckesser https://stackoverflow.com/a/39513799
mad = np.abs(np.subtract.outer(x, x)).mean() # Mean absolute difference
rmad = mad / np.mean(x) # Relative mean absolute difference
return 0.5 * rmad
```

Finally, we define our model as a subclass of `Model`

. In `Model.setup()`

, we define how many agents should be created at the beginning of the simulation. In `Model.step()`

, we define that at every time-step all agents will perform the action wealth_transfer. In `Model.update()`

, we calculate and record the current Gini coefficient. And in `Model.end()`

, we further record the wealth of each agent.

```
[6]:
```

```
class WealthModel(ap.Model):
""" A simple model of random wealth transfers """
def setup(self):
self.add_agents(self.p.agents, WealthAgent)
def step(self):
self.agents.wealth_transfer()
def update(self):
self.record('Gini Coefficient', gini(self.agents.wealth))
def end(self):
self.agents.record('wealth')
```

#### Running a simulation¶

To run a simulation, we define a dictionary of parameters that defines the number of agents and the number of steps that the model will run.

```
[7]:
```

```
parameters = {
'agents': 100,
'steps': 100
}
```

To perform a simulation, we initialize our model with these parameters and call the method `Model.run`

, which returns a `DataDict`

of our recorded variables and measures.

```
[8]:
```

```
model = WealthModel(parameters)
results = model.run()
```

```
Completed: 100 steps
Run time: 0:00:00.183086
Simulation finished
```

To visualize the evolution of our Gini Coefficient,
we can use `pandas.DataFrame.plot()`

.

```
[9]:
```

```
data = results.variables.WealthModel
ax = data.plot()
```

And to visualize the final distribution of wealth,
we can use `pandas.DataFrame.hist()`

.

```
[10]:
```

```
data = results.variables.WealthAgent
data.hist(bins=range(data.wealth.max()+1))
plt.title('')
plt.xlabel('Wealth')
plt.ylabel('Number of agents')
plt.show()
```

What we get is a Boltzmann distribution. For those interested to understand this result, you can read more about it here.

Note

You can download this demonstration as a Jupyter Notebook
`here`

### Virus spread¶

This notebook presents an agent-based model that simulates the propagation of a disease through a network. It demonstrates how to use the agentpy package to create and visualize networks, use the interactive module, and perform different types of sensitivity analysis.

```
[1]:
```

```
# Model design
import agentpy as ap
import networkx as nx
import random
# Visualization
import matplotlib.pyplot as plt
import seaborn as sns
import IPython
```

#### About the model¶

The agents of this model are people, which can be in one of the following three conditions: susceptible to the disease (S), infected (I), or recovered (R). The agents are connected to each other through a small-world network of peers. At every time-step, infected agents can infect their peers or recover from the disease based on random chance.

#### Defining the model¶

We define a new agent type `Person`

by creating a subclass of `Agent`

.
This agent has two methods: `setup()`

will be called automatically at the agent’s creation,
and `being_sick()`

will be called by the `Model.step()`

function.
Three tools are used within this class:

`Agent.p`

returns the parameters of the model`Agent.neighbors()`

returns a list of the agents’ peers in the network`random.random()`

returns a uniform random draw between 0 and 1

```
[2]:
```

```
class Person(ap.Agent):
def setup(self):
""" Initialize a new variable at agent creation. """
self.condition = 0 # Susceptible = 0, Infected = 1, Recovered = 2
def being_sick(self):
""" Spread disease to peers in the network. """
for n in self.neighbors():
if n.condition == 0 and self.p.infection_chance > random.random():
n.condition = 1 # Infect susceptible peer
if self.p.recovery_chance > random.random():
self.condition = 2 # Recover from infection
```

Next, we define our model `VirusModel`

by creating a subclass of `Model`

.
The four methods will be called automatically, as described in Running a simulation.

```
[3]:
```

```
class VirusModel(ap.Model):
def setup(self):
""" Initializes the agents and network of the model. """
self.p.population = p = int(self.p.population)
# Prepare a small-world network graph
graph = nx.watts_strogatz_graph(p,
self.p.number_of_neighbors,
self.p.network_randomness)
# Create agents and network
self.add_agents(p, Person)
self.add_network(graph=graph, agents=self.agents)
# Infect a random share of the population
I0 = int(self.p.initial_infections * self.p.population)
self.agents.random(I0).condition = 1
def update(self):
""" Records variables after setup and each step. """
# Record share of agents with each condition
for i, c in enumerate(('S', 'I', 'R')):
self[c] = (len(self.agents.select(self.agents.condition == i))
/ self.p.population)
self.record(c)
# Stop simulation if disease is gone
if self.I == 0:
self.stop()
def step(self):
""" Defines the models' events per simulation step. """
# Call 'being_sick' for infected agents
self.agents(self.agents.condition==1).being_sick()
def end(self):
""" Records evaluation measures at the end of the simulation. """
# Record final evaluation measures
self.measure('Total share infected', self.I + self.R)
self.measure('Peak share infected', max(self.log['I']))
```

#### Running a simulation¶

To run our model, we define a dictionary with our parameters.
We then create a new instance of our model, passing the parameters as an argument,
and use the method `Model.run()`

to perform the simulation and return it’s output.

```
[4]:
```

```
parameters = {
'population': 1000,
'infection_chance': 0.3,
'recovery_chance': 0.1,
'initial_infections': 0.1,
'number_of_neighbors': 2,
'network_randomness': 0.5
}
model = VirusModel(parameters)
results = model.run()
```

```
Completed: 75 steps
Run time: 0:00:00.420014
Simulation finished
```

#### Analyzing results¶

The simulation returns a `DataDict`

of recorded data with dataframes:

```
[5]:
```

```
results
```

```
[5]:
```

```
DataDict {
'log': Dictionary with 4 keys
'parameters': Dictionary with 6 keys
'measures': DataFrame with 2 variables and 1 row
'variables': DataFrame with 3 variables and 76 rows
}
```

To visualize the evolution of our variables over time, we create a plot function.

```
[6]:
```

```
def virus_stackplot(data, ax):
""" Stackplot of people's condition over time. """
x = data.index.get_level_values('t')
y = [data[var] for var in ['I', 'S', 'R']]
sns.set()
ax.stackplot(x, y, labels=['Infected', 'Susceptible', 'Recovered'],
colors = ['r', 'b', 'g'])
ax.legend()
ax.grid(False)
ax.set_xlim(0, max(1, len(x)-1))
ax.set_ylim(0, 1)
ax.set_xlabel("Time steps")
ax.set_ylabel("Percentage of population")
fig, ax = plt.subplots()
virus_stackplot(results.variables, ax)
```

#### Creating an animation¶

We can also animate the model’s dynamics as follows.
The function `animation_plot()`

takes a model instance
and displays the previous stackplot together with a network graph.
The function `animate()`

will call this plot
function for every time-step and return an `matplotlib.animation.Animation`

.

```
[7]:
```

```
def animation_plot(m, axs):
ax1, ax2 = axs
ax1.set_title("Virus spread")
ax2.set_title(f"Share infected: {m.I}")
# Plot stackplot on first axis
virus_stackplot(m.output.variables, ax1)
# Plot network on second axis
color_dict = {0:'b', 1:'r', 2:'g'}
colors = [color_dict[c] for c in m.agents.condition]
nx.draw_circular(m.env.graph, node_color=colors,
node_size=50, ax=ax2)
fig, axs = plt.subplots(1, 2, figsize=(8, 4)) # Prepare figure
parameters['population'] = 50 # Lower population for better visibility
animation = ap.animate(VirusModel(parameters), fig, axs, animation_plot)
```

Using Jupyter, we can display this animation directly in our notebook.

```
[8]:
```

```
IPython.display.HTML(animation.to_jshtml())
```

```
[8]:
```