fourmodal-pathology-bind / 14_extract_if_fm.py
Carpediem123's picture
Migrate release files 1-30
c2ede1f verified
Raw History Blame Contribute Delete
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")