|
| 1 | +"""Math-accuracy scorer — the `math-acc-v1` capability axis (the decision metric the |
| 2 | +rlvr stage needs, per research/rlvr/plan.md §C27). The MISSING piece: eval-harness has |
| 3 | +PPL + multiple-choice but NO exact-match / pass@k, and the SFT cohort proved PPL cannot |
| 4 | +separate reasoning arms — so held-out math EXACT-MATCH pass@1 + pass@k is the only valid |
| 5 | +win metric for SFT/distillation/GRPO reasoning runs. |
| 6 | +
|
| 7 | +DESIGN — model-agnostic & CPU-testable, mirroring research/eval_downstream.py. The scorer |
| 8 | +takes a `generate_fn` callable (`(prompt:str, n:int) -> list[str]` of n sampled |
| 9 | +completions), NOT a model, so all of extraction / equivalence / pass@k is unit-tested here |
| 10 | +with a deterministic stub and the SAME code runs on the real checkpoint under eval-harness. |
| 11 | +This module NEVER trains, NEVER loads a model, NEVER allocates CUDA — the CALLER |
| 12 | +(eval-harness template) loads the checkpoint behind `safe_cuda.guard(...)` and passes |
| 13 | +`generate_fn`. |
| 14 | +
|
| 15 | +The three §C25 `rlvr` battery items this module supplies: |
| 16 | + - `pass1_wilson_ci` — accuracy_wilson_ci over all (item×sample) trials. |
| 17 | + - `passk_chen2021` — the unbiased HumanEval/Chen-2021 pass@k estimator, mean over items. |
| 18 | + - `extractor_pinned` — EXTRACTOR_VERSION is stamped into every result; the extractor + |
| 19 | + verifier are frozen here and version-bumped on any change, so a |
| 20 | + number is comparable only within one extractor version. |
| 21 | +Plus `verifier_honesty_ipt` support: `verifier_false_positive_rate()` runs the verifier on |
| 22 | +KNOWN-wrong (question, answer) pairs — it MUST stay near 0, else the reward is gameable. |
| 23 | +
|
| 24 | +HONEST SCALE CAVEAT (carried into every result): at 596M params / ~1.19B training tokens |
| 25 | +the SFT'd model very likely sits near pass@k≈0 on any non-trivial math band. A pass@k of 0 |
| 26 | +is a REAL, decisive result (RL is null-by-construction — nothing to sharpen), not a bug. |
| 27 | +Report the number; do not over-read a near-zero accuracy as a failure of the harness. |
| 28 | +""" |
| 29 | +from __future__ import annotations |
| 30 | +import re |
| 31 | +from typing import Callable, Sequence |
| 32 | + |
| 33 | +from research.eval_metrics import accuracy_wilson_ci |
| 34 | + |
| 35 | +# Pinned extractor+verifier identity. BUMP on ANY change to _extract/_normalize/is_equiv — |
| 36 | +# numbers are only comparable within one version (the `extractor_pinned` §C25 item). |
| 37 | +EXTRACTOR_VERSION = "math-acc-v1" |
| 38 | + |
| 39 | +GenerateFn = Callable[[str, int], Sequence[str]] |
| 40 | + |
| 41 | +# --------------------------------------------------------------- pass@k (Chen 2021) |
| 42 | + |
| 43 | +def pass_at_k(n: int, c: int, k: int) -> float: |
| 44 | + """Unbiased pass@k estimator (Chen et al. 2021, the HumanEval formula): |
| 45 | + the probability that at least one of k samples drawn WITHOUT replacement from n |
| 46 | + total samples (of which c are correct) is correct = 1 - C(n-c,k)/C(n,k), computed |
| 47 | + in the numerically-stable product form. k=1 ⇒ c/n (plain pass@1).""" |
| 48 | + if k <= 0: |
| 49 | + raise ValueError("k must be >= 1") |
| 50 | + if n <= 0 or k > n: |
| 51 | + raise ValueError(f"need 1 <= k <= n; got n={n} k={k}") |
| 52 | + if c <= 0: |
| 53 | + return 0.0 |
| 54 | + if n - c < k: # not enough wrong samples to fill k slots ⇒ guaranteed a hit |
| 55 | + return 1.0 |
| 56 | + prod = 1.0 |
| 57 | + for i in range(n - c + 1, n + 1): |
| 58 | + prod *= 1.0 - k / i |
| 59 | + return 1.0 - prod |
| 60 | + |
| 61 | +# --------------------------------------------------------------- answer extraction |
| 62 | + |
| 63 | +def _last_boxed(text: str) -> str | None: |
| 64 | + """Return the content of the LAST \\boxed{...} (balanced braces), or None.""" |
| 65 | + idx = text.rfind(r"\boxed") |
| 66 | + if idx < 0: |
| 67 | + return None |
| 68 | + i = idx + len(r"\boxed") |
| 69 | + while i < len(text) and text[i] in " \t": |
| 70 | + i += 1 |
| 71 | + if i >= len(text) or text[i] != "{": |
| 72 | + return None |
| 73 | + depth, start = 0, i |
| 74 | + for j in range(i, len(text)): |
| 75 | + if text[j] == "{": |
| 76 | + depth += 1 |
| 77 | + elif text[j] == "}": |
| 78 | + depth -= 1 |
| 79 | + if depth == 0: |
| 80 | + return text[start + 1:j] |
| 81 | + return None |
| 82 | + |
| 83 | +_ANS_IS = re.compile(r"(?:final answer|answer)\s*(?:is|:|=)\s*\$?([^\n.$]+)", re.IGNORECASE) |
| 84 | +_NUM = re.compile(r"-?\d[\d,]*(?:\.\d+)?") |
| 85 | + |
| 86 | +def extract_answer(text: str) -> str | None: |
| 87 | + """Pinned+versioned answer extractor. Priority: \\boxed{} → GSM8K '#### X' → |
| 88 | + 'the answer is X' → last number in the text. Returns the raw span (normalized at |
| 89 | + compare time), or None if nothing parseable. Deterministic, no model, no network.""" |
| 90 | + if not text: |
| 91 | + return None |
| 92 | + boxed = _last_boxed(text) |
| 93 | + if boxed is not None: |
| 94 | + return boxed.strip() |
| 95 | + if "####" in text: # GSM8K gold/solution convention |
| 96 | + tail = text.rsplit("####", 1)[1] |
| 97 | + m = _NUM.search(tail) |
| 98 | + if m: |
| 99 | + return m.group(0).strip() |
| 100 | + return tail.strip().splitlines()[0].strip() if tail.strip() else None |
| 101 | + m = _ANS_IS.search(text) |
| 102 | + if m: |
| 103 | + return m.group(1).strip() |
| 104 | + nums = _NUM.findall(text) # last-number fallback |
| 105 | + return nums[-1].strip() if nums else None |
| 106 | + |
| 107 | +# --------------------------------------------------------------- normalization + verifier |
| 108 | + |
| 109 | +_TEXT_CMD = re.compile(r"\\(?:text|mathrm|mbox|mathbf|mathit)\s*\{([^}]*)\}") |
| 110 | +_FRAC = re.compile(r"\\d?frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}") |
| 111 | + |
| 112 | +def _normalize(s: str) -> str: |
| 113 | + """Canonicalize a math answer string for a fast string-equality path (the classic |
| 114 | + MATH/Minerva cleanup): strip $, \\left/\\right, \\text{}, units, spaces, thousands |
| 115 | + commas, trailing punctuation; \\frac{a}{b} → (a)/(b); ^ stays for the sympy path.""" |
| 116 | + if s is None: |
| 117 | + return "" |
| 118 | + s = s.strip() |
| 119 | + s = _TEXT_CMD.sub(r"\1", s) |
| 120 | + s = s.replace(r"\boxed", "").replace(r"\left", "").replace(r"\right", "") |
| 121 | + s = _FRAC.sub(r"(\1)/(\2)", s) # \frac{a}{b} → (a)/(b) BEFORE braces are stripped |
| 122 | + for a, b in ((r"\!", ""), (r"\,", ""), (r"\ ", " "), ("$", ""), (r"\%", ""), ("%", ""), |
| 123 | + (r"^\circ", ""), (r"\circ", ""), (r"\cdot", "*"), (r"\times", "*"), |
| 124 | + ("{", ""), ("}", "")): |
| 125 | + s = s.replace(a, b) |
| 126 | + s = s.replace("\\", "").replace(" ", "") |
| 127 | + s = s.rstrip(".") |
| 128 | + if re.fullmatch(r"-?\d{1,3}(?:,\d{3})+(?:\.\d+)?", s): # 1,234 → 1234 (only true separators) |
| 129 | + s = s.replace(",", "") |
| 130 | + return s.lower() |
| 131 | + |
| 132 | +def _as_float(s: str): |
| 133 | + try: |
| 134 | + return float(s) |
| 135 | + except (ValueError, TypeError): |
| 136 | + return None |
| 137 | + |
| 138 | +def _sympy_equal(a: str, b: str) -> bool: |
| 139 | + """Symbolic equivalence via sympy (1/2 == 0.5, (1)/(2) == 0.5, 2^3 == 8). Fully |
| 140 | + guarded: any parse/timeout/exception → False (a verifier must never crash training). |
| 141 | + Length-capped to avoid pathological sympify blow-ups.""" |
| 142 | + if len(a) > 100 or len(b) > 100: |
| 143 | + return False |
| 144 | + try: |
| 145 | + from sympy import simplify |
| 146 | + from sympy.parsing.sympy_parser import parse_expr |
| 147 | + expr = f"({a.replace('^', '**')})-({b.replace('^', '**')})" |
| 148 | + return simplify(parse_expr(expr, evaluate=True)) == 0 |
| 149 | + except Exception: |
| 150 | + return False |
| 151 | + |
| 152 | +def is_equiv(pred: str | None, gold: str | None) -> bool: |
| 153 | + """The VERIFIER (verifiable-reward, no neural RM). True iff the extracted prediction |
| 154 | + is mathematically equivalent to the gold answer. Order: normalized string equality → |
| 155 | + numeric equality (tol 1e-6) → sympy symbolic equality. Conservative: unparseable ⇒ False.""" |
| 156 | + if pred is None or gold is None: |
| 157 | + return False |
| 158 | + np_, ng = _normalize(pred), _normalize(gold) |
| 159 | + if np_ == "" or ng == "": |
| 160 | + return False |
| 161 | + if np_ == ng: |
| 162 | + return True |
| 163 | + fp, fg = _as_float(np_), _as_float(ng) |
| 164 | + if fp is not None and fg is not None: |
| 165 | + return abs(fp - fg) <= 1e-6 * max(1.0, abs(fg)) |
| 166 | + return _sympy_equal(np_, ng) |
| 167 | + |
| 168 | +def verifier_false_positive_rate(wrong_pairs: Sequence[tuple[str, str]]) -> float: |
| 169 | + """`verifier_honesty_ipt` support: fraction of KNOWN-WRONG (pred, gold) pairs the |
| 170 | + verifier wrongly accepts. MUST be ~0 — a nonzero rate means the reward is gameable.""" |
| 171 | + if not wrong_pairs: |
| 172 | + return 0.0 |
| 173 | + fp = sum(1 for pred, gold in wrong_pairs if is_equiv(pred, gold)) |
| 174 | + return fp / len(wrong_pairs) |
| 175 | + |
| 176 | +# --------------------------------------------------------------- scoring harness |
| 177 | + |
| 178 | +def score_item(generate_fn: GenerateFn, prompt: str, gold: str, n_samples: int) -> dict: |
| 179 | + """Generate n_samples completions for one prompt, extract+verify each. Returns |
| 180 | + {n, c, correct: [0/1...]} — c = number of verifier-correct samples.""" |
| 181 | + comps = list(generate_fn(prompt, n_samples)) |
| 182 | + correct = [1 if is_equiv(extract_answer(c), gold) else 0 for c in comps] |
| 183 | + return {"n": len(correct), "c": sum(correct), "correct": correct} |
| 184 | + |
| 185 | +def run_math_acc(generate_fn: GenerateFn, items: Sequence[dict], |
| 186 | + n_samples: int = 16, k_list: Sequence[int] = (1, 8, 16)) -> dict: |
| 187 | + """Score a held-out math set. `items` = [{"prompt", "gold"}...]. For each item sample |
| 188 | + n_samples completions; aggregate pass@1 (Wilson CI over all item×sample trials) and |
| 189 | + pass@k (Chen-2021, mean over items). Returns a flat metrics dict stamped with |
| 190 | + EXTRACTOR_VERSION — the §C25 rlvr items pass1_wilson_ci / passk_chen2021 / extractor_pinned.""" |
| 191 | + if not items: |
| 192 | + raise ValueError("no items to score") |
| 193 | + k_list = [k for k in k_list if k <= n_samples] |
| 194 | + per_item, flat = [], [] |
| 195 | + for it in items: |
| 196 | + r = score_item(generate_fn, it["prompt"], it["gold"], n_samples) |
| 197 | + per_item.append(r) |
| 198 | + flat.extend(r["correct"]) |
| 199 | + acc, lo, hi = accuracy_wilson_ci(flat) |
| 200 | + passk = {k: sum(pass_at_k(r["n"], r["c"], k) for r in per_item) / len(per_item) |
| 201 | + for k in k_list} |
| 202 | + return { |
| 203 | + "extractor_version": EXTRACTOR_VERSION, # → extractor_pinned |
| 204 | + "n_items": len(items), |
| 205 | + "n_samples": n_samples, |
| 206 | + "pass1_wilson_ci": {"acc": acc, "ci_low": lo, "ci_high": hi}, |
| 207 | + "passk_chen2021": passk, |
| 208 | + "solved_items": sum(1 for r in per_item if r["c"] > 0), |
| 209 | + } |
| 210 | + |
| 211 | +def _self_test() -> None: |
| 212 | + """Deterministic CPU smoke — no model, no network. Run: python3 -m research.eval_math_acc""" |
| 213 | + assert extract_answer(r"so the answer is \boxed{72}.") == "72" |
| 214 | + assert extract_answer("...\n#### 18") == "18" |
| 215 | + assert is_equiv(r"\boxed{1/2}", "0.5") and is_equiv("72", "72") and not is_equiv("72", "73") |
| 216 | + assert abs(pass_at_k(4, 1, 1) - 0.25) < 1e-9 and pass_at_k(4, 0, 2) == 0.0 and pass_at_k(4, 4, 2) == 1.0 |
| 217 | + # a stub policy that always emits the gold answer ⇒ pass@1 == 1 |
| 218 | + items = [{"prompt": "2+2?", "gold": "4"}, {"prompt": "3+3?", "gold": "6"}] |
| 219 | + gen = lambda p, n: [f"the answer is \\boxed{{{ '4' if '2+2' in p else '6' }}}"] * n |
| 220 | + out = run_math_acc(gen, items, n_samples=4, k_list=(1, 4)) |
| 221 | + assert out["pass1_wilson_ci"]["acc"] == 1.0 and out["passk_chen2021"][4] == 1.0 |
| 222 | + print("SELF-TEST OK", out["extractor_version"], out["passk_chen2021"]) |
| 223 | + |
| 224 | +if __name__ == "__main__": |
| 225 | + _self_test() |
0 commit comments