Repository navigation
Count each sample once in the ACC, SSR and spread of masked data - #202
Conversation
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]>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryThe PR fixes masked ACC, SSR, and spread by matching each sample’s contribution to its valid area.
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 |
|
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. # 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 * areaThe result per channel is then area = np.sum(area_weight, axis=(-2, -1))
return np.nansum(score * area, axis=0) / np.sum(area, axis=0)2. This is moot with point 1, since the flag goes away. If the flag stays, note that 3. Good catch, thanks. Please leave it out of this PR; can we fix it separately in a small targeted MR? The functions |
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]>
|
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. |
MetricRollout.updategives missing (NaN) target values zero weight, andcompute_countsthen 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, andfinalizerenormalizes 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:
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 setsnormalized_by_weights, as the ACC, SSR and spread now do.GeometricSpread.forwarddivides 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_metricsintests/test_metrics.pymasks 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.pypasses (148 passed, 12 skipped for weatherbench2), and so dotests/test_inferencer.py,tests/test_inference.pyandtests/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), andtests/distributed/test_distributed_metrics.pyon 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.forwardraisesUnboundLocalErrorfor a single member forecast with spatial weights, becausespatial_weights_splitis only set in the ensemble branch.MetricsHandlerhits 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