Migrate release files 1-30
Browse files- 14_extract_if_fm.py +378 -0
- 43_rnafm_aidocell.py +230 -0
- 43_rnafm_se600m.py +261 -0
- 43_rnafm_tahoex1.py +260 -0
- 45_text_feats.py +82 -0
- 45b_text_feats_pathvlm.py +316 -0
- 50_medvtok_feats.py +91 -0
- 52_final_model.py +360 -0
- 53_embed_check.py +151 -0
- 54_make_examples.py +77 -0
- LICENSE.md +14 -0
- README.md +205 -0
- RESULTS.md +778 -0
- biomedclip_text.py +27 -0
- cache/external/dlpfc_genes_full.csv +0 -0
- common.py +105 -0
- compare_reproduction.py +45 -0
- embed.py +347 -0
- evaluate_saved.py +84 -0
- example_check.py +99 -0
- examples/hest_counts.npz +3 -0
- examples/hest_genes.csv +0 -0
- examples/hest_he.npy +3 -0
- examples/items.csv +193 -0
- examples/orion_he/00094.png +0 -0
- examples/orion_he/00096.png +0 -0
- examples/orion_he/00098.png +0 -0
- examples/orion_he/00103.png +0 -0
- examples/orion_he/00107.png +0 -0
- examples/orion_he/00111.png +0 -0
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
|