lm-playground-api / src /tokenizer.py
deploy
Deploy LM Playground API
f0a527a
Raw
History Blame Contribute Delete
15 kB
"""From-scratch tokenizers for the playground.
Two schemes share one interface so the rest of the app (training + serving) can
treat them identically:
encode(text) -> list[int] ids for the model
decode(ids) -> str inverse of encode
tokens_with_spans(text) -> list[{text,id,start,end}] for the colored chips
vocab_size -> int
save(dir) / load(dir)
`CharTokenizer` is one-token-per-character. `BPETokenizer` is the classic
Sennrich byte-pair-encoding learned *from scratch* over characters, and it
records every merge it makes (so the UI can let you "watch it merge").
Nothing here uses the HuggingFace `tokenizers` library on purpose — the point is
to see the algorithm.
"""
from __future__ import annotations
import json
import os
import re
from collections import Counter
from typing import Dict, List, Tuple
# Pre-tokenization: split text into "pieces" before BPE so merges never cross
# word boundaries. This is a *reversible* partition — "".join(pieces) == text —
# using the GPT-2 leading-space convention (a single ASCII space attaches to the
# following word, which is why real tokenizers show tokens like " the"). \w and
# \s are Unicode-aware in Python 3, so accented letters are handled too.
_PRETOK = re.compile(r" ?\w+| ?[^\w\s]+|\s+", re.UNICODE)
UNK = "<unk>" # id 0 in every tokenizer; only produced for chars unseen in training
def pretokenize(text: str) -> List[str]:
"""Reversible split into word / punctuation / whitespace pieces."""
return _PRETOK.findall(text)
def pretokenize_with_offsets(text: str) -> List[Tuple[str, int]]:
"""Like pretokenize but also returns each piece's start offset in `text`."""
out = []
for m in _PRETOK.finditer(text):
out.append((m.group(0), m.start()))
return out
# --------------------------------------------------------------------------- #
# Character-level
# --------------------------------------------------------------------------- #
class CharTokenizer:
kind = "char"
def __init__(self, itos: List[str]):
# itos[0] is always UNK; the rest are single characters.
self.itos = itos
self.stoi = {s: i for i, s in enumerate(itos)}
@classmethod
def train(cls, text: str) -> "CharTokenizer":
chars = sorted(set(text))
return cls([UNK] + chars)
@property
def vocab_size(self) -> int:
return len(self.itos)
def encode(self, text: str) -> List[int]:
get = self.stoi.get
return [get(ch, 0) for ch in text]
def decode(self, ids: List[int]) -> str:
itos = self.itos
return "".join("" if i == 0 else itos[i] for i in ids if 0 <= i < len(itos))
def tokens_with_spans(self, text: str) -> List[dict]:
out = []
for i, ch in enumerate(text):
out.append({"text": ch, "id": self.stoi.get(ch, 0), "start": i, "end": i + 1})
return out
def token_str(self, idx: int) -> str:
return UNK if idx == 0 else self.itos[idx]
# symmetry with BPETokenizer (char has no merges)
@property
def merges_history(self) -> List[dict]:
return []
def to_meta(self) -> dict:
return {"kind": self.kind, "vocab_size": self.vocab_size, "num_merges": 0}
def save(self, path: str) -> None:
os.makedirs(path, exist_ok=True)
with open(os.path.join(path, "tokenizer.json"), "w", encoding="utf-8") as f:
json.dump({"kind": self.kind, "itos": self.itos}, f, ensure_ascii=False)
# keep a (empty) merge history so the loader is uniform
with open(os.path.join(path, "merges_history.json"), "w", encoding="utf-8") as f:
json.dump([], f)
@classmethod
def load(cls, path: str) -> "CharTokenizer":
with open(os.path.join(path, "tokenizer.json"), encoding="utf-8") as f:
data = json.load(f)
return cls(data["itos"])
# --------------------------------------------------------------------------- #
# Byte-pair encoding (from scratch)
# --------------------------------------------------------------------------- #
class BPETokenizer:
kind = "bpe"
def __init__(self, itos: List[str], merges: List[Tuple[str, str]],
merges_history: List[dict] | None = None):
self.itos = itos
self.stoi = {s: i for i, s in enumerate(itos)}
self.merges = [tuple(m) for m in merges] # learned order
self.merge_rank = {tuple(m): r for r, m in enumerate(self.merges)}
self._history = merges_history or []
self._piece_cache: Dict[str, List[str]] = {}
# ----- training ----------------------------------------------------- #
@classmethod
def train(cls, text: str, vocab_size: int, verbose: bool = False) -> "BPETokenizer":
"""Learn `vocab_size`-ish tokens of BPE from `text`, recording each merge."""
# 1. pre-tokenize and count unique pieces (BPE works on word *types*,
# weighted by frequency — far fewer than the raw token stream).
piece_freq = Counter(pretokenize(text))
# 2. each piece starts as a tuple of its characters
words: Dict[Tuple[str, ...], int] = {}
base_chars = set()
for piece, freq in piece_freq.items():
syms = tuple(piece)
words[syms] = words.get(syms, 0) + freq
base_chars.update(syms)
itos = [UNK] + sorted(base_chars)
stoi = {s: i for i, s in enumerate(itos)}
num_merges = max(0, vocab_size - len(itos))
merges: List[Tuple[str, str]] = []
history: List[dict] = []
for step in range(num_merges):
# count every adjacent symbol pair, weighted by piece frequency
pairs: Counter = Counter()
for syms, freq in words.items():
for a, b in zip(syms, syms[1:]):
pairs[(a, b)] += freq
if not pairs:
break
# most frequent pair; deterministic tie-break on the pair itself
best, freq = max(pairs.items(), key=lambda kv: (kv[1], kv[0]))
new_tok = best[0] + best[1]
# apply the merge everywhere
words = _merge_words(words, best, new_tok)
stoi[new_tok] = len(itos)
itos.append(new_tok)
merges.append(best)
history.append({
"rank": step,
"pair": [best[0], best[1]],
"token": new_tok,
"freq": freq,
"vocab_size": len(itos),
})
if verbose and (step % 200 == 0 or step == num_merges - 1):
print(f" merge {step:4d}: {best[0]!r}+{best[1]!r} -> {new_tok!r} "
f"(freq {freq}, vocab {len(itos)})")
return cls(itos, merges, history)
# ----- encoding ----------------------------------------------------- #
def _bpe_piece(self, piece: str) -> List[str]:
"""Apply learned merges, in learned order, to one pre-tokenized piece."""
cached = self._piece_cache.get(piece)
if cached is not None:
return cached
syms = list(piece)
if len(syms) >= 2:
for a, b in self.merges:
if len(syms) < 2:
break
if a not in syms: # cheap skip
continue
syms = _merge_seq(syms, a, b)
self._piece_cache[piece] = syms
return syms
def encode(self, text: str) -> List[int]:
get = self.stoi.get
out: List[int] = []
for piece in pretokenize(text):
for sym in self._bpe_piece(piece):
out.append(get(sym, 0))
return out
def decode(self, ids: List[int]) -> str:
itos = self.itos
return "".join("" if i == 0 else itos[i] for i in ids if 0 <= i < len(itos))
def tokens_with_spans(self, text: str) -> List[dict]:
out = []
for piece, start in pretokenize_with_offsets(text):
off = start
for sym in self._bpe_piece(piece):
out.append({"text": sym, "id": self.stoi.get(sym, 0),
"start": off, "end": off + len(sym)})
off += len(sym)
return out
def token_str(self, idx: int) -> str:
return UNK if idx == 0 else self.itos[idx]
# ----- the "watch it merge" trace ----------------------------------- #
def bpe_trace(self, word: str) -> List[dict]:
"""Replay training on a single word, capturing every state it passes
through as merges are applied in the order they were learned. Returns
only the change-points, so each entry is a real step in the collapse."""
syms = list(word)
steps = [{"step": 0, "applied": None, "tokens": list(syms)}]
for rank, (a, b) in enumerate(self.merges):
if len(syms) < 2:
break
if a not in syms:
continue
merged = _merge_seq(syms, a, b)
if merged != syms:
syms = merged
steps.append({"step": rank + 1, "applied": [a, b], "tokens": list(syms)})
return steps
@property
def merges_history(self) -> List[dict]:
return self._history
def to_meta(self) -> dict:
return {"kind": self.kind, "vocab_size": self.vocab_size,
"num_merges": len(self.merges)}
@property
def vocab_size(self) -> int:
return len(self.itos)
# ----- persistence -------------------------------------------------- #
def save(self, path: str) -> None:
os.makedirs(path, exist_ok=True)
with open(os.path.join(path, "tokenizer.json"), "w", encoding="utf-8") as f:
json.dump({"kind": self.kind, "itos": self.itos,
"merges": [list(m) for m in self.merges]},
f, ensure_ascii=False)
with open(os.path.join(path, "merges_history.json"), "w", encoding="utf-8") as f:
json.dump(self._history, f, ensure_ascii=False)
@classmethod
def load(cls, path: str) -> "BPETokenizer":
with open(os.path.join(path, "tokenizer.json"), encoding="utf-8") as f:
data = json.load(f)
history = []
hp = os.path.join(path, "merges_history.json")
if os.path.exists(hp):
with open(hp, encoding="utf-8") as f:
history = json.load(f)
return cls(data["itos"], data["merges"], history)
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _merge_seq(syms: List[str], a: str, b: str) -> List[str]:
"""Merge every adjacent (a, b) in a symbol list into a+b."""
merged = a + b
out: List[str] = []
i, n = 0, len(syms)
while i < n:
if i < n - 1 and syms[i] == a and syms[i + 1] == b:
out.append(merged)
i += 2
else:
out.append(syms[i])
i += 1
return out
def _merge_words(words: Dict[Tuple[str, ...], int], pair: Tuple[str, str],
new_tok: str) -> Dict[Tuple[str, ...], int]:
"""Apply a merge across the whole word->freq table."""
a, b = pair
out: Dict[Tuple[str, ...], int] = {}
for syms, freq in words.items():
if a in syms:
syms = tuple(_merge_seq(list(syms), a, b))
out[syms] = out.get(syms, 0) + freq
return out
# --------------------------------------------------------------------------- #
# loader dispatch + factory
# --------------------------------------------------------------------------- #
def load_tokenizer(path: str):
with open(os.path.join(path, "tokenizer.json"), encoding="utf-8") as f:
kind = json.load(f)["kind"]
return CharTokenizer.load(path) if kind == "char" else BPETokenizer.load(path)
def build_tokenizer(scheme: dict, text: str, verbose: bool = False):
"""Build a tokenizer from a scheme dict (see corpora.yaml `schemes`)."""
if scheme["kind"] == "char":
return CharTokenizer.train(text)
return BPETokenizer.train(text, scheme["vocab_size"], verbose=verbose)
# --------------------------------------------------------------------------- #
# self-test: python src/tokenizer.py
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
sample = (
"the quick brown fox jumps over the lazy dog. "
"The QUICK brown Fox! thoughts, thinking, thinkers think.\n"
"low lower lowest newer newest wider widest.\n"
) * 40
print("== pre-tokenization is reversible ==")
for t in ["Hello, world!", " spaced out\n\ttabs", "To be, or not to be",
"café déjà", sample[:120]]:
assert "".join(pretokenize(t)) == t, repr(t)
print("ok")
print("\n== char tokenizer ==")
ct = CharTokenizer.train(sample)
probe = "the lazy dog."
assert ct.decode(ct.encode(probe)) == probe
spans = ct.tokens_with_spans(probe)
assert "".join(s["text"] for s in spans) == probe
assert len(ct.encode(probe)) == len(probe)
print(f"vocab={ct.vocab_size} '{probe}' -> {len(ct.encode(probe))} tokens (roundtrip ok)")
for target in (512, 2048):
print(f"\n== BPE-{target} ==")
bpe = BPETokenizer.train(sample, target, verbose=True)
# roundtrip on held-back-ish text
for probe in ["the quick brown fox", "thinking thoughts", "lowest newer\n",
"café", "Unseen ZZZ chars ~`"]:
dec = bpe.decode(bpe.encode(probe))
# unseen chars (Z, ~, `, é if absent) map to unk and drop; only assert
# roundtrip when every char was in the training vocab
if all(ch in bpe.stoi for ch in probe):
assert dec == probe, (probe, dec)
# spans align
sp = bpe.tokens_with_spans("the quick brown fox")
assert "".join(s["text"] for s in sp) == "the quick brown fox"
# vocab + history invariants
assert len(bpe.merges_history) == len(bpe.merges)
assert bpe.vocab_size <= target
# BPE is no longer than char on the same text
n_char = len(ct.encode("the quick brown fox jumps over the lazy dog"))
n_bpe = len(bpe.encode("the quick brown fox jumps over the lazy dog"))
print(f"vocab={bpe.vocab_size} merges={len(bpe.merges)} "
f"'fox...dog': char={n_char} vs bpe={n_bpe} tokens")
assert n_bpe <= n_char
# watch-it-merge trace
trace = bpe.bpe_trace("thoughts")
print(f" trace 'thoughts': {len(trace)} change-points; "
f"final={trace[-1]['tokens']}")
assert trace[0]["tokens"] == list("thoughts")
assert trace[-1]["tokens"] == bpe._bpe_piece("thoughts")
print("\nAll tokenizer self-tests passed.")