Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import logging | |
| from pathlib import Path | |
| from typing import Sequence, List, Optional | |
| import sentencepiece as spm | |
| logger = logging.getLogger(__name__) | |
| # 32K is the well-established baseline vocab size for BPE/SentencePiece | |
| # LLM tokenizers (Llama-1/2, T5, Gopher, Chinchilla all use exactly this). | |
| # 128K+ only pays off for heavy multilingual/code coverage; for a small, | |
| # largely-English, narrow-domain model, 32K is the standard, safe default. | |
| DEFAULT_VOCAB_SIZE = 32000 | |
| def train_sentencepiece( | |
| data_files: Sequence[str], | |
| model_prefix: str = 'tokenizer', | |
| vocab_size: int = DEFAULT_VOCAB_SIZE, | |
| model_type: str = 'bpe', | |
| character_coverage: float = 0.9995, | |
| byte_fallback: bool = True, | |
| pad_id: int = 1, | |
| unk_id: int = 0, | |
| bos_id: int = 2, | |
| eos_id: int = 3, | |
| add_dummy_prefix: bool = True, | |
| num_threads: int = 8, | |
| input_sentence_size: int = 5_000_000, | |
| shuffle_input_sentence: bool = True, | |
| max_sentence_length: int = 16384, | |
| split_digits: bool = True, | |
| allow_whitespace_only_pieces: bool = True, | |
| train_extremely_large_corpus: bool = False, | |
| ) -> str: | |
| """ | |
| Train a SentencePiece BPE tokenizer with byte-fallback — the same | |
| scheme used by Llama-2, Mistral, and EuroLLM (BPE + byte_fallback via | |
| SentencePiece specifically, not a hand-rolled BPE implementation). | |
| Why SentencePiece and not a hand-written tiktoken export: SentencePiece's | |
| C++ core does encode/decode and merge-rank bookkeeping internally and | |
| natively — there is no manual ID-renumbering or rank-export step for | |
| calling code to get wrong. (A prior tiktoken-based rewrite of this | |
| tokenizer had exactly that class of bug: hand-exported merge ranks were | |
| non-contiguous because special tokens occupied ids 0-3 in the source | |
| vocab, silently corrupting merge-priority order and decode() mappings — | |
| manifesting as repetitive garbage output like "to to to" despite a | |
| healthy training loss. Delegating to SentencePiece's own encode/decode | |
| removes that entire class of bug by construction.) | |
| Notes on defaults: | |
| - character_coverage < 1.0 with byte_fallback=True: rare glyphs fall | |
| back to byte pieces instead of bloating the vocab with singletons. | |
| - input_sentence_size + shuffle_input_sentence: without shuffling, | |
| SentencePiece samples from the START of the concatenated corpus, | |
| which silently biases vocab toward whichever domain file comes | |
| first if you hand it multiple files back to back. | |
| - split_digits: keeps numbers as individual digit tokens, which | |
| generally helps arithmetic/math task tokenization consistency. | |
| """ | |
| data_files = [str(Path(p)) for p in data_files] | |
| if not data_files: | |
| raise ValueError('data_files is empty') | |
| missing = [f for f in data_files if not Path(f).exists()] | |
| if missing: | |
| raise FileNotFoundError(f'Missing input files: {missing}') | |
| kwargs = dict( | |
| input=','.join(data_files), | |
| model_prefix=model_prefix, | |
| vocab_size=int(vocab_size), | |
| model_type=model_type, | |
| character_coverage=character_coverage, | |
| pad_id=pad_id, | |
| unk_id=unk_id, | |
| bos_id=bos_id, | |
| eos_id=eos_id, | |
| byte_fallback=byte_fallback, | |
| hard_vocab_limit=False, | |
| normalization_rule_name='nmt_nfkc', | |
| add_dummy_prefix=add_dummy_prefix, | |
| num_threads=num_threads, | |
| input_sentence_size=input_sentence_size, | |
| shuffle_input_sentence=shuffle_input_sentence, | |
| max_sentence_length=max_sentence_length, | |
| split_digits=split_digits, | |
| allow_whitespace_only_pieces=allow_whitespace_only_pieces, | |
| train_extremely_large_corpus=train_extremely_large_corpus, | |
| ) | |
| logger.info(f"Training SentencePiece: vocab_size={vocab_size} model_type={model_type} " | |
| f"files={len(data_files)}") | |
| spm.SentencePieceTrainer.train(**kwargs) | |
| model_path = f'{model_prefix}.model' | |
| _validate_trained_model( | |
| model_path, vocab_size, | |
| expected_pad=pad_id, expected_unk=unk_id, expected_bos=bos_id, expected_eos=eos_id, | |
| ) | |
| return model_path | |
| def _validate_trained_model( | |
| model_path: str, | |
| expected_vocab_size: int, | |
| expected_pad: int, | |
| expected_unk: int, | |
| expected_bos: int, | |
| expected_eos: int, | |
| ) -> None: | |
| """ | |
| Self-critique validation pass — checks the things that actually broke | |
| in the previous (tiktoken) tokenizer, not just "does it load". | |
| """ | |
| sp = spm.SentencePieceProcessor(model_file=model_path) | |
| # 1. Vocab size sanity | |
| actual_vocab = sp.vocab_size() | |
| if actual_vocab != expected_vocab_size: | |
| logger.warning(f"Trained vocab_size={actual_vocab} differs from requested={expected_vocab_size} " | |
| f"(hard_vocab_limit=False allows this if the corpus is small)") | |
| # 2. Special token IDs must be EXACTLY what was requested — not just | |
| # ">= 0". A previous bug class involved special-token ids silently | |
| # drifting from what calling code assumed. Check explicitly, not | |
| # loosely. | |
| checks = [ | |
| ('pad', sp.pad_id(), expected_pad), | |
| ('unk', sp.unk_id(), expected_unk), | |
| ('bos', sp.bos_id(), expected_bos), | |
| ('eos', sp.eos_id(), expected_eos), | |
| ] | |
| for name, actual, expected in checks: | |
| if actual < 0: | |
| raise ValueError(f'Trained model missing <{name}> special token') | |
| if actual != expected: | |
| raise ValueError( | |
| f'<{name}> id drift: requested {expected}, SentencePiece ' | |
| f'assigned {actual}. This mismatch is exactly the class of ' | |
| f'bug that broke a previous tokenizer version — refusing ' | |
| f'to silently proceed.' | |
| ) | |
| # 3. Basic round-trip: encode -> decode must reproduce recognizable text | |
| probe = "The quick brown fox jumps over 42 lazy dogs. def foo(): return None" | |
| ids = sp.encode(probe, out_type=int) | |
| if not ids: | |
| raise ValueError('Validation encode produced empty output') | |
| decoded = sp.decode(ids) | |
| if not decoded.strip(): | |
| raise ValueError('Validation round-trip produced empty decode') | |
| # 4. SPECIFIC regression check for the actual reported failure mode: | |
| # repetitive-token degenerate decode ("to to to", ",,,"). This won't | |
| # catch a MODEL that's actually stuck in a repetition loop (that's a | |
| # decoding-strategy issue, separate from the tokenizer), but it DOES | |
| # catch a tokenizer that maps distinct ids to the same or corrupted | |
| # text, which was the real bug here: encode the same repeated-word | |
| # probe multiple times and confirm token ids are stable and decode | |
| # is exact, not degenerating into duplicated/garbled pieces. | |
| repeat_probe = "to to to , , , the the the" | |
| repeat_ids = sp.encode(repeat_probe, out_type=int) | |
| repeat_decoded = sp.decode(repeat_ids) | |
| # Re-encoding the decoded output should reproduce the same ids | |
| # (idempotency) — this is the real symptom check: a corrupted rank/id | |
| # mapping breaks exactly this property even when a single encode/decode | |
| # pass looks fine. | |
| reencoded_ids = sp.encode(repeat_decoded, out_type=int) | |
| if reencoded_ids != repeat_ids: | |
| raise ValueError( | |
| f'Round-trip idempotency FAILED on repeated-token probe: ' | |
| f'encode->decode->encode did not reproduce the same ids. ' | |
| f'original={repeat_ids} reencoded={reencoded_ids}. This is ' | |
| f'the specific failure signature of an id/rank mapping bug.' | |
| ) | |
| # 5. Byte-fallback sanity: an unusual/rare unicode character must not | |
| # crash and must not silently become <unk> if byte_fallback is on — | |
| # it should decompose into byte pieces instead. | |
| exotic_probe = "emoji test \U0001F600 and rare char \u0800" | |
| exotic_ids = sp.encode(exotic_probe, out_type=int) | |
| if not exotic_ids: | |
| raise ValueError('Byte-fallback validation: exotic-character probe produced empty encode') | |
| exotic_decoded = sp.decode(exotic_ids) | |
| if not exotic_decoded.strip(): | |
| raise ValueError('Byte-fallback validation: exotic-character round-trip produced empty decode') | |
| logger.info(f"✓ Validation OK: vocab={actual_vocab} probe_tokens={len(ids)} " | |
| f"round-trip idempotency verified, byte-fallback verified") | |
| class TokenizerWrapper: | |
| def __init__(self, model_path: str): | |
| model_path = str(Path(model_path)) | |
| if not Path(model_path).exists(): | |
| raise FileNotFoundError(model_path) | |
| self.sp = spm.SentencePieceProcessor(model_file=model_path) | |
| self.vocab_size = int(self.sp.vocab_size()) | |
| self.pad_id = self.sp.pad_id() | |
| self.unk_id = self.sp.unk_id() | |
| self.bos_id = self.sp.bos_id() | |
| self.eos_id = self.sp.eos_id() | |
| for name, val in [('pad', self.pad_id), ('unk', self.unk_id), ('bos', self.bos_id), ('eos', self.eos_id)]: | |
| if val < 0: | |
| raise ValueError(f'SentencePiece model missing <{name}>') | |
| self._special_ids = {self.pad_id, self.bos_id, self.eos_id} | |
| def encode(self, text: str, add_bos: bool = True, add_eos: bool = False) -> List[int]: | |
| if text is None: | |
| raise ValueError('encode() received None') | |
| if text == '': | |
| ids: List[int] = [] | |
| else: | |
| ids = list(self.sp.encode(text, out_type=int)) | |
| if add_bos: | |
| ids = [self.bos_id] + ids | |
| if add_eos: | |
| ids = ids + [self.eos_id] | |
| return ids | |
| def encode_batch( | |
| self, | |
| texts: Sequence[str], | |
| add_bos: bool = True, | |
| add_eos: bool = False, | |
| skip_errors: bool = False, | |
| ) -> List[List[int]]: | |
| out: List[List[int]] = [] | |
| for i, t in enumerate(texts): | |
| try: | |
| out.append(self.encode(t, add_bos=add_bos, add_eos=add_eos)) | |
| except Exception as e: | |
| if skip_errors: | |
| logger.warning(f"encode_batch: skipping item {i} ({e})") | |
| continue | |
| raise | |
| return out | |
| def decode(self, ids: Sequence[int], skip_special_tokens: bool = True) -> str: | |
| # Drop anything outside the valid piece-id range first. This is | |
| # required, not cosmetic: PyTorch's ignore_index=-100 convention for | |
| # masked label positions means `ids` is very commonly a raw labels | |
| # tensor, and sp.decode() raises IndexError on any id < 0 or | |
| # >= vocab_size instead of skipping it. | |
| ids = [int(i) for i in ids if 0 <= int(i) < self.vocab_size] | |
| if skip_special_tokens: | |
| filtered = [i for i in ids if i not in self._special_ids] | |
| else: | |
| filtered = [i for i in ids if i != self.pad_id] | |
| return self.sp.decode(filtered) | |
| def decode_batch(self, batch_ids: Sequence[Sequence[int]], skip_special_tokens: bool = True) -> List[str]: | |
| return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids] | |
| def save_config(self, path: str) -> None: | |
| Path(path).write_text(json.dumps({ | |
| 'vocab_size': self.vocab_size, | |
| 'pad_id': self.pad_id, | |
| 'unk_id': self.unk_id, | |
| 'bos_id': self.bos_id, | |
| 'eos_id': self.eos_id, | |
| }, indent=2), encoding='utf-8') | |
| def from_config(cls, model_path: str, config_path: Optional[str] = None) -> 'TokenizerWrapper': | |
| """Load and, if a config is given, verify special-id consistency against it.""" | |
| tok = cls(model_path) | |
| if config_path and Path(config_path).exists(): | |
| cfg = json.loads(Path(config_path).read_text(encoding='utf-8')) | |
| mismatches = { | |
| k: (cfg[k], getattr(tok, k)) | |
| for k in ('vocab_size', 'pad_id', 'unk_id', 'bos_id', 'eos_id') | |
| if k in cfg and cfg[k] != getattr(tok, k) | |
| } | |
| if mismatches: | |
| raise ValueError(f'Tokenizer/config mismatch: {mismatches}') | |
| return tok |