"""Train ONLY the merge adapter at the L13->L14 boundary (WORKSPACE_LOOPING.md ยง3). Frozen base model; band L14-30 unrolled k times through the adapter; loss = cross-entropy on answer tokens of the *direct* (no-CoT) prompt, supervised by gold answers on items the frozen model can reach (STaR filter from prep_star_data.py). Difficulty->depth curriculum: k=1: easy items k=2: easy+hard k=4: hard items so hard items are only ever seen at depth, forcing the loop to be used. """ import json import random import time from pathlib import Path import torch import torch.nn.functional as F from loop_common import BandLooper, MergeAdapter, build_train_batch import sys sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from jlens.core import load_model # noqa: E402 OUT = Path(__file__).resolve().parent.parent / "results-loop" STEPS = 800 BATCH = 8 LR = 1e-3 WARMUP = 20 MAX_TOK = 400 # skip overlong items K_BUCKETS = [(1, ("easy",)), (2, ("easy", "hard")), (4, ("hard",))] SEED = 0 def lr_at(step): if step < WARMUP: return LR * (step + 1) / WARMUP import math t = (step - WARMUP) / max(1, STEPS - WARMUP) return 1e-4 + 0.5 * (LR - 1e-4) * (1 + math.cos(math.pi * t)) @torch.no_grad() def val_answer_acc(looper, adapter, tok, items, k): """Teacher-forced exact match: argmax at every answer position.""" ok = 0 for i in range(0, len(items), BATCH): ids, msk, lab = build_train_batch(tok, items[i : i + BATCH]) logits = looper.loop_logits(adapter, ids, k, attention_mask=msk) pred = logits[:, :-1].argmax(-1) tgt = lab[:, 1:] m = tgt != -100 ok += (((pred == tgt) | ~m).all(-1)).sum().item() return ok / len(items) def main(): rng = random.Random(SEED) torch.manual_seed(SEED) data = json.load(open(OUT / "star_data.json")) model, tok = load_model(dtype=torch.bfloat16) for p in model.parameters(): p.requires_grad_(False) looper = BandLooper(model) adapter = MergeAdapter().cuda() opt = torch.optim.AdamW(adapter.parameters(), lr=LR, weight_decay=0.01) train = [it for it in data if it["split"] == "train" and it["label"] != "drop"] # length filter keep = [] for it in train: n = len(tok(it["question"])["input_ids"]) if n + 40 <= MAX_TOK: keep.append(it) train = keep pool = {"easy": [it for it in train if it["label"] == "easy"], "hard": [it for it in train if it["label"] == "hard"]} val = {lbl: rng.sample(lst, min(32, len(lst))) for lbl, lst in pool.items()} for lbl in pool: pool[lbl] = [it for it in pool[lbl] if it not in val[lbl]] print(f"train pool: easy={len(pool['easy'])} hard={len(pool['hard'])}", flush=True) log = [] t0 = time.time() for step in range(STEPS): k, labels = K_BUCKETS[step % len(K_BUCKETS)] cand = [it for lbl in labels for it in pool[lbl]] batch = rng.sample(cand, BATCH) ids, msk, lab = build_train_batch(tok, batch) for g in opt.param_groups: g["lr"] = lr_at(step) logits = looper.loop_logits(adapter, ids, k, attention_mask=msk, use_checkpoint=True) loss = F.cross_entropy( logits[:, :-1].flatten(0, 1).float(), lab[:, 1:].flatten(), ignore_index=-100) opt.zero_grad(set_to_none=True) loss.backward() torch.nn.utils.clip_grad_norm_(adapter.parameters(), 1.0) opt.step() log.append({"step": step, "k": k, "loss": loss.item()}) if step % 10 == 0: print(f"step {step:4d} k={k} loss={loss.item():.4f} " f"({(time.time()-t0)/(step+1):.1f}s/step)", flush=True) if step % 100 == 99 or step == STEPS - 1: accs = {} for kk in (0, 1, 2, 4): accs[f"easy_k{kk}"] = val_answer_acc(looper, adapter, tok, val["easy"], kk) accs[f"hard_k{kk}"] = val_answer_acc(looper, adapter, tok, val["hard"], kk) print(f" val@{step}: " + " ".join(f"{n}={v:.2f}" for n, v in accs.items()), flush=True) log.append({"step": step, "val": accs}) torch.save(adapter.state_dict(), OUT / "adapter.pt") json.dump(log, open(OUT / "train_log.json", "w"), indent=1) torch.save(adapter.state_dict(), OUT / "adapter.pt") json.dump(log, open(OUT / "train_log.json", "w"), indent=1) print("done; adapter ->", OUT / "adapter.pt") if __name__ == "__main__": main()