Skip to content

Count each sample once in the ACC, SSR and spread of masked data - #202

Merged
azrael417 merged 2 commits into
NVIDIA:mainfrom
MaxFreedomPollard:fix/masked-ratio-metrics
Oct 27, 2026
Merged

azrael417 merged 2 commits into
NVIDIA:mainfrom
MaxFreedomPollard:fix/masked-ratio-metrics

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

MetricRollout.update gives missing (NaN) target values zero weight, and compute_counts then counts every sample with the area its weights integrate to. That is right for the area averages (L1, RMSE, CRPS, rank histogram): their result for a sample is an integral over the weights, and finalize renormalizes it to the valid area. The ACC and the SSR of a sample, however, are ratios of integrals over the same weights and are normalized to the valid area already, so they get divided by it a second time. The spread, the square root of such an integral, comes out too large by the square root of that factor. For SST missing over land, say, the reported ACC is its actual value divided by the ocean fraction, roughly 1.4 times too large.

Minimal reproduction on CPU:

import torch
from makani.utils.metrics.functions import GeometricACC

torch.manual_seed(0)
x = torch.randn(4, 1, 32, 64)
y = x + 0.5 * torch.randn_like(x)
acc = GeometricACC("equiangular", img_shape=(32, 64), normalize=True, channel_reduction="none", batch_reduction="sum")

mask = torch.ones_like(x)
mask[..., :32] = 0.0  # the target is missing over half of the grid
print(acc.finalize(acc(x, y, mask), acc.compute_counts(x, mask)))
# main: tensor([1.7753]); with this change: tensor([0.8877]), the ACC over the valid half

Through MetricsHandler, with the target of one channel NaN over half of the grid and an 8 member ensemble, that channel reports ACC 1.30, SSR 1.93 and spread 1.39 on main, and 0.65, 0.97 and 0.98 with this change, in line with the other channels, which do not change.

The change adds GeometricBaseMetric.weighted_counts, which counts a weighted sample with the area of its weights as before, or once if the metric sets normalized_by_weights, as the ACC, SSR and spread now do. GeometricSpread.forward divides its weighted integral by the area of the weights, so that it is the spread over the valid area. A sample without any valid point still counts zero, so a field that is missing entirely stays out of the average, as before. Unweighted metrics and the area averages are unchanged, and so are all metrics for weights that integrate to one, such as the normalized masks the inferencer passes.

test_masked_metrics in tests/test_metrics.py masks about a third of the grid plus one channel of one sample, with the weights scaled by 1 and by 0.25, and compares the ACC, SSR and spread with the metrics over the valid area of every sample, computed in numpy. On main it fails: ACC and SSR come out 1.45 times the reference (5.8 times with the scaled weights) and the spread 1.21 times (2.41 times). With the change it passes.

Testing, on CPU only: tests/test_metrics.py passes (148 passed, 12 skipped for weatherbench2), and so do tests/test_inferencer.py, tests/test_inference.py and tests/test_trainers.py, which run the metrics through the inferencer and the trainers (my environment does not allow worker processes, so I ran those with the data loader workers and the visualizer's process pool switched off from outside the repository), and tests/distributed/test_distributed_metrics.py on two gloo ranks. black, ruff and the license header check are clean on the changed files.

Something I noticed but left out to keep this focused: CRPSLoss.forward raises UnboundLocalError for a single member forecast with spatial weights, because spatial_weights_split is only set in the ensemble branch. MetricsHandler hits it with a one member prediction as soon as a target contains NaN, so the inferencer, which enables the CRPS metric, should fail that way for deterministic models.

🤖 Generated with Claude Code

Missing target values reach the metrics as zero weights, and
compute_counts counted every sample with the area its weights integrate
to. That renormalizes an area average such as the L1 error to the valid
area when it is finalized. The ACC and the SSR of a sample, however, are
ratios of integrals over the same weights, normalized to that area
already, so they were divided by it a second time: with a target missing
over half of the grid, its ACC doubled. The spread, the square root of
such an integral, grew by the square root of that factor.

Count every sample with nonzero weights once for these metrics, and
normalize the weighted spread by the area of its weights. A sample
without valid points still counts zero. Unweighted metrics and the area
averages are unchanged, and so are all metrics for weights that
integrate to one, such as the normalized masks of the inferencer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

The PR fixes masked ACC, SSR, and spread by matching each sample’s contribution to its valid area.

  • Masked ACC, SSR, and spread use each sample's valid area once.

No new actionable issues were found. No previous review threads were supplied. Tests were not run during this review.

Reviews (2) · Last reviewed commit: "Weight the ACC, SSR and spread of every ..." · Reviewed by Greptile

@azrael417

azrael417 commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks, the diagnosis is right: ACC, SSR and spread divide by the valid area a second time when the targets contain NaNs. Three points:

1. Pixel weighting instead of sample weighting

We would prefer to weight each sample by its valid area rather than count every sample once, consistent with the area averages (L1, RMSE, CRPS). A sample with only a sliver of valid points (sea ice, say) should not count as much as a full field, and all metrics would then pool over samples the same way.

That also makes the change smaller. compute_counts stays as it is (each sample counts with its area), weighted_counts/normalized_by_weights are not needed, and every metric scales its per-sample value by the area in forward:

# GeometricACC.forward, macro branch
acc = cov_xy / (torch.sqrt(var_x * var_y) + self.eps)
if weight is not None:
    # weight the ACC of every sample by its valid area, compute_counts counts it with that area
    acc = acc * self.quadrature(weight.to(dtype=acc.dtype))
# GeometricSSR.forward, after forming the ratio
ssr = torch.sqrt(spread / torch.clamp(skill - spread / float(ens_size), min=self.eps))
if weight is not None:
    ssr = ssr * self.quadrature(weight.to(dtype=ssr.dtype))
# GeometricSpread.forward
if weight is not None:
    weight = weight.to(dtype=spread.dtype)
    area = self.quadrature(weight)
    spread = self.quadrature(spread * weight) / torch.clamp(area, min=torch.finfo(spread.dtype).tiny)
else:
    spread = self.quadrature(spread)
    area = None
...
spread = torch.sqrt(spread / float(ens_size - 1))
if area is not None:
    spread = spread * area

The result per channel is then sum_i a_i * m_i / sum_i a_i. Unweighted metrics and weights that integrate to one (such as the inferencer's normalized masks) are unchanged. A sample without valid points still contributes 0 with count 0, and scaling the weights cancels. In the test, the reference becomes the area-weighted mean over samples instead of nanmean, for example:

area = np.sum(area_weight, axis=(-2, -1))
return np.nansum(score * area, axis=0) / np.sum(area, axis=0)

2. method="micro" ACC

This is moot with point 1, since the flag goes away. If the flag stays, note that normalized_by_weights = True on GeometricACC also applies to method="micro". It is harmless there, because the micro finalize forms the ratio from the summed integrals and ignores the counts, but a comment saying so would help.

3. CRPSLoss UnboundLocalError for single-member forecasts

Good catch, thanks. Please leave it out of this PR; can we fix it separately in a small targeted MR? The functions GradientCRPSLoss, VortDivCRPSLoss and KernelScoreLoss have the same problem, please address those too.

Instead of counting every sample once, compute_counts keeps counting a
weighted sample with the area its weights integrate to, as for the area
averages, and forward scales the ACC (macro), SSR and spread of every
sample by that area. The result per channel is then
sum_i a_i * m_i / sum_i a_i: a sample with only a few valid points
counts less than a full field, and all metrics pool over samples the
same way. weighted_counts and normalized_by_weights are no longer
needed.

Unweighted metrics and weights that integrate to one are unchanged. A
sample without valid points still contributes zero with count zero, and
scaling the weights cancels.

The test reference is now the area-weighted mean over the samples, and
one sample has a smaller valid area than the others, so that the test
tells area weighting apart from counting every sample once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@azrael417

Copy link
Copy Markdown
Collaborator

Awesome, thanks for addressing all my points. Please do not forget to open another MR for the single-member-forecast issues in CRPS and its related functions.

@azrael417
azrael417 merged commit 3fdeb7a into NVIDIA:main Oct 27, 2026
21 checks passed
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