taejoon89 commited on
Commit
b57d99f
ยท
verified ยท
1 Parent(s): b9bd634

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -12,12 +12,18 @@ library_name: pytorch
12
  pipeline_tag: image-feature-extraction
13
  ---
14
 
15
- # OpenPath โ€” Pathology Foundation Model (training code)
16
 
17
  **OpenPath** is a vision foundation model for computational pathology: a **ViT-g/14** encoder
18
  pre-trained with self-supervision (**DINOv2** + **gram anchoring**) on **public-only** whole-slide
19
- histopathology tiles. This repository contains the **training / reproduction code**. The corpus and
20
- checkpoints are hosted separately (see below).
 
 
 
 
 
 
21
 
22
  - **Encoder:** ViT-g/14 (reg4), 1536-dim CLS embedding
23
  - **Objective:** DINO + iBOT + KDE (DINOv2) with **gram anchoring** ported from DINOv3
@@ -39,6 +45,11 @@ scripts/
39
  autoresume.sh # crash-tolerant auto-resume
40
  watch_eval.sh # online HEST probing per checkpoint
41
  run_hest_3way.py # HEST evaluation (Meta DINOv2 / Phikon-v2 / OpenPath)
 
 
 
 
 
42
  requirements.txt
43
  ```
44
 
@@ -47,7 +58,7 @@ requirements.txt
47
  | Artifact | Hugging Face repo | Notes |
48
  |---|---|---|
49
  | **Corpus** | `taejoon89/openpath-corpus` | Native 40ร— pathology tiles, 33,991 WebDataset shards / ~17 TB (public, gated) |
50
- | **Checkpoints** | `taejoon89/openpath-checkpoints` | OpenPath teacher checkpoints across training (private) |
51
  | **Code** | `taejoon89/openpath` | This repository |
52
 
53
  The training config points to the corpus via
@@ -100,20 +111,85 @@ cls = m.forward_features(x)["x_norm_clstoken"] # (B, 1536)
100
  ## Evaluation
101
 
102
  Frozen-encoder linear/ridge probing. The headline benchmark is a **contamination-free** in-house
103
- HCC (hepatocellular carcinoma) Visium spatial-transcriptomics cohort (leave-one-patient-out, mean
104
- Pearson over top-50 highly-variable genes) โ€” no public FM was trained on it.
105
-
106
- | Benchmark (metric) | OpenPath (best) | OpenMidnight | Phikon-v2 |
107
- |---|---|---|---|
108
- | **HCC-ST** (Pearson, clean) | **0.324** | 0.300 | 0.274 |
109
- | HEST-1K (Pearson) | 0.383 | 0.390 | 0.375 |
110
- | NCT-CRC-HE (9-class acc) | 0.964 | 0.967 | 0.937 |
111
- | BACH (4-class acc) | 0.811 | 0.906 | 0.708 |
112
-
113
- On the clean HCC-ST cohort the majority of OpenPath checkpoints exceed OpenMidnight, and OpenPath
114
- beats Phikon-v2 across all benchmarks. The best checkpoint (`training_63250`, available in
115
- `openpath-checkpoints`) is top on both HEST-1K and HCC-ST. Note: PCam / CAMELYON is excluded from
116
- comparison because it overlaps our training corpus.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  ## License
119
 
 
12
  pipeline_tag: image-feature-extraction
13
  ---
14
 
15
+ # OpenPath โ€” Pathology Foundation Model (training & evaluation code)
16
 
17
  **OpenPath** is a vision foundation model for computational pathology: a **ViT-g/14** encoder
18
  pre-trained with self-supervision (**DINOv2** + **gram anchoring**) on **public-only** whole-slide
19
+ histopathology tiles. This repository contains the **training, reproduction, and evaluation code**.
20
+ The corpus and checkpoints are hosted separately (see below).
21
+
22
+ > **Headline result.** On a **contamination-free** in-house spatial-transcriptomics (ST) cohort โ€” the
23
+ > least leakage-prone benchmark, since no public foundation model was trained on it โ€” OpenPath **ranks
24
+ > #1 among seven foundation models** (mean Pearson: OpenPath **0.323** > UNI2-h 0.301 > OpenMidnight
25
+ > 0.300 > Virchow2 0.292 > prov-gigapath 0.286 > Phikon-v2 0.274 > UNI 0.257). Released checkpoint:
26
+ > **`training_316250`** (in `openpath-checkpoints`). See [Evaluation](#evaluation).
27
 
28
  - **Encoder:** ViT-g/14 (reg4), 1536-dim CLS embedding
29
  - **Objective:** DINO + iBOT + KDE (DINOv2) with **gram anchoring** ported from DINOv3
 
45
  autoresume.sh # crash-tolerant auto-resume
46
  watch_eval.sh # online HEST probing per checkpoint
47
  run_hest_3way.py # HEST evaluation (Meta DINOv2 / Phikon-v2 / OpenPath)
48
+ eval/ # downstream benchmark / reference-FM comparison
49
+ openpath_eva_backbone.py # backbone factories: OpenPath + Phikon / OpenMidnight / UNI / UNI2-h / gigapath / Virchow2
50
+ st_bench.py # in-house spatial-transcriptomics benchmark (LOPO ridge, headline)
51
+ run_patch_eval.sh # PCam / CRC / BACH patch probing via kaiko-eva
52
+ eva_configs/ # eva YAML configs (crc / bach / patch_camelyon)
53
  requirements.txt
54
  ```
55
 
 
58
  | Artifact | Hugging Face repo | Notes |
59
  |---|---|---|
60
  | **Corpus** | `taejoon89/openpath-corpus` | Native 40ร— pathology tiles, 33,991 WebDataset shards / ~17 TB (public, gated) |
61
+ | **Checkpoints** | `taejoon89/openpath-checkpoints` | OpenPath teacher checkpoints across training (`training_0` โ€ฆ `training_345000`); **released model = `training_316250`** (private) |
62
  | **Code** | `taejoon89/openpath` | This repository |
63
 
64
  The training config points to the corpus via
 
111
  ## Evaluation
112
 
113
  Frozen-encoder linear/ridge probing. The headline benchmark is a **contamination-free** in-house
114
+ Visium spatial-transcriptomics (ST) cohort (leave-one-patient-out, mean Pearson over top-50
115
+ highly-variable genes) โ€” **no public FM was trained on it**, so it is the least leakage-prone
116
+ comparison. The reported OpenPath checkpoint is `training_316250`.
117
+
118
+ **Comparison** โ€” all 7 models loaded through one backbone factory and probed under an identical
119
+ protocol; sorted by the clean ST benchmark:
120
+
121
+ | Model | ST (in-house, clean) โ†“ | HEST-1K (public) | NCT-CRC-HE (9-cls acc) | BACH (4-cls acc) |
122
+ |---|---|---|---|---|
123
+ | **OpenPath** | **0.323** | 0.372 | 0.954 | 0.761 |
124
+ | UNI2-h | 0.301 | 0.414 | 0.966 | 0.908 |
125
+ | OpenMidnight | 0.300 | 0.390 | 0.967 | 0.906 |
126
+ | Virchow2 | 0.292 | 0.398 | 0.964 | 0.875 |
127
+ | prov-gigapath | 0.286 | 0.393 | 0.953 | 0.752 |
128
+ | Phikon-v2 | 0.274 | 0.375 | 0.937 | 0.708 |
129
+ | UNI | 0.257 | 0.386 | 0.946 | 0.777 |
130
+
131
+ **On the contamination-free ST cohort OpenPath ranks #1** among all seven foundation models. The
132
+ picture inverts on the **public** benchmarks (HEST-1K, CRC, BACH): there OpenPath is mid-pack to
133
+ low, and the large FMs lead. Those benchmarks derive from public repositories (TCGA/GTEx/etc.) that
134
+ these FMs were pre-trained on, so their apparent edge is confounded by **train/test leakage** โ€” which
135
+ is exactly why the leakage-free in-house ST cohort is our headline. (The reported checkpoint
136
+ `training_316250` is selected by ST; OpenPath's HEST-1K peaks earlier in training at ~0.38.)
137
+ PCam / CAMELYON is excluded because it overlaps our own training corpus.
138
+
139
+ ### Reproducing the comparison
140
+
141
+ All models are loaded through a single backbone-factory module (`eval/openpath_eva_backbone.py`) and
142
+ probed under an identical protocol, so OpenPath and the reference FMs (Phikon-v2, OpenMidnight, UNI,
143
+ UNI2-h, gigapath, Virchow2) are directly comparable.
144
+
145
+ ```bash
146
+ export PYTHONPATH="$PWD/OpenPath:$PWD/eval"
147
+ # Headline: in-house spatial-transcriptomics (LOPO ridge; cohort is private, code is provided)
148
+ python eval/st_bench.py --backbone openpath --weights <teacher_checkpoint.pth>
149
+ python eval/st_bench.py --backbone uni # reference FM (also: uni2 / gigapath / virchow2 / phikon / openmidnight)
150
+
151
+ # Patch probing (PCam / CRC / BACH) via kaiko-eva
152
+ bash eval/run_patch_eval.sh openpath crc <teacher_checkpoint.pth>
153
+ bash eval/run_patch_eval.sh uni crc # reference FM
154
+ ```
155
+
156
+ Reference FM weights are pulled from their Hugging Face hubs on first use (UNI / UNI2-h / Virchow2
157
+ are gated โ€” request access on HF beforehand).
158
+
159
+ ## Intended use & limitations
160
+
161
+ **Intended use.** OpenPath is a **frozen feature extractor** for H&E histopathology. It produces a
162
+ 1536-dim CLS embedding per 224ร—224 tile (native ~40ร— / 0.5 ยตm-per-pixel regime, ImageNet
163
+ normalization) for downstream **linear/ridge probing, k-NN, MIL aggregation, and retrieval**. It is a
164
+ research artifact, **not a medical device**, and must not be used for diagnosis or clinical
165
+ decision-making.
166
+
167
+ **Limitations.**
168
+ - **Public-benchmark leakage.** Public benchmarks (HEST-1K, NCT-CRC-HE, BACH) derive from repositories
169
+ (TCGA/GTEx/โ€ฆ) that many foundation models โ€” and partly OpenPath โ€” were pre-trained on. Absolute
170
+ numbers and cross-model rankings on them are confounded; prefer leakage-controlled evaluation.
171
+ - **Checkpoint trade-off.** The released `training_316250` is selected by the clean ST benchmark;
172
+ earlier checkpoints score higher on HEST-1K (~0.38). Pick a checkpoint to match your downstream task.
173
+ - **Domain.** Trained on H&E WSIs at native magnification. Behavior on IHC, cytology, frozen sections,
174
+ non-0.5 ยตm-per-pixel inputs, or non-pathology images is untested.
175
+ - **Patch-level encoder.** OpenPath encodes tiles independently; slide-level context requires a
176
+ separate aggregator (future work).
177
+
178
+ ## Citation
179
+
180
+ A paper is in preparation. Until then, please cite the repository and the upstream work it builds on:
181
+
182
+ ```bibtex
183
+ @misc{openpath2026,
184
+ title = {OpenPath: a public-data pathology foundation model},
185
+ author = {OpenPath authors},
186
+ year = {2026},
187
+ note = {https://huggingface.co/taejoon89/openpath}
188
+ }
189
+ ```
190
+
191
+ OpenPath builds on **DINOv2**, **OpenMidnight / Midnight**, and **gram anchoring (DINOv3)** โ€” see
192
+ `OpenPath/README.md` for the full upstream citations, which should also be cited.
193
 
194
  ## License
195
 
eval/eva_configs/bach.yaml ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ trainer:
3
+ class_path: eva.Trainer
4
+ init_args:
5
+ n_runs: &N_RUNS ${oc.env:N_RUNS, 5}
6
+ default_root_dir: &OUTPUT_ROOT ${oc.env:OUTPUT_ROOT, logs/${oc.env:MODEL_NAME, dino_vits16}/offline/bach}
7
+ max_steps: &MAX_STEPS ${oc.env:MAX_STEPS, 12500}
8
+ checkpoint_type: ${oc.env:CHECKPOINT_TYPE, best}
9
+ accelerator: ${oc.env:ACCELERATOR, auto}
10
+ devices: ${oc.env:NUM_DEVICES, 1}
11
+ callbacks:
12
+ - class_path: eva.callbacks.ConfigurationLogger
13
+ - class_path: lightning.pytorch.callbacks.TQDMProgressBar
14
+ init_args:
15
+ refresh_rate: ${oc.env:TQDM_REFRESH_RATE, 1}
16
+ - class_path: lightning.pytorch.callbacks.LearningRateMonitor
17
+ init_args:
18
+ logging_interval: epoch
19
+ - class_path: lightning.pytorch.callbacks.ModelCheckpoint
20
+ init_args:
21
+ filename: best
22
+ save_last: ${oc.env:SAVE_LAST, false}
23
+ save_top_k: 1
24
+ monitor: &MONITOR_METRIC ${oc.env:MONITOR_METRIC, val/MulticlassAccuracy}
25
+ mode: &MONITOR_METRIC_MODE ${oc.env:MONITOR_METRIC_MODE, max}
26
+ - class_path: lightning.pytorch.callbacks.EarlyStopping
27
+ init_args:
28
+ min_delta: 0
29
+ patience: ${oc.env:PATIENCE, 1250}
30
+ monitor: *MONITOR_METRIC
31
+ mode: *MONITOR_METRIC_MODE
32
+ - class_path: eva.callbacks.ClassificationEmbeddingsWriter
33
+ init_args:
34
+ output_dir: &DATASET_EMBEDDINGS_ROOT ${oc.env:EMBEDDINGS_ROOT, ./data/embeddings}/${oc.env:MODEL_NAME, dino_vits16}/bach
35
+ dataloader_idx_map:
36
+ 0: train
37
+ 1: val
38
+ backbone:
39
+ class_path: eva.core.models.wrappers.ModelFromFunction
40
+ init_args:
41
+ path: ${oc.env:BACKBONE_FN, openpath_eva_backbone.build_openpath}
42
+ arguments:
43
+ weights: ${oc.env:OPENPATH_WEIGHTS, "none"}
44
+ overwrite: false
45
+ logger:
46
+ - class_path: lightning.pytorch.loggers.TensorBoardLogger
47
+ init_args:
48
+ save_dir: *OUTPUT_ROOT
49
+ name: ""
50
+ model:
51
+ class_path: eva.HeadModule
52
+ init_args:
53
+ head:
54
+ class_path: torch.nn.Linear
55
+ init_args:
56
+ in_features: ${oc.env:IN_FEATURES, 384}
57
+ out_features: &NUM_CLASSES 4
58
+ criterion: torch.nn.CrossEntropyLoss
59
+ optimizer:
60
+ class_path: torch.optim.AdamW
61
+ init_args:
62
+ lr: ${oc.env:LR_VALUE, 0.0003}
63
+ metrics:
64
+ common:
65
+ - class_path: eva.metrics.AverageLoss
66
+ - class_path: eva.metrics.MulticlassClassificationMetrics
67
+ init_args:
68
+ num_classes: *NUM_CLASSES
69
+ data:
70
+ class_path: eva.DataModule
71
+ init_args:
72
+ datasets:
73
+ train:
74
+ class_path: eva.datasets.EmbeddingsClassificationDataset
75
+ init_args: &DATASET_ARGS
76
+ root: *DATASET_EMBEDDINGS_ROOT
77
+ manifest_file: manifest.csv
78
+ split: train
79
+ val:
80
+ class_path: eva.datasets.EmbeddingsClassificationDataset
81
+ init_args:
82
+ <<: *DATASET_ARGS
83
+ split: val
84
+ predict:
85
+ - class_path: eva.vision.datasets.BACH
86
+ init_args: &PREDICT_DATASET_ARGS
87
+ root: ${oc.env:DATA_ROOT, ./data/bach}
88
+ split: train
89
+ download: ${oc.env:DOWNLOAD_DATA, false}
90
+ # Set `download: true` to download the dataset from https://zenodo.org/records/3632035
91
+ # The BACH dataset is distributed under the following license
92
+ # Attribution-NonCommercial-NoDerivs 4.0 International license
93
+ # (see: https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode)
94
+ transforms:
95
+ class_path: eva.vision.data.transforms.common.ResizeAndCrop
96
+ init_args:
97
+ size: ${oc.env:RESIZE_DIM, 224}
98
+ mean: ${oc.env:NORMALIZE_MEAN, [0.485, 0.456, 0.406]}
99
+ std: ${oc.env:NORMALIZE_STD, [0.229, 0.224, 0.225]}
100
+ - class_path: eva.vision.datasets.BACH
101
+ init_args:
102
+ <<: *PREDICT_DATASET_ARGS
103
+ split: val
104
+ dataloaders:
105
+ train:
106
+ batch_size: &BATCH_SIZE ${oc.env:BATCH_SIZE, 256}
107
+ num_workers: &N_DATA_WORKERS ${oc.env:N_DATA_WORKERS, 4}
108
+ shuffle: true
109
+ val:
110
+ batch_size: *BATCH_SIZE
111
+ num_workers: *N_DATA_WORKERS
112
+ predict:
113
+ batch_size: &PREDICT_BATCH_SIZE ${oc.env:PREDICT_BATCH_SIZE, 64}
114
+ num_workers: *N_DATA_WORKERS
eval/eva_configs/crc.yaml ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ trainer:
3
+ class_path: eva.Trainer
4
+ init_args:
5
+ n_runs: &N_RUNS ${oc.env:N_RUNS, 5}
6
+ default_root_dir: &OUTPUT_ROOT ${oc.env:OUTPUT_ROOT, logs/${oc.env:MODEL_NAME, dino_vits16}/offline/crc}
7
+ max_steps: &MAX_STEPS ${oc.env:MAX_STEPS, 12500}
8
+ checkpoint_type: ${oc.env:CHECKPOINT_TYPE, best}
9
+ accelerator: ${oc.env:ACCELERATOR, auto}
10
+ devices: ${oc.env:NUM_DEVICES, 1}
11
+ callbacks:
12
+ - class_path: eva.callbacks.ConfigurationLogger
13
+ - class_path: lightning.pytorch.callbacks.TQDMProgressBar
14
+ init_args:
15
+ refresh_rate: ${oc.env:TQDM_REFRESH_RATE, 1}
16
+ - class_path: lightning.pytorch.callbacks.LearningRateMonitor
17
+ init_args:
18
+ logging_interval: epoch
19
+ - class_path: lightning.pytorch.callbacks.ModelCheckpoint
20
+ init_args:
21
+ filename: best
22
+ save_last: ${oc.env:SAVE_LAST, false}
23
+ save_top_k: 1
24
+ monitor: &MONITOR_METRIC ${oc.env:MONITOR_METRIC, val/MulticlassAccuracy}
25
+ mode: &MONITOR_METRIC_MODE ${oc.env:MONITOR_METRIC_MODE, max}
26
+ - class_path: lightning.pytorch.callbacks.EarlyStopping
27
+ init_args:
28
+ min_delta: 0
29
+ patience: ${oc.env:PATIENCE, 7}
30
+ monitor: *MONITOR_METRIC
31
+ mode: *MONITOR_METRIC_MODE
32
+ - class_path: eva.callbacks.ClassificationEmbeddingsWriter
33
+ init_args:
34
+ output_dir: &DATASET_EMBEDDINGS_ROOT ${oc.env:EMBEDDINGS_ROOT, ./data/embeddings}/${oc.env:MODEL_NAME, dino_vits16}/crc
35
+ dataloader_idx_map:
36
+ 0: train
37
+ 1: val
38
+ backbone:
39
+ class_path: eva.core.models.wrappers.ModelFromFunction
40
+ init_args:
41
+ path: ${oc.env:BACKBONE_FN, openpath_eva_backbone.build_openpath}
42
+ arguments:
43
+ weights: ${oc.env:OPENPATH_WEIGHTS, "none"}
44
+ overwrite: false
45
+ logger:
46
+ - class_path: lightning.pytorch.loggers.TensorBoardLogger
47
+ init_args:
48
+ save_dir: *OUTPUT_ROOT
49
+ name: ""
50
+ model:
51
+ class_path: eva.HeadModule
52
+ init_args:
53
+ head:
54
+ class_path: openpath_eva_backbone.BNHead
55
+ init_args:
56
+ in_features: ${oc.env:IN_FEATURES, 384}
57
+ out_features: &NUM_CLASSES 9
58
+ criterion: torch.nn.CrossEntropyLoss
59
+ optimizer:
60
+ class_path: torch.optim.AdamW
61
+ init_args:
62
+ lr: ${oc.env:LR_VALUE, 0.0003}
63
+ metrics:
64
+ common:
65
+ - class_path: eva.metrics.AverageLoss
66
+ - class_path: eva.metrics.MulticlassClassificationMetrics
67
+ init_args:
68
+ num_classes: *NUM_CLASSES
69
+ data:
70
+ class_path: eva.DataModule
71
+ init_args:
72
+ datasets:
73
+ train:
74
+ class_path: eva.datasets.EmbeddingsClassificationDataset
75
+ init_args: &DATASET_ARGS
76
+ root: *DATASET_EMBEDDINGS_ROOT
77
+ manifest_file: manifest.csv
78
+ split: train
79
+ val:
80
+ class_path: eva.datasets.EmbeddingsClassificationDataset
81
+ init_args:
82
+ <<: *DATASET_ARGS
83
+ split: val
84
+ predict:
85
+ - class_path: eva.vision.datasets.CRC
86
+ init_args: &PREDICT_DATASET_ARGS
87
+ root: ${oc.env:DATA_ROOT, ./data/crc}
88
+ split: train
89
+ download: ${oc.env:DOWNLOAD_DATA, false}
90
+ # Set `download: true` to download the dataset from https://zenodo.org/records/1214456
91
+ # The CRC dataset is distributed under the following license: "CC BY 4.0 LEGAL CODE"
92
+ # (see: https://creativecommons.org/licenses/by/4.0/legalcode)
93
+ transforms:
94
+ class_path: eva.vision.data.transforms.common.ResizeAndCrop
95
+ init_args:
96
+ size: ${oc.env:RESIZE_DIM, 224}
97
+ mean: ${oc.env:NORMALIZE_MEAN, [0.485, 0.456, 0.406]}
98
+ std: ${oc.env:NORMALIZE_STD, [0.229, 0.224, 0.225]}
99
+ - class_path: eva.vision.datasets.CRC
100
+ init_args:
101
+ <<: *PREDICT_DATASET_ARGS
102
+ split: val
103
+ dataloaders:
104
+ train:
105
+ batch_size: &BATCH_SIZE ${oc.env:BATCH_SIZE, 256}
106
+ num_workers: &N_DATA_WORKERS ${oc.env:N_DATA_WORKERS, 4}
107
+ shuffle: true
108
+ val:
109
+ batch_size: *BATCH_SIZE
110
+ num_workers: *N_DATA_WORKERS
111
+ predict:
112
+ batch_size: &PREDICT_BATCH_SIZE ${oc.env:PREDICT_BATCH_SIZE, 64}
113
+ num_workers: *N_DATA_WORKERS
eval/eva_configs/patch_camelyon.yaml ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ trainer:
3
+ class_path: eva.Trainer
4
+ init_args:
5
+ n_runs: &N_RUNS ${oc.env:N_RUNS, 5}
6
+ default_root_dir: &OUTPUT_ROOT ${oc.env:OUTPUT_ROOT, logs/${oc.env:MODEL_NAME, dino_vits16}/offline/patch_camelyon}
7
+ max_steps: &MAX_STEPS ${oc.env:MAX_STEPS, 12500}
8
+ checkpoint_type: ${oc.env:CHECKPOINT_TYPE, best}
9
+ accelerator: ${oc.env:ACCELERATOR, auto}
10
+ devices: ${oc.env:NUM_DEVICES, 1}
11
+ callbacks:
12
+ - class_path: eva.callbacks.ConfigurationLogger
13
+ - class_path: lightning.pytorch.callbacks.TQDMProgressBar
14
+ init_args:
15
+ refresh_rate: ${oc.env:TQDM_REFRESH_RATE, 1}
16
+ - class_path: lightning.pytorch.callbacks.LearningRateMonitor
17
+ init_args:
18
+ logging_interval: epoch
19
+ - class_path: lightning.pytorch.callbacks.ModelCheckpoint
20
+ init_args:
21
+ filename: best
22
+ save_last: ${oc.env:SAVE_LAST, false}
23
+ save_top_k: 1
24
+ monitor: &MONITOR_METRIC ${oc.env:MONITOR_METRIC, val/BinaryBalancedAccuracy}
25
+ mode: &MONITOR_METRIC_MODE ${oc.env:MONITOR_METRIC_MODE, max}
26
+ - class_path: lightning.pytorch.callbacks.EarlyStopping
27
+ init_args:
28
+ min_delta: 0
29
+ patience: ${oc.env:PATIENCE, 3}
30
+ monitor: *MONITOR_METRIC
31
+ mode: *MONITOR_METRIC_MODE
32
+ - class_path: eva.callbacks.ClassificationEmbeddingsWriter
33
+ init_args:
34
+ output_dir: &DATASET_EMBEDDINGS_ROOT ${oc.env:EMBEDDINGS_ROOT, ./data/embeddings}/${oc.env:MODEL_NAME, dino_vits16}/patch_camelyon
35
+ dataloader_idx_map:
36
+ 0: train
37
+ 1: val
38
+ 2: test
39
+ backbone:
40
+ class_path: eva.core.models.wrappers.ModelFromFunction
41
+ init_args:
42
+ path: ${oc.env:BACKBONE_FN, openpath_eva_backbone.build_openpath}
43
+ arguments:
44
+ weights: ${oc.env:OPENPATH_WEIGHTS, "none"}
45
+ overwrite: false
46
+ logger:
47
+ - class_path: lightning.pytorch.loggers.TensorBoardLogger
48
+ init_args:
49
+ save_dir: *OUTPUT_ROOT
50
+ name: ""
51
+ model:
52
+ class_path: eva.HeadModule
53
+ init_args:
54
+ head:
55
+ class_path: torch.nn.Linear
56
+ init_args:
57
+ in_features: ${oc.env:IN_FEATURES, 384}
58
+ out_features: 1
59
+ criterion: torch.nn.BCEWithLogitsLoss
60
+ optimizer:
61
+ class_path: torch.optim.AdamW
62
+ init_args:
63
+ lr: ${oc.env:LR_VALUE, 0.0003}
64
+ metrics:
65
+ common:
66
+ - class_path: eva.metrics.AverageLoss
67
+ - class_path: eva.metrics.BinaryClassificationMetrics
68
+ data:
69
+ class_path: eva.DataModule
70
+ init_args:
71
+ datasets:
72
+ train:
73
+ class_path: eva.datasets.EmbeddingsClassificationDataset
74
+ init_args: &DATASET_ARGS
75
+ root: *DATASET_EMBEDDINGS_ROOT
76
+ manifest_file: manifest.csv
77
+ split: train
78
+ target_transforms:
79
+ class_path: torchvision.transforms.v2.ToDtype
80
+ init_args:
81
+ dtype: torch.float32
82
+ val:
83
+ class_path: eva.datasets.EmbeddingsClassificationDataset
84
+ init_args:
85
+ <<: *DATASET_ARGS
86
+ split: val
87
+ test:
88
+ class_path: eva.datasets.EmbeddingsClassificationDataset
89
+ init_args:
90
+ <<: *DATASET_ARGS
91
+ split: test
92
+ predict:
93
+ - class_path: eva.vision.datasets.PatchCamelyon
94
+ init_args: &PREDICT_DATASET_ARGS
95
+ root: ${oc.env:DATA_ROOT, ./data/patch_camelyon}
96
+ split: train
97
+ download: ${oc.env:DOWNLOAD_DATA, false}
98
+ # Set `download: true` to download the dataset from https://zenodo.org/records/1494286
99
+ # The PatchCamelyon dataset is distributed under the following license:
100
+ # "Creative Commons Zero v1.0 Universal"
101
+ # (see: https://choosealicense.com/licenses/cc0-1.0/)
102
+ transforms:
103
+ class_path: eva.vision.data.transforms.common.ResizeAndCrop
104
+ init_args:
105
+ size: ${oc.env:RESIZE_DIM, 224}
106
+ mean: ${oc.env:NORMALIZE_MEAN, [0.485, 0.456, 0.406]}
107
+ std: ${oc.env:NORMALIZE_STD, [0.229, 0.224, 0.225]}
108
+ - class_path: eva.vision.datasets.PatchCamelyon
109
+ init_args:
110
+ <<: *PREDICT_DATASET_ARGS
111
+ split: val
112
+ - class_path: eva.vision.datasets.PatchCamelyon
113
+ init_args:
114
+ <<: *PREDICT_DATASET_ARGS
115
+ split: test
116
+ dataloaders:
117
+ train:
118
+ batch_size: &BATCH_SIZE ${oc.env:BATCH_SIZE, 256}
119
+ num_workers: &N_DATA_WORKERS ${oc.env:N_DATA_WORKERS, 4}
120
+ shuffle: true
121
+ val:
122
+ batch_size: *BATCH_SIZE
123
+ num_workers: *N_DATA_WORKERS
124
+ test:
125
+ batch_size: *BATCH_SIZE
126
+ num_workers: *N_DATA_WORKERS
127
+ predict:
128
+ batch_size: &PREDICT_BATCH_SIZE ${oc.env:PREDICT_BATCH_SIZE, 64}
129
+ num_workers: *N_DATA_WORKERS
eval/openpath_eva_backbone.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """eva ์—ฐ๋™์šฉ OpenPath ViT-g/14 ๋ฐฑ๋ณธ ํŒฉํ† ๋ฆฌ.
2
+
3
+ eva์˜ ModelFromFunction์ด ํ˜ธ์ถœ. dinov2 vit_giant2๋ฅผ ๋นŒ๋“œํ•˜๊ณ  ์ถ”์ถœ๋œ teacher
4
+ state_dict(.pth)๋ฅผ ๋กœ๋“œํ•ด, forward(x)->(B,1536) CLS ์ž„๋ฒ ๋”ฉ์„ ๋ฐ˜ํ™˜ํ•˜๋Š” nn.Module์„ ๋Œ๋ ค์ค€๋‹ค.
5
+ ์šฐ๋ฆฌ ํ•™์Šต/์ถ”์ถœ๊ณผ ๋™์ผ ๊ทœ์•ฝ(ImageNet norm์€ eva ์ชฝ transform์ด ๋‹ด๋‹น; ์—ฌ๊ธฐ์„  ๋ชจ๋ธ๋งŒ).
6
+ """
7
+ import torch
8
+ import torch.nn as nn
9
+
10
+
11
+ def build_openpath_vitg14(weights: str) -> nn.Module:
12
+ import dinov2.models.vision_transformer as vits
13
+
14
+ ck = torch.load(weights, map_location="cpu", weights_only=False)
15
+ arch = ck.get("arch", "vit_giant2")
16
+ kw = dict(patch_size=ck.get("patch_size", 14), img_size=224,
17
+ block_chunks=0, init_values=1.0)
18
+ if arch == "vit_giant2":
19
+ kw["ffn_layer"] = "swiglufused"
20
+ model = getattr(vits, arch)(**kw)
21
+ miss, unexp = model.load_state_dict(ck["teacher_backbone"], strict=True)
22
+ assert not miss and not unexp, f"load mismatch miss={miss} unexp={unexp}"
23
+
24
+ class CLSWrapper(nn.Module):
25
+ def __init__(self, m):
26
+ super().__init__()
27
+ self.m = m
28
+
29
+ @torch.no_grad()
30
+ def forward(self, x):
31
+ out = self.m(x) # vit_giant2(x) -> (B, embed_dim) CLS
32
+ if out.ndim == 3: # ํ˜น์‹œ ํ† ํฐ ์‹œํ€€์Šค๋ฉด CLS ์ถ”์ถœ
33
+ out = out[:, 0, :]
34
+ return out
35
+
36
+ return CLSWrapper(model).eval()
37
+
38
+
39
+ def build_phikon(weights: str = None) -> nn.Module:
40
+ """Phikon-v2 (owkin/phikon-v2, ViT-L, CLS=1024). weights ๋ฌด์‹œ(HF ๋กœ๋“œ)."""
41
+ from transformers import AutoModel
42
+ base = AutoModel.from_pretrained("owkin/phikon-v2")
43
+
44
+ class W(nn.Module):
45
+ def __init__(self, m):
46
+ super().__init__(); self.m = m
47
+ @torch.no_grad()
48
+ def forward(self, x):
49
+ return self.m(pixel_values=x).last_hidden_state[:, 0, :] # CLS
50
+ print("[eva] backbone=Phikon-v2 (CLS 1024)", flush=True)
51
+ return W(base).eval()
52
+
53
+
54
+ def build_openmidnight(weights: str = None) -> nn.Module:
55
+ """OpenMidnight (SophontAI/OpenMidnight) teacher_checkpoint โ†’ dinov2 CLS.
56
+ weights ๋ฏธ์ง€์ •์‹œ HF ์บ์‹œ์„œ ์ž๋™ ๋กœ๋“œ. (run_hest_3way build_om๊ณผ ๋™์ผ ๊ทœ์•ฝ, block_chunks=4)"""
57
+ import dinov2.models.vision_transformer as vits
58
+ if not weights or weights in ("none", "None", ""):
59
+ from huggingface_hub import hf_hub_download
60
+ weights = hf_hub_download("SophontAI/OpenMidnight", "teacher_checkpoint.pth")
61
+ ck = torch.load(weights, map_location="cpu", weights_only=False)
62
+ t = ck["teacher"] if "teacher" in ck else ck
63
+ sd = {k[len("backbone."):]: v for k, v in t.items() if k.startswith("backbone.")}
64
+ embed_dim = sd["cls_token"].shape[-1]
65
+ arch, ffn = ("vit_giant2", "swiglufused") if embed_dim == 1536 else ("vit_large", "mlp")
66
+ m = getattr(vits, arch)(patch_size=14, img_size=224, block_chunks=4,
67
+ num_register_tokens=4, ffn_layer=ffn, init_values=1.0e-05,
68
+ interpolate_antialias=True, interpolate_offset=0.0)
69
+ miss, unexp = m.load_state_dict(sd, strict=True)
70
+ assert not miss and not unexp, f"OM miss={miss} unexp={unexp}"
71
+ print(f"[eva] backbone=OpenMidnight arch={arch} embed_dim={embed_dim}", flush=True)
72
+
73
+ class W(nn.Module):
74
+ def __init__(self, mm):
75
+ super().__init__(); self.m = mm
76
+ @torch.no_grad()
77
+ def forward(self, x):
78
+ return self.m.forward_features(x)["x_norm_clstoken"]
79
+ return W(m).eval()
80
+
81
+
82
+ def _cls_wrap(base):
83
+ class W(nn.Module):
84
+ def __init__(s, m): super().__init__(); s.m = m
85
+ @torch.no_grad()
86
+ def forward(s, x):
87
+ out = s.m(x)
88
+ if isinstance(out, (tuple, list)): out = out[0]
89
+ if out.ndim == 3: out = out[:, 0, :] # tokens โ†’ CLS
90
+ return out
91
+ return W(base).eval()
92
+
93
+
94
+ def build_timm_hub(repo, **kw):
95
+ """timm hf-hub ๋ณ‘๋ฆฌ FM (UNI/UNI2/Virchow2/gigapath). config.json ์ž๋™."""
96
+ import timm
97
+ base = timm.create_model(f"hf-hub:{repo}", pretrained=True, **kw)
98
+ print(f"[eva] backbone=timm:{repo}", flush=True)
99
+ return _cls_wrap(base)
100
+
101
+
102
+ def build_uni(weights=None): return build_timm_hub("MahmoodLab/UNI", init_values=1e-5, dynamic_img_size=True)
103
+ def build_uni2(weights=None):
104
+ return build_timm_hub("MahmoodLab/UNI2-h", img_size=224, patch_size=14, depth=24, num_heads=24,
105
+ init_values=1e-5, embed_dim=1536, mlp_ratio=2.66667*2, num_classes=0,
106
+ no_embed_class=True, mlp_layer=__import__("timm").layers.SwiGLUPacked,
107
+ act_layer=__import__("torch").nn.SiLU, reg_tokens=8, dynamic_img_size=True)
108
+ def build_virchow2(weights=None):
109
+ import timm
110
+ return build_timm_hub("paige-ai/Virchow2", mlp_layer=timm.layers.SwiGLUPacked, act_layer=__import__("torch").nn.SiLU)
111
+ def build_gigapath(weights=None): return build_timm_hub("prov-gigapath/prov-gigapath", dynamic_img_size=True)
112
+
113
+
114
+ def build_openpath(weights: str) -> nn.Module:
115
+ """OpenPath teacher_checkpoint(dinov2 ViT-g/14 reg4) โ†’ CLS. our run ์ฒดํฌํฌ์ธํŠธ ๋กœ๋”."""
116
+ return build_openmidnight(weights)
117
+
118
+
119
+ class BNHead(nn.Module):
120
+ """eva linear-probe head: BatchNorm1d(์ฐจ์›๋ณ„ ํ‘œ์ค€ํ™”) โ†’ Linear.
121
+ ์šฐ๋ฆฌ ์ž„๋ฒ ๋”ฉ์€ ์ผ๋ถ€ ์ฐจ์›์— massive activation(norm~500)์ด ์žˆ์–ด raw Linear๊ฐ€
122
+ ํ•™์Šต ์‹คํŒจ. BN์œผ๋กœ per-dim ํ‘œ์ค€ํ™”ํ•˜๋ฉด sklearn StandardScaler์™€ ๋™๋“ฑํ•ด์ ธ ํšŒ๋ณต."""
123
+ def __init__(self, in_features: int, out_features: int):
124
+ super().__init__()
125
+ self.bn = nn.BatchNorm1d(in_features, affine=True)
126
+ self.fc = nn.Linear(in_features, out_features)
127
+
128
+ def forward(self, x):
129
+ return self.fc(self.bn(x))
eval/run_patch_eval.sh ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Patch ๋ถ„๋ฅ˜ ๋ฒค์น˜(pcam/crc/bach)๋ฅผ kaiko-eva๋กœ frozen-encoder linear probe.
3
+ # ์ฐธ์กฐ FM๊ณผ OpenPath๋ฅผ ๋™์ผ ํ”„๋กœํ† ์ฝœ๋กœ ๋น„๊ต.
4
+ # model: openpath|openmidnight|phikon|uni|uni2|gigapath|virchow2
5
+ # bench: pcam|crc|bach
6
+ # ์‚ฌ์šฉ: [GPU=0] [N_RUNS=5] bash eval/run_patch_eval.sh <model> <bench> [weights]
7
+ # openpath๋Š” weights(teacher_checkpoint.pth) ํ•„์ˆ˜. evaยทtimmยท๋ฐ์ดํ„ฐ์…‹์€ ์‚ฌ์ „ ์„ค์น˜/๋‹ค์šด๋กœ๋“œ ํ•„์š”.
8
+ set -u
9
+ R="${OPENPATH_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" # repo ๋ฃจํŠธ(eval/์˜ ์ƒ์œ„)
10
+ cd "$R"
11
+ export PYTHONPATH="$R/OpenPath:$R/eval" # dinov2(OpenPath/) + backbone(eval/)
12
+ export CUDA_VISIBLE_DEVICES="${GPU:-0}" # eva๋Š” ๋‹จ์ผ GPU ๊ถŒ์žฅ
13
+ export N_RUNS="${N_RUNS:-5}"
14
+ export ACCELERATOR=gpu NUM_DEVICES=1 OPENPATH_WEIGHTS="${3:-none}"
15
+ export PREDICT_BATCH_SIZE="${PREDICT_BATCH_SIZE:-512}" BATCH_SIZE="${BATCH_SIZE:-4096}" N_DATA_WORKERS="${N_DATA_WORKERS:-8}"
16
+ model="$1"; bench="$2"
17
+
18
+ case "$model" in
19
+ openpath) export BACKBONE_FN=openpath_eva_backbone.build_openpath IN_FEATURES=1536 ;;
20
+ openmidnight) export BACKBONE_FN=openpath_eva_backbone.build_openmidnight IN_FEATURES=1536 ;;
21
+ phikon) export BACKBONE_FN=openpath_eva_backbone.build_phikon IN_FEATURES=1024 ;;
22
+ uni) export BACKBONE_FN=openpath_eva_backbone.build_uni IN_FEATURES=1024 ;;
23
+ uni2) export BACKBONE_FN=openpath_eva_backbone.build_uni2 IN_FEATURES=1536 ;;
24
+ gigapath) export BACKBONE_FN=openpath_eva_backbone.build_gigapath IN_FEATURES=1536 ;;
25
+ virchow2) export BACKBONE_FN=openpath_eva_backbone.build_virchow2 IN_FEATURES=1280 ;;
26
+ *) echo "unknown model $model (openpath|openmidnight|phikon|uni|uni2|gigapath|virchow2)"; exit 1 ;;
27
+ esac
28
+ export MODEL_NAME="${MODEL_NAME:-$model}"
29
+
30
+ case "$bench" in
31
+ pcam) cfg=eval/eva_configs/patch_camelyon.yaml; export DATA_ROOT="$R/data/eva/pcam_h5" DOWNLOAD_DATA="${DL:-true}" ;;
32
+ crc) cfg=eval/eva_configs/crc.yaml; export DATA_ROOT="$R/data/eva/crc" DOWNLOAD_DATA=false ;;
33
+ bach) cfg=eval/eva_configs/bach.yaml; export DATA_ROOT="$R/data/eva/bach" DOWNLOAD_DATA="${DL:-false}" ;;
34
+ *) echo "unknown bench $bench (pcam|crc|bach)"; exit 1 ;;
35
+ esac
36
+
37
+ # eva predict_fit๋Š” ์ž„๋ฒ ๋”ฉ ์ถœ๋ ฅํด๋”๊ฐ€ ์žˆ์œผ๋ฉด ๊ฑฐ๋ถ€ โ†’ ๋งค ์‹คํ–‰ ์ „ ์ •๋ฆฌ(fresh ์ถ”์ถœ).
38
+ # eva ์ž„๋ฒ ๋”ฉ ํด๋”๋ช…์€ ๋ฐ์ดํ„ฐ์…‹๋ช…: pcamโ†’patch_camelyon.
39
+ evadir="$bench"; [ "$bench" = pcam ] && evadir=patch_camelyon
40
+ rm -rf "$R/data/embeddings/$MODEL_NAME/$evadir" 2>/dev/null
41
+
42
+ echo "=== EVAL $model ร— $bench | IN=$IN_FEATURES N_RUNS=$N_RUNS GPU=$CUDA_VISIBLE_DEVICES DATA_ROOT=$DATA_ROOT DL=$DOWNLOAD_DATA | $(date) ==="
43
+ eva predict_fit --config "$cfg"
44
+ echo "=== DONE $model ร— $bench | $(date) ==="
eval/st_bench.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """HEST-style spatial-transcriptomics(ST) ๋ฒค์น˜๋งˆํฌ โ€” in-house Visium ์ฝ”ํ˜ธํŠธ.
3
+ ๊ฐ spot์—์„œ WSI ํŒจ์น˜ ์ถ”์ถœ โ†’ FM ์ž„๋ฒ ๋”ฉ(CLS) โ†’ PCA โ†’ Ridge ํšŒ๊ท€๋กœ ์ƒ์œ„ HVG ๋ฐœํ˜„ ์˜ˆ์ธก
4
+ โ†’ Pearson ์ƒ๊ด€(์œ ์ „์žํ‰๊ท ). Leave-one-patient-out CV.
5
+
6
+ ์‚ฌ์šฉ:
7
+ PYTHONPATH=OpenPath:eval venv_eva/bin/python eval/st_bench.py \
8
+ --backbone openmidnight # ์ฐธ์กฐ: OpenMidnight
9
+ ... --backbone phikon
10
+ ... --backbone openpath --weights data/runs/openpath_run/eval/training_316250/teacher_checkpoint.pth
11
+ """
12
+ import os, sys, json, glob, argparse, re
13
+ import numpy as np, pandas as pd
14
+ import torch, openslide
15
+ from PIL import Image
16
+ from sklearn.linear_model import Ridge
17
+ from sklearn.decomposition import PCA
18
+ from sklearn.preprocessing import StandardScaler
19
+ from scipy.stats import pearsonr
20
+ import torchvision.transforms as T
21
+
22
+ ROOT = os.environ.get("OPENPATH_ROOT", os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # repo ๋ฃจํŠธ(eval/์˜ ์ƒ์œ„)
23
+ DATA = os.environ.get("ST_ROOT", f"{ROOT}/data/st_bench") # in-house Visium ST ์ฝ”ํ˜ธํŠธ(๋น„๊ณต๊ฐœ; ์ฝ”๋“œ๋งŒ ๊ณต๊ฐœ)
24
+ IMAGENET_MEAN = (0.485, 0.456, 0.406); IMAGENET_STD = (0.229, 0.224, 0.225)
25
+
26
+
27
+ def build_backbone(name, weights):
28
+ sys.path.insert(0, f"{ROOT}/OpenPath"); sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
29
+ import openpath_eva_backbone as B
30
+ ref = {"uni": (B.build_uni, 1024), "uni2": (B.build_uni2, 1536),
31
+ "gigapath": (B.build_gigapath, 1536),
32
+ "virchow2": (B.build_virchow2, 1280),
33
+ "phikon": (B.build_phikon, 1024),
34
+ "openmidnight": (B.build_openmidnight, 1536)}
35
+ if name in ref:
36
+ fn, dim = ref[name]; return fn(), dim
37
+ # openpath: ์šฐ๋ฆฌ teacher_checkpoint(dinov2 teacher ํฌ๋งท) ๋กœ๋”
38
+ return B.build_openpath(weights), 1536
39
+
40
+
41
+ def slide_dirs():
42
+ return sorted([d for d in glob.glob(f"{DATA}/*") if os.path.isdir(d)])
43
+
44
+
45
+ def patient_of(slide_dir):
46
+ b = os.path.basename(slide_dir) # ์˜ˆ: pt<N>-<M> ๋˜๋Š” pt<N>
47
+ m = re.match(r"(pt\d+)", b)
48
+ return m.group(1) if m else b # pt<N>-<M>, pt<N>-<K> โ†’ ๋™์ผ ํ™˜์ž pt<N>
49
+
50
+
51
+ def load_one(slide_dir):
52
+ exp_f = glob.glob(f"{slide_dir}/*.spatial.data.exp.csv")
53
+ pos_f = glob.glob(f"{slide_dir}/*.tissue_positions_fullres.csv")
54
+ sf_f = glob.glob(f"{slide_dir}/*.scalefactors_json.json")
55
+ wsi_f = glob.glob(f"{slide_dir}/*_p0.tif")
56
+ if not (exp_f and pos_f and sf_f and wsi_f):
57
+ return None
58
+ exp = pd.read_csv(exp_f[0], index_col=0) # spots ร— genes (log-norm)
59
+ pos = pd.read_csv(pos_f[0], index_col=0) # barcode โ†’ pxl coords
60
+ sf = json.load(open(sf_f[0]))
61
+ diam = int(round(sf["spot_diameter_fullres"])) # ~147px
62
+ common = exp.index.intersection(pos.index)
63
+ exp = exp.loc[common]; pos = pos.loc[common]
64
+ return dict(dir=slide_dir, exp=exp, pos=pos, diam=diam, wsi=wsi_f[0])
65
+
66
+
67
+ def _load_patches(sl):
68
+ """224 uint8 ํŒจ์น˜ (N,224,224,3). ์Šฌ๋ผ์ด๋“œ๋ณ„ ์บ์‹œ ์žฌ์‚ฌ์šฉ(์ฒดํฌํฌ์ธํŠธ๋งˆ๋‹ค ๋™์ผ ํŒจ์น˜)."""
69
+ import numpy as np
70
+ cache = f"{DATA}/_pcache/{os.path.basename(sl['dir'])}.pt"
71
+ if os.path.exists(cache):
72
+ try: return torch.load(cache)
73
+ except Exception: pass
74
+ sldx = openslide.OpenSlide(sl["wsi"]); d = sl["diam"]
75
+ rs = T.Resize((224, 224))
76
+ xs = sl["pos"]["pxl_x_in_fullres"].values.astype(int)
77
+ ys = sl["pos"]["pxl_y_in_fullres"].values.astype(int)
78
+ plist = []
79
+ for x, y in zip(xs, ys):
80
+ patch = sldx.read_region((int(x - d // 2), int(y - d // 2)), 0, (d, d)).convert("RGB")
81
+ plist.append(torch.from_numpy(np.asarray(rs(patch)))) # (224,224,3) uint8
82
+ sldx.close()
83
+ patches = torch.stack(plist)
84
+ os.makedirs(os.path.dirname(cache), exist_ok=True)
85
+ tmp = cache + f".tmp{os.getpid()}"
86
+ torch.save(patches, tmp); os.replace(tmp, cache) # atomic
87
+ return patches
88
+
89
+
90
+ @torch.no_grad()
91
+ def embed_slide(sl, model, device, bs=256):
92
+ patches = _load_patches(sl) # (N,224,224,3) uint8
93
+ mean = torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1)
94
+ std = torch.tensor(IMAGENET_STD).view(1, 3, 1, 1)
95
+ embs = []
96
+ for i in range(0, len(patches), bs):
97
+ b = patches[i:i + bs].permute(0, 3, 1, 2).float().div_(255.0) # (B,3,224,224)
98
+ b = ((b - mean) / std).to(device)
99
+ embs.append(model(b).float().cpu().numpy())
100
+ return np.concatenate(embs, 0) # (n_spots, dim)
101
+
102
+
103
+ def top_hvg(exp_all, k=50):
104
+ # ํ•™์Šต์…‹ ์ „์ฒด log-norm ๋ฐœํ˜„์„œ ๋ถ„์‚ฐ ์ƒ์œ„ k ์œ ์ „์ž
105
+ v = exp_all.var(axis=0)
106
+ return v.sort_values(ascending=False).index[:k].tolist()
107
+
108
+
109
+ def main():
110
+ ap = argparse.ArgumentParser()
111
+ ap.add_argument("--backbone", required=True, choices=["openpath","openmidnight","phikon","uni","uni2","gigapath","virchow2"])
112
+ ap.add_argument("--weights", default=None)
113
+ ap.add_argument("--k-genes", type=int, default=50)
114
+ ap.add_argument("--pca", type=int, default=256)
115
+ ap.add_argument("--alpha", type=float, default=100.0)
116
+ ap.add_argument("--tag", default=None)
117
+ args = ap.parse_args()
118
+ device = "cuda"
119
+
120
+ model, dim = build_backbone(args.backbone, args.weights)
121
+ model = model.to(device).eval()
122
+
123
+ dirs = slide_dirs()
124
+ print(f"[st]slides={len(dirs)} backbone={args.backbone} dim={dim}", flush=True)
125
+ slides = []
126
+ for d in dirs:
127
+ sl = load_one(d)
128
+ if sl is None: print(f" skip {os.path.basename(d)} (ํŒŒ์ผ ๋ถ€์กฑ)"); continue
129
+ sl["emb"] = embed_slide(sl, model, device)
130
+ sl["pat"] = patient_of(d)
131
+ slides.append(sl)
132
+ print(f" {os.path.basename(d)}: spots={len(sl['pos'])} emb={sl['emb'].shape} pat={sl['pat']}", flush=True)
133
+
134
+ # ๊ณตํ†ต ์œ ์ „์ž
135
+ genes = slides[0]["exp"].columns
136
+ for s in slides[1:]: genes = genes.intersection(s["exp"].columns)
137
+ genes = list(genes)
138
+ print(f"[st]๊ณตํ†ต ์œ ์ „์ž {len(genes)}", flush=True)
139
+
140
+ pats = sorted(set(s["pat"] for s in slides))
141
+ # leave-one-patient-out
142
+ per_gene_corr = []
143
+ for held in pats:
144
+ tr = [s for s in slides if s["pat"] != held]
145
+ te = [s for s in slides if s["pat"] == held]
146
+ Xtr = np.concatenate([s["emb"] for s in tr], 0)
147
+ Ytr = np.concatenate([s["exp"][genes].values for s in tr], 0)
148
+ Xte = np.concatenate([s["emb"] for s in te], 0)
149
+ Yte = np.concatenate([s["exp"][genes].values for s in te], 0)
150
+ # HVG๋Š” ํ•™์Šต์…‹์„œ ์„ ํƒ
151
+ hvg_idx = np.argsort(-Ytr.var(0))[:args.k_genes]
152
+ Ytr_h, Yte_h = Ytr[:, hvg_idx], Yte[:, hvg_idx]
153
+ # ํ‘œ์ค€ํ™” + PCA + Ridge
154
+ sc = StandardScaler().fit(Xtr)
155
+ Xtr2, Xte2 = sc.transform(Xtr), sc.transform(Xte)
156
+ p = PCA(n_components=min(args.pca, Xtr2.shape[1])).fit(Xtr2)
157
+ Xtr3, Xte3 = p.transform(Xtr2), p.transform(Xte2)
158
+ reg = Ridge(alpha=args.alpha).fit(Xtr3, Ytr_h)
159
+ pred = reg.predict(Xte3)
160
+ cors = []
161
+ for g in range(Yte_h.shape[1]):
162
+ if Yte_h[:, g].std() < 1e-8 or pred[:, g].std() < 1e-8:
163
+ cors.append(0.0)
164
+ else:
165
+ cors.append(pearsonr(Yte_h[:, g], pred[:, g])[0])
166
+ m = float(np.nanmean(cors))
167
+ per_gene_corr.append(m)
168
+ print(f" [fold {held}] test_spots={Yte.shape[0]} meanPearson={m:.4f}", flush=True)
169
+
170
+ overall = float(np.mean(per_gene_corr))
171
+ tag = args.tag or args.backbone
172
+ print(f"[ST RESULT] backbone={tag} | LOPO mean Pearson = {overall:.4f} (folds={len(pats)}, HVG={args.k_genes})", flush=True)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()