Download embed.py from Pathstudy/fourmodal-pathology-bind: direct link, hf CLI and curl.
- Browser
- Download file 17.9 kB
-
https://huggingface.co/Pathstudy/fourmodal-pathology-bind/resolve/main/embed.py
- Command line
-
hf download hf://Pathstudy/fourmodal-pathology-bind/embed.py
-
curl -L -o embed.py https://huggingface.co/Pathstudy/fourmodal-pathology-bind/resolve/main/embed.py
17.9 kB
| """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 | |
| 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 | |
| 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() | |
| def mif_from_feats(self, virtues): | |
| return nz(self.model.mif(torch.as_tensor(np.asarray(virtues, np.float32)).to(self.dev))).cpu() | |
| 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() | |
| 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 | |
| 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)) | |
| 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) | |
| 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)) | |