diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef44a6d94..76d191998d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Bugfix: Several raised errors (unexpected Anthropic input block, unsupported image data type, missing sample-buffer database) now interpolate the offending value into the message instead of showing the literal placeholder text. - Bugfix: Native compaction now truncates an oversized tool output before summarizing instead of crashing or silently summarizing an error when it would exceed the model's context window. (#3600) - Bugfix: React agent compaction now works after a checkpoint resume — the restored conversation is no longer treated as an always-preserved prefix, so compaction shrinks the context again. +- Scoring: New `aggregate(key, agg=...)` metric factory applying any standard metric (`mean`, `stderr`, `accuracy`, …) to a single key of a dict-valued `Score.value`. - Bugfix: Nested `score_reducer` and `task_source` values in serialized task args now restore as instances rather than their registered factories. (#4374) - Viewer: log listing responses include the log dir's canonical URI (`log_dir_uri`) so the viewer can reliably scope its local cache to the directory. - Inspect View: Reuse one warm async S3 client and connection pool across requests — for both log reads and directory listings — instead of creating one per operation, eliminating the per-request credential/connection cold-start (e.g. `/log-headers` ~3s -> ~0.3s, `/logs` ~1.5s -> ~0.06s). diff --git a/docs/metrics.qmd b/docs/metrics.qmd index d411ca64a3..d11e483ba1 100644 --- a/docs/metrics.qmd +++ b/docs/metrics.qmd @@ -99,6 +99,32 @@ grouped(accuracy(), "category", name_template="category_{group_name}") This would produce metrics named `category_physics`, `category_chemistry`, etc. instead of just `physics`, `chemistry`. It does not affect the "all" metric, so that can be named separately. +## Per-Key Aggregation + +Some scorers emit a dict-valued `Score.value` with several numeric fields per sample. The `aggregate()` function applies a given metric to a single field selected by key, so any standard metric can be computed per key: + +``` python +from inspect_ai.scorer import aggregate, mean, stderr + +@metric +def element_accuracy() -> Metric: + return aggregate("element_acc", mean()) + +@metric +def element_accuracy_stderr() -> Metric: + return aggregate("element_acc", stderr()) +``` + +By default the extracted value is passed straight through to the inner metric, so the inner metric's own conversion applies. Pass `to_float=` only when the inner metric can't convert the value itself (for example to feed string grades like `"C"`/`"I"` into `mean()`, which expects numerics): + +``` python +from inspect_ai.scorer import value_to_float + +aggregate("verdict", mean(), to_float=value_to_float()) +``` + +Samples where the key is missing (or present but `None`) are routed through `on_missing`: `"error"` (the default) raises, `"skip"` excludes the sample, and `"zero"` counts it as `0.0`. A per-key `NaN` is always treated as unscored and skipped, matching how the framework handles NaN scores elsewhere. If every sample is filtered out, `aggregate()` returns `NaN`. + ## Clustered Stderr The `stderr()` metric supports computing [clustered standard errors](https://en.wikipedia.org/wiki/Clustered_standard_errors) via the `cluster` parameter. Most scorers already include `stderr()` as a built-in metric, so to compute clustered standard errors you'll want to specify custom `metrics` for your task (which will override the scorer's built in metrics). diff --git a/src/inspect_ai/scorer/__init__.py b/src/inspect_ai/scorer/__init__.py index f3de20980d..03c885a7cd 100644 --- a/src/inspect_ai/scorer/__init__.py +++ b/src/inspect_ai/scorer/__init__.py @@ -22,6 +22,7 @@ value_to_float, ) from ._metrics.accuracy import accuracy +from ._metrics.aggregate import aggregate from ._metrics.categorical import categorical, frequency from ._metrics.grouped import grouped from ._metrics.mean import mean @@ -67,6 +68,7 @@ "Value", "ValueToFloat", "accuracy", + "aggregate", "answer", "at_least", "bootstrap_stderr", diff --git a/src/inspect_ai/scorer/_metrics/__init__.py b/src/inspect_ai/scorer/_metrics/__init__.py index f7436f9d98..b9bde416ac 100644 --- a/src/inspect_ai/scorer/_metrics/__init__.py +++ b/src/inspect_ai/scorer/_metrics/__init__.py @@ -1,4 +1,5 @@ from .accuracy import accuracy +from .aggregate import aggregate from .categorical import categorical, frequency from .grouped import grouped from .mean import mean @@ -7,6 +8,7 @@ __all__ = [ "accuracy", + "aggregate", "categorical", "frequency", "mean", diff --git a/src/inspect_ai/scorer/_metrics/aggregate.py b/src/inspect_ai/scorer/_metrics/aggregate.py new file mode 100644 index 0000000000..8007d2e623 --- /dev/null +++ b/src/inspect_ai/scorer/_metrics/aggregate.py @@ -0,0 +1,152 @@ +import math +from logging import getLogger +from typing import Literal, cast + +from .._metric import ( + Metric, + MetricProtocol, + SampleScore, + Value, + ValueToFloat, + metric, +) + +logger = getLogger(__name__) + + +@metric +def aggregate( + key: str, + agg: Metric, + *, + to_float: ValueToFloat | None = None, + on_missing: Literal["error", "skip", "zero"] = "error", +) -> Metric: + """Apply `agg` to a single key extracted from each dict-valued `Score.value`. + + Many scorers emit dict-valued scores (multiple numeric fields per sample). + `aggregate` selects one field by `key` and feeds the resulting scalar + `SampleScore`s into `agg`, so any standard metric (`mean`, `stderr`, + `std`, `accuracy`, ...) can be applied per key. + + A missing key (either `key not in value` or `value[key] is None`) is + routed through `on_missing`. This matches the convention used by + `inspect_evals.utils.metrics.mean_of`, so a `mean_of` → `aggregate` swap + preserves behaviour. + + `on_missing="skip"` reduces the number of samples seen by `agg`, which + changes the result of any aggregator that depends on sample count + (e.g. `stderr`, `mean`, `std`, `var`). Two evals run with the same + scorer can therefore report different stderrs purely because the rate + of missing keys differed, not because of any difference in the + underlying variance. Prefer `"zero"` if you want a constant denominator. + + If every sample is filtered out by `on_missing="skip"`, the aggregator + returns `NaN` rather than calling `agg([])` (which most built-in metrics + would raise on). This matches the `Score.unscored()` / NaN sentinel used + elsewhere in the framework. + + Args: + key: Field to extract from each sample's dict-valued `Score.value`. + agg: Metric to apply to the extracted values. + to_float: Optional function for mapping the extracted `Value` to a + float before it reaches `agg`. The default (`None`) passes the raw + extracted value straight through, so `agg`'s own conversion applies + (e.g. `accuracy()`'s `to_float`, or `mean()`'s `as_float()`). Set + this only when `agg` cannot convert the value itself — e.g. to feed + string grades ("C"/"I") into `mean()`, which expects numerics. When + set, pass `value_to_float()` (or a customised variant) to get the + standard CORRECT/INCORRECT/PARTIAL/NOANSWER mapping. + on_missing: How to handle samples whose `score.value` does not contain + `key`, or contains `key` with a `None` value: + + - `"error"` (default): raise `ValueError`. + - `"skip"`: exclude the sample from `agg`. Returns `NaN` if every + sample is skipped. + - `"zero"`: include the sample with value `0.0`. + + A per-key `NaN` value (e.g. `{"key": NaN}`) is always treated as + unscored and skipped, independent of `on_missing` (which governs + *missing keys*, a distinct concept). This matches the framework's + dict-metric expansion, which skips NaN-valued keys rather than feeding + them into the inner metric. + + Returns: + Metric that aggregates `agg` over the `key` field. `NaN` if every + sample is skipped (by `on_missing="skip"` and/or NaN-skipping). + + Raises: + ValueError: If `on_missing` is not one of "error", "skip", "zero"; + if a sample's `score.value` is not a dict; or if `key` is missing + (or `None`) and `on_missing="error"`. + """ + if on_missing not in ("error", "skip", "zero"): + raise ValueError( + f"aggregate() got invalid on_missing={on_missing!r}; " + "expected 'error', 'skip', or 'zero'." + ) + + def aggregate_metric(scores: list[SampleScore]) -> Value: + agg_protocol = cast(MetricProtocol, agg) + + extracted: list[SampleScore] = [] + for sample_score in scores: + value = sample_score.score.value + if not isinstance(value, dict): + raise ValueError( + f"Sample {sample_score.sample_id} has non-dict score value " + f"({type(value).__name__}); aggregate(key=...) requires " + "dict-valued Score.value." + ) + + raw = value.get(key) + + # A per-key NaN is unscored: skip it regardless of on_missing, + # matching the framework's dict-metric expansion (results.py), + # which excludes NaN-valued keys from the inner metric rather + # than letting them drag the aggregate to NaN. (`isinstance` + # excludes a missing key, whose `raw` is None.) + if isinstance(raw, float) and math.isnan(raw): + continue + + # Treat both "key absent" and "key present but None" as missing, + # matching inspect_evals.utils.metrics.mean_of. + if key in value and raw is not None: + # Pass the raw value through by default so `agg`'s own + # converter applies; only pre-convert when an explicit + # to_float was supplied. + extracted_value: str | int | float | bool = ( + to_float(raw) if to_float is not None else raw + ) + elif on_missing == "error": + reason = "missing" if key not in value else "None" + raise ValueError( + f"Sample {sample_score.sample_id} score key '{key}' is " + f"{reason}. Pass on_missing='skip' or on_missing='zero' " + "to aggregate() to allow missing keys." + ) + elif on_missing == "skip": + continue + else: # "zero" + extracted_value = 0.0 + + extracted.append( + SampleScore( + score=sample_score.score.model_copy( + update={"value": extracted_value} + ), + sample_id=sample_score.sample_id, + sample_metadata=sample_score.sample_metadata, + scorer=sample_score.scorer, + ) + ) + + # All samples filtered: return NaN rather than call agg([]) (which + # most built-in metrics raise on). Mirrors Score.unscored()'s NaN + # sentinel. + if not extracted: + return math.nan + + return agg_protocol(extracted) + + return aggregate_metric diff --git a/tests/scorer/test_metric.py b/tests/scorer/test_metric.py index 950697378c..865c9e9eda 100644 --- a/tests/scorer/test_metric.py +++ b/tests/scorer/test_metric.py @@ -1,3 +1,4 @@ +import math from typing import Any, Callable, cast import pytest @@ -19,6 +20,7 @@ metric, scorer, std, + value_to_float, var, ) from inspect_ai.scorer._metric import ( @@ -26,9 +28,8 @@ MetricProtocol, SampleScore, metric_create, - value_to_float, ) -from inspect_ai.scorer._metrics import grouped +from inspect_ai.scorer._metrics import aggregate, grouped from inspect_ai.scorer._metrics.std import stderr from inspect_ai.scorer._target import Target from inspect_ai.solver._task_state import TaskState @@ -714,6 +715,204 @@ def test_dict_metric_all_samples_unscored(): assert math.isnan(r.metrics["mean"].value) +def test_aggregate_mean(): + metric = aggregate("element_acc", agg=mean()) + result = metric( + [ + SampleScore(score=Score(value={"element_acc": 1, "action_f1": 0.5})), + SampleScore(score=Score(value={"element_acc": 1, "action_f1": 0.6})), + SampleScore(score=Score(value={"element_acc": 4, "action_f1": 0.7})), + ] + ) + assert result == 2.0 + + +def test_aggregate_selects_key(): + # Different keys over the same scores should give different aggregates. + scores = [ + SampleScore(score=Score(value={"a": 1, "b": 10})), + SampleScore(score=Score(value={"a": 3, "b": 30})), + ] + assert aggregate("a", agg=mean())(scores) == 2.0 + assert aggregate("b", agg=mean())(scores) == 20.0 + + +def test_aggregate_stderr_composes(): + # stderr() should work as the inner aggregator. + se = aggregate("x", agg=stderr())( + [SampleScore(score=Score(value={"x": i, "y": -i})) for i in range(20)] + ) + expected = stderr()([SampleScore(score=Score(value=i)) for i in range(20)]) + assert se == expected + + +def test_aggregate_missing_key_error_by_default(): + metric = aggregate("element_acc", agg=mean()) + with pytest.raises(ValueError, match="is missing"): + metric( + [ + SampleScore(score=Score(value={"element_acc": 1})), + SampleScore(score=Score(value={"action_f1": 0.5})), + ] + ) + + +def test_aggregate_missing_key_skip(): + metric = aggregate("element_acc", agg=mean(), on_missing="skip") + result = metric( + [ + SampleScore(score=Score(value={"element_acc": 1})), + SampleScore(score=Score(value={"action_f1": 0.5})), # skipped + SampleScore(score=Score(value={"element_acc": 3})), + ] + ) + assert result == 2.0 + + +def test_aggregate_missing_key_zero(): + metric = aggregate("element_acc", agg=mean(), on_missing="zero") + result = metric( + [ + SampleScore(score=Score(value={"element_acc": 3})), + SampleScore(score=Score(value={"action_f1": 0.5})), # treated as 0.0 + SampleScore(score=Score(value={"element_acc": 3})), + ] + ) + assert result == 2.0 + + +def test_aggregate_non_dict_value_raises(): + metric = aggregate("element_acc", agg=mean()) + with pytest.raises(ValueError, match="non-dict"): + metric( + [ + SampleScore(score=Score(value={"element_acc": 1})), + SampleScore(score=Score(value=1.0)), # scalar, not a dict + ] + ) + + +def test_aggregate_explicit_to_float_applied(): + # An explicit to_float lets string grades flow into mean() (which expects + # numerics). value_to_float maps "C"->1.0 and "I"->0.0. + result = aggregate("verdict", agg=mean(), to_float=value_to_float())( + [ + SampleScore(score=Score(value={"verdict": "C"})), + SampleScore(score=Score(value={"verdict": "I"})), + SampleScore(score=Score(value={"verdict": "C"})), + SampleScore(score=Score(value={"verdict": "C"})), + ] + ) + assert result == 0.75 + + +def test_aggregate_passthrough_respects_inner_to_float(): + # With the default to_float=None, the raw value passes straight through to + # the inner metric, so the inner metric's OWN converter applies. Here a + # custom value_to_float on accuracy() maps "pass"->1.0 / "fail"->0.0; + # aggregate must not pre-convert and bypass it. + inner = accuracy(to_float=value_to_float(correct="pass", incorrect="fail")) + result = aggregate("verdict", agg=inner)( + [ + SampleScore(score=Score(value={"verdict": "pass"})), + SampleScore(score=Score(value={"verdict": "fail"})), + SampleScore(score=Score(value={"verdict": "pass"})), + SampleScore(score=Score(value={"verdict": "pass"})), + ] + ) + assert result == 0.75 + + +def test_aggregate_none_value_treated_as_missing_error(): + # A present-but-None value is treated the same as a missing key + # (matches inspect_evals.utils.metrics.mean_of). + metric = aggregate("element_acc", agg=mean()) + with pytest.raises(ValueError, match="is None"): + metric( + [ + SampleScore(score=Score(value={"element_acc": 1})), + SampleScore(score=Score(value={"element_acc": None})), + ] + ) + + +def test_aggregate_none_value_treated_as_missing_skip(): + result = aggregate("element_acc", agg=mean(), on_missing="skip")( + [ + SampleScore(score=Score(value={"element_acc": 1})), + SampleScore(score=Score(value={"element_acc": None})), # skipped + SampleScore(score=Score(value={"element_acc": 3})), + ] + ) + assert result == 2.0 + + +def test_aggregate_none_value_treated_as_missing_zero(): + result = aggregate("element_acc", agg=mean(), on_missing="zero")( + [ + SampleScore(score=Score(value={"element_acc": 3})), + SampleScore(score=Score(value={"element_acc": None})), # treated as 0.0 + SampleScore(score=Score(value={"element_acc": 3})), + ] + ) + assert result == 2.0 + + +def test_aggregate_all_skipped_returns_nan(): + # When skip filters every sample, return NaN rather than calling agg([]) + # (which most built-in metrics raise on). + result = aggregate("element_acc", agg=mean(), on_missing="skip")( + [ + SampleScore(score=Score(value={"action_f1": 0.5})), + SampleScore(score=Score(value={"action_f1": 0.6})), + ] + ) + assert isinstance(result, float) and math.isnan(result) + + +def test_aggregate_skips_nan_values(): + # A per-key NaN is unscored: skipped regardless of on_missing (which is + # at its "error" default here), so the aggregate is the mean of the + # non-NaN values rather than NaN. Matches dict-metric expansion. + result = aggregate("x", agg=mean())( + [ + SampleScore(score=Score(value={"x": 1})), + SampleScore(score=Score(value={"x": float("nan")})), + SampleScore(score=Score(value={"x": 3})), + ] + ) + assert result == 2.0 + + +def test_aggregate_all_nan_returns_nan(): + result = aggregate("x", agg=mean())( + [ + SampleScore(score=Score(value={"x": float("nan")})), + SampleScore(score=Score(value={"x": float("nan")})), + ] + ) + assert isinstance(result, float) and math.isnan(result) + + +def test_aggregate_nan_skipped_under_zero(): + # NaN is distinct from a missing key: even with on_missing="zero" a NaN + # value is skipped (not coerced to 0.0), so it doesn't drag the mean down. + result = aggregate("x", agg=mean(), on_missing="zero")( + [ + SampleScore(score=Score(value={"x": 2})), + SampleScore(score=Score(value={"x": float("nan")})), + ] + ) + assert result == 2.0 + + +def test_aggregate_invalid_on_missing_raises(): + # Invalid on_missing must fail at construction, not silently behave like + # "zero" only when a key happens to be missing. + with pytest.raises(ValueError, match="invalid on_missing"): + aggregate("x", agg=mean(), on_missing="skpi") + + def test_metrics_return_zero_for_empty_scores() -> None: # Every built-in numeric metric must handle an empty score list by # returning 0.0 rather than nan (with numpy empty-slice warnings). See the