VLAlert / training /VLA /train_vlalert_sft_v3.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
39.7 kB
#!/usr/bin/env python3
"""VLAlert v3 SFT โ€” Qwen3-VL-4B-Instruct + LoRA.
Fine-tunes Qwen3-VL-4B-Instruct with LoRA to produce structured safety
reasoning and belief tokens from dashcam frames.
Data format (v6_sft_train.jsonl):
Each record has assistant_v6 text with [Analysis] + [Safety Assessment]
sections, where beliefs are wrapped in <|BELIEF|>...</|BELIEF|> tags
followed by action tokens (<|SILENT|>, <|OBSERVE|>, <|ALERT|>).
Loss:
L_total = L_causal_LM + lambda_emb * L_belief_embedding
L_causal_LM: weighted CE with belief content tokens at 1.5x and action
tokens at 2.0x weight.
L_belief_embedding: cosine similarity loss between hidden states at
<|BELIEF|> positions and pre-computed target embeddings (optional,
activated from --belief_emb_start_epoch onward).
Run:
python -m training.VLA.train_vlalert_sft_v3 \
--train_jsonl data/cot_corpus_v3/v6_sft_train.jsonl \
--val_jsonl data/cot_corpus_v3/v6_sft_val.jsonl \
--out_dir checkpoints/vlalert_sft \
--epochs 3 --batch_size 4 --grad_accum 4 --lr 5e-5
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
# โ”€โ”€ Conv3d -> Linear patch (MUST run before any Qwen3-VL import) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
import torch
import torch.nn as nn
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLVisionPatchEmbed
_PATCH_APPLIED: dict = {}
def _fast_patch_embed_forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Conv3d -> Linear lazy replacement (math-identical)."""
target_dtype = self.proj.weight.dtype
if isinstance(self.proj, nn.Conv3d):
conv = self.proj
out_dim = conv.out_channels
in_dim = (conv.in_channels * conv.kernel_size[0]
* conv.kernel_size[1] * conv.kernel_size[2])
w_flat = conv.weight.detach().reshape(out_dim, in_dim).contiguous()
bias = conv.bias.detach().clone() if conv.bias is not None else None
new_proj = nn.Linear(in_dim, out_dim, bias=bias is not None)
new_proj.weight.data.copy_(w_flat)
if bias is not None:
new_proj.bias.data.copy_(bias)
new_proj.to(device=conv.weight.device, dtype=conv.weight.dtype)
self.proj = new_proj
if id(self) not in _PATCH_APPLIED:
_PATCH_APPLIED[id(self)] = True
print(f"[fast_patch] Conv3d({in_dim}->{out_dim}) -> "
f"Linear({in_dim}->{out_dim})", flush=True)
if hidden_states.dim() > 2 or hidden_states.shape[-1] != self.proj.in_features:
hidden_states = hidden_states.reshape(-1, self.proj.in_features)
return self.proj(hidden_states.to(dtype=target_dtype))
Qwen3VLVisionPatchEmbed.forward = _fast_patch_embed_forward
print("[fast_patch] Qwen3VLVisionPatchEmbed.forward patched.", flush=True)
# โ”€โ”€ standard imports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
import argparse
import json
import math
import os
import re
from typing import Any, Dict, List, Optional, Tuple
import cv2
import numpy as np
from PIL import Image
from torch.optim import AdamW
from torch.utils.data import DataLoader, Dataset
from tqdm import tqdm
from peft import LoraConfig, get_peft_model
from transformers import AutoProcessor, AutoModelForImageTextToText
from transformers.optimization import get_cosine_schedule_with_warmup
# โ”€โ”€ special tokens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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
# โ”€โ”€ prompts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
SYSTEM_PROMPT_V3 = (
"You are a driving-safety assistant. Given N dashcam frames "
"(earliest โ†’ latest), first analyze the safety situation for each frame "
"in [Analysis], then produce a structured [Safety Assessment] with "
"per-frame belief summaries wrapped in <|BELIEF|>...</|BELIEF|> "
"and action tokens."
)
def user_prompt_v3(n_frames: int) -> str:
return f"Analyze these {n_frames} frames for driving safety."
# โ”€โ”€ frame loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _resize_bgr(frame: np.ndarray, resize_short: int) -> Image.Image:
h, w = frame.shape[:2]
scale = resize_short / min(h, w)
nh, nw = int(round(h * scale)), int(round(w * scale))
frame = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_AREA)
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
return Image.fromarray(frame)
def load_frames(video_path: str, frame_indices: List[int],
resize_short: int = 336) -> List[Image.Image]:
"""Load specific frames from mp4 or image directory."""
p = Path(video_path)
if p.is_dir():
# Image directory โ€” check for images/ subdirectory (DoTA convention)
if (p / "images").is_dir():
p = p / "images"
exts = (".jpg", ".jpeg", ".png")
files = sorted([f for f in p.iterdir() if f.suffix.lower() in exts])
if not files:
raise RuntimeError(f"no images in {p}")
total = len(files)
frames: List[Image.Image] = []
for idx in frame_indices:
idx_clipped = max(0, min(total - 1, int(idx)))
img = cv2.imread(str(files[idx_clipped]))
if img is None:
img = np.zeros((resize_short, resize_short, 3), dtype=np.uint8)
frames.append(_resize_bgr(img, resize_short))
return frames
# mp4/mkv video file
cap = cv2.VideoCapture(str(p))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
if total <= 0:
cap.release()
raise RuntimeError(f"bad video: {p}")
clipped = [max(0, min(total - 1, int(i))) for i in frame_indices]
wanted_sorted = sorted(set(clipped))
picked: dict = {}
cur = 0
ptr = 0
while cap.isOpened() and ptr < len(wanted_sorted):
ok, frame = cap.read()
if not ok:
break
while ptr < len(wanted_sorted) and cur == wanted_sorted[ptr]:
picked[cur] = frame
ptr += 1
cur += 1
cap.release()
frames = []
fallback = next(iter(picked.values())) if picked else None
for i in clipped:
f = picked.get(i, fallback)
if f is None:
f = np.zeros((resize_short, resize_short, 3), dtype=np.uint8)
frames.append(_resize_bgr(f, resize_short))
return frames
# โ”€โ”€ chat template builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def build_chat_v3(frames: List[Image.Image], n_frames: int,
assistant_text: Optional[str] = None):
"""Build Qwen3-VL chat messages for v3 SFT."""
user_content = [{"type": "image", "image": img} for img in frames]
user_content.append({"type": "text", "text": user_prompt_v3(n_frames)})
msgs = [
{"role": "system",
"content": [{"type": "text", "text": SYSTEM_PROMPT_V3}]},
{"role": "user", "content": user_content},
]
if assistant_text is not None:
msgs.append({"role": "assistant",
"content": [{"type": "text", "text": assistant_text}]})
return msgs
# โ”€โ”€ Dataset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class VLAlertSFTDatasetV3(Dataset):
"""v3 SFT dataset reading v6_sft_*.jsonl records.
Each record has variable n_frames (1 or 8), assistant_v6 text with
[Analysis] + [Safety Assessment] sections, and per-frame beliefs.
"""
def __init__(self,
jsonl_path: str,
processor,
resize_short: int = 336,
max_len: int = 4096):
self.processor = processor
self.resize_short = resize_short
self.max_len = max_len
# cache token IDs for action and belief tokens
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)
self.belief_open_id = tok.convert_tokens_to_ids(BELIEF_OPEN)
self.belief_close_id = tok.convert_tokens_to_ids(BELIEF_CLOSE)
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:
n_skipped += 1
continue
# validate required fields
ok = (r.get("video_path")
and isinstance(r.get("frame_indices"), list)
and len(r["frame_indices"]) > 0
and isinstance(r.get("n_frames"), int)
and r["n_frames"] == len(r["frame_indices"])
and isinstance(r.get("assistant_v6"), str)
and len(r["assistant_v6"].strip()) > 0
and isinstance(r.get("beliefs_per_frame"), list)
and len(r["beliefs_per_frame"]) == r["n_frames"])
if not ok:
n_skipped += 1
continue
self.records.append(r)
print(f"[VLAlertSFTDatasetV3] loaded {len(self.records)} records "
f"(skipped {n_skipped}) from {jsonl_path}")
def __len__(self):
return len(self.records)
def __getitem__(self, idx):
rec = self.records[idx]
n_frames = rec["n_frames"]
assistant_text = rec["assistant_v6"]
# load frames
frames = load_frames(rec["video_path"], rec["frame_indices"],
resize_short=self.resize_short)
# build full (with assistant) and prefix (without) chat
full_msgs = build_chat_v3(frames, n_frames, assistant_text)
prefix_msgs = build_chat_v3(frames, n_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
# Build per-token weight masks
# action_token_mask: True at action token positions
# belief_content_mask: True at tokens BETWEEN <|BELIEF|> and </|BELIEF|>
# (excluding the tags themselves)
seq_len = input_ids.size(0)
action_mask = torch.zeros(seq_len, dtype=torch.bool)
belief_content_mask = torch.zeros(seq_len, dtype=torch.bool)
belief_open_mask = torch.zeros(seq_len, dtype=torch.bool)
ids_list = input_ids.tolist()
in_belief = False
for i in range(prefix_len, seq_len):
tid = ids_list[i]
if tid in self.action_ids:
action_mask[i] = True
if tid == self.belief_open_id:
in_belief = True
belief_open_mask[i] = True
continue
if tid == self.belief_close_id:
in_belief = False
continue
if in_belief:
belief_content_mask[i] = True
item = {
"input_ids": input_ids,
"labels": labels,
"action_token_mask": action_mask,
"belief_content_mask": belief_content_mask,
"belief_open_mask": belief_open_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),
"record_idx": idx,
"belief_source": rec.get("belief_source", ""),
"n_beliefs": sum(1 for x in ids_list if x == self.belief_open_id),
}
for k in ("video_grid_thw", "pixel_values_videos"):
if k in full:
item[k] = full[k]
return item
# โ”€โ”€ Collator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class CollatorV3:
"""Pad sequence dim; cat pixel/grid along their natural dim."""
def __init__(self, processor):
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)
B = len(batch)
ids = torch.full((B, max_len), self.pad_id, dtype=torch.long)
labs = torch.full((B, max_len), -100, dtype=torch.long)
action_mask = torch.zeros((B, max_len), dtype=torch.bool)
belief_content_mask = torch.zeros((B, max_len), dtype=torch.bool)
belief_open_mask = torch.zeros((B, max_len), dtype=torch.bool)
attn_mask = torch.zeros((B, max_len), dtype=torch.long)
record_idxs = []
belief_sources = []
n_beliefs_list = []
for i, b in enumerate(batch):
L = b["input_ids"].size(0)
ids[i, :L] = b["input_ids"]
labs[i, :L] = b["labels"]
action_mask[i, :L] = b["action_token_mask"]
belief_content_mask[i, :L] = b["belief_content_mask"]
belief_open_mask[i, :L] = b["belief_open_mask"]
if b.get("attention_mask") is not None:
attn_mask[i, :L] = b["attention_mask"]
else:
attn_mask[i, :L] = 1
record_idxs.append(b["record_idx"])
belief_sources.append(b["belief_source"])
n_beliefs_list.append(b["n_beliefs"])
out = {
"input_ids": ids,
"labels": labs,
"attention_mask": attn_mask,
"action_token_mask": action_mask,
"belief_content_mask": belief_content_mask,
"belief_open_mask": belief_open_mask,
"record_idxs": record_idxs,
"belief_sources": belief_sources,
"n_beliefs": n_beliefs_list,
}
# pixel_values: [num_patches_total, dim] -- cat across batch
if batch[0].get("pixel_values") is not None:
out["pixel_values"] = torch.cat(
[b["pixel_values"] for b in batch], dim=0)
# image_grid_thw: [n_images_per_sample, 3] -- cat across batch
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
# โ”€โ”€ Loss functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def weighted_ce_loss(logits: torch.Tensor,
labels: torch.Tensor,
action_mask: torch.Tensor,
belief_content_mask: torch.Tensor,
action_weight: float = 2.0,
belief_weight: float = 1.5) -> torch.Tensor:
"""Causal-LM CE with per-token weighting.
Applies the standard next-token shift: position t's logits predict
position (t+1)'s label.
Weights:
- Reasoning tokens ([Analysis] section): 1.0 (default)
- Belief content tokens (between BELIEF tags): belief_weight
- Action tokens (SILENT/OBSERVE/ALERT): action_weight
"""
# shift for next-token prediction
shift_logits = logits[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()
shift_amask = action_mask[..., 1:].contiguous()
shift_bmask = belief_content_mask[..., 1:].contiguous()
V = shift_logits.size(-1)
flat_logits = shift_logits.view(-1, V)
flat_labels = shift_labels.view(-1)
flat_amask = shift_amask.view(-1)
flat_bmask = shift_bmask.view(-1)
valid = flat_labels != -100
if not valid.any():
return flat_logits.sum() * 0.0
loss_per = torch.nn.functional.cross_entropy(
flat_logits[valid], flat_labels[valid], reduction="none")
# build weight vector: start at 1.0, override for belief content and action
w = torch.ones_like(loss_per)
valid_bmask = flat_bmask[valid]
valid_amask = flat_amask[valid]
w = torch.where(valid_bmask,
torch.full_like(w, belief_weight), w)
# action tokens override belief weight if both are set (they shouldn't overlap,
# but action takes priority)
w = torch.where(valid_amask,
torch.full_like(w, action_weight), w)
return (loss_per * w).sum() / w.sum()
def belief_embedding_loss(hidden_states: torch.Tensor,
belief_open_mask: torch.Tensor,
record_idxs: List[int],
belief_sources: List[str],
n_beliefs: List[int],
belief_targets: torch.Tensor,
layer_idx: int = 28) -> torch.Tensor:
"""Cosine similarity loss on hidden states at <|BELIEF|> positions.
Args:
hidden_states: tuple of layer outputs from model (when output_hidden_states=True),
or a single tensor for the specified layer.
If tuple, index with layer_idx.
belief_open_mask: [B, T] bool mask, True at <|BELIEF|> token positions.
record_idxs: list of record indices in the dataset (length B).
belief_sources: list of belief_source strings (length B).
n_beliefs: list of number of beliefs per sample (length B).
belief_targets: [N_records, max_beliefs, hidden_dim] pre-computed embeddings.
layer_idx: which transformer layer's hidden state to use.
Returns:
Scalar loss = mean(1 - cos_sim(h_belief, target)) over valid positions.
"""
# extract the right layer's hidden states
if isinstance(hidden_states, (tuple, list)):
h = hidden_states[layer_idx] # [B, T, D]
else:
h = hidden_states # already the right layer
B, T, D = h.shape
device = h.device
total_loss = torch.tensor(0.0, device=device, dtype=h.dtype)
count = 0
for b_idx in range(B):
src = belief_sources[b_idx]
# only apply on records with gpt or annotation belief sources
if "gpt" not in src and "annotation" not in src:
continue
rec_idx = record_idxs[b_idx]
if rec_idx >= belief_targets.shape[0]:
continue
# find positions of <|BELIEF|> tokens in this sample
positions = belief_open_mask[b_idx].nonzero(as_tuple=False).squeeze(-1)
if positions.dim() == 0:
positions = positions.unsqueeze(0)
if positions.numel() == 0:
continue
n_b = min(positions.size(0), belief_targets.shape[1])
for k in range(n_b):
pos = positions[k].item()
h_belief = h[b_idx, pos, :] # [D]
target = belief_targets[rec_idx, k, :].to(device=device, dtype=h.dtype)
# skip zero targets (padding)
if target.abs().sum() < 1e-8:
continue
cos_sim = torch.nn.functional.cosine_similarity(
h_belief.unsqueeze(0), target.unsqueeze(0))
total_loss = total_loss + (1.0 - cos_sim.squeeze())
count += 1
if count == 0:
return torch.tensor(0.0, device=device, dtype=h.dtype, requires_grad=True)
return total_loss / count
# โ”€โ”€ CLI args โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def parse_args():
ap = argparse.ArgumentParser(
description="VLAlert v3 SFT: Qwen3-VL-4B + LoRA")
ap.add_argument("--train_jsonl",
default="data/cot_corpus_v3/v6_sft_train.jsonl")
ap.add_argument("--val_jsonl",
default="data/cot_corpus_v3/v6_sft_val.jsonl")
ap.add_argument("--base_model",
default="models/Qwen3-VL-4B-Instruct")
ap.add_argument("--out_dir",
default="checkpoints/vlalert_sft")
ap.add_argument("--lora_r", type=int, default=64)
ap.add_argument("--lora_alpha", type=int, default=128)
ap.add_argument("--lora_dropout", type=float, default=0.05)
ap.add_argument("--lr", type=float, default=5e-5)
ap.add_argument("--epochs", type=int, default=3)
ap.add_argument("--batch_size", type=int, default=4)
ap.add_argument("--grad_accum", type=int, default=4)
ap.add_argument("--warmup_ratio", type=float, default=0.03)
ap.add_argument("--max_len", type=int, default=4096)
ap.add_argument("--resize_short", type=int, default=336)
ap.add_argument("--action_token_weight", type=float, default=2.0)
ap.add_argument("--belief_token_weight", type=float, default=1.5)
ap.add_argument("--belief_emb_weight", type=float, default=0.1,
help="Lambda for belief embedding loss")
ap.add_argument("--belief_emb_start_epoch", type=int, default=2,
help="Epoch at which belief embedding loss activates "
"(1-indexed; default 2 = start at epoch 2)")
ap.add_argument("--belief_target_path", type=str, default="",
help="Path to .pt file with pre-computed belief "
"target embeddings [N, max_beliefs, 2560]")
ap.add_argument("--belief_layer_idx", type=int, default=28,
help="Transformer layer index for belief embedding extraction")
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--max_samples", type=int, default=0,
help="Cap dataset size for smoke tests (0 = all)")
ap.add_argument("--log_every", type=int, default=50)
ap.add_argument("--save_every_epoch", action="store_true")
ap.add_argument("--resume", type=str, default="",
help="Resume LoRA adapter from this directory")
ap.add_argument("--num_workers", type=int, default=0)
return ap.parse_args()
# โ”€โ”€ token setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def add_special_tokens_and_resize(processor, model):
"""Add BELIEF/action special tokens and resize embeddings."""
tok = processor.tokenizer
before = len(tok)
added = tok.add_special_tokens({"additional_special_tokens": ALL_SPECIAL})
after = len(tok)
print(f"[tokens] vocab {before} -> {after} ({added} new)")
if added == 0:
return
model.resize_token_embeddings(after)
emb = model.get_input_embeddings()
with torch.no_grad():
mean_vec = emb.weight[:before].mean(dim=0)
for tok_str in ALL_SPECIAL:
tid = tok.convert_tokens_to_ids(tok_str)
emb.weight[tid] = mean_vec + 0.01 * torch.randn_like(mean_vec)
out_emb = model.get_output_embeddings()
if out_emb is not None and out_emb.weight.data_ptr() != emb.weight.data_ptr():
with torch.no_grad():
mean_out = out_emb.weight[:before].mean(dim=0)
for tok_str in ALL_SPECIAL:
tid = tok.convert_tokens_to_ids(tok_str)
out_emb.weight[tid] = mean_out + 0.01 * torch.randn_like(mean_out)
# โ”€โ”€ Validation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@torch.no_grad()
def validate(model, val_dl, args, epoch: int,
belief_targets: Optional[torch.Tensor] = None) -> float:
"""Run one validation pass and return average loss."""
model.eval()
total_loss = 0.0
total_steps = 0
use_emb = (belief_targets is not None
and args.belief_emb_weight > 0
and (epoch + 1) >= args.belief_emb_start_epoch)
for batch in tqdm(val_dl, desc=f" val ep{epoch}", ncols=80, leave=False):
input_ids = batch["input_ids"].to("cuda", non_blocking=True)
labels = batch["labels"].to("cuda", non_blocking=True)
amask = batch["action_token_mask"].to("cuda", non_blocking=True)
bmask = batch["belief_content_mask"].to("cuda", non_blocking=True)
bo_mask = batch["belief_open_mask"].to("cuda", non_blocking=True)
attn = batch.get("attention_mask")
if attn is not None:
attn = attn.to("cuda", non_blocking=True)
pix = batch["pixel_values"].to("cuda", dtype=torch.bfloat16,
non_blocking=True)
grid = batch["image_grid_thw"].to("cuda", non_blocking=True)
fwd_kwargs = dict(input_ids=input_ids,
pixel_values=pix, image_grid_thw=grid)
if attn is not None:
fwd_kwargs["attention_mask"] = attn
if use_emb:
fwd_kwargs["output_hidden_states"] = True
out = model(**fwd_kwargs)
ce_loss = weighted_ce_loss(
out.logits, labels, amask, bmask,
action_weight=args.action_token_weight,
belief_weight=args.belief_token_weight)
loss = ce_loss
if use_emb and hasattr(out, "hidden_states") and out.hidden_states is not None:
emb_loss = belief_embedding_loss(
out.hidden_states, bo_mask,
batch["record_idxs"], batch["belief_sources"],
batch["n_beliefs"], belief_targets,
layer_idx=args.belief_layer_idx)
loss = loss + args.belief_emb_weight * emb_loss
total_loss += loss.float().item()
total_steps += 1
model.train()
avg = total_loss / max(1, total_steps)
return avg
# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def main():
args = parse_args()
torch.manual_seed(args.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(args.seed)
# resolve paths relative to ROOT
base_model = args.base_model
if not os.path.isabs(base_model):
base_model = str(ROOT / base_model)
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = ROOT / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
train_jsonl = args.train_jsonl
if not os.path.isabs(train_jsonl):
train_jsonl = str(ROOT / train_jsonl)
val_jsonl = args.val_jsonl
if not os.path.isabs(val_jsonl):
val_jsonl = str(ROOT / val_jsonl)
print(f"[train] base_model = {base_model}")
print(f"[train] out_dir = {out_dir}")
print(f"[train] train_jsonl = {train_jsonl}")
print(f"[train] val_jsonl = {val_jsonl}")
# โ”€โ”€ load processor + model โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
print(f"[train] loading processor/model from {base_model}")
processor = AutoProcessor.from_pretrained(base_model, trust_remote_code=True)
model = AutoModelForImageTextToText.from_pretrained(
base_model, torch_dtype=torch.bfloat16,
trust_remote_code=True, attn_implementation="sdpa",
)
add_special_tokens_and_resize(processor, model)
# freeze vision tower
for attr in ("visual", "vision_tower"):
if hasattr(model, attr):
for p in getattr(model, attr).parameters():
p.requires_grad = False
if hasattr(model, "enable_input_require_grads"):
model.enable_input_require_grads()
model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False})
# โ”€โ”€ LoRA / resume โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if args.resume:
from peft import PeftModel
print(f"[resume] loading PEFT adapter from {args.resume}")
model = PeftModel.from_pretrained(model, args.resume, is_trainable=True)
else:
lora_cfg = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_alpha,
lora_dropout=args.lora_dropout,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
bias="none",
task_type="CAUSAL_LM",
modules_to_save=["embed_tokens", "lm_head"],
)
model = get_peft_model(model, lora_cfg)
model.print_trainable_parameters()
model.to("cuda")
model.config.use_cache = False
# โ”€โ”€ Load belief target embeddings (optional) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
belief_targets = None
if args.belief_target_path:
bt_path = args.belief_target_path
if not os.path.isabs(bt_path):
bt_path = str(ROOT / bt_path)
if os.path.exists(bt_path):
belief_targets = torch.load(bt_path, map_location="cpu",
weights_only=True)
print(f"[train] loaded belief targets: {belief_targets.shape} "
f"from {bt_path}")
else:
print(f"[warn] belief_target_path not found: {bt_path}, "
f"embedding loss disabled")
# โ”€โ”€ Datasets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
train_ds = VLAlertSFTDatasetV3(
jsonl_path=train_jsonl, processor=processor,
resize_short=args.resize_short, max_len=args.max_len)
if args.max_samples > 0 and len(train_ds) > args.max_samples:
from torch.utils.data import Subset
train_ds = Subset(train_ds, list(range(args.max_samples)))
print(f"[smoke] truncated training to {len(train_ds)} samples")
val_ds = VLAlertSFTDatasetV3(
jsonl_path=val_jsonl, processor=processor,
resize_short=args.resize_short, max_len=args.max_len)
print(f"[train] train={len(train_ds)}, val={len(val_ds)}")
collator = CollatorV3(processor)
train_dl = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True,
num_workers=args.num_workers, collate_fn=collator,
pin_memory=True)
val_dl = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False,
num_workers=args.num_workers, collate_fn=collator,
pin_memory=True)
# โ”€โ”€ Optimizer + scheduler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
trainable = [p for p in model.parameters() if p.requires_grad]
opt = AdamW(trainable, lr=args.lr, betas=(0.9, 0.95), weight_decay=0.0)
total_updates = math.ceil(len(train_dl) * args.epochs / args.grad_accum)
warmup = max(1, int(total_updates * args.warmup_ratio))
sched = get_cosine_schedule_with_warmup(opt, warmup, total_updates)
print(f"[train] total_updates={total_updates} warmup={warmup} lr={args.lr}")
print(f"[train] belief_emb_weight={args.belief_emb_weight} "
f"start_epoch={args.belief_emb_start_epoch}")
# โ”€โ”€ Training loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
best_val_loss = float("inf")
global_step = 0
model.train()
for epoch in range(args.epochs):
# determine whether embedding loss is active this epoch
use_emb = (belief_targets is not None
and args.belief_emb_weight > 0
and (epoch + 1) >= args.belief_emb_start_epoch)
pbar = tqdm(enumerate(train_dl), total=len(train_dl),
desc=f"ep{epoch}", ncols=100, leave=True)
running_ce = 0.0
running_emb = 0.0
running_n = 0
for step, batch in pbar:
input_ids = batch["input_ids"].to("cuda", non_blocking=True)
labels = batch["labels"].to("cuda", non_blocking=True)
amask = batch["action_token_mask"].to("cuda", non_blocking=True)
bmask = batch["belief_content_mask"].to("cuda", non_blocking=True)
bo_mask = batch["belief_open_mask"].to("cuda", non_blocking=True)
attn = batch.get("attention_mask")
if attn is not None:
attn = attn.to("cuda", non_blocking=True)
pix = batch["pixel_values"].to("cuda", dtype=torch.bfloat16,
non_blocking=True)
grid = batch["image_grid_thw"].to("cuda", non_blocking=True)
fwd_kwargs = dict(input_ids=input_ids,
pixel_values=pix, image_grid_thw=grid)
if attn is not None:
fwd_kwargs["attention_mask"] = attn
if use_emb:
fwd_kwargs["output_hidden_states"] = True
out = model(**fwd_kwargs)
# L_causal_LM
ce_loss = weighted_ce_loss(
out.logits, labels, amask, bmask,
action_weight=args.action_token_weight,
belief_weight=args.belief_token_weight)
loss = ce_loss
# L_belief_embedding (optional)
emb_loss_val = 0.0
if use_emb and hasattr(out, "hidden_states") and out.hidden_states is not None:
emb_loss = belief_embedding_loss(
out.hidden_states, bo_mask,
batch["record_idxs"], batch["belief_sources"],
batch["n_beliefs"], belief_targets,
layer_idx=args.belief_layer_idx)
loss = loss + args.belief_emb_weight * emb_loss
emb_loss_val = emb_loss.detach().float().item()
loss = loss / args.grad_accum
loss.backward()
running_ce += ce_loss.detach().float().item()
running_emb += emb_loss_val
running_n += 1
if (step + 1) % args.grad_accum == 0 or (step + 1) == len(train_dl):
torch.nn.utils.clip_grad_norm_(trainable, 1.0)
opt.step()
sched.step()
opt.zero_grad(set_to_none=True)
global_step += 1
if global_step % args.log_every == 0:
avg_ce = running_ce / max(1, running_n)
avg_emb = running_emb / max(1, running_n)
lr_now = sched.get_last_lr()[0]
pbar.set_postfix(
ce=f"{avg_ce:.4f}",
emb=f"{avg_emb:.4f}" if use_emb else "off",
lr=f"{lr_now:.2e}",
step=global_step)
running_ce, running_emb, running_n = 0.0, 0.0, 0
# โ”€โ”€ save checkpoint BEFORE validation (้˜ฒๆญขvalidationๅดฉๆบƒไธขๅคฑ่ฎญ็ปƒ) โ”€โ”€
if args.save_every_epoch:
ep_dir = out_dir / f"epoch_{epoch}"
ep_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(ep_dir)
processor.save_pretrained(ep_dir)
with (ep_dir / "train_args.json").open("w") as f:
json.dump(vars(args), f, indent=2)
print(f"[save] epoch {epoch} -> {ep_dir} (pre-validation)")
# โ”€โ”€ end-of-epoch validation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
try:
val_loss = validate(model, val_dl, args, epoch, belief_targets)
except Exception as e:
print(f"[warn] validation failed: {e}")
val_loss = float("inf")
print(f"[val] epoch {epoch}: val_loss={val_loss:.4f} "
f"(best={best_val_loss:.4f})")
# save best
is_best = val_loss < best_val_loss
if is_best:
best_val_loss = val_loss
best_dir = out_dir / "best"
best_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(best_dir)
processor.save_pretrained(best_dir)
with (best_dir / "train_args.json").open("w") as f:
json.dump(vars(args), f, indent=2)
with (best_dir / "belief_tokens.json").open("w") as f:
json.dump({
"special_tokens": ALL_SPECIAL,
"belief_open": BELIEF_OPEN,
"belief_close": BELIEF_CLOSE,
"actions": ACTION_TOKENS,
}, f, indent=2)
print(f"[save] best -> {best_dir} (val_loss={val_loss:.4f})")
# (epoch checkpoint already saved pre-validation)
if False: # disabled โ€” already saved above
with (ep_dir / "train_args.json").open("w") as f:
json.dump(vars(args), f, indent=2)
print(f"[save] epoch {epoch} -> {ep_dir}")
# โ”€โ”€ Final save (last epoch) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
final_dir = out_dir / "final"
final_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(final_dir)
processor.save_pretrained(final_dir)
with (final_dir / "train_args.json").open("w") as f:
json.dump(vars(args), f, indent=2)
with (final_dir / "belief_tokens.json").open("w") as f:
json.dump({
"special_tokens": ALL_SPECIAL,
"belief_open": BELIEF_OPEN,
"belief_close": BELIEF_CLOSE,
"actions": ACTION_TOKENS,
}, f, indent=2)
print(f"[done] final -> {final_dir}")
print(f"[done] best val_loss = {best_val_loss:.4f}")
if __name__ == "__main__":
main()