diff --git a/CHANGELOG.md b/CHANGELOG.md index e00c087..ef6e2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **`python_som.sklearn.SOMEstimator`**, a scikit-learn estimator, behind a new `sklearn` extra: + + ```bash + pip install "python-som[sklearn]" + ``` + + The methods on `SOM` are enough when you call them yourself. They are not enough when scikit-learn + does the calling: since 1.7, `Pipeline.predict`, `GridSearchCV` and `cross_val_score` all require + `__sklearn_tags__`, which in practice means inheriting `BaseEstimator`. So the integration lives + in its own module and scikit-learn stays optional. Importing `python_som` still pulls in nothing + but NumPy, which a test asserts in a subprocess. + + `input_len` is absent from its constructor, since scikit-learn infers the feature count from `X`. + Unlike `SOM.fit`, the estimator's `fit` starts over rather than continuing, because `GridSearchCV` + fits one cloned estimator fold after fold, and carrying weights across folds would leak one fold + into the next. + + All five integration points have tests that call the real library rather than asserting about it: + `clone`, `Pipeline.fit`, `Pipeline.predict`, `GridSearchCV`, `cross_val_score`. + - **An estimator interface on `SOM`**: `fit`, `transform`, `predict`, `fit_transform`, `score`, `get_params` and `set_params`, modelled on `KMeans`. `train` remains the primary way to train and is unchanged, so nothing existing is affected. diff --git a/pyproject.toml b/pyproject.toml index 0be3d2b..e52896a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,12 @@ analysis = [ "pylint==4.0.6", ] # Restores the install set that 0.3.0 pulled in by default, for anyone who was relying on it. +# scikit-learn integration. The core never imports it; only python_som.sklearn does, and that module +# exists because these paths need `__sklearn_tags__`, which in practice means inheriting BaseEstimator. +# A lower bound rather than a pin: this is a user-facing install set, not tooling. +sklearn = [ + "scikit-learn>=1.4", +] examples = [ "matplotlib>=3.8", "pandas>=2.0", @@ -162,6 +168,9 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # Tests must be able to build a DataFrame to exercise the port, and to compare our PCA against # sklearn's differentially. The ban protects the core, not the suite that checks it. +# The scikit-learn adapter is the module that exists to import scikit-learn. +"src/python_som/sklearn.py" = ["TID251"] +"tests/test_sklearn_adapter.py" = ["TID251"] "tests/*" = [ "TID251", # Tests reach into private helpers deliberately; that is what is under test. @@ -205,6 +214,14 @@ enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] module = ["sklearn.*"] ignore_missing_imports = true +# scikit-learn ships no py.typed, so mypy sees BaseEstimator as Any and --strict refuses to subclass +# it. Relaxed for the adapter module alone, which is the only place that inherits from scikit-learn; +# everything else stays under full strictness. The alternative, a blanket type: ignore on the class, +# would hide any *other* subclassing mistake in the same file. +[[tool.mypy.overrides]] +module = ["python_som.sklearn"] +disallow_subclassing_any = false + [tool.pytest.ini_options] minversion = "8.0" testpaths = ["tests"] diff --git a/src/python_som/sklearn.py b/src/python_som/sklearn.py new file mode 100644 index 0000000..ffb0ec8 --- /dev/null +++ b/src/python_som/sklearn.py @@ -0,0 +1,220 @@ +"""scikit-learn adapter. Import this only if you want a map to work inside scikit-learn. + +:class:`~python_som.SOM` already provides ``fit``, ``transform``, ``predict`` and ``score``, which +is enough when *you* are the one calling them. It is not enough when scikit-learn does the calling: +since 1.7, ``Pipeline.predict``, ``GridSearchCV`` and ``cross_val_score`` all reach for +``__sklearn_tags__``, and the recommended way to have it is to inherit ``BaseEstimator``. + +Measured against scikit-learn 1.9, the methods on :class:`~python_som.SOM` alone give ``clone`` and +``Pipeline.fit`` and then fail: ``Pipeline.predict``, ``GridSearchCV`` and ``cross_val_score`` all +raise ``AttributeError``. :class:`SOMEstimator` passes all five. + +So the integration lives here rather than in the core, and scikit-learn stays optional:: + + pip install "python-som[sklearn]" + +This is the ports-and-adapters shape the package already uses. An adapter may depend on the thing it +adapts; the core stays numpy-only, and importing :mod:`python_som` pulls none of this in. + +Defining ``__sklearn_tags__`` by hand was the alternative and was rejected: it couples to an +internal that already changed shape once between 1.6 and 1.7, and scikit-learn's own error message +says it does not recommend the approach. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +try: + from sklearn.base import BaseEstimator, ClusterMixin, TransformerMixin +except ImportError as exc: # pragma: no cover exercised in a subprocess, not in this one + msg = ( + "python_som.sklearn needs scikit-learn, which is not installed. Install it with " + '`pip install "python-som[sklearn]"`. The rest of python_som does not need it: ' + "python_som.SOM already has fit, transform, predict and score for direct use." + ) + raise ImportError(msg) from exc + +from ._core._decay import asymptotic_decay +from ._core._distance import euclidean_distance +from ._enums import Neighborhood, NeighborhoodStr, TrainingMode, TrainingModeStr +from ._som import SOM + +if TYPE_CHECKING: # pragma: no cover + import numpy.typing as npt + + from ._artifact import TrainingReport + from ._core._protocols import DecayFunction, DistanceFunction + +__all__ = ["SOMEstimator"] + + +class SOMEstimator(ClusterMixin, TransformerMixin, BaseEstimator): + """A self-organizing map as a scikit-learn estimator. + + A SOM is a topologically-constrained k-means, so this follows ``KMeans``: ``transform`` gives a + cluster-distance space, ``predict`` gives one label per sample, ``score`` is negated so that + larger is better, and fitted attributes carry a trailing underscore. + + >>> from python_som.sklearn import SOMEstimator + >>> from sklearn.model_selection import GridSearchCV + >>> search = GridSearchCV(SOMEstimator(), {"x": [4, 6]}, cv=3) # doctest: +SKIP + + **Every argument is stored unmodified.** scikit-learn's ``clone`` rebuilds an estimator by + passing ``get_params()`` back to ``__init__`` and then checks the result is identical, so an + ``__init__`` that validates, coerces or derives anything breaks cloning. All of that is deferred + to :meth:`fit`, which is why this class holds settings rather than a + :class:`~python_som.SOM`. + + ``input_len`` is deliberately absent: scikit-learn infers the feature count from ``X``, and + :attr:`n_features_in_` reports it after fitting. + """ + + def __init__( + self, + x: int = 10, + y: int = 10, + *, + n_iteration: int | None = None, + mode: TrainingMode | TrainingModeStr = TrainingMode.BATCH, + learning_rate: float = 0.5, + learning_rate_decay: DecayFunction | None = None, + neighborhood_radius: float = 1.0, + neighborhood_radius_decay: DecayFunction | None = None, + neighborhood_function: Neighborhood | NeighborhoodStr = Neighborhood.GAUSSIAN, + distance_function: DistanceFunction | None = None, + cyclic_x: bool = False, + cyclic_y: bool = False, + random_seed: int | None = None, + min_neighborhood_radius: float = 0.5, + initialization: str = "linear", + ) -> None: + """Record the settings a map will be built from. + + Keyword-only after the grid dimensions, as ``KMeans`` is. The decays and the distance + default to None rather than to the functions themselves, so that the recorded parameters + stay exactly what the caller passed; :meth:`fit` substitutes the real defaults. + + :param x: Number of rows. + :param y: Number of columns. + :param n_iteration: Iterations to train for. Defaults as for :meth:`~python_som.SOM.train`. + :param mode: Training mode. Batch by default, which Kohonen Section 3.1 recommends. + :param learning_rate: Initial learning rate. Unused by batch training. + :param learning_rate_decay: Decay for the learning rate. + :param neighborhood_radius: Initial neighborhood radius. + :param neighborhood_radius_decay: Decay for the radius. + :param neighborhood_function: Which neighborhood to use. + :param distance_function: Dissimilarity between an input and the models. + :param cyclic_x: Whether the map wraps vertically. + :param cyclic_y: Whether the map wraps horizontally. + :param random_seed: Seed for this estimator's generator. + :param min_neighborhood_radius: Floor applied to the decayed radius. + :param initialization: How to seed the models before training, as for + :meth:`~python_som.SOM.weight_initialization`. + """ + self.x = x + self.y = y + self.n_iteration = n_iteration + self.mode = mode + self.learning_rate = learning_rate + self.learning_rate_decay = learning_rate_decay + self.neighborhood_radius = neighborhood_radius + self.neighborhood_radius_decay = neighborhood_radius_decay + self.neighborhood_function = neighborhood_function + self.distance_function = distance_function + self.cyclic_x = cyclic_x + self.cyclic_y = cyclic_y + self.random_seed = random_seed + self.min_neighborhood_radius = min_neighborhood_radius + self.initialization = initialization + + def fit(self, X: Any, y: Any = None, **kwargs: Any) -> SOMEstimator: # noqa: ANN401, ARG002, N803 + """Build a map from the recorded settings and train it on ``X``. + + A fresh map every call, unlike :meth:`python_som.SOM.fit`, which continues from wherever + its models were. Refitting an estimator is expected to start over: ``GridSearchCV`` fits the + same cloned estimator on fold after fold, and carrying weights between folds would leak one + fold into the next. + + :param X: Training dataset of shape ``(n_samples, n_features)``. + :param y: Ignored. + :param kwargs: Ignored; accepted because ``ClusterMixin.fit_predict`` forwards them. + :return: This estimator. + """ + data = np.asarray(X, dtype=float) + # Substituted here rather than defaulted in the signature: a callable default that `clone` + # round-trips would compare unequal to itself under some wrappers, and None keeps the + # recorded parameters exactly what the caller passed. + som = SOM( + x=self.x, + y=self.y, + input_len=data.shape[1], + learning_rate=self.learning_rate, + learning_rate_decay=( + asymptotic_decay if self.learning_rate_decay is None else self.learning_rate_decay + ), + neighborhood_radius=self.neighborhood_radius, + neighborhood_radius_decay=( + asymptotic_decay + if self.neighborhood_radius_decay is None + else self.neighborhood_radius_decay + ), + neighborhood_function=self.neighborhood_function, + distance_function=( + euclidean_distance if self.distance_function is None else self.distance_function + ), + cyclic_x=self.cyclic_x, + cyclic_y=self.cyclic_y, + random_seed=self.random_seed, + min_neighborhood_radius=self.min_neighborhood_radius, + ) + needs_data = self.initialization in {"linear", "sample"} + som.weight_initialization( + mode=self.initialization, # type: ignore[arg-type] + **({"data": data} if needs_data else {}), + ) + som.train(data, n_iteration=self.n_iteration, mode=self.mode) + + self.som_ = som + self.n_features_in_ = data.shape[1] + self.weights_ = som.get_weights() + self.quantization_error_ = som.quantization_error(data) + self.labels_ = som.predict(data) + return self + + def transform(self, X: Any) -> npt.NDArray[np.floating]: # noqa: ANN401, N803 + """Return the distance from each sample to every node. + + :param X: Dataset of shape ``(n_samples, n_features)``. + :return: Distances of shape ``(n_samples, x * y)``. + """ + return self.som_.transform(X) + + def predict(self, X: Any) -> npt.NDArray[np.integer]: # noqa: ANN401, N803 + """Return the flat index of the best-matching node for each sample. + + :param X: Dataset of shape ``(n_samples, n_features)``. + :return: One flat node index per sample. + """ + return self.som_.predict(X) + + def score(self, X: Any, y: Any = None) -> float: # noqa: ANN401, ARG002, N803 + """Return the negated quantization error, so that larger is better. + + :param X: Dataset to score. + :param y: Ignored. + :return: Negated mean quantization error. + """ + return self.som_.score(X) + + @property + def report_(self) -> TrainingReport | None: + """The training report of the fitted map, or None before fitting. + + :return: The report. + """ + if not hasattr(self, "som_"): + return None + return self.som_.last_report diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py index 17007aa..d768a6b 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -76,20 +76,43 @@ def test_no_core_module_imports_sklearn() -> None: def test_the_whole_package_is_numpy_only_at_runtime() -> None: """The shell, not just the core. This is what the 264 MB saving actually rests on. - Scans every module under ``src/`` rather than only ``_core/``, because after 0.4.0 no module in - the package imports pandas or scikit-learn -- the port is ``np.asarray`` and the PCA is - ``np.linalg.svd``. A `per-file-ignores` entry restoring either would pass ruff silently. + Scans every module under ``src/`` rather than only ``_core/``, because after 0.4.0 no module on + the import path of ``python_som`` reaches pandas or scikit-learn: the port is ``np.asarray`` and + the PCA is ``np.linalg.svd``. A `per-file-ignores` entry restoring either would pass ruff + silently. + + ``sklearn.py`` is exempt and is the *only* exemption. It is the adapter whose entire job is to + import scikit-learn, it is never imported by anything else in the package, and + ``tests/test_sklearn_adapter.py`` asserts that importing ``python_som`` does not pull it in. An + adapter may depend on the thing it adapts; that is what makes it an adapter rather than a + dependency. """ package = pathlib.Path(python_som.__file__).parent banned = {"pandas", "sklearn", "scipy"} + exempt = {"sklearn.py"} offenders = { str(path.relative_to(package)): sorted(_imports_of(path) & banned) for path in package.rglob("*.py") - if _imports_of(path) & banned + if path.name not in exempt and _imports_of(path) & banned } assert not offenders, f"runtime modules must not import {banned}: {offenders}" +def test_the_only_module_allowed_to_import_sklearn_is_the_adapter() -> None: + """State the exemption positively, so it cannot quietly grow. + + The test above skips ``sklearn.py`` by name. This one asserts that the skip covers exactly one + module, so adding a second scikit-learn import anywhere would need a deliberate edit here. + """ + package = pathlib.Path(python_som.__file__).parent + users = sorted( + str(path.relative_to(package)) + for path in package.rglob("*.py") + if "sklearn" in _imports_of(path) + ) + assert users == ["sklearn.py"], users + + def test_every_core_module_is_importable() -> None: """A module that no longer imports is a broken boundary, not a missing test.""" package = python_som._core diff --git a/tests/test_sklearn_adapter.py b/tests/test_sklearn_adapter.py new file mode 100644 index 0000000..3d6687b --- /dev/null +++ b/tests/test_sklearn_adapter.py @@ -0,0 +1,226 @@ +"""The scikit-learn adapter, tested by handing it to scikit-learn rather than by asserting about it. + +The earlier plan for this claimed that duck typing was enough and that inheriting ``BaseEstimator`` +was unnecessary. That was wrong: since scikit-learn 1.7, ``Pipeline.predict``, ``GridSearchCV`` and +``cross_val_score`` reach for ``__sklearn_tags__`` and raise ``AttributeError`` without it. That +claim had been reasoned about rather than run. + +So every test here calls the real scikit-learn. The five integration points that failed under duck +typing each get one, because those are the claims that were wrong, and a claim that was wrong once +is worth checking on every run rather than describing again. + +Skipped wholesale when scikit-learn is absent, since the adapter is behind an optional extra. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +pytest.importorskip("sklearn") + +from sklearn.base import clone +from sklearn.model_selection import GridSearchCV, cross_val_score +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +import python_som +from python_som import Neighborhood, TrainingMode +from python_som.sklearn import SOMEstimator + +#: Fixed so a search's choice is reproducible. +SEED = 20260801 + + +def _data(n_samples: int = 60, n_features: int = 4) -> np.ndarray: + """Build a reproducible dataset. + + :param n_samples: Number of samples. + :param n_features: Number of features. + :return: The dataset. + """ + return np.random.default_rng(SEED).normal(size=(n_samples, n_features)) + + +# --------------------------------------------------------------------------------------------- +# The five points that duck typing could not reach +# --------------------------------------------------------------------------------------------- + + +def test_clone_reproduces_the_parameters() -> None: + """``clone`` rebuilds from ``get_params`` and requires the result to compare equal. + + This is why ``__init__`` stores every argument unmodified: validating or deriving anything there + would make the rebuilt estimator differ from the original and break cloning, which is what + ``GridSearchCV`` does to every candidate. + """ + original = SOMEstimator(x=5, y=4, neighborhood_radius=2.5, random_seed=7) + copied = clone(original) + assert copied.get_params() == original.get_params() + assert copied is not original + + +def test_pipeline_fit_and_predict() -> None: + """``Pipeline.predict`` is the first thing duck typing failed.""" + data = _data() + pipeline = Pipeline([("scale", StandardScaler()), ("som", SOMEstimator(x=5, y=4))]) + pipeline.fit(data) + + labels = pipeline.predict(data) + assert labels.shape == (len(data),) + assert labels.max() < 5 * 4 + + +def test_pipeline_transform() -> None: + """One column per node, after the scaler has had its way with the data.""" + data = _data() + pipeline = Pipeline([("scale", StandardScaler()), ("som", SOMEstimator(x=5, y=4))]).fit(data) + assert pipeline.transform(data).shape == (len(data), 5 * 4) + + +def test_grid_search_selects_the_better_map() -> None: + """A search must tell a good map from a bad one, which is what the sign on ``score`` is for. + + A 2x2 grid cannot represent this data as well as a 6x6 one, so the search should prefer the + larger. If ``score`` were not negated this test would fail, and it is what would catch that. + """ + search = GridSearchCV(SOMEstimator(), {"x": [2, 6], "y": [2, 6]}, cv=3) + search.fit(_data()) + assert search.best_params_ == {"x": 6, "y": 6} + + +def test_cross_val_score_runs_and_returns_one_score_per_fold() -> None: + scores = cross_val_score(SOMEstimator(x=4, y=4), _data(), cv=3) + assert scores.shape == (3,) + assert np.isfinite(scores).all() + assert (scores < 0).all(), "the score is a negated error, so every fold should be negative" + + +def test_fit_predict_comes_from_the_cluster_mixin() -> None: + """Inheriting ``ClusterMixin`` is what provides it, and it must agree with fit-then-predict.""" + data = _data() + combined = SOMEstimator(x=5, y=4, random_seed=1).fit_predict(data) + separate = SOMEstimator(x=5, y=4, random_seed=1).fit(data).predict(data) + np.testing.assert_array_equal(combined, separate) + + +# --------------------------------------------------------------------------------------------- +# The estimator contract +# --------------------------------------------------------------------------------------------- + + +def test_fitted_attributes_follow_the_convention() -> None: + """Trailing underscore, and set only by ``fit``.""" + estimator = SOMEstimator(x=5, y=4) + assert not hasattr(estimator, "weights_") + + estimator.fit(_data()) + assert estimator.weights_.shape == (5, 4, 4) + assert estimator.n_features_in_ == 4 + assert estimator.quantization_error_ > 0 + assert estimator.labels_.shape == (60,) + assert isinstance(estimator.som_, python_som.SOM) + + +def test_input_len_is_inferred_from_the_data() -> None: + """scikit-learn infers the feature count, so it is not a constructor argument. + + A caller who changes their feature count should not have to remember to change a second number + that has to agree with it. + """ + assert "input_len" not in SOMEstimator().get_params() + for n_features in (2, 7): + fitted = SOMEstimator(x=4, y=4).fit(_data(n_features=n_features)) + assert fitted.n_features_in_ == n_features + assert fitted.weights_.shape == (4, 4, n_features) + + +def test_refitting_starts_over_rather_than_continuing() -> None: + """The opposite of ``SOM.fit``, and deliberately so. + + ``GridSearchCV`` fits one cloned estimator on fold after fold. If weights carried over, every + fold would inherit the previous one and the scores would be quietly meaningless. + """ + data = _data() + estimator = SOMEstimator(x=5, y=4, random_seed=2) + first = estimator.fit(data).weights_.copy() + second = estimator.fit(data).weights_ + np.testing.assert_array_equal(first, second) + + +def test_the_report_is_none_before_fitting() -> None: + """None rather than absent, so reading it before fitting is not an AttributeError.""" + assert SOMEstimator(x=4, y=4).report_ is None + + +def test_the_report_is_available_after_fitting() -> None: + """Separate from the test above so neither narrows the other's type for a checker.""" + report = SOMEstimator(x=4, y=4, n_iteration=12).fit(_data()).report_ + assert report is not None + assert report.n_iteration == 12 + + +def test_every_neighborhood_and_mode_combination_that_is_legal_works() -> None: + """The adapter must not narrow what the core accepts.""" + data = _data() + for neighborhood in Neighborhood: + for mode in TrainingMode: + if neighborhood is Neighborhood.MEXICAN_HAT and mode is TrainingMode.BATCH: + continue # rejected by the core, and the core's error is the right one + fitted = SOMEstimator( + x=4, y=4, neighborhood_function=neighborhood, mode=mode, n_iteration=5 + ).fit(data) + assert np.isfinite(fitted.weights_).all() + + +def test_a_plain_string_option_works_here_too() -> None: + """Strings are permanent, and the adapter is not a place they stop being accepted.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + fitted = SOMEstimator(x=4, y=4, neighborhood_function="bubble", mode="batch").fit(_data()) + assert fitted.n_features_in_ == 4 + + +def test_the_core_error_surfaces_unchanged() -> None: + """The adapter adds no validation of its own, so the core's message is what a caller sees.""" + with pytest.raises(ValueError, match="cannot be used with the 'batch' training mode"): + SOMEstimator( + x=4, y=4, neighborhood_function=Neighborhood.MEXICAN_HAT, mode=TrainingMode.BATCH + ).fit(_data()) + + +# --------------------------------------------------------------------------------------------- +# The boundary: the core must not depend on this +# --------------------------------------------------------------------------------------------- + + +def test_importing_python_som_does_not_import_sklearn() -> None: + """The whole point of putting the adapter in its own module. + + A subprocess, because scikit-learn is certainly already imported in this one. + """ + import subprocess # noqa: PLC0415 + import sys # noqa: PLC0415 + + code = ( + "import sys, python_som; " + "assert 'sklearn' not in sys.modules, sorted(m for m in sys.modules if 'sklearn' in m); " + "print('clean')" + ) + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", code], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "clean" in result.stdout + + +def test_the_adapter_is_not_reachable_from_the_package_root() -> None: + """``python_som.sklearn`` is an explicit import, never something you get by accident. + + If it were re-exported from ``__init__``, importing the package would import scikit-learn and + the numpy-only promise would be gone. + """ + assert "sklearn" not in python_som.__all__ + assert not hasattr(python_som, "SOMEstimator") diff --git a/uv.lock b/uv.lock index 34bddfd..c879bcd 100644 --- a/uv.lock +++ b/uv.lock @@ -2606,6 +2606,10 @@ examples = [ { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "seaborn" }, ] +sklearn = [ + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] [package.metadata] requires-dist = [ @@ -2630,13 +2634,14 @@ requires-dist = [ { name = "scikit-learn", marker = "python_full_version >= '3.11' and extra == 'dev'", specifier = "==1.9.0" }, { name = "scikit-learn", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = "==1.7.2" }, { name = "scikit-learn", marker = "extra == 'examples'", specifier = ">=1.3" }, + { name = "scikit-learn", marker = "extra == 'sklearn'", specifier = ">=1.4" }, { name = "seaborn", marker = "extra == 'examples'", specifier = ">=0.13" }, { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = "==2.4.1" }, { name = "tqdm", marker = "extra == 'cli'", specifier = ">=4.66" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "types-tqdm", marker = "extra == 'dev'", specifier = "==4.69.0.20260728" }, ] -provides-extras = ["cli", "dev", "docs", "analysis", "examples"] +provides-extras = ["cli", "dev", "docs", "analysis", "sklearn", "examples"] [[package]] name = "pytz"