Download 14_extract_if_fm.py from Pathstudy/fourmodal-pathology-bind: direct link, hf CLI and curl.
- Browser
- Download file 22.8 kB
-
https://huggingface.co/Pathstudy/fourmodal-pathology-bind/resolve/main/14_extract_if_fm.py
- Command line
-
hf download hf://Pathstudy/fourmodal-pathology-bind/14_extract_if_fm.py
-
curl -L -o 14_extract_if_fm.py https://huggingface.co/Pathstudy/fourmodal-pathology-bind/resolve/main/14_extract_if_fm.py
22.8 kB
| """Frozen spatial-proteomics foundation-model embeddings of every ORION multiplex-IF tile under the 8 dihedral transforms. | |
| Usage: python 14_extract_if_fm.py virtues [--no_hoechst] [--qc_drop] [--bs 4] [--no_probe] | |
| Output: cache/if_virtues.npy (N=3187, 8, 512) float16 (raw features; row i = tile_id i, axis 1 = common.dihedral d). | |
| (--no_hoechst / --qc_drop write cache/if_virtues_nohoechst.npy / if_virtues_qcdrop.npy instead.) | |
| VirTues (Wenckstern et al., Nature 2026; github.com/bunnelab/virtues, HF bunnelab/virtues, weights virtues-sp32) | |
| --------------------------------------------------------------------------------------------------------------- | |
| Model: MultiplexVirtues with the released base_config (patch 8, dim 512, encoder 'hvhvhvhvhvhvhvhv', RoPE), ESM-2 | |
| marker priors. Only the encoder is used (no masking). Input = list of (C,H,W) standardised crops + marker indices. | |
| Tile embedding (pooling): mean over the spatial grid of VirTues *patch summary tokens* (the per-8x8-patch summary | |
| token that attends across all markers; `encoder.forward_list(...).patch_summary_tokens`, shape (H/8, W/8, 512)). | |
| VirTues has no global/CLS token; its own tile/tissue-level tooling (spora-bench SporaVirTuesWrapper.embed_tile / | |
| embed_tissue) returns exactly these patch summary tokens as a bag (then ABMIL), and cell/niche tokens are | |
| area-weighted averages of them (virtues.utils.cell_tokens). A uniform mean over the whole tile = the niche token of | |
| the full 166 um tile. Encoding runs in fp16 autocast, like the spora-bench wrapper. | |
| Marker -> UniProt (ESM-2 esm2_t30_150M_UR50D, mean over residues of layer 30, computed with fair-esm exactly as | |
| virtues/utils/compute_esm_embeddings.py; FASTA from rest.uniprot.org; saved as ext/marker_embeddings/esm2_t30_150M_UR50D/[ID].pt): | |
| Hoechst P68431 (Histone H3.1) -- spora flags DNA stains `is_nuclear_marker` with no UniProt ID, so VirTues' | |
| `uniprot_filtered` loading DROPS them (spora-bench VirTues config for lin2023highsubset | |
| = the ORION cohort). spora-io's `replace_nuclear_uniprot_ids=True` instead maps them to | |
| Histone H3 P68431 (used for lin2023high in spora-bench). Default here: keep Hoechst as | |
| P68431 (H3 IMC channels are in VirTues' pretraining vocabulary); --no_hoechst drops it. | |
| CD31 P16284 (PECAM1) | CD45 P08575 (PTPRC canonical, as in spora examples) | CD68 P34810 | CD4 P01730 | FOXP3 Q9BZS1 | |
| CD8a P01732 | CD45RO P08575-4 (UniProt PTPRC isoform 2 = "CD45R0/CD45RO"; spora uses isoform IDs, e.g. CD45RA P08575-8) | |
| CD20 P11836 (MS4A1) | PD-L1 Q9NZQ7 (CD274) | CD3e P07766 | CD163 Q86VB7 | E-cadherin P12830 (CDH1) | PD-1 Q15116 | |
| Ki67 P46013 | Pan-CK P02533 (KRT14; the keratin spora/VirTues assign to pan-cytokeratin channels, e.g. cords2024cancer | |
| "Pan Cytokeratin + Keratin Epithelial") | SMA P62736 (ACTA2) | |
| Preprocessing (mirrors spora-bench configs/datasets/virtues/lin2023highsubset.yaml: | |
| standardization quantile_clipping_log1p/uq_0.99_image, resolution 1.0 um/px, filter gaussian_blur, use_mean_std, | |
| disable_quantile_mask=False): | |
| 1. resolution: ORION tiles are 512 px crops of the 0.325 um/px Orion scan resized to 333 px (0.4997 um/px, 166.4 um). | |
| Resampled to 168x168 px (0.991 um/px; 168 = 21 patches of 8) with antialiased bilinear interpolation. | |
| 2. per-"image" upper quantile: for every slide (spora tissue = one ORION slide) and channel, the 0.99 quantile of all | |
| resampled pixels of that slide's tiles; x <- log1p(clip(x, 0, uq)). | |
| 3. dataset-level per-channel mean/std of the clipped log1p values over all ORION tiles; x <- (x - mean) / (std + 1e-8). | |
| 4. 3x3 Gaussian blur (sigma 1, reflect padding; torchvision v2.GaussianBlur as in spora-io FilterFactory). | |
| 5. quantile mask: spora drops a (slide, channel) whose upper quantile is 0. On these 8-bit tiles that happens for | |
| 52/697 (slide, channel) pairs (FOXP3 in 25/41 slides, Ki67 6, CD20/CD8a/PD-L1 5, CD31/CD45RO 3) purely because of | |
| 8-bit quantisation of sparse markers, and would give slide-dependent channel sets (a slide/batch confound). Default | |
| here: uq is floored at 1 grey level and all channels are kept for every tile; --qc_drop reproduces spora's drop. | |
| 6. dihedral d (common.dihedral on the (C,H,W) tensor) applied to the standardised 168 px tile; all 8 encoded. | |
| The whole 168 px tile is encoded in one pass (21x21 patch grid; RoPE positions so non-128 sizes are supported). | |
| Deviations from the VirTues/spora preprocessing (documented): | |
| * Input intensities are the 8-bit (0-255) ORION tile exports, not the raw 16-bit Orion counts spora stores; because | |
| log1p is scale-dependent the clipped-log values are compressed differently (z-scoring per channel partly absorbs it). | |
| * Quantiles / means / stds come from the 3187 ORION tiles (all pixels, no tissue mask), not whole-slide tissue pixels. | |
| * VirTues was pretrained on 128x128 crops; tiles here are 168x168 (whole tile, no sliding window). | |
| * uq floored at 1 grey level instead of spora's per-slide channel drop (step 5). | |
| * PD-1 in these exports is a near-saturated, almost constant channel (log1p mean 4.65, std 0.16; PD-1_prop ~ 1 for | |
| most tiles); the per-channel z-scoring therefore amplifies its background texture. Kept as-is (faithful pipeline). | |
| * Hoechst is kept as Histone H3 (P68431) by default; VirTues' own ORION config drops DNA stains (see --no_hoechst). | |
| * flash_attn is not installed for this torch build: flash_attn_varlen_qkvpacked_func is replaced by an exact | |
| PyTorch SDPA implementation over the same variable-length sequences (same math, softmax scale 1/sqrt(d)). | |
| """ | |
| import os | |
| import sys | |
| import time | |
| import types | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # less fragmentation on the shared GPU | |
| import numpy as np | |
| import tifffile | |
| import torch | |
| import torch.nn.functional as F | |
| from common import CACHE, ORION, ROOT, dihedral, load_all_tiles | |
| assert sys.argv[1] == "virtues", "usage: python 14_extract_if_fm.py virtues [--no_hoechst] [--qc_drop] [--bs N] [--no_probe]" | |
| NO_HOECHST = "--no_hoechst" in sys.argv | |
| QC_DROP = "--qc_drop" in sys.argv # spora behaviour: drop (slide, channel) with 0.99-quantile == 0 (default: floor at 1) | |
| BS = int(sys.argv[sys.argv.index("--bs") + 1]) if "--bs" in sys.argv else 4 # tiles per forward (x8 dihedrals = 32 crops, ~4.7 GiB peak) | |
| dev = "cuda" | |
| VIRTUES_REPO = os.path.join(ROOT, "ext", "virtues") | |
| EMB_ROOT = os.path.join(ROOT, "ext", "marker_embeddings") | |
| ESM_NAME = "esm2_t30_150M_UR50D" | |
| EMB_DIR = os.path.join(EMB_ROOT, ESM_NAME) | |
| WEIGHTS = ("virtues-sp32/model.safetensors", "virtues-sp31/model.safetensors") | |
| SIZE, MPP_IN = 168, 0.325 * 512 / 333 # 333 px tile = 512 px @ 0.325 um/px -> 0.4997 um/px; 168 px ~ 0.991 um/px | |
| # channel order of the ORION IF tiles (= the *_prop columns of the dataframes) | |
| MARKERS = [("Hoechst", "P68431"), ("CD31", "P16284"), ("CD45", "P08575"), ("CD68", "P34810"), ("CD4", "P01730"), | |
| ("FOXP3", "Q9BZS1"), ("CD8a", "P01732"), ("CD45RO", "P08575-4"), ("CD20", "P11836"), ("PD-L1", "Q9NZQ7"), | |
| ("CD3e", "P07766"), ("CD163", "Q86VB7"), ("E-cadherin", "P12830"), ("PD-1", "Q15116"), ("Ki67", "P46013"), | |
| ("Pan-CK", "P02533"), ("SMA", "P62736")] | |
| # ----------------------------------------------------------------------------------------------- marker embeddings | |
| def ensure_marker_embeddings(ids): | |
| """ESM-2 mean-pooled embeddings, identical to virtues/utils/compute_esm_embeddings.py (fair-esm, layer 30).""" | |
| import requests | |
| fasta_dir = os.path.join(EMB_ROOT, "fasta") | |
| os.makedirs(fasta_dir, exist_ok=True) | |
| os.makedirs(EMB_DIR, exist_ok=True) | |
| todo = [u for u in ids if not os.path.exists(os.path.join(EMB_DIR, f"{u}.pt"))] | |
| if not todo: | |
| return | |
| import esm | |
| _load = torch.load # official fair-esm checkpoints pickle argparse.Namespace -> need weights_only=False | |
| torch.load = lambda *a, **k: _load(*a, **{**k, "weights_only": False}) | |
| try: | |
| model, alphabet = esm.pretrained.load_model_and_alphabet(ESM_NAME) | |
| finally: | |
| torch.load = _load | |
| # small one-off job: GPU if the shared card has room, else CPU (the repo recommends CPU for long sequences) | |
| edev = "cuda" if torch.cuda.is_available() and torch.cuda.mem_get_info()[0] > 4 * 2**30 else "cpu" | |
| model.eval().to(edev) | |
| conv = alphabet.get_batch_converter() | |
| for u in todo: | |
| fp = os.path.join(fasta_dir, f"{u}.fasta") | |
| if not os.path.exists(fp): | |
| r = requests.get(f"https://rest.uniprot.org/uniprotkb/{u}.fasta", timeout=60) | |
| r.raise_for_status() | |
| open(fp, "w").write(r.text) | |
| seq = "".join(l.strip() for l in open(fp) if not l.startswith(">")) | |
| _, _, tok = conv([(u, seq)]) | |
| n = int((tok != alphabet.padding_idx).sum()) | |
| with torch.no_grad(): | |
| # return_contacts only adds the contact head (repo passes True); representations are identical, and | |
| # stacking all attention maps for Ki67 (3256 aa) would need 25 GB | |
| rep = model(tok.to(edev), repr_layers=[30], return_contacts=False)["representations"][30] | |
| emb = rep[0, 1:n - 1].mean(0).cpu() | |
| torch.save(emb, os.path.join(EMB_DIR, f"{u}.pt")) | |
| print(f" ESM-2 {u}: len {len(seq)} -> {tuple(emb.shape)}", flush=True) | |
| del model | |
| torch.cuda.empty_cache() | |
| def check_against_repo_embeddings(ids): | |
| """The repo ships ESM-2 embeddings for its example dataset; compare on shared UniProt IDs.""" | |
| ref = os.path.join(VIRTUES_REPO, "assets", "example_dataset", "marker_embeddings") | |
| shared = [u for u in ids if os.path.exists(os.path.join(ref, f"{u}.pt"))] | |
| if shared: | |
| cs = [F.cosine_similarity(torch.load(os.path.join(EMB_DIR, f"{u}.pt")).float(), | |
| torch.load(os.path.join(ref, f"{u}.pt")).float(), 0).item() for u in shared] | |
| mx = max((torch.load(os.path.join(EMB_DIR, f"{u}.pt")) - torch.load(os.path.join(ref, f"{u}.pt"))).abs().max().item() | |
| for u in shared) | |
| print(f"marker embeddings vs repo example set ({len(shared)} shared IDs): min cos {min(cs):.6f}, max|diff| {mx:.2e}") | |
| # ----------------------------------------------------------------------------------------------- flash-attn shim | |
| def install_flash_attn_shim(): | |
| """Exact SDPA replacement for flash_attn_varlen_qkvpacked_func (qkv: (total, 3, H, D), cu_seqlens: (n+1,)).""" | |
| def varlen_qkvpacked(qkv, cu_seqlens, max_seqlen, dropout_p=0.0, softmax_scale=None, causal=False, **_): | |
| total, _, H, D = qkv.shape | |
| cu = cu_seqlens.tolist() | |
| lens = np.diff(cu) | |
| out = torch.empty(total, H, D, dtype=qkv.dtype, device=qkv.device) | |
| for L in np.unique(lens): | |
| if L == 0: | |
| continue | |
| starts = np.asarray(cu[:-1])[lens == L] | |
| if len(starts) == len(lens): # all sequences same length -> contiguous reshape | |
| q = qkv.view(len(lens), L, 3, H, D) | |
| idx = None | |
| else: | |
| idx = (torch.as_tensor(starts, device=qkv.device)[:, None] + torch.arange(L, device=qkv.device)).reshape(-1) | |
| q = qkv[idx].view(len(starts), L, 3, H, D) | |
| q = q.permute(2, 0, 3, 1, 4) # 3, n, H, L, D | |
| o = F.scaled_dot_product_attention(q[0], q[1], q[2], dropout_p=dropout_p, is_causal=causal, | |
| scale=softmax_scale) # n, H, L, D | |
| o = o.transpose(1, 2).reshape(-1, H, D) | |
| if idx is None: | |
| out = o.contiguous() | |
| else: | |
| out[idx] = o | |
| return out | |
| pkg = types.ModuleType("flash_attn") | |
| pkg.__path__ = [] | |
| sub = types.ModuleType("flash_attn.flash_attn_interface") | |
| sub.flash_attn_varlen_qkvpacked_func = varlen_qkvpacked | |
| pkg.flash_attn_interface = sub | |
| sys.modules.setdefault("flash_attn", pkg) | |
| sys.modules.setdefault("flash_attn.flash_attn_interface", sub) | |
| def load_virtues(marker_ids): | |
| install_flash_attn_shim() | |
| if VIRTUES_REPO not in sys.path: | |
| sys.path.insert(0, VIRTUES_REPO) | |
| from huggingface_hub import hf_hub_download | |
| from safetensors.torch import load_file | |
| from virtues.modules.multiplex_virtues import MultiplexVirtues | |
| from virtues.utils.utils import load_marker_embedding_dict, load_marker_embeddings | |
| emb = load_marker_embeddings(EMB_DIR) | |
| emb_dict = load_marker_embedding_dict(EMB_DIR) | |
| model = MultiplexVirtues(prior_bias_embeddings=emb) # defaults == configs/base_config.yaml (released model) | |
| for w in WEIGHTS: | |
| try: | |
| sd = load_file(hf_hub_download("bunnelab/virtues", w)) | |
| model.load_state_dict(sd, strict=True) | |
| print("loaded", w) | |
| break | |
| except Exception as e: # fall back to sp31 | |
| print("could not load", w, repr(e)[:200]) | |
| else: | |
| raise RuntimeError("no VirTues weights could be loaded") | |
| model.eval().to(dev) | |
| midx = torch.tensor([emb_dict[u] for u in marker_ids], dtype=torch.long) | |
| return model, midx | |
| # ----------------------------------------------------------------------------------------------- data | |
| def load_resampled(df): | |
| """Raw uint8 tiles -> (N,17,SIZE,SIZE) float16 at ~1 um/px, plus raw per-channel mean intensity (baseline).""" | |
| N = len(df) | |
| X = np.empty((N, 17, SIZE, SIZE), np.float16) | |
| raw_mean = np.empty((N, 17), np.float32) | |
| paths = [os.path.join(ORION, p) for p in df.target_path] | |
| chunk = 64 | |
| for s in range(0, N, chunk): | |
| t = np.stack([tifffile.imread(p) for p in paths[s:s + chunk]]) # n,333,333,17 uint8 | |
| x = torch.from_numpy(t).to(dev).permute(0, 3, 1, 2).float() | |
| raw_mean[s:s + chunk] = x.mean((2, 3)).cpu().numpy() | |
| X[s:s + chunk] = F.interpolate(x, size=(SIZE, SIZE), mode="bilinear", antialias=True, | |
| align_corners=False).clamp(min=0).half().cpu().numpy() | |
| if s % 640 == 0: | |
| print(f" read {s}/{N}", flush=True) | |
| return X, raw_mean | |
| def standardize(X, slide): | |
| """quantile_clipping_log1p / uq_0.99_image + global mean/std + gaussian blur (in place, float16).""" | |
| from torchvision.transforms.v2.functional import gaussian_blur | |
| N, C = X.shape[:2] | |
| slides = np.unique(slide) | |
| uq = np.zeros((len(slides), C), np.float32) | |
| n_zero = np.zeros((len(slides), C), bool) | |
| s1, s2, cnt = np.zeros(C), np.zeros(C), np.zeros(C) | |
| for k, sl in enumerate(slides): | |
| idx = np.where(slide == sl)[0] | |
| x = torch.from_numpy(X[idx]).to(dev).float().transpose(0, 1).reshape(C, -1) # C, n*H*W | |
| # torch.quantile is limited to 2**24 elements: very large slides use a fixed random pixel subsample | |
| qs = x if x.shape[1] <= 2 ** 24 else x[:, torch.randperm(x.shape[1], generator=torch.Generator().manual_seed(0))[: 2 ** 24].to(x.device)] | |
| uq[k] = np.array([torch.quantile(qs[c], 0.99).item() for c in range(C)]) | |
| n_zero[k] = uq[k] <= 0 | |
| if not QC_DROP: # 8-bit tiles: sparse markers (FOXP3, ...) often have uq == 0 -> floor at one grey level | |
| uq[k] = np.maximum(uq[k], 1.0) | |
| u = torch.from_numpy(uq[k]).to(dev)[:, None] | |
| v = torch.log1p(torch.minimum(x, u)) | |
| ok = (uq[k] > 0) | |
| s1[ok] += v.sum(1).double().cpu().numpy()[ok] | |
| s2[ok] += (v.double() ** 2).sum(1).cpu().numpy()[ok] | |
| cnt[ok] += v.shape[1] | |
| mean = s1 / cnt | |
| std = np.sqrt(s2 / cnt - mean ** 2) | |
| valid = uq > 0 # spora quantile mask: drop (slide, channel) with zero upper quantile | |
| slide_k = {sl: k for k, sl in enumerate(slides)} | |
| m_t = torch.tensor(mean, dtype=torch.float32, device=dev).view(1, C, 1, 1) | |
| s_t = torch.tensor(std, dtype=torch.float32, device=dev).view(1, C, 1, 1) | |
| for s in range(0, N, 128): | |
| ks = [slide_k[sl] for sl in slide[s:s + 128]] | |
| u = torch.from_numpy(uq[ks]).to(dev)[:, :, None, None] | |
| x = torch.from_numpy(X[s:s + 128]).to(dev).float() | |
| x = (torch.log1p(torch.minimum(x, u)) - m_t) / (s_t + 1e-8) | |
| x = gaussian_blur(x, kernel_size=[3, 3], sigma=[1.0, 1.0]) | |
| X[s:s + 128] = x.half().cpu().numpy() | |
| return X, uq, mean, std, valid, slide_k, n_zero | |
| # ----------------------------------------------------------------------------------------------- sanity checks | |
| def sanity(out, raw_mean, markers, groups): | |
| from scipy.stats import spearmanr | |
| from sklearn.linear_model import RidgeCV | |
| from sklearn.model_selection import GroupKFold, KFold, cross_val_predict | |
| from sklearn.pipeline import make_pipeline | |
| from sklearn.preprocessing import StandardScaler | |
| o = out.astype(np.float32) | |
| print("\n=== sanity checks ===") | |
| print("finite:", bool(np.isfinite(o).all()), "| shape", out.shape, out.dtype) | |
| z = o[:, 0] | |
| zn = z / np.linalg.norm(z, axis=1, keepdims=True) | |
| rng = np.random.default_rng(0) | |
| s = rng.choice(len(z), min(1000, len(z)), replace=False) | |
| cos = zn[s] @ zn[s].T | |
| off = cos[~np.eye(len(s), dtype=bool)] | |
| zc = z - z.mean(0) | |
| zcn = zc / np.linalg.norm(zc, axis=1, keepdims=True) | |
| offc = (zcn[s] @ zcn[s].T)[~np.eye(len(s), dtype=bool)] | |
| sd = z.std(0) | |
| ev = np.linalg.eigvalsh(np.cov(z.T))[::-1] | |
| print(f"mean pairwise cosine (raw) {off.mean():.4f} (min {off.min():.4f}) | after centering {offc.mean():.4f}") | |
| print(f"per-dim std: min {sd.min():.4f} median {np.median(sd):.4f} max {sd.max():.4f}; " | |
| f"dims with std<1e-3: {(sd < 1e-3).sum()}; PCA participation ratio {ev.sum() ** 2 / (ev ** 2).sum():.1f}; " | |
| f"top-10 PCs explain {ev[:10].sum() / ev.sum():.3f}") | |
| on = o / np.linalg.norm(o, axis=2, keepdims=True) | |
| same = np.mean([(on[:, 0] * on[:, d]).sum(1).mean() for d in range(1, 8)]) | |
| samec = np.mean([(zcn * ((o[:, d] - z.mean(0)) / np.linalg.norm(o[:, d] - z.mean(0), axis=1, keepdims=True))).sum(1).mean() | |
| for d in range(1, 8)]) | |
| print(f"dihedral consistency: mean cos(d0, d1..7) same tile {same:.4f} (centered {samec:.4f}) " | |
| f"vs random tile pairs {off.mean():.4f} (centered {offc.mean():.4f})") | |
| Y = markers[:, :17] | |
| names = [m for m, _ in MARKERS] | |
| def probe(Xf, cv, grp=None): | |
| pipe = make_pipeline(StandardScaler(), RidgeCV(alphas=np.logspace(-2, 5, 15))) | |
| P = cross_val_predict(pipe, Xf, Y, cv=cv, groups=grp) | |
| r = np.array([spearmanr(P[:, j], Y[:, j])[0] for j in range(Y.shape[1])]) | |
| return np.nan_to_num(r) | |
| feats = {"VirTues (d0)": z, "VirTues (mean 8 dihedral)": o.mean(1), "baseline: IF mean intensity": raw_mean, | |
| "baseline: log1p IF mean intensity": np.log1p(raw_mean)} | |
| res = {} | |
| for cvname, cv, grp in [("5-fold tiles", KFold(5, shuffle=True, random_state=0), None), | |
| ("5-fold patient-grouped", GroupKFold(5), groups)]: | |
| for fn, Xf in feats.items(): | |
| r = probe(Xf, cv, grp) | |
| res[(cvname, fn)] = r | |
| print(f"ridge probe [{cvname:22s}] {fn:34s} mean Spearman over 17 *_prop = {r.mean():.4f}") | |
| print("per-marker Spearman (5-fold tiles): VirTues d0 | IF mean intensity") | |
| a, b = res[("5-fold tiles", "VirTues (d0)")], res[("5-fold tiles", "baseline: IF mean intensity")] | |
| print(" " + " ".join(f"{n}:{x:.2f}|{y:.2f}" for n, x, y in zip(names, a, b))) | |
| return res | |
| # ----------------------------------------------------------------------------------------------- main | |
| if __name__ == "__main__": | |
| t0 = time.time() | |
| df = load_all_tiles() | |
| N = len(df) | |
| keep_ch = [c for c in range(17) if not (NO_HOECHST and MARKERS[c][0] == "Hoechst")] | |
| ids = [MARKERS[c][1] for c in range(17)] | |
| print("marker -> UniProt:", ", ".join(f"{m}={u}" for m, u in MARKERS), "| using channels:", len(keep_ch)) | |
| ensure_marker_embeddings(sorted(set(ids))) | |
| check_against_repo_embeddings(sorted(set(ids))) | |
| model, midx_all = load_virtues(ids) | |
| print(f"[{time.time() - t0:.0f}s] model ready; reading tiles (input {MPP_IN:.4f} um/px -> {SIZE}px = " | |
| f"{MPP_IN * 333 / SIZE:.3f} um/px)", flush=True) | |
| X, raw_mean = load_resampled(df) | |
| slide = df.in_slide_name.values | |
| X, uq, mean, std, valid, slide_k, n_zero = standardize(X, slide) | |
| print(f"[{time.time() - t0:.0f}s] standardized. global log1p mean {np.round(mean, 2).tolist()}\n std {np.round(std, 2).tolist()}") | |
| print(f" (slide, channel) pairs with 0.99-quantile == 0: {n_zero.sum()} of {n_zero.size} (" | |
| + ", ".join(f"{MARKERS[c][0]}x{n_zero[:, c].sum()}" for c in range(17) if n_zero[:, c].any()) | |
| + (") -> dropped (spora quantile mask, --qc_drop)" if QC_DROP else ") -> uq floored at 1 grey level, channel kept")) | |
| # per-tile channel set (quantile mask x optional Hoechst removal) -> batch tiles with identical channel sets | |
| tile_valid = valid[[slide_k[s] for s in slide]] | |
| tile_valid[:, [c for c in range(17) if c not in keep_ch]] = False | |
| sig = [tuple(np.where(v)[0]) for v in tile_valid] | |
| groups = {} | |
| for i, g in enumerate(sig): | |
| groups.setdefault(g, []).append(i) | |
| out = np.zeros((N, 8, 512), np.float16) | |
| torch.cuda.reset_peak_memory_stats() | |
| done = 0 | |
| with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16): | |
| for g, idxs in groups.items(): | |
| ch = torch.tensor(g, dtype=torch.long) | |
| midx = midx_all[ch].to(dev) | |
| for s in range(0, len(idxs), BS): | |
| b = idxs[s:s + BS] | |
| x = torch.from_numpy(X[b]).to(dev).float()[:, ch] # B,C,H,W | |
| xd = [dihedral(x, d) for d in range(8)] | |
| crops = [xd[d][i] for d in range(8) for i in range(len(b))] # order: d-major | |
| ps = model.encoder.forward_list(crops, [midx] * len(crops)).patch_summary_tokens # list of (21,21,512) | |
| e = torch.stack([p.float().mean((0, 1)) for p in ps]).view(8, len(b), -1).transpose(0, 1) | |
| out[b] = e.cpu().numpy().astype(np.float16) | |
| done += len(b) | |
| if (done // BS) % 50 == 0: | |
| print(f" encoded {done}/{N} [{time.time() - t0:.0f}s] peak GPU " | |
| f"{torch.cuda.max_memory_allocated() / 2**30:.2f} GiB", flush=True) | |
| fn = os.path.join(CACHE, "if_virtues" + ("_nohoechst" if NO_HOECHST else "") + ("_qcdrop" if QC_DROP else "") + ".npy") | |
| np.save(fn, out) | |
| print(f"saved {fn} {out.shape} {out.dtype} finite: {np.isfinite(out).all()} | channel-set groups: {len(groups)} | " | |
| f"peak GPU alloc {torch.cuda.max_memory_allocated() / 2**30:.2f} GiB | encode+prep time {time.time() - t0:.0f}s") | |
| if "--no_probe" not in sys.argv: | |
| sanity(out, raw_mean, np.load(os.path.join(CACHE, "markers.npy")), df.patient.values) | |
| print(f"total {time.time() - t0:.0f}s") | |