cfahlgren1's picture
cfahlgren1 HF Staff
tinynext-v2-3b: 12.5M params, 56 min on GPU
dc714b2 verified
Raw History Blame Contribute Delete
22.4 kB
# /// script
# requires-python = ">=3.11,<3.14"
# dependencies = [
# "torch>=2.6,<2.8",
# "datasets>=3.0",
# "numpy<3",
# "onnxruntime>=1.20",
# "transformers>=4.46",
# "tokenizers>=0.20",
# ]
# ///
"""Scores autocomplete models the way people use them: type real text, pause, and see if the gray suggestion is right.
Every model sees the same pauses in the same text and is decoded with the same rule as offline.page:
follow the model's top pick while the running product of probabilities stays above a cutoff, and show
the text up to the last finished word. A suggestion is right when the text really continues that way.
Two scenarios:
typing the cursor is at the end of what's written so far.
editing the cursor is mid-sentence and real text follows a gap of 2-6 words. Only fill-in-the-middle
models are given that text; the rest see only what's before the cursor.
Usage:
uv run eval.py --model tinynext-v1 --onnx model_quint8.onnx
uv run eval.py --model smollm2-135m --hf HuggingFaceTB/SmolLM2-135M
uv run eval.py --table # compare every metrics/*.json
"""
import argparse
import hashlib
import json
import random
import re
import statistics
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
HERE = Path(__file__).parent
EVAL_SET = HERE / "eval_set.json"
METRICS = HERE / "metrics"
CUTOFFS = (0.3, 0.4, 0.5, 0.6, 0.7)
MAIN_CUTOFF = 0.5 # what offline.page uses
PAUSE_EVERY = 12 # characters between pauses
MIN_SHOWN_CHARS = 4 # offline.page hides shorter suggestions
MAX_WORDS = 6
MAX_CHARS = 48
DOMAINS = ("articles", "messages", "email")
SCENARIOS = ("typing", "editing")
PUNCTUATION = ".,!?;:"
# ---- Eval text -------------------------------------------------------------------------------------------------
# Kept apart from training: Wikipedia isn't in either model's data, OpenAssistant validation isn't in SmolTalk's
# training split, and the emails come from the senders the original enron-autocomplete held out for its test set.
CUT_REPLY = re.compile(
r"(?im)^\s*(?:-+\s*original message|-+\s*forwarded by|-+\s*inline attachment|from:\s|to:\s|sent:\s"
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})"
)
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")
BULK_SENDER = re.compile(r"(?i)announce|newsletter|mailer|outlook\.team|^all[._]|general\.|chairman|enron\.(?:on|net)")
SMART_CHARS = str.maketrans({"‘": "'", "’": "'", "“": '"', "”": '"', "–": "-", "—": "-", "\t": " "})
def clean_email(body: str) -> list[str]:
"""The same cleaning enron-autocomplete was trained with."""
text = body.replace("\r\n", "\n").replace("=20", " ").replace("=\n", "").translate(SMART_CHARS)
text = "\n".join(line for line in text.split("\n") if not line.lstrip().startswith(">"))
cut = CUT_REPLY.search(text)
if cut:
text = text[: cut.start()]
text = re.sub(r"(?m)^-- *\n[\s\S]*", "", text)
paragraphs = []
for block in re.split(r"\n\s*\n", text):
lines = [re.sub(r" +", " ", line).strip() for line in block.split("\n") if line.strip()]
if not lines:
continue
joined = lines[0]
for previous, line in zip(lines, lines[1:]):
joined += (" " if len(previous) >= 50 else "\n") + line
if SENSITIVE.search(joined) or sum(c.isalpha() for c in joined) < 0.6 * len(joined):
continue
paragraphs.append(joined)
return paragraphs
def is_held_out_sender(sender: str) -> bool:
"""enron-autocomplete put senders whose hash bucket is under 5 in its test split."""
sender = sender.lower()
if "@enron.com" not in sender or BULK_SENDER.search(sender):
return False
return int(hashlib.sha1(sender.encode()).hexdigest(), 16) % 100 < 5
def ascii_paragraphs(text: str, min_chars: int = 40) -> list[str]:
text = text.translate(SMART_CHARS).encode("ascii", "ignore").decode()
return [p.strip() for p in re.split(r"\n\s*\n|\n", text) if len(p.strip()) >= min_chars]
def build_eval_set(docs_per_domain: int = 80) -> dict[str, list[list[str]]]:
from datasets import load_dataset
sets: dict[str, list[list[str]]] = {domain: [] for domain in DOMAINS}
wiki = load_dataset(
"wikimedia/wikipedia", data_files="20231101.en/train-00040-of-00041.parquet", split="train", streaming=True
)
for row in wiki:
paragraphs = [p for p in ascii_paragraphs(row["text"], 80) if not p.endswith(":")][:6]
if len(paragraphs) >= 3:
sets["articles"].append(paragraphs)
if len(sets["articles"]) >= docs_per_domain:
break
for row in load_dataset("OpenAssistant/oasst1", split="validation"):
if row["lang"] == "en" and "```" not in row["text"]:
paragraphs = ascii_paragraphs(row["text"])[:6]
if paragraphs:
sets["messages"].append(paragraphs)
if len(sets["messages"]) >= docs_per_domain:
break
for row in load_dataset("corbt/enron-emails", split="train", streaming=True):
if is_held_out_sender(row["from"] or ""):
paragraphs = [p for p in clean_email(row["body"] or "") if len(p) >= 40][:6]
if paragraphs:
sets["email"].append(paragraphs)
if len(sets["email"]) >= docs_per_domain:
break
return sets
def load_eval_set() -> dict[str, list[list[str]]]:
if not EVAL_SET.exists():
EVAL_SET.write_text(json.dumps(build_eval_set(), indent=1))
return json.loads(EVAL_SET.read_text())
@dataclass
class Pause:
domain: str
scenario: str
before: str # the document up to the cursor
after: str # text after the cursor that a model may see (empty when typing)
truth: str # what the person actually typed next
def pauses(eval_set: dict[str, list[list[str]]], max_per_domain: int) -> list[Pause]:
rng = random.Random(0)
found: list[Pause] = []
for domain in DOMAINS:
in_domain: list[Pause] = []
for paragraphs in eval_set[domain]:
for index, paragraph in enumerate(paragraphs):
earlier = "\n".join(paragraphs[:index])
for at in range(PAUSE_EVERY, len(paragraph) - 8, PAUSE_EVERY):
before = (earlier + "\n" if earlier else "") + paragraph[:at]
truth = paragraph[at:]
in_domain.append(Pause(domain, "typing", before, "", truth))
# Editing: a gap of 2-6 words, then the rest of the paragraph as it was written.
gap_words = rng.randint(2, 6)
gap = re.match(r"(\S*\s+){%d}" % gap_words, truth)
if gap and len(truth) > gap.end() + 10:
in_domain.append(Pause(domain, "editing", before, truth[gap.end():], truth))
rng.shuffle(in_domain)
found += [p for p in in_domain if p.scenario == "typing"][:max_per_domain]
found += [p for p in in_domain if p.scenario == "editing"][:max_per_domain]
return found
# ---- Decoding -------------------------------------------------------------------------------------------------
@dataclass
class Trajectory:
"""Suggestions that end at a word boundary, with the model's confidence in each. Greedy decoding gives the
same path for every cutoff, so one decode answers all of them."""
candidates: list[tuple[str, float]] = field(default_factory=list)
milliseconds: float = 0.0
def at(self, cutoff: float) -> str:
best = ""
for text, confidence in self.candidates:
if confidence >= cutoff:
best = text
return best.rstrip()
class Model:
name: str
sees_after: bool = False
def suggest(self, before: str, after: str) -> Trajectory:
raise NotImplementedError
class CharGRU(Model):
"""enron-autocomplete / tinynext v1: one character per step, reading only the current paragraph."""
HIDDEN, END, NEWLINE, FIRST, OFFSET = 512, 1, 2, 3, 29
def __init__(self, name: str, onnx_path: str):
import onnxruntime as ort
self.name = name
self.session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
self.read_text: str | None = None
self.reading = None
def step(self, char_id: int, state):
logits, state = self.session.run(None, {"input_id": np.array([char_id], np.int64), "state": state})
return logits[0], state
def encode(self, char: str) -> int | None:
if char == "\n":
return self.NEWLINE
code = ord(char)
return code - self.OFFSET if 32 <= code <= 126 else None
def read(self, text: str):
if self.read_text is None or not text.startswith(self.read_text):
self.reading = self.step(self.END, np.zeros((1, self.HIDDEN), np.float32))
self.read_text = ""
for char in text[len(self.read_text):]:
char_id = self.encode(char)
if char_id is not None:
self.reading = self.step(char_id, self.reading[1])
self.read_text = text
return self.reading
def suggest(self, before: str, after: str) -> Trajectory:
began = time.perf_counter()
logits, state = self.read(before.split("\n")[-1])
trajectory, run, confidence, words = Trajectory(), "", 1.0, 0
while len(run) < MAX_CHARS and confidence >= min(CUTOFFS):
probabilities = np.exp(logits - logits.max())
probabilities /= probabilities.sum()
char_id = int(probabilities.argmax())
if char_id < self.FIRST:
if run:
trajectory.candidates.append((run, confidence))
break
char = chr(char_id + self.OFFSET)
if char == " ":
trajectory.candidates.append((run, confidence))
words += 1
if words >= MAX_WORDS:
break
confidence *= float(probabilities[char_id])
run += char
if char in PUNCTUATION:
trajectory.candidates.append((run, confidence))
logits, state = self.step(char_id, state)
trajectory.milliseconds = (time.perf_counter() - began) * 1000
return trajectory
class TokenLM(Model):
"""A word-piece model. `next_logits(ids, cache)` runs one forward pass and returns (last logits, cache).
The word being typed is "healed": the prompt stops before it, and the first generated pieces must spell it
out, so a half-typed word like "resig" can still become "resignation" in one piece.
"""
def __init__(self, name: str, next_logits: Callable, encode: Callable[[str], list[int]], token_texts: list[str],
stop_ids: set[int], prompt: Callable[[list[int], list[int]], list[int]] | None = None,
context_chars: int = 1500, suffix_chars: int = 300):
self.name = name
self.next_logits = next_logits
self.encode = encode
self.token_texts = token_texts
self.stop_ids = stop_ids
self.prompt = prompt
self.sees_after = prompt is not None
self.context_chars = context_chars
self.suffix_chars = suffix_chars
lengths = np.array([len(t) for t in token_texts])
self.nonempty = lengths > 0
def allowed(self, need: str) -> np.ndarray:
"""Pieces consistent with the rest of the half-typed word."""
return np.array([bool(t) and (need.startswith(t) or t.startswith(need)) for t in self.token_texts])
def suggest(self, before: str, after: str) -> Trajectory:
began = time.perf_counter()
before = before[-self.context_chars:]
# The half-typed word keeps its leading space, since word pieces start with one. A line break stays in the prompt.
space, newline = before.rfind(" "), before.rfind("\n")
if space > newline:
stem, need = before[:space], before[space:]
else:
stem, need = before[: newline + 1], before[newline + 1:]
prefix_ids = self.encode(stem)
if self.prompt:
ids = self.prompt(prefix_ids, self.encode(after[: self.suffix_chars]) if after else [])
else:
ids = prefix_ids
logits, cache = self.next_logits(ids, None)
trajectory, generated, confidence = Trajectory(), "", 1.0
for _ in range(16):
logits = logits.astype(np.float64)
if need:
mask = self.allowed(need)
if not mask.any():
break
logits = np.where(mask, logits, -np.inf)
probabilities = np.exp(logits - logits.max())
probabilities /= probabilities.sum()
token = int(probabilities.argmax())
text = self.token_texts[token]
if token in self.stop_ids or "\n" in text:
if generated:
trajectory.candidates.append((generated, confidence))
break
if need:
consumed = min(len(need), len(text))
text, need = text[consumed:], need[consumed:]
if text[:1].isspace() and generated:
trajectory.candidates.append((generated, confidence))
confidence *= float(probabilities[token])
generated += text
if generated.rstrip()[-1:] in PUNCTUATION and generated.strip():
trajectory.candidates.append((generated, confidence))
if confidence < min(CUTOFFS) or len(generated) > MAX_CHARS or len(generated.split()) > MAX_WORDS:
break
logits, cache = self.next_logits([token], cache)
trajectory.milliseconds = (time.perf_counter() - began) * 1000
return trajectory
def hf_causal_lm(name: str, repo: str) -> TokenLM:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
device = "mps" if torch.backends.mps.is_available() else "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(repo)
model = AutoModelForCausalLM.from_pretrained(repo, torch_dtype=torch.float32).to(device).eval()
@torch.no_grad()
def next_logits(ids, cache):
out = model(torch.tensor([ids], device=device), past_key_values=cache, use_cache=True)
return out.logits[0, -1].float().cpu().numpy(), out.past_key_values
texts = [tokenizer.decode([i]) for i in range(len(tokenizer))]
stops = {tokenizer.eos_token_id} if tokenizer.eos_token_id is not None else set()
encode = lambda text: tokenizer.encode(text, add_special_tokens=False) or [tokenizer.eos_token_id]
return TokenLM(name, next_logits, encode, texts, stops)
def onnx_token_lm(name: str, folder: str, fim: bool) -> TokenLM:
"""A tinynext v2 export: graph.onnx + weights.f32 (compressed, expanded) or model.onnx, plus tokenizer.json."""
import onnxruntime as ort
from tokenizers import Tokenizer
folder = Path(folder)
graph = folder / "graph.onnx" if (folder / "graph.onnx").exists() else folder / "model.onnx"
session = ort.InferenceSession(str(graph), providers=["CPUExecutionProvider"])
config = json.loads((folder / "config.json").read_text())
tokenizer = Tokenizer.from_file(str(folder / "tokenizer.json"))
empty = np.zeros((config["layers"], 1, config["heads"], 0, config["dim"] // config["heads"]), np.float32)
def next_logits(ids, cache):
past_k, past_v = cache if cache else (empty, empty)
logits, k, v = session.run(None, {"input_ids": np.array([ids], np.int64), "past_k": past_k, "past_v": past_v})
return logits[0], (k, v)
special = len(config["special_tokens"])
texts = [tokenizer.decode([i], skip_special_tokens=False) if i >= special else "" for i in range(config["vocab"])]
encode = lambda text: tokenizer.encode(text).ids or [0]
prompt = (lambda prefix, suffix: prefix if not suffix else [2, *suffix, 1, *prefix, 3]) if fim else None
return TokenLM(name, next_logits, encode, texts, set(range(special)), prompt=prompt)
# ---- Scoring ---------------------------------------------------------------------------------------------------
def word_by_word(suggestion: str, truth: str) -> int:
"""Characters you'd keep accepting a word at a time until a word is wrong."""
kept = 0
for match in re.finditer(r"\s*\S+", suggestion):
if truth.startswith(suggestion[: match.end()]):
kept = match.end()
else:
break
return kept
def score(model: Model, all_pauses: list[Pause]) -> dict:
results: dict = {"model": model.name, "sees_after": model.sees_after, "cutoffs": {}, "examples": [], "latency_ms": {}}
latencies: list[float] = []
raw = []
for index, pause in enumerate(all_pauses):
trajectory = model.suggest(pause.before, pause.after if model.sees_after else "")
latencies.append(trajectory.milliseconds)
raw.append((pause, trajectory))
if index % 500 == 0:
print(f" {model.name}: {index}/{len(all_pauses)} pauses, median {statistics.median(latencies):.1f} ms", flush=True)
for cutoff in CUTOFFS:
table: dict = {}
for domain in DOMAINS:
for scenario in SCENARIOS:
rows = [(p, t.at(cutoff)) for p, t in raw if p.domain == domain and p.scenario == scenario]
if not rows:
continue
shown = [(p, s) for p, s in rows if len(s) >= MIN_SHOWN_CHARS]
right = [(p, s) for p, s in shown if p.truth.startswith(s)]
multi = [(p, s) for p, s in shown if len(s.split()) >= 2]
multi_right = [(p, s) for p, s in multi if p.truth.startswith(s)]
typed = len(rows) * PAUSE_EVERY
table[f"{domain}/{scenario}"] = {
"pauses": len(rows),
"shown": round(len(shown) / len(rows), 4),
"right": round(len(right) / max(1, len(shown)), 4),
"avg_right_chars": round(sum(len(s) for _, s in right) / max(1, len(right)), 2),
"multiword_shown": round(len(multi) / len(rows), 4),
"multiword_right": round(len(multi_right) / max(1, len(multi)), 4),
"saved_with_tab": round(sum(len(s) for _, s in right) / typed, 4),
"saved_word_by_word": round(sum(word_by_word(s, p.truth) for p, s in shown) / typed, 4),
}
results["cutoffs"][str(cutoff)] = table
for pause, trajectory in raw[:: max(1, len(raw) // 60)]:
suggestion = trajectory.at(MAIN_CUTOFF)
if len(suggestion) >= MIN_SHOWN_CHARS:
results["examples"].append({
"domain": pause.domain, "scenario": pause.scenario, "before": pause.before[-60:],
"suggestion": suggestion, "truth": pause.truth[: len(suggestion) + 12], "right": pause.truth.startswith(suggestion),
})
results["latency_ms"] = {
"p50": round(statistics.median(latencies), 2),
"p95": round(float(np.percentile(latencies, 95)), 2),
"note": "Measured in this eval process, not in a browser.",
}
return results
# ---- Report ----------------------------------------------------------------------------------------------------
def table(paths: list[Path]) -> str:
runs = [json.loads(p.read_text()) for p in paths]
lines = []
for scenario in SCENARIOS:
lines.append(f"\n### {scenario.capitalize()}, {int(MAIN_CUTOFF * 100)}% cutoff, suggestions of {MIN_SHOWN_CHARS}+ characters\n")
lines.append("| Model | Domain | Shown | Right | Avg right | 2+ words shown | 2+ words right | Saved (Tab) | Saved (word by word) |")
lines.append("|---|---|---|---|---|---|---|---|---|")
for run in runs:
for domain in DOMAINS:
m = run["cutoffs"][str(MAIN_CUTOFF)].get(f"{domain}/{scenario}")
if not m:
continue
lines.append(
f"| {run['model']} | {domain} | {m['shown']:.0%} | {m['right']:.0%} | {m['avg_right_chars']:.1f} ch "
f"| {m['multiword_shown']:.0%} | {m['multiword_right']:.0%} | {m['saved_with_tab']:.1%} | {m['saved_word_by_word']:.1%} |"
)
lines.append("\n| Model | Latency p50 | p95 |\n|---|---|---|")
for run in runs:
lines.append(f"| {run['model']} | {run['latency_ms']['p50']} ms | {run['latency_ms']['p95']} ms |")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", help="Name for the metrics file")
parser.add_argument("--onnx", help="A character-level GRU exported like enron-autocomplete")
parser.add_argument("--hf", help="A Hugging Face causal LM to evaluate, e.g. HuggingFaceTB/SmolLM2-135M")
parser.add_argument("--onnx-lm", help="Folder with a tinynext v2 ONNX export, config.json and tokenizer.json")
parser.add_argument("--no-fim", action="store_true", help="With --onnx-lm: the model wasn't trained to fill in the middle")
parser.add_argument("--max-pauses", type=int, default=1000, help="Per domain and scenario")
parser.add_argument("--table", action="store_true", help="Print a comparison of every metrics/*.json")
args = parser.parse_args()
if args.table:
print(table(sorted(METRICS.glob("*.json"))))
return
if args.onnx:
model: Model = CharGRU(args.model, args.onnx)
elif args.hf:
model = hf_causal_lm(args.model, args.hf)
elif args.onnx_lm:
model = onnx_token_lm(args.model, args.onnx_lm, fim=not args.no_fim)
else:
parser.error("Pass --onnx, --hf or --table. train.py evaluates its own model through score().")
all_pauses = pauses(load_eval_set(), args.max_pauses)
print(f"{len(all_pauses)} pauses", flush=True)
results = score(model, all_pauses)
METRICS.mkdir(exist_ok=True)
(METRICS / f"{args.model}.json").write_text(json.dumps(results, indent=1))
print(table([METRICS / f"{args.model}.json"]))
if __name__ == "__main__":
main()