tinynext / train.py
cfahlgren1's picture
cfahlgren1 HF Staff
Duplicate from cfahlgren1/enron-autocomplete
a028f12
Raw History Blame Contribute Delete
23.5 kB
# /// script
# requires-python = ">=3.11,<3.14"
# dependencies = [
# "torch>=2.6,<2.8",
# "datasets>=3.0",
# "numpy<3",
# "onnx>=1.17",
# "onnxruntime>=1.20",
# "huggingface_hub>=0.30",
# ]
# ///
"""Train a character-level GRU for in-browser email autocomplete on Enron."""
import argparse
import hashlib
import json
import os
import re
import time
from collections import Counter
from pathlib import Path
import numpy as np
import onnxruntime as ort
import torch
import torch.nn as nn
import torch.nn.functional as F
from datasets import load_dataset
from huggingface_hub import HfApi
from onnxruntime.quantization import QuantType, quantize_dynamic
# Vocabulary: PAD, EOS, newline, then printable ASCII.
PAD, EOS, NEWLINE, FIRST_PRINTABLE = 0, 1, 2, 3
VOCAB_SIZE = FIRST_PRINTABLE + (127 - 32)
IGNORE = -100
ENCODE = np.full(256, 255, dtype=np.uint8)
ENCODE[10] = NEWLINE
ENCODE[32:127] = np.arange(FIRST_PRINTABLE, VOCAB_SIZE, dtype=np.uint8)
DECODE = {NEWLINE: "\n", **{FIRST_PRINTABLE + i: chr(32 + i) for i in range(95)}}
LETTER = np.zeros(VOCAB_SIZE, dtype=bool)
for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
LETTER[ENCODE[ord(char)]] = True
KEYBOARD_NEIGHBORS = {
"q": "wa", "w": "qes", "e": "wrd", "r": "etf", "t": "ryg", "y": "tuh", "u": "yij",
"i": "uok", "o": "ipl", "p": "ol", "a": "qsz", "s": "awdx", "d": "serf", "f": "drgv",
"g": "fthb", "h": "gyjn", "j": "hukm", "k": "jil", "l": "kop", "z": "asx", "x": "zsc",
"c": "xdv", "v": "cfb", "b": "vgn", "n": "bhm", "m": "njk",
}
NEIGHBOR_TABLE = np.tile(np.arange(VOCAB_SIZE, dtype=np.uint8)[:, None], (1, 4))
for key, neighbors in KEYBOARD_NEIGHBORS.items():
for slot in range(4):
NEIGHBOR_TABLE[ENCODE[ord(key)], slot] = ENCODE[ord(neighbors[slot % len(neighbors)])]
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_paragraphs(body: str) -> list[str]:
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
# Enron bodies are hard-wrapped near 70 columns; only keep breaks after short lines.
joined = lines[0]
for previous, line in zip(lines, lines[1:]):
joined += (" " if len(previous) >= 50 else "\n") + line
letters = sum(char.isalpha() for char in joined)
if SENSITIVE.search(joined) or letters < 0.6 * len(joined):
continue
paragraphs.append(joined)
return paragraphs
def load_emails(limit_frac: float) -> dict[str, list[np.ndarray]]:
dataset = load_dataset("corbt/enron-emails", split="train").select_columns(["from", "body"])
if limit_frac < 1:
dataset = dataset.shuffle(seed=0).select(range(int(len(dataset) * limit_frac)))
parsed: list[tuple[str, list[str]]] = []
for row in dataset:
sender = (row["from"] or "").lower()
if "@enron.com" not in sender or BULK_SENDER.search(sender):
continue
paragraphs = clean_paragraphs(row["body"] or "")
if paragraphs:
parsed.append((sender, paragraphs))
counts = Counter(p.lower() for _, paragraphs in parsed for p in paragraphs if len(p) >= 60)
seen: set[bytes] = set()
splits: dict[str, list[np.ndarray]] = {"train": [], "val": [], "test": []}
for sender, paragraphs in parsed:
text = "\n\n".join(p for p in paragraphs if counts[p.lower()] < 5)
if not 30 <= len(text) <= 6000:
continue
digest = hashlib.sha1(re.sub(r"\s+", " ", text.lower()).encode()).digest()
if digest in seen:
continue
seen.add(digest)
bucket = int(hashlib.sha1(sender.encode()).hexdigest(), 16) % 100
split = "test" if bucket < 5 else "val" if bucket < 10 else "train"
ids = ENCODE[np.frombuffer(text.encode("ascii", "ignore"), dtype=np.uint8)]
splits[split].append(np.append(ids[ids != 255], EOS).astype(np.uint8))
return splits
def concat_shuffled(emails: list[np.ndarray], rng: np.random.Generator) -> np.ndarray:
return np.concatenate([emails[i] for i in rng.permutation(len(emails))])
def add_typos(clean: np.ndarray, word_rate: float, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
"""Corrupt the input stream while the targets keep predicting the clean next character."""
x = clean[:-1].copy()
y = clean[1:].astype(np.int16)
is_letter = LETTER[x]
starts = np.flatnonzero(is_letter[1:] & ~is_letter[:-1]) + 1
ends = np.flatnonzero(is_letter[:-1] & ~is_letter[1:]) + 1
if len(ends) and len(starts) and ends[0] <= starts[0]:
ends = ends[1:]
count = min(len(starts), len(ends))
starts, ends = starts[:count], ends[:count]
lengths = ends - starts
chosen = (lengths >= 3) & (rng.random(count) < word_rate)
word_starts, word_lengths = starts[chosen], lengths[chosen]
position = word_starts + 1 + (rng.random(len(word_starts)) * (word_lengths - 2)).astype(np.int64)
position = position[position + 1 < len(x)]
ops = rng.integers(0, 4, size=len(position))
neighbor = NEIGHBOR_TABLE[x[position], rng.integers(0, 4, size=len(position))]
substitute, swap, insert, delete = (position[ops == op] for op in range(4))
x[substitute] = neighbor[ops == 0]
first, second = x[swap].copy(), x[swap + 1].copy()
x[swap], x[swap + 1], y[swap] = second, first, IGNORE
repeats = np.ones(len(x), dtype=np.int64)
repeats[delete] = 0
repeats[insert] = 2
inserted_at = np.cumsum(repeats)[insert] - 1
x_out, y_out = np.repeat(x, repeats), np.repeat(y, repeats)
x_out[inserted_at] = neighbor[ops == 2]
return x_out, y_out
def make_batches(x, y, batch_size, seq_len, rng):
offset = int(rng.integers(seq_len))
starts = rng.permutation(np.arange(offset, len(x) - seq_len, seq_len))
span = np.arange(seq_len)
for index in range(0, len(starts) - batch_size + 1, batch_size):
positions = starts[index : index + batch_size][:, None] + span
yield torch.from_numpy(x[positions].astype(np.int64)), torch.from_numpy(y[positions].astype(np.int64))
class CharGRU(nn.Module):
def __init__(self, hidden: int, embedding: int = 64):
super().__init__()
self.embedding = nn.Embedding(VOCAB_SIZE, embedding, padding_idx=PAD)
self.gru = nn.GRU(embedding, hidden, batch_first=True)
self.head = nn.Linear(hidden, VOCAB_SIZE)
def forward(self, ids, state=None):
output, state = self.gru(self.embedding(ids), state)
return self.head(output), state
def sequence_loss(logits, targets, burn_in):
return F.cross_entropy(logits[:, burn_in:].flatten(0, 1), targets[:, burn_in:].flatten(), ignore_index=IGNORE)
@torch.no_grad()
def evaluate_loss(model, emails, args, device, max_chars=5_000_000):
rng = np.random.default_rng(1)
clean = concat_shuffled(emails, rng)[:max_chars]
x, y = clean[:-1], clean[1:].astype(np.int16)
total, batches = 0.0, 0
for inputs, targets in make_batches(x, y, args.batch, args.seq, rng):
logits, _ = model(inputs.to(device))
total += sequence_loss(logits, targets.to(device), args.burn_in).item()
batches += 1
return total / max(batches, 1)
def decode(ids) -> str:
return "".join(DECODE.get(int(i), "") for i in ids)
def sample_positions(emails, count, context, horizon, rng):
contexts, truths = [], []
attempts = 0
while len(contexts) < count and attempts < count * 200:
attempts += 1
email = emails[int(rng.integers(len(emails)))]
if len(email) < 64:
continue
cursor = int(rng.integers(16, len(email) - 16))
previous, following = int(email[cursor - 1]), int(email[cursor])
if not (LETTER[previous] or previous == ENCODE[32]) or not LETTER[following]:
continue
window = np.full(context, EOS, dtype=np.int64)
prefix = email[max(0, cursor - context) : cursor]
window[-len(prefix):] = prefix
truth = email[cursor : cursor + horizon]
ending = np.flatnonzero((truth == EOS) | (truth == NEWLINE))
truths.append(decode(truth[: ending[0]] if len(ending) else truth))
contexts.append(window)
return np.stack(contexts), truths
def build_suggestion(ids, probabilities, threshold, max_words=6):
text, confidence, best, words = "", 1.0, "", 0
for token, probability in zip(ids, probabilities):
token = int(token)
if token in (EOS, NEWLINE, PAD):
break
char = DECODE[token]
if char == " ":
if confidence >= threshold:
best = text
words += 1
if words >= max_words:
break
confidence *= float(probability)
if confidence < threshold:
break
text += char
if char in ".,!?":
best = text
return best
def score_suggestions(generated, probabilities, truths, thresholds):
report = {}
for threshold in thresholds:
shown, correct, saved = 0, 0, 0
for ids, probs, truth in zip(generated, probabilities, truths):
suggestion = build_suggestion(ids, probs, threshold)
if len(suggestion) < 4:
continue
shown += 1
if truth.startswith(suggestion):
correct += 1
saved += len(suggestion)
report[str(threshold)] = {
"coverage": round(shown / len(truths), 4),
"precision": round(correct / shown, 4) if shown else None,
"avg_chars_when_correct": round(saved / correct, 2) if correct else None,
"chars_saved_per_position": round(saved / len(truths), 3),
}
return report
@torch.no_grad()
def greedy_torch(model, contexts, steps, device, chunk=2048):
all_ids, all_probs = [], []
for index in range(0, len(contexts), chunk):
ids = torch.from_numpy(contexts[index : index + chunk]).to(device)
logits, state = model(ids)
step_logits = logits[:, -1]
batch_ids, batch_probs = [], []
for _ in range(steps):
probabilities = step_logits.softmax(-1)
confidence, token = probabilities.max(-1)
batch_ids.append(token.cpu().numpy())
batch_probs.append(confidence.cpu().numpy())
logits, state = model(token[:, None], state)
step_logits = logits[:, -1]
all_ids.append(np.stack(batch_ids, 1))
all_probs.append(np.stack(batch_probs, 1))
return np.concatenate(all_ids), np.concatenate(all_probs)
def greedy_onnx(session, contexts, steps, hidden):
state = np.zeros((len(contexts), hidden), dtype=np.float32)
logits = None
for column in range(contexts.shape[1]):
logits, state = session.run(None, {"input_id": contexts[:, column], "state": state})
ids, confidences = [], []
for _ in range(steps):
probabilities = np.exp(logits - logits.max(-1, keepdims=True))
probabilities /= probabilities.sum(-1, keepdims=True)
token = probabilities.argmax(-1)
ids.append(token)
confidences.append(probabilities[np.arange(len(token)), token])
logits, state = session.run(None, {"input_id": token.astype(np.int64), "state": state})
return np.stack(ids, 1), np.stack(confidences, 1)
class MatMulLinear(nn.Module):
"""Linear layer that exports as MatMul so ONNX Runtime dynamic quantization applies."""
def __init__(self, weight: torch.Tensor, bias: torch.Tensor):
super().__init__()
self.weight = nn.Parameter(weight.t().clone())
self.bias = nn.Parameter(bias.clone())
def forward(self, inputs):
return inputs.matmul(self.weight) + self.bias
class SingleStep(nn.Module):
"""One-character GRU update with weights copied from the trained nn.GRU."""
def __init__(self, model: CharGRU, hidden: int):
super().__init__()
self.embedding = model.embedding
input_weight, hidden_weight = model.gru.weight_ih_l0.detach(), model.gru.weight_hh_l0.detach()
input_bias, hidden_bias = model.gru.bias_ih_l0.detach(), model.gru.bias_hh_l0.detach()
layers = []
for gate in range(3):
row = slice(gate * hidden, (gate + 1) * hidden)
layers.append(MatMulLinear(input_weight[row], input_bias[row]))
layers.append(MatMulLinear(hidden_weight[row], hidden_bias[row]))
self.gates = nn.ModuleList(layers)
self.head = MatMulLinear(model.head.weight.detach(), model.head.bias.detach())
def forward(self, input_id, state):
embedded = self.embedding(input_id)
reset = torch.sigmoid(self.gates[0](embedded) + self.gates[1](state))
update = torch.sigmoid(self.gates[2](embedded) + self.gates[3](state))
proposal = torch.tanh(self.gates[4](embedded) + reset * self.gates[5](state))
next_state = (1 - update) * proposal + update * state
return self.head(next_state), next_state
def verify_single_step(model, step, hidden):
ids = torch.randint(3, VOCAB_SIZE, (4, 32))
logits, _ = model(ids)
state = torch.zeros(4, hidden)
outputs = []
for column in range(ids.shape[1]):
step_logits, state = step(ids[:, column], state)
outputs.append(step_logits)
difference = (torch.stack(outputs, 1) - logits).abs().max().item()
if difference > 1e-3:
raise RuntimeError(f"Exported single-step GRU does not match nn.GRU: max difference {difference}")
return difference
def measure_latency(path, hidden, runs=3000):
options = ort.SessionOptions()
options.intra_op_num_threads = 1
session = ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"])
state = np.zeros((1, hidden), dtype=np.float32)
timings = []
for run in range(runs):
token = np.array([FIRST_PRINTABLE + run % 90], dtype=np.int64)
started = time.perf_counter()
_, state = session.run(None, {"input_id": token, "state": state})
timings.append((time.perf_counter() - started) * 1000)
return {"p50_ms": round(float(np.percentile(timings, 50)), 3), "p95_ms": round(float(np.percentile(timings, 95)), 3)}
def export_and_score(model, args, output, contexts, truths, thresholds):
model = model.cpu().eval()
step = SingleStep(model, args.hidden).eval()
difference = verify_single_step(model, step, args.hidden)
print(f"single-step GRU matches nn.GRU (max difference {difference:.2e})", flush=True)
float_path = output / "model.onnx"
torch.onnx.export(
step,
(torch.zeros(1, dtype=torch.long), torch.zeros(1, args.hidden)),
float_path,
input_names=["input_id", "state"],
output_names=["logits", "next_state"],
dynamic_axes={"input_id": {0: "batch"}, "state": {0: "batch"}, "logits": {0: "batch"}, "next_state": {0: "batch"}},
opset_version=17,
dynamo=False,
)
quantized_path = output / "model_quint8.onnx"
quantize_dynamic(float_path, quantized_path, weight_type=QuantType.QUInt8, op_types_to_quantize=["MatMul"])
subset = min(1000, len(contexts))
result = {}
for name, path in (("float", float_path), ("quint8", quantized_path)):
session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
ids, probabilities = greedy_onnx(session, contexts[:subset], args.horizon, args.hidden)
result[name] = {
"size_mb": round(path.stat().st_size / 1e6, 2),
"native_cpu_single_thread_step": measure_latency(path, args.hidden),
"suggestions_1000_positions": score_suggestions(ids, probabilities, truths[:subset], thresholds),
}
return result
def show_samples(model, device):
prompts = [
"Hi John,\n\nThanks for ", "Please let me know if ", "I will send you ", "Let me kn", "Thnaks for the ",
"I look forward to ", "Can we schedule a ", "Sorry for the dela", "Plese find attached ", "We need to discuss ",
]
samples = {}
for prompt in prompts:
window = np.full(128, EOS, dtype=np.int64)
encoded = ENCODE[np.frombuffer(prompt.encode("ascii"), dtype=np.uint8)]
window[-len(encoded):] = encoded
ids, probabilities = greedy_torch(model, window[None], 48, device)
samples[prompt] = {
"continuation": decode(ids[0]),
"suggestion_at_0.2": build_suggestion(ids[0], probabilities[0], 0.2),
}
print(f"{prompt!r} -> {samples[prompt]}", flush=True)
return samples
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--hidden", type=int, default=512)
parser.add_argument("--epochs", type=int, default=30)
parser.add_argument("--batch", type=int, default=256)
parser.add_argument("--seq", type=int, default=256)
parser.add_argument("--burn-in", type=int, default=32)
parser.add_argument("--lr", type=float, default=1.5e-3)
parser.add_argument("--typo-rate", type=float, default=0.08)
parser.add_argument("--limit-frac", type=float, default=1.0)
parser.add_argument("--eval-positions", type=int, default=5000)
parser.add_argument("--context", type=int, default=128)
parser.add_argument("--horizon", type=int, default=48)
parser.add_argument("--repo", default="cfahlgren1/enron-autocomplete")
parser.add_argument("--no-push", action="store_true")
return parser.parse_args()
def main():
args = parse_args()
torch.manual_seed(0)
rng = np.random.default_rng(0)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"device={device} {torch.cuda.get_device_name(0) if device.type == 'cuda' else ''} args={vars(args)}", flush=True)
output = Path("outputs")
output.mkdir(parents=True, exist_ok=True)
started = time.time()
splits = load_emails(args.limit_frac)
data_stats = {name: {"emails": len(e), "chars": int(sum(map(len, e)))} for name, e in splits.items()}
print(f"data ready in {time.time() - started:.0f}s: {data_stats}", flush=True)
if not splits["train"] or not splits["val"] or not splits["test"]:
raise RuntimeError(f"A split is empty: {data_stats}")
model = CharGRU(args.hidden).to(device)
parameters = sum(p.numel() for p in model.parameters())
print(f"parameters={parameters:,}", flush=True)
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-5)
steps_per_epoch = max(data_stats["train"]["chars"] // (args.batch * args.seq), 1)
total_steps = args.epochs * steps_per_epoch
scheduler = torch.optim.lr_scheduler.LambdaLR(
optimizer, lambda step: 0.02 + 0.98 * 0.5 * (1 + np.cos(np.pi * min(step / total_steps, 1)))
)
history = []
best_loss = float("inf")
best_path = output / "best.pt"
for epoch in range(args.epochs):
model.train()
epoch_started = time.time()
x, y = add_typos(concat_shuffled(splits["train"], rng), args.typo_rate, rng)
running, batches, chars = 0.0, 0, 0
for inputs, targets in make_batches(x, y, args.batch, args.seq, rng):
logits, _ = model(inputs.to(device, non_blocking=True))
loss = sequence_loss(logits, targets.to(device, non_blocking=True), args.burn_in)
optimizer.zero_grad(set_to_none=True)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
scheduler.step()
running += loss.item()
batches += 1
chars += inputs.numel()
if batches == 0:
raise RuntimeError("No training batches; reduce --batch or --seq for this data size")
model.eval()
val_loss = evaluate_loss(model, splits["val"], args, device)
seconds = time.time() - epoch_started
record = {
"epoch": epoch + 1,
"train_loss": round(running / batches, 4),
"val_loss": round(val_loss, 4),
"seconds": round(seconds, 1),
"chars_per_sec": int(chars / seconds),
}
history.append(record)
print(json.dumps(record), flush=True)
if val_loss < best_loss:
best_loss = val_loss
torch.save({"model": model.state_dict(), "args": vars(args)}, best_path)
model.load_state_dict(torch.load(best_path, map_location=device)["model"])
model.eval()
thresholds = [0.05, 0.1, 0.2, 0.35, 0.5, 0.7]
eval_rng = np.random.default_rng(42)
contexts, truths = sample_positions(splits["test"], args.eval_positions, args.context, args.horizon, eval_rng)
ids, probabilities = greedy_torch(model, contexts, args.horizon, device)
suggestions = score_suggestions(ids, probabilities, truths, thresholds)
print(json.dumps({"held_out_sender_suggestions": suggestions}, indent=1), flush=True)
examples = []
for index in range(min(25, len(contexts))):
examples.append({
"context": decode(contexts[index])[-60:],
"truth": truths[index],
"suggestion_at_0.2": build_suggestion(ids[index], probabilities[index], 0.2),
})
samples = show_samples(model, device)
exported = export_and_score(model, args, output, contexts, truths, thresholds)
print(json.dumps(exported, indent=1), flush=True)
report = {
"args": vars(args),
"device": str(device),
"parameters": parameters,
"data": data_stats,
"history": history,
"best_val_loss": round(best_loss, 4),
"test_positions": len(contexts),
"held_out_sender_suggestions": suggestions,
"exports": exported,
"prompt_samples": samples,
"held_out_examples": examples,
"total_seconds": round(time.time() - started, 1),
}
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
(output / "vocab.json").write_text(json.dumps({
"pad": PAD, "eos": EOS, "newline": NEWLINE, "first_printable_id": FIRST_PRINTABLE,
"first_printable_codepoint": 32, "size": VOCAB_SIZE, "hidden": args.hidden,
}, indent=2) + "\n")
if args.no_push:
print(f"Skipping upload; artifacts in {output}", flush=True)
return
api = HfApi(token=os.environ["HF_TOKEN"])
api.create_repo(args.repo, repo_type="model", private=True, exist_ok=True)
api.upload_folder(folder_path=str(output), repo_id=args.repo, repo_type="model")
print(f"Uploaded to https://huggingface.co/{args.repo}", flush=True)
if __name__ == "__main__":
main()