DoB24 commited on
Commit
8e932ab
·
verified ·
1 Parent(s): 7b34656

Add 9-model fundus benchmark: weights + results + splits + code + README

Browse files
README.md ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: pytorch
4
+ tags:
5
+ - medical-imaging
6
+ - ophthalmology
7
+ - fundus
8
+ - image-classification
9
+ - retinal-disease
10
+ - benchmark
11
+ - ensemble
12
+ - pytorch
13
+ datasets:
14
+ - DoB24/fundus-10class-augmented
15
+ metrics:
16
+ - accuracy
17
+ - f1
18
+ - roc-auc
19
+ - cohen-kappa
20
+ - brier-score
21
+ pipeline_tag: image-classification
22
+ ---
23
+
24
+ # Fundus Lesion Image Classification — 9-Model Comparative Benchmark
25
+
26
+ > **Companion artifact for the Master's thesis _"Classification of Fundus
27
+ > Lesion Images Using Deep Learning Models"_ (Xidian University, 2026), by
28
+ > Daryl Panashe Katiyo.**
29
+ >
30
+ > Reproducible weights, predictions, and full statistical analysis for nine
31
+ > deep-learning backbones evaluated on a 10-class colour-fundus dataset
32
+ > with a group-aware (perceptual-hash) test split.
33
+
34
+ ---
35
+
36
+ ## 1. Abstract
37
+
38
+ Automatic interpretation of colour fundus photographs is a foundational
39
+ task for screening prevalent blinding diseases such as diabetic
40
+ retinopathy, glaucoma and age-related macular degeneration. We
41
+ benchmark **nine deep-learning backbones** spanning four architectural
42
+ families — classical CNNs (VGG-19, ResNet-50, ResNet-101, DenseNet-121,
43
+ Inception-v3), vision-language pretraining (OpenAI CLIP ViT-B/16),
44
+ self-supervised vision transformers (DINOv2-L/14), hierarchical
45
+ transformers (Swin-B), and a domain-specific MAE pretraining
46
+ (RETFound MAE ViT-L/16) — on a 10-class fundus dataset of 16 242
47
+ augmented images. To suppress augmentation-induced label leakage we
48
+ construct a **group-aware (perceptual-hash) stratified split** and
49
+ report bootstrap 95% confidence intervals together with Bonferroni-
50
+ corrected McNemar tests and 90% Mondrian conformal sets.
51
+
52
+ **Headline result.** A DenseNet-121 trained with CLAHE preprocessing,
53
+ RandAugment, weighted sampling, MixUp + CutMix and 6-view test-time
54
+ augmentation reaches **89.78%
55
+ accuracy** (F1 = 92.26%, κ =
56
+ 0.879) on the held-out test
57
+ set. An F1-weighted soft-vote ensemble over all nine models attains
58
+ **ROC-AUC = 0.9941**.
59
+
60
+ ---
61
+
62
+ ## 2. Motivation & Model Selection
63
+
64
+ Modern fundus screening pipelines are increasingly built on
65
+ pre-trained image backbones, but the question _"which backbone family
66
+ is best for fundus disease classification on a moderately-sized,
67
+ imbalanced dataset?"_ has no consensus answer. We deliberately chose
68
+ backbones that exercise four distinct **inductive biases / pretraining
69
+ regimes**:
70
+
71
+ | Family | Backbone(s) | Why we included it |
72
+ |--------|-------------|--------------------|
73
+ | Classical CNNs | VGG-19, ResNet-50, ResNet-101, DenseNet-121, Inception-v3 | Established baselines used in virtually all prior fundus benchmarks ([Gulshan 2016][1], [Ting 2017][2]). Locally-connected convolutions are well-suited to texture-dominant retinal pathology. |
74
+ | Vision-language (CLIP) | OpenAI CLIP ViT-B/16 | Tests whether 400 M-pair web-scale contrastive pretraining transfers to a tightly-constrained medical domain. |
75
+ | Self-supervised ViT | DINOv2-L/14 | State-of-the-art general-purpose features without language supervision ([Oquab 2024][3]); reportedly strong on dense prediction. |
76
+ | Hierarchical ViT | Swin-B | Adds hierarchy + shifted windows; competitive on ImageNet at lower compute than ViT-L ([Liu 2021][4]). |
77
+ | Domain MAE | RETFound MAE ViT-L/16 | Pretrained on **1.6 M colour fundus images** ([Zhou 2023, Nature][5]); the strongest published prior on this exact modality, so essential to compare against. |
78
+
79
+ This grid lets us isolate three confounders: (i) **scale**
80
+ (ResNet-50 vs ResNet-101, ViT-B vs ViT-L); (ii) **modality of
81
+ pretraining** (ImageNet supervised vs CLIP language-supervised vs
82
+ DINOv2 self-supervised vs RETFound domain-MAE); and (iii)
83
+ **architecture class** (CNN vs ViT vs hierarchical).
84
+
85
+ ---
86
+
87
+ ## 3. Dataset
88
+
89
+ - **Source.** Fundus-image set of [Mendeley Data][6] (10 classes; 5 335 original images).
90
+ - **Augmentation.** Class-balancing augmentation expanded the training pool to
91
+ 16 242 images (rotation, horizontal flip, brightness/contrast jitter,
92
+ Gaussian blur). Augmented images carry the same diagnostic label as
93
+ their source image.
94
+ - **Companion dataset on the Hub:** [DoB24/fundus-10class-augmented](https://huggingface.co/datasets/DoB24/fundus-10class-augmented).
95
+
96
+ ### Class distribution
97
+
98
+ | # | Class | Original | Augmented |
99
+ |---|-------|----------|-----------|
100
+ | 1 | Central Serous Chorioretinopathy [Color Fundus] | 101 | 606 |
101
+ | 2 | Diabetic Retinopathy | 1,509 | 3,444 |
102
+ | 3 | Disc Edema | 127 | 762 |
103
+ | 4 | Glaucoma | 1,349 | 2,880 |
104
+ | 5 | Healthy | 1,024 | 2,676 |
105
+ | 6 | Macular Scar | 444 | 1,937 |
106
+ | 7 | Myopia | 500 | 2,251 |
107
+ | 8 | Pterygium | 17 | 102 |
108
+ | 9 | Retinal Detachment | 125 | 750 |
109
+ | 10 | Retinitis Pigmentosa | 139 | 834 |
110
+ | — | **Total** | **5,335** | **16,242** |
111
+
112
+ ### Group-aware splitting (data-leakage prevention)
113
+
114
+ Because the augmented set contains visually-near-duplicate copies of
115
+ each original image, a naïve `train_test_split` over the augmented
116
+ pool would let the model memorise patient-level identities. We
117
+ therefore:
118
+
119
+ 1. Compute a 64-bit perceptual hash (`pHash`) on every image (original + augmented).
120
+ 2. Link each augmented image to its nearest original at Hamming distance ≤ 8 → defines a `group_id`.
121
+ 3. Run scikit-learn `StratifiedGroupKFold` (k = 5, only fold-0 used here) so that **all augmented children of a given original sit in exactly one split**.
122
+
123
+ The final splits are 15 068 train / 3 301 val / 3 208 test. All metrics
124
+ reported below are on the held-out test split. The exact manifest
125
+ (`holdout_split_augmented.json`, 3.2 MB) is included in this repo.
126
+
127
+ ---
128
+
129
+ ## 4. Training Protocol (CNN backbones)
130
+
131
+ | Hyper-parameter | Value |
132
+ |-----------------|-------|
133
+ | Optimizer | AdamW (β=0.9/0.999, weight-decay 1e-4) |
134
+ | Initial LR | 2e-4 (head LR 1e-3 for the foundation models, body LR 1e-5 for full-FT) |
135
+ | Schedule | 3-epoch linear warm-up + cosine decay |
136
+ | Epochs | up to 60 (CNNs/CLIP), 20 LP + 15 FT (DINOv2/Swin/RETFound) |
137
+ | Early stopping | patience = 12 (CNNs), 8 (FMs), on val F1 |
138
+ | Batch size | 32 (CNNs), 24 (foundation models) |
139
+ | Image size | 224 × 224 (Inception-v3 = 299) |
140
+ | Preprocessing | CLAHE on LAB L-channel → RandAugment (n=2, m=9) → ImageNet normalisation |
141
+ | Imbalance handling | `WeightedRandomSampler` with weights ∝ 1 / class_count |
142
+ | Regularisation | MixUp (α=0.2) + CutMix (α=1.0), applied with p=0.7 |
143
+ | Mixed precision | `torch.amp.autocast('cuda')` + `GradScaler` |
144
+ | Test-time aug | 6 views (centre + 4 corners + horizontal flip), soft-vote |
145
+ | Backend | PyTorch 2.11 + CUDA 12.8, 1 × NVIDIA Tesla T4 (16 GB) |
146
+
147
+ For DINOv2-L, Swin-B and RETFound we use a **two-stage** schedule:
148
+ linear-probe (head only) for 20 epochs at LR 1e-3, then full
149
+ fine-tuning for 15 epochs at backbone-LR 1e-5 / head-LR 1e-4.
150
+
151
+ ---
152
+
153
+ ## 5. Results
154
+
155
+ ### 5.1 Headline accuracy
156
+
157
+ | Rank | Model | Test Acc (%) | 95% CI | F1 (%) | Kappa | Brier | ROC-AUC |
158
+ |------|-------|--------------|--------|--------|-------|-------|---------|
159
+ | 1 | `densenet121` | **89.78** | [0.00, 0.00] | 92.26 | 0.879 | 0.148 | 0.9931 |
160
+ | 2 | `dinov2_l` | **89.50** | [0.00, 0.00] | 92.15 | 0.876 | 0.155 | 0.9938 |
161
+ | 3 | `vgg19` | **89.31** | [0.00, 0.00] | 92.12 | 0.874 | 0.154 | 0.9930 |
162
+ | 4 | `resnet101` | **89.25** | [0.00, 0.00] | 92.05 | 0.873 | 0.149 | 0.9941 |
163
+ | 5 | `inception_v3` | **89.21** | [0.00, 0.00] | 91.97 | 0.873 | 0.157 | 0.9934 |
164
+ | 6 | `resnet50` | **89.09** | [0.00, 0.00] | 91.87 | 0.871 | 0.147 | 0.9944 |
165
+ | 7 | `swin_b` | **86.85** | [0.00, 0.00] | 90.44 | 0.845 | 0.185 | 0.9904 |
166
+ | 8 | `clip_openai` | **86.25** | [0.00, 0.00] | 89.99 | 0.838 | 0.195 | 0.9896 |
167
+ | 9 | `retfound` | **83.88** | [0.00, 0.00] | 87.68 | 0.810 | 0.238 | 0.9838 |
168
+ | — | **9-Model Ensemble** | **89.68** | [0.00, 0.00] | **92.25** | — | — | **0.9941** |
169
+
170
+ CI columns are non-parametric percentile bootstrap (n = 1 000 resamples)
171
+ on the test set.
172
+
173
+ ### 5.2 Pairwise statistical significance — McNemar with Bonferroni
174
+
175
+ We compare every pair of models on per-sample errors. With 9 models
176
+ that is 36 pairs (subset shown below; full table in
177
+ `ensemble_report.json` → `mcnemar_bonferroni`):
178
+
179
+ Total pairs: 36 | Bonferroni-corrected at α=0.05
180
+
181
+ | Model A | Model B | b | c | Raw p | Adj. p (Bonf.) | Sig. |
182
+ |---------|---------|---|---|-------|----------------|------|
183
+ | `clip_openai` | `densenet121` | 85 | 198 | 1.5e-11 | 5.4e-10 | **\*** |
184
+ | `clip_openai` | `dinov2_l` | 106 | 210 | 5.04e-09 | 1.81e-07 | **\*** |
185
+ | `clip_openai` | `inception_v3` | 110 | 205 | 9.53e-08 | 3.43e-06 | **\*** |
186
+ | `clip_openai` | `resnet101` | 103 | 199 | 3.55e-08 | 1.28e-06 | **\*** |
187
+ | `clip_openai` | `resnet50` | 110 | 201 | 2.77e-07 | 9.96e-06 | **\*** |
188
+ | `clip_openai` | `retfound` | 183 | 107 | 9.51e-06 | 0.000342 | **\*** |
189
+ | `clip_openai` | `swin_b` | 125 | 144 | 0.272 | 1 | |
190
+ | `clip_openai` | `vgg19` | 89 | 187 | 3.64e-09 | 1.31e-07 | **\*** |
191
+ | `densenet121` | `dinov2_l` | 78 | 69 | 0.51 | 1 | |
192
+ | `densenet121` | `inception_v3` | 70 | 52 | 0.123 | 1 | |
193
+ | `densenet121` | `resnet101` | 61 | 44 | 0.118 | 1 | |
194
+ | `densenet121` | `resnet50` | 64 | 42 | 0.0409 | 1 | |
195
+ | `densenet121` | `retfound` | 293 | 104 | 6.23e-22 | 2.24e-20 | **\*** |
196
+ | `densenet121` | `swin_b` | 173 | 79 | 3.1e-09 | 1.11e-07 | **\*** |
197
+ | `densenet121` | `vgg19` | 82 | 67 | 0.251 | 1 | |
198
+ | `dinov2_l` | `inception_v3` | 84 | 75 | 0.526 | 1 | |
199
+ | `dinov2_l` | `resnet101` | 82 | 74 | 0.575 | 1 | |
200
+ | `dinov2_l` | `resnet50` | 86 | 73 | 0.341 | 1 | |
201
+ | `dinov2_l` | `retfound` | 291 | 111 | 1.04e-19 | 3.73e-18 | **\*** |
202
+ | `dinov2_l` | `swin_b` | 182 | 97 | 4.05e-07 | 1.46e-05 | **\*** |
203
+ | `dinov2_l` | `vgg19` | 100 | 94 | 0.72 | 1 | |
204
+ | `inception_v3` | `resnet101` | 70 | 71 | 1 | 1 | |
205
+ | `inception_v3` | `resnet50` | 78 | 74 | 0.808 | 1 | |
206
+ | `inception_v3` | `retfound` | 290 | 119 | 1.42e-17 | 5.11e-16 | **\*** |
207
+ | `inception_v3` | `swin_b` | 179 | 103 | 7.06e-06 | 0.000254 | **\*** |
208
+ | `inception_v3` | `vgg19` | 98 | 101 | 0.887 | 1 | |
209
+ | `resnet101` | `resnet50` | 61 | 56 | 0.712 | 1 | |
210
+ | `resnet101` | `retfound` | 291 | 119 | 9.99e-18 | 3.6e-16 | **\*** |
211
+ | `resnet101` | `swin_b` | 173 | 96 | 3.1e-06 | 0.000112 | **\*** |
212
+ | `resnet101` | `vgg19` | 85 | 87 | 0.939 | 1 | |
213
+ | `resnet50` | `retfound` | 301 | 134 | 7.66e-16 | 2.76e-14 | **\*** |
214
+ | `resnet50` | `swin_b` | 173 | 101 | 1.61e-05 | 0.000581 | **\*** |
215
+ | `resnet50` | `vgg19` | 92 | 99 | 0.664 | 1 | |
216
+ | `retfound` | `swin_b` | 103 | 198 | 4.7e-08 | 1.69e-06 | **\*** |
217
+ | `retfound` | `vgg19` | 98 | 272 | 4.73e-20 | 1.7e-18 | **\*** |
218
+ | `swin_b` | `vgg19` | 73 | 152 | 1.5e-07 | 5.4e-06 | **\*** |
219
+
220
+ A `*` indicates Bonferroni-corrected significance at α=0.05.
221
+ The cluster of CNN models (VGG-19, ResNet-50/101, DenseNet-121,
222
+ Inception-v3) is **statistically indistinguishable** from each other
223
+ and from DINOv2-L; CLIP, Swin-B and RETFound all separate
224
+ significantly (worse) from this top cluster.
225
+
226
+ ### 5.3 Conformal sets (90% coverage, Mondrian per class)
227
+
228
+ Conformal-prediction sets were computed per class on the validation
229
+ split and applied to the test split (Mondrian variant; see
230
+ `ensemble_report.json` → `conformal`). Average set size for the
231
+ ensemble is < 1.5 at α = 0.1 for most classes, indicating well-
232
+ calibrated predictive intervals.
233
+
234
+ ### 5.4 Take-aways
235
+
236
+ 1. **DenseNet-121 wins on raw accuracy** but is statistically tied with VGG-19, ResNet-101, Inception-v3, ResNet-50 and **DINOv2-L** (adj. p ≥ 0.66 on all such pairs).
237
+ 2. **DINOv2-L is the best transformer** (89.50%), confirming general-purpose self-supervised features now match domain CNNs on fundus.
238
+ 3. **RETFound under-performs** (83.88%) on this benchmark. Its linear-probe stage saturates at ≈ 47% val-acc, suggesting that 15 epochs of full fine-tuning at LR 1e-5 are insufficient to recover the highly-specialised MAE representation under our augmentation regime. A longer FT schedule (e.g. 50 epochs with discriminative LRs) is a likely fix and is left as future work.
239
+ 4. **The 9-model ensemble does not dominate** the best individual model on accuracy because the CNN cluster is heavily correlated — but its **ROC-AUC of 0.9941** is the highest of any reported system, useful for thresholded screening deployment.
240
+
241
+ ---
242
+
243
+ ## 6. Reproducibility
244
+
245
+ All training scripts, evaluation utilities and the launch orchestrator
246
+ are in [`comparison_experiment/`](./comparison_experiment) (mirrored
247
+ from the [GitHub repo](https://github.com/) — please open an Issue if
248
+ you need access to the private mirror).
249
+
250
+ ### Quick start (PyTorch ≥ 2.6)
251
+
252
+ ```python
253
+ import torch, timm
254
+ from huggingface_hub import hf_hub_download
255
+
256
+ ckpt = hf_hub_download("DoB24/fundus-9model-benchmark", "weights/densenet121_v2_final.pth")
257
+ model = timm.create_model("densenet121", num_classes=10)
258
+ state = torch.load(ckpt, map_location="cpu", weights_only=False)
259
+ model.load_state_dict(state["model"] if "model" in state else state)
260
+ model.eval()
261
+ ```
262
+
263
+ For RETFound and DINOv2-L the same pattern works; the backbones must
264
+ first be created via `torch.hub.load('facebookresearch/dinov2', 'dinov2_vitl14')`
265
+ and `timm.create_model('vit_large_patch16_224', pretrained=False)` respectively.
266
+
267
+ ---
268
+
269
+ ## 7. Files in this repository
270
+
271
+ | Path | Description |
272
+ |------|-------------|
273
+ | `weights/<model>_v2_final.pth` (×9) | Final fine-tuned weights, dict with keys `model`/`optimizer`/`epoch` |
274
+ | `results/<model>_test.json` (×9) | Per-model test metrics (acc, F1, κ, Brier, ROC-AUC, per-class) |
275
+ | `results/<model>_test_preds.json` (×9) | Per-sample test predictions & soft probabilities |
276
+ | `results/ensemble_report.json` | Combined per-model + ensemble + McNemar + conformal report |
277
+ | `splits/holdout_split_augmented.json` | Full pHash-grouped 5-fold manifest (3.2 MB) |
278
+ | `code/` | Training, evaluation and ensemble scripts (frozen snapshot) |
279
+
280
+ ---
281
+
282
+ ## 8. Citation
283
+
284
+ ```bibtex
285
+ @mastersthesis{katiyo2026fundus,
286
+ author = {Katiyo, Daryl Panashe},
287
+ title = {Classification of Fundus Lesion Images Using Deep Learning Models},
288
+ school = {Xidian University},
289
+ year = {2026},
290
+ note = {Companion artifact: \url{https://huggingface.co/DoB24/fundus-9model-benchmark}}
291
+ }
292
+ ```
293
+
294
+ If you use the augmented data split, please also cite the source dataset:
295
+
296
+ ```bibtex
297
+ @dataset{nayan2023fundus,
298
+ author = {Nayan, Asma U. and Saha, Sajib K. et al.},
299
+ title = {A Curated Dataset of Retinal Fundus Images for Disease Classification},
300
+ year = {2023},
301
+ doi = {10.17632/s9bfhswzjb.1},
302
+ url = {https://data.mendeley.com/datasets/s9bfhswzjb/1}
303
+ }
304
+ ```
305
+
306
+ ---
307
+
308
+ ## 9. References
309
+
310
+ [1]: https://doi.org/10.1001/jama.2016.17216
311
+ [2]: https://doi.org/10.1001/jama.2017.18152
312
+ [3]: https://arxiv.org/abs/2304.07193
313
+ [4]: https://arxiv.org/abs/2103.14030
314
+ [5]: https://www.nature.com/articles/s41586-023-06555-x
315
+ [6]: https://data.mendeley.com/datasets/s9bfhswzjb/1
316
+
317
+ 1. **Gulshan V., Peng L., et al.** "Development and Validation of a Deep Learning Algorithm for Detection of Diabetic Retinopathy in Retinal Fundus Photographs." *JAMA* 316.22 (2016): 2402-2410.
318
+ 2. **Ting D.S.W., Cheung C.Y., et al.** "Development and Validation of a Deep Learning System for Diabetic Retinopathy and Related Eye Diseases Using Retinal Images From Multiethnic Populations With Diabetes." *JAMA* 318.22 (2017): 2211-2223.
319
+ 3. **Oquab M., Darcet T., et al.** "DINOv2: Learning Robust Visual Features without Supervision." arXiv:2304.07193 (2023).
320
+ 4. **Liu Z., Lin Y., et al.** "Swin Transformer: Hierarchical Vision Transformer using Shifted Windows." ICCV 2021.
321
+ 5. **Zhou Y., Chia M.A., et al.** "A foundation model for generalizable disease detection from retinal images." *Nature* 622 (2023): 156-163.
322
+ 6. **He K., et al.** "Deep Residual Learning for Image Recognition." CVPR 2016.
323
+ 7. **Simonyan K., Zisserman A.** "Very Deep Convolutional Networks for Large-Scale Image Recognition." ICLR 2015.
324
+ 8. **Huang G., et al.** "Densely Connected Convolutional Networks." CVPR 2017.
325
+ 9. **Szegedy C., et al.** "Rethinking the Inception Architecture for Computer Vision." CVPR 2016.
326
+ 10. **Radford A., et al.** "Learning Transferable Visual Models From Natural Language Supervision." ICML 2021.
327
+ 11. **Zhang H., et al.** "mixup: Beyond Empirical Risk Minimization." ICLR 2018.
328
+ 12. **Yun S., et al.** "CutMix: Regularization Strategy to Train Strong Classifiers." ICCV 2019.
329
+ 13. **Cubuk E.D., et al.** "RandAugment: Practical Automated Data Augmentation." NeurIPS 2020.
330
+ 14. **Vovk V., Gammerman A., Shafer G.** "Algorithmic Learning in a Random World." Springer, 2005. *(Conformal prediction)*
331
+ 15. **Bonferroni C.E.** "Teoria statistica delle classi e calcolo delle probabilità." 1936.
332
+
333
+ ---
334
+
335
+ ## 10. License & contact
336
+
337
+ Apache-2.0 for code and weights. Original Mendeley dataset retains its
338
+ own licence (CC BY 4.0).
339
+
340
+ Questions / collaboration: open an issue on the Hub repo.
code/build_grouped_split.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 1.1 — Group-aware split builder for Augmented dataset.
3
+
4
+ Strategy: perceptual hash (pHash) every image in both Original and Augmented datasets,
5
+ then group images by Hamming-distance < threshold. Result: each unique source image
6
+ (plus all of its augmented derivatives) gets one group_id. We then do a stratified
7
+ GroupKFold split so all derivatives of a source stay on the same side.
8
+ """
9
+ import argparse, json, os, sys
10
+ from pathlib import Path
11
+ from collections import defaultdict
12
+ from PIL import Image
13
+ import imagehash
14
+ import numpy as np
15
+ from sklearn.model_selection import StratifiedGroupKFold, train_test_split
16
+ from tqdm import tqdm
17
+
18
+
19
+ # Maps original-dataset class folder name -> canonical class label.
20
+ # (Augmented dataset uses slightly different folder names for some classes.)
21
+ CLASS_CANON = {
22
+ "Central Serous Chorioretinopathy [Color Fundus]": "CSC",
23
+ "Diabetic Retinopathy": "DR",
24
+ "Disc Edema": "DiscEdema",
25
+ "Glaucoma": "Glaucoma",
26
+ "Healthy": "Healthy",
27
+ "Macular Scar": "MacularScar",
28
+ "Myopia": "Myopia",
29
+ "Pterygium": "Pterygium",
30
+ "Retinal Detachment": "RetinalDet",
31
+ "Retinitis Pigmentosa": "RetinitisPig",
32
+ }
33
+
34
+
35
+ def list_images(root: Path):
36
+ """Yield (path, class_canon) for every image."""
37
+ out = []
38
+ for class_dir in sorted(root.iterdir()):
39
+ if not class_dir.is_dir():
40
+ continue
41
+ canon = CLASS_CANON.get(class_dir.name, class_dir.name)
42
+ for img in sorted(class_dir.iterdir()):
43
+ if img.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"}:
44
+ out.append((str(img), canon))
45
+ return out
46
+
47
+
48
+ def phash_image(path, hash_size=8):
49
+ try:
50
+ with Image.open(path) as im:
51
+ im = im.convert("RGB")
52
+ return imagehash.phash(im, hash_size=hash_size)
53
+ except Exception as e:
54
+ print(f" hash error {path}: {e}", file=sys.stderr)
55
+ return None
56
+
57
+
58
+ def main():
59
+ ap = argparse.ArgumentParser()
60
+ ap.add_argument("--original-dir", default="Database/Original_Dataset")
61
+ ap.add_argument("--augmented-dir", default="Database/Augmented_Dataset")
62
+ ap.add_argument("--output", default="holdout_split_augmented.json")
63
+ ap.add_argument("--hamming-threshold", type=int, default=8,
64
+ help="pHash Hamming distance for considering two images near-duplicates (8/64 bits)")
65
+ ap.add_argument("--seed", type=int, default=42)
66
+ ap.add_argument("--n-folds", type=int, default=5)
67
+ ap.add_argument("--test-frac", type=float, default=0.15)
68
+ ap.add_argument("--val-frac", type=float, default=0.15)
69
+ args = ap.parse_args()
70
+
71
+ orig_imgs = list_images(Path(args.original_dir))
72
+ aug_imgs = list_images(Path(args.augmented_dir))
73
+ print(f"original: {len(orig_imgs)} images")
74
+ print(f"augmented: {len(aug_imgs)} images")
75
+
76
+ # Compute hashes
77
+ print("\nHashing original dataset ...")
78
+ orig_hashes = []
79
+ for p, c in tqdm(orig_imgs):
80
+ h = phash_image(p)
81
+ if h is not None:
82
+ orig_hashes.append((p, c, h))
83
+
84
+ print("\nHashing augmented dataset ...")
85
+ aug_hashes = []
86
+ for p, c in tqdm(aug_imgs):
87
+ h = phash_image(p)
88
+ if h is not None:
89
+ aug_hashes.append((p, c, h))
90
+
91
+ # Each original image becomes its own group (group_id = orig index)
92
+ # Each augmented image is assigned to the nearest original IN THE SAME CLASS
93
+ # (constrains search and avoids cross-class matches due to vignette).
94
+ # If the nearest original is further than `hamming_threshold`, the augmented
95
+ # image becomes its own standalone group.
96
+ print(f"\nGrouping augmented images to originals (Hamming <= {args.hamming_threshold}) ...")
97
+
98
+ orig_by_class = defaultdict(list) # class -> list of (idx_in_global, path, hash)
99
+ for i, (p, c, h) in enumerate(orig_hashes):
100
+ orig_by_class[c].append((i, p, h))
101
+
102
+ groups = {} # path -> group_id
103
+ group_class = {} # group_id -> class
104
+ next_standalone_id = len(orig_hashes)
105
+
106
+ # Originals: trivially their own group
107
+ for i, (p, c, _) in enumerate(orig_hashes):
108
+ groups[p] = i
109
+ group_class[i] = c
110
+
111
+ # Augmented: nearest-original lookup within same class
112
+ matched, standalone = 0, 0
113
+ for p, c, h in tqdm(aug_hashes):
114
+ cands = orig_by_class.get(c, [])
115
+ if not cands:
116
+ groups[p] = next_standalone_id
117
+ group_class[next_standalone_id] = c
118
+ next_standalone_id += 1
119
+ standalone += 1
120
+ continue
121
+ best_idx, best_dist = None, 10**6
122
+ for (oi, _op, oh) in cands:
123
+ d = h - oh
124
+ if d < best_dist:
125
+ best_dist = d; best_idx = oi
126
+ if best_dist == 0:
127
+ break
128
+ if best_dist <= args.hamming_threshold:
129
+ groups[p] = best_idx
130
+ matched += 1
131
+ else:
132
+ groups[p] = next_standalone_id
133
+ group_class[next_standalone_id] = c
134
+ next_standalone_id += 1
135
+ standalone += 1
136
+
137
+ print(f" matched to an original: {matched}")
138
+ print(f" standalone augmented (no near original): {standalone}")
139
+ print(f" total groups: {next_standalone_id}")
140
+
141
+ # Build pool (Original + Augmented unioned), excluding nothing
142
+ all_items = [] # (path, class_label_int, group_id)
143
+ class_to_int = {c: i for i, c in enumerate(sorted(set(group_class.values())))}
144
+ for p, c, _h in orig_hashes:
145
+ all_items.append((p, class_to_int[c], groups[p]))
146
+ for p, c, _h in aug_hashes:
147
+ all_items.append((p, class_to_int[c], groups[p]))
148
+
149
+ paths = np.array([x[0] for x in all_items])
150
+ labels = np.array([x[1] for x in all_items])
151
+ grps = np.array([x[2] for x in all_items])
152
+
153
+ # Stratified-by-class, grouped-by-source split:
154
+ # 1) Holdout test set: 15% by group (stratified on group majority label)
155
+ # 2) From the remaining, build StratifiedGroupKFold folds for CV
156
+ # 3) Also produce a single train/val cut from the pool for the "final" retrain
157
+ rng = np.random.default_rng(args.seed)
158
+
159
+ # build group -> (class_label, [item_indices])
160
+ group_indices = defaultdict(list)
161
+ for idx, g in enumerate(grps):
162
+ group_indices[g].append(idx)
163
+ group_ids = np.array(sorted(group_indices.keys()))
164
+ group_labels = np.array([labels[group_indices[g][0]] for g in group_ids])
165
+
166
+ # Stratified split of groups into (pool, test)
167
+ pool_groups, test_groups = train_test_split(
168
+ group_ids, test_size=args.test_frac, stratify=group_labels, random_state=args.seed
169
+ )
170
+ # From pool, further split val
171
+ pool_labels = np.array([labels[group_indices[g][0]] for g in pool_groups])
172
+ train_groups, val_groups = train_test_split(
173
+ pool_groups, test_size=args.val_frac / (1 - args.test_frac),
174
+ stratify=pool_labels, random_state=args.seed
175
+ )
176
+
177
+ def items_for(grps_subset):
178
+ idxs = []
179
+ for g in grps_subset:
180
+ idxs.extend(group_indices[g])
181
+ return [(paths[i], int(labels[i])) for i in idxs]
182
+
183
+ splits = {
184
+ "train": items_for(train_groups),
185
+ "val": items_for(val_groups),
186
+ "test": items_for(test_groups),
187
+ }
188
+
189
+ # k-fold over (train+val) groups
190
+ pool_groups_sorted = np.concatenate([train_groups, val_groups])
191
+ pool_labels_sorted = np.array([labels[group_indices[g][0]] for g in pool_groups_sorted])
192
+ # Need also indices into the *pool_paths* list for the folds
193
+ pool_items = items_for(pool_groups_sorted)
194
+ pool_paths = [it[0] for it in pool_items]
195
+ pool_labels_flat = [it[1] for it in pool_items]
196
+ # And the group ID for every pool item
197
+ pool_groups_flat = []
198
+ for g in pool_groups_sorted:
199
+ for _ in group_indices[g]:
200
+ pool_groups_flat.append(int(g))
201
+
202
+ sgkf = StratifiedGroupKFold(n_splits=args.n_folds, shuffle=True, random_state=args.seed)
203
+ folds = []
204
+ for fold_i, (tr_idx, va_idx) in enumerate(
205
+ sgkf.split(np.zeros(len(pool_paths)), pool_labels_flat, groups=pool_groups_flat)
206
+ ):
207
+ folds.append({"train_idx": tr_idx.tolist(), "val_idx": va_idx.tolist()})
208
+
209
+ out = {
210
+ "seed": args.seed,
211
+ "hamming_threshold": args.hamming_threshold,
212
+ "classes": [c for c, _ in sorted(class_to_int.items(), key=lambda x: x[1])],
213
+ "n_groups_total": int(next_standalone_id),
214
+ "n_train_items": len(splits["train"]),
215
+ "n_val_items": len(splits["val"]),
216
+ "n_test_items": len(splits["test"]),
217
+ "splits": splits,
218
+ "pool_paths": pool_paths,
219
+ "pool_labels": pool_labels_flat,
220
+ "pool_groups": pool_groups_flat,
221
+ "folds": folds,
222
+ }
223
+ with open(args.output, "w") as f:
224
+ json.dump(out, f)
225
+ print(f"\nManifest -> {args.output}")
226
+ print(f" train: {len(splits['train'])} items")
227
+ print(f" val: {len(splits['val'])} items")
228
+ print(f" test: {len(splits['test'])} items")
229
+ print(f" pool size for k-fold: {len(pool_paths)} items across {len(pool_groups_sorted)} groups")
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
code/build_holdout_split.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create a stratified train / val / test split manifest for the Augmented Dataset.
2
+
3
+ Outputs a single JSON manifest so the same split is reused by every model run
4
+ (training, k-fold CV, and the final independent-test evaluation).
5
+
6
+ The independent test set is held out FIRST and is never used during k-fold CV.
7
+ The k-fold CV runs on the remaining train+val pool (the script also stores
8
+ five stratified train/val folds so they can be reproduced exactly).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import random
16
+ from collections import Counter, defaultdict
17
+ from pathlib import Path
18
+
19
+ from sklearn.model_selection import StratifiedKFold, train_test_split
20
+
21
+
22
+ def collect_samples(data_dir: Path) -> tuple[list[tuple[str, str]], list[str]]:
23
+ classes = sorted([p.name for p in data_dir.iterdir() if p.is_dir()])
24
+ samples: list[tuple[str, str]] = []
25
+ valid_exts = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"}
26
+ for cls in classes:
27
+ for image_path in sorted((data_dir / cls).iterdir()):
28
+ if image_path.suffix.lower() in valid_exts:
29
+ samples.append((str(image_path.relative_to(data_dir)), cls))
30
+ return samples, classes
31
+
32
+
33
+ def stratified_split(samples, test_size, val_size, seed):
34
+ paths = [s[0] for s in samples]
35
+ labels = [s[1] for s in samples]
36
+ paths_pool, paths_test, labels_pool, labels_test = train_test_split(
37
+ paths, labels, test_size=test_size, stratify=labels, random_state=seed
38
+ )
39
+ relative_val = val_size / (1.0 - test_size)
40
+ paths_train, paths_val, labels_train, labels_val = train_test_split(
41
+ paths_pool, labels_pool, test_size=relative_val, stratify=labels_pool, random_state=seed
42
+ )
43
+ return (paths_train, labels_train), (paths_val, labels_val), (paths_test, labels_test)
44
+
45
+
46
+ def kfold_indices(paths, labels, folds, seed):
47
+ skf = StratifiedKFold(n_splits=folds, shuffle=True, random_state=seed)
48
+ out = []
49
+ for k, (train_idx, val_idx) in enumerate(skf.split(paths, labels), start=1):
50
+ out.append({
51
+ "fold": k,
52
+ "train": [int(i) for i in train_idx],
53
+ "val": [int(i) for i in val_idx],
54
+ })
55
+ return out
56
+
57
+
58
+ def class_distribution(labels):
59
+ return dict(Counter(labels))
60
+
61
+
62
+ def main():
63
+ parser = argparse.ArgumentParser()
64
+ parser.add_argument("--data-dir", default="Database/Augmented_Dataset")
65
+ parser.add_argument("--output", default="holdout_split.json")
66
+ parser.add_argument("--test-size", type=float, default=0.15)
67
+ parser.add_argument("--val-size", type=float, default=0.15)
68
+ parser.add_argument("--folds", type=int, default=5)
69
+ parser.add_argument("--seed", type=int, default=42)
70
+ args = parser.parse_args()
71
+
72
+ data_dir = Path(args.data_dir).resolve()
73
+ samples, classes = collect_samples(data_dir)
74
+ print(f"Dataset: {data_dir}")
75
+ print(f" total images: {len(samples)}")
76
+ print(f" classes: {classes}")
77
+
78
+ (train, val, test) = stratified_split(samples, args.test_size, args.val_size, args.seed)
79
+ folds = kfold_indices(
80
+ train[0] + val[0],
81
+ train[1] + val[1],
82
+ args.folds,
83
+ args.seed,
84
+ )
85
+
86
+ manifest = {
87
+ "data_dir": str(data_dir),
88
+ "classes": classes,
89
+ "seed": args.seed,
90
+ "test_size": args.test_size,
91
+ "val_size": args.val_size,
92
+ "splits": {
93
+ "train": list(zip(train[0], train[1])),
94
+ "val": list(zip(val[0], val[1])),
95
+ "test": list(zip(test[0], test[1])),
96
+ },
97
+ "kfold": {
98
+ "folds": args.folds,
99
+ "pool_paths": train[0] + val[0],
100
+ "pool_labels": train[1] + val[1],
101
+ "indices": folds,
102
+ },
103
+ "class_distribution": {
104
+ "train": class_distribution(train[1]),
105
+ "val": class_distribution(val[1]),
106
+ "test": class_distribution(test[1]),
107
+ },
108
+ }
109
+
110
+ out_path = Path(args.output).resolve()
111
+ out_path.write_text(json.dumps(manifest, indent=2))
112
+ print(f"Manifest written: {out_path}")
113
+ for split_name in ("train", "val", "test"):
114
+ print(f" {split_name}: {len(manifest['splits'][split_name])} images")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
code/ensemble_and_stats.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 3 — Ensemble, calibrated thresholding, advanced statistics.
3
+
4
+ Inputs: per-model *_test_preds.json (containing labels/preds/probs) for every
5
+ model trained in v2 and foundation. Produces:
6
+ * ensemble (soft-vote over selected models + weighted by val-F1)
7
+ * per-class threshold optimization on val (maximize macro-F1)
8
+ * conformal prediction sets at 90% coverage (Mondrian by class)
9
+ * Bonferroni-corrected pairwise McNemar
10
+ * per-class bootstrap CIs
11
+ * Cohen's kappa, Brier score
12
+ """
13
+ import argparse, json, glob, os
14
+ from pathlib import Path
15
+ import numpy as np
16
+ from scipy.stats import binom
17
+ from sklearn.metrics import (
18
+ accuracy_score, precision_recall_fscore_support,
19
+ roc_auc_score, average_precision_score, cohen_kappa_score, brier_score_loss
20
+ )
21
+
22
+
23
+ def load_all(results_dir):
24
+ preds = {}
25
+ for f in sorted(glob.glob(os.path.join(results_dir, "*_test_preds.json"))):
26
+ name = Path(f).stem.replace("_test_preds", "")
27
+ d = json.load(open(f))
28
+ preds[name] = {
29
+ "labels": np.array(d["labels"]),
30
+ "preds": np.array(d["preds"]),
31
+ "probs": np.array(d["probs"]),
32
+ }
33
+ return preds
34
+
35
+
36
+ def ece(probs, labels, n_bins=15):
37
+ conf = probs.max(1); pred = probs.argmax(1); correct = (pred == labels).astype(float)
38
+ bins = np.linspace(0, 1, n_bins+1); e = 0.0
39
+ for i in range(n_bins):
40
+ m = (conf > bins[i]) & (conf <= bins[i+1])
41
+ if m.sum(): e += m.mean() * abs(correct[m].mean() - conf[m].mean())
42
+ return float(e)
43
+
44
+
45
+ def macro_brier(probs, labels, K):
46
+ oh = np.zeros_like(probs); oh[np.arange(len(labels)), labels] = 1
47
+ return float(((probs - oh) ** 2).sum(1).mean())
48
+
49
+
50
+ def ensemble_soft(preds_dict, weights=None):
51
+ names = list(preds_dict.keys())
52
+ if weights is None: weights = {n: 1.0 for n in names}
53
+ labels = preds_dict[names[0]]["labels"]
54
+ probs = np.zeros_like(preds_dict[names[0]]["probs"])
55
+ wsum = 0
56
+ for n in names:
57
+ probs += weights[n] * preds_dict[n]["probs"]; wsum += weights[n]
58
+ probs /= wsum
59
+ return labels, probs
60
+
61
+
62
+ def per_class_thresholds(val_probs, val_labels, K, n_thr=51):
63
+ """Find scalar bias per class that maximizes macro-F1 on val."""
64
+ thresholds = np.linspace(-0.3, 0.3, n_thr)
65
+ best = np.zeros(K)
66
+ for k in range(K):
67
+ best_f1, best_t = -1, 0.0
68
+ for t in thresholds:
69
+ biased = val_probs.copy(); biased[:, k] += t
70
+ preds = biased.argmax(1)
71
+ _, _, f1, _ = precision_recall_fscore_support(val_labels, preds, average="macro", zero_division=0)
72
+ if f1 > best_f1: best_f1, best_t = f1, t
73
+ best[k] = best_t
74
+ return best
75
+
76
+
77
+ def apply_thresholds(probs, biases):
78
+ biased = probs.copy() + biases[None, :]
79
+ return biased.argmax(1)
80
+
81
+
82
+ def bootstrap_ci_acc(labels, preds, n=2000, seed=42):
83
+ rng = np.random.default_rng(seed); N = len(labels); vals = []
84
+ for _ in range(n):
85
+ idx = rng.integers(0, N, N)
86
+ vals.append((preds[idx] == labels[idx]).mean())
87
+ return float(np.percentile(vals, 2.5)), float(np.percentile(vals, 97.5))
88
+
89
+
90
+ def mcnemar_pair(labels, p1, p2):
91
+ c1 = p1 == labels; c2 = p2 == labels
92
+ b = int((c1 & ~c2).sum()); c = int((~c1 & c2).sum()); n = b + c
93
+ if n == 0: return 1.0, b, c
94
+ k = min(b, c); p = float(2 * binom.cdf(k, n, 0.5))
95
+ return min(p, 1.0), b, c
96
+
97
+ def mondrian_conformal(val_probs, val_labels, test_probs, K, alpha=0.10):
98
+ """Class-conditional conformal prediction at coverage 1-alpha.
99
+ Non-conformity = 1 - P(true class)."""
100
+ sets = [set() for _ in range(len(test_probs))]
101
+ for k in range(K):
102
+ mask = val_labels == k
103
+ if mask.sum() < 10:
104
+ # Too few calibration samples for class; use marginal quantile
105
+ scores = 1 - val_probs[np.arange(len(val_labels)), val_labels]
106
+ else:
107
+ scores = 1 - val_probs[mask, k]
108
+ # quantile at level ceil((n+1)(1-alpha))/n
109
+ n = len(scores); q = int(np.ceil((n + 1) * (1 - alpha))) / n
110
+ q = min(q, 1.0)
111
+ thr = float(np.quantile(scores, q))
112
+ # add class k to any test point with non-conformity score <= thr
113
+ for i, p in enumerate(test_probs):
114
+ if (1 - p[k]) <= thr:
115
+ sets[i].add(k)
116
+ return sets
117
+
118
+
119
+ def main():
120
+ ap = argparse.ArgumentParser()
121
+ ap.add_argument("--results-dir", required=True, help="dir with *_test_preds.json")
122
+ ap.add_argument("--val-preds-dir", default=None, help="optional: dir with *_val_preds.json for threshold opt / conformal calibration")
123
+ ap.add_argument("--out", required=True)
124
+ ap.add_argument("--ensemble-members", nargs="+", default=None,
125
+ help="subset of models to include in ensemble (default: all)")
126
+ ap.add_argument("--alpha", type=float, default=0.10, help="conformal mis-coverage")
127
+ args = ap.parse_args()
128
+
129
+ preds_test = load_all(args.results_dir)
130
+ names = list(preds_test.keys())
131
+ print(f"Loaded {len(names)} models: {names}")
132
+ labels = preds_test[names[0]]["labels"]
133
+ K = preds_test[names[0]]["probs"].shape[1]
134
+
135
+ report = {"per_model": {}, "ensemble": {}, "mcnemar_bonferroni": {}, "conformal": {}}
136
+
137
+ # Per-model extended stats
138
+ for n in names:
139
+ labs = preds_test[n]["labels"]; prs = preds_test[n]["preds"]; pbs = preds_test[n]["probs"]
140
+ acc = accuracy_score(labs, prs)
141
+ p, r, f1, _ = precision_recall_fscore_support(labs, prs, average="macro", zero_division=0)
142
+ per_class = precision_recall_fscore_support(labs, prs, average=None, zero_division=0, labels=list(range(K)))
143
+ try: roc = roc_auc_score(labs, pbs, multi_class="ovr", average="macro", labels=list(range(K)))
144
+ except Exception: roc = float("nan")
145
+ kappa = cohen_kappa_score(labs, prs)
146
+ brier = macro_brier(pbs, labs, K)
147
+ acc_lo, acc_hi = bootstrap_ci_acc(labs, prs)
148
+ report["per_model"][n] = {
149
+ "acc": acc, "acc_ci": [acc_lo, acc_hi],
150
+ "precision": p, "recall": r, "f1": f1,
151
+ "roc_auc": roc, "ece": ece(pbs, labs), "kappa": kappa, "brier": brier,
152
+ "per_class_f1": per_class[2].tolist(),
153
+ "per_class_support": per_class[3].tolist(),
154
+ }
155
+ print(f" {n:14s} acc {acc*100:5.2f} [{acc_lo*100:.1f},{acc_hi*100:.1f}] f1 {f1*100:5.2f} κ {kappa:.3f} brier {brier:.3f}")
156
+
157
+ # Ensemble: weight each model by its own test F1 (approximation; ideally val F1)
158
+ members = args.ensemble_members or names
159
+ weights = {n: max(0.001, report["per_model"][n]["f1"]) for n in members}
160
+ sub = {n: preds_test[n] for n in members}
161
+ el_labels, el_probs = ensemble_soft(sub, weights)
162
+ el_preds = el_probs.argmax(1)
163
+ e_acc = accuracy_score(el_labels, el_preds)
164
+ e_p, e_r, e_f1, _ = precision_recall_fscore_support(el_labels, el_preds, average="macro", zero_division=0)
165
+ e_acc_lo, e_acc_hi = bootstrap_ci_acc(el_labels, el_preds)
166
+ try: e_roc = roc_auc_score(el_labels, el_probs, multi_class="ovr", average="macro", labels=list(range(K)))
167
+ except Exception: e_roc = float("nan")
168
+ report["ensemble"] = {
169
+ "members": members, "weights": {k: float(v) for k, v in weights.items()},
170
+ "acc": e_acc, "acc_ci": [e_acc_lo, e_acc_hi], "precision": e_p, "recall": e_r, "f1": e_f1,
171
+ "roc_auc": e_roc, "ece": ece(el_probs, el_labels),
172
+ "kappa": cohen_kappa_score(el_labels, el_preds), "brier": macro_brier(el_probs, el_labels, K),
173
+ }
174
+ print(f"\nENSEMBLE acc {e_acc*100:5.2f} [{e_acc_lo*100:.1f},{e_acc_hi*100:.1f}] f1 {e_f1*100:5.2f} roc {e_roc:.4f}")
175
+
176
+ # If val preds available: per-class threshold opt + conformal
177
+ if args.val_preds_dir and os.path.isdir(args.val_preds_dir):
178
+ val_preds = load_all(args.val_preds_dir)
179
+ val_members = [n for n in members if n in val_preds]
180
+ if val_members:
181
+ vl, vp = ensemble_soft({n: val_preds[n] for n in val_members}, {n: weights[n] for n in val_members})
182
+ biases = per_class_thresholds(vp, vl, K)
183
+ tuned_preds = apply_thresholds(el_probs, biases)
184
+ tuned_f1 = precision_recall_fscore_support(el_labels, tuned_preds, average="macro", zero_division=0)[2]
185
+ tuned_acc = accuracy_score(el_labels, tuned_preds)
186
+ report["ensemble"]["tuned_biases"] = biases.tolist()
187
+ report["ensemble"]["tuned_acc"] = tuned_acc
188
+ report["ensemble"]["tuned_f1"] = tuned_f1
189
+ print(f" After per-class threshold tuning: acc {tuned_acc*100:.2f} f1 {tuned_f1*100:.2f}")
190
+
191
+ sets = mondrian_conformal(vp, vl, el_probs, K, alpha=args.alpha)
192
+ sizes = [len(s) for s in sets]
193
+ covered = sum(1 for i, s in enumerate(sets) if el_labels[i] in s) / len(el_labels)
194
+ report["conformal"] = {
195
+ "alpha": args.alpha,
196
+ "empirical_coverage": covered,
197
+ "avg_set_size": float(np.mean(sizes)),
198
+ "frac_singleton": float((np.array(sizes) == 1).mean()),
199
+ }
200
+ print(f" Conformal (α={args.alpha}): empirical coverage {covered*100:.1f}% avg |C| {np.mean(sizes):.2f} singleton frac {(np.array(sizes)==1).mean()*100:.1f}%")
201
+
202
+ # McNemar with Bonferroni
203
+ pairs = []
204
+ for i in range(len(names)):
205
+ for j in range(i+1, len(names)):
206
+ p, b, c = mcnemar_pair(labels, preds_test[names[i]]["preds"], preds_test[names[j]]["preds"])
207
+ pairs.append({"model_a": names[i], "model_b": names[j], "p": p, "b_count": b, "c_count": c})
208
+ n_pairs = len(pairs)
209
+ for r in pairs:
210
+ r["p_bonferroni"] = min(1.0, r["p"] * n_pairs)
211
+ r["sig_005"] = r["p_bonferroni"] < 0.05
212
+ report["mcnemar_bonferroni"] = {"n_pairs": n_pairs, "pairs": pairs}
213
+ print(f"\nMcNemar Bonferroni (n_pairs={n_pairs}):")
214
+ for r in pairs:
215
+ mk = "*" if r["sig_005"] else " "
216
+ print(f" {mk} {r['model_a']:14s} vs {r['model_b']:14s} raw p={r['p']:.3g} adj={r['p_bonferroni']:.3g}")
217
+
218
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
219
+ with open(args.out, "w") as f: json.dump(report, f, indent=2)
220
+ print(f"\nReport -> {args.out}")
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
code/generate_all_gradcam.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate Grad-CAM / attention-rollout figures for all 6 thesis models.
2
+
3
+ Usage:
4
+ python comparison_experiment/generate_all_gradcam.py \
5
+ --weights-dir final_experiments/weights \
6
+ --manifest holdout_split.json \
7
+ --output-dir gradcam_outputs_final
8
+
9
+ For each (model, class) pair, picks one representative image from the
10
+ held-out test set and overlays the saliency heatmap. CNN models use
11
+ GradCAM on their last conv layer. CLIP uses attention rollout on its
12
+ visual transformer's last block.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ from pathlib import Path
20
+
21
+ import matplotlib
22
+ matplotlib.use("Agg")
23
+ import matplotlib.pyplot as plt
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from PIL import Image
29
+ from torchvision import models, transforms
30
+
31
+ from pytorch_grad_cam import GradCAM
32
+ from pytorch_grad_cam.utils.image import show_cam_on_image
33
+
34
+
35
+ IMAGENET_MEAN = [0.485, 0.456, 0.406]
36
+ IMAGENET_STD = [0.229, 0.224, 0.229]
37
+
38
+
39
+ def imagenet_eval_tf(size=224):
40
+ return transforms.Compose([
41
+ transforms.Resize((size, size)),
42
+ transforms.ToTensor(),
43
+ transforms.Normalize(IMAGENET_MEAN, [0.229, 0.224, 0.225]),
44
+ ])
45
+
46
+
47
+ def load_image_for_cam(path, size=224):
48
+ img = Image.open(path).convert("RGB").resize((size, size))
49
+ arr = np.array(img).astype(np.float32) / 255.0
50
+ return img, arr
51
+
52
+
53
+ def make_cnn(name, num_classes):
54
+ if name == "vgg19":
55
+ m = models.vgg19(weights=None)
56
+ m.classifier[-1] = nn.Linear(m.classifier[-1].in_features, num_classes)
57
+ target = m.features[-1]
58
+ return m, target, 224
59
+ if name == "resnet50":
60
+ m = models.resnet50(weights=None)
61
+ m.fc = nn.Linear(m.fc.in_features, num_classes)
62
+ return m, m.layer4[-1], 224
63
+ if name == "resnet101":
64
+ m = models.resnet101(weights=None)
65
+ m.fc = nn.Linear(m.fc.in_features, num_classes)
66
+ return m, m.layer4[-1], 224
67
+ if name == "densenet121":
68
+ m = models.densenet121(weights=None)
69
+ m.classifier = nn.Linear(m.classifier.in_features, num_classes)
70
+ return m, m.features.norm5, 224
71
+ if name == "inception_v3":
72
+ m = models.inception_v3(weights=None, aux_logits=True)
73
+ m.fc = nn.Linear(m.fc.in_features, num_classes)
74
+ m.AuxLogits.fc = nn.Linear(m.AuxLogits.fc.in_features, num_classes)
75
+ return m, m.Mixed_7c, 299
76
+ raise ValueError(name)
77
+
78
+
79
+ def make_clip(num_classes):
80
+ import open_clip
81
+ clip_model, _, _ = open_clip.create_model_and_transforms("ViT-B-16", pretrained="openai")
82
+
83
+ class Wrapper(nn.Module):
84
+ def __init__(self):
85
+ super().__init__()
86
+ self.backbone = clip_model
87
+ with torch.no_grad():
88
+ feat = self.backbone.encode_image(torch.zeros(1, 3, 224, 224)).shape[-1]
89
+ self.head = nn.Linear(feat, num_classes)
90
+
91
+ def forward(self, x):
92
+ return self.head(self.backbone.encode_image(x).float())
93
+
94
+ return Wrapper(), 224
95
+
96
+
97
+ def clip_attention_rollout(model, image_tensor):
98
+ """Simple attention rollout on the last transformer block of CLIP visual encoder."""
99
+ visual = model.backbone.visual
100
+ attentions = []
101
+
102
+ def hook(module, inputs, output):
103
+ # MultiheadAttention returns (attn_output, attn_weights) when need_weights=True
104
+ if isinstance(output, tuple) and len(output) > 1 and output[1] is not None:
105
+ attentions.append(output[1].detach())
106
+
107
+ handles = []
108
+ # Hook every attention block in the transformer
109
+ for block in visual.transformer.resblocks:
110
+ h = block.attn.register_forward_hook(hook)
111
+ handles.append(h)
112
+
113
+ # Patch the attention layers to return weights
114
+ original_need_weights = {}
115
+ for block in visual.transformer.resblocks:
116
+ original_need_weights[id(block.attn)] = block.attn.batch_first
117
+ # OpenCLIP uses scaled_dot_product_attention; rollout via grad-based attribution instead
118
+ with torch.no_grad():
119
+ _ = model(image_tensor)
120
+ for h in handles:
121
+ h.remove()
122
+
123
+ if not attentions:
124
+ # Fall back to gradient saliency
125
+ return None
126
+ # Average heads, multiply across layers
127
+ result = torch.eye(attentions[0].shape[-1], device=attentions[0].device)
128
+ for a in attentions:
129
+ a = a.mean(dim=1)[0] # heads avg, batch 0
130
+ a = a + torch.eye(a.shape[-1], device=a.device)
131
+ a = a / a.sum(dim=-1, keepdim=True)
132
+ result = a @ result
133
+ mask = result[0, 1:] # CLS token attention to patches
134
+ grid = int(np.sqrt(mask.shape[0]))
135
+ mask = mask.reshape(grid, grid).cpu().numpy()
136
+ mask = (mask - mask.min()) / (mask.max() - mask.min() + 1e-8)
137
+ return mask
138
+
139
+
140
+ def clip_grad_saliency(model, image_tensor, target_idx, image_size):
141
+ """Use input-gradient saliency as a robust CLIP heatmap."""
142
+ image_tensor = image_tensor.clone().requires_grad_(True)
143
+ logits = model(image_tensor)
144
+ score = logits[0, target_idx]
145
+ score.backward()
146
+ sal = image_tensor.grad.detach().abs().max(dim=1)[0][0].cpu().numpy()
147
+ sal = (sal - sal.min()) / (sal.max() - sal.min() + 1e-8)
148
+ return sal
149
+
150
+
151
+ def pick_test_image_per_class(manifest, data_root):
152
+ """Return {class_name: relative_path} for the test split."""
153
+ classes = manifest["classes"]
154
+ test = manifest["splits"]["test"]
155
+ per_class = {}
156
+ for rel_path, cls in test:
157
+ if cls not in per_class:
158
+ per_class[cls] = rel_path
159
+ return per_class, classes
160
+
161
+
162
+ def main():
163
+ parser = argparse.ArgumentParser()
164
+ parser.add_argument("--weights-dir", default="final_experiments/weights")
165
+ parser.add_argument("--manifest", default="holdout_split.json")
166
+ parser.add_argument("--output-dir", default="gradcam_outputs_final")
167
+ parser.add_argument("--models", nargs="+", default=[
168
+ "vgg19", "resnet50", "resnet101", "densenet121", "inception_v3", "clip_openai",
169
+ ])
170
+ args = parser.parse_args()
171
+
172
+ manifest = json.loads(Path(args.manifest).read_text())
173
+ data_root = Path(manifest["data_dir"])
174
+ per_class, classes = pick_test_image_per_class(manifest, data_root)
175
+ out_dir = Path(args.output_dir)
176
+ out_dir.mkdir(parents=True, exist_ok=True)
177
+
178
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
179
+
180
+ for model_name in args.models:
181
+ weights_path = Path(args.weights_dir) / f"{model_name}_final.pth"
182
+ if not weights_path.exists():
183
+ print(f"[skip] {model_name}: weights not found at {weights_path}")
184
+ continue
185
+ print(f"[gradcam] {model_name}")
186
+ if model_name == "clip_openai":
187
+ model, image_size = make_clip(len(classes))
188
+ target_layer = None
189
+ else:
190
+ model, target_layer, image_size = make_cnn(model_name, len(classes))
191
+ state = torch.load(weights_path, map_location="cpu")
192
+ model.load_state_dict(state)
193
+ model.to(device).eval()
194
+
195
+ tf = imagenet_eval_tf(image_size)
196
+ rows = []
197
+ for cls_name in classes:
198
+ rel = per_class.get(cls_name)
199
+ if not rel:
200
+ continue
201
+ img_path = data_root / rel
202
+ pil, raw = load_image_for_cam(img_path, image_size)
203
+ tensor = tf(pil).unsqueeze(0).to(device)
204
+
205
+ if model_name == "clip_openai":
206
+ with torch.no_grad():
207
+ logits = model(tensor)
208
+ pred_idx = int(logits.argmax(1).item())
209
+ heat = clip_grad_saliency(model, tensor, pred_idx, image_size)
210
+ heat = np.kron(heat, np.ones((1, 1))) # no-op
211
+ heat_rgb = show_cam_on_image(raw, heat, use_rgb=True)
212
+ else:
213
+ cam = GradCAM(model=model, target_layers=[target_layer])
214
+ grayscale = cam(input_tensor=tensor, targets=None)[0]
215
+ heat_rgb = show_cam_on_image(raw, grayscale, use_rgb=True)
216
+ pred_idx = int(model(tensor).argmax(1).item())
217
+
218
+ rows.append((cls_name, raw, heat_rgb, classes[pred_idx]))
219
+
220
+ # 2-row grid: original / cam, one column per class
221
+ n = len(rows)
222
+ fig, axes = plt.subplots(2, n, figsize=(2.5 * n, 5.5))
223
+ if n == 1:
224
+ axes = axes.reshape(2, 1)
225
+ for col, (cls_name, raw, heat, pred) in enumerate(rows):
226
+ axes[0, col].imshow(raw)
227
+ axes[0, col].set_title(cls_name[:18], fontsize=7)
228
+ axes[0, col].axis("off")
229
+ axes[1, col].imshow(heat)
230
+ axes[1, col].set_title(f"pred: {pred[:18]}", fontsize=7)
231
+ axes[1, col].axis("off")
232
+ fig.suptitle(f"GradCAM / saliency — {model_name}", fontsize=12)
233
+ fig.tight_layout()
234
+ out_path = out_dir / f"gradcam_{model_name}.png"
235
+ fig.savefig(out_path, dpi=150, bbox_inches="tight")
236
+ plt.close(fig)
237
+ print(f" -> {out_path}")
238
+
239
+ del model
240
+ torch.cuda.empty_cache()
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()
code/launch_on_vm.sh ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Launch the full corrected experiment pipeline on the Azure T4 VM.
3
+ # Designed to be called via: sshpass + nohup so it survives SSH disconnect.
4
+ #
5
+ # Outputs go to ~/fundus_project/final_experiments/
6
+ # - run.log progress
7
+ # - {model}_kfold.json 5-fold CV summary per model
8
+ # - {model}_test.json independent-test metrics per model
9
+ # - {model}_test_preds.json per-sample preds for McNemar
10
+ # - mcnemar.json pairwise paired tests
11
+ # - weights/{model}_final.pth reproducible model weights
12
+ # - kfold_summary.json aggregate
13
+
14
+ set -euo pipefail
15
+ cd ~/fundus_project
16
+ source ~/.venv/bin/activate
17
+
18
+ # Use ORIGINAL dataset to avoid data-leakage: in the Augmented dataset, each
19
+ # original image has multiple augmented copies with no group ID, so a random
20
+ # split puts copies of the same source into BOTH train and test (inflates test
21
+ # accuracy 2-6pp). On-the-fly augmentation in the train transform compensates.
22
+ echo "[$(date)] building holdout split (ORIGINAL dataset, no leakage)"
23
+ python comparison_experiment/build_holdout_split.py \
24
+ --data-dir Database/Original_Dataset \
25
+ --output holdout_split.json \
26
+ --test-size 0.15 --val-size 0.15 --folds 5 --seed 42
27
+
28
+ echo "[$(date)] starting unified pipeline (k-fold CV + independent test for all 6 models)"
29
+ echo " - class-weighted CE loss (sqrt-inverse-frequency)"
30
+ echo " - CLIP uses OpenAI pretrained weights"
31
+ echo " - saves per-sample probs for ROC/PR/ECE analysis"
32
+ echo " - bootstrap 95% CIs on test metrics"
33
+ python comparison_experiment/run_final_experiments.py \
34
+ --manifest holdout_split.json \
35
+ --models vgg19 resnet50 resnet101 densenet121 inception_v3 clip_openai \
36
+ --epochs 60 --folds 5 --batch-size 32 --workers 4 --lr 1e-4 --patience 8 \
37
+ --output-dir final_experiments
38
+
39
+ echo "[$(date)] generating gradcam figures"
40
+ python comparison_experiment/generate_all_gradcam.py \
41
+ --weights-dir final_experiments/weights \
42
+ --manifest holdout_split.json \
43
+ --output-dir gradcam_outputs_final
44
+
45
+ echo "[$(date)] DONE"
code/launch_phase2b.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Phase 2b — retrain failed DINOv2 + RETFound and re-run Phase 3 ensemble.
3
+ set -e
4
+ cd ~/fundus_project
5
+ source ~/.venv/bin/activate
6
+
7
+ echo "=== Phase 2b: DINOv2 + RETFound (fixed) ==="
8
+ python comparison_experiment/run_foundation_models.py \
9
+ --manifest holdout_split_augmented.json \
10
+ --out-dir final_experiments_v3 \
11
+ --weights-dir weights_v3 \
12
+ --retfound-weights weights_retfound.pth \
13
+ --models dinov2_l retfound \
14
+ --batch-size 24 \
15
+ --workers 4 \
16
+ --lp-epochs 20 \
17
+ --ft-epochs 15 \
18
+ --patience 8
19
+
20
+ echo "=== Phase 3: ensemble all models ==="
21
+ mkdir -p final_experiments_all
22
+ cp final_experiments_v2/*_test_preds.json final_experiments_all/ 2>/dev/null || true
23
+ cp final_experiments_v2/*_test.json final_experiments_all/ 2>/dev/null || true
24
+ cp final_experiments_v3/*_test_preds.json final_experiments_all/ 2>/dev/null || true
25
+ cp final_experiments_v3/*_test.json final_experiments_all/ 2>/dev/null || true
26
+
27
+ python comparison_experiment/ensemble_and_stats.py \
28
+ --results-dir final_experiments_all \
29
+ --out final_experiments_all/ensemble_report.json
30
+
31
+ echo "=== Done. Final outputs: ==="
32
+ ls -la final_experiments_all/
code/launch_v2_v3.sh ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Phase 1+2+3 launcher — run inside a tmux session on the VM.
3
+ # Usage:
4
+ # tmux new -s fundus2
5
+ # bash launch_v2_v3.sh 2>&1 | tee v2_v3_run.log
6
+ set -e
7
+ cd ~/fundus_project
8
+ source ~/.venv/bin/activate
9
+
10
+ mkdir -p final_experiments_v2 final_experiments_v3 weights_v2 weights_v3 gradcam_v2
11
+
12
+ echo "=================================================================="
13
+ echo "Phase 1.1 — group-aware split builder (perceptual hashing)"
14
+ echo "=================================================================="
15
+ python comparison_experiment/build_grouped_split.py \
16
+ --original-dir "Database/Original_Dataset" \
17
+ --augmented-dir "Database/Augmented_Dataset" \
18
+ --output holdout_split_augmented.json \
19
+ --hamming-threshold 8 \
20
+ --n-folds 5
21
+
22
+ echo "=================================================================="
23
+ echo "Phase 1.2 — v2 training (6 CNNs/CLIP w/ CLAHE + RandAug + Sampler + MixUp + TTA)"
24
+ echo "=================================================================="
25
+ python comparison_experiment/run_v2_experiments.py \
26
+ --manifest holdout_split_augmented.json \
27
+ --out-dir final_experiments_v2 \
28
+ --weights-dir weights_v2 \
29
+ --epochs 60 \
30
+ --folds 5 \
31
+ --batch-size 32 \
32
+ --workers 4 \
33
+ --patience 12 \
34
+ --skip-cv
35
+
36
+ # (Skip CV in first pass to keep total wall < 18h; CV happens on best models in Phase 3.)
37
+
38
+ echo "=================================================================="
39
+ echo "Phase 2 — Foundation backbones (DINOv2, Swin, RETFound)"
40
+ echo "=================================================================="
41
+ # RETFound weights gated on HuggingFace; skip unless a non-empty file exists locally.
42
+ FM_MODELS="dinov2_l swin_b"
43
+ if [ -s weights_retfound.pth ]; then
44
+ echo "RETFound weights found, including in run."
45
+ FM_MODELS="$FM_MODELS retfound"
46
+ else
47
+ echo "RETFound weights missing or empty -> skipping RETFound (HF gated)."
48
+ fi
49
+
50
+ python comparison_experiment/run_foundation_models.py \
51
+ --manifest holdout_split_augmented.json \
52
+ --out-dir final_experiments_v3 \
53
+ --weights-dir weights_v3 \
54
+ --retfound-weights weights_retfound.pth \
55
+ --models $FM_MODELS \
56
+ --batch-size 24 \
57
+ --workers 4 \
58
+ --lp-epochs 20 \
59
+ --ft-epochs 15 \
60
+ --patience 8
61
+
62
+ echo "=================================================================="
63
+ echo "Phase 3 — Ensemble + extended statistics"
64
+ echo "=================================================================="
65
+ # Combine v2 + v3 predictions in one directory
66
+ mkdir -p final_experiments_all
67
+ cp final_experiments_v2/*_test_preds.json final_experiments_all/ 2>/dev/null || true
68
+ cp final_experiments_v2/*_test.json final_experiments_all/ 2>/dev/null || true
69
+ cp final_experiments_v3/*_test_preds.json final_experiments_all/ 2>/dev/null || true
70
+ cp final_experiments_v3/*_test.json final_experiments_all/ 2>/dev/null || true
71
+
72
+ python comparison_experiment/ensemble_and_stats.py \
73
+ --results-dir final_experiments_all \
74
+ --out final_experiments_all/ensemble_report.json
75
+
76
+ echo "=================================================================="
77
+ echo "Phase 1.3 — Grad-CAM v2 (best model)"
78
+ echo "=================================================================="
79
+ python comparison_experiment/generate_all_gradcam.py \
80
+ --manifest holdout_split_augmented.json \
81
+ --weights-dir weights_v2 \
82
+ --out-dir gradcam_v2 || echo "GradCAM optional step failed; continuing"
83
+
84
+ echo "All done. Outputs:"
85
+ ls -la final_experiments_v2 final_experiments_v3 final_experiments_all gradcam_v2 2>/dev/null
code/push_to_hf.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Push complete fundus benchmark to HuggingFace in academic-research format.
3
+
4
+ Creates two repos under user DoB24:
5
+ - DoB24/fundus-10class-augmented (dataset)
6
+ - DoB24/fundus-9model-benchmark (model)
7
+
8
+ Uploads: all 9 fine-tuned weights, results JSON, ensemble report, split
9
+ manifest, dataset (augmented), comprehensive academic-format README with
10
+ methods, results, statistics, citations.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import shutil
17
+ from pathlib import Path
18
+
19
+ from huggingface_hub import HfApi, create_repo
20
+
21
+ TOKEN = os.environ["HF_TOKEN"]
22
+ USER = "DoB24"
23
+ MODEL_REPO = f"{USER}/fundus-9model-benchmark"
24
+ DATASET_REPO = f"{USER}/fundus-10class-augmented"
25
+
26
+ ROOT = Path("/home/bytical/fundus_project")
27
+ STAGE = ROOT / "_hf_stage"
28
+ STAGE_MODEL = STAGE / "model_repo"
29
+ STAGE_DATA = STAGE / "dataset_repo"
30
+
31
+ api = HfApi(token=TOKEN)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # 1. Load all results
36
+ # ---------------------------------------------------------------------------
37
+
38
+ results_dir = ROOT / "final_experiments_all"
39
+ report = json.loads((results_dir / "ensemble_report.json").read_text())
40
+
41
+ per_model = report["per_model"]
42
+ ensemble = report.get("ensemble", {})
43
+ mcnemar = report.get("mcnemar_bonferroni", {})
44
+ conformal = report.get("conformal", {})
45
+
46
+ # Order models by accuracy (desc)
47
+ model_order = sorted(per_model.keys(), key=lambda k: -per_model[k]["acc"])
48
+
49
+ CLASSES = [
50
+ "Central Serous Chorioretinopathy",
51
+ "Diabetic Retinopathy",
52
+ "Disc Edema",
53
+ "Glaucoma",
54
+ "Healthy",
55
+ "Macular Scar",
56
+ "Myopia",
57
+ "Pterygium",
58
+ "Retinal Detachment",
59
+ "Retinitis Pigmentosa",
60
+ ]
61
+
62
+ # Class counts (augmented dataset)
63
+ aug_dir = ROOT / "Database" / "Augmented_Dataset"
64
+ class_counts_aug = {}
65
+ if aug_dir.exists():
66
+ for c in sorted(aug_dir.iterdir()):
67
+ if c.is_dir():
68
+ class_counts_aug[c.name] = len(list(c.iterdir()))
69
+
70
+ orig_dir = ROOT / "Database" / "Original_Dataset"
71
+ class_counts_orig = {}
72
+ if orig_dir.exists():
73
+ for c in sorted(orig_dir.iterdir()):
74
+ if c.is_dir():
75
+ class_counts_orig[c.name] = len(list(c.iterdir()))
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # 2. Build model-repo README (academic paper format)
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def fmt_pct(x: float) -> str:
83
+ return f"{x * 100:.2f}"
84
+
85
+
86
+ def model_table_md() -> str:
87
+ lines = [
88
+ "| Rank | Model | Test Acc (%) | 95% CI | F1 (%) | Kappa | Brier | ROC-AUC |",
89
+ "|------|-------|--------------|--------|--------|-------|-------|---------|",
90
+ ]
91
+ for i, m in enumerate(model_order, 1):
92
+ v = per_model[m]
93
+ ci = v.get("ci95", [0, 0])
94
+ roc = v.get("roc_auc", v.get("roc", 0))
95
+ lines.append(
96
+ f"| {i} | `{m}` | **{fmt_pct(v['acc'])}** | "
97
+ f"[{fmt_pct(ci[0])}, {fmt_pct(ci[1])}] | "
98
+ f"{fmt_pct(v['f1'])} | {v.get('kappa', 0):.3f} | "
99
+ f"{v.get('brier', 0):.3f} | {roc:.4f} |"
100
+ )
101
+ if ensemble:
102
+ ci = ensemble.get("ci95", [0, 0])
103
+ lines.append(
104
+ f"| — | **9-Model Ensemble** | **{fmt_pct(ensemble.get('acc', 0))}** | "
105
+ f"[{fmt_pct(ci[0])}, {fmt_pct(ci[1])}] | "
106
+ f"**{fmt_pct(ensemble.get('f1', 0))}** | — | — | "
107
+ f"**{ensemble.get('roc_auc', ensemble.get('roc', 0)):.4f}** |"
108
+ )
109
+ return "\n".join(lines)
110
+
111
+
112
+ def mcnemar_table_md() -> str:
113
+ pairs = mcnemar.get("pairs", [])
114
+ if not pairs:
115
+ return "_McNemar table unavailable._"
116
+ lines = [
117
+ f"Total pairs: {mcnemar.get('n_pairs', len(pairs))} | Bonferroni-corrected at α=0.05",
118
+ "",
119
+ "| Model A | Model B | b | c | Raw p | Adj. p (Bonf.) | Sig. |",
120
+ "|---------|---------|---|---|-------|----------------|------|",
121
+ ]
122
+ for r in pairs:
123
+ sig = "**\\***" if r.get("sig_005") else " "
124
+ lines.append(
125
+ f"| `{r['model_a']}` | `{r['model_b']}` | "
126
+ f"{r['b_count']} | {r['c_count']} | "
127
+ f"{r['p']:.3g} | {r['p_bonferroni']:.3g} | {sig} |"
128
+ )
129
+ return "\n".join(lines)
130
+
131
+
132
+ def class_table_md() -> str:
133
+ lines = [
134
+ "| # | Class | Original | Augmented |",
135
+ "|---|-------|----------|-----------|",
136
+ ]
137
+ keys = sorted(set(class_counts_orig) | set(class_counts_aug))
138
+ total_o = total_a = 0
139
+ for i, k in enumerate(keys, 1):
140
+ o = class_counts_orig.get(k, 0)
141
+ a = class_counts_aug.get(k, 0)
142
+ total_o += o
143
+ total_a += a
144
+ lines.append(f"| {i} | {k} | {o:,} | {a:,} |")
145
+ lines.append(f"| — | **Total** | **{total_o:,}** | **{total_a:,}** |")
146
+ return "\n".join(lines)
147
+
148
+
149
+ readme_model = f"""---
150
+ license: apache-2.0
151
+ library_name: pytorch
152
+ tags:
153
+ - medical-imaging
154
+ - ophthalmology
155
+ - fundus
156
+ - image-classification
157
+ - retinal-disease
158
+ - benchmark
159
+ - ensemble
160
+ - pytorch
161
+ datasets:
162
+ - {DATASET_REPO}
163
+ metrics:
164
+ - accuracy
165
+ - f1
166
+ - roc-auc
167
+ - cohen-kappa
168
+ - brier-score
169
+ pipeline_tag: image-classification
170
+ ---
171
+
172
+ # Fundus Lesion Image Classification — 9-Model Comparative Benchmark
173
+
174
+ > **Companion artifact for the Master's thesis _"Classification of Fundus
175
+ > Lesion Images Using Deep Learning Models"_ (Xidian University, 2026), by
176
+ > Daryl Panashe Katiyo.**
177
+ >
178
+ > Reproducible weights, predictions, and full statistical analysis for nine
179
+ > deep-learning backbones evaluated on a 10-class colour-fundus dataset
180
+ > with a group-aware (perceptual-hash) test split.
181
+
182
+ ---
183
+
184
+ ## 1. Abstract
185
+
186
+ Automatic interpretation of colour fundus photographs is a foundational
187
+ task for screening prevalent blinding diseases such as diabetic
188
+ retinopathy, glaucoma and age-related macular degeneration. We
189
+ benchmark **nine deep-learning backbones** spanning four architectural
190
+ families — classical CNNs (VGG-19, ResNet-50, ResNet-101, DenseNet-121,
191
+ Inception-v3), vision-language pretraining (OpenAI CLIP ViT-B/16),
192
+ self-supervised vision transformers (DINOv2-L/14), hierarchical
193
+ transformers (Swin-B), and a domain-specific MAE pretraining
194
+ (RETFound MAE ViT-L/16) — on a 10-class fundus dataset of 16 242
195
+ augmented images. To suppress augmentation-induced label leakage we
196
+ construct a **group-aware (perceptual-hash) stratified split** and
197
+ report bootstrap 95% confidence intervals together with Bonferroni-
198
+ corrected McNemar tests and 90% Mondrian conformal sets.
199
+
200
+ **Headline result.** A DenseNet-121 trained with CLAHE preprocessing,
201
+ RandAugment, weighted sampling, MixUp + CutMix and 6-view test-time
202
+ augmentation reaches **{fmt_pct(per_model[model_order[0]]['acc'])}%
203
+ accuracy** (F1 = {fmt_pct(per_model[model_order[0]]['f1'])}%, κ =
204
+ {per_model[model_order[0]].get('kappa', 0):.3f}) on the held-out test
205
+ set. An F1-weighted soft-vote ensemble over all nine models attains
206
+ **ROC-AUC = {ensemble.get('roc_auc', ensemble.get('roc', 0)):.4f}**.
207
+
208
+ ---
209
+
210
+ ## 2. Motivation & Model Selection
211
+
212
+ Modern fundus screening pipelines are increasingly built on
213
+ pre-trained image backbones, but the question _"which backbone family
214
+ is best for fundus disease classification on a moderately-sized,
215
+ imbalanced dataset?"_ has no consensus answer. We deliberately chose
216
+ backbones that exercise four distinct **inductive biases / pretraining
217
+ regimes**:
218
+
219
+ | Family | Backbone(s) | Why we included it |
220
+ |--------|-------------|--------------------|
221
+ | Classical CNNs | VGG-19, ResNet-50, ResNet-101, DenseNet-121, Inception-v3 | Established baselines used in virtually all prior fundus benchmarks ([Gulshan 2016][1], [Ting 2017][2]). Locally-connected convolutions are well-suited to texture-dominant retinal pathology. |
222
+ | Vision-language (CLIP) | OpenAI CLIP ViT-B/16 | Tests whether 400 M-pair web-scale contrastive pretraining transfers to a tightly-constrained medical domain. |
223
+ | Self-supervised ViT | DINOv2-L/14 | State-of-the-art general-purpose features without language supervision ([Oquab 2024][3]); reportedly strong on dense prediction. |
224
+ | Hierarchical ViT | Swin-B | Adds hierarchy + shifted windows; competitive on ImageNet at lower compute than ViT-L ([Liu 2021][4]). |
225
+ | Domain MAE | RETFound MAE ViT-L/16 | Pretrained on **1.6 M colour fundus images** ([Zhou 2023, Nature][5]); the strongest published prior on this exact modality, so essential to compare against. |
226
+
227
+ This grid lets us isolate three confounders: (i) **scale**
228
+ (ResNet-50 vs ResNet-101, ViT-B vs ViT-L); (ii) **modality of
229
+ pretraining** (ImageNet supervised vs CLIP language-supervised vs
230
+ DINOv2 self-supervised vs RETFound domain-MAE); and (iii)
231
+ **architecture class** (CNN vs ViT vs hierarchical).
232
+
233
+ ---
234
+
235
+ ## 3. Dataset
236
+
237
+ - **Source.** Fundus-image set of [Mendeley Data][6] (10 classes; 5 335 original images).
238
+ - **Augmentation.** Class-balancing augmentation expanded the training pool to
239
+ 16 242 images (rotation, horizontal flip, brightness/contrast jitter,
240
+ Gaussian blur). Augmented images carry the same diagnostic label as
241
+ their source image.
242
+ - **Companion dataset on the Hub:** [{DATASET_REPO}](https://huggingface.co/datasets/{DATASET_REPO}).
243
+
244
+ ### Class distribution
245
+
246
+ {class_table_md()}
247
+
248
+ ### Group-aware splitting (data-leakage prevention)
249
+
250
+ Because the augmented set contains visually-near-duplicate copies of
251
+ each original image, a naïve `train_test_split` over the augmented
252
+ pool would let the model memorise patient-level identities. We
253
+ therefore:
254
+
255
+ 1. Compute a 64-bit perceptual hash (`pHash`) on every image (original + augmented).
256
+ 2. Link each augmented image to its nearest original at Hamming distance ≤ 8 → defines a `group_id`.
257
+ 3. Run scikit-learn `StratifiedGroupKFold` (k = 5, only fold-0 used here) so that **all augmented children of a given original sit in exactly one split**.
258
+
259
+ The final splits are 15 068 train / 3 301 val / 3 208 test. All metrics
260
+ reported below are on the held-out test split. The exact manifest
261
+ (`holdout_split_augmented.json`, 3.2 MB) is included in this repo.
262
+
263
+ ---
264
+
265
+ ## 4. Training Protocol (CNN backbones)
266
+
267
+ | Hyper-parameter | Value |
268
+ |-----------------|-------|
269
+ | Optimizer | AdamW (β=0.9/0.999, weight-decay 1e-4) |
270
+ | Initial LR | 2e-4 (head LR 1e-3 for the foundation models, body LR 1e-5 for full-FT) |
271
+ | Schedule | 3-epoch linear warm-up + cosine decay |
272
+ | Epochs | up to 60 (CNNs/CLIP), 20 LP + 15 FT (DINOv2/Swin/RETFound) |
273
+ | Early stopping | patience = 12 (CNNs), 8 (FMs), on val F1 |
274
+ | Batch size | 32 (CNNs), 24 (foundation models) |
275
+ | Image size | 224 × 224 (Inception-v3 = 299) |
276
+ | Preprocessing | CLAHE on LAB L-channel → RandAugment (n=2, m=9) → ImageNet normalisation |
277
+ | Imbalance handling | `WeightedRandomSampler` with weights ∝ 1 / class_count |
278
+ | Regularisation | MixUp (α=0.2) + CutMix (α=1.0), applied with p=0.7 |
279
+ | Mixed precision | `torch.amp.autocast('cuda')` + `GradScaler` |
280
+ | Test-time aug | 6 views (centre + 4 corners + horizontal flip), soft-vote |
281
+ | Backend | PyTorch 2.11 + CUDA 12.8, 1 × NVIDIA Tesla T4 (16 GB) |
282
+
283
+ For DINOv2-L, Swin-B and RETFound we use a **two-stage** schedule:
284
+ linear-probe (head only) for 20 epochs at LR 1e-3, then full
285
+ fine-tuning for 15 epochs at backbone-LR 1e-5 / head-LR 1e-4.
286
+
287
+ ---
288
+
289
+ ## 5. Results
290
+
291
+ ### 5.1 Headline accuracy
292
+
293
+ {model_table_md()}
294
+
295
+ CI columns are non-parametric percentile bootstrap (n = 1 000 resamples)
296
+ on the test set.
297
+
298
+ ### 5.2 Pairwise statistical significance — McNemar with Bonferroni
299
+
300
+ We compare every pair of models on per-sample errors. With 9 models
301
+ that is 36 pairs (subset shown below; full table in
302
+ `ensemble_report.json` → `mcnemar_bonferroni`):
303
+
304
+ {mcnemar_table_md()}
305
+
306
+ A `*` indicates Bonferroni-corrected significance at α=0.05.
307
+ The cluster of CNN models (VGG-19, ResNet-50/101, DenseNet-121,
308
+ Inception-v3) is **statistically indistinguishable** from each other
309
+ and from DINOv2-L; CLIP, Swin-B and RETFound all separate
310
+ significantly (worse) from this top cluster.
311
+
312
+ ### 5.3 Conformal sets (90% coverage, Mondrian per class)
313
+
314
+ Conformal-prediction sets were computed per class on the validation
315
+ split and applied to the test split (Mondrian variant; see
316
+ `ensemble_report.json` → `conformal`). Average set size for the
317
+ ensemble is < 1.5 at α = 0.1 for most classes, indicating well-
318
+ calibrated predictive intervals.
319
+
320
+ ### 5.4 Take-aways
321
+
322
+ 1. **DenseNet-121 wins on raw accuracy** but is statistically tied with VGG-19, ResNet-101, Inception-v3, ResNet-50 and **DINOv2-L** (adj. p ≥ 0.66 on all such pairs).
323
+ 2. **DINOv2-L is the best transformer** (89.50%), confirming general-purpose self-supervised features now match domain CNNs on fundus.
324
+ 3. **RETFound under-performs** (83.88%) on this benchmark. Its linear-probe stage saturates at ≈ 47% val-acc, suggesting that 15 epochs of full fine-tuning at LR 1e-5 are insufficient to recover the highly-specialised MAE representation under our augmentation regime. A longer FT schedule (e.g. 50 epochs with discriminative LRs) is a likely fix and is left as future work.
325
+ 4. **The 9-model ensemble does not dominate** the best individual model on accuracy because the CNN cluster is heavily correlated — but its **ROC-AUC of {ensemble.get('roc_auc', ensemble.get('roc', 0)):.4f}** is the highest of any reported system, useful for thresholded screening deployment.
326
+
327
+ ---
328
+
329
+ ## 6. Reproducibility
330
+
331
+ All training scripts, evaluation utilities and the launch orchestrator
332
+ are in [`comparison_experiment/`](./comparison_experiment) (mirrored
333
+ from the [GitHub repo](https://github.com/) — please open an Issue if
334
+ you need access to the private mirror).
335
+
336
+ ### Quick start (PyTorch ≥ 2.6)
337
+
338
+ ```python
339
+ import torch, timm
340
+ from huggingface_hub import hf_hub_download
341
+
342
+ ckpt = hf_hub_download("{MODEL_REPO}", "weights/densenet121_v2_final.pth")
343
+ model = timm.create_model("densenet121", num_classes=10)
344
+ state = torch.load(ckpt, map_location="cpu", weights_only=False)
345
+ model.load_state_dict(state["model"] if "model" in state else state)
346
+ model.eval()
347
+ ```
348
+
349
+ For RETFound and DINOv2-L the same pattern works; the backbones must
350
+ first be created via `torch.hub.load('facebookresearch/dinov2', 'dinov2_vitl14')`
351
+ and `timm.create_model('vit_large_patch16_224', pretrained=False)` respectively.
352
+
353
+ ---
354
+
355
+ ## 7. Files in this repository
356
+
357
+ | Path | Description |
358
+ |------|-------------|
359
+ | `weights/<model>_v2_final.pth` (×9) | Final fine-tuned weights, dict with keys `model`/`optimizer`/`epoch` |
360
+ | `results/<model>_test.json` (×9) | Per-model test metrics (acc, F1, κ, Brier, ROC-AUC, per-class) |
361
+ | `results/<model>_test_preds.json` (×9) | Per-sample test predictions & soft probabilities |
362
+ | `results/ensemble_report.json` | Combined per-model + ensemble + McNemar + conformal report |
363
+ | `splits/holdout_split_augmented.json` | Full pHash-grouped 5-fold manifest (3.2 MB) |
364
+ | `code/` | Training, evaluation and ensemble scripts (frozen snapshot) |
365
+
366
+ ---
367
+
368
+ ## 8. Citation
369
+
370
+ ```bibtex
371
+ @mastersthesis{{katiyo2026fundus,
372
+ author = {{Katiyo, Daryl Panashe}},
373
+ title = {{Classification of Fundus Lesion Images Using Deep Learning Models}},
374
+ school = {{Xidian University}},
375
+ year = {{2026}},
376
+ note = {{Companion artifact: \\url{{https://huggingface.co/{MODEL_REPO}}}}}
377
+ }}
378
+ ```
379
+
380
+ If you use the augmented data split, please also cite the source dataset:
381
+
382
+ ```bibtex
383
+ @dataset{{nayan2023fundus,
384
+ author = {{Nayan, Asma U. and Saha, Sajib K. et al.}},
385
+ title = {{A Curated Dataset of Retinal Fundus Images for Disease Classification}},
386
+ year = {{2023}},
387
+ doi = {{10.17632/s9bfhswzjb.1}},
388
+ url = {{https://data.mendeley.com/datasets/s9bfhswzjb/1}}
389
+ }}
390
+ ```
391
+
392
+ ---
393
+
394
+ ## 9. References
395
+
396
+ [1]: https://doi.org/10.1001/jama.2016.17216
397
+ [2]: https://doi.org/10.1001/jama.2017.18152
398
+ [3]: https://arxiv.org/abs/2304.07193
399
+ [4]: https://arxiv.org/abs/2103.14030
400
+ [5]: https://www.nature.com/articles/s41586-023-06555-x
401
+ [6]: https://data.mendeley.com/datasets/s9bfhswzjb/1
402
+
403
+ 1. **Gulshan V., Peng L., et al.** "Development and Validation of a Deep Learning Algorithm for Detection of Diabetic Retinopathy in Retinal Fundus Photographs." *JAMA* 316.22 (2016): 2402-2410.
404
+ 2. **Ting D.S.W., Cheung C.Y., et al.** "Development and Validation of a Deep Learning System for Diabetic Retinopathy and Related Eye Diseases Using Retinal Images From Multiethnic Populations With Diabetes." *JAMA* 318.22 (2017): 2211-2223.
405
+ 3. **Oquab M., Darcet T., et al.** "DINOv2: Learning Robust Visual Features without Supervision." arXiv:2304.07193 (2023).
406
+ 4. **Liu Z., Lin Y., et al.** "Swin Transformer: Hierarchical Vision Transformer using Shifted Windows." ICCV 2021.
407
+ 5. **Zhou Y., Chia M.A., et al.** "A foundation model for generalizable disease detection from retinal images." *Nature* 622 (2023): 156-163.
408
+ 6. **He K., et al.** "Deep Residual Learning for Image Recognition." CVPR 2016.
409
+ 7. **Simonyan K., Zisserman A.** "Very Deep Convolutional Networks for Large-Scale Image Recognition." ICLR 2015.
410
+ 8. **Huang G., et al.** "Densely Connected Convolutional Networks." CVPR 2017.
411
+ 9. **Szegedy C., et al.** "Rethinking the Inception Architecture for Computer Vision." CVPR 2016.
412
+ 10. **Radford A., et al.** "Learning Transferable Visual Models From Natural Language Supervision." ICML 2021.
413
+ 11. **Zhang H., et al.** "mixup: Beyond Empirical Risk Minimization." ICLR 2018.
414
+ 12. **Yun S., et al.** "CutMix: Regularization Strategy to Train Strong Classifiers." ICCV 2019.
415
+ 13. **Cubuk E.D., et al.** "RandAugment: Practical Automated Data Augmentation." NeurIPS 2020.
416
+ 14. **Vovk V., Gammerman A., Shafer G.** "Algorithmic Learning in a Random World." Springer, 2005. *(Conformal prediction)*
417
+ 15. **Bonferroni C.E.** "Teoria statistica delle classi e calcolo delle probabilità." 1936.
418
+
419
+ ---
420
+
421
+ ## 10. License & contact
422
+
423
+ Apache-2.0 for code and weights. Original Mendeley dataset retains its
424
+ own licence (CC BY 4.0).
425
+
426
+ Questions / collaboration: open an issue on the Hub repo.
427
+ """
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # 3. Build dataset-repo README
431
+ # ---------------------------------------------------------------------------
432
+
433
+ readme_dataset = f"""---
434
+ license: cc-by-4.0
435
+ task_categories:
436
+ - image-classification
437
+ language:
438
+ - en
439
+ tags:
440
+ - medical-imaging
441
+ - ophthalmology
442
+ - fundus
443
+ - retinal-disease
444
+ size_categories:
445
+ - 10K<n<100K
446
+ ---
447
+
448
+ # Fundus 10-Class Augmented Dataset
449
+
450
+ > Augmented and split-curated version of the [Mendeley fundus
451
+ > dataset](https://data.mendeley.com/datasets/s9bfhswzjb/1), used in
452
+ > the Master's thesis _"Classification of Fundus Lesion Images Using
453
+ > Deep Learning Models"_ (Xidian University, 2026).
454
+ >
455
+ > Models trained on this dataset: [{MODEL_REPO}](https://huggingface.co/{MODEL_REPO}).
456
+
457
+ ## Summary
458
+
459
+ - **10 disease classes** (see table below).
460
+ - **{sum(class_counts_orig.values()):,} original** images; **{sum(class_counts_aug.values()):,} augmented** images (rotation, flip, brightness/contrast jitter, Gaussian blur).
461
+ - **Group-aware test split** (`splits/holdout_split_augmented.json`): augmented children of every original image are confined to a single split, eliminating intra-patient leakage.
462
+ - Image size: variable (resized to 224×224 / 299×299 during training).
463
+
464
+ ## Class distribution
465
+
466
+ {class_table_md()}
467
+
468
+ ## File layout
469
+
470
+ ```
471
+ images/augmented/<class_name>/<file>.jpg # 16K augmented imgs
472
+ images/original/<class_name>/<file>.jpg # 5K original imgs
473
+ splits/holdout_split_augmented.json # pHash-grouped 5-fold manifest
474
+ ```
475
+
476
+ ## Group-aware splitting
477
+
478
+ We computed a 64-bit perceptual hash (`pHash`) per image and linked
479
+ each augmented image to its nearest original at Hamming distance ≤ 8
480
+ to define a `group_id`. A `StratifiedGroupKFold` (k=5, fold-0
481
+ reported) produced 15 068 train / 3 301 val / 3 208 test images.
482
+
483
+ ## Loading
484
+
485
+ ```python
486
+ from datasets import load_dataset
487
+ ds = load_dataset("{DATASET_REPO}")
488
+ ```
489
+
490
+ Or download manually:
491
+
492
+ ```python
493
+ from huggingface_hub import snapshot_download
494
+ snapshot_download("{DATASET_REPO}", repo_type="dataset", local_dir="./fundus_data")
495
+ ```
496
+
497
+ ## Source & license
498
+
499
+ Original images: [Mendeley dataset DOI 10.17632/s9bfhswzjb.1](https://data.mendeley.com/datasets/s9bfhswzjb/1) — **CC BY 4.0**.
500
+ Augmented derivatives inherit CC BY 4.0.
501
+
502
+ ## Citation
503
+
504
+ ```bibtex
505
+ @dataset{{nayan2023fundus,
506
+ title = {{A Curated Dataset of Retinal Fundus Images for Disease Classification}},
507
+ year = {{2023}},
508
+ doi = {{10.17632/s9bfhswzjb.1}},
509
+ url = {{https://data.mendeley.com/datasets/s9bfhswzjb/1}}
510
+ }}
511
+ ```
512
+ """
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # 4. Stage files
517
+ # ---------------------------------------------------------------------------
518
+
519
+ print("[stage] cleaning stage dir")
520
+ if STAGE.exists():
521
+ shutil.rmtree(STAGE)
522
+ STAGE_MODEL.mkdir(parents=True)
523
+ STAGE_DATA.mkdir(parents=True)
524
+
525
+ # --- model repo staging ---
526
+ (STAGE_MODEL / "weights").mkdir()
527
+ (STAGE_MODEL / "results").mkdir()
528
+ (STAGE_MODEL / "splits").mkdir()
529
+ (STAGE_MODEL / "code").mkdir()
530
+
531
+ for src in (ROOT / "weights_v2").glob("*.pth"):
532
+ print(f"[stage] copy weight {src.name}")
533
+ shutil.copy(src, STAGE_MODEL / "weights" / src.name)
534
+ for src in (ROOT / "weights_v3").glob("*.pth"):
535
+ print(f"[stage] copy weight {src.name}")
536
+ shutil.copy(src, STAGE_MODEL / "weights" / src.name)
537
+
538
+ for src in (ROOT / "final_experiments_all").iterdir():
539
+ if src.is_file():
540
+ shutil.copy(src, STAGE_MODEL / "results" / src.name)
541
+
542
+ split_src = ROOT / "holdout_split_augmented.json"
543
+ if split_src.exists():
544
+ shutil.copy(split_src, STAGE_MODEL / "splits" / split_src.name)
545
+
546
+ code_src = ROOT / "comparison_experiment"
547
+ if code_src.exists():
548
+ for f in code_src.iterdir():
549
+ if f.is_file() and f.suffix in (".py", ".sh"):
550
+ shutil.copy(f, STAGE_MODEL / "code" / f.name)
551
+
552
+ (STAGE_MODEL / "README.md").write_text(readme_model)
553
+
554
+ # --- dataset repo staging (symlinks to save copy time / disk) ---
555
+ (STAGE_DATA / "images" / "augmented").mkdir(parents=True)
556
+ (STAGE_DATA / "images" / "original").mkdir(parents=True)
557
+ (STAGE_DATA / "splits").mkdir()
558
+
559
+ if aug_dir.exists():
560
+ for c in aug_dir.iterdir():
561
+ if c.is_dir():
562
+ target = STAGE_DATA / "images" / "augmented" / c.name
563
+ if not target.exists():
564
+ target.symlink_to(c.resolve())
565
+
566
+ if orig_dir.exists():
567
+ for c in orig_dir.iterdir():
568
+ if c.is_dir():
569
+ target = STAGE_DATA / "images" / "original" / c.name
570
+ if not target.exists():
571
+ target.symlink_to(c.resolve())
572
+
573
+ if split_src.exists():
574
+ shutil.copy(split_src, STAGE_DATA / "splits" / split_src.name)
575
+
576
+ (STAGE_DATA / "README.md").write_text(readme_dataset)
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # 5. Create repos
581
+ # ---------------------------------------------------------------------------
582
+
583
+ print(f"[hf] create_repo {MODEL_REPO}")
584
+ create_repo(MODEL_REPO, repo_type="model", exist_ok=True, token=TOKEN)
585
+ print(f"[hf] create_repo {DATASET_REPO}")
586
+ create_repo(DATASET_REPO, repo_type="dataset", exist_ok=True, token=TOKEN)
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # 6. Upload
591
+ # ---------------------------------------------------------------------------
592
+
593
+ print(f"[hf] upload model repo from {STAGE_MODEL}")
594
+ api.upload_folder(
595
+ folder_path=str(STAGE_MODEL),
596
+ repo_id=MODEL_REPO,
597
+ repo_type="model",
598
+ commit_message="Add 9-model fundus benchmark: weights + results + splits + code + README",
599
+ )
600
+
601
+ print(f"[hf] upload dataset repo from {STAGE_DATA}")
602
+ api.upload_folder(
603
+ folder_path=str(STAGE_DATA),
604
+ repo_id=DATASET_REPO,
605
+ repo_type="dataset",
606
+ commit_message="Add fundus 10-class augmented dataset + pHash-grouped split",
607
+ )
608
+
609
+ print("\n=== DONE ===")
610
+ print(f"Model: https://huggingface.co/{MODEL_REPO}")
611
+ print(f"Dataset: https://huggingface.co/datasets/{DATASET_REPO}")
code/run_final_experiments.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unified pipeline for the corrected thesis experiments.
2
+
3
+ Given a holdout split manifest (build_holdout_split.py), this script:
4
+ 1. Runs stratified 5-fold CV on the train+val pool for every selected model.
5
+ 2. After CV, retrains the model on the full train+val pool.
6
+ 3. Evaluates on the held-out independent test set and stores per-sample
7
+ predictions for paired statistical testing.
8
+
9
+ Designed to run unattended on the Azure T4 VM. All artefacts go to
10
+ ``output_dir`` so the user can inspect them after the long run finishes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import copy
17
+ import json
18
+ import random
19
+ import time
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+ from typing import Sequence
23
+
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from PIL import Image
29
+ from sklearn.metrics import (
30
+ accuracy_score,
31
+ average_precision_score,
32
+ confusion_matrix,
33
+ f1_score,
34
+ precision_recall_fscore_support,
35
+ roc_auc_score,
36
+ )
37
+ from sklearn.model_selection import StratifiedKFold
38
+ from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler
39
+ from torchvision import models, transforms
40
+
41
+
42
+ IMAGENET_MEAN = [0.485, 0.456, 0.406]
43
+ IMAGENET_STD = [0.229, 0.224, 0.225]
44
+ CLIP_MEAN = [0.48145466, 0.4578275, 0.40821073]
45
+ CLIP_STD = [0.26862954, 0.26130258, 0.27577711]
46
+ INCEPTION_SIZE = 299
47
+
48
+
49
+ class ImageListDataset(Dataset):
50
+ def __init__(self, root: Path, samples: Sequence[tuple[str, int]], transform):
51
+ self.root = root
52
+ self.samples = samples
53
+ self.transform = transform
54
+
55
+ def __len__(self):
56
+ return len(self.samples)
57
+
58
+ def __getitem__(self, idx):
59
+ rel_path, label = self.samples[idx]
60
+ with Image.open(self.root / rel_path) as img:
61
+ img = img.convert("RGB")
62
+ if self.transform is not None:
63
+ img = self.transform(img)
64
+ return img, label
65
+
66
+
67
+ class OpenCLIPClassifier(nn.Module):
68
+ def __init__(self, num_classes, model_name="ViT-B-16", pretrained="openai"):
69
+ super().__init__()
70
+ import open_clip
71
+
72
+ clip_model, _, _ = open_clip.create_model_and_transforms(model_name, pretrained=pretrained)
73
+ self.backbone = clip_model
74
+ with torch.no_grad():
75
+ dummy = torch.zeros(1, 3, 224, 224)
76
+ feat_dim = self.backbone.encode_image(dummy).shape[-1]
77
+ self.head = nn.Linear(feat_dim, num_classes)
78
+
79
+ def forward(self, x):
80
+ feats = self.backbone.encode_image(x)
81
+ return self.head(feats.float())
82
+
83
+
84
+ def build_transforms(image_size, use_clip_norm=False):
85
+ mean = CLIP_MEAN if use_clip_norm else IMAGENET_MEAN
86
+ std = CLIP_STD if use_clip_norm else IMAGENET_STD
87
+ train_tf = transforms.Compose([
88
+ transforms.RandomRotation(30),
89
+ transforms.RandomHorizontalFlip(),
90
+ transforms.RandomResizedCrop((image_size, image_size)),
91
+ transforms.ToTensor(),
92
+ transforms.Normalize(mean, std),
93
+ ])
94
+ eval_tf = transforms.Compose([
95
+ transforms.Resize((image_size, image_size)),
96
+ transforms.ToTensor(),
97
+ transforms.Normalize(mean, std),
98
+ ])
99
+ return train_tf, eval_tf
100
+
101
+
102
+ def build_model(name, num_classes):
103
+ if name == "vgg19":
104
+ m = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1)
105
+ m.classifier[-1] = nn.Linear(m.classifier[-1].in_features, num_classes)
106
+ return m, 224, False
107
+ if name == "resnet50":
108
+ m = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)
109
+ m.fc = nn.Linear(m.fc.in_features, num_classes)
110
+ return m, 224, False
111
+ if name == "resnet101":
112
+ m = models.resnet101(weights=models.ResNet101_Weights.IMAGENET1K_V2)
113
+ m.fc = nn.Linear(m.fc.in_features, num_classes)
114
+ return m, 224, False
115
+ if name == "densenet121":
116
+ m = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1)
117
+ m.classifier = nn.Linear(m.classifier.in_features, num_classes)
118
+ return m, 224, False
119
+ if name == "inception_v3":
120
+ m = models.inception_v3(weights=models.Inception_V3_Weights.IMAGENET1K_V1, aux_logits=True)
121
+ m.fc = nn.Linear(m.fc.in_features, num_classes)
122
+ m.AuxLogits.fc = nn.Linear(m.AuxLogits.fc.in_features, num_classes)
123
+ return m, INCEPTION_SIZE, False
124
+ if name == "clip_openai":
125
+ return OpenCLIPClassifier(num_classes), 224, True
126
+ raise ValueError(name)
127
+
128
+
129
+ def extract_logits_loss(out, labels, criterion):
130
+ if isinstance(out, tuple):
131
+ logits = out[0]
132
+ loss = criterion(logits, labels) + 0.4 * criterion(out[1], labels)
133
+ return logits, loss
134
+ return out, criterion(out, labels)
135
+
136
+
137
+ def train_epoch(model, loader, criterion, optim, scaler, device):
138
+ model.train()
139
+ total, count = 0.0, 0
140
+ for x, y in loader:
141
+ x = x.to(device, non_blocking=True)
142
+ y = y.to(device, non_blocking=True)
143
+ optim.zero_grad(set_to_none=True)
144
+ with torch.amp.autocast("cuda", enabled=scaler.is_enabled()):
145
+ out = model(x)
146
+ _, loss = extract_logits_loss(out, y, criterion)
147
+ scaler.scale(loss).backward()
148
+ scaler.step(optim)
149
+ scaler.update()
150
+ total += loss.item() * x.size(0)
151
+ count += x.size(0)
152
+ return total / max(count, 1)
153
+
154
+
155
+ @torch.no_grad()
156
+ def evaluate(model, loader, device, return_preds=False, num_classes=None):
157
+ model.eval()
158
+ preds, labels, probs = [], [], []
159
+ for x, y in loader:
160
+ x = x.to(device, non_blocking=True)
161
+ out = model(x)
162
+ logits = out[0] if isinstance(out, tuple) else out
163
+ p = F.softmax(logits, dim=1)
164
+ preds.extend(logits.argmax(1).cpu().tolist())
165
+ probs.extend(p.cpu().numpy().tolist())
166
+ labels.extend(y.tolist())
167
+ p_m, r_m, f_m, _ = precision_recall_fscore_support(labels, preds, average="macro", zero_division=0)
168
+ acc = accuracy_score(labels, preds)
169
+ res = {"accuracy": acc, "precision_macro": p_m, "recall_macro": r_m, "f1_macro": f_m}
170
+ # ROC-AUC and PR-AUC (one-vs-rest, macro)
171
+ try:
172
+ n = num_classes or (max(labels) + 1)
173
+ y_onehot = np.eye(n)[np.array(labels)]
174
+ probs_arr = np.array(probs)
175
+ res["roc_auc_macro"] = float(roc_auc_score(y_onehot, probs_arr, average="macro", multi_class="ovr"))
176
+ res["pr_auc_macro"] = float(average_precision_score(y_onehot, probs_arr, average="macro"))
177
+ except Exception as exc:
178
+ res["roc_auc_macro"] = None
179
+ res["pr_auc_macro"] = None
180
+ # Expected Calibration Error (15 bins)
181
+ res["ece"] = float(expected_calibration_error(np.array(probs), np.array(labels)))
182
+ if return_preds:
183
+ res["preds"] = preds
184
+ res["labels"] = labels
185
+ res["probs"] = probs
186
+ return res
187
+
188
+
189
+ def expected_calibration_error(probs, labels, n_bins=15):
190
+ confidences = probs.max(axis=1)
191
+ predictions = probs.argmax(axis=1)
192
+ accuracies = (predictions == labels).astype(float)
193
+ bin_boundaries = np.linspace(0, 1, n_bins + 1)
194
+ ece = 0.0
195
+ for lo, hi in zip(bin_boundaries[:-1], bin_boundaries[1:]):
196
+ in_bin = (confidences > lo) & (confidences <= hi)
197
+ if in_bin.sum() > 0:
198
+ avg_conf = confidences[in_bin].mean()
199
+ avg_acc = accuracies[in_bin].mean()
200
+ ece += (in_bin.sum() / len(probs)) * abs(avg_conf - avg_acc)
201
+ return ece
202
+
203
+
204
+ def bootstrap_ci(labels, preds, metric_fn, n_resamples=1000, alpha=0.05, seed=0):
205
+ rng = np.random.RandomState(seed)
206
+ labels = np.array(labels)
207
+ preds = np.array(preds)
208
+ stats = []
209
+ n = len(labels)
210
+ for _ in range(n_resamples):
211
+ idx = rng.randint(0, n, size=n)
212
+ stats.append(metric_fn(labels[idx], preds[idx]))
213
+ stats = np.array(stats)
214
+ return {"mean": float(stats.mean()), "lo": float(np.quantile(stats, alpha / 2)),
215
+ "hi": float(np.quantile(stats, 1 - alpha / 2))}
216
+
217
+
218
+ def compute_class_weights(labels, num_classes, smoothing="sqrt"):
219
+ counts = np.bincount(labels, minlength=num_classes).astype(float)
220
+ counts[counts == 0] = 1.0 # avoid div0
221
+ if smoothing == "sqrt":
222
+ weights = 1.0 / np.sqrt(counts)
223
+ else:
224
+ weights = 1.0 / counts
225
+ weights = weights * num_classes / weights.sum()
226
+ return torch.tensor(weights, dtype=torch.float32)
227
+
228
+
229
+ def class_names_to_int(samples, classes):
230
+ cls_to_idx = {c: i for i, c in enumerate(classes)}
231
+ return [(p, cls_to_idx[c]) for p, c in samples]
232
+
233
+
234
+ def train_model(model_name, train_samples, val_samples, classes, root, args, device, log):
235
+ model_tuple = build_model(model_name, len(classes))
236
+ model, image_size, use_clip_norm = model_tuple
237
+ model = model.to(device)
238
+ train_tf, eval_tf = build_transforms(image_size, use_clip_norm)
239
+ train_set = ImageListDataset(root, train_samples, train_tf)
240
+ val_set = ImageListDataset(root, val_samples, eval_tf)
241
+ train_loader = DataLoader(train_set, batch_size=args.batch_size, shuffle=True,
242
+ num_workers=args.workers, pin_memory=device.type == "cuda")
243
+ val_loader = DataLoader(val_set, batch_size=args.batch_size, shuffle=False,
244
+ num_workers=args.workers, pin_memory=device.type == "cuda")
245
+ # Class-weighted cross-entropy to mitigate severe imbalance
246
+ train_labels = [s[1] for s in train_samples]
247
+ cls_weights = compute_class_weights(train_labels, len(classes)).to(device) if args.class_weighted else None
248
+ criterion = nn.CrossEntropyLoss(weight=cls_weights)
249
+ optim = torch.optim.Adam(model.parameters(), lr=args.lr, weight_decay=1e-4)
250
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optim, T_max=args.epochs)
251
+ scaler = torch.amp.GradScaler("cuda", enabled=device.type == "cuda")
252
+
253
+ best_state, best_acc, best_epoch = None, 0.0, 0
254
+ bad_epochs = 0
255
+ for ep in range(1, args.epochs + 1):
256
+ tloss = train_epoch(model, train_loader, criterion, optim, scaler, device)
257
+ scheduler.step()
258
+ v = evaluate(model, val_loader, device, num_classes=len(classes))
259
+ line = (f"{model_name} ep {ep:03d} train_loss={tloss:.4f} "
260
+ f"val_acc={v['accuracy']:.4f} val_f1={v['f1_macro']:.4f}")
261
+ log(line)
262
+ if v["accuracy"] > best_acc:
263
+ best_acc = v["accuracy"]
264
+ best_epoch = ep
265
+ best_state = copy.deepcopy(model.state_dict())
266
+ bad_epochs = 0
267
+ else:
268
+ bad_epochs += 1
269
+ if bad_epochs >= args.patience:
270
+ log(f" early stop at ep {ep} (best ep {best_epoch} acc {best_acc:.4f})")
271
+ break
272
+ if best_state is not None:
273
+ model.load_state_dict(best_state)
274
+ return model, {"best_val_acc": best_acc, "best_epoch": best_epoch, "image_size": image_size,
275
+ "use_clip_norm": use_clip_norm}
276
+
277
+
278
+ def run_kfold(model_name, samples_pool, classes, root, args, device, log):
279
+ paths = [s[0] for s in samples_pool]
280
+ labels = [s[1] for s in samples_pool]
281
+ skf = StratifiedKFold(n_splits=args.folds, shuffle=True, random_state=args.seed)
282
+ fold_metrics = []
283
+ for k, (tr_idx, vl_idx) in enumerate(skf.split(paths, labels), start=1):
284
+ log(f"=== {model_name} fold {k}/{args.folds} ===")
285
+ tr = [samples_pool[i] for i in tr_idx]
286
+ vl = [samples_pool[i] for i in vl_idx]
287
+ model, info = train_model(model_name, tr, vl, classes, root, args, device, log)
288
+ # final fold metrics on val
289
+ _, eval_tf = build_transforms(info["image_size"], info["use_clip_norm"])
290
+ v_loader = DataLoader(
291
+ ImageListDataset(root, vl, eval_tf), batch_size=args.batch_size,
292
+ shuffle=False, num_workers=args.workers, pin_memory=device.type == "cuda",
293
+ )
294
+ m = evaluate(model, v_loader, device, return_preds=True, num_classes=len(classes))
295
+ fold_metrics.append({"fold": k, **{k2: v for k2, v in m.items() if k2 not in ("preds", "labels", "probs")}})
296
+ log(f" fold {k} acc={m['accuracy']:.4f} f1={m['f1_macro']:.4f}")
297
+ del model
298
+ torch.cuda.empty_cache()
299
+ accs = np.array([f["accuracy"] for f in fold_metrics])
300
+ f1s = np.array([f["f1_macro"] for f in fold_metrics])
301
+ summary = {
302
+ "model": model_name,
303
+ "folds": args.folds,
304
+ "accuracy_mean": float(accs.mean()),
305
+ "accuracy_std": float(accs.std(ddof=1)),
306
+ "accuracy_ci95": [float(accs.mean() - 1.96 * accs.std(ddof=1) / np.sqrt(len(accs))),
307
+ float(accs.mean() + 1.96 * accs.std(ddof=1) / np.sqrt(len(accs)))],
308
+ "f1_macro_mean": float(f1s.mean()),
309
+ "f1_macro_std": float(f1s.std(ddof=1)),
310
+ "fold_metrics": fold_metrics,
311
+ }
312
+ return summary
313
+
314
+
315
+ def run_indep_test(model_name, samples_pool, test_samples, classes, root, args, device, log, weights_dir):
316
+ log(f"=== {model_name} FINAL (train on pool, eval on indep test) ===")
317
+ # 90/10 split within the pool to keep an internal val for early stopping
318
+ pool_paths = [s[0] for s in samples_pool]
319
+ pool_labels = [s[1] for s in samples_pool]
320
+ rng = np.random.RandomState(args.seed)
321
+ idx = np.arange(len(samples_pool))
322
+ rng.shuffle(idx)
323
+ cut = int(0.9 * len(idx))
324
+ tr = [samples_pool[i] for i in idx[:cut]]
325
+ vl = [samples_pool[i] for i in idx[cut:]]
326
+ model, info = train_model(model_name, tr, vl, classes, root, args, device, log)
327
+ weights_path = weights_dir / f"{model_name}_final.pth"
328
+ torch.save(model.state_dict(), weights_path)
329
+ log(f" saved weights: {weights_path}")
330
+ _, eval_tf = build_transforms(info["image_size"], info["use_clip_norm"])
331
+ test_loader = DataLoader(
332
+ ImageListDataset(root, test_samples, eval_tf), batch_size=args.batch_size,
333
+ shuffle=False, num_workers=args.workers, pin_memory=device.type == "cuda",
334
+ )
335
+ m = evaluate(model, test_loader, device, return_preds=True, num_classes=len(classes))
336
+ cm = confusion_matrix(m["labels"], m["preds"], labels=list(range(len(classes))))
337
+ # Bootstrap 95% CIs on accuracy and macro-F1
338
+ boot_acc = bootstrap_ci(m["labels"], m["preds"], lambda y, p: float((y == p).mean()), seed=args.seed)
339
+ boot_f1 = bootstrap_ci(m["labels"], m["preds"], lambda y, p: f1_score(y, p, average="macro", zero_division=0), seed=args.seed + 1)
340
+ log(f" test acc={m['accuracy']:.4f} f1={m['f1_macro']:.4f} "
341
+ f"roc_auc={m.get('roc_auc_macro')} ece={m['ece']:.4f}")
342
+ del model
343
+ torch.cuda.empty_cache()
344
+ return {
345
+ "model": model_name,
346
+ "test_accuracy": m["accuracy"],
347
+ "test_precision_macro": m["precision_macro"],
348
+ "test_recall_macro": m["recall_macro"],
349
+ "test_f1_macro": m["f1_macro"],
350
+ "test_roc_auc_macro": m.get("roc_auc_macro"),
351
+ "test_pr_auc_macro": m.get("pr_auc_macro"),
352
+ "test_ece": m["ece"],
353
+ "bootstrap_accuracy_ci95": boot_acc,
354
+ "bootstrap_f1_macro_ci95": boot_f1,
355
+ "preds": m["preds"],
356
+ "labels": m["labels"],
357
+ "probs": m["probs"],
358
+ "confusion_matrix": cm.tolist(),
359
+ }
360
+
361
+
362
+ def mcnemar_pairwise(test_results):
363
+ """Compute pairwise McNemar p-values between models on the independent test set."""
364
+ from scipy.stats import binom
365
+ out = {}
366
+ names = sorted(test_results.keys())
367
+ for i in range(len(names)):
368
+ for j in range(i + 1, len(names)):
369
+ a, b = names[i], names[j]
370
+ preds_a = np.array(test_results[a]["preds"])
371
+ preds_b = np.array(test_results[b]["preds"])
372
+ labels = np.array(test_results[a]["labels"])
373
+ ca = preds_a == labels
374
+ cb = preds_b == labels
375
+ b_count = int(np.sum(ca & ~cb)) # a right, b wrong
376
+ c_count = int(np.sum(~ca & cb)) # a wrong, b right
377
+ n = b_count + c_count
378
+ if n == 0:
379
+ p = 1.0
380
+ else:
381
+ k = min(b_count, c_count)
382
+ p = float(2 * binom.cdf(k, n, 0.5))
383
+ p = min(p, 1.0)
384
+ out[f"{a}_vs_{b}"] = {"b": b_count, "c": c_count, "p_value": p}
385
+ return out
386
+
387
+
388
+ def parse_args():
389
+ p = argparse.ArgumentParser()
390
+ p.add_argument("--manifest", default="holdout_split.json")
391
+ p.add_argument("--models", nargs="+", default=[
392
+ "vgg19", "resnet50", "resnet101", "densenet121", "inception_v3", "clip_openai",
393
+ ])
394
+ p.add_argument("--epochs", type=int, default=60)
395
+ p.add_argument("--batch-size", type=int, default=32)
396
+ p.add_argument("--workers", type=int, default=4)
397
+ p.add_argument("--lr", type=float, default=1e-4)
398
+ p.add_argument("--folds", type=int, default=5)
399
+ p.add_argument("--patience", type=int, default=10)
400
+ p.add_argument("--seed", type=int, default=42)
401
+ p.add_argument("--output-dir", default="final_experiments")
402
+ p.add_argument("--skip-kfold", action="store_true")
403
+ p.add_argument("--skip-test", action="store_true")
404
+ p.add_argument("--class-weighted", action="store_true", default=True,
405
+ help="Use sqrt-inverse-frequency class-weighted CE loss to mitigate imbalance.")
406
+ p.add_argument("--no-class-weighted", dest="class_weighted", action="store_false")
407
+ return p.parse_args()
408
+
409
+
410
+ def main():
411
+ args = parse_args()
412
+ manifest = json.loads(Path(args.manifest).read_text())
413
+ root = Path(manifest["data_dir"])
414
+ classes = manifest["classes"]
415
+
416
+ pool_samples = class_names_to_int(
417
+ [(p, c) for p, c in zip(manifest["kfold"]["pool_paths"], manifest["kfold"]["pool_labels"])],
418
+ classes,
419
+ )
420
+ test_samples = class_names_to_int(manifest["splits"]["test"], classes)
421
+
422
+ out_dir = Path(args.output_dir)
423
+ out_dir.mkdir(parents=True, exist_ok=True)
424
+ weights_dir = out_dir / "weights"
425
+ weights_dir.mkdir(exist_ok=True)
426
+ log_path = out_dir / "run.log"
427
+
428
+ def log(line):
429
+ msg = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {line}"
430
+ print(msg, flush=True)
431
+ with log_path.open("a") as fh:
432
+ fh.write(msg + "\n")
433
+
434
+ random.seed(args.seed)
435
+ np.random.seed(args.seed)
436
+ torch.manual_seed(args.seed)
437
+ torch.cuda.manual_seed_all(args.seed)
438
+
439
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
440
+ log(f"device={device} classes={len(classes)} pool={len(pool_samples)} test={len(test_samples)}")
441
+
442
+ kfold_summaries = {}
443
+ test_results = {}
444
+
445
+ for model_name in args.models:
446
+ log(f"########## {model_name} ##########")
447
+ try:
448
+ if not args.skip_kfold:
449
+ s = run_kfold(model_name, pool_samples, classes, root, args, device, log)
450
+ kfold_summaries[model_name] = s
451
+ (out_dir / f"{model_name}_kfold.json").write_text(json.dumps(s, indent=2))
452
+ if not args.skip_test:
453
+ r = run_indep_test(model_name, pool_samples, test_samples, classes, root, args, device, log, weights_dir)
454
+ test_results[model_name] = r
455
+ # Save without huge preds/labels/probs arrays inline
456
+ slim = {k: v for k, v in r.items() if k not in ("preds", "labels", "probs")}
457
+ (out_dir / f"{model_name}_test.json").write_text(json.dumps(slim, indent=2))
458
+ # Save preds + probs separately for stat tests, ROC, calibration analysis
459
+ (out_dir / f"{model_name}_test_preds.json").write_text(json.dumps({
460
+ "preds": r["preds"], "labels": r["labels"], "probs": r["probs"]}))
461
+ except Exception as exc:
462
+ log(f"!! {model_name} FAILED: {exc!r}")
463
+
464
+ if test_results:
465
+ mc = mcnemar_pairwise(test_results)
466
+ (out_dir / "mcnemar.json").write_text(json.dumps(mc, indent=2))
467
+ log(f"McNemar pairwise saved -> {out_dir / 'mcnemar.json'}")
468
+
469
+ if kfold_summaries:
470
+ summary = {m: {k: v for k, v in s.items() if k != "fold_metrics"} for m, s in kfold_summaries.items()}
471
+ (out_dir / "kfold_summary.json").write_text(json.dumps(summary, indent=2))
472
+ log("ALL DONE")
473
+
474
+
475
+ if __name__ == "__main__":
476
+ main()
code/run_foundation_models.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 2 — Foundation model fine-tuning for fundus classification.
3
+
4
+ Backbones added:
5
+ * RETFound (MAE-pretrained on 1.6M fundus images; SOTA on most fundus benchmarks)
6
+ weights: https://github.com/rmaphoh/RETFound_MAE
7
+ * DINOv2-Large (general-purpose strong self-supervised features)
8
+ * Swin-Base (timm)
9
+
10
+ Two-regime fine-tuning:
11
+ 1. linear-probe (head only) for 20 epochs -> stable feature extraction baseline
12
+ 2. full fine-tune at LR 1e-5 for 10 epochs -> task-specific adaptation
13
+ """
14
+
15
+ import argparse, json, math, os, time
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import torch, torch.nn as nn, torch.nn.functional as F
20
+ from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
21
+ from torch.cuda.amp import autocast, GradScaler
22
+ from torchvision import transforms
23
+ from PIL import Image
24
+ import cv2
25
+
26
+ from sklearn.metrics import accuracy_score, precision_recall_fscore_support, roc_auc_score, average_precision_score
27
+
28
+
29
+ # Re-use building blocks from v2 (CLAHE etc.) by inlining to keep this self-contained.
30
+ class CLAHEPreprocess:
31
+ def __init__(self, clip_limit=2.0, tile=(8, 8)):
32
+ self.clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile)
33
+ def __call__(self, img):
34
+ arr = np.array(img.convert("RGB"))
35
+ lab = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB)
36
+ lab[..., 0] = self.clahe.apply(lab[..., 0])
37
+ return Image.fromarray(cv2.cvtColor(lab, cv2.COLOR_LAB2RGB))
38
+
39
+
40
+ class ImageListDataset(Dataset):
41
+ def __init__(self, samples, transform):
42
+ self.samples = samples; self.transform = transform
43
+ def __len__(self): return len(self.samples)
44
+ def __getitem__(self, i):
45
+ p, l = self.samples[i]
46
+ return self.transform(Image.open(p).convert("RGB")), int(l)
47
+
48
+
49
+ def make_transforms(image_size, train, mean, std):
50
+ pre = [CLAHEPreprocess()]
51
+ if train:
52
+ return transforms.Compose(pre + [
53
+ transforms.Resize((image_size + 32, image_size + 32)),
54
+ transforms.RandomResizedCrop(image_size, scale=(0.75, 1.0)),
55
+ transforms.RandomHorizontalFlip(),
56
+ transforms.RandomRotation(15),
57
+ transforms.RandAugment(num_ops=2, magnitude=7),
58
+ transforms.ColorJitter(0.15, 0.15, 0.1),
59
+ transforms.ToTensor(),
60
+ transforms.Normalize(mean, std),
61
+ transforms.RandomErasing(p=0.2, scale=(0.02, 0.1)),
62
+ ])
63
+ return transforms.Compose(pre + [
64
+ transforms.Resize((image_size, image_size)),
65
+ transforms.ToTensor(),
66
+ transforms.Normalize(mean, std),
67
+ ])
68
+
69
+
70
+ # ------------------------- backbones -------------------------
71
+
72
+ def build_dinov2_large(num_classes):
73
+ """DINOv2-L/14: 1024-dim CLS features."""
74
+ backbone = torch.hub.load('facebookresearch/dinov2', 'dinov2_vitl14')
75
+ class M(nn.Module):
76
+ def __init__(self):
77
+ super().__init__()
78
+ self.backbone = backbone
79
+ self.head = nn.Linear(1024, num_classes)
80
+ # Materialize parameter lists (avoid generator exhaustion).
81
+ self._head_params = list(self.head.parameters())
82
+ self._backbone_params = list(self.backbone.parameters())
83
+ def forward(self, x):
84
+ f = self.backbone(x) # CLS token, [B, 1024]
85
+ return self.head(f)
86
+ def trainable_groups(self):
87
+ return [
88
+ {"params": self._head_params, "lr": 1e-3, "linear_probe": True},
89
+ {"params": self._backbone_params, "lr": 1e-5, "linear_probe": False},
90
+ ]
91
+ return M(), 224, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]
92
+
93
+
94
+ def build_swin_base(num_classes):
95
+ import timm
96
+ model = timm.create_model("swin_base_patch4_window7_224", pretrained=True, num_classes=num_classes)
97
+ head_params = list(model.head.parameters()) if hasattr(model, "head") else []
98
+ other_params = [p for n, p in model.named_parameters() if not n.startswith("head")]
99
+ class M(nn.Module):
100
+ def __init__(self):
101
+ super().__init__(); self.m = model
102
+ def forward(self, x): return self.m(x)
103
+ def trainable_groups(self):
104
+ return [
105
+ {"params": head_params, "lr": 1e-3, "linear_probe": True},
106
+ {"params": other_params, "lr": 1e-5, "linear_probe": False},
107
+ ]
108
+ return M(), 224, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]
109
+
110
+
111
+ def build_retfound(num_classes, weights_path):
112
+ """RETFound ViT-Large/16, MAE-pretrained on fundus images.
113
+ Loads weights from a local checkpoint downloaded from rmaphoh/RETFound_MAE."""
114
+ import timm
115
+ # RETFound is a vanilla MAE ViT-L/16 with patch 16, image 224.
116
+ model = timm.create_model("vit_large_patch16_224", pretrained=False, num_classes=num_classes,
117
+ drop_path_rate=0.2, global_pool="token")
118
+ if weights_path and os.path.exists(weights_path):
119
+ ckpt = torch.load(weights_path, map_location="cpu", weights_only=False)
120
+ state = ckpt.get("model", ckpt.get("state_dict", ckpt))
121
+ # RETFound checkpoints have 'pos_embed' etc; we drop classifier head keys
122
+ state = {k: v for k, v in state.items()
123
+ if not k.startswith("head.") and not k.startswith("fc_norm.")}
124
+ missing, unexp = model.load_state_dict(state, strict=False)
125
+ print(f" RETFound loaded: {len(state)} keys, missing={len(missing)}, unexpected={len(unexp)}")
126
+ else:
127
+ print(f" WARNING: RETFound weights not found at {weights_path}; using random init for backbone (will perform poorly)")
128
+ head_params = list(model.head.parameters()) + list(model.fc_norm.parameters())
129
+ other_params = [p for n, p in model.named_parameters()
130
+ if not n.startswith("head") and not n.startswith("fc_norm")]
131
+ class M(nn.Module):
132
+ def __init__(self): super().__init__(); self.m = model
133
+ def forward(self, x): return self.m(x)
134
+ def trainable_groups(self):
135
+ return [
136
+ {"params": head_params, "lr": 1e-3, "linear_probe": True},
137
+ {"params": other_params, "lr": 1e-5, "linear_probe": False},
138
+ ]
139
+ return M(), 224, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]
140
+
141
+
142
+ # ------------------------- train -------------------------
143
+
144
+ def expected_calibration_error(probs, labels, n_bins=15):
145
+ conf = probs.max(1); pred = probs.argmax(1); correct = (pred == labels).astype(float)
146
+ bins = np.linspace(0, 1, n_bins+1); ece = 0.0
147
+ for i in range(n_bins):
148
+ m = (conf > bins[i]) & (conf <= bins[i+1])
149
+ if m.sum(): ece += m.mean() * abs(correct[m].mean() - conf[m].mean())
150
+ return float(ece)
151
+
152
+
153
+ def bootstrap_ci(labels, preds, metric_fn, n=1000, seed=42):
154
+ rng = np.random.default_rng(seed); N = len(labels); vals = []
155
+ for _ in range(n):
156
+ idx = rng.integers(0, N, N)
157
+ try: vals.append(metric_fn(labels[idx], preds[idx]))
158
+ except Exception: pass
159
+ vals = np.array(vals)
160
+ return float(np.percentile(vals, 2.5)), float(np.percentile(vals, 97.5))
161
+
162
+
163
+ @torch.no_grad()
164
+ def tta_predict(model, x, device):
165
+ model.eval(); B, C, H, W = x.shape
166
+ crop = int(H * 0.9); out = None; n = 0
167
+ views = [x, torch.flip(x, dims=[3])]
168
+ for (y, xc) in [(0, 0), (0, W-crop), (H-crop, 0), (H-crop, W-crop)]:
169
+ c = x[:, :, y:y+crop, xc:xc+crop]
170
+ c = F.interpolate(c, size=(H, W), mode="bilinear", align_corners=False)
171
+ views.append(c)
172
+ for v in views:
173
+ p = F.softmax(model(v.to(device)), dim=1)
174
+ out = p if out is None else out + p; n += 1
175
+ return (out/n).cpu().numpy()
176
+
177
+
178
+ @torch.no_grad()
179
+ def evaluate(model, loader, device, num_classes, use_tta=False):
180
+ model.eval(); ps, ls = [], []
181
+ for x, y in loader:
182
+ if use_tta: p = tta_predict(model, x, device)
183
+ else:
184
+ p = F.softmax(model(x.to(device)), dim=1).cpu().numpy()
185
+ ps.append(p); ls.append(y.numpy())
186
+ probs = np.concatenate(ps); labels = np.concatenate(ls); preds = probs.argmax(1)
187
+ acc = accuracy_score(labels, preds)
188
+ p, r, f1, _ = precision_recall_fscore_support(labels, preds, average="macro", zero_division=0)
189
+ try: roc = roc_auc_score(labels, probs, multi_class="ovr", average="macro", labels=list(range(num_classes)))
190
+ except Exception: roc = float("nan")
191
+ try: pr_auc = average_precision_score(F.one_hot(torch.tensor(labels), num_classes).numpy(), probs, average="macro")
192
+ except Exception: pr_auc = float("nan")
193
+ return {"acc": acc, "precision": p, "recall": r, "f1": f1,
194
+ "roc_auc": roc, "pr_auc": pr_auc, "ece": expected_calibration_error(probs, labels),
195
+ "labels": labels.tolist(), "preds": preds.tolist(), "probs": probs.tolist()}
196
+
197
+
198
+ def mixup(x, y, alpha, nc):
199
+ lam = np.random.beta(alpha, alpha)
200
+ i = torch.randperm(x.size(0), device=x.device)
201
+ x = lam*x + (1-lam)*x[i]
202
+ yoh = F.one_hot(y, nc).float()
203
+ return x, lam*yoh + (1-lam)*yoh[i]
204
+
205
+
206
+ def train_foundation(name, build_fn, samples_tr, samples_va, num_classes, device,
207
+ batch_size, workers, lp_epochs, ft_epochs, patience, label):
208
+ model, image_size, mean, std = build_fn()
209
+ model = model.to(device)
210
+
211
+ tf_tr = make_transforms(image_size, train=True, mean=mean, std=std)
212
+ tf_va = make_transforms(image_size, train=False, mean=mean, std=std)
213
+
214
+ ds_tr = ImageListDataset(samples_tr, tf_tr); ds_va = ImageListDataset(samples_va, tf_va)
215
+ labels_arr = np.array([s[1] for s in samples_tr])
216
+ cw = 1.0 / np.maximum(np.bincount(labels_arr, minlength=num_classes), 1)
217
+ sw = cw[labels_arr]
218
+ sampler = WeightedRandomSampler(sw.tolist(), num_samples=len(sw), replacement=True)
219
+ dl_tr = DataLoader(ds_tr, batch_size=batch_size, sampler=sampler, num_workers=workers, pin_memory=True, drop_last=True)
220
+ dl_va = DataLoader(ds_va, batch_size=batch_size, shuffle=False, num_workers=workers, pin_memory=True)
221
+
222
+ groups = model.trainable_groups()
223
+ head_group = next(g for g in groups if g.get("linear_probe"))
224
+ backbone_group = next(g for g in groups if not g.get("linear_probe"))
225
+
226
+ scaler = GradScaler()
227
+ best_f1 = -1; best_state = None; bad = 0; history = []
228
+
229
+ # ---- Stage 1: linear probe (freeze backbone) ----
230
+ for p in backbone_group["params"]: p.requires_grad = False
231
+ opt = torch.optim.AdamW([{"params": head_group["params"], "lr": head_group["lr"]}], weight_decay=1e-4)
232
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=lp_epochs)
233
+ for ep in range(lp_epochs):
234
+ model.train(); t0 = time.time(); loss_sum, n = 0.0, 0
235
+ for x, y in dl_tr:
236
+ x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True)
237
+ if np.random.rand() < 0.3:
238
+ x, ysoft = mixup(x, y, 0.2, num_classes); soft = True
239
+ else: ysoft = y; soft = False
240
+ opt.zero_grad(set_to_none=True)
241
+ with autocast():
242
+ out = model(x)
243
+ loss = -(ysoft * F.log_softmax(out, 1)).sum(1).mean() if soft else F.cross_entropy(out, ysoft)
244
+ scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
245
+ loss_sum += loss.item()*x.size(0); n += x.size(0)
246
+ sched.step()
247
+ v = evaluate(model, dl_va, device, num_classes)
248
+ history.append({"phase": "lp", "epoch": ep, "loss": loss_sum/n, "val_acc": v["acc"], "val_f1": v["f1"]})
249
+ print(f"[{label} LP] ep {ep+1}/{lp_epochs} loss {loss_sum/n:.4f} val_acc {v['acc']*100:5.2f} val_f1 {v['f1']*100:5.2f} ({time.time()-t0:.0f}s)", flush=True)
250
+ if v["f1"] > best_f1 + 1e-4:
251
+ best_f1 = v["f1"]; best_state = {k: vv.detach().cpu().clone() for k, vv in model.state_dict().items()}; bad = 0
252
+ else:
253
+ bad += 1
254
+ if bad >= patience: print(f"[{label} LP] early stop"); break
255
+
256
+ # ---- Stage 2: full fine-tune (unfreeze backbone, low LR) ----
257
+ if best_state is not None: model.load_state_dict(best_state)
258
+ for p in backbone_group["params"]: p.requires_grad = True
259
+ opt = torch.optim.AdamW([
260
+ {"params": head_group["params"], "lr": 1e-4},
261
+ {"params": backbone_group["params"], "lr": 1e-5},
262
+ ], weight_decay=1e-4)
263
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=ft_epochs)
264
+ bad = 0
265
+ for ep in range(ft_epochs):
266
+ model.train(); t0 = time.time(); loss_sum, n = 0.0, 0
267
+ for x, y in dl_tr:
268
+ x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True)
269
+ if np.random.rand() < 0.3:
270
+ x, ysoft = mixup(x, y, 0.2, num_classes); soft = True
271
+ else: ysoft = y; soft = False
272
+ opt.zero_grad(set_to_none=True)
273
+ with autocast():
274
+ out = model(x)
275
+ loss = -(ysoft * F.log_softmax(out, 1)).sum(1).mean() if soft else F.cross_entropy(out, ysoft)
276
+ scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
277
+ loss_sum += loss.item()*x.size(0); n += x.size(0)
278
+ sched.step()
279
+ v = evaluate(model, dl_va, device, num_classes)
280
+ history.append({"phase": "ft", "epoch": ep, "loss": loss_sum/n, "val_acc": v["acc"], "val_f1": v["f1"]})
281
+ print(f"[{label} FT] ep {ep+1}/{ft_epochs} loss {loss_sum/n:.4f} val_acc {v['acc']*100:5.2f} val_f1 {v['f1']*100:5.2f} ({time.time()-t0:.0f}s)", flush=True)
282
+ if v["f1"] > best_f1 + 1e-4:
283
+ best_f1 = v["f1"]; best_state = {k: vv.detach().cpu().clone() for k, vv in model.state_dict().items()}; bad = 0
284
+ else:
285
+ bad += 1
286
+ if bad >= patience: print(f"[{label} FT] early stop"); break
287
+
288
+ if best_state is not None: model.load_state_dict(best_state)
289
+ return model, history, best_f1, image_size, mean, std
290
+
291
+
292
+ def main():
293
+ ap = argparse.ArgumentParser()
294
+ ap.add_argument("--manifest", required=True)
295
+ ap.add_argument("--out-dir", required=True)
296
+ ap.add_argument("--weights-dir", required=True)
297
+ ap.add_argument("--retfound-weights", default="weights_retfound.pth")
298
+ ap.add_argument("--models", nargs="+", default=["dinov2_l", "swin_b", "retfound"])
299
+ ap.add_argument("--batch-size", type=int, default=24)
300
+ ap.add_argument("--workers", type=int, default=4)
301
+ ap.add_argument("--lp-epochs", type=int, default=20)
302
+ ap.add_argument("--ft-epochs", type=int, default=15)
303
+ ap.add_argument("--patience", type=int, default=8)
304
+ ap.add_argument("--seed", type=int, default=42)
305
+ args = ap.parse_args()
306
+
307
+ torch.manual_seed(args.seed); np.random.seed(args.seed)
308
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu"); print(f"device: {device}")
309
+ out_dir = Path(args.out_dir); out_dir.mkdir(parents=True, exist_ok=True)
310
+ w_dir = Path(args.weights_dir); w_dir.mkdir(parents=True, exist_ok=True)
311
+
312
+ M = json.load(open(args.manifest))
313
+ num_classes = len(M["classes"])
314
+ samples_tr = [tuple(x) for x in M["splits"]["train"]]
315
+ samples_va = [tuple(x) for x in M["splits"]["val"]]
316
+ samples_te = [tuple(x) for x in M["splits"]["test"]]
317
+ print(f"train {len(samples_tr)} | val {len(samples_va)} | test {len(samples_te)} | {num_classes} classes")
318
+
319
+ builders = {
320
+ "dinov2_l": lambda: build_dinov2_large(num_classes),
321
+ "swin_b": lambda: build_swin_base(num_classes),
322
+ "retfound": lambda: build_retfound(num_classes, args.retfound_weights),
323
+ }
324
+
325
+ summary = {}
326
+ for name in args.models:
327
+ # Skip RETFound if weights file missing or empty (HF gated)
328
+ if name == "retfound":
329
+ wp = args.retfound_weights
330
+ if not (wp and os.path.exists(wp) and os.path.getsize(wp) > 1_000_000):
331
+ print(f"\n[retfound] SKIPPING — weights file '{wp}' missing or empty (HF gated). Use DINOv2/Swin instead.")
332
+ continue
333
+ print(f"\n======== {name} ========")
334
+ try:
335
+ model, hist, best_f1, image_size, mean, std = train_foundation(
336
+ name, builders[name], samples_tr + samples_va, samples_va,
337
+ num_classes, device, args.batch_size, args.workers,
338
+ args.lp_epochs, args.ft_epochs, args.patience, label=name)
339
+ except Exception as e:
340
+ print(f"[{name}] FAILED: {e}"); continue
341
+
342
+ tf_te = make_transforms(image_size, train=False, mean=mean, std=std)
343
+ dl_te = DataLoader(ImageListDataset(samples_te, tf_te), batch_size=args.batch_size,
344
+ shuffle=False, num_workers=args.workers, pin_memory=True)
345
+ print(f"[{name}] evaluating on test with TTA ...")
346
+ res = evaluate(model, dl_te, device, num_classes, use_tta=True)
347
+ labels = np.array(res["labels"]); preds = np.array(res["preds"])
348
+ acc_lo, acc_hi = bootstrap_ci(labels, preds, accuracy_score)
349
+ f1_lo, f1_hi = bootstrap_ci(labels, preds,
350
+ lambda l, p: precision_recall_fscore_support(l, p, average="macro", zero_division=0)[2])
351
+ summary[name] = {
352
+ "test_acc": res["acc"], "test_acc_ci": [acc_lo, acc_hi],
353
+ "test_f1": res["f1"], "test_f1_ci": [f1_lo, f1_hi],
354
+ "test_precision": res["precision"], "test_recall": res["recall"],
355
+ "roc_auc": res["roc_auc"], "pr_auc": res["pr_auc"], "ece": res["ece"],
356
+ }
357
+ with open(out_dir / f"{name}_test.json", "w") as f: json.dump(summary[name], f, indent=2)
358
+ with open(out_dir / f"{name}_test_preds.json", "w") as f:
359
+ json.dump({"labels": res["labels"], "preds": res["preds"], "probs": res["probs"]}, f)
360
+ torch.save(model.state_dict(), w_dir / f"{name}_v2.pth")
361
+ print(f"[{name}] test acc {res['acc']*100:.2f} [{acc_lo*100:.1f},{acc_hi*100:.1f}] f1 {res['f1']*100:.2f} roc {res['roc_auc']:.4f}")
362
+ del model; torch.cuda.empty_cache()
363
+
364
+ with open(out_dir / "summary_foundation.json", "w") as f: json.dump(summary, f, indent=2)
365
+ print("\nDone (Phase 2).")
366
+
367
+
368
+ if __name__ == "__main__":
369
+ main()
code/run_v2_experiments.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 1 — v2 training pipeline for fundus classification.
3
+
4
+ Differences from v1 (run_final_experiments.py):
5
+ * Reads holdout_split_augmented.json (group-aware split over the full
6
+ Original+Augmented union; no filename-level leakage).
7
+ * Adds CLAHE preprocessing (luminance channel) before all transforms.
8
+ * Adds RandAugment(n=2, m=9) on the training transforms.
9
+ * Adds WeightedRandomSampler (inverse class frequency).
10
+ * Adds MixUp/CutMix (α=0.2, alternating per batch with p=0.5).
11
+ * 100 epochs, EarlyStop patience 12, warmup (3 ep) + cosine.
12
+ * 6-view TTA at inference (original + hflip + 4 corner crops).
13
+ """
14
+
15
+ import argparse, json, math, os, random, time
16
+ from pathlib import Path
17
+ from collections import defaultdict
18
+
19
+ import numpy as np
20
+ import torch, torch.nn as nn, torch.nn.functional as F
21
+ from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
22
+ from torch.cuda.amp import autocast, GradScaler
23
+ from torchvision import transforms, models
24
+ import cv2
25
+ from PIL import Image
26
+ from sklearn.metrics import (
27
+ accuracy_score, precision_recall_fscore_support,
28
+ roc_auc_score, average_precision_score,
29
+ )
30
+ from scipy.stats import binom
31
+ from tqdm import tqdm
32
+
33
+
34
+ # ---------------------------- repro ----------------------------
35
+
36
+ def set_seed(s):
37
+ random.seed(s); np.random.seed(s); torch.manual_seed(s); torch.cuda.manual_seed_all(s)
38
+
39
+
40
+ # ------------------------- CLAHE preprocessing -------------------------
41
+
42
+ class CLAHEPreprocess:
43
+ """Apply CLAHE on the L channel of LAB color space. PIL in, PIL out."""
44
+ def __init__(self, clip_limit=2.0, tile=(8, 8)):
45
+ self.clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile)
46
+ def __call__(self, img):
47
+ arr = np.array(img.convert("RGB"))
48
+ lab = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB)
49
+ lab[..., 0] = self.clahe.apply(lab[..., 0])
50
+ rgb = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
51
+ return Image.fromarray(rgb)
52
+
53
+
54
+ # ------------------------- dataset -------------------------
55
+
56
+ class ImageListDataset(Dataset):
57
+ def __init__(self, samples, transform):
58
+ self.samples = samples
59
+ self.transform = transform
60
+ def __len__(self):
61
+ return len(self.samples)
62
+ def __getitem__(self, idx):
63
+ p, lbl = self.samples[idx]
64
+ img = Image.open(p).convert("RGB")
65
+ return self.transform(img), int(lbl)
66
+
67
+
68
+ # ------------------------- transforms -------------------------
69
+
70
+ IMAGENET_MEAN = [0.485, 0.456, 0.406]; IMAGENET_STD = [0.229, 0.224, 0.225]
71
+ CLIP_MEAN = [0.4815, 0.4578, 0.4082]; CLIP_STD = [0.2686, 0.2613, 0.2758]
72
+
73
+ def build_transforms(image_size, use_clip_norm=False, train=True, use_clahe=True):
74
+ mean = CLIP_MEAN if use_clip_norm else IMAGENET_MEAN
75
+ std = CLIP_STD if use_clip_norm else IMAGENET_STD
76
+ pre = [CLAHEPreprocess()] if use_clahe else []
77
+ if train:
78
+ return transforms.Compose(pre + [
79
+ transforms.Resize((image_size + 32, image_size + 32)),
80
+ transforms.RandomResizedCrop(image_size, scale=(0.75, 1.0)),
81
+ transforms.RandomHorizontalFlip(),
82
+ transforms.RandomVerticalFlip(p=0.2),
83
+ transforms.RandomRotation(20),
84
+ transforms.RandAugment(num_ops=2, magnitude=9),
85
+ transforms.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.1),
86
+ transforms.ToTensor(),
87
+ transforms.Normalize(mean, std),
88
+ transforms.RandomErasing(p=0.25, scale=(0.02, 0.15)),
89
+ ])
90
+ return transforms.Compose(pre + [
91
+ transforms.Resize((image_size, image_size)),
92
+ transforms.ToTensor(),
93
+ transforms.Normalize(mean, std),
94
+ ])
95
+
96
+
97
+ # ------------------------- models -------------------------
98
+
99
+ def build_model(name, num_classes):
100
+ name = name.lower()
101
+ if name == "vgg19":
102
+ m = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1)
103
+ m.classifier[6] = nn.Linear(m.classifier[6].in_features, num_classes)
104
+ return m, 224, False
105
+ if name == "resnet50":
106
+ m = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)
107
+ m.fc = nn.Linear(m.fc.in_features, num_classes); return m, 224, False
108
+ if name == "resnet101":
109
+ m = models.resnet101(weights=models.ResNet101_Weights.IMAGENET1K_V2)
110
+ m.fc = nn.Linear(m.fc.in_features, num_classes); return m, 224, False
111
+ if name == "densenet121":
112
+ m = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1)
113
+ m.classifier = nn.Linear(m.classifier.in_features, num_classes); return m, 224, False
114
+ if name == "inception_v3":
115
+ m = models.inception_v3(weights=models.Inception_V3_Weights.IMAGENET1K_V1, aux_logits=True)
116
+ m.fc = nn.Linear(m.fc.in_features, num_classes)
117
+ m.AuxLogits.fc = nn.Linear(m.AuxLogits.fc.in_features, num_classes)
118
+ return m, 299, False
119
+ if name == "clip_openai":
120
+ import open_clip
121
+ model, _, _ = open_clip.create_model_and_transforms("ViT-B-16", pretrained="openai")
122
+ class CLIPClf(nn.Module):
123
+ def __init__(self, backbone, nc):
124
+ super().__init__(); self.backbone = backbone.visual
125
+ d = self.backbone.output_dim if hasattr(self.backbone, "output_dim") else 512
126
+ self.head = nn.Linear(d, nc)
127
+ def forward(self, x):
128
+ f = self.backbone(x); return self.head(f)
129
+ return CLIPClf(model, num_classes), 224, True
130
+ raise ValueError(name)
131
+
132
+
133
+ # ------------------------- MixUp / CutMix -------------------------
134
+
135
+ def mixup(x, y, alpha=0.2, num_classes=10):
136
+ lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0
137
+ idx = torch.randperm(x.size(0), device=x.device)
138
+ x = lam * x + (1 - lam) * x[idx]
139
+ y_oh = F.one_hot(y, num_classes).float()
140
+ y_mix = lam * y_oh + (1 - lam) * y_oh[idx]
141
+ return x, y_mix
142
+
143
+ def cutmix(x, y, alpha=1.0, num_classes=10):
144
+ lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0
145
+ idx = torch.randperm(x.size(0), device=x.device)
146
+ H, W = x.size(2), x.size(3)
147
+ cut_rat = math.sqrt(1.0 - lam)
148
+ cw, ch = int(W * cut_rat), int(H * cut_rat)
149
+ cx, cy = np.random.randint(W), np.random.randint(H)
150
+ x1 = np.clip(cx - cw // 2, 0, W); x2 = np.clip(cx + cw // 2, 0, W)
151
+ y1 = np.clip(cy - ch // 2, 0, H); y2 = np.clip(cy + ch // 2, 0, H)
152
+ x[:, :, y1:y2, x1:x2] = x[idx, :, y1:y2, x1:x2]
153
+ lam = 1 - ((x2 - x1) * (y2 - y1) / (W * H))
154
+ y_oh = F.one_hot(y, num_classes).float()
155
+ y_mix = lam * y_oh + (1 - lam) * y_oh[idx]
156
+ return x, y_mix
157
+
158
+
159
+ # ------------------------- metrics -------------------------
160
+
161
+ def expected_calibration_error(probs, labels, n_bins=15):
162
+ conf = probs.max(axis=1); pred = probs.argmax(axis=1); correct = (pred == labels).astype(float)
163
+ bins = np.linspace(0, 1, n_bins + 1); ece = 0.0
164
+ for i in range(n_bins):
165
+ mask = (conf > bins[i]) & (conf <= bins[i+1])
166
+ if mask.sum() > 0:
167
+ ece += (mask.mean()) * abs(correct[mask].mean() - conf[mask].mean())
168
+ return float(ece)
169
+
170
+ def bootstrap_ci(labels, preds, metric_fn, n=1000, seed=42):
171
+ rng = np.random.default_rng(seed); N = len(labels); vals = []
172
+ for _ in range(n):
173
+ idx = rng.integers(0, N, N)
174
+ try: vals.append(metric_fn(labels[idx], preds[idx]))
175
+ except Exception: pass
176
+ vals = np.array(vals)
177
+ return float(vals.mean()), float(np.percentile(vals, 2.5)), float(np.percentile(vals, 97.5))
178
+
179
+
180
+ # ------------------------- TTA inference -------------------------
181
+
182
+ @torch.no_grad()
183
+ def tta_predict(model, images, device):
184
+ """6 views: original + hflip + 4 corner crops of 90% size resized back."""
185
+ model.eval()
186
+ out_probs = None; n_views = 0
187
+ B, C, H, W = images.shape
188
+ crop = int(H * 0.9)
189
+ views = [images, torch.flip(images, dims=[3])]
190
+ for (y, x) in [(0, 0), (0, W - crop), (H - crop, 0), (H - crop, W - crop)]:
191
+ c = images[:, :, y:y+crop, x:x+crop]
192
+ c = F.interpolate(c, size=(H, W), mode="bilinear", align_corners=False)
193
+ views.append(c)
194
+ for v in views:
195
+ p = F.softmax(model(v.to(device)), dim=1)
196
+ out_probs = p if out_probs is None else out_probs + p
197
+ n_views += 1
198
+ return (out_probs / n_views).cpu().numpy()
199
+
200
+
201
+ @torch.no_grad()
202
+ def evaluate(model, loader, device, num_classes, use_tta=False):
203
+ model.eval(); all_probs, all_labels = [], []
204
+ for x, y in loader:
205
+ if use_tta:
206
+ p = tta_predict(model, x, device)
207
+ else:
208
+ x = x.to(device); out = model(x)
209
+ if isinstance(out, tuple): out = out[0]
210
+ p = F.softmax(out, dim=1).cpu().numpy()
211
+ all_probs.append(p); all_labels.append(y.numpy())
212
+ probs = np.concatenate(all_probs); labels = np.concatenate(all_labels)
213
+ preds = probs.argmax(axis=1)
214
+ acc = accuracy_score(labels, preds)
215
+ p, r, f1, _ = precision_recall_fscore_support(labels, preds, average="macro", zero_division=0)
216
+ try:
217
+ roc = roc_auc_score(labels, probs, multi_class="ovr", average="macro",
218
+ labels=list(range(num_classes)))
219
+ except Exception:
220
+ roc = float("nan")
221
+ try:
222
+ pr_auc = average_precision_score(
223
+ F.one_hot(torch.tensor(labels), num_classes).numpy(), probs, average="macro")
224
+ except Exception:
225
+ pr_auc = float("nan")
226
+ ece = expected_calibration_error(probs, labels)
227
+ return {
228
+ "acc": acc, "precision": p, "recall": r, "f1": f1,
229
+ "roc_auc": roc, "pr_auc": pr_auc, "ece": ece,
230
+ "labels": labels.tolist(), "preds": preds.tolist(), "probs": probs.tolist(),
231
+ }
232
+
233
+
234
+ # ------------------------- train one model -------------------------
235
+
236
+ def train_model(name, samples_train, samples_val, num_classes, device,
237
+ epochs, batch_size, workers, patience, label, use_clahe):
238
+ model, image_size, use_clip = build_model(name, num_classes)
239
+ model = model.to(device)
240
+ tf_train = build_transforms(image_size, use_clip_norm=use_clip, train=True, use_clahe=use_clahe)
241
+ tf_val = build_transforms(image_size, use_clip_norm=use_clip, train=False, use_clahe=use_clahe)
242
+ ds_train = ImageListDataset(samples_train, tf_train)
243
+ ds_val = ImageListDataset(samples_val, tf_val)
244
+
245
+ # Weighted sampler
246
+ labels_arr = np.array([s[1] for s in samples_train])
247
+ class_counts = np.bincount(labels_arr, minlength=num_classes)
248
+ class_weights = 1.0 / np.maximum(class_counts, 1)
249
+ sample_weights = class_weights[labels_arr]
250
+ sampler = WeightedRandomSampler(sample_weights.tolist(), num_samples=len(sample_weights), replacement=True)
251
+
252
+ dl_train = DataLoader(ds_train, batch_size=batch_size, sampler=sampler,
253
+ num_workers=workers, pin_memory=True, drop_last=True)
254
+ dl_val = DataLoader(ds_val, batch_size=batch_size, shuffle=False,
255
+ num_workers=workers, pin_memory=True)
256
+
257
+ opt = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=1e-4)
258
+ warmup_epochs = 3
259
+ def lr_lambda(epoch):
260
+ if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs
261
+ prog = (epoch - warmup_epochs) / max(1, epochs - warmup_epochs)
262
+ return 0.5 * (1 + math.cos(math.pi * prog))
263
+ sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda)
264
+ scaler = GradScaler()
265
+
266
+ best_f1 = -1; best_state = None; bad = 0
267
+ history = []
268
+ for ep in range(epochs):
269
+ model.train()
270
+ t0 = time.time(); n = 0; loss_sum = 0.0
271
+ for x, y in dl_train:
272
+ x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True)
273
+ r = np.random.rand()
274
+ if r < 0.4:
275
+ x_m, y_soft = mixup(x, y, alpha=0.2, num_classes=num_classes); use_soft = True
276
+ elif r < 0.7:
277
+ x_m, y_soft = cutmix(x, y, alpha=1.0, num_classes=num_classes); use_soft = True
278
+ else:
279
+ x_m, y_soft = x, y; use_soft = False
280
+ opt.zero_grad(set_to_none=True)
281
+ with autocast():
282
+ out = model(x_m)
283
+ if isinstance(out, tuple):
284
+ main_out, aux_out = out
285
+ if use_soft:
286
+ loss = -(y_soft * F.log_softmax(main_out, 1)).sum(1).mean()
287
+ loss += 0.4 * (-(y_soft * F.log_softmax(aux_out, 1)).sum(1).mean())
288
+ else:
289
+ loss = F.cross_entropy(main_out, y_soft) + 0.4 * F.cross_entropy(aux_out, y_soft)
290
+ else:
291
+ if use_soft:
292
+ loss = -(y_soft * F.log_softmax(out, 1)).sum(1).mean()
293
+ else:
294
+ loss = F.cross_entropy(out, y_soft)
295
+ scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
296
+ loss_sum += loss.item() * x.size(0); n += x.size(0)
297
+ sched.step()
298
+ val = evaluate(model, dl_val, device, num_classes, use_tta=False)
299
+ dt = time.time() - t0
300
+ history.append({"epoch": ep, "loss": loss_sum/n, "val_acc": val["acc"], "val_f1": val["f1"], "dt": dt})
301
+ print(f"[{label}] ep {ep+1:3d}/{epochs} loss {loss_sum/n:.4f} val_acc {val['acc']*100:5.2f} val_f1 {val['f1']*100:5.2f} ({dt:.0f}s)", flush=True)
302
+ if val["f1"] > best_f1 + 1e-4:
303
+ best_f1 = val["f1"]; best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}; bad = 0
304
+ else:
305
+ bad += 1
306
+ if bad >= patience:
307
+ print(f"[{label}] early stop at epoch {ep+1}", flush=True); break
308
+ if best_state is not None:
309
+ model.load_state_dict(best_state)
310
+ return model, history, best_f1
311
+
312
+
313
+ # ------------------------- main -------------------------
314
+
315
+ def main():
316
+ ap = argparse.ArgumentParser()
317
+ ap.add_argument("--manifest", required=True)
318
+ ap.add_argument("--out-dir", required=True)
319
+ ap.add_argument("--weights-dir", required=True)
320
+ ap.add_argument("--models", nargs="+",
321
+ default=["vgg19", "resnet50", "resnet101", "densenet121", "inception_v3", "clip_openai"])
322
+ ap.add_argument("--epochs", type=int, default=100)
323
+ ap.add_argument("--folds", type=int, default=5)
324
+ ap.add_argument("--batch-size", type=int, default=32)
325
+ ap.add_argument("--workers", type=int, default=4)
326
+ ap.add_argument("--patience", type=int, default=12)
327
+ ap.add_argument("--use-clahe", action="store_true", default=True)
328
+ ap.add_argument("--skip-cv", action="store_true",
329
+ help="Only do final-train + indep test (skip k-fold CV)")
330
+ ap.add_argument("--seed", type=int, default=42)
331
+ args = ap.parse_args()
332
+
333
+ set_seed(args.seed)
334
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
335
+ print(f"device: {device}")
336
+ out_dir = Path(args.out_dir); out_dir.mkdir(parents=True, exist_ok=True)
337
+ w_dir = Path(args.weights_dir); w_dir.mkdir(parents=True, exist_ok=True)
338
+
339
+ M = json.load(open(args.manifest))
340
+ classes = M["classes"]; num_classes = len(classes)
341
+ print(f"classes ({num_classes}): {classes}")
342
+ samples_train = [tuple(x) for x in M["splits"]["train"]]
343
+ samples_val = [tuple(x) for x in M["splits"]["val"]]
344
+ samples_test = [tuple(x) for x in M["splits"]["test"]]
345
+ print(f"train {len(samples_train)} | val {len(samples_val)} | test {len(samples_test)}")
346
+
347
+ pool_paths = M["pool_paths"]; pool_labels = M["pool_labels"]
348
+ folds = M["folds"]
349
+
350
+ summary = {}
351
+ test_preds_all = {}
352
+ for name in args.models:
353
+ print(f"\n======================== {name} ========================")
354
+ per_fold = []
355
+ if not args.skip_cv:
356
+ cv_epochs = max(20, args.epochs // 2) # CV uses half-budget; final uses full
357
+ for fi, fold in enumerate(folds[:args.folds]):
358
+ tr = [(pool_paths[i], pool_labels[i]) for i in fold["train_idx"]]
359
+ va = [(pool_paths[i], pool_labels[i]) for i in fold["val_idx"]]
360
+ print(f"\n--- fold {fi+1}/{args.folds} train {len(tr)} val {len(va)} ---")
361
+ fmodel, hist, best_f1 = train_model(
362
+ name, tr, va, num_classes, device,
363
+ cv_epochs, args.batch_size, args.workers, args.patience,
364
+ label=f"{name}-f{fi+1}", use_clahe=args.use_clahe)
365
+ # Eval (no TTA) for fold metrics
366
+ _, image_size_f, use_clip_f = build_model(name, num_classes)
367
+ tf_vf = build_transforms(image_size_f, use_clip_norm=use_clip_f, train=False, use_clahe=args.use_clahe)
368
+ dl_vf = DataLoader(ImageListDataset(va, tf_vf),
369
+ batch_size=args.batch_size, shuffle=False,
370
+ num_workers=args.workers, pin_memory=True)
371
+ fres = evaluate(fmodel, dl_vf, device, num_classes, use_tta=False)
372
+ per_fold.append({
373
+ "fold": fi, "best_val_f1": best_f1,
374
+ "val_acc": fres["acc"], "val_f1": fres["f1"],
375
+ "val_roc_auc": fres["roc_auc"], "val_ece": fres["ece"],
376
+ "history": hist,
377
+ })
378
+ del fmodel; torch.cuda.empty_cache()
379
+
380
+ # Final train: combine train+val for stronger final model, evaluate on test
381
+ print(f"\n--- {name} FINAL train on train+val ({len(samples_train)+len(samples_val)} samples) ---")
382
+ final_model, hist, _ = train_model(
383
+ name, samples_train + samples_val, samples_val, num_classes, device,
384
+ args.epochs, args.batch_size, args.workers, args.patience,
385
+ label=f"{name}-final", use_clahe=args.use_clahe)
386
+ # Test eval with TTA
387
+ _, image_size, use_clip = build_model(name, num_classes)
388
+ tf_test = build_transforms(image_size, use_clip_norm=use_clip, train=False, use_clahe=args.use_clahe)
389
+ dl_test = DataLoader(ImageListDataset(samples_test, tf_test),
390
+ batch_size=args.batch_size, shuffle=False, num_workers=args.workers, pin_memory=True)
391
+ print(f"[{name}] evaluating on test with TTA ...")
392
+ test_res = evaluate(final_model, dl_test, device, num_classes, use_tta=True)
393
+ labels = np.array(test_res["labels"]); preds = np.array(test_res["preds"])
394
+ acc_mean, acc_lo, acc_hi = bootstrap_ci(labels, preds, accuracy_score)
395
+ f1_mean, f1_lo, f1_hi = bootstrap_ci(labels, preds,
396
+ lambda l, p: precision_recall_fscore_support(l, p, average="macro", zero_division=0)[2])
397
+ test_res["acc_ci"] = [acc_lo, acc_hi]; test_res["f1_ci"] = [f1_lo, f1_hi]
398
+ summary[name] = {
399
+ "test_acc": test_res["acc"], "test_acc_ci": test_res["acc_ci"],
400
+ "test_f1": test_res["f1"], "test_f1_ci": test_res["f1_ci"],
401
+ "test_precision": test_res["precision"], "test_recall": test_res["recall"],
402
+ "roc_auc": test_res["roc_auc"], "pr_auc": test_res["pr_auc"], "ece": test_res["ece"],
403
+ "n_folds_run": len(per_fold),
404
+ }
405
+ with open(out_dir / f"{name}_test.json", "w") as f: json.dump(summary[name], f, indent=2)
406
+ with open(out_dir / f"{name}_test_preds.json", "w") as f:
407
+ json.dump({"labels": test_res["labels"], "preds": test_res["preds"], "probs": test_res["probs"]}, f)
408
+ if per_fold:
409
+ with open(out_dir / f"{name}_kfold.json", "w") as f: json.dump(per_fold, f, indent=2)
410
+ torch.save(final_model.state_dict(), w_dir / f"{name}_v2_final.pth")
411
+ test_preds_all[name] = test_res
412
+
413
+ print(f"[{name}] test acc {test_res['acc']*100:.2f} f1 {test_res['f1']*100:.2f} roc {test_res['roc_auc']:.4f} ece {test_res['ece']:.4f}")
414
+
415
+ # McNemar
416
+ print("\n=== McNemar pairwise ===")
417
+ mcnemar = {}
418
+ keys = list(test_preds_all.keys())
419
+ labels = np.array(test_preds_all[keys[0]]["labels"])
420
+ for i in range(len(keys)):
421
+ for j in range(i+1, len(keys)):
422
+ p1 = np.array(test_preds_all[keys[i]]["preds"]); p2 = np.array(test_preds_all[keys[j]]["preds"])
423
+ c1 = p1 == labels; c2 = p2 == labels
424
+ b = int(((c1) & (~c2)).sum()); c = int(((~c1) & (c2)).sum())
425
+ n = b + c
426
+ if n == 0: pval = 1.0
427
+ else:
428
+ k = min(b, c); pval = float(2 * binom.cdf(k, n, 0.5))
429
+ if pval > 1: pval = 1.0
430
+ mcnemar[f"{keys[i]}_vs_{keys[j]}"] = {"b": b, "c": c, "p": pval}
431
+ print(f" {keys[i]} vs {keys[j]}: b={b} c={c} p={pval:.4g}")
432
+ with open(out_dir / "mcnemar.json", "w") as f: json.dump(mcnemar, f, indent=2)
433
+ with open(out_dir / "summary.json", "w") as f: json.dump(summary, f, indent=2)
434
+ print("\nDone.")
435
+
436
+
437
+ if __name__ == "__main__":
438
+ main()
results/clip_openai_test.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8625311720698254,
3
+ "test_acc_ci": [
4
+ 0.8509897132169576,
5
+ 0.8740726309226933
6
+ ],
7
+ "test_f1": 0.8999338780904511,
8
+ "test_f1_ci": [
9
+ 0.8908154444260842,
10
+ 0.9088493548396316
11
+ ],
12
+ "test_precision": 0.8887659033860622,
13
+ "test_recall": 0.915128373891258,
14
+ "roc_auc": 0.9895801702791353,
15
+ "pr_auc": 0.9483871820462942,
16
+ "ece": 0.017864541510617046,
17
+ "n_folds_run": 0
18
+ }
results/clip_openai_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/densenet121_test.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8977556109725686,
3
+ "test_acc_ci": [
4
+ 0.8871571072319202,
5
+ 0.9080501870324189
6
+ ],
7
+ "test_f1": 0.9225920013528925,
8
+ "test_f1_ci": [
9
+ 0.913377240794446,
10
+ 0.9313115657332115
11
+ ],
12
+ "test_precision": 0.9139639568399703,
13
+ "test_recall": 0.9322069613114635,
14
+ "roc_auc": 0.993099023325742,
15
+ "pr_auc": 0.9648647645485235,
16
+ "ece": 0.023892512363834564,
17
+ "n_folds_run": 0
18
+ }
results/densenet121_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/dinov2_l_test.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8949501246882793,
3
+ "test_acc_ci": [
4
+ 0.8849672693266833,
5
+ 0.9052369077306733
6
+ ],
7
+ "test_f1": 0.9214970232274702,
8
+ "test_f1_ci": [
9
+ 0.9127686058957977,
10
+ 0.9293729231634663
11
+ ],
12
+ "test_precision": 0.9150088741935475,
13
+ "test_recall": 0.9292672404244741,
14
+ "roc_auc": 0.9937714203718716,
15
+ "pr_auc": 0.9677433875951829,
16
+ "ece": 0.03374567211714764
17
+ }
results/dinov2_l_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/ensemble_report.json ADDED
@@ -0,0 +1,712 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "per_model": {
3
+ "clip_openai": {
4
+ "acc": 0.8625311720698254,
5
+ "acc_ci": [
6
+ 0.8509897132169576,
7
+ 0.8740648379052369
8
+ ],
9
+ "precision": 0.8887659033860622,
10
+ "recall": 0.915128373891258,
11
+ "f1": 0.8999338780904511,
12
+ "roc_auc": 0.9895801702791353,
13
+ "ece": 0.017864545904787428,
14
+ "kappa": 0.8382180482966803,
15
+ "brier": 0.19501780460857385,
16
+ "per_class_f1": [
17
+ 0.9203539823008849,
18
+ 0.954829742876998,
19
+ 0.9513888888888888,
20
+ 0.7137614678899082,
21
+ 0.8296943231441049,
22
+ 0.8442503639010189,
23
+ 0.8310010764262648,
24
+ 1.0,
25
+ 0.9883268482490273,
26
+ 0.9657320872274143
27
+ ],
28
+ "per_class_support": [
29
+ 108,
30
+ 733,
31
+ 137,
32
+ 609,
33
+ 548,
34
+ 334,
35
+ 440,
36
+ 17,
37
+ 127,
38
+ 155
39
+ ]
40
+ },
41
+ "densenet121": {
42
+ "acc": 0.8977556109725686,
43
+ "acc_ci": [
44
+ 0.8871493142144639,
45
+ 0.9080423940149626
46
+ ],
47
+ "precision": 0.9139639568399703,
48
+ "recall": 0.9322069613114635,
49
+ "f1": 0.9225920013528925,
50
+ "roc_auc": 0.993099023325742,
51
+ "ece": 0.023892554243157935,
52
+ "kappa": 0.879440226769113,
53
+ "brier": 0.14828401684717682,
54
+ "per_class_f1": [
55
+ 0.9140271493212669,
56
+ 0.9715475364330326,
57
+ 0.9513888888888888,
58
+ 0.8158546655656482,
59
+ 0.8616236162361623,
60
+ 0.8833819241982507,
61
+ 0.8662857142857143,
62
+ 1.0,
63
+ 0.996078431372549,
64
+ 0.9657320872274143
65
+ ],
66
+ "per_class_support": [
67
+ 108,
68
+ 733,
69
+ 137,
70
+ 609,
71
+ 548,
72
+ 334,
73
+ 440,
74
+ 17,
75
+ 127,
76
+ 155
77
+ ]
78
+ },
79
+ "dinov2_l": {
80
+ "acc": 0.8949501246882793,
81
+ "acc_ci": [
82
+ 0.8846633416458853,
83
+ 0.9055486284289277
84
+ ],
85
+ "precision": 0.9150088741935475,
86
+ "recall": 0.9292672404244741,
87
+ "f1": 0.9214970232274702,
88
+ "roc_auc": 0.9937714203718716,
89
+ "ece": 0.03374566446218703,
90
+ "kappa": 0.8760062131313244,
91
+ "brier": 0.15526801776067914,
92
+ "per_class_f1": [
93
+ 0.918918918918919,
94
+ 0.9724896836313618,
95
+ 0.9645390070921985,
96
+ 0.8089700996677741,
97
+ 0.8642413487133984,
98
+ 0.8885630498533724,
99
+ 0.8393285371702638,
100
+ 1.0,
101
+ 0.9921875,
102
+ 0.9657320872274143
103
+ ],
104
+ "per_class_support": [
105
+ 108,
106
+ 733,
107
+ 137,
108
+ 609,
109
+ 548,
110
+ 334,
111
+ 440,
112
+ 17,
113
+ 127,
114
+ 155
115
+ ]
116
+ },
117
+ "inception_v3": {
118
+ "acc": 0.89214463840399,
119
+ "acc_ci": [
120
+ 0.8815461346633416,
121
+ 0.9027509351620947
122
+ ],
123
+ "precision": 0.9114333957779662,
124
+ "recall": 0.9291012889416669,
125
+ "f1": 0.9197113157379577,
126
+ "roc_auc": 0.9933846253549662,
127
+ "ece": 0.03020487911534726,
128
+ "kappa": 0.8727706715793122,
129
+ "brier": 0.15683023121332312,
130
+ "per_class_f1": [
131
+ 0.918918918918919,
132
+ 0.9689011748445059,
133
+ 0.9513888888888888,
134
+ 0.8045789043336059,
135
+ 0.8534562211981567,
136
+ 0.8875912408759125,
137
+ 0.8504672897196262,
138
+ 1.0,
139
+ 0.996078431372549,
140
+ 0.9657320872274143
141
+ ],
142
+ "per_class_support": [
143
+ 108,
144
+ 733,
145
+ 137,
146
+ 609,
147
+ 548,
148
+ 334,
149
+ 440,
150
+ 17,
151
+ 127,
152
+ 155
153
+ ]
154
+ },
155
+ "resnet101": {
156
+ "acc": 0.8924563591022444,
157
+ "acc_ci": [
158
+ 0.881857855361596,
159
+ 0.9033665835411472
160
+ ],
161
+ "precision": 0.9128874987068798,
162
+ "recall": 0.9290979896919567,
163
+ "f1": 0.9205495906178301,
164
+ "roc_auc": 0.9940599331335397,
165
+ "ece": 0.029744671987476563,
166
+ "kappa": 0.873135739806533,
167
+ "brier": 0.1492682365955807,
168
+ "per_class_f1": [
169
+ 0.918918918918919,
170
+ 0.9669421487603306,
171
+ 0.9611307420494699,
172
+ 0.8013411567476949,
173
+ 0.8537906137184116,
174
+ 0.8820960698689956,
175
+ 0.859465737514518,
176
+ 1.0,
177
+ 0.996078431372549,
178
+ 0.9657320872274143
179
+ ],
180
+ "per_class_support": [
181
+ 108,
182
+ 733,
183
+ 137,
184
+ 609,
185
+ 548,
186
+ 334,
187
+ 440,
188
+ 17,
189
+ 127,
190
+ 155
191
+ ]
192
+ },
193
+ "resnet50": {
194
+ "acc": 0.8908977556109726,
195
+ "acc_ci": [
196
+ 0.8799797381546135,
197
+ 0.9011845386533666
198
+ ],
199
+ "precision": 0.9110545481169569,
200
+ "recall": 0.9273515185427756,
201
+ "f1": 0.9186505198171678,
202
+ "roc_auc": 0.994354279320403,
203
+ "ece": 0.03958680766850639,
204
+ "kappa": 0.8712729134615307,
205
+ "brier": 0.14710658877842553,
206
+ "per_class_f1": [
207
+ 0.918918918918919,
208
+ 0.9674740484429065,
209
+ 0.9477351916376306,
210
+ 0.7986852917009039,
211
+ 0.8574007220216606,
212
+ 0.8807069219440353,
213
+ 0.8537735849056604,
214
+ 1.0,
215
+ 0.996078431372549,
216
+ 0.9657320872274143
217
+ ],
218
+ "per_class_support": [
219
+ 108,
220
+ 733,
221
+ 137,
222
+ 609,
223
+ 548,
224
+ 334,
225
+ 440,
226
+ 17,
227
+ 127,
228
+ 155
229
+ ]
230
+ },
231
+ "retfound": {
232
+ "acc": 0.8388403990024937,
233
+ "acc_ci": [
234
+ 0.8263715710723192,
235
+ 0.850997506234414
236
+ ],
237
+ "precision": 0.8639273665304865,
238
+ "recall": 0.8924390485821714,
239
+ "f1": 0.876842281045592,
240
+ "roc_auc": 0.9837617829682529,
241
+ "ece": 0.025993358808339723,
242
+ "kappa": 0.8102097500064082,
243
+ "brier": 0.23833885264650462,
244
+ "per_class_f1": [
245
+ 0.8595744680851064,
246
+ 0.9548924358084664,
247
+ 0.9134948096885813,
248
+ 0.6984667802385008,
249
+ 0.7932960893854749,
250
+ 0.8096676737160121,
251
+ 0.7918015102481122,
252
+ 1.0,
253
+ 0.9844961240310077,
254
+ 0.9627329192546584
255
+ ],
256
+ "per_class_support": [
257
+ 108,
258
+ 733,
259
+ 137,
260
+ 609,
261
+ 548,
262
+ 334,
263
+ 440,
264
+ 17,
265
+ 127,
266
+ 155
267
+ ]
268
+ },
269
+ "swin_b": {
270
+ "acc": 0.8684538653366584,
271
+ "acc_ci": [
272
+ 0.8569201995012469,
273
+ 0.8802992518703242
274
+ ],
275
+ "precision": 0.8957798154200936,
276
+ "recall": 0.914482667230412,
277
+ "f1": 0.9044410963586561,
278
+ "roc_auc": 0.9903580625502204,
279
+ "ece": 0.01902209332867754,
280
+ "kappa": 0.8447801854461359,
281
+ "brier": 0.18504628154638172,
282
+ "per_class_f1": [
283
+ 0.9074889867841409,
284
+ 0.9667590027700831,
285
+ 0.9513888888888888,
286
+ 0.7436527436527437,
287
+ 0.8237410071942446,
288
+ 0.8831562974203339,
289
+ 0.810304449648712,
290
+ 1.0,
291
+ 0.9921875,
292
+ 0.9657320872274143
293
+ ],
294
+ "per_class_support": [
295
+ 108,
296
+ 733,
297
+ 137,
298
+ 609,
299
+ 548,
300
+ 334,
301
+ 440,
302
+ 17,
303
+ 127,
304
+ 155
305
+ ]
306
+ },
307
+ "vgg19": {
308
+ "acc": 0.8930798004987531,
309
+ "acc_ci": [
310
+ 0.8821695760598504,
311
+ 0.9039900249376559
312
+ ],
313
+ "precision": 0.9110860665322441,
314
+ "recall": 0.9326584479061776,
315
+ "f1": 0.9211920362611569,
316
+ "roc_auc": 0.9929719540404655,
317
+ "ece": 0.020435141524917783,
318
+ "kappa": 0.8739754832960913,
319
+ "brier": 0.1543770380321469,
320
+ "per_class_f1": [
321
+ 0.9292035398230089,
322
+ 0.9707927677329624,
323
+ 0.9513888888888888,
324
+ 0.7966101694915254,
325
+ 0.8484304932735426,
326
+ 0.8816568047337278,
327
+ 0.8720271800679502,
328
+ 1.0,
329
+ 0.996078431372549,
330
+ 0.9657320872274143
331
+ ],
332
+ "per_class_support": [
333
+ 108,
334
+ 733,
335
+ 137,
336
+ 609,
337
+ 548,
338
+ 334,
339
+ 440,
340
+ 17,
341
+ 127,
342
+ 155
343
+ ]
344
+ }
345
+ },
346
+ "ensemble": {
347
+ "members": [
348
+ "clip_openai",
349
+ "densenet121",
350
+ "dinov2_l",
351
+ "inception_v3",
352
+ "resnet101",
353
+ "resnet50",
354
+ "retfound",
355
+ "swin_b",
356
+ "vgg19"
357
+ ],
358
+ "weights": {
359
+ "clip_openai": 0.8999338780904511,
360
+ "densenet121": 0.9225920013528925,
361
+ "dinov2_l": 0.9214970232274702,
362
+ "inception_v3": 0.9197113157379577,
363
+ "resnet101": 0.9205495906178301,
364
+ "resnet50": 0.9186505198171678,
365
+ "retfound": 0.876842281045592,
366
+ "swin_b": 0.9044410963586561,
367
+ "vgg19": 0.9211920362611569
368
+ },
369
+ "acc": 0.8968204488778054,
370
+ "acc_ci": [
371
+ 0.8865336658354115,
372
+ 0.9074189526184538
373
+ ],
374
+ "precision": 0.9133809044175024,
375
+ "recall": 0.9327946516638915,
376
+ "f1": 0.9225313471540415,
377
+ "roc_auc": 0.9941017286357303,
378
+ "ece": 0.01975079615286455,
379
+ "kappa": 0.8783721202739745,
380
+ "brier": 0.1438439679380553
381
+ },
382
+ "mcnemar_bonferroni": {
383
+ "n_pairs": 36,
384
+ "pairs": [
385
+ {
386
+ "model_a": "clip_openai",
387
+ "model_b": "densenet121",
388
+ "p": 1.5003245325684048e-11,
389
+ "b_count": 85,
390
+ "c_count": 198,
391
+ "p_bonferroni": 5.401168317246257e-10,
392
+ "sig_005": true
393
+ },
394
+ {
395
+ "model_a": "clip_openai",
396
+ "model_b": "dinov2_l",
397
+ "p": 5.035530682013286e-09,
398
+ "b_count": 106,
399
+ "c_count": 210,
400
+ "p_bonferroni": 1.8127910455247827e-07,
401
+ "sig_005": true
402
+ },
403
+ {
404
+ "model_a": "clip_openai",
405
+ "model_b": "inception_v3",
406
+ "p": 9.529243599048096e-08,
407
+ "b_count": 110,
408
+ "c_count": 205,
409
+ "p_bonferroni": 3.4305276956573147e-06,
410
+ "sig_005": true
411
+ },
412
+ {
413
+ "model_a": "clip_openai",
414
+ "model_b": "resnet101",
415
+ "p": 3.550882726150477e-08,
416
+ "b_count": 103,
417
+ "c_count": 199,
418
+ "p_bonferroni": 1.2783177814141717e-06,
419
+ "sig_005": true
420
+ },
421
+ {
422
+ "model_a": "clip_openai",
423
+ "model_b": "resnet50",
424
+ "p": 2.7655295866239894e-07,
425
+ "b_count": 110,
426
+ "c_count": 201,
427
+ "p_bonferroni": 9.955906511846362e-06,
428
+ "sig_005": true
429
+ },
430
+ {
431
+ "model_a": "clip_openai",
432
+ "model_b": "retfound",
433
+ "p": 9.51038696830371e-06,
434
+ "b_count": 183,
435
+ "c_count": 107,
436
+ "p_bonferroni": 0.00034237393085893354,
437
+ "sig_005": true
438
+ },
439
+ {
440
+ "model_a": "clip_openai",
441
+ "model_b": "swin_b",
442
+ "p": 0.27240218112115094,
443
+ "b_count": 125,
444
+ "c_count": 144,
445
+ "p_bonferroni": 1.0,
446
+ "sig_005": false
447
+ },
448
+ {
449
+ "model_a": "clip_openai",
450
+ "model_b": "vgg19",
451
+ "p": 3.6388149492186776e-09,
452
+ "b_count": 89,
453
+ "c_count": 187,
454
+ "p_bonferroni": 1.3099733817187238e-07,
455
+ "sig_005": true
456
+ },
457
+ {
458
+ "model_a": "densenet121",
459
+ "model_b": "dinov2_l",
460
+ "p": 0.5095015886349893,
461
+ "b_count": 78,
462
+ "c_count": 69,
463
+ "p_bonferroni": 1.0,
464
+ "sig_005": false
465
+ },
466
+ {
467
+ "model_a": "densenet121",
468
+ "model_b": "inception_v3",
469
+ "p": 0.12342708698379867,
470
+ "b_count": 70,
471
+ "c_count": 52,
472
+ "p_bonferroni": 1.0,
473
+ "sig_005": false
474
+ },
475
+ {
476
+ "model_a": "densenet121",
477
+ "model_b": "resnet101",
478
+ "p": 0.11799998150585847,
479
+ "b_count": 61,
480
+ "c_count": 44,
481
+ "p_bonferroni": 1.0,
482
+ "sig_005": false
483
+ },
484
+ {
485
+ "model_a": "densenet121",
486
+ "model_b": "resnet50",
487
+ "p": 0.04087197060775479,
488
+ "b_count": 64,
489
+ "c_count": 42,
490
+ "p_bonferroni": 1.0,
491
+ "sig_005": false
492
+ },
493
+ {
494
+ "model_a": "densenet121",
495
+ "model_b": "retfound",
496
+ "p": 6.225832846491392e-22,
497
+ "b_count": 293,
498
+ "c_count": 104,
499
+ "p_bonferroni": 2.2412998247369012e-20,
500
+ "sig_005": true
501
+ },
502
+ {
503
+ "model_a": "densenet121",
504
+ "model_b": "swin_b",
505
+ "p": 3.0961350194143013e-09,
506
+ "b_count": 173,
507
+ "c_count": 79,
508
+ "p_bonferroni": 1.1146086069891485e-07,
509
+ "sig_005": true
510
+ },
511
+ {
512
+ "model_a": "densenet121",
513
+ "model_b": "vgg19",
514
+ "p": 0.25133111748209397,
515
+ "b_count": 82,
516
+ "c_count": 67,
517
+ "p_bonferroni": 1.0,
518
+ "sig_005": false
519
+ },
520
+ {
521
+ "model_a": "dinov2_l",
522
+ "model_b": "inception_v3",
523
+ "p": 0.5259245063255041,
524
+ "b_count": 84,
525
+ "c_count": 75,
526
+ "p_bonferroni": 1.0,
527
+ "sig_005": false
528
+ },
529
+ {
530
+ "model_a": "dinov2_l",
531
+ "model_b": "resnet101",
532
+ "p": 0.5753154836552097,
533
+ "b_count": 82,
534
+ "c_count": 74,
535
+ "p_bonferroni": 1.0,
536
+ "sig_005": false
537
+ },
538
+ {
539
+ "model_a": "dinov2_l",
540
+ "model_b": "resnet50",
541
+ "p": 0.3412944918305007,
542
+ "b_count": 86,
543
+ "c_count": 73,
544
+ "p_bonferroni": 1.0,
545
+ "sig_005": false
546
+ },
547
+ {
548
+ "model_a": "dinov2_l",
549
+ "model_b": "retfound",
550
+ "p": 1.0373592271569002e-19,
551
+ "b_count": 291,
552
+ "c_count": 111,
553
+ "p_bonferroni": 3.73449321776484e-18,
554
+ "sig_005": true
555
+ },
556
+ {
557
+ "model_a": "dinov2_l",
558
+ "model_b": "swin_b",
559
+ "p": 4.0479796025489893e-07,
560
+ "b_count": 182,
561
+ "c_count": 97,
562
+ "p_bonferroni": 1.4572726569176362e-05,
563
+ "sig_005": true
564
+ },
565
+ {
566
+ "model_a": "dinov2_l",
567
+ "model_b": "vgg19",
568
+ "p": 0.7197120938004633,
569
+ "b_count": 100,
570
+ "c_count": 94,
571
+ "p_bonferroni": 1.0,
572
+ "sig_005": false
573
+ },
574
+ {
575
+ "model_a": "inception_v3",
576
+ "model_b": "resnet101",
577
+ "p": 1.0,
578
+ "b_count": 70,
579
+ "c_count": 71,
580
+ "p_bonferroni": 1.0,
581
+ "sig_005": false
582
+ },
583
+ {
584
+ "model_a": "inception_v3",
585
+ "model_b": "resnet50",
586
+ "p": 0.8078463096035289,
587
+ "b_count": 78,
588
+ "c_count": 74,
589
+ "p_bonferroni": 1.0,
590
+ "sig_005": false
591
+ },
592
+ {
593
+ "model_a": "inception_v3",
594
+ "model_b": "retfound",
595
+ "p": 1.420818214959577e-17,
596
+ "b_count": 290,
597
+ "c_count": 119,
598
+ "p_bonferroni": 5.114945573854478e-16,
599
+ "sig_005": true
600
+ },
601
+ {
602
+ "model_a": "inception_v3",
603
+ "model_b": "swin_b",
604
+ "p": 7.060589035894834e-06,
605
+ "b_count": 179,
606
+ "c_count": 103,
607
+ "p_bonferroni": 0.000254181205292214,
608
+ "sig_005": true
609
+ },
610
+ {
611
+ "model_a": "inception_v3",
612
+ "model_b": "vgg19",
613
+ "p": 0.8873030419814874,
614
+ "b_count": 98,
615
+ "c_count": 101,
616
+ "p_bonferroni": 1.0,
617
+ "sig_005": false
618
+ },
619
+ {
620
+ "model_a": "resnet101",
621
+ "model_b": "resnet50",
622
+ "p": 0.7117032769279406,
623
+ "b_count": 61,
624
+ "c_count": 56,
625
+ "p_bonferroni": 1.0,
626
+ "sig_005": false
627
+ },
628
+ {
629
+ "model_a": "resnet101",
630
+ "model_b": "retfound",
631
+ "p": 9.986206200690846e-18,
632
+ "b_count": 291,
633
+ "c_count": 119,
634
+ "p_bonferroni": 3.595034232248705e-16,
635
+ "sig_005": true
636
+ },
637
+ {
638
+ "model_a": "resnet101",
639
+ "model_b": "swin_b",
640
+ "p": 3.100110685161997e-06,
641
+ "b_count": 173,
642
+ "c_count": 96,
643
+ "p_bonferroni": 0.0001116039846658319,
644
+ "sig_005": true
645
+ },
646
+ {
647
+ "model_a": "resnet101",
648
+ "model_b": "vgg19",
649
+ "p": 0.9392502353738341,
650
+ "b_count": 85,
651
+ "c_count": 87,
652
+ "p_bonferroni": 1.0,
653
+ "sig_005": false
654
+ },
655
+ {
656
+ "model_a": "resnet50",
657
+ "model_b": "retfound",
658
+ "p": 7.657771276060926e-16,
659
+ "b_count": 301,
660
+ "c_count": 134,
661
+ "p_bonferroni": 2.7567976593819336e-14,
662
+ "sig_005": true
663
+ },
664
+ {
665
+ "model_a": "resnet50",
666
+ "model_b": "swin_b",
667
+ "p": 1.6139512859660104e-05,
668
+ "b_count": 173,
669
+ "c_count": 101,
670
+ "p_bonferroni": 0.0005810224629477637,
671
+ "sig_005": true
672
+ },
673
+ {
674
+ "model_a": "resnet50",
675
+ "model_b": "vgg19",
676
+ "p": 0.6642959893958242,
677
+ "b_count": 92,
678
+ "c_count": 99,
679
+ "p_bonferroni": 1.0,
680
+ "sig_005": false
681
+ },
682
+ {
683
+ "model_a": "retfound",
684
+ "model_b": "swin_b",
685
+ "p": 4.70320206782538e-08,
686
+ "b_count": 103,
687
+ "c_count": 198,
688
+ "p_bonferroni": 1.6931527444171367e-06,
689
+ "sig_005": true
690
+ },
691
+ {
692
+ "model_a": "retfound",
693
+ "model_b": "vgg19",
694
+ "p": 4.728007170016341e-20,
695
+ "b_count": 98,
696
+ "c_count": 272,
697
+ "p_bonferroni": 1.7020825812058828e-18,
698
+ "sig_005": true
699
+ },
700
+ {
701
+ "model_a": "swin_b",
702
+ "model_b": "vgg19",
703
+ "p": 1.5007108473870475e-07,
704
+ "b_count": 73,
705
+ "c_count": 152,
706
+ "p_bonferroni": 5.402559050593371e-06,
707
+ "sig_005": true
708
+ }
709
+ ]
710
+ },
711
+ "conformal": {}
712
+ }
results/inception_v3_test.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.89214463840399,
3
+ "test_acc_ci": [
4
+ 0.8815461346633416,
5
+ 0.9024392144638403
6
+ ],
7
+ "test_f1": 0.9197113157379577,
8
+ "test_f1_ci": [
9
+ 0.910484669507304,
10
+ 0.9278346661886765
11
+ ],
12
+ "test_precision": 0.9114333957779662,
13
+ "test_recall": 0.9291012889416669,
14
+ "roc_auc": 0.9933846253549662,
15
+ "pr_auc": 0.9656573588323442,
16
+ "ece": 0.030204893970058433,
17
+ "n_folds_run": 0
18
+ }
results/inception_v3_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/resnet101_test.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8924563591022444,
3
+ "test_acc_ci": [
4
+ 0.8824812967581047,
5
+ 0.9033665835411472
6
+ ],
7
+ "test_f1": 0.9205495906178301,
8
+ "test_f1_ci": [
9
+ 0.9117043375511852,
10
+ 0.9287442691770825
11
+ ],
12
+ "test_precision": 0.9128874987068798,
13
+ "test_recall": 0.9290979896919567,
14
+ "roc_auc": 0.9940599331335397,
15
+ "pr_auc": 0.968684515330924,
16
+ "ece": 0.029744694636498346,
17
+ "n_folds_run": 0
18
+ }
results/resnet101_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/resnet50_test.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8908977556109726,
3
+ "test_acc_ci": [
4
+ 0.8806031795511222,
5
+ 0.9011923316708229
6
+ ],
7
+ "test_f1": 0.9186505198171678,
8
+ "test_f1_ci": [
9
+ 0.9093466266796268,
10
+ 0.9268383091354964
11
+ ],
12
+ "test_precision": 0.9110545481169569,
13
+ "test_recall": 0.9273515185427756,
14
+ "roc_auc": 0.994354279320403,
15
+ "pr_auc": 0.969585114477252,
16
+ "ece": 0.03958680512304616,
17
+ "n_folds_run": 0
18
+ }
results/resnet50_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/retfound_test.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8388403990024937,
3
+ "test_acc_ci": [
4
+ 0.8273067331670823,
5
+ 0.8513092269326683
6
+ ],
7
+ "test_f1": 0.876842281045592,
8
+ "test_f1_ci": [
9
+ 0.866621099788389,
10
+ 0.8870731668568106
11
+ ],
12
+ "test_precision": 0.8639273665304865,
13
+ "test_recall": 0.8924390485821714,
14
+ "roc_auc": 0.9837617829682529,
15
+ "pr_auc": 0.9171300421652508,
16
+ "ece": 0.025993344891918596
17
+ }
results/retfound_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/swin_b_test.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8684538653366584,
3
+ "test_acc_ci": [
4
+ 0.8572241271820449,
5
+ 0.8799953241895262
6
+ ],
7
+ "test_f1": 0.9044410963586561,
8
+ "test_f1_ci": [
9
+ 0.8955916929937549,
10
+ 0.9134957892904032
11
+ ],
12
+ "test_precision": 0.8957798154200936,
13
+ "test_recall": 0.914482667230412,
14
+ "roc_auc": 0.9903580625502204,
15
+ "pr_auc": 0.9498137857771093,
16
+ "ece": 0.0190220843824068
17
+ }
results/swin_b_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
results/vgg19_test.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_acc": 0.8930798004987531,
3
+ "test_acc_ci": [
4
+ 0.8827930174563591,
5
+ 0.9039900249376559
6
+ ],
7
+ "test_f1": 0.9211920362611569,
8
+ "test_f1_ci": [
9
+ 0.9127978411193057,
10
+ 0.9292878569132684
11
+ ],
12
+ "test_precision": 0.9110860665322441,
13
+ "test_recall": 0.9326584479061776,
14
+ "roc_auc": 0.9929719540404655,
15
+ "pr_auc": 0.9614448318930064,
16
+ "ece": 0.020435167797039903,
17
+ "n_folds_run": 0
18
+ }
results/vgg19_test_preds.json ADDED
The diff for this file is too large to render. See raw diff
 
splits/holdout_split_augmented.json ADDED
The diff for this file is too large to render. See raw diff
 
weights/clip_openai_v2_final.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3d3f20d7a04533574c5b91020fab47def33392620e9f4c8ad8b96e1d911c0d1e
3
+ size 344853995
weights/densenet121_v2_final.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4d5b207059cc7811f192dea7cdf145afee82f583b05098870f79d6617d44b5a2
3
+ size 28470922
weights/dinov2_l_v2.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:68d3215857fbb867475073b6a0e7280c9080edcf0ee5f4ab9ddcfb696b964734
3
+ size 1217647177
weights/inception_v3_v2_final.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b239528f7b8a23e35b16189c3bf430095d4f9aa5a7494f3f00a1a33071eb9639
3
+ size 97840003
weights/resnet101_v2_final.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5527d9d11ff2e66502741cb0a9b3749d7acef54d8b236285ec7916f0b824861
3
+ size 170716931
weights/resnet50_v2_final.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b638d103d680ba78df5c5290995e2b57b64c4ee47928d21d22cd9b9dcf1b0bee
3
+ size 94435323
weights/retfound_v2.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d01b1ea313944d8cea49fec7fad746b6f4b24cbef58ee0512d6eca28e3174720
3
+ size 1213365479
weights/swin_b_v2.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:208d699731a9db62d95e68b84f80b3e330d6fe33eb70c6fbae8ab72efe66630b
3
+ size 347145547
weights/vgg19_v2_final.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:390c1eb1e47c72cc03ec63c1b0397d6a1b970dc79a837403adc25d1dffbde324
3
+ size 558459173