""" Build the v6 SFT corpus: the v5 three-way mix, with the synthetic corpus swapped for the larger one that carries the relational pool. This is `prepare_v5_sft.py` with `SYNTH_DIR` repointed and a fourth eval column added. Separate file, same reason v5 was separate from v3: v5 is the shipped model and must stay reproducible. The synth split comes from `split_synth_v6.py`, not `build_synth_dataset.py` — the v6 corpus is 20,139 rows larger, so a re-drawn shuffle would put 1,955 of v5's held-out rows into v6's train. The eval rows are pinned to v5's instead, which keeps the synth cell comparable and, because `eval.jsonl` is byte-identical to v5's, makes the mix-eval synth column the *same 400 rows* v5 was scored on. `eval_rel.jsonl` (400 relational rows, held out of train) is the column that actually measures what v6 was built for — none of the legacy eval rows test a relational comparison. data/{train,eval}.jsonl <- prepare_data.py (Arabic_Reasoning_Dataset) data_gsm_sft/{train,eval}.jsonl <- prepare_gsm_sft.py (oddadmix/gsm8k-reasoning-ar) data_synth_v6_sft/{train,eval,eval_rel}.jsonl <- split_synth_v6.py (120,462-row corpus) Why all three: v3 scores 77.3% on GSM8K-ar but 2.0% on the synth held-out set, and v4 (synth only) inverts that — 35.6% synth, 19.5% GSM. Neither corpus alone covers the other's distribution, so v5 trains on all three at once. Answer styles are NOT normalised — GSM8K ends in a bare numeral, Arabic_Reasoning and the synth corpus in an "إذن، …" sentence. A `source` tag rides on every row so eval_reasoning.py scores each on its own terms. The eval set keeps v1's 400 AR rows and v2's first 600 GSM rows *unchanged*, so the AR and GSM cells stay directly comparable to v1/v2/v3. Writes data_v6_sft/{train,eval}.jsonl. """ import json import os import random from collections import Counter from pathlib import Path AR_DIR = Path("data") # v1 splits GSM_DIR = Path("data_gsm_sft") # v2 splits SYNTH_DIR = Path("data_synth_v6_sft") # the 120,462-row corpus, relational pool included OUT = Path("data_v6_sft") # Arabic_Reasoning is ~25x smaller than GSM8K, so it is repeated (same value v3 used). REPEAT = int(os.environ.get("REPEAT", 3)) SYNTH_REPEAT = int(os.environ.get("SYNTH_REPEAT", 1)) # Eval: v1's 400 AR rows are all kept and v2's first 600 GSM rows, exactly as v3 built them, plus # synth rows so all three distributions are scored in one pass. EVAL_GSM = int(os.environ.get("EVAL_GSM", 600)) EVAL_SYNTH = int(os.environ.get("EVAL_SYNTH", 400)) EVAL_REL = int(os.environ.get("EVAL_REL", 400)) SEED = 42 def load(path): with open(path, encoding="utf-8") as fh: return [json.loads(line) for line in fh] def tag(rows, source): return [{**r, "source": source} for r in rows] def token_stats(rows, tok): per_source, lengths = Counter(), [] for r in rows: text = (f"<|im_start|>user\n{r['instruction']}<|im_end|>\n<|im_start|>assistant\n" f"\n{r['reasoning']}\n\n{r['answer']}<|im_end|>") n = len(tok.encode(text, add_special_tokens=False)) per_source[r["source"]] += n lengths.append(n) lengths.sort() pct = lambda p: lengths[min(int(len(lengths) * p / 100), len(lengths) - 1)] return per_source, {"p50": pct(50), "p95": pct(95), "p99": pct(99), "max": lengths[-1]} def interleave(groups): """Round-robin proportional to each group's size, so a truncated eval run still covers all three sources instead of whichever landed first.""" groups = [g for g in groups if g] if not groups: return [] total = sum(len(g) for g in groups) out, idx = [], [0] * len(groups) for _ in range(total): # pick the group that is furthest behind its target share pick = min(range(len(groups)), key=lambda i: (idx[i] / len(groups[i])) if idx[i] < len(groups[i]) else 2.0) if idx[pick] >= len(groups[pick]): break out.append(groups[pick][idx[pick]]) idx[pick] += 1 for g, i in zip(groups, idx): # anything the loop could not place out.extend(g[i:]) return out def main(): for d in (AR_DIR, GSM_DIR, SYNTH_DIR): for split in ("train", "eval"): if not (d / f"{split}.jsonl").exists(): raise SystemExit(f"missing {d/f'{split}.jsonl'}") ar_train = tag(load(AR_DIR / "train.jsonl"), "arabic_reasoning") ar_eval = tag(load(AR_DIR / "eval.jsonl"), "arabic_reasoning") gsm_train = tag(load(GSM_DIR / "train.jsonl"), "gsm8k_ar") gsm_eval = tag(load(GSM_DIR / "eval.jsonl"), "gsm8k_ar") synth_train = tag(load(SYNTH_DIR / "train.jsonl"), "synth_math_ar") synth_eval = tag(load(SYNTH_DIR / "eval.jsonl"), "synth_math_ar") rel_eval = tag(load(SYNTH_DIR / "eval_rel.jsonl"), "synth_relational_ar") train = gsm_train + ar_train * REPEAT + synth_train * SYNTH_REPEAT random.Random(SEED).shuffle(train) eval_rows = interleave([ar_eval, gsm_eval[:EVAL_GSM], synth_eval[:EVAL_SYNTH], rel_eval[:EVAL_REL]]) OUT.mkdir(exist_ok=True) for name, split in (("train", train), ("eval", eval_rows)): with open(OUT / f"{name}.jsonl", "w", encoding="utf-8") as fh: for r in split: fh.write(json.dumps(r, ensure_ascii=False) + "\n") print(f"[+] {name}: {len(split):,} -> {OUT / f'{name}.jsonl'} " f"{dict(Counter(r['source'] for r in split))}") try: from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained(os.environ.get("BASE_MODEL", "/notebooks/50M/50M-2048-Emhotob")) tok.add_special_tokens({"additional_special_tokens": ["<|im_start|>", "<|im_end|>", "", ""]}) per_source, pct = token_stats(train, tok) total = sum(per_source.values()) print(f"[*] train tokens: {total/1e6:.1f}M/epoch (REPEAT={REPEAT}, SYNTH_REPEAT={SYNTH_REPEAT})") for src, n in per_source.most_common(): print(f" {src:<18} {n/1e6:6.2f}M {100*n/total:5.1f}%") print(f"[*] sample length: p50 {pct['p50']} p95 {pct['p95']} p99 {pct['p99']} max {pct['max']}") except Exception as exc: print(f"[!] token stats skipped: {exc}") if __name__ == "__main__": main()