Skip to content

MLB Model Methodology

Hierarchical Bayesian skill model and per-PA Monte Carlo simulator, end to end

Project Overview
Hierarchical Bayesian skill model + per-PA Monte Carlo simulator for probabilistic run prediction

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

Example Output: Today's Featured Game
Per-team simulated run distributions, win probability band, and total runs percentiles

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.

Model Changelog
Significant changes to model behavior, most recent first

September 4, 2026

Moneyline flags require a paired market anchor

A moneyline play is flagged only when at least one book quotes both sides of the game, so the anchored probability behind the flag always exists.

August 18, 2026

Published win probability anchored to the de-vigged market consensus

Publishing the raw sim probability flagged big underdogs +EV systematically: dogs the market priced under 33% came out at 37% from the sim and won 29%. The published win probability is now a logit-scale blend of the sim (weight 0.2, the out-of-sample log-loss optimum on 2026 games) and the de-vigged consensus across the paired moneylines, plus a +0.09 home-field logit the sim lacks. Run-line and totals probabilities stay pure sim.

July 18, 2026

Weather effects in the simulator, and odds shopped across three books

Temperature and wind now shift the outcome logits per game, with coefficients fit on Statcast PAs controlling for park, batter and pitcher. Two days later the odds feed started pulling DraftKings, FanDuel and BetMGM and pricing each side at the best available number.

May 27, 2026

Totals recommendations switched off; form noise raised to 0.18 in June

Every totals edge bucket lost money on the 2026 backtest and model edge had no relationship to outcome, so totals flags are disabled. The per-game form-noise scalar went from 0.13 to 0.18 on June 16 after a recalibration against actuals.

May 12, 2026

v2 cutover: hierarchical Bayesian skill model + per-PA Monte Carlo simulator

Replaced the v1 XGBoost regressor. Backtest over 542 games (Mar 26 to May 9, 2026): Brier −6.9%, log-loss −7.3%, max calibration gap from 41.9% down to 3.2%, ROI improved on every market.
See full changelog (2 older entries)

April 20, 2026 (v1)

Raised +EV thresholds from 3% to 4.5% (ML/RL) and 6.5% (totals)

Below the typical vig on a −110 line there is no cushion for model miscalibration. v2 inherited these thresholds.

April 2026 (v1)

Win probability switched from Poisson to negative binomial

MLB run-scoring is overdispersed relative to Poisson. v2 needs no parametric assumption: win probability comes straight from the simulated run distributions.
Architecture & Daily Pipeline
From pitch-level data to market probabilities: nightly refit, morning scoring, intraday lineup re-scoring

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.

01

Schedule & Bullpen

Games, scores and per-pitcher workload

02

Lineups, Odds & Weather

Stats API lineups and weather; ML/RL/totals from The Odds API

03

Posterior Refit

Nightly NUTS run: pitcher, batter, park (about 15 min)

04

Score Games

30 posterior draws × ~333 sims, ~10,000 per matchup

05

Derive Markets

Win prob anchored to market consensus; RL and totals from the sims

06

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.

Bayesian Skill Layer
Hierarchical multinomial-logit per actor, fit with NUTS in numpyro/JAX

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)

μ ~ Normal(logitleague, 0.5)7 // per-outcome league intercept
σ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)

ModelR-hatmin ESSDivergencesWall
Batter (869 × platoon)1.00127308.2 min
Pitcher (1142 × role)1.00172406.4 min
Park (30 venues)1.0033.9k015 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.

Per-PA Monte Carlo Simulator
30 posterior draws × ~333 sims per draw, propagating parameter uncertainty

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)

ℓk = μk + batter_offset[b] + vs_LHP · platoon_offset[b] + pitcher_offset[p, role]
+ 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)

Var(total_runs) = Varposterior(parameter draws)
+ 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.

From Simulated Runs to Market Probabilities
Empirical frequencies over the simulated games, a market anchor on the moneyline, and quarter-Kelly sizing

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(home wins) = (1/N) · Σi 𝟙[hi > ai]
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:

f* = (p · b − q) / b // p = model prob, q = 1−p, b = decimal odds − 1
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.

Player Values
A projected grade, a trade value in dollars, and this season's impact for every 40-man player

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.

Head-to-Head Backtest vs Frozen v1
542 games, March 26 to May 9, 2026. v2 wins on every metric.

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.

Metricv1v2ΔGate
Brier score (ML)0.25700.2393−6.88%PASS
Log-loss (ML)0.72440.6713−7.33%PASS
Max calibration gap41.92%3.20%−38.7ppPASS
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.

Legacy: v1 XGBoost Model (pre-2026-05-12)
Predictions on the History and Performance tabs before the dashed v2 cutover line come from this model

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.