sparklabuofu's picture
Update the demo
22de727 verified
Raw History Blame Contribute Delete
23.6 kB
"""Train one WebMix LoRA adapter on replayed browser-use rows (runs in the `webmix` env).
python -m miniwebagent.train --adapter drag # family adapter: CE on its rows, KL anchor on the rest
python -m miniwebagent.train --adapter pooled # one adapter on every family's rows, no anchor
Rows: data/webmix/replay/<task>/rows.jsonl (miniwebgen.replay; browser-use's own prompt + screenshot ->
the flash-mode JSON it answered). Each row is a family's by the macro segment it came from.
Loss (WebMix's row-disjoint KL anchor, method/hyperWeb/integrations/llamafactory-kl.patch
`disjoint_kl_loss`, re-implemented here because that patch targets a LLaMA-Factory fork): a row is
either a CE row (the adapter's own families: mean -log p(target token)) or a KL row (other families,
`--anchor-ratio` of them per CE row, redrawn every epoch: the k3 estimate exp(d) - d - 1 with
d = log p_ref - log p_theta on the target's action tokens (not the templated memory, see
`action_mask`), clamped to [-20, 8], weighted by --beta). The
reference is the same model with the adapter disabled, so no second copy is loaded.
Recipe (WebMix default): rank 16, alpha 32, dropout 0.05, attention + MLP projections of the language
model only (targets.py `attn_mlp`: q/k/v/o on the full-attention layers, gate/up/down on every
layer; no vision tower, no DeltaNet projections), lr 1e-4 cosine, AdamW. Batch 1 x grad-accum.
Only the target positions' logits are computed (`logits_to_keep`): a 248k-token vocabulary over a
~6.6k-token prompt would not fit otherwise.
Multi-GPU (granite, 4x A800, 2026-09-30): `torchrun --nproc_per_node N -m miniwebagent.train ...` gives each rank a
disjoint share of every epoch's rows and sums the LoRA gradients across ranks at each optimizer step (one
flat all-reduce; no DDP wrapper, so KL rows' adapter-off forwards need no special handling). --grad-accum
stays the GLOBAL batch in rows, so a run's recipe does not depend on the GPU count. Rows are encoded
(screenshot decode + tokenization) in background threads ahead of the GPU.
"""
from __future__ import annotations
from helpers.paths import REPO_ROOT
import argparse
import hashlib
import json
import os
import math
import random
import time
from pathlib import Path
from miniwebagent import REPLAY_DIR
ROOT = REPO_ROOT
ROWS_DIR = REPLAY_DIR
OUT_DIR = ROOT / "data" / "webmix" / "adapters"
MODEL = os.environ.get("WEBMIX_BASE_MODEL", "Qwen/Qwen3.5-4B") # e.g. microsoft/Fara1.5-4B (RQ2 init swap), Qwen/Qwen3.5-9B
FAMILIES = ["Drag & gesture", "Discrete selection", "Text entry", "Form transaction", "Navigation",
"Out-of-page I/O", "Reasoning base"]
# the one-5090 pilot (design doc "Pilot on one 5090"): three family adapters + one pooled over the rest
ADAPTERS = {
"pooled": FAMILIES,
"drag": ["Drag & gesture"],
"io": ["Out-of-page I/O"],
"select": ["Discrete selection"],
"rest": ["Text entry", "Form transaction", "Navigation", "Reasoning base"],
# reasoning steps and final answers (user, 2026-09-30): rows from rejection sampling on the train-site human
# reasoning tasks (miniwebagent.evaluate --record-rows), KL-anchored on the synthetic families' rows
"reasoning": ["Reasoning base"],
# the planner (user, 2026-10-06: "lets try to train our planner"): its own rows by construction (miniwebgen.plan_data),
# trained with --rows-dir on them and --beta 0 (no executor rows to anchor on)
"planner": ["planner"],
}
TARGET_LEAVES = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
TARGET_RE = r".*language_model.*\.(" + "|".join(TARGET_LEAVES) + r")$"
def save_adapter(model, out):
"""PEFT writes the training regex into adapter_config.json; servers (vLLM) expect module names.
The weights only cover language-model modules, so the leaf-name list is equivalent."""
model.save_pretrained(out, selected_adapters=["default"]) # not the frozen anchor copy (--init-adapter)
cfg_path = Path(out) / "adapter_config.json"
cfg = json.loads(cfg_path.read_text())
cfg["target_modules"] = TARGET_LEAVES
cfg_path.write_text(json.dumps(cfg, indent=2))
def is_val(task_id, frac=0.05):
"""A fixed ~5% of trajectories (by task) held out for validation loss."""
return int(hashlib.md5(task_id.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF < frac
def load_rows(rows_dir=ROWS_DIR):
rows = []
for p in sorted(rows_dir.glob("*/rows.jsonl")):
if p.parent.name.startswith("_"):
continue
try:
parts = json.loads((p.parent / "episode.json").read_text()).get("parts")
except (OSError, ValueError):
parts = None
for line in p.read_text().split("\n"):
if line.strip():
r = json.loads(line)
if r.get("inject") or r.get("aug_prefix"):
continue # a recovery episode's injected mistake, and its prefix (a copy of the plain replay)
r["dir"] = str(p.parent)
if parts:
r["parts"] = parts
rows.append(r)
return rows
def in_frac(r, frac):
"""Scaling runs (2026-10-01): keep a fixed `frac` of the generated trajectories, whole episodes at a time (a
recovery variant follows its source, a chain needs all its parts); nested across fractions, separate from the
validation split."""
if frac >= 1:
return True
key = lambda t: int(hashlib.md5(f"frac:{t}".encode()).hexdigest()[:8], 16) / 0xFFFFFFFF # noqa: E731
return all(key(t) < frac for t in (r.get("parts") or [_base_task(r["task_id"])]))
def in_ids(r, ids):
"""--episode-ids (skill-mix ablation, 2026-10-02): keep a row when its trajectory is listed (a recovery variant follows
its source, a chain needs all its parts)."""
return ids is None or all(t in ids for t in (r.get("parts") or [_base_task(r["task_id"])]))
def row_is_val(r):
"""Held out when its trajectory is (a recovery variant follows its source, a chain any of its parts)."""
return any(is_val(t) for t in (r.get("parts") or [_base_task(r["task_id"])]))
# ── examples ──────────────────────────────────────────────────────────────────
def to_hf(row):
"""OpenAI-format messages (image parts point at files next to rows.jsonl) -> (HF messages, images)."""
from PIL import Image
msgs, imgs = [], []
for m in row["messages"]:
c = m["content"]
if isinstance(c, str):
msgs.append({"role": m["role"], "content": [{"type": "text", "text": c}]})
continue
parts = []
for x in c:
if x.get("type") == "text":
parts.append({"type": "text", "text": x["text"]})
elif x.get("type") == "image_url":
imgs.append(Image.open(Path(row["dir"]) / x["image_url"]["url"]).convert("RGB"))
parts.append({"type": "image"})
msgs.append({"role": m["role"], "content": parts})
return msgs, imgs
def encode(processor, row):
"""-> model inputs + labels (-100 on the prompt) + n_target (supervised tokens incl. <|im_end|>)."""
msgs, imgs = to_hf(row)
prompt = processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)
full = prompt + row["target"] + "<|im_end|>"
kw = {"images": imgs} if imgs else {}
enc = processor(text=[full], return_tensors="pt", **kw)
n_prompt = processor(text=[prompt], return_tensors="pt", **kw)["input_ids"].shape[1]
labels = enc["input_ids"].clone()
labels[:, :n_prompt] = -100
enc["labels"] = labels
enc["n_target"] = n = enc["input_ids"].shape[1] - n_prompt
enc["action_mask"] = action_mask(processor.tokenizer, row["target"], n)
return enc
def action_mask(tokenizer, target, n):
"""(n,) bool over the target tokens: True from the "action" field on (incl. <|im_end|>). The KL
anchor applies there only (the memory is written by the base model itself since cycle 3, so it needs
no anchor), and --ce-on action restricts the CE to it. All True when the
target's standalone tokenization does not line up with its tokens in context."""
import torch
t = tokenizer(target + "<|im_end|>", add_special_tokens=False, return_offsets_mapping=True)
at = target.find('"action"')
if len(t["input_ids"]) != n or at < 0:
return torch.ones(n, dtype=torch.bool)
return torch.tensor([end > at for _, end in t["offset_mapping"]], dtype=torch.bool)
def target_logprobs(model, enc, device):
"""log p(target token) at each supervised position, computing logits only for those positions."""
import torch
n = enc["n_target"]
inputs = {k: v.to(device) for k, v in enc.items() if k not in ("labels", "n_target", "action_mask")}
out = model(**inputs, logits_to_keep=n + 1, use_cache=False)
logits = out.logits[:, :-1, :].float() # positions predicting the n target tokens
tgt = enc["labels"][:, -n:].to(device)
return torch.log_softmax(logits, dim=-1).gather(-1, tgt.unsqueeze(-1)).squeeze(-1) # (1, n)
def prefetched(processor, items, workers=4, ahead=8):
"""Yield (item, encode(row)) in order, encoding up to `ahead` rows in background threads."""
from collections import deque
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(workers) as ex:
q, it = deque(), iter(items)
for x in it:
q.append((x, ex.submit(encode, processor, x[0])))
if len(q) >= ahead:
break
while q:
x, fut = q.popleft()
nxt = next(it, None)
if nxt is not None:
q.append((nxt, ex.submit(encode, processor, nxt[0])))
yield x, fut.result()
def _dist():
"""-> (rank, world, local_rank); initialises NCCL under torchrun."""
import os
world = int(os.environ.get("WORLD_SIZE", "1"))
if world == 1:
return 0, 1, 0
import torch
import torch.distributed as dist
local = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local)
from datetime import timedelta
dist.init_process_group("nccl", timeout=timedelta(hours=1)) # rank 0 evaluates while the others wait
return dist.get_rank(), world, local
def _allreduce_grads(params, world):
import torch
import torch.distributed as dist
grads = [p.grad if p.grad is not None else torch.zeros_like(p) for p in params]
flat = torch.cat([g.reshape(-1) for g in grads])
dist.all_reduce(flat)
flat /= world
i = 0
for p, g in zip(params, grads):
n = g.numel()
p.grad = flat[i:i + n].view_as(p)
i += n
# ── training ──────────────────────────────────────────────────────────────────
def main(argv=None):
ap = argparse.ArgumentParser(prog="python -m miniwebagent.train")
ap.add_argument("--adapter", required=True, choices=sorted(ADAPTERS))
ap.add_argument("--epochs", type=float, default=3)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--rank", type=int, default=16)
ap.add_argument("--alpha", type=int, default=32)
ap.add_argument("--grad-accum", type=int, default=8)
ap.add_argument("--beta", type=float, default=0.1, help="KL anchor weight (0 = no anchor rows)")
ap.add_argument("--anchor-ratio", type=float, default=0.5, help="KL rows per CE row")
ap.add_argument("--limit", type=int, default=0, help="cap CE rows (smoke tests)")
ap.add_argument("--max-steps", type=int, default=0, help="stop after this many rows (smoke tests)")
ap.add_argument("--out", default=None)
ap.add_argument("--rows-dir", default=None, help="rows to train on (default: the executor replay, REPLAY_DIR)")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--wandb-project", default="webmix-pilot", help="wandb project ('' = no wandb)")
ap.add_argument("--ce-on", choices=["all", "action"], default="all",
help="CE on the whole target (memory + action) or on the action tokens only")
ap.add_argument("--exclude-variants", default="", help="comma list of replay variants to leave out (recovery,chain)")
ap.add_argument("--frac", type=float, default=1.0,
help="scaling: train on this fraction of the generated episodes (whole episodes, nested)")
ap.add_argument("--episode-ids", default=None,
help="train only on the trajectories listed in this file (one task id per line; variants follow)")
ap.add_argument("--init-adapter", default=None,
help="branch-then-specialize (2026-10-01): start from this adapter's weights (same rank/alpha) and "
"anchor the KL rows to it (a frozen copy) instead of the base model")
ap.add_argument("--drop-done", action="store_true",
help="review round 1 control (2026-10-05): leave out every episode's final `done` step, so practice does "
"not teach the single agent to stop after one primitive (the `macro_done` before it stays)")
ap.add_argument("--save-every", type=int, default=5000,
help="also save the adapter every N rows (out/rows_<n>; 0 = epoch boundaries only)")
args = ap.parse_args(argv)
import torch
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForImageTextToText, AutoProcessor
rank, world, local = _dist()
main0 = rank == 0
random.seed(args.seed)
torch.manual_seed(args.seed)
fams = set(ADAPTERS[args.adapter])
rows = load_rows(Path(args.rows_dir)) if args.rows_dir else load_rows()
if args.exclude_variants:
drop = set(args.exclude_variants.split(","))
rows = [r for r in rows if _variant(r["task_id"]) not in drop]
if args.drop_done:
n0 = len(rows)
rows = [r for r in rows if r.get("op") != "done"]
if main0:
print(f"--drop-done: left out {n0 - len(rows)} final done steps", flush=True)
ids = set(Path(args.episode_ids).read_text().split()) if args.episode_ids else None
train = [r for r in rows if not row_is_val(r) and in_frac(r, args.frac) and in_ids(r, ids)]
val = [r for r in rows if row_is_val(r)]
ce_rows = [r for r in train if r["family"] in fams]
kl_pool = [r for r in train if r["family"] not in fams] if args.beta > 0 else []
if args.limit:
random.shuffle(ce_rows)
ce_rows = ce_rows[:args.limit]
out = Path(args.out) if args.out else OUT_DIR / args.adapter
out.mkdir(parents=True, exist_ok=True)
if main0:
print(f"adapter {args.adapter}: {len(ce_rows)} CE rows ({sorted(fams)}), anchor pool {len(kl_pool)}, "
f"val {len(val)}, {world} GPU(s)", flush=True)
device = f"cuda:{local}"
processor = AutoProcessor.from_pretrained(MODEL)
model = AutoModelForImageTextToText.from_pretrained(MODEL, dtype=torch.bfloat16, device_map=device,
attn_implementation="sdpa")
model.config.use_cache = False
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.enable_input_require_grads()
model = get_peft_model(model, LoraConfig(r=args.rank, lora_alpha=args.alpha, lora_dropout=0.05, bias="none",
target_modules=TARGET_RE, task_type="CAUSAL_LM"))
anchor = None
if args.init_adapter:
# specialists trained from base on their family's rows alone lost the general habits (scrolling, finding,
# navigating) pooled_v4 learned from every family; branching from pooled_v4 keeps them
from peft import set_peft_model_state_dict
from safetensors.torch import load_file
sd = load_file(str(Path(args.init_adapter) / "adapter_model.safetensors"))
res = set_peft_model_state_dict(model, sd)
missing = [k for k in getattr(res, "missing_keys", []) if "lora_" in k]
assert not missing, f"--init-adapter left {len(missing)} LoRA weights unset, e.g. {missing[:2]}"
if args.beta > 0:
model.load_adapter(args.init_adapter, adapter_name="anchor", is_trainable=False)
model.set_adapter("default")
anchor = "anchor"
if main0:
print(f"initialized from {args.init_adapter} ({len(sd)} tensors); KL anchor: "
f"{'that adapter (frozen)' if anchor else 'none'}", flush=True)
if main0:
model.print_trainable_parameters()
n_kl = int(round(len(ce_rows) * args.anchor_ratio)) if kl_pool else 0
per_epoch = (len(ce_rows) + n_kl) // world * world # every rank takes the same number of rows
total = int(math.ceil(per_epoch * args.epochs)) // world * world
if args.max_steps:
total = min(total, args.max_steps // world * world)
accum = max(1, args.grad_accum // world) # rows per rank per optimizer step
opt_steps = max(1, total // (accum * world))
params = [p for p in model.parameters() if p.requires_grad]
opt = torch.optim.AdamW(params, lr=args.lr, weight_decay=0.0)
warm = max(1, int(0.03 * opt_steps))
sched = torch.optim.lr_scheduler.LambdaLR(
opt, lambda s: min(1.0, (s + 1) / warm) * 0.5 * (1 + math.cos(math.pi * min(1.0, s / opt_steps))))
log = open(out / "train_log.jsonl", "a") if main0 else None
val_fam = [r for r in val if r["family"] in fams]
random.Random(1).shuffle(val_fam) # a fixed sample across macros (the file order is alphabetical by macro)
wb = None
if args.wandb_project and main0:
import wandb
wb = wandb.init(project=args.wandb_project, name=f"{out.name}-{time.strftime('%m%d-%H%M')}", dir=str(out),
config={**vars(args), "families": sorted(fams), "base_model": MODEL, "ce_rows": len(ce_rows),
"anchor_pool": len(kl_pool), "anchor_rows_per_epoch": n_kl, "val_rows": len(val_fam),
"rows_total": total, "optimizer_steps": opt_steps, "gpus": world})
model.train()
done, t0, tokens, acc = 0, time.time(), 0, {"ce": [], "kl": []} # done = rows over all ranks
saved_at = 0
epoch = 0
while done < total:
batch = [(r, "ce") for r in ce_rows] + [(r, "kl") for r in random.sample(kl_pool, min(n_kl, len(kl_pool)))]
random.shuffle(batch) # the same order on every rank (same seed, same calls)
batch = batch[:len(batch) // world * world][rank::world]
batch = batch[:max(0, (total - done) // world)]
k = 0
for (r, kind), enc in prefetched(processor, batch):
logp = target_logprobs(model, enc, device)
if kind == "ce":
if args.ce_on == "action":
m = enc["action_mask"].to(device).unsqueeze(0).float()
loss = -(logp * m).sum() / m.sum().clamp(min=1.0)
else:
loss = -logp.mean()
else:
with torch.no_grad():
if anchor: # the initial adapter, frozen (branch-then-specialize)
model.set_adapter(anchor)
ref = target_logprobs(model, enc, device)
model.set_adapter("default")
else:
with model.disable_adapter():
ref = target_logprobs(model, enc, device)
d = (ref - logp).clamp(-20.0, 8.0)
m = enc["action_mask"].to(device).unsqueeze(0).float()
loss = args.beta * ((torch.exp(d) - d - 1.0) * m).sum() / m.sum().clamp(min=1.0)
(loss / accum).backward()
acc[kind].append(loss.item() / (args.beta if kind == "kl" else 1.0))
tokens += enc["input_ids"].shape[1] * world
done += world
k += 1
if k % accum == 0 or done >= total:
if world > 1:
_allreduce_grads(params, world)
torch.nn.utils.clip_grad_norm_(params, 1.0)
opt.step()
sched.step()
opt.zero_grad(set_to_none=True)
# a mid-run checkpoint (a crash loses at most N rows; learning curves come for free)
if main0 and args.save_every and done < total and done // args.save_every > saved_at // args.save_every:
save_adapter(model, out / f"rows_{done}")
saved_at = done
if main0 and (done % (50 * world) == 0 or done >= total):
el = time.time() - t0
rec = {"rows": done, "of": total, "epoch": epoch,
"ce": sum(acc["ce"]) / max(1, len(acc["ce"])), "kl": sum(acc["kl"]) / max(1, len(acc["kl"])),
"lr": sched.get_last_lr()[0], "rows_per_s": done / el, "tokens_per_s": tokens / el,
"gpu_gb": torch.cuda.max_memory_allocated() / 2**30}
log.write(json.dumps(rec) + "\n")
log.flush()
print(json.dumps(rec), flush=True)
if wb:
wb.log(rec, step=done)
acc = {"ce": [], "kl": []}
epoch += 1
if done < total: # a checkpoint + held-out CE at each epoch boundary
if main0:
save_adapter(model, out / f"epoch_{epoch}")
v = evaluate(model, processor, val_fam[:100], device)
print(json.dumps({"epoch": epoch, "val_ce": v}), flush=True)
if wb:
wb.log({"val_ce": v, "epoch": epoch}, step=done)
_barrier(world)
if not main0:
_barrier(world)
return
save_adapter(model, out)
val_loss = evaluate(model, processor, val_fam[:200], device)
meta = {"adapter": args.adapter, "families": sorted(fams), "base_model": MODEL, "ce_rows": len(ce_rows),
"anchor_pool": len(kl_pool), "rows_trained": done, "val_ce": val_loss, "args": vars(args),
"minutes": round((time.time() - t0) / 60, 1)}
(out / "webmix_meta.json").write_text(json.dumps(meta, indent=1))
print(json.dumps(meta), flush=True)
if wb:
wb.log({"val_ce": val_loss, "epoch": epoch}, step=done)
wb.summary.update({"val_ce_final": val_loss, "minutes": meta["minutes"]})
wb.finish()
_barrier(world)
def _barrier(world):
if world > 1:
import torch.distributed as dist
dist.barrier()
def _base_task(task_id):
"""A variant's held-out split follows its (first) source trajectory, so no trajectory leaks across it."""
return task_id.split("~")[0]
def _variant(task_id):
return "recovery" if task_id.endswith("~rec") else "chain" if task_id.startswith("chain-") else "plain"
def evaluate(model, processor, rows, device):
"""Mean target-token CE on held-out rows of the adapter's families (None without any)."""
import torch
if not rows:
return None
model.eval()
tot = 0.0
with torch.no_grad():
for r in rows:
tot += -target_logprobs(model, encode(processor, r), device).mean().item()
model.train()
return tot / len(rows)
if __name__ == "__main__":
main()