tb-v1: model total_bases as a compound outcome (challenger)
Current ladder (proj_p_over_line) and champion p_win are BYTE-IDENTICAL. tb-v1 writes alongside them, on total_bases props only. STEP 0 -- components confirmed on real data, not assumed. statsapi has no singles field, but hits - doubles - triples - homeRuns reproduces stored totalBases EXACTLY on a real 10-game log. So the decomposition is exact, not an approximation. THE MODEL. Each component gets its own per-game Poisson rate; TB is their weighted sum, and the PMF is built by exact convolution rather than simulated (TB support is small). It inherits the SAME combined multiplier proj-v1.1 computes, so the two models differ only in STRUCTURE. Why this is the fix: with identical mean TB of 1.0, a pure-HR hitter and a pure-singles hitter get P(TB>=4) of 0.221 vs 0.019 -- a 12x difference an NB on TB alone cannot express, because it treats one home run as four events. A test asserts that separation, and asserts P(TB>=4) for a pure-HR hitter equals P(at least one HR) exactly. INDEPENDENCE IS AN APPROXIMATION AND IS LABELLED AS ONE: a plate appearance that becomes a double cannot also become a single, so the components are weakly negatively correlated and independent Poissons slightly overstate the tail. Closer to the truth than what it replaces; not a solved problem. HONEST-ABSENT throughout: fewer than 3 usable games, or no derivable component, returns null and the prop keeps the current ladder value. An inconsistent row (hits < extra-base hits) is SKIPPED rather than clamped to zero -- clamping would invent a plausible line out of a broken one. I HIT THE Number(null)===0 TRAP IN MY OWN CODE and a test caught it: a null rate passed a naive finite check and was treated as a measured zero, which is the difference between "this player never triples" and "we do not know his triple rate". Both tbPmf and tbMean now reject null/''/boolean strictly. Holdout committed: TB ROWS ONLY (49 of 437 settled -- averaging into other stats would hide the effect) and DIRECTION-ALIGNED, since the unaligned comparison is the artifact that accounted for 41% of the ladder's apparent loss. If tb-v1 does NOT improve, the family-mismatch hypothesis is wrong and the mean/similarity branch reopens -- recorded in the query header. Migration applied: proj_tb_p_over + proj_tb_meta, NULL-meaningful. Gates: 4,104 tests / 329 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -293,6 +293,11 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
|
||||
proj_distribution: g.proj_distribution || null,
|
||||
proj_ladder: g.proj_ladder || null,
|
||||
proj_factors: g.proj_factors || null,
|
||||
// tb-v1 CHALLENGER — total_bases as a compound outcome. Written alongside
|
||||
// proj_p_over_line, never in place of it. NULL on non-TB props and when
|
||||
// the components are underivable; never a fabricated 0.
|
||||
proj_tb_p_over: numOrNull(g.proj_tb_p_over),
|
||||
proj_tb_meta: g.proj_tb_meta || null,
|
||||
// Session 75 — the ENVIRONMENT that drove this projection. The FORECAST,
|
||||
// not the actual: this is what we knew when we projected, and it is what
|
||||
// the instrument measures. The actual lands in game_context and is never
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* compoundTotalBases — TOTAL BASES modelled as the compound outcome it is.
|
||||
*
|
||||
* WHY THIS EXISTS. proj-v1.1 models every stat as a single negative binomial
|
||||
* count. That is right for genuine low-rate event counts (walks, runs, doubles —
|
||||
* measured resolution 0.519 / 0.345 / 0.207) and WRONG for total bases, which is
|
||||
* not a count of events at all but a WEIGHTED SUM of them:
|
||||
*
|
||||
* TB = 1·singles + 2·doubles + 3·triples + 4·home_runs
|
||||
*
|
||||
* An NB fitted to TB treats one home run as "four events", which mis-states the
|
||||
* variance badly — a 4-base outcome from ONE plate appearance is not the same
|
||||
* random object as four separate 1-base outcomes. Measured: total_bases had the
|
||||
* worst result in the ladder, resolution 0.009 (mean −0.019) against the
|
||||
* champion's 0.273. See `specs/proj-v11-diagnosis.md`.
|
||||
*
|
||||
* THE MODEL. Each component is its own per-game Poisson rate; TB is their
|
||||
* weighted sum. The exact PMF is built by convolution rather than simulated, so
|
||||
* the result is deterministic and cheap (TB support is small — a cap of 20 bases
|
||||
* covers every realistic game).
|
||||
*
|
||||
* INDEPENDENCE IS AN APPROXIMATION, and a stated one: a plate appearance that
|
||||
* becomes a double cannot also become a single, so the components are weakly
|
||||
* negatively correlated. Modelling them as independent Poissons slightly
|
||||
* OVERSTATES the tail. That is still far closer to the truth than treating one
|
||||
* home run as four independent events, which is what it replaces — but it is a
|
||||
* known limitation, not a solved problem.
|
||||
*
|
||||
* DOCTRINE: this is the per-stat rule one level deeper — model the stat by its
|
||||
* actual generative structure, not by a family that happens to fit its name.
|
||||
*/
|
||||
|
||||
const TB_CAP = 20; // bases per game; beyond this is not a real outcome
|
||||
const N_CAP = 8; // per-component events per game
|
||||
|
||||
/** Poisson pmf, computed iteratively so no factorial overflows. */
|
||||
function poissonPmf(lambda, nMax) {
|
||||
const l = Number(lambda);
|
||||
if (!Number.isFinite(l) || l < 0) return null;
|
||||
const out = new Array(nMax + 1).fill(0);
|
||||
let term = Math.exp(-l); // n = 0
|
||||
out[0] = term;
|
||||
for (let n = 1; n <= nMax; n += 1) {
|
||||
term = (term * l) / n;
|
||||
out[n] = term;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact PMF of TB = Σ weightᵢ · Nᵢ, with Nᵢ ~ Poisson(rateᵢ) independent.
|
||||
*
|
||||
* @param {{singles:number,doubles:number,triples:number,home_runs:number}} rates
|
||||
* per-GAME expected counts. Any missing/negative component is treated as
|
||||
* ABSENT (rate 0) rather than guessed — a player with no recorded triples
|
||||
* genuinely has ~0 triple rate, and inventing one would add tail mass.
|
||||
* @returns {number[]|null} pmf indexed by total bases, or null if no component
|
||||
* has a usable rate (caller must fall back, never fabricate).
|
||||
*/
|
||||
function tbPmf(rates = {}) {
|
||||
const components = [
|
||||
{ w: 1, rate: rates.singles },
|
||||
{ w: 2, rate: rates.doubles },
|
||||
{ w: 3, rate: rates.triples },
|
||||
{ w: 4, rate: rates.home_runs },
|
||||
];
|
||||
// STRICT: `Number(null) === 0`, so a null rate would pass a naive finite check
|
||||
// and be treated as a real, measured zero — the difference between "this
|
||||
// player never triples" and "we do not know his triple rate". Absent stays
|
||||
// absent; only a genuine number counts.
|
||||
const isRate = (v) => v != null && v !== '' && typeof v !== 'boolean'
|
||||
&& Number.isFinite(Number(v)) && Number(v) >= 0;
|
||||
const usable = components.filter((c) => isRate(c.rate));
|
||||
if (usable.length === 0) return null;
|
||||
|
||||
let pmf = new Array(TB_CAP + 1).fill(0);
|
||||
pmf[0] = 1;
|
||||
|
||||
for (const c of usable) {
|
||||
const rate = Number(c.rate);
|
||||
if (rate === 0) continue; // contributes nothing, skip cleanly
|
||||
const comp = poissonPmf(rate, N_CAP);
|
||||
if (!comp) continue;
|
||||
const next = new Array(TB_CAP + 1).fill(0);
|
||||
for (let t = 0; t <= TB_CAP; t += 1) {
|
||||
const pt = pmf[t];
|
||||
if (pt === 0) continue;
|
||||
for (let n = 0; n <= N_CAP; n += 1) {
|
||||
const bases = t + c.w * n;
|
||||
if (bases > TB_CAP) break; // truncated tail, accounted below
|
||||
next[bases] += pt * comp[n];
|
||||
}
|
||||
}
|
||||
pmf = next;
|
||||
}
|
||||
|
||||
// Truncation leaves a small mass deficit (the >TB_CAP tail). Push it onto the
|
||||
// top bucket rather than renormalising: renormalising would silently inflate
|
||||
// every low bucket, and the deficit genuinely belongs at the top.
|
||||
const total = pmf.reduce((a, b) => a + b, 0);
|
||||
if (total > 0 && total < 1) pmf[TB_CAP] += 1 - total;
|
||||
return pmf;
|
||||
}
|
||||
|
||||
/** P(TB >= k) from a pmf. */
|
||||
function pAtLeast(pmf, k) {
|
||||
if (!Array.isArray(pmf)) return null;
|
||||
const kk = Math.max(0, Math.ceil(Number(k)));
|
||||
if (!Number.isFinite(kk)) return null;
|
||||
if (kk === 0) return 1;
|
||||
let s = 0;
|
||||
for (let i = kk; i < pmf.length; i += 1) s += pmf[i];
|
||||
return Math.min(1, Math.max(0, s));
|
||||
}
|
||||
|
||||
/** Expected total bases implied by the component rates. */
|
||||
function tbMean(rates = {}) {
|
||||
// Same strictness as tbPmf: an absent component contributes nothing, and is
|
||||
// not silently read as a measured zero.
|
||||
const n = (v) => ((v != null && v !== '' && typeof v !== 'boolean'
|
||||
&& Number.isFinite(Number(v)) && Number(v) >= 0) ? Number(v) : 0);
|
||||
return n(rates.singles) + 2 * n(rates.doubles) + 3 * n(rates.triples) + 4 * n(rates.home_runs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive component rates from per-game log rows.
|
||||
*
|
||||
* SINGLES ARE DERIVED, not read: statsapi has no `singles` field, and
|
||||
* hits − doubles − triples − homeRuns reproduces stored totalBases EXACTLY on
|
||||
* real logs (verified). A negative result means the row is inconsistent, so that
|
||||
* ROW is skipped rather than clamped to 0 — clamping would quietly invent a
|
||||
* plausible line out of a broken one.
|
||||
*
|
||||
* @param {Array<{stat:object}>|Array<object>} rows game-log rows
|
||||
* @param {number} [minGames=3]
|
||||
*/
|
||||
function ratesFromLog(rows, minGames = 3) {
|
||||
const games = [];
|
||||
for (const r of rows || []) {
|
||||
const s = (r && r.stat) || r || {};
|
||||
const h = Number(s.hits);
|
||||
const d = Number(s.doubles);
|
||||
const t = Number(s.triples);
|
||||
const hr = Number(s.homeRuns);
|
||||
if (![h, d, t, hr].every((v) => Number.isFinite(v))) continue;
|
||||
const singles = h - d - t - hr;
|
||||
if (singles < 0) continue; // inconsistent row — skip, never clamp
|
||||
games.push({ singles, doubles: d, triples: t, home_runs: hr });
|
||||
}
|
||||
if (games.length < minGames) return null; // honest-absent: caller falls back
|
||||
const mean = (k) => games.reduce((a, g) => a + g[k], 0) / games.length;
|
||||
return {
|
||||
singles: mean('singles'),
|
||||
doubles: mean('doubles'),
|
||||
triples: mean('triples'),
|
||||
home_runs: mean('home_runs'),
|
||||
games: games.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The full read: P(TB >= line) plus the mean, or null when the components are
|
||||
* not derivable. `multiplier` scales every component rate together (the same
|
||||
* park × weather × platoon × matchup product proj-v1.1 already computes), so the
|
||||
* compound model inherits the adjustments rather than ignoring them.
|
||||
*/
|
||||
function projectTotalBases({ rows, line, multiplier = 1, minGames = 3 } = {}) {
|
||||
const base = ratesFromLog(rows, minGames);
|
||||
if (!base) return null;
|
||||
const m = Number.isFinite(Number(multiplier)) && Number(multiplier) > 0 ? Number(multiplier) : 1;
|
||||
const rates = {
|
||||
singles: base.singles * m,
|
||||
doubles: base.doubles * m,
|
||||
triples: base.triples * m,
|
||||
home_runs: base.home_runs * m,
|
||||
};
|
||||
const pmf = tbPmf(rates);
|
||||
if (!pmf) return null;
|
||||
const target = Math.max(1, Math.ceil(Number(line)));
|
||||
if (!Number.isFinite(target)) return null;
|
||||
return {
|
||||
p_over_line: Math.round(pAtLeast(pmf, target) * 1000) / 1000,
|
||||
mean: Math.round(tbMean(rates) * 1000) / 1000,
|
||||
rates: {
|
||||
singles: Math.round(rates.singles * 1000) / 1000,
|
||||
doubles: Math.round(rates.doubles * 1000) / 1000,
|
||||
triples: Math.round(rates.triples * 1000) / 1000,
|
||||
home_runs: Math.round(rates.home_runs * 1000) / 1000,
|
||||
},
|
||||
games_used: base.games,
|
||||
family: 'compound_weighted_poisson',
|
||||
independence_caveat: true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tbPmf, pAtLeast, tbMean, ratesFromLog, projectTotalBases, poissonPmf, TB_CAP, N_CAP,
|
||||
};
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
const dist = require('./projection/distribution');
|
||||
const compoundTb = require('./projection/compoundTotalBases');
|
||||
const matchup = require('./projection/matchupRead');
|
||||
const parkBase = require('./parkBase');
|
||||
const { NAME_TO_ABBR } = require('./environmentContext');
|
||||
@@ -204,8 +205,35 @@ function projectProp({
|
||||
}));
|
||||
const pOverLine = tradedRung != null ? dist.round3(dist.nbSurvival(nb.r, nb.p, tradedRung)) : null;
|
||||
|
||||
// ── TOTAL BASES, modelled as the COMPOUND OUTCOME it is (tb-v1) ───────────
|
||||
// TB is a weighted sum (1B..HR = 1..4), not a count of events, so the single
|
||||
// negative binomial above treats one home run as four events. Measured, that
|
||||
// gave total_bases the worst result in the ladder (resolution 0.009 vs the
|
||||
// champion's 0.273). This computes the exact PMF from per-component Poisson
|
||||
// rates instead, and inherits the SAME combined multiplier so the two models
|
||||
// differ only in structure.
|
||||
//
|
||||
// CHALLENGER ONLY: it is written alongside, never substituted for
|
||||
// `proj_p_over_line`. The current ladder and the champion are byte-identical.
|
||||
// Components underivable (thin/inconsistent log) → null, and the prop keeps
|
||||
// the current ladder value. Never fabricated.
|
||||
let tbCompound = null;
|
||||
if (stat === 'total_bases' && tradedRung != null) {
|
||||
try {
|
||||
tbCompound = compoundTb.projectTotalBases({
|
||||
rows: gameLog, line, multiplier: M,
|
||||
});
|
||||
} catch { tbCompound = null; }
|
||||
}
|
||||
|
||||
return {
|
||||
proj_version: PROJ_VERSION,
|
||||
proj_tb_p_over: tbCompound ? tbCompound.p_over_line : null,
|
||||
proj_tb_meta: tbCompound ? {
|
||||
version: 'tb-v1', mean: tbCompound.mean, rates: tbCompound.rates,
|
||||
games_used: tbCompound.games_used, family: tbCompound.family,
|
||||
independence_caveat: tbCompound.independence_caveat,
|
||||
} : null,
|
||||
proj_point: point,
|
||||
proj_line: line,
|
||||
proj_p_over_line: pOverLine,
|
||||
|
||||
Reference in New Issue
Block a user