Training a YOLO detector: a worked exampleΒΆ
In this notebook we go through the whole training loop from the tutorial, on a small public dataset:
- Get the data and look at it (images + YOLO label files)
- Try a pretrained model first, to see why we need to fine-tune
- Train (fine-tune YOLO11n)
- Read the results: curves, metrics, confusion matrix
- Look at predictions on images the model has never seen
- Find the failures, which tell you what data to collect next
The dataset: African Wildlife (Ultralytics). About 1,500 photos with 4 classes: buffalo, elephant, rhino, zebra. It downloads automatically (~100 MB).
This is a different problem from your lab on purpose. The workflow (folders,
data.yaml,train,val,predict) is exactly the same. Only the data changes.
Requirements: pip install ultralytics and a CUDA build of PyTorch. With a GPU, training takes a few minutes.
0. SetupΒΆ
# Uncomment if ultralytics isn't installed yet:
# %pip install ultralytics
import csv
import glob
import os
import random
import matplotlib.pyplot as plt
import torch
from PIL import Image
from ultralytics import YOLO
from ultralytics.data.utils import check_det_dataset
%config InlineBackend.figure_format = "jpeg" # smaller notebook file than PNG for photo grids
print("PyTorch", torch.__version__)
print("GPU available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("GPU:", torch.cuda.get_device_name(0))
DEVICE = 0 if torch.cuda.is_available() else "cpu"
random.seed(0)
PyTorch 2.11.0+cu128 GPU available: True GPU: NVIDIA GeForce RTX 5080
If GPU available: False, you have the CPU-only build of PyTorch. Everything still works, just much slower (set EPOCHS lower below). Reinstall PyTorch with the CUDA command from the course README to fix it.
1. Get the data and look at itΒΆ
african-wildlife.yaml is a dataset config that ships with Ultralytics. check_det_dataset downloads the images (first time only) and tells us where everything is.
data = check_det_dataset("african-wildlife.yaml")
CLASS_NAMES = data["names"]
print("Classes:", CLASS_NAMES)
for split in ("train", "val", "test"):
folder = data[split]
n = len(glob.glob(os.path.join(folder, "*.jpg")))
print(f"{split:>5}: {n:4d} images ({folder})")
Classes: {0: 'buffalo', 1: 'elephant', 2: 'rhino', 3: 'zebra'}
train: 1052 images (datasets\african-wildlife\images\train)
val: 225 images (datasets\african-wildlife\images\val)
test: 227 images (datasets\african-wildlife\images\test)
Every image xyz.jpg has a label file xyz.txt in the matching labels/ folder. One line per object:
class x_centre y_centre width height (all as fractions of the image size, 0-1)
def label_path(img_path):
# .../images/xyz.jpg -> .../labels/xyz.txt
p = img_path.replace(os.sep + "images" + os.sep, os.sep + "labels" + os.sep)
return os.path.splitext(p)[0] + ".txt"
def read_labels(img_path):
lp = label_path(img_path)
if not os.path.exists(lp):
return []
with open(lp) as f:
return [list(map(float, line.split())) for line in f if line.strip()]
train_imgs = sorted(glob.glob(os.path.join(data["train"], "*.jpg")))
example = train_imgs[0]
print(example)
print(open(label_path(example)).read())
datasets\african-wildlife\images\train\1 (10).jpg 0 0.410000 0.408750 0.400000 0.382500
COLOURS = ["#2563eb", "#f08a24", "#16a34a", "#dc2626"]
def show_labelled(paths, title, cols=3):
rows = (len(paths) + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(5 * cols, 4 * rows))
for ax, p in zip(axes.flat, paths):
im = Image.open(p)
W, H = im.size
ax.imshow(im)
for c, x, y, w, h in read_labels(p):
c = int(c)
ax.add_patch(plt.Rectangle(((x - w / 2) * W, (y - h / 2) * H), w * W, h * H,
fill=False, color=COLOURS[c], lw=2.5))
ax.text((x - w / 2) * W, (y - h / 2) * H, CLASS_NAMES[c], color="white",
backgroundcolor=COLOURS[c], fontsize=9)
ax.set_title(os.path.basename(p), fontsize=9)
for ax in axes.flat:
ax.axis("off")
fig.suptitle(title, fontsize=14)
plt.tight_layout()
plt.show()
show_labelled(random.sample(train_imgs, 6), "Training images with their labels (ground truth)")
Always look at your labels before training. Wrong boxes, missing boxes or wrong class IDs are the most common reason a model learns nothing useful.
Is the dataset balanced? Count the boxes per class:
counts = {name: 0 for name in CLASS_NAMES.values()}
for p in train_imgs:
for row in read_labels(p):
counts[CLASS_NAMES[int(row[0])]] += 1
plt.figure(figsize=(6, 3))
plt.bar(counts.keys(), counts.values(), color=COLOURS)
plt.title("Boxes per class (train)")
plt.show()
print(counts)
{'buffalo': 399, 'elephant': 558, 'rhino': 399, 'zebra': 575}
2. Before training: what does a pretrained model see?ΒΆ
yolo11n.pt was trained on COCO: 80 everyday classes. COCO has elephant and zebra, but no buffalo and no rhino. Let's see what it does with our test images.
test_imgs = sorted(glob.glob(os.path.join(data["test"], "*.jpg")))
sample = random.sample(test_imgs, 6)
coco_model = YOLO("yolo11n.pt") # downloads ~5 MB the first time
results = coco_model.predict(sample, conf=0.25, device=DEVICE, verbose=False)
def show_predictions(results, title, cols=3):
rows = (len(results) + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(5 * cols, 4 * rows))
for ax, r in zip(axes.flat, results):
ax.imshow(r.plot()[..., ::-1]) # r.plot() draws boxes; BGR -> RGB
found = [f"{r.names[int(c)]} {s:.2f}" for c, s in zip(r.boxes.cls, r.boxes.conf)]
ax.set_title(", ".join(found[:4]) or "nothing found", fontsize=9)
for ax in axes.flat:
ax.axis("off")
fig.suptitle(title, fontsize=14)
plt.tight_layout()
plt.show()
show_predictions(results, "Pretrained COCO model, no fine-tuning")
Elephants and zebras look fine (COCO has them). But look at the rhinos: they're labelled elephant, and buffalo get labelled as something else (often cow) or missed. The model can't output a class it was never taught, so it picks the closest thing it knows.
But its early layers already know edges, textures, legs and fur. Fine-tuning reuses all that and only has to learn our four classes. That's why it's fast.
3. Train (fine-tune)ΒΆ
This one call does everything: loads data, applies augmentation, trains, validates each epoch, and saves the best weights.
| Setting | Meaning |
|---|---|
epochs |
passes over the training set |
imgsz |
images are resized to this |
batch |
images per step (lower it if you run out of GPU memory) |
patience |
stop early if validation hasn't improved for this many epochs |
name |
folder name for this run, so runs don't overwrite each other |
EPOCHS = 20 # ~2-4 min on a modern GPU. Try 50+ for a better model. On CPU, use 3.
model = YOLO("yolo11n.pt") # start from pretrained weights
model.train(
data="african-wildlife.yaml",
epochs=EPOCHS,
imgsz=640,
batch=16,
patience=10,
device=DEVICE,
workers=2,
seed=0,
project=os.path.abspath("runs"),
name="wildlife_demo",
exist_ok=True,
plots=True,
)
RUN_DIR = os.path.abspath(os.path.join("runs", "wildlife_demo"))
print("Results saved in", RUN_DIR)
Ultralytics 8.4.211 Python-3.12.0 torch-2.11.0+cu128 CUDA:0 (NVIDIA GeForce RTX 5080, 16303MiB)
engine\trainer: agnostic_nms=False, amp=True, angle=1.0, augment=False, auto_augment=randaugment, batch=16, bgr=0.0, box=7.5, cache=False, cfg=None, channels_last=None, classes=None, close_mosaic=10, cls=0.5, cls_pw=0.0, cls_remap=True, compile=False, conf=None, copy_paste=0.0, copy_paste_mode=flip, cos_lr=False, cutmix=0.0, data=african-wildlife.yaml, degrees=0.0, deterministic=True, device=0, dfl=1.5, dgrad=0.5, dis=6.0, distill_model=None, dlam=1.0, dlog=1.0, dnn=False, dropout=0.0, dynamic=False, embed=None, epochs=20, erasing=0.4, exist_ok=True, fliplr=0.5, flipud=0.0, format=torchscript, fraction=1.0, freeze=None, hsv_h=0.015, hsv_s=0.7, hsv_v=0.4, imgsz=640, iou=None, kobj=1.0, line_width=None, lr0=0.01, lrf=0.01, mask_ratio=4, max_det=300, mixup=0.0, mode=train, model=yolo11n.pt, momentum=0.937, mosaic=1.0, multi_scale=0.0, name=wildlife_demo, nbs=64, nms=None, opset=None, optimize=False, optimizer=auto, overlap_mask=True, patience=10, perspective=0.0, plots=True, pose=12.0, pretrained=True, profile=False, project=runs, quantize=None, rect=False, resume=False, retina_masks=False, rle=1.0, save=True, save_conf=False, save_crop=False, save_dir=runs\wildlife_demo, save_frames=False, save_json=False, save_period=-1, save_txt=False, scale=0.5, seed=0, shear=0.0, show=False, show_boxes=True, show_conf=True, show_labels=True, simplify=True, single_cls=False, source=None, split=val, stream_buffer=False, task=detect, tile=False, time=None, tracker=tracktrack.yaml, translate=0.1, val=True, verbose=True, vid_stride=1, visualize=False, warmup_bias_lr=0.1, warmup_epochs=3.0, warmup_momentum=0.8, weight_decay=0.0005, workers=2, workspace=None
Overriding model.yaml nc=80 with nc=4
from n params module arguments
0 -1 1 464 ultralytics.nn.modules.conv.Conv [3, 16, 3, 2]
1 -1 1 4672 ultralytics.nn.modules.conv.Conv [16, 32, 3, 2]
2 -1 1 6640 ultralytics.nn.modules.block.C3k2 [32, 64, 1, False, 0.25]
3 -1 1 36992 ultralytics.nn.modules.conv.Conv [64, 64, 3, 2]
4 -1 1 26080 ultralytics.nn.modules.block.C3k2 [64, 128, 1, False, 0.25]
5 -1 1 147712 ultralytics.nn.modules.conv.Conv [128, 128, 3, 2]
6 -1 1 87040 ultralytics.nn.modules.block.C3k2 [128, 128, 1, True]
7 -1 1 295424 ultralytics.nn.modules.conv.Conv [128, 256, 3, 2]
8 -1 1 346112 ultralytics.nn.modules.block.C3k2 [256, 256, 1, True]
9 -1 1 164608 ultralytics.nn.modules.block.SPPF [256, 256, 5]
10 -1 1 249728 ultralytics.nn.modules.block.C2PSA [256, 256, 1]
11 -1 1 0 torch.nn.modules.upsampling.Upsample [None, 2, 'nearest']
12 [-1, 6] 1 0 ultralytics.nn.modules.conv.Concat [1]
13 -1 1 111296 ultralytics.nn.modules.block.C3k2 [384, 128, 1, False]
14 -1 1 0 torch.nn.modules.upsampling.Upsample [None, 2, 'nearest']
15 [-1, 4] 1 0 ultralytics.nn.modules.conv.Concat [1]
16 -1 1 32096 ultralytics.nn.modules.block.C3k2 [256, 64, 1, False]
17 -1 1 36992 ultralytics.nn.modules.conv.Conv [64, 64, 3, 2]
18 [-1, 13] 1 0 ultralytics.nn.modules.conv.Concat [1]
19 -1 1 86720 ultralytics.nn.modules.block.C3k2 [192, 128, 1, False]
20 -1 1 147712 ultralytics.nn.modules.conv.Conv [128, 128, 3, 2]
21 [-1, 10] 1 0 ultralytics.nn.modules.conv.Concat [1]
22 -1 1 378880 ultralytics.nn.modules.block.C3k2 [384, 256, 1, True]
23 [16, 19, 22] 1 431452 ultralytics.nn.modules.head.Detect [4, 16, None, [64, 128, 256]]
YOLO11n summary: 181 layers, 2,590,620 parameters, 2,590,604 gradients, 6.5 GFLOPs
Remapped 2/4 cls head rows from pretrained weights by class name
Transferred 499/499 items from pretrained weights
Freezing layer 'model.23.dfl.conv.weight'
AMP: running Automatic Mixed Precision (AMP) checks...
AMP: checks passed
train: Fast image access (ping: 0.0Β±0.0 ms, read: 686.0Β±218.4 MB/s, size: 54.6 KB)
train: Scanning datasets\african-wildlife\labels\train.cache... 1052 images, 0 backgrounds, 0 corrupt: 100% ββββββββββββ 1052/1052 0.0s
val: Fast image access (ping: 0.0Β±0.0 ms, read: 488.8Β±371.7 MB/s, size: 42.2 KB)
val: Scanning datasets\african-wildlife\labels\val.cache... 225 images, 0 backgrounds, 0 corrupt: 100% ββββββββββββ 225/225 0.0s
optimizer: 'optimizer=auto' found, ignoring 'lr0=0.01' and determining best 'optimizer' and 'lr0' automatically...
optimizer: AdamW(lr=0.00125, momentum=0.9) with parameter groups 81 weight(decay=0.0), 88 weight(decay=0.0005), 87 bias(decay=0.0)
Plotting labels to runs\wildlife_demo\labels.jpg...
Using 1052 train, 225 val images for fraction=1.0 at imgsz=640
Using 2 dataloader workers
Logging results to runs\wildlife_demo
Starting training for 20 epochs...
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
1/20 2.2G 0.8671 1.632 1.226 66 640: 100% ββββββββββββ 66/66 3.5it/s 18.7s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 2.1s/it 16.7s
all 225 379 0.686 0.685 0.709 0.462
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
2/20 2.96G 0.9106 1.05 1.246 77 640: 100% ββββββββββββ 66/66 12.3it/s 5.4s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.1it/s 0.9s
all 225 379 0.619 0.662 0.709 0.468
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
3/20 2.96G 0.9714 1.068 1.287 64 640: 100% ββββββββββββ 66/66 12.6it/s 5.3s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.4it/s 0.9s
all 225 379 0.608 0.608 0.666 0.445
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
4/20 2.96G 0.9681 1.002 1.282 52 640: 100% ββββββββββββ 66/66 12.2it/s 5.4s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.2it/s 0.9s
all 225 379 0.596 0.608 0.71 0.506
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
5/20 2.96G 0.9401 0.9837 1.264 49 640: 100% ββββββββββββ 66/66 12.3it/s 5.4s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.5it/s 0.8s
all 225 379 0.74 0.694 0.758 0.545
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
6/20 2.96G 0.9237 0.917 1.257 57 640: 100% ββββββββββββ 66/66 12.5it/s 5.3s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.1it/s 0.9s
all 225 379 0.772 0.718 0.779 0.542
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
7/20 2.96G 0.9293 0.9063 1.244 41 640: 100% ββββββββββββ 66/66 12.3it/s 5.4s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.0it/s 0.9s
all 225 379 0.734 0.677 0.755 0.563
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
8/20 2.96G 0.8857 0.8662 1.229 38 640: 100% ββββββββββββ 66/66 12.6it/s 5.3s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.6it/s 0.8s
all 225 379 0.822 0.607 0.757 0.56
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
9/20 2.96G 0.8462 0.8143 1.198 61 640: 100% ββββββββββββ 66/66 12.5it/s 5.3s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.2it/s 0.9s
all 225 379 0.784 0.757 0.834 0.648
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
10/20 2.96G 0.8231 0.7815 1.179 46 640: 100% ββββββββββββ 66/66 12.6it/s 5.2s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.4it/s 0.9s
all 225 379 0.849 0.791 0.885 0.699
Closing dataloader mosaic
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
11/20 2.96G 0.8093 0.7237 1.189 22 640: 100% ββββββββββββ 66/66 8.0it/s 8.2s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.6it/s 0.8s
all 225 379 0.78 0.658 0.751 0.559
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
12/20 2.96G 0.7438 0.6517 1.139 33 640: 100% ββββββββββββ 66/66 15.0it/s 4.4s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 10.3it/s 0.8s
all 225 379 0.878 0.813 0.897 0.708
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
13/20 2.96G 0.7089 0.6477 1.128 31 640: 100% ββββββββββββ 66/66 17.5it/s 3.8s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 11.8it/s 0.7s
all 225 379 0.909 0.823 0.917 0.727
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
14/20 2.96G 0.7019 0.5696 1.12 31 640: 100% ββββββββββββ 66/66 13.9it/s 4.8s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.5it/s 0.8s
all 225 379 0.908 0.863 0.929 0.752
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
15/20 2.96G 0.657 0.5347 1.092 20 640: 100% ββββββββββββ 66/66 14.8it/s 4.5s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.6it/s 0.8s
all 225 379 0.908 0.849 0.924 0.74
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
16/20 2.96G 0.6406 0.5044 1.06 14 640: 100% ββββββββββββ 66/66 14.5it/s 4.5s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 8.8it/s 0.9s
all 225 379 0.942 0.846 0.945 0.764
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
17/20 2.96G 0.6121 0.4575 1.04 20 640: 100% ββββββββββββ 66/66 14.9it/s 4.4s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.0it/s 0.9s
all 225 379 0.929 0.876 0.935 0.773
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
18/20 2.97G 0.5894 0.4332 1.029 23 640: 100% ββββββββββββ 66/66 14.8it/s 4.5s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.4it/s 0.9s
all 225 379 0.944 0.872 0.938 0.777
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
19/20 2.97G 0.5656 0.4145 1.008 27 640: 100% ββββββββββββ 66/66 14.8it/s 4.5s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 12.4it/s 0.6s
all 225 379 0.939 0.865 0.943 0.794
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size
20/20 2.97G 0.5574 0.3997 1.001 19 640: 100% ββββββββββββ 66/66 17.3it/s 3.8s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 12.1it/s 0.7s
all 225 379 0.939 0.886 0.946 0.794
20 epochs completed in 0.043 hours.
Optimizer stripped from runs\wildlife_demo\weights\last.pt, 5.5MB
Optimizer stripped from runs\wildlife_demo\weights\best.pt, 5.5MB
Validating runs\wildlife_demo\weights\best.pt...
Ultralytics 8.4.211 Python-3.12.0 torch-2.11.0+cu128 CUDA:0 (NVIDIA GeForce RTX 5080, 16303MiB)
YOLO11n summary (fused): 101 layers, 2,597,780 parameters, 0 gradients, 6.4 GFLOPs
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 8/8 9.0it/s 0.9s
all 225 379 0.939 0.865 0.943 0.794
buffalo 62 89 0.96 0.817 0.954 0.814
elephant 53 91 0.94 0.854 0.92 0.766
rhino 55 85 0.913 0.918 0.954 0.829
zebra 59 114 0.943 0.872 0.943 0.766
Speed: 0.1ms preprocess, 0.5ms inference, 0.0ms loss, 1.1ms postprocess per image
Results saved to runs\wildlife_demo
Results saved in runs\wildlife_demo
While it runs, watch the columns: box_loss, cls_loss and dfl_loss should go down. After each epoch it validates and prints P (precision), R (recall), mAP50 and mAP50-95.
with open(os.path.join(RUN_DIR, "results.csv")) as f:
rows = [{k.strip(): float(v) for k, v in r.items()} for r in csv.DictReader(f)]
ep = [r["epoch"] for r in rows]
fig, (a1, a2) = plt.subplots(1, 2, figsize=(13, 4))
a1.plot(ep, [r["train/box_loss"] for r in rows], label="train box loss", color="#2563eb")
a1.plot(ep, [r["val/box_loss"] for r in rows], label="val box loss", color="#f08a24")
a1.plot(ep, [r["train/cls_loss"] for r in rows], "--", label="train class loss", color="#2563eb")
a1.plot(ep, [r["val/cls_loss"] for r in rows], "--", label="val class loss", color="#f08a24")
a1.set_xlabel("epoch"); a1.set_title("Losses (lower is better)"); a1.legend()
a2.plot(ep, [r["metrics/mAP50(B)"] for r in rows], label="mAP50", color="#16a34a")
a2.plot(ep, [r["metrics/mAP50-95(B)"] for r in rows], label="mAP50-95", color="#7c3aed")
a2.plot(ep, [r["metrics/precision(B)"] for r in rows], ":", label="precision", color="#0f1b2d")
a2.plot(ep, [r["metrics/recall(B)"] for r in rows], ":", label="recall", color="#dc2626")
a2.set_xlabel("epoch"); a2.set_ylim(0, 1); a2.set_title("Validation metrics (higher is better)"); a2.legend()
plt.tight_layout(); plt.show()
How to read this (from the slides):
- Train and val losses both falling and levelling off β good fit.
- Val loss turning up while train loss keeps falling β overfitting.
best.ptkeeps the best epoch anyway. - Both still falling steeply at the end β underfitting: train for more epochs.
4b. Per-class scores on the validation setΒΆ
best = YOLO(os.path.join(RUN_DIR, "weights", "best.pt"))
metrics = best.val(data="african-wildlife.yaml", imgsz=640, device=DEVICE, verbose=False,
project=os.path.abspath("runs"), name="wildlife_demo_val", exist_ok=True)
print(f"{'class':<10} {'precision':>9} {'recall':>7} {'mAP50':>6} {'mAP50-95':>9}")
for i, c in enumerate(metrics.box.ap_class_index):
p, r, ap50, ap = metrics.box.class_result(i)
print(f"{CLASS_NAMES[int(c)]:<10} {p:9.3f} {r:7.3f} {ap50:6.3f} {ap:9.3f}")
print(f"{'ALL':<10} {metrics.box.mp:9.3f} {metrics.box.mr:7.3f} {metrics.box.map50:6.3f} {metrics.box.map:9.3f}")
Ultralytics 8.4.211 Python-3.12.0 torch-2.11.0+cu128 CUDA:0 (NVIDIA GeForce RTX 5080, 16303MiB)
YOLO11n summary (fused): 101 layers, 2,597,780 parameters, 0 gradients, 6.4 GFLOPs
val: Fast image access (ping: 0.0Β±0.0 ms, read: 881.8Β±818.8 MB/s, size: 99.8 KB)
val: Scanning datasets\african-wildlife\labels\val.cache... 225 images, 0 backgrounds, 0 corrupt: 100% ββββββββββββ 225/225 0.0s
Class Images Instances Box(P R mAP50 mAP50-95): 100% ββββββββββββ 15/15 1.2s/it 17.6s
all 225 379 0.936 0.87 0.941 0.79
Speed: 0.6ms preprocess, 2.1ms inference, 0.0ms loss, 1.1ms postprocess per image
Results saved to runs\wildlife_demo_val
class precision recall mAP50 mAP50-95
buffalo 0.961 0.820 0.947 0.808
elephant 0.940 0.857 0.921 0.767
rhino 0.902 0.929 0.953 0.824
zebra 0.943 0.875 0.942 0.760
ALL 0.936 0.870 0.941 0.790
- Precision: when it says "zebra", how often is it right? (low β false alarms)
- Recall: of all the zebras, how many did it find? (low β misses)
- mAP50: overall score, box counts if it overlaps the truth by IoU β₯ 0.5
- mAP50-95: stricter, also rewards tight boxes
4c. Confusion matrixΒΆ
Rows = what the model predicted, columns = the truth. Off-diagonal cells are mix-ups. The background row/column holds false alarms and misses.
cm = os.path.join(RUN_DIR, "confusion_matrix_normalized.png")
plt.figure(figsize=(8, 7)); plt.imshow(Image.open(cm)); plt.axis("off"); plt.show()
4d. Look at the pictures!ΒΆ
Ultralytics saves a validation batch with the true boxes and with the predicted boxes. Compare them side by side.
fig, (a1, a2) = plt.subplots(1, 2, figsize=(18, 9))
a1.imshow(Image.open(os.path.join(RUN_DIR, "val_batch0_labels.jpg"))); a1.set_title("Ground truth"); a1.axis("off")
a2.imshow(Image.open(os.path.join(RUN_DIR, "val_batch0_pred.jpg"))); a2.set_title("Predictions"); a2.axis("off")
plt.tight_layout(); plt.show()
5. Predictions on unseen test imagesΒΆ
The test split was never used for training or for choosing settings. This is our honest "final exam".
sample = random.sample(test_imgs, 9)
results = best.predict(sample, conf=0.25, device=DEVICE, verbose=False)
show_predictions(results, "Fine-tuned model on unseen test images")
Compare with the pretrained COCO model in section 2: it now knows buffalo and rhino, and its confidences are much higher.
The confidence threshold is a knob, not a retrainΒΆ
Same image, three thresholds. Lower = catch more but more false alarms. Higher = fewer false alarms but more misses.
crowded = max(test_imgs, key=lambda p: len(read_labels(p))) # image with the most animals
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
for ax, conf in zip(axes, (0.05, 0.25, 0.70)):
r = best.predict(crowded, conf=conf, device=DEVICE, verbose=False)[0]
ax.imshow(r.plot()[..., ::-1]); ax.axis("off")
ax.set_title(f"conf = {conf}: {len(r.boxes)} boxes (truth: {len(read_labels(crowded))})")
plt.tight_layout(); plt.show()
6. Find the failuresΒΆ
The most useful thing you can do after training: find the images it gets wrong, then collect more data like them. We compare each test prediction with the labels:
- miss: a true box with no matching prediction (same class, IoU β₯ 0.5)
- false alarm: a prediction that matches no true box
def iou(a, b):
# boxes as (x1, y1, x2, y2)
ix = max(0, min(a[2], b[2]) - max(a[0], b[0]))
iy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
inter = ix * iy
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / union if union else 0
failures = []
for r in best.predict(test_imgs, conf=0.25, device=DEVICE, verbose=False, stream=True):
H, W = r.orig_shape
truth = [(int(c), ((x - w/2) * W, (y - h/2) * H, (x + w/2) * W, (y + h/2) * H))
for c, x, y, w, h in read_labels(r.path)]
preds = [(int(c), b.tolist()) for c, b in zip(r.boxes.cls, r.boxes.xyxy)]
matched, false_alarms = set(), 0
for pc, pb in preds:
hit = next((i for i, (tc, tb) in enumerate(truth)
if i not in matched and tc == pc and iou(pb, tb) >= 0.5), None)
if hit is None:
false_alarms += 1
else:
matched.add(hit)
misses = len(truth) - len(matched)
if misses or false_alarms:
failures.append((misses + false_alarms, misses, false_alarms, r))
print(f"{len(failures)} of {len(test_imgs)} test images have at least one mistake")
print(f"total misses: {sum(f[1] for f in failures)}, total false alarms: {sum(f[2] for f in failures)}")
40 of 227 test images have at least one mistake total misses: 22, total false alarms: 30
worst = sorted(failures, key=lambda f: -f[0])[:6]
cols = 3
fig, axes = plt.subplots(2, cols, figsize=(18, 9))
for ax, (_, m, fa, r) in zip(axes.flat, worst):
ax.imshow(r.plot()[..., ::-1]); ax.axis("off")
ax.set_title(f"{os.path.basename(r.path)}: {m} missed, {fa} false alarm(s)", fontsize=10)
for ax in axes.flat[len(worst):]:
ax.axis("off")
fig.suptitle("Worst test images: what should we collect more of?", fontsize=14)
plt.tight_layout(); plt.show()
Look for patterns in the failures: very small or distant animals? Herds where animals overlap? Animals partly hidden by grass? Unusual lighting?
Notice the antelope labelled as zebra or buffalo. The model has only ever seen four kinds of animal, so anything else with four legs gets forced into one of them. That's exactly what background / negative images fix: show it scenes containing things that are not your classes.
Each pattern tells you what data to collect next. Then retrain and compare. That's the loop from the slides.
7. Try it yourselfΒΆ
Ideas to explore (change one thing at a time, and give each run its own name=):
- Train for more epochs (e.g. 60). Does mAP50-95 keep improving? Do the curves show overfitting?
- Try a bigger model:
YOLO("yolo11s.pt"). Better? How much slower ispredict? - Change an augmentation, e.g.
degrees=15,scale=0.8, ormosaic=0, and compare with the baseline. - Train from scratch:
YOLO("yolo11n.yaml")(no pretrained weights) for the same epochs. How much does transfer learning help?
model = YOLO("yolo11n.pt")
model.train(data="african-wildlife.yaml", epochs=EPOCHS, imgsz=640, batch=16,
device=DEVICE, name="wildlife_rot15", degrees=15)
Using the model in your own code takes three lines:
model = YOLO("runs/wildlife_demo/weights/best.pt")
r = model.predict("some_image.jpg", conf=0.25)[0]
for cls, conf, box in zip(r.boxes.cls, r.boxes.conf, r.boxes.xyxy):
print(r.names[int(cls)], float(conf), box.tolist()) # class, confidence, (x1, y1, x2, y2) in pixels