Laya is an open-weights model from Convai Innovations that answers typed questions about a piece of text instead of writing prose. You hand it a state (a support thread, an email, a JSON record) and a set of questions, each of which declares the shape of its answer in advance: pick one option from a list, place the text on an ordered scale, or give the probability that a statement is true. Every answer comes back as a probability distribution over the options you declared. Laya is an encoder, a network that reads text and scores it rather than writing any. Each call is one forward pass (a single run of the network over the input), with no generated text to parse.
What you build here is a support-ticket triage script that runs entirely on your Mac. It asks Laya five typed questions about each ticket in roughly 35 to 60 ms on the Apple GPU, acts on the answers it is confident about, and escalates the rest to a human. “Confident” will not be Laya’s word for it: it will be a threshold you fitted yourself, after measuring on 100 labeled tickets how accurate each checkpoint (a trained set of weights: you will compare Laya’s base model with a version fine-tuned for this kind of task) is, how fast it runs on your GPU and on your CPU, and how far its stated probabilities are from the accuracy you observed.
That last measurement is the reason to do this rather than read the model card. Convai’s model card is candid about Laya’s weaknesses, and you will reproduce the most important of them: the base checkpoint scores below a baseline that ignores the input entirely. You will also find a result that runs the other way from what the card says about its confidence, which you would not have known without measuring.
Everything was run on 2026-09-27 against laya 0.3.20 (released 2026-09-24),
the newest release on PyPI at the time of writing. The project was first
published on 2026-09-18 and shipped 28 releases between then and 2026-09-24,
so pin the version this article uses. Versions used throughout: macOS 26.6.2 on an Apple M5 Max,
Python 3.12.9, uv 0.11.26, torch 2.14.0, transformers 5.17.0, and pytest
9.1.1. Timings depend heavily on the chip; accuracy and calibration numbers
should not.
What you end up with
laya_triage/data.py: the 100 labeled customer-service test cases (and 300 training cases) from a public benchmark, loaded as Python objects.laya_triage/model.pyandlaya_triage/first_call.py: one checkpoint loaded onto the GPU, and the raw answers for one ticket.laya_triage/evaluate.pyandlaya_triage/report.py: accuracy per question for two checkpoints on two devices, next to a baseline that ignores the input, with p50/p90 latency and a count of answers the GPU and CPU disagree on.laya_triage/metrics.pyandlaya_triage/calibrate.py: temperatures and an act-or-escalate threshold fitted on half the cases and checked on the other half, both ways round.laya_triage/triage.py: the triage script, run on two new tickets.- A
pytestsuite for the calibration arithmetic that needs no model, and aMakefilewhose default target prints help.
Prerequisites
- macOS on Apple Silicon. The GPU path uses MPS (Metal Performance Shaders), PyTorch’s backend for the Apple GPU. Any M-series Mac works; expect different timings from the ones printed here.
- About 3 GB of free disk. The virtual environment is 921 MB, almost all of it PyTorch, and each Laya checkpoint is an 843 MB weights file. You will download two.
uv0.11 or newer.uvinstalls Python 3.12 for the project if you do not have it.- Network access to Hugging Face on first run, for the checkpoints and the
benchmark data. No account or token is needed. Many runs print a warning about
unauthenticated requests to the Hugging Face Hub, which you can ignore; the
article’s output listings leave it out, along with download progress bars. After the first run, prefixing a
command with
HF_HUB_OFFLINE=1silences it. - Familiarity with Python and the command line. You do not need to know how the model is built; the parts that matter for using it are explained where they come up.
Step 1: Create the project
The project is a small Python package, laya_triage, with each script run as a
module (uv run python -m laya_triage.<name>) so they can import each other
without an install step. The .gitignore comes first so that nothing the later
steps download or write lands in version control: results/ will hold the
evaluation output, which you regenerate rather than commit.
Create the file
mkdir -p laya-triage && cd laya-triage
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
# Downloaded evaluation data and results
data/
results/
# OS / editor
.DS_Store
*.log
tmp/
Detailed breakdown
.venv/holds PyTorch and the rest of the environment, close to a gigabyte.results/holds the JSON the evaluation writes. It is derived data, so it is ignored along withdata/, which is reserved for any labeled data of your own.- The checkpoints do not appear here because they never enter the project:
huggingface_hubcaches them under~/.cache/huggingface/, andmake cleanin Step 8 leaves that cache alone so you do not download 1.7 GB twice.
Now create the project and add its dependencies. --vcs none stops uv init
from creating a Git repository and a second .gitignore, and the rm removes
the placeholder main.py it writes:
uv init --name laya-triage --python 3.12 --no-readme --vcs none \
--description "Support-ticket triage with Laya, with calibration you measure yourself" .
rm main.py
uv add "laya==0.3.20" pandas pyarrow huggingface_hub
uv add --dev pytest
mkdir -p laya_triage tests tickets
touch laya_triage/__init__.py
The version pin matters more than usual. Laya went from 0.3.3 to 0.3.20
in a week, and the GitHub repository already has merged changes that are not in
any release. pandas and pyarrow read the benchmark’s Parquet files, and
huggingface_hub downloads them.
Confirm the install without loading a model:
uv run python -c "import laya, torch; print(laya.__version__, torch.__version__, torch.backends.mps.is_available())"
0.3.20 2.14.0 True
True means PyTorch can see the Apple GPU. If it prints False, the rest of the
article still runs, only on the CPU and much more slowly.
Step 2: Load labeled cases from the benchmark
Measuring accuracy needs questions whose right answers are already known, asked
about text Laya has not seen. The typed-decisions benchmark
(LocalLLaMA/typed-decisions,
Apache 2.0) provides exactly that for four workflows, separate task domains that
each ask their own set of questions. This article uses the customer-service
one: 300 training cases and 100 test cases, each a support thread plus account
details, with the same five questions asked of every case.
The right answers need one caveat before you trust any score built on them. The benchmark calls each one the gold label. It is not human ground truth. It is the answer a teacher model (a separate model, which the benchmark describes as of “roughly 4B-class capability”) gave most weight across three samples, so accuracy here means agreement with that teacher. The benchmark’s documentation puts a fresh teacher sample’s agreement with those labels at 0.735 across all four workflows, and warns that a model scoring much higher has learned the teacher’s quirks rather than the task.
The five questions
Every case asks the same five questions. Each declares a type, which Step 3
explains in full: choice picks one option, score places the ticket on an
ordered scale whose levels are numbered from 0, and noul is the probability
that a statement is true.
| Question | Type | Options or levels |
|---|---|---|
action: what should the assistant do next? | choice | answer_directly, close_no_action, escalate_to_human, execute_refund, request_information |
category: what is the conversation about? | choice | account, billing, delivery, refund, technical |
churn_risk: how likely is the customer to leave? | score | 0 no dissatisfaction, 1 mild frustration, 2 clearly unhappy, 3 threatening to cancel |
needs_human: a human agent must take over | noul | probability the statement is true |
urgency: how time-sensitive is it? | score | 0 can wait, 1 normal queue, 2 same week, 3 same day |
Each option and level carries a sentence of description in the data, which Laya
reads along with the label. The action question has nothing to do with the
action field that appears inside every answer in Step 3.
Create the file
touch laya_triage/data.py
Add the code: laya_triage/data.py
"""Load labeled customer-service cases from the typed-decisions benchmark."""
import json
from dataclasses import dataclass
import pandas as pd
from huggingface_hub import hf_hub_download
DATASET = "LocalLLaMA/typed-decisions"
WORKFLOW = "customer_service"
@dataclass
class Case:
id: str
state: dict
questions: dict
gold: dict[str, str] # question name -> the label the benchmark counts as correct
def load_cases(split: str = "test") -> list[Case]:
path = hf_hub_download(
DATASET,
f"{WORKFLOW}/{split}-00000-of-00001.parquet",
repo_type="dataset",
)
frame = pd.read_parquet(path).sort_values("id")
cases = []
for _, row in frame.iterrows():
questions = json.loads(row["questions"])
gold = {name: str(row[f"{name}__label"]) for name in questions}
cases.append(Case(row["id"], json.loads(row["state"]), questions, gold))
return cases
def question_schema() -> dict:
"""The five customer-service questions, exactly as the benchmark asks them."""
return load_cases("test")[0].questions
Detailed breakdown
hf_hub_downloadfetches one Parquet file per split and caches it, so only the first call downloads anything. Later calls still check the Hub, which is where the warning in Prerequisites comes from.stateandquestionsare stored in the dataset as JSON strings. Together they are exactly what Laya’spredict()takes, so a case can be replayed without reshaping.- The gold label for each question lives in a column named
<question>__label. Thescorequestions store their gold level as a string such as"2", which matches the string keys Laya returns its levels under;str()guards against a copy of the data that stores them as numbers. - Sorting by
idfixes the case order. Step 6 splits the cases into alternating halves, and a stable order makes that split the same on every machine. question_schema()returns the five questions from the first test case. Every one of the 400 cases asks the identical five, so the triage script in Step 7 can ask new tickets the same questions the evaluation measured.
Step 3: Ask the five questions about one ticket
Before measuring anything, look at what one answer contains, because two of the fields that look most useful are not. You need a ticket and a way to load a checkpoint.
Convai ships three checkpoints in one Hugging
Face repository, convaiinnovations/laya: the base English model at the root
(421M parameters, built on the ModernBERT-large encoder), a multilingual one,
and laya-typed-decisions, the English model fine-tuned on the training split
of the benchmark from Step 2. This article compares the base model and the
fine-tuned one, and ignores the multilingual checkpoint because the tickets are
English.
Create the file
touch laya_triage/model.py
Add the code: laya_triage/model.py
"""Load a Laya checkpoint, and read its answers as probability distributions."""
import warnings
import laya
REPO = "convaiinnovations/laya"
# The base English checkpoint sits at the repo root; the fine-tuned one is a subfolder.
SUBFOLDERS = {"english": None, "typed-decisions": "typed-decisions"}
def load_checkpoint(name: str = "typed-decisions", device: str | None = None) -> laya.Agent:
# Both checkpoints warn on load that one shipped temperature is out of range
# and has been clamped. We fit our own, so the warning adds nothing here.
warnings.filterwarnings("ignore", message="laya: this checkpoint ships")
return laya.load(REPO, subfolder=SUBFOLDERS[name], device=device)
def distribution(answer: dict) -> dict[str, float]:
"""Any Laya answer as {label: probability}; a noul answer becomes true/false."""
if answer["type"] == "noul":
return {"true": answer["noul"], "false": 1.0 - answer["noul"]}
return dict(answer["probabilities"])
def top(probs: dict[str, float]) -> tuple[str, float]:
"""The most probable label and its probability."""
label = max(probs, key=probs.get)
return label, probs[label]
Detailed breakdown
laya.load()takes the repository and asubfolder; only the requested checkpoint’s files download. The base checkpoint is at the root, so its subfolder isNone.device=Nonelets Laya choose. Its loader tries CUDA, then MPS, then Intel XPU, then the CPU, so on a Mac it picks the GPU without being told. The card’s own examples passdevice="cuda", which on a Mac prints a warning and falls back to the CPU, not to MPS; leave it unset or pass"mps".- Both checkpoints print a
RuntimeWarningon load: one of the temperatures they ship (settings that sharpen or flatten the probabilities, explained in Step 6) is out of range and has been clamped. It applies only tochoicequestions with 11 or more options, which this article never asks, and Step 6 fits its own temperatures anyway, so the filter keeps the warning out of every output. distribution()gives every answer the same shape,{label: probability}. Anoulanswer (defined below) carries a single probability, so it becomes a two-label distribution,trueandfalse.top()returns the most probable label. That is the answer the evaluation scores.
Next, a ticket. The state is a JSON object in the benchmark’s shape: account details and the conversation so far.
Create the file
touch tickets/double-charge.json tickets/vague.json
Add the code: tickets/double-charge.json
{
"account": {"tier": "pro", "seats": 12, "tenure_months": 31, "lifetime_value_usd": 8640, "prior_tickets_90d": 2},
"thread": [
{"role": "customer", "text": "Our card was charged twice for the September invoice, $720 each time. I can see both charges on the statement."},
{"role": "agent", "text": "Thanks for flagging this. Could you confirm the last four digits of the card?"},
{"role": "customer", "text": "It ends in 4417. This is the second time this year. Refund the duplicate today or we will move the whole team to another vendor."}
]
}
Add the code: tickets/vague.json
{
"account": {"tier": "free", "seats": 1, "tenure_months": 2, "lifetime_value_usd": 0, "prior_tickets_90d": 0},
"thread": [
{"role": "customer", "text": "hey, something seems off with my account since last week. not sure what changed."}
]
}
Detailed breakdown
- The two tickets are chosen to sit at opposite ends. The first names the problem, the amount, the history and a threat to leave. The second says almost nothing, so the questions about it have no clear answer.
- Neither ticket appears in the benchmark. They are the “new tickets” Step 7 triages with numbers fitted on the benchmark’s cases.
Now the script that asks the questions.
Create the file
touch laya_triage/first_call.py
Add the code: laya_triage/first_call.py
"""Ask Laya the five customer-service questions about one ticket and print what comes back."""
import json
import sys
import time
from pathlib import Path
from laya_triage.data import question_schema
from laya_triage.model import load_checkpoint
def main() -> None:
ticket = Path(sys.argv[1] if len(sys.argv) > 1 else "tickets/double-charge.json")
questions = question_schema()
start = time.perf_counter()
agent = load_checkpoint("typed-decisions") # no device given: Laya picks one
print(f"loaded on {agent.device} in {time.perf_counter() - start:.1f} s")
result = agent.predict(json.loads(ticket.read_text()), questions)
for name in ("category", "urgency", "needs_human"):
print(f"\n{name} ({questions[name]['type']}):")
print(json.dumps(result["answers"][name], indent=2))
if __name__ == "__main__":
main()
Detailed breakdown
- The five questions come from the benchmark, so this call is shaped exactly like every call the evaluation makes.
- All five are answered in a single
predict()call. Laya encodes each question together with the state as one row of a batch and runs every row in one forward pass of the encoder. - The script prints three answers, one of each question type, which is enough to see every field Laya returns.
Run it. The first run downloads the fine-tuned checkpoint and the benchmark file:
uv run python -m laya_triage.first_call
loaded on mps in 1.2 s
category (choice):
{
"type": "choice",
"choice": "billing",
"probabilities": {
"account": 0.0893,
"billing": 0.534,
"delivery": 0.0291,
"refund": 0.2826,
"technical": 0.0651
},
"confidence": 0.2615,
"answer_confidence": 0.534,
"action": {
"act_probability": 1.0
}
}
urgency (score):
{
"type": "score",
"score": 2.672,
"legend": {
"0": "No time pressure; can wait indefinitely.",
"1": "Routine; handle within the normal queue.",
"2": "Elevated; should be handled within the same week.",
"3": "Critical; requires action within the same day."
},
"probabilities": {
"0": 0.0091,
"1": 0.0574,
"2": 0.1859,
"3": 0.7476
},
"confidence": 0.4683,
"answer_confidence": 0.7476,
"action": {
"act_probability": 1.0
}
}
needs_human (noul):
{
"type": "noul",
"noul": 0.6333,
"confidence": 0.6333,
"answer_confidence": 0.6333,
"action": {
"act_probability": 1.0
}
}
The load time is from a warm cache; the first run adds the download. Your probabilities may differ in the last digit or two, for a reason Step 5 measures.
Reading the three answer types
Laya’s three question types, which the rest of the article uses by name:
choicepicks one option from a list you define.choiceholds the most probable option andprobabilitiesthe full distribution.scoreplaces the state on an ordered scale. The levels are numbered from 0 in the order you listed them, andlegendechoes their descriptions.scoreis the expected level, the probability-weighted average (2.672 here), so it can fall between levels. The most probable level is 3.noulis the probability that a statement is true.noul: 0.6333means Laya puts a 63% chance on “this conversation requires a human agent”.
Two fields that mislead
Both look like the number to gate a decision on:
confidenceis not the probability of the answer. Forchoiceandscoreit is 1 minus the normalised entropy of the distribution, a measure of how concentrated the distribution is.billinghas probability 0.534 andconfidence0.2615. The probability of the answer is inanswer_confidence, which is the number the rest of this article uses, because it is a probability you can calibrate and compare across question types. The README on Laya’s GitHubmainbranch advises the same; the model card’s note on the next field suggestsconfidence, which can rank answers but is not a probability.action.act_probabilityis 1.0 on all three, and in practice on almost every input. Convai’s card lists this as a known issue (#185): the field carries no usable signal yet. Do not build an act-or-escalate rule on it; Step 6 builds one onanswer_confidenceinstead.
Step 4: Score both checkpoints on the GPU and the CPU
One ticket shows what an answer looks like, not whether it is right. This step runs all 100 test cases through a checkpoint on a chosen device and saves every distribution Laya returned, along with the gold label and the time the call took. It runs four times: two checkpoints, each on MPS and on the CPU.
Create the file
touch laya_triage/evaluate.py
Add the code: laya_triage/evaluate.py
"""Run one Laya checkpoint over every labeled test case and save what it answered."""
import argparse
import json
import time
from pathlib import Path
from laya_triage.data import load_cases
from laya_triage.model import distribution, load_checkpoint
RESULTS = Path("results")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="typed-decisions",
choices=["english", "typed-decisions"])
parser.add_argument("--device", default="mps", choices=["mps", "cpu"])
args = parser.parse_args()
cases = load_cases("test")
agent = load_checkpoint(args.model, args.device)
# The first calls on a GPU pay one-off setup costs; keep them out of the timings.
for case in cases[:3]:
agent.predict(case.state, case.questions)
records = []
for case in cases:
start = time.perf_counter()
result = agent.predict(case.state, case.questions)
elapsed_ms = (time.perf_counter() - start) * 1000
records.append({
"id": case.id,
"ms": round(elapsed_ms, 2),
"input_tokens": result["usage"]["input_tokens"],
"answers": {
name: {
"type": answer["type"],
"probabilities": distribution(answer),
"gold": case.gold[name],
}
for name, answer in result["answers"].items()
},
})
RESULTS.mkdir(exist_ok=True)
out = RESULTS / f"{args.model}-{args.device}.json"
out.write_text(json.dumps(records, indent=1))
print(f"{len(records)} cases, {len(records) * len(cases[0].questions)} answers -> {out}")
if __name__ == "__main__":
main()
Detailed breakdown
- The three untimed warm-up calls matter on MPS. The first GPU calls pay setup costs that a long-running service pays only once; timing them would inflate the results.
- Each case is timed as one
predict()call with all five questions, which is how a triage service would use it: one ticket arrives, five answers go out. input_tokensis Laya’s own count of the tokens (the sub-word pieces a model reads text as) it read, summed over every row. Each row carries the whole state plus one question and its options, so the state is counted five times: about 1,700 tokens for a ticket whose rows average roughly 350.- The file stores each distribution, not only the winning label. Step 6 needs the full distributions to refit the probabilities without re-running the model.
Run the four evaluations. The CPU runs take about two minutes each:
uv run python -m laya_triage.evaluate --model english --device mps
uv run python -m laya_triage.evaluate --model english --device cpu
uv run python -m laya_triage.evaluate --model typed-decisions --device mps
uv run python -m laya_triage.evaluate --model typed-decisions --device cpu
100 cases, 500 answers -> results/english-mps.json
100 cases, 500 answers -> results/english-cpu.json
100 cases, 500 answers -> results/typed-decisions-mps.json
100 cases, 500 answers -> results/typed-decisions-cpu.json
The first english run also downloads the base checkpoint.
Step 5: Read accuracy, latency, and device agreement
Four result files still need reading. This step turns them into one table, and puts a baseline next to it that every checkpoint has to beat to be worth running: the majority class, which ignores the ticket and always answers each question’s most common label from the training split. A model that loses to it does worse, overall, than not reading the text at all.
Create the file
touch laya_triage/report.py
Add the code: laya_triage/report.py
"""Compare saved evaluation runs: accuracy per question, latency, and device agreement."""
import json
import statistics
from collections import Counter
from pathlib import Path
from laya_triage.data import load_cases
from laya_triage.model import top
RESULTS = Path("results")
def majority_baseline(names: list[str]) -> dict[str, float]:
"""Accuracy on test of always answering each question's commonest train label."""
train, test = load_cases("train"), load_cases("test")
scores = {}
for name in names:
commonest = Counter(c.gold[name] for c in train).most_common(1)[0][0]
scores[name] = sum(c.gold[name] == commonest for c in test) / len(test)
return scores
def per_question_accuracy(records: list[dict]) -> dict[str, float]:
names = list(records[0]["answers"])
return {
n: sum(top(r["answers"][n]["probabilities"])[0] == r["answers"][n]["gold"]
for r in records) / len(records)
for n in names
}
def main() -> None:
# Evaluation files are named <model>-<device>.json; calibration.json has no hyphen.
runs = {p.stem: json.loads(p.read_text()) for p in sorted(RESULTS.glob("*-*.json"))}
if not runs:
raise SystemExit("no results yet: run laya_triage.evaluate first")
names = list(next(iter(runs.values()))[0]["answers"])
rows = {"majority class": majority_baseline(names)}
rows.update({run: per_question_accuracy(recs) for run, recs in runs.items()})
print(f"{'run':<22}" + "".join(f"{n:>13}" for n in names) + f"{'overall':>10}")
for run, acc in rows.items():
overall = sum(acc.values()) / len(acc)
print(f"{run:<22}" + "".join(f"{acc[n]:>13.2f}" for n in names) + f"{overall:>10.3f}")
print()
for run, recs in runs.items():
ms = sorted(r["ms"] for r in recs)
tokens = statistics.mean(r["input_tokens"] for r in recs)
print(f"{run:<22} p50 {statistics.median(ms):7.1f} ms p90 {ms[int(0.9 * len(ms)) - 1]:7.1f} ms"
f" mean input {tokens:.0f} tokens")
for model in ("english", "typed-decisions"):
mps, cpu = runs.get(f"{model}-mps"), runs.get(f"{model}-cpu")
if mps and cpu:
cpu_by_id = {r["id"]: r for r in cpu}
flips = sum(top(a["answers"][n]["probabilities"])[0]
!= top(cpu_by_id[a["id"]]["answers"][n]["probabilities"])[0]
for a in mps for n in names)
print(f"{model}: MPS and CPU pick different answers on {flips} of {len(mps) * len(names)}")
if __name__ == "__main__":
main()
Detailed breakdown
majority_baseline()takes its labels from the training split and scores them on the test split. Taking the commonest label from the test split itself would let the baseline peek at the answers.per_question_accuracy()compares the most probable label with the gold label. For ascorequestion that means the most probable level, not the rounded expected score.- p50 and p90 are the median and 90th-percentile call times: half the calls finished within the p50 figure, nine in ten within the p90.
overallis the plain mean of the five per-question accuracies.- The last block pairs each MPS answer with the CPU answer for the same case
idand counts the pairs that chose a different label. - The glob matches
<model>-<device>.jsononly, so it skips thecalibration.jsonthat Step 6 writes to the same folder.
uv run python -m laya_triage.report
run action category churn_risk needs_human urgency overall
majority class 0.52 0.18 0.47 0.67 0.42 0.452
english-cpu 0.05 0.48 0.46 0.67 0.25 0.382
english-mps 0.05 0.49 0.46 0.67 0.25 0.384
typed-decisions-cpu 0.58 0.94 0.80 0.76 0.74 0.764
typed-decisions-mps 0.58 0.94 0.81 0.76 0.74 0.766
english-cpu p50 942.8 ms p90 1314.7 ms mean input 1723 tokens
english-mps p50 59.0 ms p90 73.7 ms mean input 1723 tokens
typed-decisions-cpu p50 949.0 ms p90 1312.0 ms mean input 1738 tokens
typed-decisions-mps p50 59.2 ms p90 75.6 ms mean input 1738 tokens
english: MPS and CPU pick different answers on 1 of 500
typed-decisions: MPS and CPU pick different answers on 1 of 500
The accuracy columns should match yours to the digit. The millisecond columns are from an M5 Max and will not; rerun the report on your own Mac and use your numbers.
The base checkpoint loses to a fixed answer
The base english checkpoint scores 0.382 overall, below the 0.452 of a rule
that never reads the ticket. The action column shows why: it answers
close_no_action on 87 of the 100 cases, a label the teacher never chose for
any of them, and scores 0.05. This is the weakness Convai’s card states first:
“Base checkpoints are near chance on typed-decisions zero-shot”. Zero-shot means
answering questions it was never trained on. The base model is a starting point
for fine-tuning, not a decision engine.
The fine-tuned checkpoint scores 0.764 on the CPU, the same as the 0.764 Convai
publishes for this workflow, and wins every column against the baseline. Treat
that as a check that your setup matches theirs rather than as independent
evidence: laya-typed-decisions was trained on this benchmark’s training split,
and these test cases come from the same generator. Its score is also above the
teacher’s 0.735 from Step 2, which the benchmark reads as partly learning the
teacher’s quirks. It measures how well the checkpoint learned this benchmark,
which is what its card says it is for.
The GPU is about 16 times faster, and slightly different
On this Mac, MPS answers a five-question ticket in a median of 59 ms against 949 ms on the CPU. Convai’s headline 33 ms is for a single question on an NVIDIA Tesla T4; these are five questions and about 1,700 input tokens per call, so the two figures measure different work. The card’s one Apple datapoint, “about 1.7 s” for a 4,000-token input on the multilingual checkpoint, is a different workload again.
The disagreements come from precision, not randomness. On MPS, Laya switches to 16-bit floating point (fp16 autocast) for any call with at least five question rows, because that is faster there, and the CPU runs in 32-bit. Five questions per ticket is exactly that minimum. To see the trade-off, raise the minimum with the environment variable Laya reads for it, rerun the fine-tuned checkpoint’s MPS evaluation, and report again (the excerpt below shows the lines that change):
LAYA_MPS_AMP_MIN_ROWS=6 uv run python -m laya_triage.evaluate --model typed-decisions --device mps
uv run python -m laya_triage.report
typed-decisions-mps p50 129.1 ms p90 173.5 ms mean input 1738 tokens
english: MPS and CPU pick different answers on 1 of 500
typed-decisions: MPS and CPU pick different answers on 0 of 500
In 32-bit, the GPU and CPU agree on all 500 of the fine-tuned checkpoint’s answers, and the median call slows from 59 ms to 129 ms. Many services would accept one changed answer in 500 for twice the speed, but it means a threshold should be fitted on output from the device and precision you will serve with. Rerun the normal evaluation before moving on, so the next step fits on the fp16 numbers the triage script will see:
uv run python -m laya_triage.evaluate --model typed-decisions --device mps
Step 6: Fit the probabilities and an act-or-escalate threshold
A triage script needs a rule of the form “act on this answer if its probability is at least t”. For t to mean anything, the probabilities have to. A model is calibrated when its stated probabilities match how often it is right: of all the answers it gives at 0.8, about 80% should be correct.
Convai trains Laya with what it calls RLCD, reinforcement learning against strictly proper scoring rules: reward functions under which reporting your true belief is the best strategy. That is the basis for the card calling the probabilities calibrated. The card also says the checkpoints “ship over-confident” and recommends refitting on your own data. This step does that, and measures the result.
Measuring and fixing calibration
ECE (expected calibration error) turns calibration into one number. Group the answers into ten bins by stated probability (0.0 to 0.1, 0.1 to 0.2, and so on), take the gap between each bin’s average probability and its accuracy, and average the gaps weighted by how many answers each bin holds. 0 is perfect.
Temperature scaling is the standard fix. Raise every probability to the power 1/T and renormalise. A temperature above 1 flattens the distribution, which corrects over-confidence. A temperature below 1 sharpens it, which corrects under-confidence. The top answer never changes, only how sure the model claims to be. One temperature is fitted per question type, choosing the T that gives the gold labels the highest probability on average (the lowest log loss).
All of this is arithmetic on saved distributions, so it lives in its own module that never imports Laya.
Create the file
touch laya_triage/metrics.py
Add the code: laya_triage/metrics.py
"""Calibration and threshold arithmetic. Pure functions: no model, no Laya import."""
import math
# Candidate temperatures: 0.05 to 5.00 in steps of 0.05.
TEMPERATURES = [round(0.05 * i, 2) for i in range(1, 101)]
def rescale(probs: dict[str, float], temperature: float) -> dict[str, float]:
"""Temperature scaling: p ** (1/T), renormalised. T > 1 softens, T < 1 sharpens."""
floor = 1e-9 # a probability of exactly 0 would make the power undefined
powered = {k: max(v, floor) ** (1.0 / temperature) for k, v in probs.items()}
total = sum(powered.values())
return {k: v / total for k, v in powered.items()}
def log_loss(answers: list[dict], temperature: float) -> float:
"""Mean negative log probability given to the gold label."""
total = 0.0
for d in answers:
p = rescale(d["probabilities"], temperature).get(d["gold"], 0.0)
total -= math.log(max(p, 1e-9))
return total / len(answers)
def fit_temperature(answers: list[dict]) -> float:
"""The temperature that minimises log loss on these answers."""
return min(TEMPERATURES, key=lambda t: log_loss(answers, t))
def confidence_and_correct(answers: list[dict],
temperature: float = 1.0) -> list[tuple[float, bool]]:
pairs = []
for d in answers:
scaled = rescale(d["probabilities"], temperature)
label = max(scaled, key=scaled.get)
pairs.append((scaled[label], label == d["gold"]))
return pairs
def accuracy(pairs: list[tuple[float, bool]]) -> float:
return sum(ok for _, ok in pairs) / len(pairs)
def expected_calibration_error(pairs: list[tuple[float, bool]], bins: int = 10) -> float:
"""Average gap between stated confidence and observed accuracy, over 10 equal-width bins."""
gap = 0.0
for b in range(bins):
lo, hi = b / bins, (b + 1) / bins
in_bin = [(c, ok) for c, ok in pairs if lo < c <= hi or (b == 0 and c == 0)]
if in_bin:
mean_conf = sum(c for c, _ in in_bin) / len(in_bin)
gap += len(in_bin) / len(pairs) * abs(accuracy(in_bin) - mean_conf)
return gap
def pick_threshold(pairs: list[tuple[float, bool]], target: float) -> float | None:
"""Lowest confidence cut-off whose accepted answers are at least `target` accurate."""
for cut in sorted({c for c, _ in pairs}):
accepted = [(c, ok) for c, ok in pairs if c >= cut]
if accuracy(accepted) >= target:
return cut
return None
Detailed breakdown
rescale()works on probabilities rather than on the model’s internal scores. Raising probabilities to 1/T and renormalising gives the same result as dividing those scores by T, so no model access is needed. The floor stops a probability of exactly 0 from breaking the power.fit_temperature()is a grid search over 100 values from 0.05 to 5.00. With a single parameter and a few hundred answers, a grid search is fast and simple.- Each item in
answersis one saved answer from Step 4: its type, its distribution, and the gold label. confidence_and_correct()produces the pairs everything else consumes: for each answer, the probability of the top label after scaling, and whether that label was right.expected_calibration_error()bins on(lo, hi], with an exact 0 counted in the first bin so no pair is dropped.pick_threshold()walks the cut-offs from lowest to highest and returns the first whose accepted answers reach the target accuracy. The lowest such cut-off acts on the most answers. It returnsNonewhen even the most confident answers miss the target.
Testing the arithmetic without a model
The functions above decide which answers a human sees, so they get tests. None of the tests loads a checkpoint, so the suite runs in milliseconds.
Create the file
touch tests/test_metrics.py
Add the code: tests/test_metrics.py
import pytest
from laya_triage.metrics import (
expected_calibration_error,
fit_temperature,
pick_threshold,
rescale,
)
def test_temperature_one_changes_nothing():
probs = {"a": 0.6, "b": 0.3, "c": 0.1}
assert rescale(probs, 1.0) == pytest.approx(probs)
def test_temperature_sharpens_or_softens_but_keeps_the_answer():
probs = {"a": 0.6, "b": 0.3, "c": 0.1}
sharper, softer = rescale(probs, 0.5), rescale(probs, 2.0)
assert max(sharper, key=sharper.get) == max(softer, key=softer.get) == "a"
assert sharper["a"] > 0.6 > softer["a"]
assert sum(sharper.values()) == pytest.approx(1.0)
def test_underconfident_model_gets_a_temperature_below_one():
# Always right, but only 60% sure: the fit should sharpen it.
answers = [{"probabilities": {"a": 0.6, "b": 0.4}, "gold": "a"}] * 10
assert fit_temperature(answers) < 1.0
def test_overconfident_model_gets_a_temperature_above_one():
# 90% sure, right half the time: the fit should soften it.
answers = ([{"probabilities": {"a": 0.9, "b": 0.1}, "gold": "a"}] * 5
+ [{"probabilities": {"a": 0.9, "b": 0.1}, "gold": "b"}] * 5)
assert fit_temperature(answers) > 1.0
def test_fit_finds_a_temperature_inside_the_grid():
# 90% sure, right 80% of the time: the best T sets p(a) to 0.8, which is
# 0.9 ** (1/T) / (0.9 ** (1/T) + 0.1 ** (1/T)) = 0.8, so T = ln 9 / ln 4, about 1.58.
answers = ([{"probabilities": {"a": 0.9, "b": 0.1}, "gold": "a"}] * 8
+ [{"probabilities": {"a": 0.9, "b": 0.1}, "gold": "b"}] * 2)
assert fit_temperature(answers) == pytest.approx(1.6)
def test_ece_is_zero_when_confidence_matches_accuracy():
pairs = [(0.75, True), (0.75, True), (0.75, True), (0.75, False)]
assert expected_calibration_error(pairs) == pytest.approx(0.0)
def test_ece_is_the_gap_when_confidently_wrong():
assert expected_calibration_error([(0.9, False)] * 4) == pytest.approx(0.9)
def test_threshold_is_the_lowest_cut_that_meets_the_target():
pairs = [(0.9, True), (0.8, True), (0.7, False), (0.6, True)]
assert pick_threshold(pairs, 1.0) == 0.8
assert pick_threshold(pairs, 0.75) == 0.6
def test_no_threshold_when_the_target_is_out_of_reach():
assert pick_threshold([(0.5, False), (0.9, False)], 0.9) is None
Detailed breakdown
- The two
fit_temperaturetests pin the direction of the fit: an answer that is always right at 60% should be sharpened (T < 1), and one that is right half the time at 90% should be softened (T > 1). The calibration output below is read in exactly those terms. - The ECE tests cover both ends: a bin whose confidence equals its accuracy contributes nothing, and a confidently wrong bin contributes its whole confidence.
- The interior test guards the search itself. The two direction tests are satisfied by the grid’s end values (0.05 and 5.00), so a broken search that always returned an end would pass them; this one has a known optimum in the middle, worked out in its comment.
- The threshold tests check that the lowest qualifying cut-off wins, and that an
unreachable target returns
Nonerather than a threshold that does not work.
Tell pytest where the package and tests are by appending to pyproject.toml:
cat >> pyproject.toml <<'EOF'
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
EOF
uv run pytest -q
......... [100%]
9 passed in 0.01s
Fitting on one half and checking on the other
Fitting temperatures and a threshold on the same cases you then score them on would overstate how well they hold up. So the script splits the 100 test cases into even- and odd-numbered halves, fits on one, checks on the other (the held-out half), and then swaps them. Each fit-and-check is called a fold. If the two folds agree, the fitted numbers are probably stable. If they disagree, you have too little labeled data to trust them.
The training split is not used for this. laya-typed-decisions was trained on
it, and Convai’s card notes that the per-type temperatures shipped with this
checkpoint were fitted on training data
(#186). The same mistake here
would make the probabilities look better calibrated than they are.
Create the file
touch laya_triage/calibrate.py
Add the code: laya_triage/calibrate.py
"""Fit one temperature per question type and an act/escalate threshold, and measure how well they hold up."""
import argparse
import json
from pathlib import Path
from laya_triage.metrics import (
accuracy,
confidence_and_correct,
expected_calibration_error,
fit_temperature,
pick_threshold,
)
RESULTS = Path("results")
KINDS = ("choice", "noul", "score")
def flatten(records: list[dict]) -> list[dict]:
return [answer for r in records for answer in r["answers"].values()]
def fit(answers: list[dict], target: float) -> tuple[dict[str, float], float | None]:
temperatures = {k: fit_temperature([d for d in answers if d["type"] == k]) for k in KINDS}
return temperatures, pick_threshold(scaled_pairs(answers, temperatures), target)
def scaled_pairs(answers: list[dict], temperatures: dict[str, float]):
pairs = []
for kind, t in temperatures.items():
pairs += confidence_and_correct([d for d in answers if d["type"] == kind], t)
return pairs
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--run", default="typed-decisions-mps")
parser.add_argument("--target", type=float, default=0.90,
help="accuracy the automatically handled answers must reach")
args = parser.parse_args()
records = json.loads((RESULTS / f"{args.run}.json").read_text())
even, odd = flatten(records[0::2]), flatten(records[1::2])
# Fit on one half, judge on the other, then swap. Two folds that disagree
# are the sign that there is not yet enough labeled data to trust a threshold.
for name, fit_half, check_half in (("fold A", even, odd), ("fold B", odd, even)):
temperatures, threshold = fit(fit_half, args.target)
raw = scaled_pairs(check_half, dict.fromkeys(KINDS, 1.0))
fitted = scaled_pairs(check_half, temperatures)
acted = [p for p in fitted if threshold is not None and p[0] >= threshold]
print(f"{name}: temperatures {temperatures}")
print(f" ECE on held-out half {expected_calibration_error(raw):.3f} raw"
f" -> {expected_calibration_error(fitted):.3f} fitted")
if acted:
print(f" threshold {threshold:.3f}: acts on {len(acted)}/{len(fitted)}"
f" ({len(acted) / len(fitted):.0%}) at {accuracy(acted):.3f} accuracy")
else:
print(f" no threshold reached {args.target:.0%} on the fitting half")
# The numbers the triage script uses come from all the labeled cases.
temperatures, threshold = fit(even + odd, args.target)
if threshold is None:
raise SystemExit(f"no threshold reaches {args.target:.0%} on this data")
out = RESULTS / "calibration.json"
out.write_text(json.dumps({"run": args.run, "target": args.target,
"temperatures": temperatures, "threshold": threshold}, indent=1))
print(f"\nall {len(records)} cases: temperatures {temperatures}, threshold {threshold:.3f} -> {out}")
if __name__ == "__main__":
main()
Detailed breakdown
flatten()pools every answer in a half. With five questions per case, each half holds 250 answers.fit()fits one temperature per question type (choice,noul,score), then picks a threshold on the scaled probabilities of the same half.- Each fold prints the held-out half’s ECE before and after its temperatures, and what its threshold does there: how many answers it acts on, and how accurate those answers turn out to be.
- The final fit uses all 100 cases. The folds estimate how well that procedure
transfers; the final numbers use every labeled case available, and are saved to
results/calibration.jsonfor the triage script. --target 0.90asks for a threshold whose accepted answers are at least 90% accurate. It is a policy choice: raise it and more answers reach a human.
uv run python -m laya_triage.calibrate
fold A: temperatures {'choice': 0.3, 'noul': 0.2, 'score': 0.35}
ECE on held-out half 0.165 raw -> 0.100 fitted
threshold 0.714: acts on 179/250 (72%) at 0.793 accuracy
fold B: temperatures {'choice': 0.45, 'noul': 0.35, 'score': 0.4}
ECE on held-out half 0.258 raw -> 0.085 fitted
threshold 0.739: acts on 115/250 (46%) at 0.939 accuracy
all 100 cases: temperatures {'choice': 0.4, 'noul': 0.3, 'score': 0.4}, threshold 0.739 -> results/calibration.json
What the fit found
Every temperature is below 1. On this workflow the fine-tuned checkpoint is under-confident: it is right more often than it says. The card for this checkpoint describes it as “still over-confident”, with an ECE of 0.213. ECE measures the size of the gap between stated probability and accuracy, not its direction. Running the same checkpoint over each of the four workflows’ full test sets while preparing this article (a check outside the companion project) gave an ECE between 0.17 and 0.25 in each, in line with the card, and in every one the average stated probability (0.50 to 0.64) sat below the accuracy (0.73 to 0.81). The size of the miscalibration reproduces; its direction, on this measure, is the opposite of the card’s label.
The card itself points at a likely cause. The probabilities Laya returns have already been divided by temperatures shipped in the checkpoint’s config, and for these five questions those are 1.25 to 1.98: values the card says were “inherited from the base checkpoint” and override the ones fitted for this model. Temperatures that large flatten a distribution, which suits the over-confident base model; on the fine-tuned model they appear to flatten too much, and a fitted T below 1 takes some of that back. Here the fit cut the held-out ECE by 39% in fold A (0.165 to 0.100) and 67% in fold B (0.258 to 0.085).
The threshold did not transfer as well. Both folds asked for 90% accuracy. Fold
B delivered 93.9% on its held-out half; fold A delivered 79.3%. pick_threshold()
takes the lowest cut-off that reaches the target, so on its own half each fold
lands at barely 90% with no margin, and 50 cases are too few for that to carry
over. Fold A’s sharper temperatures also push more held-out answers over its
line. Treat the 0.739 threshold as a starting point, and plan on labeling a few
hundred of your own tickets before an automated action depends on it.
To see the same procedure on the base checkpoint, run
uv run python -m laya_triage.calibrate --run english-mps. Its temperatures for
choice and score come out above 1 (over-confident, as the card says), and its
thresholds act on almost nothing. Rerun the default command afterwards so
results/calibration.json holds the fine-tuned checkpoint’s numbers.
Step 7: Triage new tickets
The triage script loads the fine-tuned checkpoint on the GPU, asks the five questions about each ticket, rescales each answer with the fitted temperature for its type, and acts on it only if the rescaled probability clears the fitted threshold.
Create the file
touch laya_triage/triage.py
Add the code: laya_triage/triage.py
"""Triage support tickets locally: answer five typed questions, act on the confident answers, escalate the rest."""
import argparse
import json
import time
from pathlib import Path
from laya_triage.data import question_schema
from laya_triage.metrics import rescale
from laya_triage.model import distribution, load_checkpoint, top
CALIBRATION = Path("results/calibration.json")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("tickets", nargs="+", type=Path)
parser.add_argument("--device", default="mps", choices=["mps", "cpu"])
args = parser.parse_args()
if not CALIBRATION.exists():
raise SystemExit("no calibration yet: run laya_triage.calibrate first")
calibration = json.loads(CALIBRATION.read_text())
expected = f"typed-decisions-{args.device}"
if calibration["run"] != expected:
raise SystemExit(f"calibration.json was fitted on {calibration['run']}, not {expected}: "
f"run laya_triage.calibrate --run {expected} first")
temperatures, threshold = calibration["temperatures"], calibration["threshold"]
questions = question_schema()
agent = load_checkpoint("typed-decisions", args.device)
agent.predict(json.loads(args.tickets[0].read_text()), questions) # warm-up, untimed
print(f"threshold {threshold:.3f} (fitted on {expected} for {calibration['target']:.0%} accuracy)")
for path in args.tickets:
state = json.loads(path.read_text())
start = time.perf_counter()
result = agent.predict(state, questions)
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"\n{path.name} ({elapsed_ms:.0f} ms)")
for name, answer in result["answers"].items():
raw = distribution(answer)
label, p = top(rescale(raw, temperatures[answer["type"]]))
if answer["type"] == "score":
label = f"level {label}" # an index into the question's legend, 0 = lowest
verdict = "act" if p >= threshold else "ESCALATE"
print(f" {name:<12} {label:<20} raw {top(raw)[1]:.2f}"
f" calibrated {p:.2f} {verdict}")
if __name__ == "__main__":
main()
Detailed breakdown
- The script exits if
results/calibration.jsonis missing, rather than silently using Laya’s raw probabilities. It also exits if the file was fitted on a different run, for example aftercalibrate --run english-mps, or when you pass--device cputo numbers fitted on MPS output. - The questions come from
question_schema(), the exact schema the temperatures and threshold were fitted on. A new question, or a reworded one, needs its own labeled cases and its own fit. - The warm-up call is untimed, for the same reason as in Step 4.
- Each line prints the raw probability Laya returned and the calibrated one, so you can see what the fit changed.
scorelabels print aslevel N, an index into that question’s legend, 0 being the lowest. Forurgency, level 3 is “Critical; requires action within the same day”; forchurn_risk, it is “Imminent: threatening to cancel, dispute or leave”.- The decision is made per answer, not per ticket. A ticket can have a confident category and an uncertain next action; the routing is automated and the reply goes to a person.
uv run python -m laya_triage.triage tickets/double-charge.json tickets/vague.json
threshold 0.739 (fitted on typed-decisions-mps for 90% accuracy)
double-charge.json (44 ms)
action escalate_to_human raw 0.40 calibrated 0.64 ESCALATE
category billing raw 0.53 calibrated 0.82 act
churn_risk level 3 raw 0.70 calibrated 0.93 act
needs_human true raw 0.63 calibrated 0.86 act
urgency level 3 raw 0.75 calibrated 0.97 act
vague.json (36 ms)
action request_information raw 0.47 calibrated 0.76 act
category account raw 0.42 calibrated 0.59 ESCALATE
churn_risk level 1 raw 0.51 calibrated 0.79 act
needs_human false raw 0.59 calibrated 0.78 act
urgency level 1 raw 0.51 calibrated 0.66 ESCALATE
Read the category rows side by side. The same question gets a confident answer
on the double-charge ticket (billing, 0.82, act) and an uncertain one on the
vague ticket (account, 0.59, escalate), which is the behaviour you want: the
uncertainty is part of the output.
What the temperatures buy is a threshold that means what it says, not better decisions. Scaling never changes which label is on top and rarely changes how answers of one type rank by confidence, so a threshold fitted on raw probabilities makes nearly the same calls: rerunning the two folds on raw probabilities gives cut-offs of 0.470 and 0.549, and 79.4% and 94.2% accuracy on the held-out halves, against 79.3% and 93.9% calibrated. The difference is that 0.739 reads as “about 74% sure” and holds roughly true, where a raw cut-off of 0.52 is a number that only works because it was fitted.
Some acted-on answers are plausible but unverified. request_information is a sensible
next step for “something seems off”, and the script acts on it at 0.76. Whether
that is acceptable is the threshold question from Step 6, answered by your
labeled data, not by this output.
Step 8: Wrap it in a Makefile
The steps above are now commands you will rerun whenever the model, the data, or
the target changes. A Makefile records them in order, and running make with
no target prints what each one does.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install first-call evaluate report calibrate triage test clean
help: ## Show this help
@grep -E '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-12s %s\n", $$1, $$2}'
install: ## Install dependencies into .venv
uv sync
first-call: ## Ask the five questions about one ticket and print three raw answers
uv run python -m laya_triage.first_call
evaluate: ## Score both checkpoints on MPS and CPU against the 100 labeled test cases
uv run python -m laya_triage.evaluate --model english --device mps
uv run python -m laya_triage.evaluate --model english --device cpu
uv run python -m laya_triage.evaluate --model typed-decisions --device mps
uv run python -m laya_triage.evaluate --model typed-decisions --device cpu
report: ## Print accuracy, latency and MPS/CPU agreement from saved results
uv run python -m laya_triage.report
calibrate: ## Fit temperatures and the act/escalate threshold
uv run python -m laya_triage.calibrate
triage: ## Triage the sample tickets with the fitted numbers
uv run python -m laya_triage.triage tickets/double-charge.json tickets/vague.json
test: ## Run the unit tests
uv run pytest -v
clean: ## Remove saved results and the test cache (keeps downloaded models)
rm -rf results .pytest_cache
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Thehelptarget reads the##comments from the file, so each target’s help text sits on the same line as the target.evaluateruns all four evaluations in the order Step 4 did, socalibratesees the fp16 MPS results.cleandeletesresults/but not the Hugging Face cache, so a clean rebuild does not download 1.7 GB of checkpoints again.- Makefile recipes must be indented with a tab, not spaces.
make
help Show this help
install Install dependencies into .venv
first-call Ask the five questions about one ticket and print three raw answers
evaluate Score both checkpoints on MPS and CPU against the 100 labeled test cases
report Print accuracy, latency and MPS/CPU agreement from saved results
calibrate Fit temperatures and the act/escalate threshold
triage Triage the sample tickets with the fitted numbers
test Run the unit tests
clean Remove saved results and the test cache (keeps downloaded models)
The full sequence from nothing is make clean evaluate report calibrate triage.
Troubleshooting
laya.load()hangs. Convai’s card documents this: if TensorFlow is installed in the same environment,transformersimports it, and its runtime can deadlock model construction. Run withUSE_TF=0in front of the command.- Everything runs on the CPU. Check that
torch.backends.mps.is_available()printsTrue(Step 1). Passingdevice="cuda", as the card’s examples do, falls back to the CPU on a Mac, with a printed warning. - The first call is much slower than later ones. That is the download on the first run and GPU setup on every run. Steps 4 and 7 exclude warm-up calls from their timings for this reason.
no calibration yet: run laya_triage.calibrate first.triage.pyneedsresults/calibration.json. Run Steps 4 and 6 (ormake evaluate calibrate) first.- A
noulanswer looks stuck on “no”. The card lists this as a known issue on the base English checkpoint (#156): thefalse:/true:option labels can outweigh the text. Its suggested workaround is to ask a two-optionchoicewith neutral keys and the yes/no wording in the descriptions. - A
choicequestion with many options answers badly. Options share a fixed budget of tokens; the card says accuracy falls off above roughly 20 options. Keep option lists short or split them into two questions.
Recap
You installed Laya 0.3.20 into a pinned uv project and ran it on the Apple GPU,
which Laya selects without being told. On 100 labeled customer-service tickets
you measured that the base checkpoint scores below a baseline that ignores the
input, while the fine-tuned laya-typed-decisions checkpoint scores 0.764 on
the CPU (0.766 on MPS), matching Convai’s published 0.764 for this workflow. On an M5 Max a five-question ticket took a median
of 59 ms on MPS against 949 ms on the CPU, and the GPU’s fp16 path changed one
answer in 500.
You then tested the calibration claim rather than accepting it. On this workflow the fine-tuned checkpoint was under-confident, one temperature per question type cut the held-out calibration error by 39% and 67% in the two folds, and a threshold fitted for 90% accuracy delivered between 79% and 94% depending on which half it was fitted on. The triage script uses those fitted numbers to act on confident answers and escalate the rest, in about 40 ms per ticket.
For any model that claims calibrated confidence, the card’s numbers are a starting point: here the size of the miscalibration matched the card while its direction did not, and it differed between two checkpoints of the same model. Fit it on labeled examples of your own inputs, and check the fit on examples it did not see.
Where to go next:
- Your own labels. Replace
load_cases()with a few hundred of your own tickets and answers, and rerunmake evaluate calibrate. If your questions differ from the benchmark’s, the base checkpoint’s numbers in Step 5 are the honest estimate of zero-shot quality, and Convai’s fine-tuning notebook is the route to a checkpoint of your own. - A service.
uv add "laya[serve]"installslaya-serve, an HTTP server that answersPOST /v1/systemone, the request shape of TypeSafe’s hosted Jev model. It binds0.0.0.0with no authentication unlessLAYA_API_KEYis set. - An MCP server. The
laya[mcp]extra exposes the same predictions as tools for an agent. - Other Apple runtimes. Laya’s README links a third-party port, laya-apple, that runs on MLX and the Apple Neural Engine. The report and calibration steps work unchanged on distributions saved from any runtime, which makes them a direct way to check a port’s accuracy as well as its speed.