Carpediem123 commited on
Commit
9a5439c
·
verified ·
1 Parent(s): e8fa066

Migrate release files 181-210

Browse files
experiments/26_stpath_infer.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """STPath (Huang et al. 2025) zero-shot spatial gene-expression prediction from H&E GigaPath features.
2
+
3
+ Source code https://github.com/Graph-and-Geometric-Learning/STPath (commit 3b10e6d, no LICENSE file)
4
+ weights https://huggingface.co/tlhuang/STPath stfm.pth (no licence declared; not gated)
5
+ local copy: ext/stpath/{repo,weights} -- treated as untrusted: state dict loaded with weights_only=True,
6
+ and only the pure-torch model modules (model.py, spatial_transformer.py, fa.py, nn_utils) are imported.
7
+ Model STFM spatial transformer: per-spot input = Linear(GigaPath tile feature 1536) + masked gene token
8
+ + organ token + tech token; attention over all spots of a slide with a 2D frame-averaged distance bias.
9
+ Coordinates are shifted to 0 and rescaled to [0,100] per slide (official STPathInference._normalize_coords).
10
+ Output: 38,984 Ensembl-gene vector per spot on the training scale log1p(raw UMI count) (NOT library-normalised;
11
+ training used normalize_method='log1pv2' = sc.pp.log1p on raw counts).
12
+ Encoder The released checkpoint was trained on prov-gigapath tile features ONLY (official pipeline: 224-px HEST patches
13
+ @ 0.5 um/px, CenterCrop 224, ImageNet norm). prov-gigapath is gated: --extract needs HF_TOKEN in the env
14
+ (pass it as an env var only; this script never reads, prints or stores it).
15
+ Our features = mean of GigaPath(fp16) over the centre + 4 corner 224-px crops at ~0.5 um/px
16
+ (centre-only saved too as <set>_gigapath_center.npy):
17
+ ORION 333-px tiles (512-px @ 0.325 um/px resized -> ~0.49 um/px), cropped at native size.
18
+ TCGA 672-px PathGen patches = read_region level 0; visually 40x (RBC ~28-30 px -> ~0.25 um/px),
19
+ so resized 672 -> 336 px (~0.5 um/px) before cropping. (20x-scanned slides, if any, would be off 2x.)
20
+
21
+ Sets
22
+ int2_check repo example (HEST INT2, CCRCC, full slide 2541 spots) -> reproduces README mean Pearson 0.1562;
23
+ also INT2 in random 26-spot groups (sparse context, like our 1% HEST subsample) and single spots.
24
+ hest_val cache/hest (9484 Visium spots, 1% subsample of 421 HEST samples, precomputed GigaPath features),
25
+ one STPath pass per sample (all its spots as context), organ = HEST organ, tech = Visium.
26
+ Samples in HEST-bench were excluded from STPath pretraining -> reported separately as held-out.
27
+ orion ORION CRC tiles (common.load_all_tiles, 3187 tiles / 41 slides), one pass per slide with coords from
28
+ the filename x_y (level-0 px), organ = Bowel, tech = Visium. + protein sanity check
29
+ (orion_protein_check.csv: predicted RNA vs <marker>_count/n_nuclei, tiles with >= 20 nuclei).
30
+ tcga_text cache/allimg_meta.csv (6144 patches, 4088 WSIs -> mostly 1 patch = no spatial context), one pass per
31
+ WSI, coords from key x_y; organ from the TCGA project (public GDC files API, cached in
32
+ tcga_text_projects.csv) via PROJECT2ORGAN, unmapped -> 'Others'; tech = Visium.
33
+
34
+ Outputs cache/stpath/<set>_pred.npy (N,G) float16, native STPath scale log1p(raw count); <set>_genes.csv; <set>_meta.csv
35
+ hest_val additionally: hest_val_metrics.csv (per-gene Pearson/Spearman), hest_val_within_sample.csv.
36
+ Run .venv/bin/python 26_stpath_infer.py --set int2_check|hest_val
37
+ HF_TOKEN=... .venv/bin/python 26_stpath_infer.py --set orion|tcga_text --extract # GigaPath features
38
+ .venv/bin/python 26_stpath_infer.py --set orion|tcga_text [--feats F.npy] # STPath prediction
39
+ """
40
+ import argparse
41
+ import json
42
+ import os
43
+ import pickle
44
+ import re
45
+ import sys
46
+ import weakref
47
+
48
+ import numpy as np
49
+ import pandas as pd
50
+ import torch
51
+
52
+ ROOT = os.path.dirname(os.path.abspath(__file__))
53
+ EXT = os.path.join(ROOT, "ext", "stpath")
54
+ REPO = os.path.join(EXT, "repo")
55
+ WEIGHTS = os.path.join(EXT, "weights", "stfm.pth")
56
+ VOC = os.path.join(REPO, "utils_data", "symbol2ensembl.json")
57
+ CACHE = os.path.join(ROOT, "cache")
58
+ OUT = os.path.join(CACHE, "stpath")
59
+ HEST = os.path.join(ROOT, "..", "HEST")
60
+ MARKERS = ["PTPRC", "EPCAM", "KRT8", "ACTA2", "MKI67", "PECAM1", "CD68", "CD3E", "CDH1", "COL1A1"]
61
+ HEST_BENCH = { # MahmoodLab/hest-bench adata ids; STPath excluded these from pretraining (train.py)
62
+ "INT%d" % i for i in range(1, 25)} | {"TENX111", "TENX147", "TENX148", "TENX149", "NCBI642", "NCBI643",
63
+ "NCBI783", "NCBI785", "TENX95", "TENX99", "TENX118", "TENX141", "NCBI681", "NCBI682", "NCBI683", "NCBI684",
64
+ "TENX116", "TENX126", "TENX140", "ZEN36", "ZEN40", "ZEN48", "ZEN49", "TENX115", "TENX117"} | {
65
+ "MEND%d" % i for i in list(range(139, 155)) + list(range(156, 163))}
66
+
67
+
68
+ # ----------------------------------------------------------------------------------------------- model
69
+ class STPath:
70
+ def __init__(self, device):
71
+ sys.path.insert(0, REPO)
72
+ from stpath.model.model import STFM
73
+ from stpath.model.nn_utils.config import ModelConfig
74
+ import stpath.utils.constants as C
75
+
76
+ sym2ens = json.load(open(VOC))
77
+ ens = sorted(set(sym2ens.values()))
78
+ self.gene2id = {g: i + 2 for i, g in enumerate(ens)} # 0 pad, 1 mask (GeneExpTokenizer)
79
+ self.sym2ens = sym2ens
80
+ self.n_genes = max(self.gene2id.values()) + 1
81
+ self.C = C
82
+ cfg = ModelConfig.get_default_config()
83
+ cfg.feature_dim, cfg.activation, cfg.backbone = 1536, "gelu", "spatial_transformer"
84
+ cfg.n_genes, cfg.n_tech, cfg.n_species, cfg.n_organs = self.n_genes, len(C.tech_voc), len(C.specie_voc), len(C.organ_voc)
85
+ self.model = STFM(cfg)
86
+ self.model.load_state_dict(torch.load(WEIGHTS, map_location="cpu", weights_only=True))
87
+ self.model.eval().to(device)
88
+ self.device = device
89
+
90
+ def organ_id(self, organ): # IDTokenizer.encode(align_first=True); None -> "Others" as in STPathInference
91
+ organ = "Others" if organ is None or (isinstance(organ, float) and np.isnan(organ)) else organ
92
+ return self.C.organ_voc.index(self.C.organ_align_mapping.get(organ, "<pad>"))
93
+
94
+ def tech_id(self, tech):
95
+ return 0 if tech is None else self.C.tech_voc.index(self.C.tech_align_mapping.get(tech, "<pad>"))
96
+
97
+ def gene_cols(self, symbols):
98
+ """token column for each symbol (-1 if not in the STPath vocabulary)"""
99
+ return np.array([self.gene2id.get(self.sym2ens.get(s), -1) for s in symbols])
100
+
101
+ @torch.no_grad()
102
+ def predict(self, feats, coords, organ, tech, cols):
103
+ """feats (n,1536), coords (n,2) one slide -> (n, len(cols)) log1p(count)-scale, NaN for cols==-1"""
104
+ n = len(feats)
105
+ c = torch.as_tensor(np.asarray(coords, np.float64), dtype=torch.float32)
106
+ c = c - c.min(0).values
107
+ rng = c.max(0).values
108
+ c = torch.where(rng > 0, c / rng.clamp(min=1e-8) * 100, torch.zeros_like(c)) # rescale_coords; guard 1-spot/collinear
109
+ x = torch.as_tensor(np.asarray(feats, np.float32), device=self.device)
110
+ ge = torch.zeros(n, self.n_genes, device=self.device)
111
+ ge[:, 1] = 1 # all spots masked (pure image -> expression)
112
+ p = self.model.prediction_head(
113
+ img_tokens=x, coords=c.to(self.device), ge_tokens=ge,
114
+ batch_idx=torch.zeros(n, dtype=torch.long, device=self.device),
115
+ tech_tokens=torch.full((n,), self.tech_id(tech), dtype=torch.long, device=self.device),
116
+ organ_tokens=torch.full((n,), self.organ_id(organ), dtype=torch.long, device=self.device))
117
+ out = p[:, torch.as_tensor(np.clip(cols, 0, None), device=self.device)].float().cpu().numpy()
118
+ out[:, cols < 0] = np.nan
119
+ # library size of the predicted profile (all vocab genes) for optional CP10k re-normalisation
120
+ lib = torch.expm1(p[:, 2:].clamp(min=0)).sum(1).cpu().numpy()
121
+ return out, lib
122
+
123
+
124
+ # ----------------------------------------------------------------------------------------------- metrics
125
+ def corr_cols(a, b):
126
+ a = a - a.mean(0); b = b - b.mean(0)
127
+ return (a * b).sum(0) / np.sqrt((a * a).sum(0) * (b * b).sum(0) + 1e-20)
128
+
129
+
130
+ def rank_cols(a):
131
+ from scipy.stats import rankdata
132
+ return np.apply_along_axis(rankdata, 0, a)
133
+
134
+
135
+ def gene_metrics(pred, truth):
136
+ return corr_cols(pred, truth), corr_cols(rank_cols(pred), rank_cols(truth))
137
+
138
+
139
+ # ----------------------------------------------------------------------------------------------- sets
140
+ def run_int2(m):
141
+ import anndata as ad
142
+ import h5py
143
+ ex = os.path.join(REPO, "example_data")
144
+ hvg = json.load(open(os.path.join(ex, "var_50genes.json")))["genes"]
145
+ with h5py.File(os.path.join(ex, "INT2.h5")) as f:
146
+ E, Cc, bc = f["embeddings"][:], f["coords"][:], f["barcodes"][:].flatten().astype(str)
147
+ a = ad.read_h5ad(os.path.join(ex, "INT2.h5ad"))[bc, :]
148
+ gt = np.log1p(a[:, hvg].X.toarray())
149
+ cols = m.gene_cols(hvg)
150
+ p, _ = m.predict(E, Cc, "Kidney", "Visium", cols)
151
+ r = corr_cols(p, gt)
152
+ print(f"INT2 full slide ({len(E)} spots): mean Pearson 50 HVG = {np.nanmean(r):.4f} (README: 0.1562)")
153
+ rs = []
154
+ for seed in range(5): # sparse context like our 1% HEST subsample: 26 random spots per pass
155
+ idx = np.random.default_rng(seed).permutation(len(E))
156
+ pp = np.zeros_like(p)
157
+ for ch in np.array_split(idx, len(E) // 26):
158
+ pp[ch] = m.predict(E[ch], Cc[ch], "Kidney", "Visium", cols)[0]
159
+ rs.append(np.nanmean(corr_cols(pp, gt)))
160
+ print(f"INT2 sparse context (random 26-spot groups, 5 seeds): mean Pearson = {np.mean(rs):.4f} +- {np.std(rs):.4f}")
161
+ p1 = np.concatenate([m.predict(E[i:i + 1], Cc[i:i + 1], "Kidney", "Visium", cols)[0] for i in range(len(E))])
162
+ print(f"INT2 no context (each spot alone): mean Pearson = {np.nanmean(corr_cols(p1, gt)):.4f}")
163
+
164
+
165
+ def hest_coords():
166
+ from anndata._core import file_backing
167
+
168
+ def _ss(self, state): # same unpickle patch as 21_hest_prep.py
169
+ self.__dict__ = state.copy()
170
+ ref = state.get("_adata_ref", state.get("_adata"))
171
+ self.__dict__["_adata_ref"] = weakref.ref(ref) if ref is not None else (lambda: None)
172
+
173
+ file_backing.AnnDataFileManager.__setstate__ = _ss
174
+ A = pickle.load(open(os.path.join(HEST, "visium_humandisease_adata.pickle"), "rb"))
175
+ H = pd.read_csv(os.path.join(HEST, "HEST_v1_1_0.csv"))
176
+ H = H[(H.st_technology == "Visium") & (H.species == "Homo sapiens")].reset_index(drop=True)
177
+ assert len(H) == len(A)
178
+ xy = {}
179
+ for a, sid in zip(A, H.id):
180
+ for b, c in zip(a.obs_names, np.asarray(a.obsm["spatial"], np.float64)):
181
+ xy[(sid, b)] = c
182
+ return xy
183
+
184
+
185
+ def run_hest(m):
186
+ import scipy.sparse as sp
187
+ M = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv"))
188
+ genes = pd.read_csv(os.path.join(CACHE, "hest", "genes.csv")).gene.tolist()
189
+ X = sp.load_npz(os.path.join(CACHE, "hest", "counts.npz")).tocsr()
190
+ F = np.load(os.path.join(CACHE, "hest", "he_gigapath.npy"))[:, 0].astype(np.float32)
191
+ xy = hest_coords()
192
+ coords = np.stack([xy[(s, b)] for s, b in zip(M["sample"], M.spot)])
193
+ cols = m.gene_cols(genes)
194
+ keep = cols >= 0
195
+ print(f"HEST genes {len(genes)}, in STPath vocab {keep.sum()}")
196
+ genes_k, cols_k = [g for g, k in zip(genes, keep) if k], cols[keep]
197
+ pred = np.zeros((len(M), len(cols_k)), np.float32)
198
+ lib = np.zeros(len(M), np.float32)
199
+ for sid, idx in M.groupby("sample").indices.items():
200
+ pred[idx], lib[idx] = m.predict(F[idx], coords[idx], M.organ.iloc[idx[0]], "Visium", cols_k)
201
+ os.makedirs(OUT, exist_ok=True)
202
+ np.save(os.path.join(OUT, "hest_val_pred.npy"), pred.astype(np.float16))
203
+ pd.Series(genes_k).to_csv(os.path.join(OUT, "hest_val_genes.csv"), index=False, header=["gene"])
204
+ mo = M[["idx", "sample", "spot", "organ", "oncotree"]].copy()
205
+ mo["x"], mo["y"] = coords[:, 0], coords[:, 1]
206
+ mo["hest_bench_heldout"] = mo["sample"].isin(HEST_BENCH)
207
+ mo["pred_lib_expm1"] = lib
208
+ mo["status"] = "ok"
209
+ mo.to_csv(os.path.join(OUT, "hest_val_meta.csv"), index=False)
210
+
211
+ # ---- evaluation: truth = log1p(CP10k) of measured counts (and log1p raw = STPath's training target)
212
+ Xk = X[:, np.where(keep)[0]]
213
+ tot = np.asarray(X.sum(1)).ravel()
214
+ L = sp.diags(1e4 / tot) @ Xk
215
+ L.data = np.log1p(L.data)
216
+ frac = np.asarray((Xk > 0).mean(0)).ravel()
217
+ mu = np.asarray(L.mean(0)).ravel()
218
+ var = np.asarray(L.multiply(L).mean(0)).ravel() - mu ** 2
219
+ var[frac < 0.01] = -1
220
+ top50 = list(np.argsort(-var)[:50])
221
+ gi = {g: i for i, g in enumerate(genes_k)}
222
+ mk = [gi[g] for g in MARKERS if g in gi]
223
+ sel = top50 + [i for i in mk if i not in top50]
224
+ T_cp = L[:, sel].toarray()
225
+ T_raw = np.log1p(Xk[:, sel].toarray())
226
+ P = pred[:, sel]
227
+ P_cp = np.log1p(np.expm1(np.clip(P, 0, None)) / lib[:, None] * 1e4) # predicted profile re-normalised to CP10k
228
+ held = mo.hest_bench_heldout.values
229
+ rows = []
230
+ for subset, msk in [("all", np.ones(len(M), bool)), ("heldout_hestbench", held), ("seen_pretrain", ~held)]:
231
+ for tname, T, PP in [("log1p_cp10k", T_cp, P), ("log1p_raw", T_raw, P), ("log1p_cp10k_vs_predcp10k", T_cp, P_cp)]:
232
+ pr, sr = gene_metrics(PP[msk], T[msk])
233
+ for j, g in enumerate(sel):
234
+ rows.append({"subset": subset, "truth": tname, "gene": genes_k[g], "is_top50_hvg": g in top50,
235
+ "is_marker": genes_k[g] in MARKERS, "pearson": pr[j], "spearman": sr[j],
236
+ "n_spots": int(msk.sum())})
237
+ R = pd.DataFrame(rows)
238
+ R.to_csv(os.path.join(OUT, "hest_val_metrics.csv"), index=False)
239
+ # within-sample correlations (spatial-variation within a slide, the HEST-bench style), samples with >=30 spots
240
+ ws = []
241
+ for sid, idx in M.groupby("sample").indices.items():
242
+ if len(idx) >= 30:
243
+ pr, _ = gene_metrics(P[idx][:, :50], T_cp[idx][:, :50])
244
+ ws.append({"sample": sid, "heldout": sid in HEST_BENCH, "n": len(idx), "mean_pearson_top50": np.nanmean(pr)})
245
+ W = pd.DataFrame(ws)
246
+ W.to_csv(os.path.join(OUT, "hest_val_within_sample.csv"), index=False)
247
+ print(f"\nspots {len(M)}, samples {M['sample'].nunique()}; held-out (HEST-bench) spots {held.sum()} "
248
+ f"in {M[held]['sample'].nunique()} samples")
249
+ s = R.groupby(["subset", "truth", "is_top50_hvg"])[["pearson", "spearman"]].agg(["mean", "median"])
250
+ print(s.round(3).to_string())
251
+ print("\nmarkers, truth log1p CP10k (pooled across spots):")
252
+ print(R[R.is_marker & (R.truth == "log1p_cp10k")].pivot(index="gene", columns="subset", values=["pearson", "spearman"]).round(3).to_string())
253
+ print("\nwithin-sample mean Pearson top-50 HVG (samples >=30 spots):")
254
+ print(W.groupby("heldout").mean_pearson_top50.describe().round(3).to_string())
255
+
256
+
257
+ def parse_xy(s):
258
+ mm = re.search(r"_(\d+)_(\d+)(?:_0_512_512)?(?:\.\w+)?$", s)
259
+ return (float(mm.group(1)), float(mm.group(2))) if mm else (np.nan, np.nan)
260
+
261
+
262
+ def target_paths(name):
263
+ if name == "orion":
264
+ sys.path.insert(0, ROOT)
265
+ from common import load_all_tiles, ORION
266
+ return [os.path.join(ORION, p) for p in load_all_tiles().image_path], 333 # ~0.49 um/px already
267
+ d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
268
+ return d.image_path.tolist(), 336 # PathGen read_region level 0 = 40x (~0.25 um/px): 672 -> 336 = ~0.5 um/px
269
+
270
+
271
+ def five_crops(img, size=224):
272
+ W, H = img.size
273
+ offs = [((W - size) // 2, (H - size) // 2), (0, 0), (W - size, 0), (0, H - size), (W - size, H - size)]
274
+ return [img.crop((x, y, x + size, y + size)) for x, y in offs] # centre first
275
+
276
+
277
+ def extract_gigapath(name, device, bs=32):
278
+ """GigaPath tile features (fp16) = mean over centre + 4 corner 224-px crops at ~0.5 um/px; centre-only also saved.
279
+ Needs HF_TOKEN in the environment (prov-gigapath is gated); the token is never read/stored by this script."""
280
+ import timm
281
+ from PIL import Image, PngImagePlugin
282
+ from torchvision import transforms
283
+ PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
284
+ enc = timm.create_model("hf_hub:prov-gigapath/prov-gigapath", pretrained=True).half().eval().to(device)
285
+ tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))])
286
+ paths, side = target_paths(name)
287
+ feats = np.full((len(paths), 5, 1536), np.nan, np.float32)
288
+ status = ["ok"] * len(paths)
289
+ buf, rows = [], []
290
+
291
+ def flush():
292
+ if not buf:
293
+ return
294
+ with torch.inference_mode():
295
+ f = enc(torch.stack(buf).half().to(device)).float().cpu().numpy()
296
+ feats[np.array(rows)] = f.reshape(-1, 5, 1536)
297
+ buf.clear(); rows.clear()
298
+
299
+ for i, p in enumerate(paths):
300
+ try:
301
+ im = Image.open(p).convert("RGB")
302
+ if im.size != (side, side):
303
+ im = im.resize((side, side), Image.BICUBIC)
304
+ buf.extend(tf(c) for c in five_crops(im)); rows.append(i)
305
+ except Exception as e:
306
+ status[i] = f"unreadable:{type(e).__name__}"
307
+ print(f"[{name}] skip {p}: {e}", flush=True)
308
+ if len(rows) * 5 >= bs:
309
+ flush()
310
+ if i % 500 == 0:
311
+ print(f"[{name}] {i}/{len(paths)}", flush=True)
312
+ flush()
313
+ os.makedirs(OUT, exist_ok=True)
314
+ np.save(os.path.join(OUT, f"{name}_gigapath.npy"), feats.mean(1))
315
+ np.save(os.path.join(OUT, f"{name}_gigapath_center.npy"), feats[:, 0])
316
+ pd.DataFrame({"path": paths, "feat_status": status}).to_csv(os.path.join(OUT, f"{name}_gigapath_status.csv"), index_label="row")
317
+ print(f"[{name}] features {feats.shape[0]} rows, failed {sum(s != 'ok' for s in status)}; "
318
+ f"non-finite rows {int((~np.isfinite(feats.mean(1)).all(1)).sum())}")
319
+
320
+
321
+ def tcga_projects(meta):
322
+ """TCGA project per WSI from the public GDC files API (file_name = <wsi>.svs); cached in cache/stpath."""
323
+ fp = os.path.join(OUT, "tcga_text_projects.csv")
324
+ if not os.path.exists(fp):
325
+ import requests
326
+ names = sorted(set(meta.slide + ".svs"))
327
+ rows = []
328
+ for i in range(0, len(names), 500):
329
+ q = {"filters": {"op": "in", "content": {"field": "file_name", "value": names[i:i + 500]}},
330
+ "fields": "file_name,cases.project.project_id", "format": "JSON", "size": 1000}
331
+ r = requests.post("https://api.gdc.cancer.gov/files", json=q, timeout=120).json()
332
+ for h in r["data"]["hits"]:
333
+ rows.append({"slide": h["file_name"][:-4], "project": h["cases"][0]["project"]["project_id"]})
334
+ pd.DataFrame(rows).drop_duplicates("slide").to_csv(fp, index=False)
335
+ return dict(pd.read_csv(fp).values)
336
+
337
+
338
+ # TCGA project -> STPath organ name (keys of constants.organ_align_mapping; lower-case ones are the STimage-1K4M
339
+ # spellings that align to their own tokens, e.g. 'ovary'->'Ovary'); unlisted projects -> 'Others'
340
+ PROJECT2ORGAN = {"BRCA": "Breast", "COAD": "Bowel", "READ": "Bowel", "LUAD": "Lung", "LUSC": "Lung",
341
+ "KIRC": "Kidney", "KIRP": "Kidney", "KICH": "Kidney", "LIHC": "Liver", "PAAD": "Pancreas",
342
+ "PRAD": "Prostate", "SKCM": "Skin", "UCEC": "Uterus", "UCS": "Uterus", "OV": "ovary",
343
+ "GBM": "glioblastoma", "LGG": "glioma", "STAD": "stomach", "HNSC": "mouth", "UVM": "Eye"}
344
+
345
+
346
+ def run_target(m, name, feats_path):
347
+ if name == "orion":
348
+ sys.path.insert(0, ROOT)
349
+ from common import load_all_tiles
350
+ d = load_all_tiles()
351
+ meta = pd.DataFrame({"key": d.image_path, "path": d.image_path, "slide": d.in_slide_name})
352
+ meta["x"], meta["y"] = zip(*[parse_xy(os.path.basename(p)) for p in d.image_path])
353
+ meta["organ"] = "Bowel"
354
+ else:
355
+ d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
356
+ meta = pd.DataFrame({"key": d.key, "path": d.image_path, "slide": d.wsi_id})
357
+ meta["x"], meta["y"] = zip(*[parse_xy(k) for k in d.key])
358
+ proj = tcga_projects(meta) # TCGA project from GDC; organ token mapped, unknown -> 'Others'
359
+ meta["project"] = meta.slide.map(proj)
360
+ meta["organ"] = [PROJECT2ORGAN.get(str(p).replace("TCGA-", ""), "Others") for p in meta.project]
361
+ feats_path = feats_path or os.path.join(OUT, f"{name}_gigapath.npy")
362
+ if not os.path.exists(feats_path):
363
+ sys.exit(f"[{name}] BLOCKED: STPath's released checkpoint needs prov-gigapath tile features (1536-d) and "
364
+ f"prov-gigapath is gated for this HF account. Provide --feats <GigaPath features (N={len(meta)},1536) "
365
+ f"aligned to {name} row order>.")
366
+ F = np.load(feats_path).astype(np.float32)
367
+ F = F[:, 0] if F.ndim == 3 else F
368
+ assert F.shape == (len(meta), 1536), F.shape
369
+ ok = np.isfinite(F).all(1)
370
+ meta["status"] = np.where(ok, "ok", "no_feature")
371
+ meta["n_context"] = meta.groupby("slide").slide.transform("size").where(ok, 0)
372
+ vocab_genes = pd.read_csv(os.path.join(OUT, "hest_val_genes.csv")).gene.tolist()
373
+ cols = m.gene_cols(vocab_genes)
374
+ pred = np.full((len(meta), len(cols)), np.nan, np.float32)
375
+ for sl, idx in meta[ok].groupby("slide").indices.items():
376
+ idx = np.where(ok)[0][idx]
377
+ pred[idx] = m.predict(F[idx], meta[["x", "y"]].values[idx], meta.organ.values[idx[0]], "Visium", cols)[0]
378
+ np.save(os.path.join(OUT, f"{name}_pred.npy"), pred.astype(np.float16))
379
+ pd.Series(vocab_genes).to_csv(os.path.join(OUT, f"{name}_genes.csv"), index=False, header=["gene"])
380
+ meta.to_csv(os.path.join(OUT, f"{name}_meta.csv"), index_label="row")
381
+ print(f"[{name}] predicted {ok.sum()} / {len(meta)}; {meta.slide.nunique()} slides")
382
+ if name == "orion":
383
+ orion_protein_check(pred, vocab_genes)
384
+
385
+
386
+ ORION_PAIRS = [("PTPRC", "CD45"), ("EPCAM", "Pan-CK"), ("KRT8", "Pan-CK"), ("CDH1", "E-cadherin"), ("ACTA2", "SMA"),
387
+ ("MKI67", "Ki67"), ("PECAM1", "CD31"), ("CD68", "CD68"), ("CD3E", "CD3e"), ("CD8A", "CD8a"),
388
+ ("MS4A1", "CD20"), ("CD163", "CD163")]
389
+
390
+
391
+ def orion_protein_check(pred, genes):
392
+ """predicted RNA vs measured ORION protein (= <marker>_count / n_nuclei) per tile, tiles with >= 20 nuclei."""
393
+ from scipy.stats import pearsonr, spearmanr
394
+ sys.path.insert(0, ROOT)
395
+ from common import load_all_tiles
396
+ d = load_all_tiles()
397
+ ts = pd.read_csv(os.path.join(ROOT, "results", "tile_stats.csv"))
398
+ assert (ts.tile_id.values == d.tile_id.values).all()
399
+ keep = (ts.n_nuclei.values >= 20) & np.isfinite(pred).all(1)
400
+ gi = {g: i for i, g in enumerate(genes)}
401
+ rows = []
402
+ for g, p in ORION_PAIRS:
403
+ x = pred[keep, gi[g]].astype(np.float64)
404
+ y = (d[f"{p}_count"].values / np.maximum(ts.n_nuclei.values, 1))[keep]
405
+ ws = [spearmanr(x[i], y[i])[0] for i in d[keep].groupby("in_slide_name").indices.values() if len(i) >= 20]
406
+ rows.append({"gene": g, "protein": p, "n_tiles": int(keep.sum()), "pearson": pearsonr(x, y)[0],
407
+ "spearman": spearmanr(x, y)[0], "within_slide_spearman_median": np.nanmedian(ws),
408
+ "n_slides_within": len(ws)})
409
+ R = pd.DataFrame(rows)
410
+ R.to_csv(os.path.join(OUT, "orion_protein_check.csv"), index=False)
411
+ print(R.round(3).to_string(index=False))
412
+
413
+
414
+ if __name__ == "__main__":
415
+ ap = argparse.ArgumentParser()
416
+ ap.add_argument("--set", required=True, choices=["int2_check", "hest_val", "orion", "tcga_text"])
417
+ ap.add_argument("--feats", default=None, help="GigaPath (N,1536) features for orion / tcga_text")
418
+ ap.add_argument("--extract", action="store_true", help="compute GigaPath features for --set orion|tcga_text (needs HF_TOKEN env)")
419
+ ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
420
+ a = ap.parse_args()
421
+ torch.manual_seed(0)
422
+ if a.extract:
423
+ extract_gigapath(a.set, a.device)
424
+ sys.exit(0)
425
+ model = STPath(a.device)
426
+ {"int2_check": lambda: run_int2(model), "hest_val": lambda: run_hest(model)}.get(
427
+ a.set, lambda: run_target(model, a.set, a.feats))()
experiments/27_azure_caption.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate H&E descriptions with GPT-6.1 Sol on Azure OpenAI for images that have no real text (plan A:
2
+ one FIXED 3-shot prefix of real PathGen-1.6M captions -> prompt-cached; short caption; low reasoning effort).
3
+
4
+ Targets (images on disk without paired text):
5
+ ge_patches : PathGen-1.6M gene-expression patches (brain*, gliomas*, idc*, saromas*, skin*) 1640
6
+ slide_rep : PathGen-1.6M/slide_report/image_set/*.png 1
7
+ orion : ORION H&E tiles (he/*.jpeg) 6335
8
+ hemit : HEMIT H&E inputs (he/*/input/*) 55
9
+ Config (env): AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT,
10
+ optional AZURE_OPENAI_API_VERSION (if unset the v1 endpoint <endpoint>/openai/v1/ is used)
11
+ Usage: python 27_azure_caption.py --dry # list targets + 3-shot prompt, no API call
12
+ python 27_azure_caption.py --pilot 20 # 20 images, prints measured cost / image
13
+ python 27_azure_caption.py --budget 40 # everything; stops when the USD budget is reached
14
+ Output: cache/gen_text/captions.jsonl (resumable; one line per image: set, key, path, caption, usage)
15
+ Generated text is SYNTHETIC (seen only the H&E) -> never use it as test text; tag ttype='synthetic'.
16
+ """
17
+ import argparse
18
+ import base64
19
+ import concurrent.futures as cf
20
+ import glob
21
+ import io
22
+ import json
23
+ import os
24
+ import random
25
+ import threading
26
+ import time
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+ from PIL import Image, PngImagePlugin
31
+
32
+ from common import CACHE, ROOT
33
+
34
+ PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
35
+ P = os.path.join(ROOT, "..")
36
+ OUT = os.path.join(CACHE, "gen_text")
37
+ os.makedirs(OUT, exist_ok=True)
38
+ PRICE = {"in": 2.0, "cached": 0.10, "out": 10.0} # USD / 1M tokens, GPT-6.1 Sol standard
39
+ MAXPX = 512 # images are downscaled to <=512 px (one vision tile) to keep the token count low
40
+
41
+ ap = argparse.ArgumentParser()
42
+ ap.add_argument("--sets", nargs="+", default=["ge_patches", "slide_rep", "orion", "hemit"],
43
+ help="tcga_notext = TCGA images left without real text after 40_clean_text.py; dlpfc / idc = external test sets (all spots); tcga_calib = 1000 TCGA patches that HAVE real text (held-out patients of 31_), for calibrating synthetic vs real text")
44
+ ap.add_argument("--pilot", type=int, default=0)
45
+ ap.add_argument("--budget", type=float, default=40.0)
46
+ ap.add_argument("--workers", type=int, default=8)
47
+ ap.add_argument("--effort", default="low")
48
+ ap.add_argument("--dry", action="store_true")
49
+ args = ap.parse_args()
50
+
51
+
52
+ def targets():
53
+ rows = []
54
+ ge = ["brain_image", "brain_train_image", "brain_trainnew_image", "gliomas_test_image_new", "gliomas_train_image",
55
+ "idc_image", "idc_train_image", "saromas_test_image_new", "saromas_train_image_new", "skin_image", "skin_train_image"]
56
+ src = {"ge_patches": [f for d in ge for f in glob.glob(os.path.join(P, "PathGen-1.6M", d, "*"))],
57
+ "slide_rep": glob.glob(os.path.join(P, "PathGen-1.6M", "slide_report", "image_set", "*")),
58
+ "orion": glob.glob(os.path.join(P, "ORION", "ORIONCRC_dataset_tile_20x", "he", "*.jpeg")),
59
+ "hemit": glob.glob(os.path.join(P, "HEMIT", "he", "*", "input", "*"))}
60
+ if "tcga_calib" in args.sets: # same held-out image set as 31_virtual_validation.py (TCGA), random 1000
61
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
62
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
63
+ seen = set(pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv")).query("split in ['train', 'val']").patient)
64
+ ev = sorted(set(T.img_idx) & set(I.index[~I.patient.isin(seen)]))
65
+ src["tcga_calib"] = list(I.image_path.values[np.random.default_rng(0).choice(ev, 1000, replace=False)])
66
+ if "tcga_notext" in args.sets: # TCGA images whose real texts are all junk (40_clean_text.py)
67
+ src["tcga_notext"] = list(pd.read_csv(os.path.join(CACHE, "notext_meta.csv")).image_path)
68
+ for s in ("dlpfc", "idc"): # external test sets (36_external_prep.py): the same 224-px patches as the anchor
69
+ if s in args.sets: # (0.5 um/px; = UNI2 / MIPHEI input) -> path = "npy:<set>:<row>"
70
+ n = len(np.load(os.path.join(CACHE, "external", EXT[s]), mmap_mode="r"))
71
+ rows += [{"set": s, "key": f"external/{s}/{i}", "path": f"npy:{s}:{i}"} for i in range(n)]
72
+ for s in args.sets:
73
+ if s in EXT:
74
+ continue
75
+ for f in sorted(src[s]):
76
+ if f.lower().endswith((".png", ".jpg", ".jpeg", ".tif", ".tiff")):
77
+ rows.append({"set": s, "key": os.path.relpath(f, P), "path": f})
78
+ return pd.DataFrame(rows)
79
+
80
+
81
+ EXT = {"dlpfc": "dlpfc_he_05um.npy", "idc": "idc_he.npy"}
82
+
83
+
84
+ def b64(path):
85
+ if path.startswith("npy:"):
86
+ _, st, i = path.split(":")
87
+ im = Image.fromarray(np.asarray(np.load(os.path.join(CACHE, "external", EXT[st]), mmap_mode="r")[int(i)]))
88
+ else:
89
+ im = Image.open(path).convert("RGB")
90
+ im.thumbnail((MAXPX, MAXPX))
91
+ buf = io.BytesIO()
92
+ im.save(buf, format="JPEG", quality=90)
93
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
94
+
95
+
96
+ def few_shot():
97
+ """3 fixed real PathGen-1.6M captions (40-60 words), different slides; same for every request -> cacheable."""
98
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
99
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
100
+ T = T[(T.ttype == "caption") & T.source.str.contains("pathgen1.6m")]
101
+ w = T.caption.str.split().str.len()
102
+ T = T[(w >= 40) & (w <= 60)]
103
+ rng = random.Random(0)
104
+ picks, seen = [], set()
105
+ for i in rng.sample(list(T.index), len(T)):
106
+ wsi = I.wsi_id[T.img_idx[i]]
107
+ if wsi[:12] in seen:
108
+ continue
109
+ try:
110
+ img = b64(I.image_path[T.img_idx[i]])
111
+ except Exception:
112
+ continue
113
+ seen.add(wsi[:12]); picks.append((img, T.caption[i], T.key[i]))
114
+ if len(picks) == 3:
115
+ return picks
116
+
117
+
118
+ SYSTEM = ("You are a pathologist writing a short description of one H&E-stained histopathology image patch. "
119
+ "Describe only what is visible: tissue type and architecture, cell types and their density, nuclear "
120
+ "features, stroma, inflammation, vessels, necrosis or mitoses when present. Follow the style and length "
121
+ "of the examples (one paragraph, about 50 words). Do not mention the examples, staining quality, "
122
+ "magnification or patient information, and do not invent a diagnosis that the image does not support.")
123
+
124
+
125
+ def messages(shots, target_img):
126
+ m = [{"role": "system", "content": SYSTEM}]
127
+ for img, cap, _ in shots:
128
+ m.append({"role": "user", "content": [{"type": "text", "text": "Describe this H&E patch."},
129
+ {"type": "image_url", "image_url": {"url": img, "detail": "auto"}}]})
130
+ m.append({"role": "assistant", "content": cap})
131
+ m.append({"role": "user", "content": [{"type": "text", "text": "Describe this H&E patch."},
132
+ {"type": "image_url", "image_url": {"url": target_img, "detail": "auto"}}]})
133
+ return m
134
+
135
+
136
+ def client():
137
+ from openai import AzureOpenAI, OpenAI
138
+ ep, key = os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/"), os.environ["AZURE_OPENAI_API_KEY"]
139
+ if os.environ.get("AZURE_OPENAI_API_VERSION"):
140
+ return AzureOpenAI(azure_endpoint=ep, api_key=key, api_version=os.environ["AZURE_OPENAI_API_VERSION"])
141
+ return OpenAI(base_url=ep + "/openai/v1/", api_key=key)
142
+
143
+
144
+ def cost(u):
145
+ cached = (u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0
146
+ return ((u["prompt_tokens"] - cached) * PRICE["in"] + cached * PRICE["cached"] + u["completion_tokens"] * PRICE["out"]) / 1e6
147
+
148
+
149
+ D = targets()
150
+ shots = few_shot()
151
+ print(f"targets: {len(D)} " + json.dumps(D.set.value_counts().to_dict()))
152
+ print("3-shot examples (PathGen-1.6M keys):", [k for _, _, k in shots])
153
+ fout = os.path.join(OUT, "captions.jsonl")
154
+ done = set()
155
+ spent = 0.0
156
+ if os.path.exists(fout):
157
+ for line in open(fout):
158
+ r = json.loads(line)
159
+ done.add(r["key"]); spent += r.get("usd", 0)
160
+ todo = D[~D.key.isin(done)]
161
+ if args.pilot:
162
+ todo = todo.groupby("set", group_keys=False).apply(lambda g: g.sample(min(len(g), max(1, args.pilot // D.set.nunique())), random_state=0))
163
+ print(f"already done {len(done)} (USD {spent:.2f}); to do now {len(todo)}")
164
+ json.dump({"system": SYSTEM, "shots": [{"key": k, "caption": c} for _, c, k in shots], "maxpx": MAXPX,
165
+ "effort": args.effort, "deployment": os.environ.get("AZURE_OPENAI_DEPLOYMENT")},
166
+ open(os.path.join(OUT, "prompt.json"), "w"), indent=1)
167
+ if args.dry:
168
+ for _, c, k in shots:
169
+ print(f"- {k}: {c}")
170
+ raise SystemExit
171
+
172
+ cl, dep = client(), os.environ["AZURE_OPENAI_DEPLOYMENT"]
173
+ lock = threading.Lock()
174
+ stop = threading.Event()
175
+
176
+
177
+ def run(row):
178
+ if stop.is_set():
179
+ return
180
+ for attempt in range(6):
181
+ try:
182
+ r = cl.chat.completions.create(model=dep, messages=messages(shots, b64(row.path)),
183
+ reasoning_effort=args.effort, max_completion_tokens=2000,
184
+ prompt_cache_key="he-caption-3shot-v1")
185
+ u = r.usage.model_dump()
186
+ rec = {"set": row.set, "key": row.key, "path": row.path, "caption": r.choices[0].message.content.strip(),
187
+ "finish": r.choices[0].finish_reason, "usage": u, "usd": cost(u), "ttype": "synthetic"}
188
+ break
189
+ except Exception as e: # rate limits / transient errors -> exponential backoff
190
+ err = repr(e)[:300]
191
+ if "content_filter" in err or attempt == 5:
192
+ rec = {"set": row.set, "key": row.key, "path": row.path, "caption": None, "error": err, "usd": 0}
193
+ break
194
+ time.sleep(2 ** attempt * 5)
195
+ global spent
196
+ with lock:
197
+ with open(fout, "a") as f:
198
+ f.write(json.dumps(rec) + "\n")
199
+ spent += rec["usd"]
200
+ if spent >= args.budget:
201
+ stop.set()
202
+
203
+
204
+ t0 = time.time()
205
+ with cf.ThreadPoolExecutor(args.workers) as ex:
206
+ for i, _ in enumerate(ex.map(run, [r for r in todo.itertuples()])):
207
+ if i % 200 == 0:
208
+ print(f"{i}/{len(todo)} USD {spent:.2f} {time.time() - t0:.0f}s", flush=True)
209
+ R = pd.read_json(fout, lines=True)
210
+ ok = R[R.caption.notna()]
211
+ print(f"done {len(ok)} ok / {len(R)} total; failures {R.caption.isna().sum()}; USD spent {R.usd.sum():.2f}; "
212
+ f"USD/image {ok.usd.mean():.4f} -> projected for all {len(D)}: {ok.usd.mean() * len(D):.0f}")
213
+ if len(ok):
214
+ u = pd.json_normalize(ok.usage)
215
+ print(u.filter(regex="prompt_tokens$|completion_tokens$|cached_tokens|reasoning_tokens").mean().round(0).to_string())
216
+ if stop.is_set():
217
+ print(f"STOPPED: budget {args.budget} USD reached")
experiments/28_eval_anchor.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """H&E foundation-model ablation (UNI2 vs GigaPath vs CONCH as the frozen ImageBind anchor).
2
+
3
+ --prep : cache/pathgen_he_<anchor>.npy from cache/allimg_he_<anchor>.npy (PathGen-1.6M captions are a subset of the
4
+ all-pairs pool, same image files -> identical features; verified for uni2, cosine 1.0)
5
+ default: evaluate
6
+ (A) ORION H&E<->mIF, trained pair: runs/bench_fm/*_<anchor>_<if_enc>_fm-geneformer_pathgenclip (17_bench_fm.py);
7
+ tie-aware R@1 on 100-tile galleries + Hungarian pairing acc on the full test fold, both directions
8
+ (B) emergent text<->mIF: text head trained ONLY on PathGen H&E-caption pairs with the same anchor
9
+ (runs/text_pathgen/<anchor>_<txt>, 12_train_text_pathgen.py) scores the concept prompts against the ORION
10
+ mIF embeddings of that anchor's run (text and mIF never co-occur in training); AUROC + Spearman (18_eval_fm.probe)
11
+ Outputs results/anchor_ifret.csv, results/anchor_text_probes.csv
12
+ """
13
+ import glob
14
+ import json
15
+ import os
16
+ import sys
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+ import torch
21
+ import torch.nn.functional as F
22
+
23
+ from common import CACHE, ROOT, RUNS, metrics
24
+
25
+ ANCHORS = ["uni2", "gigapath", "conch"]
26
+ OUT = os.path.join(ROOT, "results")
27
+
28
+ if "--prep" in sys.argv:
29
+ P = pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv"))
30
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv")).set_index("key")
31
+ ix = I.loc[P.image_path.map(lambda p: os.path.basename(p).rsplit(".", 1)[0]), "idx"].values
32
+ assert (I.image_path.values[ix] == P.image_path.values).all()
33
+ for a in ANCHORS:
34
+ f = os.path.join(CACHE, f"pathgen_he_{a}.npy")
35
+ if not os.path.exists(f):
36
+ np.save(f, np.load(os.path.join(CACHE, f"allimg_he_{a}.npy"))[ix])
37
+ print("saved", f)
38
+ sys.exit()
39
+
40
+ # reuse the probe of 18_eval_fm.py without running its main block
41
+ src = open(os.path.join(ROOT, "18_eval_fm.py")).read().split("# collect test embeddings")[0]
42
+ ns = {}
43
+ exec(compile(src, "18_eval_fm_head", "exec"), ns)
44
+ probe, prompts = ns["probe"], ns["prompts"]
45
+
46
+
47
+ def load_head(anchor, enc, din, dout):
48
+ h = torch.nn.Sequential(torch.nn.Linear(din, 512), torch.nn.GELU(), torch.nn.Dropout(0.1), torch.nn.Linear(512, 512),
49
+ torch.nn.GELU(), torch.nn.LayerNorm(512), torch.nn.Linear(512, dout))
50
+ h.load_state_dict(torch.load(os.path.join(RUNS, "text_pathgen", f"{anchor}_{enc}", "head.pt"), map_location="cpu"))
51
+ return h.eval()
52
+
53
+
54
+ ret, prb = [], []
55
+ for f in sorted(glob.glob(os.path.join(RUNS, "bench_fm", "*", "test_embeddings.pt"))):
56
+ a = json.load(open(os.path.join(os.path.dirname(f), "train.json")))["args"]
57
+ if a["anchor"] not in ANCHORS or a["mk_enc"] != "fm:geneformer" or a["txt_enc"] != "pathgenclip":
58
+ continue
59
+ E = torch.load(f, weights_only=False) # our own files (contain numpy tile ids)
60
+ he, zi, tiles = E["z"]["he"], E["z"]["if"], np.asarray(E["tile_id"])
61
+ key = {"anchor": a["anchor"], "if_enc": a["if_enc"], "split": a["split"], "fold": a["fold"]}
62
+ for q, g, d in [(he, zi, "he->if"), (zi, he, "if->he")]:
63
+ ret.append({**key, "dir": d, **metrics(q, g)})
64
+ for t in ["biomedclip", "plip", "pathgenclip"]:
65
+ if not os.path.exists(os.path.join(RUNS, "text_pathgen", f"{a['anchor']}_{t}", "head.pt")):
66
+ continue
67
+ prm = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{t}.npy"))[:, 0].astype(np.float32))
68
+ with torch.no_grad():
69
+ zp = F.normalize(load_head(a["anchor"], t, prm.shape[1], he.shape[1])(prm), dim=-1)
70
+ for m, z in [("he", he), ("if", zi)]:
71
+ for i, (c, pr) in enumerate(prompts.items()):
72
+ prb.append({**key, "text_enc": t, "modality": m, "concept": c, **probe((z @ zp[i]).numpy(), tiles, c, pr["label"])})
73
+
74
+ R, P = pd.DataFrame(ret), pd.DataFrame(prb)
75
+ R.to_csv(os.path.join(OUT, "anchor_ifret.csv"), index=False)
76
+ P.to_csv(os.path.join(OUT, "anchor_text_probes.csv"), index=False)
77
+ pd.set_option("display.width", 250)
78
+ g = R.groupby(["if_enc", "anchor", "split", "fold"])[["R@1_g100", "pair_acc"]].mean().reset_index()
79
+ print(g.groupby(["if_enc", "anchor", "split"])[["R@1_g100", "pair_acc"]].agg(["mean", "std"]).round(3).to_string())
80
+ if len(P):
81
+ print(P.groupby(["text_enc", "modality", "if_enc", "anchor"])[["auroc", "spearman"]].mean().round(3).to_string())
experiments/29_virtual_completion.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Virtual modality completion: does training text <-> RNA / text <-> protein DIRECTLY on virtual pairs beat the
2
+ emergent (H&E-anchor) route?
3
+
4
+ Training pairs (TCGA all-pairs pool, 6,144 patches, every real text of a patch is a positive):
5
+ rna : real text <-> STPath virtual RNA of the same patch (cache/stpath/tcga_text_pred.npy -> log1p CP10k, 2000 HEST HVGs)
6
+ protein : real text <-> GigaTIME virtual mIF of the same patch (cache/gigatime/tcga_text_means.npy, 11 channels shared with ORION)
7
+ Model: frozen BiomedCLIP text tower + MLP head and an MLP head on the modality vector -> shared 512-d space
8
+ (CLIP-style, multi-positive InfoNCE), patient GroupKFold(5) over TCGA, 10% of training patients for early stopping.
9
+ Each feature is rank-gaussianised within its own dataset (virtual TCGA / real HEST / real ORION), because virtual
10
+ values (STPath expected counts, GigaTIME probabilities) and real measurements live on different scales.
11
+
12
+ Evaluation only on REAL measurements (never virtual):
13
+ rna : HEST real RNA, concept prompts vs RNA of the concept gene (protocol of 24_eval_gene_emergent.py (B):
14
+ AUROC top quartile, per HEST study+patient fold, all spots / bowel spots)
15
+ protein : ORION real mIF tile means, concept prompts vs marker+ cell fraction (probe of 18_eval_fm.py, per
16
+ patient_5fold test fold); also ORION GigaTIME virtual means (same tiles) to separate the virtual->real gap
17
+ Baselines (emergent, read from results/): text head trained on PathGen H&E-caption pairs, scored against the RNA
18
+ (23_) / VirTues mIF (17_) embeddings bound to UNI2.
19
+ Outputs results/virtual_completion.csv
20
+ """
21
+ import glob
22
+ import json
23
+ import os
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ import scipy.sparse as sp
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+ from scipy.special import ndtri
32
+ from scipy.stats import rankdata, spearmanr
33
+ from sklearn.metrics import roc_auc_score
34
+ from sklearn.model_selection import GroupKFold
35
+
36
+ from common import CACHE, ROOT, RUNS, SPLITS, load_all_tiles
37
+
38
+ dev = "cuda"
39
+ OUT = os.path.join(ROOT, "results")
40
+ TXT_ENC = "biomedclip"
41
+ prompts = json.load(open(os.path.join(ROOT, "prompts.json")))
42
+ PKEYS = list(prompts)
43
+
44
+
45
+ def rankgauss(x):
46
+ r = np.apply_along_axis(rankdata, 0, x)
47
+ return ndtri((r - 0.5) / len(x)).astype(np.float32)
48
+
49
+
50
+ # ---------------- TCGA training pool (texts, images, virtual modalities)
51
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
52
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
53
+ TX = torch.from_numpy(np.load(os.path.join(CACHE, f"alltxt_text_{TXT_ENC}.npy"))[:, 0].astype(np.float32)).to(dev)
54
+ PR = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{TXT_ENC}.npy"))[:, 0].astype(np.float32)).to(dev)
55
+ t_img = torch.as_tensor(T.img_idx.values, device=dev)
56
+ assert (pd.read_csv(os.path.join(CACHE, "stpath", "tcga_text_meta.csv")).key.values == I.key.values).all()
57
+ assert (pd.read_csv(os.path.join(CACHE, "gigatime", "tcga_text_meta.csv")).key.values == I.key.values).all()
58
+
59
+ # RNA: STPath log1p(counts) -> CP10k -> log1p, HEST HVGs
60
+ H = os.path.join(CACHE, "hest")
61
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
62
+ sg = pd.read_csv(os.path.join(CACHE, "stpath", "tcga_text_genes.csv")).gene.values
63
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", "tcga_text_pred.npy")).astype(np.float32)).clip(0)
64
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
65
+ gix = pd.Series(range(len(sg)), index=sg).loc[hvg].values
66
+ V_RNA = rankgauss(c[:, gix])
67
+ del c
68
+
69
+ # protein: GigaTIME channels shared with ORION (Actin-D + Transgelin -> SMA)
70
+ ch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))
71
+ G_CH, SHARED = ch["channels"], ch["orion_shared"]
72
+ ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
73
+ "CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"] # order of orion_real_if_means.npy (25_)
74
+ P_ORION = sorted(set(SHARED.values()), key=ORION_CH.index)
75
+
76
+
77
+ def giga_vec(means):
78
+ 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)
79
+
80
+
81
+ V_PROT = rankgauss(giga_vec(np.load(os.path.join(CACHE, "gigatime", "tcga_text_means.npy"))))
82
+
83
+ # ---------------- real evaluation sets
84
+ M = pd.read_csv(os.path.join(H, "meta.csv"))
85
+ hest_x = rankgauss(np.load(os.path.join(H, "hvg.npy")).astype(np.float32))
86
+ genes = pd.read_csv(os.path.join(H, "genes.csv")).gene.values
87
+ X = sp.load_npz(os.path.join(H, "counts.npz")).tocsr()
88
+ L = sp.diags(1e4 / np.asarray(X.sum(1)).ravel()) @ X
89
+ L.data = np.log1p(L.data)
90
+ gpos = {g: i for i, g in enumerate(genes)}
91
+ rna = lambda gs: np.asarray(L[:, [gpos[g] for g in gs]].mean(1)).ravel()
92
+ CONCEPT_RNA = {"immune": ["PTPRC"], "tumor_epith": ["KRT8", "EPCAM"], "stroma": ["ACTA2"], "proliferation": ["MKI67"],
93
+ "vessels": ["PECAM1"], "macrophage": ["CD68"]} # as 24_
94
+ hest_fold = np.full(len(M), -1)
95
+ for f in glob.glob(os.path.join(RUNS, "gene", "uni2_mlp_fold*", "test.pt")):
96
+ hest_fold[torch.load(f, weights_only=False)["idx"]] = int(f.split("_fold")[1].split(os.sep)[0])
97
+ assert (hest_fold >= 0).all()
98
+
99
+ df = load_all_tiles()
100
+ nn_ = pd.read_csv(os.path.join(OUT, "tile_stats.csv")).set_index("tile_id").loc[df.tile_id, "n_nuclei"].values
101
+ df["n_nuclei"] = nn_
102
+ orion_real = rankgauss(np.load(os.path.join(CACHE, "gigatime", "orion_real_if_means.npy"))[:, [ORION_CH.index(o) for o in P_ORION]])
103
+ orion_virt = rankgauss(giga_vec(np.load(os.path.join(CACHE, "gigatime", "orion_means.npy"))))
104
+ assert (pd.read_csv(os.path.join(CACHE, "gigatime", "orion_meta.csv")).tile_id.values == df.tile_id.values).all()
105
+ orion_fold = np.full(len(df), -1)
106
+ for k in range(5):
107
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
108
+ orion_fold[s.tile_id[s.split == "test"].values] = k
109
+
110
+
111
+ def orion_probe(scores, sel, key, label_col):
112
+ """18_eval_fm.probe restricted to tile mask `sel`"""
113
+ sub = sel if key == "cellularity" else sel & (nn_ >= 20)
114
+ y = df[label_col].values[sub].astype(float)
115
+ if label_col != "n_nuclei":
116
+ y = y / np.maximum(nn_[sub], 1)
117
+ lab = y >= np.quantile(y, 0.75)
118
+ return {"spearman": spearmanr(scores[sub], y).correlation,
119
+ "auroc": roc_auc_score(lab, scores[sub]) if 0 < lab.sum() < lab.size else np.nan}
120
+
121
+
122
+ # ---------------- model
123
+ def mlp(din, dout=512, hid=512): # same head as 20_pairing.py
124
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
125
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
126
+
127
+
128
+ def r1(a, b):
129
+ s = a @ b.T
130
+ return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
131
+
132
+
133
+ def train(V, tr_t, va_t, epochs=60, bs=256, lr=3e-4, seed=0):
134
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
135
+ th, mh = mlp(TX.shape[1]).to(dev), mlp(V.shape[1]).to(dev)
136
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
137
+ opt = torch.optim.AdamW(list(th.parameters()) + list(mh.parameters()) + [logit], lr=lr, weight_decay=0.05)
138
+ sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr_t) // bs), pct_start=0.05)
139
+ va = T.loc[va_t].groupby("img_idx").head(1).index.values # one text per val image
140
+ va_i, va_t1 = torch.as_tensor(T.img_idx.values[va], device=dev), torch.as_tensor(va, device=dev)
141
+ best, state = -1, None
142
+ for ep in range(epochs):
143
+ th.train(); mh.train()
144
+ perm = rng.permutation(tr_t)
145
+ for s in range(0, len(perm) - bs + 1, bs):
146
+ b = torch.as_tensor(perm[s:s + bs], device=dev)
147
+ ii = t_img[b]
148
+ lg = logit.exp().clamp(max=100) * F.normalize(mh(V[ii]), dim=-1) @ F.normalize(th(TX[b]), dim=-1).T
149
+ pos = (ii[:, None] == ii[None, :]).float()
150
+ tgt = pos / pos.sum(1, keepdim=True)
151
+ loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
152
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
153
+ th.eval(); mh.eval()
154
+ with torch.no_grad():
155
+ zv, zt = F.normalize(mh(V[va_i]), dim=-1), F.normalize(th(TX[va_t1]), dim=-1)
156
+ v = (r1(zv, zt) + r1(zt, zv)) / 2
157
+ if v > best:
158
+ 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()})
159
+ th.load_state_dict(state[0]); mh.load_state_dict(state[1]); th.eval(); mh.eval()
160
+ return th, mh, best
161
+
162
+
163
+ @torch.no_grad()
164
+ def score(th, mh, x):
165
+ zp = F.normalize(th(PR), dim=-1)
166
+ z = F.normalize(mh(torch.from_numpy(x).to(dev)), dim=-1)
167
+ return (z @ zp.T).cpu().numpy() # (n, n_prompts)
168
+
169
+
170
+ rows = []
171
+ folds = list(GroupKFold(5).split(I, groups=I.patient))
172
+ for mod, Vnp in [("rna", V_RNA), ("protein", V_PROT)]:
173
+ V = torch.from_numpy(Vnp).to(dev)
174
+ for k, (trv, te) in enumerate(folds):
175
+ pats = np.random.default_rng(k).permutation(sorted(I.patient.iloc[trv].unique()))
176
+ va_p = set(pats[: max(1, len(pats) // 10)])
177
+ isplit = pd.Series("test", index=I.index)
178
+ isplit.iloc[trv] = np.where(I.patient.iloc[trv].isin(va_p), "val", "train")
179
+ ts = isplit.loc[T.img_idx].values
180
+ tr_t, va_t, te_t = (T.index[ts == s].values for s in ["train", "val", "test"])
181
+ th, mh, vbest = train(V, tr_t, va_t)
182
+ # in-domain sanity: held-out TCGA patients, text <-> virtual modality, 1 text per image, R@1 on 100-galleries
183
+ te1 = T.loc[te_t].groupby("img_idx").head(1).index.values
184
+ with torch.no_grad():
185
+ zv = F.normalize(mh(V[T.img_idx.values[te1]]), dim=-1).cpu()
186
+ zt = F.normalize(th(TX[te1]), dim=-1).cpu()
187
+ g = np.random.default_rng(0)
188
+ 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))])
189
+ rows.append({"modality": mod, "model_fold": k, "eval": "tcga_virtual_R@1_g100", "concept": "-", "auroc": np.nan, "spearman": r, "val_r1": vbest})
190
+ if mod == "rna":
191
+ S = score(th, mh, hest_x)
192
+ for c, gs in CONCEPT_RNA.items():
193
+ y_all = rna(gs)
194
+ for gal in ["all", "bowel"]:
195
+ gm = np.ones(len(M), bool) if gal == "all" else (M.organ == "Bowel").values
196
+ for hk in range(5):
197
+ sel = gm & (hest_fold == hk)
198
+ y = y_all[sel]; lab = y >= np.quantile(y, 0.75)
199
+ s = S[sel, PKEYS.index(c)]
200
+ rows.append({"modality": mod, "model_fold": k, "eval": f"hest_real_{gal}", "concept": c, "eval_fold": hk,
201
+ "auroc": roc_auc_score(lab, s) if 0 < lab.sum() < lab.size else np.nan, "spearman": spearmanr(s, y).correlation})
202
+ else:
203
+ for src, x in [("orion_real", orion_real), ("orion_virtual", orion_virt)]:
204
+ S = score(th, mh, x)
205
+ for i, (c, pr) in enumerate(prompts.items()):
206
+ for ok in range(5):
207
+ rows.append({"modality": mod, "model_fold": k, "eval": src, "concept": c, "eval_fold": ok,
208
+ **orion_probe(S[:, i], orion_fold == ok, c, pr["label"])})
209
+ print(mod, "fold", k, "val", round(vbest, 3), "tcga R@1_g100", round(r, 3), flush=True)
210
+
211
+ R = pd.DataFrame(rows)
212
+ R.to_csv(os.path.join(OUT, "virtual_completion.csv"), index=False)
213
+
214
+ # ---------------- comparison with the emergent route (same prompts, same text encoder, same real labels)
215
+ pd.set_option("display.width", 250)
216
+ print(R[R["eval"] == "tcga_virtual_R@1_g100"].groupby("modality").spearman.agg(["mean", "std"]).round(3))
217
+ D = R[R["eval"] != "tcga_virtual_R@1_g100"]
218
+ print(D.groupby(["modality", "eval", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
219
+ print(D.groupby(["modality", "eval"]).auroc.mean().round(3).to_string())
220
+ E = pd.read_csv(os.path.join(OUT, "gene_emergent_text.csv"))
221
+ E = E[E.text_enc == TXT_ENC]
222
+ print("emergent text<->RNA (HEST real):\n", E.groupby(["gallery", "modality", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
223
+ A = pd.read_csv(os.path.join(OUT, "anchor_text_probes.csv"))
224
+ A = A[(A.text_enc == TXT_ENC) & (A.split == "patient_5fold") & (A.anchor == "uni2")]
225
+ print("emergent text<->protein (ORION real, VirTues):\n", A.groupby(["modality", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
226
+ # single-marker reference on ORION real: the matched channel itself as the score
227
+ REF = {"immune": "CD3e", "tumor_epith": "Pan-CK", "stroma": "SMA", "proliferation": "Ki67", "macrophage": "CD68", "cellularity": "Hoechst"}
228
+ 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)])
229
+ for c, ch_ in REF.items()}
230
+ print("single real channel as score (reference):", {c: round(v, 3) for c, v in ref.items()})
experiments/30_virtues_shared.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VirTues embeddings on the protein panel shared by ORION (real mIF) and GigaTIME (virtual mIF from H&E), so that one
2
+ mIF head trained on REAL ORION data can embed virtual mIF for validation (virtual data is never used for training).
3
+
4
+ Shared panel (ORION name <- GigaTIME channel): Hoechst<-DAPI, CD68, CD4, CD8a<-CD8, CD20, PD-L1, CD3e<-CD3, Ki67,
5
+ Pan-CK<-CK, SMA<-mean(Actin-D, Transgelin). PD-1 is left out (ORION PD-1 export is saturated / near constant).
6
+
7
+ Usage: python 30_virtues_shared.py real -> cache/if_virtues_shared.npy (3187, 8, 512) 8 dihedral views
8
+ python 30_virtues_shared.py virtual orion|tcga_text -> cache/if_virtues_shared_virtual_<set>.npy (N, 1, 512) view 0
9
+ python 30_virtues_shared.py virtual_qm orion,tcga_text -> cache/if_virtues_shared_virtual_qm_<set>_fold<k>.npy
10
+ (virtual pixels quantile-mapped per channel onto the real fold-k TRAIN pixel distribution)
11
+ --m16: 16-channel ORION panel shared with MIPHEI-ViT (all ORION markers but PD-1):
12
+ python 30_virtues_shared.py real --m16 -> cache/if_virtues_m16.npy (3187, 8, 512)
13
+ python 30_virtues_shared.py miphei <set> --m16 -> cache/if_virtues_m16_virtual_miphei_<set>.npy (N, 1, 512)
14
+ Preprocessing = 14_extract_if_fm.py (per-slide 0.99-quantile clip, log1p, dataset mean/std, 3x3 Gaussian blur, ~1 um/px):
15
+ real : ORION 333 px tiles (166 um) -> 168 px
16
+ virtual : GigaTIME maps (128 px; ORION tile 166 um, TCGA 672-px patch at ~0.25 um/px = 168 um) -> 168 px; values are
17
+ the uint8 sigmoid probabilities (0-255), standardised within their own set (slide = ORION slide / TCGA WSI)
18
+ """
19
+ import importlib.util
20
+ import json
21
+ import os
22
+ import sys
23
+ import time
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ import torch
28
+ import torch.nn.functional as F
29
+
30
+ from common import CACHE, ROOT, dihedral, load_all_tiles
31
+
32
+ MODE = sys.argv[1]
33
+ _argv, sys.argv = sys.argv, [sys.argv[0], "virtues", "--no_probe"] # 14_ parses its CLI at import time
34
+ spec = importlib.util.spec_from_file_location("virt14", os.path.join(ROOT, "14_extract_if_fm.py"))
35
+ V = importlib.util.module_from_spec(spec); spec.loader.exec_module(V)
36
+ sys.argv = _argv
37
+ dev, SIZE = V.dev, V.SIZE
38
+
39
+ SHARED = [("Hoechst", ["DAPI"]), ("CD68", ["CD68"]), ("CD4", ["CD4"]), ("CD8a", ["CD8"]), ("CD20", ["CD20"]),
40
+ ("PD-L1", ["PD-L1"]), ("CD3e", ["CD3"]), ("Ki67", ["Ki67"]), ("Pan-CK", ["CK"]), ("SMA", ["Actin-D", "Transgelin"])]
41
+ ORION_NAMES = [m for m, _ in V.MARKERS]
42
+ UNIPROT = dict(V.MARKERS)
43
+ M16 = "--m16" in sys.argv # 16-channel panel shared by ORION and MIPHEI-ViT (all ORION markers except PD-1)
44
+ if M16:
45
+ sys.argv.remove("--m16")
46
+ SHARED = [(m, [m]) for m in ORION_NAMES if m != "PD-1"]
47
+ SUF = "m16" if M16 else "shared"
48
+ ids = [UNIPROT[m] for m, _ in SHARED]
49
+
50
+ t0 = time.time()
51
+
52
+
53
+ def load_virtual(st):
54
+ gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
55
+ maps = np.load(os.path.join(CACHE, "gigatime", f"{st}_maps.npy"), mmap_mode="r") # N,21,128,128 uint8
56
+ meta = pd.read_csv(os.path.join(CACHE, "gigatime", f"{st}_meta.csv"))
57
+ N = len(maps)
58
+ X = np.empty((N, len(SHARED), SIZE, SIZE), np.float16)
59
+ for s in range(0, N, 256):
60
+ m = torch.from_numpy(np.asarray(maps[s:s + 256])).to(dev).float()
61
+ x = torch.stack([m[:, [gch.index(g) for g in gs]].mean(1) for _, gs in SHARED], 1)
62
+ X[s:s + 256] = F.interpolate(x, size=(SIZE, SIZE), mode="bilinear", align_corners=False).clamp(min=0).half().cpu().numpy()
63
+ if st == "orion":
64
+ df = load_all_tiles()
65
+ assert (meta.tile_id.values == df.tile_id.values).all()
66
+ slide = df.in_slide_name.values
67
+ else:
68
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
69
+ assert (meta.key.values == I.key.values).all()
70
+ slide = I.wsi_id.values
71
+ return X, slide
72
+
73
+
74
+ def encode(X, slide, views, out_f):
75
+ X, uq, mean, std, valid, slide_k, n_zero = V.standardize(X, slide)
76
+ print(" log1p mean", np.round(mean, 2).tolist(), "\n std", np.round(std, 2).tolist(), "| zero-uq (slide,ch):", int(n_zero.sum()))
77
+ model, midx = V.load_virtues(ids)
78
+ midx = midx.to(dev)
79
+ N = len(X)
80
+ out = np.zeros((N, views, 512), np.float16)
81
+ BS = 4 if views == 8 else 32 # 32 crops per forward (~4.7 GiB peak, shared GPU)
82
+ with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
83
+ for s in range(0, N, BS):
84
+ x = torch.from_numpy(X[s:s + BS]).to(dev).float()
85
+ crops = [dihedral(x, d)[i] for d in range(views) for i in range(len(x))] # d-major
86
+ ps = model.encoder.forward_list(crops, [midx] * len(crops)).patch_summary_tokens
87
+ e = torch.stack([p.float().mean((0, 1)) for p in ps]).view(views, len(x), -1).transpose(0, 1)
88
+ out[s:s + BS] = e.cpu().numpy().astype(np.float16)
89
+ np.save(out_f, out)
90
+ print(f"saved {out_f} {out.shape} finite {np.isfinite(out).all()} [{time.time() - t0:.0f}s]", flush=True)
91
+
92
+
93
+ if MODE == "miphei":
94
+ # MIPHEI virtual mIF (35_miphei_infer.py): already on the 8-bit ORION scale and resampled to SIZE like the real tiles
95
+ assert M16
96
+ st = sys.argv[2]
97
+ ch = json.load(open(os.path.join(CACHE, "miphei", "channels.json")))["channels"]
98
+ X = np.load(os.path.join(CACHE, "miphei", f"{st}_maps168.npy"))[:, [ch.index(m) for m, _ in SHARED]].astype(np.float16)
99
+ if st == "orion":
100
+ slide = load_all_tiles().in_slide_name.values
101
+ else:
102
+ slide = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv")).wsi_id.values if st == "tcga_text" else \
103
+ pd.read_csv(os.path.join(CACHE, "external", f"{st}_meta.csv"))["sample"].astype(str).values
104
+ assert len(slide) == len(X)
105
+ encode(X, slide, 1, os.path.join(CACHE, f"if_virtues_m16_virtual_miphei_{st}.npy"))
106
+ sys.exit(0)
107
+
108
+ if MODE == "virtual_qm":
109
+ # channel-UNIFIED virtual mIF: per channel, quantile-map the GigaTIME probability pixels onto the REAL ORION pixel
110
+ # intensity distribution of the fold-k TRAINING tiles (monotone, keeps the spatial pattern), then the real
111
+ # preprocessing + VirTues. Only real training-split pixels define the target distribution; nothing is trained.
112
+ sets = sys.argv[2].split(",")
113
+ df = load_all_tiles()
114
+ X17, _ = V.load_resampled(df)
115
+ XR = X17[:, [ORION_NAMES.index(m) for m, _ in SHARED]]; del X17
116
+ P = np.linspace(0, 1, 1001)
117
+ rng = np.random.default_rng(0)
118
+ ref = {}
119
+ for k in range(5):
120
+ sp = pd.read_csv(os.path.join(ROOT, "splits", "patient_5fold", f"fold{k}.csv"))
121
+ tr = sp.tile_id[sp.split == "train"].values
122
+ sub = rng.choice(tr, min(400, len(tr)), replace=False)
123
+ ref[k] = np.stack([np.quantile(XR[sub, c].astype(np.float32).ravel(), P) for c in range(len(SHARED))])
124
+ del XR
125
+ np.savez(os.path.join(CACHE, "gigatime", "qm_ref_real_train.npz"), P=P, **{f"fold{k}": v for k, v in ref.items()})
126
+ for st in sets:
127
+ X0, slide = load_virtual(st)
128
+ sub = rng.choice(len(X0), min(400, len(X0)), replace=False)
129
+ vq = np.stack([np.quantile(X0[sub, c].astype(np.float32).ravel(), P) for c in range(len(SHARED))])
130
+ vq = vq + np.arange(len(P))[None] * 1e-6 # strictly increasing despite the many zero-probability ties
131
+ for k in range(5):
132
+ X = np.empty_like(X0)
133
+ for s in range(0, len(X0), 128):
134
+ x = X0[s:s + 128].astype(np.float32)
135
+ for c in range(len(SHARED)):
136
+ x[:, c] = np.interp(x[:, c], vq[c], ref[k][c])
137
+ X[s:s + 128] = x.astype(np.float16)
138
+ print(f"[{time.time() - t0:.0f}s] {st} fold {k}: quantile-mapped", flush=True)
139
+ encode(X, slide, 1, os.path.join(CACHE, f"if_virtues_shared_virtual_qm_{st}_fold{k}.npy"))
140
+ del X
141
+ sys.exit(0)
142
+
143
+ if MODE == "real":
144
+ df = load_all_tiles()
145
+ X17, _ = V.load_resampled(df)
146
+ X = np.ascontiguousarray(X17[:, [ORION_NAMES.index(m) for m, _ in SHARED]]); del X17
147
+ slide, views, out_f = df.in_slide_name.values, 8, os.path.join(CACHE, f"if_virtues_{SUF}.npy")
148
+ elif MODE == "virtual":
149
+ st = sys.argv[2]
150
+ gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
151
+ maps = np.load(os.path.join(CACHE, "gigatime", f"{st}_maps.npy"), mmap_mode="r") # N,21,128,128 uint8
152
+ meta = pd.read_csv(os.path.join(CACHE, "gigatime", f"{st}_meta.csv"))
153
+ N = len(maps)
154
+ X = np.empty((N, len(SHARED), SIZE, SIZE), np.float16)
155
+ for s in range(0, N, 256):
156
+ m = torch.from_numpy(np.asarray(maps[s:s + 256])).to(dev).float()
157
+ x = torch.stack([m[:, [gch.index(g) for g in gs]].mean(1) for _, gs in SHARED], 1)
158
+ X[s:s + 256] = F.interpolate(x, size=(SIZE, SIZE), mode="bilinear", align_corners=False).clamp(min=0).half().cpu().numpy()
159
+ if st == "orion":
160
+ df = load_all_tiles()
161
+ assert (meta.tile_id.values == df.tile_id.values).all()
162
+ slide = df.in_slide_name.values
163
+ else:
164
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
165
+ assert (meta.key.values == I.key.values).all()
166
+ slide = I.wsi_id.values
167
+ views, out_f = 1, os.path.join(CACHE, f"if_virtues_shared_virtual_{st}.npy")
168
+ else:
169
+ raise ValueError(MODE)
170
+
171
+ print(f"[{time.time() - t0:.0f}s] loaded {X.shape}; standardising", flush=True)
172
+ X, uq, mean, std, valid, slide_k, n_zero = V.standardize(X, slide)
173
+ print(" log1p mean", np.round(mean, 2).tolist(), "\n std", np.round(std, 2).tolist(), "| zero-uq (slide,ch):", int(n_zero.sum()))
174
+ model, midx = V.load_virtues(ids)
175
+ midx = midx.to(dev)
176
+ N = len(X)
177
+ out = np.zeros((N, views, 512), np.float16)
178
+ BS = 4 if views == 8 else 32 # 32 crops per forward (~4.7 GiB peak, shared GPU)
179
+ with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
180
+ for s in range(0, N, BS):
181
+ x = torch.from_numpy(X[s:s + BS]).to(dev).float()
182
+ crops = [dihedral(x, d)[i] for d in range(views) for i in range(len(x))] # d-major
183
+ ps = model.encoder.forward_list(crops, [midx] * len(crops)).patch_summary_tokens
184
+ e = torch.stack([p.float().mean((0, 1)) for p in ps]).view(views, len(x), -1).transpose(0, 1)
185
+ out[s:s + BS] = e.cpu().numpy().astype(np.float16)
186
+ if s % (BS * 50) == 0:
187
+ print(f" encoded {s}/{N} [{time.time() - t0:.0f}s]", flush=True)
188
+ np.save(out_f, out)
189
+ print(f"saved {out_f} {out.shape} finite {np.isfinite(out).all()} [{time.time() - t0:.0f}s]")
experiments/31_virtual_validation.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate the 4-modality ImageBind model with VIRTUAL modalities (validation only; every encoder/head was trained
2
+ on real pairs: H&E-mIF ORION (17_), H&E-text PathGen (12_), H&E-RNA HEST (23_); H&E = frozen UNI2).
3
+
4
+ Virtual data fill the modality pairs that no dataset measures together, giving quadruplets:
5
+ ORION : H&E (real) | mIF (real) | RNA (STPath, virtual) | text (GPT synthetic caption of the H&E, 27_/32_)
6
+ TCGA : H&E (real) | text (real) | mIF (GigaTIME, virtual) | RNA (STPath, virtual)
7
+ HEST : H&E (real) | RNA (real) + RNA (STPath, virtual) -> calibration of the virtual RNA
8
+ ORION : mIF real vs mIF virtual (GigaTIME) on the same tiles -> calibration of the virtual mIF
9
+ Every embedding lives in the UNI2 space, so any two modalities are compared by cosine similarity.
10
+
11
+ Heads: RNA runs/gene/uni2_mlp_fold<k>/head.pt (MLP on log1p-CP10k of the 2000 HEST HVGs; HEST fold k)
12
+ mIF runs/bench_fm/patient_5fold_fold<k>_uni2_fm-virtues[_shared]_fm-geneformer_pathgenclip/heads.pt
13
+ (17-channel VirTues for real ORION; 10-channel shared panel (30_) for real-vs-virtual / TCGA)
14
+ text runs/text_pathgen/uni2_biomedclip/head.pt (PathGen captions); TCGA evaluation images exclude every
15
+ patient of its train/val split
16
+ Metrics on random 100-item galleries (50 draws): R@1 (tie-aware, both directions averaged) and one-to-one Hungarian
17
+ pairing accuracy (chance 0.01). "circular" = both sides are derived from the same H&E image (virtual-from-H&E), so the
18
+ score is an upper bound of what real data would give, not evidence of biological agreement.
19
+ Outputs results/virtual_validation.csv
20
+ """
21
+ import os
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from scipy.optimize import linear_sum_assignment
29
+
30
+ from common import CACHE, ROOT, RUNS, SPLITS, load_all_tiles
31
+
32
+ OUT = os.path.join(ROOT, "results")
33
+ H = os.path.join(CACHE, "hest")
34
+ G100, DRAWS = 100, 50
35
+
36
+
37
+ def mlp(din, dout=1536, hid=512):
38
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
39
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
40
+
41
+
42
+ def load_mlp(sd):
43
+ h = mlp(sd["0.weight"].shape[1]); h.load_state_dict(sd); return h.eval()
44
+
45
+
46
+ def nz(x):
47
+ return F.normalize(torch.as_tensor(np.asarray(x), dtype=torch.float32), dim=-1)
48
+
49
+
50
+ def g100(a, b, seed=0):
51
+ """mean over random 100-galleries of tie-aware R@1 (both directions) and Hungarian pairing accuracy"""
52
+ rng = np.random.default_rng(seed)
53
+ r, p = [], []
54
+ for _ in range(DRAWS):
55
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
56
+ sim = (a[s] @ b[s].T).numpy()
57
+ d = np.diag(sim)[:, None]
58
+ r.append(((sim > d).sum(1) == 0).mean() / 2 + ((sim.T > d).sum(1) == 0).mean() / 2)
59
+ ri, ci = linear_sum_assignment(-sim)
60
+ p.append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
61
+ return float(np.mean(r)), float(np.mean(p))
62
+
63
+
64
+ rows = []
65
+
66
+
67
+ def add(dataset, fold, pair, za, zb, kind, circular):
68
+ r, p = g100(za, zb)
69
+ rows.append({"dataset": dataset, "fold": fold, "pair": pair, "kind": kind, "circular": circular, "n": len(za),
70
+ "R@1_g100": r, "pair_acc_g100": p})
71
+
72
+
73
+ # ---------------- RNA: STPath log1p(counts) -> log1p CP10k on the HEST HVGs (same input as the HEST-trained head)
74
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
75
+
76
+
77
+ def virtual_rna(st):
78
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
79
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
80
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
81
+ return torch.from_numpy(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values])
82
+
83
+
84
+ rna_heads = [load_mlp(torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "head.pt"), map_location="cpu")) for k in range(5)]
85
+ text_head = load_mlp(torch.load(os.path.join(RUNS, "text_pathgen", "uni2_biomedclip", "head.pt"), map_location="cpu"))
86
+
87
+
88
+ def mif_heads(enc, k, split="patient_5fold"):
89
+ f = os.path.join(RUNS, "bench_fm", f"{split}_fold{k}_uni2_fm-{enc}_fm-geneformer_pathgenclip", "heads.pt")
90
+ if not os.path.exists(f):
91
+ return None
92
+ d = torch.load(f, map_location="cpu")
93
+ h = load_mlp(d["state"]["if"])
94
+ return lambda x: h((torch.as_tensor(x, dtype=torch.float32) - d["if_mean"]) / d["if_std"])
95
+
96
+
97
+ # ---------------- (A) HEST: H&E <-> real RNA vs H&E <-> virtual RNA, same spots, HEST test folds
98
+ M = pd.read_csv(os.path.join(H, "meta.csv"))
99
+ hv = pd.read_csv(os.path.join(CACHE, "stpath", "hest_val_meta.csv"))
100
+ assert (hv["sample"].values == M["sample"].values).all() and (hv.spot.values == M.spot.values).all()
101
+ he_h = np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32)
102
+ real_h = torch.from_numpy(np.load(os.path.join(H, "hvg.npy")).astype(np.float32))
103
+ virt_h = virtual_rna("hest_val")
104
+ with torch.no_grad():
105
+ for k in range(5):
106
+ te = torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "test.pt"), weights_only=False)["idx"]
107
+ zr, zv, zh = nz(rna_heads[k](real_h[te])), nz(rna_heads[k](virt_h[te])), nz(he_h[te])
108
+ for sub, m in [("all", np.ones(len(te), bool)), ("STPath-heldout", hv.hest_bench_heldout.values[te])]:
109
+ if m.sum() < G100:
110
+ continue
111
+ add(f"HEST ({sub})", k, "H&E<->RNA real", zh[m], zr[m], "trained (real)", False)
112
+ add(f"HEST ({sub})", k, "H&E<->RNA virtual", zh[m], zv[m], "virtual calibration", True)
113
+ add(f"HEST ({sub})", k, "RNA real<->RNA virtual", zr[m], zv[m], "virtual calibration", False)
114
+ print("HEST done", flush=True)
115
+
116
+ # ---------------- (B) ORION: patient 5-fold test tiles
117
+ df = load_all_tiles()
118
+ assert (pd.read_csv(os.path.join(CACHE, "stpath", "orion_meta.csv")).key.values == df.image_path.values).all()
119
+ he_o = np.load(os.path.join(CACHE, "he_uni2.npy"))[:, 0].astype(np.float32)
120
+ rna_o = virtual_rna("orion")
121
+ if17 = np.load(os.path.join(CACHE, "if_virtues.npy"))[:, 0].astype(np.float32)
122
+ f_sh = os.path.join(CACHE, "if_virtues_shared.npy")
123
+ f_vo = os.path.join(CACHE, "if_virtues_shared_virtual_orion.npy")
124
+ ifs_r = np.load(f_sh)[:, 0].astype(np.float32) if os.path.exists(f_sh) else None
125
+ ifs_v = np.load(f_vo)[:, 0].astype(np.float32) if os.path.exists(f_vo) else None
126
+ f_gt = os.path.join(CACHE, "orion_gentext_text_biomedclip.npy") # synthetic GPT captions of the ORION H&E (32_ + 08_)
127
+ gt_o = torch.from_numpy(np.load(f_gt)[:, 0].astype(np.float32)) if os.path.exists(f_gt) else None
128
+ with torch.no_grad():
129
+ for k in range(5):
130
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
131
+ te = s.tile_id[s.split == "test"].values
132
+ zh, zr = nz(he_o[te]), nz(rna_heads[k](rna_o[te]))
133
+ add("ORION", k, "H&E<->RNA virtual", zh, zr, "virtual (cross-dataset head)", True)
134
+ h17 = mif_heads("virtues", k)
135
+ if h17 is not None:
136
+ zp = nz(h17(if17[te]))
137
+ add("ORION", k, "H&E<->mIF real (17 ch)", zh, zp, "trained (real)", False)
138
+ add("ORION", k, "mIF real (17 ch)<->RNA virtual", zp, zr, "emergent, real x virtual", False)
139
+ hs = mif_heads("virtues_shared", k)
140
+ if hs is not None and ifs_r is not None:
141
+ zs = nz(hs(ifs_r[te]))
142
+ add("ORION", k, "H&E<->mIF real (10 ch)", zh, zs, "trained (real)", False)
143
+ add("ORION", k, "mIF real (10 ch)<->RNA virtual", zs, zr, "emergent, real x virtual", False)
144
+ if ifs_v is not None:
145
+ zv = nz(hs(ifs_v[te]))
146
+ add("ORION", k, "H&E<->mIF virtual (10 ch)", zh, zv, "virtual calibration", True)
147
+ add("ORION", k, "mIF real<->mIF virtual (10 ch)", zs, zv, "virtual calibration", False)
148
+ add("ORION", k, "mIF virtual<->RNA virtual", zv, zr, "emergent, virtual x virtual", True)
149
+ if gt_o is not None: # synthetic text is written from the same H&E -> circular with H&E and H&E-derived data
150
+ zt = nz(text_head(gt_o[te]))
151
+ add("ORION", k, "H&E<->text synthetic", zh, zt, "virtual (text head from PathGen)", True)
152
+ add("ORION", k, "text synthetic<->RNA virtual", zt, zr, "emergent, virtual x virtual", True)
153
+ if h17 is not None:
154
+ add("ORION", k, "text synthetic<->mIF real (17 ch)", zt, zp, "emergent, real x virtual", False)
155
+ if hs is not None and ifs_r is not None:
156
+ add("ORION", k, "text synthetic<->mIF real (10 ch)", zt, zs, "emergent, real x virtual", False)
157
+ print("ORION done", flush=True)
158
+
159
+ # ---------------- (C) TCGA quadruplets: H&E, real text, virtual mIF, virtual RNA
160
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
161
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
162
+ P = pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv"))
163
+ seen = set(P.patient[P.split.isin(["train", "val"])])
164
+ ev = I.index[~I.patient.isin(seen)].values
165
+ rng = np.random.default_rng(0)
166
+ t1 = T[T.img_idx.isin(ev)].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
167
+ ev = t1.index.values # images with >= 1 text, patients unseen by the text head
168
+ print(f"TCGA eval: {len(ev)} images, {I.patient.iloc[ev].nunique()} patients (excluded {len(seen)} text-head patients)", flush=True)
169
+ he_t = np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))[ev, 0].astype(np.float32)
170
+ tx = torch.from_numpy(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[t1.values, 0].astype(np.float32))
171
+ rna_t = virtual_rna("tcga_text")[ev]
172
+ f_vt = os.path.join(CACHE, "if_virtues_shared_virtual_tcga_text.npy")
173
+ ifv_t = np.load(f_vt)[ev, 0].astype(np.float32) if os.path.exists(f_vt) else None
174
+ with torch.no_grad():
175
+ zh, zt = nz(he_t), nz(text_head(tx))
176
+ for k in range(5):
177
+ zr = nz(rna_heads[k](rna_t))
178
+ Z = {"H&E": zh, "text": zt, "RNA virtual": zr}
179
+ hs = mif_heads("virtues_shared", k)
180
+ if hs is not None and ifv_t is not None:
181
+ Z["mIF virtual"] = nz(hs(ifv_t))
182
+ names = list(Z)
183
+ for i in range(len(names)):
184
+ for j in range(i + 1, len(names)):
185
+ a, b = names[i], names[j]
186
+ if (a, b) == ("H&E", "text") and k > 0:
187
+ continue # does not depend on the fold heads
188
+ virt = [x for x in (a, b) if "virtual" in x]
189
+ circ = len(virt) == 2 or (len(virt) == 1 and "H&E" in (a, b))
190
+ kind = "trained (real)" if (a, b) == ("H&E", "text") else \
191
+ "emergent, real x virtual" if len(virt) == 1 and "H&E" not in (a, b) else \
192
+ "emergent, virtual x virtual" if len(virt) == 2 else "virtual (cross-dataset head)"
193
+ add("TCGA", k, f"{a}<->{b}", Z[a], Z[b], kind, circ)
194
+ print("TCGA done", flush=True)
195
+
196
+ # ---------------- (D) mIF as 10 channel MEANS (GigaTIME per-tile means are reliable, its pixel maps are not):
197
+ # MLP head on rank-gaussianised shared-panel means, trained on REAL ORION (patient_5fold fold k train/val) vs UNI2 H&E;
198
+ # applied unchanged to virtual means (rank-gaussianised within their own set)
199
+ import json
200
+ from scipy.special import ndtri
201
+ from scipy.stats import rankdata
202
+
203
+ rg = lambda x: torch.from_numpy(ndtri((np.apply_along_axis(rankdata, 0, x) - 0.5) / len(x)).astype(np.float32))
204
+ ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
205
+ "CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"] # order of orion_real_if_means.npy (25_)
206
+ PANEL = [("Hoechst", ["DAPI"]), ("CD68", ["CD68"]), ("CD4", ["CD4"]), ("CD8a", ["CD8"]), ("CD20", ["CD20"]),
207
+ ("PD-L1", ["PD-L1"]), ("CD3e", ["CD3"]), ("Ki67", ["Ki67"]), ("Pan-CK", ["CK"]), ("SMA", ["Actin-D", "Transgelin"])] # = 30_
208
+ gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
209
+ gmean = lambda m: np.stack([m[:, [gch.index(g) for g in gs]].mean(1) for _, gs in PANEL], 1)
210
+ mr_o = rg(np.load(os.path.join(CACHE, "gigatime", "orion_real_if_means.npy"))[:, [ORION_CH.index(o) for o, _ in PANEL]])
211
+ mv_o = rg(gmean(np.load(os.path.join(CACHE, "gigatime", "orion_means.npy"))))
212
+ mv_t = rg(gmean(np.load(os.path.join(CACHE, "gigatime", "tcga_text_means.npy"))))[ev]
213
+ HE8 = torch.from_numpy(np.load(os.path.join(CACHE, "he_uni2.npy")).astype(np.float32))
214
+
215
+
216
+ def train_means_head(tr, va, epochs=150, bs=256, lr=1e-3, seed=0):
217
+ torch.manual_seed(seed); rng_ = np.random.default_rng(seed)
218
+ h = mlp(mr_o.shape[1])
219
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32))
220
+ opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=lr, weight_decay=0.05)
221
+ best, state = -1, None
222
+ for ep in range(epochs):
223
+ h.train()
224
+ perm = rng_.permutation(tr)
225
+ for s0 in range(0, len(perm) - bs + 1, bs):
226
+ b = torch.as_tensor(perm[s0:s0 + bs])
227
+ za = F.normalize(HE8[b, torch.randint(0, 8, (len(b),))], dim=-1)
228
+ lg = logit.exp().clamp(max=100) * za @ F.normalize(h(mr_o[b]), dim=-1).T
229
+ t = torch.arange(len(b))
230
+ loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
231
+ opt.zero_grad(); loss.backward(); opt.step()
232
+ h.eval()
233
+ with torch.no_grad():
234
+ a, m = nz(HE8[va, 0]), nz(h(mr_o[va]))
235
+ sim = a @ m.T
236
+ v = float(((sim > sim.diag()[:, None]).sum(1) == 0).float().mean())
237
+ if v > best:
238
+ best, state = v, {k_: x.clone() for k_, x in h.state_dict().items()}
239
+ h.load_state_dict(state); h.eval()
240
+ return h
241
+
242
+
243
+ for k in range(5):
244
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
245
+ tr, va, te = (s.tile_id[s.split == x].values for x in ["train", "val", "test"])
246
+ h = train_means_head(tr, va)
247
+ with torch.no_grad():
248
+ zh, zr = nz(he_o[te]), nz(rna_heads[k](rna_o[te]))
249
+ zmr, zmv = nz(h(mr_o[te])), nz(h(mv_o[te]))
250
+ add("ORION", k, "H&E<->mIF real (10 means)", zh, zmr, "trained (real)", False)
251
+ add("ORION", k, "H&E<->mIF virtual (10 means)", zh, zmv, "virtual calibration", True)
252
+ add("ORION", k, "mIF real<->mIF virtual (10 means)", zmr, zmv, "virtual calibration", False)
253
+ add("ORION", k, "mIF real (10 means)<->RNA virtual", zmr, zr, "emergent, real x virtual", False)
254
+ add("ORION", k, "mIF virtual (10 means)<->RNA virtual", zmv, zr, "emergent, virtual x virtual", True)
255
+ if gt_o is not None:
256
+ zt = nz(text_head(gt_o[te]))
257
+ add("ORION", k, "text synthetic<->mIF real (10 means)", zt, zmr, "emergent, real x virtual", False)
258
+ add("ORION", k, "text synthetic<->mIF virtual (10 means)", zt, zmv, "emergent, virtual x virtual", True)
259
+ zmt = nz(h(mv_t))
260
+ add("TCGA", k, "H&E<->mIF virtual (10 means)", nz(he_t), zmt, "virtual (cross-dataset head)", True)
261
+ add("TCGA", k, "text<->mIF virtual (10 means)", nz(text_head(tx)), zmt, "emergent, real x virtual", False)
262
+ add("TCGA", k, "RNA virtual<->mIF virtual (10 means)", nz(rna_heads[k](rna_t)), zmt, "emergent, virtual x virtual", True)
263
+ print("means head fold", k, flush=True)
264
+
265
+ # ---------------- (E) calibration of the synthetic text: TCGA patches with REAL text AND a synthetic caption (27_ tcga_calib)
266
+ f_cm, f_cf = os.path.join(CACHE, "tcga_calib_meta.csv"), os.path.join(CACHE, "tcga_calib_text_biomedclip.npy")
267
+ if os.path.exists(f_cf):
268
+ CM = pd.read_csv(f_cm)
269
+ pos = pd.Series(range(len(ev)), index=ev)
270
+ assert CM.img_idx.isin(ev).all()
271
+ j = pos.loc[CM.img_idx].values # rows of the TCGA eval arrays
272
+ with torch.no_grad():
273
+ zs = nz(text_head(torch.from_numpy(np.load(f_cf)[:, 0].astype(np.float32))))
274
+ zr_txt, zh_c = nz(text_head(tx))[j], nz(he_t)[j]
275
+ add("TCGA calib", 0, "H&E<->text real", zh_c, zr_txt, "trained (real)", False)
276
+ add("TCGA calib", 0, "H&E<->text synthetic", zh_c, zs, "virtual calibration", True)
277
+ add("TCGA calib", 0, "text real<->text synthetic", zr_txt, zs, "virtual calibration", False)
278
+ for k in range(5):
279
+ zrna = nz(rna_heads[k](rna_t))[j]
280
+ add("TCGA calib", k, "text real<->RNA virtual", zr_txt, zrna, "emergent, real x virtual", False)
281
+ add("TCGA calib", k, "text synthetic<->RNA virtual", zs, zrna, "emergent, virtual x virtual", True)
282
+ print("calibration done", len(CM), flush=True)
283
+
284
+ R = pd.DataFrame(rows)
285
+ R.to_csv(os.path.join(OUT, "virtual_validation.csv"), index=False)
286
+ pd.set_option("display.width", 250); pd.set_option("display.max_colwidth", 60)
287
+ S = R.groupby(["dataset", "pair", "kind", "circular"])[["R@1_g100", "pair_acc_g100", "n"]].agg(["mean", "std"]).round(3)
288
+ print(S.drop(columns=[("n", "std")]).to_string())
experiments/32_gentext_prep.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic captions (27_azure_caption.py, GPT, 3-shot PathGen style) -> per-set meta for 08_extract_text.py.
2
+ VALIDATION ONLY: these texts are never used to train any head.
3
+ Output cache/orion_gentext_meta.csv (row i = ORION tile_id i of load_all_tiles(); `caption` column)
4
+ """
5
+ import json
6
+ import os
7
+
8
+ import pandas as pd
9
+
10
+ from common import CACHE, load_all_tiles
11
+
12
+ C = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
13
+ C = C[(C.set == "orion") & C.caption.notna() & (C.caption.str.len() > 0)].drop_duplicates("path", keep="last")
14
+ cap = dict(zip(C.path.map(os.path.basename), C.caption))
15
+ df = load_all_tiles()
16
+ df["caption"] = df.image_path.map(lambda p: cap.get(os.path.basename(p)))
17
+ print(f"ORION tiles {len(df)}, with synthetic caption {df.caption.notna().sum()}")
18
+ assert df.caption.notna().all(), "missing captions -> rerun 27_azure_caption.py (resumable)"
19
+ df[["tile_id", "image_path", "caption"]].to_csv(os.path.join(CACHE, "orion_gentext_meta.csv"), index=False)
20
+
21
+ # TCGA calibration set (27_ --sets tcga_calib): synthetic captions of patches that also have REAL text
22
+ A = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
23
+ A = A[(A.set == "tcga_calib") & A.caption.notna()].drop_duplicates("path", keep="last")
24
+ if len(A):
25
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
26
+ shots = [s["key"][:12] for s in json.load(open(os.path.join(CACHE, "gen_text", "prompt.json")))["shots"]]
27
+ A["img_idx"] = A.path.map(dict(zip(I.image_path, I.index)))
28
+ A = A[A.img_idx.notna() & ~A.img_idx.map(lambda i: I.patient[int(i)] in shots)] # drop few-shot patients
29
+ A["img_idx"] = A.img_idx.astype(int)
30
+ A[["img_idx", "path", "caption"]].reset_index(drop=True).to_csv(os.path.join(CACHE, "tcga_calib_meta.csv"), index_label="idx")
31
+ print(f"TCGA calibration captions: {len(A)} (few-shot patients excluded: {shots})")
32
+
33
+ # external test sets (27_ --sets dlpfc idc): row i = row i of cache/external/<set>_meta.csv
34
+ for st in ("dlpfc", "idc"):
35
+ X = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
36
+ X = X[(X.set == st) & X.caption.notna() & (X.caption.str.len() > 0)].drop_duplicates("key", keep="last")
37
+ if not len(X):
38
+ continue
39
+ n = len(pd.read_csv(os.path.join(CACHE, "external", f"{st}_meta.csv")))
40
+ cap = dict(zip(X.path.str.split(":").str[2].astype(int), X.caption))
41
+ out = pd.DataFrame({"row": range(n), "caption": [cap.get(i) for i in range(n)]})
42
+ print(f"{st}: {n} spots, with synthetic caption {out.caption.notna().sum()}")
43
+ assert out.caption.notna().all(), "missing captions -> rerun 27_azure_caption.py --sets dlpfc idc (resumable)"
44
+ out.to_csv(os.path.join(CACHE, f"{st}_gentext_meta.csv"), index=False)
45
+
46
+ # TCGA images without real text after junk removal (40_clean_text.py; 27_ --sets tcga_notext). A caption made for
47
+ # the same image under another set (tcga_calib) is reused. Row i of the output = row i of notext_meta.csv.
48
+ N = pd.read_csv(os.path.join(CACHE, "notext_meta.csv"))
49
+ X = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
50
+ X = X[X.caption.notna() & (X.caption.str.len() > 0)].drop_duplicates("path", keep="last")
51
+ cap = dict(zip(X.path.map(os.path.abspath), X.caption))
52
+ N["caption"] = N.image_path.map(lambda p: cap.get(os.path.abspath(p)))
53
+ print(f"TCGA no-text images {len(N)}, with synthetic caption {N.caption.notna().sum()}")
54
+ assert N.caption.notna().all(), "missing captions -> rerun 27_azure_caption.py --sets tcga_notext"
55
+ N[["img_idx", "image_path", "patient", "caption"]].to_csv(os.path.join(CACHE, "tcga_notext_meta.csv"), index=False)
experiments/33_final_eval.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final protocol: every head is trained on REAL pairs only, with an explicit patient-level train / val / test split per
2
+ real dataset; virtual modalities (STPath RNA, GigaTIME mIF, GPT synthetic captions) are used ONLY at test time.
3
+
4
+ Real datasets and splits (5 folds each; fold k = train / val (early stopping only) / test; patients never cross splits)
5
+ ORION H&E <-> mIF : splits/patient_5fold/fold<k>.csv (5-fold by patient, val = 3 training patients)
6
+ + splits/orion_official/fold0.csv (the user's ORION train/val/test csvs)
7
+ HEST H&E <-> RNA : GroupKFold(5) over study|patient groups, val = 10% of training groups (= 23_bind_gene.py)
8
+ TCGA H&E <-> text : GroupKFold(5) over patients on ALL image-text pairs, val = 10% of training patients (= 20_pairing.py)
9
+ The HEST and TCGA split memberships are written to splits/final/{hest,tcga}_fold<k>.csv.
10
+
11
+ Heads (frozen UNI2 H&E = anchor space):
12
+ mIF runs/bench_fm/<split>_fold<k>_uni2_fm-virtues[_shared]_.../heads.pt (17_bench_fm.py --save_heads)
13
+ RNA runs/gene/uni2_mlp_fold<k>/head.pt (23_bind_gene.py)
14
+ text runs/text_clean/uni2_biomedclip_fold<k>/head.pt trained here (text_head recipe of 20_pairing.py) on CLEAN real
15
+ texts only: junk texts (answer letters, "error", < 5 words; 40_clean_text.py) are removed from train/val/test.
16
+ The 256 images left without real text keep their split; their few-shot GPT captions (27_ --sets tcga_notext)
17
+ are evaluated only (pooled over the test folds, each image through its own test-fold head).
18
+ Previous heads trained with the junk texts: runs/text_all (results in results/before_text_clean/).
19
+ Evaluation is on the TEST split of fold k with the fold-k heads (a head from another dataset never saw that dataset).
20
+ part 1 "real" : both sides measured (H&E<->mIF, H&E<->RNA, H&E<->text)
21
+ part 2 "virtual" : >= 1 side virtual, same test items; "circular" = both sides derived from the same H&E
22
+ Metrics: random 100-item galleries (50 draws), both directions averaged: tie-aware R@1/R@5/R@10, MRR, and one-to-one
23
+ Hungarian pairing accuracy (chance R@1 = pair acc = 0.01, R@5 = 0.05, R@10 = 0.10).
24
+ mIF everywhere = the 10-channel panel shared by ORION and GigaTIME (30_virtues_shared.py); the full 17-channel ORION
25
+ panel appears only as a reference row. "[qm]" = GigaTIME pixels quantile-mapped per channel onto the real fold-k
26
+ training-tile intensity distribution before the identical preprocessing (30_ virtual_qm).
27
+ Outputs results/final_long.csv, results/final_summary.csv; split sizes -> results/final_splits.csv
28
+ """
29
+ import os
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+ import torch
34
+ import torch.nn as nn
35
+ import torch.nn.functional as F
36
+ from scipy.optimize import linear_sum_assignment
37
+ from sklearn.model_selection import GroupKFold
38
+
39
+ from common import CACHE, ROOT, RUNS, SPLITS, load_all_tiles
40
+
41
+ OUT = os.path.join(ROOT, "results")
42
+ H = os.path.join(CACHE, "hest")
43
+ G100, DRAWS, K = 100, 50, 5
44
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
45
+ os.makedirs(os.path.join(SPLITS, "final"), exist_ok=True)
46
+
47
+
48
+ def mlp(din, dout=1536, hid=512): # same head as 12_/17_/20_/23_
49
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
50
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
51
+
52
+
53
+ def load_mlp(sd):
54
+ h = mlp(sd["0.weight"].shape[1], sd[f"{len(mlp(1)) - 1}.weight"].shape[0]); h.load_state_dict(sd); return h.eval()
55
+
56
+
57
+ def nz(x):
58
+ return F.normalize(torch.as_tensor(np.asarray(x), dtype=torch.float32), dim=-1)
59
+
60
+
61
+ def g100(a, b, seed=0):
62
+ rng = np.random.default_rng(seed)
63
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
64
+ for _ in range(DRAWS):
65
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
66
+ sim = (a[s] @ b[s].T).numpy()
67
+ d = np.diag(sim)
68
+ for S in (sim, sim.T):
69
+ rank = (S > d[:, None]).sum(1) # tie-aware: 0 = correct item ranked first
70
+ for k_ in (1, 5, 10):
71
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
72
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
73
+ ri, ci = linear_sum_assignment(-sim)
74
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
75
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
76
+
77
+
78
+ rows, split_rows = [], []
79
+
80
+
81
+ def add(dataset, split, fold, part, pair, za, zb, kind, circular):
82
+ rows.append({"dataset": dataset, "split": split, "fold": fold, "part": part, "pair": pair, "kind": kind,
83
+ "circular": circular, "n_test": len(za), **g100(za, zb)})
84
+
85
+
86
+ def log_split(dataset, split, fold, s, unit):
87
+ for x in ["train", "val", "test"]:
88
+ split_rows.append({"dataset": dataset, "split": split, "fold": fold, "role": x,
89
+ "n_items": int((s.split == x).sum()), f"n_{unit}": s[unit][s.split == x].nunique(),
90
+ "unit": unit})
91
+ g = {x: set(s[unit][s.split == x]) for x in ["train", "val", "test"]}
92
+ assert not (g["train"] & g["val"] or g["train"] & g["test"] or g["val"] & g["test"]), (dataset, fold, "leak")
93
+
94
+
95
+ # ================= virtual RNA: STPath counts -> log1p CP10k on the 2000 HEST HVGs (input of the HEST-trained head)
96
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
97
+
98
+
99
+ def virtual_rna(st):
100
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
101
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
102
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
103
+ return torch.from_numpy(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values])
104
+
105
+
106
+ def mif_head(enc, k, split):
107
+ d = torch.load(os.path.join(RUNS, "bench_fm", f"{split}_fold{k}_uni2_fm-{enc}_fm-geneformer_pathgenclip", "heads.pt"),
108
+ map_location="cpu")
109
+ h = load_mlp(d["state"]["if"])
110
+ return lambda x: h((torch.as_tensor(x, dtype=torch.float32) - d["if_mean"]) / d["if_std"])
111
+
112
+
113
+ rna_heads = [load_mlp(torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "head.pt"), map_location="cpu")) for k in range(K)]
114
+
115
+ # ================= TCGA all image-text pairs: split + text heads (real text only)
116
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
117
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
118
+ JUNK = pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values
119
+ assert len(JUNK) == len(T)
120
+ IMG = torch.from_numpy(np.load(os.path.join(CACHE, "allimg_he_uni2.npy")).astype(np.float32)) # (n_img, 8, 1536)
121
+ TXT = torch.from_numpy(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[:, 0].astype(np.float32))
122
+ t_img = torch.as_tensor(T.img_idx.values) # indexed by the ORIGINAL text row (kept after filtering)
123
+ T = T[~JUNK]
124
+ NOTXT = pd.read_csv(os.path.join(CACHE, "tcga_notext_meta.csv"))
125
+ assert not NOTXT.img_idx.isin(T.img_idx).any()
126
+ TX_N = torch.from_numpy(np.load(os.path.join(CACHE, "tcga_notext_text_biomedclip.npy"))[:, 0].astype(np.float32))
127
+
128
+
129
+ def one_text_per_image(t_ids, rng):
130
+ sub = T.loc[t_ids]
131
+ pick = sub.groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
132
+ return pick.index.values, pick.values # img ids, text ids
133
+
134
+
135
+ def train_text_head(tr_t, va_t, epochs=60, bs=256, lr=3e-4, seed=0):
136
+ """20_pairing.py text_head: frozen UNI2 image = anchor, MLP on frozen BiomedCLIP text, multi-positive InfoNCE"""
137
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
138
+ X, Tx, ti = IMG.to(dev), TXT.to(dev), t_img.to(dev)
139
+ th = mlp(Tx.shape[1], X.shape[-1]).to(dev)
140
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
141
+ opt = torch.optim.AdamW(list(th.parameters()) + [logit], lr=lr, weight_decay=0.05)
142
+ sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr_t) // bs), pct_start=0.05)
143
+ va_i, va_tt = one_text_per_image(va_t, np.random.default_rng(0))
144
+ za_va = F.normalize(X[torch.as_tensor(va_i, device=dev), 0], dim=-1)
145
+ best, state = -1, None
146
+ for ep in range(epochs):
147
+ th.train()
148
+ perm = rng.permutation(tr_t)
149
+ for s in range(0, len(perm) - bs + 1, bs):
150
+ b = torch.as_tensor(perm[s:s + bs], device=dev)
151
+ ii = ti[b]
152
+ zi = F.normalize(X[ii, torch.randint(0, 8, (len(b),), device=dev)], dim=-1)
153
+ lg = logit.exp().clamp(max=100) * zi @ F.normalize(th(Tx[b]), dim=-1).T
154
+ pos = (ii[:, None] == ii[None, :]).float()
155
+ tgt = pos / pos.sum(1, keepdim=True)
156
+ loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
157
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
158
+ th.eval()
159
+ with torch.no_grad():
160
+ zt = F.normalize(th(Tx[torch.as_tensor(va_tt, device=dev)]), dim=-1)
161
+ sim = za_va @ zt.T
162
+ v = float((((sim > sim.diag()[:, None]).sum(1) == 0).float().mean() + ((sim.T > sim.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
163
+ if v > best:
164
+ best, state = v, {k_: x.detach().cpu().clone() for k_, x in th.state_dict().items()}
165
+ return state, best
166
+
167
+
168
+ tcga_folds = list(GroupKFold(K).split(I, groups=I.patient))
169
+ text_heads, tcga_test, tcga_notext_test = [], [], []
170
+ for k, (trv, te) in enumerate(tcga_folds):
171
+ pats = np.random.default_rng(k).permutation(sorted(I.patient.iloc[trv].unique()))
172
+ va_p = set(pats[: max(1, len(pats) // 10)])
173
+ sp = pd.Series("test", index=I.index)
174
+ sp.iloc[trv] = np.where(I.patient.iloc[trv].isin(va_p), "val", "train")
175
+ s = pd.DataFrame({"img_idx": I.index, "patient": I.patient, "split": sp.values})
176
+ s.to_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv"), index=False)
177
+ log_split("TCGA image-text", "patient 5-fold", k, s, "patient")
178
+ tsplit = sp.loc[T.img_idx].values
179
+ tr_t, va_t, te_t = (T.index[tsplit == x].values for x in ["train", "val", "test"])
180
+ f = os.path.join(RUNS, "text_clean", f"uni2_biomedclip_fold{k}", "head.pt")
181
+ if not os.path.exists(f):
182
+ state, best = train_text_head(tr_t, va_t)
183
+ os.makedirs(os.path.dirname(f), exist_ok=True); torch.save(state, f)
184
+ print(f"text head fold {k}: val R@1 {best:.3f} (train pairs {len(tr_t)}, val pairs {len(va_t)})", flush=True)
185
+ text_heads.append(load_mlp(torch.load(f, map_location="cpu")))
186
+ tcga_test.append(one_text_per_image(te_t, np.random.default_rng(k)))
187
+ tcga_notext_test.append(np.flatnonzero((sp.loc[NOTXT.img_idx] == "test").values)) # rows of NOTXT
188
+
189
+ # ================= (1) HEST: H&E <-> RNA real; virtual RNA (STPath) on the same test spots
190
+ M = pd.read_csv(os.path.join(H, "meta.csv"))
191
+ hv = pd.read_csv(os.path.join(CACHE, "stpath", "hest_val_meta.csv"))
192
+ assert (hv["sample"].values == M["sample"].values).all() and (hv.spot.values == M.spot.values).all()
193
+ he_h = np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32)
194
+ real_h = torch.from_numpy(np.load(os.path.join(H, "hvg.npy")).astype(np.float32))
195
+ virt_h = virtual_rna("hest_val")
196
+ with torch.no_grad():
197
+ for k, (trv, te) in enumerate(GroupKFold(K).split(M, groups=M.group)): # identical folds to 23_bind_gene.py
198
+ grp = np.random.default_rng(k).permutation(sorted(M.group.iloc[trv].unique()))
199
+ va_g = set(grp[: max(1, len(grp) // 10)])
200
+ sp = np.array(["test"] * len(M), dtype=object)
201
+ sp[trv] = np.where(M.group.iloc[trv].isin(va_g), "val", "train")
202
+ s = pd.DataFrame({"spot_idx": M.index, "sample": M["sample"], "group": M.group, "split": sp})
203
+ s.to_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"), index=False)
204
+ log_split("HEST spot-RNA", "group 5-fold", k, s, "group")
205
+ te_saved = torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "test.pt"), weights_only=False)["idx"]
206
+ assert np.array_equal(np.sort(te_saved), np.sort(te)), "HEST fold mismatch with 23_bind_gene.py"
207
+ zr, zv, zh = nz(rna_heads[k](real_h[te])), nz(rna_heads[k](virt_h[te])), nz(he_h[te])
208
+ add("HEST", "group 5-fold", k, "real", "H&E<->RNA", zh, zr, "trained pair", False)
209
+ for sub, m in [("all test spots", np.ones(len(te), bool)), ("STPath-unseen test spots", hv.hest_bench_heldout.values[te])]:
210
+ if m.sum() >= G100:
211
+ if sub != "all test spots":
212
+ add(f"HEST ({sub})", "group 5-fold", k, "real", "H&E<->RNA", zh[m], zr[m], "trained pair", False)
213
+ add(f"HEST ({sub})" if sub != "all test spots" else "HEST", "group 5-fold", k, "virtual",
214
+ "H&E<->RNA(virtual)", zh[m], zv[m], "calibration: virtual replaces real", True)
215
+ add(f"HEST ({sub})" if sub != "all test spots" else "HEST", "group 5-fold", k, "virtual",
216
+ "RNA<->RNA(virtual)", zr[m], zv[m], "calibration: real vs virtual", False)
217
+ print("HEST done", flush=True)
218
+
219
+ # ================= (2) ORION: H&E <-> mIF real; virtual RNA / synthetic text / virtual mIF on the same test tiles
220
+ df = load_all_tiles()
221
+ assert (pd.read_csv(os.path.join(CACHE, "stpath", "orion_meta.csv")).key.values == df.image_path.values).all()
222
+ he_o = np.load(os.path.join(CACHE, "he_uni2.npy"))[:, 0].astype(np.float32)
223
+ rna_o = virtual_rna("orion")
224
+ if17 = np.load(os.path.join(CACHE, "if_virtues.npy"))[:, 0].astype(np.float32)
225
+ ifs_r = np.load(os.path.join(CACHE, "if_virtues_shared.npy"))[:, 0].astype(np.float32)
226
+ ifs_v = np.load(os.path.join(CACHE, "if_virtues_shared_virtual_orion.npy"))[:, 0].astype(np.float32)
227
+ gt_o = torch.from_numpy(np.load(os.path.join(CACHE, "orion_gentext_text_biomedclip.npy"))[:, 0].astype(np.float32))
228
+
229
+
230
+ def qm_file(st, k):
231
+ f = os.path.join(CACHE, f"if_virtues_shared_virtual_qm_{st}_fold{k}.npy")
232
+ return np.load(f)[:, 0].astype(np.float32) if os.path.exists(f) else None
233
+
234
+
235
+ qm_o = lambda k, _c={}: _c.setdefault(k, qm_file("orion", k))
236
+ # MIPHEI-ViT virtual mIF on the 16-channel ORION panel (30_ --m16). MIPHEI was trained on THIS ORION tile set with the
237
+ # official split -> clean only on the official TEST tiles; on patient 5-fold most test tiles were MIPHEI training tiles.
238
+ f_m16, f_mo = os.path.join(CACHE, "if_virtues_m16.npy"), os.path.join(CACHE, "if_virtues_m16_virtual_miphei_orion.npy")
239
+ m16_r = np.load(f_m16)[:, 0].astype(np.float32) if os.path.exists(f_m16) else None
240
+ m16_v = np.load(f_mo)[:, 0].astype(np.float32) if os.path.exists(f_mo) else None
241
+ with torch.no_grad():
242
+ for split, folds in [("patient 5-fold", range(K)), ("official", [0])]:
243
+ sdir = "patient_5fold" if split == "patient 5-fold" else "orion_official"
244
+ for k in folds:
245
+ s = pd.read_csv(os.path.join(SPLITS, sdir, f"fold{k}.csv"))
246
+ log_split("ORION H&E-mIF", split, k, s, "patient")
247
+ te = s.tile_id[s.split == "test"].values
248
+ h17, hsh = mif_head("virtues", k, sdir), mif_head("virtues_shared", k, sdir)
249
+ zh, zp, zs = nz(he_o[te]), nz(h17(if17[te])), nz(hsh(ifs_r[te]))
250
+ zr, zt = nz(rna_heads[k](rna_o[te])), nz(text_heads[k](gt_o[te])) # heads of other datasets: never saw ORION
251
+ A = lambda *a: add("ORION", split, k, *a)
252
+ # mIF = the UNIFIED 10-channel panel shared with GigaTIME everywhere; 17-ch kept only as a reference row
253
+ A("real", "H&E<->mIF (17-ch full ORION panel, reference)", zh, zp, "trained pair", False)
254
+ A("real", "H&E<->mIF", zh, zs, "trained pair", False)
255
+ A("virtual", "mIF<->RNA(virtual)", zs, zr, "emergent: real x virtual", False)
256
+ A("virtual", "mIF<->text(synthetic)", zs, zt, "emergent: real x virtual", False)
257
+ A("virtual", "H&E<->RNA(virtual)", zh, zr, "virtual from this H&E", True)
258
+ A("virtual", "H&E<->text(synthetic)", zh, zt, "virtual from this H&E", True)
259
+ A("virtual", "RNA(virtual)<->text(synthetic)", zr, zt, "emergent: virtual x virtual", True)
260
+ if m16_r is not None and os.path.exists(os.path.join(RUNS, "bench_fm", f"{sdir}_fold{k}_uni2_fm-virtues_m16_fm-geneformer_pathgenclip", "heads.pt")):
261
+ h16 = mif_head("virtues_m16", k, sdir)
262
+ z16 = nz(h16(m16_r[te]))
263
+ A("real", "H&E<->mIF (16-ch MIPHEI panel)", zh, z16, "trained pair", False)
264
+ A("virtual", "mIF(16ch)<->RNA(virtual)", z16, zr, "emergent: real x virtual", False)
265
+ A("virtual", "mIF(16ch)<->text(synthetic)", z16, zt, "emergent: real x virtual", False)
266
+ if m16_v is not None:
267
+ seen = split != "official" # 5-fold test tiles were (mostly) MIPHEI training tiles
268
+ tag = " [MIPHEI-seen tiles]" if seen else ""
269
+ zm = nz(h16(m16_v[te]))
270
+ A("virtual", f"H&E<->mIF(MIPHEI){tag}", zh, zm, "calibration: virtual replaces real", True)
271
+ A("virtual", f"mIF<->mIF(MIPHEI){tag}", z16, zm, "calibration: real vs virtual", False)
272
+ A("virtual", f"mIF(MIPHEI)<->RNA(virtual){tag}", zm, zr, "emergent: virtual x virtual", True)
273
+ A("virtual", f"mIF(MIPHEI)<->text(synthetic){tag}", zm, zt, "emergent: virtual x virtual", True)
274
+ for tag, f in [("", ifs_v[te]), (" [qm]", qm_o(k)[te] if split == "patient 5-fold" and qm_o(k) is not None else None)]:
275
+ if f is None:
276
+ continue # [qm] = virtual pixels quantile-mapped onto the fold-k TRAIN real distribution (30_ virtual_qm)
277
+ zv = nz(hsh(f))
278
+ A("virtual", f"H&E<->mIF(virtual){tag}", zh, zv, "calibration: virtual replaces real", True)
279
+ A("virtual", f"mIF<->mIF(virtual){tag}", zs, zv, "calibration: real vs virtual", False)
280
+ A("virtual", f"mIF(virtual){tag}<->RNA(virtual)", zv, zr, "emergent: virtual x virtual", True)
281
+ A("virtual", f"mIF(virtual){tag}<->text(synthetic)", zv, zt, "emergent: virtual x virtual", True)
282
+ print("ORION done", flush=True)
283
+
284
+ # ================= (3) TCGA: H&E <-> text real; virtual RNA / virtual mIF on the same test images
285
+ rna_t = virtual_rna("tcga_text")
286
+ ifv_t = np.load(os.path.join(CACHE, "if_virtues_shared_virtual_tcga_text.npy"))[:, 0].astype(np.float32)
287
+ CM = pd.read_csv(os.path.join(CACHE, "tcga_calib_meta.csv"))
288
+ syn_t = torch.from_numpy(np.load(os.path.join(CACHE, "tcga_calib_text_biomedclip.npy"))[:, 0].astype(np.float32))
289
+ with torch.no_grad():
290
+ for k, (ii, tt) in enumerate(tcga_test):
291
+ zh, zt = nz(IMG[ii, 0]), nz(text_heads[k](TXT[tt]))
292
+ zr = nz(rna_heads[k](rna_t[ii]))
293
+ A = lambda *a: add("TCGA", "patient 5-fold", k, *a)
294
+ A("real", "H&E<->text", zh, zt, "trained pair", False)
295
+ A("virtual", "text<->RNA(virtual)", zt, zr, "emergent: real x virtual", False)
296
+ A("virtual", "H&E<->RNA(virtual)", zh, zr, "virtual from this H&E", True)
297
+ f_mt = os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy")
298
+ if os.path.exists(f_mt) and os.path.exists(os.path.join(RUNS, "bench_fm", f"patient_5fold_fold{k}_uni2_fm-virtues_m16_fm-geneformer_pathgenclip", "heads.pt")):
299
+ zm = nz(mif_head("virtues_m16", k, "patient_5fold")(np.load(f_mt, mmap_mode="r")[ii, 0].astype(np.float32)))
300
+ A("virtual", "text<->mIF(MIPHEI)", zt, zm, "emergent: real x virtual", False)
301
+ A("virtual", "H&E<->mIF(MIPHEI)", zh, zm, "virtual from this H&E", True)
302
+ A("virtual", "RNA(virtual)<->mIF(MIPHEI)", zr, zm, "emergent: virtual x virtual", True)
303
+ hsh = mif_head("virtues_shared", k, "patient_5fold") # unified 10-ch panel head, trained on real ORION fold k
304
+ qt = qm_file("tcga_text", k)
305
+ for tag, f in [("", ifv_t), (" [qm]", qt)]:
306
+ if f is None:
307
+ continue
308
+ zv = nz(hsh(f[ii]))
309
+ A("virtual", f"text<->mIF(virtual){tag}", zt, zv, "emergent: real x virtual", False)
310
+ A("virtual", f"H&E<->mIF(virtual){tag}", zh, zv, "virtual from this H&E", True)
311
+ A("virtual", f"RNA(virtual)<->mIF(virtual){tag}", zr, zv, "emergent: virtual x virtual", True)
312
+ # synthetic-caption calibration: test images that have BOTH a real text and a GPT caption
313
+ pos = pd.Series(range(len(ii)), index=ii)
314
+ m = CM.img_idx.isin(ii).values
315
+ if m.sum() >= G100:
316
+ j = pos.loc[CM.img_idx[m]].values
317
+ zs = nz(text_heads[k](syn_t[m]))
318
+ B = lambda *a: add("TCGA (test images with GPT caption)", "patient 5-fold", k, *a)
319
+ B("real", "H&E<->text", zh[j], zt[j], "trained pair", False)
320
+ B("virtual", "H&E<->text(synthetic)", zh[j], zs, "calibration: virtual replaces real", True)
321
+ B("virtual", "text<->text(synthetic)", zt[j], zs, "calibration: real vs virtual", False)
322
+ B("virtual", "text<->RNA(virtual)", zt[j], zr[j], "emergent: real x virtual", False)
323
+ B("virtual", "text(synthetic)<->RNA(virtual)", zs, zr[j], "emergent: virtual x virtual", True)
324
+ # images without any real text (after junk removal): GPT few-shot caption, pooled over folds (each image in the test
325
+ # split of exactly one fold -> embedded with that fold's heads; all heads map into the same frozen UNI2 space)
326
+ f_mt = os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy")
327
+ shot_p = [x["key"][:12] for x in __import__("json").load(open(os.path.join(CACHE, "gen_text", "prompt.json")))["shots"]]
328
+ P = {x: [] for x in ["h", "s", "r", "m"]}
329
+ with torch.no_grad():
330
+ for k, r in enumerate(tcga_notext_test):
331
+ r = r[~NOTXT.patient.iloc[r].isin(shot_p).values] # drop few-shot patients
332
+ ii = NOTXT.img_idx.values[r]
333
+ P["h"].append(nz(IMG[ii, 0])); P["s"].append(nz(text_heads[k](TX_N[r]))); P["r"].append(nz(rna_heads[k](rna_t[ii])))
334
+ if os.path.exists(f_mt):
335
+ P["m"].append(nz(mif_head("virtues_m16", k, "patient_5fold")(np.load(f_mt, mmap_mode="r")[ii, 0].astype(np.float32))))
336
+ P = {x: torch.cat(v) for x, v in P.items() if v}
337
+ B = lambda *a: add("TCGA (test images WITHOUT real text, pooled folds)", "patient 5-fold", "pooled", *a)
338
+ B("virtual", "H&E<->text(synthetic)", P["h"], P["s"], "virtual from this H&E", True)
339
+ B("virtual", "text(synthetic)<->RNA(virtual)", P["s"], P["r"], "emergent: virtual x virtual", True)
340
+ if "m" in P:
341
+ B("virtual", "text(synthetic)<->mIF(MIPHEI)", P["s"], P["m"], "emergent: virtual x virtual", True)
342
+ print(f"TCGA no-text images evaluated: {len(P['h'])}")
343
+ print("TCGA done", flush=True)
344
+
345
+ R = pd.DataFrame(rows)
346
+ R.to_csv(os.path.join(OUT, "final_long.csv"), index=False)
347
+ SP = pd.DataFrame(split_rows)
348
+ SP.to_csv(os.path.join(OUT, "final_splits.csv"), index=False)
349
+ cols = ["R@1", "R@5", "R@10", "MRR", "pair_acc"]
350
+ S = R.groupby(["part", "dataset", "split", "pair", "kind", "circular"], sort=False)[cols + ["n_test"]].agg(["mean", "std"])
351
+ S.round(4).to_csv(os.path.join(OUT, "final_summary.csv"))
352
+ pd.set_option("display.width", 250); pd.set_option("display.max_colwidth", 50); pd.set_option("display.max_rows", 200)
353
+ print(SP.groupby(["dataset", "split", "role"])[["n_items", "n_patient", "n_group"]].mean().round(0).to_string())
354
+ for part in ["real", "virtual"]:
355
+ print(f"\n===== {part} (test split, mean over folds) =====")
356
+ print(R[R.part == part].groupby(["dataset", "split", "pair", "kind", "circular"], sort=False)[cols + ["n_test"]].mean().round(3).to_string())
experiments/34_gigatime_diag.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Why GigaTIME virtual mIF fails at instance-level pairing (ORION, 10 shared channels, same tiles as the real mIF).
2
+
3
+ (a) pooled vs WITHIN-slide Spearman of tile means: pairing in a 100-tile gallery needs tile-to-tile differences
4
+ inside a slide, while pooled correlation is dominated by slide/tissue-level differences
5
+ (b) direct real-mean <-> virtual-mean retrieval (no learned head): rank-gaussianised channel means, cosine, g100
6
+ (c) in-domain reference: ridge UNI2 H&E -> real means, trained on ORION patient_5fold train, scored on test
7
+ (what H&E can predict about these 10 channels when the model has seen ORION)
8
+ (d) input distribution: real mIF intensities vs GigaTIME sigmoid probabilities (what the VirTues head receives)
9
+ Output results/gigatime_diag.csv + printed tables.
10
+ """
11
+ import json
12
+ import os
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from scipy.special import ndtri
17
+ from scipy.stats import rankdata, spearmanr
18
+
19
+ from common import CACHE, ROOT, SPLITS, load_all_tiles
20
+
21
+ OUT = os.path.join(ROOT, "results")
22
+ G = os.path.join(CACHE, "gigatime")
23
+ ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
24
+ "CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"]
25
+ PANEL = [("Hoechst", ["DAPI"]), ("CD68", ["CD68"]), ("CD4", ["CD4"]), ("CD8a", ["CD8"]), ("CD20", ["CD20"]),
26
+ ("PD-L1", ["PD-L1"]), ("CD3e", ["CD3"]), ("Ki67", ["Ki67"]), ("Pan-CK", ["CK"]), ("SMA", ["Actin-D", "Transgelin"])]
27
+ gch = json.load(open(os.path.join(G, "channels.json")))["channels"]
28
+ df = load_all_tiles()
29
+ slide = df.in_slide_name.values
30
+ real = np.load(os.path.join(G, "orion_real_if_means.npy"))[:, [ORION_CH.index(o) for o, _ in PANEL]]
31
+ vm = np.load(os.path.join(G, "orion_means.npy"))
32
+ virt = np.stack([vm[:, [gch.index(g) for g in gs]].mean(1) for _, gs in PANEL], 1)
33
+ he = np.load(os.path.join(CACHE, "he_uni2.npy"))[:, 0].astype(np.float64)
34
+ names = [o for o, _ in PANEL]
35
+ rows = []
36
+
37
+
38
+ def within(a, b):
39
+ """mean within-slide Spearman (slides with >= 20 tiles), tiles-weighted"""
40
+ r, w = [], []
41
+ for s in np.unique(slide):
42
+ m = slide == s
43
+ if m.sum() >= 20 and a[m].std() > 0 and b[m].std() > 0:
44
+ r.append(spearmanr(a[m], b[m])[0]); w.append(m.sum())
45
+ return float(np.average(r, weights=w))
46
+
47
+
48
+ rg = lambda x: ndtri((np.apply_along_axis(rankdata, 0, x) - 0.5) / len(x))
49
+
50
+
51
+ def g100(a, b, draws=50, seed=0, groups=None):
52
+ a = a / np.linalg.norm(a, axis=1, keepdims=True); b = b / np.linalg.norm(b, axis=1, keepdims=True)
53
+ rng = np.random.default_rng(seed); r = []
54
+ for _ in range(draws):
55
+ if groups is None:
56
+ s = rng.choice(len(a), min(100, len(a)), replace=False)
57
+ else: # gallery drawn from ONE slide: only within-slide differences can help
58
+ g = rng.choice(groups); s = np.flatnonzero(slide == g); s = rng.choice(s, min(100, len(s)), replace=False)
59
+ sim = a[s] @ b[s].T; d = np.diag(sim)[:, None]
60
+ r.append(((sim > d).sum(1) == 0).mean() / 2 + ((sim.T > d).sum(1) == 0).mean() / 2)
61
+ return float(np.mean(r))
62
+
63
+
64
+ # ---- (c) ridge UNI2 -> real means (in-domain reference), patient 5-fold test predictions
65
+ pred = np.zeros_like(real, dtype=np.float64)
66
+ R_rg = rg(real)
67
+ for k in range(5):
68
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
69
+ tr, va, te = (s.tile_id[s.split == x].values for x in ["train", "val", "test"])
70
+ mu, sd = he[tr].mean(0), he[tr].std(0) + 1e-6
71
+ X = lambda i: (he[i] - mu) / sd
72
+ best = None
73
+ for lam in [1e1, 1e2, 1e3, 1e4]: # lambda chosen on val
74
+ W = np.linalg.solve(X(tr).T @ X(tr) + lam * np.eye(he.shape[1]), X(tr).T @ R_rg[tr])
75
+ v = np.mean([spearmanr(X(va) @ W[:, c], R_rg[va, c])[0] for c in range(len(names))])
76
+ if best is None or v > best[0]:
77
+ best = (v, W)
78
+ pred[te] = X(te) @ best[1]
79
+
80
+ # ---- (a) correlations
81
+ for c, n in enumerate(names):
82
+ rows.append({"channel": n,
83
+ "GigaTIME pooled": spearmanr(virt[:, c], real[:, c])[0], "GigaTIME within-slide": within(virt[:, c], real[:, c]),
84
+ "UNI2-ridge pooled (test)": spearmanr(pred[:, c], real[:, c])[0], "UNI2-ridge within-slide (test)": within(pred[:, c], real[:, c])})
85
+ C = pd.DataFrame(rows).set_index("channel")
86
+ C.loc["mean"] = C.mean()
87
+
88
+ # ---- (b) direct retrieval, all tiles, galleries mixed across slides vs within one slide
89
+ big = [s for s in np.unique(slide) if (slide == s).sum() >= 100]
90
+ V_rg, P_rg = rg(virt), rg(pred)
91
+ Rt = pd.DataFrame([
92
+ {"pair": "real means <-> GigaTIME means", "mixed-slide gallery": g100(R_rg, V_rg), "single-slide gallery": g100(R_rg, V_rg, groups=big)},
93
+ {"pair": "real means <-> UNI2-ridge prediction", "mixed-slide gallery": g100(R_rg, P_rg), "single-slide gallery": g100(R_rg, P_rg, groups=big)},
94
+ {"pair": "real means <-> shuffled real (chance)", "mixed-slide gallery": g100(R_rg, R_rg[np.random.default_rng(0).permutation(len(R_rg))]), "single-slide gallery": np.nan},
95
+ ]).set_index("pair")
96
+
97
+ # ---- (d) input distribution of the pixel maps (200 tiles)
98
+ maps = np.load(os.path.join(G, "orion_maps.npy"), mmap_mode="r")
99
+ idx = np.random.default_rng(0).choice(len(maps), 200, replace=False)
100
+ mv = np.asarray(maps[np.sort(idx)])[:, [gch.index(gs[0]) for _, gs in PANEL]].astype(np.float32) / 255
101
+ D = pd.DataFrame({"channel": names,
102
+ "virtual: frac px p<0.05": (mv < 0.05).mean((0, 2, 3)),
103
+ "virtual: frac px p>0.95": (mv > 0.95).mean((0, 2, 3)),
104
+ "virtual: frac px in (0.05,0.95)": ((mv >= 0.05) & (mv <= 0.95)).mean((0, 2, 3))}).set_index("channel")
105
+
106
+ pd.set_option("display.width", 200)
107
+ print("(a) Spearman of tile means vs real ORION mIF\n", C.round(3).to_string())
108
+ print("\n(b) R@1 in 100-tile galleries (chance 0.01), no learned head\n", Rt.round(3).to_string())
109
+ print("\n(d) GigaTIME pixel-value distribution (sigmoid probabilities)\n", D.round(3).to_string())
110
+ C.round(4).to_csv(os.path.join(OUT, "gigatime_diag.csv"))
111
+ Rt.round(4).to_csv(os.path.join(OUT, "gigatime_diag_retrieval.csv"))
112
+ D.round(4).to_csv(os.path.join(OUT, "gigatime_diag_pixels.csv"))
experiments/35_miphei_infer.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MIPHEI-ViT (Sanofi, arXiv 2505.10294) virtual mIF: H&E -> 16 ORION channels (all ORION markers except PD-1).
2
+
3
+ Model : ext/miphei/weights (GitHub release v1.0.0: decoder + LoRA, 26 MB; non-commercial academic licence) on top of
4
+ bioptimus/H-optimus-0 (HF, gated; HF token only via environment). model.py of the release (read in full) is
5
+ loaded from its path; nothing else from the downloaded repo is executed.
6
+ Training data of MIPHEI = this very ORION tile set with the ORION official split (37 train / 2 val / 2 test slides =
7
+ our splits/orion_official) -> MIPHEI virtual mIF is held-out ONLY on the official test (and val) tiles.
8
+ Input : RGB at 0.5 um/px (ORION tiles 333 px = 0.4997 um/px used as is; TCGA 672-px patches at ~0.25 um/px -> 336 px),
9
+ ImageNet mean/std (release config), reflect-padded to 512 px (input must be a power of 2), output cropped back.
10
+ Output: tanh in [-0.9, 0.9] -> (y + 0.9) / 1.8 * 255 = the 8-bit scale of the real ORION mIF tiles (MIPHEI targets).
11
+ cache/miphei/<set>_maps168.npy uint8 (N, 16, 168, 168) resampled exactly like the real tiles in 14_ (antialiased
12
+ bilinear to ~1 um/px) -> VirTues input
13
+ cache/miphei/<set>_means.npy float32 (N, 16) tile means at full resolution (0-255 scale)
14
+ cache/miphei/<set>_meta.csv, channels.json
15
+ Usage: HF_TOKEN=... .venv/bin/python 35_miphei_infer.py --set orion|tcga_text|dlpfc|idc [--bs 8]
16
+ """
17
+ import argparse
18
+ import importlib.util
19
+ import json
20
+ import os
21
+ import time
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from PIL import Image, PngImagePlugin
29
+ from safetensors.torch import load_file
30
+
31
+ from common import CACHE, ORION, ROOT, load_all_tiles
32
+
33
+ p = argparse.ArgumentParser()
34
+ p.add_argument("--set", required=True)
35
+ p.add_argument("--bs", type=int, default=8)
36
+ args = p.parse_args()
37
+ dev = "cuda"
38
+ W = os.path.join(ROOT, "ext", "miphei", "weights")
39
+ OUT = os.path.join(CACHE, "miphei")
40
+ os.makedirs(OUT, exist_ok=True)
41
+ CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e", "CD163",
42
+ "E-cadherin", "Ki67", "Pan-CK", "SMA"] # release config targ_channel_names (order of the output heads)
43
+ MEAN = torch.tensor([123.675, 116.28, 103.53], device=dev).view(1, 3, 1, 1) / 255
44
+ STD = torch.tensor([58.395, 57.12, 57.375], device=dev).view(1, 3, 1, 1) / 255
45
+ PAD_TO, VSIZE = 512, 168
46
+ PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 ** 2 # some TCGA PNGs carry large text metadata chunks
47
+
48
+ spec = importlib.util.spec_from_file_location("miphei_model", os.path.join(W, "model.py"))
49
+ Mm = importlib.util.module_from_spec(spec); spec.loader.exec_module(Mm)
50
+ vit = Mm.get_hoptimus0_hf("bioptimus/H-optimus-0")
51
+ vit.set_input_size(img_size=(PAD_TO, PAD_TO))
52
+ enc = Mm.Encoder(vit)
53
+ model = Mm.MIPHEIViT(enc, Mm.Detail_Capture(emb_chans=enc.embed_dim, out_chans=len(CH), use_attention=True, activation=nn.Tanh()))
54
+ sd = Mm.merge_lora_weights(model, load_file(os.path.join(W, "model.safetensors")))
55
+ Mm.validate_load_info(model.load_state_dict(sd, strict=False))
56
+ model = model.eval().to(dev)
57
+
58
+ if args.set == "orion":
59
+ df = load_all_tiles()
60
+ paths, keys, scale = [os.path.join(ORION, x) for x in df.image_path], df.image_path.values, 1.0
61
+ elif args.set == "tcga_text":
62
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
63
+ paths, keys, scale = list(I.image_path), I.key.values, 0.5
64
+ elif args.set in ("dlpfc", "idc"): # external sets (36_external_prep.py): uint8 RGB patches at 0.5 um/px, 224 px
65
+ E = os.path.join(CACHE, "external")
66
+ ARR = np.load(os.path.join(E, "dlpfc_he_05um.npy" if args.set == "dlpfc" else "idc_he.npy"), mmap_mode="r")
67
+ em = pd.read_csv(os.path.join(E, f"{args.set}_meta.csv"))
68
+ assert len(em) == len(ARR)
69
+ paths, keys, scale = list(range(len(ARR))), em.index.values, 1.0
70
+ else:
71
+ raise ValueError(args.set)
72
+ N = len(paths)
73
+ maps = np.lib.format.open_memmap(os.path.join(OUT, f"{args.set}_maps168.npy"), "w+", np.uint8, (N, len(CH), VSIZE, VSIZE))
74
+ means = np.zeros((N, len(CH)), np.float32)
75
+ sizes = []
76
+ t0 = time.time()
77
+
78
+
79
+ def load(pth):
80
+ if isinstance(pth, int):
81
+ return np.asarray(ARR[pth])
82
+ im = Image.open(pth).convert("RGB")
83
+ if scale != 1.0:
84
+ im = im.resize((round(im.width * scale), round(im.height * scale)), Image.BICUBIC)
85
+ return np.asarray(im)
86
+
87
+
88
+ with torch.inference_mode():
89
+ for s in range(0, N, args.bs):
90
+ ims = [load(x) for x in paths[s:s + args.bs]]
91
+ h, w = ims[0].shape[:2]
92
+ assert all(i.shape[:2] == (h, w) for i in ims) and h <= PAD_TO and w <= PAD_TO
93
+ x = torch.from_numpy(np.stack(ims)).to(dev).permute(0, 3, 1, 2).float() / 255
94
+ x = (x - MEAN) / STD
95
+ pt, pl = (PAD_TO - h) // 2, (PAD_TO - w) // 2
96
+ x = F.pad(x, (pl, PAD_TO - w - pl, pt, PAD_TO - h - pt), mode="reflect")
97
+ with torch.autocast("cuda", dtype=torch.float16): # as in the release notebook
98
+ y = model(x)
99
+ y = y.float().clamp(-0.9, 0.9)[:, :, pt:pt + h, pl:pl + w]
100
+ y = (y + 0.9) / 1.8 * 255
101
+ means[s:s + len(ims)] = y.mean((2, 3)).cpu().numpy()
102
+ maps[s:s + len(ims)] = F.interpolate(y, size=(VSIZE, VSIZE), mode="bilinear", antialias=True,
103
+ align_corners=False).round().clamp(0, 255).byte().cpu().numpy()
104
+ sizes += [(h, w)] * len(ims)
105
+ if s % (args.bs * 100) == 0:
106
+ print(f" {s}/{N} [{time.time() - t0:.0f}s]", flush=True)
107
+ maps.flush()
108
+ np.save(os.path.join(OUT, f"{args.set}_means.npy"), means)
109
+ pd.DataFrame({"key": keys, "path": paths, "h": [a for a, _ in sizes], "w": [b for _, b in sizes]}).to_csv(
110
+ os.path.join(OUT, f"{args.set}_meta.csv"), index=False)
111
+ json.dump({"channels": CH}, open(os.path.join(OUT, "channels.json"), "w"))
112
+ print(f"done {args.set}: {N} tiles [{time.time() - t0:.0f}s]")
experiments/36_external_prep.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """External H&E<->RNA test sets: DLPFC (spatialLIBD Visium) and breast IDC (HEST Xenium pseudo-spots).
2
+
3
+ Run: .venv/bin/python -I 36_external_prep.py [dlpfc] [idc] (default: both)
4
+ Needs the public spatialLIBD files in ext/external_raw/dlpfc_spatialLIBD/ (downloaded once, data only):
5
+ <sid>_filtered_feature_bc_matrix.h5 https://spatial-dlpfc.s3.us-east-2.amazonaws.com/h5/
6
+ <sid>_tissue_positions_list.txt https://raw.githubusercontent.com/LieberInstitute/HumanPilot/master/10X/<sid>/
7
+ <sid>_scalefactors_json.json (same)
8
+
9
+ Findings this script relies on (verified, numbers printed again at run time):
10
+ DLPFC visium_gene_expression_list.pkl = log1p(count * 3034 / total_counts_all_33538_genes) (3034 = median
11
+ total over all 47,681 spots, i.e. scanpy normalize_total default) restricted to the top-10000
12
+ scanpy 'seurat'-flavour HVGs computed on all 12 samples concatenated, columns in 10x feature (genome) order.
13
+ Rows = h5 barcode order of each sample, samples concatenated in sorted order (manifest spot_idx).
14
+ Images = ImageNet-normalised uint8 RGB, native 224x224 full-res crop centred on the spot.
15
+ Full-res scale: Visium centre-to-centre pitch 100 um = 138.0 px -> 0.7246 um/px (HEST: 0.725);
16
+ note the 'pxl_row/col' columns of the HumanPilot tissue_positions_list are (x, y), not (row, col).
17
+ IDC rebuilt from HEST-1k (MahmoodLab/hest): st/<id>.h5ad + the selected 224-px, 0.5 um/px patches of
18
+ patches/<id>.h5 for TENX95, TENX99, NCBI783, NCBI785 (= HEST-Bench IDC), paired by barcode and checked by
19
+ patch-centre vs spot-centre distance. Spots: in tissue, with a patch, >= 20 panel counts; > 1500 -> random
20
+ 1500 (seed 0). The earlier IDC built from ../IDC/UNI was unusable: subsample.py drew its image indices
21
+ independently of its adata indices (key '/k/__list__' vs '/k/__arr__', hash()-randomised seed).
22
+ First run fetches patches with HTTP range reads: needs HF_TOKEN in the environment.
23
+ Outputs cache/external/: see the OUT dict printed at the end.
24
+ """
25
+ import json
26
+ import os
27
+ import pickle
28
+ import sys
29
+
30
+ import h5py
31
+ import numpy as np
32
+ import pandas as pd
33
+ import scipy.sparse as sp
34
+ from PIL import Image
35
+
36
+ ROOT = os.path.dirname(os.path.abspath(__file__))
37
+ DATA = os.path.abspath(os.path.join(ROOT, ".."))
38
+ RAW = os.path.join(ROOT, "ext", "external_raw", "dlpfc_spatialLIBD")
39
+ OUT = os.path.join(ROOT, "cache", "external")
40
+ os.makedirs(OUT, exist_ok=True)
41
+ IMNET_MEAN = np.array([0.485, 0.456, 0.406], np.float32)
42
+ IMNET_STD = np.array([0.229, 0.224, 0.225], np.float32)
43
+ TARGET_UM = 0.5 # um/px of the resampled patches
44
+ HVG = pd.read_csv(os.path.join(ROOT, "cache", "hest", "hvg_genes.csv"))["gene"].astype(str).tolist()
45
+ HEST_META = pd.read_csv(os.path.join(DATA, "HEST", "HEST_v1_1_0.csv"))
46
+ HEST_TRAIN = set(pd.read_csv(os.path.join(ROOT, "cache", "hest", "meta.csv"), usecols=["sample"])["sample"])
47
+
48
+
49
+ def load_pickle(p):
50
+ with open(p, "rb") as f:
51
+ return pickle.load(f)
52
+
53
+
54
+ def denorm(x):
55
+ """(N,3,H,W) ImageNet-normalised float -> (N,H,W,3) uint8; returns max dequantisation error (in 0..255 units)."""
56
+ v = (np.asarray(x, np.float32).transpose(0, 2, 3, 1) * IMNET_STD + IMNET_MEAN) * 255.0
57
+ u = np.clip(np.round(v), 0, 255).astype(np.uint8)
58
+ return u, float(np.abs(v - u).max())
59
+
60
+
61
+ def resample(u8, um_native, fov_um=224 * TARGET_UM, out=224):
62
+ """Centre crop fov_um (if available) and resize to out x out => TARGET_UM um/px."""
63
+ res = []
64
+ for im in u8:
65
+ side = fov_um / um_native
66
+ h = im.shape[0]
67
+ if side > h: # not enough context: keep full patch (FOV smaller than requested)
68
+ side = h
69
+ c = (h - side) / 2
70
+ res.append(np.asarray(Image.fromarray(im).resize((out, out), Image.BICUBIC, box=(c, c, c + side, c + side))))
71
+ return np.stack(res)
72
+
73
+
74
+ def hvg_overlap(genes):
75
+ g = set(genes)
76
+ return int(sum(x in g for x in HVG))
77
+
78
+
79
+ # ================================================================== DLPFC
80
+ def read_h5(sid):
81
+ with h5py.File(os.path.join(RAW, f"{sid}_filtered_feature_bc_matrix.h5"), "r") as f:
82
+ g = f["matrix"]
83
+ M = sp.csc_matrix((g["data"][:], g["indices"][:], g["indptr"][:]), shape=g["shape"][:]).T.tocsr()
84
+ bcs = [b.decode() for b in g["barcodes"][:]]
85
+ names = [b.decode() for b in g["features"]["name"][:]]
86
+ ids = [b.decode() for b in g["features"]["id"][:]]
87
+ return M, bcs, names, ids
88
+
89
+
90
+ def seurat_hvg(L, n_top):
91
+ """scanpy.pp.highly_variable_genes(flavor='seurat', n_top_genes=n_top) on log1p data L (csr)."""
92
+ E = L.copy()
93
+ E.data = np.expm1(E.data)
94
+ n = E.shape[0]
95
+ mean = np.asarray(E.mean(0)).ravel()
96
+ var = (np.asarray(E.multiply(E).mean(0)).ravel() - mean ** 2) * n / (n - 1)
97
+ with np.errstate(all="ignore"):
98
+ disp = var / mean
99
+ disp[disp == 0] = np.nan
100
+ disp = np.log(disp)
101
+ df = pd.DataFrame({"m": np.log1p(mean), "d": disp})
102
+ df["bin"] = pd.cut(df.m, bins=20)
103
+ g = df.groupby("bin", observed=False)["d"]
104
+ bm, bs, one = g.transform("mean"), g.transform("std"), g.transform("count") == 1
105
+ bs, bm = bs.where(~one, bm), bm.where(~one, 0)
106
+ dn = ((df.d - bm) / bs).to_numpy()
107
+ v = np.sort(dn[~np.isnan(dn)])[::-1]
108
+ cut = v[n_top - 1]
109
+ print(f" HVG cut-off normalised dispersion {cut:.5f}; next {v[n_top]:.5f} (gap {cut - v[n_top]:.2e})")
110
+ return np.where(np.nan_to_num(dn) >= cut)[0]
111
+
112
+
113
+ def dlpfc():
114
+ print("== DLPFC")
115
+ P = os.path.join(DATA, "DLPFC_visium", "GPFM")
116
+ man = json.load(open(os.path.join(DATA, "_manifest", "dlpfc.json")))
117
+ samples = man["samples"]
118
+ X = load_pickle(os.path.join(P, "visium_gene_expression_list.pkl"))
119
+ X = np.asarray(X.numpy() if hasattr(X, "numpy") else X, np.float64)
120
+ gidx = np.asarray(pd.read_csv(os.path.join(DATA, "DLPFC_visium", "global_spot_index.csv"))["global_spot_idx"])
121
+ assert np.array_equal(gidx, man["global_spot_idx"]) and X.shape == (len(gidx), 10000)
122
+
123
+ Ms, rows, names, ids = [], [], None, None
124
+ sf_by, pos_by, bcs_by = {}, {}, {}
125
+ for sid in samples:
126
+ M, bcs, names, ids = read_h5(sid)
127
+ assert M.shape[0] == man["n_spots"][sid], sid
128
+ Ms.append(M)
129
+ bcs_by[sid] = bcs
130
+ sf_by[sid] = json.load(open(os.path.join(RAW, f"{sid}_scalefactors_json.json")))
131
+ pos = pd.read_csv(os.path.join(RAW, f"{sid}_tissue_positions_list.txt"), header=None,
132
+ names=["barcode", "in_tissue", "array_row", "array_col", "px_x", "px_y"]).set_index("barcode")
133
+ pos_by[sid] = pos
134
+ for i in man["spot_idx"][sid]:
135
+ rows.append((sid, i, bcs[i]))
136
+ M = sp.vstack(Ms).tocsr().astype(np.float64)
137
+ tot = np.asarray(M.sum(1)).ravel()
138
+ target = float(np.median(tot))
139
+ A = M[gidx] # raw counts of the 482 spots, all 33538 genes
140
+ print(f" all spots {M.shape}, median total counts {target}")
141
+
142
+ # 1) normalisation: x = log1p(count * target / total)
143
+ L = sp.diags(target / tot) @ M
144
+ L.data = np.log1p(L.data)
145
+ # 2) gene subset: reproduce scanpy seurat HVG (top 10000) on all 47,681 spots
146
+ sel = seurat_hvg(L, 10000)
147
+ assert len(sel) == 10000
148
+ Lsel = L[gidx][:, sel].toarray()
149
+ err = np.abs(Lsel - X).max()
150
+ # 3) independent check: exact per-column value match of recovered counts vs every h5 gene
151
+ Ad = A.toarray()
152
+ K = np.round(np.expm1(X) * (tot[gidx] / target)[:, None])
153
+ buckets = {}
154
+ for j in range(Ad.shape[1]):
155
+ buckets.setdefault(Ad[:, j].tobytes(), []).append(j)
156
+ cand = [buckets.get(K[:, c].tobytes(), []) for c in range(10000)]
157
+ n_unique = sum(len(c) == 1 for c in cand)
158
+ n_consistent = sum(sel[c] in cand[c] for c in range(10000))
159
+ n_unique_ok = sum(len(cand[c]) == 1 and cand[c][0] == sel[c] for c in range(10000))
160
+ print(f" max |log-expr(reconstructed) - pickle| = {err:.2e}")
161
+ print(f" exact-value column matches: {n_unique}/10000 columns match exactly one h5 gene "
162
+ f"({n_unique_ok} of them = HVG reconstruction); {n_consistent}/10000 HVG genes value-consistent; "
163
+ f"{int((K.sum(0) == 0).sum())} columns all-zero in the 482 spots")
164
+ assert err < 1e-5 and n_consistent == 10000
165
+
166
+ genes = pd.DataFrame({"col": np.arange(10000), "gene": np.array(names)[sel], "ensembl_id": np.array(ids)[sel],
167
+ "h5_feature_idx": sel,
168
+ "match": ["exact_unique" if len(c) == 1 else "exact_ambiguous_resolved_by_hvg" for c in cand]})
169
+ genes["duplicated_symbol"] = genes.gene.duplicated(keep=False)
170
+ genes.to_csv(os.path.join(OUT, "dlpfc_genes.csv"), index=False)
171
+ np.save(os.path.join(OUT, "dlpfc_counts.npy"), Ad[:, sel].astype(np.float32))
172
+ # full transcriptome version (same spots) from the public h5
173
+ np.save(os.path.join(OUT, "dlpfc_counts_full.npy"), Ad.astype(np.float32))
174
+ pd.DataFrame({"gene": names, "ensembl_id": ids, "in_10k": np.isin(np.arange(len(names)), sel)}).to_csv(
175
+ os.path.join(OUT, "dlpfc_genes_full.csv"), index=False)
176
+
177
+ # images
178
+ im = load_pickle(os.path.join(P, "visium_image_list.pkl"))
179
+ im = im.numpy() if hasattr(im, "numpy") else np.asarray(im)
180
+ u8, qerr = denorm(im)
181
+ print(f" images {im.shape}: ImageNet-normalised, dequantisation error {qerr:.1e} (exact uint8)")
182
+ np.save(os.path.join(OUT, "dlpfc_he.npy"), u8)
183
+
184
+ hest_ids = dict(zip(HEST_META.subseries.astype(str), HEST_META.id))
185
+ meta = []
186
+ for k, (sid, i, bc) in enumerate(rows):
187
+ p = pos_by[sid]
188
+ q = p.reset_index()
189
+ nb = q.merge(q, on="array_row")
190
+ nb = nb[nb.array_col_y == nb.array_col_x + 2]
191
+ pitch = float(np.median(np.hypot(nb.px_x_x - nb.px_x_y, nb.px_y_x - nb.px_y_y))) # 100 um
192
+ hid = hest_ids.get(sid)
193
+ meta.append(dict(sample=sid, spot_idx=i, global_spot_idx=int(gidx[k]), barcode=bc,
194
+ array_row=int(p.loc[bc, "array_row"]), array_col=int(p.loc[bc, "array_col"]),
195
+ x_fullres=float(p.loc[bc, "px_x"]), y_fullres=float(p.loc[bc, "px_y"]),
196
+ organ="Brain", tissue="dorsolateral prefrontal cortex",
197
+ um_per_px=100.0 / pitch, fov_um=224 * 100.0 / pitch,
198
+ spot_diameter_fullres=sf_by[sid]["spot_diameter_fullres"],
199
+ total_counts=float(tot[gidx[k]]), hest_id=hid, in_hest_train_cache=hid in HEST_TRAIN))
200
+ meta = pd.DataFrame(meta)
201
+ meta.to_csv(os.path.join(OUT, "dlpfc_meta.csv"), index=False)
202
+ um = float(meta.um_per_px.median())
203
+ u05 = resample(u8, um)
204
+ np.save(os.path.join(OUT, "dlpfc_he_05um.npy"), u05)
205
+ print(f" um/px {um:.4f} -> patch FOV {224 * um:.1f} um; dlpfc_he_05um = centre {112 / um:.1f}px crop -> 224 (112 um)")
206
+ print(f" HEST-HVG overlap: 10k cols {hvg_overlap(genes.gene)}/{len(HVG)}, full {hvg_overlap(names)}/{len(HVG)}")
207
+
208
+
209
+ # ================================================================== IDC (paired, from HEST-1k)
210
+ HEST_RAW = os.path.join(ROOT, "ext", "external_raw", "hest_idc")
211
+ IDC_IDS = ["TENX95", "TENX99", "NCBI783", "NCBI785"] # = HEST-Bench IDC
212
+ IDC_MIN_COUNTS = 20
213
+ IDC_MAX_SPOTS = 1500
214
+ CTRL = ("BLANK", "NegControl", "Unassigned", "antisense", "DeprecatedCodeword", "Intergenic")
215
+
216
+
217
+ def fetch_patches(sid, barcodes):
218
+ """Fetch only the selected patches of MahmoodLab/hest patches/<sid>.h5 by HTTP range reads (the full files are
219
+ 5.0 GB together; patches are stored uncompressed, one chunk per patch). Auth: HF_TOKEN env var, read by
220
+ huggingface_hub itself. Writes patches_subset/<sid>.h5 with img/barcode/coords + the source attrs."""
221
+ from huggingface_hub import HfFileSystem
222
+ out = os.path.join(HEST_RAW, "patches_subset", f"{sid}.h5")
223
+ os.makedirs(os.path.dirname(out), exist_ok=True)
224
+ fs = HfFileSystem()
225
+ src = f"datasets/MahmoodLab/hest/patches/{sid}.h5"
226
+ pb = all_patch_barcodes(sid) # cached barcode/coords table of the remote file (row order = h5 row order)
227
+ coords = pb[["x", "y"]].to_numpy()
228
+ pos = {b: i for i, b in enumerate(pb.barcode)}
229
+ rows = np.array([pos[b] for b in barcodes])
230
+ order = np.argsort(rows)
231
+ imgs = np.zeros((len(rows), 224, 224, 3), np.uint8)
232
+ with fs.open(src, "rb", block_size=160 * 1024, cache_type="readahead") as f, h5py.File(f, "r") as h:
233
+ attrs = dict(h["img"].attrs)
234
+ for n, k in enumerate(order):
235
+ imgs[k] = h["img"][rows[k]]
236
+ if n % 250 == 0:
237
+ print(f" {sid}: fetched {n}/{len(rows)} patches", flush=True)
238
+ with h5py.File(out, "w") as g:
239
+ g["img"] = imgs
240
+ g["barcode"] = np.array(barcodes, dtype="S")
241
+ g["coords"] = coords[rows]
242
+ g["src_row"] = rows
243
+ for k, v in attrs.items():
244
+ g["img"].attrs[k] = v
245
+ return out
246
+
247
+
248
+ def all_patch_barcodes(sid):
249
+ """Barcodes that have a patch in HEST (read remotely once, cached as a small csv)."""
250
+ p = os.path.join(HEST_RAW, "patches_subset", f"{sid}_all_barcodes.csv")
251
+ if not os.path.exists(p):
252
+ from huggingface_hub import HfFileSystem
253
+ os.makedirs(os.path.dirname(p), exist_ok=True)
254
+ with HfFileSystem().open(f"datasets/MahmoodLab/hest/patches/{sid}.h5", "rb", block_size=2 ** 22) as f, \
255
+ h5py.File(f, "r") as h:
256
+ bc = [b[0].decode() if isinstance(b[0], bytes) else str(b[0]) for b in h["barcode"][:]]
257
+ pd.DataFrame({"barcode": bc, "x": h["coords"][:, 0], "y": h["coords"][:, 1]}).to_csv(p, index=False)
258
+ return pd.read_csv(p)
259
+
260
+
261
+ def idc():
262
+ print("== IDC (HEST-1k, paired by barcode)")
263
+ import anndata as ad
264
+ ads = {s: ad.read_h5ad(os.path.join(HEST_RAW, "st", f"{s}.h5ad")) for s in IDC_IDS}
265
+ common = set.intersection(*[set(a.var_names) for a in ads.values()])
266
+ genes = sorted(g for g in common if not g.startswith(CTRL))
267
+ rng = np.random.default_rng(0)
268
+ he, counts, meta = [], [], []
269
+ for sid in IDC_IDS:
270
+ a = ads[sid]
271
+ md = json.load(open(os.path.join(HEST_RAW, "metadata", f"{sid}.json")))
272
+ X = a[:, genes].X
273
+ X = np.asarray(X.toarray() if sp.issparse(X) else X, np.float32)
274
+ tot = X.sum(1)
275
+ pb = all_patch_barcodes(sid)
276
+ in_t = a.obs["in_tissue"].astype(bool).values if "in_tissue" in a.obs else np.ones(a.n_obs, bool)
277
+ has_p = np.isin(a.obs_names, pb.barcode)
278
+ elig = np.where(in_t & has_p & (tot >= IDC_MIN_COUNTS))[0]
279
+ sub = len(elig) > IDC_MAX_SPOTS
280
+ sel = np.sort(rng.choice(elig, IDC_MAX_SPOTS, replace=False)) if sub else elig
281
+ print(f" {sid}: spots {a.n_obs}, in_tissue {in_t.sum()}, with patch {has_p.sum()}, "
282
+ f">= {IDC_MIN_COUNTS} counts {len(elig)} -> kept {len(sel)}{' (random subsample, seed 0)' if sub else ''}")
283
+ bcs = a.obs_names[sel].tolist()
284
+ p = os.path.join(HEST_RAW, "patches_subset", f"{sid}.h5")
285
+ if not os.path.exists(p):
286
+ fetch_patches(sid, bcs)
287
+ with h5py.File(p, "r") as g:
288
+ assert [b.decode() for b in g["barcode"][:]] == bcs, f"{sid}: cached patch subset does not match selection"
289
+ imgs, coords = g["img"][:], g["coords"][:]
290
+ src_px, ds, mpp = (float(g["img"].attrs[k]) for k in ("patch_size_src", "downsample", "pixel_size"))
291
+ # pairing check: patch centre (top-left coords + half the source patch size) vs spot centre
292
+ cx, cy = coords[:, 0] + src_px / 2, coords[:, 1] + src_px / 2
293
+ o = a.obs.iloc[sel]
294
+ dev = np.hypot(cx - o.pxl_col_in_fullres.values, cy - o.pxl_row_in_fullres.values) * mpp
295
+ um_out = mpp * ds
296
+ print(f" patch: {int(src_px)} src px @ {mpp:.4f} um/px -> 224 px @ {um_out:.4f} um/px ({224 * um_out:.0f} um FOV); "
297
+ f"|patch centre - spot centre| max {dev.max():.2f} um, median {np.median(dev):.2f} um")
298
+ assert dev.max() < 5, f"{sid}: patch/spot coordinate mismatch"
299
+ he.append(imgs)
300
+ counts.append(X[sel])
301
+ for j, i in enumerate(sel):
302
+ meta.append(dict(sample=sid, barcode=a.obs_names[i], array_row=int(a.obs.array_row.iloc[i]),
303
+ array_col=int(a.obs.array_col.iloc[i]),
304
+ x_fullres=float(a.obs.pxl_col_in_fullres.iloc[i]),
305
+ y_fullres=float(a.obs.pxl_row_in_fullres.iloc[i]),
306
+ organ="Breast", oncotree=md.get("oncotree_code"), um_per_px=um_out,
307
+ um_per_px_fullres=mpp, patch_center_dev_um=float(dev[j]), total_counts=float(tot[i]),
308
+ subsampled=bool(sub), n_eligible=len(elig),
309
+ in_hest_train_cache=sid in HEST_TRAIN))
310
+ he, counts = np.concatenate(he), np.concatenate(counts)
311
+ np.save(os.path.join(OUT, "idc_he.npy"), he)
312
+ np.save(os.path.join(OUT, "idc_counts.npy"), counts)
313
+ pd.DataFrame({"gene": genes}).to_csv(os.path.join(OUT, "idc_genes.csv"), index=False)
314
+ pd.DataFrame(meta).to_csv(os.path.join(OUT, "idc_meta.csv"), index=False)
315
+ for f in ("idc_he_unpaired.npy", "idc_he_unpaired_meta.csv"): # old, unpaired subsample images: removed
316
+ if os.path.exists(os.path.join(OUT, f)):
317
+ os.remove(os.path.join(OUT, f))
318
+ hest_common = pd.read_csv(os.path.join(ROOT, "cache", "hest", "genes.csv"))["gene"].astype(str)
319
+ print(f" genes {len(genes)} (common non-control panel genes); counts {counts.shape}; images {he.shape}")
320
+ print(f" IDC panel overlap: HEST HVG {hvg_overlap(genes)}/{len(HVG)}; "
321
+ f"HEST common genes {int(hest_common.isin(genes).sum())}/{len(hest_common)}")
322
+
323
+
324
+ if __name__ == "__main__":
325
+ todo = sys.argv[1:] or ["dlpfc", "idc"]
326
+ if "dlpfc" in todo:
327
+ dlpfc()
328
+ if "idc" in todo:
329
+ idc()
330
+ for f in sorted(os.listdir(OUT)):
331
+ p = os.path.join(OUT, f)
332
+ print(f" {f:28s}", np.load(p, mmap_mode="r").shape if f.endswith(".npy") else f"{len(pd.read_csv(p))} rows")
experiments/37_miphei_diag.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MIPHEI-ViT vs GigaTIME virtual mIF on ORION, against the real mIF of the same tiles (tile means).
2
+
3
+ MIPHEI was trained on the ORION official train split -> 'clean' = official TEST tiles (2 patients, never seen);
4
+ 'seen' = official TRAIN tiles (training data of MIPHEI, optimistic). GigaTIME never saw ORION (clean everywhere).
5
+ (a) per-channel Spearman of tile means (pooled and within-slide)
6
+ (b) direct real-mean <-> virtual-mean retrieval (rank-gaussianised means, cosine; no learned head), 100-galleries
7
+ Output results/miphei_diag.csv, results/miphei_diag_retrieval.csv
8
+ """
9
+ import json
10
+ import os
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from scipy.special import ndtri
15
+ from scipy.stats import rankdata, spearmanr
16
+
17
+ from common import CACHE, ROOT, SPLITS, load_all_tiles
18
+
19
+ OUT = os.path.join(ROOT, "results")
20
+ ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
21
+ "CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"] # order of orion_real_if_means.npy (25_)
22
+ GT = {"Hoechst": ["DAPI"], "CD68": ["CD68"], "CD4": ["CD4"], "CD8a": ["CD8"], "CD20": ["CD20"], "PD-L1": ["PD-L1"],
23
+ "CD3e": ["CD3"], "Ki67": ["Ki67"], "Pan-CK": ["CK"], "SMA": ["Actin-D", "Transgelin"]}
24
+ df = load_all_tiles()
25
+ slide = df.in_slide_name.values
26
+ real = np.load(os.path.join(CACHE, "gigatime", "orion_real_if_means.npy"))
27
+ mch = json.load(open(os.path.join(CACHE, "miphei", "channels.json")))["channels"]
28
+ mip = np.load(os.path.join(CACHE, "miphei", "orion_means.npy"))
29
+ gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
30
+ gm = np.load(os.path.join(CACHE, "gigatime", "orion_means.npy"))
31
+ off = pd.read_csv(os.path.join(SPLITS, "orion_official", "fold0.csv")).split.values
32
+ sets = {"clean (official test)": off == "test", "seen (official train)": off == "train"}
33
+
34
+
35
+ def within(a, b, m):
36
+ r, w = [], []
37
+ for s in np.unique(slide[m]):
38
+ i = m & (slide == s)
39
+ if i.sum() >= 15 and a[i].std() > 0 and b[i].std() > 0:
40
+ r.append(spearmanr(a[i], b[i])[0]); w.append(i.sum())
41
+ return float(np.average(r, weights=w)) if r else np.nan
42
+
43
+
44
+ rows = []
45
+ for ch in mch:
46
+ r = real[:, ORION_CH.index(ch)]
47
+ for sname, m in sets.items():
48
+ row = {"channel": ch, "set": sname,
49
+ "MIPHEI pooled": spearmanr(mip[m, mch.index(ch)], r[m])[0],
50
+ "MIPHEI within-slide": within(mip[:, mch.index(ch)], r, m)}
51
+ if ch in GT:
52
+ g = gm[:, [gch.index(x) for x in GT[ch]]].mean(1)
53
+ row.update({"GigaTIME pooled": spearmanr(g[m], r[m])[0], "GigaTIME within-slide": within(g, r, m)})
54
+ rows.append(row)
55
+ C = pd.DataFrame(rows)
56
+ rg = lambda x: ndtri((np.apply_along_axis(rankdata, 0, x) - 0.5) / len(x))
57
+
58
+
59
+ def g100(a, b, draws=50, seed=0):
60
+ a = a / np.linalg.norm(a, axis=1, keepdims=True); b = b / np.linalg.norm(b, axis=1, keepdims=True)
61
+ rng = np.random.default_rng(seed); r = []
62
+ for _ in range(draws):
63
+ s = rng.choice(len(a), min(100, len(a)), replace=False)
64
+ sim = a[s] @ b[s].T; d = np.diag(sim)[:, None]
65
+ r.append(((sim > d).sum(1) == 0).mean() / 2 + ((sim.T > d).sum(1) == 0).mean() / 2)
66
+ return float(np.mean(r))
67
+
68
+
69
+ ten = list(GT)
70
+ Rt = []
71
+ for sname, m in sets.items():
72
+ R16 = real[m][:, [ORION_CH.index(c) for c in mch]]
73
+ R10 = real[m][:, [ORION_CH.index(c) for c in ten]]
74
+ G10 = np.stack([gm[m][:, [gch.index(x) for x in GT[c]]].mean(1) for c in ten], 1)
75
+ Rt += [{"set": sname, "n": int(m.sum()), "pair": "real <-> MIPHEI (16 ch)", "R@1_g100": g100(rg(R16), rg(mip[m]))},
76
+ {"set": sname, "n": int(m.sum()), "pair": "real <-> MIPHEI (10 shared ch)", "R@1_g100": g100(rg(R10), rg(mip[m][:, [mch.index(c) for c in ten]]))},
77
+ {"set": sname, "n": int(m.sum()), "pair": "real <-> GigaTIME (10 shared ch)", "R@1_g100": g100(rg(R10), rg(G10))}]
78
+ Rt = pd.DataFrame(Rt)
79
+ pd.set_option("display.width", 200)
80
+ print(C.round(3).to_string(index=False))
81
+ print("\nmean over channels\n", C.groupby("set")[[c for c in C.columns if c not in ("channel", "set")]].mean().round(3).to_string())
82
+ print("\nmean over the 10 GigaTIME-shared channels\n", C[C.channel.isin(ten)].groupby("set")[[c for c in C.columns if c not in ("channel", "set")]].mean().round(3).to_string())
83
+ print("\n", Rt.round(3).to_string(index=False))
84
+ C.round(4).to_csv(os.path.join(OUT, "miphei_diag.csv"), index=False)
85
+ Rt.round(4).to_csv(os.path.join(OUT, "miphei_diag_retrieval.csv"), index=False)
experiments/38_external_eval.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """External test sets (never used for training): DLPFC Visium (brain, 12 sections / 3 donors) and HEST IDC
2
+ (breast, Xenium pseudo-spots, 4 samples; 280-gene panel). Prepared by 36_external_prep.py -> cache/external/.
3
+
4
+ Real data only for training (HEST-1k, train/val by study|patient group, val = early stopping only):
5
+ dlpfc head : H&E(UNI2) <-> RNA MLP on the 2000 HEST HVGs, trained on HEST WITHOUT the spatialLIBD study
6
+ (= DLPFC; those spots are in cache/hest) -> DLPFC is fully held out
7
+ idc head : same recipe on the IDC panel genes present in the HEST common gene set (IDC samples are not in HEST cache)
8
+ RNA input : log1p(CP10k over the HEST common genes / over the panel genes), then z-scored per gene with the
9
+ statistics of its OWN dataset (HEST training spots vs the external set; label-free platform alignment)
10
+ 5 heads per set (different val groups / seeds) -> mean +- std.
11
+ Virtual data only at test time: MIPHEI-ViT virtual mIF of the external H&E (35_ + 30_ --m16) through the 16-ch mIF
12
+ heads trained on REAL ORION (patient 5-fold, fold k).
13
+ Synthetic text only at test time: GPT captions of the external H&E (27_ --sets dlpfc idc, 32_, 08_ biomedclip)
14
+ through the TCGA text heads (runs/text_clean/uni2_biomedclip_fold<k>, trained on CLEAN real image-text pairs in 33_).
15
+ Pairs: H&E<->RNA (real, external), RNA<->mIF(MIPHEI) (emergent, real x virtual), H&E<->mIF(MIPHEI) (circular),
16
+ RNA<->text(synthetic) (emergent, real x virtual), H&E<->text(synthetic) (circular),
17
+ mIF(MIPHEI)<->text(synthetic) (virtual x virtual).
18
+ Metrics: 100-item galleries (50 draws, both directions): R@1/R@5/R@10/MRR + Hungarian pairing accuracy.
19
+ Usage: python 38_external_eval.py feats (UNI2 features of the external patches, GPU)
20
+ python 38_external_eval.py eval -> results/external_long.csv, external_summary.csv
21
+ """
22
+ import os
23
+ import sys
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ import scipy.sparse as sp
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+ from scipy.optimize import linear_sum_assignment
32
+
33
+ from common import CACHE, ROOT, RUNS
34
+
35
+ E = os.path.join(CACHE, "external")
36
+ H = os.path.join(CACHE, "hest")
37
+ OUT = os.path.join(ROOT, "results")
38
+ SETS = {"dlpfc": "dlpfc_he_05um.npy"} # IDC dropped from the report 2026-10-18 (user request)
39
+ MODE = sys.argv[1]
40
+
41
+ if MODE == "feats":
42
+ import timm
43
+ dev = "cuda"
44
+ model = timm.create_model( # = 04b_extract_anchor_fm.py uni2 (local official UNI2-h checkpoint)
45
+ "vit_giant_patch14_224", pretrained=False, img_size=224, patch_size=14, depth=24, num_heads=24,
46
+ init_values=1e-5, embed_dim=1536, mlp_ratio=2.66667 * 2, num_classes=0, no_embed_class=True,
47
+ mlp_layer=timm.layers.SwiGLUPacked, act_layer=torch.nn.SiLU, reg_tokens=8, dynamic_img_size=True)
48
+ model.load_state_dict(torch.load("/diskarray/home/tl688/reg2026/model_alldata/uni2-h.bin", map_location="cpu"), strict=True)
49
+ model = model.eval().to(dev)
50
+ mean = torch.tensor([0.485, 0.456, 0.406], device=dev).view(1, 3, 1, 1)
51
+ std = torch.tensor([0.229, 0.224, 0.225], device=dev).view(1, 3, 1, 1)
52
+ for st, f in SETS.items():
53
+ X = np.load(os.path.join(E, f), mmap_mode="r")
54
+ out = np.zeros((len(X), 1536), np.float32)
55
+ with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
56
+ for s in range(0, len(X), 32):
57
+ x = torch.from_numpy(np.asarray(X[s:s + 32])).to(dev).permute(0, 3, 1, 2).float() / 255
58
+ if x.shape[-1] != 224:
59
+ x = F.interpolate(x, size=(224, 224), mode="bilinear", antialias=True, align_corners=False)
60
+ out[s:s + 32] = model((x - mean) / std).float().cpu().numpy()
61
+ np.save(os.path.join(E, f"{st}_he_uni2.npy"), out)
62
+ print(st, out.shape, flush=True)
63
+ # protocol check: DLPFC spots that are also in the HEST cache (same barcode) -> cosine to the HEST UNI2 feature
64
+ m, hm = pd.read_csv(os.path.join(E, "dlpfc_meta.csv")), pd.read_csv(os.path.join(H, "meta.csv"))
65
+ hf = np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32)
66
+ o = np.load(os.path.join(E, "dlpfc_he_uni2.npy"))
67
+ j = m.reset_index().merge(hm.reset_index(), left_on=["hest_id", "barcode"], right_on=["sample", "spot"], suffixes=("_e", "_h"))
68
+ if len(j):
69
+ 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)
70
+ rnd = F.normalize(torch.from_numpy(hf[np.random.default_rng(0).choice(len(hf), 200, replace=False)]), dim=-1)
71
+ print(f"protocol check: {len(j)} shared spots, cosine(ours, HEST cache) = {(a * b).sum(1).numpy().round(3)}; "
72
+ f"mean cosine to random HEST spots {float((a @ rnd.T).mean()):.3f}")
73
+ sys.exit(0)
74
+
75
+ # ======================================================================== eval
76
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
77
+ G100, DRAWS, K = 100, 50, 5
78
+ HM = pd.read_csv(os.path.join(H, "meta.csv"))
79
+ hgenes = pd.read_csv(os.path.join(H, "genes.csv")).gene.values
80
+ hcounts = sp.load_npz(os.path.join(H, "counts.npz")).tocsr().astype(np.float32)
81
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
82
+ HE_H = torch.from_numpy(np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32))
83
+
84
+
85
+ def mlp(din, dout=1536, hid=512):
86
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
87
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
88
+
89
+
90
+ def lognorm(C, total):
91
+ return np.log1p(C / np.maximum(total, 1)[:, None] * 1e4).astype(np.float32)
92
+
93
+
94
+ def zscore(x, ref):
95
+ mu, sd = ref.mean(0), ref.std(0) + 1e-6
96
+ return (x - mu) / sd
97
+
98
+
99
+ def r1(a, b):
100
+ s = a @ b.T
101
+ return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
102
+
103
+
104
+ def train_head(G, tr, va, seed, epochs=100, bs=256, lr=3e-4): # = 23_bind_gene.py recipe
105
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
106
+ A, Gd = HE_H.to(dev), torch.from_numpy(G).to(dev)
107
+ head = mlp(G.shape[1]).to(dev)
108
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
109
+ opt = torch.optim.AdamW(list(head.parameters()) + [logit], lr=lr, weight_decay=0.05)
110
+ sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr) // bs), pct_start=0.05)
111
+ za_va = F.normalize(A[va], dim=-1)
112
+ best, state = -1, None
113
+ for ep in range(epochs):
114
+ head.train()
115
+ perm = rng.permutation(tr)
116
+ for s in range(0, len(perm) - bs + 1, bs):
117
+ b = torch.as_tensor(perm[s:s + bs], device=dev)
118
+ lg = logit.exp().clamp(max=100) * F.normalize(A[b], dim=-1) @ F.normalize(head(Gd[b]), dim=-1).T
119
+ t = torch.arange(len(b), device=dev)
120
+ loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
121
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
122
+ head.eval()
123
+ with torch.no_grad():
124
+ zg = F.normalize(head(Gd[va]), dim=-1)
125
+ v = (r1(za_va, zg) + r1(zg, za_va)) / 2
126
+ if v > best:
127
+ best, state = v, {k_: x.detach().cpu().clone() for k_, x in head.state_dict().items()}
128
+ head = head.cpu(); head.load_state_dict(state); head.eval()
129
+ return head, best
130
+
131
+
132
+ def g100(a, b, seed=0):
133
+ rng = np.random.default_rng(seed)
134
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
135
+ for _ in range(DRAWS):
136
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
137
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
138
+ for S in (sim, sim.T):
139
+ rank = (S > d[:, None]).sum(1)
140
+ for k_ in (1, 5, 10):
141
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
142
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
143
+ ri, ci = linear_sum_assignment(-sim)
144
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
145
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
146
+
147
+
148
+ nz = lambda x: F.normalize(torch.as_tensor(np.asarray(x), dtype=torch.float32), dim=-1)
149
+
150
+
151
+ def mif_head(k):
152
+ 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")
153
+ h = mlp(d["state"]["if"]["0.weight"].shape[1]); h.load_state_dict(d["state"]["if"]); h.eval()
154
+ return lambda x: h((torch.as_tensor(x, dtype=torch.float32) - d["if_mean"]) / d["if_std"])
155
+
156
+
157
+ def text_head(k):
158
+ sd = torch.load(os.path.join(RUNS, "text_clean", f"uni2_biomedclip_fold{k}", "head.pt"), map_location="cpu")
159
+ h = mlp(sd["0.weight"].shape[1], sd["6.weight"].shape[0]); h.load_state_dict(sd); h.eval()
160
+ return lambda x: h(torch.as_tensor(x, dtype=torch.float32))
161
+
162
+
163
+ rows = []
164
+ hset = pd.Series(range(len(hgenes)), index=hgenes)
165
+ htot = np.asarray(hcounts.sum(1)).ravel()
166
+ for st in SETS:
167
+ em = pd.read_csv(os.path.join(E, f"{st}_meta.csv"))
168
+ he = np.load(os.path.join(E, f"{st}_he_uni2.npy"))
169
+ if st == "dlpfc":
170
+ eg = pd.read_csv(os.path.join(E, "dlpfc_genes_full.csv")).gene.values
171
+ ec = np.load(os.path.join(E, "dlpfc_counts_full.npy")).astype(np.float32)
172
+ eidx = pd.Series(range(len(eg)), index=eg)
173
+ eidx = eidx[~eidx.index.duplicated()]
174
+ common = [g for g in hgenes if g in eidx.index]
175
+ genes = [g for g in hvg if g in eidx.index]
176
+ etot = ec[:, eidx.loc[common].values].sum(1) # library size over the HEST common genes
177
+ excl = HM.group.str.startswith("spatialLIBD").values # DLPFC study removed from training
178
+ htot_ = htot
179
+ else:
180
+ eg = pd.read_csv(os.path.join(E, "idc_genes.csv")).gene.values
181
+ ec = np.load(os.path.join(E, "idc_counts.npy")).astype(np.float32)
182
+ eidx = pd.Series(range(len(eg)), index=eg)
183
+ eidx = eidx[~eidx.index.duplicated()]
184
+ genes = [g for g in eg if g in hset.index]
185
+ etot = ec[:, eidx.loc[genes].values].sum(1) # library size over the panel genes
186
+ htot_ = np.asarray(hcounts[:, hset.loc[genes].values].sum(1)).ravel()
187
+ excl = HM["sample"].isin(em["sample"].unique()).values
188
+ Gh = lognorm(hcounts[:, hset.loc[genes].values].toarray(), htot_)
189
+ Ge = lognorm(ec[:, eidx.loc[genes].values], etot)
190
+ pool = np.flatnonzero(~excl & (htot_ > 0))
191
+ print(f"{st}: {len(em)} external spots, {len(genes)} genes, HEST training pool {len(pool)} spots "
192
+ f"({int(excl.sum())} excluded)", flush=True)
193
+ f_mv = os.path.join(CACHE, f"if_virtues_m16_virtual_miphei_{st}.npy")
194
+ mv = np.load(f_mv)[:, 0].astype(np.float32) if os.path.exists(f_mv) else None
195
+ f_t = os.path.join(CACHE, f"{st}_gentext_text_biomedclip.npy")
196
+ tx = np.load(f_t)[:, 0].astype(np.float32) if os.path.exists(f_t) else None
197
+ zh = nz(he)
198
+ for k in range(K):
199
+ grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
200
+ va_g = set(grp[: max(1, len(grp) // 10)])
201
+ isva = HM.group.iloc[pool].isin(va_g).values
202
+ tr, va = pool[~isva], pool[isva]
203
+ for norm in ["z-scored per dataset", "plain"]:
204
+ Gtr = zscore(Gh, Gh[tr]) if norm != "plain" else Gh
205
+ Gex = zscore(Ge, Ge) if norm != "plain" else Ge
206
+ head, best = train_head(Gtr, tr, va, seed=k)
207
+ with torch.no_grad():
208
+ zg = nz(head(torch.from_numpy(Gex)))
209
+ rows.append({"set": st, "fold": k, "rna_input": norm, "part": "real", "pair": "H&E<->RNA",
210
+ "kind": "trained pair (external)", "circular": False, "n": len(zh), "val_R@1": best, **g100(zh, zg)})
211
+ if mv is not None and norm == "z-scored per dataset":
212
+ with torch.no_grad():
213
+ zm = nz(mif_head(k)(mv))
214
+ for pair, a, b, kind, circ in [("RNA<->mIF(MIPHEI)", zg, zm, "emergent: real x virtual", False),
215
+ ("H&E<->mIF(MIPHEI)", zh, zm, "virtual from this H&E", True)]:
216
+ rows.append({"set": st, "fold": k, "rna_input": norm, "part": "virtual", "pair": pair, "kind": kind,
217
+ "circular": circ, "n": len(zh), **g100(a, b)})
218
+ if tx is not None and norm == "z-scored per dataset":
219
+ with torch.no_grad():
220
+ zt = nz(text_head(k)(tx))
221
+ zm = nz(mif_head(k)(mv)) if mv is not None else None
222
+ for pair, a, b, kind, circ in [("RNA<->text(synthetic)", zg, zt, "emergent: real x virtual", False),
223
+ ("H&E<->text(synthetic)", zh, zt, "virtual from this H&E", True),
224
+ ("mIF(MIPHEI)<->text(synthetic)", zm, zt, "emergent: virtual x virtual", True)]:
225
+ if a is not None:
226
+ rows.append({"set": st, "fold": k, "rna_input": norm, "part": "virtual", "pair": pair, "kind": kind,
227
+ "circular": circ, "n": len(zh), **g100(a, b)})
228
+ if norm == "z-scored per dataset": # embeddings for 39_external_check.py
229
+ np.savez(os.path.join(E, f"emb_{st}_fold{k}.npz"), he=zh.numpy(), rna=zg.numpy(),
230
+ **({"mif": zm.numpy()} if mv is not None else {}), **({"text": zt.numpy()} if tx is not None else {}))
231
+ print(f" {st} head {k}: val R@1 {best:.3f}", flush=True)
232
+
233
+ R = pd.DataFrame(rows)
234
+ R.to_csv(os.path.join(OUT, "external_long.csv"), index=False)
235
+ cols = ["R@1", "R@5", "R@10", "MRR", "pair_acc"]
236
+ S = R.groupby(["set", "part", "pair", "rna_input", "kind", "circular"], sort=False)[cols + ["n"]].agg(["mean", "std"])
237
+ S.round(4).to_csv(os.path.join(OUT, "external_summary.csv"))
238
+ pd.set_option("display.width", 250)
239
+ print(R.groupby(["set", "part", "pair", "rna_input", "circular"], sort=False)[cols + ["n"]].mean().round(3).to_string())
experiments/39_external_check.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sanity checks of the external results (38_external_eval.py; embeddings cache/external/emb_<set>_fold<k>.npz).
2
+
3
+ Galleries in 38_ mix spots of several samples (IDC 4 samples, DLPFC 12 sections). A model that only recognises the
4
+ SAMPLE (batch / stain / platform effects) and is random within it already beats chance there. Per pair and set:
5
+ mixed G : the 38_ metric (G = 100 random spots from all samples), both directions
6
+ sample oracle : R@1 of a model that knows the sample exactly and is random within it = mean 1/n_same_sample
7
+ top-1 same samp : fraction of top-1 hits from the query's own sample (base rate = share of same-sample items)
8
+ within-sample G : galleries drawn from ONE sample only (IDC G = 100; DLPFC G = 35 since sections have 35-48 spots,
9
+ with the mixed metric at G = 35 for comparison) -> spot-level signal beyond sample identity
10
+ shuffled null : the mixed metric after permuting one side (checks the metric code; ~1/G)
11
+ Output results/external_check.csv
12
+ """
13
+ import os
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from common import CACHE, ROOT
19
+
20
+ E = os.path.join(CACHE, "external")
21
+ PAIRS = [("he", "rna"), ("rna", "mif"), ("he", "mif"), ("rna", "text"), ("he", "text"), ("mif", "text")]
22
+ NAME = {"he": "H&E", "rna": "RNA", "mif": "mIF(MIPHEI)", "text": "text(synthetic)"}
23
+ DRAWS = 200
24
+
25
+
26
+ def r1(a, b, s):
27
+ sim = a[s] @ b[s].T; d = np.diag(sim)[:, None]
28
+ return ((sim > d).sum(1) == 0).mean() / 2 + ((sim > d.T).sum(0) == 0).mean() / 2, sim
29
+
30
+
31
+ def run(a, b, smp, G, within, rng):
32
+ out = {"R@1": [], "oracle": [], "top1_same": [], "base_same": []}
33
+ uniq = [u for u in np.unique(smp) if (smp == u).sum() >= G]
34
+ for _ in range(DRAWS):
35
+ if within:
36
+ idx = np.flatnonzero(smp == uniq[rng.integers(len(uniq))])
37
+ s = rng.choice(idx, G, replace=False)
38
+ else:
39
+ s = rng.choice(len(a), G, replace=False)
40
+ v, sim = r1(a, b, s)
41
+ g = smp[s]; same = g[:, None] == g[None, :]
42
+ out["R@1"].append(v)
43
+ out["oracle"].append((1 / same.sum(1)).mean())
44
+ out["top1_same"].append((same[np.arange(G), sim.argmax(1)].mean() + same[sim.argmax(0), np.arange(G)].mean()) / 2)
45
+ out["base_same"].append(same.mean())
46
+ return {k: float(np.mean(v)) for k, v in out.items()}
47
+
48
+
49
+ rows = []
50
+ for st, Gw in [("dlpfc", 35)]: # IDC dropped from the report 2026-10-18
51
+ smp = pd.read_csv(os.path.join(E, f"{st}_meta.csv"))["sample"].astype(str).values
52
+ for k in range(5):
53
+ Z = np.load(os.path.join(E, f"emb_{st}_fold{k}.npz"))
54
+ for x, y in PAIRS:
55
+ if x not in Z or y not in Z:
56
+ continue
57
+ a, b = Z[x], Z[y]
58
+ rng = np.random.default_rng(k)
59
+ base = {"set": st, "fold": k, "pair": f"{NAME[x]}<->{NAME[y]}"}
60
+ m = run(a, b, smp, 100, False, rng)
61
+ rows.append({**base, "gallery": "mixed G=100", "chance": 0.01, **m})
62
+ rows.append({**base, "gallery": "shuffled null G=100", "chance": 0.01,
63
+ **run(a, b[rng.permutation(len(b))], smp, 100, False, rng)})
64
+ if Gw != 100:
65
+ rows.append({**base, "gallery": f"mixed G={Gw}", "chance": 1 / Gw, **run(a, b, smp, Gw, False, rng)})
66
+ rows.append({**base, "gallery": f"within-sample G={Gw}", "chance": 1 / Gw, **run(a, b, smp, Gw, True, rng)})
67
+
68
+ R = pd.DataFrame(rows)
69
+ R.to_csv(os.path.join(ROOT, "results", "external_check.csv"), index=False)
70
+ S = R.groupby(["set", "pair", "gallery"], sort=False).agg(
71
+ R1=("R@1", "mean"), R1_sd=("R@1", "std"), chance=("chance", "first"), oracle=("oracle", "mean"),
72
+ top1_same=("top1_same", "mean"), base_same=("base_same", "mean"))
73
+ S["x_chance"] = S.R1 / S.chance
74
+ pd.set_option("display.width", 250)
75
+ print(S.round(3).to_string())
experiments/40_clean_text.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Remove junk texts from the TCGA image-text pairs (cache/alltxt_meta.csv) and list the images left without real text.
2
+ Junk = bare multiple-choice answer letters ("A A B B"), "error error" strings, or < 5 words (answers without question).
3
+ Output cache/alltxt_junk.csv (idx = row of alltxt_meta.csv, junk, reason) -> 33_ trains / tests on non-junk only
4
+ cache/notext_meta.csv (img_idx, image_path, patient) images whose texts are all junk -> 27_ --sets tcga_notext
5
+ (few-shot GPT captions; VALIDATION ONLY, never trained on)
6
+ """
7
+ import os
8
+
9
+ import pandas as pd
10
+
11
+ from common import CACHE
12
+
13
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
14
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
15
+ c = T.caption.astype(str)
16
+ w = c.str.split().str.len()
17
+ reason = pd.Series("", index=T.index)
18
+ reason[w < 5] = "fewer than 5 words"
19
+ reason[c.str.lower().str.fullmatch(r"(\s*error\s*)+")] = "error string"
20
+ reason[c.str.fullmatch(r"\s*([A-Ea-e][\s,.;]*)+")] = "answer letters"
21
+ J = pd.DataFrame({"idx": T.index, "junk": reason != "", "reason": reason})
22
+ J.to_csv(os.path.join(CACHE, "alltxt_junk.csv"), index=False)
23
+ ok_img = set(T.img_idx[~J.junk])
24
+ N = I[~I.index.isin(ok_img)]
25
+ pd.DataFrame({"img_idx": N.index, "image_path": N.image_path, "patient": N.patient}).to_csv(
26
+ os.path.join(CACHE, "notext_meta.csv"), index=False)
27
+ print(f"texts {len(T)}: junk {J.junk.sum()} {J[J.junk].reason.value_counts().to_dict()}; clean {(~J.junk).sum()}")
28
+ print(f"images {len(I)}: with clean real text {len(ok_img)}, without -> notext_meta.csv {len(N)}")
experiments/41_text_enc_clean.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Re-check of the H&E <-> text encoder comparison (20_pairing.py, 28_) under the final protocol with CLEAN real text:
2
+ TCGA splits/final/tcga_fold<k>.csv (patient 5-fold, val = early stopping only), junk texts removed (40_clean_text.py),
3
+ identical text_head recipe to 33_final_eval.py (frozen image anchor, MLP on frozen text features, multi-positive InfoNCE).
4
+ Image anchors x text towers; PathGen-CLIP is left out (trained on PathGen-1.6M = most of this pool -> not held out).
5
+ Output results/text_enc_clean_long.csv, text_enc_clean_summary.csv
6
+ """
7
+ import os
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ from scipy.optimize import linear_sum_assignment
15
+
16
+ from common import CACHE, ROOT
17
+
18
+ IMGS = ["uni2", "phikonv2", "gigapath", "conch"]
19
+ TXTS = ["biomedclip", "plip", "imagebind", "conchclip"]
20
+ K, G100, DRAWS = 5, 100, 50
21
+ dev = "cuda"
22
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
23
+ t_img = torch.as_tensor(T.img_idx.values, device=dev)
24
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
25
+
26
+
27
+ def mlp(din, dout, hid=512): # = 33_
28
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
29
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
30
+
31
+
32
+ def one_text_per_image(t_ids, rng): # = 33_
33
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
34
+ return pick.index.values, pick.values
35
+
36
+
37
+ def g100(a, b, seed=0): # = 33_
38
+ rng = np.random.default_rng(seed)
39
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
40
+ for _ in range(DRAWS):
41
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
42
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
43
+ for S in (sim, sim.T):
44
+ rank = (S > d[:, None]).sum(1)
45
+ for k_ in (1, 5, 10):
46
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
47
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
48
+ ri, ci = linear_sum_assignment(-sim)
49
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
50
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
51
+
52
+
53
+ def train(X, Tx, tr_t, va_t, epochs=60, bs=256, lr=3e-4, seed=0): # = 33_ train_text_head
54
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
55
+ th = mlp(Tx.shape[1], X.shape[-1]).to(dev)
56
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
57
+ opt = torch.optim.AdamW(list(th.parameters()) + [logit], lr=lr, weight_decay=0.05)
58
+ sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr_t) // bs), pct_start=0.05)
59
+ va_i, va_tt = one_text_per_image(va_t, np.random.default_rng(0))
60
+ za_va = F.normalize(X[torch.as_tensor(va_i, device=dev), 0], dim=-1)
61
+ best, state = -1, None
62
+ for ep in range(epochs):
63
+ th.train()
64
+ perm = rng.permutation(tr_t)
65
+ for s in range(0, len(perm) - bs + 1, bs):
66
+ b = torch.as_tensor(perm[s:s + bs], device=dev)
67
+ ii = t_img[b]
68
+ zi = F.normalize(X[ii, torch.randint(0, X.shape[1], (len(b),), device=dev)], dim=-1)
69
+ lg = logit.exp().clamp(max=100) * zi @ F.normalize(th(Tx[b]), dim=-1).T
70
+ pos = (ii[:, None] == ii[None, :]).float()
71
+ tgt = pos / pos.sum(1, keepdim=True)
72
+ loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
73
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
74
+ th.eval()
75
+ with torch.no_grad():
76
+ sim = za_va @ F.normalize(th(Tx[torch.as_tensor(va_tt, device=dev)]), dim=-1).T
77
+ v = float((((sim > sim.diag()[:, None]).sum(1) == 0).float().mean() + ((sim.T > sim.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
78
+ if v > best:
79
+ best, state = v, {k_: x.detach().clone() for k_, x in th.state_dict().items()}
80
+ th.load_state_dict(state); th.eval()
81
+ return th, best
82
+
83
+
84
+ rows = []
85
+ for ie in IMGS:
86
+ X = torch.from_numpy(np.load(os.path.join(CACHE, f"allimg_he_{ie}.npy")).astype(np.float32)).to(dev)
87
+ for te_ in TXTS:
88
+ Tx = torch.from_numpy(np.load(os.path.join(CACHE, f"alltxt_text_{te_}.npy"))[:, 0].astype(np.float32)).to(dev)
89
+ for k in range(K):
90
+ sp = pd.read_csv(os.path.join(ROOT, "splits", "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
91
+ tsp = sp.loc[T.img_idx].values
92
+ tr_t, va_t, te_t = (T.index[tsp == x].values for x in ["train", "val", "test"])
93
+ th, best = train(X, Tx, tr_t, va_t)
94
+ ii, tt = one_text_per_image(te_t, np.random.default_rng(k))
95
+ with torch.no_grad():
96
+ za = F.normalize(X[torch.as_tensor(ii, device=dev), 0], dim=-1).cpu()
97
+ zt = F.normalize(th(Tx[torch.as_tensor(tt, device=dev)]), dim=-1).cpu()
98
+ rows.append({"img_enc": ie, "txt_enc": te_, "fold": k, "val_R@1": best, "n_test": len(ii), **g100(za, zt)})
99
+ print(f"{ie} + {te_} fold {k}: val {best:.3f} test R@1 {rows[-1]['R@1']:.3f}", flush=True)
100
+ R = pd.DataFrame(rows)
101
+ R.to_csv(os.path.join(ROOT, "results", "text_enc_clean_long.csv"), index=False)
102
+ S = R.groupby(["img_enc", "txt_enc"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
103
+ S.to_csv(os.path.join(ROOT, "results", "text_enc_clean_summary.csv"))
104
+ pd.set_option("display.width", 200)
105
+ print(R.pivot_table(index="img_enc", columns="txt_enc", values="R@1", sort=False).round(3).to_string())
experiments/42_train_regimes.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training-regime comparison: SEPARATE vs JOINT training of the four-modality model (real pairs only, final protocol).
2
+
3
+ Real paired data (fold k; train / val (early stopping only) / test, patients / study groups never cross splits):
4
+ ORION H&E <-> mIF splits/patient_5fold/fold<k>.csv UNI2 (8 views) <-> VirTues 16-ch MIPHEI panel (8 aligned views)
5
+ HEST H&E <-> RNA splits/final/hest_fold<k>.csv UNI2 <-> log1p CP10k of the 2000 HVGs
6
+ TCGA H&E <-> text splits/final/tcga_fold<k>.csv UNI2 (8 views) <-> BiomedCLIP, CLEAN real texts (40_)
7
+ Regimes (same MLP heads, InfoNCE, AdamW 3e-4 / wd 0.05 / OneCycle, batch 256, one temperature per pair):
8
+ S1 separate, frozen anchor modality head -> frozen UNI2 space; three independent trainings (= current method)
9
+ S2 separate, two-tower per pair a trainable H&E head + modality head (CLIP-style); no shared space
10
+ J1 joint, frozen anchor S1 heads trained in one loop (sum of the 3 losses), joint early stopping
11
+ J2 joint, shared H&E head ONE trainable H&E head shared by all pairs + 3 modality heads (ImageBind with
12
+ a learned anchor projection)
13
+ J3 J2, size-proportional one task per step, sampled proportional to its training-set size
14
+ J4 J2 + anchor distillation + lambda * MSE between in-batch cosine-similarity matrices of UNI2 and of the projected
15
+ H&E (keeps the shared head close to the UNI2 geometry)
16
+ J5 joint, residual adapter H&E = UNI2 + shared zero-initialised MLP adapter (frozen anchor + small shared change)
17
+ S3 separate, frozen, linear S1 with a single linear layer per modality (head capacity)
18
+ J4b J4 with lambda = 10 stronger distillation
19
+ J6 J2 + uncertainty weighting learnable per-task log-variance s_t: sum_t exp(-s_t) L_t + s_t (Kendall et al. 2018)
20
+ S4 separate, hybrid heads S1 with MLP head for mIF, linear heads for RNA and text
21
+ J8 two-stage from S4 J7 starting from the S4 heads
22
+ J7 two-stage S1 heads, then joint fine-tuning of S1 heads + residual adapter (lr 1e-4, 1000 steps,
23
+ distillation lambda 1)
24
+ Early stopping: separate = own val R@1; joint = geometric mean of the three val R@1 (every 50 steps).
25
+ Test (fold k): trained pairs H&E<->mIF / RNA / text, and emergent pairs with test-time-only virtual data:
26
+ ORION mIF<->RNA(STPath virtual), mIF<->text(GPT synthetic); TCGA text<->RNA(STPath virtual), text<->mIF(MIPHEI virtual).
27
+ Usage: python 42_train_regimes.py [--regimes S1 S2 J1 J2 J3 J4 J5] [--folds 0 1 2 3 4]
28
+ Output results/regimes_long.csv, results/regimes_summary.csv
29
+ """
30
+ import argparse
31
+ import os
32
+
33
+ import numpy as np
34
+ import pandas as pd
35
+ import torch
36
+ import torch.nn as nn
37
+ import torch.nn.functional as F
38
+ from scipy.optimize import linear_sum_assignment
39
+
40
+ from common import CACHE, ROOT, SPLITS
41
+
42
+ ap = argparse.ArgumentParser()
43
+ ap.add_argument("--regimes", nargs="+", default=["S1", "S2", "S3", "S4", "J1", "J2", "J3", "J4", "J4b", "J5", "J6", "J7", "J8"])
44
+ ap.add_argument("--folds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
45
+ args = ap.parse_args()
46
+ dev = "cuda"
47
+ H = os.path.join(CACHE, "hest")
48
+ G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
49
+ SEP_STEPS = {"mif": 400, "rna": 2700, "text": 1000} # = 40 / 100 / 60 epochs of 17_ / 23_ / 33_
50
+ JOINT_STEPS = {"equal": 2000, "prop": 4000}
51
+ f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
52
+
53
+ # ---------------------------------------------------------------- data (all on GPU)
54
+ HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev) # (3187, 8, 1536)
55
+ IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev) # (3187, 8, 512)
56
+ IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
57
+ HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev) # (9484, 1536)
58
+ RNA_H = f32(np.load(os.path.join(H, "hvg.npy"))).to(dev) # (9484, 2000)
59
+ HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev) # (6144, 8, 1536)
60
+ TXT = f32(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[:, 0]).to(dev)
61
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
62
+ T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
63
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
64
+ GT_O = f32(np.load(os.path.join(CACHE, "orion_gentext_text_biomedclip.npy"))[:, 0]).to(dev)
65
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
66
+
67
+
68
+ def virtual_rna(st): # = 33_final_eval.py
69
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
70
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
71
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
72
+ return f32(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]).to(dev)
73
+
74
+
75
+ RNA_V_O, RNA_V_T = virtual_rna("orion"), virtual_rna("tcga_text")
76
+
77
+
78
+ def mlp(din, dout=1536, hid=512):
79
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
80
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
81
+
82
+
83
+ def one_text_per_image(t_ids, rng):
84
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
85
+ return pick.index.values, pick.values
86
+
87
+
88
+ def g100(a, b, seed=0): # = 33_
89
+ a, b = a.cpu(), b.cpu()
90
+ rng = np.random.default_rng(seed)
91
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
92
+ for _ in range(DRAWS):
93
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
94
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
95
+ for S in (sim, sim.T):
96
+ rank = (S > d[:, None]).sum(1)
97
+ for k_ in (1, 5, 10):
98
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
99
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
100
+ ri, ci = linear_sum_assignment(-sim)
101
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
102
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
103
+
104
+
105
+ def r1(a, b):
106
+ s = a @ b.T
107
+ return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
108
+
109
+
110
+ class Model(nn.Module):
111
+ def __init__(self, anchor, tasks, head="mlp", uw=False):
112
+ super().__init__()
113
+ self.anchor = anchor
114
+ kind = head if isinstance(head, dict) else {t: head for t in ("mif", "rna", "text")}
115
+ mk = lambda t, d: mlp(d) if kind[t] == "mlp" else nn.Linear(d, 1536)
116
+ self.heads = nn.ModuleDict({"mif": mk("mif", 512), "rna": mk("rna", 2000), "text": mk("text", 512)})
117
+ self.uw = nn.ParameterDict({t: nn.Parameter(torch.zeros(())) for t in tasks}) if uw else None
118
+ self.logit = nn.ParameterDict({t: nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32)) for t in tasks})
119
+ if anchor == "shared":
120
+ self.ah = mlp(1536)
121
+ elif anchor == "pair":
122
+ self.ah = nn.ModuleDict({t: mlp(1536) for t in tasks})
123
+ elif anchor == "residual":
124
+ self.ah = mlp(1536)
125
+ nn.init.zeros_(self.ah[-1].weight); nn.init.zeros_(self.ah[-1].bias)
126
+
127
+ def he(self, x, task):
128
+ if self.anchor == "frozen":
129
+ return x
130
+ if self.anchor == "shared":
131
+ return self.ah(x)
132
+ if self.anchor == "pair":
133
+ return self.ah[task](x)
134
+ return x + self.ah(x) # residual
135
+
136
+
137
+ class Fold:
138
+ def __init__(self, k):
139
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
140
+ self.o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
141
+ self.if_mu = IF_O[self.o["train"]].mean((0, 1)); self.if_sd = IF_O[self.o["train"]].std((0, 1)) + 1e-3
142
+ h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
143
+ self.h = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
144
+ sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
145
+ tsp = sp.loc[T.img_idx].values
146
+ self.t = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
147
+ self.t_va = one_text_per_image(self.t["val"], np.random.default_rng(0))
148
+ self.t_te = one_text_per_image(self.t["test"], np.random.default_rng(k))
149
+ self.n = {"mif": len(self.o["train"]), "rna": len(self.h["train"]), "text": len(self.t["train"])}
150
+
151
+ def nif(self, x):
152
+ return (x - self.if_mu) / self.if_sd
153
+
154
+
155
+ def batch_loss(m, F_, task, rng):
156
+ if task == "mif":
157
+ b = torch.as_tensor(rng.choice(F_.o["train"], BS, replace=False), device=dev)
158
+ d = torch.randint(0, 8, (BS,), device=dev)
159
+ x_he, x_m = HE_O[b, d], F_.nif(IF_O[b, d]); pos = None
160
+ elif task == "rna":
161
+ b = torch.as_tensor(rng.choice(F_.h["train"], BS, replace=False), device=dev)
162
+ x_he, x_m = HE_H[b], RNA_H[b]; pos = None
163
+ else:
164
+ b = torch.as_tensor(rng.choice(F_.t["train"], BS, replace=False), device=dev)
165
+ ii = T_IMG[b]
166
+ x_he, x_m = HE_T[ii, torch.randint(0, 8, (BS,), device=dev)], TXT[b]
167
+ pos = (ii[:, None] == ii[None, :]).float()
168
+ za = F.normalize(m.he(x_he, task), dim=-1)
169
+ zm = F.normalize(m.heads[task](x_m), dim=-1)
170
+ lg = m.logit[task].exp().clamp(max=100) * za @ zm.T
171
+ if pos is None:
172
+ t = torch.arange(BS, device=dev)
173
+ loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
174
+ else:
175
+ tgt = pos / pos.sum(1, keepdim=True)
176
+ loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
177
+ if m.uw is not None:
178
+ loss = torch.exp(-m.uw[task]) * loss + m.uw[task]
179
+ if m.distill and m.anchor != "frozen":
180
+ u = F.normalize(x_he, dim=-1)
181
+ loss = loss + m.distill * F.mse_loss(za @ za.T, u @ u.T)
182
+ return loss
183
+
184
+
185
+ @torch.no_grad()
186
+ def embed(m, F_, task, split):
187
+ if task == "mif":
188
+ idx = F_.o[split]
189
+ return F.normalize(m.he(HE_O[idx, 0], task), dim=-1), F.normalize(m.heads["mif"](F_.nif(IF_O[idx, 0])), dim=-1)
190
+ if task == "rna":
191
+ idx = F_.h[split]
192
+ return F.normalize(m.he(HE_H[idx], task), dim=-1), F.normalize(m.heads["rna"](RNA_H[idx]), dim=-1)
193
+ ii, tt = F_.t_va if split == "val" else F_.t_te
194
+ ii, tt = torch.as_tensor(ii, device=dev), torch.as_tensor(tt, device=dev)
195
+ return F.normalize(m.he(HE_T[ii, 0], task), dim=-1), F.normalize(m.heads["text"](TXT[tt]), dim=-1)
196
+
197
+
198
+ def val_scores(m, F_, tasks):
199
+ m.eval()
200
+ v = {t: r1(*embed(m, F_, t, "val")) for t in tasks}
201
+ m.train()
202
+ return v
203
+
204
+
205
+ def train(anchor, tasks, F_, steps, sampling="equal", distill=0.0, seed=0, head="mlp", uw=False, init=None, lr=LR):
206
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
207
+ m = Model(anchor, tasks, head, uw).to(dev); m.distill = distill
208
+ if init is not None: # two-stage: start from separately trained heads / temperatures
209
+ for t, src in init.items():
210
+ m.heads[t].load_state_dict(src.heads[t].state_dict()); m.logit[t].data.copy_(src.logit[t].data)
211
+ opt = torch.optim.AdamW(m.parameters(), lr=lr, weight_decay=0.05)
212
+ sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=steps, pct_start=0.05)
213
+ p = np.array([F_.n[t] for t in tasks], float); p /= p.sum()
214
+ best, state, bv = -1, None, None
215
+ m.train()
216
+ for step in range(1, steps + 1):
217
+ todo = tasks if sampling == "equal" else [tasks[rng.choice(len(tasks), p=p)]]
218
+ loss = sum(batch_loss(m, F_, t, rng) for t in todo)
219
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
220
+ if step % EVAL_EVERY == 0 or step == steps:
221
+ v = val_scores(m, F_, tasks)
222
+ score = float(np.exp(np.mean(np.log([max(x, 1e-4) for x in v.values()]))))
223
+ if score > best:
224
+ best, bv, state = score, v, {k_: x.detach().clone() for k_, x in m.state_dict().items()}
225
+ m.load_state_dict(state); m.eval()
226
+ return m, bv
227
+
228
+
229
+ @torch.no_grad()
230
+ def evaluate(models, F_):
231
+ """models: task -> model holding that task's head (same object for joint regimes)"""
232
+ out = {}
233
+ for t, lab in [("mif", "H&E<->mIF (ORION)"), ("rna", "H&E<->RNA (HEST)"), ("text", "H&E<->text (TCGA)")]:
234
+ out[(lab, "trained")] = g100(*embed(models[t], F_, t, "test"))
235
+ te = F_.o["test"]
236
+ zm = F.normalize(models["mif"].heads["mif"](F_.nif(IF_O[te, 0])), dim=-1)
237
+ zr = F.normalize(models["rna"].heads["rna"](RNA_V_O[te]), dim=-1)
238
+ zt = F.normalize(models["text"].heads["text"](GT_O[te]), dim=-1)
239
+ out[("mIF<->RNA(virtual) (ORION)", "emergent")] = g100(zm, zr)
240
+ out[("mIF<->text(synthetic) (ORION)", "emergent")] = g100(zm, zt)
241
+ ii, tt = F_.t_te
242
+ ii, tt = torch.as_tensor(ii, device=dev), torch.as_tensor(tt, device=dev)
243
+ zt = F.normalize(models["text"].heads["text"](TXT[tt]), dim=-1)
244
+ zr = F.normalize(models["rna"].heads["rna"](RNA_V_T[ii]), dim=-1)
245
+ zv = F.normalize(models["mif"].heads["mif"](F_.nif(IF_V_T[ii])), dim=-1)
246
+ out[("text<->RNA(virtual) (TCGA)", "emergent")] = g100(zt, zr)
247
+ out[("text<->mIF(MIPHEI) (TCGA)", "emergent")] = g100(zt, zv)
248
+ return out
249
+
250
+
251
+ HYB = {"mif": "mlp", "rna": "linear", "text": "linear"} # per-modality head capacity
252
+ REG = {"S1": dict(anchor="frozen", mode="separate"), "S2": dict(anchor="pair", mode="separate"),
253
+ "S3": dict(anchor="frozen", mode="separate", head="linear"),
254
+ "J1": dict(anchor="frozen", mode="joint"), "J2": dict(anchor="shared", mode="joint"),
255
+ "J3": dict(anchor="shared", mode="joint", sampling="prop"), "J4": dict(anchor="shared", mode="joint", distill=1.0),
256
+ "J4b": dict(anchor="shared", mode="joint", distill=10.0), "J5": dict(anchor="residual", mode="joint"),
257
+ "J6": dict(anchor="shared", mode="joint", uw=True), "J7": dict(anchor="residual", mode="two-stage", distill=1.0),
258
+ "S4": dict(anchor="frozen", mode="separate", head=HYB), "J8": dict(anchor="residual", mode="two-stage", distill=1.0, head=HYB)}
259
+ NAME = {"S1": "separate, frozen anchor", "S2": "separate, two-tower", "J1": "joint, frozen anchor",
260
+ "J2": "joint, shared H&E head", "J3": "joint, shared H&E head, size-prop.", "J4": "joint, shared H&E head + distill",
261
+ "J5": "joint, residual adapter", "S3": "separate, frozen anchor, linear head",
262
+ "J4b": "joint, shared H&E head + distill x10", "J6": "joint, shared H&E head, uncertainty wt.",
263
+ "J7": "two-stage: separate -> joint residual", "S4": "separate, frozen, MLP mIF + linear RNA/text",
264
+ "J8": "two-stage from S4 heads -> joint residual"}
265
+ TASKS = ["mif", "rna", "text"]
266
+ f_out = os.path.join(ROOT, "results", "regimes_long.csv")
267
+ rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
268
+ done = {(r["regime"], r["fold"]) for r in rows}
269
+ for k in args.folds:
270
+ F_ = Fold(k)
271
+ for rg in args.regimes:
272
+ if (rg, k) in done:
273
+ continue
274
+ c = REG[rg]
275
+ if c["mode"] in ("separate", "two-stage"):
276
+ models, val = {}, {}
277
+ for t in TASKS:
278
+ models[t], v = train("frozen" if c["mode"] == "two-stage" else c["anchor"], [t], F_, SEP_STEPS[t],
279
+ head=c.get("head", "mlp"))
280
+ val.update(v)
281
+ if c["mode"] == "joint":
282
+ sm = c.get("sampling", "equal")
283
+ m, val = train(c["anchor"], TASKS, F_, JOINT_STEPS[sm], sm, c.get("distill", 0.0), uw=c.get("uw", False))
284
+ models = {t: m for t in TASKS}
285
+ elif c["mode"] == "two-stage":
286
+ m, val = train(c["anchor"], TASKS, F_, 1000, "equal", c["distill"], init=models, lr=1e-4, head=c.get("head", "mlp"))
287
+ models = {t: m for t in TASKS}
288
+ for (pair, kind), r in evaluate(models, F_).items():
289
+ rows.append({"regime": rg, "name": NAME[rg], "fold": k, "pair": pair, "kind": kind, **r,
290
+ "val_mif": val["mif"], "val_rna": val["rna"], "val_text": val["text"]})
291
+ pd.DataFrame(rows).to_csv(f_out, index=False)
292
+ last = [r for r in rows if r["regime"] == rg and r["fold"] == k]
293
+ print(f"fold {k} {rg:>2} {NAME[rg]:<36} " + " ".join(f"{r['pair'].split(' (')[0]} {r['R@1']:.3f}" for r in last), flush=True)
294
+
295
+ R = pd.DataFrame(rows)
296
+ S = R.groupby(["regime", "name", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
297
+ S.to_csv(os.path.join(ROOT, "results", "regimes_summary.csv"))
298
+ pd.set_option("display.width", 250)
299
+ print(R.pivot_table(index=["regime", "name"], columns="pair", values="R@1", sort=False).round(3).to_string())
experiments/43_rnafm_base.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen RNA embeddings for every RNA set of rna_sets.py (hest, hest_v, orion_v, tcga_v, dlpfc).
2
+
3
+ python 43_rnafm_base.py scgpt | geneformer | genept-bge | genept-bmc
4
+
5
+ scgpt / geneformer : same preprocessing and readout as 22_extract_gene_fm.py (scGPT-human <cls> on the HEST 2000 HVGs
6
+ in vocab, quantile-binned log1p CP10k, <=1200 genes; Geneformer-V2-104M rank-value encoding,
7
+ penultimate layer mean over gene tokens). 22_ only embedded HEST; this adds the virtual / external sets.
8
+ genept-* : GenePT-w (Chen & Zou 2024) with open text encoders: each gene = text embedding of its NCBI + UniProt
9
+ summary (GenePT v2 release, Zenodo 10833191, CC BY 4.0, ext/genept/); spot = average of the gene
10
+ embeddings weighted by log1p CP10k over the genes that have a summary, L2-normalised.
11
+ bge = BAAI/bge-base-en-v1.5 (CLS, 512 tokens), bmc = BiomedCLIP PubMedBERT text tower (256 tokens,
12
+ the text tower of the text modality -> RNA lands in the same text space).
13
+ Output cache/rnafm/<model>_<set>.npy (rna_sets.save)
14
+ """
15
+ import importlib
16
+ import json
17
+ import os
18
+ import pickle
19
+ import sys
20
+ import time
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+ import scipy.sparse as sp
25
+ import torch
26
+
27
+ from common import CACHE, ROOT
28
+ from rna_sets import SETS, load, save
29
+
30
+ M15 = importlib.import_module("15_extract_marker_fm")
31
+ name = sys.argv[1]
32
+ sets = sys.argv[2:] or SETS
33
+ dev = "cuda"
34
+ HVG = pd.read_csv(os.path.join(CACHE, "hest", "hvg_genes.csv")).gene.values
35
+ t0 = time.time()
36
+
37
+
38
+ def cp10k_log(X, cols):
39
+ tot = np.asarray(X.sum(1)).ravel()
40
+ L = (sp.diags(1e4 / np.maximum(tot, 1)) @ X[:, cols]).tocsr()
41
+ L.data = np.log1p(L.data)
42
+ return L
43
+
44
+
45
+ def scgpt_model():
46
+ from huggingface_hub import hf_hub_download
47
+ args = json.load(open(hf_hub_download(M15.SCGPT_REPO, "args.json")))
48
+ vocab = json.load(open(hf_hub_download(M15.SCGPT_REPO, "vocab.json")))
49
+ sd = torch.load(hf_hub_download(M15.SCGPT_REPO, "best_model.pt"), map_location="cpu", weights_only=True)
50
+ sd = {k.replace("self_attn.Wqkv.", "self_attn.in_proj_"): v for k, v in sd.items()}
51
+ model = M15.ScGPT(len(vocab), args["embsize"], args["nheads"], args["d_hid"], args["nlayers"], vocab[args["pad_token"]])
52
+ model.load_state_dict(sd, strict=True)
53
+ return model.eval().to(dev), args, vocab
54
+
55
+
56
+ def scgpt(X, genes, model, args, vocab):
57
+ PAD, CLS = vocab[args["pad_token"]], vocab["<cls>"]
58
+ hv = set(HVG)
59
+ gi = np.array([i for i, g in enumerate(genes) if g in hv and g in vocab])
60
+ gid = np.array([vocab[g] for g in genes[gi]])
61
+ L = cp10k_log(X, gi)
62
+ rng = np.random.default_rng(0)
63
+ seqs = []
64
+ for r in range(L.shape[0]):
65
+ nz, v = L.indices[L.indptr[r]:L.indptr[r + 1]], L.data[L.indptr[r]:L.indptr[r + 1]]
66
+ if len(nz) > 1199:
67
+ keep = np.sort(rng.choice(len(nz), 1199, replace=False))
68
+ nz, v = nz[keep], v[keep]
69
+ vals = M15.scgpt_binning(v, args["n_bins"], rng).astype(np.float32) if len(v) else np.zeros(0, np.float32)
70
+ seqs.append((np.r_[CLS, gid[nz]], np.r_[np.float32(args["pad_value"]), vals]))
71
+ print(f" scGPT: {len(gi)} HVGs in vocab; genes/spot median {np.median([len(s[0]) - 1 for s in seqs])}", flush=True)
72
+ embs = []
73
+ with torch.no_grad():
74
+ for i in range(0, len(seqs), 32):
75
+ b = seqs[i:i + 32]
76
+ Lm = max(len(g) for g, _ in b)
77
+ g = torch.full((len(b), Lm), PAD, dtype=torch.long)
78
+ v = torch.full((len(b), Lm), float(args["pad_value"]))
79
+ for j, (gg, vv) in enumerate(b):
80
+ g[j, :len(gg)], v[j, :len(vv)] = torch.from_numpy(gg), torch.from_numpy(vv)
81
+ g, v = g.to(dev), v.to(dev)
82
+ with torch.autocast("cuda", dtype=torch.bfloat16):
83
+ h = model.encode(g, v, g.eq(PAD))
84
+ embs.append(h[:, 0].float().cpu())
85
+ E = torch.cat(embs).numpy()
86
+ return E / np.linalg.norm(E, axis=1, keepdims=True)
87
+
88
+
89
+ def gf_model():
90
+ from transformers import BertModel
91
+ tokd = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/token_dictionary_gc104M.pkl"), "rb"))
92
+ medd = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/gene_median_dictionary_gc104M.pkl"), "rb"))
93
+ n2id = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/gene_name_id_dict_gc104M.pkl"), "rb"))
94
+ mdir = os.path.dirname(M15.hf_file(M15.GF_REPO, "Geneformer-V2-104M/model.safetensors"))
95
+ M15.hf_file(M15.GF_REPO, "Geneformer-V2-104M/config.json")
96
+ model = BertModel.from_pretrained(mdir, add_pooling_layer=False, attn_implementation="sdpa").eval().to(dev)
97
+ return model, tokd, medd, n2id
98
+
99
+
100
+ def geneformer(X, genes, model, tokd, medd, n2id):
101
+ gi = np.array([i for i, g in enumerate(genes) if g in n2id and n2id[g] in tokd and n2id[g] in medd])
102
+ tok = np.array([tokd[n2id[g]] for g in genes[gi]])
103
+ med = np.array([medd[n2id[g]] for g in genes[gi]])
104
+ CLS, EOS, PAD = tokd["<cls>"], tokd["<eos>"], tokd["<pad>"]
105
+ tot = np.asarray(X.sum(1)).ravel()
106
+ Xg = (sp.diags(1e4 / np.maximum(tot, 1)) @ X[:, gi]).tocsr()
107
+ seqs = []
108
+ for r in range(Xg.shape[0]):
109
+ nz, v = Xg.indices[Xg.indptr[r]:Xg.indptr[r + 1]], Xg.data[Xg.indptr[r]:Xg.indptr[r + 1]]
110
+ v = v / med[nz]
111
+ order = nz[np.argsort(-v, kind="stable")][:4094]
112
+ seqs.append([CLS] + tok[order].tolist() + [EOS])
113
+ print(f" Geneformer: {len(gi)} genes in dictionary; tokens/spot median {np.median([len(s) for s in seqs])}", flush=True)
114
+ order_len = np.argsort([len(s) for s in seqs])
115
+ embs = np.zeros((len(seqs), model.config.hidden_size), np.float32)
116
+ with torch.no_grad():
117
+ for k in range(0, len(seqs), 8):
118
+ idx = order_len[k:k + 8]
119
+ b = [seqs[i] for i in idx]
120
+ ids = torch.full((len(b), max(map(len, b))), PAD, dtype=torch.long)
121
+ for j, s in enumerate(b):
122
+ ids[j, :len(s)] = torch.tensor(s)
123
+ ids = ids.to(dev)
124
+ att = (ids != PAD).long()
125
+ with torch.autocast("cuda", dtype=torch.bfloat16):
126
+ h = model(input_ids=ids, attention_mask=att, output_hidden_states=True).hidden_states[-2].float()
127
+ gm = att.clone().float()
128
+ gm[:, 0] = 0
129
+ gm[torch.arange(len(b)), att.sum(1) - 1] = 0
130
+ embs[idx] = ((h * gm[..., None]).sum(1) / gm.sum(1, keepdim=True).clamp(min=1)).cpu().numpy()
131
+ return embs
132
+
133
+
134
+ def gene_text_table(enc):
135
+ """Text embedding of each gene's NCBI + UniProt summary; cached in cache/rnafm/genetext_<enc>.npz."""
136
+ f = os.path.join(CACHE, "rnafm", f"genetext_{enc}.npz")
137
+ if os.path.exists(f):
138
+ z = np.load(f, allow_pickle=False)
139
+ return z["genes"], z["E"]
140
+ summ = json.load(open(os.path.join(ROOT, "ext", "genept", "unz", "GenePT_emebdding_v2", "NCBI_UniProt_summary_of_genes.json")))
141
+ g = np.array(sorted(summ))
142
+ txt = [summ[x] for x in g]
143
+ out = []
144
+ if enc == "bge":
145
+ from transformers import AutoModel, AutoTokenizer
146
+ tk = AutoTokenizer.from_pretrained("BAAI/bge-base-en-v1.5")
147
+ m = AutoModel.from_pretrained("BAAI/bge-base-en-v1.5").to(dev).eval()
148
+ f_ = lambda b: m(**{k: v.to(dev) for k, v in tk(b, padding=True, truncation=True, max_length=512, return_tensors="pt").items()}).last_hidden_state[:, 0]
149
+ else:
150
+ from biomedclip_text import BiomedCLIPText # open_clip-free, reproduces cache/alltxt_text_biomedclip.npy (cos 1.000)
151
+ m = BiomedCLIPText().to(dev).eval()
152
+ f_ = m
153
+ with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
154
+ for i in range(0, len(txt), 128):
155
+ out.append(torch.nn.functional.normalize(f_(txt[i:i + 128]).float(), dim=-1).cpu())
156
+ E = torch.cat(out).numpy()
157
+ os.makedirs(os.path.dirname(f), exist_ok=True)
158
+ np.savez(f, genes=g, E=E)
159
+ print(f" gene texts: {len(g)} genes embedded with {enc} {E.shape}", flush=True)
160
+ return g, E
161
+
162
+
163
+ def genept(X, genes, gt, GE):
164
+ idx = pd.Series(range(len(gt)), index=gt)
165
+ gi = np.array([i for i, g in enumerate(genes) if g in idx.index])
166
+ W = cp10k_log(X, gi)
167
+ print(f" GenePT: {len(gi)}/{len(genes)} genes with a summary", flush=True)
168
+ E = W @ GE[idx.loc[genes[gi]].values]
169
+ E = E / np.maximum(np.asarray(W.sum(1)), 1e-8)
170
+ return E / np.linalg.norm(E, axis=1, keepdims=True).clip(1e-8)
171
+
172
+
173
+ if name == "scgpt":
174
+ ctx = scgpt_model(); fn = scgpt
175
+ elif name == "geneformer":
176
+ ctx = gf_model(); fn = geneformer
177
+ elif name.startswith("genept-"):
178
+ ctx = gene_text_table(name.split("-")[1]); fn = genept
179
+ else:
180
+ raise ValueError(name)
181
+ for s in sets:
182
+ X, genes = load(s)
183
+ print(f"[{name}] {s} {X.shape}", flush=True)
184
+ save(name, s, fn(X, genes, *ctx))
185
+ print(f" {time.time() - t0:.0f}s", flush=True)
experiments/43_rnafm_c2s.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen Cell2Sentence (C2S-Scale-Gemma-2-2B) spot embeddings for the RNA sets of rna_sets.py (spot = pseudo-cell).
2
+
3
+ python 43_rnafm_c2s.py [set ...] (default: all of rna_sets.SETS)
4
+
5
+ Model : HF vandijklab/C2S-Scale-Gemma-2-2B @ 5ddf28b8f1c81b7ab7a9be192924da82b6c5d512 (cc-by-4.0), a Gemma-2 2B
6
+ decoder (26 layers, d=2304) instruction-tuned on cell sentences from >800 CellxGene/HCA datasets. Loaded as
7
+ a plain transformers Gemma2Model (no remote code, strict: no missing / unexpected keys), bf16, eager
8
+ attention (keeps Gemma-2's attention-logit soft-capping exactly as in training).
9
+ Tokenizer : the repo ships only the sentencepiece tokenizer.model; sentencepiece is not in the shared env, so it was
10
+ converted once to ext/cell2sentence/tokenizer/fast/tokenizer.json with transformers' own GemmaConverter
11
+ inside an isolated venv (ext/cell2sentence/venv) and checked token-identical to the sentencepiece
12
+ GemmaTokenizer on sampled prompts of every set.
13
+ Cell sentence (exactly as cell2sentence src/cell2sentence/utils.py generate_sentences + prompt_formatter.py, repo
14
+ vandijklab/cell2sentence @ 6599c9d, read-only clone in ext/cell2sentence/src):
15
+ raw counts -> sc.pp.normalize_total (target = median total counts of the set) -> log1p base 10
16
+ (tutorial 0; rank-preserving within a spot) -> gene symbols upper-cased (generate_vocabulary) ->
17
+ nonzero genes of the spot, shuffled with np.random.seed(42) / sklearn.utils.shuffle and stable-sorted by
18
+ descending expression (= their random tie-breaking) -> top K=200 symbols (default n_genes=200 of
19
+ tasks.embed_cells / predict_cell_types_of_data and the tutorials), space-separated.
20
+ Not applied: tutorial-0 QC filters (filter_cells min_genes=200, filter_genes min_cells=3), because they
21
+ would drop spots / make a spot's sentence depend on the other spots of the set.
22
+ Prompt : the cell-type-prediction template used by tasks.embed_cells (C2SPromptFormatter task
23
+ "cell_type_prediction"); that formatter picks one of 15 paraphrases at random, we fix the first one, which
24
+ is also the template on the C2S-Scale model card:
25
+ "The following is a list of {K} gene names ordered by descending expression level in a Homo sapiens cell.
26
+ Your task is to give the cell type which this cell belongs to based on its gene expression.\\n
27
+ Cell sentence: {sentence}.\\nThe cell type corresponding to these genes is:" (+ <bos>, no chat template)
28
+ {K} is always 200, as in the formatter (spots with < 200 expressed genes keep "200").
29
+ Readout : last hidden layer (post final RMSNorm = hidden_states[-1], as CSModel.embed_cells_batched).
30
+ "c2s" : mean over the cell-sentence tokens only (tokens whose char span overlaps the gene list;
31
+ official code averages over the whole padded prompt, which mixes in the constant template
32
+ and padding).
33
+ "c2s-last" : hidden state of the last prompt token (":" after "is"), i.e. the state that would generate
34
+ the cell type.
35
+ Output : cache/rnafm/c2s_<set>.npy and c2s-last_<set>.npy, (n_spot, 2304) float16, set order. Log logs/rnafm/c2s.log.
36
+ """
37
+ import os
38
+ import sys
39
+ import time
40
+
41
+ import numpy as np
42
+ import pandas as pd
43
+ import scipy.sparse as sp
44
+ import torch
45
+ from sklearn.utils import shuffle
46
+
47
+ from common import CACHE, ROOT
48
+ from rna_sets import SETS, load, save
49
+
50
+ REPO = "vandijklab/C2S-Scale-Gemma-2-2B"
51
+ REV = "5ddf28b8f1c81b7ab7a9be192924da82b6c5d512"
52
+ TOK_DIR = os.path.join(ROOT, "ext", "cell2sentence", "tokenizer", "fast")
53
+ K = 200
54
+ ORGANISM = "Homo sapiens"
55
+ PREFIX = (f"The following is a list of {K} gene names ordered by descending expression level in a {ORGANISM} cell. "
56
+ "Your task is to give the cell type which this cell belongs to based on its gene expression.\nCell sentence: ")
57
+ SUFFIX = ".\nThe cell type corresponding to these genes is:"
58
+ GPU_GB = 8.0
59
+ TOK_BUDGET = 8 * 800 # tokens per forward pass (halved on OOM; ~7 GB peak at L~750)
60
+ dev = "cuda"
61
+
62
+
63
+ def log(*a):
64
+ print(*a, flush=True)
65
+
66
+
67
+ def cell_sentences(X, genes):
68
+ """C2S normalisation + rank -> list of top-K space-separated (upper-case) gene symbols, plus #expressed genes."""
69
+ X = X.tocsr().astype(np.float64)
70
+ tot = np.asarray(X.sum(1)).ravel()
71
+ target = np.median(tot[tot > 0]) # sc.pp.normalize_total default target_sum=None
72
+ Xn = sp.diags(target / np.maximum(tot, 1e-12)) @ X
73
+ Xn = Xn.tocsr()
74
+ Xn.data = np.log1p(Xn.data) / np.log(10) # sc.pp.log1p(base=10)
75
+ enc = np.char.upper(np.asarray(genes, dtype=str))
76
+ np.random.seed(42) # generate_sentences(random_state=42)
77
+ sents, n_expr = [], np.zeros(Xn.shape[0], int)
78
+ for i in range(Xn.shape[0]):
79
+ cols = Xn.indices[Xn.indptr[i]:Xn.indptr[i + 1]]
80
+ vals = Xn.data[Xn.indptr[i]:Xn.indptr[i + 1]]
81
+ cols, vals = shuffle(cols, vals)
82
+ order = cols[np.argsort(-vals, kind="stable")]
83
+ n_expr[i] = len(order)
84
+ sents.append(" ".join(enc[order[:K]]))
85
+ return sents, n_expr
86
+
87
+
88
+ def tokenize(tok, sents):
89
+ """Prompt ids + mask of cell-sentence tokens (by char offsets)."""
90
+ prompts = [PREFIX + s + SUFFIX for s in sents]
91
+ enc = tok(prompts, return_offsets_mapping=True, add_special_tokens=True)
92
+ out = []
93
+ for p, s, ids, off in zip(prompts, sents, enc["input_ids"], enc["offset_mapping"]):
94
+ a, b = len(PREFIX), len(PREFIX) + len(s)
95
+ off = np.asarray(off)
96
+ m = (off[:, 0] < b) & (off[:, 1] > a) & (off[:, 1] > off[:, 0])
97
+ assert ids[0] == tok.bos_token_id and m.sum() > 0
98
+ out.append((np.asarray(ids), m))
99
+ return prompts, out
100
+
101
+
102
+ @torch.no_grad()
103
+ def embed(model, seqs, pad_id):
104
+ n, D = len(seqs), model.config.hidden_size
105
+ E_mean, E_last = np.zeros((n, D), np.float32), np.zeros((n, D), np.float32)
106
+ order = np.argsort([len(s[0]) for s in seqs])[::-1] # longest first: OOM shows up immediately
107
+ budget, k, t0, done = TOK_BUDGET, 0, time.time(), 0
108
+ while k < n:
109
+ Lm = len(seqs[order[k]][0])
110
+ bs = max(1, budget // Lm)
111
+ idx = order[k:k + bs]
112
+ ids = torch.full((len(idx), Lm), pad_id, dtype=torch.long)
113
+ msk = torch.zeros((len(idx), Lm), dtype=torch.bool)
114
+ att = torch.zeros((len(idx), Lm), dtype=torch.long)
115
+ for j, i in enumerate(idx):
116
+ s, m = seqs[i]
117
+ ids[j, :len(s)], msk[j, :len(s)], att[j, :len(s)] = torch.from_numpy(s), torch.from_numpy(m), 1
118
+ try:
119
+ h = model(input_ids=ids.to(dev), attention_mask=att.to(dev)).last_hidden_state.float()
120
+ except torch.OutOfMemoryError:
121
+ torch.cuda.empty_cache()
122
+ if budget <= Lm:
123
+ raise
124
+ budget //= 2
125
+ log(f" OOM -> token budget {budget}")
126
+ continue
127
+ mk = msk.to(dev).float()
128
+ E_mean[idx] = ((h * mk[..., None]).sum(1) / mk.sum(1, keepdim=True)).cpu().numpy()
129
+ last = att.sum(1).to(dev) - 1
130
+ E_last[idx] = h[torch.arange(len(idx), device=dev), last].cpu().numpy()
131
+ k += len(idx)
132
+ if k // 1000 > done:
133
+ done = k // 1000
134
+ log(f" {k}/{n} {time.time() - t0:.0f}s bs={len(idx)} L={Lm} peak {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
135
+ return E_mean, E_last
136
+
137
+
138
+ def sanity(E, tag, organ=None):
139
+ rng = np.random.default_rng(0)
140
+ Z = E / np.linalg.norm(E, axis=1, keepdims=True)
141
+ s = Z[rng.choice(len(Z), min(200, len(Z)), replace=False)]
142
+ C = s @ s.T
143
+ mc = C[~np.eye(len(s), dtype=bool)].mean()
144
+ msg = f"[{tag}] finite={np.isfinite(E).all()} mean pairwise cos(200)={mc:.4f} std/dim={E.std(0).mean():.4f}"
145
+ if organ is not None:
146
+ Zc = Z - Z.mean(0)
147
+ Zc /= np.linalg.norm(Zc, axis=1, keepdims=True)
148
+ o = np.asarray(organ)
149
+ same, raw = [], []
150
+ for ZZ, acc in ((Zc, same), (Z, raw)):
151
+ Zt = torch.from_numpy(ZZ).to(dev)
152
+ for i in range(0, len(Zt), 2048):
153
+ S = Zt[i:i + 2048] @ Zt.T
154
+ S[torch.arange(S.shape[0]), torch.arange(i, i + S.shape[0])] = -2
155
+ nn = S.topk(10, dim=1).indices.cpu().numpy()
156
+ acc.append((o[nn] == o[i:i + S.shape[0], None]).mean(1))
157
+ cnt = pd.Series(o).value_counts()
158
+ base = ((cnt * (cnt - 1)).sum()) / (len(o) * (len(o) - 1))
159
+ msg += (f" 10-NN same-organ: {np.concatenate(raw).mean():.3f} (cos), {np.concatenate(same).mean():.3f}"
160
+ f" (centred cos) base rate {base:.3f}")
161
+ log(msg)
162
+
163
+
164
+ def main():
165
+ from transformers import Gemma2Model, PreTrainedTokenizerFast
166
+ from huggingface_hub import snapshot_download
167
+ sets = sys.argv[1:] or SETS
168
+ torch.cuda.set_per_process_memory_fraction(min(1.0, GPU_GB * 2**30 / torch.cuda.get_device_properties(0).total_memory))
169
+ tok = PreTrainedTokenizerFast.from_pretrained(TOK_DIR)
170
+ tok.padding_side = "right"
171
+ mdir = snapshot_download(REPO, revision=REV)
172
+ model, info = Gemma2Model.from_pretrained(mdir, dtype=torch.bfloat16, attn_implementation="eager",
173
+ output_loading_info=True)
174
+ log(f"loaded {REPO}@{REV}: missing={info['missing_keys']} unexpected={info['unexpected_keys']} "
175
+ f"mismatched={info['mismatched_keys']}")
176
+ assert not info["missing_keys"] and not info["mismatched_keys"] and not info["unexpected_keys"]
177
+ model.eval().to(dev)
178
+ log(f"weights {torch.cuda.memory_allocated() / 2**30:.2f} GB; prompt prefix tokens {len(tok(PREFIX)['input_ids'])}")
179
+ meta = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv"))
180
+ tot_seq, tot_t = 0, 0.0
181
+ for name in sets:
182
+ t0 = time.time()
183
+ X, genes = load(name)
184
+ sents, n_expr = cell_sentences(X, genes)
185
+ prompts, seqs = tokenize(tok, sents)
186
+ ntok = np.array([len(s[0]) for s in seqs])
187
+ nsent = np.array([m.sum() for _, m in seqs])
188
+ upg = np.char.upper(genes.astype(str))
189
+ nonhgnc = np.mean([("." in g) or g.startswith("ENSG") for g in upg])
190
+ log(f"== {name}: {X.shape[0]} spots x {X.shape[1]} genes (all symbols usable as text; {nonhgnc:.1%} non-HGNC-"
191
+ f"style ids e.g. ENSG / RP11-..), {len(set(upg))} unique upper-case symbols; expressed genes/spot median "
192
+ f"{np.median(n_expr):.0f}, spots with < {K} expressed genes {np.mean(n_expr < K):.1%}; sentence genes/spot "
193
+ f"median {np.median(np.minimum(n_expr, K)):.0f}; tokens/prompt median {np.median(ntok):.0f} "
194
+ f"(sentence tokens {np.median(nsent):.0f}, {np.median(nsent) / K:.2f} tok/gene)")
195
+ log(" example: " + prompts[0][:400].replace("\n", " | ") + " ...")
196
+ with open(os.path.join(ROOT, "ext", "cell2sentence", f"prompts_sample_{name}.txt"), "w") as f: # tokenizer cross-check
197
+ for i in np.random.default_rng(1).choice(len(prompts), min(200, len(prompts)), replace=False):
198
+ f.write(repr(prompts[i]) + "\n")
199
+ t1 = time.time()
200
+ E_mean, E_last = embed(model, seqs, tok.pad_token_id)
201
+ dt = time.time() - t1
202
+ tot_seq, tot_t = tot_seq + len(seqs), tot_t + dt
203
+ log(f" forward {dt:.0f}s = {len(seqs) / dt:.2f} seq/s, {ntok.sum() / dt:.0f} tok/s; total {time.time() - t0:.0f}s;"
204
+ f" peak GPU {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
205
+ save("c2s", name, E_mean)
206
+ save("c2s-last", name, E_last)
207
+ organ = meta.organ.values if name in ("hest", "hest_v") else None
208
+ if organ is not None:
209
+ assert len(organ) == len(E_mean)
210
+ sanity(E_mean, f"c2s {name}", organ)
211
+ sanity(E_last, f"c2s-last {name}", organ)
212
+ log(f"overall throughput {tot_seq / max(tot_t, 1e-9):.2f} seq/s over {tot_seq} sequences ({tot_t / 60:.1f} min)")
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
experiments/43_rnafm_nicheformer.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen Nicheformer spot embeddings of the 5 RNA sets (each Visium spot / virtual spot = one pseudo-cell).
2
+
3
+ python 43_rnafm_nicheformer.py [set ...] (default: all of rna_sets.SETS)
4
+
5
+ Model : theislab/Nicheformer (HF, MIT), revision 0ba4ba162f9ad3d6c8b713761954c31be2fa69ed, file model.safetensors
6
+ (12-layer post-norm nn.TransformerEncoder, d=512, 16 heads, FFN 1024, learnable PE, context 1500).
7
+ Downloaded to ext/nicheformer/hf/; the HF remote code (modeling_/tokenization_nicheformer.py) was read but is
8
+ NOT executed: the forward pass is re-implemented below (same nn.TransformerEncoderLayer stack) and loaded
9
+ with a strict state-dict load (the unused template `encoder_layer.*`, the MLM head and the `pos` buffer
10
+ are the only checkpoint keys skipped, and that set is asserted).
11
+ Technology means: ext/nicheformer/repo/data/model_means/dissociated_mean_script.npy from the official
12
+ GitHub repo theislab/nicheformer (commit 485cadbc5caa15119adfd54228f8a8af835fcabc); gene order =
13
+ model.h5ad var (20,310 Ensembl ids, token = column index + 30, == vocab.json).
14
+ Inputs : rna_sets.load(set) raw(-like) counts + HGNC symbols. Symbols -> Ensembl via cache/external/
15
+ dlpfc_genes_full.csv (10x GRCh38 reference), fallback Geneformer gene_name_id_dict_gc104M.pkl; genes not in
16
+ the Nicheformer vocabulary are dropped (several symbols hitting one Ensembl id are summed), i.e. the spot is
17
+ aligned to model.h5ad like the official `ad.concat([model, adata])` step.
18
+ Tokenising: official NicheformerDataset / tokenization notebooks: counts -> size-factor normalise to 1e4 (over the
19
+ aligned genes) -> divide by the technology mean (nan->0, rounded half-up, 0 -> 1, as in the notebooks) ->
20
+ rank the non-zero genes by descending value (stable sort; official uses numba quicksort, so only ties may
21
+ be ordered differently) -> token = gene index + 30, zero-padded to 1500. Context tokens prepended in the
22
+ official order [specie, assay, modality] and the sequence truncated to context_length 1500 (=> at most
23
+ 1497 gene tokens). Pad 0 -> 1 and attention padding mask = (token == 1), as in models/_utils.complete_masking
24
+ with p = 0.
25
+ Context : Visium was not in the pretraining corpus (SpatialCorpus-110M = dissociated 10x/Smart-seq + MERFISH/CosMx/
26
+ Xenium/ISS) and has no technology token (the "visium": 9 entry in the get_embeddings notebook is a typo
27
+ for Xenium = 9 in every tokenisation notebook and the HF vocab). Chosen: specie = human (5),
28
+ modality = spatial (4), assay = 10x 3' v3 (11) -- Visium v1/v2 is a whole-transcriptome poly(A) 3'
29
+ sequencing assay, so the closest documented technology is 10x 3' v3, and its matching technology mean is
30
+ the dissociated (10x) one (the image-based means only cover their 300-1000-gene panels). A sensitivity
31
+ run with modality = dissociated (3) is logged for hest only (not saved).
32
+ Readout : official Nicheformer.get_embeddings(layer=-1, with_context=False): last-layer output, drop the 3 context
33
+ positions, mean over the remaining 1497 positions (this INCLUDES padding positions, exactly as the
34
+ official code; only spots with < 1497 detected vocabulary genes are affected and the fraction is logged).
35
+ D = 512, not L2-normalised. bf16 autocast inference.
36
+ Output : rna_sets.save("nicheformer", set, E) -> cache/rnafm/nicheformer_<set>.npy (n_spot, 512) float16.
37
+ Sanity : finite, mean pairwise cosine of 200 random spots, and (hest) organ agreement of cosine 10-NN vs base rate.
38
+ """
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import pickle
43
+ import sys
44
+ import time
45
+
46
+ import anndata as ad
47
+ import numpy as np
48
+ import pandas as pd
49
+ import scipy.sparse as sp
50
+ import torch
51
+ import torch.nn as nn
52
+ from safetensors.torch import load_file
53
+
54
+ from common import CACHE, ROOT
55
+ from rna_sets import SETS, load, save
56
+
57
+ MODEL = "nicheformer"
58
+ REPO, REV = "theislab/Nicheformer", "0ba4ba162f9ad3d6c8b713761954c31be2fa69ed"
59
+ GH_COMMIT = "485cadbc5caa15119adfd54228f8a8af835fcabc"
60
+ EXT = os.path.join(ROOT, "ext", "nicheformer")
61
+ HF_DIR, GH_DIR = os.path.join(EXT, "hf"), os.path.join(EXT, "repo")
62
+ PAD, AUX, CTX_LEN = 1, 30, 1500
63
+ SPECIE_HUMAN, ASSAY_10X3V3, MOD_SPATIAL, MOD_DISSOC = 5, 11, 4, 3
64
+ dev = "cuda"
65
+ torch.manual_seed(0)
66
+
67
+
68
+ def sha(p):
69
+ return hashlib.sha256(open(p, "rb").read()).hexdigest()[:16]
70
+
71
+
72
+ def ensure_download():
73
+ if not os.path.exists(os.path.join(HF_DIR, "model.safetensors")):
74
+ from huggingface_hub import snapshot_download
75
+ snapshot_download(REPO, revision=REV, local_dir=HF_DIR)
76
+ assert os.path.exists(os.path.join(GH_DIR, "data", "model_means", "dissociated_mean_script.npy")), \
77
+ f"git clone https://github.com/theislab/nicheformer {GH_DIR} (commit {GH_COMMIT})"
78
+
79
+
80
+ class Nicheformer(nn.Module):
81
+ """Re-implementation of NicheformerModel (HF modeling_nicheformer.py / official models/_nicheformer.py)."""
82
+
83
+ def __init__(self, cfg):
84
+ super().__init__()
85
+ self.embeddings = nn.Embedding(cfg["n_tokens"] + 5, cfg["dim_model"], padding_idx=1)
86
+ self.positional_embedding = nn.Embedding(cfg["context_length"], cfg["dim_model"])
87
+ layer = nn.TransformerEncoderLayer(cfg["dim_model"], cfg["nheads"], cfg["dim_feedforward"], dropout=0.0,
88
+ batch_first=True, layer_norm_eps=1e-12)
89
+ self.encoder = nn.TransformerEncoder(layer, cfg["nlayers"], enable_nested_tensor=False)
90
+
91
+ def get_embeddings(self, ids):
92
+ pad = ids.eq(PAD)
93
+ x = self.embeddings(ids) + self.positional_embedding(torch.arange(ids.shape[1], device=ids.device))
94
+ for blk in self.encoder.layers:
95
+ x = blk(x, src_key_padding_mask=pad, is_causal=False)
96
+ return x[:, 3:].float().mean(1) # with_context=False, mean incl. pad positions (official)
97
+
98
+
99
+ def load_model():
100
+ cfg = json.load(open(os.path.join(HF_DIR, "config.json")))
101
+ assert cfg["learnable_pe"] and cfg["specie"] and cfg["assay"] and cfg["modality"]
102
+ sd = load_file(os.path.join(HF_DIR, "model.safetensors"))
103
+ keep = {k[len("nicheformer."):]: v for k, v in sd.items()
104
+ if k.startswith("nicheformer.") and not k.startswith("nicheformer.encoder_layer.") and k != "nicheformer.pos"}
105
+ skipped = sorted(set(sd) - {"nicheformer." + k for k in keep})
106
+ assert all(k.startswith(("nicheformer.encoder_layer.", "classifier_head.")) or k == "nicheformer.pos"
107
+ for k in skipped), skipped
108
+ m = Nicheformer(cfg)
109
+ m.load_state_dict(keep, strict=True)
110
+ print(f"weights {sha(os.path.join(HF_DIR, 'model.safetensors'))}: loaded {len(keep)} tensors strictly; "
111
+ f"skipped {len(skipped)} (template encoder_layer / MLM head / pos buffer)", flush=True)
112
+ return m.eval().to(dev), cfg
113
+
114
+
115
+ def gene_reference():
116
+ ref = ad.read_h5ad(os.path.join(HF_DIR, "model.h5ad"), backed=None)
117
+ ens = np.asarray(ref.var_names, dtype=str)
118
+ vocab = json.load(open(os.path.join(HF_DIR, "vocab.json")))
119
+ assert all(vocab[g] == i + AUX for i, g in enumerate(ens))
120
+ mean = np.load(os.path.join(GH_DIR, "data", "model_means", "dissociated_mean_script.npy"))
121
+ assert mean.shape == ens.shape
122
+ mean = np.nan_to_num(mean)
123
+ r = np.where((mean % 1) >= 0.5, np.ceil(mean), np.floor(mean))
124
+ mean = np.where(r == 0, 1, r)
125
+ sym2ens = dict(pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv"))[["gene", "ensembl_id"]].values)
126
+ from huggingface_hub import hf_hub_download
127
+ gf = pickle.load(open(hf_hub_download("ctheodoris/Geneformer", "geneformer/gene_name_id_dict_gc104M.pkl"), "rb"))
128
+ for k, v in gf.items():
129
+ sym2ens.setdefault(k, v)
130
+ return ens, mean, sym2ens
131
+
132
+
133
+ def tokenize(X, genes, ens, mean, sym2ens, modality):
134
+ e2i = {e: i for i, e in enumerate(ens)}
135
+ src = [(j, e2i[sym2ens[g]]) for j, g in enumerate(genes) if g in sym2ens and sym2ens[g] in e2i]
136
+ rows, cols = zip(*src)
137
+ M = sp.csr_matrix((np.ones(len(src), np.float32), (rows, cols)), shape=(len(genes), len(ens)))
138
+ A = (X @ M).tocsr() # spot x model genes (aligned like concat with model)
139
+ lib = np.asarray(A.sum(1)).ravel()
140
+ cov = dict(n_in=len(genes), n_mapped=len(src), n_model_genes=int((np.asarray(M.sum(0)).ravel() > 0).sum()),
141
+ umi_frac=float(np.median(lib / np.maximum(np.asarray(X.sum(1)).ravel(), 1))))
142
+ lib = lib + (lib == 0)
143
+ A = (sp.diags(1e4 / lib) @ A).tocsr()
144
+ ids = np.full((A.shape[0], CTX_LEN), PAD, np.int64)
145
+ ids[:, :3] = [SPECIE_HUMAN, ASSAY_10X3V3, modality]
146
+ ngen = np.zeros(A.shape[0], int)
147
+ for r in range(A.shape[0]):
148
+ s, e = A.indptr[r], A.indptr[r + 1]
149
+ nz, v = A.indices[s:e], A.data[s:e] / mean[A.indices[s:e]]
150
+ keep = v > 0
151
+ nz, v = nz[keep], v[keep]
152
+ order = nz[np.argsort(-v, kind="stable")][:CTX_LEN - 3]
153
+ ids[r, 3:3 + len(order)] = order + AUX
154
+ ngen[r] = len(order)
155
+ cov["expr_genes_median"] = float(np.median(np.diff(A.indptr)))
156
+ cov["tokens_median"] = float(np.median(ngen))
157
+ cov["frac_spots_padded"] = float((ngen < CTX_LEN - 3).mean())
158
+ return ids, cov
159
+
160
+
161
+ @torch.no_grad()
162
+ def embed(model, ids, bs=32):
163
+ out = np.zeros((len(ids), 512), np.float32)
164
+ i = 0
165
+ while i < len(ids):
166
+ try:
167
+ b = torch.from_numpy(ids[i:i + bs]).to(dev)
168
+ with torch.autocast("cuda", dtype=torch.bfloat16):
169
+ out[i:i + bs] = model.get_embeddings(b).cpu().numpy()
170
+ i += bs
171
+ except torch.OutOfMemoryError:
172
+ torch.cuda.empty_cache()
173
+ bs = max(1, bs // 2)
174
+ print(f" OOM -> batch {bs}", flush=True)
175
+ return out
176
+
177
+
178
+ def sanity(E, name):
179
+ rng = np.random.default_rng(0)
180
+ S = E[rng.choice(len(E), min(200, len(E)), replace=False)]
181
+ S = S / np.linalg.norm(S, axis=1, keepdims=True)
182
+ C = S @ S.T
183
+ mc = float(C[np.triu_indices(len(S), 1)].mean())
184
+ msg = f"[{MODEL}] {name}: finite={np.isfinite(E).all()} std={E.std(0).mean():.4f} mean-pair-cos(200)={mc:.4f}"
185
+ if name == "hest":
186
+ org = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv")).organ.values
187
+ assert len(org) == len(E)
188
+ Z = torch.from_numpy(E / np.linalg.norm(E, axis=1, keepdims=True)).to(dev)
189
+ nn_idx = []
190
+ for k in range(0, len(Z), 2048):
191
+ s = Z[k:k + 2048] @ Z.T
192
+ s[torch.arange(s.shape[0]), torch.arange(k, k + s.shape[0])] = -2
193
+ nn_idx.append(s.topk(10, dim=1).indices.cpu().numpy())
194
+ nn_idx = np.concatenate(nn_idx)
195
+ knn = float((org[nn_idx] == org[:, None]).mean())
196
+ p = pd.Series(org).value_counts(normalize=True).values
197
+ msg += f" 10-NN same-organ={knn:.3f} (base rate {float((p ** 2).sum()):.3f})"
198
+ print(msg, flush=True)
199
+ return mc
200
+
201
+
202
+ def main():
203
+ sets = sys.argv[1:] or SETS
204
+ t0 = time.time()
205
+ ensure_download()
206
+ model, cfg = load_model()
207
+ ens, mean, sym2ens = gene_reference()
208
+ print(f"context tokens: specie=human({SPECIE_HUMAN}) assay=10x 3' v3({ASSAY_10X3V3}) modality=spatial({MOD_SPATIAL}); "
209
+ f"technology mean = dissociated_mean_script.npy (GitHub {GH_COMMIT[:7]})", flush=True)
210
+ for name in sets:
211
+ t1 = time.time()
212
+ X, genes = load(name)
213
+ ids, cov = tokenize(X, genes, ens, mean, sym2ens, MOD_SPATIAL)
214
+ print(f"[{MODEL}] {name}: {X.shape[0]} spots; genes in {cov['n_in']}, mapped to vocab {cov['n_mapped']} "
215
+ f"({cov['n_mapped'] / cov['n_in']:.1%}) -> {cov['n_model_genes']} model genes; median UMI fraction kept "
216
+ f"{cov['umi_frac']:.3f}; expressed vocab genes/spot median {cov['expr_genes_median']:.0f}; gene tokens "
217
+ f"median {cov['tokens_median']:.0f}; spots with padding {cov['frac_spots_padded']:.1%}", flush=True)
218
+ E = embed(model, ids)
219
+ sanity(E, name)
220
+ save(MODEL, name, E)
221
+ if name == "hest": # sensitivity: modality token = dissociated
222
+ ids[:, 2] = MOD_DISSOC
223
+ E2 = embed(model, ids)
224
+ c = (E * E2).sum(1) / np.linalg.norm(E, axis=1) / np.linalg.norm(E2, axis=1)
225
+ print(f" sensitivity modality=dissociated(3): cosine to saved per spot median {np.median(c):.4f}", flush=True)
226
+ sanity(E2, "hest")
227
+ print(f"[{MODEL}] {name}: {time.time() - t1:.0f}s", flush=True)
228
+ print(f"[{MODEL}] done {time.time() - t0:.0f}s; peak GPU {torch.cuda.max_memory_allocated() / 2**30:.2f} GB", flush=True)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
experiments/44_rnafm_eval.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RNA encoder comparison + fusion under the final protocol (real pairs only; virtual data only at test time).
2
+
3
+ RNA encoders (frozen inputs, cache/rnafm/<enc>_<set>.npy from 43_*.py; "hvg" = log1p CP10k of the 2000 HEST HVGs,
4
+ identical to 42_train_regimes.py):
5
+ hvg, scgpt, geneformer, nicheformer, tahoex1, aidocell, se600m, c2s, c2s-last, genept-bge, genept-bmc (whatever exists)
6
+ cat:<a>+<b>+... early fusion: concatenation of the z-scored inputs, each block scaled by 1/sqrt(its dim)
7
+ ens:<a>+<b>+... late fusion: mean of the L2-normalised outputs of the separately trained single-encoder heads
8
+ (possible because every head maps into the same frozen UNI2 space)
9
+ Head: linear or MLP (42_'s mlp) from the encoder input to the frozen UNI2 space, InfoNCE, AdamW 3e-4 / wd 0.05 /
10
+ OneCycle, batch 256, 2700 steps, early stopping on val R@1 every 50 steps (= 42_ S1/S4 RNA recipe).
11
+ FM inputs are z-scored with the HEST TRAINING-spot mean / std of fold k (applied unchanged to virtual / external sets).
12
+ Per fold k (splits/final/hest_fold<k>.csv, ORION splits/patient_5fold, TCGA splits/final/tcga_fold<k>.csv):
13
+ trained H&E<->RNA HEST test spots (real)
14
+ emergent mIF<->RNA(virtual) ORION test tiles, mIF = S4 mIF MLP head of fold k (trained here once, cached)
15
+ emergent text<->RNA(virtual) TCGA test images, text = S4 linear text head of fold k (clean real text)
16
+ calib H&E<->RNA(virtual) HEST test spots with STPath RNA instead of the real one (circular, reference only)
17
+ --dlpfc: additionally train on HEST WITHOUT the spatialLIBD study (val = 10% of groups) and test H&E<->RNA on the
18
+ external DLPFC set (482 spots; FM inputs z-scored per dataset as in 38_), mixed galleries G=100.
19
+ Usage: python 44_rnafm_eval.py [--encs ...] [--heads linear mlp] [--folds 0 1 2 3 4] [--dlpfc]
20
+ Output results/rnafm_long.csv (resumable), results/rnafm_summary.csv
21
+ """
22
+ import argparse
23
+ import os
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ import torch
28
+ import torch.nn as nn
29
+ import torch.nn.functional as F
30
+ from scipy.optimize import linear_sum_assignment
31
+
32
+ from common import CACHE, ROOT, SPLITS
33
+
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--encs", nargs="+", default=None)
36
+ ap.add_argument("--heads", nargs="+", default=["linear", "mlp"])
37
+ ap.add_argument("--folds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
38
+ ap.add_argument("--dlpfc", action="store_true")
39
+ args = ap.parse_args()
40
+ dev = "cuda"
41
+ H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
42
+ G100, DRAWS, BS, LR, EVAL_EVERY, STEPS = 100, 50, 256, 3e-4, 50, 2700
43
+ f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
44
+ SETS = ["hest", "hest_v", "orion_v", "tcga_v", "dlpfc"]
45
+
46
+ # ---------------------------------------------------------------- anchors / other modalities (= 42_)
47
+ HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
48
+ IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
49
+ HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
50
+ HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
51
+ HE_D = f32(np.load(os.path.join(CACHE, "external", "dlpfc_he_uni2.npy"))).to(dev)
52
+ HE_D = HE_D[:, 0] if HE_D.dim() == 3 else HE_D
53
+ TXT = f32(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[:, 0]).to(dev)
54
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
55
+ T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
56
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
57
+ HM = pd.read_csv(os.path.join(H, "meta.csv"))
58
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
59
+
60
+
61
+ def hvg_inputs():
62
+ def virt(st):
63
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
64
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
65
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
66
+ return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
67
+ eg = pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv")).gene.values
68
+ ec = np.load(os.path.join(CACHE, "external", "dlpfc_counts_full.npy")).astype(np.float32)
69
+ ei = pd.Series(range(len(eg)), index=eg); ei = ei[~ei.index.duplicated()]
70
+ d = np.log1p(ec / ec.sum(1, keepdims=True).clip(1) * 1e4)
71
+ d = np.stack([d[:, ei[g]] if g in ei.index else np.zeros(len(d), np.float32) for g in hvg], 1)
72
+ return {"hest": np.load(os.path.join(H, "hvg.npy")), "hest_v": virt("hest_val"), "orion_v": virt("orion"),
73
+ "tcga_v": virt("tcga_text"), "dlpfc": d}
74
+
75
+
76
+ def load_enc(e):
77
+ e = e.split("@")[0] # "<enc>@<n>": same input, head trained with another seed (seed-ensemble control)
78
+ if e == "hvg":
79
+ return {s: f32(x).to(dev) for s, x in hvg_inputs().items()}
80
+ return {s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in SETS}
81
+
82
+
83
+ def mlp(din, dout=1536, hid=512):
84
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
85
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
86
+
87
+
88
+ def one_text_per_image(t_ids, rng):
89
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
90
+ return pick.index.values, pick.values
91
+
92
+
93
+ def g100(a, b, seed=0): # = 33_ / 42_
94
+ a, b = a.cpu(), b.cpu()
95
+ rng = np.random.default_rng(seed)
96
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
97
+ for _ in range(DRAWS):
98
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
99
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
100
+ for S in (sim, sim.T):
101
+ rank = (S > d[:, None]).sum(1)
102
+ for k_ in (1, 5, 10):
103
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
104
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
105
+ ri, ci = linear_sum_assignment(-sim)
106
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
107
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
108
+
109
+
110
+ def r1(a, b):
111
+ s = a @ b.T
112
+ return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
113
+
114
+
115
+ def train_head(X_tr, A_tr, X_va, A_va, kind, steps, seed, pos_tr=None):
116
+ """modality head X -> frozen anchor space A (rows paired). A_* may be (n, V, 1536) -> random view per sample."""
117
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
118
+ h = (mlp(X_tr.shape[1]) if kind == "mlp" else nn.Linear(X_tr.shape[1], 1536)).to(dev)
119
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
120
+ opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
121
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
122
+ za_va = F.normalize(A_va[:, 0] if A_va.dim() == 3 else A_va, dim=-1)
123
+ best, state = -1, None
124
+ for step in range(1, steps + 1):
125
+ h.train()
126
+ b = torch.as_tensor(rng.choice(len(X_tr), BS, replace=False), device=dev)
127
+ a = A_tr[b, torch.randint(0, A_tr.shape[1], (BS,), device=dev)] if A_tr.dim() == 3 else A_tr[b]
128
+ lg = logit.exp().clamp(max=100) * F.normalize(a, dim=-1) @ F.normalize(h(X_tr[b]), dim=-1).T
129
+ if pos_tr is None:
130
+ t = torch.arange(BS, device=dev)
131
+ loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
132
+ else:
133
+ ii = pos_tr[b]; pos = (ii[:, None] == ii[None, :]).float(); tgt = pos / pos.sum(1, keepdim=True)
134
+ loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
135
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
136
+ if step % EVAL_EVERY == 0 or step == steps:
137
+ h.eval()
138
+ with torch.no_grad():
139
+ v = r1(za_va, F.normalize(h(X_va), dim=-1))
140
+ if v > best:
141
+ best, state = v, {k_: x.detach().clone() for k_, x in h.state_dict().items()}
142
+ h.load_state_dict(state); h.eval()
143
+ return h, best
144
+
145
+
146
+ class Fold:
147
+ def __init__(self, k):
148
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
149
+ self.o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
150
+ mu, sd = IF_O[self.o["train"]].mean((0, 1)), IF_O[self.o["train"]].std((0, 1)) + 1e-3
151
+ self.IF = (IF_O - mu) / sd
152
+ h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
153
+ self.h = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
154
+ sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
155
+ tsp = sp.loc[T.img_idx].values
156
+ self.t = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
157
+ self.t_va = one_text_per_image(self.t["val"], np.random.default_rng(0))
158
+ self.t_te = one_text_per_image(self.t["test"], np.random.default_rng(k))
159
+ # DLPFC-held-out training pool (= 38_)
160
+ pool = np.flatnonzero(~HM.group.str.startswith("spatialLIBD").values)
161
+ grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
162
+ isva = HM.group.iloc[pool].isin(set(grp[: max(1, len(grp) // 10)])).values
163
+ self.d = {"train": pool[~isva], "val": pool[isva]}
164
+
165
+ def other_heads(self, k):
166
+ """S4 heads of fold k for mIF (MLP) and text (linear); cached in runs/rnafm/other_fold<k>.pt"""
167
+ f = os.path.join(ROOT, "runs", "rnafm", f"other_fold{k}.pt")
168
+ hm, ht = mlp(512).to(dev), nn.Linear(512, 1536).to(dev)
169
+ if os.path.exists(f):
170
+ sd = torch.load(f, map_location=dev); hm.load_state_dict(sd["mif"]); ht.load_state_dict(sd["text"])
171
+ return hm.eval(), ht.eval()
172
+ o, t = self.o, self.t
173
+ hm, _ = train_head(self.IF[o["train"]].flatten(0, 1), HE_O[o["train"]].flatten(0, 1),
174
+ self.IF[o["val"], 0], HE_O[o["val"], 0], "mlp", 400, k)
175
+ ii, tt = self.t_va
176
+ ht, _ = train_head(TXT[t["train"]], HE_T[T_IMG[torch.as_tensor(t["train"], device=dev)]],
177
+ TXT[torch.as_tensor(tt, device=dev)], HE_T[torch.as_tensor(ii, device=dev), 0],
178
+ "linear", 1000, k, pos_tr=T_IMG[torch.as_tensor(t["train"], device=dev)])
179
+ os.makedirs(os.path.dirname(f), exist_ok=True)
180
+ torch.save({"mif": hm.state_dict(), "text": ht.state_dict()}, f)
181
+ return hm, ht
182
+
183
+
184
+ def prep(X, tr, per_dataset_ext=False):
185
+ """z-score with HEST-train stats (hvg: unchanged, = 42_). Returns dict set -> tensor."""
186
+ if X is None:
187
+ return None
188
+ mu, sd = X["hest"][tr].mean(0), X["hest"][tr].std(0) + 1e-6
189
+ out = {s: (x - mu) / sd for s, x in X.items()}
190
+ if per_dataset_ext:
191
+ out["dlpfc"] = (X["dlpfc"] - X["dlpfc"].mean(0)) / (X["dlpfc"].std(0) + 1e-6)
192
+ return out
193
+
194
+
195
+ def build(enc, fold_tr, dl_tr=None):
196
+ """input tensors per set for encoder spec enc; hvg kept raw (42_), FMs / fusions z-scored."""
197
+ parts = enc.split(":", 1)[1].split("+") if enc.startswith("cat:") else [enc]
198
+ main, ext = [], []
199
+ for p in parts:
200
+ X = CACHE_ENC[p]
201
+ if p.split("@")[0] == "hvg" and len(parts) == 1:
202
+ main.append(X)
203
+ if dl_tr is not None:
204
+ ext.append(prep(X, dl_tr, True))
205
+ continue
206
+ w = 1 / np.sqrt(X["hest"].shape[1]) if len(parts) > 1 else 1.0
207
+ main.append({s: v * w for s, v in prep(X, fold_tr).items()})
208
+ if dl_tr is not None:
209
+ ext.append({s: v * w for s, v in prep(X, dl_tr, True).items()})
210
+ cat = lambda L: {s: torch.cat([d[s] for d in L], 1) for s in SETS}
211
+ return cat(main), (cat(ext) if dl_tr is not None else None)
212
+
213
+
214
+ # ---------------------------------------------------------------- run
215
+ avail = ["hvg"] + sorted({f.rsplit("_", 1)[0] for f in os.listdir(RF) if f.endswith("_hest.npy")
216
+ and all(os.path.exists(os.path.join(RF, f.replace("_hest.npy", f"_{s}.npy"))) for s in SETS)})
217
+ encs = sorted(args.encs or avail, key=lambda e: e.startswith("ens:")) # late fusions after their single heads
218
+ singles = sorted({p for e in encs for p in (e.split(":", 1)[1].split("+") if ":" in e else [e])})
219
+ CACHE_ENC = {e: load_enc(e) for e in singles}
220
+ print("encoders:", {e: CACHE_ENC[e]["hest"].shape[1] for e in singles}, flush=True)
221
+ f_out = os.path.join(ROOT, "results", "rnafm_long.csv")
222
+ rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
223
+ done = {(r["enc"], r["head"], r["fold"]) for r in rows}
224
+ nz = lambda x: F.normalize(x, dim=-1)
225
+ for k in args.folds:
226
+ F_ = Fold(k)
227
+ hm, ht = F_.other_heads(k)
228
+ te_o = F_.o["test"]; ii, tt = (torch.as_tensor(x, device=dev) for x in F_.t_te)
229
+ with torch.no_grad():
230
+ z_mif = nz(hm(F_.IF[te_o, 0])); z_txt = nz(ht(TXT[tt]))
231
+ heads_out = {} # (enc, head) -> dict of output embeddings, for late fusion
232
+ for enc in encs:
233
+ for hk in args.heads:
234
+ parts = enc.split(":", 1)[1].split("+") if enc.startswith("ens:") else None
235
+ need = any(enc in e.split(":", 1)[1].split("+") for e in encs if e.startswith("ens:"))
236
+ if (enc, hk, k) in done and parts is None and not need:
237
+ continue
238
+ tr, va, te = F_.h["train"], F_.h["val"], F_.h["test"]
239
+ if parts is not None: # late fusion of single-encoder heads trained in this run
240
+ if not all((p, hk) in heads_out for p in parts) or (enc, hk, k) in done:
241
+ continue
242
+ Z = {s: nz(sum(heads_out[(p, hk)][s] for p in parts)) for s in heads_out[(parts[0], hk)]}
243
+ best = float("nan")
244
+ else:
245
+ X, Xd = build(enc, tr, F_.d["train"] if args.dlpfc else None)
246
+ sd_ = k + 1000 * int(enc.split("@")[1]) if "@" in enc else k
247
+ h, best = train_head(X["hest"][tr], HE_H[tr], X["hest"][va], HE_H[va], hk, STEPS, sd_)
248
+ with torch.no_grad():
249
+ Z = {"hest": nz(h(X["hest"][te])), "hest_v": nz(h(X["hest_v"][te])),
250
+ "orion_v": nz(h(X["orion_v"][te_o])), "tcga_v": nz(h(X["tcga_v"][ii]))}
251
+ if args.dlpfc:
252
+ hd, _ = train_head(Xd["hest"][F_.d["train"]], HE_H[F_.d["train"]], Xd["hest"][F_.d["val"]],
253
+ HE_H[F_.d["val"]], hk, STEPS, sd_)
254
+ with torch.no_grad():
255
+ Z["dlpfc"] = nz(hd(Xd["dlpfc"]))
256
+ heads_out[(enc, hk)] = Z
257
+ res = {("H&E<->RNA (HEST)", "trained"): g100(nz(HE_H[te]), Z["hest"]),
258
+ ("mIF<->RNA(virtual) (ORION)", "emergent"): g100(z_mif, Z["orion_v"]),
259
+ ("text<->RNA(virtual) (TCGA)", "emergent"): g100(z_txt, Z["tcga_v"]),
260
+ ("H&E<->RNA(virtual) (HEST)", "calib"): g100(nz(HE_H[te]), Z["hest_v"])}
261
+ if "dlpfc" in Z:
262
+ res[("H&E<->RNA (DLPFC external)", "external")] = g100(nz(HE_D), Z["dlpfc"])
263
+ if (enc, hk, k) in done: # retrained only to feed a late fusion
264
+ continue
265
+ for (pair, kind), r in res.items():
266
+ rows.append({"enc": enc, "head": hk, "fold": k, "pair": pair, "kind": kind, "val_R@1": best, **r})
267
+ pd.DataFrame(rows).to_csv(f_out, index=False)
268
+ print(f"fold {k} {enc:<34} {hk:<6} val {best:.3f} " +
269
+ " ".join(f"{p.split(' (')[0]} {r['R@1']:.3f}" for (p, _), r in res.items()), flush=True)
270
+
271
+ R = pd.DataFrame(rows)
272
+ S = R.groupby(["enc", "head", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
273
+ S.to_csv(os.path.join(ROOT, "results", "rnafm_summary.csv"))
274
+ pd.set_option("display.width", 250)
275
+ print(R.pivot_table(index=["enc", "head"], columns="pair", values="R@1", sort=False).round(3).to_string())
experiments/46_text_enc_eval.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text encoder comparison + fusion under the final protocol (clean real TCGA texts only; synthetic / virtual = test only).
2
+
3
+ Text encoders (frozen, cache/alltxt_text_<enc>.npy and cache/orion_gentext_text_<enc>.npy; 45_ / 45b_ / 08_):
4
+ biomedclip (current; raw features as in 42_), conchclip, plip, medcpt, biolord, pubmedbert, medembed, bge, qwen3-0.6b,
5
+ qwen3-4b, keep, quiltnet (whatever exists for both sets). Non-baseline inputs are z-scored with TCGA-train stats.
6
+ cat:<a>+<b> early fusion (z-scored blocks scaled by 1/sqrt(dim), one head)
7
+ ens:<a>+<b> late fusion (mean of the L2-normalised outputs of the single-encoder heads; same frozen UNI2 space)
8
+ Head: linear or MLP into the frozen UNI2 space, multi-positive InfoNCE (several texts per image), AdamW 3e-4 / wd 0.05 /
9
+ OneCycle, batch 256, 1000 steps, early stopping on val R@1 every 50 steps (= 42_ S4 text recipe).
10
+ Per fold k (splits/final/tcga_fold<k>.csv; one random text per test image, = 33_/42_):
11
+ trained H&E<->text TCGA test images (real text)
12
+ emergent mIF<->text(synthetic) ORION patient_5fold test tiles, mIF = S4 MLP head (runs/rnafm/other_fold<k>.pt, 44_)
13
+ emergent text<->RNA(virtual) TCGA test images, RNA = hvg linear head of fold k (runs/rnafm/rna_hvg_fold<k>.pt)
14
+ emergent text<->mIF(MIPHEI) TCGA test images, MIPHEI virtual mIF through the same mIF head
15
+ Usage: python 46_text_enc_eval.py [--encs ...] [--heads linear mlp] [--folds 0 1 2 3 4]
16
+ Output results/textenc_long.csv (resumable), results/textenc_summary.csv
17
+ """
18
+ import argparse
19
+ import os
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ import torch
24
+ import torch.nn as nn
25
+ import torch.nn.functional as F
26
+ from scipy.optimize import linear_sum_assignment
27
+
28
+ from common import CACHE, ROOT, SPLITS
29
+
30
+ ap = argparse.ArgumentParser()
31
+ ap.add_argument("--encs", nargs="+", default=None)
32
+ ap.add_argument("--heads", nargs="+", default=["linear", "mlp"])
33
+ ap.add_argument("--folds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
34
+ args = ap.parse_args()
35
+ dev = "cuda"
36
+ H = os.path.join(CACHE, "hest")
37
+ G100, DRAWS, BS, LR, EVAL_EVERY, STEPS = 100, 50, 256, 3e-4, 50, 1000
38
+ f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
39
+ nz = lambda x: F.normalize(x, dim=-1)
40
+
41
+ HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
42
+ IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
43
+ IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
44
+ HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
45
+ RNA_H = f32(np.load(os.path.join(H, "hvg.npy"))).to(dev)
46
+ HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
47
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
48
+ T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
49
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
50
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
51
+
52
+
53
+ def virtual_rna(st): # = 42_
54
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
55
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
56
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
57
+ return f32(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]).to(dev)
58
+
59
+
60
+ RNA_V_T = virtual_rna("tcga_text")
61
+
62
+
63
+ def load_enc(e):
64
+ e = e.split("@")[0] # "<enc>@<n>": same input, head trained with another seed (seed-ensemble control)
65
+ return {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev) for s in ["alltxt", "orion_gentext"]}
66
+
67
+
68
+ def mlp(din, dout=1536, hid=512):
69
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
70
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
71
+
72
+
73
+ def one_text_per_image(t_ids, rng):
74
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
75
+ return pick.index.values, pick.values
76
+
77
+
78
+ def g100(a, b, seed=0): # = 33_ / 42_
79
+ a, b = a.cpu(), b.cpu()
80
+ rng = np.random.default_rng(seed)
81
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
82
+ for _ in range(DRAWS):
83
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
84
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
85
+ for S in (sim, sim.T):
86
+ rank = (S > d[:, None]).sum(1)
87
+ for k_ in (1, 5, 10):
88
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
89
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
90
+ ri, ci = linear_sum_assignment(-sim)
91
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
92
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
93
+
94
+
95
+ def r1(a, b):
96
+ s = a @ b.T
97
+ return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
98
+
99
+
100
+ def train_head(X_tr, A_tr, X_va, A_va, kind, steps, seed, pos_tr=None): # = 44_
101
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
102
+ h = (mlp(X_tr.shape[1]) if kind == "mlp" else nn.Linear(X_tr.shape[1], 1536)).to(dev)
103
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
104
+ opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
105
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
106
+ za_va = nz(A_va[:, 0] if A_va.dim() == 3 else A_va)
107
+ best, state = -1, None
108
+ for step in range(1, steps + 1):
109
+ h.train()
110
+ b = torch.as_tensor(rng.choice(len(X_tr), BS, replace=False), device=dev)
111
+ a = A_tr[b, torch.randint(0, A_tr.shape[1], (BS,), device=dev)] if A_tr.dim() == 3 else A_tr[b]
112
+ lg = logit.exp().clamp(max=100) * nz(a) @ nz(h(X_tr[b])).T
113
+ if pos_tr is None:
114
+ t = torch.arange(BS, device=dev)
115
+ loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
116
+ else:
117
+ ii = pos_tr[b]; pos = (ii[:, None] == ii[None, :]).float(); tgt = pos / pos.sum(1, keepdim=True)
118
+ loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
119
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
120
+ if step % EVAL_EVERY == 0 or step == steps:
121
+ h.eval()
122
+ with torch.no_grad():
123
+ v = r1(za_va, nz(h(X_va)))
124
+ if v > best:
125
+ best, state = v, {k_: x.detach().clone() for k_, x in h.state_dict().items()}
126
+ h.load_state_dict(state); h.eval()
127
+ return h, best
128
+
129
+
130
+ class Fold:
131
+ def __init__(self, k):
132
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
133
+ self.o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
134
+ self.if_mu, self.if_sd = IF_O[self.o["train"]].mean((0, 1)), IF_O[self.o["train"]].std((0, 1)) + 1e-3
135
+ h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
136
+ self.h = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
137
+ sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
138
+ tsp = sp.loc[T.img_idx].values
139
+ self.t = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
140
+ self.t_va = one_text_per_image(self.t["val"], np.random.default_rng(0))
141
+ self.t_te = one_text_per_image(self.t["test"], np.random.default_rng(k))
142
+
143
+ def nif(self, x):
144
+ return (x - self.if_mu) / self.if_sd
145
+
146
+ def mif_head(self, k):
147
+ f = os.path.join(ROOT, "runs", "rnafm", f"other_fold{k}.pt") # written by 44_rnafm_eval.py
148
+ hm = mlp(512).to(dev)
149
+ hm.load_state_dict(torch.load(f, map_location=dev)["mif"])
150
+ return hm.eval()
151
+
152
+ def rna_head(self, k):
153
+ f = os.path.join(ROOT, "runs", "rnafm", f"rna_hvg_fold{k}.pt")
154
+ hr = nn.Linear(2000, 1536).to(dev)
155
+ if os.path.exists(f):
156
+ hr.load_state_dict(torch.load(f, map_location=dev))
157
+ return hr.eval()
158
+ hr, _ = train_head(RNA_H[self.h["train"]], HE_H[self.h["train"]], RNA_H[self.h["val"]], HE_H[self.h["val"]],
159
+ "linear", 2700, k)
160
+ torch.save(hr.state_dict(), f)
161
+ return hr
162
+
163
+
164
+ def build(enc, tr):
165
+ parts = enc.split(":", 1)[1].split("+") if enc.startswith("cat:") else [enc]
166
+ blocks = []
167
+ for p in parts:
168
+ X = CACHE_ENC[p]
169
+ if p.split("@")[0] == "biomedclip" and len(parts) == 1:
170
+ blocks.append(X); continue # = 42_ / 33_ (raw BiomedCLIP features)
171
+ mu, sd = X["alltxt"][tr].mean(0), X["alltxt"][tr].std(0) + 1e-6
172
+ w = 1 / np.sqrt(X["alltxt"].shape[1]) if len(parts) > 1 else 1.0
173
+ blocks.append({s: (v - mu) / sd * w for s, v in X.items()})
174
+ return {s: torch.cat([b[s] for b in blocks], 1) for s in blocks[0]}
175
+
176
+
177
+ avail = sorted({f[len("alltxt_text_"):-4] for f in os.listdir(CACHE) if f.startswith("alltxt_text_") and f.endswith(".npy")
178
+ and os.path.exists(os.path.join(CACHE, "orion_gentext_text_" + f[len("alltxt_text_"):]))})
179
+ encs = sorted(args.encs or avail, key=lambda e: e.startswith("ens:")) # late fusions after their single heads
180
+ singles = sorted({p for e in encs for p in (e.split(":", 1)[1].split("+") if ":" in e else [e])})
181
+ CACHE_ENC = {e: load_enc(e) for e in singles}
182
+ print("encoders:", {e: CACHE_ENC[e]["alltxt"].shape[1] for e in singles}, flush=True)
183
+ f_out = os.path.join(ROOT, "results", "textenc_long.csv")
184
+ rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
185
+ done = {(r["enc"], r["head"], r["fold"]) for r in rows}
186
+ for k in args.folds:
187
+ F_ = Fold(k)
188
+ hm, hr = F_.mif_head(k), F_.rna_head(k)
189
+ te_o = F_.o["test"]; ii, tt = (torch.as_tensor(x, device=dev) for x in F_.t_te)
190
+ vi, vt = (torch.as_tensor(x, device=dev) for x in F_.t_va)
191
+ trt = torch.as_tensor(F_.t["train"], device=dev)
192
+ with torch.no_grad():
193
+ z_mif = nz(hm(F_.nif(IF_O[te_o, 0]))); z_rna = nz(hr(RNA_V_T[ii])); z_vmif = nz(hm(F_.nif(IF_V_T[ii])))
194
+ z_he = nz(HE_T[ii, 0])
195
+ heads_out = {}
196
+ for enc in encs:
197
+ for hk in args.heads:
198
+ parts = enc.split(":", 1)[1].split("+") if enc.startswith("ens:") else None
199
+ need = any(enc in e.split(":", 1)[1].split("+") for e in encs if e.startswith("ens:"))
200
+ if (enc, hk, k) in done and not (parts is None and need):
201
+ continue
202
+ if parts is not None:
203
+ if not all((p, hk) in heads_out for p in parts):
204
+ continue
205
+ Z = {s: nz(sum(heads_out[(p, hk)][s] for p in parts)) for s in heads_out[(parts[0], hk)]}
206
+ best = float("nan")
207
+ else:
208
+ X = build(enc, trt)
209
+ sd_ = k + 1000 * int(enc.split("@")[1]) if "@" in enc else k
210
+ h, best = train_head(X["alltxt"][trt], HE_T[T_IMG[trt]], X["alltxt"][vt], HE_T[vi, 0], hk, STEPS, sd_,
211
+ pos_tr=T_IMG[trt])
212
+ with torch.no_grad():
213
+ Z = {"tcga": nz(h(X["alltxt"][tt])), "orion": nz(h(X["orion_gentext"][te_o]))}
214
+ heads_out[(enc, hk)] = Z
215
+ if (enc, hk, k) in done:
216
+ continue
217
+ res = {("H&E<->text (TCGA)", "trained"): g100(z_he, Z["tcga"]),
218
+ ("mIF<->text(synthetic) (ORION)", "emergent"): g100(z_mif, Z["orion"]),
219
+ ("text<->RNA(virtual) (TCGA)", "emergent"): g100(Z["tcga"], z_rna),
220
+ ("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(Z["tcga"], z_vmif)}
221
+ for (pair, kind), r in res.items():
222
+ rows.append({"enc": enc, "head": hk, "fold": k, "pair": pair, "kind": kind, "val_R@1": best, **r})
223
+ pd.DataFrame(rows).to_csv(f_out, index=False)
224
+ print(f"fold {k} {enc:<34} {hk:<6} val {best:.3f} " +
225
+ " ".join(f"{p.split(' (')[0]} {r['R@1']:.3f}" for (p, _), r in res.items()), flush=True)
226
+
227
+ R = pd.DataFrame(rows)
228
+ S = R.groupby(["enc", "head", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
229
+ S.to_csv(os.path.join(ROOT, "results", "textenc_summary.csv"))
230
+ pd.set_option("display.width", 250)
231
+ print(R.pivot_table(index=["enc", "head"], columns="pair", values="R@1", sort=False).round(3).to_string())
experiments/47_final_ens.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final four-modality evaluation with multi-encoder late fusion (frozen UNI2 anchor; real pairs only; virtual /
2
+ synthetic data only at test time). Combines the winners of 44_rnafm_eval.py (RNA) and 46_text_enc_eval.py (text).
3
+
4
+ Every head maps one frozen encoder into the frozen UNI2 space (S4 recipe of 42_: MLP for mIF, linear for RNA / text), so
5
+ the heads of several encoders of one modality can be averaged at test time: z_mod = normalise(sum_e normalise(head_e(x_e))).
6
+ Configurations (mIF = VirTues 16-ch + MLP head in all of them):
7
+ S4 RNA hvg text BiomedCLIP (current method)
8
+ single RNA AIDO.Cell text KEEP (best single encoders)
9
+ ens-small RNA hvg + Tahoe-x1 + SE-600M + AIDO.Cell text KEEP + BiomedCLIP + Qwen3-Emb-0.6B
10
+ ens-large RNA all 10 encoders text KEEP + BiomedCLIP + Qwen3-0.6B + PubMedBERT-emb + MedCPT
11
+ Pairs per fold k (same splits / test items as 42_ / 44_ / 46_): trained H&E<->mIF (ORION), H&E<->RNA (HEST),
12
+ H&E<->text (TCGA); emergent mIF<->RNA(v), mIF<->text(s) (ORION), text<->RNA(v), text<->mIF(MIPHEI) (TCGA);
13
+ external DLPFC (RNA heads retrained without spatialLIBD, inputs z-scored per dataset): H&E<->RNA, RNA<->text(s),
14
+ RNA<->mIF(MIPHEI).
15
+ Output results/final_ens_long.csv, results/final_ens_summary.csv, results/final_ens_tests.csv (paired vs S4)
16
+ """
17
+ import os
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+ from scipy import stats
25
+ from scipy.optimize import linear_sum_assignment
26
+
27
+ from common import CACHE, ROOT, SPLITS
28
+
29
+ dev = "cuda"
30
+ H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
31
+ G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
32
+ f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
33
+ nz = lambda x: F.normalize(x, dim=-1)
34
+ RNA_ALL = ["hvg", "tahoex1", "se600m", "scgpt", "nicheformer", "aidocell", "geneformer", "c2s", "genept-bge", "genept-bmc"]
35
+ CONF = {"S4": (["hvg"], ["biomedclip"]),
36
+ "single": (["aidocell"], ["keep"]),
37
+ "ens-small": (["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]),
38
+ "ens-large": (RNA_ALL, ["keep", "biomedclip", "qwen3-0.6b", "pubmedbert", "medcpt"])}
39
+
40
+ # ---------------------------------------------------------------- data
41
+ HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
42
+ IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
43
+ IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
44
+ IF_V_D = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_dlpfc.npy"))[:, 0]).to(dev)
45
+ HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
46
+ HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
47
+ HE_D = f32(np.load(os.path.join(CACHE, "external", "dlpfc_he_uni2.npy"))).to(dev)
48
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
49
+ T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
50
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
51
+ HM = pd.read_csv(os.path.join(H, "meta.csv"))
52
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
53
+
54
+
55
+ def hvg_inputs(): # = 44_
56
+ def virt(st):
57
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
58
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
59
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
60
+ return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
61
+ eg = pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv")).gene.values
62
+ ec = np.load(os.path.join(CACHE, "external", "dlpfc_counts_full.npy")).astype(np.float32)
63
+ ei = pd.Series(range(len(eg)), index=eg); ei = ei[~ei.index.duplicated()]
64
+ d = np.log1p(ec / ec.sum(1, keepdims=True).clip(1) * 1e4)
65
+ d = np.stack([d[:, ei[g]] if g in ei.index else np.zeros(len(d), np.float32) for g in hvg], 1)
66
+ return {"hest": np.load(os.path.join(H, "hvg.npy")), "orion_v": virt("orion"), "tcga_v": virt("tcga_text"), "dlpfc": d}
67
+
68
+
69
+ RNA_IN = {e: ({s: f32(x).to(dev) for s, x in hvg_inputs().items()} if e == "hvg" else
70
+ {s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in ["hest", "orion_v", "tcga_v", "dlpfc"]})
71
+ for e in RNA_ALL}
72
+ TXT_ENCS = sorted({e for _, x in CONF.values() for e in x})
73
+ TXT_IN = {e: {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev)
74
+ for s in ["alltxt", "orion_gentext", "dlpfc_gentext"]} for e in TXT_ENCS}
75
+
76
+
77
+ def mlp(din, dout=1536, hid=512):
78
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
79
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
80
+
81
+
82
+ def one_text_per_image(t_ids, rng):
83
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
84
+ return pick.index.values, pick.values
85
+
86
+
87
+ def g100(a, b, seed=0): # = 33_ / 42_
88
+ a, b = a.cpu(), b.cpu()
89
+ rng = np.random.default_rng(seed)
90
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
91
+ for _ in range(DRAWS):
92
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
93
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
94
+ for S in (sim, sim.T):
95
+ rank = (S > d[:, None]).sum(1)
96
+ for k_ in (1, 5, 10):
97
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
98
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
99
+ ri, ci = linear_sum_assignment(-sim)
100
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
101
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
102
+
103
+
104
+ def r1(a, b):
105
+ s = a @ b.T
106
+ return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
107
+
108
+
109
+ def train_head(X_tr, A_tr, X_va, A_va, kind, steps, seed, pos_tr=None): # = 44_ / 46_
110
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
111
+ h = (mlp(X_tr.shape[1]) if kind == "mlp" else nn.Linear(X_tr.shape[1], 1536)).to(dev)
112
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
113
+ opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
114
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
115
+ za_va = nz(A_va[:, 0] if A_va.dim() == 3 else A_va)
116
+ best, state = -1, None
117
+ for step in range(1, steps + 1):
118
+ h.train()
119
+ b = torch.as_tensor(rng.choice(len(X_tr), BS, replace=False), device=dev)
120
+ a = A_tr[b, torch.randint(0, A_tr.shape[1], (BS,), device=dev)] if A_tr.dim() == 3 else A_tr[b]
121
+ lg = logit.exp().clamp(max=100) * nz(a) @ nz(h(X_tr[b])).T
122
+ if pos_tr is None:
123
+ t = torch.arange(BS, device=dev)
124
+ loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
125
+ else:
126
+ ii = pos_tr[b]; pos = (ii[:, None] == ii[None, :]).float(); tgt = pos / pos.sum(1, keepdim=True)
127
+ loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
128
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
129
+ if step % EVAL_EVERY == 0 or step == steps:
130
+ h.eval()
131
+ with torch.no_grad():
132
+ v = r1(za_va, nz(h(X_va)))
133
+ if v > best:
134
+ best, state = v, {k_: x.detach().clone() for k_, x in h.state_dict().items()}
135
+ h.load_state_dict(state); h.eval()
136
+ return h
137
+
138
+
139
+ def zs(X, ref_set, idx, raw, ext=None):
140
+ """z-score every set with the training rows of ref_set (raw = keep the baseline input unchanged, as in 42_);
141
+ ext: sets z-scored on themselves (external data, = 38_ / 44_)"""
142
+ if raw:
143
+ out = dict(X)
144
+ else:
145
+ mu, sd = X[ref_set][idx].mean(0), X[ref_set][idx].std(0) + 1e-6
146
+ out = {s: (v - mu) / sd for s, v in X.items()}
147
+ for s in ext or []:
148
+ out[s] = (X[s] - X[s].mean(0)) / (X[s].std(0) + 1e-6)
149
+ return out
150
+
151
+
152
+ rows = []
153
+ for k in range(5):
154
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
155
+ o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
156
+ nif = lambda x: (x - IF_O[o["train"]].mean((0, 1))) / (IF_O[o["train"]].std((0, 1)) + 1e-3)
157
+ h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
158
+ hs = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
159
+ sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
160
+ tsp = sp.loc[T.img_idx].values
161
+ ts = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
162
+ vi, vt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["val"], np.random.default_rng(0)))
163
+ ii, tt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["test"], np.random.default_rng(k)))
164
+ trt = torch.as_tensor(ts["train"], device=dev)
165
+ pool = np.flatnonzero(~HM.group.str.startswith("spatialLIBD").values)
166
+ grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
167
+ isva = HM.group.iloc[pool].isin(set(grp[: max(1, len(grp) // 10)])).values
168
+ d_tr, d_va = pool[~isva], pool[isva]
169
+
170
+ hm = mlp(512).to(dev)
171
+ hm.load_state_dict(torch.load(os.path.join(ROOT, "runs", "rnafm", f"other_fold{k}.pt"), map_location=dev)["mif"])
172
+ hm.eval()
173
+ with torch.no_grad():
174
+ Zm = {"he_o": nz(HE_O[o["test"], 0]), "orion": nz(hm(nif(IF_O[o["test"], 0]))),
175
+ "tcga_v": nz(hm(nif(IF_V_T[ii]))), "dlpfc_v": nz(hm(nif(IF_V_D)))}
176
+ Zr, Zt = {}, {}
177
+ for e in RNA_ALL:
178
+ X = zs(RNA_IN[e], "hest", hs["train"], e == "hvg")
179
+ hr = train_head(X["hest"][hs["train"]], HE_H[hs["train"]], X["hest"][hs["val"]], HE_H[hs["val"]], "linear", 2700, k)
180
+ Xd = zs(RNA_IN[e], "hest", d_tr, False, ext=["dlpfc"])
181
+ hd = train_head(Xd["hest"][d_tr], HE_H[d_tr], Xd["hest"][d_va], HE_H[d_va], "linear", 2700, k)
182
+ with torch.no_grad():
183
+ Zr[e] = {"hest": nz(hr(X["hest"][hs["test"]])), "orion_v": nz(hr(X["orion_v"][o["test"]])),
184
+ "tcga_v": nz(hr(X["tcga_v"][ii])), "dlpfc": nz(hd(Xd["dlpfc"]))}
185
+ print(f"fold {k} RNA head {e} done", flush=True)
186
+ for e in TXT_ENCS:
187
+ X = zs(TXT_IN[e], "alltxt", trt, e == "biomedclip")
188
+ ht = train_head(X["alltxt"][trt], HE_T[T_IMG[trt]], X["alltxt"][vt], HE_T[vi, 0], "linear", 1000, k, pos_tr=T_IMG[trt])
189
+ # DLPFC synthetic captions: z-scored with TCGA-train stats (text domain is the same caption style)
190
+ with torch.no_grad():
191
+ Zt[e] = {"tcga": nz(ht(X["alltxt"][tt])), "orion_s": nz(ht(X["orion_gentext"][o["test"]])),
192
+ "dlpfc_s": nz(ht(X["dlpfc_gentext"]))}
193
+ he_h, he_t, he_d = nz(HE_H[hs["test"]]), nz(HE_T[ii, 0]), nz(HE_D)
194
+ for cname, (re_, te_) in CONF.items():
195
+ R = {s_: nz(sum(Zr[e][s_] for e in re_)) for s_ in Zr[re_[0]]}
196
+ Tx = {s_: nz(sum(Zt[e][s_] for e in te_)) for s_ in Zt[te_[0]]}
197
+ res = {("H&E<->mIF (ORION)", "trained"): g100(Zm["he_o"], Zm["orion"]),
198
+ ("H&E<->RNA (HEST)", "trained"): g100(he_h, R["hest"]),
199
+ ("H&E<->text (TCGA)", "trained"): g100(he_t, Tx["tcga"]),
200
+ ("mIF<->RNA(virtual) (ORION)", "emergent"): g100(Zm["orion"], R["orion_v"]),
201
+ ("mIF<->text(synthetic) (ORION)", "emergent"): g100(Zm["orion"], Tx["orion_s"]),
202
+ ("text<->RNA(virtual) (TCGA)", "emergent"): g100(Tx["tcga"], R["tcga_v"]),
203
+ ("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(Tx["tcga"], Zm["tcga_v"]),
204
+ ("H&E<->RNA (DLPFC)", "external"): g100(he_d, R["dlpfc"]),
205
+ ("RNA<->text(synthetic) (DLPFC)", "external"): g100(R["dlpfc"], Tx["dlpfc_s"]),
206
+ ("RNA<->mIF(MIPHEI) (DLPFC)", "external"): g100(R["dlpfc"], Zm["dlpfc_v"])}
207
+ for (pair, kind), r in res.items():
208
+ rows.append({"config": cname, "fold": k, "pair": pair, "kind": kind, **r})
209
+ print(f"fold {k} {cname:<10} " + " ".join(f"{p.split(' (')[0]} {r['R@1']:.3f}" for (p, _), r in res.items()), flush=True)
210
+ pd.DataFrame(rows).to_csv(os.path.join(ROOT, "results", "final_ens_long.csv"), index=False)
211
+
212
+ R = pd.DataFrame(rows)
213
+ S = R.groupby(["config", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
214
+ S.to_csv(os.path.join(ROOT, "results", "final_ens_summary.csv"))
215
+ tests = []
216
+ for c in CONF:
217
+ if c == "S4":
218
+ continue
219
+ for p in R.pair.unique():
220
+ a = R[(R.config == c) & (R.pair == p)].sort_values("fold")["R@1"].values
221
+ b = R[(R.config == "S4") & (R.pair == p)].sort_values("fold")["R@1"].values
222
+ tests.append({"config": c, "pair": p, "S4": b.mean(), "new": a.mean(), "delta": (a - b).mean(),
223
+ "folds_won": int((a > b).sum()), "p_paired_t": stats.ttest_rel(a, b).pvalue})
224
+ pd.DataFrame(tests).round(4).to_csv(os.path.join(ROOT, "results", "final_ens_tests.csv"), index=False)
225
+ pd.set_option("display.width", 250)
226
+ print(R.pivot_table(index="config", columns="pair", values="R@1", sort=False).round(3).T.to_string())
227
+ print(pd.DataFrame(tests).round(4).to_string())
experiments/48_joint_ens.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Joint training of the multi-encoder (late-fusion) four-modality model: does the J8 two-stage gain of 42_ stack with
2
+ the encoder fusion of 47_? Real pairs only; virtual / synthetic data only at test time (same splits / test items).
3
+
4
+ Stage 1 (separate, frozen UNI2 anchor; = 47_): one head per encoder - VirTues mIF -> MLP; every RNA / text encoder ->
5
+ linear (FM inputs z-scored with train stats, hvg / BiomedCLIP raw).
6
+ Stage 2 (joint, = J8 of 42_): all stage-1 heads + a shared zero-initialised residual adapter on UNI2 (H&E = UNI2 +
7
+ adapter(UNI2)) fine-tuned together, sum of the three pair losses (H&E-mIF, H&E-RNA, H&E-text), similarity
8
+ distillation lambda 1, AdamW lr 1e-4, 1000 steps, joint early stopping on the geometric mean of the three val R@1.
9
+ Configurations (per fold, all from the SAME stage-1 heads):
10
+ S4 stage 1 only, RNA hvg, text BiomedCLIP (single encoders, separate)
11
+ J8 S4 -> stage 2 (single encoders, joint)
12
+ E stage 1 only, ens-small (RNA hvg+Tahoe-x1+SE-600M+AIDO.Cell, text KEEP+BiomedCLIP+Qwen3-0.6B)
13
+ EJ-fused E -> stage 2, loss on the FUSED modality embedding normalise(sum_e normalise(head_e(x_e)))
14
+ EJ-each E -> stage 2, loss = mean over encoders of each head's own loss (fusion only at test time)
15
+ EJ-frz EJ-each with the RNA / text heads frozen (only the H&E adapter, the mIF head and the temperatures move)
16
+ Output results/joint_ens_long.csv, results/joint_ens_summary.csv, results/joint_ens_tests.csv
17
+ """
18
+ import copy
19
+ import os
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ import torch
24
+ import torch.nn as nn
25
+ import torch.nn.functional as F
26
+ from scipy import stats
27
+ from scipy.optimize import linear_sum_assignment
28
+
29
+ from common import CACHE, ROOT, SPLITS
30
+
31
+ dev = "cuda"
32
+ H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
33
+ G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
34
+ f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
35
+ nz = lambda x: F.normalize(x, dim=-1)
36
+ RNA_E, TXT_E = ["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]
37
+ CONF = {"S4": (["hvg"], ["biomedclip"], None), "J8": (["hvg"], ["biomedclip"], "fused"),
38
+ "E": (RNA_E, TXT_E, None), "EJ-fused": (RNA_E, TXT_E, "fused"), "EJ-each": (RNA_E, TXT_E, "each"),
39
+ "EJ-frz": (RNA_E, TXT_E, "frz")}
40
+
41
+ # ---------------------------------------------------------------- data (= 47_)
42
+ HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
43
+ IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
44
+ IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
45
+ HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
46
+ HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
47
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
48
+ T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
49
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
50
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
51
+
52
+
53
+ def hvg_inputs():
54
+ def virt(st):
55
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
56
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
57
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
58
+ return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
59
+ return {"hest": np.load(os.path.join(H, "hvg.npy")), "orion_v": virt("orion"), "tcga_v": virt("tcga_text")}
60
+
61
+
62
+ RNA_IN = {e: ({s: f32(x).to(dev) for s, x in hvg_inputs().items()} if e == "hvg" else
63
+ {s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in ["hest", "orion_v", "tcga_v"]})
64
+ for e in RNA_E}
65
+ TXT_IN = {e: {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev)
66
+ for s in ["alltxt", "orion_gentext"]} for e in TXT_E}
67
+
68
+
69
+ def mlp(din, dout=1536, hid=512):
70
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
71
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
72
+
73
+
74
+ def one_text_per_image(t_ids, rng):
75
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
76
+ return pick.index.values, pick.values
77
+
78
+
79
+ def g100(a, b, seed=0): # = 33_ / 42_ / 47_
80
+ a, b = a.cpu(), b.cpu()
81
+ rng = np.random.default_rng(seed)
82
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
83
+ for _ in range(DRAWS):
84
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
85
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
86
+ for S in (sim, sim.T):
87
+ rank = (S > d[:, None]).sum(1)
88
+ for k_ in (1, 5, 10):
89
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
90
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
91
+ ri, ci = linear_sum_assignment(-sim)
92
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
93
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
94
+
95
+
96
+ def r1(a, b):
97
+ s = a @ b.T
98
+ return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
99
+
100
+
101
+ def clip_loss(lg, pos=None):
102
+ if pos is None:
103
+ t = torch.arange(len(lg), device=dev)
104
+ return (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
105
+ tgt = pos / pos.sum(1, keepdim=True)
106
+ return (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
107
+
108
+
109
+ def zs(X, ref_set, idx, raw):
110
+ if raw:
111
+ return dict(X)
112
+ mu, sd = X[ref_set][idx].mean(0), X[ref_set][idx].std(0) + 1e-6
113
+ return {s: (v - mu) / sd for s, v in X.items()}
114
+
115
+
116
+ class Joint(nn.Module):
117
+ """heads: 'mif' + 'rna:<enc>' + 'text:<enc>'; logits per head + fused 'rna' / 'text'; residual adapter on UNI2"""
118
+
119
+ def __init__(self, heads, logits, adapter):
120
+ super().__init__()
121
+ self.heads = nn.ModuleDict({k.replace(".", "_"): copy.deepcopy(h) for k, h in heads.items()})
122
+ self.keys = {k.replace(".", "_"): k for k in heads}
123
+ self.logit = nn.ParameterDict({k.replace(":", "__").replace(".", "_"): nn.Parameter(v.detach().clone())
124
+ for k, v in logits.items()})
125
+ self.ah = None
126
+ if adapter:
127
+ self.ah = mlp(1536)
128
+ nn.init.zeros_(self.ah[-1].weight); nn.init.zeros_(self.ah[-1].bias)
129
+
130
+ def h(self, k):
131
+ return self.heads[k.replace(".", "_")]
132
+
133
+ def lg(self, k):
134
+ return self.logit[k.replace(":", "__").replace(".", "_")].exp().clamp(max=100)
135
+
136
+ def he(self, x):
137
+ return x if self.ah is None else x + self.ah(x)
138
+
139
+ def fuse(self, mod, encs, X):
140
+ """X: enc -> input rows; returns normalised fused embedding and the per-encoder embeddings"""
141
+ z = {e: nz(self.h(f"{mod}:{e}")(X[e])) for e in encs}
142
+ return nz(sum(z.values())), z
143
+
144
+
145
+ f_out = os.path.join(ROOT, "results", "joint_ens_long.csv")
146
+ rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
147
+ done = {(r["config"], r["fold"]) for r in rows}
148
+
149
+ for k in range(5):
150
+ if all((c, k) in done for c in CONF):
151
+ continue
152
+ torch.manual_seed(k)
153
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
154
+ o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
155
+ mu_if, sd_if = IF_O[o["train"]].mean((0, 1)), IF_O[o["train"]].std((0, 1)) + 1e-3
156
+ nif = lambda x: (x - mu_if) / sd_if
157
+ hh = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
158
+ hs = {x: hh.spot_idx[hh.split == x].values for x in ["train", "val", "test"]}
159
+ sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
160
+ tsp = sp.loc[T.img_idx].values
161
+ ts = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
162
+ vi, vt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["val"], np.random.default_rng(0)))
163
+ ii, tt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["test"], np.random.default_rng(k)))
164
+ trt = torch.as_tensor(ts["train"], device=dev)
165
+ Xr = {e: zs(RNA_IN[e], "hest", hs["train"], e == "hvg") for e in RNA_E}
166
+ Xt = {e: zs(TXT_IN[e], "alltxt", trt, e == "biomedclip") for e in TXT_E}
167
+ o_tr = torch.as_tensor(o["train"], device=dev)
168
+ h_tr = torch.as_tensor(hs["train"], device=dev)
169
+
170
+ def batch(task, rng):
171
+ """one training batch: (H&E anchor rows, modality inputs (enc -> rows), multi-positive matrix or None)"""
172
+ if task == "mif":
173
+ b = o_tr[torch.as_tensor(rng.choice(len(o_tr), BS, replace=False), device=dev)]
174
+ d = torch.randint(0, 8, (BS,), device=dev)
175
+ return HE_O[b, d], {"mif": nif(IF_O[b, d])}, None
176
+ if task == "rna":
177
+ b = h_tr[torch.as_tensor(rng.choice(len(h_tr), BS, replace=False), device=dev)]
178
+ return HE_H[b], {e: Xr[e]["hest"][b] for e in RNA_E}, None
179
+ b = trt[torch.as_tensor(rng.choice(len(trt), BS, replace=False), device=dev)]
180
+ im = T_IMG[b]
181
+ return (HE_T[im, torch.randint(0, 8, (BS,), device=dev)], {e: Xt[e]["alltxt"][b] for e in TXT_E},
182
+ (im[:, None] == im[None, :]).float())
183
+
184
+ VAL = {"mif": (HE_O[o["val"], 0], {"mif": nif(IF_O[o["val"], 0])}),
185
+ "rna": (HE_H[hs["val"]], {e: Xr[e]["hest"][hs["val"]] for e in RNA_E}),
186
+ "text": (HE_T[vi, 0], {e: Xt[e]["alltxt"][vt] for e in TXT_E})}
187
+
188
+ # ------------------------------------------------ stage 1: one head per encoder, frozen anchor (= 47_)
189
+ def train_head(task, enc, kind, steps):
190
+ torch.manual_seed(k); rng = np.random.default_rng(k)
191
+ din = VAL[task][1][enc].shape[1]
192
+ h = (mlp(din) if kind == "mlp" else nn.Linear(din, 1536)).to(dev)
193
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
194
+ opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
195
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
196
+ za_va = nz(VAL[task][0])
197
+ best, state = -1, None
198
+ for step in range(1, steps + 1):
199
+ h.train()
200
+ a, X, pos = batch(task, rng)
201
+ loss = clip_loss(logit.exp().clamp(max=100) * nz(a) @ nz(h(X[enc])).T, pos)
202
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
203
+ if step % EVAL_EVERY == 0 or step == steps:
204
+ h.eval()
205
+ with torch.no_grad():
206
+ v = r1(za_va, nz(h(VAL[task][1][enc])))
207
+ if v > best:
208
+ best, state = v, ({k_: x.detach().clone() for k_, x in h.state_dict().items()}, logit.detach().clone())
209
+ h.load_state_dict(state[0]); h.eval()
210
+ return h, state[1]
211
+
212
+ HEADS, LOGITS = {}, {}
213
+ HEADS["mif"], LOGITS["mif"] = train_head("mif", "mif", "mlp", 400)
214
+ for e in RNA_E:
215
+ HEADS[f"rna:{e}"], LOGITS[f"rna:{e}"] = train_head("rna", e, "linear", 2700)
216
+ for e in TXT_E:
217
+ HEADS[f"text:{e}"], LOGITS[f"text:{e}"] = train_head("text", e, "linear", 1000)
218
+ print(f"fold {k} stage 1 done", flush=True)
219
+
220
+ # ------------------------------------------------ stage 2: joint residual fine-tune (= J8 of 42_)
221
+ def val_score(m, re_, te_):
222
+ m.eval()
223
+ with torch.no_grad():
224
+ v = {"mif": r1(nz(m.he(VAL["mif"][0])), nz(m.h("mif")(VAL["mif"][1]["mif"]))),
225
+ "rna": r1(nz(m.he(VAL["rna"][0])), m.fuse("rna", re_, VAL["rna"][1])[0]),
226
+ "text": r1(nz(m.he(VAL["text"][0])), m.fuse("text", te_, VAL["text"][1])[0])}
227
+ m.train()
228
+ return float(np.exp(np.mean(np.log([max(x, 1e-4) for x in v.values()])))), v
229
+
230
+ def joint(re_, te_, mode, steps=1000, lr=1e-4, distill=1.0):
231
+ keys = ["mif"] + [f"rna:{e}" for e in re_] + [f"text:{e}" for e in te_]
232
+ lg = {kk: LOGITS[kk] for kk in keys}
233
+ lg["rna"] = torch.stack([LOGITS[f"rna:{e}"] for e in re_]).mean()
234
+ lg["text"] = torch.stack([LOGITS[f"text:{e}"] for e in te_]).mean()
235
+ m = Joint({kk: HEADS[kk] for kk in keys}, lg, adapter=True).to(dev)
236
+ torch.manual_seed(k); rng = np.random.default_rng(k)
237
+ if mode == "frz":
238
+ for kk in keys[1:]:
239
+ m.h(kk).requires_grad_(False)
240
+ opt = torch.optim.AdamW([p for p in m.parameters() if p.requires_grad], lr=lr, weight_decay=0.05)
241
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=steps, pct_start=0.05)
242
+ best, state, bv = -1, None, None
243
+ m.train()
244
+ for step in range(1, steps + 1):
245
+ loss = 0
246
+ for task in ("mif", "rna", "text"):
247
+ a, X, pos = batch(task, rng)
248
+ za = nz(m.he(a))
249
+ if task == "mif":
250
+ loss = loss + clip_loss(m.lg("mif") * za @ nz(m.h("mif")(X["mif"])).T, pos)
251
+ else:
252
+ encs = re_ if task == "rna" else te_
253
+ X = {e: X[e] for e in encs}
254
+ zf, ze = m.fuse(task, encs, X)
255
+ if mode == "fused":
256
+ loss = loss + clip_loss(m.lg(task) * za @ zf.T, pos)
257
+ else:
258
+ loss = loss + sum(clip_loss(m.lg(f"{task}:{e}") * za @ ze[e].T, pos) for e in encs) / len(encs)
259
+ u = nz(a)
260
+ loss = loss + distill * F.mse_loss(za @ za.T, u @ u.T)
261
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
262
+ if step % EVAL_EVERY == 0 or step == steps:
263
+ sc, v = val_score(m, re_, te_)
264
+ if sc > best:
265
+ best, bv, state = sc, v, {k_: x.detach().clone() for k_, x in m.state_dict().items()}
266
+ m.load_state_dict(state); m.eval()
267
+ return m, bv
268
+
269
+ @torch.no_grad()
270
+ def evaluate(m, re_, te_):
271
+ R = lambda st, idx: m.fuse("rna", re_, {e: Xr[e][st][idx] for e in re_})[0]
272
+ Tx = lambda st, idx: m.fuse("text", te_, {e: Xt[e][st][idx] for e in te_})[0]
273
+ zm = nz(m.h("mif")(nif(IF_O[o["test"], 0])))
274
+ zt = Tx("alltxt", tt)
275
+ return {("H&E<->mIF (ORION)", "trained"): g100(nz(m.he(HE_O[o["test"], 0])), zm),
276
+ ("H&E<->RNA (HEST)", "trained"): g100(nz(m.he(HE_H[hs["test"]])), R("hest", hs["test"])),
277
+ ("H&E<->text (TCGA)", "trained"): g100(nz(m.he(HE_T[ii, 0])), zt),
278
+ ("mIF<->RNA(virtual) (ORION)", "emergent"): g100(zm, R("orion_v", o["test"])),
279
+ ("mIF<->text(synthetic) (ORION)", "emergent"): g100(zm, Tx("orion_gentext", o["test"])),
280
+ ("text<->RNA(virtual) (TCGA)", "emergent"): g100(zt, R("tcga_v", ii)),
281
+ ("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(zt, nz(m.h("mif")(nif(IF_V_T[ii]))))}
282
+
283
+ for cname, (re_, te_, mode) in CONF.items():
284
+ if (cname, k) in done:
285
+ continue
286
+ if mode is None: # separate: stage-1 heads, no adapter
287
+ keys = ["mif"] + [f"rna:{e}" for e in re_] + [f"text:{e}" for e in te_]
288
+ m = Joint({kk: HEADS[kk] for kk in keys}, {kk: LOGITS[kk] for kk in keys}, adapter=False).to(dev).eval()
289
+ val = {}
290
+ else:
291
+ m, val = joint(re_, te_, mode)
292
+ for (pair, kind), r in evaluate(m, re_, te_).items():
293
+ rows.append({"config": cname, "fold": k, "pair": pair, "kind": kind, **r,
294
+ **{f"val_{t}": v for t, v in val.items()}})
295
+ pd.DataFrame(rows).to_csv(f_out, index=False)
296
+ print(f"fold {k} {cname:<9} " + " ".join(f"{r['pair'].split(' (')[0]} {r['R@1']:.3f}"
297
+ for r in rows if r["config"] == cname and r["fold"] == k), flush=True)
298
+
299
+ R = pd.DataFrame(rows)
300
+ S = R.groupby(["config", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
301
+ S.to_csv(os.path.join(ROOT, "results", "joint_ens_summary.csv"))
302
+ tests = []
303
+ for c, ref in [("J8", "S4"), ("E", "S4"), ("EJ-fused", "S4"), ("EJ-each", "S4"), ("EJ-fused", "E"), ("EJ-each", "E"),
304
+ ("EJ-fused", "J8"), ("EJ-each", "EJ-fused"), ("EJ-frz", "S4"),
305
+ ("EJ-frz", "E"), ("EJ-frz", "EJ-each")]:
306
+ for p in R.pair.unique():
307
+ a = R[(R.config == c) & (R.pair == p)].sort_values("fold")["R@1"].values
308
+ b = R[(R.config == ref) & (R.pair == p)].sort_values("fold")["R@1"].values
309
+ tests.append({"config": c, "vs": ref, "pair": p, "ref": b.mean(), "new": a.mean(), "delta": (a - b).mean(),
310
+ "folds_won": int((a > b).sum()), "p_paired_t": stats.ttest_rel(a, b).pvalue})
311
+ pd.DataFrame(tests).round(4).to_csv(os.path.join(ROOT, "results", "joint_ens_tests.csv"), index=False)
312
+ pd.set_option("display.width", 250)
313
+ print(R.pivot_table(index="config", columns="pair", values="R@1", sort=False).round(3).T.to_string())
314
+ print(R.pivot_table(index="config", columns="pair", values="pair_acc", sort=False).round(3).T.to_string())
315
+ print(pd.DataFrame(tests).round(4).to_string())
experiments/49_hest_patches.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """H&E patches of the 9,484 HEST training-pool spots (cache/hest/meta.csv), so that image encoders other than the
2
+ precomputed UNI2 / GigaPath features can be run on them (e.g. MedVTok).
3
+
4
+ Run: HF_TOKEN=... .venv/bin/python -I 49_hest_patches.py [--workers 16] [--only <sample> ...]
5
+ Source: MahmoodLab/hest wsis/<sample>.tif (pyramidal tiled TIFF), level 0, read with HTTP range requests on the
6
+ resolved CDN URL (RangeFile) - only the tiles under the needed spots. (The patches/<sample>.h5 files cannot be read
7
+ selectively: their barcode chunks are interleaved with the image chunks, i.e. finding the rows reads the whole file.)
8
+ Patch = 224 x 224 level-0 px centred on the spot centre (obsm['spatial'] = full-res (x, y), cache/hest/spot_coords.csv),
9
+ no resizing: this is exactly the image behind the cached UNI2 features (re-encoding these crops with UNI2-h gives
10
+ cos 1.000 to cache/hest/he_uni2.npy, checked on TENX152 / MEND139; 0.5-um/px resampled crops give only 0.66-0.81).
11
+ Pixels outside the slide are white. Resumable per sample.
12
+ Output cache/hest/patches/<sample>.npz (img, idx) and, once all are present, cache/hest/he_img.npy (9484, 224, 224, 3)
13
+ uint8 in meta.csv row order (+ cache/hest/he_img_missing.csv, spots whose slide could not be read).
14
+ """
15
+ import io
16
+ import os
17
+ import sys
18
+ from concurrent.futures import ThreadPoolExecutor
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ import requests
23
+ import tifffile
24
+
25
+ ROOT = os.path.dirname(os.path.abspath(__file__))
26
+ H = os.path.join(ROOT, "cache", "hest")
27
+ OUT = os.path.join(H, "patches")
28
+ os.makedirs(OUT, exist_ok=True)
29
+ WORKERS = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else 16
30
+ ONLY = sys.argv[sys.argv.index("--only") + 1:] if "--only" in sys.argv else None
31
+ M = pd.read_csv(os.path.join(H, "meta.csv"))
32
+ C = pd.read_csv(os.path.join(H, "spot_coords.csv"))
33
+ assert (C.idx.values == M.idx.values).all()
34
+
35
+
36
+ class RangeFile(io.RawIOBase):
37
+ """Read-only, seekable file over HTTP range requests (block cache), for h5py. huggingface_hub's HfFileSystem hung
38
+ in this environment (2026-10-28), plain range requests on the resolved CDN URL are fast."""
39
+ BLOCK = 256 * 1024
40
+
41
+ def __init__(self, url):
42
+ hdr = {"Authorization": f"Bearer {os.environ['HF_TOKEN']}"}
43
+ r = requests.get(url, headers={**hdr, "Range": "bytes=0-0"}, timeout=60)
44
+ r.raise_for_status()
45
+ self.url = r.url # signed CDN URL after redirects
46
+ self.size = int(r.headers["Content-Range"].split("/")[1])
47
+ self.pos, self.cache, self.s = 0, {}, requests.Session()
48
+
49
+ def _block(self, i):
50
+ if i not in self.cache:
51
+ a = i * self.BLOCK
52
+ for t in range(5):
53
+ try:
54
+ r = self.s.get(self.url, headers={"Range": f"bytes={a}-{min(self.size, a + self.BLOCK) - 1}"}, timeout=60)
55
+ r.raise_for_status()
56
+ break
57
+ except requests.RequestException:
58
+ if t == 4:
59
+ raise
60
+ self.cache[i] = r.content
61
+ return self.cache[i]
62
+
63
+ def readable(self):
64
+ return True
65
+
66
+ def seekable(self):
67
+ return True
68
+
69
+ def tell(self):
70
+ return self.pos
71
+
72
+ def seek(self, off, whence=0):
73
+ self.pos = off if whence == 0 else self.pos + off if whence == 1 else self.size + off
74
+ return self.pos
75
+
76
+ def readinto(self, b):
77
+ n = min(len(b), max(0, self.size - self.pos))
78
+ out, p = bytearray(), self.pos
79
+ while len(out) < n:
80
+ blk = self._block(p // self.BLOCK)
81
+ o = p % self.BLOCK
82
+ take = blk[o:o + n - len(out)]
83
+ out += take; p += len(take)
84
+ b[:n] = out
85
+ self.pos = p
86
+ return n
87
+
88
+
89
+ def fetch(sid):
90
+ out = os.path.join(OUT, f"{sid}.npz")
91
+ if os.path.exists(out):
92
+ return sid, "cached"
93
+ c = C[C["sample"] == sid]
94
+ f = RangeFile(f"https://huggingface.co/datasets/MahmoodLab/hest/resolve/main/wsis/{sid}.tif")
95
+ with tifffile.TiffFile(f) as tf:
96
+ pg = tf.pages[0]
97
+ Hh, W = pg.shape[:2]
98
+ th, tw = pg.tilelength, pg.tilewidth
99
+ ntx = -(-W // tw)
100
+ S = 224
101
+ tiles = {}
102
+
103
+ def tile(ty, tx):
104
+ if (ty, tx) not in tiles:
105
+ i = ty * ntx + tx
106
+ f.seek(pg.dataoffsets[i])
107
+ a = pg.decode(f.read(pg.databytecounts[i]), i, jpegtables=pg.jpegtables)[0]
108
+ tiles[(ty, tx)] = a.reshape(th, tw, -1)[..., :3]
109
+ return tiles[(ty, tx)]
110
+
111
+ imgs = []
112
+ for x, y in zip(c.x.values, c.y.values):
113
+ x0, y0 = int(round(x - S / 2)), int(round(y - S / 2))
114
+ reg = np.full((S, S, 3), 255, np.uint8)
115
+ for ty in range(max(0, y0 // th), min(-(-Hh // th), (y0 + S - 1) // th + 1)):
116
+ for tx in range(max(0, x0 // tw), min(ntx, (x0 + S - 1) // tw + 1)):
117
+ a = tile(ty, tx)
118
+ ys, xs = max(y0, ty * th), max(x0, tx * tw)
119
+ ye, xe = min(y0 + S, (ty + 1) * th, Hh), min(x0 + S, (tx + 1) * tw, W)
120
+ reg[ys - y0:ye - y0, xs - x0:xe - x0] = a[ys - ty * th:ye - ty * th, xs - tx * tw:xe - tx * tw]
121
+ imgs.append(reg)
122
+ np.savez(out + ".tmp.npz", img=np.stack(imgs), idx=c.idx.values)
123
+ os.replace(out + ".tmp.npz", out)
124
+ return sid, f"{len(imgs)} spots, crop {S}px, {len(tiles)} tiles, {sum(len(v) for v in f.cache.values()) / 1e6:.0f} MB"
125
+
126
+
127
+ samples = ONLY or M["sample"].unique().tolist()
128
+ with ThreadPoolExecutor(WORKERS) as ex:
129
+ futs = {s: ex.submit(fetch, s) for s in samples}
130
+ for n, (sid, fu) in enumerate(futs.items()):
131
+ try:
132
+ print(f"[{n + 1}/{len(samples)}] {sid}: {fu.result()[1]}", flush=True)
133
+ except Exception as e: # noqa: BLE001 - reported, spots become 'missing'
134
+ print(f"[{n + 1}/{len(samples)}] {sid}: FAILED {type(e).__name__}: {e}", flush=True)
135
+ if ONLY:
136
+ sys.exit()
137
+
138
+ img = np.zeros((len(M), 224, 224, 3), np.uint8)
139
+ have = np.zeros(len(M), bool)
140
+ for sid in M["sample"].unique():
141
+ p = os.path.join(OUT, f"{sid}.npz")
142
+ if os.path.exists(p):
143
+ z = np.load(p)
144
+ img[z["idx"]] = z["img"]; have[z["idx"]] = True
145
+ np.save(os.path.join(H, "he_img.npy"), img)
146
+ pd.DataFrame({"idx": np.flatnonzero(~have)}).to_csv(os.path.join(H, "he_img_missing.csv"), index=False)
147
+ print(f"saved he_img.npy {img.shape}; missing {(~have).sum()} spots")
experiments/51_medvtok_eval.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MedVTok as image (H&E) information for the fused four-modality model, plus the adapter-only variant of EJ-frz.
2
+ Real pairs only; virtual / synthetic data only at test time; same splits / test items as 42_ / 47_ / 48_.
3
+ RNA = ens-small (hvg + Tahoe-x1 + SE-600M + AIDO.Cell), text = KEEP + BiomedCLIP + Qwen3-Emb-0.6B, mIF = VirTues MLP.
4
+
5
+ Configurations (per fold; all UNI2 configs start from the same stage-1 heads):
6
+ E separate, frozen UNI2 anchor (= 47_ ens-small / 48_ E)
7
+ EJ-frz E -> joint stage 2: residual adapter on UNI2 + mIF head, RNA / text heads frozen (= 48_)
8
+ EJ-ad EJ-frz with the mIF head frozen as well (only the adapter + temperatures train)
9
+ MV-E separate, frozen MedVTok anchor: every head maps into the MedVTok space (1152-d) instead of UNI2
10
+ EJ-frz+MV EJ-frz whose adapter also sees MedVTok: H&E = UNI2 + A([UNI2, c * MedVTok]) (c matches the mean
11
+ feature norms on ORION train; zero-initialised output, so it starts exactly at E)
12
+ EJ-ad+MV EJ-ad with the same MedVTok-informed adapter
13
+ EJ-ad+RP control for EJ-ad+MV: the extra adapter input is a fixed random projection of UNI2 to 1152-d (same
14
+ shape / scale as MedVTok, no new information) -> separates "MedVTok information" from "wider adapter"
15
+ Stage 2 (= J8 / 48_): sum of the three pair losses (each RNA / text head its own loss, averaged over encoders),
16
+ similarity distillation to UNI2 (lambda 1), AdamW lr 1e-4, 1000 steps, early stopping on the geometric mean of the
17
+ three val R@1 (fused embeddings).
18
+ External DLPFC H&E<->RNA for E and MV-E (RNA heads retrained without spatialLIBD, inputs z-scored per dataset, = 47_).
19
+ Output results/medvtok_long.csv, results/medvtok_summary.csv, results/medvtok_tests.csv
20
+ """
21
+ import copy
22
+ import os
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+ from scipy import stats
30
+ from scipy.optimize import linear_sum_assignment
31
+
32
+ from common import CACHE, ROOT, SPLITS
33
+
34
+ dev = "cuda"
35
+ H, RF, EXT = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm"), os.path.join(CACHE, "external")
36
+ G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
37
+ f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
38
+ nz = lambda x: F.normalize(x, dim=-1)
39
+ RNA_E, TXT_E = ["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]
40
+ # name: (anchor, stage-2 mode, adapter input)
41
+ CONF = {"E": ("uni2", None, None), "EJ-frz": ("uni2", "frz", "u"), "EJ-ad": ("uni2", "ad", "u"),
42
+ "MV-E": ("medvtok", None, None), "EJ-frz+MV": ("uni2", "frz", "cat"), "EJ-ad+MV": ("uni2", "ad", "cat"),
43
+ "EJ-ad+RP": ("uni2", "ad", "rp")}
44
+
45
+ # ---------------------------------------------------------------- data
46
+ IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
47
+ IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
48
+ AN = {"uni2": {"o": np.load(os.path.join(CACHE, "he_uni2.npy")), "h": np.load(os.path.join(H, "he_uni2.npy"))[:, 0],
49
+ "t": np.load(os.path.join(CACHE, "allimg_he_uni2.npy")), "d": np.load(os.path.join(EXT, "dlpfc_he_uni2.npy"))},
50
+ "medvtok": {"o": np.load(os.path.join(CACHE, "he_medvtok.npy")), "h": np.load(os.path.join(H, "he_medvtok.npy"))[:, 0],
51
+ "t": np.load(os.path.join(CACHE, "allimg_he_medvtok.npy")),
52
+ "d": np.load(os.path.join(EXT, "dlpfc_he_medvtok.npy"))}}
53
+ AN = {a: {s: f32(x).to(dev) for s, x in d.items()} for a, d in AN.items()}
54
+ DIM = {a: AN[a]["o"].shape[-1] for a in AN}
55
+ MISS = pd.read_csv(os.path.join(H, "he_img_missing.csv")).idx.values # HEST spots without a HEST patch (no MedVTok)
56
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
57
+ T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
58
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
59
+ HM = pd.read_csv(os.path.join(H, "meta.csv"))
60
+ RPM = torch.randn(1536, DIM["medvtok"], generator=torch.Generator().manual_seed(0)).to(dev) / np.sqrt(1536)
61
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
62
+
63
+
64
+ def hvg_inputs(): # = 47_
65
+ def virt(st):
66
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
67
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
68
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
69
+ return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
70
+ eg = pd.read_csv(os.path.join(EXT, "dlpfc_genes_full.csv")).gene.values
71
+ ec = np.load(os.path.join(EXT, "dlpfc_counts_full.npy")).astype(np.float32)
72
+ ei = pd.Series(range(len(eg)), index=eg); ei = ei[~ei.index.duplicated()]
73
+ d = np.log1p(ec / ec.sum(1, keepdims=True).clip(1) * 1e4)
74
+ d = np.stack([d[:, ei[g]] if g in ei.index else np.zeros(len(d), np.float32) for g in hvg], 1)
75
+ return {"hest": np.load(os.path.join(H, "hvg.npy")), "orion_v": virt("orion"), "tcga_v": virt("tcga_text"), "dlpfc": d}
76
+
77
+
78
+ RNA_IN = {e: ({s: f32(x).to(dev) for s, x in hvg_inputs().items()} if e == "hvg" else
79
+ {s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in ["hest", "orion_v", "tcga_v", "dlpfc"]})
80
+ for e in RNA_E}
81
+ TXT_IN = {e: {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev)
82
+ for s in ["alltxt", "orion_gentext"]} for e in TXT_E}
83
+
84
+
85
+ def mlp(din, dout=1536, hid=512):
86
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
87
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
88
+
89
+
90
+ def one_text_per_image(t_ids, rng):
91
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
92
+ return pick.index.values, pick.values
93
+
94
+
95
+ def g100(a, b, seed=0): # = 33_ / 42_ / 47_ / 48_
96
+ a, b = a.cpu(), b.cpu()
97
+ rng = np.random.default_rng(seed)
98
+ out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
99
+ for _ in range(DRAWS):
100
+ s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
101
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
102
+ for S in (sim, sim.T):
103
+ rank = (S > d[:, None]).sum(1)
104
+ for k_ in (1, 5, 10):
105
+ out[f"R@{k_}"].append((rank < k_).mean() / 2)
106
+ out["MRR"].append((1 / (rank + 1)).mean() / 2)
107
+ ri, ci = linear_sum_assignment(-sim)
108
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
109
+ return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
110
+
111
+
112
+ def r1(a, b):
113
+ s = a @ b.T
114
+ return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
115
+
116
+
117
+ def clip_loss(lg, pos=None):
118
+ if pos is None:
119
+ t = torch.arange(len(lg), device=dev)
120
+ return (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
121
+ tgt = pos / pos.sum(1, keepdim=True)
122
+ return (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
123
+
124
+
125
+ def zs(X, ref_set, idx, raw, ext=None): # = 47_
126
+ out = dict(X) if raw else {s: (v - X[ref_set][idx].mean(0)) / (X[ref_set][idx].std(0) + 1e-6) for s, v in X.items()}
127
+ for s in ext or []:
128
+ out[s] = (X[s] - X[s].mean(0)) / (X[s].std(0) + 1e-6)
129
+ return out
130
+
131
+
132
+ class Joint(nn.Module):
133
+ """heads 'mif', 'rna:<enc>', 'text:<enc>' (deep copies of stage-1 heads); adapter None / 'u' (UNI2 -> residual) /
134
+ 'cat' ([UNI2, c * MedVTok] -> residual on UNI2)"""
135
+
136
+ def __init__(self, heads, logits, adapter=None, c_mv=1.0, anchor="uni2"):
137
+ super().__init__()
138
+ self.anchor = anchor
139
+ self.heads = nn.ModuleDict({k.replace(".", "_"): copy.deepcopy(h) for k, h in heads.items()})
140
+ self.logit = nn.ParameterDict({k.replace(":", "__").replace(".", "_"): nn.Parameter(v.detach().clone())
141
+ for k, v in logits.items()})
142
+ self.adapter, self.c_mv, self.ah = adapter, c_mv, None
143
+ if adapter:
144
+ self.ah = nn.Sequential(nn.Linear(1536 + (DIM["medvtok"] if adapter in ("cat", "rp") else 0), 512), *mlp(1536)[1:])
145
+ nn.init.zeros_(self.ah[-1].weight); nn.init.zeros_(self.ah[-1].bias)
146
+
147
+ def h(self, k):
148
+ return self.heads[k.replace(".", "_")]
149
+
150
+ def lg(self, k):
151
+ return self.logit[k.replace(":", "__").replace(".", "_")].exp().clamp(max=100)
152
+
153
+ def he(self, a):
154
+ """a: dict anchor -> rows (same images / views)"""
155
+ if self.adapter is None:
156
+ return a[self.anchor]
157
+ x = a["uni2"]
158
+ if self.adapter == "u":
159
+ inp = x
160
+ elif self.adapter == "cat":
161
+ inp = torch.cat([x, self.c_mv * a["medvtok"]], -1)
162
+ else: # "rp": random projection of UNI2, rescaled to the same mean norm as c * MedVTok
163
+ rp = x @ RPM
164
+ inp = torch.cat([x, rp * (self.c_mv * self.mv_norm / self.rp_norm)], -1)
165
+ return x + self.ah(inp)
166
+
167
+ def fuse(self, mod, encs, X):
168
+ z = {e: nz(self.h(f"{mod}:{e}")(X[e])) for e in encs}
169
+ return nz(sum(z.values())), z
170
+
171
+
172
+ f_out = os.path.join(ROOT, "results", "medvtok_long.csv")
173
+ rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
174
+ done = {(r["config"], r["fold"]) for r in rows}
175
+
176
+ for k in range(5):
177
+ if all((c, k) in done for c in CONF):
178
+ continue
179
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
180
+ o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
181
+ mu_if, sd_if = IF_O[o["train"]].mean((0, 1)), IF_O[o["train"]].std((0, 1)) + 1e-3
182
+ nif = lambda x: (x - mu_if) / sd_if
183
+ hh = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
184
+ # spots without a HEST patch have no MedVTok feature: dropped from every split for every config (same items)
185
+ hs = {x: np.setdiff1d(hh.spot_idx[hh.split == x].values, MISS) for x in ["train", "val", "test"]}
186
+ sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
187
+ tsp = sp.loc[T.img_idx].values
188
+ ts = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
189
+ vi, vt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["val"], np.random.default_rng(0)))
190
+ ii, tt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["test"], np.random.default_rng(k)))
191
+ trt = torch.as_tensor(ts["train"], device=dev)
192
+ Xr = {e: zs(RNA_IN[e], "hest", hs["train"], e == "hvg") for e in RNA_E}
193
+ Xt = {e: zs(TXT_IN[e], "alltxt", trt, e == "biomedclip") for e in TXT_E}
194
+ o_tr, h_tr = torch.as_tensor(o["train"], device=dev), torch.as_tensor(hs["train"], device=dev)
195
+ # DLPFC heads: HEST without spatialLIBD (= 47_)
196
+ pool = np.setdiff1d(np.flatnonzero(~HM.group.str.startswith("spatialLIBD").values), MISS)
197
+ grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
198
+ isva = HM.group.iloc[pool].isin(set(grp[: max(1, len(grp) // 10)])).values
199
+ d_tr, d_va = pool[~isva], pool[isva]
200
+ c_mv = float(AN["uni2"]["o"][o["train"]].norm(dim=-1).mean() / AN["medvtok"]["o"][o["train"]].norm(dim=-1).mean())
201
+ mv_norm = float(AN["medvtok"]["o"][o["train"]].norm(dim=-1).mean())
202
+ rp_norm = float((AN["uni2"]["o"][o["train"]] @ RPM).norm(dim=-1).mean())
203
+
204
+ def anc(key, idx, views=None):
205
+ """anchor rows of all anchor encoders for the same items / views"""
206
+ out = {}
207
+ for a in AN:
208
+ x = AN[a][key]
209
+ out[a] = x[idx] if x.dim() == 2 else x[idx, views if views is not None else 0]
210
+ return out
211
+
212
+ def batch(task, rng):
213
+ if task == "mif":
214
+ b = o_tr[torch.as_tensor(rng.choice(len(o_tr), BS, replace=False), device=dev)]
215
+ d = torch.randint(0, 8, (BS,), device=dev)
216
+ return anc("o", b, d), {"mif": nif(IF_O[b, d])}, None
217
+ if task == "rna":
218
+ b = h_tr[torch.as_tensor(rng.choice(len(h_tr), BS, replace=False), device=dev)]
219
+ return anc("h", b), {e: Xr[e]["hest"][b] for e in RNA_E}, None
220
+ b = trt[torch.as_tensor(rng.choice(len(trt), BS, replace=False), device=dev)]
221
+ im = T_IMG[b]
222
+ return (anc("t", im, torch.randint(0, 8, (BS,), device=dev)), {e: Xt[e]["alltxt"][b] for e in TXT_E},
223
+ (im[:, None] == im[None, :]).float())
224
+
225
+ VAL = {"mif": (anc("o", o["val"]), {"mif": nif(IF_O[o["val"], 0])}),
226
+ "rna": (anc("h", hs["val"]), {e: Xr[e]["hest"][hs["val"]] for e in RNA_E}),
227
+ "text": (anc("t", vi), {e: Xt[e]["alltxt"][vt] for e in TXT_E})}
228
+
229
+ # ------------------------------------------------ stage 1 (frozen anchor, one head per encoder)
230
+ def train_head(task, enc, kind, steps, an, X_tr_fn=None, X_va=None, A_va=None):
231
+ torch.manual_seed(k); rng = np.random.default_rng(k)
232
+ din = VAL[task][1][enc].shape[1]
233
+ h = (mlp(din, DIM[an]) if kind == "mlp" else nn.Linear(din, DIM[an])).to(dev)
234
+ logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
235
+ opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
236
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
237
+ za_va = nz(VAL[task][0][an] if A_va is None else A_va)
238
+ x_va = VAL[task][1][enc] if X_va is None else X_va
239
+ best, state = -1, None
240
+ for step in range(1, steps + 1):
241
+ h.train()
242
+ a, X, pos = batch(task, rng) if X_tr_fn is None else X_tr_fn(rng)
243
+ loss = clip_loss(logit.exp().clamp(max=100) * nz(a[an]) @ nz(h(X[enc])).T, pos)
244
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
245
+ if step % EVAL_EVERY == 0 or step == steps:
246
+ h.eval()
247
+ with torch.no_grad():
248
+ v = r1(za_va, nz(h(x_va)))
249
+ if v > best:
250
+ best, state = v, ({k_: x.detach().clone() for k_, x in h.state_dict().items()}, logit.detach().clone())
251
+ h.load_state_dict(state[0]); h.eval()
252
+ return h, state[1]
253
+
254
+ HEADS, LOGITS = {}, {}
255
+ for an in AN:
256
+ HEADS[an], LOGITS[an] = {}, {}
257
+ HEADS[an]["mif"], LOGITS[an]["mif"] = train_head("mif", "mif", "mlp", 400, an)
258
+ for e in RNA_E:
259
+ HEADS[an][f"rna:{e}"], LOGITS[an][f"rna:{e}"] = train_head("rna", e, "linear", 2700, an)
260
+ for e in TXT_E:
261
+ HEADS[an][f"text:{e}"], LOGITS[an][f"text:{e}"] = train_head("text", e, "linear", 1000, an)
262
+ print(f"fold {k} stage 1 done (c_mv {c_mv:.3f})", flush=True)
263
+
264
+ # DLPFC external: RNA heads without spatialLIBD, per anchor
265
+ ZD = {}
266
+ for an in AN:
267
+ zd = []
268
+ for e in RNA_E:
269
+ Xd = zs(RNA_IN[e], "hest", d_tr, False, ext=["dlpfc"])
270
+ d_tr_t = torch.as_tensor(d_tr, device=dev)
271
+
272
+ def dl_batch(rng, Xd=Xd, e=e, d_tr_t=d_tr_t):
273
+ b = d_tr_t[torch.as_tensor(rng.choice(len(d_tr_t), BS, replace=False), device=dev)]
274
+ return anc("h", b), {e: Xd["hest"][b]}, None
275
+ hd, _ = train_head("rna", e, "linear", 2700, an, dl_batch, Xd["hest"][d_va], AN[an]["h"][d_va])
276
+ with torch.no_grad():
277
+ zd.append(nz(hd(Xd["dlpfc"])))
278
+ ZD[an] = nz(sum(zd))
279
+
280
+ # ------------------------------------------------ stage 2
281
+ keys = ["mif"] + [f"rna:{e}" for e in RNA_E] + [f"text:{e}" for e in TXT_E]
282
+
283
+ def val_score(m):
284
+ m.eval()
285
+ with torch.no_grad():
286
+ v = {"mif": r1(nz(m.he(VAL["mif"][0])), nz(m.h("mif")(VAL["mif"][1]["mif"]))),
287
+ "rna": r1(nz(m.he(VAL["rna"][0])), m.fuse("rna", RNA_E, VAL["rna"][1])[0]),
288
+ "text": r1(nz(m.he(VAL["text"][0])), m.fuse("text", TXT_E, VAL["text"][1])[0])}
289
+ m.train()
290
+ return float(np.exp(np.mean(np.log([max(x, 1e-4) for x in v.values()])))), v
291
+
292
+ def joint(mode, adapter, steps=1000, lr=1e-4, distill=1.0):
293
+ m = Joint(HEADS["uni2"], LOGITS["uni2"], adapter, c_mv).to(dev)
294
+ m.mv_norm, m.rp_norm = mv_norm, rp_norm
295
+ for kk in keys[1:] if mode == "frz" else keys:
296
+ m.h(kk).requires_grad_(False)
297
+ torch.manual_seed(k); rng = np.random.default_rng(k)
298
+ opt = torch.optim.AdamW([p for p in m.parameters() if p.requires_grad], lr=lr, weight_decay=0.05)
299
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=steps, pct_start=0.05)
300
+ best, state, bv = -1, None, None
301
+ m.train()
302
+ for step in range(1, steps + 1):
303
+ loss = 0
304
+ for task in ("mif", "rna", "text"):
305
+ a, X, pos = batch(task, rng)
306
+ za = nz(m.he(a))
307
+ if task == "mif":
308
+ loss = loss + clip_loss(m.lg("mif") * za @ nz(m.h("mif")(X["mif"])).T, pos)
309
+ else:
310
+ encs = RNA_E if task == "rna" else TXT_E
311
+ _, ze = m.fuse(task, encs, X)
312
+ loss = loss + sum(clip_loss(m.lg(f"{task}:{e}") * za @ ze[e].T, pos) for e in encs) / len(encs)
313
+ u = nz(a["uni2"])
314
+ loss = loss + distill * F.mse_loss(za @ za.T, u @ u.T)
315
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
316
+ if step % EVAL_EVERY == 0 or step == steps:
317
+ sc, v = val_score(m)
318
+ if sc > best:
319
+ best, bv, state = sc, v, {k_: x.detach().clone() for k_, x in m.state_dict().items()}
320
+ m.load_state_dict(state); m.eval()
321
+ return m, bv
322
+
323
+ @torch.no_grad()
324
+ def evaluate(m, an):
325
+ R = lambda st, idx: m.fuse("rna", RNA_E, {e: Xr[e][st][idx] for e in RNA_E})[0]
326
+ Tx = lambda st, idx: m.fuse("text", TXT_E, {e: Xt[e][st][idx] for e in TXT_E})[0]
327
+ zm = nz(m.h("mif")(nif(IF_O[o["test"], 0])))
328
+ zt = Tx("alltxt", tt)
329
+ res = {("H&E<->mIF (ORION)", "trained"): g100(nz(m.he(anc("o", o["test"]))), zm),
330
+ ("H&E<->RNA (HEST)", "trained"): g100(nz(m.he(anc("h", hs["test"]))), R("hest", hs["test"])),
331
+ ("H&E<->text (TCGA)", "trained"): g100(nz(m.he(anc("t", ii))), zt),
332
+ ("mIF<->RNA(virtual) (ORION)", "emergent"): g100(zm, R("orion_v", o["test"])),
333
+ ("mIF<->text(synthetic) (ORION)", "emergent"): g100(zm, Tx("orion_gentext", o["test"])),
334
+ ("text<->RNA(virtual) (TCGA)", "emergent"): g100(zt, R("tcga_v", ii)),
335
+ ("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(zt, nz(m.h("mif")(nif(IF_V_T[ii]))))}
336
+ if m.adapter is None:
337
+ res[("H&E<->RNA (DLPFC)", "external")] = g100(nz(AN[an]["d"]), ZD[an])
338
+ return res
339
+
340
+ for cname, (an, mode, adapter) in CONF.items():
341
+ if (cname, k) in done:
342
+ continue
343
+ if mode is None:
344
+ m = Joint(HEADS[an], LOGITS[an], anchor=an).to(dev).eval() # separate: H&E = the frozen anchor itself
345
+ val = {}
346
+ else:
347
+ m, val = joint(mode, adapter)
348
+ for (pair, kind), r in evaluate(m, an).items():
349
+ rows.append({"config": cname, "fold": k, "pair": pair, "kind": kind, **r,
350
+ **{f"val_{t}": v for t, v in val.items()}})
351
+ pd.DataFrame(rows).to_csv(f_out, index=False)
352
+ print(f"fold {k} {cname:<10} " + " ".join(f"{r['pair'].split(' (')[0]} {r['R@1']:.3f}"
353
+ for r in rows if r["config"] == cname and r["fold"] == k), flush=True)
354
+
355
+ R = pd.DataFrame(rows)
356
+ S = R.groupby(["config", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
357
+ S.to_csv(os.path.join(ROOT, "results", "medvtok_summary.csv"))
358
+ tests = []
359
+ for c, ref in [("EJ-frz", "E"), ("EJ-ad", "E"), ("EJ-ad", "EJ-frz"), ("MV-E", "E"), ("EJ-frz+MV", "EJ-frz"),
360
+ ("EJ-ad+MV", "EJ-ad"), ("EJ-frz+MV", "E"), ("EJ-ad+MV", "E"),
361
+ ("EJ-ad+RP", "EJ-ad"), ("EJ-ad+MV", "EJ-ad+RP")]:
362
+ for p in R.pair.unique():
363
+ a = R[(R.config == c) & (R.pair == p)].sort_values("fold")["R@1"].values
364
+ b = R[(R.config == ref) & (R.pair == p)].sort_values("fold")["R@1"].values
365
+ if len(a) != len(b) or len(a) < 2:
366
+ continue
367
+ tests.append({"config": c, "vs": ref, "pair": p, "ref": b.mean(), "new": a.mean(), "delta": (a - b).mean(),
368
+ "folds_won": int((a > b).sum()), "p_paired_t": stats.ttest_rel(a, b).pvalue})
369
+ pd.DataFrame(tests).round(4).to_csv(os.path.join(ROOT, "results", "medvtok_tests.csv"), index=False)
370
+ pd.set_option("display.width", 250)
371
+ print(R.pivot_table(index="config", columns="pair", values="R@1", sort=False).round(3).T.to_string())
372
+ print(R.pivot_table(index="config", columns="pair", values="pair_acc", sort=False).round(3).T.to_string())
373
+ print(pd.DataFrame(tests).round(4).to_string())
experiments/README_v1_orion.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ImageBind-style 4-modality binding on ORION (H&E / IF / nuclei / markers)
2
+
3
+ Data: `../ORION/ORIONCRC_dataset_tile_20x` (1% subsample), 3187 paired 333×333 tiles, 41 slides / 40 patients.
4
+ - **he**: H&E RGB → *anchor*, frozen ImageBind-Huge vision encoder (official `imagebind_huge.pth`), embeddings precomputed for the 8 dihedral views
5
+ - **if**: 17-ch multiplex IF → ResNet18 (ImageNet init, 17-ch stem) + linear head to 1024
6
+ - **nuclei**: instance mask → 2-ch (mask, boundary) → ResNet18
7
+ - **markers**: 17 `*_prop` + 16 log1p `*_count` columns → MLP
8
+
9
+ Loss: `anchor` = symmetric InfoNCE(he, m) for m ∈ {if, nuclei, markers} (ImageBind; IF↔nuclei, IF↔markers, nuclei↔markers are *emergent*);
10
+ `allpairs` = ablation adding the 3 non-anchor pairs. 40 epochs, AdamW 3e-4, bs 128, learnable temperature, model selection on val mean R@1.
11
+
12
+ Splits (`splits/`): `orion_official` (your csvs; 2954/124/109 tiles, 2 test slides), `random_tile` (tile-level 80/10/10, leaky upper bound),
13
+ `patient_5fold` (patient-grouped 5-fold CV), `batch_lobo` (leave-one-IF-acquisition-batch-out, 4 folds: C59kX/mX/nX/qX).
14
+
15
+ Metrics (test split, all 12 directed pairs): R@1/5/10, MRR, MedR, one-to-one **pairing accuracy** (Hungarian),
16
+ plus `*_g100` = same on random 100-tile galleries (comparable across splits; chance R@1 = 0.01), and a `test_tissue` subset (≥20 nuclei).
17
+
18
+ Pipeline: `01_analyze_data.py` → `02_make_splits.py` → `03_cache_data.py` → `04_extract_anchor.py` → `run_all.sh` (`05_train_eval.py` ×22) → `06_summarize.py`.
19
+ Outputs: `results/` (data_summary.json, figures, summary_by_pair.csv, summary_headline.csv, all_runs_long.csv), `runs/*/metrics.json`, test embeddings.
20
+ Env: `.venv` (scileo + tifffile/timm/imagecodecs).
21
+
22
+ ## H&E encoder ablation (frozen anchor; `--anchor`, `04b_extract_anchor_fm.py`, `run_fm.sh <anchor>`)
23
+ | anchor | weights | dim |
24
+ |---|---|---|
25
+ | imagebind | ImageBind-Huge vision (natural images) | 1024 |
26
+ | uni2 | UNI2-h, local copy `reg2026/model_alldata/uni2-h.bin` (HF repo gated for this account) | 1536 |
27
+ | plip | vinid/plip — open pathology CLIP, stand-in for CONCH | 512 |
28
+ | phikonv2 | owkin/phikon-v2 ViT-L — open stand-in for GigaPath | 1024 |
29
+ | conch / gigapath | code ready; HF repos gated (GatedRepoError) — request access, then `04b_extract_anchor_fm.py conch` + `./run_fm.sh conch` | 512 / 1536 |
30
+
31
+ Results: `results/anchor_ablation_R1_g100.csv`, `results/anchor_ablation_pair_acc.csv`.
experiments/prompts.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "immune": {"label": "CD45_count", "text": "Dense lymphocytic inflammatory infiltrate composed of numerous small round lymphocytes and immune cells."},
3
+ "tumor_epith": {"label": "Pan-CK_count", "text": "Malignant glandular epithelium of colorectal adenocarcinoma with crowded, atypical, hyperchromatic nuclei."},
4
+ "stroma": {"label": "SMA_count", "text": "Fibrous desmoplastic stroma with spindle-shaped fibroblasts and smooth muscle bundles."},
5
+ "proliferation": {"label": "Ki67_count", "text": "Highly proliferative tumor with frequent mitotic figures and high cellular turnover."},
6
+ "vessels": {"label": "CD31_count", "text": "Numerous small blood vessels and capillaries lined by endothelial cells."},
7
+ "macrophage": {"label": "CD68_count", "text": "Sheets of foamy histiocytes and macrophages within the tissue."},
8
+ "cellularity": {"label": "n_nuclei", "text": "Hypercellular tissue densely packed with nuclei."}
9
+ }
experiments/run_all.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Full experiment grid: 4 split schemes (11 folds) x 2 losses. Two workers on one GPU.
3
+ cd "$(dirname "$0")"; mkdir -p logs
4
+ jobs=()
5
+ for loss in anchor allpairs; do
6
+ jobs+=("orion_official 0 $loss" "random_tile 0 $loss")
7
+ for k in 0 1 2 3 4; do jobs+=("patient_5fold $k $loss"); done
8
+ for k in 0 1 2 3; do jobs+=("batch_lobo $k $loss"); done
9
+ done
10
+ printf '%s\n' "${jobs[@]}" | xargs -P 2 -L 1 bash -c '
11
+ [ -f runs/${0}_fold${1}_${2}/metrics.json ] && exit 0
12
+ .venv/bin/python 05_train_eval.py --split $0 --fold $1 --loss $2 > logs/${0}_fold${1}_${2}.log 2>&1 \
13
+ && echo "DONE $0 $1 $2" || echo "FAILED $0 $1 $2"'
14
+ .venv/bin/python 06_summarize.py > logs/summary.log 2>&1; echo ALLDONE
experiments/run_bench.sh ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Encoder benchmark: 3 configs (one encoder per modality each; modalities train independently) x 6 split/folds.
3
+ cd "$(dirname "$0")"; mkdir -p logs/bench
4
+ cfgs=("resnet18 linear plip" "convnext_tiny mlp biomedclip" "chanset ftt imagebind")
5
+ jobs=()
6
+ for c in "${cfgs[@]}"; do
7
+ jobs+=("orion_official 0 $c")
8
+ for k in 0 1 2 3 4; do jobs+=("patient_5fold $k $c"); done
9
+ done
10
+ printf '%s\n' "${jobs[@]}" | xargs -P 2 -L 1 bash -c '
11
+ tag=${0}_fold${1}_uni2_${2}_${3}_${4}
12
+ [ -f runs/bench/$tag/test_embeddings.pt ] && exit 0
13
+ .venv/bin/python 09_bench.py --split $0 --fold $1 --if_enc $2 --mk_enc $3 --txt_enc $4 > logs/bench/$tag.log 2>&1 \
14
+ && echo "DONE $tag" || echo "FAILED $tag"'
15
+ echo ALLDONE