Spaces:
Runtime error
Runtime error
File size: 10,860 Bytes
2ad33c7 266d37e 2ad33c7 266d37e 2ad33c7 266d37e 2ad33c7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | """Continue-train the make/model identifier on a larger dataset (Phase 6).
Warm-starts from the existing identifier checkpoint, swaps the final head to the
new label space, and two-stage fine-tunes on a bigger dataset (CompCars by
default) at a lower LR. Reuses the exact epoch loop + MixUp/CutMix recipe from
:mod:`ccdp.train.train_car_identifier` so behaviour matches the original trainer.
What transfers vs. re-inits (see progress/phase_5-8_plan.md):
- **transfer:** full ResNet-50 backbone + the ``Linear(2048->512)`` embedding.
- **re-init:** only the final ``Linear(512->N)`` for the new class count.
An optional make-level *forgetting anchor* checks, after training, that the model
still recognises Stanford-Cars makes (a catastrophic-forgetting proxy).
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Optional
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from ccdp.data import compcars
from ccdp.models.identifier import build_resnet50_identifier, n_trainable, set_finetune_stage
from ccdp.registry import create_run, load_checkpoint, save_checkpoint, update_metrics
from ccdp.train.train_car_identifier import TrainConfig, _run_epoch
from ccdp.utils import eval_transform, pick_device, seed_everything, train_transform
@dataclass
class ContinueConfig:
base_checkpoint: Optional[str] = None # defaults to production identifier
epochs_stage1: int = 2
epochs_stage2: int = 8
batch_size: int = 64
lr_stage1: float = 5e-4 # lower than scratch — gentle continue
lr_stage2: float = 5e-5
weight_decay: float = 1e-4
num_workers: int = 2
image_size: int = 224
val_fraction: float = 0.1
seed: int = 42
tag: str = "identifier_compcars"
anchor_eval: bool = True # make-level forgetting check on Stanford
resume_from: Optional[str] = None # path to epoch_NNN.pt / last.pt to resume
resume_run_dir: Optional[str] = None # reuse existing run dir instead of creating new
def _swap_head(model: nn.Module, new_num_classes: int) -> None:
"""Re-initialise only the final Linear(512 -> N) for the new label space."""
final = model.fc[-1]
in_features = final.in_features
model.fc[-1] = nn.Linear(in_features, new_num_classes)
def _load_warm_start(base_ckpt: Path, new_num_classes: int, device) -> nn.Module:
ck = load_checkpoint(base_ckpt, map_location=str(device))
old_classes = int(ck.get("num_classes") or 196)
model = build_resnet50_identifier(num_classes=old_classes, pretrained=False)
model.load_state_dict(ck["model"])
_swap_head(model, new_num_classes)
return model.to(device)
def make_level_anchor_accuracy(model, class_names, device, max_samples: int = 500) -> Optional[float]:
"""Top-1 *make* accuracy on Stanford-Cars val — a forgetting proxy.
Returns None when Stanford Cars isn't available locally. The new head predicts
CompCars models, so we compare only the *make* token of the predicted class
name against Stanford's ground-truth make.
"""
try:
from ccdp.data import stanford_cars as sc
classes = {c.class_id: c for c in sc.load_classes()}
samples = sc.load_train_samples()
_, val = sc.split_train_val(samples, val_fraction=0.1, seed=42)
except Exception: # noqa: BLE001
return None
if not val or not class_names:
return None
pred_make = [n.split()[0] if n else "" for n in class_names]
tfm = eval_transform(224)
model.eval()
correct, total = 0, 0
from PIL import Image
with torch.no_grad():
for s in val[:max_samples]:
try:
img = Image.open(s.image_path).convert("RGB").crop(s.bbox)
except Exception: # noqa: BLE001
continue
x = tfm(img).unsqueeze(0).to(device)
idx = int(model(x).argmax(1).item())
gt_make = classes[s.class_id].make
if 0 <= idx < len(pred_make) and pred_make[idx] == gt_make:
correct += 1
total += 1
return (correct / total) if total else None
def _build_loaders(cfg: ContinueConfig, dataset=compcars):
classes = dataset.load_classes()
samples = dataset.load_train_samples()
train_samples, val_samples = dataset.split_train_val(
samples, val_fraction=cfg.val_fraction, seed=cfg.seed,
)
train_tfm = train_transform(image_size=cfg.image_size)
val_tfm = eval_transform(cfg.image_size)
train_ds = dataset.build_torch_dataset(train_samples, train_tfm)
val_ds = dataset.build_torch_dataset(val_samples, val_tfm)
train_loader = DataLoader(train_ds, batch_size=cfg.batch_size, shuffle=True,
num_workers=cfg.num_workers, persistent_workers=cfg.num_workers > 0)
val_loader = DataLoader(val_ds, batch_size=cfg.batch_size, shuffle=False,
num_workers=cfg.num_workers, persistent_workers=cfg.num_workers > 0)
return classes, train_loader, val_loader
def train(
cfg: ContinueConfig,
dataset=compcars,
training_catalog_id: Optional[str] = None,
smoke_batches: Optional[int] = None,
) -> Path:
from ccdp.registry import production_target
seed_everything(cfg.seed)
device = pick_device()
print(f"[device] {device}")
classes, train_loader, val_loader = _build_loaders(cfg, dataset)
num_classes = len(classes)
class_names = [c.raw_name for c in classes]
print(f"[data] {num_classes} classes, train≈{len(train_loader)}, val≈{len(val_loader)}")
base_ckpt = Path(cfg.base_checkpoint) if cfg.base_checkpoint else production_target("identifier")
if base_ckpt is None or not Path(base_ckpt).exists():
raise FileNotFoundError(
"No base identifier checkpoint. Pass --base-checkpoint or promote one."
)
model = _load_warm_start(Path(base_ckpt), num_classes, device)
set_finetune_stage(model, 1)
print(f"[warm-start] {base_ckpt} -> head swapped to {num_classes} classes; "
f"stage 1 trainable {n_trainable(model):,}")
# epoch-loop config (reuses train_car_identifier recipe: MixUp/CutMix/smoothing)
loop_cfg = TrainConfig(image_size=cfg.image_size, seed=cfg.seed)
optimizer = optim.AdamW([p for p in model.parameters() if p.requires_grad],
lr=cfg.lr_stage1, weight_decay=cfg.weight_decay)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=2, factor=0.5)
# ---- resume / fresh run ------------------------------------------------
start_epoch = 1
best_val, stage = 0.0, 1
if cfg.resume_from:
resume_path = Path(cfg.resume_from)
if not resume_path.exists():
raise FileNotFoundError(f"--resume-from path not found: {resume_path}")
ck = load_checkpoint(resume_path, map_location=str(device))
# Restore weights over the freshly head-swapped model. Class count must match.
ckpt_classes = int(ck.get("num_classes") or num_classes)
if ckpt_classes != num_classes:
raise ValueError(
f"resume checkpoint has {ckpt_classes} classes but current dataset has "
f"{num_classes}. Refusing to resume across different label spaces.",
)
model.load_state_dict(ck["model"])
start_epoch = int(ck.get("epoch", 0)) + 1
stage = int(ck.get("stage", 1))
best_val = float(ck.get("best_val", 0.0))
# If we resume already inside stage 2, replicate the unfreeze + LR jump that
# would have happened at the stage boundary.
if stage == 2:
set_finetune_stage(model, 2)
optimizer = optim.AdamW([p for p in model.parameters() if p.requires_grad],
lr=cfg.lr_stage2, weight_decay=cfg.weight_decay)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=2, factor=0.5)
print(f"[resume] {resume_path} -> start at epoch {start_epoch} "
f"(stage {stage}, best_val {best_val:.4f})")
if cfg.resume_run_dir:
run_dir = Path(cfg.resume_run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
print(f"[resume] reusing run dir {run_dir}")
else:
run_dir = create_run(
variant="identifier", tag=cfg.tag, training_catalog_id=training_catalog_id,
notes=f"Continue-train identifier on {dataset.__name__} ({num_classes} classes)",
)
(run_dir / "config.yaml").write_text("\n".join(f"{k}: {v}" for k, v in asdict(cfg).items()))
total_epochs = cfg.epochs_stage1 + cfg.epochs_stage2
for epoch in range(start_epoch, total_epochs + 1):
if epoch == cfg.epochs_stage1 + 1 and stage == 1:
stage = 2
set_finetune_stage(model, 2)
optimizer = optim.AdamW([p for p in model.parameters() if p.requires_grad],
lr=cfg.lr_stage2, weight_decay=cfg.weight_decay)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=2, factor=0.5)
print(f"[stage 2] unfreeze layer3/layer4, trainable {n_trainable(model):,}")
print(f"\n[epoch {epoch}/{total_epochs}] stage={stage} lr={optimizer.param_groups[0]['lr']:.2e}")
train_loss, train_acc = _run_epoch(model, train_loader, optimizer, device,
train=True, num_classes=num_classes,
cfg=loop_cfg, max_batches=smoke_batches)
val_loss, val_acc = _run_epoch(model, val_loader, None, device, train=False,
num_classes=num_classes, cfg=loop_cfg,
max_batches=smoke_batches)
scheduler.step(val_loss)
is_best = val_acc > best_val
if is_best:
best_val = val_acc
save_checkpoint(run_dir, {
"model": model.state_dict(), "epoch": epoch, "stage": stage,
"best_val": best_val, "num_classes": num_classes,
"class_names": class_names, "config": asdict(cfg),
}, epoch=epoch, is_best=is_best)
update_metrics(run_dir.name.replace("run_", ""), {
f"epoch_{epoch}": {"stage": stage, "train_acc": train_acc, "val_acc": val_acc},
"best_val_acc": best_val,
})
if cfg.anchor_eval:
anchor = make_level_anchor_accuracy(model, class_names, device)
if anchor is not None:
print(f"[anchor] Stanford make-level accuracy: {anchor:.3f}")
update_metrics(run_dir.name.replace("run_", ""), {"anchor_make_acc": anchor})
print(f"\n[done] best val acc: {best_val:.4f} -> {run_dir / 'best.pt'}")
return run_dir / "best.pt"
|