VLAlert / tools /score_v1_val_vlalert_all.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
19 kB
"""Score ALL VLAlert / LKAlert variants on benchmark/v1/val using a shared belief cache.
Runs each (danger_ckpt, policy_ckpt) combo through the sft_x_v3 cache
and writes per-tick PT files in the v1/val schema for downstream
aggregation.
Usage:
python tools/score_v1_val_vlalert_all.py \
--cache data/belief_cache_v2/sft_x_v3__v1_val.pt \
--manifest eval_results/benchmark_v1_val/val_manifest.json
Output schema (each .pt in eval_results/benchmark_v1_val/per_tick/):
Same as tools/score_v1_val_baselines.py — see that file for full schema.
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from pathlib import Path
import torch
import torch.nn.functional as F
from tqdm import tqdm
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from lkalert.models.danger_head import DangerHead
from lkalert.models.policy_head_v2 import PolicyHeadV2
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("score_v1_val_vlalert_all")
# ── Variant registry: (display_name, danger_ckpt, policy_ckpt, belief_slice_dim) ──
# All share sft_x_v3 backbone via the cache. `belief_slice_dim` is None (= use full
# 10240-d cache) or 2560 (= use only L32 = last 2560 dims, for c1_lastonly variants).
VARIANTS = [
# Headline / paper-facing
("VLAlert-X", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_strong/best.pt", None),
("VLAlert-X-v2", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_strong_v2/best.pt", None),
# RL variants (head-DPO/KTO/PPO; VLM frozen)
("VLAlert-X+Head-DPO", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_head_dpo/best.pt", None),
("VLAlert-X+Head-KTO", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_head_kto/best.pt", None),
("VLAlert-X+Head-PPO", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_head_ppo/best.pt", None),
# Closed-loop / adaptive variants
("VLAlert-X+Adaptive", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_adaptive/best.pt", None),
("VLAlert-X+Adaptive-DPO", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_adaptive_dpo/best.pt", None),
("VLAlert-X+Adaptive-DPO-v2", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_adaptive_dpo_v2/best.pt", None),
("VLAlert-X+Adaptive-relabel", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v3_adaptive_relabel/best.pt", None),
# Layer-ablation 5-seed (c1_lastonly: only L32, 2560-d belief). Paired with
# the canonical v3_strong PolicyHead since these ckpts are DangerHead-only.
("VLAlert-X+c1-seed1", "checkpoints/layer_ablation_v2/c1_lastonly_seed1/best.pt", "checkpoints/policy_v3_strong/best.pt", 2560),
("VLAlert-X+c1-seed2", "checkpoints/layer_ablation_v2/c1_lastonly_seed2/best.pt", "checkpoints/policy_v3_strong/best.pt", 2560),
("VLAlert-X+c1-seed3", "checkpoints/layer_ablation_v2/c1_lastonly_seed3/best.pt", "checkpoints/policy_v3_strong/best.pt", 2560),
("VLAlert-X+c1-seed4", "checkpoints/layer_ablation_v2/c1_lastonly_seed4/best.pt", "checkpoints/policy_v3_strong/best.pt", 2560),
("VLAlert-X+c1-seed5", "checkpoints/layer_ablation_v2/c1_lastonly_seed5/best.pt", "checkpoints/policy_v3_strong/best.pt", 2560),
# v4 experimental adaptive (2 seeds)
("VLAlert-X+v4-Adaptive-seed0", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v4_adaptive/seed0/best.pt", None),
("VLAlert-X+v4-Adaptive-seed1", "checkpoints/danger_v3_hazard/best.pt", "checkpoints/policy_v4_adaptive/seed1/best.pt", None),
# ── Legacy v2-M10 (5 seeds) — paired with danger_v2/seed2 (their training pairing)
# Cross-backbone: scored on sft_x_v3 cache; treats v3 belief features as input.
# User accepts architecture drift ("思想相同") so we report honestly.
("VLAlert-v2-M10-seed0", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v2/seed0/best.pt", None),
("VLAlert-v2-M10-seed1", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v2/seed1/best.pt", None),
("VLAlert-v2-M10-seed2", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v2/seed2/best.pt", None),
("VLAlert-v2-M10-seed3", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v2/seed3/best.pt", None),
("VLAlert-v2-M10-seed4", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v2/seed4/best.pt", None),
# ── Legacy v3 family (3 CE + 3 focord seeds) — paired with danger_v2/seed2
("VLAlert-v3-CE-seed0", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v3_full/ce_seed0/best.pt", None),
("VLAlert-v3-CE-seed1", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v3_full/ce_seed1/best.pt", None),
("VLAlert-v3-CE-seed2", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v3_full/ce_seed2/best.pt", None),
("VLAlert-v3-Focord-seed0", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v3_full/focord_seed0/best.pt", None),
("VLAlert-v3-Focord-seed1", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v3_full/focord_seed1/best.pt", None),
("VLAlert-v3-Focord-seed2", "checkpoints/danger_v2/seed2/best.pt", "checkpoints/policy_v3_full/focord_seed2/best.pt", None),
# (SFT-argmax is handled separately via tools/eval_sft_argmax_baseline.py + converter)
]
@torch.no_grad()
def score_one(name: str, danger_ckpt: Path, policy_ckpt: Path,
cache: dict, val_manifest_samples: list,
batch_size: int, device: torch.device,
prev_action: int = 3,
belief_slice_dim: int = None) -> dict:
"""Run one (danger, policy) combo on the shared cache.
Returns extended schema including: raw_logits, scores_3class, scores_binary,
danger_per_frame, danger_clip, perception_summary, first_fire_tta, lead_time.
"""
print(f"\n══════════ {name} ══════════")
print(f" danger: {danger_ckpt}")
print(f" policy: {policy_ckpt}")
if not danger_ckpt.exists():
print(f" [skip] danger ckpt missing")
return None
if not policy_ckpt.exists():
print(f" [skip] policy ckpt missing")
return None
belief_full = cache["belief_content"].float() # [N, 8, D_belief_full]
# Slice belief to last K dims if requested (for c1_lastonly variants which
# were trained on L32-only). Cache stacks layers [L20, L24, L28, L32] with
# L32 = last 2560 dims.
if belief_slice_dim is not None:
belief = belief_full[:, :, -belief_slice_dim:].contiguous()
print(f" belief sliced to last {belief_slice_dim} dims (c1_lastonly variant)")
else:
belief = belief_full
policy = cache["policy_position"].float() # [N, 8, D_policy]
valid = cache["valid_frames"] # [N, 8]
N = belief.shape[0]
# ── load heads ──
ck_d = torch.load(danger_ckpt, weights_only=False, map_location="cpu")
if ck_d["in_dim"] != belief.shape[-1]:
print(f" [skip] danger ckpt in_dim={ck_d['in_dim']} != belief dim={belief.shape[-1]}")
return None
danger = DangerHead(in_dim=ck_d["in_dim"]).to(device)
# strict=False tolerates extra modules (e.g., hazard_head sub-classifier
# in danger_v3_hazard ckpt) that aren't part of the base DangerHead API.
missing, unexpected = danger.load_state_dict(ck_d["model"], strict=False)
if unexpected:
print(f" [info] unexpected keys (ignored): {len(unexpected)}")
if missing:
print(f" [warn] missing keys: {missing[:3]}")
return None
danger.eval()
ck_p = torch.load(policy_ckpt, weights_only=False, map_location="cpu")
try:
policy_head = PolicyHeadV2(
policy_dim=ck_p["policy_dim"],
perception_dim_per_query=ck_p["perception_dim_per_query"],
k_queries=ck_p["k_queries"],
).to(device)
# Legacy v2/v3 ckpts used a single Sequential `fuse` (Linear,GELU,Dropout,Linear);
# the new PolicyHeadV2 splits it into `fuse_pre` (first 3 modules) and
# `cls_head` (final Linear). Remap keys for backward compat.
sd = ck_p["model"]
if any(k.startswith("fuse.") for k in sd):
remapped = {}
for k, v in sd.items():
if k.startswith("fuse.0."): # Linear → fuse_pre.0
remapped["fuse_pre.0." + k[len("fuse.0."):]] = v
elif k.startswith("fuse.3."): # final Linear → cls_head
remapped["cls_head." + k[len("fuse.3."):]] = v
else:
remapped[k] = v
sd = remapped
print(" [info] remapped legacy fuse.{0,3} → fuse_pre.0 + cls_head")
policy_head.load_state_dict(sd, strict=False)
policy_head.eval()
except (KeyError, RuntimeError) as e:
print(f" [skip] policy ckpt incompatible: {e}")
return None
# ── infer ──
raw_logits_out = torch.zeros(N, 3, dtype=torch.float32)
danger_pf_out = torch.zeros(N, 8, dtype=torch.float32)
danger_clip_out = torch.zeros(N, dtype=torch.float32)
# perception_summary shape is [B, K, hidden]; allocate when we know K, hidden
perception_out = None
prev_act = torch.full((batch_size,), prev_action, dtype=torch.long, device=device)
t0 = time.time()
for i in tqdm(range(0, N, batch_size), ncols=80, desc=f"infer {name}"):
end = min(N, i + batch_size)
b_belief = belief[i:end].to(device, non_blocking=True)
b_policy = policy[i:end].to(device, non_blocking=True)
b_valid = valid[i:end].to(device, non_blocking=True)
cur_bs = end - i
d_out = danger(b_belief, valid_frames=b_valid)
perc = d_out["perception_summary"]
danger_pf = d_out["per_frame"]
danger_clip = d_out["clip"]
if perception_out is None:
K, H = perc.shape[1], perc.shape[2]
perception_out = torch.zeros(N, K, H, dtype=torch.float32)
perception_out[i:end] = perc.float().cpu()
danger_pf_out[i:end] = danger_pf.float().cpu()
danger_clip_out[i:end] = danger_clip.float().cpu()
prev = prev_act[:cur_bs]
logits = policy_head(b_policy, perc, danger_pf, prev, valid_frames=b_valid)
raw_logits_out[i:end] = logits.float().cpu()
print(f" inference: {time.time()-t0:.0f}s")
s3c = F.softmax(raw_logits_out, dim=-1)
s_bin = s3c[:, 2].clone()
# ── pull metadata from cache (correct per-tick) ──
# Cache stores 'ids' = synthetic ("v1val_006901") and 'video_id' = real
# ("nexar_00002"). Use video_id for matching with the val_manifest.
ids = list(cache.get("video_id", cache["ids"]))
sources = list(cache.get("source", [""] * N))
raw_cats = list(cache.get("category", [""] * N))
ttas = cache.get("tick_tta_raw", torch.full((N,), -1.0)).float()
# ── pull GT label directly from cache (cache stores tick_action) ──
# The cache ALREADY has correct per-tick labels (tick_action) and metadata.
# We supplement only fps/n_frames/tick_idx from manifest if available.
tick_action_cache = cache.get("tick_action", torch.zeros(N, dtype=torch.long))
label_out = tick_action_cache.tolist()
# Lookup table for fps/n_frames/tick_idx (manifest only used for these).
# Match by video_id; for each video, ticks are in cache order so we use
# appearance-order to assign tick_idx within a video.
manifest_by_vid = {}
for s in val_manifest_samples:
manifest_by_vid.setdefault(s["video_id"], []).append(s)
fi_out, fps_out, nframes_out, tidx_out, cat_hf_out = [], [], [], [], []
n_empty = 0
vid_seen_count: dict = {}
for i in range(N):
vid = ids[i]
if not vid:
# Cache extractor failed for this tick (e.g., DoTA frame-folder bug)
n_empty += 1
fi_out.append([0] * 8)
fps_out.append(30.0)
nframes_out.append(0)
tidx_out.append(0)
cat_hf_out.append("?")
continue
ms = manifest_by_vid.get(vid, [])
k = vid_seen_count.get(vid, 0)
m = ms[k] if k < len(ms) else (ms[0] if ms else None)
vid_seen_count[vid] = k + 1
if m is None:
fi_out.append([0] * 8); fps_out.append(30.0); nframes_out.append(0); tidx_out.append(0); cat_hf_out.append("?")
continue
fi_out.append(list(m["frame_indices"]))
fps_out.append(float(m["fps"]))
nframes_out.append(int(m["n_frames"]))
tidx_out.append(int(m.get("tick_idx", k)))
cat_hf_out.append(m["category"])
if n_empty:
print(f" [warn] {n_empty} ticks have empty cache entries (likely DoTA frame-folder failures)")
# ── post-hoc derive first_fire_tta + lead_time per tick ──
# For each video, find the FIRST tick where s_bin >= 0.5; record its
# tta_raw and compute lead_time = max(0, that_tick.tta_raw).
# Each tick stores (first_fire_tta, lead_time) inherited from the
# video's first-fire tick (or NaN if never fires).
tick_label_t = torch.tensor(label_out, dtype=torch.long)
fps_t = torch.tensor(fps_out, dtype=torch.float)
tidx_t = torch.tensor(tidx_out, dtype=torch.long)
first_fire_tta_out = torch.full((N,), float("nan"), dtype=torch.float)
lead_time_out = torch.full((N,), float("nan"), dtype=torch.float)
# Group ticks by video_id
from collections import defaultdict as _dd
by_video = _dd(list)
for i in range(N):
by_video[ids[i]].append(i)
for vid, idxs in by_video.items():
# sort by tick_idx ascending
idxs_sorted = sorted(idxs, key=lambda j: tidx_t[j].item())
fired = False
for j in idxs_sorted:
if not fired and s_bin[j].item() >= 0.5:
first_fire_tta_out[j] = ttas[j].item()
# lead_time = tta_raw at first fire (positive = before event)
lead_time_out[j] = max(0.0, float(ttas[j].item()))
fired = True
# Distribute first-fire info to all ticks of this video for convenience
if fired:
for j in idxs_sorted:
if torch.isnan(first_fire_tta_out[j]):
first_fire_tta_out[j] = first_fire_tta_out[idxs_sorted[0]] if not torch.isnan(first_fire_tta_out[idxs_sorted[0]]) else float("nan")
# Compose schema-conforming output (EXTENDED)
out = {
# metadata
"method": name,
"ckpt": str(policy_ckpt),
"danger_ckpt": str(danger_ckpt),
"belief_slice_dim": belief_slice_dim,
"manifest": "eval_results/benchmark_v1_val/val_manifest.json",
"n_ticks": int(N),
# tick-level identifiers
"ids": ids,
"source": sources,
"category": cat_hf_out,
"raw_category": raw_cats,
"frame_indices": torch.tensor(fi_out, dtype=torch.long),
"tta_raw": ttas,
"fps": fps_t,
"n_frames": torch.tensor(nframes_out, dtype=torch.long),
"tick_idx": tidx_t,
"tick_label": tick_label_t,
# primary scores
"raw_logits": raw_logits_out,
"scores_3class": s3c,
"scores_binary": s_bin,
# NEW intermediate variables (for calibration / re-analysis)
"danger_per_frame": danger_pf_out,
"danger_clip": danger_clip_out,
"perception_summary": perception_out,
"prev_action_used": torch.full((N,), prev_action, dtype=torch.long),
"first_fire_tta": first_fire_tta_out,
"lead_time": lead_time_out,
}
return out
def report_brief(out: dict):
import numpy as np
from sklearn.metrics import average_precision_score, roc_auc_score
y_true = out["tick_label"].numpy()
y_alert = (y_true == 2).astype(np.int64)
scores = out["scores_binary"].numpy()
try:
ap = average_precision_score(y_alert, scores)
auc = roc_auc_score(y_alert, scores) if 0 < y_alert.sum() < len(y_alert) else float("nan")
except Exception:
ap = auc = float("nan")
print(f" binary AP={ap:.4f} AUROC={auc:.4f} n_pos={y_alert.sum()}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cache", type=Path,
default=ROOT / "data/belief_cache_v2/sft_x_v3__v1_val.pt")
ap.add_argument("--manifest", type=Path,
default=ROOT / "eval_results/benchmark_v1_val/val_manifest.json")
ap.add_argument("--out_dir", type=Path,
default=ROOT / "eval_results/benchmark_v1_val/per_tick")
ap.add_argument("--batch_size", type=int, default=64)
ap.add_argument("--prev_action", type=int, default=3)
ap.add_argument("--variants", nargs="+", default=None,
help="Subset of variant names to score (default: all)")
args = ap.parse_args()
args.out_dir.mkdir(parents=True, exist_ok=True)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"[device] {device}")
print(f"[cache] {args.cache}")
print(f"[manifest] {args.manifest}")
if not args.cache.exists():
print(f"[err] cache not found — wait for extraction to finish first")
return
# Load cache once (reused across variants)
print(f"[load] cache ...")
cache = torch.load(args.cache, weights_only=False, map_location="cpu")
val_doc = json.loads(args.manifest.read_text())
val_samples = val_doc["samples"]
# Optional filter
to_run = VARIANTS
if args.variants:
to_run = [v for v in VARIANTS if v[0] in args.variants]
for variant in to_run:
# Support both (name, danger, policy) and (name, danger, policy, slice_dim)
if len(variant) == 4:
name, dpath, ppath, slice_dim = variant
else:
name, dpath, ppath = variant
slice_dim = None
try:
out = score_one(name, ROOT / dpath, ROOT / ppath,
cache, val_samples, args.batch_size, device,
args.prev_action, belief_slice_dim=slice_dim)
if out is None:
continue
slug = name.lower().replace("+", "_").replace(" ", "_").replace("-", "_")
out_path = args.out_dir / f"{slug}.pt"
torch.save(out, out_path)
print(f" [save] {out_path}")
report_brief(out)
except Exception as e:
print(f" [error scoring {name}]: {e}")
import traceback; traceback.print_exc()
if __name__ == "__main__":
main()