MLB Model Methodology
Hierarchical Bayesian skill model and per-PA Monte Carlo simulator, end to end
This model predicts a full distribution of runs per team per MLB game, then derives win, run-line and totals probabilities by simulating each matchup roughly 10,000 times. Predictions are graded daily against sportsbook lines: sharp bettors push lines toward true probabilities quickly, which makes the market a better probability signal than most models built from scratch, mine included. That is also why the published win probability borrows from it.
The current model (v2, live since May 12, 2026) has two layers: a hierarchical Bayesian skill model, a multinomial-logit over the eight plate-appearance outcomes fit with NUTS, and a per-PA Monte Carlo simulator with rest-aware bullpens, an empirical baserunner-advancement table, weather effects, and per-game posterior draws that carry parameter uncertainty through. It replaced an XGBoost regressor (v1) after a 542-game head-to-head backtest that v2 won on every metric.
Prediction target
Per-team run distribution per game
Skill model
Hierarchical multinomial-logit (8 outcomes), NUTS via numpyro / JAX
Training data
~480k PAs across 2024, 2025 and 2026 to date, growing nightly
Simulator
30 posterior draws × ~333 sims each (~10,000 per matchup)
Probability output
Sim win prob anchored to market consensus; p10/p90 bands
Sizing rule
Quarter-Kelly on flagged ML and RL plays; totals off
CLE @ TBR
Mon, Oct 19, 2026 · 5:08 PM PT
TBR expected runs
4.39
p10–p90: 1.0 – 9.0
CLE expected runs
4.15
p10–p90: 1.0 – 8.0
TBR win probability
56.0%
p10–p90: – – –
Total runs: μ 8.00, p10–p90 3.0–15.0. Book O/U line: 6.5. Bars are the empirical PMF from the raw 10,000-sim run array, binned 0..20.
September 4, 2026
Moneyline flags require a paired market anchor
August 18, 2026
Published win probability anchored to the de-vigged market consensus
July 18, 2026
Weather effects in the simulator, and odds shopped across three books
May 27, 2026
Totals recommendations switched off; form noise raised to 0.18 in June
May 12, 2026
v2 cutover: hierarchical Bayesian skill model + per-PA Monte Carlo simulator
See full changelog (2 older entries)
April 20, 2026 (v1)
Raised +EV thresholds from 3% to 4.5% (ML/RL) and 6.5% (totals)
April 2026 (v1)
Win probability switched from Poisson to negative binomial
The skill layer learns slow-moving parameters from years of Statcast data and is refit nightly. The simulator consumes them to produce per-game run distributions, and the markets layer turns those into win, total and run-line probabilities with edge and sizing.
Inputs
Statcast pitch data
~480k PAs, 2024+25+26-YTD
MLB Stats API
Schedule, lineups, rosters, boxscores, weather
The Odds API
ML, RL, totals across three books
Active bullpen workload
Per-pitcher outs per day (rest)
Bayesian Skill Layer
Batter model
~870 batters × 2 platoon cells
Pitcher model
~1,140 pitchers × 2 roles (SP/RP)
Park log-PF
30 venues, residual wOBA
Sampler
NUTS via numpyro/JAX, 4 chains × 2000 draws
Monte Carlo Simulator
Per-PA outcome sampler
Vectorized over 8 categories
Empirical advancement
P(state', runs | state, outs, outcome)
Rest-aware bullpen
Active roster + 1d/2d workload caps
30 posterior draws × ~333 sims
~10,000 simulated games per matchup
Markets & EV
Win probability
Sim logit anchored to de-vigged market consensus
Totals & RL distributions
Pure sim quantiles
Edge vs best price
4.5% on ML and RL; totals switched off
Quarter-Kelly sizing
0.25 × f*, f clipped to [0, 1]
A full NUTS refit runs nightly on GitHub Actions (~4 AM PT) and, on success, triggers the scoring run, so scoring always uses fresh posteriors. From about 5 AM to 7:40 PM PT a lineup refresh fires every 20 minutes and re-scores any game whose posted lineup changed. A midnight job grades yesterday's games.
Predictions track lineups, scratches and late odds up to first pitch. Once a game starts its row is frozen, and that final pregame value is what gets graded. A flag shown in the morning can disappear if a re-score drops the edge below threshold; it is then removed from the history and from model evaluation too. A flag also requires a known starter, a fully posted lineup and enough pitching-usage evidence: a game scored on a fallback lineup is a No Play regardless of edge.
Schedule & Bullpen
Games, scores and per-pitcher workload
Lineups, Odds & Weather
Stats API lineups and weather; ML/RL/totals from The Odds API
Posterior Refit
Nightly NUTS run: pitcher, batter, park (about 15 min)
Score Games
30 posterior draws × ~333 sims, ~10,000 per matchup
Derive Markets
Win prob anchored to market consensus; RL and totals from the sims
Verify & Publish
Pairing, range, anti-correlation, posterior-age checks; write to Supabase
The stack is Python throughout: PyMC describes the models, numpyro and JAX sample them, NumPy runs the vectorized simulator, and GitHub Actions plus Supabase pg_cron schedule everything. Results land in Supabase (Postgres), which is the only thing this Next.js site reads.
Each plate appearance is a categorical draw over eight outcomes: strikeout, walk, hit-by-pitch, single, double, triple, home run, in-play out. Three hierarchical models learn the additive log-odds offsets that each actor (batter, pitcher, venue) contributes to those logits, with OUT as the reference category. The pitcher model fits first and sets the league intercept; the batter model then fits with that intercept frozen, so there is one baseline, not two competing ones. Working on the log-odds scale makes platoon, role, park and weather effects simple additive terms.
Generative model (batter, schematic)
σb ~ HalfNormal(0.6)7 // batter-level scale per outcome
zi ~ Normal(0, 1)7 // non-centered batter offsets
βi = μ + σb ⊙ zi //batter i's log-odds vector
πi = softmax([0, βi])
yi ~ Multinomial(ni, πi) // aggregated per-batter PA counts
Two implementation choices matter at this scale. The parameterization is non-centered, which avoids Neal's funnel, the distorted posterior geometry that stalls HMC on hierarchical models. And the likelihood is a Multinomial over each actor's aggregated outcome counts, which is mathematically identical to a per-PA Categorical but roughly 400× faster on 400k PAs (about 3 minutes against a projected 19 hours).
Batters are fit per (batter, vs_LHP) cell, with the platoon offset given its own tighter prior (HalfNormal(0.3)); pitchers per (pitcher, role ∈ {SP, RP}), with no platoon split. Position players who pitch in blowouts are dropped from the pitcher pool, with true two-way players exempted.
Park is fit last, on the residual wOBA after batter and pitcher effects. A per-venue scalar park_log[v]shifts the non-OUT logits, weighted by each outcome's wOBA coefficient, so HR and 3B move most under park and K, whose wOBA weight is zero, does not move at all.
Sampler diagnostics (4 chains, 2000 draws, 2500 tune)
| Model | R-hat | min ESS | Divergences | Wall |
|---|---|---|---|---|
| Batter (869 × platoon) | 1.00 | 1273 | 0 | 8.2 min |
| Pitcher (1142 × role) | 1.00 | 1724 | 0 | 6.4 min |
| Park (30 venues) | 1.00 | 33.9k | 0 | 15 s |
R-hat near 1.00 means the chains mixed and sample the same posterior. Effective sample size (ESS) is how many independent draws the autocorrelated chains are worth. The gate is R-hat ≤ 1.01 and minimum ESS > 400.
For each game I draw 30 posterior samples and run about 333 simulations per draw, roughly 10,000 per matchup. Every draw is a coherent realization of all batter, pitcher and park parameters together, so the spread across draws is parameter uncertainty and the spread within a draw is ordinary run-scoring noise.
Per-PA outcome sampler (vectorized)
+ park_log[v] · wOBA_weightk
+ weather_shiftk(temp, wind)
+ form_noisek // sigma = 0.18, per team per game, zero-sum across outcomes
π = softmax([0, ℓ1..7])
outcome ~ Categorical(π)
Indices: batter b, pitcher p, role ∈ {SP, RP}, venue v, outcome k ∈ 1..7 (K, BB, HBP, 1B, 2B, 3B, HR; OUT is the reference 0 logit).
On contact, baserunners move according to an empirical advancement table built from 365k Statcast PAs: P(new_state, runs_scored, outs_added | state, outs, outcome, out_subtype). Cells with fewer than 100 observations are shrunk toward the outcome-level marginal in proportion to their count. HR, BB and HBP advances are deterministic forced moves.
Bullpen management is rest-aware. Starters are pulled after 24 batters faced (roughly 95 pitches), or earlier after 18 outs, after 6 runs allowed, or after 12 outs with 4 or more runs allowed. Relievers come from the active 26-man roster, most rested first, and any reliever with ≥ 6 outs yesterday or ≥ 9 outs in the last 2 days is skipped. Workloads are refreshed daily from MLB boxscores.
Variance decomposition (per-game total runs)
+ Varform(per-game form noise, σ=0.18)
+ Varaleatoric(inning-level sim noise)
The form-noise scalar is calibrated against actual game outcomes, never against market backtests: that would mask modeling errors as noise.
The pre-cutover shape check replayed 200 stratified 2025 games: simulated mean runs per team-game came out 4.59 against an actual 4.38 (+4.86%), and simulated variance 9.72 against 10.32 (−5.86%). The model is mildly underdispersed, so blowouts and large totals get a little less probability than they should, which is part of why totals are off.
Market probabilities are counted directly from the simulated games, with no Poisson or negative binomial assumption on top. With hi, ai the home and away runs in sim i = 1..N:
P(total > L) = (1/N) · Σi 𝟙[hi + ai> L]
P(home covers sh) = (1/N) · Σi 𝟙[hi − ai > −sh]
shis the book's home spread (−1.5 means home must win by 2 or more). Only ±1.5 run lines are compared. Pushes split 50/50 at integer lines.
The published moneyline number is not the raw sim. Each book's two moneylines are de-vigged as a pair and averaged into a market consensus. The sim's home logit gets a +0.09 home-field shift it does not produce on its own, then is blended with the consensus at weight 0.2 on the sim, the out-of-sample log-loss optimum on 2026 games. The raw sim flagged big underdogs as +EV all season, and they did not win at the rate it said. Run-line and totals probabilities are still pure sim. A game with no paired moneyline publishes the shifted sim probability and gets no moneyline flag.
The p10/p90 win-probability band is computed per posterior draw: each of the 30 draws yields a win probability, and the band is the p10 and p90 of those, pushed through the same anchoring map. It measures parameter uncertainty about the win rate, separate from run-scoring noise.
A moneyline or run-line play is flagged when the modeled probability beats the implied probability of the best available price by at least 4.5 points. Totals carried a 6.5-point bar but are switched off entirely. Sizing uses the Kelly criterion:
stake = 0.25 · clip(f*, 0, 1) // quarter-Kelly
Full Kelly maximizes long-run growth but draws down hard on losing streaks, and model probabilities are only estimates; quarter-Kelly trades some growth for a much tighter drawdown. A high-variance flag marks games where a team's simulated runs stdev exceeds 4.0.
The Gradeis a 20 to 80 projection of true talent for a full season at the player's position. Fifteen traits (contact, power, discipline, batted-ball quality, platoon, range, arm, reliability, baserunning, sprint speed for hitters; stuff, command, bat-missing, contact management, workload for pitchers) are each a weighted composite of skill measurements from Statcast and Baseball Savant: swing decisions, whiffs, bat speed, exit velocity, pitch velocity and movement, outs above average, arm strength. A trait's weights are a bounded ridge fit of the player's own next-season value of that trait on the inputs, and the Grade's weights a second ridge of next-season runs above average on the traits plus a shrunk three-season outcome record. Both are fitted on the 2021 to 2025 development pairs with each pair held out in turn; 2026 chooses nothing.
Trade valueprices the projection over the player's remaining control years from Baseball-Reference contract data: projected wins each season after the age curve, times the price of a win, minus salary, discounted 8 percent a year. Pre-arbitration seasons pay the league minimum, arbitration seasons a fitted share of market value, options go the way of whoever holds them. The price of a win is the slope of average annual value on this model's own projected wins across free-agent contracts signed since 2024. It comes out well below the published rate because the projections are calibrated and compressed: a star projects lower here than in public systems, so long star contracts read as negative. A player with no control years left shows a one-season Market value instead.
Impactis descriptive: this season's plate appearances valued by run expectancy, plus fielding and baserunning runs, above a replacement-level player at the position, in wins. Replacement level and the positional scale are the conventional ones; the measured alternatives were biased by who kept playing and by outs above average being relative to each position already.
On the 2025 to 2026holdout, the Grade's rank correlation with next-season runs was 0.404 for hitters against 0.385 for prior-season xwOBA and 0.329 for a Marcel projection, 0.389 for starters against 0.334 for prior production, and 0.192 for relievers against 0.105 for Marcel. A player is graded once his season-equivalent sample reaches 100 plate appearances or batters faced; below that the page shows no number.
Before cutover, v2 was benchmarked against a pinned copy of the v1 XGBoost baseline on every completed 2026 game where both models had a prediction.
| Metric | v1 | v2 | Δ | Gate |
|---|---|---|---|---|
| Brier score (ML) | 0.2570 | 0.2393 | −6.88% | PASS |
| Log-loss (ML) | 0.7244 | 0.6713 | −7.33% | PASS |
| Max calibration gap | 41.92% | 3.20% | −38.7pp | PASS |
| ROI moneyline (flagged) | +15.82% | +26.25% | +10.4pp | – |
| ROI run line (flagged) | +5.04% | +14.96% | +9.9pp | – |
| ROI totals (flagged) | −12.83% | −7.58% | +5.3pp | – |
Brier and log-loss are proper scoring rules, so the comparison covers calibration and sharpness together, not just hit rate. v1's 41.9% max calibration gap is partly thin-bin variance, but the direction holds. Totals ROI improved on paper here and then kept losing once v2 went live, which is how totals ended up switched off. A six-week backtest is a small sample; the numbers are frozen from that run, and live results, including ongoing Brier and calibration, are on the Performance page.
v1 was a gradient-boosted regressor (XGBoost) on 14 hand-built team-level features per game: starter and bullpen xFIP, WHIP and K/9, team OPS, ISO and K%, rolling run scoring, park factor, home field and bullpen rest. It predicted expected runs per team; win, total and run-line probabilities came from a negative binomial score distribution with isotonic calibration.
v2 replaced it because v1 had no concept of individual batter or pitcher skill, so lineup changes, platoon advantages and bullpen identity were invisible to it; it reported no parameter uncertainty; and its calibration degraded in the tails. The v1 code was deleted on September 1, 2026. Its predictions before May 12, 2026 are kept in archive tables so the historical record is unchanged, and a dashed vertical line on every time-series chart marks the cutover.