Download train.py from InfinimindCreations/laya-rlcd-training: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/InfinimindCreations/laya-rlcd-training/resolve/main/train.py
- Command line
-
hf download hf://InfinimindCreations/laya-rlcd-training/train.py
-
curl -L -o train.py https://huggingface.co/InfinimindCreations/laya-rlcd-training/resolve/main/train.py
16.9 kB
| """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()} | |
| 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()) | |