qwenjev / train /train_system1.py
tchbcb's picture
Upload train/train_system1.py with huggingface_hub
54e9aa2 verified
Raw History Blame Contribute Delete
17.1 kB
"""Stage B: System-1 decision-head SFT (complete-question cross entropy).
Recipe mirrors the NanoJev `hard_lr1e5` run:
* hard (one-hot) targets on the Choice head;
* backbone lr 1e-5, decision-head lr 1e-4;
* mixed question families, complete-question CE (softmax over the whole
candidate set, not per-leaf binary);
* optional LoRA on the backbone.
Vision: every sample carries its frame `grid`; the backbone's vision tower
encodes it on EVERY leaf - the same path used at inference. A stage-A
checkpoint (stage_a.pt) is loaded when present so the student's perception
starts aligned with the 9B teacher.
Boolean and Score heads train with CE on trainable logits (the old detached-
tensor BCE was a silent no-op for gradients - fixed here).
Absorbed from Laya: candidate ORDER is permuted per row per epoch (their
build_sequence has the same `option_order` hook). The set-attention Choice
head is order-invariant in theory, but the training distribution is not -
the label candidate always sits where build_dataset put it - so permutation
removes a silent position shortcut. Stage C (calibrate_rlcd.py) then makes
the probabilities honest.
"""
from __future__ import annotations
import argparse
import json
import math
def _oom_skip(exc: Exception, tag: str) -> None:
"""Row-level OOM guard: release the partial graph and keep training.
Real-game rows vary wildly (64x64 grids, long propositions); one
pathological row must not kill a multi-hour run."""
import torch
torch.cuda.empty_cache()
print(f"[oom-skip] {tag}: {str(exc).splitlines()[0]}", flush=True)
def _row_sig(row: dict) -> str:
g = row.get("grid") or []
return (f"state={len(row.get('state') or '')}ch grid={len(g)}x{len(g[0]) if g and len(g) else 0} "
f"cands={len(row.get('candidates') or []) or '-'} "
f"prop={len(row.get('proposition') or '') or '-'} "
f"desc={len(row.get('description') or '') or '-'}")
def _grid_too_big(row: dict, cap: int = 64) -> bool:
"""Guard kept for tiny-GPU fallbacks; 64x64 trains fine on T4 with
ckpt + capped candidates (measured 4.4 GiB steady). The step-25 blowup
was inf/nan loss rows corrupting SDPA numerics, not grid size."""
g = row.get("grid") or []
return bool(g) and (len(g) > cap or len(g[0]) > cap)
def _near_oom(row: dict, tag: str) -> bool:
"""Hard gate (v0.4.7): the old T4 run starved to death churning through
OOM attempts at 14.9GiB reserved — each doomed row burned a full forward
before failing. Now: above the gate, skip BEFORE the backward (ms-level
skip instead of a 2-5s doomed forward) and release cache immediately.
Returns True when the caller must skip this row."""
import torch
if torch.cuda.memory_allocated() > 12.5 * 2**30:
torch.cuda.empty_cache()
print(f"[gate-skip] gpu={torch.cuda.memory_allocated()/2**30:.2f}GiB "
f"{tag} | {_row_sig(row)}", flush=True)
return True
return False
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import yaml # noqa: E402
def iter_batches(path: Path, batch_size: int):
batch = {"choice": [], "boolean": [], "score": []}
with open(path) as fh:
for line in fh:
row = json.loads(line)
batch[row["family"]].append(row)
if len(batch["choice"]) >= batch_size:
yield batch
batch = {"choice": [], "boolean": [], "score": []}
yield batch
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="configs/train.yaml")
args = ap.parse_args()
cfg = yaml.safe_load(open(args.config))
b = cfg["stage_b_sft"]
s1_cfg = cfg.get("system1", {})
import torch
import torch.nn.functional as F
from qwenjev.system1 import QwenJevModel, System1Config
from qwenjev.vision import TinyVLMConfig
model_cfg = System1Config(
backend=s1_cfg.get("backend", "tiny"),
hf_model=s1_cfg.get("hf_model", "Qwen/Qwen3-VL-2B-Instruct"),
max_leaf_tokens=int(s1_cfg.get("max_leaf_tokens", 512)),
score_levels=int(s1_cfg.get("score_levels", 10)),
leaf_batch_size=int(s1_cfg.get("leaf_batch_size", 32)),
hf_quant4=bool(s1_cfg.get("hf_quant4", False)),
pool_vision=bool(s1_cfg.get("pool_vision", False)),
tiny=TinyVLMConfig(**(s1_cfg.get("tiny") or {})),
)
model = QwenJevModel(model_cfg).build()
torch = model.torch
# v0.4.13: continue from existing decision heads when the out_dir carries
# a previous qwenjev_s1.pt (online-teach fine-tunes must keep the teacher
# generation's calibrated heads; fresh heads would throw away that state)
prev_heads = Path(cfg["out_dir"]) / "qwenjev_s1.pt"
if prev_heads.exists():
model.load_heads(str(cfg["out_dir"]))
print(f"heads continued -> {prev_heads}")
# ---- stage A perception alignment (optional but recommended) --------
stage_a_path = Path(cfg["out_dir"]) / "stage_a.pt"
if stage_a_path.exists() and model_cfg.backend == "tiny":
payload = torch.load(stage_a_path, map_location=model.device,
weights_only=False)
if payload.get("student"):
model._backbone.load_state_dict(payload["student"])
print(f"stage A loaded -> {stage_a_path}")
# gradient checkpointing: the flag lives on the base modules, so it
# stays effective after peft wrapping; REQUIRED for real-game grids on T4
if s1_cfg.get("grad_ckpt", False) and model_cfg.backend == "hf":
from qwenjev.hf_compat import enable_grad_ckpt
if enable_grad_ckpt(model._backbone):
print("gradient checkpointing ON")
backbone = model._backbone
head_params = (
list(model._choice_proj.parameters())
+ list(model._set_attn.parameters())
+ list(model._boolean_head.parameters())
+ list(model._score_head.parameters())
)
peft_model = None
if b.get("lora", {}).get("enabled") and model_cfg.backend == "hf":
# unified PEFT path: kbit-prepare for QLoRA bases, fp32-upcast adapters,
# pretrained vision tower frozen (exclude_vision)
from qwenjev.hf_compat import maybe_lora, resume_adapters
# stage A continuity: resume the distillation adapters when present
# so stage B CONTINUES the same adapters (cold-start LoRA otherwise)
sa_lora = Path(cfg["out_dir"]) / "stage_a_lora"
if sa_lora.exists():
peft_model, trainable = resume_adapters(backbone, str(sa_lora))
print(f"stage A LoRA resumed -> {sa_lora}")
else:
peft_model, trainable = maybe_lora(backbone, b["lora"])
# rebind onto QwenJevModel (same requirement as the cold-start path:
# forward must run the WRAPPED model, not the un-frozen base)
model._backbone = peft_model
elif b.get("lora", {}).get("enabled"):
from peft import LoraConfig, get_peft_model
lcfg = LoraConfig(
r=int(b["lora"]["r"]), lora_alpha=int(b["lora"]["alpha"]),
target_modules=list(b["lora"]["target"]), lora_dropout=0.05,
)
# CRITICAL: rebind the peft-wrapped model onto QwenJevModel. Without
# this, forward passes (model._pooled_hf -> self._backbone) run the
# UNWRAPPED base whose params all require grad; their .grad buffers
# accumulate outside the optimizer and OOM the T4 in ~25 steps.
# (masked before v2: the 4-bit kbit path froze the base for us)
model._backbone = backbone = get_peft_model(backbone, lcfg)
trainable = [p for p in backbone.parameters() if p.requires_grad]
else:
trainable = list(backbone.parameters())
optim = torch.optim.AdamW(
[{"params": trainable, "lr": float(b["backbone_lr"])},
{"params": head_params, "lr": float(b["head_lr"])}],
weight_decay=0.01,
)
# from_pretrained leaves the model in EVAL mode; gradient checkpointing
# only fires when self.training is True. Without this line the 64x64
# real-game backward holds ~10 GiB of full activations and OOMs the T4
# mid-run (diag2/3/4 always called train_mode(True) and never blew up).
model.train_mode(True)
print(f"[stage B] trainable: {sum(p.numel() for p in trainable)/1e6:.1f}M "
f"(backbone {sum(p.numel() for p in backbone.parameters() if p.requires_grad)/1e6:.1f}M + "
f"heads {sum(p.numel() for p in head_params)/1e6:.1f}M)", flush=True)
data = Path(cfg["data"]["dataset_dir"]) / "train" / "samples.jsonl"
if not data.exists():
raise SystemExit(f"dataset missing: {data}; run build-data first")
steps = 0
skipped_big = 0
epochs = int(b["epochs"])
grad_acc = max(1, int(b.get("grad_accum", 4)))
# v0.4.6 crash-resistance: save WIP every N steps. The old T4 died at
# step ~58/180 and EVERYTHING was lost because saving happened only at
# the end. With this, worst-case loss = save_every_steps * step_time.
save_every = int(b.get("save_every_steps", 0) or 0)
out_dir = Path(cfg["out_dir"])
max_steps = int(b.get("max_steps", 0) or 0) # 0 = unlimited
aux_cap = max(1, int(b.get("aux_rows_per_batch", 8)))
rng = random.Random(int(cfg.get("data", {}).get("seed", 13)))
for epoch in range(epochs):
if max_steps and steps >= max_steps:
break
for batch in iter_batches(data, int(b["batch_questions"])):
losses = []
# NOTE: every row's loss is backwarded IMMEDIATELY. The hf
# fp16/QLoRA forward keeps each row's activation graph alive;
# accumulating whole-batch graphs OOMs even a chunked T4 run.
# Gradients accumulate across rows/chunks; step once per batch.
# ---- Choice: complete-question cross entropy ----------------
for row in batch["choice"]:
from qwenjev.candidates import CandidateAction, cap_choice_row
if _grid_too_big(row):
skipped_big += 1
continue
# negative-sample wide rows (31 cands x chunked graphs OOMed
# a 16 GB T4); label index is remapped to the capped list
try:
cand_texts, label = cap_choice_row(
row["candidates"], int(row["label"]),
int(b.get("max_candidates", 0) or 0), rng)
cands = [CandidateAction("CAND", text=t) for t in cand_texts]
# order permutation (Laya option_order): track the label
order = list(range(len(cands)))
rng.shuffle(order)
cands = [cands[i] for i in order]
label = order.index(label)
leaves = model.encode_question(
row["state"], cands, row["instruction"], row.get("grid"))
if model_cfg.backend == "tiny":
out, seq_lens, has_vis = model._pooled_tiny(leaves)
torch_ = model.torch
vis_n = model._backbone.n_vision_tokens if has_vis else 0
pooled = model._backbone.pooled(
out, seq_lens + vis_n * torch_.ones_like(seq_lens))
else:
pooled = model._pooled_hf(leaves)
proj = model._choice_proj(pooled)
attn_out = model._set_attn(proj.unsqueeze(0)).squeeze(0)
logits = attn_out.sum(-1)
ce = torch.nn.functional.cross_entropy(
logits.unsqueeze(0),
torch.tensor([label], device=model.device),
)
if not torch.isfinite(ce):
print(f"[nan-skip] choice row loss={ce.item()} "
f"step~{steps+1}", flush=True)
continue
losses.append(float(ce.detach()))
if _near_oom(row, "pre-bwd choice"):
optim.zero_grad(set_to_none=True)
continue
(ce / grad_acc).backward()
except torch.OutOfMemoryError as exc:
_oom_skip(exc, f"choice row (step~{steps+1}) {_row_sig(row)}")
optim.zero_grad(set_to_none=True)
continue
# ---- Boolean / Score: trainable-head calibration -------------
for row in batch["boolean"][:aux_cap]:
if _grid_too_big(row):
skipped_big += 1
continue
try:
logits = model.forward_boolean_logits(
row["state"], row["instruction"], row["proposition"],
row.get("grid"))
ce = F.cross_entropy(
logits, torch.tensor([row["label"]], device=model.device))
if not torch.isfinite(ce):
print(f"[nan-skip] boolean row loss={ce.item()} "
f"step~{steps+1}", flush=True)
continue
losses.append(float(ce.detach()))
if _near_oom(row, "pre-bwd boolean"):
optim.zero_grad(set_to_none=True)
continue
(ce / grad_acc).backward()
except torch.OutOfMemoryError as exc:
_oom_skip(exc, f"boolean row (step~{steps+1}) {_row_sig(row)}")
optim.zero_grad(set_to_none=True)
continue
for row in batch["score"][:aux_cap]:
if _grid_too_big(row):
skipped_big += 1
continue
try:
logits = model.forward_score_logits(
row["state"], row["instruction"], row["description"],
row.get("grid"))
ce = F.cross_entropy(
logits, torch.tensor([row["label"]], device=model.device))
if not torch.isfinite(ce):
print(f"[nan-skip] score row loss={ce.item()} "
f"step~{steps+1}", flush=True)
continue
losses.append(float(ce.detach()))
if _near_oom(row, "pre-bwd score"):
optim.zero_grad(set_to_none=True)
continue
(ce / grad_acc).backward()
except torch.OutOfMemoryError as exc:
_oom_skip(exc, f"score row (step~{steps+1}) {_row_sig(row)}")
optim.zero_grad(set_to_none=True)
continue
if not losses: # batch without choice rows
continue
torch.nn.utils.clip_grad_norm_(
[p for group in optim.param_groups for p in group["params"]], 1.0)
optim.step()
optim.zero_grad()
steps += 1
if steps % 10 == 0:
torch.cuda.empty_cache() # v0.4.7: fight allocator fragmentation
if steps % int(cfg.get("log_every", 10)) == 0:
print(f"epoch={epoch} step={steps} "
f"loss={sum(losses)/len(losses):.4f} "
f"gpu={torch.cuda.memory_allocated()/2**30:.2f}GiB "
f"big-skip={skipped_big}", flush=True)
if save_every and steps % save_every == 0:
# WIP checkpoint: heads via model.save, LoRA via peft.
# NEVER fatal — a failed save must not kill training.
try:
model.save(str(out_dir))
if peft_model is not None:
peft_model.save_pretrained(str(out_dir / "hf_lora"))
(out_dir / "stage_b_meta.json").write_text(json.dumps(
{"steps": steps, "epochs": epoch, "wip": True},
indent=2))
print(f"[stage B] WIP save @ step {steps}", flush=True)
except Exception as exc:
print(f"[stage B] WIP save failed (non-fatal): {exc}",
flush=True)
if max_steps and steps >= max_steps:
print(f"max_steps={max_steps} reached; stopping early")
break
model.save(cfg["out_dir"])
# hf backend: persist LoRA adapters so the SFT result is reproducible
if peft_model is not None:
try:
peft_model.save_pretrained(str(Path(cfg["out_dir"]) / "hf_lora"))
except Exception as exc:
print(f"[stage B] lora save skipped: {exc}")
(Path(cfg["out_dir"]) / "stage_b_meta.json").write_text(json.dumps(
{"steps": steps, "epochs": epochs}, indent=2))
print(f"stage B done -> {cfg['out_dir']}")
if __name__ == "__main__":
main()