CuriousGame / code /train /bt_train.py
LeoMaxwell's picture
Add inference code and MineExplorer patches
4764138 verified
Raw History Blame Contribute Delete
14.1 kB
"""LoRA training of the BT policy (EXPERIMENT_LOG.md 2026-10-17): Qwen3.5-9B reads one frame and BT_PROMPT with the
target name and writes the behavior code (one digit token per FSQ dimension). Runs with torchrun in .train-venv.
Rows (jsonl): image (PNG path), name, code (digits); ppo rows also adv and vllm_logp, and memory (the two 0/1 strings
of the decision's prompt) when the rollouts ran with memory. A row is the chat template of [image, bt_prompt(name,
memory)] with the generation prompt (thinking off), as vLLM renders BTPolicy's request, followed by the code and
<|im_end|>; batches are right-padded and the scored tokens gathered per row.
- sft: cross entropy on the code tokens and <|im_end|>, new LoRA (r 16, alpha 32, train_iql.py's modules), AdamW,
10-update warmup then cosine; per-digit and whole-code (all digits argmax) accuracy on --valid after each epoch.
Saves the adapter to --out (vLLM-loadable).
- ppo: continues --adapter (and its optimizer.pt if it has one, i.e. a PPO round's), loads --ref (the SFT adapter) as
a second adapter; old and ref log-probs of the code tokens computed once with no_grad, then --epochs of PPO:
loss = -min(ratio A, clip(ratio, 0.8, 1.2) A) + beta * sum over the code tokens of k3 = exp(ref - lp) - (ref - lp) - 1,
ratio = exp(sum lp - sum old). Saves the adapter, optimizer.pt and stats.json (pg_loss, kl, clip_frac of the last
epoch; logp_gap = mean of old - vllm_logp, logp_gap_abs) to --out.
Usage: CUDA_VISIBLE_DEVICES=6,7 .train-venv/bin/python -m torch.distributed.run --nproc_per_node=2 train/bt_train.py
sft --data TRAIN.jsonl --valid VALID.jsonl --out DIR [--epochs 1 --lr 1e-4 --max-updates N]
... train/bt_train.py ppo --data ROUND/train.jsonl --adapter ADAPTER --ref SFT --out DIR
"""
import argparse
import json
import math
import os
import random
import time
from pathlib import Path
import torch
import torch.distributed as dist
from peft import LoraConfig, PeftModel, get_peft_model
from PIL import Image
from torch.nn.parallel import DistributedDataParallel
from torch.utils.data import DataLoader, DistributedSampler
from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
from ctrl_policy import bt_prompt
ROOT = Path(__file__).resolve().parents[1]
MODULES = (r"model.language_model.*\.(q_proj|k_proj|v_proj|o_proj|in_proj_qkv|in_proj_z|in_proj_b|in_proj_a|out_proj"
r"|gate_proj|up_proj|down_proj)$") # train_iql.py
END = "<|im_end|>"
CLIP, BETA = 0.2, 0.02
def log(message):
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
class Collate:
def __init__(self, processor):
self.processor = processor
def __call__(self, rows):
p = self.processor
texts = [p.apply_chat_template([{"role": "user", "content": [{"type": "image"}, {"type": "text",
"text": bt_prompt(r["name"], r.get("memory"))}]}], tokenize=False,
add_generation_prompt=True, enable_thinking=False) + r["code"] + END
for r in rows]
inputs = p(text=texts, images=[Image.open(r["image"]).convert("RGB") for r in rows], padding=True,
padding_side="right", return_tensors="pt")
n = len(rows[0]["code"])
ends = inputs["attention_mask"].sum(1) # sequence lengths: the last token, <|im_end|>, is at end - 1
targets = torch.stack([inputs["input_ids"][i, e - n - 1:e] for i, e in enumerate(ends)]) # code + im_end
extra = {k: torch.tensor([r[k] for r in rows], dtype=torch.float32) for k in ("adv", "vllm_logp", "index")
if k in rows[0]}
return {"inputs": dict(inputs), "ends": ends, "targets": targets, **extra}
def token_logprobs(model, batch, device):
"""(B, n + 1) log-probs of the code tokens and <|im_end|>, and the logits' argmax at those positions."""
inputs = {k: v.to(device) for k, v in batch["inputs"].items()}
ends, k = batch["ends"], batch["targets"].shape[1]
lo, hi = int(ends.min()) - k - 1, int(ends.max()) - 1 # logits at position j predict token j + 1
with torch.autocast("cuda", dtype=torch.bfloat16):
logits = model(**inputs, logits_to_keep=torch.arange(lo, hi, device=device), use_cache=False).logits
rows = torch.stack([logits[i, e - k - 1 - lo:e - 1 - lo] for i, e in enumerate(ends.tolist())]).float()
lp = rows.log_softmax(-1)
targets = batch["targets"].to(device)
return lp.gather(-1, targets[..., None])[..., 0], rows.argmax(-1) == targets
def load_model(local):
model = Qwen3_5ForConditionalGeneration.from_pretrained(
ROOT / "models/Qwen3.5-9B", dtype=torch.bfloat16, device_map={"": local}, attn_implementation="sdpa",
local_files_only=True, use_kernels=True)
model.requires_grad_(False)
return model
def train_setup(model):
model.enable_input_require_grads()
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.config.use_cache = False
model.config.text_config.use_cache = False
def loader(rows, collate, batch, rank, world, seed, shuffle=True):
sampler = DistributedSampler(rows, num_replicas=world, rank=rank, seed=seed, shuffle=shuffle)
return sampler, DataLoader(rows, batch_size=batch, sampler=sampler, collate_fn=collate, num_workers=4,
persistent_workers=True, prefetch_factor=2)
@torch.no_grad()
def accuracy(model, rows, collate, batch, rank, world, device):
"""Per-digit and whole-code accuracy (teacher-forced argmax) of the code tokens on rows."""
model.eval()
totals = torch.zeros(3, device=device)
for b in DataLoader(rows[rank::world], batch_size=batch, collate_fn=collate, num_workers=4):
_, right = token_logprobs(model, b, device)
right = right[:, :-1]
totals += torch.tensor([right.sum(), right.all(1).sum(), len(right)], device=device, dtype=torch.float32)
dist.all_reduce(totals)
model.train()
return {"digit_acc": round((totals[0] / totals[2] / right.shape[1]).item(), 4),
"code_acc": round((totals[1] / totals[2]).item(), 4), "rows": int(totals[2])}
def sft(args, rank, world, local, device):
rows = [json.loads(l) for l in args.data.read_text().splitlines()]
valid = [json.loads(l) for l in args.valid.read_text().splitlines()]
processor = AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
collate = Collate(processor)
torch.manual_seed(args.seed)
model = get_peft_model(load_model(local), LoraConfig(r=16, lora_alpha=32, lora_dropout=0.0,
task_type="CAUSAL_LM", target_modules=MODULES))
train_setup(model)
ddp = DistributedDataParallel(model, device_ids=[local])
sampler, data = loader(rows, collate, args.batch, rank, world, args.seed)
opt = torch.optim.AdamW((p for p in model.parameters() if p.requires_grad), lr=args.lr)
total = min(args.max_updates or 10**9, len(data) * args.epochs)
if rank == 0:
log(f"SFT rows {len(rows)} valid {len(valid)}, {world} GPUs x batch {args.batch}, updates {total}")
log("VALID before " + json.dumps(accuracy(model, valid, collate, args.batch, rank, world, device)))
else:
accuracy(model, valid, collate, args.batch, rank, world, device)
update, start = 0, time.monotonic()
for epoch in range(args.epochs):
sampler.set_epoch(epoch)
for b in data:
lp, _ = token_logprobs(ddp, b, device)
loss = -lp.mean()
opt.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
for g in opt.param_groups:
g["lr"] = args.lr * min(1, (update + 1) / 10) * 0.5 * (1 + math.cos(math.pi * update / total))
opt.step()
update += 1
if rank == 0 and (update <= 5 or update % 50 == 0):
s = time.monotonic() - start
log(f"SFT update {update}/{total} loss {loss.item():.4f} {s / update:.2f} s/update "
f"eta {(total - update) * s / update / 60:.0f} min peak {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB")
if update >= total:
break
acc = accuracy(model, valid, collate, args.batch, rank, world, device)
if rank == 0:
log(f"VALID epoch {epoch + 1} " + json.dumps(acc))
model.save_pretrained(args.out)
(args.out / "valid.json").write_text(json.dumps({"epoch": epoch + 1, "updates": update, **acc}))
if update >= total:
break
def ppo(args, rank, world, local, device):
rows = [{**json.loads(l), "index": i} for i, l in enumerate(args.data.read_text().splitlines())]
processor = AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
collate = Collate(processor)
torch.manual_seed(args.seed)
model = PeftModel.from_pretrained(load_model(local), args.adapter, is_trainable=True)
model.load_adapter(args.ref, adapter_name="ref")
n = len(rows[0]["code"])
lp_of = {}
for name in ("ref", "default"): # once per round, no_grad: the reference and the old policy
model.set_adapter(name)
model.eval()
mine = {}
with torch.no_grad():
for b in DataLoader(rows[rank::world], batch_size=2 * args.batch, collate_fn=collate, num_workers=4):
lp, _ = token_logprobs(model, b, device)
mine.update(zip(b["index"].long().tolist(), lp[:, :n].cpu()))
gathered = [None] * world
dist.all_gather_object(gathered, mine)
lp_of[name] = torch.stack([{k: v for g in gathered for k, v in g.items()}[i] for i in range(len(rows))])
model.set_adapter("default")
for name, p in model.named_parameters():
p.requires_grad = "lora_" in name and ".default." in name
model.train()
train_setup(model)
ddp = DistributedDataParallel(model, device_ids=[local])
params = [p for p in model.parameters() if p.requires_grad]
opt = torch.optim.AdamW(params, lr=args.lr)
if (args.adapter / "optimizer.pt").exists():
opt.load_state_dict(torch.load(args.adapter / "optimizer.pt", map_location=device))
for g in opt.param_groups:
g["lr"] = args.lr
sampler, data = loader(rows, collate, args.batch, rank, world, args.seed)
old_all, ref_all = lp_of["default"].to(device), lp_of["ref"].to(device)
vllm = torch.tensor([r["vllm_logp"] for r in rows], device=device)
gap = old_all.sum(1) - vllm
start = time.monotonic()
for epoch in range(args.epochs):
sampler.set_epoch(epoch)
stats = []
for step, b in enumerate(data):
idx = b["index"].long().to(device)
lp, _ = token_logprobs(ddp, b, device)
lp = lp[:, :n]
adv = b["adv"].to(device)
ratio = (lp.sum(1) - old_all[idx].sum(1)).exp()
pg = -torch.min(ratio * adv, ratio.clamp(1 - CLIP, 1 + CLIP) * adv)
d = ref_all[idx] - lp
kl = (d.exp() - d - 1).sum(1)
loss = (pg + BETA * kl).mean() / args.accumulation
sync = (step + 1) % args.accumulation == 0 or step + 1 == len(data)
if sync:
loss.backward()
torch.nn.utils.clip_grad_norm_(params, 1.0)
opt.step()
opt.zero_grad(set_to_none=True)
else:
with ddp.no_sync():
loss.backward()
stats.append([pg.mean().item(), kl.mean().item(), ((ratio - 1).abs() > CLIP).float().mean().item()])
if rank == 0:
log(f"PPO epoch {epoch + 1} pg {sum(s[0] for s in stats) / len(stats):+.4f} "
f"kl {sum(s[1] for s in stats) / len(stats):.4f} {time.monotonic() - start:.0f} s")
totals = torch.tensor(stats, device=device).mean(0)
dist.all_reduce(totals)
totals /= world
if rank == 0:
args.out.mkdir(parents=True, exist_ok=True)
model.save_pretrained(args.out, selected_adapters=["default"])
torch.save(opt.state_dict(), args.out / "optimizer.pt")
result = {"pg_loss": round(totals[0].item(), 4), "kl": round(totals[1].item(), 4),
"clip_frac": round(totals[2].item(), 4), "logp_gap": round(gap.mean().item(), 4),
"logp_gap_abs": round(gap.abs().mean().item(), 4), "rows": len(rows),
"train_s": round(time.monotonic() - start)}
(args.out / "stats.json").write_text(json.dumps(result))
log("PPO_DONE " + json.dumps(result))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mode", choices=["sft", "ppo"])
ap.add_argument("--data", type=Path, required=True)
ap.add_argument("--valid", type=Path)
ap.add_argument("--adapter", type=Path)
ap.add_argument("--ref", type=Path)
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--epochs", type=int)
ap.add_argument("--lr", type=float)
ap.add_argument("--batch", type=int, default=16, help="rows per GPU per forward")
ap.add_argument("--accumulation", type=int, default=2, help="ppo: forwards per update (64 rows on 2 GPUs)")
ap.add_argument("--max-updates", type=int)
ap.add_argument("--seed", type=int, default=0)
args = ap.parse_args()
rank, world, local = int(os.environ["RANK"]), int(os.environ["WORLD_SIZE"]), int(os.environ["LOCAL_RANK"])
torch.set_num_threads(4)
random.seed(args.seed)
torch.cuda.set_device(local)
device = torch.device("cuda", local)
dist.init_process_group("nccl", device_id=device)
if args.mode == "sft":
args.epochs, args.lr = args.epochs or 1, args.lr or 1e-4
sft(args, rank, world, local, device)
else:
args.epochs, args.lr = args.epochs or 2, args.lr or 3e-6
ppo(args, rank, world, local, device)
dist.barrier()
dist.destroy_process_group()
if __name__ == "__main__":
main()