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.
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 node 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, separated into discrete and continuous categories (notice that using a space is not actually necessary).
The discrete possibilities, often summarized as DiscreteSpace
, are
Agents.GraphSpace
— TypeGraphSpace(graph::AbstractGraph)
Create a GraphSpace
instance that is underlined by an arbitrary graph from LightGraphs.jl. In this case, your agent type must have a pos
field that is of type Int
.
Agents.GridSpace
— TypeGridSpace(dims::NTuple; periodic = false, moore = false) → GridSpace
Create a GridSpace
instance that represents a grid of dimensionality length(dims)
, with each dimension having the size of the corresponding entry of dims
. Such grids are typically used in cellular-automata-like models. In this case, your agent type must have a pos
field that is of type NTuple{N, Int}
, where N
is the number of dimensions.
The two keyword arguments denote if the grid should be periodic on its ends, and if the connections should be of type Moore or not (in the Moore case the diagonal connections are also valid. E.g. for a 2D grid, each node has 8 neighbors).
and the continuous version is
Agents.ContinuousSpace
— TypeContinuousSpace(D::Int [, update_vel!]; kwargs...)
Create a ContinuousSpace
of dimensionality D
. In this case, your agent positions (field pos
) should be of type NTuple{D, F}
where F <: AbstractFloat
. In addition, the agent type should have a third field vel::NTuple{D, F}
representing the agent's velocity to use move_agent!
.
The optional 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 arbitrary force fields acting on the agents (e.g. some magnetic field). By default no update is done this way.
Notice that if you need to write your own custom move_agent
function, call update_space!
at the end, like in e.g. the Bacterial Growth example.
Keywords
periodic = true
: whether continuous space is periodic or notextend::NTuple{D} = ones
: Extend of space. Thed
dimension starts at 0 and ends atextend[d]
. Ifperiodic = true
, this is also when periodicity occurs. Ifperiodic ≠ true
,extend
is only used at plotting.metric = :cityblock
: metric that configures distances for finding nearest neighbors in the space. The other option is:euclidean
but cityblock is faster (due to internals).
Note: if your model requires linear algebra operations for which tuples are not supported, a performant solution is to convert between Tuple and SVector using StaticArrays.jl as follows: s = SVector(t)
and back with t = Tuple(s)
.
3. The agent
Agents.AbstractAgent
— TypeAll 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.
Your agent type may have other additional fields relevant to your system, for example variable quantities like "status" or other "counters".
Examples
Imagine agents who have extra properties weight, happy
. For a GraphSpace
we would define them like
mutable struct ExampleAgent <: AbstractAgent
id::Int
pos::Int
weight::Float64
happy::Bool
end
while for e.g. a ContinuousSpace
we would use
mutable struct ExampleAgent <: AbstractAgent
id::Int
pos::NTuple{2, Float64}
vel::NTuple{2, Float64}
weight::Float64
happy::Bool
end
where vel
is optional, useful if you want to use move_agent!
in continuous space.
The agent type must be mutable. 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!(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)
Ignore the model dynamics. Use instead of model_step!
.
dummystep(agent, model)
Ignore the agent dynamics. Use instead of agent_step!
.
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!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.
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 needed and to the data structure that they need.
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.