Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TurnoutLens — County Voter Turnout, Explained

APP: https://alexbiuckians.shinyapps.io/turnoutlens/

An end-to-end R project that predicts U.S. county-level voter turnout, explains what drives it, and serves the result through an interactive Leaflet map. It models how many people vote — never who they vote for.

Seven presidential elections (2000–2024), ~3,100 counties, 21,789 county-cycle observations, built from real MIT Election Lab returns and real U.S. Census data.


The headline finding

A model that knows only demographics systematically under-predicts turnout in same-day-registration states and over-predicts it in restrictive-registration states — recovering the effect of election law from its own residuals, without ever being told election law exists.

Demographics alone (age structure, education, income, unemployment, density) explain 61% of out-of-sample variance in county turnout — trained on 2000–2020, tested on 2024, a year the model never saw. What it can't explain is the interesting part.

Each county's turnout gap (actual − demographic expectation) clusters geographically, hard:

Quantity Moran's I p
Raw turnout 0.441 ~0
Demographic-model residual 0.391 2.2e-290

Adjusting for demographics removes only 0.05 of the spatial clustering. Nearly all the geographic structure in American turnout survives.

Local Moran (LISA) clusters name the states (2024, p < 0.05):

Over-performing Under-performing
MN (64 counties), MI (60), WI (37), NC (24), IA (22), CO (10) TX (79), KS (20), NM (12), NY (8)

Mean gap by state:

Over gap Under gap
MI +0.032 AR −0.045
MN +0.027 WV −0.040
WI +0.020 OK −0.034
NC +0.013 NM −0.029
CO +0.011 TX −0.027

Minnesota, Michigan, Wisconsin, Iowa, Colorado, and Maine are the canonical election-day-registration states. Arkansas, West Virginia, Oklahoma, and Texas are among the most restrictive. The model found that from demographics and geography alone.


Benchmark

Cycle-aware validation: train on 2000–2020 (18,683 rows), test on 2024 (3,106 rows).

Feature set Model RMSE MAE
demographics only (PRIMARY) rf 0.0720 0.610 0.0566
demographics only lgbm 0.0741 0.595 0.0587
demographics only linear 0.0742 0.484 0.0575
with prior_turnout (BENCHMARK) linear 0.0580 0.915 0.0512
with prior_turnout rf 0.0603 0.889 0.0526
with prior_turnout lgbm 0.0758 0.899 0.0697

The lag model is 19.5% more accurate and far less useful — its answer is "counties vote like they voted." Permutation importance on it showed prior_turnout doing nearly all the work (RMSE 0.051 → 0.113 when permuted) while pct_bachelors moved it by 0.0001. The lag masks the demographics. Dropping it costs 20% accuracy and buys a model whose residuals are the finding. See docs/CASE_STUDY.md.

Note the flip: linear wins with the lag, RF wins without it. The lag relationship is nearly linear; the demographic ones are not.

What actually drives turnout

Permutation importance, demographics-only model (baseline RMSE ≈ 0.044):

Variable RMSE when permuted
median_age_proxy 0.0724
pct_18_24 0.0658
pct_65plus 0.0591
pop_total 0.0575
pop_growth 0.0573
log_income 0.0571
pct_bachelors 0.0563
unemp_rate 0.0492
rurality_band 0.0450
income_per_educ 0.0443

Age structure dominates. Education matters less than the conventional story suggests once age is accounted for.

Equity audit

Error is even across the rural–urban spectrum, with essentially zero directional bias:

Band MAE Bias n
mid 0.0252 +0.0001 4,919
rural 0.0272 −0.0003 4,864
small 0.0281 −0.0002 10,202
urban 0.0246 +0.0010 1,804

Architecture

Raw data (Census PEP x3 decades + MIT returns)
        │
   [ SQL layer ]   PostgreSQL 17: schema, CTEs, LAG/PERCENT_RANK window functions
        │
   [ R pipeline ]  harmonize -> turnout -> leakage-safe features -> tidymodels benchmark
        │
   [ DALEX ]       permutation importance, ALE, per-county break-down, equity audit
        │
   [ spdep ]       Moran's I + LISA on demographic residuals
        │
   [ Shiny app ]   Leaflet choropleth, per-county explanation, what-if sliders
Layer Tools
Language R (tidyverse, tidymodels)
Database PostgreSQL 17 (CTEs, window functions)
Models linear, ranger (RF), LightGBM (bonsai)
Explainability DALEX (permutation importance, ALE, break-down)
Geospatial sf, tigris, Leaflet, spdep (Moran's I, LISA)
App Shiny + bslib

Data sources

How to run

Rscript R/00_setup.R                  # install deps
Rscript -e "tidycensus::census_api_key('YOUR_KEY', install=TRUE)"
 
Rscript R/01_harmonize_population.R   # 3 Census decades -> 1 table
Rscript R/01b_acs_pull.R              # real ACS predictors
Rscript R/02_build_frame.R            # join -> turnout
Rscript R/03_load_postgres.R          # optional: Postgres + SQL pull
Rscript R/04_features.R               # leakage-safe features + audit
Rscript R/05_model.R                  # dual-model benchmark + turnout_gap
Rscript R/06_explain.R                # DALEX
Rscript R/07_spatial.R                # Moran's I + LISA
Rscript R/08_gap_analysis.R           # cleaned gap ranking
 
Rscript -e "shiny::runApp('app')"     # the map

Or run_pipeline.bat (Windows) / ./run_pipeline.sh (macOS/Linux).

Free Census API key: https://api.census.gov/data/key_signup.html


Turnout definition

turnout = totalvotes / voting-age population (18+)

VAP turnout, not VEP or CVAP: the denominator is everyone 18+, including non-citizens and ineligible persons. Chosen for consistency across all seven cycles from a single population source.

The bias is visible in the output. VAP inflates the denominator most in high-immigration counties, which should make them look artificially low — and Queens NY (−0.094), Bronx NY (−0.094), and Hudson NJ (−0.088) are among the largest under-performers. The limitation shows up exactly where theory predicts.

Limitations

  • Correlational, not causal. EDR states also differ in political culture, education, and competitiveness. The residuals show something state-level drives turnout beyond demographics; registration law is the best-supported explanation, not a proven one.
  • VAP, not CVAP — see above; visible in the NY/NJ under-performers.
  • Hawaii's four counties all under-perform (−0.079 to −0.089) despite Hawaii being heavily vote-by-mail. Unexplained; a genuine loose end.
  • ACS starts ~2009, so 2000/2004/2008 are median-imputed for education, income and unemployment. Tested: keeping those cycles still beats dropping them (R/experiment_acs_years.R) — the extra lag history outweighs the inert features.
  • Alaska excluded from the gap ranking and spatial analysis — the MIT codebook states county_fips holds districts, not counties, and that returns overstate votes.
  • Micro-counties excluded below 20,000 population — turnout ratios are unstable when the denominator is tiny, and several hit the 1.0 cap (Loving TX, pop ~60).
  • SQL/R cross-check: max diff 0.0016 on turnout across 7,505 of 18,672 rows, confined to 2004/2008/2012. Cause: 03_load_postgres.R rounds the derived fractional vap18 to integer on load, so Postgres divides by a slightly different denominator than R. Immaterial to conclusions (0.16% max) but real, and documented rather than hidden. R/09_sql_crosscheck.R measures it.

Validation that the data foundation is right

Median county turnout by cycle, computed from three differently-shaped Census files joined to MIT returns:

2000 2004 2008 2012 2016 2020 2024
53.3% 59.0% 60.0% 56.4% 57.7% 63.8% 62.0%

2020 as the high-water mark matches the historical record — independent evidence the year-code decoding and denominator construction are correct. See the case study for the four silent traps in those files.

About

Predicting U.S. county voter turnout in R. Demographics explain 61% of out-of-sample variance; the residuals recover the effect of election law (Moran's I = 0.391, p = 2.2e-290).

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages