| """Character-level BPE tokeniser for the Middle-earth names corpus. |
| |
| We use Hugging Face's ``tokenizers`` library (the Rust-backed industry standard) |
| rather than hand-rolling BPE. The tokeniser is deliberately *character* level: |
| |
| * No ByteLevel pre-tokeniser, so the base alphabet is our ~42 real characters |
| (each diacritic stays a single base token) instead of 256 raw bytes. That |
| keeps both vocab targets -- 256 and 512 -- meaningful merge counts rather |
| than making "vocab 256" a degenerate zero-merge tokeniser. |
| * A ``Split("\n", isolated)`` pre-tokeniser makes every name its own unit and |
| every newline a lone token, so merges never cross a name boundary and "\n" |
| survives as the start/end-of-name marker (EOS) the models depend on. |
| |
| The ``BpeTokenizer`` wrapper mirrors the repo's ``CharTokenizer`` interface |
| (``encode`` / ``decode`` / ``vocab_size`` / ``newline_id`` / ``eos_id``) so the |
| four model folders need almost no change to train on it. |
| |
| Run: python src/bpe_tokenizer.py # trains vocab 256 and 512, self-tests |
| Out: bpe/bpe_256.json, bpe/bpe_512.json |
| """ |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
|
|
| from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers |
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| NAMES_FILE = ROOT / "data" / "middle_earth_names.txt" |
| BPE_DIR = ROOT / "bpe" |
|
|
| NEWLINE = "\n" |
| VOCAB_SIZES = (256, 512) |
|
|
|
|
| def build_bpe(names_path: Path, vocab_size: int) -> "BpeTokenizer": |
| """Train a fresh character-level BPE tokeniser on a one-name-per-line file.""" |
| tokenizer = Tokenizer(models.BPE(unk_token=None)) |
| |
| |
| tokenizer.pre_tokenizer = pre_tokenizers.Split(pattern=NEWLINE, behavior="isolated") |
| |
| |
| tokenizer.decoder = decoders.Fuse() |
|
|
| trainer = trainers.BpeTrainer( |
| vocab_size=vocab_size, |
| min_frequency=1, |
| show_progress=False, |
| special_tokens=[], |
| ) |
| tokenizer.train([str(names_path)], trainer) |
| return BpeTokenizer(tokenizer) |
|
|
|
|
| class BpeTokenizer: |
| """Thin adapter around a trained ``tokenizers.Tokenizer`` mirroring CharTokenizer.""" |
|
|
| def __init__(self, tokenizer: Tokenizer): |
| self._tok = tokenizer |
| self.vocab_size = tokenizer.get_vocab_size() |
| newline_id = tokenizer.token_to_id(NEWLINE) |
| if newline_id is None: |
| raise ValueError("The trained vocabulary has no newline token.") |
| |
| self.newline_id = newline_id |
| self.eos_id = newline_id |
|
|
| @classmethod |
| def from_file(cls, path: str | Path) -> "BpeTokenizer": |
| return cls(Tokenizer.from_file(str(path))) |
|
|
| def save(self, path: str | Path) -> None: |
| Path(path).parent.mkdir(parents=True, exist_ok=True) |
| self._tok.save(str(path)) |
|
|
| def encode(self, text: str) -> list[int]: |
| return self._tok.encode(text, add_special_tokens=False).ids |
|
|
| def decode(self, ids: list[int]) -> str: |
| |
| |
| return "".join(self._tok.id_to_token(i) for i in ids) |
|
|
|
|
| def main() -> None: |
| text = NAMES_FILE.read_text(encoding="utf-8") |
| names = [n for n in text.split(NEWLINE) if n] |
| base_alphabet = sorted(set(text) - {NEWLINE}) |
| print(f"Corpus: {len(names)} names, {len(text)} chars, " |
| f"{len(base_alphabet)} base letters (+ newline)\n") |
|
|
| for vocab_size in VOCAB_SIZES: |
| tok = build_bpe(NAMES_FILE, vocab_size) |
| save_path = BPE_DIR / f"bpe_{vocab_size}.json" |
| tok.save(save_path) |
|
|
| |
| ids = tok.encode(text) |
| assert tok.decode(ids) == text, "round-trip mismatch!" |
|
|
| avg_tokens = sum(len(tok.encode(n)) for n in names) / len(names) |
| print(f"vocab {vocab_size}: reached {tok.vocab_size} tokens " |
| f"(target {vocab_size}), newline_id={tok.newline_id}") |
| print(f" corpus encodes to {len(ids)} ids; {avg_tokens:.2f} tokens/name") |
| for sample in ("isildur", "gondor", "galadriel", "éowyn"): |
| pieces = [tok._tok.id_to_token(i) for i in tok.encode(sample)] |
| print(f" {sample:<11} -> {pieces}") |
| print(f" saved {save_path.relative_to(ROOT)}\n") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|