Retinopathy classifier
Transfer learning on retinal photos to grade diabetic retinopathy into five stages, with an honest look at where the model fails.
- Stack
-
- Python
- TensorFlow
- Keras
- EfficientNet-B0
- scikit-learn
- OpenCV
The problem
Diabetic retinopathy is graded on a five-step scale from photos of the back of the eye. Catching it early prevents blindness, and graders are scarce.
I wanted to see how far a small pretrained model gets on a public dataset of 3,662 images, and to find out exactly where it breaks.
How it's built
Images are 224 × 224 fundus photos with a Gaussian filter already applied. EfficientNet-B0 with ImageNet weights is the backbone, its first 100 layers frozen, with a small classification head on top.
Key decisions
Fine-tune a small pretrained backbone
With under 3,000 training images, training from scratch would overfit. EfficientNet-B0 has 4.2M parameters and already knows edges and textures. Freezing the early layers and fine-tuning the rest, with augmentation, a stepped learning rate and early stopping, kept training stable.
base_model = EfficientNetB0(input_shape=(224, 224, 3),
weights="imagenet", include_top=False)
for layer in base_model.layers[:100]:
layer.trainable = False
model = Sequential([
base_model,
GlobalAveragePooling2D(),
Dropout(0.3),
Dense(128, activation="relu"),
Dropout(0.5),
Dense(num_classes, activation="softmax"),
]) Report more than accuracy
Half the validation images show no retinopathy, so accuracy flatters a lazy model. For most of the first eight epochs validation accuracy sat at exactly 49.4%: the score you get by predicting No DR for every image. I tracked Cohen's kappa, per-class F1 and the full confusion matrix instead.
Check the metric, not just the model
The notebook reported a quadratic weighted kappa of 0.25, far below the unweighted 0.63. Keras numbers classes alphabetically, so the metric was treating No DR as sitting between Moderate and Proliferative. Recomputed in severity order from the same confusion matrix, it is 0.76.
severity = ["No_DR", "Mild", "Moderate", "Severe", "Proliferate_DR"]
rank = {train_data.class_indices[name]: i for i, name in enumerate(severity)}
qwk = cohen_kappa_score([rank[y] for y in y_true],
[rank[y] for y in y_pred],
weights="quadratic") # 0.758 Results
- accuracy on 731 held-out images
- 76.5%
- Cohen's kappa, unweighted / quadratic
- 0.63 / 0.76
- of eyes with any retinopathy flagged as diseased
- 95.4%
As a yes-or-no screen for any retinopathy, the same predictions catch 95.4% of diseased eyes and clear 97.8% of healthy ones.
Grading the severe end is where it fails: 2 of 38 Severe cases are found, and Proliferative is never predicted at all. 33 of the 38 Severe cases come back as Moderate.
Show the numbers as a table
| True stage | Predicted No DR | Predicted Mild | Predicted Moderate | Predicted Severe | Predicted Proliferative |
|---|---|---|---|---|---|
| No DR | 353 | 4 | 4 | 0 | 0 |
| Mild | 4 | 31 | 37 | 2 | 0 |
| Moderate | 10 | 10 | 173 | 6 | 0 |
| Severe | 0 | 3 | 33 | 2 | 0 |
| Proliferative | 3 | 8 | 46 | 2 | 0 |
Show the numbers as a table
| Stage | Precision | Recall | F1 | Images |
|---|---|---|---|---|
| No DR | 0.95 | 0.98 | 0.97 | 361 |
| Mild | 0.55 | 0.42 | 0.48 | 74 |
| Moderate | 0.59 | 0.87 | 0.70 | 199 |
| Severe | 0.17 | 0.05 | 0.08 | 38 |
| Proliferative | 0.00 | 0.00 | 0.00 | 59 |