Epstein Civil Violence Model#
Summary#
This model is based on Joshua Epstein’s simulation of how civil unrest grows and is suppressed. Citizen agents wander the grid randomly, and are endowed with individual risk aversion and hardship levels; there is also a universal regime legitimacy value. There are also Cop agents, who work on behalf of the regime. Cops arrest Citizens who are actively rebelling; Citizens decide whether to rebel based on their hardship and the regime legitimacy, and their perceived probability of arrest.
The model generates mass uprising as self-reinforcing processes: if enough agents are rebelling, the probability of any individual agent being arrested is reduced, making more agents more likely to join the uprising. However, the more rebelling Citizens the Cops arrest, the less likely additional agents become to join.
How to Run#
Install Mesa with recommended dependencies:
pip install “mesa[rec]”
Then run the example:
solara run app.py
Open the displayed local URL in your browser.
Files#
model.py: Core model code.agent.py: Agent classes.app.py: Sets up the interactive visualization.Epstein Civil Violence.ipynb: Jupyter notebook conducting some preliminary analysis of the model.
Further Reading#
This model is based adapted from:
A similar model is also included with NetLogo:
Wilensky, U. (2004). NetLogo Rebellion model. http://ccl.northwestern.edu/netlogo/models/Rebellion. Center for Connected Learning and Computer-Based Modeling, Northwestern University, Evanston, IL.
Agents#
import math
from enum import Enum
import mesa
class CitizenState(Enum):
ACTIVE = 1
QUIET = 2
ARRESTED = 3
class EpsteinAgent(mesa.discrete_space.CellAgent):
def update_neighbors(self):
"""
Look around and see who my neighbors are
"""
self.neighborhood = self.cell.get_neighborhood(radius=self.scenario.cop_vision)
self.neighbors = self.neighborhood.agents
self.empty_neighbors = [c for c in self.neighborhood if c.is_empty]
def move(self):
"""Move to a random empty neighboring cell if movement is enabled."""
if self.scenario.movement and self.empty_neighbors:
new_pos = self.random.choice(self.empty_neighbors)
self.move_to(new_pos)
class Citizen(EpsteinAgent):
"""
A member of the general population, may or may not be in active rebellion.
Summary of rule: If grievance - risk > threshold, rebel.
Attributes:
hardship: Agent's 'perceived hardship (i.e., physical or economic
privation).' Exogenous, drawn from U(0,1).
risk_aversion: Exogenous, drawn from U(0,1).
state: Can be CitizenState.QUIET, ACTIVE, or ARRESTED
jail_sentence: remaining jail time (0 if not in jail)
grievance: deterministic function of hardship and regime_legitimacy;
how aggrieved is agent at the regime?
arrest_probability: agent's assessment of arrest probability, given rebellion
Notes:
Parameters accessed via model.scenario: legitimacy, active_threshold, citizen_vision, arrest_prob_constant
"""
def __init__(self, model):
"""
Create a new Citizen.
Args:
model: the model to which the agent belongs
"""
super().__init__(model)
self.hardship = self.random.random()
self.risk_aversion = self.random.random()
self.state = CitizenState.QUIET
self.jail_sentence = 0
self.grievance = self.hardship * (1 - self.scenario.legitimacy)
self.arrest_probability = None
self.neighborhood = []
self.neighbors = []
self.empty_neighbors = []
def step(self):
"""
Decide whether to activate, then move if applicable.
"""
if self.jail_sentence:
self.jail_sentence -= 1
return # no other changes or movements if agent is in jail.
self.update_neighbors()
self.update_estimated_arrest_probability()
net_risk = self.risk_aversion * self.arrest_probability
if (self.grievance - net_risk) > self.scenario.active_threshold:
self.state = CitizenState.ACTIVE
else:
self.state = CitizenState.QUIET
self.move()
def update_estimated_arrest_probability(self):
"""
Based on the ratio of cops to actives in my neighborhood, estimate the
p(Arrest | I go active).
"""
cops_in_vision = 0
actives_in_vision = 1 # citizen counts herself
for neighbor in self.neighbors:
if isinstance(neighbor, Cop):
cops_in_vision += 1
elif neighbor.state == CitizenState.ACTIVE:
actives_in_vision += 1
# there is a body of literature on this equation
# the round is not in the pnas paper but without it, its impossible to replicate
# the dynamics shown there.
self.arrest_probability = 1 - math.exp(
-1
* self.scenario.arrest_prob_constant
* round(cops_in_vision / actives_in_vision)
)
class Cop(EpsteinAgent):
"""
A cop for life. No defection.
Summary of rule: Inspect local vision and arrest a random active agent.
Notes:
Parameters accessed via model.scenario: cop_vision, max_jail_term
"""
def __init__(self, model):
"""
Create a new Cop.
Args:
model: model instance
"""
super().__init__(model)
def step(self):
"""
Inspect local vision and arrest a random active agent. Move if
applicable.
"""
self.update_neighbors()
active_neighbors = []
for agent in self.neighbors:
if isinstance(agent, Citizen) and agent.state == CitizenState.ACTIVE:
active_neighbors.append(agent)
if active_neighbors:
arrestee = self.random.choice(active_neighbors)
arrestee.jail_sentence = self.random.randint(0, self.scenario.max_jail_term)
arrestee.state = CitizenState.ARRESTED
self.move()
Model#
from typing import Literal
import mesa
from mesa.discrete_space import OrthogonalMooreGrid, OrthogonalVonNeumannGrid
from mesa.examples.advanced.epstein_civil_violence.agents import (
Citizen,
CitizenState,
Cop,
)
from mesa.experimental.scenarios import Scenario
# Define a typed scenario subclass with defaults
class EpsteinScenario(Scenario):
"""Scenario parameters for Epstein Civil Violence model."""
citizen_density: float = 0.7
cop_density: float = 0.074
citizen_vision: int = 7
cop_vision: int = 7
legitimacy: float = 0.8
max_jail_term: int = 1000
active_threshold: float = 0.1
arrest_prob_constant: float = 2.3
movement: bool = True
max_iters: int = 1000
activation_order: Literal["Random", "Sequential"] = "Random"
grid_type: Literal["Von Neumann", "Moore"] = "Von Neumann"
rng: int = 42
class EpsteinCivilViolence(mesa.Model):
"""
Model 1 from "Modeling civil violence: An agent-based computational
approach," by Joshua Epstein.
http://www.pnas.org/content/99/suppl_3/7243.full
Args:
height: grid height
width: grid width
seed: random seed for reproducibility
scenario: EpsteinScenario object containing model parameters.
"""
def __init__(
self,
width=40,
height=40,
scenario: EpsteinScenario = EpsteinScenario,
):
super().__init__(scenario=scenario)
match self.scenario.grid_type:
case "Moore":
self.grid = OrthogonalMooreGrid(
(width, height), capacity=1, torus=True, random=self.random
)
case "Von Neumann":
self.grid = OrthogonalVonNeumannGrid(
(width, height), capacity=1, torus=True, random=self.random
)
case _:
raise ValueError(
f"Unknown value of grid_type: {self.scenario.grid_type}"
)
model_reporters = {
"active": CitizenState.ACTIVE.name,
"quiet": CitizenState.QUIET.name,
"arrested": CitizenState.ARRESTED.name,
}
agent_reporters = {
"jail_sentence": lambda a: getattr(a, "jail_sentence", None),
"arrest_probability": lambda a: getattr(a, "arrest_probability", None),
}
self.datacollector = mesa.DataCollector(
model_reporters=model_reporters, agent_reporters=agent_reporters
)
if self.scenario.cop_density + self.scenario.citizen_density > 1:
raise ValueError("Cop density + citizen density must be less than 1")
for cell in self.grid.all_cells:
klass = self.random.choices(
[Citizen, Cop, None],
cum_weights=[
self.scenario.citizen_density,
self.scenario.citizen_density + self.scenario.cop_density,
1,
],
)[0]
if klass is not None:
agent = klass(self) # Either Citizen or Cop
agent.move_to(cell)
self.running = True
self._update_counts()
self.datacollector.collect(self)
def step(self):
"""
Advance the model by one step and collect data.
"""
match self.scenario.activation_order:
case "Random":
self.agents.shuffle_do("step")
case "Sequential":
self.agents.do("step")
case _:
raise ValueError(
f"unknown value of activation_order: {self.scenario.activation_order}"
)
self._update_counts()
self.datacollector.collect(self)
if self.time > self.scenario.max_iters:
self.running = False
def _update_counts(self):
"""Helper function for counting nr. of citizens in given state."""
counts = self.agents_by_type[Citizen].groupby("state").count()
for state in CitizenState:
setattr(self, state.name, counts.get(state, 0))
App#
from mesa.examples.advanced.epstein_civil_violence.agents import (
Citizen,
CitizenState,
Cop,
)
from mesa.examples.advanced.epstein_civil_violence.model import EpsteinCivilViolence
from mesa.visualization import (
Slider,
SolaraViz,
SpaceRenderer,
make_plot_component,
)
from mesa.visualization.components import AgentPortrayalStyle
COP_COLOR = "#000000"
agent_colors = {
CitizenState.ACTIVE: "#FE6100",
CitizenState.QUIET: "#648FFF",
CitizenState.ARRESTED: "#808080",
}
def citizen_cop_portrayal(agent):
if agent is None:
return
portrayal = AgentPortrayalStyle(size=200)
if isinstance(agent, Citizen):
portrayal.update(("color", agent_colors[agent.state]))
elif isinstance(agent, Cop):
portrayal.update(("color", COP_COLOR))
return portrayal
def post_process(ax):
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])
ax.get_figure().set_size_inches(10, 10)
model_params = {
"rng": {
"type": "InputText",
"value": 42,
"label": "Random Seed",
},
"height": 40,
"width": 40,
"citizen_density": Slider("Initial Agent Density", 0.7, 0.1, 0.9, 0.1),
"cop_density": Slider("Initial Cop Density", 0.04, 0.0, 0.1, 0.01),
"citizen_vision": Slider("Citizen Vision", 7, 1, 10, 1),
"cop_vision": Slider("Cop Vision", 7, 1, 10, 1),
"legitimacy": Slider("Government Legitimacy", 0.82, 0.0, 1, 0.01),
"max_jail_term": Slider("Max Jail Term", 30, 0, 50, 1),
"activation_order": {
"type": "Select",
"value": "Random",
"values": ["Random", "Sequential"],
"label": "Activation Order",
},
"grid_type": {
"type": "Select",
"value": "Von Neumann",
"values": ["Von Neumann", "Moore"],
"label": "Grid Type",
},
}
chart_component = make_plot_component(
{state.name.lower(): agent_colors[state] for state in CitizenState}
)
epstein_model = EpsteinCivilViolence()
renderer = SpaceRenderer(epstein_model, backend="matplotlib").setup_agents(
citizen_cop_portrayal
)
# Specifically, avoid drawing the grid to hide the grid lines.
renderer.draw_agents()
renderer.post_process = post_process
page = SolaraViz(
epstein_model,
renderer,
components=[chart_component],
model_params=model_params,
name="Epstein Civil Violence",
)
page # noqa