File size: 17,913 Bytes
c2ede1f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 | """Four-modality embedding model: H&E, mIF (protein), RNA (spot expression) and text in ONE 1536-d space.
Configuration EJ-ad+MV (RESULTS.md, "MedVTok as H&E image information + adapter-only joint training"):
stage 1 every modality encoder gets its own head into the frozen UNI2-h space (InfoNCE against UNI2 on REAL pairs:
ORION H&E-mIF, HEST H&E-RNA, TCGA/PathGen H&E-text)
mIF : VirTues (16-marker ORION panel) -> MLP
RNA : hvg (log1p CP10k of 2000 HEST HVGs), Tahoe-x1 70M, SE-600M, AIDO.Cell-100M -> one linear head each
text : KEEP, BiomedCLIP (text tower), Qwen3-Embedding-0.6B -> one linear head each
RNA / text = late fusion: z = normalise(sum_e normalise(head_e(x_e)))
stage 2 all mIF / RNA / text heads frozen; only a zero-initialised residual H&E adapter (+ temperatures) trains:
H&E = UNI2 + A([UNI2, c * MedVTok])
so mIF / RNA / text embeddings (and every emergent mIF<->RNA<->text pairing) are exactly the stage-1 ones.
Checkpoints are written by 52_final_model.py (models/<name>/model.pt + meta.json).
Usage (python: .venv/bin/python -I, run from anywhere; GPU):
from embed import Embedder
E = Embedder("models/ej_ad_mv") # release model
z = E.he(images) # H&E tiles: list of PIL images / (N, H, W, 3) uint8; ~0.5 um/px, 112-166 um field
z = E.text(["Tumor cells with ...", ...])
z = E.rna(counts, genes, zscore="train") # raw counts (spots x genes; numpy or scipy.sparse), HGNC symbols
z = E.mif(tiles, slide) # (N, 16, H, W) 8-bit-scale mIF tiles, channels = MIF_MARKERS, + slide id per tile
sim = z_a @ z_b.T # every output: L2-normalised (N, 1536) float32 torch tensor on CPU
Feature-level entry points (precomputed encoder features, as in cache/):
E.he_from_feats(uni2, medvtok), E.mif_from_feats(virtues), E.rna_from_feats({enc: X}), E.text_from_feats({enc: X})
Raw-input pre-processing is exactly the one used to build the training features (functions imported from
04b_ / 14_ / 43_ / 45_ / 45b_ / 50_ logic), see each method. Notes:
* RNA zscore="train" uses the HEST-train feature statistics; zscore="self" z-scores each FM feature over the given
spots (what the external DLPFC test does; use for a new dataset of >= a few hundred spots).
* mIF standardisation (per-slide 0.99-quantile clip, log1p, mean / std, blur) is computed over the tiles given in
one call, so embed a whole dataset (or at least whole slides) at once.
* Licences of the frozen encoders: UNI2-h (MahmoodLab, research), MedVTok (MIT; aligned to CONCH v1.5, CC-BY-NC-ND),
SE-600M and AIDO.Cell (non-commercial), Tahoe-x1 (Apache-2.0), VirTues, KEEP, BiomedCLIP (MIT), Qwen3 (Apache-2.0).
"""
import importlib.util
import os
import sys
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
ROOT = os.path.dirname(os.path.abspath(__file__))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
DIM, MV_DIM = 1536, 1152
RNA_E, TXT_E = ["hvg", "tahoex1", "se600m", "aidocell"], ["keep", "biomedclip", "qwen3-0.6b"]
MIF_MARKERS = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e", "CD163",
"E-cadherin", "Ki67", "Pan-CK", "SMA"] # ORION panel without PD-1 (= 30_virtues_shared.py --m16)
UNI2_LOCAL = "/diskarray/home/tl688/reg2026/model_alldata/uni2-h.bin" # override with $UNI2_PATH
def uni2_weights():
"""UNI2-h state dict path: $UNI2_PATH, else the local copy, else the gated HF repo MahmoodLab/UNI2-h"""
p = os.environ.get("UNI2_PATH", UNI2_LOCAL)
if os.path.exists(p):
return p
from huggingface_hub import hf_hub_download
return hf_hub_download("MahmoodLab/UNI2-h", "pytorch_model.bin")
nz = lambda x: F.normalize(x, dim=-1)
def mlp(din, dout=DIM, hid=512): # = 42_ / 47_ / 48_ / 51_
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
nn.LayerNorm(hid), nn.Linear(hid, dout))
class Bind4(nn.Module):
"""Heads + H&E adapter. Inputs are raw encoder features; normalisation statistics live in buffers."""
def __init__(self, dims, adapter="cat", n_adapters=1):
"""dims: {'mif': 512, 'rna:hvg': 2000, ..., 'text:keep': 768, ...}; adapter None / 'u' / 'cat';
n_adapters > 1: seed ensemble, H&E = UNI2 + mean_s A_s(.)"""
super().__init__()
self.keys = list(dims)
self.heads = nn.ModuleDict({self._k(k): (mlp(d) if k == "mif" else nn.Linear(d, DIM)) for k, d in dims.items()})
self.logit = nn.ParameterDict({self._k(k): nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32))
for k in dims})
for k, d in dims.items(): # feature standardisation (identity where the encoder is used raw)
self.register_buffer(f"mu_{self._k(k)}", torch.zeros(d))
self.register_buffer(f"sd_{self._k(k)}", torch.ones(d))
self.register_buffer(f"zs_{self._k(k)}", torch.tensor(False)) # True once statistics are set
self.adapter = adapter
self.register_buffer("c_mv", torch.tensor(1.0))
self.ah = None
if adapter:
self.ah = nn.ModuleList([self.new_adapter() for _ in range(n_adapters)])
def new_adapter(self):
"""zero-initialised output -> starts exactly at H&E = UNI2"""
a = nn.Sequential(nn.Linear(DIM + (MV_DIM if self.adapter == "cat" else 0), 512), *mlp(DIM)[1:])
nn.init.zeros_(a[-1].weight); nn.init.zeros_(a[-1].bias)
return a
@staticmethod
def _k(k):
return k.replace(":", "__").replace(".", "_").replace("-", "_")
def h(self, k):
return self.heads[self._k(k)]
def lg(self, k):
return self.logit[self._k(k)].exp().clamp(max=100)
def norm(self, k, x):
return (x - getattr(self, f"mu_{self._k(k)}")) / getattr(self, f"sd_{self._k(k)}")
def set_norm(self, k, mu, sd):
"""x -> (x - mu) / sd; sd already includes the epsilon of the training code"""
getattr(self, f"mu_{self._k(k)}").copy_(mu); getattr(self, f"sd_{self._k(k)}").copy_(sd)
getattr(self, f"zs_{self._k(k)}").fill_(True)
# ---- embeddings from (raw, un-normalised) encoder features; return un-normalised vectors
def he(self, uni2, medvtok=None):
if self.ah is None:
return uni2
inp = uni2 if self.adapter == "u" else torch.cat([uni2, self.c_mv * medvtok], -1)
return uni2 + sum(a(inp) for a in self.ah) / len(self.ah)
def mif(self, x):
return self.h("mif")(self.norm("mif", x))
def fuse(self, mod, X, zscore="train"):
"""X: {enc: raw features}; zscore 'train' (stored stats) / 'self' (stats of X itself, raw encoders untouched)"""
z = {}
for e, x in X.items():
k = f"{mod}:{e}"
raw = not bool(getattr(self, f"zs_{self._k(k)}"))
x = x if raw else (x - x.mean(0)) / (x.std(0) + 1e-6) if zscore == "self" else self.norm(k, x)
z[e] = nz(self.h(k)(x))
return nz(sum(z.values())), z
def _load_script(fname, argv=None):
"""import one of the numbered project scripts as a module (their CLI code is under __main__ / argv-guarded)"""
old = sys.argv
if argv is not None:
sys.argv = argv
try:
spec = importlib.util.spec_from_file_location("m_" + fname.split(".")[0], os.path.join(ROOT, fname))
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
finally:
sys.argv = old
return m
def _as_uint8_list(images):
from PIL import Image, PngImagePlugin
PngImagePlugin.MAX_TEXT_CHUNK = max(PngImagePlugin.MAX_TEXT_CHUNK, 64 * 1024 * 1024) # PathGen PNGs: huge ICC profiles
out = []
for im in images:
if isinstance(im, (str, os.PathLike)):
im = Image.open(im)
out.append(im.convert("RGB") if isinstance(im, Image.Image) else Image.fromarray(np.asarray(im, np.uint8)))
return out
class Embedder:
def __init__(self, path=os.path.join(ROOT, "models", "ej_ad_mv"), device="cuda"):
import json
self.dev = device
ck = torch.load(os.path.join(path, "model.pt"), map_location="cpu", weights_only=True)
self.meta = json.load(open(os.path.join(path, "meta.json")))
self.model = Bind4(self.meta["dims"], self.meta["adapter"], self.meta.get("n_adapters", 1))
self.model.load_state_dict(ck, strict=True)
self.model.eval().to(device)
self.hvg = np.asarray(self.meta["hvg_genes"], dtype=str)
self._enc = {}
# ------------------------------------------------------------------ feature-level
@torch.no_grad()
def he_from_feats(self, uni2, medvtok=None):
t = lambda a: None if a is None else torch.as_tensor(np.asarray(a, np.float32)).to(self.dev)
return nz(self.model.he(t(uni2), t(medvtok))).cpu()
@torch.no_grad()
def mif_from_feats(self, virtues):
return nz(self.model.mif(torch.as_tensor(np.asarray(virtues, np.float32)).to(self.dev))).cpu()
@torch.no_grad()
def rna_from_feats(self, feats, zscore="train"):
X = {e: torch.as_tensor(np.asarray(x, np.float32)).to(self.dev) for e, x in feats.items()}
return self.model.fuse("rna", X, zscore)[0].cpu()
@torch.no_grad()
def text_from_feats(self, feats):
X = {e: torch.as_tensor(np.asarray(x, np.float32)).to(self.dev) for e, x in feats.items()}
return self.model.fuse("text", X)[0].cpu()
# ------------------------------------------------------------------ frozen encoders (lazy)
def _get(self, name):
if name in self._enc:
return self._enc[name]
if name == "uni2": # = 04b_extract_anchor_fm.py
import timm
m = timm.create_model(
"vit_giant_patch14_224", pretrained=False, img_size=224, patch_size=14, depth=24, num_heads=24,
init_values=1e-5, embed_dim=1536, mlp_ratio=2.66667 * 2, num_classes=0, no_embed_class=True,
mlp_layer=timm.layers.SwiGLUPacked, act_layer=torch.nn.SiLU, reg_tokens=8, dynamic_img_size=True)
m.load_state_dict(torch.load(uni2_weights(), map_location="cpu", weights_only=True), strict=True)
obj = m.eval().to(self.dev)
elif name == "medvtok": # = 50_medvtok_feats.py
import json
sys.path.insert(0, os.path.join(ROOT, "ext", "medvtok", "repo"))
from models.medvtok import MedVTok
w = os.path.join(ROOT, "ext", "medvtok", "weights")
cfg = json.load(open(os.path.join(w, "config.json")))
m = MedVTok(**cfg["model_kwargs"])
m.load_state_dict(torch.load(os.path.join(w, "ckpt.pth"), map_location="cpu", weights_only=True), strict=True)
del m.decoder
obj = m.eval().to(self.dev)
elif name == "keep":
obj = _load_script("45b_text_feats_pathvlm.py").KEEPText().to(self.dev).eval()
elif name in ("biomedclip", "qwen3-0.6b"):
obj = _load_script("45_text_feats.py", [sys.argv[0]]).encoder(name)[0]
elif name == "tahoex1":
T = _load_script("43_rnafm_tahoex1.py")
obj = (T, *T.load_model())
elif name == "se600m":
S = _load_script("43_rnafm_se600m.py")
obj = (S, *S.build())
elif name == "aidocell":
A = _load_script("43_rnafm_aidocell.py")
obj = (A, *A.load_model())
elif name == "virtues":
V = _load_script("14_extract_if_fm.py", [sys.argv[0], "virtues", "--no_probe"])
uni = dict(V.MARKERS)
model, midx = V.load_virtues([uni[m] for m in MIF_MARKERS])
obj = (V, model, midx.to(self.dev))
else:
raise KeyError(name)
self._enc[name] = obj
return obj
def release(self):
"""free the frozen encoders (GPU memory)"""
self._enc.clear(); torch.cuda.empty_cache()
# ------------------------------------------------------------------ raw-input embedding
@torch.no_grad()
def he_feats(self, images, bs=32):
"""UNI2-h CLS (224 px PIL bicubic, ImageNet norm, fp16) + MedVTok feats (256 px PIL bicubic, [-1, 1], bf16)"""
ims = _as_uint8_list(images)
uni, mv = self._get("uni2"), self._get("medvtok")
mean = torch.tensor([0.485, 0.456, 0.406], device=self.dev).view(1, 3, 1, 1)
std = torch.tensor([0.229, 0.224, 0.225], device=self.dev).view(1, 3, 1, 1)
U, M = [], []
for s in range(0, len(ims), bs):
b = ims[s:s + bs]
x = torch.from_numpy(np.stack([np.asarray(i.resize((224, 224), 3)) for i in b])).to(self.dev)
x = (x.permute(0, 3, 1, 2).float() / 255 - mean) / std
with torch.autocast("cuda", dtype=torch.float16):
U.append(uni(x).float().cpu())
x = torch.from_numpy(np.stack([np.asarray(i.resize((256, 256), 3)) for i in b])).to(self.dev)
x = x.permute(0, 3, 1, 2).float() / 255 * 2 - 1
with torch.autocast("cuda", dtype=torch.bfloat16):
M.append(mv([i for i in x], sample_posterior=False, decode=False, return_batched_tensor=False)["feats"].float().cpu())
return torch.cat(U), torch.cat(M)
def he(self, images, bs=32):
return self.he_from_feats(*self.he_feats(images, bs))
@torch.no_grad()
def text_feats(self, texts, bs=64):
texts = ["" if t is None else str(t) for t in texts]
order = np.argsort([len(t) for t in texts]) # length-sorted batches as in 45_ / 45b_
out = {}
for e in TXT_E:
f = self._get(e)
E = None
for i in range(0, len(texts), bs):
idx = order[i:i + bs]
if e == "keep": # 45b_: fp32
z = f([texts[j] for j in idx]).float().cpu().numpy()
else: # 45_: bf16 autocast
with torch.autocast("cuda", dtype=torch.bfloat16):
z = f([texts[j] for j in idx]).float().cpu().numpy()
if E is None:
E = np.zeros((len(texts), z.shape[1]), np.float32)
E[idx] = z
out[e] = E.astype(np.float16).astype(np.float32) # the training features were stored as float16
return out
def text(self, texts, bs=64):
return self.text_from_feats(self.text_feats(texts, bs))
def rna_feats(self, counts, genes, encoders=RNA_E):
import scipy.sparse as sp
X = sp.csr_matrix(counts, dtype=np.float32)
X.eliminate_zeros()
genes = np.asarray(genes, dtype=str)
out = {}
for e in encoders:
if e == "hvg": # 21_hest_prep.py: log1p CP10k over the given genes, then the 2000 HEST HVGs (absent -> 0)
tot = np.asarray(X.sum(1)).ravel()
L = (sp.diags(1e4 / np.clip(tot, 1, None)) @ X).tocsc()
L.data = np.log1p(L.data)
pos = {}
for j, g in enumerate(genes):
pos.setdefault(g, j)
cols = [pos.get(g, -1) for g in self.hvg]
H = np.zeros((X.shape[0], len(self.hvg)), np.float32)
ok = [i for i, c in enumerate(cols) if c >= 0]
H[:, ok] = L[:, [cols[i] for i in ok]].toarray()
out[e] = H
elif e == "tahoex1":
T, m, _, ccfg, vocab = self._get(e)
seqs, _ = T.make_seqs(X, genes, "user", vocab, ccfg, np.random.default_rng(0))
out[e] = T.embed(m, seqs)
elif e == "se600m":
S, m, cfg, g2i = self._get(e)
Tk, C, _ = S.sentences(X, genes, g2i, cfg["dataset"]["pad_length"], cfg["dataset"]["cls_token_idx"], seed=0)
out[e] = S.embed(m, Tk, C)
elif e == "aidocell":
A, m, _ = self._get(e)
Xa, present = A.align(X, genes, "user")
out[e] = A.embed(m, Xa, present)
out[e] = np.asarray(out[e], np.float32).astype(np.float16).astype(np.float32) # stored as float16
return out
def rna(self, counts, genes, zscore="train", encoders=RNA_E):
return self.rna_from_feats(self.rna_feats(counts, genes, encoders), zscore)
@torch.no_grad()
def mif_feats(self, tiles, slide, size=168, bs=32):
"""tiles (N, 16, H, W) in MIF_MARKERS order, 8-bit intensity scale (0-255); 14_ preprocessing: antialiased
bilinear resize to `size` px (~1 um/px for a 166 um tile), per-slide 0.99-quantile clip + log1p, mean / std
over the given tiles, 3x3 Gaussian blur; VirTues mean patch-summary token (fp16)."""
V, model, midx = self._get("virtues")
N = len(tiles)
X = np.empty((N, len(MIF_MARKERS), size, size), np.float16)
for s in range(0, N, 64):
x = torch.as_tensor(np.asarray(tiles[s:s + 64], np.float32)).to(self.dev)
X[s:s + 64] = F.interpolate(x, size=(size, size), mode="bilinear", antialias=True,
align_corners=False).clamp(min=0).half().cpu().numpy()
X = V.standardize(X, np.asarray(slide))[0]
out = np.zeros((N, 512), np.float32)
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
for s in range(0, N, bs):
x = torch.from_numpy(X[s:s + bs]).to(self.dev).float()
ps = model.encoder.forward_list([c for c in x], [midx] * len(x)).patch_summary_tokens
out[s:s + bs] = torch.stack([p.float().mean((0, 1)) for p in ps]).cpu().numpy()
return out.astype(np.float16).astype(np.float32)
def mif(self, tiles, slide, size=168):
return self.mif_from_feats(self.mif_feats(tiles, slide, size))
|