Anatomy of a prediction

We pick one game — the 2026 NFL Kickoff Game, NE @ SEA — and trace it field-by-field. Raw nflverse inputs → every feature the model derives → how each of the four predictors arrives at its number → what happens after the result comes in. Every number came out of the real pipeline, not invented — frozen here as a worked example as of June 2026. Vegas lines keep moving between now and kickoff; the auto-updating picks live on the Week 1 page.

1. The game · 2. Raw inputs · 3. Feature row · 4. Each model scores it · 5. Side by side · 6. After kickoff

1. The game

NE @ SEA

Wednesday, September 9, 2026 · 8:20 PM ET · Kickoff Game

Vegas line: SEA −3.5

Moneylines: home −205 / away +170

Elo
SEA 67%
QBElo ★
SEA 71%
ML
SEA 74%
Vegas
SEA 64%

All four pick SEA. Our headline model (QBElo) is 7 percentage points more confident in SEA than the closing market. The rest of this page is how we got there.

2. Raw inputs from nflverse

Everything the model sees about this game starts in one row of schedules.parquet, plus all the prior team-games and plays that have already happened. No live feeds, no scrapers — just the public nflverse release.

The schedule row (selected fields)

fieldvaluewhat it is
game_id2026_01_NE_SEAnflverse’s composite key (season_week_away_home)
gameday2026-09-09kickoff date
gametime20:20kickoff (ET, 24h)
home_team / away_teamSEA / NE3-letter codes
spread_line+3.5point spread (home perspective: >0 = home favored)
home_moneyline−205American odds for SEA to win straight up
away_moneyline+170American odds for NE
home_rest / away_rest7 / 7days since each team’s last game (Week 1 default = 7)
div_game0flag: divisional matchup (no — AFC East vs NFC West)
home_qb_id / away_qb_idNaN / NaNstarting-QB IDs — not yet published for 2026 Week 1
resultNaNhome_score − away_score, populated after kickoff

What else the model has already seen by Sep 9

  • All NFL games 2010–2025 (4,175 regular-season games) — team Elo + QBElo ratings have been updated through end of 2025 and regressed 1/3 of the way back to the mean at the off-season boundary.
  • Every QB’s box-score history — QB ratings carry over un-regressed (skill is sticky); team QB baselines also carry over.
  • Every play’s EPA from 2010–2025 — rolling 10-game form means for each team, shifted one game.

3. The features the model derives

The pipeline assembles one row per game. Here’s every column of that row for this matchup — each value derived from the inputs above.

a. Team Elo (pre-game)

Plain 538-style team rating. Updated game-by-game through end of 2025, regressed 1/3 toward 1505 at season boundary, then unchanged through the off-season.

teamElo
SEA (home)1675.91
NE (away)1600.11
SEA − NE+75.80

b. QBElo team rating (pre-game)

Separate team-Elo subsystem inside QBElo — updated against the QB-adjusted prediction, so it drifts from plain Elo over time.

teamQBElo team
SEA (home)1685.53
NE (away)1578.64
SEA − NE+106.89

QB adjustment is 0 for both teams — starters haven’t been published, so we fall back to the team baseline.

c. Rolling EPA form (carries from end of 2025)

Trailing 10-game mean of EPA-per-play, shifted one game so the prediction can’t see itself. For Week 1 2026 the window is the last 10 games of 2025.

teamoff EPA/playdef EPA/play allowed
SEA+0.0921−0.1539
NE+0.0418−0.1060
SEA − NE+0.0503−0.0479

SEA looks better on both sides of the ball over their last 10 games of 2025. (Negative def-EPA-allowed is good — opposing offenses scored fewer expected points per play.)

d. Schedule features

featurevaluemeaning
home_rest7standard week-1 rest
away_rest7same for NE
rest_diff0no rest edge either way
div_game0not a divisional matchup

e. Everything merged: the one row that goes into the ML model

ml_model.assemble() stitches all of the above into a single row keyed by game_id. The 13 features below are exactly what the ML model consumes — same shape as every other game’s row.

columnvaluenotes
elo_diff+75.80SEA Elo − NE Elo
p_home_elo0.6710Elo’s home win prob (computed below)
p_home_qbelo0.7092QBElo’s home win prob
home_roll_off_epa+0.0921SEA offense
home_roll_def_epa−0.1539SEA defense (lower = better)
away_roll_off_epa+0.0418NE offense
away_roll_def_epa−0.1060NE defense
off_epa_diff+0.0503SEA off − NE off
def_epa_diff−0.0479SEA def − NE def
home_rest7
away_rest7
rest_diff0
div_game0

Note: p_home_mkt (the de-vigged Vegas implied probability) is also in the row but the ML model doesn’t use it — we leave Vegas out of training so it stays an honest benchmark.

4. Each model scores it

Elo — from team ratings to win probability

Standard 538-style Elo: add home-field advantage to the rating diff, then run through the Elo logistic.

# constants from src/elo.py HFA = 48 # home-field advantage in Elo points # pre-game ratings (carried + off-season regressed) home_elo (SEA) = 1675.91 away_elo (NE) = 1600.11 # step 1: effective rating gap (home perspective) diff = home_elo + HFA − away_elo = 1675.91 + 48 − 1600.11 = 123.80 # step 2: Elo logistic — converts rating gap to win probability p_home = 1 / (1 + 10^(−diff/400)) = 1 / (1 + 10^(−123.80/400)) = 1 / (1 + 10^(−0.3095)) = 1 / (1 + 0.4901) = 0.6710 # SEA 67%

The 400 in the denominator is the Elo scale (a 400-point gap = 10:1 odds). +48 of HFA means a home team beats an equally-rated road team about 56% of the time.

QBElo — Elo plus a QB adjustment

Same logistic, but the rating gap also includes qb_to_elo × (starter_rating − team_qb_baseline) for each side. With 2026 starters NaN, that adjustment is 0 here, so the math collapses to QBElo’s own team-Elo subsystem.

home_rating (SEA QBElo team) = 1685.53 away_rating (NE QBElo team) = 1578.64 home_qb_adj = qb_to_elo × (qb_value[hqb] − team_qb_base[home]) = 0 # hqb is NaN — falls back to baseline away_qb_adj = 0 # same for NE diff = home_rating + HFA + home_qb_adj − (away_rating + away_qb_adj) = 1685.53 + 48 + 0 − (1578.64 + 0) = 154.89 p_home = 1 / (1 + 10^(−154.89/400)) = 0.7092 # SEA 71%

QBElo’s team-Elo subsystem (1685.53) is higher than plain Elo’s (1675.91) for SEA — the two rating systems update under different predictions over thousands of games and drift apart. Once 2026 starters are published, home_qb_adj and away_qb_adj become non-zero and QBElo can pull sharply if a backup is announced.

ML (Logit) — learned weights on the 13-feature row

The pipeline is a four-step sklearn.Pipeline: impute missing values with the median, standardize each feature, fit logistic regression, then Platt-scale on the 2025 season so the output is a calibrated probability.

x = # 13 features from section 3e (column order) [elo_diff, p_home_elo, p_home_qbelo, home_roll_off_epa, home_roll_def_epa, away_roll_off_epa, away_roll_def_epa, off_epa_diff, def_epa_diff, home_rest, away_rest, rest_diff, div_game] x = [+75.80, 0.6710, 0.7092, +0.0921, −0.1539, +0.0418, −0.1060, +0.0503, −0.0479, 7, 7, 0, 0] # 1. impute median (no NaNs in this row, so no change) # 2. standardize: (x - feature_mean) / feature_std (fit on train) # 3. logistic: z = w · x_scaled + b -> sigmoid(z) = raw_p # 4. Platt: fit a 1-D logistic on raw_p’s logit using 2025 as calibration p_home_ml = platt(sigmoid(w · standardize(impute(x)) + b)) = 0.7443 # SEA 74%

In holdout testing the ML model gets slightly better straight-up accuracy than QBElo but worse Brier — the logistic is a touch more confident than it should be on lopsided games. Platt scaling on the most recent full season pulls it back toward calibrated.

Vegas — de-vigging the moneylines

Each American moneyline implies a raw probability that includes the book’s margin (the “vig”). The two sides sum to more than 1.0. We normalize so they sum to exactly 1.0 — the closest thing to an honest market probability we can extract for free.

# step 1: American -> raw implied probability home_ml = −205 -> rh = 205 / (205 + 100) = 0.6721 away_ml = +170 -> ra = 100 / (170 + 100) = 0.3704 # step 2: vig is the overround above 1.0 rh + ra = 1.0425 -> the book holds about 4.25¢ on this game # step 3: de-vig by normalizing p_home_mkt = rh / (rh + ra) = 0.6721 / 1.0425 = 0.6447 # SEA 64%

All probabilities on this site labeled “Vegas” or “market” are de-vigged like this. The implementation lives in src/metrics.py (american_to_prob + market_home_prob).

5. Side by side

All four predictors, computed from the same row of inputs, summarized.

modelSEA win %NE win %notes
Elo67.1%32.9%team ratings + HFA only
QBElo ★70.9%29.1%independent team-Elo + QB adjustment (=0 here)
ML (Logit)74.4%25.6%13 features → logistic → Platt-calibrated
Vegas (de-vig)64.5%35.5%closing-ish moneylines, vig removed

All four agree SEA is the favorite; the disagreement is on how much. Vegas is the most cautious (64.5%); our ML is the most confident (74.4%). The 10-point gap between ML and Vegas is meaningful — the holdout backtest says Vegas is sharper on average, so betting markets “know something” we don’t (probably injury / camp news that hasn’t hit our data). After kickoff we’ll grade who was closer.

6. What happens after kickoff

Once the final score is in, every part of the pipeline flows forward automatically:

a. The raw data updates (Monday/Tuesday)

  • nflverse publishes the final result, home_score, away_score on the schedules row.
  • Player stats for the game are added to the weekly stats parquet (used for QB value).
  • Play-by-play with EPA per play hits the pbp parquet (used for rolling form).

b. Our cron runs (Tuesday 16:00 UTC)

The GitHub Action re-runs the whole pipeline and pushes a refreshed web/replay_2026.json if any prediction changed. Vercel auto-deploys.

Each model gets graded on the games that have already been played:

# for each played game y = 1 if home won else 0 if home lost else 0.5 brier += (p_home − y)^2 log_loss += −(y · log(p_home) + (1−y) · log(1−p_home)) accuracy += 1 if (p_home ≥ 0.5) == (y == 1) else 0

Brier and log loss reward calibration (a confident wrong pick hurts more); accuracy is just who-picked-the-winner.

c. The ratings update online (next time the engines run)

Both Elo and QBElo update each team’s rating after every game. Worked example: if SEA wins NE@SEA by exactly 7 (covering the −3.5):

# inputs result = +7 # home (SEA) won by 7 y = 1.0 # home win diff = 123.80 # pre-game (with HFA) p_home = 0.6710 # Elo’s pre-game prediction K = 20 # margin-of-victory multiplier (538): rewards bigger wins, dampens blowouts elo_diff_winner = +diff = 123.80 # winner is home, diff stays positive mov = log(|result| + 1) × (2.2 / (elo_diff_winner × 0.001 + 2.2)) = log(8) × (2.2 / (0.124 + 2.2)) = 2.079 × 0.947 = 1.969 # update delta = K × mov × (y − p_home) = 20 × 1.969 × (1 − 0.6710) = 12.96 new SEA Elo = 1675.91 + 12.96 = 1688.87 new NE Elo = 1600.11 − 12.96 = 1587.15

SEA gains ~13 points by winning “as expected.” A loss would’ve cost them more like 27 (because y − p_home would have flipped sign and grown in magnitude). Online updates like this are why Elo doesn’t need a re-train: it adapts every week.

d. The site updates itself

The refreshed JSON includes new cumulative and weekly Brier / log-loss / accuracy. The live tracker reads it next page load and the running scoreboard moves. The Week 1 page shows the result chip next to each pick.

Where each number on this page comes from

  • section 2 — one row of data/schedules.parquet, produced by src/pull_data.py.
  • 3a / 3bsrc/elo.py and src/qbelo.py run chronologically over every game through Week 1 2026.
  • 3cdata/epa_features.parquet, built by src/epa_features.py (now appends future-game placeholders so the rolling window carries through the off-season).
  • 3eml_model.assemble() in src/ml_model.py.
  • section 4 — the engines + metrics.market_home_prob for the de-vig; the four numbers are exactly what’s in web/replay_2026.json under this game’s preds.
  • section 6 — the MOV multiplier and update rule are from src/elo.py (mov_multiplier); the grading metrics are from src/metrics.py.

Full pipeline overview: How it’s built →