"""Fine-tune two pre-trained backbones on the Food-101 subset and compare them. Models: - ResNet18 (richer features, larger) - MobileNetV3 (deployment-friendly, faster on CPU) The best model (by validation top-1 accuracy) is persisted along with the class list. Designed to run in ~10-20 minutes on a single GPU; falls back to CPU automatically with a reduced default epoch count. Usage: python -m src.cv.train --epochs 5 --batch 64 """ from __future__ import annotations import argparse import json import random import sys import time from pathlib import Path import numpy as np import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import models, transforms from torchvision.datasets import ImageFolder sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from src.config import ( # noqa: E402 CV_CLASSES_PATH, CV_METRICS_PATH, CV_MODEL_PATH, MODELS_DIR, PROCESSED_DIR, ) CV_OUT = PROCESSED_DIR / "cv" IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] # Same fixed seed as the ML block (src/ml/train.py) for cross-block consistency. RANDOM_SEED = 42 def set_seed(seed: int = RANDOM_SEED) -> None: """Make training as reproducible as the framework allows.""" random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # warn_only: some torchvision ops have no deterministic CPU kernel; we keep # going instead of hard-failing so the run still completes. torch.use_deterministic_algorithms(True, warn_only=True) def _seed_worker(worker_id: int) -> None: worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) def build_transforms() -> tuple[transforms.Compose, transforms.Compose]: train_tf = transforms.Compose( [ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ] ) eval_tf = transforms.Compose( [ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ] ) return train_tf, eval_tf def build_model(name: str, num_classes: int) -> nn.Module: if name == "resnet18": model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) return model if name == "mobilenet_v3_small": model = models.mobilenet_v3_small( weights=models.MobileNet_V3_Small_Weights.IMAGENET1K_V1 ) in_feat = model.classifier[-1].in_features model.classifier[-1] = nn.Linear(in_feat, num_classes) return model raise ValueError(f"unknown model {name}") @torch.no_grad() def evaluate(model: nn.Module, loader: DataLoader, device: torch.device) -> dict: model.eval() top1 = top5 = total = 0 for x, y in loader: x = x.to(device) y = y.to(device) logits = model(x) _, pred1 = logits.topk(1, dim=1) _, pred5 = logits.topk(min(5, logits.size(1)), dim=1) top1 += (pred1.squeeze(1) == y).sum().item() top5 += pred5.eq(y.unsqueeze(1)).any(dim=1).sum().item() total += y.size(0) return {"top1": top1 / max(total, 1), "top5": top5 / max(total, 1), "n": total} def train_one( name: str, train_loader: DataLoader, val_loader: DataLoader, num_classes: int, epochs: int, device: torch.device, lr: float = 3e-4, ) -> tuple[nn.Module, dict]: model = build_model(name, num_classes).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) best_val = 0.0 best_state = None history: list[dict] = [] for epoch in range(1, epochs + 1): model.train() start = time.time() running = 0.0 seen = 0 for x, y in train_loader: x = x.to(device) y = y.to(device) optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() optimizer.step() running += loss.item() * x.size(0) seen += x.size(0) train_loss = running / max(seen, 1) val = evaluate(model, val_loader, device) history.append( { "epoch": epoch, "train_loss": train_loss, "val_top1": val["top1"], "val_top5": val["top5"], "duration_s": time.time() - start, } ) print( f"[{name}] epoch {epoch:>2} loss={train_loss:.3f} " f"val_top1={val['top1']:.3f} val_top5={val['top5']:.3f}" ) if val["top1"] > best_val: best_val = val["top1"] best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()} if best_state is not None: model.load_state_dict(best_state) return model, {"name": name, "best_val_top1": best_val, "history": history} def main() -> None: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--epochs", type=int, default=4) p.add_argument("--batch", type=int, default=64) p.add_argument("--workers", type=int, default=2) args = p.parse_args() set_seed(RANDOM_SEED) print(f"[cv.train] seed: {RANDOM_SEED}") train_dir = CV_OUT / "train" val_dir = CV_OUT / "val" test_dir = CV_OUT / "test" if not train_dir.exists(): raise FileNotFoundError( "Training images missing. Run 'python -m src.cv.prepare_data' first." ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"[cv.train] device: {device}") train_tf, eval_tf = build_transforms() train_ds = ImageFolder(train_dir, transform=train_tf) val_ds = ImageFolder(val_dir, transform=eval_tf) test_ds = ImageFolder(test_dir, transform=eval_tf) classes = train_ds.classes print(f"[cv.train] classes ({len(classes)}): {classes}") loader_generator = torch.Generator() loader_generator.manual_seed(RANDOM_SEED) train_loader = DataLoader( train_ds, batch_size=args.batch, shuffle=True, num_workers=args.workers, worker_init_fn=_seed_worker, generator=loader_generator, ) val_loader = DataLoader( val_ds, batch_size=args.batch, shuffle=False, num_workers=args.workers ) test_loader = DataLoader( test_ds, batch_size=args.batch, shuffle=False, num_workers=args.workers ) results: list[dict] = [] best_model: nn.Module | None = None best_name = "" best_metric = -1.0 for name in ("resnet18", "mobilenet_v3_small"): model, hist = train_one( name, train_loader, val_loader, len(classes), args.epochs, device ) test_metrics = evaluate(model, test_loader, device) record = {**hist, "test": test_metrics} results.append(record) print( f"[cv.train] {name} test_top1={test_metrics['top1']:.3f}" f" test_top5={test_metrics['top5']:.3f}" ) if test_metrics["top1"] > best_metric: best_metric = test_metrics["top1"] best_model = model best_name = name MODELS_DIR.mkdir(parents=True, exist_ok=True) assert best_model is not None torch.save( {"state_dict": best_model.state_dict(), "model_name": best_name, "classes": classes}, CV_MODEL_PATH, ) CV_CLASSES_PATH.write_text(json.dumps(classes, indent=2)) CV_METRICS_PATH.write_text( json.dumps({"results": results, "best": best_name}, indent=2) ) print(f"[cv.train] saved best model {best_name} -> {CV_MODEL_PATH}") if __name__ == "__main__": main()