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:
@@ -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