diff --git a/aeon/transformations/series/smoothing/__init__.py b/aeon/transformations/series/smoothing/__init__.py index 52ebcc3c8e..cc08d0db77 100644 --- a/aeon/transformations/series/smoothing/__init__.py +++ b/aeon/transformations/series/smoothing/__init__.py @@ -4,6 +4,7 @@ "DiscreteFourierApproximation", "ExponentialSmoothing", "GaussianFilter", + "LoessSmoother", "MovingAverage", "SavitzkyGolayFilter", "RecursiveMedianSieve", @@ -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 diff --git a/aeon/transformations/series/smoothing/_loess.py b/aeon/transformations/series/smoothing/_loess.py new file mode 100644 index 0000000000..fe565e5dab --- /dev/null +++ b/aeon/transformations/series/smoothing/_loess.py @@ -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 diff --git a/aeon/transformations/series/smoothing/tests/test_loess.py b/aeon/transformations/series/smoothing/tests/test_loess.py new file mode 100644 index 0000000000..58ef608ba3 --- /dev/null +++ b/aeon/transformations/series/smoothing/tests/test_loess.py @@ -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 diff --git a/docs/changelogs/v1.3.md b/docs/changelogs/v1.3.md index eae8566a67..aa7f12fc49 100644 --- a/docs/changelogs/v1.3.md +++ b/docs/changelogs/v1.3.md @@ -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`