AI & Computer Vision, lesson 19 of 52

Evaluation: The Metrics That Matter

intermediate23 min read

Introduction

Your model says it's 95% accurate. Is it any good? Maybe not: if 95% of your crops have no ball, a model that always says "no ball" scores 95% too. Today you'll learn the numbers professionals use instead (precision, recall and F1), see how one setting trades them against each other, and finally open the sealed test folder.

By the end of this lesson you'll be able to:

  • Show with an example why accuracy alone can lie
  • Fill in a confusion matrix (true positives, false positives, false negatives, true negatives)
  • Calculate and explain precision, recall and F1
  • Explain how the threshold trades precision against recall
  • Choose which metric matters for a given job
  • Score a model on the test folder, once, and write up the result

Working principle

Why accuracy lies

If only 5% of your crops contain a ball, a model that always says "no ball" is 95% accurate and finds no balls at all. Accuracy counts all mistakes the same and hides what the model is bad at.

The confusion matrix: four boxes

Model said ballModel said no ball
There was a ballTrue positive (TP): caught itFalse negative (FN): missed it
There was no ballFalse positive (FP): false alarmTrue negative (TN): correctly quiet

Every metric below is made from these four numbers.

The metrics

MetricFormulaPlain question
Accuracy(TP + TN) / everythingHow often is it right overall?
PrecisionTP / (TP + FP)When it says "ball", how often is it right?
RecallTP / (TP + FN)Of the real balls, how many did it find?
F12 × precision × recall / (precision + recall)One number that is low if either one is low

The threshold

The model outputs a probability. We call it "ball" if the probability is above a threshold, 0.5 by default.

Caught 14, missed 6, false alarms 5, correct "no ball" 25. Precision 74% (when it says ball, is it right?) · Recall 70% (of the real balls, how many found?)

ThresholdEffect
Lower (0.3)More balls found (recall up), more false alarms (precision down)
Higher (0.7)Fewer false alarms (precision up), more missed balls (recall down)

There is no best threshold in general. It depends on the job.

Which metric matters? Ask what a mistake costs

JobWorse mistakeFavour
Auto-highlight reel: find goals (lesson 42)Missing a goalRecall
Auto-trigger a light when a player enters a zone (lesson 35)Triggering on a shadowPrecision
Counting passes for statsBoth matterF1
Safety stop on a robot armMissing a personRecall, by a lot

Using the test set properly

  • Open the test folder once, after you have finished choosing the model and the threshold on the validation data.
  • If you change anything because of the test result, the test set is spent. Collect new data for a fair test.
  • Report the test result honestly, even if it's worse than validation.

What you'll need

  • Your trained model from lesson 18
  • Your cv_course environment with scikit-learn: pip install scikit-learn

Build it

Cell 1: the lazy model

import numpy as np
 
# Pretend test set: 200 crops, only 10 of them contain a ball (5%)
y_true = np.array([1] * 10 + [0] * 190)
 
# A lazy model that ALWAYS says "no ball"
lazy = np.zeros(200, dtype=int)
 
accuracy = np.mean(lazy == y_true)
print(f"Lazy model accuracy: {accuracy:.0%}")
print("Balls it found:", int(np.sum((lazy == 1) & (y_true == 1))), "of", int(y_true.sum()))

Question: is this model any good? What does the accuracy not tell us?

Cell 2: metrics by hand, at three thresholds

import numpy as np
 
 
def confusion(y_true, y_pred):
    tp = int(np.sum((y_pred == 1) & (y_true == 1)))      # said ball, was ball
    fp = int(np.sum((y_pred == 1) & (y_true == 0)))      # said ball, was NOT (false alarm)
    fn = int(np.sum((y_pred == 0) & (y_true == 1)))      # said no ball, was a ball (miss)
    tn = int(np.sum((y_pred == 0) & (y_true == 0)))      # said no ball, was not
    return tp, fp, fn, tn
 
 
def metrics(tp, fp, fn, tn):
    accuracy = (tp + tn) / (tp + fp + fn + tn)
    precision = tp / (tp + fp) if (tp + fp) else 0.0      # of the alarms, how many were real?
    recall = tp / (tp + fn) if (tp + fn) else 0.0         # of the real balls, how many did we catch?
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
    return accuracy, precision, recall, f1
 
 
y_true = np.array([1] * 10 + [0] * 190)
rng = np.random.default_rng(3)
y_prob = np.where(y_true == 1, rng.normal(0.7, 0.2, 200), rng.normal(0.25, 0.2, 200)).clip(0, 1)   # pretend model output
 
for threshold in (0.3, 0.5, 0.7):
    y_pred = (y_prob >= threshold).astype(int)
    tp, fp, fn, tn = confusion(y_true, y_pred)
    acc, prec, rec, f1 = metrics(tp, fp, fn, tn)
    print(f"threshold {threshold}: TP={tp} FP={fp} FN={fn} TN={tn} | accuracy {acc:.0%} precision {prec:.0%} recall {rec:.0%} F1 {f1:.2f}")

Read the three lines. At threshold 0.3 the model finds 8 of 10 balls but 73 of its "balls" are false alarms (precision 10%). At 0.7 it is quiet and precise but finds only 3 of 10.

Questions: which threshold would you use for a highlight reel? For a light trigger? Why does accuracy go up while the model gets worse at finding balls?

Cell 3: the professional tools

scikit-learn and Seaborn do the same job in a few lines. The y_true and y_prob here are pretend: replace them with real test results using the block after this one.

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import classification_report, confusion_matrix, precision_recall_curve
 
# Replace these two lines with real test results (see the Keras block below)
rng = np.random.default_rng(3)
y_true = np.array([1] * 40 + [0] * 160)
y_prob = np.where(y_true == 1, rng.normal(0.7, 0.2, 200), rng.normal(0.3, 0.2, 200)).clip(0, 1)
 
THRESHOLD = 0.5
y_pred = (y_prob >= THRESHOLD).astype(int)
 
cm = confusion_matrix(y_true, y_pred)                    # rows = truth, columns = prediction
print(classification_report(y_true, y_pred, target_names=["no_ball", "ball"], digits=3))
 
fig, (a1, a2) = plt.subplots(1, 2, figsize=(9, 3.6))
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", cbar=False, ax=a1,
            xticklabels=["no_ball", "ball"], yticklabels=["no_ball", "ball"])
a1.set_xlabel("predicted")
a1.set_ylabel("truth")
a1.set_title(f"Confusion matrix (threshold {THRESHOLD})")
 
precision, recall, thresholds = precision_recall_curve(y_true, y_prob)
a2.plot(recall, precision)
a2.set_xlabel("recall")
a2.set_ylabel("precision")
a2.set_title("Precision-recall curve")
plt.tight_layout()
plt.savefig("evaluation.png", dpi=100)
 
# Which threshold gives the best F1?
f1 = 2 * precision[:-1] * recall[:-1] / np.maximum(precision[:-1] + recall[:-1], 1e-9)
best = int(np.argmax(f1))
print(f"Best F1 {f1[best]:.2f} at threshold {thresholds[best]:.2f}")

Cell 4: score your Keras model on the test folder

Needs TensorFlow. This is the one time the test folder is used.

import numpy as np
from tensorflow import keras
 
model = keras.models.load_model("ball_classifier.keras")
 
test_ds = keras.utils.image_dataset_from_directory(
    "crops/test",
    class_names=["no_ball", "ball"],      # same order as training: no_ball = 0, ball = 1
    label_mode="binary",
    image_size=(64, 64),
    batch_size=32,
    shuffle=False,                         # keep the order so predictions line up with labels
)
 
y_true = np.concatenate([labels.numpy().ravel() for _, labels in test_ds]).astype(int)
y_prob = model.predict(test_ds, verbose=0).ravel()
print("test crops:", len(y_true), " balls:", int(y_true.sum()))

Now run cell 3 again, without the two lines that make pretend y_true and y_prob. It will use these real ones.

Evaluation report: template

You or pair submits one page.

SectionWhat to write
1. ModelWhat it is (CNN, layers, epochs), and what data it was trained on
2. Test dataNumber of crops, how many were balls, and how the split was made
3. Confusion matrixPaste the heatmap
4. MetricsAccuracy, precision, recall, F1 at your threshold
5. Threshold choiceThe threshold you chose on the validation data, and why
6. MistakesLook at five false positives and five false negatives. What do they have in common?
7. Honest verdictIs it good enough for the job in lesson 25 (real-time detection) or lesson 42 (highlights)? What would you improve first?

If it goes wrong

ErrorCauseFix
Precision or recall prints 0No predictions of that classLower the threshold. Check the labels.
ValueError: Found input variables with inconsistent numbers of samplesy_true and y_prob have different lengthsPrint .shape of both
Confusion matrix looks flippedRows are the truth, columns the predictionRemember: rows truth, columns prediction (scikit-learn convention)
Test accuracy much lower than validationLeakage in validation, or the test sessions differGood news in a way: the test is honest. Look at which sessions fail.
shuffle=False forgottenLabels no longer match the predictionsKeep shuffle=False for evaluation
Metric changes every runRandom pretend dataFixed seed in the code. Real data won't change.

Try it

  • Plot precision and recall against the threshold on one chart
  • Compute the metrics for the player class too (a 3-class confusion matrix)
  • Find the threshold that gives at least 90% recall, and report the precision there

Check what you learnt

  1. In our ball model, what is a false negative?

  2. Which metric answers 'of the real balls, how many did we catch?'

  3. You lower the threshold. What usually happens?

  4. Why isn't 95% accuracy automatically good?

  5. When may you look at the test set?

Conclusion

Accuracy can lie when one class is rare. The confusion matrix splits results into four boxes; precision asks "when it says ball, is it right?", recall asks "of the real balls, how many did it find?", and F1 balances them. The threshold trades one for the other, so pick it for the job, on validation data, and score the test set once.

What next?

Next: Overfitting & the bad-data lab. You'll break your model on purpose with bad data, and measure the damage.