VLAlert / training /Policy /temporal_trainer.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
23.9 kB
#!/usr/bin/env python3
"""
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"}
# ═══════════════════════════════════════════════════════════════════════════════
# Dataset: extends PolicyDataset with temporal context
# ═══════════════════════════════════════════════════════════════════════════════
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):
# Use first frame index as temporal sort key
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)]
# Left-pad with earliest if shorter than seq_len
while len(ctx) < self.seq_len:
ctx.insert(0, ctx[0])
self._temporal_ctx[idx] = ctx
# Stats
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] # [K, H]
item["tta_mean_seq"] = self._cache["tta_means"][ctx] # [K]
item["tta_var_seq"] = self._cache["tta_vars"][ctx] # [K]
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]) # [B, K, H]
out["tta_mean_seqs"] = torch.stack([b["tta_mean_seq"] for b in batch]) # [B, K]
out["tta_var_seqs"] = torch.stack([b["tta_var_seq"] for b in batch]) # [B, K]
return out
# ═══════════════════════════════════════════════════════════════════════════════
# Model: frozen SFT + trainable TemporalPolicyHead
# ═══════════════════════════════════════════════════════════════════════════════
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}")
# ═══════════════════════════════════════════════════════════════════════════════
# Loss functions
# ═══════════════════════════════════════════════════════════════════════════════
def focal_cross_entropy(
logits: torch.Tensor, # [B, C]
targets: torch.Tensor, # [B] long
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]
# Label smoothing
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, # [B, 3]
tta_raws: torch.Tensor, # [B]
video_ids: List[str], # [B]
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]
# Group by video
vid_to_idx: dict[str, list] = defaultdict(list)
for i, vid in enumerate(video_ids):
if tta_raws[i].item() > 0: # skip non_ego / safe_neg with tta=-1
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
# ═══════════════════════════════════════════════════════════════════════════════
# Evaluation
# ═══════════════════════════════════════════════════════════════════════════════
@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) # [N, 3]
probs = F.softmax(logits, dim=-1).numpy()
labels = np.array(all_labels)
cats = np.array(all_cats)
# Binary AP: P(ALERT) ranking
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 AP
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
# Monotonic violation rate
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)
# Default (no bias)
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,
}
# Threshold grid search: adjust alert_bias to maximize PolicyScore
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 recall: fraction of ego_positive with label=ALERT that are predicted ALERT
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
# Non-ego no-alert: fraction of non_ego NOT predicted ALERT
ne_noalert = float((preds[ne_mask] != 2).mean()) if ne_mask.sum() > 0 else 0.0
# Safe-neg silent: fraction of safe_neg predicted SILENT
sn_silent = float((preds[sn_mask] == 0).mean()) if sn_mask.sum() > 0 else 0.0
# Safe-neg alert leak
sn_alert = float((preds[sn_mask] == 2).mean()) if sn_mask.sum() > 0 else 0.0
# PolicyScore v3 (safety-first): 0.65 * ego_recall + 0.25 * safe_silent - 0.15 * safe_alert
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]) # descending TTA
for i in range(len(data) - 1):
n_pairs += 1
if data[i][1] > data[i + 1][1]: # earlier frame has higher P(ALERT)
violations += 1
return violations / max(n_pairs, 1), n_pairs
# ═══════════════════════════════════════════════════════════════════════════════
# Training loop
# ═══════════════════════════════════════════════════════════════════════════════
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"
# ── datasets ──
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,
)
# ── balanced sampler ──
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,
)
# ── model ──
if args.hidden_dim and args.hidden_dim > 0:
hidden_dim = args.hidden_dim
else:
# Auto-detect from the belief cache tensor (no loader consumption).
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)
# ── training ──
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)
# Focal CE
loss = focal_cross_entropy(
logits, labels,
alpha=args.focal_alpha, gamma=args.focal_gamma,
label_smoothing=args.label_smoothing,
)
# Monotonic constraint
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}")
# Mid-epoch validation (log + save best, but do NOT count patience)
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}")
# End-of-epoch validation
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()