Skip to content
Merged
1 change: 1 addition & 0 deletions docs/source/cuml-accel/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
10 changes: 10 additions & 0 deletions docs/source/cuml-accel/limitations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^^

Expand Down
20 changes: 18 additions & 2 deletions python/cuml/cuml/accel/_overrides/sklearn/decomposition.py
Original file line number Diff line number Diff line change
@@ -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
#

Expand All @@ -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.
Expand Down Expand Up @@ -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)
80 changes: 74 additions & 6 deletions python/cuml/cuml/decomposition/incremental_pca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,6 +179,9 @@ class IncrementalPCA(PCA):
0.0037122774558343763
"""

_cpu_class_path = "sklearn.decomposition.IncrementalPCA"
var_ = CumlArrayDescriptor(order="F")

def __init__(
self,
*,
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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_]
)
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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):
"""
Expand Down
53 changes: 52 additions & 1 deletion python/cuml/cuml_accel_tests/test_estimator_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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)

Expand Down
49 changes: 49 additions & 0 deletions python/cuml/tests/test_incremental_pca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading