| |
| """ |
| 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" |
| |
| 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) |
| |
| probs = probs / probs.sum(axis=-1, keepdims=True).clip(1e-8) |
| return probs |
|
|
|
|
| def calibrate_conformal( |
| alphas: np.ndarray, |
| labels: np.ndarray, |
| epsilon: float = 0.05, |
| ) -> 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 |
|
|
| idx = np.arange(len(labels)) |
| p_true = probs[idx, labels] |
| scores = 1.0 - p_true |
|
|
| 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 |
| 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) |
|
|
| |
| correction = np.sqrt(np.log(1.0 / epsilon) / (2 * n)) |
| if mean_risk + correction <= best_risk: |
| best_risk = mean_risk + correction |
| best_lambda = lam |
|
|
| |
| 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 |
| sizes = sets.sum(axis=1) |
|
|
| preds = probs.argmax(axis=1) |
| |
| 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) |
|
|
| |
| 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()) |
|
|
| |
| 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_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 |
|
|
| |
| 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()) |
|
|
| |
| 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_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 |
|
|
| |
| model_version = _detect_model_version(args.v4_ckpt) |
| logger.info(f"Detected model version: {model_version}") |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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 |
|
|
| |
| if model_version == "v7": |
| |
| 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() |
|
|
| |
| all_alphas = [] |
| 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) |
|
|
| |
| 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") |
|
|
| |
| 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}") |
|
|
| |
| 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() |
|
|