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

Three filters, one decision: what do you want to preserve?

FilterKindSupportPreservesDestroys
moving_averagelinearfinite, symmetricslow trendsedges, spikes
exponential_moving_averagelinearinfinite, causalstreaming latencyphase
median_filternon-linearfinite, symmetricedgesfine texture

Moving average

With window length ww and l=⌊w/2⌋l = \lfloor w/2 \rfloor:

yi=1w∑k=−lw−1−lxi+ky_i = \frac{1}{w} \sum_{k=-l}^{w - 1 - l} x_{i+k}

This is a convolution with a rectangular kernel. In the frequency domain that kernel is a sinc, so it does not cleanly separate “signal” from “noise” — it attenuates some high frequencies and inverts others. It is nonetheless the right default: cheap, unbiased on a locally linear trend, and easy to reason about.

import mypackage as mp

mp.moving_average([1.0, 2.0, 3.0, 4.0, 5.0], window=3)
# [1.333..., 2.0, 3.0, 4.0, 4.666...]

A window of 1 is the identity, and the window may not exceed the series length.

Exponential moving average

y0=x0,yi=αxi+(1−α) yi−1y_0 = x_0, \qquad y_i = \alpha x_i + (1 - \alpha)\, y_{i-1}

Expanding the recursion shows the weights decay geometrically into the past:

yi=α∑k=0i−1(1−α)kxi−k+(1−α)ix0y_i = \alpha \sum_{k=0}^{i-1} (1-\alpha)^k x_{i-k} + (1-\alpha)^i x_0
mp.exponential_moving_average([1.0, 2.0, 3.0], alpha=0.5)
# [1.0, 1.5, 2.25]

α=1\alpha = 1 is the identity; smaller α\alpha smooths harder.

Median filter

The median has a breakdown point of 50%: up to half the samples in a window can be arbitrarily corrupted without moving the output at all. The mean has a breakdown point of 0 — a single infinite sample takes the whole window with it.

Median filter
Moving average
mp.median_filter([1.0, 1.0, 99.0, 1.0, 1.0], window=3)
# [1.0, 1.0, 1.0, 1.0, 1.0]     ← spike gone

The same property makes it preserve step edges that a mean filter blurs:

step = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]

mp.median_filter(step, 3)     # [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]  ← sharp
mp.moving_average(step, 3)    # [0.0, 0.0, 0.33, 0.67, 1.0, 1.0] ← ramped

The trade-off is that the median is non-linear, so it does not commute with scaling and addition, and it erases genuine fine texture along with the noise.

Boundary handling

Any finite-support filter needs values past the ends of the series. Padding makes that choice explicit instead of hiding it.

StrategyExtensionOutput lengthUse when
Padding.EDGE (default)repeat first/last valuelen(series)the signal is roughly flat at the ends
Padding.REFLECTmirror without repeating endpointslen(series)the ends are oscillatory rather than monotone
Padding.ZEROpad with 0.0len(series)the signal is genuinely mean-zero
Padding.NONEno extensionlen(series) - window + 1you’d rather have fewer, honest samples
from mypackage import Padding

mp.moving_average([0.0, 0.0, 9.0], window=3, padding=Padding.ZERO)
# [0.0, 3.0, 3.0]

mp.moving_average([1.0, 2.0, 3.0, 4.0, 5.0], window=3, padding="none")
# [2.0, 3.0, 4.0]      ← two samples shorter

Padding is a StrEnum, so the plain strings "edge", "reflect", "zero", and "none" work anywhere the enum does — handy for CLI flags and config files.

Choosing a window

There is no universal answer, but a serviceable rule:

  1. Start with the smallest odd window that spans the noise correlation length.

  2. Check that the residual (input minus output) looks like noise, not like signal. If the residual has structure, the window is too wide.

  3. If isolated spikes survive, you want the median filter, not a wider mean.

The smoothing deep dive notebook walks through this with plots and a quantitative error sweep.

API

Full signatures live in the smoothing API reference.