jvonrad commited on
Commit
03ccb40
·
verified ·
1 Parent(s): a09a1b7

Upload src/xscript/tok/analyze.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/xscript/tok/analyze.py +196 -0
src/xscript/tok/analyze.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tokenizer analysis gate (thesis-plan next-action #2).
2
+
3
+ For every trained tokenizer x study language, measured on FLORES+ (parallel,
4
+ so 'tokens per sentence relative to English' is content-normalized fertility):
5
+
6
+ - bytes/token, tokens/char, tokens/word, tokens/sentence
7
+ - parity = tokens-per-sentence relative to English on the same sentences
8
+ - %% of emitted tokens that are raw-byte atoms (the literal byte tax)
9
+ - %% single-character tokens (allocation starvation for ZH shows up here)
10
+ - unique vocab entries used
11
+ - full 64k vocabulary allocation by script
12
+
13
+ Plus segmentation samples for eyeballing subword meaningfulness (the
14
+ user-facing fidelity check that decides which flavor trains models).
15
+
16
+ Gate (plan): proceed to model training only if the AR/ZH fertility gap
17
+ between starved and destarved conditions is large.
18
+ """
19
+ import json
20
+ import unicodedata
21
+ from pathlib import Path
22
+
23
+ from .. import flores
24
+ from ..langs import LANGS, TOK_FLAVORS, tok_name, all_tok_names
25
+ from ..paths import RESULTS, tokenizer_dir, ensure
26
+ from .wrapper import Tok
27
+
28
+ # codepoint-range -> script bucket (coarse; enough for allocation accounting)
29
+ _RANGES = [
30
+ (0x0041, 0x024F, "Latin"), (0x1E00, 0x1EFF, "Latin"), (0x2C60, 0x2C7F, "Latin"),
31
+ (0x0370, 0x03FF, "Greek"),
32
+ (0x0400, 0x052F, "Cyrillic"),
33
+ (0x0590, 0x05FF, "Hebrew"),
34
+ (0x0600, 0x06FF, "Arabic"), (0x0750, 0x077F, "Arabic"), (0x08A0, 0x08FF, "Arabic"),
35
+ (0xFB50, 0xFDFF, "Arabic"), (0xFE70, 0xFEFF, "Arabic"),
36
+ (0x0900, 0x097F, "Devanagari"),
37
+ (0x0980, 0x0DFF, "OtherIndic"), (0x0E00, 0x0E7F, "Thai"),
38
+ (0x1100, 0x11FF, "Hangul"), (0xAC00, 0xD7AF, "Hangul"),
39
+ (0x3040, 0x30FF, "Kana"),
40
+ (0x3400, 0x4DBF, "Han"), (0x4E00, 0x9FFF, "Han"), (0xF900, 0xFAFF, "Han"),
41
+ (0x0E80, 0x0FFF, "OtherSEA"), (0x1000, 0x109F, "OtherSEA"),
42
+ (0x10A0, 0x10FF, "Georgian"), (0x0530, 0x058F, "Armenian"),
43
+ (0x1200, 0x139F, "Ethiopic"),
44
+ ]
45
+
46
+
47
+ def _char_bucket(ch: str) -> str:
48
+ cp = ord(ch)
49
+ if cp < 0x41:
50
+ return "ascii_sym" if not ch.isspace() else "space"
51
+ for lo, hi, name in _RANGES:
52
+ if lo <= cp <= hi:
53
+ return name
54
+ cat = unicodedata.category(ch)
55
+ if cat.startswith("L"):
56
+ return "OtherScript"
57
+ return "sym"
58
+
59
+
60
+ def classify_piece(raw: bytes) -> str:
61
+ if not raw:
62
+ return "special"
63
+ try:
64
+ s = raw.decode("utf-8")
65
+ except UnicodeDecodeError:
66
+ return "byte_atom" if len(raw) == 1 else "partial_utf8"
67
+ letters = [c for c in s if unicodedata.category(c).startswith("L")]
68
+ if not letters:
69
+ return "sym_num_space"
70
+ counts = {}
71
+ for c in letters:
72
+ b = _char_bucket(c)
73
+ counts[b] = counts.get(b, 0) + 1
74
+ top, n = max(counts.items(), key=lambda kv: kv[1])
75
+ return top if n == len(letters) else "mixed"
76
+
77
+
78
+ def vocab_allocation(tok: Tok) -> dict[str, int]:
79
+ counts: dict[str, int] = {}
80
+ for i in range(tok.vocab_size):
81
+ b = "byte_atom" if tok.is_byte_piece(i) else classify_piece(tok.piece_bytes(i))
82
+ counts[b] = counts.get(b, 0) + 1
83
+ return dict(sorted(counts.items(), key=lambda kv: -kv[1]))
84
+
85
+
86
+ def _lang_metrics(tok: Tok, texts: list[str]) -> dict:
87
+ n_tok = n_byte = n_char = n_word = n_bytepieces = n_singlechar = 0
88
+ used = set()
89
+ for ids, text in zip(tok.encode_batch(texts), texts):
90
+ n_tok += len(ids)
91
+ n_byte += len(text.encode("utf-8"))
92
+ n_char += len(text)
93
+ n_word += len(text.split())
94
+ used.update(ids)
95
+ for i in ids:
96
+ if tok.is_byte_piece(i):
97
+ n_bytepieces += 1
98
+ else:
99
+ try:
100
+ if len(tok.piece_bytes(i).decode("utf-8").strip()) == 1:
101
+ n_singlechar += 1
102
+ except UnicodeDecodeError:
103
+ pass
104
+ n_sent = len(texts)
105
+ return {
106
+ "n_sentences": n_sent,
107
+ "tokens": n_tok,
108
+ "bytes_per_token": n_byte / n_tok,
109
+ "tokens_per_char": n_tok / n_char,
110
+ "tokens_per_word": n_tok / n_word,
111
+ "tokens_per_sentence": n_tok / n_sent,
112
+ "pct_byte_tokens": 100.0 * n_bytepieces / n_tok,
113
+ "pct_single_char_tokens": 100.0 * n_singlechar / n_tok,
114
+ "unique_tokens_used": len(used),
115
+ }
116
+
117
+
118
+ def _segment(tok: Tok, text: str) -> str:
119
+ ids = tok.encode(text)
120
+ parts = []
121
+ for i in ids:
122
+ try:
123
+ parts.append(tok.piece_bytes(i).decode("utf-8"))
124
+ except UnicodeDecodeError:
125
+ parts.append(f"<{tok.piece_bytes(i).hex()}>")
126
+ return "|".join(parts)
127
+
128
+
129
+ def run(tok_names=None, out_dir: Path | None = None, n_samples: int = 3) -> dict:
130
+ out_dir = ensure(Path(out_dir) if out_dir else RESULTS / "tok_analysis")
131
+ tok_names = tok_names or all_tok_names()
132
+ toks = [Tok(tokenizer_dir(n)) for n in tok_names]
133
+
134
+ par = flores.load_parallel(list(LANGS), "dev")
135
+ par_test = flores.load_parallel(list(LANGS), "devtest")
136
+ texts = {l: par[l] + par_test[l] for l in LANGS}
137
+
138
+ metrics, alloc = {}, {}
139
+ for tok in toks:
140
+ m = {l: _lang_metrics(tok, texts[l]) for l in LANGS}
141
+ en_tps = m["en"]["tokens_per_sentence"]
142
+ for l in LANGS:
143
+ m[l]["parity_vs_en"] = m[l]["tokens_per_sentence"] / en_tps
144
+ metrics[tok.name] = m
145
+ alloc[tok.name] = vocab_allocation(tok)
146
+ print(f"[analyze] {tok.name} done")
147
+
148
+ # ---- gate summary: starved-vs-destarved fertility ratio per flavor ----
149
+ gate = {}
150
+ for f in TOK_FLAVORS:
151
+ s, d = f"{f}_starved", f"{f}_destarved"
152
+ if s in metrics and d in metrics:
153
+ gate[f] = {l: metrics[s][l]["tokens_per_sentence"] /
154
+ metrics[d][l]["tokens_per_sentence"] for l in LANGS}
155
+
156
+ result = {"metrics": metrics, "vocab_allocation": alloc,
157
+ "starved_over_destarved_tokens": gate}
158
+ (out_dir / "metrics.json").write_text(json.dumps(result, indent=2))
159
+
160
+ # ---- markdown tables ----
161
+ cols = ["bytes_per_token", "tokens_per_char", "tokens_per_word",
162
+ "tokens_per_sentence", "parity_vs_en", "pct_byte_tokens",
163
+ "pct_single_char_tokens", "unique_tokens_used"]
164
+ md = ["# Tokenizer fertility on FLORES+ (dev+devtest)", ""]
165
+ for name, m in metrics.items():
166
+ md += [f"## {name}", "", "| lang | " + " | ".join(cols) + " |",
167
+ "|" + "---|" * (len(cols) + 1)]
168
+ for l in LANGS:
169
+ md.append("| " + l + " | " +
170
+ " | ".join(f"{m[l][c]:.3f}" if isinstance(m[l][c], float)
171
+ else str(m[l][c]) for c in cols) + " |")
172
+ md.append("")
173
+ md += ["# Gate: starved/destarved token-count ratio (per flavor)", ""]
174
+ for f, g in gate.items():
175
+ md.append(f"- **{f}**: " + ", ".join(f"{l}={v:.3f}" for l, v in g.items()))
176
+ md += ["", "# Vocab allocation (64k pieces by script)", ""]
177
+ buckets = sorted({b for a in alloc.values() for b in a})
178
+ md += ["| tokenizer | " + " | ".join(buckets) + " |",
179
+ "|" + "---|" * (len(buckets) + 1)]
180
+ for name, a in alloc.items():
181
+ md.append("| " + name + " | " + " | ".join(str(a.get(b, 0)) for b in buckets) + " |")
182
+ (out_dir / "report.md").write_text("\n".join(md) + "\n")
183
+
184
+ # ---- segmentation samples for the fidelity eyeball check ----
185
+ smp = ["# Segmentation samples (FLORES+ dev)", ""]
186
+ for l in LANGS:
187
+ smp.append(f"## {l}")
188
+ for k in range(n_samples):
189
+ smp += ["", f"> {par[l][k]}", ""]
190
+ for tok in toks:
191
+ smp.append(f"- **{tok.name}**: `{_segment(tok, par[l][k])}`")
192
+ smp.append("")
193
+ (out_dir / "samples.md").write_text("\n".join(smp) + "\n")
194
+
195
+ print(f"[analyze] wrote {out_dir}/report.md, samples.md, metrics.json")
196
+ return result