Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Smoothing deep dive — filters, boundaries, and window selection

Open In Colab

Smoothing is a bias/variance trade made explicit: every filter buys a reduction in noise by paying with distortion of the signal. This notebook makes that trade measurable — we generate data with a known latent truth, sweep the filter parameters, and decompose the error into the part caused by leftover noise and the part caused by the filter itself.

What you’ll learn:

  1. Why the moving average, the exponential filter, and the median filter fail in completely different ways.

  2. How boundary padding creates artefacts, and how to choose a strategy.

  3. How to pick a window length from data rather than by eye.

  4. How to read the residual to tell whether you have over-smoothed.

Background

Model the observations as a latent signal plus independent noise,

xi=f(ti)+εi,εi∼N(0,σ2).x_i = f(t_i) + \varepsilon_i, \qquad \varepsilon_i \sim \mathcal{N}(0, \sigma^2).

A linear filter with weights wkw_k produces yi=∑kwkxi+ky_i = \sum_k w_k x_{i+k}. Its mean-square error against the truth splits cleanly in two:

E[(yi−f(ti))2]=(∑kwkf(ti+k)−f(ti))2⏟bias2  (signal distortion)+σ2∑kwk2⏟variance  (leftover noise).\mathbb{E}\bigl[(y_i - f(t_i))^2\bigr] = \underbrace{\Bigl(\sum_k w_k f(t_{i+k}) - f(t_i)\Bigr)^2}_{\text{bias}^2 \;(\text{signal distortion})} + \underbrace{\sigma^2 \sum_k w_k^2}_{\text{variance} \;(\text{leftover noise})}.

For an equally weighted window of length ww, ∑kwk2=1/w\sum_k w_k^2 = 1/w: the variance term falls as 1/w1/w, while the bias term grows with the curvature of ff over the window. The optimum is wherever those two curves cross, and that is what we will measure directly.

The median filter is non-linear, so this decomposition does not apply to it at all — which is exactly why it behaves so differently at edges and spikes.

Setup

Python implementation: CPython
Python version       : 3.12.3
IPython version      : 9.10.0

numpy     : 2.4.4
matplotlib: 3.11.0
mypackage : 0.1.6

Compiler    : GCC 13.3.0
OS          : Linux
Release     : 6.18.44-fc-v37
Machine     : x86_64
Processor   : x86_64
CPU cores   : 4
Architecture: 64bit

Test signals

Three signals, each designed to break a different filter: a smooth sine (punishes wide windows), a square wave (punishes linear filters at edges), and a flat line with isolated spikes (punishes the mean filter specifically).

<Figure size 1800x500 with 3 Axes>

Error metric

We score every filter by root-mean-square error against the known truth, computed with mypackage itself.

Filter comparison

Same window for the two finite-support filters, and an exponential factor chosen to give a comparable effective memory.

signal                raw     mean   median     ewma
----------------------------------------------------
smooth sine        0.2926   0.0733   0.0971   0.2058
square wave        0.2780   0.3110   0.1574   0.5078
spikes on flat     0.6921   0.1825   0.0200   0.1810

The ranking flips completely between signals. On the smooth sine the mean filter wins, because averaging is the maximum-likelihood estimator for Gaussian noise around a locally linear signal. On the square wave and the spikes it loses badly — and the median filter, which is worse on the sine, wins by a wide margin.

<Figure size 1800x500 with 3 Axes>

Edge preservation

Zooming in on a single transition of the square wave shows the mechanism. The mean filter ramps linearly across the discontinuity over the full window width. The median filter, whose output is an order statistic rather than a weighted sum, keeps the edge sharp until the window is more than half past it.

<Figure size 1200x500 with 1 Axes>

Spike rejection

The median has a breakdown point of 50%: up to half the samples in a window can be arbitrarily corrupted without moving the output. The mean has a breakdown point of zero — one sample is enough.

<Figure size 1200x500 with 1 Axes>
peak |error|, mean filter   : 0.4357
peak |error|, median filter : 0.0658
each spike is smeared over 15 samples by the mean filter

Boundary handling

Finite-support filters need values past the ends of the series, and there is no correct answer — only a choice you should make deliberately. Padding makes it explicit.

edge      len= 20 first: [  1.86,   2.43,   3.14] last: [ 17.86,  18.57,  19.14]
zero      len= 20 first: [  1.43,   2.14,   3.00] last: [ 15.00,  12.86,  10.57]
reflect   len= 20 first: [  2.71,   2.86,   3.29] last: [ 17.71,  18.14,  18.29]
none      len= 14 first: [  4.00,   5.00,   6.00] last: [ 15.00,  16.00,  17.00]

truth     len= 20 first: [  1.00,   2.00,   3.00] last: [ 18.00,  19.00,  20.00]

A pure ramp separates the strategies cleanly. Edge padding repeats the end value, so it assumes the signal is locally flat and biases the ends toward the last observation. Reflect mirrors the series, which preserves texture but reverses the slope — on a linear trend that puts the padded values on the wrong side of the truth, giving exactly twice the edge error. Zero padding is in a different league of wrong: it drags the ends toward the origin by a margin that looks exactly like a real trend.

<Figure size 1200x500 with 1 Axes>

Quantifying the boundary error on the first and last window // 2 samples, where padding is the only thing that differs:

padding      edge RMSE   interior RMSE
--------------------------------------
edge            0.5594          0.0000
zero            4.7581          0.0000
reflect         1.1188          0.0000

The interior is identical for every strategy, as it must be — padding only ever affects the first and last window // 2 samples. Reflect’s edge error is exactly twice edge padding’s here, which is the signature of slope reversal on a linear signal; reflect earns its keep on oscillatory ends, where repeating a single value is the worse lie. Zero padding is an order of magnitude worse than either and should be reserved for genuinely mean-zero signals.

When those end samples actually matter, the honest option is Padding.NONE: a shorter series beats invented data.

Choosing a window

Sweeping the window length traces out the bias/variance trade directly. The variance term falls as 1/w1/w; the bias term grows with the curvature of the signal over the window. The minimum is where they cross.

<Figure size 1200x500 with 1 Axes>

The smooth sine has a clear interior optimum around w = 17: wide enough to average away the noise, narrow enough not to flatten the peaks. The square wave bottoms out at the narrowest window tested, because every extra sample drags the filter further across a discontinuity — there is no width at which averaging helps a step edge.

The spike signal is the interesting one: its curve never turns around, it just keeps falling. A mean filter can only suppress an outlier by spreading it thinner over an ever-wider window, so the error decreases monotonically with width. That looks like a win here only because the truth is perfectly flat, so an arbitrarily wide window costs nothing. On any signal with real structure, the window needed to hide a spike would destroy the signal along with it — which is why the fix is a different filter, not a bigger number.

<Figure size 1200x500 with 1 Axes>
best mean-filter RMSE   : 0.0841
best median-filter RMSE : 0.0127

Reading the residual

In practice you do not have the truth, so you cannot plot those curves. What you do have is the residual, input minus output. If the filter is well chosen, the residual is structureless noise. If it has visible structure, the filter has eaten signal.

<Figure size 1800x500 with 3 Axes>

The residual you want has a standard deviation close to the true noise level σ\sigma and no visible structure. At window=3 it sits below σ\sigma — the filter is passing a good part of the noise straight through to its output. At window=15 it is close to σ\sigma. At window=101 it is well above σ\sigma and the sine is plainly visible in the plot: the filter is now removing signal, not noise.

A cheap numerical companion to the eyeball test is the lag-1 autocorrelation of the residual. It starts negative, because subtracting a local mean that includes the sample itself induces an anti-correlation, and it climbs as leaked signal starts to dominate. The crossing into positive territory is the warning sign.

  window   residual std   lag-1 autocorr
----------------------------------------
       3         0.2507          -0.6860
       7         0.2778          -0.2306
      15         0.2836          -0.1435
      31         0.2953          -0.0679
      61         0.3392           0.1940
     101         0.4852           0.6046

true noise std: 0.3000

Both diagnostics agree. The residual standard deviation crosses the true noise level of 0.30 and the lag-1 autocorrelation crosses zero between window=31 and window=61 — right where the measured RMSE curve says the optimum (w = 17) is long past. Two numbers you can compute without knowing the truth, pointing at the same conclusion as one you cannot.

Summary

  1. The filter matters more than its parameter. On spiky data no window length rescues a mean filter, while a median filter of almost any width works.

  2. The trade-off is measurable. With a known truth, sweep the window and find the RMSE minimum; the variance term falls as 1/w1/w while the bias term grows with signal curvature.

  3. Padding only touches the ends, but it touches them a lot. edge assumes flatness, reflect reverses slope (exactly twice the edge error on a ramp) but preserves texture, and zero fabricates a trend unless the signal really is mean-zero. When the ends matter, Padding.NONE and a shorter series is the honest option.

  4. Without a truth, read the residual. A residual standard deviation near the noise level with a still-negative lag-1 autocorrelation means a good fit; a residual above the noise level with positive autocorrelation means you are removing signal.

Everything above is computed with mypackage alone — NumPy only generates the test data.