File size: 14,243 Bytes
4a6405d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
"""
OpenMind BPE Tokenizer - Built from scratch.

A complete Byte-Pair Encoding tokenizer implementation with:
- GPT-2 style pre-tokenization
- Unicode/UTF-8 support
- Special tokens handling
- Save/Load to JSON format (Hugging Face compatible)
"""

import json
import os
import regex
from collections import Counter, OrderedDict
from typing import Optional


class BPETokenizer:
    """
    Byte-Pair Encoding tokenizer trained from scratch.

    Supports:
    - Training on arbitrary text corpora
    - GPT-2 style regex pre-tokenization
    - Special tokens: <|endoftext|>, <|padding|>, <|unk|>
    - Encode/decode with full Unicode roundtrip
    - Save/load vocabulary and merges to disk
    """

    # GPT-2 style pre-tokenization regex
    PAT = regex.compile(
        r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
    )

    SPECIAL_TOKENS = OrderedDict([
        ("<|endoftext|>", 0),
        ("<|padding|>", 1),
        ("<|unk|>", 2),
        ("<|system|>", 3),
        ("<|user|>", 4),
        ("<|assistant|>", 5),
    ])

    def __init__(self, vocab_size: int = 32000):
        self.vocab_size = vocab_size

        # Initialize with byte-level tokens (256 bytes) + special tokens
        self.num_special = len(self.SPECIAL_TOKENS)
        self.num_base = 256  # One token per byte value

        # Vocab: special tokens (0..5) + byte tokens (6..261) + merges (262..)
        self.vocab: dict[int, bytes] = {}
        self.merges: dict[tuple[int, int], int] = {}
        self.merge_list: list[tuple[int, int]] = []

        # Inverse mappings
        self.token_to_id: dict[bytes, int] = {}
        self.special_token_ids: dict[str, int] = dict(self.SPECIAL_TOKENS)
        self.id_to_special: dict[int, str] = {v: k for k, v in self.SPECIAL_TOKENS.items()}

        self._build_base_vocab()

    def _build_base_vocab(self):
        """Initialize vocab with special tokens and 256 byte-level tokens."""
        # Add special tokens
        for token_str, token_id in self.SPECIAL_TOKENS.items():
            self.vocab[token_id] = token_str.encode("utf-8")

        # Add byte-level tokens (0x00 to 0xFF)
        for i in range(256):
            token_id = self.num_special + i
            byte_val = bytes([i])
            self.vocab[token_id] = byte_val
            self.token_to_id[byte_val] = token_id

    def _get_pair_counts(self, token_sequences: list[list[int]]) -> Counter:
        """Count frequency of adjacent token pairs across all sequences."""
        counts = Counter()
        for seq in token_sequences:
            for i in range(len(seq) - 1):
                counts[(seq[i], seq[i + 1])] += 1
        return counts

    def _merge_pair(
        self, token_sequences: list[list[int]], pair: tuple[int, int], new_id: int
    ) -> list[list[int]]:
        """Replace all occurrences of pair with new_id in all sequences."""
        result = []
        for seq in token_sequences:
            new_seq = []
            i = 0
            while i < len(seq):
                if i < len(seq) - 1 and seq[i] == pair[0] and seq[i + 1] == pair[1]:
                    new_seq.append(new_id)
                    i += 2
                else:
                    new_seq.append(seq[i])
                    i += 1
            result.append(new_seq)
        return result

    def train(self, corpus: str, vocab_size: Optional[int] = None, verbose: bool = True) -> None:
        """
        Train the BPE tokenizer on a text corpus.

        Args:
            corpus: Training text
            vocab_size: Target vocabulary size (default: self.vocab_size)
            verbose: Print progress during training
        """
        if vocab_size is not None:
            self.vocab_size = vocab_size

        num_merges = self.vocab_size - self.num_special - self.num_base

        if verbose:
            print(f"Training BPE tokenizer with target vocab size {self.vocab_size}")
            print(f"  Special tokens: {self.num_special}")
            print(f"  Byte tokens: {self.num_base}")
            print(f"  Merges to learn: {num_merges}")

        # Pre-tokenize: split corpus into words using GPT-2 regex
        words = regex.findall(self.PAT, corpus)

        # Convert each word to a sequence of byte-level token IDs
        word_freqs: dict[tuple[int, ...], int] = Counter()
        for word in words:
            byte_ids = tuple(self.num_special + b for b in word.encode("utf-8"))
            word_freqs[byte_ids] += 1

        # Expand into weighted token sequences
        token_sequences = []
        weights = []
        for seq, count in word_freqs.items():
            token_sequences.append(list(seq))
            weights.append(count)

        # Iteratively find and merge the most frequent pair
        for merge_idx in range(num_merges):
            # Count pairs (weighted by word frequency)
            pair_counts = Counter()
            for seq, w in zip(token_sequences, weights):
                for i in range(len(seq) - 1):
                    pair_counts[(seq[i], seq[i + 1])] += w

            if not pair_counts:
                if verbose:
                    print(f"No more pairs to merge at step {merge_idx}")
                break

            # Find most frequent pair
            best_pair = pair_counts.most_common(1)[0][0]
            new_id = self.num_special + self.num_base + merge_idx

            # Record the merge
            self.merges[best_pair] = new_id
            self.merge_list.append(best_pair)

            # Create the new token (concatenation of the two tokens' bytes)
            new_token = self.vocab[best_pair[0]] + self.vocab[best_pair[1]]
            self.vocab[new_id] = new_token
            self.token_to_id[new_token] = new_id

            # Apply merge to all sequences
            token_sequences = self._merge_pair(token_sequences, best_pair, new_id)

            if verbose and (merge_idx + 1) % 1000 == 0:
                pair_str = (
                    self.vocab[best_pair[0]].decode("utf-8", errors="replace")
                    + " + "
                    + self.vocab[best_pair[1]].decode("utf-8", errors="replace")
                )
                print(
                    f"  Merge {merge_idx + 1}/{num_merges}: "
                    f"{pair_str} -> id {new_id} "
                    f"(freq={pair_counts[best_pair]})"
                )

        if verbose:
            print(f"Training complete. Final vocab size: {len(self.vocab)}")

    def _encode_chunk(self, text_bytes: bytes) -> list[int]:
        """Encode a chunk of bytes into token IDs using learned merges."""
        # Start with byte-level tokens
        ids = [self.num_special + b for b in text_bytes]

        # Apply merges in order of learning
        for pair, new_id in self.merges.items():
            new_ids = []
            i = 0
            while i < len(ids):
                if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
                    new_ids.append(new_id)
                    i += 2
                else:
                    new_ids.append(ids[i])
                    i += 1
            ids = new_ids

        return ids

    def encode(
        self,
        text: str,
        allowed_special: Optional[set[str]] = None,
    ) -> list[int]:
        """
        Encode text into a list of token IDs.

        Args:
            text: Input text to encode
            allowed_special: Set of special token strings to recognize.
                             If None, no special tokens are processed.
                             Use {"all"} to allow all special tokens.

        Returns:
            List of integer token IDs
        """
        if allowed_special is None:
            allowed_special = set()

        if "all" in allowed_special:
            allowed_special = set(self.SPECIAL_TOKENS.keys())

        # Handle special tokens by splitting on them
        if allowed_special:
            # Build regex pattern for special tokens
            special_pattern = "(" + "|".join(
                regex.escape(s) for s in sorted(allowed_special, key=len, reverse=True)
            ) + ")"
            parts = regex.split(special_pattern, text)
        else:
            parts = [text]

        ids = []
        for part in parts:
            if part in self.special_token_ids and part in allowed_special:
                ids.append(self.special_token_ids[part])
            elif part:
                # Pre-tokenize with GPT-2 regex
                chunks = regex.findall(self.PAT, part)
                for chunk in chunks:
                    chunk_bytes = chunk.encode("utf-8")
                    ids.extend(self._encode_chunk(chunk_bytes))

        return ids

    def decode(self, ids: list[int]) -> str:
        """
        Decode a list of token IDs back into text.

        Args:
            ids: List of integer token IDs

        Returns:
            Decoded text string
        """
        byte_parts = []
        for token_id in ids:
            if token_id in self.id_to_special:
                byte_parts.append(self.id_to_special[token_id].encode("utf-8"))
            elif token_id in self.vocab:
                byte_parts.append(self.vocab[token_id])
            else:
                # Unknown token
                byte_parts.append(self.id_to_special.get(2, "<|unk|>").encode("utf-8"))

        return b"".join(byte_parts).decode("utf-8", errors="replace")

    def save(self, directory: str, name: str = "tokenizer") -> None:
        """
        Save tokenizer vocab and merges to disk.

        Creates two files:
        - {name}_vocab.json: Token ID -> token string mapping
        - {name}_merges.txt: Merge rules in order

        Args:
            directory: Output directory
            name: File name prefix
        """
        os.makedirs(directory, exist_ok=True)

        # Save vocab as JSON
        vocab_data = {
            "vocab_size": self.vocab_size,
            "num_special": self.num_special,
            "num_base": self.num_base,
            "special_tokens": dict(self.SPECIAL_TOKENS),
            "vocab": {},
        }

        for token_id, token_bytes in self.vocab.items():
            # Store bytes as list of ints for JSON serialization
            vocab_data["vocab"][str(token_id)] = list(token_bytes)

        vocab_path = os.path.join(directory, f"{name}_vocab.json")
        with open(vocab_path, "w", encoding="utf-8") as f:
            json.dump(vocab_data, f, indent=2)

        # Save merges
        merges_path = os.path.join(directory, f"{name}_merges.txt")
        with open(merges_path, "w", encoding="utf-8") as f:
            f.write(f"# OpenMind BPE Merges - {len(self.merge_list)} merges\n")
            for pair in self.merge_list:
                f.write(f"{pair[0]} {pair[1]}\n")

        # Save Hugging Face compatible tokenizer.json
        hf_vocab = {}
        for token_id, token_bytes in self.vocab.items():
            try:
                token_str = token_bytes.decode("utf-8")
            except UnicodeDecodeError:
                token_str = "".join(f"<0x{b:02X}>" for b in token_bytes)
            hf_vocab[token_str] = token_id

        hf_data = {
            "version": "1.0",
            "model": {
                "type": "BPE",
                "vocab": hf_vocab,
                "merges": [
                    f"{p[0]} {p[1]}" for p in self.merge_list
                ],
            },
            "added_tokens": [
                {"id": v, "content": k, "single_word": False, "lstrip": False,
                 "rstrip": False, "normalized": False, "special": True}
                for k, v in self.SPECIAL_TOKENS.items()
            ],
        }

        hf_path = os.path.join(directory, "tokenizer.json")
        with open(hf_path, "w", encoding="utf-8") as f:
            json.dump(hf_data, f, indent=2)

        print(f"Tokenizer saved to {directory}/")

    @classmethod
    def load(cls, directory: str, name: str = "tokenizer") -> "BPETokenizer":
        """
        Load a trained tokenizer from disk.

        Args:
            directory: Directory containing tokenizer files
            name: File name prefix

        Returns:
            Loaded BPETokenizer instance
        """
        vocab_path = os.path.join(directory, f"{name}_vocab.json")
        merges_path = os.path.join(directory, f"{name}_merges.txt")

        with open(vocab_path, "r", encoding="utf-8") as f:
            vocab_data = json.load(f)

        tokenizer = cls(vocab_size=vocab_data["vocab_size"])

        # Rebuild vocab
        tokenizer.vocab = {}
        tokenizer.token_to_id = {}
        for token_id_str, byte_list in vocab_data["vocab"].items():
            token_id = int(token_id_str)
            token_bytes = bytes(byte_list)
            tokenizer.vocab[token_id] = token_bytes
            if token_id >= tokenizer.num_special:
                tokenizer.token_to_id[token_bytes] = token_id

        # Rebuild merges
        tokenizer.merges = {}
        tokenizer.merge_list = []
        with open(merges_path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line.startswith("#") or not line:
                    continue
                parts = line.split()
                pair = (int(parts[0]), int(parts[1]))
                merge_id = tokenizer.num_special + tokenizer.num_base + len(tokenizer.merge_list)
                tokenizer.merges[pair] = merge_id
                tokenizer.merge_list.append(pair)

        print(f"Tokenizer loaded from {directory}/ (vocab size: {len(tokenizer.vocab)})")
        return tokenizer

    @property
    def eos_token_id(self) -> int:
        return self.SPECIAL_TOKENS["<|endoftext|>"]

    @property
    def pad_token_id(self) -> int:
        return self.SPECIAL_TOKENS["<|padding|>"]

    @property
    def unk_token_id(self) -> int:
        return self.SPECIAL_TOKENS["<|unk|>"]

    def __len__(self) -> int:
        return len(self.vocab)

    def __repr__(self) -> str:
        return (
            f"BPETokenizer(vocab_size={len(self.vocab)}, "
            f"merges={len(self.merge_list)}, "
            f"special_tokens={list(self.SPECIAL_TOKENS.keys())})"
        )