Teaching a Prediction Loop to Distrust Itself

Ballpark Genius predicts MLB game outcomes, win probability, projected score, the “Pitcher Edge”/“Offense Edge” breakdown on a game page. Underneath it is a nightly champion/challenger loop, tune a candidate, grade it against a holdout set, promote only if it’s genuinely better. Simple in the abstract. It took three separate, individually reasonable-looking bugs, over about a week, before I trusted a single number it produced.

A Ballpark Genius game prediction: Tigers 63% to win over the Guardians, with a 56% confidence rating and the Pitcher Edge / Offense Edge / Total Runs breakdown that feeds it

The prediction surface this loop feeds, produced by the champion model after all three fixes below.

Bug one: the holdout set had quietly stopped growing. held_out = true on a game is a persisted flag, set only by a one-off backfill script months back, never by the daily import that keeps adding real games. So the eval set froze at 120 games while every game since leaked into training instead, and two eval reports run a month apart came back byte-identical to four decimal places, which should’ve been the tell (it wasn’t, for longer than I’d like to admit). Fixed it so importGames assigns heldOut at insert time going forward, backfilled the 67 games that had already leaked, holdout set’s at 187 now and grows on its own.

Bug two, and this is the one worth remembering the name of: the promotion gate compared composite scores against a flat epsilon = 0.005, and per-game noise on a 180-game holdout runs about ±0.02, four times the threshold meant to catch a real signal. There are lies, damned lies, and a promotion gate that can’t tell a real 0.003 improvement from a coin flip that came up heads four times in a row. It had, at least once, promoted a model on a +0.025 swing that was almost certainly noise. Swapped the flat threshold for a paired significance test, champion minus candidate per game, promote only when the 95% confidence interval’s lower bound clears zero. Now a worse candidate fails honestly instead of occasionally sneaking through on a lucky week.

Bug three is the one I’m most annoyed I didn’t see coming: the tuner that builds each candidate was optimizing composite score on one dataset (walk-forward) and getting graded on a completely different one (holdout). That’s not a subtle setup flaw, that’s a kid studying off last year’s answer key and being surprised by this year’s exam. +0.017 on the sample it could see, -0.023 on the one it couldn’t. Added a shrinkage-to-champion penalty chosen by cross-validation instead of by hand, and watched the CV procedure land on λ=2, which is the tuner itself concluding, correctly and a little humiliatingly, that the data doesn’t support moving off the champion at all right now.

Anyway, I digress into the play by play too easily on this one. The order these surfaced in is the actual story. A frozen holdout makes a noisy gate look stable, because it’s grading against the same 120 games forever. A noisy gate makes an overfitting tuner look successful, because noise-driven promotions go through often enough to seem like progress. Each bug was hiding behind the one before it, and I only found the third by fixing the first two first. heuristic_v9 is near-optimal for what it has to work with now, nothing beats it meaningfully, and that’s the gate reporting a true negative instead of a broken positive, exactly the boring, correct outcome you want from something you spent a week teaching not to lie to you.