AI & Computer Vision, lesson 19 of 52
Evaluation: The Metrics That Matter
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 ball | Model said no ball | |
|---|---|---|
| There was a ball | True positive (TP): caught it | False negative (FN): missed it |
| There was no ball | False positive (FP): false alarm | True negative (TN): correctly quiet |
Every metric below is made from these four numbers.
The metrics
| Metric | Formula | Plain question |
|---|---|---|
| Accuracy | (TP + TN) / everything | How often is it right overall? |
| Precision | TP / (TP + FP) | When it says "ball", how often is it right? |
| Recall | TP / (TP + FN) | Of the real balls, how many did it find? |
| F1 | 2 × 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?)
| Threshold | Effect |
|---|---|
| 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
| Job | Worse mistake | Favour |
|---|---|---|
| Auto-highlight reel: find goals (lesson 42) | Missing a goal | Recall |
| Auto-trigger a light when a player enters a zone (lesson 35) | Triggering on a shadow | Precision |
| Counting passes for stats | Both matter | F1 |
| Safety stop on a robot arm | Missing a person | Recall, 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_courseenvironment 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.
| Section | What to write |
|---|---|
| 1. Model | What it is (CNN, layers, epochs), and what data it was trained on |
| 2. Test data | Number of crops, how many were balls, and how the split was made |
| 3. Confusion matrix | Paste the heatmap |
| 4. Metrics | Accuracy, precision, recall, F1 at your threshold |
| 5. Threshold choice | The threshold you chose on the validation data, and why |
| 6. Mistakes | Look at five false positives and five false negatives. What do they have in common? |
| 7. Honest verdict | Is 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
| Error | Cause | Fix |
|---|---|---|
| Precision or recall prints 0 | No predictions of that class | Lower the threshold. Check the labels. |
ValueError: Found input variables with inconsistent numbers of samples | y_true and y_prob have different lengths | Print .shape of both |
| Confusion matrix looks flipped | Rows are the truth, columns the prediction | Remember: rows truth, columns prediction (scikit-learn convention) |
| Test accuracy much lower than validation | Leakage in validation, or the test sessions differ | Good news in a way: the test is honest. Look at which sessions fail. |
shuffle=False forgotten | Labels no longer match the predictions | Keep shuffle=False for evaluation |
| Metric changes every run | Random pretend data | Fixed 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
playerclass too (a 3-class confusion matrix) - Find the threshold that gives at least 90% recall, and report the precision there
Check what you learnt
In our ball model, what is a false negative?
Which metric answers 'of the real balls, how many did we catch?'
You lower the threshold. What usually happens?
Why isn't 95% accuracy automatically good?
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.

