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:

  1. An AgentBasedModel instance.
  2. A space instance.
  3. A subtype of AbstractAgent for the agents.

To run simulations and collect data, the following are also necessary

  1. Stepping functions that controls how the agents and the model evolve.
  2. Specifying which data should be collected from the agents and/or the model.

1. The model

Agents.AgentBasedModelType
AgentBasedModel(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.GraphSpaceType
GraphSpace(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.GridSpaceType
GridSpace(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.ContinuousSpaceType
ContinuousSpace(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 not
  • extend::NTuple{D} = ones : Extend of space. The d dimension starts at 0 and ends at extend[d]. If periodic = true, this is also when periodicity occurs. If periodic ≠ 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.AbstractAgentType

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.

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!Function
step!(model, agent_step!, n::Integer = 1)
step!(model, agent_step!, model_step!, n::Integer = 1)

Update agents n steps. Agents will be updated as specified by the model.scheduler. In the second version model_step! is triggered after every scheduled agent has acted.

step!(model, agent_step!, model_step!, n::Function)

n can be also be 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). (in this case model_step! must be provided always)

Agents.dummystepFunction
dummystep(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!Function
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.

Data-deciding keywords

  • adata::Vector means "agent data to collect". If an entry is a Symbol, e.g. :weight, then the data for this entry is agent's field weight. If an entry is a Function, e.g. 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 :weight, :f).

  • adata::Vector{<:Tuple}: if adata 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 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[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, pass mymean(a) = isempty(a) ? 0.0 : mean(a).

  • mdata::Vector means "model data to collect" and works exactly like adata. 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 steps s to perform the data collection and processing. A lot of flexibility is offered based on the type of when. If when::Vector, then data are collect if s ∈ when. Otherwise data are collected if when(model, s) returns true. By default data are collected in every step.
  • when_model = when : same as when but for model data.
  • obtainer = identity : method to transfer collected data to the DataFrame. Typically only change this to copy if some data are mutable containers (e.g. Vector) which change during evolution, or deepcopy if some data are nested mutable containers. Both of these options have performance penalties.
  • replicates=0 : Run replicates replicates of the simulation.
  • parallel=false : Only when replicates>0. Run replicate simulations in parallel.

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.