Consistency classifier: CV → index of dispersion for low-mean counts
The A/D investigation found CV (std/mean) is scale-broken on count data —
for a Poisson-ish stat cv ≈ 1/sqrt(mean), so EVERY stat with mean < 4 blew
past the boom_bust cutoff regardless of behavior. The S63 stopgap made those
return 'unknown', which silently ate a real +1.0 consistency signal on every
MLB batting prop — steady low-mean hitters never got their earned factor.
Fix, fenced to the low-mean branch of consistencyScore (the only branch that
was returning 'unknown'): classify with the index of dispersion (variance/mean,
Poisson baseline 1.0) — the scale-appropriate, UNBIASED statistic for counts.
mean ≥ 4 keeps the NBA-calibrated CV path BYTE-IDENTICAL (zero NBA blast
radius). This is a bug CORRECTION, not threshold loosening: the CV thresholds
and the engine1 ±1.0 delta are unchanged.
Bands (asymmetric around Poisson 1.0, since counts are naturally mildly
over-dispersed): iod<0.60 elite / <0.85 reliable (+1.0) / ≤1.30 volatile
(neutral) / >1.30 boom_bust (−1.0). Sample floor MIN_GAMES_FOR_IOD=8 so a
thin sample abstains ('unknown') — no small-sample guess.
Validated on real 10-game logs (two-sided): Kwan hits 0.67 / Alonso hits
0.78 → reliable (RECOVERED); Alonso TB 2.57 / Henderson hits 1.33 → boom_bust
(no false consistency); HR mean 0.1 → 1.0 → neutral. Direct engine1 proof: a
strong steady prop that grades B+ today reaches A- once the +1.0 fires; a
boom-bust bat stays B (no inflation). A- now emerges NATURALLY from a real
recovered factor. Standing two-sided test pins all three directions.
Forward-only (settled grades are locked in the ledger, never re-graded).
Emitting A- ≠ proving A- — the A-tier record accrues from emission, still
measurement-gated. Full unit suite green (4 pre-existing redis/timing flakes
pass in isolation); 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:
@@ -1,15 +1,29 @@
|
||||
/**
|
||||
* Consistency score — how predictable is this player for this stat?
|
||||
*
|
||||
* cv = stddev / mean
|
||||
* TWO scale-appropriate statistics, split at the mean where each is valid:
|
||||
*
|
||||
* Coefficient of variation collapses sample-size differences and lets us
|
||||
* compare a 25-point scorer with low variance to a 12-point scorer with
|
||||
* the same absolute variance. Lower cv = more reliable.
|
||||
* HIGH-MEAN (mean ≥ 4, e.g. NBA points, pitcher Ks):
|
||||
* cv = stddev / mean (coefficient of variation)
|
||||
* LOW-MEAN (mean < 4, e.g. MLB hits / TB / HR / RBI):
|
||||
* iod = variance / mean (index of dispersion; Poisson baseline = 1)
|
||||
*
|
||||
* The consistency score modifies Engine 2's confidence. An "elite"
|
||||
* consistency player gets a tighter projection range; a "boom_bust"
|
||||
* player gets a wider one.
|
||||
* WHY THE SPLIT (Session — consistency classifier fix): CV is scale-DEPENDENT
|
||||
* on count data — for a Poisson-ish stat cv ≈ 1/sqrt(mean), so EVERY stat with
|
||||
* mean < 4 blows past the CV boom_bust cutoff no matter how the player actually
|
||||
* behaves. The A/D investigation found this was eating a real +1.0 signal:
|
||||
* steady low-mean hitters (Kwan, Alonso hits) were blanket-classified and
|
||||
* their earned consistency factor never fired. The index of dispersion is the
|
||||
* correct statistic for counts — UNBIASED, centered at 1.0 for a random
|
||||
* (Poisson) process regardless of the mean — so it recovers that signal
|
||||
* without systematically down- or up-grading anyone. This is a bug CORRECTION,
|
||||
* not a threshold loosening: the CV thresholds and the engine1 ±1.0 delta are
|
||||
* unchanged; only the low-mean branch that used to return 'unknown' now
|
||||
* classifies on merit.
|
||||
*
|
||||
* The consistency score modifies the grade: an "elite"/"reliable" player adds
|
||||
* +1.0 (engine1), a "boom_bust" player subtracts −1.0; "volatile"/"unknown"
|
||||
* add nothing.
|
||||
*/
|
||||
|
||||
const gameLogService = require('./gameLogService');
|
||||
@@ -41,6 +55,41 @@ function classify(cv) {
|
||||
return { consistency: 'boom_bust', score: 0.1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* INDEX-OF-DISPERSION classifier for LOW-MEAN COUNT stats. iod = variance/mean;
|
||||
* a random (Poisson) process sits at 1.0 REGARDLESS of the mean, so the bands
|
||||
* are anchored on 1.0, not on an NBA-calibrated absolute like CV's.
|
||||
*
|
||||
* The bands are deliberately ASYMMETRIC around 1.0: real count stats are
|
||||
* naturally mildly over-dispersed (the per-game rate itself varies with
|
||||
* matchup / park), so "meaningfully steadier than random" (iod < 0.85) is the
|
||||
* signal that earns +1.0, and only a clear spike (iod > 1.30) earns −1.0. The
|
||||
* wide neutral band 0.85–1.30 abstains — most hitters are Poisson-ish and get
|
||||
* NO factor, which is the honest answer, not a limitation.
|
||||
*
|
||||
* Validated on real 10-game logs: Kwan hits 0.67 → reliable, Alonso hits
|
||||
* 0.78 → reliable (the recovery), Alonso TB 2.57 / Henderson hits 1.33 →
|
||||
* boom_bust (spikes), HR at mean 0.1 → 1.0 → volatile (rare-event Poisson).
|
||||
*/
|
||||
const IOD_ELITE_MAX = Number(process.env.CONSISTENCY_IOD_ELITE || 0.60);
|
||||
const IOD_RELIABLE_MAX = Number(process.env.CONSISTENCY_IOD_RELIABLE || 0.85);
|
||||
const IOD_BOOMBUST_MIN = Number(process.env.CONSISTENCY_IOD_BOOMBUST || 1.30);
|
||||
|
||||
function classifyIoD(iod) {
|
||||
if (iod < IOD_ELITE_MAX) return { consistency: 'elite', score: 1.0 };
|
||||
if (iod < IOD_RELIABLE_MAX) return { consistency: 'reliable', score: 0.7 };
|
||||
if (iod <= IOD_BOOMBUST_MIN) return { consistency: 'volatile', score: 0.4 };
|
||||
return { consistency: 'boom_bust', score: 0.1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* IoD is itself noisy at tiny samples (its sampling sd ≈ sqrt(2/(n−1)) for a
|
||||
* Poisson process). Below this many games we abstain ('unknown') rather than
|
||||
* trade a scale bug for a small-sample bug — the same refusal discipline the
|
||||
* old CV floor used. 8 games + a clear departure from 1.0 is the noise buffer.
|
||||
*/
|
||||
const MIN_GAMES_FOR_IOD = Number(process.env.CONSISTENCY_MIN_GAMES_IOD || 8);
|
||||
|
||||
/**
|
||||
* Session 63 — the CV thresholds above are NBA-calibrated (points ~20/game,
|
||||
* cv ~0.2-0.4). They are MEANINGLESS for a low-count stat.
|
||||
@@ -53,13 +102,13 @@ function classify(cv) {
|
||||
*
|
||||
* When the estimator path was revived, this would have stamped a blanket
|
||||
* -1.0 on nearly every MLB prop — a systematic downgrade masquerading as a
|
||||
* signal. Below the floor we return 'unknown' so engine1 adds NO factor:
|
||||
* absent beats wrong.
|
||||
* signal. That is why the CV floor returned 'unknown' below mean 4.
|
||||
*
|
||||
* The RIGHT long-term fix is an index-of-dispersion (variance/mean vs the
|
||||
* Poisson baseline) classifier, which is scale-free. That is a modelling
|
||||
* change with its own validation and is tracked separately — this floor is
|
||||
* the honest stopgap, not the answer.
|
||||
* RESOLVED (consistency classifier fix): the low-mean branch no longer
|
||||
* abstains blindly — it now classifies with the index of dispersion
|
||||
* (`classifyIoD`), the scale-free statistic for counts. The floor below is
|
||||
* kept as the CV/IoD SPLIT POINT (which statistic to use), not as a blanket
|
||||
* refusal: mean ≥ 4 uses CV, mean < 4 uses IoD (games-floored).
|
||||
*/
|
||||
const MIN_MEAN_FOR_CV = Number(process.env.CONSISTENCY_MIN_MEAN || 4);
|
||||
|
||||
@@ -74,7 +123,14 @@ function statsFor(values) {
|
||||
if (mean === 0) return null;
|
||||
const variance = clean.reduce((s, v) => s + (v - mean) ** 2, 0) / (clean.length - 1);
|
||||
const stddev = Math.sqrt(variance);
|
||||
return { mean, stddev, cv: stddev / Math.abs(mean), games: clean.length };
|
||||
return {
|
||||
mean,
|
||||
stddev,
|
||||
variance,
|
||||
cv: stddev / Math.abs(mean),
|
||||
iod: variance / Math.abs(mean), // index of dispersion (Poisson baseline 1.0)
|
||||
games: clean.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function getConsistency(input = {}) {
|
||||
@@ -86,13 +142,20 @@ async function getConsistency(input = {}) {
|
||||
const values = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
|
||||
const s = statsFor(values);
|
||||
if (!s) return { consistency: 'unknown', score: null, games: values.length };
|
||||
// Session 63 — refuse to classify when CV cannot discriminate at this scale.
|
||||
// LOW-MEAN regime: CV is scale-broken here, so classify with the index of
|
||||
// dispersion (scale-appropriate for counts). Abstain if the sample is too
|
||||
// thin for IoD — absent beats a small-sample guess.
|
||||
if (!cvIsMeaningful(s.mean)) {
|
||||
return { ...s, consistency: 'unknown', score: null, reason: 'low_mean_cv_unreliable' };
|
||||
if (s.games < MIN_GAMES_FOR_IOD) {
|
||||
return { ...s, consistency: 'unknown', score: null, reason: 'low_mean_thin_sample', method: 'iod' };
|
||||
}
|
||||
return { ...s, ...classifyIoD(s.iod), method: 'iod' };
|
||||
}
|
||||
return { ...s, ...classify(s.cv) };
|
||||
// HIGH-MEAN regime: CV with the NBA-calibrated thresholds (unchanged).
|
||||
return { ...s, ...classify(s.cv), method: 'cv' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConsistency, classify, statsFor, statFromGameLog, cvIsMeaningful, MIN_MEAN_FOR_CV,
|
||||
getConsistency, classify, classifyIoD, statsFor, statFromGameLog, cvIsMeaningful,
|
||||
MIN_MEAN_FOR_CV, MIN_GAMES_FOR_IOD, IOD_ELITE_MAX, IOD_RELIABLE_MAX, IOD_BOOMBUST_MIN,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user