File size: 12,878 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 | """Virtual modality completion: does training text <-> RNA / text <-> protein DIRECTLY on virtual pairs beat the
emergent (H&E-anchor) route?
Training pairs (TCGA all-pairs pool, 6,144 patches, every real text of a patch is a positive):
rna : real text <-> STPath virtual RNA of the same patch (cache/stpath/tcga_text_pred.npy -> log1p CP10k, 2000 HEST HVGs)
protein : real text <-> GigaTIME virtual mIF of the same patch (cache/gigatime/tcga_text_means.npy, 11 channels shared with ORION)
Model: frozen BiomedCLIP text tower + MLP head and an MLP head on the modality vector -> shared 512-d space
(CLIP-style, multi-positive InfoNCE), patient GroupKFold(5) over TCGA, 10% of training patients for early stopping.
Each feature is rank-gaussianised within its own dataset (virtual TCGA / real HEST / real ORION), because virtual
values (STPath expected counts, GigaTIME probabilities) and real measurements live on different scales.
Evaluation only on REAL measurements (never virtual):
rna : HEST real RNA, concept prompts vs RNA of the concept gene (protocol of 24_eval_gene_emergent.py (B):
AUROC top quartile, per HEST study+patient fold, all spots / bowel spots)
protein : ORION real mIF tile means, concept prompts vs marker+ cell fraction (probe of 18_eval_fm.py, per
patient_5fold test fold); also ORION GigaTIME virtual means (same tiles) to separate the virtual->real gap
Baselines (emergent, read from results/): text head trained on PathGen H&E-caption pairs, scored against the RNA
(23_) / VirTues mIF (17_) embeddings bound to UNI2.
Outputs results/virtual_completion.csv
"""
import glob
import json
import os
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.special import ndtri
from scipy.stats import rankdata, spearmanr
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import GroupKFold
from common import CACHE, ROOT, RUNS, SPLITS, load_all_tiles
dev = "cuda"
OUT = os.path.join(ROOT, "results")
TXT_ENC = "biomedclip"
prompts = json.load(open(os.path.join(ROOT, "prompts.json")))
PKEYS = list(prompts)
def rankgauss(x):
r = np.apply_along_axis(rankdata, 0, x)
return ndtri((r - 0.5) / len(x)).astype(np.float32)
# ---------------- TCGA training pool (texts, images, virtual modalities)
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
TX = torch.from_numpy(np.load(os.path.join(CACHE, f"alltxt_text_{TXT_ENC}.npy"))[:, 0].astype(np.float32)).to(dev)
PR = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{TXT_ENC}.npy"))[:, 0].astype(np.float32)).to(dev)
t_img = torch.as_tensor(T.img_idx.values, device=dev)
assert (pd.read_csv(os.path.join(CACHE, "stpath", "tcga_text_meta.csv")).key.values == I.key.values).all()
assert (pd.read_csv(os.path.join(CACHE, "gigatime", "tcga_text_meta.csv")).key.values == I.key.values).all()
# RNA: STPath log1p(counts) -> CP10k -> log1p, HEST HVGs
H = os.path.join(CACHE, "hest")
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
sg = pd.read_csv(os.path.join(CACHE, "stpath", "tcga_text_genes.csv")).gene.values
c = np.expm1(np.load(os.path.join(CACHE, "stpath", "tcga_text_pred.npy")).astype(np.float32)).clip(0)
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
gix = pd.Series(range(len(sg)), index=sg).loc[hvg].values
V_RNA = rankgauss(c[:, gix])
del c
# protein: GigaTIME channels shared with ORION (Actin-D + Transgelin -> SMA)
ch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))
G_CH, SHARED = ch["channels"], ch["orion_shared"]
ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
"CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"] # order of orion_real_if_means.npy (25_)
P_ORION = sorted(set(SHARED.values()), key=ORION_CH.index)
def giga_vec(means):
return np.stack([np.mean([means[:, G_CH.index(g)] for g, o in SHARED.items() if o == oc], 0) for oc in P_ORION], 1)
V_PROT = rankgauss(giga_vec(np.load(os.path.join(CACHE, "gigatime", "tcga_text_means.npy"))))
# ---------------- real evaluation sets
M = pd.read_csv(os.path.join(H, "meta.csv"))
hest_x = rankgauss(np.load(os.path.join(H, "hvg.npy")).astype(np.float32))
genes = pd.read_csv(os.path.join(H, "genes.csv")).gene.values
X = sp.load_npz(os.path.join(H, "counts.npz")).tocsr()
L = sp.diags(1e4 / np.asarray(X.sum(1)).ravel()) @ X
L.data = np.log1p(L.data)
gpos = {g: i for i, g in enumerate(genes)}
rna = lambda gs: np.asarray(L[:, [gpos[g] for g in gs]].mean(1)).ravel()
CONCEPT_RNA = {"immune": ["PTPRC"], "tumor_epith": ["KRT8", "EPCAM"], "stroma": ["ACTA2"], "proliferation": ["MKI67"],
"vessels": ["PECAM1"], "macrophage": ["CD68"]} # as 24_
hest_fold = np.full(len(M), -1)
for f in glob.glob(os.path.join(RUNS, "gene", "uni2_mlp_fold*", "test.pt")):
hest_fold[torch.load(f, weights_only=False)["idx"]] = int(f.split("_fold")[1].split(os.sep)[0])
assert (hest_fold >= 0).all()
df = load_all_tiles()
nn_ = pd.read_csv(os.path.join(OUT, "tile_stats.csv")).set_index("tile_id").loc[df.tile_id, "n_nuclei"].values
df["n_nuclei"] = nn_
orion_real = rankgauss(np.load(os.path.join(CACHE, "gigatime", "orion_real_if_means.npy"))[:, [ORION_CH.index(o) for o in P_ORION]])
orion_virt = rankgauss(giga_vec(np.load(os.path.join(CACHE, "gigatime", "orion_means.npy"))))
assert (pd.read_csv(os.path.join(CACHE, "gigatime", "orion_meta.csv")).tile_id.values == df.tile_id.values).all()
orion_fold = np.full(len(df), -1)
for k in range(5):
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
orion_fold[s.tile_id[s.split == "test"].values] = k
def orion_probe(scores, sel, key, label_col):
"""18_eval_fm.probe restricted to tile mask `sel`"""
sub = sel if key == "cellularity" else sel & (nn_ >= 20)
y = df[label_col].values[sub].astype(float)
if label_col != "n_nuclei":
y = y / np.maximum(nn_[sub], 1)
lab = y >= np.quantile(y, 0.75)
return {"spearman": spearmanr(scores[sub], y).correlation,
"auroc": roc_auc_score(lab, scores[sub]) if 0 < lab.sum() < lab.size else np.nan}
# ---------------- model
def mlp(din, dout=512, hid=512): # same head as 20_pairing.py
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 r1(a, b):
s = a @ b.T
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
def train(V, tr_t, va_t, epochs=60, bs=256, lr=3e-4, seed=0):
torch.manual_seed(seed); rng = np.random.default_rng(seed)
th, mh = mlp(TX.shape[1]).to(dev), mlp(V.shape[1]).to(dev)
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
opt = torch.optim.AdamW(list(th.parameters()) + list(mh.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_t) // bs), pct_start=0.05)
va = T.loc[va_t].groupby("img_idx").head(1).index.values # one text per val image
va_i, va_t1 = torch.as_tensor(T.img_idx.values[va], device=dev), torch.as_tensor(va, device=dev)
best, state = -1, None
for ep in range(epochs):
th.train(); mh.train()
perm = rng.permutation(tr_t)
for s in range(0, len(perm) - bs + 1, bs):
b = torch.as_tensor(perm[s:s + bs], device=dev)
ii = t_img[b]
lg = logit.exp().clamp(max=100) * F.normalize(mh(V[ii]), dim=-1) @ F.normalize(th(TX[b]), dim=-1).T
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(); sched.step()
th.eval(); mh.eval()
with torch.no_grad():
zv, zt = F.normalize(mh(V[va_i]), dim=-1), F.normalize(th(TX[va_t1]), dim=-1)
v = (r1(zv, zt) + r1(zt, zv)) / 2
if v > best:
best, state = v, ({k: x.clone() for k, x in th.state_dict().items()}, {k: x.clone() for k, x in mh.state_dict().items()})
th.load_state_dict(state[0]); mh.load_state_dict(state[1]); th.eval(); mh.eval()
return th, mh, best
@torch.no_grad()
def score(th, mh, x):
zp = F.normalize(th(PR), dim=-1)
z = F.normalize(mh(torch.from_numpy(x).to(dev)), dim=-1)
return (z @ zp.T).cpu().numpy() # (n, n_prompts)
rows = []
folds = list(GroupKFold(5).split(I, groups=I.patient))
for mod, Vnp in [("rna", V_RNA), ("protein", V_PROT)]:
V = torch.from_numpy(Vnp).to(dev)
for k, (trv, te) in enumerate(folds):
pats = np.random.default_rng(k).permutation(sorted(I.patient.iloc[trv].unique()))
va_p = set(pats[: max(1, len(pats) // 10)])
isplit = pd.Series("test", index=I.index)
isplit.iloc[trv] = np.where(I.patient.iloc[trv].isin(va_p), "val", "train")
ts = isplit.loc[T.img_idx].values
tr_t, va_t, te_t = (T.index[ts == s].values for s in ["train", "val", "test"])
th, mh, vbest = train(V, tr_t, va_t)
# in-domain sanity: held-out TCGA patients, text <-> virtual modality, 1 text per image, R@1 on 100-galleries
te1 = T.loc[te_t].groupby("img_idx").head(1).index.values
with torch.no_grad():
zv = F.normalize(mh(V[T.img_idx.values[te1]]), dim=-1).cpu()
zt = F.normalize(th(TX[te1]), dim=-1).cpu()
g = np.random.default_rng(0)
r = np.mean([(r1(zv[s], zt[s]) + r1(zt[s], zv[s])) / 2 for s in (g.choice(len(te1), 100, replace=False) for _ in range(50))])
rows.append({"modality": mod, "model_fold": k, "eval": "tcga_virtual_R@1_g100", "concept": "-", "auroc": np.nan, "spearman": r, "val_r1": vbest})
if mod == "rna":
S = score(th, mh, hest_x)
for c, gs in CONCEPT_RNA.items():
y_all = rna(gs)
for gal in ["all", "bowel"]:
gm = np.ones(len(M), bool) if gal == "all" else (M.organ == "Bowel").values
for hk in range(5):
sel = gm & (hest_fold == hk)
y = y_all[sel]; lab = y >= np.quantile(y, 0.75)
s = S[sel, PKEYS.index(c)]
rows.append({"modality": mod, "model_fold": k, "eval": f"hest_real_{gal}", "concept": c, "eval_fold": hk,
"auroc": roc_auc_score(lab, s) if 0 < lab.sum() < lab.size else np.nan, "spearman": spearmanr(s, y).correlation})
else:
for src, x in [("orion_real", orion_real), ("orion_virtual", orion_virt)]:
S = score(th, mh, x)
for i, (c, pr) in enumerate(prompts.items()):
for ok in range(5):
rows.append({"modality": mod, "model_fold": k, "eval": src, "concept": c, "eval_fold": ok,
**orion_probe(S[:, i], orion_fold == ok, c, pr["label"])})
print(mod, "fold", k, "val", round(vbest, 3), "tcga R@1_g100", round(r, 3), flush=True)
R = pd.DataFrame(rows)
R.to_csv(os.path.join(OUT, "virtual_completion.csv"), index=False)
# ---------------- comparison with the emergent route (same prompts, same text encoder, same real labels)
pd.set_option("display.width", 250)
print(R[R["eval"] == "tcga_virtual_R@1_g100"].groupby("modality").spearman.agg(["mean", "std"]).round(3))
D = R[R["eval"] != "tcga_virtual_R@1_g100"]
print(D.groupby(["modality", "eval", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
print(D.groupby(["modality", "eval"]).auroc.mean().round(3).to_string())
E = pd.read_csv(os.path.join(OUT, "gene_emergent_text.csv"))
E = E[E.text_enc == TXT_ENC]
print("emergent text<->RNA (HEST real):\n", E.groupby(["gallery", "modality", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
A = pd.read_csv(os.path.join(OUT, "anchor_text_probes.csv"))
A = A[(A.text_enc == TXT_ENC) & (A.split == "patient_5fold") & (A.anchor == "uni2")]
print("emergent text<->protein (ORION real, VirTues):\n", A.groupby(["modality", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
# single-marker reference on ORION real: the matched channel itself as the score
REF = {"immune": "CD3e", "tumor_epith": "Pan-CK", "stroma": "SMA", "proliferation": "Ki67", "macrophage": "CD68", "cellularity": "Hoechst"}
ref = {c: np.mean([orion_probe(orion_real[:, P_ORION.index(ch_)], orion_fold == ok, c, prompts[c]["label"])["auroc"] for ok in range(5)])
for c, ch_ in REF.items()}
print("single real channel as score (reference):", {c: round(v, 3) for c, v in ref.items()})
|