| """Multi-channel dataset for LKAlert-MCB. |
| |
| **Design (post Day-10 pivot 2026-04-27):** |
| LKAlert-MCB is a 2-channel architecture for the headline: |
| - Channel 1 (Qwen semantic): belief_frame [B,T,2560] + valid + text |
| - Channel 3 (V-JEPA dynamics): clip-level vjepa_feature [B,1024] (mean |
| pooled from per-frame [16,1024]) |
| |
| Channel 2 (object motion) is intentionally NOT a learned input here β |
| it failed Red Line 4 gate on Day 10. Object features remain on disk |
| for taxonomy / qualitative figures / appendix Table 6. |
| |
| **Per-cache joins are by clip_id, never by index** (Rule 3). |
| |
| Each row returned by the dataset is a dict: |
| {belief, valid, text, vjepa, vjepa_mask, tta_mean, tta_var, vid, y_p_any} |
| |
| When V-JEPA features are missing for a clip, `vjepa = zeros(1024)` and |
| `vjepa_mask = 0` so the fusion MLP can learn to ignore the channel. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from pathlib import Path |
| from typing import Dict, List, Optional |
|
|
| import numpy as np |
| import torch |
| from torch.utils.data import Dataset |
|
|
| logger = logging.getLogger("multichannel_dataset") |
|
|
| CACHE_DIR = Path("data/belief_cache_perframe_qwen3vl4b") |
| DIAG_DIR = Path("data/policy_labels") |
| VJEPA_DIR = Path("data/vjepa_features") |
|
|
|
|
| def _diag_filename(cache_name: str) -> str: |
| if cache_name.endswith("_diag"): |
| return f"{cache_name}.json" |
| return f"{cache_name}_diag.json" |
|
|
|
|
| def _resolve_vjepa_short(vid: str) -> str: |
| """V-JEPA dicts use the short Nexar id (no `nexar_` prefix).""" |
| return vid.replace("nexar_", "") |
|
|
|
|
| class MultichannelDataset(Dataset): |
| """Joins Qwen belief cache + V-JEPA features (and optional labels).""" |
|
|
| def __init__(self, |
| cache_name: str, |
| split: str, |
| vjepa_path: Optional[Path] = None, |
| with_labels: bool = True): |
| self.cache_name = cache_name |
| self.split = split |
|
|
| cache_path = CACHE_DIR / f"{cache_name}.pt" |
| if not cache_path.exists(): |
| raise FileNotFoundError(f"Qwen cache not found: {cache_path}") |
| c = torch.load(cache_path, weights_only=False, map_location="cpu") |
|
|
| self.bf = c["beliefs_frame"].float() |
| self.vf = c["valid_frames"].bool() |
| self.bt = c["beliefs_text"].float() |
| self.tm = c["tta_means"].float() |
| self.tv = c["tta_vars"].float() |
| self.ids = c["meta"]["ids"] |
| self.action_labels = c["meta"].get("action_labels", []) |
|
|
| |
| if vjepa_path is None: |
| |
| |
| if split == "train": |
| vjepa_path = VJEPA_DIR / "train_perframe_multisrc.pt" |
| elif split == "val": |
| vjepa_path = VJEPA_DIR / "val_perframe_multisrc.pt" |
| else: |
| vjepa_path = VJEPA_DIR / "test_clip_features.pt" |
|
|
| if not vjepa_path.exists(): |
| logger.warning(f" V-JEPA cache {vjepa_path} missing β " |
| "all V-JEPA features will be zero-masked") |
| self.vj_dict = {} |
| else: |
| self.vj_dict = torch.load(vjepa_path, weights_only=False, |
| map_location="cpu") |
|
|
| |
| if self.vj_dict: |
| sample = next(iter(self.vj_dict.values())) |
| self.vj_per_frame = (sample.dim() == 2) |
| else: |
| self.vj_per_frame = False |
|
|
| |
| self.vj_clip: Dict[str, torch.Tensor] = {} |
| for vid in self.ids: |
| short = _resolve_vjepa_short(vid) |
| v = self.vj_dict.get(short) |
| if v is None: |
| continue |
| v = v.float() |
| if v.dim() == 2: |
| v = v.mean(dim=0) |
| self.vj_clip[vid] = v |
| cov = len(self.vj_clip) / max(1, len(self.ids)) |
| logger.info(f"[mcb-dataset:{cache_name}] N={len(self.ids)} " |
| f"V-JEPA coverage={100*cov:.1f}% " |
| f"vj_per_frame={self.vj_per_frame}") |
|
|
| |
| if with_labels: |
| diag_path = DIAG_DIR / _diag_filename(cache_name) |
| if diag_path.exists(): |
| raw = json.loads(diag_path.read_text()) |
| by_id = {s["video_id"]: s for s in raw["samples"]} |
| self.y_any = np.asarray( |
| [1 if by_id.get(v, {}).get("action_label") == 2 else 0 |
| for v in self.ids], dtype=np.float32) |
| else: |
| |
| self.y_any = np.asarray( |
| [1 if a == 2 else 0 for a in self.action_labels], |
| dtype=np.float32) if self.action_labels else np.zeros( |
| len(self.ids), dtype=np.float32) |
| else: |
| self.y_any = None |
|
|
| def __len__(self) -> int: |
| return len(self.ids) |
|
|
| def __getitem__(self, i: int) -> Dict: |
| vid = self.ids[i] |
| vj = self.vj_clip.get(vid) |
| if vj is None: |
| vj = torch.zeros(1024, dtype=torch.float32) |
| mask = torch.tensor(0.0) |
| else: |
| mask = torch.tensor(1.0) |
| out = { |
| "belief": self.bf[i], |
| "valid": self.vf[i], |
| "text": self.bt[i], |
| "tta_mean": self.tm[i:i+1].squeeze(0), |
| "tta_var": self.tv[i:i+1].squeeze(0), |
| "vjepa": vj, |
| "vjepa_mask": mask, |
| "vid": vid, |
| } |
| if self.y_any is not None: |
| out["y_p_any"] = torch.tensor(float(self.y_any[i]), |
| dtype=torch.float32) |
| return out |
|
|
|
|
| def collate(batch: List[Dict]) -> Dict: |
| out = { |
| "belief": torch.stack([b["belief"] for b in batch]), |
| "valid": torch.stack([b["valid"] for b in batch]), |
| "text": torch.stack([b["text"] for b in batch]), |
| "tta_mean": torch.stack([b["tta_mean"] for b in batch]), |
| "tta_var": torch.stack([b["tta_var"] for b in batch]), |
| "vjepa": torch.stack([b["vjepa"] for b in batch]), |
| "vjepa_mask": torch.stack([b["vjepa_mask"] for b in batch]), |
| "vids": [b["vid"] for b in batch], |
| } |
| if "y_p_any" in batch[0]: |
| out["y_p_any"] = torch.stack([b["y_p_any"] for b in batch]) |
| return out |
|
|