Add aggregate(key, agg=...) metric factory (#3735)#3850
Conversation
…tBEIS#3735) Many scorers emit dict-valued Score.value (multiple numeric fields per sample) and then need per-key metrics. `aggregate(key, agg)` selects one field by key and applies any standard metric (mean, stderr, accuracy, ...) to it, replacing the need for bespoke per-aggregator factories like mean_of/std_of/stderr_of. - on_missing controls samples missing the key: "error" (default), "skip", "zero". - to_float follows the same convention as the other built-in metrics. - Non-dict Score.value raises ValueError with a clear message. Includes tests covering composition with mean/stderr, on_missing modes, and key-selection semantics. Doc section added under "Per-Key Aggregation" in docs/scorers.qmd.
|
two non-blocking observations:
lgtm otherwise. clean separation of concerns, sample identity ( |
|
Thanks @WatchTree-19, both fair points. Pushing a docstring update. Small nuance on (1): On (2), good catch — added a "Notes" paragraph flagging that |
…tics Address review feedback on UKGovernmentBEIS#3850: - Note that `on_missing="skip"` changes the sample count seen by the inner aggregator, so denominator-sensitive metrics (`stderr`, `mean`, `std`, `var`) can report different values purely from differing missing-key rates rather than underlying variance. - Document that a present-but-`None` key is not treated as missing; it is passed through to `to_float`, which with the default `value_to_float()` warns and returns `0.0`. No behaviour change.
|
ah, good catch on the warn-then-zero behaviour - that's worse than raising in this context because the eval still completes with a quietly-wrong number rather than failing loud. agreed that treating None-as-missing is a separate decision; the docstring note is the right scope for this PR. thanks for both updates. lgtm. |
|
Thanks for the careful read @WatchTree-19 — the warn-then-zero framing is a much sharper way of putting it than I had. Appreciated. |
ppcvote
left a comment
There was a problem hiding this comment.
Drive-by review from #3919 (now closed as duplicate of this).
Two things I'd add for the inspect_evals.utils.mean_of migration angle — they're docstring-level, not implementation:
1. Semantic-change call-out for None-valued keys. mean_of currently routes a present-but-None value through on_missing (i.e. it's treated as missing). This PR's aggregate() passes None straight through to_float, which under the default value_to_float() falls through to a 0.0-with-warning. The semantics are arguably cleaner here, but it's a quiet behavior change for anyone doing a straight mean_of("k") → aggregate("k", agg=mean()) swap. One additional sentence in the docstring (or a migration note in the CHANGELOG) saying "if you're migrating from mean_of, note that present-but-None values are handled differently — pass them through your own to_float if you want the old behavior" would close that gap.
2. on_missing="skip" denominator subtlety. The docstring already flags that skip reduces the sample count seen by agg, which changes stderr / mean / std. Great call-out. The complementary case worth mentioning is: when a downstream consumer compares two evals using the same aggregate(..., on_missing="skip") metric across the same dataset, they should be aware that different rates of missing keys produce different stderrs even when the underlying signal is identical. A pointer to "zero" as the constant-denominator alternative — which the prose already hints at — could be made explicit ("for cross-eval comparability, prefer 'zero'"). Tiny nit.
The shape of the factory itself is what I'd have written, including the model_copy(update=...) for the inner sample rebuild — that's cleaner than the field-by-field reconstruction I tried first. Composition with stderr() / std() is the thing I'd most want to see explicit tests for; if you'd like I can prep a small follow-up that adds those (and the mean_of parity case) once this lands.
Address @ppcvote's suggestion on UKGovernmentBEIS#3735: surface the semantic difference vs `inspect_evals.utils.mean_of` (which treats present-but- None as missing) so a straight swap doesn't silently change behaviour. No code change.
dragonstyle
left a comment
There was a problem hiding this comment.
-
I'd like us to properly handle present but None by routing that through
on_missing. This is the correct behavior and will bring things in line with the currentmean_ofbehavior. -
If skip filters all values (so there are no values to aggregate), should we just throw here or return Nan?
… NaN on all-skip Address @dragonstyle's review of UKGovernmentBEIS#3850: - Present-but-None values are now routed through `on_missing` (matching inspect_evals.utils.metrics.mean_of). Previously they fell through to to_float(None), which under the default value_to_float() warns and returns 0.0 — silently conflating "no signal" with "scored 0". - When `on_missing="skip"` filters every sample, aggregate() now returns NaN rather than calling agg([]) (which most built-in metrics raise on). Mirrors the Score.unscored() / NaN sentinel used elsewhere in the framework. Adds 4 tests covering the None-handling on each on_missing mode and the all-skipped → NaN case. CHANGELOG updated to remove the now-obsolete migration note about the semantic difference vs mean_of (which no longer exists).
|
Both addressed in
CHANGELOG updated to drop the now-obsolete migration note (the semantic difference vs |
| """ | ||
|
|
||
| def aggregate_metric(scores: list[SampleScore]) -> Value: | ||
| agg_protocol = cast(MetricProtocol, agg) |
There was a problem hiding this comment.
Yes — Metric is MetricProtocol | MetricDeprecated, and MetricDeprecated.__call__ takes list[Score] while we're passing list[SampleScore], so mypy needs the narrowing to the protocol form to accept agg(extracted). It mirrors the same cast(MetricProtocol, metric) in grouped() (_metrics/grouped.py), which wraps an inner metric the same way. Happy to restructure if you'd prefer to avoid the cast, but this keeps it consistent with the existing pattern.
|
Code reporting these three issues:
|
Address two of dragonstyle's review findings on UKGovernmentBEIS#3850: - P1: a per-key NaN value (e.g. {"x": NaN}) was converted and passed to the inner metric as a scored value, dragging the whole aggregate to NaN. The framework's dict-metric expansion instead skips NaN-valued keys as unscored. aggregate() now does the same — NaN values are skipped regardless of on_missing (which governs *missing keys*, a distinct concept). aggregate("x", mean()) over [{x:1},{x:NaN},{x:3}] now returns 2.0 instead of NaN. - P3: an invalid on_missing (e.g. "skpi") silently behaved like "zero", and only when a key happened to be missing. It's now validated at construction time and raises ValueError immediately. The third finding (P2 — aggregate's to_float bypassing a custom converter on the inner metric) is a deliberate API-design choice left for the maintainer to direct; raised separately in a PR comment. Adds tests: NaN skipped (default + on_missing="zero"), all-NaN → NaN, invalid on_missing raises.
|
Thanks for the careful review @dragonstyle. P1 and P3 are fixed in P1 — per-key NaN treated as scored. Fixed. A per-key P3 — invalid P2 — inner metric's
I lean A, but it's your API — which would you prefer? Happy to push it straight after. Re the inline question on the |
|
|
Implements @dragonstyle's chosen Option A on UKGovernmentBEIS#3850. Previously aggregate always pre-converted extracted values with its own `to_float` (`value_to_float()` by default), silently bypassing any custom converter on the inner metric — e.g. accuracy(to_float=value_to_float(correct="pass")) never saw "pass"/"fail" because aggregate had already mapped them to 0.0. `to_float` now defaults to None: the raw extracted value passes straight through to `agg`, so the inner metric's own conversion applies (accuracy()'s to_float, mean()'s as_float). An explicit `to_float=` is the escape hatch for feeding non-numeric values (e.g. "C"/"I" string grades) into metrics like mean() that can't convert them. - test_aggregate_to_float_applied → test_aggregate_explicit_to_float_applied (now passes to_float=value_to_float() explicitly). - Add test_aggregate_passthrough_respects_inner_to_float, asserting a custom value_to_float on accuracy() is honoured (the exact case in the review).
|
Option A implemented in That's all three findings addressed. Let me know on the |
|
Heads-up: CI hasn't kicked off on the latest commit ( |
|
Friendly check-in @dragonstyle @jjallaire — no rush, just surfacing the current state so nothing's silently stuck:
Happy to rebase any of them if they've drifted. Thanks for the time! |
dragonstyle
left a comment
There was a problem hiding this comment.
@antnewman Apologies for the delay - can you please resolve conflicts on this branch?
…etric # Conflicts: # CHANGELOG.md # docs/scorers.qmd # src/inspect_ai/scorer/__init__.py # src/inspect_ai/scorer/_metrics/__init__.py # tests/scorer/test_metric.py
|
Conflicts resolved, @dragonstyle — merged latest All aggregate tests plus |
…ng test The @Metric decorator erases the Literal["error","skip","zero"] constraint on on_missing, so passing "skpi" was never a type error and the # type: ignore[arg-type] was flagged unused by mypy. Remove it.
…etric # Conflicts: # CHANGELOG.md
…etric # Conflicts: # CHANGELOG.md
|
@dragonstyle Conflicts resolved — sorry for the silence after your request; I'd actually fixed them the next day but neglected to say so here, and the branch drifted conflicting again in the meantime. It's now merged up to today's I'll keep the branch current so it doesn't block you again — whenever you get a chance to re-review, I'd be glad to clear the changes-requested flag. |
…etric # Conflicts: # CHANGELOG.md
Closes #3735.
Implements the
aggregate(key, agg)metric factory proposed in the issue and the prior Slack discussion. Many scorers emit dict-valuedScore.value(multiple numeric fields per sample);aggregateextracts one field by key and applies any standard metric (mean,stderr,std,accuracy, ...) to it, replacing the need for bespoke per-aggregator factories likemean_of/std_of/stderr_ofin downstream evals.Usage
Behaviour
on_missing="error"(default) raisesValueErrorif any sample lacks the key.on_missing="skip"excludes such samples from the inner aggregator.on_missing="zero"substitutes0.0.Score.valuealways raisesValueErrorwith a message naming the offending sample (matchesgrouped()'s strictness for type errors).sample_id,sample_metadata,scorer) is preserved when constructing the innerSampleScores, so metrics that consult metadata (e.g. clusteredstderr) continue to work.Scope
This PR adds
aggregate()only —grouped()is not refactored to use it, to keep the diff focused. Happy to follow up with that if maintainers prefer.This PR contains:
What is the current behavior?
No general-purpose primitive for applying a metric to a single field of dict-valued scores.
inspect_evalsships amean_ofhelper, but extending to other aggregators requires a new factory each time (seecyberseceval_4'sstd_of/stderr_of/percentage_of/count_of).What is the new behavior?
aggregate(key, agg, *, to_float=value_to_float(), on_missing="error")returns aMetricthat appliesaggto thekeyfield extracted from each sample.Does this PR introduce a breaking change?
No. New public function exported from
inspect_ai.scorer.Other information
Tests cover composition with
mean()andstderr(),on_missingmodes, key-selection across multiple keys, non-dict-value error path, andto_floatapplication for"C"/"I"values. Docs added under "Per-Key Aggregation" indocs/scorers.qmd.