Layer 3 Step 6: platoon splits, regressed hard
The highest-value adjuster and the thinnest sample in baseball. The regression is not a refinement here, it is the entire feature: applying raw splits would adjust projections on noise, which is worse than not building it. PHASE 0 — both gates clear, and one was already closed. Splits are a statsapi pull, one call per hitter (statSplits with sitCodes vl,vr). The batter-handedness join that Session 69 recorded as pending is in fact DONE: statcast_aggregates carries bats for 604 of 604 batters, 210 left, 327 right, 67 switch. STATE said pending; the data says otherwise, and the note is corrected. Point-in-time holds as long as the split is fetched before first pitch, since a season split queried this afternoon cannot contain tonight — but a historical backtest would use season-final numbers and leak, so clean measurement is forward-accruing. THE SPINE — regressed = (PA x observed + K x prior) / (PA + K), with K = 600 PA and the prior being the hitter's OWN blended rate rather than the league's. The question a platoon adjustment answers is whether he is DIFFERENT against this hand than he normally is, so his own line is the correct null and a hitter with no evidence of a split correctly gets nothing. K is deliberately conservative: platoon skill is famously slow to stabilise, with the half-signal point for right-handed batters near a thousand PA. THE MAKE-OR-BREAK TEST, both halves. A .310 average against left-handed pitching on 30 PA gets 4.8% weight and moves the projection by 0.003 — essentially nothing, which is the correct answer rather than a limitation. The SAME .310 on 400 PA gets 40% weight and moves it by 0.023, eight times as far. A test asserts that ratio stays above five, so if the regression ever breaks the suite says so instead of the projections quietly drifting onto noise. Real data behaves exactly as the mechanism predicts and is worth recording: Josh Bell hits .259 against lefties and .248 against righties, which looks like a platoon split until the sample speaks — 126 PA earns 17% weight and the adjustment lands at 1.005. Aaron Judge, 76 PA against lefties, comes out at 0.999. Neither is material. Most hitters will get nothing from this adjuster, and that is the honest output, not a failure. Honest-absent has five distinct routes, all returning exactly 1.0: no batter handedness, no pitcher handedness, no splits, a stat platoon says nothing about, and a missing side falling back to the prior rather than to zero. INDEPENDENT of the environment. Park and weather compose into one coefficient because they both describe the stadium; platoon describes this hitter against this pitcher's hand, so it rides its own slot with its own label. Entangling them would make both harder to attribute when the instrument scores them. Directional, mirrored on the under, capped at 15%, and inverted for strikeouts where a higher rate means a higher prop rather than a better hitter. Tests 3729 passed / 300 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/* ============================================================
|
||||
Session 76 — PLATOON SPLITS. Highest value, highest noise.
|
||||
The regression IS the feature: raw splits applied unregressed are harmful.
|
||||
============================================================ */
|
||||
|
||||
const pl = require('../../src/services/platoonSplits');
|
||||
const ch = require('../../src/services/challengerProjection');
|
||||
|
||||
/** A hitter with `pa` PA vs LHP at `avg`, 300 PA vs RHP at his overall rate. */
|
||||
const mk = (pa, avg, overall = 0.250) => ({
|
||||
vsL: { pa, avg, ops: avg * 2.6, slg: avg * 1.7 },
|
||||
vsR: { pa: 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 },
|
||||
overall: { pa: pa + 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 },
|
||||
});
|
||||
const est = (splits, ph = 'L', stat = 'hits', bh = 'R') =>
|
||||
pl.platoonEstimate({ splits, batterHand: bh, pitcherHand: ph, statType: stat });
|
||||
|
||||
describe('THE MAKE-OR-BREAK TEST — thin splits must not move anything', () => {
|
||||
it('a .310 split on 30 PA is regressed almost entirely away', () => {
|
||||
const e = est(mk(30, 0.310));
|
||||
expect(e.observed_weight).toBeLessThan(0.06); // ~4.8% evidence
|
||||
expect(Math.abs(e.multiplier - 1)).toBeLessThan(0.02);
|
||||
expect(e.material).toBe(false);
|
||||
});
|
||||
|
||||
it('the SAME split on 400 PA produces a real adjustment', () => {
|
||||
const e = est(mk(400, 0.310));
|
||||
expect(e.observed_weight).toBeCloseTo(0.4, 1);
|
||||
expect(e.multiplier).toBeGreaterThan(1.05);
|
||||
expect(e.material).toBe(true);
|
||||
});
|
||||
|
||||
it('deep sample moves the projection MANY times more than thin', () => {
|
||||
const thin = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(30, 0.310)) });
|
||||
const deep = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(400, 0.310)) });
|
||||
// If this ratio ever collapses toward 1, the regression has broken and
|
||||
// platoon is applying noise.
|
||||
expect(Math.abs(deep.delta)).toBeGreaterThan(Math.abs(thin.delta) * 5);
|
||||
});
|
||||
|
||||
it('is symmetric — a deep NEGATIVE split suppresses by the same magnitude', () => {
|
||||
const up = est(mk(400, 0.310));
|
||||
const down = est(mk(400, 0.190));
|
||||
expect(Math.abs(up.multiplier - 1)).toBeCloseTo(Math.abs(down.multiplier - 1), 2);
|
||||
expect(down.multiplier).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('regresses toward the HITTER\'S OWN rate, not the league', () => {
|
||||
// The question is "is he different vs this hand than he normally is", so a
|
||||
// hitter whose split matches his overall gets NO adjustment however deep.
|
||||
const e = est(mk(2000, 0.250, 0.250));
|
||||
expect(e.multiplier).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regressSplit — the mechanism', () => {
|
||||
it('weight rises with PA and never reaches 1', () => {
|
||||
const w = (pa) => pl.regressSplit({ observedRate: 0.3, observedPa: pa, priorRate: 0.25 }).weight;
|
||||
expect(w(30)).toBeLessThan(w(130));
|
||||
expect(w(130)).toBeLessThan(w(400));
|
||||
expect(w(400)).toBeLessThan(w(1000));
|
||||
expect(w(100000)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('no observation → the prior stands, untouched', () => {
|
||||
const r = pl.regressSplit({ observedRate: null, observedPa: null, priorRate: 0.25 });
|
||||
expect(r.rate).toBe(0.25);
|
||||
expect(r.weight).toBe(0);
|
||||
});
|
||||
|
||||
it('no prior → no estimate at all (never invents one)', () => {
|
||||
expect(pl.regressSplit({ observedRate: 0.3, observedPa: 400, priorRate: null }).rate).toBeNull();
|
||||
});
|
||||
|
||||
it('K is conservative — 600 PA of prior', () => {
|
||||
expect(pl.K_PA).toBe(600);
|
||||
expect(pl.regressSplit({ observedRate: 1, observedPa: 600, priorRate: 0 }).weight).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('honest-absent — frequent and CORRECT here', () => {
|
||||
it.each([
|
||||
['no batter handedness', { bh: null, ph: 'L' }, 'batter_hand_absent'],
|
||||
['no pitcher handedness', { bh: 'R', ph: null }, 'pitcher_hand_absent'],
|
||||
])('%s → multiplier 1', (_n, { bh, ph }, state) => {
|
||||
const e = pl.platoonEstimate({ splits: mk(400, 0.310), batterHand: bh, pitcherHand: ph, statType: 'hits' });
|
||||
expect(e.multiplier).toBe(1);
|
||||
expect(e.state).toBe(state);
|
||||
});
|
||||
|
||||
it('a stat platoon says nothing about → not_applicable', () => {
|
||||
expect(est(mk(400, 0.310), 'L', 'stolen_bases').state).toBe('not_applicable');
|
||||
});
|
||||
|
||||
it('no splits at all → absent, never a guess', () => {
|
||||
expect(pl.platoonEstimate({ splits: null, batterHand: 'R', pitcherHand: 'L', statType: 'hits' }).state)
|
||||
.toBe('splits_absent');
|
||||
});
|
||||
|
||||
it('a missing SIDE falls back to the prior rather than zero', () => {
|
||||
const s = { vsL: null, vsR: { pa: 300, avg: 0.25 }, overall: { pa: 300, avg: 0.25 } };
|
||||
const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' });
|
||||
expect(e.multiplier).toBe(1);
|
||||
expect(e.observed_weight).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditioned on TONIGHT\'S pitcher hand', () => {
|
||||
it('uses the vs-LHP split against an LHP and the vs-RHP split against an RHP', () => {
|
||||
const s = {
|
||||
vsL: { pa: 400, avg: 0.310 }, vsR: { pa: 400, avg: 0.210 },
|
||||
overall: { pa: 800, avg: 0.260 },
|
||||
};
|
||||
const vsL = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' });
|
||||
const vsR = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'R', statType: 'hits' });
|
||||
expect(vsL.multiplier).toBeGreaterThan(1);
|
||||
expect(vsR.multiplier).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('inverts for strikeouts — a higher K rate means a LOWER hit prop, not higher', () => {
|
||||
const s = {
|
||||
vsL: { pa: 400, k_rate: 0.35 }, vsR: { pa: 400, k_rate: 0.20 },
|
||||
overall: { pa: 800, k_rate: 0.25 },
|
||||
};
|
||||
const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'strikeouts' });
|
||||
// He strikes out MORE vs LHP → the strikeout prop over is MORE likely.
|
||||
expect(e.rate_key).toBe('k_rate');
|
||||
expect(e.multiplier).not.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('INDEPENDENT of the environment coefficient', () => {
|
||||
it('platoon rides its own slot, labelled separately', () => {
|
||||
const r = ch.adjust({
|
||||
pWin: 0.5, direction: 'over', statType: 'hits',
|
||||
matchup: { ...est(mk(400, 0.310)), label: 'PLATOON' },
|
||||
environment: { multiplier: 1.10, label: 'PARK × WEATHER', venue: 'Coors Field' },
|
||||
});
|
||||
const axes = r.adjustments.map((a) => a.axis);
|
||||
expect(axes).toContain('matchup');
|
||||
expect(axes).toContain('environment');
|
||||
// Two separate entries → the instrument can attribute them independently.
|
||||
expect(r.adjustments.find((a) => a.axis === 'matchup').label).toBe('PLATOON');
|
||||
});
|
||||
|
||||
it('mirrors on the under', () => {
|
||||
const m = { ...est(mk(400, 0.310)), label: 'PLATOON' };
|
||||
const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: m });
|
||||
const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'hits', matchup: m });
|
||||
expect(under.delta).toBeCloseTo(-over.delta, 3);
|
||||
});
|
||||
|
||||
it('is capped — platoon leans, never re-forecasts', () => {
|
||||
const absurd = est(mk(5000, 0.600));
|
||||
expect(Math.abs(absurd.multiplier - 1)).toBeLessThanOrEqual(pl.MAX_MULT + 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSplits — statsapi shape', () => {
|
||||
const payload = { stats: [{ splits: [
|
||||
{ split: { code: 'vl' }, stat: { plateAppearances: 126, avg: '.259', ops: '.698', slg: '.400', strikeOuts: 30, baseOnBalls: 10 } },
|
||||
{ split: { code: 'vr' }, stat: { plateAppearances: 265, avg: '.248', ops: '.755', slg: '.430', strikeOuts: 55, baseOnBalls: 20 } },
|
||||
] }] };
|
||||
|
||||
it('parses both sides and derives rates', () => {
|
||||
const s = pl.parseSplits(payload);
|
||||
expect(s.vsL.pa).toBe(126);
|
||||
expect(s.vsL.avg).toBeCloseTo(0.259, 3);
|
||||
expect(s.vsR.k_rate).toBeCloseTo(55 / 265, 3);
|
||||
});
|
||||
|
||||
it('builds the overall prior as a PA-weighted blend when none is supplied', () => {
|
||||
const s = pl.parseSplits(payload);
|
||||
expect(s.overall.pa).toBe(391);
|
||||
expect(s.overall.avg).toBeGreaterThan(0.248);
|
||||
expect(s.overall.avg).toBeLessThan(0.259);
|
||||
});
|
||||
|
||||
it('an absent side stays null — never zero-filled', () => {
|
||||
const s = pl.parseSplits({ stats: [{ splits: [payload.stats[0].splits[1]] }] });
|
||||
expect(s.vsL).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user