| |
| """ |
| Temporal Belief Aggregation Trainer for LKAlert Policy Head. |
| |
| Key insight: single-frame beliefs cannot distinguish OBSERVE from ALERT |
| (AP locked at 0.24). By processing K consecutive observation windows |
| through a GRU, the model captures danger escalation dynamics. |
| |
| Architecture: |
| belief_seq [B, T, 2048] -> proj(256) -> GRU(258, 256) -> MLP -> 3-class logits |
| |
| Usage: |
| python -m training.Policy.temporal_trainer \ |
| --sft_checkpoint checkpoints/SFT/sft_v2/best \ |
| --label_dir data/policy_labels \ |
| --belief_cache_dir data/belief_cache \ |
| --output_dir checkpoints/Policy \ |
| --experiment_name temporal_base \ |
| --seq_len 8 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| import math |
| import time |
| from collections import Counter, defaultdict |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from sklearn.metrics import average_precision_score |
| from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler |
| from tqdm import tqdm |
|
|
| import sys |
| sys.path.insert(0, str(Path(__file__).resolve().parents[2])) |
|
|
| from lkalert.models.components import TemporalPolicyHead |
| from training.Policy.policy_dataset import PolicyDataset, policy_collate_fn |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| logger = logging.getLogger("Policy.temporal") |
|
|
| ACTION_NAMES = {0: "SILENT", 1: "OBSERVE", 2: "ALERT"} |
|
|
|
|
| |
| |
| |
|
|
| class TemporalPolicyDataset(PolicyDataset): |
| """ |
| Extends PolicyDataset with temporal context: for each sample, returns |
| the K most recent belief vectors from the same video (sorted by frame index). |
| """ |
|
|
| def __init__(self, manifests, split, belief_cache_path, seq_len=8, **kwargs): |
| super().__init__(manifests, split, belief_cache_path, **kwargs) |
| self.seq_len = seq_len |
| self._build_temporal_index() |
|
|
| def _build_temporal_index(self): |
| """Build per-video sorted index for temporal context lookup.""" |
| video_samples: dict[str, list] = defaultdict(list) |
| for i, s in enumerate(self.samples): |
| |
| frame_key = s["frame_indices"][0] if s.get("frame_indices") else i |
| video_samples[s["video_id"]].append((i, frame_key)) |
|
|
| self._temporal_ctx: list[list[int]] = [[] for _ in range(len(self.samples))] |
| for vid, pairs in video_samples.items(): |
| pairs.sort(key=lambda x: x[1]) |
| for j, (idx, _) in enumerate(pairs): |
| start = max(0, j - self.seq_len + 1) |
| ctx = [pairs[k][0] for k in range(start, j + 1)] |
| |
| while len(ctx) < self.seq_len: |
| ctx.insert(0, ctx[0]) |
| self._temporal_ctx[idx] = ctx |
|
|
| |
| n_unique = sum(1 for ctx in self._temporal_ctx if len(set(ctx)) > 1) |
| logger.info( |
| f"Temporal index built: seq_len={self.seq_len}, " |
| f"{n_unique}/{len(self.samples)} samples have >1 unique context frame" |
| ) |
|
|
| def __getitem__(self, idx: int) -> Dict[str, Any]: |
| item = super().__getitem__(idx) |
| if self._cache is not None: |
| ctx = self._temporal_ctx[idx] |
| item["belief_seq"] = self._cache["beliefs"][ctx] |
| item["tta_mean_seq"] = self._cache["tta_means"][ctx] |
| item["tta_var_seq"] = self._cache["tta_vars"][ctx] |
| return item |
|
|
|
|
| def temporal_collate_fn(batch: List[Dict[str, Any]]) -> Dict[str, Any]: |
| """Collate for temporal dataset — adds sequence tensors.""" |
| out = policy_collate_fn(batch) |
| if "belief_seq" in batch[0]: |
| out["belief_seqs"] = torch.stack([b["belief_seq"] for b in batch]) |
| out["tta_mean_seqs"] = torch.stack([b["tta_mean_seq"] for b in batch]) |
| out["tta_var_seqs"] = torch.stack([b["tta_var_seq"] for b in batch]) |
| return out |
|
|
|
|
| |
| |
| |
|
|
| class TemporalPolicyModel(nn.Module): |
| """Lightweight wrapper: only the TemporalPolicyHead is trainable.""" |
|
|
| def __init__(self, hidden_dim: int, seq_len: int, device: str = "cuda"): |
| super().__init__() |
| self.policy_head = TemporalPolicyHead(hidden_dim=hidden_dim).to(device) |
| self._device = torch.device(device) |
| trainable = sum(p.numel() for p in self.parameters() if p.requires_grad) |
| logger.info(f"TemporalPolicyModel: {trainable:,} trainable params, seq_len={seq_len}") |
|
|
| @property |
| def device(self): |
| return self._device |
|
|
| def forward(self, belief_seqs, tta_mean_seqs, tta_var_seqs): |
| """ |
| Args: belief_seqs [B,T,H], tta_mean_seqs [B,T], tta_var_seqs [B,T] |
| Returns: logits [B, 3] |
| """ |
| return self.policy_head( |
| belief_seqs.to(self._device), |
| tta_mean_seqs.to(self._device), |
| tta_var_seqs.to(self._device), |
| ) |
|
|
| def save_checkpoint(self, save_dir: str, meta: Optional[dict] = None): |
| d = Path(save_dir) |
| d.mkdir(parents=True, exist_ok=True) |
| torch.save(self.policy_head.state_dict(), d / "policy_head.pt") |
| if meta is not None: |
| meta["version"] = "v6_temporal" |
| with open(d / "policy_meta.json", "w") as f: |
| json.dump(meta, f, indent=2) |
| logger.info(f" Checkpoint saved -> {d}") |
|
|
| def load_policy_checkpoint(self, ckpt_dir: str): |
| path = Path(ckpt_dir) / "policy_head.pt" |
| self.policy_head.load_state_dict(torch.load(path, map_location=self._device)) |
| logger.info(f" Loaded checkpoint from {path}") |
|
|
|
|
| |
| |
| |
|
|
| def focal_cross_entropy( |
| logits: torch.Tensor, |
| targets: torch.Tensor, |
| alpha: float = 0.75, |
| gamma: float = 2.0, |
| label_smoothing: float = 0.0, |
| ) -> torch.Tensor: |
| """Focal loss for multi-class classification.""" |
| C = logits.shape[1] |
| probs = F.softmax(logits, dim=-1) |
| idx = torch.arange(len(targets), device=logits.device) |
| pt = probs[idx, targets] |
|
|
| |
| if label_smoothing > 0: |
| with torch.no_grad(): |
| smooth_target = torch.full_like(probs, label_smoothing / (C - 1)) |
| smooth_target.scatter_(1, targets.unsqueeze(1), 1.0 - label_smoothing) |
| ce = -(smooth_target * probs.clamp(1e-8).log()).sum(dim=-1) |
| else: |
| ce = F.cross_entropy(logits, targets, reduction="none") |
|
|
| focal_weight = alpha * (1.0 - pt) ** gamma |
| return (focal_weight * ce).mean() |
|
|
|
|
| def monotonic_loss( |
| logits: torch.Tensor, |
| tta_raws: torch.Tensor, |
| video_ids: List[str], |
| margin: float = 0.05, |
| ) -> torch.Tensor: |
| """ |
| Temporal monotonic constraint: P(ALERT) should be non-decreasing |
| as TTA decreases (closer to collision). |
| """ |
| probs = F.softmax(logits, dim=-1) |
| p_alert = probs[:, 2] |
|
|
| |
| vid_to_idx: dict[str, list] = defaultdict(list) |
| for i, vid in enumerate(video_ids): |
| if tta_raws[i].item() > 0: |
| vid_to_idx[vid].append(i) |
|
|
| violations = [] |
| n_pairs = 0 |
| for vid, indices in vid_to_idx.items(): |
| if len(indices) < 2: |
| continue |
| ttas = tta_raws[indices] |
| palerts = p_alert[indices] |
| order = ttas.argsort(descending=True) |
| sorted_p = palerts[order] |
| for i in range(len(sorted_p) - 1): |
| diff = sorted_p[i] - sorted_p[i + 1] + margin |
| if diff > 0: |
| violations.append(diff) |
| n_pairs += 1 |
|
|
| if not violations: |
| return logits.new_tensor(0.0), 0, n_pairs |
|
|
| loss = torch.stack(violations).mean() |
| return loss, len(violations), n_pairs |
|
|
|
|
| |
| |
| |
|
|
| @torch.no_grad() |
| def evaluate(model, loader, tau_grid=True) -> dict: |
| """Evaluate model on val set with optional threshold grid search.""" |
| model.eval() |
| all_logits, all_labels, all_cats, all_ttas, all_vids = [], [], [], [], [] |
|
|
| for batch in tqdm(loader, desc="Eval", ncols=80, leave=False): |
| logits = model(batch["belief_seqs"], batch["tta_mean_seqs"], batch["tta_var_seqs"]) |
| all_logits.append(logits.cpu()) |
| all_labels.extend(batch["action_labels"].tolist()) |
| all_cats.extend(batch["categories"]) |
| all_ttas.extend(batch["tta_raws"].tolist()) |
| all_vids.extend(batch["video_ids"]) |
|
|
| logits = torch.cat(all_logits, dim=0) |
| probs = F.softmax(logits, dim=-1).numpy() |
| labels = np.array(all_labels) |
| cats = np.array(all_cats) |
|
|
| |
| binary_true = (labels == 2).astype(int) |
| p_alert = probs[:, 2] |
| binary_ap = float(average_precision_score(binary_true, p_alert)) if binary_true.sum() > 0 else 0.0 |
|
|
| |
| danger_true = (labels >= 1).astype(int) |
| p_danger = 1.0 - probs[:, 0] |
| danger_ap = float(average_precision_score(danger_true, p_danger)) if danger_true.sum() > 0 else 0.0 |
|
|
| |
| mono_viol, mono_pairs = _mono_stats(p_alert, np.array(all_ttas), all_vids) |
|
|
| def _metrics_at_threshold(alert_bias=0.0): |
| """Compute PolicyScore at given alert_bias (added to P(ALERT) before argmax).""" |
| adj = probs.copy() |
| adj[:, 2] += alert_bias |
| preds = adj.argmax(axis=1) |
| return _policy_metrics(preds, labels, cats) |
|
|
| |
| base = _metrics_at_threshold(0.0) |
| result = { |
| **base, |
| "binary_ap": binary_ap, |
| "danger_ap": danger_ap, |
| "mono_violation_rate": mono_viol, |
| "mono_n_pairs": mono_pairs, |
| } |
|
|
| |
| if tau_grid: |
| best_score = base["policy_score"] |
| best_bias = 0.0 |
| for bias in np.arange(-0.3, 0.31, 0.02): |
| m = _metrics_at_threshold(bias) |
| if m["policy_score"] > best_score: |
| best_score = m["policy_score"] |
| best_bias = bias |
| if best_bias != 0.0: |
| best_m = _metrics_at_threshold(best_bias) |
| result["grid_best_policy_score"] = best_m["policy_score"] |
| result["grid_best_alert_bias"] = best_bias |
| result["grid_best_ego_alert_recall"] = best_m["ego_alert_recall"] |
| result["grid_best_safe_neg_silent"] = best_m["safe_neg_silent_rate"] |
| else: |
| result["grid_best_policy_score"] = best_score |
| result["grid_best_alert_bias"] = 0.0 |
|
|
| model.train() |
| return result |
|
|
|
|
| def _policy_metrics(preds, labels, cats): |
| """Compute PolicyScore and sub-metrics.""" |
| ego_mask = cats == "ego_positive" |
| ne_mask = cats == "non_ego" |
| sn_mask = cats == "safe_neg" |
|
|
| |
| ego_alert_mask = ego_mask & (labels == 2) |
| ego_alert_recall = float((preds[ego_alert_mask] == 2).mean()) if ego_alert_mask.sum() > 0 else 0.0 |
|
|
| |
| ne_noalert = float((preds[ne_mask] != 2).mean()) if ne_mask.sum() > 0 else 0.0 |
|
|
| |
| sn_silent = float((preds[sn_mask] == 0).mean()) if sn_mask.sum() > 0 else 0.0 |
|
|
| |
| sn_alert = float((preds[sn_mask] == 2).mean()) if sn_mask.sum() > 0 else 0.0 |
|
|
| |
| policy_score = 0.65 * ego_alert_recall + 0.25 * sn_silent - 0.15 * sn_alert |
| acc = float((preds == labels).mean()) |
|
|
| return { |
| "policy_score": policy_score, |
| "ego_alert_recall": ego_alert_recall, |
| "non_ego_noalert_rate": ne_noalert, |
| "safe_neg_silent_rate": sn_silent, |
| "safe_neg_alert_rate": sn_alert, |
| "overall_acc": acc, |
| } |
|
|
|
|
| def _mono_stats(p_alert, ttas, video_ids): |
| """Compute monotonic violation statistics.""" |
| vid_to_data: dict[str, list] = defaultdict(list) |
| for i, vid in enumerate(video_ids): |
| if ttas[i] > 0: |
| vid_to_data[vid].append((ttas[i], p_alert[i])) |
|
|
| violations = 0 |
| n_pairs = 0 |
| for vid, data in vid_to_data.items(): |
| if len(data) < 2: |
| continue |
| data.sort(key=lambda x: -x[0]) |
| for i in range(len(data) - 1): |
| n_pairs += 1 |
| if data[i][1] > data[i + 1][1]: |
| violations += 1 |
|
|
| return violations / max(n_pairs, 1), n_pairs |
|
|
|
|
| |
| |
| |
|
|
| def train(args): |
| label_dir = Path(args.label_dir) |
| cache_dir = Path(args.belief_cache_dir) |
| train_cache_path = Path(args.train_cache_path) if args.train_cache_path else cache_dir / "train.pt" |
| val_cache_path = Path(args.val_cache_path) if args.val_cache_path else cache_dir / "val.pt" |
|
|
| |
| train_ds = TemporalPolicyDataset( |
| manifests=[label_dir / "train.json"], |
| split="train", |
| belief_cache_path=train_cache_path, |
| seq_len=args.seq_len, |
| debug=args.debug, |
| debug_samples=args.debug_samples, |
| ) |
| val_ds = TemporalPolicyDataset( |
| manifests=[label_dir / "val.json"], |
| split="val", |
| belief_cache_path=val_cache_path, |
| seq_len=args.seq_len, |
| debug=args.debug, |
| debug_samples=args.debug_samples, |
| ) |
|
|
| |
| if args.use_balanced_sampler: |
| labels = [s["action_label"] for s in train_ds.samples] |
| counts = Counter(labels) |
| weights = [1.0 / counts[l] for l in labels] |
| sampler = WeightedRandomSampler(weights, len(weights), replacement=True) |
| else: |
| sampler = None |
|
|
| bs = min(args.batch_size, len(train_ds)) |
| train_loader = DataLoader( |
| train_ds, batch_size=bs, |
| sampler=sampler, shuffle=(sampler is None), |
| collate_fn=temporal_collate_fn, |
| num_workers=4, pin_memory=True, |
| drop_last=(not args.debug), |
| ) |
| val_loader = DataLoader( |
| val_ds, batch_size=args.batch_size, shuffle=False, |
| collate_fn=temporal_collate_fn, |
| num_workers=4, pin_memory=True, |
| ) |
|
|
| |
| if args.hidden_dim and args.hidden_dim > 0: |
| hidden_dim = args.hidden_dim |
| else: |
| |
| cache = getattr(train_ds, "_cache", None) |
| if cache is None or "beliefs" not in cache: |
| raise RuntimeError("Cannot auto-detect hidden_dim: belief cache missing. " |
| "Pass --hidden_dim explicitly.") |
| hidden_dim = int(cache["beliefs"].shape[-1]) |
| logger.info(f" auto-detected hidden_dim={hidden_dim} from belief cache") |
| model = TemporalPolicyModel(hidden_dim, args.seq_len) |
| optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate, weight_decay=1e-4) |
|
|
| n_epochs = 2 if args.debug else args.num_epochs |
| total_steps = n_epochs * len(train_loader) |
| scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=total_steps, eta_min=1e-6) |
|
|
| |
| exp_dir = Path(args.output_dir) / args.experiment_name |
| best_dir = exp_dir / "best" |
| best_score = -1.0 |
| patience_counter = 0 |
| global_step = 0 |
|
|
| logger.info(f"Training {args.experiment_name}: {n_epochs} epochs, " |
| f"{len(train_loader)} steps/epoch, seq_len={args.seq_len}") |
| logger.info(f" focal: alpha={args.focal_alpha}, gamma={args.focal_gamma}") |
| logger.info(f" mono_lambda={args.mono_lambda}, label_smoothing={args.label_smoothing}") |
|
|
| t0 = time.time() |
|
|
| for epoch in range(n_epochs): |
| model.train() |
| epoch_loss = 0.0 |
| epoch_mono = 0.0 |
| n_batches = 0 |
|
|
| pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{n_epochs}", ncols=100) |
| for batch in pbar: |
| logits = model(batch["belief_seqs"], batch["tta_mean_seqs"], batch["tta_var_seqs"]) |
| labels = batch["action_labels"].to(model.device) |
|
|
| |
| loss = focal_cross_entropy( |
| logits, labels, |
| alpha=args.focal_alpha, gamma=args.focal_gamma, |
| label_smoothing=args.label_smoothing, |
| ) |
|
|
| |
| mono_l = torch.tensor(0.0) |
| if args.mono_lambda > 0: |
| mono_l, _, _ = monotonic_loss( |
| logits, batch["tta_raws"], batch["video_ids"] |
| ) |
| loss = loss + args.mono_lambda * mono_l |
|
|
| optimizer.zero_grad() |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) |
| optimizer.step() |
| scheduler.step() |
|
|
| epoch_loss += loss.item() |
| epoch_mono += mono_l.item() |
| n_batches += 1 |
| global_step += 1 |
|
|
| pbar.set_postfix(loss=f"{loss.item():.4f}", mono=f"{mono_l.item():.4f}", |
| lr=f"{scheduler.get_last_lr()[0]:.2e}") |
|
|
| |
| if global_step % args.val_every_n_steps == 0: |
| val_result = evaluate(model, val_loader, tau_grid=True) |
| score = val_result.get("grid_best_policy_score", val_result["policy_score"]) |
| logger.info( |
| f" [step {global_step}] PolicyScore={score:.4f} " |
| f"AP={val_result['binary_ap']:.4f} " |
| f"ego_recall={val_result['ego_alert_recall']:.3f} " |
| f"sn_silent={val_result['safe_neg_silent_rate']:.3f}" |
| ) |
| if score > best_score: |
| best_score = score |
| patience_counter = 0 |
| model.save_checkpoint(str(best_dir), meta={ |
| **val_result, "global_step": global_step, "epoch": epoch + 1, |
| "seq_len": args.seq_len, |
| }) |
|
|
| avg_loss = epoch_loss / max(n_batches, 1) |
| avg_mono = epoch_mono / max(n_batches, 1) |
| logger.info(f"Epoch {epoch+1} avg_loss={avg_loss:.4f} avg_mono={avg_mono:.4f}") |
|
|
| |
| val_result = evaluate(model, val_loader, tau_grid=True) |
| score = val_result.get("grid_best_policy_score", val_result["policy_score"]) |
| logger.info( |
| f" Val: PolicyScore={score:.4f} AP={val_result['binary_ap']:.4f} " |
| f"danger_ap={val_result['danger_ap']:.4f} " |
| f"mono_viol={val_result['mono_violation_rate']:.3f}" |
| ) |
|
|
| if score > best_score: |
| best_score = score |
| patience_counter = 0 |
| model.save_checkpoint(str(best_dir), meta={ |
| **val_result, "global_step": global_step, "epoch": epoch + 1, |
| "seq_len": args.seq_len, |
| }) |
| else: |
| patience_counter += 1 |
|
|
| if patience_counter >= args.early_stop_patience: |
| logger.info(f"Early stopping at epoch {epoch+1} (patience={args.early_stop_patience})") |
| break |
|
|
| elapsed = time.time() - t0 |
| logger.info(f"Training complete in {elapsed/60:.1f} min. Best PolicyScore={best_score:.4f}") |
| logger.info(f"Best checkpoint: {best_dir}") |
| return best_dir |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser("temporal_trainer") |
| parser.add_argument("--sft_checkpoint", required=True, help="(unused, kept for CLI compat)") |
| parser.add_argument("--label_dir", default="data/policy_labels") |
| parser.add_argument("--belief_cache_dir", default="data/belief_cache") |
| parser.add_argument("--output_dir", default="checkpoints/Policy") |
| parser.add_argument("--experiment_name", default="temporal_base") |
| parser.add_argument("--seq_len", type=int, default=8) |
| parser.add_argument("--num_epochs", type=int, default=15) |
| parser.add_argument("--batch_size", type=int, default=256) |
| parser.add_argument("--learning_rate", type=float, default=2e-4) |
| parser.add_argument("--focal_alpha", type=float, default=0.75) |
| parser.add_argument("--focal_gamma", type=float, default=2.0) |
| parser.add_argument("--mono_lambda", type=float, default=0.0) |
| parser.add_argument("--label_smoothing", type=float, default=0.0) |
| parser.add_argument("--val_every_n_steps", type=int, default=200) |
| parser.add_argument("--early_stop_patience", type=int, default=7) |
| parser.add_argument("--use_balanced_sampler", action="store_true") |
| parser.add_argument("--debug", action="store_true") |
| parser.add_argument("--debug_samples", type=int, default=128) |
| parser.add_argument("--hidden_dim", type=int, default=0, |
| help="Belief hidden dim. 0 = auto-detect from cache (recommended).") |
| parser.add_argument("--train_cache_path", type=str, default=None, |
| help="Override: explicit path to train belief cache (.pt). " |
| "Falls back to belief_cache_dir/train.pt.") |
| parser.add_argument("--val_cache_path", type=str, default=None, |
| help="Override: explicit path to val belief cache (.pt). " |
| "Falls back to belief_cache_dir/val.pt.") |
| args = parser.parse_args() |
|
|
| train(args) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|