diff --git a/docs/source/cuml-accel/faq.rst b/docs/source/cuml-accel/faq.rst index e83b724749..1b3f925bd7 100644 --- a/docs/source/cuml-accel/faq.rst +++ b/docs/source/cuml-accel/faq.rst @@ -55,6 +55,7 @@ the following estimators are mostly or entirely accelerated when run with * ``sklearn.covariance.EmpiricalCovariance`` * ``sklearn.covariance.LedoitWolf`` * ``sklearn.decomposition.PCA`` + * ``sklearn.decomposition.IncrementalPCA`` * ``sklearn.decomposition.TruncatedSVD`` * ``sklearn.ensemble.RandomForestClassifier`` * ``sklearn.ensemble.RandomForestRegressor`` diff --git a/docs/source/cuml-accel/limitations.rst b/docs/source/cuml-accel/limitations.rst index 8a0c0aca3a..9a35f94cac 100644 --- a/docs/source/cuml-accel/limitations.rst +++ b/docs/source/cuml-accel/limitations.rst @@ -156,6 +156,16 @@ Additional notes: - Parameters for the ``"randomized"`` solver like ``random_state``, ``n_oversamples``, ``power_iteration_normalizer`` are ignored. +IncrementalPCA +^^^^^^^^^^^^^^ + +``IncrementalPCA`` has no known estimator-specific ``cuml.accel`` limitations. + +Additional notes: + +- ``partial_fit`` does not support sparse input. This matches scikit-learn; + use ``fit`` for sparse input or provide dense batches to ``partial_fit``. + TruncatedSVD ^^^^^^^^^^^^ diff --git a/python/cuml/cuml/accel/_overrides/sklearn/decomposition.py b/python/cuml/cuml/accel/_overrides/sklearn/decomposition.py index adb5ef8e55..026851cb56 100644 --- a/python/cuml/cuml/accel/_overrides/sklearn/decomposition.py +++ b/python/cuml/cuml/accel/_overrides/sklearn/decomposition.py @@ -1,5 +1,5 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # @@ -9,7 +9,7 @@ import cuml.decomposition from cuml.accel.estimator_proxy import ProxyBase -__all__ = ("PCA", "TruncatedSVD") +__all__ = ("IncrementalPCA", "PCA", "TruncatedSVD") # In sklearn 1.5 the sign flipping behavior changed. For sklearn < 1.5 we # enable the old behavior. @@ -42,3 +42,19 @@ def _gpu_fit(self, X, y=None): def _gpu_fit_transform(self, X, y=None): self._gpu._u_based_sign_flip = True return self._gpu.fit_transform(X, y) + + +class IncrementalPCA(ProxyBase): + _gpu_class = cuml.decomposition.IncrementalPCA + + def _gpu_fit_transform(self, X, y=None, **fit_params): + if fit_params: + param = next(iter(fit_params)) + raise TypeError( + "IncrementalPCA.fit() got an unexpected keyword argument " + f"{param!r}" + ) + return self._gpu.fit_transform(X, y) + + def _gpu_partial_fit(self, X, y=None, check_input=True): + return self._gpu.partial_fit(X, y, check_input=check_input) diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index b89a1e73ca..1997cf2466 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -8,10 +8,12 @@ import cupy as cp import cuml.internals +from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.common.sparse_utils import is_sparse from cuml.decomposition.pca import PCA from cuml.internals.array import CumlArray from cuml.internals.base import Base +from cuml.internals.interop import InteropMixin, to_cpu, to_gpu from cuml.internals.validation import ( check_array, check_features, @@ -177,6 +179,9 @@ class IncrementalPCA(PCA): 0.0037122774558343763 """ + _cpu_class_path = "sklearn.decomposition.IncrementalPCA" + var_ = CumlArrayDescriptor(order="F") + def __init__( self, *, @@ -279,7 +284,10 @@ def partial_fit(self, X, y=None, *, check_input=True) -> "IncrementalPCA": if first_call := getattr(self, "n_samples_seen_", 0) == 0: self._set_output_type(X) - check_features(self, X, reset=first_call) + # `fit()` pre-validates X once and calls us per batch with + # check_input=False; skip re-running check_features in that case. + if check_input or not hasattr(self, "n_features_in_"): + check_features(self, X, reset=first_call) if check_input: X = check_array(X, dtype=("float32", "float64")) @@ -309,11 +317,11 @@ def partial_fit(self, X, y=None, *, check_input=True) -> "IncrementalPCA": "more rows than columns for IncrementalPCA " "processing" % (self.n_components, n_features) ) - elif not self.n_components <= n_samples: + elif self.n_components > n_samples and first_call: raise ValueError( - "n_components=%r must be less or equal to " - "the batch number of samples " - "%d." % (self.n_components, n_samples) + f"n_components={self.n_components} must be less or equal to " + f"the batch number of samples {n_samples} for the first " + "partial_fit call." ) else: self.n_components_ = self.n_components @@ -371,7 +379,7 @@ def partial_fit(self, X, y=None, *, check_input=True) -> "IncrementalPCA": ) self.singular_values_ = CumlArray(data=S[: self.n_components_]) self.mean_ = CumlArray(data=col_mean) - self.var_ = col_var + self.var_ = CumlArray(data=col_var) self.explained_variance_ = CumlArray( data=explained_variance[: self.n_components_] ) @@ -454,6 +462,66 @@ def _get_param_names(cls): "batch_size", ] + @classmethod + def _params_from_cpu(cls, model): + return { + "n_components": model.n_components, + "whiten": model.whiten, + "copy": model.copy, + "batch_size": model.batch_size, + } + + def _params_to_cpu(self): + return { + "n_components": self.n_components, + "whiten": self.whiten, + "copy": self.copy, + "batch_size": self.batch_size, + } + + # Bypass PCA._attrs_*: sklearn IncrementalPCA lacks PCA's `n_samples_`. + # Call InteropMixin directly for universal `n_features_in_` / + # `feature_names_in_` handling. + def _attrs_from_cpu(self, model): + out = { + "components_": to_gpu(model.components_, order="F"), + "explained_variance_": to_gpu( + model.explained_variance_, order="F" + ), + "explained_variance_ratio_": to_gpu( + model.explained_variance_ratio_, order="F" + ), + "singular_values_": to_gpu(model.singular_values_, order="F"), + "mean_": to_gpu(model.mean_, order="F"), + "var_": to_gpu(model.var_, order="F"), + "n_components_": model.n_components_, + "n_samples_seen_": model.n_samples_seen_, + "noise_variance_": model.noise_variance_, + **InteropMixin._attrs_from_cpu(self, model), + } + if (batch_size_ := getattr(model, "batch_size_", None)) is not None: + out["batch_size_"] = batch_size_ + return out + + def _attrs_to_cpu(self, model): + out = { + "components_": to_cpu(self.components_), + "explained_variance_": to_cpu(self.explained_variance_), + "explained_variance_ratio_": to_cpu( + self.explained_variance_ratio_ + ), + "singular_values_": to_cpu(self.singular_values_), + "mean_": to_cpu(self.mean_), + "var_": to_cpu(self.var_), + "n_components_": self.n_components_, + "n_samples_seen_": self.n_samples_seen_, + "noise_variance_": self.noise_variance_, + **InteropMixin._attrs_to_cpu(self, model), + } + if (batch_size_ := getattr(self, "batch_size_", None)) is not None: + out["batch_size_"] = batch_size_ + return out + def _gen_batches(n, batch_size, min_batch_size=0): """ diff --git a/python/cuml/cuml_accel_tests/test_estimator_proxy.py b/python/cuml/cuml_accel_tests/test_estimator_proxy.py index f35d3d9ff2..cca1f9a6e1 100644 --- a/python/cuml/cuml_accel_tests/test_estimator_proxy.py +++ b/python/cuml/cuml_accel_tests/test_estimator_proxy.py @@ -17,7 +17,7 @@ from packaging.version import Version from sklearn.base import check_is_fitted, is_classifier, is_regressor from sklearn.datasets import make_blobs, make_classification, make_regression -from sklearn.decomposition import PCA +from sklearn.decomposition import PCA, IncrementalPCA from sklearn.ensemble import RandomForestRegressor from sklearn.exceptions import NotFittedError from sklearn.linear_model import ( @@ -142,6 +142,15 @@ def test_init_positional_and_keyword(): # Can't pass keyword-only parameters in as positional PCA(10, False) + model = IncrementalPCA() + assert model.n_components is None + + model = IncrementalPCA(n_components=10) + assert model.n_components == 10 + + model = IncrementalPCA(10) + assert model.n_components == 10 + def test_repr(): model = LogisticRegression(C=1.5) @@ -697,6 +706,48 @@ def test_set_output(methods): assert not hasattr(model._cpu, "n_features_in_") +def test_incremental_pca_partial_fit(): + X, _ = make_classification(n_samples=40, n_features=8, random_state=42) + model = IncrementalPCA(n_components=3, batch_size=10) + + model.partial_fit(X[:20]) + gpu = model._gpu + + assert gpu is not None + assert int(gpu.n_samples_seen_) == 20 + + model.partial_fit(X[20:]) + + assert model._gpu is gpu + assert int(model._gpu.n_samples_seen_) == 40 + assert not hasattr(model._cpu, "components_") + + out = model.transform(X) + assert isinstance(out, np.ndarray) + assert out.shape[1] == 3 + + +def test_incremental_pca_set_output(): + X, _ = make_classification(n_samples=40, n_features=8, random_state=42) + model = IncrementalPCA(n_components=3, batch_size=10) + + assert model.set_output(transform="pandas") is model + model.partial_fit(X) + + out = model.transform(X) + assert isinstance(out, pd.DataFrame) + assert out.columns[0].startswith("incrementalpca") + + +@pytest.mark.parametrize("method", ["fit_transform", "partial_fit"]) +def test_incremental_pca_rejects_unsupported_fit_params(method): + X, _ = make_classification(n_samples=40, n_features=8, random_state=42) + model = IncrementalPCA(n_components=3, batch_size=10) + + with pytest.raises(TypeError): + getattr(model, method)(X, sample_weight=np.ones(X.shape[0])) + + def test_get_feature_names_out(): model = PCA(n_components=5) diff --git a/python/cuml/tests/test_incremental_pca.py b/python/cuml/tests/test_incremental_pca.py index 021e0dd057..99abd6a9dc 100644 --- a/python/cuml/tests/test_incremental_pca.py +++ b/python/cuml/tests/test_incremental_pca.py @@ -112,6 +112,55 @@ def test_partial_fit( assert array_equal(cu_inv, sk_inv, 1e-3, with_sign=True) +@pytest.mark.parametrize("whiten", [False, True]) +@pytest.mark.parametrize("method", ["fit", "partial_fit"]) +def test_sklearn_parity_attrs(method, whiten): + """Check fitted-attribute parity with sklearn for fit() and partial_fit().""" + nrows, ncols, n_components = 1000, 8, 4 + X, _ = make_blobs( + n_samples=nrows, n_features=ncols, random_state=0, dtype="float64" + ) + X_np = cp.asnumpy(X) + + cu_ipca = cuIPCA(n_components=n_components, whiten=whiten, batch_size=100) + sk_ipca = skIPCA(n_components=n_components, whiten=whiten, batch_size=100) + + if method == "fit": + cu_ipca.fit(X) + sk_ipca.fit(X_np) + else: + batch = 100 + for i in range(0, nrows, batch): + cu_ipca.partial_fit(X[i : i + batch]) + sk_ipca.partial_fit(X_np[i : i + batch]) + + assert array_equal( + cu_ipca.components_, sk_ipca.components_, 1e-3, with_sign=True + ) + assert array_equal( + cu_ipca.explained_variance_, + sk_ipca.explained_variance_, + 1e-3, + with_sign=True, + ) + assert array_equal( + cu_ipca.explained_variance_ratio_, + sk_ipca.explained_variance_ratio_, + 1e-3, + with_sign=True, + ) + assert array_equal( + cu_ipca.singular_values_, + sk_ipca.singular_values_, + 1e-3, + with_sign=True, + ) + assert array_equal(cu_ipca.mean_, sk_ipca.mean_, 1e-3, with_sign=True) + assert array_equal(cu_ipca.var_, sk_ipca.var_, 1e-3, with_sign=True) + assert int(cu_ipca.n_samples_seen_) == int(sk_ipca.n_samples_seen_) + assert cu_ipca.n_components_ == sk_ipca.n_components_ + + def test_exceptions(): X = cupyx.scipy.sparse.eye(10) ipca = cuIPCA()