An agent-based model of simulated cities whose citizens are givers (cooperative strategies) or takers (exploitative ones), playing the Iterated Prisoner's Dilemma with the people they actually meet. Who meets whom is not uniform: it depends on distance through the network, the strength of an existing relationship, and how popular the other person is. Relationships strengthen with use, fade without it, break when they get too weak, and are replaced.
The project asks how the mix of givers and takers shapes a city's prosperity, the inequality of that prosperity, and the in-groups and out-groups that form.
- Why agent-based? Prosperity, inequality and segregation are outcomes no citizen chooses. They emerge from many local interactions that are each simple to state, which is what an agent-based model is for.
- What we vary: the share of givers, from 0 to 1 in steps of 0.2, on a random and a scale-free starting network. Separate experiments vary clustering (small-world networks), how long a refusal lasts, and ten other settings one at a time, including how fast relationships decay.
- How we build it: six steps, each adding one mechanism to the last, from a well-mixed crowd to the full city, and finally to a city whose citizens walk away from anyone who cheats them. Each step is built to reproduce the previous one exactly, with the same seed, when its new mechanism is switched off; the notebook checks this for S3 against S2, S5 against S4 and S6 against S5.
Each round is one week. For every round:
- every agent picks a partner, weighing proximity, habit and the partner's popularity;
- each pair plays one round of the Prisoner's Dilemma, with payoffs
(T, R, P, S) = (5, 3, 1, 0); - payoffs are added to wealth, which is created by interaction rather than moved between agents;
- relationships used this week strengthen, unused ones decay, and faded ones break;
- agents with spare capacity form new relationships, usually with friends of friends;
- popularity ratings update from how cooperative and well-connected each agent has been.
In the sixth build step (S6), a pair stops meeting once either side has defected on the other, and the tie between them ends. The rule never mentions class or strategy.
Partner choice uses the attractiveness score s_ij = (w_ij + eps)^beta * R_j^gamma * d_ij^-lambda,
where w is relationship strength, R is the partner's popularity rating and d is path length
through the network. Meetings received are capped, because attention is finite.
Every stochastic step draws from seeded numpy generators, one each for the starting network, the
population, the dynamics and measurement, all derived from the run's seed. A run is therefore
reproducible from its seed and configuration, and measuring a city never changes what happens in it.
notebooks/Project.ipynb runs the whole argument end to end in about fifteen minutes on an
8-worker pool: the model, what we expected, the sweep, the figures, an extended analysis and the
conclusions. Every figure is also written to figures/ as PDF and PNG.
- Prosperity: more givers make a city richer, and without refusal the gain is faster once givers are common. Where the gain speeds up depends on the settings.
- Inequality without refusal is positional: a city where everyone plays Tit-for-Tat, so that nobody is ever exploited, is nearly as unequal as a mixed one (Gini 0.21 against 0.23). The inequality comes from who gets met, and that is decided early: the ranking of citizens by games played at week 25 already matches the ranking at week 500 with a correlation of 0.80.
- Refusal changes whose inequality it is: when citizens walk away from a defector for even one week, the Gini barely moves, but the share of inequality lying between givers and takers rises from about a fifth to more than four fifths.
- Refusal that never forgives becomes exclusion: almost every taker ends with no ties, givers keep about 3,000 each at every mix, and taker-dominated cities become poor and unequal. The city starts losing wealth once a refusal lasts longer than 52 weeks.
- A subtle cheat escapes: Joss, which cooperates nine games in ten, keeps about nine tenths of its wealth under refusal, while AllD and Random lose about three quarters of theirs.
- Sensitivity: with each of ten settings lowered and raised in turn, refusal always cuts takers off and a city where nobody cheats always stays unequal. The faster gain in prosperity (H1) passes its test in 15 of the 20 changed settings, and the equal poverty trap (H4) in 11.
- Python >= 3.10 (tested on 3.10 and 3.12)
- pip
git clone https://github.com/Precipicee/cits4403-project.git
cd cits4403-project
python -m venv venv && source venv/bin/activate
pip install -r requirements.txtOn systems where python points to Python 2 or is missing, use python3 in the third line.
jupyter lab notebooks/Project.ipynbRun the cells top to bottom. Every result is generated on the spot; nothing is read from a saved results file.
The notebook's Explore One City section has controls for the scenario, the share of givers, how long a refusal lasts, whether Joss is among the takers, and the seed. It runs one city of 60 citizens over 300 weeks and steps through it week by week. The controls need a running Jupyter kernel; the same controls can be opened in any notebook:
from utilities.explorer import explore
explore({"S5 full city": ("M4", {}), "S6 refusal": ("M5", {})})from source.presets import stage
from source.simulation import Simulation
cfg = stage("M4", **{"population.n_agents": 200, "population.giver_fraction": 0.5})
sim = Simulation(cfg, root_seed=20260918, repeat=0, instruments=("core", "structure"))
result = sim.run()stage takes "M0" to "M5", the six build steps, and any configuration key as a keyword
override, so any modelling choice can be changed from a single call. utilities.runs.sweep
runs a grid of cities in parallel and returns one row per run.
A sweep too large for a notebook is declared in code, so its settings stay visible beside its
results, and run in parallel. Aggregated tables are written to data/results/:
from source.experiment import Experiment, run
if __name__ == "__main__":
exp = Experiment(name="cities", base="M4", root_seed=20260917, n_seeds=25,
axes={"population.giver_fraction": [0.0, 0.25, 0.5, 0.75, 1.0],
"topology.name": ["erdos_renyi", "barabasi_albert"]})
tables = run(exp, workers=8)run starts each worker as a fresh Python process, so a script needs the
if __name__ == "__main__": line; without it the script stops with a RuntimeError. In a
notebook the line makes no difference.
cits4403-project/
|-- source/ model code: settings and build steps (config, presets), the city and its
| start (state, topology, population), the weekly loop (simulation), one
| module per rule (game, strategies, encounter, ties, popularity, rewiring),
| measures (metrics, instruments), experiment runs (experiment, io) and the
| registry of rule names (registry)
|-- utilities/ plotting, network drawings and the animation, statistics, checks, small
| sweeps, the explorer and the model diagram
|-- notebooks/ Project.ipynb: the analysis, start to finish
|-- figures/ figures and the network animation, created when the notebook runs
|-- project_spec.md the original project proposal; the notebook describes the final design
|-- requirements.txt dependencies
`-- README.md