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:
- Understand the
Society/Agentprotective layer over the vector substrate - Translate
Agent/Societyconcepts from TerraME todissmodel-abm - Write an agent-based model without touching
self.gdfdirectly - Know which models ship today, and what's explicitly still missing
# Standard imports
import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
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 pretending every spatial actor is a stationary cell.
Society and Agent: A Protective Layer¶
The whole point of dissmodel-abm is that model code never touches self.gdf directly. Killing an agent whose energy has run out, in raw GeoDataFrame terms, looks like this:
self.gdf = self.gdf[self.gdf["energy"] > 0].reset_index(drop=True)
Through self.society, the same rule reads as an object-oriented loop:
for agent in self.society:
if agent.energy <= 0:
agent.die()
Society owns no data of its own — it reads and writes through the host model's gdf attribute, so model.gdf and model.society are always views onto the same rows. Agent is a thin proxy over one row: agent.energy = 5 writes the underlying cell directly, no separate copy to keep synchronized. Map, Chart, and every ModelExecutor from Chapter 22 keep working completely unmodified underneath, whether or not a given model's execute() ever mentions self.society — AgentModel is a SpatialModel subclass with a lazily-created society property, not a parallel class hierarchy competing with Chapter 21's lifecycle.
import geopandas as gpd
import numpy as np
from dissmodel.core import Environment, Model
from dissmodel_abm.core import AgentModel
n = 20
bounds = (0, 0, 100, 100)
rng = np.random.default_rng(42)
gdf = gpd.GeoDataFrame({
"energy": rng.uniform(5, 12, n),
"geometry": gpd.points_from_xy(
rng.uniform(bounds[0], bounds[2], n),
rng.uniform(bounds[1], bounds[3], n),
),
})
class EnergyDrain(AgentModel):
def execute(self):
for agent in self.society:
agent.energy -= 2.0
if agent.energy <= 0:
agent.die()
env = Environment(start_time=0, end_time=3)
model = EnergyDrain(gdf=gdf)
print("Agents before:", len(model.gdf))
env.run()
print("Agents after: ", len(model.gdf))
Agents before: 20 Running from 0 to 3 (duration: 3) Agents after: 15
Agents without a location. Following TerraME — where an agent may exist with no placement until it's explicitly given one — an agent here can exist with geometry = None: society.add(energy=4.0) creates one, agent.has_location reports False, and agent.enter(x, y) gives it a position later. Calling a spatial method (walk, neighbors, distance_to) on a location-less agent raises a clear RuntimeError rather than failing deep inside geopandas on a None geometry.
One structural difference is worth naming explicitly: TerraME agents are autonomous objects that carry their own behavior, each with its own execute. dissmodel-abm agents are data with a uniform interface — behavior lives once, in the owning model's execute(), applied identically to every agent via for agent in self.society. That trades TerraME's per-agent heterogeneous behavior for staying close to a vectorizable substrate, the identical trade-off Chapter 24 already made for CellularAutomaton.rule(idx).
orphan = model.society.add(energy=4.0) # no position yet
print("has_location:", orphan.has_location)
orphan.enter(10, 10)
print("has_location after enter():", orphan.has_location, " geometry:", orphan.geometry)
orphan.leave()
print("has_location after leave():", orphan.has_location, " agent still in society:", orphan in list(model.society))
has_location: False has_location after enter(): True geometry: POINT (10 10) has_location after leave(): False agent still in society: True
Concept Mapping: TerraME to dissmodel-abm¶
TerraME (Agent/Society) |
dissmodel-abm |
|---|---|
execute(self) |
execute() (Model lifecycle, Chapter 21) |
init(self) |
setup() (Model lifecycle, Chapter 21) |
Society (collection of Agents) |
self.society — object-oriented view over self.gdf |
Agent |
self.society[idx] — a proxy over one row |
placement / getCell() |
agent.geometry |
enter(cell) |
agent.enter(x, y) |
leave() |
agent.leave() |
move(cell) / walk() |
agent.move_to(x, y) / agent.walk(step_size, bounds) |
die() |
agent.die() |
reproduce() |
agent.reproduce(**overrides) |
| neighborhood | agent.neighbors(radius) (points) or agent.grid_neighbors() (cells) |
Society:add / Society:remove |
society.add(**attrs) / society.remove(agent_or_idx) |
Society:sample |
society.sample(n) |
forEachAgent |
for agent in society: ... |
addSocialNetwork / message |
not provided yet |
State / Jump / Flow |
not provided yet |
Two agent layouts recur across the shipped models. Point-agent models (RandomWalkModel, PredatorPreyModel) back self.society with Point geometry plus ordinary state columns — agents move continuously through space. One-agent-per-cell models (SchellingModel) back it with a polygon grid instead, vector_grid() from Chapter 7 — agents occupy discrete cells and can only move to another empty one.
Installation and Quick Start¶
Like every other extension package since Chapter 22, dissmodel-abm has no PyPI release yet:
git clone https://github.com/DisSModel/dissmodel-abm.git
cd dissmodel-abm
pip install -e .
Writing a new model looks exactly like writing a dissmodel-ca model (Chapter 24), swapping CellularAutomaton.rule(idx) for a society loop:
from dissmodel_abm.core import AgentModel
class MyModel(AgentModel):
def setup(self, **params):
... # one-time initialization
def execute(self):
for agent in self.society:
agent.walk(step_size=1.0, bounds=(0, 0, 100, 100))
if agent.energy <= 0:
agent.die()
elif agent.energy >= 15:
agent.reproduce(energy=5.0)
Run any shipped model directly, or explore it interactively — the same CLI/Streamlit split used since Chapter 22:
python examples/cli/abm_random_walk.py
python examples/cli/abm_predator_prey.py
python examples/cli/abm_schelling.py
pip install -e ".[viz]"
streamlit run examples/streamlit/abm_predator_prey.py # Map + population Chart
streamlit run examples/streamlit/abm_schelling.py # Map + satisfaction Chart
Ants and Labyrinth (§ below) currently ship a Streamlit example each but no CLI script yet —
worth checking examples/cli/ directly before assuming parity across all five models.
The shipped RandomWalkModel is the minimal working version of exactly that pattern:
from dissmodel_abm.models import RandomWalkModel
env = Environment(start_time=0, end_time=20)
walk_model = RandomWalkModel(gdf=gdf.copy(), step_size=2.0, bounds=bounds)
env.run()
print("Sample agent final position:", walk_model.gdf.geometry.iloc[0])
Running from 0 to 20 (duration: 20) Sample agent final position: POINT (80.30832800644534 45.120394683077485)
Repository Structure¶
dissmodel-abm/
├── src/dissmodel_abm/
│ ├── core/
│ │ ├── agent_model.py # AgentModel(SpatialModel) — no core changes
│ │ └── society.py # Society / Agent — the protective layer
│ └── models/
│ ├── random_walk.py # minimal example (point agents)
│ ├── predator_prey.py # wolf-sheep, written against self.society
│ ├── schelling.py # segregation, one agent per cell
│ ├── labyrinth.py # maze walkers — cell + point agents, one gdf
│ └── ants.py # pheromone foraging — cell + point agents, one gdf
├── examples/{cli,streamlit}/ # same convention as Chapters 4-5 (Chapter 22, 24)
├── tests/test_agent_model.py
└── docs/agent.md # full Agent reference, mirroring TerraME's own docs
AgentModel is a SpatialModel subclass — no changes to dissmodel's core are required, the same
"minimal core, additive extensions" principle every satellite package follows (Chapter 30). All other
SpatialModel/Model functionality — self.env, create_neighborhood, plot tracking, the
ModelExecutor/ExperimentRecord pipeline — is inherited unchanged.
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 |
PredatorPreyModel, coming up next, sits squarely in "descriptive model" — artificial agents standing in for real animals, inside 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 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. This section rebuilds the same phenomenon bottom-up, translating each ODE parameter into an individual agent rule:
| ODE parameter | Agent rule |
|---|---|
r — prey growth |
eating pasture raises energy; above a threshold, reproduce (energy halved) |
m — predator mortality |
dies at energy ≤ 0 (applies to both kinds) |
a — predation |
a predator within eat_radius of prey kills it |
b — growth from predation |
predator gains energy from the kill; above a threshold, reproduces |
PredatorPreyModel runs on continuous space — Point geometry, an eat_radius search — and splits this logic into five explicit phases each tick: movement, metabolism, predation, death, reproduction. Building the starting population requires two agent kinds sharing one GeoDataFrame:
TerraME's own logo/PredatorPrey.lua ties the same rules to a CellularSpace instead of continuous
space — eating turns a pasture cell to soil, predation and reproduction go through
getCell():getNeighborhood():sample():
model.wolf = Agent{
energy = 40,
execute = function(self)
local cell = self:getCell():getNeighborhood():sample()
if cell:isEmpty() then
if self.energy >= 50 then
local child = self:reproduce(); child:move(cell); self.energy = self.energy / 2
else self:move(cell) end
elseif cell:getAgent().name == "rabbit" then
local prey = cell:getAgent()
self.energy = self.energy + prey.energy * 0.2
prey:die()
end
self.energy = self.energy - 4
if self.energy < 0 then self:die() end
end
}
PredatorPreyModel.execute() runs on continuous Point space instead, and splits the same per-agent
logic into five explicit phases applied to the whole society at once — this is the actual method body,
not a paraphrase:
def execute(self) -> None:
society = self.society
# 1. Movement
for agent in society:
agent.walk(step_size=self.step_size, bounds=self.bounds)
# 2. Metabolism (sheep graze, everyone loses energy)
for agent in society:
if self.graze_gain and agent.kind == "sheep":
agent.energy += self.graze_gain
agent.energy -= self.energy_loss
# 3. Predation: wolves eat nearby sheep
self._predation_step(society)
# 4. Death
society.remove_if(lambda agent: agent.energy <= 0)
# 5. Reproduction
for agent in society.select(lambda a: a.energy >= self.reproduce_threshold):
agent.reproduce(energy=self.reproduce_threshold / 2.0)
Same five rules TerraME interleaves per-agent-per-tick, laid out as five separate passes over the whole
society instead — the architectural trade Chapter 24 already described for CellularAutomaton.rule(idx),
applied here to agents instead of cells.
from dissmodel_abm.models import PredatorPreyModel
rng = np.random.default_rng(0)
n_sheep, n_wolves = 30, 8
kinds = ["sheep"] * n_sheep + ["wolves"] * n_wolves
xs = rng.uniform(bounds[0], bounds[2], n_sheep + n_wolves)
ys = rng.uniform(bounds[1], bounds[3], n_sheep + n_wolves)
pp_gdf = gpd.GeoDataFrame({
"kind": kinds,
"energy": [10.0] * (n_sheep + n_wolves),
"geometry": gpd.points_from_xy(xs, ys),
})
env = Environment(start_time=0, end_time=20)
pp_model = PredatorPreyModel(
gdf=pp_gdf, bounds=bounds, eat_radius=3.0,
energy_loss=1.0, energy_gain=5.0,
reproduce_threshold=15.0, graze_gain=1.5,
)
env.run()
print("Final population:", pp_model.gdf["kind"].value_counts().to_dict())
Running from 0 to 20 (duration: 20)
Final population: {'sheep': 390}
With these particular starting numbers, sheep out-reproduce the wolves' hunting rate entirely and the wolf population collapses to zero — a legitimate outcome of the parameters chosen, not a bug, and exactly the sort of imbalance Exercise 2 asks you to correct by tuning eat_radius and energy_gain until both populations persist, the way Chapter 23's phase-plane plot showed the continuous version doing.
Watch out
This is a 1:1 architectural port of TerraME's logo/PredatorPrey.lua, not a 1:1 numerical one. Three parameters differ from the original by design: TerraME used per-species reproduction thresholds (rabbits ≥ 30, wolves ≥ 50) where this model has one shared reproduce_threshold; TerraME's predation gain was 20% of the prey's own energy at capture, where this model uses a fixed energy_gain; and TerraME's pasture→soil→pasture regrowth cycle has no equivalent here — graze_gain is a flat, unconditional gain instead. Reproducing the original course's exact numbers means setting these explicitly, not trusting the defaults.
SchellingModel, the third shipped model, applies the same self.society discipline to the one-agent-per-cell layout instead — ported directly from TerraME's own logo package as a validated reference point, with matching defaults (dim=25, 25% free space, preference=3):
from dissmodel.geo.vector import vector_grid
from dissmodel_abm.models import SchellingModel
schelling_gdf = vector_grid(dimension=(25, 25), resolution=1)
env = Environment(start_time=0, end_time=30)
schelling = SchellingModel(gdf=schelling_gdf, free_space=0.25, preference=3, seed=0)
env.run()
print("Fraction satisfied:", schelling.fraction_satisfied())
Running from 0 to 30 (duration: 30) Fraction satisfied: 1.0
A fraction_satisfied() of 1.0 means the model converged — every agent ended up with at least preference same-type neighbors, Schelling's classic segregation result emerging from nothing more than individually mild, locally-applied preferences.
Two More Ported Models: Ants and Labyrinth¶
RandomWalkModel, PredatorPreyModel, and SchellingModel cover two agent layouts: pure point-agents, and one-agent-per-cell where the cell is the agent. Two more shipped models — both, like SchellingModel, ported from TerraME's own logo package — introduce a third layout this book hasn't seen yet: cell and point agents sharing one GeoDataFrame, distinguished by a kind column.
LabyrinthModel ports logo/Labyrinth.lua: a maze of wall/empty/exit cells (kind="cell"), plus a handful of walker agents (kind="walker") that each step toward a random empty neighbor, or straight to the exit once one is visible. A walker die()s on reaching the exit — TerraME's agent:leave().
from dissmodel.core import Environment
from dissmodel_abm.models import LabyrinthModel, build_labyrinth
maze = build_labyrinth(pattern="room")
env = Environment(start_time=0, end_time=300)
labyrinth = LabyrinthModel(gdf=maze, n_walkers=5, seed=0)
env.run()
still_searching = (labyrinth.gdf["kind"] == "walker").sum()
found = (labyrinth.gdf["state"] == "found").sum()
print(f"walkers still searching: {still_searching} exits found: {found}")
Running from 0 to 300 (duration: 300) walkers still searching: 4 exits found: 1
Five walkers, a random search each, and only one found the exit within 300 steps — an honest result, not a tuned one: an unbiased random walk through a maze is genuinely slow, exactly what TerraME's original teaches by having students watch it happen.
AntsModel ports logo/Ants.lua: a grid holds a nest, scattered food cells, and an evaporating pheromone field; ants (kind="ant") alternate between "searching" (follow the strongest pheromone trail, or walk randomly if none) and "bringing" (head straight back to the nest, depositing pheromone along the way).
from dissmodel_abm.models import AntsModel, build_colony
colony = build_colony(dimension=30, n_food_cells=40, seed=0)
env = Environment(start_time=0, end_time=150)
ants = AntsModel(gdf=colony, n_ants=20, seed=0)
env.run()
print(f"collected: {ants.collected} still foraging: {ants.foraging}")
Running from 0 to 150 (duration: 150) collected: 13 still foraging: 20
Watch out
Run the cell above more than once and collected comes back different every time, even
though seed=0 is passed explicitly. This isn't a fluke — it's a real bug in the current
package: AntsModel.setup() accepts a seed parameter but never uses it, and
execute() creates a fresh, unseeded np.random.default_rng() on every single step
(ants.py, inside execute()). Only the initial food/ant placement, done once in
build_colony(), actually respects the seed you pass — everything that happens tick by tick
after that is genuinely non-reproducible. LabyrinthModel doesn't have this problem: its
seed is threaded into a single np.random.default_rng(seed) built once in
setup() and reused every step. Worth checking which pattern a model actually follows before
trusting a "seeded" result to reproduce.
Both models keep cell and agent rows in the same GeoDataFrame specifically so a single Map can render them together — the same reasoning SchellingModel used for its one-agent-per-cell layout, extended one step further.
What's Not There Yet¶
Stated directly in the package's own roadmap, not implied by omission: raster substrate (a Society backed by a NumPy array instead of a GeoDataFrame, so model code written against self.society would keep working unchanged regardless of substrate — vector support is being hardened first, deliberately); social networks (TerraME's message-passing between agents, likely as a thin layer over a graph library keyed by agent ID); and state machines (TerraME's State/Jump/Flow, for agents whose behavior depends on a discrete internal mode). Chapter 33's migration guide comes back to this list directly — not every TerraME agent model can be migrated today without a gap, and checking which gap applies before assuming a straightforward port is worth the five minutes it takes. The package's own docs/agent.md keeps a function-by-function list of TerraME Agent methods with no dissmodel-abm equivalent yet — check it directly rather than assuming the concept-mapping table above is exhaustive.
Exercises¶
- Why no snapshot?
Agentneeds no__init__-time copy of its data. What makesagent.energy = 5immediately visible inmodel.gdf, without an explicit sync step? - Balance the ecosystem. Starting from the Case Study parameters, adjust
eat_radius,energy_gain, andgraze_gainuntil bothsheepandwolvessurvive past tick 20 without either population collapsing to zero. Report the parameters you landed on. - Point agents vs grid agents. Using the concept-mapping table, explain why
agent.grid_neighbors()only makes sense forSchellingModelandagent.neighbors(radius)only forPredatorPreyModel— what's structurally different about how each model's agents occupy space? - A gap that matters to you. Pick one item from What's Not There Yet (raster substrate, social networks, state machines) and describe, in a sentence, a model you'd want to build that needs it specifically.
# Your code here
Summary¶
Key concepts introduced¶
Society/Agentas a protective, substrate-agnostic layer overself.gdf— agents read and written as objects, never as raw DataFrame masks, whileMap,Chart, andModelExecutorkeep working underneath- The TerraME-to-
dissmodel-abmconcept mapping, and the point-agent versus one-agent-per-cell distinction it implies - Five shipped models —
RandomWalkModel,PredatorPreyModel,SchellingModel,LabyrinthModel,AntsModel— covering three agent layouts: pure point-agents, one-agent-per-cell, and cell+point-agent layers sharing oneGeoDataFrame; three of the five validated directly against TerraME's ownlogopackage - Predator-Prey rebuilt bottom-up from Chapter 23's Lotka-Volterra ODE, with the real Lua
execute()compared line-for-line against the real Python one, and three explicitly documented parameter gaps between the architectural port and the original's exact numbers - A real reproducibility bug found in
AntsModel: itsseedparameter is accepted but never used, andexecute()reseeds itself unseeded every step — a reminder to verify a "seeded" result actually reproduces before trusting it - An honest list of what
dissmodel-abmdoesn't do yet — raster substrate, social networks, state machines — that Chapter 33's migration guide checks against directly, alongsidedocs/agent.md's own function-by-function gap list
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/AgentModel 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
- TerraME's
logopackage documentation, the source ofSchellingModel's validated defaults: https://www.terrame.org/package/logo/models/ - dissmodel-abm on GitHub: https://github.com/DisSModel/dissmodel-abm
- TerraME's
logopackage,Ants.luaandLabyrinth.luasource: https://www.terrame.org/package/logo/models/