diff --git a/docs/backtest.md b/docs/backtest.md index c4ace1a..87b58db 100644 --- a/docs/backtest.md +++ b/docs/backtest.md @@ -1,9 +1,49 @@ # 위험도 점수표 백테스트 +> ## 2026-09-22 표본 외 재실행 — v3 는 살아남았다 +> +> v3 를 고를 때 쓴 데이터(68주, 136단위)로 **다시 재는 것은 검증이 아니다.** 후보 10개를 +> 그 데이터에서 비교해 고른 것이라, 같은 데이터에서 잘 나오는 것은 당연하다. 고밀도 주가 +> 26건뿐이라 과적합 위험이 실질적이었다. +> +> ⚠️ **그런데 다시 돌려도 새 데이터가 들어오지 않았다.** `LIST_EDATE` 가 `'20260714'` 로 +> 하드코딩돼 있어서, 몇 달 뒤에 실행해도 같은 68주를 다시 읽었다. 고친 뒤 재실행했다. +> +> | | 선택 당시 (136단위) | 표본 외 포함 (156단위) | 2026 홀드아웃 (40단위) | +> |---|---|---|---| +> | 표본 | 68주, 고밀도 26 | 78주, 고밀도 34 | 고밀도 11 | +> | **v3 AUC** | 0.886 | **0.871** | **0.842** | +> | 베이스라인 AUC | 0.823 | 0.812 | 0.809 | +> | v3 danger+ 재현율 | 73.1% | **64.7%** | 63.6% | +> | v3 오경보율 | 2.8% | 2.6% | 18.2% | +> | v3 정밀도 | 70.4% | 71.0% | — | +> +> **① v3 의 순위 능력은 유지됐다.** 베이스라인과의 AUC 격차가 +0.063 → +0.059 로 거의 그대로다. +> 2026 홀드아웃에서도 0.842 vs 0.809 로 앞선다. **과적합으로 무너지지 않았다.** +> +> **② 재현율은 떨어졌다. 73.1% → 64.7%.** 같은 기간 베이스라인 재현율은 69.2% → 73.5% 로 +> 올랐다. 즉 **지금은 "지난주 NIFS 보고서를 그대로 베끼는 것" 이 엔진보다 더 많이 잡는다.** +> 안전 서비스에서 미경보는 가장 피해야 할 결과이므로 가볍게 볼 수 없다. +> +> ⚠️ 다만 34건 중 22건 vs 25건, **3주 차이**다. 짝지은 부트스트랩에서 v3 와 구별되는 후보는 +> 없었다((b3)·(d)·(e)·(g)·(h) 전부 ΔAUC CI 가 0 을 포함). **지금 데이터로 점수표를 다시 +> 고르면 그건 또 한 번의 선택-위-검증이다.** 이번에는 고치지 않는다. +> +> **③ 다음 재실행에서 볼 것** — 재현율 격차가 유지되는가. 유지되면 그때는 (b3) 고40/저10 +> (재현율 CI 최대 +14.8%, 오경보율 최대 +8.8%)을 놓고 **미경보와 과경보 중 무엇을 감수할지** +> 결정해야 한다. 그건 측정이 아니라 정책 결정이다. +> +> **재실행 방법**: `npx ts-node --transpile-only -r tsconfig-paths/register scripts/backtest-risk.ts` +> (기간은 이제 실행일까지 자동. 특정 시점 재현은 `BACKTEST_TO=YYYYMMDD`) + > **이 문서 이후에 일어난 일** — 여기 실린 진단을 근거로 점수표를 개정했다. > **결정 과정·최종 점수표·한계는 [`risk-rules-v2.md`](./risk-rules-v2.md) 를 보라.** 이 문서는 그 근거가 된 **측정 기록**이다. > 아래 §3 의 "제안" 은 v2 확정 과정에서 **일부 뒤집혔다**(특히 파고·풍향 제거 → 5점 유지). §6 에 결과를 요약했다. +> ⚠️ **아래 §2 의 "룰" 숫자는 `DEFAULT_RULE_SCORES`(= v1 폴백)로 낸 것이고 운영 점수표가 아니다.** +> 실제 점수는 `RISK_RULE_VERSION` 이 고른 DB 값(v3)에서 온다. 배포본의 성적은 과제 E 표의 +> (b)/(i) 행을 봐야 한다. 헤드라인만 읽으면 v1 성적을 현행으로 오해한다. + **결론부터**: 현행 점수표는 **실제 배포 상태(v1-as-run)에서 고밀도 해파리 출현을 단 한 번도 위험(danger) 단계로 잡아내지 못한다(재현율 0%).** 구현 버그로 두 룰이 죽어 있어 점수가 40점을 넘지 못하기 때문이다. 버그를 고쳐도(v1), 룰 점수(AUC 0.783)는 **"직전 주 NIFS 보고서를 그대로 베끼는" 무지성 베이스라인(AUC 0.823)보다 나쁘다.** 지금의 점수표는 심사에서 방어할 수 없다. - 재현 스크립트: [`Backend/scripts/backtest-risk.ts`](../scripts/backtest-risk.ts) diff --git a/scripts/backtest-risk.ts b/scripts/backtest-risk.ts index 3c684c3..93c9fc2 100644 --- a/scripts/backtest-risk.ts +++ b/scripts/backtest-risk.ts @@ -127,8 +127,23 @@ const SAMPLE_HOUR_KST = 12; const DAY_MS = 86_400_000; const KST_OFFSET_MS = 9 * 3600_000; -const LIST_SDATE = '20240101'; -const LIST_EDATE = '20260714'; +const LIST_SDATE = process.env.BACKTEST_FROM ?? '20240101'; + +/** + * 정답(주간보고) 목록을 어디까지 볼 것인가. + * + * ⚠️ 예전에는 **'20260714' 가 하드코딩**돼 있었다. 그래서 몇 달 뒤에 다시 돌려도 같은 68주를 + * 다시 읽었다 — 새 데이터가 쌓여도 백테스트는 그걸 보지 못했고, **다시 돌린다고 새로 아는 + * 것이 없었다.** + * + * 이게 특히 나쁜 이유 — 지금 배포된 점수표(v3)는 **바로 그 68주에서 후보 10개를 비교해 + * 고른 것**이다. 고른 데이터로 다시 재면 잘 나오는 게 당연하고, 고밀도 주가 26건뿐이라 + * 과적합 위험이 실질적이다. 그걸 확인할 유일한 방법이 **선택에 쓰이지 않은 새 주간**인데, + * 종료일이 고정돼 있으면 그 데이터가 영영 들어오지 않는다. + * + * 기본을 오늘로 둔다. 특정 시점을 재현하려면 BACKTEST_TO 로 고정한다. + */ +const LIST_EDATE = process.env.BACKTEST_TO ?? kstDayKey(new Date()).replace(/-/g, ''); const CACHE_DIR = process.env.BACKTEST_CACHE_DIR ?? path.join(os.tmpdir(), 'jellysafe-backtest'); const OUT_PATH = process.env.BACKTEST_OUT ?? path.join(CACHE_DIR, 'backtest-result.json'); @@ -1429,6 +1444,10 @@ async function main(): Promise { console.log('\n' + '='.repeat(110)); console.log(`【과제 A】 고밀도 출현 탐지 (양성 ${nHigh} / 음성 ${units.length - nHigh})`); + console.log( + ' ⚠️ 이 절의 "룰" 은 DEFAULT_RULE_SCORES(v1 폴백)다 — **운영 점수표가 아니다.** ' + + '배포본(v3)의 성적은 과제 E 의 (b)/(i) 행에서 본다.', + ); console.log('-'.repeat(110)); console.log(metricsLine('룰: 위험(danger) 이상', binary(units.map((u) => LEVEL_RANK[u.level] >= 2), truthHigh))); console.log(metricsLine('룰: 주의(caution) 이상', binary(units.map((u) => LEVEL_RANK[u.level] >= 1), truthHigh))); @@ -1441,6 +1460,10 @@ async function main(): Promise { console.log(metricsLine('B6 직전주 NIFS **고밀도**만', binary(units.map((u) => u.nearbyDensity === 'high'), truthHigh))); console.log(' --- 순위 지표 (AUC: 0.5=동전던지기) ---'); console.log(` 룰 점수 AUC ${fmt(auc(units.map((u) => u.score), truthHigh))}`); + // ⚠️ 위 '룰' 숫자는 DEFAULT_RULE_SCORES(= v1 폴백)로 낸 것이다. **운영 점수표가 아니다** + // (risk-factors.ts 주석 참고 — 실제 점수는 RISK_RULE_VERSION 이 고른 DB 값에서 온다). + // 배포된 v3 의 성적은 과제 E 표의 (b)/(i) 행이다. 이 구분이 없으면 헤드라인만 읽은 사람이 + // v1 성적을 현행으로 오해한다. console.log(` 수온만 AUC ${fmt(auc(units.map((u) => u.waterTemp ?? -99), truthHigh))}`); console.log(` 7일 평균수온만 AUC ${fmt(auc(units.map((u) => u.weekAvgTemp ?? -99), truthHigh))}`); console.log(` 파고만 AUC ${fmt(auc(units.map((u) => u.waveHeight ?? -99), truthHigh))}`); @@ -1854,7 +1877,16 @@ async function main(): Promise { console.log(` ${c.id.padEnd(18)} 2026 AUC ${fmt(e.auc, 3)} 재현율 ${pct(e.d.recall).padStart(6)} 오경보율 ${pct(e.faNone).padStart(6)} danger판정 ${pct(e.dangerRate).padStart(6)}`); } console.log(` ${'(B) 베이스라인'.padEnd(18)} 2026 AUC ${fmt(auc(test.map((u) => u.nearbyAlertCount), tHigh), 3)}`); - console.log(' ⚠️ 표본 20개(고밀도 3개). 신뢰구간이 표 전체를 덮는다 — 순위를 논할 수준이 아니다.'); + // ⚠️ 예전에는 이 문구가 '표본 20개(고밀도 3개)' 로 **하드코딩**돼 있었다. 데이터가 늘어도 + // 경고는 그대로라, 표본이 두 배가 된 뒤에도 "논할 수준이 아니다" 라고 말했다. + // 주의 문구가 실제보다 세면 **맞는 결과까지 버리게 된다.** 실제 수를 센다. + const testHigh = test.filter((u) => u.density === 'high').length; + console.log( + ` ⚠️ 홀드아웃 표본 ${test.length}개(고밀도 ${testHigh}개). ` + + (testHigh < 15 + ? '신뢰구간이 표 전체를 덮는다 — 순위를 논할 수준이 아니고, 방향만 본다.' + : '순위는 여전히 조심해서 읽되, 방향은 볼 만하다.'), + ); } else { console.log(' 홀드아웃에 양성/음성이 모두 있지 않다 — 결론 없음'); }