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()