Skip to content

Commit 906e76f

Browse files
author
Pooya Moradi
committed
extract_answer: prefer \boxed{N} extraction, fall back to legacy tags
Modern reasoning models (Qwen3, DeepSeek-R1, etc.) emit `\boxed{N}` inside `<answer>...</answer>` (or with no answer tags at all). The legacy regex returned the raw `<answer>` content — e.g. `\boxed{42}` as a string — which math_verify cannot match against a bare numeric gold like "42". Result: ~0% accuracy on Qwen3/GSM8K even when the model's numeric answer is correct. New strategy (priority order): 1. If `<answer>...</answer>` is present, use the last block's content as the search scope; otherwise use the full response. 2. Inside the scope, extract the last `\boxed{N}` via brace-balanced scan + permissive regex fallback. 3. If no `\boxed` is found, fall back to the legacy `{solution_start_token}...{solution_end_token}` regex (backward-compat for recipes that emit plain-text answers).
1 parent be7748b commit 906e76f

2 files changed

Lines changed: 152 additions & 4 deletions

File tree

src/maxtext/trainers/post_train/rl/utils_rl.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -484,12 +484,48 @@ def check_numbers(
484484

485485

486486
def extract_answer(response: str, tmvp_config: Any) -> str:
487-
"""Function to extract the answer from the text based on the tmvp_config format."""
487+
"""Extract the final numeric answer from the model's response.
488+
489+
Strategy (priority order):
490+
1. Find the LAST `<answer>...</answer>` block (if any). Use its content
491+
as the search scope; otherwise use the full response.
492+
2. Inside the search scope, find the last `\\boxed{N}` via a brace-
493+
balanced scan (handles nested braces in LaTeX). Fall back to a
494+
permissive `\\boxed{N}` regex if no balanced match is found.
495+
3. If no boxed expression is found, fall back to the legacy
496+
`{solution_start_token}...{solution_end_token}` regex for recipes
497+
that emit the answer as plain text rather than `\\boxed{N}`.
498+
499+
Step 1 + 2 are required for modern reasoning models (Qwen3, DeepSeek-R1,
500+
etc.) that emit `<think>...</think>\\boxed{N}` or `<answer>\\boxed{N}</answer>`
501+
framing. Without `\\boxed` extraction the legacy regex returns the raw
502+
`\\boxed{N}` string and math_verify cannot match it against a bare numeric
503+
gold. Step 3 keeps the function backward-compatible with recipes that
504+
emit plain-text answers inside the configured solution tags.
505+
"""
506+
ans = re.findall(r"<answer>(.*?)</answer>", response, re.DOTALL)
507+
content = ans[-1] if ans else response
508+
boxed_matches: list[str] = []
509+
stack: list[int] = []
510+
for i, ch in enumerate(content):
511+
if ch == "{":
512+
stack.append(i)
513+
elif ch == "}":
514+
if not stack:
515+
continue
516+
op = stack.pop()
517+
if content[:op].endswith(r"\boxed"):
518+
boxed_matches.append(content[op + 1 : i].strip())
519+
if boxed_matches:
520+
return boxed_matches[-1]
521+
m = re.search(r"\\boxed\s*\{?\s*([a-zA-Z0-9\.,\-]+)\s*\}?", content)
522+
if m:
523+
return m.group(1).strip()
488524
answer_fallback = get_answer_fallback_regex(tmvp_config)
489-
# Find the *last* occurrence of the answer tag (most likely the final answer).
490525
fallback_matches = answer_fallback.findall(response)
491-
extracted_response = fallback_matches[-1].strip() if fallback_matches else FALLBACK_ANSWER
492-
return extracted_response
526+
if fallback_matches:
527+
return fallback_matches[-1].strip()
528+
return FALLBACK_ANSWER
493529

494530

495531
def extract_hash_answer(text: str) -> str | None:
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2023-2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for utils_rl.extract_answer (CPU-only).
16+
17+
Covers the two-part contract of the boxed-extraction change:
18+
1. `\\boxed{N}` is extracted (with/without <answer> tags, nested LaTeX,
19+
multiple boxed, whitespace, negatives, and answer-tag scoping).
20+
2. Legacy plain-text answers inside the solution tags still work, so
21+
existing recipes that do not emit `\\boxed` are unaffected.
22+
"""
23+
24+
import unittest
25+
from types import SimpleNamespace
26+
27+
import pytest
28+
29+
from maxtext.trainers.post_train.rl import utils_rl
30+
31+
pytestmark = [pytest.mark.post_training]
32+
33+
34+
def _make_config():
35+
"""Minimal config carrying the solution/reasoning tokens extract_answer reads."""
36+
return SimpleNamespace(
37+
reasoning_start_token="<reasoning>",
38+
reasoning_end_token="</reasoning>",
39+
solution_start_token="<answer>",
40+
solution_end_token="</answer>",
41+
)
42+
43+
44+
class ExtractAnswerTest(unittest.TestCase):
45+
"""Verify boxed extraction and legacy-fallback behavior of extract_answer."""
46+
47+
def setUp(self):
48+
super().setUp()
49+
self.config = _make_config()
50+
51+
# ---- boxed extraction ----
52+
53+
@pytest.mark.cpu_only
54+
def test_boxed_inside_answer_tags(self):
55+
got = utils_rl.extract_answer("<reasoning>2+2</reasoning><answer>\\boxed{4}</answer>", self.config)
56+
self.assertEqual(got, "4")
57+
58+
@pytest.mark.cpu_only
59+
def test_boxed_without_answer_tags(self):
60+
got = utils_rl.extract_answer("the result is \\boxed{42}", self.config)
61+
self.assertEqual(got, "42")
62+
63+
@pytest.mark.cpu_only
64+
def test_boxed_nested_latex(self):
65+
"""Brace-balanced scan keeps the full nested LaTeX content."""
66+
got = utils_rl.extract_answer("<answer>\\boxed{\\frac{1}{2}}</answer>", self.config)
67+
self.assertEqual(got, "\\frac{1}{2}")
68+
69+
@pytest.mark.cpu_only
70+
def test_multiple_boxed_returns_last(self):
71+
got = utils_rl.extract_answer("first \\boxed{1} then \\boxed{99}", self.config)
72+
self.assertEqual(got, "99")
73+
74+
@pytest.mark.cpu_only
75+
def test_boxed_strips_whitespace(self):
76+
got = utils_rl.extract_answer("<answer>\\boxed{ 7 }</answer>", self.config)
77+
self.assertEqual(got, "7")
78+
79+
@pytest.mark.cpu_only
80+
def test_boxed_negative(self):
81+
got = utils_rl.extract_answer("answer: \\boxed{-3}", self.config)
82+
self.assertEqual(got, "-3")
83+
84+
@pytest.mark.cpu_only
85+
def test_answer_tag_scopes_over_reasoning_boxed(self):
86+
"""A boxed value in <reasoning> must not win over the one in <answer>."""
87+
resp = "<reasoning>maybe \\boxed{1}</reasoning><answer>\\boxed{8}</answer>"
88+
self.assertEqual(utils_rl.extract_answer(resp, self.config), "8")
89+
90+
# ---- legacy fallback (no boxed) ----
91+
92+
@pytest.mark.cpu_only
93+
def test_legacy_plain_answer_in_tags(self):
94+
"""A plain-text answer inside <answer> tags (no boxed) still extracts."""
95+
got = utils_rl.extract_answer("<reasoning>work</reasoning><answer>42</answer>", self.config)
96+
self.assertEqual(got, "42")
97+
98+
@pytest.mark.cpu_only
99+
def test_legacy_last_answer_wins(self):
100+
got = utils_rl.extract_answer("<answer>1</answer> ... <answer>5</answer>", self.config)
101+
self.assertEqual(got, "5")
102+
103+
# ---- no answer ----
104+
105+
@pytest.mark.cpu_only
106+
def test_no_answer_returns_fallback_constant(self):
107+
got = utils_rl.extract_answer("I have no idea", self.config)
108+
self.assertEqual(got, utils_rl.FALLBACK_ANSWER)
109+
110+
111+
if __name__ == "__main__":
112+
unittest.main()

0 commit comments

Comments
 (0)