Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions aeon/transformations/series/smoothing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"DiscreteFourierApproximation",
"ExponentialSmoothing",
"GaussianFilter",
"LoessSmoother",
"MovingAverage",
"SavitzkyGolayFilter",
"RecursiveMedianSieve",
Expand All @@ -12,6 +13,7 @@
from aeon.transformations.series.smoothing._dfa import DiscreteFourierApproximation
from aeon.transformations.series.smoothing._exp_smoothing import ExponentialSmoothing
from aeon.transformations.series.smoothing._gauss import GaussianFilter
from aeon.transformations.series.smoothing._loess import LoessSmoother
from aeon.transformations.series.smoothing._moving_average import MovingAverage
from aeon.transformations.series.smoothing._rms import RecursiveMedianSieve
from aeon.transformations.series.smoothing._sg import SavitzkyGolayFilter
99 changes: 99 additions & 0 deletions aeon/transformations/series/smoothing/_loess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""LOESS Smoothing (Locally Estimated Scatterplot Smoothing)."""

__maintainer__ = []
__all__ = ["LoessSmoother"]

import numpy as np

from aeon.transformations.series.base import BaseSeriesTransformer


class LoessSmoother(BaseSeriesTransformer):
"""Locally Estimated Scatterplot Smoothing (LOESS).

A non-parametric regression technique that fits a smooth curve through a
time series. For each point in the series, a polynomial is fitted to a
subset of the data (local window) using weighted least squares.

Parameters
----------
span : float, default=0.5
The fraction of the data to use for the local window.
Must be between 0 and 1. For example, 0.5 means 50% of the data
is used for each local fit.
degree : int, default=1
The degree of the local polynomial to fit.
1 = Linear (standard LOESS), 2 = Quadratic.

Examples
--------
>>> import numpy as np
>>> from aeon.transformations.series.smoothing import LoessSmoother
>>> X = np.array([-3, -2, -1, 0, 1, 2, 3])
>>> transformer = LoessSmoother(span=0.5, degree=1)
>>> transformer.fit_transform(X)
array([[-3., -2., -1., 0., 1., 2., 3.]])

References
----------
[1] Cleveland, W. S. (1979). Robust locally weighted regression and
smoothing scatterplots. Journal of the American statistical association,
74(368), 829-836.
"""

_tags = {
"capability:multivariate": True,
"X_inner_type": "np.ndarray",
"fit_is_empty": True,
}

def __init__(self, span: float = 0.5, degree: int = 1) -> None:
self.span = span
self.degree = degree
super().__init__(axis=1)

if not (0 < self.span <= 1):
raise ValueError(f"span must be in (0, 1], but got {self.span}")
if self.degree not in [1, 2]:
raise ValueError(f"degree must be 1 or 2, but got {self.degree}")

def _transform(self, X, y=None):
if X.ndim == 1:
X = X.reshape(1, -1)

n_channels, n_timepoints = X.shape
Xt = np.zeros_like(X, dtype=float)

if n_timepoints <= self.degree:
return X

k = int(np.ceil(self.span * n_timepoints))
k = max(2, min(k, n_timepoints))

for i in range(n_timepoints):
distances = np.abs(np.arange(n_timepoints) - i)
sorted_indices = np.argpartition(distances, k - 1)[:k]

local_dists = distances[sorted_indices]

d_max = local_dists.max()
if d_max == 0:
d_max = 1.0

weights = (1 - (local_dists / d_max) ** 3) ** 3
weights[weights < 0] = 0

local_t = sorted_indices - i

H = np.vander(local_t, N=self.degree + 1, increasing=True)
W = np.diag(weights)
A = H.T @ W @ H

for c in range(n_channels):
local_y = X[c, sorted_indices]
b = H.T @ W @ local_y

beta, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
Xt[c, i] = beta[0]

return Xt
98 changes: 98 additions & 0 deletions aeon/transformations/series/smoothing/tests/test_loess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Tests for LoessSmoother."""

import numpy as np

from aeon.transformations.series.smoothing._loess import LoessSmoother

DATA_LINEAR = np.arange(10, dtype=float)

DATA_QUAD = np.arange(10, dtype=float) ** 2

DATA_MULTI = np.array([np.arange(10, dtype=float), np.ones(10, dtype=float) * 5])


def test_loess_linear_recovery():
"""Test that LOESS (degree=1) recovers a straight line perfectly."""
loess = LoessSmoother(span=0.4, degree=1)
xt = loess.fit_transform(DATA_LINEAR)

assert xt.shape == (1, DATA_LINEAR.shape[0])

np.testing.assert_array_almost_equal(xt[0], DATA_LINEAR, decimal=5)


def test_loess_quadratic_recovery():
"""Test that LOESS (degree=2) recovers a quadratic curve perfectly."""
loess = LoessSmoother(span=0.5, degree=2)
xt = loess.fit_transform(DATA_QUAD)

np.testing.assert_array_almost_equal(xt[0], DATA_QUAD, decimal=5)


def test_multivariate_execution():
"""Test that multivariate inputs are handled correctly per channel."""
loess = LoessSmoother(span=0.5, degree=1)
xt = loess.fit_transform(DATA_MULTI)

assert xt.shape == DATA_MULTI.shape
np.testing.assert_array_almost_equal(xt[0], DATA_MULTI[0], decimal=5)
np.testing.assert_array_almost_equal(xt[1], DATA_MULTI[1], decimal=5)


def test_span_extremes():
"""Test execution with extreme span values."""
loess_full = LoessSmoother(span=1.0, degree=1)
xt_full = loess_full.fit_transform(DATA_LINEAR)
np.testing.assert_array_almost_equal(xt_full[0], DATA_LINEAR)

loess_small = LoessSmoother(span=0.01, degree=1)
xt_small = loess_small.fit_transform(DATA_LINEAR)
np.testing.assert_array_almost_equal(xt_small[0], DATA_LINEAR)


def test_smoothing_effectiveness():
"""Test that LOESS actually reduces variance (smooths noise)."""
x = np.linspace(0, 4 * np.pi, 100)
y_clean = np.sin(x)

rng = np.random.default_rng(42)
noise = rng.normal(0, 0.5, size=x.shape)
y_noisy = y_clean + noise

smoother = LoessSmoother(span=0.2, degree=1)
y_smoothed = smoother.fit_transform(y_noisy)[0]

mse_noisy = np.mean((y_noisy - y_clean) ** 2)
mse_smoothed = np.mean((y_smoothed - y_clean) ** 2)

assert mse_smoothed < mse_noisy, "LOESS failed to reduce noise variance."


def test_constant_preservation():
"""Test that a constant line remains constant."""
X = np.ones(50) * 10
smoother = LoessSmoother(span=0.5, degree=1)
Xt = smoother.fit_transform(X)[0]

np.testing.assert_array_almost_equal(Xt, X)


def test_short_series_fallback():
"""Test that very short series are returned as-is without crashing."""
X = np.array([1.0, 2.0])
smoother = LoessSmoother(span=0.5, degree=2)
Xt = smoother.fit_transform(X)[0]

np.testing.assert_array_equal(Xt, X)


def test_outlier_insensitivity():
"""LOESS should be somewhat less sensitive to single spikes than a global fit."""
x = np.linspace(0, 10, 20)
y = 2 * x + 1
y[10] = 50
smoother = LoessSmoother(span=0.3, degree=1)
y_smooth = smoother.fit_transform(y)[0]

assert np.abs(y_smooth[0] - y[0]) < 1.0
assert np.abs(y_smooth[-1] - y[-1]) < 1.0
1 change: 1 addition & 0 deletions docs/changelogs/v1.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ September 2025
- [ENH] New experimental module: imbalance in collection transformers ({pr}`2498`) {user}`TonyBagnall`
- [ENH] Improvements to ST transformer and classifier ({pr}`2968`) {user}`MatthewMiddlehurst`
- [ENH] Deprecate MatrixProfile collection transformer and MPDist ({pr}`3002`) {user}`TonyBagnall`
- [ENH] Implement LOESS Smoother (Locally Estimated Scatterplot Smoothing) ({pr}`3133`) {user}`Nithurshen`
- [ENH] Refactor signature code ({pr}`2943`) {user}`TonyBagnall`
- [ENH] Implement of ESMOTE for imbalanced classification problems ({pr}`2971`) {user}`LinGinQiu`
- [ENH] Change seed to random_state ({pr}`3031`) {user}`TonyBagnall`
Expand Down