"""Continued pretraining (mode: cpt) and MCQ fine-tuning (mode: sft) of the submission model. python -m train.train --config configs/train/sft_gmmlu.yaml [--max-steps N] [--out models/x] cpt documents from /*.parquet are shuffled, tokenised once ( text ) into a uint32 token file and cut into fixed seq_len sequences (fixed shapes keep torch.compile to one graph). sft each MCQ is rendered with the *inference* prompt (eval/prompts.py: same template, chat wrapping) and the loss is the cross-entropy of the answer digit at the last prompt position, which is exactly what logit scoring reads. Options can be re-shuffled every epoch (except options that refer to other options) to avoid position priors. Speed/memory options come from train/efficiency.py (measured in reports/throughput.jsonl). The optimizer is torchao AdamW8bit with bf16 stochastic rounding (weights stay bf16; small updates are not rounded away). Checkpoints are complete, loadable model directories (weights, tokenizer, chat template, SOURCE.json, TRAINING.json). """ from __future__ import annotations import argparse import json import math import random import shutil import sys import time from pathlib import Path import numpy as np import yaml sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from eval.common import ROOT, cap_gpu_memory, env_fingerprint, load_env, read_jsonl, set_seed # noqa: E402 # ---------------------------------------------------------------- data def prepare_cpt_tokens(cfg: dict, tok) -> np.memmap: import pandas as pd data_dir = ROOT / cfg["data_dir"] suffix = f"_limit{cfg['limit_docs']}" if cfg.get("limit_docs") else "" path = data_dir / f"tokens_{Path(cfg['model']).name}{suffix}.u32" if not path.exists(): docs = pd.concat([pd.read_parquet(f, columns=["text"]) for f in sorted(data_dir.glob("*.parquet"))], ignore_index=True)["text"].tolist() random.Random(cfg["seed"]).shuffle(docs) docs = docs[: cfg["limit_docs"]] if cfg.get("limit_docs") else docs tmp = path.with_suffix(".tmp") n = 0 with open(tmp, "wb") as f: for i in range(0, len(docs), 2000): for ids in tok(docs[i:i + 2000], add_special_tokens=False)["input_ids"]: arr = np.array([tok.bos_token_id] + ids + [tok.eos_token_id], dtype=np.uint32) f.write(arr.tobytes()) n += len(arr) print(f"tokenised {min(i + 2000, len(docs)):,}/{len(docs):,} docs, {n / 1e6:.1f}M tokens", flush=True) tmp.rename(path) return np.memmap(path, dtype=np.uint32, mode="r") def cpt_batches(tokens: np.memmap, seq_len: int, micro_batch: int): import torch n_seq = len(tokens) // seq_len i = 0 while True: # cycles if the run asks for more tokens than the file holds (epoch > 1) rows = [(i + k) % n_seq for k in range(micro_batch)] i += micro_batch x = np.stack([tokens[r * seq_len:(r + 1) * seq_len] for r in rows]).astype(np.int64) yield {"input_ids": torch.from_numpy(x)} def sft_examples(cfg: dict, tok, epoch: int) -> list[dict]: from eval.prompts import PromptBuilder from scripts.build_sft_gmmlu import REFERS_TO_OTHERS pb = PromptBuilder(tok, lang=cfg.get("lang", "en"), chat=cfg.get("chat", True)) rng = random.Random(f"{cfg['seed']}|{epoch}") label_ids = {} out = [] for f in cfg["data"]: for r in read_jsonl(ROOT / f): opts, ans = list(r["options"]), r["answer"] tprobs = r.get("teacher_probs") # teacher's option distribution, in the stored option order order = list(range(len(opts))) if cfg.get("permute_options", True) and not any(REFERS_TO_OTHERS.search(o) for o in opts): rng.shuffle(order) opts, ans = [opts[j] for j in order], order.index(ans - 1) + 1 tprobs = [tprobs[j] for j in order] if tprobs else None ids = tok(pb.mcq(r, opts), add_special_tokens=pb.add_special_tokens)["input_ids"] n = len(opts) if n not in label_ids: label_ids[n] = [tok.encode(str(k), add_special_tokens=False) for k in range(1, n + 1)] assert all(len(t) == 1 for t in label_ids[n]), "answer labels must be single tokens" out.append({"ids": ids, "target": label_ids[n][ans - 1][0], "labels": [t[0] for t in label_ids[n]], "tprobs": tprobs}) rng.shuffle(out) return out def trace_examples(cfg: dict, tok) -> list[dict]: """Prompt (thinking-mode chat prompt, as at inference) + the model's own correct reasoning trace; loss on the trace.""" from eval.prompts import PromptBuilder pb = PromptBuilder(tok, lang=cfg.get("lang", "en"), chat=True, chat_kwargs={"enable_thinking": True}) out = [] for f in cfg["data"]: for r in read_jsonl(ROOT / f): p_ids = tok(pb.mcq(r, r["options"]), add_special_tokens=False)["input_ids"] c_ids = tok(r["completion"], add_special_tokens=False)["input_ids"] if len(p_ids) + len(c_ids) > cfg.get("max_len", 4096): continue out.append({"ids": p_ids + c_ids, "n_prompt": len(p_ids)}) random.Random(cfg["seed"]).shuffle(out) return out def trace_steps(examples: list[dict], batch_size: int, micro_tokens: int, pad_id: int): import torch for s in range(0, len(examples) - batch_size + 1, batch_size): chunk = sorted(examples[s:s + batch_size], key=lambda e: len(e["ids"])) micros, cur = [], [] for e in chunk: if cur and max(len(x["ids"]) for x in cur + [e]) * (len(cur) + 1) > micro_tokens: micros.append(cur) cur = [] cur.append(e) micros.append(cur) batches = [] for m in micros: L = max(len(e["ids"]) for e in m) ids = torch.full((len(m), L), pad_id, dtype=torch.long) mask = torch.zeros((len(m), L), dtype=torch.long) labels = torch.full((len(m), L), -100, dtype=torch.long) for i, e in enumerate(m): n = len(e["ids"]) ids[i, :n] = torch.tensor(e["ids"]) mask[i, :n] = 1 labels[i, e["n_prompt"]:n] = ids[i, e["n_prompt"]:n] batches.append({"input_ids": ids, "attention_mask": mask, "labels": labels}) yield batches def sft_steps(examples: list[dict], batch_size: int, micro_tokens: int, pad_id: int): """Yield optimizer steps; each is a list of right-padded micro-batches (token budget), batch_size examples.""" import torch for s in range(0, len(examples) - batch_size + 1, batch_size): chunk = sorted(examples[s:s + batch_size], key=lambda e: len(e["ids"])) micros, cur = [], [] for e in chunk: if cur and max(len(x["ids"]) for x in cur + [e]) * (len(cur) + 1) > micro_tokens: micros.append(cur) cur = [] cur.append(e) micros.append(cur) batches = [] for m in micros: L = max(len(e["ids"]) for e in m) ids = torch.full((len(m), L), pad_id, dtype=torch.long) mask = torch.zeros((len(m), L), dtype=torch.long) for i, e in enumerate(m): ids[i, : len(e["ids"])] = torch.tensor(e["ids"]) mask[i, : len(e["ids"])] = 1 b = {"input_ids": ids, "attention_mask": mask, "last": torch.tensor([len(e["ids"]) - 1 for e in m]), "target": torch.tensor([e["target"] for e in m])} if all(e["tprobs"] is not None for e in m) and len({len(e["labels"]) for e in m}) == 1: b["labels"] = torch.tensor([e["labels"] for e in m]) b["tprobs"] = torch.tensor([e["tprobs"] for e in m], dtype=torch.float32) batches.append(b) yield batches def sft_loss(model, batch, batch_size: int, kd_alpha: float = 0.0): """Cross-entropy of the answer digit; with kd_alpha > 0 and teacher probabilities present, mixed with the KL divergence from the teacher's distribution over the option labels (knowledge distillation).""" import torch import torch.nn.functional as F from train.efficiency import lm_parts body, head, softcap = lm_parts(model) h = body(input_ids=batch["input_ids"], attention_mask=batch["attention_mask"]).last_hidden_state h = h[torch.arange(h.shape[0], device=h.device), batch["last"]] logits = head(h).float() if softcap: logits = torch.tanh(logits / softcap) * softcap ce = F.cross_entropy(logits, batch["target"], reduction="none") if kd_alpha > 0 and "tprobs" in batch: student = torch.log_softmax(logits.gather(1, batch["labels"]), dim=-1) teacher = batch["tprobs"].clamp_min(1e-8) teacher = teacher / teacher.sum(-1, keepdim=True) kl = (teacher * (teacher.log() - student)).sum(-1) return ((1 - kd_alpha) * ce + kd_alpha * kl).sum() / batch_size return ce.sum() / batch_size # ---------------------------------------------------------------- model / saving def load(cfg: dict): import torch from transformers import AutoModelForCausalLM, AutoTokenizer from train.efficiency import configure path = str(ROOT / cfg["model"]) if (ROOT / cfg["model"]).exists() else cfg["model"] model = AutoModelForCausalLM.from_pretrained(path, dtype=torch.bfloat16, device_map="cuda") model.config.use_cache = False if cfg.get("lora"): # adapters on all linear layers; merged into the base weights when saved from peft import LoraConfig, get_peft_model lc = cfg["lora"] model = get_peft_model(model, LoraConfig(r=lc["r"], lora_alpha=lc.get("alpha", 2 * lc["r"]), target_modules=lc.get("targets", "all-linear"), lora_dropout=lc.get("dropout", 0.0), task_type="CAUSAL_LM")) elif cfg.get("freeze_embeddings", True): for name, p in model.named_parameters(): if "embed_tokens" in name: # incl. Gemma per-layer embedding tables; lm_head is tied to embed_tokens p.requires_grad_(False) configure(model, cfg.get("ckpt_every", 1), cfg.get("compile", False)) if cfg.get("lora") and cfg.get("ckpt_every", 1) > 0: model.enable_input_require_grads() model.train() tok = AutoTokenizer.from_pretrained(path) return model, tok, Path(path) def save(model, tok, src: Path, out: Path, info: dict) -> None: out.mkdir(parents=True, exist_ok=True) if hasattr(model, "merge_and_unload"): # LoRA: keep the adapter, then save plain merged weights too model.save_pretrained(out / "adapter") model = model.merge_and_unload() model.config.use_cache = True model.save_pretrained(out, safe_serialization=True) model.config.use_cache = False tok.save_pretrained(out) for f in src.glob("chat_template.*"): shutil.copy(f, out / f.name) source = json.loads((src / "SOURCE.json").read_text()) if (src / "SOURCE.json").exists() else {} source["model_id"] = f"{info['run']}/{out.name}@{source.get('source_commit', 'unknown')[:10]}" source.setdefault("derived_from", []).append(str(src.relative_to(ROOT)) if src.is_relative_to(ROOT) else str(src)) (out / "SOURCE.json").write_text(json.dumps(source, indent=2)) (out / "TRAINING.json").write_text(json.dumps(info, indent=2, default=str)) print(f"saved {out}", flush=True) def lr_at(step: int, total: int, cfg: dict) -> float: warm, peak, floor = cfg["warmup_steps"], cfg["lr"], cfg["lr"] * cfg.get("min_lr_ratio", 0.1) if step < warm: return peak * (step + 1) / warm t = (step - warm) / max(total - warm, 1) return floor + 0.5 * (peak - floor) * (1 + math.cos(math.pi * min(t, 1.0))) # ---------------------------------------------------------------- main def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--config", required=True) ap.add_argument("--out", help="override out_dir") ap.add_argument("--max-steps", type=int, help="stop early (smoke tests)") ap.add_argument("--limit-docs", type=int, help="cpt: tokenise only N documents (smoke tests)") ap.add_argument("--data", nargs="+", help="sft: override the training files") args = ap.parse_args() cfg = yaml.safe_load(open(args.config)) cfg["out_dir"] = args.out or cfg["out_dir"] cfg["limit_docs"] = args.limit_docs or cfg.get("limit_docs") if args.data: cfg["data"] = args.data load_env() set_seed(cfg["seed"]) cap_gpu_memory(cfg.get("gpu_mem_gb")) import torch from torchao.optim import AdamW8bit from train.efficiency import lm_loss out = ROOT / cfg["out_dir"] out.mkdir(parents=True, exist_ok=True) (out / "config.yaml").write_text(yaml.safe_dump(cfg)) model, tok, src = load(cfg) trainable = [p for p in model.parameters() if p.requires_grad] opt = AdamW8bit(trainable, lr=cfg["lr"], betas=(0.9, 0.95), weight_decay=cfg.get("weight_decay", 0.0), bf16_stochastic_round=True) if cfg["mode"] == "cpt": tokens = prepare_cpt_tokens(cfg, tok) per_step = cfg["seq_len"] * cfg["micro_batch"] * cfg["grad_accum"] total = int(min(cfg["max_tokens"], len(tokens) * cfg.get("epochs", 1)) // per_step) it = cpt_batches(tokens, cfg["seq_len"], cfg["micro_batch"]) steps = ([next(it) for _ in range(cfg["grad_accum"])] for _ in range(total)) save_every = max(1, int(cfg.get("save_every_tokens", 1e18) // per_step)) elif cfg["mode"] == "trace": ex = trace_examples(cfg, tok) per_epoch = len(ex) // cfg["batch_size"] total = per_epoch * cfg["epochs"] steps = (s for _ in range(cfg["epochs"]) for s in trace_steps(ex, cfg["batch_size"], cfg["micro_tokens"], tok.pad_token_id)) save_every = per_epoch print(f"trace examples: {len(ex)}", flush=True) else: n_ex = len(sft_examples(cfg, tok, 0)) per_epoch = n_ex // cfg["batch_size"] total = per_epoch * cfg["epochs"] steps = (s for e in range(cfg["epochs"]) for s in sft_steps(sft_examples(cfg, tok, e), cfg["batch_size"], cfg["micro_tokens"], tok.pad_token_id)) save_every = per_epoch # end of every epoch if args.max_steps: total = min(total, args.max_steps) if cfg.get("lora"): save_every = total print(f"{cfg['mode']}: {total} optimizer steps, " f"{sum(p.numel() for p in trainable) / 1e9:.2f}B trainable params", flush=True) info = {"run": Path(cfg["out_dir"]).name, "config": cfg, "env": env_fingerprint(), "tokens_seen": 0} log = open(out / "log.jsonl", "a") t0, tokens_seen = time.time(), 0 for step, micro_batches in enumerate(steps): if step >= total: break for g in opt.param_groups: # torchao keeps lr as a tensor: update in place lr = lr_at(step, total, cfg) g["lr"].fill_(lr) if torch.is_tensor(g["lr"]) else g.__setitem__("lr", lr) loss_sum = 0.0 for b in micro_batches: b = {k: v.cuda(non_blocking=True) for k, v in b.items()} if cfg["mode"] == "cpt": loss = lm_loss(model, b["input_ids"], b["input_ids"], cfg.get("loss", "liger")) / len(micro_batches) tokens_seen += b["input_ids"].numel() elif cfg["mode"] == "trace": loss = lm_loss(model, b["input_ids"], b["labels"], cfg.get("loss", "liger"), b["attention_mask"]) / len(micro_batches) tokens_seen += int(b["attention_mask"].sum()) else: loss = sft_loss(model, b, cfg["batch_size"], cfg.get("kd_alpha", 0.0)) tokens_seen += int(b["attention_mask"].sum()) loss.backward() loss_sum += loss.item() gnorm = torch.nn.utils.clip_grad_norm_(trainable, cfg.get("max_grad_norm", 1.0)).item() opt.step() opt.zero_grad(set_to_none=True) if step % cfg.get("log_every", 10) == 0 or step == total - 1: el = time.time() - t0 rec = {"step": step, "loss": round(loss_sum, 4), "lr": float(opt.param_groups[0]["lr"]), "grad_norm": round(gnorm, 3), "tokens": tokens_seen, "tok_per_s": round(tokens_seen / el, 1), "mem_gb": round(torch.cuda.max_memory_allocated() / 1024**3, 1), "elapsed_min": round(el / 60, 1)} log.write(json.dumps(rec) + "\n") log.flush() print(rec, flush=True) if (step + 1) % save_every == 0 or step == total - 1: info.update(step=step + 1, total_steps=total, tokens_seen=tokens_seen) ckpt = out / f"step-{step + 1}" save(model, tok, src, ckpt, info) keep = cfg.get("keep_checkpoints", 3) old = sorted(out.glob("step-*"), key=lambda p: int(p.name.split("-")[1]))[:-keep] for p in old: shutil.rmtree(p) print(f"done: {tokens_seen / 1e6:.1f}M tokens in {(time.time() - t0) / 60:.1f} min", flush=True) if __name__ == "__main__": main()