Migrate release files 181-210
Browse files- experiments/26_stpath_infer.py +427 -0
- experiments/27_azure_caption.py +217 -0
- experiments/28_eval_anchor.py +81 -0
- experiments/29_virtual_completion.py +230 -0
- experiments/30_virtues_shared.py +189 -0
- experiments/31_virtual_validation.py +288 -0
- experiments/32_gentext_prep.py +55 -0
- experiments/33_final_eval.py +356 -0
- experiments/34_gigatime_diag.py +112 -0
- experiments/35_miphei_infer.py +112 -0
- experiments/36_external_prep.py +332 -0
- experiments/37_miphei_diag.py +85 -0
- experiments/38_external_eval.py +239 -0
- experiments/39_external_check.py +75 -0
- experiments/40_clean_text.py +28 -0
- experiments/41_text_enc_clean.py +105 -0
- experiments/42_train_regimes.py +299 -0
- experiments/43_rnafm_base.py +185 -0
- experiments/43_rnafm_c2s.py +216 -0
- experiments/43_rnafm_nicheformer.py +232 -0
- experiments/44_rnafm_eval.py +275 -0
- experiments/46_text_enc_eval.py +231 -0
- experiments/47_final_ens.py +227 -0
- experiments/48_joint_ens.py +315 -0
- experiments/49_hest_patches.py +147 -0
- experiments/51_medvtok_eval.py +373 -0
- experiments/README_v1_orion.md +31 -0
- experiments/prompts.json +9 -0
- experiments/run_all.sh +14 -0
- experiments/run_bench.sh +15 -0
experiments/26_stpath_infer.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STPath (Huang et al. 2025) zero-shot spatial gene-expression prediction from H&E GigaPath features.
|
| 2 |
+
|
| 3 |
+
Source code https://github.com/Graph-and-Geometric-Learning/STPath (commit 3b10e6d, no LICENSE file)
|
| 4 |
+
weights https://huggingface.co/tlhuang/STPath stfm.pth (no licence declared; not gated)
|
| 5 |
+
local copy: ext/stpath/{repo,weights} -- treated as untrusted: state dict loaded with weights_only=True,
|
| 6 |
+
and only the pure-torch model modules (model.py, spatial_transformer.py, fa.py, nn_utils) are imported.
|
| 7 |
+
Model STFM spatial transformer: per-spot input = Linear(GigaPath tile feature 1536) + masked gene token
|
| 8 |
+
+ organ token + tech token; attention over all spots of a slide with a 2D frame-averaged distance bias.
|
| 9 |
+
Coordinates are shifted to 0 and rescaled to [0,100] per slide (official STPathInference._normalize_coords).
|
| 10 |
+
Output: 38,984 Ensembl-gene vector per spot on the training scale log1p(raw UMI count) (NOT library-normalised;
|
| 11 |
+
training used normalize_method='log1pv2' = sc.pp.log1p on raw counts).
|
| 12 |
+
Encoder The released checkpoint was trained on prov-gigapath tile features ONLY (official pipeline: 224-px HEST patches
|
| 13 |
+
@ 0.5 um/px, CenterCrop 224, ImageNet norm). prov-gigapath is gated: --extract needs HF_TOKEN in the env
|
| 14 |
+
(pass it as an env var only; this script never reads, prints or stores it).
|
| 15 |
+
Our features = mean of GigaPath(fp16) over the centre + 4 corner 224-px crops at ~0.5 um/px
|
| 16 |
+
(centre-only saved too as <set>_gigapath_center.npy):
|
| 17 |
+
ORION 333-px tiles (512-px @ 0.325 um/px resized -> ~0.49 um/px), cropped at native size.
|
| 18 |
+
TCGA 672-px PathGen patches = read_region level 0; visually 40x (RBC ~28-30 px -> ~0.25 um/px),
|
| 19 |
+
so resized 672 -> 336 px (~0.5 um/px) before cropping. (20x-scanned slides, if any, would be off 2x.)
|
| 20 |
+
|
| 21 |
+
Sets
|
| 22 |
+
int2_check repo example (HEST INT2, CCRCC, full slide 2541 spots) -> reproduces README mean Pearson 0.1562;
|
| 23 |
+
also INT2 in random 26-spot groups (sparse context, like our 1% HEST subsample) and single spots.
|
| 24 |
+
hest_val cache/hest (9484 Visium spots, 1% subsample of 421 HEST samples, precomputed GigaPath features),
|
| 25 |
+
one STPath pass per sample (all its spots as context), organ = HEST organ, tech = Visium.
|
| 26 |
+
Samples in HEST-bench were excluded from STPath pretraining -> reported separately as held-out.
|
| 27 |
+
orion ORION CRC tiles (common.load_all_tiles, 3187 tiles / 41 slides), one pass per slide with coords from
|
| 28 |
+
the filename x_y (level-0 px), organ = Bowel, tech = Visium. + protein sanity check
|
| 29 |
+
(orion_protein_check.csv: predicted RNA vs <marker>_count/n_nuclei, tiles with >= 20 nuclei).
|
| 30 |
+
tcga_text cache/allimg_meta.csv (6144 patches, 4088 WSIs -> mostly 1 patch = no spatial context), one pass per
|
| 31 |
+
WSI, coords from key x_y; organ from the TCGA project (public GDC files API, cached in
|
| 32 |
+
tcga_text_projects.csv) via PROJECT2ORGAN, unmapped -> 'Others'; tech = Visium.
|
| 33 |
+
|
| 34 |
+
Outputs cache/stpath/<set>_pred.npy (N,G) float16, native STPath scale log1p(raw count); <set>_genes.csv; <set>_meta.csv
|
| 35 |
+
hest_val additionally: hest_val_metrics.csv (per-gene Pearson/Spearman), hest_val_within_sample.csv.
|
| 36 |
+
Run .venv/bin/python 26_stpath_infer.py --set int2_check|hest_val
|
| 37 |
+
HF_TOKEN=... .venv/bin/python 26_stpath_infer.py --set orion|tcga_text --extract # GigaPath features
|
| 38 |
+
.venv/bin/python 26_stpath_infer.py --set orion|tcga_text [--feats F.npy] # STPath prediction
|
| 39 |
+
"""
|
| 40 |
+
import argparse
|
| 41 |
+
import json
|
| 42 |
+
import os
|
| 43 |
+
import pickle
|
| 44 |
+
import re
|
| 45 |
+
import sys
|
| 46 |
+
import weakref
|
| 47 |
+
|
| 48 |
+
import numpy as np
|
| 49 |
+
import pandas as pd
|
| 50 |
+
import torch
|
| 51 |
+
|
| 52 |
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 53 |
+
EXT = os.path.join(ROOT, "ext", "stpath")
|
| 54 |
+
REPO = os.path.join(EXT, "repo")
|
| 55 |
+
WEIGHTS = os.path.join(EXT, "weights", "stfm.pth")
|
| 56 |
+
VOC = os.path.join(REPO, "utils_data", "symbol2ensembl.json")
|
| 57 |
+
CACHE = os.path.join(ROOT, "cache")
|
| 58 |
+
OUT = os.path.join(CACHE, "stpath")
|
| 59 |
+
HEST = os.path.join(ROOT, "..", "HEST")
|
| 60 |
+
MARKERS = ["PTPRC", "EPCAM", "KRT8", "ACTA2", "MKI67", "PECAM1", "CD68", "CD3E", "CDH1", "COL1A1"]
|
| 61 |
+
HEST_BENCH = { # MahmoodLab/hest-bench adata ids; STPath excluded these from pretraining (train.py)
|
| 62 |
+
"INT%d" % i for i in range(1, 25)} | {"TENX111", "TENX147", "TENX148", "TENX149", "NCBI642", "NCBI643",
|
| 63 |
+
"NCBI783", "NCBI785", "TENX95", "TENX99", "TENX118", "TENX141", "NCBI681", "NCBI682", "NCBI683", "NCBI684",
|
| 64 |
+
"TENX116", "TENX126", "TENX140", "ZEN36", "ZEN40", "ZEN48", "ZEN49", "TENX115", "TENX117"} | {
|
| 65 |
+
"MEND%d" % i for i in list(range(139, 155)) + list(range(156, 163))}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ----------------------------------------------------------------------------------------------- model
|
| 69 |
+
class STPath:
|
| 70 |
+
def __init__(self, device):
|
| 71 |
+
sys.path.insert(0, REPO)
|
| 72 |
+
from stpath.model.model import STFM
|
| 73 |
+
from stpath.model.nn_utils.config import ModelConfig
|
| 74 |
+
import stpath.utils.constants as C
|
| 75 |
+
|
| 76 |
+
sym2ens = json.load(open(VOC))
|
| 77 |
+
ens = sorted(set(sym2ens.values()))
|
| 78 |
+
self.gene2id = {g: i + 2 for i, g in enumerate(ens)} # 0 pad, 1 mask (GeneExpTokenizer)
|
| 79 |
+
self.sym2ens = sym2ens
|
| 80 |
+
self.n_genes = max(self.gene2id.values()) + 1
|
| 81 |
+
self.C = C
|
| 82 |
+
cfg = ModelConfig.get_default_config()
|
| 83 |
+
cfg.feature_dim, cfg.activation, cfg.backbone = 1536, "gelu", "spatial_transformer"
|
| 84 |
+
cfg.n_genes, cfg.n_tech, cfg.n_species, cfg.n_organs = self.n_genes, len(C.tech_voc), len(C.specie_voc), len(C.organ_voc)
|
| 85 |
+
self.model = STFM(cfg)
|
| 86 |
+
self.model.load_state_dict(torch.load(WEIGHTS, map_location="cpu", weights_only=True))
|
| 87 |
+
self.model.eval().to(device)
|
| 88 |
+
self.device = device
|
| 89 |
+
|
| 90 |
+
def organ_id(self, organ): # IDTokenizer.encode(align_first=True); None -> "Others" as in STPathInference
|
| 91 |
+
organ = "Others" if organ is None or (isinstance(organ, float) and np.isnan(organ)) else organ
|
| 92 |
+
return self.C.organ_voc.index(self.C.organ_align_mapping.get(organ, "<pad>"))
|
| 93 |
+
|
| 94 |
+
def tech_id(self, tech):
|
| 95 |
+
return 0 if tech is None else self.C.tech_voc.index(self.C.tech_align_mapping.get(tech, "<pad>"))
|
| 96 |
+
|
| 97 |
+
def gene_cols(self, symbols):
|
| 98 |
+
"""token column for each symbol (-1 if not in the STPath vocabulary)"""
|
| 99 |
+
return np.array([self.gene2id.get(self.sym2ens.get(s), -1) for s in symbols])
|
| 100 |
+
|
| 101 |
+
@torch.no_grad()
|
| 102 |
+
def predict(self, feats, coords, organ, tech, cols):
|
| 103 |
+
"""feats (n,1536), coords (n,2) one slide -> (n, len(cols)) log1p(count)-scale, NaN for cols==-1"""
|
| 104 |
+
n = len(feats)
|
| 105 |
+
c = torch.as_tensor(np.asarray(coords, np.float64), dtype=torch.float32)
|
| 106 |
+
c = c - c.min(0).values
|
| 107 |
+
rng = c.max(0).values
|
| 108 |
+
c = torch.where(rng > 0, c / rng.clamp(min=1e-8) * 100, torch.zeros_like(c)) # rescale_coords; guard 1-spot/collinear
|
| 109 |
+
x = torch.as_tensor(np.asarray(feats, np.float32), device=self.device)
|
| 110 |
+
ge = torch.zeros(n, self.n_genes, device=self.device)
|
| 111 |
+
ge[:, 1] = 1 # all spots masked (pure image -> expression)
|
| 112 |
+
p = self.model.prediction_head(
|
| 113 |
+
img_tokens=x, coords=c.to(self.device), ge_tokens=ge,
|
| 114 |
+
batch_idx=torch.zeros(n, dtype=torch.long, device=self.device),
|
| 115 |
+
tech_tokens=torch.full((n,), self.tech_id(tech), dtype=torch.long, device=self.device),
|
| 116 |
+
organ_tokens=torch.full((n,), self.organ_id(organ), dtype=torch.long, device=self.device))
|
| 117 |
+
out = p[:, torch.as_tensor(np.clip(cols, 0, None), device=self.device)].float().cpu().numpy()
|
| 118 |
+
out[:, cols < 0] = np.nan
|
| 119 |
+
# library size of the predicted profile (all vocab genes) for optional CP10k re-normalisation
|
| 120 |
+
lib = torch.expm1(p[:, 2:].clamp(min=0)).sum(1).cpu().numpy()
|
| 121 |
+
return out, lib
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ----------------------------------------------------------------------------------------------- metrics
|
| 125 |
+
def corr_cols(a, b):
|
| 126 |
+
a = a - a.mean(0); b = b - b.mean(0)
|
| 127 |
+
return (a * b).sum(0) / np.sqrt((a * a).sum(0) * (b * b).sum(0) + 1e-20)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def rank_cols(a):
|
| 131 |
+
from scipy.stats import rankdata
|
| 132 |
+
return np.apply_along_axis(rankdata, 0, a)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def gene_metrics(pred, truth):
|
| 136 |
+
return corr_cols(pred, truth), corr_cols(rank_cols(pred), rank_cols(truth))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ----------------------------------------------------------------------------------------------- sets
|
| 140 |
+
def run_int2(m):
|
| 141 |
+
import anndata as ad
|
| 142 |
+
import h5py
|
| 143 |
+
ex = os.path.join(REPO, "example_data")
|
| 144 |
+
hvg = json.load(open(os.path.join(ex, "var_50genes.json")))["genes"]
|
| 145 |
+
with h5py.File(os.path.join(ex, "INT2.h5")) as f:
|
| 146 |
+
E, Cc, bc = f["embeddings"][:], f["coords"][:], f["barcodes"][:].flatten().astype(str)
|
| 147 |
+
a = ad.read_h5ad(os.path.join(ex, "INT2.h5ad"))[bc, :]
|
| 148 |
+
gt = np.log1p(a[:, hvg].X.toarray())
|
| 149 |
+
cols = m.gene_cols(hvg)
|
| 150 |
+
p, _ = m.predict(E, Cc, "Kidney", "Visium", cols)
|
| 151 |
+
r = corr_cols(p, gt)
|
| 152 |
+
print(f"INT2 full slide ({len(E)} spots): mean Pearson 50 HVG = {np.nanmean(r):.4f} (README: 0.1562)")
|
| 153 |
+
rs = []
|
| 154 |
+
for seed in range(5): # sparse context like our 1% HEST subsample: 26 random spots per pass
|
| 155 |
+
idx = np.random.default_rng(seed).permutation(len(E))
|
| 156 |
+
pp = np.zeros_like(p)
|
| 157 |
+
for ch in np.array_split(idx, len(E) // 26):
|
| 158 |
+
pp[ch] = m.predict(E[ch], Cc[ch], "Kidney", "Visium", cols)[0]
|
| 159 |
+
rs.append(np.nanmean(corr_cols(pp, gt)))
|
| 160 |
+
print(f"INT2 sparse context (random 26-spot groups, 5 seeds): mean Pearson = {np.mean(rs):.4f} +- {np.std(rs):.4f}")
|
| 161 |
+
p1 = np.concatenate([m.predict(E[i:i + 1], Cc[i:i + 1], "Kidney", "Visium", cols)[0] for i in range(len(E))])
|
| 162 |
+
print(f"INT2 no context (each spot alone): mean Pearson = {np.nanmean(corr_cols(p1, gt)):.4f}")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def hest_coords():
|
| 166 |
+
from anndata._core import file_backing
|
| 167 |
+
|
| 168 |
+
def _ss(self, state): # same unpickle patch as 21_hest_prep.py
|
| 169 |
+
self.__dict__ = state.copy()
|
| 170 |
+
ref = state.get("_adata_ref", state.get("_adata"))
|
| 171 |
+
self.__dict__["_adata_ref"] = weakref.ref(ref) if ref is not None else (lambda: None)
|
| 172 |
+
|
| 173 |
+
file_backing.AnnDataFileManager.__setstate__ = _ss
|
| 174 |
+
A = pickle.load(open(os.path.join(HEST, "visium_humandisease_adata.pickle"), "rb"))
|
| 175 |
+
H = pd.read_csv(os.path.join(HEST, "HEST_v1_1_0.csv"))
|
| 176 |
+
H = H[(H.st_technology == "Visium") & (H.species == "Homo sapiens")].reset_index(drop=True)
|
| 177 |
+
assert len(H) == len(A)
|
| 178 |
+
xy = {}
|
| 179 |
+
for a, sid in zip(A, H.id):
|
| 180 |
+
for b, c in zip(a.obs_names, np.asarray(a.obsm["spatial"], np.float64)):
|
| 181 |
+
xy[(sid, b)] = c
|
| 182 |
+
return xy
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def run_hest(m):
|
| 186 |
+
import scipy.sparse as sp
|
| 187 |
+
M = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv"))
|
| 188 |
+
genes = pd.read_csv(os.path.join(CACHE, "hest", "genes.csv")).gene.tolist()
|
| 189 |
+
X = sp.load_npz(os.path.join(CACHE, "hest", "counts.npz")).tocsr()
|
| 190 |
+
F = np.load(os.path.join(CACHE, "hest", "he_gigapath.npy"))[:, 0].astype(np.float32)
|
| 191 |
+
xy = hest_coords()
|
| 192 |
+
coords = np.stack([xy[(s, b)] for s, b in zip(M["sample"], M.spot)])
|
| 193 |
+
cols = m.gene_cols(genes)
|
| 194 |
+
keep = cols >= 0
|
| 195 |
+
print(f"HEST genes {len(genes)}, in STPath vocab {keep.sum()}")
|
| 196 |
+
genes_k, cols_k = [g for g, k in zip(genes, keep) if k], cols[keep]
|
| 197 |
+
pred = np.zeros((len(M), len(cols_k)), np.float32)
|
| 198 |
+
lib = np.zeros(len(M), np.float32)
|
| 199 |
+
for sid, idx in M.groupby("sample").indices.items():
|
| 200 |
+
pred[idx], lib[idx] = m.predict(F[idx], coords[idx], M.organ.iloc[idx[0]], "Visium", cols_k)
|
| 201 |
+
os.makedirs(OUT, exist_ok=True)
|
| 202 |
+
np.save(os.path.join(OUT, "hest_val_pred.npy"), pred.astype(np.float16))
|
| 203 |
+
pd.Series(genes_k).to_csv(os.path.join(OUT, "hest_val_genes.csv"), index=False, header=["gene"])
|
| 204 |
+
mo = M[["idx", "sample", "spot", "organ", "oncotree"]].copy()
|
| 205 |
+
mo["x"], mo["y"] = coords[:, 0], coords[:, 1]
|
| 206 |
+
mo["hest_bench_heldout"] = mo["sample"].isin(HEST_BENCH)
|
| 207 |
+
mo["pred_lib_expm1"] = lib
|
| 208 |
+
mo["status"] = "ok"
|
| 209 |
+
mo.to_csv(os.path.join(OUT, "hest_val_meta.csv"), index=False)
|
| 210 |
+
|
| 211 |
+
# ---- evaluation: truth = log1p(CP10k) of measured counts (and log1p raw = STPath's training target)
|
| 212 |
+
Xk = X[:, np.where(keep)[0]]
|
| 213 |
+
tot = np.asarray(X.sum(1)).ravel()
|
| 214 |
+
L = sp.diags(1e4 / tot) @ Xk
|
| 215 |
+
L.data = np.log1p(L.data)
|
| 216 |
+
frac = np.asarray((Xk > 0).mean(0)).ravel()
|
| 217 |
+
mu = np.asarray(L.mean(0)).ravel()
|
| 218 |
+
var = np.asarray(L.multiply(L).mean(0)).ravel() - mu ** 2
|
| 219 |
+
var[frac < 0.01] = -1
|
| 220 |
+
top50 = list(np.argsort(-var)[:50])
|
| 221 |
+
gi = {g: i for i, g in enumerate(genes_k)}
|
| 222 |
+
mk = [gi[g] for g in MARKERS if g in gi]
|
| 223 |
+
sel = top50 + [i for i in mk if i not in top50]
|
| 224 |
+
T_cp = L[:, sel].toarray()
|
| 225 |
+
T_raw = np.log1p(Xk[:, sel].toarray())
|
| 226 |
+
P = pred[:, sel]
|
| 227 |
+
P_cp = np.log1p(np.expm1(np.clip(P, 0, None)) / lib[:, None] * 1e4) # predicted profile re-normalised to CP10k
|
| 228 |
+
held = mo.hest_bench_heldout.values
|
| 229 |
+
rows = []
|
| 230 |
+
for subset, msk in [("all", np.ones(len(M), bool)), ("heldout_hestbench", held), ("seen_pretrain", ~held)]:
|
| 231 |
+
for tname, T, PP in [("log1p_cp10k", T_cp, P), ("log1p_raw", T_raw, P), ("log1p_cp10k_vs_predcp10k", T_cp, P_cp)]:
|
| 232 |
+
pr, sr = gene_metrics(PP[msk], T[msk])
|
| 233 |
+
for j, g in enumerate(sel):
|
| 234 |
+
rows.append({"subset": subset, "truth": tname, "gene": genes_k[g], "is_top50_hvg": g in top50,
|
| 235 |
+
"is_marker": genes_k[g] in MARKERS, "pearson": pr[j], "spearman": sr[j],
|
| 236 |
+
"n_spots": int(msk.sum())})
|
| 237 |
+
R = pd.DataFrame(rows)
|
| 238 |
+
R.to_csv(os.path.join(OUT, "hest_val_metrics.csv"), index=False)
|
| 239 |
+
# within-sample correlations (spatial-variation within a slide, the HEST-bench style), samples with >=30 spots
|
| 240 |
+
ws = []
|
| 241 |
+
for sid, idx in M.groupby("sample").indices.items():
|
| 242 |
+
if len(idx) >= 30:
|
| 243 |
+
pr, _ = gene_metrics(P[idx][:, :50], T_cp[idx][:, :50])
|
| 244 |
+
ws.append({"sample": sid, "heldout": sid in HEST_BENCH, "n": len(idx), "mean_pearson_top50": np.nanmean(pr)})
|
| 245 |
+
W = pd.DataFrame(ws)
|
| 246 |
+
W.to_csv(os.path.join(OUT, "hest_val_within_sample.csv"), index=False)
|
| 247 |
+
print(f"\nspots {len(M)}, samples {M['sample'].nunique()}; held-out (HEST-bench) spots {held.sum()} "
|
| 248 |
+
f"in {M[held]['sample'].nunique()} samples")
|
| 249 |
+
s = R.groupby(["subset", "truth", "is_top50_hvg"])[["pearson", "spearman"]].agg(["mean", "median"])
|
| 250 |
+
print(s.round(3).to_string())
|
| 251 |
+
print("\nmarkers, truth log1p CP10k (pooled across spots):")
|
| 252 |
+
print(R[R.is_marker & (R.truth == "log1p_cp10k")].pivot(index="gene", columns="subset", values=["pearson", "spearman"]).round(3).to_string())
|
| 253 |
+
print("\nwithin-sample mean Pearson top-50 HVG (samples >=30 spots):")
|
| 254 |
+
print(W.groupby("heldout").mean_pearson_top50.describe().round(3).to_string())
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def parse_xy(s):
|
| 258 |
+
mm = re.search(r"_(\d+)_(\d+)(?:_0_512_512)?(?:\.\w+)?$", s)
|
| 259 |
+
return (float(mm.group(1)), float(mm.group(2))) if mm else (np.nan, np.nan)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def target_paths(name):
|
| 263 |
+
if name == "orion":
|
| 264 |
+
sys.path.insert(0, ROOT)
|
| 265 |
+
from common import load_all_tiles, ORION
|
| 266 |
+
return [os.path.join(ORION, p) for p in load_all_tiles().image_path], 333 # ~0.49 um/px already
|
| 267 |
+
d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 268 |
+
return d.image_path.tolist(), 336 # PathGen read_region level 0 = 40x (~0.25 um/px): 672 -> 336 = ~0.5 um/px
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def five_crops(img, size=224):
|
| 272 |
+
W, H = img.size
|
| 273 |
+
offs = [((W - size) // 2, (H - size) // 2), (0, 0), (W - size, 0), (0, H - size), (W - size, H - size)]
|
| 274 |
+
return [img.crop((x, y, x + size, y + size)) for x, y in offs] # centre first
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def extract_gigapath(name, device, bs=32):
|
| 278 |
+
"""GigaPath tile features (fp16) = mean over centre + 4 corner 224-px crops at ~0.5 um/px; centre-only also saved.
|
| 279 |
+
Needs HF_TOKEN in the environment (prov-gigapath is gated); the token is never read/stored by this script."""
|
| 280 |
+
import timm
|
| 281 |
+
from PIL import Image, PngImagePlugin
|
| 282 |
+
from torchvision import transforms
|
| 283 |
+
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
|
| 284 |
+
enc = timm.create_model("hf_hub:prov-gigapath/prov-gigapath", pretrained=True).half().eval().to(device)
|
| 285 |
+
tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))])
|
| 286 |
+
paths, side = target_paths(name)
|
| 287 |
+
feats = np.full((len(paths), 5, 1536), np.nan, np.float32)
|
| 288 |
+
status = ["ok"] * len(paths)
|
| 289 |
+
buf, rows = [], []
|
| 290 |
+
|
| 291 |
+
def flush():
|
| 292 |
+
if not buf:
|
| 293 |
+
return
|
| 294 |
+
with torch.inference_mode():
|
| 295 |
+
f = enc(torch.stack(buf).half().to(device)).float().cpu().numpy()
|
| 296 |
+
feats[np.array(rows)] = f.reshape(-1, 5, 1536)
|
| 297 |
+
buf.clear(); rows.clear()
|
| 298 |
+
|
| 299 |
+
for i, p in enumerate(paths):
|
| 300 |
+
try:
|
| 301 |
+
im = Image.open(p).convert("RGB")
|
| 302 |
+
if im.size != (side, side):
|
| 303 |
+
im = im.resize((side, side), Image.BICUBIC)
|
| 304 |
+
buf.extend(tf(c) for c in five_crops(im)); rows.append(i)
|
| 305 |
+
except Exception as e:
|
| 306 |
+
status[i] = f"unreadable:{type(e).__name__}"
|
| 307 |
+
print(f"[{name}] skip {p}: {e}", flush=True)
|
| 308 |
+
if len(rows) * 5 >= bs:
|
| 309 |
+
flush()
|
| 310 |
+
if i % 500 == 0:
|
| 311 |
+
print(f"[{name}] {i}/{len(paths)}", flush=True)
|
| 312 |
+
flush()
|
| 313 |
+
os.makedirs(OUT, exist_ok=True)
|
| 314 |
+
np.save(os.path.join(OUT, f"{name}_gigapath.npy"), feats.mean(1))
|
| 315 |
+
np.save(os.path.join(OUT, f"{name}_gigapath_center.npy"), feats[:, 0])
|
| 316 |
+
pd.DataFrame({"path": paths, "feat_status": status}).to_csv(os.path.join(OUT, f"{name}_gigapath_status.csv"), index_label="row")
|
| 317 |
+
print(f"[{name}] features {feats.shape[0]} rows, failed {sum(s != 'ok' for s in status)}; "
|
| 318 |
+
f"non-finite rows {int((~np.isfinite(feats.mean(1)).all(1)).sum())}")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def tcga_projects(meta):
|
| 322 |
+
"""TCGA project per WSI from the public GDC files API (file_name = <wsi>.svs); cached in cache/stpath."""
|
| 323 |
+
fp = os.path.join(OUT, "tcga_text_projects.csv")
|
| 324 |
+
if not os.path.exists(fp):
|
| 325 |
+
import requests
|
| 326 |
+
names = sorted(set(meta.slide + ".svs"))
|
| 327 |
+
rows = []
|
| 328 |
+
for i in range(0, len(names), 500):
|
| 329 |
+
q = {"filters": {"op": "in", "content": {"field": "file_name", "value": names[i:i + 500]}},
|
| 330 |
+
"fields": "file_name,cases.project.project_id", "format": "JSON", "size": 1000}
|
| 331 |
+
r = requests.post("https://api.gdc.cancer.gov/files", json=q, timeout=120).json()
|
| 332 |
+
for h in r["data"]["hits"]:
|
| 333 |
+
rows.append({"slide": h["file_name"][:-4], "project": h["cases"][0]["project"]["project_id"]})
|
| 334 |
+
pd.DataFrame(rows).drop_duplicates("slide").to_csv(fp, index=False)
|
| 335 |
+
return dict(pd.read_csv(fp).values)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# TCGA project -> STPath organ name (keys of constants.organ_align_mapping; lower-case ones are the STimage-1K4M
|
| 339 |
+
# spellings that align to their own tokens, e.g. 'ovary'->'Ovary'); unlisted projects -> 'Others'
|
| 340 |
+
PROJECT2ORGAN = {"BRCA": "Breast", "COAD": "Bowel", "READ": "Bowel", "LUAD": "Lung", "LUSC": "Lung",
|
| 341 |
+
"KIRC": "Kidney", "KIRP": "Kidney", "KICH": "Kidney", "LIHC": "Liver", "PAAD": "Pancreas",
|
| 342 |
+
"PRAD": "Prostate", "SKCM": "Skin", "UCEC": "Uterus", "UCS": "Uterus", "OV": "ovary",
|
| 343 |
+
"GBM": "glioblastoma", "LGG": "glioma", "STAD": "stomach", "HNSC": "mouth", "UVM": "Eye"}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def run_target(m, name, feats_path):
|
| 347 |
+
if name == "orion":
|
| 348 |
+
sys.path.insert(0, ROOT)
|
| 349 |
+
from common import load_all_tiles
|
| 350 |
+
d = load_all_tiles()
|
| 351 |
+
meta = pd.DataFrame({"key": d.image_path, "path": d.image_path, "slide": d.in_slide_name})
|
| 352 |
+
meta["x"], meta["y"] = zip(*[parse_xy(os.path.basename(p)) for p in d.image_path])
|
| 353 |
+
meta["organ"] = "Bowel"
|
| 354 |
+
else:
|
| 355 |
+
d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 356 |
+
meta = pd.DataFrame({"key": d.key, "path": d.image_path, "slide": d.wsi_id})
|
| 357 |
+
meta["x"], meta["y"] = zip(*[parse_xy(k) for k in d.key])
|
| 358 |
+
proj = tcga_projects(meta) # TCGA project from GDC; organ token mapped, unknown -> 'Others'
|
| 359 |
+
meta["project"] = meta.slide.map(proj)
|
| 360 |
+
meta["organ"] = [PROJECT2ORGAN.get(str(p).replace("TCGA-", ""), "Others") for p in meta.project]
|
| 361 |
+
feats_path = feats_path or os.path.join(OUT, f"{name}_gigapath.npy")
|
| 362 |
+
if not os.path.exists(feats_path):
|
| 363 |
+
sys.exit(f"[{name}] BLOCKED: STPath's released checkpoint needs prov-gigapath tile features (1536-d) and "
|
| 364 |
+
f"prov-gigapath is gated for this HF account. Provide --feats <GigaPath features (N={len(meta)},1536) "
|
| 365 |
+
f"aligned to {name} row order>.")
|
| 366 |
+
F = np.load(feats_path).astype(np.float32)
|
| 367 |
+
F = F[:, 0] if F.ndim == 3 else F
|
| 368 |
+
assert F.shape == (len(meta), 1536), F.shape
|
| 369 |
+
ok = np.isfinite(F).all(1)
|
| 370 |
+
meta["status"] = np.where(ok, "ok", "no_feature")
|
| 371 |
+
meta["n_context"] = meta.groupby("slide").slide.transform("size").where(ok, 0)
|
| 372 |
+
vocab_genes = pd.read_csv(os.path.join(OUT, "hest_val_genes.csv")).gene.tolist()
|
| 373 |
+
cols = m.gene_cols(vocab_genes)
|
| 374 |
+
pred = np.full((len(meta), len(cols)), np.nan, np.float32)
|
| 375 |
+
for sl, idx in meta[ok].groupby("slide").indices.items():
|
| 376 |
+
idx = np.where(ok)[0][idx]
|
| 377 |
+
pred[idx] = m.predict(F[idx], meta[["x", "y"]].values[idx], meta.organ.values[idx[0]], "Visium", cols)[0]
|
| 378 |
+
np.save(os.path.join(OUT, f"{name}_pred.npy"), pred.astype(np.float16))
|
| 379 |
+
pd.Series(vocab_genes).to_csv(os.path.join(OUT, f"{name}_genes.csv"), index=False, header=["gene"])
|
| 380 |
+
meta.to_csv(os.path.join(OUT, f"{name}_meta.csv"), index_label="row")
|
| 381 |
+
print(f"[{name}] predicted {ok.sum()} / {len(meta)}; {meta.slide.nunique()} slides")
|
| 382 |
+
if name == "orion":
|
| 383 |
+
orion_protein_check(pred, vocab_genes)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
ORION_PAIRS = [("PTPRC", "CD45"), ("EPCAM", "Pan-CK"), ("KRT8", "Pan-CK"), ("CDH1", "E-cadherin"), ("ACTA2", "SMA"),
|
| 387 |
+
("MKI67", "Ki67"), ("PECAM1", "CD31"), ("CD68", "CD68"), ("CD3E", "CD3e"), ("CD8A", "CD8a"),
|
| 388 |
+
("MS4A1", "CD20"), ("CD163", "CD163")]
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def orion_protein_check(pred, genes):
|
| 392 |
+
"""predicted RNA vs measured ORION protein (= <marker>_count / n_nuclei) per tile, tiles with >= 20 nuclei."""
|
| 393 |
+
from scipy.stats import pearsonr, spearmanr
|
| 394 |
+
sys.path.insert(0, ROOT)
|
| 395 |
+
from common import load_all_tiles
|
| 396 |
+
d = load_all_tiles()
|
| 397 |
+
ts = pd.read_csv(os.path.join(ROOT, "results", "tile_stats.csv"))
|
| 398 |
+
assert (ts.tile_id.values == d.tile_id.values).all()
|
| 399 |
+
keep = (ts.n_nuclei.values >= 20) & np.isfinite(pred).all(1)
|
| 400 |
+
gi = {g: i for i, g in enumerate(genes)}
|
| 401 |
+
rows = []
|
| 402 |
+
for g, p in ORION_PAIRS:
|
| 403 |
+
x = pred[keep, gi[g]].astype(np.float64)
|
| 404 |
+
y = (d[f"{p}_count"].values / np.maximum(ts.n_nuclei.values, 1))[keep]
|
| 405 |
+
ws = [spearmanr(x[i], y[i])[0] for i in d[keep].groupby("in_slide_name").indices.values() if len(i) >= 20]
|
| 406 |
+
rows.append({"gene": g, "protein": p, "n_tiles": int(keep.sum()), "pearson": pearsonr(x, y)[0],
|
| 407 |
+
"spearman": spearmanr(x, y)[0], "within_slide_spearman_median": np.nanmedian(ws),
|
| 408 |
+
"n_slides_within": len(ws)})
|
| 409 |
+
R = pd.DataFrame(rows)
|
| 410 |
+
R.to_csv(os.path.join(OUT, "orion_protein_check.csv"), index=False)
|
| 411 |
+
print(R.round(3).to_string(index=False))
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
if __name__ == "__main__":
|
| 415 |
+
ap = argparse.ArgumentParser()
|
| 416 |
+
ap.add_argument("--set", required=True, choices=["int2_check", "hest_val", "orion", "tcga_text"])
|
| 417 |
+
ap.add_argument("--feats", default=None, help="GigaPath (N,1536) features for orion / tcga_text")
|
| 418 |
+
ap.add_argument("--extract", action="store_true", help="compute GigaPath features for --set orion|tcga_text (needs HF_TOKEN env)")
|
| 419 |
+
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 420 |
+
a = ap.parse_args()
|
| 421 |
+
torch.manual_seed(0)
|
| 422 |
+
if a.extract:
|
| 423 |
+
extract_gigapath(a.set, a.device)
|
| 424 |
+
sys.exit(0)
|
| 425 |
+
model = STPath(a.device)
|
| 426 |
+
{"int2_check": lambda: run_int2(model), "hest_val": lambda: run_hest(model)}.get(
|
| 427 |
+
a.set, lambda: run_target(model, a.set, a.feats))()
|
experiments/27_azure_caption.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate H&E descriptions with GPT-6.1 Sol on Azure OpenAI for images that have no real text (plan A:
|
| 2 |
+
one FIXED 3-shot prefix of real PathGen-1.6M captions -> prompt-cached; short caption; low reasoning effort).
|
| 3 |
+
|
| 4 |
+
Targets (images on disk without paired text):
|
| 5 |
+
ge_patches : PathGen-1.6M gene-expression patches (brain*, gliomas*, idc*, saromas*, skin*) 1640
|
| 6 |
+
slide_rep : PathGen-1.6M/slide_report/image_set/*.png 1
|
| 7 |
+
orion : ORION H&E tiles (he/*.jpeg) 6335
|
| 8 |
+
hemit : HEMIT H&E inputs (he/*/input/*) 55
|
| 9 |
+
Config (env): AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT,
|
| 10 |
+
optional AZURE_OPENAI_API_VERSION (if unset the v1 endpoint <endpoint>/openai/v1/ is used)
|
| 11 |
+
Usage: python 27_azure_caption.py --dry # list targets + 3-shot prompt, no API call
|
| 12 |
+
python 27_azure_caption.py --pilot 20 # 20 images, prints measured cost / image
|
| 13 |
+
python 27_azure_caption.py --budget 40 # everything; stops when the USD budget is reached
|
| 14 |
+
Output: cache/gen_text/captions.jsonl (resumable; one line per image: set, key, path, caption, usage)
|
| 15 |
+
Generated text is SYNTHETIC (seen only the H&E) -> never use it as test text; tag ttype='synthetic'.
|
| 16 |
+
"""
|
| 17 |
+
import argparse
|
| 18 |
+
import base64
|
| 19 |
+
import concurrent.futures as cf
|
| 20 |
+
import glob
|
| 21 |
+
import io
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import random
|
| 25 |
+
import threading
|
| 26 |
+
import time
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import pandas as pd
|
| 30 |
+
from PIL import Image, PngImagePlugin
|
| 31 |
+
|
| 32 |
+
from common import CACHE, ROOT
|
| 33 |
+
|
| 34 |
+
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
|
| 35 |
+
P = os.path.join(ROOT, "..")
|
| 36 |
+
OUT = os.path.join(CACHE, "gen_text")
|
| 37 |
+
os.makedirs(OUT, exist_ok=True)
|
| 38 |
+
PRICE = {"in": 2.0, "cached": 0.10, "out": 10.0} # USD / 1M tokens, GPT-6.1 Sol standard
|
| 39 |
+
MAXPX = 512 # images are downscaled to <=512 px (one vision tile) to keep the token count low
|
| 40 |
+
|
| 41 |
+
ap = argparse.ArgumentParser()
|
| 42 |
+
ap.add_argument("--sets", nargs="+", default=["ge_patches", "slide_rep", "orion", "hemit"],
|
| 43 |
+
help="tcga_notext = TCGA images left without real text after 40_clean_text.py; dlpfc / idc = external test sets (all spots); tcga_calib = 1000 TCGA patches that HAVE real text (held-out patients of 31_), for calibrating synthetic vs real text")
|
| 44 |
+
ap.add_argument("--pilot", type=int, default=0)
|
| 45 |
+
ap.add_argument("--budget", type=float, default=40.0)
|
| 46 |
+
ap.add_argument("--workers", type=int, default=8)
|
| 47 |
+
ap.add_argument("--effort", default="low")
|
| 48 |
+
ap.add_argument("--dry", action="store_true")
|
| 49 |
+
args = ap.parse_args()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def targets():
|
| 53 |
+
rows = []
|
| 54 |
+
ge = ["brain_image", "brain_train_image", "brain_trainnew_image", "gliomas_test_image_new", "gliomas_train_image",
|
| 55 |
+
"idc_image", "idc_train_image", "saromas_test_image_new", "saromas_train_image_new", "skin_image", "skin_train_image"]
|
| 56 |
+
src = {"ge_patches": [f for d in ge for f in glob.glob(os.path.join(P, "PathGen-1.6M", d, "*"))],
|
| 57 |
+
"slide_rep": glob.glob(os.path.join(P, "PathGen-1.6M", "slide_report", "image_set", "*")),
|
| 58 |
+
"orion": glob.glob(os.path.join(P, "ORION", "ORIONCRC_dataset_tile_20x", "he", "*.jpeg")),
|
| 59 |
+
"hemit": glob.glob(os.path.join(P, "HEMIT", "he", "*", "input", "*"))}
|
| 60 |
+
if "tcga_calib" in args.sets: # same held-out image set as 31_virtual_validation.py (TCGA), random 1000
|
| 61 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 62 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 63 |
+
seen = set(pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv")).query("split in ['train', 'val']").patient)
|
| 64 |
+
ev = sorted(set(T.img_idx) & set(I.index[~I.patient.isin(seen)]))
|
| 65 |
+
src["tcga_calib"] = list(I.image_path.values[np.random.default_rng(0).choice(ev, 1000, replace=False)])
|
| 66 |
+
if "tcga_notext" in args.sets: # TCGA images whose real texts are all junk (40_clean_text.py)
|
| 67 |
+
src["tcga_notext"] = list(pd.read_csv(os.path.join(CACHE, "notext_meta.csv")).image_path)
|
| 68 |
+
for s in ("dlpfc", "idc"): # external test sets (36_external_prep.py): the same 224-px patches as the anchor
|
| 69 |
+
if s in args.sets: # (0.5 um/px; = UNI2 / MIPHEI input) -> path = "npy:<set>:<row>"
|
| 70 |
+
n = len(np.load(os.path.join(CACHE, "external", EXT[s]), mmap_mode="r"))
|
| 71 |
+
rows += [{"set": s, "key": f"external/{s}/{i}", "path": f"npy:{s}:{i}"} for i in range(n)]
|
| 72 |
+
for s in args.sets:
|
| 73 |
+
if s in EXT:
|
| 74 |
+
continue
|
| 75 |
+
for f in sorted(src[s]):
|
| 76 |
+
if f.lower().endswith((".png", ".jpg", ".jpeg", ".tif", ".tiff")):
|
| 77 |
+
rows.append({"set": s, "key": os.path.relpath(f, P), "path": f})
|
| 78 |
+
return pd.DataFrame(rows)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
EXT = {"dlpfc": "dlpfc_he_05um.npy", "idc": "idc_he.npy"}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def b64(path):
|
| 85 |
+
if path.startswith("npy:"):
|
| 86 |
+
_, st, i = path.split(":")
|
| 87 |
+
im = Image.fromarray(np.asarray(np.load(os.path.join(CACHE, "external", EXT[st]), mmap_mode="r")[int(i)]))
|
| 88 |
+
else:
|
| 89 |
+
im = Image.open(path).convert("RGB")
|
| 90 |
+
im.thumbnail((MAXPX, MAXPX))
|
| 91 |
+
buf = io.BytesIO()
|
| 92 |
+
im.save(buf, format="JPEG", quality=90)
|
| 93 |
+
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def few_shot():
|
| 97 |
+
"""3 fixed real PathGen-1.6M captions (40-60 words), different slides; same for every request -> cacheable."""
|
| 98 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 99 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 100 |
+
T = T[(T.ttype == "caption") & T.source.str.contains("pathgen1.6m")]
|
| 101 |
+
w = T.caption.str.split().str.len()
|
| 102 |
+
T = T[(w >= 40) & (w <= 60)]
|
| 103 |
+
rng = random.Random(0)
|
| 104 |
+
picks, seen = [], set()
|
| 105 |
+
for i in rng.sample(list(T.index), len(T)):
|
| 106 |
+
wsi = I.wsi_id[T.img_idx[i]]
|
| 107 |
+
if wsi[:12] in seen:
|
| 108 |
+
continue
|
| 109 |
+
try:
|
| 110 |
+
img = b64(I.image_path[T.img_idx[i]])
|
| 111 |
+
except Exception:
|
| 112 |
+
continue
|
| 113 |
+
seen.add(wsi[:12]); picks.append((img, T.caption[i], T.key[i]))
|
| 114 |
+
if len(picks) == 3:
|
| 115 |
+
return picks
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
SYSTEM = ("You are a pathologist writing a short description of one H&E-stained histopathology image patch. "
|
| 119 |
+
"Describe only what is visible: tissue type and architecture, cell types and their density, nuclear "
|
| 120 |
+
"features, stroma, inflammation, vessels, necrosis or mitoses when present. Follow the style and length "
|
| 121 |
+
"of the examples (one paragraph, about 50 words). Do not mention the examples, staining quality, "
|
| 122 |
+
"magnification or patient information, and do not invent a diagnosis that the image does not support.")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def messages(shots, target_img):
|
| 126 |
+
m = [{"role": "system", "content": SYSTEM}]
|
| 127 |
+
for img, cap, _ in shots:
|
| 128 |
+
m.append({"role": "user", "content": [{"type": "text", "text": "Describe this H&E patch."},
|
| 129 |
+
{"type": "image_url", "image_url": {"url": img, "detail": "auto"}}]})
|
| 130 |
+
m.append({"role": "assistant", "content": cap})
|
| 131 |
+
m.append({"role": "user", "content": [{"type": "text", "text": "Describe this H&E patch."},
|
| 132 |
+
{"type": "image_url", "image_url": {"url": target_img, "detail": "auto"}}]})
|
| 133 |
+
return m
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def client():
|
| 137 |
+
from openai import AzureOpenAI, OpenAI
|
| 138 |
+
ep, key = os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/"), os.environ["AZURE_OPENAI_API_KEY"]
|
| 139 |
+
if os.environ.get("AZURE_OPENAI_API_VERSION"):
|
| 140 |
+
return AzureOpenAI(azure_endpoint=ep, api_key=key, api_version=os.environ["AZURE_OPENAI_API_VERSION"])
|
| 141 |
+
return OpenAI(base_url=ep + "/openai/v1/", api_key=key)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def cost(u):
|
| 145 |
+
cached = (u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0
|
| 146 |
+
return ((u["prompt_tokens"] - cached) * PRICE["in"] + cached * PRICE["cached"] + u["completion_tokens"] * PRICE["out"]) / 1e6
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
D = targets()
|
| 150 |
+
shots = few_shot()
|
| 151 |
+
print(f"targets: {len(D)} " + json.dumps(D.set.value_counts().to_dict()))
|
| 152 |
+
print("3-shot examples (PathGen-1.6M keys):", [k for _, _, k in shots])
|
| 153 |
+
fout = os.path.join(OUT, "captions.jsonl")
|
| 154 |
+
done = set()
|
| 155 |
+
spent = 0.0
|
| 156 |
+
if os.path.exists(fout):
|
| 157 |
+
for line in open(fout):
|
| 158 |
+
r = json.loads(line)
|
| 159 |
+
done.add(r["key"]); spent += r.get("usd", 0)
|
| 160 |
+
todo = D[~D.key.isin(done)]
|
| 161 |
+
if args.pilot:
|
| 162 |
+
todo = todo.groupby("set", group_keys=False).apply(lambda g: g.sample(min(len(g), max(1, args.pilot // D.set.nunique())), random_state=0))
|
| 163 |
+
print(f"already done {len(done)} (USD {spent:.2f}); to do now {len(todo)}")
|
| 164 |
+
json.dump({"system": SYSTEM, "shots": [{"key": k, "caption": c} for _, c, k in shots], "maxpx": MAXPX,
|
| 165 |
+
"effort": args.effort, "deployment": os.environ.get("AZURE_OPENAI_DEPLOYMENT")},
|
| 166 |
+
open(os.path.join(OUT, "prompt.json"), "w"), indent=1)
|
| 167 |
+
if args.dry:
|
| 168 |
+
for _, c, k in shots:
|
| 169 |
+
print(f"- {k}: {c}")
|
| 170 |
+
raise SystemExit
|
| 171 |
+
|
| 172 |
+
cl, dep = client(), os.environ["AZURE_OPENAI_DEPLOYMENT"]
|
| 173 |
+
lock = threading.Lock()
|
| 174 |
+
stop = threading.Event()
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def run(row):
|
| 178 |
+
if stop.is_set():
|
| 179 |
+
return
|
| 180 |
+
for attempt in range(6):
|
| 181 |
+
try:
|
| 182 |
+
r = cl.chat.completions.create(model=dep, messages=messages(shots, b64(row.path)),
|
| 183 |
+
reasoning_effort=args.effort, max_completion_tokens=2000,
|
| 184 |
+
prompt_cache_key="he-caption-3shot-v1")
|
| 185 |
+
u = r.usage.model_dump()
|
| 186 |
+
rec = {"set": row.set, "key": row.key, "path": row.path, "caption": r.choices[0].message.content.strip(),
|
| 187 |
+
"finish": r.choices[0].finish_reason, "usage": u, "usd": cost(u), "ttype": "synthetic"}
|
| 188 |
+
break
|
| 189 |
+
except Exception as e: # rate limits / transient errors -> exponential backoff
|
| 190 |
+
err = repr(e)[:300]
|
| 191 |
+
if "content_filter" in err or attempt == 5:
|
| 192 |
+
rec = {"set": row.set, "key": row.key, "path": row.path, "caption": None, "error": err, "usd": 0}
|
| 193 |
+
break
|
| 194 |
+
time.sleep(2 ** attempt * 5)
|
| 195 |
+
global spent
|
| 196 |
+
with lock:
|
| 197 |
+
with open(fout, "a") as f:
|
| 198 |
+
f.write(json.dumps(rec) + "\n")
|
| 199 |
+
spent += rec["usd"]
|
| 200 |
+
if spent >= args.budget:
|
| 201 |
+
stop.set()
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
t0 = time.time()
|
| 205 |
+
with cf.ThreadPoolExecutor(args.workers) as ex:
|
| 206 |
+
for i, _ in enumerate(ex.map(run, [r for r in todo.itertuples()])):
|
| 207 |
+
if i % 200 == 0:
|
| 208 |
+
print(f"{i}/{len(todo)} USD {spent:.2f} {time.time() - t0:.0f}s", flush=True)
|
| 209 |
+
R = pd.read_json(fout, lines=True)
|
| 210 |
+
ok = R[R.caption.notna()]
|
| 211 |
+
print(f"done {len(ok)} ok / {len(R)} total; failures {R.caption.isna().sum()}; USD spent {R.usd.sum():.2f}; "
|
| 212 |
+
f"USD/image {ok.usd.mean():.4f} -> projected for all {len(D)}: {ok.usd.mean() * len(D):.0f}")
|
| 213 |
+
if len(ok):
|
| 214 |
+
u = pd.json_normalize(ok.usage)
|
| 215 |
+
print(u.filter(regex="prompt_tokens$|completion_tokens$|cached_tokens|reasoning_tokens").mean().round(0).to_string())
|
| 216 |
+
if stop.is_set():
|
| 217 |
+
print(f"STOPPED: budget {args.budget} USD reached")
|
experiments/28_eval_anchor.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""H&E foundation-model ablation (UNI2 vs GigaPath vs CONCH as the frozen ImageBind anchor).
|
| 2 |
+
|
| 3 |
+
--prep : cache/pathgen_he_<anchor>.npy from cache/allimg_he_<anchor>.npy (PathGen-1.6M captions are a subset of the
|
| 4 |
+
all-pairs pool, same image files -> identical features; verified for uni2, cosine 1.0)
|
| 5 |
+
default: evaluate
|
| 6 |
+
(A) ORION H&E<->mIF, trained pair: runs/bench_fm/*_<anchor>_<if_enc>_fm-geneformer_pathgenclip (17_bench_fm.py);
|
| 7 |
+
tie-aware R@1 on 100-tile galleries + Hungarian pairing acc on the full test fold, both directions
|
| 8 |
+
(B) emergent text<->mIF: text head trained ONLY on PathGen H&E-caption pairs with the same anchor
|
| 9 |
+
(runs/text_pathgen/<anchor>_<txt>, 12_train_text_pathgen.py) scores the concept prompts against the ORION
|
| 10 |
+
mIF embeddings of that anchor's run (text and mIF never co-occur in training); AUROC + Spearman (18_eval_fm.probe)
|
| 11 |
+
Outputs results/anchor_ifret.csv, results/anchor_text_probes.csv
|
| 12 |
+
"""
|
| 13 |
+
import glob
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import pandas as pd
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
|
| 23 |
+
from common import CACHE, ROOT, RUNS, metrics
|
| 24 |
+
|
| 25 |
+
ANCHORS = ["uni2", "gigapath", "conch"]
|
| 26 |
+
OUT = os.path.join(ROOT, "results")
|
| 27 |
+
|
| 28 |
+
if "--prep" in sys.argv:
|
| 29 |
+
P = pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv"))
|
| 30 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv")).set_index("key")
|
| 31 |
+
ix = I.loc[P.image_path.map(lambda p: os.path.basename(p).rsplit(".", 1)[0]), "idx"].values
|
| 32 |
+
assert (I.image_path.values[ix] == P.image_path.values).all()
|
| 33 |
+
for a in ANCHORS:
|
| 34 |
+
f = os.path.join(CACHE, f"pathgen_he_{a}.npy")
|
| 35 |
+
if not os.path.exists(f):
|
| 36 |
+
np.save(f, np.load(os.path.join(CACHE, f"allimg_he_{a}.npy"))[ix])
|
| 37 |
+
print("saved", f)
|
| 38 |
+
sys.exit()
|
| 39 |
+
|
| 40 |
+
# reuse the probe of 18_eval_fm.py without running its main block
|
| 41 |
+
src = open(os.path.join(ROOT, "18_eval_fm.py")).read().split("# collect test embeddings")[0]
|
| 42 |
+
ns = {}
|
| 43 |
+
exec(compile(src, "18_eval_fm_head", "exec"), ns)
|
| 44 |
+
probe, prompts = ns["probe"], ns["prompts"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_head(anchor, enc, din, dout):
|
| 48 |
+
h = torch.nn.Sequential(torch.nn.Linear(din, 512), torch.nn.GELU(), torch.nn.Dropout(0.1), torch.nn.Linear(512, 512),
|
| 49 |
+
torch.nn.GELU(), torch.nn.LayerNorm(512), torch.nn.Linear(512, dout))
|
| 50 |
+
h.load_state_dict(torch.load(os.path.join(RUNS, "text_pathgen", f"{anchor}_{enc}", "head.pt"), map_location="cpu"))
|
| 51 |
+
return h.eval()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
ret, prb = [], []
|
| 55 |
+
for f in sorted(glob.glob(os.path.join(RUNS, "bench_fm", "*", "test_embeddings.pt"))):
|
| 56 |
+
a = json.load(open(os.path.join(os.path.dirname(f), "train.json")))["args"]
|
| 57 |
+
if a["anchor"] not in ANCHORS or a["mk_enc"] != "fm:geneformer" or a["txt_enc"] != "pathgenclip":
|
| 58 |
+
continue
|
| 59 |
+
E = torch.load(f, weights_only=False) # our own files (contain numpy tile ids)
|
| 60 |
+
he, zi, tiles = E["z"]["he"], E["z"]["if"], np.asarray(E["tile_id"])
|
| 61 |
+
key = {"anchor": a["anchor"], "if_enc": a["if_enc"], "split": a["split"], "fold": a["fold"]}
|
| 62 |
+
for q, g, d in [(he, zi, "he->if"), (zi, he, "if->he")]:
|
| 63 |
+
ret.append({**key, "dir": d, **metrics(q, g)})
|
| 64 |
+
for t in ["biomedclip", "plip", "pathgenclip"]:
|
| 65 |
+
if not os.path.exists(os.path.join(RUNS, "text_pathgen", f"{a['anchor']}_{t}", "head.pt")):
|
| 66 |
+
continue
|
| 67 |
+
prm = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{t}.npy"))[:, 0].astype(np.float32))
|
| 68 |
+
with torch.no_grad():
|
| 69 |
+
zp = F.normalize(load_head(a["anchor"], t, prm.shape[1], he.shape[1])(prm), dim=-1)
|
| 70 |
+
for m, z in [("he", he), ("if", zi)]:
|
| 71 |
+
for i, (c, pr) in enumerate(prompts.items()):
|
| 72 |
+
prb.append({**key, "text_enc": t, "modality": m, "concept": c, **probe((z @ zp[i]).numpy(), tiles, c, pr["label"])})
|
| 73 |
+
|
| 74 |
+
R, P = pd.DataFrame(ret), pd.DataFrame(prb)
|
| 75 |
+
R.to_csv(os.path.join(OUT, "anchor_ifret.csv"), index=False)
|
| 76 |
+
P.to_csv(os.path.join(OUT, "anchor_text_probes.csv"), index=False)
|
| 77 |
+
pd.set_option("display.width", 250)
|
| 78 |
+
g = R.groupby(["if_enc", "anchor", "split", "fold"])[["R@1_g100", "pair_acc"]].mean().reset_index()
|
| 79 |
+
print(g.groupby(["if_enc", "anchor", "split"])[["R@1_g100", "pair_acc"]].agg(["mean", "std"]).round(3).to_string())
|
| 80 |
+
if len(P):
|
| 81 |
+
print(P.groupby(["text_enc", "modality", "if_enc", "anchor"])[["auroc", "spearman"]].mean().round(3).to_string())
|
experiments/29_virtual_completion.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Virtual modality completion: does training text <-> RNA / text <-> protein DIRECTLY on virtual pairs beat the
|
| 2 |
+
emergent (H&E-anchor) route?
|
| 3 |
+
|
| 4 |
+
Training pairs (TCGA all-pairs pool, 6,144 patches, every real text of a patch is a positive):
|
| 5 |
+
rna : real text <-> STPath virtual RNA of the same patch (cache/stpath/tcga_text_pred.npy -> log1p CP10k, 2000 HEST HVGs)
|
| 6 |
+
protein : real text <-> GigaTIME virtual mIF of the same patch (cache/gigatime/tcga_text_means.npy, 11 channels shared with ORION)
|
| 7 |
+
Model: frozen BiomedCLIP text tower + MLP head and an MLP head on the modality vector -> shared 512-d space
|
| 8 |
+
(CLIP-style, multi-positive InfoNCE), patient GroupKFold(5) over TCGA, 10% of training patients for early stopping.
|
| 9 |
+
Each feature is rank-gaussianised within its own dataset (virtual TCGA / real HEST / real ORION), because virtual
|
| 10 |
+
values (STPath expected counts, GigaTIME probabilities) and real measurements live on different scales.
|
| 11 |
+
|
| 12 |
+
Evaluation only on REAL measurements (never virtual):
|
| 13 |
+
rna : HEST real RNA, concept prompts vs RNA of the concept gene (protocol of 24_eval_gene_emergent.py (B):
|
| 14 |
+
AUROC top quartile, per HEST study+patient fold, all spots / bowel spots)
|
| 15 |
+
protein : ORION real mIF tile means, concept prompts vs marker+ cell fraction (probe of 18_eval_fm.py, per
|
| 16 |
+
patient_5fold test fold); also ORION GigaTIME virtual means (same tiles) to separate the virtual->real gap
|
| 17 |
+
Baselines (emergent, read from results/): text head trained on PathGen H&E-caption pairs, scored against the RNA
|
| 18 |
+
(23_) / VirTues mIF (17_) embeddings bound to UNI2.
|
| 19 |
+
Outputs results/virtual_completion.csv
|
| 20 |
+
"""
|
| 21 |
+
import glob
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import scipy.sparse as sp
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
import torch.nn.functional as F
|
| 31 |
+
from scipy.special import ndtri
|
| 32 |
+
from scipy.stats import rankdata, spearmanr
|
| 33 |
+
from sklearn.metrics import roc_auc_score
|
| 34 |
+
from sklearn.model_selection import GroupKFold
|
| 35 |
+
|
| 36 |
+
from common import CACHE, ROOT, RUNS, SPLITS, load_all_tiles
|
| 37 |
+
|
| 38 |
+
dev = "cuda"
|
| 39 |
+
OUT = os.path.join(ROOT, "results")
|
| 40 |
+
TXT_ENC = "biomedclip"
|
| 41 |
+
prompts = json.load(open(os.path.join(ROOT, "prompts.json")))
|
| 42 |
+
PKEYS = list(prompts)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def rankgauss(x):
|
| 46 |
+
r = np.apply_along_axis(rankdata, 0, x)
|
| 47 |
+
return ndtri((r - 0.5) / len(x)).astype(np.float32)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------- TCGA training pool (texts, images, virtual modalities)
|
| 51 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 52 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 53 |
+
TX = torch.from_numpy(np.load(os.path.join(CACHE, f"alltxt_text_{TXT_ENC}.npy"))[:, 0].astype(np.float32)).to(dev)
|
| 54 |
+
PR = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{TXT_ENC}.npy"))[:, 0].astype(np.float32)).to(dev)
|
| 55 |
+
t_img = torch.as_tensor(T.img_idx.values, device=dev)
|
| 56 |
+
assert (pd.read_csv(os.path.join(CACHE, "stpath", "tcga_text_meta.csv")).key.values == I.key.values).all()
|
| 57 |
+
assert (pd.read_csv(os.path.join(CACHE, "gigatime", "tcga_text_meta.csv")).key.values == I.key.values).all()
|
| 58 |
+
|
| 59 |
+
# RNA: STPath log1p(counts) -> CP10k -> log1p, HEST HVGs
|
| 60 |
+
H = os.path.join(CACHE, "hest")
|
| 61 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 62 |
+
sg = pd.read_csv(os.path.join(CACHE, "stpath", "tcga_text_genes.csv")).gene.values
|
| 63 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", "tcga_text_pred.npy")).astype(np.float32)).clip(0)
|
| 64 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 65 |
+
gix = pd.Series(range(len(sg)), index=sg).loc[hvg].values
|
| 66 |
+
V_RNA = rankgauss(c[:, gix])
|
| 67 |
+
del c
|
| 68 |
+
|
| 69 |
+
# protein: GigaTIME channels shared with ORION (Actin-D + Transgelin -> SMA)
|
| 70 |
+
ch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))
|
| 71 |
+
G_CH, SHARED = ch["channels"], ch["orion_shared"]
|
| 72 |
+
ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
|
| 73 |
+
"CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"] # order of orion_real_if_means.npy (25_)
|
| 74 |
+
P_ORION = sorted(set(SHARED.values()), key=ORION_CH.index)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def giga_vec(means):
|
| 78 |
+
return np.stack([np.mean([means[:, G_CH.index(g)] for g, o in SHARED.items() if o == oc], 0) for oc in P_ORION], 1)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
V_PROT = rankgauss(giga_vec(np.load(os.path.join(CACHE, "gigatime", "tcga_text_means.npy"))))
|
| 82 |
+
|
| 83 |
+
# ---------------- real evaluation sets
|
| 84 |
+
M = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 85 |
+
hest_x = rankgauss(np.load(os.path.join(H, "hvg.npy")).astype(np.float32))
|
| 86 |
+
genes = pd.read_csv(os.path.join(H, "genes.csv")).gene.values
|
| 87 |
+
X = sp.load_npz(os.path.join(H, "counts.npz")).tocsr()
|
| 88 |
+
L = sp.diags(1e4 / np.asarray(X.sum(1)).ravel()) @ X
|
| 89 |
+
L.data = np.log1p(L.data)
|
| 90 |
+
gpos = {g: i for i, g in enumerate(genes)}
|
| 91 |
+
rna = lambda gs: np.asarray(L[:, [gpos[g] for g in gs]].mean(1)).ravel()
|
| 92 |
+
CONCEPT_RNA = {"immune": ["PTPRC"], "tumor_epith": ["KRT8", "EPCAM"], "stroma": ["ACTA2"], "proliferation": ["MKI67"],
|
| 93 |
+
"vessels": ["PECAM1"], "macrophage": ["CD68"]} # as 24_
|
| 94 |
+
hest_fold = np.full(len(M), -1)
|
| 95 |
+
for f in glob.glob(os.path.join(RUNS, "gene", "uni2_mlp_fold*", "test.pt")):
|
| 96 |
+
hest_fold[torch.load(f, weights_only=False)["idx"]] = int(f.split("_fold")[1].split(os.sep)[0])
|
| 97 |
+
assert (hest_fold >= 0).all()
|
| 98 |
+
|
| 99 |
+
df = load_all_tiles()
|
| 100 |
+
nn_ = pd.read_csv(os.path.join(OUT, "tile_stats.csv")).set_index("tile_id").loc[df.tile_id, "n_nuclei"].values
|
| 101 |
+
df["n_nuclei"] = nn_
|
| 102 |
+
orion_real = rankgauss(np.load(os.path.join(CACHE, "gigatime", "orion_real_if_means.npy"))[:, [ORION_CH.index(o) for o in P_ORION]])
|
| 103 |
+
orion_virt = rankgauss(giga_vec(np.load(os.path.join(CACHE, "gigatime", "orion_means.npy"))))
|
| 104 |
+
assert (pd.read_csv(os.path.join(CACHE, "gigatime", "orion_meta.csv")).tile_id.values == df.tile_id.values).all()
|
| 105 |
+
orion_fold = np.full(len(df), -1)
|
| 106 |
+
for k in range(5):
|
| 107 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 108 |
+
orion_fold[s.tile_id[s.split == "test"].values] = k
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def orion_probe(scores, sel, key, label_col):
|
| 112 |
+
"""18_eval_fm.probe restricted to tile mask `sel`"""
|
| 113 |
+
sub = sel if key == "cellularity" else sel & (nn_ >= 20)
|
| 114 |
+
y = df[label_col].values[sub].astype(float)
|
| 115 |
+
if label_col != "n_nuclei":
|
| 116 |
+
y = y / np.maximum(nn_[sub], 1)
|
| 117 |
+
lab = y >= np.quantile(y, 0.75)
|
| 118 |
+
return {"spearman": spearmanr(scores[sub], y).correlation,
|
| 119 |
+
"auroc": roc_auc_score(lab, scores[sub]) if 0 < lab.sum() < lab.size else np.nan}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------- model
|
| 123 |
+
def mlp(din, dout=512, hid=512): # same head as 20_pairing.py
|
| 124 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 125 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def r1(a, b):
|
| 129 |
+
s = a @ b.T
|
| 130 |
+
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def train(V, tr_t, va_t, epochs=60, bs=256, lr=3e-4, seed=0):
|
| 134 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 135 |
+
th, mh = mlp(TX.shape[1]).to(dev), mlp(V.shape[1]).to(dev)
|
| 136 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 137 |
+
opt = torch.optim.AdamW(list(th.parameters()) + list(mh.parameters()) + [logit], lr=lr, weight_decay=0.05)
|
| 138 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr_t) // bs), pct_start=0.05)
|
| 139 |
+
va = T.loc[va_t].groupby("img_idx").head(1).index.values # one text per val image
|
| 140 |
+
va_i, va_t1 = torch.as_tensor(T.img_idx.values[va], device=dev), torch.as_tensor(va, device=dev)
|
| 141 |
+
best, state = -1, None
|
| 142 |
+
for ep in range(epochs):
|
| 143 |
+
th.train(); mh.train()
|
| 144 |
+
perm = rng.permutation(tr_t)
|
| 145 |
+
for s in range(0, len(perm) - bs + 1, bs):
|
| 146 |
+
b = torch.as_tensor(perm[s:s + bs], device=dev)
|
| 147 |
+
ii = t_img[b]
|
| 148 |
+
lg = logit.exp().clamp(max=100) * F.normalize(mh(V[ii]), dim=-1) @ F.normalize(th(TX[b]), dim=-1).T
|
| 149 |
+
pos = (ii[:, None] == ii[None, :]).float()
|
| 150 |
+
tgt = pos / pos.sum(1, keepdim=True)
|
| 151 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 152 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 153 |
+
th.eval(); mh.eval()
|
| 154 |
+
with torch.no_grad():
|
| 155 |
+
zv, zt = F.normalize(mh(V[va_i]), dim=-1), F.normalize(th(TX[va_t1]), dim=-1)
|
| 156 |
+
v = (r1(zv, zt) + r1(zt, zv)) / 2
|
| 157 |
+
if v > best:
|
| 158 |
+
best, state = v, ({k: x.clone() for k, x in th.state_dict().items()}, {k: x.clone() for k, x in mh.state_dict().items()})
|
| 159 |
+
th.load_state_dict(state[0]); mh.load_state_dict(state[1]); th.eval(); mh.eval()
|
| 160 |
+
return th, mh, best
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@torch.no_grad()
|
| 164 |
+
def score(th, mh, x):
|
| 165 |
+
zp = F.normalize(th(PR), dim=-1)
|
| 166 |
+
z = F.normalize(mh(torch.from_numpy(x).to(dev)), dim=-1)
|
| 167 |
+
return (z @ zp.T).cpu().numpy() # (n, n_prompts)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
rows = []
|
| 171 |
+
folds = list(GroupKFold(5).split(I, groups=I.patient))
|
| 172 |
+
for mod, Vnp in [("rna", V_RNA), ("protein", V_PROT)]:
|
| 173 |
+
V = torch.from_numpy(Vnp).to(dev)
|
| 174 |
+
for k, (trv, te) in enumerate(folds):
|
| 175 |
+
pats = np.random.default_rng(k).permutation(sorted(I.patient.iloc[trv].unique()))
|
| 176 |
+
va_p = set(pats[: max(1, len(pats) // 10)])
|
| 177 |
+
isplit = pd.Series("test", index=I.index)
|
| 178 |
+
isplit.iloc[trv] = np.where(I.patient.iloc[trv].isin(va_p), "val", "train")
|
| 179 |
+
ts = isplit.loc[T.img_idx].values
|
| 180 |
+
tr_t, va_t, te_t = (T.index[ts == s].values for s in ["train", "val", "test"])
|
| 181 |
+
th, mh, vbest = train(V, tr_t, va_t)
|
| 182 |
+
# in-domain sanity: held-out TCGA patients, text <-> virtual modality, 1 text per image, R@1 on 100-galleries
|
| 183 |
+
te1 = T.loc[te_t].groupby("img_idx").head(1).index.values
|
| 184 |
+
with torch.no_grad():
|
| 185 |
+
zv = F.normalize(mh(V[T.img_idx.values[te1]]), dim=-1).cpu()
|
| 186 |
+
zt = F.normalize(th(TX[te1]), dim=-1).cpu()
|
| 187 |
+
g = np.random.default_rng(0)
|
| 188 |
+
r = np.mean([(r1(zv[s], zt[s]) + r1(zt[s], zv[s])) / 2 for s in (g.choice(len(te1), 100, replace=False) for _ in range(50))])
|
| 189 |
+
rows.append({"modality": mod, "model_fold": k, "eval": "tcga_virtual_R@1_g100", "concept": "-", "auroc": np.nan, "spearman": r, "val_r1": vbest})
|
| 190 |
+
if mod == "rna":
|
| 191 |
+
S = score(th, mh, hest_x)
|
| 192 |
+
for c, gs in CONCEPT_RNA.items():
|
| 193 |
+
y_all = rna(gs)
|
| 194 |
+
for gal in ["all", "bowel"]:
|
| 195 |
+
gm = np.ones(len(M), bool) if gal == "all" else (M.organ == "Bowel").values
|
| 196 |
+
for hk in range(5):
|
| 197 |
+
sel = gm & (hest_fold == hk)
|
| 198 |
+
y = y_all[sel]; lab = y >= np.quantile(y, 0.75)
|
| 199 |
+
s = S[sel, PKEYS.index(c)]
|
| 200 |
+
rows.append({"modality": mod, "model_fold": k, "eval": f"hest_real_{gal}", "concept": c, "eval_fold": hk,
|
| 201 |
+
"auroc": roc_auc_score(lab, s) if 0 < lab.sum() < lab.size else np.nan, "spearman": spearmanr(s, y).correlation})
|
| 202 |
+
else:
|
| 203 |
+
for src, x in [("orion_real", orion_real), ("orion_virtual", orion_virt)]:
|
| 204 |
+
S = score(th, mh, x)
|
| 205 |
+
for i, (c, pr) in enumerate(prompts.items()):
|
| 206 |
+
for ok in range(5):
|
| 207 |
+
rows.append({"modality": mod, "model_fold": k, "eval": src, "concept": c, "eval_fold": ok,
|
| 208 |
+
**orion_probe(S[:, i], orion_fold == ok, c, pr["label"])})
|
| 209 |
+
print(mod, "fold", k, "val", round(vbest, 3), "tcga R@1_g100", round(r, 3), flush=True)
|
| 210 |
+
|
| 211 |
+
R = pd.DataFrame(rows)
|
| 212 |
+
R.to_csv(os.path.join(OUT, "virtual_completion.csv"), index=False)
|
| 213 |
+
|
| 214 |
+
# ---------------- comparison with the emergent route (same prompts, same text encoder, same real labels)
|
| 215 |
+
pd.set_option("display.width", 250)
|
| 216 |
+
print(R[R["eval"] == "tcga_virtual_R@1_g100"].groupby("modality").spearman.agg(["mean", "std"]).round(3))
|
| 217 |
+
D = R[R["eval"] != "tcga_virtual_R@1_g100"]
|
| 218 |
+
print(D.groupby(["modality", "eval", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
|
| 219 |
+
print(D.groupby(["modality", "eval"]).auroc.mean().round(3).to_string())
|
| 220 |
+
E = pd.read_csv(os.path.join(OUT, "gene_emergent_text.csv"))
|
| 221 |
+
E = E[E.text_enc == TXT_ENC]
|
| 222 |
+
print("emergent text<->RNA (HEST real):\n", E.groupby(["gallery", "modality", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
|
| 223 |
+
A = pd.read_csv(os.path.join(OUT, "anchor_text_probes.csv"))
|
| 224 |
+
A = A[(A.text_enc == TXT_ENC) & (A.split == "patient_5fold") & (A.anchor == "uni2")]
|
| 225 |
+
print("emergent text<->protein (ORION real, VirTues):\n", A.groupby(["modality", "concept"]).auroc.mean().unstack("concept").round(3).to_string())
|
| 226 |
+
# single-marker reference on ORION real: the matched channel itself as the score
|
| 227 |
+
REF = {"immune": "CD3e", "tumor_epith": "Pan-CK", "stroma": "SMA", "proliferation": "Ki67", "macrophage": "CD68", "cellularity": "Hoechst"}
|
| 228 |
+
ref = {c: np.mean([orion_probe(orion_real[:, P_ORION.index(ch_)], orion_fold == ok, c, prompts[c]["label"])["auroc"] for ok in range(5)])
|
| 229 |
+
for c, ch_ in REF.items()}
|
| 230 |
+
print("single real channel as score (reference):", {c: round(v, 3) for c, v in ref.items()})
|
experiments/30_virtues_shared.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VirTues embeddings on the protein panel shared by ORION (real mIF) and GigaTIME (virtual mIF from H&E), so that one
|
| 2 |
+
mIF head trained on REAL ORION data can embed virtual mIF for validation (virtual data is never used for training).
|
| 3 |
+
|
| 4 |
+
Shared panel (ORION name <- GigaTIME channel): Hoechst<-DAPI, CD68, CD4, CD8a<-CD8, CD20, PD-L1, CD3e<-CD3, Ki67,
|
| 5 |
+
Pan-CK<-CK, SMA<-mean(Actin-D, Transgelin). PD-1 is left out (ORION PD-1 export is saturated / near constant).
|
| 6 |
+
|
| 7 |
+
Usage: python 30_virtues_shared.py real -> cache/if_virtues_shared.npy (3187, 8, 512) 8 dihedral views
|
| 8 |
+
python 30_virtues_shared.py virtual orion|tcga_text -> cache/if_virtues_shared_virtual_<set>.npy (N, 1, 512) view 0
|
| 9 |
+
python 30_virtues_shared.py virtual_qm orion,tcga_text -> cache/if_virtues_shared_virtual_qm_<set>_fold<k>.npy
|
| 10 |
+
(virtual pixels quantile-mapped per channel onto the real fold-k TRAIN pixel distribution)
|
| 11 |
+
--m16: 16-channel ORION panel shared with MIPHEI-ViT (all ORION markers but PD-1):
|
| 12 |
+
python 30_virtues_shared.py real --m16 -> cache/if_virtues_m16.npy (3187, 8, 512)
|
| 13 |
+
python 30_virtues_shared.py miphei <set> --m16 -> cache/if_virtues_m16_virtual_miphei_<set>.npy (N, 1, 512)
|
| 14 |
+
Preprocessing = 14_extract_if_fm.py (per-slide 0.99-quantile clip, log1p, dataset mean/std, 3x3 Gaussian blur, ~1 um/px):
|
| 15 |
+
real : ORION 333 px tiles (166 um) -> 168 px
|
| 16 |
+
virtual : GigaTIME maps (128 px; ORION tile 166 um, TCGA 672-px patch at ~0.25 um/px = 168 um) -> 168 px; values are
|
| 17 |
+
the uint8 sigmoid probabilities (0-255), standardised within their own set (slide = ORION slide / TCGA WSI)
|
| 18 |
+
"""
|
| 19 |
+
import importlib.util
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import torch
|
| 28 |
+
import torch.nn.functional as F
|
| 29 |
+
|
| 30 |
+
from common import CACHE, ROOT, dihedral, load_all_tiles
|
| 31 |
+
|
| 32 |
+
MODE = sys.argv[1]
|
| 33 |
+
_argv, sys.argv = sys.argv, [sys.argv[0], "virtues", "--no_probe"] # 14_ parses its CLI at import time
|
| 34 |
+
spec = importlib.util.spec_from_file_location("virt14", os.path.join(ROOT, "14_extract_if_fm.py"))
|
| 35 |
+
V = importlib.util.module_from_spec(spec); spec.loader.exec_module(V)
|
| 36 |
+
sys.argv = _argv
|
| 37 |
+
dev, SIZE = V.dev, V.SIZE
|
| 38 |
+
|
| 39 |
+
SHARED = [("Hoechst", ["DAPI"]), ("CD68", ["CD68"]), ("CD4", ["CD4"]), ("CD8a", ["CD8"]), ("CD20", ["CD20"]),
|
| 40 |
+
("PD-L1", ["PD-L1"]), ("CD3e", ["CD3"]), ("Ki67", ["Ki67"]), ("Pan-CK", ["CK"]), ("SMA", ["Actin-D", "Transgelin"])]
|
| 41 |
+
ORION_NAMES = [m for m, _ in V.MARKERS]
|
| 42 |
+
UNIPROT = dict(V.MARKERS)
|
| 43 |
+
M16 = "--m16" in sys.argv # 16-channel panel shared by ORION and MIPHEI-ViT (all ORION markers except PD-1)
|
| 44 |
+
if M16:
|
| 45 |
+
sys.argv.remove("--m16")
|
| 46 |
+
SHARED = [(m, [m]) for m in ORION_NAMES if m != "PD-1"]
|
| 47 |
+
SUF = "m16" if M16 else "shared"
|
| 48 |
+
ids = [UNIPROT[m] for m, _ in SHARED]
|
| 49 |
+
|
| 50 |
+
t0 = time.time()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_virtual(st):
|
| 54 |
+
gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
|
| 55 |
+
maps = np.load(os.path.join(CACHE, "gigatime", f"{st}_maps.npy"), mmap_mode="r") # N,21,128,128 uint8
|
| 56 |
+
meta = pd.read_csv(os.path.join(CACHE, "gigatime", f"{st}_meta.csv"))
|
| 57 |
+
N = len(maps)
|
| 58 |
+
X = np.empty((N, len(SHARED), SIZE, SIZE), np.float16)
|
| 59 |
+
for s in range(0, N, 256):
|
| 60 |
+
m = torch.from_numpy(np.asarray(maps[s:s + 256])).to(dev).float()
|
| 61 |
+
x = torch.stack([m[:, [gch.index(g) for g in gs]].mean(1) for _, gs in SHARED], 1)
|
| 62 |
+
X[s:s + 256] = F.interpolate(x, size=(SIZE, SIZE), mode="bilinear", align_corners=False).clamp(min=0).half().cpu().numpy()
|
| 63 |
+
if st == "orion":
|
| 64 |
+
df = load_all_tiles()
|
| 65 |
+
assert (meta.tile_id.values == df.tile_id.values).all()
|
| 66 |
+
slide = df.in_slide_name.values
|
| 67 |
+
else:
|
| 68 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 69 |
+
assert (meta.key.values == I.key.values).all()
|
| 70 |
+
slide = I.wsi_id.values
|
| 71 |
+
return X, slide
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def encode(X, slide, views, out_f):
|
| 75 |
+
X, uq, mean, std, valid, slide_k, n_zero = V.standardize(X, slide)
|
| 76 |
+
print(" log1p mean", np.round(mean, 2).tolist(), "\n std", np.round(std, 2).tolist(), "| zero-uq (slide,ch):", int(n_zero.sum()))
|
| 77 |
+
model, midx = V.load_virtues(ids)
|
| 78 |
+
midx = midx.to(dev)
|
| 79 |
+
N = len(X)
|
| 80 |
+
out = np.zeros((N, views, 512), np.float16)
|
| 81 |
+
BS = 4 if views == 8 else 32 # 32 crops per forward (~4.7 GiB peak, shared GPU)
|
| 82 |
+
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
|
| 83 |
+
for s in range(0, N, BS):
|
| 84 |
+
x = torch.from_numpy(X[s:s + BS]).to(dev).float()
|
| 85 |
+
crops = [dihedral(x, d)[i] for d in range(views) for i in range(len(x))] # d-major
|
| 86 |
+
ps = model.encoder.forward_list(crops, [midx] * len(crops)).patch_summary_tokens
|
| 87 |
+
e = torch.stack([p.float().mean((0, 1)) for p in ps]).view(views, len(x), -1).transpose(0, 1)
|
| 88 |
+
out[s:s + BS] = e.cpu().numpy().astype(np.float16)
|
| 89 |
+
np.save(out_f, out)
|
| 90 |
+
print(f"saved {out_f} {out.shape} finite {np.isfinite(out).all()} [{time.time() - t0:.0f}s]", flush=True)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
if MODE == "miphei":
|
| 94 |
+
# MIPHEI virtual mIF (35_miphei_infer.py): already on the 8-bit ORION scale and resampled to SIZE like the real tiles
|
| 95 |
+
assert M16
|
| 96 |
+
st = sys.argv[2]
|
| 97 |
+
ch = json.load(open(os.path.join(CACHE, "miphei", "channels.json")))["channels"]
|
| 98 |
+
X = np.load(os.path.join(CACHE, "miphei", f"{st}_maps168.npy"))[:, [ch.index(m) for m, _ in SHARED]].astype(np.float16)
|
| 99 |
+
if st == "orion":
|
| 100 |
+
slide = load_all_tiles().in_slide_name.values
|
| 101 |
+
else:
|
| 102 |
+
slide = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv")).wsi_id.values if st == "tcga_text" else \
|
| 103 |
+
pd.read_csv(os.path.join(CACHE, "external", f"{st}_meta.csv"))["sample"].astype(str).values
|
| 104 |
+
assert len(slide) == len(X)
|
| 105 |
+
encode(X, slide, 1, os.path.join(CACHE, f"if_virtues_m16_virtual_miphei_{st}.npy"))
|
| 106 |
+
sys.exit(0)
|
| 107 |
+
|
| 108 |
+
if MODE == "virtual_qm":
|
| 109 |
+
# channel-UNIFIED virtual mIF: per channel, quantile-map the GigaTIME probability pixels onto the REAL ORION pixel
|
| 110 |
+
# intensity distribution of the fold-k TRAINING tiles (monotone, keeps the spatial pattern), then the real
|
| 111 |
+
# preprocessing + VirTues. Only real training-split pixels define the target distribution; nothing is trained.
|
| 112 |
+
sets = sys.argv[2].split(",")
|
| 113 |
+
df = load_all_tiles()
|
| 114 |
+
X17, _ = V.load_resampled(df)
|
| 115 |
+
XR = X17[:, [ORION_NAMES.index(m) for m, _ in SHARED]]; del X17
|
| 116 |
+
P = np.linspace(0, 1, 1001)
|
| 117 |
+
rng = np.random.default_rng(0)
|
| 118 |
+
ref = {}
|
| 119 |
+
for k in range(5):
|
| 120 |
+
sp = pd.read_csv(os.path.join(ROOT, "splits", "patient_5fold", f"fold{k}.csv"))
|
| 121 |
+
tr = sp.tile_id[sp.split == "train"].values
|
| 122 |
+
sub = rng.choice(tr, min(400, len(tr)), replace=False)
|
| 123 |
+
ref[k] = np.stack([np.quantile(XR[sub, c].astype(np.float32).ravel(), P) for c in range(len(SHARED))])
|
| 124 |
+
del XR
|
| 125 |
+
np.savez(os.path.join(CACHE, "gigatime", "qm_ref_real_train.npz"), P=P, **{f"fold{k}": v for k, v in ref.items()})
|
| 126 |
+
for st in sets:
|
| 127 |
+
X0, slide = load_virtual(st)
|
| 128 |
+
sub = rng.choice(len(X0), min(400, len(X0)), replace=False)
|
| 129 |
+
vq = np.stack([np.quantile(X0[sub, c].astype(np.float32).ravel(), P) for c in range(len(SHARED))])
|
| 130 |
+
vq = vq + np.arange(len(P))[None] * 1e-6 # strictly increasing despite the many zero-probability ties
|
| 131 |
+
for k in range(5):
|
| 132 |
+
X = np.empty_like(X0)
|
| 133 |
+
for s in range(0, len(X0), 128):
|
| 134 |
+
x = X0[s:s + 128].astype(np.float32)
|
| 135 |
+
for c in range(len(SHARED)):
|
| 136 |
+
x[:, c] = np.interp(x[:, c], vq[c], ref[k][c])
|
| 137 |
+
X[s:s + 128] = x.astype(np.float16)
|
| 138 |
+
print(f"[{time.time() - t0:.0f}s] {st} fold {k}: quantile-mapped", flush=True)
|
| 139 |
+
encode(X, slide, 1, os.path.join(CACHE, f"if_virtues_shared_virtual_qm_{st}_fold{k}.npy"))
|
| 140 |
+
del X
|
| 141 |
+
sys.exit(0)
|
| 142 |
+
|
| 143 |
+
if MODE == "real":
|
| 144 |
+
df = load_all_tiles()
|
| 145 |
+
X17, _ = V.load_resampled(df)
|
| 146 |
+
X = np.ascontiguousarray(X17[:, [ORION_NAMES.index(m) for m, _ in SHARED]]); del X17
|
| 147 |
+
slide, views, out_f = df.in_slide_name.values, 8, os.path.join(CACHE, f"if_virtues_{SUF}.npy")
|
| 148 |
+
elif MODE == "virtual":
|
| 149 |
+
st = sys.argv[2]
|
| 150 |
+
gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
|
| 151 |
+
maps = np.load(os.path.join(CACHE, "gigatime", f"{st}_maps.npy"), mmap_mode="r") # N,21,128,128 uint8
|
| 152 |
+
meta = pd.read_csv(os.path.join(CACHE, "gigatime", f"{st}_meta.csv"))
|
| 153 |
+
N = len(maps)
|
| 154 |
+
X = np.empty((N, len(SHARED), SIZE, SIZE), np.float16)
|
| 155 |
+
for s in range(0, N, 256):
|
| 156 |
+
m = torch.from_numpy(np.asarray(maps[s:s + 256])).to(dev).float()
|
| 157 |
+
x = torch.stack([m[:, [gch.index(g) for g in gs]].mean(1) for _, gs in SHARED], 1)
|
| 158 |
+
X[s:s + 256] = F.interpolate(x, size=(SIZE, SIZE), mode="bilinear", align_corners=False).clamp(min=0).half().cpu().numpy()
|
| 159 |
+
if st == "orion":
|
| 160 |
+
df = load_all_tiles()
|
| 161 |
+
assert (meta.tile_id.values == df.tile_id.values).all()
|
| 162 |
+
slide = df.in_slide_name.values
|
| 163 |
+
else:
|
| 164 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 165 |
+
assert (meta.key.values == I.key.values).all()
|
| 166 |
+
slide = I.wsi_id.values
|
| 167 |
+
views, out_f = 1, os.path.join(CACHE, f"if_virtues_shared_virtual_{st}.npy")
|
| 168 |
+
else:
|
| 169 |
+
raise ValueError(MODE)
|
| 170 |
+
|
| 171 |
+
print(f"[{time.time() - t0:.0f}s] loaded {X.shape}; standardising", flush=True)
|
| 172 |
+
X, uq, mean, std, valid, slide_k, n_zero = V.standardize(X, slide)
|
| 173 |
+
print(" log1p mean", np.round(mean, 2).tolist(), "\n std", np.round(std, 2).tolist(), "| zero-uq (slide,ch):", int(n_zero.sum()))
|
| 174 |
+
model, midx = V.load_virtues(ids)
|
| 175 |
+
midx = midx.to(dev)
|
| 176 |
+
N = len(X)
|
| 177 |
+
out = np.zeros((N, views, 512), np.float16)
|
| 178 |
+
BS = 4 if views == 8 else 32 # 32 crops per forward (~4.7 GiB peak, shared GPU)
|
| 179 |
+
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
|
| 180 |
+
for s in range(0, N, BS):
|
| 181 |
+
x = torch.from_numpy(X[s:s + BS]).to(dev).float()
|
| 182 |
+
crops = [dihedral(x, d)[i] for d in range(views) for i in range(len(x))] # d-major
|
| 183 |
+
ps = model.encoder.forward_list(crops, [midx] * len(crops)).patch_summary_tokens
|
| 184 |
+
e = torch.stack([p.float().mean((0, 1)) for p in ps]).view(views, len(x), -1).transpose(0, 1)
|
| 185 |
+
out[s:s + BS] = e.cpu().numpy().astype(np.float16)
|
| 186 |
+
if s % (BS * 50) == 0:
|
| 187 |
+
print(f" encoded {s}/{N} [{time.time() - t0:.0f}s]", flush=True)
|
| 188 |
+
np.save(out_f, out)
|
| 189 |
+
print(f"saved {out_f} {out.shape} finite {np.isfinite(out).all()} [{time.time() - t0:.0f}s]")
|
experiments/31_virtual_validation.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate the 4-modality ImageBind model with VIRTUAL modalities (validation only; every encoder/head was trained
|
| 2 |
+
on real pairs: H&E-mIF ORION (17_), H&E-text PathGen (12_), H&E-RNA HEST (23_); H&E = frozen UNI2).
|
| 3 |
+
|
| 4 |
+
Virtual data fill the modality pairs that no dataset measures together, giving quadruplets:
|
| 5 |
+
ORION : H&E (real) | mIF (real) | RNA (STPath, virtual) | text (GPT synthetic caption of the H&E, 27_/32_)
|
| 6 |
+
TCGA : H&E (real) | text (real) | mIF (GigaTIME, virtual) | RNA (STPath, virtual)
|
| 7 |
+
HEST : H&E (real) | RNA (real) + RNA (STPath, virtual) -> calibration of the virtual RNA
|
| 8 |
+
ORION : mIF real vs mIF virtual (GigaTIME) on the same tiles -> calibration of the virtual mIF
|
| 9 |
+
Every embedding lives in the UNI2 space, so any two modalities are compared by cosine similarity.
|
| 10 |
+
|
| 11 |
+
Heads: RNA runs/gene/uni2_mlp_fold<k>/head.pt (MLP on log1p-CP10k of the 2000 HEST HVGs; HEST fold k)
|
| 12 |
+
mIF runs/bench_fm/patient_5fold_fold<k>_uni2_fm-virtues[_shared]_fm-geneformer_pathgenclip/heads.pt
|
| 13 |
+
(17-channel VirTues for real ORION; 10-channel shared panel (30_) for real-vs-virtual / TCGA)
|
| 14 |
+
text runs/text_pathgen/uni2_biomedclip/head.pt (PathGen captions); TCGA evaluation images exclude every
|
| 15 |
+
patient of its train/val split
|
| 16 |
+
Metrics on random 100-item galleries (50 draws): R@1 (tie-aware, both directions averaged) and one-to-one Hungarian
|
| 17 |
+
pairing accuracy (chance 0.01). "circular" = both sides are derived from the same H&E image (virtual-from-H&E), so the
|
| 18 |
+
score is an upper bound of what real data would give, not evidence of biological agreement.
|
| 19 |
+
Outputs results/virtual_validation.csv
|
| 20 |
+
"""
|
| 21 |
+
import os
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
import torch
|
| 26 |
+
import torch.nn as nn
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
from scipy.optimize import linear_sum_assignment
|
| 29 |
+
|
| 30 |
+
from common import CACHE, ROOT, RUNS, SPLITS, load_all_tiles
|
| 31 |
+
|
| 32 |
+
OUT = os.path.join(ROOT, "results")
|
| 33 |
+
H = os.path.join(CACHE, "hest")
|
| 34 |
+
G100, DRAWS = 100, 50
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def mlp(din, dout=1536, hid=512):
|
| 38 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 39 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_mlp(sd):
|
| 43 |
+
h = mlp(sd["0.weight"].shape[1]); h.load_state_dict(sd); return h.eval()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def nz(x):
|
| 47 |
+
return F.normalize(torch.as_tensor(np.asarray(x), dtype=torch.float32), dim=-1)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def g100(a, b, seed=0):
|
| 51 |
+
"""mean over random 100-galleries of tie-aware R@1 (both directions) and Hungarian pairing accuracy"""
|
| 52 |
+
rng = np.random.default_rng(seed)
|
| 53 |
+
r, p = [], []
|
| 54 |
+
for _ in range(DRAWS):
|
| 55 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 56 |
+
sim = (a[s] @ b[s].T).numpy()
|
| 57 |
+
d = np.diag(sim)[:, None]
|
| 58 |
+
r.append(((sim > d).sum(1) == 0).mean() / 2 + ((sim.T > d).sum(1) == 0).mean() / 2)
|
| 59 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 60 |
+
p.append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 61 |
+
return float(np.mean(r)), float(np.mean(p))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
rows = []
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def add(dataset, fold, pair, za, zb, kind, circular):
|
| 68 |
+
r, p = g100(za, zb)
|
| 69 |
+
rows.append({"dataset": dataset, "fold": fold, "pair": pair, "kind": kind, "circular": circular, "n": len(za),
|
| 70 |
+
"R@1_g100": r, "pair_acc_g100": p})
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---------------- RNA: STPath log1p(counts) -> log1p CP10k on the HEST HVGs (same input as the HEST-trained head)
|
| 74 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def virtual_rna(st):
|
| 78 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 79 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 80 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 81 |
+
return torch.from_numpy(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values])
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
rna_heads = [load_mlp(torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "head.pt"), map_location="cpu")) for k in range(5)]
|
| 85 |
+
text_head = load_mlp(torch.load(os.path.join(RUNS, "text_pathgen", "uni2_biomedclip", "head.pt"), map_location="cpu"))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def mif_heads(enc, k, split="patient_5fold"):
|
| 89 |
+
f = os.path.join(RUNS, "bench_fm", f"{split}_fold{k}_uni2_fm-{enc}_fm-geneformer_pathgenclip", "heads.pt")
|
| 90 |
+
if not os.path.exists(f):
|
| 91 |
+
return None
|
| 92 |
+
d = torch.load(f, map_location="cpu")
|
| 93 |
+
h = load_mlp(d["state"]["if"])
|
| 94 |
+
return lambda x: h((torch.as_tensor(x, dtype=torch.float32) - d["if_mean"]) / d["if_std"])
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------- (A) HEST: H&E <-> real RNA vs H&E <-> virtual RNA, same spots, HEST test folds
|
| 98 |
+
M = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 99 |
+
hv = pd.read_csv(os.path.join(CACHE, "stpath", "hest_val_meta.csv"))
|
| 100 |
+
assert (hv["sample"].values == M["sample"].values).all() and (hv.spot.values == M.spot.values).all()
|
| 101 |
+
he_h = np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32)
|
| 102 |
+
real_h = torch.from_numpy(np.load(os.path.join(H, "hvg.npy")).astype(np.float32))
|
| 103 |
+
virt_h = virtual_rna("hest_val")
|
| 104 |
+
with torch.no_grad():
|
| 105 |
+
for k in range(5):
|
| 106 |
+
te = torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "test.pt"), weights_only=False)["idx"]
|
| 107 |
+
zr, zv, zh = nz(rna_heads[k](real_h[te])), nz(rna_heads[k](virt_h[te])), nz(he_h[te])
|
| 108 |
+
for sub, m in [("all", np.ones(len(te), bool)), ("STPath-heldout", hv.hest_bench_heldout.values[te])]:
|
| 109 |
+
if m.sum() < G100:
|
| 110 |
+
continue
|
| 111 |
+
add(f"HEST ({sub})", k, "H&E<->RNA real", zh[m], zr[m], "trained (real)", False)
|
| 112 |
+
add(f"HEST ({sub})", k, "H&E<->RNA virtual", zh[m], zv[m], "virtual calibration", True)
|
| 113 |
+
add(f"HEST ({sub})", k, "RNA real<->RNA virtual", zr[m], zv[m], "virtual calibration", False)
|
| 114 |
+
print("HEST done", flush=True)
|
| 115 |
+
|
| 116 |
+
# ---------------- (B) ORION: patient 5-fold test tiles
|
| 117 |
+
df = load_all_tiles()
|
| 118 |
+
assert (pd.read_csv(os.path.join(CACHE, "stpath", "orion_meta.csv")).key.values == df.image_path.values).all()
|
| 119 |
+
he_o = np.load(os.path.join(CACHE, "he_uni2.npy"))[:, 0].astype(np.float32)
|
| 120 |
+
rna_o = virtual_rna("orion")
|
| 121 |
+
if17 = np.load(os.path.join(CACHE, "if_virtues.npy"))[:, 0].astype(np.float32)
|
| 122 |
+
f_sh = os.path.join(CACHE, "if_virtues_shared.npy")
|
| 123 |
+
f_vo = os.path.join(CACHE, "if_virtues_shared_virtual_orion.npy")
|
| 124 |
+
ifs_r = np.load(f_sh)[:, 0].astype(np.float32) if os.path.exists(f_sh) else None
|
| 125 |
+
ifs_v = np.load(f_vo)[:, 0].astype(np.float32) if os.path.exists(f_vo) else None
|
| 126 |
+
f_gt = os.path.join(CACHE, "orion_gentext_text_biomedclip.npy") # synthetic GPT captions of the ORION H&E (32_ + 08_)
|
| 127 |
+
gt_o = torch.from_numpy(np.load(f_gt)[:, 0].astype(np.float32)) if os.path.exists(f_gt) else None
|
| 128 |
+
with torch.no_grad():
|
| 129 |
+
for k in range(5):
|
| 130 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 131 |
+
te = s.tile_id[s.split == "test"].values
|
| 132 |
+
zh, zr = nz(he_o[te]), nz(rna_heads[k](rna_o[te]))
|
| 133 |
+
add("ORION", k, "H&E<->RNA virtual", zh, zr, "virtual (cross-dataset head)", True)
|
| 134 |
+
h17 = mif_heads("virtues", k)
|
| 135 |
+
if h17 is not None:
|
| 136 |
+
zp = nz(h17(if17[te]))
|
| 137 |
+
add("ORION", k, "H&E<->mIF real (17 ch)", zh, zp, "trained (real)", False)
|
| 138 |
+
add("ORION", k, "mIF real (17 ch)<->RNA virtual", zp, zr, "emergent, real x virtual", False)
|
| 139 |
+
hs = mif_heads("virtues_shared", k)
|
| 140 |
+
if hs is not None and ifs_r is not None:
|
| 141 |
+
zs = nz(hs(ifs_r[te]))
|
| 142 |
+
add("ORION", k, "H&E<->mIF real (10 ch)", zh, zs, "trained (real)", False)
|
| 143 |
+
add("ORION", k, "mIF real (10 ch)<->RNA virtual", zs, zr, "emergent, real x virtual", False)
|
| 144 |
+
if ifs_v is not None:
|
| 145 |
+
zv = nz(hs(ifs_v[te]))
|
| 146 |
+
add("ORION", k, "H&E<->mIF virtual (10 ch)", zh, zv, "virtual calibration", True)
|
| 147 |
+
add("ORION", k, "mIF real<->mIF virtual (10 ch)", zs, zv, "virtual calibration", False)
|
| 148 |
+
add("ORION", k, "mIF virtual<->RNA virtual", zv, zr, "emergent, virtual x virtual", True)
|
| 149 |
+
if gt_o is not None: # synthetic text is written from the same H&E -> circular with H&E and H&E-derived data
|
| 150 |
+
zt = nz(text_head(gt_o[te]))
|
| 151 |
+
add("ORION", k, "H&E<->text synthetic", zh, zt, "virtual (text head from PathGen)", True)
|
| 152 |
+
add("ORION", k, "text synthetic<->RNA virtual", zt, zr, "emergent, virtual x virtual", True)
|
| 153 |
+
if h17 is not None:
|
| 154 |
+
add("ORION", k, "text synthetic<->mIF real (17 ch)", zt, zp, "emergent, real x virtual", False)
|
| 155 |
+
if hs is not None and ifs_r is not None:
|
| 156 |
+
add("ORION", k, "text synthetic<->mIF real (10 ch)", zt, zs, "emergent, real x virtual", False)
|
| 157 |
+
print("ORION done", flush=True)
|
| 158 |
+
|
| 159 |
+
# ---------------- (C) TCGA quadruplets: H&E, real text, virtual mIF, virtual RNA
|
| 160 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 161 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 162 |
+
P = pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv"))
|
| 163 |
+
seen = set(P.patient[P.split.isin(["train", "val"])])
|
| 164 |
+
ev = I.index[~I.patient.isin(seen)].values
|
| 165 |
+
rng = np.random.default_rng(0)
|
| 166 |
+
t1 = T[T.img_idx.isin(ev)].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 167 |
+
ev = t1.index.values # images with >= 1 text, patients unseen by the text head
|
| 168 |
+
print(f"TCGA eval: {len(ev)} images, {I.patient.iloc[ev].nunique()} patients (excluded {len(seen)} text-head patients)", flush=True)
|
| 169 |
+
he_t = np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))[ev, 0].astype(np.float32)
|
| 170 |
+
tx = torch.from_numpy(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[t1.values, 0].astype(np.float32))
|
| 171 |
+
rna_t = virtual_rna("tcga_text")[ev]
|
| 172 |
+
f_vt = os.path.join(CACHE, "if_virtues_shared_virtual_tcga_text.npy")
|
| 173 |
+
ifv_t = np.load(f_vt)[ev, 0].astype(np.float32) if os.path.exists(f_vt) else None
|
| 174 |
+
with torch.no_grad():
|
| 175 |
+
zh, zt = nz(he_t), nz(text_head(tx))
|
| 176 |
+
for k in range(5):
|
| 177 |
+
zr = nz(rna_heads[k](rna_t))
|
| 178 |
+
Z = {"H&E": zh, "text": zt, "RNA virtual": zr}
|
| 179 |
+
hs = mif_heads("virtues_shared", k)
|
| 180 |
+
if hs is not None and ifv_t is not None:
|
| 181 |
+
Z["mIF virtual"] = nz(hs(ifv_t))
|
| 182 |
+
names = list(Z)
|
| 183 |
+
for i in range(len(names)):
|
| 184 |
+
for j in range(i + 1, len(names)):
|
| 185 |
+
a, b = names[i], names[j]
|
| 186 |
+
if (a, b) == ("H&E", "text") and k > 0:
|
| 187 |
+
continue # does not depend on the fold heads
|
| 188 |
+
virt = [x for x in (a, b) if "virtual" in x]
|
| 189 |
+
circ = len(virt) == 2 or (len(virt) == 1 and "H&E" in (a, b))
|
| 190 |
+
kind = "trained (real)" if (a, b) == ("H&E", "text") else \
|
| 191 |
+
"emergent, real x virtual" if len(virt) == 1 and "H&E" not in (a, b) else \
|
| 192 |
+
"emergent, virtual x virtual" if len(virt) == 2 else "virtual (cross-dataset head)"
|
| 193 |
+
add("TCGA", k, f"{a}<->{b}", Z[a], Z[b], kind, circ)
|
| 194 |
+
print("TCGA done", flush=True)
|
| 195 |
+
|
| 196 |
+
# ---------------- (D) mIF as 10 channel MEANS (GigaTIME per-tile means are reliable, its pixel maps are not):
|
| 197 |
+
# MLP head on rank-gaussianised shared-panel means, trained on REAL ORION (patient_5fold fold k train/val) vs UNI2 H&E;
|
| 198 |
+
# applied unchanged to virtual means (rank-gaussianised within their own set)
|
| 199 |
+
import json
|
| 200 |
+
from scipy.special import ndtri
|
| 201 |
+
from scipy.stats import rankdata
|
| 202 |
+
|
| 203 |
+
rg = lambda x: torch.from_numpy(ndtri((np.apply_along_axis(rankdata, 0, x) - 0.5) / len(x)).astype(np.float32))
|
| 204 |
+
ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
|
| 205 |
+
"CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"] # order of orion_real_if_means.npy (25_)
|
| 206 |
+
PANEL = [("Hoechst", ["DAPI"]), ("CD68", ["CD68"]), ("CD4", ["CD4"]), ("CD8a", ["CD8"]), ("CD20", ["CD20"]),
|
| 207 |
+
("PD-L1", ["PD-L1"]), ("CD3e", ["CD3"]), ("Ki67", ["Ki67"]), ("Pan-CK", ["CK"]), ("SMA", ["Actin-D", "Transgelin"])] # = 30_
|
| 208 |
+
gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
|
| 209 |
+
gmean = lambda m: np.stack([m[:, [gch.index(g) for g in gs]].mean(1) for _, gs in PANEL], 1)
|
| 210 |
+
mr_o = rg(np.load(os.path.join(CACHE, "gigatime", "orion_real_if_means.npy"))[:, [ORION_CH.index(o) for o, _ in PANEL]])
|
| 211 |
+
mv_o = rg(gmean(np.load(os.path.join(CACHE, "gigatime", "orion_means.npy"))))
|
| 212 |
+
mv_t = rg(gmean(np.load(os.path.join(CACHE, "gigatime", "tcga_text_means.npy"))))[ev]
|
| 213 |
+
HE8 = torch.from_numpy(np.load(os.path.join(CACHE, "he_uni2.npy")).astype(np.float32))
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def train_means_head(tr, va, epochs=150, bs=256, lr=1e-3, seed=0):
|
| 217 |
+
torch.manual_seed(seed); rng_ = np.random.default_rng(seed)
|
| 218 |
+
h = mlp(mr_o.shape[1])
|
| 219 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32))
|
| 220 |
+
opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=lr, weight_decay=0.05)
|
| 221 |
+
best, state = -1, None
|
| 222 |
+
for ep in range(epochs):
|
| 223 |
+
h.train()
|
| 224 |
+
perm = rng_.permutation(tr)
|
| 225 |
+
for s0 in range(0, len(perm) - bs + 1, bs):
|
| 226 |
+
b = torch.as_tensor(perm[s0:s0 + bs])
|
| 227 |
+
za = F.normalize(HE8[b, torch.randint(0, 8, (len(b),))], dim=-1)
|
| 228 |
+
lg = logit.exp().clamp(max=100) * za @ F.normalize(h(mr_o[b]), dim=-1).T
|
| 229 |
+
t = torch.arange(len(b))
|
| 230 |
+
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 231 |
+
opt.zero_grad(); loss.backward(); opt.step()
|
| 232 |
+
h.eval()
|
| 233 |
+
with torch.no_grad():
|
| 234 |
+
a, m = nz(HE8[va, 0]), nz(h(mr_o[va]))
|
| 235 |
+
sim = a @ m.T
|
| 236 |
+
v = float(((sim > sim.diag()[:, None]).sum(1) == 0).float().mean())
|
| 237 |
+
if v > best:
|
| 238 |
+
best, state = v, {k_: x.clone() for k_, x in h.state_dict().items()}
|
| 239 |
+
h.load_state_dict(state); h.eval()
|
| 240 |
+
return h
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
for k in range(5):
|
| 244 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 245 |
+
tr, va, te = (s.tile_id[s.split == x].values for x in ["train", "val", "test"])
|
| 246 |
+
h = train_means_head(tr, va)
|
| 247 |
+
with torch.no_grad():
|
| 248 |
+
zh, zr = nz(he_o[te]), nz(rna_heads[k](rna_o[te]))
|
| 249 |
+
zmr, zmv = nz(h(mr_o[te])), nz(h(mv_o[te]))
|
| 250 |
+
add("ORION", k, "H&E<->mIF real (10 means)", zh, zmr, "trained (real)", False)
|
| 251 |
+
add("ORION", k, "H&E<->mIF virtual (10 means)", zh, zmv, "virtual calibration", True)
|
| 252 |
+
add("ORION", k, "mIF real<->mIF virtual (10 means)", zmr, zmv, "virtual calibration", False)
|
| 253 |
+
add("ORION", k, "mIF real (10 means)<->RNA virtual", zmr, zr, "emergent, real x virtual", False)
|
| 254 |
+
add("ORION", k, "mIF virtual (10 means)<->RNA virtual", zmv, zr, "emergent, virtual x virtual", True)
|
| 255 |
+
if gt_o is not None:
|
| 256 |
+
zt = nz(text_head(gt_o[te]))
|
| 257 |
+
add("ORION", k, "text synthetic<->mIF real (10 means)", zt, zmr, "emergent, real x virtual", False)
|
| 258 |
+
add("ORION", k, "text synthetic<->mIF virtual (10 means)", zt, zmv, "emergent, virtual x virtual", True)
|
| 259 |
+
zmt = nz(h(mv_t))
|
| 260 |
+
add("TCGA", k, "H&E<->mIF virtual (10 means)", nz(he_t), zmt, "virtual (cross-dataset head)", True)
|
| 261 |
+
add("TCGA", k, "text<->mIF virtual (10 means)", nz(text_head(tx)), zmt, "emergent, real x virtual", False)
|
| 262 |
+
add("TCGA", k, "RNA virtual<->mIF virtual (10 means)", nz(rna_heads[k](rna_t)), zmt, "emergent, virtual x virtual", True)
|
| 263 |
+
print("means head fold", k, flush=True)
|
| 264 |
+
|
| 265 |
+
# ---------------- (E) calibration of the synthetic text: TCGA patches with REAL text AND a synthetic caption (27_ tcga_calib)
|
| 266 |
+
f_cm, f_cf = os.path.join(CACHE, "tcga_calib_meta.csv"), os.path.join(CACHE, "tcga_calib_text_biomedclip.npy")
|
| 267 |
+
if os.path.exists(f_cf):
|
| 268 |
+
CM = pd.read_csv(f_cm)
|
| 269 |
+
pos = pd.Series(range(len(ev)), index=ev)
|
| 270 |
+
assert CM.img_idx.isin(ev).all()
|
| 271 |
+
j = pos.loc[CM.img_idx].values # rows of the TCGA eval arrays
|
| 272 |
+
with torch.no_grad():
|
| 273 |
+
zs = nz(text_head(torch.from_numpy(np.load(f_cf)[:, 0].astype(np.float32))))
|
| 274 |
+
zr_txt, zh_c = nz(text_head(tx))[j], nz(he_t)[j]
|
| 275 |
+
add("TCGA calib", 0, "H&E<->text real", zh_c, zr_txt, "trained (real)", False)
|
| 276 |
+
add("TCGA calib", 0, "H&E<->text synthetic", zh_c, zs, "virtual calibration", True)
|
| 277 |
+
add("TCGA calib", 0, "text real<->text synthetic", zr_txt, zs, "virtual calibration", False)
|
| 278 |
+
for k in range(5):
|
| 279 |
+
zrna = nz(rna_heads[k](rna_t))[j]
|
| 280 |
+
add("TCGA calib", k, "text real<->RNA virtual", zr_txt, zrna, "emergent, real x virtual", False)
|
| 281 |
+
add("TCGA calib", k, "text synthetic<->RNA virtual", zs, zrna, "emergent, virtual x virtual", True)
|
| 282 |
+
print("calibration done", len(CM), flush=True)
|
| 283 |
+
|
| 284 |
+
R = pd.DataFrame(rows)
|
| 285 |
+
R.to_csv(os.path.join(OUT, "virtual_validation.csv"), index=False)
|
| 286 |
+
pd.set_option("display.width", 250); pd.set_option("display.max_colwidth", 60)
|
| 287 |
+
S = R.groupby(["dataset", "pair", "kind", "circular"])[["R@1_g100", "pair_acc_g100", "n"]].agg(["mean", "std"]).round(3)
|
| 288 |
+
print(S.drop(columns=[("n", "std")]).to_string())
|
experiments/32_gentext_prep.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic captions (27_azure_caption.py, GPT, 3-shot PathGen style) -> per-set meta for 08_extract_text.py.
|
| 2 |
+
VALIDATION ONLY: these texts are never used to train any head.
|
| 3 |
+
Output cache/orion_gentext_meta.csv (row i = ORION tile_id i of load_all_tiles(); `caption` column)
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from common import CACHE, load_all_tiles
|
| 11 |
+
|
| 12 |
+
C = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
|
| 13 |
+
C = C[(C.set == "orion") & C.caption.notna() & (C.caption.str.len() > 0)].drop_duplicates("path", keep="last")
|
| 14 |
+
cap = dict(zip(C.path.map(os.path.basename), C.caption))
|
| 15 |
+
df = load_all_tiles()
|
| 16 |
+
df["caption"] = df.image_path.map(lambda p: cap.get(os.path.basename(p)))
|
| 17 |
+
print(f"ORION tiles {len(df)}, with synthetic caption {df.caption.notna().sum()}")
|
| 18 |
+
assert df.caption.notna().all(), "missing captions -> rerun 27_azure_caption.py (resumable)"
|
| 19 |
+
df[["tile_id", "image_path", "caption"]].to_csv(os.path.join(CACHE, "orion_gentext_meta.csv"), index=False)
|
| 20 |
+
|
| 21 |
+
# TCGA calibration set (27_ --sets tcga_calib): synthetic captions of patches that also have REAL text
|
| 22 |
+
A = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
|
| 23 |
+
A = A[(A.set == "tcga_calib") & A.caption.notna()].drop_duplicates("path", keep="last")
|
| 24 |
+
if len(A):
|
| 25 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 26 |
+
shots = [s["key"][:12] for s in json.load(open(os.path.join(CACHE, "gen_text", "prompt.json")))["shots"]]
|
| 27 |
+
A["img_idx"] = A.path.map(dict(zip(I.image_path, I.index)))
|
| 28 |
+
A = A[A.img_idx.notna() & ~A.img_idx.map(lambda i: I.patient[int(i)] in shots)] # drop few-shot patients
|
| 29 |
+
A["img_idx"] = A.img_idx.astype(int)
|
| 30 |
+
A[["img_idx", "path", "caption"]].reset_index(drop=True).to_csv(os.path.join(CACHE, "tcga_calib_meta.csv"), index_label="idx")
|
| 31 |
+
print(f"TCGA calibration captions: {len(A)} (few-shot patients excluded: {shots})")
|
| 32 |
+
|
| 33 |
+
# external test sets (27_ --sets dlpfc idc): row i = row i of cache/external/<set>_meta.csv
|
| 34 |
+
for st in ("dlpfc", "idc"):
|
| 35 |
+
X = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
|
| 36 |
+
X = X[(X.set == st) & X.caption.notna() & (X.caption.str.len() > 0)].drop_duplicates("key", keep="last")
|
| 37 |
+
if not len(X):
|
| 38 |
+
continue
|
| 39 |
+
n = len(pd.read_csv(os.path.join(CACHE, "external", f"{st}_meta.csv")))
|
| 40 |
+
cap = dict(zip(X.path.str.split(":").str[2].astype(int), X.caption))
|
| 41 |
+
out = pd.DataFrame({"row": range(n), "caption": [cap.get(i) for i in range(n)]})
|
| 42 |
+
print(f"{st}: {n} spots, with synthetic caption {out.caption.notna().sum()}")
|
| 43 |
+
assert out.caption.notna().all(), "missing captions -> rerun 27_azure_caption.py --sets dlpfc idc (resumable)"
|
| 44 |
+
out.to_csv(os.path.join(CACHE, f"{st}_gentext_meta.csv"), index=False)
|
| 45 |
+
|
| 46 |
+
# TCGA images without real text after junk removal (40_clean_text.py; 27_ --sets tcga_notext). A caption made for
|
| 47 |
+
# the same image under another set (tcga_calib) is reused. Row i of the output = row i of notext_meta.csv.
|
| 48 |
+
N = pd.read_csv(os.path.join(CACHE, "notext_meta.csv"))
|
| 49 |
+
X = pd.DataFrame([json.loads(l) for l in open(os.path.join(CACHE, "gen_text", "captions.jsonl"))])
|
| 50 |
+
X = X[X.caption.notna() & (X.caption.str.len() > 0)].drop_duplicates("path", keep="last")
|
| 51 |
+
cap = dict(zip(X.path.map(os.path.abspath), X.caption))
|
| 52 |
+
N["caption"] = N.image_path.map(lambda p: cap.get(os.path.abspath(p)))
|
| 53 |
+
print(f"TCGA no-text images {len(N)}, with synthetic caption {N.caption.notna().sum()}")
|
| 54 |
+
assert N.caption.notna().all(), "missing captions -> rerun 27_azure_caption.py --sets tcga_notext"
|
| 55 |
+
N[["img_idx", "image_path", "patient", "caption"]].to_csv(os.path.join(CACHE, "tcga_notext_meta.csv"), index=False)
|
experiments/33_final_eval.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final protocol: every head is trained on REAL pairs only, with an explicit patient-level train / val / test split per
|
| 2 |
+
real dataset; virtual modalities (STPath RNA, GigaTIME mIF, GPT synthetic captions) are used ONLY at test time.
|
| 3 |
+
|
| 4 |
+
Real datasets and splits (5 folds each; fold k = train / val (early stopping only) / test; patients never cross splits)
|
| 5 |
+
ORION H&E <-> mIF : splits/patient_5fold/fold<k>.csv (5-fold by patient, val = 3 training patients)
|
| 6 |
+
+ splits/orion_official/fold0.csv (the user's ORION train/val/test csvs)
|
| 7 |
+
HEST H&E <-> RNA : GroupKFold(5) over study|patient groups, val = 10% of training groups (= 23_bind_gene.py)
|
| 8 |
+
TCGA H&E <-> text : GroupKFold(5) over patients on ALL image-text pairs, val = 10% of training patients (= 20_pairing.py)
|
| 9 |
+
The HEST and TCGA split memberships are written to splits/final/{hest,tcga}_fold<k>.csv.
|
| 10 |
+
|
| 11 |
+
Heads (frozen UNI2 H&E = anchor space):
|
| 12 |
+
mIF runs/bench_fm/<split>_fold<k>_uni2_fm-virtues[_shared]_.../heads.pt (17_bench_fm.py --save_heads)
|
| 13 |
+
RNA runs/gene/uni2_mlp_fold<k>/head.pt (23_bind_gene.py)
|
| 14 |
+
text runs/text_clean/uni2_biomedclip_fold<k>/head.pt trained here (text_head recipe of 20_pairing.py) on CLEAN real
|
| 15 |
+
texts only: junk texts (answer letters, "error", < 5 words; 40_clean_text.py) are removed from train/val/test.
|
| 16 |
+
The 256 images left without real text keep their split; their few-shot GPT captions (27_ --sets tcga_notext)
|
| 17 |
+
are evaluated only (pooled over the test folds, each image through its own test-fold head).
|
| 18 |
+
Previous heads trained with the junk texts: runs/text_all (results in results/before_text_clean/).
|
| 19 |
+
Evaluation is on the TEST split of fold k with the fold-k heads (a head from another dataset never saw that dataset).
|
| 20 |
+
part 1 "real" : both sides measured (H&E<->mIF, H&E<->RNA, H&E<->text)
|
| 21 |
+
part 2 "virtual" : >= 1 side virtual, same test items; "circular" = both sides derived from the same H&E
|
| 22 |
+
Metrics: random 100-item galleries (50 draws), both directions averaged: tie-aware R@1/R@5/R@10, MRR, and one-to-one
|
| 23 |
+
Hungarian pairing accuracy (chance R@1 = pair acc = 0.01, R@5 = 0.05, R@10 = 0.10).
|
| 24 |
+
mIF everywhere = the 10-channel panel shared by ORION and GigaTIME (30_virtues_shared.py); the full 17-channel ORION
|
| 25 |
+
panel appears only as a reference row. "[qm]" = GigaTIME pixels quantile-mapped per channel onto the real fold-k
|
| 26 |
+
training-tile intensity distribution before the identical preprocessing (30_ virtual_qm).
|
| 27 |
+
Outputs results/final_long.csv, results/final_summary.csv; split sizes -> results/final_splits.csv
|
| 28 |
+
"""
|
| 29 |
+
import os
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import pandas as pd
|
| 33 |
+
import torch
|
| 34 |
+
import torch.nn as nn
|
| 35 |
+
import torch.nn.functional as F
|
| 36 |
+
from scipy.optimize import linear_sum_assignment
|
| 37 |
+
from sklearn.model_selection import GroupKFold
|
| 38 |
+
|
| 39 |
+
from common import CACHE, ROOT, RUNS, SPLITS, load_all_tiles
|
| 40 |
+
|
| 41 |
+
OUT = os.path.join(ROOT, "results")
|
| 42 |
+
H = os.path.join(CACHE, "hest")
|
| 43 |
+
G100, DRAWS, K = 100, 50, 5
|
| 44 |
+
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 45 |
+
os.makedirs(os.path.join(SPLITS, "final"), exist_ok=True)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def mlp(din, dout=1536, hid=512): # same head as 12_/17_/20_/23_
|
| 49 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 50 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_mlp(sd):
|
| 54 |
+
h = mlp(sd["0.weight"].shape[1], sd[f"{len(mlp(1)) - 1}.weight"].shape[0]); h.load_state_dict(sd); return h.eval()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def nz(x):
|
| 58 |
+
return F.normalize(torch.as_tensor(np.asarray(x), dtype=torch.float32), dim=-1)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def g100(a, b, seed=0):
|
| 62 |
+
rng = np.random.default_rng(seed)
|
| 63 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 64 |
+
for _ in range(DRAWS):
|
| 65 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 66 |
+
sim = (a[s] @ b[s].T).numpy()
|
| 67 |
+
d = np.diag(sim)
|
| 68 |
+
for S in (sim, sim.T):
|
| 69 |
+
rank = (S > d[:, None]).sum(1) # tie-aware: 0 = correct item ranked first
|
| 70 |
+
for k_ in (1, 5, 10):
|
| 71 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 72 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 73 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 74 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 75 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
rows, split_rows = [], []
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def add(dataset, split, fold, part, pair, za, zb, kind, circular):
|
| 82 |
+
rows.append({"dataset": dataset, "split": split, "fold": fold, "part": part, "pair": pair, "kind": kind,
|
| 83 |
+
"circular": circular, "n_test": len(za), **g100(za, zb)})
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def log_split(dataset, split, fold, s, unit):
|
| 87 |
+
for x in ["train", "val", "test"]:
|
| 88 |
+
split_rows.append({"dataset": dataset, "split": split, "fold": fold, "role": x,
|
| 89 |
+
"n_items": int((s.split == x).sum()), f"n_{unit}": s[unit][s.split == x].nunique(),
|
| 90 |
+
"unit": unit})
|
| 91 |
+
g = {x: set(s[unit][s.split == x]) for x in ["train", "val", "test"]}
|
| 92 |
+
assert not (g["train"] & g["val"] or g["train"] & g["test"] or g["val"] & g["test"]), (dataset, fold, "leak")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ================= virtual RNA: STPath counts -> log1p CP10k on the 2000 HEST HVGs (input of the HEST-trained head)
|
| 96 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def virtual_rna(st):
|
| 100 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 101 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 102 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 103 |
+
return torch.from_numpy(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values])
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def mif_head(enc, k, split):
|
| 107 |
+
d = torch.load(os.path.join(RUNS, "bench_fm", f"{split}_fold{k}_uni2_fm-{enc}_fm-geneformer_pathgenclip", "heads.pt"),
|
| 108 |
+
map_location="cpu")
|
| 109 |
+
h = load_mlp(d["state"]["if"])
|
| 110 |
+
return lambda x: h((torch.as_tensor(x, dtype=torch.float32) - d["if_mean"]) / d["if_std"])
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
rna_heads = [load_mlp(torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "head.pt"), map_location="cpu")) for k in range(K)]
|
| 114 |
+
|
| 115 |
+
# ================= TCGA all image-text pairs: split + text heads (real text only)
|
| 116 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 117 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 118 |
+
JUNK = pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values
|
| 119 |
+
assert len(JUNK) == len(T)
|
| 120 |
+
IMG = torch.from_numpy(np.load(os.path.join(CACHE, "allimg_he_uni2.npy")).astype(np.float32)) # (n_img, 8, 1536)
|
| 121 |
+
TXT = torch.from_numpy(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[:, 0].astype(np.float32))
|
| 122 |
+
t_img = torch.as_tensor(T.img_idx.values) # indexed by the ORIGINAL text row (kept after filtering)
|
| 123 |
+
T = T[~JUNK]
|
| 124 |
+
NOTXT = pd.read_csv(os.path.join(CACHE, "tcga_notext_meta.csv"))
|
| 125 |
+
assert not NOTXT.img_idx.isin(T.img_idx).any()
|
| 126 |
+
TX_N = torch.from_numpy(np.load(os.path.join(CACHE, "tcga_notext_text_biomedclip.npy"))[:, 0].astype(np.float32))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def one_text_per_image(t_ids, rng):
|
| 130 |
+
sub = T.loc[t_ids]
|
| 131 |
+
pick = sub.groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 132 |
+
return pick.index.values, pick.values # img ids, text ids
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def train_text_head(tr_t, va_t, epochs=60, bs=256, lr=3e-4, seed=0):
|
| 136 |
+
"""20_pairing.py text_head: frozen UNI2 image = anchor, MLP on frozen BiomedCLIP text, multi-positive InfoNCE"""
|
| 137 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 138 |
+
X, Tx, ti = IMG.to(dev), TXT.to(dev), t_img.to(dev)
|
| 139 |
+
th = mlp(Tx.shape[1], X.shape[-1]).to(dev)
|
| 140 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 141 |
+
opt = torch.optim.AdamW(list(th.parameters()) + [logit], lr=lr, weight_decay=0.05)
|
| 142 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr_t) // bs), pct_start=0.05)
|
| 143 |
+
va_i, va_tt = one_text_per_image(va_t, np.random.default_rng(0))
|
| 144 |
+
za_va = F.normalize(X[torch.as_tensor(va_i, device=dev), 0], dim=-1)
|
| 145 |
+
best, state = -1, None
|
| 146 |
+
for ep in range(epochs):
|
| 147 |
+
th.train()
|
| 148 |
+
perm = rng.permutation(tr_t)
|
| 149 |
+
for s in range(0, len(perm) - bs + 1, bs):
|
| 150 |
+
b = torch.as_tensor(perm[s:s + bs], device=dev)
|
| 151 |
+
ii = ti[b]
|
| 152 |
+
zi = F.normalize(X[ii, torch.randint(0, 8, (len(b),), device=dev)], dim=-1)
|
| 153 |
+
lg = logit.exp().clamp(max=100) * zi @ F.normalize(th(Tx[b]), dim=-1).T
|
| 154 |
+
pos = (ii[:, None] == ii[None, :]).float()
|
| 155 |
+
tgt = pos / pos.sum(1, keepdim=True)
|
| 156 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 157 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 158 |
+
th.eval()
|
| 159 |
+
with torch.no_grad():
|
| 160 |
+
zt = F.normalize(th(Tx[torch.as_tensor(va_tt, device=dev)]), dim=-1)
|
| 161 |
+
sim = za_va @ zt.T
|
| 162 |
+
v = float((((sim > sim.diag()[:, None]).sum(1) == 0).float().mean() + ((sim.T > sim.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 163 |
+
if v > best:
|
| 164 |
+
best, state = v, {k_: x.detach().cpu().clone() for k_, x in th.state_dict().items()}
|
| 165 |
+
return state, best
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
tcga_folds = list(GroupKFold(K).split(I, groups=I.patient))
|
| 169 |
+
text_heads, tcga_test, tcga_notext_test = [], [], []
|
| 170 |
+
for k, (trv, te) in enumerate(tcga_folds):
|
| 171 |
+
pats = np.random.default_rng(k).permutation(sorted(I.patient.iloc[trv].unique()))
|
| 172 |
+
va_p = set(pats[: max(1, len(pats) // 10)])
|
| 173 |
+
sp = pd.Series("test", index=I.index)
|
| 174 |
+
sp.iloc[trv] = np.where(I.patient.iloc[trv].isin(va_p), "val", "train")
|
| 175 |
+
s = pd.DataFrame({"img_idx": I.index, "patient": I.patient, "split": sp.values})
|
| 176 |
+
s.to_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv"), index=False)
|
| 177 |
+
log_split("TCGA image-text", "patient 5-fold", k, s, "patient")
|
| 178 |
+
tsplit = sp.loc[T.img_idx].values
|
| 179 |
+
tr_t, va_t, te_t = (T.index[tsplit == x].values for x in ["train", "val", "test"])
|
| 180 |
+
f = os.path.join(RUNS, "text_clean", f"uni2_biomedclip_fold{k}", "head.pt")
|
| 181 |
+
if not os.path.exists(f):
|
| 182 |
+
state, best = train_text_head(tr_t, va_t)
|
| 183 |
+
os.makedirs(os.path.dirname(f), exist_ok=True); torch.save(state, f)
|
| 184 |
+
print(f"text head fold {k}: val R@1 {best:.3f} (train pairs {len(tr_t)}, val pairs {len(va_t)})", flush=True)
|
| 185 |
+
text_heads.append(load_mlp(torch.load(f, map_location="cpu")))
|
| 186 |
+
tcga_test.append(one_text_per_image(te_t, np.random.default_rng(k)))
|
| 187 |
+
tcga_notext_test.append(np.flatnonzero((sp.loc[NOTXT.img_idx] == "test").values)) # rows of NOTXT
|
| 188 |
+
|
| 189 |
+
# ================= (1) HEST: H&E <-> RNA real; virtual RNA (STPath) on the same test spots
|
| 190 |
+
M = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 191 |
+
hv = pd.read_csv(os.path.join(CACHE, "stpath", "hest_val_meta.csv"))
|
| 192 |
+
assert (hv["sample"].values == M["sample"].values).all() and (hv.spot.values == M.spot.values).all()
|
| 193 |
+
he_h = np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32)
|
| 194 |
+
real_h = torch.from_numpy(np.load(os.path.join(H, "hvg.npy")).astype(np.float32))
|
| 195 |
+
virt_h = virtual_rna("hest_val")
|
| 196 |
+
with torch.no_grad():
|
| 197 |
+
for k, (trv, te) in enumerate(GroupKFold(K).split(M, groups=M.group)): # identical folds to 23_bind_gene.py
|
| 198 |
+
grp = np.random.default_rng(k).permutation(sorted(M.group.iloc[trv].unique()))
|
| 199 |
+
va_g = set(grp[: max(1, len(grp) // 10)])
|
| 200 |
+
sp = np.array(["test"] * len(M), dtype=object)
|
| 201 |
+
sp[trv] = np.where(M.group.iloc[trv].isin(va_g), "val", "train")
|
| 202 |
+
s = pd.DataFrame({"spot_idx": M.index, "sample": M["sample"], "group": M.group, "split": sp})
|
| 203 |
+
s.to_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"), index=False)
|
| 204 |
+
log_split("HEST spot-RNA", "group 5-fold", k, s, "group")
|
| 205 |
+
te_saved = torch.load(os.path.join(RUNS, "gene", f"uni2_mlp_fold{k}", "test.pt"), weights_only=False)["idx"]
|
| 206 |
+
assert np.array_equal(np.sort(te_saved), np.sort(te)), "HEST fold mismatch with 23_bind_gene.py"
|
| 207 |
+
zr, zv, zh = nz(rna_heads[k](real_h[te])), nz(rna_heads[k](virt_h[te])), nz(he_h[te])
|
| 208 |
+
add("HEST", "group 5-fold", k, "real", "H&E<->RNA", zh, zr, "trained pair", False)
|
| 209 |
+
for sub, m in [("all test spots", np.ones(len(te), bool)), ("STPath-unseen test spots", hv.hest_bench_heldout.values[te])]:
|
| 210 |
+
if m.sum() >= G100:
|
| 211 |
+
if sub != "all test spots":
|
| 212 |
+
add(f"HEST ({sub})", "group 5-fold", k, "real", "H&E<->RNA", zh[m], zr[m], "trained pair", False)
|
| 213 |
+
add(f"HEST ({sub})" if sub != "all test spots" else "HEST", "group 5-fold", k, "virtual",
|
| 214 |
+
"H&E<->RNA(virtual)", zh[m], zv[m], "calibration: virtual replaces real", True)
|
| 215 |
+
add(f"HEST ({sub})" if sub != "all test spots" else "HEST", "group 5-fold", k, "virtual",
|
| 216 |
+
"RNA<->RNA(virtual)", zr[m], zv[m], "calibration: real vs virtual", False)
|
| 217 |
+
print("HEST done", flush=True)
|
| 218 |
+
|
| 219 |
+
# ================= (2) ORION: H&E <-> mIF real; virtual RNA / synthetic text / virtual mIF on the same test tiles
|
| 220 |
+
df = load_all_tiles()
|
| 221 |
+
assert (pd.read_csv(os.path.join(CACHE, "stpath", "orion_meta.csv")).key.values == df.image_path.values).all()
|
| 222 |
+
he_o = np.load(os.path.join(CACHE, "he_uni2.npy"))[:, 0].astype(np.float32)
|
| 223 |
+
rna_o = virtual_rna("orion")
|
| 224 |
+
if17 = np.load(os.path.join(CACHE, "if_virtues.npy"))[:, 0].astype(np.float32)
|
| 225 |
+
ifs_r = np.load(os.path.join(CACHE, "if_virtues_shared.npy"))[:, 0].astype(np.float32)
|
| 226 |
+
ifs_v = np.load(os.path.join(CACHE, "if_virtues_shared_virtual_orion.npy"))[:, 0].astype(np.float32)
|
| 227 |
+
gt_o = torch.from_numpy(np.load(os.path.join(CACHE, "orion_gentext_text_biomedclip.npy"))[:, 0].astype(np.float32))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def qm_file(st, k):
|
| 231 |
+
f = os.path.join(CACHE, f"if_virtues_shared_virtual_qm_{st}_fold{k}.npy")
|
| 232 |
+
return np.load(f)[:, 0].astype(np.float32) if os.path.exists(f) else None
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
qm_o = lambda k, _c={}: _c.setdefault(k, qm_file("orion", k))
|
| 236 |
+
# MIPHEI-ViT virtual mIF on the 16-channel ORION panel (30_ --m16). MIPHEI was trained on THIS ORION tile set with the
|
| 237 |
+
# official split -> clean only on the official TEST tiles; on patient 5-fold most test tiles were MIPHEI training tiles.
|
| 238 |
+
f_m16, f_mo = os.path.join(CACHE, "if_virtues_m16.npy"), os.path.join(CACHE, "if_virtues_m16_virtual_miphei_orion.npy")
|
| 239 |
+
m16_r = np.load(f_m16)[:, 0].astype(np.float32) if os.path.exists(f_m16) else None
|
| 240 |
+
m16_v = np.load(f_mo)[:, 0].astype(np.float32) if os.path.exists(f_mo) else None
|
| 241 |
+
with torch.no_grad():
|
| 242 |
+
for split, folds in [("patient 5-fold", range(K)), ("official", [0])]:
|
| 243 |
+
sdir = "patient_5fold" if split == "patient 5-fold" else "orion_official"
|
| 244 |
+
for k in folds:
|
| 245 |
+
s = pd.read_csv(os.path.join(SPLITS, sdir, f"fold{k}.csv"))
|
| 246 |
+
log_split("ORION H&E-mIF", split, k, s, "patient")
|
| 247 |
+
te = s.tile_id[s.split == "test"].values
|
| 248 |
+
h17, hsh = mif_head("virtues", k, sdir), mif_head("virtues_shared", k, sdir)
|
| 249 |
+
zh, zp, zs = nz(he_o[te]), nz(h17(if17[te])), nz(hsh(ifs_r[te]))
|
| 250 |
+
zr, zt = nz(rna_heads[k](rna_o[te])), nz(text_heads[k](gt_o[te])) # heads of other datasets: never saw ORION
|
| 251 |
+
A = lambda *a: add("ORION", split, k, *a)
|
| 252 |
+
# mIF = the UNIFIED 10-channel panel shared with GigaTIME everywhere; 17-ch kept only as a reference row
|
| 253 |
+
A("real", "H&E<->mIF (17-ch full ORION panel, reference)", zh, zp, "trained pair", False)
|
| 254 |
+
A("real", "H&E<->mIF", zh, zs, "trained pair", False)
|
| 255 |
+
A("virtual", "mIF<->RNA(virtual)", zs, zr, "emergent: real x virtual", False)
|
| 256 |
+
A("virtual", "mIF<->text(synthetic)", zs, zt, "emergent: real x virtual", False)
|
| 257 |
+
A("virtual", "H&E<->RNA(virtual)", zh, zr, "virtual from this H&E", True)
|
| 258 |
+
A("virtual", "H&E<->text(synthetic)", zh, zt, "virtual from this H&E", True)
|
| 259 |
+
A("virtual", "RNA(virtual)<->text(synthetic)", zr, zt, "emergent: virtual x virtual", True)
|
| 260 |
+
if m16_r is not None and os.path.exists(os.path.join(RUNS, "bench_fm", f"{sdir}_fold{k}_uni2_fm-virtues_m16_fm-geneformer_pathgenclip", "heads.pt")):
|
| 261 |
+
h16 = mif_head("virtues_m16", k, sdir)
|
| 262 |
+
z16 = nz(h16(m16_r[te]))
|
| 263 |
+
A("real", "H&E<->mIF (16-ch MIPHEI panel)", zh, z16, "trained pair", False)
|
| 264 |
+
A("virtual", "mIF(16ch)<->RNA(virtual)", z16, zr, "emergent: real x virtual", False)
|
| 265 |
+
A("virtual", "mIF(16ch)<->text(synthetic)", z16, zt, "emergent: real x virtual", False)
|
| 266 |
+
if m16_v is not None:
|
| 267 |
+
seen = split != "official" # 5-fold test tiles were (mostly) MIPHEI training tiles
|
| 268 |
+
tag = " [MIPHEI-seen tiles]" if seen else ""
|
| 269 |
+
zm = nz(h16(m16_v[te]))
|
| 270 |
+
A("virtual", f"H&E<->mIF(MIPHEI){tag}", zh, zm, "calibration: virtual replaces real", True)
|
| 271 |
+
A("virtual", f"mIF<->mIF(MIPHEI){tag}", z16, zm, "calibration: real vs virtual", False)
|
| 272 |
+
A("virtual", f"mIF(MIPHEI)<->RNA(virtual){tag}", zm, zr, "emergent: virtual x virtual", True)
|
| 273 |
+
A("virtual", f"mIF(MIPHEI)<->text(synthetic){tag}", zm, zt, "emergent: virtual x virtual", True)
|
| 274 |
+
for tag, f in [("", ifs_v[te]), (" [qm]", qm_o(k)[te] if split == "patient 5-fold" and qm_o(k) is not None else None)]:
|
| 275 |
+
if f is None:
|
| 276 |
+
continue # [qm] = virtual pixels quantile-mapped onto the fold-k TRAIN real distribution (30_ virtual_qm)
|
| 277 |
+
zv = nz(hsh(f))
|
| 278 |
+
A("virtual", f"H&E<->mIF(virtual){tag}", zh, zv, "calibration: virtual replaces real", True)
|
| 279 |
+
A("virtual", f"mIF<->mIF(virtual){tag}", zs, zv, "calibration: real vs virtual", False)
|
| 280 |
+
A("virtual", f"mIF(virtual){tag}<->RNA(virtual)", zv, zr, "emergent: virtual x virtual", True)
|
| 281 |
+
A("virtual", f"mIF(virtual){tag}<->text(synthetic)", zv, zt, "emergent: virtual x virtual", True)
|
| 282 |
+
print("ORION done", flush=True)
|
| 283 |
+
|
| 284 |
+
# ================= (3) TCGA: H&E <-> text real; virtual RNA / virtual mIF on the same test images
|
| 285 |
+
rna_t = virtual_rna("tcga_text")
|
| 286 |
+
ifv_t = np.load(os.path.join(CACHE, "if_virtues_shared_virtual_tcga_text.npy"))[:, 0].astype(np.float32)
|
| 287 |
+
CM = pd.read_csv(os.path.join(CACHE, "tcga_calib_meta.csv"))
|
| 288 |
+
syn_t = torch.from_numpy(np.load(os.path.join(CACHE, "tcga_calib_text_biomedclip.npy"))[:, 0].astype(np.float32))
|
| 289 |
+
with torch.no_grad():
|
| 290 |
+
for k, (ii, tt) in enumerate(tcga_test):
|
| 291 |
+
zh, zt = nz(IMG[ii, 0]), nz(text_heads[k](TXT[tt]))
|
| 292 |
+
zr = nz(rna_heads[k](rna_t[ii]))
|
| 293 |
+
A = lambda *a: add("TCGA", "patient 5-fold", k, *a)
|
| 294 |
+
A("real", "H&E<->text", zh, zt, "trained pair", False)
|
| 295 |
+
A("virtual", "text<->RNA(virtual)", zt, zr, "emergent: real x virtual", False)
|
| 296 |
+
A("virtual", "H&E<->RNA(virtual)", zh, zr, "virtual from this H&E", True)
|
| 297 |
+
f_mt = os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy")
|
| 298 |
+
if os.path.exists(f_mt) and os.path.exists(os.path.join(RUNS, "bench_fm", f"patient_5fold_fold{k}_uni2_fm-virtues_m16_fm-geneformer_pathgenclip", "heads.pt")):
|
| 299 |
+
zm = nz(mif_head("virtues_m16", k, "patient_5fold")(np.load(f_mt, mmap_mode="r")[ii, 0].astype(np.float32)))
|
| 300 |
+
A("virtual", "text<->mIF(MIPHEI)", zt, zm, "emergent: real x virtual", False)
|
| 301 |
+
A("virtual", "H&E<->mIF(MIPHEI)", zh, zm, "virtual from this H&E", True)
|
| 302 |
+
A("virtual", "RNA(virtual)<->mIF(MIPHEI)", zr, zm, "emergent: virtual x virtual", True)
|
| 303 |
+
hsh = mif_head("virtues_shared", k, "patient_5fold") # unified 10-ch panel head, trained on real ORION fold k
|
| 304 |
+
qt = qm_file("tcga_text", k)
|
| 305 |
+
for tag, f in [("", ifv_t), (" [qm]", qt)]:
|
| 306 |
+
if f is None:
|
| 307 |
+
continue
|
| 308 |
+
zv = nz(hsh(f[ii]))
|
| 309 |
+
A("virtual", f"text<->mIF(virtual){tag}", zt, zv, "emergent: real x virtual", False)
|
| 310 |
+
A("virtual", f"H&E<->mIF(virtual){tag}", zh, zv, "virtual from this H&E", True)
|
| 311 |
+
A("virtual", f"RNA(virtual)<->mIF(virtual){tag}", zr, zv, "emergent: virtual x virtual", True)
|
| 312 |
+
# synthetic-caption calibration: test images that have BOTH a real text and a GPT caption
|
| 313 |
+
pos = pd.Series(range(len(ii)), index=ii)
|
| 314 |
+
m = CM.img_idx.isin(ii).values
|
| 315 |
+
if m.sum() >= G100:
|
| 316 |
+
j = pos.loc[CM.img_idx[m]].values
|
| 317 |
+
zs = nz(text_heads[k](syn_t[m]))
|
| 318 |
+
B = lambda *a: add("TCGA (test images with GPT caption)", "patient 5-fold", k, *a)
|
| 319 |
+
B("real", "H&E<->text", zh[j], zt[j], "trained pair", False)
|
| 320 |
+
B("virtual", "H&E<->text(synthetic)", zh[j], zs, "calibration: virtual replaces real", True)
|
| 321 |
+
B("virtual", "text<->text(synthetic)", zt[j], zs, "calibration: real vs virtual", False)
|
| 322 |
+
B("virtual", "text<->RNA(virtual)", zt[j], zr[j], "emergent: real x virtual", False)
|
| 323 |
+
B("virtual", "text(synthetic)<->RNA(virtual)", zs, zr[j], "emergent: virtual x virtual", True)
|
| 324 |
+
# images without any real text (after junk removal): GPT few-shot caption, pooled over folds (each image in the test
|
| 325 |
+
# split of exactly one fold -> embedded with that fold's heads; all heads map into the same frozen UNI2 space)
|
| 326 |
+
f_mt = os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy")
|
| 327 |
+
shot_p = [x["key"][:12] for x in __import__("json").load(open(os.path.join(CACHE, "gen_text", "prompt.json")))["shots"]]
|
| 328 |
+
P = {x: [] for x in ["h", "s", "r", "m"]}
|
| 329 |
+
with torch.no_grad():
|
| 330 |
+
for k, r in enumerate(tcga_notext_test):
|
| 331 |
+
r = r[~NOTXT.patient.iloc[r].isin(shot_p).values] # drop few-shot patients
|
| 332 |
+
ii = NOTXT.img_idx.values[r]
|
| 333 |
+
P["h"].append(nz(IMG[ii, 0])); P["s"].append(nz(text_heads[k](TX_N[r]))); P["r"].append(nz(rna_heads[k](rna_t[ii])))
|
| 334 |
+
if os.path.exists(f_mt):
|
| 335 |
+
P["m"].append(nz(mif_head("virtues_m16", k, "patient_5fold")(np.load(f_mt, mmap_mode="r")[ii, 0].astype(np.float32))))
|
| 336 |
+
P = {x: torch.cat(v) for x, v in P.items() if v}
|
| 337 |
+
B = lambda *a: add("TCGA (test images WITHOUT real text, pooled folds)", "patient 5-fold", "pooled", *a)
|
| 338 |
+
B("virtual", "H&E<->text(synthetic)", P["h"], P["s"], "virtual from this H&E", True)
|
| 339 |
+
B("virtual", "text(synthetic)<->RNA(virtual)", P["s"], P["r"], "emergent: virtual x virtual", True)
|
| 340 |
+
if "m" in P:
|
| 341 |
+
B("virtual", "text(synthetic)<->mIF(MIPHEI)", P["s"], P["m"], "emergent: virtual x virtual", True)
|
| 342 |
+
print(f"TCGA no-text images evaluated: {len(P['h'])}")
|
| 343 |
+
print("TCGA done", flush=True)
|
| 344 |
+
|
| 345 |
+
R = pd.DataFrame(rows)
|
| 346 |
+
R.to_csv(os.path.join(OUT, "final_long.csv"), index=False)
|
| 347 |
+
SP = pd.DataFrame(split_rows)
|
| 348 |
+
SP.to_csv(os.path.join(OUT, "final_splits.csv"), index=False)
|
| 349 |
+
cols = ["R@1", "R@5", "R@10", "MRR", "pair_acc"]
|
| 350 |
+
S = R.groupby(["part", "dataset", "split", "pair", "kind", "circular"], sort=False)[cols + ["n_test"]].agg(["mean", "std"])
|
| 351 |
+
S.round(4).to_csv(os.path.join(OUT, "final_summary.csv"))
|
| 352 |
+
pd.set_option("display.width", 250); pd.set_option("display.max_colwidth", 50); pd.set_option("display.max_rows", 200)
|
| 353 |
+
print(SP.groupby(["dataset", "split", "role"])[["n_items", "n_patient", "n_group"]].mean().round(0).to_string())
|
| 354 |
+
for part in ["real", "virtual"]:
|
| 355 |
+
print(f"\n===== {part} (test split, mean over folds) =====")
|
| 356 |
+
print(R[R.part == part].groupby(["dataset", "split", "pair", "kind", "circular"], sort=False)[cols + ["n_test"]].mean().round(3).to_string())
|
experiments/34_gigatime_diag.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Why GigaTIME virtual mIF fails at instance-level pairing (ORION, 10 shared channels, same tiles as the real mIF).
|
| 2 |
+
|
| 3 |
+
(a) pooled vs WITHIN-slide Spearman of tile means: pairing in a 100-tile gallery needs tile-to-tile differences
|
| 4 |
+
inside a slide, while pooled correlation is dominated by slide/tissue-level differences
|
| 5 |
+
(b) direct real-mean <-> virtual-mean retrieval (no learned head): rank-gaussianised channel means, cosine, g100
|
| 6 |
+
(c) in-domain reference: ridge UNI2 H&E -> real means, trained on ORION patient_5fold train, scored on test
|
| 7 |
+
(what H&E can predict about these 10 channels when the model has seen ORION)
|
| 8 |
+
(d) input distribution: real mIF intensities vs GigaTIME sigmoid probabilities (what the VirTues head receives)
|
| 9 |
+
Output results/gigatime_diag.csv + printed tables.
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
from scipy.special import ndtri
|
| 17 |
+
from scipy.stats import rankdata, spearmanr
|
| 18 |
+
|
| 19 |
+
from common import CACHE, ROOT, SPLITS, load_all_tiles
|
| 20 |
+
|
| 21 |
+
OUT = os.path.join(ROOT, "results")
|
| 22 |
+
G = os.path.join(CACHE, "gigatime")
|
| 23 |
+
ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
|
| 24 |
+
"CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"]
|
| 25 |
+
PANEL = [("Hoechst", ["DAPI"]), ("CD68", ["CD68"]), ("CD4", ["CD4"]), ("CD8a", ["CD8"]), ("CD20", ["CD20"]),
|
| 26 |
+
("PD-L1", ["PD-L1"]), ("CD3e", ["CD3"]), ("Ki67", ["Ki67"]), ("Pan-CK", ["CK"]), ("SMA", ["Actin-D", "Transgelin"])]
|
| 27 |
+
gch = json.load(open(os.path.join(G, "channels.json")))["channels"]
|
| 28 |
+
df = load_all_tiles()
|
| 29 |
+
slide = df.in_slide_name.values
|
| 30 |
+
real = np.load(os.path.join(G, "orion_real_if_means.npy"))[:, [ORION_CH.index(o) for o, _ in PANEL]]
|
| 31 |
+
vm = np.load(os.path.join(G, "orion_means.npy"))
|
| 32 |
+
virt = np.stack([vm[:, [gch.index(g) for g in gs]].mean(1) for _, gs in PANEL], 1)
|
| 33 |
+
he = np.load(os.path.join(CACHE, "he_uni2.npy"))[:, 0].astype(np.float64)
|
| 34 |
+
names = [o for o, _ in PANEL]
|
| 35 |
+
rows = []
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def within(a, b):
|
| 39 |
+
"""mean within-slide Spearman (slides with >= 20 tiles), tiles-weighted"""
|
| 40 |
+
r, w = [], []
|
| 41 |
+
for s in np.unique(slide):
|
| 42 |
+
m = slide == s
|
| 43 |
+
if m.sum() >= 20 and a[m].std() > 0 and b[m].std() > 0:
|
| 44 |
+
r.append(spearmanr(a[m], b[m])[0]); w.append(m.sum())
|
| 45 |
+
return float(np.average(r, weights=w))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
rg = lambda x: ndtri((np.apply_along_axis(rankdata, 0, x) - 0.5) / len(x))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def g100(a, b, draws=50, seed=0, groups=None):
|
| 52 |
+
a = a / np.linalg.norm(a, axis=1, keepdims=True); b = b / np.linalg.norm(b, axis=1, keepdims=True)
|
| 53 |
+
rng = np.random.default_rng(seed); r = []
|
| 54 |
+
for _ in range(draws):
|
| 55 |
+
if groups is None:
|
| 56 |
+
s = rng.choice(len(a), min(100, len(a)), replace=False)
|
| 57 |
+
else: # gallery drawn from ONE slide: only within-slide differences can help
|
| 58 |
+
g = rng.choice(groups); s = np.flatnonzero(slide == g); s = rng.choice(s, min(100, len(s)), replace=False)
|
| 59 |
+
sim = a[s] @ b[s].T; d = np.diag(sim)[:, None]
|
| 60 |
+
r.append(((sim > d).sum(1) == 0).mean() / 2 + ((sim.T > d).sum(1) == 0).mean() / 2)
|
| 61 |
+
return float(np.mean(r))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---- (c) ridge UNI2 -> real means (in-domain reference), patient 5-fold test predictions
|
| 65 |
+
pred = np.zeros_like(real, dtype=np.float64)
|
| 66 |
+
R_rg = rg(real)
|
| 67 |
+
for k in range(5):
|
| 68 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 69 |
+
tr, va, te = (s.tile_id[s.split == x].values for x in ["train", "val", "test"])
|
| 70 |
+
mu, sd = he[tr].mean(0), he[tr].std(0) + 1e-6
|
| 71 |
+
X = lambda i: (he[i] - mu) / sd
|
| 72 |
+
best = None
|
| 73 |
+
for lam in [1e1, 1e2, 1e3, 1e4]: # lambda chosen on val
|
| 74 |
+
W = np.linalg.solve(X(tr).T @ X(tr) + lam * np.eye(he.shape[1]), X(tr).T @ R_rg[tr])
|
| 75 |
+
v = np.mean([spearmanr(X(va) @ W[:, c], R_rg[va, c])[0] for c in range(len(names))])
|
| 76 |
+
if best is None or v > best[0]:
|
| 77 |
+
best = (v, W)
|
| 78 |
+
pred[te] = X(te) @ best[1]
|
| 79 |
+
|
| 80 |
+
# ---- (a) correlations
|
| 81 |
+
for c, n in enumerate(names):
|
| 82 |
+
rows.append({"channel": n,
|
| 83 |
+
"GigaTIME pooled": spearmanr(virt[:, c], real[:, c])[0], "GigaTIME within-slide": within(virt[:, c], real[:, c]),
|
| 84 |
+
"UNI2-ridge pooled (test)": spearmanr(pred[:, c], real[:, c])[0], "UNI2-ridge within-slide (test)": within(pred[:, c], real[:, c])})
|
| 85 |
+
C = pd.DataFrame(rows).set_index("channel")
|
| 86 |
+
C.loc["mean"] = C.mean()
|
| 87 |
+
|
| 88 |
+
# ---- (b) direct retrieval, all tiles, galleries mixed across slides vs within one slide
|
| 89 |
+
big = [s for s in np.unique(slide) if (slide == s).sum() >= 100]
|
| 90 |
+
V_rg, P_rg = rg(virt), rg(pred)
|
| 91 |
+
Rt = pd.DataFrame([
|
| 92 |
+
{"pair": "real means <-> GigaTIME means", "mixed-slide gallery": g100(R_rg, V_rg), "single-slide gallery": g100(R_rg, V_rg, groups=big)},
|
| 93 |
+
{"pair": "real means <-> UNI2-ridge prediction", "mixed-slide gallery": g100(R_rg, P_rg), "single-slide gallery": g100(R_rg, P_rg, groups=big)},
|
| 94 |
+
{"pair": "real means <-> shuffled real (chance)", "mixed-slide gallery": g100(R_rg, R_rg[np.random.default_rng(0).permutation(len(R_rg))]), "single-slide gallery": np.nan},
|
| 95 |
+
]).set_index("pair")
|
| 96 |
+
|
| 97 |
+
# ---- (d) input distribution of the pixel maps (200 tiles)
|
| 98 |
+
maps = np.load(os.path.join(G, "orion_maps.npy"), mmap_mode="r")
|
| 99 |
+
idx = np.random.default_rng(0).choice(len(maps), 200, replace=False)
|
| 100 |
+
mv = np.asarray(maps[np.sort(idx)])[:, [gch.index(gs[0]) for _, gs in PANEL]].astype(np.float32) / 255
|
| 101 |
+
D = pd.DataFrame({"channel": names,
|
| 102 |
+
"virtual: frac px p<0.05": (mv < 0.05).mean((0, 2, 3)),
|
| 103 |
+
"virtual: frac px p>0.95": (mv > 0.95).mean((0, 2, 3)),
|
| 104 |
+
"virtual: frac px in (0.05,0.95)": ((mv >= 0.05) & (mv <= 0.95)).mean((0, 2, 3))}).set_index("channel")
|
| 105 |
+
|
| 106 |
+
pd.set_option("display.width", 200)
|
| 107 |
+
print("(a) Spearman of tile means vs real ORION mIF\n", C.round(3).to_string())
|
| 108 |
+
print("\n(b) R@1 in 100-tile galleries (chance 0.01), no learned head\n", Rt.round(3).to_string())
|
| 109 |
+
print("\n(d) GigaTIME pixel-value distribution (sigmoid probabilities)\n", D.round(3).to_string())
|
| 110 |
+
C.round(4).to_csv(os.path.join(OUT, "gigatime_diag.csv"))
|
| 111 |
+
Rt.round(4).to_csv(os.path.join(OUT, "gigatime_diag_retrieval.csv"))
|
| 112 |
+
D.round(4).to_csv(os.path.join(OUT, "gigatime_diag_pixels.csv"))
|
experiments/35_miphei_infer.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MIPHEI-ViT (Sanofi, arXiv 2505.10294) virtual mIF: H&E -> 16 ORION channels (all ORION markers except PD-1).
|
| 2 |
+
|
| 3 |
+
Model : ext/miphei/weights (GitHub release v1.0.0: decoder + LoRA, 26 MB; non-commercial academic licence) on top of
|
| 4 |
+
bioptimus/H-optimus-0 (HF, gated; HF token only via environment). model.py of the release (read in full) is
|
| 5 |
+
loaded from its path; nothing else from the downloaded repo is executed.
|
| 6 |
+
Training data of MIPHEI = this very ORION tile set with the ORION official split (37 train / 2 val / 2 test slides =
|
| 7 |
+
our splits/orion_official) -> MIPHEI virtual mIF is held-out ONLY on the official test (and val) tiles.
|
| 8 |
+
Input : RGB at 0.5 um/px (ORION tiles 333 px = 0.4997 um/px used as is; TCGA 672-px patches at ~0.25 um/px -> 336 px),
|
| 9 |
+
ImageNet mean/std (release config), reflect-padded to 512 px (input must be a power of 2), output cropped back.
|
| 10 |
+
Output: tanh in [-0.9, 0.9] -> (y + 0.9) / 1.8 * 255 = the 8-bit scale of the real ORION mIF tiles (MIPHEI targets).
|
| 11 |
+
cache/miphei/<set>_maps168.npy uint8 (N, 16, 168, 168) resampled exactly like the real tiles in 14_ (antialiased
|
| 12 |
+
bilinear to ~1 um/px) -> VirTues input
|
| 13 |
+
cache/miphei/<set>_means.npy float32 (N, 16) tile means at full resolution (0-255 scale)
|
| 14 |
+
cache/miphei/<set>_meta.csv, channels.json
|
| 15 |
+
Usage: HF_TOKEN=... .venv/bin/python 35_miphei_infer.py --set orion|tcga_text|dlpfc|idc [--bs 8]
|
| 16 |
+
"""
|
| 17 |
+
import argparse
|
| 18 |
+
import importlib.util
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import time
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
import torch
|
| 26 |
+
import torch.nn as nn
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
from PIL import Image, PngImagePlugin
|
| 29 |
+
from safetensors.torch import load_file
|
| 30 |
+
|
| 31 |
+
from common import CACHE, ORION, ROOT, load_all_tiles
|
| 32 |
+
|
| 33 |
+
p = argparse.ArgumentParser()
|
| 34 |
+
p.add_argument("--set", required=True)
|
| 35 |
+
p.add_argument("--bs", type=int, default=8)
|
| 36 |
+
args = p.parse_args()
|
| 37 |
+
dev = "cuda"
|
| 38 |
+
W = os.path.join(ROOT, "ext", "miphei", "weights")
|
| 39 |
+
OUT = os.path.join(CACHE, "miphei")
|
| 40 |
+
os.makedirs(OUT, exist_ok=True)
|
| 41 |
+
CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e", "CD163",
|
| 42 |
+
"E-cadherin", "Ki67", "Pan-CK", "SMA"] # release config targ_channel_names (order of the output heads)
|
| 43 |
+
MEAN = torch.tensor([123.675, 116.28, 103.53], device=dev).view(1, 3, 1, 1) / 255
|
| 44 |
+
STD = torch.tensor([58.395, 57.12, 57.375], device=dev).view(1, 3, 1, 1) / 255
|
| 45 |
+
PAD_TO, VSIZE = 512, 168
|
| 46 |
+
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 ** 2 # some TCGA PNGs carry large text metadata chunks
|
| 47 |
+
|
| 48 |
+
spec = importlib.util.spec_from_file_location("miphei_model", os.path.join(W, "model.py"))
|
| 49 |
+
Mm = importlib.util.module_from_spec(spec); spec.loader.exec_module(Mm)
|
| 50 |
+
vit = Mm.get_hoptimus0_hf("bioptimus/H-optimus-0")
|
| 51 |
+
vit.set_input_size(img_size=(PAD_TO, PAD_TO))
|
| 52 |
+
enc = Mm.Encoder(vit)
|
| 53 |
+
model = Mm.MIPHEIViT(enc, Mm.Detail_Capture(emb_chans=enc.embed_dim, out_chans=len(CH), use_attention=True, activation=nn.Tanh()))
|
| 54 |
+
sd = Mm.merge_lora_weights(model, load_file(os.path.join(W, "model.safetensors")))
|
| 55 |
+
Mm.validate_load_info(model.load_state_dict(sd, strict=False))
|
| 56 |
+
model = model.eval().to(dev)
|
| 57 |
+
|
| 58 |
+
if args.set == "orion":
|
| 59 |
+
df = load_all_tiles()
|
| 60 |
+
paths, keys, scale = [os.path.join(ORION, x) for x in df.image_path], df.image_path.values, 1.0
|
| 61 |
+
elif args.set == "tcga_text":
|
| 62 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 63 |
+
paths, keys, scale = list(I.image_path), I.key.values, 0.5
|
| 64 |
+
elif args.set in ("dlpfc", "idc"): # external sets (36_external_prep.py): uint8 RGB patches at 0.5 um/px, 224 px
|
| 65 |
+
E = os.path.join(CACHE, "external")
|
| 66 |
+
ARR = np.load(os.path.join(E, "dlpfc_he_05um.npy" if args.set == "dlpfc" else "idc_he.npy"), mmap_mode="r")
|
| 67 |
+
em = pd.read_csv(os.path.join(E, f"{args.set}_meta.csv"))
|
| 68 |
+
assert len(em) == len(ARR)
|
| 69 |
+
paths, keys, scale = list(range(len(ARR))), em.index.values, 1.0
|
| 70 |
+
else:
|
| 71 |
+
raise ValueError(args.set)
|
| 72 |
+
N = len(paths)
|
| 73 |
+
maps = np.lib.format.open_memmap(os.path.join(OUT, f"{args.set}_maps168.npy"), "w+", np.uint8, (N, len(CH), VSIZE, VSIZE))
|
| 74 |
+
means = np.zeros((N, len(CH)), np.float32)
|
| 75 |
+
sizes = []
|
| 76 |
+
t0 = time.time()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def load(pth):
|
| 80 |
+
if isinstance(pth, int):
|
| 81 |
+
return np.asarray(ARR[pth])
|
| 82 |
+
im = Image.open(pth).convert("RGB")
|
| 83 |
+
if scale != 1.0:
|
| 84 |
+
im = im.resize((round(im.width * scale), round(im.height * scale)), Image.BICUBIC)
|
| 85 |
+
return np.asarray(im)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
with torch.inference_mode():
|
| 89 |
+
for s in range(0, N, args.bs):
|
| 90 |
+
ims = [load(x) for x in paths[s:s + args.bs]]
|
| 91 |
+
h, w = ims[0].shape[:2]
|
| 92 |
+
assert all(i.shape[:2] == (h, w) for i in ims) and h <= PAD_TO and w <= PAD_TO
|
| 93 |
+
x = torch.from_numpy(np.stack(ims)).to(dev).permute(0, 3, 1, 2).float() / 255
|
| 94 |
+
x = (x - MEAN) / STD
|
| 95 |
+
pt, pl = (PAD_TO - h) // 2, (PAD_TO - w) // 2
|
| 96 |
+
x = F.pad(x, (pl, PAD_TO - w - pl, pt, PAD_TO - h - pt), mode="reflect")
|
| 97 |
+
with torch.autocast("cuda", dtype=torch.float16): # as in the release notebook
|
| 98 |
+
y = model(x)
|
| 99 |
+
y = y.float().clamp(-0.9, 0.9)[:, :, pt:pt + h, pl:pl + w]
|
| 100 |
+
y = (y + 0.9) / 1.8 * 255
|
| 101 |
+
means[s:s + len(ims)] = y.mean((2, 3)).cpu().numpy()
|
| 102 |
+
maps[s:s + len(ims)] = F.interpolate(y, size=(VSIZE, VSIZE), mode="bilinear", antialias=True,
|
| 103 |
+
align_corners=False).round().clamp(0, 255).byte().cpu().numpy()
|
| 104 |
+
sizes += [(h, w)] * len(ims)
|
| 105 |
+
if s % (args.bs * 100) == 0:
|
| 106 |
+
print(f" {s}/{N} [{time.time() - t0:.0f}s]", flush=True)
|
| 107 |
+
maps.flush()
|
| 108 |
+
np.save(os.path.join(OUT, f"{args.set}_means.npy"), means)
|
| 109 |
+
pd.DataFrame({"key": keys, "path": paths, "h": [a for a, _ in sizes], "w": [b for _, b in sizes]}).to_csv(
|
| 110 |
+
os.path.join(OUT, f"{args.set}_meta.csv"), index=False)
|
| 111 |
+
json.dump({"channels": CH}, open(os.path.join(OUT, "channels.json"), "w"))
|
| 112 |
+
print(f"done {args.set}: {N} tiles [{time.time() - t0:.0f}s]")
|
experiments/36_external_prep.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""External H&E<->RNA test sets: DLPFC (spatialLIBD Visium) and breast IDC (HEST Xenium pseudo-spots).
|
| 2 |
+
|
| 3 |
+
Run: .venv/bin/python -I 36_external_prep.py [dlpfc] [idc] (default: both)
|
| 4 |
+
Needs the public spatialLIBD files in ext/external_raw/dlpfc_spatialLIBD/ (downloaded once, data only):
|
| 5 |
+
<sid>_filtered_feature_bc_matrix.h5 https://spatial-dlpfc.s3.us-east-2.amazonaws.com/h5/
|
| 6 |
+
<sid>_tissue_positions_list.txt https://raw.githubusercontent.com/LieberInstitute/HumanPilot/master/10X/<sid>/
|
| 7 |
+
<sid>_scalefactors_json.json (same)
|
| 8 |
+
|
| 9 |
+
Findings this script relies on (verified, numbers printed again at run time):
|
| 10 |
+
DLPFC visium_gene_expression_list.pkl = log1p(count * 3034 / total_counts_all_33538_genes) (3034 = median
|
| 11 |
+
total over all 47,681 spots, i.e. scanpy normalize_total default) restricted to the top-10000
|
| 12 |
+
scanpy 'seurat'-flavour HVGs computed on all 12 samples concatenated, columns in 10x feature (genome) order.
|
| 13 |
+
Rows = h5 barcode order of each sample, samples concatenated in sorted order (manifest spot_idx).
|
| 14 |
+
Images = ImageNet-normalised uint8 RGB, native 224x224 full-res crop centred on the spot.
|
| 15 |
+
Full-res scale: Visium centre-to-centre pitch 100 um = 138.0 px -> 0.7246 um/px (HEST: 0.725);
|
| 16 |
+
note the 'pxl_row/col' columns of the HumanPilot tissue_positions_list are (x, y), not (row, col).
|
| 17 |
+
IDC rebuilt from HEST-1k (MahmoodLab/hest): st/<id>.h5ad + the selected 224-px, 0.5 um/px patches of
|
| 18 |
+
patches/<id>.h5 for TENX95, TENX99, NCBI783, NCBI785 (= HEST-Bench IDC), paired by barcode and checked by
|
| 19 |
+
patch-centre vs spot-centre distance. Spots: in tissue, with a patch, >= 20 panel counts; > 1500 -> random
|
| 20 |
+
1500 (seed 0). The earlier IDC built from ../IDC/UNI was unusable: subsample.py drew its image indices
|
| 21 |
+
independently of its adata indices (key '/k/__list__' vs '/k/__arr__', hash()-randomised seed).
|
| 22 |
+
First run fetches patches with HTTP range reads: needs HF_TOKEN in the environment.
|
| 23 |
+
Outputs cache/external/: see the OUT dict printed at the end.
|
| 24 |
+
"""
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import pickle
|
| 28 |
+
import sys
|
| 29 |
+
|
| 30 |
+
import h5py
|
| 31 |
+
import numpy as np
|
| 32 |
+
import pandas as pd
|
| 33 |
+
import scipy.sparse as sp
|
| 34 |
+
from PIL import Image
|
| 35 |
+
|
| 36 |
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 37 |
+
DATA = os.path.abspath(os.path.join(ROOT, ".."))
|
| 38 |
+
RAW = os.path.join(ROOT, "ext", "external_raw", "dlpfc_spatialLIBD")
|
| 39 |
+
OUT = os.path.join(ROOT, "cache", "external")
|
| 40 |
+
os.makedirs(OUT, exist_ok=True)
|
| 41 |
+
IMNET_MEAN = np.array([0.485, 0.456, 0.406], np.float32)
|
| 42 |
+
IMNET_STD = np.array([0.229, 0.224, 0.225], np.float32)
|
| 43 |
+
TARGET_UM = 0.5 # um/px of the resampled patches
|
| 44 |
+
HVG = pd.read_csv(os.path.join(ROOT, "cache", "hest", "hvg_genes.csv"))["gene"].astype(str).tolist()
|
| 45 |
+
HEST_META = pd.read_csv(os.path.join(DATA, "HEST", "HEST_v1_1_0.csv"))
|
| 46 |
+
HEST_TRAIN = set(pd.read_csv(os.path.join(ROOT, "cache", "hest", "meta.csv"), usecols=["sample"])["sample"])
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def load_pickle(p):
|
| 50 |
+
with open(p, "rb") as f:
|
| 51 |
+
return pickle.load(f)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def denorm(x):
|
| 55 |
+
"""(N,3,H,W) ImageNet-normalised float -> (N,H,W,3) uint8; returns max dequantisation error (in 0..255 units)."""
|
| 56 |
+
v = (np.asarray(x, np.float32).transpose(0, 2, 3, 1) * IMNET_STD + IMNET_MEAN) * 255.0
|
| 57 |
+
u = np.clip(np.round(v), 0, 255).astype(np.uint8)
|
| 58 |
+
return u, float(np.abs(v - u).max())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def resample(u8, um_native, fov_um=224 * TARGET_UM, out=224):
|
| 62 |
+
"""Centre crop fov_um (if available) and resize to out x out => TARGET_UM um/px."""
|
| 63 |
+
res = []
|
| 64 |
+
for im in u8:
|
| 65 |
+
side = fov_um / um_native
|
| 66 |
+
h = im.shape[0]
|
| 67 |
+
if side > h: # not enough context: keep full patch (FOV smaller than requested)
|
| 68 |
+
side = h
|
| 69 |
+
c = (h - side) / 2
|
| 70 |
+
res.append(np.asarray(Image.fromarray(im).resize((out, out), Image.BICUBIC, box=(c, c, c + side, c + side))))
|
| 71 |
+
return np.stack(res)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def hvg_overlap(genes):
|
| 75 |
+
g = set(genes)
|
| 76 |
+
return int(sum(x in g for x in HVG))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ================================================================== DLPFC
|
| 80 |
+
def read_h5(sid):
|
| 81 |
+
with h5py.File(os.path.join(RAW, f"{sid}_filtered_feature_bc_matrix.h5"), "r") as f:
|
| 82 |
+
g = f["matrix"]
|
| 83 |
+
M = sp.csc_matrix((g["data"][:], g["indices"][:], g["indptr"][:]), shape=g["shape"][:]).T.tocsr()
|
| 84 |
+
bcs = [b.decode() for b in g["barcodes"][:]]
|
| 85 |
+
names = [b.decode() for b in g["features"]["name"][:]]
|
| 86 |
+
ids = [b.decode() for b in g["features"]["id"][:]]
|
| 87 |
+
return M, bcs, names, ids
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def seurat_hvg(L, n_top):
|
| 91 |
+
"""scanpy.pp.highly_variable_genes(flavor='seurat', n_top_genes=n_top) on log1p data L (csr)."""
|
| 92 |
+
E = L.copy()
|
| 93 |
+
E.data = np.expm1(E.data)
|
| 94 |
+
n = E.shape[0]
|
| 95 |
+
mean = np.asarray(E.mean(0)).ravel()
|
| 96 |
+
var = (np.asarray(E.multiply(E).mean(0)).ravel() - mean ** 2) * n / (n - 1)
|
| 97 |
+
with np.errstate(all="ignore"):
|
| 98 |
+
disp = var / mean
|
| 99 |
+
disp[disp == 0] = np.nan
|
| 100 |
+
disp = np.log(disp)
|
| 101 |
+
df = pd.DataFrame({"m": np.log1p(mean), "d": disp})
|
| 102 |
+
df["bin"] = pd.cut(df.m, bins=20)
|
| 103 |
+
g = df.groupby("bin", observed=False)["d"]
|
| 104 |
+
bm, bs, one = g.transform("mean"), g.transform("std"), g.transform("count") == 1
|
| 105 |
+
bs, bm = bs.where(~one, bm), bm.where(~one, 0)
|
| 106 |
+
dn = ((df.d - bm) / bs).to_numpy()
|
| 107 |
+
v = np.sort(dn[~np.isnan(dn)])[::-1]
|
| 108 |
+
cut = v[n_top - 1]
|
| 109 |
+
print(f" HVG cut-off normalised dispersion {cut:.5f}; next {v[n_top]:.5f} (gap {cut - v[n_top]:.2e})")
|
| 110 |
+
return np.where(np.nan_to_num(dn) >= cut)[0]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def dlpfc():
|
| 114 |
+
print("== DLPFC")
|
| 115 |
+
P = os.path.join(DATA, "DLPFC_visium", "GPFM")
|
| 116 |
+
man = json.load(open(os.path.join(DATA, "_manifest", "dlpfc.json")))
|
| 117 |
+
samples = man["samples"]
|
| 118 |
+
X = load_pickle(os.path.join(P, "visium_gene_expression_list.pkl"))
|
| 119 |
+
X = np.asarray(X.numpy() if hasattr(X, "numpy") else X, np.float64)
|
| 120 |
+
gidx = np.asarray(pd.read_csv(os.path.join(DATA, "DLPFC_visium", "global_spot_index.csv"))["global_spot_idx"])
|
| 121 |
+
assert np.array_equal(gidx, man["global_spot_idx"]) and X.shape == (len(gidx), 10000)
|
| 122 |
+
|
| 123 |
+
Ms, rows, names, ids = [], [], None, None
|
| 124 |
+
sf_by, pos_by, bcs_by = {}, {}, {}
|
| 125 |
+
for sid in samples:
|
| 126 |
+
M, bcs, names, ids = read_h5(sid)
|
| 127 |
+
assert M.shape[0] == man["n_spots"][sid], sid
|
| 128 |
+
Ms.append(M)
|
| 129 |
+
bcs_by[sid] = bcs
|
| 130 |
+
sf_by[sid] = json.load(open(os.path.join(RAW, f"{sid}_scalefactors_json.json")))
|
| 131 |
+
pos = pd.read_csv(os.path.join(RAW, f"{sid}_tissue_positions_list.txt"), header=None,
|
| 132 |
+
names=["barcode", "in_tissue", "array_row", "array_col", "px_x", "px_y"]).set_index("barcode")
|
| 133 |
+
pos_by[sid] = pos
|
| 134 |
+
for i in man["spot_idx"][sid]:
|
| 135 |
+
rows.append((sid, i, bcs[i]))
|
| 136 |
+
M = sp.vstack(Ms).tocsr().astype(np.float64)
|
| 137 |
+
tot = np.asarray(M.sum(1)).ravel()
|
| 138 |
+
target = float(np.median(tot))
|
| 139 |
+
A = M[gidx] # raw counts of the 482 spots, all 33538 genes
|
| 140 |
+
print(f" all spots {M.shape}, median total counts {target}")
|
| 141 |
+
|
| 142 |
+
# 1) normalisation: x = log1p(count * target / total)
|
| 143 |
+
L = sp.diags(target / tot) @ M
|
| 144 |
+
L.data = np.log1p(L.data)
|
| 145 |
+
# 2) gene subset: reproduce scanpy seurat HVG (top 10000) on all 47,681 spots
|
| 146 |
+
sel = seurat_hvg(L, 10000)
|
| 147 |
+
assert len(sel) == 10000
|
| 148 |
+
Lsel = L[gidx][:, sel].toarray()
|
| 149 |
+
err = np.abs(Lsel - X).max()
|
| 150 |
+
# 3) independent check: exact per-column value match of recovered counts vs every h5 gene
|
| 151 |
+
Ad = A.toarray()
|
| 152 |
+
K = np.round(np.expm1(X) * (tot[gidx] / target)[:, None])
|
| 153 |
+
buckets = {}
|
| 154 |
+
for j in range(Ad.shape[1]):
|
| 155 |
+
buckets.setdefault(Ad[:, j].tobytes(), []).append(j)
|
| 156 |
+
cand = [buckets.get(K[:, c].tobytes(), []) for c in range(10000)]
|
| 157 |
+
n_unique = sum(len(c) == 1 for c in cand)
|
| 158 |
+
n_consistent = sum(sel[c] in cand[c] for c in range(10000))
|
| 159 |
+
n_unique_ok = sum(len(cand[c]) == 1 and cand[c][0] == sel[c] for c in range(10000))
|
| 160 |
+
print(f" max |log-expr(reconstructed) - pickle| = {err:.2e}")
|
| 161 |
+
print(f" exact-value column matches: {n_unique}/10000 columns match exactly one h5 gene "
|
| 162 |
+
f"({n_unique_ok} of them = HVG reconstruction); {n_consistent}/10000 HVG genes value-consistent; "
|
| 163 |
+
f"{int((K.sum(0) == 0).sum())} columns all-zero in the 482 spots")
|
| 164 |
+
assert err < 1e-5 and n_consistent == 10000
|
| 165 |
+
|
| 166 |
+
genes = pd.DataFrame({"col": np.arange(10000), "gene": np.array(names)[sel], "ensembl_id": np.array(ids)[sel],
|
| 167 |
+
"h5_feature_idx": sel,
|
| 168 |
+
"match": ["exact_unique" if len(c) == 1 else "exact_ambiguous_resolved_by_hvg" for c in cand]})
|
| 169 |
+
genes["duplicated_symbol"] = genes.gene.duplicated(keep=False)
|
| 170 |
+
genes.to_csv(os.path.join(OUT, "dlpfc_genes.csv"), index=False)
|
| 171 |
+
np.save(os.path.join(OUT, "dlpfc_counts.npy"), Ad[:, sel].astype(np.float32))
|
| 172 |
+
# full transcriptome version (same spots) from the public h5
|
| 173 |
+
np.save(os.path.join(OUT, "dlpfc_counts_full.npy"), Ad.astype(np.float32))
|
| 174 |
+
pd.DataFrame({"gene": names, "ensembl_id": ids, "in_10k": np.isin(np.arange(len(names)), sel)}).to_csv(
|
| 175 |
+
os.path.join(OUT, "dlpfc_genes_full.csv"), index=False)
|
| 176 |
+
|
| 177 |
+
# images
|
| 178 |
+
im = load_pickle(os.path.join(P, "visium_image_list.pkl"))
|
| 179 |
+
im = im.numpy() if hasattr(im, "numpy") else np.asarray(im)
|
| 180 |
+
u8, qerr = denorm(im)
|
| 181 |
+
print(f" images {im.shape}: ImageNet-normalised, dequantisation error {qerr:.1e} (exact uint8)")
|
| 182 |
+
np.save(os.path.join(OUT, "dlpfc_he.npy"), u8)
|
| 183 |
+
|
| 184 |
+
hest_ids = dict(zip(HEST_META.subseries.astype(str), HEST_META.id))
|
| 185 |
+
meta = []
|
| 186 |
+
for k, (sid, i, bc) in enumerate(rows):
|
| 187 |
+
p = pos_by[sid]
|
| 188 |
+
q = p.reset_index()
|
| 189 |
+
nb = q.merge(q, on="array_row")
|
| 190 |
+
nb = nb[nb.array_col_y == nb.array_col_x + 2]
|
| 191 |
+
pitch = float(np.median(np.hypot(nb.px_x_x - nb.px_x_y, nb.px_y_x - nb.px_y_y))) # 100 um
|
| 192 |
+
hid = hest_ids.get(sid)
|
| 193 |
+
meta.append(dict(sample=sid, spot_idx=i, global_spot_idx=int(gidx[k]), barcode=bc,
|
| 194 |
+
array_row=int(p.loc[bc, "array_row"]), array_col=int(p.loc[bc, "array_col"]),
|
| 195 |
+
x_fullres=float(p.loc[bc, "px_x"]), y_fullres=float(p.loc[bc, "px_y"]),
|
| 196 |
+
organ="Brain", tissue="dorsolateral prefrontal cortex",
|
| 197 |
+
um_per_px=100.0 / pitch, fov_um=224 * 100.0 / pitch,
|
| 198 |
+
spot_diameter_fullres=sf_by[sid]["spot_diameter_fullres"],
|
| 199 |
+
total_counts=float(tot[gidx[k]]), hest_id=hid, in_hest_train_cache=hid in HEST_TRAIN))
|
| 200 |
+
meta = pd.DataFrame(meta)
|
| 201 |
+
meta.to_csv(os.path.join(OUT, "dlpfc_meta.csv"), index=False)
|
| 202 |
+
um = float(meta.um_per_px.median())
|
| 203 |
+
u05 = resample(u8, um)
|
| 204 |
+
np.save(os.path.join(OUT, "dlpfc_he_05um.npy"), u05)
|
| 205 |
+
print(f" um/px {um:.4f} -> patch FOV {224 * um:.1f} um; dlpfc_he_05um = centre {112 / um:.1f}px crop -> 224 (112 um)")
|
| 206 |
+
print(f" HEST-HVG overlap: 10k cols {hvg_overlap(genes.gene)}/{len(HVG)}, full {hvg_overlap(names)}/{len(HVG)}")
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ================================================================== IDC (paired, from HEST-1k)
|
| 210 |
+
HEST_RAW = os.path.join(ROOT, "ext", "external_raw", "hest_idc")
|
| 211 |
+
IDC_IDS = ["TENX95", "TENX99", "NCBI783", "NCBI785"] # = HEST-Bench IDC
|
| 212 |
+
IDC_MIN_COUNTS = 20
|
| 213 |
+
IDC_MAX_SPOTS = 1500
|
| 214 |
+
CTRL = ("BLANK", "NegControl", "Unassigned", "antisense", "DeprecatedCodeword", "Intergenic")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def fetch_patches(sid, barcodes):
|
| 218 |
+
"""Fetch only the selected patches of MahmoodLab/hest patches/<sid>.h5 by HTTP range reads (the full files are
|
| 219 |
+
5.0 GB together; patches are stored uncompressed, one chunk per patch). Auth: HF_TOKEN env var, read by
|
| 220 |
+
huggingface_hub itself. Writes patches_subset/<sid>.h5 with img/barcode/coords + the source attrs."""
|
| 221 |
+
from huggingface_hub import HfFileSystem
|
| 222 |
+
out = os.path.join(HEST_RAW, "patches_subset", f"{sid}.h5")
|
| 223 |
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
| 224 |
+
fs = HfFileSystem()
|
| 225 |
+
src = f"datasets/MahmoodLab/hest/patches/{sid}.h5"
|
| 226 |
+
pb = all_patch_barcodes(sid) # cached barcode/coords table of the remote file (row order = h5 row order)
|
| 227 |
+
coords = pb[["x", "y"]].to_numpy()
|
| 228 |
+
pos = {b: i for i, b in enumerate(pb.barcode)}
|
| 229 |
+
rows = np.array([pos[b] for b in barcodes])
|
| 230 |
+
order = np.argsort(rows)
|
| 231 |
+
imgs = np.zeros((len(rows), 224, 224, 3), np.uint8)
|
| 232 |
+
with fs.open(src, "rb", block_size=160 * 1024, cache_type="readahead") as f, h5py.File(f, "r") as h:
|
| 233 |
+
attrs = dict(h["img"].attrs)
|
| 234 |
+
for n, k in enumerate(order):
|
| 235 |
+
imgs[k] = h["img"][rows[k]]
|
| 236 |
+
if n % 250 == 0:
|
| 237 |
+
print(f" {sid}: fetched {n}/{len(rows)} patches", flush=True)
|
| 238 |
+
with h5py.File(out, "w") as g:
|
| 239 |
+
g["img"] = imgs
|
| 240 |
+
g["barcode"] = np.array(barcodes, dtype="S")
|
| 241 |
+
g["coords"] = coords[rows]
|
| 242 |
+
g["src_row"] = rows
|
| 243 |
+
for k, v in attrs.items():
|
| 244 |
+
g["img"].attrs[k] = v
|
| 245 |
+
return out
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def all_patch_barcodes(sid):
|
| 249 |
+
"""Barcodes that have a patch in HEST (read remotely once, cached as a small csv)."""
|
| 250 |
+
p = os.path.join(HEST_RAW, "patches_subset", f"{sid}_all_barcodes.csv")
|
| 251 |
+
if not os.path.exists(p):
|
| 252 |
+
from huggingface_hub import HfFileSystem
|
| 253 |
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
| 254 |
+
with HfFileSystem().open(f"datasets/MahmoodLab/hest/patches/{sid}.h5", "rb", block_size=2 ** 22) as f, \
|
| 255 |
+
h5py.File(f, "r") as h:
|
| 256 |
+
bc = [b[0].decode() if isinstance(b[0], bytes) else str(b[0]) for b in h["barcode"][:]]
|
| 257 |
+
pd.DataFrame({"barcode": bc, "x": h["coords"][:, 0], "y": h["coords"][:, 1]}).to_csv(p, index=False)
|
| 258 |
+
return pd.read_csv(p)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def idc():
|
| 262 |
+
print("== IDC (HEST-1k, paired by barcode)")
|
| 263 |
+
import anndata as ad
|
| 264 |
+
ads = {s: ad.read_h5ad(os.path.join(HEST_RAW, "st", f"{s}.h5ad")) for s in IDC_IDS}
|
| 265 |
+
common = set.intersection(*[set(a.var_names) for a in ads.values()])
|
| 266 |
+
genes = sorted(g for g in common if not g.startswith(CTRL))
|
| 267 |
+
rng = np.random.default_rng(0)
|
| 268 |
+
he, counts, meta = [], [], []
|
| 269 |
+
for sid in IDC_IDS:
|
| 270 |
+
a = ads[sid]
|
| 271 |
+
md = json.load(open(os.path.join(HEST_RAW, "metadata", f"{sid}.json")))
|
| 272 |
+
X = a[:, genes].X
|
| 273 |
+
X = np.asarray(X.toarray() if sp.issparse(X) else X, np.float32)
|
| 274 |
+
tot = X.sum(1)
|
| 275 |
+
pb = all_patch_barcodes(sid)
|
| 276 |
+
in_t = a.obs["in_tissue"].astype(bool).values if "in_tissue" in a.obs else np.ones(a.n_obs, bool)
|
| 277 |
+
has_p = np.isin(a.obs_names, pb.barcode)
|
| 278 |
+
elig = np.where(in_t & has_p & (tot >= IDC_MIN_COUNTS))[0]
|
| 279 |
+
sub = len(elig) > IDC_MAX_SPOTS
|
| 280 |
+
sel = np.sort(rng.choice(elig, IDC_MAX_SPOTS, replace=False)) if sub else elig
|
| 281 |
+
print(f" {sid}: spots {a.n_obs}, in_tissue {in_t.sum()}, with patch {has_p.sum()}, "
|
| 282 |
+
f">= {IDC_MIN_COUNTS} counts {len(elig)} -> kept {len(sel)}{' (random subsample, seed 0)' if sub else ''}")
|
| 283 |
+
bcs = a.obs_names[sel].tolist()
|
| 284 |
+
p = os.path.join(HEST_RAW, "patches_subset", f"{sid}.h5")
|
| 285 |
+
if not os.path.exists(p):
|
| 286 |
+
fetch_patches(sid, bcs)
|
| 287 |
+
with h5py.File(p, "r") as g:
|
| 288 |
+
assert [b.decode() for b in g["barcode"][:]] == bcs, f"{sid}: cached patch subset does not match selection"
|
| 289 |
+
imgs, coords = g["img"][:], g["coords"][:]
|
| 290 |
+
src_px, ds, mpp = (float(g["img"].attrs[k]) for k in ("patch_size_src", "downsample", "pixel_size"))
|
| 291 |
+
# pairing check: patch centre (top-left coords + half the source patch size) vs spot centre
|
| 292 |
+
cx, cy = coords[:, 0] + src_px / 2, coords[:, 1] + src_px / 2
|
| 293 |
+
o = a.obs.iloc[sel]
|
| 294 |
+
dev = np.hypot(cx - o.pxl_col_in_fullres.values, cy - o.pxl_row_in_fullres.values) * mpp
|
| 295 |
+
um_out = mpp * ds
|
| 296 |
+
print(f" patch: {int(src_px)} src px @ {mpp:.4f} um/px -> 224 px @ {um_out:.4f} um/px ({224 * um_out:.0f} um FOV); "
|
| 297 |
+
f"|patch centre - spot centre| max {dev.max():.2f} um, median {np.median(dev):.2f} um")
|
| 298 |
+
assert dev.max() < 5, f"{sid}: patch/spot coordinate mismatch"
|
| 299 |
+
he.append(imgs)
|
| 300 |
+
counts.append(X[sel])
|
| 301 |
+
for j, i in enumerate(sel):
|
| 302 |
+
meta.append(dict(sample=sid, barcode=a.obs_names[i], array_row=int(a.obs.array_row.iloc[i]),
|
| 303 |
+
array_col=int(a.obs.array_col.iloc[i]),
|
| 304 |
+
x_fullres=float(a.obs.pxl_col_in_fullres.iloc[i]),
|
| 305 |
+
y_fullres=float(a.obs.pxl_row_in_fullres.iloc[i]),
|
| 306 |
+
organ="Breast", oncotree=md.get("oncotree_code"), um_per_px=um_out,
|
| 307 |
+
um_per_px_fullres=mpp, patch_center_dev_um=float(dev[j]), total_counts=float(tot[i]),
|
| 308 |
+
subsampled=bool(sub), n_eligible=len(elig),
|
| 309 |
+
in_hest_train_cache=sid in HEST_TRAIN))
|
| 310 |
+
he, counts = np.concatenate(he), np.concatenate(counts)
|
| 311 |
+
np.save(os.path.join(OUT, "idc_he.npy"), he)
|
| 312 |
+
np.save(os.path.join(OUT, "idc_counts.npy"), counts)
|
| 313 |
+
pd.DataFrame({"gene": genes}).to_csv(os.path.join(OUT, "idc_genes.csv"), index=False)
|
| 314 |
+
pd.DataFrame(meta).to_csv(os.path.join(OUT, "idc_meta.csv"), index=False)
|
| 315 |
+
for f in ("idc_he_unpaired.npy", "idc_he_unpaired_meta.csv"): # old, unpaired subsample images: removed
|
| 316 |
+
if os.path.exists(os.path.join(OUT, f)):
|
| 317 |
+
os.remove(os.path.join(OUT, f))
|
| 318 |
+
hest_common = pd.read_csv(os.path.join(ROOT, "cache", "hest", "genes.csv"))["gene"].astype(str)
|
| 319 |
+
print(f" genes {len(genes)} (common non-control panel genes); counts {counts.shape}; images {he.shape}")
|
| 320 |
+
print(f" IDC panel overlap: HEST HVG {hvg_overlap(genes)}/{len(HVG)}; "
|
| 321 |
+
f"HEST common genes {int(hest_common.isin(genes).sum())}/{len(hest_common)}")
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
if __name__ == "__main__":
|
| 325 |
+
todo = sys.argv[1:] or ["dlpfc", "idc"]
|
| 326 |
+
if "dlpfc" in todo:
|
| 327 |
+
dlpfc()
|
| 328 |
+
if "idc" in todo:
|
| 329 |
+
idc()
|
| 330 |
+
for f in sorted(os.listdir(OUT)):
|
| 331 |
+
p = os.path.join(OUT, f)
|
| 332 |
+
print(f" {f:28s}", np.load(p, mmap_mode="r").shape if f.endswith(".npy") else f"{len(pd.read_csv(p))} rows")
|
experiments/37_miphei_diag.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MIPHEI-ViT vs GigaTIME virtual mIF on ORION, against the real mIF of the same tiles (tile means).
|
| 2 |
+
|
| 3 |
+
MIPHEI was trained on the ORION official train split -> 'clean' = official TEST tiles (2 patients, never seen);
|
| 4 |
+
'seen' = official TRAIN tiles (training data of MIPHEI, optimistic). GigaTIME never saw ORION (clean everywhere).
|
| 5 |
+
(a) per-channel Spearman of tile means (pooled and within-slide)
|
| 6 |
+
(b) direct real-mean <-> virtual-mean retrieval (rank-gaussianised means, cosine; no learned head), 100-galleries
|
| 7 |
+
Output results/miphei_diag.csv, results/miphei_diag_retrieval.csv
|
| 8 |
+
"""
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from scipy.special import ndtri
|
| 15 |
+
from scipy.stats import rankdata, spearmanr
|
| 16 |
+
|
| 17 |
+
from common import CACHE, ROOT, SPLITS, load_all_tiles
|
| 18 |
+
|
| 19 |
+
OUT = os.path.join(ROOT, "results")
|
| 20 |
+
ORION_CH = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
|
| 21 |
+
"CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"] # order of orion_real_if_means.npy (25_)
|
| 22 |
+
GT = {"Hoechst": ["DAPI"], "CD68": ["CD68"], "CD4": ["CD4"], "CD8a": ["CD8"], "CD20": ["CD20"], "PD-L1": ["PD-L1"],
|
| 23 |
+
"CD3e": ["CD3"], "Ki67": ["Ki67"], "Pan-CK": ["CK"], "SMA": ["Actin-D", "Transgelin"]}
|
| 24 |
+
df = load_all_tiles()
|
| 25 |
+
slide = df.in_slide_name.values
|
| 26 |
+
real = np.load(os.path.join(CACHE, "gigatime", "orion_real_if_means.npy"))
|
| 27 |
+
mch = json.load(open(os.path.join(CACHE, "miphei", "channels.json")))["channels"]
|
| 28 |
+
mip = np.load(os.path.join(CACHE, "miphei", "orion_means.npy"))
|
| 29 |
+
gch = json.load(open(os.path.join(CACHE, "gigatime", "channels.json")))["channels"]
|
| 30 |
+
gm = np.load(os.path.join(CACHE, "gigatime", "orion_means.npy"))
|
| 31 |
+
off = pd.read_csv(os.path.join(SPLITS, "orion_official", "fold0.csv")).split.values
|
| 32 |
+
sets = {"clean (official test)": off == "test", "seen (official train)": off == "train"}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def within(a, b, m):
|
| 36 |
+
r, w = [], []
|
| 37 |
+
for s in np.unique(slide[m]):
|
| 38 |
+
i = m & (slide == s)
|
| 39 |
+
if i.sum() >= 15 and a[i].std() > 0 and b[i].std() > 0:
|
| 40 |
+
r.append(spearmanr(a[i], b[i])[0]); w.append(i.sum())
|
| 41 |
+
return float(np.average(r, weights=w)) if r else np.nan
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
rows = []
|
| 45 |
+
for ch in mch:
|
| 46 |
+
r = real[:, ORION_CH.index(ch)]
|
| 47 |
+
for sname, m in sets.items():
|
| 48 |
+
row = {"channel": ch, "set": sname,
|
| 49 |
+
"MIPHEI pooled": spearmanr(mip[m, mch.index(ch)], r[m])[0],
|
| 50 |
+
"MIPHEI within-slide": within(mip[:, mch.index(ch)], r, m)}
|
| 51 |
+
if ch in GT:
|
| 52 |
+
g = gm[:, [gch.index(x) for x in GT[ch]]].mean(1)
|
| 53 |
+
row.update({"GigaTIME pooled": spearmanr(g[m], r[m])[0], "GigaTIME within-slide": within(g, r, m)})
|
| 54 |
+
rows.append(row)
|
| 55 |
+
C = pd.DataFrame(rows)
|
| 56 |
+
rg = lambda x: ndtri((np.apply_along_axis(rankdata, 0, x) - 0.5) / len(x))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def g100(a, b, draws=50, seed=0):
|
| 60 |
+
a = a / np.linalg.norm(a, axis=1, keepdims=True); b = b / np.linalg.norm(b, axis=1, keepdims=True)
|
| 61 |
+
rng = np.random.default_rng(seed); r = []
|
| 62 |
+
for _ in range(draws):
|
| 63 |
+
s = rng.choice(len(a), min(100, len(a)), replace=False)
|
| 64 |
+
sim = a[s] @ b[s].T; d = np.diag(sim)[:, None]
|
| 65 |
+
r.append(((sim > d).sum(1) == 0).mean() / 2 + ((sim.T > d).sum(1) == 0).mean() / 2)
|
| 66 |
+
return float(np.mean(r))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
ten = list(GT)
|
| 70 |
+
Rt = []
|
| 71 |
+
for sname, m in sets.items():
|
| 72 |
+
R16 = real[m][:, [ORION_CH.index(c) for c in mch]]
|
| 73 |
+
R10 = real[m][:, [ORION_CH.index(c) for c in ten]]
|
| 74 |
+
G10 = np.stack([gm[m][:, [gch.index(x) for x in GT[c]]].mean(1) for c in ten], 1)
|
| 75 |
+
Rt += [{"set": sname, "n": int(m.sum()), "pair": "real <-> MIPHEI (16 ch)", "R@1_g100": g100(rg(R16), rg(mip[m]))},
|
| 76 |
+
{"set": sname, "n": int(m.sum()), "pair": "real <-> MIPHEI (10 shared ch)", "R@1_g100": g100(rg(R10), rg(mip[m][:, [mch.index(c) for c in ten]]))},
|
| 77 |
+
{"set": sname, "n": int(m.sum()), "pair": "real <-> GigaTIME (10 shared ch)", "R@1_g100": g100(rg(R10), rg(G10))}]
|
| 78 |
+
Rt = pd.DataFrame(Rt)
|
| 79 |
+
pd.set_option("display.width", 200)
|
| 80 |
+
print(C.round(3).to_string(index=False))
|
| 81 |
+
print("\nmean over channels\n", C.groupby("set")[[c for c in C.columns if c not in ("channel", "set")]].mean().round(3).to_string())
|
| 82 |
+
print("\nmean over the 10 GigaTIME-shared channels\n", C[C.channel.isin(ten)].groupby("set")[[c for c in C.columns if c not in ("channel", "set")]].mean().round(3).to_string())
|
| 83 |
+
print("\n", Rt.round(3).to_string(index=False))
|
| 84 |
+
C.round(4).to_csv(os.path.join(OUT, "miphei_diag.csv"), index=False)
|
| 85 |
+
Rt.round(4).to_csv(os.path.join(OUT, "miphei_diag_retrieval.csv"), index=False)
|
experiments/38_external_eval.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""External test sets (never used for training): DLPFC Visium (brain, 12 sections / 3 donors) and HEST IDC
|
| 2 |
+
(breast, Xenium pseudo-spots, 4 samples; 280-gene panel). Prepared by 36_external_prep.py -> cache/external/.
|
| 3 |
+
|
| 4 |
+
Real data only for training (HEST-1k, train/val by study|patient group, val = early stopping only):
|
| 5 |
+
dlpfc head : H&E(UNI2) <-> RNA MLP on the 2000 HEST HVGs, trained on HEST WITHOUT the spatialLIBD study
|
| 6 |
+
(= DLPFC; those spots are in cache/hest) -> DLPFC is fully held out
|
| 7 |
+
idc head : same recipe on the IDC panel genes present in the HEST common gene set (IDC samples are not in HEST cache)
|
| 8 |
+
RNA input : log1p(CP10k over the HEST common genes / over the panel genes), then z-scored per gene with the
|
| 9 |
+
statistics of its OWN dataset (HEST training spots vs the external set; label-free platform alignment)
|
| 10 |
+
5 heads per set (different val groups / seeds) -> mean +- std.
|
| 11 |
+
Virtual data only at test time: MIPHEI-ViT virtual mIF of the external H&E (35_ + 30_ --m16) through the 16-ch mIF
|
| 12 |
+
heads trained on REAL ORION (patient 5-fold, fold k).
|
| 13 |
+
Synthetic text only at test time: GPT captions of the external H&E (27_ --sets dlpfc idc, 32_, 08_ biomedclip)
|
| 14 |
+
through the TCGA text heads (runs/text_clean/uni2_biomedclip_fold<k>, trained on CLEAN real image-text pairs in 33_).
|
| 15 |
+
Pairs: H&E<->RNA (real, external), RNA<->mIF(MIPHEI) (emergent, real x virtual), H&E<->mIF(MIPHEI) (circular),
|
| 16 |
+
RNA<->text(synthetic) (emergent, real x virtual), H&E<->text(synthetic) (circular),
|
| 17 |
+
mIF(MIPHEI)<->text(synthetic) (virtual x virtual).
|
| 18 |
+
Metrics: 100-item galleries (50 draws, both directions): R@1/R@5/R@10/MRR + Hungarian pairing accuracy.
|
| 19 |
+
Usage: python 38_external_eval.py feats (UNI2 features of the external patches, GPU)
|
| 20 |
+
python 38_external_eval.py eval -> results/external_long.csv, external_summary.csv
|
| 21 |
+
"""
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import scipy.sparse as sp
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
import torch.nn.functional as F
|
| 31 |
+
from scipy.optimize import linear_sum_assignment
|
| 32 |
+
|
| 33 |
+
from common import CACHE, ROOT, RUNS
|
| 34 |
+
|
| 35 |
+
E = os.path.join(CACHE, "external")
|
| 36 |
+
H = os.path.join(CACHE, "hest")
|
| 37 |
+
OUT = os.path.join(ROOT, "results")
|
| 38 |
+
SETS = {"dlpfc": "dlpfc_he_05um.npy"} # IDC dropped from the report 2026-10-18 (user request)
|
| 39 |
+
MODE = sys.argv[1]
|
| 40 |
+
|
| 41 |
+
if MODE == "feats":
|
| 42 |
+
import timm
|
| 43 |
+
dev = "cuda"
|
| 44 |
+
model = timm.create_model( # = 04b_extract_anchor_fm.py uni2 (local official UNI2-h checkpoint)
|
| 45 |
+
"vit_giant_patch14_224", pretrained=False, img_size=224, patch_size=14, depth=24, num_heads=24,
|
| 46 |
+
init_values=1e-5, embed_dim=1536, mlp_ratio=2.66667 * 2, num_classes=0, no_embed_class=True,
|
| 47 |
+
mlp_layer=timm.layers.SwiGLUPacked, act_layer=torch.nn.SiLU, reg_tokens=8, dynamic_img_size=True)
|
| 48 |
+
model.load_state_dict(torch.load("/diskarray/home/tl688/reg2026/model_alldata/uni2-h.bin", map_location="cpu"), strict=True)
|
| 49 |
+
model = model.eval().to(dev)
|
| 50 |
+
mean = torch.tensor([0.485, 0.456, 0.406], device=dev).view(1, 3, 1, 1)
|
| 51 |
+
std = torch.tensor([0.229, 0.224, 0.225], device=dev).view(1, 3, 1, 1)
|
| 52 |
+
for st, f in SETS.items():
|
| 53 |
+
X = np.load(os.path.join(E, f), mmap_mode="r")
|
| 54 |
+
out = np.zeros((len(X), 1536), np.float32)
|
| 55 |
+
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
|
| 56 |
+
for s in range(0, len(X), 32):
|
| 57 |
+
x = torch.from_numpy(np.asarray(X[s:s + 32])).to(dev).permute(0, 3, 1, 2).float() / 255
|
| 58 |
+
if x.shape[-1] != 224:
|
| 59 |
+
x = F.interpolate(x, size=(224, 224), mode="bilinear", antialias=True, align_corners=False)
|
| 60 |
+
out[s:s + 32] = model((x - mean) / std).float().cpu().numpy()
|
| 61 |
+
np.save(os.path.join(E, f"{st}_he_uni2.npy"), out)
|
| 62 |
+
print(st, out.shape, flush=True)
|
| 63 |
+
# protocol check: DLPFC spots that are also in the HEST cache (same barcode) -> cosine to the HEST UNI2 feature
|
| 64 |
+
m, hm = pd.read_csv(os.path.join(E, "dlpfc_meta.csv")), pd.read_csv(os.path.join(H, "meta.csv"))
|
| 65 |
+
hf = np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32)
|
| 66 |
+
o = np.load(os.path.join(E, "dlpfc_he_uni2.npy"))
|
| 67 |
+
j = m.reset_index().merge(hm.reset_index(), left_on=["hest_id", "barcode"], right_on=["sample", "spot"], suffixes=("_e", "_h"))
|
| 68 |
+
if len(j):
|
| 69 |
+
a, b = F.normalize(torch.from_numpy(o[j.index_e.values]), dim=-1), F.normalize(torch.from_numpy(hf[j.index_h.values]), dim=-1)
|
| 70 |
+
rnd = F.normalize(torch.from_numpy(hf[np.random.default_rng(0).choice(len(hf), 200, replace=False)]), dim=-1)
|
| 71 |
+
print(f"protocol check: {len(j)} shared spots, cosine(ours, HEST cache) = {(a * b).sum(1).numpy().round(3)}; "
|
| 72 |
+
f"mean cosine to random HEST spots {float((a @ rnd.T).mean()):.3f}")
|
| 73 |
+
sys.exit(0)
|
| 74 |
+
|
| 75 |
+
# ======================================================================== eval
|
| 76 |
+
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 77 |
+
G100, DRAWS, K = 100, 50, 5
|
| 78 |
+
HM = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 79 |
+
hgenes = pd.read_csv(os.path.join(H, "genes.csv")).gene.values
|
| 80 |
+
hcounts = sp.load_npz(os.path.join(H, "counts.npz")).tocsr().astype(np.float32)
|
| 81 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 82 |
+
HE_H = torch.from_numpy(np.load(os.path.join(H, "he_uni2.npy"))[:, 0].astype(np.float32))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def mlp(din, dout=1536, hid=512):
|
| 86 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 87 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def lognorm(C, total):
|
| 91 |
+
return np.log1p(C / np.maximum(total, 1)[:, None] * 1e4).astype(np.float32)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def zscore(x, ref):
|
| 95 |
+
mu, sd = ref.mean(0), ref.std(0) + 1e-6
|
| 96 |
+
return (x - mu) / sd
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def r1(a, b):
|
| 100 |
+
s = a @ b.T
|
| 101 |
+
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def train_head(G, tr, va, seed, epochs=100, bs=256, lr=3e-4): # = 23_bind_gene.py recipe
|
| 105 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 106 |
+
A, Gd = HE_H.to(dev), torch.from_numpy(G).to(dev)
|
| 107 |
+
head = mlp(G.shape[1]).to(dev)
|
| 108 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 109 |
+
opt = torch.optim.AdamW(list(head.parameters()) + [logit], lr=lr, weight_decay=0.05)
|
| 110 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr) // bs), pct_start=0.05)
|
| 111 |
+
za_va = F.normalize(A[va], dim=-1)
|
| 112 |
+
best, state = -1, None
|
| 113 |
+
for ep in range(epochs):
|
| 114 |
+
head.train()
|
| 115 |
+
perm = rng.permutation(tr)
|
| 116 |
+
for s in range(0, len(perm) - bs + 1, bs):
|
| 117 |
+
b = torch.as_tensor(perm[s:s + bs], device=dev)
|
| 118 |
+
lg = logit.exp().clamp(max=100) * F.normalize(A[b], dim=-1) @ F.normalize(head(Gd[b]), dim=-1).T
|
| 119 |
+
t = torch.arange(len(b), device=dev)
|
| 120 |
+
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 121 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 122 |
+
head.eval()
|
| 123 |
+
with torch.no_grad():
|
| 124 |
+
zg = F.normalize(head(Gd[va]), dim=-1)
|
| 125 |
+
v = (r1(za_va, zg) + r1(zg, za_va)) / 2
|
| 126 |
+
if v > best:
|
| 127 |
+
best, state = v, {k_: x.detach().cpu().clone() for k_, x in head.state_dict().items()}
|
| 128 |
+
head = head.cpu(); head.load_state_dict(state); head.eval()
|
| 129 |
+
return head, best
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def g100(a, b, seed=0):
|
| 133 |
+
rng = np.random.default_rng(seed)
|
| 134 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 135 |
+
for _ in range(DRAWS):
|
| 136 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 137 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 138 |
+
for S in (sim, sim.T):
|
| 139 |
+
rank = (S > d[:, None]).sum(1)
|
| 140 |
+
for k_ in (1, 5, 10):
|
| 141 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 142 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 143 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 144 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 145 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
nz = lambda x: F.normalize(torch.as_tensor(np.asarray(x), dtype=torch.float32), dim=-1)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def mif_head(k):
|
| 152 |
+
d = torch.load(os.path.join(RUNS, "bench_fm", f"patient_5fold_fold{k}_uni2_fm-virtues_m16_fm-geneformer_pathgenclip", "heads.pt"), map_location="cpu")
|
| 153 |
+
h = mlp(d["state"]["if"]["0.weight"].shape[1]); h.load_state_dict(d["state"]["if"]); h.eval()
|
| 154 |
+
return lambda x: h((torch.as_tensor(x, dtype=torch.float32) - d["if_mean"]) / d["if_std"])
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def text_head(k):
|
| 158 |
+
sd = torch.load(os.path.join(RUNS, "text_clean", f"uni2_biomedclip_fold{k}", "head.pt"), map_location="cpu")
|
| 159 |
+
h = mlp(sd["0.weight"].shape[1], sd["6.weight"].shape[0]); h.load_state_dict(sd); h.eval()
|
| 160 |
+
return lambda x: h(torch.as_tensor(x, dtype=torch.float32))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
rows = []
|
| 164 |
+
hset = pd.Series(range(len(hgenes)), index=hgenes)
|
| 165 |
+
htot = np.asarray(hcounts.sum(1)).ravel()
|
| 166 |
+
for st in SETS:
|
| 167 |
+
em = pd.read_csv(os.path.join(E, f"{st}_meta.csv"))
|
| 168 |
+
he = np.load(os.path.join(E, f"{st}_he_uni2.npy"))
|
| 169 |
+
if st == "dlpfc":
|
| 170 |
+
eg = pd.read_csv(os.path.join(E, "dlpfc_genes_full.csv")).gene.values
|
| 171 |
+
ec = np.load(os.path.join(E, "dlpfc_counts_full.npy")).astype(np.float32)
|
| 172 |
+
eidx = pd.Series(range(len(eg)), index=eg)
|
| 173 |
+
eidx = eidx[~eidx.index.duplicated()]
|
| 174 |
+
common = [g for g in hgenes if g in eidx.index]
|
| 175 |
+
genes = [g for g in hvg if g in eidx.index]
|
| 176 |
+
etot = ec[:, eidx.loc[common].values].sum(1) # library size over the HEST common genes
|
| 177 |
+
excl = HM.group.str.startswith("spatialLIBD").values # DLPFC study removed from training
|
| 178 |
+
htot_ = htot
|
| 179 |
+
else:
|
| 180 |
+
eg = pd.read_csv(os.path.join(E, "idc_genes.csv")).gene.values
|
| 181 |
+
ec = np.load(os.path.join(E, "idc_counts.npy")).astype(np.float32)
|
| 182 |
+
eidx = pd.Series(range(len(eg)), index=eg)
|
| 183 |
+
eidx = eidx[~eidx.index.duplicated()]
|
| 184 |
+
genes = [g for g in eg if g in hset.index]
|
| 185 |
+
etot = ec[:, eidx.loc[genes].values].sum(1) # library size over the panel genes
|
| 186 |
+
htot_ = np.asarray(hcounts[:, hset.loc[genes].values].sum(1)).ravel()
|
| 187 |
+
excl = HM["sample"].isin(em["sample"].unique()).values
|
| 188 |
+
Gh = lognorm(hcounts[:, hset.loc[genes].values].toarray(), htot_)
|
| 189 |
+
Ge = lognorm(ec[:, eidx.loc[genes].values], etot)
|
| 190 |
+
pool = np.flatnonzero(~excl & (htot_ > 0))
|
| 191 |
+
print(f"{st}: {len(em)} external spots, {len(genes)} genes, HEST training pool {len(pool)} spots "
|
| 192 |
+
f"({int(excl.sum())} excluded)", flush=True)
|
| 193 |
+
f_mv = os.path.join(CACHE, f"if_virtues_m16_virtual_miphei_{st}.npy")
|
| 194 |
+
mv = np.load(f_mv)[:, 0].astype(np.float32) if os.path.exists(f_mv) else None
|
| 195 |
+
f_t = os.path.join(CACHE, f"{st}_gentext_text_biomedclip.npy")
|
| 196 |
+
tx = np.load(f_t)[:, 0].astype(np.float32) if os.path.exists(f_t) else None
|
| 197 |
+
zh = nz(he)
|
| 198 |
+
for k in range(K):
|
| 199 |
+
grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
|
| 200 |
+
va_g = set(grp[: max(1, len(grp) // 10)])
|
| 201 |
+
isva = HM.group.iloc[pool].isin(va_g).values
|
| 202 |
+
tr, va = pool[~isva], pool[isva]
|
| 203 |
+
for norm in ["z-scored per dataset", "plain"]:
|
| 204 |
+
Gtr = zscore(Gh, Gh[tr]) if norm != "plain" else Gh
|
| 205 |
+
Gex = zscore(Ge, Ge) if norm != "plain" else Ge
|
| 206 |
+
head, best = train_head(Gtr, tr, va, seed=k)
|
| 207 |
+
with torch.no_grad():
|
| 208 |
+
zg = nz(head(torch.from_numpy(Gex)))
|
| 209 |
+
rows.append({"set": st, "fold": k, "rna_input": norm, "part": "real", "pair": "H&E<->RNA",
|
| 210 |
+
"kind": "trained pair (external)", "circular": False, "n": len(zh), "val_R@1": best, **g100(zh, zg)})
|
| 211 |
+
if mv is not None and norm == "z-scored per dataset":
|
| 212 |
+
with torch.no_grad():
|
| 213 |
+
zm = nz(mif_head(k)(mv))
|
| 214 |
+
for pair, a, b, kind, circ in [("RNA<->mIF(MIPHEI)", zg, zm, "emergent: real x virtual", False),
|
| 215 |
+
("H&E<->mIF(MIPHEI)", zh, zm, "virtual from this H&E", True)]:
|
| 216 |
+
rows.append({"set": st, "fold": k, "rna_input": norm, "part": "virtual", "pair": pair, "kind": kind,
|
| 217 |
+
"circular": circ, "n": len(zh), **g100(a, b)})
|
| 218 |
+
if tx is not None and norm == "z-scored per dataset":
|
| 219 |
+
with torch.no_grad():
|
| 220 |
+
zt = nz(text_head(k)(tx))
|
| 221 |
+
zm = nz(mif_head(k)(mv)) if mv is not None else None
|
| 222 |
+
for pair, a, b, kind, circ in [("RNA<->text(synthetic)", zg, zt, "emergent: real x virtual", False),
|
| 223 |
+
("H&E<->text(synthetic)", zh, zt, "virtual from this H&E", True),
|
| 224 |
+
("mIF(MIPHEI)<->text(synthetic)", zm, zt, "emergent: virtual x virtual", True)]:
|
| 225 |
+
if a is not None:
|
| 226 |
+
rows.append({"set": st, "fold": k, "rna_input": norm, "part": "virtual", "pair": pair, "kind": kind,
|
| 227 |
+
"circular": circ, "n": len(zh), **g100(a, b)})
|
| 228 |
+
if norm == "z-scored per dataset": # embeddings for 39_external_check.py
|
| 229 |
+
np.savez(os.path.join(E, f"emb_{st}_fold{k}.npz"), he=zh.numpy(), rna=zg.numpy(),
|
| 230 |
+
**({"mif": zm.numpy()} if mv is not None else {}), **({"text": zt.numpy()} if tx is not None else {}))
|
| 231 |
+
print(f" {st} head {k}: val R@1 {best:.3f}", flush=True)
|
| 232 |
+
|
| 233 |
+
R = pd.DataFrame(rows)
|
| 234 |
+
R.to_csv(os.path.join(OUT, "external_long.csv"), index=False)
|
| 235 |
+
cols = ["R@1", "R@5", "R@10", "MRR", "pair_acc"]
|
| 236 |
+
S = R.groupby(["set", "part", "pair", "rna_input", "kind", "circular"], sort=False)[cols + ["n"]].agg(["mean", "std"])
|
| 237 |
+
S.round(4).to_csv(os.path.join(OUT, "external_summary.csv"))
|
| 238 |
+
pd.set_option("display.width", 250)
|
| 239 |
+
print(R.groupby(["set", "part", "pair", "rna_input", "circular"], sort=False)[cols + ["n"]].mean().round(3).to_string())
|
experiments/39_external_check.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sanity checks of the external results (38_external_eval.py; embeddings cache/external/emb_<set>_fold<k>.npz).
|
| 2 |
+
|
| 3 |
+
Galleries in 38_ mix spots of several samples (IDC 4 samples, DLPFC 12 sections). A model that only recognises the
|
| 4 |
+
SAMPLE (batch / stain / platform effects) and is random within it already beats chance there. Per pair and set:
|
| 5 |
+
mixed G : the 38_ metric (G = 100 random spots from all samples), both directions
|
| 6 |
+
sample oracle : R@1 of a model that knows the sample exactly and is random within it = mean 1/n_same_sample
|
| 7 |
+
top-1 same samp : fraction of top-1 hits from the query's own sample (base rate = share of same-sample items)
|
| 8 |
+
within-sample G : galleries drawn from ONE sample only (IDC G = 100; DLPFC G = 35 since sections have 35-48 spots,
|
| 9 |
+
with the mixed metric at G = 35 for comparison) -> spot-level signal beyond sample identity
|
| 10 |
+
shuffled null : the mixed metric after permuting one side (checks the metric code; ~1/G)
|
| 11 |
+
Output results/external_check.csv
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
from common import CACHE, ROOT
|
| 19 |
+
|
| 20 |
+
E = os.path.join(CACHE, "external")
|
| 21 |
+
PAIRS = [("he", "rna"), ("rna", "mif"), ("he", "mif"), ("rna", "text"), ("he", "text"), ("mif", "text")]
|
| 22 |
+
NAME = {"he": "H&E", "rna": "RNA", "mif": "mIF(MIPHEI)", "text": "text(synthetic)"}
|
| 23 |
+
DRAWS = 200
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def r1(a, b, s):
|
| 27 |
+
sim = a[s] @ b[s].T; d = np.diag(sim)[:, None]
|
| 28 |
+
return ((sim > d).sum(1) == 0).mean() / 2 + ((sim > d.T).sum(0) == 0).mean() / 2, sim
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def run(a, b, smp, G, within, rng):
|
| 32 |
+
out = {"R@1": [], "oracle": [], "top1_same": [], "base_same": []}
|
| 33 |
+
uniq = [u for u in np.unique(smp) if (smp == u).sum() >= G]
|
| 34 |
+
for _ in range(DRAWS):
|
| 35 |
+
if within:
|
| 36 |
+
idx = np.flatnonzero(smp == uniq[rng.integers(len(uniq))])
|
| 37 |
+
s = rng.choice(idx, G, replace=False)
|
| 38 |
+
else:
|
| 39 |
+
s = rng.choice(len(a), G, replace=False)
|
| 40 |
+
v, sim = r1(a, b, s)
|
| 41 |
+
g = smp[s]; same = g[:, None] == g[None, :]
|
| 42 |
+
out["R@1"].append(v)
|
| 43 |
+
out["oracle"].append((1 / same.sum(1)).mean())
|
| 44 |
+
out["top1_same"].append((same[np.arange(G), sim.argmax(1)].mean() + same[sim.argmax(0), np.arange(G)].mean()) / 2)
|
| 45 |
+
out["base_same"].append(same.mean())
|
| 46 |
+
return {k: float(np.mean(v)) for k, v in out.items()}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
rows = []
|
| 50 |
+
for st, Gw in [("dlpfc", 35)]: # IDC dropped from the report 2026-10-18
|
| 51 |
+
smp = pd.read_csv(os.path.join(E, f"{st}_meta.csv"))["sample"].astype(str).values
|
| 52 |
+
for k in range(5):
|
| 53 |
+
Z = np.load(os.path.join(E, f"emb_{st}_fold{k}.npz"))
|
| 54 |
+
for x, y in PAIRS:
|
| 55 |
+
if x not in Z or y not in Z:
|
| 56 |
+
continue
|
| 57 |
+
a, b = Z[x], Z[y]
|
| 58 |
+
rng = np.random.default_rng(k)
|
| 59 |
+
base = {"set": st, "fold": k, "pair": f"{NAME[x]}<->{NAME[y]}"}
|
| 60 |
+
m = run(a, b, smp, 100, False, rng)
|
| 61 |
+
rows.append({**base, "gallery": "mixed G=100", "chance": 0.01, **m})
|
| 62 |
+
rows.append({**base, "gallery": "shuffled null G=100", "chance": 0.01,
|
| 63 |
+
**run(a, b[rng.permutation(len(b))], smp, 100, False, rng)})
|
| 64 |
+
if Gw != 100:
|
| 65 |
+
rows.append({**base, "gallery": f"mixed G={Gw}", "chance": 1 / Gw, **run(a, b, smp, Gw, False, rng)})
|
| 66 |
+
rows.append({**base, "gallery": f"within-sample G={Gw}", "chance": 1 / Gw, **run(a, b, smp, Gw, True, rng)})
|
| 67 |
+
|
| 68 |
+
R = pd.DataFrame(rows)
|
| 69 |
+
R.to_csv(os.path.join(ROOT, "results", "external_check.csv"), index=False)
|
| 70 |
+
S = R.groupby(["set", "pair", "gallery"], sort=False).agg(
|
| 71 |
+
R1=("R@1", "mean"), R1_sd=("R@1", "std"), chance=("chance", "first"), oracle=("oracle", "mean"),
|
| 72 |
+
top1_same=("top1_same", "mean"), base_same=("base_same", "mean"))
|
| 73 |
+
S["x_chance"] = S.R1 / S.chance
|
| 74 |
+
pd.set_option("display.width", 250)
|
| 75 |
+
print(S.round(3).to_string())
|
experiments/40_clean_text.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Remove junk texts from the TCGA image-text pairs (cache/alltxt_meta.csv) and list the images left without real text.
|
| 2 |
+
Junk = bare multiple-choice answer letters ("A A B B"), "error error" strings, or < 5 words (answers without question).
|
| 3 |
+
Output cache/alltxt_junk.csv (idx = row of alltxt_meta.csv, junk, reason) -> 33_ trains / tests on non-junk only
|
| 4 |
+
cache/notext_meta.csv (img_idx, image_path, patient) images whose texts are all junk -> 27_ --sets tcga_notext
|
| 5 |
+
(few-shot GPT captions; VALIDATION ONLY, never trained on)
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from common import CACHE
|
| 12 |
+
|
| 13 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 14 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 15 |
+
c = T.caption.astype(str)
|
| 16 |
+
w = c.str.split().str.len()
|
| 17 |
+
reason = pd.Series("", index=T.index)
|
| 18 |
+
reason[w < 5] = "fewer than 5 words"
|
| 19 |
+
reason[c.str.lower().str.fullmatch(r"(\s*error\s*)+")] = "error string"
|
| 20 |
+
reason[c.str.fullmatch(r"\s*([A-Ea-e][\s,.;]*)+")] = "answer letters"
|
| 21 |
+
J = pd.DataFrame({"idx": T.index, "junk": reason != "", "reason": reason})
|
| 22 |
+
J.to_csv(os.path.join(CACHE, "alltxt_junk.csv"), index=False)
|
| 23 |
+
ok_img = set(T.img_idx[~J.junk])
|
| 24 |
+
N = I[~I.index.isin(ok_img)]
|
| 25 |
+
pd.DataFrame({"img_idx": N.index, "image_path": N.image_path, "patient": N.patient}).to_csv(
|
| 26 |
+
os.path.join(CACHE, "notext_meta.csv"), index=False)
|
| 27 |
+
print(f"texts {len(T)}: junk {J.junk.sum()} {J[J.junk].reason.value_counts().to_dict()}; clean {(~J.junk).sum()}")
|
| 28 |
+
print(f"images {len(I)}: with clean real text {len(ok_img)}, without -> notext_meta.csv {len(N)}")
|
experiments/41_text_enc_clean.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Re-check of the H&E <-> text encoder comparison (20_pairing.py, 28_) under the final protocol with CLEAN real text:
|
| 2 |
+
TCGA splits/final/tcga_fold<k>.csv (patient 5-fold, val = early stopping only), junk texts removed (40_clean_text.py),
|
| 3 |
+
identical text_head recipe to 33_final_eval.py (frozen image anchor, MLP on frozen text features, multi-positive InfoNCE).
|
| 4 |
+
Image anchors x text towers; PathGen-CLIP is left out (trained on PathGen-1.6M = most of this pool -> not held out).
|
| 5 |
+
Output results/text_enc_clean_long.csv, text_enc_clean_summary.csv
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
from scipy.optimize import linear_sum_assignment
|
| 15 |
+
|
| 16 |
+
from common import CACHE, ROOT
|
| 17 |
+
|
| 18 |
+
IMGS = ["uni2", "phikonv2", "gigapath", "conch"]
|
| 19 |
+
TXTS = ["biomedclip", "plip", "imagebind", "conchclip"]
|
| 20 |
+
K, G100, DRAWS = 5, 100, 50
|
| 21 |
+
dev = "cuda"
|
| 22 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 23 |
+
t_img = torch.as_tensor(T.img_idx.values, device=dev)
|
| 24 |
+
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def mlp(din, dout, hid=512): # = 33_
|
| 28 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 29 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def one_text_per_image(t_ids, rng): # = 33_
|
| 33 |
+
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 34 |
+
return pick.index.values, pick.values
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def g100(a, b, seed=0): # = 33_
|
| 38 |
+
rng = np.random.default_rng(seed)
|
| 39 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 40 |
+
for _ in range(DRAWS):
|
| 41 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 42 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 43 |
+
for S in (sim, sim.T):
|
| 44 |
+
rank = (S > d[:, None]).sum(1)
|
| 45 |
+
for k_ in (1, 5, 10):
|
| 46 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 47 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 48 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 49 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 50 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def train(X, Tx, tr_t, va_t, epochs=60, bs=256, lr=3e-4, seed=0): # = 33_ train_text_head
|
| 54 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 55 |
+
th = mlp(Tx.shape[1], X.shape[-1]).to(dev)
|
| 56 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 57 |
+
opt = torch.optim.AdamW(list(th.parameters()) + [logit], lr=lr, weight_decay=0.05)
|
| 58 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=epochs * max(1, len(tr_t) // bs), pct_start=0.05)
|
| 59 |
+
va_i, va_tt = one_text_per_image(va_t, np.random.default_rng(0))
|
| 60 |
+
za_va = F.normalize(X[torch.as_tensor(va_i, device=dev), 0], dim=-1)
|
| 61 |
+
best, state = -1, None
|
| 62 |
+
for ep in range(epochs):
|
| 63 |
+
th.train()
|
| 64 |
+
perm = rng.permutation(tr_t)
|
| 65 |
+
for s in range(0, len(perm) - bs + 1, bs):
|
| 66 |
+
b = torch.as_tensor(perm[s:s + bs], device=dev)
|
| 67 |
+
ii = t_img[b]
|
| 68 |
+
zi = F.normalize(X[ii, torch.randint(0, X.shape[1], (len(b),), device=dev)], dim=-1)
|
| 69 |
+
lg = logit.exp().clamp(max=100) * zi @ F.normalize(th(Tx[b]), dim=-1).T
|
| 70 |
+
pos = (ii[:, None] == ii[None, :]).float()
|
| 71 |
+
tgt = pos / pos.sum(1, keepdim=True)
|
| 72 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 73 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 74 |
+
th.eval()
|
| 75 |
+
with torch.no_grad():
|
| 76 |
+
sim = za_va @ F.normalize(th(Tx[torch.as_tensor(va_tt, device=dev)]), dim=-1).T
|
| 77 |
+
v = float((((sim > sim.diag()[:, None]).sum(1) == 0).float().mean() + ((sim.T > sim.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 78 |
+
if v > best:
|
| 79 |
+
best, state = v, {k_: x.detach().clone() for k_, x in th.state_dict().items()}
|
| 80 |
+
th.load_state_dict(state); th.eval()
|
| 81 |
+
return th, best
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
rows = []
|
| 85 |
+
for ie in IMGS:
|
| 86 |
+
X = torch.from_numpy(np.load(os.path.join(CACHE, f"allimg_he_{ie}.npy")).astype(np.float32)).to(dev)
|
| 87 |
+
for te_ in TXTS:
|
| 88 |
+
Tx = torch.from_numpy(np.load(os.path.join(CACHE, f"alltxt_text_{te_}.npy"))[:, 0].astype(np.float32)).to(dev)
|
| 89 |
+
for k in range(K):
|
| 90 |
+
sp = pd.read_csv(os.path.join(ROOT, "splits", "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
|
| 91 |
+
tsp = sp.loc[T.img_idx].values
|
| 92 |
+
tr_t, va_t, te_t = (T.index[tsp == x].values for x in ["train", "val", "test"])
|
| 93 |
+
th, best = train(X, Tx, tr_t, va_t)
|
| 94 |
+
ii, tt = one_text_per_image(te_t, np.random.default_rng(k))
|
| 95 |
+
with torch.no_grad():
|
| 96 |
+
za = F.normalize(X[torch.as_tensor(ii, device=dev), 0], dim=-1).cpu()
|
| 97 |
+
zt = F.normalize(th(Tx[torch.as_tensor(tt, device=dev)]), dim=-1).cpu()
|
| 98 |
+
rows.append({"img_enc": ie, "txt_enc": te_, "fold": k, "val_R@1": best, "n_test": len(ii), **g100(za, zt)})
|
| 99 |
+
print(f"{ie} + {te_} fold {k}: val {best:.3f} test R@1 {rows[-1]['R@1']:.3f}", flush=True)
|
| 100 |
+
R = pd.DataFrame(rows)
|
| 101 |
+
R.to_csv(os.path.join(ROOT, "results", "text_enc_clean_long.csv"), index=False)
|
| 102 |
+
S = R.groupby(["img_enc", "txt_enc"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
|
| 103 |
+
S.to_csv(os.path.join(ROOT, "results", "text_enc_clean_summary.csv"))
|
| 104 |
+
pd.set_option("display.width", 200)
|
| 105 |
+
print(R.pivot_table(index="img_enc", columns="txt_enc", values="R@1", sort=False).round(3).to_string())
|
experiments/42_train_regimes.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training-regime comparison: SEPARATE vs JOINT training of the four-modality model (real pairs only, final protocol).
|
| 2 |
+
|
| 3 |
+
Real paired data (fold k; train / val (early stopping only) / test, patients / study groups never cross splits):
|
| 4 |
+
ORION H&E <-> mIF splits/patient_5fold/fold<k>.csv UNI2 (8 views) <-> VirTues 16-ch MIPHEI panel (8 aligned views)
|
| 5 |
+
HEST H&E <-> RNA splits/final/hest_fold<k>.csv UNI2 <-> log1p CP10k of the 2000 HVGs
|
| 6 |
+
TCGA H&E <-> text splits/final/tcga_fold<k>.csv UNI2 (8 views) <-> BiomedCLIP, CLEAN real texts (40_)
|
| 7 |
+
Regimes (same MLP heads, InfoNCE, AdamW 3e-4 / wd 0.05 / OneCycle, batch 256, one temperature per pair):
|
| 8 |
+
S1 separate, frozen anchor modality head -> frozen UNI2 space; three independent trainings (= current method)
|
| 9 |
+
S2 separate, two-tower per pair a trainable H&E head + modality head (CLIP-style); no shared space
|
| 10 |
+
J1 joint, frozen anchor S1 heads trained in one loop (sum of the 3 losses), joint early stopping
|
| 11 |
+
J2 joint, shared H&E head ONE trainable H&E head shared by all pairs + 3 modality heads (ImageBind with
|
| 12 |
+
a learned anchor projection)
|
| 13 |
+
J3 J2, size-proportional one task per step, sampled proportional to its training-set size
|
| 14 |
+
J4 J2 + anchor distillation + lambda * MSE between in-batch cosine-similarity matrices of UNI2 and of the projected
|
| 15 |
+
H&E (keeps the shared head close to the UNI2 geometry)
|
| 16 |
+
J5 joint, residual adapter H&E = UNI2 + shared zero-initialised MLP adapter (frozen anchor + small shared change)
|
| 17 |
+
S3 separate, frozen, linear S1 with a single linear layer per modality (head capacity)
|
| 18 |
+
J4b J4 with lambda = 10 stronger distillation
|
| 19 |
+
J6 J2 + uncertainty weighting learnable per-task log-variance s_t: sum_t exp(-s_t) L_t + s_t (Kendall et al. 2018)
|
| 20 |
+
S4 separate, hybrid heads S1 with MLP head for mIF, linear heads for RNA and text
|
| 21 |
+
J8 two-stage from S4 J7 starting from the S4 heads
|
| 22 |
+
J7 two-stage S1 heads, then joint fine-tuning of S1 heads + residual adapter (lr 1e-4, 1000 steps,
|
| 23 |
+
distillation lambda 1)
|
| 24 |
+
Early stopping: separate = own val R@1; joint = geometric mean of the three val R@1 (every 50 steps).
|
| 25 |
+
Test (fold k): trained pairs H&E<->mIF / RNA / text, and emergent pairs with test-time-only virtual data:
|
| 26 |
+
ORION mIF<->RNA(STPath virtual), mIF<->text(GPT synthetic); TCGA text<->RNA(STPath virtual), text<->mIF(MIPHEI virtual).
|
| 27 |
+
Usage: python 42_train_regimes.py [--regimes S1 S2 J1 J2 J3 J4 J5] [--folds 0 1 2 3 4]
|
| 28 |
+
Output results/regimes_long.csv, results/regimes_summary.csv
|
| 29 |
+
"""
|
| 30 |
+
import argparse
|
| 31 |
+
import os
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
import pandas as pd
|
| 35 |
+
import torch
|
| 36 |
+
import torch.nn as nn
|
| 37 |
+
import torch.nn.functional as F
|
| 38 |
+
from scipy.optimize import linear_sum_assignment
|
| 39 |
+
|
| 40 |
+
from common import CACHE, ROOT, SPLITS
|
| 41 |
+
|
| 42 |
+
ap = argparse.ArgumentParser()
|
| 43 |
+
ap.add_argument("--regimes", nargs="+", default=["S1", "S2", "S3", "S4", "J1", "J2", "J3", "J4", "J4b", "J5", "J6", "J7", "J8"])
|
| 44 |
+
ap.add_argument("--folds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
|
| 45 |
+
args = ap.parse_args()
|
| 46 |
+
dev = "cuda"
|
| 47 |
+
H = os.path.join(CACHE, "hest")
|
| 48 |
+
G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
|
| 49 |
+
SEP_STEPS = {"mif": 400, "rna": 2700, "text": 1000} # = 40 / 100 / 60 epochs of 17_ / 23_ / 33_
|
| 50 |
+
JOINT_STEPS = {"equal": 2000, "prop": 4000}
|
| 51 |
+
f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------- data (all on GPU)
|
| 54 |
+
HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev) # (3187, 8, 1536)
|
| 55 |
+
IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev) # (3187, 8, 512)
|
| 56 |
+
IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
|
| 57 |
+
HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev) # (9484, 1536)
|
| 58 |
+
RNA_H = f32(np.load(os.path.join(H, "hvg.npy"))).to(dev) # (9484, 2000)
|
| 59 |
+
HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev) # (6144, 8, 1536)
|
| 60 |
+
TXT = f32(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[:, 0]).to(dev)
|
| 61 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 62 |
+
T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
|
| 63 |
+
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
|
| 64 |
+
GT_O = f32(np.load(os.path.join(CACHE, "orion_gentext_text_biomedclip.npy"))[:, 0]).to(dev)
|
| 65 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def virtual_rna(st): # = 33_final_eval.py
|
| 69 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 70 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 71 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 72 |
+
return f32(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]).to(dev)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
RNA_V_O, RNA_V_T = virtual_rna("orion"), virtual_rna("tcga_text")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def mlp(din, dout=1536, hid=512):
|
| 79 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 80 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def one_text_per_image(t_ids, rng):
|
| 84 |
+
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 85 |
+
return pick.index.values, pick.values
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def g100(a, b, seed=0): # = 33_
|
| 89 |
+
a, b = a.cpu(), b.cpu()
|
| 90 |
+
rng = np.random.default_rng(seed)
|
| 91 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 92 |
+
for _ in range(DRAWS):
|
| 93 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 94 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 95 |
+
for S in (sim, sim.T):
|
| 96 |
+
rank = (S > d[:, None]).sum(1)
|
| 97 |
+
for k_ in (1, 5, 10):
|
| 98 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 99 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 100 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 101 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 102 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def r1(a, b):
|
| 106 |
+
s = a @ b.T
|
| 107 |
+
return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class Model(nn.Module):
|
| 111 |
+
def __init__(self, anchor, tasks, head="mlp", uw=False):
|
| 112 |
+
super().__init__()
|
| 113 |
+
self.anchor = anchor
|
| 114 |
+
kind = head if isinstance(head, dict) else {t: head for t in ("mif", "rna", "text")}
|
| 115 |
+
mk = lambda t, d: mlp(d) if kind[t] == "mlp" else nn.Linear(d, 1536)
|
| 116 |
+
self.heads = nn.ModuleDict({"mif": mk("mif", 512), "rna": mk("rna", 2000), "text": mk("text", 512)})
|
| 117 |
+
self.uw = nn.ParameterDict({t: nn.Parameter(torch.zeros(())) for t in tasks}) if uw else None
|
| 118 |
+
self.logit = nn.ParameterDict({t: nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32)) for t in tasks})
|
| 119 |
+
if anchor == "shared":
|
| 120 |
+
self.ah = mlp(1536)
|
| 121 |
+
elif anchor == "pair":
|
| 122 |
+
self.ah = nn.ModuleDict({t: mlp(1536) for t in tasks})
|
| 123 |
+
elif anchor == "residual":
|
| 124 |
+
self.ah = mlp(1536)
|
| 125 |
+
nn.init.zeros_(self.ah[-1].weight); nn.init.zeros_(self.ah[-1].bias)
|
| 126 |
+
|
| 127 |
+
def he(self, x, task):
|
| 128 |
+
if self.anchor == "frozen":
|
| 129 |
+
return x
|
| 130 |
+
if self.anchor == "shared":
|
| 131 |
+
return self.ah(x)
|
| 132 |
+
if self.anchor == "pair":
|
| 133 |
+
return self.ah[task](x)
|
| 134 |
+
return x + self.ah(x) # residual
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class Fold:
|
| 138 |
+
def __init__(self, k):
|
| 139 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 140 |
+
self.o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
|
| 141 |
+
self.if_mu = IF_O[self.o["train"]].mean((0, 1)); self.if_sd = IF_O[self.o["train"]].std((0, 1)) + 1e-3
|
| 142 |
+
h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
|
| 143 |
+
self.h = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
|
| 144 |
+
sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
|
| 145 |
+
tsp = sp.loc[T.img_idx].values
|
| 146 |
+
self.t = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
|
| 147 |
+
self.t_va = one_text_per_image(self.t["val"], np.random.default_rng(0))
|
| 148 |
+
self.t_te = one_text_per_image(self.t["test"], np.random.default_rng(k))
|
| 149 |
+
self.n = {"mif": len(self.o["train"]), "rna": len(self.h["train"]), "text": len(self.t["train"])}
|
| 150 |
+
|
| 151 |
+
def nif(self, x):
|
| 152 |
+
return (x - self.if_mu) / self.if_sd
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def batch_loss(m, F_, task, rng):
|
| 156 |
+
if task == "mif":
|
| 157 |
+
b = torch.as_tensor(rng.choice(F_.o["train"], BS, replace=False), device=dev)
|
| 158 |
+
d = torch.randint(0, 8, (BS,), device=dev)
|
| 159 |
+
x_he, x_m = HE_O[b, d], F_.nif(IF_O[b, d]); pos = None
|
| 160 |
+
elif task == "rna":
|
| 161 |
+
b = torch.as_tensor(rng.choice(F_.h["train"], BS, replace=False), device=dev)
|
| 162 |
+
x_he, x_m = HE_H[b], RNA_H[b]; pos = None
|
| 163 |
+
else:
|
| 164 |
+
b = torch.as_tensor(rng.choice(F_.t["train"], BS, replace=False), device=dev)
|
| 165 |
+
ii = T_IMG[b]
|
| 166 |
+
x_he, x_m = HE_T[ii, torch.randint(0, 8, (BS,), device=dev)], TXT[b]
|
| 167 |
+
pos = (ii[:, None] == ii[None, :]).float()
|
| 168 |
+
za = F.normalize(m.he(x_he, task), dim=-1)
|
| 169 |
+
zm = F.normalize(m.heads[task](x_m), dim=-1)
|
| 170 |
+
lg = m.logit[task].exp().clamp(max=100) * za @ zm.T
|
| 171 |
+
if pos is None:
|
| 172 |
+
t = torch.arange(BS, device=dev)
|
| 173 |
+
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 174 |
+
else:
|
| 175 |
+
tgt = pos / pos.sum(1, keepdim=True)
|
| 176 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 177 |
+
if m.uw is not None:
|
| 178 |
+
loss = torch.exp(-m.uw[task]) * loss + m.uw[task]
|
| 179 |
+
if m.distill and m.anchor != "frozen":
|
| 180 |
+
u = F.normalize(x_he, dim=-1)
|
| 181 |
+
loss = loss + m.distill * F.mse_loss(za @ za.T, u @ u.T)
|
| 182 |
+
return loss
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@torch.no_grad()
|
| 186 |
+
def embed(m, F_, task, split):
|
| 187 |
+
if task == "mif":
|
| 188 |
+
idx = F_.o[split]
|
| 189 |
+
return F.normalize(m.he(HE_O[idx, 0], task), dim=-1), F.normalize(m.heads["mif"](F_.nif(IF_O[idx, 0])), dim=-1)
|
| 190 |
+
if task == "rna":
|
| 191 |
+
idx = F_.h[split]
|
| 192 |
+
return F.normalize(m.he(HE_H[idx], task), dim=-1), F.normalize(m.heads["rna"](RNA_H[idx]), dim=-1)
|
| 193 |
+
ii, tt = F_.t_va if split == "val" else F_.t_te
|
| 194 |
+
ii, tt = torch.as_tensor(ii, device=dev), torch.as_tensor(tt, device=dev)
|
| 195 |
+
return F.normalize(m.he(HE_T[ii, 0], task), dim=-1), F.normalize(m.heads["text"](TXT[tt]), dim=-1)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def val_scores(m, F_, tasks):
|
| 199 |
+
m.eval()
|
| 200 |
+
v = {t: r1(*embed(m, F_, t, "val")) for t in tasks}
|
| 201 |
+
m.train()
|
| 202 |
+
return v
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def train(anchor, tasks, F_, steps, sampling="equal", distill=0.0, seed=0, head="mlp", uw=False, init=None, lr=LR):
|
| 206 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 207 |
+
m = Model(anchor, tasks, head, uw).to(dev); m.distill = distill
|
| 208 |
+
if init is not None: # two-stage: start from separately trained heads / temperatures
|
| 209 |
+
for t, src in init.items():
|
| 210 |
+
m.heads[t].load_state_dict(src.heads[t].state_dict()); m.logit[t].data.copy_(src.logit[t].data)
|
| 211 |
+
opt = torch.optim.AdamW(m.parameters(), lr=lr, weight_decay=0.05)
|
| 212 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=steps, pct_start=0.05)
|
| 213 |
+
p = np.array([F_.n[t] for t in tasks], float); p /= p.sum()
|
| 214 |
+
best, state, bv = -1, None, None
|
| 215 |
+
m.train()
|
| 216 |
+
for step in range(1, steps + 1):
|
| 217 |
+
todo = tasks if sampling == "equal" else [tasks[rng.choice(len(tasks), p=p)]]
|
| 218 |
+
loss = sum(batch_loss(m, F_, t, rng) for t in todo)
|
| 219 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 220 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 221 |
+
v = val_scores(m, F_, tasks)
|
| 222 |
+
score = float(np.exp(np.mean(np.log([max(x, 1e-4) for x in v.values()]))))
|
| 223 |
+
if score > best:
|
| 224 |
+
best, bv, state = score, v, {k_: x.detach().clone() for k_, x in m.state_dict().items()}
|
| 225 |
+
m.load_state_dict(state); m.eval()
|
| 226 |
+
return m, bv
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@torch.no_grad()
|
| 230 |
+
def evaluate(models, F_):
|
| 231 |
+
"""models: task -> model holding that task's head (same object for joint regimes)"""
|
| 232 |
+
out = {}
|
| 233 |
+
for t, lab in [("mif", "H&E<->mIF (ORION)"), ("rna", "H&E<->RNA (HEST)"), ("text", "H&E<->text (TCGA)")]:
|
| 234 |
+
out[(lab, "trained")] = g100(*embed(models[t], F_, t, "test"))
|
| 235 |
+
te = F_.o["test"]
|
| 236 |
+
zm = F.normalize(models["mif"].heads["mif"](F_.nif(IF_O[te, 0])), dim=-1)
|
| 237 |
+
zr = F.normalize(models["rna"].heads["rna"](RNA_V_O[te]), dim=-1)
|
| 238 |
+
zt = F.normalize(models["text"].heads["text"](GT_O[te]), dim=-1)
|
| 239 |
+
out[("mIF<->RNA(virtual) (ORION)", "emergent")] = g100(zm, zr)
|
| 240 |
+
out[("mIF<->text(synthetic) (ORION)", "emergent")] = g100(zm, zt)
|
| 241 |
+
ii, tt = F_.t_te
|
| 242 |
+
ii, tt = torch.as_tensor(ii, device=dev), torch.as_tensor(tt, device=dev)
|
| 243 |
+
zt = F.normalize(models["text"].heads["text"](TXT[tt]), dim=-1)
|
| 244 |
+
zr = F.normalize(models["rna"].heads["rna"](RNA_V_T[ii]), dim=-1)
|
| 245 |
+
zv = F.normalize(models["mif"].heads["mif"](F_.nif(IF_V_T[ii])), dim=-1)
|
| 246 |
+
out[("text<->RNA(virtual) (TCGA)", "emergent")] = g100(zt, zr)
|
| 247 |
+
out[("text<->mIF(MIPHEI) (TCGA)", "emergent")] = g100(zt, zv)
|
| 248 |
+
return out
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
HYB = {"mif": "mlp", "rna": "linear", "text": "linear"} # per-modality head capacity
|
| 252 |
+
REG = {"S1": dict(anchor="frozen", mode="separate"), "S2": dict(anchor="pair", mode="separate"),
|
| 253 |
+
"S3": dict(anchor="frozen", mode="separate", head="linear"),
|
| 254 |
+
"J1": dict(anchor="frozen", mode="joint"), "J2": dict(anchor="shared", mode="joint"),
|
| 255 |
+
"J3": dict(anchor="shared", mode="joint", sampling="prop"), "J4": dict(anchor="shared", mode="joint", distill=1.0),
|
| 256 |
+
"J4b": dict(anchor="shared", mode="joint", distill=10.0), "J5": dict(anchor="residual", mode="joint"),
|
| 257 |
+
"J6": dict(anchor="shared", mode="joint", uw=True), "J7": dict(anchor="residual", mode="two-stage", distill=1.0),
|
| 258 |
+
"S4": dict(anchor="frozen", mode="separate", head=HYB), "J8": dict(anchor="residual", mode="two-stage", distill=1.0, head=HYB)}
|
| 259 |
+
NAME = {"S1": "separate, frozen anchor", "S2": "separate, two-tower", "J1": "joint, frozen anchor",
|
| 260 |
+
"J2": "joint, shared H&E head", "J3": "joint, shared H&E head, size-prop.", "J4": "joint, shared H&E head + distill",
|
| 261 |
+
"J5": "joint, residual adapter", "S3": "separate, frozen anchor, linear head",
|
| 262 |
+
"J4b": "joint, shared H&E head + distill x10", "J6": "joint, shared H&E head, uncertainty wt.",
|
| 263 |
+
"J7": "two-stage: separate -> joint residual", "S4": "separate, frozen, MLP mIF + linear RNA/text",
|
| 264 |
+
"J8": "two-stage from S4 heads -> joint residual"}
|
| 265 |
+
TASKS = ["mif", "rna", "text"]
|
| 266 |
+
f_out = os.path.join(ROOT, "results", "regimes_long.csv")
|
| 267 |
+
rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
|
| 268 |
+
done = {(r["regime"], r["fold"]) for r in rows}
|
| 269 |
+
for k in args.folds:
|
| 270 |
+
F_ = Fold(k)
|
| 271 |
+
for rg in args.regimes:
|
| 272 |
+
if (rg, k) in done:
|
| 273 |
+
continue
|
| 274 |
+
c = REG[rg]
|
| 275 |
+
if c["mode"] in ("separate", "two-stage"):
|
| 276 |
+
models, val = {}, {}
|
| 277 |
+
for t in TASKS:
|
| 278 |
+
models[t], v = train("frozen" if c["mode"] == "two-stage" else c["anchor"], [t], F_, SEP_STEPS[t],
|
| 279 |
+
head=c.get("head", "mlp"))
|
| 280 |
+
val.update(v)
|
| 281 |
+
if c["mode"] == "joint":
|
| 282 |
+
sm = c.get("sampling", "equal")
|
| 283 |
+
m, val = train(c["anchor"], TASKS, F_, JOINT_STEPS[sm], sm, c.get("distill", 0.0), uw=c.get("uw", False))
|
| 284 |
+
models = {t: m for t in TASKS}
|
| 285 |
+
elif c["mode"] == "two-stage":
|
| 286 |
+
m, val = train(c["anchor"], TASKS, F_, 1000, "equal", c["distill"], init=models, lr=1e-4, head=c.get("head", "mlp"))
|
| 287 |
+
models = {t: m for t in TASKS}
|
| 288 |
+
for (pair, kind), r in evaluate(models, F_).items():
|
| 289 |
+
rows.append({"regime": rg, "name": NAME[rg], "fold": k, "pair": pair, "kind": kind, **r,
|
| 290 |
+
"val_mif": val["mif"], "val_rna": val["rna"], "val_text": val["text"]})
|
| 291 |
+
pd.DataFrame(rows).to_csv(f_out, index=False)
|
| 292 |
+
last = [r for r in rows if r["regime"] == rg and r["fold"] == k]
|
| 293 |
+
print(f"fold {k} {rg:>2} {NAME[rg]:<36} " + " ".join(f"{r['pair'].split(' (')[0]} {r['R@1']:.3f}" for r in last), flush=True)
|
| 294 |
+
|
| 295 |
+
R = pd.DataFrame(rows)
|
| 296 |
+
S = R.groupby(["regime", "name", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
|
| 297 |
+
S.to_csv(os.path.join(ROOT, "results", "regimes_summary.csv"))
|
| 298 |
+
pd.set_option("display.width", 250)
|
| 299 |
+
print(R.pivot_table(index=["regime", "name"], columns="pair", values="R@1", sort=False).round(3).to_string())
|
experiments/43_rnafm_base.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen RNA embeddings for every RNA set of rna_sets.py (hest, hest_v, orion_v, tcga_v, dlpfc).
|
| 2 |
+
|
| 3 |
+
python 43_rnafm_base.py scgpt | geneformer | genept-bge | genept-bmc
|
| 4 |
+
|
| 5 |
+
scgpt / geneformer : same preprocessing and readout as 22_extract_gene_fm.py (scGPT-human <cls> on the HEST 2000 HVGs
|
| 6 |
+
in vocab, quantile-binned log1p CP10k, <=1200 genes; Geneformer-V2-104M rank-value encoding,
|
| 7 |
+
penultimate layer mean over gene tokens). 22_ only embedded HEST; this adds the virtual / external sets.
|
| 8 |
+
genept-* : GenePT-w (Chen & Zou 2024) with open text encoders: each gene = text embedding of its NCBI + UniProt
|
| 9 |
+
summary (GenePT v2 release, Zenodo 10833191, CC BY 4.0, ext/genept/); spot = average of the gene
|
| 10 |
+
embeddings weighted by log1p CP10k over the genes that have a summary, L2-normalised.
|
| 11 |
+
bge = BAAI/bge-base-en-v1.5 (CLS, 512 tokens), bmc = BiomedCLIP PubMedBERT text tower (256 tokens,
|
| 12 |
+
the text tower of the text modality -> RNA lands in the same text space).
|
| 13 |
+
Output cache/rnafm/<model>_<set>.npy (rna_sets.save)
|
| 14 |
+
"""
|
| 15 |
+
import importlib
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import pickle
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
import scipy.sparse as sp
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
from common import CACHE, ROOT
|
| 28 |
+
from rna_sets import SETS, load, save
|
| 29 |
+
|
| 30 |
+
M15 = importlib.import_module("15_extract_marker_fm")
|
| 31 |
+
name = sys.argv[1]
|
| 32 |
+
sets = sys.argv[2:] or SETS
|
| 33 |
+
dev = "cuda"
|
| 34 |
+
HVG = pd.read_csv(os.path.join(CACHE, "hest", "hvg_genes.csv")).gene.values
|
| 35 |
+
t0 = time.time()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def cp10k_log(X, cols):
|
| 39 |
+
tot = np.asarray(X.sum(1)).ravel()
|
| 40 |
+
L = (sp.diags(1e4 / np.maximum(tot, 1)) @ X[:, cols]).tocsr()
|
| 41 |
+
L.data = np.log1p(L.data)
|
| 42 |
+
return L
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def scgpt_model():
|
| 46 |
+
from huggingface_hub import hf_hub_download
|
| 47 |
+
args = json.load(open(hf_hub_download(M15.SCGPT_REPO, "args.json")))
|
| 48 |
+
vocab = json.load(open(hf_hub_download(M15.SCGPT_REPO, "vocab.json")))
|
| 49 |
+
sd = torch.load(hf_hub_download(M15.SCGPT_REPO, "best_model.pt"), map_location="cpu", weights_only=True)
|
| 50 |
+
sd = {k.replace("self_attn.Wqkv.", "self_attn.in_proj_"): v for k, v in sd.items()}
|
| 51 |
+
model = M15.ScGPT(len(vocab), args["embsize"], args["nheads"], args["d_hid"], args["nlayers"], vocab[args["pad_token"]])
|
| 52 |
+
model.load_state_dict(sd, strict=True)
|
| 53 |
+
return model.eval().to(dev), args, vocab
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def scgpt(X, genes, model, args, vocab):
|
| 57 |
+
PAD, CLS = vocab[args["pad_token"]], vocab["<cls>"]
|
| 58 |
+
hv = set(HVG)
|
| 59 |
+
gi = np.array([i for i, g in enumerate(genes) if g in hv and g in vocab])
|
| 60 |
+
gid = np.array([vocab[g] for g in genes[gi]])
|
| 61 |
+
L = cp10k_log(X, gi)
|
| 62 |
+
rng = np.random.default_rng(0)
|
| 63 |
+
seqs = []
|
| 64 |
+
for r in range(L.shape[0]):
|
| 65 |
+
nz, v = L.indices[L.indptr[r]:L.indptr[r + 1]], L.data[L.indptr[r]:L.indptr[r + 1]]
|
| 66 |
+
if len(nz) > 1199:
|
| 67 |
+
keep = np.sort(rng.choice(len(nz), 1199, replace=False))
|
| 68 |
+
nz, v = nz[keep], v[keep]
|
| 69 |
+
vals = M15.scgpt_binning(v, args["n_bins"], rng).astype(np.float32) if len(v) else np.zeros(0, np.float32)
|
| 70 |
+
seqs.append((np.r_[CLS, gid[nz]], np.r_[np.float32(args["pad_value"]), vals]))
|
| 71 |
+
print(f" scGPT: {len(gi)} HVGs in vocab; genes/spot median {np.median([len(s[0]) - 1 for s in seqs])}", flush=True)
|
| 72 |
+
embs = []
|
| 73 |
+
with torch.no_grad():
|
| 74 |
+
for i in range(0, len(seqs), 32):
|
| 75 |
+
b = seqs[i:i + 32]
|
| 76 |
+
Lm = max(len(g) for g, _ in b)
|
| 77 |
+
g = torch.full((len(b), Lm), PAD, dtype=torch.long)
|
| 78 |
+
v = torch.full((len(b), Lm), float(args["pad_value"]))
|
| 79 |
+
for j, (gg, vv) in enumerate(b):
|
| 80 |
+
g[j, :len(gg)], v[j, :len(vv)] = torch.from_numpy(gg), torch.from_numpy(vv)
|
| 81 |
+
g, v = g.to(dev), v.to(dev)
|
| 82 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 83 |
+
h = model.encode(g, v, g.eq(PAD))
|
| 84 |
+
embs.append(h[:, 0].float().cpu())
|
| 85 |
+
E = torch.cat(embs).numpy()
|
| 86 |
+
return E / np.linalg.norm(E, axis=1, keepdims=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def gf_model():
|
| 90 |
+
from transformers import BertModel
|
| 91 |
+
tokd = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/token_dictionary_gc104M.pkl"), "rb"))
|
| 92 |
+
medd = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/gene_median_dictionary_gc104M.pkl"), "rb"))
|
| 93 |
+
n2id = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/gene_name_id_dict_gc104M.pkl"), "rb"))
|
| 94 |
+
mdir = os.path.dirname(M15.hf_file(M15.GF_REPO, "Geneformer-V2-104M/model.safetensors"))
|
| 95 |
+
M15.hf_file(M15.GF_REPO, "Geneformer-V2-104M/config.json")
|
| 96 |
+
model = BertModel.from_pretrained(mdir, add_pooling_layer=False, attn_implementation="sdpa").eval().to(dev)
|
| 97 |
+
return model, tokd, medd, n2id
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def geneformer(X, genes, model, tokd, medd, n2id):
|
| 101 |
+
gi = np.array([i for i, g in enumerate(genes) if g in n2id and n2id[g] in tokd and n2id[g] in medd])
|
| 102 |
+
tok = np.array([tokd[n2id[g]] for g in genes[gi]])
|
| 103 |
+
med = np.array([medd[n2id[g]] for g in genes[gi]])
|
| 104 |
+
CLS, EOS, PAD = tokd["<cls>"], tokd["<eos>"], tokd["<pad>"]
|
| 105 |
+
tot = np.asarray(X.sum(1)).ravel()
|
| 106 |
+
Xg = (sp.diags(1e4 / np.maximum(tot, 1)) @ X[:, gi]).tocsr()
|
| 107 |
+
seqs = []
|
| 108 |
+
for r in range(Xg.shape[0]):
|
| 109 |
+
nz, v = Xg.indices[Xg.indptr[r]:Xg.indptr[r + 1]], Xg.data[Xg.indptr[r]:Xg.indptr[r + 1]]
|
| 110 |
+
v = v / med[nz]
|
| 111 |
+
order = nz[np.argsort(-v, kind="stable")][:4094]
|
| 112 |
+
seqs.append([CLS] + tok[order].tolist() + [EOS])
|
| 113 |
+
print(f" Geneformer: {len(gi)} genes in dictionary; tokens/spot median {np.median([len(s) for s in seqs])}", flush=True)
|
| 114 |
+
order_len = np.argsort([len(s) for s in seqs])
|
| 115 |
+
embs = np.zeros((len(seqs), model.config.hidden_size), np.float32)
|
| 116 |
+
with torch.no_grad():
|
| 117 |
+
for k in range(0, len(seqs), 8):
|
| 118 |
+
idx = order_len[k:k + 8]
|
| 119 |
+
b = [seqs[i] for i in idx]
|
| 120 |
+
ids = torch.full((len(b), max(map(len, b))), PAD, dtype=torch.long)
|
| 121 |
+
for j, s in enumerate(b):
|
| 122 |
+
ids[j, :len(s)] = torch.tensor(s)
|
| 123 |
+
ids = ids.to(dev)
|
| 124 |
+
att = (ids != PAD).long()
|
| 125 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 126 |
+
h = model(input_ids=ids, attention_mask=att, output_hidden_states=True).hidden_states[-2].float()
|
| 127 |
+
gm = att.clone().float()
|
| 128 |
+
gm[:, 0] = 0
|
| 129 |
+
gm[torch.arange(len(b)), att.sum(1) - 1] = 0
|
| 130 |
+
embs[idx] = ((h * gm[..., None]).sum(1) / gm.sum(1, keepdim=True).clamp(min=1)).cpu().numpy()
|
| 131 |
+
return embs
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def gene_text_table(enc):
|
| 135 |
+
"""Text embedding of each gene's NCBI + UniProt summary; cached in cache/rnafm/genetext_<enc>.npz."""
|
| 136 |
+
f = os.path.join(CACHE, "rnafm", f"genetext_{enc}.npz")
|
| 137 |
+
if os.path.exists(f):
|
| 138 |
+
z = np.load(f, allow_pickle=False)
|
| 139 |
+
return z["genes"], z["E"]
|
| 140 |
+
summ = json.load(open(os.path.join(ROOT, "ext", "genept", "unz", "GenePT_emebdding_v2", "NCBI_UniProt_summary_of_genes.json")))
|
| 141 |
+
g = np.array(sorted(summ))
|
| 142 |
+
txt = [summ[x] for x in g]
|
| 143 |
+
out = []
|
| 144 |
+
if enc == "bge":
|
| 145 |
+
from transformers import AutoModel, AutoTokenizer
|
| 146 |
+
tk = AutoTokenizer.from_pretrained("BAAI/bge-base-en-v1.5")
|
| 147 |
+
m = AutoModel.from_pretrained("BAAI/bge-base-en-v1.5").to(dev).eval()
|
| 148 |
+
f_ = lambda b: m(**{k: v.to(dev) for k, v in tk(b, padding=True, truncation=True, max_length=512, return_tensors="pt").items()}).last_hidden_state[:, 0]
|
| 149 |
+
else:
|
| 150 |
+
from biomedclip_text import BiomedCLIPText # open_clip-free, reproduces cache/alltxt_text_biomedclip.npy (cos 1.000)
|
| 151 |
+
m = BiomedCLIPText().to(dev).eval()
|
| 152 |
+
f_ = m
|
| 153 |
+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
|
| 154 |
+
for i in range(0, len(txt), 128):
|
| 155 |
+
out.append(torch.nn.functional.normalize(f_(txt[i:i + 128]).float(), dim=-1).cpu())
|
| 156 |
+
E = torch.cat(out).numpy()
|
| 157 |
+
os.makedirs(os.path.dirname(f), exist_ok=True)
|
| 158 |
+
np.savez(f, genes=g, E=E)
|
| 159 |
+
print(f" gene texts: {len(g)} genes embedded with {enc} {E.shape}", flush=True)
|
| 160 |
+
return g, E
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def genept(X, genes, gt, GE):
|
| 164 |
+
idx = pd.Series(range(len(gt)), index=gt)
|
| 165 |
+
gi = np.array([i for i, g in enumerate(genes) if g in idx.index])
|
| 166 |
+
W = cp10k_log(X, gi)
|
| 167 |
+
print(f" GenePT: {len(gi)}/{len(genes)} genes with a summary", flush=True)
|
| 168 |
+
E = W @ GE[idx.loc[genes[gi]].values]
|
| 169 |
+
E = E / np.maximum(np.asarray(W.sum(1)), 1e-8)
|
| 170 |
+
return E / np.linalg.norm(E, axis=1, keepdims=True).clip(1e-8)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
if name == "scgpt":
|
| 174 |
+
ctx = scgpt_model(); fn = scgpt
|
| 175 |
+
elif name == "geneformer":
|
| 176 |
+
ctx = gf_model(); fn = geneformer
|
| 177 |
+
elif name.startswith("genept-"):
|
| 178 |
+
ctx = gene_text_table(name.split("-")[1]); fn = genept
|
| 179 |
+
else:
|
| 180 |
+
raise ValueError(name)
|
| 181 |
+
for s in sets:
|
| 182 |
+
X, genes = load(s)
|
| 183 |
+
print(f"[{name}] {s} {X.shape}", flush=True)
|
| 184 |
+
save(name, s, fn(X, genes, *ctx))
|
| 185 |
+
print(f" {time.time() - t0:.0f}s", flush=True)
|
experiments/43_rnafm_c2s.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen Cell2Sentence (C2S-Scale-Gemma-2-2B) spot embeddings for the RNA sets of rna_sets.py (spot = pseudo-cell).
|
| 2 |
+
|
| 3 |
+
python 43_rnafm_c2s.py [set ...] (default: all of rna_sets.SETS)
|
| 4 |
+
|
| 5 |
+
Model : HF vandijklab/C2S-Scale-Gemma-2-2B @ 5ddf28b8f1c81b7ab7a9be192924da82b6c5d512 (cc-by-4.0), a Gemma-2 2B
|
| 6 |
+
decoder (26 layers, d=2304) instruction-tuned on cell sentences from >800 CellxGene/HCA datasets. Loaded as
|
| 7 |
+
a plain transformers Gemma2Model (no remote code, strict: no missing / unexpected keys), bf16, eager
|
| 8 |
+
attention (keeps Gemma-2's attention-logit soft-capping exactly as in training).
|
| 9 |
+
Tokenizer : the repo ships only the sentencepiece tokenizer.model; sentencepiece is not in the shared env, so it was
|
| 10 |
+
converted once to ext/cell2sentence/tokenizer/fast/tokenizer.json with transformers' own GemmaConverter
|
| 11 |
+
inside an isolated venv (ext/cell2sentence/venv) and checked token-identical to the sentencepiece
|
| 12 |
+
GemmaTokenizer on sampled prompts of every set.
|
| 13 |
+
Cell sentence (exactly as cell2sentence src/cell2sentence/utils.py generate_sentences + prompt_formatter.py, repo
|
| 14 |
+
vandijklab/cell2sentence @ 6599c9d, read-only clone in ext/cell2sentence/src):
|
| 15 |
+
raw counts -> sc.pp.normalize_total (target = median total counts of the set) -> log1p base 10
|
| 16 |
+
(tutorial 0; rank-preserving within a spot) -> gene symbols upper-cased (generate_vocabulary) ->
|
| 17 |
+
nonzero genes of the spot, shuffled with np.random.seed(42) / sklearn.utils.shuffle and stable-sorted by
|
| 18 |
+
descending expression (= their random tie-breaking) -> top K=200 symbols (default n_genes=200 of
|
| 19 |
+
tasks.embed_cells / predict_cell_types_of_data and the tutorials), space-separated.
|
| 20 |
+
Not applied: tutorial-0 QC filters (filter_cells min_genes=200, filter_genes min_cells=3), because they
|
| 21 |
+
would drop spots / make a spot's sentence depend on the other spots of the set.
|
| 22 |
+
Prompt : the cell-type-prediction template used by tasks.embed_cells (C2SPromptFormatter task
|
| 23 |
+
"cell_type_prediction"); that formatter picks one of 15 paraphrases at random, we fix the first one, which
|
| 24 |
+
is also the template on the C2S-Scale model card:
|
| 25 |
+
"The following is a list of {K} gene names ordered by descending expression level in a Homo sapiens cell.
|
| 26 |
+
Your task is to give the cell type which this cell belongs to based on its gene expression.\\n
|
| 27 |
+
Cell sentence: {sentence}.\\nThe cell type corresponding to these genes is:" (+ <bos>, no chat template)
|
| 28 |
+
{K} is always 200, as in the formatter (spots with < 200 expressed genes keep "200").
|
| 29 |
+
Readout : last hidden layer (post final RMSNorm = hidden_states[-1], as CSModel.embed_cells_batched).
|
| 30 |
+
"c2s" : mean over the cell-sentence tokens only (tokens whose char span overlaps the gene list;
|
| 31 |
+
official code averages over the whole padded prompt, which mixes in the constant template
|
| 32 |
+
and padding).
|
| 33 |
+
"c2s-last" : hidden state of the last prompt token (":" after "is"), i.e. the state that would generate
|
| 34 |
+
the cell type.
|
| 35 |
+
Output : cache/rnafm/c2s_<set>.npy and c2s-last_<set>.npy, (n_spot, 2304) float16, set order. Log logs/rnafm/c2s.log.
|
| 36 |
+
"""
|
| 37 |
+
import os
|
| 38 |
+
import sys
|
| 39 |
+
import time
|
| 40 |
+
|
| 41 |
+
import numpy as np
|
| 42 |
+
import pandas as pd
|
| 43 |
+
import scipy.sparse as sp
|
| 44 |
+
import torch
|
| 45 |
+
from sklearn.utils import shuffle
|
| 46 |
+
|
| 47 |
+
from common import CACHE, ROOT
|
| 48 |
+
from rna_sets import SETS, load, save
|
| 49 |
+
|
| 50 |
+
REPO = "vandijklab/C2S-Scale-Gemma-2-2B"
|
| 51 |
+
REV = "5ddf28b8f1c81b7ab7a9be192924da82b6c5d512"
|
| 52 |
+
TOK_DIR = os.path.join(ROOT, "ext", "cell2sentence", "tokenizer", "fast")
|
| 53 |
+
K = 200
|
| 54 |
+
ORGANISM = "Homo sapiens"
|
| 55 |
+
PREFIX = (f"The following is a list of {K} gene names ordered by descending expression level in a {ORGANISM} cell. "
|
| 56 |
+
"Your task is to give the cell type which this cell belongs to based on its gene expression.\nCell sentence: ")
|
| 57 |
+
SUFFIX = ".\nThe cell type corresponding to these genes is:"
|
| 58 |
+
GPU_GB = 8.0
|
| 59 |
+
TOK_BUDGET = 8 * 800 # tokens per forward pass (halved on OOM; ~7 GB peak at L~750)
|
| 60 |
+
dev = "cuda"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def log(*a):
|
| 64 |
+
print(*a, flush=True)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def cell_sentences(X, genes):
|
| 68 |
+
"""C2S normalisation + rank -> list of top-K space-separated (upper-case) gene symbols, plus #expressed genes."""
|
| 69 |
+
X = X.tocsr().astype(np.float64)
|
| 70 |
+
tot = np.asarray(X.sum(1)).ravel()
|
| 71 |
+
target = np.median(tot[tot > 0]) # sc.pp.normalize_total default target_sum=None
|
| 72 |
+
Xn = sp.diags(target / np.maximum(tot, 1e-12)) @ X
|
| 73 |
+
Xn = Xn.tocsr()
|
| 74 |
+
Xn.data = np.log1p(Xn.data) / np.log(10) # sc.pp.log1p(base=10)
|
| 75 |
+
enc = np.char.upper(np.asarray(genes, dtype=str))
|
| 76 |
+
np.random.seed(42) # generate_sentences(random_state=42)
|
| 77 |
+
sents, n_expr = [], np.zeros(Xn.shape[0], int)
|
| 78 |
+
for i in range(Xn.shape[0]):
|
| 79 |
+
cols = Xn.indices[Xn.indptr[i]:Xn.indptr[i + 1]]
|
| 80 |
+
vals = Xn.data[Xn.indptr[i]:Xn.indptr[i + 1]]
|
| 81 |
+
cols, vals = shuffle(cols, vals)
|
| 82 |
+
order = cols[np.argsort(-vals, kind="stable")]
|
| 83 |
+
n_expr[i] = len(order)
|
| 84 |
+
sents.append(" ".join(enc[order[:K]]))
|
| 85 |
+
return sents, n_expr
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def tokenize(tok, sents):
|
| 89 |
+
"""Prompt ids + mask of cell-sentence tokens (by char offsets)."""
|
| 90 |
+
prompts = [PREFIX + s + SUFFIX for s in sents]
|
| 91 |
+
enc = tok(prompts, return_offsets_mapping=True, add_special_tokens=True)
|
| 92 |
+
out = []
|
| 93 |
+
for p, s, ids, off in zip(prompts, sents, enc["input_ids"], enc["offset_mapping"]):
|
| 94 |
+
a, b = len(PREFIX), len(PREFIX) + len(s)
|
| 95 |
+
off = np.asarray(off)
|
| 96 |
+
m = (off[:, 0] < b) & (off[:, 1] > a) & (off[:, 1] > off[:, 0])
|
| 97 |
+
assert ids[0] == tok.bos_token_id and m.sum() > 0
|
| 98 |
+
out.append((np.asarray(ids), m))
|
| 99 |
+
return prompts, out
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@torch.no_grad()
|
| 103 |
+
def embed(model, seqs, pad_id):
|
| 104 |
+
n, D = len(seqs), model.config.hidden_size
|
| 105 |
+
E_mean, E_last = np.zeros((n, D), np.float32), np.zeros((n, D), np.float32)
|
| 106 |
+
order = np.argsort([len(s[0]) for s in seqs])[::-1] # longest first: OOM shows up immediately
|
| 107 |
+
budget, k, t0, done = TOK_BUDGET, 0, time.time(), 0
|
| 108 |
+
while k < n:
|
| 109 |
+
Lm = len(seqs[order[k]][0])
|
| 110 |
+
bs = max(1, budget // Lm)
|
| 111 |
+
idx = order[k:k + bs]
|
| 112 |
+
ids = torch.full((len(idx), Lm), pad_id, dtype=torch.long)
|
| 113 |
+
msk = torch.zeros((len(idx), Lm), dtype=torch.bool)
|
| 114 |
+
att = torch.zeros((len(idx), Lm), dtype=torch.long)
|
| 115 |
+
for j, i in enumerate(idx):
|
| 116 |
+
s, m = seqs[i]
|
| 117 |
+
ids[j, :len(s)], msk[j, :len(s)], att[j, :len(s)] = torch.from_numpy(s), torch.from_numpy(m), 1
|
| 118 |
+
try:
|
| 119 |
+
h = model(input_ids=ids.to(dev), attention_mask=att.to(dev)).last_hidden_state.float()
|
| 120 |
+
except torch.OutOfMemoryError:
|
| 121 |
+
torch.cuda.empty_cache()
|
| 122 |
+
if budget <= Lm:
|
| 123 |
+
raise
|
| 124 |
+
budget //= 2
|
| 125 |
+
log(f" OOM -> token budget {budget}")
|
| 126 |
+
continue
|
| 127 |
+
mk = msk.to(dev).float()
|
| 128 |
+
E_mean[idx] = ((h * mk[..., None]).sum(1) / mk.sum(1, keepdim=True)).cpu().numpy()
|
| 129 |
+
last = att.sum(1).to(dev) - 1
|
| 130 |
+
E_last[idx] = h[torch.arange(len(idx), device=dev), last].cpu().numpy()
|
| 131 |
+
k += len(idx)
|
| 132 |
+
if k // 1000 > done:
|
| 133 |
+
done = k // 1000
|
| 134 |
+
log(f" {k}/{n} {time.time() - t0:.0f}s bs={len(idx)} L={Lm} peak {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
|
| 135 |
+
return E_mean, E_last
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def sanity(E, tag, organ=None):
|
| 139 |
+
rng = np.random.default_rng(0)
|
| 140 |
+
Z = E / np.linalg.norm(E, axis=1, keepdims=True)
|
| 141 |
+
s = Z[rng.choice(len(Z), min(200, len(Z)), replace=False)]
|
| 142 |
+
C = s @ s.T
|
| 143 |
+
mc = C[~np.eye(len(s), dtype=bool)].mean()
|
| 144 |
+
msg = f"[{tag}] finite={np.isfinite(E).all()} mean pairwise cos(200)={mc:.4f} std/dim={E.std(0).mean():.4f}"
|
| 145 |
+
if organ is not None:
|
| 146 |
+
Zc = Z - Z.mean(0)
|
| 147 |
+
Zc /= np.linalg.norm(Zc, axis=1, keepdims=True)
|
| 148 |
+
o = np.asarray(organ)
|
| 149 |
+
same, raw = [], []
|
| 150 |
+
for ZZ, acc in ((Zc, same), (Z, raw)):
|
| 151 |
+
Zt = torch.from_numpy(ZZ).to(dev)
|
| 152 |
+
for i in range(0, len(Zt), 2048):
|
| 153 |
+
S = Zt[i:i + 2048] @ Zt.T
|
| 154 |
+
S[torch.arange(S.shape[0]), torch.arange(i, i + S.shape[0])] = -2
|
| 155 |
+
nn = S.topk(10, dim=1).indices.cpu().numpy()
|
| 156 |
+
acc.append((o[nn] == o[i:i + S.shape[0], None]).mean(1))
|
| 157 |
+
cnt = pd.Series(o).value_counts()
|
| 158 |
+
base = ((cnt * (cnt - 1)).sum()) / (len(o) * (len(o) - 1))
|
| 159 |
+
msg += (f" 10-NN same-organ: {np.concatenate(raw).mean():.3f} (cos), {np.concatenate(same).mean():.3f}"
|
| 160 |
+
f" (centred cos) base rate {base:.3f}")
|
| 161 |
+
log(msg)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def main():
|
| 165 |
+
from transformers import Gemma2Model, PreTrainedTokenizerFast
|
| 166 |
+
from huggingface_hub import snapshot_download
|
| 167 |
+
sets = sys.argv[1:] or SETS
|
| 168 |
+
torch.cuda.set_per_process_memory_fraction(min(1.0, GPU_GB * 2**30 / torch.cuda.get_device_properties(0).total_memory))
|
| 169 |
+
tok = PreTrainedTokenizerFast.from_pretrained(TOK_DIR)
|
| 170 |
+
tok.padding_side = "right"
|
| 171 |
+
mdir = snapshot_download(REPO, revision=REV)
|
| 172 |
+
model, info = Gemma2Model.from_pretrained(mdir, dtype=torch.bfloat16, attn_implementation="eager",
|
| 173 |
+
output_loading_info=True)
|
| 174 |
+
log(f"loaded {REPO}@{REV}: missing={info['missing_keys']} unexpected={info['unexpected_keys']} "
|
| 175 |
+
f"mismatched={info['mismatched_keys']}")
|
| 176 |
+
assert not info["missing_keys"] and not info["mismatched_keys"] and not info["unexpected_keys"]
|
| 177 |
+
model.eval().to(dev)
|
| 178 |
+
log(f"weights {torch.cuda.memory_allocated() / 2**30:.2f} GB; prompt prefix tokens {len(tok(PREFIX)['input_ids'])}")
|
| 179 |
+
meta = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv"))
|
| 180 |
+
tot_seq, tot_t = 0, 0.0
|
| 181 |
+
for name in sets:
|
| 182 |
+
t0 = time.time()
|
| 183 |
+
X, genes = load(name)
|
| 184 |
+
sents, n_expr = cell_sentences(X, genes)
|
| 185 |
+
prompts, seqs = tokenize(tok, sents)
|
| 186 |
+
ntok = np.array([len(s[0]) for s in seqs])
|
| 187 |
+
nsent = np.array([m.sum() for _, m in seqs])
|
| 188 |
+
upg = np.char.upper(genes.astype(str))
|
| 189 |
+
nonhgnc = np.mean([("." in g) or g.startswith("ENSG") for g in upg])
|
| 190 |
+
log(f"== {name}: {X.shape[0]} spots x {X.shape[1]} genes (all symbols usable as text; {nonhgnc:.1%} non-HGNC-"
|
| 191 |
+
f"style ids e.g. ENSG / RP11-..), {len(set(upg))} unique upper-case symbols; expressed genes/spot median "
|
| 192 |
+
f"{np.median(n_expr):.0f}, spots with < {K} expressed genes {np.mean(n_expr < K):.1%}; sentence genes/spot "
|
| 193 |
+
f"median {np.median(np.minimum(n_expr, K)):.0f}; tokens/prompt median {np.median(ntok):.0f} "
|
| 194 |
+
f"(sentence tokens {np.median(nsent):.0f}, {np.median(nsent) / K:.2f} tok/gene)")
|
| 195 |
+
log(" example: " + prompts[0][:400].replace("\n", " | ") + " ...")
|
| 196 |
+
with open(os.path.join(ROOT, "ext", "cell2sentence", f"prompts_sample_{name}.txt"), "w") as f: # tokenizer cross-check
|
| 197 |
+
for i in np.random.default_rng(1).choice(len(prompts), min(200, len(prompts)), replace=False):
|
| 198 |
+
f.write(repr(prompts[i]) + "\n")
|
| 199 |
+
t1 = time.time()
|
| 200 |
+
E_mean, E_last = embed(model, seqs, tok.pad_token_id)
|
| 201 |
+
dt = time.time() - t1
|
| 202 |
+
tot_seq, tot_t = tot_seq + len(seqs), tot_t + dt
|
| 203 |
+
log(f" forward {dt:.0f}s = {len(seqs) / dt:.2f} seq/s, {ntok.sum() / dt:.0f} tok/s; total {time.time() - t0:.0f}s;"
|
| 204 |
+
f" peak GPU {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
|
| 205 |
+
save("c2s", name, E_mean)
|
| 206 |
+
save("c2s-last", name, E_last)
|
| 207 |
+
organ = meta.organ.values if name in ("hest", "hest_v") else None
|
| 208 |
+
if organ is not None:
|
| 209 |
+
assert len(organ) == len(E_mean)
|
| 210 |
+
sanity(E_mean, f"c2s {name}", organ)
|
| 211 |
+
sanity(E_last, f"c2s-last {name}", organ)
|
| 212 |
+
log(f"overall throughput {tot_seq / max(tot_t, 1e-9):.2f} seq/s over {tot_seq} sequences ({tot_t / 60:.1f} min)")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
main()
|
experiments/43_rnafm_nicheformer.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen Nicheformer spot embeddings of the 5 RNA sets (each Visium spot / virtual spot = one pseudo-cell).
|
| 2 |
+
|
| 3 |
+
python 43_rnafm_nicheformer.py [set ...] (default: all of rna_sets.SETS)
|
| 4 |
+
|
| 5 |
+
Model : theislab/Nicheformer (HF, MIT), revision 0ba4ba162f9ad3d6c8b713761954c31be2fa69ed, file model.safetensors
|
| 6 |
+
(12-layer post-norm nn.TransformerEncoder, d=512, 16 heads, FFN 1024, learnable PE, context 1500).
|
| 7 |
+
Downloaded to ext/nicheformer/hf/; the HF remote code (modeling_/tokenization_nicheformer.py) was read but is
|
| 8 |
+
NOT executed: the forward pass is re-implemented below (same nn.TransformerEncoderLayer stack) and loaded
|
| 9 |
+
with a strict state-dict load (the unused template `encoder_layer.*`, the MLM head and the `pos` buffer
|
| 10 |
+
are the only checkpoint keys skipped, and that set is asserted).
|
| 11 |
+
Technology means: ext/nicheformer/repo/data/model_means/dissociated_mean_script.npy from the official
|
| 12 |
+
GitHub repo theislab/nicheformer (commit 485cadbc5caa15119adfd54228f8a8af835fcabc); gene order =
|
| 13 |
+
model.h5ad var (20,310 Ensembl ids, token = column index + 30, == vocab.json).
|
| 14 |
+
Inputs : rna_sets.load(set) raw(-like) counts + HGNC symbols. Symbols -> Ensembl via cache/external/
|
| 15 |
+
dlpfc_genes_full.csv (10x GRCh38 reference), fallback Geneformer gene_name_id_dict_gc104M.pkl; genes not in
|
| 16 |
+
the Nicheformer vocabulary are dropped (several symbols hitting one Ensembl id are summed), i.e. the spot is
|
| 17 |
+
aligned to model.h5ad like the official `ad.concat([model, adata])` step.
|
| 18 |
+
Tokenising: official NicheformerDataset / tokenization notebooks: counts -> size-factor normalise to 1e4 (over the
|
| 19 |
+
aligned genes) -> divide by the technology mean (nan->0, rounded half-up, 0 -> 1, as in the notebooks) ->
|
| 20 |
+
rank the non-zero genes by descending value (stable sort; official uses numba quicksort, so only ties may
|
| 21 |
+
be ordered differently) -> token = gene index + 30, zero-padded to 1500. Context tokens prepended in the
|
| 22 |
+
official order [specie, assay, modality] and the sequence truncated to context_length 1500 (=> at most
|
| 23 |
+
1497 gene tokens). Pad 0 -> 1 and attention padding mask = (token == 1), as in models/_utils.complete_masking
|
| 24 |
+
with p = 0.
|
| 25 |
+
Context : Visium was not in the pretraining corpus (SpatialCorpus-110M = dissociated 10x/Smart-seq + MERFISH/CosMx/
|
| 26 |
+
Xenium/ISS) and has no technology token (the "visium": 9 entry in the get_embeddings notebook is a typo
|
| 27 |
+
for Xenium = 9 in every tokenisation notebook and the HF vocab). Chosen: specie = human (5),
|
| 28 |
+
modality = spatial (4), assay = 10x 3' v3 (11) -- Visium v1/v2 is a whole-transcriptome poly(A) 3'
|
| 29 |
+
sequencing assay, so the closest documented technology is 10x 3' v3, and its matching technology mean is
|
| 30 |
+
the dissociated (10x) one (the image-based means only cover their 300-1000-gene panels). A sensitivity
|
| 31 |
+
run with modality = dissociated (3) is logged for hest only (not saved).
|
| 32 |
+
Readout : official Nicheformer.get_embeddings(layer=-1, with_context=False): last-layer output, drop the 3 context
|
| 33 |
+
positions, mean over the remaining 1497 positions (this INCLUDES padding positions, exactly as the
|
| 34 |
+
official code; only spots with < 1497 detected vocabulary genes are affected and the fraction is logged).
|
| 35 |
+
D = 512, not L2-normalised. bf16 autocast inference.
|
| 36 |
+
Output : rna_sets.save("nicheformer", set, E) -> cache/rnafm/nicheformer_<set>.npy (n_spot, 512) float16.
|
| 37 |
+
Sanity : finite, mean pairwise cosine of 200 random spots, and (hest) organ agreement of cosine 10-NN vs base rate.
|
| 38 |
+
"""
|
| 39 |
+
import hashlib
|
| 40 |
+
import json
|
| 41 |
+
import os
|
| 42 |
+
import pickle
|
| 43 |
+
import sys
|
| 44 |
+
import time
|
| 45 |
+
|
| 46 |
+
import anndata as ad
|
| 47 |
+
import numpy as np
|
| 48 |
+
import pandas as pd
|
| 49 |
+
import scipy.sparse as sp
|
| 50 |
+
import torch
|
| 51 |
+
import torch.nn as nn
|
| 52 |
+
from safetensors.torch import load_file
|
| 53 |
+
|
| 54 |
+
from common import CACHE, ROOT
|
| 55 |
+
from rna_sets import SETS, load, save
|
| 56 |
+
|
| 57 |
+
MODEL = "nicheformer"
|
| 58 |
+
REPO, REV = "theislab/Nicheformer", "0ba4ba162f9ad3d6c8b713761954c31be2fa69ed"
|
| 59 |
+
GH_COMMIT = "485cadbc5caa15119adfd54228f8a8af835fcabc"
|
| 60 |
+
EXT = os.path.join(ROOT, "ext", "nicheformer")
|
| 61 |
+
HF_DIR, GH_DIR = os.path.join(EXT, "hf"), os.path.join(EXT, "repo")
|
| 62 |
+
PAD, AUX, CTX_LEN = 1, 30, 1500
|
| 63 |
+
SPECIE_HUMAN, ASSAY_10X3V3, MOD_SPATIAL, MOD_DISSOC = 5, 11, 4, 3
|
| 64 |
+
dev = "cuda"
|
| 65 |
+
torch.manual_seed(0)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def sha(p):
|
| 69 |
+
return hashlib.sha256(open(p, "rb").read()).hexdigest()[:16]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def ensure_download():
|
| 73 |
+
if not os.path.exists(os.path.join(HF_DIR, "model.safetensors")):
|
| 74 |
+
from huggingface_hub import snapshot_download
|
| 75 |
+
snapshot_download(REPO, revision=REV, local_dir=HF_DIR)
|
| 76 |
+
assert os.path.exists(os.path.join(GH_DIR, "data", "model_means", "dissociated_mean_script.npy")), \
|
| 77 |
+
f"git clone https://github.com/theislab/nicheformer {GH_DIR} (commit {GH_COMMIT})"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class Nicheformer(nn.Module):
|
| 81 |
+
"""Re-implementation of NicheformerModel (HF modeling_nicheformer.py / official models/_nicheformer.py)."""
|
| 82 |
+
|
| 83 |
+
def __init__(self, cfg):
|
| 84 |
+
super().__init__()
|
| 85 |
+
self.embeddings = nn.Embedding(cfg["n_tokens"] + 5, cfg["dim_model"], padding_idx=1)
|
| 86 |
+
self.positional_embedding = nn.Embedding(cfg["context_length"], cfg["dim_model"])
|
| 87 |
+
layer = nn.TransformerEncoderLayer(cfg["dim_model"], cfg["nheads"], cfg["dim_feedforward"], dropout=0.0,
|
| 88 |
+
batch_first=True, layer_norm_eps=1e-12)
|
| 89 |
+
self.encoder = nn.TransformerEncoder(layer, cfg["nlayers"], enable_nested_tensor=False)
|
| 90 |
+
|
| 91 |
+
def get_embeddings(self, ids):
|
| 92 |
+
pad = ids.eq(PAD)
|
| 93 |
+
x = self.embeddings(ids) + self.positional_embedding(torch.arange(ids.shape[1], device=ids.device))
|
| 94 |
+
for blk in self.encoder.layers:
|
| 95 |
+
x = blk(x, src_key_padding_mask=pad, is_causal=False)
|
| 96 |
+
return x[:, 3:].float().mean(1) # with_context=False, mean incl. pad positions (official)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def load_model():
|
| 100 |
+
cfg = json.load(open(os.path.join(HF_DIR, "config.json")))
|
| 101 |
+
assert cfg["learnable_pe"] and cfg["specie"] and cfg["assay"] and cfg["modality"]
|
| 102 |
+
sd = load_file(os.path.join(HF_DIR, "model.safetensors"))
|
| 103 |
+
keep = {k[len("nicheformer."):]: v for k, v in sd.items()
|
| 104 |
+
if k.startswith("nicheformer.") and not k.startswith("nicheformer.encoder_layer.") and k != "nicheformer.pos"}
|
| 105 |
+
skipped = sorted(set(sd) - {"nicheformer." + k for k in keep})
|
| 106 |
+
assert all(k.startswith(("nicheformer.encoder_layer.", "classifier_head.")) or k == "nicheformer.pos"
|
| 107 |
+
for k in skipped), skipped
|
| 108 |
+
m = Nicheformer(cfg)
|
| 109 |
+
m.load_state_dict(keep, strict=True)
|
| 110 |
+
print(f"weights {sha(os.path.join(HF_DIR, 'model.safetensors'))}: loaded {len(keep)} tensors strictly; "
|
| 111 |
+
f"skipped {len(skipped)} (template encoder_layer / MLM head / pos buffer)", flush=True)
|
| 112 |
+
return m.eval().to(dev), cfg
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def gene_reference():
|
| 116 |
+
ref = ad.read_h5ad(os.path.join(HF_DIR, "model.h5ad"), backed=None)
|
| 117 |
+
ens = np.asarray(ref.var_names, dtype=str)
|
| 118 |
+
vocab = json.load(open(os.path.join(HF_DIR, "vocab.json")))
|
| 119 |
+
assert all(vocab[g] == i + AUX for i, g in enumerate(ens))
|
| 120 |
+
mean = np.load(os.path.join(GH_DIR, "data", "model_means", "dissociated_mean_script.npy"))
|
| 121 |
+
assert mean.shape == ens.shape
|
| 122 |
+
mean = np.nan_to_num(mean)
|
| 123 |
+
r = np.where((mean % 1) >= 0.5, np.ceil(mean), np.floor(mean))
|
| 124 |
+
mean = np.where(r == 0, 1, r)
|
| 125 |
+
sym2ens = dict(pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv"))[["gene", "ensembl_id"]].values)
|
| 126 |
+
from huggingface_hub import hf_hub_download
|
| 127 |
+
gf = pickle.load(open(hf_hub_download("ctheodoris/Geneformer", "geneformer/gene_name_id_dict_gc104M.pkl"), "rb"))
|
| 128 |
+
for k, v in gf.items():
|
| 129 |
+
sym2ens.setdefault(k, v)
|
| 130 |
+
return ens, mean, sym2ens
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def tokenize(X, genes, ens, mean, sym2ens, modality):
|
| 134 |
+
e2i = {e: i for i, e in enumerate(ens)}
|
| 135 |
+
src = [(j, e2i[sym2ens[g]]) for j, g in enumerate(genes) if g in sym2ens and sym2ens[g] in e2i]
|
| 136 |
+
rows, cols = zip(*src)
|
| 137 |
+
M = sp.csr_matrix((np.ones(len(src), np.float32), (rows, cols)), shape=(len(genes), len(ens)))
|
| 138 |
+
A = (X @ M).tocsr() # spot x model genes (aligned like concat with model)
|
| 139 |
+
lib = np.asarray(A.sum(1)).ravel()
|
| 140 |
+
cov = dict(n_in=len(genes), n_mapped=len(src), n_model_genes=int((np.asarray(M.sum(0)).ravel() > 0).sum()),
|
| 141 |
+
umi_frac=float(np.median(lib / np.maximum(np.asarray(X.sum(1)).ravel(), 1))))
|
| 142 |
+
lib = lib + (lib == 0)
|
| 143 |
+
A = (sp.diags(1e4 / lib) @ A).tocsr()
|
| 144 |
+
ids = np.full((A.shape[0], CTX_LEN), PAD, np.int64)
|
| 145 |
+
ids[:, :3] = [SPECIE_HUMAN, ASSAY_10X3V3, modality]
|
| 146 |
+
ngen = np.zeros(A.shape[0], int)
|
| 147 |
+
for r in range(A.shape[0]):
|
| 148 |
+
s, e = A.indptr[r], A.indptr[r + 1]
|
| 149 |
+
nz, v = A.indices[s:e], A.data[s:e] / mean[A.indices[s:e]]
|
| 150 |
+
keep = v > 0
|
| 151 |
+
nz, v = nz[keep], v[keep]
|
| 152 |
+
order = nz[np.argsort(-v, kind="stable")][:CTX_LEN - 3]
|
| 153 |
+
ids[r, 3:3 + len(order)] = order + AUX
|
| 154 |
+
ngen[r] = len(order)
|
| 155 |
+
cov["expr_genes_median"] = float(np.median(np.diff(A.indptr)))
|
| 156 |
+
cov["tokens_median"] = float(np.median(ngen))
|
| 157 |
+
cov["frac_spots_padded"] = float((ngen < CTX_LEN - 3).mean())
|
| 158 |
+
return ids, cov
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@torch.no_grad()
|
| 162 |
+
def embed(model, ids, bs=32):
|
| 163 |
+
out = np.zeros((len(ids), 512), np.float32)
|
| 164 |
+
i = 0
|
| 165 |
+
while i < len(ids):
|
| 166 |
+
try:
|
| 167 |
+
b = torch.from_numpy(ids[i:i + bs]).to(dev)
|
| 168 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 169 |
+
out[i:i + bs] = model.get_embeddings(b).cpu().numpy()
|
| 170 |
+
i += bs
|
| 171 |
+
except torch.OutOfMemoryError:
|
| 172 |
+
torch.cuda.empty_cache()
|
| 173 |
+
bs = max(1, bs // 2)
|
| 174 |
+
print(f" OOM -> batch {bs}", flush=True)
|
| 175 |
+
return out
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def sanity(E, name):
|
| 179 |
+
rng = np.random.default_rng(0)
|
| 180 |
+
S = E[rng.choice(len(E), min(200, len(E)), replace=False)]
|
| 181 |
+
S = S / np.linalg.norm(S, axis=1, keepdims=True)
|
| 182 |
+
C = S @ S.T
|
| 183 |
+
mc = float(C[np.triu_indices(len(S), 1)].mean())
|
| 184 |
+
msg = f"[{MODEL}] {name}: finite={np.isfinite(E).all()} std={E.std(0).mean():.4f} mean-pair-cos(200)={mc:.4f}"
|
| 185 |
+
if name == "hest":
|
| 186 |
+
org = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv")).organ.values
|
| 187 |
+
assert len(org) == len(E)
|
| 188 |
+
Z = torch.from_numpy(E / np.linalg.norm(E, axis=1, keepdims=True)).to(dev)
|
| 189 |
+
nn_idx = []
|
| 190 |
+
for k in range(0, len(Z), 2048):
|
| 191 |
+
s = Z[k:k + 2048] @ Z.T
|
| 192 |
+
s[torch.arange(s.shape[0]), torch.arange(k, k + s.shape[0])] = -2
|
| 193 |
+
nn_idx.append(s.topk(10, dim=1).indices.cpu().numpy())
|
| 194 |
+
nn_idx = np.concatenate(nn_idx)
|
| 195 |
+
knn = float((org[nn_idx] == org[:, None]).mean())
|
| 196 |
+
p = pd.Series(org).value_counts(normalize=True).values
|
| 197 |
+
msg += f" 10-NN same-organ={knn:.3f} (base rate {float((p ** 2).sum()):.3f})"
|
| 198 |
+
print(msg, flush=True)
|
| 199 |
+
return mc
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def main():
|
| 203 |
+
sets = sys.argv[1:] or SETS
|
| 204 |
+
t0 = time.time()
|
| 205 |
+
ensure_download()
|
| 206 |
+
model, cfg = load_model()
|
| 207 |
+
ens, mean, sym2ens = gene_reference()
|
| 208 |
+
print(f"context tokens: specie=human({SPECIE_HUMAN}) assay=10x 3' v3({ASSAY_10X3V3}) modality=spatial({MOD_SPATIAL}); "
|
| 209 |
+
f"technology mean = dissociated_mean_script.npy (GitHub {GH_COMMIT[:7]})", flush=True)
|
| 210 |
+
for name in sets:
|
| 211 |
+
t1 = time.time()
|
| 212 |
+
X, genes = load(name)
|
| 213 |
+
ids, cov = tokenize(X, genes, ens, mean, sym2ens, MOD_SPATIAL)
|
| 214 |
+
print(f"[{MODEL}] {name}: {X.shape[0]} spots; genes in {cov['n_in']}, mapped to vocab {cov['n_mapped']} "
|
| 215 |
+
f"({cov['n_mapped'] / cov['n_in']:.1%}) -> {cov['n_model_genes']} model genes; median UMI fraction kept "
|
| 216 |
+
f"{cov['umi_frac']:.3f}; expressed vocab genes/spot median {cov['expr_genes_median']:.0f}; gene tokens "
|
| 217 |
+
f"median {cov['tokens_median']:.0f}; spots with padding {cov['frac_spots_padded']:.1%}", flush=True)
|
| 218 |
+
E = embed(model, ids)
|
| 219 |
+
sanity(E, name)
|
| 220 |
+
save(MODEL, name, E)
|
| 221 |
+
if name == "hest": # sensitivity: modality token = dissociated
|
| 222 |
+
ids[:, 2] = MOD_DISSOC
|
| 223 |
+
E2 = embed(model, ids)
|
| 224 |
+
c = (E * E2).sum(1) / np.linalg.norm(E, axis=1) / np.linalg.norm(E2, axis=1)
|
| 225 |
+
print(f" sensitivity modality=dissociated(3): cosine to saved per spot median {np.median(c):.4f}", flush=True)
|
| 226 |
+
sanity(E2, "hest")
|
| 227 |
+
print(f"[{MODEL}] {name}: {time.time() - t1:.0f}s", flush=True)
|
| 228 |
+
print(f"[{MODEL}] done {time.time() - t0:.0f}s; peak GPU {torch.cuda.max_memory_allocated() / 2**30:.2f} GB", flush=True)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
main()
|
experiments/44_rnafm_eval.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RNA encoder comparison + fusion under the final protocol (real pairs only; virtual data only at test time).
|
| 2 |
+
|
| 3 |
+
RNA encoders (frozen inputs, cache/rnafm/<enc>_<set>.npy from 43_*.py; "hvg" = log1p CP10k of the 2000 HEST HVGs,
|
| 4 |
+
identical to 42_train_regimes.py):
|
| 5 |
+
hvg, scgpt, geneformer, nicheformer, tahoex1, aidocell, se600m, c2s, c2s-last, genept-bge, genept-bmc (whatever exists)
|
| 6 |
+
cat:<a>+<b>+... early fusion: concatenation of the z-scored inputs, each block scaled by 1/sqrt(its dim)
|
| 7 |
+
ens:<a>+<b>+... late fusion: mean of the L2-normalised outputs of the separately trained single-encoder heads
|
| 8 |
+
(possible because every head maps into the same frozen UNI2 space)
|
| 9 |
+
Head: linear or MLP (42_'s mlp) from the encoder input to the frozen UNI2 space, InfoNCE, AdamW 3e-4 / wd 0.05 /
|
| 10 |
+
OneCycle, batch 256, 2700 steps, early stopping on val R@1 every 50 steps (= 42_ S1/S4 RNA recipe).
|
| 11 |
+
FM inputs are z-scored with the HEST TRAINING-spot mean / std of fold k (applied unchanged to virtual / external sets).
|
| 12 |
+
Per fold k (splits/final/hest_fold<k>.csv, ORION splits/patient_5fold, TCGA splits/final/tcga_fold<k>.csv):
|
| 13 |
+
trained H&E<->RNA HEST test spots (real)
|
| 14 |
+
emergent mIF<->RNA(virtual) ORION test tiles, mIF = S4 mIF MLP head of fold k (trained here once, cached)
|
| 15 |
+
emergent text<->RNA(virtual) TCGA test images, text = S4 linear text head of fold k (clean real text)
|
| 16 |
+
calib H&E<->RNA(virtual) HEST test spots with STPath RNA instead of the real one (circular, reference only)
|
| 17 |
+
--dlpfc: additionally train on HEST WITHOUT the spatialLIBD study (val = 10% of groups) and test H&E<->RNA on the
|
| 18 |
+
external DLPFC set (482 spots; FM inputs z-scored per dataset as in 38_), mixed galleries G=100.
|
| 19 |
+
Usage: python 44_rnafm_eval.py [--encs ...] [--heads linear mlp] [--folds 0 1 2 3 4] [--dlpfc]
|
| 20 |
+
Output results/rnafm_long.csv (resumable), results/rnafm_summary.csv
|
| 21 |
+
"""
|
| 22 |
+
import argparse
|
| 23 |
+
import os
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import torch
|
| 28 |
+
import torch.nn as nn
|
| 29 |
+
import torch.nn.functional as F
|
| 30 |
+
from scipy.optimize import linear_sum_assignment
|
| 31 |
+
|
| 32 |
+
from common import CACHE, ROOT, SPLITS
|
| 33 |
+
|
| 34 |
+
ap = argparse.ArgumentParser()
|
| 35 |
+
ap.add_argument("--encs", nargs="+", default=None)
|
| 36 |
+
ap.add_argument("--heads", nargs="+", default=["linear", "mlp"])
|
| 37 |
+
ap.add_argument("--folds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
|
| 38 |
+
ap.add_argument("--dlpfc", action="store_true")
|
| 39 |
+
args = ap.parse_args()
|
| 40 |
+
dev = "cuda"
|
| 41 |
+
H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
|
| 42 |
+
G100, DRAWS, BS, LR, EVAL_EVERY, STEPS = 100, 50, 256, 3e-4, 50, 2700
|
| 43 |
+
f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
|
| 44 |
+
SETS = ["hest", "hest_v", "orion_v", "tcga_v", "dlpfc"]
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------- anchors / other modalities (= 42_)
|
| 47 |
+
HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
|
| 48 |
+
IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
|
| 49 |
+
HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
|
| 50 |
+
HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
|
| 51 |
+
HE_D = f32(np.load(os.path.join(CACHE, "external", "dlpfc_he_uni2.npy"))).to(dev)
|
| 52 |
+
HE_D = HE_D[:, 0] if HE_D.dim() == 3 else HE_D
|
| 53 |
+
TXT = f32(np.load(os.path.join(CACHE, "alltxt_text_biomedclip.npy"))[:, 0]).to(dev)
|
| 54 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 55 |
+
T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
|
| 56 |
+
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
|
| 57 |
+
HM = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 58 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def hvg_inputs():
|
| 62 |
+
def virt(st):
|
| 63 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 64 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 65 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 66 |
+
return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
|
| 67 |
+
eg = pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv")).gene.values
|
| 68 |
+
ec = np.load(os.path.join(CACHE, "external", "dlpfc_counts_full.npy")).astype(np.float32)
|
| 69 |
+
ei = pd.Series(range(len(eg)), index=eg); ei = ei[~ei.index.duplicated()]
|
| 70 |
+
d = np.log1p(ec / ec.sum(1, keepdims=True).clip(1) * 1e4)
|
| 71 |
+
d = np.stack([d[:, ei[g]] if g in ei.index else np.zeros(len(d), np.float32) for g in hvg], 1)
|
| 72 |
+
return {"hest": np.load(os.path.join(H, "hvg.npy")), "hest_v": virt("hest_val"), "orion_v": virt("orion"),
|
| 73 |
+
"tcga_v": virt("tcga_text"), "dlpfc": d}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_enc(e):
|
| 77 |
+
e = e.split("@")[0] # "<enc>@<n>": same input, head trained with another seed (seed-ensemble control)
|
| 78 |
+
if e == "hvg":
|
| 79 |
+
return {s: f32(x).to(dev) for s, x in hvg_inputs().items()}
|
| 80 |
+
return {s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in SETS}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def mlp(din, dout=1536, hid=512):
|
| 84 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 85 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def one_text_per_image(t_ids, rng):
|
| 89 |
+
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 90 |
+
return pick.index.values, pick.values
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def g100(a, b, seed=0): # = 33_ / 42_
|
| 94 |
+
a, b = a.cpu(), b.cpu()
|
| 95 |
+
rng = np.random.default_rng(seed)
|
| 96 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 97 |
+
for _ in range(DRAWS):
|
| 98 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 99 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 100 |
+
for S in (sim, sim.T):
|
| 101 |
+
rank = (S > d[:, None]).sum(1)
|
| 102 |
+
for k_ in (1, 5, 10):
|
| 103 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 104 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 105 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 106 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 107 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def r1(a, b):
|
| 111 |
+
s = a @ b.T
|
| 112 |
+
return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def train_head(X_tr, A_tr, X_va, A_va, kind, steps, seed, pos_tr=None):
|
| 116 |
+
"""modality head X -> frozen anchor space A (rows paired). A_* may be (n, V, 1536) -> random view per sample."""
|
| 117 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 118 |
+
h = (mlp(X_tr.shape[1]) if kind == "mlp" else nn.Linear(X_tr.shape[1], 1536)).to(dev)
|
| 119 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 120 |
+
opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
|
| 121 |
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
|
| 122 |
+
za_va = F.normalize(A_va[:, 0] if A_va.dim() == 3 else A_va, dim=-1)
|
| 123 |
+
best, state = -1, None
|
| 124 |
+
for step in range(1, steps + 1):
|
| 125 |
+
h.train()
|
| 126 |
+
b = torch.as_tensor(rng.choice(len(X_tr), BS, replace=False), device=dev)
|
| 127 |
+
a = A_tr[b, torch.randint(0, A_tr.shape[1], (BS,), device=dev)] if A_tr.dim() == 3 else A_tr[b]
|
| 128 |
+
lg = logit.exp().clamp(max=100) * F.normalize(a, dim=-1) @ F.normalize(h(X_tr[b]), dim=-1).T
|
| 129 |
+
if pos_tr is None:
|
| 130 |
+
t = torch.arange(BS, device=dev)
|
| 131 |
+
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 132 |
+
else:
|
| 133 |
+
ii = pos_tr[b]; pos = (ii[:, None] == ii[None, :]).float(); tgt = pos / pos.sum(1, keepdim=True)
|
| 134 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 135 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
|
| 136 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 137 |
+
h.eval()
|
| 138 |
+
with torch.no_grad():
|
| 139 |
+
v = r1(za_va, F.normalize(h(X_va), dim=-1))
|
| 140 |
+
if v > best:
|
| 141 |
+
best, state = v, {k_: x.detach().clone() for k_, x in h.state_dict().items()}
|
| 142 |
+
h.load_state_dict(state); h.eval()
|
| 143 |
+
return h, best
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class Fold:
|
| 147 |
+
def __init__(self, k):
|
| 148 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 149 |
+
self.o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
|
| 150 |
+
mu, sd = IF_O[self.o["train"]].mean((0, 1)), IF_O[self.o["train"]].std((0, 1)) + 1e-3
|
| 151 |
+
self.IF = (IF_O - mu) / sd
|
| 152 |
+
h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
|
| 153 |
+
self.h = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
|
| 154 |
+
sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
|
| 155 |
+
tsp = sp.loc[T.img_idx].values
|
| 156 |
+
self.t = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
|
| 157 |
+
self.t_va = one_text_per_image(self.t["val"], np.random.default_rng(0))
|
| 158 |
+
self.t_te = one_text_per_image(self.t["test"], np.random.default_rng(k))
|
| 159 |
+
# DLPFC-held-out training pool (= 38_)
|
| 160 |
+
pool = np.flatnonzero(~HM.group.str.startswith("spatialLIBD").values)
|
| 161 |
+
grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
|
| 162 |
+
isva = HM.group.iloc[pool].isin(set(grp[: max(1, len(grp) // 10)])).values
|
| 163 |
+
self.d = {"train": pool[~isva], "val": pool[isva]}
|
| 164 |
+
|
| 165 |
+
def other_heads(self, k):
|
| 166 |
+
"""S4 heads of fold k for mIF (MLP) and text (linear); cached in runs/rnafm/other_fold<k>.pt"""
|
| 167 |
+
f = os.path.join(ROOT, "runs", "rnafm", f"other_fold{k}.pt")
|
| 168 |
+
hm, ht = mlp(512).to(dev), nn.Linear(512, 1536).to(dev)
|
| 169 |
+
if os.path.exists(f):
|
| 170 |
+
sd = torch.load(f, map_location=dev); hm.load_state_dict(sd["mif"]); ht.load_state_dict(sd["text"])
|
| 171 |
+
return hm.eval(), ht.eval()
|
| 172 |
+
o, t = self.o, self.t
|
| 173 |
+
hm, _ = train_head(self.IF[o["train"]].flatten(0, 1), HE_O[o["train"]].flatten(0, 1),
|
| 174 |
+
self.IF[o["val"], 0], HE_O[o["val"], 0], "mlp", 400, k)
|
| 175 |
+
ii, tt = self.t_va
|
| 176 |
+
ht, _ = train_head(TXT[t["train"]], HE_T[T_IMG[torch.as_tensor(t["train"], device=dev)]],
|
| 177 |
+
TXT[torch.as_tensor(tt, device=dev)], HE_T[torch.as_tensor(ii, device=dev), 0],
|
| 178 |
+
"linear", 1000, k, pos_tr=T_IMG[torch.as_tensor(t["train"], device=dev)])
|
| 179 |
+
os.makedirs(os.path.dirname(f), exist_ok=True)
|
| 180 |
+
torch.save({"mif": hm.state_dict(), "text": ht.state_dict()}, f)
|
| 181 |
+
return hm, ht
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def prep(X, tr, per_dataset_ext=False):
|
| 185 |
+
"""z-score with HEST-train stats (hvg: unchanged, = 42_). Returns dict set -> tensor."""
|
| 186 |
+
if X is None:
|
| 187 |
+
return None
|
| 188 |
+
mu, sd = X["hest"][tr].mean(0), X["hest"][tr].std(0) + 1e-6
|
| 189 |
+
out = {s: (x - mu) / sd for s, x in X.items()}
|
| 190 |
+
if per_dataset_ext:
|
| 191 |
+
out["dlpfc"] = (X["dlpfc"] - X["dlpfc"].mean(0)) / (X["dlpfc"].std(0) + 1e-6)
|
| 192 |
+
return out
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def build(enc, fold_tr, dl_tr=None):
|
| 196 |
+
"""input tensors per set for encoder spec enc; hvg kept raw (42_), FMs / fusions z-scored."""
|
| 197 |
+
parts = enc.split(":", 1)[1].split("+") if enc.startswith("cat:") else [enc]
|
| 198 |
+
main, ext = [], []
|
| 199 |
+
for p in parts:
|
| 200 |
+
X = CACHE_ENC[p]
|
| 201 |
+
if p.split("@")[0] == "hvg" and len(parts) == 1:
|
| 202 |
+
main.append(X)
|
| 203 |
+
if dl_tr is not None:
|
| 204 |
+
ext.append(prep(X, dl_tr, True))
|
| 205 |
+
continue
|
| 206 |
+
w = 1 / np.sqrt(X["hest"].shape[1]) if len(parts) > 1 else 1.0
|
| 207 |
+
main.append({s: v * w for s, v in prep(X, fold_tr).items()})
|
| 208 |
+
if dl_tr is not None:
|
| 209 |
+
ext.append({s: v * w for s, v in prep(X, dl_tr, True).items()})
|
| 210 |
+
cat = lambda L: {s: torch.cat([d[s] for d in L], 1) for s in SETS}
|
| 211 |
+
return cat(main), (cat(ext) if dl_tr is not None else None)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ---------------------------------------------------------------- run
|
| 215 |
+
avail = ["hvg"] + sorted({f.rsplit("_", 1)[0] for f in os.listdir(RF) if f.endswith("_hest.npy")
|
| 216 |
+
and all(os.path.exists(os.path.join(RF, f.replace("_hest.npy", f"_{s}.npy"))) for s in SETS)})
|
| 217 |
+
encs = sorted(args.encs or avail, key=lambda e: e.startswith("ens:")) # late fusions after their single heads
|
| 218 |
+
singles = sorted({p for e in encs for p in (e.split(":", 1)[1].split("+") if ":" in e else [e])})
|
| 219 |
+
CACHE_ENC = {e: load_enc(e) for e in singles}
|
| 220 |
+
print("encoders:", {e: CACHE_ENC[e]["hest"].shape[1] for e in singles}, flush=True)
|
| 221 |
+
f_out = os.path.join(ROOT, "results", "rnafm_long.csv")
|
| 222 |
+
rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
|
| 223 |
+
done = {(r["enc"], r["head"], r["fold"]) for r in rows}
|
| 224 |
+
nz = lambda x: F.normalize(x, dim=-1)
|
| 225 |
+
for k in args.folds:
|
| 226 |
+
F_ = Fold(k)
|
| 227 |
+
hm, ht = F_.other_heads(k)
|
| 228 |
+
te_o = F_.o["test"]; ii, tt = (torch.as_tensor(x, device=dev) for x in F_.t_te)
|
| 229 |
+
with torch.no_grad():
|
| 230 |
+
z_mif = nz(hm(F_.IF[te_o, 0])); z_txt = nz(ht(TXT[tt]))
|
| 231 |
+
heads_out = {} # (enc, head) -> dict of output embeddings, for late fusion
|
| 232 |
+
for enc in encs:
|
| 233 |
+
for hk in args.heads:
|
| 234 |
+
parts = enc.split(":", 1)[1].split("+") if enc.startswith("ens:") else None
|
| 235 |
+
need = any(enc in e.split(":", 1)[1].split("+") for e in encs if e.startswith("ens:"))
|
| 236 |
+
if (enc, hk, k) in done and parts is None and not need:
|
| 237 |
+
continue
|
| 238 |
+
tr, va, te = F_.h["train"], F_.h["val"], F_.h["test"]
|
| 239 |
+
if parts is not None: # late fusion of single-encoder heads trained in this run
|
| 240 |
+
if not all((p, hk) in heads_out for p in parts) or (enc, hk, k) in done:
|
| 241 |
+
continue
|
| 242 |
+
Z = {s: nz(sum(heads_out[(p, hk)][s] for p in parts)) for s in heads_out[(parts[0], hk)]}
|
| 243 |
+
best = float("nan")
|
| 244 |
+
else:
|
| 245 |
+
X, Xd = build(enc, tr, F_.d["train"] if args.dlpfc else None)
|
| 246 |
+
sd_ = k + 1000 * int(enc.split("@")[1]) if "@" in enc else k
|
| 247 |
+
h, best = train_head(X["hest"][tr], HE_H[tr], X["hest"][va], HE_H[va], hk, STEPS, sd_)
|
| 248 |
+
with torch.no_grad():
|
| 249 |
+
Z = {"hest": nz(h(X["hest"][te])), "hest_v": nz(h(X["hest_v"][te])),
|
| 250 |
+
"orion_v": nz(h(X["orion_v"][te_o])), "tcga_v": nz(h(X["tcga_v"][ii]))}
|
| 251 |
+
if args.dlpfc:
|
| 252 |
+
hd, _ = train_head(Xd["hest"][F_.d["train"]], HE_H[F_.d["train"]], Xd["hest"][F_.d["val"]],
|
| 253 |
+
HE_H[F_.d["val"]], hk, STEPS, sd_)
|
| 254 |
+
with torch.no_grad():
|
| 255 |
+
Z["dlpfc"] = nz(hd(Xd["dlpfc"]))
|
| 256 |
+
heads_out[(enc, hk)] = Z
|
| 257 |
+
res = {("H&E<->RNA (HEST)", "trained"): g100(nz(HE_H[te]), Z["hest"]),
|
| 258 |
+
("mIF<->RNA(virtual) (ORION)", "emergent"): g100(z_mif, Z["orion_v"]),
|
| 259 |
+
("text<->RNA(virtual) (TCGA)", "emergent"): g100(z_txt, Z["tcga_v"]),
|
| 260 |
+
("H&E<->RNA(virtual) (HEST)", "calib"): g100(nz(HE_H[te]), Z["hest_v"])}
|
| 261 |
+
if "dlpfc" in Z:
|
| 262 |
+
res[("H&E<->RNA (DLPFC external)", "external")] = g100(nz(HE_D), Z["dlpfc"])
|
| 263 |
+
if (enc, hk, k) in done: # retrained only to feed a late fusion
|
| 264 |
+
continue
|
| 265 |
+
for (pair, kind), r in res.items():
|
| 266 |
+
rows.append({"enc": enc, "head": hk, "fold": k, "pair": pair, "kind": kind, "val_R@1": best, **r})
|
| 267 |
+
pd.DataFrame(rows).to_csv(f_out, index=False)
|
| 268 |
+
print(f"fold {k} {enc:<34} {hk:<6} val {best:.3f} " +
|
| 269 |
+
" ".join(f"{p.split(' (')[0]} {r['R@1']:.3f}" for (p, _), r in res.items()), flush=True)
|
| 270 |
+
|
| 271 |
+
R = pd.DataFrame(rows)
|
| 272 |
+
S = R.groupby(["enc", "head", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
|
| 273 |
+
S.to_csv(os.path.join(ROOT, "results", "rnafm_summary.csv"))
|
| 274 |
+
pd.set_option("display.width", 250)
|
| 275 |
+
print(R.pivot_table(index=["enc", "head"], columns="pair", values="R@1", sort=False).round(3).to_string())
|
experiments/46_text_enc_eval.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text encoder comparison + fusion under the final protocol (clean real TCGA texts only; synthetic / virtual = test only).
|
| 2 |
+
|
| 3 |
+
Text encoders (frozen, cache/alltxt_text_<enc>.npy and cache/orion_gentext_text_<enc>.npy; 45_ / 45b_ / 08_):
|
| 4 |
+
biomedclip (current; raw features as in 42_), conchclip, plip, medcpt, biolord, pubmedbert, medembed, bge, qwen3-0.6b,
|
| 5 |
+
qwen3-4b, keep, quiltnet (whatever exists for both sets). Non-baseline inputs are z-scored with TCGA-train stats.
|
| 6 |
+
cat:<a>+<b> early fusion (z-scored blocks scaled by 1/sqrt(dim), one head)
|
| 7 |
+
ens:<a>+<b> late fusion (mean of the L2-normalised outputs of the single-encoder heads; same frozen UNI2 space)
|
| 8 |
+
Head: linear or MLP into the frozen UNI2 space, multi-positive InfoNCE (several texts per image), AdamW 3e-4 / wd 0.05 /
|
| 9 |
+
OneCycle, batch 256, 1000 steps, early stopping on val R@1 every 50 steps (= 42_ S4 text recipe).
|
| 10 |
+
Per fold k (splits/final/tcga_fold<k>.csv; one random text per test image, = 33_/42_):
|
| 11 |
+
trained H&E<->text TCGA test images (real text)
|
| 12 |
+
emergent mIF<->text(synthetic) ORION patient_5fold test tiles, mIF = S4 MLP head (runs/rnafm/other_fold<k>.pt, 44_)
|
| 13 |
+
emergent text<->RNA(virtual) TCGA test images, RNA = hvg linear head of fold k (runs/rnafm/rna_hvg_fold<k>.pt)
|
| 14 |
+
emergent text<->mIF(MIPHEI) TCGA test images, MIPHEI virtual mIF through the same mIF head
|
| 15 |
+
Usage: python 46_text_enc_eval.py [--encs ...] [--heads linear mlp] [--folds 0 1 2 3 4]
|
| 16 |
+
Output results/textenc_long.csv (resumable), results/textenc_summary.csv
|
| 17 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn as nn
|
| 25 |
+
import torch.nn.functional as F
|
| 26 |
+
from scipy.optimize import linear_sum_assignment
|
| 27 |
+
|
| 28 |
+
from common import CACHE, ROOT, SPLITS
|
| 29 |
+
|
| 30 |
+
ap = argparse.ArgumentParser()
|
| 31 |
+
ap.add_argument("--encs", nargs="+", default=None)
|
| 32 |
+
ap.add_argument("--heads", nargs="+", default=["linear", "mlp"])
|
| 33 |
+
ap.add_argument("--folds", nargs="+", type=int, default=[0, 1, 2, 3, 4])
|
| 34 |
+
args = ap.parse_args()
|
| 35 |
+
dev = "cuda"
|
| 36 |
+
H = os.path.join(CACHE, "hest")
|
| 37 |
+
G100, DRAWS, BS, LR, EVAL_EVERY, STEPS = 100, 50, 256, 3e-4, 50, 1000
|
| 38 |
+
f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
|
| 39 |
+
nz = lambda x: F.normalize(x, dim=-1)
|
| 40 |
+
|
| 41 |
+
HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
|
| 42 |
+
IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
|
| 43 |
+
IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
|
| 44 |
+
HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
|
| 45 |
+
RNA_H = f32(np.load(os.path.join(H, "hvg.npy"))).to(dev)
|
| 46 |
+
HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
|
| 47 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 48 |
+
T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
|
| 49 |
+
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
|
| 50 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def virtual_rna(st): # = 42_
|
| 54 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 55 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 56 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 57 |
+
return f32(c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]).to(dev)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
RNA_V_T = virtual_rna("tcga_text")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_enc(e):
|
| 64 |
+
e = e.split("@")[0] # "<enc>@<n>": same input, head trained with another seed (seed-ensemble control)
|
| 65 |
+
return {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev) for s in ["alltxt", "orion_gentext"]}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def mlp(din, dout=1536, hid=512):
|
| 69 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 70 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def one_text_per_image(t_ids, rng):
|
| 74 |
+
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 75 |
+
return pick.index.values, pick.values
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def g100(a, b, seed=0): # = 33_ / 42_
|
| 79 |
+
a, b = a.cpu(), b.cpu()
|
| 80 |
+
rng = np.random.default_rng(seed)
|
| 81 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 82 |
+
for _ in range(DRAWS):
|
| 83 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 84 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 85 |
+
for S in (sim, sim.T):
|
| 86 |
+
rank = (S > d[:, None]).sum(1)
|
| 87 |
+
for k_ in (1, 5, 10):
|
| 88 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 89 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 90 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 91 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 92 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def r1(a, b):
|
| 96 |
+
s = a @ b.T
|
| 97 |
+
return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def train_head(X_tr, A_tr, X_va, A_va, kind, steps, seed, pos_tr=None): # = 44_
|
| 101 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 102 |
+
h = (mlp(X_tr.shape[1]) if kind == "mlp" else nn.Linear(X_tr.shape[1], 1536)).to(dev)
|
| 103 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 104 |
+
opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
|
| 105 |
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
|
| 106 |
+
za_va = nz(A_va[:, 0] if A_va.dim() == 3 else A_va)
|
| 107 |
+
best, state = -1, None
|
| 108 |
+
for step in range(1, steps + 1):
|
| 109 |
+
h.train()
|
| 110 |
+
b = torch.as_tensor(rng.choice(len(X_tr), BS, replace=False), device=dev)
|
| 111 |
+
a = A_tr[b, torch.randint(0, A_tr.shape[1], (BS,), device=dev)] if A_tr.dim() == 3 else A_tr[b]
|
| 112 |
+
lg = logit.exp().clamp(max=100) * nz(a) @ nz(h(X_tr[b])).T
|
| 113 |
+
if pos_tr is None:
|
| 114 |
+
t = torch.arange(BS, device=dev)
|
| 115 |
+
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 116 |
+
else:
|
| 117 |
+
ii = pos_tr[b]; pos = (ii[:, None] == ii[None, :]).float(); tgt = pos / pos.sum(1, keepdim=True)
|
| 118 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 119 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
|
| 120 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 121 |
+
h.eval()
|
| 122 |
+
with torch.no_grad():
|
| 123 |
+
v = r1(za_va, nz(h(X_va)))
|
| 124 |
+
if v > best:
|
| 125 |
+
best, state = v, {k_: x.detach().clone() for k_, x in h.state_dict().items()}
|
| 126 |
+
h.load_state_dict(state); h.eval()
|
| 127 |
+
return h, best
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class Fold:
|
| 131 |
+
def __init__(self, k):
|
| 132 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 133 |
+
self.o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
|
| 134 |
+
self.if_mu, self.if_sd = IF_O[self.o["train"]].mean((0, 1)), IF_O[self.o["train"]].std((0, 1)) + 1e-3
|
| 135 |
+
h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
|
| 136 |
+
self.h = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
|
| 137 |
+
sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
|
| 138 |
+
tsp = sp.loc[T.img_idx].values
|
| 139 |
+
self.t = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
|
| 140 |
+
self.t_va = one_text_per_image(self.t["val"], np.random.default_rng(0))
|
| 141 |
+
self.t_te = one_text_per_image(self.t["test"], np.random.default_rng(k))
|
| 142 |
+
|
| 143 |
+
def nif(self, x):
|
| 144 |
+
return (x - self.if_mu) / self.if_sd
|
| 145 |
+
|
| 146 |
+
def mif_head(self, k):
|
| 147 |
+
f = os.path.join(ROOT, "runs", "rnafm", f"other_fold{k}.pt") # written by 44_rnafm_eval.py
|
| 148 |
+
hm = mlp(512).to(dev)
|
| 149 |
+
hm.load_state_dict(torch.load(f, map_location=dev)["mif"])
|
| 150 |
+
return hm.eval()
|
| 151 |
+
|
| 152 |
+
def rna_head(self, k):
|
| 153 |
+
f = os.path.join(ROOT, "runs", "rnafm", f"rna_hvg_fold{k}.pt")
|
| 154 |
+
hr = nn.Linear(2000, 1536).to(dev)
|
| 155 |
+
if os.path.exists(f):
|
| 156 |
+
hr.load_state_dict(torch.load(f, map_location=dev))
|
| 157 |
+
return hr.eval()
|
| 158 |
+
hr, _ = train_head(RNA_H[self.h["train"]], HE_H[self.h["train"]], RNA_H[self.h["val"]], HE_H[self.h["val"]],
|
| 159 |
+
"linear", 2700, k)
|
| 160 |
+
torch.save(hr.state_dict(), f)
|
| 161 |
+
return hr
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def build(enc, tr):
|
| 165 |
+
parts = enc.split(":", 1)[1].split("+") if enc.startswith("cat:") else [enc]
|
| 166 |
+
blocks = []
|
| 167 |
+
for p in parts:
|
| 168 |
+
X = CACHE_ENC[p]
|
| 169 |
+
if p.split("@")[0] == "biomedclip" and len(parts) == 1:
|
| 170 |
+
blocks.append(X); continue # = 42_ / 33_ (raw BiomedCLIP features)
|
| 171 |
+
mu, sd = X["alltxt"][tr].mean(0), X["alltxt"][tr].std(0) + 1e-6
|
| 172 |
+
w = 1 / np.sqrt(X["alltxt"].shape[1]) if len(parts) > 1 else 1.0
|
| 173 |
+
blocks.append({s: (v - mu) / sd * w for s, v in X.items()})
|
| 174 |
+
return {s: torch.cat([b[s] for b in blocks], 1) for s in blocks[0]}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
avail = sorted({f[len("alltxt_text_"):-4] for f in os.listdir(CACHE) if f.startswith("alltxt_text_") and f.endswith(".npy")
|
| 178 |
+
and os.path.exists(os.path.join(CACHE, "orion_gentext_text_" + f[len("alltxt_text_"):]))})
|
| 179 |
+
encs = sorted(args.encs or avail, key=lambda e: e.startswith("ens:")) # late fusions after their single heads
|
| 180 |
+
singles = sorted({p for e in encs for p in (e.split(":", 1)[1].split("+") if ":" in e else [e])})
|
| 181 |
+
CACHE_ENC = {e: load_enc(e) for e in singles}
|
| 182 |
+
print("encoders:", {e: CACHE_ENC[e]["alltxt"].shape[1] for e in singles}, flush=True)
|
| 183 |
+
f_out = os.path.join(ROOT, "results", "textenc_long.csv")
|
| 184 |
+
rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
|
| 185 |
+
done = {(r["enc"], r["head"], r["fold"]) for r in rows}
|
| 186 |
+
for k in args.folds:
|
| 187 |
+
F_ = Fold(k)
|
| 188 |
+
hm, hr = F_.mif_head(k), F_.rna_head(k)
|
| 189 |
+
te_o = F_.o["test"]; ii, tt = (torch.as_tensor(x, device=dev) for x in F_.t_te)
|
| 190 |
+
vi, vt = (torch.as_tensor(x, device=dev) for x in F_.t_va)
|
| 191 |
+
trt = torch.as_tensor(F_.t["train"], device=dev)
|
| 192 |
+
with torch.no_grad():
|
| 193 |
+
z_mif = nz(hm(F_.nif(IF_O[te_o, 0]))); z_rna = nz(hr(RNA_V_T[ii])); z_vmif = nz(hm(F_.nif(IF_V_T[ii])))
|
| 194 |
+
z_he = nz(HE_T[ii, 0])
|
| 195 |
+
heads_out = {}
|
| 196 |
+
for enc in encs:
|
| 197 |
+
for hk in args.heads:
|
| 198 |
+
parts = enc.split(":", 1)[1].split("+") if enc.startswith("ens:") else None
|
| 199 |
+
need = any(enc in e.split(":", 1)[1].split("+") for e in encs if e.startswith("ens:"))
|
| 200 |
+
if (enc, hk, k) in done and not (parts is None and need):
|
| 201 |
+
continue
|
| 202 |
+
if parts is not None:
|
| 203 |
+
if not all((p, hk) in heads_out for p in parts):
|
| 204 |
+
continue
|
| 205 |
+
Z = {s: nz(sum(heads_out[(p, hk)][s] for p in parts)) for s in heads_out[(parts[0], hk)]}
|
| 206 |
+
best = float("nan")
|
| 207 |
+
else:
|
| 208 |
+
X = build(enc, trt)
|
| 209 |
+
sd_ = k + 1000 * int(enc.split("@")[1]) if "@" in enc else k
|
| 210 |
+
h, best = train_head(X["alltxt"][trt], HE_T[T_IMG[trt]], X["alltxt"][vt], HE_T[vi, 0], hk, STEPS, sd_,
|
| 211 |
+
pos_tr=T_IMG[trt])
|
| 212 |
+
with torch.no_grad():
|
| 213 |
+
Z = {"tcga": nz(h(X["alltxt"][tt])), "orion": nz(h(X["orion_gentext"][te_o]))}
|
| 214 |
+
heads_out[(enc, hk)] = Z
|
| 215 |
+
if (enc, hk, k) in done:
|
| 216 |
+
continue
|
| 217 |
+
res = {("H&E<->text (TCGA)", "trained"): g100(z_he, Z["tcga"]),
|
| 218 |
+
("mIF<->text(synthetic) (ORION)", "emergent"): g100(z_mif, Z["orion"]),
|
| 219 |
+
("text<->RNA(virtual) (TCGA)", "emergent"): g100(Z["tcga"], z_rna),
|
| 220 |
+
("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(Z["tcga"], z_vmif)}
|
| 221 |
+
for (pair, kind), r in res.items():
|
| 222 |
+
rows.append({"enc": enc, "head": hk, "fold": k, "pair": pair, "kind": kind, "val_R@1": best, **r})
|
| 223 |
+
pd.DataFrame(rows).to_csv(f_out, index=False)
|
| 224 |
+
print(f"fold {k} {enc:<34} {hk:<6} val {best:.3f} " +
|
| 225 |
+
" ".join(f"{p.split(' (')[0]} {r['R@1']:.3f}" for (p, _), r in res.items()), flush=True)
|
| 226 |
+
|
| 227 |
+
R = pd.DataFrame(rows)
|
| 228 |
+
S = R.groupby(["enc", "head", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
|
| 229 |
+
S.to_csv(os.path.join(ROOT, "results", "textenc_summary.csv"))
|
| 230 |
+
pd.set_option("display.width", 250)
|
| 231 |
+
print(R.pivot_table(index=["enc", "head"], columns="pair", values="R@1", sort=False).round(3).to_string())
|
experiments/47_final_ens.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final four-modality evaluation with multi-encoder late fusion (frozen UNI2 anchor; real pairs only; virtual /
|
| 2 |
+
synthetic data only at test time). Combines the winners of 44_rnafm_eval.py (RNA) and 46_text_enc_eval.py (text).
|
| 3 |
+
|
| 4 |
+
Every head maps one frozen encoder into the frozen UNI2 space (S4 recipe of 42_: MLP for mIF, linear for RNA / text), so
|
| 5 |
+
the heads of several encoders of one modality can be averaged at test time: z_mod = normalise(sum_e normalise(head_e(x_e))).
|
| 6 |
+
Configurations (mIF = VirTues 16-ch + MLP head in all of them):
|
| 7 |
+
S4 RNA hvg text BiomedCLIP (current method)
|
| 8 |
+
single RNA AIDO.Cell text KEEP (best single encoders)
|
| 9 |
+
ens-small RNA hvg + Tahoe-x1 + SE-600M + AIDO.Cell text KEEP + BiomedCLIP + Qwen3-Emb-0.6B
|
| 10 |
+
ens-large RNA all 10 encoders text KEEP + BiomedCLIP + Qwen3-0.6B + PubMedBERT-emb + MedCPT
|
| 11 |
+
Pairs per fold k (same splits / test items as 42_ / 44_ / 46_): trained H&E<->mIF (ORION), H&E<->RNA (HEST),
|
| 12 |
+
H&E<->text (TCGA); emergent mIF<->RNA(v), mIF<->text(s) (ORION), text<->RNA(v), text<->mIF(MIPHEI) (TCGA);
|
| 13 |
+
external DLPFC (RNA heads retrained without spatialLIBD, inputs z-scored per dataset): H&E<->RNA, RNA<->text(s),
|
| 14 |
+
RNA<->mIF(MIPHEI).
|
| 15 |
+
Output results/final_ens_long.csv, results/final_ens_summary.csv, results/final_ens_tests.csv (paired vs S4)
|
| 16 |
+
"""
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn as nn
|
| 23 |
+
import torch.nn.functional as F
|
| 24 |
+
from scipy import stats
|
| 25 |
+
from scipy.optimize import linear_sum_assignment
|
| 26 |
+
|
| 27 |
+
from common import CACHE, ROOT, SPLITS
|
| 28 |
+
|
| 29 |
+
dev = "cuda"
|
| 30 |
+
H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
|
| 31 |
+
G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
|
| 32 |
+
f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
|
| 33 |
+
nz = lambda x: F.normalize(x, dim=-1)
|
| 34 |
+
RNA_ALL = ["hvg", "tahoex1", "se600m", "scgpt", "nicheformer", "aidocell", "geneformer", "c2s", "genept-bge", "genept-bmc"]
|
| 35 |
+
CONF = {"S4": (["hvg"], ["biomedclip"]),
|
| 36 |
+
"single": (["aidocell"], ["keep"]),
|
| 37 |
+
"ens-small": (["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]),
|
| 38 |
+
"ens-large": (RNA_ALL, ["keep", "biomedclip", "qwen3-0.6b", "pubmedbert", "medcpt"])}
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------- data
|
| 41 |
+
HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
|
| 42 |
+
IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
|
| 43 |
+
IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
|
| 44 |
+
IF_V_D = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_dlpfc.npy"))[:, 0]).to(dev)
|
| 45 |
+
HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
|
| 46 |
+
HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
|
| 47 |
+
HE_D = f32(np.load(os.path.join(CACHE, "external", "dlpfc_he_uni2.npy"))).to(dev)
|
| 48 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 49 |
+
T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
|
| 50 |
+
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
|
| 51 |
+
HM = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 52 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def hvg_inputs(): # = 44_
|
| 56 |
+
def virt(st):
|
| 57 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 58 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 59 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 60 |
+
return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
|
| 61 |
+
eg = pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv")).gene.values
|
| 62 |
+
ec = np.load(os.path.join(CACHE, "external", "dlpfc_counts_full.npy")).astype(np.float32)
|
| 63 |
+
ei = pd.Series(range(len(eg)), index=eg); ei = ei[~ei.index.duplicated()]
|
| 64 |
+
d = np.log1p(ec / ec.sum(1, keepdims=True).clip(1) * 1e4)
|
| 65 |
+
d = np.stack([d[:, ei[g]] if g in ei.index else np.zeros(len(d), np.float32) for g in hvg], 1)
|
| 66 |
+
return {"hest": np.load(os.path.join(H, "hvg.npy")), "orion_v": virt("orion"), "tcga_v": virt("tcga_text"), "dlpfc": d}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
RNA_IN = {e: ({s: f32(x).to(dev) for s, x in hvg_inputs().items()} if e == "hvg" else
|
| 70 |
+
{s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in ["hest", "orion_v", "tcga_v", "dlpfc"]})
|
| 71 |
+
for e in RNA_ALL}
|
| 72 |
+
TXT_ENCS = sorted({e for _, x in CONF.values() for e in x})
|
| 73 |
+
TXT_IN = {e: {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev)
|
| 74 |
+
for s in ["alltxt", "orion_gentext", "dlpfc_gentext"]} for e in TXT_ENCS}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def mlp(din, dout=1536, hid=512):
|
| 78 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 79 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def one_text_per_image(t_ids, rng):
|
| 83 |
+
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 84 |
+
return pick.index.values, pick.values
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def g100(a, b, seed=0): # = 33_ / 42_
|
| 88 |
+
a, b = a.cpu(), b.cpu()
|
| 89 |
+
rng = np.random.default_rng(seed)
|
| 90 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 91 |
+
for _ in range(DRAWS):
|
| 92 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 93 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 94 |
+
for S in (sim, sim.T):
|
| 95 |
+
rank = (S > d[:, None]).sum(1)
|
| 96 |
+
for k_ in (1, 5, 10):
|
| 97 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 98 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 99 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 100 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 101 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def r1(a, b):
|
| 105 |
+
s = a @ b.T
|
| 106 |
+
return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def train_head(X_tr, A_tr, X_va, A_va, kind, steps, seed, pos_tr=None): # = 44_ / 46_
|
| 110 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 111 |
+
h = (mlp(X_tr.shape[1]) if kind == "mlp" else nn.Linear(X_tr.shape[1], 1536)).to(dev)
|
| 112 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 113 |
+
opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
|
| 114 |
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
|
| 115 |
+
za_va = nz(A_va[:, 0] if A_va.dim() == 3 else A_va)
|
| 116 |
+
best, state = -1, None
|
| 117 |
+
for step in range(1, steps + 1):
|
| 118 |
+
h.train()
|
| 119 |
+
b = torch.as_tensor(rng.choice(len(X_tr), BS, replace=False), device=dev)
|
| 120 |
+
a = A_tr[b, torch.randint(0, A_tr.shape[1], (BS,), device=dev)] if A_tr.dim() == 3 else A_tr[b]
|
| 121 |
+
lg = logit.exp().clamp(max=100) * nz(a) @ nz(h(X_tr[b])).T
|
| 122 |
+
if pos_tr is None:
|
| 123 |
+
t = torch.arange(BS, device=dev)
|
| 124 |
+
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 125 |
+
else:
|
| 126 |
+
ii = pos_tr[b]; pos = (ii[:, None] == ii[None, :]).float(); tgt = pos / pos.sum(1, keepdim=True)
|
| 127 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 128 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
|
| 129 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 130 |
+
h.eval()
|
| 131 |
+
with torch.no_grad():
|
| 132 |
+
v = r1(za_va, nz(h(X_va)))
|
| 133 |
+
if v > best:
|
| 134 |
+
best, state = v, {k_: x.detach().clone() for k_, x in h.state_dict().items()}
|
| 135 |
+
h.load_state_dict(state); h.eval()
|
| 136 |
+
return h
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def zs(X, ref_set, idx, raw, ext=None):
|
| 140 |
+
"""z-score every set with the training rows of ref_set (raw = keep the baseline input unchanged, as in 42_);
|
| 141 |
+
ext: sets z-scored on themselves (external data, = 38_ / 44_)"""
|
| 142 |
+
if raw:
|
| 143 |
+
out = dict(X)
|
| 144 |
+
else:
|
| 145 |
+
mu, sd = X[ref_set][idx].mean(0), X[ref_set][idx].std(0) + 1e-6
|
| 146 |
+
out = {s: (v - mu) / sd for s, v in X.items()}
|
| 147 |
+
for s in ext or []:
|
| 148 |
+
out[s] = (X[s] - X[s].mean(0)) / (X[s].std(0) + 1e-6)
|
| 149 |
+
return out
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
rows = []
|
| 153 |
+
for k in range(5):
|
| 154 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 155 |
+
o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
|
| 156 |
+
nif = lambda x: (x - IF_O[o["train"]].mean((0, 1))) / (IF_O[o["train"]].std((0, 1)) + 1e-3)
|
| 157 |
+
h = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
|
| 158 |
+
hs = {x: h.spot_idx[h.split == x].values for x in ["train", "val", "test"]}
|
| 159 |
+
sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
|
| 160 |
+
tsp = sp.loc[T.img_idx].values
|
| 161 |
+
ts = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
|
| 162 |
+
vi, vt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["val"], np.random.default_rng(0)))
|
| 163 |
+
ii, tt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["test"], np.random.default_rng(k)))
|
| 164 |
+
trt = torch.as_tensor(ts["train"], device=dev)
|
| 165 |
+
pool = np.flatnonzero(~HM.group.str.startswith("spatialLIBD").values)
|
| 166 |
+
grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
|
| 167 |
+
isva = HM.group.iloc[pool].isin(set(grp[: max(1, len(grp) // 10)])).values
|
| 168 |
+
d_tr, d_va = pool[~isva], pool[isva]
|
| 169 |
+
|
| 170 |
+
hm = mlp(512).to(dev)
|
| 171 |
+
hm.load_state_dict(torch.load(os.path.join(ROOT, "runs", "rnafm", f"other_fold{k}.pt"), map_location=dev)["mif"])
|
| 172 |
+
hm.eval()
|
| 173 |
+
with torch.no_grad():
|
| 174 |
+
Zm = {"he_o": nz(HE_O[o["test"], 0]), "orion": nz(hm(nif(IF_O[o["test"], 0]))),
|
| 175 |
+
"tcga_v": nz(hm(nif(IF_V_T[ii]))), "dlpfc_v": nz(hm(nif(IF_V_D)))}
|
| 176 |
+
Zr, Zt = {}, {}
|
| 177 |
+
for e in RNA_ALL:
|
| 178 |
+
X = zs(RNA_IN[e], "hest", hs["train"], e == "hvg")
|
| 179 |
+
hr = train_head(X["hest"][hs["train"]], HE_H[hs["train"]], X["hest"][hs["val"]], HE_H[hs["val"]], "linear", 2700, k)
|
| 180 |
+
Xd = zs(RNA_IN[e], "hest", d_tr, False, ext=["dlpfc"])
|
| 181 |
+
hd = train_head(Xd["hest"][d_tr], HE_H[d_tr], Xd["hest"][d_va], HE_H[d_va], "linear", 2700, k)
|
| 182 |
+
with torch.no_grad():
|
| 183 |
+
Zr[e] = {"hest": nz(hr(X["hest"][hs["test"]])), "orion_v": nz(hr(X["orion_v"][o["test"]])),
|
| 184 |
+
"tcga_v": nz(hr(X["tcga_v"][ii])), "dlpfc": nz(hd(Xd["dlpfc"]))}
|
| 185 |
+
print(f"fold {k} RNA head {e} done", flush=True)
|
| 186 |
+
for e in TXT_ENCS:
|
| 187 |
+
X = zs(TXT_IN[e], "alltxt", trt, e == "biomedclip")
|
| 188 |
+
ht = train_head(X["alltxt"][trt], HE_T[T_IMG[trt]], X["alltxt"][vt], HE_T[vi, 0], "linear", 1000, k, pos_tr=T_IMG[trt])
|
| 189 |
+
# DLPFC synthetic captions: z-scored with TCGA-train stats (text domain is the same caption style)
|
| 190 |
+
with torch.no_grad():
|
| 191 |
+
Zt[e] = {"tcga": nz(ht(X["alltxt"][tt])), "orion_s": nz(ht(X["orion_gentext"][o["test"]])),
|
| 192 |
+
"dlpfc_s": nz(ht(X["dlpfc_gentext"]))}
|
| 193 |
+
he_h, he_t, he_d = nz(HE_H[hs["test"]]), nz(HE_T[ii, 0]), nz(HE_D)
|
| 194 |
+
for cname, (re_, te_) in CONF.items():
|
| 195 |
+
R = {s_: nz(sum(Zr[e][s_] for e in re_)) for s_ in Zr[re_[0]]}
|
| 196 |
+
Tx = {s_: nz(sum(Zt[e][s_] for e in te_)) for s_ in Zt[te_[0]]}
|
| 197 |
+
res = {("H&E<->mIF (ORION)", "trained"): g100(Zm["he_o"], Zm["orion"]),
|
| 198 |
+
("H&E<->RNA (HEST)", "trained"): g100(he_h, R["hest"]),
|
| 199 |
+
("H&E<->text (TCGA)", "trained"): g100(he_t, Tx["tcga"]),
|
| 200 |
+
("mIF<->RNA(virtual) (ORION)", "emergent"): g100(Zm["orion"], R["orion_v"]),
|
| 201 |
+
("mIF<->text(synthetic) (ORION)", "emergent"): g100(Zm["orion"], Tx["orion_s"]),
|
| 202 |
+
("text<->RNA(virtual) (TCGA)", "emergent"): g100(Tx["tcga"], R["tcga_v"]),
|
| 203 |
+
("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(Tx["tcga"], Zm["tcga_v"]),
|
| 204 |
+
("H&E<->RNA (DLPFC)", "external"): g100(he_d, R["dlpfc"]),
|
| 205 |
+
("RNA<->text(synthetic) (DLPFC)", "external"): g100(R["dlpfc"], Tx["dlpfc_s"]),
|
| 206 |
+
("RNA<->mIF(MIPHEI) (DLPFC)", "external"): g100(R["dlpfc"], Zm["dlpfc_v"])}
|
| 207 |
+
for (pair, kind), r in res.items():
|
| 208 |
+
rows.append({"config": cname, "fold": k, "pair": pair, "kind": kind, **r})
|
| 209 |
+
print(f"fold {k} {cname:<10} " + " ".join(f"{p.split(' (')[0]} {r['R@1']:.3f}" for (p, _), r in res.items()), flush=True)
|
| 210 |
+
pd.DataFrame(rows).to_csv(os.path.join(ROOT, "results", "final_ens_long.csv"), index=False)
|
| 211 |
+
|
| 212 |
+
R = pd.DataFrame(rows)
|
| 213 |
+
S = R.groupby(["config", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
|
| 214 |
+
S.to_csv(os.path.join(ROOT, "results", "final_ens_summary.csv"))
|
| 215 |
+
tests = []
|
| 216 |
+
for c in CONF:
|
| 217 |
+
if c == "S4":
|
| 218 |
+
continue
|
| 219 |
+
for p in R.pair.unique():
|
| 220 |
+
a = R[(R.config == c) & (R.pair == p)].sort_values("fold")["R@1"].values
|
| 221 |
+
b = R[(R.config == "S4") & (R.pair == p)].sort_values("fold")["R@1"].values
|
| 222 |
+
tests.append({"config": c, "pair": p, "S4": b.mean(), "new": a.mean(), "delta": (a - b).mean(),
|
| 223 |
+
"folds_won": int((a > b).sum()), "p_paired_t": stats.ttest_rel(a, b).pvalue})
|
| 224 |
+
pd.DataFrame(tests).round(4).to_csv(os.path.join(ROOT, "results", "final_ens_tests.csv"), index=False)
|
| 225 |
+
pd.set_option("display.width", 250)
|
| 226 |
+
print(R.pivot_table(index="config", columns="pair", values="R@1", sort=False).round(3).T.to_string())
|
| 227 |
+
print(pd.DataFrame(tests).round(4).to_string())
|
experiments/48_joint_ens.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Joint training of the multi-encoder (late-fusion) four-modality model: does the J8 two-stage gain of 42_ stack with
|
| 2 |
+
the encoder fusion of 47_? Real pairs only; virtual / synthetic data only at test time (same splits / test items).
|
| 3 |
+
|
| 4 |
+
Stage 1 (separate, frozen UNI2 anchor; = 47_): one head per encoder - VirTues mIF -> MLP; every RNA / text encoder ->
|
| 5 |
+
linear (FM inputs z-scored with train stats, hvg / BiomedCLIP raw).
|
| 6 |
+
Stage 2 (joint, = J8 of 42_): all stage-1 heads + a shared zero-initialised residual adapter on UNI2 (H&E = UNI2 +
|
| 7 |
+
adapter(UNI2)) fine-tuned together, sum of the three pair losses (H&E-mIF, H&E-RNA, H&E-text), similarity
|
| 8 |
+
distillation lambda 1, AdamW lr 1e-4, 1000 steps, joint early stopping on the geometric mean of the three val R@1.
|
| 9 |
+
Configurations (per fold, all from the SAME stage-1 heads):
|
| 10 |
+
S4 stage 1 only, RNA hvg, text BiomedCLIP (single encoders, separate)
|
| 11 |
+
J8 S4 -> stage 2 (single encoders, joint)
|
| 12 |
+
E stage 1 only, ens-small (RNA hvg+Tahoe-x1+SE-600M+AIDO.Cell, text KEEP+BiomedCLIP+Qwen3-0.6B)
|
| 13 |
+
EJ-fused E -> stage 2, loss on the FUSED modality embedding normalise(sum_e normalise(head_e(x_e)))
|
| 14 |
+
EJ-each E -> stage 2, loss = mean over encoders of each head's own loss (fusion only at test time)
|
| 15 |
+
EJ-frz EJ-each with the RNA / text heads frozen (only the H&E adapter, the mIF head and the temperatures move)
|
| 16 |
+
Output results/joint_ens_long.csv, results/joint_ens_summary.csv, results/joint_ens_tests.csv
|
| 17 |
+
"""
|
| 18 |
+
import copy
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn as nn
|
| 25 |
+
import torch.nn.functional as F
|
| 26 |
+
from scipy import stats
|
| 27 |
+
from scipy.optimize import linear_sum_assignment
|
| 28 |
+
|
| 29 |
+
from common import CACHE, ROOT, SPLITS
|
| 30 |
+
|
| 31 |
+
dev = "cuda"
|
| 32 |
+
H, RF = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm")
|
| 33 |
+
G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
|
| 34 |
+
f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
|
| 35 |
+
nz = lambda x: F.normalize(x, dim=-1)
|
| 36 |
+
RNA_E, TXT_E = ["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]
|
| 37 |
+
CONF = {"S4": (["hvg"], ["biomedclip"], None), "J8": (["hvg"], ["biomedclip"], "fused"),
|
| 38 |
+
"E": (RNA_E, TXT_E, None), "EJ-fused": (RNA_E, TXT_E, "fused"), "EJ-each": (RNA_E, TXT_E, "each"),
|
| 39 |
+
"EJ-frz": (RNA_E, TXT_E, "frz")}
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------- data (= 47_)
|
| 42 |
+
HE_O = f32(np.load(os.path.join(CACHE, "he_uni2.npy"))).to(dev)
|
| 43 |
+
IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
|
| 44 |
+
IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
|
| 45 |
+
HE_H = f32(np.load(os.path.join(H, "he_uni2.npy"))[:, 0]).to(dev)
|
| 46 |
+
HE_T = f32(np.load(os.path.join(CACHE, "allimg_he_uni2.npy"))).to(dev)
|
| 47 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 48 |
+
T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
|
| 49 |
+
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
|
| 50 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def hvg_inputs():
|
| 54 |
+
def virt(st):
|
| 55 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 56 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 57 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 58 |
+
return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
|
| 59 |
+
return {"hest": np.load(os.path.join(H, "hvg.npy")), "orion_v": virt("orion"), "tcga_v": virt("tcga_text")}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
RNA_IN = {e: ({s: f32(x).to(dev) for s, x in hvg_inputs().items()} if e == "hvg" else
|
| 63 |
+
{s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in ["hest", "orion_v", "tcga_v"]})
|
| 64 |
+
for e in RNA_E}
|
| 65 |
+
TXT_IN = {e: {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev)
|
| 66 |
+
for s in ["alltxt", "orion_gentext"]} for e in TXT_E}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def mlp(din, dout=1536, hid=512):
|
| 70 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 71 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def one_text_per_image(t_ids, rng):
|
| 75 |
+
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 76 |
+
return pick.index.values, pick.values
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def g100(a, b, seed=0): # = 33_ / 42_ / 47_
|
| 80 |
+
a, b = a.cpu(), b.cpu()
|
| 81 |
+
rng = np.random.default_rng(seed)
|
| 82 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 83 |
+
for _ in range(DRAWS):
|
| 84 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 85 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 86 |
+
for S in (sim, sim.T):
|
| 87 |
+
rank = (S > d[:, None]).sum(1)
|
| 88 |
+
for k_ in (1, 5, 10):
|
| 89 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 90 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 91 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 92 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 93 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def r1(a, b):
|
| 97 |
+
s = a @ b.T
|
| 98 |
+
return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def clip_loss(lg, pos=None):
|
| 102 |
+
if pos is None:
|
| 103 |
+
t = torch.arange(len(lg), device=dev)
|
| 104 |
+
return (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 105 |
+
tgt = pos / pos.sum(1, keepdim=True)
|
| 106 |
+
return (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def zs(X, ref_set, idx, raw):
|
| 110 |
+
if raw:
|
| 111 |
+
return dict(X)
|
| 112 |
+
mu, sd = X[ref_set][idx].mean(0), X[ref_set][idx].std(0) + 1e-6
|
| 113 |
+
return {s: (v - mu) / sd for s, v in X.items()}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class Joint(nn.Module):
|
| 117 |
+
"""heads: 'mif' + 'rna:<enc>' + 'text:<enc>'; logits per head + fused 'rna' / 'text'; residual adapter on UNI2"""
|
| 118 |
+
|
| 119 |
+
def __init__(self, heads, logits, adapter):
|
| 120 |
+
super().__init__()
|
| 121 |
+
self.heads = nn.ModuleDict({k.replace(".", "_"): copy.deepcopy(h) for k, h in heads.items()})
|
| 122 |
+
self.keys = {k.replace(".", "_"): k for k in heads}
|
| 123 |
+
self.logit = nn.ParameterDict({k.replace(":", "__").replace(".", "_"): nn.Parameter(v.detach().clone())
|
| 124 |
+
for k, v in logits.items()})
|
| 125 |
+
self.ah = None
|
| 126 |
+
if adapter:
|
| 127 |
+
self.ah = mlp(1536)
|
| 128 |
+
nn.init.zeros_(self.ah[-1].weight); nn.init.zeros_(self.ah[-1].bias)
|
| 129 |
+
|
| 130 |
+
def h(self, k):
|
| 131 |
+
return self.heads[k.replace(".", "_")]
|
| 132 |
+
|
| 133 |
+
def lg(self, k):
|
| 134 |
+
return self.logit[k.replace(":", "__").replace(".", "_")].exp().clamp(max=100)
|
| 135 |
+
|
| 136 |
+
def he(self, x):
|
| 137 |
+
return x if self.ah is None else x + self.ah(x)
|
| 138 |
+
|
| 139 |
+
def fuse(self, mod, encs, X):
|
| 140 |
+
"""X: enc -> input rows; returns normalised fused embedding and the per-encoder embeddings"""
|
| 141 |
+
z = {e: nz(self.h(f"{mod}:{e}")(X[e])) for e in encs}
|
| 142 |
+
return nz(sum(z.values())), z
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
f_out = os.path.join(ROOT, "results", "joint_ens_long.csv")
|
| 146 |
+
rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
|
| 147 |
+
done = {(r["config"], r["fold"]) for r in rows}
|
| 148 |
+
|
| 149 |
+
for k in range(5):
|
| 150 |
+
if all((c, k) in done for c in CONF):
|
| 151 |
+
continue
|
| 152 |
+
torch.manual_seed(k)
|
| 153 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 154 |
+
o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
|
| 155 |
+
mu_if, sd_if = IF_O[o["train"]].mean((0, 1)), IF_O[o["train"]].std((0, 1)) + 1e-3
|
| 156 |
+
nif = lambda x: (x - mu_if) / sd_if
|
| 157 |
+
hh = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
|
| 158 |
+
hs = {x: hh.spot_idx[hh.split == x].values for x in ["train", "val", "test"]}
|
| 159 |
+
sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
|
| 160 |
+
tsp = sp.loc[T.img_idx].values
|
| 161 |
+
ts = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
|
| 162 |
+
vi, vt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["val"], np.random.default_rng(0)))
|
| 163 |
+
ii, tt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["test"], np.random.default_rng(k)))
|
| 164 |
+
trt = torch.as_tensor(ts["train"], device=dev)
|
| 165 |
+
Xr = {e: zs(RNA_IN[e], "hest", hs["train"], e == "hvg") for e in RNA_E}
|
| 166 |
+
Xt = {e: zs(TXT_IN[e], "alltxt", trt, e == "biomedclip") for e in TXT_E}
|
| 167 |
+
o_tr = torch.as_tensor(o["train"], device=dev)
|
| 168 |
+
h_tr = torch.as_tensor(hs["train"], device=dev)
|
| 169 |
+
|
| 170 |
+
def batch(task, rng):
|
| 171 |
+
"""one training batch: (H&E anchor rows, modality inputs (enc -> rows), multi-positive matrix or None)"""
|
| 172 |
+
if task == "mif":
|
| 173 |
+
b = o_tr[torch.as_tensor(rng.choice(len(o_tr), BS, replace=False), device=dev)]
|
| 174 |
+
d = torch.randint(0, 8, (BS,), device=dev)
|
| 175 |
+
return HE_O[b, d], {"mif": nif(IF_O[b, d])}, None
|
| 176 |
+
if task == "rna":
|
| 177 |
+
b = h_tr[torch.as_tensor(rng.choice(len(h_tr), BS, replace=False), device=dev)]
|
| 178 |
+
return HE_H[b], {e: Xr[e]["hest"][b] for e in RNA_E}, None
|
| 179 |
+
b = trt[torch.as_tensor(rng.choice(len(trt), BS, replace=False), device=dev)]
|
| 180 |
+
im = T_IMG[b]
|
| 181 |
+
return (HE_T[im, torch.randint(0, 8, (BS,), device=dev)], {e: Xt[e]["alltxt"][b] for e in TXT_E},
|
| 182 |
+
(im[:, None] == im[None, :]).float())
|
| 183 |
+
|
| 184 |
+
VAL = {"mif": (HE_O[o["val"], 0], {"mif": nif(IF_O[o["val"], 0])}),
|
| 185 |
+
"rna": (HE_H[hs["val"]], {e: Xr[e]["hest"][hs["val"]] for e in RNA_E}),
|
| 186 |
+
"text": (HE_T[vi, 0], {e: Xt[e]["alltxt"][vt] for e in TXT_E})}
|
| 187 |
+
|
| 188 |
+
# ------------------------------------------------ stage 1: one head per encoder, frozen anchor (= 47_)
|
| 189 |
+
def train_head(task, enc, kind, steps):
|
| 190 |
+
torch.manual_seed(k); rng = np.random.default_rng(k)
|
| 191 |
+
din = VAL[task][1][enc].shape[1]
|
| 192 |
+
h = (mlp(din) if kind == "mlp" else nn.Linear(din, 1536)).to(dev)
|
| 193 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 194 |
+
opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
|
| 195 |
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
|
| 196 |
+
za_va = nz(VAL[task][0])
|
| 197 |
+
best, state = -1, None
|
| 198 |
+
for step in range(1, steps + 1):
|
| 199 |
+
h.train()
|
| 200 |
+
a, X, pos = batch(task, rng)
|
| 201 |
+
loss = clip_loss(logit.exp().clamp(max=100) * nz(a) @ nz(h(X[enc])).T, pos)
|
| 202 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
|
| 203 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 204 |
+
h.eval()
|
| 205 |
+
with torch.no_grad():
|
| 206 |
+
v = r1(za_va, nz(h(VAL[task][1][enc])))
|
| 207 |
+
if v > best:
|
| 208 |
+
best, state = v, ({k_: x.detach().clone() for k_, x in h.state_dict().items()}, logit.detach().clone())
|
| 209 |
+
h.load_state_dict(state[0]); h.eval()
|
| 210 |
+
return h, state[1]
|
| 211 |
+
|
| 212 |
+
HEADS, LOGITS = {}, {}
|
| 213 |
+
HEADS["mif"], LOGITS["mif"] = train_head("mif", "mif", "mlp", 400)
|
| 214 |
+
for e in RNA_E:
|
| 215 |
+
HEADS[f"rna:{e}"], LOGITS[f"rna:{e}"] = train_head("rna", e, "linear", 2700)
|
| 216 |
+
for e in TXT_E:
|
| 217 |
+
HEADS[f"text:{e}"], LOGITS[f"text:{e}"] = train_head("text", e, "linear", 1000)
|
| 218 |
+
print(f"fold {k} stage 1 done", flush=True)
|
| 219 |
+
|
| 220 |
+
# ------------------------------------------------ stage 2: joint residual fine-tune (= J8 of 42_)
|
| 221 |
+
def val_score(m, re_, te_):
|
| 222 |
+
m.eval()
|
| 223 |
+
with torch.no_grad():
|
| 224 |
+
v = {"mif": r1(nz(m.he(VAL["mif"][0])), nz(m.h("mif")(VAL["mif"][1]["mif"]))),
|
| 225 |
+
"rna": r1(nz(m.he(VAL["rna"][0])), m.fuse("rna", re_, VAL["rna"][1])[0]),
|
| 226 |
+
"text": r1(nz(m.he(VAL["text"][0])), m.fuse("text", te_, VAL["text"][1])[0])}
|
| 227 |
+
m.train()
|
| 228 |
+
return float(np.exp(np.mean(np.log([max(x, 1e-4) for x in v.values()])))), v
|
| 229 |
+
|
| 230 |
+
def joint(re_, te_, mode, steps=1000, lr=1e-4, distill=1.0):
|
| 231 |
+
keys = ["mif"] + [f"rna:{e}" for e in re_] + [f"text:{e}" for e in te_]
|
| 232 |
+
lg = {kk: LOGITS[kk] for kk in keys}
|
| 233 |
+
lg["rna"] = torch.stack([LOGITS[f"rna:{e}"] for e in re_]).mean()
|
| 234 |
+
lg["text"] = torch.stack([LOGITS[f"text:{e}"] for e in te_]).mean()
|
| 235 |
+
m = Joint({kk: HEADS[kk] for kk in keys}, lg, adapter=True).to(dev)
|
| 236 |
+
torch.manual_seed(k); rng = np.random.default_rng(k)
|
| 237 |
+
if mode == "frz":
|
| 238 |
+
for kk in keys[1:]:
|
| 239 |
+
m.h(kk).requires_grad_(False)
|
| 240 |
+
opt = torch.optim.AdamW([p for p in m.parameters() if p.requires_grad], lr=lr, weight_decay=0.05)
|
| 241 |
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=steps, pct_start=0.05)
|
| 242 |
+
best, state, bv = -1, None, None
|
| 243 |
+
m.train()
|
| 244 |
+
for step in range(1, steps + 1):
|
| 245 |
+
loss = 0
|
| 246 |
+
for task in ("mif", "rna", "text"):
|
| 247 |
+
a, X, pos = batch(task, rng)
|
| 248 |
+
za = nz(m.he(a))
|
| 249 |
+
if task == "mif":
|
| 250 |
+
loss = loss + clip_loss(m.lg("mif") * za @ nz(m.h("mif")(X["mif"])).T, pos)
|
| 251 |
+
else:
|
| 252 |
+
encs = re_ if task == "rna" else te_
|
| 253 |
+
X = {e: X[e] for e in encs}
|
| 254 |
+
zf, ze = m.fuse(task, encs, X)
|
| 255 |
+
if mode == "fused":
|
| 256 |
+
loss = loss + clip_loss(m.lg(task) * za @ zf.T, pos)
|
| 257 |
+
else:
|
| 258 |
+
loss = loss + sum(clip_loss(m.lg(f"{task}:{e}") * za @ ze[e].T, pos) for e in encs) / len(encs)
|
| 259 |
+
u = nz(a)
|
| 260 |
+
loss = loss + distill * F.mse_loss(za @ za.T, u @ u.T)
|
| 261 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
|
| 262 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 263 |
+
sc, v = val_score(m, re_, te_)
|
| 264 |
+
if sc > best:
|
| 265 |
+
best, bv, state = sc, v, {k_: x.detach().clone() for k_, x in m.state_dict().items()}
|
| 266 |
+
m.load_state_dict(state); m.eval()
|
| 267 |
+
return m, bv
|
| 268 |
+
|
| 269 |
+
@torch.no_grad()
|
| 270 |
+
def evaluate(m, re_, te_):
|
| 271 |
+
R = lambda st, idx: m.fuse("rna", re_, {e: Xr[e][st][idx] for e in re_})[0]
|
| 272 |
+
Tx = lambda st, idx: m.fuse("text", te_, {e: Xt[e][st][idx] for e in te_})[0]
|
| 273 |
+
zm = nz(m.h("mif")(nif(IF_O[o["test"], 0])))
|
| 274 |
+
zt = Tx("alltxt", tt)
|
| 275 |
+
return {("H&E<->mIF (ORION)", "trained"): g100(nz(m.he(HE_O[o["test"], 0])), zm),
|
| 276 |
+
("H&E<->RNA (HEST)", "trained"): g100(nz(m.he(HE_H[hs["test"]])), R("hest", hs["test"])),
|
| 277 |
+
("H&E<->text (TCGA)", "trained"): g100(nz(m.he(HE_T[ii, 0])), zt),
|
| 278 |
+
("mIF<->RNA(virtual) (ORION)", "emergent"): g100(zm, R("orion_v", o["test"])),
|
| 279 |
+
("mIF<->text(synthetic) (ORION)", "emergent"): g100(zm, Tx("orion_gentext", o["test"])),
|
| 280 |
+
("text<->RNA(virtual) (TCGA)", "emergent"): g100(zt, R("tcga_v", ii)),
|
| 281 |
+
("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(zt, nz(m.h("mif")(nif(IF_V_T[ii]))))}
|
| 282 |
+
|
| 283 |
+
for cname, (re_, te_, mode) in CONF.items():
|
| 284 |
+
if (cname, k) in done:
|
| 285 |
+
continue
|
| 286 |
+
if mode is None: # separate: stage-1 heads, no adapter
|
| 287 |
+
keys = ["mif"] + [f"rna:{e}" for e in re_] + [f"text:{e}" for e in te_]
|
| 288 |
+
m = Joint({kk: HEADS[kk] for kk in keys}, {kk: LOGITS[kk] for kk in keys}, adapter=False).to(dev).eval()
|
| 289 |
+
val = {}
|
| 290 |
+
else:
|
| 291 |
+
m, val = joint(re_, te_, mode)
|
| 292 |
+
for (pair, kind), r in evaluate(m, re_, te_).items():
|
| 293 |
+
rows.append({"config": cname, "fold": k, "pair": pair, "kind": kind, **r,
|
| 294 |
+
**{f"val_{t}": v for t, v in val.items()}})
|
| 295 |
+
pd.DataFrame(rows).to_csv(f_out, index=False)
|
| 296 |
+
print(f"fold {k} {cname:<9} " + " ".join(f"{r['pair'].split(' (')[0]} {r['R@1']:.3f}"
|
| 297 |
+
for r in rows if r["config"] == cname and r["fold"] == k), flush=True)
|
| 298 |
+
|
| 299 |
+
R = pd.DataFrame(rows)
|
| 300 |
+
S = R.groupby(["config", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
|
| 301 |
+
S.to_csv(os.path.join(ROOT, "results", "joint_ens_summary.csv"))
|
| 302 |
+
tests = []
|
| 303 |
+
for c, ref in [("J8", "S4"), ("E", "S4"), ("EJ-fused", "S4"), ("EJ-each", "S4"), ("EJ-fused", "E"), ("EJ-each", "E"),
|
| 304 |
+
("EJ-fused", "J8"), ("EJ-each", "EJ-fused"), ("EJ-frz", "S4"),
|
| 305 |
+
("EJ-frz", "E"), ("EJ-frz", "EJ-each")]:
|
| 306 |
+
for p in R.pair.unique():
|
| 307 |
+
a = R[(R.config == c) & (R.pair == p)].sort_values("fold")["R@1"].values
|
| 308 |
+
b = R[(R.config == ref) & (R.pair == p)].sort_values("fold")["R@1"].values
|
| 309 |
+
tests.append({"config": c, "vs": ref, "pair": p, "ref": b.mean(), "new": a.mean(), "delta": (a - b).mean(),
|
| 310 |
+
"folds_won": int((a > b).sum()), "p_paired_t": stats.ttest_rel(a, b).pvalue})
|
| 311 |
+
pd.DataFrame(tests).round(4).to_csv(os.path.join(ROOT, "results", "joint_ens_tests.csv"), index=False)
|
| 312 |
+
pd.set_option("display.width", 250)
|
| 313 |
+
print(R.pivot_table(index="config", columns="pair", values="R@1", sort=False).round(3).T.to_string())
|
| 314 |
+
print(R.pivot_table(index="config", columns="pair", values="pair_acc", sort=False).round(3).T.to_string())
|
| 315 |
+
print(pd.DataFrame(tests).round(4).to_string())
|
experiments/49_hest_patches.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""H&E patches of the 9,484 HEST training-pool spots (cache/hest/meta.csv), so that image encoders other than the
|
| 2 |
+
precomputed UNI2 / GigaPath features can be run on them (e.g. MedVTok).
|
| 3 |
+
|
| 4 |
+
Run: HF_TOKEN=... .venv/bin/python -I 49_hest_patches.py [--workers 16] [--only <sample> ...]
|
| 5 |
+
Source: MahmoodLab/hest wsis/<sample>.tif (pyramidal tiled TIFF), level 0, read with HTTP range requests on the
|
| 6 |
+
resolved CDN URL (RangeFile) - only the tiles under the needed spots. (The patches/<sample>.h5 files cannot be read
|
| 7 |
+
selectively: their barcode chunks are interleaved with the image chunks, i.e. finding the rows reads the whole file.)
|
| 8 |
+
Patch = 224 x 224 level-0 px centred on the spot centre (obsm['spatial'] = full-res (x, y), cache/hest/spot_coords.csv),
|
| 9 |
+
no resizing: this is exactly the image behind the cached UNI2 features (re-encoding these crops with UNI2-h gives
|
| 10 |
+
cos 1.000 to cache/hest/he_uni2.npy, checked on TENX152 / MEND139; 0.5-um/px resampled crops give only 0.66-0.81).
|
| 11 |
+
Pixels outside the slide are white. Resumable per sample.
|
| 12 |
+
Output cache/hest/patches/<sample>.npz (img, idx) and, once all are present, cache/hest/he_img.npy (9484, 224, 224, 3)
|
| 13 |
+
uint8 in meta.csv row order (+ cache/hest/he_img_missing.csv, spots whose slide could not be read).
|
| 14 |
+
"""
|
| 15 |
+
import io
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import pandas as pd
|
| 22 |
+
import requests
|
| 23 |
+
import tifffile
|
| 24 |
+
|
| 25 |
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 26 |
+
H = os.path.join(ROOT, "cache", "hest")
|
| 27 |
+
OUT = os.path.join(H, "patches")
|
| 28 |
+
os.makedirs(OUT, exist_ok=True)
|
| 29 |
+
WORKERS = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else 16
|
| 30 |
+
ONLY = sys.argv[sys.argv.index("--only") + 1:] if "--only" in sys.argv else None
|
| 31 |
+
M = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 32 |
+
C = pd.read_csv(os.path.join(H, "spot_coords.csv"))
|
| 33 |
+
assert (C.idx.values == M.idx.values).all()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class RangeFile(io.RawIOBase):
|
| 37 |
+
"""Read-only, seekable file over HTTP range requests (block cache), for h5py. huggingface_hub's HfFileSystem hung
|
| 38 |
+
in this environment (2026-10-28), plain range requests on the resolved CDN URL are fast."""
|
| 39 |
+
BLOCK = 256 * 1024
|
| 40 |
+
|
| 41 |
+
def __init__(self, url):
|
| 42 |
+
hdr = {"Authorization": f"Bearer {os.environ['HF_TOKEN']}"}
|
| 43 |
+
r = requests.get(url, headers={**hdr, "Range": "bytes=0-0"}, timeout=60)
|
| 44 |
+
r.raise_for_status()
|
| 45 |
+
self.url = r.url # signed CDN URL after redirects
|
| 46 |
+
self.size = int(r.headers["Content-Range"].split("/")[1])
|
| 47 |
+
self.pos, self.cache, self.s = 0, {}, requests.Session()
|
| 48 |
+
|
| 49 |
+
def _block(self, i):
|
| 50 |
+
if i not in self.cache:
|
| 51 |
+
a = i * self.BLOCK
|
| 52 |
+
for t in range(5):
|
| 53 |
+
try:
|
| 54 |
+
r = self.s.get(self.url, headers={"Range": f"bytes={a}-{min(self.size, a + self.BLOCK) - 1}"}, timeout=60)
|
| 55 |
+
r.raise_for_status()
|
| 56 |
+
break
|
| 57 |
+
except requests.RequestException:
|
| 58 |
+
if t == 4:
|
| 59 |
+
raise
|
| 60 |
+
self.cache[i] = r.content
|
| 61 |
+
return self.cache[i]
|
| 62 |
+
|
| 63 |
+
def readable(self):
|
| 64 |
+
return True
|
| 65 |
+
|
| 66 |
+
def seekable(self):
|
| 67 |
+
return True
|
| 68 |
+
|
| 69 |
+
def tell(self):
|
| 70 |
+
return self.pos
|
| 71 |
+
|
| 72 |
+
def seek(self, off, whence=0):
|
| 73 |
+
self.pos = off if whence == 0 else self.pos + off if whence == 1 else self.size + off
|
| 74 |
+
return self.pos
|
| 75 |
+
|
| 76 |
+
def readinto(self, b):
|
| 77 |
+
n = min(len(b), max(0, self.size - self.pos))
|
| 78 |
+
out, p = bytearray(), self.pos
|
| 79 |
+
while len(out) < n:
|
| 80 |
+
blk = self._block(p // self.BLOCK)
|
| 81 |
+
o = p % self.BLOCK
|
| 82 |
+
take = blk[o:o + n - len(out)]
|
| 83 |
+
out += take; p += len(take)
|
| 84 |
+
b[:n] = out
|
| 85 |
+
self.pos = p
|
| 86 |
+
return n
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def fetch(sid):
|
| 90 |
+
out = os.path.join(OUT, f"{sid}.npz")
|
| 91 |
+
if os.path.exists(out):
|
| 92 |
+
return sid, "cached"
|
| 93 |
+
c = C[C["sample"] == sid]
|
| 94 |
+
f = RangeFile(f"https://huggingface.co/datasets/MahmoodLab/hest/resolve/main/wsis/{sid}.tif")
|
| 95 |
+
with tifffile.TiffFile(f) as tf:
|
| 96 |
+
pg = tf.pages[0]
|
| 97 |
+
Hh, W = pg.shape[:2]
|
| 98 |
+
th, tw = pg.tilelength, pg.tilewidth
|
| 99 |
+
ntx = -(-W // tw)
|
| 100 |
+
S = 224
|
| 101 |
+
tiles = {}
|
| 102 |
+
|
| 103 |
+
def tile(ty, tx):
|
| 104 |
+
if (ty, tx) not in tiles:
|
| 105 |
+
i = ty * ntx + tx
|
| 106 |
+
f.seek(pg.dataoffsets[i])
|
| 107 |
+
a = pg.decode(f.read(pg.databytecounts[i]), i, jpegtables=pg.jpegtables)[0]
|
| 108 |
+
tiles[(ty, tx)] = a.reshape(th, tw, -1)[..., :3]
|
| 109 |
+
return tiles[(ty, tx)]
|
| 110 |
+
|
| 111 |
+
imgs = []
|
| 112 |
+
for x, y in zip(c.x.values, c.y.values):
|
| 113 |
+
x0, y0 = int(round(x - S / 2)), int(round(y - S / 2))
|
| 114 |
+
reg = np.full((S, S, 3), 255, np.uint8)
|
| 115 |
+
for ty in range(max(0, y0 // th), min(-(-Hh // th), (y0 + S - 1) // th + 1)):
|
| 116 |
+
for tx in range(max(0, x0 // tw), min(ntx, (x0 + S - 1) // tw + 1)):
|
| 117 |
+
a = tile(ty, tx)
|
| 118 |
+
ys, xs = max(y0, ty * th), max(x0, tx * tw)
|
| 119 |
+
ye, xe = min(y0 + S, (ty + 1) * th, Hh), min(x0 + S, (tx + 1) * tw, W)
|
| 120 |
+
reg[ys - y0:ye - y0, xs - x0:xe - x0] = a[ys - ty * th:ye - ty * th, xs - tx * tw:xe - tx * tw]
|
| 121 |
+
imgs.append(reg)
|
| 122 |
+
np.savez(out + ".tmp.npz", img=np.stack(imgs), idx=c.idx.values)
|
| 123 |
+
os.replace(out + ".tmp.npz", out)
|
| 124 |
+
return sid, f"{len(imgs)} spots, crop {S}px, {len(tiles)} tiles, {sum(len(v) for v in f.cache.values()) / 1e6:.0f} MB"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
samples = ONLY or M["sample"].unique().tolist()
|
| 128 |
+
with ThreadPoolExecutor(WORKERS) as ex:
|
| 129 |
+
futs = {s: ex.submit(fetch, s) for s in samples}
|
| 130 |
+
for n, (sid, fu) in enumerate(futs.items()):
|
| 131 |
+
try:
|
| 132 |
+
print(f"[{n + 1}/{len(samples)}] {sid}: {fu.result()[1]}", flush=True)
|
| 133 |
+
except Exception as e: # noqa: BLE001 - reported, spots become 'missing'
|
| 134 |
+
print(f"[{n + 1}/{len(samples)}] {sid}: FAILED {type(e).__name__}: {e}", flush=True)
|
| 135 |
+
if ONLY:
|
| 136 |
+
sys.exit()
|
| 137 |
+
|
| 138 |
+
img = np.zeros((len(M), 224, 224, 3), np.uint8)
|
| 139 |
+
have = np.zeros(len(M), bool)
|
| 140 |
+
for sid in M["sample"].unique():
|
| 141 |
+
p = os.path.join(OUT, f"{sid}.npz")
|
| 142 |
+
if os.path.exists(p):
|
| 143 |
+
z = np.load(p)
|
| 144 |
+
img[z["idx"]] = z["img"]; have[z["idx"]] = True
|
| 145 |
+
np.save(os.path.join(H, "he_img.npy"), img)
|
| 146 |
+
pd.DataFrame({"idx": np.flatnonzero(~have)}).to_csv(os.path.join(H, "he_img_missing.csv"), index=False)
|
| 147 |
+
print(f"saved he_img.npy {img.shape}; missing {(~have).sum()} spots")
|
experiments/51_medvtok_eval.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MedVTok as image (H&E) information for the fused four-modality model, plus the adapter-only variant of EJ-frz.
|
| 2 |
+
Real pairs only; virtual / synthetic data only at test time; same splits / test items as 42_ / 47_ / 48_.
|
| 3 |
+
RNA = ens-small (hvg + Tahoe-x1 + SE-600M + AIDO.Cell), text = KEEP + BiomedCLIP + Qwen3-Emb-0.6B, mIF = VirTues MLP.
|
| 4 |
+
|
| 5 |
+
Configurations (per fold; all UNI2 configs start from the same stage-1 heads):
|
| 6 |
+
E separate, frozen UNI2 anchor (= 47_ ens-small / 48_ E)
|
| 7 |
+
EJ-frz E -> joint stage 2: residual adapter on UNI2 + mIF head, RNA / text heads frozen (= 48_)
|
| 8 |
+
EJ-ad EJ-frz with the mIF head frozen as well (only the adapter + temperatures train)
|
| 9 |
+
MV-E separate, frozen MedVTok anchor: every head maps into the MedVTok space (1152-d) instead of UNI2
|
| 10 |
+
EJ-frz+MV EJ-frz whose adapter also sees MedVTok: H&E = UNI2 + A([UNI2, c * MedVTok]) (c matches the mean
|
| 11 |
+
feature norms on ORION train; zero-initialised output, so it starts exactly at E)
|
| 12 |
+
EJ-ad+MV EJ-ad with the same MedVTok-informed adapter
|
| 13 |
+
EJ-ad+RP control for EJ-ad+MV: the extra adapter input is a fixed random projection of UNI2 to 1152-d (same
|
| 14 |
+
shape / scale as MedVTok, no new information) -> separates "MedVTok information" from "wider adapter"
|
| 15 |
+
Stage 2 (= J8 / 48_): sum of the three pair losses (each RNA / text head its own loss, averaged over encoders),
|
| 16 |
+
similarity distillation to UNI2 (lambda 1), AdamW lr 1e-4, 1000 steps, early stopping on the geometric mean of the
|
| 17 |
+
three val R@1 (fused embeddings).
|
| 18 |
+
External DLPFC H&E<->RNA for E and MV-E (RNA heads retrained without spatialLIBD, inputs z-scored per dataset, = 47_).
|
| 19 |
+
Output results/medvtok_long.csv, results/medvtok_summary.csv, results/medvtok_tests.csv
|
| 20 |
+
"""
|
| 21 |
+
import copy
|
| 22 |
+
import os
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn as nn
|
| 28 |
+
import torch.nn.functional as F
|
| 29 |
+
from scipy import stats
|
| 30 |
+
from scipy.optimize import linear_sum_assignment
|
| 31 |
+
|
| 32 |
+
from common import CACHE, ROOT, SPLITS
|
| 33 |
+
|
| 34 |
+
dev = "cuda"
|
| 35 |
+
H, RF, EXT = os.path.join(CACHE, "hest"), os.path.join(CACHE, "rnafm"), os.path.join(CACHE, "external")
|
| 36 |
+
G100, DRAWS, BS, LR, EVAL_EVERY = 100, 50, 256, 3e-4, 50
|
| 37 |
+
f32 = lambda a: torch.from_numpy(np.asarray(a, dtype=np.float32))
|
| 38 |
+
nz = lambda x: F.normalize(x, dim=-1)
|
| 39 |
+
RNA_E, TXT_E = ["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]
|
| 40 |
+
# name: (anchor, stage-2 mode, adapter input)
|
| 41 |
+
CONF = {"E": ("uni2", None, None), "EJ-frz": ("uni2", "frz", "u"), "EJ-ad": ("uni2", "ad", "u"),
|
| 42 |
+
"MV-E": ("medvtok", None, None), "EJ-frz+MV": ("uni2", "frz", "cat"), "EJ-ad+MV": ("uni2", "ad", "cat"),
|
| 43 |
+
"EJ-ad+RP": ("uni2", "ad", "rp")}
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------- data
|
| 46 |
+
IF_O = f32(np.load(os.path.join(CACHE, "if_virtues_m16.npy"))).to(dev)
|
| 47 |
+
IF_V_T = f32(np.load(os.path.join(CACHE, "if_virtues_m16_virtual_miphei_tcga_text.npy"))[:, 0]).to(dev)
|
| 48 |
+
AN = {"uni2": {"o": np.load(os.path.join(CACHE, "he_uni2.npy")), "h": np.load(os.path.join(H, "he_uni2.npy"))[:, 0],
|
| 49 |
+
"t": np.load(os.path.join(CACHE, "allimg_he_uni2.npy")), "d": np.load(os.path.join(EXT, "dlpfc_he_uni2.npy"))},
|
| 50 |
+
"medvtok": {"o": np.load(os.path.join(CACHE, "he_medvtok.npy")), "h": np.load(os.path.join(H, "he_medvtok.npy"))[:, 0],
|
| 51 |
+
"t": np.load(os.path.join(CACHE, "allimg_he_medvtok.npy")),
|
| 52 |
+
"d": np.load(os.path.join(EXT, "dlpfc_he_medvtok.npy"))}}
|
| 53 |
+
AN = {a: {s: f32(x).to(dev) for s, x in d.items()} for a, d in AN.items()}
|
| 54 |
+
DIM = {a: AN[a]["o"].shape[-1] for a in AN}
|
| 55 |
+
MISS = pd.read_csv(os.path.join(H, "he_img_missing.csv")).idx.values # HEST spots without a HEST patch (no MedVTok)
|
| 56 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 57 |
+
T_IMG = torch.as_tensor(T.img_idx.values, device=dev)
|
| 58 |
+
T = T[~pd.read_csv(os.path.join(CACHE, "alltxt_junk.csv")).junk.values]
|
| 59 |
+
HM = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 60 |
+
RPM = torch.randn(1536, DIM["medvtok"], generator=torch.Generator().manual_seed(0)).to(dev) / np.sqrt(1536)
|
| 61 |
+
hvg = pd.read_csv(os.path.join(H, "hvg_genes.csv")).gene.values
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def hvg_inputs(): # = 47_
|
| 65 |
+
def virt(st):
|
| 66 |
+
g = pd.read_csv(os.path.join(CACHE, "stpath", f"{st}_genes.csv")).gene.values
|
| 67 |
+
c = np.expm1(np.load(os.path.join(CACHE, "stpath", f"{st}_pred.npy")).astype(np.float32)).clip(0)
|
| 68 |
+
c = np.log1p(c / c.sum(1, keepdims=True) * 1e4)
|
| 69 |
+
return c[:, pd.Series(range(len(g)), index=g).loc[hvg].values]
|
| 70 |
+
eg = pd.read_csv(os.path.join(EXT, "dlpfc_genes_full.csv")).gene.values
|
| 71 |
+
ec = np.load(os.path.join(EXT, "dlpfc_counts_full.npy")).astype(np.float32)
|
| 72 |
+
ei = pd.Series(range(len(eg)), index=eg); ei = ei[~ei.index.duplicated()]
|
| 73 |
+
d = np.log1p(ec / ec.sum(1, keepdims=True).clip(1) * 1e4)
|
| 74 |
+
d = np.stack([d[:, ei[g]] if g in ei.index else np.zeros(len(d), np.float32) for g in hvg], 1)
|
| 75 |
+
return {"hest": np.load(os.path.join(H, "hvg.npy")), "orion_v": virt("orion"), "tcga_v": virt("tcga_text"), "dlpfc": d}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
RNA_IN = {e: ({s: f32(x).to(dev) for s, x in hvg_inputs().items()} if e == "hvg" else
|
| 79 |
+
{s: f32(np.load(os.path.join(RF, f"{e}_{s}.npy"))).to(dev) for s in ["hest", "orion_v", "tcga_v", "dlpfc"]})
|
| 80 |
+
for e in RNA_E}
|
| 81 |
+
TXT_IN = {e: {s: f32(np.load(os.path.join(CACHE, f"{s}_text_{e}.npy"))[:, 0]).to(dev)
|
| 82 |
+
for s in ["alltxt", "orion_gentext"]} for e in TXT_E}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def mlp(din, dout=1536, hid=512):
|
| 86 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 87 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def one_text_per_image(t_ids, rng):
|
| 91 |
+
pick = T.loc[t_ids].groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False)
|
| 92 |
+
return pick.index.values, pick.values
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def g100(a, b, seed=0): # = 33_ / 42_ / 47_ / 48_
|
| 96 |
+
a, b = a.cpu(), b.cpu()
|
| 97 |
+
rng = np.random.default_rng(seed)
|
| 98 |
+
out = {m: [] for m in ["R@1", "R@5", "R@10", "MRR", "pair_acc"]}
|
| 99 |
+
for _ in range(DRAWS):
|
| 100 |
+
s = torch.from_numpy(rng.choice(len(a), min(G100, len(a)), replace=False))
|
| 101 |
+
sim = (a[s] @ b[s].T).numpy(); d = np.diag(sim)
|
| 102 |
+
for S in (sim, sim.T):
|
| 103 |
+
rank = (S > d[:, None]).sum(1)
|
| 104 |
+
for k_ in (1, 5, 10):
|
| 105 |
+
out[f"R@{k_}"].append((rank < k_).mean() / 2)
|
| 106 |
+
out["MRR"].append((1 / (rank + 1)).mean() / 2)
|
| 107 |
+
ri, ci = linear_sum_assignment(-sim)
|
| 108 |
+
out["pair_acc"].append((ci[np.argsort(ri)] == np.arange(len(s))).mean())
|
| 109 |
+
return {m: float(np.sum(v) / DRAWS) for m, v in out.items()}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def r1(a, b):
|
| 113 |
+
s = a @ b.T
|
| 114 |
+
return float((((s > s.diag()[:, None]).sum(1) == 0).float().mean() + ((s.T > s.diag()[:, None]).sum(1) == 0).float().mean()) / 2)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def clip_loss(lg, pos=None):
|
| 118 |
+
if pos is None:
|
| 119 |
+
t = torch.arange(len(lg), device=dev)
|
| 120 |
+
return (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 121 |
+
tgt = pos / pos.sum(1, keepdim=True)
|
| 122 |
+
return (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def zs(X, ref_set, idx, raw, ext=None): # = 47_
|
| 126 |
+
out = dict(X) if raw else {s: (v - X[ref_set][idx].mean(0)) / (X[ref_set][idx].std(0) + 1e-6) for s, v in X.items()}
|
| 127 |
+
for s in ext or []:
|
| 128 |
+
out[s] = (X[s] - X[s].mean(0)) / (X[s].std(0) + 1e-6)
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class Joint(nn.Module):
|
| 133 |
+
"""heads 'mif', 'rna:<enc>', 'text:<enc>' (deep copies of stage-1 heads); adapter None / 'u' (UNI2 -> residual) /
|
| 134 |
+
'cat' ([UNI2, c * MedVTok] -> residual on UNI2)"""
|
| 135 |
+
|
| 136 |
+
def __init__(self, heads, logits, adapter=None, c_mv=1.0, anchor="uni2"):
|
| 137 |
+
super().__init__()
|
| 138 |
+
self.anchor = anchor
|
| 139 |
+
self.heads = nn.ModuleDict({k.replace(".", "_"): copy.deepcopy(h) for k, h in heads.items()})
|
| 140 |
+
self.logit = nn.ParameterDict({k.replace(":", "__").replace(".", "_"): nn.Parameter(v.detach().clone())
|
| 141 |
+
for k, v in logits.items()})
|
| 142 |
+
self.adapter, self.c_mv, self.ah = adapter, c_mv, None
|
| 143 |
+
if adapter:
|
| 144 |
+
self.ah = nn.Sequential(nn.Linear(1536 + (DIM["medvtok"] if adapter in ("cat", "rp") else 0), 512), *mlp(1536)[1:])
|
| 145 |
+
nn.init.zeros_(self.ah[-1].weight); nn.init.zeros_(self.ah[-1].bias)
|
| 146 |
+
|
| 147 |
+
def h(self, k):
|
| 148 |
+
return self.heads[k.replace(".", "_")]
|
| 149 |
+
|
| 150 |
+
def lg(self, k):
|
| 151 |
+
return self.logit[k.replace(":", "__").replace(".", "_")].exp().clamp(max=100)
|
| 152 |
+
|
| 153 |
+
def he(self, a):
|
| 154 |
+
"""a: dict anchor -> rows (same images / views)"""
|
| 155 |
+
if self.adapter is None:
|
| 156 |
+
return a[self.anchor]
|
| 157 |
+
x = a["uni2"]
|
| 158 |
+
if self.adapter == "u":
|
| 159 |
+
inp = x
|
| 160 |
+
elif self.adapter == "cat":
|
| 161 |
+
inp = torch.cat([x, self.c_mv * a["medvtok"]], -1)
|
| 162 |
+
else: # "rp": random projection of UNI2, rescaled to the same mean norm as c * MedVTok
|
| 163 |
+
rp = x @ RPM
|
| 164 |
+
inp = torch.cat([x, rp * (self.c_mv * self.mv_norm / self.rp_norm)], -1)
|
| 165 |
+
return x + self.ah(inp)
|
| 166 |
+
|
| 167 |
+
def fuse(self, mod, encs, X):
|
| 168 |
+
z = {e: nz(self.h(f"{mod}:{e}")(X[e])) for e in encs}
|
| 169 |
+
return nz(sum(z.values())), z
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
f_out = os.path.join(ROOT, "results", "medvtok_long.csv")
|
| 173 |
+
rows = pd.read_csv(f_out).to_dict("records") if os.path.exists(f_out) else []
|
| 174 |
+
done = {(r["config"], r["fold"]) for r in rows}
|
| 175 |
+
|
| 176 |
+
for k in range(5):
|
| 177 |
+
if all((c, k) in done for c in CONF):
|
| 178 |
+
continue
|
| 179 |
+
s = pd.read_csv(os.path.join(SPLITS, "patient_5fold", f"fold{k}.csv"))
|
| 180 |
+
o = {x: s.tile_id[s.split == x].values for x in ["train", "val", "test"]}
|
| 181 |
+
mu_if, sd_if = IF_O[o["train"]].mean((0, 1)), IF_O[o["train"]].std((0, 1)) + 1e-3
|
| 182 |
+
nif = lambda x: (x - mu_if) / sd_if
|
| 183 |
+
hh = pd.read_csv(os.path.join(SPLITS, "final", f"hest_fold{k}.csv"))
|
| 184 |
+
# spots without a HEST patch have no MedVTok feature: dropped from every split for every config (same items)
|
| 185 |
+
hs = {x: np.setdiff1d(hh.spot_idx[hh.split == x].values, MISS) for x in ["train", "val", "test"]}
|
| 186 |
+
sp = pd.read_csv(os.path.join(SPLITS, "final", f"tcga_fold{k}.csv")).set_index("img_idx").split
|
| 187 |
+
tsp = sp.loc[T.img_idx].values
|
| 188 |
+
ts = {x: T.index[tsp == x].values for x in ["train", "val", "test"]}
|
| 189 |
+
vi, vt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["val"], np.random.default_rng(0)))
|
| 190 |
+
ii, tt = (torch.as_tensor(x, device=dev) for x in one_text_per_image(ts["test"], np.random.default_rng(k)))
|
| 191 |
+
trt = torch.as_tensor(ts["train"], device=dev)
|
| 192 |
+
Xr = {e: zs(RNA_IN[e], "hest", hs["train"], e == "hvg") for e in RNA_E}
|
| 193 |
+
Xt = {e: zs(TXT_IN[e], "alltxt", trt, e == "biomedclip") for e in TXT_E}
|
| 194 |
+
o_tr, h_tr = torch.as_tensor(o["train"], device=dev), torch.as_tensor(hs["train"], device=dev)
|
| 195 |
+
# DLPFC heads: HEST without spatialLIBD (= 47_)
|
| 196 |
+
pool = np.setdiff1d(np.flatnonzero(~HM.group.str.startswith("spatialLIBD").values), MISS)
|
| 197 |
+
grp = np.random.default_rng(k).permutation(sorted(HM.group.iloc[pool].unique()))
|
| 198 |
+
isva = HM.group.iloc[pool].isin(set(grp[: max(1, len(grp) // 10)])).values
|
| 199 |
+
d_tr, d_va = pool[~isva], pool[isva]
|
| 200 |
+
c_mv = float(AN["uni2"]["o"][o["train"]].norm(dim=-1).mean() / AN["medvtok"]["o"][o["train"]].norm(dim=-1).mean())
|
| 201 |
+
mv_norm = float(AN["medvtok"]["o"][o["train"]].norm(dim=-1).mean())
|
| 202 |
+
rp_norm = float((AN["uni2"]["o"][o["train"]] @ RPM).norm(dim=-1).mean())
|
| 203 |
+
|
| 204 |
+
def anc(key, idx, views=None):
|
| 205 |
+
"""anchor rows of all anchor encoders for the same items / views"""
|
| 206 |
+
out = {}
|
| 207 |
+
for a in AN:
|
| 208 |
+
x = AN[a][key]
|
| 209 |
+
out[a] = x[idx] if x.dim() == 2 else x[idx, views if views is not None else 0]
|
| 210 |
+
return out
|
| 211 |
+
|
| 212 |
+
def batch(task, rng):
|
| 213 |
+
if task == "mif":
|
| 214 |
+
b = o_tr[torch.as_tensor(rng.choice(len(o_tr), BS, replace=False), device=dev)]
|
| 215 |
+
d = torch.randint(0, 8, (BS,), device=dev)
|
| 216 |
+
return anc("o", b, d), {"mif": nif(IF_O[b, d])}, None
|
| 217 |
+
if task == "rna":
|
| 218 |
+
b = h_tr[torch.as_tensor(rng.choice(len(h_tr), BS, replace=False), device=dev)]
|
| 219 |
+
return anc("h", b), {e: Xr[e]["hest"][b] for e in RNA_E}, None
|
| 220 |
+
b = trt[torch.as_tensor(rng.choice(len(trt), BS, replace=False), device=dev)]
|
| 221 |
+
im = T_IMG[b]
|
| 222 |
+
return (anc("t", im, torch.randint(0, 8, (BS,), device=dev)), {e: Xt[e]["alltxt"][b] for e in TXT_E},
|
| 223 |
+
(im[:, None] == im[None, :]).float())
|
| 224 |
+
|
| 225 |
+
VAL = {"mif": (anc("o", o["val"]), {"mif": nif(IF_O[o["val"], 0])}),
|
| 226 |
+
"rna": (anc("h", hs["val"]), {e: Xr[e]["hest"][hs["val"]] for e in RNA_E}),
|
| 227 |
+
"text": (anc("t", vi), {e: Xt[e]["alltxt"][vt] for e in TXT_E})}
|
| 228 |
+
|
| 229 |
+
# ------------------------------------------------ stage 1 (frozen anchor, one head per encoder)
|
| 230 |
+
def train_head(task, enc, kind, steps, an, X_tr_fn=None, X_va=None, A_va=None):
|
| 231 |
+
torch.manual_seed(k); rng = np.random.default_rng(k)
|
| 232 |
+
din = VAL[task][1][enc].shape[1]
|
| 233 |
+
h = (mlp(din, DIM[an]) if kind == "mlp" else nn.Linear(din, DIM[an])).to(dev)
|
| 234 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 235 |
+
opt = torch.optim.AdamW(list(h.parameters()) + [logit], lr=LR, weight_decay=0.05)
|
| 236 |
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.05)
|
| 237 |
+
za_va = nz(VAL[task][0][an] if A_va is None else A_va)
|
| 238 |
+
x_va = VAL[task][1][enc] if X_va is None else X_va
|
| 239 |
+
best, state = -1, None
|
| 240 |
+
for step in range(1, steps + 1):
|
| 241 |
+
h.train()
|
| 242 |
+
a, X, pos = batch(task, rng) if X_tr_fn is None else X_tr_fn(rng)
|
| 243 |
+
loss = clip_loss(logit.exp().clamp(max=100) * nz(a[an]) @ nz(h(X[enc])).T, pos)
|
| 244 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
|
| 245 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 246 |
+
h.eval()
|
| 247 |
+
with torch.no_grad():
|
| 248 |
+
v = r1(za_va, nz(h(x_va)))
|
| 249 |
+
if v > best:
|
| 250 |
+
best, state = v, ({k_: x.detach().clone() for k_, x in h.state_dict().items()}, logit.detach().clone())
|
| 251 |
+
h.load_state_dict(state[0]); h.eval()
|
| 252 |
+
return h, state[1]
|
| 253 |
+
|
| 254 |
+
HEADS, LOGITS = {}, {}
|
| 255 |
+
for an in AN:
|
| 256 |
+
HEADS[an], LOGITS[an] = {}, {}
|
| 257 |
+
HEADS[an]["mif"], LOGITS[an]["mif"] = train_head("mif", "mif", "mlp", 400, an)
|
| 258 |
+
for e in RNA_E:
|
| 259 |
+
HEADS[an][f"rna:{e}"], LOGITS[an][f"rna:{e}"] = train_head("rna", e, "linear", 2700, an)
|
| 260 |
+
for e in TXT_E:
|
| 261 |
+
HEADS[an][f"text:{e}"], LOGITS[an][f"text:{e}"] = train_head("text", e, "linear", 1000, an)
|
| 262 |
+
print(f"fold {k} stage 1 done (c_mv {c_mv:.3f})", flush=True)
|
| 263 |
+
|
| 264 |
+
# DLPFC external: RNA heads without spatialLIBD, per anchor
|
| 265 |
+
ZD = {}
|
| 266 |
+
for an in AN:
|
| 267 |
+
zd = []
|
| 268 |
+
for e in RNA_E:
|
| 269 |
+
Xd = zs(RNA_IN[e], "hest", d_tr, False, ext=["dlpfc"])
|
| 270 |
+
d_tr_t = torch.as_tensor(d_tr, device=dev)
|
| 271 |
+
|
| 272 |
+
def dl_batch(rng, Xd=Xd, e=e, d_tr_t=d_tr_t):
|
| 273 |
+
b = d_tr_t[torch.as_tensor(rng.choice(len(d_tr_t), BS, replace=False), device=dev)]
|
| 274 |
+
return anc("h", b), {e: Xd["hest"][b]}, None
|
| 275 |
+
hd, _ = train_head("rna", e, "linear", 2700, an, dl_batch, Xd["hest"][d_va], AN[an]["h"][d_va])
|
| 276 |
+
with torch.no_grad():
|
| 277 |
+
zd.append(nz(hd(Xd["dlpfc"])))
|
| 278 |
+
ZD[an] = nz(sum(zd))
|
| 279 |
+
|
| 280 |
+
# ------------------------------------------------ stage 2
|
| 281 |
+
keys = ["mif"] + [f"rna:{e}" for e in RNA_E] + [f"text:{e}" for e in TXT_E]
|
| 282 |
+
|
| 283 |
+
def val_score(m):
|
| 284 |
+
m.eval()
|
| 285 |
+
with torch.no_grad():
|
| 286 |
+
v = {"mif": r1(nz(m.he(VAL["mif"][0])), nz(m.h("mif")(VAL["mif"][1]["mif"]))),
|
| 287 |
+
"rna": r1(nz(m.he(VAL["rna"][0])), m.fuse("rna", RNA_E, VAL["rna"][1])[0]),
|
| 288 |
+
"text": r1(nz(m.he(VAL["text"][0])), m.fuse("text", TXT_E, VAL["text"][1])[0])}
|
| 289 |
+
m.train()
|
| 290 |
+
return float(np.exp(np.mean(np.log([max(x, 1e-4) for x in v.values()])))), v
|
| 291 |
+
|
| 292 |
+
def joint(mode, adapter, steps=1000, lr=1e-4, distill=1.0):
|
| 293 |
+
m = Joint(HEADS["uni2"], LOGITS["uni2"], adapter, c_mv).to(dev)
|
| 294 |
+
m.mv_norm, m.rp_norm = mv_norm, rp_norm
|
| 295 |
+
for kk in keys[1:] if mode == "frz" else keys:
|
| 296 |
+
m.h(kk).requires_grad_(False)
|
| 297 |
+
torch.manual_seed(k); rng = np.random.default_rng(k)
|
| 298 |
+
opt = torch.optim.AdamW([p for p in m.parameters() if p.requires_grad], lr=lr, weight_decay=0.05)
|
| 299 |
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=steps, pct_start=0.05)
|
| 300 |
+
best, state, bv = -1, None, None
|
| 301 |
+
m.train()
|
| 302 |
+
for step in range(1, steps + 1):
|
| 303 |
+
loss = 0
|
| 304 |
+
for task in ("mif", "rna", "text"):
|
| 305 |
+
a, X, pos = batch(task, rng)
|
| 306 |
+
za = nz(m.he(a))
|
| 307 |
+
if task == "mif":
|
| 308 |
+
loss = loss + clip_loss(m.lg("mif") * za @ nz(m.h("mif")(X["mif"])).T, pos)
|
| 309 |
+
else:
|
| 310 |
+
encs = RNA_E if task == "rna" else TXT_E
|
| 311 |
+
_, ze = m.fuse(task, encs, X)
|
| 312 |
+
loss = loss + sum(clip_loss(m.lg(f"{task}:{e}") * za @ ze[e].T, pos) for e in encs) / len(encs)
|
| 313 |
+
u = nz(a["uni2"])
|
| 314 |
+
loss = loss + distill * F.mse_loss(za @ za.T, u @ u.T)
|
| 315 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sch.step()
|
| 316 |
+
if step % EVAL_EVERY == 0 or step == steps:
|
| 317 |
+
sc, v = val_score(m)
|
| 318 |
+
if sc > best:
|
| 319 |
+
best, bv, state = sc, v, {k_: x.detach().clone() for k_, x in m.state_dict().items()}
|
| 320 |
+
m.load_state_dict(state); m.eval()
|
| 321 |
+
return m, bv
|
| 322 |
+
|
| 323 |
+
@torch.no_grad()
|
| 324 |
+
def evaluate(m, an):
|
| 325 |
+
R = lambda st, idx: m.fuse("rna", RNA_E, {e: Xr[e][st][idx] for e in RNA_E})[0]
|
| 326 |
+
Tx = lambda st, idx: m.fuse("text", TXT_E, {e: Xt[e][st][idx] for e in TXT_E})[0]
|
| 327 |
+
zm = nz(m.h("mif")(nif(IF_O[o["test"], 0])))
|
| 328 |
+
zt = Tx("alltxt", tt)
|
| 329 |
+
res = {("H&E<->mIF (ORION)", "trained"): g100(nz(m.he(anc("o", o["test"]))), zm),
|
| 330 |
+
("H&E<->RNA (HEST)", "trained"): g100(nz(m.he(anc("h", hs["test"]))), R("hest", hs["test"])),
|
| 331 |
+
("H&E<->text (TCGA)", "trained"): g100(nz(m.he(anc("t", ii))), zt),
|
| 332 |
+
("mIF<->RNA(virtual) (ORION)", "emergent"): g100(zm, R("orion_v", o["test"])),
|
| 333 |
+
("mIF<->text(synthetic) (ORION)", "emergent"): g100(zm, Tx("orion_gentext", o["test"])),
|
| 334 |
+
("text<->RNA(virtual) (TCGA)", "emergent"): g100(zt, R("tcga_v", ii)),
|
| 335 |
+
("text<->mIF(MIPHEI) (TCGA)", "emergent"): g100(zt, nz(m.h("mif")(nif(IF_V_T[ii]))))}
|
| 336 |
+
if m.adapter is None:
|
| 337 |
+
res[("H&E<->RNA (DLPFC)", "external")] = g100(nz(AN[an]["d"]), ZD[an])
|
| 338 |
+
return res
|
| 339 |
+
|
| 340 |
+
for cname, (an, mode, adapter) in CONF.items():
|
| 341 |
+
if (cname, k) in done:
|
| 342 |
+
continue
|
| 343 |
+
if mode is None:
|
| 344 |
+
m = Joint(HEADS[an], LOGITS[an], anchor=an).to(dev).eval() # separate: H&E = the frozen anchor itself
|
| 345 |
+
val = {}
|
| 346 |
+
else:
|
| 347 |
+
m, val = joint(mode, adapter)
|
| 348 |
+
for (pair, kind), r in evaluate(m, an).items():
|
| 349 |
+
rows.append({"config": cname, "fold": k, "pair": pair, "kind": kind, **r,
|
| 350 |
+
**{f"val_{t}": v for t, v in val.items()}})
|
| 351 |
+
pd.DataFrame(rows).to_csv(f_out, index=False)
|
| 352 |
+
print(f"fold {k} {cname:<10} " + " ".join(f"{r['pair'].split(' (')[0]} {r['R@1']:.3f}"
|
| 353 |
+
for r in rows if r["config"] == cname and r["fold"] == k), flush=True)
|
| 354 |
+
|
| 355 |
+
R = pd.DataFrame(rows)
|
| 356 |
+
S = R.groupby(["config", "kind", "pair"], sort=False)[["R@1", "R@10", "pair_acc"]].agg(["mean", "std"]).round(4)
|
| 357 |
+
S.to_csv(os.path.join(ROOT, "results", "medvtok_summary.csv"))
|
| 358 |
+
tests = []
|
| 359 |
+
for c, ref in [("EJ-frz", "E"), ("EJ-ad", "E"), ("EJ-ad", "EJ-frz"), ("MV-E", "E"), ("EJ-frz+MV", "EJ-frz"),
|
| 360 |
+
("EJ-ad+MV", "EJ-ad"), ("EJ-frz+MV", "E"), ("EJ-ad+MV", "E"),
|
| 361 |
+
("EJ-ad+RP", "EJ-ad"), ("EJ-ad+MV", "EJ-ad+RP")]:
|
| 362 |
+
for p in R.pair.unique():
|
| 363 |
+
a = R[(R.config == c) & (R.pair == p)].sort_values("fold")["R@1"].values
|
| 364 |
+
b = R[(R.config == ref) & (R.pair == p)].sort_values("fold")["R@1"].values
|
| 365 |
+
if len(a) != len(b) or len(a) < 2:
|
| 366 |
+
continue
|
| 367 |
+
tests.append({"config": c, "vs": ref, "pair": p, "ref": b.mean(), "new": a.mean(), "delta": (a - b).mean(),
|
| 368 |
+
"folds_won": int((a > b).sum()), "p_paired_t": stats.ttest_rel(a, b).pvalue})
|
| 369 |
+
pd.DataFrame(tests).round(4).to_csv(os.path.join(ROOT, "results", "medvtok_tests.csv"), index=False)
|
| 370 |
+
pd.set_option("display.width", 250)
|
| 371 |
+
print(R.pivot_table(index="config", columns="pair", values="R@1", sort=False).round(3).T.to_string())
|
| 372 |
+
print(R.pivot_table(index="config", columns="pair", values="pair_acc", sort=False).round(3).T.to_string())
|
| 373 |
+
print(pd.DataFrame(tests).round(4).to_string())
|
experiments/README_v1_orion.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ImageBind-style 4-modality binding on ORION (H&E / IF / nuclei / markers)
|
| 2 |
+
|
| 3 |
+
Data: `../ORION/ORIONCRC_dataset_tile_20x` (1% subsample), 3187 paired 333×333 tiles, 41 slides / 40 patients.
|
| 4 |
+
- **he**: H&E RGB → *anchor*, frozen ImageBind-Huge vision encoder (official `imagebind_huge.pth`), embeddings precomputed for the 8 dihedral views
|
| 5 |
+
- **if**: 17-ch multiplex IF → ResNet18 (ImageNet init, 17-ch stem) + linear head to 1024
|
| 6 |
+
- **nuclei**: instance mask → 2-ch (mask, boundary) → ResNet18
|
| 7 |
+
- **markers**: 17 `*_prop` + 16 log1p `*_count` columns → MLP
|
| 8 |
+
|
| 9 |
+
Loss: `anchor` = symmetric InfoNCE(he, m) for m ∈ {if, nuclei, markers} (ImageBind; IF↔nuclei, IF↔markers, nuclei↔markers are *emergent*);
|
| 10 |
+
`allpairs` = ablation adding the 3 non-anchor pairs. 40 epochs, AdamW 3e-4, bs 128, learnable temperature, model selection on val mean R@1.
|
| 11 |
+
|
| 12 |
+
Splits (`splits/`): `orion_official` (your csvs; 2954/124/109 tiles, 2 test slides), `random_tile` (tile-level 80/10/10, leaky upper bound),
|
| 13 |
+
`patient_5fold` (patient-grouped 5-fold CV), `batch_lobo` (leave-one-IF-acquisition-batch-out, 4 folds: C59kX/mX/nX/qX).
|
| 14 |
+
|
| 15 |
+
Metrics (test split, all 12 directed pairs): R@1/5/10, MRR, MedR, one-to-one **pairing accuracy** (Hungarian),
|
| 16 |
+
plus `*_g100` = same on random 100-tile galleries (comparable across splits; chance R@1 = 0.01), and a `test_tissue` subset (≥20 nuclei).
|
| 17 |
+
|
| 18 |
+
Pipeline: `01_analyze_data.py` → `02_make_splits.py` → `03_cache_data.py` → `04_extract_anchor.py` → `run_all.sh` (`05_train_eval.py` ×22) → `06_summarize.py`.
|
| 19 |
+
Outputs: `results/` (data_summary.json, figures, summary_by_pair.csv, summary_headline.csv, all_runs_long.csv), `runs/*/metrics.json`, test embeddings.
|
| 20 |
+
Env: `.venv` (scileo + tifffile/timm/imagecodecs).
|
| 21 |
+
|
| 22 |
+
## H&E encoder ablation (frozen anchor; `--anchor`, `04b_extract_anchor_fm.py`, `run_fm.sh <anchor>`)
|
| 23 |
+
| anchor | weights | dim |
|
| 24 |
+
|---|---|---|
|
| 25 |
+
| imagebind | ImageBind-Huge vision (natural images) | 1024 |
|
| 26 |
+
| uni2 | UNI2-h, local copy `reg2026/model_alldata/uni2-h.bin` (HF repo gated for this account) | 1536 |
|
| 27 |
+
| plip | vinid/plip — open pathology CLIP, stand-in for CONCH | 512 |
|
| 28 |
+
| phikonv2 | owkin/phikon-v2 ViT-L — open stand-in for GigaPath | 1024 |
|
| 29 |
+
| conch / gigapath | code ready; HF repos gated (GatedRepoError) — request access, then `04b_extract_anchor_fm.py conch` + `./run_fm.sh conch` | 512 / 1536 |
|
| 30 |
+
|
| 31 |
+
Results: `results/anchor_ablation_R1_g100.csv`, `results/anchor_ablation_pair_acc.csv`.
|
experiments/prompts.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"immune": {"label": "CD45_count", "text": "Dense lymphocytic inflammatory infiltrate composed of numerous small round lymphocytes and immune cells."},
|
| 3 |
+
"tumor_epith": {"label": "Pan-CK_count", "text": "Malignant glandular epithelium of colorectal adenocarcinoma with crowded, atypical, hyperchromatic nuclei."},
|
| 4 |
+
"stroma": {"label": "SMA_count", "text": "Fibrous desmoplastic stroma with spindle-shaped fibroblasts and smooth muscle bundles."},
|
| 5 |
+
"proliferation": {"label": "Ki67_count", "text": "Highly proliferative tumor with frequent mitotic figures and high cellular turnover."},
|
| 6 |
+
"vessels": {"label": "CD31_count", "text": "Numerous small blood vessels and capillaries lined by endothelial cells."},
|
| 7 |
+
"macrophage": {"label": "CD68_count", "text": "Sheets of foamy histiocytes and macrophages within the tissue."},
|
| 8 |
+
"cellularity": {"label": "n_nuclei", "text": "Hypercellular tissue densely packed with nuclei."}
|
| 9 |
+
}
|
experiments/run_all.sh
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Full experiment grid: 4 split schemes (11 folds) x 2 losses. Two workers on one GPU.
|
| 3 |
+
cd "$(dirname "$0")"; mkdir -p logs
|
| 4 |
+
jobs=()
|
| 5 |
+
for loss in anchor allpairs; do
|
| 6 |
+
jobs+=("orion_official 0 $loss" "random_tile 0 $loss")
|
| 7 |
+
for k in 0 1 2 3 4; do jobs+=("patient_5fold $k $loss"); done
|
| 8 |
+
for k in 0 1 2 3; do jobs+=("batch_lobo $k $loss"); done
|
| 9 |
+
done
|
| 10 |
+
printf '%s\n' "${jobs[@]}" | xargs -P 2 -L 1 bash -c '
|
| 11 |
+
[ -f runs/${0}_fold${1}_${2}/metrics.json ] && exit 0
|
| 12 |
+
.venv/bin/python 05_train_eval.py --split $0 --fold $1 --loss $2 > logs/${0}_fold${1}_${2}.log 2>&1 \
|
| 13 |
+
&& echo "DONE $0 $1 $2" || echo "FAILED $0 $1 $2"'
|
| 14 |
+
.venv/bin/python 06_summarize.py > logs/summary.log 2>&1; echo ALLDONE
|
experiments/run_bench.sh
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Encoder benchmark: 3 configs (one encoder per modality each; modalities train independently) x 6 split/folds.
|
| 3 |
+
cd "$(dirname "$0")"; mkdir -p logs/bench
|
| 4 |
+
cfgs=("resnet18 linear plip" "convnext_tiny mlp biomedclip" "chanset ftt imagebind")
|
| 5 |
+
jobs=()
|
| 6 |
+
for c in "${cfgs[@]}"; do
|
| 7 |
+
jobs+=("orion_official 0 $c")
|
| 8 |
+
for k in 0 1 2 3 4; do jobs+=("patient_5fold $k $c"); done
|
| 9 |
+
done
|
| 10 |
+
printf '%s\n' "${jobs[@]}" | xargs -P 2 -L 1 bash -c '
|
| 11 |
+
tag=${0}_fold${1}_uni2_${2}_${3}_${4}
|
| 12 |
+
[ -f runs/bench/$tag/test_embeddings.pt ] && exit 0
|
| 13 |
+
.venv/bin/python 09_bench.py --split $0 --fold $1 --if_enc $2 --mk_enc $3 --txt_enc $4 > logs/bench/$tag.log 2>&1 \
|
| 14 |
+
&& echo "DONE $tag" || echo "FAILED $tag"'
|
| 15 |
+
echo ALLDONE
|