Carpediem123 commited on
Commit
e8fa066
·
verified ·
1 Parent(s): ad92c21

Migrate release files 151-180

Browse files
.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

  • SHA256: 2e72404d687e5ac107807c0e27215408cf305f836ae0de0a5197790e3e86bf43
  • Pointer size: 131 Bytes
  • Size of remote file: 818 kB
examples/tcga_he/05660.png ADDED

Git LFS Details

  • SHA256: 001d62227fb108ec0434f8f6d026dc37445a6826efbd36e7f1acf8d9c1cc7e47
  • Pointer size: 132 Bytes
  • Size of remote file: 1.12 MB
examples/tcga_he/05757.png ADDED

Git LFS Details

  • SHA256: 4d36ae4ce67b2b37c29a3a62734f8739bf91c1d950b9fc74b458a8c3adc88841
  • Pointer size: 131 Bytes
  • Size of remote file: 813 kB
examples/tcga_he/05776.png ADDED

Git LFS Details

  • SHA256: 509ce25ade549a293b899be5b0dd811dfe009f30c0ec47dd90e9a0f33b30a342
  • Pointer size: 131 Bytes
  • Size of remote file: 808 kB
examples/tcga_he/06007.png ADDED

Git LFS Details

  • SHA256: e42cee252465fc17ddb27e3372949fcd6fb82bc80347ff265fc367d86713527c
  • Pointer size: 131 Bytes
  • Size of remote file: 921 kB
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()