Preserving the structural similarity index for floating-point data (dSSIM) with safeguards¶
In this example, we compare the structural similarity index for floating-point data (dSSIM) [^1] after compressing a dataset of u wind with three different lossy compressors (ZFP, SZ3, SPERR). We then stress-test the quantity of interest (QoI) implementation of the safeguards by preserving an error bound on the dSSIM by translating the dSSIM computation into a quantity of interest, which involves rescaling and linearly quantizing values, applying a 2D Gaussian smoothing kernel, and computing variances and co-variances. We also compare the safeguards with the compressor configuration auto-tuner OptZConfig.
QPET supports mean error bounds over non-overlapping blocks of data, but not over overlapping windows of data, which are required to preserve an error over a convolution. To be safe, QPET would need to be configured with a maximally conservative pointwise error bound, which is no different than configuring SPERR (or SZ3 or ZFP) with this error bound. Furthermore, QPET does not support a rounding/quantisation/binning function. Therefore, we do not compare with QPET in this example.
[^1]: Baker, A. H., Pinard, A. and Hammerling, D. M. (2024). On a Structural Similarity Index Approach for Floating-Point Data. IEEE Transactions on Visualization and Computer Graphics. 30(9), 6261-6274. Available from: doi:10.1109/TVCG.2023.3332843.
import ssl
ssl._create_default_https_context = ssl._create_stdlib_context
from collections import defaultdict
from pathlib import Path
import earthkit.plots
import humanize
import matplotlib as mpl
import numpy as np
import pandas as pd
import xarray as xr
from matplotlib import patheffects as PathEffects
# 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", pressure_level=500)
.astype(np.float64)
)
ERA5_U.shape
(721, 1440)
def dssim_mat(
a1: np.ndarray,
a2: np.ndarray,
eps: float = 1e-8,
kernel_size: int = 3,
reproducible: bool = True,
) -> np.ndarray:
"""
Implementation adapted from the official dSSIM implementation at
https://github.com/NCAR/ldcpy/blob/6c5bcb8149ec7876a4f53b0e784e9c528f6f14cb/ldcpy/calcs.py#L2516
The official implementation makes assumptions about the input data that are
specific to models developed at NCAR which is why we cannot use the official
implementation directly.
The implementation has been further adapted to ensure bit-reproducible
evaluation with the below dSSIM quantity of interest, with the following
notable changes:
- we take extra care to ensure all operations are computed in the data
dtype, i.e. without accidentally upcasting to np.float64
- the original uses np.round, which rounds halfway cases away from zero,
we use np.rint which rounds halfway cases to the nearest even integer
- the original uses astropy.convolution.convolve, which internally upcasts
to float64 - to avoid rounding differences, we manually implement it here
- astropy.convolution.convolve handles NaN values by replacing them during
convolution with a Gaussian kernel by interpolating with the same
Gaussian kernel, to avoid NaNs bleeding over, and by returning NaN for
elements that were NaN in the input. We implement a simpler version by
excluding NaN values from the convolution and returning NaN for elements
that were NaN in the input
- convolution requires summation but np.sum internally uses partial sums
for better rounding, which the safeguards currently do not use, so we
manually sum
- we reduce the 11x11 Gaussian kernel to 3x3 for the sake of compute time
Parameters
----------
x : np.ndarray
Shape: (latitude, longitude)
y : np.ndarray
Shape: (latitude, longitude)
kernel_size : int
The size of the Gaussian kernel for the convolution operation in SSIM. Has to be
an odd number. The default is 3.
Returns
-------
dssim : np.ndarray
The pointwise data-SSIM values where the k x k kernel is valid.
"""
assert kernel_size % 2 == 1, "kernel_size must be an odd number."
assert a1.shape == a2.shape
assert a1.dtype == a2.dtype
# re-scale to [0,1] - if not constant
smin = min(np.nanmin(a1), np.nanmin(a2))
smax = max(np.nanmax(a1), np.nanmax(a2))
r = smax - smin
if r == 0: # scale by smax if field is a constant (and smax != 0)
if smax == 0:
sc_a1 = a1
sc_a2 = a2
else:
sc_a1 = a1 / smax
sc_a2 = a2 / smax
else:
sc_a1 = (a1 - smin) / r
sc_a2 = (a2 - smin) / r
# now quantize to 256 bins
sc_a1 = np.rint(sc_a1 * 255) / 255
sc_a2 = np.rint(sc_a2 * 255) / 255
# 2D gaussian filter
sigma = a1.dtype.type(1.5)
pi = a1.dtype.type(np.pi)
i = np.arange(kernel_size).astype(a1.dtype) - (kernel_size // 2)
k = 1 / (np.sqrt(2 * pi) * sigma) * np.exp((-np.square(i)) / (2 * np.square(sigma)))
kernel = np.array([k]).T @ np.array([k])
def convolve(a, k):
# pad with zeros
ap = np.pad(a, kernel_size // 2, mode="constant", constant_values=0)
# reshape a to (*a.shape, *k.shape)
av = np.lib.stride_tricks.as_strided(
ap, a.shape + k.shape, ap.strides + ap.strides
)
# broadcast k to (*a.shape, *k.shape)
kv = np.copy(
np.broadcast_to(k.reshape((1, 1, kernel_size, kernel_size)), av.shape)
)
# exclude weights in kv where av is NaN
kv[np.isnan(av)] = 0
# renormalize kv
kv_flat = kv.reshape(a.shape + (-1,))
if reproducible:
# manual np.sum over the last dimension
kv_acc = np.copy(kv_flat[..., 0])
for i in range(1, kv_flat.shape[-1]):
kv_acc += kv_flat[..., i]
else:
kv_acc = np.sum(kv_flat, axis=-1)
kv /= kv_acc.reshape(a.shape + (1, 1))
# pointwise multiply av with kv, then reshape to (*a.shape, -1)
ag = (av * kv).reshape(a.shape + (-1,))
if reproducible:
# manual np.sum over the last dimension
acc = np.copy(ag[..., 0])
for i in range(1, ag.shape[-1]):
acc += ag[..., i]
else:
acc = np.sum(ag, axis=-1)
# keep NaN for elements that are NaN in a
return np.where(np.isnan(a), np.nan, acc)
a1_mu = convolve(sc_a1, kernel)
a2_mu = convolve(sc_a2, kernel)
a1a1 = convolve(np.square(sc_a1), kernel)
a2a2 = convolve(np.square(sc_a2), kernel)
a1a2 = convolve(sc_a1 * sc_a2, kernel)
###########
var_a1 = a1a1 - np.square(a1_mu)
var_a2 = a2a2 - np.square(a2_mu)
cov_a1a2 = a1a2 - a1_mu * a2_mu
# ssim constants
C1 = a1.dtype.type(eps)
C2 = a1.dtype.type(eps)
ssim_t1 = 2 * a1_mu * a2_mu + C1
ssim_t2 = 2 * cov_a1a2 + C2
ssim_b1 = np.square(a1_mu) + np.square(a2_mu) + C1
ssim_b2 = var_a1 + var_a2 + C2
ssim_1 = ssim_t1 / ssim_b1
ssim_2 = ssim_t2 / ssim_b2
ssim_mat = ssim_1 * ssim_2
# Cropping the border region of the 2D field where the convolution kernel is not
# fully overlapping with the 2D input field.
k = (kernel_size - 1) // 2
return ssim_mat[k : ssim_mat.shape[0] - k, k : ssim_mat.shape[1] - k]
def dssim(
a1: np.ndarray,
a2: np.ndarray,
eps: float = 1e-8,
kernel_size: int = 3,
reproducible: bool = True,
) -> float:
"""
Implementation adapted from the official dSSIM implementation at
https://github.com/NCAR/ldcpy/blob/6c5bcb8149ec7876a4f53b0e784e9c528f6f14cb/ldcpy/calcs.py#L2516
The official implementation makes assumptions about the input data that are
specific to models developed at NCAR which is why we cannot use the official
implementation directly.
Parameters
----------
x : np.ndarray
Shape: (latitude, longitude)
y : np.ndarray
Shape: (latitude, longitude)
kernel_size : int
The size of the Gaussian kernel for the convolution operation in SSIM. Has to be
an odd number. The default is 3.
Returns
-------
dssim : float
The data-SSIM value between the two input arrays.
"""
return np.nanmean(
dssim_mat(a1, a2, eps=eps, kernel_size=kernel_size, reproducible=reproducible)
)
dssim(ERA5_U.values, ERA5_U.values, reproducible=False)
np.float64(1.0)
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 compute_corrections_percentage(
my_ERA5_U: xr.DataArray, orig_ERA5_U: xr.DataArray
) -> float:
return np.mean(my_ERA5_U != orig_ERA5_U)
def plot_u_wind_dssim(
my_ERA5_U: xr.DataArray,
cr,
chart,
title,
span,
dssim_bound,
error=False,
corr=None,
my_ERA5_U_it=None,
cr_it=None,
inset=True,
corr_colours=["white", "green", "lawngreen"],
):
import copy
if error:
with xr.set_options(keep_attrs=True):
da = ERA5_U[1:-1, 1:-1].copy(
data=dssim_mat(
ERA5_U.values,
my_ERA5_U.values,
reproducible=False,
)
)
da.attrs.update(long_name=f"dSSIM({da.long_name.lower()})", units=None)
else:
da = my_ERA5_U
# 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, 22))
style._legend_kwargs["ticks"] = np.linspace(*span, 5)
if error:
style._colors = "cool_r"
extend_left = np.nanmin(da) < span[0]
extend_right = np.nanmax(da) > span[1]
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=-11)
if corr is not None:
da_hatch = my_ERA5_U == corr
if my_ERA5_U_it is None:
da_corr = da_hatch.astype(float)
else:
with xr.set_options(keep_attrs=True):
da_hatch_it = my_ERA5_U_it == corr
da_corr = (~da_hatch).astype(float) + (~da_hatch_it).astype(float)
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.025, 0.05, 1 / 3, 1 / 3],
xticklabels=[],
yticklabels=[],
axes_class=type(chart.ax),
projection=chart.ax.projection,
)
axin.pcolormesh(
da_corr.longitude.values,
da_corr.latitude.values,
np.squeeze(da_corr.values),
cmap=mpl.colors.ListedColormap(corr_colours),
vmin=0,
vmax=2,
rasterized=True,
)
axin.coastlines(color="#555555")
axin.spines["geo"].set_edgecolor("black")
axin.set_title(
"Corrections",
path_effects=[PathEffects.withStroke(linewidth=3, foreground="white")],
)
elif inset:
da_err = ~(da >= dssim_bound)
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.025, 0.05, 1 / 3, 1 / 3],
xticklabels=[],
yticklabels=[],
axes_class=type(chart.ax),
projection=chart.ax.projection,
)
axin.pcolormesh(
da_err.longitude.values,
da_err.latitude.values,
np.squeeze(da_err.values),
cmap=mpl.colors.ListedColormap(["white", "darkorange"]),
vmin=0,
vmax=1,
rasterized=True,
)
axin.coastlines(color="#555555")
axin.spines["geo"].set_edgecolor("black")
axin.set_title(
f"dSSIM < {dssim_bound}",
path_effects=[PathEffects.withStroke(linewidth=3, foreground="white")],
)
else:
chart.quickplot(da, style=style, extend=extend, zorder=-11)
chart.ax.set_rasterization_zorder(-10)
chart.title(title)
if error:
dssim_mean = np.nanmean(da)
err_s = ~(dssim_mean >= dssim_bound)
err_s = r"$\times$" if err_s else r"$\checkmark$"
t = chart.ax.text(
0.95,
0.1,
f"E[dSSIM]={dssim_mean:.05} ({err_s})",
ha="right",
va="bottom",
transform=chart.ax.transAxes,
)
t.set_bbox(dict(facecolor="white", alpha=0.75, edgecolor="black"))
t = chart.ax.text(
0.95,
0.9,
(
rf"$\times$ {np.round(cr, 2)}"
+ ("" if cr_it is None else rf" ($\times$ {np.round(cr_it, 2)})")
)
if error
else humanize.naturalsize(ERA5_U.nbytes, binary=True),
ha="right",
va="top",
transform=chart.ax.transAxes,
)
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:
getattr(chart, m)()
counts, bins = np.histogram(da.values.flatten(), range=span, bins=21)
midpoints = bins[:-1] + np.diff(bins) / 2
cb = chart.ax.collections[0].colorbar
if error:
if extend_left:
cb._extend_patches[0].set_hatch("xx")
cb._extend_patches[0].set_ec("white")
cb.ax.fill_between(
[span[0], dssim_bound],
*cb.ax.get_ylim(),
hatch="xx",
ec="w",
fc="none",
lw=0,
)
extend_width = (bins[-1] - bins[-2]) / (bins[-1] - bins[0])
cax = cb.ax.inset_axes(
[
0.0 - extend_width * extend_left,
1.25,
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)),
**(
dict(
hatch=["xx" if np.abs(m) < dssim_bound else "" for m in midpoints],
ec="white",
lw=0,
)
if error
else dict()
),
)
if extend_left:
cax.bar(
bins[0] - (bins[1] - bins[0]) / 2,
height=np.sum(da < span[0]),
width=(bins[-1] - bins[0]) / len(counts),
color=cb.cmap(cb.norm(midpoints[0])),
**(
dict(
hatch="xx",
ec="white",
lw=0,
)
if error and span[0] < dssim_bound
else dict()
),
)
if extend_right:
cax.bar(
bins[-1] + (bins[-1] - bins[-2]) / 2,
height=np.sum(da > span[1]),
width=(bins[-1] - bins[0]) / len(counts),
color=cb.cmap(cb.norm(midpoints[-1])),
**(
dict(
hatch="xx",
ec="white",
lw=0,
)
if error and span[1] < dssim_bound
else dict()
),
)
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[0] - (bins[-1] - bins[-2]) * extend_left,
span[1] + (bins[-1] - bins[-2]) * extend_right,
)
cax.set_xticks([])
cax.set_yticks([])
cax.spines[:].set_visible(False)
def table_u_wind_dssim(
my_ERA5_U: xr.DataArray,
cr,
title,
dssim_bound,
corr,
) -> pd.DataFrame:
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)))
with xr.set_options(keep_attrs=True):
da = ERA5_U[1:-1, 1:-1].copy(
data=dssim_mat(
ERA5_U.values,
my_ERA5_U.values,
reproducible=False,
)
)
dssim_mean = np.nanmean(da)
dssim_min = np.nanmin(da)
dssim_max = np.nanmax(da)
dssim_pw_v = np.mean(~(da >= dssim_bound))
dssim_pw_v = (
0
if dssim_pw_v == 0
else np.format_float_positional(100 * dssim_pw_v, precision=1, min_digits=1)
+ "%"
)
if dssim_pw_v == "0.0%":
dssim_pw_v = "<0.05%"
err_s = ~(np.mean(da) >= dssim_bound)
err_s = r"$\times$" if err_s else r"$\checkmark$"
corr = None if corr is None else compute_corrections_percentage(my_ERA5_U, corr)
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%"
return pd.DataFrame(
{
"Compressor": [title[0]],
r"$\epsilon_{abs}$": [title[1]],
"Safeguarded": [title[2]],
"Corrections": [title[3]],
r"$L_{\infty}(\hat{u})$": [
f"{err_inf_U:.03}",
],
r"$L_{2}(\hat{u})$": [
f"{err_2_U:.03}",
],
r"$E[\text{D}]$": [
f"{dssim_mean:.05}",
],
r"$\min(\text{D})$": [
f"{dssim_min:.03}",
],
r"$\max(\text{D})$": [
f"{dssim_max:.03}",
],
rf"$\text{{D}} < {dssim_bound}$": [dssim_pw_v],
"S": [err_s],
"C": [corr],
"CR": [
rf"$\times$ {np.round(cr, 2)}",
],
}
)
import observe
observations = []
Lossless compression¶
We first compress the data losslessly with ZStandard at level 22, which gives maximum compression, to provide a baseline.
from numcodecs_wasm_zstd import Zstd
zstd = Zstd(level=22)
with observe.observe(zstd, observations):
ERA5_U_zstd_enc = zstd.encode(ERA5_U.values)
ERA5_U_zstd = ERA5_U.copy(data=zstd.decode(ERA5_U_zstd_enc))
ERA5_U_zstd_cr = ERA5_U.nbytes / np.asarray(ERA5_U_zstd_enc).nbytes
Compressing u with lossy compressors¶
We compare each compressor with two absolute error bounds, 1 m/s and 0.1 m/s, to compare the dSSIM scores they achieve.
from numcodecs_wasm_sperr import Sperr
from numcodecs_wasm_sz3 import Sz3
from numcodecs_wasm_zfp import Zfp
from numcodecs_zero import ZeroCodec
eb_abs = [1.0, 0.1]
ERA5_U_codec = defaultdict(dict)
ERA5_U_codec_cr = defaultdict(dict)
for ea in eb_abs:
for codec in [
Zfp(mode="fixed-accuracy", tolerance=ea),
Sz3(eb_mode="abs", eb_abs=ea),
Sperr(mode="pwe", pwe=ea),
ZeroCodec(),
]:
with observe.observe(codec, observations):
ERA5_U_codec_enc = codec.encode(ERA5_U.values)
ERA5_U_codec[codec.codec_id][ea] = ERA5_U.copy(
data=codec.decode(ERA5_U_codec_enc)
)
ERA5_U_codec_cr[codec.codec_id][ea] = (
ERA5_U.nbytes / np.asarray(ERA5_U_codec_enc).nbytes
)
Compressing u using the safeguarded lossy compressors¶
We configure the safeguards to bound the dSSIM to be $\geq 0.995$ using a spatial quantity of interest (QoI). In the translation of the dSSIM calculation to a QoI, we make use of the following tricks:
- we preserve the global minimum and maximum exactly, using sign safeguards with offsets, to ensure that the linear quantisation range is constant
- we manually create a 2D Gaussian kernel in the QoI expression
- we use valid boundary conditions to only compute the pointwise dSSIM for points that have a valid 3x3 neighbourhood
- we compute the dSSIM of the (compressed) data with respect to the constant original data. When the QoI is evaluated on the original data, this computes $dSSIM(u, u) = 1$
- we bound an absolute error of $\epsilon_{abs} = 1 - 0.995$ on the QoI to ensure that $dSSIM(u, \hat{u}) \geq 0.995$
- we conservatively bound the pointwise dSSIM, not the mean of the dSSIM
from compression_safeguards import SafeguardKind
# Baker et al. recommend one of [0.99, 0.995, 0.99919]
dssim_bound = 0.995
qoi_eb_stencil = SafeguardKind.qoi_eb_stencil.value(
qoi="""
# dssim constants
V["sigma"] = 1.5;
V["C1"] = 1e-8;
V["C2"] = 1e-8;
# we guarantee that
# min(data) = min(corrected) and
# max(data) = max(corrected)
# with the sign safeguards above
V["smin"] = c["$x_min"];
V["smax"] = c["$x_max"];
V["r"] = V["smax"] - V["smin"];
# re-scale to [0-1] and quantize to 256 bins
V["sc_a1"] = round_ties_even(((C["$X"] - V["smin"]) / V["r"]) * 255) / 255;
V["sc_a2"] = round_ties_even(((X - V["smin"]) / V["r"]) * 255) / 255;
# create a 2D 3x3 Gaussian kernel
V["i"] = [-1, 0, 1];
V["k"] = 1/(sqrt(2*pi)*V["sigma"]) * exp((-square(V["i"])) / (2*square(V["sigma"])));
V["kern"] = matmul([V["k"]].T, [V["k"]]);
# handle sparse NaN values by excluding them from the convolution,
# then renormalize the kernel
V["kern_no_NaN1"] = where(isnan(V["sc_a1"]), 0, V["kern"]);
V["kernel1"] = V["kern_no_NaN1"] / sum(V["kern_no_NaN1"]);
V["kern_no_NaN2"] = where(isnan(V["sc_a2"]), 0, V["kern"]);
V["kernel2"] = V["kern_no_NaN2"] / sum(V["kern_no_NaN2"]);
V["kern_no_NaN12"] = where(isnan(V["sc_a1"] * V["sc_a2"]), 0, V["kern"]);
V["kernel12"] = V["kern_no_NaN12"] / sum(V["kern_no_NaN12"]);
# apply the Gaussian filter via convolution
V["a1_mu_noNaN"] = sum(V["sc_a1"] * V["kernel1"]);
V["a2_mu_noNaN"] = sum(V["sc_a2"] * V["kernel2"]);
V["a1a1_noNaN"] = sum(square(V["sc_a1"]) * V["kernel1"]);
V["a2a2_noNaN"] = sum(square(V["sc_a2"]) * V["kernel2"]);
V["a1a2_noNaN"] = sum(V["sc_a1"] * V["sc_a2"] * V["kernel12"]);
# keep NaN for elements that are originally NaN
V["a1_mu"] = where(isnan(V["sc_a1"])[I], NaN, V["a1_mu_noNaN"]);
V["a2_mu"] = where(isnan(V["sc_a2"])[I], NaN, V["a2_mu_noNaN"]);
V["a1a1"] = where(isnan(V["sc_a1"])[I], NaN, V["a1a1_noNaN"]);
V["a2a2"] = where(isnan(V["sc_a2"])[I], NaN, V["a2a2_noNaN"]);
V["a1a2"] = where(isnan(V["sc_a1"] * V["sc_a2"])[I], NaN, V["a1a2_noNaN"]);
###########
V["var_a1"] = V["a1a1"] - square(V["a1_mu"]);
V["var_a2"] = V["a2a2"] - square(V["a2_mu"]);
V["cov_a1a2"] = V["a1a2"] - V["a1_mu"] * V["a2_mu"];
# compute the SSIM components
V["ssim_t1"] = 2 * V["a1_mu"] * V["a2_mu"] + V["C1"];
V["ssim_t2"] = 2 * V["cov_a1a2"] + V["C2"];
V["ssim_b1"] = square(V["a1_mu"]) + square(V["a2_mu"]) + V["C1"];
V["ssim_b2"] = V["var_a1"] + V["var_a2"] + V["C2"];
V["ssim_1"] = V["ssim_t1"] / V["ssim_b1"];
V["ssim_2"] = V["ssim_t2"] / V["ssim_b2"];
# compute the pointwise dSSIM
return V["ssim_1"] * V["ssim_2"];
""",
type="abs",
eb=1 - dssim_bound,
# 3x3 neighbourhood
neighbourhood=[
# latitude
dict(axis=0, before=1, after=1, boundary="valid"),
# longitude
dict(axis=1, before=1, after=1, boundary="valid"),
],
)
qoi_eb_stencil_expr = qoi_eb_stencil._qoi_expr._expr
First, we ensure that the dssim_mat function and the dSSIM quantity of interest produce equivalent results, to ensure that bounding the quantity of interest also bounds the dssim score we later showcase.
When using the original precision of the data, float32, both methods produce the same results, but due to the limited precision some pointwise dSSIM values are outside the expected range of [-1, 1].
from compression_safeguards.utils.bindings import Bindings
a1 = ERA5_U.values.astype(np.float32)
a2 = ERA5_U_codec["zfp.rs"][eb_abs[0]].values.astype(np.float32)
dssim_py = dssim_mat(a1, a2)
dssim_qoi = qoi_eb_stencil.evaluate_qoi(
a2,
late_bound=Bindings(
**{
"$x": a1,
"$X": a1,
"$x_min": min(np.nanmin(a1), np.nanmin(a2)),
"$x_max": max(np.nanmax(a1), np.nanmax(a2)),
}
),
)
assert np.all(dssim_py == dssim_qoi)
print(
f"min(dSSIM)={np.nanmin(dssim_py)} "
f"max(dSSIM)={np.nanmax(dssim_py)} "
f"mean(dSSIM)={np.nanmean(dssim_py)} "
f"outside(dSSIM)={np.mean((dssim_py < 0.0) | (dssim_py > 1.0)) * 100}%"
)
min(dSSIM)=-2.0402638912200928 max(dSSIM)=10.120443344116211 mean(dSSIM)=0.8803545832633972 outside(dSSIM)=1.963397625739659%
When using extended precision, float64, fewer dSSIM values are outside the expected range. Therefore, we use float64 precision for the data in this example.
a1 = ERA5_U.values.astype(np.float64)
a2 = ERA5_U_codec["zfp.rs"][eb_abs[0]].values.astype(np.float64)
dssim_py = dssim_mat(a1, a2)
dssim_qoi = qoi_eb_stencil.evaluate_qoi(
a2,
late_bound=Bindings(
**{
"$x": a1,
"$X": a1,
"$x_min": min(np.nanmin(a1), np.nanmin(a2)),
"$x_max": max(np.nanmax(a1), np.nanmax(a2)),
}
),
)
assert np.all(dssim_py == dssim_qoi)
print(
f"min(dSSIM)={np.nanmin(dssim_py)} "
f"max(dSSIM)={np.nanmax(dssim_py)} "
f"mean(dSSIM)={np.nanmean(dssim_py)} "
f"outside(dSSIM)={np.mean((dssim_py < 0.0) | (dssim_py > 1.0)) * 100}%"
)
min(dSSIM)=-0.9969480854354561 max(dSSIM)=1.0 mean(dSSIM)=0.8812509543261983 outside(dSSIM)=0.28232303790808205%
Since computing the safeguards correction for the dSSIM quantity of interest takes a while, we also use a development reporting API to inject a progress bar into the QoI computation.
import tqdm
from compression_safeguards.safeguards._qois.expr import map_expr
from compression_safeguards.safeguards._qois.expr.abc import AnyExpr
from compression_safeguards.safeguards._qois.expr.reporter import (
Reporter,
ReportingExpr,
)
def data_expr_size(expr: AnyExpr) -> int:
if not expr.has_data:
return 0
args_size = sum(data_expr_size(a) for a in expr.args)
if isinstance(expr, ReportingExpr):
return args_size
return args_size + 1
class TqdmReporter(Reporter):
__slots__ = ("_tqdm", "_current", "_stack")
_tqdm: None | tqdm.tqdm
_current: int
_stack: list[int]
def __init__(self) -> None:
self._tqdm = None
self._current = 0
self._stack = []
def enter(self, expr: AnyExpr) -> None:
size = data_expr_size(expr)
if self._tqdm is None:
self._tqdm = tqdm.tqdm(
total=size, desc="QoI", postfix=dict(depth=len(self._stack))
)
self._current = 0
self._stack.append(self._current + size)
self._tqdm.set_postfix(refresh=False, depth=len(self._stack))
def exit(self, expr: AnyExpr) -> None:
after = self._stack.pop()
self._tqdm.set_postfix(refresh=False, depth=len(self._stack))
self._tqdm.update(after - self._current)
self._current = after
if len(self._stack) > 0:
return
self._tqdm.close()
self._tqdm = None
self._current = 0
reporter = TqdmReporter()
qoi_eb_stencil._qoi_expr._expr = map_expr(
qoi_eb_stencil_expr, mapper=lambda e: ReportingExpr(e, reporter)
)
Finally, we use the numcodecs-safeguards frontend to wrap the safeguards around several different lossy compressors.
from numcodecs_safeguards import SafeguardedCodec
ERA5_U_sg = defaultdict(dict)
ERA5_U_sg_cr = defaultdict(dict)
for ea in eb_abs:
for codec in [
ZeroCodec(),
Zfp(mode="fixed-accuracy", tolerance=ea),
Sz3(eb_mode="abs", eb_abs=ea),
Sperr(mode="pwe", pwe=ea),
]:
codec_sg = SafeguardedCodec(
codec=codec,
safeguards=[
# guarantee that the global minimum and maximum are preserved,
# which simplifies the rescaling
dict(kind="sign", offset="$x_min"),
dict(kind="sign", offset="$x_max"),
qoi_eb_stencil,
],
)
with observe.observe(codec_sg, observations):
ERA5_U_sg_enc = codec_sg.encode(ERA5_U.values)
ERA5_U_sg[codec.codec_id][ea] = ERA5_U.copy(
data=codec_sg.decode(ERA5_U_sg_enc)
)
ERA5_U_sg_cr[codec.codec_id][ea] = (
ERA5_U.nbytes / np.asarray(ERA5_U_sg_enc).nbytes
)
QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:00<00:00, 41.29it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:00<00:00, 41.34it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:00<00:00, 41.20it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:22<00:00, 36.89it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:13<00:00, 38.54it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:03<00:00, 40.56it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:01<00:00, 41.01it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:04<00:00, 40.47it/s, depth=0]
ERA5_U_sg_it = defaultdict(dict)
ERA5_U_sg_it_cr = defaultdict(dict)
for ea in eb_abs:
for codec in [
ZeroCodec(),
Zfp(mode="fixed-accuracy", tolerance=ea),
Sz3(eb_mode="abs", eb_abs=ea),
Sperr(mode="pwe", pwe=ea),
]:
codec_sg = SafeguardedCodec(
codec=codec,
safeguards=[
# guarantee that the global minimum and maximum are preserved,
# which simplifies the rescaling
dict(kind="sign", offset="$x_min"),
dict(kind="sign", offset="$x_max"),
qoi_eb_stencil,
],
# use iteration to refine the corrections
compute=dict(unstable_iterative=True),
)
with observe.observe(codec_sg, observations):
ERA5_U_sg_it_enc = codec_sg.encode(ERA5_U.values)
ERA5_U_sg_it[codec.codec_id][ea] = ERA5_U.copy(
data=codec_sg.decode(ERA5_U_sg_it_enc)
)
ERA5_U_sg_it_cr[codec.codec_id][ea] = (
ERA5_U.nbytes / np.asarray(ERA5_U_sg_it_enc).nbytes
)
QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:05<00:00, 40.18it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:02<00:00, 40.89it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:13<00:00, 38.47it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:04<00:00, 40.38it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:00<00:00, 41.23it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:38<00:00, 34.04it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:12<00:00, 38.67it/s, depth=0] QoI: 100%|██████████████████████████████████████████████████████████████████████| 7453/7453 [03:21<00:00, 37.06it/s, depth=0]
ERA5_U_sg_lossless = defaultdict(dict)
ERA5_U_sg_lossless_cr = defaultdict(dict)
for ea in eb_abs:
for codec in [
ZeroCodec(),
Zfp(mode="fixed-accuracy", tolerance=ea),
Sz3(eb_mode="abs", eb_abs=ea),
Sperr(mode="pwe", pwe=ea),
]:
codec_sg = SafeguardedCodec(
codec=codec,
safeguards=[
# guarantee that the global minimum and maximum are preserved,
# which simplifies the rescaling
dict(kind="sign", offset="$x_min"),
dict(kind="sign", offset="$x_max"),
qoi_eb_stencil,
],
# produce lossless corrections and refine them with iteration
compute=dict(unstable_iterative=True, unstable_lossless_corrections=True),
)
with observe.observe(codec_sg, observations):
ERA5_U_sg_lossless_enc = codec_sg.encode(ERA5_U.values)
ERA5_U_sg_lossless[codec.codec_id][ea] = ERA5_U.copy(
data=codec_sg.decode(ERA5_U_sg_lossless_enc)
)
ERA5_U_sg_lossless_cr[codec.codec_id][ea] = (
ERA5_U.nbytes / np.asarray(ERA5_U_sg_lossless_enc).nbytes
)
For comparison, we also check how the SZ3 compressor would perform with a pointwise normalised absolute error (NOA) bound of 1/255, corresponding to the width of the bins that the dSSIM metric first quantizes the data into.
noa = Sz3(
eb_mode="abs", eb_abs=(np.nanmax(ERA5_U.values) - np.nanmin(ERA5_U.values)) / 255
)
with observe.observe(noa, observations):
ERA5_U_noa_enc = noa.encode(ERA5_U.values)
ERA5_U_noa = ERA5_U.copy(data=noa.decode(ERA5_U_noa_enc))
ERA5_U_noa_cr = ERA5_U.nbytes / ERA5_U_noa_enc.nbytes
We also compare a much simpler and cheaper quantity of interest, which ensures that the quantized data values remain the same. In effect, this method guarantees a perfect dSSIM score of $1.0$. At the time of writing, this method produces the same results as the more complex dSSIM quantity of interest (the dSSIM QoI contains several sums and products that need need to handle worst case error propagation, which likely reduces the error bound on the quantized values to near zero, thus arriving at the same result). However, future improvements in the quantity of interest safeguard, e.g. incremental corrections, could allow the dSSIM quantity of interest to compress better in the future.
codec_sg_rint = SafeguardedCodec(
codec=ZeroCodec(),
safeguards=[
# guarantee that the global minimum and maximum are preserved,
# which simplifies the rescaling
dict(kind="sign", offset="$x_min"),
dict(kind="sign", offset="$x_max"),
dict(
kind="qoi_eb_pw",
qoi="""
# we guarantee that
# min(data) = min(corrected) and
# max(data) = max(corrected)
# with the sign safeguards above
v["smin"] = c["$x_min"];
v["smax"] = c["$x_max"];
v["r"] = v["smax"] - v["smin"];
# re-scale to [0-1] and quantize to 256 bins
v["sc_a2"] = round_ties_even(((x - v["smin"]) / v["r"]) * 255) / 255;
# force the quantized value to stay the same
return v["sc_a2"];
""",
type="abs",
eb=0,
),
],
)
with observe.observe(codec_sg_rint, observations):
ERA5_U_sg_rint_enc = codec_sg_rint.encode(ERA5_U.values)
ERA5_U_sg_rint = ERA5_U.copy(data=codec_sg_rint.decode(ERA5_U_sg_rint_enc))
ERA5_U_sg_rint_cr = ERA5_U.nbytes / np.asarray(ERA5_U_sg_rint_enc).nbytes
codec_sg_it_rint = SafeguardedCodec(
codec=ZeroCodec(),
safeguards=codec_sg_rint.safeguards,
# use iteration to refine the corrections
compute=dict(unstable_iterative=True),
)
with observe.observe(codec_sg_it_rint, observations):
ERA5_U_sg_it_rint_enc = codec_sg_it_rint.encode(ERA5_U.values)
ERA5_U_sg_it_rint = ERA5_U.copy(data=codec_sg_it_rint.decode(ERA5_U_sg_it_rint_enc))
ERA5_U_sg_it_rint_cr = ERA5_U.nbytes / np.asarray(ERA5_U_sg_it_rint_enc).nbytes
codec_sg_lossless_rint = SafeguardedCodec(
codec=ZeroCodec(),
safeguards=codec_sg_rint.safeguards,
# produce lossless corrections and refine them with iteration
compute=dict(unstable_iterative=True, unstable_lossless_corrections=True),
)
with observe.observe(codec_sg_lossless_rint, observations):
ERA5_U_sg_lossless_rint_enc = codec_sg_lossless_rint.encode(ERA5_U.values)
ERA5_U_sg_lossless_rint = ERA5_U.copy(
data=codec_sg_lossless_rint.decode(ERA5_U_sg_lossless_rint_enc)
)
ERA5_U_sg_lossless_rint_cr = (
ERA5_U.nbytes / np.asarray(ERA5_U_sg_lossless_rint_enc).nbytes
)
Compressing u with OptZConfig¶
We configure OptZConfig with a custom safety violations metric, implemented in Python, that computes the percentage of violations $V$. We then maximise the score
$$ \textrm{score} = \begin{cases} -\textrm{V} \quad &\text{if } \textrm{V} > 0 \\ \textrm{CR} \quad &\text{otherwise} \end{cases} $$
using the FRAZ search algorithm with 25 iterations, where CR is the achieved compression ratio. Since FRAZ seems to struggle with finding sufficient absolute error bounds spread across several orders of magnitude, we search for bounds in logarithmic space by wrapping each codec in an Exponential<CODEC> meta-codec.
import numcodecs
class SafetyViolationsMetric(numcodecs.abc.Codec):
codec_id = "safety-violations-metric"
def __init__(self):
self._data = None
def encode(self, buf):
# store the original data for later
self._data = np.array(buf, copy=True)
# return no metric
return np.empty(0, dtype=np.float64)
def decode(self, buf, out=None):
# compute the violations
buf_dssim = np.nanmean(
dssim_mat(
self._data,
buf,
reproducible=False,
)
)
# use distance to the dSSIM threshold as the violations score
violations = dssim_bound - buf_dssim
self._data = None
# return the violations score metric
return numcodecs.compat.ndarray_copy(np.float64(violations), out)
numcodecs.registry.register_codec(SafetyViolationsMetric)
class ExponentialZfp(Zfp):
codec_id = "e-zfp.rs"
def __new__(cls, tolerance: float, **kwargs):
codec = super().__new__(cls, tolerance=np.exp(tolerance), **kwargs)
codec._tolerance = tolerance
return codec
def get_config(self):
return {
**super().get_config(),
"id": type(self).codec_id,
"tolerance": self._tolerance,
}
class ExponentialSz3(Sz3):
codec_id = "e-sz3.rs"
def __new__(cls, eb_abs: float, **kwargs):
codec = super().__new__(cls, eb_abs=np.exp(eb_abs), **kwargs)
codec._eb_abs = eb_abs
return codec
def get_config(self):
return {
**super().get_config(),
"id": type(self).codec_id,
"eb_abs": self._eb_abs,
}
class ExponentialSperr(Sperr):
codec_id = "e-sperr.rs"
def __new__(cls, pwe: float, **kwargs):
codec = super().__new__(cls, pwe=np.exp(pwe), **kwargs)
codec._pwe = pwe
return codec
def get_config(self):
return {**super().get_config(), "id": type(self).codec_id, "pwe": self._pwe}
numcodecs.registry.register_codec(ExponentialZfp)
numcodecs.registry.register_codec(ExponentialSz3)
numcodecs.registry.register_codec(ExponentialSperr)
from numcodecs_wasm_pressio import Pressio
ERA5_U_optzconfig = dict()
ERA5_U_optzconfig_cr = dict()
for codec, parameter, lower_bound in [
(Zfp(mode="fixed-accuracy", tolerance=1.0), "tolerance", 1e-4), # decent guess
(Sz3(eb_mode="abs", eb_abs=1.0), "eb_abs", 1e-4), # decent guess
(Sperr(mode="pwe", pwe=1.0), "pwe", 1e-4), # decent guess
]:
optzconfig = Pressio(
compressor_id="opt",
compressor_config={
"opt:output": ["composite:score"],
"opt:inputs": [f"numcodecs.rs:{parameter}"],
"opt:lower_bound": np.log(lower_bound),
"opt:upper_bound": np.log(1.0),
"opt:max_iterations": 25,
"opt:objective_mode_name": "max",
},
early_config={
"opt:compressor": "pressio",
"pressio:compressor": "numcodecs.rs",
**{
f"numcodecs.rs:{k}": f"e-{v}" if k == "id" else v
for k, v in codec.get_config().items()
},
"opt:search": "fraz",
"pressio:metric": "composite",
"composite:plugins": ["size", "numcodecs.rs-metric"],
"composite:scripts": [
"""
violations = metrics["numcodecs.rs-metric:decompression"]
if violations > 0 then
return "score", -violations
else
return "score", metrics["size:compression_ratio"]
end
"""
],
"numcodecs.rs-metric:id": "safety-violations-metric",
},
)
with observe.observe(optzconfig, observations):
ERA5_U_optzconfig_enc = optzconfig.encode(ERA5_U.values)
ERA5_U_optzconfig[codec.codec_id] = ERA5_U.copy(
data=optzconfig.decode(ERA5_U_optzconfig_enc)
)
ERA5_U_optzconfig_cr[codec.codec_id] = (
ERA5_U.nbytes / np.asarray(ERA5_U_optzconfig_enc).nbytes
)
rank={0,1,} iter={0} input={-4.60517,} output={7.83912,} objective={7.83912}
rank={0,1,} iter={1} input={-7.06182,} output={5.25999,} objective={5.25999}
rank={0,1,} iter={2} input={-2.18607,} output={-0.00754271,} objective={-0.00754271}
rank={0,1,} iter={3} input={-5.23695,} output={6.98307,} objective={6.98307}
rank={0,1,} iter={4} input={-9.20755,} output={4.2205,} objective={4.2205}
rank={0,1,} iter={5} input={-4.47128,} output={7.83912,} objective={7.83912}
rank={0,1,} iter={6} input={-5.89774,} output={6.2941,} objective={6.2941}
rank={0,1,} iter={7} input={-4.79639,} output={7.83912,} objective={7.83912}
rank={0,1,} iter={8} input={-7.9821,} output={4.86091,} objective={4.86091}
rank={0,1,} iter={9} input={-4.89178,} output={6.98307,} objective={6.98307}
rank={0,1,} iter={10} input={-0.000130486,} output={-0.0688416,} objective={-0.0688416}
rank={0,1,} iter={11} input={-3.86497,} output={8.89944,} objective={8.89944}
rank={0,1,} iter={12} input={-3.16177,} output={-0.00174731,} objective={-0.00174731}
rank={0,1,} iter={13} input={-1.09024,} output={-0.0371666,} objective={-0.0371666}
rank={0,1,} iter={14} input={-4.08863,} output={8.89944,} objective={8.89944}
rank={0,1,} iter={15} input={-6.43996,} output={5.73067,} objective={5.73067}
rank={0,1,} iter={16} input={-3.9768,} output={8.89944,} objective={8.89944}
rank={0,1,} iter={17} input={-8.56972,} output={4.51812,} objective={4.51812}
rank={0,1,} iter={18} input={-7.50494,} output={5.25999,} objective={5.25999}
rank={0,1,} iter={19} input={-5.54042,} output={6.98307,} objective={6.98307}
rank={0,1,} iter={20} input={-4.23618,} output={7.83912,} objective={7.83912}
rank={0,1,} iter={21} input={-4.03289,} output={8.89944,} objective={8.89944}
rank={0,1,} iter={22} input={-3.92083,} output={8.89944,} objective={8.89944}
rank={0,1,} iter={23} input={-6.14733,} output={6.2941,} objective={6.2941}
rank={0,1,} iter={24} input={-6.73156,} output={5.73067,} objective={5.73067}
final_iter={25} inputs={-3.86497,} output={8.89944,}
rank={0,1,} iter={0} input={-4.60517,} output={-0.0045849,} objective={-0.0045849}
rank={0,1,} iter={1} input={-7.06182,} output={8.56878,} objective={8.56878}
rank={0,1,} iter={2} input={-2.18607,} output={-0.0788497,} objective={-0.0788497}
rank={0,1,} iter={3} input={-9.21034,} output={6.27985,} objective={6.27985}
rank={0,1,} iter={4} input={-7.80758,} output={7.2684,} objective={7.2684}
rank={0,1,} iter={5} input={-4.74584,} output={-0.00341721,} objective={-0.00341721}
rank={0,1,} iter={6} input={-8.37615,} output={6.474,} objective={6.474}
rank={0,1,} iter={7} input={-5.90383,} output={11.5394,} objective={11.5394}
rank={0,1,} iter={8} input={-0.000166178,} output={-0.273373,} objective={-0.273373}
rank={0,1,} iter={9} input={-6.24575,} output={10.4801,} objective={10.4801}
rank={0,1,} iter={10} input={-6.55825,} output={9.78282,} objective={9.78282}
rank={0,1,} iter={11} input={-4.74584,} output={-0.00341721,} objective={-0.00341721}
rank={0,1,} iter={12} input={-6.0214,} output={11.1628,} objective={11.1628}
rank={0,1,} iter={13} input={-5.32483,} output={13.6472,} objective={13.6472}
rank={0,1,} iter={14} input={-3.39636,} output={-0.0247463,} objective={-0.0247463}
rank={0,1,} iter={15} input={-6.48282,} output={9.79808,} objective={9.79808}
rank={0,1,} iter={16} input={-1.09157,} output={-0.136103,} objective={-0.136103}
rank={0,1,} iter={17} input={-5.90383,} output={11.5394,} objective={11.5394}
rank={0,1,} iter={18} input={-5.56962,} output={12.6513,} objective={12.6513}
rank={0,1,} iter={19} input={-5.3866,} output={13.3787,} objective={13.3787}
rank={0,1,} iter={20} input={-7.40727,} output={7.93355,} objective={7.93355}
rank={0,1,} iter={21} input={-5.18009,} output={-0.000540528,} objective={-0.000540528}
rank={0,1,} iter={22} input={-4.00091,} output={-0.0118845,} objective={-0.0118845}
rank={0,1,} iter={23} input={-5.35117,} output={13.5535,} objective={13.5535}
rank={0,1,} iter={24} input={-2.79109,} output={-0.0457674,} objective={-0.0457674}
final_iter={25} inputs={-5.32483,} output={13.6472,}
rank={0,1,} iter={0} input={-4.60517,} output={-0.000829658,} objective={-0.000829658}
rank={0,1,} iter={1} input={-7.06182,} output={9.51461,} objective={9.51461}
rank={0,1,} iter={2} input={-2.18607,} output={-0.0396817,} objective={-0.0396817}
rank={0,1,} iter={3} input={-9.21034,} output={6.52341,} objective={6.52341}
rank={0,1,} iter={4} input={-7.75045,} output={8.29013,} objective={8.29013}
rank={0,1,} iter={5} input={-4.74584,} output={-0.000211268,} objective={-0.000211268}
rank={0,1,} iter={6} input={-8.26569,} output={7.56854,} objective={7.56854}
rank={0,1,} iter={7} input={-5.90383,} output={12.6564,} objective={12.6564}
rank={0,1,} iter={8} input={-0.000166178,} output={-0.15309,} objective={-0.15309}
rank={0,1,} iter={9} input={-6.25254,} output={11.5681,} objective={11.5681}
rank={0,1,} iter={10} input={-6.56292,} output={10.6872,} objective={10.6872}
rank={0,1,} iter={11} input={-4.74584,} output={-0.000211268,} objective={-0.000211268}
rank={0,1,} iter={12} input={-6.02767,} output={12.2583,} objective={12.2583}
rank={0,1,} iter={13} input={-5.32483,} output={14.8321,} objective={14.8321}
rank={0,1,} iter={14} input={-3.39636,} output={-0.012263,} objective={-0.012263}
rank={0,1,} iter={15} input={-6.48282,} output={10.9031,} objective={10.9031}
rank={0,1,} iter={16} input={-1.09157,} output={-0.0862977,} objective={-0.0862977}
rank={0,1,} iter={17} input={-5.90383,} output={12.6564,} objective={12.6564}
rank={0,1,} iter={18} input={-5.57192,} output={13.8353,} objective={13.8353}
rank={0,1,} iter={19} input={-5.39218,} output={14.5469,} objective={14.5469}
rank={0,1,} iter={20} input={-8.71635,} output={7.02976,} objective={7.02976}
rank={0,1,} iter={21} input={-5.18009,} output={15.4727,} objective={15.4727}
rank={0,1,} iter={22} input={-4.00091,} output={-0.00527798,} objective={-0.00527798}
rank={0,1,} iter={23} input={-4.89059,} output={16.891,} objective={16.891}
rank={0,1,} iter={24} input={-2.79109,} output={-0.0233931,} objective={-0.0233931}
final_iter={25} inputs={-4.89059,} output={16.891,}
Visual comparison of the dSSIM score distributions¶
fig = earthkit.plots.Figure(
size=(10, 23),
rows=6,
columns=2,
)
plot_u_wind_dssim(
ERA5_U, 1.0, fig.add_map(0, 0), "Original", span=(-60, 60), dssim_bound=dssim_bound
)
plot_u_wind_dssim(
ERA5_U_codec["zfp.rs"][eb_abs[1]],
ERA5_U_codec_cr["zfp.rs"][eb_abs[1]],
fig.add_map(1, 0),
r"ZFP($\epsilon_{{abs}}$)",
span=(0.95, 1.0),
dssim_bound=dssim_bound,
error=True,
)
plot_u_wind_dssim(
ERA5_U_codec["sz3.rs"][eb_abs[1]],
ERA5_U_codec_cr["sz3.rs"][eb_abs[1]],
fig.add_map(2, 0),
r"SZ3($\epsilon_{{abs}}$)",
span=(0.95, 1.0),
dssim_bound=dssim_bound,
error=True,
)
plot_u_wind_dssim(
ERA5_U_codec["sperr.rs"][eb_abs[1]],
ERA5_U_codec_cr["sperr.rs"][eb_abs[1]],
fig.add_map(3, 0),
r"SPERR($\epsilon_{{abs}}$)",
span=(0.95, 1.0),
dssim_bound=dssim_bound,
error=True,
)
plot_u_wind_dssim(
ERA5_U_sg["zero"][eb_abs[1]],
ERA5_U_sg_cr["zero"][eb_abs[1]],
fig.add_map(0, 1),
r"Safeguarded(0, dSSIM)",
span=(0.999, 1.0),
dssim_bound=dssim_bound,
error=True,
corr=ERA5_U_codec["zero"][eb_abs[1]],
my_ERA5_U_it=ERA5_U_sg_it["zero"][eb_abs[1]],
cr_it=ERA5_U_sg_it_cr["zero"][eb_abs[1]],
)
plot_u_wind_dssim(
ERA5_U_sg["zfp.rs"][eb_abs[1]],
ERA5_U_sg_cr["zfp.rs"][eb_abs[1]],
fig.add_map(1, 1),
r"Safeguarded(ZFP($\epsilon_{{abs}}$), dSSIM)",
span=(0.999, 1.0),
dssim_bound=dssim_bound,
error=True,
corr=ERA5_U_codec["zfp.rs"][eb_abs[1]],
my_ERA5_U_it=ERA5_U_sg_it["zfp.rs"][eb_abs[1]],
cr_it=ERA5_U_sg_it_cr["zfp.rs"][eb_abs[1]],
corr_colours=["white", "green", "limegreen"],
)
plot_u_wind_dssim(
ERA5_U_sg["sz3.rs"][eb_abs[1]],
ERA5_U_sg_cr["sz3.rs"][eb_abs[1]],
fig.add_map(2, 1),
r"Safeguarded(SZ3($\epsilon_{{abs}}$), dSSIM)",
span=(0.999, 1.0),
dssim_bound=dssim_bound,
error=True,
corr=ERA5_U_codec["sz3.rs"][eb_abs[1]],
my_ERA5_U_it=ERA5_U_sg_it["sz3.rs"][eb_abs[1]],
cr_it=ERA5_U_sg_it_cr["sz3.rs"][eb_abs[1]],
)
plot_u_wind_dssim(
ERA5_U_sg["sperr.rs"][eb_abs[1]],
ERA5_U_sg_cr["sperr.rs"][eb_abs[1]],
fig.add_map(3, 1),
r"Safeguarded(SPERR($\epsilon_{{abs}}$), dSSIM)",
span=(0.999, 1.0),
dssim_bound=dssim_bound,
error=True,
corr=ERA5_U_codec["sperr.rs"][eb_abs[1]],
my_ERA5_U_it=ERA5_U_sg_it["sperr.rs"][eb_abs[1]],
cr_it=ERA5_U_sg_it_cr["sperr.rs"][eb_abs[1]],
)
plot_u_wind_dssim(
ERA5_U_optzconfig["zfp.rs"],
ERA5_U_optzconfig_cr["zfp.rs"],
fig.add_map(4, 0),
r"OptZConfig(ZFP, dSSIM)",
span=(0.999, 1.0),
dssim_bound=dssim_bound,
error=True,
)
plot_u_wind_dssim(
ERA5_U_optzconfig["sz3.rs"],
ERA5_U_optzconfig_cr["sz3.rs"],
fig.add_map(4, 1),
r"OptZConfig(SZ3, dSSIM)",
span=(0.999, 1.0),
dssim_bound=dssim_bound,
error=True,
)
plot_u_wind_dssim(
ERA5_U_optzconfig["sperr.rs"],
ERA5_U_optzconfig_cr["sperr.rs"],
fig.add_map(5, 0),
r"OptZConfig(SPER, dSSIM)",
span=(0.999, 1.0),
dssim_bound=dssim_bound,
error=True,
)
fig.save(Path("plots") / "dssim.pdf")
dssim_table = pd.concat(
[
table_u_wind_dssim(
ERA5_U_sg_lossless["zero"][eb_abs[0]],
ERA5_U_sg_lossless_cr["zero"][eb_abs[0]],
# no eb_abs in Safeguarded(0), so results for eb_abs[0] and
# eb_abs[1] are equivalent
["0", "", rf"$\text{{dSSIM}} \geq {dssim_bound}$", "lossless"],
dssim_bound,
ERA5_U_codec["zero"][eb_abs[0]],
),
table_u_wind_dssim(
ERA5_U_sg["zero"][eb_abs[0]],
ERA5_U_sg_cr["zero"][eb_abs[0]],
# no eb_abs in Safeguarded(0), so results for eb_abs[0] and
# eb_abs[1] are equivalent
["0", "", rf"$\text{{dSSIM}} \geq {dssim_bound}$", "one-shot"],
dssim_bound,
ERA5_U_codec["zero"][eb_abs[0]],
),
table_u_wind_dssim(
ERA5_U_sg_it["zero"][eb_abs[0]],
ERA5_U_sg_it_cr["zero"][eb_abs[0]],
# no eb_abs in Safeguarded(0), so results for eb_abs[0] and
# eb_abs[1] are equivalent
["0", "", rf"$\text{{dSSIM}} \geq {dssim_bound}$", "iterative"],
dssim_bound,
ERA5_U_codec["zero"][eb_abs[0]],
),
]
+ [
table_u_wind_dssim(
ERA5_U_sg_lossless_rint,
ERA5_U_sg_lossless_rint_cr,
["0", "", "bin(256)", "lossless"],
dssim_bound,
ERA5_U_codec["zero"][eb_abs[0]],
),
table_u_wind_dssim(
ERA5_U_sg_rint,
ERA5_U_sg_rint_cr,
["0", "", "bin(256)", "one-shot"],
dssim_bound,
ERA5_U_codec["zero"][eb_abs[0]],
),
table_u_wind_dssim(
ERA5_U_sg_it_rint,
ERA5_U_sg_it_rint_cr,
["0", "", "bin(256)", "iterative"],
dssim_bound,
ERA5_U_codec["zero"][eb_abs[0]],
),
]
+ [
table_u_wind_dssim(
# for SZ3(eb_noa), we only need the non-safeguarded results
ERA5_U_noa,
ERA5_U_noa_cr,
[r"SZ3($\epsilon_{noa}$)", "", "-", ""],
dssim_bound,
None,
),
]
+ [
x
for eb_abs_zfp in eb_abs
for x in [
table_u_wind_dssim(
ERA5_U_codec["zfp.rs"][eb_abs_zfp],
ERA5_U_codec_cr["zfp.rs"][eb_abs_zfp],
[r"ZFP($\epsilon_{abs}$)", eb_abs_zfp, "-", ""],
dssim_bound,
None,
),
table_u_wind_dssim(
ERA5_U_sg_lossless["zfp.rs"][eb_abs_zfp],
ERA5_U_sg_lossless_cr["zfp.rs"][eb_abs_zfp],
[
r"ZFP($\epsilon_{abs}$)",
eb_abs_zfp,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"lossless",
],
dssim_bound,
ERA5_U_codec["zfp.rs"][eb_abs_zfp],
),
table_u_wind_dssim(
ERA5_U_sg["zfp.rs"][eb_abs_zfp],
ERA5_U_sg_cr["zfp.rs"][eb_abs_zfp],
[
r"ZFP($\epsilon_{abs}$)",
eb_abs_zfp,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"one-shot",
],
dssim_bound,
ERA5_U_codec["zfp.rs"][eb_abs_zfp],
),
table_u_wind_dssim(
ERA5_U_sg_it["zfp.rs"][eb_abs_zfp],
ERA5_U_sg_it_cr["zfp.rs"][eb_abs_zfp],
[
r"ZFP($\epsilon_{abs}$)",
eb_abs_zfp,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"iterative",
],
dssim_bound,
ERA5_U_codec["zfp.rs"][eb_abs_zfp],
),
]
]
+ [
table_u_wind_dssim(
ERA5_U_optzconfig["zfp.rs"],
ERA5_U_optzconfig_cr["zfp.rs"],
[
"OptZConfig(ZFP)",
"",
rf"$\text{{E}}[\text{{dSSIM}}] \geq {dssim_bound}$",
"",
],
dssim_bound,
None,
)
]
+ [
x
for eb_abs_sz3 in eb_abs
for x in [
table_u_wind_dssim(
ERA5_U_codec["sz3.rs"][eb_abs_sz3],
ERA5_U_codec_cr["sz3.rs"][eb_abs_sz3],
[r"SZ3($\epsilon_{abs}$)", eb_abs_sz3, "-", ""],
dssim_bound,
None,
),
table_u_wind_dssim(
ERA5_U_sg_lossless["sz3.rs"][eb_abs_sz3],
ERA5_U_sg_lossless_cr["sz3.rs"][eb_abs_sz3],
[
r"SZ3($\epsilon_{abs}$)",
eb_abs_sz3,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"lossless",
],
dssim_bound,
ERA5_U_codec["sz3.rs"][eb_abs_sz3],
),
table_u_wind_dssim(
ERA5_U_sg["sz3.rs"][eb_abs_sz3],
ERA5_U_sg_cr["sz3.rs"][eb_abs_sz3],
[
r"SZ3($\epsilon_{abs}$)",
eb_abs_sz3,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"one-shot",
],
dssim_bound,
ERA5_U_codec["sz3.rs"][eb_abs_sz3],
),
table_u_wind_dssim(
ERA5_U_sg_it["sz3.rs"][eb_abs_sz3],
ERA5_U_sg_it_cr["sz3.rs"][eb_abs_sz3],
[
r"SZ3($\epsilon_{abs}$)",
eb_abs_sz3,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"iterative",
],
dssim_bound,
ERA5_U_codec["sz3.rs"][eb_abs_sz3],
),
]
]
+ [
table_u_wind_dssim(
ERA5_U_optzconfig["sz3.rs"],
ERA5_U_optzconfig_cr["sz3.rs"],
[
"OptZConfig(SZ3)",
"",
rf"$\text{{E}}[\text{{dSSIM}}] \geq {dssim_bound}$",
"",
],
dssim_bound,
None,
)
]
+ [
x
for eb_abs_sperr in eb_abs
for x in [
table_u_wind_dssim(
ERA5_U_codec["sperr.rs"][eb_abs_sperr],
ERA5_U_codec_cr["sperr.rs"][eb_abs_sperr],
[r"SPERR($\epsilon_{abs}$)", eb_abs_sperr, "-", ""],
dssim_bound,
None,
),
table_u_wind_dssim(
ERA5_U_sg_lossless["sperr.rs"][eb_abs_sperr],
ERA5_U_sg_lossless_cr["sperr.rs"][eb_abs_sperr],
[
r"SPERR($\epsilon_{abs}$)",
eb_abs_sperr,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"lossless",
],
dssim_bound,
ERA5_U_codec["sperr.rs"][eb_abs_sperr],
),
table_u_wind_dssim(
ERA5_U_sg["sperr.rs"][eb_abs_sperr],
ERA5_U_sg_cr["sperr.rs"][eb_abs_sperr],
[
r"SPERR($\epsilon_{abs}$)",
eb_abs_sperr,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"one-shot",
],
dssim_bound,
ERA5_U_codec["sperr.rs"][eb_abs_sperr],
),
table_u_wind_dssim(
ERA5_U_sg_it["sperr.rs"][eb_abs_sperr],
ERA5_U_sg_it_cr["sperr.rs"][eb_abs_sperr],
[
r"SPERR($\epsilon_{abs}$)",
eb_abs_sperr,
rf"$\text{{dSSIM}} \geq {dssim_bound}$",
"iterative",
],
dssim_bound,
ERA5_U_codec["sperr.rs"][eb_abs_sperr],
),
]
]
+ [
table_u_wind_dssim(
ERA5_U_optzconfig["sperr.rs"],
ERA5_U_optzconfig_cr["sperr.rs"],
[
"OptZConfig(SPERR)",
"",
rf"$\text{{E}}[\text{{dSSIM}}] \geq {dssim_bound}$",
"",
],
dssim_bound,
None,
)
]
+ [
table_u_wind_dssim(
ERA5_U_zstd,
ERA5_U_zstd_cr,
["ZSTD(22)", "", "-", ""],
dssim_bound,
None,
),
]
).set_index(["Compressor", r"$\epsilon_{abs}$", "Safeguarded", "Corrections"])
Path("tables").joinpath("dssim.tex").write_text(
dssim_table.to_latex(escape=False)
.replace("%", r"\%")
.replace("\\cline{1-13} \\cline{2-13} \\cline{3-13}\n\\bottomrule", "\\bottomrule")
)
dssim_table
| $L_{\infty}(\hat{u})$ | $L_{2}(\hat{u})$ | $E[\text{D}]$ | $\min(\text{D})$ | $\max(\text{D})$ | $\text{D} < 0.995$ | S | C | CR | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Compressor | $\epsilon_{abs}$ | Safeguarded | Corrections | |||||||||
| 0 | $\text{dSSIM} \geq 0.995$ | lossless | 0.207 | 0.00151 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 99.7% | $\times$ 5.51 | |
| one-shot | 0.356 | 0.137 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 98.6% | $\times$ 28.27 | |||
| iterative | 0.356 | 0.137 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 98.6% | $\times$ 28.27 | |||
| bin(256) | lossless | 0.226 | 0.0123 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 98.6% | $\times$ 5.64 | ||
| one-shot | 0.356 | 0.137 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 98.6% | $\times$ 28.27 | |||
| iterative | 0.356 | 0.137 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 98.6% | $\times$ 28.27 | |||
| SZ3($\epsilon_{noa}$) | - | 0.358 | 0.173 | 0.82942 | -0.997 | 1.0 | 92.3% | $\times$ | $\times$ 110.98 | |||
| ZFP($\epsilon_{abs}$) | 1.0 | - | 0.543 | 0.0987 | 0.88125 | -0.997 | 1.0 | 80.4% | $\times$ | $\times$ 24.35 | ||
| $\text{dSSIM} \geq 0.995$ | lossless | 0.469 | 0.033 | 0.99995 | 0.995 | 1.0 | 0 | $\checkmark$ | 81.3% | $\times$ 5.05 | ||
| one-shot | 0.355 | 0.0789 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 21.5% | $\times$ 15.73 | |||
| iterative | 0.469 | 0.0792 | 0.99998 | 0.995 | 1.0 | 0 | $\checkmark$ | 21.2% | $\times$ 15.77 | |||
| 0.1 | - | 0.0392 | 0.00825 | 0.98746 | -0.183 | 1.0 | 13.6% | $\times$ | $\times$ 11.94 | |||
| $\text{dSSIM} \geq 0.995$ | lossless | 0.0392 | 0.00756 | 0.99999 | 0.995 | 1.0 | 0 | $\checkmark$ | 14.2% | $\times$ 8.9 | ||
| one-shot | 0.227 | 0.0178 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 1.8% | $\times$ 11.34 | |||
| iterative | 0.227 | 0.0175 | 0.99999 | 0.995 | 1.0 | 0 | $\checkmark$ | 1.8% | $\times$ 11.35 | |||
| OptZConfig(ZFP) | $\text{E}[\text{dSSIM}] \geq 0.995$ | 0.00975 | 0.0022 | 0.99654 | -0.149 | 1.0 | 3.9% | $\checkmark$ | $\times$ 8.9 | |||
| SZ3($\epsilon_{abs}$) | 1.0 | - | 1.0 | 0.328 | 0.78367 | -0.999 | 1.0 | 94.9% | $\times$ | $\times$ 262.5 | ||
| $\text{dSSIM} \geq 0.995$ | lossless | 0.973 | 0.0269 | 0.99997 | 0.995 | 1.0 | 0 | $\checkmark$ | 97.4% | $\times$ 0.94 | ||
| one-shot | 0.358 | 0.135 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 62.4% | $\times$ 17.29 | |||
| iterative | 0.973 | 0.137 | 0.99998 | 0.995 | 1.0 | 0 | $\checkmark$ | 62.2% | $\times$ 17.33 | |||
| 0.1 | - | 0.1 | 0.0515 | 0.92396 | -0.997 | 1.0 | 61.8% | $\times$ | $\times$ 50.69 | |||
| $\text{dSSIM} \geq 0.995$ | lossless | 0.1 | 0.0287 | 0.99996 | 0.995 | 1.0 | 0 | $\checkmark$ | 62.9% | $\times$ 1.42 | ||
| one-shot | 0.226 | 0.0527 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 12.1% | $\times$ 28.35 | |||
| iterative | 0.226 | 0.0527 | 0.99998 | 0.995 | 1.0 | 0 | $\checkmark$ | 11.9% | $\times$ 28.48 | |||
| OptZConfig(SZ3) | $\text{E}[\text{dSSIM}] \geq 0.995$ | 0.00487 | 0.00275 | 0.9952 | -0.149 | 1.0 | 5.3% | $\checkmark$ | $\times$ 13.65 | |||
| SPERR($\epsilon_{abs}$) | 1.0 | - | 0.998 | 0.176 | 0.84181 | -0.997 | 1.0 | 89.2% | $\times$ | $\times$ 195.53 | ||
| $\text{dSSIM} \geq 0.995$ | lossless | 0.857 | 0.0333 | 0.99995 | 0.995 | 1.0 | 0 | $\checkmark$ | 90.1% | $\times$ 1.01 | ||
| one-shot | 0.357 | 0.107 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 36.4% | $\times$ 24.74 | |||
| iterative | 0.857 | 0.108 | 0.99998 | 0.995 | 1.0 | 0 | $\checkmark$ | 36.0% | $\times$ 24.86 | |||
| 0.1 | - | 0.1 | 0.0293 | 0.95924 | -0.442 | 1.0 | 40.0% | $\times$ | $\times$ 49.01 | |||
| $\text{dSSIM} \geq 0.995$ | lossless | 0.1 | 0.021 | 0.99997 | 0.995 | 1.0 | 0 | $\checkmark$ | 41.0% | $\times$ 2.14 | ||
| one-shot | 0.227 | 0.0362 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | 6.4% | $\times$ 32.36 | |||
| iterative | 0.227 | 0.0361 | 0.99998 | 0.995 | 1.0 | 0 | $\checkmark$ | 6.2% | $\times$ 32.55 | |||
| OptZConfig(SPERR) | $\text{E}[\text{dSSIM}] \geq 0.995$ | 0.00752 | 0.00281 | 0.99552 | -0.149 | 1.0 | 5.1% | $\checkmark$ | $\times$ 16.89 | |||
| ZSTD(22) | - | 0.0 | 0.0 | 1.0 | 1.0 | 1.0 | 0 | $\checkmark$ | $\times$ 4.51 |
import json
with Path("observations").joinpath("dssim.json").open("w") as f:
json.dump(observations, f)