File size: 13,867 Bytes
9a5439c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""External test sets (never used for training): DLPFC Visium (brain, 12 sections / 3 donors) and HEST IDC
(breast, Xenium pseudo-spots, 4 samples; 280-gene panel). Prepared by 36_external_prep.py -> cache/external/.

Real data only for training (HEST-1k, train/val by study|patient group, val = early stopping only):
  dlpfc head : H&E(UNI2) <-> RNA MLP on the 2000 HEST HVGs, trained on HEST WITHOUT the spatialLIBD study
               (= DLPFC; those spots are in cache/hest) -> DLPFC is fully held out
  idc head   : same recipe on the IDC panel genes present in the HEST common gene set (IDC samples are not in HEST cache)
  RNA input  : log1p(CP10k over the HEST common genes / over the panel genes), then z-scored per gene with the
               statistics of its OWN dataset (HEST training spots vs the external set; label-free platform alignment)
  5 heads per set (different val groups / seeds) -> mean +- std.
Virtual data only at test time: MIPHEI-ViT virtual mIF of the external H&E (35_ + 30_ --m16) through the 16-ch mIF
heads trained on REAL ORION (patient 5-fold, fold k).
Synthetic text only at test time: GPT captions of the external H&E (27_ --sets dlpfc idc, 32_, 08_ biomedclip)
through the TCGA text heads (runs/text_clean/uni2_biomedclip_fold<k>, trained on CLEAN real image-text pairs in 33_).
Pairs: H&E<->RNA (real, external), RNA<->mIF(MIPHEI) (emergent, real x virtual), H&E<->mIF(MIPHEI) (circular),
       RNA<->text(synthetic) (emergent, real x virtual), H&E<->text(synthetic) (circular),
       mIF(MIPHEI)<->text(synthetic) (virtual x virtual).
Metrics: 100-item galleries (50 draws, both directions): R@1/R@5/R@10/MRR + Hungarian pairing accuracy.
Usage: python 38_external_eval.py feats   (UNI2 features of the external patches, GPU)
       python 38_external_eval.py eval    -> results/external_long.csv, external_summary.csv
"""
import os
import sys

import numpy as np
import pandas as pd
import scipy.sparse as sp
import torch
import torch.nn as nn
import torch.nn.functional as F
from scipy.optimize import linear_sum_assignment

from common import CACHE, ROOT, RUNS

E = os.path.join(CACHE, "external")
H = os.path.join(CACHE, "hest")
OUT = os.path.join(ROOT, "results")
SETS = {"dlpfc": "dlpfc_he_05um.npy"}  # IDC dropped from the report 2026-10-18 (user request)
MODE = sys.argv[1]

if MODE == "feats":
    import timm
    dev = "cuda"
    model = timm.create_model(  # = 04b_extract_anchor_fm.py uni2 (local official UNI2-h checkpoint)
        "vit_giant_patch14_224", pretrained=False, img_size=224, patch_size=14, depth=24, num_heads=24,
        init_values=1e-5, embed_dim=1536, mlp_ratio=2.66667 * 2, num_classes=0, no_embed_class=True,
        mlp_layer=timm.layers.SwiGLUPacked, act_layer=torch.nn.SiLU, reg_tokens=8, dynamic_img_size=True)
    model.load_state_dict(torch.load("/diskarray/home/tl688/reg2026/model_alldata/uni2-h.bin", map_location="cpu"), strict=True)
    model = model.eval().to(dev)
    mean = torch.tensor([0.485, 0.456, 0.406], device=dev).view(1, 3, 1, 1)
    std = torch.tensor([0.229, 0.224, 0.225], device=dev).view(1, 3, 1, 1)
    for st, f in SETS.items():
        X = np.load(os.path.join(E, f), mmap_mode="r")
        out = np.zeros((len(X), 1536), np.float32)
        with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
            for s in range(0, len(X), 32):
                x = torch.from_numpy(np.asarray(X[s:s + 32])).to(dev).permute(0, 3, 1, 2).float() / 255
                if x.shape[-1] != 224:
                    x = F.interpolate(x, size=(224, 224), mode="bilinear", antialias=True, align_corners=False)
                out[s:s + 32] = model((x - mean) / std).float().cpu().numpy()
        np.save(os.path.join(E, f"{st}_he_uni2.npy"), out)
        print(st, out.shape, flush=True)
    # protocol check: DLPFC spots that are also in the HEST cache (same barcode) -> cosine to the HEST UNI2 feature
    m, hm = pd.read_csv(os.path.join(E, "dlpfc_meta.csv")), pd.read_csv(os.path.join(H, "meta.csv"))
    hf = np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32)
    o = np.load(os.path.join(E, "dlpfc_he_uni2.npy"))
    j = m.reset_index().merge(hm.reset_index(), left_on=["hest_id", "barcode"], right_on=["sample", "spot"], suffixes=("_e", "_h"))
    if len(j):
        a, b = F.normalize(torch.from_numpy(o[j.index_e.values]), dim=-1), F.normalize(torch.from_numpy(hf[j.index_h.values]), dim=-1)
        rnd = F.normalize(torch.from_numpy(hf[np.random.default_rng(0).choice(len(hf), 200, replace=False)]), dim=-1)
        print(f"protocol check: {len(j)} shared spots, cosine(ours, HEST cache) = {(a * b).sum(1).numpy().round(3)}; "
              f"mean cosine to random HEST spots {float((a @ rnd.T).mean()):.3f}")
    sys.exit(0)

# ======================================================================== eval
dev = "cuda" if torch.cuda.is_available() else "cpu"
G100, DRAWS, K = 100, 50, 5
HM = pd.read_csv(os.path.join(H, "meta.csv"))
hgenes = pd.read_csv(os.path.join(H, "genes.csv")).gene.values
hcounts = sp.load_npz(os.path.join(H, "counts.npz")).tocsr().astype(np.float32)
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
HE_H = torch.from_numpy(np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32))


def mlp(din, dout=1536, hid=512):
    return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
                         nn.LayerNorm(hid), nn.Linear(hid, dout))


def lognorm(C, total):
    return np.log1p(C / np.maximum(total, 1)[:, None] * 1e4).astype(np.float32)


def zscore(x, ref):
    mu, sd = ref.mean(0), ref.std(0) + 1e-6
    return (x - mu) / sd


def r1(a, b):
    s = a @ b.T
    return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())


def train_head(G, tr, va, seed, epochs=100, bs=256, lr=3e-4):  # = 23_bind_gene.py recipe
    torch.manual_seed(seed); rng = np.random.default_rng(seed)
    A, Gd = HE_H.to(dev), torch.from_numpy(G).to(dev)
    head = mlp(G.shape[1]).to(dev)
    logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
    opt = torch.optim.AdamW(list(head.parameters()) + [logit], lr=lr, weight_decay=0.05)
    sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr) // bs), pct_start=0.05)
    za_va = F.normalize(A[va], dim=-1)
    best, state = -1, None
    for ep in range(epochs):
        head.train()
        perm = rng.permutation(tr)
        for s in range(0, len(perm) - bs + 1, bs):
            b = torch.as_tensor(perm[s:s + bs], device=dev)
            lg = logit.exp().clamp(max=100) * F.normalize(A[b], dim=-1) @ F.normalize(head(Gd[b]), dim=-1).T
            t = torch.arange(len(b), device=dev)
            loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
            opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
        head.eval()
        with torch.no_grad():
            zg = F.normalize(head(Gd[va]), dim=-1)
            v = (r1(za_va, zg) + r1(zg, za_va)) / 2
        if v > best:
            best, state = v, {k_: x.detach().cpu().clone() for k_, x in head.state_dict().items()}
    head = head.cpu(); head.load_state_dict(state); head.eval()
    return head, best


def g100(a, b, seed=0):
    rng = np.random.default_rng(seed)
    out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
    for _ in range(DRAWS):
        s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
        sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
        for S in (sim, sim.T):
            rank = (S > d[:, None]).sum(1)
            for k_ in (1, 5, 10):
                out[f"R@{k_}"].append((rank < k_).mean() / 2)
            out["MRR"].append((1 / (rank + 1)).mean() / 2)
        ri, ci = linear_sum_assignment(-sim)
        out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
    return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}


nz = lambda x: F.normalize(torch.as_tensor(np.asarray(x), dtype=torch.float32), dim=-1)


def mif_head(k):
    d = torch.load(os.path.join(RUNS, "bench_fm", f"patient_5fold_fold{k}_uni2_fm-virtues_m16_fm-geneformer_pathgenclip", "heads.pt"), map_location="cpu")
    h = mlp(d["state"]["if"]["0.weight"].shape[1]); h.load_state_dict(d["state"]["if"]); h.eval()
    return lambda x: h((torch.as_tensor(x, dtype=torch.float32) - d["if_mean"]) / d["if_std"])


def text_head(k):
    sd = torch.load(os.path.join(RUNS, "text_clean", f"uni2_biomedclip_fold{k}", "head.pt"), map_location="cpu")
    h = mlp(sd["0.weight"].shape[1], sd["6.weight"].shape[0]); h.load_state_dict(sd); h.eval()
    return lambda x: h(torch.as_tensor(x, dtype=torch.float32))


rows = []
hset = pd.Series(range(len(hgenes)), index=hgenes)
htot = np.asarray(hcounts.sum(1)).ravel()
for st in SETS:
    em = pd.read_csv(os.path.join(E, f"{st}_meta.csv"))
    he = np.load(os.path.join(E, f"{st}_he_uni2.npy"))
    if st == "dlpfc":
        eg = pd.read_csv(os.path.join(E, "dlpfc_genes_full.csv")).gene.values
        ec = np.load(os.path.join(E, "dlpfc_counts_full.npy")).astype(np.float32)
        eidx = pd.Series(range(len(eg)), index=eg)
        eidx = eidx[~eidx.index.duplicated()]
        common = [g for g in hgenes if g in eidx.index]
        genes = [g for g in hvg if g in eidx.index]
        etot = ec[:, eidx.loc[common].values].sum(1)                      # library size over the HEST common genes
        excl = HM.group.str.startswith("spatialLIBD").values                # DLPFC study removed from training
        htot_ = htot
    else:
        eg = pd.read_csv(os.path.join(E, "idc_genes.csv")).gene.values
        ec = np.load(os.path.join(E, "idc_counts.npy")).astype(np.float32)
        eidx = pd.Series(range(len(eg)), index=eg)
        eidx = eidx[~eidx.index.duplicated()]
        genes = [g for g in eg if g in hset.index]
        etot = ec[:, eidx.loc[genes].values].sum(1)                         # library size over the panel genes
        htot_ = np.asarray(hcounts[:, hset.loc[genes].values].sum(1)).ravel()
        excl = HM["sample"].isin(em["sample"].unique()).values
    Gh = lognorm(hcounts[:, hset.loc[genes].values].toarray(), htot_)
    Ge = lognorm(ec[:, eidx.loc[genes].values], etot)
    pool = np.flatnonzero(~excl & (htot_ > 0))
    print(f"{st}: {len(em)} external spots, {len(genes)} genes, HEST training pool {len(pool)} spots "
          f"({int(excl.sum())} excluded)", flush=True)
    f_mv = os.path.join(CACHE, f"if_virtues_m16_virtual_miphei_{st}.npy")
    mv = np.load(f_mv)[:, 0].astype(np.float32) if os.path.exists(f_mv) else None
    f_t = os.path.join(CACHE, f"{st}_gentext_text_biomedclip.npy")
    tx = np.load(f_t)[:, 0].astype(np.float32) if os.path.exists(f_t) else None
    zh = nz(he)
    for k in range(K):
        grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
        va_g = set(grp[: max(1, len(grp) // 10)])
        isva = HM.group.iloc[pool].isin(va_g).values
        tr, va = pool[~isva], pool[isva]
        for norm in ["z-scored per dataset", "plain"]:
            Gtr = zscore(Gh, Gh[tr]) if norm != "plain" else Gh
            Gex = zscore(Ge, Ge) if norm != "plain" else Ge
            head, best = train_head(Gtr, tr, va, seed=k)
            with torch.no_grad():
                zg = nz(head(torch.from_numpy(Gex)))
            rows.append({"set": st, "fold": k, "rna_input": norm, "part": "real", "pair": "H&E<->RNA",
                         "kind": "trained pair (external)", "circular": False, "n": len(zh), "val_R@1": best, **g100(zh, zg)})
            if mv is not None and norm == "z-scored per dataset":
                with torch.no_grad():
                    zm = nz(mif_head(k)(mv))
                for pair, a, b, kind, circ in [("RNA<->mIF(MIPHEI)", zg, zm, "emergent: real x virtual", False),
                                               ("H&E<->mIF(MIPHEI)", zh, zm, "virtual from this H&E", True)]:
                    rows.append({"set": st, "fold": k, "rna_input": norm, "part": "virtual", "pair": pair, "kind": kind,
                                 "circular": circ, "n": len(zh), **g100(a, b)})
            if tx is not None and norm == "z-scored per dataset":
                with torch.no_grad():
                    zt = nz(text_head(k)(tx))
                    zm = nz(mif_head(k)(mv)) if mv is not None else None
                for pair, a, b, kind, circ in [("RNA<->text(synthetic)", zg, zt, "emergent: real x virtual", False),
                                               ("H&E<->text(synthetic)", zh, zt, "virtual from this H&E", True),
                                               ("mIF(MIPHEI)<->text(synthetic)", zm, zt, "emergent: virtual x virtual", True)]:
                    if a is not None:
                        rows.append({"set": st, "fold": k, "rna_input": norm, "part": "virtual", "pair": pair, "kind": kind,
                                     "circular": circ, "n": len(zh), **g100(a, b)})
            if norm == "z-scored per dataset":  # embeddings for 39_external_check.py
                np.savez(os.path.join(E, f"emb_{st}_fold{k}.npz"), he=zh.numpy(), rna=zg.numpy(),
                         **({"mif": zm.numpy()} if mv is not None else {}), **({"text": zt.numpy()} if tx is not None else {}))
        print(f"  {st} head {k}: val R@1 {best:.3f}", flush=True)

R = pd.DataFrame(rows)
R.to_csv(os.path.join(OUT, "external_long.csv"), index=False)
cols = ["R@1", "R@5", "R@10", "MRR", "pair_acc"]
S = R.groupby(["set", "part", "pair", "rna_input", "kind", "circular"], sort=False)[cols + ["n"]].agg(["mean", "std"])
S.round(4).to_csv(os.path.join(OUT, "external_summary.csv"))
pd.set_option("display.width", 250)
print(R.groupby(["set", "part", "pair", "rna_input", "circular"], sort=False)[cols + ["n"]].mean().round(3).to_string())