A full-stack simulation of what American politics might look like under proportional representation, using real ideological data from the 2024 Cooperative Election Study (CES): 60,000+ respondents, 45,707 after listwise deletion.
▶ Explore the live simulation: https://usmultipartysystem.pages.dev/
Methodology:
docs/METHODOLOGY.md— how 45,707 survey responses become a ten-party legislature, with caveats. For the full technical reference (agent/developer guide), seedocs/AGENTS.md; for EFA factor loadings,docs/EFA_FACTORS.md.
This project explores what American politics might look like under different electoral rules:
- Multi-party proportional representation instead of winner-take-all
- Single Transferable Vote (STV) primaries with Droop quota surplus transfers
- A rolling presidential primary with geographic balance across 4 rounds
- Ranked Pairs Condorcet and IRV for senate general elections (reported side by side)
- Two candidate fields — party-line vs. crossover — run through every chamber and the presidency
2024 CES Survey (N=45,707)
│
▼
Exploratory Factor Analysis (EFA)
24 policy items → 5 latent ideological dimensions (F1–F5)
│
▼
DPGMM Clustering
Factor scores → 10 voter typology clusters (C0–C9)
│
▼
Two candidate fields (used for House, Senate, and President)
• Party-line (pure_multi): 3 identical-platform candidates per party
• Crossover (factor_deviation): candidates shifted ±1 axis off base
│
├──────────────────────────────────────┐
▼ ▼
House STV Simulation Senate Simulation
873 seats / 180 districts 51 senators (one per state)
Droop quota + Gregory surplus STV primary → Condorcet + IRV
→ pure_multi/ + factor_deviation/ 4 scenarios (2 fields × 2 methods)
│ │
└──────────────┬───────────────────────┘
▼
Chamber Vote Model
37 binary policy items
Sum-of-Binomials → P(pass)
Produced by Dirichlet Process Gaussian Mixture Model (DPGMM) clustering on 5 EFA factor scores. All 10 clusters are active parties, including OAO (cluster 7, the Order & Opportunity Party — the former "Blue Dogs," a law-and-order Democratic bloc kept as its own party). DISSOLVED_PARTIES = [].
| ID | Abbrev | Name | Character | ~Electorate |
|---|---|---|---|---|
| C0 | CON | Conservative | Mainstream center-right; pro-law enforcement, economically traditional | ~19% |
| C1 | SD | Social Democrat | Center-left institutionalist; supports safety net, moderate on social issues | ~16% |
| C2 | STY | Solidarity | Disaffected working-class left; skeptical of institutions, economically left but low union density | ~15% |
| C3 | NAT | Nationalist | Populist right; strong on immigration, anti-establishment, very high F5 | ~9% |
| C4 | LIB | Liberal | College-educated progressive; socially liberal, moderate-left economics | ~9% |
| C5 | POP | Populist | Right-of-center reformists; skeptical of elections, high F2 + F5 | ~11% |
| C6 | CUP | Civic Union Party | True centrists; cross-pressured, low electoral skepticism | ~10% |
| C7 | OAO | Order & Opportunity | Law-and-order Democrats (former "Blue Dogs"); a full party that wins seats | — |
| C8 | DSA | DSA | Progressive left; far left on security, high electoral skepticism | ~6% |
| C9 | PRG | Progressive | Progressive elite; urban, far left across most dimensions | ~5% |
House seat counts (party-line, canonical, 873 total): CON=201, LBR=159, STY=129, POP=106, LIB=93, CUP=89, NAT=41, DSA=25, OAO=15, PRG=15. Conservative is the largest party. Source: viz/src/data/houseSeats.json — see docs/DATA_SOURCES.md. (Do not quote clusterProfiles.json seatsHouse for seat counts — it's a population baseline, not seats won.)
Factor scores are standardized to the survey population (mean≈0, SD≈1). Absolute tier thresholds reflect position relative to the full electorate:
| Tier | Score Range |
|---|---|
| Very High | > +0.75 |
| High | +0.25 to +0.75 |
| Medium | −0.25 to +0.25 |
| Low | −0.75 to −0.25 |
| Very Low | < −0.75 |
High = pro-law enforcement, pro-border security, pro-surveillance Low = civil libertarian, anti-enforcement Top items: support increased police (+0.73), increase border patrols (+0.71), deny asylum (+0.66), oppose decreasing police (+0.65)
High = believes elections are NOT run fairly; distrusts voting systems Low = trusts electoral institutions Top items: state/local elections not fair (+0.90), US elections not fair (+0.73) Note: Near-orthogonal to partisan ID — STY, POP, and DSA all score High despite being ideologically distant on F1/F5.
High = low trust in federal and state government Low = trusts government institutions Top items: distrust federal govt (+0.66), distrust state govt (+0.48) Key finding: All 23 winning coalition types score Medium on F3 (range −0.21 to +0.13) — this axis does not differentiate winning coalitions at all.
High = traditional religious values; conservative on abortion and same-sex marriage Low = secular, socially progressive Top items: church attendance (+0.69), abortion week limits (+0.69), oppose same-sex marriage recognition (+0.65)
High = populist-right; anti-immigration, fiscal conservatism, racial traditionalism Low = progressive-left Top items (negative-loaded): racial resentment (−0.62), oppose police reform (−0.56), oppose Dreamers (−0.54), oppose tax hike on $400k+ (−0.53) NAT is the extreme high end (+1.51); PRG (−0.99) and LIB (−0.95) are the extreme low end.
Institutional distrust reads as a floor, not a level. Clustering requires a complete answer on all 24 items, which restricts the analysis sample to the ~45,700 respondents who returned for the post-election wave. That attrition is not random: the returners run about eleven years older and markedly more politically engaged than the full 60,000, and the CES post-election weights correct the demographic margins but not the engagement gap. The exposure concentrates on one battery. The institutional-trust items, whether elections are run fairly and how far federal and state government can be trusted, are all post-wave, so the voters who left are absent from exactly the questions their attitudes would move most. And among those who did answer, the least engaged are the most distrustful: they score highest on election-illegitimacy and on distrust of state government. The alienated, low-information, "system is rigged" voter is the one least likely to have stayed to say so. So the simulation more likely understates institutional distrust than overstates it, especially toward elections and state government.
Broader modeling caveats (party cohesion, sincere voting, static factor space, turnout) live in docs/METHODOLOGY.md and the simulation's Caveats tab.
Scripts: pipeline/stv_main.py and supporting pipeline/stv_step1.py–pipeline/stv_step5.py
Published result: data/outputs/pure_multi/house/stv_seat_summary.csv → viz/src/data/houseSeats.json (the party-line view the viz shows).
Note: published seat counts come from pure_multi, not from data/outputs/No_C7_canonical/stv_seat_summary.csv (an outdated 750-seat summary — don't quote it). The No_C7_* directories are retained on purpose: the pure_multi and factor_deviation runs read their ballots_checkpoint.parquet + district_apportionment.csv, and the viz transfer matrix is built from No_C7_canonical/transfer_matrix_10party.csv. See docs/DATA_SOURCES.md.
- 873 seats across 180 multi-member districts (Urban / Suburban / Rural tiers per state)
- Apportionment: Hamilton method, ~380,000 pop/seat from 2020 Census
- Droop quota:
⌊ total_weight / (seats + 1) ⌋ + 1 - Gregory surplus transfer: fractional weight redistribution when a candidate exceeds quota
- Scenarios may dissolve a party (
pre_dissolved); the baseline dissolves none. A dissolved party's ballots skip to the next-ranked active party. - Ballots derived from DPGMM soft cluster probabilities via Plackett-Luce ranking
Seat results (party-line, canonical):
| Party | Seats | % | Urban | Suburban | Rural |
|---|---|---|---|---|---|
| CON | 202 | 23.1% | 106 | 68 | 28 |
| SD | 164 | 18.8% | 96 | 51 | 17 |
| STY | 130 | 14.9% | 71 | 50 | 9 |
| CUP | 103 | 11.8% | 59 | 30 | 14 |
| POP | 99 | 11.3% | 47 | 38 | 14 |
| LIB | 93 | 10.7% | 62 | 26 | 5 |
| NAT | 46 | 5.3% | 16 | 18 | 12 |
| DSA | 22 | 2.5% | 14 | 6 | 2 |
| PRG | 14 | 1.6% | 10 | 3 | 1 |
This is the party-line field. The crossover field (run_fd_house_stv.py → data/outputs/factor_deviation/house/) runs the same STV over axis-shifted candidates; the viz House tab toggles between the two fields and the double/triple Wyoming-rule apportionments.
Scripts: crossover field — pipeline/run_fd_senate_simulation.py; party-line field — pipeline/pure_only/run_pure_multi_senate.py (each runs both Condorcet and IRV)
Outputs: data/outputs/factor_deviation/senate/ and data/outputs/pure_multi/senate/ — senate_composition.csv (Condorcet) and senate_irv_composition.csv (IRV)
- 51 senators — one per state (50 states + DC).
- The senate uses the same two candidate fields as the rest of the simulation (there are no "blend" candidates):
- Party-line (
pure_multi) — 3 candidates per party, all on the identical party platform (e.g.STY_1). Tests proportional voting with parties as monoliths. - Crossover (
factor_deviation) — 9 pure party candidates + 28 crossover candidates that shift one party off its base on a single axis: Security & Order, Anti-Establishment, or Populist Conservatism (±). Variant codes likeSTY_lo_ae(Solidarity, less anti-establishment).
- Party-line (
- In each state, an STV primary narrows the field; the general is then decided two ways — Ranked Pairs Condorcet (beats-all winner) and IRV (last-place elimination).
- Two fields × two methods = 4 scenarios.
Senate seats by party across the four scenarios:
| Party | Crossover Cond | Crossover IRV | Party-line Cond | Party-line IRV |
|---|---|---|---|---|
| STY | 34 | 19 | 33 | 11 |
| SD | 11 | 27 | 15 | 26 |
| CON | 1 | 3 | 1 | 11 |
| POP | 5 | 1 | 1 | 2 |
| CUP | 0 | 1 | 1 | 0 |
| LIB | 0 | 0 | 0 | 1 |
NAT, DSA, and PRG win 0 senate seats in every scenario. Pattern: Condorcet rewards broad acceptability, so Solidarity (the spatial median) dominates; IRV rewards first-choice intensity, so Social Democrat leads and Conservative breaks through in the party-line field. Full state-by-state results are in comms/party_profiles.md.
Script: pipeline/chamber_vote_model.py
Outputs: data/outputs/senate/senate_vote_model.csv, data/outputs/house_vote_model.csv
For each of 37 binary CC24_ policy items, models the probability of a bill passing a floor vote.
Method — Sum-of-Independent-Binomials → Normal approximation:
E[Y] = Σᵢ nᵢ · pᵢ (expected yes votes)
σ²[Y] = Σᵢ nᵢ · pᵢ · (1 − pᵢ)
P(pass) = 1 − Φ((majority − 0.5 − E[Y]) / σ[Y]) (continuity correction)
where nᵢ = seats held by type i, pᵢ = that type's % supporting / 100.
Verdict thresholds: PASS ≥67% | TOSS-UP 33–67% | FAIL ≤33% Results (both chambers): 29 PASS / 1 TOSS-UP / 7 FAIL
These chambers are cross-cutting — they simultaneously pass both tax cuts AND tax hikes, both border enforcement AND Dreamer protections. Different majority coalitions form for each vote. This is expected behavior for proportional multi-party representation.
Script: pipeline/cross_chamber_coalitions.py
Output: data/outputs/coalitions/ (feeds the viz House tab's coalition view)
A secondary diagnostic — not the election model (the chambers are elected via the party-line and crossover fields above). For each of the 5 factor dimensions it shows which house and senate party types fall on the same side, surfacing issue-specific coalition partners rather than overall ideological proximity. Per factor it computes k=2 poles (1D k-means) and absolute EFA-scale tiers.
| Output File | Contents |
|---|---|
coalition_type_profiles.csv |
per-type F1–F5 scores, chamber tag, seat counts |
coalition_factor_alignment.csv |
per-(factor × type) rank, k=2 pole, absolute tier |
coalition_pairwise.csv |
per-factor pairwise alignment scores (0–1) |
All outputs are under data/outputs/.
| File | Description |
|---|---|
pure_multi/house/stv_seat_summary.csv |
Party-line House seat totals by party + density tier (→ houseSeats.json) |
factor_deviation/house/stv_seat_summary.csv |
Crossover House seat totals (→ fdHouseSeats.json) |
pure_multi/senate/senate_composition.csv · senate_irv_composition.csv |
Party-line Senate — Condorcet / IRV winner per state |
factor_deviation/senate/senate_composition.csv · senate_irv_composition.csv |
Crossover Senate — Condorcet / IRV winner per state |
No_C7_canonical/{ballots_checkpoint.parquet, district_apportionment.csv} |
Shared ballot + apportionment cache the pure_multi / factor_deviation runs read as input — see DATA_SOURCES.md |
No_C7_canonical/transfer_matrix_10party.csv |
Vote-transfer matrix (→ viz transferMatrix.json) |
profiles/cluster_stats.csv |
Per-item statistics for all 10 clusters (policy + demographics) |
senate/senate_chamber_profile.csv · house_chamber_profile.csv |
Seat-weighted policy aggregates per chamber (vote-model inputs) |
senate/senate_vote_model.csv · house_vote_model.csv |
37-item bill passage probability per chamber |
coalitions/coalition_type_profiles.csv (+ _factor_alignment, _pairwise) |
Secondary cross-chamber factor-alignment diagnostic |
All scripts live in pipeline/ and use relative paths anchored to the project root.
All 10 clusters are active parties (C7 = Order and Opportunity Party). The election
sims + prepare_data.py are the reproducible path; they read data/processed/*.csv and
the geography checkpoint in data/outputs/No_C7_canonical/ (party-independent).
# ── Base geography checkpoint (already cached; rebuilds ballots + apportionment) ──
python3 pipeline/stv_main.py
# ── Party-line field (pure_multi): ballots, primary, president, house, senate ──
python3 pipeline/pure_only/generate_pure_multi_ballots.py
python3 pipeline/pure_only/generate_party_ballots.py # single-candidate ballots (transfers)
python3 pipeline/pure_only/run_pure_multi_primary.py
python3 pipeline/pure_only/run_pure_multi_presidential.py
python3 pipeline/pure_only/run_pure_multi_house_stv.py # → pure_multi/house/
python3 pipeline/pure_only/run_pure_multi_house_stv.py --triple # → pure_multi_triple/house/
python3 pipeline/pure_only/run_pure_multi_senate.py # → pure_multi/senate/ (Condorcet + IRV)
# ── "No Solidarity" scenario: cluster 2 dissolved, its voters flow to the ──────
# remaining 9. NO_STY=1 drops STY candidates and redirects output to pure_multi_nosty/.
NO_STY=1 python3 pipeline/pure_only/generate_pure_multi_ballots.py
NO_STY=1 python3 pipeline/pure_only/run_pure_multi_primary.py
NO_STY=1 python3 pipeline/pure_only/run_pure_multi_presidential.py
NO_STY=1 python3 pipeline/pure_only/run_pure_multi_senate.py
NO_STY=1 python3 pipeline/pure_only/run_pure_multi_house_stv.py # → pure_multi_nosty/house/
# ── Crossover field (factor_deviation) ──────────────────────────────────────
python3 pipeline/generate_factor_deviation_candidates.py # OAO fields base only (small party)
python3 pipeline/generate_factor_deviation_ballots.py
python3 pipeline/generate_factor_deviation_stats.py # per-candidate policy support
python3 pipeline/run_fd_house_stv.py # → factor_deviation/house/
python3 pipeline/run_fd_house_stv.py --triple
python3 pipeline/run_fd_senate_simulation.py # → factor_deviation/senate/ (Condorcet + IRV)
python3 pipeline/run_fd_primary_2028.py
python3 pipeline/run_fd_irv_2028.py # FD presidential
# ── Canonical house + shared profiles ───────────────────────────────────────
python3 pipeline/run_house_canonical.py # → No_C7_canonical/ (party-card seats)
python3 pipeline/house_chamber_profile.py # House chamber policy aggregate
python3 pipeline/generate_blend_stats.py # blended-type policy support (senate analysis)
# ── Respondent-level extras (read the raw .dta directly) ────────────────────
python3 pipeline/add_compare_items.py # FP/abortion/voting items → cluster_stats.csv
python3 pipeline/compute_intensity.py # full multi-point distributions → viz/src/data/clusterIntensity.json
# ── Visualization ──────────────────────────────────────────────────────────
cd viz && python3 scripts/prepare_data.py # Regenerate all JSON from outputs
npm run dev # Dev server
npm run build # Production build → dist/Legacy senate vote-model chain (
senate_chamber_profile.py→chamber_vote_model.py→generate_results.py, pluscross_chamber_coalitions.py) produces analysis CSVs and depends on the archived single-candidatepure_only/senate/run. It is not required for the viz:prepare_data.pyrecomputes every Legislation column (Party-Line, Crossover, Triple — House and Senate) directly from the native seat counts × per-cluster policy support, so those numbers reflect the current 10-party chambers without the old chain.
EFA/clustering use the full ordinal responses — the 0/1 collapse is display-only.
The typology is built from a polychoric correlation matrix (analysis/efa/pipeline/run_efa.py)
and Bartlett factor scores from the standardized full-scale item matrix
(analysis/efa/pipeline/efa_update.py): multi-point items enter at full resolution
(reverse-coding only flips direction; "Not sure" on the 1–3 trust items is mapped to the
scale midpoint, not dropped), so neutral/middle opinions are preserved in the factor scores
and cluster assignments. Binarization to a single "% agree"/"% increase" happens only in the
display layer (add_compare_items.py); compute_intensity.py recovers the full distributions
for the viz. (An earlier commit message on the Intensity view wrongly stated the clustering
ran on collapsed encodings — it does not.)
stv_main.py and generate_blend_stats.py were archived in an earlier cleanup and have
been restored to pipeline/ and re-keyed to the current party codes. The only piece still
archive-only is the single-candidate pure_only/senate/ run, which feeds the legacy
senate vote-model chain (see the note above) and is superseded by prepare_data.py's
recompute. Older archived copies under data/outputs/archive/ and pipeline/archive/
predate three party renames (Reform→Populist, Center→Civic Union, SD→Labor, C7→OAO) and
should not be restored as-is.
python3 >= 3.10
numpy
pandas
scipy
scikit-learn
pyreadstat # Stata .dta reading
pyarrow # Parquet I/O for ballot checkpoint
plotly # HTML cluster profile visualizations
openpyxl # Excel read for county density-tier map
| Dataset | Description | Path |
|---|---|---|
| 2024 CES | ~60,000 respondents, 2024 Cooperative Election Study | data/raw/2024 CES Base/CCES24_Common_OUTPUT_vv_topost_final.dta |
| Typology | DPGMM cluster assignments (45,707 rows) | data/processed/typology_cluster_assignments.csv |
| EFA scores | 5 factor scores per respondent | data/processed/efa_factor_scores.csv |
| Polychoric matrix | 24×24 polychoric correlation matrix | data/processed/polychoric_matrix.csv |
Analysis sample: N=45,707 after listwise deletion (24 items + commonpostweight non-missing).
