A from-scratch replication of:
Leibo, J. Z., Zambaldi, V., Lanctot, M., Marecki, J., & Graepel, T. (2017). Multi-agent Reinforcement Learning in Sequential Social Dilemmas. AAMAS 2017.
The paper's question: matrix games like Prisoner's Dilemma treat "cooperate" and "defect" as atomic, one-shot choices. Real social dilemmas are temporally extended — cooperativeness is a property of a policy, not a single action, and has to be inferred from a whole trajectory of behavior. Leibo et al. formalize this as a Sequential Social Dilemma (SSD): a partially-observable Markov game that, when you empirically extract the payoffs induced by "cooperative" vs. "defecting" policies, reduces to a classical matrix-game social dilemma (Prisoner's Dilemma, Chicken, or Stag Hunt) — while the sequential structure (how hard cooperation/defection are to implement, not just their payoff values) produces qualitatively different, sometimes opposite, predictions about when cooperation emerges.
This repo rebuilds their two environments (Gathering, an apple-collection game where a costly "tagging" beam can remove a rival, and Wolfpack, a pack-hunting game where two wolves share a bonus for capturing prey together) and their independent-DQN training setup directly, then reproduces their three experiments: the abundance/conflict-cost and radius/group-bonus sweeps (Fig. 4), the empirical game-theoretic analysis that classifies the induced matrix game (Fig. 5–6), and the DQN hyperparameter ablations (Fig. 7).
This is a sibling project to SequentialSocialDilemmas (a port of Vinitsky et al.'s modernized RLlib codebase for Cleanup/Harvest/ Gathering/Switch, trained with PPO/IMPALA). That repo is an engineering fork of someone else's environment design, doesn't include Wolfpack, and doesn't run the paper's own independent, non-target-shared, growing-replay-buffer DQN setup. This repo is the from-scratch faithful baseline instead: both of the paper's own games, its own (much simpler) agent architecture, and its own three named experiments, by default. An optional Ray RLlib backend can also be layered on top of the same environments as a cross-check — see "Optional: RLlib backend" below — without touching any of that default setup.
Two independent DQN agents (Sec. 3.1) — each owning its own tiny Q-network and its own replay buffer, never communicating, each treating the other purely as part of a non-stationary environment — learn by ordinary Q-learning gradient descent on transitions sampled from a size-capped, constantly-refreshed ("growing") batch. Cooperation and defection are never given as literal actions; they're empirical properties of trained policies, measured via a social behavior metric (beam-use rate for Gathering, wolves-per-capture for Wolfpack) and via empirical game-theoretic analysis (EGTA): sample "cooperative" and "defecting" trained policies against each other, average the resulting returns into an empirical payoff matrix (R, P, S, T), and classify it against the same inequalities that define a matrix-game social dilemma.
- Gathering (
leibo2017/envs/gathering.py): two players collect apples (+1 reward, removed on pickup, respawns aftern_applesteps) in an open map with a central orchard. Either player can fire a beam; two hits tag a rival, removing them from the game forn_taggedsteps — a costly action whose only function is eliminating competition for apples. - Wolfpack (
leibo2017/envs/wolfpack.py): two wolves chase a scripted prey. A wolf that touches the prey alone getsr_lone; if its partner is also withincapture_radius, both get the largerr_teaminstead. - Independent DQN (
leibo2017/agents/dqn.py): a plain 2-hidden-layer, 32-unit-by-default MLP over the flattened (3, 16, 21) observation crop, trained with the update rule from Sec. 3.1,epsilon-greedy with linear decay from 1.0 to 0.1 (Sec. 4). - EGTA (
leibo2017/analysis/egta.py): the Fig. 5 workflow — sample policy pairs from cooperator/defector pools, play them out, average returns into (R, P, S, T), classify byfear = P - S/greed = T - Rinto the Fig. 6 quadrant scheme.
Matched, using the paper's own stated numbers where it gives any:
- Shared 8-action space exactly as listed in Sec. 4: step forward/backward, strafe left/right, rotate left/right, beam, stand still.
- Observation shape
(3, 16, 21)(RGB, 15 cells ahead + the agent's own row, 10 cells each side + the agent's own column), agent-centered and orientation-dependent (Sec. 4). - Episode length 1,000 steps; default per-step discount 0.99 (Sec. 4).
- Default network: two hidden layers, 32 units, ReLU, 8 output units (Sec. 4).
- Epsilon-greedy exploration decaying linearly from 1.0 to 0.1 (Sec. 4).
- Default replay-buffer ("batch") size 1e5, a FIFO buffer that grows to a cap and is then constantly refreshed by discarding old transitions, not an unbounded buffer (Sec. 3.1 / footnote 1).
- Q-learning update rule exactly as written in Sec. 3.1 (
Q ← Q + α[r + γ max Q' − Q]), trained by gradient descent on the MSE Bellman residual over uniformly-sampled batch transitions. - Full independence between the two learners: no shared weights, no communication, no centralized value function.
- Gathering's reward (+1/apple, apple removed and respawns after
n_apple), tagging mechanic (2 beam hits → removed forn_taggedsteps, no reward for tagging), and its role as the sole source of conflict (Sec. 5.1). - Wolfpack's reward structure (
r_lonesolo capture vs.r_teamjoint capture when a partner is withincapture_radius), Sec. 5.2. - The Fig. 4 sweep axes (Gathering:
N_appleabundance ×N_taggedconflict-cost; Wolfpack: capture radius × group-capture bonus) and the Fig. 7 ablation factors (discount, batch size, network size). - The EGTA classification scheme itself:
fear = P - S,greed = T - R, and the four-way Fig. 6 quadrant split (Prisoner's Dilemma / Chicken / Stag Hunt / non-SSD), implemented exactly as stated in Sec. 2.2 and Eqs. 1–4 — seeleibo2017/analysis/egta.py's docstring andtests/test_egta.py(checked against the paper's own canonical PD/ Chicken/Stag Hunt payoff numbers from Fig. 1).
Blind spots — where the paper doesn't say enough to reproduce byte-for-byte, and what was chosen instead (all in code, clearly marked):
- Map layout and size for both games are never published — only Fig.
3's small illustrative screenshot (a "plus"-shaped apple orchard for
Gathering; an open arena of unstated size for Wolfpack).
gathering.pyreconstructs a 13×33 map with a plus-shaped orchard in the middle third and players starting at opposite ends of the corridor, matching Fig. 3's visual proportions;wolfpack.pyuses an arbitrary 21×21 open arena. Neither is verified against DeepMind's actual level file, which isn't published in the paper. Light corroboration, not confirmation: Vinitsky et al.'s independent Gathering implementation (ported in the sibling SequentialSocialDilemmas repo) also lands on a similarly-shaped multi-cluster apple orchard and the identical(3, 16, 21)observation shape — but it's their own reconstruction from the same Fig. 3 schematic, not a second, independent data point grounded in DeepMind's actual level file, so it doesn't settle the ambiguity, just shows two independent guesses converging on the same general shape. - The Q-network's exact architecture is ambiguous. Sec. 4 says only
"two hidden layers with 32 units, interleaved with rectified linear
layers" — no mention of convolution, despite the network's input being
an RGB image and the paper citing a DQN lineage (Mnih et al. 2015) that
uses convolutional layers over pixel input. Taken literally, this is
read here as a plain MLP over the flattened observation
(
leibo2017/agents/dqn.py::QNetwork). A convolutional reading is equally defensible from the surrounding context; it just isn't what the sentence itself says. - Learning rate and optimizer are never given — the paper only says
"trained through gradient descent." Adam with
lr=1e-4is used here (DQNConfig.learning_rate), an arbitrary but standard choice for this network scale. - Whether a separate target network is used for the
max_a' Q(s',a')term is not shown. The literal Sec. 3.1 equation has no target network, but the paper frames its whole approach as an application of the cited Mnih et al. (2015) DQN, which does use one. A target network is included by default (DQNConfig.use_target_network=True,target_update_interval=1000) since omitting it against a paper that explicitly leans on Mnih et al. felt like the wrong default; it can be turned off to match the bare equation exactly. - The epsilon-decay schedule's length is unstated — "decaying linearly
over time (from 1.0 to 0.1)" doesn't say over how many steps. Made
configurable (
DQNConfig.epsilon_decay_steps), defaulting to 1,000,000. - A real inconsistency in the paper itself, not just an omission:
Sec. 3.1's text says batch sizes of "1e5 (our default) and 1e6" are
compared, but Fig. 7's own legend labels the two compared batch-size
curves "1e+04" and "1e+05".
run_experiment3_agent_params.pyreproduces Fig. 7's literal legend values (1e4 vs. 1e5) for that specific ablation, while the single-run default elsewhere stays at 1e5 per the Sec. 3.1 text — see that script's docstring. - Fig. 7's network-size ablation (16 vs. 64) doesn't include the Sec. 4 stated default (32) — that's the paper's own experimental design, not a gap on this end, just worth knowing going in: the ablation brackets the default rather than including it.
- Wolfpack's prey behavior is never specified at all. Sec. 5.2 calls
it "a third player" but never states whether it's a learning agent, a
scripted opponent, or something else — only that "the wolves learn to
catch the prey over the course of training," implying the prey itself
is not the subject of the reported learning curves. This repo scripts
the prey (
WolfpackEnv._move_prey) to flee the nearest wolf only when one is withinprey_detection_range(default 3 cells), and only with probabilityprey_flee_prob(default 0.5) when it does. Both constants were tuned, not guessed once and left: an earlier version had the prey unconditionally maximize distance from the nearest wolf, which let it retreat into a permanently safe corner of the bounded arena that undirected wolves essentially never reached again — verified directly (0captures in 50,000 random-policy steps) — which would leave epsilon-greedy exploration with no reward signal to ever bootstrap learning from. The tuned values give real DQN training something to find (~1capture per ~1,400 random steps, confirmed empirically) while still requiring a wolf to actually approach. - The exact shape/trigger of Wolfpack's capture mechanic is
under-specified. "If an agent is inside the blue diamond-shaped region
around the prey when a capture occurs" is read here as: a capture is
triggered by any wolf occupying the prey's exact cell; the resulting
reward (
r_teamto both, orr_loneto the capturer alone) depends on whether the other wolf is withincapture_radius(Manhattan/L1 distance, matching "diamond-shaped") at that instant. Whether the original used Euclidean distance, a different trigger radius for the capture itself vs. the team-bonus radius, or some other convention isn't stated. - Gathering's beam range, line-of-sight, and hit-memory are unspecified. Implemented here: beam range of 15 cells (matching the observation's forward extent), blocked only by walls (passes freely over open floor and apples), and hit count that accumulates indefinitely (never decays) until the second hit triggers a tag. None of this is stated in the paper.
- Movement-collision resolution when two agents contest the same cell (order of resolution, whether a move silently fails or one agent yields) isn't specified; this repo resolves actions in a random per-step order and simply blocks a move into an already-occupied cell.
- How many random seeds went into each Fig. 4 heatmap cell or each Fig. 7
line-plot point is never stated — Fig. 7 shows error bars, implying
multiple seeds/runs were averaged, but the count isn't given. All
run_experiment*.pyscripts here run one seed per grid cell / curve point by default; wrap them over multiple--seedvalues yourself for error bars. - Fig. 6's fine-grained scatter (dozens of points spread across all three
dilemma types) is the least-specified part of the whole paper. Sec.
5.1 clearly describes deriving one aggregate (R, P, S, T) estimate from
two fixed policy pools (Π^C from the high-abundance/low-cost corner, Π^D
from the low-abundance/high-cost corner) — that single-estimate
procedure is what
run_egta_gathering.py/run_egta_wolfpack.pyimplement by default, and it's directly traceable to the text. But the text also says this "generates estimates of R, P, S, and T for the game corresponding to each abundance/conflict-cost level tested" — implying many distinct payoff-matrix points, one per swept condition, which is exactly what Fig. 6 shows, without ever explaining how a specific swept condition's own (R, P, S, T) tuple is derived from just two policy pools. The--sweepflag on both EGTA scripts produces a Fig. 6-shaped scatter using an explicit, disclosed extension: each swept condition's own trained pair supplies its self-play return as R, while the fixed Π^D reference pool supplies P/S/T by cross-play against it. This is a documented interpretation, not a verified reproduction of DeepMind's actual method — treat the resulting scatter's overall shape (a spread across dilemma types) as illustrative, not the paper's own numbers.
The paper trains each Fig. 4/7 grid point for 40,000,000 steps. Across
even a modest sweep (a 5×5 grid, say) that's on the order of a billion
environment steps per figure — a training budget the original authors ran
on DeepMind's own compute, not something this repo attempts to reproduce on
a single machine. Every run_*.py script defaults to a much smaller
--total-steps (tens of thousands) purely as a smoke test that exercises
the full pipeline (env → independent DQN training → social-behavior metric
→ plot) correctly; it does not claim converged, paper-scale results. Pass
--total-steps 40000000 (and a long-running machine, ideally GPU-backed —
this repo trains on CUDA automatically when available via PyTorch) to
attempt the real thing.
# Option A: plain pip, into whatever Python/venv you already have active
pip install -r requirements.txt
# Option B: conda, into a local .conda/ this repo owns (not a
# name-registered env in conda's default envs directory -- the --prefix
# flag is what makes it project-local; plain `-f environment.yml` alone
# does not reliably honor the file's own `prefix:` line)
./create_conda_env.sh
conda activate ./.conda
pytest tests/ -q
# Smoke tests (small step counts; see above)
python run_experiment1_gathering.py # Fig. 4 top: Gathering heatmap
python run_experiment2_wolfpack.py # Fig. 4 bottom: Wolfpack heatmap
python run_experiment3_agent_params.py # Fig. 7: discount/batch/network-size ablations
python run_egta_gathering.py --sweep # Fig. 5-6 left: Gathering EGTA + scatter
python run_egta_wolfpack.py --sweep # Fig. 5-6 right: Wolfpack EGTA + scatterEach script writes a results.json and one or more .png figures to
output/<script-name>/ (override with --out-dir). Run
python run_experiment1_gathering.py --help (etc.) for the full set of
sweep/step/seed overrides.
render_rollout.py renders a played-out Gathering or Wolfpack episode to an
animated GIF — the graphics-utility counterpart to the sibling
SequentialSocialDilemmas repo's visualization/visualizer_rllib.py
rollout-to-video tooling (there: RLlib rollout + OpenCV → .mp4; here: this
repo's own env/agents + Pillow → .gif, no new heavy dependency). Both
GatheringEnv and WolfpackEnv expose a render() method returning the
same full-map RGB frame each already builds internally to construct agent
observations, just not cropped/oriented to any one agent.
python render_rollout.py --game gathering --random-policy # fast sanity check, no training
python render_rollout.py --game wolfpack --total-steps 20000 # trains briefly, then renders a greedy rolloutWrites <out-dir>/<game>_rollout.gif (default output/render_rollout/).
Everything above trains with leibo2017/agents/dqn.py — a direct,
literal implementation of the paper's own independent-DQN method, and the
only thing every run_*.py script and the README's fidelity claims are
about. run_rllib_train.py is a separate, purely additive way to train
the exact same GatheringEnv/WolfpackEnv with Ray RLlib's PPO or DQN
instead — useful for checking whether a standard, well-tuned RL library
reaches different conclusions than the paper's own simple setup on
identical environments. It does not touch leibo2017/agents/dqn.py,
doesn't change what any existing script does, and isn't required for
anything else in this repo — the base pip install -r requirements.txt
has no Ray/RLlib dependency at all.
It also isn't a path to reproducing Fig. 4/6/7 via RLlib: it reports raw per-agent episode return from RLlib's own training loop, not the paper's beam-use-rate / wolves-per-capture social-behavior metrics (wiring those up would need a custom RLlib callback, not built here).
pip install -r requirements-rllib.txt # ray[rllib]; tested against ray==2.58.0
python run_rllib_train.py --game gathering --algo PPO --iterations 10
python run_rllib_train.py --game wolfpack --algo DQN --iterations 10 --num-env-runners 30 --gpuBy default training is single-process (num_env_runners=0, matching the
paper's tiny-scale setup) and CPU-only. --num-env-runners N parallelizes
env rollouts across N remote workers (real speedup, since env stepping is
usually the bottleneck for envs this small); --gpu puts the (also tiny,
2×32-unit) network's training on GPU, which the model is too small to
benefit from much but doesn't hurt either.
For DQN, --epsilon-timesteps/--target-network-update-freq and
--updates-per-iteration correct two easy-to-miss defaults once
--num-env-runners is on:
epsilon's decay-to-floor timestep andtarget_network_update_freqare both counted in global env steps, which scale withnum_env_runners. RLlib's raw defaults (10,000 / 500) implicitly assume a single env runner, so left unscaled,--num-env-runners 30collapses exploration to its floor by iteration ~2 of 1000 instead of the intended ~10, and hard-updates the target network multiple times per iteration instead of every ~0.5.build_config()auto-scales both by the actual measured steps-per-algo.train()-call ratio vs.--num-env-runners=0(not bynum_env_runnersdirectly — DQN's ownmin_sample_timesteps_per_iteration=1000makes that ratio 6x at 30 runners, not 30x), reproducing today's exact behavior at--num-env-runners 0. Override either directly if needed.- Separately, and more impactful: with
training_intensityleft unset (RLlib's own default),calculate_rr_weights()always does exactly one gradient update peralgo.train()call, regardless of--train-batch-size,--episode-length, or--num-env-runners-- confirmed empirically. 1000 iterations trains the network on only 1000 total batches no matter how much data got collected each iteration.--updates-per-iteration Ksetstraining_intensityso exactlyKgradient updates happen peralgo.train()call instead (verified exact at--num-env-runners0, 5, and 30). LargeKraises the replay ratio (samples reused vs. freshly collected) -- a real DQN hyperparameter that can overfit/destabilize if pushed too high, not a free win.
Both scripts call algo.save(checkpoint_dir=...) when training finishes,
to ~/simulation_results/ray_results/<ALGO>_Leibo2017_<Game>_<timestamp>/
by default (default_checkpoint_dir() in run_rllib_train.py; override
with --checkpoint-dir) — outside the repo entirely, in a user-level
directory shared across projects, not output/ (git-ignored but still
repo-local and easy to lose track of). This is a real, reloadable RLlib
checkpoint (model weights included) — distinct from Algorithm.build()'s
own ~/ray_results/<timestamp>/ trial directory, which Trainable.__init__
creates as a side effect of any config.build() call (a hardcoded
constant in ray.train.constants, not something AlgorithmConfig exposes
a way to redirect) and stays empty here: algorithm_state.pkl,
.../rl_module/module_state.pkl etc. only get written by this explicit
algo.save() call, or by driving training through a ray.tune.Tuner (not
what these scripts do) instead of a manual algo.train() loop. Both
scripts call cleanup_algo_logdir(algo.logdir) in their finally block to
rmdir() that empty trial dir once the run finishes, so it doesn't keep
accumulating clutter in ~/ray_results across runs.
leibo2017/envs/rllib_wrappers.py adapts both envs' plain list-indexed
reset()/step() API to RLlib's dict-keyed MultiAgentEnv API, one
independent PolicySpec per agent (no parameter sharing, matching the
paper's own independence assumption), fcnet [hidden_size, hidden_size]
matching Sec. 4's default network size. Observations are flattened and
scaled to [0, 1] float32 rather than left as the raw (3, 16, 21)
uint8 array leibo2017/agents/dqn.py uses directly: RLlib's default
Catalog auto-detects any 3D Box as an image and tries to pick a default
CNN, which has no preset for this shape and raises ValueError — flattening
sidesteps that and keeps the same literal "MLP, not a described conv
stack" reading used everywhere else in this repo.
render_rllib_rollout.py is the RLlib counterpart to render_rollout.py
above: it trains the same run_rllib_train.py config, then rolls out one
greedy episode and renders it to a GIF, printing per-agent return and the
env's info dict (captures/avg_wolves_per_capture for Wolfpack,
beam_use_rate for Gathering) along the way. The new API stack has no
working Algorithm.compute_single_action for multi-agent envs, so it reads
actions from each agent's trained RLModule directly — Columns.ACTIONS
for DQN (already the greedy/exploit action), or a deterministic sample from
action_dist_inputs for PPO.
python render_rllib_rollout.py --game wolfpack --algo PPO --iterations 10Writes <out-dir>/<game>_<algo>_rollout.gif (default
output/render_rllib_rollout/; this one GIF is a repo-local artifact,
fine to leave git-ignored) and, like run_rllib_train.py, saves a
checkpoint to ~/simulation_results/ray_results/<ALGO>_Leibo2017_<Game>_ <timestamp>/ before rendering.
- No attempt at the paper's actual 40,000,000-steps-per-condition training scale — see "Running at paper scale" above.
- Fig. 6's exact per-condition EGTA construction isn't reproducible from
the text as written; the
--sweepscatter is a disclosed interpretation, not a verified match to DeepMind's method (see "Blind spots"). - Wolfpack's prey is a scripted, tuned stand-in, not a verified match to whatever the original "third player" actually was.
- No convolutional-network variant was tried; only the literal "two hidden layers, 32 units" MLP reading of Sec. 4.
Developed with AI coding assistance from Claude (Anthropic), which does the implementation, with Codex (OpenAI) acting as an independent second opinion, peer-reviewing Claude's nontrivial code changes.
- Leibo, J. Z., Zambaldi, V., Lanctot, M., Marecki, J., & Graepel, T. (2017). Multi-agent Reinforcement Learning in Sequential Social Dilemmas. AAMAS 2017.
- Mnih, V., et al. (2015). Human-level control through deep reinforcement learning. Nature, 518(7540), 529–533. (The DQN this paper's Q-learning update is built on top of.)
- SequentialSocialDilemmas — a sibling repo porting Vinitsky et al.'s modernized-RLlib Cleanup/Harvest/Gathering/Switch environments (PPO/IMPALA/DQN), credited in full there. That repo is an engineering port of someone else's environment design; this one is the from-scratch, paper-faithful baseline covering both of Leibo et al.'s own games and their own independent-DQN method.