| """ |
| Stage 1: Tokenization (character-level). |
| |
| This maps directly onto the "Tokenization summary" slide you saw: |
| we're starting at the CHARACTER level because it's the simplest possible |
| scheme — near-zero risk of out-of-vocabulary tokens, trivially |
| interpretable, and the vocab is tiny (~65 symbols for Shakespeare). |
| The cost is longer sequences and less meaningful individual tokens. |
| |
| Later (Stage 4) you'll swap this out for a subword tokenizer (BPE) and |
| watch how vocab size, sequence length, and sample quality change. |
| That contrast is one of the best lessons in this whole exercise. |
| |
| Key idea: a tokenizer is just two lookup tables. |
| encode: string -> list of integers |
| decode: list of integers -> string |
| Everything downstream (embeddings, attention) operates on the integers. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
|
|
|
|
| class CharTokenizer: |
| """A character-level tokenizer built from a training corpus.""" |
|
|
| def __init__(self, text: str) -> None: |
| |
| |
| chars = sorted(set(text)) |
| self.vocab_size = len(chars) |
|
|
| |
| |
| self.stoi: dict[str, int] = {ch: i for i, ch in enumerate(chars)} |
| self.itos: dict[int, str] = {i: ch for i, ch in enumerate(chars)} |
|
|
| def encode(self, text: str) -> list[int]: |
| """Convert a string into a list of token ids.""" |
| return [self.stoi[ch] for ch in text] |
|
|
| def decode(self, ids: list[int]) -> str: |
| """Convert a list of token ids back into a string.""" |
| return "".join(self.itos[i] for i in ids) |
|
|
| |
|
|
| def save(self, path: str | Path) -> None: |
| Path(path).write_text( |
| json.dumps({"stoi": self.stoi}, indent=2), encoding="utf-8" |
| ) |
|
|
| @classmethod |
| def load(cls, path: str | Path) -> "CharTokenizer": |
| stoi = json.loads(Path(path).read_text(encoding="utf-8"))["stoi"] |
| tok = cls.__new__(cls) |
| tok.stoi = {k: int(v) for k, v in stoi.items()} |
| tok.itos = {int(v): k for k, v in stoi.items()} |
| tok.vocab_size = len(tok.stoi) |
| return tok |
|
|
|
|
| if __name__ == "__main__": |
| |
| data_path = Path(__file__).resolve().parent.parent / "data" / "input.txt" |
| text = data_path.read_text(encoding="utf-8") |
|
|
| tok = CharTokenizer(text) |
| print(f"Vocab size: {tok.vocab_size}") |
| print(f"Vocabulary: {''.join(tok.itos[i] for i in range(tok.vocab_size))!r}") |
|
|
| sample = "To be, or not to be" |
| ids = tok.encode(sample) |
| print(f"\nencode({sample!r})\n -> {ids}") |
| print(f"decode(...)\n -> {tok.decode(ids)!r}") |
| assert tok.decode(ids) == sample, "Round-trip failed!" |
| print("\nRound-trip OK ✔") |
|
|