aimeri commited on
Commit
2d198ff
·
verified ·
1 Parent(s): 06dd9ae

Upload scripts/prep_base.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. scripts/prep_base.py +253 -0
scripts/prep_base.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Mistral-Small-3.1-24B-Base-2503 -> thrasher base: strip vision, claim ChatML.
3
+
4
+ python prep_base.py --src <base snapshot dir> --out <dir> [--init copy|mean|none]
5
+ python prep_base.py --src <dir with tokenizer jsons> --out <dir> --tokenizer-only
6
+
7
+ One streaming pass over the shards (CPU-only, ~one tensor in memory at a time;
8
+ fine on the box or locally). What it does, and why:
9
+
10
+ WEIGHTS drop `vision_tower.*` + `multi_modal_projector.*` (222 keys, ~0.9 GB),
11
+ strip the `language_model.` prefix off the remaining 363 keys so the
12
+ result is a plain published-shape MistralForCausalLM dict
13
+ (lm_head.weight, model.embed_tokens.weight, model.layers.*).
14
+ lm_head is genuinely untied in the source — verified against
15
+ model.safetensors.index.json 2026-08-26.
16
+
17
+ TOKENS rename <SPECIAL_20> -> <|im_start|> and <SPECIAL_21> -> <|im_end|>
18
+ in tokenizer.json (added_tokens + model.vocab), tokenizer_config.json
19
+ and special_tokens_map.json (recursive string replace — the slot
20
+ names also appear in additional_special_tokens). eos_token becomes
21
+ <|im_end|>; bos stays <s> (the tokenizer.json post_processor
22
+ auto-prepends it on encode, so the jinja emits no BOS and training/
23
+ GGUF/MLX all see the same bytes).
24
+
25
+ INIT the claimed rows never appeared in pretraining data, so their
26
+ embed/lm_head rows are dead (the Qwen3-Base never-stops lesson: a
27
+ dead <eos> lm_head row means the model cannot terminate, and our
28
+ SFT is ~1B tokens, not Hermes's 60B — don't gamble on cold-claiming).
29
+ --init copy (default): <|im_start|> rows <- <s> rows, <|im_end|>
30
+ rows <- </s> rows, embed AND head. Same-model semantic donors, the
31
+ graft half of the qwen3 graft+heal recipe; SFT is the heal.
32
+ --init mean: mean of all live rows. --init none: report only.
33
+ A liveness report (row norms vs live-row stats) prints either way.
34
+
35
+ CONFIG text_config promoted to top level, architectures=[MistralForCausalLM],
36
+ model_type=mistral, tie_word_embeddings=false explicit. generation_
37
+ config eos_token_id=[21]. preprocessor/processor configs not copied.
38
+
39
+ TEMPLATE thrasher.jinja written to chat_template.jinja AND embedded in
40
+ tokenizer_config.json (consolidate_ckpt pattern — MLX/GGUF read it
41
+ from there and the base ships none).
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import json
47
+ import shutil
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ CLAIMS = { # id -> (old, new, donor_id)
52
+ 20: ("<SPECIAL_20>", "<|im_start|>", 1), # donor <s>
53
+ 21: ("<SPECIAL_21>", "<|im_end|>", 2), # donor </s>
54
+ }
55
+
56
+ # The published tokenizer.json carries a BROKEN pre-tokenizer regex (the
57
+ # known Mistral conversion bug — transformers warns and offers
58
+ # fix_mistral_regex=True at load time, but that fix lives in-memory only).
59
+ # Training and GGUF/MLX read tokenizer.json directly, so the real Tekken
60
+ # pattern must be baked into the file. FIXED is byte-identical to what
61
+ # transformers 5.x fix_mistral_regex produces (diffed on-box 2026-08-26):
62
+ # case-aware word pattern, single-digit \p{N} splits, [\r\n/]* punct tail.
63
+ BROKEN_REGEX = (r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+"
64
+ r"|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+")
65
+ FIXED_REGEX = (r"[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*"
66
+ r"[\p{Ll}\p{Lm}\p{Lo}\p{M}]+|[^\r\n\p{L}\p{N}]?"
67
+ r"[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*"
68
+ r"|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n/]*|\s*[\r\n]+|\s+(?!\S)|\s+")
69
+ DROP_PREFIXES = ("vision_tower.", "multi_modal_projector.")
70
+ LM_PREFIX = "language_model."
71
+ ROW_KEYS = ("model.embed_tokens.weight", "lm_head.weight")
72
+
73
+
74
+ def replace_deep(obj, mapping: dict[str, str]):
75
+ if isinstance(obj, str):
76
+ return mapping.get(obj, obj)
77
+ if isinstance(obj, list):
78
+ return [replace_deep(x, mapping) for x in obj]
79
+ if isinstance(obj, dict):
80
+ return {k: replace_deep(v, mapping) for k, v in obj.items()}
81
+ return obj
82
+
83
+
84
+ def jload(p: Path):
85
+ with open(p) as f:
86
+ return json.load(f)
87
+
88
+
89
+ def jdump(obj, p: Path):
90
+ with open(p, "w") as f:
91
+ json.dump(obj, f, indent=2, ensure_ascii=False)
92
+ f.write("\n")
93
+
94
+
95
+ def prep_tokenizer(src: Path, out: Path, template_text: str) -> None:
96
+ strmap = {old: new for old, new, _ in CLAIMS.values()}
97
+
98
+ tj = jload(src / "tokenizer.json")
99
+ renamed = 0
100
+ for tok in tj["added_tokens"]:
101
+ if tok["content"] in strmap:
102
+ tok["content"] = strmap[tok["content"]]
103
+ renamed += 1
104
+ vocab = tj["model"]["vocab"]
105
+ for old, new, _ in CLAIMS.values():
106
+ assert old in vocab, f"{old} not in vocab — wrong base?"
107
+ assert new not in vocab, f"{new} already in vocab"
108
+ vocab[new] = vocab.pop(old)
109
+ assert renamed == len(CLAIMS), f"renamed {renamed} added_tokens, expected {len(CLAIMS)}"
110
+
111
+ split = tj["pre_tokenizer"]["pretokenizers"][0]["pattern"]
112
+ assert split["Regex"] == BROKEN_REGEX, "pre_tokenizer not the known-broken pattern — re-diff before baking"
113
+ split["Regex"] = FIXED_REGEX
114
+ jdump(tj, out / "tokenizer.json")
115
+
116
+ tc = replace_deep(jload(src / "tokenizer_config.json"), strmap)
117
+ tc["eos_token"] = "<|im_end|>"
118
+ tc["chat_template"] = template_text
119
+ jdump(tc, out / "tokenizer_config.json")
120
+
121
+ sm = replace_deep(jload(src / "special_tokens_map.json"), strmap)
122
+ eos = sm.get("eos_token")
123
+ if isinstance(eos, dict):
124
+ eos["content"] = "<|im_end|>"
125
+ else:
126
+ sm["eos_token"] = "<|im_end|>"
127
+ jdump(sm, out / "special_tokens_map.json")
128
+
129
+ (out / "chat_template.jinja").write_text(template_text)
130
+
131
+ # round-trip proof, not guess
132
+ from tokenizers import Tokenizer
133
+ tok = Tokenizer.from_file(str(out / "tokenizer.json"))
134
+ ids = tok.encode("<|im_start|>user\nhi<|im_end|>\n").ids
135
+ assert ids[0] == 1 and 20 in ids and 21 in ids, f"claim round-trip failed: {ids}"
136
+ assert tok.decode([20, 21], skip_special_tokens=False) == "<|im_start|><|im_end|>"
137
+ print(f"[tokenizer] claimed: " + ", ".join(
138
+ f"{new}={i}" for i, (_, new, _) in CLAIMS.items()))
139
+ print(f"[tokenizer] round-trip ids for ChatML probe: {ids}")
140
+
141
+
142
+ def prep_configs(src: Path, out: Path) -> None:
143
+ cfg = jload(src / "config.json")
144
+ text = cfg["text_config"]
145
+ text.update({
146
+ "architectures": ["MistralForCausalLM"],
147
+ "model_type": "mistral",
148
+ "torch_dtype": cfg.get("torch_dtype", "bfloat16"),
149
+ "tie_word_embeddings": False,
150
+ "bos_token_id": 1,
151
+ "eos_token_id": 21,
152
+ })
153
+ jdump(text, out / "config.json")
154
+
155
+ gen = {"bos_token_id": 1, "eos_token_id": [21]}
156
+ if (src / "generation_config.json").exists():
157
+ g = jload(src / "generation_config.json")
158
+ g.update(gen)
159
+ g.pop("pad_token_id", None)
160
+ gen = g
161
+ gen["transformers_version"] = None
162
+ gen = {k: v for k, v in gen.items() if v is not None}
163
+ jdump(gen, out / "generation_config.json")
164
+ print("[config] MistralForCausalLM, untied, eos_token_id=[21]")
165
+
166
+
167
+ def prep_weights(src: Path, out: Path, init: str) -> None:
168
+ import torch
169
+ from safetensors import safe_open
170
+ from safetensors.torch import save_file
171
+
172
+ index = jload(src / "model.safetensors.index.json")
173
+ wmap = index["weight_map"]
174
+ shards: dict[str, list[str]] = {}
175
+ for key, shard in wmap.items():
176
+ shards.setdefault(shard, []).append(key)
177
+
178
+ new_map: dict[str, str] = {}
179
+ total = 0
180
+ n_drop = n_keep = 0
181
+ donor_rows: dict[str, dict[int, torch.Tensor]] = {} # row_key -> {donor_id: row}
182
+
183
+ shard_names = sorted(shards)
184
+ for si, shard in enumerate(shard_names, 1):
185
+ out_name = f"model-{si:05d}-of-{len(shard_names):05d}.safetensors"
186
+ tensors: dict[str, torch.Tensor] = {}
187
+ with safe_open(src / shard, framework="pt") as f:
188
+ for key in sorted(shards[shard]):
189
+ if key.startswith(DROP_PREFIXES):
190
+ n_drop += 1
191
+ continue
192
+ assert key.startswith(LM_PREFIX), f"unexpected key {key}"
193
+ nk = key[len(LM_PREFIX):]
194
+ t = f.get_tensor(key)
195
+ if nk in ROW_KEYS:
196
+ t = claim_rows(nk, t, init, donor_rows)
197
+ tensors[nk] = t
198
+ n_keep += 1
199
+ if not tensors:
200
+ continue
201
+ save_file(tensors, str(out / out_name), metadata={"format": "pt"})
202
+ for nk, t in tensors.items():
203
+ new_map[nk] = out_name
204
+ total += t.numel() * t.element_size()
205
+ print(f"[weights] {shard} -> {out_name} ({len(tensors)} tensors)")
206
+
207
+ jdump({"metadata": {"total_size": total}, "weight_map": new_map},
208
+ out / "model.safetensors.index.json")
209
+ print(f"[weights] kept {n_keep}, dropped {n_drop}, total {total/1e9:.2f} GB")
210
+ assert n_keep == 363 and n_drop == 222, "key census mismatch vs 2026-08-26 index"
211
+
212
+
213
+ def claim_rows(name: str, t, init: str, donor_rows) -> "torch.Tensor":
214
+ import torch
215
+ live = t[1000:] # rows past the control block are all trained BPE tokens
216
+ live_norm = live.float().norm(dim=1)
217
+ print(f"[liveness] {name}: live rows norm mean {live_norm.mean():.4f} "
218
+ f"(p5 {live_norm.quantile(0.05):.4f})")
219
+ for tid, (_, new, donor) in CLAIMS.items():
220
+ print(f"[liveness] row {tid} ({new}): norm {t[tid].float().norm():.4f}, "
221
+ f"donor row {donor}: {t[donor].float().norm():.4f}")
222
+ if init == "none":
223
+ return t
224
+ t = t.clone()
225
+ for tid, (_, _, donor) in CLAIMS.items():
226
+ t[tid] = t[donor] if init == "copy" else live.float().mean(0).to(t.dtype)
227
+ return t
228
+
229
+
230
+ def main() -> None:
231
+ ap = argparse.ArgumentParser()
232
+ ap.add_argument("--src", required=True, type=Path)
233
+ ap.add_argument("--out", required=True, type=Path)
234
+ ap.add_argument("--template", type=Path,
235
+ default=Path(__file__).parent.parent / "configs" / "thrasher.jinja")
236
+ ap.add_argument("--init", choices=("copy", "mean", "none"), default="copy")
237
+ ap.add_argument("--tokenizer-only", action="store_true")
238
+ args = ap.parse_args()
239
+
240
+ args.out.mkdir(parents=True, exist_ok=True)
241
+ template_text = args.template.read_text()
242
+
243
+ prep_tokenizer(args.src, args.out, template_text)
244
+ if args.tokenizer_only:
245
+ print("[done] tokenizer-only")
246
+ return
247
+ prep_configs(args.src, args.out)
248
+ prep_weights(args.src, args.out, args.init)
249
+ print("[done] prepped base at", args.out)
250
+
251
+
252
+ if __name__ == "__main__":
253
+ main()