Open_Mind / src /data /tokenizer.py
Rachit17-12's picture
Initial commit
4a6405d
Raw
History Blame Contribute Delete
14.2 kB
"""
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())})"
)