Stable Voting Agent-Based Model
An agent-based model of repeated elections using the Stable Voting method, built on the Emergent ABM framework. It studies how voter preferences evolve over repeated elections when voters are socially connected and influence each other, and whether — and how quickly — an electorate converges on a stable winner when preferences drift through social interaction.
Abstract
Stable Voting (Holliday & Pacuit) is a single-winner, ranked-ballot rule grounded in Stability for Winners: if candidate A would win without B present, and A beats B head-to-head, then A should still win when B is included — unless another candidate has an equally valid claim. That design limits spoilers and behaves well under majority cycles. This implementation couples global Stable Voting elections with DeGroot-style social influence on cardinal preferences, ideal-point stubbornness so a connected network does not collapse to full consensus, and optional outcome feedback (
winner_pull).
Model Details
Sources
-
Holliday, W. H., & Pacuit, E. (2023). Stable Voting. Constitutional Political Economy, 34(3), 421–433. https://doi.org/10.1007/s10602-022-09383-9
-
Stable Voting refines Split Cycle (same authors): cycles are handled by discarding the weakest pairwise wins in each cycle. Stable Voting uses a recursive sub-election procedure to choose among candidates Split Cycle leaves undefeated.
Agents
Each agent is a voter (a graph node). Candidates are fixed and exogenous — not agents.
| Field | Type | Description |
| --- | --- | --- |
| ranking | list[str] | Ordered list of candidates, most to least preferred |
| scores | dict[str, float] | Cardinal preferences (Borda-style), updated each step; after blending, mapped to [1, n] |
| ideal | dict[str, float] | Initial scores, stored permanently as an anchor for stubbornness |
| satisfaction | float | Normalized rank of the current winner in this voter’s ranking; 1.0 if first choice, 0.0 if last |
Network topology
By default the model uses a Watts–Strogatz small-world graph (k=4, p=0.2, rewiring seed via ws_rewire_seed), for high clustering and short path length. Parameters ws_k, ws_p, and ws_rewire_seed are on the model. Setting graph_type to Emergent’s built-in values (complete, cycle, wheel) with num_nodes uses the library’s initialize_graph() instead; watts_strogatz builds a custom NetworkX graph and seeds nodes without calling initialize_graph() (which would replace the graph).
What happens each timestep (generateTimestepData)
Each model.timestep() runs generateTimestepData, in three phases.
Phase 1 — Global election. All ranking lists go into compute_margins: for each ordered pair (A, B), margin = (# preferring A over B) − (# preferring B over A). stable_voting returns the winner.
-
is_defeated(Split Cycle defeat): B defeats A iff there is no justifying chain from A to B — no path A = c₀, …, cₖ = B where each step’s margin is at least the margin of B over A. Implemented as a graph reachability check from A at that threshold; if B is reachable, B does not defeat A. -
stable_voting(recursive): For each candidate B, remove B and take the sub-winner; if that sub-winner A beats B head-to-head and B does not defeat A, A has a stable claim. Among claimed candidates, pick the one with the largest supporting head-to-head margin (matchups ordered by margin). Base cases: one candidate; two → majority. Fallbacks if no claims: most head-to-head wins among “undefeated”; then highest total pairwise margin sum.
Phase 2 — Preference update (read phase). Synchronous reads, then writes in phase 3.
-
Social:
social[c] = (1 - learning_rate) * own[c] + learning_rate * neighbor_avg[c](no neighbors → neighbor average = own). -
Ideal anchoring:
blended[c] = stubbornness * ideal[c] + (1 - stubbornness) * social[c]— avoids pure DeGroot consensus on a connected graph whenstubbornness > 0. -
Outcome feedback:
winner_pullis added on the winner’s score before min–max scaling. -
Normalization:
score[c] = 1 + (n - 1) * (blended[c] - min) / spreadso values lie in [1, n] after the step. -
Satisfaction:
(n_candidates - 1 - winner_rank) / (n_candidates - 1)from the ranking implied by final scores.
Phase 3 — Write. Apply all node updates; store model["last_winner"]. Timestep ends with model.set_graph(graph) (same pattern as other Emergent models in this repo).
Convergence
run_simulation loops timesteps until the standard deviation of satisfaction across voters is ≤ convergence_std_dev (via _satisfaction_std_dev), or max_timesteps is reached (converged=False). It avoids Emergent’s is_converged, which prints debug output.
With stubbornness > 0, preferences may plateau while satisfaction std dev stays above the threshold — persistent disagreement is intentional. The run may then hit max_timesteps without converged=True. A plateau-based stop (e.g. std dev stable for many steps) would better match that regime.
Key functions
| Function | Description |
| --- | --- |
| compute_margins(all_rankings, candidates) | Pairwise margin matrix from rankings |
| is_defeated(a, b, margins, candidates) | Split Cycle defeat check |
| stable_voting(margins, candidates) | Stable Voting winner |
| ranking_to_scores / scores_to_ranking | Ordinal ↔ Borda-style cardinal |
| blend_scores(...) | Social + ideal + winner_pull + normalization |
| generateInitialData(model) | Emergent init: random ranking, scores, ideal, satisfaction |
| generateTimestepData(model) | Election + preference update |
| constructModel(**overrides) | Configured AgentModel and graph |
| run_simulation(...) | Returns (model, winner_history, converged, steps) |
Parameters
| Parameter | Default | Description |
| --- | --- | --- |
| num_nodes | 40 | Number of voters |
| candidates | Alice, Bob, Carol, Dave | Fixed candidate set |
| graph_type | watts_strogatz | Topology; see Network section |
| ws_k | 4 | WS: ring neighbors |
| ws_p | 0.2 | WS: rewiring probability |
| ws_rewire_seed | 42 | WS: random seed |
| learning_rate | 0.15 | Neighbor pull, in [0, 1] |
| winner_pull | 0.0 | Score bonus for the winner before normalization |
| stubbornness | 0.2 | Pull toward ideal, in [0, 1] |
| convergence_std_dev | 0.02 | Satisfaction std threshold |
Interactions: learning_rate vs stubbornness trades off conformity vs durable disagreement. Nonzero winner_pull centralizes preferences over time; use 0.0 to isolate topology and social learning.
Installation and usage
pip install emergent networkx
python model.py
Custom run:
from model import run_simulation
model, winner_history, converged, steps = run_simulation(
max_timesteps=1000,
seed=7,
num_nodes=100,
learning_rate=0.1,
stubbornness=0.3,
winner_pull=0.0,
)
print(f"Converged: {converged} after {steps} steps")
print(f"Final winner: {winner_history[-1]}")
References
-
Holliday, W. H., & Pacuit, E. (2021). Stable Voting. arXiv:2108.00542. https://arxiv.org/abs/2108.00542
-
Holliday, W. H., & Pacuit, E. (2023). Split Cycle: A new Condorcet-consistent voting method independent of clones and immune to spoilers. Public Choice, 197(1), 1–62.
-
DeGroot, M. H. (1974). Reaching a consensus. Journal of the American Statistical Association, 69(345), 118–121.
-
Watts, D. J., & Strogatz, S. H. (1998). Collective dynamics of ‘small-world’ networks. Nature, 393, 440–442.