Smoothing
smooth() extracts a trend line from a noisy numeric column.
pond-ts ships three algorithms; this page is the place to figure out
which one to pick.
- TypeScript
- Rust
const trend = cpu.smooth('cpu', 'ema', { alpha: 0.3, output: 'cpuTrend' });
// trend.schema gains { cpuTrend, number? } — source 'cpu' passes through.
use pond_rs::{Column, Smooth, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// One reading a minute for twelve minutes.
let cpu = TimeSeries::new(
"cpu",
(0..12).map(|i| i * 60_000).collect(),
vec![Column::required(
"cpu",
vec![
0.30, 0.52, 0.41, 0.88, 0.62, 0.25, 0.71, 0.80, 0.49, 0.43, 0.66, 0.58,
],
)],
)?;
let trend = cpu.smooth("cpu", &Smooth::ema(0.3).output("cpuTrend"))?;
// trend gains an optional cpuTrend column; the source cpu passes through.
let names: Vec<&str> = trend.columns().iter().map(|c| c.name()).collect();
assert_eq!(names, ["cpu", "cpuTrend"]);
assert!(!trend.column("cpuTrend")?.is_required());
// 0.3 * 0.52 + 0.7 * 0.30
assert_eq!(trend.column("cpuTrend")?.numbers()?.get(1), Some(0.366));
Ok(())
}
In Rust the method and its options are one value: Smooth::ema(alpha), then .output(name) and the rest. Smooth::ema_span(20.0) gives the finance-style "20-bar EMA" (alpha = 2 / (span + 1)).
A real noisy source — 600 days of sunspot counts (grey) — smoothed three ways over the same data: EMA (reactive, lags), moving average (centered, stable, but rounds the peaks), and LOESS (follows the shape). Toggle each on or off; slide to smooth more or less. Watch a sharp peak: LOESS tracks it, the others round it off. Source: SILSO, Royal Observatory of Belgium.
The three algorithms
EMA — exponential moving average
Each output is alpha * raw + (1 - alpha) * previous. The bigger
alpha, the more reactive (less smoothing).
cpu.smooth('cpu', 'ema', { alpha: 0.3, output: 'cpuTrend' });
What it actually does: every previous value contributes, with weight
that decays geometrically. Recent values dominate; older values
fade. Streaming-friendly — the math is O(1) per event with one
state variable, so it works incrementally over a LiveSeries (write
the closure inside live.map; see
Live transforms → EMA).
The first ~1/alpha rows are noisy as the smoother seeds on the
first raw value and converges. Use warmup: N to drop those:
- TypeScript
- Rust
cpu.smooth('cpu', 'ema', { alpha: 0.3, warmup: 4 });
// length === source.length - 4
use pond_rs::{Column, Smooth, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// One reading a minute for twelve minutes.
let cpu = TimeSeries::new(
"cpu",
(0..12).map(|i| i * 60_000).collect(),
vec![Column::required(
"cpu",
vec![
0.30, 0.52, 0.41, 0.88, 0.62, 0.25, 0.71, 0.80, 0.49, 0.43, 0.66, 0.58,
],
)],
)?;
let trend = cpu.smooth("cpu", &Smooth::ema(0.3).warmup(4))?;
// The first 4 rows are dropped.
assert_eq!(trend.len(), cpu.len() - 4);
assert_eq!(trend.key().begin()[0], 240_000);
assert_eq!(trend.column("cpu")?.numbers()?.get(0), Some(0.556608));
Ok(())
}
Moving average — windowed arithmetic mean
- TypeScript
- Rust
cpu.smooth('cpu', 'movingAverage', {
window: '5m',
alignment: 'centered', // 'trailing' | 'leading' | 'centered'
output: 'cpuAvg',
});
use pond_rs::{Alignment, Column, Smooth, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// One reading a minute for twelve minutes.
let cpu = TimeSeries::new(
"cpu",
(0..12).map(|i| i * 60_000).collect(),
vec![Column::required(
"cpu",
vec![
0.30, 0.52, 0.41, 0.88, 0.62, 0.25, 0.71, 0.80, 0.49, 0.43, 0.66, 0.58,
],
)],
)?;
let avg = cpu.smooth(
"cpu",
&Smooth::moving_average("5m")
.alignment(Alignment::Centered) // Trailing | Leading | Centered
.output("cpuAvg"),
)?;
// At minute 5 the window [2.5m, 7.5m) holds minutes 3 to 7.
assert_eq!(
avg.column("cpuAvg")?.numbers()?.get(5),
Some(0.6519999999999999)
);
Ok(())
}
What it actually does: at each event, average every value inside the window. Predictable lag (centered alignment removes phase shift; trailing introduces a half-window lag). Equal weighting of every sample inside the window — older and newer data treated the same, which makes it more stable than EMA but less reactive to recent trends.
LOESS — locally weighted regression
- TypeScript
- Rust
cpu.smooth('cpu', 'loess', {
span: 0.25, // fraction of series — smaller = more local
output: 'cpuLoess',
});
use pond_rs::{Column, Smooth, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// 40 noisy readings with an upward drift.
let cpu = TimeSeries::new(
"cpu",
(0..40).map(|i| i * 60_000).collect(),
vec![Column::required(
"cpu",
(0..40)
.map(|i| ((i * 7) % 11) as f64 / 10.0 + i as f64 * 0.02)
.collect::<Vec<_>>(),
)],
)?;
// span: the fraction of the series each fit uses; smaller is more local.
let fit = cpu.smooth("cpu", &Smooth::loess(0.25).output("cpuLoess"))?;
let raw = cpu.column("cpu")?.numbers()?;
let smooth = fit.column("cpuLoess")?.numbers()?;
// A jump from 0.48 to 1.2 in the raw values; the fit barely moves.
assert_eq!(
(raw.get(19), raw.get(20)),
(Some(0.48), Some(1.2000000000000002))
);
assert_eq!(smooth.get(19), Some(0.8434140976273988));
assert_eq!(smooth.get(20), Some(0.8365859023726016));
Ok(())
}
Giving a method an option it does not take (warmup on a loess, say) is an error when the code runs; pond-ts's types catch it before.
What it actually does: fits a low-degree polynomial through the neighborhood of each point, weighted by distance. High shape fidelity — preserves curves the other two flatten. Cost is per- point regression; not a streaming fit. Use it offline when the shape of the trend matters more than the speed.
Comparison
| Aspect | EMA | Moving average | LOESS |
|---|---|---|---|
| Cost per output | O(1) | O(window size)* | O(span × N) |
| Streaming-friendly | ✅ | with care | ❌ (offline) |
| Reactivity to recent | weighted recent | equal in window | local fit |
| Lag | none, but skewed | half-window (trailing) / 0 (centered) | none |
| Shape fidelity | low | medium | high |
| Tuning knob | alpha | window | span |
| When to pick | streaming dashboard | "average of last N min" semantics | offline analysis where curves matter |
* O(1) amortized via sliding-window deque under
rolling('avg').
Picking one
- Live dashboard, "give me the trend now": EMA,
alpha≈ 0.2–0.4. - Reporting "average over the last hour" with predictable bucketing: moving average.
- Offline analysis, exploratory plots, where the shape of bumps matters: LOESS.
If you don't know yet: start with EMA, alpha: 0.3, and adjust if
the trend feels too noisy or too slow.
Output column
All three smoothers take an optional output parameter:
- Omitted — the source column is replaced in place.
- String — a new column is appended; the source column passes through unchanged. Useful when you want both the raw and smoothed series for an overlay plot.
Further reading
- Wikipedia — Moving average for the EMA / simple / weighted MA family.
- Wikipedia — Local regression for LOESS / LOWESS.
For rolling stats other than smoothing — windowed sums, percentiles,
counts — reach for rolling() directly.