Skip to content
This repository was archived by the owner on Sep 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/backtest.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
38 changes: 35 additions & 3 deletions scripts/backtest-risk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -1429,6 +1444,10 @@ async function main(): Promise<void> {

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)));
Expand All @@ -1441,6 +1460,10 @@ async function main(): Promise<void> {
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))}`);
Expand Down Expand Up @@ -1854,7 +1877,16 @@ async function main(): Promise<void> {
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(' 홀드아웃에 양성/음성이 모두 있지 않다 — 결론 없음');
}
Expand Down
Loading