Event-driven continuous-time multiagent model

Event-driven continuous-time multiagent model#

The spatial rock-paper-scissors (RPS) is an ABM with the following rules:

  • Agents can be any of three “kinds”: Rock, Paper, or Scissors.

  • Agents live in a 2D periodic grid space allowing only one agent per cell.

  • When an agent activates, it can do one of three actions:

    1. Attack: choose a random nearby agent and attack it. If the agent loses the RPS game it gets removed.

    2. Move: choose a random nearby position. If it is empty move to it, otherwise swap positions with the agent there.

    3. Reproduce: choose a random empty nearby position (if any exist). Generate there a new agent of the same type.

using Agents
using Random
using LinearAlgebra
using Base64
using Agents.DataFrames
using CairoMakie
CairoMakie.activate!(px_per_unit = 1.0)

function display_mp4(filename)
    display("text/html", string("""<video autoplay controls><source src="data:video/x-m4v;base64,""",
        Base64.base64encode(open(read, filename)), """" type="video/mp4"></video>"""))
end
display_mp4 (generic function with 1 method)

Rock, Paper, or Scissors (RPS) agent The agent type could be obtained via kindof(agent)

@multiagent struct RPS(GridAgent{2})
    @subagent struct Rock end
    @subagent struct Paper end
    @subagent struct Scissors end
end

Attack actions

function attack!(agent, model)
    # Randomly pick a nearby agent
    contender = random_nearby_agent(agent, model)
    # do nothing if there isn't anyone nearby
    isnothing(contender) && return
    # else perform standard rock paper scissors logic
    # and remove the contender if you win.
    # Remember to compare agents with `kindof` instead of
    # `typeof` since we use `@multiagent`
    kind = kindof(agent)
    kindc = kindof(contender)
    if kind === :Rock && kindc === :Scissors
        remove_agent!(contender, model)
    elseif kind === :Scissors && kindc === :Paper
        remove_agent!(contender, model)
    elseif kind === :Paper && kindc === :Rock
        remove_agent!(contender, model)
    end
    return
end
attack! (generic function with 1 method)

Move actions Use move_agent! and swap_agents! functions

function move!(agent, model)
    rand_pos = random_nearby_position(agent.pos, model)
    if isempty(rand_pos, model)
        move_agent!(agent, rand_pos, model)
    else
        occupant_id = id_in_position(rand_pos, model)
        occupant = model[occupant_id]
        swap_agents!(agent, occupant, model)
    end
    return
end
move! (generic function with 1 method)

Reproduce actions Use replicate! function

function reproduce!(agent, model)
    pos = random_nearby_position(agent, model, 1, pos -> isempty(pos, model))
    isnothing(pos) && return
    # pass target position as a keyword argument
    replicate!(agent, model; pos)
    return
end
reproduce! (generic function with 1 method)

Defining the propensity (“rate” in Gillespie stochastic simulations) and timing of the events

attack_propensity = 1.0
movement_propensity = 0.5
reproduction_propensity(agent, model) = cos(abmtime(model))^2
reproduction_propensity (generic function with 1 method)

Register events with AgentEvent structures

attack_event = AgentEvent(action! = attack!, propensity = attack_propensity)
reproduction_event = AgentEvent(action! = reproduce!, propensity = reproduction_propensity)
AgentEvent{typeof(reproduce!), typeof(reproduction_propensity), Nothing, typeof(Agents.exp_propensity)}(reproduce!, reproduction_propensity, nothing, Agents.exp_propensity)

We want a different distribution other than exponential dist. for movement time

function movement_time(agent, model, propensity)
    # Make time around 1
    t = 0.1 * randn(abmrng(model)) + 1
    return clamp(t, 0, Inf)
end
movement_time (generic function with 1 method)

Also the rocks do not move

movement_event = AgentEvent(
    action! = move!, propensity = movement_propensity,
    kinds = (:Scissors, :Paper), timing = movement_time
)
AgentEvent{typeof(move!), Float64, Tuple{Symbol, Symbol}, typeof(movement_time)}(move!, 0.5, (:Scissors, :Paper), movement_time)

Those are all the events

events = (attack_event, reproduction_event, movement_event)
(AgentEvent{typeof(attack!), Float64, Nothing, typeof(Agents.exp_propensity)}(attack!, 1.0, nothing, Agents.exp_propensity), AgentEvent{typeof(reproduce!), typeof(reproduction_propensity), Nothing, typeof(Agents.exp_propensity)}(reproduce!, reproduction_propensity, nothing, Agents.exp_propensity), AgentEvent{typeof(move!), Float64, Tuple{Symbol, Symbol}, typeof(movement_time)}(move!, 0.5, (:Scissors, :Paper), movement_time))

Model factory function EventQueueABM for an event-driven ABM

function initialize_rps(; n = 100, nx = n, ny = n, seed = 42)
    space = GridSpaceSingle((nx, ny))
    rng = Xoshiro(seed)
    model = EventQueueABM(RPS, events, space; rng, warn = false)
    for p in positions(model)
        # Randomly assign one of the agent
        type = rand(abmrng(model), (Rock, Paper, Scissors))
        add_agent!(p, type, model)
    end
    return model
end
initialize_rps (generic function with 1 method)

Create model

model = initialize_rps()
EventQueueABM{GridSpaceSingle{2, true}, RPS, Dict{Int64, RPS}, Nothing, Tuple{AgentEvent{typeof(attack!), Float64, Nothing, typeof(Agents.exp_propensity)}, AgentEvent{typeof(reproduce!), typeof(reproduction_propensity), Nothing, typeof(Agents.exp_propensity)}, AgentEvent{typeof(move!), Float64, Tuple{Symbol, Symbol}, typeof(movement_time)}}, Xoshiro, Vector{Vector{Int64}}, Vector{Vector{Float64}}, Vector{Vector{Int64}}, DataStructures.BinaryHeap{Pair{Tuple{Int64, Int64}, Float64}, Base.Order.By{typeof(last), Base.Order.ForwardOrdering}}}(Dict{Int64, RPS}(4986 => Rock(4986, (86, 50))::RPS, 7329 => Scissors(7329, (29, 74))::RPS, 4700 => Rock(4700, (100, 47))::RPS, 4576 => Scissors(4576, (76, 46))::RPS, 7144 => Scissors(7144, (44, 72))::RPS, 6073 => Rock(6073, (73, 61))::RPS, 2288 => Scissors(2288, (88, 23))::RPS, 1703 => Paper(1703, (3, 18))::RPS, 1956 => Paper(1956, (56, 20))::RPS, 8437 => Paper(8437, (37, 85))::RPS…), GridSpaceSingle with size (100, 100), metric=chebyshev, periodic=true, nothing, Xoshiro(0xa405ec0752c2028c, 0x3d4f218ef98f3afe, 0x91f0bfc460469f65, 0x1c338e1be8e3d11e, 0xc90c4a0730db3f7e), Base.RefValue{Int64}(10000), Base.RefValue{Float64}(0.0), (AgentEvent{typeof(attack!), Float64, Nothing, typeof(Agents.exp_propensity)}(attack!, 1.0, nothing, Agents.exp_propensity), AgentEvent{typeof(reproduce!), typeof(reproduction_propensity), Nothing, typeof(Agents.exp_propensity)}(reproduce!, reproduction_propensity, nothing, Agents.exp_propensity), AgentEvent{typeof(move!), Float64, Tuple{Symbol, Symbol}, typeof(movement_time)}(move!, 0.5, (:Scissors, :Paper), movement_time)), Dict(:Paper => 2, :Rock => 1, :Scissors => 3), [[1, 2], [1, 2, 3], [1, 2, 3]], [[1.0, 1.0], [1.0, 1.0, 0.5], [1.0, 1.0, 0.5]], [[2], [2], [2]], DataStructures.BinaryHeap{Pair{Tuple{Int64, Int64}, Float64}, Base.Order.By{typeof(last), Base.Order.ForwardOrdering}}(Base.Order.By{typeof(last), Base.Order.ForwardOrdering}(last, Base.Order.ForwardOrdering()), [(6080, 1) => 2.9790681020079457e-6, (3987, 1) => 0.0003364593569030146, (6221, 2) => 0.0004887948187761659, (8424, 1) => 0.0006778319693496694, (5035, 1) => 0.0012044922191219774, (7014, 2) => 0.0005771225059120387, (3301, 2) => 0.0011396494447446436, (2505, 1) => 0.0014309955844005063, (5041, 2) => 0.001449028423933035, (5539, 2) => 0.0024520873429744127  …  (9990, 1) => 1.0666363792018059, (9992, 2) => 0.7429399475482082, (9993, 1) => 1.9378465659189394, (4997, 1) => 1.7907183401427267, (2496, 1) => 0.5304394096759053, (2499, 1) => 2.7442278746073216, (4998, 3) => 1.0804360033507288, (4999, 1) => 5.80807081086072, (9998, 1) => 0.9433255253597445, (1250, 3) => 1.258669914970242]), true, true)

Have a look at the event queue

abmqueue(model)
DataStructures.BinaryHeap{Pair{Tuple{Int64, Int64}, Float64}, Base.Order.By{typeof(last), Base.Order.ForwardOrdering}}(Base.Order.By{typeof(last), Base.Order.ForwardOrdering}(last, Base.Order.ForwardOrdering()), [(6080, 1) => 2.9790681020079457e-6, (3987, 1) => 0.0003364593569030146, (6221, 2) => 0.0004887948187761659, (8424, 1) => 0.0006778319693496694, (5035, 1) => 0.0012044922191219774, (7014, 2) => 0.0005771225059120387, (3301, 2) => 0.0011396494447446436, (2505, 1) => 0.0014309955844005063, (5041, 2) => 0.001449028423933035, (5539, 2) => 0.0024520873429744127  …  (9990, 1) => 1.0666363792018059, (9992, 2) => 0.7429399475482082, (9993, 1) => 1.9378465659189394, (4997, 1) => 1.7907183401427267, (2496, 1) => 0.5304394096759053, (2499, 1) => 2.7442278746073216, (4998, 3) => 1.0804360033507288, (4999, 1) => 5.80807081086072, (9998, 1) => 0.9433255253597445, (1250, 3) => 1.258669914970242])

The time in EventQueueABM is continuous, so we can pass real-valued time

step!(model, 123.456)
nagents(model)
9760

step! also accepts a terminating condition

function terminate(model, t)
    willterm = length(allagents(model)) < 5000
    return willterm || (t > 1000.0)
end

model = initialize_rps()
step!(model, terminate)
abmtime(model)
1000.0004228831673

Data collection#

adata: aggregated data to extract information from the execution stats adf: agent data frame

model = initialize_rps()
adata = [(a -> kindof(a) === X, count) for X in allkinds(RPS)]

adf, mdf = run!(model, 100.0; adata, when = 0.5, dt = 0.01)
adf[1:10, :]
10×4 DataFrame
Rowtimecount_#21_X=Rockcount_#21_X=Papercount_#21_X=Scissors
Float64Int64Int64Int64
10.0329333723335
20.5321532513185
31.0320632043049
41.5315131062887
52.0304429892667
62.51294428822406
73.02294228872319
83.53310829852345
94.04324031062360
104.55324131352276

Visualize population change#

tvec = adf[!, :time]  ## time as x axis
populations = adf[:, Not(:time)]  ## agents as data
alabels = ["rocks", "papers", "scissors"]

fig = Figure();
ax = Axis(fig[1,1]; xlabel = "time", ylabel = "population")
for (i, l) in enumerate(alabels)
    lines!(ax, tvec, populations[!, i]; label = l)
end
axislegend(ax)
fig

Visualize agent distribution#

const colormap = Dict(:Rock => "black", :Scissors => "gray", :Paper => "orange")
agent_color(agent) = colormap[kindof(agent)]
plotkw = (agent_color, agent_marker = :rect, agent_size = 5)
fig, ax, abmobs = abmplot(model; plotkw...)

fig

Animation#

model = initialize_rps()
abmvideo("rps_eventqueue.mp4", model;
    dt = 0.5, frames = 300,
    title = "Rock Paper Scissors (event based)",
    plotkw...,
)

display_mp4("rps_eventqueue.mp4")

This notebook was generated using Literate.jl.