| |
| """ |
| Generate Nexar submission CSV and optionally evaluate against solution.csv. |
| |
| Modes: |
| 1. zero_shot β use P(ALERT) from cached PolicyHead directly (no extra training) |
| 2. trained β use fine-tuned NexarTemporalHead/NexarSimpleHead |
| 3. ensemble β weighted blend of trained scores + baseline sample_submission scores |
| |
| Usage: |
| # Zero-shot (fastest, no training needed): |
| python -m training.Nexar.nexar_submit \ |
| --mode zero_shot \ |
| --test_cache data/nexar_cache/test.pt \ |
| --out_csv submissions/nexar_zero_shot.csv |
| |
| # Trained model: |
| python -m training.Nexar.nexar_submit \ |
| --mode trained \ |
| --test_cache data/nexar_cache/test.pt \ |
| --model_dir checkpoints/Nexar/nexar_v1 \ |
| --out_csv submissions/nexar_trained.csv |
| |
| # Ensemble: |
| python -m training.Nexar.nexar_submit \ |
| --mode ensemble \ |
| --test_cache data/nexar_cache/test.pt \ |
| --model_dir checkpoints/Nexar/nexar_v1 \ |
| --baseline_csv nexar-collision-prediction/sample_submission.csv \ |
| --ensemble_alpha 0.5 \ |
| --out_csv submissions/nexar_ensemble.csv \ |
| --evaluate NEXAR_COLLISION/solution.csv |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| from pathlib import Path |
| from typing import Dict, List, Optional |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| import torch.nn.functional as F |
| from torch.utils.data import DataLoader |
| from tqdm import tqdm |
|
|
| import sys |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) |
|
|
| from training.Nexar.nexar_dataset import NexarTestDataset, nexar_collate_test |
| from training.Nexar.nexar_model import build_model |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| logger = logging.getLogger("Nexar.submit") |
|
|
|
|
| |
|
|
| def scores_zero_shot(cache_file: str, n_windows: int = 3, agg: str = "max_last") -> Dict[str, float]: |
| """ |
| Zero-shot collision scores from cached P(ALERT) values. |
| |
| agg strategies: |
| last β P(ALERT) of the last (most recent) window only |
| max β max P(ALERT) over all windows |
| max_last β 0.7 * last + 0.3 * max (emphasises recency) |
| weighted β linearly increasing weights over windows (latest = highest) |
| tta β 1 / (1 + tta_mean_last) combined with p_alert_last |
| """ |
| cache = torch.load(cache_file, map_location="cpu", weights_only=False) |
| scores = {} |
| for vid_id, feat in cache["features"].items(): |
| p = feat["p_alert"].float() |
| tta = feat["tta_means"].float() |
|
|
| if agg == "last": |
| s = p[-1].item() |
| elif agg == "max": |
| s = p.max().item() |
| elif agg == "max_last": |
| s = 0.6 * p[-1].item() + 0.4 * p.max().item() |
| elif agg == "weighted": |
| T = p.shape[0] |
| w = torch.linspace(0.5, 1.0, T) |
| w = w / w.sum() |
| s = (p * w).sum().item() |
| elif agg == "tta": |
| |
| tta_score = 1.0 / (1.0 + tta[-1].item()) |
| s = 0.6 * p[-1].item() + 0.4 * tta_score |
| else: |
| s = p[-1].item() |
| scores[vid_id] = float(np.clip(s, 0, 1)) |
| return scores |
|
|
|
|
| @torch.no_grad() |
| def scores_trained( |
| cache_file: str, |
| model_dir: str, |
| batch_size: int = 128, |
| n_windows: int = 3, |
| ) -> Dict[str, float]: |
| """Run fine-tuned NexarHead on test features.""" |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| meta_path = Path(model_dir) / "best_meta.json" |
| with open(meta_path) as f: |
| meta = json.load(f) |
| hidden_dim = meta["hidden_dim"] |
| arch = meta["arch"] |
| n_windows = meta.get("n_windows", n_windows) |
|
|
| model = build_model(hidden_dim, arch).to(device) |
| model.load_state_dict(torch.load(Path(model_dir) / "best_model.pt", map_location=device)) |
| model.eval() |
|
|
| ds = NexarTestDataset(cache_file, n_windows=n_windows) |
| loader = DataLoader(ds, batch_size=batch_size, shuffle=False, |
| num_workers=4, collate_fn=nexar_collate_test, pin_memory=True) |
|
|
| scores = {} |
| for batch in tqdm(loader, desc="Scoring"): |
| beliefs = batch["beliefs"].to(device) |
| tta_means = batch["tta_means"].to(device) |
| tta_vars = batch["tta_vars"].to(device) |
| p_alerts = batch["p_alerts"].to(device) |
|
|
| if hasattr(model, "lstm"): |
| s = model(beliefs, tta_means, tta_vars, p_alerts) |
| else: |
| s = model(beliefs[:, -1, :], tta_means[:, -1], tta_vars[:, -1], p_alerts[:, -1]) |
|
|
| for vid_id, score in zip(batch["video_ids"], s.cpu().tolist()): |
| scores[vid_id] = float(np.clip(score, 0, 1)) |
| return scores |
|
|
|
|
| def scores_ensemble( |
| primary: Dict[str, float], |
| baseline: Dict[str, float], |
| alpha: float = 0.5, |
| ) -> Dict[str, float]: |
| """ |
| Blend primary scores with baseline (sample_submission) scores. |
| score = alpha * primary + (1 - alpha) * baseline |
| """ |
| all_ids = set(primary) | set(baseline) |
| result = {} |
| for vid_id in all_ids: |
| p = primary.get(vid_id, 0.5) |
| b = baseline.get(vid_id, 0.5) |
| result[vid_id] = float(np.clip(alpha * p + (1 - alpha) * b, 0, 1)) |
| return result |
|
|
|
|
| def evaluate_submission(submission: Dict[str, float], solution_csv: str): |
| """Compute mAP (Public) and mAP (Private) against solution.csv.""" |
| from sklearn.metrics import average_precision_score |
|
|
| sol = pd.read_csv(solution_csv) |
| sol["id"] = sol["id"].astype(str).str.zfill(5) |
| sub_df = pd.DataFrame(list(submission.items()), columns=["id", "score"]) |
| sub_df["id"] = sub_df["id"].astype(str).str.zfill(5) |
|
|
| for usage in ["Public", "Private"]: |
| subset = sol[sol["Usage"] == usage].copy() |
| merged = subset.merge(sub_df, on="id", how="left").fillna(0.5) |
| ap_list = [] |
| for g in sorted(merged["group"].unique()): |
| g_df = merged[merged["group"] == g] |
| if g_df["target"].nunique() < 2: |
| continue |
| ap = average_precision_score(g_df["target"], g_df["score"]) |
| ap_list.append(ap) |
| mean_ap = np.mean(ap_list) if ap_list else float("nan") |
| print(f"mAP ({usage}): {mean_ap:.6f}") |
|
|
|
|
| def write_submission(scores: Dict[str, float], out_csv: str, test_csv: Optional[str] = None): |
| """Write submission CSV. Aligns with test.csv order if provided.""" |
| out = Path(out_csv) |
| out.parent.mkdir(parents=True, exist_ok=True) |
|
|
| if test_csv and Path(test_csv).exists(): |
| ids = pd.read_csv(test_csv)["id"].astype(str).str.zfill(5).tolist() |
| else: |
| ids = sorted(scores.keys()) |
|
|
| rows = [{"id": vid_id, "score": scores.get(vid_id, 0.5)} for vid_id in ids] |
| df = pd.DataFrame(rows) |
| df.to_csv(out_csv, index=False) |
| logger.info(f"Submission saved β {out_csv} ({len(df)} rows)") |
|
|
| |
| vals = [r["score"] for r in rows] |
| logger.info( |
| f"Score stats: mean={np.mean(vals):.3f} std={np.std(vals):.3f} " |
| f"min={np.min(vals):.3f} max={np.max(vals):.3f} " |
| f"p50={np.median(vals):.3f} p90={np.percentile(vals,90):.3f}" |
| ) |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser("nexar_submit") |
| parser.add_argument("--mode", required=True, choices=["zero_shot", "trained", "ensemble"]) |
| parser.add_argument("--test_cache", required=True) |
| parser.add_argument("--model_dir", default=None) |
| parser.add_argument("--baseline_csv", default=None, |
| help="sample_submission.csv for ensemble mode") |
| parser.add_argument("--ensemble_alpha", type=float, default=0.5, |
| help="Weight for primary model (0=baseline only, 1=model only)") |
| parser.add_argument("--zero_shot_agg", default="max_last", |
| choices=["last", "max", "max_last", "weighted", "tta"]) |
| parser.add_argument("--n_windows", type=int, default=3) |
| parser.add_argument("--out_csv", required=True) |
| parser.add_argument("--test_csv", default="nexar-collision-prediction/test.csv") |
| parser.add_argument("--evaluate", default=None, |
| help="Path to solution.csv for local evaluation") |
| args = parser.parse_args() |
|
|
| |
| if args.mode == "zero_shot": |
| logger.info(f"Zero-shot inference (agg={args.zero_shot_agg}) ...") |
| primary_scores = scores_zero_shot(args.test_cache, args.n_windows, args.zero_shot_agg) |
|
|
| elif args.mode == "trained": |
| if not args.model_dir: |
| parser.error("--model_dir required for mode=trained") |
| logger.info("Running trained NexarHead ...") |
| primary_scores = scores_trained(args.test_cache, args.model_dir, |
| n_windows=args.n_windows) |
|
|
| elif args.mode == "ensemble": |
| if not args.model_dir: |
| parser.error("--model_dir required for mode=ensemble") |
| logger.info("Running trained NexarHead + ensemble ...") |
| trained = scores_trained(args.test_cache, args.model_dir, n_windows=args.n_windows) |
|
|
| baseline = {} |
| if args.baseline_csv and Path(args.baseline_csv).exists(): |
| b_df = pd.read_csv(args.baseline_csv) |
| for _, row in b_df.iterrows(): |
| vid_id = str(row["id"]).zfill(5) |
| baseline[vid_id] = float(row["score"]) |
| logger.info(f"Baseline scores loaded: {len(baseline)} entries") |
|
|
| primary_scores = scores_ensemble(trained, baseline, args.ensemble_alpha) |
|
|
| |
| write_submission(primary_scores, args.out_csv, args.test_csv) |
|
|
| |
| if args.evaluate: |
| logger.info(f"\nLocal evaluation against {args.evaluate}:") |
| evaluate_submission(primary_scores, args.evaluate) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|