Skip to content
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ experiments/crosscancer/*/full/*.prehome_bak
# 없으면 index.html 이 CDN 폴백으로 렌더된다. (BIOP02-113, kkkim #11915)
ai_scientist/output_v*/mermaid.min.js

# BIOP02-149 인코더 ablation 로컬 캐시 (TCGA 임상 원본·casemean npz 12.5MB) — 미커밋 유지.
# 무결성은 experiments/kkkim/20260830_encoder_ablation/_cache_manifest.sha256 으로 대조. (kkkim 2026-08-31)
experiments/**/_cache/
# 인프라 실값(공개금지)
INFRA_PRIVATE.md
biop02_infra.local


109 changes: 109 additions & 0 deletions experiments/kkkim/20260830_encoder_ablation/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 인코더 ablation — 파운데이션 모델 선택이 표현형 예측을 얼마나 좌우하는가

- 실행일: 2026-08-30 (v1), 2026-08-31 재실행 (v2)
- 실행자: kkkim
- JIRA: BIOP02-149
- 상태: **v1은 reject(폐기), v2가 정본.** v2는 Critic 재검토 대기.

---

## ⚠️ v1은 폐기한다 (2026-08-31)

v1(`bp02_encoder_ablation.json`, `bp02_encoder_ablation_ci.json`)은 적대적 Critic 리뷰에서
**reject** 판정을 받았다(`critic_report.json`). 사유 네 가지 중 결정적인 것은 분할이다.

- **v1은 잠긴 분할을 쓰지 않았다.** 정본은 `agents/data/manifests/split_policy_v0_folds.json`
(train 707 / val 152 / test 151, `split_hash=5995f29d3978b831`)인데, v1은 서버의
`split_policy_v1.csv`를 써서 test 153을 냈다. 선행 결과와 비교가 불가능하다.
- `metrics.json`의 `split_hash` 칸에 해시가 아니라 파일명을 적어 넣어, 자동 게이트가 이
불일치를 잡지 못했다.
- trivial baseline이 하나도 없었다.
- PAM50이 Normal-like를 포함한 5-class로 돌았다(정책 §92는 4-class).
- 다중비교 보정이 없었다.

**v1의 헤드라인이었던 "염색 정규화가 ER에서 이득을 준다"는 v2에서 재현되지 않는다.**
분할을 바로잡자 uni_v1 0.809 → 0.774, uni_stainnorm_v1 0.892 → 0.796으로 내려갔고
차이도 유의하지 않다. v1 수치를 어디에도 인용하지 않는다.

---

## v2: 잠긴 분할로 재실행

### 방법

- **분할**: `split_policy_v0_folds.json` 정본 그대로. hash `5995f29d3978b831`.
train 707 / val 152 / test 151 (라벨 결측으로 과제별 유효 수는 아래 표).
- **인코더**: uni_v1(1024), uni_stainnorm_v1(1024), conch_v1(512), exaone_v2(768).
case 단위 평균 임베딩(`_cache/*_casemean.npz`).
- **라벨**: ER/PR/HER2는 biotab IHC(positive/negative만), PAM50은 **4-class**
(LumA, LumB, HER2, Basal. Normal-like 제외 — 정책 §92).
- **프로브**: StandardScaler → LogisticRegression(class_weight=balanced, max_iter=5000).
- **기준선**: random_uniform, prevalence(stratified), majority, embedding_mean_1d.
- **불확실성**: test 환자 2,000회 부트스트랩. 쌍대 차이는 같은 재표집 인덱스로 계산(paired).
- **다중비교**: 24개 비교에 Benjamini-Hochberg 보정.

재현: `python rerun_locked.py` (레포 안에서 실행, 서버 불필요)

### 결과 (test AUROC)

| 인코더 | dim | ER (n=151) | PR (n=149) | HER2 (n=90) | PAM50 4c (n=131) |
|---|---|---|---|---|---|
| uni_v1 | 1024 | 0.774 | 0.732 | 0.552 | 0.719 |
| uni_stainnorm_v1 | 1024 | 0.796 | 0.707 | 0.498 | 0.737 |
| conch_v1 | 512 | 0.746 | 0.745 | 0.564 | **0.783** |
| exaone_v2 | 768 | **0.828** | **0.761** | **0.562** | 0.747 |
| *random/majority* | — | *0.500* | *0.500* | *0.500* | *0.500* |
| *embedding_mean_1d* | 1 | *0.406* | *0.435* | *0.305* | *0.435* |

### 쌍대 비교: **24건 중 BH 보정 후 유의 0건**

가장 작은 q값 셋은 이렇다.

| 비교 | Δ | 95% CI | p | q(BH) |
|---|---|---|---|---|
| PAM50: conch_v1 > uni_v1 | +0.063 | [0.017, 0.113] | 0.004 | 0.096 |
| ER: exaone_v2 > conch_v1 | +0.082 | [0.009, 0.164] | 0.026 | 0.312 |
| ER: exaone_v2 > uni_v1 | +0.054 | [−0.026, 0.141] | 0.198 | 0.679 |

## 읽는 법

**1. 인코더 선택은 이 표본에서 결론을 바꾸지 않는다.** 보정 후 유의한 차이가 없다.
Paper C에서 UNI를 주모델로 쓰는 선택은 성능 우위가 아니라 다른 근거(가용성, 선행 사용,
문서화)로 정당화해야 하며, 결론이 인코더에 의존하지 않는다고 말할 수 있다.
이것이 리뷰어의 "왜 이 인코더인가"에 대한 정직한 답이다.

**2. 염색 정규화의 이득은 확인되지 않았다.** ER에서 +0.022로 방향은 같으나 CI가 0을
포함한다. 원고 methods의 "염색 정규화 미적용" 서술과 충돌하지 않는다.

**3. HER2는 네 인코더 모두 우연 수준이다**(0.498~0.564, n=90). 형태에서 HER2를 읽어내지
못한다는 뜻이고, Paper C의 음성 앵커 서사와 방향이 같다.

**4. 임베딩이 밝기·크기 대리 변수가 아니다.** embedding_mean_1d 기준선이 모두 0.5 미만이라,
표현이 단순 강도 정보로 환원되지 않는다.

## 한계

- 평균 풀링에 선형 프로브까지만 본 값이다. attention MIL의 순위와 다를 수 있다.
- **exaone_v2는 인코더만 다른 것이 아니다.** 내부 타일링과 Macenko 정규화를 거친 별도
파이프라인이라 차이를 인코더 단독으로 귀속하면 안 된다.
- `uni_stainnorm_v1`의 추출 스크립트가 레포에 없어 정규화 방법과 기준 이미지, 타일 좌표
동일성을 확인하지 못했다.
- PAM50 라벨 소스(`tcga_brca_pam50_computed.csv`)가 무엇으로 계산되었는지 확인하지 못했다.
정책 §220은 cBioPortal PAM50을 1순위로 지정하므로 소스 대조가 필요하다.
- 단일 test fold다. val fold나 교차검증 반복은 하지 않았다.
- `predictions.npy`와 `config.yaml`을 남기지 않았다(리포 아티팩트 계약 미충족).

## 다음

1. `uni_stainnorm_v1` 추출 스크립트 확보 후 정규화 조건 확인
2. PAM50 라벨 소스를 cBioPortal 정본으로 교체해 재계산
3. attention MIL로 같은 비교 반복
4. Critic 재검토(담당: jamie 또는 braveji) → 통과 시 Paper C ablation 절 반영

## 파일

- `rerun_locked.py`, `results_locked_v2.json` — **v2 정본**
- `_cache/` — case 평균 임베딩과 라벨(서버 반납 대비 회수본). **미커밋·로컬 보관**(TCGA 임상 원본 포함, 저장소 공개). 무결성은 `_cache_manifest.sha256` 으로 대조한다.
- `_cache_manifest.sha256` — `_cache/` 6파일 sha256
- `critic_report.json` — v1에 대한 적대적 리뷰(reject)
- `bp02_*.py`, `bp02_*.json`, `metrics.json`, `gate_report.json` — v1 기록(폐기, 이력 보존용)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
465a3d7be11c2f887c853d622037fadb15d45bea40aee65bc685fffbe20cfda7 clinical_patient_brca.txt
71086d9d2ddfc5a0054e0e3c3924972a9ebaab03daa5bffcbc7e681dbeb25541 conch_v1_casemean.npz
0cf50f0e9b9dc02cdfff1c230b8e30751fa81307ca4c03e7c8b7a2e73d023aa5 exaone_v2_casemean.npz
5d6c21ee6169b5ac756f351d84f67f1594015a3f4367c3b98b59a1bc3dcb6a9c tcga_brca_pam50_computed.csv
1afd6e001eefd37f1b78e58d2f1d247880f648de348d05a9030204e33d2342f8 uni_stainnorm_v1_casemean.npz
4ec8672758ac24d153eff41c4bfd004ffa5185b2893ad9ebded6629e4ccfd56a uni_v1_casemean.npz
126 changes: 126 additions & 0 deletions experiments/kkkim/20260830_encoder_ablation/bp02_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""BP02 인코더 ablation에 부트스트랩 신뢰구간과 쌍대 비교를 붙인다.

단일 test 분할의 점추정만으로는 인코더 간 차이를 주장할 수 없다.
같은 test 환자를 재표집해 각 인코더의 CI를 구하고, 인코더 쌍의 차이도
같은 재표집 인덱스로 계산해(paired) 차이의 CI를 낸다.
"""
import argparse, json, os
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.preprocessing import StandardScaler

from bp02_encoder_ablation import ENCODERS, load_labels

TASKS = [("er", False), ("pr", False), ("her2", False), ("pam50_subtype", True)]


def load_cached(cache_dir, enc):
z = np.load(os.path.join(cache_dir, f"{enc}_casemean.npz"), allow_pickle=True)
return z["cases"].astype(str), z["Z"]


def score(y, P, multi):
if multi:
try:
return roc_auc_score(y, P, multi_class="ovr", average="macro")
except ValueError:
return np.nan
return roc_auc_score(y, P[:, 1])


def main():
p = argparse.ArgumentParser()
p.add_argument("--cache", default=os.path.expanduser("~/work/fmpretrain/cache_bp02"))
p.add_argument("--clin-dir", default=os.path.expanduser("~/data/clinical"))
p.add_argument("--split", default=os.path.expanduser("~/data/split_policy_v1.csv"))
p.add_argument("--out", default=os.path.expanduser(
"~/work/fmpretrain/bp02_encoder_ablation_ci.json"))
p.add_argument("--n-boot", type=int, default=1000)
args = p.parse_args()

split = pd.read_csv(args.split).set_index("case_id")["split"]
labels = load_labels(args.clin_dir)
rng = np.random.default_rng(0)
out = {"n_boot": args.n_boot, "eval_split": "test", "tasks": {}}

for tgt, multi in TASKS:
probs, ytest, common = {}, None, None
for enc in ENCODERS:
cases, Z = load_cached(args.cache, enc)
idx = pd.Index(cases)
y = labels.reindex(idx)[tgt]
sp_ = split.reindex(idx)
ok = y.notna().to_numpy() & sp_.notna().to_numpy()
if multi:
vc = y[ok].value_counts()
ok &= y.isin(vc[vc >= 20].index).to_numpy()
tr = ok & (sp_ == "train").to_numpy()
te = ok & (sp_ == "test").to_numpy()
yv = y.to_numpy()
ytr = yv[tr].astype(str) if multi else yv[tr].astype(int)
yte = yv[te].astype(str) if multi else yv[te].astype(int)

sc = StandardScaler().fit(Z[tr])
clf = LogisticRegression(max_iter=5000, class_weight="balanced")
clf.fit(sc.transform(Z[tr]), ytr)
P = clf.predict_proba(sc.transform(Z[te]))
# 인코더마다 test 환자 집합이 같은지 확인해 paired 비교의 전제를 지킨다
te_cases = cases[te]
if common is None:
common, ytest = te_cases, yte
elif not np.array_equal(common, te_cases):
raise RuntimeError(f"{enc}: test 환자 집합 불일치")
probs[enc] = P

n = len(ytest)
boot_idx = [rng.integers(0, n, n) for _ in range(args.n_boot)]
# 부트스트랩 표본에 클래스가 하나만 걸리면 AUROC가 정의되지 않아 건너뛴다
per_enc, samples = {}, {}
for enc, P in probs.items():
vals = []
for b in boot_idx:
yb = ytest[b]
if len(np.unique(yb)) < 2:
vals.append(np.nan)
continue
vals.append(score(yb, P[b], multi))
v = np.array(vals, dtype=float)
samples[enc] = v
ok = ~np.isnan(v)
per_enc[enc] = {
"auroc": float(score(ytest, P, multi)),
"ci95": [float(np.percentile(v[ok], 2.5)),
float(np.percentile(v[ok], 97.5))],
"n_test": int(n), "n_valid_boot": int(ok.sum()),
}

pairs = {}
encs = list(probs)
for i in range(len(encs)):
for j in range(i + 1, len(encs)):
a, b_ = encs[i], encs[j]
d = samples[a] - samples[b_]
d = d[~np.isnan(d)]
lo, hi = np.percentile(d, [2.5, 97.5])
pairs[f"{a} - {b_}"] = {
"delta": float(per_enc[a]["auroc"] - per_enc[b_]["auroc"]),
"ci95": [float(lo), float(hi)],
"excludes_zero": bool(lo > 0 or hi < 0),
}

out["tasks"][tgt] = {"per_encoder": per_enc, "pairwise": pairs}
print(f"\n[{tgt}] n_test={n}")
for enc, r in per_enc.items():
print(f" {enc:20s} AUROC={r['auroc']:.4f} "
f"CI95=[{r['ci95'][0]:.4f}, {r['ci95'][1]:.4f}]")
sig = [k for k, v in pairs.items() if v["excludes_zero"]]
print(f" 0을 포함하지 않는 쌍: {sig if sig else '없음'}")

json.dump(out, open(args.out, "w"), indent=2)
print(f"\n[done] {args.out}")


if __name__ == "__main__":
main()
136 changes: 136 additions & 0 deletions experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
{
"eval_split": "test",
"split_policy": "split_policy_v1.csv",
"aggregation": "tile mean-pool \u2192 slide \u2192 case mean",
"probe": "LogisticRegression(class_weight=balanced), StandardScaler",
"results": {
"uni_v1": {
"dim": 1024,
"n_cases": 1010,
"er": {
"n_train": 706,
"n_test": 153,
"acc": 0.7843137254901961,
"macro_f1": 0.69739286784537,
"auroc": 0.8092009685230025
},
"pr": {
"n_train": 703,
"n_test": 153,
"acc": 0.7516339869281046,
"macro_f1": 0.7177669902912621,
"auroc": 0.7870784234051198
},
"her2": {
"n_train": 491,
"n_test": 102,
"acc": 0.7156862745098039,
"macro_f1": 0.6209150326797386,
"auroc": 0.6851851851851853
},
"pam50_subtype": {
"n_train": 705,
"n_test": 153,
"acc": 0.3790849673202614,
"macro_f1": 0.3514165144949136,
"auroc_ovr": 0.7097197931216526
}
},
"uni_stainnorm_v1": {
"dim": 1024,
"n_cases": 1010,
"er": {
"n_train": 706,
"n_test": 153,
"acc": 0.8431372549019608,
"macro_f1": 0.7861136999068034,
"auroc": 0.8917675544794189
},
"pr": {
"n_train": 703,
"n_test": 153,
"acc": 0.7973856209150327,
"macro_f1": 0.7605633802816901,
"auroc": 0.78992279561154
},
"her2": {
"n_train": 491,
"n_test": 102,
"acc": 0.7647058823529411,
"macro_f1": 0.6522727272727272,
"auroc": 0.6917989417989419
},
"pam50_subtype": {
"n_train": 705,
"n_test": 153,
"acc": 0.45098039215686275,
"macro_f1": 0.424829131652661,
"auroc_ovr": 0.7211632544781148
}
},
"conch_v1": {
"dim": 512,
"n_cases": 1010,
"er": {
"n_train": 706,
"n_test": 153,
"acc": 0.8104575163398693,
"macro_f1": 0.7483695343957353,
"auroc": 0.8743341404358353
},
"pr": {
"n_train": 703,
"n_test": 153,
"acc": 0.7450980392156863,
"macro_f1": 0.7088274044795784,
"auroc": 0.798049573344169
},
"her2": {
"n_train": 491,
"n_test": 102,
"acc": 0.6176470588235294,
"macro_f1": 0.5252416756176155,
"auroc": 0.6064814814814815
},
"pam50_subtype": {
"n_train": 705,
"n_test": 153,
"acc": 0.4117647058823529,
"macro_f1": 0.39404748283752855,
"auroc_ovr": 0.721879198634033
}
},
"exaone_v2": {
"dim": 768,
"n_cases": 1010,
"er": {
"n_train": 706,
"n_test": 153,
"acc": 0.8169934640522876,
"macro_f1": 0.7549199084668192,
"auroc": 0.8278450363196126
},
"pr": {
"n_train": 703,
"n_test": 153,
"acc": 0.7320261437908496,
"macro_f1": 0.697029702970297,
"auroc": 0.7295814709467695
},
"her2": {
"n_train": 491,
"n_test": 102,
"acc": 0.7156862745098039,
"macro_f1": 0.6209150326797386,
"auroc": 0.7486772486772487
},
"pam50_subtype": {
"n_train": 705,
"n_test": 153,
"acc": 0.477124183006536,
"macro_f1": 0.44072756974585403,
"auroc_ovr": 0.7463098673321611
}
}
}
}
Loading
Loading