Text Generation
Transformers
Safetensors
Arabic
llama
arabic
reasoning
chain-of-thought
math
gsm8k
small-language-model
slm
sft
conversational
text-generation-inference
Instructions to use oddadmix/Nawah-Math-Reasoning with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use oddadmix/Nawah-Math-Reasoning with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="oddadmix/Nawah-Math-Reasoning") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("oddadmix/Nawah-Math-Reasoning") model = AutoModelForCausalLM.from_pretrained("oddadmix/Nawah-Math-Reasoning", device_map="auto") messages = [ {"role": "user", "content": "Who are you?"}, ] inputs = tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=40) print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use oddadmix/Nawah-Math-Reasoning with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "oddadmix/Nawah-Math-Reasoning" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "oddadmix/Nawah-Math-Reasoning", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/oddadmix/Nawah-Math-Reasoning
- SGLang
How to use oddadmix/Nawah-Math-Reasoning with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "oddadmix/Nawah-Math-Reasoning" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "oddadmix/Nawah-Math-Reasoning", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "oddadmix/Nawah-Math-Reasoning" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "oddadmix/Nawah-Math-Reasoning", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use oddadmix/Nawah-Math-Reasoning with Docker Model Runner:
docker model run hf.co/oddadmix/Nawah-Math-Reasoning
training code: data generation, SFT, eval, GRPO
Browse files- code/README.md +196 -0
- code/build_dataset.py +144 -0
- code/build_release_code.py +274 -0
- code/build_synth_dataset.py +115 -0
- code/chat.py +16 -0
- code/eval_reasoning.py +136 -0
- code/finish_merge_push.sh +42 -0
- code/grpo_train.py +193 -0
- code/gsm_common.py +45 -0
- code/passk_diag.py +68 -0
- code/prepare_data.py +140 -0
- code/prepare_gsm_sft.py +38 -0
- code/prepare_v6_sft.py +139 -0
- code/push_dataset.py +150 -0
- code/push_release.py +321 -0
- code/push_synth_dataset.py +200 -0
- code/retry_failed.py +102 -0
- code/space/README.md +76 -0
- code/space/app.py +222 -0
- code/space/requirements.txt +5 -0
- code/split_synth_v6.py +75 -0
- code/synth_common.py +331 -0
- code/synth_generate.py +212 -0
- code/train_reasoning.py +192 -0
- code/translate_gsm.py +119 -0
code/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nawah-Math-Reasoning — training code
|
| 2 |
+
|
| 3 |
+
Everything that produced the model in this repo: two dataset pipelines, the SFT trainer, the eval
|
| 4 |
+
harness, a `pass@k` diagnostic, and a GRPO implementation. Run top to bottom and you get the
|
| 5 |
+
release; run any single stage and it resumes from what is already on disk.
|
| 6 |
+
|
| 7 |
+
Paths below assume this directory is the working directory and `$P` is a Python with
|
| 8 |
+
`torch`, `transformers>=5.15`, `pyarrow` and `huggingface_hub`.
|
| 9 |
+
|
| 10 |
+
## 0. Two environments, and why
|
| 11 |
+
|
| 12 |
+
| env | for | pins |
|
| 13 |
+
|---|---|---|
|
| 14 |
+
| training / eval / Hub | `train_reasoning.py`, `eval_reasoning.py`, everything else | torch 2.9 + cu126, **transformers 5.15** |
|
| 15 |
+
| generation | `translate_gsm.py`, `synth_generate.py` | **vLLM 0.8.5.post1**, torch 2.6 + cu124, **transformers 4.51.3** |
|
| 16 |
+
|
| 17 |
+
They are not interchangeable. The model configs are transformers-v5 format (`rope_parameters`,
|
| 18 |
+
`dtype`, `tokenizer_class: TokenizersBackend`) and transformers 4.x cannot read them — but vLLM
|
| 19 |
+
0.8.5 breaks on transformers 5.x (`TokenizersBackend has no attribute
|
| 20 |
+
all_special_tokens_extended`), so the generation env must stay at 4.51.3. Do not `pip install -U`
|
| 21 |
+
in it.
|
| 22 |
+
|
| 23 |
+
vLLM 0.8.5 is itself a pin: every release from 0.20.2 up requires torch 2.11, which is a CUDA 13
|
| 24 |
+
build needing driver ≥ 580. On a CUDA 12.4 host that is a hard stop, and cu12 wheels do not rescue
|
| 25 |
+
it (`ImportError: libcudart.so.13`). 0.8.5 is the last cu124 release.
|
| 26 |
+
|
| 27 |
+
In transformers v5 `TrainingArguments` it is `eval_strategy` (not `evaluation_strategy`) and
|
| 28 |
+
`warmup_steps` (there is no `warmup_ratio`).
|
| 29 |
+
|
| 30 |
+
## 1. `gsm8k-reasoning-ar` — 142,969 machine-translated rows
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
$V translate_gsm.py 150000 # vLLM env; ~95 min on 1x A6000; resumable
|
| 34 |
+
$P build_dataset.py # -> out_gsm/*.parquet + rejects.jsonl
|
| 35 |
+
$P push_dataset.py --repo <user>/gsm8k-reasoning-ar
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
`Ajhesh7/gsm8k-reasoning-SFT-datas` translated with `ByteDance-Seed/Seed-X-PPO-7B`. The trailing
|
| 39 |
+
language tag in the prompt is **mandatory**: `Translate the following English sentence into
|
| 40 |
+
Arabic:\n{text} <ar>`.
|
| 41 |
+
|
| 42 |
+
Only 150k of the source's 600,000 rows are translated, and the sample is stratified by question
|
| 43 |
+
pattern with a floor of 13 rows/pattern: the corpus expands from just **2,814** question patterns,
|
| 44 |
+
so translating all 600k is ~99% redundant.
|
| 45 |
+
|
| 46 |
+
**The reason `build_dataset.py` has a validator at all:** Seed-X silently corrupts arithmetic in
|
| 47 |
+
~0.6% of segments — `$13751` → `13571`, `4 × 44 = 176` → `4 × 46 = 176`. These read as fluent
|
| 48 |
+
Arabic and pass any fluency check. So every row is numeral-audited, asymmetrically: **reasoning
|
| 49 |
+
chains strictly** (every numeral must survive exactly), **questions leniently** (a value ≤ 12 may
|
| 50 |
+
be verbalised — `6 friends` → `أصدقائها الستة` — but any number the English never contained is
|
| 51 |
+
rejected). Naive exact-multiset matching rejects ~6% of *correct* translations; do not "simplify"
|
| 52 |
+
it back to that.
|
| 53 |
+
|
| 54 |
+
`translate_gsm.py` appends every chunk to `out_gsm/translations.jsonl` and skips cached segments on
|
| 55 |
+
restart, so an interruption costs at most one 20k chunk. Do not pass `enable_prefix_caching=True`
|
| 56 |
+
— it hangs at startup on this vLLM/V1 engine.
|
| 57 |
+
|
| 58 |
+
## 2. `arabic-math-reasoning-synth` — 120,462 verified rows
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
# general pool: 100,323 rows, ~22 h
|
| 62 |
+
GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth TARGET=100000 \
|
| 63 |
+
BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 \
|
| 64 |
+
nohup $V -u synth_generate.py > synth_run.log 2>&1 &
|
| 65 |
+
|
| 66 |
+
# relational pool: 20,139 rows, ~3.5 h. Disjoint task-id range, on purpose.
|
| 67 |
+
GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth_rel POOL=relational \
|
| 68 |
+
START_TASK=1000000 TARGET=20000 BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 SEED=1234 \
|
| 69 |
+
nohup $V -u synth_generate.py > synth_rel.log 2>&1 &
|
| 70 |
+
|
| 71 |
+
./finish_merge_push.sh # merge primary cache + any node shards -> out_merged_v6/
|
| 72 |
+
$P split_synth_v6.py # -> data_synth_v6_sft/{train,eval,eval_rel}.jsonl
|
| 73 |
+
$P push_synth_dataset.py --repo <user>/arabic-math-reasoning-synth
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
**Resumability is the design, not a feature.** A task is one generation call asking for 4 problems,
|
| 77 |
+
and task *N*'s prompt is a pure function of *N* (`synth_common.build_task`) — nothing about the
|
| 78 |
+
plan is persisted, so a restart redraws identical prompts. Finished tasks are appended to
|
| 79 |
+
`generations.jsonl` and fsynced; a torn final line is dropped with a warning. The stop condition is
|
| 80 |
+
**accepted rows, not tasks**.
|
| 81 |
+
|
| 82 |
+
**Raw completions are stored, never just the parsed rows.** Every validator change can be re-scored
|
| 83 |
+
over the whole cache with no GPU — which is how the accept rate went 48.4% → 64.9% without
|
| 84 |
+
regenerating anything.
|
| 85 |
+
|
| 86 |
+
**The arithmetic audit** (`synth_common.validate`) re-evaluates every `a op b = c` in the reasoning
|
| 87 |
+
and one wrong equation rejects the row. Two things it must keep doing, both of which were bugs that
|
| 88 |
+
rejected *correct* rows:
|
| 89 |
+
|
| 90 |
+
1. **Equation chains.** Models write `75 + 15 × 5 = 75 + 75 = 150`. Reading only to the first `=`
|
| 91 |
+
compares 150 against 75 and rejects a correct chain. Split the whole chain, require every
|
| 92 |
+
segment to agree.
|
| 93 |
+
2. **Rounding.** `3200 / 60 = 53.33` is arithmetic as people write it. `_close()` forgives rounding
|
| 94 |
+
*at the precision the model displayed* (and floor/ceil for integers), so a genuinely wrong
|
| 95 |
+
number still fails.
|
| 96 |
+
|
| 97 |
+
Also rejected: `noop_step` (`63 + 0 = 63`, padding to hit a step count), `meta_commentary`, Latin
|
| 98 |
+
residue, missing conclusion marker, and answers that disagree with the last computed value.
|
| 99 |
+
|
| 100 |
+
**The relational pool is a separate pool** (`RELATIONAL_OPS`), deliberately not appended to
|
| 101 |
+
`OPERATIONS`. Appending would change `rng.choice()` for every task id and silently break
|
| 102 |
+
reproducibility of the first 100,323 rows. `build_task(task_id, seed, pool)` takes
|
| 103 |
+
`pool="default"` or `"relational"`; keep it that way.
|
| 104 |
+
|
| 105 |
+
Check `unique_templates` in `build_stats.json` rather than assuming the variation grid worked.
|
| 106 |
+
|
| 107 |
+
## 3. SFT
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
$P prepare_data.py # Arabic_Reasoning_Dataset -> data/{train,eval}.jsonl
|
| 111 |
+
$P prepare_gsm_sft.py # -> data_gsm_sft/{train,eval}.jsonl
|
| 112 |
+
$P prepare_v6_sft.py # three-way mix -> data_v6_sft/{train,eval}.jsonl
|
| 113 |
+
|
| 114 |
+
BASE_MODEL=<base> OUTPUT_DIR=./Nawah-Math-Reasoning \
|
| 115 |
+
TRAIN_FILE=data_v6_sft/train.jsonl EVAL_FILE=data_v6_sft/eval.jsonl \
|
| 116 |
+
MAX_LENGTH=768 EPOCHS=5 BATCH_SIZE=64 GRAD_ACCUM=1 WARMUP_STEPS=200 EVAL_STEPS=1000 LOAD_BEST=0 \
|
| 117 |
+
$P -u train_reasoning.py 2>&1 | tee train.log # 21,535 steps, ~85 min on 1x A6000
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
**`LOAD_BEST=0` is load-bearing.** Eval loss selects the *worse* checkpoint on this ladder, and
|
| 121 |
+
that was measured rather than assumed: on a corpus with no repeated rows, the minimum-loss
|
| 122 |
+
checkpoint scored 30.9% where the final scored 35.6%. It held for four consecutive runs. Ship the
|
| 123 |
+
final checkpoint.
|
| 124 |
+
|
| 125 |
+
**Answer styles are not normalised.** GSM8K rows end in a bare numeral, the other two corpora in an
|
| 126 |
+
`إذن، …` sentence. Rewriting them into one style deletes what the mix adds, so a `source` tag rides
|
| 127 |
+
on every row and eval scores each half on its own terms.
|
| 128 |
+
|
| 129 |
+
The eval splits are **pinned across versions** — the same 400 `Arabic_Reasoning` rows and the same
|
| 130 |
+
600 GSM8K rows since the first model, and `split_synth_v6.py` copies the previous synth eval rows
|
| 131 |
+
through verbatim rather than re-shuffling. Re-drawing would have moved 1,955 of 2,000 held-out
|
| 132 |
+
items into train.
|
| 133 |
+
|
| 134 |
+
## 4. Eval
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
EVAL_FILE=data_v6_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1800
|
| 138 |
+
EVAL_FILE=data_synth_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1000
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Greedy. Reports well-formedness, **number agreement**, exact match and reasoning length, broken
|
| 142 |
+
down per `source` when the rows carry the tag. `EVAL_FILE` comes from the env — pointing it at the
|
| 143 |
+
wrong split silently scores against the wrong data.
|
| 144 |
+
|
| 145 |
+
Score on **number agreement**, not exact match: with two answer styles in the mix, exact match
|
| 146 |
+
measures style compliance, not arithmetic.
|
| 147 |
+
|
| 148 |
+
## 5. RL — run the diagnostic first
|
| 149 |
+
|
| 150 |
+
```bash
|
| 151 |
+
$P -u passk_diag.py ./Nawah-Math-Reasoning # 240 problems x k=8 @ T=1.0, ~25 min
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
RLVR reweights samples the model already produces. A group where every sample is wrong scores
|
| 155 |
+
all-zero, the advantage is zero, and there is no gradient — so exploitable headroom is bounded by
|
| 156 |
+
`pass@k − pass@1`. On the previous version that was **+27.1 points**, with 40.4% of problems never
|
| 157 |
+
solved in 8 tries. Run this before spending a GPU-hour.
|
| 158 |
+
|
| 159 |
+
That diagnostic is also what produced this release: the dead 40% turned out to be a *data* gap
|
| 160 |
+
(relational comparisons were 1.34% of the corpus), so the fix was §2's relational pool through
|
| 161 |
+
SFT, not RL. **Data first, then RL** — running RL first leaves the dead tail untouched and caps
|
| 162 |
+
the gain.
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
MODEL=./Nawah-Math-Reasoning OUTPUT_DIR=./Nawah-Math-Reasoning-grpo \
|
| 166 |
+
GROUP=8 PROMPTS_PER=8 STEPS=500 LR=1e-6 BETA=0.02 \
|
| 167 |
+
$P -u grpo_train.py 2>&1 | tee grpo.log
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
`grpo_train.py` is hand-rolled — TRL is not installable against these pins. Group-normalised
|
| 171 |
+
advantage with no value network, binary final-answer reward (not partial credit — the failure being
|
| 172 |
+
fixed is fluent reasoning landing on a wrong number), KL to a frozen reference via Schulman's k3
|
| 173 |
+
estimator, and no importance ratio or PPO clipping because sampling is on-policy with one step per
|
| 174 |
+
batch. Zero-spread groups are skipped **and counted**; the dead-group percentage in the log is the
|
| 175 |
+
number to watch.
|
| 176 |
+
|
| 177 |
+
## 6. Release
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
$P build_release_code.py # stage this directory
|
| 181 |
+
$P push_release.py --dry-run # render the card only
|
| 182 |
+
$P push_release.py
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
Every number in the model card is read from an eval JSON on disk. Nothing is typed by hand, so the
|
| 186 |
+
card cannot drift from the measurements.
|
| 187 |
+
|
| 188 |
+
## Demo
|
| 189 |
+
|
| 190 |
+
`space/` is the Gradio demo, deployable as-is to a Space. Two things that break it if "cleaned up":
|
| 191 |
+
the streamer must use `skip_special_tokens=False` (`<think>`/`</think>` are real special tokens and
|
| 192 |
+
stripping them destroys the reasoning/answer split), and `render_prompt()` must stay byte-identical
|
| 193 |
+
to the trainer's rendering — a 52M model is very sensitive to format drift.
|
| 194 |
+
|
| 195 |
+
On ZeroGPU, `import spaces` must come **before** torch, and `model.to("cuda")` at startup must stay
|
| 196 |
+
unguarded: no GPU is attached at startup and the call is replayed inside the forked GPU process.
|
code/build_dataset.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Assemble the Arabic dataset from the translation cache and validate it.
|
| 3 |
+
|
| 4 |
+
Validation per row (a translation is only kept if it passes):
|
| 5 |
+
* both segments translated and non-empty
|
| 6 |
+
* the numbers in the Arabic text match the English exactly (order-insensitive multiset)
|
| 7 |
+
* no degenerate repetition loop from the translator
|
| 8 |
+
* not left largely untranslated (Latin-script residue)
|
| 9 |
+
|
| 10 |
+
Writes out_gsm/gsm8k_reasoning_ar.parquet plus a rejects file for inspection/retry.
|
| 11 |
+
"""
|
| 12 |
+
import collections
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import pyarrow as pa
|
| 19 |
+
import pyarrow.parquet as pq
|
| 20 |
+
|
| 21 |
+
sys.path.insert(0, ".")
|
| 22 |
+
from gsm_common import build_record
|
| 23 |
+
|
| 24 |
+
OUT = Path("out_gsm")
|
| 25 |
+
AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩", "0123456789")
|
| 26 |
+
NUM_RE = re.compile(r"\d+(?:\.\d+)?")
|
| 27 |
+
LATIN_RE = re.compile(r"[A-Za-z]")
|
| 28 |
+
ARABIC_RE = re.compile(r"[-ۿ]")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def numbers(text):
|
| 32 |
+
return NUM_RE.findall(text.translate(AR_DIGITS).replace(",", ""))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def has_repetition_loop(text, n=6, times=3):
|
| 36 |
+
"""Detect the translator getting stuck repeating an n-word window."""
|
| 37 |
+
words = text.split()
|
| 38 |
+
if len(words) < n * times:
|
| 39 |
+
return False
|
| 40 |
+
counts = collections.Counter(
|
| 41 |
+
" ".join(words[i : i + n]) for i in range(len(words) - n + 1)
|
| 42 |
+
)
|
| 43 |
+
return counts.most_common(1)[0][1] >= times
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
VERBALISABLE_MAX = 12 # Arabic writes small quantities as words ("ضعف" for 2, "الستة" for 6)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def check(en, ar, strict=True):
|
| 50 |
+
"""
|
| 51 |
+
strict=True (reasoning chains): every numeral must survive exactly — the arithmetic depends
|
| 52 |
+
on it. strict=False (questions): a small number may be verbalised, but a number the source
|
| 53 |
+
never contained is a translation error (observed: '$13751' -> '13571', '4 × 44' -> '4 × 46'),
|
| 54 |
+
which silently corrupts the math and is always rejected.
|
| 55 |
+
"""
|
| 56 |
+
if not ar or not ar.strip():
|
| 57 |
+
return "empty"
|
| 58 |
+
en_n, ar_n = collections.Counter(numbers(en)), collections.Counter(numbers(ar))
|
| 59 |
+
if ar_n - en_n:
|
| 60 |
+
return "invented_number"
|
| 61 |
+
missing = en_n - ar_n
|
| 62 |
+
if missing:
|
| 63 |
+
if strict:
|
| 64 |
+
return "number_dropped"
|
| 65 |
+
if any(float(v) > VERBALISABLE_MAX for v in missing):
|
| 66 |
+
return "number_dropped"
|
| 67 |
+
if has_repetition_loop(ar):
|
| 68 |
+
return "repetition"
|
| 69 |
+
if not ARABIC_RE.search(ar):
|
| 70 |
+
return "not_arabic"
|
| 71 |
+
latin = len(LATIN_RE.findall(ar))
|
| 72 |
+
if latin > 0.25 * len(ar.replace(" ", "")):
|
| 73 |
+
return "latin_residue"
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
trans = {}
|
| 79 |
+
with open(OUT / "translations.jsonl", encoding="utf-8") as fh:
|
| 80 |
+
for line in fh:
|
| 81 |
+
try:
|
| 82 |
+
r = json.loads(line)
|
| 83 |
+
except json.JSONDecodeError:
|
| 84 |
+
continue
|
| 85 |
+
trans[r["src"]] = r["tgt"]
|
| 86 |
+
print(f"[*] {len(trans)} cached translations")
|
| 87 |
+
|
| 88 |
+
rows = [json.loads(l) for l in open(OUT / "selected_rows.jsonl", encoding="utf-8")]
|
| 89 |
+
print(f"[*] {len(rows)} selected rows")
|
| 90 |
+
|
| 91 |
+
kept, rejects = [], []
|
| 92 |
+
reasons = collections.Counter()
|
| 93 |
+
for r in rows:
|
| 94 |
+
q_ar, t_ar = trans.get(r["question"]), trans.get(r["thinking"])
|
| 95 |
+
if q_ar is None or t_ar is None:
|
| 96 |
+
reasons["missing"] += 1
|
| 97 |
+
rejects.append({**r, "reason": "missing"})
|
| 98 |
+
continue
|
| 99 |
+
why = check(r["question"], q_ar, strict=False) or check(r["thinking"], t_ar, strict=True)
|
| 100 |
+
if why:
|
| 101 |
+
reasons[why] += 1
|
| 102 |
+
rejects.append({**r, "question_ar": q_ar, "thinking_ar": t_ar, "reason": why})
|
| 103 |
+
continue
|
| 104 |
+
kept.append(
|
| 105 |
+
{
|
| 106 |
+
"text": build_record(q_ar, t_ar, r["answer"]),
|
| 107 |
+
"question": q_ar,
|
| 108 |
+
"thinking": t_ar,
|
| 109 |
+
"answer": r["answer"],
|
| 110 |
+
"question_en": r["question"],
|
| 111 |
+
"thinking_en": r["thinking"],
|
| 112 |
+
"source_index": r["idx"],
|
| 113 |
+
}
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
print(f"[*] kept {len(kept)}/{len(rows)} ({len(kept)/len(rows):.2%})")
|
| 117 |
+
print(f"[*] rejects: {dict(reasons)}")
|
| 118 |
+
|
| 119 |
+
table = pa.table({k: [row[k] for row in kept] for k in kept[0]})
|
| 120 |
+
pq.write_table(table, OUT / "gsm8k_reasoning_ar.parquet", compression="zstd")
|
| 121 |
+
print(f"[+] wrote {OUT / 'gsm8k_reasoning_ar.parquet'} ({table.num_rows} rows)")
|
| 122 |
+
|
| 123 |
+
with open(OUT / "rejects.jsonl", "w", encoding="utf-8") as fh:
|
| 124 |
+
for r in rejects:
|
| 125 |
+
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 126 |
+
print(f"[+] wrote {OUT / 'rejects.jsonl'} ({len(rejects)} rows)")
|
| 127 |
+
|
| 128 |
+
stats = {
|
| 129 |
+
"selected": len(rows),
|
| 130 |
+
"kept": len(kept),
|
| 131 |
+
"kept_pct": 100 * len(kept) / len(rows),
|
| 132 |
+
"rejects": dict(reasons),
|
| 133 |
+
"unique_translations": len(trans),
|
| 134 |
+
}
|
| 135 |
+
(OUT / "build_stats.json").write_text(json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 136 |
+
for row in kept[:3]:
|
| 137 |
+
print("-" * 70)
|
| 138 |
+
print("EN:", row["question_en"][:120])
|
| 139 |
+
print("AR:", row["question"][:120])
|
| 140 |
+
print("AR think:", row["thinking"][:160])
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
main()
|
code/build_release_code.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage the training code that ships inside the released model repo, under `code/`.
|
| 3 |
+
|
| 4 |
+
Copies a curated subset of the working directory — the scripts that actually produce the released
|
| 5 |
+
model, not the whole scratch dir — into `release_code/`, and writes the recipe README beside them.
|
| 6 |
+
`push_release.py` uploads the result to `code/` in the model repo.
|
| 7 |
+
|
| 8 |
+
The list below is deliberate. Excluded: dead ends (`latex_segment.py`, `pilot_*.py`), the
|
| 9 |
+
per-version push scripts for models that stay private, and the logs. Included: everything a reader
|
| 10 |
+
needs to rebuild both datasets and rerun the training and eval end to end.
|
| 11 |
+
"""
|
| 12 |
+
import shutil
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
OUT = Path("release_code")
|
| 16 |
+
|
| 17 |
+
FILES = {
|
| 18 |
+
# ── data: translation pipeline (gsm8k-reasoning-ar) ───────────────────────
|
| 19 |
+
"gsm_common.py": "data",
|
| 20 |
+
"translate_gsm.py": "data",
|
| 21 |
+
"build_dataset.py": "data",
|
| 22 |
+
"retry_failed.py": "data",
|
| 23 |
+
"push_dataset.py": "data",
|
| 24 |
+
# ── data: synthetic pipeline (arabic-math-reasoning-synth) ────────────────
|
| 25 |
+
"synth_common.py": "data",
|
| 26 |
+
"synth_generate.py": "data",
|
| 27 |
+
"build_synth_dataset.py": "data",
|
| 28 |
+
"split_synth_v6.py": "data",
|
| 29 |
+
"finish_merge_push.sh": "data",
|
| 30 |
+
"push_synth_dataset.py": "data",
|
| 31 |
+
# ── SFT splits ────────────────────────────────────────────────────────────
|
| 32 |
+
"prepare_data.py": "data",
|
| 33 |
+
"prepare_gsm_sft.py": "data",
|
| 34 |
+
"prepare_v6_sft.py": "data",
|
| 35 |
+
# ── train / eval / RL ─────────────────────────────────────────────────────
|
| 36 |
+
"train_reasoning.py": "train",
|
| 37 |
+
"eval_reasoning.py": "train",
|
| 38 |
+
"passk_diag.py": "train",
|
| 39 |
+
"grpo_train.py": "train",
|
| 40 |
+
"chat.py": "train",
|
| 41 |
+
# ── release ───────────────────────────────────────────────────────────────
|
| 42 |
+
"push_release.py": "release",
|
| 43 |
+
"build_release_code.py": "release",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
SPACE_FILES = ["app.py", "requirements.txt", "README.md"]
|
| 47 |
+
|
| 48 |
+
README = """# Nawah-Math-Reasoning — training code
|
| 49 |
+
|
| 50 |
+
Everything that produced the model in this repo: two dataset pipelines, the SFT trainer, the eval
|
| 51 |
+
harness, a `pass@k` diagnostic, and a GRPO implementation. Run top to bottom and you get the
|
| 52 |
+
release; run any single stage and it resumes from what is already on disk.
|
| 53 |
+
|
| 54 |
+
Paths below assume this directory is the working directory and `$P` is a Python with
|
| 55 |
+
`torch`, `transformers>=5.15`, `pyarrow` and `huggingface_hub`.
|
| 56 |
+
|
| 57 |
+
## 0. Two environments, and why
|
| 58 |
+
|
| 59 |
+
| env | for | pins |
|
| 60 |
+
|---|---|---|
|
| 61 |
+
| training / eval / Hub | `train_reasoning.py`, `eval_reasoning.py`, everything else | torch 2.9 + cu126, **transformers 5.15** |
|
| 62 |
+
| generation | `translate_gsm.py`, `synth_generate.py` | **vLLM 0.8.5.post1**, torch 2.6 + cu124, **transformers 4.51.3** |
|
| 63 |
+
|
| 64 |
+
They are not interchangeable. The model configs are transformers-v5 format (`rope_parameters`,
|
| 65 |
+
`dtype`, `tokenizer_class: TokenizersBackend`) and transformers 4.x cannot read them — but vLLM
|
| 66 |
+
0.8.5 breaks on transformers 5.x (`TokenizersBackend has no attribute
|
| 67 |
+
all_special_tokens_extended`), so the generation env must stay at 4.51.3. Do not `pip install -U`
|
| 68 |
+
in it.
|
| 69 |
+
|
| 70 |
+
vLLM 0.8.5 is itself a pin: every release from 0.20.2 up requires torch 2.11, which is a CUDA 13
|
| 71 |
+
build needing driver ≥ 580. On a CUDA 12.4 host that is a hard stop, and cu12 wheels do not rescue
|
| 72 |
+
it (`ImportError: libcudart.so.13`). 0.8.5 is the last cu124 release.
|
| 73 |
+
|
| 74 |
+
In transformers v5 `TrainingArguments` it is `eval_strategy` (not `evaluation_strategy`) and
|
| 75 |
+
`warmup_steps` (there is no `warmup_ratio`).
|
| 76 |
+
|
| 77 |
+
## 1. `gsm8k-reasoning-ar` — 142,969 machine-translated rows
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
$V translate_gsm.py 150000 # vLLM env; ~95 min on 1x A6000; resumable
|
| 81 |
+
$P build_dataset.py # -> out_gsm/*.parquet + rejects.jsonl
|
| 82 |
+
$P push_dataset.py --repo <user>/gsm8k-reasoning-ar
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
`Ajhesh7/gsm8k-reasoning-SFT-datas` translated with `ByteDance-Seed/Seed-X-PPO-7B`. The trailing
|
| 86 |
+
language tag in the prompt is **mandatory**: `Translate the following English sentence into
|
| 87 |
+
Arabic:\\n{text} <ar>`.
|
| 88 |
+
|
| 89 |
+
Only 150k of the source's 600,000 rows are translated, and the sample is stratified by question
|
| 90 |
+
pattern with a floor of 13 rows/pattern: the corpus expands from just **2,814** question patterns,
|
| 91 |
+
so translating all 600k is ~99% redundant.
|
| 92 |
+
|
| 93 |
+
**The reason `build_dataset.py` has a validator at all:** Seed-X silently corrupts arithmetic in
|
| 94 |
+
~0.6% of segments — `$13751` → `13571`, `4 × 44 = 176` → `4 × 46 = 176`. These read as fluent
|
| 95 |
+
Arabic and pass any fluency check. So every row is numeral-audited, asymmetrically: **reasoning
|
| 96 |
+
chains strictly** (every numeral must survive exactly), **questions leniently** (a value ≤ 12 may
|
| 97 |
+
be verbalised — `6 friends` → `أصدقائها الستة` — but any number the English never contained is
|
| 98 |
+
rejected). Naive exact-multiset matching rejects ~6% of *correct* translations; do not "simplify"
|
| 99 |
+
it back to that.
|
| 100 |
+
|
| 101 |
+
`translate_gsm.py` appends every chunk to `out_gsm/translations.jsonl` and skips cached segments on
|
| 102 |
+
restart, so an interruption costs at most one 20k chunk. Do not pass `enable_prefix_caching=True`
|
| 103 |
+
— it hangs at startup on this vLLM/V1 engine.
|
| 104 |
+
|
| 105 |
+
## 2. `arabic-math-reasoning-synth` — 120,462 verified rows
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
# general pool: 100,323 rows, ~22 h
|
| 109 |
+
GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth TARGET=100000 \\
|
| 110 |
+
BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 \\
|
| 111 |
+
nohup $V -u synth_generate.py > synth_run.log 2>&1 &
|
| 112 |
+
|
| 113 |
+
# relational pool: 20,139 rows, ~3.5 h. Disjoint task-id range, on purpose.
|
| 114 |
+
GEN_MODEL=<path>/gemma-3-12b-it BACKEND=vllm OUT_DIR=out_synth_rel POOL=relational \\
|
| 115 |
+
START_TASK=1000000 TARGET=20000 BATCH=256 MAX_NEW=1200 MAX_LEN=3072 GPU_UTIL=0.90 SEED=1234 \\
|
| 116 |
+
nohup $V -u synth_generate.py > synth_rel.log 2>&1 &
|
| 117 |
+
|
| 118 |
+
./finish_merge_push.sh # merge primary cache + any node shards -> out_merged_v6/
|
| 119 |
+
$P split_synth_v6.py # -> data_synth_v6_sft/{train,eval,eval_rel}.jsonl
|
| 120 |
+
$P push_synth_dataset.py --repo <user>/arabic-math-reasoning-synth
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
**Resumability is the design, not a feature.** A task is one generation call asking for 4 problems,
|
| 124 |
+
and task *N*'s prompt is a pure function of *N* (`synth_common.build_task`) — nothing about the
|
| 125 |
+
plan is persisted, so a restart redraws identical prompts. Finished tasks are appended to
|
| 126 |
+
`generations.jsonl` and fsynced; a torn final line is dropped with a warning. The stop condition is
|
| 127 |
+
**accepted rows, not tasks**.
|
| 128 |
+
|
| 129 |
+
**Raw completions are stored, never just the parsed rows.** Every validator change can be re-scored
|
| 130 |
+
over the whole cache with no GPU — which is how the accept rate went 48.4% → 64.9% without
|
| 131 |
+
regenerating anything.
|
| 132 |
+
|
| 133 |
+
**The arithmetic audit** (`synth_common.validate`) re-evaluates every `a op b = c` in the reasoning
|
| 134 |
+
and one wrong equation rejects the row. Two things it must keep doing, both of which were bugs that
|
| 135 |
+
rejected *correct* rows:
|
| 136 |
+
|
| 137 |
+
1. **Equation chains.** Models write `75 + 15 × 5 = 75 + 75 = 150`. Reading only to the first `=`
|
| 138 |
+
compares 150 against 75 and rejects a correct chain. Split the whole chain, require every
|
| 139 |
+
segment to agree.
|
| 140 |
+
2. **Rounding.** `3200 / 60 = 53.33` is arithmetic as people write it. `_close()` forgives rounding
|
| 141 |
+
*at the precision the model displayed* (and floor/ceil for integers), so a genuinely wrong
|
| 142 |
+
number still fails.
|
| 143 |
+
|
| 144 |
+
Also rejected: `noop_step` (`63 + 0 = 63`, padding to hit a step count), `meta_commentary`, Latin
|
| 145 |
+
residue, missing conclusion marker, and answers that disagree with the last computed value.
|
| 146 |
+
|
| 147 |
+
**The relational pool is a separate pool** (`RELATIONAL_OPS`), deliberately not appended to
|
| 148 |
+
`OPERATIONS`. Appending would change `rng.choice()` for every task id and silently break
|
| 149 |
+
reproducibility of the first 100,323 rows. `build_task(task_id, seed, pool)` takes
|
| 150 |
+
`pool="default"` or `"relational"`; keep it that way.
|
| 151 |
+
|
| 152 |
+
Check `unique_templates` in `build_stats.json` rather than assuming the variation grid worked.
|
| 153 |
+
|
| 154 |
+
## 3. SFT
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
$P prepare_data.py # Arabic_Reasoning_Dataset -> data/{train,eval}.jsonl
|
| 158 |
+
$P prepare_gsm_sft.py # -> data_gsm_sft/{train,eval}.jsonl
|
| 159 |
+
$P prepare_v6_sft.py # three-way mix -> data_v6_sft/{train,eval}.jsonl
|
| 160 |
+
|
| 161 |
+
BASE_MODEL=<base> OUTPUT_DIR=./Nawah-Math-Reasoning \\
|
| 162 |
+
TRAIN_FILE=data_v6_sft/train.jsonl EVAL_FILE=data_v6_sft/eval.jsonl \\
|
| 163 |
+
MAX_LENGTH=768 EPOCHS=5 BATCH_SIZE=64 GRAD_ACCUM=1 WARMUP_STEPS=200 EVAL_STEPS=1000 LOAD_BEST=0 \\
|
| 164 |
+
$P -u train_reasoning.py 2>&1 | tee train.log # 21,535 steps, ~85 min on 1x A6000
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
**`LOAD_BEST=0` is load-bearing.** Eval loss selects the *worse* checkpoint on this ladder, and
|
| 168 |
+
that was measured rather than assumed: on a corpus with no repeated rows, the minimum-loss
|
| 169 |
+
checkpoint scored 30.9% where the final scored 35.6%. It held for four consecutive runs. Ship the
|
| 170 |
+
final checkpoint.
|
| 171 |
+
|
| 172 |
+
**Answer styles are not normalised.** GSM8K rows end in a bare numeral, the other two corpora in an
|
| 173 |
+
`إذن، …` sentence. Rewriting them into one style deletes what the mix adds, so a `source` tag rides
|
| 174 |
+
on every row and eval scores each half on its own terms.
|
| 175 |
+
|
| 176 |
+
The eval splits are **pinned across versions** — the same 400 `Arabic_Reasoning` rows and the same
|
| 177 |
+
600 GSM8K rows since the first model, and `split_synth_v6.py` copies the previous synth eval rows
|
| 178 |
+
through verbatim rather than re-shuffling. Re-drawing would have moved 1,955 of 2,000 held-out
|
| 179 |
+
items into train.
|
| 180 |
+
|
| 181 |
+
## 4. Eval
|
| 182 |
+
|
| 183 |
+
```bash
|
| 184 |
+
EVAL_FILE=data_v6_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1800
|
| 185 |
+
EVAL_FILE=data_synth_sft/eval.jsonl $P eval_reasoning.py ./Nawah-Math-Reasoning 1000
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
Greedy. Reports well-formedness, **number agreement**, exact match and reasoning length, broken
|
| 189 |
+
down per `source` when the rows carry the tag. `EVAL_FILE` comes from the env — pointing it at the
|
| 190 |
+
wrong split silently scores against the wrong data.
|
| 191 |
+
|
| 192 |
+
Score on **number agreement**, not exact match: with two answer styles in the mix, exact match
|
| 193 |
+
measures style compliance, not arithmetic.
|
| 194 |
+
|
| 195 |
+
## 5. RL — run the diagnostic first
|
| 196 |
+
|
| 197 |
+
```bash
|
| 198 |
+
$P -u passk_diag.py ./Nawah-Math-Reasoning # 240 problems x k=8 @ T=1.0, ~25 min
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
RLVR reweights samples the model already produces. A group where every sample is wrong scores
|
| 202 |
+
all-zero, the advantage is zero, and there is no gradient — so exploitable headroom is bounded by
|
| 203 |
+
`pass@k − pass@1`. On the previous version that was **+27.1 points**, with 40.4% of problems never
|
| 204 |
+
solved in 8 tries. Run this before spending a GPU-hour.
|
| 205 |
+
|
| 206 |
+
That diagnostic is also what produced this release: the dead 40% turned out to be a *data* gap
|
| 207 |
+
(relational comparisons were 1.34% of the corpus), so the fix was §2's relational pool through
|
| 208 |
+
SFT, not RL. **Data first, then RL** — running RL first leaves the dead tail untouched and caps
|
| 209 |
+
the gain.
|
| 210 |
+
|
| 211 |
+
```bash
|
| 212 |
+
MODEL=./Nawah-Math-Reasoning OUTPUT_DIR=./Nawah-Math-Reasoning-grpo \\
|
| 213 |
+
GROUP=8 PROMPTS_PER=8 STEPS=500 LR=1e-6 BETA=0.02 \\
|
| 214 |
+
$P -u grpo_train.py 2>&1 | tee grpo.log
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
`grpo_train.py` is hand-rolled — TRL is not installable against these pins. Group-normalised
|
| 218 |
+
advantage with no value network, binary final-answer reward (not partial credit — the failure being
|
| 219 |
+
fixed is fluent reasoning landing on a wrong number), KL to a frozen reference via Schulman's k3
|
| 220 |
+
estimator, and no importance ratio or PPO clipping because sampling is on-policy with one step per
|
| 221 |
+
batch. Zero-spread groups are skipped **and counted**; the dead-group percentage in the log is the
|
| 222 |
+
number to watch.
|
| 223 |
+
|
| 224 |
+
## 6. Release
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
$P build_release_code.py # stage this directory
|
| 228 |
+
$P push_release.py --dry-run # render the card only
|
| 229 |
+
$P push_release.py
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
Every number in the model card is read from an eval JSON on disk. Nothing is typed by hand, so the
|
| 233 |
+
card cannot drift from the measurements.
|
| 234 |
+
|
| 235 |
+
## Demo
|
| 236 |
+
|
| 237 |
+
`space/` is the Gradio demo, deployable as-is to a Space. Two things that break it if "cleaned up":
|
| 238 |
+
the streamer must use `skip_special_tokens=False` (`<think>`/`</think>` are real special tokens and
|
| 239 |
+
stripping them destroys the reasoning/answer split), and `render_prompt()` must stay byte-identical
|
| 240 |
+
to the trainer's rendering — a 52M model is very sensitive to format drift.
|
| 241 |
+
|
| 242 |
+
On ZeroGPU, `import spaces` must come **before** torch, and `model.to("cuda")` at startup must stay
|
| 243 |
+
unguarded: no GPU is attached at startup and the call is replayed inside the forked GPU process.
|
| 244 |
+
"""
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def main():
|
| 248 |
+
if OUT.exists():
|
| 249 |
+
shutil.rmtree(OUT)
|
| 250 |
+
OUT.mkdir()
|
| 251 |
+
|
| 252 |
+
missing = [f for f in FILES if not Path(f).is_file()]
|
| 253 |
+
if missing:
|
| 254 |
+
raise SystemExit(f"missing source files: {missing}")
|
| 255 |
+
|
| 256 |
+
for name in FILES:
|
| 257 |
+
shutil.copy2(name, OUT / name)
|
| 258 |
+
|
| 259 |
+
space_out = OUT / "space"
|
| 260 |
+
space_out.mkdir()
|
| 261 |
+
for name in SPACE_FILES:
|
| 262 |
+
shutil.copy2(Path("space") / name, space_out / name)
|
| 263 |
+
|
| 264 |
+
(OUT / "README.md").write_text(README, encoding="utf-8")
|
| 265 |
+
|
| 266 |
+
n = sum(1 for _ in OUT.rglob("*") if _.is_file())
|
| 267 |
+
print(f"[+] {OUT}/ — {n} files")
|
| 268 |
+
for p in sorted(OUT.rglob("*")):
|
| 269 |
+
if p.is_file():
|
| 270 |
+
print(f" {p.relative_to(OUT)} ({p.stat().st_size:,} B)")
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
if __name__ == "__main__":
|
| 274 |
+
main()
|
code/build_synth_dataset.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Turn out_synth/generations.jsonl into the finished synthetic corpus.
|
| 3 |
+
|
| 4 |
+
Re-parses and re-validates every cached generation with synth_common (the generator's inline
|
| 5 |
+
accounting is only a progress estimate; this is the authoritative pass), drops duplicates, and
|
| 6 |
+
writes a parquet plus the SFT splits train_reasoning.py reads directly.
|
| 7 |
+
|
| 8 |
+
Duplicates are keyed on the question with its numbers masked out, so "3 apples at 5 riyal" and
|
| 9 |
+
"7 apples at 9 riyal" collapse to one template — this is the exact failure the translated GSM8K
|
| 10 |
+
set has (142,969 rows from 2,814 templates), and the whole point of the variation grid is to not
|
| 11 |
+
repeat it. The template count is reported so it can be checked rather than assumed.
|
| 12 |
+
|
| 13 |
+
Writes:
|
| 14 |
+
out_synth/arabic_math_reasoning_synth.parquet the corpus
|
| 15 |
+
out_synth/rejects.jsonl every rejected item with its reason
|
| 16 |
+
out_synth/build_stats.json counts, reject histogram, axis coverage
|
| 17 |
+
data_synth_sft/{train,eval}.jsonl ready for train_reasoning.py
|
| 18 |
+
"""
|
| 19 |
+
import collections
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import pyarrow as pa
|
| 26 |
+
import pyarrow.parquet as pq
|
| 27 |
+
|
| 28 |
+
import synth_common as sc
|
| 29 |
+
|
| 30 |
+
OUT_DIR = Path(os.environ.get("OUT_DIR", "out_synth"))
|
| 31 |
+
CACHE = OUT_DIR / "generations.jsonl"
|
| 32 |
+
SFT_DIR = Path(os.environ.get("SFT_DIR", "data_synth_sft"))
|
| 33 |
+
EVAL_N = int(os.environ.get("EVAL_N", 2000))
|
| 34 |
+
LIMIT = int(os.environ.get("LIMIT", 0)) # 0 = keep everything that validates
|
| 35 |
+
SEED = 42
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
stats = collections.Counter()
|
| 40 |
+
rejects_by_reason = collections.Counter()
|
| 41 |
+
axes_seen = collections.defaultdict(collections.Counter)
|
| 42 |
+
rows, rejects = [], []
|
| 43 |
+
seen = {}
|
| 44 |
+
|
| 45 |
+
with open(CACHE, encoding="utf-8") as fh:
|
| 46 |
+
for line in fh:
|
| 47 |
+
try:
|
| 48 |
+
rec = json.loads(line)
|
| 49 |
+
except json.JSONDecodeError:
|
| 50 |
+
stats["truncated_lines"] += 1 # a crash mid-write; the rest is still good
|
| 51 |
+
continue
|
| 52 |
+
stats["tasks"] += 1
|
| 53 |
+
items = sc.parse_items(rec["raw"])
|
| 54 |
+
stats["parsed_items"] += len(items)
|
| 55 |
+
if not items:
|
| 56 |
+
stats["tasks_with_no_parsable_item"] += 1
|
| 57 |
+
for item in items:
|
| 58 |
+
ok, reason = sc.validate(item)
|
| 59 |
+
if not ok:
|
| 60 |
+
rejects_by_reason[reason] += 1
|
| 61 |
+
rejects.append({**item, "reason": reason, "task_id": rec["task_id"]})
|
| 62 |
+
continue
|
| 63 |
+
key = sc.dedup_key(item["instruction"])
|
| 64 |
+
if key in seen:
|
| 65 |
+
stats["dropped_duplicate_template"] += 1
|
| 66 |
+
continue
|
| 67 |
+
seen[key] = True
|
| 68 |
+
for axis, value in rec["axes"].items():
|
| 69 |
+
axes_seen[axis][value] += 1
|
| 70 |
+
rows.append({**item, **{f"axis_{k}": v for k, v in rec["axes"].items()},
|
| 71 |
+
"task_id": rec["task_id"],
|
| 72 |
+
# rows cached before multi-node generation carry no model field
|
| 73 |
+
"gen_model": rec.get("model", "gemma-3-12b-it")})
|
| 74 |
+
|
| 75 |
+
for r in rows:
|
| 76 |
+
stats[f"model_{r['gen_model']}"] += 1
|
| 77 |
+
|
| 78 |
+
stats["kept"] = len(rows)
|
| 79 |
+
stats["rejected"] = sum(rejects_by_reason.values())
|
| 80 |
+
|
| 81 |
+
random.Random(SEED).shuffle(rows)
|
| 82 |
+
if LIMIT:
|
| 83 |
+
rows = rows[:LIMIT]
|
| 84 |
+
|
| 85 |
+
OUT_DIR.mkdir(exist_ok=True)
|
| 86 |
+
pq.write_table(pa.Table.from_pylist(rows), OUT_DIR / "arabic_math_reasoning_synth.parquet")
|
| 87 |
+
with open(OUT_DIR / "rejects.jsonl", "w", encoding="utf-8") as fh:
|
| 88 |
+
for r in rejects:
|
| 89 |
+
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 90 |
+
|
| 91 |
+
SFT_DIR.mkdir(exist_ok=True)
|
| 92 |
+
eval_rows, train_rows = rows[:EVAL_N], rows[EVAL_N:]
|
| 93 |
+
for name, split in (("train", train_rows), ("eval", eval_rows)):
|
| 94 |
+
with open(SFT_DIR / f"{name}.jsonl", "w", encoding="utf-8") as fh:
|
| 95 |
+
for r in split:
|
| 96 |
+
fh.write(json.dumps({"instruction": r["instruction"], "reasoning": r["reasoning"],
|
| 97 |
+
"answer": r["answer"], "source": "synth_math_ar"},
|
| 98 |
+
ensure_ascii=False) + "\n")
|
| 99 |
+
print(f"[+] {name}: {len(split):,} -> {SFT_DIR / f'{name}.jsonl'}")
|
| 100 |
+
|
| 101 |
+
summary = {
|
| 102 |
+
"stats": dict(stats),
|
| 103 |
+
"reject_reasons": dict(rejects_by_reason.most_common()),
|
| 104 |
+
"accept_rate": stats["kept"] / max(stats["parsed_items"], 1),
|
| 105 |
+
"unique_templates": len(seen),
|
| 106 |
+
"axis_coverage": {k: len(v) for k, v in axes_seen.items()},
|
| 107 |
+
}
|
| 108 |
+
(OUT_DIR / "build_stats.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2),
|
| 109 |
+
encoding="utf-8")
|
| 110 |
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
| 111 |
+
print(f"[+] {OUT_DIR / 'arabic_math_reasoning_synth.parquet'}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
main()
|
code/chat.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick manual probe: python chat.py "سؤالك هنا" [model_dir]"""
|
| 2 |
+
import sys, torch
|
| 3 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 4 |
+
|
| 5 |
+
question = sys.argv[1] if len(sys.argv) > 1 else "إذا كان لديك 1500 ريال وأنفقت 20% منها على الكتب، فكم تبقى معك؟"
|
| 6 |
+
model_dir = sys.argv[2] if len(sys.argv) > 2 else "./Nawah-Reasoning-v1"
|
| 7 |
+
|
| 8 |
+
tok = AutoTokenizer.from_pretrained(model_dir)
|
| 9 |
+
model = AutoModelForCausalLM.from_pretrained(model_dir, dtype=torch.bfloat16).cuda().eval()
|
| 10 |
+
|
| 11 |
+
prompt = tok.apply_chat_template([{"role": "user", "content": question}], tokenize=False, add_generation_prompt=True)
|
| 12 |
+
ids = tok(prompt, return_tensors="pt").to("cuda")
|
| 13 |
+
out = model.generate(**ids, max_new_tokens=512, do_sample=False,
|
| 14 |
+
eos_token_id=[tok.eos_token_id, tok.convert_tokens_to_ids("<|im_end|>")],
|
| 15 |
+
pad_token_id=tok.pad_token_id)
|
| 16 |
+
print(tok.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=False).split("<|im_end|>")[0])
|
code/eval_reasoning.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Greedy evaluation of the reasoning model on the held-out split.
|
| 3 |
+
|
| 4 |
+
Reports:
|
| 5 |
+
* format compliance — a single well-formed <think>…</think> block followed by an answer
|
| 6 |
+
* numeric agreement — do the numbers in the generated conclusion match the reference's
|
| 7 |
+
* length stats — how long the produced reasoning is
|
| 8 |
+
|
| 9 |
+
When the eval rows carry a `source` tag (the v3 mix), every metric is also broken down per
|
| 10 |
+
source, since the two corpora answer in different styles.
|
| 11 |
+
|
| 12 |
+
Usage: python eval_reasoning.py [model_dir] [n_samples]
|
| 13 |
+
"""
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 22 |
+
|
| 23 |
+
MODEL_DIR = sys.argv[1] if len(sys.argv) > 1 else "./Nawah-Reasoning-v1"
|
| 24 |
+
LIMIT = int(sys.argv[2]) if len(sys.argv) > 2 else 400
|
| 25 |
+
EVAL_FILE = os.environ.get("EVAL_FILE", "data/eval.jsonl")
|
| 26 |
+
MAX_NEW = 512
|
| 27 |
+
BATCH = 16
|
| 28 |
+
|
| 29 |
+
AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩٫٬", "0123456789.,")
|
| 30 |
+
NUM_RE = re.compile(r"\d+(?:\.\d+)?")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def numbers(text: str):
|
| 34 |
+
text = text.translate(AR_DIGITS).replace(",", "")
|
| 35 |
+
out = []
|
| 36 |
+
for tok in NUM_RE.findall(text):
|
| 37 |
+
val = float(tok)
|
| 38 |
+
out.append(int(val) if val.is_integer() else val)
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def parse(completion: str):
|
| 43 |
+
"""-> (reasoning, final_answer, well_formed)"""
|
| 44 |
+
m = re.match(r"\s*<think>(.*?)</think>(.*)", completion, re.S)
|
| 45 |
+
if not m:
|
| 46 |
+
return None, completion.strip(), False
|
| 47 |
+
reasoning, final = m.group(1).strip(), m.group(2).strip()
|
| 48 |
+
well_formed = (
|
| 49 |
+
completion.count("<think>") == 1
|
| 50 |
+
and completion.count("</think>") == 1
|
| 51 |
+
and bool(reasoning)
|
| 52 |
+
and bool(final)
|
| 53 |
+
)
|
| 54 |
+
return reasoning, final, well_formed
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def metrics(results):
|
| 58 |
+
n = len(results)
|
| 59 |
+
return {
|
| 60 |
+
"n": n,
|
| 61 |
+
"well_formed_pct": 100 * sum(r["well_formed"] for r in results) / n,
|
| 62 |
+
"numbers_match_pct": 100 * sum(r["numbers_match"] for r in results) / n,
|
| 63 |
+
"primary_number_match_pct": 100 * sum(r["primary_number_match"] for r in results) / n,
|
| 64 |
+
"answer_exact_pct": 100 * sum(r["answer_exact"] for r in results) / n,
|
| 65 |
+
"mean_reasoning_tokens": sum(r["reasoning_tokens"] for r in results) / n,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def main():
|
| 70 |
+
tok = AutoTokenizer.from_pretrained(MODEL_DIR)
|
| 71 |
+
model = AutoModelForCausalLM.from_pretrained(MODEL_DIR, dtype=torch.bfloat16).cuda().eval()
|
| 72 |
+
model.config.use_cache = True
|
| 73 |
+
|
| 74 |
+
rows = [json.loads(l) for l in open(EVAL_FILE, encoding="utf-8")][:LIMIT]
|
| 75 |
+
im_end = tok.convert_tokens_to_ids("<|im_end|>")
|
| 76 |
+
|
| 77 |
+
results = []
|
| 78 |
+
for start in range(0, len(rows), BATCH):
|
| 79 |
+
chunk = rows[start : start + BATCH]
|
| 80 |
+
prompts = [
|
| 81 |
+
f"<|im_start|>user\n{r['instruction']}<|im_end|>\n<|im_start|>assistant\n" for r in chunk
|
| 82 |
+
]
|
| 83 |
+
encoded = [[tok.bos_token_id] + tok.encode(p, add_special_tokens=False) for p in prompts]
|
| 84 |
+
width = max(len(e) for e in encoded)
|
| 85 |
+
# left-pad so every row's generation starts at the same offset
|
| 86 |
+
input_ids = torch.tensor([[tok.pad_token_id] * (width - len(e)) + e for e in encoded]).cuda()
|
| 87 |
+
attn = torch.tensor([[0] * (width - len(e)) + [1] * len(e) for e in encoded]).cuda()
|
| 88 |
+
|
| 89 |
+
with torch.no_grad():
|
| 90 |
+
out = model.generate(
|
| 91 |
+
input_ids=input_ids,
|
| 92 |
+
attention_mask=attn,
|
| 93 |
+
max_new_tokens=MAX_NEW,
|
| 94 |
+
do_sample=False,
|
| 95 |
+
eos_token_id=[im_end, tok.eos_token_id],
|
| 96 |
+
pad_token_id=tok.pad_token_id,
|
| 97 |
+
)
|
| 98 |
+
for row, seq in zip(chunk, out):
|
| 99 |
+
gen = tok.decode(seq[width:], skip_special_tokens=False)
|
| 100 |
+
gen = gen.split("<|im_end|>")[0].replace("</s>", "").replace("<pad>", "")
|
| 101 |
+
reasoning, final, ok = parse(gen)
|
| 102 |
+
ref_nums, gen_nums = numbers(row["answer"]), numbers(final)
|
| 103 |
+
results.append(
|
| 104 |
+
{
|
| 105 |
+
"source": row.get("source"),
|
| 106 |
+
"instruction": row["instruction"],
|
| 107 |
+
"reference_reasoning": row["reasoning"],
|
| 108 |
+
"reference_answer": row["answer"],
|
| 109 |
+
"generated_reasoning": reasoning,
|
| 110 |
+
"generated_answer": final,
|
| 111 |
+
"well_formed": ok,
|
| 112 |
+
"answer_exact": final.strip() == row["answer"].strip(),
|
| 113 |
+
"numbers_match": bool(ref_nums) and ref_nums == gen_nums,
|
| 114 |
+
"primary_number_match": bool(ref_nums) and ref_nums[0] in gen_nums,
|
| 115 |
+
"reasoning_tokens": len(tok.encode(reasoning or "", add_special_tokens=False)),
|
| 116 |
+
}
|
| 117 |
+
)
|
| 118 |
+
print(f" {min(start + BATCH, len(rows))}/{len(rows)}", flush=True)
|
| 119 |
+
|
| 120 |
+
summary = metrics(results)
|
| 121 |
+
summary["model"] = MODEL_DIR
|
| 122 |
+
# A mixed corpus (v3) answers in two different styles, so a single exact-match number is
|
| 123 |
+
# meaningless — score each source on its own terms.
|
| 124 |
+
sources = sorted({r["source"] for r in results if r["source"]})
|
| 125 |
+
if len(sources) > 1:
|
| 126 |
+
summary["by_source"] = {s: metrics([r for r in results if r["source"] == s]) for s in sources}
|
| 127 |
+
print(json.dumps(summary, indent=2))
|
| 128 |
+
|
| 129 |
+
Path(MODEL_DIR, "eval_reasoning.json").write_text(
|
| 130 |
+
json.dumps({"summary": summary, "samples": results}, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 131 |
+
)
|
| 132 |
+
print(f"[+] wrote {Path(MODEL_DIR, 'eval_reasoning.json')}")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
main()
|
code/finish_merge_push.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Merge the primary cache with every node shard, rebuild the corpus, push to the Hub.
|
| 3 |
+
#
|
| 4 |
+
# Run this after the primary generator exits. Idempotent: it re-merges from the two
|
| 5 |
+
# caches every time, so re-running after a resumed generation just produces a larger
|
| 6 |
+
# corpus. Neither node's live cache is touched — the merge lands in out_merged/.
|
| 7 |
+
#
|
| 8 |
+
# ./finish_merge_push.sh # build + card, no push (review, then re-run with push)
|
| 9 |
+
# ./finish_merge_push.sh --push # build + card + upload
|
| 10 |
+
set -euo pipefail
|
| 11 |
+
cd /notebooks/50M/reasonsing
|
| 12 |
+
P=/notebooks/50M/.venv-lfm2/bin/python
|
| 13 |
+
REPO=oddadmix/arabic-math-reasoning-synth
|
| 14 |
+
SHARD_DIR=shards_dl/shards
|
| 15 |
+
|
| 16 |
+
# 1. merge — primary cache first, then every shard pulled from the Hub
|
| 17 |
+
mkdir -p out_merged
|
| 18 |
+
cat out_synth/generations.jsonl > out_merged/generations.jsonl
|
| 19 |
+
for gz in "$SHARD_DIR"/*.jsonl.gz; do
|
| 20 |
+
echo "[*] merging shard $(basename "$gz")"
|
| 21 |
+
gunzip -c "$gz" >> out_merged/generations.jsonl
|
| 22 |
+
done
|
| 23 |
+
echo "[+] merged cache: $(wc -l < out_merged/generations.jsonl) tasks"
|
| 24 |
+
|
| 25 |
+
# 2. authoritative re-parse / re-validate / dedup over the whole merged cache
|
| 26 |
+
OUT_DIR=out_merged SFT_DIR=data_synth_sft $P build_synth_dataset.py > /tmp/build.log 2>&1
|
| 27 |
+
$P - <<'PY'
|
| 28 |
+
import json
|
| 29 |
+
d = json.load(open("out_merged/build_stats.json")); s = d["stats"]
|
| 30 |
+
print(f"[+] kept {s['kept']:,} rows | templates {d['unique_templates']:,} | "
|
| 31 |
+
f"accept {d['accept_rate']:.1%} | dup drops {s.get('dropped_duplicate_template',0):,}")
|
| 32 |
+
for k in sorted(s):
|
| 33 |
+
if k.startswith("model_"):
|
| 34 |
+
print(f" {k[6:]:36s} {s[k]:,}")
|
| 35 |
+
PY
|
| 36 |
+
|
| 37 |
+
# 3. card + upload
|
| 38 |
+
if [[ "${1:-}" == "--push" ]]; then
|
| 39 |
+
OUT_DIR=out_merged $P push_synth_dataset.py --repo "$REPO"
|
| 40 |
+
else
|
| 41 |
+
OUT_DIR=out_merged $P push_synth_dataset.py --repo "$REPO" --dry-run
|
| 42 |
+
fi
|
code/grpo_train.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GRPO (Group Relative Policy Optimization) with a verifiable reward, for the Nawah reasoning models.
|
| 3 |
+
|
| 4 |
+
Why hand-rolled: TRL is not installed and cannot be added cleanly here — .venv-lfm2 runs
|
| 5 |
+
transformers 5.15 while current TRL targets 4.x, and .venv-vllm is pinned to transformers 4.51.3
|
| 6 |
+
and must not be upgraded (see HANDOFF.md). At 51.8M parameters the algorithm is small enough that
|
| 7 |
+
a direct implementation is less risk than a third environment.
|
| 8 |
+
|
| 9 |
+
The method, briefly: sample G completions per prompt, score each with a mechanical verifier,
|
| 10 |
+
normalise the rewards WITHIN the group to get advantages, and do a policy-gradient step. No value
|
| 11 |
+
network — the group mean is the baseline, which is the whole point of GRPO.
|
| 12 |
+
|
| 13 |
+
A_i = (r_i - mean(r)) / (std(r) + eps)
|
| 14 |
+
loss = -mean_i( A_i * mean_t log pi(token_t) ) + beta * KL_k3(pi || pi_ref)
|
| 15 |
+
|
| 16 |
+
Sampling is on-policy and each batch takes exactly one gradient step, so there is no importance
|
| 17 |
+
ratio and no PPO clipping to get wrong — the ratio would be identically 1.
|
| 18 |
+
|
| 19 |
+
⚠️ The measured precondition: a group where every sample scores the same has zero advantage and
|
| 20 |
+
contributes NO gradient. On v5, 40.4% of synth problems are never solved in 8 tries, so those
|
| 21 |
+
groups are dead weight — they are skipped and counted, not silently averaged in. The exploitable
|
| 22 |
+
headroom is pass@k - pass@1, measured at +27.1 points (32.5% -> 59.6%) by passk_diag.py.
|
| 23 |
+
"""
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import sys
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
import torch.nn.functional as F
|
| 31 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 32 |
+
|
| 33 |
+
sys.path.insert(0, ".")
|
| 34 |
+
from eval_reasoning import numbers, parse
|
| 35 |
+
|
| 36 |
+
MODEL = os.environ.get("MODEL", "./Nawah-Reasoning-v5")
|
| 37 |
+
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "./Nawah-Reasoning-v6-grpo")
|
| 38 |
+
TRAIN_FILE = os.environ.get("TRAIN_FILE", "data_synth_sft/train.jsonl")
|
| 39 |
+
|
| 40 |
+
GROUP = int(os.environ.get("GROUP", 8)) # completions per prompt
|
| 41 |
+
PROMPTS_PER = int(os.environ.get("PROMPTS_PER", 8)) # prompts per optimiser step
|
| 42 |
+
STEPS = int(os.environ.get("STEPS", 500))
|
| 43 |
+
LR = float(os.environ.get("LR", 1e-6)) # RL wants far less than SFT's 3e-4
|
| 44 |
+
BETA = float(os.environ.get("BETA", 0.02)) # KL to the frozen reference
|
| 45 |
+
TEMP = float(os.environ.get("TEMP", 1.0))
|
| 46 |
+
MAX_NEW = int(os.environ.get("MAX_NEW", 320))
|
| 47 |
+
MAX_GRAD = 1.0
|
| 48 |
+
SAVE_EVERY = int(os.environ.get("SAVE_EVERY", 100))
|
| 49 |
+
LOG_EVERY = int(os.environ.get("LOG_EVERY", 5))
|
| 50 |
+
SEED = int(os.environ.get("SEED", 42))
|
| 51 |
+
FORMAT_PENALTY = float(os.environ.get("FORMAT_PENALTY", 0.1))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def reward(completion: str, ref: float) -> float:
|
| 55 |
+
"""Verifiable reward: does the stated final answer equal the reference number?
|
| 56 |
+
|
| 57 |
+
Deliberately NOT a partial-credit score. The failure this is meant to fix is a model that
|
| 58 |
+
reasons plausibly and lands on the wrong number, so rewarding anything short of the right
|
| 59 |
+
number would reinforce exactly that.
|
| 60 |
+
"""
|
| 61 |
+
_, ans, well_formed = parse(completion)
|
| 62 |
+
ns = numbers(ans or "")
|
| 63 |
+
correct = bool(ns) and ref is not None and ns[-1] == ref
|
| 64 |
+
r = 1.0 if correct else 0.0
|
| 65 |
+
if not well_formed:
|
| 66 |
+
r -= FORMAT_PENALTY
|
| 67 |
+
return r
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def completion_logprobs(model, ids, attn, prompt_len):
|
| 71 |
+
"""-> (mean log pi over completion tokens, per-token log pi, completion mask).
|
| 72 |
+
|
| 73 |
+
Prompt tokens are masked out: the prompt is not an action the policy chose, so including it
|
| 74 |
+
would add a term with no gradient meaning and would dilute the per-sequence mean.
|
| 75 |
+
"""
|
| 76 |
+
out = model(input_ids=ids, attention_mask=attn)
|
| 77 |
+
logits = out.logits[:, :-1] # position t predicts token t+1
|
| 78 |
+
targets = ids[:, 1:]
|
| 79 |
+
logp = torch.log_softmax(logits.float(), dim=-1)
|
| 80 |
+
tok_logp = logp.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
|
| 81 |
+
mask = attn[:, 1:].clone().float()
|
| 82 |
+
mask[:, : prompt_len - 1] = 0.0 # only the generated part is the "action"
|
| 83 |
+
mean_lp = (tok_logp * mask).sum(1) / mask.sum(1).clamp(min=1)
|
| 84 |
+
return mean_lp, tok_logp, mask
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def k3_kl(pol_tok, ref_tok, mask):
|
| 88 |
+
"""Schulman's k3 estimator of KL(pi || pi_ref): exp(d) - d - 1 where d = log pi_ref - log pi.
|
| 89 |
+
|
| 90 |
+
Unbiased and always non-negative, unlike the naive (log pi - log pi_ref) difference. Computed
|
| 91 |
+
per token and averaged over the completion, which is what GRPO regularises.
|
| 92 |
+
"""
|
| 93 |
+
d = (ref_tok - pol_tok).clamp(-20, 20)
|
| 94 |
+
per_tok = torch.exp(d) - d - 1.0
|
| 95 |
+
return (per_tok * mask).sum(1) / mask.sum(1).clamp(min=1)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main():
|
| 99 |
+
torch.manual_seed(SEED)
|
| 100 |
+
tok = AutoTokenizer.from_pretrained(MODEL)
|
| 101 |
+
policy = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32).cuda()
|
| 102 |
+
ref = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32).cuda().eval()
|
| 103 |
+
for p in ref.parameters():
|
| 104 |
+
p.requires_grad_(False)
|
| 105 |
+
policy.config.use_cache = True
|
| 106 |
+
|
| 107 |
+
rows = [json.loads(l) for l in open(TRAIN_FILE, encoding="utf-8")]
|
| 108 |
+
refs = []
|
| 109 |
+
for r in rows:
|
| 110 |
+
ns = numbers(r["answer"])
|
| 111 |
+
if ns:
|
| 112 |
+
refs.append((r["instruction"], ns[-1]))
|
| 113 |
+
print(f"[*] {len(refs):,} prompts with a parseable reference answer", flush=True)
|
| 114 |
+
|
| 115 |
+
opt = torch.optim.AdamW(policy.parameters(), lr=LR, weight_decay=0.0)
|
| 116 |
+
rng = torch.Generator().manual_seed(SEED)
|
| 117 |
+
Path(OUTPUT_DIR).mkdir(exist_ok=True)
|
| 118 |
+
history, dead_groups, seen = [], 0, 0
|
| 119 |
+
|
| 120 |
+
for step in range(1, STEPS + 1):
|
| 121 |
+
idx = torch.randint(0, len(refs), (PROMPTS_PER,), generator=rng).tolist()
|
| 122 |
+
batch = [refs[i] for i in idx]
|
| 123 |
+
step_rewards, step_solved, losses = [], [], []
|
| 124 |
+
opt.zero_grad(set_to_none=True)
|
| 125 |
+
used_groups = 0
|
| 126 |
+
|
| 127 |
+
for question, ref_num in batch:
|
| 128 |
+
prompt = tok.apply_chat_template([{"role": "user", "content": question}],
|
| 129 |
+
tokenize=False, add_generation_prompt=True)
|
| 130 |
+
enc = tok(prompt, return_tensors="pt").to("cuda")
|
| 131 |
+
plen = enc["input_ids"].shape[1]
|
| 132 |
+
|
| 133 |
+
policy.eval()
|
| 134 |
+
with torch.no_grad():
|
| 135 |
+
gen = policy.generate(**enc, max_new_tokens=MAX_NEW, do_sample=True,
|
| 136 |
+
temperature=TEMP, top_p=0.95,
|
| 137 |
+
num_return_sequences=GROUP,
|
| 138 |
+
pad_token_id=tok.pad_token_id)
|
| 139 |
+
policy.train()
|
| 140 |
+
|
| 141 |
+
texts = tok.batch_decode(gen[:, plen:], skip_special_tokens=True)
|
| 142 |
+
r = torch.tensor([reward(t, ref_num) for t in texts], dtype=torch.float32)
|
| 143 |
+
step_rewards.append(r.mean().item())
|
| 144 |
+
step_solved.append(float((r > 0.5).any()))
|
| 145 |
+
seen += 1
|
| 146 |
+
|
| 147 |
+
# A group with no reward spread carries no learning signal — skip it rather than
|
| 148 |
+
# letting a zero-advantage group dilute the batch.
|
| 149 |
+
if r.std() < 1e-6:
|
| 150 |
+
dead_groups += 1
|
| 151 |
+
continue
|
| 152 |
+
adv = ((r - r.mean()) / (r.std() + 1e-6)).cuda()
|
| 153 |
+
|
| 154 |
+
attn = (gen != tok.pad_token_id).long()
|
| 155 |
+
attn[:, :plen] = 1
|
| 156 |
+
mean_lp, pol_tok, mask = completion_logprobs(policy, gen, attn, plen)
|
| 157 |
+
with torch.no_grad():
|
| 158 |
+
_, ref_tok, _ = completion_logprobs(ref, gen, attn, plen)
|
| 159 |
+
|
| 160 |
+
pg = -(adv * mean_lp).mean()
|
| 161 |
+
kl = k3_kl(pol_tok, ref_tok, mask).mean()
|
| 162 |
+
loss = (pg + BETA * kl) / PROMPTS_PER
|
| 163 |
+
loss.backward()
|
| 164 |
+
losses.append(pg.item())
|
| 165 |
+
used_groups += 1
|
| 166 |
+
|
| 167 |
+
if used_groups:
|
| 168 |
+
torch.nn.utils.clip_grad_norm_(policy.parameters(), MAX_GRAD)
|
| 169 |
+
opt.step()
|
| 170 |
+
|
| 171 |
+
history.append({"step": step, "mean_reward": sum(step_rewards) / len(step_rewards),
|
| 172 |
+
"any_solved": sum(step_solved) / len(step_solved),
|
| 173 |
+
"pg_loss": (sum(losses) / len(losses)) if losses else None,
|
| 174 |
+
"live_groups": used_groups})
|
| 175 |
+
if step % LOG_EVERY == 0:
|
| 176 |
+
h = history[-1]
|
| 177 |
+
print(f"[{step:>4}/{STEPS}] reward {h['mean_reward']:.3f} "
|
| 178 |
+
f"pass@{GROUP} {h['any_solved']:.2f} live {used_groups}/{PROMPTS_PER} "
|
| 179 |
+
f"dead so far {100*dead_groups/max(seen,1):.0f}%", flush=True)
|
| 180 |
+
|
| 181 |
+
if step % SAVE_EVERY == 0 or step == STEPS:
|
| 182 |
+
policy.config.use_cache = True
|
| 183 |
+
policy.save_pretrained(OUTPUT_DIR)
|
| 184 |
+
tok.save_pretrained(OUTPUT_DIR)
|
| 185 |
+
Path(OUTPUT_DIR, "grpo_history.json").write_text(
|
| 186 |
+
json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 187 |
+
print(f" [+] saved at step {step}", flush=True)
|
| 188 |
+
|
| 189 |
+
print(f"[+] done -> {OUTPUT_DIR} ({100*dead_groups/max(seen,1):.1f}% of groups had no signal)")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
main()
|
code/gsm_common.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared parsing/masking helpers for the GSM8K reasoning dataset translation."""
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
RECORD_RE = re.compile(
|
| 5 |
+
r"<question>(.*?)</question>\s*<thinking>(.*?)</thinking>\s*<answer>(.*?)</answer>", re.S
|
| 6 |
+
)
|
| 7 |
+
NUM_RE = re.compile(r"\d+(?:\.\d+)?")
|
| 8 |
+
PH_RE = re.compile(r"#(\d+)#")
|
| 9 |
+
|
| 10 |
+
SRC_PROMPT = "Translate the following English sentence into Arabic:\n{text} <ar>"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse(text):
|
| 14 |
+
m = RECORD_RE.match(text.strip())
|
| 15 |
+
if not m:
|
| 16 |
+
return None
|
| 17 |
+
return m.group(1).strip(), m.group(2).strip(), m.group(3).strip()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def mask_numbers(text):
|
| 21 |
+
"""'168 + 19 = 187' -> ('#0# + #1# = #2#', ['168', '19', '187'])"""
|
| 22 |
+
nums = []
|
| 23 |
+
|
| 24 |
+
def repl(m):
|
| 25 |
+
nums.append(m.group(0))
|
| 26 |
+
return f"#{len(nums) - 1}#"
|
| 27 |
+
|
| 28 |
+
return NUM_RE.sub(repl, text), nums
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def unmask_numbers(text, nums):
|
| 32 |
+
"""Restore. Returns (text, ok) — ok is False if any placeholder was lost or duplicated."""
|
| 33 |
+
seen = []
|
| 34 |
+
|
| 35 |
+
def repl(m):
|
| 36 |
+
i = int(m.group(1))
|
| 37 |
+
seen.append(i)
|
| 38 |
+
return nums[i] if i < len(nums) else m.group(0)
|
| 39 |
+
|
| 40 |
+
out = PH_RE.sub(repl, text)
|
| 41 |
+
return out, sorted(seen) == list(range(len(nums)))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_record(question, thinking, answer):
|
| 45 |
+
return f"<question>{question}</question> <thinking>{thinking}</thinking> <answer>{answer}</answer>"
|
code/passk_diag.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pass@1 vs pass@k on the synthetic held-out set, broken down by step count.
|
| 3 |
+
|
| 4 |
+
This is the go/no-go diagnostic for RL with verifiable rewards. RLVR (GRPO/RLOO) reweights samples
|
| 5 |
+
the model ALREADY produces: if a problem is never solved in k tries, every sample in the group gets
|
| 6 |
+
reward 0, the advantage is 0, and there is no gradient. So the headroom RL can capture is bounded
|
| 7 |
+
by (pass@k - pass@1), and only on problems where pass@k > 0.
|
| 8 |
+
"""
|
| 9 |
+
import json, os, sys, collections
|
| 10 |
+
import torch, pyarrow.parquet as pq
|
| 11 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 12 |
+
sys.path.insert(0, ".")
|
| 13 |
+
from eval_reasoning import numbers, parse
|
| 14 |
+
|
| 15 |
+
MODEL = sys.argv[1] if len(sys.argv) > 1 else "./Nawah-Reasoning-v5"
|
| 16 |
+
PER_BUCKET = int(os.environ.get("PER_BUCKET", 60))
|
| 17 |
+
K = int(os.environ.get("K", 8))
|
| 18 |
+
TEMP = float(os.environ.get("TEMP", 1.0))
|
| 19 |
+
|
| 20 |
+
tok = AutoTokenizer.from_pretrained(MODEL)
|
| 21 |
+
model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.bfloat16).cuda().eval()
|
| 22 |
+
|
| 23 |
+
rows = pq.read_table("out_merged/arabic_math_reasoning_synth.parquet").to_pylist()[:2000]
|
| 24 |
+
buckets = collections.defaultdict(list)
|
| 25 |
+
for r in rows:
|
| 26 |
+
buckets[r["axis_steps"]].append(r)
|
| 27 |
+
sample = [r for b in buckets.values() for r in b[:PER_BUCKET]]
|
| 28 |
+
print(f"[*] {len(sample)} problems x k={K} @ T={TEMP} -> {len(sample)*K} generations", flush=True)
|
| 29 |
+
|
| 30 |
+
def ref_number(r):
|
| 31 |
+
ns = numbers(r["answer"])
|
| 32 |
+
return ns[-1] if ns else None
|
| 33 |
+
|
| 34 |
+
stats = collections.defaultdict(lambda: {"n": 0, "p1": 0, "pk": 0})
|
| 35 |
+
BATCH = 16
|
| 36 |
+
for start in range(0, len(sample), BATCH):
|
| 37 |
+
chunk = sample[start:start + BATCH]
|
| 38 |
+
prompts = [tok.apply_chat_template([{"role": "user", "content": r["instruction"]}],
|
| 39 |
+
tokenize=False, add_generation_prompt=True) for r in chunk]
|
| 40 |
+
enc = tok(prompts, return_tensors="pt", padding=True, padding_side="left").to("cuda")
|
| 41 |
+
torch.manual_seed(1234 + start)
|
| 42 |
+
out = model.generate(**enc, max_new_tokens=320, do_sample=True, temperature=TEMP,
|
| 43 |
+
top_p=0.95, num_return_sequences=K)
|
| 44 |
+
gen = tok.batch_decode(out[:, enc["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 45 |
+
for i, r in enumerate(chunk):
|
| 46 |
+
ref = ref_number(r)
|
| 47 |
+
hits = []
|
| 48 |
+
for j in range(K):
|
| 49 |
+
_, ans, _ = parse(gen[i * K + j])
|
| 50 |
+
ns = numbers(ans or "")
|
| 51 |
+
hits.append(bool(ns) and ref is not None and ns[-1] == ref)
|
| 52 |
+
s = stats[r["axis_steps"]]
|
| 53 |
+
s["n"] += 1
|
| 54 |
+
s["p1"] += hits[0]
|
| 55 |
+
s["pk"] += any(hits)
|
| 56 |
+
print(f" {start + len(chunk)}/{len(sample)}", flush=True)
|
| 57 |
+
|
| 58 |
+
print("\n| steps | n | pass@1 | pass@%d | headroom |" % K)
|
| 59 |
+
print("|---|---:|---:|---:|---:|")
|
| 60 |
+
tot = {"n": 0, "p1": 0, "pk": 0}
|
| 61 |
+
for k, s in sorted(stats.items(), key=lambda kv: kv[1]["n"], reverse=True):
|
| 62 |
+
for f in tot: tot[f] += s[f]
|
| 63 |
+
print(f"| {k} | {s['n']} | {100*s['p1']/s['n']:.1f}% | {100*s['pk']/s['n']:.1f}% | "
|
| 64 |
+
f"{100*(s['pk']-s['p1'])/s['n']:+.1f} |")
|
| 65 |
+
print(f"| **all** | {tot['n']} | {100*tot['p1']/tot['n']:.1f}% | {100*tot['pk']/tot['n']:.1f}% | "
|
| 66 |
+
f"{100*(tot['pk']-tot['p1'])/tot['n']:+.1f} |")
|
| 67 |
+
print(f"\nnever-solved (pass@{K}=0): {100*(tot['n']-tot['pk'])/tot['n']:.1f}% of problems "
|
| 68 |
+
f"-> zero RL gradient on these")
|
code/prepare_data.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prepare Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset for reasoning SFT.
|
| 3 |
+
|
| 4 |
+
Each row (instruction, answer) becomes a ChatML sample where the derivation lives
|
| 5 |
+
inside <think>...</think> and the conclusion follows it:
|
| 6 |
+
|
| 7 |
+
<|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n<think>\n{reasoning}\n</think>\n{answer}<|im_end|>
|
| 8 |
+
|
| 9 |
+
Only rows whose derivation ends in an explicit conclusion line ("إذن، ...") are kept.
|
| 10 |
+
Expository rows without one have no real "final answer" to place after </think> — their
|
| 11 |
+
closing paragraph is a side remark, so training on them would teach the model to think and
|
| 12 |
+
then trail off. They are dropped rather than force-split.
|
| 13 |
+
|
| 14 |
+
Writes data/train.jsonl and data/eval.jsonl.
|
| 15 |
+
"""
|
| 16 |
+
import json, re, random, unicodedata, collections
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pyarrow.parquet as pq
|
| 20 |
+
|
| 21 |
+
SRC = Path("data/data/train-00000-of-00001.parquet")
|
| 22 |
+
OUT_DIR = Path("data")
|
| 23 |
+
EVAL_N = 400
|
| 24 |
+
SEED = 42
|
| 25 |
+
|
| 26 |
+
# Lines that mark the final conclusion of a derivation.
|
| 27 |
+
CONCLUSION = ["إذن،", "إذن ", "لذا،", "لذلك،", "باختصار،", "وبالتالي،", "في النهاية،",
|
| 28 |
+
"الخلاصة", "النتيجة النهائية", "النتيجة:", "الإجابة", "الجواب", "بالتالي،"]
|
| 29 |
+
# Chatty sign-offs that are not part of the answer.
|
| 30 |
+
FLUFF = ["تذكر", "آمل", "أتمنى", "يرجى", "لا تتردد", "إذا كان لديك أي", "هل لديك",
|
| 31 |
+
"أرجو", "نصيحة:", "ملاحظة:", "إذا كانت لديك"]
|
| 32 |
+
# Prompt suffix present on a subset of instructions; stripped so reasoning is unconditional.
|
| 33 |
+
SUFFIX_RE = re.compile(r"\s*خذ\s+نفسًا?\s+عميقًا.*$", re.S)
|
| 34 |
+
|
| 35 |
+
MIN_THINK_CHARS = 60
|
| 36 |
+
MIN_ANSWER_CHARS = 10
|
| 37 |
+
|
| 38 |
+
MD_NOISE_RE = re.compile(r"\*\*|__|#{2,}")
|
| 39 |
+
# Some source rows bundle several problems; the conclusion of one is followed by the next
|
| 40 |
+
# problem's header. Anything from that header on is not part of the answer.
|
| 41 |
+
NEXT_PROBLEM_RE = re.compile(r"^\s*(المشكلة|المسألة|السؤال|التمرين|مثال|Problem|Question|Example)\b")
|
| 42 |
+
ANSWER_ARTIFACT_RE = re.compile(r"^\s*(\*\*)?Answer:\s*", re.I)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def norm(text: str) -> str:
|
| 46 |
+
text = unicodedata.normalize("NFC", text.replace("", "").replace("", ""))
|
| 47 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 48 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 49 |
+
return text.strip()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def clean_instruction(text: str) -> str:
|
| 53 |
+
return norm(SUFFIX_RE.sub("", norm(text))).rstrip(". ").strip() or norm(text)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def is_fluff(line: str) -> bool:
|
| 57 |
+
head = line.lstrip("*#- ").strip()
|
| 58 |
+
return any(head.startswith(f) for f in FLUFF)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def is_conclusion(line: str) -> bool:
|
| 62 |
+
head = line.lstrip("*#- ").strip()
|
| 63 |
+
return any(head.startswith(m) for m in CONCLUSION)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def strip_markup(line: str) -> str:
|
| 67 |
+
"""Drop markdown emphasis and the stray "**Answer:" prefix some rows carry."""
|
| 68 |
+
return norm(MD_NOISE_RE.sub("", ANSWER_ARTIFACT_RE.sub("", line)))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def split_answer(answer: str, instruction: str):
|
| 72 |
+
"""-> (reasoning, final_answer) or None when the row can't be split cleanly."""
|
| 73 |
+
lines = [strip_markup(l) for l in norm(answer).split("\n")]
|
| 74 |
+
lines = [l for l in lines if l]
|
| 75 |
+
if len(lines) < 2:
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
# Drop trailing chatter first — it belongs to neither part.
|
| 79 |
+
while lines and is_fluff(lines[-1]):
|
| 80 |
+
lines.pop()
|
| 81 |
+
if len(lines) < 2:
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
# A leading restatement of the question adds nothing to the derivation.
|
| 85 |
+
if lines and lines[0][:40] == instruction.strip()[:40]:
|
| 86 |
+
lines.pop(0)
|
| 87 |
+
|
| 88 |
+
idx = next((i for i in range(len(lines) - 1, 0, -1) if is_conclusion(lines[i])), None)
|
| 89 |
+
if idx is None:
|
| 90 |
+
return None
|
| 91 |
+
reasoning = "\n".join(lines[:idx])
|
| 92 |
+
tail = lines[idx:]
|
| 93 |
+
cut = next((i for i in range(1, len(tail)) if NEXT_PROBLEM_RE.match(tail[i])), len(tail))
|
| 94 |
+
final = "\n".join(tail[:cut])
|
| 95 |
+
|
| 96 |
+
if len(reasoning) < MIN_THINK_CHARS or len(final) < MIN_ANSWER_CHARS:
|
| 97 |
+
return None
|
| 98 |
+
# A "conclusion" longer than the derivation means the split went the wrong way.
|
| 99 |
+
if len(final) > len(reasoning):
|
| 100 |
+
return None
|
| 101 |
+
return reasoning, final
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def main():
|
| 105 |
+
table = pq.read_table(SRC).to_pydict()
|
| 106 |
+
rows = list(zip(table["instruction"], table["answer"]))
|
| 107 |
+
stats = collections.Counter(total=len(rows))
|
| 108 |
+
|
| 109 |
+
seen, samples = set(), []
|
| 110 |
+
for raw_ins, raw_ans in rows:
|
| 111 |
+
ins = clean_instruction(raw_ins)
|
| 112 |
+
key = re.sub(r"\W+", "", ins)
|
| 113 |
+
if key in seen:
|
| 114 |
+
stats["dropped_duplicate"] += 1
|
| 115 |
+
continue
|
| 116 |
+
seen.add(key)
|
| 117 |
+
|
| 118 |
+
split = split_answer(raw_ans, ins)
|
| 119 |
+
if split is None:
|
| 120 |
+
stats["dropped_no_conclusion"] += 1
|
| 121 |
+
continue
|
| 122 |
+
reasoning, final = split
|
| 123 |
+
stats["kept"] += 1
|
| 124 |
+
samples.append({"instruction": ins, "reasoning": reasoning, "answer": final})
|
| 125 |
+
|
| 126 |
+
random.Random(SEED).shuffle(samples)
|
| 127 |
+
eval_set, train_set = samples[:EVAL_N], samples[EVAL_N:]
|
| 128 |
+
|
| 129 |
+
OUT_DIR.mkdir(exist_ok=True)
|
| 130 |
+
for name, split in (("train", train_set), ("eval", eval_set)):
|
| 131 |
+
with open(OUT_DIR / f"{name}.jsonl", "w", encoding="utf-8") as fh:
|
| 132 |
+
for s in split:
|
| 133 |
+
fh.write(json.dumps(s, ensure_ascii=False) + "\n")
|
| 134 |
+
print(f"[+] {name}: {len(split)} samples -> {OUT_DIR / f'{name}.jsonl'}")
|
| 135 |
+
|
| 136 |
+
print("[*] stats:", dict(stats))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
main()
|
code/prepare_gsm_sft.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Convert the Arabic GSM8K dataset into the SFT schema train_reasoning.py already reads
|
| 3 |
+
({instruction, reasoning, answer}), so v2 trains in the same ChatML + <think> format as v1.
|
| 4 |
+
|
| 5 |
+
The final answer is kept as the bare numeral: it matches the source's intent and makes
|
| 6 |
+
exact-match evaluation trivial.
|
| 7 |
+
"""
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import pyarrow.parquet as pq
|
| 13 |
+
|
| 14 |
+
SRC = Path("out_gsm/gsm8k_reasoning_ar.parquet")
|
| 15 |
+
OUT = Path("data_gsm_sft")
|
| 16 |
+
EVAL_N = 2000
|
| 17 |
+
SEED = 42
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def main():
|
| 21 |
+
d = pq.read_table(SRC).to_pydict()
|
| 22 |
+
rows = [
|
| 23 |
+
{"instruction": q, "reasoning": t, "answer": a}
|
| 24 |
+
for q, t, a in zip(d["question"], d["thinking"], d["answer"])
|
| 25 |
+
]
|
| 26 |
+
random.Random(SEED).shuffle(rows)
|
| 27 |
+
eval_rows, train_rows = rows[:EVAL_N], rows[EVAL_N:]
|
| 28 |
+
|
| 29 |
+
OUT.mkdir(exist_ok=True)
|
| 30 |
+
for name, split in (("train", train_rows), ("eval", eval_rows)):
|
| 31 |
+
with open(OUT / f"{name}.jsonl", "w", encoding="utf-8") as fh:
|
| 32 |
+
for r in split:
|
| 33 |
+
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 34 |
+
print(f"[+] {name}: {len(split)} -> {OUT / f'{name}.jsonl'}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
main()
|
code/prepare_v6_sft.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build the v6 SFT corpus: the v5 three-way mix, with the synthetic corpus swapped for the larger
|
| 3 |
+
one that carries the relational pool.
|
| 4 |
+
|
| 5 |
+
This is `prepare_v5_sft.py` with `SYNTH_DIR` repointed and a fourth eval column added. Separate
|
| 6 |
+
file, same reason v5 was separate from v3: v5 is the shipped model and must stay reproducible.
|
| 7 |
+
|
| 8 |
+
The synth split comes from `split_synth_v6.py`, not `build_synth_dataset.py` — the v6 corpus is
|
| 9 |
+
20,139 rows larger, so a re-drawn shuffle would put 1,955 of v5's held-out rows into v6's train.
|
| 10 |
+
The eval rows are pinned to v5's instead, which keeps the synth cell comparable and, because
|
| 11 |
+
`eval.jsonl` is byte-identical to v5's, makes the mix-eval synth column the *same 400 rows* v5 was
|
| 12 |
+
scored on.
|
| 13 |
+
|
| 14 |
+
`eval_rel.jsonl` (400 relational rows, held out of train) is the column that actually measures
|
| 15 |
+
what v6 was built for — none of the legacy eval rows test a relational comparison.
|
| 16 |
+
|
| 17 |
+
data/{train,eval}.jsonl <- prepare_data.py (Arabic_Reasoning_Dataset)
|
| 18 |
+
data_gsm_sft/{train,eval}.jsonl <- prepare_gsm_sft.py (oddadmix/gsm8k-reasoning-ar)
|
| 19 |
+
data_synth_v6_sft/{train,eval,eval_rel}.jsonl <- split_synth_v6.py (120,462-row corpus)
|
| 20 |
+
|
| 21 |
+
Why all three: v3 scores 77.3% on GSM8K-ar but 2.0% on the synth held-out set, and v4 (synth only)
|
| 22 |
+
inverts that — 35.6% synth, 19.5% GSM. Neither corpus alone covers the other's distribution, so v5
|
| 23 |
+
trains on all three at once.
|
| 24 |
+
|
| 25 |
+
Answer styles are NOT normalised — GSM8K ends in a bare numeral, Arabic_Reasoning and the synth
|
| 26 |
+
corpus in an "إذن، …" sentence. A `source` tag rides on every row so eval_reasoning.py scores each
|
| 27 |
+
on its own terms. The eval set keeps v1's 400 AR rows and v2's first 600 GSM rows *unchanged*, so
|
| 28 |
+
the AR and GSM cells stay directly comparable to v1/v2/v3.
|
| 29 |
+
|
| 30 |
+
Writes data_v6_sft/{train,eval}.jsonl.
|
| 31 |
+
"""
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import random
|
| 35 |
+
from collections import Counter
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
AR_DIR = Path("data") # v1 splits
|
| 39 |
+
GSM_DIR = Path("data_gsm_sft") # v2 splits
|
| 40 |
+
SYNTH_DIR = Path("data_synth_v6_sft") # the 120,462-row corpus, relational pool included
|
| 41 |
+
OUT = Path("data_v6_sft")
|
| 42 |
+
|
| 43 |
+
# Arabic_Reasoning is ~25x smaller than GSM8K, so it is repeated (same value v3 used).
|
| 44 |
+
REPEAT = int(os.environ.get("REPEAT", 3))
|
| 45 |
+
SYNTH_REPEAT = int(os.environ.get("SYNTH_REPEAT", 1))
|
| 46 |
+
# Eval: v1's 400 AR rows are all kept and v2's first 600 GSM rows, exactly as v3 built them, plus
|
| 47 |
+
# synth rows so all three distributions are scored in one pass.
|
| 48 |
+
EVAL_GSM = int(os.environ.get("EVAL_GSM", 600))
|
| 49 |
+
EVAL_SYNTH = int(os.environ.get("EVAL_SYNTH", 400))
|
| 50 |
+
EVAL_REL = int(os.environ.get("EVAL_REL", 400))
|
| 51 |
+
SEED = 42
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load(path):
|
| 55 |
+
with open(path, encoding="utf-8") as fh:
|
| 56 |
+
return [json.loads(line) for line in fh]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def tag(rows, source):
|
| 60 |
+
return [{**r, "source": source} for r in rows]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def token_stats(rows, tok):
|
| 64 |
+
per_source, lengths = Counter(), []
|
| 65 |
+
for r in rows:
|
| 66 |
+
text = (f"<|im_start|>user\n{r['instruction']}<|im_end|>\n<|im_start|>assistant\n"
|
| 67 |
+
f"<think>\n{r['reasoning']}\n</think>\n{r['answer']}<|im_end|>")
|
| 68 |
+
n = len(tok.encode(text, add_special_tokens=False))
|
| 69 |
+
per_source[r["source"]] += n
|
| 70 |
+
lengths.append(n)
|
| 71 |
+
lengths.sort()
|
| 72 |
+
pct = lambda p: lengths[min(int(len(lengths) * p / 100), len(lengths) - 1)]
|
| 73 |
+
return per_source, {"p50": pct(50), "p95": pct(95), "p99": pct(99), "max": lengths[-1]}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def interleave(groups):
|
| 77 |
+
"""Round-robin proportional to each group's size, so a truncated eval run still covers all
|
| 78 |
+
three sources instead of whichever landed first."""
|
| 79 |
+
groups = [g for g in groups if g]
|
| 80 |
+
if not groups:
|
| 81 |
+
return []
|
| 82 |
+
total = sum(len(g) for g in groups)
|
| 83 |
+
out, idx = [], [0] * len(groups)
|
| 84 |
+
for _ in range(total):
|
| 85 |
+
# pick the group that is furthest behind its target share
|
| 86 |
+
pick = min(range(len(groups)), key=lambda i: (idx[i] / len(groups[i])) if idx[i] < len(groups[i]) else 2.0)
|
| 87 |
+
if idx[pick] >= len(groups[pick]):
|
| 88 |
+
break
|
| 89 |
+
out.append(groups[pick][idx[pick]])
|
| 90 |
+
idx[pick] += 1
|
| 91 |
+
for g, i in zip(groups, idx): # anything the loop could not place
|
| 92 |
+
out.extend(g[i:])
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def main():
|
| 97 |
+
for d in (AR_DIR, GSM_DIR, SYNTH_DIR):
|
| 98 |
+
for split in ("train", "eval"):
|
| 99 |
+
if not (d / f"{split}.jsonl").exists():
|
| 100 |
+
raise SystemExit(f"missing {d/f'{split}.jsonl'}")
|
| 101 |
+
|
| 102 |
+
ar_train = tag(load(AR_DIR / "train.jsonl"), "arabic_reasoning")
|
| 103 |
+
ar_eval = tag(load(AR_DIR / "eval.jsonl"), "arabic_reasoning")
|
| 104 |
+
gsm_train = tag(load(GSM_DIR / "train.jsonl"), "gsm8k_ar")
|
| 105 |
+
gsm_eval = tag(load(GSM_DIR / "eval.jsonl"), "gsm8k_ar")
|
| 106 |
+
synth_train = tag(load(SYNTH_DIR / "train.jsonl"), "synth_math_ar")
|
| 107 |
+
synth_eval = tag(load(SYNTH_DIR / "eval.jsonl"), "synth_math_ar")
|
| 108 |
+
rel_eval = tag(load(SYNTH_DIR / "eval_rel.jsonl"), "synth_relational_ar")
|
| 109 |
+
|
| 110 |
+
train = gsm_train + ar_train * REPEAT + synth_train * SYNTH_REPEAT
|
| 111 |
+
random.Random(SEED).shuffle(train)
|
| 112 |
+
|
| 113 |
+
eval_rows = interleave([ar_eval, gsm_eval[:EVAL_GSM], synth_eval[:EVAL_SYNTH],
|
| 114 |
+
rel_eval[:EVAL_REL]])
|
| 115 |
+
|
| 116 |
+
OUT.mkdir(exist_ok=True)
|
| 117 |
+
for name, split in (("train", train), ("eval", eval_rows)):
|
| 118 |
+
with open(OUT / f"{name}.jsonl", "w", encoding="utf-8") as fh:
|
| 119 |
+
for r in split:
|
| 120 |
+
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 121 |
+
print(f"[+] {name}: {len(split):,} -> {OUT / f'{name}.jsonl'} "
|
| 122 |
+
f"{dict(Counter(r['source'] for r in split))}")
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
from transformers import AutoTokenizer
|
| 126 |
+
tok = AutoTokenizer.from_pretrained(os.environ.get("BASE_MODEL", "/notebooks/50M/50M-2048-Emhotob"))
|
| 127 |
+
tok.add_special_tokens({"additional_special_tokens": ["<|im_start|>", "<|im_end|>", "<think>", "</think>"]})
|
| 128 |
+
per_source, pct = token_stats(train, tok)
|
| 129 |
+
total = sum(per_source.values())
|
| 130 |
+
print(f"[*] train tokens: {total/1e6:.1f}M/epoch (REPEAT={REPEAT}, SYNTH_REPEAT={SYNTH_REPEAT})")
|
| 131 |
+
for src, n in per_source.most_common():
|
| 132 |
+
print(f" {src:<18} {n/1e6:6.2f}M {100*n/total:5.1f}%")
|
| 133 |
+
print(f"[*] sample length: p50 {pct['p50']} p95 {pct['p95']} p99 {pct['p99']} max {pct['max']}")
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
print(f"[!] token stats skipped: {exc}")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
main()
|
code/push_dataset.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Push the Arabic GSM8K reasoning dataset to the Hub as a PRIVATE dataset repo.
|
| 3 |
+
|
| 4 |
+
Usage: python push_dataset.py [--repo oddadmix/gsm8k-reasoning-ar] [--dry-run]
|
| 5 |
+
"""
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import pyarrow.parquet as pq
|
| 11 |
+
from huggingface_hub import HfApi
|
| 12 |
+
|
| 13 |
+
OUT = Path("out_gsm")
|
| 14 |
+
PARQUET = OUT / "gsm8k_reasoning_ar.parquet"
|
| 15 |
+
SOURCE = "Ajhesh7/gsm8k-reasoning-SFT-datas"
|
| 16 |
+
MT_MODEL = "ByteDance-Seed/Seed-X-PPO-7B"
|
| 17 |
+
|
| 18 |
+
CARD = """---
|
| 19 |
+
license: apache-2.0
|
| 20 |
+
language:
|
| 21 |
+
- ar
|
| 22 |
+
- en
|
| 23 |
+
task_categories:
|
| 24 |
+
- text-generation
|
| 25 |
+
tags:
|
| 26 |
+
- arabic
|
| 27 |
+
- reasoning
|
| 28 |
+
- chain-of-thought
|
| 29 |
+
- math
|
| 30 |
+
- gsm8k
|
| 31 |
+
- machine-translated
|
| 32 |
+
size_categories:
|
| 33 |
+
- 100K<n<1M
|
| 34 |
+
dataset_info:
|
| 35 |
+
features:
|
| 36 |
+
- name: text
|
| 37 |
+
dtype: string
|
| 38 |
+
- name: question
|
| 39 |
+
dtype: string
|
| 40 |
+
- name: thinking
|
| 41 |
+
dtype: string
|
| 42 |
+
- name: answer
|
| 43 |
+
dtype: string
|
| 44 |
+
- name: question_en
|
| 45 |
+
dtype: string
|
| 46 |
+
- name: thinking_en
|
| 47 |
+
dtype: string
|
| 48 |
+
- name: source_index
|
| 49 |
+
dtype: int64
|
| 50 |
+
splits:
|
| 51 |
+
- name: train
|
| 52 |
+
num_examples: {rows}
|
| 53 |
+
configs:
|
| 54 |
+
- config_name: default
|
| 55 |
+
data_files:
|
| 56 |
+
- split: train
|
| 57 |
+
path: data/train-*
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
# GSM8K Reasoning — Arabic (مترجم آليًا)
|
| 61 |
+
|
| 62 |
+
**{rows:,}** grade-school math reasoning items translated from English into Arabic with
|
| 63 |
+
[`{mt}`](https://huggingface.co/{mt}), a 7B translation model.
|
| 64 |
+
|
| 65 |
+
Source: [`{source}`](https://huggingface.co/datasets/{source}) (600,000 rows).
|
| 66 |
+
|
| 67 |
+
> **بالعربية:** مجموعة بيانات للاستدلال الرياضي بالعربية، مترجمة آليًا من الإنجليزية.
|
| 68 |
+
> كل مثال يحتوي على سؤال، وخطوات التفكير، والإجابة النهائية.
|
| 69 |
+
|
| 70 |
+
## Format
|
| 71 |
+
|
| 72 |
+
`text` keeps the source's tag layout, with Arabic content:
|
| 73 |
+
|
| 74 |
+
```
|
| 75 |
+
<question>يجمع فريا 168 صندوقًا وجمعت هانا 19 صندوقًا...</question> <thinking>دعونا نفكر خطوة بخطوة...</thinking> <answer>187</answer>
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
The parts are also available as separate columns — `question`, `thinking`, `answer` (Arabic;
|
| 79 |
+
`answer` is the untouched numeral) — with `question_en` / `thinking_en` carrying the English
|
| 80 |
+
source so every row is auditable, and `source_index` pointing back into the source dataset.
|
| 81 |
+
|
| 82 |
+
## How it was built
|
| 83 |
+
|
| 84 |
+
1. **Sampling.** {selected:,} of the 600,000 source rows. The corpus is generated from only
|
| 85 |
+
**2,814** underlying question patterns (numbers and names masked), so the sample is stratified
|
| 86 |
+
by pattern with a floor of {floor} rows per pattern — every pattern is represented rather than
|
| 87 |
+
over-weighting the common ones.
|
| 88 |
+
2. **Translation.** Question and reasoning translated separately, each as its own sentence, with
|
| 89 |
+
`Translate the following English sentence into Arabic:\\n{{text}} <ar>` and greedy decoding.
|
| 90 |
+
Numbers and names were left in place rather than masked, so Arabic gender agreement follows the
|
| 91 |
+
actual name (`اشترت` for Aisha) and number agreement follows the actual quantity. The final
|
| 92 |
+
`answer` numeral is never sent to the translator.
|
| 93 |
+
3. **Validation.** A row is kept only if, for **both** segments, the numbers in the Arabic exactly
|
| 94 |
+
match the English (order-insensitive), the output is non-empty Arabic script, has no degenerate
|
| 95 |
+
repetition loop, and has no significant Latin-script residue. **{kept_pct:.2f}%** of translated
|
| 96 |
+
rows passed.
|
| 97 |
+
|
| 98 |
+
Rejection breakdown: `{rejects}`
|
| 99 |
+
|
| 100 |
+
## Limitations
|
| 101 |
+
|
| 102 |
+
This is **machine translation**, not human-verified Arabic. It inherits the source's synthetic,
|
| 103 |
+
templated phrasing — {selected:,} rows expand from 2,814 patterns, so linguistic diversity is far
|
| 104 |
+
lower than the row count suggests.
|
| 105 |
+
|
| 106 |
+
**Gender agreement.** The English source pairs names with pronouns arbitrarily ("This week Emil
|
| 107 |
+
did chores and earned $76. **She** bought a bottle…"), which English mostly hides but Arabic does
|
| 108 |
+
not: a row can read `قام جورج …` and then `اشترت …` for the same person. The translator rendered
|
| 109 |
+
the source faithfully; the disagreement is upstream, and it is visible throughout. The arithmetic itself is copied from the source and was not
|
| 110 |
+
re-verified; in the source, the reasoning's final number agrees with the `answer` field ~96.6% of
|
| 111 |
+
the time, so a small fraction of items are internally inconsistent. Suitable for SFT on reasoning
|
| 112 |
+
*format* and basic Arabic math phrasing; not a benchmark.
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def main():
|
| 117 |
+
ap = argparse.ArgumentParser()
|
| 118 |
+
ap.add_argument("--repo", default="oddadmix/gsm8k-reasoning-ar")
|
| 119 |
+
ap.add_argument("--dry-run", action="store_true")
|
| 120 |
+
args = ap.parse_args()
|
| 121 |
+
|
| 122 |
+
stats = json.loads((OUT / "build_stats.json").read_text(encoding="utf-8"))
|
| 123 |
+
rows = pq.ParquetFile(PARQUET).metadata.num_rows
|
| 124 |
+
floor = max(1, stats["selected"] // (2814 * 4))
|
| 125 |
+
|
| 126 |
+
card = CARD.format(
|
| 127 |
+
rows=rows, mt=MT_MODEL, source=SOURCE, selected=stats["selected"],
|
| 128 |
+
kept_pct=stats["kept_pct"], rejects=stats["rejects"], floor=floor,
|
| 129 |
+
)
|
| 130 |
+
(OUT / "README.md").write_text(card, encoding="utf-8")
|
| 131 |
+
print(f"[+] wrote card ({len(card)} chars), {rows} rows")
|
| 132 |
+
|
| 133 |
+
if args.dry_run:
|
| 134 |
+
print("[dry-run] not pushing")
|
| 135 |
+
return
|
| 136 |
+
|
| 137 |
+
api = HfApi()
|
| 138 |
+
api.create_repo(args.repo, repo_type="dataset", private=True, exist_ok=True)
|
| 139 |
+
api.upload_file(path_or_fileobj=str(PARQUET), path_in_repo="data/train-00000-of-00001.parquet",
|
| 140 |
+
repo_id=args.repo, repo_type="dataset")
|
| 141 |
+
api.upload_file(path_or_fileobj=str(OUT / "README.md"), path_in_repo="README.md",
|
| 142 |
+
repo_id=args.repo, repo_type="dataset")
|
| 143 |
+
for script in ("gsm_common.py", "translate_gsm.py", "build_dataset.py"):
|
| 144 |
+
api.upload_file(path_or_fileobj=script, path_in_repo=f"scripts/{script}",
|
| 145 |
+
repo_id=args.repo, repo_type="dataset")
|
| 146 |
+
print(f"[+] https://huggingface.co/datasets/{args.repo}")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
main()
|
code/push_release.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Public release of the v6 weights as **oddadmix/Nawah-Math-Reasoning**.
|
| 3 |
+
|
| 4 |
+
This is `push_model_v6.py` retargeted at a public repo under the release name. The differences
|
| 5 |
+
that matter:
|
| 6 |
+
|
| 7 |
+
* `private=False` — this is the release, unlike every rung of the v1→v6 ladder.
|
| 8 |
+
* The card is written for someone who has never seen the ladder. v1–v5 stay private, so the
|
| 9 |
+
comparison table names them but does not link them, and the text does not send readers to
|
| 10 |
+
repos they cannot open.
|
| 11 |
+
* The training code ships **inside the repo** under `code/`, so the recipe and the weights are
|
| 12 |
+
one link. `code/README.md` is generated by `build_release_code.py`.
|
| 13 |
+
|
| 14 |
+
Every number in the card is still read from an eval file on disk — nothing is typed by hand:
|
| 15 |
+
|
| 16 |
+
Nawah-Reasoning-v6/eval_reasoning.json v6 on the 1,800-row v6 mix eval (by_source, 4 sources)
|
| 17 |
+
Nawah-Reasoning-v6/eval_on_synth.json v6 on the same 1,000-row synth set v4/v5 used
|
| 18 |
+
Nawah-Reasoning-v5/eval_reasoning.json v5 on the 1,400-row v5 mix eval
|
| 19 |
+
Nawah-Reasoning-v5/eval_on_synth.json v5, same 1,000 synth rows
|
| 20 |
+
Nawah-Reasoning-v5/eval_on_rel.json v5 on v6's 400 held-out relational rows (the baseline)
|
| 21 |
+
Nawah-Reasoning-v4/eval_on_mix.json v4 on v3's mix eval
|
| 22 |
+
Nawah-Reasoning-v4/eval_on_synth.json v4, same 1,000 synth rows
|
| 23 |
+
Nawah-Reasoning-v3/eval_reasoning.json v3 on the same mix eval
|
| 24 |
+
Nawah-Reasoning-v3/eval_on_synth.json v3, same 1,000 synth rows
|
| 25 |
+
|
| 26 |
+
Usage: python push_release.py [--repo oddadmix/Nawah-Math-Reasoning] [--dry-run] [--no-code]
|
| 27 |
+
"""
|
| 28 |
+
import argparse
|
| 29 |
+
import json
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
from huggingface_hub import HfApi
|
| 33 |
+
|
| 34 |
+
MODEL_DIR = Path("./Nawah-Reasoning-v6")
|
| 35 |
+
V5_DIR = Path("./Nawah-Reasoning-v5")
|
| 36 |
+
V4_DIR = Path("./Nawah-Reasoning-v4")
|
| 37 |
+
V3_DIR = Path("./Nawah-Reasoning-v3")
|
| 38 |
+
CODE_DIR = Path("./release_code")
|
| 39 |
+
BASE = "oddadmix/50M-2048-Emhotob"
|
| 40 |
+
DEMO = "oddadmix/Nawah-Math-Reasoning-Demo"
|
| 41 |
+
|
| 42 |
+
CARD = """---
|
| 43 |
+
license: apache-2.0
|
| 44 |
+
language:
|
| 45 |
+
- ar
|
| 46 |
+
base_model: {base}
|
| 47 |
+
datasets:
|
| 48 |
+
- oddadmix/arabic-math-reasoning-synth
|
| 49 |
+
- oddadmix/gsm8k-reasoning-ar
|
| 50 |
+
- Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset
|
| 51 |
+
library_name: transformers
|
| 52 |
+
pipeline_tag: text-generation
|
| 53 |
+
tags:
|
| 54 |
+
- arabic
|
| 55 |
+
- reasoning
|
| 56 |
+
- chain-of-thought
|
| 57 |
+
- math
|
| 58 |
+
- gsm8k
|
| 59 |
+
- small-language-model
|
| 60 |
+
- slm
|
| 61 |
+
- llama
|
| 62 |
+
- sft
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
# Nawah-Math-Reasoning — نموذج استدلال رياضي عربي
|
| 66 |
+
|
| 67 |
+
A **{params:.1f}M-parameter** Arabic math reasoning model. It writes its derivation step by step
|
| 68 |
+
inside `<think>…</think>`, then gives the answer. It is small enough to run on a CPU.
|
| 69 |
+
|
| 70 |
+
> **بالعربية:** نموذج عربي صغير (~{params:.0f} مليون معامل) لحل المسائل الحسابية: يكتب خطوات
|
| 71 |
+
> تفكيره داخل وسم `<think>` ثم يعطي الإجابة. صغير بما يكفي ليعمل على المعالج (CPU).
|
| 72 |
+
|
| 73 |
+
| | |
|
| 74 |
+
|---|---|
|
| 75 |
+
| 🤗 **Demo** | [`{demo}`](https://huggingface.co/spaces/{demo}) |
|
| 76 |
+
| 🧩 **Base model** | [`{base}`](https://huggingface.co/{base}) — Llama architecture, 12 layers, hidden 512, 2048 ctx, pre-trained from scratch on ~20B Arabic tokens |
|
| 77 |
+
| 📚 **Data** | [`arabic-math-reasoning-synth`](https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth) · [`gsm8k-reasoning-ar`](https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar) · [`Arabic_Reasoning_Dataset`](https://huggingface.co/datasets/Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset) |
|
| 78 |
+
| 🛠️ **Training code** | [`code/`](https://huggingface.co/{repo}/tree/main/code) in this repo — data generation, translation, SFT, eval, GRPO |
|
| 79 |
+
| 🔤 **Vocab** | {vocab} (4 chat/reasoning tokens added to the 32000 base vocab) |
|
| 80 |
+
|
| 81 |
+
## Results
|
| 82 |
+
|
| 83 |
+
Number agreement, greedy decoding. **Every cell is measured on identical held-out rows.** The
|
| 84 |
+
`Arabic_Reasoning` and `GSM8K-ar` rows are the eval splits fixed at the start of the project and
|
| 85 |
+
never re-drawn; the synthetic rows are pinned to the same 1,000 items every earlier version was
|
| 86 |
+
scored on.
|
| 87 |
+
|
| 88 |
+
The `v3 / v4 / v5` columns are internal development runs, kept here because they are what makes
|
| 89 |
+
the release number mean something. They are not published — the numbers are, so the ablation is
|
| 90 |
+
readable without them.
|
| 91 |
+
|
| 92 |
+
| eval set | n | v3 | v4 | v5 | **release** |
|
| 93 |
+
|---|---:|---:|---:|---:|---:|
|
| 94 |
+
| GSM8K-ar | {g_n} | {v3_gsm:.1f}% | {v4_gsm:.1f}% | {v5_gsm:.1f}% | **{v6_gsm:.1f}%** |
|
| 95 |
+
| Arabic_Reasoning | {a_n} | {v3_ar:.1f}% | {v4_ar:.1f}% | **{v5_ar:.1f}%** | {v6_ar:.1f}% |
|
| 96 |
+
| synthetic math | 1000 | {v3_synth:.1f}% | {v4_synth:.1f}% | {v5_synth:.1f}% | **{v6_synth:.1f}%** |
|
| 97 |
+
| **synthetic relational** | {r_n} | — | — | {v5_rel:.1f}% | **{v6_rel:.1f}%** |
|
| 98 |
+
|
| 99 |
+
**The relational row is what this release adds.** On problems whose difficulty is the *relation*
|
| 100 |
+
between quantities (`ضعف`, `نصف`, `أكثر بـ…`) rather than the arithmetic, it scores
|
| 101 |
+
**{v6_rel:.1f}%** where the previous run scores {v5_rel:.1f}% — a **{rel_gain:+.1f} point** gain and
|
| 102 |
+
the largest single-cell move anywhere in the development ladder. It did not cost the other
|
| 103 |
+
distributions: GSM8K-ar is simultaneously the best of the series at **{v6_gsm:.1f}%**, and
|
| 104 |
+
synthetic math gains {synth_delta:+.1f}.
|
| 105 |
+
|
| 106 |
+
The one regression is `Arabic_Reasoning` at **{ar_delta:+.1f}** against v5 — on {a_n} rows that is
|
| 107 |
+
close to sampling noise, but it is the second consecutive mix where this column is the give.
|
| 108 |
+
|
| 109 |
+
| detail | GSM8K-ar | Arabic_Reasoning | synth math | synth relational |
|
| 110 |
+
|---|---:|---:|---:|---:|
|
| 111 |
+
| final-answer number correct | {g_primary:.1f}% | {a_primary:.1f}% | {s_primary:.1f}% | {r_primary:.1f}% |
|
| 112 |
+
| all numbers match | {v6_gsm:.1f}% | {v6_ar:.1f}% | {s_nums:.1f}% | {v6_rel:.1f}% |
|
| 113 |
+
| well-formed `<think>` + answer | {g_well:.1f}% | {a_well:.1f}% | {s_well:.1f}% | {r_well:.1f}% |
|
| 114 |
+
| mean reasoning length | {g_tokens:.0f} tok | {a_tokens:.0f} tok | {s_tokens:.0f} tok | {r_tokens:.0f} tok |
|
| 115 |
+
|
| 116 |
+
*(the synth-math column here is the 400-row mix cell; the {v6_synth:.1f}% in the table above is the
|
| 117 |
+
1,000-row set used for the cross-model comparison.)*
|
| 118 |
+
|
| 119 |
+
Reproduce any cell with `code/eval_reasoning.py` — it is the same script for every model and every
|
| 120 |
+
row, which is the only reason these are comparable.
|
| 121 |
+
|
| 122 |
+
### The final checkpoint ships, and eval loss disagrees
|
| 123 |
+
|
| 124 |
+
Loss bottoms at **{best_loss:.4f}** (epoch {best_epoch:.2f}) and rises to **{shipped_loss:.4f}** by
|
| 125 |
+
epoch {epochs} — yet the epoch-{epochs} weights are the better model. This was measured directly on
|
| 126 |
+
an earlier run whose corpus contained **no repeated rows**, which rules out memorisation: the
|
| 127 |
+
minimum-loss checkpoint scored 30.9% where the final scored 35.6%. It happened on four consecutive
|
| 128 |
+
runs. `train_reasoning.py` therefore takes `LOAD_BEST=0`, and that is not an oversight.
|
| 129 |
+
|
| 130 |
+
## Training mix
|
| 131 |
+
|
| 132 |
+
{train_n:,} rows, {tok_per_epoch:.1f}M tokens/epoch:
|
| 133 |
+
|
| 134 |
+
| source | rows | tokens/epoch | share |
|
| 135 |
+
|---|---:|---:|---:|
|
| 136 |
+
| [`oddadmix/arabic-math-reasoning-synth`](https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth) | {synth_rows:,} | {synth_tok:.2f}M | {synth_share:.1f}% |
|
| 137 |
+
| [`oddadmix/gsm8k-reasoning-ar`](https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar) | {gsm_rows:,} | {gsm_tok:.2f}M | {gsm_share:.1f}% |
|
| 138 |
+
| [`Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset`](https://huggingface.co/datasets/Omartificial-Intelligence-Space/Arabic_Reasoning_Dataset) | {ar_rows:,} (5,536 × 3) | {ar_tok:.2f}M | {ar_share:.1f}% |
|
| 139 |
+
|
| 140 |
+
Of the synthetic corpus's {corpus_rows:,} rows, {rel_rows:,} are **relational** problems generated
|
| 141 |
+
specifically for this release, after a `pass@k` diagnostic showed the previous model went 0/8 on
|
| 142 |
+
`ضعف`-style problems and a corpus audit found the relation appears in only 1.34% of rows. The
|
| 143 |
+
synthetic eval split was **pinned, not re-drawn** when those rows were added: re-shuffling would
|
| 144 |
+
have moved 1,955 of the 2,000 previously held-out items into train, turning that column into a
|
| 145 |
+
memorisation score.
|
| 146 |
+
|
| 147 |
+
Full fine-tune from the base (not from the previous version). Loss on the assistant turn only, user
|
| 148 |
+
prompt masked with `-100`. `Arabic_Reasoning` is ~25× smaller than GSM8K, so it is repeated 3×.
|
| 149 |
+
|
| 150 |
+
| | |
|
| 151 |
+
|---|---|
|
| 152 |
+
| epochs | {epochs} ({steps:,} steps) |
|
| 153 |
+
| effective batch | 64 |
|
| 154 |
+
| learning rate | 3e-4 cosine, 200 warmup steps |
|
| 155 |
+
| max length | 768 tokens (mix p100 is {p100} — nothing truncated) |
|
| 156 |
+
| precision | bf16 |
|
| 157 |
+
| checkpoint | final (`load_best_model_at_end` disabled — it picks the worse model) |
|
| 158 |
+
| hardware | 1× RTX A6000, ~{minutes} min |
|
| 159 |
+
|
| 160 |
+
## Usage
|
| 161 |
+
|
| 162 |
+
```python
|
| 163 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 164 |
+
import torch
|
| 165 |
+
|
| 166 |
+
model_id = "{repo}"
|
| 167 |
+
tok = AutoTokenizer.from_pretrained(model_id)
|
| 168 |
+
model = AutoModelForCausalLM.from_pretrained(model_id, dtype=torch.bfloat16).eval()
|
| 169 |
+
|
| 170 |
+
messages = [{{"role": "user", "content": "اشترى خالد 4 دفاتر بسعر 15 جنيهًا للدفتر، ودفع بورقة 100 جنيه. كم المبلغ المتبقي؟"}}]
|
| 171 |
+
prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 172 |
+
ids = tok(prompt, return_tensors="pt")
|
| 173 |
+
|
| 174 |
+
out = model.generate(**ids, max_new_tokens=384, do_sample=False)
|
| 175 |
+
print(tok.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=False))
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
Split the parts with `re.match(r"\\s*<think>(.*?)</think>(.*)", completion, re.S)`.
|
| 179 |
+
Decode with `skip_special_tokens=False` — `<think>` and `</think>` are real tokens in this
|
| 180 |
+
tokenizer, and stripping them destroys the split.
|
| 181 |
+
|
| 182 |
+
It is **single-turn**: one user message per call. Chat history is out of distribution.
|
| 183 |
+
|
| 184 |
+
**Answer style is not something you can request.** The three corpora disagree — GSM8K rows end in a
|
| 185 |
+
bare numeral, the other two in an `إذن، …` sentence — and arithmetic word problems look alike in
|
| 186 |
+
all of them, so the model picks a style per prompt. **Score it on number agreement, not exact
|
| 187 |
+
string match**, and parse the answer by extracting its numbers.
|
| 188 |
+
|
| 189 |
+
## Limitations
|
| 190 |
+
|
| 191 |
+
At ~{params:.0f}M parameters this is a **proof of concept**, and the honest headline is the
|
| 192 |
+
synthetic columns — **{v6_synth:.1f}%** and **{v6_rel:.1f}%** on multi-step problems, well below the
|
| 193 |
+
{v6_gsm:.1f}% it scores on GSM8K's narrower phrasing. Arithmetic is the dominant failure mode: the
|
| 194 |
+
reasoning is usually structurally right, one computation step is wrong, and the model then stays
|
| 195 |
+
faithful to its own bad number.
|
| 196 |
+
|
| 197 |
+
Each corpus brings its own defect. The GSM8K half is machine-translated, its 140,969 rows expanding
|
| 198 |
+
from only 2,814 question patterns, so that score partly reflects narrow phrasing. The synthetic
|
| 199 |
+
half is verified for **arithmetic, not for sense** — rows survive where every equation checks out
|
| 200 |
+
but a step introduces an entity never mentioned, or the answer resolves the reverse of what was
|
| 201 |
+
asked. The `Arabic_Reasoning` half excludes open-ended expository rows (they have no final answer
|
| 202 |
+
to place after `</think>`), so expository prompts remain out of distribution.
|
| 203 |
+
|
| 204 |
+
Everything is MSA; the synthetic corpus's region axis sets currency and context, not dialect. The
|
| 205 |
+
Arabic inherits source artifacts including inconsistent gender agreement. Its reasoning trace is
|
| 206 |
+
not a faithful account of any internal computation. Do not use it for anything consequential.
|
| 207 |
+
|
| 208 |
+
## Citation
|
| 209 |
+
|
| 210 |
+
```bibtex
|
| 211 |
+
@misc{{nawah_math_reasoning_2026,
|
| 212 |
+
title = {{Nawah-Math-Reasoning: a 52M-parameter Arabic chain-of-thought math model}},
|
| 213 |
+
author = {{Ahmed Wasfy}},
|
| 214 |
+
year = {{2026}},
|
| 215 |
+
url = {{https://huggingface.co/{repo}}}
|
| 216 |
+
}}
|
| 217 |
+
```
|
| 218 |
+
"""
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def main():
|
| 222 |
+
ap = argparse.ArgumentParser()
|
| 223 |
+
ap.add_argument("--repo", default="oddadmix/Nawah-Math-Reasoning")
|
| 224 |
+
ap.add_argument("--dry-run", action="store_true")
|
| 225 |
+
ap.add_argument("--no-code", action="store_true", help="skip the code/ upload")
|
| 226 |
+
ap.add_argument("--minutes", default="85")
|
| 227 |
+
args = ap.parse_args()
|
| 228 |
+
|
| 229 |
+
def summary(p):
|
| 230 |
+
return json.loads(Path(p).read_text(encoding="utf-8"))["summary"]
|
| 231 |
+
|
| 232 |
+
v6 = summary(MODEL_DIR / "eval_reasoning.json")
|
| 233 |
+
v6_synth = summary(MODEL_DIR / "eval_on_synth.json")
|
| 234 |
+
v5_mix = summary(V5_DIR / "eval_reasoning.json")
|
| 235 |
+
v5_synth = summary(V5_DIR / "eval_on_synth.json")
|
| 236 |
+
v5_rel = summary(V5_DIR / "eval_on_rel.json")
|
| 237 |
+
v4_mix, v4_synth = summary(V4_DIR / "eval_on_mix.json"), summary(V4_DIR / "eval_on_synth.json")
|
| 238 |
+
v3_mix, v3_synth = summary(V3_DIR / "eval_reasoning.json"), summary(V3_DIR / "eval_on_synth.json")
|
| 239 |
+
|
| 240 |
+
g, a, s, r = (v6["by_source"]["gsm8k_ar"], v6["by_source"]["arabic_reasoning"],
|
| 241 |
+
v6["by_source"]["synth_math_ar"], v6["by_source"]["synth_relational_ar"])
|
| 242 |
+
|
| 243 |
+
tm = json.loads(Path(MODEL_DIR, "train_metrics.json").read_text(encoding="utf-8"))
|
| 244 |
+
cfg = json.loads(Path(MODEL_DIR, "config.json").read_text(encoding="utf-8"))
|
| 245 |
+
evals = [h for h in tm["log_history"] if "eval_loss" in h]
|
| 246 |
+
last = max(h["step"] for h in evals)
|
| 247 |
+
shipped_loss = next(h["eval_loss"] for h in evals if h["step"] == last)
|
| 248 |
+
best = min(evals, key=lambda h: h["eval_loss"])
|
| 249 |
+
|
| 250 |
+
v3_gsm, v4_gsm, v5_gsm = (v3_mix["by_source"]["gsm8k_ar"], v4_mix["by_source"]["gsm8k_ar"],
|
| 251 |
+
v5_mix["by_source"]["gsm8k_ar"])
|
| 252 |
+
v3_ar, v4_ar, v5_ar = (v3_mix["by_source"]["arabic_reasoning"],
|
| 253 |
+
v4_mix["by_source"]["arabic_reasoning"],
|
| 254 |
+
v5_mix["by_source"]["arabic_reasoning"])
|
| 255 |
+
|
| 256 |
+
# measured from data_v6_sft/train.jsonl with the base tokenizer (+12 tok/row of chat scaffold)
|
| 257 |
+
MIX = {"synth_math_ar": (118062, 16.79, 53.9), "gsm8k_ar": (140969, 11.88, 38.2),
|
| 258 |
+
"arabic_reasoning": (16608, 2.45, 7.9)}
|
| 259 |
+
|
| 260 |
+
card = CARD.format(
|
| 261 |
+
repo=args.repo, base=BASE, demo=DEMO, params=51.79, vocab=cfg["vocab_size"],
|
| 262 |
+
g_n=g["n"], a_n=a["n"], r_n=r["n"],
|
| 263 |
+
v3_gsm=v3_gsm["numbers_match_pct"], v4_gsm=v4_gsm["numbers_match_pct"],
|
| 264 |
+
v5_gsm=v5_gsm["numbers_match_pct"], v6_gsm=g["numbers_match_pct"],
|
| 265 |
+
v3_ar=v3_ar["numbers_match_pct"], v4_ar=v4_ar["numbers_match_pct"],
|
| 266 |
+
v5_ar=v5_ar["numbers_match_pct"], v6_ar=a["numbers_match_pct"],
|
| 267 |
+
v3_synth=v3_synth["numbers_match_pct"], v4_synth=v4_synth["numbers_match_pct"],
|
| 268 |
+
v5_synth=v5_synth["numbers_match_pct"], v6_synth=v6_synth["numbers_match_pct"],
|
| 269 |
+
v5_rel=v5_rel["numbers_match_pct"], v6_rel=r["numbers_match_pct"],
|
| 270 |
+
rel_gain=r["numbers_match_pct"] - v5_rel["numbers_match_pct"],
|
| 271 |
+
ar_delta=a["numbers_match_pct"] - v5_ar["numbers_match_pct"],
|
| 272 |
+
synth_delta=v6_synth["numbers_match_pct"] - v5_synth["numbers_match_pct"],
|
| 273 |
+
g_primary=g["primary_number_match_pct"], a_primary=a["primary_number_match_pct"],
|
| 274 |
+
s_primary=s["primary_number_match_pct"], r_primary=r["primary_number_match_pct"],
|
| 275 |
+
s_nums=s["numbers_match_pct"],
|
| 276 |
+
g_well=g["well_formed_pct"], a_well=a["well_formed_pct"],
|
| 277 |
+
s_well=s["well_formed_pct"], r_well=r["well_formed_pct"],
|
| 278 |
+
g_tokens=g["mean_reasoning_tokens"], a_tokens=a["mean_reasoning_tokens"],
|
| 279 |
+
s_tokens=s["mean_reasoning_tokens"], r_tokens=r["mean_reasoning_tokens"],
|
| 280 |
+
best_loss=best["eval_loss"], best_epoch=best["epoch"], shipped_loss=shipped_loss,
|
| 281 |
+
epochs=round(max(h.get("epoch", 0) for h in tm["log_history"])),
|
| 282 |
+
steps=max(h.get("step", 0) for h in tm["log_history"]),
|
| 283 |
+
train_n=sum(v[0] for v in MIX.values()),
|
| 284 |
+
tok_per_epoch=sum(v[1] for v in MIX.values()),
|
| 285 |
+
synth_rows=MIX["synth_math_ar"][0], synth_tok=MIX["synth_math_ar"][1],
|
| 286 |
+
synth_share=MIX["synth_math_ar"][2],
|
| 287 |
+
gsm_rows=MIX["gsm8k_ar"][0], gsm_tok=MIX["gsm8k_ar"][1], gsm_share=MIX["gsm8k_ar"][2],
|
| 288 |
+
ar_rows=MIX["arabic_reasoning"][0], ar_tok=MIX["arabic_reasoning"][1],
|
| 289 |
+
ar_share=MIX["arabic_reasoning"][2],
|
| 290 |
+
corpus_rows=120462, rel_rows=20139, p100=703, minutes=args.minutes,
|
| 291 |
+
)
|
| 292 |
+
out = Path("release_README.md")
|
| 293 |
+
out.write_text(card, encoding="utf-8")
|
| 294 |
+
print(f"[+] wrote {out} ({len(card)} chars)")
|
| 295 |
+
|
| 296 |
+
if args.dry_run:
|
| 297 |
+
print("[dry-run] not pushing")
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
api = HfApi()
|
| 301 |
+
api.create_repo(args.repo, private=False, exist_ok=True)
|
| 302 |
+
|
| 303 |
+
# Weights first, then the card, so the repo is never public-and-uncarded for long.
|
| 304 |
+
api.upload_folder(
|
| 305 |
+
folder_path=str(MODEL_DIR), repo_id=args.repo,
|
| 306 |
+
ignore_patterns=["checkpoint-*/*", "checkpoint-*", "eval_on_*.json", "README.md"],
|
| 307 |
+
commit_message="Nawah-Math-Reasoning: weights, tokenizer, eval + training metrics")
|
| 308 |
+
api.upload_file(path_or_fileobj=str(out), path_in_repo="README.md", repo_id=args.repo,
|
| 309 |
+
commit_message="model card")
|
| 310 |
+
|
| 311 |
+
if not args.no_code:
|
| 312 |
+
if not CODE_DIR.is_dir():
|
| 313 |
+
raise SystemExit(f"{CODE_DIR} missing — run build_release_code.py first")
|
| 314 |
+
api.upload_folder(folder_path=str(CODE_DIR), repo_id=args.repo, path_in_repo="code",
|
| 315 |
+
commit_message="training code: data generation, SFT, eval, GRPO")
|
| 316 |
+
|
| 317 |
+
print(f"[+] https://huggingface.co/{args.repo}")
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
main()
|
code/push_synth_dataset.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Push the synthetic Arabic math-reasoning corpus to the Hub as a PRIVATE dataset with a card.
|
| 3 |
+
|
| 4 |
+
Usage: python push_synth_dataset.py [--repo oddadmix/arabic-math-reasoning-synth] [--dry-run]
|
| 5 |
+
"""
|
| 6 |
+
import argparse
|
| 7 |
+
import collections
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import pyarrow.parquet as pq
|
| 13 |
+
from huggingface_hub import HfApi
|
| 14 |
+
|
| 15 |
+
OUT_DIR = Path(os.environ.get("OUT_DIR", "out_synth"))
|
| 16 |
+
PARQUET = OUT_DIR / "arabic_math_reasoning_synth.parquet"
|
| 17 |
+
|
| 18 |
+
# Hub ids for the generators, keyed by the `model` field the nodes record per row.
|
| 19 |
+
GEN_MODEL_HUB = {
|
| 20 |
+
"gemma-3-12b-it": "google/gemma-3-12b-it",
|
| 21 |
+
"Qwen3.6-27B-NVFP4": "nvidia/Qwen3.6-27B-NVFP4",
|
| 22 |
+
"Qwen3.8-27B-Uncensored-NVFP4": "orcarouter/Qwen3.8-27B-Uncensored-NVFP4",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
CARD = """---
|
| 26 |
+
license: apache-2.0
|
| 27 |
+
language:
|
| 28 |
+
- ar
|
| 29 |
+
size_categories:
|
| 30 |
+
- {size_cat}
|
| 31 |
+
task_categories:
|
| 32 |
+
- text-generation
|
| 33 |
+
tags:
|
| 34 |
+
- arabic
|
| 35 |
+
- math
|
| 36 |
+
- reasoning
|
| 37 |
+
- chain-of-thought
|
| 38 |
+
- gsm8k
|
| 39 |
+
- synthetic
|
| 40 |
+
configs:
|
| 41 |
+
- config_name: default
|
| 42 |
+
data_files:
|
| 43 |
+
- split: train
|
| 44 |
+
path: arabic_math_reasoning_synth.parquet
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
# Arabic Math Reasoning (synthetic) — مسائل رياضيات عربية مع خطوات الحل
|
| 48 |
+
{status_note}
|
| 49 |
+
**{n:,}** Arabic grade-school math word problems, each with a step-by-step derivation and a
|
| 50 |
+
concluding sentence. Generated with {gen_blurb} and
|
| 51 |
+
**arithmetically verified** — every equation the reasoning states was re-evaluated, and rows whose
|
| 52 |
+
own arithmetic does not check out were dropped.
|
| 53 |
+
|
| 54 |
+
{gen_table}
|
| 55 |
+
|
| 56 |
+
> **بالعربية:** {n:,} مسألة حسابية عربية مع خطوات حل مفصّلة وجملة إجابة نهائية. جميع المعادلات
|
| 57 |
+
> داخل خطوات الحل تم التحقق منها حسابيًا، والصفوف التي تحتوي على خطأ حسابي مستبعدة.
|
| 58 |
+
|
| 59 |
+
## Schema
|
| 60 |
+
|
| 61 |
+
| field | description |
|
| 62 |
+
|---|---|
|
| 63 |
+
| `instruction` | the word problem, ending in a question |
|
| 64 |
+
| `reasoning` | numbered derivation, each step containing an explicit equation |
|
| 65 |
+
| `answer` | one concluding sentence (`إذن، …`) carrying the final number and its unit |
|
| 66 |
+
| `axis_*` | the variation axes this item was drawn from (domain, operation, steps, numbers, region, twist) |
|
| 67 |
+
| `task_id` | the generation task it came from |
|
| 68 |
+
| `gen_model` | which model generated the row |
|
| 69 |
+
|
| 70 |
+
It matches the `{{instruction, reasoning, answer}}` schema used by the Nawah-Reasoning training
|
| 71 |
+
scripts, so it drops straight into ChatML + `<think>` SFT.
|
| 72 |
+
|
| 73 |
+
## How it was built
|
| 74 |
+
|
| 75 |
+
Prompts are drawn from a **variation grid** — {n_domains} domains × {n_ops} operation types ×
|
| 76 |
+
step counts × number styles × {n_regions} country/currency pairs × {n_twists} structural twists —
|
| 77 |
+
seeded deterministically from the task index. This is a deliberate reaction to the corpus this was
|
| 78 |
+
built to supplement: a machine-translated Arabic GSM8K whose {gsm_rows:,} rows expand from only
|
| 79 |
+
2,814 underlying question templates. Here, **{templates:,}** distinct templates survive
|
| 80 |
+
number-masked deduplication.
|
| 81 |
+
{pool_note}
|
| 82 |
+
|
| 83 |
+
### Verification
|
| 84 |
+
|
| 85 |
+
Generated math is fluent and often wrong, so acceptance is mechanical rather than stylistic:
|
| 86 |
+
|
| 87 |
+
1. every `a op b = c` in the reasoning is re-evaluated with real arithmetic — one bad equation
|
| 88 |
+
rejects the row;
|
| 89 |
+
2. the number in the answer sentence must equal the last computed result (a chain that is
|
| 90 |
+
internally correct but ends on a different number is the failure that survives fluency checks);
|
| 91 |
+
3. no Latin characters, length bounds, and the answer must open with a conclusion marker.
|
| 92 |
+
|
| 93 |
+
**{accept_rate:.1%}** of parsed items passed. Rejections:
|
| 94 |
+
|
| 95 |
+
{reject_table}
|
| 96 |
+
|
| 97 |
+
`out_synth/rejects.jsonl` in the source repo keeps every rejected item with its reason, so the
|
| 98 |
+
filter itself can be audited.
|
| 99 |
+
|
| 100 |
+
## Limitations
|
| 101 |
+
|
| 102 |
+
This is **synthetic** data: the problems are machine-written and reflect that model's habits of
|
| 103 |
+
phrasing, its distribution of scenarios, and its idea of what a "typical" Arabic math problem
|
| 104 |
+
looks like. Verification proves each row's arithmetic is self-consistent — it does **not** prove
|
| 105 |
+
the problem is well-posed, that the setup is the only reasonable reading, or that the wording is
|
| 106 |
+
natural to any particular dialect region (the `axis_region` field sets currency and context, not
|
| 107 |
+
dialect; everything is MSA). Rows whose reasoning states no explicit equation are dropped, which
|
| 108 |
+
biases the corpus toward problems that decompose into clean arithmetic steps.
|
| 109 |
+
"""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def main():
|
| 113 |
+
ap = argparse.ArgumentParser()
|
| 114 |
+
ap.add_argument("--repo", default="oddadmix/arabic-math-reasoning-synth")
|
| 115 |
+
ap.add_argument("--target", type=int, default=100_000)
|
| 116 |
+
ap.add_argument("--dry-run", action="store_true")
|
| 117 |
+
args = ap.parse_args()
|
| 118 |
+
|
| 119 |
+
import synth_common as sc
|
| 120 |
+
|
| 121 |
+
n = pq.read_metadata(PARQUET).num_rows
|
| 122 |
+
stats = json.loads((OUT_DIR / "build_stats.json").read_text(encoding="utf-8"))
|
| 123 |
+
|
| 124 |
+
# Credit every generator that actually contributed, from the build's own per-model counts.
|
| 125 |
+
by_model = collections.Counter({k[len("model_"):]: v
|
| 126 |
+
for k, v in stats["stats"].items() if k.startswith("model_")})
|
| 127 |
+
def _link(name):
|
| 128 |
+
hub = GEN_MODEL_HUB.get(name)
|
| 129 |
+
return f"[`{name}`](https://huggingface.co/{hub})" if hub else f"`{name}`"
|
| 130 |
+
names = [m for m, _ in by_model.most_common()]
|
| 131 |
+
gen_blurb = " and ".join(filter(None, [", ".join(_link(m) for m in names[:-1]),
|
| 132 |
+
_link(names[-1])])) if names else "an unrecorded model"
|
| 133 |
+
gen_table = ("| generator | rows | share |\n|---|---:|---:|\n" + "\n".join(
|
| 134 |
+
f"| {_link(m)} | {c:,} | {100 * c / max(n, 1):.1f}% |"
|
| 135 |
+
for m, c in by_model.most_common())) if len(names) > 1 else ""
|
| 136 |
+
total_rejects = max(sum(stats["reject_reasons"].values()), 1)
|
| 137 |
+
folded = collections.Counter()
|
| 138 |
+
for k, v in stats["reject_reasons"].items():
|
| 139 |
+
folded[k.split(":", 1)[0]] += v
|
| 140 |
+
reject_table = "| reason | count | share of rejects |\n|---|---:|---:|\n" + "\n".join(
|
| 141 |
+
f"| `{k}` | {v:,} | {100 * v / total_rejects:.1f}% |"
|
| 142 |
+
for k, v in folded.most_common(8))
|
| 143 |
+
|
| 144 |
+
if n < args.target:
|
| 145 |
+
status_note = (
|
| 146 |
+
f"\n> ⚠️ **Interim snapshot — generation is still running.** This is "
|
| 147 |
+
f"{n:,} of a planned {args.target:,} rows, published early so it can be reviewed and "
|
| 148 |
+
f"trained against. Rows are only ever *appended*: the generator is resumable and "
|
| 149 |
+
f"deterministic per task index, so everything here stays in the finished corpus "
|
| 150 |
+
f"unchanged. Expect this repo to be overwritten with a larger version.\n")
|
| 151 |
+
else:
|
| 152 |
+
status_note = ""
|
| 153 |
+
|
| 154 |
+
# The relational pool (task ids from 1,000,000) is a second, disjoint operation set added
|
| 155 |
+
# after the first 100,323 rows shipped. Count it from the data rather than asserting it.
|
| 156 |
+
n_rel = sum(1 for tid in pq.read_table(PARQUET, columns=["task_id"]).column("task_id").to_pylist()
|
| 157 |
+
if tid >= 1_000_000)
|
| 158 |
+
pool_note = ("""
|
| 159 |
+
The operation axis is drawn from **two disjoint pools**. The first {n_default:,} rows use 15
|
| 160 |
+
general operations (totals, percentages, unit rates, remainders, …). A later pass added a
|
| 161 |
+
**relational pool** of 8 operations — *ضعف* / *أضعاف*, *نصف*, *ثلث* و *ربع*, absolute increase and
|
| 162 |
+
decrease, three-way chains, and the inverse direction — contributing **{n_rel:,}** rows. These
|
| 163 |
+
express a quantity *relative to another entity* ("Ziad bought **double that number**"), which
|
| 164 |
+
forces a derivation step that a percentage problem does not. In the original corpus *ضعف /
|
| 165 |
+
أضعاف* appeared in 1.34% of rows and *أكثر بـ / أقل بـ* in 0.26% — against 17.6% for percentages —
|
| 166 |
+
and that gap is the documented weak spot of models trained on it, so the pool was generated
|
| 167 |
+
deliberately rather than left to the grid.
|
| 168 |
+
""".format(n_rel=n_rel, n_default=n - n_rel) if n_rel else "")
|
| 169 |
+
|
| 170 |
+
size_cat = "100K<n<1M" if n >= 100_000 else "10K<n<100K" if n >= 10_000 else "1K<n<10K"
|
| 171 |
+
card = CARD.format(
|
| 172 |
+
n=n, gen_blurb=gen_blurb, gen_table=gen_table, size_cat=size_cat,
|
| 173 |
+
n_domains=len(sc.DOMAINS), n_ops=stats["axis_coverage"]["op"], n_regions=len(sc.REGIONS),
|
| 174 |
+
n_twists=len(sc.TWISTS), gsm_rows=142_969,
|
| 175 |
+
templates=stats["unique_templates"], accept_rate=stats["accept_rate"],
|
| 176 |
+
status_note=status_note,
|
| 177 |
+
reject_table=reject_table,
|
| 178 |
+
pool_note=pool_note,
|
| 179 |
+
)
|
| 180 |
+
(OUT_DIR / "README.md").write_text(card, encoding="utf-8")
|
| 181 |
+
print(f"[+] wrote card ({len(card)} chars) | {n:,} rows | accept {stats['accept_rate']:.1%}")
|
| 182 |
+
|
| 183 |
+
if args.dry_run:
|
| 184 |
+
print("[dry-run] not pushing")
|
| 185 |
+
return
|
| 186 |
+
|
| 187 |
+
api = HfApi()
|
| 188 |
+
api.create_repo(args.repo, repo_type="dataset", private=True, exist_ok=True)
|
| 189 |
+
api.upload_file(path_or_fileobj=str(PARQUET), path_in_repo=PARQUET.name,
|
| 190 |
+
repo_id=args.repo, repo_type="dataset")
|
| 191 |
+
api.upload_file(path_or_fileobj=str(OUT_DIR / "README.md"), path_in_repo="README.md",
|
| 192 |
+
repo_id=args.repo, repo_type="dataset")
|
| 193 |
+
for script in ("synth_common.py", "synth_generate.py", "build_synth_dataset.py"):
|
| 194 |
+
api.upload_file(path_or_fileobj=script, path_in_repo=script,
|
| 195 |
+
repo_id=args.repo, repo_type="dataset")
|
| 196 |
+
print(f"[+] https://huggingface.co/datasets/{args.repo}")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
if __name__ == "__main__":
|
| 200 |
+
main()
|
code/retry_failed.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Second pass over segments whose greedy translation failed validation.
|
| 3 |
+
|
| 4 |
+
Two recovery strategies, tried in order, keeping the first candidate that validates:
|
| 5 |
+
1. beam search (beam_width=4) — what the Seed-X authors recommend
|
| 6 |
+
2. sampled best-of-8 — a different part of the distribution when beam search repeats the error
|
| 7 |
+
|
| 8 |
+
Recovered translations are appended to the cache, overriding the greedy result.
|
| 9 |
+
"""
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, ".")
|
| 16 |
+
from build_dataset import check
|
| 17 |
+
from gsm_common import SRC_PROMPT
|
| 18 |
+
|
| 19 |
+
OUT = Path("out_gsm")
|
| 20 |
+
CACHE = OUT / "translations.jsonl"
|
| 21 |
+
MODEL = "./models/Seed-X-PPO-7B"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load():
|
| 25 |
+
trans = {}
|
| 26 |
+
with open(CACHE, encoding="utf-8") as fh:
|
| 27 |
+
for line in fh:
|
| 28 |
+
try:
|
| 29 |
+
r = json.loads(line)
|
| 30 |
+
except json.JSONDecodeError:
|
| 31 |
+
continue
|
| 32 |
+
trans[r["src"]] = r["tgt"]
|
| 33 |
+
rows = [json.loads(l) for l in open(OUT / "selected_rows.jsonl", encoding="utf-8")]
|
| 34 |
+
kind = {}
|
| 35 |
+
for r in rows:
|
| 36 |
+
kind.setdefault(r["question"], "question")
|
| 37 |
+
kind.setdefault(r["thinking"], "thinking")
|
| 38 |
+
return trans, kind
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main():
|
| 42 |
+
trans, kind = load()
|
| 43 |
+
failed = [
|
| 44 |
+
s for s, t in trans.items()
|
| 45 |
+
if check(s, t, strict=(kind.get(s) == "thinking")) is not None
|
| 46 |
+
]
|
| 47 |
+
print(f"[*] {len(failed)}/{len(trans)} segments failed validation ({len(failed)/len(trans):.2%})")
|
| 48 |
+
if not failed:
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
from vllm import LLM, SamplingParams
|
| 52 |
+
from vllm.sampling_params import BeamSearchParams
|
| 53 |
+
|
| 54 |
+
llm = LLM(model=MODEL, max_num_seqs=256, gpu_memory_utilization=0.92, max_model_len=1024)
|
| 55 |
+
prompts = [SRC_PROMPT.format(text=s) for s in failed]
|
| 56 |
+
recovered = {}
|
| 57 |
+
|
| 58 |
+
# Beam search is disabled by default: vLLM 0.8.5 runs it as a Python-level loop and it took
|
| 59 |
+
# >50 min on ~4.8k segments without finishing, versus ~2 min for the batched sampling path
|
| 60 |
+
# below. Set RETRY_BEAM=1 to use it anyway.
|
| 61 |
+
if os.environ.get("RETRY_BEAM") == "1":
|
| 62 |
+
print("[*] pass 1: beam search")
|
| 63 |
+
outs = llm.beam_search([{"prompt": p} for p in prompts], BeamSearchParams(beam_width=4, max_tokens=256))
|
| 64 |
+
still = []
|
| 65 |
+
for src, o in zip(failed, outs):
|
| 66 |
+
strict = kind.get(src) == "thinking"
|
| 67 |
+
for seq in o.sequences:
|
| 68 |
+
cand = seq.text.strip()
|
| 69 |
+
if check(src, cand, strict=strict) is None:
|
| 70 |
+
recovered[src] = cand
|
| 71 |
+
break
|
| 72 |
+
else:
|
| 73 |
+
still.append(src)
|
| 74 |
+
print(f" recovered {len(recovered)}, still failing {len(still)}")
|
| 75 |
+
else:
|
| 76 |
+
print("[*] pass 1: skipped (beam search disabled)")
|
| 77 |
+
still = list(failed)
|
| 78 |
+
|
| 79 |
+
if still:
|
| 80 |
+
print("[*] pass 2: sampled best-of-8")
|
| 81 |
+
params = SamplingParams(n=8, temperature=0.8, top_p=0.95, max_tokens=256, skip_special_tokens=True)
|
| 82 |
+
outs = llm.generate([SRC_PROMPT.format(text=s) for s in still], params)
|
| 83 |
+
final = []
|
| 84 |
+
for src, o in zip(still, outs):
|
| 85 |
+
strict = kind.get(src) == "thinking"
|
| 86 |
+
for cand in o.outputs:
|
| 87 |
+
text = cand.text.strip()
|
| 88 |
+
if check(src, text, strict=strict) is None:
|
| 89 |
+
recovered[src] = text
|
| 90 |
+
break
|
| 91 |
+
else:
|
| 92 |
+
final.append(src)
|
| 93 |
+
print(f" recovered {len(recovered)} total, unrecoverable {len(final)}")
|
| 94 |
+
|
| 95 |
+
with open(CACHE, "a", encoding="utf-8") as fh:
|
| 96 |
+
for src, tgt in recovered.items():
|
| 97 |
+
fh.write(json.dumps({"src": src, "tgt": tgt}, ensure_ascii=False) + "\n")
|
| 98 |
+
print(f"[+] appended {len(recovered)} recovered translations to {CACHE}")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
code/space/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Nawah Math Reasoning
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.26.0
|
| 8 |
+
python_version: '3.12'
|
| 9 |
+
app_file: app.py
|
| 10 |
+
pinned: false
|
| 11 |
+
models:
|
| 12 |
+
- oddadmix/Nawah-Math-Reasoning
|
| 13 |
+
datasets:
|
| 14 |
+
- oddadmix/arabic-math-reasoning-synth
|
| 15 |
+
- oddadmix/gsm8k-reasoning-ar
|
| 16 |
+
short_description: نموذج استدلال رياضي عربي (52M) يفكّر خطوة بخطوة قبل الإجابة
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# Nawah-Math-Reasoning — Demo
|
| 20 |
+
|
| 21 |
+
A **51.8M-parameter** Arabic math reasoning model. It writes its derivation inside
|
| 22 |
+
`<think>…</think>` and then gives the final answer; the demo splits the two live as it streams —
|
| 23 |
+
the reasoning trace in one panel, the answer in the other.
|
| 24 |
+
|
| 25 |
+
Fine-tuned from [`oddadmix/50M-2048-Emhotob`](https://huggingface.co/oddadmix/50M-2048-Emhotob),
|
| 26 |
+
a Llama-architecture base pre-trained from scratch on ~20B Arabic tokens (12 layers, hidden 512,
|
| 27 |
+
2048 context).
|
| 28 |
+
|
| 29 |
+
> **بالعربية:** نموذج عربي صغير (~52 مليون معامل) يكتب خطوات تفكيره داخل وسم `<think>` ثم يعطي
|
| 30 |
+
> الإجابة النهائية. الديمو بيفصل الاتنين وانت بتتفرج على النموذج وهو بيكتب.
|
| 31 |
+
|
| 32 |
+
## Everything is open — Apache 2.0
|
| 33 |
+
|
| 34 |
+
| | |
|
| 35 |
+
|---|---|
|
| 36 |
+
| 🧠 **Model** | [`oddadmix/Nawah-Math-Reasoning`](https://huggingface.co/oddadmix/Nawah-Math-Reasoning) |
|
| 37 |
+
| 🛠️ **Training code** | [`code/`](https://huggingface.co/oddadmix/Nawah-Math-Reasoning/tree/main/code) — data generation, translation, SFT, eval, GRPO |
|
| 38 |
+
| 📚 **Synthetic corpus** | [`oddadmix/arabic-math-reasoning-synth`](https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth) — 120,462 arithmetically verified rows |
|
| 39 |
+
| 📚 **Translated corpus** | [`oddadmix/gsm8k-reasoning-ar`](https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar) — 142,969 rows |
|
| 40 |
+
|
| 41 |
+
## Results
|
| 42 |
+
|
| 43 |
+
Number agreement, greedy decoding, on held-out splits. Every version of the model was scored on
|
| 44 |
+
identical rows, so the numbers are comparable across the whole development ladder.
|
| 45 |
+
|
| 46 |
+
| eval set | n | score |
|
| 47 |
+
|---|---:|---:|
|
| 48 |
+
| GSM8K-ar | 600 | **79.0%** |
|
| 49 |
+
| Arabic_Reasoning | 400 | **73.0%** |
|
| 50 |
+
| synthetic math | 1000 | **40.4%** |
|
| 51 |
+
| synthetic relational | 400 | **52.2%** |
|
| 52 |
+
|
| 53 |
+
The last row is what this release adds: problems where the difficulty is the *relation* between
|
| 54 |
+
quantities (`ضعف`, `نصف`, `أكثر بـ…`) rather than the arithmetic. The previous version scored
|
| 55 |
+
34.0% there — the relation appeared in barely 1.3% of the training corpus, so 20,139 rows were
|
| 56 |
+
generated specifically to fill the gap.
|
| 57 |
+
|
| 58 |
+
## Limitations
|
| 59 |
+
|
| 60 |
+
A 52M proof of concept. It reliably produces the *shape* of Arabic step-by-step reasoning, but
|
| 61 |
+
**arithmetic errors are the dominant failure mode** — the derivation is usually structurally
|
| 62 |
+
right, one computation is wrong, and the model then stays faithful to its own bad number. The
|
| 63 |
+
40.4% and 52.2% above are the honest ceiling on multi-step problems. Single-turn only; open-ended
|
| 64 |
+
and non-mathematical questions are out of distribution.
|
| 65 |
+
|
| 66 |
+
نموذج تجريبي: بيعرف يمشي خطوة خطوة بالعربي، بس بيغلط في الحساب كتير.
|
| 67 |
+
|
| 68 |
+
Runs on **ZeroGPU**. The model is small enough for CPU too — switch the Space to `cpu-basic` and
|
| 69 |
+
it still works, just slower.
|
| 70 |
+
|
| 71 |
+
## Configuration
|
| 72 |
+
|
| 73 |
+
| Variable | Purpose |
|
| 74 |
+
|---|---|
|
| 75 |
+
| `MODEL_ID` | Model repo to load (default `oddadmix/Nawah-Math-Reasoning`) |
|
| 76 |
+
| `MODEL_HF_TOKEN` | Only needed if `MODEL_ID` points at a **private** repo. (`HF_TOKEN` is reserved by Spaces and does not reach the container.) |
|
code/space/app.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nawah-Math-Reasoning — Gradio demo.
|
| 3 |
+
|
| 4 |
+
The prompt rendering (ChatML + BOS prepend) is IDENTICAL to train_reasoning.py. A 51M model
|
| 5 |
+
is very sensitive to format drift, so do not change render_prompt() without changing training.
|
| 6 |
+
|
| 7 |
+
Runs on ZeroGPU. The model is only ~52M parameters and works on CPU too, but ZeroGPU keeps
|
| 8 |
+
responses snappy. `import spaces` must come BEFORE torch so it can patch the CUDA calls.
|
| 9 |
+
|
| 10 |
+
The model emits <think>…</think> before its answer, so the stream is split live into two
|
| 11 |
+
panels: the reasoning trace and the final answer.
|
| 12 |
+
|
| 13 |
+
Deploy: push this + requirements.txt + README.md to a Gradio Space. The released model is
|
| 14 |
+
public, so no token is needed; MODEL_HF_TOKEN is still read for pointing MODEL_ID at a private
|
| 15 |
+
checkpoint.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import threading
|
| 21 |
+
|
| 22 |
+
import spaces # import BEFORE torch so it can patch CUDA calls
|
| 23 |
+
import gradio as gr
|
| 24 |
+
import torch
|
| 25 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
|
| 26 |
+
|
| 27 |
+
# ── Config ────────────────────────────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
MODEL_ID = os.environ.get("MODEL_ID", "oddadmix/Nawah-Math-Reasoning")
|
| 30 |
+
# Unused for the public release; needed only if MODEL_ID is repointed at a private repo.
|
| 31 |
+
# HF_TOKEN is reserved by Spaces (a secret set under that name does not reach the container),
|
| 32 |
+
# so MODEL_HF_TOKEN is the one to set.
|
| 33 |
+
HF_TOKEN = os.environ.get("MODEL_HF_TOKEN") or os.environ.get("HF_TOKEN")
|
| 34 |
+
|
| 35 |
+
IM_START, IM_END = "<|im_start|>", "<|im_end|>"
|
| 36 |
+
THINK_OPEN, THINK_CLOSE = "<think>", "</think>"
|
| 37 |
+
MAX_NEW_TOKENS_CAP = 1500
|
| 38 |
+
|
| 39 |
+
# ── Load (once, at startup) ───────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
print("[*] token env vars present:",
|
| 42 |
+
[k for k in ("MODEL_HF_TOKEN", "HF_TOKEN") if os.environ.get(k)] or "NONE")
|
| 43 |
+
print(f"[*] Loading {MODEL_ID} ...")
|
| 44 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)
|
| 45 |
+
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16, token=HF_TOKEN)
|
| 46 |
+
model.to("cuda").eval()
|
| 47 |
+
|
| 48 |
+
CTX = getattr(model.config, "max_position_embeddings", 2048)
|
| 49 |
+
|
| 50 |
+
_eos = {tokenizer.eos_token_id} if tokenizer.eos_token_id is not None else set()
|
| 51 |
+
_im_end = tokenizer.convert_tokens_to_ids(IM_END)
|
| 52 |
+
if isinstance(_im_end, int) and _im_end >= 0:
|
| 53 |
+
_eos.add(_im_end)
|
| 54 |
+
EOS_IDS = list(_eos) or None
|
| 55 |
+
PAD_ID = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
|
| 56 |
+
|
| 57 |
+
print(f"[+] {model.num_parameters():,} params | eos_ids={EOS_IDS} | ctx={CTX}")
|
| 58 |
+
|
| 59 |
+
# ── Prompt rendering — must match train_reasoning.py ──────────────────────────
|
| 60 |
+
|
| 61 |
+
def render_prompt(question: str) -> str:
|
| 62 |
+
# Single-turn only: the model was trained on one user turn per sample, so a chat
|
| 63 |
+
# history would be out of distribution.
|
| 64 |
+
return f"{IM_START}user\n{question.strip()}{IM_END}\n{IM_START}assistant\n"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
STRIP_RE = re.compile(r"<\|im_end\|>|</s>|<pad>|<s>")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def split_stream(text: str):
|
| 71 |
+
"""-> (reasoning_so_far, answer_so_far). Handles the partial state mid-stream."""
|
| 72 |
+
text = STRIP_RE.sub("", text)
|
| 73 |
+
if THINK_CLOSE in text:
|
| 74 |
+
reasoning, answer = text.split(THINK_CLOSE, 1)
|
| 75 |
+
return reasoning.replace(THINK_OPEN, "").strip(), answer.strip()
|
| 76 |
+
return text.replace(THINK_OPEN, "").strip(), ""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ── Generate ──────────────────────────────────────────────────────────────────
|
| 80 |
+
|
| 81 |
+
@spaces.GPU(duration=60)
|
| 82 |
+
def solve(question, max_new_tokens, temperature, repetition_penalty):
|
| 83 |
+
question = (question or "").strip()
|
| 84 |
+
if not question:
|
| 85 |
+
yield "", "", ""
|
| 86 |
+
return
|
| 87 |
+
|
| 88 |
+
ids = tokenizer(render_prompt(question), add_special_tokens=False)["input_ids"]
|
| 89 |
+
if tokenizer.bos_token_id is not None:
|
| 90 |
+
ids = [tokenizer.bos_token_id] + ids # match training's explicit BOS
|
| 91 |
+
input_ids = torch.tensor([ids], device=model.device)
|
| 92 |
+
|
| 93 |
+
# skip_special_tokens must stay False — <think>/</think> are real special tokens
|
| 94 |
+
# in this tokenizer, and stripping them would destroy the split.
|
| 95 |
+
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False)
|
| 96 |
+
|
| 97 |
+
kwargs = dict(
|
| 98 |
+
input_ids=input_ids,
|
| 99 |
+
attention_mask=torch.ones_like(input_ids),
|
| 100 |
+
max_new_tokens=int(max_new_tokens),
|
| 101 |
+
repetition_penalty=float(repetition_penalty),
|
| 102 |
+
eos_token_id=EOS_IDS,
|
| 103 |
+
pad_token_id=PAD_ID,
|
| 104 |
+
streamer=streamer,
|
| 105 |
+
)
|
| 106 |
+
if temperature and temperature > 0:
|
| 107 |
+
kwargs.update(do_sample=True, temperature=float(temperature), top_p=0.95)
|
| 108 |
+
else:
|
| 109 |
+
kwargs.update(do_sample=False) # greedy — how the model was evaluated
|
| 110 |
+
|
| 111 |
+
threading.Thread(target=model.generate, kwargs=kwargs).start()
|
| 112 |
+
|
| 113 |
+
out = ""
|
| 114 |
+
for chunk in streamer:
|
| 115 |
+
out += chunk
|
| 116 |
+
reasoning, answer = split_stream(out)
|
| 117 |
+
yield reasoning, (answer or "…"), out
|
| 118 |
+
|
| 119 |
+
reasoning, answer = split_stream(out)
|
| 120 |
+
if not answer:
|
| 121 |
+
answer = "⚠️ لم يُغلق النموذج وسم التفكير — جرّب سؤالًا أقرب لأمثلة التدريب.\n" \
|
| 122 |
+
"(The model never closed `</think>` — try a question closer to its training distribution.)"
|
| 123 |
+
yield reasoning, answer, out
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ── UI ────────────────────────────────────────────────────────────────────────
|
| 127 |
+
|
| 128 |
+
DESCRIPTION = """
|
| 129 |
+
<div style="text-align:center">
|
| 130 |
+
<h1>🧠 Nawah-Math-Reasoning</h1>
|
| 131 |
+
<p>نموذج استدلال عربي صغير (~52M بارامتر) يفكّر خطوة بخطوة داخل وسم <code><think></code>
|
| 132 |
+
ثم يعطي الإجابة النهائية.<br>
|
| 133 |
+
A ~52M-parameter Arabic reasoning model that thinks step by step inside
|
| 134 |
+
<code><think></code> before answering.</p>
|
| 135 |
+
<p><i>نموذج صغير بما يكفي ليعمل حتى على المعالج (CPU).<br>
|
| 136 |
+
Small enough to run on a CPU — this Space uses ZeroGPU for snappier responses.</i></p>
|
| 137 |
+
<p>
|
| 138 |
+
<a href="https://huggingface.co/oddadmix/Nawah-Math-Reasoning">Model</a> ·
|
| 139 |
+
<a href="https://huggingface.co/oddadmix/Nawah-Math-Reasoning/tree/main/code">Training code</a> ·
|
| 140 |
+
<a href="https://huggingface.co/datasets/oddadmix/arabic-math-reasoning-synth">Synthetic dataset</a> ·
|
| 141 |
+
<a href="https://huggingface.co/datasets/oddadmix/gsm8k-reasoning-ar">GSM8K-ar dataset</a>
|
| 142 |
+
<br><i>Weights, both datasets and the full training code are open — Apache 2.0.</i>
|
| 143 |
+
</p>
|
| 144 |
+
</div>
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
NOTE = """
|
| 148 |
+
### 📊 النتائج / Results
|
| 149 |
+
|
| 150 |
+
Number agreement, greedy decoding, on held-out splits — the same rows for every version of the
|
| 151 |
+
model, so the numbers are comparable.
|
| 152 |
+
|
| 153 |
+
| eval set | n | score |
|
| 154 |
+
|---|---:|---:|
|
| 155 |
+
| GSM8K-ar | 600 | **79.0%** |
|
| 156 |
+
| Arabic_Reasoning | 400 | **73.0%** |
|
| 157 |
+
| synthetic math | 1000 | **40.4%** |
|
| 158 |
+
| synthetic relational (`ضعف`, `نصف`, `أكثر بـ…`) | 400 | **52.2%** |
|
| 159 |
+
|
| 160 |
+
### ⚠️ حدود النموذج / Limitations
|
| 161 |
+
نموذج تجريبي بحجم 52M: يجيد **شكل** الاستدلال العربي ويحلّ مسائل النِّسب والحساب البسيطة،
|
| 162 |
+
لكنه **يخطئ في الحساب كثيرًا** — غالبًا خطوات الحل سليمة ثم تقع غلطة في عملية حسابية واحدة
|
| 163 |
+
ويكمل النموذج على رقمه الخاطئ. الأسئلة المفتوحة وغير الحسابية خارج نطاقه، والحوار متعدد
|
| 164 |
+
الأدوار كذلك.
|
| 165 |
+
|
| 166 |
+
A 52M proof of concept. It reliably produces the *shape* of Arabic step-by-step reasoning, but
|
| 167 |
+
**arithmetic errors are the dominant failure mode**: the derivation is usually structurally
|
| 168 |
+
right, one computation is wrong, and the model then stays faithful to its own bad number. The
|
| 169 |
+
40.4% and 52.2% above are the honest ceiling on multi-step problems. Single-turn only;
|
| 170 |
+
open-ended and non-mathematical questions are out of distribution.
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
EXAMPLES = [
|
| 174 |
+
"إذا كان لديك 1500 ريال وأنفقت 20% منها على الكتب، فكم تبقى معك؟",
|
| 175 |
+
"في مصنع تم إنتاج 5000 وحدة، وكانت نسبة الوحدات المعيبة 2%، فما عدد الوحدات السليمة؟",
|
| 176 |
+
"في مدرسة بها 500 طالب، إذا كانت نسبة الذكور 55%، فما عدد الطالبات؟",
|
| 177 |
+
"لدى تاجر 240 كيلوغرامًا من الأرز، باع منها 35%، فكم كيلوغرامًا تبقى لديه؟",
|
| 178 |
+
"إذا كان عمر أحمد 12 سنة وعمر أخيه ضعف عمره، فما مجموع عمريهما؟",
|
| 179 |
+
"في حديقة 80 حيوانًا، 25% منها طيور، ونصف الطيور بيضاء. كم عدد الطيور البيضاء؟",
|
| 180 |
+
"جمع سامي 45 صدفة، وجمع أخوه ضعف هذا العدد. كم صدفة جمعا معًا؟",
|
| 181 |
+
"لدى ليلى 60 جنيهًا، ولدى ندى أقل منها بـ 18 جنيهًا. كم معهما معًا؟",
|
| 182 |
+
]
|
| 183 |
+
|
| 184 |
+
with gr.Blocks(title="Nawah-Math-Reasoning") as demo:
|
| 185 |
+
gr.HTML(DESCRIPTION)
|
| 186 |
+
|
| 187 |
+
with gr.Row():
|
| 188 |
+
with gr.Column(scale=3):
|
| 189 |
+
question = gr.Textbox(
|
| 190 |
+
label="السؤال / Question", rtl=True, lines=3,
|
| 191 |
+
placeholder="اكتب مسألة حسابية هنا…",
|
| 192 |
+
)
|
| 193 |
+
with gr.Row():
|
| 194 |
+
submit = gr.Button("🧮 حل / Solve", variant="primary")
|
| 195 |
+
clear = gr.Button("مسح / Clear")
|
| 196 |
+
|
| 197 |
+
with gr.Accordion("⚙️ إعدادات التوليد / Generation settings", open=False):
|
| 198 |
+
max_new_tokens = gr.Slider(32, MAX_NEW_TOKENS_CAP, value=320, step=8,
|
| 199 |
+
label="أقصى عدد توكنز / Max new tokens")
|
| 200 |
+
temperature = gr.Slider(0.0, 1.5, value=0.0, step=0.05,
|
| 201 |
+
label="درجة الحرارة / Temperature (0 = greedy, as evaluated)")
|
| 202 |
+
repetition_penalty = gr.Slider(1.0, 1.5, value=1.0, step=0.01,
|
| 203 |
+
label="عقوبة التكرار / Repetition penalty")
|
| 204 |
+
|
| 205 |
+
with gr.Column(scale=4):
|
| 206 |
+
answer_box = gr.Textbox(label="✅ الإجابة / Answer", rtl=True, lines=3)
|
| 207 |
+
with gr.Accordion("🧠 التفكير / Reasoning trace", open=True):
|
| 208 |
+
reasoning_box = gr.Textbox(label="", rtl=True, lines=12)
|
| 209 |
+
with gr.Accordion("🔍 المخرجات الخام / Raw output", open=False):
|
| 210 |
+
raw_box = gr.Textbox(label="", lines=8)
|
| 211 |
+
|
| 212 |
+
gr.Examples(examples=EXAMPLES, inputs=question, label="أمثلة / Examples")
|
| 213 |
+
gr.Markdown(NOTE)
|
| 214 |
+
|
| 215 |
+
inputs = [question, max_new_tokens, temperature, repetition_penalty]
|
| 216 |
+
outputs = [reasoning_box, answer_box, raw_box]
|
| 217 |
+
submit.click(solve, inputs=inputs, outputs=outputs)
|
| 218 |
+
question.submit(solve, inputs=inputs, outputs=outputs)
|
| 219 |
+
clear.click(lambda: ("", "", "", ""), outputs=[question] + outputs)
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
demo.queue().launch(theme=gr.themes.Soft())
|
code/space/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cu128
|
| 2 |
+
spaces
|
| 3 |
+
torch==2.8.0
|
| 4 |
+
transformers>=5.15,<6
|
| 5 |
+
gradio
|
code/split_synth_v6.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Re-split the merged v6 synthetic corpus so v5 -> v6 stays a fair comparison.
|
| 3 |
+
|
| 4 |
+
build_synth_dataset.py shuffles with SEED=42 over whatever rows it is given. The v6 corpus has
|
| 5 |
+
20,139 more rows than the v5 one, so that shuffle lands differently and **1,955 of v5's 2,000
|
| 6 |
+
held-out synth rows fall into v6's train split**. Training on them and then reporting the synth
|
| 7 |
+
cell would be scoring memorisation.
|
| 8 |
+
|
| 9 |
+
So the eval split is not re-drawn, it is *pinned*: `data_synth_sft/eval.jsonl` (v5's rows, in v5's
|
| 10 |
+
order) is copied through verbatim, and every one of those instructions is removed from train. The
|
| 11 |
+
first 1,000 of them are the same rows v4 and v5 were scored on, so the cell stays comparable
|
| 12 |
+
across all three models.
|
| 13 |
+
|
| 14 |
+
A second held-out set, `eval_rel.jsonl`, is carved from the relational pool (task_id >= 1,000,000)
|
| 15 |
+
— the whole point of v6 is a capability v5 lacks, and none of the legacy eval rows test it.
|
| 16 |
+
|
| 17 |
+
Writes data_synth_v6_sft/{train,eval,eval_rel}.jsonl.
|
| 18 |
+
"""
|
| 19 |
+
import json
|
| 20 |
+
import random
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import pyarrow.parquet as pq
|
| 24 |
+
|
| 25 |
+
CORPUS = Path("out_merged_v6/arabic_math_reasoning_synth.parquet")
|
| 26 |
+
LEGACY = Path("data_synth_sft/eval.jsonl") # v5's held-out synth rows — pinned, not redrawn
|
| 27 |
+
OUT = Path("data_synth_v6_sft")
|
| 28 |
+
EVAL_REL = 400
|
| 29 |
+
REL_MIN_TASK_ID = 1_000_000
|
| 30 |
+
SEED = 42
|
| 31 |
+
|
| 32 |
+
FIELDS = ("instruction", "reasoning", "answer")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def sft(row, source="synth_math_ar"):
|
| 36 |
+
return {**{k: row[k] for k in FIELDS}, "source": source}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main():
|
| 40 |
+
rows = pq.read_table(CORPUS).to_pylist()
|
| 41 |
+
by_instruction = {r["instruction"]: r for r in rows}
|
| 42 |
+
print(f"[*] corpus {len(rows):,} rows")
|
| 43 |
+
|
| 44 |
+
legacy = [json.loads(l) for l in open(LEGACY, encoding="utf-8")]
|
| 45 |
+
missing = [r for r in legacy if r["instruction"] not in by_instruction]
|
| 46 |
+
print(f"[*] pinned eval {len(legacy):,} rows, {len(missing)} no longer in the corpus")
|
| 47 |
+
|
| 48 |
+
held = {r["instruction"] for r in legacy}
|
| 49 |
+
|
| 50 |
+
# relational held-out: deterministic sample of the new pool, also excluded from train
|
| 51 |
+
rel = [r for r in rows if r["task_id"] >= REL_MIN_TASK_ID and r["instruction"] not in held]
|
| 52 |
+
rel.sort(key=lambda r: (r["task_id"], r["instruction"])) # parquet order is shuffled
|
| 53 |
+
eval_rel = random.Random(SEED).sample(rel, min(EVAL_REL, len(rel)))
|
| 54 |
+
held |= {r["instruction"] for r in eval_rel}
|
| 55 |
+
print(f"[*] relational rows {len(rel):,}, holding out {len(eval_rel):,}")
|
| 56 |
+
|
| 57 |
+
train = [r for r in rows if r["instruction"] not in held]
|
| 58 |
+
n_rel_train = sum(1 for r in train if r["task_id"] >= REL_MIN_TASK_ID)
|
| 59 |
+
|
| 60 |
+
OUT.mkdir(exist_ok=True)
|
| 61 |
+
for name, split in (("train", [sft(r) for r in train]),
|
| 62 |
+
("eval", legacy), # verbatim, v5's order
|
| 63 |
+
("eval_rel", [sft(r) for r in eval_rel])):
|
| 64 |
+
with open(OUT / f"{name}.jsonl", "w", encoding="utf-8") as fh:
|
| 65 |
+
for r in split:
|
| 66 |
+
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 67 |
+
print(f"[+] {name}: {len(split):,} -> {OUT / f'{name}.jsonl'}")
|
| 68 |
+
print(f"[*] train carries {n_rel_train:,} relational rows ({n_rel_train/len(train):.1%})")
|
| 69 |
+
|
| 70 |
+
leak = sum(1 for r in train if r["instruction"] in held)
|
| 71 |
+
print(f"[{'+' if leak == 0 else '!'}] contamination check: {leak} held-out rows in train")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
main()
|
code/synth_common.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared pieces for the synthetic Arabic math-reasoning corpus.
|
| 3 |
+
|
| 4 |
+
Holds everything both the generator and the dataset builder need, so the accept/reject decision
|
| 5 |
+
is made by exactly one implementation:
|
| 6 |
+
|
| 7 |
+
* AXES — the variation grid every prompt is drawn from
|
| 8 |
+
* build_task — task_id -> (axes draw, prompt). Deterministic: task 7 always has the same draw,
|
| 9 |
+
which is what makes the run resumable and reproducible.
|
| 10 |
+
* parse_items — split a raw completion into {instruction, reasoning, answer} records
|
| 11 |
+
* audit — verify the arithmetic *inside* the reasoning and reject rows that don't check out
|
| 12 |
+
|
| 13 |
+
Why the audit matters: a 12B model writes fluent Arabic derivations that are wrong roughly one
|
| 14 |
+
time in ten. The reasoning states its own equations ("35 × 4 = 140"), so they can be re-evaluated
|
| 15 |
+
exactly. This is the same discipline as the numeral audit in build_dataset.py, applied to
|
| 16 |
+
generated arithmetic rather than translated arithmetic.
|
| 17 |
+
"""
|
| 18 |
+
import random
|
| 19 |
+
import re
|
| 20 |
+
import unicodedata
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------- variation grid
|
| 23 |
+
|
| 24 |
+
DOMAINS = [
|
| 25 |
+
"التسوق وشراء البقالة", "الرواتب والأجور اليومية", "المسافات والسفر بالسيارة",
|
| 26 |
+
"وصفات الطبخ والمقادير", "المدرسة والطلاب والدرجات", "المزرعة والمحاصيل والحيوانات",
|
| 27 |
+
"الرياضة والمباريات والنقاط", "الادخار والميزانية الشهرية", "البناء والدهان والبلاط",
|
| 28 |
+
"فواتير الهاتف والإنترنت", "الوقود واستهلاك السيارة", "السوق وبيع الخضار والفواكه",
|
| 29 |
+
"المكتبة وشراء الكتب", "ورشة الخياطة والأقمشة", "توصيل الطلبات والمندوبين",
|
| 30 |
+
"المستشفى والجرعات والمواعيد", "الحديقة والأشجار والري", "المخبز وإنتاج الخبز",
|
| 31 |
+
"محل الحلويات والطلبيات", "النقل المدرسي والحافلات", "تربية الدواجن والبيض",
|
| 32 |
+
"مصنع صغير وخط الإنتاج", "الرحلات المدرسية والتذاكر", "تأجير المعدات باليوم",
|
| 33 |
+
"صيد السمك وبيعه", "محطة تحلية ومياه الشرب", "متجر إلكتروني والشحن",
|
| 34 |
+
"معرض السيارات والأقساط", "الزكاة وتوزيع الصدقات", "تنظيم حفل وعدد الضيوف",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
OPERATIONS = [
|
| 38 |
+
("الجمع والطرح", "خطوتان من الجمع والطرح على أعداد صحيحة"),
|
| 39 |
+
("الضرب والقسمة", "الضرب ثم القسمة للوصول إلى نصيب الواحد"),
|
| 40 |
+
("النسبة المئوية", "حساب نسبة مئوية من مبلغ ثم الزيادة أو النقصان"),
|
| 41 |
+
("الكسور", "أخذ كسر من كمية ثم التعامل مع الباقي"),
|
| 42 |
+
("النسبة والتناسب", "توزيع كمية بنسبة معلومة بين طرفين أو ثلاثة"),
|
| 43 |
+
("سعر الوحدة", "استنتاج سعر الوحدة ثم حساب تكلفة كمية مختلفة"),
|
| 44 |
+
("المتوسط الحسابي", "حساب متوسط مجموعة قيم ثم استنتاج قيمة ناقصة"),
|
| 45 |
+
("السرعة والزمن والمسافة", "علاقة السرعة بالزمن والمسافة على مرحلتين"),
|
| 46 |
+
("المساحة والمحيط", "مساحة أو محيط شكل مستطيل ثم تكلفة تغطيته"),
|
| 47 |
+
("الربح والخسارة", "شراء بسعر وبيع بسعر آخر وحساب الربح أو نسبته"),
|
| 48 |
+
("الخصم والضريبة", "خصم على السعر ثم إضافة ضريبة القيمة المضافة"),
|
| 49 |
+
("العمل والزمن", "عاملان أو ثلاثة ينجزون عملاً في أزمنة مختلفة"),
|
| 50 |
+
("تحويل الوحدات", "تحويل بين الوحدات (كجم/جرام، ساعة/دقيقة، متر/سم) ضمن الحل"),
|
| 51 |
+
("المتتاليات البسيطة", "نمط يزيد بمقدار ثابت كل يوم أو كل أسبوع"),
|
| 52 |
+
("المعادلة ذات المجهول الواحد", "صياغة معادلة بسيطة بمجهول واحد وحلها"),
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
# --- relational comparison pool -------------------------------------------------------------
|
| 56 |
+
# A SEPARATE pool, not appended to OPERATIONS, so build_task(N) stays byte-identical for the task
|
| 57 |
+
# ids that produced the original 100,323-row corpus — that corpus must remain reproducible.
|
| 58 |
+
#
|
| 59 |
+
# Why this exists: v5 scores pass@8 = 0 on prompts like "عمر أخيه ضعف عمره" because the grid never
|
| 60 |
+
# generated the relation. Measured coverage in the 100k corpus: ضعف/أضعاف 1.34%, أكثر بـ/أقل بـ
|
| 61 |
+
# 0.26%. RL cannot repair that class (no correct sample in the group -> no gradient), so it has to
|
| 62 |
+
# come from data.
|
| 63 |
+
#
|
| 64 |
+
# The defining trait: one quantity is stated ONLY in terms of another, so the solver must compute
|
| 65 |
+
# the second quantity before it can do anything else. That first step is exactly what v5 skips.
|
| 66 |
+
RELATIONAL_OPS = [
|
| 67 |
+
("المقارنة بالمضاعفة", "كمية ثانية تساوي ضعف الأولى، ثم يُطلب المجموع أو الفرق"),
|
| 68 |
+
("المقارنة بالتضعيف المتعدد", "كمية ثانية تساوي ثلاثة أو أربعة أضعاف الأولى، ثم المجموع"),
|
| 69 |
+
("المقارنة بالتنصيف", "كمية ثانية تساوي نصف الأولى، ثم المجموع أو الباقي"),
|
| 70 |
+
("المقارنة بالكسر", "كمية ثانية تساوي ثلث أو ربع الأولى، ثم المجموع"),
|
| 71 |
+
("المقارنة بالزيادة المطلقة", "كمية ثانية تزيد عن الأولى بمقدار ثابت، ثم المجموع"),
|
| 72 |
+
("المقارنة بالنقصان المطلق", "كمية ثانية تقل عن الأولى بمقدار ثابت، ثم المجموع"),
|
| 73 |
+
("المقارنة بين ثلاث كميات", "ثلاث كميات كل واحدة معرّفة بدلالة سابقتها، ثم المجموع"),
|
| 74 |
+
("المقارنة العكسية", "تُعطى الكمية الثانية وعلاقتها بالأولى، والمطلوب الأولى ثم المجموع"),
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
# Relational problems are naturally short — a 5-step chain forces padding with filler steps.
|
| 78 |
+
RELATIONAL_STEPS = [(2, "خطوتين"), (2, "خطوتين"), (3, "ثلاث خطوات")]
|
| 79 |
+
|
| 80 |
+
STEPS = [(2, "خطوتين"), (3, "ثلاث خطوات"), (3, "ثلاث خطوات"), (4, "أربع خطوات")]
|
| 81 |
+
|
| 82 |
+
NUMBER_STYLE = [
|
| 83 |
+
"أعداد صحيحة صغيرة (أقل من 100)",
|
| 84 |
+
"أعداد صحيحة متوسطة (بين 100 و 5000)",
|
| 85 |
+
"مبالغ مالية تحتوي على كسور عشرية بمنزلتين",
|
| 86 |
+
"أعداد كبيرة نسبيًا (بالآلاف) تقبل القسمة بدون باقٍ",
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
REGIONS = [
|
| 90 |
+
("السعودية", "ريال"), ("مصر", "جنيه"), ("الإمارات", "درهم"), ("الأردن", "دينار"),
|
| 91 |
+
("المغرب", "درهم"), ("الكويت", "دينار"), ("تونس", "دينار"), ("العراق", "دينار"),
|
| 92 |
+
("سوريا", "ليرة"), ("عُمان", "ريال"), ("قطر", "ريال"), ("الجزائر", "دينار"),
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
NAMES = [
|
| 96 |
+
"أحمد", "فاطمة", "محمد", "سارة", "خالد", "نور", "يوسف", "مريم", "عمر", "ليلى",
|
| 97 |
+
"سلمان", "هند", "طارق", "رنا", "بلال", "أسماء", "زياد", "دعاء", "كريم", "شيماء",
|
| 98 |
+
"ياسر", "بثينة", "حسن", "رغد", "إبراهيم", "جنى", "مصطفى", "لمى", "سعيد", "أروى",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
TWISTS = [
|
| 102 |
+
"أضف رقمًا واحدًا في نص المسألة لا يُستخدم في الحل (معلومة زائدة)",
|
| 103 |
+
"اجعل المسألة تقارن بين شخصين أو بين يومين",
|
| 104 |
+
"اجعل السؤال يطلب الباقي أو المتبقي وليس المجموع",
|
| 105 |
+
"اجعل السؤال يطلب عدد المرات أو عدد المجموعات",
|
| 106 |
+
"اجعل المسألة على مرحلتين زمنيتين (اليوم الأول ثم اليوم الثاني)",
|
| 107 |
+
"اجعل الإجابة النهائية تحتاج تقريبًا لأقرب عدد صحيح مع توضيح سبب التقريب",
|
| 108 |
+
"لا تضف أي تعقيد إضافي، اجعلها مباشرة وواضحة",
|
| 109 |
+
"اجعل السؤال يطلب النسبة المئوية للنتيجة من الإجمالي",
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
ITEMS_PER_TASK = 4
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------- prompt
|
| 115 |
+
|
| 116 |
+
Q, T, A, E = "### مسألة", "### تفكير", "### الإجابة", "### نهاية"
|
| 117 |
+
|
| 118 |
+
PROMPT = """أنت معلم رياضيات عربي تكتب مسائل تدريبية عالية الجودة باللغة العربية الفصحى.
|
| 119 |
+
|
| 120 |
+
اكتب {k} مسائل حسابية **مختلفة تمامًا عن بعضها** بالمواصفات التالية:
|
| 121 |
+
|
| 122 |
+
- المجال: {domain}
|
| 123 |
+
- نوع العملية: {op_name} — {op_hint}
|
| 124 |
+
- عدد خطوات الحل: {steps_word} تقريبًا (اكتب فقط الخطوات التي يحتاجها الحل فعلًا)
|
| 125 |
+
- طبيعة الأرقام: {numbers}
|
| 126 |
+
- السياق: {region}، والعملة {currency}
|
| 127 |
+
- استخدم أسماء مثل: {names}
|
| 128 |
+
- {twist}
|
| 129 |
+
|
| 130 |
+
اكتب كل مسألة بهذا الشكل بالضبط، ولا تكتب أي شيء آخر خارج هذه الوسوم:
|
| 131 |
+
|
| 132 |
+
{Q}
|
| 133 |
+
نص المسألة في جملة أو جملتين، وينتهي بسؤال واضح.
|
| 134 |
+
{T}
|
| 135 |
+
خطوات الحل مرقمة، وكل خطوة تحتوي على معادلة صريحة بالأرقام مثل: 45 × 3 = 135
|
| 136 |
+
{A}
|
| 137 |
+
إذن، جملة واحدة تذكر الإجابة النهائية بالرقم مع وحدتها.
|
| 138 |
+
{E}
|
| 139 |
+
|
| 140 |
+
قواعد إلزامية:
|
| 141 |
+
1. كل معادلة تكتبها يجب أن تكون **صحيحة حسابيًا**. تحقق من كل عملية قبل كتابتها.
|
| 142 |
+
2. الرقم في سطر الإجابة يجب أن يساوي ناتج آخر خطوة في التفكير.
|
| 143 |
+
3. اكتب الأرقام بالأرقام الإنجليزية (0-9) وليس بالحروف ولا بالأرقام الهندية.
|
| 144 |
+
4. اكتب بالعربية فقط، بدون أي كلمة إنجليزية.
|
| 145 |
+
5. لا تكرر مسألة سبق أن كتبتها في هذه الإجابة.
|
| 146 |
+
6. لا تكتب خطوات فارغة أو بلا فائدة مثل «63 + 0 = 63» أو «4500 = 4500» أو «24 × 1 = 24».
|
| 147 |
+
كل خطوة يجب أن تُنتج قيمة جديدة لم تكن معروفة قبلها.
|
| 148 |
+
7. إذا لم تناسب العملية المطلوبة المجالَ المطلوب، غيّر تفاصيل المسألة لتناسبها من البداية،
|
| 149 |
+
ولا تعلّق على ذلك ولا تعِد صياغة المسألة داخل خطوات الحل.
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_task(task_id: int, seed: int = 1234, pool: str = "default"):
|
| 154 |
+
"""task_id -> (axes, prompt). Deterministic, so a resumed run redraws identical prompts.
|
| 155 |
+
|
| 156 |
+
pool="default" the original 15-operation grid — draws are byte-identical to the run that
|
| 157 |
+
produced the 100,323-row corpus, so that corpus stays reproducible.
|
| 158 |
+
pool="relational" the relational-comparison pool (RELATIONAL_OPS). Use a disjoint task-id
|
| 159 |
+
range for it, the same rule that keeps two generating nodes from colliding.
|
| 160 |
+
"""
|
| 161 |
+
ops, steps_pool = (RELATIONAL_OPS, RELATIONAL_STEPS) if pool == "relational" else (OPERATIONS, STEPS)
|
| 162 |
+
rng = random.Random(seed * 1_000_003 + task_id)
|
| 163 |
+
domain = rng.choice(DOMAINS)
|
| 164 |
+
op_name, op_hint = rng.choice(ops)
|
| 165 |
+
_, steps_word = rng.choice(steps_pool)
|
| 166 |
+
numbers = rng.choice(NUMBER_STYLE)
|
| 167 |
+
region, currency = rng.choice(REGIONS)
|
| 168 |
+
names = "، ".join(rng.sample(NAMES, 3))
|
| 169 |
+
twist = rng.choice(TWISTS)
|
| 170 |
+
axes = {"domain": domain, "op": op_name, "steps": steps_word, "numbers": numbers,
|
| 171 |
+
"region": region, "twist": twist}
|
| 172 |
+
prompt = PROMPT.format(k=ITEMS_PER_TASK, domain=domain, op_name=op_name, op_hint=op_hint,
|
| 173 |
+
steps_word=steps_word, numbers=numbers, region=region,
|
| 174 |
+
currency=currency, names=names, twist=twist, Q=Q, T=T, A=A, E=E)
|
| 175 |
+
return axes, prompt
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---------------------------------------------------------------- parsing
|
| 179 |
+
|
| 180 |
+
AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩٪", "0123456789%")
|
| 181 |
+
BLOCK_RE = re.compile(
|
| 182 |
+
re.escape(Q) + r"(?P<q>.*?)" + re.escape(T) + r"(?P<t>.*?)" + re.escape(A) + r"(?P<a>.*?)"
|
| 183 |
+
+ r"(?:" + re.escape(E) + r"|$)", re.S)
|
| 184 |
+
LATIN_RE = re.compile(r"[A-Za-z]")
|
| 185 |
+
CONCLUSION = ("إذن", "لذلك", "بالتالي", "وبالتالي", "لذا", "في النهاية", "الخلاصة")
|
| 186 |
+
# The model sometimes breaks frame and rewrites the problem inside its own derivation
|
| 187 |
+
# ("*تصحيح للمسألة لتناسب العملية المطلوبة*") when a drawn operation type does not fit the drawn
|
| 188 |
+
# domain. The arithmetic in those rows often still checks out, so the audit alone won't catch them.
|
| 189 |
+
META_MARKERS = ("تصحيح للمسألة", "سنعيد صياغة", "لتناسب العملية", "المسألة تطلب",
|
| 190 |
+
"إعادة صياغة", "لنفترض أن المسألة", "تصحيح السؤال", "بما أن المسألة")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def norm(text: str) -> str:
|
| 194 |
+
text = unicodedata.normalize("NFC", text.replace("", "").replace("", ""))
|
| 195 |
+
text = text.translate(AR_DIGITS)
|
| 196 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 197 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 198 |
+
return text.strip()
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def parse_items(raw: str):
|
| 202 |
+
"""Raw completion -> list of {instruction, reasoning, answer}. Malformed blocks are skipped."""
|
| 203 |
+
out = []
|
| 204 |
+
for m in BLOCK_RE.finditer(raw):
|
| 205 |
+
q, t, a = (norm(m.group(g)) for g in ("q", "t", "a"))
|
| 206 |
+
if q and t and a:
|
| 207 |
+
out.append({"instruction": q, "reasoning": t, "answer": a})
|
| 208 |
+
return out
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ---------------------------------------------------------------- arithmetic audit
|
| 212 |
+
|
| 213 |
+
NUM = r"\d+(?:\.\d+)?"
|
| 214 |
+
# "x + 0 = x", "x × 1 = x", "x = x" — vacuous steps the model emits to hit a requested step count.
|
| 215 |
+
NOOP_RE = re.compile(r"(?<![\d.])(" + NUM + r")\s*(?:[+\-−–]\s*0+(?:\.0+)?|"
|
| 216 |
+
r"[×xX*]\s*1(?:\.0+)?|[÷/]\s*1(?:\.0+)?)?\s*=\s*\1(?![\d.])")
|
| 217 |
+
|
| 218 |
+
# One arithmetic segment: digits and operators only, at least one digit. Deliberately excludes
|
| 219 |
+
# newlines — a chain never spans two steps.
|
| 220 |
+
_SC = r"[\d.()+\-−–×xX*÷/ \t]"
|
| 221 |
+
SEG = _SC + r"*\d" + _SC + r"*"
|
| 222 |
+
# "1." / "2)" list numbering at the head of a step is not part of the arithmetic; left in, it
|
| 223 |
+
# turns "1. 75 + 15" into the un-evaluatable "1. 75 + 15".
|
| 224 |
+
LIST_NUM_RE = re.compile(r"^[ \t]*\d+[.)][ \t]*", re.M)
|
| 225 |
+
# A *chain*: "a × b = c + d = e". Models routinely show their working this way, and reading only
|
| 226 |
+
# the first "=" turns a correct chain into a false rejection — every segment must be compared.
|
| 227 |
+
CHAIN_RE = re.compile(r"(?<![\d.])(" + SEG + r"(?:=" + SEG + r")+)")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _evaluate(expr: str):
|
| 231 |
+
"""Evaluate a pure-arithmetic expression with normal precedence. None if it isn't one."""
|
| 232 |
+
expr = expr.replace("−", "-").replace("–", "-").replace("×", "*").replace("x", "*") \
|
| 233 |
+
.replace("X", "*").replace("÷", "/")
|
| 234 |
+
expr = expr.strip()
|
| 235 |
+
if not expr or not re.fullmatch(r"[\d.+\-*/() ]+", expr) or not re.search(r"\d", expr):
|
| 236 |
+
return None
|
| 237 |
+
try:
|
| 238 |
+
# The regex above admits only digits, operators, dots, parens and spaces — no names,
|
| 239 |
+
# no calls, no attribute access — so this cannot execute anything else.
|
| 240 |
+
value = eval(expr, {"__builtins__": {}}, {}) # noqa: S307
|
| 241 |
+
except Exception:
|
| 242 |
+
return None
|
| 243 |
+
return value if isinstance(value, (int, float)) else None
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _decimals(text: str) -> int:
|
| 247 |
+
text = text.strip()
|
| 248 |
+
return len(text.split(".")[1]) if "." in text else 0
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _close(a, b, shown: str | None = None):
|
| 252 |
+
"""
|
| 253 |
+
Is the stated value `b` an acceptable rendering of the true value `a`?
|
| 254 |
+
|
| 255 |
+
Exact, or `b` is `a` rounded to the precision `b` is written with — "3200 / 60 = 53.33" and
|
| 256 |
+
"9.44 × 60 = 566.4 → 566" are how people write arithmetic, not arithmetic errors. Only
|
| 257 |
+
rounding at the displayed precision is forgiven, so a genuinely wrong number still fails.
|
| 258 |
+
"""
|
| 259 |
+
if abs(a - b) <= max(1e-6, abs(b) * 1e-9):
|
| 260 |
+
return True
|
| 261 |
+
if shown is not None:
|
| 262 |
+
d = _decimals(shown)
|
| 263 |
+
if abs(round(a, d) - b) <= 1e-9:
|
| 264 |
+
return True
|
| 265 |
+
if d == 0 and b in (int(a), int(a) + 1 if a > 0 else int(a) - 1):
|
| 266 |
+
return True # a step that floors or ceils, e.g. "3 crates with a remainder"
|
| 267 |
+
return False
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def audit(reasoning: str, answer: str, min_equations: int = 1):
|
| 271 |
+
"""
|
| 272 |
+
-> (ok, reason, n_checked)
|
| 273 |
+
|
| 274 |
+
Every "a op b = c [= d ...]" chain the reasoning states is re-evaluated end to end. One
|
| 275 |
+
segment that disagrees rejects the row. The answer's number must also match the last chain's
|
| 276 |
+
final value — a derivation that is internally correct but ends on a different number is the
|
| 277 |
+
failure mode that survives any fluency check.
|
| 278 |
+
"""
|
| 279 |
+
checked = 0
|
| 280 |
+
last = None
|
| 281 |
+
body = LIST_NUM_RE.sub("", reasoning)
|
| 282 |
+
for m in CHAIN_RE.finditer(body):
|
| 283 |
+
parts = m.group(1).split("=")
|
| 284 |
+
values = [_evaluate(part) for part in parts]
|
| 285 |
+
if any(v is None for v in values) or len(values) < 2:
|
| 286 |
+
continue # not pure arithmetic (units, words, %) — not our call
|
| 287 |
+
checked += 1
|
| 288 |
+
head = values[0]
|
| 289 |
+
for part, v in zip(parts[1:], values[1:]):
|
| 290 |
+
# Compare each stated value against the *first* segment, which is the one written in
|
| 291 |
+
# full precision; `part` carries how many decimals the model chose to show.
|
| 292 |
+
if not _close(head, v, part):
|
| 293 |
+
return False, f"bad_equation:{m.group(1).strip()[:60]}", checked
|
| 294 |
+
last = values[-1]
|
| 295 |
+
if checked < min_equations:
|
| 296 |
+
return False, "no_equations", checked
|
| 297 |
+
|
| 298 |
+
ans_shown = re.findall(NUM, answer)
|
| 299 |
+
if not ans_shown:
|
| 300 |
+
return False, "answer_has_no_number", checked
|
| 301 |
+
if last is not None and not any(_close(last, float(x), x) for x in ans_shown):
|
| 302 |
+
return False, "answer_not_last_result", checked
|
| 303 |
+
return True, "ok", checked
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def validate(item, min_reasoning=40, max_reasoning=1200):
|
| 307 |
+
"""Full accept/reject for one parsed item -> (ok, reason)."""
|
| 308 |
+
q, t, a = item["instruction"], item["reasoning"], item["answer"]
|
| 309 |
+
if LATIN_RE.search(q) or LATIN_RE.search(t) or LATIN_RE.search(a):
|
| 310 |
+
return False, "latin_residue"
|
| 311 |
+
if not (20 <= len(q) <= 600):
|
| 312 |
+
return False, "question_length"
|
| 313 |
+
if not (min_reasoning <= len(t) <= max_reasoning):
|
| 314 |
+
return False, "reasoning_length"
|
| 315 |
+
if not (10 <= len(a) <= 300):
|
| 316 |
+
return False, "answer_length"
|
| 317 |
+
if not a.lstrip("*-# ").startswith(CONCLUSION):
|
| 318 |
+
return False, "no_conclusion_marker"
|
| 319 |
+
if any(mark in t for mark in (Q, A, E)):
|
| 320 |
+
return False, "tag_leak"
|
| 321 |
+
if any(mark in t or mark in q for mark in META_MARKERS):
|
| 322 |
+
return False, "meta_commentary"
|
| 323 |
+
if NOOP_RE.search(t):
|
| 324 |
+
return False, "noop_step"
|
| 325 |
+
ok, reason, _ = audit(t, a)
|
| 326 |
+
return ok, reason
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def dedup_key(instruction: str) -> str:
|
| 330 |
+
"""Numbers masked out, so the same template with different values collapses to one key."""
|
| 331 |
+
return re.sub(r"\d+(?:\.\d+)?", "#", re.sub(r"\W+", "", instruction))
|
code/synth_generate.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate the synthetic Arabic math-reasoning corpus with google/gemma-4-12B-it.
|
| 3 |
+
|
| 4 |
+
Resumable by construction:
|
| 5 |
+
|
| 6 |
+
* A "task" is one generation call that asks for ITEMS_PER_TASK problems. Task N's prompt is a
|
| 7 |
+
pure function of N (synth_common.build_task), so nothing about the plan is stored — restart
|
| 8 |
+
the script and it redraws the identical prompts.
|
| 9 |
+
* Every completed task is appended to out_synth/generations.jsonl as one line and fsynced.
|
| 10 |
+
On start the file is replayed, completed task_ids are skipped, and a torn final line (a
|
| 11 |
+
machine that died mid-write) is dropped. Worst case a crash costs one in-flight batch.
|
| 12 |
+
* The stop condition is *accepted rows*, not tasks: each cached generation is re-validated on
|
| 13 |
+
load, so a resumed run knows how many good rows it already has and issues only what's missing.
|
| 14 |
+
|
| 15 |
+
Backends:
|
| 16 |
+
BACKEND=hf transformers batched generation (default, works here)
|
| 17 |
+
BACKEND=vllm vLLM continuous batching (see the note below)
|
| 18 |
+
|
| 19 |
+
vLLM cannot serve Gemma 4 on this box: every vLLM release that knows the gemma4 architecture
|
| 20 |
+
(>= 0.20.2) pins torch 2.11, which is a CUDA 13 build, and this host's driver (550 / CUDA 12.4)
|
| 21 |
+
caps at CUDA 12.x — vllm imports die on `libcudart.so.13`. The vLLM path is kept working for a
|
| 22 |
+
box with driver >= 580, or for a gemma3/Qwen generator on the pinned .venv-vllm stack.
|
| 23 |
+
|
| 24 |
+
Usage:
|
| 25 |
+
P=/notebooks/50M/.venv-lfm2/bin/python
|
| 26 |
+
TARGET=100000 $P -u synth_generate.py
|
| 27 |
+
"""
|
| 28 |
+
import json
|
| 29 |
+
import os
|
| 30 |
+
import sys
|
| 31 |
+
import time
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
import synth_common as sc
|
| 35 |
+
|
| 36 |
+
MODEL_DIR = os.environ.get("GEN_MODEL", "./models/gemma-4-12B-it")
|
| 37 |
+
OUT_DIR = Path(os.environ.get("OUT_DIR", "out_synth"))
|
| 38 |
+
CACHE = OUT_DIR / "generations.jsonl"
|
| 39 |
+
TARGET = int(os.environ.get("TARGET", 100_000))
|
| 40 |
+
BATCH = int(os.environ.get("BATCH", 32))
|
| 41 |
+
MAX_NEW = int(os.environ.get("MAX_NEW", 1400))
|
| 42 |
+
TEMPERATURE = float(os.environ.get("TEMPERATURE", 0.9))
|
| 43 |
+
TOP_P = float(os.environ.get("TOP_P", 0.95))
|
| 44 |
+
SEED = int(os.environ.get("SEED", 1234))
|
| 45 |
+
BACKEND = os.environ.get("BACKEND", "hf")
|
| 46 |
+
# Recorded on every cached row so a merged multi-node corpus stays attributable.
|
| 47 |
+
MODEL_NAME = os.environ.get("GEN_MODEL_NAME", os.path.basename(MODEL_DIR.rstrip("/")))
|
| 48 |
+
# Multimodal wrappers can default to eager attention, which is several times slower to decode.
|
| 49 |
+
ATTN = os.environ.get("ATTN", "sdpa")
|
| 50 |
+
MAX_TASKS = int(os.environ.get("MAX_TASKS", 400_000))
|
| 51 |
+
# Two machines generating at once must own disjoint task-id ranges: build_task() is a pure
|
| 52 |
+
# function of the id, so overlapping ranges redraw byte-identical prompts and every row the
|
| 53 |
+
# second machine produces dies as a duplicate template in build_synth_dataset.py. START_TASK
|
| 54 |
+
# offsets this node's range; MAX_TASKS is counted from there, not from zero.
|
| 55 |
+
START_TASK = int(os.environ.get("START_TASK", 0))
|
| 56 |
+
# Which slice of the variation grid to draw from. "default" is the original 15-operation grid;
|
| 57 |
+
# "relational" is the comparison pool (x = 2y, x = y/2, x = y + n ...) that the default grid never
|
| 58 |
+
# produced. Use a disjoint START_TASK for a relational run — same rule as two nodes not colliding.
|
| 59 |
+
POOL = os.environ.get("POOL", "default")
|
| 60 |
+
# Qwen3-style templates open a <think> block in the generation prompt unless this is passed,
|
| 61 |
+
# and the model then spends the whole MAX_NEW budget reasoning before it ever emits a tag.
|
| 62 |
+
CHAT_KWARGS = {"enable_thinking": False} if os.environ.get("NO_THINK", "0") == "1" else {}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def load_cache():
|
| 66 |
+
"""-> (set of finished task_ids, accepted row count). Tolerates a truncated final line."""
|
| 67 |
+
done, accepted = set(), 0
|
| 68 |
+
if not CACHE.exists():
|
| 69 |
+
return done, accepted
|
| 70 |
+
with open(CACHE, encoding="utf-8") as fh:
|
| 71 |
+
for line in fh:
|
| 72 |
+
try:
|
| 73 |
+
rec = json.loads(line)
|
| 74 |
+
except json.JSONDecodeError:
|
| 75 |
+
print("[!] dropping truncated final line of the cache")
|
| 76 |
+
continue
|
| 77 |
+
done.add(rec["task_id"])
|
| 78 |
+
for item in sc.parse_items(rec["raw"]):
|
| 79 |
+
ok, _ = sc.validate(item)
|
| 80 |
+
accepted += ok
|
| 81 |
+
return done, accepted
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class HFBackend:
|
| 85 |
+
"""Batched transformers generation. No paged attention, but B sequences decode in parallel."""
|
| 86 |
+
|
| 87 |
+
def __init__(self):
|
| 88 |
+
import torch
|
| 89 |
+
import transformers
|
| 90 |
+
from transformers import AutoProcessor, AutoTokenizer
|
| 91 |
+
|
| 92 |
+
self.torch = torch
|
| 93 |
+
try:
|
| 94 |
+
self.tok = AutoProcessor.from_pretrained(MODEL_DIR).tokenizer
|
| 95 |
+
except Exception:
|
| 96 |
+
self.tok = AutoTokenizer.from_pretrained(MODEL_DIR)
|
| 97 |
+
self.tok.padding_side = "left"
|
| 98 |
+
if self.tok.pad_token_id is None:
|
| 99 |
+
self.tok.pad_token = self.tok.eos_token
|
| 100 |
+
print(f"[*] loading {MODEL_DIR} (bf16)", flush=True)
|
| 101 |
+
# gemma-4-*-it is a unified multimodal checkpoint, so the plain causal-LM auto class does
|
| 102 |
+
# not always claim it. Try the multimodal auto classes first and fall back.
|
| 103 |
+
self.model, last = None, None
|
| 104 |
+
for name in ("AutoModelForMultimodalLM", "AutoModelForImageTextToText",
|
| 105 |
+
"AutoModelForCausalLM"):
|
| 106 |
+
cls = getattr(transformers, name, None)
|
| 107 |
+
if cls is None:
|
| 108 |
+
continue
|
| 109 |
+
try:
|
| 110 |
+
self.model = cls.from_pretrained(
|
| 111 |
+
MODEL_DIR, dtype=torch.bfloat16, device_map="cuda:0",
|
| 112 |
+
attn_implementation=ATTN).eval()
|
| 113 |
+
print(f" loaded via {name}", flush=True)
|
| 114 |
+
break
|
| 115 |
+
except Exception as exc: # noqa: BLE001 - report the last failure
|
| 116 |
+
last = f"{name}: {exc}"
|
| 117 |
+
if self.model is None:
|
| 118 |
+
raise RuntimeError(f"could not load {MODEL_DIR}; last error -> {last}")
|
| 119 |
+
self.model.config.use_cache = True
|
| 120 |
+
|
| 121 |
+
def generate(self, prompts):
|
| 122 |
+
texts = [self.tok.apply_chat_template([{"role": "user", "content": p}],
|
| 123 |
+
tokenize=False, add_generation_prompt=True,
|
| 124 |
+
**CHAT_KWARGS)
|
| 125 |
+
for p in prompts]
|
| 126 |
+
enc = self.tok(texts, return_tensors="pt", padding=True, add_special_tokens=False).to("cuda:0")
|
| 127 |
+
with self.torch.no_grad():
|
| 128 |
+
out = self.model.generate(**enc, max_new_tokens=MAX_NEW, do_sample=True,
|
| 129 |
+
temperature=TEMPERATURE, top_p=TOP_P,
|
| 130 |
+
pad_token_id=self.tok.pad_token_id)
|
| 131 |
+
width = enc["input_ids"].shape[1]
|
| 132 |
+
return [self.tok.decode(seq[width:], skip_special_tokens=True) for seq in out]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class VLLMBackend:
|
| 136 |
+
def __init__(self):
|
| 137 |
+
from vllm import LLM, SamplingParams
|
| 138 |
+
|
| 139 |
+
kw = {}
|
| 140 |
+
if os.environ.get("QUANT"): # e.g. QUANT=modelopt for NVFP4 checkpoints
|
| 141 |
+
kw["quantization"] = os.environ["QUANT"]
|
| 142 |
+
self.llm = LLM(model=MODEL_DIR, dtype=os.environ.get("DTYPE", "bfloat16"),
|
| 143 |
+
max_num_seqs=BATCH,
|
| 144 |
+
gpu_memory_utilization=float(os.environ.get("GPU_UTIL", 0.90)),
|
| 145 |
+
max_model_len=int(os.environ.get("MAX_LEN", 4096)), **kw)
|
| 146 |
+
self.params = SamplingParams(temperature=TEMPERATURE, top_p=TOP_P, max_tokens=MAX_NEW,
|
| 147 |
+
seed=None)
|
| 148 |
+
self.tok = self.llm.get_tokenizer()
|
| 149 |
+
|
| 150 |
+
def generate(self, prompts):
|
| 151 |
+
texts = [self.tok.apply_chat_template([{"role": "user", "content": p}],
|
| 152 |
+
tokenize=False, add_generation_prompt=True,
|
| 153 |
+
**CHAT_KWARGS)
|
| 154 |
+
for p in prompts]
|
| 155 |
+
outs = self.llm.generate(texts, self.params)
|
| 156 |
+
return [o.outputs[0].text for o in outs]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def main():
|
| 160 |
+
OUT_DIR.mkdir(exist_ok=True)
|
| 161 |
+
done, accepted = load_cache()
|
| 162 |
+
print(f"[*] cache: {len(done):,} tasks done, {accepted:,} rows accepted "
|
| 163 |
+
f"(target {TARGET:,}) | model {MODEL_NAME} | tasks {START_TASK:,}.."
|
| 164 |
+
f"{START_TASK + MAX_TASKS:,}", flush=True)
|
| 165 |
+
if accepted >= TARGET:
|
| 166 |
+
print("[+] target already met — nothing to do")
|
| 167 |
+
return
|
| 168 |
+
|
| 169 |
+
backend = (VLLMBackend if BACKEND == "vllm" else HFBackend)()
|
| 170 |
+
|
| 171 |
+
next_id = START_TASK
|
| 172 |
+
last_id = START_TASK + MAX_TASKS
|
| 173 |
+
started, gen_rows, gen_tasks = time.time(), 0, 0
|
| 174 |
+
fh = open(CACHE, "a", encoding="utf-8")
|
| 175 |
+
while accepted < TARGET and next_id < last_id:
|
| 176 |
+
batch = []
|
| 177 |
+
while len(batch) < BATCH and next_id < last_id:
|
| 178 |
+
if next_id not in done:
|
| 179 |
+
batch.append((next_id, *sc.build_task(next_id, SEED, POOL)))
|
| 180 |
+
next_id += 1
|
| 181 |
+
if not batch:
|
| 182 |
+
break
|
| 183 |
+
|
| 184 |
+
t0 = time.time()
|
| 185 |
+
raws = backend.generate([p for _, _, p in batch])
|
| 186 |
+
batch_accept = 0
|
| 187 |
+
for (task_id, axes, _), raw in zip(batch, raws):
|
| 188 |
+
fh.write(json.dumps({"task_id": task_id, "axes": axes, "raw": raw,
|
| 189 |
+
"model": MODEL_NAME}, ensure_ascii=False) + "\n")
|
| 190 |
+
for item in sc.parse_items(raw):
|
| 191 |
+
ok, _ = sc.validate(item)
|
| 192 |
+
batch_accept += ok
|
| 193 |
+
fh.flush()
|
| 194 |
+
os.fsync(fh.fileno()) # a crash costs the in-flight batch, never the cache
|
| 195 |
+
|
| 196 |
+
accepted += batch_accept
|
| 197 |
+
gen_rows += batch_accept
|
| 198 |
+
gen_tasks += len(batch)
|
| 199 |
+
dt = time.time() - t0
|
| 200 |
+
rate = gen_rows / max(time.time() - started, 1e-9)
|
| 201 |
+
eta = (TARGET - accepted) / rate / 3600 if rate > 0 else float("inf")
|
| 202 |
+
print(f"[{accepted:>7,}/{TARGET:,}] +{batch_accept:>3} rows "
|
| 203 |
+
f"batch {len(batch)} in {dt:5.1f}s "
|
| 204 |
+
f"accept {batch_accept / (len(batch) * sc.ITEMS_PER_TASK):5.1%} "
|
| 205 |
+
f"{rate * 3600:,.0f} rows/h ETA {eta:4.1f}h", flush=True)
|
| 206 |
+
|
| 207 |
+
fh.close()
|
| 208 |
+
print(f"[+] {accepted:,} accepted rows in cache after {gen_tasks:,} new tasks")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
if __name__ == "__main__":
|
| 212 |
+
sys.exit(main())
|
code/train_reasoning.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reasoning SFT for oddadmix/50M-2048-Emhotob on Arabic_Reasoning_Dataset.
|
| 3 |
+
|
| 4 |
+
ChatML format with the derivation wrapped in <think>...</think>. Loss is computed on the
|
| 5 |
+
assistant turn only — the user prompt is masked out, same as the earlier Emhotob SFT runs.
|
| 6 |
+
No TRL; plain HF Trainer.
|
| 7 |
+
"""
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch.utils.data import Dataset
|
| 15 |
+
from transformers import (
|
| 16 |
+
AutoModelForCausalLM,
|
| 17 |
+
AutoTokenizer,
|
| 18 |
+
Trainer,
|
| 19 |
+
TrainingArguments,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0")
|
| 23 |
+
|
| 24 |
+
# Defaults reproduce v1 (Arabic_Reasoning_Dataset); every value can be overridden by env var
|
| 25 |
+
# so the same recipe can be pointed at a different corpus.
|
| 26 |
+
def _env(name, default, cast=str):
|
| 27 |
+
return cast(os.environ.get(name, default))
|
| 28 |
+
|
| 29 |
+
BASE_MODEL = _env("BASE_MODEL", "/notebooks/50M/50M-2048-Emhotob")
|
| 30 |
+
OUTPUT_DIR = _env("OUTPUT_DIR", "./Nawah-Reasoning-v1")
|
| 31 |
+
TRAIN_FILE = _env("TRAIN_FILE", "data/train.jsonl")
|
| 32 |
+
EVAL_FILE = _env("EVAL_FILE", "data/eval.jsonl")
|
| 33 |
+
|
| 34 |
+
MAX_LENGTH = _env("MAX_LENGTH", 768, int) # v1: p100 of that corpus is 708 tokens
|
| 35 |
+
IGNORE_INDEX = -100
|
| 36 |
+
|
| 37 |
+
LEARNING_RATE = _env("LEARNING_RATE", 3e-4, float) # same as the Emhotob translation SFT ladder
|
| 38 |
+
EPOCHS = _env("EPOCHS", 8, int) # v1 is tiny (~840k tok/epoch); best checkpoint wins
|
| 39 |
+
BATCH_SIZE = _env("BATCH_SIZE", 16, int)
|
| 40 |
+
GRAD_ACCUM = _env("GRAD_ACCUM", 2, int)
|
| 41 |
+
WARMUP_STEPS = _env("WARMUP_STEPS", 100, int)
|
| 42 |
+
EVAL_STEPS = _env("EVAL_STEPS", 100, int)
|
| 43 |
+
# On the v3 mix, eval loss is a bad model selector: the repeated Arabic_Reasoning rows start
|
| 44 |
+
# memorising around epoch 1.4 and drag the loss up while generation quality on *both* halves is
|
| 45 |
+
# still improving. Set LOAD_BEST=0 there and keep the final checkpoint.
|
| 46 |
+
LOAD_BEST = _env("LOAD_BEST", 1, int) == 1
|
| 47 |
+
# Point at a checkpoint dir to continue an interrupted run (optimizer/scheduler/RNG/step are
|
| 48 |
+
# restored from it). Empty = fresh run, so v1-v5 still reproduce exactly.
|
| 49 |
+
RESUME = _env("RESUME", "") or None
|
| 50 |
+
WEIGHT_DECAY = 0.0
|
| 51 |
+
MAX_GRAD_NORM = 1.0
|
| 52 |
+
SEED = 42
|
| 53 |
+
|
| 54 |
+
SPECIAL_TOKENS = ["<|im_start|>", "<|im_end|>", "<think>", "</think>"]
|
| 55 |
+
|
| 56 |
+
CHAT_TEMPLATE = (
|
| 57 |
+
"{% for message in messages %}"
|
| 58 |
+
"{{ '<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n' }}"
|
| 59 |
+
"{% endfor %}"
|
| 60 |
+
"{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
PROMPT_TMPL = "<|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n"
|
| 64 |
+
RESPONSE_TMPL = "<think>\n{reasoning}\n</think>\n{answer}<|im_end|>"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def load_jsonl(path):
|
| 68 |
+
with open(path, encoding="utf-8") as fh:
|
| 69 |
+
return [json.loads(line) for line in fh]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class ReasoningDataset(Dataset):
|
| 73 |
+
"""Prompt tokens are masked so loss falls only on <think>…</think> + answer."""
|
| 74 |
+
|
| 75 |
+
def __init__(self, rows, tokenizer, max_length):
|
| 76 |
+
self.rows = rows
|
| 77 |
+
self.tok = tokenizer
|
| 78 |
+
self.max_length = max_length
|
| 79 |
+
|
| 80 |
+
def __len__(self):
|
| 81 |
+
return len(self.rows)
|
| 82 |
+
|
| 83 |
+
def __getitem__(self, idx):
|
| 84 |
+
row = self.rows[idx]
|
| 85 |
+
prompt = PROMPT_TMPL.format(instruction=row["instruction"])
|
| 86 |
+
response = RESPONSE_TMPL.format(reasoning=row["reasoning"], answer=row["answer"])
|
| 87 |
+
|
| 88 |
+
prompt_ids = [self.tok.bos_token_id] + self.tok.encode(prompt, add_special_tokens=False)
|
| 89 |
+
response_ids = self.tok.encode(response, add_special_tokens=False)
|
| 90 |
+
|
| 91 |
+
input_ids = (prompt_ids + response_ids)[: self.max_length]
|
| 92 |
+
prompt_len = min(len(prompt_ids), len(input_ids))
|
| 93 |
+
labels = [IGNORE_INDEX] * prompt_len + input_ids[prompt_len:]
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
"input_ids": torch.tensor(input_ids, dtype=torch.long),
|
| 97 |
+
"labels": torch.tensor(labels, dtype=torch.long),
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@dataclass
|
| 102 |
+
class PaddingCollator:
|
| 103 |
+
pad_token_id: int
|
| 104 |
+
|
| 105 |
+
def __call__(self, features):
|
| 106 |
+
longest = max(len(f["input_ids"]) for f in features)
|
| 107 |
+
input_ids, labels, attention = [], [], []
|
| 108 |
+
for f in features:
|
| 109 |
+
pad = longest - len(f["input_ids"])
|
| 110 |
+
input_ids.append(torch.cat([f["input_ids"], torch.full((pad,), self.pad_token_id, dtype=torch.long)]))
|
| 111 |
+
labels.append(torch.cat([f["labels"], torch.full((pad,), IGNORE_INDEX, dtype=torch.long)]))
|
| 112 |
+
attention.append(torch.cat([torch.ones(len(f["input_ids"]), dtype=torch.long), torch.zeros(pad, dtype=torch.long)]))
|
| 113 |
+
return {
|
| 114 |
+
"input_ids": torch.stack(input_ids),
|
| 115 |
+
"labels": torch.stack(labels),
|
| 116 |
+
"attention_mask": torch.stack(attention),
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def main():
|
| 121 |
+
print("[*] loading tokenizer + base model")
|
| 122 |
+
tok = AutoTokenizer.from_pretrained(BASE_MODEL)
|
| 123 |
+
added = tok.add_special_tokens({"additional_special_tokens": SPECIAL_TOKENS})
|
| 124 |
+
tok.chat_template = CHAT_TEMPLATE
|
| 125 |
+
print(f" added {added} special tokens -> vocab {len(tok)}")
|
| 126 |
+
|
| 127 |
+
model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, dtype=torch.float32)
|
| 128 |
+
model.resize_token_embeddings(len(tok))
|
| 129 |
+
model.config.use_cache = False
|
| 130 |
+
print(f" params: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M")
|
| 131 |
+
|
| 132 |
+
train_rows, eval_rows = load_jsonl(TRAIN_FILE), load_jsonl(EVAL_FILE)
|
| 133 |
+
print(f"[*] train {len(train_rows)} / eval {len(eval_rows)}")
|
| 134 |
+
|
| 135 |
+
args = TrainingArguments(
|
| 136 |
+
output_dir=OUTPUT_DIR,
|
| 137 |
+
num_train_epochs=EPOCHS,
|
| 138 |
+
per_device_train_batch_size=BATCH_SIZE,
|
| 139 |
+
per_device_eval_batch_size=BATCH_SIZE,
|
| 140 |
+
gradient_accumulation_steps=GRAD_ACCUM,
|
| 141 |
+
learning_rate=LEARNING_RATE,
|
| 142 |
+
lr_scheduler_type="cosine",
|
| 143 |
+
warmup_steps=WARMUP_STEPS,
|
| 144 |
+
weight_decay=WEIGHT_DECAY,
|
| 145 |
+
max_grad_norm=MAX_GRAD_NORM,
|
| 146 |
+
bf16=True,
|
| 147 |
+
logging_steps=25,
|
| 148 |
+
eval_strategy="steps",
|
| 149 |
+
eval_steps=EVAL_STEPS,
|
| 150 |
+
save_strategy="steps",
|
| 151 |
+
save_steps=EVAL_STEPS,
|
| 152 |
+
save_total_limit=2,
|
| 153 |
+
load_best_model_at_end=LOAD_BEST,
|
| 154 |
+
metric_for_best_model="eval_loss",
|
| 155 |
+
greater_is_better=False,
|
| 156 |
+
report_to=[],
|
| 157 |
+
seed=SEED,
|
| 158 |
+
dataloader_num_workers=2,
|
| 159 |
+
remove_unused_columns=False,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
trainer = Trainer(
|
| 163 |
+
model=model,
|
| 164 |
+
args=args,
|
| 165 |
+
train_dataset=ReasoningDataset(train_rows, tok, MAX_LENGTH),
|
| 166 |
+
eval_dataset=ReasoningDataset(eval_rows, tok, MAX_LENGTH),
|
| 167 |
+
data_collator=PaddingCollator(pad_token_id=tok.pad_token_id),
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
if RESUME:
|
| 171 |
+
print(f"[*] resuming from {RESUME}")
|
| 172 |
+
trainer.train(resume_from_checkpoint=RESUME)
|
| 173 |
+
|
| 174 |
+
print("[*] saving best checkpoint")
|
| 175 |
+
im_end_id = tok.convert_tokens_to_ids("<|im_end|>")
|
| 176 |
+
model.config.use_cache = True
|
| 177 |
+
model.generation_config.eos_token_id = [tok.eos_token_id, im_end_id]
|
| 178 |
+
model.generation_config.pad_token_id = tok.pad_token_id
|
| 179 |
+
trainer.save_model(OUTPUT_DIR)
|
| 180 |
+
tok.save_pretrained(OUTPUT_DIR)
|
| 181 |
+
|
| 182 |
+
metrics = trainer.evaluate()
|
| 183 |
+
print("[*] final eval:", metrics)
|
| 184 |
+
Path(OUTPUT_DIR, "train_metrics.json").write_text(
|
| 185 |
+
json.dumps({"final_eval": metrics, "log_history": trainer.state.log_history}, ensure_ascii=False, indent=2),
|
| 186 |
+
encoding="utf-8",
|
| 187 |
+
)
|
| 188 |
+
print(f"[+] done -> {OUTPUT_DIR}")
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
if __name__ == "__main__":
|
| 192 |
+
main()
|
code/translate_gsm.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Translate a stratified 150k-row subset of Ajhesh7/gsm8k-reasoning-SFT-datas into Arabic
|
| 3 |
+
with ByteDance-Seed/Seed-X-PPO-7B (direct, per-row — numbers and names kept in place).
|
| 4 |
+
|
| 5 |
+
Unique segments are translated once (greedy decoding is deterministic, so identical input
|
| 6 |
+
gives identical output) and cached to a jsonl that makes the run resumable.
|
| 7 |
+
|
| 8 |
+
Usage: python translate_gsm.py [n_rows]
|
| 9 |
+
"""
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import random
|
| 13 |
+
import re
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
|
| 18 |
+
import pyarrow.parquet as pq
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, ".")
|
| 21 |
+
from gsm_common import NUM_RE, SRC_PROMPT, parse
|
| 22 |
+
|
| 23 |
+
SRC_PARQUET = "data_gsm/data/train-00000-of-00001.parquet"
|
| 24 |
+
CACHE = "out_gsm/translations.jsonl"
|
| 25 |
+
N_ROWS = int(sys.argv[1]) if len(sys.argv) > 1 else 150_000
|
| 26 |
+
CHUNK = 20_000
|
| 27 |
+
SEED = 42
|
| 28 |
+
MODEL = "./models/Seed-X-PPO-7B"
|
| 29 |
+
|
| 30 |
+
NAME_RE = re.compile(r"\b[A-Z][a-z]{2,}\b")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def select_rows(texts, n, seed=SEED):
|
| 34 |
+
"""Stratify by name/number-agnostic question pattern so every pattern is represented."""
|
| 35 |
+
rows = []
|
| 36 |
+
for i, t in enumerate(texts):
|
| 37 |
+
p = parse(t)
|
| 38 |
+
if p:
|
| 39 |
+
rows.append((i,) + p)
|
| 40 |
+
buckets = defaultdict(list)
|
| 41 |
+
for r in rows:
|
| 42 |
+
key = NAME_RE.sub("@", NUM_RE.sub("#", r[1]))
|
| 43 |
+
buckets[key].append(r)
|
| 44 |
+
|
| 45 |
+
rng = random.Random(seed)
|
| 46 |
+
for b in buckets.values():
|
| 47 |
+
rng.shuffle(b)
|
| 48 |
+
|
| 49 |
+
chosen, leftover = [], []
|
| 50 |
+
floor = max(1, n // (len(buckets) * 4))
|
| 51 |
+
for b in buckets.values():
|
| 52 |
+
chosen.extend(b[:floor])
|
| 53 |
+
leftover.extend(b[floor:])
|
| 54 |
+
rng.shuffle(leftover)
|
| 55 |
+
chosen.extend(leftover[: max(0, n - len(chosen))])
|
| 56 |
+
rng.shuffle(chosen)
|
| 57 |
+
print(f"[*] {len(buckets)} question patterns; floor {floor}/pattern; selected {len(chosen)} rows")
|
| 58 |
+
return chosen[:n]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def load_cache():
|
| 62 |
+
done = {}
|
| 63 |
+
if os.path.exists(CACHE):
|
| 64 |
+
with open(CACHE, encoding="utf-8") as fh:
|
| 65 |
+
for line in fh:
|
| 66 |
+
try:
|
| 67 |
+
rec = json.loads(line)
|
| 68 |
+
done[rec["src"]] = rec["tgt"]
|
| 69 |
+
except json.JSONDecodeError:
|
| 70 |
+
continue # truncated last line from a killed run
|
| 71 |
+
return done
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def main():
|
| 75 |
+
os.makedirs("out_gsm", exist_ok=True)
|
| 76 |
+
texts = pq.read_table(SRC_PARQUET).to_pydict()["text"]
|
| 77 |
+
rows = select_rows(texts, N_ROWS)
|
| 78 |
+
|
| 79 |
+
with open("out_gsm/selected_rows.jsonl", "w", encoding="utf-8") as fh:
|
| 80 |
+
for idx, q, t, a in rows:
|
| 81 |
+
fh.write(json.dumps({"idx": idx, "question": q, "thinking": t, "answer": a}, ensure_ascii=False) + "\n")
|
| 82 |
+
|
| 83 |
+
segments = []
|
| 84 |
+
seen = set()
|
| 85 |
+
for _, q, t, _ in rows:
|
| 86 |
+
for s in (q, t):
|
| 87 |
+
if s not in seen:
|
| 88 |
+
seen.add(s)
|
| 89 |
+
segments.append(s)
|
| 90 |
+
done = load_cache()
|
| 91 |
+
todo = [s for s in segments if s not in done]
|
| 92 |
+
print(f"[*] {len(rows)} rows -> {len(segments)} unique segments; {len(done)} cached, {len(todo)} to translate")
|
| 93 |
+
if not todo:
|
| 94 |
+
print("[+] nothing to do")
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
from vllm import LLM, SamplingParams
|
| 98 |
+
|
| 99 |
+
llm = LLM(model=MODEL, max_num_seqs=512, gpu_memory_utilization=0.92, max_model_len=1024)
|
| 100 |
+
params = SamplingParams(temperature=0, max_tokens=256, skip_special_tokens=True)
|
| 101 |
+
|
| 102 |
+
start = time.time()
|
| 103 |
+
with open(CACHE, "a", encoding="utf-8") as fh:
|
| 104 |
+
for i in range(0, len(todo), CHUNK):
|
| 105 |
+
chunk = todo[i : i + CHUNK]
|
| 106 |
+
outs = llm.generate([SRC_PROMPT.format(text=s) for s in chunk], params)
|
| 107 |
+
for src, o in zip(chunk, outs):
|
| 108 |
+
fh.write(json.dumps({"src": src, "tgt": o.outputs[0].text.strip()}, ensure_ascii=False) + "\n")
|
| 109 |
+
fh.flush()
|
| 110 |
+
done_n = i + len(chunk)
|
| 111 |
+
rate = done_n / (time.time() - start)
|
| 112 |
+
eta = (len(todo) - done_n) / rate / 60
|
| 113 |
+
print(f"[*] {done_n}/{len(todo)} segments {rate:.1f} seg/s ETA {eta:.0f} min", flush=True)
|
| 114 |
+
|
| 115 |
+
print(f"[+] done in {(time.time() - start)/60:.1f} min -> {CACHE}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|