fourmodal-pathology-bind / experiments /26_stpath_infer.py
Carpediem123's picture
Migrate release files 181-210
9a5439c verified
Raw History Blame Contribute Delete
23.8 kB
"""STPath (Huang et al. 2025) zero-shot spatial gene-expression prediction from H&E GigaPath features.
Source code https://github.com/Graph-and-Geometric-Learning/STPath (commit 3b10e6d, no LICENSE file)
weights https://huggingface.co/tlhuang/STPath stfm.pth (no licence declared; not gated)
local copy: ext/stpath/{repo,weights} -- treated as untrusted: state dict loaded with weights_only=True,
and only the pure-torch model modules (model.py, spatial_transformer.py, fa.py, nn_utils) are imported.
Model STFM spatial transformer: per-spot input = Linear(GigaPath tile feature 1536) + masked gene token
+ organ token + tech token; attention over all spots of a slide with a 2D frame-averaged distance bias.
Coordinates are shifted to 0 and rescaled to [0,100] per slide (official STPathInference._normalize_coords).
Output: 38,984 Ensembl-gene vector per spot on the training scale log1p(raw UMI count) (NOT library-normalised;
training used normalize_method='log1pv2' = sc.pp.log1p on raw counts).
Encoder The released checkpoint was trained on prov-gigapath tile features ONLY (official pipeline: 224-px HEST patches
@ 0.5 um/px, CenterCrop 224, ImageNet norm). prov-gigapath is gated: --extract needs HF_TOKEN in the env
(pass it as an env var only; this script never reads, prints or stores it).
Our features = mean of GigaPath(fp16) over the centre + 4 corner 224-px crops at ~0.5 um/px
(centre-only saved too as <set>_gigapath_center.npy):
ORION 333-px tiles (512-px @ 0.325 um/px resized -> ~0.49 um/px), cropped at native size.
TCGA 672-px PathGen patches = read_region level 0; visually 40x (RBC ~28-30 px -> ~0.25 um/px),
so resized 672 -> 336 px (~0.5 um/px) before cropping. (20x-scanned slides, if any, would be off 2x.)
Sets
int2_check repo example (HEST INT2, CCRCC, full slide 2541 spots) -> reproduces README mean Pearson 0.1562;
also INT2 in random 26-spot groups (sparse context, like our 1% HEST subsample) and single spots.
hest_val cache/hest (9484 Visium spots, 1% subsample of 421 HEST samples, precomputed GigaPath features),
one STPath pass per sample (all its spots as context), organ = HEST organ, tech = Visium.
Samples in HEST-bench were excluded from STPath pretraining -> reported separately as held-out.
orion ORION CRC tiles (common.load_all_tiles, 3187 tiles / 41 slides), one pass per slide with coords from
the filename x_y (level-0 px), organ = Bowel, tech = Visium. + protein sanity check
(orion_protein_check.csv: predicted RNA vs <marker>_count/n_nuclei, tiles with >= 20 nuclei).
tcga_text cache/allimg_meta.csv (6144 patches, 4088 WSIs -> mostly 1 patch = no spatial context), one pass per
WSI, coords from key x_y; organ from the TCGA project (public GDC files API, cached in
tcga_text_projects.csv) via PROJECT2ORGAN, unmapped -> 'Others'; tech = Visium.
Outputs cache/stpath/<set>_pred.npy (N,G) float16, native STPath scale log1p(raw count); <set>_genes.csv; <set>_meta.csv
hest_val additionally: hest_val_metrics.csv (per-gene Pearson/Spearman), hest_val_within_sample.csv.
Run .venv/bin/python 26_stpath_infer.py --set int2_check|hest_val
HF_TOKEN=... .venv/bin/python 26_stpath_infer.py --set orion|tcga_text --extract # GigaPath features
.venv/bin/python 26_stpath_infer.py --set orion|tcga_text [--feats F.npy] # STPath prediction
"""
import argparse
import json
import os
import pickle
import re
import sys
import weakref
import numpy as np
import pandas as pd
import torch
ROOT = os.path.dirname(os.path.abspath(__file__))
EXT = os.path.join(ROOT, "ext", "stpath")
REPO = os.path.join(EXT, "repo")
WEIGHTS = os.path.join(EXT, "weights", "stfm.pth")
VOC = os.path.join(REPO, "utils_data", "symbol2ensembl.json")
CACHE = os.path.join(ROOT, "cache")
OUT = os.path.join(CACHE, "stpath")
HEST = os.path.join(ROOT, "..", "HEST")
MARKERS = ["PTPRC", "EPCAM", "KRT8", "ACTA2", "MKI67", "PECAM1", "CD68", "CD3E", "CDH1", "COL1A1"]
HEST_BENCH = { # MahmoodLab/hest-bench adata ids; STPath excluded these from pretraining (train.py)
"INT%d" % i for i in range(1, 25)} | {"TENX111", "TENX147", "TENX148", "TENX149", "NCBI642", "NCBI643",
"NCBI783", "NCBI785", "TENX95", "TENX99", "TENX118", "TENX141", "NCBI681", "NCBI682", "NCBI683", "NCBI684",
"TENX116", "TENX126", "TENX140", "ZEN36", "ZEN40", "ZEN48", "ZEN49", "TENX115", "TENX117"} | {
"MEND%d" % i for i in list(range(139, 155)) + list(range(156, 163))}
# ----------------------------------------------------------------------------------------------- model
class STPath:
def __init__(self, device):
sys.path.insert(0, REPO)
from stpath.model.model import STFM
from stpath.model.nn_utils.config import ModelConfig
import stpath.utils.constants as C
sym2ens = json.load(open(VOC))
ens = sorted(set(sym2ens.values()))
self.gene2id = {g: i + 2 for i, g in enumerate(ens)} # 0 pad, 1 mask (GeneExpTokenizer)
self.sym2ens = sym2ens
self.n_genes = max(self.gene2id.values()) + 1
self.C = C
cfg = ModelConfig.get_default_config()
cfg.feature_dim, cfg.activation, cfg.backbone = 1536, "gelu", "spatial_transformer"
cfg.n_genes, cfg.n_tech, cfg.n_species, cfg.n_organs = self.n_genes, len(C.tech_voc), len(C.specie_voc), len(C.organ_voc)
self.model = STFM(cfg)
self.model.load_state_dict(torch.load(WEIGHTS, map_location="cpu", weights_only=True))
self.model.eval().to(device)
self.device = device
def organ_id(self, organ): # IDTokenizer.encode(align_first=True); None -> "Others" as in STPathInference
organ = "Others" if organ is None or (isinstance(organ, float) and np.isnan(organ)) else organ
return self.C.organ_voc.index(self.C.organ_align_mapping.get(organ, "<pad>"))
def tech_id(self, tech):
return 0 if tech is None else self.C.tech_voc.index(self.C.tech_align_mapping.get(tech, "<pad>"))
def gene_cols(self, symbols):
"""token column for each symbol (-1 if not in the STPath vocabulary)"""
return np.array([self.gene2id.get(self.sym2ens.get(s), -1) for s in symbols])
@torch.no_grad()
def predict(self, feats, coords, organ, tech, cols):
"""feats (n,1536), coords (n,2) one slide -> (n, len(cols)) log1p(count)-scale, NaN for cols==-1"""
n = len(feats)
c = torch.as_tensor(np.asarray(coords, np.float64), dtype=torch.float32)
c = c - c.min(0).values
rng = c.max(0).values
c = torch.where(rng > 0, c / rng.clamp(min=1e-8) * 100, torch.zeros_like(c)) # rescale_coords; guard 1-spot/collinear
x = torch.as_tensor(np.asarray(feats, np.float32), device=self.device)
ge = torch.zeros(n, self.n_genes, device=self.device)
ge[:, 1] = 1 # all spots masked (pure image -> expression)
p = self.model.prediction_head(
img_tokens=x, coords=c.to(self.device), ge_tokens=ge,
batch_idx=torch.zeros(n, dtype=torch.long, device=self.device),
tech_tokens=torch.full((n,), self.tech_id(tech), dtype=torch.long, device=self.device),
organ_tokens=torch.full((n,), self.organ_id(organ), dtype=torch.long, device=self.device))
out = p[:, torch.as_tensor(np.clip(cols, 0, None), device=self.device)].float().cpu().numpy()
out[:, cols < 0] = np.nan
# library size of the predicted profile (all vocab genes) for optional CP10k re-normalisation
lib = torch.expm1(p[:, 2:].clamp(min=0)).sum(1).cpu().numpy()
return out, lib
# ----------------------------------------------------------------------------------------------- metrics
def corr_cols(a, b):
a = a - a.mean(0); b = b - b.mean(0)
return (a * b).sum(0) / np.sqrt((a * a).sum(0) * (b * b).sum(0) + 1e-20)
def rank_cols(a):
from scipy.stats import rankdata
return np.apply_along_axis(rankdata, 0, a)
def gene_metrics(pred, truth):
return corr_cols(pred, truth), corr_cols(rank_cols(pred), rank_cols(truth))
# ----------------------------------------------------------------------------------------------- sets
def run_int2(m):
import anndata as ad
import h5py
ex = os.path.join(REPO, "example_data")
hvg = json.load(open(os.path.join(ex, "var_50genes.json")))["genes"]
with h5py.File(os.path.join(ex, "INT2.h5")) as f:
E, Cc, bc = f["embeddings"][:], f["coords"][:], f["barcodes"][:].flatten().astype(str)
a = ad.read_h5ad(os.path.join(ex, "INT2.h5ad"))[bc, :]
gt = np.log1p(a[:, hvg].X.toarray())
cols = m.gene_cols(hvg)
p, _ = m.predict(E, Cc, "Kidney", "Visium", cols)
r = corr_cols(p, gt)
print(f"INT2 full slide ({len(E)} spots): mean Pearson 50 HVG = {np.nanmean(r):.4f} (README: 0.1562)")
rs = []
for seed in range(5): # sparse context like our 1% HEST subsample: 26 random spots per pass
idx = np.random.default_rng(seed).permutation(len(E))
pp = np.zeros_like(p)
for ch in np.array_split(idx, len(E) // 26):
pp[ch] = m.predict(E[ch], Cc[ch], "Kidney", "Visium", cols)[0]
rs.append(np.nanmean(corr_cols(pp, gt)))
print(f"INT2 sparse context (random 26-spot groups, 5 seeds): mean Pearson = {np.mean(rs):.4f} +- {np.std(rs):.4f}")
p1 = np.concatenate([m.predict(E[i:i + 1], Cc[i:i + 1], "Kidney", "Visium", cols)[0] for i in range(len(E))])
print(f"INT2 no context (each spot alone): mean Pearson = {np.nanmean(corr_cols(p1, gt)):.4f}")
def hest_coords():
from anndata._core import file_backing
def _ss(self, state): # same unpickle patch as 21_hest_prep.py
self.__dict__ = state.copy()
ref = state.get("_adata_ref", state.get("_adata"))
self.__dict__["_adata_ref"] = weakref.ref(ref) if ref is not None else (lambda: None)
file_backing.AnnDataFileManager.__setstate__ = _ss
A = pickle.load(open(os.path.join(HEST, "visium_humandisease_adata.pickle"), "rb"))
H = pd.read_csv(os.path.join(HEST, "HEST_v1_1_0.csv"))
H = H[(H.st_technology == "Visium") & (H.species == "Homo sapiens")].reset_index(drop=True)
assert len(H) == len(A)
xy = {}
for a, sid in zip(A, H.id):
for b, c in zip(a.obs_names, np.asarray(a.obsm["spatial"], np.float64)):
xy[(sid, b)] = c
return xy
def run_hest(m):
import scipy.sparse as sp
M = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv"))
genes = pd.read_csv(os.path.join(CACHE, "hest", "genes.csv")).gene.tolist()
X = sp.load_npz(os.path.join(CACHE, "hest", "counts.npz")).tocsr()
F = np.load(os.path.join(CACHE, "hest", "he_gigapath.npy"))[:, 0].astype(np.float32)
xy = hest_coords()
coords = np.stack([xy[(s, b)] for s, b in zip(M["sample"], M.spot)])
cols = m.gene_cols(genes)
keep = cols >= 0
print(f"HEST genes {len(genes)}, in STPath vocab {keep.sum()}")
genes_k, cols_k = [g for g, k in zip(genes, keep) if k], cols[keep]
pred = np.zeros((len(M), len(cols_k)), np.float32)
lib = np.zeros(len(M), np.float32)
for sid, idx in M.groupby("sample").indices.items():
pred[idx], lib[idx] = m.predict(F[idx], coords[idx], M.organ.iloc[idx[0]], "Visium", cols_k)
os.makedirs(OUT, exist_ok=True)
np.save(os.path.join(OUT, "hest_val_pred.npy"), pred.astype(np.float16))
pd.Series(genes_k).to_csv(os.path.join(OUT, "hest_val_genes.csv"), index=False, header=["gene"])
mo = M[["idx", "sample", "spot", "organ", "oncotree"]].copy()
mo["x"], mo["y"] = coords[:, 0], coords[:, 1]
mo["hest_bench_heldout"] = mo["sample"].isin(HEST_BENCH)
mo["pred_lib_expm1"] = lib
mo["status"] = "ok"
mo.to_csv(os.path.join(OUT, "hest_val_meta.csv"), index=False)
# ---- evaluation: truth = log1p(CP10k) of measured counts (and log1p raw = STPath's training target)
Xk = X[:, np.where(keep)[0]]
tot = np.asarray(X.sum(1)).ravel()
L = sp.diags(1e4 / tot) @ Xk
L.data = np.log1p(L.data)
frac = np.asarray((Xk > 0).mean(0)).ravel()
mu = np.asarray(L.mean(0)).ravel()
var = np.asarray(L.multiply(L).mean(0)).ravel() - mu ** 2
var[frac < 0.01] = -1
top50 = list(np.argsort(-var)[:50])
gi = {g: i for i, g in enumerate(genes_k)}
mk = [gi[g] for g in MARKERS if g in gi]
sel = top50 + [i for i in mk if i not in top50]
T_cp = L[:, sel].toarray()
T_raw = np.log1p(Xk[:, sel].toarray())
P = pred[:, sel]
P_cp = np.log1p(np.expm1(np.clip(P, 0, None)) / lib[:, None] * 1e4) # predicted profile re-normalised to CP10k
held = mo.hest_bench_heldout.values
rows = []
for subset, msk in [("all", np.ones(len(M), bool)), ("heldout_hestbench", held), ("seen_pretrain", ~held)]:
for tname, T, PP in [("log1p_cp10k", T_cp, P), ("log1p_raw", T_raw, P), ("log1p_cp10k_vs_predcp10k", T_cp, P_cp)]:
pr, sr = gene_metrics(PP[msk], T[msk])
for j, g in enumerate(sel):
rows.append({"subset": subset, "truth": tname, "gene": genes_k[g], "is_top50_hvg": g in top50,
"is_marker": genes_k[g] in MARKERS, "pearson": pr[j], "spearman": sr[j],
"n_spots": int(msk.sum())})
R = pd.DataFrame(rows)
R.to_csv(os.path.join(OUT, "hest_val_metrics.csv"), index=False)
# within-sample correlations (spatial-variation within a slide, the HEST-bench style), samples with >=30 spots
ws = []
for sid, idx in M.groupby("sample").indices.items():
if len(idx) >= 30:
pr, _ = gene_metrics(P[idx][:, :50], T_cp[idx][:, :50])
ws.append({"sample": sid, "heldout": sid in HEST_BENCH, "n": len(idx), "mean_pearson_top50": np.nanmean(pr)})
W = pd.DataFrame(ws)
W.to_csv(os.path.join(OUT, "hest_val_within_sample.csv"), index=False)
print(f"\nspots {len(M)}, samples {M['sample'].nunique()}; held-out (HEST-bench) spots {held.sum()} "
f"in {M[held]['sample'].nunique()} samples")
s = R.groupby(["subset", "truth", "is_top50_hvg"])[["pearson", "spearman"]].agg(["mean", "median"])
print(s.round(3).to_string())
print("\nmarkers, truth log1p CP10k (pooled across spots):")
print(R[R.is_marker & (R.truth == "log1p_cp10k")].pivot(index="gene", columns="subset", values=["pearson", "spearman"]).round(3).to_string())
print("\nwithin-sample mean Pearson top-50 HVG (samples >=30 spots):")
print(W.groupby("heldout").mean_pearson_top50.describe().round(3).to_string())
def parse_xy(s):
mm = re.search(r"_(\d+)_(\d+)(?:_0_512_512)?(?:\.\w+)?$", s)
return (float(mm.group(1)), float(mm.group(2))) if mm else (np.nan, np.nan)
def target_paths(name):
if name == "orion":
sys.path.insert(0, ROOT)
from common import load_all_tiles, ORION
return [os.path.join(ORION, p) for p in load_all_tiles().image_path], 333 # ~0.49 um/px already
d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
return d.image_path.tolist(), 336 # PathGen read_region level 0 = 40x (~0.25 um/px): 672 -> 336 = ~0.5 um/px
def five_crops(img, size=224):
W, H = img.size
offs = [((W - size) // 2, (H - size) // 2), (0, 0), (W - size, 0), (0, H - size), (W - size, H - size)]
return [img.crop((x, y, x + size, y + size)) for x, y in offs] # centre first
def extract_gigapath(name, device, bs=32):
"""GigaPath tile features (fp16) = mean over centre + 4 corner 224-px crops at ~0.5 um/px; centre-only also saved.
Needs HF_TOKEN in the environment (prov-gigapath is gated); the token is never read/stored by this script."""
import timm
from PIL import Image, PngImagePlugin
from torchvision import transforms
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
enc = timm.create_model("hf_hub:prov-gigapath/prov-gigapath", pretrained=True).half().eval().to(device)
tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))])
paths, side = target_paths(name)
feats = np.full((len(paths), 5, 1536), np.nan, np.float32)
status = ["ok"] * len(paths)
buf, rows = [], []
def flush():
if not buf:
return
with torch.inference_mode():
f = enc(torch.stack(buf).half().to(device)).float().cpu().numpy()
feats[np.array(rows)] = f.reshape(-1, 5, 1536)
buf.clear(); rows.clear()
for i, p in enumerate(paths):
try:
im = Image.open(p).convert("RGB")
if im.size != (side, side):
im = im.resize((side, side), Image.BICUBIC)
buf.extend(tf(c) for c in five_crops(im)); rows.append(i)
except Exception as e:
status[i] = f"unreadable:{type(e).__name__}"
print(f"[{name}] skip {p}: {e}", flush=True)
if len(rows) * 5 >= bs:
flush()
if i % 500 == 0:
print(f"[{name}] {i}/{len(paths)}", flush=True)
flush()
os.makedirs(OUT, exist_ok=True)
np.save(os.path.join(OUT, f"{name}_gigapath.npy"), feats.mean(1))
np.save(os.path.join(OUT, f"{name}_gigapath_center.npy"), feats[:, 0])
pd.DataFrame({"path": paths, "feat_status": status}).to_csv(os.path.join(OUT, f"{name}_gigapath_status.csv"), index_label="row")
print(f"[{name}] features {feats.shape[0]} rows, failed {sum(s != 'ok' for s in status)}; "
f"non-finite rows {int((~np.isfinite(feats.mean(1)).all(1)).sum())}")
def tcga_projects(meta):
"""TCGA project per WSI from the public GDC files API (file_name = <wsi>.svs); cached in cache/stpath."""
fp = os.path.join(OUT, "tcga_text_projects.csv")
if not os.path.exists(fp):
import requests
names = sorted(set(meta.slide + ".svs"))
rows = []
for i in range(0, len(names), 500):
q = {"filters": {"op": "in", "content": {"field": "file_name", "value": names[i:i + 500]}},
"fields": "file_name,cases.project.project_id", "format": "JSON", "size": 1000}
r = requests.post("https://api.gdc.cancer.gov/files", json=q, timeout=120).json()
for h in r["data"]["hits"]:
rows.append({"slide": h["file_name"][:-4], "project": h["cases"][0]["project"]["project_id"]})
pd.DataFrame(rows).drop_duplicates("slide").to_csv(fp, index=False)
return dict(pd.read_csv(fp).values)
# TCGA project -> STPath organ name (keys of constants.organ_align_mapping; lower-case ones are the STimage-1K4M
# spellings that align to their own tokens, e.g. 'ovary'->'Ovary'); unlisted projects -> 'Others'
PROJECT2ORGAN = {"BRCA": "Breast", "COAD": "Bowel", "READ": "Bowel", "LUAD": "Lung", "LUSC": "Lung",
"KIRC": "Kidney", "KIRP": "Kidney", "KICH": "Kidney", "LIHC": "Liver", "PAAD": "Pancreas",
"PRAD": "Prostate", "SKCM": "Skin", "UCEC": "Uterus", "UCS": "Uterus", "OV": "ovary",
"GBM": "glioblastoma", "LGG": "glioma", "STAD": "stomach", "HNSC": "mouth", "UVM": "Eye"}
def run_target(m, name, feats_path):
if name == "orion":
sys.path.insert(0, ROOT)
from common import load_all_tiles
d = load_all_tiles()
meta = pd.DataFrame({"key": d.image_path, "path": d.image_path, "slide": d.in_slide_name})
meta["x"], meta["y"] = zip(*[parse_xy(os.path.basename(p)) for p in d.image_path])
meta["organ"] = "Bowel"
else:
d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
meta = pd.DataFrame({"key": d.key, "path": d.image_path, "slide": d.wsi_id})
meta["x"], meta["y"] = zip(*[parse_xy(k) for k in d.key])
proj = tcga_projects(meta) # TCGA project from GDC; organ token mapped, unknown -> 'Others'
meta["project"] = meta.slide.map(proj)
meta["organ"] = [PROJECT2ORGAN.get(str(p).replace("TCGA-", ""), "Others") for p in meta.project]
feats_path = feats_path or os.path.join(OUT, f"{name}_gigapath.npy")
if not os.path.exists(feats_path):
sys.exit(f"[{name}] BLOCKED: STPath's released checkpoint needs prov-gigapath tile features (1536-d) and "
f"prov-gigapath is gated for this HF account. Provide --feats <GigaPath features (N={len(meta)},1536) "
f"aligned to {name} row order>.")
F = np.load(feats_path).astype(np.float32)
F = F[:, 0] if F.ndim == 3 else F
assert F.shape == (len(meta), 1536), F.shape
ok = np.isfinite(F).all(1)
meta["status"] = np.where(ok, "ok", "no_feature")
meta["n_context"] = meta.groupby("slide").slide.transform("size").where(ok, 0)
vocab_genes = pd.read_csv(os.path.join(OUT, "hest_val_genes.csv")).gene.tolist()
cols = m.gene_cols(vocab_genes)
pred = np.full((len(meta), len(cols)), np.nan, np.float32)
for sl, idx in meta[ok].groupby("slide").indices.items():
idx = np.where(ok)[0][idx]
pred[idx] = m.predict(F[idx], meta[["x", "y"]].values[idx], meta.organ.values[idx[0]], "Visium", cols)[0]
np.save(os.path.join(OUT, f"{name}_pred.npy"), pred.astype(np.float16))
pd.Series(vocab_genes).to_csv(os.path.join(OUT, f"{name}_genes.csv"), index=False, header=["gene"])
meta.to_csv(os.path.join(OUT, f"{name}_meta.csv"), index_label="row")
print(f"[{name}] predicted {ok.sum()} / {len(meta)}; {meta.slide.nunique()} slides")
if name == "orion":
orion_protein_check(pred, vocab_genes)
ORION_PAIRS = [("PTPRC", "CD45"), ("EPCAM", "Pan-CK"), ("KRT8", "Pan-CK"), ("CDH1", "E-cadherin"), ("ACTA2", "SMA"),
("MKI67", "Ki67"), ("PECAM1", "CD31"), ("CD68", "CD68"), ("CD3E", "CD3e"), ("CD8A", "CD8a"),
("MS4A1", "CD20"), ("CD163", "CD163")]
def orion_protein_check(pred, genes):
"""predicted RNA vs measured ORION protein (= <marker>_count / n_nuclei) per tile, tiles with >= 20 nuclei."""
from scipy.stats import pearsonr, spearmanr
sys.path.insert(0, ROOT)
from common import load_all_tiles
d = load_all_tiles()
ts = pd.read_csv(os.path.join(ROOT, "results", "tile_stats.csv"))
assert (ts.tile_id.values == d.tile_id.values).all()
keep = (ts.n_nuclei.values >= 20) & np.isfinite(pred).all(1)
gi = {g: i for i, g in enumerate(genes)}
rows = []
for g, p in ORION_PAIRS:
x = pred[keep, gi[g]].astype(np.float64)
y = (d[f"{p}_count"].values / np.maximum(ts.n_nuclei.values, 1))[keep]
ws = [spearmanr(x[i], y[i])[0] for i in d[keep].groupby("in_slide_name").indices.values() if len(i) >= 20]
rows.append({"gene": g, "protein": p, "n_tiles": int(keep.sum()), "pearson": pearsonr(x, y)[0],
"spearman": spearmanr(x, y)[0], "within_slide_spearman_median": np.nanmedian(ws),
"n_slides_within": len(ws)})
R = pd.DataFrame(rows)
R.to_csv(os.path.join(OUT, "orion_protein_check.csv"), index=False)
print(R.round(3).to_string(index=False))
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--set", required=True, choices=["int2_check", "hest_val", "orion", "tcga_text"])
ap.add_argument("--feats", default=None, help="GigaPath (N,1536) features for orion / tcga_text")
ap.add_argument("--extract", action="store_true", help="compute GigaPath features for --set orion|tcga_text (needs HF_TOKEN env)")
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
a = ap.parse_args()
torch.manual_seed(0)
if a.extract:
extract_gigapath(a.set, a.device)
sys.exit(0)
model = STPath(a.device)
{"int2_check": lambda: run_int2(model), "hest_val": lambda: run_hest(model)}.get(
a.set, lambda: run_target(model, a.set, a.feats))()