Migrate release files 151-180
Browse files- .gitattributes +5 -0
- examples/tcga_he/05632.png +3 -0
- examples/tcga_he/05660.png +3 -0
- examples/tcga_he/05757.png +3 -0
- examples/tcga_he/05776.png +3 -0
- examples/tcga_he/06007.png +3 -0
- experiments/01_analyze_data.py +83 -0
- experiments/02_make_splits.py +51 -0
- experiments/03_cache_data.py +35 -0
- experiments/04_extract_anchor.py +25 -0
- experiments/04b_extract_anchor_fm.py +94 -0
- experiments/05_train_eval.py +229 -0
- experiments/06_summarize.py +49 -0
- experiments/07_make_text.py +69 -0
- experiments/08_extract_text.py +68 -0
- experiments/09_bench.py +209 -0
- experiments/10_bench_summary.py +63 -0
- experiments/11_pathgen_prep.py +24 -0
- experiments/12_train_text_pathgen.py +104 -0
- experiments/13_eval_text_emergent.py +88 -0
- experiments/15_extract_marker_fm.py +338 -0
- experiments/16_extract_pathgenclip.py +107 -0
- experiments/17_bench_fm.py +241 -0
- experiments/18_eval_fm.py +94 -0
- experiments/19_pairsets_prep.py +162 -0
- experiments/20_pairing.py +153 -0
- experiments/21_hest_prep.py +124 -0
- experiments/22_extract_gene_fm.py +126 -0
- experiments/23_bind_gene.py +106 -0
- experiments/24_eval_gene_emergent.py +160 -0
- experiments/25_gigatime_infer.py +361 -0
.gitattributes
CHANGED
|
@@ -92,3 +92,8 @@ examples/tcga_he/05162.png filter=lfs diff=lfs merge=lfs -text
|
|
| 92 |
examples/tcga_he/05256.png filter=lfs diff=lfs merge=lfs -text
|
| 93 |
examples/tcga_he/05403.png filter=lfs diff=lfs merge=lfs -text
|
| 94 |
examples/tcga_he/05631.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
examples/tcga_he/05256.png filter=lfs diff=lfs merge=lfs -text
|
| 93 |
examples/tcga_he/05403.png filter=lfs diff=lfs merge=lfs -text
|
| 94 |
examples/tcga_he/05631.png filter=lfs diff=lfs merge=lfs -text
|
| 95 |
+
examples/tcga_he/05632.png filter=lfs diff=lfs merge=lfs -text
|
| 96 |
+
examples/tcga_he/05660.png filter=lfs diff=lfs merge=lfs -text
|
| 97 |
+
examples/tcga_he/05757.png filter=lfs diff=lfs merge=lfs -text
|
| 98 |
+
examples/tcga_he/05776.png filter=lfs diff=lfs merge=lfs -text
|
| 99 |
+
examples/tcga_he/06007.png filter=lfs diff=lfs merge=lfs -text
|
examples/tcga_he/05632.png
ADDED
|
Git LFS Details
|
examples/tcga_he/05660.png
ADDED
|
Git LFS Details
|
examples/tcga_he/05757.png
ADDED
|
Git LFS Details
|
examples/tcga_he/05776.png
ADDED
|
Git LFS Details
|
examples/tcga_he/06007.png
ADDED
|
Git LFS Details
|
experiments/01_analyze_data.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Descriptive analysis of the ORION tri-modal (+markers) data. Writes results/data_summary.json + figures."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import matplotlib
|
| 6 |
+
matplotlib.use("Agg")
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import numpy as np
|
| 9 |
+
import tifffile
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from common import ORION, ROOT, load_all_tiles, marker_columns
|
| 13 |
+
|
| 14 |
+
out = os.path.join(ROOT, "results")
|
| 15 |
+
os.makedirs(out, exist_ok=True)
|
| 16 |
+
df = load_all_tiles()
|
| 17 |
+
prop, cnt = marker_columns(df)
|
| 18 |
+
|
| 19 |
+
summary = {
|
| 20 |
+
"n_tiles": len(df),
|
| 21 |
+
"n_slides": int(df.in_slide_name.nunique()),
|
| 22 |
+
"n_patients": int(df.patient.nunique()),
|
| 23 |
+
"official_split_tiles": df.orion_split.value_counts().to_dict(),
|
| 24 |
+
"official_split_slides": df.groupby("orion_split").in_slide_name.nunique().to_dict(),
|
| 25 |
+
"if_batches_tiles": df.if_batch.value_counts().to_dict(),
|
| 26 |
+
"if_batches_patients": df.groupby("if_batch").patient.nunique().to_dict(),
|
| 27 |
+
"tiles_per_slide": df.groupby("in_slide_name").size().describe().round(1).to_dict(),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# per-tile stats from images (all tiles; small set)
|
| 31 |
+
he_mean, if_ch_mean, n_nuc, nuc_cov = [], [], [], []
|
| 32 |
+
for _, r in df.iterrows():
|
| 33 |
+
he = np.asarray(Image.open(os.path.join(ORION, r.image_path)).convert("RGB"))
|
| 34 |
+
iff = tifffile.imread(os.path.join(ORION, r.target_path))
|
| 35 |
+
nuc = tifffile.imread(os.path.join(ORION, r.nuclei_path))
|
| 36 |
+
he_mean.append(he.mean())
|
| 37 |
+
if_ch_mean.append(iff.reshape(-1, iff.shape[-1]).mean(0))
|
| 38 |
+
n_nuc.append(len(np.unique(nuc)) - 1)
|
| 39 |
+
nuc_cov.append((nuc > 0).mean())
|
| 40 |
+
df["he_mean"], df["n_nuclei"], df["nuc_cov"] = he_mean, n_nuc, nuc_cov
|
| 41 |
+
if_ch_mean = np.stack(if_ch_mean)
|
| 42 |
+
summary["shapes"] = {"he": list(he.shape), "if": list(iff.shape), "nuclei": list(nuc.shape),
|
| 43 |
+
"markers": len(prop) + len(cnt)}
|
| 44 |
+
summary["if_channel_names_from_prop_cols"] = [c[:-5] for c in prop]
|
| 45 |
+
summary["n_nuclei_per_tile"] = df.n_nuclei.describe().round(1).to_dict()
|
| 46 |
+
summary["frac_tiles_lt20_nuclei"] = float((df.n_nuclei < 20).mean())
|
| 47 |
+
summary["he_mean_vs_n_nuclei_spearman"] = float(df[["he_mean", "n_nuclei"]].corr("spearman").iloc[0, 1])
|
| 48 |
+
summary["marker_prop_mean"] = df[prop].mean().round(4).to_dict()
|
| 49 |
+
summary["marker_count_mean"] = df[cnt].mean().round(2).to_dict()
|
| 50 |
+
summary["if_channel_mean_intensity"] = dict(zip([c[:-5] for c in prop], if_ch_mean.mean(0).round(2).tolist()))
|
| 51 |
+
df[["tile_id", "in_slide_name", "patient", "if_batch", "orion_split", "n_nuclei", "nuc_cov", "he_mean"]].to_csv(
|
| 52 |
+
os.path.join(out, "tile_stats.csv"), index=False)
|
| 53 |
+
json.dump(summary, open(os.path.join(out, "data_summary.json"), "w"), indent=1)
|
| 54 |
+
print(json.dumps(summary, indent=1))
|
| 55 |
+
|
| 56 |
+
# figure: example quadruplets
|
| 57 |
+
rng = np.random.default_rng(0)
|
| 58 |
+
ex = df[df.n_nuclei > 100].sample(4, random_state=0)
|
| 59 |
+
fig, ax = plt.subplots(4, 5, figsize=(15, 12))
|
| 60 |
+
for i, (_, r) in enumerate(ex.iterrows()):
|
| 61 |
+
he = Image.open(os.path.join(ORION, r.image_path)).convert("RGB")
|
| 62 |
+
iff = tifffile.imread(os.path.join(ORION, r.target_path))
|
| 63 |
+
nuc = tifffile.imread(os.path.join(ORION, r.nuclei_path))
|
| 64 |
+
ax[i, 0].imshow(he); ax[i, 0].set_title(f"H&E {r.orion_slide_id}")
|
| 65 |
+
# Hoechst / Pan-CK / CD45 as RGB-ish composite
|
| 66 |
+
names = [c[:-5] for c in prop]
|
| 67 |
+
comp = np.stack([iff[..., names.index(n)] for n in ["Pan-CK", "CD45", "Hoechst"]], -1)
|
| 68 |
+
ax[i, 1].imshow(comp); ax[i, 1].set_title("IF: Pan-CK/CD45/Hoechst")
|
| 69 |
+
ax[i, 2].imshow(iff[..., names.index("SMA")], cmap="magma"); ax[i, 2].set_title("IF: SMA")
|
| 70 |
+
ax[i, 3].imshow(nuc > 0, cmap="gray"); ax[i, 3].set_title(f"nuclei ({r.n_nuclei})")
|
| 71 |
+
ax[i, 4].barh(range(len(cnt)), np.log1p(r[cnt].values.astype(float)))
|
| 72 |
+
ax[i, 4].set_yticks(range(len(cnt))); ax[i, 4].set_yticklabels([c[:-6] for c in cnt], fontsize=6)
|
| 73 |
+
ax[i, 4].set_title("markers log1p(count)")
|
| 74 |
+
for a in ax[i, :4]:
|
| 75 |
+
a.axis("off")
|
| 76 |
+
plt.tight_layout(); plt.savefig(os.path.join(out, "example_quadruplets.png"), dpi=80)
|
| 77 |
+
|
| 78 |
+
fig, ax = plt.subplots(1, 3, figsize=(15, 4))
|
| 79 |
+
ax[0].hist(df.n_nuclei, bins=50); ax[0].set_title("nuclei per tile"); ax[0].set_yscale("log")
|
| 80 |
+
df.groupby("if_batch").size().plot.bar(ax=ax[1], title="tiles per IF batch")
|
| 81 |
+
ax[2].imshow(np.corrcoef(np.concatenate([df[prop].values, np.log1p(df[cnt].values)], 1).T), cmap="RdBu_r", vmin=-1, vmax=1)
|
| 82 |
+
ax[2].set_title("marker vector correlation (17 prop + 16 log count)")
|
| 83 |
+
plt.tight_layout(); plt.savefig(os.path.join(out, "data_overview.png"), dpi=80)
|
experiments/02_make_splits.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Define evaluation splits. Each split file: splits/<name>/fold<k>.csv with columns tile_id, split.
|
| 2 |
+
|
| 3 |
+
orion_official : the user's train/val/test_dataframe.csv (slide-disjoint)
|
| 4 |
+
random_tile : tile-level random 80/10/10 (slides shared across splits -> optimistic upper bound)
|
| 5 |
+
patient_5fold : 5-fold CV grouped by patient (CRC33_01/_02 kept together); val = 3 patients from train folds
|
| 6 |
+
batch_lobo : leave-one-IF-acquisition-batch-out (C59kX/mX/nX/qX); val = 2 patients from training batches
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from common import SPLITS, load_all_tiles
|
| 13 |
+
|
| 14 |
+
df = load_all_tiles()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def save(name, k, split):
|
| 18 |
+
os.makedirs(os.path.join(SPLITS, name), exist_ok=True)
|
| 19 |
+
out = df[["tile_id", "in_slide_name", "patient", "if_batch"]].copy()
|
| 20 |
+
out["split"] = split
|
| 21 |
+
out.to_csv(os.path.join(SPLITS, name, f"fold{k}.csv"), index=False)
|
| 22 |
+
print(name, k, out.split.value_counts().to_dict(),
|
| 23 |
+
{s: out[out.split == s].patient.nunique() for s in ["train", "val", "test"]})
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# 1. official
|
| 27 |
+
save("orion_official", 0, df.orion_split.values)
|
| 28 |
+
|
| 29 |
+
# 2. random tile-level
|
| 30 |
+
rng = np.random.default_rng(0)
|
| 31 |
+
u = rng.random(len(df))
|
| 32 |
+
save("random_tile", 0, np.where(u < 0.8, "train", np.where(u < 0.9, "val", "test")))
|
| 33 |
+
|
| 34 |
+
# 3. patient-grouped 5-fold
|
| 35 |
+
pats = np.array(sorted(df.patient.unique()))
|
| 36 |
+
rng = np.random.default_rng(0)
|
| 37 |
+
perm = rng.permutation(pats)
|
| 38 |
+
folds = np.array_split(perm, 5)
|
| 39 |
+
for k in range(5):
|
| 40 |
+
test_p = set(folds[k])
|
| 41 |
+
rest = [p for p in perm if p not in test_p]
|
| 42 |
+
val_p = set(rest[:3])
|
| 43 |
+
split = np.where(df.patient.isin(test_p), "test", np.where(df.patient.isin(val_p), "val", "train"))
|
| 44 |
+
save("patient_5fold", k, split)
|
| 45 |
+
|
| 46 |
+
# 4. leave-one-IF-batch-out
|
| 47 |
+
for k, b in enumerate(sorted(df.if_batch.unique())):
|
| 48 |
+
rest = sorted(df[df.if_batch != b].patient.unique())
|
| 49 |
+
val_p = set(np.random.default_rng(k).choice(rest, 2, replace=False))
|
| 50 |
+
split = np.where(df.if_batch == b, "test", np.where(df.patient.isin(val_p), "val", "train"))
|
| 51 |
+
save("batch_lobo", k, split)
|
experiments/03_cache_data.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Resize every modality to 224x224 once and store as .npy (uint8) for fast training."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import tifffile
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from scipy import ndimage
|
| 10 |
+
|
| 11 |
+
from common import CACHE, IMG, ORION, load_all_tiles, marker_matrix
|
| 12 |
+
|
| 13 |
+
os.makedirs(CACHE, exist_ok=True)
|
| 14 |
+
df = load_all_tiles()
|
| 15 |
+
N = len(df)
|
| 16 |
+
he = np.lib.format.open_memmap(os.path.join(CACHE, "he.npy"), "w+", np.uint8, (N, 3, IMG, IMG))
|
| 17 |
+
iff = np.lib.format.open_memmap(os.path.join(CACHE, "if.npy"), "w+", np.uint8, (N, 17, IMG, IMG))
|
| 18 |
+
nuc = np.lib.format.open_memmap(os.path.join(CACHE, "nuclei.npy"), "w+", np.uint8, (N, 2, IMG, IMG))
|
| 19 |
+
|
| 20 |
+
for i, r in df.iterrows():
|
| 21 |
+
h = Image.open(os.path.join(ORION, r.image_path)).convert("RGB").resize((IMG, IMG), Image.BICUBIC)
|
| 22 |
+
he[i] = np.asarray(h).transpose(2, 0, 1)
|
| 23 |
+
x = torch.from_numpy(tifffile.imread(os.path.join(ORION, r.target_path)).astype(np.float32)).permute(2, 0, 1)[None]
|
| 24 |
+
iff[i] = F.interpolate(x, size=(IMG, IMG), mode="bilinear", antialias=True, align_corners=False)[0].round().clamp(0, 255).byte().numpy()
|
| 25 |
+
lab = tifffile.imread(os.path.join(ORION, r.nuclei_path))
|
| 26 |
+
# boundary = pixels whose label differs from a neighbour (separates touching nuclei)
|
| 27 |
+
bnd = (ndimage.maximum_filter(lab, 3) != ndimage.minimum_filter(lab, 3)) & (lab > 0)
|
| 28 |
+
m = torch.from_numpy(np.stack([lab > 0, bnd]).astype(np.float32))[None]
|
| 29 |
+
nuc[i] = (F.interpolate(m, size=(IMG, IMG), mode="area")[0] * 255).round().byte().numpy()
|
| 30 |
+
if i % 500 == 0:
|
| 31 |
+
print(i, N, flush=True)
|
| 32 |
+
|
| 33 |
+
np.save(os.path.join(CACHE, "markers.npy"), marker_matrix(df))
|
| 34 |
+
he.flush(); iff.flush(); nuc.flush()
|
| 35 |
+
print("done")
|
experiments/04_extract_anchor.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen ImageBind-Huge vision embeddings of every H&E tile under each of the 8 dihedral transforms.
|
| 2 |
+
Output cache/he_imagebind.npy : (N, 8, 1024) float16, L2-normalised (ImageBind postprocessor)."""
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from common import CACHE, dihedral, load_imagebind_vision
|
| 9 |
+
|
| 10 |
+
MEAN = torch.tensor([0.48145466, 0.4578275, 0.40821073]).view(1, 3, 1, 1)
|
| 11 |
+
STD = torch.tensor([0.26862954, 0.26130258, 0.27577711]).view(1, 3, 1, 1)
|
| 12 |
+
|
| 13 |
+
he = np.load(os.path.join(CACHE, "he.npy"), mmap_mode="r")
|
| 14 |
+
model, MT = load_imagebind_vision()
|
| 15 |
+
out = np.zeros((len(he), 8, 1024), np.float16)
|
| 16 |
+
bs = 64
|
| 17 |
+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.float16):
|
| 18 |
+
for s in range(0, len(he), bs):
|
| 19 |
+
x = (torch.from_numpy(np.ascontiguousarray(he[s:s + bs])).float() / 255 - MEAN) / STD
|
| 20 |
+
for d in range(8):
|
| 21 |
+
e = model({MT.VISION: dihedral(x, d).cuda()})[MT.VISION]
|
| 22 |
+
out[s:s + bs, d] = e.float().cpu().numpy()
|
| 23 |
+
print(s, len(he), flush=True)
|
| 24 |
+
np.save(os.path.join(CACHE, "he_imagebind.npy"), out)
|
| 25 |
+
print("saved", out.shape)
|
experiments/04b_extract_anchor_fm.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen pathology foundation-model embeddings of every H&E tile under the 8 dihedral transforms.
|
| 2 |
+
Usage: python 04b_extract_anchor_fm.py {uni2|conch|conchclip|gigapath|plip|phikonv2} [--set <name>]
|
| 3 |
+
Output: cache/he_<name>.npy (N, 8, D) float16 (raw features; L2-normalised later in training).
|
| 4 |
+
|
| 5 |
+
uni2 : MahmoodLab/UNI2-h, ViT-H/14 (reg tokens), 224px, ImageNet norm, CLS -> 1536-d
|
| 6 |
+
gigapath : prov-gigapath/prov-gigapath tile encoder, ViT-g/14, 224px, ImageNet norm -> 1536-d
|
| 7 |
+
conch : MahmoodLab/CONCH v1 vision tower (ViT-B/16), 448px, OpenAI-CLIP norm,
|
| 8 |
+
encode_image(proj_contrast=False, normalize=False) -> 512-d (authors' recommended tile feature)
|
| 9 |
+
plip : vinid/plip CLIP ViT-B/32 image features (projected) -> 512-d [open stand-in for CONCH]
|
| 10 |
+
phikonv2 : owkin/phikon-v2 ViT-L/16 DINOv2, CLS -> 1024-d [open stand-in for GigaPath]
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import timm
|
| 17 |
+
import torch
|
| 18 |
+
from PIL import Image, PngImagePlugin
|
| 19 |
+
|
| 20 |
+
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024 # some PathGen PNGs embed very large ICC profiles
|
| 21 |
+
|
| 22 |
+
from common import CACHE, ORION, dihedral, load_all_tiles, load_imagebind_vision
|
| 23 |
+
|
| 24 |
+
name = sys.argv[1]
|
| 25 |
+
SET = sys.argv[sys.argv.index("--set") + 1] if "--set" in sys.argv else "pathgen" if "--pathgen" in sys.argv else None
|
| 26 |
+
PATHGEN = SET is not None # encode an image-text pair set (cache/<SET>_meta.csv, e.g. PathGen-1.6M) instead of ORION tiles
|
| 27 |
+
UNI2_LOCAL = "/diskarray/home/tl688/reg2026/model_alldata/uni2-h.bin"
|
| 28 |
+
dev = "cuda"
|
| 29 |
+
IMNET = ([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
|
| 30 |
+
CLIPN = ([0.48145466, 0.4578275, 0.40821073], [0.26862954, 0.26130258, 0.27577711])
|
| 31 |
+
|
| 32 |
+
if name == "uni2":
|
| 33 |
+
# HF repo is gated for this account -> use the local copy of the official UNI2-h checkpoint
|
| 34 |
+
model = timm.create_model(
|
| 35 |
+
"vit_giant_patch14_224", pretrained=False, img_size=224, patch_size=14, depth=24, num_heads=24,
|
| 36 |
+
init_values=1e-5, embed_dim=1536, mlp_ratio=2.66667 * 2, num_classes=0, no_embed_class=True,
|
| 37 |
+
mlp_layer=timm.layers.SwiGLUPacked, act_layer=torch.nn.SiLU, reg_tokens=8, dynamic_img_size=True)
|
| 38 |
+
model.load_state_dict(torch.load(UNI2_LOCAL, map_location="cpu"), strict=True)
|
| 39 |
+
size, norm, fwd = 224, IMNET, lambda m, x: m(x)
|
| 40 |
+
elif name == "gigapath":
|
| 41 |
+
model = timm.create_model("hf_hub:prov-gigapath/prov-gigapath", pretrained=True)
|
| 42 |
+
size, norm, fwd = 224, IMNET, lambda m, x: m(x)
|
| 43 |
+
elif name == "conch":
|
| 44 |
+
from conch.open_clip_custom import create_model_from_pretrained
|
| 45 |
+
model, _ = create_model_from_pretrained("conch_ViT-B-16", "hf_hub:MahmoodLab/conch")
|
| 46 |
+
size, norm = 448, CLIPN
|
| 47 |
+
fwd = lambda m, x: m.encode_image(x, proj_contrast=False, normalize=False)
|
| 48 |
+
elif name == "conchclip": # CONCH image tower projected into its joint image-text space (zero-shot with CONCH text)
|
| 49 |
+
from conch.open_clip_custom import create_model_from_pretrained
|
| 50 |
+
model, _ = create_model_from_pretrained("conch_ViT-B-16", "hf_hub:MahmoodLab/conch")
|
| 51 |
+
size, norm = 448, CLIPN
|
| 52 |
+
fwd = lambda m, x: m.encode_image(x, proj_contrast=True, normalize=False)
|
| 53 |
+
elif name == "plip": # open pathology CLIP (OpenPath tweets), stand-in for gated CONCH
|
| 54 |
+
from transformers import CLIPModel
|
| 55 |
+
model = CLIPModel.from_pretrained("vinid/plip")
|
| 56 |
+
size, norm, fwd = 224, CLIPN, lambda m, x: m.get_image_features(pixel_values=x)
|
| 57 |
+
elif name == "phikonv2": # open DINOv2 ViT-L pathology SSL model, stand-in for gated GigaPath
|
| 58 |
+
from transformers import AutoModel
|
| 59 |
+
model = AutoModel.from_pretrained("owkin/phikon-v2")
|
| 60 |
+
size, norm, fwd = 224, IMNET, lambda m, x: m(pixel_values=x).last_hidden_state[:, 0]
|
| 61 |
+
elif name == "biomedclip": # BiomedCLIP ViT-B/16 image tower (PMC-15M), projected -> 512; zero-shot baselines
|
| 62 |
+
import open_clip
|
| 63 |
+
model, _ = open_clip.create_model_from_pretrained("hf-hub:microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224")
|
| 64 |
+
size, norm, fwd = 224, CLIPN, lambda m, x: m.encode_image(x)
|
| 65 |
+
elif name == "imagebind": # used here for PathGen zero-shot baselines
|
| 66 |
+
model, MT = load_imagebind_vision()
|
| 67 |
+
size, norm, fwd = 224, CLIPN, lambda m, x: m({MT.VISION: x})[MT.VISION]
|
| 68 |
+
else:
|
| 69 |
+
raise ValueError(name)
|
| 70 |
+
model.eval().to(dev)
|
| 71 |
+
mean = torch.tensor(norm[0]).view(1, 3, 1, 1)
|
| 72 |
+
std = torch.tensor(norm[1]).view(1, 3, 1, 1)
|
| 73 |
+
|
| 74 |
+
if PATHGEN:
|
| 75 |
+
import pandas as pd
|
| 76 |
+
paths = pd.read_csv(os.path.join(CACHE, f"{SET}_meta.csv")).image_path.values
|
| 77 |
+
else:
|
| 78 |
+
paths = np.array([os.path.join(ORION, p) for p in load_all_tiles().image_path])
|
| 79 |
+
out = None
|
| 80 |
+
bs = 32
|
| 81 |
+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.float16):
|
| 82 |
+
for s in range(0, len(paths), bs):
|
| 83 |
+
ims = [np.asarray(Image.open(p).convert("RGB").resize((size, size), Image.BICUBIC))
|
| 84 |
+
for p in paths[s:s + bs]]
|
| 85 |
+
x = (torch.from_numpy(np.stack(ims)).permute(0, 3, 1, 2).float() / 255 - mean) / std
|
| 86 |
+
for d in range(8):
|
| 87 |
+
e = fwd(model, dihedral(x, d).to(dev)).float().cpu().numpy()
|
| 88 |
+
if out is None:
|
| 89 |
+
out = np.zeros((len(paths), 8, e.shape[1]), np.float16)
|
| 90 |
+
out[s:s + bs, d] = e
|
| 91 |
+
if s % 640 == 0:
|
| 92 |
+
print(name, s, len(paths), flush=True)
|
| 93 |
+
np.save(os.path.join(CACHE, (f"{SET}_" if PATHGEN else "") + f"he_{name}.npy"), out)
|
| 94 |
+
print("saved", name, out.shape, "finite:", np.isfinite(out).all())
|
experiments/05_train_eval.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ImageBind-style binding of IF / nuclei / markers to a frozen H&E (ImageBind-Huge vision) anchor.
|
| 2 |
+
|
| 3 |
+
loss = sum_m InfoNCE_sym(he, m) (--loss anchor, ImageBind default; IF<->nuclei etc. are *emergent*)
|
| 4 |
+
+ sum_{m<n} InfoNCE_sym(m, n) (--loss allpairs ablation)
|
| 5 |
+
|
| 6 |
+
Evaluation on the test split: cross-modal retrieval for all 12 directed modality pairs
|
| 7 |
+
(R@1/5/10, MRR, median rank) and one-to-one pairing accuracy (Hungarian assignment on similarity).
|
| 8 |
+
"""
|
| 9 |
+
import argparse
|
| 10 |
+
import itertools
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
import timm
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
import torch.nn.functional as F
|
| 21 |
+
from scipy.optimize import linear_sum_assignment
|
| 22 |
+
|
| 23 |
+
from common import CACHE, MODALITIES, RUNS, SPLITS, dihedral
|
| 24 |
+
|
| 25 |
+
p = argparse.ArgumentParser()
|
| 26 |
+
p.add_argument("--split", default="orion_official")
|
| 27 |
+
p.add_argument("--fold", type=int, default=0)
|
| 28 |
+
p.add_argument("--loss", default="anchor", choices=["anchor", "allpairs"])
|
| 29 |
+
p.add_argument("--anchor", default="imagebind", choices=["imagebind", "uni2", "conch", "gigapath", "plip", "phikonv2"],
|
| 30 |
+
help="frozen H&E encoder whose features (cache/he_<anchor>.npy) define the shared space")
|
| 31 |
+
p.add_argument("--epochs", type=int, default=40)
|
| 32 |
+
p.add_argument("--bs", type=int, default=128)
|
| 33 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 34 |
+
p.add_argument("--backbone", default="resnet18.a1_in1k")
|
| 35 |
+
p.add_argument("--seed", type=int, default=0)
|
| 36 |
+
p.add_argument("--min_nuclei_tissue", type=int, default=20)
|
| 37 |
+
args = p.parse_args()
|
| 38 |
+
torch.manual_seed(args.seed); np.random.seed(args.seed)
|
| 39 |
+
dev = "cuda"
|
| 40 |
+
run_dir = os.path.join(RUNS, f"{args.split}_fold{args.fold}_{args.loss}"
|
| 41 |
+
+ ("" if args.anchor == "imagebind" else f"_{args.anchor}"))
|
| 42 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 43 |
+
|
| 44 |
+
# ---------------- data ----------------
|
| 45 |
+
sp = pd.read_csv(os.path.join(SPLITS, args.split, f"fold{args.fold}.csv"))
|
| 46 |
+
he_emb = torch.from_numpy(np.load(os.path.join(CACHE, f"he_{args.anchor}.npy")).astype(np.float32)) # N,8,D
|
| 47 |
+
D = he_emb.shape[-1] # shared embedding dim = anchor dim (1024 ImageBind/Phikon-v2, 1536 UNI2/GigaPath, 512 CONCH/PLIP)
|
| 48 |
+
IF = torch.from_numpy(np.load(os.path.join(CACHE, "if.npy"))) # N,17,224,224 uint8 (in RAM, ~2.7GB)
|
| 49 |
+
NUC = torch.from_numpy(np.load(os.path.join(CACHE, "nuclei.npy"))) # N,2,224,224
|
| 50 |
+
MK = torch.from_numpy(np.load(os.path.join(CACHE, "markers.npy"))) # N,33
|
| 51 |
+
tr_idx = sp.tile_id[sp.split == "train"].values
|
| 52 |
+
va_idx = sp.tile_id[sp.split == "val"].values
|
| 53 |
+
te_idx = sp.tile_id[sp.split == "test"].values
|
| 54 |
+
# normalisation statistics from training tiles only
|
| 55 |
+
if_mean = IF[tr_idx].float().mean((0, 2, 3)).view(1, -1, 1, 1) / 255
|
| 56 |
+
if_std = IF[tr_idx].float().std((0, 2, 3)).view(1, -1, 1, 1) / 255 + 1e-3
|
| 57 |
+
mk_mean, mk_std = MK[tr_idx].mean(0), MK[tr_idx].std(0) + 1e-3
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def batch(idx, d=None, train=False):
|
| 61 |
+
"""Returns dict of modality tensors on GPU. d: dihedral id per sample (shared across image modalities)."""
|
| 62 |
+
idx = torch.as_tensor(idx)
|
| 63 |
+
if d is None:
|
| 64 |
+
d = torch.zeros(len(idx), dtype=torch.long)
|
| 65 |
+
x_if = IF[idx].to(dev, non_blocking=True).float() / 255
|
| 66 |
+
x_nu = NUC[idx].to(dev, non_blocking=True).float() / 255
|
| 67 |
+
x_if = torch.stack([dihedral(a, int(k)) for a, k in zip(x_if, d)])
|
| 68 |
+
x_nu = torch.stack([dihedral(a, int(k)) for a, k in zip(x_nu, d)])
|
| 69 |
+
if train: # mild per-channel intensity jitter for IF
|
| 70 |
+
x_if = x_if * (1 + 0.1 * torch.randn(len(idx), x_if.shape[1], 1, 1, device=dev))
|
| 71 |
+
x_if = (x_if - if_mean.to(dev)) / if_std.to(dev)
|
| 72 |
+
x_nu = (x_nu - 0.5) / 0.5
|
| 73 |
+
mk = ((MK[idx] - mk_mean) / mk_std).to(dev)
|
| 74 |
+
if train:
|
| 75 |
+
mk = mk + 0.05 * torch.randn_like(mk)
|
| 76 |
+
he = he_emb[idx, d].to(dev)
|
| 77 |
+
return {"he": he, "if": x_if, "nuclei": x_nu, "markers": mk}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------- model ----------------
|
| 81 |
+
class ImgEnc(nn.Module):
|
| 82 |
+
def __init__(self, in_chans):
|
| 83 |
+
super().__init__()
|
| 84 |
+
self.trunk = timm.create_model(args.backbone, pretrained=True, in_chans=in_chans, num_classes=0)
|
| 85 |
+
self.head = nn.Sequential(nn.LayerNorm(self.trunk.num_features), nn.Linear(self.trunk.num_features, D))
|
| 86 |
+
|
| 87 |
+
def forward(self, x):
|
| 88 |
+
return self.head(self.trunk(x))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class MLPEnc(nn.Module):
|
| 92 |
+
def __init__(self, d_in):
|
| 93 |
+
super().__init__()
|
| 94 |
+
self.net = nn.Sequential(nn.Linear(d_in, 512), nn.GELU(), nn.Dropout(0.1), nn.Linear(512, 512), nn.GELU(),
|
| 95 |
+
nn.LayerNorm(512), nn.Linear(512, D))
|
| 96 |
+
|
| 97 |
+
def forward(self, x):
|
| 98 |
+
return self.net(x)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class Bind(nn.Module):
|
| 102 |
+
def __init__(self):
|
| 103 |
+
super().__init__()
|
| 104 |
+
self.enc = nn.ModuleDict({"if": ImgEnc(17), "nuclei": ImgEnc(2), "markers": MLPEnc(MK.shape[1])})
|
| 105 |
+
pairs = [("he", m) for m in ["if", "nuclei", "markers"]]
|
| 106 |
+
if args.loss == "allpairs":
|
| 107 |
+
pairs += [("if", "nuclei"), ("if", "markers"), ("nuclei", "markers")]
|
| 108 |
+
self.pairs = pairs
|
| 109 |
+
# ImageBind: learnable logit scale (init 1/0.07) per pair
|
| 110 |
+
self.logit_scale = nn.ParameterDict({f"{a}-{b}": nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32))
|
| 111 |
+
for a, b in pairs})
|
| 112 |
+
|
| 113 |
+
def embed(self, b):
|
| 114 |
+
z = {"he": F.normalize(b["he"], dim=-1)} # frozen anchor
|
| 115 |
+
for m, e in self.enc.items():
|
| 116 |
+
z[m] = F.normalize(e(b[m]), dim=-1)
|
| 117 |
+
return z
|
| 118 |
+
|
| 119 |
+
def loss(self, z):
|
| 120 |
+
tot, logs = 0, {}
|
| 121 |
+
tgt = torch.arange(len(z["he"]), device=dev)
|
| 122 |
+
for a, bm in self.pairs:
|
| 123 |
+
s = self.logit_scale[f"{a}-{bm}"].exp().clamp(max=100)
|
| 124 |
+
lg = s * z[a] @ z[bm].T
|
| 125 |
+
l = (F.cross_entropy(lg, tgt) + F.cross_entropy(lg.T, tgt)) / 2
|
| 126 |
+
tot = tot + l
|
| 127 |
+
logs[f"{a}-{bm}"] = l.item()
|
| 128 |
+
return tot, logs
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
model = Bind().to(dev)
|
| 132 |
+
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.05)
|
| 133 |
+
steps = args.epochs * max(1, len(tr_idx) // args.bs)
|
| 134 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=steps, pct_start=0.05)
|
| 135 |
+
scaler = torch.amp.GradScaler()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@torch.no_grad()
|
| 139 |
+
def embed_all(idx):
|
| 140 |
+
model.eval()
|
| 141 |
+
zs = {m: [] for m in MODALITIES}
|
| 142 |
+
for s in range(0, len(idx), 256):
|
| 143 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 144 |
+
z = model.embed(batch(idx[s:s + 256]))
|
| 145 |
+
for m in MODALITIES:
|
| 146 |
+
zs[m].append(z[m].float().cpu())
|
| 147 |
+
return {m: torch.cat(v) for m, v in zs.items()}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def retrieval_metrics(zq, zg):
|
| 151 |
+
sim = (zq @ zg.T).numpy()
|
| 152 |
+
n = len(sim)
|
| 153 |
+
rank = (sim > sim[np.arange(n), np.arange(n)][:, None]).sum(1) # 0 = correct is top-1
|
| 154 |
+
r, c = linear_sum_assignment(-sim)
|
| 155 |
+
return {"R@1": float((rank < 1).mean()), "R@5": float((rank < 5).mean()), "R@10": float((rank < 10).mean()),
|
| 156 |
+
"MRR": float((1 / (rank + 1)).mean()), "MedR": float(np.median(rank + 1)),
|
| 157 |
+
"pair_acc": float((c[np.argsort(r)] == np.arange(n)).mean()), "N": n, "chance_R@1": 1 / n}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def fixed_gallery(zq, zg, size=100, reps=50):
|
| 161 |
+
"""R@1 / pairing accuracy averaged over random galleries of a fixed size (comparable across splits)."""
|
| 162 |
+
rng = np.random.default_rng(0)
|
| 163 |
+
n = len(zq)
|
| 164 |
+
if n <= size:
|
| 165 |
+
m = retrieval_metrics(zq, zg)
|
| 166 |
+
return {"R@1_g100": m["R@1"], "pair_acc_g100": m["pair_acc"]}
|
| 167 |
+
r1, pa = [], []
|
| 168 |
+
for _ in range(reps):
|
| 169 |
+
s = torch.from_numpy(rng.choice(n, size, replace=False))
|
| 170 |
+
m = retrieval_metrics(zq[s], zg[s])
|
| 171 |
+
r1.append(m["R@1"]); pa.append(m["pair_acc"])
|
| 172 |
+
return {"R@1_g100": float(np.mean(r1)), "pair_acc_g100": float(np.mean(pa))}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def evaluate(idx, fixed=False):
|
| 176 |
+
z = embed_all(idx)
|
| 177 |
+
res = {}
|
| 178 |
+
for a, b in itertools.permutations(MODALITIES, 2):
|
| 179 |
+
res[f"{a}->{b}"] = retrieval_metrics(z[a], z[b])
|
| 180 |
+
if fixed:
|
| 181 |
+
res[f"{a}->{b}"].update(fixed_gallery(z[a], z[b]))
|
| 182 |
+
return res, z
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def mean_r1(res):
|
| 186 |
+
return float(np.mean([v["R@1"] for v in res.values()]))
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ---------------- train ----------------
|
| 190 |
+
best, hist = -1, []
|
| 191 |
+
t0 = time.time()
|
| 192 |
+
for ep in range(args.epochs):
|
| 193 |
+
model.train()
|
| 194 |
+
perm = np.random.permutation(tr_idx)
|
| 195 |
+
tl = []
|
| 196 |
+
for s in range(0, len(perm) - args.bs + 1, args.bs):
|
| 197 |
+
idx = perm[s:s + args.bs]
|
| 198 |
+
b = batch(idx, d=torch.randint(0, 8, (len(idx),)), train=True)
|
| 199 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 200 |
+
loss, logs = model.loss(model.embed(b))
|
| 201 |
+
opt.zero_grad(set_to_none=True)
|
| 202 |
+
loss.backward()
|
| 203 |
+
opt.step(); sched.step()
|
| 204 |
+
tl.append(loss.item())
|
| 205 |
+
vres, _ = evaluate(va_idx)
|
| 206 |
+
v = mean_r1(vres)
|
| 207 |
+
hist.append({"epoch": ep, "train_loss": float(np.mean(tl)), "val_meanR1": v, **{k: x for k, x in logs.items()}})
|
| 208 |
+
print(f"ep {ep} loss {np.mean(tl):.3f} val meanR@1 {v:.3f} ({time.time() - t0:.0f}s)", flush=True)
|
| 209 |
+
if v > best:
|
| 210 |
+
best = v
|
| 211 |
+
torch.save(model.state_dict(), os.path.join(run_dir, "best.pt"))
|
| 212 |
+
|
| 213 |
+
# ---------------- test ----------------
|
| 214 |
+
model.load_state_dict(torch.load(os.path.join(run_dir, "best.pt")))
|
| 215 |
+
tres, z = evaluate(te_idx, fixed=True)
|
| 216 |
+
stats = pd.read_csv(os.path.join(os.path.dirname(RUNS), "results", "tile_stats.csv")).set_index("tile_id")
|
| 217 |
+
tissue = stats.loc[te_idx, "n_nuclei"].values >= args.min_nuclei_tissue
|
| 218 |
+
tres_tissue = {}
|
| 219 |
+
for a, b in itertools.permutations(MODALITIES, 2):
|
| 220 |
+
tres_tissue[f"{a}->{b}"] = retrieval_metrics(z[a][tissue], z[b][tissue])
|
| 221 |
+
tres_tissue[f"{a}->{b}"].update(fixed_gallery(z[a][tissue], z[b][tissue]))
|
| 222 |
+
out = {"args": vars(args), "n_train": len(tr_idx), "n_val": len(va_idx), "n_test": len(te_idx),
|
| 223 |
+
"n_test_tissue": int(tissue.sum()), "best_val_meanR1": best, "test": tres, "test_tissue": tres_tissue,
|
| 224 |
+
"history": hist}
|
| 225 |
+
json.dump(out, open(os.path.join(run_dir, "metrics.json"), "w"), indent=1)
|
| 226 |
+
torch.save({m: v for m, v in z.items()}, os.path.join(run_dir, "test_embeddings.pt"))
|
| 227 |
+
print("TEST mean R@1 (all pairs):", mean_r1(tres), "| tissue-only:", mean_r1(tres_tissue))
|
| 228 |
+
for k in ["he->if", "he->nuclei", "he->markers", "if->nuclei", "if->markers", "nuclei->markers"]:
|
| 229 |
+
print(f" {k:18s} R@1 {tres[k]['R@1']:.3f} R@5 {tres[k]['R@5']:.3f} pair_acc {tres[k]['pair_acc']:.3f} R@1_g100 {tres[k]['R@1_g100']:.3f}")
|
experiments/06_summarize.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregate runs/*/metrics.json into results/summary_*.csv (mean ± std over folds)."""
|
| 2 |
+
import glob
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
from common import ROOT, RUNS
|
| 9 |
+
|
| 10 |
+
PAIRS = ["he->if", "if->he", "he->nuclei", "nuclei->he", "he->markers", "markers->he",
|
| 11 |
+
"if->nuclei", "nuclei->if", "if->markers", "markers->if", "nuclei->markers", "markers->nuclei"]
|
| 12 |
+
rows = []
|
| 13 |
+
for f in sorted(glob.glob(os.path.join(RUNS, "*", "metrics.json"))):
|
| 14 |
+
m = json.load(open(f))
|
| 15 |
+
a = m["args"]
|
| 16 |
+
for subset in ["test", "test_tissue"]:
|
| 17 |
+
for pr in PAIRS:
|
| 18 |
+
r = m[subset][pr]
|
| 19 |
+
rows.append({"anchor": a.get("anchor", "imagebind"), "split": a["split"], "fold": a["fold"], "loss": a["loss"], "subset": subset, "pair": pr,
|
| 20 |
+
"trained_directly": pr.startswith("he") or pr.endswith("he") or a["loss"] == "allpairs",
|
| 21 |
+
**{k: r[k] for k in ["N", "chance_R@1", "R@1", "R@5", "R@10", "MRR", "MedR", "pair_acc",
|
| 22 |
+
"R@1_g100", "pair_acc_g100"]}})
|
| 23 |
+
df = pd.DataFrame(rows)
|
| 24 |
+
out = os.path.join(ROOT, "results")
|
| 25 |
+
df.to_csv(os.path.join(out, "all_runs_long.csv"), index=False)
|
| 26 |
+
|
| 27 |
+
met = ["R@1", "R@5", "R@10", "MRR", "pair_acc", "R@1_g100", "pair_acc_g100"]
|
| 28 |
+
g = df.groupby(["anchor", "loss", "subset", "split", "pair"], sort=False)
|
| 29 |
+
agg = g[met + ["N"]].mean()
|
| 30 |
+
sd = g[met].std().fillna(0)
|
| 31 |
+
tab = agg.copy()
|
| 32 |
+
for c in met:
|
| 33 |
+
tab[c] = [f"{x:.3f}±{s:.3f}" if n > 1 else f"{x:.3f}" for x, s, n in zip(agg[c], sd[c], g.size())]
|
| 34 |
+
tab["n_folds"] = g.size()
|
| 35 |
+
tab.reset_index().to_csv(os.path.join(out, "summary_by_pair.csv"), index=False)
|
| 36 |
+
|
| 37 |
+
# compact headline: mean over the 12 directed pairs, separated into anchor-trained vs emergent pairs
|
| 38 |
+
h = df.groupby(["anchor", "loss", "subset", "split", "fold", "trained_directly"])[met].mean().reset_index()
|
| 39 |
+
h = h.groupby(["anchor", "loss", "subset", "split", "trained_directly"])[met].agg(["mean", "std"]).round(3)
|
| 40 |
+
h.to_csv(os.path.join(out, "summary_headline.csv"))
|
| 41 |
+
pd.set_option("display.width", 250)
|
| 42 |
+
print(h[[("R@1", "mean"), ("R@1", "std"), ("R@1_g100", "mean"), ("pair_acc", "mean"), ("pair_acc_g100", "mean")]])
|
| 43 |
+
|
| 44 |
+
# H&E-encoder ablation (anchor loss, full test set): fixed-gallery R@1 per pair, mean over folds
|
| 45 |
+
ab = df[(df.loss == "anchor") & (df.subset == "test")]
|
| 46 |
+
fm = ab.pivot_table(index=["split", "pair"], columns="anchor", values="R@1_g100", aggfunc="mean").round(3)
|
| 47 |
+
fm.to_csv(os.path.join(out, "anchor_ablation_R1_g100.csv"))
|
| 48 |
+
fm2 = ab.pivot_table(index=["split", "pair"], columns="anchor", values="pair_acc", aggfunc="mean").round(3)
|
| 49 |
+
fm2.to_csv(os.path.join(out, "anchor_ablation_pair_acc.csv"))
|
experiments/07_make_text.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Templated per-tile text for ORION (no real text is paired with ORION tiles).
|
| 2 |
+
|
| 3 |
+
Each tile gets 4 paraphrases describing cellularity and cell-type composition derived from the
|
| 4 |
+
marker-positive nuclei counts (fraction of all nuclei) — i.e. the text carries a coarse, discretised
|
| 5 |
+
version of the marker information. Kept short (< 77 CLIP tokens).
|
| 6 |
+
Output: cache/text.json {tile_id: [4 strings]}
|
| 7 |
+
"""
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
from common import CACHE, ROOT, load_all_tiles
|
| 15 |
+
|
| 16 |
+
df = load_all_tiles()
|
| 17 |
+
stats = pd.read_csv(os.path.join(ROOT, "results", "tile_stats.csv")).set_index("tile_id")
|
| 18 |
+
df["n_nuclei"] = stats.loc[df.tile_id, "n_nuclei"].values
|
| 19 |
+
|
| 20 |
+
CELLS = [ # (count column, phrase)
|
| 21 |
+
("Pan-CK_count", "Pan-CK+ epithelial cells"), ("SMA_count", "SMA+ stromal cells"),
|
| 22 |
+
("CD45_count", "CD45+ immune cells"), ("CD3e_count", "CD3e+ T cells"), ("CD8a_count", "CD8a+ cytotoxic T cells"),
|
| 23 |
+
("CD4_count", "CD4+ helper T cells"), ("FOXP3_count", "FOXP3+ regulatory T cells"), ("CD20_count", "CD20+ B cells"),
|
| 24 |
+
("CD68_count", "CD68+ macrophages"), ("CD163_count", "CD163+ macrophages"), ("CD31_count", "CD31+ endothelial cells"),
|
| 25 |
+
("Ki67_count", "Ki67+ proliferating cells"), ("PD-L1_count", "PD-L1+ cells"), ("PD-1_count", "PD-1+ cells"),
|
| 26 |
+
]
|
| 27 |
+
LEVEL_WORDS = [ # per paraphrase: (few, moderate, abundant)
|
| 28 |
+
("few", "moderate", "abundant"), ("scattered", "some", "many"),
|
| 29 |
+
("rare", "a moderate number of", "numerous"), ("sparse", "intermediate", "dense"),
|
| 30 |
+
]
|
| 31 |
+
DENSITY = [("acellular", "sparsely cellular", "moderately cellular", "highly cellular"),
|
| 32 |
+
("background", "low-density", "medium-density", "dense"),
|
| 33 |
+
("nearly empty", "sparse", "moderately dense", "crowded"),
|
| 34 |
+
("mostly non-tissue", "hypocellular", "cellular", "hypercellular")]
|
| 35 |
+
OPEN = ["H&E tile, {d} region with {n} nuclei.", "A {d} tissue patch ({n} nuclei).",
|
| 36 |
+
"Colorectal cancer tissue, {d}, about {n} nuclei.", "{D} area containing {n} nuclei."]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def level(frac):
|
| 40 |
+
return None if frac < 0.02 else 0 if frac < 0.08 else 1 if frac < 0.25 else 2
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def describe(r, v, rng):
|
| 44 |
+
n = int(r.n_nuclei)
|
| 45 |
+
di = 0 if n < 20 else 1 if n < 100 else 2 if n < 250 else 3
|
| 46 |
+
d = DENSITY[v][di]
|
| 47 |
+
n_round = int(round(n, -1))
|
| 48 |
+
parts = [OPEN[v].format(d=d, D=d.capitalize(), n=n_round)]
|
| 49 |
+
if n >= 20:
|
| 50 |
+
items = []
|
| 51 |
+
for col, name in CELLS:
|
| 52 |
+
lv = level(r[col] / max(n, 1))
|
| 53 |
+
if lv is not None:
|
| 54 |
+
items.append((lv, f"{LEVEL_WORDS[v][lv]} {name}"))
|
| 55 |
+
items.sort(key=lambda x: -x[0])
|
| 56 |
+
items = [s for _, s in items[:6]] # most prominent 6 keep text < 77 tokens
|
| 57 |
+
if v % 2 == 1:
|
| 58 |
+
rng.shuffle(items)
|
| 59 |
+
parts.append(("Shows " + ", ".join(items) + ".") if items else "No marker-positive population stands out.")
|
| 60 |
+
return " ".join(parts)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
rng = np.random.default_rng(0)
|
| 64 |
+
texts = {int(r.tile_id): [describe(r, v, rng) for v in range(4)] for _, r in df.iterrows()}
|
| 65 |
+
json.dump(texts, open(os.path.join(CACHE, "text.json"), "w"), indent=0)
|
| 66 |
+
u = len(set(t[0] for t in texts.values()))
|
| 67 |
+
print(f"{len(texts)} tiles, {u} unique variant-0 texts")
|
| 68 |
+
for k in [0, 500, 1500]:
|
| 69 |
+
print(texts[k])
|
experiments/08_extract_text.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen text-tower embeddings of the templated tile descriptions.
|
| 2 |
+
Usage: python 08_extract_text.py {plip|biomedclip|imagebind|conchclip} [--set <name> | --pathgen | --prompts]
|
| 3 |
+
Output: cache/text_<enc>.npy (N, 4 paraphrases, D) float16.
|
| 4 |
+
plip : vinid/plip CLIP text tower (77 tokens), projected -> 512
|
| 5 |
+
biomedclip : microsoft/BiomedCLIP PubMedBERT text tower (256 tokens), projected -> 512
|
| 6 |
+
imagebind : ImageBind-Huge text tower (OpenCLIP ViT-H text, 77 tokens) -> 1024
|
| 7 |
+
conchclip : MahmoodLab/CONCH text tower (128 tokens), projected -> 512
|
| 8 |
+
"""
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from common import CACHE, ROOT, load_imagebind_vision
|
| 17 |
+
|
| 18 |
+
name = sys.argv[1]
|
| 19 |
+
SET = sys.argv[sys.argv.index("--set") + 1] if "--set" in sys.argv else "pathgen" if "--pathgen" in sys.argv else None
|
| 20 |
+
PATHGEN = SET is not None # encode an image-text pair set (cache/<SET>_meta.csv, e.g. PathGen-1.6M) instead of ORION tiles
|
| 21 |
+
PROMPTS = "--prompts" in sys.argv # encode concept prompts (prompts.json) for zero-shot probing
|
| 22 |
+
if PROMPTS:
|
| 23 |
+
pr = json.load(open(os.path.join(ROOT, "prompts.json")))
|
| 24 |
+
flat = [v["text"] for v in pr.values()]
|
| 25 |
+
ids, NV = list(range(len(flat))), 1
|
| 26 |
+
elif PATHGEN:
|
| 27 |
+
import pandas as pd
|
| 28 |
+
flat = pd.read_csv(os.path.join(CACHE, f"{SET}_meta.csv")).caption.tolist()
|
| 29 |
+
ids, NV = list(range(len(flat))), 1
|
| 30 |
+
else:
|
| 31 |
+
texts = json.load(open(os.path.join(CACHE, "text.json")))
|
| 32 |
+
ids, NV = sorted(int(k) for k in texts), 4
|
| 33 |
+
flat = [texts[str(i)][v] for i in ids for v in range(NV)]
|
| 34 |
+
|
| 35 |
+
if name == "plip":
|
| 36 |
+
from transformers import CLIPModel, CLIPTokenizer
|
| 37 |
+
m = CLIPModel.from_pretrained("vinid/plip").cuda().eval()
|
| 38 |
+
tok = CLIPTokenizer.from_pretrained("openai/clip-vit-base-patch32") # PLIP ships no tokenizer; it uses the CLIP B/32 one
|
| 39 |
+
enc = lambda b: m.get_text_features(**{k: v.cuda() for k, v in tok(b, padding=True, truncation=True, max_length=77, return_tensors="pt").items()})
|
| 40 |
+
elif name == "biomedclip":
|
| 41 |
+
import open_clip
|
| 42 |
+
m, _ = open_clip.create_model_from_pretrained("hf-hub:microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224")
|
| 43 |
+
tok = open_clip.get_tokenizer("hf-hub:microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224")
|
| 44 |
+
m = m.cuda().eval()
|
| 45 |
+
enc = lambda b: m.encode_text(tok(b, context_length=256).cuda())
|
| 46 |
+
elif name == "imagebind":
|
| 47 |
+
model, MT = load_imagebind_vision()
|
| 48 |
+
from imagebind.models.multimodal_preprocessors import SimpleTokenizer
|
| 49 |
+
tk = SimpleTokenizer(bpe_path=os.path.join(ROOT, "ImageBind_src", "imagebind", "bpe", "bpe_simple_vocab_16e6.txt.gz"))
|
| 50 |
+
enc = lambda b: model({MT.TEXT: torch.cat([tk(t).unsqueeze(0) for t in b]).cuda()})[MT.TEXT]
|
| 51 |
+
elif name == "conchclip": # CONCH text tower (CoCa, 128 tokens), projected -> 512 (pairs with the conchclip image tower)
|
| 52 |
+
from conch.open_clip_custom import create_model_from_pretrained, get_tokenizer, tokenize
|
| 53 |
+
m, _ = create_model_from_pretrained("conch_ViT-B-16", "hf_hub:MahmoodLab/conch")
|
| 54 |
+
m = m.cuda().eval()
|
| 55 |
+
tok = get_tokenizer()
|
| 56 |
+
enc = lambda b: m.encode_text(tokenize(tok, b).cuda(), normalize=False)
|
| 57 |
+
else:
|
| 58 |
+
raise ValueError(name)
|
| 59 |
+
|
| 60 |
+
out = []
|
| 61 |
+
with torch.no_grad():
|
| 62 |
+
for s in range(0, len(flat), 256):
|
| 63 |
+
out.append(enc(flat[s:s + 256]).float().cpu().numpy())
|
| 64 |
+
out = np.concatenate(out).reshape(len(ids), NV, -1).astype(np.float16)
|
| 65 |
+
assert ids == list(range(len(ids)))
|
| 66 |
+
pre = "prompts_" if PROMPTS else f"{SET}_" if PATHGEN else ""
|
| 67 |
+
np.save(os.path.join(CACHE, pre + f"text_{name}.npy"), out)
|
| 68 |
+
print("saved", name, out.shape, np.isfinite(out).all())
|
experiments/09_bench.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encoder benchmark for the H&E / mIF / markers / text binding (ImageBind-style, frozen H&E anchor).
|
| 2 |
+
|
| 3 |
+
With a frozen anchor and anchor-only losses, each trainable encoder receives gradient only from its own
|
| 4 |
+
InfoNCE(he, m) term, so one run trains one IF encoder, one marker encoder and one text head *independently*;
|
| 5 |
+
each modality keeps its own best epoch (val R@1 of he<->m) and its test embeddings are saved at that epoch.
|
| 6 |
+
Cross pairs (if<->markers, if<->text, markers<->text) are then evaluated post hoc for any encoder combination
|
| 7 |
+
(10_bench_summary.py).
|
| 8 |
+
|
| 9 |
+
IF encoders : resnet18 | convnext_tiny | chanset (shared per-channel ResNet18 + channel tokens + transformer)
|
| 10 |
+
marker encoders : linear | mlp | ftt (FT-Transformer, one token per marker)
|
| 11 |
+
text encoders : plip | biomedclip | imagebind (frozen text tower, trainable MLP head)
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import pandas as pd
|
| 20 |
+
import timm
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn as nn
|
| 23 |
+
import torch.nn.functional as F
|
| 24 |
+
|
| 25 |
+
from common import CACHE, RUNS, SPLITS, dihedral
|
| 26 |
+
|
| 27 |
+
p = argparse.ArgumentParser()
|
| 28 |
+
p.add_argument("--split", default="orion_official")
|
| 29 |
+
p.add_argument("--fold", type=int, default=0)
|
| 30 |
+
p.add_argument("--anchor", default="uni2")
|
| 31 |
+
p.add_argument("--if_enc", default="resnet18", choices=["resnet18", "convnext_tiny", "chanset"])
|
| 32 |
+
p.add_argument("--mk_enc", default="mlp", choices=["linear", "mlp", "ftt"])
|
| 33 |
+
p.add_argument("--txt_enc", default="plip", choices=["plip", "biomedclip", "imagebind"])
|
| 34 |
+
p.add_argument("--epochs", type=int, default=40)
|
| 35 |
+
p.add_argument("--bs", type=int, default=128)
|
| 36 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 37 |
+
p.add_argument("--seed", type=int, default=0)
|
| 38 |
+
args = p.parse_args()
|
| 39 |
+
torch.manual_seed(args.seed); np.random.seed(args.seed)
|
| 40 |
+
dev = "cuda"
|
| 41 |
+
TRAIN = ["if", "markers", "text"]
|
| 42 |
+
ENC = {"if": args.if_enc, "markers": args.mk_enc, "text": args.txt_enc}
|
| 43 |
+
run_dir = os.path.join(RUNS, "bench", f"{args.split}_fold{args.fold}_{args.anchor}_{args.if_enc}_{args.mk_enc}_{args.txt_enc}")
|
| 44 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 45 |
+
|
| 46 |
+
# ---------------- data ----------------
|
| 47 |
+
sp = pd.read_csv(os.path.join(SPLITS, args.split, f"fold{args.fold}.csv"))
|
| 48 |
+
HE = torch.from_numpy(np.load(os.path.join(CACHE, f"he_{args.anchor}.npy")).astype(np.float32)) # N,8,D
|
| 49 |
+
D = HE.shape[-1]
|
| 50 |
+
IF = torch.from_numpy(np.load(os.path.join(CACHE, "if.npy")))
|
| 51 |
+
MK = torch.from_numpy(np.load(os.path.join(CACHE, "markers.npy")))
|
| 52 |
+
TX = torch.from_numpy(np.load(os.path.join(CACHE, f"text_{args.txt_enc}.npy")).astype(np.float32)) # N,4,Dt
|
| 53 |
+
tr_idx, va_idx, te_idx = (sp.tile_id[sp.split == s].values for s in ["train", "val", "test"])
|
| 54 |
+
if_mean = IF[tr_idx].float().mean((0, 2, 3)).view(1, -1, 1, 1).to(dev) / 255
|
| 55 |
+
if_std = IF[tr_idx].float().std((0, 2, 3)).view(1, -1, 1, 1).to(dev) / 255 + 1e-3
|
| 56 |
+
mk_mean, mk_std = MK[tr_idx].mean(0), MK[tr_idx].std(0) + 1e-3
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def batch(idx, train=False):
|
| 60 |
+
idx = torch.as_tensor(idx)
|
| 61 |
+
d = torch.randint(0, 8, (len(idx),)) if train else torch.zeros(len(idx), dtype=torch.long)
|
| 62 |
+
v = torch.randint(0, 4, (len(idx),)) if train else torch.zeros(len(idx), dtype=torch.long)
|
| 63 |
+
x = IF[idx].to(dev, non_blocking=True).float() / 255
|
| 64 |
+
x = torch.stack([dihedral(a, int(k)) for a, k in zip(x, d)])
|
| 65 |
+
if train:
|
| 66 |
+
x = x * (1 + 0.1 * torch.randn(len(idx), x.shape[1], 1, 1, device=dev))
|
| 67 |
+
mk = (MK[idx] - mk_mean) / mk_std
|
| 68 |
+
if train:
|
| 69 |
+
mk = mk + 0.05 * torch.randn_like(mk)
|
| 70 |
+
return {"he": HE[idx, d].to(dev), "if": (x - if_mean) / if_std, "markers": mk.to(dev), "text": TX[idx, v].to(dev)}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---------------- encoders ----------------
|
| 74 |
+
def head(din):
|
| 75 |
+
return nn.Sequential(nn.LayerNorm(din), nn.Linear(din, D))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class TimmEnc(nn.Module):
|
| 79 |
+
def __init__(self, name):
|
| 80 |
+
super().__init__()
|
| 81 |
+
tag = {"resnet18": "resnet18.a1_in1k", "convnext_tiny": "convnext_tiny.fb_in1k"}[name]
|
| 82 |
+
self.trunk = timm.create_model(tag, pretrained=True, in_chans=17, num_classes=0)
|
| 83 |
+
self.head = head(self.trunk.num_features)
|
| 84 |
+
|
| 85 |
+
def forward(self, x):
|
| 86 |
+
return self.head(self.trunk(x))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class ChanSet(nn.Module):
|
| 90 |
+
"""Channel-agnostic mIF encoder: each channel -> shared 1-ch ResNet18 (112px) -> token (+ channel embedding)
|
| 91 |
+
-> 2-layer transformer over channel tokens -> mean pool."""
|
| 92 |
+
def __init__(self, C=17, d=256):
|
| 93 |
+
super().__init__()
|
| 94 |
+
self.cnn = timm.create_model("resnet18.a1_in1k", pretrained=True, in_chans=1, num_classes=0)
|
| 95 |
+
self.proj = nn.Linear(512, d)
|
| 96 |
+
self.ch = nn.Parameter(torch.zeros(1, C, d)); nn.init.normal_(self.ch, std=0.02)
|
| 97 |
+
self.tf = nn.TransformerEncoder(nn.TransformerEncoderLayer(d, 4, 4 * d, 0.1, batch_first=True, norm_first=True), 2)
|
| 98 |
+
self.head = head(d)
|
| 99 |
+
|
| 100 |
+
def forward(self, x):
|
| 101 |
+
B, C, H, W = x.shape
|
| 102 |
+
x = F.interpolate(x, size=(112, 112), mode="bilinear", antialias=True)
|
| 103 |
+
t = self.proj(self.cnn(x.reshape(B * C, 1, 112, 112))).view(B, C, -1) + self.ch
|
| 104 |
+
return self.head(self.tf(t).mean(1))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class FTT(nn.Module):
|
| 108 |
+
"""FT-Transformer: feature tokenizer (value * w_j + b_j) + CLS, 3 layers."""
|
| 109 |
+
def __init__(self, F_in, d=128):
|
| 110 |
+
super().__init__()
|
| 111 |
+
self.w = nn.Parameter(torch.randn(F_in, d) * 0.02)
|
| 112 |
+
self.b = nn.Parameter(torch.zeros(F_in, d))
|
| 113 |
+
self.cls = nn.Parameter(torch.zeros(1, 1, d))
|
| 114 |
+
self.tf = nn.TransformerEncoder(nn.TransformerEncoderLayer(d, 8, 2 * d, 0.1, batch_first=True, norm_first=True), 3)
|
| 115 |
+
self.head = head(d)
|
| 116 |
+
|
| 117 |
+
def forward(self, x):
|
| 118 |
+
t = x.unsqueeze(-1) * self.w + self.b
|
| 119 |
+
t = torch.cat([self.cls.expand(len(x), -1, -1), t], 1)
|
| 120 |
+
return self.head(self.tf(t)[:, 0])
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def mlp(din, hid=512):
|
| 124 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 125 |
+
nn.LayerNorm(hid), nn.Linear(hid, D))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
enc = nn.ModuleDict({
|
| 129 |
+
"if": ChanSet() if args.if_enc == "chanset" else TimmEnc(args.if_enc),
|
| 130 |
+
"markers": {"linear": lambda: nn.Linear(MK.shape[1], D), "mlp": lambda: mlp(MK.shape[1]),
|
| 131 |
+
"ftt": lambda: FTT(MK.shape[1])}[args.mk_enc](),
|
| 132 |
+
"text": mlp(TX.shape[-1]), # frozen text tower features -> trainable projection
|
| 133 |
+
}).to(dev)
|
| 134 |
+
logit = nn.ParameterDict({m: nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32)) for m in TRAIN}).to(dev)
|
| 135 |
+
params = list(enc.parameters()) + list(logit.parameters())
|
| 136 |
+
opt = torch.optim.AdamW(params, lr=args.lr, weight_decay=0.05)
|
| 137 |
+
steps = args.epochs * (len(tr_idx) // args.bs)
|
| 138 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=steps, pct_start=0.05)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def embed(b):
|
| 142 |
+
z = {"he": F.normalize(b["he"], dim=-1)}
|
| 143 |
+
for m in TRAIN:
|
| 144 |
+
z[m] = F.normalize(enc[m](b[m]).float(), dim=-1)
|
| 145 |
+
return z
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@torch.no_grad()
|
| 149 |
+
def embed_all(idx):
|
| 150 |
+
enc.eval()
|
| 151 |
+
out = {m: [] for m in ["he"] + TRAIN}
|
| 152 |
+
for s in range(0, len(idx), 256):
|
| 153 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 154 |
+
z = embed(batch(idx[s:s + 256]))
|
| 155 |
+
for m in out:
|
| 156 |
+
out[m].append(z[m].float().cpu())
|
| 157 |
+
return {m: torch.cat(v) for m, v in out.items()}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def r1(a, b):
|
| 161 |
+
s = a @ b.T
|
| 162 |
+
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ---------------- train with per-modality model selection ----------------
|
| 166 |
+
best = {m: -1.0 for m in TRAIN}
|
| 167 |
+
best_ep = {m: -1 for m in TRAIN}
|
| 168 |
+
test_z = {}
|
| 169 |
+
hist = []
|
| 170 |
+
t0 = time.time()
|
| 171 |
+
for ep in range(args.epochs):
|
| 172 |
+
enc.train()
|
| 173 |
+
perm = np.random.permutation(tr_idx)
|
| 174 |
+
tl = {m: [] for m in TRAIN}
|
| 175 |
+
for s in range(0, len(perm) - args.bs + 1, args.bs):
|
| 176 |
+
b = batch(perm[s:s + args.bs], train=True)
|
| 177 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 178 |
+
z = embed(b)
|
| 179 |
+
tgt = torch.arange(args.bs, device=dev)
|
| 180 |
+
loss = 0
|
| 181 |
+
for m in TRAIN:
|
| 182 |
+
lg = logit[m].exp().clamp(max=100) * z["he"] @ z[m].T
|
| 183 |
+
l = (F.cross_entropy(lg, tgt) + F.cross_entropy(lg.T, tgt)) / 2
|
| 184 |
+
loss = loss + l
|
| 185 |
+
tl[m].append(l.item())
|
| 186 |
+
opt.zero_grad(set_to_none=True)
|
| 187 |
+
loss.backward()
|
| 188 |
+
opt.step(); sched.step()
|
| 189 |
+
zv = embed_all(va_idx)
|
| 190 |
+
rec = {"epoch": ep}
|
| 191 |
+
improved = []
|
| 192 |
+
for m in TRAIN:
|
| 193 |
+
v = (r1(zv["he"], zv[m]) + r1(zv[m], zv["he"])) / 2
|
| 194 |
+
rec[f"loss_{m}"], rec[f"val_r1_{m}"] = float(np.mean(tl[m])), v
|
| 195 |
+
if v > best[m]:
|
| 196 |
+
best[m], best_ep[m] = v, ep
|
| 197 |
+
improved.append(m)
|
| 198 |
+
if improved:
|
| 199 |
+
zt = embed_all(te_idx)
|
| 200 |
+
test_z["he"] = zt["he"]
|
| 201 |
+
for m in improved:
|
| 202 |
+
test_z[m] = zt[m]
|
| 203 |
+
hist.append(rec)
|
| 204 |
+
print(f"ep {ep} " + " ".join(f"{m}:{rec[f'val_r1_{m}']:.3f}" for m in TRAIN) + f" ({time.time() - t0:.0f}s)", flush=True)
|
| 205 |
+
|
| 206 |
+
torch.save({"z": test_z, "tile_id": te_idx}, os.path.join(run_dir, "test_embeddings.pt"))
|
| 207 |
+
json.dump({"args": vars(args), "best_val": best, "best_epoch": best_ep, "n_train": len(tr_idx), "n_test": len(te_idx),
|
| 208 |
+
"time_s": time.time() - t0, "history": hist}, open(os.path.join(run_dir, "train.json"), "w"), indent=1)
|
| 209 |
+
print("done", best, best_ep)
|
experiments/10_bench_summary.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Summarise the encoder benchmark (runs/bench/*/test_embeddings.pt).
|
| 2 |
+
|
| 3 |
+
- per-modality encoder: he<->m retrieval (trained pair)
|
| 4 |
+
- cross-modal (never trained together): if<->markers, if<->text, markers<->text for every encoder combination
|
| 5 |
+
- zero-shot baselines (no training): PLIP image<->PLIP text, ImageBind image<->ImageBind text
|
| 6 |
+
Metrics are tie-aware (duplicate template texts): expected rank under random tie-breaking.
|
| 7 |
+
Outputs results/bench_*.csv
|
| 8 |
+
"""
|
| 9 |
+
import glob
|
| 10 |
+
import itertools
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
|
| 19 |
+
from common import CACHE, ROOT, RUNS, metrics
|
| 20 |
+
|
| 21 |
+
OUT = os.path.join(ROOT, "results")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
runs = {}
|
| 25 |
+
for f in glob.glob(os.path.join(RUNS, "bench", "*", "test_embeddings.pt")):
|
| 26 |
+
a = json.load(open(os.path.join(os.path.dirname(f), "train.json")))["args"]
|
| 27 |
+
runs[(a["split"], a["fold"], a["if_enc"], a["mk_enc"], a["txt_enc"])] = torch.load(f, weights_only=False) # our own files (contain numpy tile ids)
|
| 28 |
+
|
| 29 |
+
rows = []
|
| 30 |
+
folds = sorted({k[:2] for k in runs})
|
| 31 |
+
for split, fold in folds:
|
| 32 |
+
rs = {k[2:]: v for k, v in runs.items() if k[:2] == (split, fold)}
|
| 33 |
+
he = next(iter(rs.values()))["z"]["he"]
|
| 34 |
+
te = next(iter(rs.values()))["tile_id"]
|
| 35 |
+
by = {"if": {}, "markers": {}, "text": {}}
|
| 36 |
+
for (ie, me, txe), v in rs.items():
|
| 37 |
+
by["if"][ie], by["markers"][me], by["text"][txe] = v["z"]["if"], v["z"]["markers"], v["z"]["text"]
|
| 38 |
+
# trained pairs
|
| 39 |
+
for m, encs in by.items():
|
| 40 |
+
for e, z in encs.items():
|
| 41 |
+
for q, g, name in [(he, z, f"he->{m}"), (z, he, f"{m}->he")]:
|
| 42 |
+
rows.append({"split": split, "fold": fold, "kind": "trained", "pair": name, "enc": e, **metrics(q, g)})
|
| 43 |
+
# cross pairs: all combinations
|
| 44 |
+
for m1, m2 in [("if", "markers"), ("if", "text"), ("markers", "text")]:
|
| 45 |
+
for e1, e2 in itertools.product(by[m1], by[m2]):
|
| 46 |
+
for q, g, name in [(by[m1][e1], by[m2][e2], f"{m1}->{m2}"), (by[m2][e2], by[m1][e1], f"{m2}->{m1}")]:
|
| 47 |
+
rows.append({"split": split, "fold": fold, "kind": "cross", "pair": name, "enc": f"{e1}+{e2}", **metrics(q, g)})
|
| 48 |
+
# zero-shot baselines (variant-0 text, untransformed H&E view)
|
| 49 |
+
for fm in ["plip", "imagebind"]:
|
| 50 |
+
hz = F.normalize(torch.from_numpy(np.load(os.path.join(CACHE, f"he_{fm}.npy"))[te, 0].astype(np.float32)), dim=-1)
|
| 51 |
+
tz = F.normalize(torch.from_numpy(np.load(os.path.join(CACHE, f"text_{fm}.npy"))[te, 0].astype(np.float32)), dim=-1)
|
| 52 |
+
for q, g, name in [(hz, tz, "he->text"), (tz, hz, "text->he")]:
|
| 53 |
+
rows.append({"split": split, "fold": fold, "kind": "zeroshot", "pair": name, "enc": f"{fm} (zero-shot)", **metrics(q, g)})
|
| 54 |
+
|
| 55 |
+
df = pd.DataFrame(rows)
|
| 56 |
+
df.to_csv(os.path.join(OUT, "bench_long.csv"), index=False)
|
| 57 |
+
met = ["R@1", "R@5", "MRR", "pair_acc", "R@1_g100"]
|
| 58 |
+
grp = df.assign(split=df.split.replace({"patient_5fold": "patient_5fold"}))
|
| 59 |
+
agg = grp.groupby(["kind", "pair", "enc", "split"])[met].agg(["mean", "std"]).round(3)
|
| 60 |
+
agg.to_csv(os.path.join(OUT, "bench_summary.csv"))
|
| 61 |
+
pd.set_option("display.width", 250); pd.set_option("display.max_rows", 500)
|
| 62 |
+
show = grp.groupby(["kind", "pair", "enc", "split"])[["R@1_g100", "pair_acc"]].mean().unstack("split").round(3)
|
| 63 |
+
print(show.to_string())
|
experiments/11_pathgen_prep.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PathGen-1.6M H&E-caption pairs (real text) -> cache/pathgen_meta.csv with a slide-level 80/10/10 split.
|
| 2 |
+
Used to bind the text modality to the H&E anchor (ImageBind: text only needs to be paired with images)."""
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
from common import CACHE, ROOT
|
| 10 |
+
|
| 11 |
+
PG = os.path.join(ROOT, "..", "PathGen-1.6M")
|
| 12 |
+
d = json.load(open(os.path.join(PG, "Data", "PathGen-1.6M.json")))
|
| 13 |
+
m = pd.DataFrame([{"wsi_id": x["wsi_id"], "caption": x["caption"],
|
| 14 |
+
"image_path": os.path.join(PG, "Data", "images", f"{x['wsi_id']}_{x['position'][0]}_{x['position'][1]}.png")}
|
| 15 |
+
for x in d])
|
| 16 |
+
assert m.image_path.map(os.path.exists).all()
|
| 17 |
+
m["patient"] = m.wsi_id.str[:12] # TCGA-XX-XXXX: keep a patient's slides together
|
| 18 |
+
pats = np.random.default_rng(0).permutation(sorted(m.patient.unique()))
|
| 19 |
+
n = len(pats)
|
| 20 |
+
role = {p: "train" if i < 0.8 * n else "val" if i < 0.9 * n else "test" for i, p in enumerate(pats)}
|
| 21 |
+
m["split"] = m.patient.map(role)
|
| 22 |
+
m["project_code"] = m.wsi_id.str[5:7]
|
| 23 |
+
m.to_csv(os.path.join(CACHE, "pathgen_meta.csv"), index_label="idx")
|
| 24 |
+
print(m.split.value_counts().to_dict(), "patients:", n)
|
experiments/12_train_text_pathgen.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bind real pathology text to the frozen H&E anchor using PathGen-1.6M H&E-caption pairs (ImageBind recipe:
|
| 2 |
+
text is paired only with images; it never sees ORION, IF or markers).
|
| 3 |
+
|
| 4 |
+
frozen text tower features (cache/pathgen_text_<enc>.npy) -> trainable MLP head -> anchor space
|
| 5 |
+
loss: symmetric InfoNCE vs frozen anchor image features (cache/pathgen_he_<anchor>.npy, 8 dihedral views)
|
| 6 |
+
Usage: python 12_train_text_pathgen.py --txt_enc plip|biomedclip|imagebind [--anchor uni2]
|
| 7 |
+
Saves runs/text_pathgen/<anchor>_<enc>/{head.pt, metrics.json}
|
| 8 |
+
"""
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
|
| 19 |
+
from common import CACHE, RUNS
|
| 20 |
+
|
| 21 |
+
p = argparse.ArgumentParser()
|
| 22 |
+
p.add_argument("--txt_enc", default="plip")
|
| 23 |
+
p.add_argument("--anchor", default="uni2")
|
| 24 |
+
p.add_argument("--epochs", type=int, default=100)
|
| 25 |
+
p.add_argument("--bs", type=int, default=256)
|
| 26 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 27 |
+
p.add_argument("--seed", type=int, default=0)
|
| 28 |
+
args = p.parse_args()
|
| 29 |
+
torch.manual_seed(args.seed); np.random.seed(args.seed)
|
| 30 |
+
dev = "cuda"
|
| 31 |
+
out_dir = os.path.join(RUNS, "text_pathgen", f"{args.anchor}_{args.txt_enc}")
|
| 32 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
meta = pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv"))
|
| 35 |
+
HE = torch.from_numpy(np.load(os.path.join(CACHE, f"pathgen_he_{args.anchor}.npy")).astype(np.float32))
|
| 36 |
+
TX = torch.from_numpy(np.load(os.path.join(CACHE, f"pathgen_text_{args.txt_enc}.npy"))[:, 0].astype(np.float32))
|
| 37 |
+
D = HE.shape[-1]
|
| 38 |
+
idx = {s: meta.idx[meta.split == s].values for s in ["train", "val", "test"]}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def make_head(din, dout, hid=512): # same architecture as the ORION benchmark text head
|
| 42 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 43 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
head = make_head(TX.shape[1], D).to(dev)
|
| 47 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 48 |
+
opt = torch.optim.AdamW(list(head.parameters()) + [logit], lr=args.lr, weight_decay=0.05)
|
| 49 |
+
steps = args.epochs * max(1, len(idx["train"]) // args.bs)
|
| 50 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=steps, pct_start=0.05)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@torch.no_grad()
|
| 54 |
+
def retrieval(ids, size=None, reps=50):
|
| 55 |
+
head.eval()
|
| 56 |
+
zi = F.normalize(HE[ids, 0], dim=-1)
|
| 57 |
+
zt = F.normalize(head(TX[ids].to(dev)).float().cpu(), dim=-1)
|
| 58 |
+
|
| 59 |
+
def r1(a, b):
|
| 60 |
+
s = a @ b.T
|
| 61 |
+
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
|
| 62 |
+
|
| 63 |
+
if size is None or len(ids) <= size:
|
| 64 |
+
return {"he->text": r1(zi, zt), "text->he": r1(zt, zi), "N": len(ids)}
|
| 65 |
+
rng = np.random.default_rng(0)
|
| 66 |
+
a, b = [], []
|
| 67 |
+
for _ in range(reps):
|
| 68 |
+
s = torch.from_numpy(rng.choice(len(ids), size, replace=False))
|
| 69 |
+
a.append(r1(zi[s], zt[s])); b.append(r1(zt[s], zi[s]))
|
| 70 |
+
return {"he->text": float(np.mean(a)), "text->he": float(np.mean(b)), "N": size}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
best, hist = -1, []
|
| 74 |
+
for ep in range(args.epochs):
|
| 75 |
+
head.train()
|
| 76 |
+
perm = np.random.permutation(idx["train"])
|
| 77 |
+
for s in range(0, len(perm) - args.bs + 1, args.bs):
|
| 78 |
+
b = perm[s:s + args.bs]
|
| 79 |
+
zi = F.normalize(HE[b, torch.randint(0, 8, (len(b),))].to(dev), dim=-1)
|
| 80 |
+
zt = F.normalize(head(TX[b].to(dev)), dim=-1)
|
| 81 |
+
lg = logit.exp().clamp(max=100) * zi @ zt.T
|
| 82 |
+
tgt = torch.arange(len(b), device=dev)
|
| 83 |
+
loss = (F.cross_entropy(lg, tgt) + F.cross_entropy(lg.T, tgt)) / 2
|
| 84 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 85 |
+
v = retrieval(idx["val"])
|
| 86 |
+
vm = (v["he->text"] + v["text->he"]) / 2
|
| 87 |
+
hist.append({"epoch": ep, "loss": loss.item(), "val_r1": vm})
|
| 88 |
+
if vm > best:
|
| 89 |
+
best = vm
|
| 90 |
+
torch.save(head.state_dict(), os.path.join(out_dir, "head.pt"))
|
| 91 |
+
|
| 92 |
+
head.load_state_dict(torch.load(os.path.join(out_dir, "head.pt")))
|
| 93 |
+
res = {"args": vars(args), "best_val_r1": best, "test_full": retrieval(idx["test"]),
|
| 94 |
+
"test_g100": retrieval(idx["test"], size=100), "n": {k: len(v) for k, v in idx.items()}, "history": hist}
|
| 95 |
+
# zero-shot baseline on the same test pairs: the text tower's own image tower, no training
|
| 96 |
+
zs_img = os.path.join(CACHE, f"pathgen_he_{args.txt_enc}.npy")
|
| 97 |
+
if os.path.exists(zs_img):
|
| 98 |
+
zi = F.normalize(torch.from_numpy(np.load(zs_img)[idx["test"], 0].astype(np.float32)), dim=-1)
|
| 99 |
+
zt = F.normalize(TX[idx["test"]], dim=-1)
|
| 100 |
+
s = zi @ zt.T
|
| 101 |
+
res["zeroshot_test_full"] = {"he->text": float(((s > s.diag()[:, None]).sum(1) == 0).float().mean()),
|
| 102 |
+
"text->he": float(((s.T > s.diag()[:, None]).sum(1) == 0).float().mean())}
|
| 103 |
+
json.dump(res, open(os.path.join(out_dir, "metrics.json"), "w"), indent=1)
|
| 104 |
+
print(args.txt_enc, "val", round(best, 3), "test full", res["test_full"], "g100", res["test_g100"])
|
experiments/13_eval_text_emergent.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Emergent text alignment on ORION. The text head was trained ONLY on PathGen H&E-caption pairs (12_), the IF /
|
| 2 |
+
marker encoders ONLY on ORION H&E pairs (09_, --if_enc convnext_tiny --mk_enc mlp). Text and IF/markers never
|
| 3 |
+
co-occur in training; any text<->IF / text<->markers agreement is emergent through the shared UNI2 H&E space.
|
| 4 |
+
|
| 5 |
+
(A) concept probes: prompts.json (PathGen-style sentences) scored against every ORION test tile embedding
|
| 6 |
+
(he / if / markers); ground truth = marker-positive fraction (or nuclei count). Metrics: Spearman rho and
|
| 7 |
+
AUROC (top quartile vs rest), on tissue tiles (>=20 nuclei; all tiles for 'cellularity').
|
| 8 |
+
Baselines: zero-shot PLIP and ImageBind (their own image/text towers, H&E only, no training).
|
| 9 |
+
(B) retrieval with templated ORION descriptions (07_) as text queries through the PathGen-trained head
|
| 10 |
+
(domain-shifted: template style != PathGen captions), tie-aware R@1 on 100-tile galleries.
|
| 11 |
+
Outputs results/text_emergent_probes.csv, results/text_emergent_retrieval.csv
|
| 12 |
+
"""
|
| 13 |
+
import glob
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn.functional as F
|
| 21 |
+
from scipy.stats import spearmanr
|
| 22 |
+
from sklearn.metrics import roc_auc_score
|
| 23 |
+
|
| 24 |
+
from common import CACHE, ROOT, RUNS, load_all_tiles, metrics
|
| 25 |
+
|
| 26 |
+
TXT = ["plip", "biomedclip", "imagebind"]
|
| 27 |
+
OUT = os.path.join(ROOT, "results")
|
| 28 |
+
prompts = json.load(open(os.path.join(ROOT, "prompts.json")))
|
| 29 |
+
df = load_all_tiles()
|
| 30 |
+
stats = pd.read_csv(os.path.join(OUT, "tile_stats.csv")).set_index("tile_id")
|
| 31 |
+
df["n_nuclei"] = stats.loc[df.tile_id, "n_nuclei"].values
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_head(enc, din, dout):
|
| 35 |
+
h = torch.nn.Sequential(torch.nn.Linear(din, 512), torch.nn.GELU(), torch.nn.Dropout(0.1), torch.nn.Linear(512, 512),
|
| 36 |
+
torch.nn.GELU(), torch.nn.LayerNorm(512), torch.nn.Linear(512, dout))
|
| 37 |
+
h.load_state_dict(torch.load(os.path.join(RUNS, "text_pathgen", f"uni2_{enc}", "head.pt"), map_location="cpu"))
|
| 38 |
+
return h.eval()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def probe(scores, tiles, key, label_col):
|
| 42 |
+
sub = tiles if key == "cellularity" else tiles[df.loc[tiles, "n_nuclei"].values >= 20]
|
| 43 |
+
pos = np.isin(tiles, sub)
|
| 44 |
+
s = scores[pos]
|
| 45 |
+
y = df.loc[sub, label_col].values.astype(float)
|
| 46 |
+
if label_col != "n_nuclei":
|
| 47 |
+
y = y / np.maximum(df.loc[sub, "n_nuclei"].values, 1)
|
| 48 |
+
lab = y >= np.quantile(y, 0.75)
|
| 49 |
+
return {"spearman": float(spearmanr(s, y).correlation),
|
| 50 |
+
"auroc": float(roc_auc_score(lab, s)) if 0 < lab.sum() < len(lab) else np.nan, "n": int(len(sub))}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
probe_rows, ret_rows = [], []
|
| 54 |
+
for f in sorted(glob.glob(os.path.join(RUNS, "bench", "*_uni2_convnext_tiny_mlp_*", "test_embeddings.pt"))):
|
| 55 |
+
run = os.path.basename(os.path.dirname(f))
|
| 56 |
+
split, fold = run.split("_fold")[0], int(run.split("_fold")[1].split("_")[0])
|
| 57 |
+
E = torch.load(f, weights_only=False)
|
| 58 |
+
tiles, Z = np.asarray(E["tile_id"]), E["z"]
|
| 59 |
+
for enc in TXT:
|
| 60 |
+
prm = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{enc}.npy"))[:, 0].astype(np.float32))
|
| 61 |
+
orion_txt = torch.from_numpy(np.load(os.path.join(CACHE, f"text_{enc}.npy"))[tiles, 0].astype(np.float32))
|
| 62 |
+
head = load_head(enc, prm.shape[1], Z["he"].shape[1])
|
| 63 |
+
with torch.no_grad():
|
| 64 |
+
zp = F.normalize(head(prm), dim=-1)
|
| 65 |
+
zt = F.normalize(head(orion_txt), dim=-1)
|
| 66 |
+
for i, (key, pr) in enumerate(prompts.items()):
|
| 67 |
+
for m in ["he", "if", "markers"]:
|
| 68 |
+
sc = (Z[m] @ zp[i]).numpy()
|
| 69 |
+
probe_rows.append({"split": split, "fold": fold, "text_enc": enc, "concept": key, "modality": m,
|
| 70 |
+
"kind": "bound (PathGen text head)", **probe(sc, tiles, key, pr["label"])})
|
| 71 |
+
for m in ["he", "if", "markers"]:
|
| 72 |
+
for q, g, name in [(zt, Z[m], f"text->{m}"), (Z[m], zt, f"{m}->text")]:
|
| 73 |
+
ret_rows.append({"split": split, "fold": fold, "text_enc": enc, "pair": name, **metrics(q, g)})
|
| 74 |
+
# zero-shot baselines on H&E with the model's own towers (computed once per fold)
|
| 75 |
+
for fm in ["plip", "imagebind"]:
|
| 76 |
+
hz = F.normalize(torch.from_numpy(np.load(os.path.join(CACHE, f"he_{fm}.npy"))[tiles, 0].astype(np.float32)), dim=-1)
|
| 77 |
+
pz = F.normalize(torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{fm}.npy"))[:, 0].astype(np.float32)), dim=-1)
|
| 78 |
+
for i, (key, pr) in enumerate(prompts.items()):
|
| 79 |
+
probe_rows.append({"split": split, "fold": fold, "text_enc": fm, "concept": key, "modality": "he",
|
| 80 |
+
"kind": "zero-shot (own image tower)", **probe((hz @ pz[i]).numpy(), tiles, key, pr["label"])})
|
| 81 |
+
|
| 82 |
+
P = pd.DataFrame(probe_rows); R = pd.DataFrame(ret_rows)
|
| 83 |
+
P.to_csv(os.path.join(OUT, "text_emergent_probes.csv"), index=False)
|
| 84 |
+
R.to_csv(os.path.join(OUT, "text_emergent_retrieval.csv"), index=False)
|
| 85 |
+
pd.set_option("display.width", 250); pd.set_option("display.max_rows", 300)
|
| 86 |
+
print(P.groupby(["kind", "text_enc", "modality", "split"])[["auroc", "spearman"]].mean().round(3).unstack("split").to_string())
|
| 87 |
+
print(P.groupby(["kind", "text_enc", "modality", "concept"])["auroc"].mean().unstack("concept").round(2).to_string())
|
| 88 |
+
print(R.groupby(["text_enc", "pair", "split"])["R@1_g100"].mean().unstack("split").round(3).to_string())
|
experiments/15_extract_marker_fm.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen single-cell foundation-model embeddings of the per-tile marker vector.
|
| 2 |
+
|
| 3 |
+
python 15_extract_marker_fm.py geneformer [--value prop|count] [--pool mean|cls]
|
| 4 |
+
python 15_extract_marker_fm.py scgpt [--value prop|count]
|
| 5 |
+
|
| 6 |
+
Each ORION tile is treated as one pseudo-cell: the "expression" of each mapped gene is that
|
| 7 |
+
marker's positive-cell fraction (default, `*_prop`) or positive-cell count (`--value count`) in
|
| 8 |
+
the tile; every other gene is unexpressed. Both models only see the *within-cell ordering* of
|
| 9 |
+
expressed genes (Geneformer: rank-value encoding; scGPT: per-cell quantile binning), so global
|
| 10 |
+
scale (how many cells / how much signal the tile has) is not representable — only composition.
|
| 11 |
+
|
| 12 |
+
Marker -> gene (protein panel -> HGNC symbol -> Ensembl via Geneformer gc104M dictionary):
|
| 13 |
+
CD31->PECAM1, CD45->PTPRC, CD45RO->PTPRC (isoform of the same gene: merged, value=max(CD45,CD45RO)),
|
| 14 |
+
CD68->CD68, CD4->CD4, FOXP3->FOXP3, CD8a->CD8A, CD20->MS4A1, PD-L1->CD274, CD3e->CD3E,
|
| 15 |
+
CD163->CD163, E-cadherin->CDH1, PD-1->PDCD1, Ki67->MKI67, Pan-CK->KRT8+KRT18+KRT19 (same value
|
| 16 |
+
for all three; the pan-cytokeratin antibody cocktail targets these simple-epithelium keratins),
|
| 17 |
+
SMA->ACTA2. Hoechst (DNA stain) has no gene -> dropped.
|
| 18 |
+
PD-1_prop is degenerate in the ORION dataframes (>=0.998 in every tile, Spearman 0.02 with
|
| 19 |
+
PD-1_count), so with --value prop the PDCD1 value is PD-1_count / n_nuclei (results/tile_stats.csv),
|
| 20 |
+
clipped to [0, 1].
|
| 21 |
+
|
| 22 |
+
Outputs cache/markers_<name>.npy (3187, 1, D) float16, row order = tile_id
|
| 23 |
+
cache/markers_<name>_meta.json (mapping, choices, sanity numbers)
|
| 24 |
+
<name> = geneformer | scgpt, with "-count" / "-cls" suffixes for the non-default options.
|
| 25 |
+
"""
|
| 26 |
+
import argparse
|
| 27 |
+
import glob
|
| 28 |
+
import json
|
| 29 |
+
import os
|
| 30 |
+
import pickle
|
| 31 |
+
import time
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
import pandas as pd
|
| 35 |
+
import torch
|
| 36 |
+
import torch.nn as nn
|
| 37 |
+
|
| 38 |
+
from common import CACHE, ROOT, load_all_tiles, marker_columns, marker_matrix
|
| 39 |
+
|
| 40 |
+
MARKER2GENES = {
|
| 41 |
+
"CD31": ["PECAM1"], "CD45": ["PTPRC"], "CD45RO": ["PTPRC"], "CD68": ["CD68"], "CD4": ["CD4"],
|
| 42 |
+
"FOXP3": ["FOXP3"], "CD8a": ["CD8A"], "CD20": ["MS4A1"], "PD-L1": ["CD274"], "CD3e": ["CD3E"],
|
| 43 |
+
"CD163": ["CD163"], "E-cadherin": ["CDH1"], "PD-1": ["PDCD1"], "Ki67": ["MKI67"],
|
| 44 |
+
"Pan-CK": ["KRT8", "KRT18", "KRT19"], "SMA": ["ACTA2"],
|
| 45 |
+
}
|
| 46 |
+
DROPPED = {"Hoechst": "nuclear DNA stain, no gene product"}
|
| 47 |
+
GF_REPO, GF_REV = "ctheodoris/Geneformer", None
|
| 48 |
+
SCGPT_REPO = "wanglab/scGPT-human" # official bowang-lab whole-human pretrained checkpoint + vocab
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def gene_expression(df, value):
|
| 52 |
+
"""(N, G) pseudo-cell expression over the mapped genes (unique, ordered)."""
|
| 53 |
+
genes = list(dict.fromkeys(g for gs in MARKER2GENES.values() for g in gs))
|
| 54 |
+
X = pd.DataFrame(0.0, index=df.index, columns=genes)
|
| 55 |
+
notes = {}
|
| 56 |
+
for m, gs in MARKER2GENES.items():
|
| 57 |
+
if value == "prop":
|
| 58 |
+
if m == "PD-1":
|
| 59 |
+
ts = pd.read_csv(os.path.join(ROOT, "results", "tile_stats.csv"), index_col="tile_id")
|
| 60 |
+
nn_ = ts.loc[df.tile_id, "n_nuclei"].values.astype(float)
|
| 61 |
+
v = np.clip(df["PD-1_count"].values / np.maximum(nn_, 1.0), 0, 1)
|
| 62 |
+
notes["PD-1"] = "PD-1_count / max(n_nuclei,1), clipped to [0,1] (PD-1_prop degenerate ~1.0)"
|
| 63 |
+
else:
|
| 64 |
+
v = df[f"{m}_prop"].values.astype(float)
|
| 65 |
+
else:
|
| 66 |
+
v = df[f"{m}_count"].values.astype(float)
|
| 67 |
+
for g in gs:
|
| 68 |
+
X[g] = np.maximum(X[g].values, v) # PTPRC = max(CD45, CD45RO)
|
| 69 |
+
return X, notes
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ----------------------------------------------------------------------------- Geneformer
|
| 73 |
+
def hf_file(repo, f):
|
| 74 |
+
from huggingface_hub import hf_hub_download
|
| 75 |
+
return hf_hub_download(repo, f)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def run_geneformer(X, pool, device, bs=256):
|
| 79 |
+
from transformers import BertModel
|
| 80 |
+
tokd = pickle.load(open(hf_file(GF_REPO, "geneformer/token_dictionary_gc104M.pkl"), "rb"))
|
| 81 |
+
medd = pickle.load(open(hf_file(GF_REPO, "geneformer/gene_median_dictionary_gc104M.pkl"), "rb"))
|
| 82 |
+
n2id = pickle.load(open(hf_file(GF_REPO, "geneformer/gene_name_id_dict_gc104M.pkl"), "rb"))
|
| 83 |
+
mdir = os.path.dirname(hf_file(GF_REPO, "Geneformer-V2-104M/model.safetensors"))
|
| 84 |
+
hf_file(GF_REPO, "Geneformer-V2-104M/config.json")
|
| 85 |
+
ens = [n2id[g] for g in X.columns]
|
| 86 |
+
tok = np.array([tokd[e] for e in ens])
|
| 87 |
+
med = np.array([medd[e] for e in ens])
|
| 88 |
+
CLS, EOS, PAD = tokd["<cls>"], tokd["<eos>"], tokd["<pad>"]
|
| 89 |
+
|
| 90 |
+
model, info = BertModel.from_pretrained(mdir, output_loading_info=True, add_pooling_layer=False)
|
| 91 |
+
model.eval().to(device)
|
| 92 |
+
# Geneformer tokenizer: X / n_counts * 1e4 / gene_median, rank descending, drop zeros.
|
| 93 |
+
# The per-cell n_counts factor does not change the within-cell order, so it is omitted.
|
| 94 |
+
V = X.values / med[None]
|
| 95 |
+
seqs = []
|
| 96 |
+
for row in V:
|
| 97 |
+
nz = np.nonzero(row > 0)[0]
|
| 98 |
+
order = nz[np.argsort(-row[nz], kind="stable")]
|
| 99 |
+
seqs.append([CLS] + tok[order].tolist() + [EOS])
|
| 100 |
+
L = max(map(len, seqs))
|
| 101 |
+
embs = []
|
| 102 |
+
with torch.no_grad():
|
| 103 |
+
for i in range(0, len(seqs), bs):
|
| 104 |
+
b = seqs[i:i + bs]
|
| 105 |
+
ids = torch.full((len(b), L), PAD, dtype=torch.long)
|
| 106 |
+
for j, s in enumerate(b):
|
| 107 |
+
ids[j, :len(s)] = torch.tensor(s)
|
| 108 |
+
ids = ids.to(device)
|
| 109 |
+
att = (ids != PAD).long()
|
| 110 |
+
hs = model(input_ids=ids, attention_mask=att, output_hidden_states=True).hidden_states
|
| 111 |
+
h = hs[-2].float() # penultimate layer (Geneformer EmbExtractor default emb_layer=-1)
|
| 112 |
+
if pool == "cls":
|
| 113 |
+
e = h[:, 0]
|
| 114 |
+
else: # mean over gene tokens (exclude <cls>, <eos>, pads)
|
| 115 |
+
gm = att.clone().float()
|
| 116 |
+
gm[:, 0] = 0
|
| 117 |
+
lens = att.sum(1)
|
| 118 |
+
gm[torch.arange(len(b)), lens - 1] = 0
|
| 119 |
+
empty = gm.sum(1) == 0 # no expressed gene -> mean over <cls>,<eos>
|
| 120 |
+
gm[empty] = att[empty].float()
|
| 121 |
+
e = (h * gm[..., None]).sum(1) / gm.sum(1, keepdim=True)
|
| 122 |
+
embs.append(e.cpu())
|
| 123 |
+
E = torch.cat(embs).numpy()
|
| 124 |
+
n_empty = int(sum(len(s) == 2 for s in seqs))
|
| 125 |
+
meta = {"model": f"{GF_REPO}/Geneformer-V2-104M", "hidden": int(E.shape[1]),
|
| 126 |
+
"missing_keys": info["missing_keys"], "unexpected_keys": info["unexpected_keys"],
|
| 127 |
+
"ensembl": dict(zip(X.columns, ens)), "token_ids": dict(zip(X.columns, tok.tolist())),
|
| 128 |
+
"gene_median_gc104M": dict(zip(X.columns, med.round(4).tolist())),
|
| 129 |
+
"input": "<cls> + genes ranked by value/gene_median (desc, nonzero only) + <eos>",
|
| 130 |
+
"layer": "penultimate (hidden_states[-2])",
|
| 131 |
+
"pool": "CLS token" if pool == "cls" else "mean over gene tokens (excl. <cls>/<eos>/pad); "
|
| 132 |
+
"tiles with no expressed gene: mean over <cls>,<eos>",
|
| 133 |
+
"n_tiles_no_expressed_gene": n_empty,
|
| 134 |
+
"seq_len_mean": float(np.mean([len(s) - 2 for s in seqs]))}
|
| 135 |
+
return E, meta
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ----------------------------------------------------------------------------- scGPT
|
| 139 |
+
class GeneEncoder(nn.Module):
|
| 140 |
+
def __init__(self, n, d, pad):
|
| 141 |
+
super().__init__()
|
| 142 |
+
self.embedding = nn.Embedding(n, d, padding_idx=pad)
|
| 143 |
+
self.enc_norm = nn.LayerNorm(d)
|
| 144 |
+
|
| 145 |
+
def forward(self, x):
|
| 146 |
+
return self.enc_norm(self.embedding(x))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class ContinuousValueEncoder(nn.Module):
|
| 150 |
+
def __init__(self, d, max_value=512):
|
| 151 |
+
super().__init__()
|
| 152 |
+
self.linear1, self.linear2, self.norm = nn.Linear(1, d), nn.Linear(d, d), nn.LayerNorm(d)
|
| 153 |
+
self.max_value = max_value
|
| 154 |
+
|
| 155 |
+
def forward(self, x):
|
| 156 |
+
x = torch.clamp(x.unsqueeze(-1), max=self.max_value)
|
| 157 |
+
return self.norm(self.linear2(torch.relu(self.linear1(x))))
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class ExprDecoder(nn.Module):
|
| 161 |
+
def __init__(self, d):
|
| 162 |
+
super().__init__()
|
| 163 |
+
self.fc = nn.Sequential(nn.Linear(d, d), nn.LeakyReLU(), nn.Linear(d, d), nn.LeakyReLU(), nn.Linear(d, 1))
|
| 164 |
+
|
| 165 |
+
def forward(self, x):
|
| 166 |
+
return self.fc(x).squeeze(-1)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class MVCDecoder(nn.Module):
|
| 170 |
+
def __init__(self, d):
|
| 171 |
+
super().__init__()
|
| 172 |
+
self.gene2query, self.W = nn.Linear(d, d), nn.Linear(d, d, bias=False)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class ScGPT(nn.Module):
|
| 176 |
+
"""Re-implementation of scgpt.model.TransformerModel for the whole-human checkpoint
|
| 177 |
+
(continuous value encoder, post-norm encoder, no DSBN/batch tokens); the flash-attn packed
|
| 178 |
+
Wqkv weights map 1:1 onto nn.MultiheadAttention.in_proj_*."""
|
| 179 |
+
|
| 180 |
+
def __init__(self, ntoken, d=512, nhead=8, d_hid=512, nlayers=12, pad=0):
|
| 181 |
+
super().__init__()
|
| 182 |
+
self.encoder = GeneEncoder(ntoken, d, pad)
|
| 183 |
+
self.flag_encoder = nn.Embedding(2, d)
|
| 184 |
+
self.value_encoder = ContinuousValueEncoder(d)
|
| 185 |
+
layer = nn.TransformerEncoderLayer(d, nhead, d_hid, dropout=0.0, batch_first=True, norm_first=False)
|
| 186 |
+
self.transformer_encoder = nn.TransformerEncoder(layer, nlayers, enable_nested_tensor=False)
|
| 187 |
+
self.decoder = ExprDecoder(d)
|
| 188 |
+
self.mvc_decoder = MVCDecoder(d)
|
| 189 |
+
|
| 190 |
+
def encode(self, genes, values, pad_mask):
|
| 191 |
+
x = self.encoder(genes) + self.value_encoder(values)
|
| 192 |
+
return self.transformer_encoder(x, src_key_padding_mask=pad_mask)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def scgpt_binning(row, n_bins, rng):
|
| 196 |
+
"""scgpt.preprocess.binning for a row of strictly positive values (random tie-splitting _digitize)."""
|
| 197 |
+
bins = np.quantile(row, np.linspace(0, 1, n_bins - 1))
|
| 198 |
+
left, right = np.digitize(row, bins), np.digitize(row, bins, right=True)
|
| 199 |
+
return np.ceil(rng.random(len(row)) * (right - left) + left).astype(np.int64)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def run_scgpt(X, device, bs=256, seed=0):
|
| 203 |
+
from huggingface_hub import hf_hub_download
|
| 204 |
+
args = json.load(open(hf_hub_download(SCGPT_REPO, "args.json")))
|
| 205 |
+
vocab = json.load(open(hf_hub_download(SCGPT_REPO, "vocab.json")))
|
| 206 |
+
sd = torch.load(hf_hub_download(SCGPT_REPO, "best_model.pt"), map_location="cpu", weights_only=True)
|
| 207 |
+
sd = {k.replace("self_attn.Wqkv.", "self_attn.in_proj_"): v for k, v in sd.items()}
|
| 208 |
+
PAD, CLS = vocab[args["pad_token"]], vocab["<cls>"]
|
| 209 |
+
model = ScGPT(len(vocab), args["embsize"], args["nheads"], args["d_hid"], args["nlayers"], PAD)
|
| 210 |
+
model.load_state_dict(sd, strict=True)
|
| 211 |
+
model.eval().to(device)
|
| 212 |
+
n_bins, pad_value = args["n_bins"], args["pad_value"]
|
| 213 |
+
gid = np.array([vocab[g] for g in X.columns])
|
| 214 |
+
rng = np.random.default_rng(seed)
|
| 215 |
+
seqs = []
|
| 216 |
+
for row in X.values: # scgpt.tasks.cell_emb: nonzero genes only, <cls> first with value=pad_value, then binning
|
| 217 |
+
nz = np.nonzero(row > 0)[0]
|
| 218 |
+
vals = scgpt_binning(row[nz], n_bins, rng).astype(np.float32) if len(nz) else np.zeros(0, np.float32)
|
| 219 |
+
seqs.append((np.r_[CLS, gid[nz]], np.r_[np.float32(pad_value), vals]))
|
| 220 |
+
L = max(len(g) for g, _ in seqs)
|
| 221 |
+
embs, rec = [], []
|
| 222 |
+
with torch.no_grad():
|
| 223 |
+
for i in range(0, len(seqs), bs):
|
| 224 |
+
b = seqs[i:i + bs]
|
| 225 |
+
g = torch.full((len(b), L), PAD, dtype=torch.long)
|
| 226 |
+
v = torch.full((len(b), L), float(pad_value))
|
| 227 |
+
for j, (gg, vv) in enumerate(b):
|
| 228 |
+
g[j, :len(gg)], v[j, :len(vv)] = torch.from_numpy(gg), torch.from_numpy(vv)
|
| 229 |
+
g, v = g.to(device), v.to(device)
|
| 230 |
+
mask = g.eq(PAD)
|
| 231 |
+
h = model.encode(g, v, mask)
|
| 232 |
+
embs.append(h[:, 0].float().cpu())
|
| 233 |
+
pred = model.decoder(h) # expression decoder: should track the visible input bins
|
| 234 |
+
for j, (gg, vv) in enumerate(b):
|
| 235 |
+
if len(gg) > 3:
|
| 236 |
+
rec.append(np.corrcoef(pred[j, 1:len(gg)].cpu().numpy(), vv[1:])[0, 1])
|
| 237 |
+
E = torch.cat(embs).numpy()
|
| 238 |
+
E = E / np.linalg.norm(E, axis=1, keepdims=True) # as scgpt.tasks.embed_data
|
| 239 |
+
meta = {"model": SCGPT_REPO + " (best_model.pt, args.json, vocab.json; official whole-human pretrained)",
|
| 240 |
+
"hidden": int(E.shape[1]), "strict_load": True,
|
| 241 |
+
"note_tdc": "tdc/scGPT weights differ from the official checkpoint and its original_model.bin "
|
| 242 |
+
"contains a cls_decoder (fine-tuned) -> not used",
|
| 243 |
+
"vocab_ids": dict(zip(X.columns, gid.tolist())),
|
| 244 |
+
"input": "<cls>(value=pad_value=-2) + expressed genes, values = scGPT per-cell quantile binning "
|
| 245 |
+
f"(n_bins={n_bins}, random tie-split, seed={seed}); padding masked",
|
| 246 |
+
"pool": "<cls> token output of last layer, L2-normalised (scgpt.tasks.embed_data)",
|
| 247 |
+
"n_tiles_no_expressed_gene": int(sum(len(gg) == 1 for gg, _ in seqs)),
|
| 248 |
+
"decoder_recon_pearson_mean": float(np.nanmean(rec))}
|
| 249 |
+
return E, meta
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ----------------------------------------------------------------------------- sanity
|
| 253 |
+
def ridge_cv_spearman(Z, Y, seed=0):
|
| 254 |
+
from scipy.stats import spearmanr
|
| 255 |
+
from sklearn.linear_model import RidgeCV
|
| 256 |
+
from sklearn.model_selection import KFold, cross_val_predict
|
| 257 |
+
from sklearn.pipeline import make_pipeline
|
| 258 |
+
from sklearn.preprocessing import StandardScaler
|
| 259 |
+
m = make_pipeline(StandardScaler(), RidgeCV(alphas=np.logspace(-2, 5, 15), alpha_per_target=True))
|
| 260 |
+
P = cross_val_predict(m, Z, Y, cv=KFold(5, shuffle=True, random_state=seed))
|
| 261 |
+
P, Y = P.reshape(len(Y), -1), Y.reshape(len(Y), -1)
|
| 262 |
+
return np.array([spearmanr(P[:, k], Y[:, k])[0] for k in range(Y.shape[1])])
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def sanity(E, df):
|
| 266 |
+
Z = E.astype(np.float32)
|
| 267 |
+
out = {"finite": bool(np.isfinite(Z).all()), "n_unique_rows": int(len(np.unique(E, axis=0)))}
|
| 268 |
+
Zn = Z / np.linalg.norm(Z, axis=1, keepdims=True)
|
| 269 |
+
S = Zn @ Zn.T
|
| 270 |
+
iu = np.triu_indices(len(Z), 1)
|
| 271 |
+
out["mean_pairwise_cos"] = float(S[iu].mean())
|
| 272 |
+
Zc = Z - Z.mean(0)
|
| 273 |
+
Zc /= np.linalg.norm(Zc, axis=1, keepdims=True) + 1e-8
|
| 274 |
+
out["mean_pairwise_cos_centered"] = float((Zc @ Zc.T)[iu].mean())
|
| 275 |
+
ev = np.linalg.svd(Z - Z.mean(0), compute_uv=False) ** 2
|
| 276 |
+
out["pcs_for_90pct_var"] = int(np.searchsorted(np.cumsum(ev) / ev.sum(), 0.9) + 1)
|
| 277 |
+
prop, _ = marker_columns(df)
|
| 278 |
+
Y = df[prop].values.astype(np.float64)
|
| 279 |
+
informative = [i for i, c in enumerate(prop) if c != "PD-1_prop"]
|
| 280 |
+
raw = np.load(os.path.join(CACHE, "markers.npy")) if os.path.exists(os.path.join(CACHE, "markers.npy")) \
|
| 281 |
+
else marker_matrix(df)
|
| 282 |
+
for tag, X in [("emb", Z), ("raw_marker_baseline", raw)]:
|
| 283 |
+
r = ridge_cv_spearman(X, Y)
|
| 284 |
+
out[f"{tag}_prop_spearman_mean17"] = float(np.nanmean(r))
|
| 285 |
+
out[f"{tag}_prop_spearman_mean16_noPD1"] = float(np.nanmean(r[informative]))
|
| 286 |
+
out[f"{tag}_prop_spearman_per_marker"] = {c: round(float(x), 3) for c, x in zip(prop, r)}
|
| 287 |
+
ts_p = os.path.join(ROOT, "results", "tile_stats.csv")
|
| 288 |
+
if os.path.exists(ts_p):
|
| 289 |
+
nn_ = pd.read_csv(ts_p, index_col="tile_id").loc[df.tile_id, "n_nuclei"].values
|
| 290 |
+
out[f"{tag}_log1p_nnuclei_spearman"] = float(ridge_cv_spearman(X, np.log1p(nn_)[:, None])[0])
|
| 291 |
+
return out
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def main():
|
| 295 |
+
ap = argparse.ArgumentParser()
|
| 296 |
+
ap.add_argument("model", choices=["geneformer", "scgpt"])
|
| 297 |
+
ap.add_argument("--value", choices=["prop", "count"], default="prop")
|
| 298 |
+
ap.add_argument("--pool", choices=["mean", "cls"], default="mean", help="geneformer only")
|
| 299 |
+
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 300 |
+
a = ap.parse_args()
|
| 301 |
+
t0 = time.time()
|
| 302 |
+
df = load_all_tiles()
|
| 303 |
+
X, notes = gene_expression(df, a.value)
|
| 304 |
+
name = a.model + ("-count" if a.value == "count" else "") + ("-cls" if a.model == "geneformer" and a.pool == "cls" else "")
|
| 305 |
+
if a.model == "geneformer":
|
| 306 |
+
E, meta = run_geneformer(X, a.pool, a.device)
|
| 307 |
+
else:
|
| 308 |
+
E, meta = run_scgpt(X, a.device)
|
| 309 |
+
t_emb = time.time() - t0
|
| 310 |
+
if a.device.startswith("cuda"):
|
| 311 |
+
meta["peak_gpu_mem_GB"] = round(torch.cuda.max_memory_allocated() / 1e9, 3)
|
| 312 |
+
E16 = E.astype(np.float16)[:, None, :]
|
| 313 |
+
assert E16.shape[0] == len(df) == 3187
|
| 314 |
+
np.save(os.path.join(CACHE, f"markers_{name}.npy"), E16)
|
| 315 |
+
meta.update({"name": name, "shape": list(E16.shape), "dtype": "float16", "row_order": "tile_id",
|
| 316 |
+
"value_source": "*_prop (positive fraction)" if a.value == "prop" else "*_count (positive cells)",
|
| 317 |
+
"value_notes": notes, "marker_to_genes": MARKER2GENES, "dropped_markers": DROPPED,
|
| 318 |
+
"merges": {"PTPRC": "max(CD45, CD45RO) — CD45RO is an isoform of PTPRC",
|
| 319 |
+
"KRT8/KRT18/KRT19": "all = Pan-CK"},
|
| 320 |
+
"scale_note": "both models encode only within-cell gene order -> tile-level abundance/cellularity "
|
| 321 |
+
"is not representable",
|
| 322 |
+
"runtime_embed_s": round(t_emb, 1)})
|
| 323 |
+
s = sanity(E16[:, 0], df)
|
| 324 |
+
meta["sanity"] = s
|
| 325 |
+
meta["runtime_total_s"] = round(time.time() - t0, 1)
|
| 326 |
+
json.dump(meta, open(os.path.join(CACHE, f"markers_{name}_meta.json"), "w"), indent=1)
|
| 327 |
+
print(f"[{name}] saved {E16.shape} float16 embed {t_emb:.1f}s total {meta['runtime_total_s']}s")
|
| 328 |
+
for k in ["n_tiles_no_expressed_gene", "decoder_recon_pearson_mean", "missing_keys", "unexpected_keys", "peak_gpu_mem_GB"]:
|
| 329 |
+
if k in meta:
|
| 330 |
+
print(f" {k}: {meta[k]}")
|
| 331 |
+
for k, v in s.items():
|
| 332 |
+
if not k.endswith("per_marker"):
|
| 333 |
+
print(f" {k}: {v:.4f}" if isinstance(v, float) else f" {k}: {v}")
|
| 334 |
+
print(" per-marker Spearman (emb):", s["emb_prop_spearman_per_marker"])
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
if __name__ == "__main__":
|
| 338 |
+
main()
|
experiments/16_extract_pathgenclip.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PathGen-CLIP-L (jamessyx/PathGen-CLIP-L, CLIP trained on PathGen-1.6M) text + image towers.
|
| 2 |
+
Usage: python 16_extract_pathgenclip.py {text|image} [--pathgen|--prompts]
|
| 3 |
+
text -> cache/text_pathgenclip.npy (N, 4, 768) ORION templated texts (cache/text.json)
|
| 4 |
+
text --pathgen -> cache/pathgen_text_pathgenclip.npy (N, 1, 768) PathGen captions (cache/pathgen_meta.csv)
|
| 5 |
+
text --prompts -> cache/prompts_text_pathgenclip.npy (P, 1, 768) concept prompts (prompts.json)
|
| 6 |
+
image -> cache/he_pathgenclip.npy (N, 8, 768) ORION tiles, 8 dihedral views
|
| 7 |
+
image --pathgen -> cache/pathgen_he_pathgenclip.npy (N, 8, 768) PathGen images, 8 dihedral views
|
| 8 |
+
All float16, projected CLIP embeddings (encode_text / encode_image), NOT L2-normalised -- same as 04b / 08.
|
| 9 |
+
|
| 10 |
+
Architecture: the README's 'ViT-B-16' is wrong for -L. The checkpoint has visual.conv1 (1024,3,14,14), 24 visual
|
| 11 |
+
blocks, visual.positional_embedding (577,1024) = 24x24+1 -> 336px, text width 768 / 12 blocks, proj -> 768.
|
| 12 |
+
That is OpenAI ViT-L/14@336; the authors' HF conversion (jamessyx/pathgenclip-vit-large-patch14-hf) is
|
| 13 |
+
initialised from openai/clip-vit-large-patch14-336 with hidden_act=quick_gelu -> open_clip 'ViT-L-14-336-quickgelu',
|
| 14 |
+
loaded strictly. Preprocess: 336px bicubic, OpenAI-CLIP mean/std (all ORION / PathGen images are square, so the
|
| 15 |
+
open_clip Resize+CenterCrop equals the plain square resize used in 04b). Tokenizer: OpenAI CLIP BPE, 77 tokens.
|
| 16 |
+
"""
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
from PIL import Image, PngImagePlugin
|
| 24 |
+
|
| 25 |
+
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024 # some PathGen PNGs embed very large ICC profiles
|
| 26 |
+
|
| 27 |
+
import open_clip
|
| 28 |
+
from huggingface_hub import hf_hub_download
|
| 29 |
+
|
| 30 |
+
from common import CACHE, ORION, ROOT, dihedral, load_all_tiles
|
| 31 |
+
|
| 32 |
+
ARCH = "ViT-L-14-336-quickgelu"
|
| 33 |
+
NAME = "pathgenclip"
|
| 34 |
+
mode = sys.argv[1]
|
| 35 |
+
SET = sys.argv[sys.argv.index("--set") + 1] if "--set" in sys.argv else "pathgen" if "--pathgen" in sys.argv else None
|
| 36 |
+
PATHGEN = SET is not None # encode an image-text pair set (cache/<SET>_meta.csv, e.g. PathGen-1.6M) instead of ORION tiles
|
| 37 |
+
PROMPTS = "--prompts" in sys.argv
|
| 38 |
+
dev = "cuda"
|
| 39 |
+
CLIPN = ([0.48145466, 0.4578275, 0.40821073], [0.26862954, 0.26130258, 0.27577711])
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_model():
|
| 43 |
+
ckpt = hf_hub_download("jamessyx/PathGen-CLIP-L", "pathgen-clip-l.pt")
|
| 44 |
+
model = open_clip.create_model(ARCH, pretrained=None)
|
| 45 |
+
sd = torch.load(ckpt, map_location="cpu")
|
| 46 |
+
sd = sd.get("state_dict", sd)
|
| 47 |
+
sd = {k[len("module."):] if k.startswith("module.") else k: v for k, v in sd.items()}
|
| 48 |
+
model.load_state_dict(sd, strict=True)
|
| 49 |
+
return model.eval().to(dev)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
model = load_model()
|
| 53 |
+
|
| 54 |
+
if mode == "text":
|
| 55 |
+
tok = open_clip.get_tokenizer(ARCH)
|
| 56 |
+
if PROMPTS:
|
| 57 |
+
pr = json.load(open(os.path.join(ROOT, "prompts.json")))
|
| 58 |
+
flat = [v["text"] for v in pr.values()]
|
| 59 |
+
ids, NV = list(range(len(flat))), 1
|
| 60 |
+
elif PATHGEN:
|
| 61 |
+
import pandas as pd
|
| 62 |
+
flat = pd.read_csv(os.path.join(CACHE, f"{SET}_meta.csv")).caption.tolist()
|
| 63 |
+
ids, NV = list(range(len(flat))), 1
|
| 64 |
+
else:
|
| 65 |
+
texts = json.load(open(os.path.join(CACHE, "text.json")))
|
| 66 |
+
ids, NV = sorted(int(k) for k in texts), 4
|
| 67 |
+
flat = [texts[str(i)][v] for i in ids for v in range(NV)]
|
| 68 |
+
out = []
|
| 69 |
+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.float16):
|
| 70 |
+
for s in range(0, len(flat), 256):
|
| 71 |
+
out.append(model.encode_text(tok(flat[s:s + 256]).to(dev)).float().cpu().numpy())
|
| 72 |
+
out = np.concatenate(out).reshape(len(ids), NV, -1).astype(np.float16)
|
| 73 |
+
assert ids == list(range(len(ids)))
|
| 74 |
+
pre = "prompts_" if PROMPTS else f"{SET}_" if PATHGEN else ""
|
| 75 |
+
fn = os.path.join(CACHE, pre + f"text_{NAME}.npy")
|
| 76 |
+
|
| 77 |
+
elif mode == "image":
|
| 78 |
+
size = model.visual.image_size[0] if isinstance(model.visual.image_size, (tuple, list)) else model.visual.image_size
|
| 79 |
+
assert size == 336, size
|
| 80 |
+
mean = torch.tensor(CLIPN[0]).view(1, 3, 1, 1)
|
| 81 |
+
std = torch.tensor(CLIPN[1]).view(1, 3, 1, 1)
|
| 82 |
+
if PATHGEN:
|
| 83 |
+
import pandas as pd
|
| 84 |
+
paths = pd.read_csv(os.path.join(CACHE, f"{SET}_meta.csv")).image_path.values
|
| 85 |
+
else:
|
| 86 |
+
paths = np.array([os.path.join(ORION, p) for p in load_all_tiles().image_path])
|
| 87 |
+
out = None
|
| 88 |
+
bs = 32
|
| 89 |
+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.float16):
|
| 90 |
+
for s in range(0, len(paths), bs):
|
| 91 |
+
ims = [np.asarray(Image.open(p).convert("RGB").resize((size, size), Image.BICUBIC))
|
| 92 |
+
for p in paths[s:s + bs]]
|
| 93 |
+
x = (torch.from_numpy(np.stack(ims)).permute(0, 3, 1, 2).float() / 255 - mean) / std
|
| 94 |
+
for d in range(8):
|
| 95 |
+
e = model.encode_image(dihedral(x, d).to(dev)).float().cpu().numpy()
|
| 96 |
+
if out is None:
|
| 97 |
+
out = np.zeros((len(paths), 8, e.shape[1]), np.float16)
|
| 98 |
+
out[s:s + bs, d] = e
|
| 99 |
+
if s % 640 == 0:
|
| 100 |
+
print(NAME, s, len(paths), flush=True)
|
| 101 |
+
fn = os.path.join(CACHE, (f"{SET}_" if PATHGEN else "") + f"he_{NAME}.npy")
|
| 102 |
+
else:
|
| 103 |
+
raise ValueError(mode)
|
| 104 |
+
|
| 105 |
+
np.save(fn, out)
|
| 106 |
+
print("saved", fn, out.shape, out.dtype, "finite:", np.isfinite(out).all(),
|
| 107 |
+
"peak GPU MB:", torch.cuda.max_memory_allocated() // 2**20)
|
experiments/17_bench_fm.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modality-specific foundation-model encoders for the H&E / mIF / markers / text binding (ImageBind-style).
|
| 2 |
+
|
| 3 |
+
Same trainer as 09_bench.py, but every modality can use its own *pretrained, modality-specific* foundation model
|
| 4 |
+
(frozen; features precomputed) followed by a trainable MLP head:
|
| 5 |
+
--if_enc fm:<name> -> cache/if_<name>.npy (N,8,D; 8 dihedral views, aligned with the H&E views) e.g. fm:virtues
|
| 6 |
+
--mk_enc fm:<name> -> cache/markers_<name>.npy (N,1,D) e.g. fm:geneformer, fm:scgpt
|
| 7 |
+
--txt_enc <name> -> cache/text_<name>.npy (N,4,D) e.g. pathgenclip
|
| 8 |
+
--save_heads -> runs/bench_fm/<run>/heads.pt (best-epoch encoders + input normalisation, for 31_virtual_validation.py)
|
| 9 |
+
The from-scratch encoders of 09_bench.py (resnet18 | convnext_tiny | chanset, linear | mlp | ftt) remain available
|
| 10 |
+
as baselines. Runs are written to runs/bench_fm/.
|
| 11 |
+
|
| 12 |
+
Original 09 description:
|
| 13 |
+
|
| 14 |
+
With a frozen anchor and anchor-only losses, each trainable encoder receives gradient only from its own
|
| 15 |
+
InfoNCE(he, m) term, so one run trains one IF encoder, one marker encoder and one text head *independently*;
|
| 16 |
+
each modality keeps its own best epoch (val R@1 of he<->m) and its test embeddings are saved at that epoch.
|
| 17 |
+
Cross pairs (if<->markers, if<->text, markers<->text) are then evaluated post hoc for any encoder combination
|
| 18 |
+
(10_bench_summary.py).
|
| 19 |
+
|
| 20 |
+
IF encoders : resnet18 | convnext_tiny | chanset (shared per-channel ResNet18 + channel tokens + transformer)
|
| 21 |
+
marker encoders : linear | mlp | ftt (FT-Transformer, one token per marker)
|
| 22 |
+
text encoders : plip | biomedclip | imagebind (frozen text tower, trainable MLP head)
|
| 23 |
+
"""
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import time
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
import pandas as pd
|
| 31 |
+
import timm
|
| 32 |
+
import torch
|
| 33 |
+
import torch.nn as nn
|
| 34 |
+
import torch.nn.functional as F
|
| 35 |
+
|
| 36 |
+
from common import CACHE, RUNS, SPLITS, dihedral
|
| 37 |
+
|
| 38 |
+
p = argparse.ArgumentParser()
|
| 39 |
+
p.add_argument("--split", default="orion_official")
|
| 40 |
+
p.add_argument("--fold", type=int, default=0)
|
| 41 |
+
p.add_argument("--anchor", default="uni2")
|
| 42 |
+
p.add_argument("--if_enc", default="resnet18", help="resnet18|convnext_tiny|chanset|fm:<name>")
|
| 43 |
+
p.add_argument("--mk_enc", default="mlp", help="linear|mlp|ftt|fm:<name>")
|
| 44 |
+
p.add_argument("--txt_enc", default="plip", help="any <name> with cache/text_<name>.npy")
|
| 45 |
+
p.add_argument("--epochs", type=int, default=40)
|
| 46 |
+
p.add_argument("--bs", type=int, default=128)
|
| 47 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 48 |
+
p.add_argument("--seed", type=int, default=0)
|
| 49 |
+
p.add_argument("--save_heads", action="store_true", help="also save each modality's encoder at its best epoch (heads.pt)")
|
| 50 |
+
args = p.parse_args()
|
| 51 |
+
torch.manual_seed(args.seed); np.random.seed(args.seed)
|
| 52 |
+
dev = "cuda"
|
| 53 |
+
TRAIN = ["if", "markers", "text"]
|
| 54 |
+
ENC = {"if": args.if_enc, "markers": args.mk_enc, "text": args.txt_enc}
|
| 55 |
+
tag = lambda e: e.replace("fm:", "fm-")
|
| 56 |
+
run_dir = os.path.join(RUNS, "bench_fm", f"{args.split}_fold{args.fold}_{args.anchor}_{tag(args.if_enc)}_{tag(args.mk_enc)}_{args.txt_enc}")
|
| 57 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 58 |
+
|
| 59 |
+
# ---------------- data ----------------
|
| 60 |
+
sp = pd.read_csv(os.path.join(SPLITS, args.split, f"fold{args.fold}.csv"))
|
| 61 |
+
HE = torch.from_numpy(np.load(os.path.join(CACHE, f"he_{args.anchor}.npy")).astype(np.float32)) # N,8,D
|
| 62 |
+
D = HE.shape[-1]
|
| 63 |
+
IF_FM, MK_FM = args.if_enc.startswith("fm:"), args.mk_enc.startswith("fm:")
|
| 64 |
+
if IF_FM: # frozen mIF foundation-model features, one per dihedral view
|
| 65 |
+
IF = torch.from_numpy(np.load(os.path.join(CACHE, f"if_{args.if_enc[3:]}.npy")).astype(np.float32))
|
| 66 |
+
else:
|
| 67 |
+
IF = torch.from_numpy(np.load(os.path.join(CACHE, "if.npy")))
|
| 68 |
+
if MK_FM: # frozen gene-expression foundation-model features of the marker vector
|
| 69 |
+
MK = torch.from_numpy(np.load(os.path.join(CACHE, f"markers_{args.mk_enc[3:]}.npy"))[:, 0].astype(np.float32))
|
| 70 |
+
else:
|
| 71 |
+
MK = torch.from_numpy(np.load(os.path.join(CACHE, "markers.npy")))
|
| 72 |
+
TX = torch.from_numpy(np.load(os.path.join(CACHE, f"text_{args.txt_enc}.npy")).astype(np.float32)) # N,4,Dt
|
| 73 |
+
tr_idx, va_idx, te_idx = (sp.tile_id[sp.split == s].values for s in ["train", "val", "test"])
|
| 74 |
+
if IF_FM:
|
| 75 |
+
if_mean, if_std = IF[tr_idx].mean((0, 1)).to(dev), IF[tr_idx].std((0, 1)).to(dev) + 1e-3
|
| 76 |
+
else:
|
| 77 |
+
if_mean = IF[tr_idx].float().mean((0, 2, 3)).view(1, -1, 1, 1).to(dev) / 255
|
| 78 |
+
if_std = IF[tr_idx].float().std((0, 2, 3)).view(1, -1, 1, 1).to(dev) / 255 + 1e-3
|
| 79 |
+
mk_mean, mk_std = MK[tr_idx].mean(0), MK[tr_idx].std(0) + 1e-3
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def batch(idx, train=False):
|
| 83 |
+
idx = torch.as_tensor(idx)
|
| 84 |
+
d = torch.randint(0, 8, (len(idx),)) if train else torch.zeros(len(idx), dtype=torch.long)
|
| 85 |
+
v = torch.randint(0, 4, (len(idx),)) if train else torch.zeros(len(idx), dtype=torch.long)
|
| 86 |
+
if IF_FM: # precomputed features of the same dihedral view as the H&E anchor
|
| 87 |
+
x = IF[idx, d].to(dev)
|
| 88 |
+
else:
|
| 89 |
+
x = IF[idx].to(dev, non_blocking=True).float() / 255
|
| 90 |
+
x = torch.stack([dihedral(a, int(k)) for a, k in zip(x, d)])
|
| 91 |
+
if train:
|
| 92 |
+
x = x * (1 + 0.1 * torch.randn(len(idx), x.shape[1], 1, 1, device=dev))
|
| 93 |
+
mk = (MK[idx] - mk_mean) / mk_std
|
| 94 |
+
if train and not MK_FM:
|
| 95 |
+
mk = mk + 0.05 * torch.randn_like(mk)
|
| 96 |
+
return {"he": HE[idx, d].to(dev), "if": (x - if_mean) / if_std, "markers": mk.to(dev), "text": TX[idx, v].to(dev)}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---------------- encoders ----------------
|
| 100 |
+
def head(din):
|
| 101 |
+
return nn.Sequential(nn.LayerNorm(din), nn.Linear(din, D))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TimmEnc(nn.Module):
|
| 105 |
+
def __init__(self, name):
|
| 106 |
+
super().__init__()
|
| 107 |
+
tag = {"resnet18": "resnet18.a1_in1k", "convnext_tiny": "convnext_tiny.fb_in1k"}[name]
|
| 108 |
+
self.trunk = timm.create_model(tag, pretrained=True, in_chans=17, num_classes=0)
|
| 109 |
+
self.head = head(self.trunk.num_features)
|
| 110 |
+
|
| 111 |
+
def forward(self, x):
|
| 112 |
+
return self.head(self.trunk(x))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class ChanSet(nn.Module):
|
| 116 |
+
"""Channel-agnostic mIF encoder: each channel -> shared 1-ch ResNet18 (112px) -> token (+ channel embedding)
|
| 117 |
+
-> 2-layer transformer over channel tokens -> mean pool."""
|
| 118 |
+
def __init__(self, C=17, d=256):
|
| 119 |
+
super().__init__()
|
| 120 |
+
self.cnn = timm.create_model("resnet18.a1_in1k", pretrained=True, in_chans=1, num_classes=0)
|
| 121 |
+
self.proj = nn.Linear(512, d)
|
| 122 |
+
self.ch = nn.Parameter(torch.zeros(1, C, d)); nn.init.normal_(self.ch, std=0.02)
|
| 123 |
+
self.tf = nn.TransformerEncoder(nn.TransformerEncoderLayer(d, 4, 4 * d, 0.1, batch_first=True, norm_first=True), 2)
|
| 124 |
+
self.head = head(d)
|
| 125 |
+
|
| 126 |
+
def forward(self, x):
|
| 127 |
+
B, C, H, W = x.shape
|
| 128 |
+
x = F.interpolate(x, size=(112, 112), mode="bilinear", antialias=True)
|
| 129 |
+
t = self.proj(self.cnn(x.reshape(B * C, 1, 112, 112))).view(B, C, -1) + self.ch
|
| 130 |
+
return self.head(self.tf(t).mean(1))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class FTT(nn.Module):
|
| 134 |
+
"""FT-Transformer: feature tokenizer (value * w_j + b_j) + CLS, 3 layers."""
|
| 135 |
+
def __init__(self, F_in, d=128):
|
| 136 |
+
super().__init__()
|
| 137 |
+
self.w = nn.Parameter(torch.randn(F_in, d) * 0.02)
|
| 138 |
+
self.b = nn.Parameter(torch.zeros(F_in, d))
|
| 139 |
+
self.cls = nn.Parameter(torch.zeros(1, 1, d))
|
| 140 |
+
self.tf = nn.TransformerEncoder(nn.TransformerEncoderLayer(d, 8, 2 * d, 0.1, batch_first=True, norm_first=True), 3)
|
| 141 |
+
self.head = head(d)
|
| 142 |
+
|
| 143 |
+
def forward(self, x):
|
| 144 |
+
t = x.unsqueeze(-1) * self.w + self.b
|
| 145 |
+
t = torch.cat([self.cls.expand(len(x), -1, -1), t], 1)
|
| 146 |
+
return self.head(self.tf(t)[:, 0])
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def mlp(din, hid=512):
|
| 150 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 151 |
+
nn.LayerNorm(hid), nn.Linear(hid, D))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
enc = nn.ModuleDict({
|
| 155 |
+
"if": mlp(IF.shape[-1]) if IF_FM else ChanSet() if args.if_enc == "chanset" else TimmEnc(args.if_enc),
|
| 156 |
+
"markers": mlp(MK.shape[1]) if MK_FM else {"linear": lambda: nn.Linear(MK.shape[1], D), "mlp": lambda: mlp(MK.shape[1]),
|
| 157 |
+
"ftt": lambda: FTT(MK.shape[1])}[args.mk_enc](),
|
| 158 |
+
"text": mlp(TX.shape[-1]), # frozen text tower features -> trainable projection
|
| 159 |
+
}).to(dev)
|
| 160 |
+
logit = nn.ParameterDict({m: nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32)) for m in TRAIN}).to(dev)
|
| 161 |
+
params = list(enc.parameters()) + list(logit.parameters())
|
| 162 |
+
opt = torch.optim.AdamW(params, lr=args.lr, weight_decay=0.05)
|
| 163 |
+
steps = args.epochs * (len(tr_idx) // args.bs)
|
| 164 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=steps, pct_start=0.05)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def embed(b):
|
| 168 |
+
z = {"he": F.normalize(b["he"], dim=-1)}
|
| 169 |
+
for m in TRAIN:
|
| 170 |
+
z[m] = F.normalize(enc[m](b[m]).float(), dim=-1)
|
| 171 |
+
return z
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@torch.no_grad()
|
| 175 |
+
def embed_all(idx):
|
| 176 |
+
enc.eval()
|
| 177 |
+
out = {m: [] for m in ["he"] + TRAIN}
|
| 178 |
+
for s in range(0, len(idx), 256):
|
| 179 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 180 |
+
z = embed(batch(idx[s:s + 256]))
|
| 181 |
+
for m in out:
|
| 182 |
+
out[m].append(z[m].float().cpu())
|
| 183 |
+
return {m: torch.cat(v) for m, v in out.items()}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def r1(a, b):
|
| 187 |
+
s = a @ b.T
|
| 188 |
+
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------- train with per-modality model selection ----------------
|
| 192 |
+
best = {m: -1.0 for m in TRAIN}
|
| 193 |
+
best_ep = {m: -1 for m in TRAIN}
|
| 194 |
+
test_z = {}
|
| 195 |
+
best_state = {}
|
| 196 |
+
hist = []
|
| 197 |
+
t0 = time.time()
|
| 198 |
+
for ep in range(args.epochs):
|
| 199 |
+
enc.train()
|
| 200 |
+
perm = np.random.permutation(tr_idx)
|
| 201 |
+
tl = {m: [] for m in TRAIN}
|
| 202 |
+
for s in range(0, len(perm) - args.bs + 1, args.bs):
|
| 203 |
+
b = batch(perm[s:s + args.bs], train=True)
|
| 204 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 205 |
+
z = embed(b)
|
| 206 |
+
tgt = torch.arange(args.bs, device=dev)
|
| 207 |
+
loss = 0
|
| 208 |
+
for m in TRAIN:
|
| 209 |
+
lg = logit[m].exp().clamp(max=100) * z["he"] @ z[m].T
|
| 210 |
+
l = (F.cross_entropy(lg, tgt) + F.cross_entropy(lg.T, tgt)) / 2
|
| 211 |
+
loss = loss + l
|
| 212 |
+
tl[m].append(l.item())
|
| 213 |
+
opt.zero_grad(set_to_none=True)
|
| 214 |
+
loss.backward()
|
| 215 |
+
opt.step(); sched.step()
|
| 216 |
+
zv = embed_all(va_idx)
|
| 217 |
+
rec = {"epoch": ep}
|
| 218 |
+
improved = []
|
| 219 |
+
for m in TRAIN:
|
| 220 |
+
v = (r1(zv["he"], zv[m]) + r1(zv[m], zv["he"])) / 2
|
| 221 |
+
rec[f"loss_{m}"], rec[f"val_r1_{m}"] = float(np.mean(tl[m])), v
|
| 222 |
+
if v > best[m]:
|
| 223 |
+
best[m], best_ep[m] = v, ep
|
| 224 |
+
improved.append(m)
|
| 225 |
+
if improved:
|
| 226 |
+
zt = embed_all(te_idx)
|
| 227 |
+
test_z["he"] = zt["he"]
|
| 228 |
+
for m in improved:
|
| 229 |
+
test_z[m] = zt[m]
|
| 230 |
+
if args.save_heads:
|
| 231 |
+
best_state[m] = {k: v.detach().cpu().clone() for k, v in enc[m].state_dict().items()}
|
| 232 |
+
hist.append(rec)
|
| 233 |
+
print(f"ep {ep} " + " ".join(f"{m}:{rec[f'val_r1_{m}']:.3f}" for m in TRAIN) + f" ({time.time() - t0:.0f}s)", flush=True)
|
| 234 |
+
|
| 235 |
+
torch.save({"z": test_z, "tile_id": te_idx}, os.path.join(run_dir, "test_embeddings.pt"))
|
| 236 |
+
if args.save_heads: # + the input normalisation needed to apply the heads to new data
|
| 237 |
+
torch.save({"state": best_state, "if_mean": if_mean.cpu(), "if_std": if_std.cpu(), "mk_mean": mk_mean, "mk_std": mk_std},
|
| 238 |
+
os.path.join(run_dir, "heads.pt"))
|
| 239 |
+
json.dump({"args": vars(args), "best_val": best, "best_epoch": best_ep, "n_train": len(tr_idx), "n_test": len(te_idx),
|
| 240 |
+
"time_s": time.time() - t0, "history": hist}, open(os.path.join(run_dir, "train.json"), "w"), indent=1)
|
| 241 |
+
print("done", best, best_ep)
|
experiments/18_eval_fm.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate modality-specific encoders: from-scratch baselines (runs/bench, 09_bench.py) vs frozen modality-specific
|
| 2 |
+
foundation models + MLP head (runs/bench_fm, 17_bench_fm.py), all bound to the frozen UNI2 H&E anchor.
|
| 3 |
+
|
| 4 |
+
(A) retrieval, tie-aware R@1 on 100-tile galleries + Hungarian pairing acc on the full test set:
|
| 5 |
+
trained pairs he<->if, he<->markers (per encoder)
|
| 6 |
+
emergent pairs if<->markers (never trained together) (every if_enc x mk_enc combination of a fold)
|
| 7 |
+
(B) emergent text: the text head trained ONLY on PathGen H&E-caption pairs (12_train_text_pathgen.py, one per text
|
| 8 |
+
encoder) scores the concept prompts (prompts.json) against ORION he / if / markers embeddings; Spearman + AUROC
|
| 9 |
+
(same `probe` as 13_eval_text_emergent.py). Text never co-occurs with IF or markers in training.
|
| 10 |
+
Outputs results/fm_retrieval.csv, results/fm_text_probes.csv
|
| 11 |
+
"""
|
| 12 |
+
import glob
|
| 13 |
+
import itertools
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn.functional as F
|
| 21 |
+
from scipy.stats import spearmanr
|
| 22 |
+
from sklearn.metrics import roc_auc_score
|
| 23 |
+
|
| 24 |
+
from common import CACHE, ROOT, RUNS, load_all_tiles, metrics
|
| 25 |
+
|
| 26 |
+
OUT = os.path.join(ROOT, "results")
|
| 27 |
+
prompts = json.load(open(os.path.join(ROOT, "prompts.json")))
|
| 28 |
+
df = load_all_tiles()
|
| 29 |
+
stats = pd.read_csv(os.path.join(OUT, "tile_stats.csv")).set_index("tile_id")
|
| 30 |
+
df["n_nuclei"] = stats.loc[df.tile_id, "n_nuclei"].values
|
| 31 |
+
TXT = sorted(os.path.basename(d).split("_", 1)[1] for d in glob.glob(os.path.join(RUNS, "text_pathgen", "uni2_*"))
|
| 32 |
+
if os.path.exists(os.path.join(d, "head.pt")) and os.path.exists(os.path.join(CACHE, f"prompts_text_{os.path.basename(d).split('_', 1)[1]}.npy")))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_head(enc, din, dout):
|
| 36 |
+
h = torch.nn.Sequential(torch.nn.Linear(din, 512), torch.nn.GELU(), torch.nn.Dropout(0.1), torch.nn.Linear(512, 512),
|
| 37 |
+
torch.nn.GELU(), torch.nn.LayerNorm(512), torch.nn.Linear(512, dout))
|
| 38 |
+
h.load_state_dict(torch.load(os.path.join(RUNS, "text_pathgen", f"uni2_{enc}", "head.pt"), map_location="cpu"))
|
| 39 |
+
return h.eval()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def probe(scores, tiles, key, label_col):
|
| 43 |
+
sub = tiles if key == "cellularity" else tiles[df.loc[tiles, "n_nuclei"].values >= 20]
|
| 44 |
+
s = scores[np.isin(tiles, sub)]
|
| 45 |
+
y = df.loc[sub, label_col].values.astype(float)
|
| 46 |
+
if label_col != "n_nuclei":
|
| 47 |
+
y = y / np.maximum(df.loc[sub, "n_nuclei"].values, 1)
|
| 48 |
+
lab = y >= np.quantile(y, 0.75)
|
| 49 |
+
return {"spearman": float(spearmanr(s, y).correlation),
|
| 50 |
+
"auroc": float(roc_auc_score(lab, s)) if 0 < lab.sum() < len(lab) else np.nan, "n": int(len(sub))}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# collect test embeddings: (split, fold) -> {"if": {enc: z}, "markers": {enc: z}, "he": z, "tile_id": ids}
|
| 54 |
+
folds = {}
|
| 55 |
+
for root, src in [("bench", "scratch"), ("bench_fm", None)]:
|
| 56 |
+
for f in glob.glob(os.path.join(RUNS, root, "*", "test_embeddings.pt")):
|
| 57 |
+
a = json.load(open(os.path.join(os.path.dirname(f), "train.json")))["args"]
|
| 58 |
+
if a["anchor"] != "uni2":
|
| 59 |
+
continue
|
| 60 |
+
E = torch.load(f, weights_only=False) # our own files (contain numpy tile ids)
|
| 61 |
+
d = folds.setdefault((a["split"], a["fold"]), {"if": {}, "markers": {}, "he": E["z"]["he"], "tile_id": np.asarray(E["tile_id"])})
|
| 62 |
+
assert (d["tile_id"] == np.asarray(E["tile_id"])).all()
|
| 63 |
+
d["if"][a["if_enc"]] = E["z"]["if"]
|
| 64 |
+
d["markers"][a["mk_enc"]] = E["z"]["markers"]
|
| 65 |
+
|
| 66 |
+
ret, prb = [], []
|
| 67 |
+
for (split, fold), d in sorted(folds.items()):
|
| 68 |
+
he, tiles = d["he"], d["tile_id"]
|
| 69 |
+
for m in ["if", "markers"]:
|
| 70 |
+
for e, z in d[m].items():
|
| 71 |
+
for q, g, name in [(he, z, f"he->{m}"), (z, he, f"{m}->he")]:
|
| 72 |
+
ret.append({"split": split, "fold": fold, "kind": "trained", "pair": name, "enc": e, **metrics(q, g)})
|
| 73 |
+
for ei, em in itertools.product(d["if"], d["markers"]):
|
| 74 |
+
zi, zm = d["if"][ei], d["markers"][em]
|
| 75 |
+
for q, g, name in [(zi, zm, "if->markers"), (zm, zi, "markers->if")]:
|
| 76 |
+
ret.append({"split": split, "fold": fold, "kind": "emergent", "pair": name, "enc": f"{ei} + {em}", **metrics(q, g)})
|
| 77 |
+
for t in TXT:
|
| 78 |
+
prm = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{t}.npy"))[:, 0].astype(np.float32))
|
| 79 |
+
with torch.no_grad():
|
| 80 |
+
zp = F.normalize(load_head(t, prm.shape[1], he.shape[1])(prm), dim=-1)
|
| 81 |
+
embs = [("he", "uni2", he)] + [(m, e, z) for m in ["if", "markers"] for e, z in d[m].items()]
|
| 82 |
+
for m, e, z in embs:
|
| 83 |
+
for i, (key, pr) in enumerate(prompts.items()):
|
| 84 |
+
prb.append({"split": split, "fold": fold, "text_enc": t, "modality": m, "enc": e, "concept": key,
|
| 85 |
+
**probe((z @ zp[i]).numpy(), tiles, key, pr["label"])})
|
| 86 |
+
|
| 87 |
+
R, P = pd.DataFrame(ret), pd.DataFrame(prb)
|
| 88 |
+
R.to_csv(os.path.join(OUT, "fm_retrieval.csv"), index=False)
|
| 89 |
+
P.to_csv(os.path.join(OUT, "fm_text_probes.csv"), index=False)
|
| 90 |
+
pd.set_option("display.width", 250); pd.set_option("display.max_rows", 500)
|
| 91 |
+
R["pair2"] = R.pair.map(lambda p: "<->".join(sorted(p.split("->"))))
|
| 92 |
+
g = R.groupby(["kind", "pair2", "enc", "split", "fold"])[["R@1_g100", "pair_acc"]].mean().reset_index()
|
| 93 |
+
print(g.groupby(["kind", "pair2", "enc", "split"])[["R@1_g100", "pair_acc"]].agg(["mean", "std"]).round(3).to_string())
|
| 94 |
+
print(P.groupby(["text_enc", "modality", "enc", "split"])[["auroc", "spearman"]].mean().round(3).unstack("split").to_string())
|
experiments/19_pairsets_prep.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified pool of ALL H&E image-text pairs in PathGen-1.6M/ (every text-image source of PAIR_SUMMARY.md; all TCGA
|
| 2 |
+
patches). Images are de-duplicated by patch key <wsi>_<x>_<y> (the same patch recurs across sources); one image can
|
| 3 |
+
carry several texts. Text types:
|
| 4 |
+
caption : PathGen-1.6M captions, figure_caption_summary / summary_testing / subsample_summary, slide_report desc,
|
| 5 |
+
sample_data/output txt, output_subsample txt, path_reason_data caption
|
| 6 |
+
summary : path_summary_train(.json, _5w.json) one-paragraph trainee summaries
|
| 7 |
+
qa_open : open-ended Q/A -> assistant answers joined (PathGen-Instruct OPEN, output_subsample json,
|
| 8 |
+
qa_pathinstruct_withreason*.csv answers, path_reason_data question+answer)
|
| 9 |
+
qa_close : multiple-choice Q/A -> question stem + text of the correct option (PathGen-Instruct CLOSE,
|
| 10 |
+
qa_instruct_merged_onlyqaforrl, output_reason json, multiple-choice csv rows)
|
| 11 |
+
Outputs
|
| 12 |
+
cache/allimg_meta.csv : unique images (idx, key, wsi_id, patient, image_path) -> image features
|
| 13 |
+
cache/alltxt_meta.csv : unique (image, text) pairs (idx, img_idx, caption, ttype, source) -> text features
|
| 14 |
+
(the `caption` column holds the text so the extractors' --set option works unchanged)
|
| 15 |
+
Not used: pathor1_output*.csv (hashed non-TCGA image names, images absent).
|
| 16 |
+
"""
|
| 17 |
+
import ast
|
| 18 |
+
import glob
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
from common import CACHE, ROOT
|
| 26 |
+
|
| 27 |
+
PG = os.path.join(ROOT, "..", "PathGen-1.6M")
|
| 28 |
+
img, txt = {}, [] # key -> image path ; list of (key, text, ttype, source)
|
| 29 |
+
KEY = re.compile(r"(TCGA-[A-Z0-9-]+\.[A-Za-z0-9-]+)_(\d+)_(\d+)")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def key_of(s):
|
| 33 |
+
m = KEY.search(os.path.basename(str(s)))
|
| 34 |
+
return f"{m.group(1)}_{m.group(2)}_{m.group(3)}" if m else None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def add_img(key, path):
|
| 38 |
+
if key and key not in img and os.path.exists(path):
|
| 39 |
+
img[key] = path
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def add_txt(key, text, ttype, source):
|
| 43 |
+
text = re.sub(r"\s+", " ", str(text).replace("<image>", " ")).strip()
|
| 44 |
+
if key and text and text.lower() != "nan":
|
| 45 |
+
txt.append((key, text, ttype, source))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def close_text(question, answer):
|
| 49 |
+
"""multiple-choice -> 'question stem + correct option text'"""
|
| 50 |
+
q = question.replace("<image>", " ")
|
| 51 |
+
opts = dict(re.findall(r"(?:^|\n)\s*\(?([A-F])[\).:]\s*([^\n]+)", q))
|
| 52 |
+
stem = re.split(r"\n\s*\(?A[\).:]", q)[0].strip()
|
| 53 |
+
a = answer.strip()
|
| 54 |
+
letter = re.match(r"^\(?([A-F])\)?(?:[\).:\s]|$)", a)
|
| 55 |
+
if letter and letter.group(1) in opts:
|
| 56 |
+
a = opts[letter.group(1)]
|
| 57 |
+
elif letter and len(a) <= 3:
|
| 58 |
+
return None
|
| 59 |
+
else:
|
| 60 |
+
a = re.sub(r"^\(?[A-F]\)\s*", "", a)
|
| 61 |
+
return f"{stem} {a.strip()}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def conv_text(conv):
|
| 65 |
+
"""returns (ttype, text) for a LLaVA-style conversation list"""
|
| 66 |
+
gpt = [c["value"].strip() for c in conv if c["from"] == "gpt"]
|
| 67 |
+
if len(gpt) == 1 and re.fullmatch(r"\(?[A-F]\)?\.?", gpt[0]):
|
| 68 |
+
human = [c["value"] for c in conv if c["from"] == "human"][0]
|
| 69 |
+
t = close_text(human, gpt[0])
|
| 70 |
+
return ("qa_close", t) if t else (None, None)
|
| 71 |
+
return "qa_open", " ".join(gpt)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# 1. PathGen-1.6M captions (Data/images, 672 px)
|
| 75 |
+
for x in json.load(open(os.path.join(PG, "Data", "PathGen-1.6M.json"))):
|
| 76 |
+
k = f"{x['wsi_id']}_{x['position'][0]}_{x['position'][1]}"
|
| 77 |
+
add_img(k, os.path.join(PG, "Data", "images", k + ".png"))
|
| 78 |
+
add_txt(k, x["caption"], "caption", "pathgen1.6m")
|
| 79 |
+
# 2. PathGen-Instruct (OPEN + CLOSE)
|
| 80 |
+
idx = pd.read_csv(os.path.join(PG, "PathGen-Instruct.json.image_index.csv")).drop_duplicates("key").set_index("key")
|
| 81 |
+
for x in json.load(open(os.path.join(PG, "PathGen-Instruct.json"))):
|
| 82 |
+
k = f"{x['wsi_id']}_{x['position'][0]}_{x['position'][1]}"
|
| 83 |
+
if k in idx.index:
|
| 84 |
+
add_img(k, os.path.join(PG, idx.loc[k, "local_image"]))
|
| 85 |
+
tt, t = conv_text(x["conversations"])
|
| 86 |
+
if tt:
|
| 87 |
+
add_txt(k, t, tt, "pathgen-instruct")
|
| 88 |
+
# 3. qa_instruct_merged_onlyqaforrl
|
| 89 |
+
for x in json.load(open(os.path.join(PG, "qa_instruct_merged_onlyqaforrl.json"))):
|
| 90 |
+
k = x.get("path_list") or f"{x['wsi_id']}_{x['position'][0]}_{x['position'][1]}"
|
| 91 |
+
for c in glob.glob(os.path.join(PG, "qa_instruct_merged_images", k + ".*")):
|
| 92 |
+
if c.endswith((".png", ".jpg")):
|
| 93 |
+
add_img(k, c)
|
| 94 |
+
tt, t = conv_text(x["conversations"])
|
| 95 |
+
if tt:
|
| 96 |
+
add_txt(k, t, tt, "qa_instruct_merged")
|
| 97 |
+
# 4. summaries
|
| 98 |
+
for f in ["path_summary_train.json", "path_summary_train_5w.json"]:
|
| 99 |
+
for x in json.load(open(os.path.join(PG, f))):
|
| 100 |
+
k = key_of(x["images"][0])
|
| 101 |
+
add_img(k, os.path.join(PG, x["images"][0]))
|
| 102 |
+
add_txt(k, [m["content"] for m in x["messages"] if m["role"] == "assistant"][0], "summary", f.replace(".json", ""))
|
| 103 |
+
# 5. output_reason (png + json conversation), output_subsample (png + txt caption / json conversation), sample_data
|
| 104 |
+
for d, src in [("output_reason", "output_reason"), ("output_subsample", "output_subsample"), ("sample_data/output", "sample_data")]:
|
| 105 |
+
for pth in glob.glob(os.path.join(PG, d, "*.png")):
|
| 106 |
+
k = key_of(pth)
|
| 107 |
+
add_img(k, pth)
|
| 108 |
+
base = pth[:-4]
|
| 109 |
+
if os.path.exists(base + ".txt"):
|
| 110 |
+
add_txt(k, open(base + ".txt").read(), "caption", src)
|
| 111 |
+
if os.path.exists(base + ".json"):
|
| 112 |
+
tt, t = conv_text(json.load(open(base + ".json")))
|
| 113 |
+
if tt:
|
| 114 |
+
add_txt(k, t, tt, src)
|
| 115 |
+
# 6. qa_pathinstruct_withreason*.csv (images in output_reason/)
|
| 116 |
+
for f in sorted(glob.glob(os.path.join(PG, "qa_pathinstruct_withreason*.csv"))):
|
| 117 |
+
for _, r in pd.read_csv(f).iterrows():
|
| 118 |
+
k = key_of(r.get("path_list", r.get("imagep")))
|
| 119 |
+
add_img(k, os.path.join(PG, str(r["imagep"]).lstrip("./")))
|
| 120 |
+
t = close_text(r["question"], str(r["answer"])) if re.search(r"\n\s*\(?A[\).:]", str(r["question"])) else r["answer"]
|
| 121 |
+
if t:
|
| 122 |
+
add_txt(k, t, "qa_close" if t is not r["answer"] else "qa_open", "qa_pathinstruct_withreason")
|
| 123 |
+
# 7. path_reason_data (caption + diagnostic Q/A)
|
| 124 |
+
for f in sorted(glob.glob(os.path.join(PG, "path_reason_data_3600*.csv"))):
|
| 125 |
+
for _, r in pd.read_csv(f).iterrows():
|
| 126 |
+
k = key_of(r["patch_id"])
|
| 127 |
+
for c in [os.path.join(PG, str(r["patch_id"]).lstrip("./")), os.path.join(PG, "output_subsample", os.path.basename(str(r["patch_id"])))]:
|
| 128 |
+
add_img(k, c)
|
| 129 |
+
add_txt(k, r["caption"], "caption", "path_reason_data")
|
| 130 |
+
add_txt(k, f"{r['question']} {r['answer']}", "qa_open", "path_reason_data")
|
| 131 |
+
# 8. figure_caption_summary (summary_testing_data / subsample_summary_sentence), slide_report
|
| 132 |
+
for f in ["summary_testing_data.csv", "subsample_summary_sentence.csv"]:
|
| 133 |
+
for _, r in pd.read_csv(os.path.join(PG, f)).iterrows():
|
| 134 |
+
x, y = ast.literal_eval(r["position"])
|
| 135 |
+
k = f"{r['wsi_id']}_{x}_{y}"
|
| 136 |
+
add_img(k, os.path.join(PG, "figure_caption_summary", k + ".png"))
|
| 137 |
+
add_txt(k, r["caption"], "caption", "figure_caption_summary")
|
| 138 |
+
for f in glob.glob(os.path.join(PG, "slide_report", "*", "*_desc.csv")):
|
| 139 |
+
k = key_of(f.replace("_desc.csv", ""))
|
| 140 |
+
add_img(k, f.replace("_desc.csv", ".png"))
|
| 141 |
+
for _, r in pd.read_csv(f).iterrows():
|
| 142 |
+
add_txt(k, r["caption"], "caption", "slide_report")
|
| 143 |
+
|
| 144 |
+
T = pd.DataFrame(txt, columns=["key", "caption", "ttype", "source"])
|
| 145 |
+
n_raw = len(T)
|
| 146 |
+
T = T[T.key.isin(img)]
|
| 147 |
+
n_noimg = n_raw - len(T)
|
| 148 |
+
T = T.groupby(["key", "caption"], as_index=False).agg(ttype=("ttype", "first"), source=("source", lambda s: "|".join(sorted(set(s)))))
|
| 149 |
+
I = pd.DataFrame({"key": sorted(T.key.unique())})
|
| 150 |
+
I["image_path"] = I.key.map(img)
|
| 151 |
+
I["wsi_id"] = I.key.str.rsplit("_", n=2).str[0]
|
| 152 |
+
I["patient"] = I.wsi_id.str[:12]
|
| 153 |
+
I["caption"] = ""
|
| 154 |
+
T["img_idx"] = T.key.map(dict(zip(I.key, I.index)))
|
| 155 |
+
I.to_csv(os.path.join(CACHE, "allimg_meta.csv"), index_label="idx")
|
| 156 |
+
T[["img_idx", "key", "caption", "ttype", "source"]].to_csv(os.path.join(CACHE, "alltxt_meta.csv"), index_label="idx")
|
| 157 |
+
print(f"texts: {n_raw} raw, {n_noimg} without an image on disk, {len(T)} unique (image, text) pairs")
|
| 158 |
+
print(f"images: {len(I)} unique patches, {I.wsi_id.nunique()} slides, {I.patient.nunique()} patients")
|
| 159 |
+
print(T.ttype.value_counts().to_string())
|
| 160 |
+
print(T.source.str.split("|").explode().value_counts().to_string())
|
| 161 |
+
print("texts per image:", T.groupby("img_idx").size().describe().round(2).to_dict())
|
| 162 |
+
print("words per text by type:", T.assign(w=T.caption.str.split().str.len()).groupby("ttype").w.median().to_dict())
|
experiments/20_pairing.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text <-> H&E image pairing benchmark on ALL real image-text pairs (unified pool from 19_pairsets_prep.py:
|
| 2 |
+
6144 TCGA patches, 6480 pairs; text types caption / summary / qa_open / qa_close).
|
| 3 |
+
|
| 4 |
+
Protocol: patient-level 5-fold CV over the pool (inside each training fold 10% of patients are held out for early
|
| 5 |
+
stopping). Training uses every (image, text) pair of the training patients; an image with several texts counts all of
|
| 6 |
+
them as positives (multi-positive InfoNCE). Every image encoder x text encoder combination in three modes:
|
| 7 |
+
zeroshot : the CLIP model's own image and text towers, no training (only when image enc == text enc)
|
| 8 |
+
text_head : frozen image features = anchor; trainable MLP head on the frozen text features (ImageBind recipe)
|
| 9 |
+
two_heads : trainable MLP heads on both frozen towers into a shared 512-d space (CLIP-style projection)
|
| 10 |
+
Evaluation on the held-out fold, per text type and for "all" (one text per image): tie-aware R@1/5/10, MRR,
|
| 11 |
+
Hungarian one-to-one pairing accuracy over the whole gallery, R@1 on random 100-pair galleries; both directions.
|
| 12 |
+
Caveat: PathGen-CLIP-L was trained on PathGen-1.6M (most of this pool) -> its numbers are not held-out.
|
| 13 |
+
Outputs results/pairing_long.csv, results/pairing_summary.csv
|
| 14 |
+
"""
|
| 15 |
+
import argparse
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import pandas as pd
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn as nn
|
| 22 |
+
import torch.nn.functional as F
|
| 23 |
+
from sklearn.model_selection import GroupKFold
|
| 24 |
+
|
| 25 |
+
from common import CACHE, ROOT, metrics
|
| 26 |
+
|
| 27 |
+
p = argparse.ArgumentParser()
|
| 28 |
+
p.add_argument("--img", nargs="+", default=["uni2", "phikonv2", "plip", "biomedclip", "imagebind", "pathgenclip"])
|
| 29 |
+
p.add_argument("--txt", nargs="+", default=["plip", "biomedclip", "imagebind", "pathgenclip"])
|
| 30 |
+
p.add_argument("--epochs", type=int, default=60)
|
| 31 |
+
p.add_argument("--bs", type=int, default=256)
|
| 32 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 33 |
+
p.add_argument("--tag", default="")
|
| 34 |
+
args = p.parse_args()
|
| 35 |
+
dev = "cuda"
|
| 36 |
+
OUT = os.path.join(ROOT, "results")
|
| 37 |
+
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 38 |
+
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
|
| 39 |
+
TTYPES = ["caption", "summary", "qa_open", "qa_close"]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load(kind, enc):
|
| 43 |
+
f = os.path.join(CACHE, f"all{kind}_{enc}.npy")
|
| 44 |
+
return torch.from_numpy(np.load(f).astype(np.float32)).to(dev) if os.path.exists(f) else None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
IMG = {e: load("img_he", e) for e in args.img} # (n_img, 8, D)
|
| 48 |
+
TXT = {e: (lambda a: a[:, 0] if a is not None else None)(load("txt_text", e)) for e in args.txt} # (n_txt, D)
|
| 49 |
+
t_img = torch.as_tensor(T.img_idx.values, device=dev)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def mlp(din, dout, hid=512): # same head as 12_train_text_pathgen.py / 09_bench.py
|
| 53 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 54 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def r1(a, b):
|
| 58 |
+
s = a @ b.T
|
| 59 |
+
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def eval_pairs(t_ids, rng):
|
| 63 |
+
"""one text per image (random) -> aligned (img_ids, txt_ids) without duplicate images"""
|
| 64 |
+
sub = T.loc[t_ids]
|
| 65 |
+
pick = sub.groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False).values
|
| 66 |
+
return T.img_idx.values[pick], pick
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def train(ie, te, mode, tr_t, va_t, seed=0):
|
| 70 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 71 |
+
X, Tx = IMG[ie], TXT[te]
|
| 72 |
+
th = mlp(Tx.shape[1], 512 if mode == "two_heads" else X.shape[-1]).to(dev)
|
| 73 |
+
ih = mlp(X.shape[-1], 512).to(dev) if mode == "two_heads" else nn.Identity()
|
| 74 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 75 |
+
opt = torch.optim.AdamW(list(th.parameters()) + list(ih.parameters()) + [logit], lr=args.lr, weight_decay=0.05)
|
| 76 |
+
steps = args.epochs * max(1, len(tr_t) // args.bs)
|
| 77 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=steps, pct_start=0.05)
|
| 78 |
+
va_i, va_tt = eval_pairs(va_t, np.random.default_rng(0))
|
| 79 |
+
|
| 80 |
+
def enc_img(ids, view=None):
|
| 81 |
+
return F.normalize(ih(X[ids, 0 if view is None else view]), dim=-1)
|
| 82 |
+
|
| 83 |
+
def enc_txt(ids):
|
| 84 |
+
return F.normalize(th(Tx[ids]), dim=-1)
|
| 85 |
+
|
| 86 |
+
best, state = -1, None
|
| 87 |
+
for ep in range(args.epochs):
|
| 88 |
+
th.train(); ih.train()
|
| 89 |
+
perm = rng.permutation(tr_t)
|
| 90 |
+
for s in range(0, len(perm) - args.bs + 1, args.bs):
|
| 91 |
+
b = torch.as_tensor(perm[s:s + args.bs], device=dev)
|
| 92 |
+
ii = t_img[b]
|
| 93 |
+
zi = enc_img(ii, torch.randint(0, 8, (len(b),), device=dev))
|
| 94 |
+
zt = enc_txt(b)
|
| 95 |
+
lg = logit.exp().clamp(max=100) * zi @ zt.T
|
| 96 |
+
pos = (ii[:, None] == ii[None, :]).float() # same image -> positive (multi-positive InfoNCE)
|
| 97 |
+
tgt = pos / pos.sum(1, keepdim=True)
|
| 98 |
+
loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
|
| 99 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 100 |
+
th.eval(); ih.eval()
|
| 101 |
+
with torch.no_grad():
|
| 102 |
+
zi, zt = enc_img(torch.as_tensor(va_i, device=dev)), enc_txt(torch.as_tensor(va_tt, device=dev))
|
| 103 |
+
v = (r1(zi, zt) + r1(zt, zi)) / 2
|
| 104 |
+
if v > best:
|
| 105 |
+
best = v
|
| 106 |
+
state = ({k: x.clone() for k, x in th.state_dict().items()}, {k: x.clone() for k, x in ih.state_dict().items()})
|
| 107 |
+
th.load_state_dict(state[0]); ih.load_state_dict(state[1]); th.eval(); ih.eval()
|
| 108 |
+
return (lambda i_ids, t_ids: (enc_img(torch.as_tensor(i_ids, device=dev)).cpu(), enc_txt(torch.as_tensor(t_ids, device=dev)).cpu())), best
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
folds = list(GroupKFold(5).split(I, groups=I.patient))
|
| 112 |
+
rows = []
|
| 113 |
+
for k, (trv_img, te_img) in enumerate(folds):
|
| 114 |
+
pats = np.random.default_rng(k).permutation(sorted(I.patient.iloc[trv_img].unique()))
|
| 115 |
+
va_p = set(pats[: max(1, len(pats) // 10)])
|
| 116 |
+
img_split = pd.Series("test", index=I.index)
|
| 117 |
+
img_split.iloc[trv_img] = np.where(I.patient.iloc[trv_img].isin(va_p), "val", "train")
|
| 118 |
+
tsplit = img_split.loc[T.img_idx].values
|
| 119 |
+
tr_t, va_t, te_t = (T.index[tsplit == s].values for s in ["train", "val", "test"])
|
| 120 |
+
# evaluation sets: per text type and 'all', one text per image
|
| 121 |
+
ev = {"all": eval_pairs(te_t, np.random.default_rng(k))}
|
| 122 |
+
for tt in TTYPES:
|
| 123 |
+
ids = te_t[T.ttype.values[te_t] == tt]
|
| 124 |
+
if len(ids) >= 20:
|
| 125 |
+
ev[tt] = eval_pairs(ids, np.random.default_rng(k))
|
| 126 |
+
for ie in args.img:
|
| 127 |
+
for te in args.txt:
|
| 128 |
+
if IMG[ie] is None or TXT[te] is None:
|
| 129 |
+
continue
|
| 130 |
+
for mode in (["zeroshot"] if ie == te else []) + ["text_head", "two_heads"]:
|
| 131 |
+
if mode == "zeroshot":
|
| 132 |
+
f = lambda i_ids, t_ids: (F.normalize(IMG[ie][torch.as_tensor(i_ids, device=dev), 0], dim=-1).cpu(),
|
| 133 |
+
F.normalize(TXT[te][torch.as_tensor(t_ids, device=dev)], dim=-1).cpu())
|
| 134 |
+
val = np.nan
|
| 135 |
+
else:
|
| 136 |
+
f, val = train(ie, te, mode, tr_t, va_t)
|
| 137 |
+
for name, (i_ids, t_ids) in ev.items():
|
| 138 |
+
with torch.no_grad():
|
| 139 |
+
zi, zt = f(i_ids, t_ids)
|
| 140 |
+
for q, g, d in [(zi, zt, "img->text"), (zt, zi, "text->img")]:
|
| 141 |
+
rows.append({"eval": name, "fold": k, "img_enc": ie, "txt_enc": te, "mode": mode, "dir": d,
|
| 142 |
+
"val_r1": val, **metrics(q, g)})
|
| 143 |
+
print(f"fold {k} {ie:11s} {te:11s} done (n_train_pairs={len(tr_t)}, n_test_img={len(ev['all'][0])})", flush=True)
|
| 144 |
+
pd.DataFrame(rows).to_csv(os.path.join(OUT, f"pairing_long{args.tag}.csv"), index=False)
|
| 145 |
+
|
| 146 |
+
R = pd.DataFrame(rows)
|
| 147 |
+
cols = ["R@1", "R@5", "R@10", "pair_acc", "R@1_g100"]
|
| 148 |
+
S = R.groupby(["eval", "mode", "img_enc", "txt_enc", "fold"])[cols + ["N"]].mean().groupby(["eval", "mode", "img_enc", "txt_enc"]).agg(["mean", "std"])
|
| 149 |
+
S.round(4).to_csv(os.path.join(OUT, f"pairing_summary{args.tag}.csv"))
|
| 150 |
+
pd.set_option("display.width", 250); pd.set_option("display.max_rows", 500)
|
| 151 |
+
for e in ["all"] + TTYPES:
|
| 152 |
+
print(f"\n=== eval: {e} (mean over both directions and 5 folds) ===")
|
| 153 |
+
print(R[R["eval"] == e].groupby(["mode", "img_enc", "txt_enc"])[cols + ["N"]].mean().round(3).sort_values("R@1_g100", ascending=False).to_string())
|
experiments/21_hest_prep.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gene-expression modality: HEST-1k human Visium spots (H&E patch + whole-transcriptome spot expression).
|
| 2 |
+
|
| 3 |
+
Input ../HEST/visium_humandisease_adata.pickle list of 421 AnnData (1% spot subsample per sample)
|
| 4 |
+
../HEST/UNI/visium_hest_univ2_imagefeature.pickle list of 421 (n_spot, 1536) UNI2-h spot features (aligned)
|
| 5 |
+
../HEST/prov-gigapath-conchinside/visium_hest_gigapath_imagefeature.pickle (GigaPath, aligned)
|
| 6 |
+
../HEST/HEST_v1_1_0.csv sample metadata; list order = Visium/Homo sapiens rows
|
| 7 |
+
Kept samples whose panel covers the common whole-transcriptome genes (targeted ~1k-gene panels dropped).
|
| 8 |
+
Groups for CV: study + patient (patient missing -> whole study), i.e. no patient leaks across folds.
|
| 9 |
+
Outputs cache/hest/
|
| 10 |
+
meta.csv one row per spot (sample id, organ, oncotree, group, total counts)
|
| 11 |
+
counts.npz raw counts (n_spot, n_common_genes) sparse + gene names
|
| 12 |
+
hvg.npy log1p(CP10k) of the top-2000 HVGs (n_spot, 2000) float32 -> MLP gene encoder input
|
| 13 |
+
he_uni2.npy, he_gigapath.npy (n_spot, 1, 1536) float16 -> frozen H&E anchor
|
| 14 |
+
"""
|
| 15 |
+
import os
|
| 16 |
+
import pickle
|
| 17 |
+
import weakref
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
import scipy.sparse as sp
|
| 22 |
+
import torch
|
| 23 |
+
from anndata._core import file_backing
|
| 24 |
+
|
| 25 |
+
from common import CACHE, ROOT
|
| 26 |
+
|
| 27 |
+
HEST = os.path.join(ROOT, "..", "HEST")
|
| 28 |
+
OUT = os.path.join(CACHE, "hest")
|
| 29 |
+
os.makedirs(OUT, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _ss(self, state): # old anndata pickles store '_adata' instead of '_adata_ref'
|
| 33 |
+
self.__dict__ = state.copy()
|
| 34 |
+
ref = state.get("_adata_ref", state.get("_adata"))
|
| 35 |
+
self.__dict__["_adata_ref"] = weakref.ref(ref) if ref is not None else (lambda: None)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
file_backing.AnnDataFileManager.__setstate__ = _ss
|
| 39 |
+
A = pickle.load(open(os.path.join(HEST, "visium_humandisease_adata.pickle"), "rb"))
|
| 40 |
+
FU = pickle.load(open(os.path.join(HEST, "UNI", "visium_hest_univ2_imagefeature.pickle"), "rb"))
|
| 41 |
+
FG = pickle.load(open(os.path.join(HEST, "prov-gigapath-conchinside", "visium_hest_gigapath_imagefeature.pickle"), "rb"))
|
| 42 |
+
H = pd.read_csv(os.path.join(HEST, "HEST_v1_1_0.csv"))
|
| 43 |
+
H = H[(H.st_technology == "Visium") & (H.species == "Homo sapiens")].reset_index(drop=True)
|
| 44 |
+
assert len(H) == len(A) == len(FU) == len(FG)
|
| 45 |
+
|
| 46 |
+
# --- check the list order against the metadata (full-res size from thumbnail / scalefactor, oncotree code)
|
| 47 |
+
ok = []
|
| 48 |
+
for a, (_, h) in zip(A, H.iterrows()):
|
| 49 |
+
st = a.uns["spatial"]["ST"]
|
| 50 |
+
sh = np.array(st["images"]["downscaled_fullres"].shape[:2]) / st["scalefactors"]["tissue_downscaled_fullres_scalef"]
|
| 51 |
+
size_ok = np.allclose(sh, [h.fullres_px_height, h.fullres_px_width], rtol=0.02)
|
| 52 |
+
onc = a.obs.oncotree_code.iloc[0]
|
| 53 |
+
onc_ok = (pd.isna(onc) and pd.isna(h.oncotree_code)) or onc == h.oncotree_code
|
| 54 |
+
ok.append(size_ok and onc_ok)
|
| 55 |
+
print(f"metadata order check: {sum(ok)}/{len(ok)} samples match (image size + oncotree)")
|
| 56 |
+
assert np.mean(ok) > 0.95
|
| 57 |
+
|
| 58 |
+
# --- harmonise gene names to HGNC symbols (samples use symbols, Ensembl ids + SYMBOL column, or 'GRCh38__' prefixes)
|
| 59 |
+
e2s = {}
|
| 60 |
+
for a in A:
|
| 61 |
+
if "gene_ids" in a.var and not str(a.var_names[0]).startswith(("ENSG", "GRCh")):
|
| 62 |
+
e2s.update(dict(zip(a.var.gene_ids.astype(str), a.var_names)))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def symbols(a):
|
| 66 |
+
if "SYMBOL" in a.var:
|
| 67 |
+
return a.var.SYMBOL.astype(str).tolist()
|
| 68 |
+
out = []
|
| 69 |
+
for x in map(str, a.var_names):
|
| 70 |
+
x = x.split("__")[-1] if x.startswith("GRCh") else x
|
| 71 |
+
out.append(e2s.get(x.split(".")[0], x) if x.startswith("ENSG") else x)
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
for a in A:
|
| 76 |
+
a.var_names = symbols(a)
|
| 77 |
+
|
| 78 |
+
# --- common gene set over whole-transcriptome samples
|
| 79 |
+
wt = [i for i, a in enumerate(A) if a.n_vars >= 17000]
|
| 80 |
+
common = set(A[wt[0]].var_names)
|
| 81 |
+
for i in wt[1:]:
|
| 82 |
+
common &= set(A[i].var_names)
|
| 83 |
+
common = sorted(g for g in common if not g.startswith(("MT-", "RPL", "RPS")))
|
| 84 |
+
print(f"whole-transcriptome samples: {len(wt)}/{len(A)}; common genes (no MT/ribo): {len(common)}")
|
| 85 |
+
|
| 86 |
+
rows, Xs, fu, fg = [], [], [], []
|
| 87 |
+
for i in wt:
|
| 88 |
+
a, h = A[i], H.iloc[i]
|
| 89 |
+
a = a[:, ~a.var_names.duplicated()]
|
| 90 |
+
X = a[:, common].X
|
| 91 |
+
Xs.append(sp.csr_matrix(X, dtype=np.float32))
|
| 92 |
+
fu.append(FU[i].numpy()); fg.append(FG[i].numpy())
|
| 93 |
+
study = str(h.dataset_title)
|
| 94 |
+
grp = f"{study}|{h.patient}" if pd.notna(h.patient) else study
|
| 95 |
+
for b in a.obs_names:
|
| 96 |
+
rows.append({"sample": h.id, "spot": b, "organ": h.organ, "oncotree": h.oncotree_code,
|
| 97 |
+
"disease": h.disease_state, "study": study, "patient": h.patient, "group": grp})
|
| 98 |
+
M = pd.DataFrame(rows)
|
| 99 |
+
X = sp.vstack(Xs).tocsr()
|
| 100 |
+
M["total_counts"] = np.asarray(X.sum(1)).ravel()
|
| 101 |
+
keep = (M.total_counts >= 200).values
|
| 102 |
+
M, X = M[keep].reset_index(drop=True), X[keep]
|
| 103 |
+
fu, fg = np.concatenate(fu)[keep], np.concatenate(fg)[keep]
|
| 104 |
+
M["n_genes"] = np.asarray((X > 0).sum(1)).ravel()
|
| 105 |
+
|
| 106 |
+
# log1p CP10k, HVG = top-2000 by variance of the log-normalised values (genes expressed in >=1% spots)
|
| 107 |
+
L = sp.diags(1e4 / M.total_counts.values) @ X
|
| 108 |
+
L.data = np.log1p(L.data)
|
| 109 |
+
frac = np.asarray((X > 0).mean(0)).ravel()
|
| 110 |
+
mu = np.asarray(L.mean(0)).ravel()
|
| 111 |
+
var = np.asarray(L.multiply(L).mean(0)).ravel() - mu ** 2
|
| 112 |
+
var[frac < 0.01] = -1
|
| 113 |
+
hvg = np.sort(np.argsort(-var)[:2000])
|
| 114 |
+
np.save(os.path.join(OUT, "hvg.npy"), L[:, hvg].toarray().astype(np.float32))
|
| 115 |
+
sp.save_npz(os.path.join(OUT, "counts.npz"), X)
|
| 116 |
+
pd.Series(common).to_csv(os.path.join(OUT, "genes.csv"), index=False, header=["gene"])
|
| 117 |
+
pd.Series(np.array(common)[hvg]).to_csv(os.path.join(OUT, "hvg_genes.csv"), index=False, header=["gene"])
|
| 118 |
+
np.save(os.path.join(OUT, "he_uni2.npy"), fu.astype(np.float16)[:, None])
|
| 119 |
+
np.save(os.path.join(OUT, "he_gigapath.npy"), fg.astype(np.float16)[:, None])
|
| 120 |
+
M.to_csv(os.path.join(OUT, "meta.csv"), index_label="idx")
|
| 121 |
+
print(f"spots kept: {len(M)} (dropped {int((~keep).sum())} with <200 counts); samples {M['sample'].nunique()}, "
|
| 122 |
+
f"groups {M.group.nunique()}, studies {M.study.nunique()}")
|
| 123 |
+
print("organs:", M.organ.value_counts().to_dict())
|
| 124 |
+
print("median counts / genes per spot:", M.total_counts.median(), M.n_genes.median())
|
experiments/22_extract_gene_fm.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen single-cell foundation-model embeddings of HEST Visium spot expression (each spot = one pseudo-cell).
|
| 2 |
+
|
| 3 |
+
python 22_extract_gene_fm.py scgpt | geneformer
|
| 4 |
+
|
| 5 |
+
scgpt : wanglab/scGPT-human (same re-implementation / strict load as 15_extract_marker_fm.py); input = expressed
|
| 6 |
+
genes among the 2000 HVGs that are in the scGPT vocab, raw counts -> log1p CP10k -> per-spot quantile
|
| 7 |
+
binning (51 bins); at most 1200 genes (random subset, as scgpt.tasks.embed_data max_length=1200);
|
| 8 |
+
<cls> output, L2-normalised.
|
| 9 |
+
geneformer : ctheodoris/Geneformer V2-104M; input = rank-value encoding of all common genes (count / total * 1e4 /
|
| 10 |
+
gc104M gene median, descending, nonzero), truncated to 4096 tokens incl. <cls>/<eos>;
|
| 11 |
+
penultimate layer, mean over gene tokens.
|
| 12 |
+
Output cache/hest/gene_<name>.npy (n_spot, 1, D) float16
|
| 13 |
+
"""
|
| 14 |
+
import importlib
|
| 15 |
+
import os
|
| 16 |
+
import pickle
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import pandas as pd
|
| 22 |
+
import scipy.sparse as sp
|
| 23 |
+
import torch
|
| 24 |
+
|
| 25 |
+
from common import CACHE
|
| 26 |
+
|
| 27 |
+
M15 = importlib.import_module("15_extract_marker_fm")
|
| 28 |
+
OUT = os.path.join(CACHE, "hest")
|
| 29 |
+
name = sys.argv[1]
|
| 30 |
+
dev = "cuda"
|
| 31 |
+
X = sp.load_npz(os.path.join(OUT, "counts.npz")).tocsr()
|
| 32 |
+
genes = pd.read_csv(os.path.join(OUT, "genes.csv")).gene.values
|
| 33 |
+
tot = np.asarray(X.sum(1)).ravel()
|
| 34 |
+
t0 = time.time()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def batches(seqs, bs):
|
| 38 |
+
for i in range(0, len(seqs), bs):
|
| 39 |
+
yield seqs[i:i + bs]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
if name == "scgpt":
|
| 43 |
+
import json
|
| 44 |
+
from huggingface_hub import hf_hub_download
|
| 45 |
+
args = json.load(open(hf_hub_download(M15.SCGPT_REPO, "args.json")))
|
| 46 |
+
vocab = json.load(open(hf_hub_download(M15.SCGPT_REPO, "vocab.json")))
|
| 47 |
+
sd = torch.load(hf_hub_download(M15.SCGPT_REPO, "best_model.pt"), map_location="cpu", weights_only=True)
|
| 48 |
+
sd = {k.replace("self_attn.Wqkv.", "self_attn.in_proj_"): v for k, v in sd.items()}
|
| 49 |
+
PAD, CLS = vocab[args["pad_token"]], vocab["<cls>"]
|
| 50 |
+
model = M15.ScGPT(len(vocab), args["embsize"], args["nheads"], args["d_hid"], args["nlayers"], PAD)
|
| 51 |
+
model.load_state_dict(sd, strict=True)
|
| 52 |
+
model.eval().to(dev)
|
| 53 |
+
hvg = pd.read_csv(os.path.join(OUT, "hvg_genes.csv")).gene.values
|
| 54 |
+
gi = np.array([i for i, g in enumerate(genes) if g in set(hvg) and g in vocab])
|
| 55 |
+
gid = np.array([vocab[g] for g in genes[gi]])
|
| 56 |
+
L = sp.diags(1e4 / tot) @ X[:, gi]
|
| 57 |
+
L.data = np.log1p(L.data)
|
| 58 |
+
L = L.tocsr()
|
| 59 |
+
rng = np.random.default_rng(0)
|
| 60 |
+
seqs = []
|
| 61 |
+
for r in range(L.shape[0]):
|
| 62 |
+
row = L.getrow(r)
|
| 63 |
+
nz, v = row.indices, row.data
|
| 64 |
+
if len(nz) > 1199:
|
| 65 |
+
keep = np.sort(rng.choice(len(nz), 1199, replace=False))
|
| 66 |
+
nz, v = nz[keep], v[keep]
|
| 67 |
+
vals = M15.scgpt_binning(v, args["n_bins"], rng).astype(np.float32)
|
| 68 |
+
seqs.append((np.r_[CLS, gid[nz]], np.r_[np.float32(args["pad_value"]), vals]))
|
| 69 |
+
print(f"scGPT: {len(gi)} HVGs in vocab; genes/spot median {np.median([len(s[0]) - 1 for s in seqs])}", flush=True)
|
| 70 |
+
embs = []
|
| 71 |
+
with torch.no_grad():
|
| 72 |
+
for b in batches(seqs, 32):
|
| 73 |
+
Lm = max(len(g) for g, _ in b)
|
| 74 |
+
g = torch.full((len(b), Lm), PAD, dtype=torch.long)
|
| 75 |
+
v = torch.full((len(b), Lm), float(args["pad_value"]))
|
| 76 |
+
for j, (gg, vv) in enumerate(b):
|
| 77 |
+
g[j, :len(gg)], v[j, :len(vv)] = torch.from_numpy(gg), torch.from_numpy(vv)
|
| 78 |
+
g, v = g.to(dev), v.to(dev)
|
| 79 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 80 |
+
h = model.encode(g, v, g.eq(PAD))
|
| 81 |
+
embs.append(h[:, 0].float().cpu())
|
| 82 |
+
E = torch.cat(embs).numpy()
|
| 83 |
+
E /= np.linalg.norm(E, axis=1, keepdims=True)
|
| 84 |
+
else:
|
| 85 |
+
from transformers import BertModel
|
| 86 |
+
tokd = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/token_dictionary_gc104M.pkl"), "rb"))
|
| 87 |
+
medd = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/gene_median_dictionary_gc104M.pkl"), "rb"))
|
| 88 |
+
n2id = pickle.load(open(M15.hf_file(M15.GF_REPO, "geneformer/gene_name_id_dict_gc104M.pkl"), "rb"))
|
| 89 |
+
mdir = os.path.dirname(M15.hf_file(M15.GF_REPO, "Geneformer-V2-104M/model.safetensors"))
|
| 90 |
+
M15.hf_file(M15.GF_REPO, "Geneformer-V2-104M/config.json")
|
| 91 |
+
gi = np.array([i for i, g in enumerate(genes) if g in n2id and n2id[g] in tokd and n2id[g] in medd])
|
| 92 |
+
tok = np.array([tokd[n2id[g]] for g in genes[gi]])
|
| 93 |
+
med = np.array([medd[n2id[g]] for g in genes[gi]])
|
| 94 |
+
CLS, EOS, PAD = tokd["<cls>"], tokd["<eos>"], tokd["<pad>"]
|
| 95 |
+
model = BertModel.from_pretrained(mdir, add_pooling_layer=False, attn_implementation="sdpa").eval().to(dev)
|
| 96 |
+
Xg = (sp.diags(1e4 / tot) @ X[:, gi]).tocsr()
|
| 97 |
+
seqs = []
|
| 98 |
+
for r in range(Xg.shape[0]):
|
| 99 |
+
row = Xg.getrow(r)
|
| 100 |
+
nz, v = row.indices, row.data / med[row.indices]
|
| 101 |
+
order = nz[np.argsort(-v, kind="stable")][:4094]
|
| 102 |
+
seqs.append([CLS] + tok[order].tolist() + [EOS])
|
| 103 |
+
print(f"Geneformer: {len(gi)} genes in dictionary; tokens/spot median {np.median([len(s) for s in seqs])}", flush=True)
|
| 104 |
+
order_len = np.argsort([len(s) for s in seqs]) # length-sorted batches (less padding), restored below
|
| 105 |
+
embs = np.zeros((len(seqs), model.config.hidden_size), np.float32)
|
| 106 |
+
with torch.no_grad():
|
| 107 |
+
for k in range(0, len(seqs), 8):
|
| 108 |
+
idx = order_len[k:k + 8]
|
| 109 |
+
b = [seqs[i] for i in idx]
|
| 110 |
+
Lm = max(map(len, b))
|
| 111 |
+
ids = torch.full((len(b), Lm), PAD, dtype=torch.long)
|
| 112 |
+
for j, s in enumerate(b):
|
| 113 |
+
ids[j, :len(s)] = torch.tensor(s)
|
| 114 |
+
ids = ids.to(dev)
|
| 115 |
+
att = (ids != PAD).long()
|
| 116 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 117 |
+
h = model(input_ids=ids, attention_mask=att, output_hidden_states=True).hidden_states[-2].float()
|
| 118 |
+
gm = att.clone().float()
|
| 119 |
+
gm[:, 0] = 0
|
| 120 |
+
gm[torch.arange(len(b)), att.sum(1) - 1] = 0
|
| 121 |
+
embs[idx] = ((h * gm[..., None]).sum(1) / gm.sum(1, keepdim=True).clamp(min=1)).cpu().numpy()
|
| 122 |
+
if k % 800 == 0:
|
| 123 |
+
print(f" {k}/{len(seqs)} {time.time() - t0:.0f}s", flush=True)
|
| 124 |
+
E = embs
|
| 125 |
+
np.save(os.path.join(OUT, f"gene_{name}.npy"), E.astype(np.float16)[:, None])
|
| 126 |
+
print(f"[{name}] saved {E.shape} finite={np.isfinite(E).all()} {time.time() - t0:.0f}s")
|
experiments/23_bind_gene.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bind the gene-expression modality (HEST Visium spots) to the frozen H&E anchor (ImageBind recipe).
|
| 2 |
+
|
| 3 |
+
anchor (frozen, no head): UNI2-h (same model as the ORION / PathGen anchor) or GigaPath spot features
|
| 4 |
+
gene encoders: mlp MLP on log1p-CP10k of the 2000 HVGs (from scratch)
|
| 5 |
+
fm:scgpt frozen scGPT-human spot embedding (22_) + MLP head
|
| 6 |
+
fm:geneformer frozen Geneformer-V2 spot embedding (22_) + MLP head
|
| 7 |
+
loss: symmetric InfoNCE(he, gene); the gene head maps into the raw anchor space, so the same space also holds the
|
| 8 |
+
ORION mIF head and the PathGen text head (emergent gene<->protein, gene<->text evaluated in 24_).
|
| 9 |
+
CV: 5-fold GroupKFold over study+patient groups (21_), 10% of training groups held out for early stopping.
|
| 10 |
+
Outputs runs/gene/<anchor>_<enc>_fold<k>/{head.pt, test.pt}, results/gene_retrieval.csv
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
import torch
|
| 18 |
+
import torch.nn as nn
|
| 19 |
+
import torch.nn.functional as F
|
| 20 |
+
from sklearn.model_selection import GroupKFold
|
| 21 |
+
|
| 22 |
+
from common import CACHE, ROOT, RUNS, metrics
|
| 23 |
+
|
| 24 |
+
p = argparse.ArgumentParser()
|
| 25 |
+
p.add_argument("--anchors", nargs="+", default=["uni2", "gigapath"])
|
| 26 |
+
p.add_argument("--encs", nargs="+", default=["mlp", "fm:scgpt", "fm:geneformer"])
|
| 27 |
+
p.add_argument("--epochs", type=int, default=100)
|
| 28 |
+
p.add_argument("--bs", type=int, default=256)
|
| 29 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 30 |
+
args = p.parse_args()
|
| 31 |
+
dev = "cuda"
|
| 32 |
+
H = os.path.join(CACHE, "hest")
|
| 33 |
+
M = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def feats(enc):
|
| 37 |
+
f = os.path.join(H, "hvg.npy" if enc == "mlp" else f"gene_{enc.split(':')[1]}.npy")
|
| 38 |
+
x = np.load(f).astype(np.float32)
|
| 39 |
+
return torch.from_numpy(x.reshape(len(x), -1)).to(dev)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def mlp(din, dout, hid=512): # same head as 09_bench / 12_ / 20_
|
| 43 |
+
return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
|
| 44 |
+
nn.LayerNorm(hid), nn.Linear(hid, dout))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def r1(a, b):
|
| 48 |
+
s = a @ b.T
|
| 49 |
+
return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def train(A, G, tr, va, seed=0):
|
| 53 |
+
torch.manual_seed(seed); rng = np.random.default_rng(seed)
|
| 54 |
+
head = mlp(G.shape[1], A.shape[1]).to(dev)
|
| 55 |
+
logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
|
| 56 |
+
opt = torch.optim.AdamW(list(head.parameters()) + [logit], lr=args.lr, weight_decay=0.05)
|
| 57 |
+
steps = args.epochs * max(1, len(tr) // args.bs)
|
| 58 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=steps, pct_start=0.05)
|
| 59 |
+
za_va = F.normalize(A[va], dim=-1)
|
| 60 |
+
best, state = -1, None
|
| 61 |
+
for ep in range(args.epochs):
|
| 62 |
+
head.train()
|
| 63 |
+
perm = rng.permutation(tr)
|
| 64 |
+
for s in range(0, len(perm) - args.bs + 1, args.bs):
|
| 65 |
+
b = torch.as_tensor(perm[s:s + args.bs], device=dev)
|
| 66 |
+
za, zg = F.normalize(A[b], dim=-1), F.normalize(head(G[b]), dim=-1)
|
| 67 |
+
lg = logit.exp().clamp(max=100) * za @ zg.T
|
| 68 |
+
t = torch.arange(len(b), device=dev)
|
| 69 |
+
loss = (F.cross_entropy(lg, t) + F.cross_entropy(lg.T, t)) / 2
|
| 70 |
+
opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
|
| 71 |
+
head.eval()
|
| 72 |
+
with torch.no_grad():
|
| 73 |
+
zg = F.normalize(head(G[va]), dim=-1)
|
| 74 |
+
v = (r1(za_va, zg) + r1(zg, za_va)) / 2
|
| 75 |
+
if v > best:
|
| 76 |
+
best, state = v, {k: x.clone() for k, x in head.state_dict().items()}
|
| 77 |
+
head.load_state_dict(state); head.eval()
|
| 78 |
+
return head, best
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
folds = list(GroupKFold(5).split(M, groups=M.group))
|
| 82 |
+
rows = []
|
| 83 |
+
for an in args.anchors:
|
| 84 |
+
A = torch.from_numpy(np.load(os.path.join(H, f"he_{an}.npy"))[:, 0].astype(np.float32)).to(dev)
|
| 85 |
+
for enc in args.encs:
|
| 86 |
+
G = feats(enc)
|
| 87 |
+
for k, (trv, te) in enumerate(folds):
|
| 88 |
+
grp = np.random.default_rng(k).permutation(sorted(M.group.iloc[trv].unique()))
|
| 89 |
+
va_g = set(grp[: max(1, len(grp) // 10)])
|
| 90 |
+
is_va = M.group.iloc[trv].isin(va_g).values
|
| 91 |
+
tr, va = trv[~is_va], trv[is_va]
|
| 92 |
+
head, best = train(A, G, tr, va)
|
| 93 |
+
with torch.no_grad():
|
| 94 |
+
za, zg = F.normalize(A[te], dim=-1).cpu(), F.normalize(head(G[te]), dim=-1).cpu()
|
| 95 |
+
d = os.path.join(RUNS, "gene", f"{an}_{enc.replace(':', '-')}_fold{k}")
|
| 96 |
+
os.makedirs(d, exist_ok=True)
|
| 97 |
+
torch.save(head.state_dict(), os.path.join(d, "head.pt"))
|
| 98 |
+
torch.save({"idx": te, "he": za, "gene": zg}, os.path.join(d, "test.pt"))
|
| 99 |
+
for q, g, name in [(za, zg, "he->gene"), (zg, za, "gene->he")]:
|
| 100 |
+
rows.append({"anchor": an, "gene_enc": enc, "fold": k, "dir": name, "val_r1": best, **metrics(q, g)})
|
| 101 |
+
print(f"{an} {enc} fold{k}: val {best:.3f} test R@1_g100 {np.mean([r['R@1_g100'] for r in rows[-2:]]):.3f} "
|
| 102 |
+
f"pair_acc {np.mean([r['pair_acc'] for r in rows[-2:]]):.3f} (n_test={len(te)})", flush=True)
|
| 103 |
+
pd.DataFrame(rows).to_csv(os.path.join(ROOT, "results", "gene_retrieval.csv"), index=False)
|
| 104 |
+
R = pd.DataFrame(rows)
|
| 105 |
+
g = R.groupby(["anchor", "gene_enc", "fold"])[["R@1", "R@1_g100", "R@10", "pair_acc"]].mean()
|
| 106 |
+
print(g.groupby(["anchor", "gene_enc"]).agg(["mean", "std"]).round(3).to_string())
|
experiments/24_eval_gene_emergent.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Emergent alignment of the gene-expression modality (HEST, 23_) with protein (ORION mIF, 17_/09_) and text
|
| 2 |
+
(PathGen, 12_). All heads were trained separately against the same frozen UNI2 H&E space; gene never co-occurs with
|
| 3 |
+
mIF or text in training, and no dataset measures RNA + protein + text on the same tissue, so 1:1 pairing accuracy
|
| 4 |
+
cannot be computed for these pairs. Instead:
|
| 5 |
+
|
| 6 |
+
(A) protein <-> gene, cross-dataset neighbour transfer
|
| 7 |
+
protein->gene: each ORION test tile (mIF embedding) retrieves its k=20 nearest HEST spots by gene embedding;
|
| 8 |
+
predicted RNA level = mean log1p-CP10k of the matching gene over those spots;
|
| 9 |
+
score = Spearman(predicted RNA, measured protein = marker+ cells / nuclei) over tiles (>=20 nuclei).
|
| 10 |
+
gene->protein: each HEST spot (gene embedding) retrieves its k=20 nearest ORION tiles by mIF embedding;
|
| 11 |
+
Spearman(predicted protein, measured RNA of the spot).
|
| 12 |
+
references : he->gene (ORION UNI2 vs HEST gene head; trained modality pair, cross-dataset),
|
| 13 |
+
he->he (UNI2 vs UNI2, image similarity only), and the same with labels shuffled (chance ~0).
|
| 14 |
+
galleries : all HEST spots, and bowel-only spots (same organ as ORION colorectal cancer).
|
| 15 |
+
(B) text <-> gene concept probes: prompts.json through the PathGen-trained text head, scored against HEST test-fold
|
| 16 |
+
gene embeddings (and UNI2 H&E of the same spots); label = RNA of the concept gene; Spearman + AUROC (top quartile).
|
| 17 |
+
Outputs results/gene_emergent_protein.csv, results/gene_emergent_text.csv
|
| 18 |
+
"""
|
| 19 |
+
import glob
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
import scipy.sparse as sp
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
from scipy.stats import spearmanr
|
| 29 |
+
from sklearn.metrics import roc_auc_score
|
| 30 |
+
|
| 31 |
+
from common import CACHE, ROOT, RUNS, load_all_tiles
|
| 32 |
+
|
| 33 |
+
OUT = os.path.join(ROOT, "results")
|
| 34 |
+
H = os.path.join(CACHE, "hest")
|
| 35 |
+
K = 20
|
| 36 |
+
M = pd.read_csv(os.path.join(H, "meta.csv"))
|
| 37 |
+
genes = pd.read_csv(os.path.join(H, "genes.csv")).gene.values
|
| 38 |
+
X = sp.load_npz(os.path.join(H, "counts.npz")).tocsr()
|
| 39 |
+
L = sp.diags(1e4 / np.asarray(X.sum(1)).ravel()) @ X
|
| 40 |
+
L.data = np.log1p(L.data)
|
| 41 |
+
gpos = {g: i for i, g in enumerate(genes)}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def rna(gs):
|
| 45 |
+
return np.asarray(L[:, [gpos[g] for g in gs]].mean(1)).ravel()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
PROT2RNA = {"CD45": ["PTPRC"], "Pan-CK": ["KRT8", "EPCAM"], "E-cadherin": ["CDH1"], "SMA": ["ACTA2"],
|
| 49 |
+
"Ki67": ["MKI67"], "CD31": ["PECAM1"], "CD68": ["CD68"], "CD163": ["CD163"], "CD3e": ["CD3E"],
|
| 50 |
+
"CD4": ["CD4"], "CD8a": ["CD8A"], "FOXP3": ["FOXP3"], "CD20": ["MS4A1"], "PD-L1": ["CD274"],
|
| 51 |
+
"PD-1": ["PDCD1"]}
|
| 52 |
+
RNA = {m: rna(g) for m, g in PROT2RNA.items()}
|
| 53 |
+
|
| 54 |
+
# ---------------- HEST: gene embeddings of every spot from the fold whose head did not train on it
|
| 55 |
+
hest = {}
|
| 56 |
+
for f in glob.glob(os.path.join(RUNS, "gene", "uni2_*_fold*", "test.pt")):
|
| 57 |
+
enc = os.path.basename(os.path.dirname(f)).split("_", 1)[1].rsplit("_fold", 1)[0]
|
| 58 |
+
d = torch.load(f, weights_only=False)
|
| 59 |
+
z = hest.setdefault(enc, {"gene": torch.zeros(len(M), d["gene"].shape[1]), "he": torch.zeros(len(M), d["he"].shape[1]),
|
| 60 |
+
"fold": np.full(len(M), -1)})
|
| 61 |
+
z["gene"][d["idx"]], z["he"][d["idx"]] = d["gene"], d["he"]
|
| 62 |
+
z["fold"][d["idx"]] = int(f.split("_fold")[1].split(os.sep)[0])
|
| 63 |
+
hest = {e: z for e, z in hest.items() if (z["fold"] >= 0).all()}
|
| 64 |
+
print("gene encoders:", list(hest))
|
| 65 |
+
|
| 66 |
+
# ---------------- ORION: patient 5-fold test embeddings (mIF encoders) in the same UNI2 space
|
| 67 |
+
df = load_all_tiles()
|
| 68 |
+
nn_ = pd.read_csv(os.path.join(OUT, "tile_stats.csv")).set_index("tile_id").loc[df.tile_id, "n_nuclei"].values
|
| 69 |
+
orion = {}
|
| 70 |
+
for root in ["bench", "bench_fm"]:
|
| 71 |
+
for f in glob.glob(os.path.join(RUNS, root, "patient_5fold_*_uni2_*", "test_embeddings.pt")):
|
| 72 |
+
a = json.load(open(os.path.join(os.path.dirname(f), "train.json")))["args"]
|
| 73 |
+
E = torch.load(f, weights_only=False)
|
| 74 |
+
o = orion.setdefault(a["if_enc"], {"if": torch.zeros(len(df), E["z"]["if"].shape[1]), "he": torch.zeros(len(df), E["z"]["he"].shape[1]),
|
| 75 |
+
"seen": np.zeros(len(df), bool)})
|
| 76 |
+
t = np.asarray(E["tile_id"])
|
| 77 |
+
o["if"][t], o["he"][t], o["seen"][t] = E["z"]["if"], E["z"]["he"], True
|
| 78 |
+
orion = {e: o for e, o in orion.items() if o["seen"].all()}
|
| 79 |
+
print("mIF encoders:", list(orion))
|
| 80 |
+
tissue = nn_ >= 20
|
| 81 |
+
PROT = {m: df[f"{m}_count"].values / np.maximum(nn_, 1) for m in PROT2RNA}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def knn_mean(q, g, vals, k=K, bs=1024):
|
| 85 |
+
out = np.zeros(len(q))
|
| 86 |
+
for i in range(0, len(q), bs):
|
| 87 |
+
s = q[i:i + bs] @ g.T
|
| 88 |
+
idx = s.topk(k, dim=1).indices.numpy()
|
| 89 |
+
out[i:i + bs] = vals[idx].mean(1)
|
| 90 |
+
return out
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
rows = []
|
| 94 |
+
rng = np.random.default_rng(0)
|
| 95 |
+
for genc, hz in hest.items():
|
| 96 |
+
for gal in ["all", "bowel"]:
|
| 97 |
+
gm = np.ones(len(M), bool) if gal == "all" else (M.organ == "Bowel").values
|
| 98 |
+
for ienc, oz in orion.items():
|
| 99 |
+
queries = [("protein->gene (emergent)", oz["if"], hz["gene"]), ("he->gene (cross-dataset)", oz["he"], hz["gene"]),
|
| 100 |
+
("he->he (image only)", oz["he"], hz["he"])]
|
| 101 |
+
for kind, q, g in queries:
|
| 102 |
+
for m in PROT2RNA:
|
| 103 |
+
pred = knn_mean(q[tissue], g[gm], RNA[m][gm])
|
| 104 |
+
y = PROT[m][tissue]
|
| 105 |
+
rows.append({"gene_enc": genc, "if_enc": ienc, "gallery": gal, "kind": kind, "marker": m,
|
| 106 |
+
"spearman": spearmanr(pred, y).correlation,
|
| 107 |
+
"spearman_shuffled": spearmanr(pred, rng.permutation(y)).correlation})
|
| 108 |
+
# reverse: each HEST spot -> nearest ORION tiles
|
| 109 |
+
for kind, q, g in [("gene->protein (emergent)", hz["gene"][gm], oz["if"][tissue]), ("gene->he (cross-dataset)", hz["gene"][gm], oz["he"][tissue])]:
|
| 110 |
+
for m in PROT2RNA:
|
| 111 |
+
pred = knn_mean(q, g, PROT[m][tissue])
|
| 112 |
+
y = RNA[m][gm]
|
| 113 |
+
rows.append({"gene_enc": genc, "if_enc": ienc, "gallery": gal, "kind": kind, "marker": m,
|
| 114 |
+
"spearman": spearmanr(pred, y).correlation,
|
| 115 |
+
"spearman_shuffled": spearmanr(pred, rng.permutation(y)).correlation})
|
| 116 |
+
print("done", genc, flush=True)
|
| 117 |
+
P = pd.DataFrame(rows)
|
| 118 |
+
P.to_csv(os.path.join(OUT, "gene_emergent_protein.csv"), index=False)
|
| 119 |
+
|
| 120 |
+
# ---------------- (B) text <-> gene concept probes
|
| 121 |
+
prompts = json.load(open(os.path.join(ROOT, "prompts.json")))
|
| 122 |
+
CONCEPT_RNA = {"immune": ["PTPRC"], "tumor_epith": ["KRT8", "EPCAM"], "stroma": ["ACTA2"], "proliferation": ["MKI67"],
|
| 123 |
+
"vessels": ["PECAM1"], "macrophage": ["CD68"]}
|
| 124 |
+
TXT = sorted(os.path.basename(d).split("_", 1)[1] for d in glob.glob(os.path.join(RUNS, "text_pathgen", "uni2_*"))
|
| 125 |
+
if os.path.exists(os.path.join(CACHE, f"prompts_text_{os.path.basename(d).split('_', 1)[1]}.npy")))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def load_head(enc, din, dout):
|
| 129 |
+
h = torch.nn.Sequential(torch.nn.Linear(din, 512), torch.nn.GELU(), torch.nn.Dropout(0.1), torch.nn.Linear(512, 512),
|
| 130 |
+
torch.nn.GELU(), torch.nn.LayerNorm(512), torch.nn.Linear(512, dout))
|
| 131 |
+
h.load_state_dict(torch.load(os.path.join(RUNS, "text_pathgen", f"uni2_{enc}", "head.pt"), map_location="cpu"))
|
| 132 |
+
return h.eval()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
trow = []
|
| 136 |
+
for t in TXT:
|
| 137 |
+
prm = torch.from_numpy(np.load(os.path.join(CACHE, f"prompts_text_{t}.npy"))[:, 0].astype(np.float32))
|
| 138 |
+
with torch.no_grad():
|
| 139 |
+
zp = F.normalize(load_head(t, prm.shape[1], 1536)(prm), dim=-1)
|
| 140 |
+
keys = list(prompts)
|
| 141 |
+
for genc, hz in hest.items():
|
| 142 |
+
embs = [("he (UNI2)", hz["he"]), (f"gene ({genc})", hz["gene"])] if genc == list(hest)[0] else [(f"gene ({genc})", hz["gene"])]
|
| 143 |
+
for mod, z in embs:
|
| 144 |
+
for c, gs in CONCEPT_RNA.items():
|
| 145 |
+
s = (z @ zp[keys.index(c)]).numpy()
|
| 146 |
+
for gal in ["all", "bowel"]:
|
| 147 |
+
gm = np.ones(len(M), bool) if gal == "all" else (M.organ == "Bowel").values
|
| 148 |
+
for k in range(5):
|
| 149 |
+
sel = gm & (hz["fold"] == k)
|
| 150 |
+
y = rna(gs)[sel]
|
| 151 |
+
lab = y >= np.quantile(y, 0.75)
|
| 152 |
+
trow.append({"text_enc": t, "modality": mod, "concept": c, "gallery": gal, "fold": k,
|
| 153 |
+
"spearman": spearmanr(s[sel], y).correlation,
|
| 154 |
+
"auroc": roc_auc_score(lab, s[sel]) if 0 < lab.sum() < len(lab) else np.nan})
|
| 155 |
+
T = pd.DataFrame(trow)
|
| 156 |
+
T.to_csv(os.path.join(OUT, "gene_emergent_text.csv"), index=False)
|
| 157 |
+
pd.set_option("display.width", 250); pd.set_option("display.max_rows", 500)
|
| 158 |
+
print(P.groupby(["gallery", "kind", "if_enc", "gene_enc"])[["spearman", "spearman_shuffled"]].mean().round(3).to_string())
|
| 159 |
+
print(P[P.gallery == "bowel"].groupby(["kind", "marker"]).spearman.mean().unstack("kind").round(2).to_string())
|
| 160 |
+
print(T.groupby(["gallery", "text_enc", "modality"])[["auroc", "spearman"]].mean().round(3).to_string())
|
experiments/25_gigatime_infer.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GigaTIME (Providence/Microsoft, Cell 2025) virtual mIF inference.
|
| 2 |
+
|
| 3 |
+
Model : prov-gigatime/GigaTIME (HF, gated, Apache-2.0) -- UNet++ CNN, H&E RGB -> 23 sigmoid maps
|
| 4 |
+
(21 proteins + 2 background channels TRITC/Cy5, which are dropped here).
|
| 5 |
+
Code : https://github.com/prov-gigatime/GigaTIME (architecture re-implemented below, verbatim
|
| 6 |
+
layer layout of scripts/archs.py, so nothing from the downloaded repo is imported).
|
| 7 |
+
|
| 8 |
+
Preprocessing (as in the official notebooks): RGB/255, ImageNet mean/std normalisation, no stain norm.
|
| 9 |
+
Optional bilinear upsampling by --scale before inference (the model was trained on native-resolution
|
| 10 |
+
Providence tiles; ORION/PathGen tiles are ~0.5 um/px, so --scale 2 brings them to ~0.25 um/px = 40x).
|
| 11 |
+
The net is fully convolutional (4 poolings): images are reflect-padded to a multiple of 16 and run
|
| 12 |
+
whole; images larger than --max_full px are run as 512-px tiles with a 64-px overlap margin
|
| 13 |
+
(only the tile centres are kept), which avoids the seams of the official non-overlapping 256 windows.
|
| 14 |
+
Outputs are sigmoid probabilities (the official code thresholds at 0.5; we keep the probabilities).
|
| 15 |
+
|
| 16 |
+
Usage (HF token only via environment, never stored):
|
| 17 |
+
HF_TOKEN=... .venv/bin/python 25_gigatime_infer.py --set orion --scale 2
|
| 18 |
+
.venv/bin/python 25_gigatime_infer.py --set orion --scale 1 --subset 200 --tag scaletest_s1 --no_maps
|
| 19 |
+
.venv/bin/python 25_gigatime_infer.py --set tcga_text --scale 2
|
| 20 |
+
.venv/bin/python 25_gigatime_infer.py --set ge_patches --scale 2
|
| 21 |
+
|
| 22 |
+
Outputs (cache/gigatime/):
|
| 23 |
+
<set>_maps.npy uint8 (N, 21, R, R) probability*255, area-resampled to R=--map_res (default 128)
|
| 24 |
+
<set>_means.npy float32 (N, 21) per-tile channel mean probability (at inference resolution,
|
| 25 |
+
padding excluded)
|
| 26 |
+
<set>_frac.npy float32 (N, 21) per-tile fraction of pixels with prob > 0.5
|
| 27 |
+
<set>_meta.csv row order, source path, status, sizes
|
| 28 |
+
channels.json
|
| 29 |
+
orion: orion_validation.csv/json (shared-channel agreement with the real ORION mIF)
|
| 30 |
+
"""
|
| 31 |
+
import argparse
|
| 32 |
+
import glob
|
| 33 |
+
import json
|
| 34 |
+
import os
|
| 35 |
+
import queue
|
| 36 |
+
import sys
|
| 37 |
+
import threading
|
| 38 |
+
import time
|
| 39 |
+
|
| 40 |
+
import numpy as np
|
| 41 |
+
import pandas as pd
|
| 42 |
+
import torch
|
| 43 |
+
import torch.nn as nn
|
| 44 |
+
import torch.nn.functional as F
|
| 45 |
+
from PIL import Image, PngImagePlugin
|
| 46 |
+
|
| 47 |
+
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
|
| 48 |
+
|
| 49 |
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 50 |
+
sys.path.insert(0, ROOT)
|
| 51 |
+
from common import CACHE, ORION, load_all_tiles # noqa: E402
|
| 52 |
+
|
| 53 |
+
OUT = os.path.join(CACHE, "gigatime")
|
| 54 |
+
WDIR = os.path.join(ROOT, "ext", "gigatime", "weights", "GigaTIME")
|
| 55 |
+
PATHGEN = os.path.join(ROOT, "..", "PathGen-1.6M")
|
| 56 |
+
GE_DIRS = ["brain_image", "brain_train_image", "brain_trainnew_image", "gliomas_test_image_new",
|
| 57 |
+
"gliomas_train_image", "idc_image", "idc_train_image", "saromas_test_image_new",
|
| 58 |
+
"saromas_train_image_new", "skin_image", "skin_train_image"]
|
| 59 |
+
|
| 60 |
+
ALL_CHANNELS = ['DAPI', 'TRITC', 'Cy5', 'PD-1', 'CD14', 'CD4', 'T-bet', 'CD34', 'CD68', 'CD16', 'CD11c',
|
| 61 |
+
'CD138', 'CD20', 'CD3', 'CD8', 'PD-L1', 'CK', 'Ki67', 'Tryptase', 'Actin-D', 'Caspase3-D',
|
| 62 |
+
'PHH3-B', 'Transgelin']
|
| 63 |
+
BACKGROUND = ['TRITC', 'Cy5']
|
| 64 |
+
KEEP = [i for i, c in enumerate(ALL_CHANNELS) if c not in BACKGROUND]
|
| 65 |
+
CHANNELS = [ALL_CHANNELS[i] for i in KEEP]
|
| 66 |
+
# GigaTIME channel -> ORION channel (ORION if/*.tiff channel order == *_prop column order)
|
| 67 |
+
SHARED = {'DAPI': 'Hoechst', 'PD-1': 'PD-1', 'CD4': 'CD4', 'CD68': 'CD68', 'CD20': 'CD20', 'CD3': 'CD3e',
|
| 68 |
+
'CD8': 'CD8a', 'PD-L1': 'PD-L1', 'CK': 'Pan-CK', 'Ki67': 'Ki67',
|
| 69 |
+
# tentative (smooth-muscle markers vs SMA)
|
| 70 |
+
'Actin-D': 'SMA', 'Transgelin': 'SMA'}
|
| 71 |
+
MEAN = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
|
| 72 |
+
STD = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------- architecture (same layout as GigaTIME scripts/archs.py, UNet++) ----------------
|
| 76 |
+
class VGGBlock(nn.Module):
|
| 77 |
+
def __init__(self, i, m, o):
|
| 78 |
+
super().__init__()
|
| 79 |
+
self.relu = nn.ReLU(inplace=True)
|
| 80 |
+
self.conv1, self.bn1 = nn.Conv2d(i, m, 3, padding=1), nn.BatchNorm2d(m)
|
| 81 |
+
self.conv2, self.bn2 = nn.Conv2d(m, o, 3, padding=1), nn.BatchNorm2d(o)
|
| 82 |
+
|
| 83 |
+
def forward(self, x):
|
| 84 |
+
return self.relu(self.bn2(self.conv2(self.relu(self.bn1(self.conv1(x))))))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class GigaTIME(nn.Module):
|
| 88 |
+
def __init__(self, num_classes=23, input_channels=3):
|
| 89 |
+
super().__init__()
|
| 90 |
+
f = [32, 64, 128, 256, 512]
|
| 91 |
+
self.pool = nn.MaxPool2d(2, 2)
|
| 92 |
+
self.up = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)
|
| 93 |
+
self.conv0_0 = VGGBlock(input_channels, f[0], f[0])
|
| 94 |
+
self.conv1_0 = VGGBlock(f[0], f[1], f[1])
|
| 95 |
+
self.conv2_0 = VGGBlock(f[1], f[2], f[2])
|
| 96 |
+
self.conv3_0 = VGGBlock(f[2], f[3], f[3])
|
| 97 |
+
self.conv4_0 = VGGBlock(f[3], f[4], f[4])
|
| 98 |
+
self.conv0_1 = VGGBlock(f[0] + f[1], f[0], f[0])
|
| 99 |
+
self.conv1_1 = VGGBlock(f[1] + f[2], f[1], f[1])
|
| 100 |
+
self.conv2_1 = VGGBlock(f[2] + f[3], f[2], f[2])
|
| 101 |
+
self.conv3_1 = VGGBlock(f[3] + f[4], f[3], f[3])
|
| 102 |
+
self.conv0_2 = VGGBlock(f[0] * 2 + f[1], f[0], f[0])
|
| 103 |
+
self.conv1_2 = VGGBlock(f[1] * 2 + f[2], f[1], f[1])
|
| 104 |
+
self.conv2_2 = VGGBlock(f[2] * 2 + f[3], f[2], f[2])
|
| 105 |
+
self.conv0_3 = VGGBlock(f[0] * 3 + f[1], f[0], f[0])
|
| 106 |
+
self.conv1_3 = VGGBlock(f[1] * 3 + f[2], f[1], f[1])
|
| 107 |
+
self.conv0_4 = VGGBlock(f[0] * 4 + f[1], f[0], f[0])
|
| 108 |
+
self.final = nn.Conv2d(f[0], num_classes, kernel_size=1)
|
| 109 |
+
|
| 110 |
+
def forward(self, x):
|
| 111 |
+
up, pool = self.up, self.pool
|
| 112 |
+
x0_0 = self.conv0_0(x)
|
| 113 |
+
x1_0 = self.conv1_0(pool(x0_0))
|
| 114 |
+
x0_1 = self.conv0_1(torch.cat([x0_0, up(x1_0)], 1))
|
| 115 |
+
x2_0 = self.conv2_0(pool(x1_0))
|
| 116 |
+
x1_1 = self.conv1_1(torch.cat([x1_0, up(x2_0)], 1))
|
| 117 |
+
x0_2 = self.conv0_2(torch.cat([x0_0, x0_1, up(x1_1)], 1))
|
| 118 |
+
x3_0 = self.conv3_0(pool(x2_0))
|
| 119 |
+
x2_1 = self.conv2_1(torch.cat([x2_0, up(x3_0)], 1))
|
| 120 |
+
x1_2 = self.conv1_2(torch.cat([x1_0, x1_1, up(x2_1)], 1))
|
| 121 |
+
x0_3 = self.conv0_3(torch.cat([x0_0, x0_1, x0_2, up(x1_2)], 1))
|
| 122 |
+
x4_0 = self.conv4_0(pool(x3_0))
|
| 123 |
+
x3_1 = self.conv3_1(torch.cat([x3_0, up(x4_0)], 1))
|
| 124 |
+
x2_2 = self.conv2_2(torch.cat([x2_0, x2_1, up(x3_1)], 1))
|
| 125 |
+
x1_3 = self.conv1_3(torch.cat([x1_0, x1_1, x1_2, up(x2_2)], 1))
|
| 126 |
+
x0_4 = self.conv0_4(torch.cat([x0_0, x0_1, x0_2, x0_3, up(x1_3)], 1))
|
| 127 |
+
return self.final(x0_4)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def load_model(device):
|
| 131 |
+
wp = os.path.join(WDIR, "model.pth")
|
| 132 |
+
if not os.path.exists(wp): # token is read from the HF_TOKEN env var by huggingface_hub
|
| 133 |
+
from huggingface_hub import snapshot_download
|
| 134 |
+
snapshot_download("prov-gigatime/GigaTIME", local_dir=WDIR)
|
| 135 |
+
sd = torch.load(wp, map_location="cpu", weights_only=True)
|
| 136 |
+
m = GigaTIME(23, 3)
|
| 137 |
+
m.load_state_dict(sd, strict=True)
|
| 138 |
+
return m.eval().to(device)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ---------------- inference ----------------
|
| 142 |
+
def _pad_reflect(x, top, bottom, left, right):
|
| 143 |
+
# reflect padding must be < dim; fall back to replicate for tiny images
|
| 144 |
+
mode = "reflect" if max(top, bottom) < x.shape[2] and max(left, right) < x.shape[3] else "replicate"
|
| 145 |
+
return F.pad(x, (left, right, top, bottom), mode=mode)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@torch.no_grad()
|
| 149 |
+
def infer(model, x, max_full=768, tile=512, margin=64, tile_bs=4):
|
| 150 |
+
"""x: (1,3,H,W) normalised float on device -> logits (1,23,H,W) float32."""
|
| 151 |
+
_, _, H, W = x.shape
|
| 152 |
+
with torch.autocast("cuda", dtype=torch.float16):
|
| 153 |
+
if max(H, W) <= max_full:
|
| 154 |
+
xp = _pad_reflect(x, 0, (-H) % 16, 0, (-W) % 16)
|
| 155 |
+
return model(xp)[:, :, :H, :W].float()
|
| 156 |
+
step = tile - 2 * margin
|
| 157 |
+
ny, nx = -(-H // step), -(-W // step)
|
| 158 |
+
xp = _pad_reflect(x, margin, ny * step + margin - H, margin, nx * step + margin - W)
|
| 159 |
+
out = torch.zeros(1, 23, ny * step, nx * step, device=x.device)
|
| 160 |
+
pos = [(iy, ix) for iy in range(ny) for ix in range(nx)]
|
| 161 |
+
for k in range(0, len(pos), tile_bs):
|
| 162 |
+
chunk = pos[k:k + tile_bs]
|
| 163 |
+
tiles = torch.cat([xp[:, :, iy * step:iy * step + tile, ix * step:ix * step + tile] for iy, ix in chunk])
|
| 164 |
+
o = model(tiles)[:, :, margin:margin + step, margin:margin + step].float()
|
| 165 |
+
for j, (iy, ix) in enumerate(chunk):
|
| 166 |
+
out[:, :, iy * step:(iy + 1) * step, ix * step:(ix + 1) * step] = o[j]
|
| 167 |
+
return out[:, :, :H, :W]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def read_rgb(path):
|
| 171 |
+
with Image.open(path) as im:
|
| 172 |
+
return np.asarray(im.convert("RGB"))
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def prefetch(paths, extra=None, qsize=16):
|
| 176 |
+
q = queue.Queue(qsize)
|
| 177 |
+
|
| 178 |
+
def work():
|
| 179 |
+
for i, p in enumerate(paths):
|
| 180 |
+
try:
|
| 181 |
+
img = read_rgb(p)
|
| 182 |
+
ex = extra(i) if extra else None
|
| 183 |
+
q.put((i, img, ex, None))
|
| 184 |
+
except Exception as e: # unreadable -> logged as failed
|
| 185 |
+
q.put((i, None, None, f"{type(e).__name__}: {str(e)[:200]}"))
|
| 186 |
+
q.put(None)
|
| 187 |
+
|
| 188 |
+
threading.Thread(target=work, daemon=True).start()
|
| 189 |
+
while True:
|
| 190 |
+
it = q.get()
|
| 191 |
+
if it is None:
|
| 192 |
+
return
|
| 193 |
+
yield it
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ---------------- datasets ----------------
|
| 197 |
+
def get_set(name, subset, seed=0):
|
| 198 |
+
if name == "orion":
|
| 199 |
+
df = load_all_tiles()
|
| 200 |
+
meta = pd.DataFrame({"tile_id": df.tile_id, "source_path": [os.path.normpath(os.path.join(ORION, p)) for p in df.image_path],
|
| 201 |
+
"if_path": [os.path.normpath(os.path.join(ORION, p)) for p in df.target_path],
|
| 202 |
+
"orion_split": df.orion_split})
|
| 203 |
+
prop = df[[c + "_prop" for c in sorted(set(SHARED.values()), key=list(SHARED.values()).index)]]
|
| 204 |
+
elif name == "tcga_text":
|
| 205 |
+
d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
|
| 206 |
+
meta = pd.DataFrame({"idx": d["idx"], "key": d["key"], "source_path": d.image_path.map(os.path.normpath)})
|
| 207 |
+
prop = None
|
| 208 |
+
elif name == "ge_patches":
|
| 209 |
+
files = []
|
| 210 |
+
for g in GE_DIRS:
|
| 211 |
+
files += [(g, f) for f in sorted(glob.glob(os.path.join(PATHGEN, g, "*"))) if os.path.isfile(f) and f.lower().endswith((".png", ".jpg", ".jpeg", ".tif", ".tiff"))]
|
| 212 |
+
meta = pd.DataFrame({"group": [g for g, _ in files], "source_path": [os.path.normpath(f) for _, f in files]})
|
| 213 |
+
prop = None
|
| 214 |
+
else:
|
| 215 |
+
raise ValueError(name)
|
| 216 |
+
if subset:
|
| 217 |
+
sel = np.sort(np.random.default_rng(seed).choice(len(meta), subset, replace=False))
|
| 218 |
+
meta = meta.iloc[sel].reset_index(drop=True)
|
| 219 |
+
prop = prop.iloc[sel].reset_index(drop=True) if prop is not None else None
|
| 220 |
+
return meta.reset_index(drop=True), prop
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def pearson_rows(a, b):
|
| 224 |
+
"""row-wise Pearson for (n, p) arrays; NaN where a row is constant."""
|
| 225 |
+
a = a - a.mean(1, keepdims=True)
|
| 226 |
+
b = b - b.mean(1, keepdims=True)
|
| 227 |
+
den = np.sqrt((a * a).sum(1) * (b * b).sum(1))
|
| 228 |
+
with np.errstate(invalid="ignore", divide="ignore"):
|
| 229 |
+
return np.where(den > 0, (a * b).sum(1) / den, np.nan)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def main():
|
| 233 |
+
ap = argparse.ArgumentParser()
|
| 234 |
+
ap.add_argument("--set", required=True, choices=["orion", "tcga_text", "ge_patches"])
|
| 235 |
+
ap.add_argument("--scale", type=float, default=2.0)
|
| 236 |
+
ap.add_argument("--subset", type=int, default=0)
|
| 237 |
+
ap.add_argument("--tag", default="")
|
| 238 |
+
ap.add_argument("--no_maps", action="store_true")
|
| 239 |
+
ap.add_argument("--map_res", type=int, default=128)
|
| 240 |
+
ap.add_argument("--pix_tiles", type=int, default=400, help="ORION tiles used for pixel-level Pearson")
|
| 241 |
+
ap.add_argument("--gpu_frac", type=float, default=0.2, help="cap on this process' share of GPU memory")
|
| 242 |
+
a = ap.parse_args()
|
| 243 |
+
|
| 244 |
+
os.makedirs(OUT, exist_ok=True)
|
| 245 |
+
dev = "cuda"
|
| 246 |
+
torch.cuda.set_per_process_memory_fraction(a.gpu_frac)
|
| 247 |
+
torch.backends.cudnn.benchmark = False
|
| 248 |
+
model = load_model(dev)
|
| 249 |
+
mean, std = MEAN.to(dev), STD.to(dev)
|
| 250 |
+
tag = a.tag or a.set
|
| 251 |
+
meta, prop = get_set(a.set, a.subset)
|
| 252 |
+
N, C, R = len(meta), len(KEEP), a.map_res
|
| 253 |
+
print(f"[{tag}] N={N} scale={a.scale} channels={C}", flush=True)
|
| 254 |
+
|
| 255 |
+
with open(os.path.join(OUT, "channels.json"), "w") as f:
|
| 256 |
+
json.dump({"channels": CHANNELS, "model_output_channels": ALL_CHANNELS, "dropped_background": BACKGROUND,
|
| 257 |
+
"orion_shared": SHARED, "value": "sigmoid probability (maps: uint8 = round(prob*255))",
|
| 258 |
+
"source": "huggingface.co/prov-gigatime/GigaTIME"}, f, indent=1)
|
| 259 |
+
|
| 260 |
+
maps = None if a.no_maps else np.lib.format.open_memmap(os.path.join(OUT, f"{tag}_maps.npy"), "w+", np.uint8, (N, C, R, R))
|
| 261 |
+
means = np.full((N, C), np.nan, np.float32)
|
| 262 |
+
frac = np.full((N, C), np.nan, np.float32)
|
| 263 |
+
status, hw, inhw = ["failed: not processed"] * N, [(0, 0)] * N, [(0, 0)] * N
|
| 264 |
+
|
| 265 |
+
is_orion = a.set == "orion"
|
| 266 |
+
if is_orion:
|
| 267 |
+
import tifffile
|
| 268 |
+
orion_ch = ["Hoechst", "CD31", "CD45", "CD68", "CD4", "FOXP3", "CD8a", "CD45RO", "CD20", "PD-L1", "CD3e",
|
| 269 |
+
"CD163", "E-cadherin", "PD-1", "Ki67", "Pan-CK", "SMA"]
|
| 270 |
+
pairs = [(g, CHANNELS.index(g), o, orion_ch.index(o)) for g, o in SHARED.items()]
|
| 271 |
+
real_mean = np.full((N, len(orion_ch)), np.nan, np.float32)
|
| 272 |
+
pix_sel = set(np.linspace(0, N - 1, min(a.pix_tiles, N)).astype(int).tolist())
|
| 273 |
+
pix_r = {g: [] for g, *_ in pairs}
|
| 274 |
+
box_r = {g: [] for g, *_ in pairs}
|
| 275 |
+
pooled = {g: ([], []) for g, *_ in pairs}
|
| 276 |
+
rng = np.random.default_rng(0)
|
| 277 |
+
extra = lambda i: tifffile.imread(meta.if_path[i]) # (333,333,17) uint8
|
| 278 |
+
else:
|
| 279 |
+
extra = None
|
| 280 |
+
|
| 281 |
+
t0 = time.time()
|
| 282 |
+
for i, img, ifa, err in prefetch(list(meta.source_path), extra):
|
| 283 |
+
if err is not None:
|
| 284 |
+
status[i] = "failed: " + err
|
| 285 |
+
print(f" FAIL {i} {meta.source_path[i]} {err}", flush=True)
|
| 286 |
+
continue
|
| 287 |
+
try:
|
| 288 |
+
H, W = img.shape[:2]
|
| 289 |
+
x = torch.from_numpy(img).to(dev).permute(2, 0, 1)[None].float() / 255.0
|
| 290 |
+
if a.scale != 1:
|
| 291 |
+
x = F.interpolate(x, scale_factor=a.scale, mode="bilinear", align_corners=False).clamp(0, 1)
|
| 292 |
+
x = (x - mean) / std
|
| 293 |
+
p = torch.sigmoid(infer(model, x))[:, KEEP] # (1,C,h,w)
|
| 294 |
+
means[i] = p.mean((0, 2, 3)).cpu().numpy()
|
| 295 |
+
frac[i] = (p > 0.5).float().mean((0, 2, 3)).cpu().numpy()
|
| 296 |
+
if maps is not None:
|
| 297 |
+
maps[i] = (F.interpolate(p, size=(R, R), mode="area")[0] * 255).round().clamp(0, 255).byte().cpu().numpy()
|
| 298 |
+
status[i], hw[i], inhw[i] = "ok", (H, W), tuple(p.shape[2:])
|
| 299 |
+
if is_orion:
|
| 300 |
+
real_mean[i] = ifa.reshape(-1, ifa.shape[2]).mean(0)
|
| 301 |
+
if i in pix_sel:
|
| 302 |
+
pr = F.interpolate(p, size=(H, W), mode="area")[0].cpu().numpy() # back onto the IF grid
|
| 303 |
+
for g, gi, o, oi in pairs:
|
| 304 |
+
a1, b1 = pr[gi].ravel()[None], ifa[..., oi].astype(np.float32).ravel()[None]
|
| 305 |
+
pix_r[g].append(pearson_rows(a1, b1)[0])
|
| 306 |
+
bh, bw = H // 8 * 8, W // 8 * 8 # 8x8-pixel boxes (~4 um), as in the official eval
|
| 307 |
+
a8 = pr[gi][:bh, :bw].reshape(bh // 8, 8, bw // 8, 8).mean((1, 3)).ravel()[None]
|
| 308 |
+
b8 = ifa[:bh, :bw, oi].astype(np.float32).reshape(bh // 8, 8, bw // 8, 8).mean((1, 3)).ravel()[None]
|
| 309 |
+
box_r[g].append(pearson_rows(a8, b8)[0])
|
| 310 |
+
s = rng.choice(a1.shape[1], 2000, replace=False)
|
| 311 |
+
pooled[g][0].append(a1[0, s]); pooled[g][1].append(b1[0, s])
|
| 312 |
+
except Exception as e:
|
| 313 |
+
status[i] = f"failed: {type(e).__name__}: {str(e)[:200]}"
|
| 314 |
+
print(f" FAIL {i} {meta.source_path[i]} {status[i]}", flush=True)
|
| 315 |
+
if (i + 1) % 500 == 0:
|
| 316 |
+
print(f" {i + 1}/{N} {time.time() - t0:.0f}s gpu_max={torch.cuda.max_memory_allocated() / 2**30:.2f}GB", flush=True)
|
| 317 |
+
|
| 318 |
+
if maps is not None:
|
| 319 |
+
maps.flush()
|
| 320 |
+
meta["status"] = status
|
| 321 |
+
meta["orig_h"], meta["orig_w"] = [h for h, _ in hw], [w for _, w in hw]
|
| 322 |
+
meta["infer_h"], meta["infer_w"] = [h for h, _ in inhw], [w for _, w in inhw]
|
| 323 |
+
meta["scale"] = a.scale
|
| 324 |
+
meta.insert(0, "row", np.arange(N))
|
| 325 |
+
meta.to_csv(os.path.join(OUT, f"{tag}_meta.csv"), index=False)
|
| 326 |
+
np.save(os.path.join(OUT, f"{tag}_means.npy"), means)
|
| 327 |
+
np.save(os.path.join(OUT, f"{tag}_frac.npy"), frac)
|
| 328 |
+
nok = int((meta.status == "ok").sum())
|
| 329 |
+
print(f"[{tag}] done ok={nok} failed={N - nok} time={time.time() - t0:.0f}s "
|
| 330 |
+
f"gpu_max={torch.cuda.max_memory_allocated() / 2**30:.2f}GB", flush=True)
|
| 331 |
+
|
| 332 |
+
if is_orion:
|
| 333 |
+
from scipy.stats import spearmanr, pearsonr
|
| 334 |
+
ok = (meta.status == "ok").values
|
| 335 |
+
rows = []
|
| 336 |
+
for g, gi, o, oi in pairs:
|
| 337 |
+
r = dict(gigatime=g, orion=o,
|
| 338 |
+
tile_mean_spearman=spearmanr(means[ok, gi], real_mean[ok, oi])[0],
|
| 339 |
+
tile_frac_spearman=spearmanr(frac[ok, gi], real_mean[ok, oi])[0],
|
| 340 |
+
tile_mean_vs_prop_spearman=spearmanr(means[ok, gi], prop[o + "_prop"].values[ok])[0],
|
| 341 |
+
pixel_pearson_tile_median=float(np.nanmedian(pix_r[g])),
|
| 342 |
+
pixel_pearson_tile_mean=float(np.nanmean(pix_r[g])),
|
| 343 |
+
pixel_pearson_pooled=pearsonr(np.concatenate(pooled[g][0]), np.concatenate(pooled[g][1]))[0],
|
| 344 |
+
box8_pearson_tile_mean=float(np.nanmean(box_r[g])),
|
| 345 |
+
n_tiles=int(ok.sum()), n_pix_tiles=len(pix_r[g]))
|
| 346 |
+
rows.append(r)
|
| 347 |
+
v = pd.DataFrame(rows)
|
| 348 |
+
v.to_csv(os.path.join(OUT, f"{tag}_validation.csv"), index=False)
|
| 349 |
+
core = v[~v.gigatime.isin(["Actin-D", "Transgelin"])]
|
| 350 |
+
summ = {"scale": a.scale, "N": int(ok.sum()),
|
| 351 |
+
"mean_over_10_core_channels": core.drop(columns=["gigatime", "orion"]).mean().to_dict()}
|
| 352 |
+
with open(os.path.join(OUT, f"{tag}_validation.json"), "w") as f:
|
| 353 |
+
json.dump(summ, f, indent=1, default=float)
|
| 354 |
+
np.save(os.path.join(OUT, f"{tag}_real_if_means.npy"), real_mean)
|
| 355 |
+
pd.set_option("display.width", 250)
|
| 356 |
+
print(v.round(3).to_string(index=False))
|
| 357 |
+
print(json.dumps(summ, indent=1, default=float))
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
if __name__ == "__main__":
|
| 361 |
+
main()
|