Chapter 25: Agent-Based Modeling with DisSModel¶
Part IV — DisSModel: Core and Paradigms
Implemented by the dissmodel-abm package.
Learning Objectives¶
By the end of this chapter you will be able to:
- Write an agent type as a Python class, group agents in a
Societyand place them in aCellSpace - Translate TerraME's
Agent,Society,CellularSpaceandcreatePlacementintodissmodel-abm, name for name - Make an agent-based model reproducible with one seeded random generator
- Use
Group, social networks and messages, and know the TerraME semantics they keep - Check an agent-based port against its original when the two cannot draw the same random numbers
# Standard imports
import numpy as np
import matplotlib.pyplot as plt
from dissmodel.core import Environment
Chapter 24's cellular automata all shared one constraint: a fixed grid, one rule applied identically to every cell, cells that neither move nor disappear. Real agents don't cooperate with that constraint — they walk, they compete for space, they're born and they die mid-run. This chapter is where DisSModel stops treating every spatial actor as a stationary cell.
Agents, Societies and a Cellular Space¶
dissmodel-abm follows TerraME's own vocabulary. An agent type is a Python class: its class attributes are the default state of every new agent, init() runs once when an agent is created, and execute() runs once per step. A Society holds the agents of one type. A CellSpace is the grid they live on, and placement puts each agent in a cell.
Three things are adapted to Python and to the rest of DisSModel:
- One clock. A model is an ordinary DisSModel
Model(Chapter 22), run byEnvironment. There is noTimerorEvent: the model'sexecute()says what happens in a step. - One random generator.
AgentModel(seed=...)creates a NumPy generator that every society, cell space and agent uses (self.rng,agent.rng). The same seed gives the same run, byte for byte. Never draw fromnumpy.randomorrandominside a model: those draws escape the seed. - Cells are raster bands. A cell's attributes are the bands of a
RasterBackend(Chapters 24 and 28), so agents read and write one cell at a time (cell.state = SOIL), while a rule that applies to every cell can update a whole band with NumPy, or run as adissmodel-cacellular automaton on the same backend.RasterMapdraws the bands.
The smallest complete model: walkers that move to an empty neighbor at every step and die with probability 0.01.
from dissmodel_abm import Agent, AgentModel, CellSpace, Society
class Walker(Agent):
age = 0 # default state of every new agent
def execute(self): # once per step
self.walk_to_empty()
self.age += 1
if self.rng.random() < 0.01: # the model's generator
self.die()
class Walkers(AgentModel):
def setup(self, dim=20, quantity=50):
self.cs = CellSpace.grid(dim)
self.cs.create_neighborhood() # Moore, as in TerraME
self.society = Society(Walker, quantity=quantity)
self.place(self.society, cellspace=self.cs) # random, one per cell
def execute(self):
self.society.execute()
env = Environment(start_time=1, end_time=100) # 100 steps, as TerraME's finalTime = 100
model = Walkers(seed=42)
env.run()
print("Agents alive after 100 steps:", len(model.society))
print("Agents counted in the n_agents band:", int(model.cs.backend.arrays["n_agents"].sum()))
Running from 1 to 100 (duration: 99) Agents alive after 100 steps: 19 Agents counted in the n_agents band: 19
Environment(start_time=1, end_time=100) runs the steps t = 1, ..., 100, exactly the steps of a TerraME Timer with finalTime = 100; the default start_time=0 would run one step more. Every cell space keeps a band n_agents with the number of agents in each cell, ready for RasterMap(backend=model.cs.backend, band="n_agents"). Run the cell again with seed=42 and the same number comes out; change the seed and it changes.
Concept Mapping: TerraME to dissmodel-abm¶
| TerraME | dissmodel-abm |
|---|---|
Model{init = function(model) ... end} |
class M(AgentModel) with setup() and execute() |
Timer{Event{action = soc}} |
self.society.execute() inside execute(); Environment is the clock |
random = true, Random():number() |
AgentModel(seed=...), self.rng, agent.rng |
Agent{energy = 40, execute = ...} |
class Rabbit(Agent): energy = 40 with def execute(self) |
init = function(agent) |
def init(self) |
Society{instance = ag, quantity = n} |
Society(Rabbit, quantity=n) |
CellularSpace{xdim = n} |
CellSpace.grid(n), or CellSpace(backend) for an existing RasterBackend |
Cell{state = function(cell) ... end} |
cs.add_agent_band("state", fn) |
cs:createNeighborhood{wrap = true} |
cs.create_neighborhood(wrap=True) |
Environment{cs, soc}:createPlacement{} |
self.place(soc, cellspace=cs) |
agent:getCell(), cell:getAgent(), cell:isEmpty() |
agent.cell, cell.agent, cell.is_empty() |
cell:getNeighborhood():sample() |
cell.neighborhood.sample() |
move, walk, walkToEmpty, die, reproduce |
move, walk, walk_to_empty, die, reproduce |
Group{target = soc, select = f}, filter, rebuild |
Group(soc, select=f), filter(), rebuild() |
Trajectory{target = cs, select = f} |
Trajectory(cs, select=f) |
soc:createSocialNetwork{quantity = 6} |
soc.create_social_network(quantity=6) |
agent:message{receiver = b, delay = 1}, on_message |
agent.message(b, delay=1), def on_message(self, msg) |
The package's docs/agent.md describes each piece, the semantics kept from TerraME (read from the source of TerraME's base package) and the places where it differs.
The dissmodel-abm Package¶
Like the other extension packages since Chapter 21, dissmodel-abm is not on PyPI; install the tagged release from GitHub:
pip install "git+https://github.com/DisSModel/dissmodel-abm.git@v0.2.0"
Six of the twelve models of TerraME's logo package (version 0.5.6) ship in dissmodel_abm.models, written to follow the Lua code rule by rule, with the original defaults:
dissmodel_abm.models |
TerraME logo (lua/) |
What it exercises |
|---|---|---|
SingleAgent |
SingleAgent.lua |
walkToEmpty |
Schelling |
Schelling.lua |
Group, Trajectory, wrapped neighborhood, one move per step |
PredatorPrey |
PredatorPrey.lua |
two societies, reproduce, die, a cell rule (pasture regrowth) |
LifeCycle |
LifeCycle.lua |
births and deaths by age, agents sharing a cell |
Ants |
Ants.lua |
void placement, enter, bands written by agents, evaporation |
Disease |
Disease.lua |
a social network redrawn every step, messages with delay; no space |
The other six — GrowingSociety, Heatbugs, Labyrinth, Overpopulation, SpatialPD and Sugarscape — have no port yet.
This chapter works through three in depth: PredatorPrey, which rebuilds Chapter 23's Lotka-Volterra system from individuals; Schelling, whose port hid a subtle difference from TerraME; and Disease, which uses social networks and messages instead of space.
Theory: Bottom-Up Modeling¶
Agent-based modeling appears under several names across the literature — ABM, multi-agent systems, individual-based modeling — spanning economics, sociology, ecology, and political science. What unifies them is a bottom-up approach: complex system behavior emerges from the interaction of discrete agents, rather than being specified as an aggregate equation the way Chapter 23's system dynamics models are. An agent is any actor able to affect itself, its environment, and other agents.
Helen Couclelis's classification of ABM applications, along two axes — natural versus artificial agent, natural versus artificial environment — places most of the models in this book in the same quadrant:
| Natural environment | Artificial environment | |
|---|---|---|
| Natural agent | Behavioral experiments | Descriptive model |
| Artificial agent | Engineering applications | e-science |
PredatorPrey, coming up next, sits in "descriptive model": its agents are meant to stand for real animals (natural agents, in this classification), placed in a deliberately simplified, artificial environment.
Nigel Gilbert's case for why ABM is worth its extra complexity, relative to Chapter 23's aggregate models, comes down to three things a bottom-up model represents directly instead of assuming: structure (it emerges from agent interaction rather than being imposed from outside), agency (agents have goals and beliefs that drive their actions), and dynamics (agents move, learn, and change position — spatially and socially — over the course of a run). ABM also handles qualitative and relational data that System Dynamics' continuous aggregate quantities simply can't represent — who is a given type, who is adjacent to whom.
Case Study: Predator-Prey, From Equation to Individual¶
Chapter 23 modeled predator and prey as two continuous stocks under Lotka-Volterra. TerraME's logo/PredatorPrey.lua rebuilds the same phenomenon bottom-up, with rabbits, wolves and a pasture that regrows, all sharing one cellular space. Each ODE term becomes an individual rule:
| ODE term | Agent rule in PredatorPrey.lua |
|---|---|
| prey growth | a rabbit eats the pasture of its cell (+20 energy) and, with 30 or more, gives birth in an empty neighbor, splitting its energy with the child |
| predation | a wolf that finds a rabbit in a neighboring cell eats it, gaining 20% of the rabbit's energy |
| predator growth | a wolf with 50 or more energy gives birth in an empty neighbor, halving its energy |
| mortality | every agent loses energy each step (rabbits 1, wolves 4) and dies below zero |
| resource limit | eaten pasture becomes soil and grows back on its fourth step |
-- PredatorPrey.lua (TerraME logo) -- rabbit agent shown
model.rabbit = Agent{
energy = 40,
name = "rabbit",
eat = function(self)
if self:getCell().state == "pasture" then
self:getCell().state = "soil"
self.energy = self.energy + 20
end
end,
execute = function(self)
local cell = self:getCell():getNeighborhood():sample()
if cell:isEmpty() then self:move(cell) end
self.energy = self.energy - 1
self:eat()
cell = self:getCell():getNeighborhood():sample()
if self.energy >= 30 and cell:isEmpty() then
local child = self:reproduce()
child:move(cell)
self.energy = self.energy / 2
child.energy = self.energy
end
if self.energy < 0 then self:die() end
end
}
The rabbit in dissmodel_abm.models.predator_prey is the same rule, line for line:
class Rabbit(Agent):
energy: float = 40
name = "rabbit"
def eat(self):
cell = self.cell
if cell.state == PASTURE:
cell.state = SOIL
self.energy += 20
def execute(self):
cell = self.cell.neighborhood.sample()
if cell.is_empty():
self.move(cell)
self.energy -= 1
self.eat()
cell = self.cell.neighborhood.sample()
if self.energy >= 30 and cell.is_empty():
child = self.reproduce()
child.move(cell)
self.energy /= 2
child.energy = self.energy
if self.energy < 0:
self.die()
The pasture is a cell rule, the same for every cell, so the model writes it with NumPy over the whole band instead of a loop over cells — the same choice Chapter 24's raster automata make:
def regrow(self):
state = self.cs.backend.arrays["state"]
count = self.cs.backend.arrays["count"]
soil = state == SOIL
count[soil] += 1
back = soil & (count == 4)
state[back] = PASTURE
count[back] = 0
def execute(self):
self.regrow() # cells first, as in the original Timer
self.rabbit_society.execute()
self.wolf_society.execute()
Execution — the package model with TerraME's defaults (a 30 × 30 space, 20 rabbits, 20 wolves), recording both populations after every step:
from dissmodel_abm.models import PredatorPrey
class RecordedPredatorPrey(PredatorPrey):
def setup(self, **params):
super().setup(**params)
self.history = [(self.rabbits, self.wolves)]
def execute(self):
super().execute()
self.history.append((self.rabbits, self.wolves))
env = Environment(start_time=1, end_time=200)
pp = RecordedPredatorPrey(seed=1)
env.run()
rabbits, wolves = np.array(pp.history).T
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.8))
ax1.plot(rabbits, label="rabbits", color="tab:blue")
ax1.plot(wolves, label="wolves", color="tab:red")
ax1.set_xlabel("step"); ax1.set_ylabel("agents"); ax1.legend()
ax2.plot(rabbits, wolves, color="0.3", lw=0.8)
ax2.set_xlabel("rabbits"); ax2.set_ylabel("wolves"); ax2.set_title("phase plane")
plt.tight_layout()
plt.show()
print("After 200 steps:", pp.rabbits, "rabbits,", pp.wolves, "wolves")
Running from 1 to 200 (duration: 199)
After 200 steps: 522 rabbits, 148 wolves
The rabbits fill most of the 900 cells within about 15 steps, limited by space and pasture rather than by the wolves; the wolves then grow on the abundant prey, eat the rabbits down, starve back, and the two populations oscillate. The phase plane does not close into Chapter 23's neat loop: the cycles are noisy, because they are made of individual births and deaths. Run it with other seeds and the oscillation keeps its shape while the details change — which is exactly why an agent-based port has to be compared with its original by distribution, as the last section of this chapter does.
Schelling: When a Port Looks Right and Isn't¶
Schelling's segregation model places agents of two kinds on a grid; an agent is unhappy when fewer than preference of its neighbors are of its own kind, and at each step one unhappy agent moves to a random empty cell. TerraME's version keeps the unhappy agents in a Group:
-- Schelling.lua (TerraME logo), the step
step = function()
model.unhappy_agents:filter() -- a Group of unhappy agents
if #model.unhappy_agents > 0 then
local myagent = model.unhappy_agents:sample()
local empty_cells = Trajectory{
target = model.cs,
select = function(cell) return cell:state() == "free" end
}
myagent:move(empty_cells:sample())
end
end
and the port in dissmodel_abm.models.schelling:
def setup(self, dim=25, free_space=0.05, preference=3):
...
self.unhappy_agents = Group(self.society, select=lambda a: a.is_unhappy(self.preference))
def execute(self):
self.unhappy_agents.filter()
if len(self.unhappy_agents) > 0:
agent = self.unhappy_agents.sample()
agent.move(self.cs.sample_empty())
The two read the same. The subtlety is in what filter() means. A first version of the port rebuilt the group from the whole society at every filter(), which is what most readers expect. TerraME's Group:filter does something narrower: it applies select again to the current members only; rebuilding from the society is a different method, rebuild(). So in TerraME an agent that became happy leaves the group for good — even if a later move makes it unhappy again — and the group empties while some agents are still unhappy. The next cell shows the two methods side by side:
from dissmodel_abm import Group
class Person(Agent):
happy = True
society = Society(Person, quantity=4, rng=np.random.default_rng(0))
society[2].happy = society[3].happy = False
unhappy = Group(society, select=lambda a: not a.happy)
print("group:", [a.id for a in unhappy])
society[2].happy = True # agent 2 becomes happy...
society[1].happy = False # ...and agent 1, outside the group, unhappy
unhappy.filter()
print("after filter():", [a.id for a in unhappy], " (current members only)")
society[2].happy = False # agent 2 is unhappy again
unhappy.filter()
print("after filter():", [a.id for a in unhappy], " (agent 2 does not come back)")
unhappy.rebuild()
print("after rebuild():", [a.id for a in unhappy], " (built again from the society)")
group: [2, 3] after filter(): [3] (current members only) after filter(): [3] (agent 2 does not come back) after rebuild(): [1, 2, 3] (built again from the society)
Execution — the package model with TerraME's defaults (25 × 25, 5% free space, preference = 3, 500 steps). unhappy is what TerraME's chart shows, the size of the group; percent_unhappy() counts every unhappy agent:
from dissmodel_abm.models import Schelling
from dissmodel_abm.models import schelling as sch
class RecordedSchelling(Schelling):
def setup(self, **params):
super().setup(**params)
self.history = [(self.unhappy, self.percent_unhappy())]
def execute(self):
super().execute()
self.history.append((self.unhappy, self.percent_unhappy()))
env = Environment(start_time=1, end_time=500)
seg = RecordedSchelling(seed=3)
env.run()
group, all_unhappy = np.array(seg.history).T
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.plot(group, label="size of the unhappy group (TerraME's chart)")
ax1.plot(all_unhappy, label="all unhappy agents")
ax1.set_xlabel("step"); ax1.set_ylabel("% of agents"); ax1.legend()
colors = [sch.COLORS[k] for k in (sch.FREE, sch.BRAZIL, sch.GERMANY)]
ax2.imshow(seg.cs.backend.arrays["state"], cmap=plt.matplotlib.colors.ListedColormap(colors),
vmin=0, vmax=2, interpolation="nearest")
ax2.set_title("after 500 steps"); ax2.set_xticks([]); ax2.set_yticks([])
plt.tight_layout()
plt.show()
print(f"After 500 steps: group {seg.unhappy:.1f} %, all unhappy {seg.percent_unhappy():.1f} %")
Running from 1 to 500 (duration: 499)
After 500 steps: group 0.0 %, all unhappy 4.9 %
The group empties and the moves stop, while a few percent of the agents are still unhappy: they became unhappy after leaving the group, and TerraME never looks at them again. Over 50 seeds, TerraME itself ends with 5.6% of the agents unhappy; the first version of the port ended with 0.4%. Nothing crashed and every rule looked right; only running the original many times, and comparing distributions, showed the difference.
Social Networks and Messages: Disease¶
Not every agent-based model needs space. TerraME's logo/Disease.lua spreads an infection through contacts: every infected agent sends a message to six agents drawn at random, each message infects a susceptible receiver with probability 0.25, and the network is drawn again at every step. In dissmodel-abm the network is created with create_social_network and the messages go through message and on_message:
class Person(Agent):
state = "susceptible"
counter = 0
def on_message(self, msg): # TerraME on_message
if self.state == "susceptible" and self.rng.random() < self.model.probability:
self.sick()
def execute(self):
if self.state == "infected":
for other in self.connections(): # forEachConnection
self.message(other, delay=1) # arrives at the start of the next step
...
with society.create_social_network(quantity=contacts, inmemory=False) in setup(): inmemory=False makes the network a function, so every call to connections() draws new contacts, as TerraME does. A message sent with delay=1 waits in the sender's society and is delivered by society.execute() at the start of the next step, before the agents act.
from dissmodel_abm.models import Disease
class RecordedDisease(Disease):
def setup(self, **params):
super().setup(**params)
self.history = [(self.susceptible, self.infected, self.recovered)]
def execute(self):
super().execute()
self.history.append((self.susceptible, self.infected, self.recovered))
env = Environment(start_time=1, end_time=30)
epi = RecordedDisease(seed=1)
env.run()
s, i, r = np.array(epi.history).T
plt.figure(figsize=(6, 3.6))
plt.plot(s, label="susceptible"); plt.plot(i, label="infected"); plt.plot(r, label="recovered")
plt.xlabel("step"); plt.ylabel("agents"); plt.legend()
plt.tight_layout()
plt.show()
print("Peak of infected:", int(i.max()), "at step", int(i.argmax()), "| never infected:", int(s[-1]))
Running from 1 to 30 (duration: 29)
Peak of infected: 646 at step 10 | never infected: 2
The curves have the shape of Chapter 23's SIR, but nothing here is an equation: they are counts of individual agents and messages. Compare them with Chapter 23's SIR model with the same contacts and probability.
Checking a Port When the Random Numbers Differ¶
Chapter 23's system dynamics models can be compared with TerraME value by value, and Chapter 24's deterministic automata cell by cell. An agent-based model draws random numbers at every step, and Lua and NumPy draw different ones, so a single run of the port and a single run of the original are not expected to agree. What should agree is the distribution: run both many times and compare.
The terrame-goldens repository holds what TerraME 2.0.1 computed running the logo models for seeds 1 to 50, and dissmodel-abm's test suite compares 20 runs of each port with them, at a few steps, with a two-sample Kolmogorov–Smirnov test and a test on the means:
| Model | Variable, step | TerraME (50 seeds) | Port (20 seeds) |
|---|---|---|---|
Schelling |
unhappy agents (%), t = 500 | 5.6 ± 0.8 | 5.6 ± 1.0 |
PredatorPrey |
rabbits, t = 20 | 855 ± 6 | 859 ± 7 |
LifeCycle |
agents, t = 40 | 101 ± 16 | 92 ± 12 |
Ants |
food left, t = 40 | 90.6 ± 3.1 | 90.3 ± 4.0 |
Disease |
never infected, t = 30 | 2.7 ± 1.8 | 2.6 ± 1.2 |
Three lessons came out of this comparison, and each is worth keeping beyond this package:
- One run of the original is not a reference. Before the goldens existed, the ports were compared with the single reference charts kept in
logo's own tests.AntsandDiseaseseemed to differ (97 food left instead of 90; 12 to 17 agents never infected instead of 2). Over 50 TerraME runs, both differences disappeared: the chart was one draw among many. - A distribution test finds what a plausible-looking run hides. The
Schellingdifference above was found this way, not by reading the code. - When a model has an exact answer, use it.
LifeCycleis a branching process whose mean population can be computed exactly (97.8 agents at t = 40). The 50 TerraME runs lie above it and the 20 runs of the port below it, both by chance; with 2,000 runs the port gives 98.1, and the package tests check it against the exact value.
What's Not There Yet¶
The package states its own limits (docs/agent.md):
- A vector space. The cellular space is raster only; a space of polygons from a
GeoDataFrame, with the neighborhoods ofdissmodel.geo.vector, is planned behind the same interface. - Several named placements, and neighborhoods other than Moore and von Neumann (TerraME's
mxn,functionandcoordstrategies). - State machines (
State,Jump,Flow). - Six
logomodels without a port, listed above.
Chapter 34's migration guide checks a TerraME model against this list before porting it.
Exercises¶
- One seed, one run. Run
Walkerstwice withseed=42and once withseed=7, and comparelen(model.society). Then replaceself.rng.random()inWalker.executewithnp.random.random()and run withseed=42twice again. What changed, and why does the package insist onself.rng? - Balance the ecosystem. Rerun
RecordedPredatorPreyon a 20 × 20 and on a 50 × 50 space (dim=20,dim=50). How does the size of the space change the oscillation, and does either population go extinct within 500 steps? - filter or rebuild. Change
Schelling.execute(in a subclass) to callself.unhappy_agents.rebuild()instead offilter(). Plot both curves of the Schelling cell again. Which of the two is the model TerraME's authors wrote, and which one is "Schelling's model" as you would describe it in words? - Contacts and probability. In
Disease, the product ofcontactsandprobabilityis the number of agents an infected agent infects per step. Find two pairs with the same product (for example 6 × 0.25 and 3 × 0.5) and compare their peaks over five seeds each. - A gap that matters to you. Pick one item from What's Not There Yet and describe, in a sentence, a model you'd want to build that needs it specifically.
# Your code here
Summary¶
Key concepts introduced¶
- An agent type as a Python class (
init(),execute(), class attributes as default state), aSocietyof agents, a rasterCellSpacewhose cell attributes are the bands of aRasterBackend, and TerraME's placement strategies - Three adaptations to DisSModel:
Environmentas the only clock, one seeded generator per model, and cells as raster bands that agents write one at a time and cell rules update with NumPy - The TerraME-to-
dissmodel-abmconcept mapping, name for name PredatorPreyas Chapter 23's Lotka-Volterra rebuilt from individuals;Schellingand the difference betweenGroup.filter()andGroup.rebuild();Diseasewith social networks and delayed messages- Comparing an agent-based port with its original by distribution, over many runs, and why one reference run is not enough
- What
dissmodel-abmdoes not do yet — vector space, several placements, state machines, sixlogomodels — which Chapter 34's migration guide checks against
Chapter 26 leaves the general-purpose paradigm chapters behind and turns to a specific domain: land use and cover change, built on the same Model foundation this Part has spent five chapters establishing.
Further Reading¶
- Gilbert, N. (2008). Agent-Based Models. Sage Publications — a concise case for bottom-up modeling over aggregate equations
- Couclelis, H. (2001). "Why I no longer work with agents." In Agent-Based Models of Land-Use and Land-Cover Change
- Schelling, T. C. (1971). "Dynamic models of segregation." Journal of Mathematical Sociology, 1(2), 143–186
- TerraME's
logopackage, source of the six ported models: https://github.com/TerraME/logo dissmodel-abmon GitHub, withdocs/agent.mdon the semantics kept from TerraME: https://github.com/DisSModel/dissmodel-abmterrame-goldens, the TerraME runs the ports are checked against: https://github.com/LambdaGeo/terrame-goldens (v0.1.0, https://doi.org/10.5281/zenodo.23592970)