Skip to content

Scoring metrics silently drop inconclusive/errored samples from the denominator and don't bound their range #4286

Description

@AUTHENSOR

Summary

The accuracy/metric layer conflates several distinct outcomes — a sample that errored, a scorer that returned NaN/abstained, and a scorer that returned None — with "this sample does not exist," silently removing them from the metric denominator. Separately, value_to_float output is never range-checked, so a headline accuracy can exceed 1.0 / go negative / be inf; and mean() and accuracy() use incompatible value-to-float rules so mean() raises on the framework's own C/I/P labels. Net effect: a half-broken or abstained eval can report a clean, high accuracy with status="success" — the worst direction for a safety/capability eval.


1. Errored samples vanish from the accuracy denominator

When a sample errors and the error is not severe enough to trip fail_on_error, its score is dropped (run.py ~L1742-1745 returns None; ~L742-746 keeps only dict results; eval_results is called with samples=all but scores=completed; accuracy.py:33 divides by len(scores)). The denominator becomes "samples that happened to succeed."

from inspect_ai import Task, eval
from inspect_ai.dataset import Sample
from inspect_ai.scorer import Score, Target, scorer, accuracy, CORRECT
from inspect_ai.solver import TaskState, solver, Generate

@solver
def passthrough():
    async def solve(state, generate): return state
    return solve

@scorer(metrics=[accuracy()])
def flaky_scorer():
    async def score(state: TaskState, target: Target) -> Score:
        if int(state.metadata["idx"]) >= 5:        # the "hard"/adversarial half
            raise RuntimeError("judge/parse/sandbox error")
        return Score(value=CORRECT)
    return score

task = Task(
    dataset=[Sample(input=f"q{i}", target="x", metadata={"idx": i}) for i in range(10)],
    solver=passthrough(), scorer=flaky_scorer(),
)
log = eval(task, model="mockllm/model", fail_on_error=0.9, display="none")[0]
print(log.status, log.results.scores[0].metrics["accuracy"].value)  # success 1.0

Actual: status="success", accuracy=1.0 over the 5 survivors.
Expected (for discussion): errored samples should be visible in the headline — counted against the denominator, or the metric reported alongside an explicit "N errored / inconclusive" so a reader cannot mistake a half-failed run for 100%.

2. A scorer that returns NaN ("abstain/inconclusive") is dropped too

results.py ~L300-307 filters out any Score whose value is a float NaN as "unscored" — but NaN is reachable from the public API (Score(value=float("nan"))), the natural way a judge says "I couldn't decide." Those abstentions leave the denominator and inflate accuracy (3 correct, 2 incorrect, 5 abstained -> reported 3/5 = 0.6, not 0.3).

3. accuracy()/mean() never clamp -> headline > 100% / negative / inf

value_to_float passes numbers straight through (_metric.py:228-229) and accuracy.py:29-33 just sums and divides — no [0,1] clamp, no finiteness check. A scorer on a non-0..1 scale (a 0..10 rubric, a count) makes accuracy exceed 1; a single inf makes it inf (it also escapes the math.isnan-only unscored filter).

# per-sample values [8, 5, 10] -> accuracy = 7.67  (767%); [-1,-1,1] -> -0.33

Expected (for discussion): clamp value_to_float to [0,1] (or document that custom numeric scorers must define their own metric), and treat non-finite as unscored rather than a contributor.

4. mean() and accuracy() disagree; mean() raises on C/I/P

accuracy() uses value_to_float() ("C"->1.0); mean() uses Score.as_float() -> float(raw), and float("C") raises ValueError (mean.py:15, _metric.py:157). A scorer emitting the framework's own CORRECT constant is 1.0 under accuracy() but crashes mean(). Tasks that attach [accuracy(), mean(), stderr()] to a label-emitting scorer throw at metric time — the whole run's scores are lost.

from inspect_ai.scorer._metric import Score, value_to_float
vtf = value_to_float()
for v in ["C", "I", "P"]:
    try: m = Score(value=v).as_float()
    except Exception as e: m = f"{type(e).__name__}"
    print(v, "accuracy-path", vtf(v), "| mean-path", m)   # C 1.0 | mean-path ValueError

Expected: mean() should understand the same label vocabulary as accuracy() (or raise a clear error at construction when paired with a label scorer). This sub-item is an unambiguous bug and could ship as a small standalone PR independent of items 1-3.


Proposed direction (one coherent change)

Items 1, 2, and the inf part of 3 are one design question: inspect has no concept of an inconclusive outcome. Erroring / abstaining / out-of-range samples all collapse into "absent," silently. A single direction fixes all of them: keep inconclusive samples in the denominator (or report a first-class "inconclusive / errored" count next to every metric), clamp value_to_float to [0,1] and treat non-finite as inconclusive, and make mean()/accuracy() agree on the label vocabulary. Happy to implement once the intended semantics are agreed — and to split into focused PRs (item 4 first as a clear bug).

Environment

inspect_ai @ 8915598; Python 3.12+; reproduced with mockllm (no model provider required).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions