Skip to content

Commit 18c2a8c

Browse files
langmoreWeatherbench2 authors
authored andcommitted
Validate transforms in WrappedMetric. This checks that transform.which in transform.unique_name_suffix. This check ideally would be done in the base class, InputTransform.__init__, but cannot because unique_name_suffix is not determined until *after* base class init.
PiperOrigin-RevId: 743185413
1 parent 7914d06 commit 18c2a8c

2 files changed

Lines changed: 86 additions & 0 deletions

File tree

weatherbench2/metrics.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,8 @@ def __init__(
19171917
self,
19181918
ensemble_dim: str = REALIZATION,
19191919
num_bins: t.Optional[int] = None,
1920+
break_ties_randomly: bool = True,
1921+
seed: int = 802701,
19201922
):
19211923
"""Initializes a RankHistogram.
19221924
@@ -1925,9 +1927,17 @@ def __init__(
19251927
num_bins: Number of bins in histogram. If None, the number of bins will be
19261928
`ensemble_size + 1`. If provided, `num_bins` must evenly divide into
19271929
`ensemble_size + 1`.
1930+
break_ties_randomly: If True, break ties with the following behavior.
1931+
If a subset of bins are identical (due to identical ensemble members),
1932+
and truth falls within the corresponding bins, a random choice (within
1933+
the tied bins) is made. If truth is exactly equal to some ensemble
1934+
members, it is randomly assigned a bin within the tied bins.
1935+
seed: Seed for RNG used to break ties.
19281936
"""
19291937
super().__init__(ensemble_dim=ensemble_dim)
19301938
self.num_bins = num_bins
1939+
self._break_ties_randomly = break_ties_randomly
1940+
self._seed = seed
19311941

19321942
def _num_bins_actual(self, ensemble_size: int) -> int:
19331943
default_n_bins = ensemble_size + 1
@@ -1949,6 +1959,34 @@ def _bin_ranks(self, ensemble_size: int, ranks: xr.DataArray):
19491959
else:
19501960
return ranks // reduction_factor
19511961

1962+
def _perturb_by_min_ensemble_diff(self, da: xr.DataArray) -> xr.DataArray:
1963+
"""Perturbs da values by the minimum diff along ensemble_dim / 2."""
1964+
if da.sizes[self.ensemble_dim] < 2:
1965+
# The purpose of the perturbation is to break ties. No ties if only 1.
1966+
return da
1967+
idx = da.dims.index(self.ensemble_dim)
1968+
diffs = np.diff(np.sort(da, axis=idx), axis=idx)
1969+
1970+
# diff = 0 is a problem. We don't want to perturb by 0, since that does
1971+
# nothing. We want to perturb by the smallest diff that is > 0.
1972+
diffs_zero_replaced_by_inf = np.where(
1973+
diffs == 0,
1974+
np.inf,
1975+
diffs,
1976+
)
1977+
min_diff = diffs_zero_replaced_by_inf.min(axis=idx, keepdims=True)
1978+
perturbation_size = np.where(
1979+
# If all diffs were zero, then the minimum will be Inf, and in this case
1980+
# perturb by 1.
1981+
min_diff < np.inf,
1982+
min_diff / 2,
1983+
1,
1984+
)
1985+
perturbation = np.random.default_rng(self._seed).uniform(
1986+
size=da.shape, low=-perturbation_size / 2, high=perturbation_size / 2
1987+
)
1988+
return da + perturbation
1989+
19521990
def compute_chunk(
19531991
self,
19541992
forecast: xr.Dataset,
@@ -1975,6 +2013,14 @@ def compute_chunk(
19752013
{self.ensemble_dim: forecast[self.ensemble_dim]}
19762014
)
19772015
combined = xr.concat([truth, forecast], dim=self.ensemble_dim)
2016+
if self._break_ties_randomly:
2017+
# It is not enough to just perturb forecast. Consider e.g. the case when
2018+
# forecast = [0, 0, 0, 0, 0], every time, and so is truth. Then, if we
2019+
# only perturb the forecast, truth will end up in the center bin more than
2020+
# the others.
2021+
combined = combined.map(
2022+
self._perturb_by_min_ensemble_diff, keep_attrs=True
2023+
)
19782024

19792025
def array_rank_one_hot(da: xr.DataArray) -> xr.DataArray:
19802026
ensemble_size = forecast.sizes[self.ensemble_dim]

weatherbench2/metrics_test.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,46 @@ def test_well_and_mis_calibrated(
599599
test_utils.assert_strictly_increasing(hist.sel(level=3))
600600
test_utils.assert_strictly_decreasing(hist.sel(level=4))
601601

602+
@parameterized.parameters(
603+
dict(ensemble_size=1),
604+
dict(ensemble_size=2),
605+
dict(ensemble_size=3),
606+
dict(ensemble_size=10),
607+
)
608+
def test_repeated_entries_get_random_bin(self, ensemble_size):
609+
num_bins = ensemble_size + 1
610+
# Forecast and truth both come from Normal(0, I).
611+
truth, forecast = get_random_truth_and_forecast(
612+
ensemble_size=ensemble_size,
613+
# Get enough days so our sample size is large.
614+
time_start='2019-12-01',
615+
time_stop='2019-12-10',
616+
)
617+
618+
# Give repeated values, but maintain that they are from the same dist.
619+
truth = xr.where(truth <= 0, 0, truth)
620+
forecast = xr.where(forecast <= 0, 0, forecast)
621+
622+
one_hot_ranks = metrics.RankHistogram(
623+
ensemble_dim='realization', num_bins=num_bins
624+
).compute_chunk(forecast, truth)
625+
626+
averaging_dims = [
627+
# Reduce over all dims, since the distribution is IID.
628+
# This increases statistical power.
629+
'prediction_timedelta',
630+
'time',
631+
'latitude',
632+
'longitude',
633+
'level',
634+
]
635+
sample_size = np.prod([one_hot_ranks.sizes[d] for d in averaging_dims])
636+
rtol = 5 * np.sqrt((num_bins - 1) / sample_size) # 5 standard errors.
637+
638+
hist = one_hot_ranks.mean(averaging_dims).geopotential
639+
640+
np.testing.assert_allclose(1 / num_bins, hist, rtol=rtol)
641+
602642

603643
class CentralReliabilityTest(parameterized.TestCase):
604644

0 commit comments

Comments
 (0)