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
4 changes: 4 additions & 0 deletions aeon/classification/distance_based/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
__all__ = [
"ElasticEnsemble",
"KNeighborsTimeSeriesClassifier",
"NearestCentroidClassifier",
"ProximityTree",
"ProximityForest",
]

from aeon.classification.distance_based._elastic_ensemble import ElasticEnsemble
from aeon.classification.distance_based._nearest_centroid import (
NearestCentroidClassifier,
)
from aeon.classification.distance_based._proximity_forest import ProximityForest
from aeon.classification.distance_based._proximity_tree import ProximityTree
from aeon.classification.distance_based._time_series_neighbors import (
Expand Down
163 changes: 163 additions & 0 deletions aeon/classification/distance_based/_nearest_centroid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Nearest centroid classifier for time series."""

__maintainer__ = []
__all__ = ["NearestCentroidClassifier"]

import numpy as np

from aeon.classification.base import BaseClassifier
from aeon.clustering.averaging import elastic_barycenter_average, mean_average
from aeon.clustering.averaging._ba_utils import VALID_SOFT_BA_METHODS
from aeon.distances import pairwise_distance
from aeon.utils.validation import check_n_jobs


class NearestCentroidClassifier(BaseClassifier):
"""Nearest centroid (Rocchio) classifier for time series.

Computes a single centroid per class by averaging that class's training
series, then classifies a new series by assigning it to the nearest class
centroid under a chosen (elastic) distance.

The centroid is the elastic barycentre of the class series. For an ordinary
distance this is a discrete barycentre average (DTW Barycentre Averaging,
DBA, when ``distance="dtw"``); for a soft distance (``"soft_dtw"`` /
``"soft_msm"``) it is the gradient-based soft barycentre. ``"mean"`` uses a
plain arithmetic mean.

Parameters
----------
distance : str, default="dtw"
Distance used to assign test series to the nearest centroid and, for
barycentre averaging, to compute the centroids. See
:func:`aeon.distances.pairwise_distance` for valid options.
average_method : str or None, default=None
How class centroids are computed. One of ``"mean"``, ``"petitjean"``,
``"subgradient"``, ``"kasba"`` or ``"soft"``. If ``None`` (default), it
resolves to ``"soft"`` for a soft ``distance`` and ``"petitjean"`` (DBA)
otherwise. A soft ``distance`` requires ``average_method="soft"`` (the
default resolves to it automatically); pairing a soft distance with a
non-soft averaging method, or vice versa, raises ``ValueError``.
distance_params : dict or None, default=None
Keyword arguments for the distance (e.g. ``{"window": 0.2}`` for DTW or
``{"gamma": 0.1}`` for soft distances), used for both averaging and
nearest-centroid assignment.
average_params : dict or None, default=None
Keyword arguments forwarded to the barycentre averaging (e.g.
``{"max_iters": 50}``). Ignored when ``average_method="mean"``.
n_jobs : int, default=1
The number of jobs to run in parallel. ``-1`` means using all
processors.
random_state : int, np.random.RandomState instance or None, default=None
Seed forwarded to the barycentre averaging. Only affects the stochastic
averaging methods (``"subgradient"``, ``"kasba"``) and random
initialisation; ignored by ``"mean"`` and deterministic ``"petitjean"``
with a non-random init. Do not also set ``random_state`` in
``average_params``.

Attributes
----------
classes_ : np.ndarray
The class labels, ordered as the centroids.
centroids_ : np.ndarray of shape (n_classes, n_channels, n_timepoints)
The per-class centroids.

See Also
--------
KNeighborsTimeSeriesClassifier : Distance-based nearest neighbours classifier.

Examples
--------
>>> import numpy as np
>>> from aeon.classification.distance_based import NearestCentroidClassifier
>>> X = np.array([[[1.0, 2, 3, 4, 5]], [[1.0, 2, 3, 4, 6]],
... [[8.0, 7, 6, 5, 4]], [[8.0, 7, 6, 5, 3]]])
>>> y = np.array([0, 0, 1, 1])
>>> clf = NearestCentroidClassifier(
... distance="euclidean", average_method="mean"
... ).fit(X, y)
>>> clf.predict(np.array([[[1.0, 2, 3, 4, 5]]]))
array([0])
"""

_tags = {
"capability:multivariate": True,
"capability:multithreading": True,
"algorithm_type": "distance",
"X_inner_type": "numpy3D",
}

def __init__(
self,
distance: str = "dtw",
average_method: str | None = None,
distance_params: dict | None = None,
average_params: dict | None = None,
n_jobs: int = 1,
random_state: int | None = None,
):
self.distance = distance
self.average_method = average_method
self.distance_params = distance_params
self.average_params = average_params
self.n_jobs = n_jobs
self.random_state = random_state
super().__init__()

def _fit(self, X, y):
self._check_params()
self.classes_ = np.unique(y)
self.centroids_ = np.zeros((len(self.classes_), X.shape[1], X.shape[2]))

for i, label in enumerate(self.classes_):
class_X = X[y == label]
if self._average_method == "mean":
self.centroids_[i] = mean_average(class_X)
else:
self.centroids_[i] = elastic_barycenter_average(
class_X,
distance=self.distance,
method=self._average_method,
n_jobs=self._n_jobs,
random_state=self.random_state,
**self._average_params,
**self._distance_params,
)
return self

def _predict(self, X) -> np.ndarray:
pairwise_matrix = pairwise_distance(
X,
self.centroids_,
method=self.distance,
n_jobs=self._n_jobs,
**self._distance_params,
)
return self.classes_[pairwise_matrix.argmin(axis=1)]

def _check_params(self):
self._n_jobs = check_n_jobs(self.n_jobs)
self._distance_params = self.distance_params or {}
self._average_params = self.average_params or {}

is_soft = self.distance in VALID_SOFT_BA_METHODS
if self.average_method is None:
self._average_method = "soft" if is_soft else "petitjean"
else:
self._average_method = self.average_method
if is_soft and self._average_method != "soft":
raise ValueError(
f"distance={self.distance!r} is a soft distance and can only "
"be averaged with average_method='soft', got "
f"average_method={self.average_method!r}."
)
if not is_soft and self._average_method == "soft":
raise ValueError(
"average_method='soft' requires a soft distance, one of "
f"{VALID_SOFT_BA_METHODS}, got distance={self.distance!r}."
)

@classmethod
def _get_test_params(cls, parameter_set: str = "default") -> dict:
"""Return testing parameter settings for the estimator."""
return {"distance": "euclidean", "average_method": "mean"}
107 changes: 107 additions & 0 deletions aeon/classification/distance_based/tests/test_nearest_centroid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Tests for NearestCentroidClassifier."""

import numpy as np
import pytest

from aeon.classification.distance_based import NearestCentroidClassifier
from aeon.testing.data_generation import make_example_3d_numpy


def _data(n_cases=10, n_channels=1, random_state=1):
return make_example_3d_numpy(
n_cases=n_cases,
n_channels=n_channels,
n_timepoints=12,
n_labels=2,
random_state=random_state,
)


def test_fit_sets_one_centroid_per_class():
"""``fit`` builds one centroid per class with the series shape."""
X, y = _data(n_channels=2)
clf = NearestCentroidClassifier(distance="euclidean", average_method="mean").fit(
X, y
)
assert set(clf.classes_) == set(np.unique(y))
assert clf.centroids_.shape == (len(np.unique(y)), 2, 12)
assert np.all(np.isfinite(clf.centroids_))


def test_predict_returns_known_labels():
"""``predict`` returns labels drawn from the training classes."""
X, y = _data()
X_test, _ = _data(n_cases=5, random_state=2)
clf = NearestCentroidClassifier(distance="euclidean", average_method="mean").fit(
X, y
)
preds = clf.predict(X_test)
assert preds.shape == (5,)
assert set(preds).issubset(set(clf.classes_))


@pytest.mark.parametrize("distance", ["soft_dtw", "soft_msm"])
def test_soft_distance_auto_promotes_to_soft_averaging(distance):
"""A soft distance with the default ``average_method`` uses soft averaging."""
X, y = _data()
clf = NearestCentroidClassifier(distance=distance, distance_params={"gamma": 0.1})
clf.fit(X, y)
assert clf._average_method == "soft"
assert np.all(np.isfinite(clf.centroids_))


def test_soft_distance_with_hard_averaging_raises():
"""Explicitly pairing a soft distance with non-soft averaging raises."""
X, y = _data()
with pytest.raises(ValueError, match="soft distance"):
NearestCentroidClassifier(distance="soft_dtw", average_method="petitjean").fit(
X, y
)


def test_soft_averaging_with_hard_distance_raises():
"""``average_method='soft'`` with a non-soft distance raises."""
X, y = _data()
with pytest.raises(ValueError, match="requires a soft distance"):
NearestCentroidClassifier(distance="dtw", average_method="soft").fit(X, y)


def test_default_average_method_is_dba_for_hard_distance():
"""The default ``average_method`` resolves to DBA (petitjean) for ``dtw``."""
X, y = _data()
clf = NearestCentroidClassifier(distance="dtw").fit(X, y)
assert clf._average_method == "petitjean"


def test_predict_multivariate():
"""``fit`` and ``predict`` work end-to-end on multivariate series."""
X, y = _data(n_channels=3)
X_test, _ = _data(n_cases=4, n_channels=3, random_state=2)
clf = NearestCentroidClassifier(distance="dtw", average_method="mean").fit(X, y)
preds = clf.predict(X_test)
assert preds.shape == (4,)
assert set(preds).issubset(set(clf.classes_))


def test_random_state_is_reproducible():
"""The same ``random_state`` gives identical centroids for stochastic averaging."""
X, y = _data()
clf1 = NearestCentroidClassifier(
distance="dtw", average_method="subgradient", random_state=42
).fit(X, y)
clf2 = NearestCentroidClassifier(
distance="dtw", average_method="subgradient", random_state=42
).fit(X, y)
assert np.allclose(clf1.centroids_, clf2.centroids_)


def test_mean_and_dba_give_different_centroids():
"""Mean and DBA averaging produce different centroids for warped data."""
X, y = _data()
mean_clf = NearestCentroidClassifier(distance="dtw", average_method="mean").fit(
X, y
)
dba_clf = NearestCentroidClassifier(distance="dtw", average_method="petitjean").fit(
X, y
)
assert not np.allclose(mean_clf.centroids_, dba_clf.centroids_)
1 change: 1 addition & 0 deletions docs/api_reference/classification.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Distance-based

ElasticEnsemble
KNeighborsTimeSeriesClassifier
NearestCentroidClassifier
ProximityForest
ProximityTree

Expand Down
Loading