| |
| """CoT + BeliefToken fast-SFT on Qwen3-VL-4B-Instruct (skip pretrain, domain-adapt only). |
| |
| Pipeline: |
| 1. Load Qwen3-VL-4B processor + model (bf16). |
| 2. Add 4 special tokens: <|BELIEF|>, </|BELIEF|>, <|ALERT|>, <|OBSERVE|>, <|SILENT|> |
| (resizes embedding + lm_head; new rows initialized with mean of existing). |
| 3. Freeze vision tower, LoRA on LLM (q/k/v/o/gate/up/down_proj). |
| 4. Train on data/vla_cot_belief/train500_belief.jsonl |
| — assistant target = scene + threat + <|BELIEF|> <|ACTION|> </|BELIEF|>. |
| |
| At belief-extraction time (separate script), we teacher-force the prefix + |
| CoT up through "<|BELIEF|>" and read hidden_states[-1] at that position. |
| |
| Run: |
| python -m training.VLA.train_cot_belief \ |
| --cot_jsonl data/vla_cot_belief/train500_belief.jsonl \ |
| --video_dir nexar-collision-prediction/train \ |
| --out_dir checkpoints/VLA/qwen3vl4b_cot_belief \ |
| --epochs 5 --batch_size 1 --grad_accum 4 --lr 2e-4 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import math |
| import sys |
| from functools import partial |
| from pathlib import Path |
|
|
| import torch |
| from torch.optim import AdamW |
| from torch.utils.data import DataLoader |
| from tqdm import tqdm |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parents[2])) |
|
|
| from peft import LoraConfig, get_peft_model |
| from transformers import AutoProcessor, AutoModelForImageTextToText |
| from transformers.optimization import get_cosine_schedule_with_warmup |
|
|
| from training.VLA.cot_belief_dataset import ( |
| CoTBeliefDataset, collate_fn, ALL_SPECIAL, |
| ) |
|
|
|
|
| def parse_args(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--model_name", |
| default="PROJECT_ROOT/models/Qwen3-VL-4B-Instruct") |
| ap.add_argument("--cot_jsonl", required=True) |
| ap.add_argument("--video_dir", required=True) |
| ap.add_argument("--out_dir", required=True) |
| ap.add_argument("--lora_r", type=int, default=32) |
| ap.add_argument("--lora_alpha", type=int, default=16) |
| ap.add_argument("--lora_dropout", type=float, default=0.05) |
| ap.add_argument("--lr", type=float, default=2e-4) |
| ap.add_argument("--epochs", type=int, default=5) |
| ap.add_argument("--batch_size", type=int, default=1) |
| ap.add_argument("--grad_accum", type=int, default=4) |
| ap.add_argument("--warmup_ratio", type=float, default=0.05) |
| ap.add_argument("--n_frames", type=int, default=8) |
| ap.add_argument("--resize_short", type=int, default=336) |
| ap.add_argument("--max_len", type=int, default=3072) |
| ap.add_argument("--max_samples", type=int, default=0, |
| help="If >0, truncate dataset for smoke-test") |
| ap.add_argument("--log_every", type=int, default=10) |
| ap.add_argument("--save_every_epoch", action="store_true") |
| ap.add_argument("--seed", type=int, default=0) |
| ap.add_argument("--resume", type=str, default="", |
| help="Path to existing PEFT adapter dir to warm-start from") |
| ap.add_argument("--per_frame", action="store_true", |
| help="Per-frame POMDP target (requires belief.actions_per_frame)") |
| ap.add_argument("--state_conditional", action="store_true", |
| help="VLAlert-X Stage A: emit state-specific phrases inside " |
| "<|BELIEF|> blocks (forces state-distinguishing belief)") |
| return ap.parse_args() |
|
|
|
|
| def add_special_tokens_and_resize(processor, model): |
| """Add the 4 belief/action special tokens; resize embeddings; init new rows.""" |
| 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) |
|
|
|
|
| def main(): |
| args = parse_args() |
| torch.manual_seed(args.seed) |
| out_dir = Path(args.out_dir) |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| print(f"[train] loading processor/model from {args.model_name}") |
| processor = AutoProcessor.from_pretrained(args.model_name, trust_remote_code=True) |
| model = AutoModelForImageTextToText.from_pretrained( |
| args.model_name, |
| torch_dtype=torch.bfloat16, |
| trust_remote_code=True, |
| attn_implementation="sdpa", |
| ) |
|
|
| |
| add_special_tokens_and_resize(processor, model) |
|
|
| |
| 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}) |
|
|
| |
| 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 |
|
|
| |
| ds = CoTBeliefDataset( |
| jsonl_path=args.cot_jsonl, |
| video_dir=args.video_dir, |
| processor=processor, |
| n_frames=args.n_frames, |
| resize_short=args.resize_short, |
| max_len=args.max_len, |
| per_frame=args.per_frame, |
| state_conditional=args.state_conditional, |
| ) |
| if args.state_conditional: |
| print("[stage-A] state_conditional=True — <|BELIEF|> blocks " |
| "will contain state-specific phrases.") |
| print(f"[train] dataset size = {len(ds)}") |
| if args.max_samples > 0 and len(ds) > args.max_samples: |
| from torch.utils.data import Subset |
| ds = Subset(ds, list(range(args.max_samples))) |
| print(f"[smoke] truncated to {len(ds)}") |
| if len(ds) == 0: |
| raise SystemExit("empty dataset — check --cot_jsonl") |
|
|
| pad_id = processor.tokenizer.pad_token_id or processor.tokenizer.eos_token_id |
| dl = DataLoader( |
| ds, |
| batch_size=args.batch_size, |
| shuffle=True, |
| num_workers=0, |
| collate_fn=partial(collate_fn, pad_token_id=pad_id), |
| pin_memory=True, |
| ) |
|
|
| |
| 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_steps = math.ceil(len(dl) * args.epochs / args.grad_accum) |
| warmup_steps = max(1, int(total_steps * args.warmup_ratio)) |
| sched = get_cosine_schedule_with_warmup(opt, warmup_steps, total_steps) |
| print(f"[train] total_updates={total_steps} warmup={warmup_steps} lr={args.lr}") |
|
|
| |
| global_step = 0 |
| model.train() |
| for epoch in range(args.epochs): |
| pbar = tqdm(enumerate(dl), total=len(dl), desc=f"ep{epoch}", ncols=80, leave=True) |
| running = 0.0 |
| running_n = 0 |
| for step, batch in pbar: |
| input_ids = batch["input_ids"].to("cuda", non_blocking=True) |
| attn = batch["attention_mask"].to("cuda", non_blocking=True) |
| labels = batch["labels"].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) |
|
|
| out = model( |
| input_ids=input_ids, |
| attention_mask=attn, |
| labels=labels, |
| pixel_values=pix, |
| image_grid_thw=grid, |
| ) |
| loss = out.loss / args.grad_accum |
| loss.backward() |
| running += out.loss.detach().float().item() |
| running_n += 1 |
|
|
| if (step + 1) % args.grad_accum == 0 or (step + 1) == len(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: |
| pbar.set_postfix(loss=running / max(1, running_n), |
| lr=sched.get_last_lr()[0]) |
| running, running_n = 0.0, 0 |
|
|
| if args.save_every_epoch or epoch == args.epochs - 1: |
| 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"[train] saved -> {ep_dir}") |
|
|
| |
| final = out_dir / "best" |
| final.mkdir(parents=True, exist_ok=True) |
| model.save_pretrained(final) |
| processor.save_pretrained(final) |
| with (final / "train_args.json").open("w") as f: |
| json.dump(vars(args), f, indent=2) |
| with (final / "belief_tokens.json").open("w") as f: |
| json.dump({"special_tokens": ALL_SPECIAL, |
| "belief_open": "<|BELIEF|>", |
| "belief_close": "</|BELIEF|>", |
| "actions": ["<|ALERT|>", "<|OBSERVE|>", "<|SILENT|>"]}, f, indent=2) |
| print(f"[train] done. final -> {final}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|