Skip to content

fix(utils): mean_of reports nan rather than 0.0 when nothing was averaged - #2230

Merged
MattFisher merged 4 commits into
UKGovernmentBEIS:mainfrom
arthi-arumugam-git:fix/mean-of-empty-denominator
Aug 24, 2026
Merged

MattFisher merged 4 commits into
UKGovernmentBEIS:mainfrom
arthi-arumugam-git:fix/mean-of-empty-denominator

Conversation

@arthi-arumugam-git

@arthi-arumugam-git arthi-arumugam-git commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

This PR contains

Description

mean_of can report 0.0 when it measured nothing at all.

This came up in review on #2173, where the automated reviewer pointed out that the shared helper violates the convention that PR documents. REPO_CONTEXT.md:92 tells contributors to use mean_of instead of reimplementing the mean-over-dict-value pattern, and it has 43 call sites, so it seemed worth fixing rather than only documenting.

The reachable one

With on_missing="skip", a sample missing the key doesn't increment count. If no sample carries the key, count stays 0 and the final line returns 0.0:

return total / count if count > 0 else 0.0

That's reachable in an otherwise healthy run. The samples scored fine, they just don't carry this key. Reproduced against inspect_ai 0.3.259 with three normally scored samples:

mean_of('bleu_score', on_missing='skip') over 3 scored samples -> 0.0

0.0 is a real value on every scale these metrics use, so from the outside an all-miss run and an unmeasured one look identical. Seven call sites use skip today: assistant_bench, browse_comp, cyberseceval_4/instruct_or_autocomplete, mind2web (x2) and tac (x2).

The unreachable one

if not scores: return 0.0 at the top is dead from the framework, which doesn't invoke a metric when every sample is unscored. I removed it rather than changing it, because the loop already handles an empty list: count stays 0 and falls through to the same return. Direct callers get nan too, which matters because unit tests and metrics that delegate to this helper both invoke it directly.

What I changed

Both paths now return nan, matching what agentharm's avg_score_non_refusals does after #2174, and what #2179 proposes for percentage_of in cyberseceval_4.

Tests: three new ones that fail on main (all-skipped with real samples, all-None-and-skipped, empty list), plus two that pass on main and must keep passing, so this doesn't just turn every zero into nan. A measured 0.0 is still 0.0, and on_missing="zero" still counts missing keys as measured zeros.

on main:   3 failed, 6 passed
with fix:  9 passed

Call sites I checked

All seven skip sites return the metric straight to the framework, none does arithmetic on the result, so nan flows the same way agentharm's already does.

Two existing tests asserted the old behaviour and now assert nan: TestAssistantBenchAccuracy::test_empty_scores and TestAnswerRate::test_empty_scores, both on the empty-list path. Flagging that explicitly since it's a behaviour change someone pinned deliberately.

Ran tests/utils, tests/assistant_bench, tests/browse_comp, tests/mind2web, tests/tac, tests/cyberseceval_4 and tests/cti_realm: 504 passed, 29 skipped (HF_TOKEN and slow markers), 5 failed.

Those 5 are all pre-existing on my machine and none is in the changed area. Three are tests/utils/test_eval_params.py failing on No module named 'toml', and two are registry-prefix assertions (test_mind2web_task_creation and test_mitre_tool_definition_matches_expected_contract, both 'mind2web' vs 'inspect_evals/mind2web' in shape). I checked out main and reran the same five there: identical failures, identical messages. Calling that out rather than quietly reporting only the green subset.

Version bumps

Added, per @MattFisher's call in review and the precedent settled on #2188: bump N where reported numbers can move. The five evals holding the seven skip sites are bumped, with a one-line README changelog entry each.

Eval Version Metrics affected
assistant_bench 4-B → 5-B assistant_bench_accuracy
browse_comp 2-B → 3-B browse_comp_accuracy
cyberseceval_4 4-B → 5-B bleu_score_average
mind2web 2-A → 3-A element_accuracy, action_f1
tac 6-C → 7-C welfare_rate, completion_rate

The remaining call sites are untouched. They pass on_missing="error" or "zero", so their behaviour changes only on the empty-scores path the framework does not invoke.

cyberseceval_4/eval.yaml will collide with #2179, which bumps the same file. Whichever lands second rebumps.

Also noting #2122 plans to migrate these helpers to aggregate(). This is a small fix in the meantime and shouldn't complicate that.

Checklist

  • Does this change affect existing eval(s)? If yes:

    • Have you checked your changes against the evaluation checklist?
    • Have the affected task version(s) been incremented?. assistant_bench 4-B → 5-B, browse_comp 2-B → 3-B, cyberseceval_4 4-B → 5-B, mind2web 2-A → 3-A, tac 6-C → 7-C.
    • Have the affected task changelog(s) been updated? Example. One entry per eval README.

    Yes, indirectly: seven call sites across assistant_bench, browse_comp, cyberseceval_4/instruct_or_autocomplete, mind2web and tac pass on_missing="skip" and so can reach the changed path. No task definition, dataset, prompt or scoring logic changes, and the reported number only differs where nothing was measured.

  • Is this change consequential to users? If yes:

    • Has uv run scriv create been run and the changelog fragment committed? See Fragment Format.

    changelog.d/20260821_mean_of_empty_denominator.md. The five bumped evals are named under Existing Evals with their new versions; the shared-utility entry for mean_of itself stays under Other. It is filed there rather than under Shared Utilities, which is not one of the three categories declared in pyproject.toml and so would have been dropped from the release notes.

    Consequential in a narrow way: anyone parsing these metrics gets nan where they used to get 0.0, but only on runs where the 0.0 didn't mean anything. Worth saying out loud because a downstream reader doing arithmetic on the result will now propagate nan instead of silently averaging in a zero, which is the point.

  • Does this change affect how future contributors write or submit evaluations (e.g. new required fields, changed tooling, updated conventions)? If yes:

    No. mean_of keeps its signature and its three on_missing modes, so nothing changes about how a new eval calls it.

…aged

mean_of had two zero returns. The leading `if not scores: return 0.0`
is unreachable from the framework, which does not invoke a metric when
every sample is unscored. The one at the end is reachable in an
otherwise healthy run: with on_missing="skip", a sample missing the key
never increments count, so if no sample carries it the metric reports
0.0 with nothing to say it measured nothing.

0.0 is a real value on every scale these metrics use, so the caller
cannot tell an all-miss run from an unmeasured one. Reported as nan
instead, matching agentharm's avg_score_non_refusals after UKGovernmentBEIS#2174.

The unreachable guard is removed rather than changed, since the loop
already handles an empty list: count stays 0 and falls to the same
return, so direct callers (unit tests, one metric delegating to
another) get nan too.

Two existing assistant_bench tests asserted the old 0.0 on an empty
list and now assert nan.
@github-actions

This comment has been minimized.

MetricProtocol returns Value, the union of every shape a metric can
report, which mypy will not accept as an argument to math.isnan. Adds
the same narrowing helper tests/agentharm/test_metric.py already uses.

Also files the changelog fragment under Other. Shared Utilities is not
one of the three categories declared in pyproject, so the entry would
not have been collected into the release notes.
@github-actions

This comment has been minimized.

@MattFisher MattFisher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving, with the version-bump question you raised answered below. This fix is close to a worked example of the convention it aligns with: the on_missing="skip" all-miss case is the live, internally-emptied, fallen-into shape (nothing pinned either 0.0); removing the dead guard so the fall-through returns nan for direct callers matches the true-empty-value rule for a quotient-shaped metric; and the legitimate zeros stay pinned. Red-green shown, the two deliberately-flipped legacy assertions are flagged rather than silently rewritten, and 347 tests across the affected suites pass.

Version bumps — please add them for the five skip-site evals (assistant_bench, browse_comp, cyberseceval_4, mind2web, tac), one-line README entries each. The repo precedent (settled on #2188 and applied through the cluster) is bump where reported numbers can move, and the all-miss case moves them — the same shape #2174 and #2179 bump for. The other ~36 call sites need nothing: their behaviour changes only on the framework-unreachable empty path. One sequencing note: cyberseceval_4's eval.yaml will collide with #2179 — whichever lands second rebumps, same as the agentharm pair.

Upstream alignment, verified — this fix converges with where #2122 is headed. inspect_ai.scorer.aggregate() (the planned mean_of replacement) already returns NaN for all-skipped, empty-list, and all-NaN-leaf inputs — probed empirically on the current pin, and deliberate per its docstring ("matches the Score.unscored() / NaN sentinel used elsewhere in the framework"). Until this PR, upstream's claim that "a mean_of → aggregate swap preserves behaviour" was subtly false for the empty case; after it, the #2122 migration becomes genuinely behaviour-preserving.

One nuance to hand to #2122 rather than this PR: upstream's docstring warns that on_missing="skip" makes stderr/std depend on the missing-key rate (two runs with identical variance report different stderrs if miss rates differ), recommending "zero" for a constant denominator. None of the seven skip sites has considered that question — worth a per-site decision at migration time.

Posted by Claude Code on Matt's behalf.

@MattFisher MattFisher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry that should have been requested changes

@MattFisher

Copy link
Copy Markdown
Collaborator

Could you also raise an issue for this?

Mixed nan/0.0 inside the same eval: e.g. Mind2Web element_accuracy → nan while step_success_rate/task_success_rate (src/inspect_evals/mind2web/scorer.py:129,164) → 0.0; same in TAC and BrowseComp. Worth stating in the PR description or a tracking issue rather than leaving a maintainer to find a mixed scoreboard.

The nan change reaches assistant_bench, browse_comp, cyberseceval_4,
mind2web and tac, whose metrics pass on_missing="skip" to mean_of. Per
the precedent settled on UKGovernmentBEIS#2188, bump N where reported numbers can move,
and the all-miss case moves these.

  assistant_bench  4-B -> 5-B
  browse_comp      2-B -> 3-B
  cyberseceval_4   4-B -> 5-B
  mind2web         2-A -> 3-A
  tac              6-C -> 7-C

Each gets a one-line README changelog entry. The scriv fragment now
names the five under Existing Evals, alongside the shared-utility entry
that stays under Other.

The remaining call sites are untouched: they use on_missing="error" or
"zero", so their behaviour changes only on the empty-scores path the
framework does not invoke.

cyberseceval_4's eval.yaml will collide with UKGovernmentBEIS#2179. Whichever lands
second rebumps.
@github-actions

This comment has been minimized.

The TAC entry carried its house-style comparability line, which claims
results are not comparable to 6-C and require a re-run. That is not
true. tac_scorer sets welfare and completed on every one of its five
return branches, so no sample can be missing the key and the all-miss
path cannot arise. Re-running a 52-sample agentic eval for that is a
real cost for no change.

The same holds for assistant_bench, browse_comp and mind2web: every
return branch sets the key. Each entry now says so. cyberseceval_4 is
the one where it is genuinely reachable, because bleu_score is only
recorded when a reference can be built, and its entry already says so.

The bumps stay as requested. They declare the shared-helper change
rather than describing a number that moved.
@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review

Claude Code Review

Summary

Follow-up review. One commit since my last review: 395e135fc, which drops TAC's "require a re-run" line and adds a "so no existing result changes and no re-run is needed" clause to the four unreachable entries, plus a PR-description rewrite. Both issues I raised last time are resolved.

I re-verified the reachability claims the new README text now asserts, because the branch depends on them being true: src/inspect_evals/tac/scorer.py:170,181,191,200,211 (all five branches emit welfare and completed; the two raise ValueError paths leave the sample unscored, not key-less), src/inspect_evals/mind2web/scorer.py:308,332, src/inspect_evals/browse_comp/browse_comp.py:240 (single path, score always CORRECT/INCORRECT), src/inspect_evals/assistant_bench/scoring.py:253. All four claims hold. mind2web's extra claim that step_success_rate/task_success_rate keep reporting 0.0 also holds (scorer.py:126-127,153-154 use .get(..., 0.0)).

Also re-checked the mechanical constraints: all five eval.yaml versions match their top README changelog entry; the fragment's - Name (vN-X): shape trips VERSION_BUMP_RE in .github/scripts/detect_bump_type.py:25 so the release is a minor; both fragment sections are declared categories (pyproject.toml:278); no README embeds a generated version line, so nothing needs regenerating. src/inspect_evals/utils/metrics.py is unchanged since my last review and still correct.

Issues Found

Release-notes fragment no longer matches the README entries it summarises

395e135fc updated the five READMEs but not changelog.d/20260821_mean_of_empty_denominator.md. The fragment's four lines for assistant_bench, browse_comp, mind2web and tac still read as plain behaviour changes ("returns nan rather than 0.0 when no sample carries the key"), with no hint that the case cannot arise for those four — while the corresponding README entries now say the scorer sets the key on every path and no re-run is needed. cyberseceval_4's fragment line is the only one that explains reachability, and it is the only one where the case is reachable, so the asymmetry reads as accidental rather than informative.

The release notes are the artefact most users see, and four of the five lines currently invite a reader to wonder whether their numbers moved. Suggest a short clause on each of the four (e.g. "the eval's scorer sets the key on every path, so no reported number changes; the bump declares the shared-helper change"), or one shared sentence covering the four. Location: changelog.d/20260821_mean_of_empty_denominator.md:9-10,12-13.

For the maintainer

N declares "results may not be comparable" (TASK_VERSIONING.md:15), and four of the five entries now say in the same breath that no result changes. That is the honest outcome of @MattFisher's request plus my reachability finding, and the author has documented it rather than papering over it — but it does leave four bumps whose own changelog text says nothing moved. The agentharm precedent (src/inspect_evals/agentharm/README.md:234) scoped the claim instead ("Runs where every sample was refused are not comparable across this bump"), which is not available here since the case cannot occur. Keeping or dropping those four bumps is your call; I don't think the branch should churn again on my say-so.

Previously raised, still no response and no code change

  • Comment length: src/inspect_evals/utils/metrics.py:90-94, five lines of comment on a one-line return, restating the new Returns: docstring.
  • Empty-list behaviour only pinned for skip: tests/utils/test_metrics.py:93 covers on_missing="skip" only, though the docstring's empty-list claim covers all three modes.
  • Parity check for #2122: the remaining ask is that the migration's parity check explicitly cover the empty-denominator case.

Reviewer Feedback Status

  • Version bumps for the five skip-site evals (@MattFisher, changes requested) — Addressed in 144fa35e1 (five eval.yaml bumps, five README entries, fragment recategorised) and refined in 395e135fc. Scheme arithmetic and tools/check_changelog.py's constraint verified for all five.
  • cyberseceval_4 eval.yaml collides with fix(cyberseceval_4): report nan rather than 0.0 when percentage_of has no samples #2179 (@MattFisher) — Responded, and I agree: whichever lands second rebumps. This PR takes 4-B → 5-B.
  • "Could you also raise an issue for this?" (mixed nan/0.0 inside the same eval, @MattFisher) — Unaddressed as far as this branch shows: no issue number appears in the diff or the PR body. The new README entries partially mitigate it by naming the siblings that still report 0.0 for assistant_bench (answer_rate), browse_comp (calibration_error) and mind2web (step_success_rate, task_success_rate), but not for TAC (nudge_rate, category_welfare_rate at src/inspect_evals/tac/scorer.py:110,88) or cyberseceval_4 (_score_utils.percentage_of/count_of). Please link the issue.
  • Upstream alignment with aggregate() (@MattFisher) — informational, no action needed.

Notes

  • CI is still pending at review time, so the modified tests/utils and tests/assistant_bench suites have not yet gone green in this PR's CI. With 43 call sites on the helper, that run should be green before merge rather than relying on the local run in the description.
  • Precedent for the nan return is broader than the PR states: invalid_answer_rate in stereoset already returns nan on an empty list (tests/stereoset/test_stereoset.py:425), alongside agentharm after fix(agentharm): return nan rather than 0.0 when a metric has no samples #2174.
  • The one non-@metric consumer of the helper is grouped(mean_of("correctness"), ...) at src/inspect_evals/ifevalcode/scorer.py:57,63. It uses the default on_missing="error" and all=False, so it reaches count == 0 only on an empty group and does no arithmetic on the result — unaffected.

This is an automatic review performed by Claude Code. Any issues raised here should be fixed or justified, but a human review is still required in order for the PR to be merged.


Maintainers: comment /claude <instruction> on this PR and Claude will push a fix. To batch multiple changes, submit a review with body /claude and inline comments — Claude will address them all in one run. Single inline comments starting with /claude also work.

@arthi-arumugam-git

Copy link
Copy Markdown
Contributor Author

Thanks, and no worries about the approve then request-changes, I knew what you meant.

Bumps are pushed in 144fa35e1, with a follow-up in 395e135fc. Five evals, one README changelog line each:

  • assistant_bench 4-B → 5-B
  • browse_comp 2-B → 3-B
  • cyberseceval_4 4-B → 5-B
  • mind2web 2-A → 3-A
  • tac 6-C → 7-C

I've ticked the two checklist boxes and removed the note asking the question, since you've answered it. The scriv fragment now names the five under Existing Evals with their versions. The mean_of entry itself stays under Other, for the reason in the earlier commit: Shared Utilities isn't one of the three categories declared in pyproject.toml, so it would have been dropped from the release notes.

On cyberseceval_4 and #2179: I'll let #2179 go first. If it merges before this one I'll rebase, take the next number after whatever it lands on, and fix my changelog entry to match, so you're not chasing it.

Issue raised: #2231.

One thing I should flag before you read it, because it's about the examples in your comment. I checked all three rather than just Mind2Web, and two of them aren't quite what the comment says:

  • Mind2Web is right. step_success_rate and task_success_rate do report 0.0 next to the nan. The reason isn't the else 0.0 at :129 and :164 though, it's value.get("element_acc", 0.0) a few lines above each of them. A missing key becomes a real zero, the step counts as failed, and the average of those zeros is genuine. Those two guards only fire on an empty list.
  • TAC's other metrics don't report 0.0. category_welfare_rate returns an empty dict, because every sample gets skipped and no category key is ever created. nudge_rate reads confirms_used from metadata, which is a different key entirely, so it isn't part of the same miss.
  • BrowseComp's calibration_error reports 1.0, not 0.0. Missing score defaults to 0 and missing confidence defaults to 100, so it computes the full gap between them, which is the maximum possible calibration error. That one bothers me more than a 0.0 would, because it looks like a real measurement rather than a suspicious one.

And one more, which might change how you feel about the five bumps. I went looking for how an all-miss actually arises, and I could only find a path in cyberseceval_4, where bleu_score is added to the score dict only when a reference can be built (instruct_or_autocomplete/scorers.py:208). The other four scorers set their keys in every return Score(...) branch, so I couldn't reach the all-miss case through the eval itself. The review bot came to the same list independently, which makes me more confident in it than I'd be on my own.

That turned up something I got wrong on the first push. I'd copied TAC's house-style line saying results are not comparable to 6-C and require a re-run, and for TAC that just isn't true, since tac_scorer sets both keys on all five of its return branches. Telling people to re-run a 52-sample agentic eval for a number that can't move is a real cost, and I shouldn't have written it without checking. Thanks to the bot for catching it. 395e135fc drops that line, and the assistant_bench, browse_comp and mind2web entries now say explicitly that no existing result changes, so nobody re-runs Mind2Web's 7,775 samples for nothing.

I've kept all five bumps as you asked, and I'd rather over-declare than under-declare. But four of the five are declarations of the shared-helper change rather than descriptions of a number that moved, so if you'd rather trim the list to just cyberseceval_4, say the word and I'll redo it.

Your point about on_missing="skip" making stderr and std depend on the missing-key rate is a good one and I hadn't considered it. Agreed it sits with #2122 rather than here.

Tests: tests/utils/test_metrics.py plus the five affected evals, 237 passed, 21 skipped on HF_TOKEN and slow markers. The one failure is test_mind2web_task_creation, the 'mind2web' vs 'inspect_evals/mind2web' registry-prefix assertion I mentioned before. It fails identically on a clean main checkout here, so it's not from this change. CI on 395e135fc is green, including Markdown lint, README check and all three Mypy versions.

@MattFisher
MattFisher merged commit 83ef02e into UKGovernmentBEIS:main Aug 24, 2026
30 checks passed
WatchTree-19 added a commit to WatchTree-19/inspect_evals that referenced this pull request Aug 29, 2026
Two things from the latest review.

Correctness: the four field patterns replaced the literal ': ' with ':\s*\**\s*',
and \s matches \n. A label emitted with no value on its own line therefore
skipped the blank line and bound to the *next* field, so

    extracted_final_answer:

    reasoning: the years do not match

set Score.answer to 'reasoning: the years do not match'. On main the ': '
requirement simply did not match and answer fell back to 'None'. That is the
same failure this PR exists to fix - a parser returning a confident wrong value
instead of not matching - so it should not have been introduced here.

All four now use horizontal-only whitespace ([^\S\n]), which keeps the
capitalisation, markdown-emphasis and missing-space tolerance while making it
impossible to cross a newline. Two regression tests added, one per affected
field.

Versioning: eval.yaml was already at 3-B on main, bumped by UKGovernmentBEIS#2230 for a change
documented as leaving results unchanged. This one does change verdicts and
confidences, so it now bumps to 4-B with its own README section and the
changelog fragment relabelled, rather than riding on someone else's version.
That also resolves the self-contradictory [3-B] section.
WatchTree-19 added a commit to WatchTree-19/inspect_evals that referenced this pull request Aug 29, 2026
eval.yaml was already at 5-B on main, bumped by UKGovernmentBEIS#2230 for a change explicitly
documented as 'no existing result changes and no re-run is needed'. This PR does
change scores on affected samples, so it was shipping a comparability break under
a version number that says results are unchanged, and the [5-B] README section
carried two bullets contradicting each other on the only question that section
answers.

Bumps to 6-B with its own README section and the changelog fragment relabelled,
which also removes the duplicate 'AssistantBench (v5-B)' bullet that would
otherwise appear twice in the next release notes. Adds the missing PR link to
the bullet, matching the sibling entries.
@github-actions github-actions Bot mentioned this pull request Aug 30, 2026
8 tasks
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.

2 participants