Derive the lineup K-rate free, and fingerprint the cap fix
Two premise corrections first. Pitcher stuff features have NOT proven solo through the gate -- every one was refused on sample (n=57 against 500). Four exceed the effect-size bar (arm angle -0.250, whiff +0.213, k rate +0.206, chase +0.195), which is why they are worth pursuing, but clearing one of three thresholds is not passing. And the carrier was not blocked only on the lineup input: that input was built and measured last session at 94.7% coverage. What blocks it is n, and n was being throttled by the grading cap. RUNG 1 IS DERIVED AND COSTS NOTHING. Opposing-team K-rate comes from joining the opposing roster to the batter k_pct values already in statcast_aggregates -- no new feed. The improvement this session is that it is PA-WEIGHTED: an unweighted roster mean counts a 12-PA callup the same as an everyday starter, which is not the lineup a pitcher faces. That change alone reversed the term's sign. Unweighted, the lineup term HURT the model (0.1738 -> 0.1285). PA-weighted, it HELPS (0.1738 -> 0.1953). Same hypothesis, same data -- the derivation was the problem, not the signal, which is the entire argument for deriving the best honest version before sourcing anything. Head-to-head is now +0.2592 with a CI of [-0.0167, +0.5645], very nearly excluding zero, at n=57. Within archetype, the two strata come out with OPPOSITE signs -- FLAME incremental -0.152, non-FLAME +0.145 -- and the pooled value (+0.077) sits between them, which is the shape a conditional effect makes and is invisible when pooled. That is what stratifying was for. But n is 20 and 24, the standard error on a correlation there is about 0.22, and the direction contradicts the theory that predicted a stronger effect for finesse arms. It is recorded as a structure to re-test, not as a finding. Rungs 2 and 3 are NOT triggered. A rung fails only once it has been fairly tested, and Rung 1 is n-blocked rather than failed. Sourcing confirmed lineups now would be paying for precision on top of a proxy we have not yet measured. THE RESULT THAT DECIDES THE TIMELINE: yesterday's cap raise is fingerprinted in production at 907 grades per snapshot, up from 334, with strikeouts going 6 to 17. That puts n>=500 for pitcher Ks about a week out instead of three months. Operational note: the manual internal snapshot endpoint now 524s at the Cloudflare edge because grading the full board exceeds 100s -- the run still completes server-side (this very snapshot was written by a 524'd request) and the cron is in-process, so a 524 there is not a failure. Nothing proven, nothing calibrated, nothing shipped. The counter remains anti-predictive on strikeouts at -0.064 and the skill model leads it by 0.26. 4,221 tests green (335 suites); web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -161,13 +161,23 @@ async function main() {
|
||||
if (!abbr) { teamKRate.set(teamName, null); return null; }
|
||||
const team = await mlb.resolveTeam(abbr);
|
||||
const roster = team && team.id ? await mlb.getTeamRoster(team.id) : null;
|
||||
const rates = [];
|
||||
// PA-WEIGHTED, not a flat roster average. An unweighted mean counts a
|
||||
// 12-PA September call-up the same as an everyday starter, which is not
|
||||
// the lineup a pitcher faces. Weighting by each batter's own sample_pa is
|
||||
// the closest honest approximation of "who actually bats" from data we
|
||||
// already hold — and it needs no new sourcing at all.
|
||||
let wSum = 0; let wK = 0; let counted = 0;
|
||||
for (const p of roster || []) {
|
||||
const prof = batterByKey.get(nameKey(p.name || p.fullName || ''));
|
||||
const k = prof ? knownRate(prof.k_pct) : null;
|
||||
if (k !== null) rates.push(k);
|
||||
if (!prof) continue;
|
||||
const k = knownRate(prof.k_pct);
|
||||
const pa = knownRate(prof.sample_pa);
|
||||
if (k === null) continue;
|
||||
const w = pa === null ? 0 : pa; // no PA read -> contributes nothing
|
||||
if (w <= 0) continue;
|
||||
wSum += w; wK += w * k; counted += 1;
|
||||
}
|
||||
if (rates.length >= 5) val = rates.reduce((a, b) => a + b, 0) / rates.length;
|
||||
if (counted >= 5 && wSum > 0) val = wK / wSum;
|
||||
} catch { val = null; }
|
||||
teamKRate.set(teamName, val);
|
||||
return val;
|
||||
@@ -201,9 +211,16 @@ async function main() {
|
||||
pitcher: prof, lineupKRate: lineupK, archetype: arch,
|
||||
role: 'starter', line: Number(r.line),
|
||||
});
|
||||
// The SAME model with the lineup term switched off, so the term's
|
||||
// contribution is isolated rather than inferred.
|
||||
const projNo = pe.projectStrikeouts({
|
||||
pitcher: prof, lineupKRate: null, archetype: arch,
|
||||
role: 'starter', line: Number(r.line),
|
||||
});
|
||||
rows.push({
|
||||
won, champ, residual: won - champ,
|
||||
pitch: proj ? (under ? 1 - proj.p_over_line : proj.p_over_line) : null,
|
||||
pitch_nolineup: projNo ? (under ? 1 - projNo.p_over_line : projNo.p_over_line) : null,
|
||||
lineup_applied: !!lineupK,
|
||||
archetype: arch,
|
||||
archetype_flame: arch == null ? null : (arch === 'FLAME' ? 1 : 0),
|
||||
@@ -253,9 +270,49 @@ async function main() {
|
||||
};
|
||||
}
|
||||
|
||||
// ── WITHIN-ARCHETYPE: the order's sharper hypothesis ────────────────────
|
||||
// The interaction should matter MORE for finesse arms (SCALPEL/SINKER), whose
|
||||
// strikeouts need a lineup that will chase or can be beaten, than for power
|
||||
// arms (FLAME) whose stuff whiffs regardless of who is standing there. Pooling
|
||||
// the two would average a real conditional effect toward zero — which is
|
||||
// exactly the failure mode "test within archetype" exists to prevent.
|
||||
const strata = {};
|
||||
for (const [label, pred] of [
|
||||
['FLAME (power)', (r) => r.archetype === 'FLAME'],
|
||||
['non-FLAME (finesse/contact)', (r) => r.archetype && r.archetype !== 'FLAME'],
|
||||
]) {
|
||||
const rs = rows.filter((r) => pred(r)
|
||||
&& knownNumber(r.pitcher_whiff_pct) !== null
|
||||
&& knownNumber(r.opposing_lineup_k_rate) !== null);
|
||||
if (rs.length < 15) { strata[label] = { n: rs.length, verdict: 'UNTESTABLE — stratum too thin' }; continue; }
|
||||
const I = rs.map((r) => r.pitcher_whiff_pct * r.opposing_lineup_k_rate);
|
||||
const Y = rs.map((r) => r.residual);
|
||||
const ctrl = [rs.map((r) => r.pitcher_whiff_pct), rs.map((r) => r.opposing_lineup_k_rate)];
|
||||
const incr = partialCorr(I, Y, ctrl);
|
||||
const parts = [
|
||||
{ feature: 'pitcher_whiff_pct', r: r4(cv.pearson(rs.map((r) => r.pitcher_whiff_pct), Y).r) },
|
||||
{ feature: 'opposing_lineup_k_rate', r: r4(cv.pearson(rs.map((r) => r.opposing_lineup_k_rate), Y).r) },
|
||||
];
|
||||
const best = Math.max(...parts.map((p) => Math.abs(p.r ?? 0)));
|
||||
strata[label] = {
|
||||
n: rs.length,
|
||||
raw_r_vs_residual: r4(cv.pearson(I, Y).r),
|
||||
lineup_solo_r: parts[1].r,
|
||||
component_solo_r: parts,
|
||||
best_component_abs_r: r4(best),
|
||||
INCREMENTAL_partial_r: r4(incr),
|
||||
adds_over_components: incr !== null && Math.abs(incr) > best,
|
||||
gate: cv.validateFactor(I, Y, TESTS),
|
||||
verdict: incr === null ? 'UNTESTABLE — collinear'
|
||||
: rs.length < 500 ? 'UNDERPOWERED — n below the gate'
|
||||
: (Math.abs(incr) >= 0.15 ? 'ADDS' : 'REDUNDANT'),
|
||||
};
|
||||
}
|
||||
|
||||
const h2h = rows.filter((r) => r.pitch != null);
|
||||
const ys = h2h.map((r) => r.won);
|
||||
const bs = bootstrapDiff(h2h, 'pitch', 'champ');
|
||||
const bsNoLineup = bootstrapDiff(h2h.filter((r) => r.pitch_nolineup != null), 'pitch_nolineup', 'champ');
|
||||
|
||||
console.log(JSON.stringify({
|
||||
stat: 'strikeouts',
|
||||
@@ -267,12 +324,19 @@ async function main() {
|
||||
bonferroni_tests: TESTS,
|
||||
step1_solo_baseline: solo,
|
||||
step3_interactions: interactions,
|
||||
step3b_within_archetype_carrier: strata,
|
||||
step4_vs_counter: {
|
||||
n: h2h.length,
|
||||
base_rate: r4(mean(ys)),
|
||||
resolution: { pitch_v1: r4(cv.pearson(h2h.map((r) => r.pitch), ys).r), counter: r4(cv.pearson(h2h.map((r) => r.champ), ys).r) },
|
||||
brier: { pitch_v1: r4(brier(h2h.map((r) => r.pitch), ys)), counter: r4(brier(h2h.map((r) => r.champ), ys)) },
|
||||
delta: bs,
|
||||
// Isolating the lineup term: does including it help or hurt?
|
||||
without_lineup_term: {
|
||||
resolution: r4(cv.pearson(h2h.filter((r) => r.pitch_nolineup != null).map((r) => r.pitch_nolineup),
|
||||
h2h.filter((r) => r.pitch_nolineup != null).map((r) => r.won)).r),
|
||||
delta_vs_counter: bsNoLineup,
|
||||
},
|
||||
verdict: !bs ? 'N-BLOCKED — too few rows to bootstrap'
|
||||
: (bs.ci_excludes_zero && bs.point > 0) ? 'BEATS THE COUNTER'
|
||||
: (bs.ci_excludes_zero && bs.point < 0) ? 'LOSES to the counter' : 'INCONCLUSIVE',
|
||||
|
||||
Reference in New Issue
Block a user