Add 9-model fundus benchmark: weights + results + splits + code + README
Browse files- README.md +340 -0
- code/build_grouped_split.py +233 -0
- code/build_holdout_split.py +118 -0
- code/ensemble_and_stats.py +224 -0
- code/generate_all_gradcam.py +244 -0
- code/launch_on_vm.sh +45 -0
- code/launch_phase2b.sh +32 -0
- code/launch_v2_v3.sh +85 -0
- code/push_to_hf.py +611 -0
- code/run_final_experiments.py +476 -0
- code/run_foundation_models.py +369 -0
- code/run_v2_experiments.py +438 -0
- results/clip_openai_test.json +18 -0
- results/clip_openai_test_preds.json +0 -0
- results/densenet121_test.json +18 -0
- results/densenet121_test_preds.json +0 -0
- results/dinov2_l_test.json +17 -0
- results/dinov2_l_test_preds.json +0 -0
- results/ensemble_report.json +712 -0
- results/inception_v3_test.json +18 -0
- results/inception_v3_test_preds.json +0 -0
- results/resnet101_test.json +18 -0
- results/resnet101_test_preds.json +0 -0
- results/resnet50_test.json +18 -0
- results/resnet50_test_preds.json +0 -0
- results/retfound_test.json +17 -0
- results/retfound_test_preds.json +0 -0
- results/swin_b_test.json +17 -0
- results/swin_b_test_preds.json +0 -0
- results/vgg19_test.json +18 -0
- results/vgg19_test_preds.json +0 -0
- splits/holdout_split_augmented.json +0 -0
- weights/clip_openai_v2_final.pth +3 -0
- weights/densenet121_v2_final.pth +3 -0
- weights/dinov2_l_v2.pth +3 -0
- weights/inception_v3_v2_final.pth +3 -0
- weights/resnet101_v2_final.pth +3 -0
- weights/resnet50_v2_final.pth +3 -0
- weights/retfound_v2.pth +3 -0
- weights/swin_b_v2.pth +3 -0
- weights/vgg19_v2_final.pth +3 -0
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
|