import argparse import json import time from pathlib import Path import torch import torch.nn as nn import yaml from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR from tqdm import tqdm from src.dataset import build_dataloaders from src.model import build_model from src.utils import seed_everything def _top5(logits: torch.Tensor, labels: torch.Tensor) -> float: k = min(5, logits.size(1)) _, top = logits.topk(k, dim=1) return top.eq(labels.view(-1, 1).expand_as(top)).any(1).float().mean().item() class EarlyStopping: def __init__(self, patience: int): self.patience, self.best, self.count = patience, float("inf"), 0 def __call__(self, val_loss: float) -> bool: if val_loss < self.best - 1e-4: self.best, self.count = val_loss, 0 else: self.count += 1 return self.count >= self.patience def _run_epoch(model, loader, criterion, device, optimizer=None, scaler=None): training = optimizer is not None model.train(training) total_loss = correct = top5_sum = n = 0 for images, labels in tqdm(loader, desc="train" if training else "val ", leave=False): images, labels = images.to(device), labels.to(device) if training: optimizer.zero_grad() if scaler: with torch.cuda.amp.autocast(): logits = model(images) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() else: logits = model(images) loss = criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() else: with torch.no_grad(): logits = model(images) loss = criterion(logits, labels) total_loss += loss.item() correct += (logits.detach().argmax(1) == labels).sum().item() top5_sum += _top5(logits.detach(), labels) * labels.size(0) n += labels.size(0) return total_loss / len(loader), correct / n, top5_sum / n def train(config_path: str = "configs/config.yaml"): with open(config_path) as f: config = yaml.safe_load(f) seed_everything(42) device = ( "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu" ) print(f"Device: {device}") train_loader, val_loader, classes = build_dataloaders(config) config["model"]["num_classes"] = len(classes) print(f"Classes: {len(classes)}") model = build_model(config).to(device) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = AdamW(model.parameters(), lr=config["training"]["lr"], weight_decay=config["training"]["weight_decay"]) scheduler = CosineAnnealingLR(optimizer, T_max=config["training"]["epochs"]) stopper = EarlyStopping(config["training"]["early_stopping_patience"]) scaler = torch.cuda.amp.GradScaler() if device == "cuda" else None use_wandb = config["logging"]["use_wandb"] if use_wandb: import wandb wandb.init(project=config["logging"]["project"], config=config) ckpt_dir = Path(config["paths"]["checkpoints"]) ckpt_dir.mkdir(parents=True, exist_ok=True) (ckpt_dir / "classes.json").write_text(json.dumps(classes)) best_acc = 0.0 for epoch in range(1, config["training"]["epochs"] + 1): t0 = time.time() tr_loss, tr_acc, _ = _run_epoch(model, train_loader, criterion, device, optimizer, scaler) vl_loss, vl_acc, vl_top5 = _run_epoch(model, val_loader, criterion, device) scheduler.step() print( f"[{epoch:03d}] train loss={tr_loss:.4f} acc={tr_acc:.3f} | " f"val loss={vl_loss:.4f} acc={vl_acc:.3f} top5={vl_top5:.3f} | " f"{time.time() - t0:.0f}s" ) if use_wandb: import wandb wandb.log({"train/loss": tr_loss, "train/acc": tr_acc, "val/loss": vl_loss, "val/acc": vl_acc, "val/top5": vl_top5}) if vl_acc > best_acc: best_acc = vl_acc torch.save( {"epoch": epoch, "model_state_dict": model.state_dict(), "config": config, "val_acc": vl_acc}, ckpt_dir / "best.pt", ) print(f" ✓ saved best (val_acc={vl_acc:.4f})") if stopper(vl_loss): print(f"Early stopping at epoch {epoch}") break if use_wandb: import wandb wandb.finish() print(f"\nBest val accuracy: {best_acc:.4f}") if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--config", default="configs/config.yaml") args = parser.parse_args() train(args.config)