From ccf64050c51004f1dc9f52b9eddffbf23fc8d2e8 Mon Sep 17 00:00:00 2001 From: kakyungkim Date: Sun, 30 Aug 2026 20:39:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?BIOP02-149=20experiments:=20=EC=9D=B8?= =?UTF-8?q?=EC=BD=94=EB=8D=94=204=EC=A2=85=20ablation=20(ER/PR/HER2/PAM50,?= =?UTF-8?q?=20=EB=B6=80=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20CI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BIOP02-48(UNI vs CONCH, ER/PR, 교차검증)의 확장. 보유 인코더 4종을 split_policy_v1의 고정 test 분할로 PAM50까지 포함해 한 프로토콜에 놓고 비교. - 인코더: uni_v1, uni_stainnorm_v1, conch_v1, exaone_v2 - 집계: 타일 평균 → 슬라이드 → case 평균, 프로브는 LogisticRegression(balanced) - 불확실성: test 환자 1,000회 부트스트랩, 인코더 쌍 차이는 paired 재표집 - 지표: AUROC와 함께 AUPRC·balanced accuracy (HER2 등 불균형 대응) 결과에서 차이의 95% 구간이 0을 포함하지 않는 쌍은 둘뿐이다. ER에서 uni_stainnorm_v1 > uni_v1(0.892 vs 0.809), HER2에서 exaone_v2 > conch_v1. PR과 PAM50은 네 인코더가 모두 겹쳐 이 표본 크기에서는 판정할 수 없다. auto_review_gate.py 하드룰 3종 통과, claim_level=hypothesis_only. critic_status: pending — 적대적 AI 리뷰 대기. 원고·발표 반영은 게이트 통과 후. 한계(평균 풀링+선형 프로브, 단일 분할, 다중비교 미보정, exaone만 사전계산 patch_mean)는 NOTES.md에 기재. --- .../kkkim/20260830_encoder_ablation/NOTES.md | 67 ++++ .../bp02_bootstrap.py | 126 ++++++ .../bp02_encoder_ablation.json | 136 +++++++ .../bp02_encoder_ablation.py | 150 +++++++ .../bp02_encoder_ablation_ci.json | 366 ++++++++++++++++++ .../20260830_encoder_ablation/bp02_metrics.py | 85 ++++ .../bp02_metrics_full.json | 162 ++++++++ .../gate_report.json | 31 ++ .../20260830_encoder_ablation/metrics.json | 179 +++++++++ 9 files changed, 1302 insertions(+) create mode 100644 experiments/kkkim/20260830_encoder_ablation/NOTES.md create mode 100644 experiments/kkkim/20260830_encoder_ablation/bp02_bootstrap.py create mode 100644 experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.json create mode 100644 experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.py create mode 100644 experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation_ci.json create mode 100644 experiments/kkkim/20260830_encoder_ablation/bp02_metrics.py create mode 100644 experiments/kkkim/20260830_encoder_ablation/bp02_metrics_full.json create mode 100644 experiments/kkkim/20260830_encoder_ablation/gate_report.json create mode 100644 experiments/kkkim/20260830_encoder_ablation/metrics.json diff --git a/experiments/kkkim/20260830_encoder_ablation/NOTES.md b/experiments/kkkim/20260830_encoder_ablation/NOTES.md new file mode 100644 index 0000000..7124493 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/NOTES.md @@ -0,0 +1,67 @@ +# 인코더 ablation — 파운데이션 모델 선택과 염색 정규화가 표현형 예측을 얼마나 좌우하는가 + +- 실행일: 2026-08-30 +- 실행자: kkkim +- 상태: **critic_status: pending** — Critic 검토 전이며 공유·발표·원고 반영 전 게이트를 거쳐야 한다. +- JIRA: 미배정 (기존 BIOP02-48 `20260701_uni_vs_conch`의 확장에 해당) + +## 왜 했나 + +기존 BIOP02-48은 UNI와 CONCH를 ER·PR에서 `StratifiedGroupKFold(5)`로 비교한 sanity 수준이었다. +Paper C 리뷰에서 "왜 이 인코더인가", "염색 정규화는 왜 하는가"가 나올 것이 확실하므로, +보유한 인코더 4종을 **프로젝트 분할 정책의 고정 test 분할**로, **PAM50까지 포함해** 한 프로토콜에 +놓고 비교했다. + +## 방법 + +- 인코더: `uni_v1`(1024), `uni_stainnorm_v1`(1024), `conch_v1`(512), `exaone_v2`(768) + - 경로: `~/data/embeddings/biop02/tcga//` + - exaone_v2는 npz의 `patch_mean`을 사용(사전 계산). 나머지는 타일 임베딩 평균. +- 집계: 타일 평균 → 슬라이드 → case 평균 (다중 슬라이드 환자는 슬라이드 평균의 평균) +- 라벨: `clinical_patient_brca.txt`의 `er/pr/her2_status_by_ihc`(positive/negative만), + `tcga_brca_pam50_computed.csv`의 `pam50_subtype`(test 20건 미만 클래스 제외) +- 분할: `split_policy_v1.csv` (case 단위, train 706 / val 151 / test 153). 학습 train, 보고 test. +- 프로브: `StandardScaler` → `LogisticRegression(class_weight="balanced", max_iter=5000)` +- 불확실성: test 환자를 1,000회 재표집한 부트스트랩. 인코더 쌍 차이는 **같은 재표집 인덱스**로 + 계산(paired)해 차이의 95% 구간을 냈다. 인코더별 test 환자 집합이 동일한지 코드에서 검증한다. + +재현: `bp02_encoder_ablation.py` → `bp02_bootstrap.py` (환경 `spatialpatho`) + +## 결과 (test 분할, AUROC. 이진은 양성확률, PAM50은 OvR macro) + +| 인코더 | dim | ER | PR | HER2 | PAM50 | +|---|---|---|---|---|---| +| uni_v1 | 1024 | 0.809 [0.725, 0.882] | 0.787 [0.710, 0.858] | 0.685 [0.529, 0.824] | 0.710 [0.660, 0.753] | +| uni_stainnorm_v1 | 1024 | **0.892** [0.825, 0.946] | 0.790 [0.709, 0.865] | 0.692 [0.546, 0.824] | 0.721 [0.667, 0.767] | +| conch_v1 | 512 | 0.874 [0.793, 0.942] | **0.798** [0.716, 0.876] | 0.606 [0.446, 0.750] | 0.722 [0.665, 0.770] | +| exaone_v2 | 768 | 0.828 [0.732, 0.909] | 0.730 [0.629, 0.826] | **0.749** [0.588, 0.888] | **0.746** [0.691, 0.800] | + +n_test = 153 (HER2는 라벨 결측으로 102) + +### 차이의 95% 구간이 0을 포함하지 않는 쌍 + +- **ER**: `uni_stainnorm_v1 > uni_v1`, `uni_stainnorm_v1 > exaone_v2` +- **HER2**: `exaone_v2 > conch_v1` +- **PR·PAM50**: 없음 + +## 읽는 법 + +1. **염색 정규화의 이득이 ER에서 실재한다.** 같은 UNI 인코더인데 정규화판이 0.083 높고 + 차이의 구간이 0을 넘지 않는다. 전처리 선택을 근거로 방어할 수 있다. +2. **인코더 선택은 대체로 이 표본에서 판정 불가다.** PR과 PAM50은 네 인코더가 모두 겹친다. + "가장 좋은 인코더"를 주장하지 말고, 표본 크기의 한계를 명시하는 편이 정확하다. +3. HER2는 n=102에 구간이 넓어(±0.15) 어떤 결론도 탐색적이다. + +## 한계 (원고에 반드시 함께 적을 것) + +- 평균 풀링 + 선형 프로브까지만 본 값이다. attention MIL의 순위와 다를 수 있다. +- exaone_v2만 사전 계산된 `patch_mean`을 써서 집계 경로가 완전히 동일하지 않다. +- 단일 test 분할이다. 분할을 바꾼 반복은 하지 않았다. +- 부트스트랩은 test 환자 재표집만 반영하며, 학습 변동은 포함하지 않는다. +- **Critic 미통과.** 이 수치는 팀 내부 검토 전이므로 외부(발표·면접·공개 저장소) 인용 금지. + +## 다음 + +- attention MIL로 같은 비교를 반복해 순위가 유지되는지 확인 +- val 분할에서도 같은 방향인지 대조(현재 test만 보고) +- Critic 게이트 → JIRA 이슈 배정 → Paper C ablation 절 반영 diff --git a/experiments/kkkim/20260830_encoder_ablation/bp02_bootstrap.py b/experiments/kkkim/20260830_encoder_ablation/bp02_bootstrap.py new file mode 100644 index 0000000..c32a934 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/bp02_bootstrap.py @@ -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() diff --git a/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.json b/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.json new file mode 100644 index 0000000..09a9b8a --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.json @@ -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 + } + } + } +} \ No newline at end of file diff --git a/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.py b/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.py new file mode 100644 index 0000000..2b289d1 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation.py @@ -0,0 +1,150 @@ +"""BP02 인코더 ablation: 파운데이션 모델 선택이 표현형 예측을 얼마나 좌우하는가. + +기존 BIOP02-48은 UNI vs CONCH를 ER/PR에서 교차검증으로 비교했다(sanity 수준). +여기서는 인코더 4종을 프로젝트의 분할 정책(case 단위 train/val/test)으로 +ER/PR/HER2와 PAM50까지 한 프로토콜에 놓고 비교한다. + +인코더: uni_v1, uni_stainnorm_v1(염색 정규화), conch_v1, exaone_v2 +집계: 타일 임베딩 평균 풀링(슬라이드) → 환자 단위 평균 +분할: split_policy_v1.csv (case_id 기준). 학습은 train, 보고는 test. +""" +import argparse, glob, json, os, re, time +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import roc_auc_score, accuracy_score, f1_score +from sklearn.preprocessing import StandardScaler + +ENCODERS = { + "uni_v1": ("*_uni_embeddings.npy", "npy"), + "uni_stainnorm_v1": ("*_uni_stainnorm_embeddings.npy", "npy"), + "conch_v1": ("*_conch_embeddings.npy", "npy"), + "exaone_v2": ("*_exaone.npz", "npz_patch_mean"), +} +CASE_RE = re.compile(r"(TCGA-[0-9A-Z]{2}-[0-9A-Z]{4})") + + +def load_encoder(root, enc, cache_dir): + """슬라이드별 평균 풀링 → case 단위 평균. 결과를 캐시한다.""" + cache = os.path.join(cache_dir, f"{enc}_casemean.npz") + if os.path.exists(cache): + z = np.load(cache, allow_pickle=True) + return z["cases"].astype(str), z["Z"] + + pattern, kind = ENCODERS[enc] + files = sorted(glob.glob(os.path.join(root, enc, pattern))) + per_case, t0 = {}, time.time() + for i, f in enumerate(files): + m = CASE_RE.search(os.path.basename(f)) + if not m: + continue + if kind == "npy": + v = np.load(f, mmap_mode="r") + v = np.asarray(v, dtype=np.float32).mean(0) + else: + v = np.load(f)["patch_mean"].astype(np.float32) + per_case.setdefault(m.group(1), []).append(v) + if (i + 1) % 200 == 0: + print(f" {enc}: {i+1}/{len(files)} ({time.time()-t0:.0f}s)", flush=True) + cases = np.array(sorted(per_case)) + Z = np.stack([np.mean(per_case[c], axis=0) for c in cases]) + os.makedirs(cache_dir, exist_ok=True) + np.savez_compressed(cache, cases=cases, Z=Z) + print(f" [{enc}] cases={len(cases)} dim={Z.shape[1]} " + f"({time.time()-t0:.0f}s)", flush=True) + return cases, Z + + +def load_labels(clin_dir): + """ER/PR/HER2(IHC)와 PAM50 서브타입을 case 단위로 모은다.""" + clin = pd.read_csv(os.path.join(clin_dir, "clinical_patient_brca.txt"), + sep="\t", low_memory=False) + clin = clin[clin["bcr_patient_barcode"].astype(str).str.startswith("TCGA")] + lab = pd.DataFrame({"case_id": clin["bcr_patient_barcode"].astype(str)}) + for tgt, col in [("er", "er_status_by_ihc"), ("pr", "pr_status_by_ihc"), + ("her2", "her2_status_by_ihc")]: + s = clin[col].astype(str).str.strip().str.lower() + lab[tgt] = np.where(s.eq("positive"), 1, + np.where(s.eq("negative"), 0, np.nan)) + pam = pd.read_csv(os.path.join(clin_dir, "tcga_brca_pam50_computed.csv")) + pam["case_id"] = pam["case_id"].astype(str) + lab = lab.merge(pam[["case_id", "pam50_subtype"]], on="case_id", how="left") + return lab.drop_duplicates("case_id").set_index("case_id") + + +def evaluate(Ztr, ytr, Zte, yte, multiclass): + sc = StandardScaler().fit(Ztr) + clf = LogisticRegression(max_iter=5000, class_weight="balanced", n_jobs=-1) + clf.fit(sc.transform(Ztr), ytr) + P = clf.predict_proba(sc.transform(Zte)) + pred = clf.predict(sc.transform(Zte)) + out = {"n_train": int(len(ytr)), "n_test": int(len(yte)), + "acc": float(accuracy_score(yte, pred)), + "macro_f1": float(f1_score(yte, pred, average="macro"))} + if multiclass: + try: + out["auroc_ovr"] = float(roc_auc_score(yte, P, multi_class="ovr", + average="macro")) + except ValueError: + out["auroc_ovr"] = None + else: + out["auroc"] = float(roc_auc_score(yte, P[:, 1])) + return out + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--emb-root", default=os.path.expanduser( + "~/data/embeddings/biop02/tcga")) + 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("--cache", default=os.path.expanduser("~/work/fmpretrain/cache_bp02")) + p.add_argument("--out", default=os.path.expanduser( + "~/work/fmpretrain/bp02_encoder_ablation.json")) + p.add_argument("--eval-split", default="test", choices=["val", "test"]) + args = p.parse_args() + + split = pd.read_csv(args.split).set_index("case_id")["split"] + labels = load_labels(args.clin_dir) + print(f"[labels] cases={len(labels)} " + f"er={labels.er.notna().sum()} pr={labels.pr.notna().sum()} " + f"her2={labels.her2.notna().sum()} " + f"pam50={labels.pam50_subtype.notna().sum()}", flush=True) + + tasks = [("er", False), ("pr", False), ("her2", False), ("pam50_subtype", True)] + results = {} + for enc in ENCODERS: + cases, Z = load_encoder(args.emb_root, enc, args.cache) + idx = pd.Index(cases) + results[enc] = {"dim": int(Z.shape[1]), "n_cases": int(len(cases))} + for tgt, multi in tasks: + 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_ == args.eval_split).to_numpy() + if te.sum() < 20 or len(np.unique(y[tr])) < 2: + results[enc][tgt] = {"skipped": "insufficient data"} + continue + yv = y.to_numpy() + r = evaluate(Z[tr], yv[tr].astype(str) if multi else yv[tr].astype(int), + Z[te], yv[te].astype(str) if multi else yv[te].astype(int), + multi) + results[enc][tgt] = r + key = "auroc_ovr" if multi else "auroc" + print(f" [{enc:18s}] {tgt:14s} {key}={r.get(key)} " + f"acc={r['acc']:.4f} n_test={r['n_test']}", flush=True) + + meta = {"eval_split": args.eval_split, "split_policy": os.path.basename(args.split), + "aggregation": "tile mean-pool → slide → case mean", + "probe": "LogisticRegression(class_weight=balanced), StandardScaler", + "results": results} + json.dump(meta, open(args.out, "w"), indent=2) + print(f"[done] {args.out}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation_ci.json b/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation_ci.json new file mode 100644 index 0000000..eecf2b3 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/bp02_encoder_ablation_ci.json @@ -0,0 +1,366 @@ +{ + "n_boot": 1000, + "eval_split": "test", + "tasks": { + "er": { + "per_encoder": { + "uni_v1": { + "auroc": 0.8092009685230025, + "ci95": [ + 0.7249893445032334, + 0.8818460132935032 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "uni_stainnorm_v1": { + "auroc": 0.8917675544794189, + "ci95": [ + 0.8250022580679439, + 0.9458745541422707 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "conch_v1": { + "auroc": 0.8743341404358353, + "ci95": [ + 0.792860215141759, + 0.9422812961256897 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "exaone_v2": { + "auroc": 0.8278450363196126, + "ci95": [ + 0.7314772727272728, + 0.9087721579529489 + ], + "n_test": 153, + "n_valid_boot": 1000 + } + }, + "pairwise": { + "uni_v1 - uni_stainnorm_v1": { + "delta": -0.08256658595641642, + "ci95": [ + -0.15474117754438121, + -0.014889887244538375 + ], + "excludes_zero": true + }, + "uni_v1 - conch_v1": { + "delta": -0.06513317191283285, + "ci95": [ + -0.14829336606069343, + 0.013140868140868105 + ], + "excludes_zero": false + }, + "uni_v1 - exaone_v2": { + "delta": -0.018644067796610098, + "ci95": [ + -0.11645196344727027, + 0.07692901234567906 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - conch_v1": { + "delta": 0.01743341404358356, + "ci95": [ + -0.021947278116403242, + 0.06150706192244812 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - exaone_v2": { + "delta": 0.06392251815980632, + "ci95": [ + 0.00024626415670104824, + 0.12957217540327476 + ], + "excludes_zero": true + }, + "conch_v1 - exaone_v2": { + "delta": 0.04648910411622276, + "ci95": [ + -0.02147408918242257, + 0.11608276044420948 + ], + "excludes_zero": false + } + } + }, + "pr": { + "per_encoder": { + "uni_v1": { + "auroc": 0.7870784234051198, + "ci95": [ + 0.7102692863841523, + 0.8582337990849714 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "uni_stainnorm_v1": { + "auroc": 0.78992279561154, + "ci95": [ + 0.7090930921110048, + 0.8653884778012685 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "conch_v1": { + "auroc": 0.798049573344169, + "ci95": [ + 0.71581201340649, + 0.8757197047652903 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "exaone_v2": { + "auroc": 0.7295814709467695, + "ci95": [ + 0.6290518350863178, + 0.8263507991692252 + ], + "n_test": 153, + "n_valid_boot": 1000 + } + }, + "pairwise": { + "uni_v1 - uni_stainnorm_v1": { + "delta": -0.002844372206420198, + "ci95": [ + -0.06383438857395272, + 0.06484405245838165 + ], + "excludes_zero": false + }, + "uni_v1 - conch_v1": { + "delta": -0.01097114993904924, + "ci95": [ + -0.08367197062423491, + 0.061152317274225994 + ], + "excludes_zero": false + }, + "uni_v1 - exaone_v2": { + "delta": 0.05749695245835029, + "ci95": [ + -0.043074506155677705, + 0.1700482309819586 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - conch_v1": { + "delta": -0.008126777732629042, + "ci95": [ + -0.07889773183460452, + 0.05948692425876898 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - exaone_v2": { + "delta": 0.06034132466477049, + "ci95": [ + -0.04557748975659032, + 0.16720905857313614 + ], + "excludes_zero": false + }, + "conch_v1 - exaone_v2": { + "delta": 0.06846810239739953, + "ci95": [ + -0.03233225608146585, + 0.16352382636950827 + ], + "excludes_zero": false + } + } + }, + "her2": { + "per_encoder": { + "uni_v1": { + "auroc": 0.6851851851851853, + "ci95": [ + 0.529327861952862, + 0.8241302456345055 + ], + "n_test": 102, + "n_valid_boot": 1000 + }, + "uni_stainnorm_v1": { + "auroc": 0.6917989417989419, + "ci95": [ + 0.5457480658607112, + 0.8235661764705882 + ], + "n_test": 102, + "n_valid_boot": 1000 + }, + "conch_v1": { + "auroc": 0.6064814814814815, + "ci95": [ + 0.44555238217291276, + 0.7496269240019241 + ], + "n_test": 102, + "n_valid_boot": 1000 + }, + "exaone_v2": { + "auroc": 0.7486772486772487, + "ci95": [ + 0.5883733488114022, + 0.8879742547425474 + ], + "n_test": 102, + "n_valid_boot": 1000 + } + }, + "pairwise": { + "uni_v1 - uni_stainnorm_v1": { + "delta": -0.006613756613756516, + "ci95": [ + -0.09851480760774926, + 0.09082540369088822 + ], + "excludes_zero": false + }, + "uni_v1 - conch_v1": { + "delta": 0.07870370370370383, + "ci95": [ + -0.017069145171437598, + 0.1818855594430031 + ], + "excludes_zero": false + }, + "uni_v1 - exaone_v2": { + "delta": -0.06349206349206338, + "ci95": [ + -0.15080429732868758, + 0.026893369409923124 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - conch_v1": { + "delta": 0.08531746031746035, + "ci95": [ + -0.013800955988455996, + 0.1948127104377104 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - exaone_v2": { + "delta": -0.05687830687830686, + "ci95": [ + -0.17632479489923317, + 0.06977358349807891 + ], + "excludes_zero": false + }, + "conch_v1 - exaone_v2": { + "delta": -0.1421957671957672, + "ci95": [ + -0.25364805692391895, + -0.02686664716097868 + ], + "excludes_zero": true + } + } + }, + "pam50_subtype": { + "per_encoder": { + "uni_v1": { + "auroc": 0.7097197931216526, + "ci95": [ + 0.6596448587860182, + 0.7534027854198587 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "uni_stainnorm_v1": { + "auroc": 0.7211632544781148, + "ci95": [ + 0.6673643002404038, + 0.7670285111536946 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "conch_v1": { + "auroc": 0.721879198634033, + "ci95": [ + 0.6646396962555864, + 0.7703149015421038 + ], + "n_test": 153, + "n_valid_boot": 1000 + }, + "exaone_v2": { + "auroc": 0.7463098673321611, + "ci95": [ + 0.6908496654757563, + 0.8004479801105309 + ], + "n_test": 153, + "n_valid_boot": 1000 + } + }, + "pairwise": { + "uni_v1 - uni_stainnorm_v1": { + "delta": -0.011443461356462237, + "ci95": [ + -0.04999129856971087, + 0.02771117187888368 + ], + "excludes_zero": false + }, + "uni_v1 - conch_v1": { + "delta": -0.012159405512380483, + "ci95": [ + -0.05606718366045545, + 0.03473597585357231 + ], + "excludes_zero": false + }, + "uni_v1 - exaone_v2": { + "delta": -0.03659007421050853, + "ci95": [ + -0.08928862296224203, + 0.015465797249685154 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - conch_v1": { + "delta": -0.0007159441559182467, + "ci95": [ + -0.04874125154720908, + 0.04891656285214046 + ], + "excludes_zero": false + }, + "uni_stainnorm_v1 - exaone_v2": { + "delta": -0.02514661285404629, + "ci95": [ + -0.08288475769022134, + 0.029267925507868744 + ], + "excludes_zero": false + }, + "conch_v1 - exaone_v2": { + "delta": -0.024430668698128044, + "ci95": [ + -0.06731975129065434, + 0.014124123424238143 + ], + "excludes_zero": false + } + } + } + } +} \ No newline at end of file diff --git a/experiments/kkkim/20260830_encoder_ablation/bp02_metrics.py b/experiments/kkkim/20260830_encoder_ablation/bp02_metrics.py new file mode 100644 index 0000000..079da03 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/bp02_metrics.py @@ -0,0 +1,85 @@ +"""ablation 결과에 AUPRC와 balanced accuracy를 추가하고 리포 규약의 metrics.json을 쓴다.""" +import json, os +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import (roc_auc_score, average_precision_score, + balanced_accuracy_score) +from sklearn.preprocessing import StandardScaler + +from bp02_encoder_ablation import ENCODERS, load_labels +from bp02_bootstrap import load_cached + +TASKS = [("er", False), ("pr", False), ("her2", False), ("pam50_subtype", True)] +CACHE = os.path.expanduser("~/work/fmpretrain/cache_bp02") +CLIN = os.path.expanduser("~/data/clinical") +SPLIT = os.path.expanduser("~/data/split_policy_v1.csv") + +split = pd.read_csv(SPLIT).set_index("case_id")["split"] +labels = load_labels(CLIN) +rows = [] + +for enc in ENCODERS: + cases, Z = load_cached(CACHE, enc) + idx = pd.Index(cases) + for tgt, multi in TASKS: + 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])) + pred = clf.predict(sc.transform(Z[te])) + + if multi: + auc = roc_auc_score(yte, P, multi_class="ovr", average="macro") + Yb = np.stack([(yte == c).astype(int) for c in clf.classes_], 1) + auprc = average_precision_score(Yb, P, average="macro") + else: + auc = roc_auc_score(yte, P[:, 1]) + auprc = average_precision_score(yte, P[:, 1]) + rows.append({ + "embedding_model": enc, "task": tgt, + "auc": round(float(auc), 4), "auprc": round(float(auprc), 4), + "balanced_accuracy": round(float(balanced_accuracy_score(yte, pred)), 4), + "n_train": int(tr.sum()), "n_val": int(te.sum()), + "positives_test": (None if multi else int((yte == 1).sum())), + }) + print(f"{enc:20s} {tgt:14s} auc={auc:.4f} auprc={auprc:.4f} " + f"bacc={rows[-1]['balanced_accuracy']:.4f}", flush=True) + +out = os.path.expanduser("~/work/fmpretrain/bp02_metrics_full.json") +json.dump(rows, open(out, "w"), indent=2) + +# 리포 규약의 metrics.json — 대표 구성(ER, 염색 정규화 UNI) 기준 +head = next(r for r in rows if r["embedding_model"] == "uni_stainnorm_v1" + and r["task"] == "er") +metrics = { + "schema_version": "0.1", + "task": "er_status", + "model": "LogisticRegression(class_weight=balanced) on mean-pooled embeddings", + "embedding_model": "uni_stainnorm_v1", + "smoke_test": False, + "claim_level": "hypothesis_only", + "n_train": head["n_train"], "n_val": head["n_val"], + "auc": head["auc"], "auprc": head["auprc"], + "balanced_accuracy": head["balanced_accuracy"], + "best_val_loss": None, + "commit_hash": None, + "split_hash": "split_policy_v1.csv", + "note": "인코더 4종 × 표현형 4종 ablation의 대표 구성. 전체는 bp02_metrics_full.json", + "all_configs": rows, +} +json.dump(metrics, open(os.path.expanduser("~/work/fmpretrain/metrics.json"), "w"), + indent=2) +print("[done] metrics.json + bp02_metrics_full.json") diff --git a/experiments/kkkim/20260830_encoder_ablation/bp02_metrics_full.json b/experiments/kkkim/20260830_encoder_ablation/bp02_metrics_full.json new file mode 100644 index 0000000..3322d4b --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/bp02_metrics_full.json @@ -0,0 +1,162 @@ +[ + { + "embedding_model": "uni_v1", + "task": "er", + "auc": 0.8092, + "auprc": 0.9331, + "balanced_accuracy": 0.6994, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "uni_v1", + "task": "pr", + "auc": 0.7871, + "auprc": 0.8961, + "balanced_accuracy": 0.7295, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "uni_v1", + "task": "her2", + "auc": 0.6852, + "auprc": 0.3397, + "balanced_accuracy": 0.6746, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "uni_v1", + "task": "pam50_subtype", + "auc": 0.7097, + "auprc": 0.3632, + "balanced_accuracy": 0.368, + "n_train": 705, + "n_val": 153, + "positives_test": null + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "er", + "auc": 0.8918, + "auprc": 0.9644, + "balanced_accuracy": 0.7978, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "pr", + "auc": 0.7899, + "auprc": 0.8795, + "balanced_accuracy": 0.7622, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "her2", + "auc": 0.6918, + "auprc": 0.3164, + "balanced_accuracy": 0.6825, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "pam50_subtype", + "auc": 0.7212, + "auprc": 0.3802, + "balanced_accuracy": 0.4432, + "n_train": 705, + "n_val": 153, + "positives_test": null + }, + { + "embedding_model": "conch_v1", + "task": "er", + "auc": 0.8743, + "auprc": 0.943, + "balanced_accuracy": 0.7666, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "conch_v1", + "task": "pr", + "auc": 0.798, + "auprc": 0.8817, + "balanced_accuracy": 0.7186, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "conch_v1", + "task": "her2", + "auc": 0.6065, + "auprc": 0.2992, + "balanced_accuracy": 0.5714, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "conch_v1", + "task": "pam50_subtype", + "auc": 0.7219, + "auprc": 0.4097, + "balanced_accuracy": 0.4229, + "n_train": 705, + "n_val": 153, + "positives_test": null + }, + { + "embedding_model": "exaone_v2", + "task": "er", + "auc": 0.8278, + "auprc": 0.9183, + "balanced_accuracy": 0.7708, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "exaone_v2", + "task": "pr", + "auc": 0.7296, + "auprc": 0.8252, + "balanced_accuracy": 0.7093, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "exaone_v2", + "task": "her2", + "auc": 0.7487, + "auprc": 0.5593, + "balanced_accuracy": 0.6746, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "exaone_v2", + "task": "pam50_subtype", + "auc": 0.7463, + "auprc": 0.4314, + "balanced_accuracy": 0.4688, + "n_train": 705, + "n_val": 153, + "positives_test": null + } +] \ No newline at end of file diff --git a/experiments/kkkim/20260830_encoder_ablation/gate_report.json b/experiments/kkkim/20260830_encoder_ablation/gate_report.json new file mode 100644 index 0000000..96d2ffe --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/gate_report.json @@ -0,0 +1,31 @@ +{ + "schema": "auto_review_gate/v1", + "path": "experiments/kkkim/20260830_encoder_ablation", + "tier": "B", + "tier_reason": "hint:results-dir", + "owner": "kkkim", + "hard_rule_checks": { + "drp_forbidden": { + "pass": true, + "hits": [] + }, + "claim_level": { + "seen": true, + "pass": true, + "bad": [] + }, + "metrics_required": { + "present": true, + "file": "experiments/kkkim/20260830_encoder_ablation/metrics.json", + "missing_fields": [] + }, + "critic_evidence": null + }, + "critic_status": "provisional", + "ai_review_pending": true, + "ai_review_cmd": "세션/OpenClaw에서: paper-critic + Critic 7-point 에이전트로 'experiments/kkkim/20260830_encoder_ablation' 적대적 리뷰 → critic_report.json 갱신", + "human_adjudication_items": [ + "AI 리뷰 후 비동기 1-클릭 확인(공유 전)" + ], + "note": "결정론적 기계검사만. 최종 pass/reject는 정책 Tier에 따라 AI리뷰+사람." +} \ No newline at end of file diff --git a/experiments/kkkim/20260830_encoder_ablation/metrics.json b/experiments/kkkim/20260830_encoder_ablation/metrics.json new file mode 100644 index 0000000..b84a1a0 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/metrics.json @@ -0,0 +1,179 @@ +{ + "schema_version": "0.1", + "task": "er_status", + "model": "LogisticRegression(class_weight=balanced) on mean-pooled embeddings", + "embedding_model": "uni_stainnorm_v1", + "smoke_test": false, + "claim_level": "hypothesis_only", + "n_train": 706, + "n_val": 153, + "auc": 0.8918, + "auprc": 0.9644, + "balanced_accuracy": 0.7978, + "best_val_loss": null, + "commit_hash": "f2c0a2b6c90a8a408e8d268bf576a7e83f409908", + "split_hash": "split_policy_v1.csv", + "note": "인코더 4종 × 표현형 4종 ablation의 대표 구성. 전체는 bp02_metrics_full.json", + "all_configs": [ + { + "embedding_model": "uni_v1", + "task": "er", + "auc": 0.8092, + "auprc": 0.9331, + "balanced_accuracy": 0.6994, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "uni_v1", + "task": "pr", + "auc": 0.7871, + "auprc": 0.8961, + "balanced_accuracy": 0.7295, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "uni_v1", + "task": "her2", + "auc": 0.6852, + "auprc": 0.3397, + "balanced_accuracy": 0.6746, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "uni_v1", + "task": "pam50_subtype", + "auc": 0.7097, + "auprc": 0.3632, + "balanced_accuracy": 0.368, + "n_train": 705, + "n_val": 153, + "positives_test": null + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "er", + "auc": 0.8918, + "auprc": 0.9644, + "balanced_accuracy": 0.7978, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "pr", + "auc": 0.7899, + "auprc": 0.8795, + "balanced_accuracy": 0.7622, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "her2", + "auc": 0.6918, + "auprc": 0.3164, + "balanced_accuracy": 0.6825, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "uni_stainnorm_v1", + "task": "pam50_subtype", + "auc": 0.7212, + "auprc": 0.3802, + "balanced_accuracy": 0.4432, + "n_train": 705, + "n_val": 153, + "positives_test": null + }, + { + "embedding_model": "conch_v1", + "task": "er", + "auc": 0.8743, + "auprc": 0.943, + "balanced_accuracy": 0.7666, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "conch_v1", + "task": "pr", + "auc": 0.798, + "auprc": 0.8817, + "balanced_accuracy": 0.7186, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "conch_v1", + "task": "her2", + "auc": 0.6065, + "auprc": 0.2992, + "balanced_accuracy": 0.5714, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "conch_v1", + "task": "pam50_subtype", + "auc": 0.7219, + "auprc": 0.4097, + "balanced_accuracy": 0.4229, + "n_train": 705, + "n_val": 153, + "positives_test": null + }, + { + "embedding_model": "exaone_v2", + "task": "er", + "auc": 0.8278, + "auprc": 0.9183, + "balanced_accuracy": 0.7708, + "n_train": 706, + "n_val": 153, + "positives_test": 118 + }, + { + "embedding_model": "exaone_v2", + "task": "pr", + "auc": 0.7296, + "auprc": 0.8252, + "balanced_accuracy": 0.7093, + "n_train": 703, + "n_val": 153, + "positives_test": 107 + }, + { + "embedding_model": "exaone_v2", + "task": "her2", + "auc": 0.7487, + "auprc": 0.5593, + "balanced_accuracy": 0.6746, + "n_train": 491, + "n_val": 102, + "positives_test": 18 + }, + { + "embedding_model": "exaone_v2", + "task": "pam50_subtype", + "auc": 0.7463, + "auprc": 0.4314, + "balanced_accuracy": 0.4688, + "n_train": 705, + "n_val": 153, + "positives_test": null + } + ] +} \ No newline at end of file From babcc02fc962b72aa848c227251805de9b4d685f Mon Sep 17 00:00:00 2001 From: kakyungkim Date: Mon, 31 Aug 2026 10:37:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?BIOP02-149=20experiments:=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=B4=20=EB=B6=84=ED=95=A0=EB=A1=9C=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=8D=94=20ablation=20=EC=9E=AC=EC=8B=A4=ED=96=89,=20v1=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critic 적대적 리뷰가 v1을 reject했다(critic_report.json). 결정적 사유는 분할이다. v1은 서버의 split_policy_v1.csv를 써서 test 153을 냈는데, 정본은 split_policy_v0_folds.json의 test 151(hash 5995f29d3978b831)이다. 선행 결과와 비교가 불가능한 수치였고, metrics.json의 split_hash 칸에 해시 대신 파일명을 적어 자동 게이트가 이 불일치를 잡지 못했다. v2에서 고친 것 - 분할을 잠긴 정본으로 교체 (train 707 / val 152 / test 151) - trivial baseline 4종 추가 (random, prevalence, majority, embedding_mean_1d) - PAM50을 정책대로 4-class로 제한 (Normal-like 제외, split_policy_v0.md §92) - 24개 쌍대비교에 Benjamini-Hochberg 보정 결과가 뒤집혔다. v1의 헤드라인이던 "염색 정규화가 ER에서 이득을 준다"가 재현되지 않는다. uni_v1 0.809→0.774, uni_stainnorm_v1 0.892→0.796으로 내려갔고 차이도 유의하지 않다. BH 보정 후 24건 중 유의 0건이다. 읽는 법이 바뀐다. 인코더 선택이 이 표본에서 결론을 바꾸지 않으므로, UNI를 주모델로 쓰는 선택은 성능 우위가 아니라 결론이 인코더에 의존하지 않는다는 근거로 정당화한다. HER2는 네 인코더 모두 우연 수준(0.498~0.564, n=90)으로 Paper C의 음성 앵커와 방향이 같다. v1 산출물은 이력 보존용으로 남기되 어디에도 인용하지 않는다. critic_status: v2 재검토 대기 (담당 jamie 또는 braveji). `_cache/`(TCGA 임상 원본·case 평균 npz 12.5MB)는 저장소가 공개라 커밋하지 않고 로컬에만 둔다. 무결성 대조는 `_cache_manifest.sha256`(6파일)으로 한다. --- .gitignore | 4 + .../kkkim/20260830_encoder_ablation/NOTES.md | 134 +++-- .../_cache_manifest.sha256 | 6 + .../critic_report.json | 117 ++++ .../20260830_encoder_ablation/rerun_locked.py | 227 ++++++++ .../results_locked_v2.json | 536 ++++++++++++++++++ 6 files changed, 978 insertions(+), 46 deletions(-) create mode 100644 experiments/kkkim/20260830_encoder_ablation/_cache_manifest.sha256 create mode 100644 experiments/kkkim/20260830_encoder_ablation/critic_report.json create mode 100644 experiments/kkkim/20260830_encoder_ablation/rerun_locked.py create mode 100644 experiments/kkkim/20260830_encoder_ablation/results_locked_v2.json diff --git a/.gitignore b/.gitignore index 7b84cee..781f17a 100644 --- a/.gitignore +++ b/.gitignore @@ -294,3 +294,7 @@ experiments/crosscancer/*/full/*.prehome_bak # 시각화용 vendored mermaid 런타임 (3.2MB×N) — Leader 결정으로 미커밋 유지. # 없으면 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/ diff --git a/experiments/kkkim/20260830_encoder_ablation/NOTES.md b/experiments/kkkim/20260830_encoder_ablation/NOTES.md index 7124493..d306eb5 100644 --- a/experiments/kkkim/20260830_encoder_ablation/NOTES.md +++ b/experiments/kkkim/20260830_encoder_ablation/NOTES.md @@ -1,67 +1,109 @@ -# 인코더 ablation — 파운데이션 모델 선택과 염색 정규화가 표현형 예측을 얼마나 좌우하는가 +# 인코더 ablation — 파운데이션 모델 선택이 표현형 예측을 얼마나 좌우하는가 -- 실행일: 2026-08-30 +- 실행일: 2026-08-30 (v1), 2026-08-31 재실행 (v2) - 실행자: kkkim -- 상태: **critic_status: pending** — Critic 검토 전이며 공유·발표·원고 반영 전 게이트를 거쳐야 한다. -- JIRA: 미배정 (기존 BIOP02-48 `20260701_uni_vs_conch`의 확장에 해당) +- JIRA: BIOP02-149 +- 상태: **v1은 reject(폐기), v2가 정본.** v2는 Critic 재검토 대기. -## 왜 했나 +--- -기존 BIOP02-48은 UNI와 CONCH를 ER·PR에서 `StratifiedGroupKFold(5)`로 비교한 sanity 수준이었다. -Paper C 리뷰에서 "왜 이 인코더인가", "염색 정규화는 왜 하는가"가 나올 것이 확실하므로, -보유한 인코더 4종을 **프로젝트 분할 정책의 고정 test 분할**로, **PAM50까지 포함해** 한 프로토콜에 -놓고 비교했다. +## ⚠️ v1은 폐기한다 (2026-08-31) -## 방법 +v1(`bp02_encoder_ablation.json`, `bp02_encoder_ablation_ci.json`)은 적대적 Critic 리뷰에서 +**reject** 판정을 받았다(`critic_report.json`). 사유 네 가지 중 결정적인 것은 분할이다. -- 인코더: `uni_v1`(1024), `uni_stainnorm_v1`(1024), `conch_v1`(512), `exaone_v2`(768) - - 경로: `~/data/embeddings/biop02/tcga//` - - exaone_v2는 npz의 `patch_mean`을 사용(사전 계산). 나머지는 타일 임베딩 평균. -- 집계: 타일 평균 → 슬라이드 → case 평균 (다중 슬라이드 환자는 슬라이드 평균의 평균) -- 라벨: `clinical_patient_brca.txt`의 `er/pr/her2_status_by_ihc`(positive/negative만), - `tcga_brca_pam50_computed.csv`의 `pam50_subtype`(test 20건 미만 클래스 제외) -- 분할: `split_policy_v1.csv` (case 단위, train 706 / val 151 / test 153). 학습 train, 보고 test. -- 프로브: `StandardScaler` → `LogisticRegression(class_weight="balanced", max_iter=5000)` -- 불확실성: test 환자를 1,000회 재표집한 부트스트랩. 인코더 쌍 차이는 **같은 재표집 인덱스**로 - 계산(paired)해 차이의 95% 구간을 냈다. 인코더별 test 환자 집합이 동일한지 코드에서 검증한다. +- **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). +- 다중비교 보정이 없었다. -재현: `bp02_encoder_ablation.py` → `bp02_bootstrap.py` (환경 `spatialpatho`) +**v1의 헤드라인이었던 "염색 정규화가 ER에서 이득을 준다"는 v2에서 재현되지 않는다.** +분할을 바로잡자 uni_v1 0.809 → 0.774, uni_stainnorm_v1 0.892 → 0.796으로 내려갔고 +차이도 유의하지 않다. v1 수치를 어디에도 인용하지 않는다. -## 결과 (test 분할, AUROC. 이진은 양성확률, PAM50은 OvR macro) +--- -| 인코더 | dim | ER | PR | HER2 | PAM50 | +## 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.809 [0.725, 0.882] | 0.787 [0.710, 0.858] | 0.685 [0.529, 0.824] | 0.710 [0.660, 0.753] | -| uni_stainnorm_v1 | 1024 | **0.892** [0.825, 0.946] | 0.790 [0.709, 0.865] | 0.692 [0.546, 0.824] | 0.721 [0.667, 0.767] | -| conch_v1 | 512 | 0.874 [0.793, 0.942] | **0.798** [0.716, 0.876] | 0.606 [0.446, 0.750] | 0.722 [0.665, 0.770] | -| exaone_v2 | 768 | 0.828 [0.732, 0.909] | 0.730 [0.629, 0.826] | **0.749** [0.588, 0.888] | **0.746** [0.691, 0.800] | +| 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* | -n_test = 153 (HER2는 라벨 결측으로 102) +### 쌍대 비교: **24건 중 BH 보정 후 유의 0건** -### 차이의 95% 구간이 0을 포함하지 않는 쌍 +가장 작은 q값 셋은 이렇다. -- **ER**: `uni_stainnorm_v1 > uni_v1`, `uni_stainnorm_v1 > exaone_v2` -- **HER2**: `exaone_v2 > conch_v1` -- **PR·PAM50**: 없음 +| 비교 | Δ | 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. **염색 정규화의 이득이 ER에서 실재한다.** 같은 UNI 인코더인데 정규화판이 0.083 높고 - 차이의 구간이 0을 넘지 않는다. 전처리 선택을 근거로 방어할 수 있다. -2. **인코더 선택은 대체로 이 표본에서 판정 불가다.** PR과 PAM50은 네 인코더가 모두 겹친다. - "가장 좋은 인코더"를 주장하지 말고, 표본 크기의 한계를 명시하는 편이 정확하다. -3. HER2는 n=102에 구간이 넓어(±0.15) 어떤 결론도 탐색적이다. +**1. 인코더 선택은 이 표본에서 결론을 바꾸지 않는다.** 보정 후 유의한 차이가 없다. +Paper C에서 UNI를 주모델로 쓰는 선택은 성능 우위가 아니라 다른 근거(가용성, 선행 사용, +문서화)로 정당화해야 하며, 결론이 인코더에 의존하지 않는다고 말할 수 있다. +이것이 리뷰어의 "왜 이 인코더인가"에 대한 정직한 답이다. -## 한계 (원고에 반드시 함께 적을 것) +**2. 염색 정규화의 이득은 확인되지 않았다.** ER에서 +0.022로 방향은 같으나 CI가 0을 +포함한다. 원고 methods의 "염색 정규화 미적용" 서술과 충돌하지 않는다. -- 평균 풀링 + 선형 프로브까지만 본 값이다. attention MIL의 순위와 다를 수 있다. -- exaone_v2만 사전 계산된 `patch_mean`을 써서 집계 경로가 완전히 동일하지 않다. -- 단일 test 분할이다. 분할을 바꾼 반복은 하지 않았다. -- 부트스트랩은 test 환자 재표집만 반영하며, 학습 변동은 포함하지 않는다. -- **Critic 미통과.** 이 수치는 팀 내부 검토 전이므로 외부(발표·면접·공개 저장소) 인용 금지. +**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`을 남기지 않았다(리포 아티팩트 계약 미충족). ## 다음 -- attention MIL로 같은 비교를 반복해 순위가 유지되는지 확인 -- val 분할에서도 같은 방향인지 대조(현재 test만 보고) -- Critic 게이트 → JIRA 이슈 배정 → Paper C ablation 절 반영 +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 기록(폐기, 이력 보존용) diff --git a/experiments/kkkim/20260830_encoder_ablation/_cache_manifest.sha256 b/experiments/kkkim/20260830_encoder_ablation/_cache_manifest.sha256 new file mode 100644 index 0000000..0a4756f --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/_cache_manifest.sha256 @@ -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 diff --git a/experiments/kkkim/20260830_encoder_ablation/critic_report.json b/experiments/kkkim/20260830_encoder_ablation/critic_report.json new file mode 100644 index 0000000..8ae0a19 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/critic_report.json @@ -0,0 +1,117 @@ +{ + "schema_version": "0.1", + "created_at": "2026-08-31T00:00:00Z", + "reviewer": "ai-adversarial+leader-confirm", + "owner": "kkkim", + "experiment": { + "experiment_id": "20260830_encoder_ablation", + "task": "encoder ablation (er / pr / her2 / pam50_subtype), mean-pool + linear probe", + "endpoint": "ER/PR/HER2 IHC status (binary) + PAM50 subtype (OvR macro AUROC), test 분할 보고", + "commit_hash": "f2c0a2b6c90a8a408e8d268bf576a7e83f409908", + "config_path": "(미존재) experiments/kkkim/20260830_encoder_ablation/config.yaml", + "metrics_path": "experiments/kkkim/20260830_encoder_ablation/metrics.json", + "predictions_path": "(미존재) experiments/kkkim/20260830_encoder_ablation/predictions.npy", + "embedding_manifest_path": "(미기재) ~/data/embeddings/biop02/tcga// 개인 홈 경로만 코드에 있음", + "split_policy_path": "(레포 미존재) ~/data/split_policy_v1.csv" + }, + "checks": { + "data_leakage": { + "status": "reject", + "evidence": [ + "NOTES.md:23 · bp02_encoder_ablation.py:100 · bp02_bootstrap.py:37: 사용 분할이 개인 홈 경로 `~/data/split_policy_v1.csv`이고, NOTES는 그 크기를 train 706 / val 151 / test 153으로 적었다. 레포에는 이 파일이 없어 리뷰어가 case 단위 분리와 site-disjoint를 직접 확인할 수 없다(grep 결과 레포 내 실체 없음, 참조만 존재).", + "agents/data/manifests/split_policy_v0_folds.json: 잠긴 정본 분할은 n_patients 1010, fold_counts train 707 / val 152 / test 151, split_hash 5995f29d3978b831. 내가 이 JSON을 직접 열어 재집계해 확인했다.", + "bp02_encoder_ablation.json:14,20,33(uni_v1 er/pr/pam50) 외 4개 인코더 전부 n_test=153: 잠긴 test fold(151건)로는 나올 수 없는 수치다. 즉 이 실험이 쓴 분할은 잠긴 분할과 다르거나, 최소한 동일성이 입증되지 않았다. CLAUDE.md:305 '분할은 sign-off 후 잠금, 변경 금지' 및 split_policy_v0.md:127 '잠금 후 변경 = 새 버전 + 새 해시 + 재서명' 위반 소지.", + "metrics.json:15 · bp02_metrics.py:79: split_hash 값이 해시가 아니라 파일명 문자열 \"split_policy_v1.csv\"다. split_policy_v0.md:126이 요구하는 sha256 기록이 아니어서, 결정론적 게이트(gate_report.json)가 분할 불일치를 구조적으로 잡지 못한다.", + "split_policy_v0.md:213: site-disjoint(37 TSS, fold 교차 0건)와 patient-disjoint(707+152+151)는 '잠긴 분할'에 대해서만 cross-sign됐다. 나는 split_policy_v0_folds.json에서 TSS를 재계산해 37개 site 중 fold를 넘는 site 0건임을 확인했으나, 이 실험이 실제로 쓴 분할에는 같은 검증 기록이 없다(확인 불가).", + "experiments/kkkim/20260703_3way/comparison.md 'Site-confound' 표 · split_policy_v0.md:213: UNI 임베딩의 submitting-site macro-AUROC 0.9977, CONCH 0.9782. 임베딩이 제출기관을 사실상 완벽히 인코딩하므로, site-disjoint가 확인되지 않은 분할에서는 잔여 site 누수를 배제할 수 없다. checklist_v1.md:52-54 판정표 기준 site AUC >= 0.75는 site 통제 없이는 reject 구간이다.", + "bp02_encoder_ablation.py 전체 · bp02_metrics.py 전체: checklist_v1.md:31-35가 요구하는 `assert len(set(train_ids) & set(test_ids)) == 0` 하드 assert가 어느 스크립트에도 없다. bp02_bootstrap.py:69-74의 인코더 간 test 집합 동일성 확인은 누수 assert가 아니다.", + "bp02_encoder_ablation.py:124-126 · bp02_bootstrap.py:56-58: PAM50 클래스 선별(`vc = y[ok].value_counts(); ok &= y.isin(vc[vc>=20].index)`)의 vc가 train이 아니라 train+val+test 라벨 전체로 계산된다. test 라벨 분포를 보고 평가 클래스를 정하는 경미한 test 참조이며, NOTES.md:22의 '테스트 20건 미만 클래스 제외'라는 서술과도 다르다." + ], + "notes": "환자 단위 분할 자체는 코드가 case_id 기준으로 동작하므로 슬라이드/타일 누수(AP-01)는 없다고 본다. 문제는 '어떤 분할인지'다. 관측된 n_test=153이 잠긴 test fold 151과 어긋나므로, 잠긴 split_hash 5995f29d3978b831과의 일치를 재계산으로 증명하기 전에는 site-disjoint 보증이 이 실험에 전이되지 않는다. 임베딩의 site 신호가 0.9977인 코호트에서 이 보증은 결과 전체의 전제다. 따라서 pass 불가." + }, + "baseline_comparison": { + "status": "reject", + "evidence": [ + "실험 디렉터리 전체(ls 확인)에 random / subtype-only / pixel-mean 어떤 baseline 수치도 없다. metrics.json에 baseline_random_auc·baseline_subtype_auc·baseline_pixelmean_auc 필드가 모두 없다 = anti_patterns.md AP-02.", + "checklist_v1.md:87 판정표: 'baseline 미달 OR baseline 수치 누락'은 reject로 규정돼 있다.", + "prevalence 바닥선도 없다. metrics.json:26 positives_test=118/153(ER 유병률 0.771)인데 AUPRC 0.9644를 이 기준선 없이 제시했고, HER2는 18/102(0.176) 대비 AUPRC 0.2992~0.5593이라 conch_v1(0.2992)은 유병률 바닥선을 겨우 넘는다.", + "프로젝트 내 더 강한 비교 대상이 이미 있는데 대조하지 않았다. experiments/kkkim/20260703_critic_biop02-53/critic_report.json: 잠긴 분할에서 ER CLAM-SB 0.901, mean_embed 0.816, random 0.526, subtype_only 0.918이 기록돼 있다. 이번 실험의 최고값(uni_stainnorm_v1 ER 0.8918)은 같은 계열의 mean-pool 프로브인데, 기존 attention MIL 0.901과의 관계가 언급되지 않는다.", + "선행 실험 experiments/kkkim/20260703_3way/comparison.md는 같은 인코더 4종을 patient-grouped 5-fold로 이미 비교했고(UNI ER 0.8551 [0.8279, 0.8824], CONCH 0.8419, EXAONE_pm 0.8517), NOTES.md는 이 선행 결과와의 방향 일치 여부를 대조하지 않았다." + ], + "notes": "인코더 간 상대 비교가 목적이라 해도, 보고된 AUROC를 표로 싣는 이상 바닥선은 필요하다. 특히 ER은 유병률이 0.77이라 AUPRC 0.96이 얼마나 의미 있는지 바닥선 없이는 읽을 수 없다. 최소 비용 보완: 이미 캐시된 case-mean 임베딩으로 (1) 라벨 셔플 random, (2) 유병률 상수 예측, (3) 슬라이드 픽셀 평균 또는 임베딩 전역 평균 프로브 3종을 같은 분할에서 돌려 같은 표에 넣는다." + }, + "counterfactual_check": { + "status": "caution", + "evidence": [ + "이 실험에는 라벨 셔플 null도, 피처 ablation도 없다. bp02_encoder_ablation.py / bp02_bootstrap.py / bp02_metrics.py 어디에도 permutation 경로가 없다.", + "checklist_v1.md:106-110 판정표: ablation 부재 + 셔플 pass면 caution, 셔플 자체가 없으면 근거가 비어 있다.", + "프로젝트에 선행 셔플 실험이 있으나(experiments/sjpark/pam50_clam_mb_uni_v1_labelshuffle) 모델·분할·집계가 달라 이 프로토콜의 null로 전용할 수 없다.", + "차원이 큰 편이다. n_train=706에 uni 계열 1024차원 선형 프로브라 train 쪽 과적합 여지가 있고, 셔플 null이 없으면 '집계+프로브 파이프라인 자체의 바닥선'을 모른다." + ], + "notes": "held-out 환자 test에서 잰 값이라 셔플 없이도 심각한 왜곡 가능성은 낮다고 본다. 다만 비용이 매우 싸다. 캐시된 임베딩으로 train 라벨만 셔플해 인코더 4종 × 표현형 4종을 20회 반복하면 몇 분 안에 null 분포가 나오고, 특히 PAM50 5-class macro OvR의 바닥선(클래스 불균형 때문에 0.5가 아닐 수 있다)을 확정할 수 있다." + }, + "cross_dataset_check": { + "status": "not_applicable", + "evidence": [ + "checklist_v1.md:120: Paper A 표현형 예측 단계는 DepMap/GDSC 교차 검증 대상이 아니므로 not_applicable.", + "이 실험에 DepMap·GDSC·약물 연결 산출물이 없다(디렉터리 전체 확인)." + ], + "notes": "다만 이 실험의 헤드라인 주장이 '염색 정규화 이득'이라는 점에서, 실질적 교차 검증 대상은 CPTAC-BRCA다. 염색·스캐너가 다른 외부 코호트에서 uni_v1과 uni_stainnorm_v1의 격차가 유지되는지가 결정적 증거이고, 선행 실험이 이미 ER 내부 0.901 대 CPTAC 0.894 전이를 기록했으므로(experiments/kkkim/20260703_critic_biop02-53/critic_report.json) 비교 틀은 이미 있다. required_followups에 넣었다." + }, + "biological_plausibility": { + "status": "caution", + "evidence": [ + "PAM50이 4-class가 아니라 Normal-like 포함 5-class로 평가됐다. bp02_encoder_ablation.py:69-71의 load_labels는 pam50_subtype을 그대로 병합하고 Normal 제외 로직이 없으며, 소스 CSV(agents/data/manifests/tcga_brca_pam50_computed.csv)의 Normal은 134건이라 vc>=20 필터를 통과한다. 내가 잠긴 folds JSON으로 재계산한 결과, Normal 포함 시 train 707 / test 151, 제외 시 train 628 / test 131이 된다. 보고값은 n_train 705 / n_test 153이라 5-class임이 사실상 확정된다.", + "split_policy_v0.md:92: 'PAM50 (4-class) LumA/LumB/HER2-E/Basal. Normal-like는 제외'가 잠긴 라벨 정책이다. guide/pam50_4class_implementation_20260710.md도 같은 방향이다. 즉 이 실험의 PAM50 수치는 프로젝트의 선행 PAM50 결과와 직접 비교할 수 없다.", + "Normal-like는 종양 순도·정상 조직 혼입의 산물에 가까워, 형태학 모델이 '분자 아형'이 아닌 종양 함량을 학습해 macro OvR AUROC를 올릴 수 있다. 이번 PAM50 1위가 exaone_v2(0.746)인데 exaone은 타일링 파이프라인 자체가 다르므로 이 경로가 특히 열려 있다.", + "HER2는 test 양성 18건(metrics.json:46)이다. AI_REVIEW_PROMPT.md:25 기준(n_pos<25)의 저검정력 구간이고, 유일하게 0을 배제한 HER2 쌍(exaone_v2 > conch_v1, ci95 [-0.2536, -0.0269])이 이 18건 위에 서 있다.", + "인코더 간 비교가 인코더만의 비교가 아니다. agents/embedding/scripts/extract_exaone.py:7과 compare_models.py:186은 'EXAONE = internal tiling + Macenko@0.5mpp'라고 명시한다. 즉 exaone_v2 arm은 타일링·배율·염색 정규화·집계가 모두 다르다. NOTES.md:58은 집계 경로 차이만 적었고 타일링·Macenko 차이는 빠졌다. 선행 문서 comparison.md의 'deltas conflate model + tiling pipeline, not model alone' 경고가 이번 NOTES에서 사라졌다.", + "manuscript/sections/03_methods.md:13은 'H&E 염색 정규화(Macenko·Reinhard 등)는 적용하지 않았으며'라고 확정 서술하고 있고, EXAONE Path 2.0은 좌표 파이프라인 비호환으로 견고성 세트에서 제외한다고 적혀 있다. 이 실험의 uni_stainnorm_v1·exaone_v2 편입은 원고 Methods와 정면 충돌한다.", + "ICI·약물 추천 관련 서술 없음(grep 0건) → AP-04 위험 없음." + ], + "notes": "생물학적으로 말이 안 되는 방향은 없다. ER·PR이 높고 HER2가 낮은 순서는 형태학 문헌과 일치한다. 문제는 라벨 정의(PAM50 5-class)와 비교 층위(인코더 대 전처리 파이프라인)가 프로젝트 잠금 정책·원고 서술과 어긋난다는 점이다." + }, + "drp_framing": { + "status": "pass", + "evidence": [ + "실험 디렉터리 전체 grep(대소문자 무시): 'drug response prediction', 'personalized therapy', 'patient-specific ... treatment', 'optimal treatment', 'pembrolizumab', 'immune checkpoint' 0건.", + "metrics.json:3 task='er_status', bp02_metrics_full.json의 task는 er/pr/her2/pam50_subtype로 허용 범위 안.", + "metrics.json:7 claim_level='hypothesis_only'.", + "NOTES.md 전체에 치료 선택·환자 맞춤 치료 표현 없음. README의 'DRP 모델이 아니다' 제약 위반 없음." + ], + "notes": "이 항목은 깨끗하다. gate_report.json:8-11의 결정론적 검사 결과와도 일치한다." + }, + "claim_level": { + "status": "caution", + "evidence": [ + "NOTES.md:49-50 '염색 정규화의 이득이 ER에서 실재한다 ... 전처리 선택을 근거로 방어할 수 있다': 근거를 넘어선다. (1) 단일 test 분할, 단일 학습 실행이고, (2) NOTES.md:60이 스스로 밝히듯 부트스트랩은 test 환자 재표집만 반영해 학습 변동을 포함하지 않으므로 이 CI는 '이 학습된 모델이 이 test에서 더 높다'를 말할 뿐 '인코더/전처리가 더 낫다'를 말하지 않는다. (3) uni_stainnorm_v1 임베딩의 추출 스크립트가 레포에 없어(agents/embedding/scripts/에 extract_uni_stainnorm 계열 부재) 정규화 방법·기준 이미지·타일 좌표 동일성이 확인 불가다.", + "다중비교 미보정. bp02_encoder_ablation_ci.json에 표현형 4종 × 쌍 6개 = 24개 쌍대비교가 있고 excludes_zero=true는 3개다. 무보정 alpha=0.05에서 기대 위양성은 약 1.2개라, 관측된 3건 중 일부는 우연으로 설명된다. bp02_bootstrap.py:110의 'excludes_zero'를 유의성으로 읽는 것은 보정 없이는 성립하지 않는다.", + "경계 사례가 헤드라인에 포함됐다. bp02_encoder_ablation_ci.json의 'uni_stainnorm_v1 - exaone_v2'(er) ci95 하한이 0.00024626이다. 사실상 0이며 어떤 보정에서도 살아남지 못한다. NOTES.md:44는 이를 다른 결과와 같은 무게로 나열한다.", + "염색 정규화 효과의 방향성이 ER에서만 나타난다. PR 델타 0.0028, HER2 0.0066, PAM50 0.0114로 모두 0 근처다(bp02_encoder_ablation_ci.json). 4개 엔드포인트 중 1개에서만 나온 효과를 '실재한다'로 못 박으면 다중비교 문제를 그대로 떠안는다.", + "split_policy_v0.md:155가 이미 반대 방향의 경고를 담고 있다. 'stain normalization은 보조일 뿐 단독 방어 아님(Howard #4)'. site 신호가 0.9977인 코호트에서 염색 정규화는 site 시그니처 노출을 바꾸는 조작이므로, ER 이득이 형태학 신호 개선인지 site 시그니처 재배치인지 이 실험으로는 구분되지 않는다.", + "긍정 확인: NOTES.md의 헤드라인 16개 AUROC와 CI 전부를 bp02_encoder_ablation_ci.json에서 재대조한 결과 소수 셋째 자리까지 일치했고, '0을 배제하는 쌍' 3건 목록도 JSON과 일치했다. 수치 날조는 없다.", + "긍정 확인: bp02_bootstrap.py:78,104는 인코더 전체에 동일한 boot_idx를 쓰고 69-74에서 test 환자 집합 동일성을 assert하므로, paired 비교 구현 자체는 실제로 paired다." + ], + "notes": "claim_level 필드는 hypothesis_only로 올바르게 박혀 있고, NOTES.md:53·55-61의 한계 서술은 여러 축에서 정직하다. 하지만 '읽는 법' 1번 문장이 데이터가 지지하는 범위를 넘는다. checklist_v1.md:198 기준으로 '표현이 과장됐으나 데이터 범위 내'에 해당해 caution으로 둔다. 권고 문구: '이 단일 분할·단일 학습에서 ER 한 엔드포인트에 한해 정규화판이 높았다. 다중비교 보정과 분할 반복 전까지는 탐색적이며, 원고 Methods의 염색 정규화 미적용 서술을 바꿀 근거로 쓰지 않는다.'" + } + }, + "critic_status": "reject", + "claim_level": "hypothesis_only", + "summary": "인코더 4종 × 표현형 4종을 한 프로토콜에 올린 시도 자체는 타당하고, paired 부트스트랩 구현과 한계 서술은 이 팀 평균보다 정직하다. NOTES.md의 헤드라인 16개 AUROC와 CI, '0을 배제하는 쌍' 3건을 결과 JSON에서 전부 재대조해 일치를 확인했으니 수치 날조는 없다. 그럼에도 reject다. 첫째, 사용 분할이 잠긴 분할과 어긋난다. 잠긴 정본은 train 707 / val 152 / test 151(split_hash 5995f29d3978b831)인데 이 실험은 모든 인코더에서 n_test=153을 보고하고, metrics.json:15의 split_hash 자리에는 해시가 아니라 파일명이 들어가 있어 게이트가 이 불일치를 잡을 수 없다. 임베딩의 submitting-site AUROC가 0.9977인 코호트에서 site-disjoint 보증이 전이되지 않으면 결과 전체의 전제가 무너진다. 둘째, trivial baseline 3종이 전무해 checklist_v1.md:87의 명시적 reject 조건에 걸린다. 셋째, PAM50이 잠긴 정책(Normal-like 제외 4-class)과 달리 5-class로 평가돼 선행 결과와 비교 불가하다. 넷째, 헤드라인 주장인 염색 정규화 이득은 추출 코드가 레포에 없는 임베딩에 기대고 있고, 24개 쌍대비교 무보정 상태이며(경계 사례 하한 0.00025 포함), 원고 03_methods.md:13의 '염색 정규화 미적용' 서술과 충돌한다. 분할 해시 재계산과 baseline 3종은 반나절 안에 끝나는 작업이므로 재제출 비용은 낮다.", + "required_followups": [ + "[차단] `~/data/split_policy_v1.csv`의 sha256 split_hash(build_manifest.split_hash 정의)를 재계산해 잠긴 5995f29d3978b831과 대조하고 결과를 로그로 첨부한다. 불일치하면 잠긴 분할로 전면 재실행하고, 일치하면 n_test가 왜 151이 아니라 153인지 설명한다.", + "[차단] 사용 분할에 대해 patient-overlap==0 · TSS site-disjoint==True 하드 assert를 스크립트에 넣고 출력 로그를 첨부한다(checklist_v1.md:31-35).", + "[차단] trivial baseline 3종(라벨 셔플 random, 유병률 상수, 임베딩 전역평균 또는 픽셀평균)을 같은 분할에서 돌려 표에 넣고, 잠긴 분할 기준 기존 최고치(ER CLAM-SB 0.901)와의 관계를 명시한다.", + "[차단] PAM50을 Normal-like 제외 4-class로 재실행하거나, 5-class임을 표·본문에 명시하고 선행 4-class 결과와 비교 불가임을 기재한다. 클래스 선별 필터는 train 라벨 분포로만 계산하도록 고친다(현재 test 참조).", + "[차단] uni_stainnorm_v1의 추출 스크립트·정규화 방법(Macenko/Vahadane/Reinhard)·기준 이미지·타일 좌표가 uni_v1과 동일한지 여부를 커밋한다. 슬라이드별 타일 수가 두 임베딩 간 같은지 수치로 확인한다(타일 선택 차이가 이득을 만든 것이 아님을 배제).", + "[강력권고] uni_stainnorm_v1의 site-probe macro-AUROC를 측정해 uni_v1의 0.9977과 비교한다. 염색 정규화가 site 신호를 실제로 줄였는지가 ER 이득 해석의 분기점이다.", + "[강력권고] 24개 쌍대비교에 Benjamini-Hochberg 또는 Bonferroni를 적용하거나, ER 1건을 사전지정 주비교로 선언하고 나머지를 탐색적으로 강등한다. 부트스트랩 양측 p값을 함께 보고한다.", + "[강력권고] exaone_v2를 동급 인코더 arm이 아니라 별도 파이프라인 계열로 표기한다(내부 타일링 + Macenko@0.5mpp + 사전계산 patch_mean). 동일 좌표 재추출이 불가하면 인코더 순위 주장에서 제외한다.", + "[권고] NOTES.md:49-50 '염색 정규화의 이득이 ER에서 실재한다 ... 방어할 수 있다'를 탐색적 표현으로 완화하고, manuscript/sections/03_methods.md:13의 '염색 정규화 미적용' 서술과의 충돌을 원고 담당자와 정리한다.", + "[권고] 분할 반복(site-disjoint 재분할 10~20회) 또는 CPTAC-BRCA 외부 검증으로 ER 격차가 유지되는지 확인한다. 현 CI는 test 재표집만 반영해 인코더 우열 주장을 지지하지 못한다.", + "[권고] 아티팩트 5종 완비: config.yaml·predictions.npy 부재로 리뷰어가 AUROC/CI를 원자료에서 독립 재계산할 수 없었다(확인 불가로 남김). metrics.json의 n_val은 실제로 test 건수이므로(bp02_metrics.py:56) 필드명을 정정한다.", + "[권고] commit_hash 재기록. bp02_metrics.py:75는 None을 쓰는데 metrics.json:14에는 f2c0a2b6가 수기 삽입돼 있고, 그 커밋(2026-08-30 20:10:33)은 최종 결과 파일(20:14 갱신)보다 앞선다.", + "[권고] 상태 어휘 통일. NOTES.md:5 'pending', gate_report.json:24 'provisional', 스키마 enum은 pass/caution/reject다. 세 어휘가 달라 자동 게이트와 스키마가 서로 검증하지 못한다.", + "[사람 판단] 이 리포트는 owner 세션에서 구동된 AI 적대적 리뷰다. 교차리뷰 표상 kkkim 임베딩 결과의 담당 Critic은 jamie이므로, 공유 전 jamie 또는 braveji의 사람 확인이 필요하다." + ] +} \ No newline at end of file diff --git a/experiments/kkkim/20260830_encoder_ablation/rerun_locked.py b/experiments/kkkim/20260830_encoder_ablation/rerun_locked.py new file mode 100644 index 0000000..953b6da --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/rerun_locked.py @@ -0,0 +1,227 @@ +"""인코더 ablation 재실행 (Critic reject 지적 반영, v2). + +v1이 reject된 사유 네 가지를 고친다. + (1) 분할이 잠긴 정본과 어긋났다(test 153 vs 151) → split_policy_v0_folds.json을 쓴다. + (2) trivial baseline이 없었다 → random, prevalence, subtype-only, embedding-mean을 넣는다. + (3) PAM50이 5-class로 돌았다(Normal-like 포함) → 정책대로 4-class로 제한한다. + (4) 다중비교 보정이 없었다 → Benjamini-Hochberg로 보정한 부트스트랩 p를 함께 낸다. + +집계 경로 주의: exaone_v2는 내부 타일링과 Macenko 정규화를 거친 별도 파이프라인이라 +인코더만의 차이로 읽으면 안 된다. 결과에 그 사실을 함께 기록한다. +""" +import argparse, json, os +import numpy as np +import pandas as pd +from sklearn.dummy import DummyClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import (roc_auc_score, average_precision_score, + balanced_accuracy_score) +from sklearn.preprocessing import StandardScaler + +HERE = os.path.dirname(os.path.abspath(__file__)) +CACHE = os.path.join(HERE, "_cache") +REPO = os.path.abspath(os.path.join(HERE, "..", "..", "..")) +FOLDS = os.path.join(REPO, "agents/data/manifests/split_policy_v0_folds.json") + +ENCODERS = ["uni_v1", "uni_stainnorm_v1", "conch_v1", "exaone_v2"] +PAM50_KEEP = {"LumA", "LumB", "HER2", "HER2-E", "Basal"} # Normal-like 제외 (정책 §92) + + +def load_split(): + d = json.load(open(FOLDS)) + return d["case_id_to_fold"], d["split_hash"], d["fold_counts"] + + +def load_labels(): + clin = pd.read_csv(os.path.join(CACHE, "clinical_patient_brca.txt"), + sep="\t", low_memory=False) + clin = clin[clin["bcr_patient_barcode"].astype(str).str.startswith("TCGA")] + lab = pd.DataFrame({"case_id": clin["bcr_patient_barcode"].astype(str)}) + for tgt, col in [("er", "er_status_by_ihc"), ("pr", "pr_status_by_ihc"), + ("her2", "her2_status_by_ihc")]: + s = clin[col].astype(str).str.strip().str.lower() + lab[tgt] = np.where(s.eq("positive"), 1, + np.where(s.eq("negative"), 0, np.nan)) + pam = pd.read_csv(os.path.join(CACHE, "tcga_brca_pam50_computed.csv")) + pam["case_id"] = pam["case_id"].astype(str) + sub = pam["pam50_subtype"].astype(str).str.strip() + pam["pam50_4c"] = np.where(sub.isin(PAM50_KEEP), sub, np.nan) + lab = lab.merge(pam[["case_id", "pam50_4c"]], on="case_id", how="left") + return lab.drop_duplicates("case_id").set_index("case_id") + + +def load_enc(enc): + z = np.load(os.path.join(CACHE, f"{enc}_casemean.npz"), allow_pickle=True) + return z["cases"].astype(str), z["Z"] + + +def score(y, prob, pred, multi): + out = {"balanced_acc": round(float(balanced_accuracy_score(y, pred)), 4)} + if multi: + try: + out["auroc"] = round(float(roc_auc_score(y, prob, multi_class="ovr", + average="macro")), 4) + except ValueError: + out["auroc"] = None + out["auprc"] = None + else: + out["auroc"] = round(float(roc_auc_score(y, prob[:, 1])), 4) + out["auprc"] = round(float(average_precision_score(y, prob[:, 1])), 4) + return out + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--out", default=os.path.join(HERE, "results_locked_v2.json")) + p.add_argument("--n-boot", type=int, default=2000) + a = p.parse_args() + + fold_of, split_hash, counts = load_split() + labels = load_labels() + print(f"[split] 잠긴 정본 {os.path.basename(FOLDS)} | hash={split_hash} | {counts}") + + tasks = [("er", False), ("pr", False), ("her2", False), ("pam50_4c", True)] + per_enc_pred, meta_task = {}, {} + + for enc in ENCODERS: + cases, Z = load_enc(enc) + idx = pd.Index(cases) + fold = np.array([fold_of.get(c, "NA") for c in cases]) + per_enc_pred[enc] = {} + for tgt, multi in tasks: + y = labels.reindex(idx)[tgt] + ok = y.notna().to_numpy() & (fold != "NA") + tr = ok & (fold == "train") + te = ok & (fold == "test") + 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) + prob = clf.predict_proba(sc.transform(Z[te])) + pred = clf.predict(sc.transform(Z[te])) + per_enc_pred[enc][tgt] = {"y": yte, "prob": prob, "pred": pred, + "cases": cases[te]} + meta_task.setdefault(tgt, {"n_train": int(tr.sum()), + "n_test": int(te.sum()), + "classes": sorted(set(map(str, yte)))}) + print(f" [{enc:18s}] {tgt:9s} n_train={tr.sum():4d} n_test={te.sum():4d} " + f"AUROC={score(yte, prob, pred, multi)['auroc']}", flush=True) + + # --- trivial baseline (인코더와 무관, 같은 분할·같은 test 환자) --- + base = {} + for tgt, multi in tasks: + ref = per_enc_pred["uni_v1"][tgt] + cases, Z = load_enc("uni_v1") + idx = pd.Index(cases) + fold = np.array([fold_of.get(c, "NA") for c in cases]) + y = labels.reindex(idx)[tgt] + ok = y.notna().to_numpy() & (fold != "NA") + tr, te = ok & (fold == "train"), ok & (fold == "test") + yv = y.to_numpy() + ytr = yv[tr].astype(str) if multi else yv[tr].astype(int) + yte = ref["y"] + b = {} + for name, strat in [("random_uniform", "uniform"), + ("prevalence", "stratified"), + ("majority", "most_frequent")]: + dm = DummyClassifier(strategy=strat, random_state=0).fit( + np.zeros((tr.sum(), 1)), ytr) + pr = dm.predict_proba(np.zeros((te.sum(), 1))) + pd_ = dm.predict(np.zeros((te.sum(), 1))) + b[name] = score(yte, pr, pd_, multi) + # 임베딩 평균만 쓰는 1차원 기준선 (표현이 아니라 밝기·크기 대리) + m1 = Z[:, :].mean(1, keepdims=True) + sc = StandardScaler().fit(m1[tr]) + clf = LogisticRegression(max_iter=2000, class_weight="balanced").fit( + sc.transform(m1[tr]), ytr) + b["embedding_mean_1d"] = score(yte, clf.predict_proba(sc.transform(m1[te])), + clf.predict(sc.transform(m1[te])), multi) + if not multi: + b["prevalence_positive_rate"] = round(float((yte == 1).mean()), 4) + base[tgt] = b + print(f" [baseline] {tgt}: " + + ", ".join(f"{k}={v['auroc']}" for k, v in b.items() + if isinstance(v, dict)), flush=True) + + # --- paired 부트스트랩 + BH 보정 --- + rng = np.random.default_rng(0) + results, pairwise = {}, {} + for tgt, multi in tasks: + n = len(per_enc_pred["uni_v1"][tgt]["y"]) + boots = [rng.integers(0, n, n) for _ in range(a.n_boot)] + samples, point = {}, {} + for enc in ENCODERS: + d = per_enc_pred[enc][tgt] + point[enc] = score(d["y"], d["prob"], d["pred"], multi) + vals = [] + for bi in boots: + yb = d["y"][bi] + if len(np.unique(yb)) < 2: + vals.append(np.nan); continue + try: + v = (roc_auc_score(yb, d["prob"][bi], multi_class="ovr", + average="macro") if multi + else roc_auc_score(yb, d["prob"][bi, 1])) + except ValueError: + v = np.nan + vals.append(v) + s = np.array(vals, float) + samples[enc] = s + okb = ~np.isnan(s) + point[enc]["ci95"] = [round(float(np.percentile(s[okb], 2.5)), 4), + round(float(np.percentile(s[okb], 97.5)), 4)] + results[tgt] = {"per_encoder": point, **meta_task[tgt], + "baseline": base[tgt]} + for i in range(len(ENCODERS)): + for j in range(i + 1, len(ENCODERS)): + x, w = ENCODERS[i], ENCODERS[j] + d = samples[x] - samples[w] + d = d[~np.isnan(d)] + lo, hi = np.percentile(d, [2.5, 97.5]) + # 부트스트랩 양측 p (0을 넘는 비율) + pv = 2 * min((d <= 0).mean(), (d >= 0).mean()) + pairwise[f"{tgt}|{x} - {w}"] = { + "delta": round(float(point[x]["auroc"] - point[w]["auroc"]), 4), + "ci95": [round(float(lo), 4), round(float(hi), 4)], + "p_boot": round(float(min(1.0, pv)), 4)} + + # Benjamini-Hochberg + keys = list(pairwise) + ps = np.array([pairwise[k]["p_boot"] for k in keys]) + order = np.argsort(ps) + m = len(ps) + q = np.empty(m) + prev = 1.0 + for rank in range(m - 1, -1, -1): + i = order[rank] + prev = min(prev, ps[i] * m / (rank + 1)) + q[i] = prev + for k, qi in zip(keys, q): + pairwise[k]["q_bh"] = round(float(min(1.0, qi)), 4) + pairwise[k]["significant_bh05"] = bool(qi < 0.05) + + sig = [k for k in keys if pairwise[k]["significant_bh05"]] + print(f"\n[BH 보정] 전체 비교 {m}건 중 q<0.05: {len(sig)}건") + for k in sig: + v = pairwise[k] + print(f" {k}: Δ={v['delta']:+.4f} CI={v['ci95']} q={v['q_bh']}") + + json.dump({ + "version": "v2 (Critic reject 반영)", + "split_source": os.path.relpath(FOLDS, REPO), + "split_hash": split_hash, "fold_counts": counts, + "pam50_policy": "4-class, Normal-like 제외 (split_policy_v0.md §92)", + "n_boot": a.n_boot, + "multiple_comparison": "Benjamini-Hochberg on bootstrap two-sided p", + "caveat_exaone": "exaone_v2는 내부 타일링과 Macenko 정규화를 거친 별도 " + "파이프라인이라 인코더 단독 차이로 읽으면 안 된다", + "claim_level": "hypothesis_only", + "results": results, "pairwise": pairwise}, + open(a.out, "w"), indent=2, ensure_ascii=False) + print(f"[done] {a.out}") + + +if __name__ == "__main__": + main() diff --git a/experiments/kkkim/20260830_encoder_ablation/results_locked_v2.json b/experiments/kkkim/20260830_encoder_ablation/results_locked_v2.json new file mode 100644 index 0000000..cb32a89 --- /dev/null +++ b/experiments/kkkim/20260830_encoder_ablation/results_locked_v2.json @@ -0,0 +1,536 @@ +{ + "version": "v2 (Critic reject 반영)", + "split_source": "agents/data/manifests/split_policy_v0_folds.json", + "split_hash": "5995f29d3978b831", + "fold_counts": { + "test": 151, + "val": 152, + "train": 707 + }, + "pam50_policy": "4-class, Normal-like 제외 (split_policy_v0.md §92)", + "n_boot": 2000, + "multiple_comparison": "Benjamini-Hochberg on bootstrap two-sided p", + "caveat_exaone": "exaone_v2는 내부 타일링과 Macenko 정규화를 거친 별도 파이프라인이라 인코더 단독 차이로 읽으면 안 된다", + "claim_level": "hypothesis_only", + "results": { + "er": { + "per_encoder": { + "uni_v1": { + "balanced_acc": 0.7074, + "auroc": 0.7737, + "auprc": 0.8893, + "ci95": [ + 0.6932, + 0.8518 + ] + }, + "uni_stainnorm_v1": { + "balanced_acc": 0.7351, + "auroc": 0.7961, + "auprc": 0.8863, + "ci95": [ + 0.7126, + 0.8762 + ] + }, + "conch_v1": { + "balanced_acc": 0.7145, + "auroc": 0.7455, + "auprc": 0.8701, + "ci95": [ + 0.6604, + 0.8301 + ] + }, + "exaone_v2": { + "balanced_acc": 0.7583, + "auroc": 0.8277, + "auprc": 0.8986, + "ci95": [ + 0.7502, + 0.902 + ] + } + }, + "n_train": 707, + "n_test": 151, + "classes": [ + "0", + "1" + ], + "baseline": { + "random_uniform": { + "balanced_acc": 0.5057, + "auroc": 0.5, + "auprc": 0.7152 + }, + "prevalence": { + "balanced_acc": 0.447, + "auroc": 0.447, + "auprc": 0.6945 + }, + "majority": { + "balanced_acc": 0.5, + "auroc": 0.5, + "auprc": 0.7152 + }, + "embedding_mean_1d": { + "balanced_acc": 0.4274, + "auroc": 0.4063, + "auprc": 0.6385 + }, + "prevalence_positive_rate": 0.7152 + } + }, + "pr": { + "per_encoder": { + "uni_v1": { + "balanced_acc": 0.6517, + "auroc": 0.7317, + "auprc": 0.8069, + "ci95": [ + 0.6503, + 0.8142 + ] + }, + "uni_stainnorm_v1": { + "balanced_acc": 0.6845, + "auroc": 0.7073, + "auprc": 0.7186, + "ci95": [ + 0.6141, + 0.7977 + ] + }, + "conch_v1": { + "balanced_acc": 0.6883, + "auroc": 0.745, + "auprc": 0.8017, + "ci95": [ + 0.6628, + 0.8207 + ] + }, + "exaone_v2": { + "balanced_acc": 0.7104, + "auroc": 0.7614, + "auprc": 0.7744, + "ci95": [ + 0.6744, + 0.8408 + ] + } + }, + "n_train": 705, + "n_test": 149, + "classes": [ + "0", + "1" + ], + "baseline": { + "random_uniform": { + "balanced_acc": 0.5439, + "auroc": 0.5, + "auprc": 0.5906 + }, + "prevalence": { + "balanced_acc": 0.5112, + "auroc": 0.5112, + "auprc": 0.5961 + }, + "majority": { + "balanced_acc": 0.5, + "auroc": 0.5, + "auprc": 0.5906 + }, + "embedding_mean_1d": { + "balanced_acc": 0.4604, + "auroc": 0.4352, + "auprc": 0.5305 + }, + "prevalence_positive_rate": 0.5906 + } + }, + "her2": { + "per_encoder": { + "uni_v1": { + "balanced_acc": 0.5503, + "auroc": 0.552, + "auprc": 0.3898, + "ci95": [ + 0.4078, + 0.6893 + ] + }, + "uni_stainnorm_v1": { + "balanced_acc": 0.5079, + "auroc": 0.4979, + "auprc": 0.3279, + "ci95": [ + 0.3606, + 0.6284 + ] + }, + "conch_v1": { + "balanced_acc": 0.5873, + "auroc": 0.5638, + "auprc": 0.3591, + "ci95": [ + 0.4215, + 0.6991 + ] + }, + "exaone_v2": { + "balanced_acc": 0.5899, + "auroc": 0.562, + "auprc": 0.3788, + "ci95": [ + 0.4161, + 0.6966 + ] + } + }, + "n_train": 496, + "n_test": 90, + "classes": [ + "0", + "1" + ], + "baseline": { + "random_uniform": { + "balanced_acc": 0.5714, + "auroc": 0.5, + "auprc": 0.3 + }, + "prevalence": { + "balanced_acc": 0.4947, + "auroc": 0.4947, + "auprc": 0.2979 + }, + "majority": { + "balanced_acc": 0.5, + "auroc": 0.5, + "auprc": 0.3 + }, + "embedding_mean_1d": { + "balanced_acc": 0.3942, + "auroc": 0.3045, + "auprc": 0.2216 + }, + "prevalence_positive_rate": 0.3 + } + }, + "pam50_4c": { + "per_encoder": { + "uni_v1": { + "balanced_acc": 0.4248, + "auroc": 0.7193, + "auprc": null, + "ci95": [ + 0.6558, + 0.7782 + ] + }, + "uni_stainnorm_v1": { + "balanced_acc": 0.4856, + "auroc": 0.7372, + "auprc": null, + "ci95": [ + 0.6764, + 0.7958 + ] + }, + "conch_v1": { + "balanced_acc": 0.5089, + "auroc": 0.7827, + "auprc": null, + "ci95": [ + 0.7238, + 0.8358 + ] + }, + "exaone_v2": { + "balanced_acc": 0.493, + "auroc": 0.7468, + "auprc": null, + "ci95": [ + 0.6837, + 0.8066 + ] + } + }, + "n_train": 628, + "n_test": 131, + "classes": [ + "Basal", + "HER2", + "LumA", + "LumB" + ], + "baseline": { + "random_uniform": { + "balanced_acc": 0.2263, + "auroc": 0.5, + "auprc": null + }, + "prevalence": { + "balanced_acc": 0.242, + "auroc": 0.4942, + "auprc": null + }, + "majority": { + "balanced_acc": 0.25, + "auroc": 0.5, + "auprc": null + }, + "embedding_mean_1d": { + "balanced_acc": 0.2036, + "auroc": 0.4346, + "auprc": null + } + } + } + }, + "pairwise": { + "er|uni_v1 - uni_stainnorm_v1": { + "delta": -0.0224, + "ci95": [ + -0.0822, + 0.0383 + ], + "p_boot": 0.47, + "q_bh": 0.7425, + "significant_bh05": false + }, + "er|uni_v1 - conch_v1": { + "delta": 0.0282, + "ci95": [ + -0.042, + 0.0964 + ], + "p_boot": 0.41, + "q_bh": 0.708, + "significant_bh05": false + }, + "er|uni_v1 - exaone_v2": { + "delta": -0.054, + "ci95": [ + -0.1408, + 0.0258 + ], + "p_boot": 0.198, + "q_bh": 0.6789, + "significant_bh05": false + }, + "er|uni_stainnorm_v1 - conch_v1": { + "delta": 0.0506, + "ci95": [ + -0.0086, + 0.1101 + ], + "p_boot": 0.091, + "q_bh": 0.6789, + "significant_bh05": false + }, + "er|uni_stainnorm_v1 - exaone_v2": { + "delta": -0.0316, + "ci95": [ + -0.1079, + 0.0398 + ], + "p_boot": 0.379, + "q_bh": 0.708, + "significant_bh05": false + }, + "er|conch_v1 - exaone_v2": { + "delta": -0.0822, + "ci95": [ + -0.1641, + -0.0085 + ], + "p_boot": 0.026, + "q_bh": 0.312, + "significant_bh05": false + }, + "pr|uni_v1 - uni_stainnorm_v1": { + "delta": 0.0244, + "ci95": [ + -0.0671, + 0.115 + ], + "p_boot": 0.606, + "q_bh": 0.808, + "significant_bh05": false + }, + "pr|uni_v1 - conch_v1": { + "delta": -0.0133, + "ci95": [ + -0.0781, + 0.0543 + ], + "p_boot": 0.729, + "q_bh": 0.856, + "significant_bh05": false + }, + "pr|uni_v1 - exaone_v2": { + "delta": -0.0297, + "ci95": [ + -0.126, + 0.0718 + ], + "p_boot": 0.574, + "q_bh": 0.808, + "significant_bh05": false + }, + "pr|uni_stainnorm_v1 - conch_v1": { + "delta": -0.0377, + "ci95": [ + -0.123, + 0.0464 + ], + "p_boot": 0.413, + "q_bh": 0.708, + "significant_bh05": false + }, + "pr|uni_stainnorm_v1 - exaone_v2": { + "delta": -0.0541, + "ci95": [ + -0.1244, + 0.0174 + ], + "p_boot": 0.159, + "q_bh": 0.6789, + "significant_bh05": false + }, + "pr|conch_v1 - exaone_v2": { + "delta": -0.0164, + "ci95": [ + -0.0976, + 0.0677 + ], + "p_boot": 0.707, + "q_bh": 0.856, + "significant_bh05": false + }, + "her2|uni_v1 - uni_stainnorm_v1": { + "delta": 0.0541, + "ci95": [ + -0.0758, + 0.1914 + ], + "p_boot": 0.408, + "q_bh": 0.708, + "significant_bh05": false + }, + "her2|uni_v1 - conch_v1": { + "delta": -0.0118, + "ci95": [ + -0.1493, + 0.1182 + ], + "p_boot": 0.865, + "q_bh": 0.9339, + "significant_bh05": false + }, + "her2|uni_v1 - exaone_v2": { + "delta": -0.01, + "ci95": [ + -0.1401, + 0.1246 + ], + "p_boot": 0.895, + "q_bh": 0.9339, + "significant_bh05": false + }, + "her2|uni_stainnorm_v1 - conch_v1": { + "delta": -0.0659, + "ci95": [ + -0.201, + 0.0628 + ], + "p_boot": 0.308, + "q_bh": 0.708, + "significant_bh05": false + }, + "her2|uni_stainnorm_v1 - exaone_v2": { + "delta": -0.0641, + "ci95": [ + -0.1908, + 0.0665 + ], + "p_boot": 0.325, + "q_bh": 0.708, + "significant_bh05": false + }, + "her2|conch_v1 - exaone_v2": { + "delta": 0.0018, + "ci95": [ + -0.1421, + 0.1565 + ], + "p_boot": 0.941, + "q_bh": 0.941, + "significant_bh05": false + }, + "pam50_4c|uni_v1 - uni_stainnorm_v1": { + "delta": -0.0179, + "ci95": [ + -0.0726, + 0.0296 + ], + "p_boot": 0.495, + "q_bh": 0.7425, + "significant_bh05": false + }, + "pam50_4c|uni_v1 - conch_v1": { + "delta": -0.0634, + "ci95": [ + -0.1128, + -0.0171 + ], + "p_boot": 0.004, + "q_bh": 0.096, + "significant_bh05": false + }, + "pam50_4c|uni_v1 - exaone_v2": { + "delta": -0.0275, + "ci95": [ + -0.0755, + 0.0176 + ], + "p_boot": 0.242, + "q_bh": 0.708, + "significant_bh05": false + }, + "pam50_4c|uni_stainnorm_v1 - conch_v1": { + "delta": -0.0455, + "ci95": [ + -0.1031, + 0.0158 + ], + "p_boot": 0.145, + "q_bh": 0.6789, + "significant_bh05": false + }, + "pam50_4c|uni_stainnorm_v1 - exaone_v2": { + "delta": -0.0096, + "ci95": [ + -0.0688, + 0.0478 + ], + "p_boot": 0.749, + "q_bh": 0.856, + "significant_bh05": false + }, + "pam50_4c|conch_v1 - exaone_v2": { + "delta": 0.0359, + "ci95": [ + -0.0174, + 0.0888 + ], + "p_boot": 0.196, + "q_bh": 0.6789, + "significant_bh05": false + } + } +} \ No newline at end of file