Compactbot commited on
Commit
7765cd2
·
verified ·
1 Parent(s): 3e41983

Add tokenizer_inspector.py (v3)

Browse files
Files changed (1) hide show
  1. tokenizer_inspector.py +225 -0
tokenizer_inspector.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ tokenizer_inspector.py v3 — Inspect and compare Hugging Face tokenizers.
4
+
5
+ Accepts repo IDs (e.g. gpt2, HuggingFaceTB/SmolLM2-135M) or local paths to
6
+ tokenizer.json files. Uses the `tokenizers` library for accurate tokenization.
7
+
8
+ Usage:
9
+ python3 tokenizer_inspector.py gpt2
10
+ python3 tokenizer_inspector.py gpt2 HuggingFaceTB/SmolLM2-135M --compare
11
+ python3 tokenizer_inspector.py gpt2 --text "hello world"
12
+ python3 tokenizer_inspector.py gpt2 --show-tokens
13
+
14
+ What it reports:
15
+ - Vocab size, token type (BPE / SentencePiece / byte-level)
16
+ - Special tokens
17
+ - chars/token on a test corpus (higher = more compact)
18
+ - Vocab utilization: what fraction of the vocab does your data actually use?
19
+ - Sample of interesting tokens (longest, shortest, most common)
20
+ - In --compare mode: side-by-side comparison table
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import sys
26
+ from collections import Counter
27
+ from pathlib import Path
28
+
29
+ # Default test corpus: mixed English + code + numbers
30
+ DEFAULT_CORPUS = """\
31
+ The quick brown fox jumps over the lazy dog.
32
+ def fibonacci(n):
33
+ if n <= 1: return n
34
+ return fibonacci(n-1) + fibonacci(n-2)
35
+ print(fibonacci(10))
36
+ #!/usr/bin/env python3
37
+ import numpy as np
38
+ arr = np.zeros((1024, 1024), dtype=np.float32)
39
+ print(arr.sum())
40
+ The quick brown fox jumps over the lazy dog.
41
+ """
42
+
43
+
44
+ def load_tokenizer(source: str):
45
+ """Load a tokenizer from a HF repo ID or local path. Returns (Tokenizer, display_name, json_path_or_None)."""
46
+ from tokenizers import Tokenizer
47
+
48
+ # Try local path first
49
+ local = Path(source)
50
+ if local.exists() and local.is_file():
51
+ return Tokenizer.from_file(str(local)), local.name, str(local)
52
+
53
+ if local.exists() and local.is_dir():
54
+ for name in ("tokenizer.json", "tokenizer.model"):
55
+ p = local / name
56
+ if p.exists():
57
+ return Tokenizer.from_file(str(p)), source, str(p)
58
+
59
+ # Try Hugging Face
60
+ try:
61
+ from huggingface_hub import hf_hub_download
62
+ path = hf_hub_download(repo_id=source, filename="tokenizer.json")
63
+ return Tokenizer.from_file(path), source, path
64
+ except Exception:
65
+ pass
66
+ try:
67
+ from huggingface_hub import hf_hub_download
68
+ path = hf_hub_download(repo_id=source, filename="tokenizer.model")
69
+ return Tokenizer.from_file(path), source, None
70
+ except Exception as e:
71
+ print(f"ERROR: cannot load tokenizer for '{source}': {e}", file=sys.stderr)
72
+ sys.exit(1)
73
+
74
+
75
+ def get_special_tokens(json_path):
76
+ """Read special/added tokens from the tokenizer JSON."""
77
+ if not json_path:
78
+ return []
79
+ try:
80
+ with open(json_path) as f:
81
+ data = json.load(f)
82
+ added = data.get("added_tokens", [])
83
+ return [t["content"] for t in added]
84
+ except Exception:
85
+ return []
86
+
87
+
88
+ def detect_token_type(tok) -> str:
89
+ """Detect the tokenization scheme."""
90
+ s = tok.to_str()
91
+ head = s[:3000]
92
+ if "ByteLevel" in head:
93
+ return "byte-level BPE"
94
+ if "BPE" in head:
95
+ return "BPE"
96
+ if "Unigram" in head:
97
+ return "Unigram (SentencePiece)"
98
+ if "WordPiece" in head:
99
+ return "WordPiece"
100
+ if "WordLevel" in head:
101
+ return "WordLevel"
102
+ return "unknown"
103
+
104
+
105
+ def analyze(source: str, corpus: str, show_tokens: bool = False) -> dict:
106
+ """Analyze one tokenizer and return a metrics dict."""
107
+ tok, name, json_path = load_tokenizer(source)
108
+ vocab_size = tok.get_vocab_size()
109
+ token_type = detect_token_type(tok)
110
+ special_tokens = get_special_tokens(json_path)
111
+
112
+ enc = tok.encode(corpus)
113
+ tokens = enc.tokens
114
+ n_tokens = len(tokens)
115
+ n_chars = len(corpus)
116
+ chars_per_token = n_chars / max(n_tokens, 1)
117
+
118
+ freq = Counter(tokens)
119
+ most_common = freq.most_common(10)
120
+
121
+ # Vocab utilization: how many distinct vocab tokens appear in the corpus?
122
+ distinct_used = set(tokens)
123
+ utilization = len(distinct_used) / max(vocab_size, 1)
124
+
125
+ # Longest / shortest real tokens (exclude special)
126
+ vocab = tok.get_vocab()
127
+ real = [t for t in vocab if t and not (t.startswith("<") and t.endswith(">"))]
128
+ longest = max(real, key=len) if real else ("", 0)
129
+ shortest = min((t for t in real if t), key=len, default="")
130
+
131
+ # Roundtrip check
132
+ decoded = tok.decode(enc.ids)
133
+ roundtrip_ok = decoded.strip() == corpus.strip()
134
+
135
+ result = {
136
+ "repo": name,
137
+ "vocab_size": vocab_size,
138
+ "token_type": token_type,
139
+ "n_special": len(special_tokens),
140
+ "special_sample": special_tokens[:5],
141
+ "corpus_chars": n_chars,
142
+ "corpus_tokens": n_tokens,
143
+ "chars_per_token": round(chars_per_token, 2),
144
+ "vocab_utilization": round(utilization, 4),
145
+ "distinct_tokens_used": len(distinct_used),
146
+ "most_common": most_common,
147
+ "longest_token": (longest[:40], len(longest)),
148
+ "shortest_token": (shortest[:40], len(shortest)),
149
+ "roundtrip_ok": roundtrip_ok,
150
+ }
151
+ if show_tokens:
152
+ result["sample_tokens"] = tokens[:30]
153
+ return result
154
+
155
+
156
+ def print_report(r: dict, show_tokens: bool = False):
157
+ print(f"\n{'='*60}")
158
+ print(f" {r['repo']}")
159
+ print(f"{'='*60}")
160
+ print(f" Vocab size: {r['vocab_size']:,}")
161
+ print(f" Token type: {r['token_type']}")
162
+ print(f" Special tokens: {r['n_special']} ({', '.join(r['special_sample'])}{'...' if r['n_special'] > 5 else ''})")
163
+ print(f" Corpus: {r['corpus_chars']} chars -> {r['corpus_tokens']} tokens")
164
+ print(f" Chars/token: {r['chars_per_token']}")
165
+ print(f" Vocab utilization: {r['vocab_utilization']:.2%} ({r['distinct_tokens_used']} distinct)")
166
+ print(f" Longest token: {r['longest_token'][0]!r} ({r['longest_token'][1]} chars)")
167
+ print(f" Shortest token: {r['shortest_token'][0]!r} ({r['shortest_token'][1]} chars)")
168
+ print(f" Roundtrip OK: {'yes' if r['roundtrip_ok'] else 'NO'}")
169
+ print(f"\n Top 10 tokens by frequency:")
170
+ for tok, cnt in r["most_common"]:
171
+ print(f" {repr(tok):>20s} x{cnt}")
172
+ if show_tokens and "sample_tokens" in r:
173
+ print(f"\n First 30 tokens:")
174
+ print(f" {r['sample_tokens']}")
175
+ print()
176
+
177
+
178
+ def print_comparison(results: list):
179
+ print(f"\n{'='*60}")
180
+ print(f" COMPARISON ({len(results)} tokenizers)")
181
+ print(f"{'='*60}")
182
+ names = [r["repo"][:18] for r in results]
183
+ print(f" {'Metric':<20s} " + " ".join(f"{n:>20s}" for n in names))
184
+ print(f" {'-'*20} " + " ".join(f"{'-'*20}" for _ in names))
185
+ rows = [
186
+ ("Vocab size", lambda r: f"{r['vocab_size']:,}"),
187
+ ("Token type", lambda r: r["token_type"]),
188
+ ("Special tokens", lambda r: str(r["n_special"])),
189
+ ("Chars/token", lambda r: f"{r['chars_per_token']}"),
190
+ ("Corpus tokens", lambda r: str(r["corpus_tokens"])),
191
+ ("Vocab util", lambda r: f"{r['vocab_utilization']:.2%}"),
192
+ ("Longest tok", lambda r: str(r["longest_token"][1])),
193
+ ("Roundtrip", lambda r: "ok" if r["roundtrip_ok"] else "FAIL"),
194
+ ]
195
+ for label, fn in rows:
196
+ print(f" {label:<20s} " + " ".join(f"{fn(r):>20s}" for r in results))
197
+ print()
198
+
199
+
200
+ def main():
201
+ parser = argparse.ArgumentParser(description="Inspect and compare HF tokenizers")
202
+ parser.add_argument("sources", nargs="+", help="Repo IDs or paths to tokenizer.json")
203
+ parser.add_argument("--corpus", type=str, default=DEFAULT_CORPUS, help="Test corpus string")
204
+ parser.add_argument("--text", type=str, default=None, help="Short text to tokenize (overrides default corpus)")
205
+ parser.add_argument("--compare", action="store_true", help="Side-by-side comparison mode")
206
+ parser.add_argument("--show-tokens", action="store_true", help="Show first 30 tokens of the corpus")
207
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
208
+ args = parser.parse_args()
209
+
210
+ corpus = args.text if args.text else args.corpus
211
+ results = [analyze(s, corpus, args.show_tokens) for s in args.sources]
212
+
213
+ if args.json:
214
+ print(json.dumps(results, indent=2))
215
+ elif args.compare and len(results) >= 2:
216
+ print_comparison(results)
217
+ for r in results:
218
+ print_report(r, args.show_tokens)
219
+ else:
220
+ for r in results:
221
+ print_report(r, args.show_tokens)
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()