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

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

Browse files
Files changed (1) hide show
  1. src/xscript/tok/train.py +215 -0
src/xscript/tok/train.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the study's tokenizers.
2
+
3
+ We pretrain models with TWO SentencePiece Unigram tokenizers only. The `bpe`
4
+ and `pa` flavors are trained purely as tokenizer-analysis comparators for the
5
+ fertility/allocation gate (`xscript tok-analyze`); no model run ever uses them.
6
+
7
+ MODEL-TRAINING tokenizers -- SentencePiece Unigram, character_coverage=0.999995,
8
+ byte fallback:
9
+ unigram_starved -- ATLAS-style replication arm: T=100 temperature mixture
10
+ over ~419 languages. Matches both ATLAS's ~uniform 420-
11
+ language mixture and the Unigram algorithm of the MADLAD-
12
+ 400 lineage its tokenizer descends from.
13
+ unigram_destarved -- the intervention arm: our 5 study languages only, byte-
14
+ premium content-aligned (equal *content*, not bytes, per
15
+ language; see data/tokcorpus.py). Same algorithm as the
16
+ starved arm, so the starved-vs-destarved contrast isolates
17
+ vocabulary allocation rather than confounding it with the
18
+ tokenizer algorithm.
19
+
20
+ On the algorithm choice: Unigram is MADLAD-400's confirmed algorithm (its
21
+ released 256k *model* tokenizer is SentencePiece Unigram). ATLAS's 64k tokenizer
22
+ is a SEPARATE artifact -- trained by the MADLAD-400 authors (Kudugunta et al.)
23
+ on the same T=100 recipe -- whose algorithm ATLAS does not state in-text, though
24
+ Unigram is the natural inference from that lineage. Do not conflate ATLAS's 64k
25
+ with MADLAD's 256k; they are different tokenizers.
26
+
27
+ ANALYSIS-ONLY comparators -- trained for the gate, never used to pretrain:
28
+ bpe -- byte-level BPE (Whitespace + ByteLevel pre-tokenization) trained with
29
+ HuggingFace `tokenizers`' Rust `BpeTrainer`. Quantifies how much the
30
+ Unigram-vs-BPE algorithm choice alone moves fertility/allocation.
31
+ pa -- parity-aware byte-level BPE via swiss-ai/parity-aware-bpe's
32
+ `parity_aware_learn_bpe.py` (window variant, for ZH), fertility-
33
+ equalized over the 5-way-parallel FLORES+ dev set. Same byte-level
34
+ alphabet as `bpe`; the merge criterion (parity-balanced vs frequency)
35
+ is the only difference -> a clean upper bound on fertility
36
+ equalization. Destarved only (it balances a fixed dev-language set).
37
+ Uses the slow single-threaded reference trainer -- tolerable only
38
+ because its corpus is 5 languages, not 419.
39
+
40
+ Every flavor exposes exactly `VOCAB_SIZE` pieces with our four specials at ids
41
+ 0..3, so packed token ids stay uint16 and every downstream module stays flavor-
42
+ agnostic. VOCAB_SIZE is overridable via XSCRIPT_VOCAB for the CPU smoke test.
43
+ """
44
+ import json
45
+ import os
46
+ import subprocess
47
+ from pathlib import Path
48
+
49
+ from ..langs import tok_name
50
+ from ..paths import TOK_CORPORA, tokenizer_dir, ensure
51
+ from ..data.tokcorpus import corpus_files
52
+
53
+ VOCAB_SIZE = int(os.environ.get("XSCRIPT_VOCAB", "65536"))
54
+ SPECIALS = ["<unk>", "<bos>", "<eos>", "<pad>"] # ids 0..3 in every flavor
55
+ PA_REPO = "swiss-ai/parity-aware-bpe"
56
+
57
+
58
+ # --------------------------------------------------------------------------- #
59
+ # unigram (SentencePiece)
60
+ # --------------------------------------------------------------------------- #
61
+ def train_unigram(condition: str, seed: int = 42) -> Path:
62
+ import sentencepiece as spm
63
+ if hasattr(spm, "set_random_generator_seed"):
64
+ spm.set_random_generator_seed(seed) # not a TrainerSpec field in >=0.2
65
+ files = corpus_files(condition)
66
+ out = ensure(tokenizer_dir(tok_name("unigram", condition)))
67
+ spm.SentencePieceTrainer.train(
68
+ input=",".join(str(f) for f in files),
69
+ model_prefix=str(out / "sp"),
70
+ model_type="unigram",
71
+ vocab_size=VOCAB_SIZE,
72
+ character_coverage=0.999995,
73
+ byte_fallback=True,
74
+ unk_id=0, bos_id=1, eos_id=2, pad_id=3,
75
+ unk_piece="<unk>", bos_piece="<bos>", eos_piece="<eos>", pad_piece="<pad>",
76
+ input_sentence_size=10_000_000,
77
+ shuffle_input_sentence=True,
78
+ train_extremely_large_corpus=True,
79
+ remove_extra_whitespaces=False,
80
+ num_threads=max(1, (os.cpu_count() or 8) - 2),
81
+ )
82
+ _write_meta(out, "unigram", condition, files)
83
+ return out
84
+
85
+
86
+ # --------------------------------------------------------------------------- #
87
+ # byte-level BPE + parity-aware BPE (swiss-ai/parity-aware-bpe)
88
+ # --------------------------------------------------------------------------- #
89
+ def _n_merges() -> int:
90
+ # vocab = 4 specials + 256 byte-level base alphabet + merges
91
+ return VOCAB_SIZE - len(SPECIALS) - 256
92
+
93
+
94
+ def train_bpe(condition: str) -> Path:
95
+ from tokenizers import Tokenizer, models, pre_tokenizers, decoders, trainers
96
+
97
+ files = corpus_files(condition)
98
+ out = ensure(tokenizer_dir(tok_name("bpe", condition)))
99
+
100
+ tok = Tokenizer(models.BPE(unk_token=None, fuse_unk=False))
101
+ tok.pre_tokenizer = pre_tokenizers.Sequence(
102
+ [pre_tokenizers.Whitespace(), pre_tokenizers.ByteLevel(use_regex=False)])
103
+ tok.decoder = decoders.ByteLevel()
104
+ trainer = trainers.BpeTrainer(
105
+ vocab_size=VOCAB_SIZE,
106
+ special_tokens=SPECIALS, # ids 0..3, in order
107
+ initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), # full 256 bytes
108
+ show_progress=True,
109
+ )
110
+ tok.train([str(f) for f in files], trainer)
111
+ tok.save(str(out / "tokenizer.json"))
112
+ _write_meta(out, "bpe", condition, files,
113
+ extra={"vocab_size_actual": tok.get_vocab_size(),
114
+ "source": "huggingface-tokenizers-bpe"})
115
+ return out
116
+
117
+
118
+ def train_pa(condition: str = "destarved", variant: str = "window") -> Path:
119
+ if condition != "destarved":
120
+ raise ValueError("parity-aware BPE is destarved-only (see langs.tok_conditions)")
121
+ inputs = corpus_files("destarved") # one file per study language
122
+ dev = _write_pa_dev(inputs) # aligned FLORES+ dev per lang
123
+ out = ensure(tokenizer_dir(tok_name("pa", condition)))
124
+ merges = out / "merges.raw.txt"
125
+ # parity-aware's multi-worker vocab builder is broken in the released
126
+ # version (pickle.load on a text-mode file), so force single-worker.
127
+ pa_workers = os.environ.get("XSCRIPT_PA_WORKERS", "1")
128
+ cmd = ["python", "-m", "parity_aware_bpe.parity_aware_learn_bpe",
129
+ "--variant", variant, "--symbols", str(_n_merges()),
130
+ "--num-workers", pa_workers, "--output", str(merges),
131
+ "--input", *[str(f) for f in inputs],
132
+ "--dev", *[str(f) for f in dev]]
133
+ _run(cmd)
134
+ _bytelevel_from_merges(merges, out, "pa", condition, inputs)
135
+ return out
136
+
137
+
138
+ def _write_pa_dev(inputs) -> list[Path]:
139
+ """FLORES+ dev text per language, in the SAME order as `inputs` (stem=code)."""
140
+ from .. import flores
141
+ d = ensure(TOK_CORPORA / "pa_dev")
142
+ dev = []
143
+ for f in inputs:
144
+ code = f.stem
145
+ sents = list(flores.load(code, "dev").values())
146
+ p = d / f"{code}.dev.txt"
147
+ p.write_text("\n".join(sents) + "\n", encoding="utf-8")
148
+ dev.append(p)
149
+ return dev
150
+
151
+
152
+ def _bytelevel_from_merges(merges_path: Path, out: Path, flavor: str,
153
+ condition: str, corpus_files_used) -> None:
154
+ """Merge rules -> HuggingFace byte-level BPE tokenizer, exactly VOCAB_SIZE."""
155
+ from tokenizers import Tokenizer, models, pre_tokenizers, decoders
156
+
157
+ lines = [l.strip() for l in merges_path.read_text(encoding="utf-8").splitlines()
158
+ if l.strip()]
159
+ if lines and lines[0].startswith("#version"):
160
+ lines = lines[1:]
161
+
162
+ vocab: dict[str, int] = {s: i for i, s in enumerate(SPECIALS)} # 0..3
163
+ for ch in pre_tokenizers.ByteLevel.alphabet(): # 256 bytes
164
+ vocab.setdefault(ch, len(vocab))
165
+ keep = max(0, VOCAB_SIZE - len(vocab)) # merges budget
166
+ merges: list[tuple[str, str]] = []
167
+ for line in lines:
168
+ if len(merges) >= keep:
169
+ break
170
+ a, b = line.split(" ")
171
+ if a not in vocab or b not in vocab: # order guarantees this won't hit
172
+ continue
173
+ merges.append((a, b))
174
+ vocab.setdefault(a + b, len(vocab))
175
+
176
+ tok = Tokenizer(models.BPE(vocab=vocab, merges=merges,
177
+ unk_token=None, fuse_unk=False))
178
+ # EXACT pre-tokenizer/decoder the repo trains and loads with (byte-level)
179
+ tok.pre_tokenizer = pre_tokenizers.Sequence(
180
+ [pre_tokenizers.Whitespace(), pre_tokenizers.ByteLevel(use_regex=False)])
181
+ tok.decoder = decoders.ByteLevel()
182
+ tok.save(str(out / "tokenizer.json"))
183
+ _write_meta(out, flavor, condition, corpus_files_used,
184
+ extra={"vocab_size_actual": tok.get_vocab_size(),
185
+ "n_merges": len(merges), "source": PA_REPO})
186
+
187
+
188
+ def _run(cmd, shell: bool = False) -> None:
189
+ print(f"[tok] $ {cmd if shell else ' '.join(cmd)}")
190
+ subprocess.run(cmd, shell=shell, check=True)
191
+
192
+
193
+ # --------------------------------------------------------------------------- #
194
+ def _write_meta(out: Path, flavor: str, condition: str, files, extra=None) -> None:
195
+ meta = {
196
+ "flavor": flavor,
197
+ "condition": condition,
198
+ "vocab_size": VOCAB_SIZE,
199
+ "specials": SPECIALS,
200
+ "corpus_files": [str(f) for f in files],
201
+ }
202
+ if extra:
203
+ meta.update(extra)
204
+ (out / "meta.json").write_text(json.dumps(meta, indent=2))
205
+ print(f"[tok] trained {flavor}_{condition} -> {out}")
206
+
207
+
208
+ def train(flavor: str, condition: str) -> Path:
209
+ if flavor == "unigram":
210
+ return train_unigram(condition)
211
+ if flavor == "bpe":
212
+ return train_bpe(condition)
213
+ if flavor == "pa":
214
+ return train_pa(condition)
215
+ raise ValueError(f"unknown flavor {flavor!r} (want unigram|bpe|pa)")