Spaces:
Sleeping
Sleeping
Commit ·
742b752
1
Parent(s): b36a01b
Delete tokenizer.py
Browse files- tokenizer.py +0 -282
tokenizer.py
DELETED
|
@@ -1,282 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import logging
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from typing import Sequence, List, Optional
|
| 7 |
-
|
| 8 |
-
import sentencepiece as spm
|
| 9 |
-
|
| 10 |
-
logger = logging.getLogger(__name__)
|
| 11 |
-
|
| 12 |
-
# 32K is the well-established baseline vocab size for BPE/SentencePiece
|
| 13 |
-
# LLM tokenizers (Llama-1/2, T5, Gopher, Chinchilla all use exactly this).
|
| 14 |
-
# 128K+ only pays off for heavy multilingual/code coverage; for a small,
|
| 15 |
-
# largely-English, narrow-domain model, 32K is the standard, safe default.
|
| 16 |
-
DEFAULT_VOCAB_SIZE = 32000
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def train_sentencepiece(
|
| 20 |
-
data_files: Sequence[str],
|
| 21 |
-
model_prefix: str = 'tokenizer',
|
| 22 |
-
vocab_size: int = DEFAULT_VOCAB_SIZE,
|
| 23 |
-
model_type: str = 'bpe',
|
| 24 |
-
character_coverage: float = 0.9995,
|
| 25 |
-
byte_fallback: bool = True,
|
| 26 |
-
pad_id: int = 1,
|
| 27 |
-
unk_id: int = 0,
|
| 28 |
-
bos_id: int = 2,
|
| 29 |
-
eos_id: int = 3,
|
| 30 |
-
add_dummy_prefix: bool = True,
|
| 31 |
-
num_threads: int = 8,
|
| 32 |
-
input_sentence_size: int = 5_000_000,
|
| 33 |
-
shuffle_input_sentence: bool = True,
|
| 34 |
-
max_sentence_length: int = 16384,
|
| 35 |
-
split_digits: bool = True,
|
| 36 |
-
allow_whitespace_only_pieces: bool = True,
|
| 37 |
-
train_extremely_large_corpus: bool = False,
|
| 38 |
-
) -> str:
|
| 39 |
-
"""
|
| 40 |
-
Train a SentencePiece BPE tokenizer with byte-fallback — the same
|
| 41 |
-
scheme used by Llama-2, Mistral, and EuroLLM (BPE + byte_fallback via
|
| 42 |
-
SentencePiece specifically, not a hand-rolled BPE implementation).
|
| 43 |
-
|
| 44 |
-
Why SentencePiece and not a hand-written tiktoken export: SentencePiece's
|
| 45 |
-
C++ core does encode/decode and merge-rank bookkeeping internally and
|
| 46 |
-
natively — there is no manual ID-renumbering or rank-export step for
|
| 47 |
-
calling code to get wrong. (A prior tiktoken-based rewrite of this
|
| 48 |
-
tokenizer had exactly that class of bug: hand-exported merge ranks were
|
| 49 |
-
non-contiguous because special tokens occupied ids 0-3 in the source
|
| 50 |
-
vocab, silently corrupting merge-priority order and decode() mappings —
|
| 51 |
-
manifesting as repetitive garbage output like "to to to" despite a
|
| 52 |
-
healthy training loss. Delegating to SentencePiece's own encode/decode
|
| 53 |
-
removes that entire class of bug by construction.)
|
| 54 |
-
|
| 55 |
-
Notes on defaults:
|
| 56 |
-
- character_coverage < 1.0 with byte_fallback=True: rare glyphs fall
|
| 57 |
-
back to byte pieces instead of bloating the vocab with singletons.
|
| 58 |
-
- input_sentence_size + shuffle_input_sentence: without shuffling,
|
| 59 |
-
SentencePiece samples from the START of the concatenated corpus,
|
| 60 |
-
which silently biases vocab toward whichever domain file comes
|
| 61 |
-
first if you hand it multiple files back to back.
|
| 62 |
-
- split_digits: keeps numbers as individual digit tokens, which
|
| 63 |
-
generally helps arithmetic/math task tokenization consistency.
|
| 64 |
-
"""
|
| 65 |
-
data_files = [str(Path(p)) for p in data_files]
|
| 66 |
-
if not data_files:
|
| 67 |
-
raise ValueError('data_files is empty')
|
| 68 |
-
|
| 69 |
-
missing = [f for f in data_files if not Path(f).exists()]
|
| 70 |
-
if missing:
|
| 71 |
-
raise FileNotFoundError(f'Missing input files: {missing}')
|
| 72 |
-
|
| 73 |
-
kwargs = dict(
|
| 74 |
-
input=','.join(data_files),
|
| 75 |
-
model_prefix=model_prefix,
|
| 76 |
-
vocab_size=int(vocab_size),
|
| 77 |
-
model_type=model_type,
|
| 78 |
-
character_coverage=character_coverage,
|
| 79 |
-
pad_id=pad_id,
|
| 80 |
-
unk_id=unk_id,
|
| 81 |
-
bos_id=bos_id,
|
| 82 |
-
eos_id=eos_id,
|
| 83 |
-
byte_fallback=byte_fallback,
|
| 84 |
-
hard_vocab_limit=False,
|
| 85 |
-
normalization_rule_name='nmt_nfkc',
|
| 86 |
-
add_dummy_prefix=add_dummy_prefix,
|
| 87 |
-
num_threads=num_threads,
|
| 88 |
-
input_sentence_size=input_sentence_size,
|
| 89 |
-
shuffle_input_sentence=shuffle_input_sentence,
|
| 90 |
-
max_sentence_length=max_sentence_length,
|
| 91 |
-
split_digits=split_digits,
|
| 92 |
-
allow_whitespace_only_pieces=allow_whitespace_only_pieces,
|
| 93 |
-
train_extremely_large_corpus=train_extremely_large_corpus,
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
logger.info(f"Training SentencePiece: vocab_size={vocab_size} model_type={model_type} "
|
| 97 |
-
f"files={len(data_files)}")
|
| 98 |
-
spm.SentencePieceTrainer.train(**kwargs)
|
| 99 |
-
|
| 100 |
-
model_path = f'{model_prefix}.model'
|
| 101 |
-
_validate_trained_model(
|
| 102 |
-
model_path, vocab_size,
|
| 103 |
-
expected_pad=pad_id, expected_unk=unk_id, expected_bos=bos_id, expected_eos=eos_id,
|
| 104 |
-
)
|
| 105 |
-
return model_path
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
def _validate_trained_model(
|
| 109 |
-
model_path: str,
|
| 110 |
-
expected_vocab_size: int,
|
| 111 |
-
expected_pad: int,
|
| 112 |
-
expected_unk: int,
|
| 113 |
-
expected_bos: int,
|
| 114 |
-
expected_eos: int,
|
| 115 |
-
) -> None:
|
| 116 |
-
"""
|
| 117 |
-
Self-critique validation pass — checks the things that actually broke
|
| 118 |
-
in the previous (tiktoken) tokenizer, not just "does it load".
|
| 119 |
-
"""
|
| 120 |
-
sp = spm.SentencePieceProcessor(model_file=model_path)
|
| 121 |
-
|
| 122 |
-
# 1. Vocab size sanity
|
| 123 |
-
actual_vocab = sp.vocab_size()
|
| 124 |
-
if actual_vocab != expected_vocab_size:
|
| 125 |
-
logger.warning(f"Trained vocab_size={actual_vocab} differs from requested={expected_vocab_size} "
|
| 126 |
-
f"(hard_vocab_limit=False allows this if the corpus is small)")
|
| 127 |
-
|
| 128 |
-
# 2. Special token IDs must be EXACTLY what was requested — not just
|
| 129 |
-
# ">= 0". A previous bug class involved special-token ids silently
|
| 130 |
-
# drifting from what calling code assumed. Check explicitly, not
|
| 131 |
-
# loosely.
|
| 132 |
-
checks = [
|
| 133 |
-
('pad', sp.pad_id(), expected_pad),
|
| 134 |
-
('unk', sp.unk_id(), expected_unk),
|
| 135 |
-
('bos', sp.bos_id(), expected_bos),
|
| 136 |
-
('eos', sp.eos_id(), expected_eos),
|
| 137 |
-
]
|
| 138 |
-
for name, actual, expected in checks:
|
| 139 |
-
if actual < 0:
|
| 140 |
-
raise ValueError(f'Trained model missing <{name}> special token')
|
| 141 |
-
if actual != expected:
|
| 142 |
-
raise ValueError(
|
| 143 |
-
f'<{name}> id drift: requested {expected}, SentencePiece '
|
| 144 |
-
f'assigned {actual}. This mismatch is exactly the class of '
|
| 145 |
-
f'bug that broke a previous tokenizer version — refusing '
|
| 146 |
-
f'to silently proceed.'
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
# 3. Basic round-trip: encode -> decode must reproduce recognizable text
|
| 150 |
-
probe = "The quick brown fox jumps over 42 lazy dogs. def foo(): return None"
|
| 151 |
-
ids = sp.encode(probe, out_type=int)
|
| 152 |
-
if not ids:
|
| 153 |
-
raise ValueError('Validation encode produced empty output')
|
| 154 |
-
decoded = sp.decode(ids)
|
| 155 |
-
if not decoded.strip():
|
| 156 |
-
raise ValueError('Validation round-trip produced empty decode')
|
| 157 |
-
|
| 158 |
-
# 4. SPECIFIC regression check for the actual reported failure mode:
|
| 159 |
-
# repetitive-token degenerate decode ("to to to", ",,,"). This won't
|
| 160 |
-
# catch a MODEL that's actually stuck in a repetition loop (that's a
|
| 161 |
-
# decoding-strategy issue, separate from the tokenizer), but it DOES
|
| 162 |
-
# catch a tokenizer that maps distinct ids to the same or corrupted
|
| 163 |
-
# text, which was the real bug here: encode the same repeated-word
|
| 164 |
-
# probe multiple times and confirm token ids are stable and decode
|
| 165 |
-
# is exact, not degenerating into duplicated/garbled pieces.
|
| 166 |
-
repeat_probe = "to to to , , , the the the"
|
| 167 |
-
repeat_ids = sp.encode(repeat_probe, out_type=int)
|
| 168 |
-
repeat_decoded = sp.decode(repeat_ids)
|
| 169 |
-
# Re-encoding the decoded output should reproduce the same ids
|
| 170 |
-
# (idempotency) — this is the real symptom check: a corrupted rank/id
|
| 171 |
-
# mapping breaks exactly this property even when a single encode/decode
|
| 172 |
-
# pass looks fine.
|
| 173 |
-
reencoded_ids = sp.encode(repeat_decoded, out_type=int)
|
| 174 |
-
if reencoded_ids != repeat_ids:
|
| 175 |
-
raise ValueError(
|
| 176 |
-
f'Round-trip idempotency FAILED on repeated-token probe: '
|
| 177 |
-
f'encode->decode->encode did not reproduce the same ids. '
|
| 178 |
-
f'original={repeat_ids} reencoded={reencoded_ids}. This is '
|
| 179 |
-
f'the specific failure signature of an id/rank mapping bug.'
|
| 180 |
-
)
|
| 181 |
-
|
| 182 |
-
# 5. Byte-fallback sanity: an unusual/rare unicode character must not
|
| 183 |
-
# crash and must not silently become <unk> if byte_fallback is on —
|
| 184 |
-
# it should decompose into byte pieces instead.
|
| 185 |
-
exotic_probe = "emoji test \U0001F600 and rare char \u0800"
|
| 186 |
-
exotic_ids = sp.encode(exotic_probe, out_type=int)
|
| 187 |
-
if not exotic_ids:
|
| 188 |
-
raise ValueError('Byte-fallback validation: exotic-character probe produced empty encode')
|
| 189 |
-
exotic_decoded = sp.decode(exotic_ids)
|
| 190 |
-
if not exotic_decoded.strip():
|
| 191 |
-
raise ValueError('Byte-fallback validation: exotic-character round-trip produced empty decode')
|
| 192 |
-
|
| 193 |
-
logger.info(f"✓ Validation OK: vocab={actual_vocab} probe_tokens={len(ids)} "
|
| 194 |
-
f"round-trip idempotency verified, byte-fallback verified")
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
class TokenizerWrapper:
|
| 198 |
-
def __init__(self, model_path: str):
|
| 199 |
-
model_path = str(Path(model_path))
|
| 200 |
-
if not Path(model_path).exists():
|
| 201 |
-
raise FileNotFoundError(model_path)
|
| 202 |
-
self.sp = spm.SentencePieceProcessor(model_file=model_path)
|
| 203 |
-
self.vocab_size = int(self.sp.vocab_size())
|
| 204 |
-
self.pad_id = self.sp.pad_id()
|
| 205 |
-
self.unk_id = self.sp.unk_id()
|
| 206 |
-
self.bos_id = self.sp.bos_id()
|
| 207 |
-
self.eos_id = self.sp.eos_id()
|
| 208 |
-
for name, val in [('pad', self.pad_id), ('unk', self.unk_id), ('bos', self.bos_id), ('eos', self.eos_id)]:
|
| 209 |
-
if val < 0:
|
| 210 |
-
raise ValueError(f'SentencePiece model missing <{name}>')
|
| 211 |
-
self._special_ids = {self.pad_id, self.bos_id, self.eos_id}
|
| 212 |
-
|
| 213 |
-
def encode(self, text: str, add_bos: bool = True, add_eos: bool = False) -> List[int]:
|
| 214 |
-
if text is None:
|
| 215 |
-
raise ValueError('encode() received None')
|
| 216 |
-
if text == '':
|
| 217 |
-
ids: List[int] = []
|
| 218 |
-
else:
|
| 219 |
-
ids = list(self.sp.encode(text, out_type=int))
|
| 220 |
-
if add_bos:
|
| 221 |
-
ids = [self.bos_id] + ids
|
| 222 |
-
if add_eos:
|
| 223 |
-
ids = ids + [self.eos_id]
|
| 224 |
-
return ids
|
| 225 |
-
|
| 226 |
-
def encode_batch(
|
| 227 |
-
self,
|
| 228 |
-
texts: Sequence[str],
|
| 229 |
-
add_bos: bool = True,
|
| 230 |
-
add_eos: bool = False,
|
| 231 |
-
skip_errors: bool = False,
|
| 232 |
-
) -> List[List[int]]:
|
| 233 |
-
out: List[List[int]] = []
|
| 234 |
-
for i, t in enumerate(texts):
|
| 235 |
-
try:
|
| 236 |
-
out.append(self.encode(t, add_bos=add_bos, add_eos=add_eos))
|
| 237 |
-
except Exception as e:
|
| 238 |
-
if skip_errors:
|
| 239 |
-
logger.warning(f"encode_batch: skipping item {i} ({e})")
|
| 240 |
-
continue
|
| 241 |
-
raise
|
| 242 |
-
return out
|
| 243 |
-
|
| 244 |
-
def decode(self, ids: Sequence[int], skip_special_tokens: bool = True) -> str:
|
| 245 |
-
# Drop anything outside the valid piece-id range first. This is
|
| 246 |
-
# required, not cosmetic: PyTorch's ignore_index=-100 convention for
|
| 247 |
-
# masked label positions means `ids` is very commonly a raw labels
|
| 248 |
-
# tensor, and sp.decode() raises IndexError on any id < 0 or
|
| 249 |
-
# >= vocab_size instead of skipping it.
|
| 250 |
-
ids = [int(i) for i in ids if 0 <= int(i) < self.vocab_size]
|
| 251 |
-
if skip_special_tokens:
|
| 252 |
-
filtered = [i for i in ids if i not in self._special_ids]
|
| 253 |
-
else:
|
| 254 |
-
filtered = [i for i in ids if i != self.pad_id]
|
| 255 |
-
return self.sp.decode(filtered)
|
| 256 |
-
|
| 257 |
-
def decode_batch(self, batch_ids: Sequence[Sequence[int]], skip_special_tokens: bool = True) -> List[str]:
|
| 258 |
-
return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids]
|
| 259 |
-
|
| 260 |
-
def save_config(self, path: str) -> None:
|
| 261 |
-
Path(path).write_text(json.dumps({
|
| 262 |
-
'vocab_size': self.vocab_size,
|
| 263 |
-
'pad_id': self.pad_id,
|
| 264 |
-
'unk_id': self.unk_id,
|
| 265 |
-
'bos_id': self.bos_id,
|
| 266 |
-
'eos_id': self.eos_id,
|
| 267 |
-
}, indent=2), encoding='utf-8')
|
| 268 |
-
|
| 269 |
-
@classmethod
|
| 270 |
-
def from_config(cls, model_path: str, config_path: Optional[str] = None) -> 'TokenizerWrapper':
|
| 271 |
-
"""Load and, if a config is given, verify special-id consistency against it."""
|
| 272 |
-
tok = cls(model_path)
|
| 273 |
-
if config_path and Path(config_path).exists():
|
| 274 |
-
cfg = json.loads(Path(config_path).read_text(encoding='utf-8'))
|
| 275 |
-
mismatches = {
|
| 276 |
-
k: (cfg[k], getattr(tok, k))
|
| 277 |
-
for k in ('vocab_size', 'pad_id', 'unk_id', 'bos_id', 'eos_id')
|
| 278 |
-
if k in cfg and cfg[k] != getattr(tok, k)
|
| 279 |
-
}
|
| 280 |
-
if mismatches:
|
| 281 |
-
raise ValueError(f'Tokenizer/config mismatch: {mismatches}')
|
| 282 |
-
return tok
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|