Creating my AI Election Forecasting App
I once tracked election polls daily and wanted a forecast that revealed every step rather than a black box. That curiosity turned into a project that reads public polling data, engineers time‑series features, and predicts weekly margins. I chose Streamlit because I could share a transparent, reproducible app that runs directly from a GitHub repository. The goal is simple: show how the national generic ballot margin moves week by week, and document the full pipeline that makes it possible. Dataset used here.

While exploring datasets, I found that careful feature design matters as much as the model. Lagged margins capture momentum, rolling averages tame noise, and poll quality indicators help weigh stronger surveys. This blog explains every file I uploaded, every helper function I wrote, and how the parts fit together into a working forecast.
Repository Layout and Dependencies
I uploaded four key elements to GitHub: the Streamlit app (app.py
), a data/
folder with the polling CSV, a models/
folder with the trained estimator and metadata, and requirements.txt
to pin versions.
streamlit==1.36.0
pandas==2.2.2
numpy==1.26.4
scikit-learn==1.4.2
joblib==1.4.2
matplotlib==3.8.4
These dependencies are the exact versions I tested. Streamlit renders the interface, pandas/numpy handle data, scikit‑learn powers the regressor, joblib loads the artifact, and matplotlib draws the time‑series charts.
Data Snapshot
The app expects data/generic_ballot_polls.csv
. Here is a small preview:
poll_id pollster_id pollster sponsor_ids sponsors display_name pollster_rating_id pollster_rating_name numeric_grade pollscore methodology transparency_score state start_date end_date sponsor_candidate_id sponsor_candidate sponsor_candidate_party question_id sample_size population subpopulation population_full tracking created_at notes url source internal partisan race_id cycle office_type seat_number seat_name election_date stage nationwide_batch dem rep ind
0 87781 1102 Emerson NaN NaN Emerson College 88 Emerson College 2.9 -1.1 IVR/Online Panel/Text-to-Web 7.0 NaN 8/12/24 8/14/24 NaN NaN NaN 206005 1000.0 lv NaN lv NaN 8/15/24 09:30 NaN https://emersoncollegepolling.com/august-2024-national-poll-harris-50-trump-46/ NaN NaN NaN 9549 2024 U.S. House NaN Generic 11/5/24 general False 47.5 45.5 NaN
1 87760 568 YouGov 352 Economist YouGov 391 YouGov 2.9 -1.1 Online Panel 9.0 NaN 8/11/24 8/13/24 NaN NaN NaN 205807 1407.0 rv NaN rv NaN 8/14/24 10:02 NaN https://d3nkl3psvxxpe9.cloudfront.net/documents/econtoplines_W3lebBm.pdf NaN NaN NaN 9549 2024 U.S. House NaN Generic 11/5/24 general False 45.0 44.0 NaN
2 87774 320 Monmouth NaN NaN Monmouth University Polling Institute 215 Monmouth University Polling Institute 2.9 -0.9 Live Phone/Text-to-Web 9.0 NaN 8/8/24 8/12/24 NaN NaN NaN 205881 801.0 rv NaN rv NaN 8/14/24 11:15 NaN https://www.monmouth.edu/polling-institute/reports/MonmouthPoll_US_081424/ NaN NaN NaN 9549 2024 U.S. House NaN Generic 11/5/24 general False 48.0 46.0 NaN
3 87791 1347 Cygnal NaN NaN Cygnal 67 Cygnal 2.1 -1.3 NaN 4.0 NaN 8/6/24 8/8/24 NaN NaN NaN 206110 1500.0 lv NaN lv NaN 8/15/24 20:42 NaN https://www.cygn.al/national-poll-2024-election-has-re-entered-stasis-while-trump-republicans-maintains-advantage/ NaN NaN NaN 9549 2024 U.S. House NaN Generic 11/5/24 general False 46.4 47.1 NaN
4 87696 568 YouGov 352 Economist YouGov 391 YouGov 2.9 -1.1 Online Panel 9.0 NaN 8/4/24 8/6/24 NaN NaN NaN 205394 1413.0 rv NaN rv NaN 8/7/24 10:45 NaN https://d3nkl3psvxxpe9.cloudfront.net/documents/econtoplines_OHhDhBP.pdf NaN NaN NaN 9549 2024 U.S. House NaN Generic 11/5/24 general False 45.0 44.0 NaN
The model focuses on Democratic and Republican support to compute a margin, while also using poll quality fields and sample sizes as predictors. Rows are later grouped by calendar week for stable comparison.
Training Metadata
I store training details in models/train_metadata.json
so inference knows what to compute:
{
"feature_cols": [
"polls_per_week",
"avg_sample",
"avg_grade",
"avg_transparency",
"avg_pollscore",
"margin_lag1",
"margin_lag2",
"margin_lag3",
"margin_lag4",
"margin_lag5",
"margin_lag6",
"margin_lag7",
"margin_lag8",
"margin_rollmean_3",
"margin_rollmean_5",
"margin_rollmean_8"
],
"train_weeks": 36,
"test_weeks": 10,
"last_train_date": "2024-06-03",
"mae": 1.1721256437772012,
"r2": -0.6977368496462497
}
The feature_cols
list documents the expected lags and rolling windows. The file also records the training/test split and error metrics so I can compare future retrains.
Application Script (app.py
) — Merged Blocks with Explanations
imports
import os
import json
import math
import joblib
import numpy as np
import pandas as pd
import streamlit as st
import matplotlib.pyplot as plt
from datetime import datetime
# -----------------------------
# App config
# -----------------------------
I import core libraries used across the app. os
and json
handle paths and configuration files. joblib
loads the trained scikit‑learn estimator from disk. numpy
and pandas
power numeric work and data frames. matplotlib.pyplot
lets me draw figures that Streamlit can display. streamlit
is the UI engine. datetime
utilities help me align polls by calendar week for stable comparisons.
page_config
st.set_page_config(page_title="Generic Ballot: Weekly Margin Forecast", layout="wide")
I configure the Streamlit page early. The wide layout gives charts enough space, and the title sets context. This helps the dashboard feel like a single‑purpose tool rather than a generic demo.
title_header
st.title("Generic Ballot: Weekly Dem–Rep Margin Forecast")
# -----------------------------
# Helper functions (match training logic)
# -----------------------------
DATE_COLS_CANDIDATES = ["end_date", "start_date"]
I add a visible title at the top of the app so users understand that the focus is a national generic ballot forecast. Establishing this heading makes the following controls and charts easier to digest.
def:_to_datetime_safe
def _to_datetime_safe(series):
return pd.to_datetime(series, errors="coerce")
This helper converts a pandas
series to timezone‑naive datetimes and coerces bad strings to NaT
. Polling datasets sometimes include inconsistent date formats or empty cells; coercion prevents hard crashes. By returning a clean datetime series, I can safely group rows by ISO calendar week later in the pipeline.
def:load_training_metadata
def load_training_metadata(meta_path="models/train_metadata.json"):
if not os.path.exists(meta_path):
st.error("Missing models/train_metadata.json. Upload it to your repo's models/ folder.")
st.stop()
with open(meta_path, "r") as f:
meta = json.load(f)
return meta
This loader reads models/train_metadata.json
to retrieve the exact feature columns, training split sizes, and evaluation metrics. If the file is missing, the function warns through Streamlit so users know the model artifacts were not uploaded. Returning a dictionary keeps downstream code explicit about which engineered columns must be built for inference.
def:load_model
def load_model(model_path="models/national_margin_forecaster.joblib"):
if not os.path.exists(model_path):
st.error("Missing models/national_margin_forecaster.joblib. Upload it to your repo's models/ folder.")
st.stop()
return joblib.load(model_path)
This function loads models/national_margin_forecaster.joblib
. The artifact contains a scikit‑learn regressor fitted on weekly features. By separating storage from code, I avoid retraining at runtime and keep app startup fast. The function raises a friendly error if the model file is not present, guiding setup.
def:load_poll_csv
def load_poll_csv():
"""
Tries to read data/generic_ballot_polls.csv from the repo.
If not present, lets the user upload the CSV at runtime.
"""
default_path = "data/generic_ballot_polls.csv"
if os.path.exists(default_path):
df = pd.read_csv(default_path)
st.info("Loaded bundled data from data/generic_ballot_polls.csv")
return df
uploaded = st.file_uploader("Upload generic_ballot_polls.csv", type=["csv"])
if uploaded is not None:
df = pd.read_csv(uploaded)
st.success("CSV uploaded.")
return df
st.warning("Please upload generic_ballot_polls.csv, or add it to data/ in the repo.")
st.stop()
This function reads data/generic_ballot_polls.csv
with pandas.read_csv
. It selects the columns I rely on most, including pollster ratings, sample size, cycle, and Democratic/Republican support. Centralizing the load step makes it easy to swap in an updated CSV while keeping the rest of the code stable.
def:basic_clean_and_weekly
def basic_clean_and_weekly(df):
"""
Match the training preprocessing:
- parse dates
- margin = dem - rep
- weekly national average across pollsters
"""
# Parse date columns if present
for col in ["start_date", "end_date", "election_date"]:
if col in df.columns:
df[col] = _to_datetime_safe(df[col])
# Numeric coercions used in training (safe)
for col in ["dem", "rep", "ind", "numeric_grade", "pollscore", "transparency_score", "sample_size"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# Margin
if not {"dem", "rep"}.issubset(df.columns):
st.error("CSV must include 'dem' and 'rep' columns.")
st.stop()
df["margin"] = df["dem"] - df["rep"]
# Pick date column preference like training
date_col = None
if "end_date" in df.columns:
date_col = "end_date"
elif "start_date" in df.columns:
date_col = "start_date"
else:
st.error("CSV must have end_date or start_date column.")
st.stop()
df = df.dropna(subset=[date_col, "margin"]).copy()
# Weekly alignment (week start for stability)
df["date"] = df[date_col].dt.to_period("W").dt.start_time
# Aggregate national weekly averages
weekly = (
df.groupby("date", as_index=False)
.agg(
margin=("margin", "mean"),
polls_per_week=("margin", "size"),
avg_sample=("sample_size", "mean") if "sample_size" in df.columns else ("margin", "size"),
avg_grade=("numeric_grade", "mean") if "numeric_grade" in df.columns else ("margin", "size"),
avg_transparency=("transparency_score", "mean") if "transparency_score" in df.columns else ("margin", "size"),
avg_pollscore=("pollscore", "mean") if "pollscore" in df.columns else ("margin", "size"),
)
.sort_values("date")
.reset_index(drop=True)
)
# If helper cols are entirely NaN, set to 0.0 (like training)
for c in ["avg_sample", "avg_grade", "avg_transparency", "avg_pollscore"]:
if c not in weekly.columns:
weekly[c] = 0.0
elif weekly[c].isna().all():
weekly[c] = 0.0
return weekly
Here I standardize the raw poll rows and aggregate them into weekly observations. The function computes the weekly Democratic minus Republican margin and derives per‑week quality summaries like average grade and pollscore. It also counts polls per week and stores average sample size, which later act as predictors and diagnostics.
def:make_lags
def make_lags(df_ts, target_col="margin", lags=8, roll_windows=(3, 5, 8)):
out = df_ts.copy()
for L in range(1, lags + 1):
out[f"{target_col}_lag{L}"] = out[target_col].shift(L)
for w in roll_windows:
out[f"{target_col}_rollmean_{w}"] = out[target_col].rolling(w).mean().shift(1)
return out
This routine adds autoregressive structure by creating lagged margins and rolling means (3, 5, and 8 weeks). Lags capture momentum, while rolling windows smooth noise from volatile weekly samples. These engineered time‑series features match the feature_cols
listed in metadata and are crucial for stable predictions.
def:infer_lags_and_windows_from_features
def infer_lags_and_windows_from_features(feature_cols):
"""
Reads back the lags and rolling windows that were used during training
from metadata['feature_cols'].
"""
lags = set()
rolls = set()
for c in feature_cols:
if c.startswith("margin_lag"):
try:
lags.add(int(c.replace("margin_lag", "")))
except:
pass
if c.startswith("margin_rollmean_"):
try:
rolls.add(int(c.replace("margin_rollmean_", "")))
except:
pass
lags = sorted(list(lags)) if lags else list(range(1, 9))
rolls = sorted(list(rolls)) if rolls else [3, 5, 8]
return lags, rolls
Given the feature_cols
from metadata, this helper infers which lags and rolling windows the model expects. It parses column names like margin_lag3
or margin_rollmean_5
and returns the numeric offsets. This allows me to compute exactly the required features even if I later tweak the model’s specification.
def:recursive_forecast
def recursive_forecast(last_history_df, model, horizon_weeks, feature_cols):
"""
last_history_df: weekly df with columns: date, margin, polls_per_week, avg_* ...
We build lag/rolling features each step using the growing history (actual + predicted).
Returns a dataframe with future dates and predicted margins.
"""
# Determine lags and rolling windows from training features
lags, rolls = infer_lags_and_windows_from_features(feature_cols)
max_lag = max(lags) if lags else 8
rolls = tuple(rolls) if rolls else (3, 5, 8)
# Work on a copy
hist = last_history_df.copy().reset_index(drop=True)
# Ensure we have enough history to create features
if hist.shape[0] < max(max_lag, max(rolls)):
raise ValueError(f"Not enough weekly history to create features. Need at least {max(max_lag, max(rolls))} weeks, have {hist.shape[0]}.")
# Start forecasting one week at a time
preds = []
last_date = hist["date"].max()
for step in range(1, horizon_weeks + 1):
next_date = last_date + pd.Timedelta(days=7)
# Build a temporary series with all rows so far
tmp = pd.concat([hist], ignore_index=True)
tmp = make_lags(tmp, target_col="margin", lags=max_lag, roll_windows=rolls)
# Feature row is the last row (after adding placeholder for next week)
# Create a placeholder row for next_date by copying aux features from latest row
latest_aux = tmp.iloc[-1][["polls_per_week", "avg_sample", "avg_grade", "avg_transparency", "avg_pollscore"]].to_dict()
next_row = {
"date": next_date,
"margin": np.nan, # unknown yet
"polls_per_week": latest_aux.get("polls_per_week", 0.0),
"avg_sample": latest_aux.get("avg_sample", 0.0),
"avg_grade": latest_aux.get("avg_grade", 0.0),
"avg_transparency": latest_aux.get("avg_transparency", 0.0),
"avg_pollscore": latest_aux.get("avg_pollscore", 0.0),
}
tmp = pd.concat([tmp, pd.DataFrame([next_row])], ignore_index=True)
# Recompute lags/rolls including the appended row
tmp = make_lags(tmp, target_col="margin", lags=max_lag, roll_windows=rolls)
# Select features for the last row (new week)
X_row = tmp.iloc[[-1]][feature_cols].copy()
# If any lag/roll features are still NaN (very early horizon), fill with last known values
X_row = X_row.fillna(method="ffill", axis=1).fillna(0.0)
# Predict next margin
yhat = float(model.predict(X_row)[0])
preds.append({"date": next_date, "pred_margin": yhat})
# Append prediction into history so next step can use it
hist = pd.concat(
[hist, pd.DataFrame([{
"date": next_date,
"margin": yhat,
"polls_per_week": next_row["polls_per_week"],
"avg_sample": next_row["avg_sample"],
"avg_grade": next_row["avg_grade"],
"avg_transparency": next_row["avg_transparency"],
"avg_pollscore": next_row["avg_pollscore"],
}])],
ignore_index=True
)
last_date = next_date
return pd.DataFrame(preds)
# -----------------------------
# Load artifacts
# -----------------------------
meta = load_training_metadata("models/train_metadata.json")
feature_cols = meta.get("feature_cols", [])
model = load_model("models/national_margin_forecaster.joblib")
# -----------------------------
# Data input
# -----------------------------
df_raw = load_poll_csv()
weekly = basic_clean_and_weekly(df_raw)
if weekly.empty:
st.error("No weekly data after cleaning. Check your CSV.")
st.stop()
# Show a quick peek
with st.expander("Preview weekly aggregated data"):
st.dataframe(weekly.tail(10))
# -----------------------------
# Forecast horizon & run button
# -----------------------------
col1, col2, col3 = st.columns([1,1,2])
with col1:
horizon = st.slider("Forecast horizon (weeks ahead)", min_value=1, max_value=24, value=8, step=1)
with col2:
run_forecast = st.button("Run Forecast")
# -----------------------------
# Compute lags for plotting latest fit vs actual (optional)
# -----------------------------
# (Not used for prediction directly; model already trained. We just plot recent history.)
# This also validates that our feature columns exist in the engineered frame.
lags, rolls = infer_lags_and_windows_from_features(feature_cols)
max_lag_needed = max(lags) if lags else 8
weekly_lagged = make_lags(weekly, target_col="margin", lags=max_lag_needed, roll_windows=tuple(rolls))
engineered_cols_ok = all([(c in weekly_lagged.columns) for c in feature_cols])
if not engineered_cols_ok:
st.warning("Some training features are missing in the current engineered data. Forecast may be limited. Proceeding with available history.")
# -----------------------------
# Forecast and visualize
# -----------------------------
if run_forecast:
try:
preds_df = recursive_forecast(weekly, model, horizon, feature_cols)
# Merge recent actuals and forecast for plotting
recent_actual = weekly[["date", "margin"]].tail(40).copy()
recent_actual = recent_actual.rename(columns={"margin": "Actual margin"})
plot_df = pd.merge(
recent_actual,
preds_df.rename(columns={"pred_margin": "Forecast margin"}),
on="date",
how="outer"
).sort_values("date")
# Plot
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(plot_df["date"], plot_df["Actual margin"], label="Actual margin")
ax.plot(plot_df["date"], plot_df["Forecast margin"], label="Forecast margin")
ax.axhline(0, linestyle="--", linewidth=1)
ax.set_title("Weekly Dem–Rep Margin: Actual vs Forecast")
ax.set_xlabel("Week")
ax.set_ylabel("Margin (Dem - Rep)")
ax.legend()
plt.xticks(rotation=45)
plt.tight_layout()
st.pyplot(fig)
# Show forecast table
st.subheader("Forecast Table")
show_df = preds_df.copy()
show_df["date"] = show_df["date"].dt.date
show_df["pred_margin"] = show_df["pred_margin"].round(3)
st.dataframe(show_df)
# Simple interpretation
last_row = show_df.tail(1).iloc[0]
sign = "Democratic lead" if last_row["pred_margin"] > 0 else ("Republican lead" if last_row["pred_margin"] < 0 else "Tie")
st.info(f"On {last_row['date']}, model forecasts margin {last_row['pred_margin']} → {sign}.")
except Exception as e:
st.error(f"Forecast failed: {e}")
# -----------------------------
# Notes panel
# -----------------------------
with st.expander("How this app works (summary)"):
st.markdown(
"""
- Uses the same weekly aggregation and lag/rolling features as your training notebook.
- The model is a tiny Ridge regressor (scaled), so the model file is very small and under 25 MB.
- Forecasts are computed recursively, each week using lags of recent actual/predicted margins.
- To keep it reproducible, keep your `generic_ballot_polls.csv` in `data/` or upload it.
"""
)
This function produces a forward path of weekly predictions. It builds features from the most recent observed weeks, then steps forward recursively, feeding predictions back as lag inputs. The loop respects the lag/window structure so that each new week uses realistic historical context.
Final Notes
To run locally, install the requirements and start Streamlit with streamlit run app.py
. The repository structure keeps data, model artifacts, and code separate but synchronized. Because every feature and assumption is visible, the forecast is easy to audit and extend.