Robust Statistics with Histograms and T-Digest#

The mean of a bin is sensitive to outliers and hides the shape of skewed distributions. pyinterp.Histogram2D keeps, in each bin, a compact summary of the whole distribution built with the T-Digest algorithm, and pyinterp.TDigest does the same for a 1D stream of values. In this example, you will learn how to map per-bin medians and quantiles, how to merge digests computed on separate batches, and how the compression parameter trades accuracy for memory.

import pickle

import matplotlib.pyplot
import numpy

import pyinterp
import pyinterp.tests

rng = numpy.random.default_rng(0)

Contaminated Drifter Speeds#

We reuse the drifter velocities of the Black Sea, already binned in the Binning example, and compute their speed. To mimic spurious positioning fixes, 3 % of the speeds are replaced by values between 1 and 3 m/s, well above the typical speed of the currents.

Per-Bin Distributions#

A pyinterp.Histogram2D is defined by two axes, like pyinterp.Binning2D. Each value pushed is assigned to the bin of the nearest grid node, and NaN values are ignored. Besides the mean, the quantile method estimates any quantile of the values in each bin. Empty bins are set to NaN.

x_axis = pyinterp.Axis(numpy.arange(27, 42, 0.3), period=360.0)
y_axis = pyinterp.Axis(numpy.arange(40, 47, 0.3))

hist = pyinterp.Histogram2D(x_axis, y_axis)
hist.push(lon, lat, noisy)

mean = hist.mean()
median = hist.quantile(0.5)
q05 = hist.quantile(0.05)
q95 = hist.quantile(0.95)

The same histogram built from the clean speeds gives the reference against which we measure the bias introduced by the outliers.

reference = pyinterp.Histogram2D(x_axis, y_axis)
reference.push(lon, lat, speed)
ref_median = reference.quantile(0.5)

print(f'Mean absolute bias of the mean:   '
      f'{numpy.nanmean(numpy.abs(mean - ref_median)):.3f} m/s')
print(f'Mean absolute bias of the median: '
      f'{numpy.nanmean(numpy.abs(median - ref_median)):.3f} m/s')
Mean absolute bias of the mean:   0.077 m/s
Mean absolute bias of the median: 0.008 m/s

The mean is pulled up wherever an outlier falls in a bin, whereas the median barely moves. The spread between the 5 % and 95 % quantiles shows where the distribution is wide, either because the currents are variable or because the bin holds a few outliers.

mx, my = numpy.meshgrid(x_axis, y_axis, indexing='ij')
fig, axes = matplotlib.pyplot.subplots(2,
                                       2,
                                       figsize=(10, 6),
                                       layout='constrained',
                                       sharex=True,
                                       sharey=True)
panels = [
    (mean, 'Mean', 'viridis', 0, 0.6),
    (median, 'Median', 'viridis', 0, 0.6),
    (mean - median, 'Mean - median', 'RdBu_r', -0.3, 0.3),
    (q95 - q05, 'Quantile 95 % - quantile 5 %', 'viridis', 0, 2),
]
for ax, (field, title, cmap, vmin, vmax) in zip(axes.flat, panels):
    pcm = ax.pcolormesh(mx,
                        my,
                        field,
                        cmap=cmap,
                        vmin=vmin,
                        vmax=vmax,
                        shading='auto')
    ax.set_title(title)
    ax.set_aspect('equal')
    fig.colorbar(pcm, ax=ax, label='m/s')
Mean, Median, Mean - median, Quantile 95 % - quantile 5 %

Streaming Quantiles#

pyinterp.TDigest summarizes a stream of values. Here, the stream is a heavy-tailed log-normal sample that arrives in two batches. Each batch is reduced to a digest, and the two digests are merged with +=, as you would do with partial results computed by different workers. The merged digest is compared with numpy.quantile() computed on the whole sample.

stream = rng.lognormal(mean=0.0, sigma=1.0, size=200_000)
first, second = numpy.split(stream, 2)

digest = pyinterp.TDigest(first)
digest += pyinterp.TDigest(second)

probabilities = numpy.array([0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99])
estimated = digest.quantile(probabilities)
exact = numpy.quantile(stream, probabilities)
relative_error = numpy.abs(estimated / exact - 1)

print(f'Values summarized: {digest.count()}')
for p, est, ref, err in zip(probabilities, estimated, exact, relative_error):
    print(f'  q{p:<5.2f} T-Digest={est:8.4f} numpy={ref:8.4f} '
          f'error={err:.2%}')
Values summarized: 200000
  q0.01  T-Digest=  0.0974 numpy=  0.0968 error=0.62%
  q0.05  T-Digest=  0.1925 numpy=  0.1927 error=0.11%
  q0.25  T-Digest=  0.5092 numpy=  0.5084 error=0.15%
  q0.50  T-Digest=  1.0002 numpy=  1.0000 error=0.02%
  q0.75  T-Digest=  1.9685 numpy=  1.9653 error=0.16%
  q0.95  T-Digest=  5.2282 numpy=  5.1759 error=1.01%
  q0.99  T-Digest= 10.4894 numpy= 10.3521 error=1.33%

Compression versus Accuracy#

The compression parameter (100 by default) bounds the number of centroids kept by the digest: its size no longer depends on the number of values, only on the compression. Higher values give more accurate quantiles, especially in the tails, at the cost of memory. We measure the size of the digest by its pickled state. The same parameter is accepted by pyinterp.Histogram2D, where it applies to every bin.

compressions = numpy.array([20, 50, 100, 200, 500, 1000])
max_error = []
size_kib = []
for compression in compressions:
    item = pyinterp.TDigest(stream, compression=int(compression))
    max_error.append(
        numpy.abs(item.quantile(probabilities) / exact - 1).max())
    size_kib.append(len(pickle.dumps(item)) / 1024)

fig, (ax1, ax2) = matplotlib.pyplot.subplots(1,
                                             2,
                                             figsize=(10, 4),
                                             layout='constrained')
ax1.loglog(compressions, numpy.array(max_error) * 100, 'o-')
ax1.set_xlabel('Compression')
ax1.set_ylabel('Maximum relative error (%)')
ax1.set_title('Accuracy of the quantiles')
ax1.grid(True, which='both', alpha=0.3)
ax2.loglog(compressions, size_kib, 'o-', color='tab:orange')
ax2.set_xlabel('Compression')
ax2.set_ylabel('Size of the digest (KiB)')
ax2.set_title(f'Memory, for {stream.size:,} values')
ax2.grid(True, which='both', alpha=0.3)
Accuracy of the quantiles, Memory, for 200,000 values

Total running time of the script: (0 minutes 1.442 seconds)

Gallery generated by Sphinx-Gallery