Carpediem123 commited on
Commit
c2ede1f
·
verified ·
1 Parent(s): 82057b2

Migrate release files 1-30

Browse files
14_extract_if_fm.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen spatial-proteomics foundation-model embeddings of every ORION multiplex-IF tile under the 8 dihedral transforms.
2
+ Usage: python 14_extract_if_fm.py virtues [--no_hoechst] [--qc_drop] [--bs 4] [--no_probe]
3
+ Output: cache/if_virtues.npy (N=3187, 8, 512) float16 (raw features; row i = tile_id i, axis 1 = common.dihedral d).
4
+ (--no_hoechst / --qc_drop write cache/if_virtues_nohoechst.npy / if_virtues_qcdrop.npy instead.)
5
+
6
+ VirTues (Wenckstern et al., Nature 2026; github.com/bunnelab/virtues, HF bunnelab/virtues, weights virtues-sp32)
7
+ ---------------------------------------------------------------------------------------------------------------
8
+ Model: MultiplexVirtues with the released base_config (patch 8, dim 512, encoder 'hvhvhvhvhvhvhvhv', RoPE), ESM-2
9
+ marker priors. Only the encoder is used (no masking). Input = list of (C,H,W) standardised crops + marker indices.
10
+
11
+ Tile embedding (pooling): mean over the spatial grid of VirTues *patch summary tokens* (the per-8x8-patch summary
12
+ token that attends across all markers; `encoder.forward_list(...).patch_summary_tokens`, shape (H/8, W/8, 512)).
13
+ VirTues has no global/CLS token; its own tile/tissue-level tooling (spora-bench SporaVirTuesWrapper.embed_tile /
14
+ embed_tissue) returns exactly these patch summary tokens as a bag (then ABMIL), and cell/niche tokens are
15
+ area-weighted averages of them (virtues.utils.cell_tokens). A uniform mean over the whole tile = the niche token of
16
+ the full 166 um tile. Encoding runs in fp16 autocast, like the spora-bench wrapper.
17
+
18
+ Marker -> UniProt (ESM-2 esm2_t30_150M_UR50D, mean over residues of layer 30, computed with fair-esm exactly as
19
+ virtues/utils/compute_esm_embeddings.py; FASTA from rest.uniprot.org; saved as ext/marker_embeddings/esm2_t30_150M_UR50D/[ID].pt):
20
+ Hoechst P68431 (Histone H3.1) -- spora flags DNA stains `is_nuclear_marker` with no UniProt ID, so VirTues'
21
+ `uniprot_filtered` loading DROPS them (spora-bench VirTues config for lin2023highsubset
22
+ = the ORION cohort). spora-io's `replace_nuclear_uniprot_ids=True` instead maps them to
23
+ Histone H3 P68431 (used for lin2023high in spora-bench). Default here: keep Hoechst as
24
+ P68431 (H3 IMC channels are in VirTues' pretraining vocabulary); --no_hoechst drops it.
25
+ CD31 P16284 (PECAM1) | CD45 P08575 (PTPRC canonical, as in spora examples) | CD68 P34810 | CD4 P01730 | FOXP3 Q9BZS1
26
+ CD8a P01732 | CD45RO P08575-4 (UniProt PTPRC isoform 2 = "CD45R0/CD45RO"; spora uses isoform IDs, e.g. CD45RA P08575-8)
27
+ CD20 P11836 (MS4A1) | PD-L1 Q9NZQ7 (CD274) | CD3e P07766 | CD163 Q86VB7 | E-cadherin P12830 (CDH1) | PD-1 Q15116
28
+ Ki67 P46013 | Pan-CK P02533 (KRT14; the keratin spora/VirTues assign to pan-cytokeratin channels, e.g. cords2024cancer
29
+ "Pan Cytokeratin + Keratin Epithelial") | SMA P62736 (ACTA2)
30
+
31
+ Preprocessing (mirrors spora-bench configs/datasets/virtues/lin2023highsubset.yaml:
32
+ standardization quantile_clipping_log1p/uq_0.99_image, resolution 1.0 um/px, filter gaussian_blur, use_mean_std,
33
+ disable_quantile_mask=False):
34
+ 1. resolution: ORION tiles are 512 px crops of the 0.325 um/px Orion scan resized to 333 px (0.4997 um/px, 166.4 um).
35
+ Resampled to 168x168 px (0.991 um/px; 168 = 21 patches of 8) with antialiased bilinear interpolation.
36
+ 2. per-"image" upper quantile: for every slide (spora tissue = one ORION slide) and channel, the 0.99 quantile of all
37
+ resampled pixels of that slide's tiles; x <- log1p(clip(x, 0, uq)).
38
+ 3. dataset-level per-channel mean/std of the clipped log1p values over all ORION tiles; x <- (x - mean) / (std + 1e-8).
39
+ 4. 3x3 Gaussian blur (sigma 1, reflect padding; torchvision v2.GaussianBlur as in spora-io FilterFactory).
40
+ 5. quantile mask: spora drops a (slide, channel) whose upper quantile is 0. On these 8-bit tiles that happens for
41
+ 52/697 (slide, channel) pairs (FOXP3 in 25/41 slides, Ki67 6, CD20/CD8a/PD-L1 5, CD31/CD45RO 3) purely because of
42
+ 8-bit quantisation of sparse markers, and would give slide-dependent channel sets (a slide/batch confound). Default
43
+ here: uq is floored at 1 grey level and all channels are kept for every tile; --qc_drop reproduces spora's drop.
44
+ 6. dihedral d (common.dihedral on the (C,H,W) tensor) applied to the standardised 168 px tile; all 8 encoded.
45
+ The whole 168 px tile is encoded in one pass (21x21 patch grid; RoPE positions so non-128 sizes are supported).
46
+
47
+ Deviations from the VirTues/spora preprocessing (documented):
48
+ * Input intensities are the 8-bit (0-255) ORION tile exports, not the raw 16-bit Orion counts spora stores; because
49
+ log1p is scale-dependent the clipped-log values are compressed differently (z-scoring per channel partly absorbs it).
50
+ * Quantiles / means / stds come from the 3187 ORION tiles (all pixels, no tissue mask), not whole-slide tissue pixels.
51
+ * VirTues was pretrained on 128x128 crops; tiles here are 168x168 (whole tile, no sliding window).
52
+ * uq floored at 1 grey level instead of spora's per-slide channel drop (step 5).
53
+ * PD-1 in these exports is a near-saturated, almost constant channel (log1p mean 4.65, std 0.16; PD-1_prop ~ 1 for
54
+ most tiles); the per-channel z-scoring therefore amplifies its background texture. Kept as-is (faithful pipeline).
55
+ * Hoechst is kept as Histone H3 (P68431) by default; VirTues' own ORION config drops DNA stains (see --no_hoechst).
56
+ * flash_attn is not installed for this torch build: flash_attn_varlen_qkvpacked_func is replaced by an exact
57
+ PyTorch SDPA implementation over the same variable-length sequences (same math, softmax scale 1/sqrt(d)).
58
+ """
59
+ import os
60
+ import sys
61
+ import time
62
+ import types
63
+
64
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # less fragmentation on the shared GPU
65
+ import numpy as np
66
+ import tifffile
67
+ import torch
68
+ import torch.nn.functional as F
69
+
70
+ from common import CACHE, ORION, ROOT, dihedral, load_all_tiles
71
+
72
+ assert sys.argv[1] == "virtues", "usage: python 14_extract_if_fm.py virtues [--no_hoechst] [--qc_drop] [--bs N] [--no_probe]"
73
+ NO_HOECHST = "--no_hoechst" in sys.argv
74
+ QC_DROP = "--qc_drop" in sys.argv # spora behaviour: drop (slide, channel) with 0.99-quantile == 0 (default: floor at 1)
75
+ BS = int(sys.argv[sys.argv.index("--bs") + 1]) if "--bs" in sys.argv else 4 # tiles per forward (x8 dihedrals = 32 crops, ~4.7 GiB peak)
76
+ dev = "cuda"
77
+ VIRTUES_REPO = os.path.join(ROOT, "ext", "virtues")
78
+ EMB_ROOT = os.path.join(ROOT, "ext", "marker_embeddings")
79
+ ESM_NAME = "esm2_t30_150M_UR50D"
80
+ EMB_DIR = os.path.join(EMB_ROOT, ESM_NAME)
81
+ WEIGHTS = ("virtues-sp32/model.safetensors", "virtues-sp31/model.safetensors")
82
+ SIZE, MPP_IN = 168, 0.325 * 512 / 333 # 333 px tile = 512 px @ 0.325 um/px -> 0.4997 um/px; 168 px ~ 0.991 um/px
83
+
84
+ # channel order of the ORION IF tiles (= the *_prop columns of the dataframes)
85
+ MARKERS = [("Hoechst", "P68431"), ("CD31", "P16284"), ("CD45", "P08575"), ("CD68", "P34810"), ("CD4", "P01730"),
86
+ ("FOXP3", "Q9BZS1"), ("CD8a", "P01732"), ("CD45RO", "P08575-4"), ("CD20", "P11836"), ("PD-L1", "Q9NZQ7"),
87
+ ("CD3e", "P07766"), ("CD163", "Q86VB7"), ("E-cadherin", "P12830"), ("PD-1", "Q15116"), ("Ki67", "P46013"),
88
+ ("Pan-CK", "P02533"), ("SMA", "P62736")]
89
+
90
+
91
+ # ----------------------------------------------------------------------------------------------- marker embeddings
92
+ def ensure_marker_embeddings(ids):
93
+ """ESM-2 mean-pooled embeddings, identical to virtues/utils/compute_esm_embeddings.py (fair-esm, layer 30)."""
94
+ import requests
95
+ fasta_dir = os.path.join(EMB_ROOT, "fasta")
96
+ os.makedirs(fasta_dir, exist_ok=True)
97
+ os.makedirs(EMB_DIR, exist_ok=True)
98
+ todo = [u for u in ids if not os.path.exists(os.path.join(EMB_DIR, f"{u}.pt"))]
99
+ if not todo:
100
+ return
101
+ import esm
102
+ _load = torch.load # official fair-esm checkpoints pickle argparse.Namespace -> need weights_only=False
103
+ torch.load = lambda *a, **k: _load(*a, **{**k, "weights_only": False})
104
+ try:
105
+ model, alphabet = esm.pretrained.load_model_and_alphabet(ESM_NAME)
106
+ finally:
107
+ torch.load = _load
108
+ # small one-off job: GPU if the shared card has room, else CPU (the repo recommends CPU for long sequences)
109
+ edev = "cuda" if torch.cuda.is_available() and torch.cuda.mem_get_info()[0] > 4 * 2**30 else "cpu"
110
+ model.eval().to(edev)
111
+ conv = alphabet.get_batch_converter()
112
+ for u in todo:
113
+ fp = os.path.join(fasta_dir, f"{u}.fasta")
114
+ if not os.path.exists(fp):
115
+ r = requests.get(f"https://rest.uniprot.org/uniprotkb/{u}.fasta", timeout=60)
116
+ r.raise_for_status()
117
+ open(fp, "w").write(r.text)
118
+ seq = "".join(l.strip() for l in open(fp) if not l.startswith(">"))
119
+ _, _, tok = conv([(u, seq)])
120
+ n = int((tok != alphabet.padding_idx).sum())
121
+ with torch.no_grad():
122
+ # return_contacts only adds the contact head (repo passes True); representations are identical, and
123
+ # stacking all attention maps for Ki67 (3256 aa) would need 25 GB
124
+ rep = model(tok.to(edev), repr_layers=[30], return_contacts=False)["representations"][30]
125
+ emb = rep[0, 1:n - 1].mean(0).cpu()
126
+ torch.save(emb, os.path.join(EMB_DIR, f"{u}.pt"))
127
+ print(f" ESM-2 {u}: len {len(seq)} -> {tuple(emb.shape)}", flush=True)
128
+ del model
129
+ torch.cuda.empty_cache()
130
+
131
+
132
+ def check_against_repo_embeddings(ids):
133
+ """The repo ships ESM-2 embeddings for its example dataset; compare on shared UniProt IDs."""
134
+ ref = os.path.join(VIRTUES_REPO, "assets", "example_dataset", "marker_embeddings")
135
+ shared = [u for u in ids if os.path.exists(os.path.join(ref, f"{u}.pt"))]
136
+ if shared:
137
+ cs = [F.cosine_similarity(torch.load(os.path.join(EMB_DIR, f"{u}.pt")).float(),
138
+ torch.load(os.path.join(ref, f"{u}.pt")).float(), 0).item() for u in shared]
139
+ mx = max((torch.load(os.path.join(EMB_DIR, f"{u}.pt")) - torch.load(os.path.join(ref, f"{u}.pt"))).abs().max().item()
140
+ for u in shared)
141
+ print(f"marker embeddings vs repo example set ({len(shared)} shared IDs): min cos {min(cs):.6f}, max|diff| {mx:.2e}")
142
+
143
+
144
+ # ----------------------------------------------------------------------------------------------- flash-attn shim
145
+ def install_flash_attn_shim():
146
+ """Exact SDPA replacement for flash_attn_varlen_qkvpacked_func (qkv: (total, 3, H, D), cu_seqlens: (n+1,))."""
147
+ def varlen_qkvpacked(qkv, cu_seqlens, max_seqlen, dropout_p=0.0, softmax_scale=None, causal=False, **_):
148
+ total, _, H, D = qkv.shape
149
+ cu = cu_seqlens.tolist()
150
+ lens = np.diff(cu)
151
+ out = torch.empty(total, H, D, dtype=qkv.dtype, device=qkv.device)
152
+ for L in np.unique(lens):
153
+ if L == 0:
154
+ continue
155
+ starts = np.asarray(cu[:-1])[lens == L]
156
+ if len(starts) == len(lens): # all sequences same length -> contiguous reshape
157
+ q = qkv.view(len(lens), L, 3, H, D)
158
+ idx = None
159
+ else:
160
+ idx = (torch.as_tensor(starts, device=qkv.device)[:, None] + torch.arange(L, device=qkv.device)).reshape(-1)
161
+ q = qkv[idx].view(len(starts), L, 3, H, D)
162
+ q = q.permute(2, 0, 3, 1, 4) # 3, n, H, L, D
163
+ o = F.scaled_dot_product_attention(q[0], q[1], q[2], dropout_p=dropout_p, is_causal=causal,
164
+ scale=softmax_scale) # n, H, L, D
165
+ o = o.transpose(1, 2).reshape(-1, H, D)
166
+ if idx is None:
167
+ out = o.contiguous()
168
+ else:
169
+ out[idx] = o
170
+ return out
171
+
172
+ pkg = types.ModuleType("flash_attn")
173
+ pkg.__path__ = []
174
+ sub = types.ModuleType("flash_attn.flash_attn_interface")
175
+ sub.flash_attn_varlen_qkvpacked_func = varlen_qkvpacked
176
+ pkg.flash_attn_interface = sub
177
+ sys.modules.setdefault("flash_attn", pkg)
178
+ sys.modules.setdefault("flash_attn.flash_attn_interface", sub)
179
+
180
+
181
+ def load_virtues(marker_ids):
182
+ install_flash_attn_shim()
183
+ if VIRTUES_REPO not in sys.path:
184
+ sys.path.insert(0, VIRTUES_REPO)
185
+ from huggingface_hub import hf_hub_download
186
+ from safetensors.torch import load_file
187
+ from virtues.modules.multiplex_virtues import MultiplexVirtues
188
+ from virtues.utils.utils import load_marker_embedding_dict, load_marker_embeddings
189
+
190
+ emb = load_marker_embeddings(EMB_DIR)
191
+ emb_dict = load_marker_embedding_dict(EMB_DIR)
192
+ model = MultiplexVirtues(prior_bias_embeddings=emb) # defaults == configs/base_config.yaml (released model)
193
+ for w in WEIGHTS:
194
+ try:
195
+ sd = load_file(hf_hub_download("bunnelab/virtues", w))
196
+ model.load_state_dict(sd, strict=True)
197
+ print("loaded", w)
198
+ break
199
+ except Exception as e: # fall back to sp31
200
+ print("could not load", w, repr(e)[:200])
201
+ else:
202
+ raise RuntimeError("no VirTues weights could be loaded")
203
+ model.eval().to(dev)
204
+ midx = torch.tensor([emb_dict[u] for u in marker_ids], dtype=torch.long)
205
+ return model, midx
206
+
207
+
208
+ # ----------------------------------------------------------------------------------------------- data
209
+ def load_resampled(df):
210
+ """Raw uint8 tiles -> (N,17,SIZE,SIZE) float16 at ~1 um/px, plus raw per-channel mean intensity (baseline)."""
211
+ N = len(df)
212
+ X = np.empty((N, 17, SIZE, SIZE), np.float16)
213
+ raw_mean = np.empty((N, 17), np.float32)
214
+ paths = [os.path.join(ORION, p) for p in df.target_path]
215
+ chunk = 64
216
+ for s in range(0, N, chunk):
217
+ t = np.stack([tifffile.imread(p) for p in paths[s:s + chunk]]) # n,333,333,17 uint8
218
+ x = torch.from_numpy(t).to(dev).permute(0, 3, 1, 2).float()
219
+ raw_mean[s:s + chunk] = x.mean((2, 3)).cpu().numpy()
220
+ X[s:s + chunk] = F.interpolate(x, size=(SIZE, SIZE), mode="bilinear", antialias=True,
221
+ align_corners=False).clamp(min=0).half().cpu().numpy()
222
+ if s % 640 == 0:
223
+ print(f" read {s}/{N}", flush=True)
224
+ return X, raw_mean
225
+
226
+
227
+ def standardize(X, slide):
228
+ """quantile_clipping_log1p / uq_0.99_image + global mean/std + gaussian blur (in place, float16)."""
229
+ from torchvision.transforms.v2.functional import gaussian_blur
230
+ N, C = X.shape[:2]
231
+ slides = np.unique(slide)
232
+ uq = np.zeros((len(slides), C), np.float32)
233
+ n_zero = np.zeros((len(slides), C), bool)
234
+ s1, s2, cnt = np.zeros(C), np.zeros(C), np.zeros(C)
235
+ for k, sl in enumerate(slides):
236
+ idx = np.where(slide == sl)[0]
237
+ x = torch.from_numpy(X[idx]).to(dev).float().transpose(0, 1).reshape(C, -1) # C, n*H*W
238
+ # torch.quantile is limited to 2**24 elements: very large slides use a fixed random pixel subsample
239
+ qs = x if x.shape[1] <= 2 ** 24 else x[:, torch.randperm(x.shape[1], generator=torch.Generator().manual_seed(0))[: 2 ** 24].to(x.device)]
240
+ uq[k] = np.array([torch.quantile(qs[c], 0.99).item() for c in range(C)])
241
+ n_zero[k] = uq[k] <= 0
242
+ if not QC_DROP: # 8-bit tiles: sparse markers (FOXP3, ...) often have uq == 0 -> floor at one grey level
243
+ uq[k] = np.maximum(uq[k], 1.0)
244
+ u = torch.from_numpy(uq[k]).to(dev)[:, None]
245
+ v = torch.log1p(torch.minimum(x, u))
246
+ ok = (uq[k] > 0)
247
+ s1[ok] += v.sum(1).double().cpu().numpy()[ok]
248
+ s2[ok] += (v.double() ** 2).sum(1).cpu().numpy()[ok]
249
+ cnt[ok] += v.shape[1]
250
+ mean = s1 / cnt
251
+ std = np.sqrt(s2 / cnt - mean ** 2)
252
+ valid = uq > 0 # spora quantile mask: drop (slide, channel) with zero upper quantile
253
+ slide_k = {sl: k for k, sl in enumerate(slides)}
254
+ m_t = torch.tensor(mean, dtype=torch.float32, device=dev).view(1, C, 1, 1)
255
+ s_t = torch.tensor(std, dtype=torch.float32, device=dev).view(1, C, 1, 1)
256
+ for s in range(0, N, 128):
257
+ ks = [slide_k[sl] for sl in slide[s:s + 128]]
258
+ u = torch.from_numpy(uq[ks]).to(dev)[:, :, None, None]
259
+ x = torch.from_numpy(X[s:s + 128]).to(dev).float()
260
+ x = (torch.log1p(torch.minimum(x, u)) - m_t) / (s_t + 1e-8)
261
+ x = gaussian_blur(x, kernel_size=[3, 3], sigma=[1.0, 1.0])
262
+ X[s:s + 128] = x.half().cpu().numpy()
263
+ return X, uq, mean, std, valid, slide_k, n_zero
264
+
265
+
266
+ # ----------------------------------------------------------------------------------------------- sanity checks
267
+ def sanity(out, raw_mean, markers, groups):
268
+ from scipy.stats import spearmanr
269
+ from sklearn.linear_model import RidgeCV
270
+ from sklearn.model_selection import GroupKFold, KFold, cross_val_predict
271
+ from sklearn.pipeline import make_pipeline
272
+ from sklearn.preprocessing import StandardScaler
273
+ o = out.astype(np.float32)
274
+ print("\n=== sanity checks ===")
275
+ print("finite:", bool(np.isfinite(o).all()), "| shape", out.shape, out.dtype)
276
+ z = o[:, 0]
277
+ zn = z / np.linalg.norm(z, axis=1, keepdims=True)
278
+ rng = np.random.default_rng(0)
279
+ s = rng.choice(len(z), min(1000, len(z)), replace=False)
280
+ cos = zn[s] @ zn[s].T
281
+ off = cos[~np.eye(len(s), dtype=bool)]
282
+ zc = z - z.mean(0)
283
+ zcn = zc / np.linalg.norm(zc, axis=1, keepdims=True)
284
+ offc = (zcn[s] @ zcn[s].T)[~np.eye(len(s), dtype=bool)]
285
+ sd = z.std(0)
286
+ ev = np.linalg.eigvalsh(np.cov(z.T))[::-1]
287
+ print(f"mean pairwise cosine (raw) {off.mean():.4f} (min {off.min():.4f}) | after centering {offc.mean():.4f}")
288
+ print(f"per-dim std: min {sd.min():.4f} median {np.median(sd):.4f} max {sd.max():.4f}; "
289
+ f"dims with std<1e-3: {(sd < 1e-3).sum()}; PCA participation ratio {ev.sum() ** 2 / (ev ** 2).sum():.1f}; "
290
+ f"top-10 PCs explain {ev[:10].sum() / ev.sum():.3f}")
291
+ on = o / np.linalg.norm(o, axis=2, keepdims=True)
292
+ same = np.mean([(on[:, 0] * on[:, d]).sum(1).mean() for d in range(1, 8)])
293
+ samec = np.mean([(zcn * ((o[:, d] - z.mean(0)) / np.linalg.norm(o[:, d] - z.mean(0), axis=1, keepdims=True))).sum(1).mean()
294
+ for d in range(1, 8)])
295
+ print(f"dihedral consistency: mean cos(d0, d1..7) same tile {same:.4f} (centered {samec:.4f}) "
296
+ f"vs random tile pairs {off.mean():.4f} (centered {offc.mean():.4f})")
297
+
298
+ Y = markers[:, :17]
299
+ names = [m for m, _ in MARKERS]
300
+
301
+ def probe(Xf, cv, grp=None):
302
+ pipe = make_pipeline(StandardScaler(), RidgeCV(alphas=np.logspace(-2, 5, 15)))
303
+ P = cross_val_predict(pipe, Xf, Y, cv=cv, groups=grp)
304
+ r = np.array([spearmanr(P[:, j], Y[:, j])[0] for j in range(Y.shape[1])])
305
+ return np.nan_to_num(r)
306
+
307
+ feats = {"VirTues (d0)": z, "VirTues (mean 8 dihedral)": o.mean(1), "baseline: IF mean intensity": raw_mean,
308
+ "baseline: log1p IF mean intensity": np.log1p(raw_mean)}
309
+ res = {}
310
+ for cvname, cv, grp in [("5-fold tiles", KFold(5, shuffle=True, random_state=0), None),
311
+ ("5-fold patient-grouped", GroupKFold(5), groups)]:
312
+ for fn, Xf in feats.items():
313
+ r = probe(Xf, cv, grp)
314
+ res[(cvname, fn)] = r
315
+ print(f"ridge probe [{cvname:22s}] {fn:34s} mean Spearman over 17 *_prop = {r.mean():.4f}")
316
+ print("per-marker Spearman (5-fold tiles): VirTues d0 | IF mean intensity")
317
+ a, b = res[("5-fold tiles", "VirTues (d0)")], res[("5-fold tiles", "baseline: IF mean intensity")]
318
+ print(" " + " ".join(f"{n}:{x:.2f}|{y:.2f}" for n, x, y in zip(names, a, b)))
319
+ return res
320
+
321
+
322
+ # ----------------------------------------------------------------------------------------------- main
323
+ if __name__ == "__main__":
324
+ t0 = time.time()
325
+ df = load_all_tiles()
326
+ N = len(df)
327
+ keep_ch = [c for c in range(17) if not (NO_HOECHST and MARKERS[c][0] == "Hoechst")]
328
+ ids = [MARKERS[c][1] for c in range(17)]
329
+ print("marker -> UniProt:", ", ".join(f"{m}={u}" for m, u in MARKERS), "| using channels:", len(keep_ch))
330
+ ensure_marker_embeddings(sorted(set(ids)))
331
+ check_against_repo_embeddings(sorted(set(ids)))
332
+ model, midx_all = load_virtues(ids)
333
+ print(f"[{time.time() - t0:.0f}s] model ready; reading tiles (input {MPP_IN:.4f} um/px -> {SIZE}px = "
334
+ f"{MPP_IN * 333 / SIZE:.3f} um/px)", flush=True)
335
+
336
+ X, raw_mean = load_resampled(df)
337
+ slide = df.in_slide_name.values
338
+ X, uq, mean, std, valid, slide_k, n_zero = standardize(X, slide)
339
+ print(f"[{time.time() - t0:.0f}s] standardized. global log1p mean {np.round(mean, 2).tolist()}\n std {np.round(std, 2).tolist()}")
340
+ print(f" (slide, channel) pairs with 0.99-quantile == 0: {n_zero.sum()} of {n_zero.size} ("
341
+ + ", ".join(f"{MARKERS[c][0]}x{n_zero[:, c].sum()}" for c in range(17) if n_zero[:, c].any())
342
+ + (") -> dropped (spora quantile mask, --qc_drop)" if QC_DROP else ") -> uq floored at 1 grey level, channel kept"))
343
+
344
+ # per-tile channel set (quantile mask x optional Hoechst removal) -> batch tiles with identical channel sets
345
+ tile_valid = valid[[slide_k[s] for s in slide]]
346
+ tile_valid[:, [c for c in range(17) if c not in keep_ch]] = False
347
+ sig = [tuple(np.where(v)[0]) for v in tile_valid]
348
+ groups = {}
349
+ for i, g in enumerate(sig):
350
+ groups.setdefault(g, []).append(i)
351
+
352
+ out = np.zeros((N, 8, 512), np.float16)
353
+ torch.cuda.reset_peak_memory_stats()
354
+ done = 0
355
+ with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
356
+ for g, idxs in groups.items():
357
+ ch = torch.tensor(g, dtype=torch.long)
358
+ midx = midx_all[ch].to(dev)
359
+ for s in range(0, len(idxs), BS):
360
+ b = idxs[s:s + BS]
361
+ x = torch.from_numpy(X[b]).to(dev).float()[:, ch] # B,C,H,W
362
+ xd = [dihedral(x, d) for d in range(8)]
363
+ crops = [xd[d][i] for d in range(8) for i in range(len(b))] # order: d-major
364
+ ps = model.encoder.forward_list(crops, [midx] * len(crops)).patch_summary_tokens # list of (21,21,512)
365
+ e = torch.stack([p.float().mean((0, 1)) for p in ps]).view(8, len(b), -1).transpose(0, 1)
366
+ out[b] = e.cpu().numpy().astype(np.float16)
367
+ done += len(b)
368
+ if (done // BS) % 50 == 0:
369
+ print(f" encoded {done}/{N} [{time.time() - t0:.0f}s] peak GPU "
370
+ f"{torch.cuda.max_memory_allocated() / 2**30:.2f} GiB", flush=True)
371
+ fn = os.path.join(CACHE, "if_virtues" + ("_nohoechst" if NO_HOECHST else "") + ("_qcdrop" if QC_DROP else "") + ".npy")
372
+ np.save(fn, out)
373
+ print(f"saved {fn} {out.shape} {out.dtype} finite: {np.isfinite(out).all()} | channel-set groups: {len(groups)} | "
374
+ f"peak GPU alloc {torch.cuda.max_memory_allocated() / 2**30:.2f} GiB | encode+prep time {time.time() - t0:.0f}s")
375
+
376
+ if "--no_probe" not in sys.argv:
377
+ sanity(out, raw_mean, np.load(os.path.join(CACHE, "markers.npy")), df.patient.values)
378
+ print(f"total {time.time() - t0:.0f}s")
43_rnafm_aidocell.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen AIDO.Cell-100M (a.k.a. GB.Cell-100M) cell embeddings of the RNA sets (each spot = one pseudo-cell).
2
+
3
+ python 43_rnafm_aidocell.py [set ...] (default: all of rna_sets.SETS)
4
+
5
+ Model : HF genbio-ai/AIDO.Cell-100M @ a983b388e1025860f2c413ecef60e76078253c24 (model.safetensors, config.json;
6
+ identical sha256 to genbio-ai/GB.Cell-100M, the renamed repo). Licence "other" = GenBio AI Community
7
+ License: non-commercial use only; outputs (explicitly incl. embeddings) may not be used to train models
8
+ "similar to the GenBio Materials" (other cell foundation models) or for distillation.
9
+ Code reference: github genbio-ai/ModelGenerator @ bda6f2da (cloned to ext/modelgenerator, read, NOT
10
+ installed / imported), huggingface/gb.cell/gb_cell/{models/modeling_cellfoundation.py,
11
+ utils/gene_alignment.py, utils/preprocessing.py}, quickstart.ipynb, embed.py.
12
+ The forward pass of CellFoundationModel is re-implemented below; the bert.* tensors load with strict=True
13
+ (the only other tensors, cls.* = MLM head, are unused):
14
+ gene_embedding : auto-discretisation v -> Linear(1,100) -> LeakyReLU(0.1) -> x + Linear(100,100)(x)
15
+ (bin_alpha=1) -> softmax over 100 bins -> @ lookup_table (100 x 640)
16
+ + absolute position embedding (19266 x 640; position = gene index, 19264 genes + 2 depth tokens)
17
+ 18 x layer : pre-LN; MHA 20 heads x 32, no linear biases, scale 1/sqrt(32), FULL attention over all
18
+ 19,266 tokens (the official flash-attn path ignores the attention mask: "assuming NO
19
+ PADDING"; its SDPA fallback likewise uses attn_mask=None) -> torch SDPA here, no
20
+ flash-attn package needed; SwiGLU MLP 640->1664->640; final LayerNorm.
21
+ Weights and inputs in bf16 as in embed.py (model.to(torch.bfloat16)).
22
+ Input : official gene_alignment.align_adata: data genes matched by HGNC symbol to the fixed 19,264-gene list
23
+ (OS_scRNA_gene_index.19264.tsv, = scFoundation's list), other genes dropped, missing genes = 0 (still
24
+ attended to, as in the official code); duplicate data symbols keep the first column.
25
+ official preprocessing.preprocess_counts on the aligned raw counts: total = sum over the 19,264 aligned genes;
26
+ two depth tokens max(log10(total), 5) appended; X = log1p(count / total * 1e4); clip at 20; bf16.
27
+ (Deviation: an all-zero spot -- 1 each in hest_v / tcga_v after rounding the virtual counts -- gets an
28
+ all-zero gene input instead of the official 0/0 = NaN.)
29
+ Readout : quickstart.ipynb cell-level embedding: last_hidden_state (after final LN) with the 2 depth tokens dropped,
30
+ mean over the gene positions whose gene is present in the data (align_adata attention_mask = 1; i.e. the
31
+ genes the dataset measured, whether or not expressed in that spot). D = 640. Not L2-normalised.
32
+ Output : rna_sets.save("aidocell", set, E) -> cache/rnafm/aidocell_<set>.npy (n_spot, 640) float16.
33
+ Log : logs/rnafm/aidocell.log (stdout of this script, tee'd by the caller).
34
+ """
35
+ import json
36
+ import os
37
+ import sys
38
+ import time
39
+
40
+ import numpy as np
41
+ import pandas as pd
42
+ import scipy.sparse as sp
43
+ import torch
44
+ import torch.nn as nn
45
+ import torch.nn.functional as F
46
+ from huggingface_hub import hf_hub_download
47
+ from safetensors.torch import load_file
48
+
49
+ from common import CACHE, ROOT
50
+ from rna_sets import SETS, load, save
51
+
52
+ MODEL = "aidocell"
53
+ REPO, REV = "genbio-ai/AIDO.Cell-100M", "a983b388e1025860f2c413ecef60e76078253c24"
54
+ GENE_LIST = os.path.join(ROOT, "ext", "modelgenerator", "huggingface", "gb.cell", "gb_cell", "models", "gene_lists",
55
+ "OS_scRNA_gene_index.19264.tsv")
56
+ DEV = "cuda"
57
+
58
+
59
+ # ----------------------------------------------------------------------------- model (re-implementation)
60
+ class Layer(nn.Module):
61
+ def __init__(self, d, h, ff, eps):
62
+ super().__init__()
63
+ self.h = h
64
+ self.attention = nn.Module()
65
+ self.attention.ln = nn.LayerNorm(d, eps=eps)
66
+ self.attention.self = nn.Module()
67
+ for n in ["query", "key", "value"]:
68
+ setattr(self.attention.self, n, nn.Linear(d, d, bias=False))
69
+ self.attention.output = nn.Module()
70
+ self.attention.output.dense = nn.Linear(d, d, bias=False)
71
+ self.ln = nn.LayerNorm(d, eps=eps)
72
+ self.mlp = nn.Module()
73
+ self.mlp.up_proj, self.mlp.gate_proj = nn.Linear(d, ff, bias=False), nn.Linear(d, ff, bias=False)
74
+ self.mlp.down_proj = nn.Linear(ff, d, bias=False)
75
+
76
+ def forward(self, x):
77
+ B, S, D = x.shape
78
+ a, y = self.attention, self.attention.ln(x)
79
+ q, k, v = (getattr(a.self, n)(y).view(B, S, self.h, D // self.h).transpose(1, 2)
80
+ for n in ["query", "key", "value"])
81
+ o = F.scaled_dot_product_attention(q, k, v, scale=(D // self.h) ** -0.5)
82
+ x = x + a.output.dense(o.transpose(1, 2).reshape(B, S, D))
83
+ y = self.ln(x)
84
+ return x + self.mlp.down_proj(F.silu(self.mlp.gate_proj(y)) * self.mlp.up_proj(y))
85
+
86
+
87
+ class AIDOCell(nn.Module):
88
+ def __init__(self, c):
89
+ super().__init__()
90
+ assert c["hidden_act"] == "swiglu" and c["normalization_type"] == "LayerNorm" and not c["add_linear_bias"]
91
+ assert c["position_embedding_type"] == "absolute"
92
+ d, nb = c["hidden_size"], c["bin_num"]
93
+ self.alpha = c["bin_alpha"]
94
+ ge = nn.Module()
95
+ ge.linear1, ge.linear2 = nn.Linear(1, nb), nn.Linear(nb, nb)
96
+ ge.lookup_table, ge.mask_embedding, ge.pad_embedding = nn.Embedding(nb, d), nn.Embedding(1, d), nn.Embedding(1, d)
97
+ self.gene_embedding = ge
98
+ self.position_embedding = nn.Embedding(c["max_position_embeddings"] + 2, d)
99
+ self.encoder = nn.Module()
100
+ self.encoder.layer = nn.ModuleList([Layer(d, c["num_attention_heads"], c["intermediate_size"],
101
+ c["layer_norm_eps"]) for _ in range(c["num_hidden_layers"])])
102
+ self.encoder.ln = nn.LayerNorm(d, eps=c["layer_norm_eps"])
103
+
104
+ def forward(self, X): # X (B, S) preprocessed values >= 0
105
+ ge = self.gene_embedding
106
+ x = F.leaky_relu(ge.linear1(X.unsqueeze(-1)), 0.1)
107
+ x = self.alpha * x + ge.linear2(x)
108
+ x = torch.softmax(x, dim=-1) @ ge.lookup_table.weight
109
+ x = x + self.position_embedding.weight[:X.shape[1]]
110
+ for lyr in self.encoder.layer:
111
+ x = lyr(x)
112
+ return self.encoder.ln(x)
113
+
114
+
115
+ def load_model():
116
+ cfg = json.load(open(hf_hub_download(REPO, "config.json", revision=REV)))
117
+ sd = load_file(hf_hub_download(REPO, "model.safetensors", revision=REV))
118
+ bert = {k[len("bert."):]: v for k, v in sd.items() if k.startswith("bert.")}
119
+ rest = sorted(k for k in sd if not k.startswith("bert."))
120
+ assert all(k.startswith("cls.") for k in rest), rest
121
+ model = AIDOCell(cfg)
122
+ model.load_state_dict(bert, strict=True)
123
+ print(f"loaded {REPO}@{REV[:8]}/model.safetensors: {len(bert)} bert.* tensors strictly "
124
+ f"({sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params); unused MLM head: {rest}", flush=True)
125
+ return model.to(torch.bfloat16).eval().to(DEV), cfg
126
+
127
+
128
+ # ----------------------------------------------------------------------------- input (official alignment / preprocessing)
129
+ MODEL_GENES = pd.read_csv(GENE_LIST, sep="\t")["gene_name"].to_numpy()
130
+ assert len(MODEL_GENES) == 19264 and len(set(MODEL_GENES)) == 19264
131
+
132
+
133
+ def align(X, genes, name):
134
+ pos = {}
135
+ for j, g in enumerate(genes):
136
+ pos.setdefault(g, j)
137
+ mi = np.array([pos.get(g, -1) for g in MODEL_GENES])
138
+ present = mi >= 0
139
+ rows, cols = mi[present], np.nonzero(present)[0]
140
+ P = sp.csr_matrix((np.ones(len(rows), np.float32), (rows, cols)), shape=(len(genes), len(MODEL_GENES)))
141
+ Xa = (X @ P).tocsr()
142
+ n_dup = len(genes) - len(pos)
143
+ print(f"[{name}] data genes {len(genes)} ({n_dup} duplicate symbols); used {present.sum()}; "
144
+ f"coverage of the 19,264 AIDO genes: {present.mean():.1%}; data genes not in AIDO list: "
145
+ f"{len(pos) - present.sum()}", flush=True)
146
+ return Xa, present
147
+
148
+
149
+ def preprocess(counts):
150
+ X = torch.from_numpy(counts).to(device=DEV, dtype=torch.float32)
151
+ tot = X.sum(1, keepdim=True)
152
+ depth = torch.clamp(torch.log10(tot), min=5.0) # log10(0) = -inf -> 5 for an empty spot
153
+ X = torch.log1p(X / tot.clamp(min=1) * 10000) # empty spot: all-zero input instead of 0/0 = NaN
154
+ X = torch.cat([X, depth, depth], dim=1)
155
+ return torch.clamp(X, max=20).to(torch.bfloat16)
156
+
157
+
158
+ @torch.no_grad()
159
+ def embed(model, Xa, present, bs=4):
160
+ G = Xa.shape[1]
161
+ m = torch.from_numpy(present.astype(np.float32)).to(DEV)[None, :, None]
162
+ E = np.zeros((Xa.shape[0], model.encoder.ln.normalized_shape[0]), np.float32)
163
+ t0, k = time.time(), 0
164
+ while k < Xa.shape[0]:
165
+ try:
166
+ h = model(preprocess(Xa[k:k + bs].toarray()))[:, :G].float() # drop the 2 depth tokens
167
+ E[k:k + bs] = ((h * m).sum(1) / m.sum(1)).cpu().numpy()
168
+ k += bs
169
+ if (k // bs) % 250 == 0:
170
+ print(f" {k}/{Xa.shape[0]} {time.time() - t0:.0f}s", flush=True)
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 E
176
+
177
+
178
+ # ----------------------------------------------------------------------------- sanity
179
+ def sanity(E, name, rng):
180
+ out = {"finite": bool(np.isfinite(E).all())}
181
+ s = rng.choice(len(E), min(200, len(E)), replace=False)
182
+ Z = E[s] / np.linalg.norm(E[s], axis=1, keepdims=True)
183
+ C = Z @ Z.T
184
+ out["mean_pairwise_cos200"] = float(C[np.triu_indices(len(s), 1)].mean())
185
+ if name == "hest":
186
+ organ = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv")).organ.values
187
+ assert len(organ) == len(E)
188
+ Zt = torch.from_numpy(E / np.linalg.norm(E, axis=1, keepdims=True)).to(DEV)
189
+ nn_idx = []
190
+ for i in range(0, len(Zt), 2048):
191
+ sim = Zt[i:i + 2048] @ Zt.T
192
+ sim[torch.arange(sim.shape[0]), torch.arange(i, i + sim.shape[0])] = -2
193
+ nn_idx.append(sim.topk(10, dim=1).indices.cpu().numpy())
194
+ nn_idx = np.concatenate(nn_idx)
195
+ out["knn10_same_organ"] = float((organ[nn_idx] == organ[:, None]).mean())
196
+ cnt = pd.Series(organ).value_counts()
197
+ out["organ_base_rate"] = float(((cnt * (cnt - 1)).sum()) / (len(organ) * (len(organ) - 1)))
198
+ return out
199
+
200
+
201
+ def main():
202
+ sets = sys.argv[1:] or SETS
203
+ t0 = time.time()
204
+ model, cfg = load_model()
205
+ report = {}
206
+ for s in sets:
207
+ t1 = time.time()
208
+ X, genes = load(s)
209
+ Xa, present = align(X, genes, s)
210
+ tot = np.asarray(Xa.sum(1)).ravel()
211
+ nexp = np.diff(Xa.indptr)
212
+ print(f"[{s}] aligned UMI/spot median {np.median(tot):.0f} (min {tot.min():.0f}); expressed genes/spot median "
213
+ f"{np.median(nexp):.0f}; spots with total>=1e5 (depth token > 5): {(tot >= 1e5).sum()}", flush=True)
214
+ if (tot == 0).any():
215
+ print(f"[{s}] {(tot == 0).sum()} empty spot(s) (all-zero counts): input = all zeros + depth tokens 5 "
216
+ f"(the official code would give NaN from 0/0)", flush=True)
217
+ E = embed(model, Xa, present)
218
+ save(MODEL, s, E)
219
+ info = dict(n_genes=len(genes), n_aido_genes_present=int(present.sum()), coverage=float(present.mean()),
220
+ median_expr=float(np.median(nexp)))
221
+ info.update(sanity(E, s, np.random.default_rng(0)))
222
+ info["D"], info["seconds"] = int(E.shape[1]), round(time.time() - t1, 1)
223
+ report[s] = info
224
+ print(f"[{s}] {json.dumps(info)}", flush=True)
225
+ print(f"done in {time.time() - t0:.0f}s; peak GPU mem {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
226
+ print(json.dumps(report, indent=1))
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
43_rnafm_se600m.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen Arc Institute STATE embedding (SE-600M) spot embeddings of the 5 RNA sets (each spot = one pseudo-cell).
2
+
3
+ python 43_rnafm_se600m.py [set ...] (default: all of rna_sets.SETS)
4
+
5
+ Model : arcinstitute/SE-600M (HF), revision 5a9a80f44f7ce32ce57059933ef0d735d7c10ce5, files model.safetensors
6
+ (metadata: converted from se600m_epoch16.ckpt, i.e. the final 16-epoch checkpoint), config.yaml and
7
+ protein_embeddings.pt (19,790 gene symbols -> ESM2-15B mean protein embedding, 5120-d; identical, in
8
+ the same order, to the checkpoint's pe_embedding.weight -- asserted). Licence: Arc Research Institute
9
+ State Model Non-Commercial License + Acceptable Use Policy (weights; non-commercial research use only),
10
+ code CC BY-NC-SA 4.0. Official code: github.com/ArcInstitute/state (commit 9bbfe78a, cloned to
11
+ ext/state/state, read but NOT imported / installed): the forward pass of src/state/emb/nn/model.py
12
+ (StateEmbeddingModel) + nn/flash_transformer.py is re-implemented below and loaded with a strict
13
+ state-dict load of all 238 tensors (the encoder / gene_embedding_layer tie is asserted); the unused
14
+ heads (binary_decoder, dataset_encoder, gene_embedding_layer) are dropped after loading.
15
+ 16-layer post-LN transformer, d=2048, 16 heads, FFN 2048, GELU, no positional encoding, no attention mask.
16
+ Inputs : rna_sets.load(set) raw(-like) counts + HGNC symbols. Genes = the set's genes whose symbol is a key of
17
+ protein_embeddings.pt (official FilteredGenesCounts valid mask, set gene order kept); other genes dropped.
18
+ Preprocess: official `state emb transform` path = Inference.encode_adata -> create_dataloader ->
19
+ VCIDatasetSentenceCollator.sample_cell_sentences (is_train=False):
20
+ * counts are raw -> log1p (the official per-cell raw-vs-log1p heuristic, max>35 or sum(expm1)>5e6, is
21
+ bypassed because all inputs are known raw counts; how many spots it would misclassify is logged);
22
+ * expression weight w = log1p / sum(log1p) over the valid genes (uniform if the spot is all-zero);
23
+ * cell sentence (pad_length 2048) = [CLS] + 2047 genes ranked by descending expression with random
24
+ tie-breaking (every set has > 2047 valid genes, so: all detected genes in rank order, top 2047 if more,
25
+ then randomly drawn undetected genes without replacement); seeded numpy RNG (seed 0) instead of
26
+ torch.randperm;
27
+ * token count value = 100 * w[gene]; position 0 carries 100 * w[local gene index 3] exactly as the
28
+ official collator (cls_token_idx = 3 is written as a local gene index before the count lookup);
29
+ * token embedding = L2-normalised ESM2 embedding, position 0 replaced by the learned cls_token, the
30
+ learned dataset_token appended (count embedding 0) -> 2049 tokens.
31
+ Forward : encoder(Linear 5120->2048, LayerNorm, SiLU) * sqrt(2048) + soft-binned count embedding
32
+ (softmax(count_encoder(c)) @ bin_encoder) -> 16 layers -> decoder (SkipBlock + Linear).
33
+ Whole model in bf16 (official get_precision_config('cuda') casts the model to bf16 + bf16 autocast).
34
+ Readout : official embedding = L2-normalised decoder output at the CLS position (StateEmbeddingModel.forward
35
+ `embedding`), D = 2048. `state emb transform` additionally concatenates a 10-d dataset-token vector
36
+ (dataset_embedder(decoder output at the dataset token)) -> 2058-d X_state; that batch-identity part is NOT
37
+ saved here (only the 2048-d cell CLS embedding).
38
+ Output : rna_sets.save("se600m", set, E) -> cache/rnafm/se600m_<set>.npy (n_spot, 2048) float16.
39
+ Sanity : finite, mean pairwise cosine of 200 random spots, (hest) organ agreement of cosine 10-NN vs base rate,
40
+ and (hest, 300 spots) cosine between two runs with different tie-breaking seeds (stochasticity of the
41
+ undetected-gene filler tokens).
42
+ """
43
+ import math
44
+ import os
45
+ import sys
46
+ import time
47
+
48
+ import numpy as np
49
+ import pandas as pd
50
+ import torch
51
+ import torch.nn as nn
52
+ import torch.nn.functional as F
53
+ import yaml
54
+ from huggingface_hub import hf_hub_download
55
+ from safetensors.torch import load_file
56
+
57
+ from common import CACHE
58
+ from rna_sets import SETS, load, save
59
+
60
+ MODEL = "se600m"
61
+ REPO, REV = "arcinstitute/SE-600M", "5a9a80f44f7ce32ce57059933ef0d735d7c10ce5"
62
+ dev = "cuda"
63
+ DT = torch.bfloat16
64
+
65
+
66
+ # ----------------------------------------------------------------------------- model (re-implemented from ext/state)
67
+ class SkipBlock(nn.Module):
68
+ def __init__(self, d):
69
+ super().__init__()
70
+ self.intermediate_dense = nn.Linear(d, 2 * d)
71
+ self.dense = nn.Linear(2 * d, d)
72
+ self.layer_norm = nn.LayerNorm(d)
73
+
74
+ def forward(self, x):
75
+ return self.layer_norm(x + self.dense(F.relu(self.intermediate_dense(x))))
76
+
77
+
78
+ class Layer(nn.Module): # FlashTransformerEncoderLayer (post-LN, dropout 0 at inference)
79
+ def __init__(self, d, nhead, d_hid):
80
+ super().__init__()
81
+ self.nhead = nhead
82
+ self.qkv_proj = nn.Linear(d, 3 * d)
83
+ self.out_proj = nn.Linear(d, d)
84
+ self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
85
+ self.linear1, self.linear2 = nn.Linear(d, d_hid), nn.Linear(d_hid, d)
86
+
87
+ def forward(self, x):
88
+ B, T, D = x.shape
89
+ q, k, v = self.qkv_proj(x).chunk(3, dim=-1)
90
+ q, k, v = (t.view(B, T, self.nhead, D // self.nhead).transpose(1, 2) for t in (q, k, v))
91
+ a = F.scaled_dot_product_attention(q, k, v, is_causal=False).transpose(1, 2).reshape(B, T, D)
92
+ x = self.norm1(x + self.out_proj(a))
93
+ return self.norm2(x + self.linear2(F.gelu(self.linear1(x))))
94
+
95
+
96
+ class TransformerEncoder(nn.Module):
97
+ def __init__(self, layers):
98
+ super().__init__()
99
+ self.layers = nn.ModuleList(layers)
100
+
101
+
102
+ class SE(nn.Module):
103
+ def __init__(self, token_dim, d, nhead, d_hid, nlayers, out_dim, n_pe, z_dim, n_ds):
104
+ super().__init__()
105
+ self.d = d
106
+ self.cls_token = nn.Parameter(torch.zeros(1, token_dim))
107
+ self.dataset_token = nn.Parameter(torch.zeros(1, token_dim))
108
+ self.encoder = nn.Sequential(nn.Linear(token_dim, d), nn.LayerNorm(d), nn.SiLU())
109
+ self.gene_embedding_layer = self.encoder # tied, as in the official model
110
+ self.transformer_encoder = TransformerEncoder([Layer(d, nhead, d_hid) for _ in range(nlayers)])
111
+ self.decoder = nn.Sequential(SkipBlock(d), nn.Linear(d, out_dim))
112
+ self.binary_decoder = nn.Sequential(SkipBlock(out_dim + d + z_dim), SkipBlock(out_dim + d + z_dim),
113
+ nn.Linear(out_dim + d + z_dim, 1))
114
+ self.bin_encoder = nn.Embedding(10, d)
115
+ self.count_encoder = nn.Sequential(nn.Linear(1, 512), nn.LeakyReLU(), nn.Linear(512, 10))
116
+ self.dataset_embedder = nn.Linear(out_dim, 10)
117
+ self.dataset_encoder = nn.Sequential(nn.Linear(out_dim, d), nn.SiLU(), nn.LayerNorm(d), nn.Dropout(0.1),
118
+ nn.Linear(d, n_ds))
119
+ self.pe_embedding = nn.Embedding(n_pe, token_dim)
120
+
121
+ def forward(self, tok, cnt):
122
+ """tok: (B, L) global gene ids (position 0 = placeholder for CLS); cnt: (B, L) count values."""
123
+ x = F.normalize(self.pe_embedding(tok), dim=2)
124
+ x[:, 0, :] = self.cls_token.expand(x.size(0), -1)
125
+ x = torch.cat([x, self.dataset_token.expand(x.size(0), -1).unsqueeze(1)], 1)
126
+ x = self.encoder(x) * math.sqrt(self.d)
127
+ w = F.softmax(self.count_encoder(cnt.unsqueeze(-1)), dim=-1)
128
+ ce = w @ self.bin_encoder.weight
129
+ x = x + torch.cat([ce, torch.zeros_like(ce[:, :1])], 1)
130
+ for lyr in self.transformer_encoder.layers:
131
+ x = lyr(x)
132
+ return F.normalize(self.decoder(x)[:, 0, :], dim=1)
133
+
134
+
135
+ def build():
136
+ cfg = yaml.safe_load(open(hf_hub_download(REPO, "config.yaml", revision=REV)))
137
+ m, ds = cfg["model"], cfg["dataset"]
138
+ assert m["counts"] and m["rda"] and m["dataset_correction"] and ds["pad_length"] == 2048
139
+ assert ds["cls_token_idx"] == 3 and cfg["loss"]["name"] == "tabular"
140
+ pe = torch.load(hf_hub_download(REPO, "protein_embeddings.pt", revision=REV), map_location="cpu",
141
+ weights_only=True)
142
+ sd = load_file(hf_hub_download(REPO, "model.safetensors", revision=REV))
143
+ n_ds = cfg["dataset"][cfg["dataset"]["current"]]["num_datasets"]
144
+ model = SE(cfg["tokenizer"]["token_dim"], m["emsize"], m["nhead"], m["d_hid"], m["nlayers"], m["output_dim"],
145
+ len(pe), 1 + 10, n_ds)
146
+ for k in ["0.weight", "0.bias", "1.weight", "1.bias"]:
147
+ assert torch.equal(sd[f"encoder.{k}"], sd[f"gene_embedding_layer.{k}"])
148
+ model.load_state_dict(sd, strict=True)
149
+ assert torch.equal(model.pe_embedding.weight, torch.vstack(list(pe.values()))) # CLI's protein_embeddings.pt
150
+ del model.binary_decoder, model.dataset_encoder, model.gene_embedding_layer
151
+ model = model.eval().to(dev, DT)
152
+ print(f"[{MODEL}] loaded {len(sd)} tensors strictly; config pad_length={ds['pad_length']} "
153
+ f"{m['nlayers']}x{m['emsize']} heads={m['nhead']}", flush=True)
154
+ return model, cfg, {g: i for i, g in enumerate(pe)}
155
+
156
+
157
+ # ----------------------------------------------------------------------------- tokenisation (official collator)
158
+ def sentences(X, genes, g2i, pad_len, cls_idx, seed):
159
+ valid = np.array([g in g2i for g in genes])
160
+ Xv = X[:, valid].tocsr()
161
+ gid = np.array([g2i[g] for g in genes[valid]], dtype=np.int64)
162
+ n = Xv.shape[1]
163
+ assert n >= pad_len - 1 # -> official "top pad_len-1 genes" branch
164
+ rng = np.random.default_rng(seed)
165
+ T = np.zeros((Xv.shape[0], pad_len), np.int64)
166
+ C = np.zeros((Xv.shape[0], pad_len), np.float32)
167
+ with np.errstate(over="ignore"): # official is_raw_integer_counts heuristic
168
+ n_heur = int(sum((r.data.max(initial=0) <= 35) and (np.expm1(r.data.astype(np.float64)).sum() <= 5e6)
169
+ for r in (X.getrow(i) for i in range(X.shape[0])))) # (on the full row, as official)
170
+ n_trunc = 0
171
+ for r in range(Xv.shape[0]):
172
+ a, b = Xv.indptr[r], Xv.indptr[r + 1]
173
+ nz, raw = Xv.indices[a:b], Xv.data[a:b].astype(np.float64)
174
+ lv = np.log1p(raw)
175
+ s = lv.sum()
176
+ w = np.zeros(n)
177
+ if s > 0:
178
+ w[nz] = lv / s
179
+ else:
180
+ w[:] = 1.0 / n # softmax of an all-zero row
181
+ p = rng.permutation(len(nz)) # random tie-break among expressed genes
182
+ order = nz[p][np.argsort(-lv[p], kind="stable")][:pad_len - 1]
183
+ n_trunc += len(nz) > pad_len - 1
184
+ k = pad_len - 1 - len(order)
185
+ if k > 0: # random undetected genes, no replacement
186
+ zero = np.setdiff1d(np.arange(n), nz, assume_unique=True)
187
+ order = np.r_[order, rng.choice(zero, k, replace=False)]
188
+ loc = np.r_[cls_idx, order]
189
+ T[r] = gid[loc]
190
+ C[r] = 100.0 * w[loc]
191
+ stats = dict(valid=int(valid.sum()), n_gene=len(genes), heur_log=n_heur, trunc=n_trunc,
192
+ det_med=float(np.median(np.diff(Xv.indptr))))
193
+ return T, C, stats
194
+
195
+
196
+ def embed(model, T, C, bs=16):
197
+ E = np.zeros((len(T), model.decoder[1].out_features), np.float32)
198
+ i = 0
199
+ with torch.no_grad(), torch.autocast("cuda", dtype=DT):
200
+ while i < len(T):
201
+ try:
202
+ t = torch.from_numpy(T[i:i + bs]).to(dev)
203
+ c = torch.from_numpy(C[i:i + bs]).to(dev, DT)
204
+ E[i:i + bs] = model(t, c).float().cpu().numpy()
205
+ i += bs
206
+ except torch.OutOfMemoryError:
207
+ torch.cuda.empty_cache()
208
+ assert bs > 1, "OOM at batch size 1"
209
+ bs //= 2
210
+ print(f" OOM -> batch size {bs}", flush=True)
211
+ return E
212
+
213
+
214
+ def sanity(E, name):
215
+ rng = np.random.default_rng(0)
216
+ S = E[rng.choice(len(E), min(200, len(E)), replace=False)]
217
+ S = S / np.linalg.norm(S, axis=1, keepdims=True)
218
+ mc = float((S @ S.T)[np.triu_indices(len(S), 1)].mean())
219
+ msg = f"[{MODEL}] {name}: finite={np.isfinite(E).all()} std={E.std(0).mean():.4f} mean-pair-cos(200)={mc:.4f}"
220
+ if name == "hest":
221
+ org = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv")).organ.values
222
+ assert len(org) == len(E)
223
+ Z = torch.from_numpy(E / np.linalg.norm(E, axis=1, keepdims=True)).to(dev)
224
+ nn_idx = []
225
+ for k in range(0, len(Z), 2048):
226
+ s = Z[k:k + 2048] @ Z.T
227
+ s[torch.arange(s.shape[0]), torch.arange(k, k + s.shape[0])] = -2
228
+ nn_idx.append(s.topk(10, dim=1).indices.cpu().numpy())
229
+ nn_idx = np.concatenate(nn_idx)
230
+ knn = float((org[nn_idx] == org[:, None]).mean())
231
+ p = pd.Series(org).value_counts(normalize=True).values
232
+ msg += f" 10-NN same-organ={knn:.3f} (base rate {float((p ** 2).sum()):.3f})"
233
+ print(msg, flush=True)
234
+ return mc
235
+
236
+
237
+ if __name__ == "__main__":
238
+ torch.cuda.set_per_process_memory_fraction(min(1.0, 8 * 2 ** 30 / torch.cuda.get_device_properties(0).total_memory))
239
+ t0 = time.time()
240
+ model, cfg, g2i = build()
241
+ PAD, CLS = cfg["dataset"]["pad_length"], cfg["dataset"]["cls_token_idx"]
242
+ for name in (sys.argv[1:] or SETS):
243
+ t1 = time.time()
244
+ X, genes = load(name)
245
+ T, C, st = sentences(X, genes, g2i, PAD, CLS, seed=0)
246
+ print(f"[{MODEL}] {name}: {X.shape[0]} spots; gene coverage {st['valid']}/{st['n_gene']} "
247
+ f"({st['valid'] / st['n_gene']:.1%}) in protein_embeddings; detected valid genes/spot median "
248
+ f"{st['det_med']:.0f}; spots with >2047 detected (truncated) {st['trunc']}; spots the official "
249
+ f"raw-vs-log1p heuristic would treat as log1p {st['heur_log']}", flush=True)
250
+ E = embed(model, T, C)
251
+ if name == "hest": # tie-breaking stochasticity check
252
+ idx = np.random.default_rng(1).choice(len(T), 300, replace=False)
253
+ T2, C2, _ = sentences(X[idx], genes, g2i, PAD, CLS, seed=1)
254
+ c = (embed(model, T2, C2) * E[idx]).sum(1)
255
+ print(f" seed-0 vs seed-1 tie-breaking: per-spot cosine median {np.median(c):.4f} min {c.min():.4f}",
256
+ flush=True)
257
+ sanity(E, name)
258
+ save(MODEL, name, E)
259
+ print(f" {name}: {time.time() - t1:.0f}s peak GPU {torch.cuda.max_memory_allocated() / 2 ** 30:.2f} GB",
260
+ flush=True)
261
+ print(f"[{MODEL}] done in {time.time() - t0:.0f}s", flush=True)
43_rnafm_tahoex1.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen Tahoe-x1 70M cell embeddings of the RNA sets (each Visium / virtual spot = one pseudo-cell).
2
+
3
+ python 43_rnafm_tahoex1.py [set ...] (default: all of rna_sets.SETS)
4
+
5
+ Model : HF tahoebio/Tahoe-x1 @ d218a580b9c2500ae9dfc8367a398545e6f017a8, subfolder 70m-model/
6
+ (model.safetensors, model_config.yml, collator_config.yml, vocab.json); licence apache-2.0.
7
+ Code reference: github tahoebio/tahoe-x1 @ 167cc19f (cloned to ext/tahoe-x1, read, NOT installed / imported;
8
+ it needs llm-foundry + composer). The forward pass of tahoe_x1.model.TXModel is re-implemented below and
9
+ the safetensors state dict is loaded with strict=True:
10
+ gene_encoder : Embedding(62720, 512, pad=0) -> LayerNorm
11
+ expr encoder : ContinuousValueEncoder clamp(max=512) -> Linear(1,512) -> ReLU -> Linear -> LayerNorm
12
+ 12 x TXBlock : pre-norm; llm-foundry GroupedQueryAttention with kv_n_heads = n_heads = 8 (= MHA),
13
+ fused Wqkv split [q | k | v], softmax scale 1/sqrt(64), non-causal, padding keys
14
+ excluded (flash-attn unpadding == SDPA with a key-padding mask here);
15
+ FFN Linear(512,2048) -> ReLU -> Linear(2048,512); final LayerNorm.
16
+ (llm-foundry source read from ext/llmfoundry_src/repo @ 0cdb2f42 to confirm the Wqkv split / scale.)
17
+ The flag (gen-mask) embedding is 0 at inference (no MLM masking); decoders are unused.
18
+ Input : gene symbols -> Ensembl (10x GRCh38 map in cache/external/dlpfc_genes_full.csv, fallback Geneformer
19
+ gene_name_id_dict_gc104M; DLPFC uses its own Ensembl column) -> Tahoe vocab id; duplicate Ensembl ids keep
20
+ the first column. Exactly the official inference collator (tahoe_x1.utils.loader_from_adata +
21
+ data.collator.DataCollator, do_mlm=False): per spot the expressed (count>0) in-vocab genes, <cls> prepended
22
+ with value pad_value=-2; raw counts -> per-cell quantile binning over the expressed genes
23
+ (num_bins=51, torch.quantile edges, bucketize right=False, +1 -> bins 1..50; no normalisation /
24
+ log, log_transform=False); then if longer than max_length a random subset is sampled (sampling=True,
25
+ <cls> kept, seed 0). max_length = 2048 = the documented inference setting (predict.yaml /
26
+ clustering_tutorial `seq_len_dataset: 2048`); collator_config.yml's max_length=1024 is the training length.
27
+ Pad token 0 / value -2.
28
+ Readout : model_config cell_emb_style = "cls": final-layer (after final LayerNorm) <cls> output, L2-normalised
29
+ (as in tahoe_x1.tasks.get_batch_embeddings / predict_embeddings). bf16 autocast (precision amp_bf16).
30
+ Output : rna_sets.save("tahoex1", set, E) -> cache/rnafm/tahoex1_<set>.npy (n_spot, 512) float16.
31
+ Log : logs/rnafm/tahoex1.log (stdout of this script, tee'd by the caller).
32
+ """
33
+ import json
34
+ import os
35
+ import pickle
36
+ import sys
37
+ import time
38
+
39
+ import numpy as np
40
+ import pandas as pd
41
+ import torch
42
+ import torch.nn as nn
43
+ import torch.nn.functional as F
44
+ import yaml
45
+ from huggingface_hub import hf_hub_download
46
+ from safetensors.torch import load_file
47
+
48
+ from common import CACHE
49
+ from rna_sets import SETS, load, save
50
+
51
+ MODEL = "tahoex1"
52
+ REPO, REV = "tahoebio/Tahoe-x1", "d218a580b9c2500ae9dfc8367a398545e6f017a8"
53
+ SUB = "70m-model"
54
+ MAX_LEN = 2048
55
+ DEV = "cuda"
56
+
57
+
58
+ # ----------------------------------------------------------------------------- model (re-implementation)
59
+ class Block(nn.Module):
60
+ def __init__(self, d, h, ff, eps):
61
+ super().__init__()
62
+ self.h = h
63
+ self.self_attn = nn.Module()
64
+ self.self_attn.Wqkv = nn.Linear(d, 3 * d)
65
+ self.self_attn.out_proj = nn.Linear(d, d)
66
+ self.up_proj, self.down_proj = nn.Linear(d, ff), nn.Linear(ff, d)
67
+ self.norm1, self.norm2 = nn.LayerNorm(d, eps=eps), nn.LayerNorm(d, eps=eps)
68
+
69
+ def forward(self, x, kmask): # kmask (B,1,1,S) bool, True = attend
70
+ B, S, D = x.shape
71
+ q, k, v = self.self_attn.Wqkv(self.norm1(x)).split(D, dim=2)
72
+ q, k, v = (t.view(B, S, self.h, D // self.h).transpose(1, 2) for t in (q, k, v))
73
+ a = F.scaled_dot_product_attention(q, k, v, attn_mask=kmask, scale=(D // self.h) ** -0.5)
74
+ x = x + self.self_attn.out_proj(a.transpose(1, 2).reshape(B, S, D))
75
+ return x + self.down_proj(F.relu(self.up_proj(self.norm2(x))))
76
+
77
+
78
+ class TahoeX1(nn.Module):
79
+ def __init__(self, cfg):
80
+ super().__init__()
81
+ d, eps = cfg["d_model"], cfg["norm_config"]["eps"]
82
+ assert cfg["norm_scheme"] == "pre" and not cfg["use_glu"] and cfg["transformer_activation"] == "relu"
83
+ assert cfg["attn_config"]["kv_nheads"] == cfg["n_heads"] and cfg["norm_config"]["norm_type"] == "layernorm"
84
+ assert cfg["expression_encoder"]["input_emb_style"] == "continuous" and cfg["cell_emb_style"] == "cls"
85
+ self.max_value = cfg["expression_encoder"]["max_value"]
86
+ ge = nn.Module()
87
+ ge.embedding, ge.enc_norm = nn.Embedding(cfg["vocab_size"], d, padding_idx=0), nn.LayerNorm(d)
88
+ self.gene_encoder = ge
89
+ self.flag_encoder = nn.Embedding(2, d)
90
+ ee = nn.Module()
91
+ ee.linear1, ee.linear2, ee.norm = nn.Linear(1, d), nn.Linear(d, d), nn.LayerNorm(d)
92
+ self.expression_encoder = ee
93
+ te = nn.Module()
94
+ te.layers = nn.ModuleList([Block(d, cfg["n_heads"], d * cfg["expansion_ratio"], eps)
95
+ for _ in range(cfg["n_layers"])])
96
+ te.norm = nn.LayerNorm(d, eps=eps)
97
+ self.transformer_encoder = te
98
+ # unused heads, present only so that the checkpoint loads strictly
99
+ dec = nn.Module()
100
+ dec.linear_layers = nn.ModuleList([nn.Linear(d, d) for _ in range(cfg["expression_decoder"]["n_layers"])])
101
+ dec.out_proj = nn.Linear(d, cfg["expression_decoder"]["n_outputs"])
102
+ self.expression_decoder = dec
103
+ mvc = nn.Module()
104
+ mvc.gene2query, mvc.W = nn.Linear(d, d), nn.Linear(d, d, bias=False)
105
+ self.mvc_decoder = mvc
106
+
107
+ def forward(self, genes, values, pad_mask): # pad_mask True = real token
108
+ ee = self.expression_encoder
109
+ x = self.gene_encoder.enc_norm(self.gene_encoder.embedding(genes))
110
+ v = torch.clamp(values.unsqueeze(-1), max=self.max_value)
111
+ x = x + ee.norm(ee.linear2(F.relu(ee.linear1(v)))) # + flag term = 0 (no gen/masked tokens)
112
+ km = pad_mask[:, None, None, :]
113
+ for blk in self.transformer_encoder.layers:
114
+ x = blk(x, km)
115
+ return self.transformer_encoder.norm(x)
116
+
117
+
118
+ def load_model():
119
+ f = {n: hf_hub_download(REPO, f"{SUB}/{n}", revision=REV)
120
+ for n in ["model.safetensors", "model_config.yml", "collator_config.yml", "vocab.json"]}
121
+ mcfg = yaml.safe_load(open(f["model_config.yml"]))
122
+ ccfg = yaml.safe_load(open(f["collator_config.yml"]))
123
+ vocab = json.load(open(f["vocab.json"]))
124
+ sd = {k[len("model."):]: v for k, v in load_file(f["model.safetensors"]).items()}
125
+ model = TahoeX1(mcfg)
126
+ model.load_state_dict(sd, strict=True)
127
+ print(f"loaded {REPO}@{REV[:8]}/{SUB}/model.safetensors strictly: {len(sd)} tensors, "
128
+ f"{sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params", flush=True)
129
+ return model.eval().to(DEV), mcfg, ccfg, vocab
130
+
131
+
132
+ # ----------------------------------------------------------------------------- collator (official logic)
133
+ def binning(row, n_bins):
134
+ """tahoe_x1.data.collator.binning for a strictly positive row, right=False."""
135
+ grades = torch.linspace(0, 1, n_bins - 1, dtype=torch.float32)
136
+ bins = torch.quantile(row, grades)
137
+ return torch.bucketize(row, bins, right=False) + 1
138
+
139
+
140
+ def symbol_to_ensembl(genes, name):
141
+ if name == "dlpfc":
142
+ d = pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv"))
143
+ assert (d.gene.values == genes).all()
144
+ return d.ensembl_id.values.astype(str)
145
+ d = pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv"))
146
+ m10 = dict(zip(d.gene, d.ensembl_id))
147
+ from huggingface_hub import hf_hub_download
148
+ gf = pickle.load(open(hf_hub_download("ctheodoris/Geneformer", "geneformer/gene_name_id_dict_gc104M.pkl",
149
+ revision="1f7fbae4e469a5f4f1af8c111a529cfe1b3829f5"), "rb"))
150
+ return np.array([m10.get(g) or gf.get(g) or "" for g in genes])
151
+
152
+
153
+ def make_seqs(X, genes, name, vocab, ccfg, rng):
154
+ ens = symbol_to_ensembl(genes, name)
155
+ tid = np.array([vocab.get(e, -1) for e in ens])
156
+ keep = tid >= 0
157
+ first = np.zeros(len(tid), bool)
158
+ _, fi = np.unique(np.where(keep, tid, -1), return_index=True)
159
+ first[fi] = True
160
+ keep &= first
161
+ cols = np.nonzero(keep)[0]
162
+ Xs = X[:, cols].tocsr()
163
+ gid = tid[cols]
164
+ print(f"[{name}] genes {len(genes)}; Ensembl-mapped {int((ens != '').sum())}; in Tahoe vocab "
165
+ f"(unique) {len(cols)} ({len(cols) / len(genes):.1%})", flush=True)
166
+ CLS, PADV, NB = vocab["<cls>"], float(ccfg["pad_value"]), int(ccfg["num_bins"])
167
+ seqs, nexp, nsamp = [], [], 0
168
+ for r in range(Xs.shape[0]):
169
+ lo, hi = Xs.indptr[r], Xs.indptr[r + 1]
170
+ idx, val = Xs.indices[lo:hi], Xs.data[lo:hi]
171
+ pos = val > 0
172
+ idx, val = idx[pos], val[pos]
173
+ nexp.append(len(idx))
174
+ b = binning(torch.from_numpy(val.astype(np.float32)), NB).numpy().astype(np.float32) if len(idx) else \
175
+ np.zeros(0, np.float32)
176
+ g = np.r_[CLS, gid[idx]].astype(np.int64)
177
+ v = np.r_[PADV, b].astype(np.float32)
178
+ if len(g) > MAX_LEN: # _sample: keep <cls>, random subset of the rest
179
+ sel = np.r_[0, rng.permutation(len(g) - 1)[:MAX_LEN - 1] + 1]
180
+ g, v = g[sel], v[sel]
181
+ nsamp += 1
182
+ seqs.append((g, v))
183
+ nexp = np.array(nexp)
184
+ print(f"[{name}] expressed in-vocab genes/spot median {np.median(nexp):.0f} (min {nexp.min()}, max {nexp.max()}); "
185
+ f"spots subsampled to {MAX_LEN}: {nsamp}/{len(seqs)}; empty spots: {(nexp == 0).sum()}", flush=True)
186
+ return seqs, dict(n_genes=len(genes), n_in_vocab=int(len(cols)), median_expr=float(np.median(nexp)))
187
+
188
+
189
+ @torch.no_grad()
190
+ def embed(model, seqs, bs=32):
191
+ order = np.argsort([len(g) for g, _ in seqs])
192
+ E = np.zeros((len(seqs), model.gene_encoder.embedding.embedding_dim), np.float32)
193
+ k = 0
194
+ while k < len(order):
195
+ idx = order[k:k + bs]
196
+ try:
197
+ b = [seqs[i] for i in idx]
198
+ L = max(len(g) for g, _ in b)
199
+ G = torch.zeros((len(b), L), dtype=torch.long)
200
+ V = torch.full((len(b), L), -2.0)
201
+ for j, (g, v) in enumerate(b):
202
+ G[j, :len(g)], V[j, :len(v)] = torch.from_numpy(g), torch.from_numpy(v)
203
+ G, V = G.to(DEV), V.to(DEV)
204
+ with torch.autocast("cuda", dtype=torch.bfloat16):
205
+ h = model(G, V, G.ne(0))
206
+ E[idx] = h[:, 0].float().cpu().numpy()
207
+ k += len(idx)
208
+ except torch.OutOfMemoryError:
209
+ torch.cuda.empty_cache()
210
+ bs = max(1, bs // 2)
211
+ print(f" OOM -> batch {bs}", flush=True)
212
+ return E / np.linalg.norm(E, axis=1, keepdims=True)
213
+
214
+
215
+ # ----------------------------------------------------------------------------- sanity
216
+ def sanity(E, name, rng):
217
+ out = {"finite": bool(np.isfinite(E).all())}
218
+ s = rng.choice(len(E), min(200, len(E)), replace=False)
219
+ Z = E[s] / np.linalg.norm(E[s], axis=1, keepdims=True)
220
+ C = Z @ Z.T
221
+ out["mean_pairwise_cos200"] = float(C[np.triu_indices(len(s), 1)].mean())
222
+ if name == "hest":
223
+ organ = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv")).organ.values
224
+ assert len(organ) == len(E)
225
+ Zt = torch.from_numpy(E / np.linalg.norm(E, axis=1, keepdims=True)).to(DEV)
226
+ nn_idx = []
227
+ for i in range(0, len(Zt), 2048):
228
+ sim = Zt[i:i + 2048] @ Zt.T
229
+ sim[torch.arange(sim.shape[0]), torch.arange(i, i + sim.shape[0])] = -2
230
+ nn_idx.append(sim.topk(10, dim=1).indices.cpu().numpy())
231
+ nn_idx = np.concatenate(nn_idx)
232
+ out["knn10_same_organ"] = float((organ[nn_idx] == organ[:, None]).mean())
233
+ cnt = pd.Series(organ).value_counts()
234
+ out["organ_base_rate"] = float(((cnt * (cnt - 1)).sum()) / (len(organ) * (len(organ) - 1)))
235
+ return out
236
+
237
+
238
+ def main():
239
+ sets = sys.argv[1:] or SETS
240
+ torch.manual_seed(0)
241
+ t0 = time.time()
242
+ model, mcfg, ccfg, vocab = load_model()
243
+ print(f"collator_config: {ccfg}", flush=True)
244
+ report = {}
245
+ for s in sets:
246
+ t1 = time.time()
247
+ X, genes = load(s)
248
+ seqs, info = make_seqs(X, genes, s, vocab, ccfg, np.random.default_rng(0))
249
+ E = embed(model, seqs)
250
+ save(MODEL, s, E)
251
+ info.update(sanity(E, s, np.random.default_rng(0)))
252
+ info["D"], info["seconds"] = int(E.shape[1]), round(time.time() - t1, 1)
253
+ report[s] = info
254
+ print(f"[{s}] {json.dumps(info)}", flush=True)
255
+ print(f"done in {time.time() - t0:.0f}s; peak GPU mem {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
256
+ print(json.dumps(report, indent=1))
257
+
258
+
259
+ if __name__ == "__main__":
260
+ main()
45_text_feats.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen embeddings of every text set with additional (bio)medical / general text-embedding models.
2
+
3
+ python 45_text_feats.py <name> [<name> ...]
4
+
5
+ Text sets (same row order as the existing BiomedCLIP files):
6
+ alltxt cache/alltxt_meta.csv caption (TCGA real PathGen texts, 6480, junk rows kept, filtered later)
7
+ orion_gentext cache/orion_gentext_meta.csv caption (GPT synthetic captions of the ORION tiles, test only)
8
+ tcga_notext cache/tcga_notext_meta.csv caption (synthetic captions of TCGA images without real text, test only)
9
+ dlpfc_gentext cache/dlpfc_gentext_meta.csv caption (external DLPFC synthetic captions, test only)
10
+ Models (pooling / normalisation as on each model card; documents embedded without a query instruction):
11
+ biomedclip BiomedCLIP text tower (biomedclip_text.py, 256 tokens) - only for the sets that lack it
12
+ medcpt ncbi/MedCPT-Article-Encoder, [CLS], 512 tokens (PubMed search-log contrastive)
13
+ biolord FremyCompany/BioLORD-2023, mean pooling, 512 (clinical sentences / concepts, UMLS-based)
14
+ pubmedbert NeuML/pubmedbert-base-embeddings, mean pooling, 512 (PubMed title-abstract)
15
+ medembed abhinand/MedEmbed-large-v0.1 (bge-large medical fine-tune), [CLS], 512
16
+ bge BAAI/bge-large-en-v1.5, [CLS], 512 (general-domain reference)
17
+ qwen3-0.6b / qwen3-4b Qwen/Qwen3-Embedding-*, last-token pooling, left padding, 1024 tokens
18
+ Output cache/<set>_text_<name>.npy (n, 1, D) float16 (raw, not normalised).
19
+ """
20
+ import os
21
+ import sys
22
+ import time
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+ import torch
27
+ from transformers import AutoModel, AutoTokenizer
28
+
29
+ from common import CACHE
30
+
31
+ dev = "cuda"
32
+ SETS = {"alltxt": "alltxt_meta.csv", "orion_gentext": "orion_gentext_meta.csv", "tcga_notext": "tcga_notext_meta.csv",
33
+ "dlpfc_gentext": "dlpfc_gentext_meta.csv"}
34
+ HF = {"medcpt": ("ncbi/MedCPT-Article-Encoder", "cls", 512), "biolord": ("FremyCompany/BioLORD-2023", "mean", 512),
35
+ "pubmedbert": ("NeuML/pubmedbert-base-embeddings", "mean", 512),
36
+ "medembed": ("abhinand/MedEmbed-large-v0.1", "cls", 512), "bge": ("BAAI/bge-large-en-v1.5", "cls", 512),
37
+ "qwen3-0.6b": ("Qwen/Qwen3-Embedding-0.6B", "last", 1024), "qwen3-4b": ("Qwen/Qwen3-Embedding-4B", "last", 1024)}
38
+
39
+
40
+ def encoder(name):
41
+ if name == "biomedclip":
42
+ from biomedclip_text import BiomedCLIPText
43
+ m = BiomedCLIPText().to(dev).eval()
44
+ return m, 64
45
+ repo, pool, L = HF[name]
46
+ tk = AutoTokenizer.from_pretrained(repo, padding_side="left" if pool == "last" else "right")
47
+ m = AutoModel.from_pretrained(repo, torch_dtype=torch.bfloat16 if pool == "last" else torch.float32).to(dev).eval()
48
+
49
+ def f(b):
50
+ x = tk(b, padding=True, truncation=True, max_length=L, return_tensors="pt").to(dev)
51
+ h = m(**x).last_hidden_state
52
+ if pool == "cls":
53
+ return h[:, 0]
54
+ if pool == "mean":
55
+ a = x["attention_mask"][..., None].to(h.dtype)
56
+ return (h * a).sum(1) / a.sum(1)
57
+ return h[:, -1] # left padding -> last position is the last real token
58
+ return f, (16 if name == "qwen3-4b" else 64)
59
+
60
+
61
+ for name in sys.argv[1:]:
62
+ t0 = time.time()
63
+ f, bs = encoder(name)
64
+ for st, meta in SETS.items():
65
+ out_f = os.path.join(CACHE, f"{st}_text_{name}.npy")
66
+ if os.path.exists(out_f):
67
+ continue
68
+ txt = pd.read_csv(os.path.join(CACHE, meta)).caption.fillna("").astype(str).tolist()
69
+ order = np.argsort([len(t) for t in txt]) # length-sorted batches, restored below
70
+ E = None
71
+ with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
72
+ for i in range(0, len(txt), bs):
73
+ idx = order[i:i + bs]
74
+ z = f([txt[j] for j in idx]).float().cpu().numpy()
75
+ if E is None:
76
+ E = np.zeros((len(txt), z.shape[1]), np.float32)
77
+ E[idx] = z
78
+ assert np.isfinite(E).all()
79
+ np.save(out_f, E[:, None].astype(np.float16))
80
+ print(f"[{name}] {st}: {E.shape} {time.time() - t0:.0f}s", flush=True)
81
+ del f
82
+ torch.cuda.empty_cache()
45b_text_feats_pathvlm.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen TEXT-tower embeddings of two pathology vision-language models (same output format as 45_text_feats.py).
2
+
3
+ python 45b_text_feats_pathvlm.py <name> [<name> ...] # name in {keep, quiltnet, quiltnet-gelu}
4
+ python 45b_text_feats_pathvlm.py verify [keep|quiltnet] # checks against the official code (see below)
5
+
6
+ Text sets / row order / output exactly as 45_text_feats.py: cache/<set>_text_<name>.npy, (n, 1, D) float16, raw
7
+ (pre-normalisation) embedding, for alltxt, orion_gentext, tcga_notext, dlpfc_gentext (column `caption`).
8
+ Inference in fp32 (no autocast; the models are small) and cast to float16 at save time.
9
+ Runtime (shared A5000, 1 loaded CPU): keep 85 s for all 4 sets, quiltnet + quiltnet-gelu 35 s.
10
+ Leakage check (papers + repos, 2026-10-24): neither model was pretrained on PathGen-1.6M or other TCGA captions.
11
+ KEEP: OpenPath (Twitter + LAION) + Quilt-1M, regrouped into 143k semantic groups (Loie/KEEP_dataset); text
12
+ encoder PubMedBERT-arch., initialised from a knowledge encoder trained on their disease KG (UMLS-based).
13
+ QuiltNet: OpenAI CLIP fine-tuned on Quilt-1M = YouTube 802k + PubMed OA 59k + Twitter/OpenPath list ~134k
14
+ + LAION 23k (paper June 2023, a year before PathGen-1.6M, July 2024). TCGA appears only as evaluation data.
15
+
16
+ keep KEEP (Zhou et al., Cancer Cell 2026; github MAGIC-AI4Med/KEEP), HF Astaxanthin/KEEP
17
+ revision 28a25d95cc6ba27a7e6fab3f144e13dbafd8b21e, licence MIT. D = 768.
18
+ Text tower = PubMedBERT-architecture BertModel (12 layers, 768, vocab 30522) incl. its BERT pooler
19
+ (tanh(Linear(h_CLS))); KEEP has no separate text projection - its official modeling_keep.py
20
+ encode_text() is normalize(BertModel(**tokens).pooler_output). We return pooler_output before the
21
+ L2-normalisation. Tokenisation as in the README / repo zero-shot example: AutoTokenizer of the repo
22
+ (BertTokenizerFast, repo vocab.txt, lower-case), max_length=256, padding='max_length', truncation=True (input_ids, token_type_ids,
23
+ attention_mask all passed). Weights: text.* of model.safetensors, strict load. The official
24
+ modeling_keep.py (copied to ext/keep/, read in full: model definition only, but it imports timm - not in
25
+ scileo - and monkey-patches timm's LayerScale) is therefore NOT imported for extraction; the text tower is
26
+ rebuilt here with transformers' BertModel.
27
+ quiltnet QuiltNet-B-32 (Ikezogwo et al., NeurIPS 2023 D&B), HF wisdomik/QuiltNet-B-32
28
+ revision 8ce77289ce35a90b2f1db1137dfa4bc2df175e33, licence MIT. D = 512.
29
+ open_clip CLIP text tower (12 layers, width 512, 8 heads, causal mask, 77 ctx) re-implemented with
30
+ transformers' CLIPTextModelWithProjection and the open_clip state dict (open_clip_pytorch_model.bin)
31
+ remapped key-by-key (in_proj split into q/k/v, text_projection transposed), strict load.
32
+ Activation = QuickGELU. QuiltNet was fine-tuned from OpenAI CLIP ViT-B/32 with OpenCLIP (paper sec. 4 / app.
33
+ C.2), and OpenAI CLIP uses QuickGELU, but the repo's open_clip_config.json has no quick_gelu flag (and its HF
34
+ config.json says hidden_act 'gelu'), so a plain open_clip 'hf-hub:' load silently runs GELU. On image<->caption
35
+ retrieval with the real open_clip model (both towers), QuickGELU beats GELU on every metric: TCGA 256 i2t R@1
36
+ .082 vs .051, t2i .094 vs .066; ORION 512 i2t .029 vs .012, t2i .031 vs .021. So the weights were trained
37
+ with QuickGELU and the released config is wrong; we use QuickGELU (= open_clip
38
+ create_model('hf-hub:wisdomik/QuiltNet-B-32', force_quick_gelu=True)). The literal released-config (GELU)
39
+ variant is available as name 'quiltnet-gelu' (mean cosine to the QuickGELU embedding only ~0.78).
40
+ Tokenisation = open_clip's default SimpleTokenizer, reproduced as: whitespace_clean(basic_clean(t)).lower()
41
+ (ftfy.fix_text 6.3.1 from ext/quiltnet/pylib (pure python, not installed), html.unescape x2), OpenAI CLIP BPE
42
+ (CLIPTokenizerFast of openai/clip-vit-base-patch32), [SOT] + first 75 BPE + [EOT], zero padding to 77, no
43
+ padding mask. Pooling = hidden state at the EOT token (argmax of ids) after ln_final, @ text_projection
44
+ = open_clip encode_text(normalize=False).
45
+
46
+ Verification (`verify`, run 2026-10-24; references loaded from unpacked wheels in ext/*/pylib, never pip-installed):
47
+ keep official KEEPModel (ext/keep/modeling_keep.py, timm 1.0.15 from ext/keep/pylib, from_pretrained: all keys
48
+ matched): its encode_text == normalize(ours) BIT-EXACT (max|diff| 0) on 67 texts (64 real captions, an empty
49
+ and an over-length one); README AutoTokenizer == slow BertTokenizer inputs on 10405/10405 captions; README
50
+ example.tif zero-shot: breast invasive carcinoma 0.79 / lung adeno 0.21 / normal 0.001; 256-tile TCGA
51
+ image<->synthetic caption retrieval with the official image tower i2t R@1 0.094, t2i 0.063 (chance 0.004).
52
+ quiltnet open_clip 2.32.0 create_model('hf-hub:...', force_quick_gelu=True).encode_text vs ours (fp32, CPU):
53
+ max|diff| ~1e-5 (|x| up to ~8), min cosine 0.9999998 (float-op order only); same for the GELU variant vs the
54
+ default open_clip load; open_clip token ids == ours on 10405/10405 captions; remapped weights == the repo's
55
+ own HF-format pytorch_model.bin text weights (197/197 tensors, max|diff| 0); repo ADI-TCGA example tile
56
+ zero-shot over 9 CRC tissue classes -> adipose (top-1); retrieval numbers above.
57
+ """
58
+ import html
59
+ import json
60
+ import os
61
+ import re
62
+ import sys
63
+ import time
64
+
65
+ import numpy as np
66
+ import pandas as pd
67
+ import torch
68
+ import torch.nn as nn
69
+ from huggingface_hub import hf_hub_download
70
+
71
+ from common import CACHE, ROOT
72
+
73
+ dev = "cuda"
74
+ SETS = {"alltxt": "alltxt_meta.csv", "orion_gentext": "orion_gentext_meta.csv", "tcga_notext": "tcga_notext_meta.csv",
75
+ "dlpfc_gentext": "dlpfc_gentext_meta.csv"}
76
+ KEEP_REPO, KEEP_REV = "Astaxanthin/KEEP", "28a25d95cc6ba27a7e6fab3f144e13dbafd8b21e"
77
+ QN_REPO, QN_REV = "wisdomik/QuiltNet-B-32", "8ce77289ce35a90b2f1db1137dfa4bc2df175e33"
78
+ CLIP_TOK = "openai/clip-vit-base-patch32"
79
+ EXT = os.path.join(ROOT, "ext")
80
+
81
+
82
+ class KEEPText(nn.Module):
83
+ """KEEP text tower: BertModel (+pooler) -> pooler_output (un-normalised)."""
84
+
85
+ def __init__(self):
86
+ super().__init__()
87
+ from safetensors.torch import load_file
88
+ from transformers import AutoTokenizer, BertConfig, BertModel
89
+ cfg = json.load(open(hf_hub_download(KEEP_REPO, "config.json", revision=KEEP_REV)))
90
+ self.text = BertModel(BertConfig(**cfg["text_config"]), add_pooling_layer=True)
91
+ sd = load_file(hf_hub_download(KEEP_REPO, "model.safetensors", revision=KEEP_REV))
92
+ sd = {k[5:]: v for k, v in sd.items() if k.startswith("text.")}
93
+ self.text.load_state_dict(sd, strict=True)
94
+ self.tok = AutoTokenizer.from_pretrained(KEEP_REPO, revision=KEEP_REV) # as README (BertTokenizer(Fast), lower)
95
+
96
+ def tokenize(self, texts):
97
+ return self.tok(texts, max_length=256, padding="max_length", truncation=True, return_tensors="pt")
98
+
99
+ def forward(self, texts):
100
+ x = self.tokenize(texts).to(next(self.parameters()).device)
101
+ return self.text(**x).pooler_output
102
+
103
+
104
+ def _oc_clean(t): # = open_clip tokenizer 'lower' clean: whitespace_clean(basic_clean(t)).lower()
105
+ import ftfy # ftfy 6.3.1 (pure python, unpacked wheel in ext/quiltnet/pylib; e.g. uncurls quotes as open_clip does)
106
+ t = html.unescape(html.unescape(ftfy.fix_text(t))).strip()
107
+ return re.sub(r"\s+", " ", t).strip().lower()
108
+
109
+
110
+ class QuiltText(nn.Module):
111
+ """open_clip CLIP text tower of QuiltNet-B-32 on transformers' CLIPTextModelWithProjection."""
112
+ CTX = 77
113
+
114
+ def __init__(self, act="quick_gelu"):
115
+ super().__init__()
116
+ from transformers import CLIPTextConfig, CLIPTextModelWithProjection, CLIPTokenizerFast
117
+ sys.path.append(os.path.join(EXT, "quiltnet", "pylib")) # for ftfy only (open_clip's text cleaner)
118
+ oc = json.load(open(hf_hub_download(QN_REPO, "open_clip_config.json", revision=QN_REV)))["model_cfg"]
119
+ tc = oc["text_cfg"]
120
+ cfg = CLIPTextConfig(vocab_size=tc["vocab_size"], hidden_size=tc["width"], intermediate_size=4 * tc["width"],
121
+ num_hidden_layers=tc["layers"], num_attention_heads=tc["heads"],
122
+ max_position_embeddings=tc["context_length"], projection_dim=oc["embed_dim"],
123
+ hidden_act=act, layer_norm_eps=1e-5, eos_token_id=2) # eos_token_id=2 -> argmax pooling
124
+ self.m = CLIPTextModelWithProjection(cfg)
125
+ sd = torch.load(hf_hub_download(QN_REPO, "open_clip_pytorch_model.bin", revision=QN_REV), map_location="cpu",
126
+ weights_only=True)
127
+ self.m.load_state_dict(self.map_open_clip(sd, tc["layers"]), strict=True)
128
+ self.tok = CLIPTokenizerFast.from_pretrained(CLIP_TOK)
129
+ self.sot, self.eot = self.tok.bos_token_id, self.tok.eos_token_id # 49406, 49407
130
+
131
+ @staticmethod
132
+ def map_open_clip(sd, L):
133
+ t = "text_model."
134
+ o = {t + "embeddings.token_embedding.weight": sd["token_embedding.weight"],
135
+ t + "embeddings.position_embedding.weight": sd["positional_embedding"],
136
+ t + "final_layer_norm.weight": sd["ln_final.weight"], t + "final_layer_norm.bias": sd["ln_final.bias"],
137
+ "text_projection.weight": sd["text_projection"].t().contiguous()}
138
+ for i in range(L):
139
+ a, b = f"transformer.resblocks.{i}.", f"{t}encoder.layers.{i}."
140
+ for w in ("weight", "bias"):
141
+ q, k, v = sd[a + "attn.in_proj_" + w].chunk(3, 0)
142
+ o.update({b + "self_attn.q_proj." + w: q, b + "self_attn.k_proj." + w: k, b + "self_attn.v_proj." + w: v,
143
+ b + "self_attn.out_proj." + w: sd[a + "attn.out_proj." + w],
144
+ b + "layer_norm1." + w: sd[a + "ln_1." + w], b + "layer_norm2." + w: sd[a + "ln_2." + w],
145
+ b + "mlp.fc1." + w: sd[a + "mlp.c_fc." + w], b + "mlp.fc2." + w: sd[a + "mlp.c_proj." + w]})
146
+ return o
147
+
148
+ def tokenize(self, texts):
149
+ out = torch.zeros(len(texts), self.CTX, dtype=torch.long)
150
+ for i, ids in enumerate(self.tok([_oc_clean(t) for t in texts], add_special_tokens=False).input_ids):
151
+ ids = [self.sot] + ids[:self.CTX - 2] + [self.eot]
152
+ out[i, :len(ids)] = torch.tensor(ids)
153
+ return out
154
+
155
+ def forward(self, texts):
156
+ ids = self.tokenize(texts).to(next(self.parameters()).device)
157
+ return self.m(input_ids=ids).text_embeds # EOT pooling (argmax id), ln_final, @ text_projection
158
+
159
+
160
+ def encoder(name):
161
+ m = {"keep": KEEPText, "quiltnet": lambda: QuiltText("quick_gelu"),
162
+ "quiltnet-gelu": lambda: QuiltText("gelu")}[name]().to(dev).eval()
163
+ return m, 64
164
+
165
+
166
+ def load_texts(meta):
167
+ return pd.read_csv(os.path.join(CACHE, meta)).caption.fillna("").astype(str).tolist()
168
+
169
+
170
+ def extract(names):
171
+ for name in names:
172
+ t0 = time.time()
173
+ f, bs = encoder(name)
174
+ for st, meta in SETS.items():
175
+ out_f = os.path.join(CACHE, f"{st}_text_{name}.npy")
176
+ if os.path.exists(out_f):
177
+ continue
178
+ txt = load_texts(meta)
179
+ order = np.argsort([len(t) for t in txt]) # length-sorted batches, restored below
180
+ E = None
181
+ with torch.no_grad():
182
+ for i in range(0, len(txt), bs):
183
+ idx = order[i:i + bs]
184
+ z = f([txt[j] for j in idx]).float().cpu().numpy()
185
+ if E is None:
186
+ E = np.zeros((len(txt), z.shape[1]), np.float32)
187
+ E[idx] = z
188
+ assert np.isfinite(E).all()
189
+ np.save(out_f, E[:, None].astype(np.float16))
190
+ print(f"[{name}] {st}: {E.shape} {time.time() - t0:.0f}s", flush=True)
191
+ del f
192
+ torch.cuda.empty_cache()
193
+
194
+
195
+ # ----------------------------------------------------------------------------------------------- verification
196
+ def _retrieval(img_feats, txt_feats):
197
+ a = torch.nn.functional.normalize(img_feats, dim=-1)
198
+ b = torch.nn.functional.normalize(txt_feats, dim=-1)
199
+ S = a @ b.T
200
+ r = torch.arange(len(S))
201
+ return {"i2t_R@1": (S.argmax(1) == r).float().mean().item(), "t2i_R@1": (S.argmax(0) == r).float().mean().item(),
202
+ "i2t_R@5": (S.topk(5, 1).indices == r[:, None]).any(1).float().mean().item()}
203
+
204
+
205
+ def verify(which=("keep", "quiltnet")):
206
+ from PIL import Image, PngImagePlugin
207
+ PngImagePlugin.MAX_TEXT_CHUNK = 1 << 30 # some PathGen PNGs carry huge iCCP chunks
208
+ torch.manual_seed(0)
209
+ texts = load_texts(SETS["alltxt"])
210
+ sample = [texts[i] for i in np.random.default_rng(0).choice(len(texts), 64, replace=False)]
211
+ sample += ["an H&E image of breast invasive carcinoma.", "", "a " + "very " * 120 + "long text"]
212
+ tn = pd.read_csv(os.path.join(CACHE, SETS["tcga_notext"]))
213
+ tn = tn[tn.caption.notna()].reset_index(drop=True)
214
+ imgs = [Image.open(p).convert("RGB") for p in tn.image_path]
215
+ print(f"retrieval set: {len(imgs)} TCGA tiles with synthetic captions (tcga_notext)")
216
+ if "keep" in which:
217
+ verify_keep(sample, tn, imgs)
218
+ if "quiltnet" in which:
219
+ verify_quiltnet(sample, tn, imgs)
220
+
221
+
222
+ def verify_keep(sample, tn, imgs):
223
+ """KEEP: official modeling_keep.py from the local copy, timm from the unpacked wheel."""
224
+ from PIL import Image
225
+ sys.path[:0] = [os.path.join(EXT, "keep", "pylib"), os.path.join(EXT, "keep")]
226
+ from torchvision import transforms
227
+ from transformers import AutoTokenizer
228
+ import modeling_keep # ext/keep/modeling_keep.py (read in full before use)
229
+ ref, info = modeling_keep.KEEPModel.from_pretrained(KEEP_REPO, revision=KEEP_REV, output_loading_info=True)
230
+ ref = ref.eval()
231
+ print(f"[keep] official from_pretrained loading info: { {k: v for k, v in info.items() if v} or 'all keys matched'}")
232
+ rtok = AutoTokenizer.from_pretrained(os.path.join(EXT, "keep"), use_fast=False) # slow BertTokenizer as reference
233
+ ours = KEEPText().eval()
234
+ with torch.no_grad():
235
+ a = ref.encode_text(rtok(sample, max_length=256, padding="max_length", truncation=True, return_tensors="pt"))
236
+ b = torch.nn.functional.normalize(ours(sample), dim=-1)
237
+ print(f"[keep] official encode_text vs normalize(ours): max|diff| = {(a - b).abs().max().item():.3e} "
238
+ f"bit-exact: {torch.equal(a, b)}")
239
+ n_same = n_tok = 0
240
+ for meta in SETS.values():
241
+ t = load_texts(meta)
242
+ for i in range(0, len(t), 256):
243
+ kw = dict(max_length=256, padding="max_length", truncation=True, return_tensors="pt")
244
+ x, y = rtok(t[i:i + 256], **kw), ours.tokenize(t[i:i + 256])
245
+ same = torch.stack([(x[k] == y[k]).all(1) for k in ("input_ids", "token_type_ids", "attention_mask")]).all(0)
246
+ n_tok, n_same = n_tok + len(same), n_same + int(same.sum())
247
+ print(f"[keep] slow BertTokenizer vs our (README AutoTokenizer) inputs identical on {n_same}/{n_tok} captions")
248
+ tf = transforms.Compose([transforms.Resize(224, interpolation=transforms.InterpolationMode.BICUBIC),
249
+ transforms.CenterCrop(224), transforms.ToTensor(),
250
+ transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))])
251
+ ex = ["an H&E image of breast invasive carcinoma.", "an H&E image of normal tissue.",
252
+ "an H&E image of lung adenocarcinoma."]
253
+ img = tf(Image.open(os.path.join(EXT, "keep", "example.tif")).convert("RGB"))[None]
254
+ p = (ref.logit_scale.exp() * ref.encode_image(img) @ torch.nn.functional.normalize(ours(ex), dim=-1).T).softmax(-1)
255
+ print(f"[keep] README example.tif zero-shot probs {dict(zip(ex, p[0].round(decimals=3).tolist()))}")
256
+ ref = ref.to(dev)
257
+ I = torch.cat([ref.encode_image(torch.stack([tf(x) for x in imgs[i:i + 32]]).to(dev)).cpu()
258
+ for i in range(0, len(imgs), 32)])
259
+ T = torch.cat([ours(list(tn.caption[i:i + 64])) for i in range(0, len(tn), 64)])
260
+ print(f"[keep] image<->caption retrieval (chance R@1 {1 / len(imgs):.3f}): {_retrieval(I, T)}")
261
+ del ref, ours
262
+ torch.cuda.empty_cache()
263
+
264
+
265
+ def verify_quiltnet(sample, tn, imgs):
266
+ """QuiltNet: real open_clip 2.32.0 (+ftfy) from the unpacked wheels."""
267
+ from PIL import Image
268
+ sys.path[:0] = [os.path.join(EXT, "quiltnet", "pylib")]
269
+ import open_clip
270
+ from transformers import CLIPModel
271
+ otok = open_clip.get_tokenizer("hf-hub:" + QN_REPO)
272
+ ours = QuiltText().eval()
273
+ n_tok = n_same = 0
274
+ for meta in SETS.values():
275
+ t = load_texts(meta)
276
+ for i in range(0, len(t), 256):
277
+ same = (otok(t[i:i + 256]) == ours.tokenize(t[i:i + 256])).all(1)
278
+ n_tok, n_same = n_tok + len(same), n_same + int(same.sum())
279
+ print(f"[quiltnet] open_clip vs our token ids identical on {n_same}/{n_tok} captions of the 4 sets")
280
+ hf = CLIPModel.from_pretrained(QN_REPO, revision=QN_REV)
281
+ hsd, osd = hf.state_dict(), ours.m.state_dict()
282
+ dw = max((hsd[k] - osd[k]).abs().max().item() for k in osd if k in hsd)
283
+ print(f"[quiltnet] repo HF-format pytorch_model.bin: {sum(k in hsd for k in osd)}/{len(osd)} text tensors present, "
284
+ f"max|w diff| vs remapped open_clip weights = {dw:.3e}; its config hidden_act = {hf.config.text_config.hidden_act}")
285
+ del hf
286
+ cls = ["adipose", "background", "debris", "lymphocytes", "mucus", "smooth muscle", "normal colon mucosa",
287
+ "cancer-associated stroma", "colorectal adenocarcinoma epithelium"]
288
+ prompts = [f"a histopathology image of {c}" for c in cls]
289
+ for act, qg in (("quick_gelu", True), ("gelu", False)):
290
+ oc, _, pre = open_clip.create_model_and_transforms("hf-hub:" + QN_REPO, force_quick_gelu=qg)
291
+ oc = oc.eval()
292
+ mine = ours if qg else QuiltText("gelu").eval()
293
+ with torch.no_grad():
294
+ a, b = oc.encode_text(otok(sample)), mine(sample)
295
+ print(f"[quiltnet/{act}] open_clip encode_text vs ours: max|diff| = {(a - b).abs().max().item():.3e} "
296
+ f"(max|a| {a.abs().max().item():.2f}), min cos = "
297
+ f"{torch.nn.functional.cosine_similarity(a, b).min().item():.7f}")
298
+ x = pre(Image.open(os.path.join(EXT, "quiltnet", "ADI-TCGA-CFQTRNMG.tif")).convert("RGB"))[None]
299
+ p = (100 * torch.nn.functional.normalize(oc.encode_image(x), dim=-1)
300
+ @ torch.nn.functional.normalize(mine(prompts), dim=-1).T).softmax(-1)[0]
301
+ print(f"[quiltnet/{act}] repo ADI-TCGA example tile zero-shot (true class adipose): top = {cls[p.argmax()]}"
302
+ f" p={p.max().item():.3f}")
303
+ oc, mine = oc.to(dev), mine.to(dev)
304
+ I = torch.cat([oc.encode_image(torch.stack([pre(x) for x in imgs[i:i + 64]]).to(dev)).cpu()
305
+ for i in range(0, len(imgs), 64)])
306
+ T = torch.cat([mine(list(tn.caption[i:i + 64])).cpu() for i in range(0, len(tn), 64)])
307
+ print(f"[quiltnet/{act}] retrieval (both towers {act}): {_retrieval(I, T)}")
308
+ del oc, mine
309
+ torch.cuda.empty_cache()
310
+
311
+
312
+ if __name__ == "__main__":
313
+ if sys.argv[1:2] == ["verify"]:
314
+ verify(sys.argv[2:] or ("keep", "quiltnet"))
315
+ else:
316
+ extract(sys.argv[1:])
50_medvtok_feats.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MedVTok (general medical visual tokenizer, MERA-aligned to CONCH v1.5 for histopathology) global image features
2
+ of every H&E set, as an additional / alternative image encoder next to the frozen UNI2 anchor.
3
+
4
+ Run: .venv/bin/python -I 50_medvtok_feats.py <set> [<set> ...] sets: orion tcga hest dlpfc
5
+ Model: ext/medvtok/repo (Masaaki-75/medvtok @a558c24, MIT; only models/medvtok.py + models/utils.py +
6
+ models/transformer.py are imported) + ext/medvtok/weights/ckpt.pth (HF massaki75/medvtok, MIT), loaded here with
7
+ torch.load(weights_only=True) and strict=True (the repo's build_model uses weights_only=False / strict=False).
8
+ Encoder only (decode=False). Preprocessing = repo build_transform: RGB -> bicubic resize 256 -> [-1, 1].
9
+ Feature = outputs["feats"]: mean-pooled encoder tokens -> LayerNorm -> Linear (1152-d, the aligned global feature).
10
+ Views: orion / tcga 8 dihedral transforms (= he_uni2.npy); hest / dlpfc 1 view (= their UNI2 caches).
11
+ Images: orion ../ORION tiles, tcga cache/allimg_meta.csv image_path, hest cache/hest/he_img.npy (49_),
12
+ dlpfc cache/external/dlpfc_he_05um.npy (0.5 um/px, as for MIPHEI / captions).
13
+ Output: cache/he_medvtok.npy (3187, 8, 1152), cache/allimg_he_medvtok.npy (6144, 8, 1152),
14
+ cache/hest/he_medvtok.npy (9484, 1, 1152), cache/external/dlpfc_he_medvtok.npy (482, 1152); float16.
15
+ """
16
+ import json
17
+ import os
18
+ import sys
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ import torch
23
+ import torch.nn.functional as F
24
+ from PIL import Image, PngImagePlugin
25
+
26
+ PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024 # some PathGen PNGs embed very large ICC profiles (= 04b_)
27
+
28
+ ROOT = os.path.dirname(os.path.abspath(__file__))
29
+ sys.path.insert(0, ROOT)
30
+ from common import CACHE, ORION, dihedral, load_all_tiles # noqa: E402
31
+
32
+ REPO = os.path.join(ROOT, "ext", "medvtok", "repo")
33
+ WTS = os.path.join(ROOT, "ext", "medvtok", "weights")
34
+ sys.path.insert(0, REPO)
35
+ from models.medvtok import MedVTok # noqa: E402
36
+
37
+ dev = "cuda"
38
+ SIZE, BS = 256, 64
39
+
40
+ cfg = json.load(open(os.path.join(WTS, "config.json")))
41
+ assert cfg["architecture"] == "medvtok-clip-meanpool"
42
+ model = MedVTok(**cfg["model_kwargs"])
43
+ sd = torch.load(os.path.join(WTS, "ckpt.pth"), map_location="cpu", weights_only=True)
44
+ print(model.load_state_dict(sd, strict=True))
45
+ del sd, model.decoder
46
+ model.eval().to(dev)
47
+
48
+
49
+ @torch.no_grad()
50
+ def encode(u8):
51
+ """u8: (B, H, W, 3) uint8 -> (B, 1152) float32"""
52
+ x = torch.from_numpy(np.ascontiguousarray(u8)).to(dev).permute(0, 3, 1, 2).float() / 255
53
+ x = F.interpolate(x, size=(SIZE, SIZE), mode="bicubic", align_corners=False, antialias=True).clamp(0, 1)
54
+ x = x * 2 - 1
55
+ with torch.autocast("cuda", dtype=torch.bfloat16):
56
+ out = model([im for im in x], sample_posterior=False, decode=False, return_batched_tensor=False)
57
+ return out["feats"].float().cpu().numpy()
58
+
59
+
60
+ def run(load, n, views):
61
+ out = np.zeros((n, views, 1152), np.float16)
62
+ for s in range(0, n, BS):
63
+ u8 = load(s, min(n, s + BS))
64
+ for d in range(views):
65
+ x = u8 if views == 1 else dihedral(torch.from_numpy(u8).permute(0, 3, 1, 2), d).permute(0, 2, 3, 1).numpy()
66
+ out[s:s + len(u8), d] = encode(x)
67
+ if s % (BS * 20) == 0:
68
+ print(f" {s}/{n}", flush=True)
69
+ assert np.isfinite(out.astype(np.float32)).all()
70
+ return out
71
+
72
+
73
+ def from_paths(paths):
74
+ return lambda a, b: np.stack([np.asarray(Image.open(p).convert("RGB").resize((SIZE, SIZE), Image.BICUBIC))
75
+ for p in paths[a:b]])
76
+
77
+
78
+ for st in sys.argv[1:]:
79
+ if st == "orion":
80
+ paths = np.array([os.path.join(ORION, p) for p in load_all_tiles().image_path])
81
+ np.save(os.path.join(CACHE, "he_medvtok.npy"), run(from_paths(paths), len(paths), 8))
82
+ elif st == "tcga":
83
+ paths = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv")).image_path.values
84
+ np.save(os.path.join(CACHE, "allimg_he_medvtok.npy"), run(from_paths(paths), len(paths), 8))
85
+ elif st == "hest":
86
+ A = np.load(os.path.join(CACHE, "hest", "he_img.npy"), mmap_mode="r")
87
+ np.save(os.path.join(CACHE, "hest", "he_medvtok.npy"), run(lambda a, b: np.asarray(A[a:b]), len(A), 1))
88
+ elif st == "dlpfc":
89
+ A = np.load(os.path.join(CACHE, "external", "dlpfc_he_05um.npy"))
90
+ np.save(os.path.join(CACHE, "external", "dlpfc_he_medvtok.npy"), run(lambda a, b: A[a:b], len(A), 1)[:, 0])
91
+ print("saved", st, flush=True)
52_final_model.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train and save the final four-modality model (EJ-ad+MV, the recommended configuration of 51_) for embed.py.
2
+
3
+ .venv/bin/python -I 52_final_model.py cv 5-fold check of the stage-2 selection rule (3 seeds)
4
+ .venv/bin/python -I 52_final_model.py fold0 [--rule R] -> models/ej_ad_mv_fold0/ (fold-0 train / val; test held out)
5
+ .venv/bin/python -I 52_final_model.py release [--rule R] -> models/ej_ad_mv/ (train = fold-0 train + test, val = fold-0 val)
6
+ --out NAME writes models/NAME/ (cv: results/final_cv_*_NAME.csv) instead, e.g. to retrain without overwriting
7
+ the shipped checkpoints / results
8
+
9
+ Same data, heads, losses, steps and early stopping as 51_medvtok_eval.py (UNI2 anchor; EJ-ad+MV):
10
+ stage 1 mIF head (VirTues -> MLP, 400 steps), RNA heads (hvg / Tahoe-x1 / SE-600M / AIDO.Cell -> linear, 2700),
11
+ text heads (KEEP / BiomedCLIP / Qwen3-Emb-0.6B -> linear, 1000), each InfoNCE against frozen UNI2 on real
12
+ pairs, early stopping on its val R@1 (deterministic: identical to 51_)
13
+ stage 2 heads frozen; residual adapter H&E = UNI2 + A([UNI2, c * MedVTok]) + temperatures, sum of the three pair
14
+ losses (RNA / text: mean over the encoder heads) + similarity distillation to UNI2 (lambda 1), AdamW lr 1e-4,
15
+ 1000 steps (OneCycle)
16
+ Stage-2 selection rules (the val curve is flat after ~100 steps, so 51_'s argmax of the noisy val geo-mean picks very
17
+ different steps between otherwise identical runs; 'cv' quantifies it):
18
+ es 51_: early stopping on the geometric mean of the three val R@1, one seed
19
+ last the adapter after the full 1000-step schedule, one seed (val unused)
20
+ ens-es mean of the residuals of 3 early-stopped adapters (seeds): H&E = UNI2 + mean_s A_s(.)
21
+ ens-last same with the 1000-step adapters
22
+ Real pairs only (ORION H&E-mIF, HEST H&E-RNA, TCGA/PathGen H&E-text); virtual / synthetic data never trained on, used
23
+ only in the emergent test pairs. 'fold0' evaluates on its held-out test (51_ protocol, g100, same test items) and
24
+ writes test.csv + test_items.npz (for 53_); 'release' has no test split: expected performance = the 5-fold CV.
25
+ Output: results/final_cv_{long,summary,curves}.csv (cv); models/<name>/{model.pt, meta.json, val_curve.csv[, test.csv]}
26
+ """
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+
32
+ import numpy as np
33
+ import pandas as pd
34
+ import torch
35
+ import torch.nn.functional as F
36
+
37
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
38
+ from common import CACHE, ROOT, SPLITS # noqa: E402
39
+ from embed import RNA_E, TXT_E, Bind4, mlp # noqa: E402
40
+
41
+ MODE = sys.argv[1]
42
+ assert MODE in ("cv", "fold0", "release"), MODE
43
+ RULE = sys.argv[sys.argv.index("--rule") + 1] if "--rule" in sys.argv else "ens-es" # chosen by `cv` (RESULTS.md)
44
+ assert RULE in ("es", "last", "ens-es", "ens-last"), RULE
45
+ NSEED = 3
46
+ dev = "cuda"
47
+ H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
48
+ G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
49
+ f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
50
+ nz = lambda x: F.normalize(x, dim=-1)
51
+ t0 = time.time()
52
+
53
+ # ---------------------------------------------------------------- data (= 51_)
54
+ IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
55
+ IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
56
+ U = {"o": np.load(os.path.join(CACHE, "he_uni2.npy")), "h": np.load(os.path.join(H, "he_uni2.npy"))[:, 0],
57
+ "t": np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))}
58
+ MV = {"o": np.load(os.path.join(CACHE, "he_medvtok.npy")), "h": np.load(os.path.join(H, "he_medvtok.npy"))[:, 0],
59
+ "t": np.load(os.path.join(CACHE, "allimg_he_medvtok.npy"))}
60
+ U = {s: f32(x).to(dev) for s, x in U.items()}
61
+ MV = {s: f32(x).to(dev) for s, x in MV.items()}
62
+ MISS = pd.read_csv(os.path.join(H, "he_img_missing.csv")).idx.values
63
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
64
+ T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
65
+ T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
66
+ hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
67
+
68
+
69
+ def virt_hvg(st): # = 47_ / 51_ hvg_inputs (virtual RNA, test only)
70
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
71
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
72
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
73
+ return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
74
+
75
+
76
+ RNA_IN = {e: ({"hest": f32(np.load(os.path.join(H, "hvg.npy"))).to(dev), "orion_v": f32(virt_hvg("orion")).to(dev),
77
+ "tcga_v": f32(virt_hvg("tcga_text")).to(dev)} if e == "hvg" else
78
+ {s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in ["hest", "orion_v", "tcga_v"]})
79
+ for e in RNA_E}
80
+ TXT_IN = {e: {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev)
81
+ for s in ["alltxt", "orion_gentext"]} for e in TXT_E}
82
+ RAW = {"rna:hvg", "text:biomedclip"} # encoders used without z-scoring (= 47_ / 51_)
83
+ DIMS = {"mif": IF_O.shape[-1], **{f"rna:{e}": RNA_IN[e]["hest"].shape[1] for e in RNA_E},
84
+ **{f"text:{e}": TXT_IN[e]["alltxt"].shape[1] for e in TXT_E}}
85
+
86
+
87
+ def one_text_per_image(t_ids, rng):
88
+ pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
89
+ return pick.index.values, pick.values
90
+
91
+
92
+ def g100(a, b, seed=0): # = 51_
93
+ from scipy.optimize import linear_sum_assignment
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 clip_loss(lg, pos=None):
116
+ if pos is None:
117
+ t = torch.arange(len(lg), device=dev)
118
+ return (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
119
+ tgt = pos / pos.sum(1, keepdim=True)
120
+ return (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
121
+
122
+
123
+ def run(k, release=False, test=True):
124
+ """stage 1 + stage 2 (NSEED seeds) on fold k -> (model, use(rule), test results per rule, val curves, info)"""
125
+ s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
126
+ hh = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
127
+ sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
128
+ tsp = sp.loc[T.img_idx].values
129
+ o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
130
+ hs = {x: np.setdiff1d(hh.spot_idx[hh.split == x].values, MISS) for x in ["train", "val", "test"]}
131
+ ts = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
132
+ if release: # everything real except the early-stopping val split
133
+ for d in (o, hs, ts):
134
+ d["train"] = np.sort(np.r_[d["train"], d.pop("test")])
135
+ sizes = {n: {x: len(v) for x, v in d.items()} for n, d in [("orion", o), ("hest", hs), ("tcga_text", ts)]}
136
+ print(f"fold {k}{' (release)' if release else ''}: {sizes}", flush=True)
137
+
138
+ # ------------------------------------------------ model + normalisation statistics (train only)
139
+ model = Bind4(DIMS, "cat").to(dev)
140
+ model.set_norm("mif", IF_O[o["train"]].mean((0, 1)), IF_O[o["train"]].std((0, 1)) + 1e-3)
141
+ trt = torch.as_tensor(ts["train"], device=dev)
142
+ for e in RNA_E:
143
+ if f"rna:{e}" not in RAW:
144
+ x = RNA_IN[e]["hest"][hs["train"]]
145
+ model.set_norm(f"rna:{e}", x.mean(0), x.std(0) + 1e-6)
146
+ for e in TXT_E:
147
+ if f"text:{e}" not in RAW:
148
+ x = TXT_IN[e]["alltxt"][trt]
149
+ model.set_norm(f"text:{e}", x.mean(0), x.std(0) + 1e-6)
150
+ model.c_mv.fill_(float(U["o"][o["train"]].norm(dim=-1).mean() / MV["o"][o["train"]].norm(dim=-1).mean()))
151
+ Xr = {e: {st: model.norm(f"rna:{e}", v) for st, v in RNA_IN[e].items()} for e in RNA_E}
152
+ Xt = {e: {st: model.norm(f"text:{e}", v) for st, v in TXT_IN[e].items()} for e in TXT_E}
153
+ Xm = model.norm("mif", IF_O)
154
+ o_tr, h_tr = torch.as_tensor(o["train"], device=dev), torch.as_tensor(hs["train"], device=dev)
155
+ vi, vt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["val"], np.random.default_rng(0)))
156
+
157
+ def anc(key, idx, views=None):
158
+ x, m = U[key], MV[key]
159
+ if x.dim() == 2:
160
+ return x[idx], m[idx]
161
+ v = views if views is not None else 0
162
+ return x[idx, v], m[idx, v]
163
+
164
+ def batch(task, rng):
165
+ if task == "mif":
166
+ b = o_tr[torch.as_tensor(rng.choice(len(o_tr), BS, replace=False), device=dev)]
167
+ d = torch.randint(0, 8, (BS,), device=dev)
168
+ return anc("o", b, d), {"mif": Xm[b, d]}, None
169
+ if task == "rna":
170
+ b = h_tr[torch.as_tensor(rng.choice(len(h_tr), BS, replace=False), device=dev)]
171
+ return anc("h", b), {e: Xr[e]["hest"][b] for e in RNA_E}, None
172
+ b = trt[torch.as_tensor(rng.choice(len(trt), BS, replace=False), device=dev)]
173
+ im = T_IMG[b]
174
+ return (anc("t", im, torch.randint(0, 8, (BS,), device=dev)), {e: Xt[e]["alltxt"][b] for e in TXT_E},
175
+ (im[:, None] == im[None, :]).float())
176
+
177
+ VAL = {"mif": (anc("o", o["val"]), {"mif": Xm[o["val"], 0]}),
178
+ "rna": (anc("h", hs["val"]), {e: Xr[e]["hest"][hs["val"]] for e in RNA_E}),
179
+ "text": (anc("t", vi), {e: Xt[e]["alltxt"][vt] for e in TXT_E})}
180
+
181
+ # ------------------------------------------------ stage 1 (= 51_ train_head, UNI2 anchor)
182
+ def train_head(key, task, enc, steps):
183
+ torch.manual_seed(k); rng = np.random.default_rng(k)
184
+ h = model.h(key) # initialised exactly like 51_: a fresh module created right after the same seed
185
+ h.load_state_dict((mlp(h[0].in_features) if key == "mif" else torch.nn.Linear(h.in_features, h.out_features)).state_dict())
186
+ logit = model.logit[model._k(key)]
187
+ opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
188
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
189
+ za_va, x_va = nz(VAL[task][0][0]), VAL[task][1][enc]
190
+ best, state = -1, None
191
+ for step in range(1, steps + 1):
192
+ h.train()
193
+ (u, _), X, pos = batch(task, rng)
194
+ loss = clip_loss(logit.exp().clamp(max=100) * nz(u) @ nz(h(X[enc])).T, pos)
195
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
196
+ if step % EVAL_EVERY == 0 or step == steps:
197
+ h.eval()
198
+ with torch.no_grad():
199
+ v = r1(za_va, nz(h(x_va)))
200
+ if v > best:
201
+ best, state = v, ({k_: x.detach().clone() for k_, x in h.state_dict().items()}, logit.detach().clone())
202
+ h.load_state_dict(state[0]); h.eval()
203
+ with torch.no_grad():
204
+ logit.copy_(state[1])
205
+ return best
206
+
207
+ stage1 = {"mif": train_head("mif", "mif", "mif", 400)}
208
+ for e in RNA_E:
209
+ stage1[f"rna:{e}"] = train_head(f"rna:{e}", "rna", e, 2700)
210
+ for e in TXT_E:
211
+ stage1[f"text:{e}"] = train_head(f"text:{e}", "text", e, 1000)
212
+ for p in model.heads.parameters():
213
+ p.requires_grad_(False)
214
+ model.heads.eval()
215
+ logit0 = {k_: v.detach().clone() for k_, v in model.logit.items()}
216
+ print(f"[{time.time() - t0:.0f}s] fold {k} stage 1 val R@1: {json.dumps({a: round(b, 3) for a, b in stage1.items()})}",
217
+ flush=True)
218
+
219
+ # ------------------------------------------------ stage 2 (= 51_ joint, mode 'ad', adapter 'cat'), NSEED seeds
220
+ def fuse_n(mod, encs, X): # inputs already normalised
221
+ return nz(sum(nz(model.h(f"{mod}:{e}")(X[e])) for e in encs))
222
+
223
+ def val_score():
224
+ model.eval()
225
+ with torch.no_grad():
226
+ v = {"mif": r1(nz(model.he(*VAL["mif"][0])), nz(model.h("mif")(VAL["mif"][1]["mif"]))),
227
+ "rna": r1(nz(model.he(*VAL["rna"][0])), fuse_n("rna", RNA_E, VAL["rna"][1])),
228
+ "text": r1(nz(model.he(*VAL["text"][0])), fuse_n("text", TXT_E, VAL["text"][1]))}
229
+ return float(np.exp(np.mean(np.log([max(x, 1e-4) for x in v.values()])))), v
230
+
231
+ def snap():
232
+ return ({k_: x.detach().clone() for k_, x in model.ah[0].state_dict().items()},
233
+ {k_: x.detach().clone() for k_, x in model.logit.items()})
234
+
235
+ def stage2(seed):
236
+ torch.manual_seed(seed); rng = np.random.default_rng(seed)
237
+ model.ah = torch.nn.ModuleList([model.new_adapter()]).to(dev)
238
+ with torch.no_grad():
239
+ for k_, v in model.logit.items():
240
+ v.copy_(logit0[k_])
241
+ opt = torch.optim.AdamW(list(model.ah.parameters()) + list(model.logit.parameters()), lr=1e-4, weight_decay=0.05)
242
+ sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=1e-4, total_steps=1000, pct_start=0.05)
243
+ best, es, curve = -1, None, []
244
+ for step in range(1, 1001):
245
+ model.ah.train()
246
+ loss = 0
247
+ for task in ("mif", "rna", "text"):
248
+ (u, mv), X, pos = batch(task, rng)
249
+ za = nz(model.he(u, mv))
250
+ if task == "mif":
251
+ loss = loss + clip_loss(model.lg("mif") * za @ nz(model.h("mif")(X["mif"])).T, pos)
252
+ else:
253
+ encs = RNA_E if task == "rna" else TXT_E
254
+ loss = loss + sum(clip_loss(model.lg(f"{task}:{e}") * za @ nz(model.h(f"{task}:{e}")(X[e])).T, pos)
255
+ for e in encs) / len(encs)
256
+ un = nz(u)
257
+ loss = loss + F.mse_loss(za @ za.T, un @ un.T)
258
+ opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
259
+ if step % EVAL_EVERY == 0 or step == 1000:
260
+ sc, v = val_score()
261
+ curve.append({"seed": seed, "step": step, "geo": sc, **v})
262
+ if sc > best:
263
+ best, es = sc, (step, *snap())
264
+ return es, (1000, *snap()), curve
265
+
266
+ seeds = [k] + [1000 * i + k for i in range(1, NSEED)] # the first = 51_'s seed
267
+ runs = [stage2(sd) for sd in seeds]
268
+ curve = pd.DataFrame([c for r in runs for c in r[2]])
269
+ sel = {"es": [runs[0][0]], "last": [runs[0][1]], "ens-es": [r[0] for r in runs], "ens-last": [r[1] for r in runs],
270
+ **{f"es@seed{i}": [r[0]] for i, r in enumerate(runs)}, **{f"last@seed{i}": [r[1]] for i, r in enumerate(runs)}}
271
+ print(f"[{time.time() - t0:.0f}s] fold {k} stage 2: early-stopping step per seed {[r[0][0] for r in runs]}", flush=True)
272
+
273
+ def use(rule):
274
+ """set the model's adapter(s) to a selection rule (temperatures: those of the first member; unused at inference)"""
275
+ st = sel[rule]
276
+ model.ah = torch.nn.ModuleList([model.new_adapter() for _ in st]).to(dev)
277
+ for a, (_, sd_, _) in zip(model.ah, st):
278
+ a.load_state_dict(sd_)
279
+ with torch.no_grad():
280
+ for k_, v in model.logit.items():
281
+ v.copy_(st[0][2][k_])
282
+ model.eval()
283
+
284
+ res = {}
285
+ if test:
286
+ ii, tt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["test"], np.random.default_rng(k)))
287
+ for rule in ["stage1"] + list(sel):
288
+ if rule == "stage1":
289
+ model.ah = torch.nn.ModuleList([model.new_adapter()]).to(dev) # zero output: H&E = UNI2 (= E of 51_)
290
+ else:
291
+ use(rule)
292
+ with torch.no_grad():
293
+ R = lambda st, idx: fuse_n("rna", RNA_E, {e: Xr[e][st][idx] for e in RNA_E})
294
+ Tx = lambda st, idx: fuse_n("text", TXT_E, {e: Xt[e][st][idx] for e in TXT_E})
295
+ zm = nz(model.h("mif")(Xm[o["test"], 0]))
296
+ zt = Tx("alltxt", tt)
297
+ res[rule] = {"H&E<->mIF (ORION)": g100(nz(model.he(*anc("o", o["test"]))), zm),
298
+ "H&E<->RNA (HEST)": g100(nz(model.he(*anc("h", hs["test"]))), R("hest", hs["test"])),
299
+ "H&E<->text (TCGA)": g100(nz(model.he(*anc("t", ii))), zt),
300
+ "mIF<->RNA(virtual) (ORION)": g100(zm, R("orion_v", o["test"])),
301
+ "mIF<->text(synthetic) (ORION)": g100(zm, Tx("orion_gentext", o["test"])),
302
+ "text<->RNA(virtual) (TCGA)": g100(zt, R("tcga_v", ii)),
303
+ "text<->mIF(MIPHEI) (TCGA)": g100(zt, nz(model.h("mif")(model.norm("mif", IF_V_T[ii]))))}
304
+ res["_items"] = dict(orion=o["test"], hest=hs["test"], tcga_img=ii.cpu().numpy(), tcga_txt=tt.cpu().numpy())
305
+ info = {"sizes": sizes, "stage1_val_R@1": stage1, "es_steps": [r[0][0] for r in runs], "seeds": seeds}
306
+ return model, use, res, curve, info
307
+
308
+
309
+ if MODE == "cv":
310
+ TAG = f"_{sys.argv[sys.argv.index('--out') + 1]}" if "--out" in sys.argv else "" # results/final_cv_<x><TAG>.csv
311
+ rows = []
312
+ for k in range(5):
313
+ _, _, res, curve, info = run(k)
314
+ res.pop("_items")
315
+ for rule, rr in res.items():
316
+ for pair, r in rr.items():
317
+ rows.append({"rule": rule, "fold": k, "pair": pair, **r})
318
+ D = pd.DataFrame(rows)
319
+ D.to_csv(os.path.join(ROOT, "results", f"final_cv_long{TAG}.csv"), index=False)
320
+ curve.assign(fold=k).to_csv(os.path.join(ROOT, "results", f"final_cv_curves{TAG}.csv"), mode="w" if k == 0 else "a",
321
+ header=k == 0, index=False)
322
+ print(D[D.fold == k].pivot_table(index="rule", columns="pair", values="R@1", sort=False).round(3).to_string(), flush=True)
323
+ D = pd.DataFrame(rows)
324
+ S = D.groupby(["rule", "pair"], sort=False)[["R@1", "pair_acc"]].agg(["mean", "std"]).round(4)
325
+ S.to_csv(os.path.join(ROOT, "results", f"final_cv_summary{TAG}.csv"))
326
+ pd.set_option("display.width", 250)
327
+ print(D.pivot_table(index="rule", columns="pair", values="R@1", sort=False).round(3).to_string())
328
+ for r in ["es", "last"]: # seed-to-seed spread of the single-seed rules (std over seeds, mean over folds)
329
+ sub = D[D.rule.str.startswith(f"{r}@seed")]
330
+ print(r, "seed std of R@1 (mean over folds):",
331
+ sub.groupby(["pair", "fold"])["R@1"].std().groupby("pair").mean().round(4).to_dict())
332
+ sys.exit()
333
+
334
+ # ---------------------------------------------------------------- fold0 / release: build, save
335
+ release = MODE == "release"
336
+ OUT = os.path.join(ROOT, "models", sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else
337
+ "ej_ad_mv" if release else "ej_ad_mv_fold0")
338
+ os.makedirs(OUT, exist_ok=True)
339
+ model, use, res, curve, info = run(0, release=release, test=not release)
340
+ use(RULE)
341
+ curve.round(4).to_csv(os.path.join(OUT, "val_curve.csv"), index=False)
342
+ torch.save({k_: v.cpu() for k_, v in model.state_dict().items()}, os.path.join(OUT, "model.pt"))
343
+ meta = {"config": "EJ-ad+MV", "stage2_rule": RULE, "n_adapters": len(model.ah), "mode": MODE,
344
+ "created": time.strftime("%Y-%m-%d %H:%M"), "dims": DIMS, "adapter": "cat", "raw_encoders": sorted(RAW),
345
+ "hvg_genes": hvg.tolist(), "c_mv": float(model.c_mv),
346
+ "split": "fold0 of splits/patient_5fold (ORION), splits/final/hest_fold0, tcga_fold0"
347
+ + ("; train = train + test, val = val (early stopping)" if release else "; test held out"),
348
+ **info, "anchor": "UNI2-h CLS (1536-d), frozen", "image_extra": "MedVTok feats (1152-d), frozen",
349
+ "mif_markers": "embed.MIF_MARKERS", "rna_encoders": RNA_E, "text_encoders": TXT_E}
350
+ json.dump(meta, open(os.path.join(OUT, "meta.json"), "w"), indent=1)
351
+ print(f"saved {OUT} (rule {RULE}, {len(model.ah)} adapter(s))", flush=True)
352
+ if not release:
353
+ items = res.pop("_items")
354
+ np.savez(os.path.join(OUT, "test_items.npz"), **items)
355
+ ref = pd.read_csv(os.path.join(ROOT, "results", "medvtok_long.csv"))
356
+ ref = ref[(ref.config == "EJ-ad+MV") & (ref.fold == 0)].set_index("pair")
357
+ D = pd.DataFrame([{"pair": p, "R@1": r["R@1"], "pair_acc": r["pair_acc"], "R@1_51_fold0": ref.loc[p, "R@1"]}
358
+ for p, r in res[RULE].items()])
359
+ D.to_csv(os.path.join(OUT, "test.csv"), index=False)
360
+ print(D.round(3).to_string(), flush=True)
53_embed_check.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Check embed.py from RAW inputs (pixels, strings, counts) against the cached training features and the fold-0 test.
2
+
3
+ .venv/bin/python -I 53_embed_check.py [n] (n = items per raw check, default 300)
4
+
5
+ 1. feature reproduction: embed.py raw-input encoders vs the cached features the model was trained / tested on
6
+ (per-item cosine of the raw encoder features), for every encoder:
7
+ H&E UNI2 + MedVTok ORION tiles (PNG), TCGA/PathGen patches (PNG), HEST spot crops (cache/hest/he_img.npy)
8
+ text KEEP / BiomedCLIP / Qwen3 TCGA real captions (alltxt)
9
+ RNA hvg / Tahoe-x1 / SE-600M / AIDO.Cell HEST spots (rna_sets.load('hest') rows)
10
+ mIF VirTues (all 3187 ORION tiles: standardisation statistics are per slide / per dataset)
11
+ 2. end-to-end: models/ej_ad_mv_fold0 on held-out fold-0 test items, embeddings from raw inputs vs from cached
12
+ features (same items, g100 R@1)
13
+ Output results/embed_check.csv
14
+ """
15
+ import os
16
+ import sys
17
+ import time
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import torch
22
+
23
+ ROOT = os.path.dirname(os.path.abspath(__file__))
24
+ sys.path.insert(0, ROOT)
25
+ from common import CACHE, ORION, load_all_tiles # noqa: E402
26
+ from embed import MIF_MARKERS, RNA_E, TXT_E, Embedder # noqa: E402
27
+
28
+ N = int(sys.argv[1]) if len(sys.argv) > 1 else 300
29
+ MD = os.path.join(ROOT, "models", "ej_ad_mv_fold0")
30
+ E = Embedder(MD)
31
+ TI = np.load(os.path.join(MD, "test_items.npz"))
32
+ rng = np.random.default_rng(0)
33
+ rows, t0 = [], time.time()
34
+ f32 = lambda a: np.asarray(a, np.float32)
35
+
36
+
37
+ def cos(a, b):
38
+ a, b = torch.as_tensor(f32(a)), torch.as_tensor(f32(b))
39
+ return torch.nn.functional.cosine_similarity(a, b, dim=-1).numpy()
40
+
41
+
42
+ def report(mod, enc, src, c):
43
+ rows.append({"check": "features", "modality": mod, "encoder": enc, "source": src, "n": len(c),
44
+ "cos_median": float(np.median(c)), "cos_min": float(c.min())})
45
+ print(f"[{time.time() - t0:.0f}s] {mod:5s} {enc:11s} {src:14s} n={len(c)} cos median {np.median(c):.4f} min {c.min():.4f}",
46
+ flush=True)
47
+
48
+
49
+ def r1_g100(a, b, seed=0):
50
+ a, b = torch.nn.functional.normalize(a, dim=-1), torch.nn.functional.normalize(b, dim=-1)
51
+ g = np.random.default_rng(seed)
52
+ out = []
53
+ for _ in range(50):
54
+ s = torch.from_numpy(g.choice(len(a), min(100, len(a)), replace=False))
55
+ S = a[s] @ b[s].T; d = S.diag()
56
+ out.append((((S > d[:, None]).sum(1) == 0).float().mean() + ((S.T > d[:, None]).sum(1) == 0).float().mean()).item() / 2)
57
+ return float(np.mean(out))
58
+
59
+
60
+ # ------------------------------------------------------------------ H&E
61
+ tiles = load_all_tiles()
62
+ ot = rng.choice(TI["orion"], min(N, len(TI["orion"])), replace=False)
63
+ U, M = E.he_feats([os.path.join(ORION, p) for p in tiles.image_path.values[ot]])
64
+ report("H&E", "uni2", "ORION", cos(U, np.load(os.path.join(CACHE, "he_uni2.npy"), mmap_mode="r")[ot, 0]))
65
+ report("H&E", "medvtok", "ORION", cos(M, np.load(os.path.join(CACHE, "he_medvtok.npy"), mmap_mode="r")[ot, 0]))
66
+ he_raw = {"orion": (ot, U, M)}
67
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
68
+ tt_img = rng.choice(len(TI["tcga_img"]), min(N, len(TI["tcga_img"])), replace=False)
69
+ ti = TI["tcga_img"][tt_img]
70
+ U, M = E.he_feats(I.image_path.values[ti])
71
+ report("H&E", "uni2", "TCGA/PathGen", cos(U, np.load(os.path.join(CACHE, "allimg_he_uni2.npy"), mmap_mode="r")[ti, 0]))
72
+ report("H&E", "medvtok", "TCGA/PathGen", cos(M, np.load(os.path.join(CACHE, "allimg_he_medvtok.npy"), mmap_mode="r")[ti, 0]))
73
+ he_raw["tcga"] = (tt_img, U, M)
74
+ hsp = rng.choice(TI["hest"], min(N, len(TI["hest"])), replace=False)
75
+ U, M = E.he_feats(np.load(os.path.join(CACHE, "hest", "he_img.npy"), mmap_mode="r")[hsp])
76
+ report("H&E", "uni2", "HEST", cos(U, np.load(os.path.join(CACHE, "hest", "he_uni2.npy"), mmap_mode="r")[hsp, 0]))
77
+ report("H&E", "medvtok", "HEST", cos(M, np.load(os.path.join(CACHE, "hest", "he_medvtok.npy"), mmap_mode="r")[hsp, 0]))
78
+ he_raw["hest"] = (hsp, U, M)
79
+ E.release()
80
+
81
+ # ------------------------------------------------------------------ text
82
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
83
+ txt_ids = TI["tcga_txt"][tt_img] # the captions paired with the TCGA test images above
84
+ Ft = E.text_feats(T.caption.fillna("").astype(str).values[txt_ids].tolist())
85
+ for e in TXT_E:
86
+ report("text", e, "TCGA captions", cos(Ft[e], np.load(os.path.join(CACHE, f"alltxt_text_{e}.npy"), mmap_mode="r")[txt_ids, 0]))
87
+ E.release()
88
+
89
+ # ------------------------------------------------------------------ RNA
90
+ from rna_sets import load # noqa: E402
91
+
92
+ Xh, genes = load("hest")
93
+ Fr = E.rna_feats(Xh[hsp], genes)
94
+ for e in RNA_E:
95
+ ref = (np.load(os.path.join(CACHE, "hest", "hvg.npy"), mmap_mode="r") if e == "hvg" else
96
+ np.load(os.path.join(CACHE, "rnafm", f"{e}_hest.npy"), mmap_mode="r"))[hsp]
97
+ report("RNA", e, "HEST", cos(Fr[e], ref))
98
+ E.release()
99
+
100
+
101
+ # ------------------------------------------------------------------ mIF (whole ORION set: dataset-level standardisation)
102
+ class LazyIF:
103
+ """(N, 16, 333, 333) ORION mIF tiles read on demand (uint8 tifs, 17 channels -> MIF_MARKERS)"""
104
+
105
+ def __init__(self, df):
106
+ import tifffile
107
+ self.paths, self.rd = [os.path.join(ORION, p) for p in df.target_path], tifffile.imread
108
+ V = E._get("virtues")[0]
109
+ names = [m for m, _ in V.MARKERS]
110
+ self.ch = [names.index(m) for m in MIF_MARKERS]
111
+
112
+ def __len__(self):
113
+ return len(self.paths)
114
+
115
+ def __getitem__(self, s):
116
+ return np.stack([self.rd(p)[..., self.ch].transpose(2, 0, 1) for p in self.paths[s]])
117
+
118
+
119
+ Fm = E.mif_feats(LazyIF(tiles), tiles.in_slide_name.values)
120
+ report("mIF", "virtues", "ORION (all)", cos(Fm, np.load(os.path.join(CACHE, "if_virtues_m16.npy"), mmap_mode="r")[:, 0]))
121
+ E.release()
122
+
123
+ # ------------------------------------------------------------------ end-to-end on held-out fold-0 test items
124
+ UO, MO = np.load(os.path.join(CACHE, "he_uni2.npy"), mmap_mode="r"), np.load(os.path.join(CACHE, "he_medvtok.npy"), mmap_mode="r")
125
+ IFc = np.load(os.path.join(CACHE, "if_virtues_m16.npy"), mmap_mode="r")
126
+ ot, U, M = he_raw["orion"]
127
+ e2e = {"H&E<->mIF (ORION)": ((E.he_from_feats(U, M), E.mif_from_feats(Fm[ot])),
128
+ (E.he_from_feats(UO[ot, 0], MO[ot, 0]), E.mif_from_feats(IFc[ot, 0])))}
129
+ hsp, U, M = he_raw["hest"]
130
+ ref_r = {e: (np.load(os.path.join(CACHE, "hest", "hvg.npy"), mmap_mode="r") if e == "hvg" else
131
+ np.load(os.path.join(CACHE, "rnafm", f"{e}_hest.npy"), mmap_mode="r"))[hsp] for e in RNA_E}
132
+ e2e["H&E<->RNA (HEST)"] = ((E.he_from_feats(U, M), E.rna_from_feats(Fr)),
133
+ (E.he_from_feats(np.load(os.path.join(CACHE, "hest", "he_uni2.npy"), mmap_mode="r")[hsp, 0],
134
+ np.load(os.path.join(CACHE, "hest", "he_medvtok.npy"), mmap_mode="r")[hsp, 0]),
135
+ E.rna_from_feats(ref_r)))
136
+ _, U, M = he_raw["tcga"]
137
+ ref_t = {e: np.load(os.path.join(CACHE, f"alltxt_text_{e}.npy"), mmap_mode="r")[txt_ids, 0] for e in TXT_E}
138
+ e2e["H&E<->text (TCGA)"] = ((E.he_from_feats(U, M), E.text_from_feats(Ft)),
139
+ (E.he_from_feats(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"), mmap_mode="r")[ti, 0],
140
+ np.load(os.path.join(CACHE, "allimg_he_medvtok.npy"), mmap_mode="r")[ti, 0]),
141
+ E.text_from_feats(ref_t)))
142
+ for pair, v in e2e.items():
143
+ (a_raw, b_raw), (a_c, b_c) = v
144
+ rows.append({"check": "end-to-end", "modality": pair, "n": len(a_raw), "R@1_raw": r1_g100(a_raw, b_raw),
145
+ "R@1_cached": r1_g100(a_c, b_c), "emb_cos_median_a": float(np.median(cos(a_raw, a_c))),
146
+ "emb_cos_median_b": float(np.median(cos(b_raw, b_c)))})
147
+ print(f"{pair:20s} n={len(a_raw)} g100 R@1 raw-input {rows[-1]['R@1_raw']:.3f} | cached features "
148
+ f"{rows[-1]['R@1_cached']:.3f}; embedding cos median {rows[-1]['emb_cos_median_a']:.4f} / "
149
+ f"{rows[-1]['emb_cos_median_b']:.4f}", flush=True)
150
+ pd.DataFrame(rows).to_csv(os.path.join(ROOT, "results", "embed_check.csv"), index=False)
151
+ print(f"done [{time.time() - t0:.0f}s]")
54_make_examples.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build examples/: a small raw-input sample (fold-0 TEST items) + the cached training features of the same items, so
2
+ that example_check.py can test embed.py end to end without the full datasets.
3
+
4
+ .venv/bin/python -I 54_make_examples.py [n=64]
5
+
6
+ examples/
7
+ orion_he/*.png, orion_mif.npz (tiles uint8 (n, 16, 333, 333) in embed.MIF_MARKERS order, slide) ORION H&E + mIF
8
+ hest_he.npy (n, 224, 224, 3) uint8, hest_counts.npz (raw counts, spots x genes), hest_genes.csv HEST H&E + RNA
9
+ tcga_he/*.png, tcga_captions.csv TCGA/PathGen H&E + text
10
+ ref_feats.npz cached encoder features (view 0) of the same items: the inputs the model was trained / tested on
11
+ items.csv which items (global indices into cache/)
12
+ """
13
+ import os
14
+ import shutil
15
+ import sys
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ import scipy.sparse as sp
20
+
21
+ ROOT = os.path.dirname(os.path.abspath(__file__))
22
+ sys.path.insert(0, ROOT)
23
+ from common import CACHE, ORION, load_all_tiles # noqa: E402
24
+ from embed import MIF_MARKERS, RNA_E, TXT_E, _load_script # noqa: E402
25
+ from rna_sets import load # noqa: E402
26
+
27
+ N = int(sys.argv[1]) if len(sys.argv) > 1 else 64
28
+ OUT = os.path.join(ROOT, "examples")
29
+ shutil.rmtree(OUT, ignore_errors=True)
30
+ os.makedirs(os.path.join(OUT, "orion_he")); os.makedirs(os.path.join(OUT, "tcga_he"))
31
+ TI = np.load(os.path.join(ROOT, "models", "ej_ad_mv_fold0", "test_items.npz"))
32
+ rng = np.random.default_rng(0)
33
+ ld = lambda *p: np.load(os.path.join(*p), mmap_mode="r")
34
+ ref, items = {}, []
35
+
36
+ # ORION: H&E PNG + 16-marker mIF
37
+ import tifffile # noqa: E402
38
+
39
+ tiles = load_all_tiles()
40
+ ot = np.sort(rng.choice(TI["orion"], N, replace=False))
41
+ names = [n for n, _ in _load_script("14_extract_if_fm.py", [sys.argv[0], "virtues", "--no_probe"]).MARKERS]
42
+ ch = [names.index(m) for m in MIF_MARKERS]
43
+ mif = np.stack([tifffile.imread(os.path.join(ORION, p))[..., ch].transpose(2, 0, 1) for p in tiles.target_path.values[ot]])
44
+ for i, p in zip(ot, tiles.image_path.values[ot]):
45
+ shutil.copy(os.path.join(ORION, p), os.path.join(OUT, "orion_he", f"{i:05d}.png"))
46
+ np.savez_compressed(os.path.join(OUT, "orion_mif.npz"), tiles=mif.astype(np.uint8), slide=tiles.in_slide_name.values[ot])
47
+ ref |= {"orion_uni2": ld(CACHE, "he_uni2.npy")[ot, 0], "orion_medvtok": ld(CACHE, "he_medvtok.npy")[ot, 0],
48
+ "orion_virtues": ld(CACHE, "if_virtues_m16.npy")[ot, 0]}
49
+ items += [{"set": "orion", "idx": int(i)} for i in ot]
50
+
51
+ # HEST: spot crops + raw counts
52
+ hs = np.sort(rng.choice(TI["hest"], N, replace=False))
53
+ np.save(os.path.join(OUT, "hest_he.npy"), ld(CACHE, "hest", "he_img.npy")[hs])
54
+ X, genes = load("hest")
55
+ sp.save_npz(os.path.join(OUT, "hest_counts.npz"), sp.csr_matrix(X[hs]))
56
+ pd.DataFrame({"gene": genes}).to_csv(os.path.join(OUT, "hest_genes.csv"), index=False)
57
+ ref |= {"hest_uni2": ld(CACHE, "hest", "he_uni2.npy")[hs, 0], "hest_medvtok": ld(CACHE, "hest", "he_medvtok.npy")[hs, 0],
58
+ **{f"hest_{e}": (ld(CACHE, "hest", "hvg.npy") if e == "hvg" else ld(CACHE, "rnafm", f"{e}_hest.npy"))[hs]
59
+ for e in RNA_E}}
60
+ items += [{"set": "hest", "idx": int(i)} for i in hs]
61
+
62
+ # TCGA / PathGen: patch PNG + its paired caption
63
+ k = np.sort(rng.choice(len(TI["tcga_img"]), N, replace=False))
64
+ ti, tt = TI["tcga_img"][k], TI["tcga_txt"][k]
65
+ I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
66
+ T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
67
+ for i in ti:
68
+ shutil.copy(I.image_path.values[i], os.path.join(OUT, "tcga_he", f"{i:05d}.png"))
69
+ pd.DataFrame({"img_idx": ti, "txt_idx": tt, "file": [f"{i:05d}.png" for i in ti],
70
+ "caption": T.caption.fillna("").astype(str).values[tt]}).to_csv(os.path.join(OUT, "tcga_captions.csv"), index=False)
71
+ ref |= {"tcga_uni2": ld(CACHE, "allimg_he_uni2.npy")[ti, 0], "tcga_medvtok": ld(CACHE, "allimg_he_medvtok.npy")[ti, 0],
72
+ **{f"tcga_{e}": ld(CACHE, f"alltxt_text_{e}.npy")[tt, 0] for e in TXT_E}}
73
+ items += [{"set": "tcga", "idx": int(i), "txt_idx": int(j)} for i, j in zip(ti, tt)]
74
+
75
+ np.savez_compressed(os.path.join(OUT, "ref_feats.npz"), **{k_: np.asarray(v, np.float32) for k_, v in ref.items()})
76
+ pd.DataFrame(items).to_csv(os.path.join(OUT, "items.csv"), index=False)
77
+ os.system(f"du -sh {OUT}")
LICENSE.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Licence
2
+
3
+ The trained weights in `models/` and the code in this repository are released for **non-commercial research use only**.
4
+
5
+ The weights were trained on features of frozen third-party encoders and on third-party datasets; any use must also
6
+ comply with their terms:
7
+
8
+ - Encoders: UNI2-h (MahmoodLab, CC-BY-NC-ND 4.0, gated), MedVTok (MIT; aligned to CONCH v1.5, CC-BY-NC-ND 4.0),
9
+ SE-600M (Arc Institute, non-commercial), AIDO.Cell (GenBio AI, non-commercial), Tahoe-x1 (Apache-2.0),
10
+ VirTues (MIT), KEEP (MIT), BiomedCLIP (MIT), Qwen3-Embedding (Apache-2.0).
11
+ - Data: ORION CRC, HEST-1k (CC-BY-NC-SA 4.0), TCGA, PathGen-1.6M, spatialLIBD (DLPFC); virtual data from
12
+ STPath and MIPHEI (non-commercial academic) and captions generated with an Azure OpenAI model (evaluation only).
13
+
14
+ No warranty. Not for clinical use.
README.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: non-commercial-research
4
+ license_link: LICENSE.md
5
+ library_name: pytorch
6
+ pipeline_tag: feature-extraction
7
+ tags:
8
+ - pathology
9
+ - multimodal
10
+ - imagebind
11
+ - spatial-transcriptomics
12
+ - multiplex-immunofluorescence
13
+ - contrastive-learning
14
+ ---
15
+
16
+ # 四模态病理嵌入模型(H&E · mIF · RNA · 文本)
17
+
18
+ 这个模型把四种模态嵌入到同一个 1536 维空间里:
19
+ - **H&E** 图像;
20
+ - **mIF**:多重免疫荧光,即蛋白;
21
+ - **RNA**:空间转录组 spot 表达;
22
+ - **病理文本**。
23
+
24
+ 做法和 ImageBind 一样。冻结的 UNI2-h 图像空间是锚点,其余每种模态只和 H&E 配对训练。mIF↔RNA、mIF↔文本、文本↔RNA 之间的对齐是"涌现"出来的,没有直接训练过。
25
+
26
+ - **训练数据:** 只用真实的配对数据。
27
+ - **评估数据:** 虚拟数据和合成数据只用于评估,从不参与训练。
28
+
29
+ | 内容 | 位置 |
30
+ |---|---|
31
+ | 发布版模型 | `models/ej_ad_mv/` |
32
+ | fold-0 模型(带留出测试集) | `models/ej_ad_mv_fold0/` |
33
+ | 推理接口 | `embed.py` |
34
+ | 训练 | `52_final_model.py` |
35
+ | 训练用的编码器特征和数据划分 | 私有数据集仓库 [`Pathstudy/fourmodal-pathology-features`](https://huggingface.co/datasets/Pathstudy/fourmodal-pathology-features) |
36
+ | 图文报告 | `report/fourmodal_report.html` |
37
+ | 全部实验记录 | `RESULTS.md` |
38
+
39
+ ## 1. 数据
40
+
41
+ 没有任何数据集同时测量了全部四种模态,所以每种模态各用一个和 H&E 配对的真实数据集。
42
+
43
+ | 数据集 | 配对 | 规模 | 用途 |
44
+ |---|---|---|---|
45
+ | ORION CRC(1% 图块子集) | H&E – mIF(17 通道,使用 16 个标记) | 3,187 个 333×333 图块,约 40 名病人 | 训练 + 测试 |
46
+ | HEST-1k Visium | H&E – RNA(14,802 基因) | 9,484 spots,57 项研究,16 个器官 | 训练 + 测试 |
47
+ | TCGA / PathGen-1.6M | H&E – 文本 | 6,144 张图像,6,115 条清洗后的描述 | 训练 + 测试 |
48
+ | STPath 虚拟 RNA / MIPHEI 虚拟 mIF / GPT 合成描述 | 给没有真实配对的模态对构建测试集 | ORION、TCGA 测试图块 | **只用于测试** |
49
+ | DLPFC(spatialLIBD,脑) | H&E – RNA | 482 spots,3 名供体 | 外部测试 |
50
+
51
+ - **划分:** 按病人划分 train/val/test。HEST 按研究划分,病人分组见 `splits/`。做 5 折交叉验证。
52
+ - **验证集:** val 只用于早停,所有报告的数字都是 test 上的。
53
+ - **虚拟模态的校准:** 每种虚拟模态先在同一批有真实数据的样本上校准,即用虚拟数据替换真实数据,看配对结果能否接近。
54
+
55
+ | 虚拟模态 | 校准结果(真实 → 虚拟) | 结论 |
56
+ |---|---|---|
57
+ | STPath 虚拟 RNA | 0.148 → 0.126 | 可用 |
58
+ | GPT 合成描述 | 0.219 → 0.162 | 可用,偏保守 |
59
+ | MIPHEI 虚拟 mIF | 配对准确率 0.92 → 0.55 | 可用 |
60
+ | GigaTIME 虚拟 mIF | 0.644 → 0.038 | 不可用,已弃用 |
61
+
62
+ ## 2. 最佳架构:EJ-ad+MV(两阶段,3 种子适配器平均)
63
+
64
+ ```
65
+ H&E ──► UNI2-h (frozen, 1536) ───────────────┬──────────────► (+) ──► z_HE
66
+ └► MedVTok (frozen, 1152) ─► Adapter×3 ◄┘ (zero-init, mean of 3 seeds) ┐
67
+ mIF ──► VirTues (16 markers, 512) ─► MLP head ─────────────────────► z_mIF │ 1536-d
68
+ RNA ──► hvg · Tahoe-x1 · SE-600M · AIDO.Cell ─► 4 linear heads ─► fuse ─► z_RNA │ shared
69
+ text ──► KEEP · BiomedCLIP · Qwen3-Emb-0.6B ─► 3 linear heads ─► fuse ─► z_text ┘ space
70
+ ```
71
+
72
+ **第一阶段:每个编码器一个映射头。**
73
+ - 每个映射头用 InfoNCE 对齐到冻结的 UNI2 CLS 特征。
74
+ - 文本用多正样本 InfoNCE:同一张图像的多条文本都算正样本。
75
+ - RNA 和文本是后期融合:`z = normalise(Σ_e normalise(head_e(x_e)))`。
76
+
77
+ **第二阶段:只训练 H&E 一侧。**
78
+ - 冻结所有映射头,只训练一个零初始化的残差适配器:`H&E = UNI2 + mean_s A_s([UNI2, c·MedVTok])`。
79
+ - 损失:三种配对的 InfoNCE 之和,加上对 UNI2 相似度结构的蒸馏(λ=1)。
80
+ - 优化:AdamW,学习率 1e-4,1000 步,OneCycle 学习率调度。
81
+ - 选择规则:每个种子按验证集上三种 R@1 的几何平均早停,再把 3 个种子的适配器残差取平均。
82
+
83
+ **为什么这样设计:** mIF、RNA、文本的嵌入和第一阶段完全一样,所以涌现配对不受第二阶段影响,只有 H&E 这一侧变好。以下结论都来自 5 折配对检验,详见 `RESULTS.md`:
84
+
85
+ | 设计选择 | 依据 |
86
+ |---|---|
87
+ | UNI2-h 做锚点 | 在任何训练配对上都没有被显著超过。GigaPath 在 RNA 上低 0.035;CONCH 在 H&E↔mIF 上低 0.155;MedVTok 当锚点在 H&E↔RNA 上只有 0.131(UNI2 是 0.192)。 |
88
+ | 多编码器后期融合 | 最大的单项提升:H&E↔RNA +0.037,H&E↔文本 +0.062,4 个涌现配对全部提升(5/5 折)。拼接(早期融合)和单编码器多种子平均都达��到这个效果。 |
89
+ | 只动 H&E 侧的联合训练 | 共享 H&E 头会让涌现配对损失 26–74%;只训练残差适配器时,H&E↔mIF 提升 0.10,涌现配对完全不变。 |
90
+ | MedVTok 作为额外输入 | H&E↔mIF +0.015,H&E↔文本 +0.016。换成同尺寸的 UNI2 随机投影没有提升。 |
91
+ | 3 种子平均 | 单次早停的步数在 100–900 步之间波动,同一设置重跑 H&E↔mIF 可以差 0.05。3 个种子平均后,H&E↔文本再提高 0.015(5/5 折,p=0.02)。 |
92
+ | RNA 和文本用线性头 | 训练配对持平,涌现配对 +5%。mIF 保留 MLP 头。 |
93
+ | 编码器挑选 | RNA:4 个编码器融合(+0.037)接近 10 个融合(+0.044)。文本:3 个融合(0.299)接近 5 个融合(0.303)。mIF:VirTues 的跨模态迁移比从头训练的 CNN 好得多。 |
94
+
95
+ ## 3. 结果(5 折测试集均值,100 个样本的检索库,随机水平 R@1 = 0.01)
96
+
97
+ | 配对 | 类型 | R@1 | R@10 | 配对准确率 |
98
+ |---|---|---|---|---|
99
+ | H&E ↔ mIF(ORION) | 训练 | **0.801** ± 0.043 | 0.991 | 0.942 |
100
+ | H&E ↔ RNA(HEST) | 训练 | **0.203** ± 0.033 | 0.625 | 0.258 |
101
+ | H&E ↔ 文本(TCGA) | 训练 | **0.323** ± 0.007 | 0.813 | 0.347 |
102
+ | mIF ↔ RNA(虚拟) | 涌现 | 0.141 ± 0.007 | 0.657 | 0.194 |
103
+ | mIF ↔ 文本(合成) | 涌现 | 0.089 ± 0.008 | 0.491 | 0.104 |
104
+ | 文本 ↔ RNA(虚拟) | 涌现 | 0.095 ± 0.006 | 0.436 | 0.100 |
105
+ | 文本 ↔ mIF(MIPHEI 虚拟) | 涌现 | 0.046 ± 0.005 | 0.271 | 0.051 |
106
+ | H&E ↔ RNA(DLPFC 脑,外部) | 外部 | 0.012 | 0.112 | 0.012 |
107
+
108
+ 各阶段的提升见下表(训练配对 R@1,5 折均值)。逐折结果在 `results/final_cv_long.csv`,各阶段的完整比较在 `results/medvtok_*.csv`、`results/joint_ens_*.csv`。
109
+
110
+ | 阶段 | H&E↔mIF | H&E↔RNA | H&E↔文本 |
111
+ |---|---|---|---|
112
+ | 单编码器(S4) | 0.675 | 0.155 | 0.236 |
113
+ | 多编码器融合(E,第一阶段) | 0.675 | 0.192 | 0.299 |
114
+ | + H&E 适配器 | 0.776 | 0.201 | 0.299 |
115
+ | + MedVTok | 0.792 | 0.200 | 0.315 |
116
+ | **最终(3 种子平均)** | **0.801** | **0.203** | **0.323** |
117
+
118
+ ## 4. 使用方法
119
+
120
+ 环境:Python 3.11 和一块 CUDA GPU。依赖见 `requirements.txt`。所有脚本都用 `python -I` 运行。
121
+
122
+ ```bash
123
+ pip install -r requirements.txt
124
+ ```
125
+
126
+ **用预先算好的编码器特征**(不需要任何冻结编码器):
127
+
128
+ ```python
129
+ from embed import Embedder
130
+ E = Embedder("models/ej_ad_mv")
131
+ z_he = E.he_from_feats(uni2_feats, medvtok_feats) # (N,1536) / (N,1152)
132
+ z_mif = E.mif_from_feats(virtues_feats) # (N,512)
133
+ z_rna = E.rna_from_feats({"hvg": X, "tahoex1": X, "se600m": X, "aidocell": X})
134
+ z_text = E.text_from_feats({"keep": X, "biomedclip": X, "qwen3-0.6b": X})
135
+ sim = z_he @ z_mif.T # 输出都是 L2 归一化的 (N, 1536) CPU tensor
136
+ ```
137
+
138
+ **用原始输入**。需要先运行 `bash setup_encoders.sh` 下载第三方代码和权重。UNI2-h 是受限仓库,需要账号有访问权限,或者设置 `UNI2_PATH`。
139
+
140
+ ```python
141
+ z = E.he(images) # PIL 图像列表或 (N,H,W,3) uint8;约 0.5 µm/px,视野 112–166 µm
142
+ z = E.text(["Tumor cells with ...", ...])
143
+ z = E.rna(counts, genes) # 原始计数(spots × genes),HGNC 基因名
144
+ z = E.mif(tiles, slide) # (N,16,H,W) 8 位强度,通道顺序为 embed.MIF_MARKERS,每个图块附 slide id
145
+ ```
146
+
147
+ 注意:
148
+ - **mIF 标准化:** 统计量是在一次调用给定的全部图块上计算的,所以请一次传入整张切片或整个数据集。
149
+ - **新平台的 RNA 数据:** 建议用 `zscore="self"`,并且一次至少传入几百个 spots。
150
+
151
+ ## 5. 复现(已验证:从 HF 下载到全新目录 + 全新虚拟环境)
152
+
153
+ ```bash
154
+ PYTHON=python bash reproduce.sh # 约 20 分钟(单卡 RTX A5000)
155
+ RAW=1 PYTHON=python bash reproduce.sh # 再加上原始输入检查(需要先运行 setup_encoders.sh)
156
+ ```
157
+
158
+ `reproduce.sh` 依次执行:
159
+ 1. 下载特征仓库,得到 `cache/` 和 `splits/`,共约 1.1 GB。
160
+ 2. `evaluate_saved.py`:用已发布的 fold-0 模型在留出测试集上重算结果,必须和 `models/ej_ad_mv_fold0/test.csv` 完全一致。
161
+ 3. 从特征重新训练 fold 0 和 5 折交叉验证,然后用 `compare_reproduction.py` 和已发布的数字对比。
162
+ - 在相同硬件和相同依赖版本下,结果逐位一致。2026-10-28 在 RTX A5000 上用全新的 `requirements.txt` 环境验证:fold 0 和 5 折的全部配对差值都是 0。
163
+ - 换 GPU 或库版本时,第二阶段(H&E 适配器)可能在种子波动范围内变化,约 ±0.01;`compare_reproduction.py` 的容差就是按这个设的。
164
+ 4. 重新训练发布版模型,输出到 `models/repro_release/`。
165
+ 5. 加 `RAW=1` 时运行 `example_check.py`:把 `examples/`(每个数据集 64 个 fold-0 测试样本)的原始输入送进 `embed.py`,检查编码器特征和缓存特征是否一致,再检查检索结果。
166
+
167
+ 已发布的结果不会被覆盖:复现的输出写到 `repro_*` 名下。
168
+
169
+ ## 6. 局限
170
+
171
+ - **外部泛化差:** 脑组织 DLPFC 上处于随机水平。训练用的 HEST 里脑组织也只有 R@1 0.021。所以模型只适用于训练数据覆盖过的组织类型。
172
+ - **H&E↔文本的 MedVTok 增益可能偏高:** MedVTok 预训练时见过 PathGen 的图像,包括我们 TCGA 测试集的图像(只有图像,没有文本)。ORION 上的 mIF 增益不受影响。
173
+ - **涌现配对的数值偏保守:** 涌现配对的一侧是虚拟或合成数据,所以这些数值是保守估计。
174
+ - **实例级涌现对齐仍然弱:** mIF↔RNA 只有 0.141。概念级的对齐(AUROC 0.6–0.7)更可靠。
175
+
176
+ ## 7. 许可证
177
+
178
+ **只能用于非商业研究。** 本仓库的权重是在以下冻结编码器的特征上训练的,使用时需要分别遵守它们的许可:
179
+
180
+ | 编码器 | 许可 |
181
+ |---|---|
182
+ | UNI2-h | CC-BY-NC-ND,受限访问 |
183
+ | MedVTok | MIT;它对齐的 CONCH v1.5 是 CC-BY-NC-ND |
184
+ | SE-600M、AIDO.Cell | 非商业 |
185
+ | Tahoe-x1 | Apache-2.0 |
186
+ | VirTues、KEEP、BiomedCLIP | MIT |
187
+ | Qwen3-Embedding | Apache-2.0 |
188
+
189
+ 数据本身也有各自的条款,见各数据集的原始发布:ORION、HEST-1k(CC-BY-NC-SA)、TCGA、PathGen-1.6M、spatialLIBD。
190
+
191
+ ## 8. 文件
192
+
193
+ ```
194
+ embed.py 推理接口 (Bind4 模型 + Embedder)
195
+ 52_final_model.py 训练 (cv / fold0 / release)
196
+ evaluate_saved.py 特征级评估 (fold-0 测试, 与 test.csv 完全一致)
197
+ example_check.py 原始输入端到端检查 (examples/)
198
+ reproduce.sh, compare_reproduction.py, setup_encoders.sh, requirements.txt
199
+ 14_ 43_ 45_ 45b_ 50_ *.py, biomedclip_text.py, common.py, rna_sets.py 冻结编码器的特征提取 (embed.py 调用)
200
+ 53_embed_check.py, 54_make_examples.py 在完整原始数据上做的检查 / 生成 examples/ (需要原始数据集)
201
+ models/ ej_ad_mv (发布版), ej_ad_mv_fold0 (+ test.csv, test_items.npz)
202
+ results/ 所有实验的 csv 结果
203
+ experiments/ 01_–51_ 全部实验脚本 (记录用; 依赖原始数据, 不在本仓库)
204
+ RESULTS.md 完整实验记录; report/fourmodal_report.html 图文报告
205
+ ```
RESULTS.md ADDED
@@ -0,0 +1,778 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Results — ImageBind-style multimodal binding on ORION (1% subsample)
2
+
3
+ Metric: **R@1 on random 100-tile galleries** (chance = 0.01), mean over the two retrieval directions unless noted;
4
+ patient 5-fold reported as mean ± std over folds. "Pairing acc" = one-to-one Hungarian matching on the full test set.
5
+ Your split (`orion_official`) has 109 test tiles from 2 slides → single, noisy estimate.
6
+
7
+ ## 1. Base experiment (H&E / IF / nuclei / markers, ImageBind-Huge H&E anchor) — 22 runs
8
+ Your split, R@1 (full 109-tile gallery):
9
+
10
+ | pair | anchor loss (ImageBind) | all-pairs loss |
11
+ |---|---|---|
12
+ | H&E→IF | **0.49** (pair acc 0.73) | 0.38 |
13
+ | H&E→nuclei | 0.36 | 0.28 |
14
+ | H&E→markers | 0.16 | 0.15 |
15
+ | IF→nuclei (emergent) | 0.41 | **0.54** |
16
+ | IF→markers (emergent) | 0.26 | **0.70** |
17
+ | nuclei→markers (emergent) | 0.15 | 0.29 |
18
+
19
+ - Emergent pairs (never trained together) match far above chance.
20
+ - Training the non-H&E pairs directly (all-pairs) boosts them but costs H&E→IF.
21
+ - Patient 5-fold ≈ leave-one-IF-batch-out → small batch effect; random tile split is optimistic.
22
+
23
+ ## 2. H&E encoder ablation (anchor loss) — 33 runs
24
+ Mean R@1 (g100) over H&E pairs / emergent pairs:
25
+
26
+ | H&E encoder | your split | patient 5-fold |
27
+ |---|---|---|
28
+ | ImageBind-Huge | 0.29 / 0.25 | 0.33 ± 0.01 / 0.30 ± 0.00 |
29
+ | PLIP (stand-in for CONCH) | 0.35 / 0.29 | 0.37 ± 0.01 / 0.31 ± 0.01 |
30
+ | Phikon-v2 (stand-in for GigaPath) | **0.41** / 0.31 | 0.45 ± 0.02 / 0.36 ± 0.01 |
31
+ | **UNI2-h** | 0.40 / **0.34** | **0.48 ± 0.02 / 0.37 ± 0.01** |
32
+
33
+ CONCH and GigaPath: HF access refused (GatedRepoError); code ready (`04b_extract_anchor_fm.py`).
34
+
35
+ ## 3. Encoder benchmark (H&E = UNI2 frozen; mIF / markers / text) — 18 runs
36
+ Text = templated per-tile descriptions generated from marker counts + nuclei count (no real text exists for ORION).
37
+
38
+ ### Trained pairs (H&E ↔ modality), R@1 g100
39
+ | modality | encoder | your split | patient 5-fold |
40
+ |---|---|---|---|
41
+ | mIF | ResNet18 (17-ch stem) | 0.59 | 0.64 ± 0.01 |
42
+ | mIF | **ConvNeXt-Tiny** (17-ch stem) | **0.73** (pair acc 0.95) | **0.78 ± 0.02** |
43
+ | mIF | per-channel encoder (shared CNN + channel transformer) | 0.51 | 0.62 ± 0.01 |
44
+ | markers | linear | 0.18 | 0.22 ± 0.01 |
45
+ | markers | **MLP** | **0.31** | **0.39 ± 0.03** |
46
+ | markers | FT-Transformer | 0.21 | 0.30 ± 0.02 |
47
+ | text | PLIP text | 0.17 | 0.20 ± 0.02 |
48
+ | text | BiomedCLIP text | 0.16 | 0.19 ± 0.02 |
49
+ | text | ImageBind text | **0.22** | 0.21 ± 0.02 |
50
+ | text | PLIP / ImageBind **zero-shot** (no training) | ≈0.01–0.02 | ≈0.01–0.02 (chance) |
51
+
52
+ ### Cross pairs (never trained together), best combos, patient 5-fold R@1 g100
53
+ | pair | best combo | R@1 |
54
+ |---|---|---|
55
+ | mIF ↔ markers | ConvNeXt-T + MLP / ResNet18 + MLP | 0.48 ± 0.03 |
56
+ | markers ↔ text | MLP + PLIP / MLP + ImageBind | 0.43 ± 0.07 / 0.42 ± 0.03 |
57
+ | mIF ↔ text | ResNet18/ConvNeXt-T + ImageBind | 0.25 ± 0.01 |
58
+
59
+ ### Takeaways
60
+ - **mIF**: ConvNeXt-Tiny clearly best (+0.14 over ResNet18). The channel-agnostic per-channel encoder is not better here
61
+ (fixed 17-marker panel; it would matter for transfer across panels).
62
+ - **Markers**: the plain MLP wins; FT-Transformer < MLP, linear worst. MLP markers also give the best cross-modal pairs.
63
+ - **Text**: the three text towers are within noise (0.19–0.21); ImageBind text slightly ahead. Zero-shot pathology CLIP
64
+ (PLIP) cannot match these synthetic marker-based descriptions to H&E (≈ chance) → training a projection is required.
65
+ - Text is derived from markers, so markers↔text is partly by construction; H&E↔text measures how well H&E predicts
66
+ coarse cell composition. Real paired text (PathGen images or ORION reports) is needed for a true text evaluation.
67
+ - Gene expression: none in ORION; the marker-encoder benchmark would be repeated on HEST/DLPFC for RNA.
68
+
69
+ Files: `results/bench_summary.csv`, `results/bench_long.csv`, `results/anchor_ablation_*.csv`, `results/summary_*.csv`.
70
+
71
+ ---
72
+ # v2 / v3 — re-sampled ORION (rsync 2026-10-11) + real text + modality-specific foundation models
73
+ v1 sections above used the old ORION 1% sample (archived in `v1_old_orion_sample/`). Same slide split, new tiles.
74
+ H&E anchor = UNI2 (frozen) throughout. R@1 on 100-tile galleries (chance 0.01); patient 5-fold mean ± std.
75
+
76
+ ## Text bound to H&E with real captions (PathGen-1.6M, patient split, 255 test pairs) — `12_train_text_pathgen.py`
77
+ | text tower (frozen) + MLP head | H&E→text R@1 (full 255) | g100 | zero-shot (own image tower) |
78
+ |---|---|---|---|
79
+ | BiomedCLIP | **0.17** | **0.29** | – |
80
+ | PLIP | 0.13 | 0.23 | 0.035 |
81
+ | ImageBind | 0.11 | 0.22 | 0.024 |
82
+ | PathGen-CLIP-L | 0.13 | 0.24 | 0.19* |
83
+ *PathGen-CLIP-L was trained on PathGen-1.6M → its PathGen numbers are not held-out.
84
+
85
+ ## mIF / markers: from-scratch vs modality-specific foundation models — `17_bench_fm.py`, `18_eval_fm.py`
86
+ | pair | encoder | your split (pair acc) | patient 5-fold |
87
+ |---|---|---|---|
88
+ | H&E↔mIF | ConvNeXt-T (scratch) | **0.73** (0.96) | **0.76 ± 0.02** |
89
+ | H&E↔mIF | **VirTues** sp32 (frozen) + MLP | 0.67 (0.95) | 0.73 ± 0.03 |
90
+ | H&E↔markers | MLP on raw markers | **0.28** | **0.38 ± 0.03** |
91
+ | H&E↔markers | Geneformer V2 (frozen) + MLP | 0.12 | 0.17 ± 0.01 |
92
+ | H&E↔markers | scGPT human (frozen) + MLP | 0.11 | 0.18 ± 0.01 |
93
+ | mIF↔markers (emergent) | ConvNeXt-T + MLP | 0.37 | 0.48 ± 0.02 |
94
+ | mIF↔markers (emergent) | **VirTues + MLP** | **0.51 (0.93)** | **0.66 ± 0.02** |
95
+ | mIF↔markers (emergent) | VirTues + scGPT / + Geneformer | 0.22 / 0.19 | 0.29 / 0.26 |
96
+
97
+ ## Emergent text ↔ mIF / markers (text head trained only on PathGen; never sees ORION/IF/markers) — patient 5-fold AUROC (Spearman), 7 concept prompts
98
+ | text | H&E (UNI2) | mIF ConvNeXt | mIF VirTues | markers MLP | markers scGPT | markers Geneformer |
99
+ |---|---|---|---|---|---|---|
100
+ | **PLIP** | 0.69 (0.32) | 0.69 (0.31) | 0.71 (0.34) | 0.76 (0.46) | **0.79 (0.49)** | 0.74 (0.41) |
101
+ | ImageBind | 0.67 (0.32) | 0.65 (0.27) | 0.67 (0.31) | 0.73 (0.41) | 0.75 (0.45) | 0.70 (0.35) |
102
+ | BiomedCLIP | 0.66 (0.25) | 0.66 (0.24) | 0.68 (0.26) | 0.71 (0.29) | 0.72 (0.32) | 0.68 (0.27) |
103
+ | PathGen-CLIP-L | 0.59 (0.10) | 0.62 (0.13) | 0.63 (0.16) | 0.66 (0.18) | 0.69 (0.25) | 0.64 (0.18) |
104
+ Zero-shot H&E (own image tower, no binding): PathGen-CLIP-L 0.75 (0.46), PLIP 0.73 (0.41), ImageBind 0.65 (0.28).
105
+
106
+ ### Takeaways
107
+ - **mIF**: frozen VirTues ≈ ConvNeXt from scratch on H&E↔mIF (−0.03, within ~1 std), but its embedding transfers much better
108
+ to markers through the shared space (emergent mIF↔markers 0.66 vs 0.48) — the pretrained, marker-aware mIF model is the
109
+ better choice for a multi-modal space.
110
+ - **markers**: Geneformer / scGPT are clearly worse than an MLP on raw values for retrieval: both models only see the rank
111
+ order of the 16 mapped genes in a tile (abundance is lost; ridge recovers marker props at ρ≈0.69 vs 0.998 raw).
112
+ scGPT markers nevertheless give the best emergent text↔markers probe (PLIP 0.79). Single-cell RNA models are not a good fit
113
+ for tile-level protein proportions; they should be tested on real expression (HEST/DLPFC).
114
+ - **text**: PLIP text head transfers best to ORION concepts; BiomedCLIP best on PathGen retrieval. PathGen-CLIP-L is the
115
+ strongest zero-shot model, but its text features bound to UNI2 transfer worst (likely memorised PathGen captions → head
116
+ overfits). Recommended: UNI2 (H&E) + VirTues (mIF) + MLP (markers) + PLIP text head.
117
+ - Data note: `PD-1_prop` ≈ 1 in every tile (doesn't track PD-1_count) — the column looks corrupted.
118
+
119
+ Files: `results/fm_retrieval.csv`, `results/fm_text_probes.csv`, `runs/bench_fm/`, `runs/text_pathgen/`, logs in `logs/v2`, `logs/v3`.
120
+
121
+ ## Text ↔ H&E image pairing on ALL image-text pairs — `19_pairsets_prep.py`, `20_pairing.py`
122
+ Unified pool of every text-image source in PathGen-1.6M/ (12 sources; pathor1 skipped, images absent): 6144 unique
123
+ TCGA patches (4088 patients), 6480 pairs — caption 2697, qa_open 1799, qa_close 1391 (stem + correct option), summary 593.
124
+ Patient 5-fold CV; one text per image at test; test gallery ≈1229 pairs ("all"), 540 caption, 330 qa_open, 278 qa_close,
125
+ 119 summary. Metric: R@1 on 100-pair galleries (chance 0.01) / Hungarian pair acc on the full fold; mean of both directions.
126
+
127
+ | image enc → / text enc ↓ (text_head, eval=all, R@1 g100) | BiomedCLIP | PLIP | ImageBind | PathGen-CLIP-L* |
128
+ |---|---|---|---|---|
129
+ | UNI2 | **0.231** | 0.200 | 0.199 | 0.248 |
130
+ | Phikon-v2 | **0.234** | 0.204 | 0.197 | 0.254 |
131
+ | PLIP (image) | 0.170 | 0.147 | 0.139 | 0.180 |
132
+ | BiomedCLIP (image) | 0.163 | 0.138 | 0.136 | 0.168 |
133
+ | ImageBind (image) | 0.132 | 0.108 | 0.098 | 0.139 |
134
+ | PathGen-CLIP-L (image)* | 0.282 | 0.252 | 0.243 | 0.303 |
135
+ Zero-shot (same CLIP model): PLIP 0.090, BiomedCLIP 0.111, ImageBind 0.029, PathGen-CLIP-L* 0.188.
136
+ Mode means (excluding PathGen-CLIP-L): zero-shot 0.077 < two_heads 0.165 ≈ text_head 0.166.
137
+
138
+ By text type, UNI2 + BiomedCLIP text head (g100 / pair acc): caption 0.28 / 0.11, summary 0.29 / 0.29, qa_open 0.22 / 0.09,
139
+ qa_close 0.13 / 0.06 (short multiple-choice stems carry little patch-specific information).
140
+ *PathGen-CLIP-L was trained on PathGen-1.6M (most of this pool) → not held-out; shown for reference only.
141
+ Takeaways: pathology SSL image encoders (UNI2, Phikon-v2) + BiomedCLIP text (256-token context) are the best clean
142
+ combination; binding to a frozen image anchor (ImageBind recipe) is as good as training both projections; every trained
143
+ head beats zero-shot CLIP by ~2×.
144
+ Files: `results/pairing_long.csv`, `results/pairing_summary.csv`.
145
+
146
+ ## Four real modalities: H&E = image, text = text, mIF = protein, gene expression = RNA (HEST) — `21_`–`24_`
147
+ The former "markers" modality was computed from the mIF image (protein), so it is retired. Gene expression is now real
148
+ spatial transcriptomics: HEST-1k human Visium, 9484 spots (415 samples, 57 studies, 16 organs, 2046 bowel), 14,802
149
+ common genes, precomputed UNI2-h / GigaPath spot features. One frozen UNI2 space holds every modality:
150
+ H&E–mIF (ORION), H&E–text (PathGen), H&E–RNA (HEST) are trained; protein↔RNA, text↔RNA, text↔protein are emergent
151
+ (no dataset measures them together, so no 1:1 pairing accuracy is possible for them).
152
+
153
+ ### H&E ↔ RNA (trained), HEST study+patient 5-fold, test gallery ≈1897 spots; R@1 on 100-spot galleries (chance 0.01)
154
+ | RNA encoder | UNI2 anchor | GigaPath anchor |
155
+ |---|---|---|
156
+ | MLP on 2000 HVGs | 0.148 ± 0.026 | 0.113 ± 0.018 |
157
+ | scGPT-human (frozen) + MLP | 0.154 ± 0.028 | 0.118 ± 0.017 |
158
+ | Geneformer-V2 (frozen) + MLP | **0.159 ± 0.027** | 0.124 ± 0.010 |
159
+ On real expression, the single-cell foundation models slightly beat the MLP. On the derived markers it was the other way round.
160
+
161
+ ### Protein ↔ RNA (emergent, cross-dataset neighbour transfer; Spearman, shuffled-label chance ≈ 0.00)
162
+ ORION tile → k=20 nearest HEST spots. The predicted RNA of the matching gene is compared with the measured protein (marker⁺ cells / nuclei).
163
+
164
+ | query → gallery | all spots | bowel spots |
165
+ |---|---|---|
166
+ | **protein (VirTues mIF) → RNA (MLP / Geneformer)** | **0.45 / 0.44** | **0.42 / 0.42** |
167
+ | H&E (UNI2) → RNA head | 0.38 | 0.33 |
168
+ | H&E → H&E (image similarity only) | 0.36 | 0.33 |
169
+ | RNA → protein (reverse) | 0.18–0.22 | 0.20–0.23 |
170
+
171
+ Per marker (bowel, protein→RNA vs H&E→H&E):
172
+ - Pan-CK 0.81 vs 0.81; E-cad 0.81 vs 0.77
173
+ - Ki67 0.63 vs 0.49
174
+ - SMA 0.60 vs 0.49
175
+ - CD45 0.52 vs 0.35
176
+ - CD3e 0.38 vs 0.32
177
+ - CD68 0.32 vs 0.21
178
+ - CD4 0.29 vs 0.09
179
+ - CD20 0.29 vs 0.10
180
+ - PD-1 / PD-L1 < 0.2
181
+
182
+ ### Text ↔ RNA (emergent, PathGen text head; concept prompts vs RNA of the concept gene; AUROC top quartile)
183
+ | text encoder | RNA emb (best of 3), all / bowel | H&E (UNI2) of the same spots, all / bowel |
184
+ |---|---|---|
185
+ | PLIP | 0.64 / 0.65 | 0.58 / 0.54 |
186
+ | ImageBind | 0.64 / 0.63 | 0.60 / 0.56 |
187
+ | BiomedCLIP | 0.61 / 0.63 | 0.57 / 0.54 |
188
+ | PathGen-CLIP-L | 0.59 / 0.62 | 0.55 / 0.56 |
189
+ Bowel AUROC per concept (RNA/MLP): stroma 0.74, immune 0.69, tumour epithelium 0.66, proliferation 0.59, macrophage 0.52, vessels 0.51.
190
+ Files: `cache/hest/`, `runs/gene/`, `results/gene_retrieval.csv`, `gene_emergent_protein.csv`, `gene_emergent_text.csv`, logs in `logs/gene/`.
191
+
192
+ ## Virtual modalities predicted from H&E (to fill missing pairs; never used as test truth)
193
+
194
+ ### GigaTIME (H&E → 21-channel virtual mIF) — `25_gigatime_infer.py`, `cache/gigatime/`
195
+ Model: prov-gigatime/GigaTIME (code Apache-2.0; weights research-only). Run at native tile resolution (2× upsampling was worse).
196
+ Sets: ORION 3,187 tiles, TCGA text patches 6,144, gene-expression patches 1,640; 0 failures.
197
+
198
+ ORION validation against the real mIF of the same tile (Spearman of tile-mean intensity, n = 3,187):
199
+
200
+ | Channel (GigaTIME ~ ORION) | Spearman |
201
+ |---|---|
202
+ | DAPI ~ Hoechst | 0.91 |
203
+ | CK ~ Pan-CK | 0.74 |
204
+ | Ki67 | 0.73 |
205
+ | PD-1 | 0.73 (the ORION PD-1 proportion is corrupt, ≈1 everywhere) |
206
+ | CD3 ~ CD3e | 0.65 |
207
+ | CD4 | 0.58 |
208
+ | CD68 | 0.47 |
209
+ | CD8 ~ CD8a | 0.37 |
210
+ | CD20 | 0.36 |
211
+ | PD-L1 | 0.34 |
212
+ | Actin-D / Transgelin ~ SMA | 0.18 / 0.23 |
213
+
214
+ Use the per-tile means only. At the pixel level, the per-tile median Pearson is ≈0.1 for every channel except DAPI (0.50), so the virtual maps are not spatially reliable.
215
+
216
+ ### STPath (H&E → whole-transcriptome virtual RNA) — `26_stpath_infer.py`, `cache/stpath/`
217
+ Model: tlhuang/STPath (**no licence declared** — ask the authors before redistributing). Input: GigaPath tile features + coordinates + organ/tech tokens. Output: log1p counts for 14,791 genes.
218
+ Sets: HEST 9,484 spots, ORION 3,187 tiles, TCGA text patches 6,144.
219
+
220
+ HEST check (top-50 HVG Pearson):
221
+ - All spots: 0.40. This number is optimistic because STPath was trained on most of HEST.
222
+ - HEST-bench samples held out from STPath: 0.21.
223
+ - Within-sample: ≈0.09.
224
+
225
+ ORION check: predicted RNA vs the real protein of the same tile (Spearman, tiles with ≥20 nuclei; pooled / within-slide):
226
+
227
+ | Gene ~ protein | Pooled | Within slide |
228
+ |---|---|---|
229
+ | CDH1 ~ E-cad | 0.77 | 0.77 |
230
+ | EPCAM ~ Pan-CK | 0.76 | 0.74 |
231
+ | MKI67 ~ Ki67 | 0.68 | 0.64 |
232
+ | CD3E ~ CD3e | 0.45 | 0.42 |
233
+ | CD8A ~ CD8a | 0.44 | 0.40 |
234
+ | MS4A1 ~ CD20 | 0.42 | 0.34 |
235
+ | PTPRC ~ CD45 | 0.37 | 0.34 |
236
+ | ACTA2 ~ SMA | 0.34 | 0.34 |
237
+ | CD68 ~ CD68 | 0.25 | 0.22 |
238
+ | CD163 ~ CD163 | 0.07 | 0.15 |
239
+
240
+ TCGA caveat: 2,540 of 4,088 slides contribute a single patch, so most TCGA predictions have no spatial context and are less reliable than the ORION ones.
241
+
242
+ Coverage after completion: TCGA patches have H&E + real text + virtual protein + virtual RNA. ORION has H&E + real protein + virtual RNA, and synthetic text is pending (Azure). HEST has H&E features + real RNA.
243
+
244
+ ## H&E foundation-model ablation: UNI2 vs GigaPath vs CONCH as the frozen anchor — `run_v4.sh`, `28_eval_anchor.py`
245
+ All other settings are identical. CONCH v1 vision tower: 448 px, unprojected 512-d (the authors' recommended tile feature). `conchclip` = CONCH projected into its own image-text space.
246
+
247
+ ### H&E ↔ mIF (ORION, trained pair; mIF = frozen VirTues + MLP head; both directions averaged)
248
+ | Anchor | Patient 5-fold R@1 g100 | Patient 5-fold pair acc | ORION official R@1 (109 tiles) | ORION official pair acc |
249
+ |---|---|---|---|---|
250
+ | **UNI2** | **0.727 ± 0.030** | **0.751 ± 0.045** | 0.665 | **0.945** |
251
+ | GigaPath | 0.717 ± 0.025 | 0.690 ± 0.046 | **0.702** | 0.936 |
252
+ | CONCH | 0.571 ± 0.035 | 0.379 ± 0.037 | 0.529 | 0.651 |
253
+
254
+ Same with a from-scratch ConvNeXt-T mIF encoder:
255
+
256
+ | Anchor | Patient 5-fold R@1 g100 | Patient 5-fold pair acc | ORION official R@1 | ORION official pair acc |
257
+ |---|---|---|---|---|
258
+ | **UNI2** | **0.753 ± 0.019** | **0.744 ± 0.022** | **0.738** | **0.945** |
259
+ | GigaPath | 0.740 ± 0.022 | 0.691 ± 0.018 | 0.707 | 0.927 |
260
+ | CONCH | 0.639 ± 0.021 | 0.481 ± 0.058 | 0.608 | 0.780 |
261
+
262
+ The ranking UNI2 ≥ GigaPath ≫ CONCH holds for both mIF encoders.
263
+
264
+ ### H&E ↔ text (all 6,144 TCGA patches / 6,480 real pairs, patient 5-fold, R@1 on 100-pair galleries, "all" text types)
265
+ | Image | Text | Text head only (ImageBind) | Two heads (CLIP-style) |
266
+ |---|---|---|---|
267
+ | UNI2 | BiomedCLIP | **0.231 ± 0.008** | 0.210 ± 0.012 |
268
+ | CONCH | BiomedCLIP | 0.230 ± 0.007 | **0.229 ± 0.007** |
269
+ | CONCH-CLIP | BiomedCLIP | 0.226 ± 0.007 | 0.226 ± 0.012 |
270
+ | GigaPath | BiomedCLIP | 0.218 ± 0.007 | 0.200 ± 0.011 |
271
+ | CONCH | CONCH text | 0.216 ± 0.009 | 0.221 ± 0.007 |
272
+ | UNI2 / GigaPath | CONCH text | 0.209 / 0.192 | 0.193 / 0.179 |
273
+ | CONCH-CLIP | CONCH text, **zero-shot** | 0.170 ± 0.010 | — |
274
+
275
+ On the PathGen-1.6M caption split (`12_train_text_pathgen.py`, BiomedCLIP, 255 test pairs, g100 he→text / text→he):
276
+ - UNI2: 0.29 / 0.27
277
+ - GigaPath: 0.26 / 0.22
278
+ - CONCH: 0.26 / 0.24
279
+
280
+ ### Emergent text ↔ mIF (PathGen-trained text head per anchor, BiomedCLIP; ORION patient 5-fold AUROC, mean of 7 concepts)
281
+ | Anchor | mIF embedding | H&E embedding |
282
+ |---|---|---|
283
+ | UNI2 | 0.674 | 0.665 |
284
+ | GigaPath | 0.686 | 0.678 |
285
+ | **CONCH** | **0.719** | **0.701** |
286
+
287
+ CONCH gains most on tumour epithelium (0.89 vs UNI2 0.76), macrophage (0.65 vs 0.60) and vessels (0.57 vs 0.44).
288
+
289
+ ### Takeaways
290
+ - **Trade-off between instance-level pairing and semantics.**
291
+ - CONCH has the weakest H&E↔mIF pairing: pair accuracy is half of UNI2's.
292
+ - CONCH transfers text concepts to mIF best, emergently. Its features were trained against captions, so they encode coarse semantics more than fine tile identity.
293
+ - **UNI2 is the best single anchor.** It is the strongest or tied on every trained pair: mIF, text, and RNA (UNI2 > GigaPath on HEST, see above).
294
+ - **The CONCH text tower is worse than BiomedCLIP** on these long PathGen texts, even when paired with CONCH images. Zero-shot CONCH (0.17) is well below any trained head (≥0.18).
295
+ - **HEST RNA has no CONCH anchor** because only UNI2 and GigaPath features are distributed, not the images.
296
+
297
+ ## Virtual modality completion: train text directly on virtual pairs — `29_virtual_completion.py`
298
+ **Question:** does training text↔RNA or text↔protein directly on virtual pairs beat the emergent route through the H&E anchor?
299
+ - RNA pairs: TCGA real text ↔ STPath virtual RNA.
300
+ - Protein pairs: TCGA real text ↔ GigaTIME virtual mIF (11 channels shared with ORION).
301
+ - Model: BiomedCLIP head + MLP head on the modality vector, patient 5-fold over TCGA.
302
+ - Features are rank-gaussianised within each dataset.
303
+ - Testing uses only **real** measurements, with the same prompts, labels and protocol as the emergent probes.
304
+
305
+ | Text ↔ | Test set | Direct on virtual pairs | Emergent via H&E anchor (best encoder) |
306
+ |---|---|---|---|
307
+ | RNA | HEST real RNA, all spots | 0.595 | 0.59–0.61 (MLP / scGPT / Geneformer) |
308
+ | RNA | HEST real RNA, bowel | 0.552 | 0.61–0.62 |
309
+ | protein | ORION real mIF means | 0.596 | **0.678** (VirTues mIF), 0.663 (H&E) |
310
+ | protein | ORION *virtual* GigaTIME means (same tiles) | 0.686 | — |
311
+
312
+ (AUROC, top-quartile label, mean over concepts.)
313
+
314
+ - **In-domain retrieval** (held-out TCGA, text ↔ virtual modality, R@1 g100):
315
+ - RNA: 0.196 ± 0.010
316
+ - protein, 11 channel means: 0.048 ± 0.007
317
+ - **Virtual pairs do not beat the emergent route.** RNA ties on all spots and is worse on bowel. Protein is clearly worse.
318
+ - **The bottleneck is the virtual→real gap.** The same protein model scores 0.686 on ORION's virtual means but 0.596 on the real means of the same tiles.
319
+ - **The single-channel reference** (the matched real channel used as the score) gives 0.79–0.98. The probes are far from saturated, and an 11-number protein summary carries much less than the VirTues image embedding.
320
+ - **Recommendation:** use virtual modalities only as auxiliary or augmentation data, never as replacements for, or test truth instead of, real pairs.
321
+
322
+ ## Four-modality validation with virtual / synthetic data (validation only, never training) — `31_virtual_validation.py`
323
+ - **Training:** every encoder is trained on real pairs only (H&E–mIF ORION, H&E–text PathGen, H&E–RNA HEST; frozen UNI2 anchor).
324
+ - **Virtual / synthetic data:** used only to build quadruplet test sets, so that modality pairs never measured together get a pairing accuracy:
325
+ - virtual RNA: STPath
326
+ - virtual mIF: GigaTIME
327
+ - synthetic text: Azure `gpt-5.6-sol`, fixed 3-shot PathGen-style prompt, `27_azure_caption.py`; 9,031 captions, 0 failures, USD 38.11
328
+ - **Metrics:** 100-item galleries, R@1 (both directions) / one-to-one Hungarian pairing accuracy; chance 0.01.
329
+ - **TCGA evaluation:** 2,868 patches of 2,158 patients that the text head never saw.
330
+
331
+ ### Calibration: is each virtual modality a usable stand-in for the real one?
332
+ | Virtual modality | Real pair (same items) | Same pair with the virtual modality | Verdict |
333
+ |---|---|---|---|
334
+ | RNA (STPath), HEST all | H&E↔RNA 0.148 / 0.166 | 0.126 / 0.182 | **usable** |
335
+ | RNA (STPath), HEST held out from STPath | 0.086 / 0.110 | 0.080 / 0.136 | **usable** |
336
+ | text (synthetic), 1,000 TCGA patches with real text | H&E↔text 0.184 / 0.165 | 0.134 / 0.140 | **usable, conservative (≈0.75×)** |
337
+ | text, same 1,000 patches | text real↔RNA virtual 0.039 / 0.038 | text synthetic↔RNA virtual 0.044 / 0.049 | consistent |
338
+ | mIF (GigaTIME, 10-ch VirTues), ORION | H&E↔mIF 0.644 / 0.863 | 0.038 / 0.046 | **not usable** |
339
+ | mIF (GigaTIME, 10 channel means), ORION | 0.279 / 0.357 | 0.050 / 0.053 | **not usable** |
340
+
341
+ GigaTIME's tile means correlate with the real mIF at the population level (Spearman ≈0.6), but they cannot tell one tile from 99 others. Pixel maps are worse. Virtual mIF is therefore only good for coarse concept-level checks, not pairing accuracy.
342
+
343
+ ### Pairing accuracy for every modality pair (R@1 / pair acc on 100-galleries; ⚠ = circular)
344
+ "Circular" means both sides come from the same H&E image, so the number is an optimistic upper bound.
345
+
346
+ **ORION** (H&E real, mIF real (VirTues 17 ch), RNA virtual, text synthetic), patient 5-fold:
347
+
348
+ | | mIF (real) | RNA (virtual) | text (synthetic) |
349
+ |---|---|---|---|
350
+ | **H&E** | **0.726 / 0.935** (trained) | 0.103 / 0.192 ⚠ | 0.066 / 0.091 ⚠ |
351
+ | **mIF (real)** | | **0.108 / 0.152** (emergent) | **0.068 / 0.069** (emergent) |
352
+ | **RNA (virtual)** | | | 0.036 / 0.050 ⚠ |
353
+
354
+ **TCGA** (H&E real, text real, RNA virtual):
355
+
356
+ | | text (real) | RNA (virtual) |
357
+ |---|---|---|
358
+ | **H&E** | **0.178 / 0.170** (trained) | 0.121 / 0.175 ⚠ |
359
+ | **text (real)** | | **0.042 / 0.041** (emergent) |
360
+
361
+ Virtual mIF on TCGA (text↔mIF 0.012–0.021) fails calibration and is not reported as a result.
362
+
363
+ ### Takeaways
364
+ - **Protein ↔ RNA**, never trained together: 0.108 R@1 / 0.152 pairing accuracy on ORION, 10–15× chance. This is real protein against virtual RNA, and virtual RNA scores close to real RNA in the HEST calibration.
365
+ - **Text ↔ protein**, never trained together: 0.068, about 7× chance. This uses synthetic text, which scores about 0.75× real text in the calibration, so the real-text value is likely ≈0.09.
366
+ - ORION is a single-organ cohort (colon), so its galleries are harder than TCGA's 30-cancer galleries. H&E↔synthetic text is 0.066 on ORION vs 0.134 on TCGA.
367
+ - **Text ↔ RNA**, never trained together: 0.042 with real text and 0.044 with synthetic text on the same patches, about 4× chance.
368
+ - Emergent pairs are clearly above chance but an order of magnitude below the trained H&E↔mIF pair. Instance-level emergent alignment is weak; concept-level alignment (AUROC 0.6–0.7, earlier sections) is moderate.
369
+
370
+ ## Final protocol: real-only training, explicit train/val/test, virtual = test-time only (`33_final_eval.py`)
371
+ Every head is trained on REAL pairs only; val is used only for early stopping; all numbers are on the TEST split
372
+ (5 folds, mean ± std; patients/groups never cross splits, asserted). Virtual modalities (STPath RNA, GigaTIME mIF,
373
+ GPT captions) are only fed through the frozen real-trained heads at test time. Metrics: 100-item galleries × 50 draws,
374
+ both directions; chance R@1 = pair acc = 0.01, R@10 = 0.10. Split files: `splits/patient_5fold`, `splits/orion_official`,
375
+ `splits/final/{hest,tcga}_fold<k>.csv`; sizes `results/final_splits.csv`; numbers `results/final_{long,summary}.csv`.
376
+ TCGA text heads are now trained on ALL image-text pairs per fold (`runs/text_all/uni2_biomedclip_fold<k>`).
377
+
378
+ | dataset | unit | train | val | test (per fold) |
379
+ |---|---|---|---|---|
380
+ | ORION patient 5-fold | patient | 2291 tiles / 29 pts | 258 / 3 | 637 / 8 |
381
+ | ORION official (user split) | patient | 2954 / 36 | 124 / 2 | 109 / 2 |
382
+ | HEST | study\|patient group | 7141 spots / 91 grp | 447 / 10 | 1897 / 25 |
383
+ | TCGA all image-text pairs | patient | 4426 images / 2943 pts | 489 / 327 | 1229 / 818 |
384
+
385
+ **(1) Real pairs (test split)**
386
+
387
+ | pair | dataset | R@1 | R@10 | MRR | pair acc |
388
+ |---|---|---|---|---|---|
389
+ | H&E↔mIF | ORION 5-fold | 0.726±0.031 | 0.989 | 0.828 | 0.935±0.030 |
390
+ | H&E↔mIF | ORION official | 0.665 | 1.000 | 0.791 | 0.948 |
391
+ | H&E↔RNA | HEST | 0.148±0.026 | 0.547 | 0.277 | 0.166±0.032 |
392
+ | H&E↔RNA | HEST, STPath-unseen spots | 0.086±0.038 | 0.509 | 0.214 | 0.110±0.038 |
393
+ | H&E↔text | TCGA | 0.229±0.011 | 0.700 | 0.383 | 0.238±0.011 |
394
+
395
+ **(2) Virtual pairs (same test items; ⚠ = both sides derived from the same H&E → optimistic)**
396
+
397
+ | pair | dataset | R@1 | R@10 | pair acc | type |
398
+ |---|---|---|---|---|---|
399
+ | mIF↔RNA(virtual) | ORION 5-fold | 0.108±0.006 | 0.516 | 0.152±0.020 | emergent, real×virtual |
400
+ | mIF↔text(synthetic) | ORION 5-fold | 0.073±0.011 | 0.446 | 0.077±0.012 | emergent, real×virtual |
401
+ | text↔RNA(virtual) | TCGA | 0.055±0.009 | 0.318 | 0.058±0.010 | emergent, real×virtual |
402
+ | RNA(virtual)↔text(synthetic) | ORION 5-fold | 0.047±0.008 | 0.343 | 0.058±0.009 | ⚠ virtual×virtual |
403
+ | H&E↔RNA(virtual) | ORION / TCGA | 0.103 / 0.120 | 0.52 / 0.45 | 0.19 / 0.18 | ⚠ |
404
+ | H&E↔text(synthetic) | ORION 5-fold | 0.075±0.006 | 0.439 | 0.101±0.013 | ⚠ |
405
+ | text↔mIF(virtual) | TCGA | 0.015±0.002 | 0.133 | 0.014 | GigaTIME not usable |
406
+
407
+ Calibration (virtual replaces real on the same test items): HEST H&E↔RNA 0.148 → 0.126 (STPath-unseen 0.086 → 0.080);
408
+ TCGA H&E↔text 0.197 → 0.166 (synthetic = 0.84× real); ORION H&E↔mIF 0.644 → 0.038 (GigaTIME fails at instance level).
409
+ ORION official numbers (109 test tiles, 2 patients) match the 5-fold ones (mIF↔RNA(virtual) 0.108 / 0.152).
410
+
411
+ ### Why GigaTIME virtual mIF fails at pairing (`34_gigatime_diag.py`, `results/gigatime_diag*.csv`)
412
+ - Per-channel tile-mean Spearman vs real ORION mIF: GigaTIME mean 0.54 pooled / 0.50 within-slide (not a slide-level
413
+ artefact), vs 0.79 / 0.75 for an in-domain reference (ridge UNI2 → real means, trained on ORION train, scored on test).
414
+ Weakest: SMA 0.20 (no SMA channel; Actin-D/Transgelin proxy), PD-L1 0.34, CD20 0.36, CD8 0.37.
415
+ - Retrieval needs every channel to be right at once: real means ↔ GigaTIME means R@1 0.035 (chance 0.01) vs
416
+ real ↔ UNI2-ridge 0.261 — same 10 channels, no learned head.
417
+ - Pixel maps are sigmoid probabilities, mostly < 0.05 (58–96% of pixels per channel) with ~0 confident positives except
418
+ DAPI/CK; pixel Pearson ≈0.1. The real-trained VirTues head sees out-of-distribution input → 0.038.
419
+ - Conclusion: H&E does contain this information (the in-domain reference recovers it); GigaTIME, trained on another
420
+ cohort/panel/scanner, does not transfer to ORION at tile resolution.
421
+
422
+ ### Unified mIF channels (real and virtual mIF both = the 10-channel panel shared with GigaTIME)
423
+ `33_final_eval.py` now uses the 10-ch head (real ORION, fold-k train/val) for every mIF row; 17-ch only as reference.
424
+ `[qm]` = GigaTIME pixels quantile-mapped per channel onto the real fold-k TRAIN pixel distribution before the same
425
+ preprocessing (`30_virtues_shared.py virtual_qm`). ORION patient 5-fold test, R@1 / R@10 / pair acc:
426
+
427
+ | pair | raw virtual | [qm] virtual |
428
+ |---|---|---|
429
+ | H&E↔mIF (real, 10 ch) | 0.644 / 0.979 / 0.863 | — |
430
+ | H&E↔mIF (real, 17 ch, reference) | 0.726 / 0.989 / 0.935 | — |
431
+ | mIF(10 ch)↔RNA(virtual) | 0.098 / 0.495 / 0.131 | — |
432
+ | mIF(10 ch)↔text(synthetic) | 0.074 / 0.434 / 0.071 | — |
433
+ | H&E↔mIF(virtual) | 0.038 / 0.264 / 0.046 | 0.042 / 0.278 / 0.047 |
434
+ | mIF↔mIF(virtual) | 0.045 / 0.248 / 0.054 | 0.052 / 0.301 / 0.060 |
435
+ | mIF(virtual)↔RNA(virtual) | 0.021 / 0.174 / 0.019 | 0.017 / 0.167 / 0.016 |
436
+ | mIF(virtual)↔text(synthetic) | 0.024 / 0.190 / 0.025 | 0.022 / 0.182 / 0.021 |
437
+ | TCGA text↔mIF(virtual) | 0.015 / 0.133 / 0.014 | 0.016 / 0.138 / 0.016 |
438
+
439
+ Unifying channels costs the real pairs ~0.08 R@1 (17 → 10 ch); matching the pixel distribution barely helps virtual mIF
440
+ (+0.004–0.007 R@1). The bottleneck is GigaTIME's per-tile accuracy, not channel naming or intensity scale.
441
+
442
+ ## MIPHEI-ViT virtual mIF (replaces GigaTIME) — `35_miphei_infer.py`, `37_miphei_diag.py`
443
+ Model: Sanofi MIPHEI-ViT v1.0.0 (H-optimus-0 + LoRA + conv decoder; non-commercial academic licence), 16 ORION
444
+ channels (all but PD-1). It was trained on this ORION tile set with the official split (37/2/2 slides = our
445
+ `orion_official`), so only the official TEST tiles (CRC02, CRC11; 109 tiles) are clean for ORION; TCGA is fully clean.
446
+ Input 0.5 µm/px tiles reflect-padded to 512, output mapped back to the 8-bit ORION scale.
447
+
448
+ Tile-mean Spearman vs the real mIF (mean over channels):
449
+
450
+ | set | MIPHEI (16 ch) | MIPHEI (10 GigaTIME ch) | GigaTIME (10 ch) |
451
+ |---|---|---|---|
452
+ | clean (official test), pooled / within-slide | 0.697 / 0.654 | 0.690 / 0.645 | 0.249 / 0.301 |
453
+ | seen (official train) | 0.840 / 0.791 | 0.840 / 0.787 | 0.545 / 0.505 |
454
+
455
+ Direct real-mean ↔ virtual-mean retrieval (no head), R@1 in 100-galleries, clean test: MIPHEI 16 ch 0.272,
456
+ MIPHEI 10 ch 0.239, GigaTIME 10 ch 0.017 (seen tiles: 0.538 / 0.433 / 0.036).
457
+
458
+ ### MIPHEI virtual mIF in the final protocol (`33_final_eval.py`, 16-ch heads trained on REAL ORION)
459
+ Clean = ORION official test (109 tiles, MIPHEI never saw them) and TCGA. R@1 / R@10 / pair acc:
460
+
461
+ | pair | ORION official test (clean) | ORION 5-fold (MIPHEI-seen, optimistic) | TCGA (clean) |
462
+ |---|---|---|---|
463
+ | H&E↔mIF real (16 ch) | 0.591 / 0.991 / 0.921 | 0.689 / 0.984 / 0.901 | — |
464
+ | H&E↔mIF(MIPHEI) | 0.189 / 0.784 / 0.553 | 0.255 / 0.722 / 0.509 | 0.133 / 0.455 / 0.213 |
465
+ | mIF real↔mIF(MIPHEI) | 0.297 / 0.800 / 0.586 | 0.379 / 0.762 / 0.669 | — |
466
+ | text↔mIF(MIPHEI) | — | — | 0.033 / 0.231 / 0.036 |
467
+ | RNA(virtual)↔mIF(MIPHEI) | 0.041 / 0.311 / 0.078 | 0.057 / 0.352 / 0.093 | 0.035 / 0.218 / 0.040 |
468
+ | mIF(MIPHEI)↔text(synthetic) | 0.050 / 0.396 / 0.051 | 0.049 / 0.314 / 0.058 | — |
469
+ | (real) mIF 16 ch↔RNA(virtual) | 0.101 / 0.466 / 0.123 | 0.108 / 0.512 / 0.149 | — |
470
+
471
+ GigaTIME on the same pairs: H&E↔mIF(virtual) 0.012–0.038 R@1, text↔mIF(virtual) 0.015 → MIPHEI is 5–15× better and
472
+ usable for validation (calibration: pair acc 0.55 vs 0.92 real on clean tiles ≈ 0.6×).
473
+
474
+ ## External test: DLPFC (`36_external_prep.py`, `38_external_eval.py`, `39_external_check.py`, `results/external_*.csv`)
475
+ IDC results were removed from this report on 2026-10-18 at the user's request (the prepared data stays in cache/external).
476
+ - DLPFC: spatialLIBD Visium brain, 482 spots / 12 sections / 3 donors; gene names recovered exactly from the public h5.
477
+ All 12 sections are in HEST (MISC1–12) -> the whole spatialLIBD study is REMOVED from HEST training for this test.
478
+ - Heads: H&E(UNI2)↔RNA trained on real HEST only (train/val by group; 5 heads), RNA z-scored per dataset.
479
+ Virtual mIF: MIPHEI of the DLPFC H&E through the 16-ch heads trained on real ORION.
480
+ Synthetic text: Azure `gpt-5.6-sol` captions of the same patches (same 3-shot prompt) through the TCGA real-pair text
481
+ heads of 33_ (clean-text heads, runs/text_clean). Nothing is trained on external or virtual data.
482
+
483
+ 5 heads, R@1 ± std / R@10 / pair acc; mixed galleries G=100 (chance 0.01) and within-section galleries G=35 (chance 0.029):
484
+
485
+ | pair | kind | mixed G=100 | within section G=35 |
486
+ |---|---|---|---|
487
+ | H&E↔RNA | real, trained pair | 0.010±0.003 / 0.107 / 0.013 | 0.031 |
488
+ | RNA↔mIF(MIPHEI) | emergent, real×virtual | 0.008 / 0.099 / 0.009 | 0.027 |
489
+ | RNA↔text(synthetic) | emergent, real×virtual | 0.010±0.002 / 0.117 / 0.011 | 0.032 |
490
+ | H&E↔mIF(MIPHEI) | ⚠ circular | 0.017 / 0.136 / 0.015 | 0.040 |
491
+ | H&E↔text(synthetic) | ⚠ circular | 0.022±0.002 / 0.179 / 0.025 | 0.054 |
492
+ | mIF(MIPHEI)↔text(synthetic) | ⚠ virtual×virtual | 0.014 / 0.144 / 0.017 | 0.038 |
493
+
494
+ - Every non-circular pair is at chance on DLPFC. Reference: HEST internal test restricted to brain = 0.021 (mixed organs 0.148).
495
+ - Caption audit: 59% of DLPFC captions say "lymphoid", only 6% recognise CNS/neuropil, 13% "no identifiable tissue"
496
+ (blurry 112-µm crops) -> GPT cannot read these patches either.
497
+ - Checks: captions aligned row by row (482/482); shuffled nulls ≈ 1/G; rerun identical. Protocol check: our UNI2
498
+ features of 3 DLPFC spots that also exist in the HEST cache have cosine 0.52–0.83 to the cached ones (random 0.12).
499
+
500
+ ## Text cleaning + retrain on clean real text (v10) — `40_clean_text.py`, `33_`, `run_v10.sh`
501
+ Supersedes every TCGA-text number above (old results kept in results/before_text_clean/; old heads runs/text_all).
502
+ - Junk texts removed from train / val / test: 365 of 6,480 (353 bare answer letters "A A B B", 4 "error error",
503
+ 8 answers < 5 words). 256 of 6,144 TCGA images were left without real text; they keep their split, get a few-shot GPT
504
+ caption (27_ --sets tcga_notext, USD 0.81; cumulative USD 65.09 of 100) and are used for evaluation only.
505
+ - Text heads retrained on clean real pairs only (runs/text_clean/uni2_biomedclip_fold<k>; ~4,400 train pairs / fold).
506
+
507
+ R@1 / R@10 / pair acc, test split, mean of 5 folds (before → after cleaning):
508
+
509
+ | pair | before | after |
510
+ |---|---|---|
511
+ | TCGA H&E↔text (real) | 0.229 / 0.700 / 0.238 | **0.237±0.012 / 0.730 / 0.248** |
512
+ | TCGA text↔RNA(virtual) | 0.055 / 0.318 / 0.058 | 0.056 / 0.319 / 0.057 |
513
+ | TCGA text↔mIF(MIPHEI) | 0.033 / 0.231 / 0.036 | 0.036 / 0.237 / 0.038 |
514
+ | calibration set: H&E↔text real vs synthetic | 0.197 vs 0.166 | 0.219 vs 0.162 |
515
+ | ORION 5-fold mIF↔text(synthetic) | 0.074 / 0.434 / 0.071 | 0.074 / 0.437 / 0.072 |
516
+ | ORION 5-fold H&E↔text(synthetic) | 0.075 / 0.439 / 0.101 | 0.076 / 0.441 / 0.102 |
517
+
518
+ Part of the gain on TCGA comes from removing junk queries from the test split (n test pairs 1229 → 1178).
519
+ Images without real text (256, pooled over test folds, each image through its own test-fold head):
520
+ H&E↔text(synthetic) 0.150 / 0.557 / 0.160 (⚠ circular; consistent with 0.162 on the calibration set);
521
+ text(synthetic)↔RNA(virtual) 0.051; text(synthetic)↔mIF(MIPHEI) 0.036 (⚠ virtual×virtual).
522
+
523
+ ## Best encoder per modality — re-check under the final protocol (`41_text_enc_clean.py`, paired per-fold tests)
524
+ mIF (17_bench_fm / 28_, ORION patient_5fold) and RNA (23_, HEST group 5-fold) comparisons already used the final-protocol
525
+ splits -> re-analysed per fold. Text was re-run on CLEAN real text with the 33_ recipe and splits (UNI2+BiomedCLIP
526
+ reproduces 33_ fold by fold: 0.234 / 0.236 / 0.248 / 0.219 / 0.248). R@1 g100 mean of 5 folds; Δ = best − other,
527
+ folds won, paired t-test (5 folds; 5/5 wins = sign-test p 0.06).
528
+
529
+ | modality | best | runner-up (Δ, folds won, p) | verdict |
530
+ |---|---|---|---|
531
+ | text tower (H&E↔text, clean) | **BiomedCLIP** 0.237 (UNI2 image) | CONCH text 0.216 (+0.021, 5/5, p=0.03); PLIP 0.211 (+0.026, 5/5, p=0.007); ImageBind 0.201 (+0.036, 5/5, p=0.002) | clear winner for every image anchor |
532
+ | H&E image for text | Phikon-v2 0.241 ≈ CONCH 0.241 ≈ UNI2 0.237 | GigaPath 0.223 (UNI2 +0.014, 4/5, p=0.05) | 3-way tie, GigaPath worse |
533
+ | H&E image for mIF | **UNI2** 0.727 (VirTues) / 0.753 (ConvNeXt) | GigaPath (+0.009 / +0.013, ns); CONCH (+0.155 / +0.114, 5/5, p<0.001) | UNI2 ≈ GigaPath ≫ CONCH |
534
+ | H&E image for RNA | **UNI2** 0.148 | GigaPath 0.113 (+0.035, 4/5, p=0.05) | UNI2 |
535
+ | mIF encoder (H&E↔mIF) | **ConvNeXt-T** scratch 0.758 | VirTues 0.728 (+0.030, 5/5, p=0.07) | ConvNeXt-T consistently ahead; VirTues kept for cross-modal transfer / channel flexibility |
536
+ | RNA encoder (H&E↔RNA) | Geneformer-V2 0.159 | scGPT 0.154 (+0.005, 3/5, p=0.36); MLP 0.148 (+0.011, 4/5, p=0.05) | tie |
537
+
538
+ UNI2 is the only H&E anchor never significantly beaten on any pair (Phikon-v2 has no mIF/RNA runs: HEST ships only
539
+ UNI2/GigaPath features). Files: results/text_enc_clean_{long,summary}.csv.
540
+
541
+ ## Training regimes: separate vs joint (`42_train_regimes.py`, results/regimes_{long,summary}.csv)
542
+ Real pairs only, final-protocol splits (ORION patient 5-fold with the 16-ch VirTues panel, HEST group 5-fold, TCGA
543
+ patient 5-fold on clean text). Same heads, loss and optimiser for every regime; virtual data only at test time.
544
+ R@1 g100, mean of 5 folds; "rel" = mean of R@1 / S1's R@1 over the trained (3) or emergent (4) pairs.
545
+
546
+ | regime | H&E↔mIF | H&E↔RNA | H&E↔text | mIF↔RNA(v) | mIF↔text(s) | text↔RNA(v) | text↔mIF(MIPHEI) | trained rel | emergent rel |
547
+ |---|---|---|---|---|---|---|---|---|---|
548
+ | S1 separate, frozen anchor (current) | 0.680 | 0.148 | 0.234 | 0.108 | 0.076 | 0.060 | 0.038 | 1.00 | 1.00 |
549
+ | S2 separate, two-tower (CLIP) | **0.794** | 0.130 | 0.195 | 0.019 | 0.022 | 0.014 | 0.013 | 0.96 | 0.26 |
550
+ | S3 separate, frozen, linear heads | 0.642 | 0.156 | 0.237 | **0.123** | 0.070 | 0.069 | 0.038 | 1.00 | 1.05 |
551
+ | S4 separate, frozen, MLP mIF + linear RNA/text | 0.680 | 0.152 | 0.234 | 0.117 | 0.074 | **0.071** | 0.037 | 1.01 | 1.05 |
552
+ | J1 joint, frozen anchor | 0.706 | 0.148 | **0.237** | 0.094 | 0.065 | 0.056 | 0.031 | 1.02 | 0.87 |
553
+ | J2 joint, shared trainable H&E head | 0.761 | 0.147 | 0.204 | 0.061 | 0.060 | 0.044 | 0.031 | 0.99 | 0.72 |
554
+ | J3 J2, size-proportional sampling | 0.740 | 0.138 | 0.187 | 0.063 | 0.060 | 0.039 | 0.031 | 0.94 | 0.70 |
555
+ | J4 J2 + anchor distillation λ=1 | 0.765 | 0.144 | 0.202 | 0.062 | 0.058 | 0.043 | 0.030 | 0.99 | 0.71 |
556
+ | J4b J2 + anchor distillation λ=10 | 0.768 | 0.149 | 0.209 | 0.067 | 0.058 | 0.047 | 0.030 | 1.01 | 0.74 |
557
+ | J5 joint, residual adapter on UNI2 | 0.762 | 0.142 | 0.201 | 0.058 | 0.059 | 0.044 | 0.029 | 0.98 | 0.71 |
558
+ | J6 J2 + uncertainty weighting | 0.765 | 0.136 | 0.195 | 0.057 | 0.055 | 0.041 | 0.027 | 0.96 | 0.66 |
559
+ | J7 two-stage: S1 → joint residual fine-tune | 0.776 | 0.150 | 0.234 | 0.100 | 0.076 | 0.055 | 0.035 | 1.05 | 0.94 |
560
+ | **J8 two-stage: S4 → joint residual fine-tune** | 0.762 | **0.164** | 0.232 | 0.112 | 0.073 | 0.068 | 0.035 | **1.07** | **1.01** |
561
+
562
+ Pair accuracy (H&E↔mIF / RNA / text): S1 0.897 / 0.172 / 0.242 → J8 0.929 / 0.198 / 0.237.
563
+ J8 vs S1 (paired, 5 folds): H&E↔mIF +0.083 (5/5, p=0.01), H&E↔RNA +0.015 (5/5, p=0.03), text↔RNA(v) +0.008
564
+ (5/5, p=0.02), H&E↔text −0.002, mIF↔RNA(v) +0.004, mIF↔text(s) −0.004, text↔mIF −0.003 (all ns).
565
+ - Training the H&E side from scratch (S2, J2–J6) helps H&E↔mIF (+0.08–0.11) but costs H&E↔text (−0.03 to −0.05) and
566
+ 26–74% of the emergent performance: the shared H&E head is pulled towards the easiest / strongest pair (mIF).
567
+ Size-proportional sampling and uncertainty weighting make this worse; distillation to the UNI2 geometry helps a little.
568
+ - Joint training with a frozen anchor (J1) only changes the step budget: +0.026 mIF, −13% emergent.
569
+ - Linear heads for RNA / text generalise better across modalities than MLP heads (S3/S4: emergent +5%).
570
+ - Best: **J8 = first train every head separately on the frozen UNI2 anchor (MLP for mIF, linear for RNA and text), then
571
+ fine-tune all heads jointly with a shared zero-initialised residual adapter on UNI2** (lr 1e-4, 1000 steps, sum of
572
+ the three losses, similarity distillation λ=1, joint early stopping on the geometric mean of the val R@1).
573
+
574
+ ## More RNA / text encoders + multi-encoder late fusion (`43_*`, `44_rnafm_eval.py`, `45_*`, `46_text_enc_eval.py`, `47_final_ens.py`)
575
+ All encoders frozen; one head per encoder into the frozen UNI2 space (S4 recipe of 42_: linear for RNA / text), same
576
+ splits and test items as 42_. Late fusion = mean of the L2-normalised head outputs of several encoders of one modality
577
+ (possible because every head lands in the same frozen space). R@1 g100, mean of 5 folds; virtual / synthetic = test only.
578
+ RNA inputs: cache/rnafm/<model>_<set>.npy (rna_sets.py: hest real, STPath virtual = round(expm1(pred)), DLPFC real);
579
+ FM inputs z-scored with HEST-train stats (DLPFC: per dataset, heads retrained without spatialLIBD).
580
+
581
+ RNA encoders (linear head):
582
+
583
+ | RNA encoder | H&E↔RNA | mIF↔RNA(v) | text↔RNA(v) | H&E↔RNA(v) (HEST, calib) | DLPFC H&E↔RNA |
584
+ |---|---|---|---|---|---|
585
+ | hvg 2000 (current) | 0.155 | 0.124 | 0.069 | 0.198 | 0.009 |
586
+ | AIDO.Cell-100M (MLP head 0.171) | 0.169 | 0.094 | 0.049 | 0.083 | 0.010 |
587
+ | Tahoe-x1 70M | 0.166 | 0.087 | 0.039 | 0.070 | 0.011 |
588
+ | Geneformer-V2-104M | 0.164 | 0.071 | 0.037 | 0.064 | 0.010 |
589
+ | scGPT-human | 0.155 | 0.087 | 0.050 | 0.096 | 0.013 |
590
+ | C2S-Scale-Gemma-2-2B (mean / last token) | 0.155 / 0.142 | 0.101 / 0.101 | 0.055 / 0.051 | 0.111 / 0.097 | 0.011 |
591
+ | Nicheformer | 0.152 | 0.104 | 0.056 | 0.097 | 0.006 |
592
+ | SE-600M (STATE) | 0.150 | 0.117 | 0.062 | 0.143 | 0.011 |
593
+ | GenePT-w (bge / BiomedCLIP gene-summary embeddings) | 0.141 / 0.130 | 0.093 / 0.075 | 0.056 / 0.052 | 0.116 / 0.086 | 0.009 |
594
+ | seed control: hvg x 5 seeds, averaged | 0.161 | 0.132 | 0.073 | 0.208 | 0.008 |
595
+ | fusion hvg + Tahoe-x1 + SE-600M + AIDO.Cell | 0.192 | 0.142 | 0.075 | 0.174 | 0.012 |
596
+ | fusion all 10 | 0.199 | 0.148 | 0.078 | 0.163 | 0.012 |
597
+ | early fusion (concat) hvg + Tahoe-x1 | 0.169 | 0.114 | 0.059 | 0.131 | 0.009 |
598
+
599
+ - Single FMs gain up to +0.016 on real H&E↔RNA but lose on every virtual-RNA pair; the calibration column shows why:
600
+ the FMs embed STPath virtual RNA far from the real RNA of the same spot (H&E↔RNA(v) 0.06–0.14 vs 0.198), so the
601
+ emergent drop reflects the virtual test data more than the encoders.
602
+ - Late fusion beats early fusion (concat) and the seed-ensemble control by a wide margin -> the encoders are complementary.
603
+
604
+ Text encoders (TCGA clean real text, linear head; 45_ / 45b_; KEEP and Quilt-1M corpora contain no PathGen / TCGA text):
605
+
606
+ | text encoder | H&E↔text | mIF↔text(s) | text↔RNA(v) | text↔mIF(MIPHEI) |
607
+ |---|---|---|---|---|
608
+ | BiomedCLIP (current; z-scored control 0.235) | 0.236 | 0.077 | 0.069 | 0.038 |
609
+ | KEEP (MLP head 0.267) | 0.278 | 0.077 | 0.084 | 0.041 |
610
+ | Qwen3-Embedding-0.6B / 4B | 0.264 / 0.258 | 0.062 / 0.062 | 0.075 / 0.067 | 0.038 / 0.033 |
611
+ | PubMedBERT-emb / bge-large / BioLORD-2023 / MedCPT-Article / MedEmbed-large | 0.262 / 0.259 / 0.258 / 0.257 / 0.252 | 0.054–0.063 | 0.068–0.076 | 0.034–0.037 |
612
+ | QuiltNet-B-32 (QuickGELU) | 0.218 | 0.058 | 0.069 | 0.035 |
613
+ | seed control: KEEP x 5 seeds | 0.284 | 0.082 | 0.085 | 0.045 |
614
+ | fusion KEEP + BiomedCLIP + Qwen3-0.6B | 0.299 | 0.091 | 0.088 | 0.047 |
615
+ | fusion + PubMedBERT-emb + MedCPT | 0.303 | 0.088 | 0.091 | 0.047 |
616
+
617
+ Final combination (47_final_ens.py; mIF = VirTues 16-ch MLP head in all; paired vs S4 over 5 folds):
618
+
619
+ | pair | S4 | AIDO.Cell + KEEP | ens-small (4 RNA + 3 text) | ens-large (10 RNA + 5 text) | ens-small vs S4 |
620
+ |---|---|---|---|---|---|
621
+ | H&E↔mIF | 0.677 | 0.677 | 0.677 | 0.677 | unchanged |
622
+ | H&E↔RNA | 0.155 | 0.169 | 0.192 | 0.199 | +0.037 (5/5, p=0.0006) |
623
+ | H&E↔text | 0.236 | 0.278 | 0.299 | 0.303 | +0.062 (5/5, p<0.0001) |
624
+ | mIF↔RNA(v) | 0.124 | 0.094 | 0.142 | 0.148 | +0.018 (5/5, p=0.0002) |
625
+ | mIF↔text(s) | 0.077 | 0.077 | 0.091 | 0.088 | +0.014 (5/5, p=0.011) |
626
+ | text↔RNA(v) | 0.069 | 0.052 | 0.095 | 0.098 | +0.027 (5/5, p=0.0009) |
627
+ | text↔mIF(MIPHEI) | 0.038 | 0.041 | 0.047 | 0.047 | +0.010 (5/5, p=0.008) |
628
+ | DLPFC H&E↔RNA / RNA↔text(s) / RNA↔mIF(v) | 0.009 / 0.012 / 0.011 | 0.010 / 0.010 / 0.008 | 0.012 / 0.015 / 0.009 | 0.012 / 0.011 / 0.006 | still chance |
629
+
630
+ Pair accuracy ens-small vs S4: H&E↔RNA 0.182→0.246, H&E↔text 0.245→0.324, mIF↔RNA(v) 0.191→0.198, text↔RNA(v) 0.077→0.100.
631
+ Recommendation: ens-small (nearly all of ens-large's gain at 4 + 3 heads). Licences: AIDO.Cell (GenBio community licence,
632
+ non-commercial; restricts using its embeddings to train "similar" models) and SE-600M (Arc non-commercial) are
633
+ research-only; KEEP / QuiltNet / Nicheformer MIT, Tahoe-x1 / Qwen3-Embedding Apache-2.0, C2S-Scale CC-BY-4.0.
634
+ Not yet tested: fusion inside the J8 two-stage regime (J8's mIF gain should stack, unverified).
635
+
636
+ ## Joint training of the fused model (`48_joint_ens.py`, results/joint_ens_{long,summary,tests}.csv)
637
+ Does the J8 two-stage gain stack with multi-encoder fusion? Same splits / test items; all configs of a fold start from
638
+ the SAME stage-1 heads (separate, frozen UNI2). Stage 2 = J8 recipe (shared zero-init residual adapter on UNI2, sum of
639
+ the 3 pair losses, similarity distillation λ=1, lr 1e-4, 1000 steps, joint early stopping). E = ens-small of 47_.
640
+ EJ-fused: loss on the fused modality embedding; EJ-each: each encoder head keeps its own loss (fusion only at test);
641
+ EJ-frz: EJ-each with RNA / text heads frozen (only adapter + mIF head + temperatures train). R@1 g100, 5 folds.
642
+
643
+ | config | H&E↔mIF | H&E↔RNA | H&E↔text | mIF↔RNA(v) | mIF↔text(s) | text↔RNA(v) | text↔mIF(MIPHEI) |
644
+ |---|---|---|---|---|---|---|---|
645
+ | S4 separate, single encoders | 0.675 | 0.155 | 0.236 | 0.122 | 0.076 | 0.069 | 0.038 |
646
+ | J8 joint, single encoders | 0.776 | 0.159 | 0.238 | 0.122 | 0.073 | 0.065 | 0.035 |
647
+ | E separate, fused encoders | 0.675 | 0.192 | 0.299 | **0.141** | **0.089** | **0.095** | **0.046** |
648
+ | EJ-fused | 0.792 | 0.178 | 0.292 | 0.118 | 0.080 | 0.060 | 0.035 |
649
+ | EJ-each | 0.790 | **0.202** | **0.304** | 0.128 | 0.086 | 0.086 | 0.039 |
650
+ | **EJ-frz** | **0.793** | 0.201 | **0.304** | 0.128 | 0.088 | **0.095** | 0.040 |
651
+
652
+ Pair accuracy H&E↔mIF / RNA / text: S4 0.899 / 0.182 / 0.245, E 0.899 / 0.246 / 0.324, EJ-frz 0.936 / 0.255 / 0.320.
653
+ - Joint stage-2 gains stack on the trained pairs: EJ-frz vs E H&E↔mIF +0.118 (5/5, p<0.001), H&E↔RNA +0.009 (5/5,
654
+ p=0.01), H&E↔text +0.005 (ns); vs S4 all three trained pairs +0.05–0.12 (5/5).
655
+ - Price: the pairs that involve the fine-tuned mIF head lose vs E: mIF↔RNA(v) −0.013 (0/5, p=0.04), text↔mIF −0.007
656
+ (0/5, p=0.02); mIF↔text(s) −0.001 (ns). text↔RNA(v) unchanged (both heads frozen).
657
+ - Loss on the fused embedding (EJ-fused) is clearly worse than per-encoder losses (EJ-each: 5/5 better on 6 pairs):
658
+ the fused loss lets single heads specialise, which breaks the cross-modal geometry (text↔RNA 0.095 → 0.060).
659
+ - Freezing RNA / text heads (EJ-frz) costs nothing on the trained pairs and keeps text↔RNA at 0.095 (vs EJ-each 0.086,
660
+ 5/5, p=0.03): the adapter alone does the H&E-side work.
661
+ - Recommendation: EJ-frz if H&E↔mIF matters (+0.12) and a −0.01 on the virtual mIF emergent pairs is acceptable;
662
+ E (pure separate) if the emergent mIF pairs are the priority.
663
+
664
+ ## MedVTok as H&E image information + adapter-only joint training (`49_`–`51_`, results/medvtok_{long,summary,tests}.csv)
665
+ MedVTok (arXiv 2610.35842; github Masaaki-75/medvtok @a558c24, HF massaki75/medvtok, MIT): general medical visual
666
+ tokenizer, SigLIP2-So400m-initialised encoder (1152-d), aligned (MERA) to frozen expert encoders, CONCH v1.5 for
667
+ histopathology; no image-text pairs. Pathology training images: HISTAI 4.0M, **PathGen 1.61M**, bone-marrow
668
+ cytomorphology, LYSTO. => it has seen the TCGA/PathGen test IMAGES (not their captions); ORION / HEST / DLPFC not listed.
669
+ Loaded with weights_only=True + strict=True; reconstruction PSNR on ORION H&E 32–42 dB; feature = mean-pooled tokens ->
670
+ LN -> Linear ("feats", 1152-d), 256 px, [-1, 1]. HEST images: 224-px level-0 crops from the HEST WSIs (`49_`, HTTP range
671
+ reads of only the needed TIFF tiles); re-encoding them with UNI2-h reproduces cache/hest/he_uni2.npy (cos ≥ 0.9994 for
672
+ all 9,484 spots), i.e. MedVTok sees exactly the anchor's images. DLPFC: the 0.5-µm crops behind dlpfc_he_uni2 (cos 1.000).
673
+ Configs (RNA / text ens-small, mIF VirTues MLP; same splits / test items; R@1 g100, 5 folds):
674
+ E separate UNI2 anchor; EJ-frz (= 48_); EJ-ad = EJ-frz with the mIF head frozen too (only the residual H&E adapter and
675
+ the temperatures train); MV-E = MedVTok instead of UNI2 as the frozen anchor; +MV = adapter input [UNI2, c·MedVTok];
676
+ +RP = control, adapter input [UNI2, random projection of UNI2 to 1152-d at the same norm] (wider adapter, no new info).
677
+
678
+ | config | H&E↔mIF | H&E↔RNA | H&E↔text | mIF↔RNA(v) | mIF↔text(s) | text↔RNA(v) | text↔mIF | DLPFC H&E↔RNA |
679
+ |---|---|---|---|---|---|---|---|---|
680
+ | E | 0.675 | 0.192 | 0.299 | 0.141 | 0.089 | 0.095 | 0.046 | 0.012 |
681
+ | EJ-frz | 0.786 | 0.202 | 0.305 | 0.134 | 0.087 | 0.095 | 0.042 | |
682
+ | EJ-ad | 0.776 | 0.201 | 0.299 | 0.141 | 0.089 | 0.095 | 0.046 | |
683
+ | MV-E (MedVTok anchor) | 0.658 | 0.131 | 0.293 | 0.081 | **0.113** | 0.056 | **0.052** | 0.012 |
684
+ | EJ-frz+MV | **0.811** | 0.200 | 0.307 | 0.125 | 0.084 | 0.095 | 0.041 | |
685
+ | **EJ-ad+MV** | 0.792 | 0.200 | **0.315** | 0.141 | 0.089 | 0.095 | 0.046 | |
686
+ | EJ-ad+RP (control) | 0.778 | 0.202 | 0.298 | 0.141 | 0.089 | 0.095 | 0.046 | |
687
+
688
+ Pair accuracy H&E↔mIF / RNA / text: E 0.899 / 0.246 / 0.324, EJ-ad 0.929 / 0.256 / 0.313, EJ-ad+MV 0.937 / 0.252 / 0.338.
689
+ - EJ-ad (only the H&E adapter moves): every emergent pair is identical to E by construction (all modality heads frozen)
690
+ while H&E↔mIF +0.101 (5/5, p<0.001) and H&E↔RNA +0.009 (5/5, p=0.01). vs EJ-frz: H&E↔mIF −0.010 (ns), H&E↔text −0.006
691
+ (0/5, p=0.04), but mIF↔RNA(v) +0.007 (5/5, p=0.045) and text↔mIF +0.005 (ns) -> the emergent losses of EJ-frz came
692
+ entirely from moving the mIF head.
693
+ - MedVTok as the anchor (MV-E) is worse: H&E↔RNA −0.061 (0/5), RNA emergent pairs −0.04/−0.06 (0/5); only the pairs
694
+ with synthetic GPT text / virtual mIF gain (mIF↔text(s) +0.024, text↔mIF +0.006, 5/5) - CONCH-v1.5 semantics.
695
+ DLPFC stays at chance (0.012) with either anchor.
696
+ - MedVTok as extra adapter input: EJ-ad+MV vs EJ-ad H&E↔mIF +0.015 (4/5, p=0.04), H&E↔text +0.016 (5/5, p=0.02),
697
+ H&E↔RNA −0.002 (ns), emergent unchanged. The control shows it is MedVTok's information, not adapter width:
698
+ EJ-ad+RP = EJ-ad (all |Δ| ≤ 0.002, ns); EJ-ad+MV vs EJ-ad+RP: H&E↔mIF +0.014 (5/5, p=0.03), H&E↔text +0.017 (5/5, p=0.01).
699
+ Caveat: the H&E↔text test images are PathGen images MedVTok was pretrained on (image-only, aligned to CONCH v1.5);
700
+ the ORION H&E↔mIF gain is free of that.
701
+ - EJ-frz+MV has the best H&E↔mIF (0.811) but again loses the mIF emergent pairs (mIF↔RNA(v) −0.017 vs E, 0/5).
702
+ - Recommendation: **EJ-ad+MV** (stage 1 separate heads on frozen UNI2; stage 2 trains only a residual H&E adapter that
703
+ reads UNI2 + MedVTok). Every non-H&E embedding is unchanged; H&E↔mIF / text improve. Cost: a 2nd image encoder
704
+ (MedVTok encoder 417M params) at H&E inference. EJ-ad (no MedVTok) if a single image encoder is required.
705
+
706
+ ## Final model: training, stage-2 selection rule, inference API (`52_final_model.py`, `53_embed_check.py`, `embed.py`)
707
+ Configuration EJ-ad+MV (previous section). Stage 1 is deterministic (52_ reproduces 51_'s stage-1 heads exactly: the
708
+ four emergent pairs of fold 0 are identical to 51_). Stage 2 is not: two identical fold-0 runs gave H&E↔mIF 0.791 and
709
+ 0.741, because the val geo-mean is flat after ~100 steps and the argmax picks very different steps (100–700).
710
+ `52_ cv` (5 folds × 3 stage-2 seeds, same stage-1 heads, same test items; results/final_cv_{long,summary,tests,curves}.csv):
711
+
712
+ | stage-2 rule | H&E↔mIF | H&E↔RNA | H&E↔text | geo-mean | pair acc mIF / RNA / text | seed std mIF / text |
713
+ |---|---|---|---|---|---|---|
714
+ | stage 1 only (= E) | 0.675 | 0.192 | 0.299 | 0.337 | 0.899 / 0.246 / 0.324 | – |
715
+ | es (51_ rule, 1 seed) | 0.794 | 0.201 | 0.309 | 0.366 | 0.937 / 0.253 / 0.329 | 0.009 / 0.011 |
716
+ | last (1000 steps, 1 seed) | 0.802 | 0.202 | 0.301 | 0.364 | 0.939 / 0.254 / 0.314 | 0.002 / 0.003 |
717
+ | **ens-es (mean of 3 early-stopped adapters)** | 0.801 | 0.203 | **0.323** | **0.373** | **0.942 / 0.258 / 0.347** | ensemble |
718
+ | ens-last | **0.807** | **0.204** | 0.309 | 0.370 | 0.941 / 0.255 / 0.322 | ensemble |
719
+
720
+ ens-es vs es: H&E↔text +0.015 (5/5, p=0.02), mIF +0.007, RNA +0.002 (ns); ens-es vs ens-last: text +0.014 (5/5,
721
+ p=0.004), mIF −0.006 (0/5, p=0.03). Emergent pairs are identical for every rule (heads frozen): mIF↔RNA(v) 0.141,
722
+ mIF↔text(s) 0.089, text↔RNA(v) 0.095, text↔mIF 0.046. -> **ens-es** is the final rule (3 adapters, H&E = UNI2 +
723
+ mean_s A_s([UNI2, c·MedVTok]); each adapter 2.4M parameters).
724
+ Final 5-fold test performance (R@1 / R@10 / pair acc): H&E↔mIF 0.801±0.043 / 0.991 / 0.942, H&E↔RNA 0.203±0.033 /
725
+ 0.625 / 0.258, H&E↔text 0.323±0.007 / 0.813 / 0.347; emergent as above (chance 0.01 / 0.10 / 0.01).
726
+
727
+ Saved models (`52_ fold0` / `52_ release`, rule ens-es):
728
+ - `models/ej_ad_mv_fold0/` fold-0 train / val, test held out: test R@1 mIF 0.783, RNA 0.192, text 0.320 (51_ fold 0:
729
+ 0.768 / 0.187 / 0.316; emergent identical).
730
+ - `models/ej_ad_mv/` **release**: train = fold-0 train + test (ORION 3000 tiles, HEST 8938 spots, TCGA 5615 texts),
731
+ val = fold-0 val (187 / 546 / 500) for early stopping. No own test split: expected performance = the 5-fold CV above.
732
+ model.pt (heads, 3 adapters, feature-normalisation statistics) + meta.json (dims, HVG list, split sizes, val scores).
733
+
734
+ `embed.py` (Embedder): raw-input embedding of all four modalities (H&E images → UNI2-h + MedVTok; text → KEEP +
735
+ BiomedCLIP + Qwen3-Emb-0.6B; RNA counts + gene symbols → hvg + Tahoe-x1 + SE-600M + AIDO.Cell; mIF tiles (16 ORION
736
+ markers) + slide ids → VirTues), or from precomputed features; output L2-normalised 1536-d vectors. The raw-input
737
+ pre-processing reuses the extraction code of 04b_/14_/43_/45_/45b_/50_. `53_embed_check.py` (results/embed_check.csv):
738
+ raw-input features vs cached training features, per-item cosine median (min): UNI2 1.000 (1.000) on ORION / PathGen /
739
+ HEST; MedVTok 1.000 (≥0.9997); KEEP 1.000, BiomedCLIP 1.000 (0.929, bf16 batch effects), Qwen3 0.9998; hvg 1.000,
740
+ AIDO.Cell 1.000, Tahoe-x1 0.991 (0.929) and SE-600M 0.994 (0.939) (both sample / tie-break genes at random, as in their
741
+ official collators); VirTues 1.000 (all 3187 ORION tiles). End-to-end with models/ej_ad_mv_fold0 on held-out fold-0
742
+ test items (300 each), g100 R@1 raw-input vs cached features: H&E↔mIF 0.792 / 0.792, H&E↔RNA 0.194 / 0.194,
743
+ H&E↔text 0.335 / 0.336.
744
+
745
+ ## Release on the Hugging Face Hub and reproduction check (2026-10-28)
746
+
747
+ All three repos are private:
748
+ - Model, code and results: `Pathstudy/fourmodal-pathology-bind`. The README is the model card in Chinese: data, architecture, results and usage.
749
+ - Features and splits (966 MB): `datasets/Pathstudy/fourmodal-pathology-features`.
750
+ - Report: the Space `Pathstudy/imagebind-pathology-report`. Its `index.html` is the final report; the earlier report is kept as `previous_report.html`.
751
+
752
+ New scripts:
753
+ - `evaluate_saved.py`: feature-level re-evaluation of a saved checkpoint.
754
+ - `example_check.py` + `examples/`: a raw-input check on 64 fold-0 test items per dataset, built by `54_make_examples.py`.
755
+ - `reproduce.sh` and `compare_reproduction.py`: retrain from the features and compare with the shipped numbers.
756
+ - `setup_encoders.sh`: clones VirTues, ModelGenerator and MedVTok at the pinned commits and fetches the MedVTok weights.
757
+ - `requirements.txt`.
758
+
759
+ Code changes:
760
+ - `52_final_model.py --out NAME`, so a retraining does not overwrite the shipped checkpoints or results.
761
+ - `embed.py`: `$UNI2_PATH`, falling back to the gated HF repo.
762
+ - `43_rnafm_tahoex1.py`: the Geneformer name→ID dictionary is now loaded with `hf_hub_download` at a pinned revision. It used to be globbed from the local HF cache.
763
+
764
+ Check run in a fresh directory, downloaded from the Hub, with a new uv virtualenv (Python 3.11, torch 2.7.1+cu126) on an RTX A5000:
765
+ - **`evaluate_saved.py`:** equal to `test.csv` (max |diff| 8e-17).
766
+ - **Fold-0 and 5-fold retraining:** every pair is identical to the shipped numbers, stage 1 and ens-es (|diff| 0).
767
+ - **Release retraining:** the same early-stopping steps, [200, 200, 900].
768
+ - **`example_check.py`:** all encoders reproduce the cached features. Cosine is 1.000 except Tahoe-x1 .991, SE-600M .995 and VirTues .999 (64-tile standardisation).
769
+ - **R@1 within 64 items, raw inputs vs cached features:**
770
+
771
+ | Pair | Raw inputs | Cached features |
772
+ |---|---|---|
773
+ | H&E↔RNA | .227 | .227 |
774
+ | H&E↔text | .398 | .398 |
775
+ | H&E↔mIF | .828 | .875 |
776
+
777
+ - **Bug found by the fresh run and fixed:** Tahoe-x1 raw input needed `cache/external/dlpfc_genes_full.csv`. That file now ships with the model repo.
778
+ - **Not covered:** the frozen-encoder weights came from the existing HF cache on this machine, and UNI2-h from its local copy. A new machine downloads them from the Hub at the pinned revisions, and UNI2-h needs gated access.
biomedclip_text.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BiomedCLIP text tower without open_clip (not installed in this env): BiomedBERT + 2-layer MLP projection, CLS pooling,
2
+ 256 tokens, attention mask = token != pad (= open_clip HFTextEncoder with hf_proj_type mlp, cls_last_hidden_state_pooler).
3
+ Weights: text.* of microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224/open_clip_pytorch_model.bin, strict load."""
4
+ import torch
5
+ import torch.nn as nn
6
+ from huggingface_hub import hf_hub_download
7
+ from transformers import AutoConfig, AutoTokenizer, BertModel
8
+
9
+ REPO = "microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224"
10
+ BERT = "microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract"
11
+
12
+
13
+ class BiomedCLIPText(nn.Module):
14
+ def __init__(self):
15
+ super().__init__()
16
+ self.transformer = BertModel(AutoConfig.from_pretrained(BERT), add_pooling_layer=False)
17
+ self.proj = nn.Sequential(nn.Linear(768, 640, bias=False), nn.GELU(), nn.Linear(640, 512, bias=False))
18
+ sd = torch.load(hf_hub_download(REPO, "open_clip_pytorch_model.bin"), map_location="cpu", weights_only=True)
19
+ sd = {k[5:]: v for k, v in sd.items() if k.startswith("text.") and not k.endswith("position_ids")}
20
+ self.load_state_dict(sd, strict=True)
21
+ self.tok = AutoTokenizer.from_pretrained(BERT)
22
+
23
+ def forward(self, texts):
24
+ dev = next(self.parameters()).device
25
+ ids = self.tok(texts, return_tensors="pt", max_length=256, padding="max_length", truncation=True).input_ids.to(dev)
26
+ h = self.transformer(input_ids=ids, attention_mask=(ids != self.tok.pad_token_id).long()).last_hidden_state
27
+ return self.proj(h[:, 0])
cache/external/dlpfc_genes_full.csv ADDED
The diff for this file is too large to render. See raw diff
 
common.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared paths / helpers for the ORION ImageBind-style 4-modality experiments.
2
+
3
+ Modalities (all spatially paired per 333x333 tile at 20x):
4
+ he : H&E RGB tile -> anchor, frozen ImageBind-Huge vision encoder
5
+ if : 17-channel multiplex IF (uint8) -> trainable encoder
6
+ nuclei : nuclei instance mask (int32) -> trainable encoder (binary mask + boundary channels)
7
+ markers : per-tile marker vector (17 *_prop + 16 *_count from the dataframes) -> trainable MLP
8
+ """
9
+ import os
10
+ import sys
11
+ import types
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ ROOT = os.path.dirname(os.path.abspath(__file__))
17
+ ORION = os.path.join(ROOT, "..", "ORION", "ORIONCRC_dataset_tile_20x")
18
+ CACHE = os.path.join(ROOT, "cache")
19
+ SPLITS = os.path.join(ROOT, "splits")
20
+ RUNS = os.path.join(ROOT, "runs")
21
+ IB_WEIGHTS = os.path.join(ROOT, "weights", "imagebind_huge.pth")
22
+ IMG = 224
23
+
24
+ MODALITIES = ["he", "if", "nuclei", "markers"]
25
+
26
+
27
+ def load_all_tiles():
28
+ """All tiles across the official train/val/test csvs, with a stable global index."""
29
+ dfs = []
30
+ for s in ["train", "val", "test"]:
31
+ d = pd.read_csv(os.path.join(ORION, f"{s}_dataframe.csv"))
32
+ d["orion_split"] = s
33
+ dfs.append(d)
34
+ df = pd.concat(dfs, ignore_index=True)
35
+ slides = pd.read_csv(os.path.join(ORION, "slide_dataframe.csv"))
36
+ df = df.merge(slides[["in_slide_name", "orion_slide_id", "targ_slide_path"]], on="in_slide_name", how="left")
37
+ df["patient"] = df.orion_slide_id.str.split("_").str[0] # CRC33_01 / CRC33_02 -> CRC33
38
+ df["if_batch"] = df.targ_slide_path.str.extract(r"_(C59[a-z]X)_")[0] # IF acquisition batch
39
+ df["tile_id"] = np.arange(len(df))
40
+ return df
41
+
42
+
43
+ def marker_columns(df):
44
+ prop = [c for c in df.columns if c.endswith("_prop")]
45
+ cnt = [c for c in df.columns if c.endswith("_count")]
46
+ return prop, cnt
47
+
48
+
49
+ def marker_matrix(df):
50
+ prop, cnt = marker_columns(df)
51
+ return np.concatenate([df[prop].values, np.log1p(df[cnt].values)], 1).astype(np.float32)
52
+
53
+
54
+ def dihedral(x, d):
55
+ """Apply one of 8 dihedral transforms to the last two (spatial) dims of a tensor/array."""
56
+ import torch
57
+ k, flip = d % 4, d // 4
58
+ if flip:
59
+ x = torch.flip(x, dims=(-1,))
60
+ return torch.rot90(x, k, dims=(-2, -1))
61
+
62
+
63
+ def load_imagebind_vision(device="cuda"):
64
+ """Build ImageBind-Huge and load the official checkpoint without importing imagebind.data
65
+ (which needs torchaudio/pytorchvideo)."""
66
+ src = os.path.join(ROOT, "ImageBind_src", "imagebind")
67
+ if "imagebind" not in sys.modules:
68
+ pkg = types.ModuleType("imagebind")
69
+ pkg.__path__ = [src]
70
+ sys.modules["imagebind"] = pkg
71
+ import torch
72
+ from imagebind.models import imagebind_model
73
+ from imagebind.models.imagebind_model import ModalityType
74
+
75
+ model = imagebind_model.imagebind_huge(pretrained=False)
76
+ sd = torch.load(IB_WEIGHTS, map_location="cpu")
77
+ missing, unexpected = model.load_state_dict(sd, strict=True), None
78
+ model.eval().to(device)
79
+ return model, ModalityType
80
+
81
+
82
+ def metrics(zq, zg, g100=True, eps=1e-6):
83
+ """Tie-aware retrieval metrics (expected rank under random tie-breaking; template texts can tie)."""
84
+ import torch
85
+ from scipy.optimize import linear_sum_assignment
86
+ sim = (zq @ zg.T).numpy()
87
+ n = len(sim)
88
+ d = sim[np.arange(n), np.arange(n)][:, None]
89
+ gt = (sim > d + eps).sum(1)
90
+ tie = (np.abs(sim - d) <= eps).sum(1) - 1 # other candidates tied with the true match
91
+
92
+ def rk(k): # P(true match within top-k) with random tie-breaking
93
+ return np.clip((k - gt) / (tie + 1), 0, 1).mean()
94
+
95
+ r, c = linear_sum_assignment(-sim)
96
+ res = {"R@1": rk(1), "R@5": rk(5), "R@10": rk(10), "MRR": float(np.mean(1 / (gt + tie / 2 + 1))),
97
+ "pair_acc": float((c[np.argsort(r)] == np.arange(n)).mean()), "N": n}
98
+ if g100:
99
+ rng = np.random.default_rng(0)
100
+ if n <= 100:
101
+ res["R@1_g100"] = res["R@1"]
102
+ else:
103
+ res["R@1_g100"] = float(np.mean([metrics(zq[s], zg[s], g100=False)["R@1"]
104
+ for s in (torch.from_numpy(rng.choice(n, 100, replace=False)) for _ in range(50))]))
105
+ return res
compare_reproduction.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare a retraining (reproduce.sh) with the shipped results.
2
+
3
+ python -I compare_reproduction.py [TAG=repro]
4
+
5
+ fold 0 : models/<TAG>_fold0/test.csv vs models/ej_ad_mv_fold0/test.csv
6
+ 5-fold : results/final_cv_long_<TAG>.csv vs results/final_cv_long.csv (rule ens-es and stage 1, mean over folds)
7
+ Stage 1 (mIF / RNA / text heads) is seeded and deterministic on the same GPU / library stack, so the emergent pairs
8
+ should agree to ~1e-3; stage 2 (H&E adapter, 3 early-stopped seeds) is not bit-deterministic on GPU, so the trained
9
+ H&E pairs agree within the run-to-run spread (5-fold seed std of a single adapter: mIF 0.009, text 0.011).
10
+ Tolerances: |diff| <= 0.01 (emergent / stage 1), <= 0.03 (fold-0 H&E pairs), <= 0.015 (5-fold means of H&E pairs).
11
+ """
12
+ import os
13
+ import sys
14
+
15
+ import pandas as pd
16
+
17
+ ROOT = os.path.dirname(os.path.abspath(__file__))
18
+ TAG = sys.argv[1] if len(sys.argv) > 1 else "repro"
19
+ pd.set_option("display.width", 200)
20
+ ok = True
21
+
22
+
23
+ def show(D, tol_trained, title):
24
+ global ok
25
+ D["abs_diff"] = (D.reproduced - D.shipped).abs()
26
+ D["tol"] = [tol_trained if p.startswith("H&E") and r != "stage1" else 0.01 for p, r in zip(D.pair, D.get("rule", [""] * len(D)))]
27
+ D["ok"] = D.abs_diff <= D.tol
28
+ ok &= bool(D.ok.all())
29
+ print(f"\n{title}\n{D.round(4).to_string(index=False)}")
30
+
31
+
32
+ f0 = os.path.join(ROOT, "models", f"{TAG}_fold0", "test.csv")
33
+ if os.path.exists(f0):
34
+ a = pd.read_csv(f0).set_index("pair")["R@1"]
35
+ b = pd.read_csv(os.path.join(ROOT, "models", "ej_ad_mv_fold0", "test.csv")).set_index("pair")["R@1"]
36
+ show(pd.DataFrame({"pair": a.index, "reproduced": a.values, "shipped": b.loc[a.index].values}), 0.03,
37
+ "fold 0, held-out test R@1 (g100)")
38
+ cv = os.path.join(ROOT, "results", f"final_cv_long_{TAG}.csv")
39
+ if os.path.exists(cv):
40
+ m = lambda f: pd.read_csv(f).query("rule in ['stage1', 'ens-es']").groupby(["rule", "pair"], sort=False)["R@1"].mean()
41
+ a, b = m(cv), m(os.path.join(ROOT, "results", "final_cv_long.csv"))
42
+ show(pd.DataFrame({"rule": [r for r, _ in a.index], "pair": [p for _, p in a.index], "reproduced": a.values,
43
+ "shipped": b.loc[a.index].values}), 0.015, "5-fold mean test R@1 (g100)")
44
+ print("\nREPRODUCED within tolerance" if ok else "\nNOT reproduced within tolerance (see ok=False rows)")
45
+ sys.exit(0 if ok else 1)
embed.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Four-modality embedding model: H&E, mIF (protein), RNA (spot expression) and text in ONE 1536-d space.
2
+
3
+ Configuration EJ-ad+MV (RESULTS.md, "MedVTok as H&E image information + adapter-only joint training"):
4
+ stage 1 every modality encoder gets its own head into the frozen UNI2-h space (InfoNCE against UNI2 on REAL pairs:
5
+ ORION H&E-mIF, HEST H&E-RNA, TCGA/PathGen H&E-text)
6
+ mIF : VirTues (16-marker ORION panel) -> MLP
7
+ RNA : hvg (log1p CP10k of 2000 HEST HVGs), Tahoe-x1 70M, SE-600M, AIDO.Cell-100M -> one linear head each
8
+ text : KEEP, BiomedCLIP (text tower), Qwen3-Embedding-0.6B -> one linear head each
9
+ RNA / text = late fusion: z = normalise(sum_e normalise(head_e(x_e)))
10
+ stage 2 all mIF / RNA / text heads frozen; only a zero-initialised residual H&E adapter (+ temperatures) trains:
11
+ H&E = UNI2 + A([UNI2, c * MedVTok])
12
+ so mIF / RNA / text embeddings (and every emergent mIF<->RNA<->text pairing) are exactly the stage-1 ones.
13
+ Checkpoints are written by 52_final_model.py (models/<name>/model.pt + meta.json).
14
+
15
+ Usage (python: .venv/bin/python -I, run from anywhere; GPU):
16
+ from embed import Embedder
17
+ E = Embedder("models/ej_ad_mv") # release model
18
+ z = E.he(images) # H&E tiles: list of PIL images / (N, H, W, 3) uint8; ~0.5 um/px, 112-166 um field
19
+ z = E.text(["Tumor cells with ...", ...])
20
+ z = E.rna(counts, genes, zscore="train") # raw counts (spots x genes; numpy or scipy.sparse), HGNC symbols
21
+ z = E.mif(tiles, slide) # (N, 16, H, W) 8-bit-scale mIF tiles, channels = MIF_MARKERS, + slide id per tile
22
+ sim = z_a @ z_b.T # every output: L2-normalised (N, 1536) float32 torch tensor on CPU
23
+ Feature-level entry points (precomputed encoder features, as in cache/):
24
+ E.he_from_feats(uni2, medvtok), E.mif_from_feats(virtues), E.rna_from_feats({enc: X}), E.text_from_feats({enc: X})
25
+ Raw-input pre-processing is exactly the one used to build the training features (functions imported from
26
+ 04b_ / 14_ / 43_ / 45_ / 45b_ / 50_ logic), see each method. Notes:
27
+ * RNA zscore="train" uses the HEST-train feature statistics; zscore="self" z-scores each FM feature over the given
28
+ spots (what the external DLPFC test does; use for a new dataset of >= a few hundred spots).
29
+ * mIF standardisation (per-slide 0.99-quantile clip, log1p, mean / std, blur) is computed over the tiles given in
30
+ one call, so embed a whole dataset (or at least whole slides) at once.
31
+ * Licences of the frozen encoders: UNI2-h (MahmoodLab, research), MedVTok (MIT; aligned to CONCH v1.5, CC-BY-NC-ND),
32
+ SE-600M and AIDO.Cell (non-commercial), Tahoe-x1 (Apache-2.0), VirTues, KEEP, BiomedCLIP (MIT), Qwen3 (Apache-2.0).
33
+ """
34
+ import importlib.util
35
+ import os
36
+ import sys
37
+
38
+ import numpy as np
39
+ import torch
40
+ import torch.nn as nn
41
+ import torch.nn.functional as F
42
+
43
+ ROOT = os.path.dirname(os.path.abspath(__file__))
44
+ if ROOT not in sys.path:
45
+ sys.path.insert(0, ROOT)
46
+
47
+ DIM, MV_DIM = 1536, 1152
48
+ RNA_E, TXT_E = ["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]
49
+ MIF_MARKERS = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e", "CD163",
50
+ "E-cadherin", "Ki67", "Pan-CK", "SMA"] # ORION panel without PD-1 (= 30_virtues_shared.py --m16)
51
+ UNI2_LOCAL = "/diskarray/home/tl688/reg2026/model_alldata/uni2-h.bin" # override with $UNI2_PATH
52
+
53
+
54
+ def uni2_weights():
55
+ """UNI2-h state dict path: $UNI2_PATH, else the local copy, else the gated HF repo MahmoodLab/UNI2-h"""
56
+ p = os.environ.get("UNI2_PATH", UNI2_LOCAL)
57
+ if os.path.exists(p):
58
+ return p
59
+ from huggingface_hub import hf_hub_download
60
+ return hf_hub_download("MahmoodLab/UNI2-h", "pytorch_model.bin")
61
+ nz = lambda x: F.normalize(x, dim=-1)
62
+
63
+
64
+ def mlp(din, dout=DIM, hid=512): # = 42_ / 47_ / 48_ / 51_
65
+ return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
66
+ nn.LayerNorm(hid), nn.Linear(hid, dout))
67
+
68
+
69
+ class Bind4(nn.Module):
70
+ """Heads + H&E adapter. Inputs are raw encoder features; normalisation statistics live in buffers."""
71
+
72
+ def __init__(self, dims, adapter="cat", n_adapters=1):
73
+ """dims: {'mif': 512, 'rna:hvg': 2000, ..., 'text:keep': 768, ...}; adapter None / 'u' / 'cat';
74
+ n_adapters > 1: seed ensemble, H&E = UNI2 + mean_s A_s(.)"""
75
+ super().__init__()
76
+ self.keys = list(dims)
77
+ self.heads = nn.ModuleDict({self._k(k): (mlp(d) if k == "mif" else nn.Linear(d, DIM)) for k, d in dims.items()})
78
+ self.logit = nn.ParameterDict({self._k(k): nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32))
79
+ for k in dims})
80
+ for k, d in dims.items(): # feature standardisation (identity where the encoder is used raw)
81
+ self.register_buffer(f"mu_{self._k(k)}", torch.zeros(d))
82
+ self.register_buffer(f"sd_{self._k(k)}", torch.ones(d))
83
+ self.register_buffer(f"zs_{self._k(k)}", torch.tensor(False)) # True once statistics are set
84
+ self.adapter = adapter
85
+ self.register_buffer("c_mv", torch.tensor(1.0))
86
+ self.ah = None
87
+ if adapter:
88
+ self.ah = nn.ModuleList([self.new_adapter() for _ in range(n_adapters)])
89
+
90
+ def new_adapter(self):
91
+ """zero-initialised output -> starts exactly at H&E = UNI2"""
92
+ a = nn.Sequential(nn.Linear(DIM + (MV_DIM if self.adapter == "cat" else 0), 512), *mlp(DIM)[1:])
93
+ nn.init.zeros_(a[-1].weight); nn.init.zeros_(a[-1].bias)
94
+ return a
95
+
96
+ @staticmethod
97
+ def _k(k):
98
+ return k.replace(":", "__").replace(".", "_").replace("-", "_")
99
+
100
+ def h(self, k):
101
+ return self.heads[self._k(k)]
102
+
103
+ def lg(self, k):
104
+ return self.logit[self._k(k)].exp().clamp(max=100)
105
+
106
+ def norm(self, k, x):
107
+ return (x - getattr(self, f"mu_{self._k(k)}")) / getattr(self, f"sd_{self._k(k)}")
108
+
109
+ def set_norm(self, k, mu, sd):
110
+ """x -> (x - mu) / sd; sd already includes the epsilon of the training code"""
111
+ getattr(self, f"mu_{self._k(k)}").copy_(mu); getattr(self, f"sd_{self._k(k)}").copy_(sd)
112
+ getattr(self, f"zs_{self._k(k)}").fill_(True)
113
+
114
+ # ---- embeddings from (raw, un-normalised) encoder features; return un-normalised vectors
115
+ def he(self, uni2, medvtok=None):
116
+ if self.ah is None:
117
+ return uni2
118
+ inp = uni2 if self.adapter == "u" else torch.cat([uni2, self.c_mv * medvtok], -1)
119
+ return uni2 + sum(a(inp) for a in self.ah) / len(self.ah)
120
+
121
+ def mif(self, x):
122
+ return self.h("mif")(self.norm("mif", x))
123
+
124
+ def fuse(self, mod, X, zscore="train"):
125
+ """X: {enc: raw features}; zscore 'train' (stored stats) / 'self' (stats of X itself, raw encoders untouched)"""
126
+ z = {}
127
+ for e, x in X.items():
128
+ k = f"{mod}:{e}"
129
+ raw = not bool(getattr(self, f"zs_{self._k(k)}"))
130
+ x = x if raw else (x - x.mean(0)) / (x.std(0) + 1e-6) if zscore == "self" else self.norm(k, x)
131
+ z[e] = nz(self.h(k)(x))
132
+ return nz(sum(z.values())), z
133
+
134
+
135
+ def _load_script(fname, argv=None):
136
+ """import one of the numbered project scripts as a module (their CLI code is under __main__ / argv-guarded)"""
137
+ old = sys.argv
138
+ if argv is not None:
139
+ sys.argv = argv
140
+ try:
141
+ spec = importlib.util.spec_from_file_location("m_" + fname.split(".")[0], os.path.join(ROOT, fname))
142
+ m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
143
+ finally:
144
+ sys.argv = old
145
+ return m
146
+
147
+
148
+ def _as_uint8_list(images):
149
+ from PIL import Image, PngImagePlugin
150
+ PngImagePlugin.MAX_TEXT_CHUNK = max(PngImagePlugin.MAX_TEXT_CHUNK, 64 * 1024 * 1024) # PathGen PNGs: huge ICC profiles
151
+ out = []
152
+ for im in images:
153
+ if isinstance(im, (str, os.PathLike)):
154
+ im = Image.open(im)
155
+ out.append(im.convert("RGB") if isinstance(im, Image.Image) else Image.fromarray(np.asarray(im, np.uint8)))
156
+ return out
157
+
158
+
159
+ class Embedder:
160
+ def __init__(self, path=os.path.join(ROOT, "models", "ej_ad_mv"), device="cuda"):
161
+ import json
162
+ self.dev = device
163
+ ck = torch.load(os.path.join(path, "model.pt"), map_location="cpu", weights_only=True)
164
+ self.meta = json.load(open(os.path.join(path, "meta.json")))
165
+ self.model = Bind4(self.meta["dims"], self.meta["adapter"], self.meta.get("n_adapters", 1))
166
+ self.model.load_state_dict(ck, strict=True)
167
+ self.model.eval().to(device)
168
+ self.hvg = np.asarray(self.meta["hvg_genes"], dtype=str)
169
+ self._enc = {}
170
+
171
+ # ------------------------------------------------------------------ feature-level
172
+ @torch.no_grad()
173
+ def he_from_feats(self, uni2, medvtok=None):
174
+ t = lambda a: None if a is None else torch.as_tensor(np.asarray(a, np.float32)).to(self.dev)
175
+ return nz(self.model.he(t(uni2), t(medvtok))).cpu()
176
+
177
+ @torch.no_grad()
178
+ def mif_from_feats(self, virtues):
179
+ return nz(self.model.mif(torch.as_tensor(np.asarray(virtues, np.float32)).to(self.dev))).cpu()
180
+
181
+ @torch.no_grad()
182
+ def rna_from_feats(self, feats, zscore="train"):
183
+ X = {e: torch.as_tensor(np.asarray(x, np.float32)).to(self.dev) for e, x in feats.items()}
184
+ return self.model.fuse("rna", X, zscore)[0].cpu()
185
+
186
+ @torch.no_grad()
187
+ def text_from_feats(self, feats):
188
+ X = {e: torch.as_tensor(np.asarray(x, np.float32)).to(self.dev) for e, x in feats.items()}
189
+ return self.model.fuse("text", X)[0].cpu()
190
+
191
+ # ------------------------------------------------------------------ frozen encoders (lazy)
192
+ def _get(self, name):
193
+ if name in self._enc:
194
+ return self._enc[name]
195
+ if name == "uni2": # = 04b_extract_anchor_fm.py
196
+ import timm
197
+ m = timm.create_model(
198
+ "vit_giant_patch14_224", pretrained=False, img_size=224, patch_size=14, depth=24, num_heads=24,
199
+ init_values=1e-5, embed_dim=1536, mlp_ratio=2.66667 * 2, num_classes=0, no_embed_class=True,
200
+ mlp_layer=timm.layers.SwiGLUPacked, act_layer=torch.nn.SiLU, reg_tokens=8, dynamic_img_size=True)
201
+ m.load_state_dict(torch.load(uni2_weights(), map_location="cpu", weights_only=True), strict=True)
202
+ obj = m.eval().to(self.dev)
203
+ elif name == "medvtok": # = 50_medvtok_feats.py
204
+ import json
205
+ sys.path.insert(0, os.path.join(ROOT, "ext", "medvtok", "repo"))
206
+ from models.medvtok import MedVTok
207
+ w = os.path.join(ROOT, "ext", "medvtok", "weights")
208
+ cfg = json.load(open(os.path.join(w, "config.json")))
209
+ m = MedVTok(**cfg["model_kwargs"])
210
+ m.load_state_dict(torch.load(os.path.join(w, "ckpt.pth"), map_location="cpu", weights_only=True), strict=True)
211
+ del m.decoder
212
+ obj = m.eval().to(self.dev)
213
+ elif name == "keep":
214
+ obj = _load_script("45b_text_feats_pathvlm.py").KEEPText().to(self.dev).eval()
215
+ elif name in ("biomedclip", "qwen3-0.6b"):
216
+ obj = _load_script("45_text_feats.py", [sys.argv[0]]).encoder(name)[0]
217
+ elif name == "tahoex1":
218
+ T = _load_script("43_rnafm_tahoex1.py")
219
+ obj = (T, *T.load_model())
220
+ elif name == "se600m":
221
+ S = _load_script("43_rnafm_se600m.py")
222
+ obj = (S, *S.build())
223
+ elif name == "aidocell":
224
+ A = _load_script("43_rnafm_aidocell.py")
225
+ obj = (A, *A.load_model())
226
+ elif name == "virtues":
227
+ V = _load_script("14_extract_if_fm.py", [sys.argv[0], "virtues", "--no_probe"])
228
+ uni = dict(V.MARKERS)
229
+ model, midx = V.load_virtues([uni[m] for m in MIF_MARKERS])
230
+ obj = (V, model, midx.to(self.dev))
231
+ else:
232
+ raise KeyError(name)
233
+ self._enc[name] = obj
234
+ return obj
235
+
236
+ def release(self):
237
+ """free the frozen encoders (GPU memory)"""
238
+ self._enc.clear(); torch.cuda.empty_cache()
239
+
240
+ # ------------------------------------------------------------------ raw-input embedding
241
+ @torch.no_grad()
242
+ def he_feats(self, images, bs=32):
243
+ """UNI2-h CLS (224 px PIL bicubic, ImageNet norm, fp16) + MedVTok feats (256 px PIL bicubic, [-1, 1], bf16)"""
244
+ ims = _as_uint8_list(images)
245
+ uni, mv = self._get("uni2"), self._get("medvtok")
246
+ mean = torch.tensor([0.485, 0.456, 0.406], device=self.dev).view(1, 3, 1, 1)
247
+ std = torch.tensor([0.229, 0.224, 0.225], device=self.dev).view(1, 3, 1, 1)
248
+ U, M = [], []
249
+ for s in range(0, len(ims), bs):
250
+ b = ims[s:s + bs]
251
+ x = torch.from_numpy(np.stack([np.asarray(i.resize((224, 224), 3)) for i in b])).to(self.dev)
252
+ x = (x.permute(0, 3, 1, 2).float() / 255 - mean) / std
253
+ with torch.autocast("cuda", dtype=torch.float16):
254
+ U.append(uni(x).float().cpu())
255
+ x = torch.from_numpy(np.stack([np.asarray(i.resize((256, 256), 3)) for i in b])).to(self.dev)
256
+ x = x.permute(0, 3, 1, 2).float() / 255 * 2 - 1
257
+ with torch.autocast("cuda", dtype=torch.bfloat16):
258
+ M.append(mv([i for i in x], sample_posterior=False, decode=False, return_batched_tensor=False)["feats"].float().cpu())
259
+ return torch.cat(U), torch.cat(M)
260
+
261
+ def he(self, images, bs=32):
262
+ return self.he_from_feats(*self.he_feats(images, bs))
263
+
264
+ @torch.no_grad()
265
+ def text_feats(self, texts, bs=64):
266
+ texts = ["" if t is None else str(t) for t in texts]
267
+ order = np.argsort([len(t) for t in texts]) # length-sorted batches as in 45_ / 45b_
268
+ out = {}
269
+ for e in TXT_E:
270
+ f = self._get(e)
271
+ E = None
272
+ for i in range(0, len(texts), bs):
273
+ idx = order[i:i + bs]
274
+ if e == "keep": # 45b_: fp32
275
+ z = f([texts[j] for j in idx]).float().cpu().numpy()
276
+ else: # 45_: bf16 autocast
277
+ with torch.autocast("cuda", dtype=torch.bfloat16):
278
+ z = f([texts[j] for j in idx]).float().cpu().numpy()
279
+ if E is None:
280
+ E = np.zeros((len(texts), z.shape[1]), np.float32)
281
+ E[idx] = z
282
+ out[e] = E.astype(np.float16).astype(np.float32) # the training features were stored as float16
283
+ return out
284
+
285
+ def text(self, texts, bs=64):
286
+ return self.text_from_feats(self.text_feats(texts, bs))
287
+
288
+ def rna_feats(self, counts, genes, encoders=RNA_E):
289
+ import scipy.sparse as sp
290
+ X = sp.csr_matrix(counts, dtype=np.float32)
291
+ X.eliminate_zeros()
292
+ genes = np.asarray(genes, dtype=str)
293
+ out = {}
294
+ for e in encoders:
295
+ if e == "hvg": # 21_hest_prep.py: log1p CP10k over the given genes, then the 2000 HEST HVGs (absent -> 0)
296
+ tot = np.asarray(X.sum(1)).ravel()
297
+ L = (sp.diags(1e4 / np.clip(tot, 1, None)) @ X).tocsc()
298
+ L.data = np.log1p(L.data)
299
+ pos = {}
300
+ for j, g in enumerate(genes):
301
+ pos.setdefault(g, j)
302
+ cols = [pos.get(g, -1) for g in self.hvg]
303
+ H = np.zeros((X.shape[0], len(self.hvg)), np.float32)
304
+ ok = [i for i, c in enumerate(cols) if c >= 0]
305
+ H[:, ok] = L[:, [cols[i] for i in ok]].toarray()
306
+ out[e] = H
307
+ elif e == "tahoex1":
308
+ T, m, _, ccfg, vocab = self._get(e)
309
+ seqs, _ = T.make_seqs(X, genes, "user", vocab, ccfg, np.random.default_rng(0))
310
+ out[e] = T.embed(m, seqs)
311
+ elif e == "se600m":
312
+ S, m, cfg, g2i = self._get(e)
313
+ Tk, C, _ = S.sentences(X, genes, g2i, cfg["dataset"]["pad_length"], cfg["dataset"]["cls_token_idx"], seed=0)
314
+ out[e] = S.embed(m, Tk, C)
315
+ elif e == "aidocell":
316
+ A, m, _ = self._get(e)
317
+ Xa, present = A.align(X, genes, "user")
318
+ out[e] = A.embed(m, Xa, present)
319
+ out[e] = np.asarray(out[e], np.float32).astype(np.float16).astype(np.float32) # stored as float16
320
+ return out
321
+
322
+ def rna(self, counts, genes, zscore="train", encoders=RNA_E):
323
+ return self.rna_from_feats(self.rna_feats(counts, genes, encoders), zscore)
324
+
325
+ @torch.no_grad()
326
+ def mif_feats(self, tiles, slide, size=168, bs=32):
327
+ """tiles (N, 16, H, W) in MIF_MARKERS order, 8-bit intensity scale (0-255); 14_ preprocessing: antialiased
328
+ bilinear resize to `size` px (~1 um/px for a 166 um tile), per-slide 0.99-quantile clip + log1p, mean / std
329
+ over the given tiles, 3x3 Gaussian blur; VirTues mean patch-summary token (fp16)."""
330
+ V, model, midx = self._get("virtues")
331
+ N = len(tiles)
332
+ X = np.empty((N, len(MIF_MARKERS), size, size), np.float16)
333
+ for s in range(0, N, 64):
334
+ x = torch.as_tensor(np.asarray(tiles[s:s + 64], np.float32)).to(self.dev)
335
+ X[s:s + 64] = F.interpolate(x, size=(size, size), mode="bilinear", antialias=True,
336
+ align_corners=False).clamp(min=0).half().cpu().numpy()
337
+ X = V.standardize(X, np.asarray(slide))[0]
338
+ out = np.zeros((N, 512), np.float32)
339
+ with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
340
+ for s in range(0, N, bs):
341
+ x = torch.from_numpy(X[s:s + bs]).to(self.dev).float()
342
+ ps = model.encoder.forward_list([c for c in x], [midx] * len(x)).patch_summary_tokens
343
+ out[s:s + bs] = torch.stack([p.float().mean((0, 1)) for p in ps]).cpu().numpy()
344
+ return out.astype(np.float16).astype(np.float32)
345
+
346
+ def mif(self, tiles, slide, size=168):
347
+ return self.mif_from_feats(self.mif_feats(tiles, slide, size))
evaluate_saved.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate a saved checkpoint from the cached encoder features through the public embed.py API (no frozen encoders,
2
+ no raw data; needs cache/ + splits/ from the features repo).
3
+
4
+ python -I evaluate_saved.py [models/ej_ad_mv_fold0]
5
+
6
+ Uses the held-out fold-0 test items stored with the checkpoint (test_items.npz, written by 52_final_model.py fold0)
7
+ and the same g100 protocol as 52_ (100-item galleries, 50 draws, seed 0, both directions; Hungarian pair accuracy).
8
+ If the checkpoint has a test.csv, the numbers must match it (pure inference: deterministic up to float rounding).
9
+ Output: <model>/test_recomputed.csv
10
+ """
11
+ import os
12
+ import sys
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import torch
17
+
18
+ ROOT = os.path.dirname(os.path.abspath(__file__))
19
+ sys.path.insert(0, ROOT)
20
+ from embed import RNA_E, TXT_E, Embedder # noqa: E402
21
+
22
+ MD = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "models", "ej_ad_mv_fold0")
23
+ CACHE = os.path.join(ROOT, "cache")
24
+ H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
25
+ ld = lambda *p: np.load(os.path.join(*p), mmap_mode="r")
26
+ E = Embedder(MD)
27
+ TI = np.load(os.path.join(MD, "test_items.npz"))
28
+ o, hs, ii, tt = TI["orion"], TI["hest"], TI["tcga_img"], TI["tcga_txt"]
29
+
30
+
31
+ def g100(a, b, seed=0, G=100, draws=50): # = 52_final_model.g100
32
+ from scipy.optimize import linear_sum_assignment
33
+ rng = np.random.default_rng(seed)
34
+ out = {"R@1": [], "R@10": [], "pair_acc": []}
35
+ for _ in range(draws):
36
+ s = torch.from_numpy(rng.choice(len(a), min(G, len(a)), replace=False))
37
+ sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
38
+ for S in (sim, sim.T):
39
+ rank = (S > d[:, None]).sum(1)
40
+ out["R@1"].append((rank < 1).mean() / 2); out["R@10"].append((rank < 10).mean() / 2)
41
+ ri, ci = linear_sum_assignment(-sim)
42
+ out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
43
+ return {m: float(np.sum(v) / draws) for m, v in out.items()}
44
+
45
+
46
+ def virt_hvg(st): # STPath virtual RNA -> log1p CP10k on the model's HVG list (= 52_)
47
+ g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
48
+ c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
49
+ c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
50
+ return c[:, pd.Series(range(len(g)), index=g).loc[E.hvg].values]
51
+
52
+
53
+ def rna(st, idx):
54
+ if st == "hest":
55
+ F = {e: (ld(H, "hvg.npy") if e == "hvg" else ld(RF, f"{e}_hest.npy"))[idx] for e in RNA_E}
56
+ else:
57
+ F = {e: (virt_hvg({"orion_v": "orion", "tcga_v": "tcga_text"}[st]) if e == "hvg" else ld(RF, f"{e}_{st}.npy"))[idx]
58
+ for e in RNA_E}
59
+ return E.rna_from_feats(F)
60
+
61
+
62
+ text = lambda st, idx: E.text_from_feats({e: ld(CACHE, f"{st}_text_{e}.npy")[idx, 0] for e in TXT_E})
63
+ zm = E.mif_from_feats(ld(CACHE, "if_virtues_m16.npy")[o, 0])
64
+ zt = text("alltxt", tt)
65
+ res = {"H&E<->mIF (ORION)": g100(E.he_from_feats(ld(CACHE, "he_uni2.npy")[o, 0], ld(CACHE, "he_medvtok.npy")[o, 0]), zm),
66
+ "H&E<->RNA (HEST)": g100(E.he_from_feats(ld(H, "he_uni2.npy")[hs, 0], ld(H, "he_medvtok.npy")[hs, 0]), rna("hest", hs)),
67
+ "H&E<->text (TCGA)": g100(E.he_from_feats(ld(CACHE, "allimg_he_uni2.npy")[ii, 0],
68
+ ld(CACHE, "allimg_he_medvtok.npy")[ii, 0]), zt),
69
+ "mIF<->RNA(virtual) (ORION)": g100(zm, rna("orion_v", o)),
70
+ "mIF<->text(synthetic) (ORION)": g100(zm, text("orion_gentext", o)),
71
+ "text<->RNA(virtual) (TCGA)": g100(zt, rna("tcga_v", ii)),
72
+ "text<->mIF(MIPHEI) (TCGA)": g100(zt, E.mif_from_feats(ld(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy")[ii, 0]))}
73
+ D = pd.DataFrame([{"pair": p, **r} for p, r in res.items()])
74
+ ref = os.path.join(MD, "test.csv")
75
+ if os.path.exists(ref):
76
+ D["R@1_saved"] = pd.read_csv(ref).set_index("pair").loc[D.pair, "R@1"].values
77
+ D["abs_diff"] = (D["R@1"] - D["R@1_saved"]).abs()
78
+ D.to_csv(os.path.join(MD, "test_recomputed.csv"), index=False)
79
+ pd.set_option("display.width", 200)
80
+ print(f"{MD}: fold-0 test, g100 (chance R@1 0.01)\n{D.round(4).to_string(index=False)}")
81
+ if "abs_diff" in D:
82
+ ok = D.abs_diff.max() < 2e-3
83
+ print("MATCHES the saved test.csv" if ok else "DOES NOT MATCH the saved test.csv", f"(max |diff| {D.abs_diff.max():.2e})")
84
+ sys.exit(0 if ok else 1)
example_check.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end check of embed.py from RAW inputs on examples/ (64 held-out fold-0 test items per dataset; needs the
2
+ frozen encoders, see setup_encoders.sh; GPU).
3
+
4
+ python -I example_check.py [models/ej_ad_mv_fold0]
5
+
6
+ 1. raw input -> encoder features, compared with the cached features the model was trained / tested on
7
+ (examples/ref_feats.npz): per-item cosine. Expected ~1.000; Tahoe-x1 / SE-600M ~0.99 (their official input
8
+ pipelines sample genes at random); VirTues lower here because mIF standardisation statistics are computed over the
9
+ tiles given in one call (64 tiles here vs the whole ORION set for the cache).
10
+ 2. raw input -> 1536-d embeddings -> retrieval inside the 64 items (R@1, both directions; chance 1/64), next to the
11
+ same retrieval from the cached features.
12
+ Output: examples/example_check.csv; exit code 1 if a deterministic encoder does not reproduce its cached features.
13
+ """
14
+ import os
15
+ import sys
16
+ import time
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+ import scipy.sparse as sp
21
+ import torch
22
+
23
+ ROOT = os.path.dirname(os.path.abspath(__file__))
24
+ sys.path.insert(0, ROOT)
25
+ from embed import RNA_E, TXT_E, Embedder # noqa: E402
26
+
27
+ MD = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "models", "ej_ad_mv_fold0")
28
+ EX = os.path.join(ROOT, "examples")
29
+ E = Embedder(MD)
30
+ R = dict(np.load(os.path.join(EX, "ref_feats.npz")))
31
+ rows, t0 = [], time.time()
32
+ MIN_COS = {"uni2": .999, "medvtok": .999, "keep": .999, "biomedclip": .99, "qwen3-0.6b": .999, "hvg": .999,
33
+ "aidocell": .999, "tahoex1": .97, "se600m": .97, "virtues": 0.0} # median cosine required (virtues: report only)
34
+
35
+
36
+ def cos(a, b):
37
+ a, b = torch.as_tensor(np.asarray(a, np.float32)), torch.as_tensor(np.asarray(b, np.float32))
38
+ return torch.nn.functional.cosine_similarity(a, b, dim=-1).numpy()
39
+
40
+
41
+ def feat(ds, enc, raw):
42
+ c = cos(raw, R[f"{ds}_{enc}"])
43
+ rows.append({"check": "features", "data": ds, "encoder": enc, "n": len(c), "cos_median": float(np.median(c)),
44
+ "cos_min": float(c.min()), "ok": bool(np.median(c) >= MIN_COS[enc])})
45
+ print(f"[{time.time() - t0:4.0f}s] {ds:5s} {enc:11s} cos median {np.median(c):.4f} min {c.min():.4f}"
46
+ f"{'' if rows[-1]['ok'] else ' <-- FAIL'}", flush=True)
47
+
48
+
49
+ def r1(a, b):
50
+ s = a @ b.T; d = s.diag()
51
+ return float((((s > d[:, None]).sum(1) == 0).float().mean() + ((s.T > d[:, None]).sum(1) == 0).float().mean()) / 2)
52
+
53
+
54
+ # raw H&E (PNG files / uint8 arrays) -> UNI2 + MedVTok
55
+ U, M = {}, {}
56
+ U["orion"], M["orion"] = E.he_feats(sorted(os.path.join(EX, "orion_he", f) for f in os.listdir(os.path.join(EX, "orion_he"))))
57
+ U["hest"], M["hest"] = E.he_feats(np.load(os.path.join(EX, "hest_he.npy")))
58
+ C = pd.read_csv(os.path.join(EX, "tcga_captions.csv"))
59
+ U["tcga"], M["tcga"] = E.he_feats([os.path.join(EX, "tcga_he", f) for f in C.file])
60
+ for ds in U:
61
+ feat(ds, "uni2", U[ds]); feat(ds, "medvtok", M[ds])
62
+ E.release()
63
+ # raw text -> KEEP / BiomedCLIP / Qwen3
64
+ Ft = E.text_feats(C.caption.fillna("").astype(str).tolist())
65
+ for e in TXT_E:
66
+ feat("tcga", e, Ft[e])
67
+ E.release()
68
+ # raw counts -> hvg / Tahoe-x1 / SE-600M / AIDO.Cell
69
+ genes = pd.read_csv(os.path.join(EX, "hest_genes.csv")).gene.values
70
+ Fr = E.rna_feats(sp.load_npz(os.path.join(EX, "hest_counts.npz")), genes)
71
+ for e in RNA_E:
72
+ feat("hest", e, Fr[e])
73
+ E.release()
74
+ # raw mIF tiles -> VirTues
75
+ m = np.load(os.path.join(EX, "orion_mif.npz"), allow_pickle=True)
76
+ Fm = E.mif_feats(m["tiles"], m["slide"])
77
+ feat("orion", "virtues", Fm)
78
+ E.release()
79
+
80
+ # embeddings + retrieval inside the 64 items
81
+ pairs = {"H&E<->mIF (ORION)": ((E.he_from_feats(U["orion"], M["orion"]), E.mif_from_feats(Fm)),
82
+ (E.he_from_feats(R["orion_uni2"], R["orion_medvtok"]), E.mif_from_feats(R["orion_virtues"]))),
83
+ "H&E<->RNA (HEST)": ((E.he_from_feats(U["hest"], M["hest"]), E.rna_from_feats(Fr)),
84
+ (E.he_from_feats(R["hest_uni2"], R["hest_medvtok"]),
85
+ E.rna_from_feats({e: R[f"hest_{e}"] for e in RNA_E}))),
86
+ "H&E<->text (TCGA)": ((E.he_from_feats(U["tcga"], M["tcga"]), E.text_from_feats(Ft)),
87
+ (E.he_from_feats(R["tcga_uni2"], R["tcga_medvtok"]),
88
+ E.text_from_feats({e: R[f"tcga_{e}"] for e in TXT_E})))}
89
+ for p, ((a, b), (ac, bc)) in pairs.items():
90
+ rows.append({"check": "retrieval", "data": p, "n": len(a), "R@1_raw": r1(a, b), "R@1_cached": r1(ac, bc),
91
+ "chance": 1 / len(a)})
92
+ print(f"{p:18s} R@1 within {len(a)}: raw inputs {rows[-1]['R@1_raw']:.3f} | cached features "
93
+ f"{rows[-1]['R@1_cached']:.3f} (chance {1 / len(a):.3f})", flush=True)
94
+ D = pd.DataFrame(rows)
95
+ D.to_csv(os.path.join(EX, "example_check.csv"), index=False)
96
+ bad = D[(D.check == "features") & ~D.ok.astype(bool)]
97
+ print(f"done [{time.time() - t0:.0f}s]:", "all encoders reproduce the cached features" if bad.empty else
98
+ f"FAILED {bad[['data', 'encoder']].values.tolist()}")
99
+ sys.exit(0 if bad.empty else 1)
examples/hest_counts.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:78a1f9d3b380027a1d95190409851f4b5b966daf47a7602100786362f96569eb
3
+ size 492119
examples/hest_genes.csv ADDED
The diff for this file is too large to render. See raw diff
 
examples/hest_he.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5d91cd94cfa57a713ecf10b2be3949c118ec6836fcf754e0e7e2192ab94efd92
3
+ size 9633920
examples/items.csv ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ set,idx,txt_idx
2
+ orion,94,
3
+ orion,96,
4
+ orion,98,
5
+ orion,103,
6
+ orion,107,
7
+ orion,111,
8
+ orion,115,
9
+ orion,118,
10
+ orion,140,
11
+ orion,146,
12
+ orion,152,
13
+ orion,176,
14
+ orion,204,
15
+ orion,208,
16
+ orion,263,
17
+ orion,267,
18
+ orion,272,
19
+ orion,287,
20
+ orion,292,
21
+ orion,596,
22
+ orion,598,
23
+ orion,599,
24
+ orion,608,
25
+ orion,621,
26
+ orion,652,
27
+ orion,659,
28
+ orion,661,
29
+ orion,1602,
30
+ orion,1605,
31
+ orion,1610,
32
+ orion,1613,
33
+ orion,1614,
34
+ orion,1640,
35
+ orion,1652,
36
+ orion,1658,
37
+ orion,1665,
38
+ orion,1668,
39
+ orion,1687,
40
+ orion,1688,
41
+ orion,1698,
42
+ orion,1703,
43
+ orion,1720,
44
+ orion,1721,
45
+ orion,1726,
46
+ orion,1731,
47
+ orion,1737,
48
+ orion,1753,
49
+ orion,1901,
50
+ orion,1913,
51
+ orion,1918,
52
+ orion,1932,
53
+ orion,1940,
54
+ orion,1943,
55
+ orion,1954,
56
+ orion,1961,
57
+ orion,1967,
58
+ orion,1988,
59
+ orion,2426,
60
+ orion,2442,
61
+ orion,2454,
62
+ orion,2472,
63
+ orion,2480,
64
+ orion,2482,
65
+ orion,2493,
66
+ hest,1096,
67
+ hest,1099,
68
+ hest,1158,
69
+ hest,1161,
70
+ hest,1214,
71
+ hest,1224,
72
+ hest,1263,
73
+ hest,1285,
74
+ hest,1294,
75
+ hest,1318,
76
+ hest,1416,
77
+ hest,1431,
78
+ hest,1435,
79
+ hest,1500,
80
+ hest,1597,
81
+ hest,1617,
82
+ hest,1719,
83
+ hest,1741,
84
+ hest,1827,
85
+ hest,1830,
86
+ hest,1846,
87
+ hest,1854,
88
+ hest,1858,
89
+ hest,1884,
90
+ hest,1926,
91
+ hest,1947,
92
+ hest,1996,
93
+ hest,1998,
94
+ hest,2010,
95
+ hest,2059,
96
+ hest,2132,
97
+ hest,2193,
98
+ hest,2221,
99
+ hest,2230,
100
+ hest,2242,
101
+ hest,2636,
102
+ hest,2814,
103
+ hest,2936,
104
+ hest,3387,
105
+ hest,3391,
106
+ hest,3449,
107
+ hest,3461,
108
+ hest,3465,
109
+ hest,3468,
110
+ hest,3525,
111
+ hest,5684,
112
+ hest,5694,
113
+ hest,6670,
114
+ hest,6672,
115
+ hest,6682,
116
+ hest,6684,
117
+ hest,7312,
118
+ hest,7596,
119
+ hest,7616,
120
+ hest,7677,
121
+ hest,8096,
122
+ hest,8099,
123
+ hest,8102,
124
+ hest,8113,
125
+ hest,8114,
126
+ hest,8174,
127
+ hest,8176,
128
+ hest,8639,
129
+ hest,8647,
130
+ tcga,39,41.0
131
+ tcga,113,117.0
132
+ tcga,141,146.0
133
+ tcga,188,193.0
134
+ tcga,191,196.0
135
+ tcga,268,279.0
136
+ tcga,360,374.0
137
+ tcga,369,383.0
138
+ tcga,556,581.0
139
+ tcga,601,628.0
140
+ tcga,654,684.0
141
+ tcga,686,717.0
142
+ tcga,689,720.0
143
+ tcga,712,745.0
144
+ tcga,796,837.0
145
+ tcga,1160,1220.0
146
+ tcga,1210,1273.0
147
+ tcga,1265,1332.0
148
+ tcga,1421,1491.0
149
+ tcga,1533,1609.0
150
+ tcga,1554,1632.0
151
+ tcga,1565,1643.0
152
+ tcga,2094,2200.0
153
+ tcga,2110,2217.0
154
+ tcga,2287,2404.0
155
+ tcga,2379,2500.0
156
+ tcga,2513,2640.0
157
+ tcga,2526,2653.0
158
+ tcga,2725,2864.0
159
+ tcga,3015,3172.0
160
+ tcga,3104,3264.0
161
+ tcga,3161,3330.0
162
+ tcga,3167,3336.0
163
+ tcga,3292,3467.0
164
+ tcga,3413,3595.0
165
+ tcga,3441,3626.0
166
+ tcga,3584,3776.0
167
+ tcga,3656,3850.0
168
+ tcga,3726,3922.0
169
+ tcga,3845,4047.0
170
+ tcga,4124,4341.0
171
+ tcga,4207,4428.0
172
+ tcga,4370,4598.0
173
+ tcga,4425,4653.0
174
+ tcga,4609,4851.0
175
+ tcga,4689,4933.0
176
+ tcga,4699,4943.0
177
+ tcga,4783,5030.0
178
+ tcga,4807,5057.0
179
+ tcga,4869,5126.0
180
+ tcga,4902,5160.0
181
+ tcga,4935,5195.0
182
+ tcga,4982,5244.0
183
+ tcga,5005,5269.0
184
+ tcga,5028,5293.0
185
+ tcga,5162,5438.0
186
+ tcga,5256,5538.0
187
+ tcga,5403,5691.0
188
+ tcga,5631,5934.0
189
+ tcga,5632,5935.0
190
+ tcga,5660,5964.0
191
+ tcga,5757,6070.0
192
+ tcga,5776,6090.0
193
+ tcga,6007,6335.0
examples/orion_he/00094.png ADDED
examples/orion_he/00096.png ADDED
examples/orion_he/00098.png ADDED
examples/orion_he/00103.png ADDED
examples/orion_he/00107.png ADDED
examples/orion_he/00111.png ADDED