Tutorial
- This Tutorial is also available as a YouTube video: https://youtu.be/fgwAfAa4kt0
In Agents.jl a central structure called AgentBasedModel
contains all data of a simulation and maps unique IDs (integers) to agent instances. During the simulation, the model evolves in discrete steps. During one step, the user decides which agents will act, how will they act, how many times, and whether any model-level properties will be adjusted. Once the time evolution is defined, collecting data during time evolution is straightforward by simply stating which data should be collected.
In the spirit of simple design, all of this is done by defining simple Julia data types, like basic functions, structs and dictionaries.
To set up an ABM simulation in Agents.jl, a user only needs to follow these steps:
- Choose in what kind of space the agents will live in, for example a graph, a grid, etc. Several spaces are provided by Agents.jl and can be initialized immediately.
- Define the agent type (or types, for mixed models) that will populate the ABM. Agent types are standard Julia
mutable struct
s. They can be created manually, but typically you'd want to use@agent
. The types must contain some mandatory fields, which is ensured by using@agent
. The remaining fields of the agent type are up to user's choice. - The created agent type, the chosen space, optional additional model level properties, and other simulation tuning properties like schedulers or random number generators, are given to
AgentBasedModel
. This instance defines the model within an Agents.jl simulation. More specialized structures are also available, seeAgentBasedModel
. - Provide functions that govern the time evolution of the ABM. A user can provide an agent-stepping function, that acts on each agent one by one, and/or a model-stepping function, that steps the entire model as a whole. These functions are standard Julia functions that take advantage of the Agents.jl API. Once these functions are created, they are simply passed to
step!
to evolve the model. - (Optional) Visualize the model and animate its time evolution. This can help checking that the model behaves as expected and there aren't any mistakes, or can be used in making figures for a paper/presentation.
- Collect data. To do this, specify which data should be collected, by providing one standard Julia
Vector
of data-to-collect for agents, for example[:mood, :wealth]
, and another one for the model. The agent data names are given as the keywordadata
and the model as keywordmdata
to the functionrun!
. This function outputs collected data in the form of aDataFrame
.
If you're planning of running massive simulations, it might be worth having a look at the Performance Tips after familiarizing yourself with Agents.jl.
1. 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 Creating a new space type.
The available spaces are listed in the Available spaces part of the API. An example of a space is OpenStreetMapSpace
. It is based on Open Street Map, where agents are confined to move along streets of the map, using real-world values for the length of each street.
After deciding on the space, one simply initializes an instance of a space, e.g. with grid = GridSpace((10, 10))
and passes that into AgentBasedModel
. See each individual space for all its possible arguments.
2. The agent type(s)
Agents.@agent
— Macro@agent YourAgentType{X} AnotherAgentType [OptionalSupertype] begin
extra_property::X
other_extra_property::Int
# etc...
end
Define an agent struct which includes all fields that AnotherAgentType
has, as well as any additional ones the user may provide via the begin
block. See below for examples.
Using @agent
is the recommended way to create agent types for Agents.jl, however keep in mind that the macro (currently) doesn't work with Base.@kwdef
or const
declarations in individual fields (for Julia v1.8+).
Structs created with @agent
by default subtype AbstractAgent
. They cannot subtype each other, as all structs created from @agent
are concrete types and AnotherAgentType
itself is also concrete (only concrete types have fields). If you want YourAgentType
to subtype something other than AbstractAgent
, use the optional argument OptionalSupertype
(which itself must then subtype AbstractAgent
).
Usage
The macro @agent
has two primary uses:
- To include the mandatory fields for a particular space in your agent struct. In this case you would use one of the minimal agent types as
AnotherAgentType
. - A convenient way to include fields from another, already existing struct.
The existing minimal agent types are:
All will attribute an id::Int
field, and besides NoSpaceAgent
will also attribute a pos
field. You should never directly manipulate the mandatory fields id, pos
that the resulting new agent type will have. The id
is an unchangeable field. Use functions like move_agent!
etc., to change the position.
Examples
Example without optional hierarchy
Using
@agent Person{T} GridAgent{2} begin
age::Int
moneyz::T
end
will 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
and then, one can even do
@agent Baker{T} Person{T} begin
breadz_per_day::T
end
which would make
mutable struct Baker{T} <: AbstractAgent
id::Int
pos::NTuple{2, Int}
age::Int
moneyz::T
breadz_per_day::T
end
Example with optional hierarchy
An alternative way to make the above structs, that also establishes a user-specific subtyping hierarchy would be to do:
abstract type AbstractHuman <: AbstractAgent end
@agent Worker GridAgent{2} AbstractHuman begin
age::Int
moneyz::Float64
end
@agent Fisher Worker AbstractHuman begin
fish_per_day::Float64
end
which would now make both Fisher
and Worker
subtypes of AbstractHuman
.
julia> supertypes(Fisher)
(Fisher, AbstractHuman, AbstractAgent, Any)
julia> supertypes(Worker)
(Worker, AbstractHuman, AbstractAgent, Any)
Note that Fisher
will not be a subtype of Worker
although Fisher
has inherited the fields from Worker
.
Example highlighting problems with parametric types
Notice that in Julia parametric types are union types. Hence, the following cannot be used:
@agent Dummy{T} GridAgent{2} begin
moneyz::T
end
@agent Fisherino{T} Dummy{T} begin
fish_per_day::T
end
You will get an error in the definition of Fisherino
, because the fields of Dummy{T}
cannot be obtained, because it is a union type. Same with using Dummy
. You can only use Dummy{Float64}
.
Example with common dispatch and no subtyping
It may be that you do not even need to create a subtyping relation if you want to utilize multiple dispatch. Consider the example:
@agent CommonTraits GridSpace{2} begin
age::Int
speed::Int
energy::Int
end
and then two more structs are made from these traits:
@agent Bird CommonTraits begin
height::Float64
end
@agent Rabbit CommonTraits begin
underground::Bool
end
If you wanted a function that dispatches to both Rabbit, Bird
, you only have to define:
Animal = Union{Bird, Rabbit}
f(x::Animal) = ... # uses `CommonTraits` fields
However, it should also be said, that there is no real reason here to explicitly type-annotate x::Animal
in f
. Don't annotate any type. Annotating a type only becomes useful if there are at least two "abstract" groups, like Animal, Person
. Then it would make sense to define
Person = Union{Fisher, Baker}
f(x::Animal) = ... # uses `CommonTraits` fields
f(x::Person) = ... # uses fields that all "persons" have
Agents.AbstractAgent
— TypeYourAgentType <: AbstractAgent
Agents participating in Agents.jl simulations are instances of user-defined Types that are subtypes of AbstractAgent
.
Your agent type(s) must have the id::Int
field as first field. If any space is used (see Available spaces), a pos
field of appropriate type is also mandatory. The core model structure, and each space, may also require additional fields that may, or may not, be communicated as part of the public API.
The @agent
macro ensures that all of these constrains are in place and hence it is the recommended way to generate new agent types.
3. The model
Once an agent is created (typically by instantiating a struct generated with @agent
), it can be added to a model using add_agent!
. Then, the agent can interact with the model and the space further by using e.g. move_agent!
or kill_agent!
. The "model" here stands for an instance of AgentBasedModel
.
Agents.AgentBasedModel
— TypeAgentBasedModel
An AgentBasedModel
is the supertype encompassing models in Agents.jl. All models are some concrete implementation of AgentBasedModel
and follow its interface (see below). ABM
is an alias to AgentBasedModel
.
A model is typically constructed with:
AgentBasedModel(AgentType [, space]; properties, kwargs...) → model
which creates a model expecting agents of type AgentType
living in the given space
. AgentBasedModel(...)
defaults to StandardABM
, which stores agents in a dictionary that maps unique IDs (integers) to agents. See also UnremovableABM
for better performance in case number of agents can only increase during the model evolution.
Agents.jl supports multiple agent types by passing a Union
of agent types as AgentType
. However, please have a look at Performance Tips for potential drawbacks of this approach.
space
is a subtype of AbstractSpace
, see Space for all available spaces. If it is omitted then all agents are virtually in one position and there is no spatial structure. 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.
Keywords
properties = nothing
: additional model-level properties that the user may decide upon and include in the model.properties
can be an arbitrary container of data, however it is most typically aDict
withSymbol
keys, or a composite type (struct
).scheduler = Schedulers.fastest
: is the scheduler that decides the (default) activation order of the agents. See the scheduler API for more options.rng = Random.default_rng()
: the random number generation stored and used by the model in all calls to random functions. Accepts any subtype ofAbstractRNG
.warn=true
: some type tests forAgentType
are done, and by default warnings are thrown when appropriate.
Interface of AgentBasedModel
Here we the most important information on how to query an instance of AgentBasedModel
:
model[id]
gives the agent with givenid
.abmproperties(model)
gives theproperies
container stored in the model.model.property
: If the model properties is a dictionary with key typeSymbol
, or if it is a composite type (struct
), then the syntaxmodel.property
will return the model property with key:property
.abmrng(model)
will return the random number generator of the model. It is strongly recommended to useabmrng(model)
to all calls torand
and similar functions, so that reproducibility can be established in your modelling workflow.abmscheduler(model)
will return the default scheduler of the model.
Many more functions exist in the API page, such as allagents
.
4. Evolving the model
In Agents.jl, an agent based model should be accompanied with least one and at most two stepping 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 must accept two arguments: first, an agent instance, and second, a model instance.
The model step function must accept one argument, that is the model. To use only a model step function, users can use the built-in dummystep
as the agent step function. This is typically the case for Advanced stepping.
The stepping functions are created using the API functions, and the Examples hosted in this documentation showcase several different variants.
After you have defined the stepping functions functions, you can evolve your model with step!
:
CommonSolve.step!
— Functionstep!(model::ABM, agent_step!, n::Int = 1)
step!(model::ABM, 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 remove 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).
See also Advanced stepping for stepping complex models where agent_step!
might not be convenient.
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.
Advanced stepping
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.
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. Notice that if you follow this road, the argument scheduler
given to AgentBasedModel
somewhat loses its meaning.
Here is an example:
function complex_step!(model)
for id in scheduler1(model)
agent_step1!(model[id], model)
end
intermediate_model_action!(model)
for id in scheduler2(model)
agent_step2!(model[id], model)
end
if model.step_counter % 100 == 0
model_action_every_100_steps!(model)
end
final_model_action!(model)
end
step!(model, dummystep, complex_step!, n)
For defining your own schedulers, see Schedulers.
5. Visualizations
Once you have defined a model and the stepping functions you can visualize the model statically or animate its time evolution straightforwardly in ~5 lines of code. This is discussed in a different page: Visualizations and Animations for Agent Based Models. Furthermore, all models in the Examples showcase plotting.
6. 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, and the ensemblerun!
that performs ensemble simulations and data collection.
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.
See also offline_run!
to write data to file while running the model.
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
dataname
. They create something like:mean_weight
or:maximum_f_x_pos
. In addition, you can use anonymous functions in a list comprehension to assign elements of an array into different columns:adata = [(a)->(a.interesting_array[i]) for i=1:N]
. Column names can also be renamed withDataFrames.rename!
after data is collected.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).Alternatively,
mdata
can also be a function. This is a "generator" function, that acceptsmodel
as input and provides aVector
that representsmdata
. Useful in combination with anensemblerun!
call that requires a generator function.
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::AbstractVector
, then data are collected 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.agents_first=true
: Whether to update agents first and then the model, or vice versa.showprogress=false
: Whether to show progress
The run!
function has been designed for maximum flexibility: nearly all scenarios of data collection are possible whether you need agent data, model data, aggregated data, or arbitrary combinations.
Nevertheless, 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.
As your models become more complex, it may not be advantageous to use lots of helper functions in the global scope to assist with data collection. If this is the case in your model, here's a helpful tip to keep things clean: use a generator function to collect data as instructed in the documentation string of run!
. For example:
function assets(model)
total_savings(model) = model.bank_balance + sum(model.assets)
function stategy(model)
if model.year == 0
return model.initial_strategy
else
return get_strategy(model)
end
end
return [:age, :details, total_savings, strategy]
end
run!(model, agent_step!, model_step!, 10; mdata = assets)
Seeding and Random numbers
Each model created by AgentBasedModel
provides a random number generator pool model.rng
which by default coincides with the global RNG. For performance and reproducibility reasons, one should never use rand()
without using a pool, thus throughout our examples we use rand(model.rng)
or rand(model.rng, 1:10, 100)
, etc.
Another benefit of this approach is deterministic models that can be run again and yield the same output. To do this, always pass a specifically seeded RNG to the model creation, e.g. rng = Random.MersenneTwister(1234)
.
Passing RandomDevice()
will use the system's entropy source (coupled with hardware like TrueRNG will invoke a true random source, rather than pseudo-random methods like MersenneTwister
). Models using this method cannot be repeatable, but avoid potential biases of pseudo-randomness.
An educative example
A simple, education-oriented example of using the basic Agents.jl API is given in Schelling's segregation model.