Text Generation
Transformers
Safetensors
Arabic
llama
arabic
reasoning
chain-of-thought
math
gsm8k
small-language-model
slm
sft
conversational
text-generation-inference
File size: 6,386 Bytes
867d0f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""
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"<think>\n{r['reasoning']}\n</think>\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|>", "<think>", "</think>"]})
        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()