From 9b64ba5259be456db07087e11077c4c160c02510 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 22 Sep 2026 18:57:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=88=98=EC=A0=95:=20=EB=B0=B1=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=83=88=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EB=B3=B4=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ `LIST_EDATE` 가 **'20260714' 로 하드코딩**돼 있었다. 그래서 두 달 뒤에 다시 돌려도 같은 68주를 다시 읽었다 — **다시 돌린다고 새로 아는 것이 없었다.** 이게 특히 나쁜 자리인 이유 — 배포된 점수표(v3)는 **바로 그 68주에서 후보 10개를 비교해 고른 것**이다. 고른 데이터로 다시 재면 잘 나오는 게 당연하고, 고밀도 주가 26건뿐이라 과적합 위험이 실질적이었다. 그걸 확인할 유일한 방법이 **선택에 쓰이지 않은 새 주간**인데, 종료일이 고정돼 있으면 그 데이터가 영영 들어오지 않는다. 기본을 실행일로 두고 BACKTEST_FROM / BACKTEST_TO 로 덮을 수 있게 했다. 특정 시점을 재현해야 할 때는 BACKTEST_TO 로 고정한다. 고친 뒤 표본이 136 → 156 단위(고밀도 26 → 34)로 늘었다. --- scripts/backtest-risk.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/backtest-risk.ts b/scripts/backtest-risk.ts index 3c684c3..ea11e3f 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'); From f0d9ae8809c33e962f16bb67b3070deee7f6cca7 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 22 Sep 2026 18:57:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EC=88=98=EC=A0=95:=20=EB=B0=B1=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B3=A0=EC=84=9C=EA=B0=80=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EB=B3=B8=EC=9D=B4=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=EC=A0=90=EC=88=98=ED=91=9C=EB=A5=BC=20=ED=97=A4=EB=93=9C?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=82=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 과제 A·B 의 "룰" 숫자는 `DEFAULT_RULE_SCORES` 로 낸 것인데, 그건 **v1 폴백**이다 (risk-factors.ts 주석: "이건 DB 에 룰이 없을 때의 안전망이지 운영 점수표가 아니다"). 실제 운영 점수는 RISK_RULE_VERSION 이 고른 DB 값(v3)에서 온다. 그래서 헤드라인만 읽으면 **AUC 0.742 를 현행 성적으로 오해한다.** 배포본(v3)은 같은 실행에서 AUC 0.871 이고, 그 숫자는 과제 E 표 안에만 있었다. ⚠️ 홀드아웃 경고 문구도 '표본 20개(고밀도 3개)' 로 하드코딩돼 있었다. 데이터가 늘어 실제로는 40개(고밀도 11개)가 됐는데도 "순위를 논할 수준이 아니다" 라고 말했다. **주의 문구가 실제보다 세면 맞는 결과까지 버리게 된다.** 실제 수를 세어 찍고, 고밀도가 15건 미만일 때만 강한 경고를 낸다. --- scripts/backtest-risk.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/backtest-risk.ts b/scripts/backtest-risk.ts index ea11e3f..93c9fc2 100644 --- a/scripts/backtest-risk.ts +++ b/scripts/backtest-risk.ts @@ -1444,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))); @@ -1456,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))}`); @@ -1869,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(' 홀드아웃에 양성/음성이 모두 있지 않다 — 결론 없음'); } From f35492b26d7f4be79645771a47ad6fe214b86679 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 22 Sep 2026 18:57:26 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EB=AC=B8=EC=84=9C:=20=ED=91=9C=EB=B3=B8=20?= =?UTF-8?q?=EC=99=B8=20=EC=9E=AC=EC=8B=A4=ED=96=89=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=E2=80=94=20v3=20=EB=8A=94=20=EC=82=B4=EC=95=84=EB=82=A8?= =?UTF-8?q?=EC=95=98=EA=B3=A0,=20=EC=9E=AC=ED=98=84=EC=9C=A8=EC=9D=80=20?= =?UTF-8?q?=EB=96=A8=EC=96=B4=EC=A1=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선택 당시(136) 표본외 포함(156) 2026 홀드아웃(40) v3 AUC 0.886 0.871 0.842 베이스라인 AUC 0.823 0.812 0.809 v3 재현율 73.1% 64.7% 63.6% **① 순위 능력은 유지됐다.** 베이스라인과의 격차가 +0.063 → +0.059 로 거의 그대로이고, 홀드아웃에서도 앞선다. 과적합으로 무너지지 않았다. **② 재현율은 떨어졌다.** 73.1% → 64.7%. 같은 기간 베이스라인은 69.2% → 73.5% 로 올랐다. 즉 지금은 **"지난주 NIFS 보고서를 그대로 베끼는 것" 이 엔진보다 더 많이 잡는다.** 안전 서비스에서 미경보는 가장 피해야 할 결과이므로 가볍게 볼 수 없다. ⚠️ 다만 34건 중 22 vs 25, **3주 차이**다. 짝지은 부트스트랩에서 v3 와 구별되는 후보가 하나도 없었다(ΔAUC CI 가 전부 0 을 포함). **지금 데이터로 점수표를 다시 고르면 그건 또 한 번의 선택-위-검증이다.** 이번에는 고치지 않는다 — 그게 이번 실행의 결론이다. 다음 재실행에서 재현율 격차가 유지되면, 그때는 (b3) 고40/저10 을 놓고 **미경보와 과경보 중 무엇을 감수할지** 정해야 한다. 그건 측정이 아니라 정책 결정이다. --- docs/backtest.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) 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)