Skip to content

Commit 1fba2f5

Browse files
AndreFCruzclaude
andcommitted
Percentage-mode decoder: anchor on probability<...>: instead of prob mass
The previous decoder picked the "best answer position" by scanning for the position with the most numeric mass, and skipped range-label positions via a `max_single_value_prob > 0.95 * total_mass` heuristic. That worked but was implicit: correctness depended on the numeric argmax at the answer position being ≤ 0.95 (empirical on gpt-5.4-nano), not on a property of the response format. Rewrite as a deterministic walk over the reconstructed chosen-token stream (argmax per position; temperature=0 guarantees chosen==argmax): 1. Accumulate text token-by-token. 2. Fire an anchor once the running text matches `re.compile(r"probability[^:]*:", re.IGNORECASE)` — this catches both `Probability (0-100):` (label preserved) and paraphrases like `Estimated probability:` (label dropped) that the model emits roughly evenly on gpt-5.4-nano. 3. Return the mass-weighted expected value at the first post-anchor position with ≥0.1 numeric mass. Pre-anchor digit positions (the `0` and `100` from `(0-100)`) are prompt echo — they precede the colon, so `[^:]*` correctly stops before them and they never get considered as answers. No probability threshold needed. Live n=100 ACSIncome / gpt-5.4-nano: AUC 0.803 (previous heuristic approach: 0.805 — a 0.002 rounding-noise difference; the anchor loses one edge case where the model malformed its response as `**Estimated probability ( 62%**` with no colon). Full test suite still passes (296 tests). Percentage-mode unit tests rewritten to mirror real response shapes (echoed label; paraphrased anchor; preamble-only rejection). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent dc333b3 commit 1fba2f5

2 files changed

Lines changed: 154 additions & 55 deletions

File tree

folktexts/qa_interface.py

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def get_answer_from_model_output(
263263
# violates.
264264
if self.percentage:
265265
return self._decode_percentage_expected_value(
266-
last_token_probs, numeric_tokens_vocab
266+
last_token_probs, numeric_tokens_vocab, tokenizer_vocab
267267
)
268268

269269
answer_text = ""
@@ -294,19 +294,49 @@ def get_answer_from_model_output(
294294
else:
295295
return float(numeric_answer_text)
296296

297+
# Preamble anchor for percentage-mode decoding. Matches the `probability`
298+
# keyword (case-insensitive) followed by a colon at any distance, which
299+
# is where all gpt-5-style responses (and the underlying prompt
300+
# `NUMERIC_PERCENTAGE_CHAT_PROMPT`) place their answer delimiter:
301+
# - `**Probability (0-100): 28%**` -> `Probability (0-100):`
302+
# - `**Estimated probability: 18%**` -> `probability:`
303+
# - `**Probability (above $50,000): ~62%**` -> `Probability (above $50,000):`
304+
# `[^:]*` deliberately stops at the FIRST colon after `probability`,
305+
# so any digit tokens the model emits as prompt echo (the `0` and `100`
306+
# in `(0-100)`) precede the anchor and are correctly skipped.
307+
_ANSWER_ANCHOR: ClassVar[re.Pattern] = re.compile(
308+
r"probability[^:]*:", re.IGNORECASE
309+
)
310+
297311
def _decode_percentage_expected_value(
298312
self,
299313
last_token_probs: np.ndarray,
300314
numeric_tokens_vocab: dict[str, int],
315+
tokenizer_vocab: dict[str, int],
301316
) -> float:
302-
"""Decode a percentage-mode response.
303-
304-
Real API responses often prefix the digit with markdown echo (e.g.
305-
`'**Probability (0-100): 28**'`), so we scan every position for the
306-
one where numeric-token mass concentrates on plausible percentage
307-
values (integers in [0, 100]) and compute a mass-weighted expected
308-
value at that position. Falls back to 0.0 if no such position
309-
exists (model emitted no digits at all).
317+
"""Decode a percentage-mode response deterministically.
318+
319+
Real gpt-5 responses either echo the prompt's `(0-100)` range
320+
label (`'**Probability (0-100): 28%**'`) or paraphrase it
321+
(`'**Estimated probability: 18%**'`) before the answer digit.
322+
Argmax-per-position over the whole response is noisy: the `0`
323+
and `100` from the range label carry high probability and can
324+
hijack the "best answer position" selection.
325+
326+
Algorithm:
327+
1. Reconstruct the chosen-token stream via argmax per
328+
position (temperature=0 guarantees chosen == argmax).
329+
2. Walk positions accumulating text; once the accumulated
330+
text matches `_ANSWER_ANCHOR` (`probability<...>:`), mark
331+
the anchor as consumed.
332+
3. Return the mass-weighted expected value of integer-percent
333+
tokens at the first *post-anchor* position that carries
334+
meaningful numeric mass.
335+
336+
Falls back to 0.0 when either no anchor appears (the model
337+
never framed the answer as a probability — usually a preamble
338+
that never reached the digit) or no post-anchor position
339+
carries digit mass.
310340
"""
311341
# Restrict to tokens that could be an integer percentage answer.
312342
# Rejects: multi-digit tokens with a `.`, values >100, non-digit
@@ -325,10 +355,28 @@ def _decode_percentage_expected_value(
325355
)
326356
return 0.0
327357

328-
best_pos_idx = -1
329-
best_pos_mass = -1.0
330-
best_pos_expected = 0.0
358+
# Reconstruct chosen tokens per position to walk the response text
359+
# deterministically. `tokenizer_vocab` on the WebAPI backend is a
360+
# synthetic map (token string → sequential id) built from the top-K
361+
# entries actually returned for this row, so the inverse is total
362+
# on the ids that appear as argmax.
363+
inverse_vocab = {tid: tok for tok, tid in tokenizer_vocab.items()}
364+
365+
passed_anchor = False
366+
accumulated_text = ""
331367
for pos_idx, ltp in enumerate(last_token_probs):
368+
chosen_id = int(np.argmax(ltp))
369+
chosen_tok = inverse_vocab.get(chosen_id, "")
370+
accumulated_text += chosen_tok
371+
372+
if not passed_anchor:
373+
if self._ANSWER_ANCHOR.search(accumulated_text):
374+
passed_anchor = True
375+
# Everything before/at the anchor is prompt echo (label
376+
# digits like `0`/`100`, punctuation, preamble words) —
377+
# never the answer, even when it's a valid percentage.
378+
continue
379+
332380
probs_by_value: dict[int, float] = {}
333381
for tok, tid in valid_percent_tokens.items():
334382
p = ltp[tid]
@@ -337,32 +385,20 @@ def _decode_percentage_expected_value(
337385
if p > 0:
338386
probs_by_value[int(tok)] = probs_by_value.get(int(tok), 0.0) + p
339387
total_mass = sum(probs_by_value.values())
340-
if total_mass <= 0:
388+
# Positions between the label and the answer (whitespace,
389+
# `):`, `%`, etc.) carry no numeric mass; skip them.
390+
if total_mass < 0.1:
341391
continue
342-
# Skip positions where a single value dominates (>95% of mass)
343-
# — those are almost always range-label positions like the `0`
344-
# or `100` in `(0-100)`, not the answer. Real answer positions
345-
# have a spread of plausible percentages (probe on gpt-5.4-nano:
346-
# top value ≈0.3-0.5 with 4-5 alternatives sharing the rest).
347-
max_single_value_prob = max(probs_by_value.values())
348-
if max_single_value_prob > 0.95 * total_mass:
349-
continue
350-
if total_mass > best_pos_mass:
351-
best_pos_mass = total_mass
352-
best_pos_idx = pos_idx
353-
# Mass-weighted expected value over the numeric tokens.
354-
best_pos_expected = (
355-
sum(v * p for v, p in probs_by_value.items()) / total_mass
356-
)
357392

358-
if best_pos_idx < 0 or best_pos_mass < 0.1:
359-
logging.warning(
360-
f"No confident percentage answer found "
361-
f"(best_pos_mass={best_pos_mass:.3f}); returning 0.0."
362-
)
363-
return 0.0
393+
expected = sum(v * p for v, p in probs_by_value.items()) / total_mass
394+
return min(expected / 100.0, 1.0)
364395

365-
return min(best_pos_expected / 100.0, 1.0)
396+
logging.warning(
397+
"No post-anchor digit position found in percentage-mode "
398+
"response (accumulated_text=%r); returning 0.0.",
399+
accumulated_text,
400+
)
401+
return 0.0
366402

367403

368404
@dataclass(frozen=True, eq=True)

tests/test_web_api_classifier.py

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -421,41 +421,104 @@ def test_directnumericqa_percentage_prefix_has_no_decimal_prefill():
421421

422422
def test_directnumericqa_percentage_decode_expected_value():
423423
"""`percentage=True` returns a mass-weighted expected value over the
424-
numeric tokens at the highest-confidence position, divided by 100."""
424+
numeric tokens at the first post-`probability<...>:` position with
425+
meaningful numeric mass, divided by 100."""
425426
import numpy as np
426427
from folktexts.qa_interface import DirectNumericQA
427428
q = DirectNumericQA(
428-
column="test", text="P?", num_forward_passes=1, percentage=True,
429+
column="test", text="P?", num_forward_passes=4, percentage=True,
429430
)
430-
# Position 0: only '92' has meaningful mass → expected value = 92.
431-
vocab = {"92": 0, "88": 1, "12": 2}
432-
probs = np.zeros((1, 3))
433-
probs[0, 0] = 0.9 # '92'
434-
probs[0, 1] = 0.05 # '88' → expected = (92*0.9 + 88*0.05 + 12*0.05) / 1.0 = 87.8
435-
probs[0, 2] = 0.05 # '12'
431+
# Simulate `'**Probability (0-100): 92%**'`-style response: the
432+
# accumulated text hits `probability(0-100):` by pos 2, then the
433+
# answer arrives at pos 3.
434+
vocab = {
435+
"92": 0, "88": 1, "12": 2, # answer tokens
436+
"**Probability ": 3, "(0-100):": 4, " ": 5,
437+
}
438+
probs = np.zeros((4, 6))
439+
probs[0, 3] = 1.0 # '**Probability '
440+
probs[1, 4] = 1.0 # '(0-100):' (anchor `Probability (0-100):` fires)
441+
probs[2, 5] = 1.0 # ' ' (whitespace, no numeric mass → skip)
442+
probs[3, 0] = 0.9 # '92' → expected = (92*0.9 + 88*0.05 + 12*0.05) / 1.0 = 87.8
443+
probs[3, 1] = 0.05 # '88'
444+
probs[3, 2] = 0.05 # '12'
436445
decoded = q.get_answer_from_model_output(probs, vocab)
437446
assert decoded == pytest.approx(0.878)
438447

439448

440-
def test_directnumericqa_percentage_finds_answer_across_positions():
441-
"""When the model wraps the digit in markdown (early positions have no
442-
digit mass, digit appears at a later position), the decoder should
443-
lock onto the answer position rather than concatenate noise."""
449+
def test_directnumericqa_percentage_skips_range_label_digits():
450+
"""The `0` and `100` from an echoed `(0-100)` range label are valid
451+
percentages, but appear BEFORE the anchor colon and must be skipped.
452+
The decoder should walk to the first post-anchor position that
453+
carries digit mass."""
454+
import numpy as np
455+
from folktexts.qa_interface import DirectNumericQA
456+
q = DirectNumericQA(
457+
column="test", text="P?", num_forward_passes=6, percentage=True,
458+
)
459+
# Response shape: '**Probability 0 - 100 ): 35'
460+
# Prefix tokens produce accumulated `**Probability 0-100):` by
461+
# position 5, so the anchor `probability[^:]*:` matches. Answer at
462+
# position 5 must NOT be the `100` (still pre-anchor) — verify by
463+
# placing the real answer at position 6.
464+
vocab = {
465+
"35": 0, "100": 1, "0": 2, "-": 3, "):": 4,
466+
"**Probability ": 5,
467+
}
468+
probs = np.zeros((7, 6))
469+
probs[0, 5] = 1.0 # '**Probability ' (no anchor yet — no colon)
470+
probs[1, 2] = 1.0 # '0' (range label starts)
471+
probs[2, 3] = 1.0 # '-'
472+
probs[3, 1] = 1.0 # '100' (still pre-anchor — no colon yet)
473+
probs[4, 4] = 1.0 # '):' (accumulated hits `Probability 0-100):`)
474+
probs[5, 0] = 0.0 # (empty — skipped, no mass)
475+
probs[6, 0] = 0.6 # '35' (post-anchor answer)
476+
probs[6, 2] = 0.3 # '0' (contributes 0)
477+
decoded = q.get_answer_from_model_output(probs, vocab)
478+
# Expected = (35*0.6 + 0*0.3 + 100*0) / 0.9 = 21 / 0.9 = 23.333...
479+
# `100` doesn't appear at pos 6 → not counted.
480+
assert decoded == pytest.approx(23.333 / 100.0, abs=0.001)
481+
482+
483+
def test_directnumericqa_percentage_returns_zero_without_anchor():
484+
"""When the model preambles for the entire token budget without
485+
ever emitting `probability<...>:`, the decoder should return 0.0 —
486+
those responses (~4% on gpt-5.4-nano) contain no answer to decode,
487+
and inventing one would harm calibration."""
444488
import numpy as np
445489
from folktexts.qa_interface import DirectNumericQA
446490
q = DirectNumericQA(
447491
column="test", text="P?", num_forward_passes=3, percentage=True,
448492
)
449-
# 3 positions × 3 numeric tokens. Position 0: no numeric mass.
450-
# Position 1: dominated by literal `100` (the range label — should be
451-
# rejected). Position 2: real answer at 35 with some spread.
452-
vocab = {"35": 0, "100": 1, "0": 2}
493+
# Preamble that never reaches the anchor `probability:`, but
494+
# includes a numeric-looking token (`42` from `**42-year-old`).
495+
vocab = {"42": 0, "Doctorate": 1, "**": 2}
453496
probs = np.zeros((3, 3))
454-
probs[0, :] = [0.0, 0.0, 0.0] # pos 0: no digits (e.g. '**')
455-
probs[1, :] = [0.0, 0.99, 0.0] # pos 1: only '100' — must be skipped
456-
probs[2, :] = [0.6, 0.0, 0.3] # pos 2: '35' dominates; expected = 35*0.6/0.9 = 23.33...
497+
probs[0, 2] = 1.0 # '**'
498+
probs[1, 0] = 1.0 # '42' — a numeric token but before any anchor
499+
probs[2, 1] = 1.0 # 'Doctorate'
457500
decoded = q.get_answer_from_model_output(probs, vocab)
458-
assert decoded == pytest.approx(23.333 / 100.0, abs=0.001)
501+
assert decoded == 0.0
502+
503+
504+
def test_directnumericqa_percentage_accepts_paraphrased_anchor():
505+
"""Real gpt-5 responses often drop the `(0-100)` range label and
506+
paraphrase (e.g. `'**Estimated probability: 18%**'`). The anchor
507+
matches `probability<anything>:` so paraphrases still decode."""
508+
import numpy as np
509+
from folktexts.qa_interface import DirectNumericQA
510+
q = DirectNumericQA(
511+
column="test", text="P?", num_forward_passes=4, percentage=True,
512+
)
513+
# Response: '**Estimated probability: 18%**'
514+
vocab = {"18": 0, "**Estimated probability": 1, ":": 2, " ": 3}
515+
probs = np.zeros((4, 4))
516+
probs[0, 1] = 1.0 # '**Estimated probability' (no anchor yet)
517+
probs[1, 2] = 1.0 # ':' (anchor fires here)
518+
probs[2, 3] = 1.0 # ' ' (whitespace — skip)
519+
probs[3, 0] = 1.0 # '18' (answer)
520+
decoded = q.get_answer_from_model_output(probs, vocab)
521+
assert decoded == pytest.approx(0.18)
459522

460523

461524
def test_directnumericqa_percentage_clamps_above_100():

0 commit comments

Comments
 (0)