Download src/bpe_tokenizer.py from erenyanic/eldamar-tokenizer: direct link, hf CLI and curl.
- Browser
- Download file 4.84 kB
-
https://huggingface.co/erenyanic/eldamar-tokenizer/resolve/main/src/bpe_tokenizer.py
- Command line
-
hf download hf://erenyanic/eldamar-tokenizer/src/bpe_tokenizer.py
-
curl -L -o bpe_tokenizer.py https://huggingface.co/erenyanic/eldamar-tokenizer/resolve/main/src/bpe_tokenizer.py
4.84 kB
| """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)) | |
| # Isolate newlines: each name becomes one pre-token, each "\n" its own -- so | |
| # the trainer only ever merges character pairs *within* a single name. | |
| tokenizer.pre_tokenizer = pre_tokenizers.Split(pattern=NEWLINE, behavior="isolated") | |
| # A no-op decoder; our wrapper reconstructs text by concatenating token | |
| # strings directly (the tokens are literal substrings, so this is exact). | |
| tokenizer.decoder = decoders.Fuse() | |
| trainer = trainers.BpeTrainer( | |
| vocab_size=vocab_size, | |
| min_frequency=1, # allow even rare pairs, so small data can grow the vocab | |
| show_progress=False, | |
| special_tokens=[], # "\n" is a normal character token, not a special one | |
| ) | |
| 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.") | |
| # The newline both separates names and marks end-of-sequence (EOS). | |
| self.newline_id = newline_id | |
| self.eos_id = newline_id | |
| 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: | |
| # Concatenate the literal token strings -- exact for character-level BPE, | |
| # and avoids the space-joining that Tokenizer.decode applies by default. | |
| 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) | |
| # Round-trip the whole corpus to prove encode/decode is lossless. | |
| 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() | |