AI & Computer Vision, lesson 24 of 52
Train a Custom Sports Detector with YOLO
Introduction
The pretrained YOLO from lesson 23 knows "person" and "sports ball", but not your ball on your pitch, and it has never heard of a goal. Today you'll fix that: take the dataset you collected and labelled, train YOLO on it with a free cloud GPU, and learn to read the numbers that tell you whether it worked.
By the end of this lesson you'll be able to:
- Export a labelled Roboflow dataset in YOLO format and read
data.yaml - Run a YOLO training job in Google Colab on a free GPU
- Explain the training loss, precision, recall, mAP50 and mAP50-95
- Compute average precision for one class by hand and in code
- Read the results plots and decide whether to train longer, collect more data, or stop
- Evaluate on the test split once, and export the model for later sessions
- Log runs so experiments can be compared
Working principle
The plan for today
Roboflow (your labelled data) -> Colab (rent a GPU) -> YOLO training -> results -> best.pt (your model)Fine-tuning, not training from zero
As in lesson 21, we start from the pretrained weights (yolov8n.pt), which already know edges, shapes and everyday objects, and teach them our classes: player, ball, goal. That's why a few hundred labelled images can be enough.
What the training prints
| Term | Meaning | You want |
|---|---|---|
| box loss | How far the predicted boxes are from the true boxes | Falling |
| cls loss | How wrong the class labels are | Falling |
| dfl loss | A loss that sharpens the box edges | Falling |
| Precision | Of the boxes it drew, how many were right | High |
| Recall | Of the real objects, how many it found | High |
| mAP50 | Average precision per class at IoU 0.5, averaged over classes | High |
| mAP50-95 | The same, averaged over stricter IoU thresholds 0.5 to 0.95. Rewards tight boxes. | High (always lower than mAP50) |
Average precision (AP) and mAP in plain words
For one class, sort all detections by confidence. Walk down the list. A detection is correct if its IoU with an unused real box is 0.5 or more. As you go, plot precision against recall. AP is the area under that curve. A perfect detector scores 1. A detector that finds 3 of 4 balls but also makes confident false alarms scores much less. mAP is the mean of the APs of all the classes.
Reading the results
| You see | It probably means | What to try |
|---|---|---|
| Losses fall, mAP rises, then flattens | Converged | Stop (the patience setting does this) |
| Training loss falls, validation loss rises | Overfitting (lesson 20) | More data, more augmentation, fewer epochs |
| Player AP high, ball AP low | Small object, too few examples | More ball images, larger imgsz, check the labels |
| Everything low | Bad labels, too little data, or too few epochs | Check labels with the lesson 12 checker, then train longer |
| Test result much worse than validation | Leakage in validation (lesson 13) | Split by session, as in lesson 13 |
What you'll need
- Your labelled Roboflow dataset from lessons 12 and 13 (or the class practice set)
- A Google account for Google Colab (free GPU)
- Optional: a free Weights & Biases account for logging runs
Build it
Cell 1: average precision from scratch (runs anywhere)
import numpy as np
def iou(a, b):
ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / union if union > 0 else 0.0
def average_precision(truths, detections, iou_threshold=0.5):
"""
truths: {image_id: [box, box, ...]} the boxes a human drew
detections: [(image_id, box, score), ...] what the model predicted
Returns AP: the area under the precision-recall curve for ONE class.
"""
n_truth = sum(len(v) for v in truths.values())
used = {img: [False] * len(boxes) for img, boxes in truths.items()}
detections = sorted(detections, key=lambda d: -d[2]) # most confident first
tp = np.zeros(len(detections))
for i, (img, box, score) in enumerate(detections):
best, best_j = 0.0, -1
for j, t in enumerate(truths.get(img, [])):
overlap = iou(box, t)
if overlap > best:
best, best_j = overlap, j
if best >= iou_threshold and not used[img][best_j]:
tp[i] = 1 # a correct, first-time match
used[img][best_j] = True # each real ball can be found once
fp = 1 - tp
cum_tp, cum_fp = np.cumsum(tp), np.cumsum(fp)
recall = cum_tp / max(n_truth, 1)
precision = cum_tp / np.maximum(cum_tp + cum_fp, 1e-9)
# area under the curve (precision made monotonically decreasing, as in the standard method)
r = np.concatenate([[0.0], recall, [1.0]])
p = np.concatenate([[1.0], precision, [0.0]])
for k in range(len(p) - 2, -1, -1):
p[k] = max(p[k], p[k + 1])
idx = np.where(r[1:] != r[:-1])[0]
return float(np.sum((r[idx + 1] - r[idx]) * p[idx + 1])), precision, recall
# Three images, four real balls
truths = {"a": [(100, 100, 120, 120)], "b": [(50, 60, 70, 80), (200, 200, 220, 220)], "c": [(300, 40, 320, 60)]}
good = [("a", (101, 101, 121, 121), 0.95), ("b", (50, 61, 71, 81), 0.90), ("b", (201, 199, 221, 219), 0.85), ("c", (299, 41, 319, 61), 0.80)]
sloppy = [("a", (101, 101, 121, 121), 0.95), ("c", (500, 500, 520, 520), 0.92), # a confident false alarm
("b", (50, 61, 71, 81), 0.70), ("b", (400, 100, 420, 120), 0.60), ("c", (299, 41, 319, 61), 0.40)]
for name, dets in (("good detector", good), ("sloppy detector", sloppy)):
ap, precision, recall = average_precision(truths, dets)
print(f"{name}: AP@0.5 = {ap:.2f} (found {int(recall[-1] * 4)} of 4 balls, {len(dets)} detections)")Check one value by hand. For the sloppy detector the walk down the list is: hit, false alarm, hit, false alarm, hit. Precision at each hit is 1.0, 0.67 and 0.6 and each hit adds 0.25 of recall, so AP is 0.25 × 1.0 + 0.25 × 0.67 + 0.25 × 0.6 ≈ 0.57.
Questions: which mistake hurt more, the confident false alarm at 0.92 or the unfound ball? What happens to AP if the false alarm had a confidence of 0.10 instead?
Colab cells: train on your own data
Open a new notebook at colab.research.google.com. Choose Runtime, Change runtime type, GPU. Each block below is one notebook cell.
Cell A: install
!pip install -q ultralytics roboflowCell B: add your key safely. Click the key icon (Secrets) on the left of Colab, add a secret called ROBOFLOW_API_KEY and switch on notebook access. Never paste a key into the code, especially if you share the notebook.
from google.colab import userdata
from roboflow import Roboflow
rf = Roboflow(api_key=userdata.get("ROBOFLOW_API_KEY"))
project = rf.workspace("YOUR-WORKSPACE").project("YOUR-PROJECT") # copy both names from the Roboflow URL
dataset = project.version(1).download("yolov8") # the YOLO format, as in lesson 12 and lesson 13
print(dataset.location)If the "yolov8" format name is rejected, open the Export page in Roboflow and use the name it shows for the YOLO format.
Cell C: check the dataset before spending GPU time. Save this as yamlcheck.py (or paste into a cell). It confirms the paths and that every image has a label file.
import os
import sys
import yaml
root = sys.argv[1] if len(sys.argv) > 1 else "football-1" # the folder Roboflow downloaded
cfg = yaml.safe_load(open(os.path.join(root, "data.yaml")))
print("classes:", cfg.get("names"), " count:", cfg.get("nc"))
problems = []
for split in ("train", "val", "test"):
rel = cfg.get(split)
if not rel:
problems.append(f"no '{split}' entry in data.yaml")
continue
images = os.path.normpath(os.path.join(root, rel))
if not os.path.isdir(images): # Roboflow often writes "../train/images"
images = os.path.normpath(os.path.join(root, rel.replace("../", "")))
labels = images.replace("images", "labels")
n_img = len([f for f in os.listdir(images)]) if os.path.isdir(images) else -1
n_lab = len([f for f in os.listdir(labels)]) if os.path.isdir(labels) else -1
print(f"{split:5s}: {n_img} images, {n_lab} label files ({images})")
if n_img < 0:
problems.append(f"folder not found: {images}")
elif n_img != n_lab:
problems.append(f"{split}: {n_img} images but {n_lab} label files")
print("PROBLEMS:" if problems else "data.yaml looks fine", *problems, sep="\n ")Run it with python yamlcheck.py YOUR-DATASET-FOLDER (the folder printed by dataset.location). Fix any problem it prints before training.
Cell D: train
from ultralytics import YOLO
model = YOLO("yolov8n.pt") # the small pretrained model (or "yolo26n.pt": see lesson 23)
results = model.train(
data=f"{dataset.location}/data.yaml",
epochs=50, # an upper limit
patience=15, # stop early if validation stops improving for 15 epochs
imgsz=640, # larger helps the small ball but is slower
batch=16,
project="football",
name="run1",
)The progress bar shows the losses, precision, recall and mAP after each epoch. Training on a free Colab GPU for a few hundred images typically takes minutes, not hours. The best model is saved as football/run1/weights/best.pt. Depending on the version, the folder may be runs/detect/football/run1: read the last lines of the output for the exact path.
Cell E: evaluate on the test split, once
best = YOLO("football/run1/weights/best.pt")
metrics = best.val(data=f"{dataset.location}/data.yaml", split="test") # the test folder, for the first time
print("mAP50:", round(metrics.box.map50, 3), " mAP50-95:", round(metrics.box.map, 3))
print("per-class mAP50-95:", dict(zip(best.names.values(), [round(float(v), 3) for v in metrics.box.maps])))Look at the per-class numbers. The ball is likely the lowest.
Cell F: try it and export
best.predict("some_test_image.jpg", save=True, conf=0.25) # writes an annotated copy
best.export(format="onnx") # used for speed in lesson 25Download best.pt (right-click in the Colab file panel) before the session ends: the Colab files vanish when the runtime resets.
Cell G: plot the training curves
After training, download results.csv from the run folder, then run this on your laptop (or in Colab):
import sys
import matplotlib
import pandas as pd
matplotlib.use("Agg")
import matplotlib.pyplot as plt
path = sys.argv[1] if len(sys.argv) > 1 else "runs/detect/run1/results.csv"
df = pd.read_csv(path)
df.columns = [c.strip() for c in df.columns] # Ultralytics pads some column names with spaces
fig, axes = plt.subplots(1, 3, figsize=(12, 3.4))
for col in ("train/box_loss", "val/box_loss"):
if col in df:
axes[0].plot(df["epoch"], df[col], label=col)
axes[0].set_title("box loss")
axes[0].legend()
for col in ("metrics/precision(B)", "metrics/recall(B)"):
if col in df:
axes[1].plot(df["epoch"], df[col], label=col.split("/")[1])
axes[1].set_title("precision and recall")
axes[1].legend()
for col in ("metrics/mAP50(B)", "metrics/mAP50-95(B)"):
if col in df:
axes[2].plot(df["epoch"], df[col], label=col.split("/")[1])
axes[2].set_title("mAP")
axes[2].legend()
for a in axes:
a.set_xlabel("epoch")
plt.tight_layout()
plt.savefig("yolo_training.png", dpi=100)
last = df.iloc[-1]
best = df["metrics/mAP50(B)"].idxmax() if "metrics/mAP50(B)" in df else None
print("epochs run:", int(last["epoch"]))
if best is not None:
print(f"best mAP50 = {df.loc[best, 'metrics/mAP50(B)']:.3f} at epoch {int(df.loc[best, 'epoch'])}")Run it as python results.py path/to/results.csv.
Logging runs with Weights & Biases (optional)
Ultralytics can send each run to Weights & Biases so that you can compare several experiments in one dashboard. How it is switched on has changed between Ultralytics versions, so open the Ultralytics page on the Weights & Biases integration and follow the current steps. Free accounts are fine for students; check the terms for under-18s before you create accounts. If W&B isn't allowed, results.csv plus the plotting script above gives you the same information.
A simple lab-book habit that works either way: after each run, write one line in a shared sheet.
| Run | Data (images) | Model | imgsz | Epochs | mAP50 | Ball AP | What changed |
|---|---|---|---|---|---|---|---|
| run1 | 180 | yolov8n | 640 | 50 | baseline |
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| "No GPU" or training is very slow | GPU runtime not selected | Runtime, Change runtime type, GPU |
FileNotFoundError ... data.yaml | Wrong dataset path | Use dataset.location, and run yamlcheck.py |
Dataset not found or label warnings | Relative paths in data.yaml | Use the generated dataset folder, or fix the paths as yamlcheck.py suggests |
CUDA out of memory | Batch too big | batch=8, or imgsz=512 |
| All metrics 0 | Wrong class order or empty labels | Compare names in data.yaml with your labels |
| Training stops early | patience triggered | That's by design. Check the plots. |
| Colab session timed out, files lost | Idle or reset runtime | Download best.pt and results.csv as soon as training ends |
roboflow API key error | Secret not enabled for the notebook | Toggle "Notebook access" on the secret |
| Version-specific API errors | Library updated | pip install -U ultralytics or pin the version your teacher tested |
Try it
- Compare
imgsz=640andimgsz=960for the ball AP - Train with augmentation turned down and up and plot both
- Write a short "experiment report" from the lab book
Check what you learnt
Why start from yolov8n.pt instead of from zero?
What does mAP50-95 reward that mAP50 doesn't?
Player AP is 0.9, ball AP is 0.3. What should you do?
Why not paste your API key into the notebook?
Validation loss rises while training loss falls. What's happening?
Conclusion
Training a custom detector is fine-tuning: start from pretrained YOLO weights, train on your labelled data on a GPU, and read precision, recall, mAP50 and mAP50-95 per class. Low AP on one class usually means that class needs more or better data. Test once, export, and log every run so you can compare.
What next?
Next: Real-time pipeline & making it fast on a laptop. You'll make detection run live, and fast, on a normal laptop.

