cfahlgren1 HF Staff commited on
Commit
3f86878
·
verified ·
1 Parent(s): b89a598

v2: training script, eval harness, eval set and baseline metrics

Browse files
v2/eval.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11,<3.14"
3
+ # dependencies = [
4
+ # "torch>=2.6,<2.8",
5
+ # "datasets>=3.0",
6
+ # "numpy<3",
7
+ # "onnxruntime>=1.20",
8
+ # "transformers>=4.46",
9
+ # "tokenizers>=0.20",
10
+ # ]
11
+ # ///
12
+ """Scores autocomplete models the way people use them: type real text, pause, and see if the gray suggestion is right.
13
+
14
+ Every model sees the same pauses in the same text and is decoded with the same rule as offline.page:
15
+ follow the model's top pick while the running product of probabilities stays above a cutoff, and show
16
+ the text up to the last finished word. A suggestion is right when the text really continues that way.
17
+
18
+ Two scenarios:
19
+ typing the cursor is at the end of what's written so far.
20
+ editing the cursor is mid-sentence and real text follows a gap of 2-6 words. Only fill-in-the-middle
21
+ models are given that text; the rest see only what's before the cursor.
22
+
23
+ Usage:
24
+ uv run eval.py --model tinynext-v1 --onnx model_quint8.onnx
25
+ uv run eval.py --model smollm2-135m --hf HuggingFaceTB/SmolLM2-135M
26
+ uv run eval.py --table # compare every metrics/*.json
27
+ """
28
+
29
+ import argparse
30
+ import hashlib
31
+ import json
32
+ import random
33
+ import re
34
+ import statistics
35
+ import time
36
+ from collections.abc import Callable
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+ import numpy as np
41
+
42
+ HERE = Path(__file__).parent
43
+ EVAL_SET = HERE / "eval_set.json"
44
+ METRICS = HERE / "metrics"
45
+
46
+ CUTOFFS = (0.3, 0.4, 0.5, 0.6, 0.7)
47
+ MAIN_CUTOFF = 0.5 # what offline.page uses
48
+ PAUSE_EVERY = 12 # characters between pauses
49
+ MIN_SHOWN_CHARS = 4 # offline.page hides shorter suggestions
50
+ MAX_WORDS = 6
51
+ MAX_CHARS = 48
52
+ DOMAINS = ("articles", "messages", "email")
53
+ SCENARIOS = ("typing", "editing")
54
+ PUNCTUATION = ".,!?;:"
55
+
56
+ # ---- Eval text -------------------------------------------------------------------------------------------------
57
+ # Kept apart from training: Wikipedia isn't in either model's data, OpenAssistant validation isn't in SmolTalk's
58
+ # training split, and the emails come from the senders the original enron-autocomplete held out for its test set.
59
+
60
+ CUT_REPLY = re.compile(
61
+ r"(?im)^\s*(?:-+\s*original message|-+\s*forwarded by|-+\s*inline attachment|from:\s|to:\s|sent:\s"
62
+ r"|subject:\s|cc:\s|.*\bwrote:\s*$|_{5,}|-{5,}|={5,}|\*{5,}|\d{1,2}/\d{1,2}/\d{2,4}\s+\d{1,2}:\d{2})"
63
+ )
64
+ SENSITIVE = re.compile(r"(?i)https?://|www\.|\S+@\S+|\(?\b\d{3}\)?[-. ]\d{3}[-. ]\d{4}\b|\b\d{3}-\d{2}-\d{4}\b")
65
+ BULK_SENDER = re.compile(r"(?i)announce|newsletter|mailer|outlook\.team|^all[._]|general\.|chairman|enron\.(?:on|net)")
66
+ SMART_CHARS = str.maketrans({"‘": "'", "’": "'", "“": '"', "”": '"', "–": "-", "—": "-", "\t": " "})
67
+
68
+
69
+ def clean_email(body: str) -> list[str]:
70
+ """The same cleaning enron-autocomplete was trained with."""
71
+ text = body.replace("\r\n", "\n").replace("=20", " ").replace("=\n", "").translate(SMART_CHARS)
72
+ text = "\n".join(line for line in text.split("\n") if not line.lstrip().startswith(">"))
73
+ cut = CUT_REPLY.search(text)
74
+ if cut:
75
+ text = text[: cut.start()]
76
+ text = re.sub(r"(?m)^-- *\n[\s\S]*", "", text)
77
+ paragraphs = []
78
+ for block in re.split(r"\n\s*\n", text):
79
+ lines = [re.sub(r" +", " ", line).strip() for line in block.split("\n") if line.strip()]
80
+ if not lines:
81
+ continue
82
+ joined = lines[0]
83
+ for previous, line in zip(lines, lines[1:]):
84
+ joined += (" " if len(previous) >= 50 else "\n") + line
85
+ if SENSITIVE.search(joined) or sum(c.isalpha() for c in joined) < 0.6 * len(joined):
86
+ continue
87
+ paragraphs.append(joined)
88
+ return paragraphs
89
+
90
+
91
+ def is_held_out_sender(sender: str) -> bool:
92
+ """enron-autocomplete put senders whose hash bucket is under 5 in its test split."""
93
+ sender = sender.lower()
94
+ if "@enron.com" not in sender or BULK_SENDER.search(sender):
95
+ return False
96
+ return int(hashlib.sha1(sender.encode()).hexdigest(), 16) % 100 < 5
97
+
98
+
99
+ def ascii_paragraphs(text: str, min_chars: int = 40) -> list[str]:
100
+ text = text.translate(SMART_CHARS).encode("ascii", "ignore").decode()
101
+ return [p.strip() for p in re.split(r"\n\s*\n|\n", text) if len(p.strip()) >= min_chars]
102
+
103
+
104
+ def build_eval_set(docs_per_domain: int = 80) -> dict[str, list[list[str]]]:
105
+ from datasets import load_dataset
106
+
107
+ sets: dict[str, list[list[str]]] = {domain: [] for domain in DOMAINS}
108
+ wiki = load_dataset(
109
+ "wikimedia/wikipedia", data_files="20231101.en/train-00040-of-00041.parquet", split="train", streaming=True
110
+ )
111
+ for row in wiki:
112
+ paragraphs = [p for p in ascii_paragraphs(row["text"], 80) if not p.endswith(":")][:6]
113
+ if len(paragraphs) >= 3:
114
+ sets["articles"].append(paragraphs)
115
+ if len(sets["articles"]) >= docs_per_domain:
116
+ break
117
+
118
+ for row in load_dataset("OpenAssistant/oasst1", split="validation"):
119
+ if row["lang"] == "en" and "```" not in row["text"]:
120
+ paragraphs = ascii_paragraphs(row["text"])[:6]
121
+ if paragraphs:
122
+ sets["messages"].append(paragraphs)
123
+ if len(sets["messages"]) >= docs_per_domain:
124
+ break
125
+
126
+ for row in load_dataset("corbt/enron-emails", split="train", streaming=True):
127
+ if is_held_out_sender(row["from"] or ""):
128
+ paragraphs = [p for p in clean_email(row["body"] or "") if len(p) >= 40][:6]
129
+ if paragraphs:
130
+ sets["email"].append(paragraphs)
131
+ if len(sets["email"]) >= docs_per_domain:
132
+ break
133
+ return sets
134
+
135
+
136
+ def load_eval_set() -> dict[str, list[list[str]]]:
137
+ if not EVAL_SET.exists():
138
+ EVAL_SET.write_text(json.dumps(build_eval_set(), indent=1))
139
+ return json.loads(EVAL_SET.read_text())
140
+
141
+
142
+ @dataclass
143
+ class Pause:
144
+ domain: str
145
+ scenario: str
146
+ before: str # the document up to the cursor
147
+ after: str # text after the cursor that a model may see (empty when typing)
148
+ truth: str # what the person actually typed next
149
+
150
+
151
+ def pauses(eval_set: dict[str, list[list[str]]], max_per_domain: int) -> list[Pause]:
152
+ rng = random.Random(0)
153
+ found: list[Pause] = []
154
+ for domain in DOMAINS:
155
+ in_domain: list[Pause] = []
156
+ for paragraphs in eval_set[domain]:
157
+ for index, paragraph in enumerate(paragraphs):
158
+ earlier = "\n".join(paragraphs[:index])
159
+ for at in range(PAUSE_EVERY, len(paragraph) - 8, PAUSE_EVERY):
160
+ before = (earlier + "\n" if earlier else "") + paragraph[:at]
161
+ truth = paragraph[at:]
162
+ in_domain.append(Pause(domain, "typing", before, "", truth))
163
+ # Editing: a gap of 2-6 words, then the rest of the paragraph as it was written.
164
+ gap_words = rng.randint(2, 6)
165
+ gap = re.match(r"(\S*\s+){%d}" % gap_words, truth)
166
+ if gap and len(truth) > gap.end() + 10:
167
+ in_domain.append(Pause(domain, "editing", before, truth[gap.end():], truth))
168
+ rng.shuffle(in_domain)
169
+ found += [p for p in in_domain if p.scenario == "typing"][:max_per_domain]
170
+ found += [p for p in in_domain if p.scenario == "editing"][:max_per_domain]
171
+ return found
172
+
173
+
174
+ # ---- Decoding -------------------------------------------------------------------------------------------------
175
+
176
+
177
+ @dataclass
178
+ class Trajectory:
179
+ """Suggestions that end at a word boundary, with the model's confidence in each. Greedy decoding gives the
180
+ same path for every cutoff, so one decode answers all of them."""
181
+
182
+ candidates: list[tuple[str, float]] = field(default_factory=list)
183
+ milliseconds: float = 0.0
184
+
185
+ def at(self, cutoff: float) -> str:
186
+ best = ""
187
+ for text, confidence in self.candidates:
188
+ if confidence >= cutoff:
189
+ best = text
190
+ return best.rstrip()
191
+
192
+
193
+ class Model:
194
+ name: str
195
+ sees_after: bool = False
196
+
197
+ def suggest(self, before: str, after: str) -> Trajectory:
198
+ raise NotImplementedError
199
+
200
+
201
+ class CharGRU(Model):
202
+ """enron-autocomplete / tinynext v1: one character per step, reading only the current paragraph."""
203
+
204
+ HIDDEN, END, NEWLINE, FIRST, OFFSET = 512, 1, 2, 3, 29
205
+
206
+ def __init__(self, name: str, onnx_path: str):
207
+ import onnxruntime as ort
208
+
209
+ self.name = name
210
+ self.session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
211
+ self.read_text: str | None = None
212
+ self.reading = None
213
+
214
+ def step(self, char_id: int, state):
215
+ logits, state = self.session.run(None, {"input_id": np.array([char_id], np.int64), "state": state})
216
+ return logits[0], state
217
+
218
+ def encode(self, char: str) -> int | None:
219
+ if char == "\n":
220
+ return self.NEWLINE
221
+ code = ord(char)
222
+ return code - self.OFFSET if 32 <= code <= 126 else None
223
+
224
+ def read(self, text: str):
225
+ if self.read_text is None or not text.startswith(self.read_text):
226
+ self.reading = self.step(self.END, np.zeros((1, self.HIDDEN), np.float32))
227
+ self.read_text = ""
228
+ for char in text[len(self.read_text):]:
229
+ char_id = self.encode(char)
230
+ if char_id is not None:
231
+ self.reading = self.step(char_id, self.reading[1])
232
+ self.read_text = text
233
+ return self.reading
234
+
235
+ def suggest(self, before: str, after: str) -> Trajectory:
236
+ began = time.perf_counter()
237
+ logits, state = self.read(before.split("\n")[-1])
238
+ trajectory, run, confidence, words = Trajectory(), "", 1.0, 0
239
+ while len(run) < MAX_CHARS and confidence >= min(CUTOFFS):
240
+ probabilities = np.exp(logits - logits.max())
241
+ probabilities /= probabilities.sum()
242
+ char_id = int(probabilities.argmax())
243
+ if char_id < self.FIRST:
244
+ if run:
245
+ trajectory.candidates.append((run, confidence))
246
+ break
247
+ char = chr(char_id + self.OFFSET)
248
+ if char == " ":
249
+ trajectory.candidates.append((run, confidence))
250
+ words += 1
251
+ if words >= MAX_WORDS:
252
+ break
253
+ confidence *= float(probabilities[char_id])
254
+ run += char
255
+ if char in PUNCTUATION:
256
+ trajectory.candidates.append((run, confidence))
257
+ logits, state = self.step(char_id, state)
258
+ trajectory.milliseconds = (time.perf_counter() - began) * 1000
259
+ return trajectory
260
+
261
+
262
+ class TokenLM(Model):
263
+ """A word-piece model. `next_logits(ids, cache)` runs one forward pass and returns (last logits, cache).
264
+
265
+ The word being typed is "healed": the prompt stops before it, and the first generated pieces must spell it
266
+ out, so a half-typed word like "resig" can still become "resignation" in one piece.
267
+ """
268
+
269
+ def __init__(self, name: str, next_logits: Callable, encode: Callable[[str], list[int]], token_texts: list[str],
270
+ stop_ids: set[int], prompt: Callable[[list[int], list[int]], list[int]] | None = None,
271
+ context_chars: int = 1500, suffix_chars: int = 300):
272
+ self.name = name
273
+ self.next_logits = next_logits
274
+ self.encode = encode
275
+ self.token_texts = token_texts
276
+ self.stop_ids = stop_ids
277
+ self.prompt = prompt
278
+ self.sees_after = prompt is not None
279
+ self.context_chars = context_chars
280
+ self.suffix_chars = suffix_chars
281
+ lengths = np.array([len(t) for t in token_texts])
282
+ self.nonempty = lengths > 0
283
+
284
+ def allowed(self, need: str) -> np.ndarray:
285
+ """Pieces consistent with the rest of the half-typed word."""
286
+ return np.array([bool(t) and (need.startswith(t) or t.startswith(need)) for t in self.token_texts])
287
+
288
+ def suggest(self, before: str, after: str) -> Trajectory:
289
+ began = time.perf_counter()
290
+ before = before[-self.context_chars:]
291
+ # The half-typed word keeps its leading space, since word pieces start with one. A line break stays in the prompt.
292
+ space, newline = before.rfind(" "), before.rfind("\n")
293
+ if space > newline:
294
+ stem, need = before[:space], before[space:]
295
+ else:
296
+ stem, need = before[: newline + 1], before[newline + 1:]
297
+ prefix_ids = self.encode(stem)
298
+ if self.prompt:
299
+ ids = self.prompt(prefix_ids, self.encode(after[: self.suffix_chars]) if after else [])
300
+ else:
301
+ ids = prefix_ids
302
+ logits, cache = self.next_logits(ids, None)
303
+
304
+ trajectory, generated, confidence = Trajectory(), "", 1.0
305
+ for _ in range(16):
306
+ logits = logits.astype(np.float64)
307
+ if need:
308
+ mask = self.allowed(need)
309
+ if not mask.any():
310
+ break
311
+ logits = np.where(mask, logits, -np.inf)
312
+ probabilities = np.exp(logits - logits.max())
313
+ probabilities /= probabilities.sum()
314
+ token = int(probabilities.argmax())
315
+ text = self.token_texts[token]
316
+ if token in self.stop_ids or "\n" in text:
317
+ if generated:
318
+ trajectory.candidates.append((generated, confidence))
319
+ break
320
+ if need:
321
+ consumed = min(len(need), len(text))
322
+ text, need = text[consumed:], need[consumed:]
323
+ if text[:1].isspace() and generated:
324
+ trajectory.candidates.append((generated, confidence))
325
+ confidence *= float(probabilities[token])
326
+ generated += text
327
+ if generated.rstrip()[-1:] in PUNCTUATION and generated.strip():
328
+ trajectory.candidates.append((generated, confidence))
329
+ if confidence < min(CUTOFFS) or len(generated) > MAX_CHARS or len(generated.split()) > MAX_WORDS:
330
+ break
331
+ logits, cache = self.next_logits([token], cache)
332
+ trajectory.milliseconds = (time.perf_counter() - began) * 1000
333
+ return trajectory
334
+
335
+
336
+ def hf_causal_lm(name: str, repo: str) -> TokenLM:
337
+ import torch
338
+ from transformers import AutoModelForCausalLM, AutoTokenizer
339
+
340
+ device = "mps" if torch.backends.mps.is_available() else "cuda" if torch.cuda.is_available() else "cpu"
341
+ tokenizer = AutoTokenizer.from_pretrained(repo)
342
+ model = AutoModelForCausalLM.from_pretrained(repo, torch_dtype=torch.float32).to(device).eval()
343
+
344
+ @torch.no_grad()
345
+ def next_logits(ids, cache):
346
+ out = model(torch.tensor([ids], device=device), past_key_values=cache, use_cache=True)
347
+ return out.logits[0, -1].float().cpu().numpy(), out.past_key_values
348
+
349
+ texts = [tokenizer.decode([i]) for i in range(len(tokenizer))]
350
+ stops = {tokenizer.eos_token_id} if tokenizer.eos_token_id is not None else set()
351
+ encode = lambda text: tokenizer.encode(text, add_special_tokens=False) or [tokenizer.eos_token_id]
352
+ return TokenLM(name, next_logits, encode, texts, stops)
353
+
354
+
355
+ # ---- Scoring ---------------------------------------------------------------------------------------------------
356
+
357
+
358
+ def word_by_word(suggestion: str, truth: str) -> int:
359
+ """Characters you'd keep accepting a word at a time until a word is wrong."""
360
+ kept = 0
361
+ for match in re.finditer(r"\s*\S+", suggestion):
362
+ if truth.startswith(suggestion[: match.end()]):
363
+ kept = match.end()
364
+ else:
365
+ break
366
+ return kept
367
+
368
+
369
+ def score(model: Model, all_pauses: list[Pause]) -> dict:
370
+ results: dict = {"model": model.name, "sees_after": model.sees_after, "cutoffs": {}, "examples": [], "latency_ms": {}}
371
+ latencies: list[float] = []
372
+ raw = []
373
+ for index, pause in enumerate(all_pauses):
374
+ trajectory = model.suggest(pause.before, pause.after if model.sees_after else "")
375
+ latencies.append(trajectory.milliseconds)
376
+ raw.append((pause, trajectory))
377
+ if index % 500 == 0:
378
+ print(f" {model.name}: {index}/{len(all_pauses)} pauses, median {statistics.median(latencies):.1f} ms", flush=True)
379
+
380
+ for cutoff in CUTOFFS:
381
+ table: dict = {}
382
+ for domain in DOMAINS:
383
+ for scenario in SCENARIOS:
384
+ rows = [(p, t.at(cutoff)) for p, t in raw if p.domain == domain and p.scenario == scenario]
385
+ if not rows:
386
+ continue
387
+ shown = [(p, s) for p, s in rows if len(s) >= MIN_SHOWN_CHARS]
388
+ right = [(p, s) for p, s in shown if p.truth.startswith(s)]
389
+ multi = [(p, s) for p, s in shown if len(s.split()) >= 2]
390
+ multi_right = [(p, s) for p, s in multi if p.truth.startswith(s)]
391
+ typed = len(rows) * PAUSE_EVERY
392
+ table[f"{domain}/{scenario}"] = {
393
+ "pauses": len(rows),
394
+ "shown": round(len(shown) / len(rows), 4),
395
+ "right": round(len(right) / max(1, len(shown)), 4),
396
+ "avg_right_chars": round(sum(len(s) for _, s in right) / max(1, len(right)), 2),
397
+ "multiword_shown": round(len(multi) / len(rows), 4),
398
+ "multiword_right": round(len(multi_right) / max(1, len(multi)), 4),
399
+ "saved_with_tab": round(sum(len(s) for _, s in right) / typed, 4),
400
+ "saved_word_by_word": round(sum(word_by_word(s, p.truth) for p, s in shown) / typed, 4),
401
+ }
402
+ results["cutoffs"][str(cutoff)] = table
403
+
404
+ for pause, trajectory in raw[:: max(1, len(raw) // 60)]:
405
+ suggestion = trajectory.at(MAIN_CUTOFF)
406
+ if len(suggestion) >= MIN_SHOWN_CHARS:
407
+ results["examples"].append({
408
+ "domain": pause.domain, "scenario": pause.scenario, "before": pause.before[-60:],
409
+ "suggestion": suggestion, "truth": pause.truth[: len(suggestion) + 12], "right": pause.truth.startswith(suggestion),
410
+ })
411
+ results["latency_ms"] = {
412
+ "p50": round(statistics.median(latencies), 2),
413
+ "p95": round(float(np.percentile(latencies, 95)), 2),
414
+ "note": "Measured in this eval process, not in a browser.",
415
+ }
416
+ return results
417
+
418
+
419
+ # ---- Report ----------------------------------------------------------------------------------------------------
420
+
421
+
422
+ def table(paths: list[Path]) -> str:
423
+ runs = [json.loads(p.read_text()) for p in paths]
424
+ lines = []
425
+ for scenario in SCENARIOS:
426
+ lines.append(f"\n### {scenario.capitalize()}, {int(MAIN_CUTOFF * 100)}% cutoff, suggestions of {MIN_SHOWN_CHARS}+ characters\n")
427
+ lines.append("| Model | Domain | Shown | Right | Avg right | 2+ words shown | 2+ words right | Saved (Tab) | Saved (word by word) |")
428
+ lines.append("|---|---|---|---|---|---|---|---|---|")
429
+ for run in runs:
430
+ for domain in DOMAINS:
431
+ m = run["cutoffs"][str(MAIN_CUTOFF)].get(f"{domain}/{scenario}")
432
+ if not m:
433
+ continue
434
+ lines.append(
435
+ f"| {run['model']} | {domain} | {m['shown']:.0%} | {m['right']:.0%} | {m['avg_right_chars']:.1f} ch "
436
+ f"| {m['multiword_shown']:.0%} | {m['multiword_right']:.0%} | {m['saved_with_tab']:.1%} | {m['saved_word_by_word']:.1%} |"
437
+ )
438
+ lines.append("\n| Model | Latency p50 | p95 |\n|---|---|---|")
439
+ for run in runs:
440
+ lines.append(f"| {run['model']} | {run['latency_ms']['p50']} ms | {run['latency_ms']['p95']} ms |")
441
+ return "\n".join(lines)
442
+
443
+
444
+ def main():
445
+ parser = argparse.ArgumentParser()
446
+ parser.add_argument("--model", help="Name for the metrics file")
447
+ parser.add_argument("--onnx", help="A character-level GRU exported like enron-autocomplete")
448
+ parser.add_argument("--hf", help="A Hugging Face causal LM to evaluate, e.g. HuggingFaceTB/SmolLM2-135M")
449
+ parser.add_argument("--max-pauses", type=int, default=1000, help="Per domain and scenario")
450
+ parser.add_argument("--table", action="store_true", help="Print a comparison of every metrics/*.json")
451
+ args = parser.parse_args()
452
+
453
+ if args.table:
454
+ print(table(sorted(METRICS.glob("*.json"))))
455
+ return
456
+ if args.onnx:
457
+ model: Model = CharGRU(args.model, args.onnx)
458
+ elif args.hf:
459
+ model = hf_causal_lm(args.model, args.hf)
460
+ else:
461
+ parser.error("Pass --onnx, --hf or --table. train.py evaluates its own model through score().")
462
+ all_pauses = pauses(load_eval_set(), args.max_pauses)
463
+ print(f"{len(all_pauses)} pauses", flush=True)
464
+ results = score(model, all_pauses)
465
+ METRICS.mkdir(exist_ok=True)
466
+ (METRICS / f"{args.model}.json").write_text(json.dumps(results, indent=1))
467
+ print(table([METRICS / f"{args.model}.json"]))
468
+
469
+
470
+ if __name__ == "__main__":
471
+ main()
v2/eval_set.json ADDED
The diff for this file is too large to render. See raw diff
 
v2/metrics/smollm2-135m.json ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "smollm2-135m",
3
+ "sees_after": false,
4
+ "cutoffs": {
5
+ "0.3": {
6
+ "articles/typing": {
7
+ "pauses": 400,
8
+ "shown": 0.33,
9
+ "right": 0.6515,
10
+ "avg_right_chars": 7.33,
11
+ "multiword_shown": 0.175,
12
+ "multiword_right": 0.6286,
13
+ "saved_with_tab": 0.1313,
14
+ "saved_word_by_word": 0.1462
15
+ },
16
+ "articles/editing": {
17
+ "pauses": 400,
18
+ "shown": 0.3175,
19
+ "right": 0.685,
20
+ "avg_right_chars": 6.25,
21
+ "multiword_shown": 0.1475,
22
+ "multiword_right": 0.6271,
23
+ "saved_with_tab": 0.1133,
24
+ "saved_word_by_word": 0.1221
25
+ },
26
+ "messages/typing": {
27
+ "pauses": 400,
28
+ "shown": 0.4375,
29
+ "right": 0.6057,
30
+ "avg_right_chars": 9.35,
31
+ "multiword_shown": 0.3125,
32
+ "multiword_right": 0.6,
33
+ "saved_with_tab": 0.2065,
34
+ "saved_word_by_word": 0.25
35
+ },
36
+ "messages/editing": {
37
+ "pauses": 400,
38
+ "shown": 0.44,
39
+ "right": 0.6136,
40
+ "avg_right_chars": 9.51,
41
+ "multiword_shown": 0.2825,
42
+ "multiword_right": 0.5841,
43
+ "saved_with_tab": 0.214,
44
+ "saved_word_by_word": 0.2498
45
+ },
46
+ "email/typing": {
47
+ "pauses": 400,
48
+ "shown": 0.2725,
49
+ "right": 0.5321,
50
+ "avg_right_chars": 5.97,
51
+ "multiword_shown": 0.0875,
52
+ "multiword_right": 0.4286,
53
+ "saved_with_tab": 0.0721,
54
+ "saved_word_by_word": 0.0879
55
+ },
56
+ "email/editing": {
57
+ "pauses": 400,
58
+ "shown": 0.27,
59
+ "right": 0.6204,
60
+ "avg_right_chars": 6.22,
61
+ "multiword_shown": 0.1175,
62
+ "multiword_right": 0.6383,
63
+ "saved_with_tab": 0.0869,
64
+ "saved_word_by_word": 0.0942
65
+ }
66
+ },
67
+ "0.4": {
68
+ "articles/typing": {
69
+ "pauses": 400,
70
+ "shown": 0.25,
71
+ "right": 0.72,
72
+ "avg_right_chars": 7.04,
73
+ "multiword_shown": 0.115,
74
+ "multiword_right": 0.6739,
75
+ "saved_with_tab": 0.1056,
76
+ "saved_word_by_word": 0.1131
77
+ },
78
+ "articles/editing": {
79
+ "pauses": 400,
80
+ "shown": 0.2375,
81
+ "right": 0.7895,
82
+ "avg_right_chars": 6.17,
83
+ "multiword_shown": 0.1,
84
+ "multiword_right": 0.75,
85
+ "saved_with_tab": 0.0965,
86
+ "saved_word_by_word": 0.1008
87
+ },
88
+ "messages/typing": {
89
+ "pauses": 400,
90
+ "shown": 0.3625,
91
+ "right": 0.6759,
92
+ "avg_right_chars": 8.56,
93
+ "multiword_shown": 0.2375,
94
+ "multiword_right": 0.6421,
95
+ "saved_with_tab": 0.1748,
96
+ "saved_word_by_word": 0.2052
97
+ },
98
+ "messages/editing": {
99
+ "pauses": 400,
100
+ "shown": 0.3825,
101
+ "right": 0.6993,
102
+ "avg_right_chars": 8.83,
103
+ "multiword_shown": 0.2225,
104
+ "multiword_right": 0.6742,
105
+ "saved_with_tab": 0.1969,
106
+ "saved_word_by_word": 0.2167
107
+ },
108
+ "email/typing": {
109
+ "pauses": 400,
110
+ "shown": 0.2075,
111
+ "right": 0.6747,
112
+ "avg_right_chars": 5.62,
113
+ "multiword_shown": 0.0375,
114
+ "multiword_right": 0.6667,
115
+ "saved_with_tab": 0.0656,
116
+ "saved_word_by_word": 0.0688
117
+ },
118
+ "email/editing": {
119
+ "pauses": 400,
120
+ "shown": 0.2025,
121
+ "right": 0.6914,
122
+ "avg_right_chars": 5.43,
123
+ "multiword_shown": 0.065,
124
+ "multiword_right": 0.6538,
125
+ "saved_with_tab": 0.0633,
126
+ "saved_word_by_word": 0.0667
127
+ }
128
+ },
129
+ "0.5": {
130
+ "articles/typing": {
131
+ "pauses": 400,
132
+ "shown": 0.185,
133
+ "right": 0.7973,
134
+ "avg_right_chars": 6.61,
135
+ "multiword_shown": 0.0675,
136
+ "multiword_right": 0.8148,
137
+ "saved_with_tab": 0.0813,
138
+ "saved_word_by_word": 0.0829
139
+ },
140
+ "articles/editing": {
141
+ "pauses": 400,
142
+ "shown": 0.18,
143
+ "right": 0.8889,
144
+ "avg_right_chars": 5.92,
145
+ "multiword_shown": 0.07,
146
+ "multiword_right": 0.8214,
147
+ "saved_with_tab": 0.079,
148
+ "saved_word_by_word": 0.0819
149
+ },
150
+ "messages/typing": {
151
+ "pauses": 400,
152
+ "shown": 0.31,
153
+ "right": 0.7823,
154
+ "avg_right_chars": 8.3,
155
+ "multiword_shown": 0.185,
156
+ "multiword_right": 0.7703,
157
+ "saved_with_tab": 0.1677,
158
+ "saved_word_by_word": 0.1773
159
+ },
160
+ "messages/editing": {
161
+ "pauses": 400,
162
+ "shown": 0.32,
163
+ "right": 0.7969,
164
+ "avg_right_chars": 8.48,
165
+ "multiword_shown": 0.1675,
166
+ "multiword_right": 0.8209,
167
+ "saved_with_tab": 0.1802,
168
+ "saved_word_by_word": 0.1913
169
+ },
170
+ "email/typing": {
171
+ "pauses": 400,
172
+ "shown": 0.1625,
173
+ "right": 0.7538,
174
+ "avg_right_chars": 5.61,
175
+ "multiword_shown": 0.0275,
176
+ "multiword_right": 0.8182,
177
+ "saved_with_tab": 0.0573,
178
+ "saved_word_by_word": 0.0585
179
+ },
180
+ "email/editing": {
181
+ "pauses": 400,
182
+ "shown": 0.1575,
183
+ "right": 0.7302,
184
+ "avg_right_chars": 5.24,
185
+ "multiword_shown": 0.045,
186
+ "multiword_right": 0.7778,
187
+ "saved_with_tab": 0.0502,
188
+ "saved_word_by_word": 0.0521
189
+ }
190
+ },
191
+ "0.6": {
192
+ "articles/typing": {
193
+ "pauses": 400,
194
+ "shown": 0.15,
195
+ "right": 0.9167,
196
+ "avg_right_chars": 6.11,
197
+ "multiword_shown": 0.0475,
198
+ "multiword_right": 0.8947,
199
+ "saved_with_tab": 0.07,
200
+ "saved_word_by_word": 0.071
201
+ },
202
+ "articles/editing": {
203
+ "pauses": 400,
204
+ "shown": 0.1525,
205
+ "right": 0.9344,
206
+ "avg_right_chars": 5.46,
207
+ "multiword_shown": 0.055,
208
+ "multiword_right": 0.9545,
209
+ "saved_with_tab": 0.0648,
210
+ "saved_word_by_word": 0.066
211
+ },
212
+ "messages/typing": {
213
+ "pauses": 400,
214
+ "shown": 0.2625,
215
+ "right": 0.8476,
216
+ "avg_right_chars": 7.99,
217
+ "multiword_shown": 0.145,
218
+ "multiword_right": 0.8103,
219
+ "saved_with_tab": 0.1481,
220
+ "saved_word_by_word": 0.1554
221
+ },
222
+ "messages/editing": {
223
+ "pauses": 400,
224
+ "shown": 0.2575,
225
+ "right": 0.8641,
226
+ "avg_right_chars": 8.08,
227
+ "multiword_shown": 0.1325,
228
+ "multiword_right": 0.9057,
229
+ "saved_with_tab": 0.1498,
230
+ "saved_word_by_word": 0.1544
231
+ },
232
+ "email/typing": {
233
+ "pauses": 400,
234
+ "shown": 0.125,
235
+ "right": 0.82,
236
+ "avg_right_chars": 5.44,
237
+ "multiword_shown": 0.0175,
238
+ "multiword_right": 0.8571,
239
+ "saved_with_tab": 0.0465,
240
+ "saved_word_by_word": 0.0471
241
+ },
242
+ "email/editing": {
243
+ "pauses": 400,
244
+ "shown": 0.11,
245
+ "right": 0.7955,
246
+ "avg_right_chars": 5.23,
247
+ "multiword_shown": 0.035,
248
+ "multiword_right": 0.7857,
249
+ "saved_with_tab": 0.0381,
250
+ "saved_word_by_word": 0.039
251
+ }
252
+ },
253
+ "0.7": {
254
+ "articles/typing": {
255
+ "pauses": 400,
256
+ "shown": 0.1175,
257
+ "right": 0.9149,
258
+ "avg_right_chars": 6.09,
259
+ "multiword_shown": 0.03,
260
+ "multiword_right": 0.9167,
261
+ "saved_with_tab": 0.0546,
262
+ "saved_word_by_word": 0.0548
263
+ },
264
+ "articles/editing": {
265
+ "pauses": 400,
266
+ "shown": 0.0925,
267
+ "right": 0.9459,
268
+ "avg_right_chars": 5.51,
269
+ "multiword_shown": 0.03,
270
+ "multiword_right": 0.9167,
271
+ "saved_with_tab": 0.0402,
272
+ "saved_word_by_word": 0.0415
273
+ },
274
+ "messages/typing": {
275
+ "pauses": 400,
276
+ "shown": 0.1925,
277
+ "right": 0.9351,
278
+ "avg_right_chars": 7.86,
279
+ "multiword_shown": 0.0925,
280
+ "multiword_right": 0.8919,
281
+ "saved_with_tab": 0.1179,
282
+ "saved_word_by_word": 0.1206
283
+ },
284
+ "messages/editing": {
285
+ "pauses": 400,
286
+ "shown": 0.1975,
287
+ "right": 0.9367,
288
+ "avg_right_chars": 7.42,
289
+ "multiword_shown": 0.0875,
290
+ "multiword_right": 0.9143,
291
+ "saved_with_tab": 0.1144,
292
+ "saved_word_by_word": 0.1175
293
+ },
294
+ "email/typing": {
295
+ "pauses": 400,
296
+ "shown": 0.095,
297
+ "right": 0.8684,
298
+ "avg_right_chars": 5.55,
299
+ "multiword_shown": 0.0125,
300
+ "multiword_right": 1.0,
301
+ "saved_with_tab": 0.0381,
302
+ "saved_word_by_word": 0.0381
303
+ },
304
+ "email/editing": {
305
+ "pauses": 400,
306
+ "shown": 0.075,
307
+ "right": 0.8,
308
+ "avg_right_chars": 5.25,
309
+ "multiword_shown": 0.0225,
310
+ "multiword_right": 0.7778,
311
+ "saved_with_tab": 0.0262,
312
+ "saved_word_by_word": 0.0271
313
+ }
314
+ }
315
+ },
316
+ "examples": [
317
+ {
318
+ "domain": "articles",
319
+ "scenario": "typing",
320
+ "before": "ROME KAVALKADE\". The segment featured acrobats, bareback rid",
321
+ "suggestion": "ers,",
322
+ "truth": "ers, clowns, tig",
323
+ "right": true
324
+ },
325
+ {
326
+ "domain": "articles",
327
+ "scenario": "typing",
328
+ "before": "Street/Perimeter Road, just south of the Miami International",
329
+ "suggestion": " Airport.",
330
+ "truth": " Airport runway.",
331
+ "right": false
332
+ },
333
+ {
334
+ "domain": "articles",
335
+ "scenario": "editing",
336
+ "before": "le tree, with a number of ecotypes, and in forms closely res",
337
+ "suggestion": "embling",
338
+ "truth": "embling other speci",
339
+ "right": true
340
+ },
341
+ {
342
+ "domain": "messages",
343
+ "scenario": "typing",
344
+ "before": " L'Hpital's Rule can be used to analyze the behavior of thes",
345
+ "suggestion": "e systems",
346
+ "truth": "e systems under speci",
347
+ "right": true
348
+ },
349
+ {
350
+ "domain": "messages",
351
+ "scenario": "typing",
352
+ "before": "f you know the formula for calculating the coefficient of fr",
353
+ "suggestion": "iction,",
354
+ "truth": "iction as a functio",
355
+ "right": false
356
+ },
357
+ {
358
+ "domain": "messages",
359
+ "scenario": "editing",
360
+ "before": "eleration. Set the acceleration equation equal to zero and s",
361
+ "suggestion": "olve for",
362
+ "truth": "olve for time. More ",
363
+ "right": true
364
+ },
365
+ {
366
+ "domain": "messages",
367
+ "scenario": "editing",
368
+ "before": "tion. Social media has allowed people to connect with each o",
369
+ "suggestion": "ther",
370
+ "truth": "ther and share i",
371
+ "right": true
372
+ },
373
+ {
374
+ "domain": "messages",
375
+ "scenario": "editing",
376
+ "before": "day, it is the capital and largest city of Germany, and a gl",
377
+ "suggestion": "obal",
378
+ "truth": "obal center for ",
379
+ "right": true
380
+ },
381
+ {
382
+ "domain": "messages",
383
+ "scenario": "editing",
384
+ "before": "d applications include:\n1. Physics: Calculus plays a vital r",
385
+ "suggestion": "ole in",
386
+ "truth": "ole in physics, an",
387
+ "right": true
388
+ },
389
+ {
390
+ "domain": "email",
391
+ "scenario": "typing",
392
+ "before": "ay the course with Continental plus I am confident that Cont",
393
+ "suggestion": "inental",
394
+ "truth": "inental can either ",
395
+ "right": true
396
+ },
397
+ {
398
+ "domain": "email",
399
+ "scenario": "editing",
400
+ "before": "ured products and marketing of electricity for the United St",
401
+ "suggestion": "ates",
402
+ "truth": "ates ? Actively ",
403
+ "right": true
404
+ },
405
+ {
406
+ "domain": "email",
407
+ "scenario": "editing",
408
+ "before": " action is necessary as contractually we are coming to the e",
409
+ "suggestion": "nd of",
410
+ "truth": "nd of the accepta",
411
+ "right": true
412
+ },
413
+ {
414
+ "domain": "email",
415
+ "scenario": "editing",
416
+ "before": "we should get something by the end of the year.\nSimilarly, C",
417
+ "suggestion": "harlie",
418
+ "truth": "ontinental has not",
419
+ "right": false
420
+ }
421
+ ],
422
+ "latency_ms": {
423
+ "p50": 128.9,
424
+ "p95": 437.94,
425
+ "note": "Measured in this eval process, not in a browser."
426
+ }
427
+ }
v2/metrics/tinynext-v1.json ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "tinynext-v1",
3
+ "sees_after": false,
4
+ "cutoffs": {
5
+ "0.3": {
6
+ "articles/typing": {
7
+ "pauses": 1000,
8
+ "shown": 0.11,
9
+ "right": 0.4909,
10
+ "avg_right_chars": 5.24,
11
+ "multiword_shown": 0.019,
12
+ "multiword_right": 0.3684,
13
+ "saved_with_tab": 0.0236,
14
+ "saved_word_by_word": 0.0252
15
+ },
16
+ "articles/editing": {
17
+ "pauses": 1000,
18
+ "shown": 0.081,
19
+ "right": 0.5309,
20
+ "avg_right_chars": 5.37,
21
+ "multiword_shown": 0.018,
22
+ "multiword_right": 0.6111,
23
+ "saved_with_tab": 0.0192,
24
+ "saved_word_by_word": 0.0199
25
+ },
26
+ "messages/typing": {
27
+ "pauses": 1000,
28
+ "shown": 0.137,
29
+ "right": 0.4891,
30
+ "avg_right_chars": 5.25,
31
+ "multiword_shown": 0.042,
32
+ "multiword_right": 0.5238,
33
+ "saved_with_tab": 0.0293,
34
+ "saved_word_by_word": 0.0313
35
+ },
36
+ "messages/editing": {
37
+ "pauses": 1000,
38
+ "shown": 0.141,
39
+ "right": 0.4539,
40
+ "avg_right_chars": 5.39,
41
+ "multiword_shown": 0.043,
42
+ "multiword_right": 0.4419,
43
+ "saved_with_tab": 0.0288,
44
+ "saved_word_by_word": 0.0316
45
+ },
46
+ "email/typing": {
47
+ "pauses": 1000,
48
+ "shown": 0.182,
49
+ "right": 0.5165,
50
+ "avg_right_chars": 5.76,
51
+ "multiword_shown": 0.042,
52
+ "multiword_right": 0.3333,
53
+ "saved_with_tab": 0.0451,
54
+ "saved_word_by_word": 0.0483
55
+ },
56
+ "email/editing": {
57
+ "pauses": 1000,
58
+ "shown": 0.188,
59
+ "right": 0.5372,
60
+ "avg_right_chars": 5.47,
61
+ "multiword_shown": 0.041,
62
+ "multiword_right": 0.4878,
63
+ "saved_with_tab": 0.046,
64
+ "saved_word_by_word": 0.0486
65
+ }
66
+ },
67
+ "0.4": {
68
+ "articles/typing": {
69
+ "pauses": 1000,
70
+ "shown": 0.074,
71
+ "right": 0.6081,
72
+ "avg_right_chars": 5.07,
73
+ "multiword_shown": 0.007,
74
+ "multiword_right": 0.4286,
75
+ "saved_with_tab": 0.019,
76
+ "saved_word_by_word": 0.0192
77
+ },
78
+ "articles/editing": {
79
+ "pauses": 1000,
80
+ "shown": 0.057,
81
+ "right": 0.5614,
82
+ "avg_right_chars": 5.34,
83
+ "multiword_shown": 0.007,
84
+ "multiword_right": 0.8571,
85
+ "saved_with_tab": 0.0143,
86
+ "saved_word_by_word": 0.0143
87
+ },
88
+ "messages/typing": {
89
+ "pauses": 1000,
90
+ "shown": 0.092,
91
+ "right": 0.6304,
92
+ "avg_right_chars": 5.14,
93
+ "multiword_shown": 0.026,
94
+ "multiword_right": 0.5769,
95
+ "saved_with_tab": 0.0248,
96
+ "saved_word_by_word": 0.0261
97
+ },
98
+ "messages/editing": {
99
+ "pauses": 1000,
100
+ "shown": 0.094,
101
+ "right": 0.5638,
102
+ "avg_right_chars": 5.26,
103
+ "multiword_shown": 0.019,
104
+ "multiword_right": 0.5789,
105
+ "saved_with_tab": 0.0232,
106
+ "saved_word_by_word": 0.0239
107
+ },
108
+ "email/typing": {
109
+ "pauses": 1000,
110
+ "shown": 0.13,
111
+ "right": 0.6,
112
+ "avg_right_chars": 5.63,
113
+ "multiword_shown": 0.024,
114
+ "multiword_right": 0.4583,
115
+ "saved_with_tab": 0.0366,
116
+ "saved_word_by_word": 0.038
117
+ },
118
+ "email/editing": {
119
+ "pauses": 1000,
120
+ "shown": 0.143,
121
+ "right": 0.6224,
122
+ "avg_right_chars": 5.39,
123
+ "multiword_shown": 0.023,
124
+ "multiword_right": 0.6957,
125
+ "saved_with_tab": 0.04,
126
+ "saved_word_by_word": 0.0408
127
+ }
128
+ },
129
+ "0.5": {
130
+ "articles/typing": {
131
+ "pauses": 1000,
132
+ "shown": 0.051,
133
+ "right": 0.7059,
134
+ "avg_right_chars": 5.06,
135
+ "multiword_shown": 0.002,
136
+ "multiword_right": 0.5,
137
+ "saved_with_tab": 0.0152,
138
+ "saved_word_by_word": 0.0152
139
+ },
140
+ "articles/editing": {
141
+ "pauses": 1000,
142
+ "shown": 0.037,
143
+ "right": 0.6486,
144
+ "avg_right_chars": 5.12,
145
+ "multiword_shown": 0.005,
146
+ "multiword_right": 1.0,
147
+ "saved_with_tab": 0.0103,
148
+ "saved_word_by_word": 0.0103
149
+ },
150
+ "messages/typing": {
151
+ "pauses": 1000,
152
+ "shown": 0.062,
153
+ "right": 0.7581,
154
+ "avg_right_chars": 5.04,
155
+ "multiword_shown": 0.01,
156
+ "multiword_right": 0.8,
157
+ "saved_with_tab": 0.0198,
158
+ "saved_word_by_word": 0.0198
159
+ },
160
+ "messages/editing": {
161
+ "pauses": 1000,
162
+ "shown": 0.071,
163
+ "right": 0.6338,
164
+ "avg_right_chars": 5.16,
165
+ "multiword_shown": 0.009,
166
+ "multiword_right": 0.5556,
167
+ "saved_with_tab": 0.0193,
168
+ "saved_word_by_word": 0.0197
169
+ },
170
+ "email/typing": {
171
+ "pauses": 1000,
172
+ "shown": 0.093,
173
+ "right": 0.6774,
174
+ "avg_right_chars": 5.68,
175
+ "multiword_shown": 0.016,
176
+ "multiword_right": 0.4375,
177
+ "saved_with_tab": 0.0298,
178
+ "saved_word_by_word": 0.0305
179
+ },
180
+ "email/editing": {
181
+ "pauses": 1000,
182
+ "shown": 0.098,
183
+ "right": 0.6939,
184
+ "avg_right_chars": 5.22,
185
+ "multiword_shown": 0.009,
186
+ "multiword_right": 0.6667,
187
+ "saved_with_tab": 0.0296,
188
+ "saved_word_by_word": 0.0297
189
+ }
190
+ },
191
+ "0.6": {
192
+ "articles/typing": {
193
+ "pauses": 1000,
194
+ "shown": 0.036,
195
+ "right": 0.8056,
196
+ "avg_right_chars": 5.07,
197
+ "multiword_shown": 0.002,
198
+ "multiword_right": 0.5,
199
+ "saved_with_tab": 0.0123,
200
+ "saved_word_by_word": 0.0123
201
+ },
202
+ "articles/editing": {
203
+ "pauses": 1000,
204
+ "shown": 0.024,
205
+ "right": 0.6667,
206
+ "avg_right_chars": 5.0,
207
+ "multiword_shown": 0.003,
208
+ "multiword_right": 1.0,
209
+ "saved_with_tab": 0.0067,
210
+ "saved_word_by_word": 0.0067
211
+ },
212
+ "messages/typing": {
213
+ "pauses": 1000,
214
+ "shown": 0.041,
215
+ "right": 0.7805,
216
+ "avg_right_chars": 4.94,
217
+ "multiword_shown": 0.007,
218
+ "multiword_right": 0.8571,
219
+ "saved_with_tab": 0.0132,
220
+ "saved_word_by_word": 0.0132
221
+ },
222
+ "messages/editing": {
223
+ "pauses": 1000,
224
+ "shown": 0.045,
225
+ "right": 0.6667,
226
+ "avg_right_chars": 5.0,
227
+ "multiword_shown": 0.003,
228
+ "multiword_right": 0.3333,
229
+ "saved_with_tab": 0.0125,
230
+ "saved_word_by_word": 0.0125
231
+ },
232
+ "email/typing": {
233
+ "pauses": 1000,
234
+ "shown": 0.056,
235
+ "right": 0.75,
236
+ "avg_right_chars": 4.9,
237
+ "multiword_shown": 0.004,
238
+ "multiword_right": 0.0,
239
+ "saved_with_tab": 0.0172,
240
+ "saved_word_by_word": 0.0174
241
+ },
242
+ "email/editing": {
243
+ "pauses": 1000,
244
+ "shown": 0.067,
245
+ "right": 0.7313,
246
+ "avg_right_chars": 4.86,
247
+ "multiword_shown": 0.004,
248
+ "multiword_right": 0.75,
249
+ "saved_with_tab": 0.0198,
250
+ "saved_word_by_word": 0.0198
251
+ }
252
+ },
253
+ "0.7": {
254
+ "articles/typing": {
255
+ "pauses": 1000,
256
+ "shown": 0.025,
257
+ "right": 0.84,
258
+ "avg_right_chars": 5.1,
259
+ "multiword_shown": 0.001,
260
+ "multiword_right": 1.0,
261
+ "saved_with_tab": 0.0089,
262
+ "saved_word_by_word": 0.0089
263
+ },
264
+ "articles/editing": {
265
+ "pauses": 1000,
266
+ "shown": 0.018,
267
+ "right": 0.6667,
268
+ "avg_right_chars": 5.0,
269
+ "multiword_shown": 0.001,
270
+ "multiword_right": 1.0,
271
+ "saved_with_tab": 0.005,
272
+ "saved_word_by_word": 0.005
273
+ },
274
+ "messages/typing": {
275
+ "pauses": 1000,
276
+ "shown": 0.025,
277
+ "right": 0.84,
278
+ "avg_right_chars": 4.9,
279
+ "multiword_shown": 0.005,
280
+ "multiword_right": 0.8,
281
+ "saved_with_tab": 0.0086,
282
+ "saved_word_by_word": 0.0086
283
+ },
284
+ "messages/editing": {
285
+ "pauses": 1000,
286
+ "shown": 0.036,
287
+ "right": 0.6944,
288
+ "avg_right_chars": 4.96,
289
+ "multiword_shown": 0.002,
290
+ "multiword_right": 0.5,
291
+ "saved_with_tab": 0.0103,
292
+ "saved_word_by_word": 0.0103
293
+ },
294
+ "email/typing": {
295
+ "pauses": 1000,
296
+ "shown": 0.042,
297
+ "right": 0.8333,
298
+ "avg_right_chars": 4.89,
299
+ "multiword_shown": 0.001,
300
+ "multiword_right": 0.0,
301
+ "saved_with_tab": 0.0143,
302
+ "saved_word_by_word": 0.0143
303
+ },
304
+ "email/editing": {
305
+ "pauses": 1000,
306
+ "shown": 0.047,
307
+ "right": 0.8298,
308
+ "avg_right_chars": 4.95,
309
+ "multiword_shown": 0.002,
310
+ "multiword_right": 1.0,
311
+ "saved_with_tab": 0.0161,
312
+ "saved_word_by_word": 0.0161
313
+ }
314
+ }
315
+ },
316
+ "examples": [
317
+ {
318
+ "domain": "messages",
319
+ "scenario": "typing",
320
+ "before": "onsidered new. Those decisions make the graphs as neat as po",
321
+ "suggestion": "ssible",
322
+ "truth": "ssible, and it is ",
323
+ "right": true
324
+ },
325
+ {
326
+ "domain": "email",
327
+ "scenario": "editing",
328
+ "before": "ured products and marketing of electricity for the United St",
329
+ "suggestion": "ates",
330
+ "truth": "ates ? Actively ",
331
+ "right": true
332
+ }
333
+ ],
334
+ "latency_ms": {
335
+ "p50": 4.22,
336
+ "p95": 16.52,
337
+ "note": "Measured in this eval process, not in a browser."
338
+ }
339
+ }
v2/train.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11,<3.14"
3
+ # dependencies = [
4
+ # "torch>=2.6,<2.8",
5
+ # "datasets>=3.0",
6
+ # "numpy<3",
7
+ # "tokenizers>=0.20",
8
+ # "transformers>=4.46",
9
+ # "onnx>=1.17",
10
+ # "onnxruntime>=1.20",
11
+ # "huggingface_hub>=0.30",
12
+ # ]
13
+ # ///
14
+ """tinynext v2: a small word-piece transformer for in-browser autocomplete that can also fill in the middle.
15
+
16
+ One script does everything: it trains a tokenizer, builds a mix of general text, conversation and email,
17
+ trains the model, scores it with eval.py (the same harness as tinynext v1 and SmolLM2), exports ONNX with a
18
+ key/value cache for the browser, and uploads the lot to the model repo under v2/.
19
+
20
+ hf jobs uv run --flavor a100-large --secrets HF_TOKEN train.py
21
+ uv run train.py --smoke --no-push # a few minutes on a laptop, to check the whole pipeline
22
+ """
23
+
24
+ import argparse
25
+ import importlib.util
26
+ import json
27
+ import math
28
+ import os
29
+ import random
30
+ import shutil
31
+ import time
32
+ from pathlib import Path
33
+
34
+ import numpy as np
35
+ import torch
36
+ import torch.nn as nn
37
+ import torch.nn.functional as F
38
+
39
+ HERE = Path(__file__).parent
40
+ SPECIAL = ["<|endoftext|>", "<|pre|>", "<|suf|>", "<|mid|>"]
41
+ EOT, PRE, SUF, MID = range(4)
42
+
43
+ # ---- Shared eval code ------------------------------------------------------------------------------------------
44
+
45
+
46
+ def load_eval_module(repo: str, folder: str):
47
+ """Uses eval.py and eval_set.json next to this script, or fetches them from the repo, so every model is scored
48
+ by one implementation on the same text."""
49
+ path = HERE / "eval.py"
50
+ if not path.exists():
51
+ from huggingface_hub import hf_hub_download
52
+
53
+ for name in ("eval.py", "eval_set.json"):
54
+ shutil.copy(hf_hub_download(repo, f"{folder}/{name}"), HERE / name)
55
+ spec = importlib.util.spec_from_file_location("tinynext_eval", path)
56
+ module = importlib.util.module_from_spec(spec)
57
+ spec.loader.exec_module(module)
58
+ return module
59
+
60
+
61
+ # ---- Data ------------------------------------------------------------------------------------------------------
62
+
63
+
64
+ def fineweb_docs():
65
+ from datasets import load_dataset
66
+
67
+ for row in load_dataset("HuggingFaceFW/fineweb-edu", name="sample-10BT", split="train", streaming=True):
68
+ yield row["text"]
69
+
70
+
71
+ def smoltalk_docs():
72
+ from datasets import load_dataset
73
+
74
+ for row in load_dataset("HuggingFaceTB/smoltalk", "all", split="train", streaming=True):
75
+ turns = [m["content"] for m in row["messages"] if m["role"] in ("user", "assistant")]
76
+ if turns:
77
+ yield "\n\n".join(turns)
78
+
79
+
80
+ def enron_docs(ev):
81
+ """Training senders only: the eval's held-out senders (and enron-autocomplete's val split) are skipped."""
82
+ import hashlib
83
+
84
+ from datasets import load_dataset
85
+
86
+ for row in load_dataset("corbt/enron-emails", split="train", streaming=True):
87
+ sender = (row["from"] or "").lower()
88
+ if "@enron.com" not in sender or ev.BULK_SENDER.search(sender):
89
+ continue
90
+ if int(hashlib.sha1(sender.encode()).hexdigest(), 16) % 100 < 10:
91
+ continue
92
+ paragraphs = ev.clean_email(row["body"] or "")
93
+ if paragraphs:
94
+ yield "\n\n".join(paragraphs)
95
+
96
+
97
+ def mixed_docs(ev, weights: dict[str, float], seed: int):
98
+ """Interleaves the sources by weight, forever (until the caller stops)."""
99
+ rng = random.Random(seed)
100
+ sources = {"fineweb": fineweb_docs(), "smoltalk": smoltalk_docs(), "enron": enron_docs(ev)}
101
+ names, probs = zip(*weights.items())
102
+ counts = dict.fromkeys(names, 0)
103
+ while True:
104
+ name = rng.choices(names, probs)[0]
105
+ try:
106
+ doc = next(sources[name])
107
+ except StopIteration:
108
+ sources[name] = {"fineweb": fineweb_docs, "smoltalk": smoltalk_docs}.get(name, lambda: enron_docs(ev))()
109
+ continue
110
+ counts[name] += 1
111
+ # Log the first document from each source and then every 50k, so a stalled download shows which one.
112
+ if counts[name] == 1 or counts[name] % 50_000 == 0:
113
+ print(f" data seed {seed}: {name} doc {counts[name]:,}", flush=True)
114
+ yield doc
115
+
116
+
117
+ def clean(text: str, ev) -> str:
118
+ """The browser model only sees keyboard characters, so train on the same."""
119
+ return text.translate(ev.SMART_CHARS).encode("ascii", "ignore").decode().strip()
120
+
121
+
122
+ def train_tokenizer(docs, vocab: int, sample_docs: int):
123
+ from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
124
+
125
+ tokenizer = Tokenizer(models.BPE())
126
+ tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
127
+ tokenizer.decoder = decoders.ByteLevel()
128
+ trainer = trainers.BpeTrainer(vocab_size=vocab, special_tokens=SPECIAL, min_frequency=2,
129
+ initial_alphabet=pre_tokenizers.ByteLevel.alphabet())
130
+ tokenizer.train_from_iterator((next(docs) for _ in range(sample_docs)), trainer=trainer)
131
+ return tokenizer
132
+
133
+
134
+ def fim(ids: list[int], rng: random.Random, max_middle: int, max_suffix: int, max_prefix: int) -> list[int]:
135
+ """Suffix first, then prefix, then the middle, so typing at the cursor only appends to the prompt."""
136
+ if len(ids) < 8:
137
+ return ids
138
+ start = rng.randint(1, len(ids) - 2)
139
+ end = min(len(ids), start + rng.randint(1, max_middle))
140
+ prefix, middle, suffix = ids[max(0, start - max_prefix):start], ids[start:end], ids[end:end + max_suffix]
141
+ if rng.random() < 0.25:
142
+ suffix = [] # also learn the plain "nothing after the cursor" case in this format
143
+ return [SUF, *suffix, PRE, *prefix, MID, *middle, EOT]
144
+
145
+
146
+ def build_tokens(tokenizer, docs, ev, total: int, seq: int, fim_rate: float, seed: int) -> np.ndarray:
147
+ """Packs documents into rows of seq+1 tokens. A share of chunks become fill-in-the-middle examples."""
148
+ rng = random.Random(seed)
149
+ out = np.empty(total + seq + 1, dtype=np.uint16)
150
+ filled, started, next_report = 0, time.time(), 0
151
+ while filled < total:
152
+ batch = [clean(next(docs), ev) for _ in range(512)]
153
+ for ids in tokenizer.encode_batch([b for b in batch if b]):
154
+ ids = ids.ids
155
+ for at in range(0, len(ids), seq - 8):
156
+ chunk = ids[at: at + seq - 8]
157
+ piece = fim(chunk, rng, 24, 64, seq - 100) if rng.random() < fim_rate else chunk + [EOT]
158
+ take = min(len(piece), len(out) - filled)
159
+ out[filled: filled + take] = piece[:take]
160
+ filled += take
161
+ if filled >= total:
162
+ break
163
+ if filled >= total:
164
+ break
165
+ if filled >= next_report:
166
+ print(f" tokens {filled / 1e6:.0f}M / {total / 1e6:.0f}M ({time.time() - started:.0f}s)", flush=True)
167
+ next_report += 20_000_000
168
+ rows = total // (seq + 1)
169
+ return out[: rows * (seq + 1)].reshape(rows, seq + 1)
170
+
171
+
172
+ # ---- Model -----------------------------------------------------------------------------------------------------
173
+
174
+
175
+ class RMSNorm(nn.Module):
176
+ def __init__(self, dim: int):
177
+ super().__init__()
178
+ self.weight = nn.Parameter(torch.ones(dim))
179
+
180
+ def forward(self, x):
181
+ return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + 1e-6) * self.weight
182
+
183
+
184
+ def rotate(x, cos, sin):
185
+ half = x.shape[-1] // 2
186
+ x1, x2 = x[..., :half], x[..., half:]
187
+ return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1)
188
+
189
+
190
+ class Block(nn.Module):
191
+ def __init__(self, dim: int, heads: int, ffn: int):
192
+ super().__init__()
193
+ self.heads, self.head_dim = heads, dim // heads
194
+ self.norm1, self.norm2 = RMSNorm(dim), RMSNorm(dim)
195
+ self.qkv = nn.Linear(dim, 3 * dim, bias=False)
196
+ self.out = nn.Linear(dim, dim, bias=False)
197
+ self.gate_up = nn.Linear(dim, 2 * ffn, bias=False)
198
+ self.down = nn.Linear(ffn, dim, bias=False)
199
+
200
+ def forward(self, x, cos, sin, past_k, past_v, mask):
201
+ batch, length, _ = x.shape
202
+ q, k, v = self.qkv(self.norm1(x)).view(batch, length, 3, self.heads, self.head_dim).unbind(2)
203
+ q, k, v = rotate(q.transpose(1, 2), cos, sin), rotate(k.transpose(1, 2), cos, sin), v.transpose(1, 2)
204
+ if past_k is not None:
205
+ k, v = torch.cat([past_k, k], dim=2), torch.cat([past_v, v], dim=2)
206
+ if mask is None:
207
+ attended = F.scaled_dot_product_attention(q, k, v, is_causal=True)
208
+ else:
209
+ attended = F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
210
+ x = x + self.out(attended.transpose(1, 2).reshape(batch, length, -1))
211
+ gate, up = self.gate_up(self.norm2(x)).chunk(2, dim=-1)
212
+ return x + self.down(F.silu(gate) * up), k, v
213
+
214
+
215
+ class TinyNext(nn.Module):
216
+ def __init__(self, vocab: int, dim: int, layers: int, heads: int, ffn: int):
217
+ super().__init__()
218
+ self.config = {"vocab": vocab, "dim": dim, "layers": layers, "heads": heads, "ffn": ffn}
219
+ self.embed = nn.Embedding(vocab, dim)
220
+ self.blocks = nn.ModuleList(Block(dim, heads, ffn) for _ in range(layers))
221
+ self.norm = RMSNorm(dim)
222
+ self.head_dim = dim // heads
223
+ inverse = 1.0 / (10000 ** (torch.arange(0, self.head_dim, 2).float() / self.head_dim))
224
+ self.register_buffer("inverse_frequency", inverse, persistent=False)
225
+ nn.init.normal_(self.embed.weight, std=0.02)
226
+
227
+ def forward(self, ids, past_k=None, past_v=None):
228
+ """ids: [batch, length]. past_k/past_v: [layers, batch, heads, past, head_dim] or None.
229
+ Returns logits for every position and the updated cache."""
230
+ past = 0 if past_k is None else past_k.shape[3]
231
+ length = ids.shape[1]
232
+ positions = torch.arange(length, device=ids.device) + past
233
+ angles = positions[:, None].float() * self.inverse_frequency[None, :]
234
+ cos, sin = angles.cos()[None, None], angles.sin()[None, None]
235
+ mask = None
236
+ if past_k is not None:
237
+ keys = torch.arange(past + length, device=ids.device)
238
+ mask = keys[None, :] <= positions[:, None]
239
+ x = self.embed(ids)
240
+ new_k, new_v = [], []
241
+ for index, block in enumerate(self.blocks):
242
+ x, k, v = block(x, cos, sin, None if past_k is None else past_k[index], None if past_v is None else past_v[index], mask)
243
+ new_k.append(k)
244
+ new_v.append(v)
245
+ logits = self.norm(x) @ self.embed.weight.T
246
+ return logits, torch.stack(new_k), torch.stack(new_v)
247
+
248
+
249
+ class BrowserStep(nn.Module):
250
+ """The ONNX graph: always takes a cache (possibly empty) and returns only the last position's logits."""
251
+
252
+ def __init__(self, model: TinyNext):
253
+ super().__init__()
254
+ self.model = model
255
+
256
+ def forward(self, ids, past_k, past_v):
257
+ logits, k, v = self.model(ids, past_k, past_v)
258
+ return logits[:, -1], k, v
259
+
260
+
261
+ # ---- Training --------------------------------------------------------------------------------------------------
262
+
263
+
264
+ def evaluate_loss(model, rows: np.ndarray, device, batch: int) -> float:
265
+ model.eval()
266
+ losses = []
267
+ with torch.no_grad(), torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
268
+ for at in range(0, len(rows), batch):
269
+ chunk = torch.from_numpy(rows[at: at + batch].astype(np.int64)).to(device)
270
+ logits, _, _ = model(chunk[:, :-1])
271
+ losses.append(F.cross_entropy(logits.float().flatten(0, 1), chunk[:, 1:].flatten()).item())
272
+ model.train()
273
+ return float(np.mean(losses))
274
+
275
+
276
+ def train(model, rows: np.ndarray, val_rows: np.ndarray, args, device) -> list[dict]:
277
+ steps = args.epochs * len(rows) // args.batch
278
+ decay = [p for n, p in model.named_parameters() if p.dim() >= 2]
279
+ no_decay = [p for n, p in model.named_parameters() if p.dim() < 2]
280
+ optimizer = torch.optim.AdamW([{"params": decay, "weight_decay": 0.1}, {"params": no_decay, "weight_decay": 0.0}],
281
+ lr=args.lr, betas=(0.9, 0.95), fused=device.type == "cuda")
282
+ warmup = max(1, steps // 50)
283
+ schedule = lambda step: min(1, step / warmup) * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * min(1, step / steps))))
284
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, schedule)
285
+ compiled = torch.compile(model) if device.type == "cuda" else model
286
+ history, rng, started = [], np.random.default_rng(0), time.time()
287
+ for step in range(steps):
288
+ index = rng.integers(0, len(rows), args.batch)
289
+ chunk = torch.from_numpy(rows[index].astype(np.int64)).to(device, non_blocking=True)
290
+ with torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
291
+ logits, _, _ = compiled(chunk[:, :-1])
292
+ loss = F.cross_entropy(logits.float().flatten(0, 1), chunk[:, 1:].flatten())
293
+ optimizer.zero_grad(set_to_none=True)
294
+ loss.backward()
295
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
296
+ optimizer.step()
297
+ scheduler.step()
298
+ if step % args.log_every == 0 or step == steps - 1:
299
+ entry = {"step": step, "train_loss": round(loss.item(), 4), "minutes": round((time.time() - started) / 60, 2)}
300
+ if step % (args.log_every * 10) == 0 or step == steps - 1:
301
+ entry["val_loss"] = round(evaluate_loss(model, val_rows, device, args.batch), 4)
302
+ history.append(entry)
303
+ print(json.dumps(entry), flush=True)
304
+ return history
305
+
306
+
307
+ # ---- Eval, export --------------------------------------------------------------------------------------------
308
+
309
+
310
+ def as_token_lm(ev, name: str, model: TinyNext, tokenizer, device):
311
+ texts = [tokenizer.decode([i], skip_special_tokens=False) if i >= len(SPECIAL) else "" for i in range(tokenizer.get_vocab_size())]
312
+
313
+ @torch.no_grad()
314
+ def next_logits(ids, cache):
315
+ past_k, past_v = cache if cache else (None, None)
316
+ logits, k, v = model(torch.tensor([ids], device=device), past_k, past_v)
317
+ return logits[0, -1].float().cpu().numpy(), (k, v)
318
+
319
+ encode = lambda text: tokenizer.encode(text).ids or [EOT]
320
+ prompt = lambda prefix, suffix: prefix if not suffix else [SUF, *suffix, PRE, *prefix, MID]
321
+ return ev.TokenLM(name, next_logits, encode, texts, set(range(len(SPECIAL))), prompt=prompt)
322
+
323
+
324
+ def export(model: TinyNext, output: Path, device) -> dict:
325
+ import onnxruntime as ort
326
+ from onnxruntime.quantization import QuantType, quantize_dynamic
327
+
328
+ model = model.float().eval().to("cpu")
329
+ cfg = model.config
330
+ head_dim = cfg["dim"] // cfg["heads"]
331
+ ids = torch.tensor([[5, 6, 7]], dtype=torch.long)
332
+ empty = torch.zeros(cfg["layers"], 1, cfg["heads"], 0, head_dim)
333
+ # Trace with a non-empty cache so no shape gets fixed at zero; the checks below run with an empty one.
334
+ with torch.no_grad():
335
+ _, trace_k, trace_v = model(torch.tensor([[1, 2]]))
336
+ float_path, int8_path = output / "model.onnx", output / "model_int8.onnx"
337
+ torch.onnx.export(
338
+ BrowserStep(model), (ids, trace_k, trace_v), float_path, opset_version=17,
339
+ input_names=["input_ids", "past_k", "past_v"], output_names=["logits", "present_k", "present_v"],
340
+ dynamic_axes={"input_ids": {1: "length"}, "past_k": {3: "past"}, "past_v": {3: "past"},
341
+ "present_k": {3: "total"}, "present_v": {3: "total"}},
342
+ dynamo=False,
343
+ )
344
+ quantize_dynamic(float_path, int8_path, weight_type=QuantType.QInt8, op_types_to_quantize=["MatMul"])
345
+
346
+ report = {}
347
+ with torch.no_grad():
348
+ expected, k, v = model(ids)
349
+ expected_next, _, _ = model(torch.tensor([[8]]), k, v)
350
+ for name, path in (("float", float_path), ("int8", int8_path)):
351
+ session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
352
+ logits, pk, pv = session.run(None, {"input_ids": ids.numpy(), "past_k": empty.numpy(), "past_v": empty.numpy()})
353
+ logits2, _, _ = session.run(None, {"input_ids": np.array([[8]]), "past_k": pk, "past_v": pv})
354
+ timings = []
355
+ for _ in range(200):
356
+ began = time.perf_counter()
357
+ session.run(None, {"input_ids": np.array([[8]]), "past_k": pk, "past_v": pv})
358
+ timings.append((time.perf_counter() - began) * 1000)
359
+ report[name] = {
360
+ "megabytes": round(path.stat().st_size / 1e6, 2),
361
+ "same_top_token": bool(logits.argmax() == expected[0, -1].argmax() and logits2.argmax() == expected_next[0, -1].argmax()),
362
+ "cached_step_ms_p50_cpu": round(float(np.median(timings)), 3),
363
+ }
364
+ model.to(device)
365
+ return report
366
+
367
+
368
+ # ---- Main ------------------------------------------------------------------------------------------------------
369
+
370
+
371
+ def parse_args():
372
+ parser = argparse.ArgumentParser()
373
+ parser.add_argument("--vocab", type=int, default=8192)
374
+ parser.add_argument("--dim", type=int, default=320)
375
+ parser.add_argument("--layers", type=int, default=8)
376
+ parser.add_argument("--heads", type=int, default=5)
377
+ parser.add_argument("--ffn", type=int, default=864)
378
+ parser.add_argument("--seq", type=int, default=256)
379
+ parser.add_argument("--tokens", type=float, default=800e6, help="Training tokens to build")
380
+ parser.add_argument("--epochs", type=float, default=1.0)
381
+ parser.add_argument("--batch", type=int, default=256)
382
+ parser.add_argument("--lr", type=float, default=3e-3)
383
+ parser.add_argument("--fim-rate", type=float, default=0.5)
384
+ parser.add_argument("--mix", default="fineweb=0.6,smoltalk=0.3,enron=0.1")
385
+ parser.add_argument("--tokenizer-docs", type=int, default=200_000)
386
+ parser.add_argument("--eval-pauses", type=int, default=1000)
387
+ parser.add_argument("--log-every", type=int, default=100)
388
+ parser.add_argument("--repo", default="cfahlgren1/tinynext")
389
+ parser.add_argument("--folder", default="v2")
390
+ parser.add_argument("--name", default="tinynext-v2")
391
+ parser.add_argument("--smoke", action="store_true", help="Tiny settings to check the pipeline end to end")
392
+ parser.add_argument("--no-push", action="store_true")
393
+ args = parser.parse_args()
394
+ if args.smoke:
395
+ args.vocab, args.dim, args.layers, args.heads, args.ffn = 2048, 64, 2, 2, 128
396
+ args.tokens, args.batch, args.tokenizer_docs, args.eval_pauses, args.log_every = 3e6, 32, 3000, 20, 20
397
+ args.name = "tinynext-v2-smoke"
398
+ return args
399
+
400
+
401
+ def main():
402
+ args = parse_args()
403
+ device = torch.device("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
404
+ torch.manual_seed(0)
405
+ output = HERE / "output"
406
+ output.mkdir(exist_ok=True)
407
+ ev = load_eval_module(args.repo, args.folder)
408
+ weights = {k: float(v) for k, v in (part.split("=") for part in args.mix.split(","))}
409
+ print(f"device {device}, mix {weights}", flush=True)
410
+
411
+ tokenizer = train_tokenizer((clean(d, ev) for d in mixed_docs(ev, weights, seed=1)), args.vocab, args.tokenizer_docs)
412
+ tokenizer.save(str(output / "tokenizer.json"))
413
+ # Validation takes the first documents of the stream and training continues after them, so they never overlap.
414
+ docs = mixed_docs(ev, weights, seed=2)
415
+ val_rows = build_tokens(tokenizer, docs, ev, int(min(2e6, args.tokens / 20)), args.seq, args.fim_rate, seed=5)
416
+ rows = build_tokens(tokenizer, docs, ev, int(args.tokens), args.seq, args.fim_rate, seed=3)
417
+
418
+ model = TinyNext(tokenizer.get_vocab_size(), args.dim, args.layers, args.heads, args.ffn).to(device)
419
+ parameters = sum(p.numel() for p in model.parameters())
420
+ print(f"{parameters / 1e6:.2f}M parameters, {rows.size / 1e6:.0f}M tokens", flush=True)
421
+ started = time.time()
422
+ history = train(model, rows, val_rows, args, device)
423
+ minutes = (time.time() - started) / 60
424
+
425
+ model.eval()
426
+ torch.save({"config": model.config, "state_dict": model.state_dict()}, output / "model.pt")
427
+ scored = ev.score(as_token_lm(ev, args.name, model, tokenizer, device), ev.pauses(ev.load_eval_set(), args.eval_pauses))
428
+ (output / "metrics").mkdir(exist_ok=True)
429
+ (output / "metrics" / f"{args.name}.json").write_text(json.dumps(scored, indent=1))
430
+ exported = export(model, output, device)
431
+
432
+ config = {
433
+ **model.config, "parameters": parameters, "seq": args.seq, "special_tokens": dict(zip(SPECIAL, range(len(SPECIAL)))),
434
+ "prompt": "No text after the cursor: the text before it. Otherwise: <|suf|> after <|pre|> before <|mid|>.",
435
+ }
436
+ (output / "config.json").write_text(json.dumps(config, indent=1))
437
+ report = {"args": vars(args), "parameters": parameters, "train_minutes": round(minutes, 1), "history": history, "exports": exported}
438
+ (output / "report.json").write_text(json.dumps(report, indent=1))
439
+ print(json.dumps(exported, indent=1))
440
+ print(ev.table([output / "metrics" / f"{args.name}.json"]))
441
+
442
+ for name in ("train.py", "eval.py", "eval_set.json"):
443
+ shutil.copy(HERE / name, output / name)
444
+ if (HERE / "metrics").exists():
445
+ for path in (HERE / "metrics").glob("*.json"):
446
+ shutil.copy(path, output / "metrics" / path.name)
447
+ if args.no_push:
448
+ print(f"Skipping upload; everything is in {output}")
449
+ return
450
+ from huggingface_hub import HfApi
451
+
452
+ HfApi(token=os.environ.get("HF_TOKEN")).upload_folder(
453
+ folder_path=str(output), repo_id=args.repo, path_in_repo=args.folder, repo_type="model",
454
+ commit_message=f"{args.name}: {parameters / 1e6:.1f}M params, trained {minutes:.0f} min",
455
+ )
456
+ print(f"Uploaded to https://huggingface.co/{args.repo}/tree/main/{args.folder}")
457
+
458
+
459
+ if __name__ == "__main__":
460
+ main()