GUI-Decisions / slotread /train.py
infinitylogesh's picture
GUI-Decisions Space
d0e7c02 verified
Raw History Blame Contribute Delete
12.7 kB
"""Train one LoRA adapter for slot reading, jointly across tasks.
Every slot's answer is the full-vocabulary log-probability of its labels at the token before its
placeholder; the loss is cross-entropy against soft ordinal targets, normalized per number (a coarse
slot adds to its number's loss rather than diluting it). Only the logits at the slot positions are
computed (logits_to_keep), so a whole layout costs one forward pass.
# the blog's arms (Gemma 4 31B, all three tasks)
python -m slotread.train --arm E --out runs/j_E --run-name j-E
python -m slotread.train --arm A --out runs/j_A # also F, Bplus (and C, Bplusw)
# E3: general-object boxes, warm-started from E
python -m slotread.train --arm E --init runs/j_E/adapter --lr 1e-4 --n-agent 5000 --n-click 1000 \\
--n-box 4000 --n-box-photo 5000 --n-seg 6000 --out runs/j_E3
# a smaller model, computer use only
python -m slotread.train --arm E --model mPLUG/GUI-Owl-1.5-2B-Instruct --processor mPLUG/GUI-Owl-1.5-2B-Instruct \\
--tasks agent --n-agent 5000 --n-click 2000 --image-cap 1048576 --out runs/owl_E
Writes <out>/config.json (each task's system prompt, slot order and label ids: what a reader needs),
<out>/adapter and <out>/adapter-<n> checkpoints.
"""
import argparse, json, math, os, random, time
from multiprocessing import Pool
import torch
from . import images
from . import labels as L
from .geometry import poly_mask, radial_encode
from .numbers import LOG_MIN
from .tasks import AgentTask, BoxTask, SegTask
LORA_TARGETS = r".*language_model.*\.(q_proj|k_proj|v_proj|o_proj|gate_proj|up_proj|down_proj)"
def encode(proc, task, ex, arm):
"""The chat-templated input and the positions whose logits predict each slot (the token before
each placeholder)."""
enc = proc.apply_chat_template(task.messages(ex, arm), tokenize=True, return_dict=True, return_tensors="pt",
add_generation_prompt=False, continue_final_message=True, enable_thinking=False)
ids = enc["input_ids"][0]
n = len(task.layout(arm))
pos = (ids == L.PH).nonzero().flatten()[-n:]
assert len(pos) == n, f"{task.name}: {len(pos)} placeholders for {n} slots"
return enc, pos - 1
def read(model, enc, keep, slots, labels):
"""Full-vocabulary log-probabilities of each slot's labels (a loss on them also moves mass onto
the labels; readouts renormalize over the labels)."""
out = model(**{k: v.to(model.device) for k, v in enc.items()}, logits_to_keep=keep.to(model.device),
use_cache=False)
lv = torch.log_softmax(out.logits[0].float(), -1)
return {s: lv[i, labels[s]] for i, s in enumerate(slots)}
def _radial(args):
polys, size, K = args
return radial_encode(poly_mask(polys, *size), K)
def aggregate(name, rows):
keys = {k for r in rows for k, v in r.items() if v is not None}
m = {k: sum(r[k] for r in rows if r.get(k) is not None) / max(1, sum(r.get(k) is not None for r in rows))
for k in keys if k not in ("inter", "union")}
if name == "seg":
m["ciou"] = sum(r["inter"] for r in rows) / max(1, sum(r["union"] for r in rows))
m["miou"] = m.pop("iou")
return m
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--arm", choices=list(L.ARMS), required=True)
ap.add_argument("--tasks", default="agent,box,seg")
ap.add_argument("--n-agent", type=int, default=7000, help="AGUVIS training steps")
ap.add_argument("--n-click", type=int, default=2000, help="Wave-UI clicks in the agent layout")
ap.add_argument("--n-box", type=int, default=6000, help="Wave-UI element boxes")
ap.add_argument("--n-box-photo", type=int, default=0, help="RefCOCO referent boxes (general objects)")
ap.add_argument("--n-seg", type=int, default=9000, help="RefCOCO masks")
ap.add_argument("--n-val", type=int, default=100, help="per task, for the in-run evals")
ap.add_argument("--rays", type=int, default=24)
ap.add_argument("--init", default="", help="continue this adapter (warm start) instead of a fresh LoRA")
ap.add_argument("--max-train", type=int, default=0, help="cap the shuffled training list (pilots)")
ap.add_argument("--model", default="google/gemma-4-31B-it")
ap.add_argument("--processor", default="nvidia/Gemma-4-31B-IT-NVFP4", help="the served model's processor (template)")
ap.add_argument("--image-cap", type=int, default=0, help="max pixels per image, as served; 0: as given")
ap.add_argument("--out", required=True)
ap.add_argument("--lr", type=float, default=2e-4)
ap.add_argument("--accum", type=int, default=8)
ap.add_argument("--r", type=int, default=16)
ap.add_argument("--eval-every", type=int, default=4000)
ap.add_argument("--clip", type=float, default=1.0, help="gradient-norm clip (the norm before clipping is logged)")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--wandb", default="", help="wandb project (needs WANDB_API_KEY); empty: stdout only")
ap.add_argument("--run-name", default="")
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
random.seed(a.seed); torch.manual_seed(a.seed)
from transformers import AutoModelForImageTextToText, AutoProcessor
from peft import LoraConfig, PeftModel, get_peft_model
proc = AutoProcessor.from_pretrained(a.processor)
tok = proc.tokenizer
if "gemma" not in a.model.lower(): # placeholder and digit ids from this tokenizer
L.configure_tokenizer(tok)
images.IMAGE_CAP = a.image_cap
arm = L.make_arm(a.arm, tok)
make = {"agent": AgentTask, "box": BoxTask, "seg": lambda: SegTask(a.rays)}
tasks = {n: make[n]() for n in a.tasks.split(",")}
labels = {n: t.labels(arm, tok) for n, t in tasks.items()}
for n, t in tasks.items():
print(f"{n}: {len(t.layout(arm))} slots | {' '.join(t.layout(arm))[:160]}", flush=True)
json.dump(dict(arm=arm.name, coarse=arm.coarse, bins=arm.bins, tokens=arm.tokens, fine=arm.fine,
log_min=LOG_MIN, rays=a.rays, placeholder=L.PH, seed=a.seed, run_name=a.run_name,
model=a.model, image_cap=a.image_cap, init=a.init,
counts=dict(agent=a.n_agent, click=a.n_click, box=a.n_box, box_photo=a.n_box_photo, seg=a.n_seg),
tasks={n: t.config(arm, tok) for n, t in tasks.items()}),
open(f"{a.out}/config.json", "w"), indent=1)
t0 = time.time()
train, val = [], {}
if "agent" in tasks:
tr, va = tasks["agent"].examples(a.n_agent, a.n_click, 200)
train += [("agent", ex) for ex in tr]
val["agent"] = va[:a.n_val]
if "box" in tasks:
tr, va, va_photo = tasks["box"].examples(a.n_box, a.n_val, a.n_box_photo, a.seed)
train += [("box", ex) for ex in tr]
val["box"] = va
if va_photo:
val["box_photo"] = va_photo
if "seg" in tasks:
tr, va = tasks["seg"].examples(a.n_seg, a.n_val, seed=a.seed)
with Pool(16) as p: # the radial encodings, once, off the training loop
for ex, enc in zip(tr + va, p.map(_radial, [(e["polys"], e["size"], a.rays) for e in tr + va], chunksize=16)):
ex["radial"] = enc
train += [("seg", ex) for ex in tr]
val["seg"] = va
random.Random(a.seed).shuffle(train)
if a.max_train:
train = train[:a.max_train]
counts = {n: sum(t == n for t, _ in train) for n in tasks}
if a.n_box_photo:
counts["box_photo"] = sum(t == "box" and ex.get("source") == "refcoco" for t, ex in train)
print(f"data: {len(train)} train {counts}, val {({n: len(v) for n, v in val.items()})} in {time.time() - t0:.0f}s",
flush=True)
model = AutoModelForImageTextToText.from_pretrained(a.model, dtype=torch.bfloat16, device_map={"": 0},
attn_implementation="sdpa")
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.enable_input_require_grads()
if a.init:
model = PeftModel.from_pretrained(model, a.init, is_trainable=True)
else:
model = get_peft_model(model, LoraConfig(r=a.r, lora_alpha=2 * a.r, lora_dropout=0.05, task_type="CAUSAL_LM",
target_modules=LORA_TARGETS))
model.print_trainable_parameters()
params = [p for p in model.parameters() if p.requires_grad]
opt = torch.optim.AdamW(params, lr=a.lr, weight_decay=0.0)
steps = math.ceil(len(train) / a.accum)
sched = torch.optim.lr_scheduler.LambdaLR( # 30 warm-up steps, then cosine to zero
opt, lambda s: min(1, (s + 1) / 30) * 0.5 * (1 + math.cos(math.pi * min(s, steps) / steps)))
wb = None
if a.wandb and os.environ.get("WANDB_API_KEY"):
import wandb
wb = wandb.init(project=a.wandb, name=a.run_name or os.path.basename(a.out), dir=a.out,
config=dict(vars(a), bins=arm.bins, coarse=arm.coarse, tokens=arm.tokens, counts=counts))
elif a.wandb:
print("no WANDB_API_KEY: logging to stdout only", flush=True)
def evaluate(tag, step):
model.eval()
out = {}
with torch.no_grad():
for n, exs in val.items():
tn = "box" if n == "box_photo" else n
task, slots = tasks[tn], tasks[tn].layout(arm)
rows = []
for ex in exs:
enc, keep = encode(proc, task, ex, arm)
rows.append(task.score(task.decode(read(model, enc, keep, slots, labels[tn]), arm), ex))
out[n] = aggregate(n, rows)
model.train()
show = {"agent": ("step", "action", "point"), "box": ("iou", "iou75", "center_in"),
"box_photo": ("iou", "iou50", "iou75"), "seg": ("miou", "ciou", "rec50")}
print(f"EVAL {tag}: " + " | ".join(f"{n} " + " ".join(f"{k}={out[n][k]:.3f}" for k in show[n]
if out[n].get(k) is not None) for n in out), flush=True)
if wb:
wb.log({f"val/{n}/{k}": v for n, m in out.items() for k, v in m.items()}, step=step)
return out
evaluate("start", 0)
model.train()
t1, run, seen, norms, skipped = time.time(), {}, 0, [], 0
for n, ex in train:
task = tasks[n]
try:
enc, keep = encode(proc, task, ex, arm)
except (OSError, ValueError) as e: # an unreadable image: skip it rather than lose the run
skipped += 1
print(f"SKIP {n} example ({skipped} so far): {repr(e)[:160]}", flush=True)
enc = None
if enc is not None:
lp = read(model, enc, keep, task.layout(arm), labels[n])
tg = task.targets(ex, arm)
losses = {s: -(t.to(lp[s].device) * lp[s]).sum() for s, t in tg.items()}
numbers = {task.number_of(s) for s in tg}
loss = sum(losses.values()) / len(numbers) # per number: coarse slots add, they do not dilute
(loss / a.accum).backward()
mass = sum(float(lp[s].detach().exp().sum()) for s in tg) / len(tg) # probability on the labels
run[f"loss_{n}"] = 0.98 * run.get(f"loss_{n}", float(loss.detach())) + 0.02 * float(loss.detach())
run[f"mass_{n}"] = 0.98 * run.get(f"mass_{n}", mass) + 0.02 * mass
seen += 1
if seen % a.accum == 0 or seen == len(train):
gn = float(torch.nn.utils.clip_grad_norm_(params, a.clip))
norms.append(gn)
run["grad_norm"] = 0.9 * run.get("grad_norm", gn) + 0.1 * gn # before clipping
opt.step(); sched.step(); opt.zero_grad(set_to_none=True)
if seen % 50 == 0:
el = time.time() - t1
print(f"[{seen}/{len(train)}] {el / seen:.2f}s/ex eta {el / seen * (len(train) - seen) / 60:.0f}min | "
+ " ".join(f"{k}={v:.3f}" for k, v in sorted(run.items())), flush=True)
if wb:
wb.log({**{f"train/{k}": v for k, v in run.items()}, "train/sec_per_example": el / seen,
"train/lr": sched.get_last_lr()[0]}, step=seen)
if seen % a.eval_every == 0 and seen < len(train):
evaluate(f"after {seen}", seen)
model.save_pretrained(f"{a.out}/adapter-{seen}")
model.save_pretrained(f"{a.out}/adapter")
print(f"skipped {skipped} unreadable examples", flush=True)
if norms:
json.dump(norms, open(f"{a.out}/grad_norms.json", "w"))
evaluate("end", seen)
if wb:
wb.finish()
print("TRAIN_DONE", flush=True)
if __name__ == "__main__":
main()