| |
| """ |
| train_nexar_head.py |
| ═══════════════════════════════════════════════════════════════════════════════ |
| Train a binary collision-risk head on cached Qwen3-VL-4B CoT+BeliefToken |
| per-frame visual features (Nexar-only). |
| |
| Input caches (from make_nexar_belief_cache.py): |
| data/belief_cache_nexar_qwen3vl4b/train.pt |
| data/belief_cache_nexar_qwen3vl4b/val.pt |
| |
| Cache layout: |
| beliefs_frame [N, T, D] fp16 |
| valid_frames [N, T] bool |
| beliefs_text [N, D] fp16 |
| labels [N] int64 (0 safe / 1 collision) |
| meta dict (video_ids, hidden_dim, n_frames, ...) |
| |
| Head: |
| A small Transformer encoder over the T frame embeddings + a mean-pool |
| over valid frames, followed by a 2-layer MLP classifier. Keeps the |
| temporal axis (LKAlert's main inductive bias) but stays tiny so 1200 clips |
| don't overfit. |
| |
| Outputs: |
| checkpoints/Nexar/qwen3vl4b_head/best.pt (head weights + meta) |
| checkpoints/Nexar/qwen3vl4b_head/train_log.json |
| |
| Usage |
| ───── |
| python -m training.Policy.train_nexar_head \ |
| --train_cache data/belief_cache_nexar_qwen3vl4b/train.pt \ |
| --val_cache data/belief_cache_nexar_qwen3vl4b/val.pt \ |
| --out_dir checkpoints/Nexar/qwen3vl4b_head \ |
| --epochs 30 --batch_size 64 --lr 3e-4 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| import math |
| from pathlib import Path |
| from typing import Dict |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from torch.utils.data import DataLoader, TensorDataset |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| logger = logging.getLogger("Policy.train_nexar_head") |
|
|
|
|
| def binary_ap(y_true: np.ndarray, y_score: np.ndarray) -> float: |
| from sklearn.metrics import average_precision_score |
| if (y_true == 1).sum() == 0 or (y_true == 0).sum() == 0: |
| return float("nan") |
| return float(average_precision_score(y_true, y_score)) |
|
|
|
|
| def binary_auc(y_true: np.ndarray, y_score: np.ndarray) -> float: |
| from sklearn.metrics import roc_auc_score |
| if (y_true == 1).sum() == 0 or (y_true == 0).sum() == 0: |
| return float("nan") |
| return float(roc_auc_score(y_true, y_score)) |
|
|
|
|
| class NexarHead(nn.Module): |
| """Temporal encoder over T frame beliefs + binary classifier.""" |
|
|
| def __init__(self, hidden_dim: int, proj_dim: int = 512, |
| n_layers: int = 2, n_heads: int = 8, dropout: float = 0.2): |
| super().__init__() |
| self.proj = nn.Linear(hidden_dim, proj_dim) |
| enc_layer = nn.TransformerEncoderLayer( |
| d_model=proj_dim, nhead=n_heads, dim_feedforward=proj_dim * 4, |
| dropout=dropout, activation="gelu", batch_first=True, norm_first=True, |
| ) |
| self.encoder = nn.TransformerEncoder(enc_layer, num_layers=n_layers) |
| self.cls = nn.Sequential( |
| nn.LayerNorm(proj_dim), |
| nn.Linear(proj_dim, proj_dim), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(proj_dim, 1), |
| ) |
|
|
| def forward(self, frames: torch.Tensor, valid: torch.Tensor) -> torch.Tensor: |
| """ |
| frames: [B, T, D] fp |
| valid: [B, T] bool |
| returns: [B] logit |
| """ |
| x = self.proj(frames) |
| key_padding_mask = ~valid |
| x = self.encoder(x, src_key_padding_mask=key_padding_mask) |
| denom = valid.sum(dim=1, keepdim=True).clamp(min=1).float() |
| pooled = (x * valid.unsqueeze(-1).float()).sum(dim=1) / denom |
| return self.cls(pooled).squeeze(-1) |
|
|
|
|
| def load_cache(path: str | Path): |
| d = torch.load(path, map_location="cpu", weights_only=False) |
| return d |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser("train_nexar_head") |
| ap.add_argument("--train_cache", required=True) |
| ap.add_argument("--val_cache", required=True) |
| ap.add_argument("--out_dir", required=True) |
| ap.add_argument("--proj_dim", type=int, default=512) |
| ap.add_argument("--n_layers", type=int, default=2) |
| ap.add_argument("--n_heads", type=int, default=8) |
| ap.add_argument("--dropout", type=float, default=0.2) |
| ap.add_argument("--lr", type=float, default=3e-4) |
| ap.add_argument("--weight_decay", type=float, default=1e-4) |
| ap.add_argument("--epochs", type=int, default=30) |
| ap.add_argument("--batch_size", type=int, default=64) |
| ap.add_argument("--warmup_frac", type=float, default=0.1) |
| ap.add_argument("--pos_weight", type=float, default=0.0, |
| help=">0 overrides auto-balance; 0 = auto (neg/pos ratio)") |
| ap.add_argument("--patience", type=int, default=8, |
| help="Early stop after N epochs w/o val AP improvement") |
| ap.add_argument("--seed", type=int, default=0) |
| args = ap.parse_args() |
|
|
| torch.manual_seed(args.seed) |
| np.random.seed(args.seed) |
| out_dir = Path(args.out_dir) |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| logger.info(f"loading train cache: {args.train_cache}") |
| tr = load_cache(args.train_cache) |
| logger.info(f"loading val cache: {args.val_cache}") |
| va = load_cache(args.val_cache) |
|
|
| D = int(tr["meta"]["hidden_dim"]) |
| T = int(tr["meta"]["n_frames"]) |
| assert va["meta"]["hidden_dim"] == D and va["meta"]["n_frames"] == T, \ |
| "train/val cache hidden_dim or n_frames mismatch" |
|
|
| tr_x = tr["beliefs_frame"].float() |
| tr_v = tr["valid_frames"].bool() |
| tr_y = tr["labels"].long() |
| va_x = va["beliefs_frame"].float() |
| va_v = va["valid_frames"].bool() |
| va_y = va["labels"].long() |
|
|
| |
| tr_keep = tr_y >= 0; va_keep = va_y >= 0 |
| tr_x, tr_v, tr_y = tr_x[tr_keep], tr_v[tr_keep], tr_y[tr_keep] |
| va_x, va_v, va_y = va_x[va_keep], va_v[va_keep], va_y[va_keep] |
|
|
| n_pos = int((tr_y == 1).sum()); n_neg = int((tr_y == 0).sum()) |
| logger.info(f"train: {len(tr_y)} (pos={n_pos} neg={n_neg})") |
| logger.info(f"val: {len(va_y)} (pos={int((va_y==1).sum())} neg={int((va_y==0).sum())})") |
|
|
| pos_weight_val = args.pos_weight if args.pos_weight > 0 else (n_neg / max(n_pos, 1)) |
| pos_weight = torch.tensor([pos_weight_val], dtype=torch.float32, device="cuda") |
| logger.info(f"pos_weight = {pos_weight_val:.3f}") |
|
|
| tr_ds = TensorDataset(tr_x, tr_v, tr_y) |
| va_ds = TensorDataset(va_x, va_v, va_y) |
| tr_dl = DataLoader(tr_ds, batch_size=args.batch_size, shuffle=True, |
| num_workers=0, pin_memory=True, drop_last=False) |
| va_dl = DataLoader(va_ds, batch_size=args.batch_size, shuffle=False, |
| num_workers=0, pin_memory=True, drop_last=False) |
|
|
| model = NexarHead(hidden_dim=D, proj_dim=args.proj_dim, |
| n_layers=args.n_layers, n_heads=args.n_heads, |
| dropout=args.dropout).to("cuda") |
| logger.info(f"params: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M") |
|
|
| opt = torch.optim.AdamW(model.parameters(), lr=args.lr, |
| weight_decay=args.weight_decay, betas=(0.9, 0.999)) |
| total_steps = args.epochs * len(tr_dl) |
| warmup_steps = max(1, int(total_steps * args.warmup_frac)) |
|
|
| def lr_at(step): |
| if step < warmup_steps: |
| return step / warmup_steps |
| p = (step - warmup_steps) / max(1, total_steps - warmup_steps) |
| return 0.5 * (1 + math.cos(math.pi * p)) |
|
|
| sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_at) |
|
|
| log = {"epochs": []} |
| best_ap = -1.0; best_state = None; best_epoch = -1; no_imp = 0 |
|
|
| for epoch in range(args.epochs): |
| model.train() |
| tr_loss_sum = 0.0; n = 0 |
| for xb, vb, yb in tr_dl: |
| xb = xb.to("cuda", non_blocking=True) |
| vb = vb.to("cuda", non_blocking=True) |
| yb = yb.to("cuda", non_blocking=True).float() |
| logits = model(xb, vb) |
| loss = F.binary_cross_entropy_with_logits(logits, yb, pos_weight=pos_weight) |
| opt.zero_grad(set_to_none=True) |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) |
| opt.step(); sched.step() |
| tr_loss_sum += float(loss.item()) * xb.size(0); n += xb.size(0) |
| tr_loss = tr_loss_sum / max(n, 1) |
|
|
| model.eval() |
| va_logits = []; va_labels = [] |
| with torch.no_grad(): |
| for xb, vb, yb in va_dl: |
| xb = xb.to("cuda"); vb = vb.to("cuda") |
| va_logits.append(model(xb, vb).cpu()) |
| va_labels.append(yb) |
| va_logits = torch.cat(va_logits).numpy() |
| va_labels = torch.cat(va_labels).numpy() |
| va_prob = 1 / (1 + np.exp(-va_logits)) |
| ap = binary_ap(va_labels, va_prob) |
| auc = binary_auc(va_labels, va_prob) |
|
|
| lr_now = opt.param_groups[0]["lr"] |
| logger.info(f"ep{epoch:02d} tr_loss={tr_loss:.4f} " |
| f"val AP={ap:.4f} val AUC={auc:.4f} lr={lr_now:.2e}") |
| log["epochs"].append({"epoch": epoch, "tr_loss": tr_loss, |
| "val_ap": ap, "val_auc": auc, "lr": lr_now}) |
|
|
| if ap > best_ap: |
| best_ap = ap; best_epoch = epoch; no_imp = 0 |
| best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()} |
| else: |
| no_imp += 1 |
| if no_imp >= args.patience: |
| logger.info(f"early stop at epoch {epoch} (no val AP improvement for {args.patience} epochs)") |
| break |
|
|
| if best_state is None: |
| raise SystemExit("no best state recorded; training failed") |
|
|
| meta_out = { |
| "hidden_dim": D, |
| "n_frames": T, |
| "proj_dim": args.proj_dim, |
| "n_layers": args.n_layers, |
| "n_heads": args.n_heads, |
| "dropout": args.dropout, |
| "best_epoch": best_epoch, |
| "best_val_ap": best_ap, |
| "train_cache": str(args.train_cache), |
| "val_cache": str(args.val_cache), |
| } |
| torch.save({"state_dict": best_state, "meta": meta_out}, out_dir / "best.pt") |
| with open(out_dir / "train_log.json", "w") as f: |
| json.dump({"log": log, "best": meta_out}, f, indent=2) |
| logger.info(f"best val AP = {best_ap:.4f} @ epoch {best_epoch}") |
| logger.info(f"saved -> {out_dir/'best.pt'}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|