| """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): |
| |
| self.merges = {} |
| |
| self.vocab = {i: bytes([i]) for i in range(256)} |
|
|
| |
|
|
| 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 |
| best = max(pairs, key=lambda p: pairs[p]) |
| new_id = 256 + i |
| ids = self._merge(ids, best, new_id) |
| 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 |
|
|
| |
|
|
| 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) |
| |
| candidate = min(pairs, key=lambda p: self.merges.get(p, float("inf"))) |
| if candidate not in self.merges: |
| break |
| 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") |
|
|
| |
|
|
| 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__": |
| |
| 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}") |
|
|