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))