File size: 4,838 Bytes
c60ec61 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | """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
@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:
# 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()
|