tiny-llm-27m / bpe.py
sergeymanevitch's picture
tiny-llm: 27M from-scratch pipeline (BPE, pretrain, SFT, DPO, draft, RAG)
7e56b25 verified
Raw
History Blame Contribute Delete
4.39 kB
"""Byte-level BPE tokenizer, written from scratch (minbpe-style).
How BPE works, in one paragraph:
Start from raw bytes (256 possible values — covers ANY text, so there is no
such thing as an "unknown" character). Count which PAIR of tokens appears most
often in the data, merge that pair into one new token, repeat. After a few
thousand merges the vocabulary contains the most useful chunks of THIS dataset
("the", " said", "once upon"), and any text encodes into far fewer tokens.
Contract notes: A1 (lossless round-trip) holds by construction — decode is the
exact inverse of encode. A2 (no unknowns) holds because the base is all 256 bytes.
"""
import json
class BPETokenizer:
def __init__(self):
# merges: (token_a, token_b) -> new_token_id, in the ORDER they were learned
self.merges = {}
# vocab: token_id -> bytes it represents
self.vocab = {i: bytes([i]) for i in range(256)}
# ---------------- training ----------------
def train(self, text, vocab_size, progress_every=200):
"""Learn merges until the vocabulary reaches vocab_size."""
assert vocab_size > 256, "vocab must exceed the 256 base bytes"
ids = list(text.encode("utf-8"))
num_merges = vocab_size - 256
for i in range(num_merges):
pairs = self._count_pairs(ids)
if not pairs:
break # nothing left to merge (tiny input)
best = max(pairs, key=lambda p: pairs[p]) # most frequent pair
new_id = 256 + i
ids = self._merge(ids, best, new_id) # replace pair everywhere
self.merges[best] = new_id
self.vocab[new_id] = self.vocab[best[0]] + self.vocab[best[1]]
if (i + 1) % progress_every == 0:
print(f" merge {i + 1}/{num_merges}: {best} -> {new_id} "
f"({self.vocab[new_id]!r}), data length {len(ids):,}")
@staticmethod
def _count_pairs(ids):
counts = {}
for a, b in zip(ids, ids[1:]):
counts[(a, b)] = counts.get((a, b), 0) + 1
return counts
@staticmethod
def _merge(ids, pair, new_id):
out = []
i = 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
out.append(new_id)
i += 2
else:
out.append(ids[i])
i += 1
return out
# ---------------- encode / decode ----------------
def encode(self, text):
"""Text -> token ids, applying merges in the order they were learned."""
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
pairs = self._count_pairs(ids)
# among pairs present, pick the one learned EARLIEST (lowest new id)
candidate = min(pairs, key=lambda p: self.merges.get(p, float("inf")))
if candidate not in self.merges:
break # no learned merge applies anymore
ids = self._merge(ids, candidate, self.merges[candidate])
return ids
def decode(self, ids):
"""Token ids -> text. Exact inverse of encode."""
data = b"".join(self.vocab[i] for i in ids)
return data.decode("utf-8", errors="replace")
# ---------------- save / load ----------------
def save(self, path):
payload = {
"merges": [[a, b, new] for (a, b), new in self.merges.items()],
}
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f)
@classmethod
def load(cls, path):
tok = cls()
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
for a, b, new in payload["merges"]:
tok.merges[(a, b)] = new
tok.vocab[new] = tok.vocab[a] + tok.vocab[b]
return tok
@property
def vocab_size(self):
return len(self.vocab)
if __name__ == "__main__":
# self-check: train on a tiny sample, round-trip must be exact
sample = "Once upon a time there was a little dog. The dog liked to play."
tok = BPETokenizer()
tok.train(sample * 20, vocab_size=300, progress_every=50)
ids = tok.encode(sample)
assert tok.decode(ids) == sample, "round-trip failed"
print(f"self-check OK: {len(sample)} chars -> {len(ids)} tokens, "
f"vocab {tok.vocab_size}")