"""RLCD fine-tuning loop for Laya System One models. The published Laya repository ships the building blocks but no training loop (checked 2026-09-20: no `backward`, no optimizer in any of its .py files). This loop implements the method described in the Laya README: "The policy reports a distribution; exploration adds zero-mean Gaussian noise to the logits; the reward is a strictly proper scoring rule (log + spherical, plus ranked probability score for ordinal questions). Expected reward is maximised only by reporting honest probabilities. Updates are REINFORCE with a group-mean baseline (GRPO-style)." The gradient: the policy is a Gaussian with mean = logits(theta) and fixed sigma. For a sample a = logits.detach() + eps, grad_theta log pi(a) = (eps / sigma^2) * grad_theta logits, hence the term (eps.detach() * logits).sum() / sigma^2 in the loss. The baseline is the mean reward of the G samples of the SAME item (GRPO), not a learned critic. Usage: LAYA_SNAP=/path/to/laya/snapshot python train.py \ --data train.jsonl --gold gold.jsonl --questions questions.json \ --n 20000 --batch 16 --group 32 --stage 400 --save out/ """ import argparse, json, os, random, sys, time import numpy as np, torch LAYA_REPO = "convaiinnovations/laya" SNAP = os.environ.get("LAYA_SNAP") if not SNAP: # Fetch the base model on first use. Set LAYA_SNAP to point at a local copy # instead -- on an air-gapped machine, or to train from your own checkpoint. from huggingface_hub import snapshot_download print("fetching %s ..." % LAYA_REPO, flush=True) SNAP = snapshot_download(LAYA_REPO) sys.path.insert(0, SNAP) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from rl_common import (QTYPES, build_model, collate_items, encode_record, # noqa: E402 proper_reward, auroc, spearman, seed_all) import data as data_mod # noqa: E402 def load_model(variant="multilingual", device="cuda"): from safetensors.torch import load_file base = os.path.join(SNAP, variant) if variant else SNAP cfg = json.load(open(os.path.join(base, "rl_agent_config.json"))) m = build_model(cfg, encoder_dir=os.path.join(base, "encoder")) sd = load_file(os.path.join(base, "model.safetensors")) missing, _ = m.load_state_dict(sd, strict=False) if missing: print(" missing weights:", len(missing), missing[:3]) from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained(os.path.join(base, "tokenizer")) return m.to(device), tok, cfg def batches(recs, tok, cfg, rng, train, size): groups = [] for r in recs: g = encode_record(r, tok, cfg, rng, train) for it in g: # encode_record only knows q_index; carry the name qi = it.get("q_index") if qi is not None and qi < len(r["qs"]): it["question"] = r["qs"][qi]["name"] if g: groups.append(g) for i in range(0, len(groups), size): b = collate_items(groups[i:i + size], tok.pad_token_id) if b: yield b def to_device(b, dev): return {k: (v.to(dev) if torch.is_tensor(v) else v) for k, v in b.items()} @torch.no_grad() def evaluate(m, recs, tok, cfg, dev, size=8): """Score against the gold set, PER QUESTION. Averaging over the question type hides everything: on our news corpus the four ordinal questions ranged from 0.54 (urgency) to 0.74 (sentiment) even for the teacher model. Reporting a single "score" number tells you neither which question is lagging nor where the gold set needs more examples. """ m.eval() per = {} for b in batches(recs, tok, cfg, None, False, size): bd = to_device(b, dev) with torch.autocast("cuda", dtype=torch.bfloat16): logits, _ = m(bd["input_ids"], bd["attention_mask"], bd["marker_pos"], bd["marker_mask"], bd["qtype"]) pr = torch.softmax(logits.float(), -1) * bd["marker_mask"] pick = pr.argmax(-1).cpu().numpy() want = b["target"].argmax(-1).numpy() p1 = pr[:, 1].cpu().numpy() for i, meta in enumerate(b["meta"]): n = meta.get("question", "?") d = per.setdefault(n, {"t": int(b["qtype"][i]), "pick": [], "want": [], "p": []}) d["pick"].append(int(pick[i])); d["want"].append(int(want[i])) d["p"].append(float(p1[i])) m.train() out = {} for n, d in per.items(): w, z = np.array(d["pick"]), np.array(d["want"]) e = {"n": len(w), "acc": round(float(np.mean(w == z)), 4)} if d["t"] == QTYPES["score"]: e["pm1"] = round(float(np.mean(np.abs(w - z) <= 1)), 4) e["rho"] = round(spearman(w.astype(float), z.astype(float)), 4) if d["t"] == QTYPES["noul"] and len(set(z.tolist())) > 1: e["auc"] = round(auroc(np.array(d["p"]), z.astype(float)), 4) out[n] = e return out def train(a): dev = "cuda" if torch.cuda.is_available() else "cpu" seed_all(a.seed); rng = random.Random(a.seed) print("loading model (%s) ..." % a.variant, flush=True) m, tok, cfg = load_model(a.variant, dev) if a.chars: cfg["max_len"] = a.chars questions = data_mod.load_questions(a.questions) recs = data_mod.records(a.data, questions, max_chars=a.max_chars)[:a.n] gold = data_mod.records(a.gold, questions, max_chars=a.max_chars) gold_ids = {r["src"] for r in gold} recs = [r for r in recs if r["src"] not in gold_ids] # never train on gold print("training %d records | gold %d (disjoint)" % (len(recs), len(gold)), flush=True) # --init is NOT --resume. Resuming continues an interrupted run on its own # schedule; init starts a NEW run from trained weights, with a fresh optimizer # and a fresh learning-rate curve. Use it to add a question, or to retrain one # question on a rebalanced set without starting from the base model. # # This has to happen BEFORE the baseline measurement. Loading the weights # after it prints a "before" score for the untrained model -- which is not # the number you are comparing against, and the run looks like a bigger win # than it was. if a.init: st = torch.load(a.init, map_location=dev, weights_only=False) m.load_state_dict(st["model"] if "model" in st else st) print(" initialised from %s (fresh optimizer and schedule)" % a.init, flush=True) print("BEFORE:", json.dumps(evaluate(m, gold, tok, cfg, dev)), flush=True) if a.eval_only: return # Memory. A 12 GB consumer card cannot hold AdamW over 322M parameters: # params + grads + two moments is roughly 5.2 GB before activations, and a # full fine-tune OOMs at batch 4. --freeze is the way out on such a card; # on an 80 GB accelerator leave it at 0. m.head_checkpointing = True if hasattr(m.encoder, "gradient_checkpointing_enable"): m.encoder.gradient_checkpointing_enable() if a.freeze: # Freeze the lower encoder layers: they carry general language, the # task-specific behaviour sits on top. Saves gradients AND optimizer state. for name, par in m.encoder.named_parameters(): part = name.split(".") nr = int(part[1]) if len(part) > 1 and part[0] == "layers" and part[1].isdigit() else -1 if 0 <= nr < a.freeze or name.startswith("embeddings"): par.requires_grad = False free = sum(p.numel() for p in m.parameters() if p.requires_grad) print(" frozen layers 0..%d + embeddings | trainable %.0f M" % (a.freeze - 1, free / 1e6), flush=True) opt = torch.optim.AdamW([p for p in m.parameters() if p.requires_grad], lr=a.lr, weight_decay=0.01) # Learning-rate decay is not optional here. Without it REINFORCE keeps # jumping until the very last step: measured on a 1200-step run, one # question oscillated between 0.653 and 0.756 without settling, and the # final checkpoint scored 4.6 points below the best one. total = a.max_steps or (len(recs) // max(a.batch, 1)) * max(a.epochs, 1) plan = torch.optim.lr_scheduler.OneCycleLR( opt, max_lr=a.lr, total_steps=max(total, 2), pct_start=0.1, anneal_strategy="cos", div_factor=10, final_div_factor=100) print(" lr %.1e, 10%% warmup, then cosine decay over %d steps" % (a.lr, total), flush=True) step = 0; t0 = time.time(); rewards = []; best = (0.0, 0); last_push = 0.0 # RESUMING after an interruption. Weights alone are NOT enough: AdamW carries # two running moments per parameter, and OneCycleLR has to know where on its # curve it stopped. Without both it is a warm start with a fresh warmup cycle, # not a continuation -- the learning rate jumps back up and shakes the weights. src = a.resume or os.environ.get("LAYA_RESUME") if src and os.path.exists(src): st = torch.load(src, map_location=dev, weights_only=False) m.load_state_dict(st["model"]); opt.load_state_dict(st["optimizer"]) plan.load_state_dict(st["plan"]) step = st["step"]; best = tuple(st["best"]) print(" RESUMED at step %d (best so far %.4f at %d)" % (step, best[0], best[1]), flush=True) for _ in range(a.epochs): rng.shuffle(recs) for b in batches(recs, tok, cfg, rng, True, a.batch): b = to_device(b, dev) with torch.autocast("cuda", dtype=torch.bfloat16): logits, _ = m(b["input_ids"], b["attention_mask"], b["marker_pos"], b["marker_mask"], b["qtype"]) logits = logits.float() mask = b["marker_mask"] # G samples per item; noise only on the valid options eps = torch.randn((a.group,) + logits.shape, device=dev) * a.sigma eps = eps * mask q = torch.softmax(logits.detach()[None] + eps, -1) * mask q = q / q.sum(-1, keepdim=True).clamp_min(1e-9) r = proper_reward(q, b["target"], b["qtype"], mask) # [G, N] adv = r - r.mean(0, keepdim=True) # GRPO baseline adv = adv / adv.std(0, keepdim=True).clamp_min(1e-6) score = (eps.detach() * logits[None]).sum(-1) / (a.sigma ** 2) loss = -(adv.detach() * score).mean() opt.zero_grad(set_to_none=True) loss.backward() torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0) opt.step() if step + 1 < plan.total_steps: plan.step() rewards.append(float(r.mean())); step += 1 if a.stage and step % a.stage == 0: row = {"step": step, "seen": step * a.batch, "reward": round(float(np.mean(rewards[-a.stage:])), 4), "lr": round(plan.get_last_lr()[0], 8), "gold": evaluate(m, gold, tok, cfg, dev)} print(" STAGE " + json.dumps(row), flush=True) if a.history: with open(a.history, "a") as fh: fh.write(json.dumps(row) + "\n") mean = float(np.mean([v["acc"] for v in row["gold"].values()])) # Two different things, and conflating them costs you a run: # # checkpoint.pt is the LATEST state, written every stage. It is # what --resume reads, so an interruption costs one stage at most. # (Writing it only on improvement looks harmless and is not: a run # whose best score falls early would resume hours behind.) # # model-best.pt is the BEST state. REINFORCE oscillates, so the # final weights are a lottery ticket -- up to 6 points between # adjacent stages, and one of our runs ended 4.6 points below its # own best. That file is the one you deploy. improved = mean > best[0] if a.save and improved: best = (mean, step) if a.save: os.makedirs(a.save, exist_ok=True) torch.save({"model": m.state_dict(), "optimizer": opt.state_dict(), "plan": plan.state_dict(), "step": step, "best": list(best), "seen": step * a.batch}, os.path.join(a.save, "checkpoint.pt")) if a.save and improved: torch.save(m.state_dict(), os.path.join(a.save, "model-best.pt")) with open(os.path.join(a.save, "best.json"), "w") as fh: json.dump({"step": step, "gold_mean": round(mean, 4), "gold": row["gold"]}, fh, indent=1) print(" -> best so far (gold mean %.4f)" % mean, flush=True) # Push intermediate results: a multi-hour run must not lose # everything to a timeout. Throttled, because every upload # runs on the accelerator's clock. dest = os.environ.get("LAYA_PUSH") if dest and time.time() - last_push < a.push_every: dest = None if dest: last_push = time.time() try: from huggingface_hub import HfApi api = HfApi(); tk = os.environ.get("HF_TOKEN") api.create_repo(dest, private=True, exist_ok=True, token=tk) api.upload_folder(folder_path=a.save, repo_id=dest, token=tk) print(" -> pushed to %s (step %d)" % (dest, step), flush=True) except Exception as e: print(" !! push failed: %s" % e, flush=True) if step % a.report == 0: print(" step %5d reward %+.4f %.1f s/100" % (step, float(np.mean(rewards[-a.report:])), (time.time() - t0) / max(step, 1) * 100), flush=True) if a.max_steps and step >= a.max_steps: break if a.max_steps and step >= a.max_steps: break print("AFTER:", json.dumps(evaluate(m, gold, tok, cfg, dev)), flush=True) if a.save: os.makedirs(a.save, exist_ok=True) torch.save(m.state_dict(), os.path.join(a.save, "model-last.pt")) print("saved to %s (best at step %d, gold mean %.4f)" % (a.save, best[1], best[0])) def cli(): p = argparse.ArgumentParser() p.add_argument("--data", required=True, help="training records, one JSON object per line") p.add_argument("--gold", required=True, help="held-out records, same format") p.add_argument("--questions", required=True, help="question definitions (JSON)") p.add_argument("--n", type=int, default=20000, help="cap on training records") p.add_argument("--batch", type=int, default=16) p.add_argument("--group", type=int, default=32, help="G samples per item (GRPO baseline)") p.add_argument("--sigma", type=float, default=1.0, help="exploration noise on the logits") p.add_argument("--lr", type=float, default=1e-5) p.add_argument("--epochs", type=int, default=1) p.add_argument("--max-steps", type=int, default=0, dest="max_steps") p.add_argument("--stage", type=int, default=400, help="evaluate every N steps") p.add_argument("--report", type=int, default=200) p.add_argument("--variant", default="multilingual", help="subfolder of the Laya snapshot") p.add_argument("--chars", type=int, default=0, help="override the model's max_len (tokens)") p.add_argument("--max-chars", type=int, default=data_mod.MAX_CHARS, dest="max_chars", help="cut each text to this many characters before encoding") p.add_argument("--freeze", type=int, default=0, help="freeze the lowest N encoder layers") p.add_argument("--seed", type=int, default=0) p.add_argument("--save", default="", help="output directory") p.add_argument("--resume", default="", help="checkpoint.pt to continue an interrupted run") p.add_argument("--init", default="", help="weights to start a NEW run from " "(fresh optimizer and schedule)") p.add_argument("--history", default="", help="append per-stage metrics as JSONL") p.add_argument("--push-every", type=int, default=1800, dest="push_every", help="minimum seconds between hub pushes (LAYA_PUSH)") p.add_argument("--eval-only", action="store_true", dest="eval_only") return p.parse_args() if __name__ == "__main__": train(cli())