ArushBuilds commited on
Commit
742b752
·
1 Parent(s): b36a01b

Delete tokenizer.py

Browse files
Files changed (1) hide show
  1. 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