Evaluation#

Navigation: ← Previous: Interpretation | Next: Project Overview →

Held-out comparison: accuracy, precision, recall, F1, specificity, ROC/PR, Brier score, and MCC.

What to trust on an imbalanced medical task#

Metric

What it answers

Accuracy

Overall hit rate. Inflated by the 73% benign majority.

Precision (PPV)

Of scans flagged malignant, how many are?

Recall (sensitivity)

Of true cancers, how many did we catch?

Specificity

Of benign scans, how many did we leave unflagged?

F1

Harmonic mean of precision and recall; used to pick thresholds on val.

Balanced accuracy

Mean of recall and specificity; chance is 0.5.

ROC-AUC

Ranking quality across all thresholds.

PR-AUC

Ranking quality with emphasis on the positive class.

Brier

Mean squared error of predicted probabilities (lower is better).

MCC

Correlation between predictions and labels; 0 is chance.

There is no single winner. A screening-style reader may prefer recall; a second-reader tool may prefer precision. The dummy model makes that tension obvious.

import sys
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
plt.style.use("seaborn-v0_8-whitegrid")

PROJ_DIR = Path(".").resolve()
if not (PROJ_DIR / "cancer_cv_utils.py").exists():
    PROJ_DIR = Path("projects/cancer-imaging").resolve()
if str(PROJ_DIR) not in sys.path:
    sys.path.insert(0, str(PROJ_DIR))

from cancer_cv_utils import (
    CLASS_NAMES,
    METRIC_ORDER,
    artifacts_dir,
    classification_metrics,
    display_plotly,
    extract_cv_features,
    figures_dir,
    grouped_importance,
    load_metrics,
    load_predictions,
    load_splits,
    metrics_frame,
    overlay_heatmap,
    tune_threshold,
)

SPLITS = load_splits()
FIG = figures_dir()
ART = artifacts_dir()
print("Splits:", {k: v["labels"].shape[0] for k, v in SPLITS.items()})
print("Malignant rates:", {k: f"{v['labels'].mean():.1%}" for k, v in SPLITS.items()})
Splits: {'train': 546, 'val': 78, 'test': 156}
Malignant rates: {'train': '26.9%', 'val': '26.9%', 'test': '26.9%'}
mets = load_metrics()
preds = load_predictions()
table = metrics_frame(mets).round(3)
table
accuracy precision recall f1 specificity balanced_accuracy roc_auc pr_auc brier mcc
model
Dummy (majority) 0.731 0.000 0.000 0.000 1.000 0.500 0.500 0.269 0.269 0.000
Logistic + PCA 0.782 0.682 0.357 0.469 0.939 0.648 0.802 0.599 0.177 0.377
HOG/LBP + RF 0.846 0.765 0.619 0.684 0.930 0.774 0.858 0.703 0.135 0.590
Small CNN 0.846 0.725 0.690 0.707 0.904 0.797 0.833 0.736 0.236 0.603
ResNet-18 transfer 0.776 0.554 0.857 0.673 0.746 0.801 0.869 0.772 0.164 0.542

Interactive metric table#

import plotly.graph_objects as go
show = table.reset_index().rename(columns={'model': 'Model'})
fig = go.Figure(data=[go.Table(
    header=dict(values=list(show.columns), fill_color='#0b3d4a', font=dict(color='white')),
    cells=dict(values=[show[c] for c in show.columns], align='left'),
)])
fig.update_layout(margin=dict(l=0, r=0, t=8, b=8), height=280)
display_plotly(fig)

Bars and ranking curves#

from IPython.display import Image, display
display(Image(str(FIG / '05_metrics_bars.png')))
display(Image(str(FIG / '05_roc_pr.png')))
../../_images/861ddc5da0b5ee3d805374149b8138069a6b593c90fb6426a6f249fd8042514f.png ../../_images/8c7f0a06daa50b04c980428479e72580c7dc86042b3852c89fb3497153c32910.png

ROC and precision–recall from stored test probabilities#

from sklearn.metrics import precision_recall_curve, roc_curve, auc as sk_auc

y = preds['y_true']
series = [
    ('Dummy', preds['dummy']),
    ('Logistic + PCA', preds['logreg']),
    ('HOG/LBP + RF', preds['rf']),
    ('Small CNN', preds['cnn']),
    ('ResNet-18', preds['resnet']),
]
fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.4))
for name, prob in series:
    fpr, tpr, _ = roc_curve(y, prob)
    prec, rec, _ = precision_recall_curve(y, prob)
    axes[0].plot(fpr, tpr, label=f'{name} ({sk_auc(fpr, tpr):.2f})')
    axes[1].plot(rec, prec, label=f'{name} ({sk_auc(rec, prec):.2f})')
axes[0].plot([0, 1], [0, 1], ls='--', c='grey', lw=0.8)
axes[0].set_xlabel('False positive rate'); axes[0].set_ylabel('Recall')
axes[0].set_title('ROC')
axes[1].set_xlabel('Recall'); axes[1].set_ylabel('Precision')
axes[1].set_title('Precision–recall')
axes[1].axhline(y.mean(), ls=':', c='grey', label='Prevalence')
for ax in axes:
    ax.legend(frameon=False, fontsize=8)
fig.tight_layout()
plt.show()
../../_images/0db4dd7cd8fab04b4d53b58c4ec41381fb44a09c1710e866c5e59de0143821e8.png

Calibration (Brier and reliability)#

from sklearn.calibration import calibration_curve

brier_of = {
    'Logistic': mets['Logistic + PCA']['brier'],
    'RF': mets['HOG/LBP + RF']['brier'],
    'CNN': mets['Small CNN']['brier'],
    'ResNet': mets['ResNet-18 transfer']['brier'],
}
fig, ax = plt.subplots(figsize=(5.6, 4.6))
ax.plot([0, 1], [0, 1], ls='--', c='grey', lw=0.8, label='Perfect')
for name, key in [('Logistic', 'logreg'), ('RF', 'rf'), ('CNN', 'cnn'), ('ResNet', 'resnet')]:
    frac, meanp = calibration_curve(y, preds[key], n_bins=6, strategy='quantile')
    ax.plot(meanp, frac, marker='o', label=f'{name} (Brier={brier_of[name]:.3f})')
ax.set_xlabel('Predicted P(malignant)')
ax.set_ylabel('Observed malignant rate')
ax.set_title('Reliability diagram (test set)')
ax.legend(frameon=False, fontsize=8)
plt.tight_layout()
plt.show()
../../_images/a3b4eb17ba9260ee58a9ef881824ac724200263f7ab0caa11067956b045c7d0a.png

Error counts at the chosen thresholds#

False negatives are missed cancers; false positives are extra work for a human reader.

err = pd.DataFrame({
    name: {'TP': m['tp'], 'FP': m['fp'], 'TN': m['tn'], 'FN': m['fn'], 'threshold': m['threshold']}
    for name, m in mets.items()
}).T
err
TP FP TN FN threshold
Dummy (majority) 0.0 0.0 114.0 42.0 0.5
Logistic + PCA 15.0 7.0 107.0 27.0 0.8
HOG/LBP + RF 26.0 8.0 106.0 16.0 0.4
Small CNN 29.0 11.0 103.0 13.0 0.7
ResNet-18 transfer 36.0 29.0 85.0 6.0 0.5

What improved?#

  • Dummy → logistic: ranking appears (ROC-AUC ~0.80). Accuracy barely moves; recall is no longer zero.

  • Logistic → HOG/LBP forest: best precision of the ladder, strong accuracy and AUC. Classical CV still earns its keep on a small dataset.

  • Forest → small CNN: similar accuracy, slightly higher F1 and PR-AUC, worse Brier (probabilities are less calibrated).

  • CNN → ResNet-18 transfer: highest ROC-AUC and PR-AUC and the fewest missed cancers (FN), at the cost of more false positives and lower accuracy than the forest.

On 156 test images these gaps are noisy. The honest summary is: engineered edges beat raw pixels; transfer learning is the best ranker and the most sensitive detector; no model is ready for clinic. Domain shift, 128px resolution, and a single-source ultrasound set are hard limits.

Rebuild#

python projects/cancer-imaging/_prepare_data.py   # if the NPZ is missing
python projects/cancer-imaging/_train_models.py   # regenerate weights and figures
python projects/cancer-imaging/_generate_notebooks.py

Navigation: ← Previous: Interpretation | Next: Project Overview →