cone-distance / src /train /train.py
Aryan Sethi
Claude Opus 5 (1M context)
Drop barrel, add held-out stop-sign distance, add the GPU runbook
5d449ff
Raw History Blame Contribute Delete
8.93 kB
"""Fine-tune YOLO11 on the unified dataset.
YOLO11n is the primary arm and YOLO11s the second, which is the "lightweight
backbone comparison" for free. Both are trained by one invocation so the runs
sit side by side under the same project directory.
The training recipe itself is stock Ultralytics, identical to the reference
YOLO11 fine-tuning notebook: of the 105 resolved config keys, only `batch`,
`device`, `project`, `name` and `exist_ok` differ, and all 55 recipe keys --
optimiser, learning-rate schedule, loss gains, and every augmentation -- match.
This script is plumbing around `YOLO.train()`, not a modified training loop.
Reports per-class mAP, not just the mean. The mean hides the case this project
actually cares about: cones are the smallest class and stop signs come from a
different domain, so either can be quietly terrible while the average looks fine.
Note: Ultralytics YOLO11 weights are AGPL-3.0.
Usage:
python -m src.train.train --models yolo11n.pt yolo11s.pt --epochs 100
python -m src.train.train --wandb # + train/val loss and grad norm
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import pandas as pd
from src.common import paths, schema
def find_manifest(root: Path, data_yaml: Path) -> Path | None:
"""The manifest sits beside the data.yaml when pulled from the Hub, and
under the unified root when built locally. Try both."""
for candidate in (data_yaml.parent / "manifest.parquet", Path(root) / "manifest.parquet"):
if candidate.exists():
return candidate
return None
def print_class_balance(manifest_path: Path | None) -> None:
"""Instance counts before training, because the imbalance is severe.
Barriers are everywhere in nuScenes, stop signs come almost entirely from
COCO. Knowing the counts up front is what makes a
weak per-class mAP readable afterwards.
"""
if manifest_path is None:
print("(no manifest found, skipping the class balance report)")
return
# This is a diagnostic, not a prerequisite. A dataset pulled from the Hub is
# perfectly trainable without a readable manifest, so a failure here must not
# take the training run down with it.
try:
objects = schema.objects_only(schema.read_manifest(manifest_path))
print("\ntraining instances per class")
print(pd.crosstab(objects["split"], objects["class"]).to_string())
print()
except Exception as error: # noqa: BLE001 -- report and carry on
print(f"(could not read {manifest_path}: {error}; skipping the balance report)")
def print_per_class_metrics(metrics, names: dict[int, str]) -> None:
print("\n" + "-" * 62)
print(f"{'class':<14}{'images':>8}{'instances':>11}{'mAP50':>9}{'mAP50-95':>11}")
print("-" * 62)
for index, class_id in enumerate(metrics.box.ap_class_index):
precision, recall, ap50, ap = metrics.box.class_result(index)
print(f"{names.get(int(class_id), class_id):<14}{'':>8}{'':>11}"
f"{ap50:>9.3f}{ap:>11.3f}")
print("-" * 62)
print(f"{'all':<14}{'':>8}{'':>11}{metrics.box.map50:>9.3f}{metrics.box.map:>11.3f}")
print("-" * 62)
missing = [names[i] for i in names if i not in set(int(c) for c in metrics.box.ap_class_index)]
if missing:
print(f"!! no val instances for: {', '.join(missing)}")
# Ultralytics normalises the optimiser step to a nominal batch of 64 by
# accumulating gradients: accumulate = round(nbs / batch), and weight decay is
# rescaled by batch * accumulate / nbs to compensate. That compensation is exact
# only when the batch divides 64. At batch 37 -- the kind of number AutoBatch
# (batch=-1) picks from free GPU memory -- effective weight decay lands 15.6%
# above the reference recipe, and at 48 it lands 25% below.
NOMINAL_BATCH = 64
def weight_decay_scale(batch: int) -> float:
"""How far this batch size moves effective weight decay from the default."""
accumulate = max(round(NOMINAL_BATCH / batch), 1)
return batch * accumulate / NOMINAL_BATCH
def warn_if_batch_perturbs_recipe(batch: int) -> None:
if batch < 0:
print("!! --batch -1 lets AutoBatch pick from free GPU memory. It returns an")
print("!! arbitrary integer, so effective weight decay drifts from the")
print("!! reference recipe by as much as 25-56%. Prefer 16, 32 or 64.")
return
scale = weight_decay_scale(batch)
if abs(scale - 1.0) > 0.01:
print(f"!! batch={batch} does not divide {NOMINAL_BATCH}, so effective weight")
print(f"!! decay is {scale:.3f}x the configured value ({(scale - 1) * 100:+.1f}%).")
print(f"!! Use a divisor of {NOMINAL_BATCH} to match the stock recipe exactly.")
def train_one(model_name: str, data_yaml: Path, args) -> None:
from ultralytics import YOLO
print("=" * 62)
print(f"training {model_name}")
print("=" * 62)
model = YOLO(model_name)
close_logger = None
if args.wandb:
from src.train import wandb_logger
close_logger = wandb_logger.attach(
model,
project=args.wandb_project,
run_name=f"{Path(model_name).stem}-{args.epochs}ep",
extra_config={"dataset": str(data_yaml)},
log_grad_norm=args.log_grad_norm,
)
print(f"logging to W&B project {args.wandb_project!r}"
+ (" (with gradient norm)" if args.log_grad_norm else ""))
try:
_run_training(model, model_name, data_yaml, args)
finally:
# Ultralytics fires on_train_end only on success, and the gradient-norm
# probe patches a torch function that must be put back either way --
# otherwise a crash training the first model leaves a wrapper in place
# for the second.
if close_logger is not None:
close_logger()
metrics = model.val(data=str(data_yaml), imgsz=args.imgsz, device=args.device)
print_per_class_metrics(metrics, model.names)
print(f"\nweights: {args.project}/{Path(model_name).stem}/weights/best.pt")
def _run_training(model, model_name: str, data_yaml: Path, args) -> None:
model.train(
data=str(data_yaml),
epochs=args.epochs,
imgsz=args.imgsz,
batch=args.batch,
device=args.device,
workers=args.workers,
project=str(args.project),
name=f"{Path(model_name).stem}",
exist_ok=True,
seed=args.seed,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--unified-root", type=Path, default=None)
parser.add_argument("--data", type=Path, default=None,
help="defaults to <unified-root>/yolo/data.yaml")
parser.add_argument("--models", nargs="+", default=["yolo11n.pt", "yolo11s.pt"])
parser.add_argument("--epochs", type=int, default=100)
parser.add_argument("--imgsz", type=int, default=640)
# Ultralytics' own default, and what the reference notebook trains with.
# Divides NOMINAL_BATCH, so the optimiser recipe matches exactly.
parser.add_argument("--batch", type=int, default=16)
parser.add_argument("--device", default="0")
parser.add_argument("--workers", type=int, default=8)
parser.add_argument("--project", type=Path, default=Path("runs/detect"))
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--wandb", action="store_true",
help="log train/val loss and gradient norm to Weights & Biases; "
"authenticate with $WANDB_API_KEY")
parser.add_argument("--wandb-project", default="cone-distance")
parser.add_argument("--log-grad-norm", action="store_true",
help="also log gradient norm. Needs --wandb. Unlike the loss "
"callbacks, this patches torch.nn.utils.clip_grad_norm_ "
"for the duration of training, because Ultralytics "
"discards the value and fires no callback while the "
"gradients are still live.")
args = parser.parse_args()
if args.wandb and not os.environ.get("WANDB_API_KEY"):
print("!! --wandb given but $WANDB_API_KEY is not set; wandb will prompt "
"or fall back to offline mode")
root = paths.unified_root(args.unified_root)
data_yaml = args.data or (root / "yolo" / "data.yaml")
if not data_yaml.exists():
raise SystemExit(f"{data_yaml} does not exist; run src.data.to_yolo first")
warn_if_batch_perturbs_recipe(args.batch)
print_class_balance(find_manifest(root, data_yaml))
for model_name in args.models:
train_one(model_name, data_yaml, args)
if __name__ == "__main__":
main()