Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +23 -0
- README.md +116 -0
- configs/base.yaml +39 -0
- configs/generator_g.yaml +24 -0
- configs/verifier_v.yaml +24 -0
- data/verifier/README.md +21 -0
- data/verifier/stats.json +9 -0
- data/verifier/train.jsonl +0 -0
- data/verifier/val.jsonl +0 -0
- notes/Train Your Own Small Learning Model.md +0 -0
- notes/brainlift.md +306 -0
- notes/research-small-vs-large-math.md +287 -0
- pyproject.toml +24 -0
- requirements-gpu.txt +9 -0
- requirements.txt +19 -0
- results/teacher_bakeoff.md +28 -0
- scripts/check_teacher.py +45 -0
- scripts/download_prm800k.py +48 -0
- scripts/publish_hf.py +99 -0
- scripts/rebalance_verifier.py +53 -0
- scripts/smoke_all.sh +19 -0
- scripts/teacher_bakeoff.py +99 -0
- src/mathcompose/__init__.py +14 -0
- src/mathcompose/common/__init__.py +21 -0
- src/mathcompose/common/chat.py +108 -0
- src/mathcompose/common/config.py +33 -0
- src/mathcompose/common/env.py +39 -0
- src/mathcompose/common/io.py +25 -0
- src/mathcompose/common/math_grade.py +227 -0
- src/mathcompose/common/parallel.py +44 -0
- src/mathcompose/common/seeding.py +24 -0
- src/mathcompose/data/__init__.py +13 -0
- src/mathcompose/data/build_generator_dataset.py +117 -0
- src/mathcompose/data/build_verifier_dataset.py +85 -0
- src/mathcompose/data/schema.py +93 -0
- src/mathcompose/datagen/__init__.py +11 -0
- src/mathcompose/datagen/dedup.py +48 -0
- src/mathcompose/datagen/gen_generator_data.py +70 -0
- src/mathcompose/datagen/gen_verifier_data.py +96 -0
- src/mathcompose/datagen/prm800k_loader.py +113 -0
- src/mathcompose/eval/__init__.py +11 -0
- src/mathcompose/eval/compose.py +101 -0
- src/mathcompose/eval/math500.py +62 -0
- src/mathcompose/eval/parse.py +46 -0
- src/mathcompose/eval/prmbench.py +85 -0
- src/mathcompose/eval/processbench.py +116 -0
- src/mathcompose/eval/run.py +151 -0
- src/mathcompose/eval/runners.py +105 -0
- src/mathcompose/infer/__init__.py +0 -0
- src/mathcompose/infer/demo.py +67 -0
.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# secrets — NEVER commit
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
|
| 6 |
+
# data + model artifacts (published to HF Hub, not committed)
|
| 7 |
+
# NOTE: anchored to repo root so they do NOT match the src/mathcompose/data/ package.
|
| 8 |
+
/data/
|
| 9 |
+
/runs/
|
| 10 |
+
*.safetensors
|
| 11 |
+
*.bin
|
| 12 |
+
|
| 13 |
+
# python
|
| 14 |
+
__pycache__/
|
| 15 |
+
*.pyc
|
| 16 |
+
.pytest_cache/
|
| 17 |
+
*.egg-info/
|
| 18 |
+
.ipynb_checkpoints/
|
| 19 |
+
.venv/
|
| 20 |
+
venv/
|
| 21 |
+
|
| 22 |
+
# keep results tables committed
|
| 23 |
+
!results/
|
README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mathcompose — two ≤4B math models that demonstrate the generator–verifier gap
|
| 2 |
+
|
| 3 |
+
**Thesis:** you can't make a 1.5B model *out-reason* a frontier model, but you can make a small
|
| 4 |
+
**specialist** win on the *easy side of the generator–verifier asymmetry* — and you can **measure the
|
| 5 |
+
asymmetry itself** with two small models that compose.
|
| 6 |
+
|
| 7 |
+
- **Model V — a generative process verifier.** Given a problem and a step-by-step solution, it emits
|
| 8 |
+
a paragraph-by-paragraph critique ending in `\boxed{k}` (0-based index of the first wrong step, or
|
| 9 |
+
`-1` if all correct). Evaluated on **ProcessBench** (objective harmonic-mean F1; target: beat
|
| 10 |
+
GPT-4o's ~61.9) with **PRMBench** as the adversarial robustness set.
|
| 11 |
+
- **Model G — a step-by-step solution generator.** Distilled from a frontier teacher and filtered so
|
| 12 |
+
only answer-correct traces survive.
|
| 13 |
+
- **The headline:** V reranks G's samples (V-weighted majority / best-of-n). We report the recovered
|
| 14 |
+
**generator–verifier gap** (`oracle pass@n − majority`) at ≤4B — the Weaver headroom result, but
|
| 15 |
+
ours.
|
| 16 |
+
|
| 17 |
+
Every core metric is an **objective checker** (step-label F1, boxed-answer equivalence) — **no
|
| 18 |
+
LLM-judge**, so there is no judge bias to argue about.
|
| 19 |
+
|
| 20 |
+
Both models share **one base** (`Qwen/Qwen2.5-Math-1.5B-Instruct`, apache-2.0) and **one training
|
| 21 |
+
path** (a generative verifier is just SFT), which is what makes a two-model build tractable.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Why these choices (grounded in `notes/`)
|
| 26 |
+
- `notes/research-small-vs-large-math.md` finds the **only** clean sub-4B "beats GPT-4o" precedent is
|
| 27 |
+
process verification (GenPRM-1.5B on ProcessBench) → **V is the primary model**.
|
| 28 |
+
- The generator–verifier asymmetry is "the key lever" → **G + composition** turn two models into one
|
| 29 |
+
coherent demonstration.
|
| 30 |
+
- `notes/brainlift.md`: distill the *process* not the label, data quality over quantity, eval before
|
| 31 |
+
training → the whole pipeline follows this.
|
| 32 |
+
- Verified constraints baked in: base context is **4096 tokens**; `trl 1.7.1` has **no PRMTrainer**
|
| 33 |
+
(so V is generative SFT, not a discriminative PRM); transformers 5.x renamed `max_seq_length →
|
| 34 |
+
max_length` and `torch_dtype → dtype`.
|
| 35 |
+
|
| 36 |
+
## Layout
|
| 37 |
+
```
|
| 38 |
+
configs/ base.yaml + verifier_v.yaml + generator_g.yaml (extends base)
|
| 39 |
+
src/mathcompose/
|
| 40 |
+
common/ chat prompts (ProcessBench critic format), \boxed grading, config, io
|
| 41 |
+
teachers/ pluggable Anthropic/OpenAI teacher (+ offline DummyTeacher)
|
| 42 |
+
datagen/ PRM800K parsing, teacher critique synthesis, answer-filtered CoT, dedup
|
| 43 |
+
data/ V output schema + dataset builders (CLI)
|
| 44 |
+
eval/ ProcessBench, PRMBench, MATH, composition, runners, run.py (CLI)
|
| 45 |
+
train/ shared QLoRA SFT (train.py --task {v,g}) + colab_train.ipynb
|
| 46 |
+
infer/ demo.py (incl. base-vs-tuned side-by-side)
|
| 47 |
+
tests/ CPU smoke suite (26 tests) scripts/smoke_all.sh
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Quickstart
|
| 51 |
+
|
| 52 |
+
### 0. Prove the harness on CPU (no GPU, no key)
|
| 53 |
+
```bash
|
| 54 |
+
pip install -e .
|
| 55 |
+
./scripts/smoke_all.sh # fast logic tests
|
| 56 |
+
./scripts/smoke_all.sh --full # + tiny-model train/eval + HF dataset probes
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### 1. Build the datasets (needs a teacher key)
|
| 60 |
+
The default teacher is the **promptlens gateway** (OpenAI-compatible). Copy
|
| 61 |
+
`.env.example` → `.env` and set `TFY_API_KEY=tfy-...` (auto-loaded). Verify with
|
| 62 |
+
`python scripts/check_teacher.py`. (Use `--teacher openai|anthropic` for native
|
| 63 |
+
providers instead.)
|
| 64 |
+
```bash
|
| 65 |
+
cp .env.example .env && $EDITOR .env # set TFY_API_KEY
|
| 66 |
+
# Model V: PRM800K first-error labels + teacher-synthesized critiques (labels stay authoritative)
|
| 67 |
+
python -m mathcompose.data.build_verifier_dataset --prm800k data/raw/prm800k/phase2_train.jsonl \
|
| 68 |
+
--teacher promptlens --limit 15000
|
| 69 |
+
# Model G: distill CoT, keep only answer-correct traces
|
| 70 |
+
python -m mathcompose.data.build_generator_dataset --source AI-MO/NuminaMath-CoT \
|
| 71 |
+
--teacher promptlens --limit 8000
|
| 72 |
+
```
|
| 73 |
+
Contamination against ProcessBench/MATH-500 is enforced automatically (`--no-dedup` to disable).
|
| 74 |
+
|
| 75 |
+
### 2. Train (GPU — free Colab T4 works; see `src/mathcompose/train/colab_train.ipynb`)
|
| 76 |
+
```bash
|
| 77 |
+
python -m mathcompose.train.train --task v --config configs/verifier_v.yaml
|
| 78 |
+
python -m mathcompose.train.train --task g --config configs/generator_g.yaml
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
### 3. Evaluate (objective, base vs tuned) + the composition
|
| 82 |
+
```bash
|
| 83 |
+
python -m mathcompose.eval.run processbench --tag base --maj-k 1
|
| 84 |
+
python -m mathcompose.eval.run processbench --adapter runs/verifier_v --tag tuned --maj-k 8
|
| 85 |
+
python -m mathcompose.eval.run prmbench --adapter runs/verifier_v --tag tuned
|
| 86 |
+
python -m mathcompose.eval.run math --adapter runs/generator_g --tag tuned
|
| 87 |
+
python -m mathcompose.eval.run compose --gen-adapter runs/generator_g --ver-adapter runs/verifier_v \
|
| 88 |
+
--dataset Maxwell-Jia/AIME_2024 --split train --problem-field Problem --answer-field Answer
|
| 89 |
+
python -m mathcompose.eval.run report # -> results/RESULTS.md
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### 4. Demo (for the video)
|
| 93 |
+
```bash
|
| 94 |
+
python -m mathcompose.infer.demo --task v --adapter runs/verifier_v --compare \
|
| 95 |
+
--problem "..." --steps "step 0" "step 1 (wrong)"
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
## What runs where
|
| 99 |
+
| Piece | Where | Needs |
|
| 100 |
+
|---|---|---|
|
| 101 |
+
| Harness + smoke tests | this repo, CPU | nothing |
|
| 102 |
+
| Data generation | anywhere | a teacher API key (Anthropic/OpenAI) |
|
| 103 |
+
| QLoRA training | Colab/Modal/RunPod GPU | a CUDA GPU (free T4 ok) |
|
| 104 |
+
| Publishing datasets/models | — | your HF token |
|
| 105 |
+
| Demo video | — | you record; script in the plan |
|
| 106 |
+
|
| 107 |
+
## Honest framing (from the research note)
|
| 108 |
+
"Beats frontier" means **beats GPT-4o**, not o1-mini/o3. Sub-4B evidence is thin (most precedents are
|
| 109 |
+
7B) — expect a step down. The real, defensible claim is the **base-vs-tuned delta** and the
|
| 110 |
+
**composition lift**, both measured objectively. PRMBench is reported alongside ProcessBench as the
|
| 111 |
+
independent robustness check.
|
| 112 |
+
|
| 113 |
+
## License / attribution
|
| 114 |
+
Code: MIT. Training data derives from **PRM800K** (MIT, OpenAI) and **NuminaMath-CoT** (apache-2.0);
|
| 115 |
+
evals use **ProcessBench**, **PRMBench**, **MATH-500**, **AIME-2024** (all apache-2.0). Attribute
|
| 116 |
+
these in any published dataset/model card.
|
configs/base.yaml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shared defaults inherited by verifier_v.yaml and generator_g.yaml.
|
| 2 |
+
# Verified facts baked in: Qwen2.5-Math-1.5B-Instruct is apache-2.0, ctx=4096,
|
| 3 |
+
# uses the CoT system prompt "Please reason step by step, and put your final
|
| 4 |
+
# answer within \boxed{}." Do NOT use <|endoftext|> as pad token.
|
| 5 |
+
|
| 6 |
+
model:
|
| 7 |
+
base_id: "Qwen/Qwen2.5-Math-1.5B-Instruct"
|
| 8 |
+
max_context: 4096 # HARD ceiling for this base — mind truncation.
|
| 9 |
+
pad_token: "<|fim_pad|>" # distinct from eos <|endoftext|>; avoids infinite-gen bug.
|
| 10 |
+
|
| 11 |
+
lora:
|
| 12 |
+
r: 16
|
| 13 |
+
alpha: 32 # alpha = 2r
|
| 14 |
+
dropout: 0.05
|
| 15 |
+
target_modules: "all-linear" # attention + MLP (LoRA-Learns-Less best practice)
|
| 16 |
+
|
| 17 |
+
train:
|
| 18 |
+
epochs: 3
|
| 19 |
+
learning_rate: 2.0e-4
|
| 20 |
+
lr_scheduler_type: "cosine"
|
| 21 |
+
warmup_ratio: 0.03
|
| 22 |
+
per_device_train_batch_size: 2
|
| 23 |
+
gradient_accumulation_steps: 8
|
| 24 |
+
gradient_checkpointing: true
|
| 25 |
+
optim: "paged_adamw_8bit"
|
| 26 |
+
bf16: true
|
| 27 |
+
packing: false
|
| 28 |
+
completion_only_loss: true # loss on completion only (robust vs assistant_only_loss for Qwen)
|
| 29 |
+
seed: 0
|
| 30 |
+
eval_strategy: "steps" # transformers 5.x name (not evaluation_strategy)
|
| 31 |
+
eval_steps: 50
|
| 32 |
+
save_steps: 50
|
| 33 |
+
logging_steps: 10
|
| 34 |
+
|
| 35 |
+
quant:
|
| 36 |
+
load_in_4bit: true
|
| 37 |
+
bnb_4bit_quant_type: "nf4"
|
| 38 |
+
bnb_4bit_use_double_quant: true
|
| 39 |
+
bnb_4bit_compute_dtype: "bfloat16"
|
configs/generator_g.yaml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model G — step-by-step solution generator.
|
| 2 |
+
# Inherits configs/base.yaml; keys here override.
|
| 3 |
+
extends: "base.yaml"
|
| 4 |
+
|
| 5 |
+
task: "g"
|
| 6 |
+
output_dir: "runs/generator_g"
|
| 7 |
+
hub_model_id: null # e.g. "your-username/mathcompose-generator-1.5b"
|
| 8 |
+
|
| 9 |
+
data:
|
| 10 |
+
train_path: "data/generator/train.jsonl"
|
| 11 |
+
val_path: "data/generator/val.jsonl"
|
| 12 |
+
# jsonl rows are {"prompt": <problem as chat messages or str>, "completion": <CoT + \boxed{answer}>}
|
| 13 |
+
|
| 14 |
+
train:
|
| 15 |
+
max_length: 2048
|
| 16 |
+
per_device_train_batch_size: 2
|
| 17 |
+
gradient_accumulation_steps: 8
|
| 18 |
+
|
| 19 |
+
eval:
|
| 20 |
+
math500_id: "HuggingFaceH4/MATH-500"
|
| 21 |
+
aime_id: "Maxwell-Jia/AIME_2024"
|
| 22 |
+
# Composition (V reranks G):
|
| 23 |
+
compose_n: 8 # samples per problem
|
| 24 |
+
compose_temperature: 0.8
|
configs/verifier_v.yaml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model V — generative process verifier / first-error localizer.
|
| 2 |
+
# Inherits configs/base.yaml; keys here override.
|
| 3 |
+
extends: "base.yaml"
|
| 4 |
+
|
| 5 |
+
task: "v"
|
| 6 |
+
output_dir: "runs/verifier_v"
|
| 7 |
+
hub_model_id: null # e.g. "your-username/mathcompose-verifier-1.5b"
|
| 8 |
+
|
| 9 |
+
data:
|
| 10 |
+
train_path: "data/verifier/train.jsonl"
|
| 11 |
+
val_path: "data/verifier/val.jsonl"
|
| 12 |
+
# jsonl rows are {"prompt": <chat messages or str>, "completion": <critique + \boxed{k}>}
|
| 13 |
+
|
| 14 |
+
train:
|
| 15 |
+
# Verifier inputs (problem + full solution + per-step critique) are long:
|
| 16 |
+
# push seq_len toward the 4096 ceiling. Report truncation rate at build time.
|
| 17 |
+
max_length: 3072
|
| 18 |
+
per_device_train_batch_size: 1
|
| 19 |
+
gradient_accumulation_steps: 16
|
| 20 |
+
|
| 21 |
+
eval:
|
| 22 |
+
processbench_splits: ["gsm8k", "math", "olympiadbench", "omnimath"]
|
| 23 |
+
maj_k: 8 # Maj@k test-time voting for the headline number
|
| 24 |
+
gpt4o_reference_f1: 61.9 # standard proprietary baseline to beat
|
data/verifier/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
task_categories: [text-generation]
|
| 4 |
+
tags: [math, process-verification, ProcessBench, PRM800K, mathcompose]
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# mathcompose — Model V (process verifier) SFT data
|
| 8 |
+
|
| 9 |
+
First-error-localization critiques for training a small generative math verifier.
|
| 10 |
+
Each row: a problem + a step-indexed candidate solution (`prompt`) and a
|
| 11 |
+
paragraph-by-paragraph critique ending in `\boxed{{k}}` (`completion`), where `k`
|
| 12 |
+
is the 0-based index of the first erroneous step (`-1` = all correct).
|
| 13 |
+
|
| 14 |
+
- **Labels** are ground truth from **PRM800K** (OpenAI, MIT).
|
| 15 |
+
- **Critiques** distilled from **claude-opus-4-8** (rationalization conditioned on
|
| 16 |
+
the known label; the boxed index is stamped authoritatively, not trusted to the teacher).
|
| 17 |
+
- **Deduped** against ProcessBench + MATH-500 (no eval contamination).
|
| 18 |
+
- `train.jsonl`/`val.jsonl` are class-balanced (~50/50 erroneous/all-correct);
|
| 19 |
+
`*_full.jsonl` keep the natural PRM800K distribution.
|
| 20 |
+
|
| 21 |
+
Built by the mathcompose harness. Eval target: ProcessBench first-error F1.
|
data/verifier/stats.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"n_total": 3214,
|
| 3 |
+
"n_train": 3054,
|
| 4 |
+
"n_val": 160,
|
| 5 |
+
"n_erroneous": 747,
|
| 6 |
+
"n_all_correct": 2467,
|
| 7 |
+
"teacher": "promptlens",
|
| 8 |
+
"deduped": true
|
| 9 |
+
}
|
data/verifier/train.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/verifier/val.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notes/Train Your Own Small Learning Model.md
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
notes/brainlift.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Brainlift: Training and Optimizing Small Language Models to Reliably Perform Specific Behaviors
|
| 2 |
+
|
| 3 |
+
## TL;DR
|
| 4 |
+
- **For instilling one narrow, falsifiable behavior into a 0.6B–4B model, the dataset is the deliverable:** LIMA (1,000 examples), s1 (1,000 traces), and QLoRA's own ablations converge on quality-and-diversity over quantity, and QLoRA makes single-GPU tuning routine. Capability is mostly *elicited* from a base model that already latently has it — not installed from scratch.
|
| 5 |
+
- **QLoRA is the right default precisely because it "forgets less"** (Biderman et al.), and reliability comes from an engineering stack — distilling a teacher's *process*, constrained decoding for output form, and an adversarial base-vs-tuned eval — not from a bigger model or a cleverer prompt.
|
| 6 |
+
- **Build the evaluation before you generate a single training row, and make it adversarial**, because small (and large) LMs fail in predictable, testable ways: compositional reasoning collapse (Faith and Fate), irrelevant-clause sensitivity (GSM-Symbolic, up to 65% drop), the reversal curse, lost-in-the-middle, and confident hallucination.
|
| 7 |
+
|
| 8 |
+
## Purpose
|
| 9 |
+
A portable research knowledge base for instilling ONE specific, falsifiable behavior into a small open base model (0.6B–4B) via supervised fine-tuning (QLoRA), using distillation from a frontier teacher to build the dataset, and evaluating with LLM-as-judge plus a base-vs-tuned comparison.
|
| 10 |
+
|
| 11 |
+
**In scope:** data-centric generation/curation and distillation; PEFT (LoRA/QLoRA); SFT mechanics; preference optimization (DPO and variants); compression/quantization; small-model landscape; evaluation and LLM-as-judge; reliability and structured output; failure modes and limits of (small) LMs; practitioner playbooks.
|
| 12 |
+
|
| 13 |
+
**Out of scope:** pretraining from scratch; large-scale RLHF infrastructure; multimodal training; serving/infra beyond quantization formats; agent orchestration.
|
| 14 |
+
|
| 15 |
+
## Key Findings
|
| 16 |
+
1. **Small-data alignment works when the capability is latent.** Two independent 1,000-example results (LIMA for general alignment, s1 for reasoning) plus QLoRA's finding that data quality beats size establish that a narrow behavior can be reached cheaply — but LoRA-Learns-Less shows this breaks down for genuinely new hard skills.
|
| 17 |
+
2. **Distill the process, not the style.** Orca and DeepSeek-R1 show reasoning traces transfer real capability into small models; Orca explicitly warns that style-only imitation inflates apparent quality on shallow evals.
|
| 18 |
+
3. **PEFT trades peak capacity for forgetting-resistance**, which is a feature for narrow-behavior work. Full fine-tuning still wins on hard new skills.
|
| 19 |
+
4. **LLM-as-judge is usable (>80% human agreement) but biased** (position, verbosity, self-preference); design evals around the biases.
|
| 20 |
+
5. **Reliability = data + constrained decoding + calibration eval.** Neither fine-tuning nor grammar-constrained decoding is individually sufficient.
|
| 21 |
+
6. **Small models fail in named, reproducible ways** — build adversarial evals that target exactly those failure modes.
|
| 22 |
+
|
| 23 |
+
## DOK 4 — Spiky Points of View
|
| 24 |
+
|
| 25 |
+
**1. For a narrow behavior, ~1,000–2,000 excellent, diverse examples is enough; past that, more data is usually wasted effort.**
|
| 26 |
+
|
| 27 |
+
- _Why:_ pretraining already installed the capability, so SFT only has to pin a consistent output policy over what the model already knows (Superficial Alignment Hypothesis). Rows past the first ~1k mostly re-teach that and add overfitting risk.
|
| 28 |
+
- What still helps is coverage of the behavior's input variety, not raw count.
|
| 29 |
+
- Two independent replications on very different tasks (LIMA on alignment, s1 on competition math) landed on the same ~1,000 figure, so it isn't a single-domain fluke.
|
| 30 |
+
- _Evidence:_ LIMA (1,000 examples rival RLHF models trained on 52× more data), s1 (7 vs 394 GPU-hours for near-identical accuracy), QLoRA's quality>size ablation.
|
| 31 |
+
- _When wrong:_ if the base genuinely lacks the capability (novel reasoning, unfamiliar code), LoRA-Learns-Less shows you need more data AND higher rank or full FT.
|
| 32 |
+
- _Abandon when:_ the base-vs-tuned delta plateaus below target despite clean, diverse data; that plateau means the capability is absent, not under-sampled.
|
| 33 |
+
|
| 34 |
+
**2. Use QLoRA by default and treat "forgets less" as a feature, not a compromise.**
|
| 35 |
+
|
| 36 |
+
- _Why:_ full FT rewrites high-rank directions across all weights and injects "intruder dimensions" that overwrite unrelated pretrained abilities; LoRA's low-rank cap keeps the update from traveling that far.
|
| 37 |
+
- For one-behavior work you don't want to disturb anything else, so LoRA's capacity ceiling is the guardrail you want, not a weakness.
|
| 38 |
+
- The alternative fails concretely: Raschka saw a model "unlearn arithmetic" from narrow SFT; Luo et al. show forgetting worsens with scale under continual full tuning.
|
| 39 |
+
- QLoRA is single-GPU with no measured quality loss on tested tasks, so the default costs nothing.
|
| 40 |
+
- _Evidence:_ Biderman et al. (LoRA forgets less, retains out-of-domain capability), intruder-dimensions paper (the mechanism), QLoRA (single-GPU, no perf loss), Raschka.
|
| 41 |
+
- _Counter:_ full FT wins peak in-domain accuracy on genuinely hard new skills.
|
| 42 |
+
- _Wrong if:_ your ONLY metric is target-task accuracy and base-capability retention is irrelevant; then use full FT or high-rank (r≈256) LoRA.
|
| 43 |
+
|
| 44 |
+
**3. Build the eval before you generate a single training row, make it adversarial, and trust base-vs-tuned deltas over any single judge score.**
|
| 45 |
+
|
| 46 |
+
- Without a pre-committed eval, "we fine-tuned a model" is unfalsifiable; the eval is what turns a vibe into a measurement.
|
| 47 |
+
- Make it adversarial: small models fail in named ways (irrelevant clauses, reversed relations, mid-context facts) a happy-path test never triggers, so a fine-tune that clears only easy cases has learned the surface.
|
| 48 |
+
- Read the delta, not the absolute score: judge biases (position, verbosity, self-preference) distort raw numbers but largely cancel when one judge compares two outputs with randomized order.
|
| 49 |
+
- Prefer binary pass/fail over 1–5: it forces a crisp definition and is far more stable across judge runs.
|
| 50 |
+
- _Evidence:_ Husain (evals first, 60–80% of effort; binary > 1–5), Zheng et al. (quantified biases; >80% human agreement), GSM-Symbolic and Reversal Curse (fragilities to target).
|
| 51 |
+
- _Spiky corollary:_ a fine-tune shipped with no robustness eval should not be trusted, however good the demos.
|
| 52 |
+
- _Wrong if:_ the behavior is code-verifiable by exact-match/regex (skip the judge), or you've validated high judge-human agreement on your task.
|
| 53 |
+
|
| 54 |
+
**4. Reliability is an engineering stack (data + constrained decoding + a calibration/abstention eval), not a bigger model or a better prompt.**
|
| 55 |
+
|
| 56 |
+
- Three levers fix three failure sources: fine-tuning raises the odds of correct content but can't guarantee valid form; constrained decoding guarantees form but can push the model off-path and hurt content; a calibration eval surfaces the residual confident-guess errors that only rewarding "I don't know" suppresses.
|
| 57 |
+
- The deeper point: reliability is a tail property (the 1-in-50 malformed output breaks the pipeline), and scaling or prompting shifts the average while leaving the tail. Engineer the tail.
|
| 58 |
+
- _Evidence:_ constrained-decoding literature (Outlines/XGrammar guarantee form), Why-Models-Hallucinate (residual errors are calibration failures), plus fine-tuning raising correct-behavior probability.
|
| 59 |
+
- _Counter:_ constrained decoding adds ~2–5× overhead and can degrade content; over-constraining hides genuine uncertainty.
|
| 60 |
+
- _Wrong if:_ the base is already schema-reliable and latency can't absorb decoding overhead.
|
| 61 |
+
|
| 62 |
+
## DOK 3 — Insights
|
| 63 |
+
|
| 64 |
+
**1. The dataset is the deliverable; capability is mostly elicited, not installed.**
|
| 65 |
+
|
| 66 |
+
- LIMA (1,000 examples), s1 (1,000 traces; 394 vs 7 GPU-hours), and QLoRA's quality>size finding converge: a small, diverse, high-quality set beats a large noisy one.
|
| 67 |
+
- _Why:_ pretraining did the heavy lifting, so SFT mainly selects a consistent output policy over existing knowledge; beyond a point you pay to re-teach what the model knows, and coverage matters more than count.
|
| 68 |
+
- _Tension:_ LoRA-Learns-Less shows that for genuinely NEW capabilities (code, math) low-rank small-data tuning underperforms full FT—elicitation works when the capability is latent, not when it's absent.
|
| 69 |
+
|
| 70 |
+
**2. Distill the process, not the style.**
|
| 71 |
+
|
| 72 |
+
- Orca and DeepSeek-R1 show that transferring reasoning traces/explanations (not just final answers) is what moves a real capability into a small model; Orca warns that style-only imitation inflates apparent quality.
|
| 73 |
+
- _Why:_ final-answer-only data teaches outputs without the intermediate computation, so it copies tone and collapses when the problem shifts; traces hand over the steps the student can internalize. It's also why style-rewarding evals overrate imitation-trained models.
|
| 74 |
+
- _Tension:_ DeepSeek-R1 distillation still leaves persistent gaps for ≤7B students; process distillation narrows but doesn't close the teacher-student gap.
|
| 75 |
+
|
| 76 |
+
**3. LoRA/QLoRA is the right default for behavior instillation precisely because it forgets less.**
|
| 77 |
+
|
| 78 |
+
- Biderman et al. and the intruder-dimension paper show LoRA sacrifices peak capacity but preserves base competence—ideal when you want ONE behavior without collateral regression.
|
| 79 |
+
- _Why:_ the low-rank cap limits how far the update moves the weights, so unrelated abilities survive; full FT has no such cap. For single-behavior work, preserving everything else is the goal, so the capacity limit is the property you want.
|
| 80 |
+
- Raschka's "unlearned arithmetic" and Luo et al.'s forgetting-scales-with-size results show full FT/narrow data risks broad capability loss.
|
| 81 |
+
- _Steelman for full FT:_ if the behavior is a hard new skill, full FT (or high-rank LoRA, α=2r, all modules) wins on the target metric.
|
| 82 |
+
|
| 83 |
+
**4. Evaluation must precede and drive training.**
|
| 84 |
+
|
| 85 |
+
- Hamel Husain (evals first, 60–80% of effort), LIMA (perplexity ≠ quality), and practitioner consensus all treat the eval harness as the real deliverable.
|
| 86 |
+
- _Why:_ loss and perplexity don't track the behavior you care about, so without a behavioral eval you optimize a proxy; the eval is also the only signal for whether the fix is more data, different data, or a different method.
|
| 87 |
+
- A base-vs-tuned LLM-judge comparison on a held-out set is the minimum viable measurement.
|
| 88 |
+
|
| 89 |
+
**5. LLM-as-judge is usable but biased; design around it.**
|
| 90 |
+
|
| 91 |
+
- Zheng et al. quantify position, verbosity, and self-preference biases (position can swing win-rate 10–15 points; length bias ~+17%).
|
| 92 |
+
- _Why it's still workable:_ the biases are systematic, not random, so they distort absolute scores but largely cancel in A/B comparisons under a fixed judge with randomized order.
|
| 93 |
+
- Mitigations: randomize order, control for length, don't judge with the same model family, prefer binary pass/fail (Husain).
|
| 94 |
+
- _Tension:_ judges agree with humans >80%, so the biases are manageable, not disqualifying.
|
| 95 |
+
|
| 96 |
+
**6. Reliability comes from data + decoding, not prompting.**
|
| 97 |
+
|
| 98 |
+
- Fine-tuning raises the probability of correct behavior; constrained decoding (Outlines/XGrammar) guarantees output FORM.
|
| 99 |
+
- _Why both:_ they govern different things (content odds vs form validity), and reliability is a tail property, so closing it takes both at once rather than a prompt that only shifts the average.
|
| 100 |
+
- Neither alone suffices: constrained decoding can force off-distribution tokens and hurt content; fine-tuning can't guarantee 100% valid structure.
|
| 101 |
+
|
| 102 |
+
**7. Small models fail in predictable, testable ways; build adversarial evals for exactly those.**
|
| 103 |
+
|
| 104 |
+
- Named fragilities: GSM-Symbolic (irrelevant-clause drops up to 65%), Faith-and-Fate (multiplication 59%→4%), Reversal Curse (near-0% on reversed facts), Lost-in-the-Middle (U-shaped context use), and the hallucination-calibration work.
|
| 105 |
+
- _Why it helps:_ because the failures are named and reproducible, the papers' own perturbations become your robustness suite, turning known weaknesses into a pre-deployment checklist.
|
| 106 |
+
- A robustness eval should perturb numbers, add distractor clauses, reverse relations, and move key info to the middle.
|
| 107 |
+
|
| 108 |
+
**8. "Emergence" is partly a measurement artifact.**
|
| 109 |
+
|
| 110 |
+
- Schaeffer et al. show apparent emergence tracks metric choice.
|
| 111 |
+
- _Why it matters here:_ a behavior that looks absent at small scale under exact-match may just be the metric masking steady progress; continuous metrics (edit distance, token-level, Brier) expose the gradient and prevent premature abandonment.
|
| 112 |
+
|
| 113 |
+
## DOK 2 — Knowledge Tree
|
| 114 |
+
|
| 115 |
+
### A. Data-centric AI & distillation
|
| 116 |
+
|
| 117 |
+
**LIMA: Less Is More for Alignment** — Zhou et al., Meta AI, NeurIPS 2023. https://arxiv.org/abs/2305.11206. *Peer-reviewed / researcher.*
|
| 118 |
+
- Fine-tuned LLaMa-65B on only 1,000 curated prompt-response pairs with standard supervised loss, no RLHF.
|
| 119 |
+
- In a controlled human study, LIMA responses were equivalent or preferred to GPT-4 in 43% of cases; it beat DaVinci003 (RLHF-trained) and Alpaca-65B (trained on 52× more data). Even GPT-4 preferred LIMA's output over its own 19% of the time.
|
| 120 |
+
- Hyperparameters: 15 epochs, LR 1e-5→1e-6, batch 32; perplexity did NOT correlate with generation quality, so checkpoints were selected manually on a 50-example dev set.
|
| 121 |
+
- **DOK 2 Summary:** Proposes the "Superficial Alignment Hypothesis" — nearly all knowledge is learned in pretraining and alignment mainly teaches format/style, so a tiny high-quality dataset can suffice. Intellectual foundation for behavior-focused small-data fine-tuning.
|
| 122 |
+
|
| 123 |
+
**Self-Instruct / Stanford Alpaca** — Wang et al. 2022 (https://arxiv.org/abs/2212.10560); Taori et al. 2023. *Peer-reviewed + technical / practitioner.*
|
| 124 |
+
- Self-Instruct bootstraps 52K instructions from 175 seed tasks using a base LLM; improved the SUPER-NATURALINSTRUCTIONS baseline by 33%.
|
| 125 |
+
- Alpaca reused the pipeline with text-davinci-003, generated 52K samples, and fine-tuned LLaMA-7B cheaply; AlpaGasus later showed filtering Alpaca's low-quality rows improves the model.
|
| 126 |
+
- **DOK 2 Summary:** Established the template for distilling a teacher's behavior into a smaller student via synthetic instruction data — the direct ancestor of the "generate data from a frontier teacher" workflow.
|
| 127 |
+
|
| 128 |
+
**Evol-Instruct / WizardLM** — Xu et al. 2023. *Peer-reviewed / researcher.*
|
| 129 |
+
- Iteratively rewrites simple instructions into more complex/diverse ones (in-depth and in-breadth evolution).
|
| 130 |
+
- **DOK 2 Summary:** Complexity and diversity of instructions, not just count, drive instruction-following quality — a concrete lever for dataset design.
|
| 131 |
+
|
| 132 |
+
**Orca: Progressive Learning from Complex Explanation Traces of GPT-4** — Mukherjee et al., Microsoft 2023. https://arxiv.org/abs/2306.02707. *Peer-reviewed / researcher.*
|
| 133 |
+
- 13B model trained to imitate GPT-4 reasoning via explanation traces and step-by-step thought, with ChatGPT as intermediate teacher; ~5M samples.
|
| 134 |
+
- Beat Vicuna-13B by >100% on Big-Bench Hard and 42% on AGIEval.
|
| 135 |
+
- Explicitly warns: naive imitation makes students copy the STYLE but not the REASONING of teachers, and shallow evaluation overestimates capability.
|
| 136 |
+
- **DOK 2 Summary:** To transfer a behavior (not just surface style), distill the process — reasoning traces and explanations �� and evaluate rigorously, because style mimicry masks capability gaps.
|
| 137 |
+
|
| 138 |
+
**Textbooks Are All You Need (phi-1) / phi-1.5** — Gunasekar et al. 2023 (https://arxiv.org/abs/2306.11644); Li et al. 2023 (https://arxiv.org/abs/2309.05463). *Peer-reviewed / researcher.*
|
| 139 |
+
- phi-1: 1.3B params, trained on 6B tokens of "textbook-quality" web data + 1B tokens of GPT-3.5 synthetic textbooks/exercises; 50.6% pass@1 on HumanEval, 55.5% on MBPP. phi-1-small (350M) still hit 45% on HumanEval.
|
| 140 |
+
- phi-1.5: 1.3B, matches models 5× larger on reasoning; challenges the notion that capability is determined solely by scale.
|
| 141 |
+
- **DOK 2 Summary:** Data quality can substitute for scale — carefully curated/synthetic "textbook" data lets tiny models punch far above their weight, reinforcing the data-as-deliverable thesis.
|
| 142 |
+
|
| 143 |
+
**FineWeb-Edu / Cosmopedia / SmolLM-Corpus** — Hugging Face (Penedo et al.). *Technical / practitioner.*
|
| 144 |
+
- Cosmopedia: 25B tokens, 30M synthetic samples (Mixtral-generated textbooks/blogs/stories) — largest open synthetic dataset at release. FineWeb-Edu is an educational-quality filtered subset of FineWeb; FineMath ~50B tokens.
|
| 145 |
+
- **DOK 2 Summary:** Open replications confirm that quality-filtering and synthetic generation at scale produce better small models than raw web crawl.
|
| 146 |
+
|
| 147 |
+
*Collective note:* The synthetic-data surveys ("Best Practices and Lessons Learned on Synthetic Data," https://arxiv.org/abs/2404.07503; "A Survey on Post-training of LLMs," https://arxiv.org/abs/2503.06072) document that the recurring quality levers are complexity, diversity, and scale, and that quality filtering beats raw quantity. Tooling: **distilabel/Argilla** for programmatic synthetic-data and preference-pair pipelines.
|
| 148 |
+
|
| 149 |
+
### B. PEFT / LoRA
|
| 150 |
+
|
| 151 |
+
**LoRA: Low-Rank Adaptation** — Hu et al., Microsoft, ICLR 2022. https://arxiv.org/abs/2106.09685. *Peer-reviewed / researcher.*
|
| 152 |
+
- Freezes base weights, learns low-rank update matrices A,B; far fewer trainable params and no inference latency once merged.
|
| 153 |
+
- **DOK 2 Summary:** The core PEFT method — adapt behavior by learning a small rank-r perturbation instead of all weights.
|
| 154 |
+
|
| 155 |
+
**QLoRA: Efficient Finetuning of Quantized LLMs** — Dettmers et al., UW, NeurIPS 2023. https://arxiv.org/abs/2305.14314. *Peer-reviewed / researcher.*
|
| 156 |
+
- Finetunes a 65B model on a single 48GB GPU. Innovations: 4-bit NormalFloat (NF4), double quantization (~0.37 bits/param saved, ~3GB on a 65B model), paged optimizers. Storage in NF4, compute de-quantized to bf16.
|
| 157 |
+
- Guanaco reached 99.3% of ChatGPT on the Vicuna benchmark in 24h on one GPU; the authors trained >1,000 models.
|
| 158 |
+
- Found data quality > dataset size for instruction following, and that MMLU does not predict chatbot quality.
|
| 159 |
+
- **DOK 2 Summary:** Makes single-GPU fine-tuning of small models routine, with no measured performance loss vs 16-bit for the tested tasks.
|
| 160 |
+
|
| 161 |
+
**LoRA Learns Less and Forgets Less** — Biderman et al., Columbia/Databricks Mosaic, TMLR 2024. https://arxiv.org/abs/2405.09673. *Peer-reviewed / researcher.*
|
| 162 |
+
- On code and math, standard low-rank LoRA substantially underperforms full fine-tuning; full FT learns perturbations 10–100× higher rank than typical LoRA configs.
|
| 163 |
+
- But LoRA forgets less (better retains out-of-domain capability) and maintains more diverse generations; mitigates forgetting more than weight decay/dropout. Example: code IFT full-FT scored 0.414 vs LoRA r=64's 0.509 on retention.
|
| 164 |
+
- Best practices: target all modules (attention + MLP), use α=2r, rank ~16 as a starting point, ≥4 epochs; LoRA is highly LR-sensitive.
|
| 165 |
+
- **DOK 2 Summary:** LoRA trades peak in-domain capacity for regularization/forgetting-resistance — a favorable trade when instilling a narrow behavior without wrecking base competence.
|
| 166 |
+
|
| 167 |
+
**LoRA vs Full Fine-tuning: An Illusion of Equivalence** — Shuttleworth et al. 2024. https://arxiv.org/abs/2410.21228. *Peer-reviewed / researcher.*
|
| 168 |
+
- LoRA introduces "intruder dimensions" — high-ranking singular vectors dissimilar to the pretrained weights — that full FT does not; causal intervention on them shows they drive forgetting.
|
| 169 |
+
- **DOK 2 Summary:** LoRA and full FT reach structurally different solutions; that difference explains both LoRA's forgetting-resistance and its capacity limits.
|
| 170 |
+
|
| 171 |
+
**Sebastian Raschka — Practical Tips for Finetuning LLMs Using LoRA** (Ahead of AI, 2023). https://magazine.sebastianraschka.com/p/practical-tips-for-finetuning-llms. *Technical blog / practitioner.*
|
| 172 |
+
- QLoRA: ~33% memory savings for ~33% runtime increase. Optimizer choice barely matters. In his sweep r=256/α=512 gave the best performance.
|
| 173 |
+
- Apply LoRA to ALL layers, not just K/V. Multiple epochs on small instruction sets can overfit and hurt.
|
| 174 |
+
- Observed a model "unlearned arithmetic" because Alpaca lacked arithmetic examples — a concrete instance of narrow capability regression.
|
| 175 |
+
- **DOK 2 Summary:** Hard-won operational defaults for LoRA; the "unlearned arithmetic" anecdote grounds the forgetting risk from narrow data.
|
| 176 |
+
|
| 177 |
+
### C. SFT mechanics
|
| 178 |
+
- **Loss masking / train-on-completions-only**: standard TRL SFTTrainer practice, but "Instruction Tuning With Loss Over Instructions" (Shi et al. 2024) questions always masking the instruction.
|
| 179 |
+
- **Chat templates**: model-specific special tokens (LIMA introduced an explicit EOT token distinct from EOS); mismatched templates silently degrade behavior.
|
| 180 |
+
- **Hyperparameters**: small datasets overfit fast; LIMA found perplexity uncorrelated with quality, so gate on behavioral eval, not loss. Use packing for throughput; watch effective batch size and LR.
|
| 181 |
+
- **DOK 2 Summary (collective):** For narrow-behavior SFT the choices that matter most are correct chat-template/token handling, masking the prompt so loss applies only to completions, and early stopping by behavioral eval rather than loss.
|
| 182 |
+
|
| 183 |
+
### D. Preference optimization
|
| 184 |
+
|
| 185 |
+
**DPO: Direct Preference Optimization** — Rafailov et al., Stanford, NeurIPS 2023. https://arxiv.org/abs/2305.18290. *Peer-reviewed / researcher.*
|
| 186 |
+
- Reparameterizes the RLHF reward so the optimal policy has a closed form; trains directly on preference pairs with a simple classification loss — no separate reward model, no sampling during training.
|
| 187 |
+
- Matches or beats PPO-based RLHF on sentiment control, summarization, and single-turn dialogue while being far simpler and more stable.
|
| 188 |
+
- **DOK 2 Summary:** Makes preference tuning accessible on small hardware; the go-to "stretch" step after SFT when you have chosen/rejected pairs.
|
| 189 |
+
|
| 190 |
+
**ORPO, KTO, SimPO, IPO** — Hong et al. 2024; Ethayarajh et al. 2024; Meng et al. 2024 (https://arxiv.org/abs/2405.14734); Azar et al. 2024. *Peer-reviewed / researcher.*
|
| 191 |
+
- ORPO: reference-model-free, folds preference into SFT via an odds-ratio term (one model, one dataset), more efficient but hyperparameter-sensitive.
|
| 192 |
+
- KTO: learns from unpaired binary (good/bad) signals — no preference pairs required.
|
| 193 |
+
- SimPO: reference-free, length-normalized reward + target margin; reported to outperform DPO and ORPO in its own experiments while tolerating noisier labels.
|
| 194 |
+
- IPO: addresses DPO overfitting with a theoretically grounded objective.
|
| 195 |
+
- **DOK 2 Summary:** A menu trading off data format (paired vs unpaired), compute (reference model or not), and stability; choose by what feedback you can cheaply generate.
|
| 196 |
+
|
| 197 |
+
*Collective note:* Argilla's RLHF overview (https://argilla.io/blog/mantisnlp-rlhf-part-9/) and post-training-stack write-ups stress that DPO alone optimizes generic preference; controllable/domain behavior often needs SFT first, then a preference pass, and that all these methods are β- and length-term–sensitive.
|
| 198 |
+
|
| 199 |
+
### E. Compression / quantization & reasoning distillation
|
| 200 |
+
|
| 201 |
+
**Distilling the Knowledge in a Neural Network** — Hinton, Vinyals, Dean, 2015. https://arxiv.org/abs/1503.02531. *Peer-reviewed / researcher.* Origin of soft-target knowledge distillation.
|
| 202 |
+
|
| 203 |
+
**DeepSeek-R1 (distilled models)** — DeepSeek-AI, 2025. https://arxiv.org/abs/2501.12948. *Technical / researcher-practitioner.*
|
| 204 |
+
- Fine-tuned six dense students (1.5B–70B, Qwen2.5 & Llama) on 800K reasoning traces from R1; SFT only, no RL stage.
|
| 205 |
+
- Per the R1 technical report, DeepSeek-R1-Distill-Qwen-32B scores 72.6% on AIME 2024, 94.3% on MATH-500, and 57.2% on LiveCodeBench — results that significantly outperform previous open-source models and are comparable to o1-mini.
|
| 206 |
+
- Key claim: distilling from a strong teacher beats running large-scale RL directly on a small model.
|
| 207 |
+
- **DOK 2 Summary:** Landmark evidence that a hard capability (long-CoT reasoning) transfers into small models purely via SFT on teacher traces — though persistent gaps remain for ≤7B students.
|
| 208 |
+
|
| 209 |
+
**s1: Simple Test-Time Scaling** — Muennighoff et al., Stanford, EMNLP 2025. https://arxiv.org/abs/2501.19393. *Peer-reviewed / researcher.*
|
| 210 |
+
- Per the paper: "After supervised finetuning the Qwen2.5-32B-Instruct language model on s1K and equipping it with budget forcing, our model s1-32B exceeds o1-preview on competition math questions by up to 27% (MATH and AIME24)"; budget forcing (appending "Wait") scaled AIME24 from 50% to 57%.
|
| 211 |
+
- s1K = 1,000 traces selected for difficulty, diversity, quality. Training on the full 59K vs the 1K cost 394 vs 7 H100-hours for marginal gains — data selection dominates.
|
| 212 |
+
- Explicitly invokes LIMA's Superficial Alignment Hypothesis.
|
| 213 |
+
- **DOK 2 Summary:** A second independent confirmation that ~1,000 well-chosen examples can activate a latent capability; the behavior is elicited, not taught from scratch.
|
| 214 |
+
|
| 215 |
+
**Quantization formats** — GPTQ, AWQ, bitsandbytes (NF4), GGUF/llama.cpp. *Docs / practitioner.*
|
| 216 |
+
- **DOK 2 Summary (collective):** Post-training quantization (GGUF for llama.cpp/CPU, AWQ/GPTQ for GPU) lets a tuned small model deploy on edge; 4-bit is the practical sweet spot.
|
| 217 |
+
|
| 218 |
+
### F. Small-model landscape
|
| 219 |
+
|
| 220 |
+
**SmolLM3-3B** — Hugging Face, released July 8, 2025. https://huggingface.co/HuggingFaceTB/SmolLM3-3B. *Technical / practitioner.*
|
| 221 |
+
- 3B, trained on 11.2T tokens; dual reasoning (think/no_think); 64k context (128k via YaRN); fully open (data mixture, configs, 100+ intermediate checkpoints). Trained on 384 H100s for 24 days.
|
| 222 |
+
- Outperforms Llama-3.2-3B and Qwen2.5-3B; competitive with Qwen3-4B and Gemma3-4B. Instruct variant optimized for reasoning and tool use.
|
| 223 |
+
- **DOK 2 Summary:** Best current fully-open small base for tuning, with a documented recipe and a strong instruct variant.
|
| 224 |
+
|
| 225 |
+
*Collective note:* **Qwen3 (0.6/1.7/4B), Llama 3.2 (1B/3B), Gemma 3 small, Phi family, SmolLM2 (135M/360M/1.7B)** are the practical base pool. Instruct variants ship chat templates and instruction-following priors; base variants give a cleaner slate but need format teaching. Tokenizer/number-tokenization differences matter (Goat attributed arithmetic gains to LLaMA's consistent digit tokenization).
|
| 226 |
+
|
| 227 |
+
### G. Evaluation & LLM-as-judge
|
| 228 |
+
|
| 229 |
+
**Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena** — Zheng et al. 2023. https://arxiv.org/abs/2306.05685. *Peer-reviewed / researcher.*
|
| 230 |
+
- Per the paper: "strong LLM judges like GPT-4 can match both controlled and crowdsourced human preferences well, achieving over 80% agreement, the same level of agreement between humans" (validated on ~3K controlled expert votes + ~3K crowdsourced votes).
|
| 231 |
+
- Documents position bias (most judges favor the first answer; only GPT-4 stays consistent in >60% of swapped cases), verbosity bias, and self-enhancement bias (judges favor their own outputs). Independent studies measure length bias around +17% for LLM judges vs ~+13% for humans.
|
| 232 |
+
- **DOK 2 Summary:** LLM-as-judge is scalable and roughly human-aligned but carries measurable biases — always randomize position, control length, and avoid judging with the same model family.
|
| 233 |
+
|
| 234 |
+
**Hamel Husain — Your AI Product Needs Evals / LLM-as-a-Judge / Evals FAQ** (hamel.dev). https://hamel.dev/blog/posts/evals/. *Technical blog / practitioner.*
|
| 235 |
+
- Error analysis first: read ~100 traces; stop when ~20 traces reveal no new failure mode ("theoretical saturation").
|
| 236 |
+
- Use a single domain-expert "benevolent dictator"; build a custom, friction-free data-viewing tool. Prefer binary pass/fail over 1–5 scales ("Critique Shadowing"); pass rate is a product decision, not necessarily 100%.
|
| 237 |
+
- Per the Evals FAQ: "In the projects we've worked on, we've spent 60-80% of our development time on error analysis and evaluation"; he notes a ~70% pass rate can indicate an eval that is actually stress-testing the app.
|
| 238 |
+
- **DOK 2 Summary:** Build evals before/alongside the model; the eval harness IS the improvement flywheel and the debugging infrastructure.
|
| 239 |
+
|
| 240 |
+
*Collective note:* **G-Eval** (Liu et al. 2023) uses CoT + form-filling for judge scoring; **JSONSchemaBench** (https://arxiv.org/abs/2501.10868) shows models still struggle with real-world schemas. Watch for contamination, overfitting to the judge, and benchmark gaming.
|
| 241 |
+
|
| 242 |
+
### H. Reliability & structured output
|
| 243 |
+
|
| 244 |
+
**Constrained decoding — Outlines, XGrammar, Guidance, llama.cpp GBNF; JSONSchemaBench** — Willard & Louf 2023; Dong et al. 2024 (XGrammar); https://arxiv.org/abs/2501.10868. *Docs + peer-reviewed / practitioner.*
|
| 245 |
+
- FSM/token-masking sets invalid-token logits to −∞, guaranteeing schema-valid output; Outlines compiles JSON schemas for ~O(1) valid-token lookup per step.
|
| 246 |
+
- Overhead ranges from minimal to ~2–5× for naive implementations; XGrammar is current SOTA for low overhead. Output quality can suffer when constraints force the model off its preferred tokens, but constrained decoding sometimes IMPROVES task performance.
|
| 247 |
+
- **DOK 2 Summary:** For structured-output behaviors, constrained decoding guarantees form while fine-tuning improves the model's tendency to produce correct content within that form — complementary, not substitutes.
|
| 248 |
+
|
| 249 |
+
### I. Failure modes & limits (dedicated strand)
|
| 250 |
+
|
| 251 |
+
**GSM-Symbolic** — Mirzadeh et al., Apple, 2024 (ICLR 2025). https://arxiv.org/abs/2410.05229. *Peer-reviewed / researcher.*
|
| 252 |
+
- All tested models drop accuracy when only numbers change in a template; performance degrades further as clause count rises.
|
| 253 |
+
- Per the paper: "adding seemingly relevant but ultimately irrelevant information to problems, we demonstrate substantial performance drops (up to 65%) across all state-of-the-art models" — Phi-3-mini experienced over a 65% drop on the GSM-NoOp variant.
|
| 254 |
+
- Interpretation: reasoning resembles sophisticated pattern matching, not robust logic. **Contested:** Ivanova and others critique the statistical rigor; treat as evidence of fragility, not proof of "no reasoning."
|
| 255 |
+
|
| 256 |
+
**Faith and Fate: Limits of Transformers on Compositionality** — Dziri et al., NeurIPS 2023. https://arxiv.org/abs/2305.18654. *Peer-reviewed / researcher.*
|
| 257 |
+
- GPT-4 zero-shot: 59% on 3×3-digit multiplication, dropping to 4% on 4×4; a few-shot scratchpad lifts 3×3 to 92% but stays near 0 on the hardest cases.
|
| 258 |
+
- Transformers reduce compositional reasoning to "linearized subgraph matching"; under stated assumptions the probability of error converges toward 1 as problem size grows. Exhaustive fine-tuning (~1.8M multiplication pairs) generalized in-distribution but "utterly failed" out-of-distribution.
|
| 259 |
+
|
| 260 |
+
**Why Language Models Hallucinate** — Kalai, Nachum, Vempala, Zhang (OpenAI/Georgia Tech), 2025. https://arxiv.org/abs/2509.04664. *Researcher.*
|
| 261 |
+
- Frames hallucination as statistical error: generative error rate is lower-bounded by the "Is-It-Valid" binary misclassification rate — even with clean data, generating valid outputs is statistically harder than classifying validity.
|
| 262 |
+
- Post-training persists/worsens hallucination because benchmarks reward confident guessing over "I don't know"; the proposed fix is reworking eval metrics to reward calibrated uncertainty/abstention.
|
| 263 |
+
|
| 264 |
+
**Lost in the Middle: How Language Models Use Long Contexts** — Liu et al., TACL 2024. https://arxiv.org/abs/2307.03172. *Peer-reviewed / researcher.*
|
| 265 |
+
- U-shaped accuracy: models use info at the start/end of context well and degrade sharply in the middle, even in explicitly long-context models.
|
| 266 |
+
|
| 267 |
+
**Are Emergent Abilities of Large Language Models a Mirage?** — Schaeffer, Miranda, Koyejo, Stanford, NeurIPS 2023. https://arxiv.org/abs/2304.15004. *Peer-reviewed / researcher.*
|
| 268 |
+
- Core claim: apparent "emergence" appears "due to the researcher's choice of metric rather than due to fundamental changes in model behavior with scale." Nonlinear/discontinuous metrics (exact match) manufacture sharp jumps; linear/continuous metrics (token edit distance, Brier score) show smooth, predictable scaling. They even *induce* apparent emergence in vision models by choosing metrics.
|
| 269 |
+
|
| 270 |
+
**The Reversal Curse: LLMs trained on "A is B" fail to learn "B is A"** — Berglund et al., ICLR 2024. https://arxiv.org/abs/2309.12288. *Peer-reviewed / researcher.*
|
| 271 |
+
- Models trained on "A is B" do not generalize to "B is A": "near 0% accuracy on reversals" across GPT-3-350M, Llama-7B, and GPT-3-175B; the log-probability of the correct reversed name is no higher than a random name. Data augmentation with paraphrases did not fix it. (In-context, models CAN reverse; the curse is about stored/trained knowledge.)
|
| 272 |
+
|
| 273 |
+
**An Empirical Study of Catastrophic Forgetting in LLMs During Continual Fine-tuning** — Luo et al., 2023 (EMNLP 2025 proceedings). https://arxiv.org/abs/2308.08747. *Peer-reviewed / researcher.*
|
| 274 |
+
- Catastrophic forgetting observed across 1B–7B models, and severity INTENSIFIES with scale during continual instruction tuning. Decoder-only BLOOMZ forgets less than encoder-decoder mT0. General instruction tuning (Alpaca vs LLaMA) mitigates forgetting; continual tuning can also reduce some biases (e.g., gender bias).
|
| 275 |
+
|
| 276 |
+
**Alignment tax** — Ouyang et al. (InstructGPT), NeurIPS 2022. https://arxiv.org/abs/2203.02155; confirmed by Lin et al., EMNLP 2024, https://arxiv.org/abs/2309.06256.
|
| 277 |
+
- InstructGPT coined the term: RLHF "comes at the cost of lower performance on certain tasks," with regressions on SQuAD, DROP, HellaSwag, and WMT'15 Fr→En. Mitigation: PPO-ptx (mixing pretraining-distribution gradients into PPO) largely closes the gap without hurting labeler scores. Lin et al. independently confirm the tax on OpenLLaMA-3B/Mistral-7B and note DPO induces less tax than other RLHF algorithms.
|
| 278 |
+
|
| 279 |
+
**DOK 2 Summary (collective for I):** Small (and large) LMs fail systematically at compositional/multi-step reasoning, robustness to irrelevant info, symmetric fact retrieval, middle-of-context use, and calibrated uncertainty. Fine-tuning a narrow behavior can cause narrow forgetting and an alignment tax. Design evals to probe these exact fragilities.
|
| 280 |
+
|
| 281 |
+
### J. Practitioner playbooks
|
| 282 |
+
- **Hamel Husain** (evals), **Sebastian Raschka** (LoRA experiments; "Build a Reasoning Model From Scratch"), **Unsloth / Axolotl** docs (fast QLoRA recipes; HF alignment-handbook), **Argilla / distilabel** (data + preference pipelines).
|
| 283 |
+
- **DOK 2 Summary:** The consensus workflow — define behavior + eval → generate/curate teacher data → QLoRA SFT → base-vs-tuned judge eval → optional DPO → quantize/deploy.
|
| 284 |
+
|
| 285 |
+
### K. Forums / community
|
| 286 |
+
- r/LocalLLaMA, Hugging Face forums, Unsloth/Axolotl Discords, Hacker News threads on QLoRA/DeepSeek-R1. Useful for hardware-specific gotchas, chat-template bugs, and reproductions; treat single-poster claims as anecdotes to verify.
|
| 287 |
+
|
| 288 |
+
## Recommendations
|
| 289 |
+
|
| 290 |
+
**Stage 0 — Define and instrument (before any training).** Write the ONE behavior as a falsifiable spec. Build the eval harness first: a held-out behavioral set plus an adversarial set that perturbs numbers, injects irrelevant clauses (GSM-Symbolic style), reverses relations (reversal curse), and shifts key info to the middle (lost-in-the-middle). Decide the judge protocol now — binary pass/fail, randomized answer order, a different model family as judge. *Benchmark that gates progress:* baseline the untuned model on this harness; the whole project is measured as the base-vs-tuned delta.
|
| 291 |
+
|
| 292 |
+
**Stage 1 — Data.** Generate ~1,000–2,000 examples from a frontier teacher, distilling the *process* (reasoning traces/explanations), then filter for quality and diversity (AlpaGasus-style). Use distilabel/Argilla for pipelines. *Threshold to add more data:* only if the base-vs-tuned delta plateaus below target with clean data — that signals a missing latent capability, not a data-quantity problem.
|
| 293 |
+
|
| 294 |
+
**Stage 2 — Train.** QLoRA (NF4, double quant) on a fully-open small base (SmolLM3-3B or Qwen3-1.7B/4B). Defaults: target all modules, α=2r, r≈16 (raise to 64–256 only if the target metric is capacity-bound), ≥3–4 epochs, sweep LR (LoRA is LR-sensitic). Mask the prompt (loss on completions only); verify the exact chat template. Early-stop on the behavioral eval, not loss. *Escalate to full FT or high-rank LoRA if:* the behavior is a genuinely new hard skill and low-rank LoRA underperforms full FT on the target metric.
|
| 295 |
+
|
| 296 |
+
**Stage 3 — Preference tuning (stretch).** Only after SFT clears most of the target. Build chosen/rejected pairs (or unpaired good/bad for KTO) and run DPO (or ORPO to fold it into one stage). Re-run the full eval to check for an alignment tax — regression on base capabilities. *Threshold to keep it:* preference tuning must improve the target behavior without dropping retained-capability checks.
|
| 297 |
+
|
| 298 |
+
**Stage 4 — Reliability & deploy.** Add constrained decoding (Outlines/XGrammar) for any structured output; measure the quality cost vs the validity gain. Quantize to GGUF (llama.cpp) or AWQ/GPTQ for deployment. *Kill-switch:* if constrained decoding measurably degrades content quality and the base is already schema-reliable, drop it.
|
| 299 |
+
|
| 300 |
+
## Caveats
|
| 301 |
+
- **Contested reasoning claims.** GSM-Symbolic's "not genuine reasoning" framing is disputed (Ivanova and others critique statistical rigor); treat it as strong evidence of *fragility*, not proof of no reasoning.
|
| 302 |
+
- **Vendor vs independent.** DeepSeek-R1, SmolLM3, and QLoRA headline numbers are self-reported by the authors/vendors; the s1, LIMA, MT-Bench, GSM-Symbolic, Faith-and-Fate, and LoRA-Learns-Less findings are peer-reviewed but some rely on GPT-4-as-judge, which carries the biases documented in Section G.
|
| 303 |
+
- **Alignment-tax magnitude is method-dependent.** InstructGPT quantifies regressions qualitatively; the headline drop is largely mitigated by PPO-ptx and DPO, so the "tax" is real but not fixed — measure it on your own benchmarks.
|
| 304 |
+
- **Scale gap in the evidence.** Several marquee results (LIMA-65B, s1-32B, Guanaco-65B, Faith-and-Fate on GPT-4) are on models much larger than the 0.6B–4B target range; the *mechanisms* (superficial alignment, forgetting, compositional limits) transfer, but exact numbers will not. Small models show these failure modes at least as strongly.
|
| 305 |
+
- **Fast-moving field.** Model releases (SmolLM3, Qwen3, DeepSeek-R1) and preference methods (SimPO/ORPO/KTO) are recent; recheck for newer bases and recipes before committing.
|
| 306 |
+
- **Two searches were cut short by budget** (a direct pull of the emergent-abilities and reversal-curse primary PDFs); those facts were verified via a dedicated sub-search against the primary arXiv abstracts and are cited accordingly.
|
notes/research-small-vs-large-math.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Can a Fine-Tuned Small LM Beat a Large LM at Mathematics?
|
| 2 |
+
|
| 3 |
+
**Research report — 2026-07-09.** Scope: can a fine-tuned **small** model (target 0.6B–4B,
|
| 4 |
+
QLoRA/SFT on an open base) reach **benchmark parity with or beat** a large/frontier model at a
|
| 5 |
+
math skill, across four behaviors — **proof generation, grading/evaluation, teaching/tutoring,
|
| 6 |
+
autoformalization** — comparing **informal (NL)** vs **formal (verifiable)** styles. Purpose: pick
|
| 7 |
+
(or reject) a behavior for the one-week QLoRA build described in
|
| 8 |
+
`Train Your Own Small Learning Model.md`. This report *extends* `brainlift.md` (which covers the
|
| 9 |
+
general SFT/QLoRA/eval stack) with math-specific evidence.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Executive answer
|
| 14 |
+
|
| 15 |
+
**"Beat a frontier model on a public math benchmark" is achievable — but only in the
|
| 16 |
+
narrow, specialist-beats-generalist sense, only where the task is machine-checkable, and
|
| 17 |
+
mostly at 7B–8B rather than sub-4B.** The pattern is consistent across all four behaviors:
|
| 18 |
+
|
| 19 |
+
| Behavior | Does a small tuned model beat a frontier model? | Best real evidence | Genuine ≤4B? |
|
| 20 |
+
|---|---|---|---|
|
| 21 |
+
| **Formal proof gen** (Lean) | **Yes, decisively** vs generalists | Goedel-Prover-V2-**8B** > DeepSeek-Prover-V2-**671B** on miniF2F pass@32 (84.6 vs ~82.4) | Kimina-Distill-**1.7B** = 72.95% miniF2F (distilled) |
|
| 22 |
+
| **Grading / verifying** | **Yes**, on first-error localization | Qwen2.5-Math-PRM-**7B** (73.5 F1) > GPT-4o (61.9) on ProcessBench | **GenPRM-1.5B**+Maj@8 (63.4) > GPT-4o ✅ |
|
| 23 |
+
| **Teaching / tutoring** | **Yes**, on "withhold the answer" | DPO Llama-3.1-**8B** > GPT-4o in human pedagogy eval | MathDial Flan-T5-**780M** reveals 4% vs ChatGPT 32% |
|
| 24 |
+
| **Autoformalization** (statement) | **Yes** vs frontier | StepFun-Formalizer-**7B** > R1-671B, o3-pro, Claude-4, Gemini-2.5 on BEq@1 | *(none shown sub-4B; all ~7B)* |
|
| 25 |
+
| **Informal PROSE proofs** (real analysis) | **No — small models don't even compete** | No sub-4B prose-proof generator exists in the literature | ✗ |
|
| 26 |
+
|
| 27 |
+
**The single most important distinction:** every small-model "win" above is on a task with a
|
| 28 |
+
**cheap, objective correctness signal** — a Lean compiler, an integer final answer, a step-label,
|
| 29 |
+
or a "did it reveal the answer" flag. The one behavior with **no** such signal — writing
|
| 30 |
+
rigorous real-analysis **prose proofs** — is exactly the one where small models are absent and
|
| 31 |
+
even frontier models are weak. This is the generator–verifier asymmetry in action, and it should
|
| 32 |
+
drive the project choice.
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## Cross-cutting frame
|
| 37 |
+
|
| 38 |
+
### 1. The generator–verifier asymmetry is confirmed and is the key lever
|
| 39 |
+
Verifying/grading a solution is generically easier than producing one, so a small specialist can
|
| 40 |
+
win at *checking* while losing at *generating*.
|
| 41 |
+
- **Measured gap:** on GPQA-Diamond, oracle Pass@100 = 82.8% but majority-vote selection = 45.5%
|
| 42 |
+
— the right answer is usually generated but not *selected*, so a better (even small) verifier
|
| 43 |
+
captures huge headroom. (Weaver, Stanford Hazy Research, arXiv:2506.18203; GV-gap formalized in
|
| 44 |
+
arXiv:2509.17995.) *Caveat: asymmetry is not universal — some tasks are "easy to solve, hard to
|
| 45 |
+
verify."*
|
| 46 |
+
- **Process supervision beats outcome supervision** at all data scales, and catches
|
| 47 |
+
right-answer/wrong-reasoning cases (OpenAI "Let's Verify Step by Step", arXiv:2305.20050;
|
| 48 |
+
released PRM800K = 800k human step labels).
|
| 49 |
+
- **Implication for the four behaviors:** grading (2) and formal proving/autoformalization (4)
|
| 50 |
+
sit on the easy side of the asymmetry; informal prose-proof generation (1) sits on the hard
|
| 51 |
+
side. Tutoring (3) is a third category — a *policy* problem (withhold the answer), not a
|
| 52 |
+
capability problem.
|
| 53 |
+
|
| 54 |
+
### 2. Scale calibration — most "small beats frontier" headlines are 7B–32B, NOT sub-4B
|
| 55 |
+
Be explicit about the size a result was achieved at. The genuinely **sub-4B** wins are narrow:
|
| 56 |
+
- **GenPRM-1.5B** (+Maj@8) beats GPT-4o on ProcessBench error localization — *the strongest
|
| 57 |
+
clean sub-4B "beats frontier" result found* (arXiv:2504.00891, independent lab). Note the win
|
| 58 |
+
needs test-time voting over 8 samples; greedy Pass@1 (57.3) trails GPT-4o.
|
| 59 |
+
- **Kimina-Prover-Distill-1.7B** = 72.95% miniF2F pass@32 — a real sub-2B formal prover, but
|
| 60 |
+
**distilled from a 72B teacher** (capability inherited, not independently trained) and reliant
|
| 61 |
+
on heavy proof search.
|
| 62 |
+
- **DeepSeek-R1-Distill-Qwen-1.5B** = 83.9% MATH-500 / 28.9% AIME'24 — beats non-reasoning GPT-4o
|
| 63 |
+
but this is **final-answer** accuracy (see §Skill 1), and it loses to o1-mini/o1-preview.
|
| 64 |
+
- Everything else headline-worthy (Goedel-V2-8B, StepFun-7B, DPO-tutor-8B, Qwen-PRM-7B) is
|
| 65 |
+
**7B–8B+**. At 0.6–4B expect a meaningful step down from these numbers.
|
| 66 |
+
|
| 67 |
+
### 3. Contamination / self-report caveats (apply to every number below)
|
| 68 |
+
- **GSM8K/MATH are contaminated.** GSM1k (1,000 fresh analog problems) shows drops up to 13%,
|
| 69 |
+
correlated with memorization; smaller/benchmark-tuned models drop most (Scale AI,
|
| 70 |
+
arXiv:2405.00332). GSM-Symbolic shows all models degrade on templated variants, with Phi-3/3.5
|
| 71 |
+
among the larger droppers (Apple, arXiv:2410.05229) — though a reanalysis argues the
|
| 72 |
+
contamination-vs-distribution-shift evidence is weaker than headlined.
|
| 73 |
+
- **Many prover/PRM headline numbers are vendor self-reported**, sometimes on a
|
| 74 |
+
**same-vendor benchmark** (ProcessBench and Qwen2.5-Math-PRM are both Qwen). Independent
|
| 75 |
+
cross-checks used here: PutnamBench public leaderboard (provers), GenPRM (independent lab,
|
| 76 |
+
corroborates ProcessBench), PRMBench (independent, *tempers* it).
|
| 77 |
+
- **Compute regime dominates prover comparisons.** pass@32 vs pass@8192 vs step-level
|
| 78 |
+
multi-agent tree search vs self-correction loops are different compute classes — read every
|
| 79 |
+
prover % with its sampling budget.
|
| 80 |
+
- A few 2026-dated sources the agents surfaced cite unreleased models (GPT-5.5, Gemini-3.1);
|
| 81 |
+
their specific numbers are treated as **low-confidence** and are not load-bearing here.
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Skill 1 — Proof generation
|
| 86 |
+
|
| 87 |
+
### Formal (Lean/Isabelle): small specialists beat frontier generalists — real and robust
|
| 88 |
+
- **Goedel-Prover-V2-8B** = 84.6% miniF2F pass@32, explicitly **outperforming DeepSeek-Prover-V2-671B
|
| 89 |
+
(~82.4% matched budget) at ~80–100× fewer params**; the **32B** version solves 86/PutnamBench
|
| 90 |
+
(pass@184) vs the 671B's 47, and beats Kimina-72B (arXiv:2508.03613, self-reported;
|
| 91 |
+
PutnamBench leaderboard corroborates).
|
| 92 |
+
- **Kimina-Prover-Distill-1.7B** = 72.95% miniF2F pass@32 (RL variant 76.63%) — smallest
|
| 93 |
+
competitive prover found; exceeds every pre-2025 7B prover and vastly exceeds **GPT-4-direct
|
| 94 |
+
(~20–31%)**. Distilled from Kimina-72B (arXiv:2504.11354; HF AI-MO).
|
| 95 |
+
- **BFS-Prover-V2-32B** = 95.08% miniF2F (step-level multi-agent tree search) — SOTA-class but
|
| 96 |
+
very high inference compute (arXiv:2509.06493).
|
| 97 |
+
- **Why small wins here:** the Lean compiler is a perfect verifier, so a specialist can be trained
|
| 98 |
+
with expert iteration and search-verify loops; frontier generalists are "under-trained on Lean 4"
|
| 99 |
+
(TheoremLlama, arXiv:2407.03203). *But note: these are heavily-engineered systems (distillation
|
| 100 |
+
from huge teachers + tree search + self-correction), not a plain one-week QLoRA.*
|
| 101 |
+
|
| 102 |
+
### Informal (real-analysis prose proofs): small models do NOT compete
|
| 103 |
+
- **No sub-4B (indeed no small) prose-proof generator exists in the literature.** The Open Proof
|
| 104 |
+
Corpus (arXiv:2506.21621) evaluates only frontier/≥235B generators (o3, Gemini-2.5-Pro,
|
| 105 |
+
Qwen3-235B, R1); small models appear at most as **8B graders**.
|
| 106 |
+
- **Right answer ≠ right proof:** o3's score dropped ~30% when a *valid proof* was required (only
|
| 107 |
+
59.5% of its correct answers had a valid proof). This is the crux — the MATH-500/AIME
|
| 108 |
+
small-model "wins" below are all on the answer-accuracy axis, which overstates proof ability.
|
| 109 |
+
- **Even frontier models are weak at analysis proofs.** On analysis-style proof benchmarks the
|
| 110 |
+
best models score in the low tens of percent (and open models ~0% on the hardest tiers).
|
| 111 |
+
FrontierMath: even frontier models are low (o3 ~25% self-reported vs Epoch-independent
|
| 112 |
+
o3-mini 11%; research-tier ≈ 0%), amid a real contamination controversy (OpenAI funded it and
|
| 113 |
+
saw nearly all problems). Prose-proof grading itself is a *recent, still-open* research area
|
| 114 |
+
needing large LLM judges + reference solutions (ProofBench/ProofGrader,
|
| 115 |
+
proofgrader.github.io / arXiv:2510.13888).
|
| 116 |
+
|
| 117 |
+
### Final-answer competition math (the "wins" that aren't proofs)
|
| 118 |
+
- **DeepSeek-R1-Distill-Qwen-1.5B/7B** = 83.9%/92.8% MATH-500, 28.9%/55.5% AIME'24 — 1.5B beats
|
| 119 |
+
non-reasoning GPT-4o (74.6/9.3) but **not** o1-mini (90.0/63.6) (arXiv:2501.12948, self-reported).
|
| 120 |
+
- **rStar-Math**: Qwen2.5-Math-7B 58.8→90.0% MATH, 0→53.3% AIME via MCTS + a 7B process reward
|
| 121 |
+
model; 1.5B → 88.6% MATH; "surpasses o1-preview" **on answer accuracy** (arXiv:2501.04519).
|
| 122 |
+
- **Phi-4-mini-reasoning (3.8B)** = 94.6% MATH-500 / 57.5% AIME'24, distilled from R1 traces
|
| 123 |
+
(arXiv:2504.21233). All of these are **auto-verified integer/expression answers**, not graded
|
| 124 |
+
proofs.
|
| 125 |
+
|
| 126 |
+
**Verdict (Skill 1):** formal proving is a genuine small-beats-large domain but demands
|
| 127 |
+
serious infra; informal real-analysis proof *generation* is the **worst** possible one-week
|
| 128 |
+
target — no small-model precedent, frontier models themselves are weak, and grading is unsolved.
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## Skill 2 — Grading / evaluating proofs (verification)
|
| 133 |
+
|
| 134 |
+
**This is where the cleanest sub-4B "beats frontier" evidence lives.**
|
| 135 |
+
- **Qwen2.5-Math-PRM-7B (73.5 avg F1) beats GPT-4o-0806 (61.9)** at first-error localization on
|
| 136 |
+
**ProcessBench** (GSM8K/MATH/Olympiad/Omni-MATH) (arXiv:2501.07301). The 72B PRM = 78.3.
|
| 137 |
+
- **GenPRM-1.5B (+Maj@8) = 63.4 F1 > GPT-4o (61.9)** — genuine ≤4B win, from an **independent**
|
| 138 |
+
lab, trained on just 23K MATH examples; **GenPRM-7B (80.5) beats the 10× larger 72B PRM**
|
| 139 |
+
(arXiv:2504.00891). *Caveat: the sub-GPT-4o win needs Maj@8 test-time compute + code execution;
|
| 140 |
+
greedy Pass@1 (57.3) trails GPT-4o.*
|
| 141 |
+
- **Load-bearing caveats:**
|
| 142 |
+
1. All specialists still **trail the reasoning frontier model o1-mini (87.9)** on ProcessBench.
|
| 143 |
+
2. On the **harder, adversarial PRMBench**, the same 7B PRM (65.5) drops *below* GPT-4o (66.8)
|
| 144 |
+
and o1-mini (68.8); all models are far under human (83.8) (arXiv:2501.03124, independent).
|
| 145 |
+
3. The advantage comes from **supervision quality** (human/consensus step labels), not size —
|
| 146 |
+
untuned open PRMs lose badly (Math-Shepherd-7B 31.5, Skywork-7B 42.1 vs GPT-4o 61.9).
|
| 147 |
+
4. Headline ProcessBench is **Qwen model on Qwen benchmark** (GenPRM independently corroborates).
|
| 148 |
+
- **LLM-as-judge for math grading** reaches ~86–93% agreement with humans (κ≈0.73–0.81); **binary
|
| 149 |
+
pass/fail beats partial-credit scales** by ~20 F1 points; judges are stricter than humans
|
| 150 |
+
(~10% false-negative bias). Consistent with `brainlift.md`'s judge findings.
|
| 151 |
+
|
| 152 |
+
**Verdict (Skill 2):** the generator–verifier asymmetry is real and *documented at ≤4B* on a
|
| 153 |
+
ready-made public benchmark. Best-supported "small beats frontier on a benchmark" story of the
|
| 154 |
+
four — with the honest caveat that "frontier" here means GPT-4o, not o1-mini.
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## Skill 3 — Teaching / tutoring
|
| 159 |
+
|
| 160 |
+
**Best fit for the project's actual framing ("reliably do ONE narrow behavior"), because
|
| 161 |
+
pedagogy failure is a *policy* problem, not a capability problem.**
|
| 162 |
+
- **Frontier models are bad tutors by default — they reveal the answer.** MRBench: GPT-4 fails
|
| 163 |
+
the "doesn't reveal the answer" dimension ~47% of the time — **worst of all tested LLMs**;
|
| 164 |
+
prompted Mistral-7B (86.5) and Llama-3.1-8B (74.0) already withhold better (NAACL 2025,
|
| 165 |
+
arXiv:2412.09416).
|
| 166 |
+
- **A fine-tuned small model beats GPT-4o on human pedagogy eval.** DPO-tuned **Llama-3.1-8B**
|
| 167 |
+
beats GPT-4o on student-correctness (0.65 vs 0.49) and wins the **human** rubric eval
|
| 168 |
+
(8.55 vs 8.07, p<0.05) (UMass, arXiv:2503.06424). *Caveat: simulated students, and possible
|
| 169 |
+
GPT-4o self-bias as the automated judge.*
|
| 170 |
+
- **The narrow-behavior "reliability" case, quantified at 780M:** MathDial's fine-tuned
|
| 171 |
+
**Flan-T5-780M** reveals the answer **4%** of the time vs prompted **ChatGPT's 32%**, at
|
| 172 |
+
comparable early solve-success (EMNLP-F 2023, arXiv:2305.14536).
|
| 173 |
+
- **BEA-2025 shared task:** small fine-tuned/open models **won the pedagogy tracks** (Guidance =
|
| 174 |
+
Mathstral-7B, Actionability = GLM-4-9B; a 0.5–1.5B entry was competitive); frontier models won
|
| 175 |
+
only "Mistake Location" (arXiv:2507.10579).
|
| 176 |
+
- **Big caveats:** (1) **no objective benchmark** — pedagogy is scored by human rubric, learned
|
| 177 |
+
reward model, or LLM-judge, and generic LLM-judges (Prometheus2, Llama-8B) were found
|
| 178 |
+
*unreliable* for pedagogy (MRBench). (2) Fine-tuning is **not automatically sufficient**:
|
| 179 |
+
MathTutorBench shows two poorly-specialized 7B tutors *lost* to GPT-4o on scaffolding, and
|
| 180 |
+
documents a solving-vs-teaching **trade-off** (arXiv:2502.18940). (3) Essentially **no
|
| 181 |
+
real-student learning-gain evidence** (Khanmigo studies null/pending).
|
| 182 |
+
|
| 183 |
+
**Verdict (Skill 3):** strongest match to "reliability of a narrow behavior" and to the project's
|
| 184 |
+
own litmus test (a prompted frontier model *can't* reliably withhold the answer). Weakest match to
|
| 185 |
+
"beat on a **public benchmark**" because the objective benchmark doesn't exist.
|
| 186 |
+
|
| 187 |
+
---
|
| 188 |
+
|
| 189 |
+
## Skill 4 — Autoformalization (Lean)
|
| 190 |
+
|
| 191 |
+
**The most "beatable" *capability* target, thanks to the free type-check signal — but faithfulness
|
| 192 |
+
is the catch.**
|
| 193 |
+
- **StepFun-Formalizer-7B beats every frontier model tested** — DeepSeek-R1-671B, o3-pro,
|
| 194 |
+
Claude-4-thinking, Gemini-2.5-thinking — on **BEq@1** (compiles AND is bidirectionally
|
| 195 |
+
equivalent to a human ground-truth Lean statement): 38.3 vs 18.4/22.6/20.8/17.8 on
|
| 196 |
+
FormalMATH-Lite (arXiv:2508.04440). 7B ≈ 32B (data-limited).
|
| 197 |
+
- **Herald-7B** = 93.2% miniF2F-test statement formalization (Pass@128, compile + NLI
|
| 198 |
+
back-translation), crushing InternLM2-Math-7B (74.0) and TheoremLlama (50.1) (ICLR 2025,
|
| 199 |
+
arXiv:2410.10878). *Caveat: Pass@128 is a loose "any-of-128" metric.*
|
| 200 |
+
- **The free Lean signal powers self-improvement loops** with little/no labeled data: FormaRL
|
| 201 |
+
(GRPO with compiler + consistency reward, arXiv:2508.18914), DeepSeek-Prover expert iteration,
|
| 202 |
+
Lean Workbook active learning (57K problems @ 93.5% audited).
|
| 203 |
+
- **The catch — compile ≠ faithful.** Every serious pipeline bolts an LLM/NLI/critic judge on top
|
| 204 |
+
of the compiler because a Lean statement can type-check yet mean the wrong thing. Faithfulness
|
| 205 |
+
eval is a **documented open problem**: 31.8% of published Lean-4 ProofNet ports were themselves
|
| 206 |
+
*wrong*, motivating BEq+/ProofNetVerif and critic models like CriticLean (arXiv:2406.07222,
|
| 207 |
+
arXiv:2507.06181). Statement autoformalization is much easier than **full-proof**
|
| 208 |
+
autoformalization.
|
| 209 |
+
- **Scale note:** the winners (StepFun, Herald, Kimina) are all **7B**; no sub-4B autoformalizer
|
| 210 |
+
win was found.
|
| 211 |
+
|
| 212 |
+
**Verdict (Skill 4):** strong small-beats-frontier evidence and a built-in verification signal
|
| 213 |
+
ideal for an expert-iteration loop — but the real deliverable becomes the **faithfulness eval**,
|
| 214 |
+
which is unsolved, and the demonstrated wins are at 7B, not sub-4B.
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## Recommendation for the one-week QLoRA build (0.6–4B)
|
| 219 |
+
|
| 220 |
+
**Is "beating a frontier model on a public benchmark" realistic at 0.6–4B in one week?**
|
| 221 |
+
Partially. It is realistic for a *machine-checkable* task against a *non-reasoning* frontier model
|
| 222 |
+
(GPT-4o), on the *easy side* of the generator–verifier asymmetry. It is **not** realistic to beat a
|
| 223 |
+
reasoning frontier model (o1-mini/o3) or to win at prose-proof *generation*.
|
| 224 |
+
|
| 225 |
+
**Ranked picks:**
|
| 226 |
+
|
| 227 |
+
1. **PRIMARY — Grading / verification (a fine-tuned first-error-localizer / process verifier).**
|
| 228 |
+
- *Only one of the four with a clean sub-4B "beats GPT-4o on a public benchmark" precedent*
|
| 229 |
+
(GenPRM-1.5B on ProcessBench).
|
| 230 |
+
- Fits QLoRA-in-a-week: distill step-level correct/incorrect labels from a frontier teacher
|
| 231 |
+
(PRM800K-style), train a small verifier, evaluate on the ready-made **ProcessBench**
|
| 232 |
+
(objective F1 — no judge-bias problem).
|
| 233 |
+
- Sits on the winning side of the asymmetry; the base model likely *lacks* reliable
|
| 234 |
+
error-localization (passes the project's "a prompt can't already do it" gate).
|
| 235 |
+
- *Honest framing to adopt:* target "beats GPT-4o-class grading," not o1-mini; report the
|
| 236 |
+
PRMBench regression as a robustness caveat.
|
| 237 |
+
|
| 238 |
+
2. **CO-PRIMARY — Tutoring "withhold-the-answer / Socratic hint" behavior.**
|
| 239 |
+
- Best fit to the project's *actual* thesis (reliability of a narrow behavior), with the
|
| 240 |
+
cleanest "prompted frontier model fails, small tune succeeds" evidence (MRBench + DPO-tutor).
|
| 241 |
+
- Cheap data (MathDial/Bridge exist; distill a teacher for Socratic traces) and a cheap
|
| 242 |
+
**binary eval** ("did it reveal the answer?").
|
| 243 |
+
- *Trade-off vs pick 1:* weaker on "public benchmark" (no objective leaderboard; pedagogy
|
| 244 |
+
judged by rubric), so it's the better pick if you weight the project's reliability framing
|
| 245 |
+
over a headline benchmark number.
|
| 246 |
+
|
| 247 |
+
3. **STRETCH — Statement autoformalization (informal math → Lean statement).**
|
| 248 |
+
- Free type-check signal enables a self-improvement loop; strong 7B precedent. But winners are
|
| 249 |
+
7B not sub-4B, and the real work becomes the **faithfulness eval** (an open problem) — likely
|
| 250 |
+
too much for one week at ≤4B unless scoped tightly (one narrow domain, human-audited sample).
|
| 251 |
+
|
| 252 |
+
4. **AVOID — Informal real-analysis prose-proof generation.**
|
| 253 |
+
- No small-model precedent, frontier models themselves are weak, and grading is unsolved. This
|
| 254 |
+
is the hard side of the asymmetry and violates the project's spirit (you'd be chasing a
|
| 255 |
+
capability even frontier models lack). Formal proof *generation* is impressive but needs
|
| 256 |
+
distillation-from-72B + tree-search infra that doesn't fit a one-week QLoRA.
|
| 257 |
+
|
| 258 |
+
**One-line answer:** *A fine-tuned 0.6–4B model can credibly beat a (non-reasoning) frontier model
|
| 259 |
+
at math tasks that have a cheap objective checker — grade/verify steps, withhold answers,
|
| 260 |
+
autoformalize statements — but not at writing rigorous real-analysis proofs. Pick the verifier
|
| 261 |
+
(for a benchmark win) or the Socratic tutor (for the project's reliability thesis); avoid prose
|
| 262 |
+
proof generation.*
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
## Caveats summary
|
| 267 |
+
- Sub-4B evidence is thin; most wins are 7B–8B. Budget for a step down at 0.6–4B.
|
| 268 |
+
- "Beats frontier" almost always means **beats GPT-4o**, not o1-mini/o3.
|
| 269 |
+
- Many prover/PRM numbers are vendor self-reported, sometimes same-vendor benchmark; compute
|
| 270 |
+
budget (pass@k, test-time voting, search) is often doing the heavy lifting.
|
| 271 |
+
- GSM8K/MATH contamination is real; prefer contamination-controlled or held-out evals.
|
| 272 |
+
- For grading/formal/autoformalization the checker is objective; for tutoring it is not — its
|
| 273 |
+
"benchmark" is a rubric/judge, so an adversarial binary behavioral eval (per `brainlift.md`)
|
| 274 |
+
matters most there.
|
| 275 |
+
|
| 276 |
+
## Key sources
|
| 277 |
+
Formal proving: Goedel-Prover-V2 (arXiv:2508.03613), Kimina-Prover (2504.11354), DeepSeek-Prover-V2
|
| 278 |
+
(2504.21801), BFS-Prover-V2 (2509.06493), PutnamBench (2407.11214 + trishullab.github.io/PutnamBench),
|
| 279 |
+
miniF2F (2109.00110), miniF2F-Revisited (2511.03108). Verification: ProcessBench (2412.06559),
|
| 280 |
+
"Lessons of Developing PRMs" (2501.07301), GenPRM (2504.00891), PRMBench (2501.03124), Math-Shepherd
|
| 281 |
+
(2312.08935), Let's Verify Step by Step (2305.20050), Weaver (2506.18203). Tutoring: MathDial
|
| 282 |
+
(2305.14536), DPO tutor (2503.06424), MRBench (2412.09416), MathTutorBench (2502.18940), BEA-2025
|
| 283 |
+
(2507.10579), Bridge (NAACL 2024). Autoformalization: StepFun-Formalizer (2508.04440), Herald
|
| 284 |
+
(2410.10878), Kimina-Autoformalizer (2504.11354), FormaRL (2508.18914), ProofNet (2302.12433),
|
| 285 |
+
type-check/BEq+ (2406.07222), CriticLean (2507.06181), MMA (2311.03755). Proof-eval / ceilings:
|
| 286 |
+
Open Proof Corpus (2506.21621), ProofGrader (2510.13888), FrontierMath (2411.04872). Contamination:
|
| 287 |
+
GSM1k (2405.00332), GSM-Symbolic (2410.05229).
|
pyproject.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "mathcompose"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Two <=4B math models (generative verifier + solution generator) that demonstrate the generator-verifier gap."
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dynamic = ["dependencies"]
|
| 11 |
+
|
| 12 |
+
[tool.setuptools.packages.find]
|
| 13 |
+
where = ["src"]
|
| 14 |
+
|
| 15 |
+
[tool.setuptools.dynamic]
|
| 16 |
+
dependencies = { file = ["requirements.txt"] }
|
| 17 |
+
|
| 18 |
+
[tool.pytest.ini_options]
|
| 19 |
+
testpaths = ["tests"]
|
| 20 |
+
addopts = "-q"
|
| 21 |
+
markers = [
|
| 22 |
+
"network: test hits the network (HF Hub); skip with -m 'not network'",
|
| 23 |
+
"slow: slow test (loads a model)",
|
| 24 |
+
]
|
requirements-gpu.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Extra deps for the GPU training/inference path (Colab / Modal / RunPod).
|
| 2 |
+
# Install on top of requirements.txt on a CUDA box:
|
| 3 |
+
# pip install -r requirements.txt -r requirements-gpu.txt
|
| 4 |
+
#
|
| 5 |
+
# NOTE: install a CUDA build of torch first (Colab ships one). The CPU box uses
|
| 6 |
+
# torch==*+cpu, which cannot run 4-bit QLoRA.
|
| 7 |
+
bitsandbytes>=0.43 # 4-bit NF4 kernels for QLoRA (requires CUDA)
|
| 8 |
+
# Optional speedup (UNCONFIRMED against transformers 5.13 — pin transformers down if you use it):
|
| 9 |
+
# pip install unsloth
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CPU / harness dependencies (already present in the dev box).
|
| 2 |
+
# Pinned loosely to the versions this project was built and verified against.
|
| 3 |
+
transformers>=5.13,<6
|
| 4 |
+
trl>=1.7,<2
|
| 5 |
+
peft>=0.19,<0.20
|
| 6 |
+
accelerate>=1.14
|
| 7 |
+
datasets>=5.0
|
| 8 |
+
huggingface_hub>=1.22
|
| 9 |
+
tokenizers>=0.22
|
| 10 |
+
safetensors>=0.8
|
| 11 |
+
sympy>=1.12
|
| 12 |
+
pyyaml>=6
|
| 13 |
+
numpy>=2.0
|
| 14 |
+
pandas>=2.2
|
| 15 |
+
tqdm>=4.66
|
| 16 |
+
# Teacher clients are optional and imported lazily; install the one you use:
|
| 17 |
+
# pip install anthropic # for the Anthropic teacher
|
| 18 |
+
# pip install openai # for the OpenAI teacher
|
| 19 |
+
# Neither is required for CPU smoke tests (DummyTeacher has no external deps).
|
results/teacher_bakeoff.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Teacher bake-off — Model G (solution generator)
|
| 2 |
+
|
| 3 |
+
Data-driven teacher selection. 40 NuminaMath-CoT problems, one solution each,
|
| 4 |
+
graded by boxed-answer equivalence (`scripts/teacher_bakeoff.py`).
|
| 5 |
+
|
| 6 |
+
scripts/teacher_bakeoff.py --models gpt-5.6-sol claude-opus-4-8 claude-sonnet-5 --n 40
|
| 7 |
+
|
| 8 |
+
| model | keep-rate | median tok | p90 tok | % over 2048 | sec/ex |
|
| 9 |
+
|---|---|---|---|---|---|
|
| 10 |
+
| **claude-opus-4-8** | **57%** | 155 | 703 | 0% | 6.7 |
|
| 11 |
+
| gpt-5.6-sol | 32% | 103 | 324 | 0% | 10.4 |
|
| 12 |
+
| claude-sonnet-5 | 12% | 224 | 691 | 0% | 9.9 |
|
| 13 |
+
|
| 14 |
+
**Winner: `claude-opus-4-8`** — highest keep-rate (most usable data per call),
|
| 15 |
+
fastest, and traces fit the 4096-token student ceiling with room to spare.
|
| 16 |
+
|
| 17 |
+
`claude-fable-5` was requested but **excluded**: 403 on both routes (AWS Bedrock
|
| 18 |
+
Marketplace access not granted on this gateway account).
|
| 19 |
+
|
| 20 |
+
**Caveats:** n=40 is noisy; absolute keep-rates are depressed by NuminaMath
|
| 21 |
+
difficulty + strict grading + format adherence, not just model capability — the
|
| 22 |
+
*relative* ranking is the signal. For the real G build, rejection sampling with
|
| 23 |
+
`--max-attempts > 1` lifts the effective keep-rate.
|
| 24 |
+
|
| 25 |
+
## Teacher assignments
|
| 26 |
+
- **Model G** (solution generator): `claude-opus-4-8`.
|
| 27 |
+
- **Model V** (critique writer; label already known from PRM800K → easier job):
|
| 28 |
+
`claude-haiku-4-5` (cheaper) is sufficient; use `claude-opus-4-8` if budget allows.
|
scripts/check_teacher.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verify the teacher gateway is reachable and returns text.
|
| 2 |
+
|
| 3 |
+
python scripts/check_teacher.py # default: promptlens
|
| 4 |
+
python scripts/check_teacher.py --teacher openai
|
| 5 |
+
|
| 6 |
+
Loads .env, sends a 1-line prompt, prints the reply. Exits non-zero on failure.
|
| 7 |
+
"""
|
| 8 |
+
import argparse
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 13 |
+
|
| 14 |
+
from mathcompose.teachers import get_teacher # noqa: E402
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main() -> int:
|
| 18 |
+
ap = argparse.ArgumentParser()
|
| 19 |
+
ap.add_argument("--teacher", default="promptlens")
|
| 20 |
+
ap.add_argument("--model", default=None)
|
| 21 |
+
ap.add_argument("--prompt", default="Reply with exactly: pong")
|
| 22 |
+
args = ap.parse_args()
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
teacher = get_teacher(args.teacher, **({"model": args.model} if args.model else {}))
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"[FAIL] could not build teacher '{args.teacher}': {e}")
|
| 28 |
+
return 1
|
| 29 |
+
|
| 30 |
+
who = f"{args.teacher} (model={getattr(teacher, 'model', '?')}, base_url={getattr(teacher, 'base_url', None)})"
|
| 31 |
+
print(f"calling {who} ...")
|
| 32 |
+
try:
|
| 33 |
+
reply = teacher.complete(
|
| 34 |
+
[{"role": "user", "content": args.prompt}], temperature=0.0, max_tokens=32
|
| 35 |
+
)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print(f"[FAIL] teacher call errored: {type(e).__name__}: {e}")
|
| 38 |
+
return 1
|
| 39 |
+
|
| 40 |
+
print(f"[OK] reply: {reply!r}")
|
| 41 |
+
return 0
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
if __name__ == "__main__":
|
| 45 |
+
raise SystemExit(main())
|
scripts/download_prm800k.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download PRM800K step-label files (OpenAI, MIT) into data/raw/prm800k/.
|
| 2 |
+
|
| 3 |
+
python scripts/download_prm800k.py # phase2_train (what we use)
|
| 4 |
+
python scripts/download_prm800k.py --all # all four phase files
|
| 5 |
+
|
| 6 |
+
The GitHub raw URLs serve the real JSONL (schema: question/label/steps/...),
|
| 7 |
+
which is exactly what mathcompose.datagen.prm800k_loader expects.
|
| 8 |
+
"""
|
| 9 |
+
import argparse
|
| 10 |
+
import sys
|
| 11 |
+
import urllib.request
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
BASE = "https://github.com/openai/prm800k/raw/main/prm800k/data"
|
| 15 |
+
FILES = ["phase2_train.jsonl", "phase1_train.jsonl", "phase2_test.jsonl", "phase1_test.jsonl"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def download(name: str, out_dir: Path) -> None:
|
| 19 |
+
url = f"{BASE}/{name}"
|
| 20 |
+
dest = out_dir / name
|
| 21 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
print(f"downloading {name} -> {dest}")
|
| 23 |
+
|
| 24 |
+
def _hook(block, bsize, total):
|
| 25 |
+
if total > 0:
|
| 26 |
+
pct = min(100, block * bsize * 100 // total)
|
| 27 |
+
sys.stdout.write(f"\r {pct:3d}% ({block * bsize / 1e6:.0f} MB)")
|
| 28 |
+
sys.stdout.flush()
|
| 29 |
+
|
| 30 |
+
urllib.request.urlretrieve(url, dest, reporthook=_hook)
|
| 31 |
+
print(f"\r done: {dest.stat().st_size / 1e6:.1f} MB{' ' * 10}")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main() -> int:
|
| 35 |
+
ap = argparse.ArgumentParser()
|
| 36 |
+
ap.add_argument("--out-dir", default="data/raw/prm800k")
|
| 37 |
+
ap.add_argument("--all", action="store_true", help="download all four phase files")
|
| 38 |
+
args = ap.parse_args()
|
| 39 |
+
names = FILES if args.all else ["phase2_train.jsonl"]
|
| 40 |
+
for n in names:
|
| 41 |
+
download(n, Path(args.out_dir))
|
| 42 |
+
print("\nNext: python -m mathcompose.data.build_verifier_dataset "
|
| 43 |
+
f"--prm800k {args.out_dir}/phase2_train.jsonl --teacher promptlens --limit 15000")
|
| 44 |
+
return 0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
raise SystemExit(main())
|
scripts/publish_hf.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publish to HF Hub: the V dataset (deliverable) + a self-contained code+data
|
| 2 |
+
snapshot the Colab notebook can pull with just an HF token (no GitHub needed).
|
| 3 |
+
|
| 4 |
+
# after setting HF_TOKEN in .env:
|
| 5 |
+
python scripts/publish_hf.py --dataset --code
|
| 6 |
+
|
| 7 |
+
Creates:
|
| 8 |
+
<user>/mathcompose-verifier (dataset repo) — data/verifier/*.jsonl + card
|
| 9 |
+
<user>/mathcompose (model repo) — full code + data/verifier
|
| 10 |
+
(excludes data/raw, runs, caches)
|
| 11 |
+
The Colab notebook snapshot_downloads the model repo, `pip install -e .`, trains.
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 18 |
+
from mathcompose.common.env import load_dotenv, first_env # noqa: E402
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
|
| 22 |
+
DATASET_CARD = """---
|
| 23 |
+
license: mit
|
| 24 |
+
task_categories: [text-generation]
|
| 25 |
+
tags: [math, process-verification, ProcessBench, PRM800K, mathcompose]
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
# mathcompose — Model V (process verifier) SFT data
|
| 29 |
+
|
| 30 |
+
First-error-localization critiques for training a small generative math verifier.
|
| 31 |
+
Each row: a problem + a step-indexed candidate solution (`prompt`) and a
|
| 32 |
+
paragraph-by-paragraph critique ending in `\\boxed{{k}}` (`completion`), where `k`
|
| 33 |
+
is the 0-based index of the first erroneous step (`-1` = all correct).
|
| 34 |
+
|
| 35 |
+
- **Labels** are ground truth from **PRM800K** (OpenAI, MIT).
|
| 36 |
+
- **Critiques** distilled from **claude-opus-4-8** (rationalization conditioned on
|
| 37 |
+
the known label; the boxed index is stamped authoritatively, not trusted to the teacher).
|
| 38 |
+
- **Deduped** against ProcessBench + MATH-500 (no eval contamination).
|
| 39 |
+
- `train.jsonl`/`val.jsonl` are class-balanced (~50/50 erroneous/all-correct);
|
| 40 |
+
`*_full.jsonl` keep the natural PRM800K distribution.
|
| 41 |
+
|
| 42 |
+
Built by the mathcompose harness. Eval target: ProcessBench first-error F1.
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main() -> int:
|
| 47 |
+
ap = argparse.ArgumentParser()
|
| 48 |
+
ap.add_argument("--dataset", action="store_true", help="publish the dataset repo")
|
| 49 |
+
ap.add_argument("--code", action="store_true", help="publish the code+data snapshot repo")
|
| 50 |
+
ap.add_argument("--user", default=None, help="HF username/org (default: from token)")
|
| 51 |
+
ap.add_argument("--dataset-repo", default=None)
|
| 52 |
+
ap.add_argument("--code-repo", default=None)
|
| 53 |
+
ap.add_argument("--private", action="store_true")
|
| 54 |
+
args = ap.parse_args()
|
| 55 |
+
if not (args.dataset or args.code):
|
| 56 |
+
args.dataset = args.code = True
|
| 57 |
+
|
| 58 |
+
load_dotenv()
|
| 59 |
+
token = first_env(["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"])
|
| 60 |
+
if not token:
|
| 61 |
+
print("No HF token — set HF_TOKEN in .env."); return 1
|
| 62 |
+
|
| 63 |
+
from huggingface_hub import HfApi
|
| 64 |
+
api = HfApi(token=token)
|
| 65 |
+
user = args.user or api.whoami()["name"]
|
| 66 |
+
ds_repo = args.dataset_repo or f"{user}/mathcompose-verifier"
|
| 67 |
+
code_repo = args.code_repo or f"{user}/mathcompose"
|
| 68 |
+
|
| 69 |
+
if args.dataset:
|
| 70 |
+
print(f"publishing dataset -> {ds_repo}")
|
| 71 |
+
api.create_repo(ds_repo, repo_type="dataset", exist_ok=True, private=args.private)
|
| 72 |
+
(ROOT / "data/verifier/README.md").write_text(DATASET_CARD)
|
| 73 |
+
api.upload_folder(
|
| 74 |
+
folder_path=str(ROOT / "data/verifier"),
|
| 75 |
+
repo_id=ds_repo, repo_type="dataset",
|
| 76 |
+
allow_patterns=["*.jsonl", "README.md"],
|
| 77 |
+
)
|
| 78 |
+
print(f" https://huggingface.co/datasets/{ds_repo}")
|
| 79 |
+
|
| 80 |
+
if args.code:
|
| 81 |
+
print(f"publishing code+data snapshot -> {code_repo}")
|
| 82 |
+
api.create_repo(code_repo, repo_type="model", exist_ok=True, private=args.private)
|
| 83 |
+
api.upload_folder(
|
| 84 |
+
folder_path=str(ROOT),
|
| 85 |
+
repo_id=code_repo, repo_type="model",
|
| 86 |
+
ignore_patterns=[
|
| 87 |
+
"data/raw/*", "**/__pycache__/*", "*.egg-info/*", "**/*.pyc",
|
| 88 |
+
"runs/*", ".git/*", ".pytest_cache/*", "data/verifier/*_full.jsonl",
|
| 89 |
+
".env", ".env.*",
|
| 90 |
+
],
|
| 91 |
+
)
|
| 92 |
+
print(f" https://huggingface.co/{code_repo}")
|
| 93 |
+
|
| 94 |
+
print("\nColab: snapshot_download the model repo, `pip install -e .`, then train.")
|
| 95 |
+
return 0
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
raise SystemExit(main())
|
scripts/rebalance_verifier.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rebalance the Model-V dataset by downsampling all-correct (-1) examples.
|
| 2 |
+
|
| 3 |
+
ProcessBench F1 = harmonic_mean(acc_error, acc_correct), so a training set skewed
|
| 4 |
+
toward -1 biases V to over-accept, crushing acc_error and thus F1. This makes a
|
| 5 |
+
~balanced default (correct:erroneous = --ratio) and preserves the original.
|
| 6 |
+
|
| 7 |
+
python scripts/rebalance_verifier.py # train.jsonl + val.jsonl, ratio 1.0
|
| 8 |
+
python scripts/rebalance_verifier.py --ratio 1.5
|
| 9 |
+
|
| 10 |
+
Original files are moved to *_full.jsonl; the balanced set takes the canonical name.
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import random
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def rebalance(path: Path, ratio: float, seed: int) -> tuple[int, int, int]:
|
| 19 |
+
rows = [json.loads(l) for l in path.read_text().splitlines() if l.strip()]
|
| 20 |
+
err = [r for r in rows if r.get("first_error_index", -1) != -1]
|
| 21 |
+
cor = [r for r in rows if r.get("first_error_index", -1) == -1]
|
| 22 |
+
rng = random.Random(seed)
|
| 23 |
+
rng.shuffle(cor)
|
| 24 |
+
keep_cor = cor[: int(round(len(err) * ratio))]
|
| 25 |
+
balanced = err + keep_cor
|
| 26 |
+
rng.shuffle(balanced)
|
| 27 |
+
|
| 28 |
+
full = path.with_name(path.stem + "_full.jsonl")
|
| 29 |
+
if not full.exists():
|
| 30 |
+
path.rename(full) # preserve original once
|
| 31 |
+
with open(path, "w") as f:
|
| 32 |
+
for r in balanced:
|
| 33 |
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 34 |
+
return len(err), len(keep_cor), len(balanced)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def main() -> int:
|
| 38 |
+
ap = argparse.ArgumentParser()
|
| 39 |
+
ap.add_argument("--dir", default="data/verifier")
|
| 40 |
+
ap.add_argument("--ratio", type=float, default=1.0, help="correct:erroneous ratio to keep")
|
| 41 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 42 |
+
args = ap.parse_args()
|
| 43 |
+
for name in ("train.jsonl", "val.jsonl"):
|
| 44 |
+
p = Path(args.dir) / name
|
| 45 |
+
if not p.exists():
|
| 46 |
+
continue
|
| 47 |
+
e, c, t = rebalance(p, args.ratio, args.seed)
|
| 48 |
+
print(f"{name}: {t} rows (erroneous {e}, all-correct {c}); original -> {p.stem}_full.jsonl")
|
| 49 |
+
return 0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
raise SystemExit(main())
|
scripts/smoke_all.sh
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# CPU smoke suite — proves the harness works before spending GPU.
|
| 3 |
+
# Fast logic-only tests by default; add --full to include the tiny-model
|
| 4 |
+
# train/eval and the HF dataset-schema probes (needs network).
|
| 5 |
+
set -euo pipefail
|
| 6 |
+
cd "$(dirname "$0")/.."
|
| 7 |
+
|
| 8 |
+
export HF_HUB_DISABLE_PROGRESS_BARS=1
|
| 9 |
+
export TOKENIZERS_PARALLELISM=false
|
| 10 |
+
|
| 11 |
+
if [[ "${1:-}" == "--full" ]]; then
|
| 12 |
+
echo ">> full smoke suite (logic + tiny-model train/eval + dataset probes)"
|
| 13 |
+
python -m pytest -q -p no:cacheprovider
|
| 14 |
+
else
|
| 15 |
+
echo ">> fast smoke suite (pure logic; no model/network)"
|
| 16 |
+
python -m pytest -q -m "not slow and not network" -p no:cacheprovider
|
| 17 |
+
echo
|
| 18 |
+
echo " (run './scripts/smoke_all.sh --full' to also exercise training + datasets)"
|
| 19 |
+
fi
|
scripts/teacher_bakeoff.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pick a teacher empirically: keep-rate + trace-length-vs-ceiling per model.
|
| 2 |
+
|
| 3 |
+
# after setting TFY_API_KEY in .env:
|
| 4 |
+
python scripts/teacher_bakeoff.py --models claude-fable-5 gpt-5.6-sol --n 40
|
| 5 |
+
|
| 6 |
+
For each model it distills N solutions (same problems), grades the boxed answer,
|
| 7 |
+
and reports: keep-rate (higher = better/cheaper data), median/p90 trace length in
|
| 8 |
+
STUDENT tokens, and the fraction that would overflow the generator's max_length
|
| 9 |
+
(dropped). Highest keep-rate that mostly fits the ceiling wins.
|
| 10 |
+
|
| 11 |
+
The V-critique job is easier (label is known from PRM800K), so a lighter/cheaper
|
| 12 |
+
model usually suffices there regardless of who wins for G.
|
| 13 |
+
"""
|
| 14 |
+
import argparse
|
| 15 |
+
import statistics as stats
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 21 |
+
|
| 22 |
+
from mathcompose.common.chat import load_tokenizer # noqa: E402
|
| 23 |
+
from mathcompose.common.math_grade import extract_last_boxed, grade_answer # noqa: E402
|
| 24 |
+
from mathcompose.datagen.gen_generator_data import Problem, synthesize_solution # noqa: E402
|
| 25 |
+
from mathcompose.teachers import get_teacher # noqa: E402
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_problems(source, split, n, problem_field, solution_field):
|
| 29 |
+
"""Stream the source (no full download) and collect the first n problems that
|
| 30 |
+
have a parseable boxed gold answer."""
|
| 31 |
+
from datasets import load_dataset
|
| 32 |
+
|
| 33 |
+
ds = load_dataset(source, split=split, streaming=True)
|
| 34 |
+
out = []
|
| 35 |
+
for row in ds:
|
| 36 |
+
prob = row.get(problem_field)
|
| 37 |
+
ans = extract_last_boxed(row.get(solution_field) or "")
|
| 38 |
+
if prob and ans:
|
| 39 |
+
out.append(Problem(problem=prob, answer=ans))
|
| 40 |
+
if len(out) >= n:
|
| 41 |
+
break
|
| 42 |
+
return out
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def main() -> int:
|
| 46 |
+
ap = argparse.ArgumentParser()
|
| 47 |
+
ap.add_argument("--models", nargs="+", required=True, help="gateway model slugs to compare")
|
| 48 |
+
ap.add_argument("--teacher", default="promptlens")
|
| 49 |
+
ap.add_argument("--n", type=int, default=40)
|
| 50 |
+
ap.add_argument("--source", default="AI-MO/NuminaMath-CoT")
|
| 51 |
+
ap.add_argument("--split", default="train")
|
| 52 |
+
ap.add_argument("--problem-field", default="problem")
|
| 53 |
+
ap.add_argument("--solution-field", default="solution")
|
| 54 |
+
ap.add_argument("--max-tokens", type=int, default=2048, help="teacher output cap")
|
| 55 |
+
ap.add_argument("--ceiling", type=int, default=2048, help="generator max_length; traces over this are dropped")
|
| 56 |
+
ap.add_argument("--base-id", default="Qwen/Qwen2.5-Math-1.5B-Instruct")
|
| 57 |
+
args = ap.parse_args()
|
| 58 |
+
|
| 59 |
+
problems = load_problems(args.source, args.split, args.n, args.problem_field, args.solution_field)
|
| 60 |
+
print(f"loaded {len(problems)} problems from {args.source}\n")
|
| 61 |
+
tok = load_tokenizer(args.base_id)
|
| 62 |
+
|
| 63 |
+
rows = []
|
| 64 |
+
for slug in args.models:
|
| 65 |
+
teacher = get_teacher(args.teacher, model=slug)
|
| 66 |
+
kept, lens, t0 = 0, [], time.time()
|
| 67 |
+
for p in problems:
|
| 68 |
+
try:
|
| 69 |
+
sol = synthesize_solution(teacher, p.problem, temperature=0.7, max_tokens=args.max_tokens)
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f" [{slug}] call failed: {type(e).__name__}: {e}")
|
| 72 |
+
continue
|
| 73 |
+
n_tok = len(tok(sol)["input_ids"])
|
| 74 |
+
lens.append(n_tok)
|
| 75 |
+
if grade_answer(extract_last_boxed(sol), p.answer):
|
| 76 |
+
kept += 1
|
| 77 |
+
dt = time.time() - t0
|
| 78 |
+
n = len(lens) or 1
|
| 79 |
+
rows.append({
|
| 80 |
+
"model": slug,
|
| 81 |
+
"keep_rate": kept / len(problems),
|
| 82 |
+
"median_tok": int(stats.median(lens)) if lens else 0,
|
| 83 |
+
"p90_tok": int(sorted(lens)[int(0.9 * (len(lens) - 1))]) if lens else 0,
|
| 84 |
+
"over_ceiling": sum(1 for x in lens if x > args.ceiling) / n,
|
| 85 |
+
"sec_per_ex": dt / len(problems),
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
print(f"\n{'model':<24}{'keep':>8}{'med_tok':>9}{'p90_tok':>9}{'>ceil':>8}{'s/ex':>8}")
|
| 89 |
+
print("-" * 66)
|
| 90 |
+
for r in sorted(rows, key=lambda r: -r["keep_rate"]):
|
| 91 |
+
print(f"{r['model']:<24}{r['keep_rate']:>7.0%}{r['median_tok']:>9}{r['p90_tok']:>9}"
|
| 92 |
+
f"{r['over_ceiling']:>7.0%}{r['sec_per_ex']:>7.1f}")
|
| 93 |
+
print("\nPick the highest keep-rate whose p90_tok comfortably fits the ceiling "
|
| 94 |
+
f"({args.ceiling}). Set it via TFY_MODEL or --model at build time.")
|
| 95 |
+
return 0
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
raise SystemExit(main())
|
src/mathcompose/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""mathcompose — two <=4B math models (generative verifier + solution generator)
|
| 2 |
+
that together demonstrate the generator-verifier gap.
|
| 3 |
+
|
| 4 |
+
Subpackages:
|
| 5 |
+
common — chat templating, boxed-answer grading, config, seeding (no heavy deps)
|
| 6 |
+
teachers — pluggable frontier teacher clients (+ offline DummyTeacher)
|
| 7 |
+
datagen — build V/G training data (PRM800K parsing, teacher distillation, dedup)
|
| 8 |
+
data — V output schema + dataset builders
|
| 9 |
+
eval — ProcessBench / PRMBench / MATH / composition metrics (objective, no judge)
|
| 10 |
+
train — shared QLoRA SFT entrypoint
|
| 11 |
+
infer — inference demo
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
__version__ = "0.1.0"
|
src/mathcompose/common/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .math_grade import (
|
| 2 |
+
extract_boxed,
|
| 3 |
+
extract_last_boxed,
|
| 4 |
+
extract_boxed_int,
|
| 5 |
+
grade_answer,
|
| 6 |
+
normalize_final_answer,
|
| 7 |
+
normalize_problem_text,
|
| 8 |
+
)
|
| 9 |
+
from .seeding import set_seed
|
| 10 |
+
from .config import load_config
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"extract_boxed",
|
| 14 |
+
"extract_last_boxed",
|
| 15 |
+
"extract_boxed_int",
|
| 16 |
+
"grade_answer",
|
| 17 |
+
"normalize_final_answer",
|
| 18 |
+
"normalize_problem_text",
|
| 19 |
+
"set_seed",
|
| 20 |
+
"load_config",
|
| 21 |
+
]
|
src/mathcompose/common/chat.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Prompt construction + tokenizer/chat-template helpers.
|
| 2 |
+
|
| 3 |
+
Prompt builders are pure-Python (no heavy deps) so datagen and tests can build
|
| 4 |
+
prompts without importing transformers. ``load_tokenizer`` is the only function
|
| 5 |
+
that touches transformers, and it applies the Qwen2.5-Math pad-token fix.
|
| 6 |
+
|
| 7 |
+
Model V speaks the *official ProcessBench critic prompt*: the solution is split
|
| 8 |
+
into paragraphs, tagged and indexed from 0; V critiques each and returns the
|
| 9 |
+
0-based index of the first erroneous paragraph in ``\boxed{}`` (``-1`` = all
|
| 10 |
+
correct). Using ProcessBench's own format means the same model scores
|
| 11 |
+
ProcessBench *and* reranks Model G with zero prompt divergence.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
# Qwen2.5-Math default CoT system prompt (do NOT use the TIR variant here).
|
| 18 |
+
GENERATOR_SYSTEM = (
|
| 19 |
+
"Please reason step by step, and put your final answer within \\boxed{}."
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
VERIFIER_SYSTEM = (
|
| 23 |
+
"You are a meticulous math grader. You are given a problem and a candidate "
|
| 24 |
+
"solution split into indexed paragraphs. Critically review the solution one "
|
| 25 |
+
"paragraph at a time and find the FIRST paragraph that contains an error."
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# The official ProcessBench critic instruction (verbatim intent).
|
| 29 |
+
_VERIFIER_INSTRUCTION = (
|
| 30 |
+
"The following is a math problem and a solution (split into paragraphs, "
|
| 31 |
+
"enclosed with tags and indexed from 0):\n\n"
|
| 32 |
+
"[Math Problem]\n{problem}\n\n"
|
| 33 |
+
"[Solution]\n{tagged_steps}\n\n"
|
| 34 |
+
"Your task is to review and critique the solution paragraph by paragraph. "
|
| 35 |
+
"Once you identify an error in a paragraph, return the index of the paragraph "
|
| 36 |
+
"where the earliest error occurs. Otherwise, return the index of -1 (which "
|
| 37 |
+
"typically denotes \"all correct\").\n\n"
|
| 38 |
+
"Please put your final answer (i.e., the index) in \\boxed{{}}."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def tag_steps(steps: list[str]) -> str:
|
| 43 |
+
"""Render a list of solution steps as indexed <paragraph_i> blocks."""
|
| 44 |
+
return "\n\n".join(
|
| 45 |
+
f"<paragraph_{i}>\n{s.strip()}\n</paragraph_{i}>" for i, s in enumerate(steps)
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def build_verifier_prompt(problem: str, steps: list[str]) -> str:
|
| 50 |
+
"""User-turn content for Model V (ProcessBench critic format)."""
|
| 51 |
+
return _VERIFIER_INSTRUCTION.format(problem=problem.strip(), tagged_steps=tag_steps(steps))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def verifier_messages(problem: str, steps: list[str]) -> list[dict]:
|
| 55 |
+
return [
|
| 56 |
+
{"role": "system", "content": VERIFIER_SYSTEM},
|
| 57 |
+
{"role": "user", "content": build_verifier_prompt(problem, steps)},
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_generator_prompt(problem: str) -> str:
|
| 62 |
+
return problem.strip()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def generator_messages(problem: str) -> list[dict]:
|
| 66 |
+
return [
|
| 67 |
+
{"role": "system", "content": GENERATOR_SYSTEM},
|
| 68 |
+
{"role": "user", "content": build_generator_prompt(problem)},
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def split_into_steps(solution: str) -> list[str]:
|
| 73 |
+
"""Split a free-form solution into paragraph 'steps' the way ProcessBench does
|
| 74 |
+
(double-newline separated, non-empty). Used when reranking G's own outputs."""
|
| 75 |
+
parts = [p.strip() for p in solution.replace("\r\n", "\n").split("\n\n")]
|
| 76 |
+
return [p for p in parts if p]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ----------------------------------------------------------------------------
|
| 80 |
+
# Tokenizer (transformers) — only import when actually loading a model.
|
| 81 |
+
# ----------------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
def load_tokenizer(base_id: str, pad_token: Optional[str] = "<|fim_pad|>"):
|
| 84 |
+
"""Load the tokenizer and apply the Qwen pad-token fix.
|
| 85 |
+
|
| 86 |
+
Using <|endoftext|> as the pad token triggers infinite generations after
|
| 87 |
+
finetuning (documented Qwen2.5 gotcha); we pin a distinct pad token.
|
| 88 |
+
"""
|
| 89 |
+
from transformers import AutoTokenizer
|
| 90 |
+
|
| 91 |
+
tok = AutoTokenizer.from_pretrained(base_id)
|
| 92 |
+
if pad_token and pad_token in tok.get_vocab():
|
| 93 |
+
tok.pad_token = pad_token
|
| 94 |
+
elif tok.pad_token is None:
|
| 95 |
+
tok.pad_token = tok.eos_token
|
| 96 |
+
# Base (non-chat) models and some tiny test tokenizers ship no chat template;
|
| 97 |
+
# fall back to a minimal ChatML so apply_chat_template always works.
|
| 98 |
+
if getattr(tok, "chat_template", None) is None:
|
| 99 |
+
tok.chat_template = _MINIMAL_CHATML
|
| 100 |
+
return tok
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
_MINIMAL_CHATML = (
|
| 104 |
+
"{% for m in messages %}"
|
| 105 |
+
"{{ '<|im_start|>' + m['role'] + '\n' + m['content'] + '<|im_end|>\n' }}"
|
| 106 |
+
"{% endfor %}"
|
| 107 |
+
"{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}"
|
| 108 |
+
)
|
src/mathcompose/common/config.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny YAML config loader with single-level `extends` inheritance and deep-merge.
|
| 2 |
+
|
| 3 |
+
cfg = load_config("configs/verifier_v.yaml")
|
| 4 |
+
cfg["model"]["base_id"] # inherited from base.yaml, overridable per-file
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import yaml
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _deep_merge(base: dict, override: dict) -> dict:
|
| 16 |
+
out = copy.deepcopy(base)
|
| 17 |
+
for k, v in override.items():
|
| 18 |
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
| 19 |
+
out[k] = _deep_merge(out[k], v)
|
| 20 |
+
else:
|
| 21 |
+
out[k] = copy.deepcopy(v)
|
| 22 |
+
return out
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_config(path: str | Path) -> dict[str, Any]:
|
| 26 |
+
path = Path(path)
|
| 27 |
+
with open(path) as f:
|
| 28 |
+
cfg = yaml.safe_load(f) or {}
|
| 29 |
+
parent = cfg.pop("extends", None)
|
| 30 |
+
if parent:
|
| 31 |
+
parent_cfg = load_config(path.parent / parent)
|
| 32 |
+
cfg = _deep_merge(parent_cfg, cfg)
|
| 33 |
+
return cfg
|
src/mathcompose/common/env.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal .env loader (no python-dotenv dependency).
|
| 2 |
+
|
| 3 |
+
Parses simple KEY=VALUE lines (optionally `export KEY=VALUE`, quoted values,
|
| 4 |
+
`#` comments) from a .env file and puts them in os.environ. Existing env vars
|
| 5 |
+
win unless override=True.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_dotenv(path: str | Path = ".env", override: bool = False) -> bool:
|
| 14 |
+
p = Path(path)
|
| 15 |
+
if not p.exists():
|
| 16 |
+
return False
|
| 17 |
+
for raw in p.read_text().splitlines():
|
| 18 |
+
line = raw.strip()
|
| 19 |
+
if not line or line.startswith("#"):
|
| 20 |
+
continue
|
| 21 |
+
if line.startswith("export "):
|
| 22 |
+
line = line[len("export "):]
|
| 23 |
+
if "=" not in line:
|
| 24 |
+
continue
|
| 25 |
+
key, val = line.split("=", 1)
|
| 26 |
+
key = key.strip()
|
| 27 |
+
val = val.strip().strip('"').strip("'")
|
| 28 |
+
if override or key not in os.environ:
|
| 29 |
+
os.environ[key] = val
|
| 30 |
+
return True
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def first_env(names: list[str]) -> str | None:
|
| 34 |
+
"""Return the first set-and-non-empty env var among names."""
|
| 35 |
+
for n in names:
|
| 36 |
+
v = os.environ.get(n)
|
| 37 |
+
if v:
|
| 38 |
+
return v
|
| 39 |
+
return None
|
src/mathcompose/common/io.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""jsonl read/write helpers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Iterable, Iterator
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def write_jsonl(rows: Iterable[dict], path: str | Path) -> int:
|
| 10 |
+
path = Path(path)
|
| 11 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 12 |
+
n = 0
|
| 13 |
+
with open(path, "w") as f:
|
| 14 |
+
for r in rows:
|
| 15 |
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 16 |
+
n += 1
|
| 17 |
+
return n
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def read_jsonl(path: str | Path) -> Iterator[dict]:
|
| 21 |
+
with open(path) as f:
|
| 22 |
+
for line in f:
|
| 23 |
+
line = line.strip()
|
| 24 |
+
if line:
|
| 25 |
+
yield json.loads(line)
|
src/mathcompose/common/math_grade.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Boxed-answer extraction and math-equivalence grading.
|
| 2 |
+
|
| 3 |
+
Two independent jobs:
|
| 4 |
+
|
| 5 |
+
1. ``extract_boxed_int`` — pull the integer inside the LAST ``\boxed{...}``.
|
| 6 |
+
Used by Model V, whose canonical output ends in ``\boxed{k}`` (first-error
|
| 7 |
+
index, ``-1`` = all steps correct). This is the ProcessBench primitive.
|
| 8 |
+
|
| 9 |
+
2. ``grade_answer`` — decide whether a predicted final answer is mathematically
|
| 10 |
+
equivalent to the gold answer. Used by Model G's eval and by the data filter.
|
| 11 |
+
Best-effort: string normalization -> sympy simplify -> numeric fallback,
|
| 12 |
+
fully guarded so it degrades to a normalized string compare. Swap in
|
| 13 |
+
``math_verify`` / the PRM800K sympy grader later if you want stricter grading.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
# ----------------------------------------------------------------------------
|
| 21 |
+
# Boxed extraction (balanced-brace aware)
|
| 22 |
+
# ----------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
_BOXED_PREFIXES = (r"\boxed", r"\fbox")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _find_boxed_spans(text: str) -> list[str]:
|
| 28 |
+
"""Return the *contents* of every ``\boxed{...}`` / ``\fbox{...}`` with
|
| 29 |
+
balanced-brace matching (so ``\boxed{\frac{1}{2}}`` returns ``\frac{1}{2}``)."""
|
| 30 |
+
results: list[str] = []
|
| 31 |
+
for prefix in _BOXED_PREFIXES:
|
| 32 |
+
start = 0
|
| 33 |
+
while True:
|
| 34 |
+
idx = text.find(prefix, start)
|
| 35 |
+
if idx == -1:
|
| 36 |
+
break
|
| 37 |
+
i = idx + len(prefix)
|
| 38 |
+
# skip whitespace, expect an opening brace
|
| 39 |
+
while i < len(text) and text[i] in " \t":
|
| 40 |
+
i += 1
|
| 41 |
+
if i >= len(text) or text[i] != "{":
|
| 42 |
+
# \boxed without braces, e.g. "\boxed -1" — grab the token
|
| 43 |
+
m = re.match(r"\s*(-?\d+)", text[idx + len(prefix):])
|
| 44 |
+
if m:
|
| 45 |
+
results.append(m.group(1))
|
| 46 |
+
start = idx + len(prefix)
|
| 47 |
+
continue
|
| 48 |
+
depth = 0
|
| 49 |
+
j = i
|
| 50 |
+
while j < len(text):
|
| 51 |
+
if text[j] == "{":
|
| 52 |
+
depth += 1
|
| 53 |
+
elif text[j] == "}":
|
| 54 |
+
depth -= 1
|
| 55 |
+
if depth == 0:
|
| 56 |
+
break
|
| 57 |
+
j += 1
|
| 58 |
+
results.append(text[i + 1 : j])
|
| 59 |
+
start = j + 1
|
| 60 |
+
return results
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def extract_boxed(text: str) -> Optional[str]:
|
| 64 |
+
"""Contents of the FIRST boxed expression, or None."""
|
| 65 |
+
spans = _find_boxed_spans(text)
|
| 66 |
+
return spans[0] if spans else None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def extract_last_boxed(text: str) -> Optional[str]:
|
| 70 |
+
"""Contents of the LAST boxed expression, or None. This is the final answer."""
|
| 71 |
+
spans = _find_boxed_spans(text)
|
| 72 |
+
return spans[-1] if spans else None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def extract_boxed_int(text: str) -> Optional[int]:
|
| 76 |
+
"""Integer inside the LAST boxed expression (Model V's first-error index).
|
| 77 |
+
|
| 78 |
+
Tolerant of ``\boxed{-1}``, ``\boxed{ 3 }``, ``\boxed{\text{-1}}``, and a
|
| 79 |
+
bare trailing ``-1`` if no box is present at all.
|
| 80 |
+
"""
|
| 81 |
+
span = extract_last_boxed(text)
|
| 82 |
+
if span is not None:
|
| 83 |
+
m = re.search(r"-?\d+", span)
|
| 84 |
+
if m:
|
| 85 |
+
return int(m.group())
|
| 86 |
+
# Last-ditch: a trailing integer on the final non-empty line.
|
| 87 |
+
for line in reversed([l for l in text.strip().splitlines() if l.strip()]):
|
| 88 |
+
m = re.search(r"(-?\d+)\s*$", line.strip())
|
| 89 |
+
if m:
|
| 90 |
+
return int(m.group(1))
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ----------------------------------------------------------------------------
|
| 95 |
+
# Answer normalization + equivalence
|
| 96 |
+
# ----------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
_SUBST = [
|
| 99 |
+
(r"\\left", ""),
|
| 100 |
+
(r"\\right", ""),
|
| 101 |
+
(r"\\!", ""),
|
| 102 |
+
(r"\\,", ""),
|
| 103 |
+
(r"\\;", ""),
|
| 104 |
+
(r"\\ ", " "),
|
| 105 |
+
(r"\\%", ""),
|
| 106 |
+
(r"\\\$", ""),
|
| 107 |
+
(r"\\cdot", "*"),
|
| 108 |
+
(r"\\times", "*"),
|
| 109 |
+
(r"\\div", "/"),
|
| 110 |
+
(r"\\pi", "pi"),
|
| 111 |
+
(r"\\pm", ""),
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _strip_text_wrappers(s: str) -> str:
|
| 116 |
+
# \text{...}, \mathrm{...}, \mbox{...} -> inner
|
| 117 |
+
for macro in ("text", "mathrm", "mbox", "textbf", "mathbf"):
|
| 118 |
+
s = re.sub(r"\\" + macro + r"\{([^{}]*)\}", r"\1", s)
|
| 119 |
+
return s
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _normalize_answer(s: str) -> str:
|
| 123 |
+
if s is None:
|
| 124 |
+
return ""
|
| 125 |
+
s = str(s).strip()
|
| 126 |
+
# strip surrounding math delimiters and whitespace
|
| 127 |
+
s = s.replace("$", "").replace("\\(", "").replace("\\)", "")
|
| 128 |
+
s = s.replace("\\[", "").replace("\\]", "")
|
| 129 |
+
s = _strip_text_wrappers(s)
|
| 130 |
+
for pat, rep in _SUBST:
|
| 131 |
+
s = re.sub(pat, rep, s)
|
| 132 |
+
s = s.replace("°", "") # degree sign
|
| 133 |
+
s = s.replace("^{\\circ}", "").replace("^\\circ", "").replace("\\circ", "")
|
| 134 |
+
s = s.replace(" ", "")
|
| 135 |
+
s = s.rstrip(".")
|
| 136 |
+
# thousands separators inside numbers: 1,000 -> 1000
|
| 137 |
+
s = re.sub(r"(?<=\d),(?=\d{3}\b)", "", s)
|
| 138 |
+
# trailing units words like "dollars", "cm" — conservative: only strip a
|
| 139 |
+
# trailing alpha run if the rest is numeric-ish.
|
| 140 |
+
return s
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _latex_to_sympy(s: str) -> str:
|
| 144 |
+
"""Very small latex->sympy-parseable rewrite (no latex2sympy dependency)."""
|
| 145 |
+
# \frac{a}{b} -> ((a)/(b)) (repeat for nesting)
|
| 146 |
+
for _ in range(6):
|
| 147 |
+
new = re.sub(r"\\d?frac\{([^{}]*)\}\{([^{}]*)\}", r"((\1)/(\2))", s)
|
| 148 |
+
new = re.sub(r"\\sqrt\{([^{}]*)\}", r"sqrt(\1)", new)
|
| 149 |
+
new = re.sub(r"\\sqrt\[(\d+)\]\{([^{}]*)\}", r"(\2)**(1/\1)", new)
|
| 150 |
+
if new == s:
|
| 151 |
+
break
|
| 152 |
+
s = new
|
| 153 |
+
s = s.replace("{", "(").replace("}", ")")
|
| 154 |
+
s = re.sub(r"\^", "**", s)
|
| 155 |
+
s = s.replace("\\", "")
|
| 156 |
+
return s
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _try_float(s: str) -> Optional[float]:
|
| 160 |
+
try:
|
| 161 |
+
return float(s)
|
| 162 |
+
except (ValueError, TypeError):
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def normalize_final_answer(s: str) -> str:
|
| 167 |
+
"""Public normalizer for bucketing answers in majority voting."""
|
| 168 |
+
return _normalize_answer(s)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def grade_answer(pred: Optional[str], gold: Optional[str]) -> bool:
|
| 172 |
+
"""True iff `pred` is mathematically equivalent to `gold`.
|
| 173 |
+
|
| 174 |
+
Order: exact normalized string -> numeric -> sympy simplify. Fully guarded.
|
| 175 |
+
"""
|
| 176 |
+
if pred is None or gold is None:
|
| 177 |
+
return False
|
| 178 |
+
p, g = _normalize_answer(pred), _normalize_answer(gold)
|
| 179 |
+
if p == "" and g == "":
|
| 180 |
+
return False
|
| 181 |
+
if p == g:
|
| 182 |
+
return True
|
| 183 |
+
|
| 184 |
+
# numeric comparison
|
| 185 |
+
pf, gf = _try_float(p), _try_float(g)
|
| 186 |
+
if pf is not None and gf is not None:
|
| 187 |
+
return abs(pf - gf) <= 1e-6 * max(1.0, abs(gf))
|
| 188 |
+
|
| 189 |
+
# symbolic comparison
|
| 190 |
+
try:
|
| 191 |
+
import sympy as sp
|
| 192 |
+
from sympy.parsing.sympy_parser import (
|
| 193 |
+
parse_expr,
|
| 194 |
+
standard_transformations,
|
| 195 |
+
implicit_multiplication_application,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
transforms = standard_transformations + (
|
| 199 |
+
implicit_multiplication_application,
|
| 200 |
+
)
|
| 201 |
+
ep = parse_expr(_latex_to_sympy(p), transformations=transforms, evaluate=True)
|
| 202 |
+
eg = parse_expr(_latex_to_sympy(g), transformations=transforms, evaluate=True)
|
| 203 |
+
diff = sp.simplify(ep - eg)
|
| 204 |
+
if diff == 0:
|
| 205 |
+
return True
|
| 206 |
+
# fall back to numeric equality of the two expressions
|
| 207 |
+
try:
|
| 208 |
+
return bool(abs(float(ep) - float(eg)) <= 1e-6)
|
| 209 |
+
except (TypeError, ValueError):
|
| 210 |
+
return False
|
| 211 |
+
except Exception:
|
| 212 |
+
return False
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ----------------------------------------------------------------------------
|
| 216 |
+
# Problem-text normalization for contamination dedup
|
| 217 |
+
# ----------------------------------------------------------------------------
|
| 218 |
+
|
| 219 |
+
def normalize_problem_text(s: str) -> str:
|
| 220 |
+
"""Aggressive normalization so the same problem from different sources
|
| 221 |
+
(PRM800K vs ProcessBench vs MATH-500) collides for dedup."""
|
| 222 |
+
if s is None:
|
| 223 |
+
return ""
|
| 224 |
+
s = s.lower()
|
| 225 |
+
s = _strip_text_wrappers(s)
|
| 226 |
+
s = re.sub(r"[^a-z0-9]+", "", s) # keep only alnum; drop all latex/space/punct
|
| 227 |
+
return s
|
src/mathcompose/common/parallel.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ordered, fault-tolerant thread-pool map for I/O-bound teacher calls.
|
| 2 |
+
|
| 3 |
+
API calls are I/O-bound, so threads give near-linear speedup. Results preserve
|
| 4 |
+
input order; any item whose fn raises becomes None (so callers filter(None)).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
+
from typing import Callable, Iterable, Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def thread_map(
|
| 13 |
+
fn: Callable,
|
| 14 |
+
items: Iterable,
|
| 15 |
+
workers: int = 8,
|
| 16 |
+
desc: Optional[str] = None,
|
| 17 |
+
) -> list:
|
| 18 |
+
items = list(items)
|
| 19 |
+
results: list = [None] * len(items)
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from tqdm.auto import tqdm
|
| 23 |
+
except Exception: # pragma: no cover
|
| 24 |
+
def tqdm(x, **k):
|
| 25 |
+
return x
|
| 26 |
+
|
| 27 |
+
if workers <= 1:
|
| 28 |
+
seq = enumerate(items)
|
| 29 |
+
for i, x in tqdm(seq, total=len(items), desc=desc):
|
| 30 |
+
try:
|
| 31 |
+
results[i] = fn(x)
|
| 32 |
+
except Exception:
|
| 33 |
+
results[i] = None
|
| 34 |
+
return results
|
| 35 |
+
|
| 36 |
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
| 37 |
+
futs = {ex.submit(fn, x): i for i, x in enumerate(items)}
|
| 38 |
+
for fut in tqdm(as_completed(futs), total=len(items), desc=desc):
|
| 39 |
+
i = futs[fut]
|
| 40 |
+
try:
|
| 41 |
+
results[i] = fut.result()
|
| 42 |
+
except Exception:
|
| 43 |
+
results[i] = None
|
| 44 |
+
return results
|
src/mathcompose/common/seeding.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic seeding across random / numpy / torch (torch optional)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def set_seed(seed: int = 0) -> None:
|
| 9 |
+
os.environ["PYTHONHASHSEED"] = str(seed)
|
| 10 |
+
random.seed(seed)
|
| 11 |
+
try:
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
np.random.seed(seed)
|
| 15 |
+
except Exception:
|
| 16 |
+
pass
|
| 17 |
+
try:
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
torch.manual_seed(seed)
|
| 21 |
+
if torch.cuda.is_available():
|
| 22 |
+
torch.cuda.manual_seed_all(seed)
|
| 23 |
+
except Exception:
|
| 24 |
+
pass
|
src/mathcompose/data/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .schema import (
|
| 2 |
+
StepVerdict,
|
| 3 |
+
VerifierOutput,
|
| 4 |
+
serialize_verifier_output,
|
| 5 |
+
parse_verifier_output,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"StepVerdict",
|
| 10 |
+
"VerifierOutput",
|
| 11 |
+
"serialize_verifier_output",
|
| 12 |
+
"parse_verifier_output",
|
| 13 |
+
]
|
src/mathcompose/data/build_generator_dataset.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Build Model G's SFT dataset by distilling CoT solutions from the teacher and
|
| 2 |
+
keeping only answer-correct traces.
|
| 3 |
+
|
| 4 |
+
python -m mathcompose.data.build_generator_dataset \
|
| 5 |
+
--source AI-MO/NuminaMath-CoT --split train \
|
| 6 |
+
--teacher anthropic --limit 8000 --out-dir data/generator
|
| 7 |
+
|
| 8 |
+
Problem/answer are read from the HF dataset; if no explicit answer field exists,
|
| 9 |
+
the gold answer is taken from the LAST \boxed{} in the dataset's own solution.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import random
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from ..common.io import write_jsonl
|
| 19 |
+
from ..common.math_grade import extract_last_boxed, grade_answer
|
| 20 |
+
from ..common.parallel import thread_map
|
| 21 |
+
from ..teachers import get_teacher
|
| 22 |
+
from ..datagen.dedup import build_contamination_set, is_contaminated
|
| 23 |
+
from ..datagen.gen_generator_data import Problem, synthesize_solution, make_generator_row
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _load_problems(source: str, split: str, problem_field: str, answer_field: str | None,
|
| 27 |
+
solution_field: str, limit: int | None) -> list[Problem]:
|
| 28 |
+
from datasets import load_dataset
|
| 29 |
+
|
| 30 |
+
ds = load_dataset(source, split=split)
|
| 31 |
+
if limit:
|
| 32 |
+
ds = ds.select(range(min(limit, len(ds))))
|
| 33 |
+
problems: list[Problem] = []
|
| 34 |
+
for row in ds:
|
| 35 |
+
prob = row.get(problem_field)
|
| 36 |
+
if not prob:
|
| 37 |
+
continue
|
| 38 |
+
if answer_field and row.get(answer_field) not in (None, ""):
|
| 39 |
+
ans = str(row[answer_field])
|
| 40 |
+
else:
|
| 41 |
+
sol = row.get(solution_field) or ""
|
| 42 |
+
ans = extract_last_boxed(sol)
|
| 43 |
+
if not ans:
|
| 44 |
+
continue
|
| 45 |
+
problems.append(Problem(problem=prob, answer=ans))
|
| 46 |
+
return problems
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main() -> None:
|
| 50 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 51 |
+
ap.add_argument("--source", default="AI-MO/NuminaMath-CoT")
|
| 52 |
+
ap.add_argument("--split", default="train")
|
| 53 |
+
ap.add_argument("--problem-field", default="problem")
|
| 54 |
+
ap.add_argument("--answer-field", default=None, help="explicit answer column, if any")
|
| 55 |
+
ap.add_argument("--solution-field", default="solution", help="fallback: parse \\boxed{} from here")
|
| 56 |
+
ap.add_argument("--out-dir", default="data/generator")
|
| 57 |
+
ap.add_argument("--teacher", default="promptlens",
|
| 58 |
+
choices=["promptlens", "tfy", "anthropic", "openai", "dummy"])
|
| 59 |
+
ap.add_argument("--model", default=None)
|
| 60 |
+
ap.add_argument("--limit", type=int, default=None)
|
| 61 |
+
ap.add_argument("--val-frac", type=float, default=0.05)
|
| 62 |
+
ap.add_argument("--temperature", type=float, default=0.7)
|
| 63 |
+
ap.add_argument("--max-attempts", type=int, default=2)
|
| 64 |
+
ap.add_argument("--workers", type=int, default=8, help="concurrent teacher calls")
|
| 65 |
+
ap.add_argument("--keep-all", action="store_true", help="skip the answer-correct filter")
|
| 66 |
+
ap.add_argument("--no-dedup", action="store_true")
|
| 67 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 68 |
+
args = ap.parse_args()
|
| 69 |
+
|
| 70 |
+
random.seed(args.seed)
|
| 71 |
+
teacher = get_teacher(args.teacher, **({"model": args.model} if args.model else {}))
|
| 72 |
+
|
| 73 |
+
banned = set()
|
| 74 |
+
if not args.no_dedup:
|
| 75 |
+
print("Building contamination set from ProcessBench + MATH-500 ...")
|
| 76 |
+
banned = build_contamination_set()
|
| 77 |
+
print(f" {len(banned)} eval problems banned")
|
| 78 |
+
|
| 79 |
+
problems = [
|
| 80 |
+
p for p in _load_problems(args.source, args.split, args.problem_field,
|
| 81 |
+
args.answer_field, args.solution_field, args.limit)
|
| 82 |
+
if not (banned and is_contaminated(p.problem, banned))
|
| 83 |
+
]
|
| 84 |
+
print(f"Loaded {len(problems)} problems; distilling "
|
| 85 |
+
f"({args.workers} workers, teacher={args.teacher}) ...")
|
| 86 |
+
|
| 87 |
+
def _one(p):
|
| 88 |
+
for _ in range(args.max_attempts):
|
| 89 |
+
sol = synthesize_solution(teacher, p.problem, temperature=args.temperature)
|
| 90 |
+
if args.keep_all or grade_answer(extract_last_boxed(sol), p.answer):
|
| 91 |
+
return make_generator_row(p.problem, sol)
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
rows = [r for r in thread_map(_one, problems, workers=args.workers, desc="generator") if r]
|
| 95 |
+
random.shuffle(rows)
|
| 96 |
+
|
| 97 |
+
n_val = max(1, int(len(rows) * args.val_frac)) if rows else 0
|
| 98 |
+
val, train = rows[:n_val], rows[n_val:]
|
| 99 |
+
|
| 100 |
+
out = Path(args.out_dir)
|
| 101 |
+
n_train = write_jsonl(train, out / "train.jsonl")
|
| 102 |
+
n_val = write_jsonl(val, out / "val.jsonl")
|
| 103 |
+
stats = {
|
| 104 |
+
"n_problems": len(problems),
|
| 105 |
+
"n_kept": len(rows),
|
| 106 |
+
"n_train": n_train,
|
| 107 |
+
"n_val": n_val,
|
| 108 |
+
"keep_rate": (len(rows) / len(problems)) if problems else 0.0,
|
| 109 |
+
"teacher": args.teacher,
|
| 110 |
+
"filtered_by_answer": not args.keep_all,
|
| 111 |
+
}
|
| 112 |
+
(out / "stats.json").write_text(json.dumps(stats, indent=2))
|
| 113 |
+
print(json.dumps(stats, indent=2))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
main()
|
src/mathcompose/data/build_verifier_dataset.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Build Model V's SFT dataset from PRM800K.
|
| 2 |
+
|
| 3 |
+
python -m mathcompose.data.build_verifier_dataset \
|
| 4 |
+
--prm800k data/raw/prm800k/phase2_train.jsonl \
|
| 5 |
+
--teacher anthropic --limit 15000 --out-dir data/verifier
|
| 6 |
+
|
| 7 |
+
Writes data/verifier/{train,val}.jsonl (+ stats.json). Contamination against
|
| 8 |
+
ProcessBench/MATH-500 is enforced unless --no-dedup.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import random
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from ..common.io import write_jsonl
|
| 18 |
+
from ..common.parallel import thread_map
|
| 19 |
+
from ..teachers import get_teacher
|
| 20 |
+
from ..datagen.prm800k_loader import iter_prm800k
|
| 21 |
+
from ..datagen.dedup import build_contamination_set, is_contaminated
|
| 22 |
+
from ..datagen.gen_verifier_data import synthesize_verifier_completion, make_verifier_row
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> None:
|
| 26 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 27 |
+
ap.add_argument("--prm800k", required=True, help="path to PRM800K .jsonl")
|
| 28 |
+
ap.add_argument("--out-dir", default="data/verifier")
|
| 29 |
+
ap.add_argument("--teacher", default="promptlens",
|
| 30 |
+
choices=["promptlens", "tfy", "anthropic", "openai", "dummy"])
|
| 31 |
+
ap.add_argument("--model", default=None, help="teacher model id override")
|
| 32 |
+
ap.add_argument("--limit", type=int, default=None)
|
| 33 |
+
ap.add_argument("--val-frac", type=float, default=0.05)
|
| 34 |
+
ap.add_argument("--temperature", type=float, default=0.4)
|
| 35 |
+
ap.add_argument("--workers", type=int, default=8, help="concurrent teacher calls")
|
| 36 |
+
ap.add_argument("--no-dedup", action="store_true")
|
| 37 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 38 |
+
args = ap.parse_args()
|
| 39 |
+
|
| 40 |
+
random.seed(args.seed)
|
| 41 |
+
teacher = get_teacher(args.teacher, **({"model": args.model} if args.model else {}))
|
| 42 |
+
|
| 43 |
+
banned = set()
|
| 44 |
+
if not args.no_dedup:
|
| 45 |
+
print("Building contamination set from ProcessBench + MATH-500 ...")
|
| 46 |
+
banned = build_contamination_set()
|
| 47 |
+
print(f" {len(banned)} eval problems banned")
|
| 48 |
+
|
| 49 |
+
records = [
|
| 50 |
+
r for r in iter_prm800k(args.prm800k, limit=args.limit)
|
| 51 |
+
if not (banned and is_contaminated(r.problem, banned))
|
| 52 |
+
]
|
| 53 |
+
print(f"synthesizing critiques for {len(records)} records "
|
| 54 |
+
f"({args.workers} workers, teacher={args.teacher}) ...")
|
| 55 |
+
|
| 56 |
+
def _one(rec):
|
| 57 |
+
completion = synthesize_verifier_completion(teacher, rec, temperature=args.temperature)
|
| 58 |
+
return make_verifier_row(rec, completion)
|
| 59 |
+
|
| 60 |
+
rows = [r for r in thread_map(_one, records, workers=args.workers, desc="verifier") if r]
|
| 61 |
+
random.shuffle(rows)
|
| 62 |
+
|
| 63 |
+
n_val = max(1, int(len(rows) * args.val_frac)) if rows else 0
|
| 64 |
+
val, train = rows[:n_val], rows[n_val:]
|
| 65 |
+
|
| 66 |
+
out = Path(args.out_dir)
|
| 67 |
+
n_train = write_jsonl(train, out / "train.jsonl")
|
| 68 |
+
n_val = write_jsonl(val, out / "val.jsonl")
|
| 69 |
+
|
| 70 |
+
err = sum(1 for r in rows if r.get("first_error_index", -1) != -1)
|
| 71 |
+
stats = {
|
| 72 |
+
"n_total": len(rows),
|
| 73 |
+
"n_train": n_train,
|
| 74 |
+
"n_val": n_val,
|
| 75 |
+
"n_erroneous": err,
|
| 76 |
+
"n_all_correct": len(rows) - err,
|
| 77 |
+
"teacher": args.teacher,
|
| 78 |
+
"deduped": not args.no_dedup,
|
| 79 |
+
}
|
| 80 |
+
(out / "stats.json").write_text(json.dumps(stats, indent=2))
|
| 81 |
+
print(json.dumps(stats, indent=2))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
main()
|
src/mathcompose/data/schema.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Model V's canonical output schema — the single primitive that lets one model
|
| 2 |
+
both score ProcessBench and rerank Model G.
|
| 3 |
+
|
| 4 |
+
Serialized completion (what V is trained to produce and what we parse back):
|
| 5 |
+
|
| 6 |
+
Paragraph 0: <one-line critique>. Verdict: correct
|
| 7 |
+
Paragraph 1: <one-line critique>. Verdict: incorrect
|
| 8 |
+
|
| 9 |
+
The earliest error is in paragraph 1.
|
| 10 |
+
|
| 11 |
+
\boxed{1}
|
| 12 |
+
|
| 13 |
+
The trailing ``\boxed{k}`` is authoritative: ``k`` = 0-based index of the first
|
| 14 |
+
incorrect paragraph, ``-1`` = all correct. Per-paragraph ``Verdict:`` lines are
|
| 15 |
+
a secondary, best-effort signal (useful for analysis and soft rerank scores).
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class StepVerdict:
|
| 26 |
+
index: int
|
| 27 |
+
verdict: str # "correct" | "incorrect"
|
| 28 |
+
critique: str = ""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class VerifierOutput:
|
| 33 |
+
first_error_index: int # -1 == all correct
|
| 34 |
+
steps: list[StepVerdict] = field(default_factory=list)
|
| 35 |
+
raw: str = ""
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def all_correct(self) -> bool:
|
| 39 |
+
return self.first_error_index == -1
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def serialize_verifier_output(
|
| 43 |
+
step_verdicts: list[StepVerdict], first_error_index: int
|
| 44 |
+
) -> str:
|
| 45 |
+
"""Render a training-target completion from structured verdicts."""
|
| 46 |
+
lines = []
|
| 47 |
+
for sv in step_verdicts:
|
| 48 |
+
crit = sv.critique.strip()
|
| 49 |
+
prefix = f"Paragraph {sv.index}: "
|
| 50 |
+
body = (crit + " " if crit else "")
|
| 51 |
+
lines.append(f"{prefix}{body}Verdict: {sv.verdict}")
|
| 52 |
+
body = "\n".join(lines)
|
| 53 |
+
if first_error_index == -1:
|
| 54 |
+
tail = "\n\nAll paragraphs are correct."
|
| 55 |
+
else:
|
| 56 |
+
tail = f"\n\nThe earliest error is in paragraph {first_error_index}."
|
| 57 |
+
return f"{body}{tail}\n\n\\boxed{{{first_error_index}}}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
_VERDICT_RE = re.compile(
|
| 61 |
+
r"[Pp]aragraph\s*(\d+)\s*:?.*?[Vv]erdict\s*:?\s*(correct|incorrect)",
|
| 62 |
+
re.DOTALL,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def parse_verifier_output(text: str) -> VerifierOutput:
|
| 67 |
+
"""Parse raw model text back into a VerifierOutput.
|
| 68 |
+
|
| 69 |
+
The boxed integer is authoritative for ``first_error_index``. If no box is
|
| 70 |
+
present we fall back to the earliest paragraph explicitly marked incorrect.
|
| 71 |
+
"""
|
| 72 |
+
from ..common.math_grade import extract_boxed_int
|
| 73 |
+
|
| 74 |
+
step_verdicts: list[StepVerdict] = []
|
| 75 |
+
for m in _VERDICT_RE.finditer(text):
|
| 76 |
+
step_verdicts.append(StepVerdict(index=int(m.group(1)), verdict=m.group(2)))
|
| 77 |
+
|
| 78 |
+
idx = extract_boxed_int(text)
|
| 79 |
+
if idx is None:
|
| 80 |
+
# fall back: earliest "incorrect" paragraph, else -1 if any verdicts seen
|
| 81 |
+
incorrect = [sv.index for sv in step_verdicts if sv.verdict == "incorrect"]
|
| 82 |
+
if incorrect:
|
| 83 |
+
idx = min(incorrect)
|
| 84 |
+
elif step_verdicts:
|
| 85 |
+
idx = -1
|
| 86 |
+
else:
|
| 87 |
+
idx = None # genuinely unparseable
|
| 88 |
+
|
| 89 |
+
return VerifierOutput(
|
| 90 |
+
first_error_index=idx if idx is not None else -2, # -2 == parse failure sentinel
|
| 91 |
+
steps=step_verdicts,
|
| 92 |
+
raw=text,
|
| 93 |
+
)
|
src/mathcompose/datagen/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data-generation pipeline for Model V (verifier) and Model G (generator)."""
|
| 2 |
+
from .prm800k_loader import PRM800KRecord, parse_prm800k_row, iter_prm800k
|
| 3 |
+
from .dedup import build_contamination_set, is_contaminated
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"PRM800KRecord",
|
| 7 |
+
"parse_prm800k_row",
|
| 8 |
+
"iter_prm800k",
|
| 9 |
+
"build_contamination_set",
|
| 10 |
+
"is_contaminated",
|
| 11 |
+
]
|
src/mathcompose/datagen/dedup.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Contamination guard.
|
| 2 |
+
|
| 3 |
+
PRM800K is built on MATH; ProcessBench's ``math``/``olympiadbench`` subsets and
|
| 4 |
+
MATH-500 also derive from MATH. Training V on a problem that appears in the eval
|
| 5 |
+
would inflate the score. We drop any training problem whose normalized text
|
| 6 |
+
collides with an eval problem.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Iterable, Optional
|
| 11 |
+
|
| 12 |
+
from ..common.math_grade import normalize_problem_text
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_contamination_set(
|
| 16 |
+
processbench_splits: Iterable[str] = ("math", "olympiadbench", "omnimath", "gsm8k"),
|
| 17 |
+
include_math500: bool = True,
|
| 18 |
+
extra_problems: Optional[Iterable[str]] = None,
|
| 19 |
+
processbench_id: str = "Qwen/ProcessBench",
|
| 20 |
+
math500_id: str = "HuggingFaceH4/MATH-500",
|
| 21 |
+
) -> set[str]:
|
| 22 |
+
"""Normalized problem texts appearing in the eval sets. Requires network
|
| 23 |
+
(HF Hub) unless you pass only ``extra_problems``."""
|
| 24 |
+
banned: set[str] = set()
|
| 25 |
+
from datasets import load_dataset
|
| 26 |
+
|
| 27 |
+
for split in processbench_splits:
|
| 28 |
+
try:
|
| 29 |
+
ds = load_dataset(processbench_id, split=split)
|
| 30 |
+
for p in ds["problem"]:
|
| 31 |
+
banned.add(normalize_problem_text(p))
|
| 32 |
+
except Exception:
|
| 33 |
+
continue
|
| 34 |
+
if include_math500:
|
| 35 |
+
try:
|
| 36 |
+
ds = load_dataset(math500_id, split="test")
|
| 37 |
+
for p in ds["problem"]:
|
| 38 |
+
banned.add(normalize_problem_text(p))
|
| 39 |
+
except Exception:
|
| 40 |
+
pass
|
| 41 |
+
if extra_problems:
|
| 42 |
+
for p in extra_problems:
|
| 43 |
+
banned.add(normalize_problem_text(p))
|
| 44 |
+
return banned
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def is_contaminated(problem: str, banned: set[str]) -> bool:
|
| 48 |
+
return normalize_problem_text(problem) in banned
|
src/mathcompose/datagen/gen_generator_data.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Synthesize Model G training rows: distill CoT solutions from the teacher and
|
| 2 |
+
keep only those whose ``\boxed{}`` answer is correct (rejection sampling on a
|
| 3 |
+
free objective signal).
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Iterable, Iterator, Optional
|
| 9 |
+
|
| 10 |
+
from ..common.chat import generator_messages, GENERATOR_SYSTEM
|
| 11 |
+
from ..common.math_grade import extract_last_boxed, grade_answer
|
| 12 |
+
from .dedup import is_contaminated
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class Problem:
|
| 17 |
+
problem: str
|
| 18 |
+
answer: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def synthesize_solution(teacher, problem: str, *, temperature: float = 0.7, max_tokens: int = 2048) -> str:
|
| 22 |
+
return teacher.complete(generator_messages(problem), temperature=temperature, max_tokens=max_tokens)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def make_generator_row(problem: str, solution: str) -> dict:
|
| 26 |
+
return {
|
| 27 |
+
"prompt": [
|
| 28 |
+
{"role": "system", "content": GENERATOR_SYSTEM},
|
| 29 |
+
{"role": "user", "content": problem.strip()},
|
| 30 |
+
],
|
| 31 |
+
"completion": [{"role": "assistant", "content": solution.strip()}],
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def generate_generator_dataset(
|
| 36 |
+
problems: Iterable[Problem],
|
| 37 |
+
teacher,
|
| 38 |
+
banned: Optional[set[str]] = None,
|
| 39 |
+
*,
|
| 40 |
+
temperature: float = 0.7,
|
| 41 |
+
keep_only_correct: bool = True,
|
| 42 |
+
max_attempts: int = 1,
|
| 43 |
+
skip_on_error: bool = True,
|
| 44 |
+
) -> Iterator[dict]:
|
| 45 |
+
"""Yield filtered G training rows.
|
| 46 |
+
|
| 47 |
+
For each problem, sample up to ``max_attempts`` solutions and keep the first
|
| 48 |
+
whose boxed answer matches the gold (when ``keep_only_correct``). This is the
|
| 49 |
+
rejection-sampling filter that makes the dataset the deliverable.
|
| 50 |
+
"""
|
| 51 |
+
for p in problems:
|
| 52 |
+
if banned and is_contaminated(p.problem, banned):
|
| 53 |
+
continue
|
| 54 |
+
kept = None
|
| 55 |
+
for _ in range(max_attempts):
|
| 56 |
+
try:
|
| 57 |
+
sol = synthesize_solution(teacher, p.problem, temperature=temperature)
|
| 58 |
+
except Exception:
|
| 59 |
+
if skip_on_error:
|
| 60 |
+
break
|
| 61 |
+
raise
|
| 62 |
+
if not keep_only_correct:
|
| 63 |
+
kept = sol
|
| 64 |
+
break
|
| 65 |
+
pred = extract_last_boxed(sol)
|
| 66 |
+
if pred is not None and grade_answer(pred, p.answer):
|
| 67 |
+
kept = sol
|
| 68 |
+
break
|
| 69 |
+
if kept is not None:
|
| 70 |
+
yield make_generator_row(p.problem, kept)
|
src/mathcompose/datagen/gen_verifier_data.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Synthesize Model V training rows from PRM800K records.
|
| 2 |
+
|
| 3 |
+
Key idea (GenPRM-style rationale synthesis): the *label* (first-error index) is
|
| 4 |
+
ground truth from PRM800K. The teacher only writes the paragraph-by-paragraph
|
| 5 |
+
*critique prose*, conditioned on that known index ("rationalization with hint").
|
| 6 |
+
We then STAMP the authoritative ``\boxed{gold}`` onto the completion, so a
|
| 7 |
+
teacher that boxes the wrong index can never corrupt a label.
|
| 8 |
+
|
| 9 |
+
Two distinct prompts:
|
| 10 |
+
* synthesis prompt (to the teacher) — INCLUDES the gold index.
|
| 11 |
+
* stored training prompt (what V sees) — the plain ProcessBench critic prompt,
|
| 12 |
+
with NO gold leak; V must learn to find the error itself.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
from typing import Iterable, Iterator, Optional
|
| 18 |
+
|
| 19 |
+
from ..common.chat import build_verifier_prompt, verifier_messages, VERIFIER_SYSTEM
|
| 20 |
+
from .prm800k_loader import PRM800KRecord
|
| 21 |
+
from .dedup import is_contaminated
|
| 22 |
+
|
| 23 |
+
_SYNTH_INSTRUCTION = (
|
| 24 |
+
"You are writing a high-quality critique for a grader-training dataset.\n\n"
|
| 25 |
+
"{prompt}\n\n"
|
| 26 |
+
"GROUND TRUTH (for your eyes only — never reveal that you were told): "
|
| 27 |
+
"{gold_desc}\n\n"
|
| 28 |
+
"Write a concise paragraph-by-paragraph critique. For each paragraph i, output "
|
| 29 |
+
"exactly one line:\n"
|
| 30 |
+
" Paragraph i: <one-sentence analysis>. Verdict: correct\n"
|
| 31 |
+
"or\n"
|
| 32 |
+
" Paragraph i: <one-sentence analysis>. Verdict: incorrect\n"
|
| 33 |
+
"Paragraphs before the first error are correct; the first error is the paragraph "
|
| 34 |
+
"named above; do not analyze paragraphs after the first error. Finish with a short "
|
| 35 |
+
"sentence naming the earliest error paragraph (or stating all paragraphs are correct). "
|
| 36 |
+
"Do NOT write a \\boxed{{}} — it is appended automatically."
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _gold_desc(idx: int) -> str:
|
| 41 |
+
if idx == -1:
|
| 42 |
+
return "the solution is fully correct (no erroneous paragraph)."
|
| 43 |
+
return f"the FIRST error occurs in paragraph {idx}."
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _strip_trailing_box(text: str) -> str:
|
| 47 |
+
return re.sub(r"\\boxed\{[^{}]*\}\s*$", "", text.strip()).strip()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def synthesize_verifier_completion(
|
| 51 |
+
teacher, rec: PRM800KRecord, *, temperature: float = 0.4, max_tokens: int = 1536
|
| 52 |
+
) -> str:
|
| 53 |
+
"""Teacher critique + authoritative boxed label."""
|
| 54 |
+
synth_prompt = _SYNTH_INSTRUCTION.format(
|
| 55 |
+
prompt=build_verifier_prompt(rec.problem, rec.steps),
|
| 56 |
+
gold_desc=_gold_desc(rec.first_error_index),
|
| 57 |
+
)
|
| 58 |
+
messages = [
|
| 59 |
+
{"role": "system", "content": VERIFIER_SYSTEM},
|
| 60 |
+
{"role": "user", "content": synth_prompt},
|
| 61 |
+
]
|
| 62 |
+
critique = teacher.complete(messages, temperature=temperature, max_tokens=max_tokens)
|
| 63 |
+
critique = _strip_trailing_box(critique)
|
| 64 |
+
return f"{critique}\n\n\\boxed{{{rec.first_error_index}}}"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def make_verifier_row(rec: PRM800KRecord, completion: str) -> dict:
|
| 68 |
+
"""Conversational prompt-completion row (triggers TRL completion_only_loss)."""
|
| 69 |
+
prompt_msgs = verifier_messages(rec.problem, rec.steps) # system + user, no gold
|
| 70 |
+
return {
|
| 71 |
+
"prompt": prompt_msgs,
|
| 72 |
+
"completion": [{"role": "assistant", "content": completion}],
|
| 73 |
+
"first_error_index": rec.first_error_index, # kept for inspection/stats
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def generate_verifier_dataset(
|
| 78 |
+
records: Iterable[PRM800KRecord],
|
| 79 |
+
teacher,
|
| 80 |
+
banned: Optional[set[str]] = None,
|
| 81 |
+
*,
|
| 82 |
+
temperature: float = 0.4,
|
| 83 |
+
skip_on_error: bool = True,
|
| 84 |
+
) -> Iterator[dict]:
|
| 85 |
+
"""Yield training rows; drops contaminated problems and (optionally) teacher
|
| 86 |
+
failures."""
|
| 87 |
+
for rec in records:
|
| 88 |
+
if banned and is_contaminated(rec.problem, banned):
|
| 89 |
+
continue
|
| 90 |
+
try:
|
| 91 |
+
completion = synthesize_verifier_completion(teacher, rec, temperature=temperature)
|
| 92 |
+
except Exception:
|
| 93 |
+
if skip_on_error:
|
| 94 |
+
continue
|
| 95 |
+
raise
|
| 96 |
+
yield make_verifier_row(rec, completion)
|
src/mathcompose/datagen/prm800k_loader.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Parse PRM800K (OpenAI "Let's Verify Step by Step", MIT license) into
|
| 2 |
+
first-error-localization records.
|
| 3 |
+
|
| 4 |
+
PRM800K rows (newline-delimited JSON) look like::
|
| 5 |
+
|
| 6 |
+
{
|
| 7 |
+
"question": {"problem": "...", "ground_truth_answer": "..."},
|
| 8 |
+
"label": {
|
| 9 |
+
"steps": [
|
| 10 |
+
{"completions": [{"text": "...", "rating": 1}], "chosen_completion": 0,
|
| 11 |
+
"human_completion": null},
|
| 12 |
+
...
|
| 13 |
+
],
|
| 14 |
+
"finish_reason": "solution" | "found_error" | "give_up"
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
We reconstruct the solution as the sequence of *chosen* step texts and set
|
| 19 |
+
``first_error_index`` = index of the first step whose chosen completion is rated
|
| 20 |
+
``-1`` (or ``-1`` if the whole solution is correct). PRM800K phase-2 stops
|
| 21 |
+
labeling after the first mistake, so the erroneous step is the last one we keep.
|
| 22 |
+
|
| 23 |
+
The parser accepts either a path to a ``.jsonl`` file or an iterable of dict
|
| 24 |
+
rows (handy for tests / fixtures).
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
from dataclasses import dataclass
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import Iterable, Iterator, Optional, Union
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class PRM800KRecord:
|
| 36 |
+
problem: str
|
| 37 |
+
steps: list[str]
|
| 38 |
+
first_error_index: int # -1 == all correct
|
| 39 |
+
ground_truth_answer: Optional[str] = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _chosen_text_and_rating(step: dict) -> tuple[Optional[str], Optional[int]]:
|
| 43 |
+
"""Return (text, rating) for the human-selected completion of a step."""
|
| 44 |
+
human = step.get("human_completion")
|
| 45 |
+
completions = step.get("completions") or []
|
| 46 |
+
ci = step.get("chosen_completion")
|
| 47 |
+
|
| 48 |
+
if ci is not None and 0 <= ci < len(completions):
|
| 49 |
+
c = completions[ci]
|
| 50 |
+
return c.get("text"), c.get("rating")
|
| 51 |
+
if human: # human wrote a correct step
|
| 52 |
+
text = human if isinstance(human, str) else human.get("text")
|
| 53 |
+
return text, 1
|
| 54 |
+
if completions: # fall back to first completion
|
| 55 |
+
c = completions[0]
|
| 56 |
+
return c.get("text"), c.get("rating")
|
| 57 |
+
return None, None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def parse_prm800k_row(row: dict) -> Optional[PRM800KRecord]:
|
| 61 |
+
"""Convert one PRM800K row into a record, or None if it can't be used."""
|
| 62 |
+
q = row.get("question") or {}
|
| 63 |
+
problem = q.get("problem")
|
| 64 |
+
label = row.get("label") or {}
|
| 65 |
+
raw_steps = label.get("steps") or []
|
| 66 |
+
if not problem or not raw_steps:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
steps: list[str] = []
|
| 70 |
+
first_error = -1
|
| 71 |
+
for i, step in enumerate(raw_steps):
|
| 72 |
+
text, rating = _chosen_text_and_rating(step)
|
| 73 |
+
if text is None:
|
| 74 |
+
break
|
| 75 |
+
steps.append(text.strip())
|
| 76 |
+
if rating is not None and rating < 0:
|
| 77 |
+
first_error = i
|
| 78 |
+
break # phase-2: labeling stops at the first mistake
|
| 79 |
+
|
| 80 |
+
if not steps:
|
| 81 |
+
return None
|
| 82 |
+
return PRM800KRecord(
|
| 83 |
+
problem=problem.strip(),
|
| 84 |
+
steps=steps,
|
| 85 |
+
first_error_index=first_error,
|
| 86 |
+
ground_truth_answer=q.get("ground_truth_answer"),
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def iter_prm800k(
|
| 91 |
+
source: Union[str, Path, Iterable[dict]],
|
| 92 |
+
limit: Optional[int] = None,
|
| 93 |
+
) -> Iterator[PRM800KRecord]:
|
| 94 |
+
"""Yield PRM800KRecords from a .jsonl path or an iterable of dict rows."""
|
| 95 |
+
def _rows() -> Iterator[dict]:
|
| 96 |
+
if isinstance(source, (str, Path)):
|
| 97 |
+
with open(source) as f:
|
| 98 |
+
for line in f:
|
| 99 |
+
line = line.strip()
|
| 100 |
+
if line:
|
| 101 |
+
yield json.loads(line)
|
| 102 |
+
else:
|
| 103 |
+
yield from source
|
| 104 |
+
|
| 105 |
+
n = 0
|
| 106 |
+
for row in _rows():
|
| 107 |
+
rec = parse_prm800k_row(row)
|
| 108 |
+
if rec is None:
|
| 109 |
+
continue
|
| 110 |
+
yield rec
|
| 111 |
+
n += 1
|
| 112 |
+
if limit is not None and n >= limit:
|
| 113 |
+
return
|
src/mathcompose/eval/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .parse import (
|
| 2 |
+
first_error_index,
|
| 3 |
+
majority_index,
|
| 4 |
+
p_correct_from_samples,
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"first_error_index",
|
| 9 |
+
"majority_index",
|
| 10 |
+
"p_correct_from_samples",
|
| 11 |
+
]
|
src/mathcompose/eval/compose.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Composition eval: does Model V recover Model G's headroom?
|
| 2 |
+
|
| 3 |
+
For each problem we sample n solutions from G and compare selection strategies:
|
| 4 |
+
pass@1 greedy/first sample (floor)
|
| 5 |
+
maj@n plain majority vote over final answers (baseline)
|
| 6 |
+
oracle pass@n any sample correct (ceiling)
|
| 7 |
+
weighted_maj majority weighted by V's p_correct (V result)
|
| 8 |
+
best_of_n single highest-V-scored sample (V result)
|
| 9 |
+
|
| 10 |
+
generator-verifier gap = oracle - maj@n ; V's value = how much of that gap the
|
| 11 |
+
weighted vote recovers. This reproduces the Weaver headroom result at <=4B.
|
| 12 |
+
|
| 13 |
+
Pure w.r.t. two callbacks (solve_fn, v_score_fn) so it is unit-testable with
|
| 14 |
+
fakes; the real callbacks come from eval/runners.py.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from collections import defaultdict
|
| 19 |
+
from typing import Callable, Optional, Sequence
|
| 20 |
+
|
| 21 |
+
from ..common.chat import split_into_steps
|
| 22 |
+
from ..common.math_grade import extract_last_boxed, grade_answer, normalize_final_answer
|
| 23 |
+
from .parse import p_correct_from_samples
|
| 24 |
+
|
| 25 |
+
SolveFn = Callable[[str], list[str]] # problem -> n solution strings
|
| 26 |
+
VScoreFn = Callable[[str, list[str]], float] # (problem, solution_steps) -> p_correct in [0,1]
|
| 27 |
+
|
| 28 |
+
_NONE_KEY = "__no_answer__"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _weighted_vote(answers: Sequence[Optional[str]], weights: Sequence[float],
|
| 32 |
+
correct: Sequence[bool]) -> bool:
|
| 33 |
+
"""Return whether the max-weight answer bucket is correct."""
|
| 34 |
+
buckets: dict[str, dict] = defaultdict(lambda: {"w": 0.0, "correct": False})
|
| 35 |
+
for ans, w, ok in zip(answers, weights, correct):
|
| 36 |
+
key = normalize_final_answer(ans) if ans else _NONE_KEY
|
| 37 |
+
b = buckets[key]
|
| 38 |
+
b["w"] += w
|
| 39 |
+
b["correct"] = b["correct"] or ok
|
| 40 |
+
if not buckets:
|
| 41 |
+
return False
|
| 42 |
+
winner = max(buckets.values(), key=lambda b: b["w"])
|
| 43 |
+
return bool(winner["correct"])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def evaluate_composition(
|
| 47 |
+
problems: Sequence[tuple[str, str]], # (problem, gold_answer)
|
| 48 |
+
solve_fn: SolveFn,
|
| 49 |
+
v_score_fn: VScoreFn,
|
| 50 |
+
progress: bool = True,
|
| 51 |
+
) -> dict:
|
| 52 |
+
try:
|
| 53 |
+
from tqdm.auto import tqdm
|
| 54 |
+
except Exception: # pragma: no cover
|
| 55 |
+
def tqdm(x, **k):
|
| 56 |
+
return x
|
| 57 |
+
|
| 58 |
+
acc = {k: 0 for k in ["pass@1", "maj@n", "oracle", "weighted_maj", "best_of_n"]}
|
| 59 |
+
n_total = 0
|
| 60 |
+
|
| 61 |
+
it = tqdm(problems, desc="compose") if progress else problems
|
| 62 |
+
for problem, gold in it:
|
| 63 |
+
samples = solve_fn(problem)
|
| 64 |
+
if not samples:
|
| 65 |
+
n_total += 1
|
| 66 |
+
continue
|
| 67 |
+
answers = [extract_last_boxed(s) for s in samples]
|
| 68 |
+
correct = [grade_answer(a, gold) for a in answers]
|
| 69 |
+
v_scores = [v_score_fn(problem, split_into_steps(s)) for s in samples]
|
| 70 |
+
|
| 71 |
+
n_total += 1
|
| 72 |
+
acc["pass@1"] += 1 if correct[0] else 0
|
| 73 |
+
acc["oracle"] += 1 if any(correct) else 0
|
| 74 |
+
acc["maj@n"] += 1 if _weighted_vote(answers, [1.0] * len(samples), correct) else 0
|
| 75 |
+
acc["weighted_maj"] += 1 if _weighted_vote(answers, v_scores, correct) else 0
|
| 76 |
+
# best-of-n: single highest-V sample
|
| 77 |
+
best_i = max(range(len(samples)), key=lambda i: v_scores[i])
|
| 78 |
+
acc["best_of_n"] += 1 if correct[best_i] else 0
|
| 79 |
+
|
| 80 |
+
out = {k: (v / n_total if n_total else 0.0) for k, v in acc.items()}
|
| 81 |
+
out["n"] = n_total
|
| 82 |
+
gap = out["oracle"] - out["maj@n"]
|
| 83 |
+
out["generator_verifier_gap"] = gap
|
| 84 |
+
out["gap_recovered_by_V"] = (
|
| 85 |
+
(out["weighted_maj"] - out["maj@n"]) / gap if gap > 1e-9 else None
|
| 86 |
+
)
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def make_v_scorer(runner, k: int = 4, temperature: float = 0.6,
|
| 91 |
+
max_new_tokens: int = 1024) -> VScoreFn:
|
| 92 |
+
"""Build a V-score callback from an HFRunner: sample k critiques per solution
|
| 93 |
+
and return the fraction that judged it fully correct."""
|
| 94 |
+
from ..common.chat import verifier_messages
|
| 95 |
+
|
| 96 |
+
def v_score_fn(problem: str, solution_steps: list[str]) -> float:
|
| 97 |
+
samples = runner.chat(verifier_messages(problem, solution_steps), n=k,
|
| 98 |
+
temperature=temperature, max_new_tokens=max_new_tokens)
|
| 99 |
+
return p_correct_from_samples(samples)
|
| 100 |
+
|
| 101 |
+
return v_score_fn
|
src/mathcompose/eval/math500.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Model G answer-accuracy eval (objective: boxed answer vs gold, no judge).
|
| 2 |
+
|
| 3 |
+
Works for MATH-500 (fields problem/answer) and AIME_2024 (fields Problem/Answer)
|
| 4 |
+
via configurable field names. Reports pass@1 (first sample) and pass@k
|
| 5 |
+
(any-of-k correct).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Callable, Optional, Sequence
|
| 10 |
+
|
| 11 |
+
from ..common.math_grade import extract_last_boxed, grade_answer
|
| 12 |
+
|
| 13 |
+
# solve_fn(problem) -> list of k solution strings
|
| 14 |
+
SolveFn = Callable[[str], list[str]]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _any_correct(samples: Sequence[str], gold: str) -> list[bool]:
|
| 18 |
+
return [grade_answer(extract_last_boxed(s), gold) for s in samples]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def evaluate_generator(
|
| 22 |
+
solve_fn: SolveFn,
|
| 23 |
+
dataset_id: str = "HuggingFaceH4/MATH-500",
|
| 24 |
+
split: str = "test",
|
| 25 |
+
problem_field: str = "problem",
|
| 26 |
+
answer_field: str = "answer",
|
| 27 |
+
limit: Optional[int] = None,
|
| 28 |
+
progress: bool = True,
|
| 29 |
+
) -> dict:
|
| 30 |
+
from datasets import load_dataset
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
from tqdm.auto import tqdm
|
| 34 |
+
except Exception: # pragma: no cover
|
| 35 |
+
def tqdm(x, **k):
|
| 36 |
+
return x
|
| 37 |
+
|
| 38 |
+
ds = load_dataset(dataset_id, split=split)
|
| 39 |
+
if limit is not None:
|
| 40 |
+
ds = ds.select(range(min(limit, len(ds))))
|
| 41 |
+
|
| 42 |
+
n_total = 0
|
| 43 |
+
n_pass1 = 0
|
| 44 |
+
n_passk = 0
|
| 45 |
+
it = tqdm(ds, desc=f"MATH/{dataset_id.split('/')[-1]}") if progress else ds
|
| 46 |
+
for row in it:
|
| 47 |
+
gold = str(row[answer_field])
|
| 48 |
+
samples = solve_fn(row[problem_field])
|
| 49 |
+
if not samples:
|
| 50 |
+
n_total += 1
|
| 51 |
+
continue
|
| 52 |
+
flags = _any_correct(samples, gold)
|
| 53 |
+
n_total += 1
|
| 54 |
+
n_pass1 += 1 if flags[0] else 0
|
| 55 |
+
n_passk += 1 if any(flags) else 0
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"dataset": dataset_id,
|
| 59 |
+
"n": n_total,
|
| 60 |
+
"pass@1": n_pass1 / n_total if n_total else 0.0,
|
| 61 |
+
"pass@k": n_passk / n_total if n_total else 0.0,
|
| 62 |
+
}
|
src/mathcompose/eval/parse.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Eval-facing helpers over Model V's output — used by ProcessBench scoring
|
| 2 |
+
(single output or Maj@k) and by the composition reranker.
|
| 3 |
+
|
| 4 |
+
A parse failure maps to the sentinel ``-2`` so it never accidentally equals a
|
| 5 |
+
gold label (which is >= -1); i.e. an unparseable V output is scored as wrong.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from collections import Counter
|
| 10 |
+
|
| 11 |
+
from ..data.schema import parse_verifier_output
|
| 12 |
+
|
| 13 |
+
PARSE_FAIL = -2
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def first_error_index(text: str) -> int:
|
| 17 |
+
"""0-based first-error index (or -1 all-correct; PARSE_FAIL if unparseable)."""
|
| 18 |
+
return parse_verifier_output(text).first_error_index
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def majority_index(samples: list[str]) -> int:
|
| 22 |
+
"""Maj@k over the first-error index across k sampled V outputs.
|
| 23 |
+
|
| 24 |
+
Parse failures are dropped before voting; if every sample failed, returns
|
| 25 |
+
PARSE_FAIL. Ties broken by the smallest (earliest) index — the conservative
|
| 26 |
+
choice for *first*-error localization.
|
| 27 |
+
"""
|
| 28 |
+
idxs = [first_error_index(s) for s in samples]
|
| 29 |
+
idxs = [i for i in idxs if i != PARSE_FAIL]
|
| 30 |
+
if not idxs:
|
| 31 |
+
return PARSE_FAIL
|
| 32 |
+
counts = Counter(idxs)
|
| 33 |
+
top = max(counts.values())
|
| 34 |
+
winners = [i for i, c in counts.items() if c == top]
|
| 35 |
+
return min(winners)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def p_correct_from_samples(samples: list[str]) -> float:
|
| 39 |
+
"""Solution-level correctness probability for reranking Model G:
|
| 40 |
+
fraction of k verifier samples that judged the whole solution correct
|
| 41 |
+
(first_error_index == -1). Parse failures count as 'not correct'.
|
| 42 |
+
"""
|
| 43 |
+
if not samples:
|
| 44 |
+
return 0.0
|
| 45 |
+
good = sum(1 for s in samples if first_error_index(s) == -1)
|
| 46 |
+
return good / len(samples)
|
src/mathcompose/eval/prmbench.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""PRMBench robustness eval (hitsmy/PRMBench_Preview, apache-2.0).
|
| 2 |
+
|
| 3 |
+
Each row pairs a correct ``original_process`` with a ``modified_process`` that has
|
| 4 |
+
injected errors at ``error_steps`` (1-based), tagged with a ``classification``
|
| 5 |
+
category (confidence, redundancy, ...). This stresses whether V gets fooled into
|
| 6 |
+
accepting subtly-wrong reasoning.
|
| 7 |
+
|
| 8 |
+
We report a simplified but faithful robustness picture (the official ``mr_eval``
|
| 9 |
+
toolkit computes finer per-category scores):
|
| 10 |
+
detection — fraction of MODIFIED processes where V flags any error (index != -1)
|
| 11 |
+
localization— fraction where V's flagged index hits a gold error step
|
| 12 |
+
false_pos — fraction of ORIGINAL (correct) processes V wrongly flags (index != -1)
|
| 13 |
+
per_category detection breakdown.
|
| 14 |
+
|
| 15 |
+
Uses the same ``verify_fn`` as ProcessBench, so V is evaluated identically.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from collections import defaultdict
|
| 20 |
+
from typing import Callable, Optional
|
| 21 |
+
|
| 22 |
+
from .parse import majority_index
|
| 23 |
+
|
| 24 |
+
VerifyFn = Callable[[str, list[str]], list[str]]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def evaluate_prmbench(
|
| 28 |
+
verify_fn: VerifyFn,
|
| 29 |
+
dataset_id: str = "hitsmy/PRMBench_Preview",
|
| 30 |
+
split: str = "train",
|
| 31 |
+
limit: Optional[int] = None,
|
| 32 |
+
error_steps_base: int = 1, # PRMBench error_steps are 1-based
|
| 33 |
+
include_original: bool = True, # also run the false-positive pass
|
| 34 |
+
progress: bool = True,
|
| 35 |
+
) -> dict:
|
| 36 |
+
from datasets import load_dataset
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from tqdm.auto import tqdm
|
| 40 |
+
except Exception: # pragma: no cover
|
| 41 |
+
def tqdm(x, **k):
|
| 42 |
+
return x
|
| 43 |
+
|
| 44 |
+
ds = load_dataset(dataset_id, split=split)
|
| 45 |
+
if limit is not None:
|
| 46 |
+
ds = ds.select(range(min(limit, len(ds))))
|
| 47 |
+
|
| 48 |
+
n_mod = detected = localized = 0
|
| 49 |
+
n_orig = false_pos = 0
|
| 50 |
+
per_cat: dict[str, dict] = defaultdict(lambda: {"n": 0, "detected": 0})
|
| 51 |
+
|
| 52 |
+
it = tqdm(ds, desc="PRMBench") if progress else ds
|
| 53 |
+
for row in it:
|
| 54 |
+
q_mod = row.get("modified_question") or row.get("question") or ""
|
| 55 |
+
steps_mod = list(row.get("modified_process") or [])
|
| 56 |
+
gold0 = {int(e) - error_steps_base for e in (row.get("error_steps") or [])}
|
| 57 |
+
cat = row.get("classification") or "unknown"
|
| 58 |
+
if steps_mod and gold0:
|
| 59 |
+
pred = majority_index(verify_fn(q_mod, steps_mod))
|
| 60 |
+
n_mod += 1
|
| 61 |
+
is_detected = pred != -1
|
| 62 |
+
detected += 1 if is_detected else 0
|
| 63 |
+
localized += 1 if pred in gold0 else 0
|
| 64 |
+
per_cat[cat]["n"] += 1
|
| 65 |
+
per_cat[cat]["detected"] += 1 if is_detected else 0
|
| 66 |
+
|
| 67 |
+
if include_original:
|
| 68 |
+
q_orig = row.get("original_question") or row.get("question") or ""
|
| 69 |
+
steps_orig = list(row.get("original_process") or [])
|
| 70 |
+
if steps_orig:
|
| 71 |
+
pred_o = majority_index(verify_fn(q_orig, steps_orig))
|
| 72 |
+
n_orig += 1
|
| 73 |
+
false_pos += 1 if pred_o != -1 else 0
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"dataset": dataset_id,
|
| 77 |
+
"n_modified": n_mod,
|
| 78 |
+
"detection": detected / n_mod if n_mod else 0.0,
|
| 79 |
+
"localization": localized / n_mod if n_mod else 0.0,
|
| 80 |
+
"false_positive_rate": (false_pos / n_orig) if n_orig else None,
|
| 81 |
+
"per_category_detection": {
|
| 82 |
+
c: {"n": d["n"], "detection": d["detected"] / d["n"] if d["n"] else 0.0}
|
| 83 |
+
for c, d in sorted(per_cat.items())
|
| 84 |
+
},
|
| 85 |
+
}
|
src/mathcompose/eval/processbench.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""ProcessBench evaluation — the core objective metric for Model V.
|
| 2 |
+
|
| 3 |
+
Dataset: ``Qwen/ProcessBench`` (apache-2.0), 4 splits (gsm8k 400, math 1000,
|
| 4 |
+
olympiadbench 1000, omnimath 1000). Each row: ``problem`` (str),
|
| 5 |
+
``steps`` (list[str]), ``label`` (int; 0-based index of first erroneous step,
|
| 6 |
+
``-1`` == all correct), ``final_answer_correct`` (bool).
|
| 7 |
+
|
| 8 |
+
Official metric (per subset, then averaged across the 4 subsets):
|
| 9 |
+
acc_error = accuracy on erroneous samples (pred index == gold index)
|
| 10 |
+
acc_correct = accuracy on correct samples (pred index == -1)
|
| 11 |
+
F1 = harmonic_mean(acc_error, acc_correct)
|
| 12 |
+
|
| 13 |
+
The scoring functions here are pure (no model, no network) so they can be unit
|
| 14 |
+
tested. ``evaluate`` takes a ``verify_fn`` callback that returns k raw V outputs
|
| 15 |
+
for one (problem, steps); Maj@k reduction happens via ``majority_index``.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from dataclasses import dataclass, asdict
|
| 20 |
+
from typing import Callable, Optional, Sequence
|
| 21 |
+
|
| 22 |
+
from .parse import majority_index
|
| 23 |
+
|
| 24 |
+
DEFAULT_SPLITS = ("gsm8k", "math", "olympiadbench", "omnimath")
|
| 25 |
+
|
| 26 |
+
# verify_fn(problem, steps) -> list of k raw V-output strings
|
| 27 |
+
VerifyFn = Callable[[str, list[str]], list[str]]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def harmonic_mean(a: float, b: float) -> float:
|
| 31 |
+
if a + b == 0:
|
| 32 |
+
return 0.0
|
| 33 |
+
return 2 * a * b / (a + b)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class SubsetScore:
|
| 38 |
+
subset: str
|
| 39 |
+
n_error: int
|
| 40 |
+
n_correct: int
|
| 41 |
+
acc_error: float
|
| 42 |
+
acc_correct: float
|
| 43 |
+
f1: float
|
| 44 |
+
|
| 45 |
+
def as_dict(self) -> dict:
|
| 46 |
+
return asdict(self)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def score_subset(
|
| 50 |
+
subset: str,
|
| 51 |
+
gold_labels: Sequence[int],
|
| 52 |
+
pred_indices: Sequence[int],
|
| 53 |
+
) -> SubsetScore:
|
| 54 |
+
"""Compute acc_error / acc_correct / harmonic-mean F1 for one subset."""
|
| 55 |
+
assert len(gold_labels) == len(pred_indices)
|
| 56 |
+
n_error = n_correct = 0
|
| 57 |
+
hit_error = hit_correct = 0
|
| 58 |
+
for gold, pred in zip(gold_labels, pred_indices):
|
| 59 |
+
if gold == -1:
|
| 60 |
+
n_correct += 1
|
| 61 |
+
if pred == -1:
|
| 62 |
+
hit_correct += 1
|
| 63 |
+
else:
|
| 64 |
+
n_error += 1
|
| 65 |
+
if pred == gold:
|
| 66 |
+
hit_error += 1
|
| 67 |
+
acc_error = hit_error / n_error if n_error else 0.0
|
| 68 |
+
acc_correct = hit_correct / n_correct if n_correct else 0.0
|
| 69 |
+
return SubsetScore(
|
| 70 |
+
subset=subset,
|
| 71 |
+
n_error=n_error,
|
| 72 |
+
n_correct=n_correct,
|
| 73 |
+
acc_error=acc_error,
|
| 74 |
+
acc_correct=acc_correct,
|
| 75 |
+
f1=harmonic_mean(acc_error, acc_correct),
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def evaluate(
|
| 80 |
+
verify_fn: VerifyFn,
|
| 81 |
+
splits: Sequence[str] = DEFAULT_SPLITS,
|
| 82 |
+
limit: Optional[int] = None,
|
| 83 |
+
dataset_id: str = "Qwen/ProcessBench",
|
| 84 |
+
progress: bool = True,
|
| 85 |
+
) -> dict:
|
| 86 |
+
"""Run V over ProcessBench and return per-subset scores + the averaged F1.
|
| 87 |
+
|
| 88 |
+
``limit`` caps examples per split (use a small value for CPU smoke tests).
|
| 89 |
+
"""
|
| 90 |
+
from datasets import load_dataset
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
from tqdm.auto import tqdm
|
| 94 |
+
except Exception: # pragma: no cover
|
| 95 |
+
def tqdm(x, **k):
|
| 96 |
+
return x
|
| 97 |
+
|
| 98 |
+
per_subset: list[SubsetScore] = []
|
| 99 |
+
for split in splits:
|
| 100 |
+
ds = load_dataset(dataset_id, split=split)
|
| 101 |
+
if limit is not None:
|
| 102 |
+
ds = ds.select(range(min(limit, len(ds))))
|
| 103 |
+
golds, preds = [], []
|
| 104 |
+
it = tqdm(ds, desc=f"ProcessBench/{split}") if progress else ds
|
| 105 |
+
for row in it:
|
| 106 |
+
samples = verify_fn(row["problem"], list(row["steps"]))
|
| 107 |
+
preds.append(majority_index(samples))
|
| 108 |
+
golds.append(int(row["label"]))
|
| 109 |
+
per_subset.append(score_subset(split, golds, preds))
|
| 110 |
+
|
| 111 |
+
avg_f1 = sum(s.f1 for s in per_subset) / len(per_subset) if per_subset else 0.0
|
| 112 |
+
return {
|
| 113 |
+
"subsets": {s.subset: s.as_dict() for s in per_subset},
|
| 114 |
+
"average_f1": avg_f1,
|
| 115 |
+
"gpt4o_reference_f1": 61.9,
|
| 116 |
+
}
|
src/mathcompose/eval/run.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""One CLI to run every eval against a base or tuned model and write results.
|
| 2 |
+
|
| 3 |
+
# Verifier on ProcessBench (base then tuned -> comparison table):
|
| 4 |
+
python -m mathcompose.eval.run processbench --adapter runs/verifier_v --maj-k 8
|
| 5 |
+
python -m mathcompose.eval.run processbench --tag base # no adapter
|
| 6 |
+
|
| 7 |
+
python -m mathcompose.eval.run prmbench --adapter runs/verifier_v --limit 300
|
| 8 |
+
python -m mathcompose.eval.run math --adapter runs/generator_g --dataset HuggingFaceH4/MATH-500
|
| 9 |
+
python -m mathcompose.eval.run compose --gen-adapter runs/generator_g --ver-adapter runs/verifier_v
|
| 10 |
+
|
| 11 |
+
Each subcommand writes results/<name>_<tag>.json. `report` collates them into a
|
| 12 |
+
markdown table (results/RESULTS.md).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
RESULTS_DIR = Path("results")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _save(name: str, tag: str, payload: dict) -> None:
|
| 24 |
+
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
path = RESULTS_DIR / f"{name}_{tag}.json"
|
| 26 |
+
path.write_text(json.dumps(payload, indent=2))
|
| 27 |
+
print(f"\nwrote {path}")
|
| 28 |
+
print(json.dumps(payload, indent=2))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _runner(base_id, adapter):
|
| 32 |
+
from .runners import HFRunner
|
| 33 |
+
return HFRunner(base_id, adapter_path=adapter)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def cmd_processbench(a):
|
| 37 |
+
from .processbench import evaluate
|
| 38 |
+
from .runners import make_verify_fn
|
| 39 |
+
r = _runner(a.base_id, a.adapter)
|
| 40 |
+
verify_fn = make_verify_fn(r, n=a.maj_k, temperature=(0.6 if a.maj_k > 1 else 0.0))
|
| 41 |
+
res = evaluate(verify_fn, splits=tuple(a.splits), limit=a.limit)
|
| 42 |
+
_save("processbench", a.tag, res)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def cmd_prmbench(a):
|
| 46 |
+
from .prmbench import evaluate_prmbench
|
| 47 |
+
from .runners import make_verify_fn
|
| 48 |
+
r = _runner(a.base_id, a.adapter)
|
| 49 |
+
verify_fn = make_verify_fn(r, n=a.maj_k, temperature=(0.6 if a.maj_k > 1 else 0.0))
|
| 50 |
+
res = evaluate_prmbench(verify_fn, limit=a.limit)
|
| 51 |
+
_save("prmbench", a.tag, res)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def cmd_math(a):
|
| 55 |
+
from .math500 import evaluate_generator
|
| 56 |
+
from .runners import make_solve_fn
|
| 57 |
+
r = _runner(a.base_id, a.adapter)
|
| 58 |
+
solve_fn = make_solve_fn(r, n=a.k, temperature=(0.8 if a.k > 1 else 0.0))
|
| 59 |
+
res = evaluate_generator(solve_fn, dataset_id=a.dataset, split=a.split,
|
| 60 |
+
problem_field=a.problem_field, answer_field=a.answer_field,
|
| 61 |
+
limit=a.limit)
|
| 62 |
+
_save("math", a.tag, res)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def cmd_compose(a):
|
| 66 |
+
from datasets import load_dataset
|
| 67 |
+
from .compose import evaluate_composition, make_v_scorer
|
| 68 |
+
from .runners import HFRunner, make_solve_fn
|
| 69 |
+
|
| 70 |
+
gen = HFRunner(a.base_id, adapter_path=a.gen_adapter)
|
| 71 |
+
ver = HFRunner(a.base_id, adapter_path=a.ver_adapter)
|
| 72 |
+
solve_fn = make_solve_fn(gen, n=a.n, temperature=a.temperature)
|
| 73 |
+
v_score_fn = make_v_scorer(ver, k=a.ver_k)
|
| 74 |
+
|
| 75 |
+
ds = load_dataset(a.dataset, split=a.split)
|
| 76 |
+
if a.limit:
|
| 77 |
+
ds = ds.select(range(min(a.limit, len(ds))))
|
| 78 |
+
problems = [(row[a.problem_field], str(row[a.answer_field])) for row in ds]
|
| 79 |
+
res = evaluate_composition(problems, solve_fn, v_score_fn)
|
| 80 |
+
_save("compose", a.tag, res)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def cmd_report(a):
|
| 84 |
+
rows = []
|
| 85 |
+
for p in sorted(RESULTS_DIR.glob("*.json")):
|
| 86 |
+
rows.append((p.stem, json.loads(p.read_text())))
|
| 87 |
+
lines = ["# Results\n"]
|
| 88 |
+
for name, data in rows:
|
| 89 |
+
lines.append(f"## {name}\n")
|
| 90 |
+
lines.append("```json")
|
| 91 |
+
lines.append(json.dumps(data, indent=2))
|
| 92 |
+
lines.append("```\n")
|
| 93 |
+
out = RESULTS_DIR / "RESULTS.md"
|
| 94 |
+
out.write_text("\n".join(lines))
|
| 95 |
+
print(f"wrote {out}")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main() -> None:
|
| 99 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 100 |
+
ap.add_argument("--base-id", default="Qwen/Qwen2.5-Math-1.5B-Instruct")
|
| 101 |
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
| 102 |
+
|
| 103 |
+
p = sub.add_parser("processbench")
|
| 104 |
+
p.add_argument("--adapter", default=None)
|
| 105 |
+
p.add_argument("--tag", default="tuned")
|
| 106 |
+
p.add_argument("--maj-k", type=int, default=1)
|
| 107 |
+
p.add_argument("--limit", type=int, default=None)
|
| 108 |
+
p.add_argument("--splits", nargs="+", default=["gsm8k", "math", "olympiadbench", "omnimath"])
|
| 109 |
+
p.set_defaults(func=cmd_processbench)
|
| 110 |
+
|
| 111 |
+
p = sub.add_parser("prmbench")
|
| 112 |
+
p.add_argument("--adapter", default=None)
|
| 113 |
+
p.add_argument("--tag", default="tuned")
|
| 114 |
+
p.add_argument("--maj-k", type=int, default=1)
|
| 115 |
+
p.add_argument("--limit", type=int, default=None)
|
| 116 |
+
p.set_defaults(func=cmd_prmbench)
|
| 117 |
+
|
| 118 |
+
p = sub.add_parser("math")
|
| 119 |
+
p.add_argument("--adapter", default=None)
|
| 120 |
+
p.add_argument("--tag", default="tuned")
|
| 121 |
+
p.add_argument("--dataset", default="HuggingFaceH4/MATH-500")
|
| 122 |
+
p.add_argument("--split", default="test")
|
| 123 |
+
p.add_argument("--problem-field", default="problem")
|
| 124 |
+
p.add_argument("--answer-field", default="answer")
|
| 125 |
+
p.add_argument("--k", type=int, default=1)
|
| 126 |
+
p.add_argument("--limit", type=int, default=None)
|
| 127 |
+
p.set_defaults(func=cmd_math)
|
| 128 |
+
|
| 129 |
+
p = sub.add_parser("compose")
|
| 130 |
+
p.add_argument("--gen-adapter", default=None)
|
| 131 |
+
p.add_argument("--ver-adapter", default=None)
|
| 132 |
+
p.add_argument("--tag", default="compose")
|
| 133 |
+
p.add_argument("--dataset", default="HuggingFaceH4/MATH-500")
|
| 134 |
+
p.add_argument("--split", default="test")
|
| 135 |
+
p.add_argument("--problem-field", default="problem")
|
| 136 |
+
p.add_argument("--answer-field", default="answer")
|
| 137 |
+
p.add_argument("--n", type=int, default=8)
|
| 138 |
+
p.add_argument("--ver-k", type=int, default=4)
|
| 139 |
+
p.add_argument("--temperature", type=float, default=0.8)
|
| 140 |
+
p.add_argument("--limit", type=int, default=None)
|
| 141 |
+
p.set_defaults(func=cmd_compose)
|
| 142 |
+
|
| 143 |
+
p = sub.add_parser("report")
|
| 144 |
+
p.set_defaults(func=cmd_report)
|
| 145 |
+
|
| 146 |
+
a = ap.parse_args()
|
| 147 |
+
a.func(a)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
main()
|
src/mathcompose/eval/runners.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""HF generation wrapper + factory closures used by every eval.
|
| 2 |
+
|
| 3 |
+
``HFRunner`` loads a base model (optionally with a LoRA adapter merged in),
|
| 4 |
+
applies the chat template, and samples n completions. It imports torch /
|
| 5 |
+
transformers lazily so the rest of the package stays importable on machines
|
| 6 |
+
without them.
|
| 7 |
+
|
| 8 |
+
Factories:
|
| 9 |
+
make_verify_fn(runner, n) -> verify_fn(problem, steps) -> list[str] (Model V)
|
| 10 |
+
make_solve_fn(runner, n) -> solve_fn(problem) -> list[str] (Model G)
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Callable, Optional
|
| 15 |
+
|
| 16 |
+
from ..common.chat import verifier_messages, generator_messages
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class HFRunner:
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
base_id: str,
|
| 23 |
+
adapter_path: Optional[str] = None,
|
| 24 |
+
device: Optional[str] = None,
|
| 25 |
+
dtype: str = "auto",
|
| 26 |
+
max_context: int = 4096,
|
| 27 |
+
pad_token: str = "<|fim_pad|>",
|
| 28 |
+
):
|
| 29 |
+
import torch
|
| 30 |
+
from transformers import AutoModelForCausalLM
|
| 31 |
+
|
| 32 |
+
from ..common.chat import load_tokenizer
|
| 33 |
+
|
| 34 |
+
self.tokenizer = load_tokenizer(base_id, pad_token=pad_token)
|
| 35 |
+
self.max_context = max_context
|
| 36 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 37 |
+
|
| 38 |
+
torch_dtype = {"auto": "auto", "bf16": torch.bfloat16, "fp16": torch.float16,
|
| 39 |
+
"fp32": torch.float32}.get(dtype, "auto")
|
| 40 |
+
# transformers 5.x: `dtype=` (torch_dtype is deprecated)
|
| 41 |
+
model = AutoModelForCausalLM.from_pretrained(base_id, dtype=torch_dtype)
|
| 42 |
+
if adapter_path:
|
| 43 |
+
from peft import PeftModel
|
| 44 |
+
|
| 45 |
+
model = PeftModel.from_pretrained(model, adapter_path)
|
| 46 |
+
self.model = model.to(self.device).eval()
|
| 47 |
+
|
| 48 |
+
def chat(
|
| 49 |
+
self,
|
| 50 |
+
messages: list[dict],
|
| 51 |
+
n: int = 1,
|
| 52 |
+
temperature: float = 0.0,
|
| 53 |
+
max_new_tokens: int = 1024,
|
| 54 |
+
repetition_penalty: float = 1.1,
|
| 55 |
+
) -> list[str]:
|
| 56 |
+
"""Return n decoded completions (generated text only)."""
|
| 57 |
+
import torch
|
| 58 |
+
|
| 59 |
+
# transformers 5.x returns a BatchEncoding (dict) here, not a bare tensor.
|
| 60 |
+
enc = self.tokenizer.apply_chat_template(
|
| 61 |
+
messages, add_generation_prompt=True, return_tensors="pt", return_dict=True,
|
| 62 |
+
)
|
| 63 |
+
input_ids = enc["input_ids"]
|
| 64 |
+
attn = enc.get("attention_mask")
|
| 65 |
+
|
| 66 |
+
# keep room for generation under the hard context ceiling
|
| 67 |
+
max_prompt = max(1, self.max_context - max_new_tokens)
|
| 68 |
+
if input_ids.shape[-1] > max_prompt:
|
| 69 |
+
input_ids = input_ids[:, -max_prompt:]
|
| 70 |
+
if attn is not None:
|
| 71 |
+
attn = attn[:, -max_prompt:]
|
| 72 |
+
input_ids = input_ids.to(self.device)
|
| 73 |
+
attn = attn.to(self.device) if attn is not None else None
|
| 74 |
+
|
| 75 |
+
do_sample = bool(temperature and temperature > 0)
|
| 76 |
+
gen_kwargs = dict(
|
| 77 |
+
max_new_tokens=max_new_tokens,
|
| 78 |
+
do_sample=do_sample,
|
| 79 |
+
num_return_sequences=n,
|
| 80 |
+
pad_token_id=self.tokenizer.pad_token_id,
|
| 81 |
+
repetition_penalty=repetition_penalty,
|
| 82 |
+
)
|
| 83 |
+
if do_sample:
|
| 84 |
+
gen_kwargs.update(temperature=temperature, top_p=0.95)
|
| 85 |
+
|
| 86 |
+
with torch.no_grad():
|
| 87 |
+
out = self.model.generate(input_ids, attention_mask=attn, **gen_kwargs)
|
| 88 |
+
gen = out[:, input_ids.shape[-1]:]
|
| 89 |
+
return self.tokenizer.batch_decode(gen, skip_special_tokens=True)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def make_verify_fn(runner: HFRunner, n: int = 1, temperature: float = 0.0,
|
| 93 |
+
max_new_tokens: int = 1024) -> Callable[[str, list[str]], list[str]]:
|
| 94 |
+
def verify_fn(problem: str, steps: list[str]) -> list[str]:
|
| 95 |
+
return runner.chat(verifier_messages(problem, steps), n=n,
|
| 96 |
+
temperature=temperature, max_new_tokens=max_new_tokens)
|
| 97 |
+
return verify_fn
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def make_solve_fn(runner: HFRunner, n: int = 1, temperature: float = 0.0,
|
| 101 |
+
max_new_tokens: int = 1024) -> Callable[[str], list[str]]:
|
| 102 |
+
def solve_fn(problem: str) -> list[str]:
|
| 103 |
+
return runner.chat(generator_messages(problem), n=n,
|
| 104 |
+
temperature=temperature, max_new_tokens=max_new_tokens)
|
| 105 |
+
return solve_fn
|
src/mathcompose/infer/__init__.py
ADDED
|
File without changes
|
src/mathcompose/infer/demo.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Inference demo for Model V and Model G, with a base-vs-tuned side-by-side
|
| 2 |
+
mode for the demo video.
|
| 3 |
+
|
| 4 |
+
# Verify a solution with the tuned verifier:
|
| 5 |
+
python -m mathcompose.infer.demo --task v --adapter runs/verifier_v \
|
| 6 |
+
--problem "Compute 1+1." --steps "We add." "1+1=3."
|
| 7 |
+
|
| 8 |
+
# Solve a problem:
|
| 9 |
+
python -m mathcompose.infer.demo --task g --adapter runs/generator_g \
|
| 10 |
+
--problem "What is 12*11?"
|
| 11 |
+
|
| 12 |
+
# Base vs tuned side by side (the money shot for the video):
|
| 13 |
+
python -m mathcompose.infer.demo --task v --adapter runs/verifier_v --compare \
|
| 14 |
+
--problem "..." --steps "..." "..."
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
|
| 20 |
+
from ..common.chat import verifier_messages, generator_messages
|
| 21 |
+
from ..data.schema import parse_verifier_output
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def run_verifier(runner, problem: str, steps: list[str], temperature: float = 0.0) -> str:
|
| 25 |
+
out = runner.chat(verifier_messages(problem, steps), n=1, temperature=temperature,
|
| 26 |
+
max_new_tokens=1024)[0]
|
| 27 |
+
parsed = parse_verifier_output(out)
|
| 28 |
+
verdict = ("all steps correct" if parsed.first_error_index == -1
|
| 29 |
+
else f"first error at step {parsed.first_error_index}")
|
| 30 |
+
return f"[V verdict: {verdict}]\n{out}"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def run_generator(runner, problem: str, temperature: float = 0.0) -> str:
|
| 34 |
+
return runner.chat(generator_messages(problem), n=1, temperature=temperature,
|
| 35 |
+
max_new_tokens=1024)[0]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main() -> None:
|
| 39 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 40 |
+
ap.add_argument("--task", choices=["v", "g"], required=True)
|
| 41 |
+
ap.add_argument("--base-id", default="Qwen/Qwen2.5-Math-1.5B-Instruct")
|
| 42 |
+
ap.add_argument("--adapter", default=None, help="path/hub id of the tuned adapter")
|
| 43 |
+
ap.add_argument("--problem", required=True)
|
| 44 |
+
ap.add_argument("--steps", nargs="*", default=None, help="solution steps (task v)")
|
| 45 |
+
ap.add_argument("--temperature", type=float, default=0.0)
|
| 46 |
+
ap.add_argument("--compare", action="store_true", help="show base vs tuned")
|
| 47 |
+
args = ap.parse_args()
|
| 48 |
+
|
| 49 |
+
from ..eval.runners import HFRunner
|
| 50 |
+
|
| 51 |
+
def do(runner, tag):
|
| 52 |
+
print(f"\n===== {tag} =====")
|
| 53 |
+
if args.task == "v":
|
| 54 |
+
print(run_verifier(runner, args.problem, args.steps or [], args.temperature))
|
| 55 |
+
else:
|
| 56 |
+
print(run_generator(runner, args.problem, args.temperature))
|
| 57 |
+
|
| 58 |
+
if args.compare:
|
| 59 |
+
do(HFRunner(args.base_id, adapter_path=None), "BASE (untuned)")
|
| 60 |
+
if args.adapter:
|
| 61 |
+
do(HFRunner(args.base_id, adapter_path=args.adapter), "TUNED")
|
| 62 |
+
else:
|
| 63 |
+
do(HFRunner(args.base_id, adapter_path=args.adapter), "TUNED" if args.adapter else "BASE")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
main()
|