diff --git a/.github/ISSUE_TEMPLATE/feature_request.md "b/.github/ISSUE_TEMPLATE/\360\237\222\241feature-request.md" similarity index 84% rename from .github/ISSUE_TEMPLATE/feature_request.md rename to ".github/ISSUE_TEMPLATE/\360\237\222\241feature-request.md" index e55a51524..bfc474732 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ "b/.github/ISSUE_TEMPLATE/\360\237\222\241feature-request.md" @@ -1,8 +1,8 @@ --- -name: Feature request +name: "\U0001F4A1Feature request" about: Suggest an idea for this project title: '' -labels: new feature +labels: new feature, Triage assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/bug_report.md "b/.github/ISSUE_TEMPLATE/\360\237\233\240\357\270\217-bug-report.md" similarity index 90% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to ".github/ISSUE_TEMPLATE/\360\237\233\240\357\270\217-bug-report.md" index fcf0d759f..b532aa9a6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ "b/.github/ISSUE_TEMPLATE/\360\237\233\240\357\270\217-bug-report.md" @@ -1,8 +1,8 @@ --- -name: Bug report +name: "\U0001F6E0️ Bug report" about: Create a report to help us improve title: '' -labels: bug +labels: bug, Triage assignees: '' --- diff --git a/.github/workflows/test_against_nightly_deps.yaml b/.github/workflows/test_against_nightly_deps.yaml new file mode 100644 index 000000000..f0cb3f120 --- /dev/null +++ b/.github/workflows/test_against_nightly_deps.yaml @@ -0,0 +1,27 @@ +name: 'Test against nightly dependencies' + +on: + workflow_dispatch: + schedule: + - cron: "30 2 * * *" + +jobs: + test_against_nightly: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + name: 'Setup python' + - shell: bash + run: | + dev_anaconda_url=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + dev_pytorch_url=https://download.pytorch.org/whl/nightly/cpu + pip install --pre --upgrade --timeout=60 --extra-index $dev_anaconda_url --extra-index $dev_pytorch_url .[tests,all_features] + name: 'Install tslearn and nightly dependencies' + - shell: bash + run: | + pip list + pytest -vsl tslearn --doctest-modules + name: 'Run tests' diff --git a/CHANGELOG.md b/CHANGELOG.md index 80439b8b4..05a8b3a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,30 +9,33 @@ and this project adheres to Changelogs for this project are recorded in this file since v0.2.0. -## [Towards v0.7.0] +## [Towards v0.8.0] + +### Changed + +* Fixed centroids computations in K-shape for multivariate timeseries ([#288](https://github.com/tslearn-team/tslearn/issues/288)) + +## [v0.7.0] ### Changed * Explicit exception when using Global Alignment Kernel with sigma close to zero ([#440](https://github.com/tslearn-team/tslearn/issues/440)) -* Fix shifting in K-shape shape extraction process ([#385](https://github.com/tslearn-team/tslearn/issues/385)) +* Fixed shifting in K-shape shape extraction process ([#385](https://github.com/tslearn-team/tslearn/issues/385)) * Support for `scikit-learn` up to 1.7 ([#549](https://github.com/tslearn-team/tslearn/issues/549)) +* Fixed `LearningShapelets` with variable length timeseries ([#352](https://github.com/tslearn-team/tslearn/issues/352)) +* The `shapelets` module now depends on Keras3+ and the underlying backend can be selected through +the KERAS_BACKEND environment variable. Defaults to the first found installed backend among `torch`, +`tensorflow` and `jax` (in that order). ### Removed -* Support for Python versions 3.8 and 3.9 are dropped +* Support for Python versions 3.8 and 3.9 is dropped ### Added * `per_timeseries` and `per_feature` options for min-max and mean-variance scalers ([#536](https://github.com/tslearn-team/tslearn/issues/536)) * `TimeSeriesImputer`class: missing value imputer for time series ([#564](https://github.com/tslearn-team/tslearn/issues/564)) -* Frechet metrics and KNeighbors integration ([#402](https://github.com/tslearn-team/tslearn/issues/402) - -### Changed - -* The `shapelets` module now depends on Keras3+ and the underlying backend can be selected through -the KERAS_BACKEND environment variable. Defaults to the first found installed backend among `torch`, -`tensorflow` and `jax` (in that order). -* Fixed `LearningShapelets` with variable length timeseries ([#352](https://github.com/tslearn-team/tslearn/issues/352)) +* Frechet metrics and KNeighbors integration ([#402](https://github.com/tslearn-team/tslearn/issues/402)) ## [v0.6.4] diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 1c45d7112..823f0d4e7 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -21,6 +21,8 @@ jobs: python.version: '3.12' Python313: python.version: '3.13' + Python314: + python.version: '3.14' variables: OMP_NUM_THREADS: '2' NUMBA_NUM_THREADS: '2' @@ -59,6 +61,8 @@ jobs: python.version: '3.12' Python313: python.version: '3.13' + Python314: + python.version: '3.14' variables: OMP_NUM_THREADS: '2' NUMBA_NUM_THREADS: '2' @@ -173,6 +177,8 @@ jobs: python.version: '3.12' Python313: python.version: '3.13' + Python314: + python.version: '3.14' variables: OMP_NUM_THREADS: '2' NUMBA_NUM_THREADS: '2' @@ -208,6 +214,8 @@ jobs: python.version: '3.12' Python313: python.version: '3.13' + Python314: + python.version: '3.14' variables: OMP_NUM_THREADS: '2' NUMBA_NUM_THREADS: '2' diff --git a/conftest.py b/conftest.py index 7375e37fb..d604050a8 100644 --- a/conftest.py +++ b/conftest.py @@ -12,7 +12,7 @@ keras = None -def pytest_ignore_collect(collection_path, path, config): +def pytest_ignore_collect(collection_path, *args, **kwargs): if keras is None and "shapelets" in collection_path.parts: return True diff --git a/docs/requirements_rtd.txt b/docs/requirements_rtd.txt index 7921b8d2d..0a790e120 100644 --- a/docs/requirements_rtd.txt +++ b/docs/requirements_rtd.txt @@ -12,98 +12,100 @@ babel==2.17.0 # via # pydata-sphinx-theme # sphinx -beautifulsoup4==4.13.4 +beautifulsoup4==4.14.3 # via pydata-sphinx-theme -certifi==2025.6.15 +certifi==2025.11.12 # via requests -charset-normalizer==3.4.2 +charset-normalizer==3.4.4 # via requests -contourpy==1.3.2 +contourpy==1.3.3 # via matplotlib cycler==0.12.1 # via matplotlib -docutils==0.21.2 +docutils==0.22.3 # via # pydata-sphinx-theme # sphinx -fonttools==4.58.5 +fonttools==4.61.0 # via matplotlib -idna==3.10 +idna==3.11 # via requests imagesize==1.4.1 # via sphinx jinja2==3.1.6 # via sphinx -joblib==1.5.1 +joblib==1.5.2 # via # scikit-learn - # tslearn (setup.py) -kiwisolver==1.4.8 + # tslearn (pyproject.toml) +kiwisolver==1.4.9 # via matplotlib -llvmlite==0.44.0 +llvmlite==0.45.1 # via numba -markupsafe==3.0.2 +markupsafe==3.0.3 # via jinja2 -matplotlib==3.10.3 - # via tslearn (setup.py) -numba==0.61.2 - # via tslearn (setup.py) -numpy==2.2.6 +matplotlib==3.10.7 + # via tslearn (pyproject.toml) +numba==0.62.1 + # via tslearn (pyproject.toml) +numpy==2.3.5 # via # contourpy # matplotlib # numba # scikit-learn # scipy - # tslearn (setup.py) -numpydoc==1.9.0 - # via tslearn (setup.py) + # tslearn (pyproject.toml) +numpydoc==1.10.0 + # via tslearn (pyproject.toml) packaging==25.0 # via # matplotlib # sphinx -pillow==11.3.0 +pillow==12.0.0 # via # matplotlib # sphinx-gallery pydata-sphinx-theme==0.16.1 - # via tslearn (setup.py) + # via tslearn (pyproject.toml) pygments==2.19.2 # via # accessible-pygments # pydata-sphinx-theme # sphinx -pypandoc==1.15 - # via tslearn (setup.py) -pyparsing==3.2.3 +pypandoc==1.16.2 + # via tslearn (pyproject.toml) +pyparsing==3.2.5 # via matplotlib python-dateutil==2.9.0.post0 # via matplotlib -requests==2.32.4 +requests==2.32.5 # via sphinx -scikit-learn==1.6.1 - # via tslearn (setup.py) -scipy==1.16.0 +roman-numerals==3.1.0 + # via sphinx +scikit-learn==1.7.2 + # via tslearn (pyproject.toml) +scipy==1.16.3 # via # scikit-learn - # tslearn (setup.py) + # tslearn (pyproject.toml) six==1.17.0 # via python-dateutil snowballstemmer==3.0.1 # via sphinx -soupsieve==2.7 +soupsieve==2.8 # via beautifulsoup4 -sphinx==8.1.3 +sphinx==9.0.4 # via # numpydoc # pydata-sphinx-theme # sphinx-copybutton # sphinx-gallery - # tslearn (setup.py) + # tslearn (pyproject.toml) sphinx-copybutton==0.5.2 - # via tslearn (setup.py) -sphinx-gallery==0.19.0 - # via tslearn (setup.py) + # via tslearn (pyproject.toml) +sphinx-gallery==0.20.0 + # via tslearn (pyproject.toml) sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 @@ -118,9 +120,9 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx threadpoolctl==3.6.0 # via scikit-learn -typing-extensions==4.14.1 +typing-extensions==4.15.0 # via # beautifulsoup4 # pydata-sphinx-theme -urllib3==2.5.0 +urllib3==2.6.0 # via requests diff --git a/pyproject.toml b/pyproject.toml index 598f93255..31c073b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,11 +26,11 @@ classifiers=[ "Topic :: Software Development :: Libraries", ] dependencies = [ - "scikit-learn>=1.4,<1.8", - "numpy>=1.24.3,<2.4", - "scipy>=1.10.1,<1.17", - "numba>=0.58.1,<0.63", - "joblib>=1.2,<1.6" + "scikit-learn>=1.4", + "numpy>=1.24.3", + "scipy>=1.10.1", + "numba>=0.58.1", + "joblib>=1.2" ] [project.urls] @@ -41,7 +41,10 @@ changelog = "https://github.com/tslearn-team/tslearn/CHANGELOG.md" [project.optional-dependencies] pytorch = ['torch'] -tests = ["pytest", "h5py"] +tests = [ + "pytest", + "h5py" +] docs = [ "sphinx", "pydata_sphinx_theme", @@ -71,4 +74,4 @@ minversion = "7" testpaths = [ "tests", "tslearn" -] \ No newline at end of file +] diff --git a/tests/test_clustering.py b/tests/test_clustering.py index 9366a9900..039d59344 100644 --- a/tests/test_clustering.py +++ b/tests/test_clustering.py @@ -211,6 +211,23 @@ def test_kshape(): with pytest.raises(ValueError): KShape(n_clusters=2, verbose=False, init="invalid").fit(time_series) + # Test that shape extraction operates on second features + feature_1 = rng.randn(1, 10, 1) + feature_2_0 = rng.randn(1, 10, 1) + 10 + feature_2_1 = rng.randn(1, 10, 1) - 10 + X1 = np.dstack((feature_1, feature_2_0)) + X2 = np.dstack((feature_1, feature_2_1)) + X = np.vstack(( + np.repeat(X1, 10, axis=0), + np.repeat(X2, 10, axis=0), + )) + + X = TimeSeriesScalerMeanVariance().fit_transform(X) + kshape = KShape(n_clusters=2, n_init=5, random_state=rng).fit(X) + assert all(kshape.labels_[0] == kshape.labels_[:10]) + assert all(kshape.labels_[10] == kshape.labels_[10:]) + assert kshape.labels_[0] != kshape.labels_[10] + def test_silhouette(): np.random.seed(0) diff --git a/tslearn/__init__.py b/tslearn/__init__.py index 7de5e44e2..d95bc4acb 100644 --- a/tslearn/__init__.py +++ b/tslearn/__init__.py @@ -1,5 +1,5 @@ __author__ = 'Romain Tavenard romain.tavenard[at]univ-rennes2.fr' -__version__ = "0.7.0.dev0" +__version__ = "0.8.0.dev0" __bibtex__ = r"""@article{JMLR:v21:20-091, author = {Romain Tavenard and Johann Faouzi and Gilles Vandewiele and Felix Divo and Guillaume Androz and Chester Holtz and diff --git a/tslearn/clustering/kshape.py b/tslearn/clustering/kshape.py index 1a6731e3d..6d44262b4 100644 --- a/tslearn/clustering/kshape.py +++ b/tslearn/clustering/kshape.py @@ -117,24 +117,29 @@ def _is_fitted(self): return True def _shape_extraction(self, X, k): - sz = X.shape[1] + _, sz, d = X.shape Xp = y_shifted_sbd_vec(self.cluster_centers_[k], X[self.labels_ == k], norm_ref=-1, norms_dataset=self.norms_[self.labels_ == k]) - S = numpy.dot(Xp[:, :, 0].T, Xp[:, :, 0]) - Q = numpy.eye(sz) - numpy.ones((sz, sz)) / sz - M = numpy.dot(Q.T, numpy.dot(S, Q)) - _, vec = numpy.linalg.eigh(M) - mu_k = vec[:, -1].reshape((sz, 1)) - - # The way the optimization problem is (ill-)formulated, both mu_k and - # -mu_k are candidates for barycenters - # In the following, we check which one is best candidate - dist_plus_mu = numpy.sum(numpy.linalg.norm(Xp - mu_k, axis=(1, 2))) - dist_minus_mu = numpy.sum(numpy.linalg.norm(Xp + mu_k, axis=(1, 2))) - if dist_minus_mu < dist_plus_mu: - mu_k *= -1 + mu_k_list = [] + for i in range(d): + S = numpy.dot(Xp[:, :, i].T, Xp[:, :, i]) + Q = numpy.eye(sz) - numpy.ones((sz, sz)) / sz + M = numpy.dot(Q.T, numpy.dot(S, Q)) + _, vec = numpy.linalg.eigh(M) + mu_k = vec[:, -1].reshape((sz, 1)) + + # The way the optimization problem is (ill-)formulated, both mu_k and + # -mu_k are candidates for barycenters + # In the following, we check which one is the best candidate + dist_plus_mu = numpy.sum(numpy.linalg.norm(Xp - mu_k, axis=(1, 2))) + dist_minus_mu = numpy.sum(numpy.linalg.norm(Xp + mu_k, axis=(1, 2))) + if dist_minus_mu < dist_plus_mu: + mu_k *= -1 + mu_k_list.append(mu_k) + + mu_k = numpy.hstack(mu_k_list) return mu_k def _update_centroids(self, X): diff --git a/tslearn/neighbors/neighbors.py b/tslearn/neighbors/neighbors.py index 32fad271c..cdefb599a 100644 --- a/tslearn/neighbors/neighbors.py +++ b/tslearn/neighbors/neighbors.py @@ -203,6 +203,38 @@ def __sklearn_tags__(self): return tags +def _predict_generic(caller, X, predict_func): + """Predict the class labels or target (depending on predict_func) for the provided data + + Parameters + ---------- + X : array-like, shape (n_ts, sz, d) + Test samples. + + Returns + ------- + array, shape = (n_ts, ) or (n_ts, dim_y) + Returns the result of predict_func + """ + if caller.metric in TSLEARN_VALID_METRICS: + check_is_fitted(caller, '_ts_fit') + X = check_array(X, allow_nd=True, force_all_finite=False) + X = to_time_series_dataset(X) + X = check_dims(X, X_fit_dims=caller._ts_fit.shape, extend=True, + check_n_features_only=True) + X_ = caller._precompute_cross_dist(X) + pred = predict_func(X_) + caller.metric = caller._ts_metric + return pred + else: + check_is_fitted(caller, '_X_fit') + X = check_array(X, allow_nd=True) + X = to_time_series_dataset(X) + X_ = to_sklearn_dataset(X) + X_ = check_dims(X_, X_fit_dims=caller._X_fit.shape, extend=False) + return predict_func(X_) + + class KNeighborsTimeSeries(KNeighborsTimeSeriesMixin, NearestNeighbors, BaseModelPackage): @@ -563,23 +595,7 @@ def predict(self, X): array, shape = (n_ts, ) Array of predicted class labels """ - if self.metric in TSLEARN_VALID_METRICS: - check_is_fitted(self, '_ts_fit') - 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._ts_fit.shape, extend=True, - check_n_features_only=True) - X_ = self._precompute_cross_dist(X) - pred = super().predict(X_) - self.metric = self._ts_metric - return pred - else: - check_is_fitted(self, '_X_fit') - X = check_array(X, allow_nd=True) - X = to_time_series_dataset(X) - X_ = to_sklearn_dataset(X) - X_ = check_dims(X_, X_fit_dims=self._X_fit.shape, extend=False) - return super().predict(X_) + return _predict_generic(self, X, super().predict) def predict_proba(self, X): """Predict the class probabilities for the provided data @@ -594,22 +610,7 @@ def predict_proba(self, X): array, shape = (n_ts, n_classes) Array of predicted class probabilities """ - if self.metric in TSLEARN_VALID_METRICS: - check_is_fitted(self, '_ts_fit') - X = check_array(X, allow_nd=True, force_all_finite=False) - X = check_dims(X, X_fit_dims=self._ts_fit.shape, extend=True, - check_n_features_only=True) - X_ = self._precompute_cross_dist(X) - pred = super().predict_proba(X_) - self.metric = self._ts_metric - return pred - else: - check_is_fitted(self, '_X_fit') - X = check_array(X, allow_nd=True) - X = to_time_series_dataset(X) - X_ = to_sklearn_dataset(X) - X_ = check_dims(X_, X_fit_dims=self._X_fit.shape, extend=False) - return super().predict_proba(X_) + return _predict_generic(self, X, super().predict_proba) class KNeighborsTimeSeriesRegressor(KNeighborsTimeSeriesMixin, @@ -748,20 +749,4 @@ def predict(self, X): array, shape = (n_ts, ) or (n_ts, dim_y) Array of predicted targets """ - if self.metric in TSLEARN_VALID_METRICS: - check_is_fitted(self, '_ts_fit') - 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._ts_fit.shape, extend=True, - check_n_features_only=True) - X_ = self._precompute_cross_dist(X) - pred = super().predict(X_) - self.metric = self._ts_metric - return pred - else: - check_is_fitted(self, '_X_fit') - X = check_array(X, allow_nd=True) - X = to_time_series_dataset(X) - X_ = to_sklearn_dataset(X) - X_ = check_dims(X_, X_fit_dims=self._X_fit.shape, extend=False) - return super().predict(X_) + return _predict_generic(self, X, super().predict)