VLAlert / training /Policy /multichannel_dataset.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
6.89 kB
"""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() # [N, T, D]
self.vf = c["valid_frames"].bool() # [N, T]
self.bt = c["beliefs_text"].float() # [N, D]
self.tm = c["tta_means"].float() # [N]
self.tv = c["tta_vars"].float() # [N]
self.ids = c["meta"]["ids"]
self.action_labels = c["meta"].get("action_labels", [])
# ── V-JEPA feature dict ──────────────────────────────────────────
if vjepa_path is None:
# default heuristic: train→multisrc train, val→multisrc val,
# test→clip features
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")
# detect per-frame [T, 1024] vs clip-level [1024]
if self.vj_dict:
sample = next(iter(self.vj_dict.values()))
self.vj_per_frame = (sample.dim() == 2)
else:
self.vj_per_frame = False
# pre-compute clip-level V-JEPA per id (mean-pool if per-frame)
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}")
# ── Labels (optional) ────────────────────────────────────────────
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:
# fallback: from cache action_labels
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