File size: 10,574 Bytes
48c8658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# Train your own System-1 model

This folder is the recipe we used to train [Raya](../README.md), packaged so you can train your own
fast decision model on your own data in an afternoon.

A **System-1 model** answers one well-defined question about an input, instantly and with calibrated
probabilities: *which model should answer this prompt?*, *does this ticket need a human?*, *which team
owns this request?* It is a small encoder (a [Laya](https://huggingface.co/convaiinnovations/laya)
decision model), not an LLM. It runs in tens of milliseconds on a CPU, costs nothing per call, and you
can host it anywhere.

## The whole pipeline

```bash
pip install -r requirements.txt

# 1. Describe your task: the labels and a few ways of asking the question
cp task.example.json my_task.json

# 2. Label your inputs with two independent LLMs (skip if you already have labels)
python label.py --task my_task.json --data prompts.jsonl --out labelled.jsonl \
    --annotator <model-a> --annotator <model-b>@https://api.anthropic.com/v1/#ANTHROPIC_API_KEY

# 3. Train
python train.py --task my_task.json --data labelled.jsonl --out my-model

# 4. Check it on data it has never seen
python evaluate.py --model my-model --task my_task.json --data test.jsonl

# 5. (optional) Export for fast CPU serving, then publish
pip install -r requirements-onnx.txt
python export_onnx.py --model my-model --task my_task.json --data test.jsonl
python train.py ... --push-to-hub your-name/my-model      # or upload my-model/ yourself
```

**Try it in five minutes** with the bundled toy data (44 hand-written routing prompts, just enough to
see every step run; it is far too small to train a useful model):

```bash
python train.py --task task.example.json --data data/example.jsonl --out my-router --epochs 2
python evaluate.py --model my-router --task task.example.json --data data/example_test.jsonl
```

Then use it like any Laya model:

```python
import json, laya

model = laya.Agent("my-router")                       # a local dir or a Hub repo id
question = json.load(open("my-router/task.json"))["questions"][0]
print(model.system_one({"prompt": "Prove that √2 is irrational."}, {"route": question})["answers"]["route"])
# {'choice': 'frontier_model', 'probabilities': {...}, ...}
```

## 1. Describe the task (`task.json`)

```json
{
  "labels": ["small_model", "medium_model", "frontier_model"],
  "questions": [
    {"type": "choice", "instructions": "Route this prompt to a model.",
     "criteria": {"small_model": "simple requests", "medium_model": "moderately complex requests",
                  "frontier_model": "very hard requests"}},
    {"type": "score", "instructions": "How difficult is this prompt for an AI assistant to answer well?",
     "criteria": ["simple: a small model answers it perfectly", "moderate: needs a capable general model",
                  "hard: needs the strongest frontier model"]}
  ],
  "rubric": "Detailed labelling instructions for label.py (optional)."
}
```

- **`labels`**: the possible answers, 2 or more.
- **`questions`**: one or more *phrasings* of the same decision, written as normal Laya questions. The
  model trains on all of them, so it learns the decision rather than one exact wording. Raya was
  trained on three phrasings and scores 80–81% on each.
  - A `choice` question's `criteria` keys must be exactly your labels. Their order doesn't matter,
    because options are shuffled during training.
  - A `score` question is ordinal: one criterion per label, **in label order** (lowest first).
- **`rubric`**: what `label.py` shows the annotators. Be specific, with examples per label: the model
  can only be as consistent as its labels.

`task.example.json` is Raya's exact routing task.

## 2. Get labels (`label.py`)

Your data is JSON Lines, one input per line:

```json
{"prompt": "hi there!", "label": "small_model"}
{"prompt": "Review this contract …", "labels": ["medium_model", "frontier_model"]}
{"state": {"ticket": "You charged me twice!", "plan": "enterprise"}, "label": "human_agent"}
{"prompt": "…", "label": "…", "split": "val"}
```

- `label` is one gold answer. `labels` holds several annotators' votes: disagreements become soft
  targets (50/50 above), which is better than forcing a hard label on a genuinely ambiguous input.
- Use `state` instead of `prompt` for structured inputs (any JSON object).
- Mark rows `"split": "val"` to fix your validation set; otherwise 10% is held out at random.
- Rows labelled `"exclude"` are skipped.

**No labels yet?** `label.py` has two (or more) different LLMs label every input independently from
your rubric, which is how Raya's labels were made (Claude Opus and Claude Sonnet, blind to each other).
It speaks the OpenAI chat-completions API, so it works with OpenAI, Anthropic's OpenAI-compatible
endpoint, OpenRouter, vLLM, Ollama and others. An annotator is `MODEL[@BASE_URL][#API_KEY_ENV]`.
It resumes where it stopped, and prints how often the annotators agree: that agreement rate is
roughly the ceiling your model can reach against these labels (78% for Raya's test set).

**How much data?**

- 1,000–5,000 is a good target for a first model.
- Raya used about 10,000.
- Use **real inputs** from your product where you can, in the languages you serve.
- Don't balance the classes artificially: `train.py` already up-weights rare labels.
- Keep a separate test set you never train or validate on.

## 3. Train (`train.py`)

```bash
python train.py --task my_task.json --data labelled.jsonl --out my-model
```

What it does, in the same way as Raya's training:

- **Soft targets:** each label's share of the annotator votes, learned with cross-entropy.
- **Every question phrasing:** trained on each one, with choice options shuffled every epoch.
- **Class weights:** about 1/√(label frequency), so rare labels still count.
- **Memory:** the token-embedding table stays frozen.
- **Best epoch:** picked on validation accuracy (or `--select nll`).
- **Calibration:** a temperature is then fitted per question on validation, so that 0.9 means about 90%.

The output folder is a normal Laya checkpoint, plus `task.json` and `training_log.json`.

**Pick a starting point:**

| Flag | Starts from | When |
|---|---|---|
| *(default)* | Laya multilingual (mmBERT-base, 300M) | Most tasks, multilingual input (Raya was tested on 14 languages) |
| `--base TextCortex/raya` | Raya | LLM routing on your own traffic: adapt Raya instead of starting over |
| `--subfolder .` | Laya English (ModernBERT-large) | English-only input; a larger encoder, so slower |
| `--encoder <hf-id>` | Any Hugging Face encoder, fresh decision head | e.g. a larger multilingual encoder |
| `--base <dir or repo>` | Any Laya checkpoint | Continue from a model you trained before |

A bigger encoder was the biggest single lever in our experiments: with the same data, a large encoder
reached about 84% on Raya's benchmark where mmBERT-base topped out around 81–82%. Adding more data
barely moved the smaller model.

**Hardware:**

- **GPU:** Raya trained in about 6 minutes on one 48 GB RTX A6000 (batch 32, bf16). With the default
  batch size of 16 it should fit on a 24 GB GPU.
- **Apple Silicon or CPU:** fine for a few thousand examples. `train.py` picks CUDA, then MPS, then CPU
  automatically, and turns on gradient checkpointing off-GPU to save memory.

Useful flags: `--epochs` (3), `--batch-size` (16), `--lr-encoder` (2e-5), `--lr-head` (1e-4, or 3e-4
for a fresh head), `--max-tokens` (512), `--val-data`, `--seed`. Raya used these learning rates and
token budget with `--batch-size 32`, and its seed was chosen on validation only.

## 4. Evaluate (`evaluate.py`)

```bash
python evaluate.py --model my-model --task my_task.json --data test.jsonl [--out predictions.jsonl]
```

For each question phrasing this prints accuracy, macro-F1 and a confusion matrix on rows with a single
gold label. It also prints the "always answer the most common label" baseline, which is the number to
beat, and per-decision latency. Pass `--onnx <file>` to score an exported model.

## 5. Export and serve (`export_onnx.py`)

```bash
python export_onnx.py --model my-model --task my_task.json --data test.jsonl
```

This writes `my-model/onnx/model.onnx` (fp32) and `model-int8-blockwise.onnx`, then checks both against
PyTorch on your data. The export fails if any choice changes or probabilities drift by more than 0.001
(fp32) or 0.05 (int8). Serve either with Laya's `ONNXAgent`, which uses the same call and answer format:

```python
from laya.onnx_agent import ONNXAgent

model = ONNXAgent("my-model", onnx_path="my-model/onnx/model.onnx")
model.cfg["max_len"] = 512   # match --max-tokens
```

**Which ONNX file?** `model.onnx` matches PyTorch on any CPU. The block-wise int8 file kept Raya's accuracy
and was about 10–15% faster on x86 CPUs with VNNI instructions (Intel Cascade Lake or Alder Lake and
newer, AMD Zen 4 and newer), but slower on CPUs without VNNI and on ARM. Measure on your own hardware
before choosing it. In our tests 8 CPU threads were faster than 16.

## Tips

- **Evaluate on data you never trained on.** Always, and keep the split fixed when comparing runs.
- **Don't tune on the test set.** Choose epochs, seeds and ensembles on validation only.
- **Match the serving token budget to training** (`--max-tokens`, default 512).
- **Your labels are the ceiling.** If two good annotators agree only 75% of the time, a 90% score
  means the model learned your annotator's quirks. Tighten the rubric first.
- **Calibrate your action threshold on real traffic.** Before acting automatically on a prediction
  (for example, only escalate when p(frontier) > 0.6), set the threshold from what your real traffic
  looks like.

## Files

| File | Purpose |
|---|---|
| `task.example.json` | Raya's routing task, a template for yours |
| `common.py` | Task and data format, validation (read its docstring for the full format) |
| `label.py` | Label inputs with independent LLM annotators |
| `train.py` | Fine-tune and calibrate |
| `evaluate.py` | Accuracy, macro-F1, confusion, latency |
| `export_onnx.py` | ONNX export (fp32 + block-wise int8) with equivalence checks |
| `data/example.jsonl`, `data/example_test.jsonl` | Tiny hand-written demo data for training and evaluation (not Raya's training data) |
| `data/unlabelled.jsonl` | A few unlabelled prompts to try `label.py` on |

Tested with laya 0.3.20, torch 2.14, transformers 5.17 and onnxruntime 1.30. The training data behind
Raya is not published. This code is Apache-2.0, like Raya and Laya.