Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Changelogs for this project are recorded in this file since v0.2.0.
probing for classification. Requires PyTorch.
* soft-dtw related tools now support Itakura and Sakoe-Chiba global constraints. ([#189](https://github.com/tslearn-team/tslearn/issues/189))
* Multithreading support added to `cdist_soft_dtw` and `softdtw_barycenter`. ([#310](https://github.com/tslearn-team/tslearn/issues/310))
* `TimeSeriesScalerMinMax` and `TimeSeriesScalerMeanVariance` now offer an inverse_transform method
to reverse the normalization. ([#697](https://github.com/tslearn-team/tslearn/issues/697))
* Added `tslearn.clustering.silhouette_samples` to compute per-sample silhouette coefficients with time-series metrics. ([#451](https://github.com/tslearn-team/tslearn/issues/451))

### Removed
Expand Down
31 changes: 30 additions & 1 deletion tests/test_preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import pytest

from tslearn.generators import random_walks
from tslearn.preprocessing import (TimeSeriesScalerMeanVariance,
TimeSeriesScalerMinMax,
TimeSeriesImputer)

from tslearn.utils import to_time_series_dataset, to_time_series


Expand All @@ -19,6 +19,11 @@ def test_single_value_ts_no_nan():
assert np.sum(np.isnan(minmax_scaler.fit_transform(X))) == 0


def test_min_max_scaler_range():
with pytest.raises(ValueError):
TimeSeriesScalerMinMax((1., 0.)).fit_transform([[1, 2, 3]])


def test_min_max_scaler_variable_length():
X = [
[1, np.nan],
Expand All @@ -40,6 +45,30 @@ def test_min_max_scaler_variable_length():
np.array([[[0], [0.5], [1]]])
)


@pytest.mark.parametrize(
"scaler",
[TimeSeriesScalerMinMax, TimeSeriesScalerMeanVariance]
)
@pytest.mark.parametrize(
"per_timeseries, per_feature",
[(True, True), (True, False), (False, True), (False, False)]
)
def test_scaler_inverse_transform(scaler, per_timeseries, per_feature):
X = random_walks(10, 10, 2, mu=1, random_state=0)

estimator = scaler(per_timeseries=per_timeseries, per_feature=per_feature)
transformed = estimator.fit_transform(X)
if per_timeseries:
with pytest.raises(RuntimeError):
estimator.inverse_transform(X)
else:
np.testing.assert_array_almost_equal(
estimator.inverse_transform(transformed),
X
)


def test_min_max_scaler_modes():
univariate_dataset = [
[1, 2, 3],
Expand Down
128 changes: 113 additions & 15 deletions tslearn/preprocessing/preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,20 +140,31 @@ def __sklearn_tags__(self):

class TimeSeriesScalerMinMax(TimeSeriesMixin, TransformerMixin, BaseEstimator):
"""Scaler for time series datasets.
When `per_timeseries` is False, scales features based on computation led on the fitted data,
so that their span in given dimensions is between ``min`` and ``max`` where ``value_range=(min, max)``.
The transformation is stateless otherwise, dealing with each timeseries individually.

Parameters
----------
value_range : tuple (default: (0., 1.))
The minimum and maximum value for the output time series.
per_timeseries: bool (default: True)
per_timeseries : bool (default: True)
Wether the scaling should be performed per time series.
per_feature: bool (default: True)
When `per_timeseries` is False, scales features based on computation led on the fitted data,
so that their span in given dimensions is between ``min`` and ``max`` where ``value_range=(min, max)``.
The transformation is stateless otherwise, dealing with each timeseries individually.
per_feature : bool (default: True)
Wether the scaling should be performed per feature.
Meaningless for univariate timeseries.

Attributes
----------
min_ : array-like of shape=(1, 1, d or 1)
The miminum value(s) seen in data and used for normalization.
Only available when `per_timeseries` is False.
The shape depends on the `per_feature` parameters.
max_ : array-like of shape=(1, 1, d or 1)
The maximum value(s) seen in data and used for normalization.
Only available when `per_timeseries` is False.
The shape depends on the `per_feature` parameters.

Notes
-----
NaNs within a time series are ignored when calculating min and max.
Expand Down Expand Up @@ -203,9 +214,15 @@ def fit(self, X, y=None, **kwargs):
self._X_fit_dims = X_.shape
self.n_features_in_ = self._X_fit_dims[-1]

if not self.per_timeseries:
self.min_, self.max_ = self._process(X_)
# Reset if needed
if hasattr(self, 'min_'):
del self.min_
if hasattr(self, 'max_'):
del self.max_

if not self.per_timeseries:
min_, max_ = self._process(X_)
self.min_, self.max_ = min_.reshape((-1,)), max_.reshape((-1,))
return self

def fit_transform(self, X, y=None, **kwargs):
Expand Down Expand Up @@ -248,7 +265,7 @@ def transform(self, X, y=None, **kwargs):

Returns
-------
numpy.ndarray
array-like of shape (n_ts, sz, d)
Rescaled time series dataset.
"""
if self.value_range[0] >= self.value_range[1]:
Expand All @@ -260,14 +277,45 @@ def transform(self, X, y=None, **kwargs):
X_ = to_time_series_dataset(X_)
X_ = check_dims(X_, X_fit_dims=self._X_fit_dims, check_n_features_only=True, extend=False)

min_, max_ = self._process(X_) if self.per_timeseries else (self.min_, self.max_)
min_, max_ = (self._process(X_)
if self.per_timeseries else (self.min_.reshape(1, 1, -1), self.max_.reshape(1, 1, -1)))

range_t = max_ - min_
range_t[range_t == 0.] = 1.
nomin = (X_ - min_) * (self.value_range[1] - self.value_range[0])
X_ = nomin / range_t + self.value_range[0]
return X_

def inverse_transform(self, X):
"""
Undo the scaling of X based on fitted data.
Only available when per_timeseries is False, raises RuntimeError otherwise.

Parameters
----------
X : array-like of shape (n_ts, sz, d)
Input dataset.

Returns
-------
array-like of shape (n_ts, sz, d)
Transformed dataset.
"""
check_is_fitted(self, '_X_fit_dims')
if self.per_timeseries:
raise RuntimeError("Cannot inverse per timeseries scaling.")

X_ = check_array(X, allow_nd=True, force_all_finite=False)
X_ = to_time_series_dataset(X_)
X_ = check_dims(X_, X_fit_dims=self._X_fit_dims, check_n_features_only=True, extend=False)

min_, max_ = self.min_.reshape(1, 1, -1), self.max_.reshape(1, 1, -1)

X_ -= self.value_range[0]
X_ *= (max_ - min_) / (self.value_range[1] - self.value_range[0])
X_ += min_
return X_

def _more_tags(self):
tags = super()._more_tags()
tags.update({'allow_nan': True, ALLOW_VARIABLE_LENGTH: True})
Expand All @@ -282,22 +330,34 @@ def __sklearn_tags__(self):

class TimeSeriesScalerMeanVariance(TimeSeriesMixin, TransformerMixin, BaseEstimator):
"""Scaler for time series datasets.
When `per_timeseries` is False, scales features based on computation led on the fitted data,
so that their mean (resp. standard deviation) in given dimensions is mu (resp. std).
The transformation is stateless otherwise, dealing with each timeseries individually.

Parameters
----------
mu : float (default: 0.)
Mean of the output time series.
std : float (default: 1.)
Standard deviation of the output time series.
per_timeseries: bool (default: True)
per_timeseries : bool (default: True)
Whether the scaling should be performed per time series.
per_feature: bool (default: True)
When `per_timeseries` is False, scales features based on computation led on the fitted data,
so that their mean (resp. standard deviation) in given dimensions is mu (resp. std).
The transformation is stateless otherwise, dealing with each timeseries individually.
per_feature : bool (default: True)
Whether the scaling should be performed per feature.
Meaningless for univariate timeseries.

Attributes
----------
mean_ : array-like of shape=(1, 1, d or 1)
The mean value(s) seen in input data and used for normalization.
Only available when `per_timeseries` is False.
The shape depends on the `per_feature` parameters.

std_ : array-like of shape=(1, 1, d or 1)
The standard deviation values seen in input data and used for normalization.
Only available when `per_timeseries` is False.
The shape depends on the `per_feature` parameters.

Notes
-----
NaNs within a time series are ignored when calculating mu and std.
Expand Down Expand Up @@ -347,6 +407,12 @@ def fit(self, X, y=None, **kwargs):
self._X_fit_dims = X_.shape
self.n_features_in_ = self._X_fit_dims[-1]

# Reset if needed
if hasattr(self, 'mean_'):
del self.mean_
if hasattr(self, 'std_'):
del self.std_

if not self.per_timeseries:
self.mean_, self.std_ = self._process(X_)

Expand Down Expand Up @@ -402,11 +468,43 @@ def transform(self, X, y=None, **kwargs):
X_ = to_time_series_dataset(X_)
X_ = check_dims(X_, X_fit_dims=self._X_fit_dims, check_n_features_only=True, extend=False)

mean_, std_ = self._process(X_) if self.per_timeseries else (self.mean_, self.std_)
mean_, std_ = self._process(X_) if self.per_timeseries else (self.mean_.reshape(1, 1, -1),
self.std_.reshape(1, 1, -1))

X_ = (X_ - mean_) * self.std / std_ + self.mu
return X_

def inverse_transform(self, X):
"""
Undo the scaling of X based on fitted data.
Only available when per_timeseries is False, raises RuntimeError otherwise.

Parameters
----------
X : array-like of shape (n_ts, sz, d)
Input dataset.

Returns
-------
array-like of shape (n_ts, sz, d)
Transformed dataset.
"""

check_is_fitted(self, '_X_fit_dims')
if self.per_timeseries:
raise RuntimeError("Cannot inverse per timeseries scaling.")

X_ = check_array(X, allow_nd=True, force_all_finite=False)
X_ = to_time_series_dataset(X_)
X_ = check_dims(X_, X_fit_dims=self._X_fit_dims, check_n_features_only=True, extend=False)

mean_, std_ = self.mean_.reshape(1, 1, -1), self.std_.reshape(1, 1, -1)

X_ += self.mu
X_ *= std_ / self.std
X_ += mean_
return X_

def _more_tags(self):
tags = super()._more_tags()
tags.update({'allow_nan': True, ALLOW_VARIABLE_LENGTH: True})
Expand Down