In Agents.jl a central structure maps unique IDs (integers) to agent instances, similar to a dictionary. 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. This is defined as a standard Julia
structand contains two mandatory fields
id, pos, with the position field being appropriate for the chosen space.
- The created agent type, the chosen space, and optional additional model level properties (typically in the form of a dictionary) are provided in our universal structure
AgentBasedModel. This instance defines the model within an Agents.jl simulation. Further options are also available, regarding schedulers and random number generation.
- 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 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.
- Collect data. To do this, specify which data should be collected, by providing one standard Julia
Vectorof data-to-collect for agents, and another one for the model, for example
[:mood, :wealth]. The outputted data are in the form of a
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.
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.
The available spaces are:
GraphSpace: Agent positions are equivalent with nodes of a graph/network.
GridSpace: Space is discretized into boxes, typical style for cellular automata.
ContinuousSpace: Truthful representation of continuous space, regarding location, orientation, and identification of neighboring agents.
OpenStreetMapSpace: A space based on Open Street Map, where agents are confined to move along streets of the map, using real-world meter values for the length of each street.
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.
YourAgentType <: AbstractAgent
Agents participating in Agents.jl simulations are instances of user-defined Types that are subtypes of
AbstractAgent. It is almost always the case that mutable Types make for a simpler modelling experience.
Your agent type(s) 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".
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
For more functions visit the API page.
AgentBasedModel(AgentType [, space]; properties, kwargs...) → 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
The agents are stored in a dictionary that maps unique IDs (integers) to agents. Use
model[id] to get the agent with the given
space is a subtype of
AbstractSpace, see Space for all available spaces. If it is ommited then all agents are virtually in one position and there is 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.
Note: 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.
properties = nothing is additional model-level properties (typically a dictionary) that can be accessed as
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 for structs). This syntax can't be used for
agents, space, scheduler, properties, rng, maxid, which are the fields of
scheduler = Schedulers.fastest decides the order with which agents are activated (see e.g.
Schedulers.by_id and the scheduler API).
scheduler is only meaningful if an agent-stepping function is defined for
run!, otherwise a user decides a scheduler in the model-stepping function, as illustrated in the Advanced stepping part of the tutorial.
rng = Random.default_rng() provides random number generation to the model. Accepts any subtype of
AbstractRNG and is accessed by
warn=true: Type tests for
AgentType are done, and by default warnings are thrown when appropriate.
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. This is typically the case for Advanced stepping.
After you have defined these two functions, you evolve your model with
step!(model, agent_step!, n::Int = 1) step!(model, agent_step!, model_step!, n::Int = 1, agents_first::Bool = true)
n steps according to the stepping function
agent_step!. Agents will be activated as specified by the
model_step! is triggered after every scheduled agent has acted, unless the argument
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
s is the current amount of steps taken, starting from 0. For this method of
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.
Use instead of
step! if no function is useful to be defined.
Use instead of
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.
The interface of
step!, which allows the option of both
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
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
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 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 schedulers, see Schedulers.
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.
run!(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
DataFrames, one for agent-level data and one for model-level data.
adata::Vectormeans "agent data to collect". If an entry is a
:weight, then the data for this entry is agent's field
weight. If an entry is a
f, then the data for this entry is just
f(a)for each agent
a. The resulting dataframe columns are named with the input symbol (here
adatais 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 have
adata = [(: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>5with
(: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
: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 with
DataFrames.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
mymean(a) = isempty(a) ? 0.0 : mean(a).
mdata::Vectormeans "model data to collect" and works exactly like
adata. For the model, no aggregation is possible (nothing to aggregate over).
mdata::Functionuse a generator function that accepts
modelas input and provides a
Vector. Useful in combination with an
ensemblerun!call that requires a generator function.
By default both keywords are
nothing, i.e. nothing is collected/aggregated.
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
a2 (type: Agent2) has no
a2(a) = a isa Agent2; adata = [(:weight, sum, a2)] to filter out the missing results.
when=true: at which steps
sto perform the data collection and processing. A lot of flexibility is offered based on the type of
when::Vector, then data are collect if
s ∈ when. Otherwise data are collected if
true. By default data are collected in every step.
when_model = when: same as
whenbut for model data.
obtainer = identity: method to transfer collected data to the
DataFrame. Typically only change this to
copyif some data are mutable containers (e.g.
Vector) which change during evolution, or
deepcopyif 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.
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.
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.
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)
Each model created by
AgentBasedModel provides a random number generator pool
model.rng which by default coincides with the global RNG. For performance reasons, one should never use
rand() without using a pool, thus throughout our examples we use
rand(model.rng, 1:10, 100), etc.
Another benefit of this approach is deterministic models that can be ran again and yield the same output. To do this, either always pass a specifically seeded RNG to the model creation, e.g.
MersenneTwister(1234), or call
seed!(model, 1234) (with any number) after creating the model but before actually running the simulation.
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.
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. 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.