Add normalized correction metrics to the inference aggregators#1347
Add normalized correction metrics to the inference aggregators#1347jpdunc23 wants to merge 13 commits into
Conversation
Corrector-equipped runs currently write their per-step correction deltas to autoregressive_step_diagnostics.nc but log no metrics about them, so there is no at-a-glance answer to "how much does this model lean on its corrector?". This PR adds normalized-space correction metrics to both inference aggregators, computed from the StepDiagnostics attached to prediction data: an area-weighted global mean of the time-mean |normalized correction| per corrected variable (plus a channel mean and a signed time-mean map image) under the existing time_mean_norm group, and per-forecast-step area-weighted magnitude/std series under mean_norm. The normalized correction is computed as normalize(prediction) - normalize(prediction - delta), since normalizing the raw delta would wrongly subtract the per-variable mean offset; all reductions go through the mask-aware GriddedOperations, so NaN off-mask cells drop out of the scalars. Metrics are uniform over all corrected variables, on by default via log_correction_metrics flags, and silent when no corrector ran (or in coupled inference, which does not attach step diagnostics). Inline training keeps the time-mean metrics but drops the per-step series, matching the existing time-series behavior.
…aggregator surface Review round 1: hosts now carry a generic dict of StepDiagnosticsAggregators instead of a delta-specific recorder; config becomes StepDiagnosticsMetricConfig with scalars on / maps off by default; channel_mean dropped; the normalize(pred) - normalize(pred - delta) formulation is documented as the uncentered delta/std (no center=False normalizer option needed).
- Hosts hold a single optional StepDiagnosticsAggregator (concrete wrapper owning the dict of per-concern sub-aggregators, which satisfy a StepDiagnosticsSubAggregator protocol) instead of dict fan-out in the host aggregators. - Rename compute_correction_norm -> normalize_delta_without_mean.
Update pr-plan.md for the merged apply_mean option (#1353): compute the normalized correction directly as normalize(delta, apply_mean=False) instead of a normalize_delta_without_mean helper, and use the NormalizeFn protocol in normalize-passing signatures.
Corrector-equipped runs now log how much the model leans on its corrector: normalized-space correction-magnitude metrics computed from the StepDiagnostics delta carried on prediction data, merged into the existing time_mean_norm / mean_norm metric groups in both the evaluator and no-target inference aggregators. Each host carries a single optional StepDiagnosticsAggregator that owns per-concern sub-aggregators (the correction delta today) and fans record/log/flush calls out over them. Scalar metrics are on by default and map images are opt-in via the new step_diagnostics config on both aggregator configs; runs without a corrector (or without a normalizer, for the no-target aggregator) are unaffected.
|
Pre-review agent: I ran a pre-review audit of this PR ahead of human review, in a clean session separate from the one that built the change. All 19 new tests pass locally and ruff/mypy are clean. Three observations for the reviewer, none blocking:
Minor: — 🤖 Drafted by Claude (Fable 5) via Claude Code, posted on @jpdunc23's behalf. |
I think we already have precedents of raising errors in the aggregators when user config and build-time data availability are mismatched. Should we do the same here?
Is this a preexisting issue in other time mean aggregators? If not, we should follow their prior art. If so, do we just need to provide some better documentation of the
Let's add this test.
Let's make it public. |
Pull MeanAggregator._get_series_data and its get_dataset body out into public get_series_data / series_data_to_dataset helpers in reduced.py, reused by MeanAggregator, SingleTargetMeanAggregator, and CorrectionDeltaMeanAggregator. SingleTargetMeanAggregator.get_dataset now returns an empty dataset instead of raising when nothing was recorded, inheriting the empty-safe branch. Remove the pr-plan.md planning artifact ahead of merge.
…on invariants, public SeriesData - StepDiagnosticsMetricConfig.build raises ValueError when the config is explicitly non-default but no normalizer is supplied; the default config keeps the silent skip (the coupled builder constructs InferenceAggregatorConfig with defaults and no normalizer). - CorrectionDeltaTimeMeanAggregator.record_batch documents its contiguous-windows assumptions and raises ValueError if a batch changes the sample count or variable set mid-accumulation. - Add a two-window accumulation test with a hand-computed mean, plus tests for the new errors. - Rename reduced.py's _SeriesData to public SeriesData.
|
All four addressed in cd6dd05:
— 🤖 Drafted by Claude (Fable 5) via Claude Code, posted on @jpdunc23's behalf. |
The prediction was only needed for normalization before #1353; the record-time time-alignment check goes with it (the delta's alignment is guaranteed by StepOutput.stack_diagnostics's construction). Also remove the coupled-inference example from a comment that would go stale when coupled carriage lands.
mcgibbon
left a comment
There was a problem hiding this comment.
LGTM, only optional comments.
| class StepDiagnosticsSubAggregator(Protocol): | ||
| """A per-concern aggregator of metrics from the StepDiagnostics carried | ||
| on prediction data. Entries in the StepDiagnosticsAggregator's dict | ||
| satisfy this; the wrapper delegates record/log/flush calls without | ||
| knowing what any entry measures. | ||
| """ | ||
|
|
||
| @property | ||
| def log_time_series(self) -> bool: ... | ||
|
|
||
| def record_batch( | ||
| self, | ||
| step_diagnostics: StepDiagnostics | None, | ||
| i_time_start: int, | ||
| ) -> None: ... | ||
|
|
||
| def summary_logs(self) -> dict[str, Any]: ... | ||
|
|
||
| def time_series_logs(self, step_slice: slice) -> dict[str, Any]: ... | ||
|
|
||
| def diagnostics(self) -> dict[str, xr.Dataset]: ... |
There was a problem hiding this comment.
Suggestion (optional): Use StepDiagnosticsSubAggregator = CorrectionDeltaAggregator (or whatever the correct form is for type variable assignment, and update this to equal the union of types when we have more than one such example. Or I don't mind you leaving it as-is, especially if this is replicating an existing pattern.
Optional: If you want a stronger guarantee that new implementations have the API, you could use an ABC instead of protocol.
Corrector-equipped runs currently write their per-step correction deltas to
autoregressive_step_diagnostics.ncbut log no metrics about them, so there is no at-a-glance answer to "how much does this model lean on its corrector?". This PR adds normalized-space correction metrics to both inference aggregators, computed from theStepDiagnosticsattached to prediction data: an area-weighted global mean of the time-mean |normalized correction| per corrected variable (plus an opt-in signed time-mean map image) under the existingtime_mean_normgroup, and per-forecast-step area-weighted magnitude/std series undermean_norm. The normalized correction isnormalize(delta, apply_mean=False)— i.e.delta / std, using theapply_meanoption from #1353, since mean-centering a difference quantity would wrongly subtract the per-variable mean offset; all reductions go through the mask-awareGriddedOperations, so NaN off-mask cells drop out of the scalars. Metrics are uniform over all corrected variables and silent when no corrector ran (or in coupled inference, which does not attach step diagnostics). Inline training keeps the time-mean metrics but drops the per-step series, matching the existing time-series behavior.The wiring mirrors the
StepDiagnosticscontainer rather than its current contents: each host aggregator carries a single optionalStepDiagnosticsAggregator— a concrete wrapper owning a dict of per-concern sub-aggregators (one entry today — the correction-delta aggregator) and fanning record/log/flush out over it — so futureStepDiagnosticsfields get sibling entries inside the wrapper instead of more host surgery. Granular sub-aggregators for specific container fields (justdelta, for now) live another level down and are equally invisible to the hosts.Changes:
fme.ace.aggregator.inference.step_diagnostics(new): theStepDiagnosticsAggregatorwrapper, theStepDiagnosticsSubAggregatorprotocol,StepDiagnosticsMetricConfig, theCorrectionDeltaAggregator, and its granularCorrectionDeltaTimeMeanAggregator/CorrectionDeltaMeanAggregatorsub-aggregatorsInferenceEvaluatorAggregatorConfig.step_diagnosticsandInferenceAggregatorConfig.step_diagnostics: newStepDiagnosticsMetricConfigfields controlling metric granularity —correction_scalars(default on) andcorrection_maps(default off)Both inference aggregators gain
step_diagnostics_aggregator: StepDiagnosticsAggregator | None = Noneand delegate record/log/flush to itInferenceAggregatorConfig.buildgains an optionalnormalizeargument (defaultNone); the no-target inference entrypoint now passesstepper.normalizer.normalize. With no normalizer, a defaultstep_diagnosticsconfig silently skips the correction metrics (preserving current behavior for callers that omitnormalize, e.g. the coupled builder), while an explicitly non-default config raises rather than silently dropping an opt-inreduced.py's series-assembly dataclass is public (SeriesData), andCorrectionDeltaTimeMeanAggregatorraises if a batch changes the sample count or variable set mid-accumulation instead of silently biasing the time-meansTests added
If dependencies changed, "deps only" image rebuilt and "latest_deps_only_image.txt" file updated