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.
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.
Cycle-aware validation: train on 2000–2020 (18,683 rows), test on 2024 (3,106 rows).
| Feature set | Model | RMSE | R² | 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.
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.
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 |
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 |
- MIT Election Lab — County Presidential Election Returns 2000–2024
- U.S. Census Bureau — Population Estimates Program (PEP)
- https://www2.census.gov/programs-surveys/popest/datasets/
cc-est2010-alldata.csv(2000–2010),CC-EST2020-AGESEX-ALL.csv(2010–2020),cc-est2025-agesex-all.csv(2020–2025)
- U.S. Census ACS 5-Year — education, income, unemployment, via
tidycensus- S-tables
S1501_C02_015,S1901_C01_012,S2301_C04_001Source data is committed via Git LFS (cc-est2010-alldata.csvis 193 MB, over GitHub's 100 MB limit). Clone with Git LFS installed to get the real files.
- S-tables
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 mapOr run_pipeline.bat (Windows) / ./run_pipeline.sh (macOS/Linux).
Free Census API key: https://api.census.gov/data/key_signup.html
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.
- 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_fipsholds 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.Rrounds the derived fractionalvap18to 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.Rmeasures it.
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.