| """VLAlert-X v2 SFT dataset β per-frame BELIEF reasoning content. |
| |
| KEY DIFFERENCE from v1 (`cot_belief_dataset.py`): |
| v1 wrote `<|BELIEF|> <|ACTION_i|> </|BELIEF|>` (action token wedged BETWEEN |
| BELIEF tags β causes leak when pooling at BELIEF positions). |
| v2 writes `<|BELIEF|> {per-frame reasoning text} </|BELIEF|> <|ACTION_i|>` |
| so BELIEF tags wrap actual REASONING and the action token sits AFTER the |
| closing tag. Pooling inside the BELIEF span yields a leak-free perception |
| vector; the action token never enters the pool window. |
| |
| Manifest schema expected (one record per tick, jsonl): |
| { |
| "id": str, "video_id": str, "video_path": str, "source": str, |
| "frame_indices": [8 ints], |
| "actions_per_frame": [8 strs of {SILENT, OBSERVE, ALERT}], |
| "beliefs_per_frame": [8 strs, 10-25 tokens each], |
| "danger_per_frame": [8 floats in [0, 1]], |
| "tta_per_frame": [8 floats, seconds], |
| "tick_action": str, |
| "tick_tta_raw": float, |
| "scene": str (optional, prepended if non-empty), |
| "critical": str (optional, prepended if non-empty), |
| ... |
| } |
| |
| Assistant text format produced: |
| [Scene: ...] β optional |
| [Critical: ...] β optional |
| <|BELIEF|> {belief_0} </|BELIEF|> <|ACTION_0|> |
| <|BELIEF|> {belief_1} </|BELIEF|> <|ACTION_1|> |
| ... |
| <|BELIEF|> {belief_7} </|BELIEF|> <|ACTION_7|> |
| |
| CE loss is on all assistant tokens (model must generate the belief text AND |
| the action token). Belief content is teacher-forced from manifest during SFT |
| so the model learns: visual β reasoning + action. |
| |
| For cache extraction (separate, see `tools/make_cache_x_v2.py`), action tokens |
| are STRIPPED from the prompt so causal attention can't leak GT actions when |
| we pool inside the BELIEF span. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional |
|
|
| import torch |
| from torch.utils.data import Dataset |
|
|
| from training.VLA.frame_utils import sample_frames |
|
|
|
|
| |
|
|
| BELIEF_OPEN = "<|BELIEF|>" |
| BELIEF_CLOSE = "</|BELIEF|>" |
| ACTION_ALERT = "<|ALERT|>" |
| ACTION_OBSERVE = "<|OBSERVE|>" |
| ACTION_SILENT = "<|SILENT|>" |
| ACTION_TOKENS = [ACTION_ALERT, ACTION_OBSERVE, ACTION_SILENT] |
| ALL_SPECIAL = [BELIEF_OPEN, BELIEF_CLOSE] + ACTION_TOKENS |
|
|
| ACTION_MAP = { |
| "ALERT": ACTION_ALERT, |
| "OBSERVE": ACTION_OBSERVE, |
| "SILENT": ACTION_SILENT, |
| } |
|
|
| ACTION_TO_IDX = {"SILENT": 0, "OBSERVE": 1, "ALERT": 2} |
|
|
|
|
| |
|
|
| SYSTEM_PROMPT_V2 = ( |
| "You are a driving-safety assistant. Given N dashcam frames " |
| "(earliest β latest), for each frame produce a short reasoning sentence " |
| "describing the most safety-relevant cue you observe (lead-vehicle behaviour, " |
| "TTC estimate, pedestrians, sudden brake, lane drift, etc.), wrap it in " |
| "<|BELIEF|>...</|BELIEF|>, then immediately emit the per-frame action: " |
| "<|SILENT|> (no threat), <|OBSERVE|> (developing situation), " |
| "or <|ALERT|> (imminent collision risk, < 2 s)." |
| ) |
|
|
| USER_PROMPT_V2 = ( |
| "Emit 8 per-frame belief+action blocks for these frames." |
| ) |
|
|
|
|
| def format_assistant_v2(beliefs_per_frame: List[str], |
| actions_per_frame: List[str], |
| scene: str = "", |
| critical: str = "") -> str: |
| """Build the assistant string for v2 SFT. |
| |
| `beliefs_per_frame` must have length 8 (one per frame). |
| `actions_per_frame` must have length 8, values in {SILENT, OBSERVE, ALERT}. |
| `scene` and `critical` are optional clip-level prefix lines. |
| """ |
| assert len(beliefs_per_frame) == 8, "expected 8 belief sentences" |
| assert len(actions_per_frame) == 8, "expected 8 actions" |
| lines: List[str] = [] |
| scene = (scene or "").strip() |
| critical = (critical or "").strip() |
| if scene: |
| lines.append(f"Scene: {scene}") |
| if critical: |
| lines.append(f"Critical: {critical}") |
| if lines: |
| lines.append("") |
| for b, a in zip(beliefs_per_frame, actions_per_frame): |
| b_clean = (b or "").strip().replace("\n", " ") |
| |
| b_clean = " ".join(b_clean.split()[:25]) |
| action_tok = ACTION_MAP.get(a, ACTION_SILENT) |
| lines.append(f"{BELIEF_OPEN} {b_clean} {BELIEF_CLOSE} {action_tok}") |
| return "\n".join(lines) |
|
|
|
|
| def build_chat_v2(frames, assistant_text: Optional[str]): |
| user_content = [{"type": "image", "image": img} for img in frames] |
| user_content.append({"type": "text", "text": USER_PROMPT_V2}) |
| msgs = [ |
| {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_V2}]}, |
| {"role": "user", "content": user_content}, |
| ] |
| if assistant_text is not None: |
| msgs.append({"role": "assistant", |
| "content": [{"type": "text", "text": assistant_text}]}) |
| return msgs |
|
|
|
|
| |
|
|
| class CoTBeliefDatasetV2(Dataset): |
| """Per-frame BELIEF reasoning SFT dataset. |
| |
| Requires the processor's tokenizer to already have ALL_SPECIAL added. |
| """ |
|
|
| def __init__(self, |
| jsonl_path: str, |
| processor, |
| n_frames: int = 8, |
| resize_short: int = 336, |
| max_len: int = 4096, |
| action_token_weight: float = 2.0): |
| """ |
| action_token_weight: 2.0 β action token positions get 2Γ CE weight |
| (encourages crisp action prediction; tracked via |
| returned `action_token_mask`). |
| """ |
| self.processor = processor |
| self.n_frames = n_frames |
| self.resize_short = resize_short |
| self.max_len = max_len |
| self.action_token_weight = action_token_weight |
|
|
| self.records: List[Dict[str, Any]] = [] |
| n_skipped = 0 |
| with open(jsonl_path) as f: |
| for ln in f: |
| ln = ln.strip() |
| if not ln: continue |
| try: |
| r = json.loads(ln) |
| except json.JSONDecodeError: |
| continue |
| |
| ok = (isinstance(r.get("beliefs_per_frame"), list) |
| and len(r["beliefs_per_frame"]) == n_frames |
| and isinstance(r.get("actions_per_frame"), list) |
| and len(r["actions_per_frame"]) == n_frames |
| and isinstance(r.get("frame_indices"), list) |
| and len(r["frame_indices"]) == n_frames |
| and r.get("video_path")) |
| if not ok: |
| n_skipped += 1 |
| continue |
| self.records.append(r) |
| print(f"[CoTBeliefDatasetV2] loaded {len(self.records)} records " |
| f"(skipped {n_skipped} malformed) from {jsonl_path}") |
|
|
| |
| tok = processor.tokenizer |
| self.action_ids = set() |
| for t in ACTION_TOKENS: |
| tid = tok.convert_tokens_to_ids(t) |
| if tid is not None and tid != tok.unk_token_id: |
| self.action_ids.add(tid) |
|
|
| def __len__(self): |
| return len(self.records) |
|
|
| def __getitem__(self, idx): |
| rec = self.records[idx] |
| |
| frames = sample_frames( |
| rec["video_path"], n_frames=self.n_frames, |
| resize_short=self.resize_short, |
| frame_indices=rec["frame_indices"], |
| ) |
| |
| assistant_text = format_assistant_v2( |
| beliefs_per_frame=rec["beliefs_per_frame"], |
| actions_per_frame=rec["actions_per_frame"], |
| scene=rec.get("scene", ""), |
| critical=rec.get("critical", ""), |
| ) |
| full_msgs = build_chat_v2(frames, assistant_text) |
| prefix_msgs = build_chat_v2(frames, None) |
|
|
| proc = self.processor |
| full_text = proc.apply_chat_template(full_msgs, tokenize=False, |
| add_generation_prompt=False) |
| prefix_text = proc.apply_chat_template(prefix_msgs, tokenize=False, |
| add_generation_prompt=True) |
| full = proc(text=[full_text], images=[frames], return_tensors="pt", |
| padding=False, truncation=True, max_length=self.max_len) |
| prefix = proc(text=[prefix_text], images=[frames], return_tensors="pt", |
| padding=False, truncation=True, max_length=self.max_len) |
|
|
| input_ids = full["input_ids"][0] |
| labels = input_ids.clone() |
| prefix_len = prefix["input_ids"].shape[1] |
| labels[:prefix_len] = -100 |
|
|
| |
| action_mask = torch.zeros_like(input_ids, dtype=torch.bool) |
| for i, tid in enumerate(input_ids.tolist()): |
| if i >= prefix_len and tid in self.action_ids: |
| action_mask[i] = True |
|
|
| |
| |
| |
| item = { |
| "input_ids": input_ids, |
| "labels": labels, |
| "action_token_mask": action_mask, |
| "attention_mask": full["attention_mask"][0] |
| if "attention_mask" in full else None, |
| "pixel_values": full["pixel_values"] |
| if "pixel_values" in full else None, |
| "image_grid_thw": full["image_grid_thw"] |
| if "image_grid_thw" in full else None, |
| } |
| for k in ("video_grid_thw", "pixel_values_videos"): |
| if k in full: |
| item[k] = full[k] |
| return item |
|
|
|
|
| |
|
|
| class CollatorV2: |
| """Pad seq dim; cat pixel/grid along their natural dim (matches Qwen3-VL).""" |
|
|
| def __init__(self, processor, n_frames: int = 8): |
| self.processor = processor |
| self.n_frames = n_frames |
| self.pad_id = (processor.tokenizer.pad_token_id |
| or processor.tokenizer.eos_token_id or 0) |
|
|
| def __call__(self, batch): |
| max_len = max(b["input_ids"].size(0) for b in batch) |
| ids = torch.full((len(batch), max_len), self.pad_id, dtype=torch.long) |
| labs = torch.full((len(batch), max_len), -100, dtype=torch.long) |
| amask = torch.zeros((len(batch), max_len), dtype=torch.bool) |
| attn_mask = torch.zeros((len(batch), max_len), dtype=torch.long) |
| for i, b in enumerate(batch): |
| L = b["input_ids"].size(0) |
| ids[i, :L] = b["input_ids"] |
| labs[i, :L] = b["labels"] |
| amask[i, :L] = b["action_token_mask"] |
| if b.get("attention_mask") is not None: |
| attn_mask[i, :L] = b["attention_mask"] |
| else: |
| attn_mask[i, :L] = 1 |
| out = { |
| "input_ids": ids, |
| "labels": labs, |
| "attention_mask": attn_mask, |
| "action_token_mask": amask, |
| } |
| |
| if batch[0].get("pixel_values") is not None: |
| out["pixel_values"] = torch.cat([b["pixel_values"] for b in batch], dim=0) |
| |
| if batch[0].get("image_grid_thw") is not None: |
| out["image_grid_thw"] = torch.cat([b["image_grid_thw"] for b in batch], dim=0) |
| for k in ("video_grid_thw", "pixel_values_videos"): |
| if batch[0].get(k) is not None: |
| out[k] = torch.cat([b[k] for b in batch], dim=0) |
| return out |
|
|