Tutorial
Agents.jl is composed of components for building models, building and managing space structures, collecting data, running batch simulations, and data visualization.
Agents.jl structures simulations in three components:
- An
AgentBasedModel
instance. - A space instance.
- A subtype of
AbstractAgent
for the agents.
To run simulations and collect data, the following are also necessary
- Stepping functions that controls how the agents and the model evolve.
- Specifying which data should be collected from the agents and/or the model.
So, in order to set up and run an ABM simulation with Agents.jl, you typically need to define a structure, function, or parameter collection for steps 1-3, define the rules of the agent evolution for step 4, and then declare which parameters of the model and the agents should be collected as data during step 5.
1. The model
Agents.AgentBasedModel
— TypeAgentBasedModel(AgentType [, space]; scheduler, properties) → model
Create an agent based model from the given agent type and space
. You can provide an agent instance instead of type, and the type will be deduced. ABM
is equivalent with AgentBasedModel
.
The agents are stored in a dictionary that maps unique ids (integers) to agents. Use model[id]
to get the agent with the given id
.
space
is a subtype of AbstractSpace
: GraphSpace
, GridSpace
or ContinuousSpace
. If it is ommited then all agents are virtually in one position and have no spatial structure.
Note: Spaces are mutable objects and are not designed to be shared between models. Create a fresh instance of a space with the same properties if you need to do this.
properties = nothing
is additional model-level properties (typically a dictionary) that can be accessed as model.properties
. However, if properties
is a dictionary with key type Symbol
, or of it is a struct, then the syntax model.name
is short hand for model.properties[:name]
(or model.properties.name
for structs). This syntax can't be used for name
being agents, space, scheduler, properties
, which are the fields of AgentBasedModel
.
scheduler = fastest
decides the order with which agents are activated (see e.g. by_id
and the scheduler API).
Type tests for AgentType
are done, and by default warnings are thrown when appropriate. Use keyword warn=false
to supress that.
2. The space
Agents.jl offers several possibilities for the space the agents live in. In addition, it is straightforward to implement a fundamentally new type of space, see Developer Docs.
Spaces are separated into disrete spaces (which by definition have a finite amount of possible positions) and continuous spaces. Thus, it is common for a specific position to contain several agents.
Discrete spaces
Agents.GraphSpace
— TypeGraphSpace(graph::AbstractGraph)
Create a GraphSpace
instance that is underlined by an arbitrary graph from LightGraphs.jl. The position type for this space is Int
, use GraphAgent
for convenience. The underlying graph can be altered using add_node!
and rem_node!
.
GraphSpace
represents a space where each node (i.e. position) of a graph can hold an arbitrary amount of agents, and each agent can move between the nodes of the graph. An example of its usage can be found in SIR model for the spread of COVID-19. If you want to model social networks, where each agent is equivalent with a node of a graph, you're better of using nothing
(or other spaces) as the model space, and using a graph from LightGraphs.jl directly in the model parameters, as shown in the Social networks with LightGraphs.jl integration example.
Agents.GridSpace
— TypeGridSpace(d::NTuple{D, Int}; periodic = true, metric = :chebyshev)
Create a GridSpace
that has size given by the tuple d
, having D ≥ 1
dimensions. Optionally decide whether the space will be periodic and what will be the distance metric used, which decides the behavior of e.g. nearby_ids
. The position type for this space is NTuple{D, Int}
, use GridAgent
for convenience. In our examples we typically use Dims{D}
instead of NTuple{D, Int}
(they are equivalent). Valid positions have indices in the range 1:d[i]
for the i
th dimension.
:chebyshev
metric means that the r
-neighborhood of a position are all positions within the hypercube having side length of 2*floor(r)
and being centered in the origin position.
:euclidean
metric means that the r
-neighborhood of a position are all positions whose cartesian indices have Euclidean distance ≤ r
from the cartesian index of the given position.
An example using GridSpace
is the Forest fire model.
Continuous spaces
Agents.ContinuousSpace
— TypeContinuousSpace(extent::NTuple{D, <:Real}, spacing = min(extent...)/10; kwargs...)
Create a D
-dimensional ContinuousSpace
in range 0 to (but not including) extent
. spacing
configures the compartment spacing that the space is divided in, in order to accelerate nearest neighbor functions like nearby_ids
. All dimensions in extent
must be completely divisible by spacing
(i.e. no fractional remainder). Your agent positions (field pos
) must be of type NTuple{D, <:Real}
, use ContinuousAgent
for convenience. In addition it is useful for agents to have a field vel::NTuple{D, <:Real}
to use in conjunction with move_agent!
.
The keyword periodic = true
configures whether the space is periodic or not. If set to false
an error will occur if an agent's position exceeds the boundary.
The keyword argument update_vel!
is a function, update_vel!(agent, model)
that updates the agent's velocity before the agent has been moved, see move_agent!
. You can of course change the agents' velocities during the agent interaction, the update_vel!
functionality targets spatial force fields acting on the agents individually (e.g. some magnetic field). By default no update is done this way. If you use update_vel!
, the agent type must have a field vel::NTuple{D, <:Real}
.
There is no "best" choice for the value of spacing
. If you need optimal performance it's advised to set up a benchmark over a range of choices. The value matters most when searching for neighbors. In Models.flocking
for example, an optimal value for spacing
is 66% of the search distance.
Agents.OpenStreetMapSpace
— TypeOpenStreetMapSpace(path::AbstractString; kwargs...)
Create a space residing on the Open Street Map (OSM) file provided via path
.
The abbreviation OSMSpace
may be used interchangeably.
Much of the functionality of this space is provided by interfacing with OpenStreetMapX.jl, for example the two keyword arguments use_cache = false
and trim_to_connected_graph = true
can be passed into the OpenStreetMapX.get_map_data
function.
For details on how to obtain an OSM file for your use case, consult the OpenStreetMapX.jl README. We provide a variable TEST_MAP
to use as a path
for testing.
This space represents the underlying map as a continuous entity choosing accuracy over performance. An example of its usage can be found in Zombie Outbreak.
If your solution can tolerate routes to and from intersections only, a faster implementation can be achieved by using the graph representation of your map provided by OpenStreetMapX.jl. For tips on how to implement this, see our integration example: Social networks with LightGraphs.jl.
The OSMAgent
The base properties for an agent residing on an OSMSpace
are as follows:
mutable struct OSMAgent <: AbstractAgent
id::Int
pos::Tuple{Int,Int,Float64}
route::Vector{Int}
destination::Tuple{Int,Int,Float64}
end
Current pos
ition and destination
tuples are represented as (start intersection index, finish intersection index, distance travelled in meters)
. The route
is an ordered list of intersections, providing a path to reach destination
.
Further details can be found in OSMAgent
.
Routing
There are two ways to generate a route, depending on the situation.
osm_plan_route
, which provides:shortest
and:fastest
paths (with the option of areturn_trip
) between intersections or positions.osm_random_route!
, choses a newdestination
an plans a new path to it; overriding the current route (if any).
3. The agent
Agents.AbstractAgent
— TypeAbstractAgent
All agents must be a mutable subtype of AbstractAgent
. Your agent type must have the id
field as first field. Depending on the space structure there might be a pos
field of appropriate type and a vel
field of appropriate type. Each space structure quantifies precicely what extra fields (if any) are necessary, however we recommend to use the [@agent
] macro to help you create the agent type.
Your agent type may have other additional fields relevant to your system, for example variable quantities like "status" or other "counters".
Examples
As an example, a GraphSpace
requires an id::Int
field and a pos::Int
field. To make an agent with two additional properties, weight, happy
, we'd write
mutable struct ExampleAgent <: AbstractAgent
id::Int
pos::Int
weight::Float64
happy::Bool
end
Agents.@agent
— Macro@agent YourAgentType{X, Y} AgentSupertype begin
some_property::X
other_extra_property::Y
# etc...
end
Create a struct for your agents which includes the mandatory fields required to operate in a particular space. Depending on the space of your model, the AgentSupertype
is chosen appropriately from GraphAgent
, GridAgent
, ContinuousAgent
.
Example
Using
@agent Person{T} GridAgent{2} begin
age::Int
moneyz::T
end
will in fact create an agent appropriate for using with 2-dimensional GridSpace
mutable struct Person{T} <: AbstractAgent
id::Int
pos::NTuple{2, Int}
age::Int
moneyz::T
end
Agents.GraphAgent
— TypeGraphAgent
Combine with @agent
to create an agent type for GraphSpace
. It attributes the fields id::Int, pos::Int
to the start of the agent type.
Agents.GridAgent
— TypeGridAgent{D}
Combine with @agent
to create an agent type for D
-dimensional GraphSpace
. It attributes the fields id::Int, pos::NTuple{D,Int}
to the start of the agent type.
Agents.ContinuousAgent
— TypeContinuousAgent{D}
Combine with @agent
to create an agent type for D
-dimensional ContinuousSpace
. It attributes the fields id::Int, pos::NTuple{D,Float64}, vel::NTuple{D,Float64}
to the start of the agent type.
Agents.OSMAgent
— TypeOSMAgent
Combine with @agent
to create an agent type for OpenStreetMapSpace
. It attributes the fields id::Int, pos::Tuple{Int,Int,Float64}, route::Vector{Int}, destination::Tuple{Int,Int,Float64}
to the start of the agent type.
Once an Agent is created it can be added to a model using e.g. add_agent!
. Then, the agent can interact with the model and the space further by using e.g. move_agent!
or kill_agent!
.
For more functions visit the API page.
4. Evolving the model
Any ABM model should have at least one and at most two step functions. An agent step function is required by default. Such an agent step function defines what happens to an agent when it activates. Sometimes we also need a function that changes all agents at once, or changes a model property. In such cases, we can also provide a model step function.
An agent step function should only accept two arguments: first, an agent object, and second, a model object.
The model step function should accept only one argument, that is the model object. To use only a model step function, users can use the built-in dummystep
as the agent step function.
After you have defined these two functions, you evolve your model with step!
:
Agents.step!
— Functionstep!(model, agent_step!, n::Int = 1)
step!(model, agent_step!, model_step!, n::Int = 1, agents_first::Bool=true)
Update agents n
steps according to the stepping function agent_step!
. Agents will be activated as specified by the model.scheduler
. model_step!
is triggered after every scheduled agent has acted, unless the argument agents_first
is false
(which then first calls model_step!
and then activates the agents).
step!
ignores scheduled IDs that do not exist within the model, allowing you to safely kill agents dynamically.
step!(model, agent_step!, model_step!, n::Function, agents_first::Bool=true)
In this version n
is a function. Then step!
runs the model until n(model, s)
returns true
, where s
is the current amount of steps taken, starting from 0. For this method of step!
, model_step!
must be provided always (use dummystep
if you have no model stepping dynamics).
Agents.dummystep
— Functiondummystep(model)
Use instead of model_step!
in step!
if no function is useful to be defined.
dummystep(agent, model)
Use instead of agent_step!
in step!
if no function is useful to be defined.
Notice that the current step number is not explicitly given to the model_step!
function, because this is useful only for a subset of ABMs. If you need the step information, implement this by adding a counting parameter into the model properties
, and incrementing it by 1 each time model_step!
is called. An example can be seen in the model_step!
function of Daisyworld, where a tick
is increased at each step.
Advanced stepping
The interface of step!
, which allows the option of both agent_step!
and model_step!
is driven mostly by convenience. In principle, the model_step!
function by itself can perform all operations related with stepping the ABM. However, for many models, this simplified approach offers the benefit of not having to write an explicit loop over existing agents inside the model_step!
. Most of the examples in our documentation can be expressed using an independent agent_step!
and model_step!
function.
On the other hand, more advanced models require special handling for scheduling, or may need to schedule several times and act on different subsets of agents with different functions. In such a scenario, it is more sensible to provide only a model_step!
function (and use dummystep
as agent_step!
), where all configuration is contained within. For example
function complex_step!(model)
for a in scheduler1(model)
agent_step1!(a, model)
end
intermediate_model_action!(model)
for a in scheduler2(model)
agent_step2!(a, model)
end
final_model_action!(model)
end
step!(model, dummystep, complex_step!, n)
For defining your own scheduler, see Schedulers.
5. Collecting data
Running the model and collecting data while the model runs is done with the run!
function. Besides run!
, there is also the paramscan
function that performs data collection, while scanning ranges of the parameters of the model.
Agents.run!
— Functionrun!(model, agent_step! [, model_step!], n::Integer; kwargs...) → agent_df, model_df
run!(model, agent_step!, model_step!, n::Function; kwargs...) → agent_df, model_df
Run the model (step it with the input arguments propagated into step!
) and collect data specified by the keywords, explained one by one below. Return the data as two DataFrame
s, one for agent-level data and one for model-level data.
Data-deciding keywords
adata::Vector
means "agent data to collect". If an entry is aSymbol
, e.g.:weight
, then the data for this entry is agent's fieldweight
. If an entry is aFunction
, e.g.f
, then the data for this entry is justf(a)
for each agenta
. The resulting dataframe columns are named with the input symbol (here:weight, :f
).adata::Vector{<:Tuple}
: ifadata
is a vector of tuples instead, data aggregation is done over the agent properties.For each 2-tuple, the first entry is the "key" (any entry like the ones mentioned above, e.g.
:weight, f
). The second entry is an aggregating function that aggregates the key, e.g.mean, maximum
. So, continuing from the above example, we would haveadata = [(:weight, mean), (f, maximum)]
.It's also possible to provide a 3-tuple, with the third entry being a conditional function (returning a
Bool
), which assesses if each agent should be included in the aggregate. For example:x_pos(a) = a.pos[1]>5
with(:weight, mean, x_pos)
will result in the average weight of agents conditional on their x-position being greater than 5.The resulting data name columns use the function
aggname
, and create something like:mean_weight
or:maximum_f_x_pos
. This name doesn't play well with anonymous functions, but you can simply useDataFrames.rename!
to change the returned dataframe's column names.Notice: Aggregating only works if there are agents to be aggregated over. If you remove agents during model run, you should modify the aggregating functions. E.g. instead of passing
mean
, passmymean(a) = isempty(a) ? 0.0 : mean(a)
.mdata::Vector
means "model data to collect" and works exactly likeadata
. For the model, no aggregation is possible (nothing to aggregate over).
By default both keywords are nothing
, i.e. nothing is collected/aggregated.
Mixed-Models
For mixed-models, the adata
keyword has some additional options & properties. An additional column agent_type
will be placed in the output dataframe.
In the case that data is needed for one agent type that does not exist in a second agent type, missing
values will be added to the dataframe.
Warning: Since this option is inherently type unstable, try to avoid this in a performance critical situation.
Aggregate functions will fail if missing
values are not handled explicitly. If a1.weight
but a2
(type: Agent2) has no weight
, use a2(a) = a isa Agent2; adata = [(:weight, sum, a2)]
to filter out the missing results.
Other keywords
when=true
: at which stepss
to perform the data collection and processing. A lot of flexibility is offered based on the type ofwhen
. Ifwhen::Vector
, then data are collect ifs ∈ when
. Otherwise data are collected ifwhen(model, s)
returnstrue
. By default data are collected in every step.when_model = when
: same aswhen
but for model data.obtainer = identity
: method to transfer collected data to theDataFrame
. Typically only change this tocopy
if some data are mutable containers (e.g.Vector
) which change during evolution, ordeepcopy
if some data are nested mutable containers. Both of these options have performance penalties.replicates=0
: Runreplicates
replicates of the simulation.parallel=false
: Only whenreplicates>0
. Run replicate simulations in parallel.agents_first=true
: Whether to update agents first and then the model, or vice versa.
The run!
function has been designed for maximum flexibility: nearly all scenarios of data collection are possible whether you need agent data, model data, aggregating model data, or arbitrary combinations.
This means that run!
has not been designed for maximum performance (or minimum memory allocation). However, we also expose a simple data-collection API (see Data collection), that gives users even more flexibility, allowing them to make their own "data collection loops" arbitrarily calling step!
and collecting data as, and when, needed.
An educative example
A simple, education-oriented example of using the basic Agents.jl API is given in Schelling's segregation model, also discussing in detail how to visualize your ABMs.
Each of the examples listed within this documentation are designed to showcase different ways of interacting with the API. If you are not sure about how to use a particular function, most likely one of the examples can show you how to interact with it.
For a quick reference concerning the main concepts of agent based modelling, and how the Agents.jl examples implement each one, take a look at the Overview of Examples page.