Three filters, one decision: what do you want to preserve?
| Filter | Kind | Support | Preserves | Destroys |
|---|---|---|---|---|
moving_average | linear | finite, symmetric | slow trends | edges, spikes |
exponential_moving_average | linear | infinite, causal | streaming latency | phase |
median_filter | non-linear | finite, symmetric | edges | fine texture |
Moving average¶
With window length and :
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¶
Expanding the recursion shows the weights decay geometrically into the past:
mp.exponential_moving_average([1.0, 2.0, 3.0], alpha=0.5)
# [1.0, 1.5, 2.25]is the identity; smaller 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.
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 gonemp.moving_average([1.0, 1.0, 99.0, 1.0, 1.0], window=3)
# [1.0, 33.67, 33.67, 33.67, 1.0] ← spike smeared over 3 samplesThe 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] ← rampedThe 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.
| Strategy | Extension | Output length | Use when |
|---|---|---|---|
Padding.EDGE (default) | repeat first/last value | len(series) | the signal is roughly flat at the ends |
Padding.REFLECT | mirror without repeating endpoints | len(series) | the ends are oscillatory rather than monotone |
Padding.ZERO | pad with 0.0 | len(series) | the signal is genuinely mean-zero |
Padding.NONE | no extension | len(series) - window + 1 | you’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 shorterPadding 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:
Start with the smallest odd window that spans the noise correlation length.
Check that the residual (input minus output) looks like noise, not like signal. If the residual has structure, the window is too wide.
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.