Skip to content

fix(searchqa): treat a single string gold answer as one answer - #401

Open
Lanre Shittu (Shizoqua) wants to merge 2 commits into
microsoft:mainfrom
Shizoqua:fix/searchqa-string-answers
Open

Lanre Shittu (Shizoqua) wants to merge 2 commits into
microsoft:mainfrom
Shizoqua:fix/searchqa-string-answers

Conversation

@Shizoqua

@Shizoqua Lanre Shittu (Shizoqua) commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #398.

When a SearchQA item's answers field was a single string instead of a list, evaluate() and the EM, F1 and sub_em helpers iterated its characters. The correct answer Paris scored EM 0, while P or s scored EM 1 and F1 1. A single string gold answer is now treated as a one-item list, both when scoring and in the gold answers saved on rollout rows.

User impact: hard is EM and soft is F1, so such items gave an inverted training reward (correct answers penalized, one-letter answers rewarded) and wrong reported scores. Saved rows also recorded the bare string, so anything that later iterated them got characters again.

Proof: the regression tests fail on upstream 45f65ca and pass with this change.

Root cause

evaluate() in skillopt/envs/searchqa/evaluator.py passed gold_answers straight to exact_match(), f1_score() and sub_em(), which loop over it. Iterating a str yields characters. In rollout.py, process_one() stored the raw item["answers"] as gold_answers, and run_batch()'s timeout and error rows stored it as gold_answer. materialize_searchqa.py writes lists, but custom data or a hand-written split can store "answers": "Paris", and nothing normalized it.

Changes

  • rollout.py: a new _gold_answers(item) helper returns the gold answers as a list. process_one() uses it for scoring and the saved row, and the timeout and error rows use it too.
  • evaluator.py: evaluate() also wraps a string gold_answers in a list, for direct callers. List gold answers are unchanged.
  • Tests: tests/test_searchqa_evaluator.py covers scoring; new tests/test_searchqa_rollout_gold_answers.py covers the real process_one() row (fake model response) and the run_batch() error row.
  • CHANGELOG entry under Unreleased / Fixed.

Updated after review feedback (thanks Muhammad Abdullah Butt (@1AbdullahButt)) to normalize once in the rollout as well.

Reproduction

from skillopt.envs.searchqa.evaluator import evaluate

for response in ["<answer>P</answer>", "<answer>s</answer>", "<answer>Paris</answer>", "<answer>London</answer>"]:
    result = evaluate(response, "Paris")
    print(response, result["em"], result["f1"], result["sub_em"])

Before:

<answer>P</answer> 1.0 1.0 1.0
<answer>s</answer> 1.0 1.0 1.0
<answer>Paris</answer> 0.0 0.0 1.0
<answer>London</answer> 0.0 0.0 0.0

After: Paris scores 1.0 1.0 1.0 and the other three score 0.0 0.0 0.0. Rollout rows record ["Paris"].

Testing

  • python -m pytest tests/test_searchqa_evaluator.py tests/test_searchqa_rollout_gold_answers.py -q: on upstream 45f65ca, 5 failed and 6 passed; with the fix, 11 passed.
  • All SearchQA-related tests plus tests/test_benchmark_task_output_paths.py: 61 passed.
  • python -m pytest -q: 2221 passed, 12 skipped, 4 xfailed.
  • python -m ruff check and python -m ruff format --check on the changed files: clean.
  • python -m mkdocs build --strict: passed.

Risk and compatibility

Only items whose answers is a single string change, and those were scored and recorded incorrectly before. List inputs behave exactly as before. No backend, config key, file format or CLI change.

evaluate() and the EM, F1 and sub_em helpers iterate gold_answers. When
an item's answers field was a single string instead of a list, they
iterated its characters: the correct answer "Paris" scored EM 0, while
"P" or "s" scored EM 1 and F1 1. hard is EM and soft is F1, so these
items gave an inverted training reward and wrong reported scores.

Wrap a string gold answer in a list at the start of evaluate(), which is
the entry point both rollout paths call. List gold answers are unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix in evaluate() is right. isinstance(gold_answers, str) wraps it before exact_match, f1_score and sub_em loop over it. Both callers in rollout.py (:243, :321) go through evaluate(), so the reward and the reported scores are both covered.

The raw string still travels on next to the fix, though. rollout.py:187 reads gold_answers = item.get("answers", []) and keeps that value. It's written to the trajectory at :198 ("gold_answers": gold_answers) and at :418 ("gold_answer": item.get("answers", [])). So for these items the score says gold_answers == ["Paris"] (the new test asserts that), while the trajectory records "Paris". Anything that later iterates the trajectory's gold answers, such as a report or a re-scoring script, gets characters again.

Normalizing once at the source, right after :187, would keep the two in step:

gold_answers = item.get("answers", [])
if isinstance(gold_answers, str):
    gold_answers = [gold_answers]

Then use gold_answers at :418 as well. You can keep the evaluate() guard too, for direct callers.

Merge note: #401, #402 and #403 each add an entry at the same spot in CHANGELOG.md. git merge-tree of any two of the heads conflicts there, though each merges cleanly against main on its own. Whichever lands second needs a rebase.

I read the diff, evaluator.py and rollout.py at the PR head. I didn't run the tests.

Reviewed with help from Claude Code.

evaluate() now wraps a string gold answer, but the rollout kept the raw
value: process_one stored item["answers"] as gold_answers, and run_batch's
timeout and error rows stored it as gold_answer. For an item with
"answers": "Paris" the score used ["Paris"] while the saved row recorded
"Paris", so anything that later iterated the row's gold answers would get
characters again.

Normalize once with a _gold_answers() helper and use it for both rows.
The evaluate() guard stays for direct callers.
@Shizoqua

Copy link
Copy Markdown
Contributor Author

Thanks, good catch. Pushed c875c29: gold answers are now normalized once in rollout.py, and both the process_one row and the timeout/error rows use the normalized list, so saved rows record ["Paris"]. The evaluate() guard stays for direct callers. Added tests for both paths.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SearchQA scores each character when a gold answer is a single string

2 participants