VLAlert / training /Policy /conformal_risk.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
18.8 kB
#!/usr/bin/env python3
"""
Conformal Risk Control for LKAlert PolicyHead (v4 Evidential / v5 Hierarchical).
Provides distribution-free statistical guarantees:
- "With probability ≥ 1-ε, the true class is in the prediction set."
- Prediction set size adapts: uncertain inputs → larger set → conservative.
Two modes:
1. Standard conformal: coverage guarantee on class membership.
2. Risk control: guarantee on asymmetric miss cost (missing ALERT is worse).
Supports both model architectures:
- v4 EvidentialPolicyModel: Dirichlet α → probs = α / S
- v5 HierarchicalPolicyModel: (alert_logit, danger_logit) → 3-class probs
Usage:
python -m training.Policy.conformal_risk \
--sft_checkpoint checkpoints/SFT/sft_v2/best \
--v4_ckpt checkpoints/Policy/policy_warmstart_v5_mono/best \
--label_dir data/policy_labels \
--belief_cache_dir data/belief_cache \
--output_dir eval_results/paper_comparison_v5 \
--epsilon 0.05
"""
from __future__ import annotations
import argparse
import json
import logging
from collections import defaultdict
from pathlib import Path
from typing import List, Optional
import numpy as np
import torch
from torch.utils.data import DataLoader
from tqdm import tqdm
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from training.Policy.policy_model_v4 import EvidentialPolicyModel
from training.Policy.policy_model_v5 import HierarchicalPolicyModel
from training.Policy.policy_dataset import PolicyDataset, policy_collate_fn
from training.Policy.temporal_trainer import TemporalPolicyDataset, TemporalPolicyModel, temporal_collate_fn
from training.Policy.trajectory_trainer import TrajectoryPolicyDataset, TrajectoryPolicyModel, trajectory_collate_fn
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("Policy.conformal")
def _detect_model_version(ckpt_dir: str) -> str:
"""Detect whether checkpoint is v4/v5/v6/v7."""
meta_path = Path(ckpt_dir) / "policy_meta.json"
if meta_path.exists():
with open(meta_path) as f:
meta = json.load(f)
ver = meta.get("version", "")
if ver == "v7_trajectory":
return "v7"
if ver == "v6_temporal":
return "v6"
if ver == "v5_hierarchical":
return "v5"
# fallback: check state_dict keys
head_path = Path(ckpt_dir) / "policy_head.pt"
if head_path.exists():
sd = torch.load(head_path, map_location="cpu")
if "danger_estimator.0.weight" in sd:
return "v7"
if "gru.weight_ih_l0" in sd:
return "v6"
if "alert_head.weight" in sd:
return "v5"
return "v4"
def _v5_logits_to_probs(alert_logit: np.ndarray, danger_logit: np.ndarray) -> np.ndarray:
"""
Convert v5 hierarchical outputs to 3-class probabilities.
P(ALERT) = σ(alert_logit)
P(DANGER) = σ(danger_logit) # = P(OBSERVE ∪ ALERT)
P(SILENT) = 1 - P(DANGER)
P(OBSERVE) = P(DANGER) - P(ALERT) (clipped ≥ 0)
Returns: [N, 3] probabilities normalized to sum to 1.
"""
p_alert = 1.0 / (1.0 + np.exp(-alert_logit))
p_danger = 1.0 / (1.0 + np.exp(-danger_logit))
p_silent = 1.0 - p_danger
p_observe = np.clip(p_danger - p_alert, 0.0, None)
probs = np.stack([p_silent, p_observe, p_alert], axis=-1) # [N, 3]
# renormalize (sigmoid outputs are independent, may not sum to 1)
probs = probs / probs.sum(axis=-1, keepdims=True).clip(1e-8)
return probs
def calibrate_conformal(
alphas: np.ndarray, # [N, K] Dirichlet concentrations
labels: np.ndarray, # [N] int
epsilon: float = 0.05, # target miscoverage rate
) -> dict:
"""
Split-conformal calibration.
Non-conformity score: s_i = 1 - p_{y_i}(x_i)
where p = α / S (expected probability under Dirichlet).
Returns threshold q_hat such that:
P(y ∈ C(x)) ≥ 1 - ε where C(x) = {k : p_k(x) ≥ 1 - q_hat}
"""
S = alphas.sum(axis=-1, keepdims=True)
probs = alphas / S # [N, K]
idx = np.arange(len(labels))
p_true = probs[idx, labels]
scores = 1.0 - p_true # [N]
n = len(scores)
q_level = np.ceil((n + 1) * (1 - epsilon)) / n
q_level = min(q_level, 1.0)
q_hat = float(np.quantile(scores, q_level))
return {
"q_hat": q_hat,
"epsilon": epsilon,
"n_calibration": n,
"q_level": q_level,
"score_mean": float(scores.mean()),
"score_std": float(scores.std()),
"score_p90": float(np.percentile(scores, 90)),
}
def calibrate_risk_control(
alphas: np.ndarray,
labels: np.ndarray,
epsilon: float = 0.05,
cost_miss_alert: float = 5.0,
cost_fa: float = 1.0,
) -> dict:
"""
Conformal risk control with asymmetric costs (Angelopoulos et al. 2024).
Risk function: L(C, y) = cost_miss * 1[y=ALERT, ALERT ∉ C] + cost_fa * |C|/K
Find threshold λ such that E[L(C_λ, y)] ≤ δ, where
C_λ(x) = {k : p_k(x) ≥ λ}.
"""
S = alphas.sum(axis=-1, keepdims=True)
probs = alphas / S
K = alphas.shape[1]
n = len(labels)
lambdas = np.linspace(0.01, 0.99, 200)
best_lambda = 0.01
best_risk = float("inf")
for lam in lambdas:
sets = probs >= lam # [N, K] bool
risks = []
for i in range(n):
r = 0.0
if labels[i] == 2 and not sets[i, 2]:
r += cost_miss_alert
r += cost_fa * sets[i].sum() / K
risks.append(r)
mean_risk = np.mean(risks)
# Hoeffding correction for finite-sample guarantee
correction = np.sqrt(np.log(1.0 / epsilon) / (2 * n))
if mean_risk + correction <= best_risk:
best_risk = mean_risk + correction
best_lambda = lam
# compute final metrics at best_lambda
sets = probs >= best_lambda
coverage = float(np.mean([labels[i] in np.where(sets[i])[0] for i in range(n)]))
avg_set_size = float(sets.sum(axis=1).mean())
alert_miss = float(np.mean([
(not sets[i, 2]) for i in range(n) if labels[i] == 2
])) if (labels == 2).any() else 0.0
return {
"lambda": best_lambda,
"epsilon": epsilon,
"coverage": coverage,
"avg_set_size": avg_set_size,
"alert_miss_rate": alert_miss,
"cost_miss_alert": cost_miss_alert,
"cost_fa": cost_fa,
"n_calibration": n,
}
def predict_conformal(
alphas: np.ndarray,
q_hat: float,
) -> dict:
"""
Apply conformal prediction to produce prediction sets.
Returns:
sets: [N, K] bool — prediction set membership
sizes: [N] int — size of each prediction set
preds: [N] int — point prediction (argmax within set, or conservative)
"""
S = alphas.sum(axis=-1, keepdims=True)
probs = alphas / S
threshold = 1.0 - q_hat
sets = probs >= threshold # [N, K]
sizes = sets.sum(axis=1)
preds = probs.argmax(axis=1)
# if prediction set is empty (very high q_hat), predict the argmax
empty = sizes == 0
if empty.any():
sets[empty] = False
sets[empty, preds[empty]] = True
sizes[empty] = 1
return {
"sets": sets,
"sizes": sizes,
"preds": preds,
}
def evaluate_conformal(
alphas: np.ndarray,
labels: np.ndarray,
categories: np.ndarray,
ttas: np.ndarray,
video_ids: List[str],
cal_result: dict,
risk_result: dict,
) -> dict:
"""Full evaluation with conformal metrics."""
N, K = alphas.shape
S = alphas.sum(axis=-1, keepdims=True)
probs = alphas / S
u = K / S.squeeze(-1)
# standard conformal
conf_pred = predict_conformal(alphas, cal_result["q_hat"])
sets = conf_pred["sets"]
sizes = conf_pred["sizes"]
coverage = float(np.mean([labels[i] in np.where(sets[i])[0] for i in range(N)]))
avg_size = float(sizes.mean())
# coverage by category
ego_mask = categories == "ego_positive"
ne_mask = categories == "non_ego"
sn_mask = categories == "safe_neg"
def _cov(mask):
idx = np.where(mask)[0]
if len(idx) == 0:
return 0.0
return float(np.mean([labels[i] in np.where(sets[i])[0] for i in idx]))
# alert-specific safety metric: guaranteed miss rate
alert_mask = labels == 2
alert_in_set = np.array([sets[i, 2] for i in range(N)])
guaranteed_miss_rate = 1.0 - float(alert_in_set[alert_mask].mean()) if alert_mask.any() else 0.0
# conditional set sizes
size_by_class = {}
for k in range(K):
mask = labels == k
if mask.any():
size_by_class[f"set_size_class_{k}"] = float(sizes[mask].mean())
# uncertainty-coverage curve (for plotting)
u_flat = u.flatten()
thresholds = np.percentile(u_flat, np.arange(0, 101, 10))
u_coverage_curve = []
for thr in thresholds:
mask = u_flat <= thr
if mask.any():
cov = float(np.mean([labels[i] in np.where(sets[i])[0] for i in np.where(mask)[0]]))
u_coverage_curve.append({"u_threshold": float(thr), "coverage": cov, "frac": float(mask.mean())})
# risk control metrics
risk_lambda = risk_result["lambda"]
risk_sets = probs >= risk_lambda
risk_alert_miss = float(np.mean([
(not risk_sets[i, 2]) for i in range(N) if labels[i] == 2
])) if alert_mask.any() else 0.0
return {
"conformal": {
"q_hat": cal_result["q_hat"],
"epsilon": cal_result["epsilon"],
"empirical_coverage": coverage,
"avg_set_size": avg_size,
"coverage_ego": _cov(ego_mask),
"coverage_non_ego": _cov(ne_mask),
"coverage_safe_neg": _cov(sn_mask),
"guaranteed_alert_miss_rate": guaranteed_miss_rate,
**size_by_class,
},
"risk_control": {
"lambda": risk_lambda,
"alert_miss_rate": risk_alert_miss,
"avg_set_size": float(risk_sets.sum(axis=1).mean()),
"coverage": float(np.mean([labels[i] in np.where(risk_sets[i])[0] for i in range(N)])),
},
"uncertainty_stats": {
"mean_u": float(u_flat.mean()),
"u_alert": float(u_flat[alert_mask].mean()) if alert_mask.any() else 0.0,
"u_silent": float(u_flat[labels == 0].mean()) if (labels == 0).any() else 0.0,
"u_observe": float(u_flat[labels == 1].mean()) if (labels == 1).any() else 0.0,
},
"u_coverage_curve": u_coverage_curve,
"n_samples": int(N),
}
def main():
parser = argparse.ArgumentParser("conformal_risk")
parser.add_argument("--sft_checkpoint", required=True)
parser.add_argument("--v4_ckpt", required=True, help="Policy checkpoint (v4 or v5)")
parser.add_argument("--label_dir", default="data/policy_labels")
parser.add_argument("--belief_cache_dir", default=None)
parser.add_argument("--output_dir", default="eval_results/paper_comparison")
parser.add_argument("--epsilon", type=float, default=0.05)
parser.add_argument("--cost_miss_alert", type=float, default=5.0)
parser.add_argument("--cost_fa", type=float, default=1.0)
parser.add_argument("--batch_size", type=int, default=256)
args = parser.parse_args()
cache_dir = Path(args.belief_cache_dir) if args.belief_cache_dir else None
def _cache_path(split):
if cache_dir is None:
return None
p = cache_dir / f"{split}.pt"
return p if p.exists() else None
# auto-detect model version
model_version = _detect_model_version(args.v4_ckpt)
logger.info(f"Detected model version: {model_version}")
# load val set — v6/v7 temporal models need sequence datasets
if model_version in ("v6", "v7"):
meta_path = Path(args.v4_ckpt) / "policy_meta.json"
seq_len = 8
if meta_path.exists():
with open(meta_path) as f:
seq_len = json.load(f).get("seq_len", 8)
if model_version == "v7":
val_ds = TrajectoryPolicyDataset(
manifests=[Path(args.label_dir) / "val.json"],
split="val",
belief_cache_path=_cache_path("val"),
seq_len=seq_len,
)
val_loader = DataLoader(
val_ds, batch_size=args.batch_size, shuffle=False,
num_workers=4, collate_fn=trajectory_collate_fn,
)
else:
val_ds = TemporalPolicyDataset(
manifests=[Path(args.label_dir) / "val.json"],
split="val",
belief_cache_path=_cache_path("val"),
seq_len=seq_len,
)
val_loader = DataLoader(
val_ds, batch_size=args.batch_size, shuffle=False,
num_workers=4, collate_fn=temporal_collate_fn,
)
else:
val_ds = PolicyDataset(
manifests=[Path(args.label_dir) / "val.json"],
split="val",
belief_cache_path=_cache_path("val"),
)
val_loader = DataLoader(
val_ds, batch_size=args.batch_size, shuffle=False,
num_workers=4, collate_fn=policy_collate_fn,
)
# infer hidden_dim from the val cache (backbone-agnostic)
ds_cache = getattr(val_ds, "_cache", None)
if ds_cache is not None and "beliefs" in ds_cache:
cache_hidden_dim = int(ds_cache["beliefs"].shape[-1])
else:
cache_hidden_dim = 2048 # legacy fallback
# load model
if model_version == "v7":
# detect use_gru from meta or state_dict
use_gru = True
if meta_path.exists():
with open(meta_path) as f:
use_gru = json.load(f).get("use_gru", True)
model = TrajectoryPolicyModel(
hidden_dim=cache_hidden_dim, seq_len=seq_len, use_gru=use_gru
)
model.load_policy_checkpoint(args.v4_ckpt)
elif model_version == "v6":
model = TemporalPolicyModel(hidden_dim=cache_hidden_dim, seq_len=seq_len)
model.load_policy_checkpoint(args.v4_ckpt)
elif model_version == "v5":
model = HierarchicalPolicyModel(
sft_checkpoint_dir=args.sft_checkpoint,
use_bf16=True,
)
model.load_policy_checkpoint(args.v4_ckpt)
else:
model = EvidentialPolicyModel(
sft_checkpoint_dir=args.sft_checkpoint,
use_bf16=True,
)
model.load_policy_checkpoint(args.v4_ckpt)
model.eval()
# extract outputs
all_alphas = [] # [N, 3] — Dirichlet α (v4), pseudo-probs (v5), or softmax probs (v6)
all_labels = []
all_cats = []
all_ttas = []
all_vids = []
logger.info(f"Extracting predictions from val set ({model_version})...")
with torch.no_grad():
for batch in tqdm(val_loader, desc=f"Extract ({model_version})", ncols=80):
if model_version == "v7":
logits, _danger_t = model(
batch["belief_seqs"], batch["tta_mean_seqs"], batch["tta_var_seqs"]
)
probs = torch.softmax(logits, dim=-1).cpu().numpy()
all_alphas.append(probs)
elif model_version == "v6":
logits = model(batch["belief_seqs"], batch["tta_mean_seqs"], batch["tta_var_seqs"])
probs = torch.softmax(logits, dim=-1).cpu().numpy()
all_alphas.append(probs)
elif model_version == "v5":
if "beliefs" in batch:
out = model.forward_cached(batch["beliefs"], batch["tta_means"], batch["tta_vars"])
else:
out = model(batch["images"], batch["metadata"])
alert_logit = out[0].cpu().numpy()
danger_logit = out[1].cpu().numpy()
all_alphas.append(_v5_logits_to_probs(alert_logit, danger_logit))
else:
if "beliefs" in batch:
out = model.forward_cached(batch["beliefs"], batch["tta_means"], batch["tta_vars"])
else:
out = model(batch["images"], batch["metadata"])
all_alphas.append(out.cpu().numpy())
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"])
alphas = np.concatenate(all_alphas, axis=0)
labels = np.array(all_labels)
cats = np.array(all_cats)
ttas = np.array(all_ttas)
# split val into calibration (50%) and test (50%)
n = len(labels)
np.random.seed(42)
perm = np.random.permutation(n)
n_cal = n // 2
cal_idx, test_idx = perm[:n_cal], perm[n_cal:]
logger.info(f"Calibration: {n_cal} samples, Test: {n - n_cal} samples")
# calibrate on first half
cal_result = calibrate_conformal(alphas[cal_idx], labels[cal_idx], args.epsilon)
logger.info(f"Conformal q_hat = {cal_result['q_hat']:.4f} (epsilon={args.epsilon})")
risk_result = calibrate_risk_control(
alphas[cal_idx], labels[cal_idx],
epsilon=args.epsilon,
cost_miss_alert=args.cost_miss_alert,
cost_fa=args.cost_fa,
)
logger.info(f"Risk control lambda = {risk_result['lambda']:.4f}")
# evaluate on second half
test_vids = [all_vids[i] for i in test_idx]
eval_result = evaluate_conformal(
alphas[test_idx], labels[test_idx], cats[test_idx], ttas[test_idx],
test_vids, cal_result, risk_result,
)
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
with open(out_dir / "conformal_results.json", "w") as f:
json.dump({
"model_version": model_version,
"calibration": cal_result,
"risk_control": risk_result,
"evaluation": eval_result,
}, f, indent=2, default=str)
logger.info(f"\nResults saved to {out_dir / 'conformal_results.json'}")
logger.info(f" Model version: {model_version}")
logger.info(f" Coverage: {eval_result['conformal']['empirical_coverage']:.4f}")
logger.info(f" Avg set size: {eval_result['conformal']['avg_set_size']:.2f}")
logger.info(f" Guaranteed alert miss: {eval_result['conformal']['guaranteed_alert_miss_rate']:.4f}")
logger.info(f" Risk control alert miss: {eval_result['risk_control']['alert_miss_rate']:.4f}")
if model_version in ("v5", "v6", "v7"):
logger.info(" Note: uncertainty stats (K/S) are not meaningful for %s "
"(no Dirichlet output)" % model_version)
if __name__ == "__main__":
main()