From 38cbe2c12e08f6c5b9f67b995d4216f6de82e577 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Wed, 5 Jul 2023 14:08:15 +0200 Subject: [PATCH] [MRG] Fix import error when torch is not installed. (#463) Co-authored-by: Yann Cabanes Co-authored-by: Romain Tavenard --- CHANGELOG.md | 13 +- azure-pipelines.yml | 41 +++ requirements.txt | 1 - requirements_nocast.txt | 1 - tslearn/backend/__init__.py | 4 - tslearn/backend/pytorch_backend.py | 475 +++++++++++++++-------------- tslearn/tests/test_estimators.py | 5 +- tslearn/tests/test_metrics.py | 35 +-- 8 files changed, 314 insertions(+), 261 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d88941996..e5dd1579d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,18 @@ and this project adheres to Changelogs for this project are recorded in this file since v0.2.0. -## [Towards v0.6] + + +## [Towards v0.6.2] + +## [v0.6.1] + +### Fixed + +* Fixed an import error when `torch` is not installed. This error appeared in tslearn v0.6. +`PyTorch` is now an optional dependency. + +## [v0.6.0] ### Added diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 72752d413..2dcedfb08 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -14,6 +14,43 @@ jobs: python.version: '3.8' Python39: python.version: '3.9' + variables: + OMP_NUM_THREADS: '2' + NUMBA_NUM_THREADS: '2' + PIP_PREFER_BINARY: 'true' + + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '$(python.version)' + displayName: 'Use Python $(python.version)' + + - script: | + set -xe + python --version + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install torch + displayName: 'Install dependencies' + + - script: | + set -xe + python -m pip install -e . + displayName: 'Install tslearn' + + - script: | + set -xe + python -m pip install pytest pytest-azurepipelines + python -m pip install scikit-learn==1.0 + python -m pip install tensorflow==2.9.0 + python -m pytest -v tslearn/ --doctest-modules + displayName: 'Test' + +- job: 'linux_without_torch' + pool: + vmImage: 'ubuntu-latest' + strategy: + matrix: Python310: python.version: '3.10' variables: @@ -32,6 +69,7 @@ jobs: python --version python -m pip install --upgrade pip python -m pip install -r requirements.txt + python -m pip uninstall torch displayName: 'Install dependencies' - script: | @@ -72,6 +110,7 @@ jobs: python --version python -m pip install --upgrade pip python -m pip install -r requirements.txt + python -m pip install torch displayName: 'Install dependencies' - script: | @@ -124,6 +163,7 @@ jobs: export OPENBLAS=$(brew --prefix openblas) python -m pip install --upgrade pip python -m pip install -r requirements.txt + python -m pip install torch displayName: 'Install dependencies' - script: | @@ -169,6 +209,7 @@ jobs: python --version python -m pip install --upgrade pip python -m pip install -r requirements_nocast.txt + python -m pip install torch displayName: 'Install dependencies' - script: | diff --git a/requirements.txt b/requirements.txt index dac7d0fd1..b23ded6e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ scipy scikit-learn joblib>=0.12 tensorflow>=2 -torch pandas cesium h5py diff --git a/requirements_nocast.txt b/requirements_nocast.txt index c7d1c127b..270f87f5d 100644 --- a/requirements_nocast.txt +++ b/requirements_nocast.txt @@ -4,5 +4,4 @@ scipy scikit-learn joblib>=0.12 tensorflow>=2 -torch h5py diff --git a/tslearn/backend/__init__.py b/tslearn/backend/__init__.py index 4dcfcaaf4..172c60788 100755 --- a/tslearn/backend/__init__.py +++ b/tslearn/backend/__init__.py @@ -4,13 +4,9 @@ """ from .backend import Backend, instantiate_backend, select_backend -from .numpy_backend import NumPyBackend -from .pytorch_backend import PyTorchBackend __all__ = [ "Backend", "instantiate_backend", "select_backend", - "NumPyBackend", - "PyTorchBackend", ] diff --git a/tslearn/backend/pytorch_backend.py b/tslearn/backend/pytorch_backend.py index 0019f6316..0b6079378 100755 --- a/tslearn/backend/pytorch_backend.py +++ b/tslearn/backend/pytorch_backend.py @@ -15,234 +15,247 @@ """ import numpy as _np -import torch as _torch - - -class PyTorchBackend(object): - """Class for the PyTorch backend.""" - - def __init__(self): - self.backend_string = "pytorch" - - self.linalg = PyTorchLinalg() - self.random = PyTorchRandom() - self.testing = PyTorchTesting() - - self.int8 = _torch.int8 - self.int16 = _torch.int16 - self.int32 = _torch.int32 - self.int64 = _torch.int64 - self.float32 = _torch.float32 - self.float64 = _torch.float64 - self.complex64 = _torch.complex64 - self.complex128 = _torch.complex128 - - self.abs = _torch.abs - self.any = _torch.any - self.arange = _torch.arange - self.argmax = _torch.argmax - self.argmin = _torch.argmin - self.dbl_max = _torch.finfo(_torch.double).max - self.ceil = _torch.ceil - self.diag = _torch.diag - self.empty = _torch.empty - self.exp = _torch.exp - self.eye = _torch.eye - self.floor = _torch.floor - self.full = _torch.full - self.hstack = _torch.hstack - self.inf = _torch.inf - self.is_array = _torch.is_tensor - self.isfinite = _torch.isfinite - self.isnan = _torch.isnan - self.log = _torch.log - self.max = _torch.max - self.mean = _torch.mean - self.median = _torch.median - self.min = _torch.min - self.nan = _torch.nan - self.pairwise_euclidean_distances = _torch.cdist - self.reshape = _torch.reshape - self.round = _torch.round - self.sum = _torch.sum - self.vstack = _torch.vstack - self.zeros = _torch.zeros - self.zeros_like = _torch.zeros_like - - @staticmethod - def all(x, axis=None): - if not _torch.is_tensor(x): - x = _torch.tensor(x) - if axis is None: - return x.bool().all() - if isinstance(axis, int): - return _torch.all(x.bool(), axis) - if len(axis) == 1: - return _torch.all(x, *axis) - axis = list(axis) - for i_axis, one_axis in enumerate(axis): - if one_axis < 0: - axis[i_axis] = x.ndim() + one_axis - new_axis = tuple(k - 1 if k >= 0 else k for k in axis[1:]) - return all(_torch.all(x.bool(), axis[0]), new_axis) - - def array(self, val, dtype=None): - if _torch.is_tensor(val): - if dtype is None or val.dtype == dtype: - return val.clone() - return self.cast(val, dtype=dtype) - - elif isinstance(val, _np.ndarray): - tensor = self.from_numpy(val) - if dtype is not None and tensor.dtype != dtype: - tensor = self.cast(tensor, dtype=dtype) - - return tensor - - elif isinstance(val, (list, tuple)) and len(val): - tensors = [self.array(tensor, dtype=dtype) for tensor in val] - return _torch.stack(tensors) - - return _torch.tensor(val, dtype=dtype) - - def cast(self, x, dtype): - if _torch.is_tensor(x): - return x.to(dtype=dtype) - return self.array(x, dtype=dtype) - - @staticmethod - def cdist(x, y, metric="euclidean", p=None): - if metric == "euclidean": - return _torch.cdist(x, y) - if metric == "sqeuclidean": - return _torch.cdist(x, y) ** 2 - if metric == "minkowski": - return _torch.cdist(x, y, p=p) - raise ValueError(f"Metric {metric} not implemented in PyTorch backend.") - - @staticmethod - def copy(x): - return x.clone() - - @staticmethod - def from_numpy(x): - return _torch.from_numpy(x) - - @staticmethod - def iscomplex(x): - if isinstance(x, complex): - return True - return x.dtype.is_complex - - @staticmethod - def is_float(x): - if isinstance(x, float): - return True - return x.dtype.is_floating_point - - @staticmethod - def is_float32(x): - return isinstance(x, _torch.float32) - - @staticmethod - def is_float64(x): - return isinstance(x, _torch.float64) - - @staticmethod - def ndim(x): - return x.dim() - - @staticmethod - def pairwise_distances(X, Y=None, metric="euclidean"): - if Y is None: - Y = X - if metric == "euclidean": - return _torch.cdist(X, Y) - if metric == "sqeuclidean": - return _torch.cdist(X, Y) ** 2 - if callable(metric): - distance_matrix = _torch.zeros(X.shape[0], Y.shape[0]) - for i in range(X.shape[0]): - for j in range(Y.shape[0]): - distance_matrix[i, j] = metric(X[i, ...], Y[j, ...]) - return distance_matrix - raise ValueError(f"Metric {metric} not implemented in PyTorch backend.") - - @staticmethod - def pdist(x, metric="euclidean", p=None): - if metric == "euclidean": - return _torch.pdist(x) - if metric == "sqeuclidean": - return _torch.pdist(x) ** 2 - if metric == "minkowski": - return _torch.pdist(x, p=p) - raise ValueError(f"Metric {metric} not implemented in PyTorch backend.") - - def shape(self, data): - if not self.is_array(data): - data = self.array(data) - return tuple(_torch.Tensor.size(data)) - - def sqrt(self, x, out=None): - if not self.is_array(x): - x = self.array(x) - return _torch.sqrt(x, out=out) - - @staticmethod - def to_numpy(x): - return x.detach().cpu().numpy() - - @staticmethod - def tril(mat, k=0): - return _torch.tril(mat, diagonal=k) - - @staticmethod - def tril_indices(n, k=0, m=None): - if m is None: - m = n - x = _torch.tril_indices(row=n, col=m, offset=k) - return x[0], x[1] - - @staticmethod - def triu(mat, k=0): - return _torch.triu(mat, diagonal=k) - - @staticmethod - def triu_indices(n, k=0, m=None): - if m is None: - m = n - x = _torch.triu_indices(row=n, col=m, offset=k) - return x[0], x[1] - - -class PyTorchLinalg: - def __init__(self): - self.inv = _torch.linalg.inv - self.norm = _torch.linalg.norm - - -class PyTorchRandom: - def __init__(self): - self.rand = _torch.rand - self.randint = _torch.randint - self.randn = _torch.randn - - @staticmethod - def normal(loc=0.0, scale=1.0, size=(1,)): - if not hasattr(size, "__iter__"): - size = (size,) - return _torch.normal(mean=loc, std=scale, size=size) - - @staticmethod - def uniform(low=0.0, high=1.0, size=(1,), dtype=None): - if not hasattr(size, "__iter__"): - size = (size,) - if low >= high: - raise ValueError("Upper bound must be higher than lower bound") - return (high - low) * _torch.rand(*size, dtype=dtype) + low - - -class PyTorchTesting: - def __init__(self): - self.assert_allclose = _torch.allclose - self.assert_equal = _torch.testing.assert_close + +try: + import torch as _torch + + HAS_TORCH = True +except ImportError: + HAS_TORCH = False + + +if not HAS_TORCH: + + class PyTorchBackend: + def __init__(self): + raise ValueError( + "Could not use the PyTorch backend since torch is not installed" + ) + +else: + + class PyTorchBackend(object): + """Class for the PyTorch backend.""" + + def __init__(self): + self.backend_string = "pytorch" + + self.linalg = PyTorchLinalg() + self.random = PyTorchRandom() + self.testing = PyTorchTesting() + + self.int8 = _torch.int8 + self.int16 = _torch.int16 + self.int32 = _torch.int32 + self.int64 = _torch.int64 + self.float32 = _torch.float32 + self.float64 = _torch.float64 + self.complex64 = _torch.complex64 + self.complex128 = _torch.complex128 + + self.abs = _torch.abs + self.any = _torch.any + self.arange = _torch.arange + self.argmax = _torch.argmax + self.argmin = _torch.argmin + self.dbl_max = _torch.finfo(_torch.double).max + self.ceil = _torch.ceil + self.diag = _torch.diag + self.empty = _torch.empty + self.exp = _torch.exp + self.eye = _torch.eye + self.floor = _torch.floor + self.full = _torch.full + self.hstack = _torch.hstack + self.inf = _torch.inf + self.is_array = _torch.is_tensor + self.isfinite = _torch.isfinite + self.isnan = _torch.isnan + self.log = _torch.log + self.max = _torch.max + self.mean = _torch.mean + self.median = _torch.median + self.min = _torch.min + self.nan = _torch.nan + self.pairwise_euclidean_distances = _torch.cdist + self.reshape = _torch.reshape + self.round = _torch.round + self.sum = _torch.sum + self.vstack = _torch.vstack + self.zeros = _torch.zeros + self.zeros_like = _torch.zeros_like + + @staticmethod + def all(x, axis=None): + if not _torch.is_tensor(x): + x = _torch.tensor(x) + if axis is None: + return x.bool().all() + if isinstance(axis, int): + return _torch.all(x.bool(), axis) + if len(axis) == 1: + return _torch.all(x, *axis) + axis = list(axis) + for i_axis, one_axis in enumerate(axis): + if one_axis < 0: + axis[i_axis] = x.ndim() + one_axis + new_axis = tuple(k - 1 if k >= 0 else k for k in axis[1:]) + return all(_torch.all(x.bool(), axis[0]), new_axis) + + def array(self, val, dtype=None): + if _torch.is_tensor(val): + if dtype is None or val.dtype == dtype: + return val.clone() + return self.cast(val, dtype=dtype) + + elif isinstance(val, _np.ndarray): + tensor = self.from_numpy(val) + if dtype is not None and tensor.dtype != dtype: + tensor = self.cast(tensor, dtype=dtype) + + return tensor + + elif isinstance(val, (list, tuple)) and len(val): + tensors = [self.array(tensor, dtype=dtype) for tensor in val] + return _torch.stack(tensors) + + return _torch.tensor(val, dtype=dtype) + + def cast(self, x, dtype): + if _torch.is_tensor(x): + return x.to(dtype=dtype) + return self.array(x, dtype=dtype) + + @staticmethod + def cdist(x, y, metric="euclidean", p=None): + if metric == "euclidean": + return _torch.cdist(x, y) + if metric == "sqeuclidean": + return _torch.cdist(x, y) ** 2 + if metric == "minkowski": + return _torch.cdist(x, y, p=p) + raise ValueError(f"Metric {metric} not implemented in PyTorch backend.") + + @staticmethod + def copy(x): + return x.clone() + + @staticmethod + def from_numpy(x): + return _torch.from_numpy(x) + + @staticmethod + def iscomplex(x): + if isinstance(x, complex): + return True + return x.dtype.is_complex + + @staticmethod + def is_float(x): + if isinstance(x, float): + return True + return x.dtype.is_floating_point + + @staticmethod + def is_float32(x): + return isinstance(x, _torch.float32) + + @staticmethod + def is_float64(x): + return isinstance(x, _torch.float64) + + @staticmethod + def ndim(x): + return x.dim() + + @staticmethod + def pairwise_distances(X, Y=None, metric="euclidean"): + if Y is None: + Y = X + if metric == "euclidean": + return _torch.cdist(X, Y) + if metric == "sqeuclidean": + return _torch.cdist(X, Y) ** 2 + if callable(metric): + distance_matrix = _torch.zeros(X.shape[0], Y.shape[0]) + for i in range(X.shape[0]): + for j in range(Y.shape[0]): + distance_matrix[i, j] = metric(X[i, ...], Y[j, ...]) + return distance_matrix + raise ValueError(f"Metric {metric} not implemented in PyTorch backend.") + + @staticmethod + def pdist(x, metric="euclidean", p=None): + if metric == "euclidean": + return _torch.pdist(x) + if metric == "sqeuclidean": + return _torch.pdist(x) ** 2 + if metric == "minkowski": + return _torch.pdist(x, p=p) + raise ValueError(f"Metric {metric} not implemented in PyTorch backend.") + + def shape(self, data): + if not self.is_array(data): + data = self.array(data) + return tuple(_torch.Tensor.size(data)) + + def sqrt(self, x, out=None): + if not self.is_array(x): + x = self.array(x) + return _torch.sqrt(x, out=out) + + @staticmethod + def to_numpy(x): + return x.detach().cpu().numpy() + + @staticmethod + def tril(mat, k=0): + return _torch.tril(mat, diagonal=k) + + @staticmethod + def tril_indices(n, k=0, m=None): + if m is None: + m = n + x = _torch.tril_indices(row=n, col=m, offset=k) + return x[0], x[1] + + @staticmethod + def triu(mat, k=0): + return _torch.triu(mat, diagonal=k) + + @staticmethod + def triu_indices(n, k=0, m=None): + if m is None: + m = n + x = _torch.triu_indices(row=n, col=m, offset=k) + return x[0], x[1] + + class PyTorchLinalg: + def __init__(self): + self.inv = _torch.linalg.inv + self.norm = _torch.linalg.norm + + class PyTorchRandom: + def __init__(self): + self.rand = _torch.rand + self.randint = _torch.randint + self.randn = _torch.randn + + @staticmethod + def normal(loc=0.0, scale=1.0, size=(1,)): + if not hasattr(size, "__iter__"): + size = (size,) + return _torch.normal(mean=loc, std=scale, size=size) + + @staticmethod + def uniform(low=0.0, high=1.0, size=(1,), dtype=None): + if not hasattr(size, "__iter__"): + size = (size,) + if low >= high: + raise ValueError("Upper bound must be higher than lower bound") + return (high - low) * _torch.rand(*size, dtype=dtype) + low + + class PyTorchTesting: + def __init__(self): + self.assert_allclose = _torch.allclose + self.assert_equal = _torch.testing.assert_close diff --git a/tslearn/tests/test_estimators.py b/tslearn/tests/test_estimators.py index cf50e43d1..08108e38d 100644 --- a/tslearn/tests/test_estimators.py +++ b/tslearn/tests/test_estimators.py @@ -80,8 +80,11 @@ def _get_all_classes(): '(and tensorflow) are probably not ' 'installed!') continue + elif name.endswith('pytorch_backend'): + # pytorch is likely not installed + continue else: - raise + raise Exception('Could not import module %s' % name) all_classes.extend(inspect.getmembers(module, inspect.isclass)) return all_classes diff --git a/tslearn/tests/test_metrics.py b/tslearn/tests/test_metrics.py index 688e270e5..3b66def2c 100644 --- a/tslearn/tests/test_metrics.py +++ b/tslearn/tests/test_metrics.py @@ -3,21 +3,23 @@ import numpy as np import pytest -import torch -from scipy.spatial.distance import cdist - import tslearn.clustering import tslearn.metrics +from scipy.spatial.distance import cdist from tslearn.backend.backend import Backend from tslearn.metrics.dtw_variants import dtw_path -from tslearn.metrics.soft_dtw_loss_pytorch import _SoftDTWLossPyTorch from tslearn.utils import to_time_series __author__ = "Romain Tavenard romain.tavenard[at]univ-rennes2.fr" +try: + import torch + backends = ["numpy", "pytorch"] +except ImportError: + backends = ["numpy"] + def test_dtw(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) # dtw_path @@ -52,7 +54,6 @@ def test_dtw(): def test_ctw(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) # ctw_path @@ -92,7 +93,6 @@ def test_ctw(): def test_ldtw(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) @@ -140,7 +140,6 @@ def test_ldtw(): def test_lcss(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) sim = tslearn.metrics.lcss([1, 2, 3], [1.0, 2.0, 2.0, 3.0], be=be) @@ -157,7 +156,6 @@ def test_lcss(): def test_lcss_path(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) path, sim = tslearn.metrics.lcss_path( @@ -186,7 +184,6 @@ def test_lcss_path(): def test_lcss_path_from_metric(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) for d in be.arange(1, 5): @@ -225,7 +222,6 @@ def sqeuclidean(x, y): def test_constrained_paths(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) n, d = 10, 3 @@ -266,7 +262,6 @@ def test_constrained_paths(): def test_dtw_subseq(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) @@ -284,7 +279,6 @@ def test_dtw_subseq(): def test_dtw_subseq_path(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) subseq, longseq = [1, 4], [1.0, 2.0, 2.0, 3.0, 4.0] @@ -300,7 +294,6 @@ def test_dtw_subseq_path(): def test_masks(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) sk_mask = tslearn.metrics.sakoe_chiba_mask(4, 4, 1, be=be) @@ -407,7 +400,6 @@ def test_masks(): def test_gak(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) # GAK @@ -463,7 +455,6 @@ def test_gak(): reason="Test failing for MacOS with python3.9 (Segmentation fault)", ) def test_gamma_soft_dtw(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) dataset = be.array([[1, 2, 2, 3], [1.0, 2.0, 3.0, 4.0]]) @@ -479,7 +470,6 @@ def test_gamma_soft_dtw(): reason="Test failing for MacOS with python3.9 (Segmentation fault)", ) def test_symmetric_cdist(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) rng = np.random.RandomState(0) @@ -505,7 +495,6 @@ def test_symmetric_cdist(): def test_lb_keogh(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) ts1 = [1, 2, 3, 2, 1] @@ -519,7 +508,6 @@ def test_lb_keogh(): def test_dtw_path_from_metric(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) rng = np.random.RandomState(0) @@ -557,7 +545,6 @@ def sqeuclidean(x, y): def test_softdtw(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) rng = np.random.RandomState(0) @@ -578,7 +565,6 @@ def test_softdtw(): def test_dtw_path_with_empty_or_nan_inputs(): - backends = ["numpy", "pytorch"] for backend in backends: be = Backend(backend) s1 = be.zeros((3, 10)) @@ -598,9 +584,14 @@ def test_dtw_path_with_empty_or_nan_inputs(): == "One of the input time series contains only nans or has zero length." ) - +@pytest.mark.skipif( + len(backends) == 1, + reason="Skipping test that requires pytorch backend", +) def test_soft_dtw_loss_pytorch(): """Tests for the class SoftDTWLossPyTorch.""" + from tslearn.metrics.soft_dtw_loss_pytorch import _SoftDTWLossPyTorch + b = 5 m = 10 n = 12