Train · the recipe, step by step
Train Laya for your own task. Then measure it honestly.
If your app asks a paid model the same kind of small judgment all day, or you have a pile of decisions people already made, you can turn them into a local model that makes those judgments on your own GPU with no fee per decision. This page is the recipe, for your task and your logs: what to log, how to split, the loss, the settings, the code, how to test it without fooling yourself, and how to serve it. One example runs through every step, a support-ticket router. Our own project appears only as the case study, in the notes marked What we saw.
Base model: Laya
and the laya library, by Convai Innovations (Apache-2.0, built on ModernBERT-large, about 421M
parameters). Our teacher was Jev (jev-1.13.0) by TypeSafe;
thank you to both. The code below is shortened from the scripts that trained our fine-tune, layatown-v1. We checked
that it compiles and ran its data, batching, loss and metric parts on a CPU; the shortened loop as printed has not
been rerun end to end on a GPU, so treat a first run as a test. Not affiliated with TypeSafe or Convai Innovations.
The fast way: paste this into your coding agent
Fill in the brackets and paste it into Claude Code or any coding agent. It reads this page, follows the recipe one step at a time with your data, and holds itself to the same honesty rules we did. The rest of the page is what the agent will follow, if you want to understand or do it yourself.
You are helping me train a local fine-tune of Laya (Convai Innovations, Apache-2.0) for my own judgment task, following the recipe published at https://jevtown.com/train. Path A: distil a "teacher" model I already use. Path B: train from my own labelled decisions (human choices or outcomes), with no teacher.
First read https://jevtown.com/train in full. Then read https://jevtown.com/evaluation (how the authors pre-registered, amended and reported their own evaluation) and https://jevtown.com/model (how they wrote up results and limits). Use those pages as the method only. The page's running example (a support-ticket router) and its "What we saw" notes (the authors' own project) are illustrations: my task, my data, my questions and my pass marks are my own.
My setup:
- My task: [what the model decides, and where in my product the answer is used]
- Path: [A: teacher = model name and version, called for (what my app asks it) | B: my own labels = who or what labelled them]
- Questions, each with fixed answers: [choice: the options | noul (yes/no): the question]
- Logs: [path], about [N] requests
- The unit that groups correlated rows: [day / customer / session / user]
- Hardware: [GPU and VRAM], Python 3.12, uv
- What the model must do well in my product: [the behaviours that matter]
Work through the recipe one step at a time. After each step, show me what you did and the numbers, and wait for my go-ahead.
0. Terms: for path A, ask me to confirm that the teacher's terms allow training on its outputs; for both paths, that I may use the people's data in the logs for this. Do not continue without that confirmation.
1. Audit the logs against the JSONL format on /train: full probabilities (or exact labels for path B), exact inputs, one subject per question, subjects named rather than addressed by list position, provenance per row. Report what is missing or rebuilt, with counts. Never invent, backfill or guess labels.
2. Split by whole groups, never by row. Hold out one whole real deployment period (for example the most recent full week) as the test set and do not open it. Remove from train any request that also appears in validation or test. Compute the floors with the same code the evaluator will use.
3. Build the sequences with the laya library (512 tokens, 256 for the question head). Count and drop items whose options do not fit.
4. Before training, write EVALUATION.md: systems, metrics with exact formulas, pass marks, cluster bootstrap intervals, the behaviours my product depends on (each with a tolerance), checks that do not come from the teacher, a whole-system comparison, verdict rules and threats to validity. Commit it. After that, change it only by dated amendments appended at the end.
5. Train with the recipe's settings: KL to the soft labels (one-hot labels for path B), AdamW, lr 3e-5 encoder and 1e-4 head, 2% warm-up then linear decay to 5%, about 12,288 padded tokens per length-bucketed batch, one epoch, bf16, gradient checkpointing, clipping 1.0, a fixed seed. No early stopping, no choosing checkpoints. Record the checkpoint's SHA-256 before any scoring.
6. Fit one temperature per question type on validation only.
7. Open the test set once and evaluate at the three levels: fidelity to the teacher or labels (with the floor and the untouched base model), checks that do not come from the teacher, and my whole system running on the student (for example a shadow deployment beside what runs today).
8. Serve it with the same state slicing as training, an exact cache, bearer auth, and a failure mode that skips a decision (falls back to what my product does today) instead of inventing one.
Honesty rules. These override any wish to show a good result:
- Never look at, tune on, or select anything with the test set. If it was looked at before the plan, say so in the plan.
- Never change a pass mark or a metric after seeing a number. Add a dated amendment that says what changed and why.
- Report every failed criterion as prominently as the passes, with its number and interval.
- Say "not measured" rather than estimating, and label every estimate as one.
- Do not route a request type to the student until it has been measured on that type.
- Do not make paid calls to the teacher, or anything that costs money, without asking me first.
At the end, give me a short report: what passed, what failed, the numbers with intervals, what it cost in time and money, and what the model must not be used for.
Plain text, no links to follow except these pages. Works with any agent that can read a web page and run code on your machine.
Why train your own?
Lots of apps ask a paid model small judgments all day: which team gets this ticket, is this message spam or abuse, is this lead worth a call, does this post need a human to look at it, what does a character in your game do next, which model should answer this request. Every call costs money, sends your users' data to someone else and waits on the network. A small local model that gives the same judgments has no fee per decision, keeps the data on your machine and answers in milliseconds.
Path A: you already use a model
- log its answers (the full probabilities) and distil them into Laya
- the copy inherits the teacher's strengths and its mistakes
Path B: you have no teacher
- train from your own labelled decisions: people's choices (which team your agents actually sent the ticket to), or what actually worked (outcomes: was it resolved without being passed on)
- the same pipeline with one-hot (or outcome-weighted) targets instead of probabilities
Check your teacher's terms before you train on its outputs. Many model providers forbid using their outputs to train another model. TypeSafe's terms permit it for Jev; the project's owner checked on 21 September 2026 before we trained. Your teacher's may not. The same goes for the people in your logs: your customers, users or staff. Train only on data you are allowed to use for this.
Is this for you?
It fits if
- your app asks typed questions with fixed answers: pick one of these options, or yes/no. From a model that gives a probability for each (a System One model such as Jev, or a classifier), or from people who decide
- you have, or can start collecting, thousands of logged answers with the exact inputs
- you want the same judgments locally: no fee per decision, no network, your hardware
It does not, if
- the teacher writes text (replies, summaries). Laya scores options; it does not generate
- you want it to be right rather than to copy its teacher: a copy inherits the teacher's mistakes
- you need it on tasks you did not log. A copy learns what you trained it on and little else
The example: a support-ticket router
Every step below uses one task. A helpdesk gets tickets all day, and for each one the app asks three questions:
- State
- The ticket's fields: the customer's plan, the channel, the text, and a short customer history (how long they have been a customer, earlier tickets).
- Which team?
- A
choice: billing, technical, sales or account, each with its rule in words. - Is it urgent?
- A
noul(yes/no): is the customer blocked or losing money right now? - Is the customer angry?
- A
noul: it decides whether a senior agent answers first.
Swap in your own: a spam or abuse flag on each message, a score for each sales lead, triage for posts waiting for review, what an NPC in your game does next, or which of your models should answer a request. The recipe does not change; only the state, the questions and the options do.
Before you start
- GPU
- An NVIDIA card with 12 GB, such as the RTX 3080 Ti we used. bf16 and gradient checkpointing make it fit.
- Python and tools
- Python 3.12 and uv. PyTorch with CUDA, Transformers, FastAPI.
- Base model
convaiinnovations/layaon Hugging Face, Apache-2.0. We pinned revision1c5edc17.- Library
- The public
layapackage on PyPI, by Convai Innovations: sequence format, the option-scoring head, batching. Our lockfile pins 0.3.4. - Data
- Your teacher's logged answers (path A) or your own labelled decisions (path B), in the format of step 1.
- Time
- An afternoon to set up. Training time grows with the number of questions: about 77 minutes for 150,000 on our card.
uv init my-laya && cd my-laya
uv python pin 3.12
uv add "laya==0.3.4" torch transformers safetensors huggingface_hub numpy fastapi uvicorn
# the base checkpoint downloads on first use (the whole repository snapshot, about 2.3 GB), pinned in load.pyFive files make the whole pipeline: load.py, data.py, train.py,
evaluate.py and serve.py. Each is printed in full below, where its step needs it.
Log the answers
Do this from the first day, before you know you will train. Everything later depends on it.
- Log the full probabilities, not the top choice. A teacher that says billing 0.58, technical 0.34 is telling you the ticket is ambiguous. The student learns that uncertainty from the soft labels. Log them unrounded: rounding puts a floor under how close any copy can get. For path B, log the label exactly as it was decided, and who or what decided it.
- Log the exact input, byte for byte, next to the answer: the ticket as the teacher saw it, not as it looks after three edits. Rebuilding inputs later is a liability; logging is cheap.
- One subject per question. If your app asks about a batch of 100 tickets in one shared state, keep only that ticket's slice in each training example, because that is how the small model will be asked. Context moves a teacher, so slice in training exactly as you will slice when serving (step 8).
- Point at subjects by name, never by list position. Models cannot count. Asked about
tickets[37], a teacher may answer about a different ticket and you will never see it. Write`tickets.T-4812`, in backticks, in the question, and key the state by that id. - Record where each example came from: the group it belongs to (day, customer, session), the moment, the model version, whether the input is exact. The group is what makes step 2 possible.
- Handle what people typed with care. Ticket text is the input here, so train on it only if your privacy terms and your customers allow it, and strip names, emails and card numbers first. Whatever you leave out, the student never learns to read.
One line per request, in the shape the teacher was asked (state plus named questions), with the
answers next to it:
{
"id": "3f9c0a17e2b4d851",
"state": {
"queue": { "time": "Monday morning", "backlog": "high" },
"tickets": {
"T-4812": { "plan": "business", "channel": "email",
"text": "Charged twice and the export still fails. Third time I write.",
"customer": { "customer_for": "over two years",
"history": ["refund in March", "outage report in July"] } }
}
},
"questions": {
"team": { "type": "choice",
"instructions": "Which team should handle `tickets.T-4812`? Judge from its `text` and `plan`.",
"criteria": { "billing": "Charges, refunds, invoices. Comes first when money was taken wrongly.",
"technical": "Something in the product does not work.", "sales": "Upgrades, quotes, new seats.", "account": null } },
"urgent": { "type": "noul", "instructions": "Is `tickets.T-4812` urgent? Yes if the customer is blocked or losing money now." },
"angry": { "type": "noul", "instructions": "Is the customer who wrote `tickets.T-4812` angry?" }
},
"targets": {
"team": { "type": "choice",
"probabilities": { "billing": 0.58, "technical": 0.34, "sales": 0.02, "account": 0.06 } },
"urgent": { "type": "noul", "p": 0.71 },
"angry": { "type": "noul", "p": 0.83 }
},
"source": { "group": "2026-09-14", "moment": "T-4812", "model": "teacher-2.1.0", "inputs": "exact", "duplicates_merged": 1 }
}- questions / targets
- Same keys. A
choicetarget has a probability for exactly the options incriteria; anoul(yes/no) target hasp, the probability of yes. For path B, write the decided option as 1 and the rest as 0. - criteria
- Each option and its rule, in words.
nullmeans no description. Decision rules and boundary cases belong here ("Comes first when money was taken wrongly"). - source.group
- Whatever groups correlated rows: a day, a customer, a session. You split on it.
- source.moment
- What rows asked together share (here the ticket, whose three questions are correlated). Intervals resample whole moments.
- source.inputs
exact, or how the input was rebuilt. Drop rebuilt rows you cannot check.
Split by whole groups
Rows that share a day or a customer are near-copies of each other: the same outage floods one morning with the same ticket, and one customer writes the same complaint five times. Split by row and the test set leaks into training; split by whole days or whole customers instead. Group by day if you want to test on a future period (and say that returning customers appear on both sides); group by customer if a few customers write most of your tickets. Then hold out the thing you will actually deploy on.
- Test: one whole real deployment period, chosen before training, such as your most recent full week of tickets. Never use it for training, temperatures, checkpoint choice or settings.
- Validation: about 10% of the other groups, picked by a hash of the group id.
- Train: the rest, minus any request that also appears in validation or test.
- Floors, written down before training: the score of always answering the training average. A copy has to beat it by a wide margin to be worth anything. Compute it with the same code your evaluator uses.
# data.py: logged answers (JSONL) -> training items, split by whole groups
import hashlib
import json
from laya.common import QTYPES, build_sequence, render_options
from load import HEAD_MAX_LEN, MAX_LEN
def split_of(group: str, test_groups: set[str], val_share: float = 0.10) -> str:
"""Whole groups only (a day, a customer). The test groups are a real deployment you chose before training."""
if group in test_groups:
return "test"
h = int(hashlib.sha256(group.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
return "val" if h < val_share else "train"
def target_of(q: dict, t: dict) -> list[float]:
if q["type"] == "choice":
return [float(t["probabilities"][k]) for k in q["criteria"]] # in criteria order
if q["type"] == "noul":
return [1.0 - float(t["p"]), float(t["p"])] # Laya shows a yes/no as [false, true]
raise ValueError(f"unsupported question type {q['type']}")
def to_items(ex: dict, tok) -> tuple[list[dict], int]:
items, dropped = [], 0
for qid, q in ex["questions"].items():
iq = {"t": q["type"], "ins": q["instructions"], "crit": q.get("criteria")}
ids, markers = build_sequence(tok, ex["state"], iq, MAX_LEN, HEAD_MAX_LEN)
if len(markers) != len(render_options(iq)):
dropped += 1 # an option fell off the end: count it, never train or score on it
continue
t = target_of(q, ex["targets"][qid])
s = sum(t)
items.append({
"ids": ids, "markers": markers, "qtype": QTYPES[q["type"]],
"target": [v / s for v in t],
"group": ex["source"]["group"], "moment": ex["source"].get("moment"),
})
return items, dropped
def build(path: str, tok, test_groups: set[str]) -> dict[str, list[dict]]:
out, seen, dropped = {"train": [], "val": [], "test": []}, {"val": set(), "test": set()}, 0
rows = [json.loads(line) for line in open(path)]
key = lambda ex: hashlib.sha256(json.dumps([ex["state"], ex["questions"]], sort_keys=True).encode()).hexdigest()
for ex in rows: # first pass: what val and test contain
s = split_of(ex["source"]["group"], test_groups)
if s != "train":
seen[s].add(key(ex))
for ex in rows:
s = split_of(ex["source"]["group"], test_groups)
if s == "train" and (key(ex) in seen["val"] or key(ex) in seen["test"]):
continue # the same request in two splits is a leak: drop it from train
items, d = to_items(ex, tok)
out[s] += items
dropped += d
print({k: len(v) for k, v in out.items()}, "dropped (did not fit):", dropped)
return outThe sequence Laya reads
Laya is an encoder: it reads the question, the options and the state in one sequence and scores each option at a
[MASK] marker placed in front of it. The laya library builds the sequence for you
(build_sequence):
[CLS] choice question: <instructions> [SEP] [MASK] billing: <rule> [MASK] technical: <rule> [MASK] sales: <rule> [MASK] account [SEP] <ticket as JSON> [SEP]
└──────────────────────────────────── head: at most 256 tokens ───────────────────────────────────────┘ └── the rest of 512 ──┘- 512 tokens in all, 256 for the question head. Laya's default head is 192 tokens, which can cut long option rules short. Measure your longest question plus options and your longest state before you train.
- Each option is cut at 48 tokens. If an option's marker falls off the end, drop the item and count it:
never train or score on a question the model could not fully see.
data.pydoes this. - A yes/no question is always shown as two options,
[false, true]; its target is[1 − p, p]. - Long ticket text eats the state's share first. Keep the state to what the question needs: the fields, a short history in words, not the whole account.
# load.py: the base checkpoint, the sequence lengths, and loading
import json
import os
from huggingface_hub import snapshot_download
from laya.agent import _fix_tokenizer_config # a private helper in laya 0.3.4; it patches older tokenizer files
from laya.common import build_model
from safetensors.torch import load_file
from transformers import AutoTokenizer
BASE_REPO = "convaiinnovations/laya"
BASE_REVISION = "1c5edc17a7acd8701df6fc341c0d179f1c62c982" # the revision we trained from: pin yours
MAX_LEN = 512 # the whole sequence
HEAD_MAX_LEN = 256 # question + options (Laya's default, 192, can cut long option rules short)
def resolve(path: str) -> str:
return snapshot_download(BASE_REPO, revision=BASE_REVISION) if path == "base" else path
def load(path: str = "base", device: str = "cuda"):
d = resolve(path)
_fix_tokenizer_config(d)
tok = AutoTokenizer.from_pretrained(os.path.join(d, "tokenizer"))
with open(os.path.join(d, "rl_agent_config.json")) as f:
cfg = json.load(f)
model = build_model(cfg, encoder_dir=os.path.join(d, "encoder"))
model.load_state_dict(load_file(os.path.join(d, "model.safetensors")), strict=True)
return model.to(device), tok, cfgTrain on the soft labels
The loss is the KL divergence from the teacher's distribution to the student's, over each question's options: cross-entropy against the soft targets minus their own entropy, so 0 means a perfect copy. With path B's one-hot labels the same loss is plain cross-entropy. All question types train together (the team choice and both yes/no questions); Laya's head knows each question's type. If one question type is rare, repeat its items so it is not drowned out.
| Setting | Value | Why |
|---|---|---|
| Optimiser | AdamW, weight decay 0.01 | standard for fine-tuning an encoder |
| Learning rate | 3e-5 encoder, 1e-4 head | the head starts further from the task |
| Schedule | 2% warm-up, linear decay to 5% | fixed in advance |
| Batch | about 12,288 padded tokens, length-bucketed | similar lengths together, little padding |
| Length | one epoch | no early stopping, no picking a checkpoint |
| Precision and memory | bf16 autocast, gradient checkpointing, clipping at 1.0 | fits a 12 GB card |
| Seed | 7 | write it down |
# train.py: train Laya on the soft labels (or one-hot labels), one epoch, one GPU
import json
import os
import random
import shutil
import sys
import torch
from laya.common import collate_items
from safetensors.torch import save_file
from data import build
from evaluate import fit_temperature
from load import HEAD_MAX_LEN, MAX_LEN, load, resolve
def batches(items, max_tokens, rng):
"""Length-bucketed batches of about max_tokens padded tokens, in random order."""
order = sorted(range(len(items)), key=lambda i: (len(items[i]["ids"]), rng.random()))
out, cur, longest = [], [], 0
for i in order:
n = len(items[i]["ids"])
if cur and max(longest, n) * (len(cur) + 1) > max_tokens:
out.append(cur)
cur, longest = [], 0
cur.append(i)
longest = max(longest, n)
if cur:
out.append(cur)
rng.shuffle(out)
return out
def main(logs: str, out_dir: str, test_groups: set[str]):
torch.manual_seed(7)
model, tok, cfg = load("base")
data = build(logs, tok, test_groups)
items = data["train"] # repeat a rare question type a few times so it is not drowned out
model.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.train()
enc = [p for n, p in model.named_parameters() if n.startswith("encoder.")]
head = [p for n, p in model.named_parameters() if not n.startswith("encoder.")]
opt = torch.optim.AdamW([{"params": enc, "lr": 3e-5}, {"params": head, "lr": 1e-4}], weight_decay=0.01, fused=True)
plan = batches(items, 12288, random.Random(7)) # one epoch; fixed schedule, no early stopping
warmup = max(1, len(plan) // 50) # 2% warm-up, then linear decay to 5%
sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / warmup) * max(0.05, 1 - s / len(plan)))
for step, idx in enumerate(plan):
b = collate_items([[items[i] for i in idx]], tok.pad_token_id)
with torch.autocast("cuda", dtype=torch.bfloat16):
logits, act = model(b["input_ids"].cuda(), b["attention_mask"].cuda(),
b["marker_pos"].cuda(), b["marker_mask"].cuda(), b["qtype"].cuda())
mask, target = b["marker_mask"].cuda(), b["target"].cuda()
logp = torch.log_softmax(logits.float().masked_fill(~mask, -1e4), -1)
# KL(teacher || student): cross-entropy minus the target's own entropy, so 0 means a perfect copy.
# 0 * act keeps Laya's unused action head in the graph, as our script does.
loss = (target * (torch.log(target.clamp_min(1e-9)) - logp) * mask).sum(-1).mean() + 0.0 * act.sum()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
sched.step()
opt.zero_grad(set_to_none=True)
if step % 100 == 0:
print(f"step {step}/{len(plan)} KL {loss.item():.4f}", flush=True)
model.eval()
temps = fit_temperature(model, data["val"], tok.pad_token_id) # validation only, never test
os.makedirs(out_dir, exist_ok=True)
save_file({k: v.detach().to(torch.bfloat16).float().contiguous().cpu() for k, v in model.state_dict().items()},
os.path.join(out_dir, "model.safetensors"))
for sub in ("encoder", "tokenizer"):
shutil.copytree(os.path.join(resolve("base"), sub), os.path.join(out_dir, sub), dirs_exist_ok=True)
cfg.update({"model_name": "my-laya", "max_len": MAX_LEN, "head_max_len": HEAD_MAX_LEN,
"temperature": temps, "temperature_by_options": {}}) # drop the base model's own temperatures
with open(os.path.join(out_dir, "rl_agent_config.json"), "w") as f:
json.dump(cfg, f, indent=2)
print("saved", out_dir, "temperatures (choice, score, noul):", temps)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2], set(sys.argv[3].split(",")))# test_groups.txt: your held-out groups, one per line (for example the seven days of your test week)
uv run python train.py logs.jsonl checkpoints/v1 "$(paste -sd, test_groups.txt)"
sha256sum checkpoints/v1/model.safetensors # record this before you score anything (step 6)Validation numbers during training are for watching, never for choosing.
Calibrate on validation only
Fit one temperature per question type by minimising cross-entropy against the teacher on the validation split,
and store it with the checkpoint. train.py calls fit_temperature at the end. Never fit
anything on the test set. Calibration matters when your app acts on the probability, not only the top answer: page
a senior agent when p(angry) is over 0.7, and 0.7 has to mean the same thing for the copy as for the teacher.
# evaluate.py: predictions, temperatures (validation only), metrics with intervals over clusters
import numpy as np
import torch
from laya.common import collate_items
@torch.no_grad()
def predict(model, items, pad_id, temperature=None, batch_tokens=16384):
"""Probabilities per item, in input order, batched by length."""
model.eval()
order = sorted(range(len(items)), key=lambda i: len(items[i]["ids"]))
out, i = [None] * len(items), 0
while i < len(order):
longest = len(items[order[min(i + 255, len(order) - 1)]]["ids"])
idx = order[i : i + min(max(1, batch_tokens // longest), 512)]
b = collate_items([[items[j] for j in idx]], pad_id)
with torch.autocast("cuda", dtype=torch.bfloat16):
logits, _ = model(b["input_ids"].cuda(), b["attention_mask"].cuda(),
b["marker_pos"].cuda(), b["marker_mask"].cuda(), b["qtype"].cuda())
logits = logits.float()
if temperature is not None:
logits = logits / temperature[b["qtype"].cuda()][:, None]
p = torch.softmax(logits, -1).cpu().numpy()
for r, j in enumerate(idx):
out[j] = p[r, : len(items[j]["markers"])]
i += len(idx)
return out
def fit_temperature(model, val_items, pad_id):
"""One temperature per question type (choice, score, noul), minimising cross-entropy against the teacher."""
by_type = {0: [], 1: [], 2: []}
for it, p in zip(val_items, predict(model, val_items, pad_id)):
by_type[it["qtype"]].append((torch.log(torch.tensor(p).clamp_min(1e-9)), torch.tensor(it["target"])))
temps = [1.0, 1.0, 1.0]
for qt, sel in by_type.items():
if len(sel) < 20:
continue # too few to fit: leave 1.0
log_t = torch.zeros(1, requires_grad=True)
opt = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)
def closure():
opt.zero_grad()
loss = sum(-(t * torch.log_softmax(z / log_t.exp(), -1)).sum() for z, t in sel) / len(sel)
loss.backward()
return loss
opt.step(closure)
temps[qt] = float(log_t.detach().exp().clamp(0.2, 5.0))
return temps
def per_item(items, preds):
t = [np.asarray(it["target"]) for it in items]
rows = {
"tvd": [0.5 * np.abs(p - q).sum() for p, q in zip(preds, t)], # distance between the two distributions
"top1": [float(p.argmax() == q.argmax()) for p, q in zip(preds, t)], # same top answer as the teacher
"brier": [float(((p - q) ** 2).sum()) for p, q in zip(preds, t)],
}
return {k: np.asarray(v) for k, v in rows.items()}
def bootstrap(values, clusters, n=10_000, seed=0):
"""95% interval for the mean, resampling whole clusters (items from one moment are correlated)."""
keys, inv = np.unique(np.asarray(clusters).astype(str), return_inverse=True)
sums = np.bincount(inv, weights=values, minlength=len(keys))
counts = np.bincount(inv, minlength=len(keys)).astype(float)
rng = np.random.default_rng(seed)
means = [sums[pick].sum() / counts[pick].sum() for pick in (rng.integers(0, len(keys), len(keys)) for _ in range(n))]
return [float(np.percentile(means, 2.5)), float(np.percentile(means, 97.5))]
def report(items, preds):
clusters = [f"{it['group']}:{it['moment']}" for it in items]
return {k: (float(v.mean()), bootstrap(v, clusters)) for k, v in per_item(items, preds).items()}Write the pass marks before you open the test set
Write the plan, commit it, then score. After that, change it only with dated amendments, and keep the original text. Our own plan, with its amendments and results, is published as an example: the evaluation. Rules that matter:
- Freeze the checkpoint by its hash before scoring, with its temperatures. Nothing about the model changes after the test week is opened.
- Disclose every earlier look. If anyone ran anything on the test week before the plan existed, the plan says so.
- Review your evaluator against the plan, line by line, before any fine-tuned number exists: intervals, floor, every metric the plan names, and the fields you cluster by.
- Cluster the uncertainty. The three questions about one ticket are correlated, so resample whole moments (tickets, or hours if one outage floods an hour), with 10,000 resamples, a fixed seed, and paired comparisons.
- Check each criterion has enough cases. If angry enterprise customers are a handful of tickets a week, a criterion about them cannot pass for anyone, the teacher included. Count cases first, or run the teacher first.
# Evaluation plan: can <student> replace <teacher> for <task>?
Committed <date>, before the test split is opened. Changes below only as dated amendments.
## Systems teacher <id> · student (checkpoint SHA-256 recorded before scoring) · floor · untouched base model
## Data train / val / test by whole <days / customers>; test = <deployment period>; earlier looks at test: <none, or what>
## Metrics top-1 agreement, TVD, MAE, Brier: exact formulas; 95% intervals by bootstrap over <clusters>
## Pass marks A1 TVD <= __ · A2 top-1 >= __ · behaviours the product depends on, each with a tolerance
## Beyond the teacher written rules · invariance to option order · human-labelled sets
## Whole system run the app on the student (shadow or A/B); a period unseen by every system; criteria C1..Cn
## Verdicts what counts as a replacement, and what does not
## Threats to validity
## Amendments (dated, appended, never edited)Evaluate at three levels
Agreement with the teacher cannot tell you whether the copy is better or worse than the teacher, and agreement per question cannot tell you whether a system built on those answers behaves the same. Measure all three.
Level 1 · fidelity on the held-out period
Against the teacher (or your labels), on the test week, next to the floor and untouched Laya:
- Which team: same top answer as the teacher, and TVD (the distance between the two distributions).
- Urgent and angry: mean error on p(yes).
- The behaviours your product depends on, each with a tolerance written first: for example, how much p(urgent) rises when the plan is enterprise, or how often double-charge tickets go to billing. Test the behaviour that makes the answers matter, not only the average: a copy can agree with the teacher most of the time and still overshoot the one effect you care about.
Level 2 · checks that do not come from the teacher
Build them by code from your own written rules, on test tickets only, and record the probes' hashes before any system is asked:
- Written routing rules: a ticket that asks for a refund goes to billing; one that says the app will not start goes to technical. Score how often the teacher and the copy follow them.
- Order invariance: shuffle the order of the options and count how often the top answer changes. Send identical requests to the teacher several times too: that is the noise floor.
- A human-labelled sample: a few hundred test tickets labelled by your support leads tell you whether the copy, the teacher, or neither is right.
Level 3 · the whole system
Run your product on the student and compare it with the teacher and a cheap baseline (keyword rules, say), on a period none of them has seen. For the router, a shadow deployment works: for a week or two the teacher still routes every ticket and the student answers beside it, logged but unused. Then compare routing accuracy (how often a ticket was reassigned from the team each system chose), urgent flags against the tickets your agents actually escalated, and, if you then route a slice of traffic to the student, time to resolution.
Serve it
Answer in the teacher's own wire shape, so your app switches with a URL. Four things matter more than speed:
- The same slicing as training. If the app sends one shared state for many subjects (a queue of 100 tickets), cut every collection a question names in backticks down to that entry, exactly as the dataset did.
- An exact cache. The model is deterministic, so an identical (slice, question) gets the stored answer: exact, never approximate. Duplicate tickets and retries hit it. Report hit rates next to latency.
- Time limits sized for one GPU. A GPU answers a burst of requests one after another, so the last waits for the rest. Size the limit from the serial GPU time of your largest burst, not from a hosted API's limit.
- Auth, even at home, and on failure skip the decision, never invent one: if the model does not answer, the ticket goes to the general queue, as it would today.
# serve.py: the teacher's wire shape, answered by your checkpoint on the GPU
import hashlib
import hmac
import json
import os
import re
from collections import OrderedDict
import torch
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from laya.common import QTYPES, build_sequence, collate_items, render_options
from load import HEAD_MAX_LEN, MAX_LEN, load
MODEL_DIR = os.environ.get("MODEL_DIR", "checkpoints/v1")
SECRET = os.environ.get("LAYA_SECRET")
BATCH_TOKENS = 24576
PATH = re.compile(r"`([A-Za-z_]\w*)\.([^`.\s]+)") # `collection.key` in a question's instructions, e.g. `tickets.T-4812`
model, tok, cfg = load(MODEL_DIR)
model.eval()
temps = torch.tensor(cfg.get("temperature", [1.0, 1.0, 1.0]), device="cuda")
cache: OrderedDict[str, list[float]] = OrderedDict() # exact: same (slice, question) -> same answer
app = FastAPI()
def slice_state(state, instructions: str):
"""Cut each collection a question names down to that one entry: the same slicing as the training data."""
if not isinstance(state, dict):
return state
named: dict[str, set[str]] = {}
for coll, key in PATH.findall(instructions):
if isinstance(state.get(coll), dict) and key in state[coll]:
named.setdefault(coll, set()).add(key)
return {k: ({n: v[n] for n in v if n in named[k]} if k in named else v) for k, v in state.items()}
@torch.no_grad()
def run(batch: list[dict]) -> list[list[float]]:
b = collate_items([batch], tok.pad_token_id)
with torch.autocast("cuda", dtype=torch.bfloat16):
logits, _ = model(b["input_ids"].cuda(), b["attention_mask"].cuda(),
b["marker_pos"].cuda(), b["marker_mask"].cuda(), b["qtype"].cuda())
p = torch.softmax(logits.float() / temps[b["qtype"].cuda()][:, None], -1).cpu()
return [p[r, : len(it["markers"])].tolist() for r, it in enumerate(batch)]
@app.post("/v1/systemone")
async def systemone(req: Request):
if SECRET and not hmac.compare_digest(req.headers.get("authorization", "").encode(), f"Bearer {SECRET}".encode()):
raise HTTPException(401, "unauthorized")
body = await req.json()
state, questions = body.get("state"), body.get("questions")
if not isinstance(questions, dict) or not 0 < len(questions) <= 1000:
raise HTTPException(400, "questions must be an object with 1 to 1000 entries")
asked, todo = {}, []
for qid, q in questions.items():
crit = q.get("criteria")
if q.get("type") == "choice" and isinstance(crit, list):
crit = {c: None for c in crit}
if q.get("type") not in ("choice", "noul") or (q["type"] == "choice" and not crit):
raise HTTPException(400, f"{qid}: a choice with criteria, or a noul")
iq = {"t": q["type"], "ins": str(q["instructions"]), "crit": crit}
sliced = slice_state(state, iq["ins"])
key = hashlib.sha256(json.dumps([sliced, iq]).encode()).hexdigest()
if key not in cache:
ids, markers = build_sequence(tok, sliced, iq, MAX_LEN, HEAD_MAX_LEN)
if len(markers) != len(render_options(iq)):
raise HTTPException(400, f"{qid}: the options do not fit in {HEAD_MAX_LEN} tokens")
todo.append((key, {"ids": ids, "markers": markers, "qtype": QTYPES[iq["t"]]}))
asked[qid] = (key, iq)
todo.sort(key=lambda kv: len(kv[1]["ids"])) # shortest first, batches of about BATCH_TOKENS padded tokens
batch: list = []
for key, it in todo + [(None, None)]:
if batch and (it is None or len(it["ids"]) * (len(batch) + 1) > BATCH_TOKENS):
for (k, _), p in zip(batch, run([x for _, x in batch])):
cache[k] = p
batch = []
if it is not None:
batch.append((key, it))
answers = {}
for qid, (key, iq) in asked.items():
p = cache[key]
cache.move_to_end(key)
if iq["t"] == "choice":
opts = list(iq["crit"])
answers[qid] = {"type": "choice", "choice": opts[p.index(max(p))], "probabilities": dict(zip(opts, p))}
else:
answers[qid] = {"type": "noul", "noul": p[1]}
while len(cache) > 200_000:
cache.popitem(last=False)
return {"model": cfg.get("model_name", "my-laya"), "answers": answers}
if __name__ == "__main__":
host = os.environ.get("HOST", "127.0.0.1")
if host not in ("127.0.0.1", "localhost") and not SECRET:
raise SystemExit("refusing to listen beyond localhost without LAYA_SECRET")
uvicorn.run(app, host=host, port=int(os.environ.get("PORT", "8765")))MODEL_DIR=checkpoints/v1 uv run python serve.py # localhost only, no secret needed
LAYA_SECRET=change-me HOST=0.0.0.0 MODEL_DIR=checkpoints/v1 uv run python serve.py
curl -s localhost:8765/v1/systemone -H 'content-type: application/json' -d '{
"state": {"tickets": {"T-4812": {"plan": "business", "text": "Charged twice this month."}}},
"questions": {
"team": {"type": "choice", "instructions": "Which team should handle `tickets.T-4812`?",
"criteria": {"billing": "Comes first when money was taken wrongly.", "technical": null, "sales": null, "account": null}},
"urgent": {"type": "noul", "instructions": "Is `tickets.T-4812` urgent?"}}}'This server runs the GPU inside the request handler, so it answers one request at a time, which is what one GPU does anyway. Ours adds a lock, gzip, a health route with live GPU readings, and 1,000-question limits.
Mistakes to avoid
We made every one of these on our own project.
- Pointing at subjects by list position. A teacher that cannot count labels the wrong ticket, silently. Key subjects by name everywhere.
- A time limit sized for an API skips decisions on a GPU. Size it from the serial GPU time of your largest burst and watch the slowest request, not the median. Then measure with the limit you actually deploy: a looser limit in testing makes the copy look better than it is.
- The evaluator does not yet implement the plan. Review the scoring code line by line before the test set is opened.
- Tasks it never trained on are far worse. The default for an unmeasured request type is "not the copy": keep sending it to the teacher, or remove it.
- Average agreement hides a behaviour that overshoots. Test the specific behaviours your product depends on, with tolerances written first.
- A criterion without enough cases fails for the teacher too. Check power, or run the teacher first.
- A bug in your inputs is copied from teacher to student. If your app sends the wrong plan name, both models learn from it. Audit the inputs, not only the labels.
- The teacher is not deterministic. When you test invariance, also send identical requests several times and report that as the noise floor.
- "In-sample" hides in whole-system tests. Check which periods the student has seen before choosing the one you compare on.
- Documents drift behind result files. Generate the numbers people read from the committed results.
What it cost us
For scale, our own numbers. Your costs depend on how many questions you log and train on.