proj-v1 sanity fixes (caught in the real-data induction)
1. matchupRead fly-ball signal: the batter metrics `gb_pct_bb`/`fb_ld_pct` are MISLABELED — they're exit velocities by batted-ball type (Judge fb_ld_pct = 100.3 mph, not a rate), not ground/fly RATES. Switched fly-ball lean to avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1°), the correct signal. 2. Absolute rate now fits the FULL season (recency-weighted), not a 20-game window: the window under-sampled rare stats — Judge HR projected 0.11 vs his 0.28 season rate (a fake -32pt edge). Now point=0.27 (matches season); the last-5-2x recency lean is preserved. Post-fix induction (real statsapi logs + real statcast): Judge HR 0.27 (P>=1 0.235 vs book 0.42 -> flags the juiced over), Judge TB P>=2 0.548 vs 0.48 (+6.8pt), thin-hot 3-game P>=1 0.726 / P>=3 0.164 (credible low, thin high), .300 hitter != 3.0. proj-v1 suites 23/23. 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:
@@ -64,28 +64,29 @@ function classifyArsenal(pitchMix) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* hitterProfile(batterRow) — the aggregate tendencies we DO have. gb/fb lean
|
* hitterProfile(batterRow) — the aggregate tendencies we DO have.
|
||||||
* comes from gb_pct_bb vs fb_ld_pct in the metrics blob (batted-ball type).
|
*
|
||||||
|
* FLY-BALL lean comes from avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1°
|
||||||
|
* verified in prod). NOTE: the metrics blob's `gb_pct_bb` / `fb_ld_pct` are
|
||||||
|
* mislabeled — they are EXIT VELOCITIES by batted-ball type (Judge fb_ld_pct =
|
||||||
|
* 100.3 mph, not a rate), NOT ground/fly RATES. Launch angle is the correct,
|
||||||
|
* unambiguous air-vs-ground signal. (Caught in the proj-v1 sanity induction.)
|
||||||
*/
|
*/
|
||||||
function hitterProfile(row) {
|
function hitterProfile(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const m = row.metrics || {};
|
const m = row.metrics || {};
|
||||||
const gb = num(m.gb_pct_bb);
|
|
||||||
const fb = num(m.fb_ld_pct);
|
|
||||||
let flyLean = null;
|
|
||||||
if (gb != null && fb != null && (gb + fb) > 0) flyLean = fb / (gb + fb); // 0..1, high = fly-ball
|
|
||||||
return {
|
return {
|
||||||
whiff: num(row.whiff_pct) ?? num(m.whiff_pct),
|
whiff: num(row.whiff_pct) ?? num(m.whiff_pct),
|
||||||
chase: num(row.chase_pct) ?? num(m.chase_pct),
|
chase: num(row.chase_pct) ?? num(m.chase_pct),
|
||||||
hard_hit: num(row.hard_hit_pct) ?? num(m.hard_hit_pct),
|
hard_hit: num(row.hard_hit_pct) ?? num(m.hard_hit_pct),
|
||||||
fly_lean: flyLean, // fraction of batted balls in the air
|
launch_angle: num(row.avg_launch_angle) ?? num(m.avg_launch_angle), // ° — higher = more air
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// League anchors (2026 percentiles, verified in prod) — the neutral point each
|
// League anchors (2026 percentiles, verified in prod) — the neutral point each
|
||||||
// tendency is measured against, so "high"/"low" means relative to the league.
|
// tendency is measured against, so "high"/"low" means relative to the league.
|
||||||
const ANCHOR = Object.freeze({
|
const ANCHOR = Object.freeze({
|
||||||
arsenal_whiff: 22, hitter_whiff: 24, hard_hit: 38, fly_lean: 0.5,
|
arsenal_whiff: 22, hitter_whiff: 24, hard_hit: 38, launch_angle: 13.9,
|
||||||
});
|
});
|
||||||
// Max single-component lean and total matchup lean — coarse read, small effect.
|
// Max single-component lean and total matchup lean — coarse read, small effect.
|
||||||
const COMP_MAX = 0.05;
|
const COMP_MAX = 0.05;
|
||||||
@@ -118,7 +119,7 @@ function matchupMultiplier({ arsenal, hitter, statType } = {}) {
|
|||||||
// fly-ball arsenal lets a fly-ball hitter elevate. DERIVED: sinker share
|
// fly-ball arsenal lets a fly-ball hitter elevate. DERIVED: sinker share
|
||||||
// vs hitter fly lean.
|
// vs hitter fly lean.
|
||||||
const groundy = clamp(arsenal.sinker_pct - 0.20, -0.5, 0.6) / 0.6; // >20% sinker = groundy
|
const groundy = clamp(arsenal.sinker_pct - 0.20, -0.5, 0.6) / 0.6; // >20% sinker = groundy
|
||||||
const flyBat = z(hitter.fly_lean, ANCHOR.fly_lean, 0.2);
|
const flyBat = z(hitter.launch_angle, ANCHOR.launch_angle, 6); // p90 20° → ~+1, p10 7° → ~-1
|
||||||
const airSuppress = clamp(groundy * Math.max(0, flyBat), -1, 1); // groundy arm vs fly bat → power down
|
const airSuppress = clamp(groundy * Math.max(0, flyBat), -1, 1); // groundy arm vs fly bat → power down
|
||||||
|
|
||||||
// (3) HARD-CONTACT axis — an arsenal that allows hard contact, faced by a
|
// (3) HARD-CONTACT axis — an arsenal that allows hard contact, faced by a
|
||||||
|
|||||||
@@ -87,9 +87,14 @@ function parkBaselineFromLogs(gameLog, playerTeamAbbr) {
|
|||||||
return factors.reduce((a, b) => a + b, 0) / factors.length;
|
return factors.reduce((a, b) => a + b, 0) / factors.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recency-weighted observed counts (last-5 games weighted 2×, like the champion). */
|
/**
|
||||||
function recencyWeighted(gameLog, field, window = 20) {
|
* Season-with-recency observed counts: the FULL season anchors the absolute rate
|
||||||
// gameLog is most-recent-LAST (statsapi order); take the tail as recent.
|
* (a 20-game window under-samples rare stats — a slugger's HR drought produced a
|
||||||
|
* 0.11 point vs a 0.28 season rate, a fake -32pt edge, caught in the induction),
|
||||||
|
* with the last 5 games weighted 2× for current form (as the champion does).
|
||||||
|
*/
|
||||||
|
function recencyWeighted(gameLog, field, window = 200) {
|
||||||
|
// gameLog is most-recent-LAST (statsapi order); the tail is the recent form.
|
||||||
const rows = (gameLog || []).filter((g) => g && g.stat);
|
const rows = (gameLog || []).filter((g) => g && g.stat);
|
||||||
const recent = rows.slice(-window);
|
const recent = rows.slice(-window);
|
||||||
const n = recent.length;
|
const n = recent.length;
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const FB_ARM = mr.classifyArsenal([
|
|||||||
{ type: 'FC', usage_pct: 20, whiff_pct: 18, hard_hit_pct: 40 },
|
{ type: 'FC', usage_pct: 20, whiff_pct: 18, hard_hit_pct: 40 },
|
||||||
{ type: 'CU', usage_pct: 15, whiff_pct: 20, hard_hit_pct: 30 },
|
{ type: 'CU', usage_pct: 15, whiff_pct: 20, hard_hit_pct: 30 },
|
||||||
]);
|
]);
|
||||||
// A fly-ball, hard-hit hitter; and a whiff-prone one.
|
// A fly-ball (high launch angle), hard-hit hitter; and a whiff-prone, flatter one.
|
||||||
const flyBat = { whiff_pct: 22, chase_pct: 28, hard_hit_pct: 46, metrics: { gb_pct_bb: 30, fb_ld_pct: 55 } };
|
const flyBat = { whiff_pct: 22, chase_pct: 28, hard_hit_pct: 46, avg_launch_angle: 20 };
|
||||||
const whiffBat = { whiff_pct: 34, chase_pct: 34, hard_hit_pct: 34, metrics: { gb_pct_bb: 45, fb_ld_pct: 40 } };
|
const whiffBat = { whiff_pct: 34, chase_pct: 34, hard_hit_pct: 34, avg_launch_angle: 10 };
|
||||||
|
|
||||||
describe('arsenal classification', () => {
|
describe('arsenal classification', () => {
|
||||||
it('buckets usage by pitch family + carries whiff/hard-hit tendency', () => {
|
it('buckets usage by pitch family + carries whiff/hard-hit tendency', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user