File size: 15,781 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 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 | """RNA encoder comparison + fusion under the final protocol (real pairs only; virtual data only at test time).
RNA encoders (frozen inputs, cache/rnafm/<enc>_<set>.npy from 43_*.py; "hvg" = log1p CP10k of the 2000 HEST HVGs,
identical to 42_train_regimes.py):
hvg, scgpt, geneformer, nicheformer, tahoex1, aidocell, se600m, c2s, c2s-last, genept-bge, genept-bmc (whatever exists)
cat:<a>+<b>+... early fusion: concatenation of the z-scored inputs, each block scaled by 1/sqrt(its dim)
ens:<a>+<b>+... late fusion: mean of the L2-normalised outputs of the separately trained single-encoder heads
(possible because every head maps into the same frozen UNI2 space)
Head: linear or MLP (42_'s mlp) from the encoder input to the frozen UNI2 space, InfoNCE, AdamW 3e-4 / wd 0.05 /
OneCycle, batch 256, 2700 steps, early stopping on val R@1 every 50 steps (= 42_ S1/S4 RNA recipe).
FM inputs are z-scored with the HEST TRAINING-spot mean / std of fold k (applied unchanged to virtual / external sets).
Per fold k (splits/final/hest_fold<k>.csv, ORION splits/patient_5fold, TCGA splits/final/tcga_fold<k>.csv):
trained H&E<->RNA HEST test spots (real)
emergent mIF<->RNA(virtual) ORION test tiles, mIF = S4 mIF MLP head of fold k (trained here once, cached)
emergent text<->RNA(virtual) TCGA test images, text = S4 linear text head of fold k (clean real text)
calib H&E<->RNA(virtual) HEST test spots with STPath RNA instead of the real one (circular, reference only)
--dlpfc: additionally train on HEST WITHOUT the spatialLIBD study (val = 10% of groups) and test H&E<->RNA on the
external DLPFC set (482 spots; FM inputs z-scored per dataset as in 38_), mixed galleries G=100.
Usage: python 44_rnafm_eval.py [--encs ...] [--heads linear mlp] [--folds 0 1 2 3 4] [--dlpfc]
Output results/rnafm_long.csv (resumable), results/rnafm_summary.csv
"""
import argparse
import os
import numpy as np
import pandas as pd
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, SPLITS
ap = argparse.ArgumentParser()
ap.add_argument("--encs", nargs="+", default=None)
ap.add_argument("--heads", nargs="+", default=["linear", "mlp"])
ap.add_argument("--folds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
ap.add_argument("--dlpfc", action="store_true")
args = ap.parse_args()
dev = "cuda"
H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
G100, DRAWS, BS, LR, EVAL_EVERY, STEPS = 100, 50, 256, 3e-4, 50, 2700
f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
SETS = ["hest", "hest_v", "orion_v", "tcga_v", "dlpfc"]
# ---------------------------------------------------------------- anchors / other modalities (= 42_)
HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
HE_D = f32(np.load(os.path.join(CACHE, "external", "dlpfc_he_uni2.npy"))).to(dev)
HE_D = HE_D[:, 0] if HE_D.dim() == 3 else HE_D
TXT = f32(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[:, 0]).to(dev)
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
HM = pd.read_csv(os.path.join(H, "meta.csv"))
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
def hvg_inputs():
def virt(st):
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
eg = pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv")).gene.values
ec = np.load(os.path.join(CACHE, "external", "dlpfc_counts_full.npy")).astype(np.float32)
ei = pd.Series(range(len(eg)), index=eg); ei = ei[~ei.index.duplicated()]
d = np.log1p(ec / ec.sum(1, keepdims=True).clip(1) * 1e4)
d = np.stack([d[:, ei[g]] if g in ei.index else np.zeros(len(d), np.float32) for g in hvg], 1)
return {"hest": np.load(os.path.join(H, "hvg.npy")), "hest_v": virt("hest_val"), "orion_v": virt("orion"),
"tcga_v": virt("tcga_text"), "dlpfc": d}
def load_enc(e):
e = e.split("@")[0] # "<enc>@<n>": same input, head trained with another seed (seed-ensemble control)
if e == "hvg":
return {s: f32(x).to(dev) for s, x in hvg_inputs().items()}
return {s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in SETS}
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 one_text_per_image(t_ids, rng):
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
return pick.index.values, pick.values
def g100(a, b, seed=0): # = 33_ / 42_
a, b = a.cpu(), b.cpu()
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()}
def r1(a, b):
s = a @ b.T
return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
def train_head(X_tr, A_tr, X_va, A_va, kind, steps, seed, pos_tr=None):
"""modality head X -> frozen anchor space A (rows paired). A_* may be (n, V, 1536) -> random view per sample."""
torch.manual_seed(seed); rng = np.random.default_rng(seed)
h = (mlp(X_tr.shape[1]) if kind == "mlp" else nn.Linear(X_tr.shape[1], 1536)).to(dev)
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
za_va = F.normalize(A_va[:, 0] if A_va.dim() == 3 else A_va, dim=-1)
best, state = -1, None
for step in range(1, steps + 1):
h.train()
b = torch.as_tensor(rng.choice(len(X_tr), BS, replace=False), device=dev)
a = A_tr[b, torch.randint(0, A_tr.shape[1], (BS,), device=dev)] if A_tr.dim() == 3 else A_tr[b]
lg = logit.exp().clamp(max=100) * F.normalize(a, dim=-1) @ F.normalize(h(X_tr[b]), dim=-1).T
if pos_tr is None:
t = torch.arange(BS, device=dev)
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
else:
ii = pos_tr[b]; pos = (ii[:, None] == ii[None, :]).float(); tgt = pos / pos.sum(1, keepdim=True)
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
if step % EVAL_EVERY == 0 or step == steps:
h.eval()
with torch.no_grad():
v = r1(za_va, F.normalize(h(X_va), dim=-1))
if v > best:
best, state = v, {k_: x.detach().clone() for k_, x in h.state_dict().items()}
h.load_state_dict(state); h.eval()
return h, best
class Fold:
def __init__(self, k):
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
self.o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
mu, sd = IF_O[self.o["train"]].mean((0, 1)), IF_O[self.o["train"]].std((0, 1)) + 1e-3
self.IF = (IF_O - mu) / sd
h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
self.h = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
tsp = sp.loc[T.img_idx].values
self.t = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
self.t_va = one_text_per_image(self.t["val"], np.random.default_rng(0))
self.t_te = one_text_per_image(self.t["test"], np.random.default_rng(k))
# DLPFC-held-out training pool (= 38_)
pool = np.flatnonzero(~HM.group.str.startswith("spatialLIBD").values)
grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
isva = HM.group.iloc[pool].isin(set(grp[: max(1, len(grp) // 10)])).values
self.d = {"train": pool[~isva], "val": pool[isva]}
def other_heads(self, k):
"""S4 heads of fold k for mIF (MLP) and text (linear); cached in runs/rnafm/other_fold<k>.pt"""
f = os.path.join(ROOT, "runs", "rnafm", f"other_fold{k}.pt")
hm, ht = mlp(512).to(dev), nn.Linear(512, 1536).to(dev)
if os.path.exists(f):
sd = torch.load(f, map_location=dev); hm.load_state_dict(sd["mif"]); ht.load_state_dict(sd["text"])
return hm.eval(), ht.eval()
o, t = self.o, self.t
hm, _ = train_head(self.IF[o["train"]].flatten(0, 1), HE_O[o["train"]].flatten(0, 1),
self.IF[o["val"], 0], HE_O[o["val"], 0], "mlp", 400, k)
ii, tt = self.t_va
ht, _ = train_head(TXT[t["train"]], HE_T[T_IMG[torch.as_tensor(t["train"], device=dev)]],
TXT[torch.as_tensor(tt, device=dev)], HE_T[torch.as_tensor(ii, device=dev), 0],
"linear", 1000, k, pos_tr=T_IMG[torch.as_tensor(t["train"], device=dev)])
os.makedirs(os.path.dirname(f), exist_ok=True)
torch.save({"mif": hm.state_dict(), "text": ht.state_dict()}, f)
return hm, ht
def prep(X, tr, per_dataset_ext=False):
"""z-score with HEST-train stats (hvg: unchanged, = 42_). Returns dict set -> tensor."""
if X is None:
return None
mu, sd = X["hest"][tr].mean(0), X["hest"][tr].std(0) + 1e-6
out = {s: (x - mu) / sd for s, x in X.items()}
if per_dataset_ext:
out["dlpfc"] = (X["dlpfc"] - X["dlpfc"].mean(0)) / (X["dlpfc"].std(0) + 1e-6)
return out
def build(enc, fold_tr, dl_tr=None):
"""input tensors per set for encoder spec enc; hvg kept raw (42_), FMs / fusions z-scored."""
parts = enc.split(":", 1)[1].split("+") if enc.startswith("cat:") else [enc]
main, ext = [], []
for p in parts:
X = CACHE_ENC[p]
if p.split("@")[0] == "hvg" and len(parts) == 1:
main.append(X)
if dl_tr is not None:
ext.append(prep(X, dl_tr, True))
continue
w = 1 / np.sqrt(X["hest"].shape[1]) if len(parts) > 1 else 1.0
main.append({s: v * w for s, v in prep(X, fold_tr).items()})
if dl_tr is not None:
ext.append({s: v * w for s, v in prep(X, dl_tr, True).items()})
cat = lambda L: {s: torch.cat([d[s] for d in L], 1) for s in SETS}
return cat(main), (cat(ext) if dl_tr is not None else None)
# ---------------------------------------------------------------- run
avail = ["hvg"] + sorted({f.rsplit("_", 1)[0] for f in os.listdir(RF) if f.endswith("_hest.npy")
and all(os.path.exists(os.path.join(RF, f.replace("_hest.npy", f"_{s}.npy"))) for s in SETS)})
encs = sorted(args.encs or avail, key=lambda e: e.startswith("ens:")) # late fusions after their single heads
singles = sorted({p for e in encs for p in (e.split(":", 1)[1].split("+") if ":" in e else [e])})
CACHE_ENC = {e: load_enc(e) for e in singles}
print("encoders:", {e: CACHE_ENC[e]["hest"].shape[1] for e in singles}, flush=True)
f_out = os.path.join(ROOT, "results", "rnafm_long.csv")
rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
done = {(r["enc"], r["head"], r["fold"]) for r in rows}
nz = lambda x: F.normalize(x, dim=-1)
for k in args.folds:
F_ = Fold(k)
hm, ht = F_.other_heads(k)
te_o = F_.o["test"]; ii, tt = (torch.as_tensor(x, device=dev) for x in F_.t_te)
with torch.no_grad():
z_mif = nz(hm(F_.IF[te_o, 0])); z_txt = nz(ht(TXT[tt]))
heads_out = {} # (enc, head) -> dict of output embeddings, for late fusion
for enc in encs:
for hk in args.heads:
parts = enc.split(":", 1)[1].split("+") if enc.startswith("ens:") else None
need = any(enc in e.split(":", 1)[1].split("+") for e in encs if e.startswith("ens:"))
if (enc, hk, k) in done and parts is None and not need:
continue
tr, va, te = F_.h["train"], F_.h["val"], F_.h["test"]
if parts is not None: # late fusion of single-encoder heads trained in this run
if not all((p, hk) in heads_out for p in parts) or (enc, hk, k) in done:
continue
Z = {s: nz(sum(heads_out[(p, hk)][s] for p in parts)) for s in heads_out[(parts[0], hk)]}
best = float("nan")
else:
X, Xd = build(enc, tr, F_.d["train"] if args.dlpfc else None)
sd_ = k + 1000 * int(enc.split("@")[1]) if "@" in enc else k
h, best = train_head(X["hest"][tr], HE_H[tr], X["hest"][va], HE_H[va], hk, STEPS, sd_)
with torch.no_grad():
Z = {"hest": nz(h(X["hest"][te])), "hest_v": nz(h(X["hest_v"][te])),
"orion_v": nz(h(X["orion_v"][te_o])), "tcga_v": nz(h(X["tcga_v"][ii]))}
if args.dlpfc:
hd, _ = train_head(Xd["hest"][F_.d["train"]], HE_H[F_.d["train"]], Xd["hest"][F_.d["val"]],
HE_H[F_.d["val"]], hk, STEPS, sd_)
with torch.no_grad():
Z["dlpfc"] = nz(hd(Xd["dlpfc"]))
heads_out[(enc, hk)] = Z
res = {("H&E<->RNA (HEST)", "trained"): g100(nz(HE_H[te]), Z["hest"]),
("mIF<->RNA(virtual) (ORION)", "emergent"): g100(z_mif, Z["orion_v"]),
("text<->RNA(virtual) (TCGA)", "emergent"): g100(z_txt, Z["tcga_v"]),
("H&E<->RNA(virtual) (HEST)", "calib"): g100(nz(HE_H[te]), Z["hest_v"])}
if "dlpfc" in Z:
res[("H&E<->RNA (DLPFC external)", "external")] = g100(nz(HE_D), Z["dlpfc"])
if (enc, hk, k) in done: # retrained only to feed a late fusion
continue
for (pair, kind), r in res.items():
rows.append({"enc": enc, "head": hk, "fold": k, "pair": pair, "kind": kind, "val_R@1": best, **r})
pd.DataFrame(rows).to_csv(f_out, index=False)
print(f"fold {k} {enc:<34} {hk:<6} val {best:.3f} " +
" ".join(f"{p.split(' (')[0]} {r['R@1']:.3f}" for (p, _), r in res.items()), flush=True)
R = pd.DataFrame(rows)
S = R.groupby(["enc", "head", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
S.to_csv(os.path.join(ROOT, "results", "rnafm_summary.csv"))
pd.set_option("display.width", 250)
print(R.pivot_table(index=["enc", "head"], columns="pair", values="R@1", sort=False).round(3).to_string())
|