Upload folder using huggingface_hub
Browse files- src/mathcompose/common/chat.py +25 -0
- src/mathcompose/eval/code_verify.py +59 -0
- src/mathcompose/eval/processbench.py +48 -0
- src/mathcompose/eval/run.py +13 -5
- tests/test_code_verify.py +34 -0
src/mathcompose/common/chat.py
CHANGED
|
@@ -58,6 +58,31 @@ def verifier_messages(problem: str, steps: list[str]) -> list[dict]:
|
|
| 58 |
]
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def build_generator_prompt(problem: str) -> str:
|
| 62 |
return problem.strip()
|
| 63 |
|
|
|
|
| 58 |
]
|
| 59 |
|
| 60 |
|
| 61 |
+
CODE_VERIFIER_SYSTEM = (
|
| 62 |
+
"You are a meticulous math grader with a Python interpreter. You are given a "
|
| 63 |
+
"problem and a candidate solution split into indexed paragraphs. Check each "
|
| 64 |
+
"paragraph; whenever a step involves arithmetic or algebra, VERIFY it by writing "
|
| 65 |
+
"a Python snippet in a ```python ... ``` block and reading the printed result "
|
| 66 |
+
"before judging. Find the FIRST paragraph containing an error."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
_CODE_VERIFIER_INSTRUCTION = _VERIFIER_INSTRUCTION + (
|
| 70 |
+
"\n\nYou may write ```python``` blocks to check any computation; I will run them "
|
| 71 |
+
"and return the output. When you are certain, give the final index in \\boxed{{}}."
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def build_code_verifier_prompt(problem: str, steps: list[str]) -> str:
|
| 76 |
+
return _CODE_VERIFIER_INSTRUCTION.format(problem=problem.strip(), tagged_steps=tag_steps(steps))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def code_verifier_messages(problem: str, steps: list[str]) -> list[dict]:
|
| 80 |
+
return [
|
| 81 |
+
{"role": "system", "content": CODE_VERIFIER_SYSTEM},
|
| 82 |
+
{"role": "user", "content": build_code_verifier_prompt(problem, steps)},
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
def build_generator_prompt(problem: str) -> str:
|
| 87 |
return problem.strip()
|
| 88 |
|
src/mathcompose/eval/code_verify.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Inference-time code-execution verify loop (GenPRM-style, no retrain required).
|
| 2 |
+
|
| 3 |
+
The verifier is told it may write ```python``` blocks to check arithmetic. We run
|
| 4 |
+
this loop per example:
|
| 5 |
+
1. generate a critique,
|
| 6 |
+
2. if it emitted code AND hasn't boxed a final index yet, execute the code and
|
| 7 |
+
feed the outputs back as a tool turn,
|
| 8 |
+
3. repeat up to ``max_rounds``, then read the boxed first-error index.
|
| 9 |
+
|
| 10 |
+
Because Qwen2.5-Math already supports tool-use, this can be run on the *existing*
|
| 11 |
+
base/tuned models to test whether a calculator closes the arithmetic gap — before
|
| 12 |
+
committing to a code-verified retrain.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from ..common.chat import code_verifier_messages
|
| 17 |
+
from ..common.math_grade import extract_boxed_int
|
| 18 |
+
from ..common.pyexec import extract_code_blocks, run_python
|
| 19 |
+
from .parse import PARSE_FAIL
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def verify_with_code(
|
| 23 |
+
runner,
|
| 24 |
+
problem: str,
|
| 25 |
+
steps: list[str],
|
| 26 |
+
max_rounds: int = 3,
|
| 27 |
+
max_new_tokens: int = 512,
|
| 28 |
+
temperature: float = 0.0,
|
| 29 |
+
exec_timeout: float = 5.0,
|
| 30 |
+
) -> int:
|
| 31 |
+
"""Return the first-error index (or -1 all-correct, PARSE_FAIL if unparseable)."""
|
| 32 |
+
messages = code_verifier_messages(problem, steps)
|
| 33 |
+
last = ""
|
| 34 |
+
for round_i in range(max_rounds):
|
| 35 |
+
out = runner.chat(messages, n=1, temperature=temperature, max_new_tokens=max_new_tokens)[0]
|
| 36 |
+
last = out
|
| 37 |
+
idx = extract_boxed_int(out)
|
| 38 |
+
blocks = extract_code_blocks(out)
|
| 39 |
+
|
| 40 |
+
# Boxed answer with no pending code, or out of rounds -> done.
|
| 41 |
+
if idx is not None and (not blocks or round_i == max_rounds - 1):
|
| 42 |
+
return idx
|
| 43 |
+
if not blocks:
|
| 44 |
+
if idx is not None:
|
| 45 |
+
return idx
|
| 46 |
+
# nudge once for the boxed index
|
| 47 |
+
messages.append({"role": "assistant", "content": out})
|
| 48 |
+
messages.append({"role": "user", "content": "State the first-error index as \\boxed{index}."})
|
| 49 |
+
continue
|
| 50 |
+
|
| 51 |
+
# run the code, feed results back
|
| 52 |
+
results = [run_python(b, timeout=exec_timeout).as_feedback() for b in blocks]
|
| 53 |
+
feedback = "\n".join(f"[block {i} output]\n{r}" for i, r in enumerate(results))
|
| 54 |
+
messages.append({"role": "assistant", "content": out})
|
| 55 |
+
messages.append({"role": "user", "content":
|
| 56 |
+
f"Execution results:\n{feedback}\n\nContinue, then give \\boxed{{index}}."})
|
| 57 |
+
|
| 58 |
+
idx = extract_boxed_int(last)
|
| 59 |
+
return idx if idx is not None else PARSE_FAIL
|
src/mathcompose/eval/processbench.py
CHANGED
|
@@ -116,6 +116,54 @@ def evaluate(
|
|
| 116 |
}
|
| 117 |
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
def evaluate_batched(
|
| 120 |
runner,
|
| 121 |
splits: Sequence[str] = DEFAULT_SPLITS,
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
|
| 119 |
+
def evaluate_codeexec(
|
| 120 |
+
runner,
|
| 121 |
+
splits: Sequence[str] = DEFAULT_SPLITS,
|
| 122 |
+
limit: Optional[int] = None,
|
| 123 |
+
max_rounds: int = 3,
|
| 124 |
+
max_new_tokens: int = 512,
|
| 125 |
+
temperature: float = 0.0,
|
| 126 |
+
dataset_id: str = "Qwen/ProcessBench",
|
| 127 |
+
) -> dict:
|
| 128 |
+
"""Per-example code-execution verify loop (GenPRM-style). Slower (sequential
|
| 129 |
+
tool-use per example) — use a small --limit. Tests whether a calculator closes
|
| 130 |
+
the arithmetic gap, on any existing model."""
|
| 131 |
+
from datasets import load_dataset
|
| 132 |
+
|
| 133 |
+
from .code_verify import verify_with_code
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
from tqdm.auto import tqdm
|
| 137 |
+
except Exception: # pragma: no cover
|
| 138 |
+
def tqdm(x, **k):
|
| 139 |
+
return x
|
| 140 |
+
|
| 141 |
+
per_subset = []
|
| 142 |
+
for split in splits:
|
| 143 |
+
ds = load_dataset(dataset_id, split=split)
|
| 144 |
+
if limit is not None:
|
| 145 |
+
ds = ds.shuffle(seed=0).select(range(min(limit, len(ds))))
|
| 146 |
+
golds, preds = [], []
|
| 147 |
+
for row in tqdm(ds, desc=f"ProcessBench+code/{split}"):
|
| 148 |
+
golds.append(int(row["label"]))
|
| 149 |
+
preds.append(verify_with_code(
|
| 150 |
+
runner, row["problem"], list(row["steps"]),
|
| 151 |
+
max_rounds=max_rounds, max_new_tokens=max_new_tokens, temperature=temperature))
|
| 152 |
+
score = score_subset(split, golds, preds)
|
| 153 |
+
sd = score.as_dict()
|
| 154 |
+
tot = len(preds) or 1
|
| 155 |
+
sd["pred_dist"] = {
|
| 156 |
+
"all_correct(-1)": round(sum(1 for p in preds if p == -1) / tot, 3),
|
| 157 |
+
"error_idx(>=0)": round(sum(1 for p in preds if p >= 0) / tot, 3),
|
| 158 |
+
"parse_fail(-2)": round(sum(1 for p in preds if p == -2) / tot, 3),
|
| 159 |
+
}
|
| 160 |
+
per_subset.append((score, sd))
|
| 161 |
+
|
| 162 |
+
avg_f1 = sum(s.f1 for s, _ in per_subset) / len(per_subset) if per_subset else 0.0
|
| 163 |
+
return {"subsets": {sd["subset"]: sd for _, sd in per_subset},
|
| 164 |
+
"average_f1": avg_f1, "gpt4o_reference_f1": 61.9, "mode": "code-exec"}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
def evaluate_batched(
|
| 168 |
runner,
|
| 169 |
splits: Sequence[str] = DEFAULT_SPLITS,
|
src/mathcompose/eval/run.py
CHANGED
|
@@ -78,12 +78,17 @@ def _prompts_per_batch(a):
|
|
| 78 |
|
| 79 |
|
| 80 |
def cmd_processbench(a):
|
| 81 |
-
from .processbench import evaluate_batched
|
| 82 |
r = _runner(a.base_id, a.adapter)
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
_save("processbench", a.tag, res)
|
| 88 |
|
| 89 |
|
|
@@ -154,6 +159,9 @@ def main() -> None:
|
|
| 154 |
p.add_argument("--max-seqs", type=int, default=0, help="max concurrent sequences (0=auto by VRAM)")
|
| 155 |
p.add_argument("--max-new-tokens", type=int, default=640)
|
| 156 |
p.add_argument("--splits", nargs="+", default=["gsm8k", "math", "olympiadbench", "omnimath"])
|
|
|
|
|
|
|
|
|
|
| 157 |
p.set_defaults(func=cmd_processbench)
|
| 158 |
|
| 159 |
p = sub.add_parser("prmbench")
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
def cmd_processbench(a):
|
|
|
|
| 81 |
r = _runner(a.base_id, a.adapter)
|
| 82 |
+
if getattr(a, "code_exec", False):
|
| 83 |
+
from .processbench import evaluate_codeexec
|
| 84 |
+
res = evaluate_codeexec(r, splits=tuple(a.splits), limit=a.limit,
|
| 85 |
+
max_rounds=a.max_rounds, max_new_tokens=a.max_new_tokens)
|
| 86 |
+
else:
|
| 87 |
+
from .processbench import evaluate_batched
|
| 88 |
+
res = evaluate_batched(r, splits=tuple(a.splits), limit=a.limit, n=a.maj_k,
|
| 89 |
+
batch_size=_prompts_per_batch(a),
|
| 90 |
+
temperature=(0.6 if a.maj_k > 1 else 0.0),
|
| 91 |
+
max_new_tokens=a.max_new_tokens)
|
| 92 |
_save("processbench", a.tag, res)
|
| 93 |
|
| 94 |
|
|
|
|
| 159 |
p.add_argument("--max-seqs", type=int, default=0, help="max concurrent sequences (0=auto by VRAM)")
|
| 160 |
p.add_argument("--max-new-tokens", type=int, default=640)
|
| 161 |
p.add_argument("--splits", nargs="+", default=["gsm8k", "math", "olympiadbench", "omnimath"])
|
| 162 |
+
p.add_argument("--code-exec", action="store_true",
|
| 163 |
+
help="run the code-execution verify loop (GenPRM-style; per-example, slower)")
|
| 164 |
+
p.add_argument("--max-rounds", type=int, default=3, help="code-exec: max generate/execute rounds")
|
| 165 |
p.set_defaults(func=cmd_processbench)
|
| 166 |
|
| 167 |
p = sub.add_parser("prmbench")
|
tests/test_code_verify.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Code-execution verify loop (M1c) — with a scripted runner (no model/network)."""
|
| 2 |
+
from mathcompose.eval.code_verify import verify_with_code
|
| 3 |
+
from mathcompose.eval.parse import PARSE_FAIL
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class _ScriptedRunner:
|
| 7 |
+
"""Emits a code block first, then boxes an index after seeing execution output."""
|
| 8 |
+
def chat(self, messages, n=1, temperature=0.0, max_new_tokens=512):
|
| 9 |
+
saw_exec = any("Execution results" in m["content"] for m in messages if m["role"] == "user")
|
| 10 |
+
if saw_exec:
|
| 11 |
+
return ["The output shows 5, contradicting the step. \\boxed{2}"]
|
| 12 |
+
return ["Check paragraph 2.\n```python\nprint(2+3)\n```"]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class _ImmediateRunner:
|
| 16 |
+
def chat(self, messages, n=1, temperature=0.0, max_new_tokens=512):
|
| 17 |
+
return ["All steps verify. \\boxed{-1}"]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class _GarbageRunner:
|
| 21 |
+
def chat(self, messages, n=1, temperature=0.0, max_new_tokens=512):
|
| 22 |
+
return ["hmm no answer here"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_executes_then_boxes():
|
| 26 |
+
assert verify_with_code(_ScriptedRunner(), "2+3", ["a", "2+3=4", "c"], max_rounds=3) == 2
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_immediate_box_no_code():
|
| 30 |
+
assert verify_with_code(_ImmediateRunner(), "p", ["a", "b"]) == -1
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_unparseable_returns_sentinel():
|
| 34 |
+
assert verify_with_code(_GarbageRunner(), "p", ["a", "b"], max_rounds=2) == PARSE_FAIL
|