Skip to content

Repository files navigation

Retinal Disease Classifier

Multi-label classifier for the ODIR-5K fundus dataset - 8 ocular conditions from paired left/right eye images.

Dual-backbone ensemble, per-backbone input normalization, CLAHE preprocessing, and test-time augmentation, served behind a FastAPI inference API.

Python PyTorch timm FastAPI Docker MLflow macro-AUC License: MIT

Rebuilt from a university capstone. The original used softmax on a multi-label dataset - a fundamental framing error. This version fixes that and a number of other decisions that quietly hurt the original's results.

Contents


Highlights

  • Multi-label done right. Sigmoid + BCEWithLogitsLoss, not the softmax/CrossEntropy that most public ODIR-5K notebooks use. Patients present with co-occurring conditions; softmax actively fights that gradient signal.
  • Heterogeneous ensemble, macro-AUC 0.888. EfficientNet-B4 + Inception-ResNet-v2 with correct per-backbone input normalization, averaged across both models and 4 TTA views.
  • Dual-eye fusion. Both fundus images pass through a shared backbone in one forward pass; features are concatenated at the head, so the model sees inter-eye asymmetry (a real signal for Hypertension and AMD).
  • Per-class threshold tuning. Thresholds optimized per class on val, not a flat 0.5 - the cost of a missed Glaucoma is not the cost of a missed Myopia.
  • Production-shaped. FastAPI inference endpoint with health/readiness probes, upload validation, and request logging; Dockerfile for portable inference; MLflow experiment tracking and model registry.
  • Honest failure analysis. Per-class false-positive/false-negative composites plus a written account of which classes are data-bound vs model-bound.

Multi-label framing

The majority of public notebooks on ODIR-5K use CrossEntropyLoss with softmax. That's wrong. Patients routinely present with multiple simultaneous conditions - Diabetes and Hypertension together, for instance - and softmax forces the probabilities to sum to 1, which produces contradictory gradients when multiple labels are true.

This is framed as multi-label from the ground up:

Loss Output Why
Softmax (wrong) CrossEntropyLoss Mutually exclusive Penalises co-occurring labels
Sigmoid (correct) BCEWithLogitsLoss Independent per class Handles co-occurrence correctly

Architecture

flowchart LR
    L["Left eye"] --> P["CLAHE on L channel<br/>resize 448x448"]
    R["Right eye"] --> P

    P --> E1
    P --> I1

    subgraph EFF["EfficientNet-B4 (shared backbone)"]
        E1["backbone"] --> EC["concat left, right"] --> EH["Dropout 0.5<br/>Linear(3584, 8)"]
    end

    subgraph INC["Inception-ResNet-v2 (shared backbone)"]
        I1["backbone"] --> IC["concat left, right"] --> IH["Dropout 0.5<br/>Linear(3072, 8)"]
    end

    EH --> AVG["sigmoid average<br/>2 models x 4 TTA views"]
    IH --> AVG
    AVG --> T["per-class thresholds"] --> OUT["8 probabilities<br/>+ binary predictions"]
Loading

Dual-eye fusion

A single shared backbone processes both eyes in one forward pass. Features from each eye are concatenated before the classification head. This matters for conditions like Hypertension and AMD that can be asymmetric - the model sees both eyes at decision time.

Two backbones, trained independently

Backbone Params Feature dim Input normalization
EfficientNet-B4 19M 1792 ImageNet mean (0.485, 0.456, 0.406)
Inception-ResNet-v2 55M 1536 Inception mean (0.5, 0.5, 0.5)

Inception-family pretrained weights expect inputs in [-1, 1]. Using ImageNet normalization with them isn't a minor detail - it shifts the activation distribution the pretrained features were built on, which matters most during the frozen warm-up phase when the head is calibrating to backbone output.

Ensemble

Both models are loaded simultaneously at inference. Each normalizes its own inputs internally, so there's a single data-loading path regardless of backbone. Sigmoid probabilities are averaged across both models and all TTA views.


Preprocessing

CLAHE on the L channel of LAB - standard preprocessing for fundus imaging, used in most published ODIR-5K work. Applied on both train and val so the input distribution at inference matches training. Operating on L only (not per-channel RGB) avoids the white-balance shift that produces colour casts.

  • clipLimit=2.0 - standard for fundus imaging; higher values amplify noise in dark retinal regions
  • tileGridSize=(8, 8) - at 448x448, gives 56x56px tiles, right-sized for optic disc and macula variation

Training

Phase 1 - head only, 5 epochs, LR 1e-3 Backbone frozen. Gets the head to a reasonable starting point before touching pretrained weights.

Phase 2 - full fine-tune, up to 25 epochs, LR 1e-4 Cosine annealing, early stopping on val AUC (patience=7). Gradient checkpointing on EfficientNet-B4 via timm's API; applied manually to the Inception-ResNet-v2 repeat blocks, which don't expose a checkpointing interface.

Optimizer: AdamW, weight decay 1e-3

Augmentation (train only): RandomResizedCrop(448, scale=0.8-1.0), HorizontalFlip, VerticalFlip, Rotation(15 deg), ColorJitter

Class imbalance: Per-class pos_weight = neg/pos from the training set, passed directly to BCEWithLogitsLoss.

Threshold tuning: Decision thresholds are optimized per class on the val set to maximize F1. A fixed 0.5 cutoff applied uniformly ignores the fact that the cost of a missed Glaucoma diagnosis is not the same as a missed Myopia diagnosis.

TTA: Four deterministic views at inference - original, horizontal flip, vertical flip, 90-degree rotation. Fundus images have no meaningful orientation, so all four are valid. Probabilities are averaged before thresholding.


Results

macro-AUC 0.888 (Ensemble + TTA).

Single backbone vs ensemble

Model macro-AUC
EfficientNet-B4 0.870
Inception-ResNet-v2 0.885
Ensemble + TTA 0.888

The ensemble gains most in the low-prevalence classes (A, H, O) where single-model predictions are noisiest. The two backbones make partially uncorrelated errors - EfficientNet's compound-scaled convolutions vs. Inception-ResNet's mixed-kernel residual blocks - which is what makes averaging useful.

Per-class ROC-AUC

Class Condition AUC F1 Precision Recall Threshold
N Normal 0.821 0.659 0.595 0.739 0.453
D Diabetes 0.860 0.717 0.764 0.674 0.596
G Glaucoma 0.959 0.649 0.667 0.633 0.714
C Cataract 0.980 0.857 0.852 0.862 0.752
A AMD 0.938 0.659 0.635 0.684 0.511
H Hypertension 0.816 0.306 0.220 0.500 0.330
M Myopia 0.996 0.889 0.889 0.889 0.540
O Other 0.737 0.518 0.491 0.548 0.513
macro 0.888

Per-class precision / recall / F1

Myopia and Glaucoma are strong - distinctive visual signatures, clean labels. Hypertension is the problem class across the board: subtle signs (arteriovenous nicking, focal arteriolar narrowing), ~5% prevalence, and limited positive examples. Other is a noisy catch-all; its ceiling is a labeling problem, not a modeling one.

The gap to the top of the published range (~0.93) is not an architecture problem. It's a data problem. The teams hitting 0.93 are pre-training on EyePACS, MESSIDOR, or APTOS before touching ODIR-5K. At 3,500 training images with the current label quality, the two classes that would need to move most (H and O) don't have enough signal to get there regardless of what sits on top.

Charts are regenerated from the reported metrics with python scripts/make_charts.py (writes to assets/).


How to run

uv sync
python main.py
Select an option:
  1) Train EfficientNet-B4
  2) Train Inception-ResNet-v2
  3) Evaluate ensemble (both checkpoints must exist)
  4) Exit

Checkpoints saved to checkpoints/<backbone>/best_model.pt. Option 3 runs full ensemble inference with TTA and prints per-class metrics.

mlflow ui   # experiment tracking

Dataset: ODIR-5K placed at data/archive/. Available on Kaggle.

Repository layout

src/        model, dataset, two-phase training loop, evaluation, FastAPI app
scripts/    failure_analysis.py, make_charts.py
analysis/   per-class FP/FN composites + summary
tests/      CLAHE, model wiring, threshold tuning
main.py     train / evaluate entrypoint

Inference API

The ensemble is served via FastAPI. Both backbone checkpoints and the ensemble threshold file must exist before the server will start.

# Generate ensemble thresholds (option 3 in main.py, only needed once)
python main.py

# Start the server
uvicorn src.api:app
curl -X POST http://localhost:8000/predict \
  -F left=@/path/to/left_fundus.jpg \
  -F right=@/path/to/right_fundus.jpg

Returns per-class probabilities and thresholded binary predictions:

{
  "probabilities": {"N": 0.87, "D": 0.04, "G": 0.12, "C": 0.01, "A": 0.03, "H": 0.08, "M": 0.0, "O": 0.15},
  "predictions":   {"N": true, "D": false, "G": false, "C": false, "A": false, "H": false, "M": false, "O": false}
}

TTA is on by default (4 views averaged). Pass ?tta=false to skip it - roughly 4x faster, slightly less accurate.

The preprocessing pipeline matches training exactly: CLAHE on the L channel, resize to 448x448, then the ensemble handles per-backbone normalization internally.


Failure analysis

scripts/failure_analysis.py runs the ensemble over the val split and saves the top-N highest-confidence false positives and false negatives per class to analysis/failures/<label>_fp.png and <label>_fn.png, with a draft summary.md listing each composite.

uv run python scripts/failure_analysis.py            # default: top 4 per class, TTA on
uv run python scripts/failure_analysis.py --top-n 6 --no-tta

What the metrics already tell you, before opening any image:

  • Hypertension (AUC 0.816, F1 0.306). The model can rank H cases reasonably well but cannot put a useful threshold on them. Expect false positives that look mildly abnormal (vessel tortuosity, age-related changes) and false negatives in mild cases where the only sign is subtle arteriovenous nicking. ~5% prevalence and ~200 positive training examples is the binding constraint.
  • Other (AUC 0.737). O is a labeling catch-all. Expect FPs that are genuinely abnormal but not in any of the seven specific classes, and FNs that the model handles correctly under a different label that happens to be present.
  • Normal (AUC 0.821). The lowest specific-class AUC. Expect FPs where pathological images get tagged as Normal because the dominant signal is "not obviously diseased", and FNs where mild Diabetes or Hypertension changes are present alongside an N=1 label.
  • Glaucoma, Cataract, Myopia (AUC 0.96-1.00). Failures here are rare and worth inspecting individually - usually image-quality issues (overexposure, off-center fovea) or unusual presentations.

Per-class image composites and one-line hypotheses live in analysis/failures/summary.md after running the script.


Limitations

This is a portfolio project, not a medical device. It has not been clinically validated and must not be used to make or inform a diagnosis.

Training-data coverage. Trained on ODIR-5K only - a single-source dataset with ~3,500 patients and a small number of clinical sites with similar acquisition equipment. Performance on fundus images from different cameras, lighting conditions, or patient populations is unmeasured and likely worse.

Hypertension is a data-volume problem. AUC 0.816, F1 0.306. The model ranks H cases reasonably, but binary predictions are unreliable. At ~5% prevalence in 3,500 images, there are fewer than 200 positive training examples. No architecture change fixes that.

"Other" is a labeling problem. O is a catch-all for conditions outside the first seven classes. Its ceiling is the label noise, not the model.

No demographic stratification. ODIR-5K does not provide consistent demographic metadata, so per-subgroup performance (age, sex, ethnicity) is not evaluated. This is a meaningful blind spot for any medical imaging model.


Project status

Phase Description Status
1 Data pipeline, model, training loop, per-class evaluation Done
2 MLflow experiment tracking, model registry Done
3 CLAHE preprocessing, dual-backbone ensemble, TTA Done

Design decisions

What worked

Multi-label framing. The most consequential correctness fix in the whole project. BCEWithLogitsLoss with independent sigmoid outputs is the only sensible choice for this dataset. Softmax co-occurrence penalty isn't a subtle bias - it actively fights the gradient signal on patients with multiple conditions, which is a significant portion of ODIR-5K.

Dual-eye input. Processing both fundus images jointly rather than independently gives the model access to inter-eye asymmetry, which is a real diagnostic signal for Hypertension and AMD. The shared backbone with concatenated features also avoids running two separate forward passes, so there's no VRAM penalty for the dual-eye design.

Two-phase training. Freezing the backbone for the first few epochs consistently produces better final AUC than end-to-end training from scratch. Pretrained ImageNet features are worth preserving - a high-LR head run in epoch 1 will degrade them before the backbone has any chance to adapt.

Per-class threshold tuning. Sweeping thresholds per class on the val set and selecting by F1 is correct for an imbalanced multi-label problem. A fixed 0.5 threshold assumes balanced classes and symmetric false positive / false negative costs. Neither is true here.

Heterogeneous ensemble. Pairing EfficientNet-B4 with Inception-ResNet-v2 produces real diversity - compound-scaled convolutions vs. mixed-kernel residual blocks generate partially uncorrelated errors. Two copies of the same backbone would add compute for minimal variance reduction.

Correct per-backbone normalization. Inception-family pretrained weights expect inputs in [-1, 1], not ImageNet statistics. Getting this wrong shifts the activation distribution at the input and degrades how much signal you recover from pretraining, particularly during Phase 1 when the backbone is frozen and the head is adapting to its output.

What could be done differently

Two models are not enough. Averaging two models reduces variance, but the gains flatten quickly. Teams hitting 0.93+ on this dataset typically run 5-10 model ensembles. With two, any systematic bias shared between the architectures is still fully present in the final output.

The val set is too small to trust individual runs. At ~700 patients and 80/20 split, per-class sample counts for rare conditions are in the dozens. AUC estimates at that scale have wide confidence intervals - a 0.003 swing between runs is noise. K-fold cross-validation would give a more reliable picture of actual generalization, at the cost of significantly more training time.

Hypertension (H) is unsolved. AUC 0.816, F1 0.306. The model can rank H cases but can't make reliable binary predictions on them. ~5% prevalence in a 3,500-image dataset means fewer than 200 positive training examples. No architecture change fixes that - it's a data volume problem.

Pretrained retinal weights are the right next investment. The step from 0.888 to the 0.93+ range requires starting from features trained on fundus images, not ImageNet. Models pretrained on EyePACS or APTOS understand haemorrhages, exudates, and vessel structure in a way ImageNet pretraining doesn't transfer. More ensembles or augmentation tuning won't close that gap.


License

MIT - see LICENSE.

About

Multi-label retinal disease classifier on ODIR-5K. Dual-eye fusion, per-class threshold tuning. Macro-AUC 0.88.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages