Preview: Incrementally applying safeguards for tighter and less conservative corrections¶
The compression-safeguards are implemented to use one-shot (instead of iterative) algorithms and to make use of vectorisation over arrays. As a consequence, the corrections for all elements are computed simultaneously. While this makes no difference for pointwise safeguards, it means that stencil safeguards have to make conservative assumptions about how neighbouring elements can affect each other in order to guarantee that their combined errors do not violate any user safety requirements. Incrementally applying the safeguards allows making less conservative assumptions and producing tighter corrections by giving the safeguards knowledge of previously corrected elements when computing the corrections for the next elements. While the compression-safeguards API currently does not provide support for incrementally computing corrections, this notebook sketches how incrementally applying the safeguards might work, by extending and patching the package where necessary.
Note that fully supporting incrementally applying safeguards will require support in the
compression-safeguardscore package. For instance, the process of computing incremental corrections over chunked data would first need to be investigated.
For this example, we use a quantity of interest safeguard that preserves the box mean over five elements (two before, two after) along the latitude axis of a U-wind field. (In the default one-shot safeguards implementation, guaranteeing an absolute error bound on the unweighted box mean is equivalent to guaranteeing the same error bound pointwise, i.e. the safeguards do not exploit that in a box mean some elements can tolerate a higher error if others compress with a lower error). We then test a simple high-level implementation for incremental safeguards, where we partition all data points into $K$ non-overlapping groups and then apply the safeguards $K$ times, each round giving the safeguards access to the already-corrected values from prior rounds and only keeping the corrections for this round's subset of data points. For this simple approach, we compare two variants. For the first, we randomly partition the data points into $K=5$ groups, for the second we select the groups based on the box-mean's five element stencil by stepping over the latitude axis in steps of $5$ and putting the first element in the first group, second element in the second group, etc. Finally, we compare both approaches for two different approximations of the data that the safeguards produce corrections for: an all-zero approximation that requires corrections for most elements, and an approximation from the lossy SPERR compressor that has been configured with a RMSE error bound that exceeds the conservative pointwise absolute error bound for many but not all points.
import ssl
ssl._create_default_https_context = ssl._create_stdlib_context
import re
from collections.abc import Collection, Mapping, Set
from itertools import product
from pathlib import Path
from typing import ClassVar, Literal
import earthkit.plots
import humanize
import matplotlib as mpl
import numpy as np
import pandas as pd
import xarray as xr
from matplotlib import pyplot as plt
from numcodecs_safeguards.lossless import Lossless
from typing_extensions import override
from compression_safeguards import SafeguardKind, Safeguards
from compression_safeguards.safeguards._qois import StencilQuantityOfInterest
from compression_safeguards.safeguards._qois.typing import F, Ns, Ps
from compression_safeguards.safeguards.abc import Safeguard
from compression_safeguards.safeguards.pointwise.abc import PointwiseSafeguard
from compression_safeguards.safeguards.stencil.qoi.eb import (
StencilQuantityOfInterestErrorBoundSafeguard,
)
from compression_safeguards.utils.bindings import Bindings, Parameter
from compression_safeguards.utils.intervals import Interval, IntervalUnion, Lower, Upper
from compression_safeguards.utils.typing import JSON, C, S, T
# Retrieve the data
ERA5 = xr.open_dataset(Path() / "data" / "era5-uv" / "data.nc")
ERA5_U = ERA5["u"].sel(valid_time="2024-04-02T12:00:00")
ERA5_U.shape
(1, 721, 1440)
# 0.1 m/s absolute error bound on the box mean
u_mean_eb_abs = 0.1
qoi = SafeguardKind.qoi_eb_stencil.value(
qoi="sum(X) / size(X)", # box mean along the latitude axis
neighbourhood=[
dict(axis=1, before=2, after=2, boundary="reflect"),
],
type="abs",
eb=u_mean_eb_abs,
)
qoi._qoi_expr
(X[0] + X[1] + X[2] + X[3] + X[4]) / 5
def compute_mean_wind_u(ERA5_U: xr.DataArray) -> xr.DataArray:
ERA5_U_mean = ERA5_U.copy(
data=qoi.evaluate_qoi(ERA5_U.values, late_bound=Bindings.EMPTY)
)
ERA5_U_mean.attrs.update(long_name=f"Mean {ERA5_U.long_name.lower()}")
return ERA5_U_mean
ERA5_U_mean = compute_mean_wind_u(ERA5_U)
# for computing compression ratios, we use the default lossless codec for
# safeguards corrections from the numcodecs-safeguards package
corrections_codec = Lossless().for_corrections
corrections_codec
PickBestCodec(CodecStack(TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))), CodecStack(BinaryDeltaCodec(), TokenizeCodec(), BitmapIndexCodec(), TypedByteShuffleCodec(), FramedCodecStack(Zstd(level=3))))
old_cmap_and_norm = earthkit.plots.styles.colors.cmap_and_norm
def my_cmap_and_norm(colors, levels, normalize=True, extend=None, extend_levels=True):
return old_cmap_and_norm(colors, levels, normalize, extend, True)
earthkit.plots.styles.colors.cmap_and_norm = my_cmap_and_norm
def plot_mean_wind_u(
my_ERA5_U: xr.DataArray,
chart,
title,
span,
u_eb_abs,
mean=True,
error=False,
qoi_stencil=None,
incremental_masks=None,
correction=None,
extra_bytes=0,
):
import copy
ERA5_U_pick = ERA5_U_mean if mean else ERA5_U
my_ERA5_U_pick = compute_mean_wind_u(my_ERA5_U) if mean else my_ERA5_U
if error:
with xr.set_options(keep_attrs=True):
da = (my_ERA5_U_pick - ERA5_U_pick).compute()
da.attrs.update(long_name=f"Absolute error over {da.long_name.lower()}")
else:
da = my_ERA5_U_pick
# compute the default style that earthkit.maps would apply
source = earthkit.plots.sources.XarraySource(da)
style = copy.deepcopy(
earthkit.plots.styles.auto.guess_style(
source,
units=source.units,
)
)
style._levels = earthkit.plots.styles.levels.Levels(np.linspace(-span, span, 22))
style._legend_kwargs["ticks"] = np.linspace(-span, span, 5)
style._colors = ("PiYG_r" if mean else "coolwarm") if error else style._colors
extend_left = np.nanmin(da) < -span
extend_right = np.nanmax(da) > span
extend = {
(False, False): "neither",
(True, False): "min",
(False, True): "max",
(True, True): "both",
}[(extend_left, extend_right)]
if error:
style._legend_kwargs["extend"] = extend
chart.pcolormesh(da, style=style, zorder=-12)
else:
chart.quickplot(da, style=style, extend=extend, zorder=-11)
chart.ax.set_rasterization_zorder(-10)
if title:
chart.title(title)
if error and mean:
err_v = np.mean(~(np.abs(my_ERA5_U_pick - ERA5_U_pick) <= u_eb_abs))
err_v = (
0
if err_v == 0
else np.format_float_positional(100 * err_v, precision=1, min_digits=1)
+ "%"
)
if err_v == "0.0%":
err_v = "<0.05%"
t = chart.ax.text(
0.95,
0.1,
f"V={err_v}",
ha="right",
va="bottom",
transform=chart.ax.transAxes,
)
t.set_bbox(dict(facecolor="white", alpha=0.75, edgecolor="black"))
if correction is not None:
encoded = corrections_codec.encode(correction)
cr = ERA5_U.nbytes / (np.array(encoded).nbytes + extra_bytes)
if mean:
corr = np.mean(correction != 0)
corr = (
0
if corr == 0
else np.format_float_positional(100 * corr, precision=2, min_digits=2)
+ "%"
)
if corr == "0.00%":
corr = "<0.005%"
t = chart.ax.text(
0.05,
0.1,
f"C={corr}",
ha="left",
va="bottom",
transform=chart.ax.transAxes,
)
t.set_bbox(dict(facecolor="white", alpha=0.75, edgecolor="black"))
if mean:
t = chart.ax.text(
0.95,
0.9,
rf"$\times$ {np.round(cr, 2)}"
if error
else humanize.naturalsize(ERA5_U.nbytes, binary=True),
ha="right",
va="top",
transform=chart.ax.transAxes,
zorder=99,
)
t.set_bbox(dict(facecolor="white", alpha=0.75, edgecolor="black"))
for m in earthkit.plots.schemas.schema.quickmap_subplot_workflow:
if m != "title":
getattr(chart, m)()
for m in earthkit.plots.schemas.schema.quickmap_figure_workflow:
if m != "legend":
getattr(chart, m)()
legend_layer = chart.distinct_legend_layers[-1]
cb = legend_layer.style.legend(legend_layer, location="bottom")
stackcolors = np.array(mpl.color_sequences["Pastel1"])[[2, 3, 4, 0, 1]]
stackcmap = mpl.colors.LinearSegmentedColormap.from_list(
"stackcolors", stackcolors, N=5
)
boldstackcolors = np.array(mpl.color_sequences["Set1"])[[2, 3, 4, 0, 1]]
if not mean:
old_process_projection_requirements = (
chart.ax.get_figure()._process_projection_requirements
)
def _process_projection_requirements(
*, axes_class=None, polar=False, projection=None, **kwargs
):
if axes_class is not None and projection is not None:
return axes_class, dict(projection=projection, **kwargs)
return old_process_projection_requirements(
axes_class=axes_class, polar=polar, projection=projection, **kwargs
)
chart.ax.get_figure()._process_projection_requirements = (
_process_projection_requirements
)
axin = chart.ax.inset_axes(
[0.75, 0.5, 0.225, 0.45],
xlim=(-0.5 * 7.5, 0.5 * 7.5),
ylim=(50 - 0.5 * 7.5, 50 + 0.5 * 7.5),
xticklabels=[],
yticklabels=[],
axes_class=type(chart.ax),
projection=chart.ax.projection,
)
axin.pcolormesh(
da.longitude.values,
da.latitude.values,
np.squeeze(da.values),
cmap=cb.cmap,
norm=cb.norm,
rasterized=True,
)
axin.coastlines(color="#555555")
axin.spines["geo"].set_edgecolor("black")
inset_indicator = chart.ax.indicate_inset_zoom(axin, edgecolor="black", alpha=1)
inset_indicator.connectors[1].set_visible(True)
inset_indicator.connectors[3].set_visible(False)
qoi_stencil_indices = (
[
tuple(
slice(None) if s == 1 else slice(j, None, s)
for j, s in zip(i, qoi_stencil)
)
for i in product(*[range(s) for s in qoi_stencil])
]
if qoi_stencil is not None
else incremental_masks
)
da_indices = np.zeros(da.shape, dtype=int)
for i, s in enumerate(qoi_stencil_indices):
da_indices[s] = i
axin = chart.ax.inset_axes(
[0.025, 0.5, 0.225, 0.45],
xlim=(-0.5 * 7.5, 0.5 * 7.5),
ylim=(50 - 0.5 * 7.5, 50 + 0.5 * 7.5),
xticklabels=[],
yticklabels=[],
axes_class=type(chart.ax),
projection=chart.ax.projection,
)
axin.pcolormesh(
da.longitude.values,
da.latitude.values,
np.squeeze(da_indices),
cmap=stackcmap,
rasterized=True,
)
axin.coastlines(color="#555555")
axin.spines["geo"].set_edgecolor("black")
inset_indicator = chart.ax.indicate_inset_zoom(axin, edgecolor="black", alpha=1)
inset_indicator.connectors[1].set_visible(False)
inset_indicator.connectors[3].set_visible(True)
counts, bins = np.histogram(da.values.flatten(), range=(-span, span), bins=21)
midpoints = bins[:-1] + np.diff(bins) / 2
extend_width = (bins[-1] - bins[-2]) / (bins[-1] - bins[0])
cax = cb.ax.inset_axes(
[
0.0 - extend_width * extend_left,
1.25 if (qoi_stencil is None and incremental_masks is None) else 3.5,
1.0 + extend_width * (0 + extend_left + extend_right),
1.0,
]
)
cax.bar(
midpoints,
height=counts,
width=(bins[-1] - bins[0]) / len(counts),
color=cb.cmap(cb.norm(midpoints)),
)
if extend_left:
cax.bar(
bins[0] - (bins[1] - bins[0]) / 2,
height=np.sum(da < -span),
width=(bins[-1] - bins[0]) / len(counts),
color=cb.cmap(cb.norm(midpoints[0])),
)
if extend_right:
cax.bar(
bins[-1] + (bins[-1] - bins[-2]) / 2,
height=np.sum(da > span),
width=(bins[-1] - bins[0]) / len(counts),
color=cb.cmap(cb.norm(midpoints[-1])),
)
q1, q2, q3 = da.quantile([0.25, 0.5, 0.75]).values
cax.axvline(da.mean().item(), ls=":", ymin=0.1, ymax=0.9, c="w", lw=2)
cax.axvline(q1, ymin=0.25, ymax=0.75, c="w", lw=2)
cax.axvline(q2, ymin=0.1, ymax=0.9, c="w", lw=2)
cax.axvline(q3, ymin=0.25, ymax=0.75, c="w", lw=2)
cax.axvline(da.mean().item(), ymin=0.1, ymax=0.9, ls=":", c="k", lw=1)
cax.axvline(q1, ymin=0.25, ymax=0.75, c="k", lw=1)
cax.axvline(q2, ymin=0.1, ymax=0.9, c="k", lw=1)
cax.axvline(q3, ymin=0.25, ymax=0.75, c="k", lw=1)
cax.set_xlim(
-span - (bins[-1] - bins[-2]) * extend_left,
span + (bins[-1] - bins[-2]) * extend_right,
)
cax.set_xticks([])
cax.set_yticks([])
cax.spines[:].set_visible(False)
if (qoi_stencil is not None) or (incremental_masks is not None):
assert (qoi_stencil is None) or (incremental_masks is None)
cax2 = cb.ax.inset_axes(
[
0.0 - extend_width * extend_left,
1.25,
1.0 + extend_width * (0 + extend_left + extend_right),
2.0,
]
)
left_stacked = []
counts_stacked = []
right_stacked = []
if qoi_stencil is not None:
for i in product(*[range(s) for s in qoi_stencil]):
incrindex = tuple(
slice(None) if s == 1 else slice(j, None, s)
for j, s in zip(i, qoi_stencil)
)
counts2, _bins2 = np.histogram(
da.values[incrindex].flatten(), range=(-span, span), bins=21
)
counts_stacked.append(counts2)
left_stacked.append(np.sum(da.values[incrindex] < -span))
right_stacked.append(np.sum(da.values[incrindex] > span))
else:
for next_incremental_mask in incremental_masks:
counts2, _bins2 = np.histogram(
da.values.flatten()[next_incremental_mask.flatten()],
range=(-span, span),
bins=21,
)
counts_stacked.append(counts2)
left_stacked.append(
np.sum(da.values.flatten()[next_incremental_mask.flatten()] < -span)
)
right_stacked.append(
np.sum(da.values.flatten()[next_incremental_mask.flatten()] > span)
)
counts_stacked = np.stack(counts_stacked, axis=0)
counts_stacked = counts_stacked / np.sum(counts_stacked, axis=0)
for i in range(len(counts_stacked)):
cax2.bar(
midpoints,
counts_stacked[i],
bottom=counts_stacked[:i].sum(0),
width=(bins[-1] - bins[0]) / len(counts),
color=stackcolors[i],
)
if extend_left:
left_stacked = np.stack(left_stacked, axis=0)
left_stacked = left_stacked / np.sum(left_stacked, axis=0)
for i in range(len(left_stacked)):
cax2.bar(
bins[0] - (bins[1] - bins[0]) / 2,
left_stacked[i],
bottom=left_stacked[:i].sum(0),
width=(bins[-1] - bins[0]) / len(counts),
color=stackcolors[i],
)
if extend_right:
right_stacked = np.stack(right_stacked, axis=0)
right_stacked = right_stacked / np.sum(right_stacked, axis=0)
for i in range(len(right_stacked)):
cax2.bar(
bins[-1] + (bins[-1] - bins[-2]) / 2,
right_stacked[i],
bottom=right_stacked[:i].sum(0),
width=(bins[-1] - bins[0]) / len(counts),
color=stackcolors[i],
)
cax2.set_xlim(
-span - (bins[-1] - bins[-2]) * extend_left,
span + (bins[-1] - bins[-2]) * extend_right,
)
cax2.set_xticks([])
cax2.set_yticks([])
cax2.spines[:].set_visible(False)
if error and not mean:
qoi_stencil_indices = (
[
tuple(
slice(None) if s == 1 else slice(j, None, s)
for j, s in zip(i, qoi_stencil)
)
for i in product(*[range(s) for s in qoi_stencil])
]
if qoi_stencil is not None
else incremental_masks
)
if len(qoi_stencil_indices) > 1:
k_cr = []
k_c = []
for i in qoi_stencil_indices:
correction_encoded = corrections_codec.encode(
np.ascontiguousarray(correction[i])
)
if qoi_stencil is not None:
correction_cr = ERA5_U[i].nbytes / (
np.array(correction_encoded).nbytes
+ extra_bytes * ERA5_U[i].size / ERA5_U.size
)
corrections = np.mean(correction[i] != 0)
else:
correction_cr = ERA5_U.data[i].nbytes / (
np.array(correction_encoded).nbytes
+ extra_bytes * ERA5_U.data[i].size / ERA5_U.size
)
corrections = np.mean(correction.flatten()[i.flatten()] != 0)
k_cr.append(correction_cr)
k_c.append(corrections * 100)
axin = chart.ax.inset_axes([0.5 - 0.225 / 2, 0.05, 0.225, 0.45])
axin2 = axin.twinx()
axin.plot(k_c, c="gray", lw=1)
axin2.plot(k_cr, c="gray", lw=1)
for i, a, b, c in zip(range(5), k_c, k_cr, boldstackcolors):
axin.scatter([i], [a], color=c, s=15, marker="x")
axin2.scatter([i], [b], color=c, s=15, marker="x")
axin.set_xlim([-0.25, 4.25])
ylim = axin.get_ylim()
axin.set_ylim(
ylim[0] - (ylim[1] - ylim[0]) * 0.25 / 4,
ylim[1] + (ylim[1] - ylim[0]) * 0.25 / 4,
)
# axin.set_yticks([y for y in axin.get_yticks() if y > (ylim[0]+(ylim[1]-ylim[0])*0.125) and y < ylim[1]-(ylim[1]-ylim[0])*0.125])
# axin.set_yticklabels([f"{y.get_text()}%" for y in axin.get_yticklabels()])
axin.set_yticks([])
ylim2 = axin2.get_ylim()
axin2.set_ylim(
ylim2[0] - (ylim2[1] - ylim2[0]) * 0.25 / 4,
ylim2[1] + (ylim2[1] - ylim2[0]) * 0.25 / 4,
)
# axin2.set_yticks([y for y in axin2.get_yticks() if y > (ylim2[0]+(ylim2[1]-ylim2[0])*0.125) and y < ylim2[1]-(ylim2[1]-ylim2[0])*0.125])
# axin2.set_yticklabels([rf"$\times${y.get_text()}" for y in axin2.get_yticklabels()])
axin2.set_yticks([])
axin.set_ylabel("C", rotation="horizontal", ha="left", va="top")
axin2.set_ylabel("CR", rotation="horizontal", ha="right", va="top")
axin.set_xticks([])
axin.tick_params(axis="x", pad=-1, labelsize=6)
axin.tick_params(axis="y", direction="in", pad=-5)
axin2.tick_params(axis="y", direction="in", pad=-5)
axin.set_xticks([0, 1, 2, 3, 4])
axin.set_xticklabels(["#1", "#2", "#3", "#4", "#5"])
plt.setp(
axin.get_xticklabels(),
bbox=dict(
boxstyle="square,pad=0.1", ec="white", fc="white", alpha=0.5
),
)
plt.setp(axin.get_yticklabels(), ha="left")
plt.setp(axin2.get_yticklabels(), ha="right")
axin.yaxis.set_label_coords(0.15, 0.95)
axin2.yaxis.set_label_coords(0.85, 0.95)
for t, c in zip(axin.xaxis.get_ticklabels(), boldstackcolors):
t.set_color(c)
axin.set_facecolor((1.0, 1.0, 1.0, 0.5))
axin.grid(False)
axin2.grid(False)
if not mean:
chart.layers.pop()
def table_mean_wind_u(
my_ERA5_U: xr.DataArray,
title,
u_mean_eb_abs,
correction,
extra_bytes,
) -> pd.DataFrame:
my_ERA5_U_mean = compute_mean_wind_u(my_ERA5_U)
err_inf_U = np.amax(np.abs(my_ERA5_U - ERA5_U))
err_2_U = np.sqrt(np.mean(np.square(my_ERA5_U - ERA5_U)))
err_inf_U_mean = np.amax(np.abs(my_ERA5_U_mean - ERA5_U_mean))
err_2_U_mean = np.sqrt(np.mean(np.square(my_ERA5_U_mean - ERA5_U_mean)))
err_v = np.mean(~(np.abs(my_ERA5_U_mean - ERA5_U_mean) <= u_mean_eb_abs))
err_v = (
0
if err_v == 0
else np.format_float_positional(100 * err_v, precision=1, min_digits=1) + "%"
)
if err_v == "0.0%":
err_v = "<0.05%"
corr = None if correction is None else np.mean(correction != 0)
corr = (
""
if corr is None
else (
0
if corr == 0
else np.format_float_positional(100 * corr, precision=1, min_digits=1) + "%"
)
)
if corr == "0.0%":
corr = "<0.05%"
if correction is None:
cr = ERA5_U.nbytes / extra_bytes
else:
encoded = corrections_codec.encode(correction)
cr = ERA5_U.nbytes / (np.array(encoded).nbytes + extra_bytes)
return pd.DataFrame(
{
"Compressor": [title[0]],
"Safeguarded": [title[1]],
"Corrections": [title[2]],
r"$L_{\infty}(\hat{u})$": [
f"{err_inf_U:.02}",
],
r"$L_{2}(\hat{u})$": [
f"{err_2_U:.02}",
],
r"$L_{\infty}(\overline{\hat{u}})$": [
f"{err_inf_U_mean:.02}",
],
r"$L_{2}(\overline{\hat{u}})$": [
f"{err_2_U_mean:.02}",
],
"V": [err_v],
"C": [corr],
"CR": [
rf"$\times$ {np.round(cr, 2)}",
],
}
)
Approximating u with lossy compressors¶
from numcodecs_zero import ZeroCodec
zero = ZeroCodec()
ERA5_U_zero_encoded = zero.encode(ERA5_U.values)
ERA5_U_zero_approximation = zero.decode(ERA5_U_zero_encoded)
from numcodecs_wasm_sperr import Sperr
# PSNR is chosen such that the RMSE is approximately 0.1
sperr = Sperr(mode="psnr", psnr=49.875)
ERA5_U_sperr_encoded = sperr.encode(ERA5_U.values)
ERA5_U_sperr_approximation = sperr.decode(ERA5_U_sperr_encoded)
print(
f"RMSE(SPERR) = {np.sqrt(np.mean((ERA5_U_sperr_approximation - ERA5_U.values).flatten() ** 2))}"
)
RMSE(SPERR) = 0.10017839819192886
Computing the one-shot safeguards corrections¶
sg = Safeguards(safeguards=[qoi])
ERA5_U_sg_zero_correction = sg.compute_correction(
ERA5_U.values, ERA5_U_zero_approximation
)
ERA5_U_sg_zero = sg.apply_correction(
ERA5_U_zero_approximation, ERA5_U_sg_zero_correction
)
assert sg.check(ERA5_U.values, ERA5_U_sg_zero)
ERA5_U_sg_sperr_correction = sg.compute_correction(
ERA5_U.values, ERA5_U_sperr_approximation
)
ERA5_U_sg_sperr = sg.apply_correction(
ERA5_U_sperr_approximation, ERA5_U_sg_sperr_correction
)
assert sg.check(ERA5_U.values, ERA5_U_sg_sperr)
Computing the incremental safeguards corrections¶
INCREMENTAL_MASK: Parameter = Parameter("$incrmask")
INCREMENTAL_APPROXIMATION: Parameter = Parameter("$incrapprox")
class IncrementalSafeguard(PointwiseSafeguard):
__slots__: tuple[str, ...] = ()
kind: ClassVar[str] = "incremental"
def __init__(self) -> None:
pass
@property
@override
def late_bound(self) -> Set[Parameter]:
return frozenset([INCREMENTAL_MASK, INCREMENTAL_APPROXIMATION])
@override
def check_pointwise(
self,
data: np.ndarray[S, np.dtype[T]],
approximation: np.ndarray[S, np.dtype[T]],
*,
late_bound: Bindings,
where: Literal[True] | np.ndarray[S, np.dtype[np.bool]] = True,
) -> np.ndarray[S, np.dtype[np.bool]]:
return np.ones_like(data, dtype=np.bool) # type: ignore
@override
def compute_safe_intervals(
self,
data: np.ndarray[S, np.dtype[T]],
*,
late_bound: Bindings,
where: Literal[True] | np.ndarray[S, np.dtype[np.bool]] = True,
) -> IntervalUnion[T, int, int]:
mask = late_bound.resolve_ndarray_with_lossless_cast(
INCREMENTAL_MASK, data.shape, np.dtype(np.bool)
)
approximation = late_bound.resolve_ndarray_with_lossless_cast(
INCREMENTAL_APPROXIMATION, data.shape, data.dtype
)
valid = Interval.full_like(data)
Lower(approximation.flatten()) <= valid[mask.flatten()] <= Upper(
approximation.flatten()
)
validu = valid.into_union()
# override the class of the valid interval union with one that accepts
# non-overlap with the data for elements that have already been
# corrected,
# which is required to create a safeguard that forces the existing
# corrections to be recreated
class IncrementalIntervalUnion(IntervalUnion):
__slots__ = ()
def contains(
self, other: np.ndarray[S, np.dtype[T]]
) -> np.ndarray[S, np.dtype[np.bool]]:
is_contained: np.ndarray[S, np.dtype[np.bool]] = super().contains(other)
if np.array_equal(other, data, equal_nan=True):
is_contained |= mask
return is_contained
validu.__class__ = IncrementalIntervalUnion
return validu
@override
def compute_footprint(
self,
foot: np.ndarray[S, np.dtype[np.bool]],
*,
late_bound: Bindings,
where: Literal[True] | np.ndarray[S, np.dtype[np.bool]] = True,
) -> np.ndarray[S, np.dtype[np.bool]]:
# the incremental safeguard does not affect the footprint
return np.zeros_like(foot)
@override
def compute_inverse_footprint(
self,
foot: np.ndarray[S, np.dtype[np.bool]],
*,
late_bound: Bindings,
where: Literal[True] | np.ndarray[S, np.dtype[np.bool]] = True,
) -> np.ndarray[S, np.dtype[np.bool]]:
# the incremental safeguard does not affect the footprint
return np.zeros_like(foot)
@override
def get_config(self) -> dict[str, JSON]:
return dict(kind=type(self).kind)
def rewrite_stencil_qoi_eb_safeguard_into_incremental(
qoi: StencilQuantityOfInterestErrorBoundSafeguard,
) -> Collection[Safeguard]:
# extract the fully-resolved QoI expression, in text representation
qoi_expr_repr = repr(qoi._qoi_expr)
# replace mentions of the data stencil X with where(mask, approximation, X)
# to fill in the already-corrected approximation as a constant for elements
# where the correction has already been produced
qoi_expr_incremental_repr = re.sub(
r"X\[([^]]+)\]",
rf'where(C["{INCREMENTAL_MASK}"][\1], C["{INCREMENTAL_APPROXIMATION}"][\1], X[\1])',
qoi_expr_repr,
)
# create a new QoI expression with the incremental QoI
qoi_incremental_expr = StencilQuantityOfInterestErrorBoundSafeguard(
qoi=qoi_expr_incremental_repr,
**{k: v for k, v in qoi.get_config().items() if k not in ("kind", "qoi")},
)._qoi_expr
# override (a copy of) the QoI's class with one that uses the incremental
# QoI, but only for deriving corrections, and thus requires the additional
# late-bound constants for incremental corrections
class IncrementalStencilQuantityOfInterest(StencilQuantityOfInterest):
__slots__ = ()
@property
def late_bound_constants(self) -> Set[Parameter]:
return qoi_incremental_expr.late_bound_constants
@np.errstate(divide="ignore", over="ignore", under="ignore", invalid="ignore")
def compute_data_bounds(
self,
qoi_lower: np.ndarray[Ps, np.dtype[F]],
qoi_upper: np.ndarray[Ps, np.dtype[F]],
Xs: np.ndarray[Ns, np.dtype[F]],
late_bound: Mapping[Parameter, np.ndarray[Ns, np.dtype[F]]],
) -> tuple[np.ndarray[Ns, np.dtype[F]], np.ndarray[Ns, np.dtype[F]]]:
return qoi_incremental_expr.compute_data_bounds(
qoi_lower, qoi_upper, Xs, late_bound
)
qoi_incremental = StencilQuantityOfInterestErrorBoundSafeguard(
**{k: v for k, v in qoi.get_config().items() if k != "kind"}
)
qoi_incremental._qoi_expr.__class__ = IncrementalStencilQuantityOfInterest
# combine the incremental safeguard, which takes care of recreating already
# computed corrections, with the now-incremental QoI, which takes these
# already computed corrections into account to produce tighter corrections
return (IncrementalSafeguard(), qoi_incremental)
sg_incremental = Safeguards(
safeguards=[*rewrite_stencil_qoi_eb_safeguard_into_incremental(qoi)]
)
# compute the total stencil size over which we can incrementally apply the
# safeguards
qoi_stencil = []
for axis in qoi.compute_check_neighbourhood_for_data_shape(ERA5_U.shape):
max_stencil = 1
for b, s in axis.items():
max_stencil = max(max_stencil, s.before + 1 + s.after)
qoi_stencil.append(max_stencil)
qoi_stencil
[1, 5, 1]
# create stepby masks using array slices with steps
# instead of applying the safeguards incrementally one element at a time,
# apply them to the i'th element of non-overlapping windows of the same size as
# the stencil
# this gives (asymptotically) the same extra knowledge when the stencil is
# symmetric, but one element per axis less knowledge when it is asymmetric
stepby_incremental_masks = [
tuple(slice(None) if s == 1 else slice(j, None, s) for j, s in zip(i, qoi_stencil))
for i in product(*[range(s) for s in qoi_stencil])
]
rng = np.random.Generator(np.random.PCG64(seed=42))
# create pseudo-randomly shuffled masks that determine how the safeguards are
# applied incrementally
# the number of masks is arbitrary (any number works), though setting it to the
# size of the stencil makes sense as a default
shuffled_incremental_masks = [
np.zeros(ERA5_U.size, dtype=np.bool) for _ in range(np.prod(qoi_stencil))
]
indices = rng.permutation(ERA5_U.size)
for i in range(len(shuffled_incremental_masks)):
shuffled_incremental_masks[i][indices[i :: len(shuffled_incremental_masks)]] = True
shuffled_incremental_masks[i] = shuffled_incremental_masks[i].reshape(ERA5_U.shape)
def compute_incremental_correction(
data: np.ndarray[S, np.dtype[T]],
approximation: np.ndarray[S, np.dtype[T]],
incremental_masks: list[tuple[slice] | tuple[np.ndarray[S, np.dtype[np.bool]]]],
) -> np.ndarray[S, np.dtype[C]]:
incremental_mask = np.zeros(data.shape, dtype=np.bool)
incremental_correction = np.empty(
data.shape, sg.correction_dtype_for_data(data.dtype)
)
incremental_approximation = np.empty_like(data)
# instead of applying the safeguards incrementally one element at a time,
# apply them to the several non-overlapping masks that partition the data
for next_incremental_mask in incremental_masks:
# compute and apply the correction
incremental_correction_partial = sg_incremental.compute_correction(
data,
approximation,
late_bound={
# all boundary conditions work for the mask
# - valid, edge, reflect, symmetric, and wrap work as expected
# - for a constant zero boundary, the mask is False for
# boundary points even though the constant value there is
# known - while unfortunate this is more conservative and
# thus still produces the correct results
# - for a constant non-zero boundary, the mask is True for
# boundary points and, since the constant boundary points
# are always known, this is correct
INCREMENTAL_MASK: incremental_mask,
# boundary conditions are applied as expected to the already-
# corrected approximation
INCREMENTAL_APPROXIMATION: incremental_approximation,
},
)
incremental_corrected_partial = sg_incremental.apply_correction(
approximation, incremental_correction_partial
)
# save the correction and corrected elements for the part of the stencil
# that we are (re-)computing, then update the mask to reflect that these
# elements have now already been corrected
incremental_correction[next_incremental_mask] = incremental_correction_partial[
next_incremental_mask
]
incremental_approximation[next_incremental_mask] = (
incremental_corrected_partial[next_incremental_mask]
)
incremental_mask[next_incremental_mask] = True
return incremental_correction
ERA5_U_sg_incremental_correction = dict()
ERA5_U_sg_incremental = dict()
for name, ERA5_U_approximation in dict(
zero=ERA5_U_zero_approximation, sperr=ERA5_U_sperr_approximation
).items():
# apply the combined correction using the non-incremental safeguard
ERA5_U_sg_incremental_correction[f"{name}-stepby"] = compute_incremental_correction(
ERA5_U.values,
ERA5_U_approximation,
stepby_incremental_masks,
)
ERA5_U_sg_incremental[f"{name}-stepby"] = sg.apply_correction(
ERA5_U_approximation, ERA5_U_sg_incremental_correction[f"{name}-stepby"]
)
assert sg.check(ERA5_U.values, ERA5_U_sg_incremental[f"{name}-stepby"])
# apply the combined correction using the non-incremental safeguard
ERA5_U_sg_incremental_correction[f"{name}-shuffled"] = (
compute_incremental_correction(
ERA5_U.values, ERA5_U_approximation, shuffled_incremental_masks
)
)
ERA5_U_sg_incremental[f"{name}-shuffled"] = sg.apply_correction(
ERA5_U_approximation, ERA5_U_sg_incremental_correction[f"{name}-shuffled"]
)
assert sg.check(ERA5_U.values, ERA5_U_sg_incremental[f"{name}-shuffled"])
Visual comparison of the error distributions for the one-shot and incremental safeguards¶
We can see from the figures below that the incremental safeguards produce increasingly less-conservative corrections with each round. While less-conservative corrections have lower entropy and may thus be easier to compress, the largest benefit of incremental safeguards corrections comes from requiring fewer (non-zero) corrections (for decent approximations) with every round. Even though the stepby incremental corrections produce a clear stepby pattern in the error distribution, they produce fewer and thus better-compressible corrections than the shuffled masks.
Note: the per-round compression ratios are only directly comparable with the final compression ratio for the stepby method which maintains the same data structure (in contrast to the shuffled method that has random spacings in between the points in the per-round subset that can introduce additional entropy inside each round but not to the final combined correction).
fig = earthkit.plots.Figure(
size=(10, 15),
rows=3,
columns=2,
hspace=0.1,
)
m = fig.add_map(0, 0)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_zero),
m,
r"Safeguarded[one-shot](0, $\epsilon_{{QoI,abs}}$)",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
qoi_stencil=[1, 1, 1],
correction=ERA5_U_sg_zero_correction,
extra_bytes=np.array(ERA5_U_zero_encoded).nbytes,
)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_zero),
m,
"",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
mean=False,
qoi_stencil=[1, 1, 1],
correction=ERA5_U_sg_zero_correction,
extra_bytes=np.array(ERA5_U_zero_encoded).nbytes,
)
m = fig.add_map(1, 0)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["zero-shuffled"]),
m,
r"Safeguarded[shuffled incremental](0, $\epsilon_{{QoI,abs}}$)",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
incremental_masks=shuffled_incremental_masks,
correction=ERA5_U_sg_incremental_correction["zero-shuffled"],
extra_bytes=np.array(ERA5_U_zero_encoded).nbytes,
)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["zero-shuffled"]),
m,
"",
span=0.3,
u_eb_abs=u_mean_eb_abs,
error=True,
mean=False,
incremental_masks=shuffled_incremental_masks,
correction=ERA5_U_sg_incremental_correction["zero-shuffled"],
extra_bytes=np.array(ERA5_U_zero_encoded).nbytes,
)
m = fig.add_map(2, 0)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["zero-stepby"]),
m,
r"Safeguarded[stepby incremental](0, $\epsilon_{{QoI,abs}}$)",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
qoi_stencil=qoi_stencil,
correction=ERA5_U_sg_incremental_correction["zero-stepby"],
extra_bytes=np.array(ERA5_U_zero_encoded).nbytes,
)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["zero-stepby"]),
m,
"",
span=0.3,
u_eb_abs=u_mean_eb_abs,
error=True,
mean=False,
qoi_stencil=qoi_stencil,
correction=ERA5_U_sg_incremental_correction["zero-stepby"],
extra_bytes=np.array(ERA5_U_zero_encoded).nbytes,
)
m = fig.add_map(0, 1)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_sperr),
m,
r"Safeguarded[one-shot](SPERR, $\epsilon_{{QoI,abs}}$)",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
qoi_stencil=[1, 1, 1],
correction=ERA5_U_sg_sperr_correction,
extra_bytes=ERA5_U_sperr_encoded.nbytes,
)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_sperr),
m,
"",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
mean=False,
qoi_stencil=[1, 1, 1],
correction=ERA5_U_sg_sperr_correction,
extra_bytes=ERA5_U_sperr_encoded.nbytes,
)
m = fig.add_map(1, 1)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["sperr-shuffled"]),
m,
r"Safeguarded[shuffled incremental](SPERR, $\epsilon_{{QoI,abs}}$)",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
incremental_masks=shuffled_incremental_masks,
correction=ERA5_U_sg_incremental_correction["sperr-shuffled"],
extra_bytes=ERA5_U_sperr_encoded.nbytes,
)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["sperr-shuffled"]),
m,
"",
span=0.3,
u_eb_abs=u_mean_eb_abs,
error=True,
mean=False,
incremental_masks=shuffled_incremental_masks,
correction=ERA5_U_sg_incremental_correction["sperr-shuffled"],
extra_bytes=ERA5_U_sperr_encoded.nbytes,
)
m = fig.add_map(2, 1)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["sperr-stepby"]),
m,
r"Safeguarded[stepby incremental](SPERR, $\epsilon_{{QoI,abs}}$)",
span=0.1,
u_eb_abs=u_mean_eb_abs,
error=True,
qoi_stencil=qoi_stencil,
correction=ERA5_U_sg_incremental_correction["sperr-stepby"],
extra_bytes=ERA5_U_sperr_encoded.nbytes,
)
plot_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["sperr-stepby"]),
m,
"",
span=0.3,
u_eb_abs=u_mean_eb_abs,
error=True,
mean=False,
qoi_stencil=qoi_stencil,
correction=ERA5_U_sg_incremental_correction["sperr-stepby"],
extra_bytes=ERA5_U_sperr_encoded.nbytes,
)
# manual fig.save to override the dpi
fig._release_queue()
plt.savefig(Path("plots") / "incremental.pdf", bbox_inches="tight", dpi=400)
inc_table = pd.concat(
[
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_zero_approximation),
["0", "-", ""],
u_mean_eb_abs,
None,
np.array(ERA5_U_zero_encoded).nbytes,
),
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_zero),
["0", r"$\epsilon_{QoI,abs}$", "one-shot"],
u_mean_eb_abs,
ERA5_U_sg_zero_correction,
np.array(ERA5_U_zero_encoded).nbytes,
),
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["zero-shuffled"]),
["0", r"$\epsilon_{QoI,abs}$", "shuffled incremental"],
u_mean_eb_abs,
ERA5_U_sg_incremental_correction["zero-shuffled"],
np.array(ERA5_U_zero_encoded).nbytes,
),
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["zero-stepby"]),
["0", r"$\epsilon_{QoI,abs}$", "stepby incremental"],
u_mean_eb_abs,
ERA5_U_sg_incremental_correction["zero-stepby"],
np.array(ERA5_U_zero_encoded).nbytes,
),
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sperr_approximation),
[r"SPERR($\epsilon_{abs}$)", "-", ""],
u_mean_eb_abs,
None,
ERA5_U_sperr_encoded.nbytes,
),
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_sperr),
[r"SPERR($\epsilon_{abs}$)", r"$\epsilon_{QoI,abs}$", "one-shot"],
u_mean_eb_abs,
ERA5_U_sg_sperr_correction,
ERA5_U_sperr_encoded.nbytes,
),
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["sperr-shuffled"]),
[
r"SPERR($\epsilon_{abs}$)",
r"$\epsilon_{QoI,abs}$",
"shuffled incremental",
],
u_mean_eb_abs,
ERA5_U_sg_incremental_correction["sperr-shuffled"],
ERA5_U_sperr_encoded.nbytes,
),
table_mean_wind_u(
ERA5_U.copy(data=ERA5_U_sg_incremental["sperr-stepby"]),
[r"SPERR($\epsilon_{abs}$)", r"$\epsilon_{QoI,abs}$", "stepby incremental"],
u_mean_eb_abs,
ERA5_U_sg_incremental_correction["sperr-stepby"],
ERA5_U_sperr_encoded.nbytes,
),
]
).set_index(["Compressor", "Safeguarded", "Corrections"])
Path("tables").joinpath("incremental.tex").write_text(
inc_table.to_latex(escape=False)
.replace("%", r"\%")
.replace("\\cline{1-10} \\cline{2-10}\n\\bottomrule", "\\bottomrule")
)
inc_table
| $L_{\infty}(\hat{u})$ | $L_{2}(\hat{u})$ | $L_{\infty}(\overline{\hat{u}})$ | $L_{2}(\overline{\hat{u}})$ | V | C | CR | |||
|---|---|---|---|---|---|---|---|---|---|
| Compressor | Safeguarded | Corrections | |||||||
| 0 | - | 5.9e+01 | 1.5e+01 | 5.8e+01 | 1.5e+01 | 99.4% | $\times$ 415296.0 | ||
| $\epsilon_{QoI,abs}$ | one-shot | 0.1 | 0.052 | 0.095 | 0.024 | 0 | 99.1% | $\times$ 9.58 | |
| shuffled incremental | 0.71 | 0.087 | 0.1 | 0.034 | 0 | 98.8% | $\times$ 9.44 | ||
| stepby incremental | 0.76 | 0.12 | 0.1 | 0.045 | 0 | 98.6% | $\times$ 11.28 | ||
| SPERR($\epsilon_{abs}$) | - | 0.96 | 0.1 | 0.53 | 0.059 | 8.9% | $\times$ 58.86 | ||
| $\epsilon_{QoI,abs}$ | one-shot | 0.1 | 0.054 | 0.096 | 0.031 | 0 | 27.7% | $\times$ 14.01 | |
| shuffled incremental | 0.47 | 0.063 | 0.1 | 0.035 | 0 | 20.2% | $\times$ 15.96 | ||
| stepby incremental | 0.65 | 0.071 | 0.1 | 0.039 | 0 | 15.2% | $\times$ 19.19 |