Spaces:
Sleeping
Sleeping
| """ | |
| 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}/") | |
| 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 | |
| def eos_token_id(self) -> int: | |
| return self.SPECIAL_TOKENS["<|endoftext|>"] | |
| def pad_token_id(self) -> int: | |
| return self.SPECIAL_TOKENS["<|padding|>"] | |
| 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())})" | |
| ) | |