| |
| """LKAlert-BD multi-horizon trainer (Day 3). |
| |
| Extends `POMDPTemporalHead` with: |
| * a dynamic-feature side-channel built by `dynamic_features.build_features` |
| * configurable multi-horizon binary outputs `{p_any, p_1500, p_1000, p_500, |
| p_resolution_proxy, p_ego}` |
| * optional ordinal monotonic regulariser `p_500 ≤ p_1000 ≤ p_1500 ≤ p_any` |
| * automatic skipping of heads whose training labels are degenerate |
| (single class) — e.g. the current Nexar train cache has 0 clips with |
| TTA ≤ 1.0 s, so p_1000 and p_500 are unsupported and silently dropped. |
| |
| Inputs / labels: |
| * belief cache: `data/belief_cache_perframe_qwen3vl4b/nexar_train_diag.pt` |
| keys: beliefs_frame [N,T,D], valid_frames [N,T], beliefs_text [N,D], |
| tta_means [N], tta_vars [N], meta.{ids, action_labels} |
| * diag json: `data/policy_labels/nexar_train_diag.json` |
| per-clip {tta_raw, action_label, category, source} |
| |
| Reused frozen heads remain on disk; training is ~6 min × 5 seeds on CPU. |
| |
| Output: |
| checkpoints/Policy/lkalert_bd_seed{0..4}/best.pt |
| checkpoints/Policy/lkalert_bd_best/ (symlink to best by nexar_val ap) |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| import random |
| import sys |
| from pathlib import Path |
| from typing import Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from torch.utils.data import DataLoader, Dataset |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parents[2])) |
|
|
| from training.Policy.dynamic_features import build_features, feature_dim |
| from training.Policy.train_pomdp_head import POMDPTemporalHead |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| logger = logging.getLogger("lkalert_bd") |
|
|
| CACHE_DIR = Path("data/belief_cache_perframe_qwen3vl4b") |
| DIAG_DIR = Path("data/policy_labels") |
|
|
|
|
| def _diag_filename(cache_name: str) -> str: |
| """Map cache stem to its diag-json filename. |
| |
| Convention: most caches use `{cache}_diag.json` but `nexar_train_diag` |
| already has `_diag` in its name, so the file is `nexar_train_diag.json`. |
| """ |
| if cache_name.endswith("_diag"): |
| return f"{cache_name}.json" |
| return f"{cache_name}_diag.json" |
|
|
| HORIZON_BUCKETS = [ |
| ("p_500", 0.0, 0.5), |
| ("p_1000", 0.0, 1.0), |
| ("p_1500", 0.0, 1.5), |
| ("p_any", 0.0, 2.5), |
| ] |
|
|
|
|
| |
|
|
| def derive_labels(diag_json: Path, ids: List[str]) -> Dict[str, np.ndarray]: |
| """Return per-clip binary label arrays keyed by horizon name + p_ego + |
| p_resolution_proxy. Aligned to the cache's id order. |
| |
| `p_any` is `action_label != 0` (consistent across Nexar / DoTA / DAD / |
| DADA, where DoTA-style caches set tta_raw = -1 even for true positives). |
| |
| `p_1500` / `p_1000` / `p_500` derive from `tta_raw` and require |
| `tta_raw >= 0`; otherwise the bucket label is 0 (caches without TTA |
| info simply produce all-0 horizon labels and the trainer skips them). |
| """ |
| raw = json.loads(diag_json.read_text()) |
| by_id = {s["video_id"]: s for s in raw["samples"]} |
| tta = np.asarray([by_id[v]["tta_raw"] for v in ids], dtype=np.float32) |
| cat = [by_id[v]["category"] for v in ids] |
| al = np.asarray([by_id[v]["action_label"] for v in ids], dtype=np.int32) |
|
|
| labels: Dict[str, np.ndarray] = {} |
| has_tta = tta >= 0.0 |
| for name, lo, hi in HORIZON_BUCKETS: |
| if name == "p_any": |
| |
| labels[name] = (al != 0).astype(np.float32) |
| else: |
| |
| labels[name] = (has_tta & (tta >= lo) & (tta <= hi)).astype(np.float32) |
| labels["p_ego"] = np.asarray([1.0 if c == "ego_positive" else 0.0 |
| for c in cat], dtype=np.float32) |
| labels["p_resolution_proxy"] = 1.0 - labels["p_any"] |
| return labels |
|
|
|
|
| def usable_heads(labels: Dict[str, np.ndarray], head_names: List[str] |
| ) -> List[str]: |
| out = [] |
| for name in head_names: |
| y = labels[name] |
| if 0 < y.sum() < y.size: |
| out.append(name) |
| else: |
| logger.warning(f" skip {name}: degenerate (n_pos={int(y.sum())} " |
| f"of {y.size})") |
| return out |
|
|
|
|
| |
|
|
| class CacheDataset(Dataset): |
| def __init__(self, cache_path: Path, label_path: Optional[Path], |
| head_names: List[str]): |
| d = torch.load(cache_path, weights_only=False, map_location="cpu") |
| self.bf = d["beliefs_frame"].float() |
| self.vf = d["valid_frames"].bool() |
| self.bt = d["beliefs_text"].float() |
| self.tm = d["tta_means"].float() |
| self.tv = d["tta_vars"].float() |
| self.ids = d["meta"]["ids"] |
| if label_path is not None: |
| self.lbl = derive_labels(label_path, self.ids) |
| else: |
| |
| al = np.asarray(d["meta"].get("action_labels", []), |
| dtype=np.int32) |
| self.lbl = {"p_any": (al == 2).astype(np.float32)} |
| self.head_names = head_names |
|
|
| def __len__(self) -> int: |
| return self.bf.shape[0] |
|
|
| def __getitem__(self, i: int) -> Dict[str, torch.Tensor]: |
| out = { |
| "belief": self.bf[i], "valid": self.vf[i], |
| "text": self.bt[i], "tta_mean": self.tm[i:i+1].squeeze(0), |
| "tta_var": self.tv[i:i+1].squeeze(0), |
| "vid": self.ids[i], |
| } |
| for h in self.head_names: |
| if h in self.lbl: |
| out[f"y_{h}"] = torch.tensor(self.lbl[h][i], dtype=torch.float32) |
| return out |
|
|
|
|
| def collate(batch: List[Dict]) -> Dict[str, torch.Tensor]: |
| out = { |
| "belief": torch.stack([b["belief"] for b in batch]), |
| "valid": torch.stack([b["valid"] for b in batch]), |
| "text": torch.stack([b["text"] for b in batch]), |
| "tta_mean": torch.stack([b["tta_mean"] for b in batch]), |
| "tta_var": torch.stack([b["tta_var"] for b in batch]), |
| "vids": [b["vid"] for b in batch], |
| } |
| for k in batch[0]: |
| if k.startswith("y_"): |
| out[k] = torch.stack([b[k] for b in batch]) |
| return out |
|
|
|
|
| |
|
|
| class LKAlertBDHead(nn.Module): |
| """POMDP trunk + dynamic-feature side-channel + per-horizon binary heads.""" |
|
|
| def __init__(self, in_dim: int = 2560, proj_dim: int = 512, |
| gru_hidden: int = 256, dyn_dim: int = 10250, |
| dyn_hidden: int = 64, dropout: float = 0.2, |
| head_names: Optional[List[str]] = None): |
| super().__init__() |
| self.head_names = head_names or ["p_any"] |
|
|
| |
| self.in_proj = nn.Sequential( |
| nn.Linear(in_dim, proj_dim), |
| nn.LayerNorm(proj_dim), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| ) |
| self.text_proj = nn.Sequential( |
| nn.Linear(in_dim, gru_hidden), |
| nn.LayerNorm(gru_hidden), |
| nn.Tanh(), |
| ) |
| self.gru = nn.GRU(proj_dim, gru_hidden, num_layers=1, batch_first=True) |
| self.attn = nn.Linear(gru_hidden, 1) |
|
|
| |
| self.dyn_proj = nn.Sequential( |
| nn.Linear(dyn_dim, 256), |
| nn.LayerNorm(256), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(256, dyn_hidden), |
| nn.GELU(), |
| ) |
| |
| self.fusion = nn.Sequential( |
| nn.Linear(gru_hidden + dyn_hidden, 128), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| ) |
| |
| self.heads = nn.ModuleDict({ |
| h: nn.Linear(128, 1) for h in self.head_names |
| }) |
|
|
| def trunk_pool(self, beliefs: torch.Tensor, valid: torch.Tensor, |
| text: torch.Tensor) -> torch.Tensor: |
| x = self.in_proj(beliefs) |
| h0 = self.text_proj(text).unsqueeze(0).contiguous() |
| out, _ = self.gru(x, h0) |
| attn_logits = self.attn(out).squeeze(-1) |
| attn_logits = attn_logits.masked_fill(~valid, float("-inf")) |
| empty = (~valid).all(dim=1) |
| if empty.any(): |
| attn_logits[empty] = 0.0 |
| w = F.softmax(attn_logits, dim=1).unsqueeze(-1) |
| return (out * w).sum(dim=1) |
|
|
| def forward(self, beliefs: torch.Tensor, valid: torch.Tensor, |
| text: torch.Tensor, dyn_feat: torch.Tensor |
| ) -> Dict[str, torch.Tensor]: |
| pooled = self.trunk_pool(beliefs, valid, text) |
| side = self.dyn_proj(dyn_feat) |
| joint = self.fusion(torch.cat([pooled, side], dim=-1)) |
| return {h: self.heads[h](joint).squeeze(-1) for h in self.head_names} |
|
|
| def warm_start_from_pomdp(self, pomdp_state: Dict[str, torch.Tensor]): |
| """Copy in_proj / text_proj / gru / attn weights from POMDPTemporalHead.""" |
| own = self.state_dict() |
| copied = [] |
| for k, v in pomdp_state.items(): |
| if k in own and own[k].shape == v.shape: |
| own[k] = v.clone() |
| copied.append(k) |
| self.load_state_dict(own) |
| logger.info(f"warm-started {len(copied)} trunk params from POMDP") |
|
|
|
|
| |
|
|
| def evaluate(model: LKAlertBDHead, ds: CacheDataset, head_names: List[str], |
| device: torch.device, batch_size: int = 128) -> Dict: |
| from sklearn.metrics import average_precision_score, roc_auc_score |
| model.eval() |
| loader = DataLoader(ds, batch_size=batch_size, shuffle=False, |
| collate_fn=collate) |
| preds: Dict[str, List[float]] = {h: [] for h in head_names} |
| labels: Dict[str, List[float]] = {h: [] for h in head_names} |
| with torch.no_grad(): |
| for b in loader: |
| dyn = build_features(b["belief"].to(device), b["valid"].to(device), |
| b["tta_mean"].to(device), |
| b["tta_var"].to(device)) |
| out = model(b["belief"].to(device), b["valid"].to(device), |
| b["text"].to(device), dyn["pooled"]) |
| for h in head_names: |
| preds[h].extend(torch.sigmoid(out[h]).cpu().tolist()) |
| if f"y_{h}" in b: |
| labels[h].extend(b[f"y_{h}"].tolist()) |
| metrics: Dict[str, Dict] = {} |
| for h in head_names: |
| if not labels[h]: |
| continue |
| y = np.asarray(labels[h]); p = np.asarray(preds[h]) |
| if y.min() == y.max(): |
| metrics[h] = {"ap": 0.0, "auc": 0.0, "n": int(y.size), |
| "n_pos": int(y.sum())} |
| continue |
| metrics[h] = { |
| "ap": float(average_precision_score(y, p)), |
| "auc": float(roc_auc_score(y, p)), |
| "n": int(y.size), |
| "n_pos": int(y.sum()), |
| } |
| return metrics |
|
|
|
|
| def train_one_seed(args) -> Dict: |
| torch.manual_seed(args.seed) |
| np.random.seed(args.seed) |
| random.seed(args.seed) |
|
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| |
| train_lbl = derive_labels(DIAG_DIR / _diag_filename(args.train_cache), |
| ids := torch.load( |
| CACHE_DIR / f"{args.train_cache}.pt", |
| weights_only=False, |
| map_location="cpu")["meta"]["ids"]) |
| head_names = usable_heads(train_lbl, args.head_names) |
| if not head_names: |
| raise SystemExit("no usable heads") |
| logger.info(f"usable heads: {head_names}") |
| for h in head_names: |
| y = train_lbl[h] |
| logger.info(f" {h}: n_pos={int(y.sum())} n_neg={int(y.size - y.sum())}") |
|
|
| |
| train_ds = CacheDataset(CACHE_DIR / f"{args.train_cache}.pt", |
| DIAG_DIR / _diag_filename(args.train_cache), |
| head_names) |
| val_caches: Dict[str, CacheDataset] = {} |
| for vc in args.val_caches: |
| diag = DIAG_DIR / _diag_filename(vc) |
| diag_p = diag if diag.exists() else None |
| val_caches[vc] = CacheDataset(CACHE_DIR / f"{vc}.pt", diag_p, head_names) |
|
|
| |
| y_any = train_lbl["p_any"] |
| pos = (y_any == 1).sum(); neg = (y_any == 0).sum() |
| weights = np.where(y_any == 1, 1.0 / max(pos, 1), 1.0 / max(neg, 1)) |
| sampler = torch.utils.data.WeightedRandomSampler( |
| weights=weights, num_samples=len(weights), replacement=True) |
|
|
| train_loader = DataLoader(train_ds, batch_size=args.batch_size, |
| sampler=sampler, collate_fn=collate, |
| num_workers=args.num_workers) |
|
|
| |
| in_dim = train_ds.bf.shape[-1] |
| fdim = feature_dim(in_dim, with_tta=True) |
| model = LKAlertBDHead(in_dim=in_dim, proj_dim=args.proj_dim, |
| gru_hidden=args.gru_hidden, |
| dyn_dim=fdim, dyn_hidden=args.dyn_hidden, |
| dropout=args.dropout, head_names=head_names) |
|
|
| |
| if args.warm_start: |
| ck = torch.load(args.warm_start, weights_only=False, map_location="cpu") |
| model.warm_start_from_pomdp(ck["head_state"]) |
|
|
| model.to(device) |
| opt = torch.optim.AdamW(model.parameters(), lr=args.lr, |
| weight_decay=args.wd) |
|
|
| |
| best = {"epoch": -1, "macro_ap": -1.0, "per_cache": {}} |
| out_dir = Path(args.out_dir) |
| out_dir.mkdir(parents=True, exist_ok=True) |
| for epoch in range(args.epochs): |
| model.train() |
| ep_loss = 0.0 |
| n = 0 |
| for b in train_loader: |
| opt.zero_grad() |
| dyn = build_features(b["belief"].to(device), b["valid"].to(device), |
| b["tta_mean"].to(device), |
| b["tta_var"].to(device)) |
| logits = model(b["belief"].to(device), b["valid"].to(device), |
| b["text"].to(device), dyn["pooled"]) |
| losses = [] |
| for h in head_names: |
| y = b[f"y_{h}"].to(device) |
| losses.append(F.binary_cross_entropy_with_logits(logits[h], y)) |
| |
| order = ["p_500", "p_1000", "p_1500", "p_any"] |
| present = [h for h in order if h in head_names] |
| if args.ordinal_lambda > 0 and len(present) >= 2: |
| with torch.no_grad(): |
| pass |
| ord_loss = 0.0 |
| for a, c in zip(present[:-1], present[1:]): |
| ord_loss = ord_loss + F.relu( |
| torch.sigmoid(logits[a]) - torch.sigmoid(logits[c]) |
| ).mean() |
| losses.append(args.ordinal_lambda * ord_loss) |
| loss = sum(losses) / max(len(losses), 1) |
| loss.backward() |
| opt.step() |
| ep_loss += float(loss.detach()) * b["belief"].shape[0] |
| n += b["belief"].shape[0] |
|
|
| |
| per_cache: Dict[str, Dict] = {} |
| macro_ap = 0.0 |
| for vc, ds in val_caches.items(): |
| m = evaluate(model, ds, head_names, device, args.batch_size) |
| per_cache[vc] = m |
| if "p_any" in m: |
| macro_ap += m["p_any"]["ap"] |
| macro_ap /= max(1, len(val_caches)) |
| logger.info(f"ep {epoch:02d} loss={ep_loss/max(n,1):.4f} " |
| f"macro p_any AP={macro_ap:.4f}") |
| for vc, m in per_cache.items(): |
| for h, mh in m.items(): |
| logger.info(f" {vc}/{h}: AP={mh['ap']:.4f} AUC={mh['auc']:.4f} " |
| f"n_pos={mh['n_pos']}/{mh['n']}") |
| if macro_ap > best["macro_ap"]: |
| best = {"epoch": epoch, "macro_ap": float(macro_ap), |
| "per_cache": per_cache, |
| "head_state": model.state_dict(), |
| "args": vars(args), "head_names": head_names} |
| torch.save(best, out_dir / "best.pt") |
| logger.info(f" -> saved best.pt @ macro_ap={macro_ap:.4f}") |
| return best |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--train_cache", default="nexar_train_diag") |
| ap.add_argument("--val_caches", nargs="+", |
| default=["nexar_val", "dota_val", "dad_test", "dada_test"]) |
| ap.add_argument("--out_dir", default="checkpoints/Policy/lkalert_bd_seed0") |
| ap.add_argument("--epochs", type=int, default=30) |
| ap.add_argument("--batch_size", type=int, default=64) |
| ap.add_argument("--num_workers", type=int, default=2) |
| ap.add_argument("--lr", type=float, default=3e-4) |
| ap.add_argument("--wd", type=float, default=1e-4) |
| ap.add_argument("--proj_dim", type=int, default=512) |
| ap.add_argument("--gru_hidden", type=int, default=256) |
| ap.add_argument("--dyn_hidden", type=int, default=64) |
| ap.add_argument("--dropout", type=float, default=0.2) |
| ap.add_argument("--ordinal_lambda", type=float, default=0.1) |
| ap.add_argument("--seed", type=int, default=0) |
| ap.add_argument("--head_names", nargs="+", |
| default=["p_any", "p_1500", "p_1000", "p_500", |
| "p_resolution_proxy", "p_ego"], |
| help="degenerate buckets are silently dropped") |
| ap.add_argument("--warm_start", default= |
| "checkpoints/Policy/pomdp_head_qwen3vl4b_best_seed/best.pt", |
| help="POMDP best.pt to warm-start trunk") |
| args = ap.parse_args() |
| train_one_seed(args) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|