Nearest-neighbour time-series classification under elastic distances.
Classes
class algorithms.timeseries.classification.knn.DTWNeighborsClassifier(TimeSeriesClassifier)
Nearest-neighbour classification under Dynamic Time Warping.
__init__( self, n_neighbors: int = 1, window: Optional[float] = 0.1, weights: str = 'uniform', )
Overview
- Store the training panel; there is no model to fit.
- For a query series, bound its distance to every training series with
- Compute full DTW only for candidates whose bound could still win,
-
Vote among the
knearest labels.
Theory
DTW finds the alignment minimising the accumulated cost
over a monotone path from (1,1) to (n,m), returning \sqrt{D(n, m)}. Unconstrained, that is O(nm) per pair and permits degenerate alignments where one point absorbs half the other series. A Sakoe-Chiba band of half-width w restricts |i - j| \leq w, which cuts the cost and — because those degenerate paths are usually wrong — typically raises accuracy. A band of about 10% of the series length is the standard starting point.
The search cost is what usually rules DTW out, and what this implementation attacks. LB_Keogh gives a lower bound in O(n); sorting candidates by it and skipping any whose bound already exceeds the running k-th best removes most of the DTW computations entirely. On 60 queries against 400 training series of length 100, the pruned search is 12.6x faster than building the full distance matrix, and returns identical neighbours.
Parameters
n_neighbors
1 is the classical and usually strongest setting; larger values help only on noisy labels.
window
(0, 1] is a fraction of the series length, an int a step count, None unconstrained. Defaults to 0.1.
weights
Attributes
X_train_
y_train_
classes_
fit.
Notes
Complexity. Fitting is O(1). Prediction is worst-case O(m n L w) for m queries, n training series of length L and band w, but pruning removes most of it in practice. Memory is O(n L). The distance, bound and search all run in the shared C++ kernel tuiml._cpp_ext.timeseries.
When to use. Use DTW-kNN as the baseline you must beat before believing any fancier time-series classifier, and as a strong final model on small datasets. Its cost grows with the training set, so beyond a few thousand series prefer a transform-based method. Series should be z-normalised per instance unless absolute level is genuinely meaningful: without it DTW mostly measures offset.
Warning — warping invariance is not always what you want. DTW is deliberately blind to when things happen. If the classes differ by timing, that blindness discards the only signal there is, and a plain Euclidean nearest neighbour beats it outright. Two synthetic problems, both 160 train / 160 test, length 100:
================================== ========== =============== ========== problem DTW (10%) Euclidean 1NN RandomForest ================================== ========== =============== ========== classes differ by shape, 1.000 0.969 0.925 randomly warped and shifted classes differ by peak timing 0.812 1.000 0.994 ================================== ========== =============== ==========
So the question to ask before reaching for DTW is not "is this a time series?" but "would a human still call these the same class if one were played faster?" If yes, DTW; if the timing is the label, use a plain classifier on the raw values.
The band is close to free accuracy. On the shape problem above, a 10% band scored within a point of unconstrained DTW while running 20x faster (49 ms against 982 ms), and widening it bought nothing. A band forbids some optimal warping paths, so it is not guaranteed never to cost a borderline series — but the compute it saves is large and the accuracy it costs is close to noise. Start at 10% and only widen if a held-out score says to.
Multivariate panels use dependent DTW — one warping path shared across channels — which suits synchronised channels and not independently warping ones.
References
See Also
>>> import numpy as np
>>> from tuiml.algorithms.timeseries.classification import DTWNeighborsClassifier
>>> rng = np.random.default_rng(0)
>>> t = np.linspace(0, 4 * np.pi, 60)
>>> # Two classes: sine and sawtooth, each with a random phase shift.
>>> shifts = rng.uniform(0, 2 * np.pi, 80)
>>> sines = np.sin(t + shifts[:40, None])
>>> saws = ((t + shifts[40:, None]) % (2 * np.pi)) / np.pi - 1.0
>>> X = np.vstack([sines, saws])
>>> y = np.array([0] * 40 + [1] * 40)
>>> model = DTWNeighborsClassifier(n_neighbors=1, window=0.1).fit(X, y)
>>> float((model.predict(X) == y).mean())
1.0
Methods
fit
(self, X: np.ndarray, y: np.ndarray) -> 'DTWNeighborsClassifier'
fit
(self, X: np.ndarray, y: np.ndarray) -> 'DTWNeighborsClassifier'
Store the training panel.
Parameters
X
y
Returns
self
kneighbors
(self, X: np.ndarray) -> tuple
kneighbors
(self, X: np.ndarray) -> tuple
Return the nearest training series of each query.
Parameters
X
Returns
distances
indices
predict_proba
(self, X: np.ndarray) -> np.ndarray
predict_proba
(self, X: np.ndarray) -> np.ndarray
Return the neighbour vote share for each class.
Parameters
X
Returns
proba