From a2b019b4bf1aa6cfd79de23ca8140f4f7d1694cf Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Wed, 22 Apr 2026 21:54:09 +0000 Subject: [PATCH 01/10] Use new input validation infrastructure for cuml.decomposition. --- .../cuml/decomposition/incremental_pca.py | 135 +++++--------- python/cuml/cuml/decomposition/pca.pyx | 175 +++++++++++------- python/cuml/cuml/decomposition/tsvd.pyx | 112 ++++++----- .../upstream/scikit-learn/xfail-list.yaml | 8 - python/cuml/tests/test_incremental_pca.py | 6 - .../cuml/tests/test_sklearn_compatibility.py | 9 - 6 files changed, 214 insertions(+), 231 deletions(-) diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index ffd8a1e1de..017e25176b 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -7,15 +7,17 @@ import cupy as cp import cupyx -import scipy.sparse import cuml.internals -from cuml.common import input_to_cuml_array +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.input_utils import input_to_cupy_array -from cuml.internals.validation import check_features, check_is_fitted +from cuml.internals.validation import ( + check_array, + check_features, + check_is_fitted, +) class IncrementalPCA(PCA): @@ -194,7 +196,7 @@ def __init__( ) self.batch_size = batch_size - @cuml.internals.reflect(reset=True) + @cuml.internals.reflect(reset="type") def fit(self, X, y=None, *, convert_dtype=True) -> "IncrementalPCA": """ Fit the model with X, using minibatches of size batch_size. @@ -217,20 +219,13 @@ def fit(self, X, y=None, *, convert_dtype=True) -> "IncrementalPCA": self.mean_ = 0.0 self.var_ = 0.0 - if scipy.sparse.issparse(X) or cupyx.scipy.sparse.issparse(X): - X = _validate_sparse_input(X) - else: - # NOTE: While we cast the input to a cupy array here, we still - # respect the `output_type` parameter in the constructor. This - # is done by PCA, which IncrementalPCA inherits from. PCA's - # transform and inverse transform convert the output to the - # required type. - X, n_samples, n_features, _ = input_to_cupy_array( - X, - order="K", - convert_to_dtype=(cp.float32 if convert_dtype else None), - check_dtype=[cp.float32, cp.float64], - ) + X = check_array( + X, + accept_sparse=["csr", "csc"], + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + ) + check_features(self, X, reset=True) n_samples, n_features = X.shape @@ -272,38 +267,33 @@ def partial_fit(self, X, y=None, *, check_input=True) -> "IncrementalPCA": Returns the instance itself. """ - if getattr(self, "n_samples_seen_", 0) == 0: - # This instance hasn't been fit yet + if check_input and is_sparse(X): + raise TypeError( + "IncrementalPCA.partial_fit does not support " + "sparse input. Either convert data to dense " + "or use IncrementalPCA.fit to do so in batches." + ) + + if first_call := getattr(self, "n_samples_seen_", 0) == 0: self._set_output_type(X) - check_features(self, X, reset=True) + check_features(self, X, reset=first_call) + + if check_input: + X = check_array(X, dtype=("float32", "float64")) + + n_samples, n_features = X.shape + if first_call: self.n_samples_seen_ = 0 mean = 0.0 var = 0.0 singular_values = None components = None else: - check_features(self, X) - - with cuml.using_output_type("cupy"): - mean = self.mean_ - var = self.var_ - singular_values = self.singular_values_ - components = self.components_ - - if check_input: - if scipy.sparse.issparse(X) or cupyx.scipy.sparse.issparse(X): - raise TypeError( - "IncrementalPCA.partial_fit does not support " - "sparse input. Either convert data to dense " - "or use IncrementalPCA.fit to do so in batches." - ) - - X, n_samples, n_features, _ = input_to_cupy_array( - X, order="K", check_dtype=[cp.float32, cp.float64] - ) - else: - n_samples, n_features = X.shape + mean = cp.asarray(self.mean_) + var = cp.asarray(self.var_) + singular_values = cp.asarray(self.singular_values_) + components = cp.asarray(self.components_) if self.n_components is None: if components is None: @@ -424,8 +414,13 @@ def transform(self, X, *, convert_dtype=False) -> CumlArray: check_is_fitted(self) check_features(self, X) - if scipy.sparse.issparse(X) or cupyx.scipy.sparse.issparse(X): - X = _validate_sparse_input(X) + if is_sparse(X): + X = check_array( + X, + accept_sparse=["csr", "csc"], + dtype=self.components_.dtype, + convert_dtype=convert_dtype, + ) n_samples = X.shape[0] output = [] @@ -435,9 +430,9 @@ def transform(self, X, *, convert_dtype=False) -> CumlArray: min_batch_size=self.n_components or 0, ): output.append(super().transform(X[batch])) - output, _, _, _ = input_to_cuml_array(cp.vstack(output), order="K") - - return output + return CumlArray( + data=cp.vstack([o.to_output("cupy") for o in output]) + ) else: return super().transform(X) @@ -452,50 +447,6 @@ def _get_param_names(cls): ] -def _validate_sparse_input(X): - """ - Validate the format and dtype of sparse inputs. - This function throws an error for any cupyx.scipy.sparse object that is not - of type cupyx.scipy.sparse.csr_matrix or cupyx.scipy.sparse.csc_matrix. - It also validates the dtype of the input to be 'float32' or 'float64' - - Parameters - ---------- - - X : scipy.sparse or cupyx.scipy.sparse object - A sparse input - - Returns - ------- - - X : The input converted to a cupyx.scipy.sparse.csr_matrix object - - """ - - acceptable_dtypes = ("float32", "float64") - - # NOTE: We can include cupyx.scipy.sparse.csc.csc_matrix - # once it supports indexing in cupy 8.0.0b5 - acceptable_cupy_sparse_formats = cupyx.scipy.sparse.csr_matrix - - if X.dtype not in acceptable_dtypes: - raise TypeError( - "Expected input to be of type float32 or float64." - " Received %s" % X.dtype - ) - if scipy.sparse.issparse(X): - return cupyx.scipy.sparse.csr_matrix(X) - elif cupyx.scipy.sparse.issparse(X): - if not isinstance(X, acceptable_cupy_sparse_formats): - raise TypeError( - "Expected input to be of type" - " cupyx.scipy.sparse.csr_matrix or" - " cupyx.scipy.sparse.csc_matrix. Received %s" % type(X) - ) - else: - return X - - def _gen_batches(n, batch_size, min_batch_size=0): """ Generator to create slices containing batch_size elements, from 0 to n. diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index 7828c63c5d..60d9b19fe8 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -7,13 +7,11 @@ import cupyx.scipy.sparse import numpy as np import cuml.internals -from cuml.common import using_output_type from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.common.doc_utils import generate_docstring from cuml.common.sparse_utils import is_sparse from cuml.internals.array import CumlArray from cuml.internals.base import Base, get_handle -from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.interop import ( InteropMixin, UnsupportedOnGPU, @@ -21,7 +19,11 @@ from cuml.internals.interop import ( to_gpu, ) from cuml.internals.mixins import FMajorInputTagMixin, SparseInputTagMixin -from cuml.internals.validation import check_features, check_is_fitted +from cuml.internals.validation import ( + check_array, + check_inputs, + check_is_fitted, +) from cuml.prims.stats import cov from libc.stdint cimport uintptr_t @@ -379,23 +381,23 @@ class PCA(Base, f"got {self.svd_solver!r}" ) - # Allocate output arrays - components = CumlArray.zeros( - (self.n_components_, self.n_features_in_), dtype=X.dtype + # Allocate output arrays (F-order: libcuml writes column-major) + components = cp.zeros( + (self.n_components_, self.n_features_in_), dtype=X.dtype, order="F" ) - explained_variance = CumlArray.zeros(self.n_components_, dtype=X.dtype) - explained_variance_ratio = CumlArray.zeros(self.n_components_, dtype=X.dtype) - mean = CumlArray.zeros(self.n_features_in_, dtype=X.dtype) - singular_values = CumlArray.zeros(self.n_components_, dtype=X.dtype) - noise_variance = CumlArray.zeros(1, dtype=X.dtype) - - cdef uintptr_t X_ptr = X.ptr - cdef uintptr_t components_ptr = components.ptr - cdef uintptr_t explained_variance_ptr = explained_variance.ptr - cdef uintptr_t explained_variance_ratio_ptr = explained_variance_ratio.ptr - cdef uintptr_t singular_values_ptr = singular_values.ptr - cdef uintptr_t mean_ptr = mean.ptr - cdef uintptr_t noise_variance_ptr = noise_variance.ptr + explained_variance = cp.zeros(self.n_components_, dtype=X.dtype) + explained_variance_ratio = cp.zeros(self.n_components_, dtype=X.dtype) + mean = cp.zeros(self.n_features_in_, dtype=X.dtype) + singular_values = cp.zeros(self.n_components_, dtype=X.dtype) + noise_variance = cp.zeros(1, dtype=X.dtype) + + cdef uintptr_t X_ptr = X.data.ptr + cdef uintptr_t components_ptr = components.data.ptr + cdef uintptr_t explained_variance_ptr = explained_variance.data.ptr + cdef uintptr_t explained_variance_ratio_ptr = explained_variance_ratio.data.ptr + cdef uintptr_t singular_values_ptr = singular_values.data.ptr + cdef uintptr_t mean_ptr = mean.data.ptr + cdef uintptr_t noise_variance_ptr = noise_variance.data.ptr cdef bool fit_float32 = (X.dtype == np.float32) handle = get_handle() cdef handle_t* handle_ = handle.getHandle() @@ -432,12 +434,12 @@ class PCA(Base, handle.sync() # Store results - self.components_ = components - self.explained_variance_ = explained_variance - self.explained_variance_ratio_ = explained_variance_ratio - self.mean_ = mean - self.singular_values_ = singular_values - self.noise_variance_ = noise_variance.to_output("numpy").item() + self.components_ = CumlArray(data=components) + self.explained_variance_ = CumlArray(data=explained_variance) + self.explained_variance_ratio_ = CumlArray(data=explained_variance_ratio) + self.mean_ = CumlArray(data=mean) + self.singular_values_ = CumlArray(data=singular_values) + self.noise_variance_ = float(noise_variance.item()) def _fit_sparse(self, X): covariance, mean, _ = cov(X, X, return_mean=True) @@ -476,22 +478,33 @@ class PCA(Base, self.noise_variance_ = noise_variance @generate_docstring(X='dense_sparse') - @cuml.internals.reflect(reset=True) + @cuml.internals.reflect(reset="type") def fit(self, X, y=None, *, convert_dtype=True) -> "PCA": """ Fit the model with X. y is currently ignored. """ if (sparse := is_sparse(X)): + X = check_inputs( + self, + X, + accept_sparse=True, + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + reset=True, + ) X = cupyx.scipy.sparse.coo_matrix(X) - n_rows, n_cols = X.shape else: - X, n_rows, n_cols, _ = input_to_cuml_array( + X = check_inputs( + self, X, - convert_to_dtype=(np.float32 if convert_dtype else None), - check_dtype=[np.float32, np.float64], + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + order="F", + reset=True, ) + n_rows, n_cols = X.shape self.n_samples_ = n_rows if self.n_components is None: @@ -515,7 +528,7 @@ class PCA(Base, 'type': 'dense_sparse', 'description': 'Transformed values', 'shape': '(n_samples, n_components)'}) - @cuml.internals.reflect + @cuml.internals.reflect(reset="type") def fit_transform(self, X, y=None) -> CumlArray: """ Fit the model with X and apply the dimensionality reduction on X. @@ -526,10 +539,9 @@ class PCA(Base, def _inverse_transform_sparse(self, X, return_sparse=False, sparse_tol=1e-10): X = cupyx.scipy.sparse.coo_matrix(X) - with using_output_type("cupy"): - components = self.components_ - explained_variance = self.explained_variance_ - mean = self.mean_ + components = self.components_.to_output("cupy") + explained_variance = self.explained_variance_.to_output("cupy") + mean = self.mean_.to_output("cupy") if self.whiten: components = cp.sqrt(explained_variance[:, None]) * components @@ -543,16 +555,11 @@ class PCA(Base, return out - def _inverse_transform_dense(self, X, convert_dtype=True): - dtype = self.components_.dtype - X_m, n_rows, _, _ = input_to_cuml_array( - X, - check_dtype=dtype, - convert_to_dtype=(dtype if convert_dtype else None), - check_cols=self.n_components_, - ) + def _inverse_transform_dense(self, X, *, index=None): + dtype = X.dtype + n_rows = X.shape[0] - out = CumlArray.zeros((n_rows, self.n_features_in_), dtype=dtype) + out = cp.zeros((n_rows, self.n_features_in_), dtype=dtype, order="F") cdef paramsPCA params params.n_components = self.n_components_ @@ -560,8 +567,8 @@ class PCA(Base, params.n_cols = self.n_features_in_ params.whiten = self.whiten - cdef uintptr_t X_ptr = X_m.ptr - cdef uintptr_t X_inv_ptr = out.ptr + cdef uintptr_t X_ptr = X.data.ptr + cdef uintptr_t X_inv_ptr = out.data.ptr cdef uintptr_t components_ptr = self.components_.ptr cdef uintptr_t singular_values_ptr = self.singular_values_.ptr cdef uintptr_t mean_ptr = self.mean_.ptr @@ -588,7 +595,7 @@ class PCA(Base, params) handle.sync() - return out + return CumlArray(data=out, index=index) @generate_docstring(X='dense_sparse', return_values={'name': 'X_inv', @@ -611,19 +618,42 @@ class PCA(Base, """ check_is_fitted(self) + dtype = self.components_.dtype if is_sparse(X): + X = check_array( + X, + accept_sparse=True, + dtype=dtype, + convert_dtype=convert_dtype, + ) + if X.shape[1] != self.n_components_: + raise ValueError( + f"X has {X.shape[1]} features, but PCA is expecting " + f"{self.n_components_} features as input." + ) return self._inverse_transform_sparse( X, return_sparse=return_sparse, sparse_tol=sparse_tol ) - return self._inverse_transform_dense(X, convert_dtype=convert_dtype) + X, index = check_array( + X, + dtype=dtype, + convert_dtype=convert_dtype, + order="F", + return_index=True, + ) + if X.shape[1] != self.n_components_: + raise ValueError( + f"X has {X.shape[1]} features, but PCA is expecting " + f"{self.n_components_} features as input." + ) + return self._inverse_transform_dense(X, index=index) def _transform_sparse(self, X): X = cupyx.scipy.sparse.coo_matrix(X) - with using_output_type("cupy"): - components = self.components_ - explained_variance = self.explained_variance_ - mean = self.mean_ + components = self.components_.to_output("cupy") + explained_variance = self.explained_variance_.to_output("cupy") + mean = self.mean_.to_output("cupy") out = X @ components.T out -= (mean.reshape((1, -1)) @ components.T) @@ -634,19 +664,11 @@ class PCA(Base, out /= scale return out - def _transform_dense(self, X, convert_dtype=True): - dtype = self.components_.dtype + def _transform_dense(self, X, *, index=None): + dtype = X.dtype + n_rows, n_cols = X.shape - X_m, n_rows, n_cols, _ = input_to_cuml_array( - X, - check_dtype=dtype, - convert_to_dtype=(dtype if convert_dtype else None), - check_cols=self.n_features_in_, - ) - - out = CumlArray.zeros( - (n_rows, self.n_components_), dtype=dtype, index=X_m.index - ) + out = cp.zeros((n_rows, self.n_components_), dtype=dtype, order="F") cdef paramsPCA params params.n_components = self.n_components_ @@ -654,8 +676,8 @@ class PCA(Base, params.n_cols = n_cols params.whiten = self.whiten - cdef uintptr_t X_ptr = X_m.ptr - cdef uintptr_t out_ptr = out.ptr + cdef uintptr_t X_ptr = X.data.ptr + cdef uintptr_t out_ptr = out.data.ptr cdef uintptr_t components_ptr = self.components_.ptr cdef uintptr_t singular_values_ptr = self.singular_values_.ptr cdef uintptr_t mean_ptr = self.mean_.ptr @@ -685,7 +707,7 @@ class PCA(Base, params ) handle.sync() - return out + return CumlArray(data=out, index=index) @generate_docstring(X='dense_sparse', return_values={'name': 'trans', @@ -702,8 +724,23 @@ class PCA(Base, """ check_is_fitted(self) - check_features(self, X) if is_sparse(X): + X = check_inputs( + self, + X, + accept_sparse=True, + dtype=self.components_.dtype, + convert_dtype=convert_dtype, + ) return self._transform_sparse(X) - return self._transform_dense(X, convert_dtype=convert_dtype) + + X, index = check_inputs( + self, + X, + dtype=self.components_.dtype, + convert_dtype=convert_dtype, + order="F", + return_index=True, + ) + return self._transform_dense(X, index=index) diff --git a/python/cuml/cuml/decomposition/tsvd.pyx b/python/cuml/cuml/decomposition/tsvd.pyx index 05187ac348..73b8d71d0e 100644 --- a/python/cuml/cuml/decomposition/tsvd.pyx +++ b/python/cuml/cuml/decomposition/tsvd.pyx @@ -3,17 +3,21 @@ # SPDX-License-Identifier: Apache-2.0 # +import cupy as cp import numpy as np import cuml.internals -from cuml.common import input_to_cuml_array from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.common.doc_utils import generate_docstring from cuml.internals.array import CumlArray from cuml.internals.base import Base, get_handle from cuml.internals.interop import InteropMixin, to_cpu, to_gpu from cuml.internals.mixins import FMajorInputTagMixin -from cuml.internals.validation import check_features, check_is_fitted +from cuml.internals.validation import ( + check_array, + check_inputs, + check_is_fitted, +) from libc.stdint cimport uintptr_t from libcpp cimport bool @@ -286,7 +290,7 @@ class TruncatedSVD(Base, return self.components_.shape[0] @generate_docstring() - @cuml.internals.reflect + @cuml.internals.reflect(reset="type") def fit(self, X, y=None) -> "TruncatedSVD": """ Fit model on training cudf DataFrame X. y is currently ignored. @@ -299,21 +303,26 @@ class TruncatedSVD(Base, 'type': 'dense', 'description': 'Reduced version of X', 'shape': '(n_samples, n_components)'}) - @cuml.internals.reflect(reset=True) + @cuml.internals.reflect(reset="type") def fit_transform(self, X, y=None, *, convert_dtype=True) -> CumlArray: """ Fit model to X and perform dimensionality reduction on X. y is currently ignored. """ - # Validate input - X_m, n_rows, n_cols, dtype = input_to_cuml_array( + X, index = check_inputs( + self, X, - convert_to_dtype=(np.float32 if convert_dtype else None), - check_dtype=[np.float32, np.float64] + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + order="F", + return_index=True, + reset=True, ) - # Validate and initialize parameters + n_rows, n_cols = X.shape + dtype = X.dtype + if self.n_components > n_cols: raise ValueError( f"`n_components` ({self.n_components}) must be <= than the " @@ -337,19 +346,19 @@ class TruncatedSVD(Base, f"got {self.algorithm!r}" ) - # Allocate output arrays - components = CumlArray.zeros((self.n_components, n_cols), dtype=dtype) - explained_variance = CumlArray.zeros(self.n_components, dtype=dtype) - explained_variance_ratio = CumlArray.zeros(self.n_components, dtype=dtype) - singular_values = CumlArray.zeros(self.n_components, dtype=dtype) - out = CumlArray.zeros((n_rows, self.n_components), dtype=dtype, index=X_m.index) - - cdef uintptr_t X_ptr = X_m.ptr - cdef uintptr_t components_ptr = components.ptr - cdef uintptr_t explained_variance_ptr = explained_variance.ptr - cdef uintptr_t explained_variance_ratio_ptr = explained_variance_ratio.ptr - cdef uintptr_t singular_values_ptr = singular_values.ptr - cdef uintptr_t out_ptr = out.ptr + # Allocate output arrays (F-order expected by libcuml) + components = cp.zeros((self.n_components, n_cols), dtype=dtype, order="F") + explained_variance = cp.zeros(self.n_components, dtype=dtype) + explained_variance_ratio = cp.zeros(self.n_components, dtype=dtype) + singular_values = cp.zeros(self.n_components, dtype=dtype) + out = cp.zeros((n_rows, self.n_components), dtype=dtype, order="F") + + cdef uintptr_t X_ptr = X.data.ptr + cdef uintptr_t components_ptr = components.data.ptr + cdef uintptr_t explained_variance_ptr = explained_variance.data.ptr + cdef uintptr_t explained_variance_ratio_ptr = explained_variance_ratio.data.ptr + cdef uintptr_t singular_values_ptr = singular_values.data.ptr + cdef uintptr_t out_ptr = out.data.ptr cdef bool use_float32 = dtype == np.float32 handle = get_handle() cdef handle_t* handle_ = handle.getHandle() @@ -383,12 +392,12 @@ class TruncatedSVD(Base, handle.sync() # Store results - self.components_ = components - self.explained_variance_ = explained_variance - self.explained_variance_ratio_ = explained_variance_ratio - self.singular_values_ = singular_values + self.components_ = CumlArray(data=components) + self.explained_variance_ = CumlArray(data=explained_variance) + self.explained_variance_ratio_ = CumlArray(data=explained_variance_ratio) + self.singular_values_ = CumlArray(data=singular_values) - return out + return CumlArray(data=out, index=index) @generate_docstring(return_values={'name': 'X_original', 'type': 'dense', @@ -404,24 +413,30 @@ class TruncatedSVD(Base, check_is_fitted(self) dtype = self.components_.dtype - X_m, n_rows, _, _ = input_to_cuml_array( + X, index = check_array( X, - check_dtype=dtype, - convert_to_dtype=(dtype if convert_dtype else None), - check_cols=self.n_components, + dtype=dtype, + convert_dtype=convert_dtype, + order="F", + return_index=True, ) + if X.shape[1] != self.n_components: + raise ValueError( + f"X has {X.shape[1]} features, but TruncatedSVD is expecting " + f"{self.n_components} features as input." + ) + + n_rows = X.shape[0] cdef paramsTSVD params params.n_components = self.n_components params.n_rows = n_rows params.n_cols = self.n_features_in_ - out = CumlArray.zeros( - (n_rows, self.n_features_in_), dtype=dtype, index=X_m.index - ) + out = cp.zeros((n_rows, self.n_features_in_), dtype=dtype, order="F") - cdef uintptr_t X_ptr = X_m.ptr - cdef uintptr_t out_ptr = out.ptr + cdef uintptr_t X_ptr = X.data.ptr + cdef uintptr_t out_ptr = out.data.ptr cdef uintptr_t components_ptr = self.components_.ptr cdef bool use_float32 = dtype == np.float32 handle = get_handle() @@ -446,7 +461,7 @@ class TruncatedSVD(Base, ) handle.sync() - return out + return CumlArray(data=out, index=index) @generate_docstring(return_values={'name': 'X_new', 'type': 'dense', @@ -459,25 +474,28 @@ class TruncatedSVD(Base, """ check_is_fitted(self) - check_features(self, X) - dtype = self.components_.dtype - X_m, n_rows, _, _ = input_to_cuml_array( + X, index = check_inputs( + self, X, - check_dtype=dtype, - convert_to_dtype=(dtype if convert_dtype else None), - check_cols=self.n_features_in_, + dtype=self.components_.dtype, + convert_dtype=convert_dtype, + order="F", + return_index=True, ) + n_rows = X.shape[0] + dtype = X.dtype + cdef paramsTSVD params params.n_components = self.n_components params.n_rows = n_rows params.n_cols = self.n_features_in_ - out = CumlArray.zeros((n_rows, self.n_components), dtype=dtype, index=X_m.index) + out = cp.zeros((n_rows, self.n_components), dtype=dtype, order="F") - cdef uintptr_t X_ptr = X_m.ptr - cdef uintptr_t out_ptr = out.ptr + cdef uintptr_t X_ptr = X.data.ptr + cdef uintptr_t out_ptr = out.data.ptr cdef uintptr_t components_ptr = self.components_.ptr cdef bool use_float32 = dtype == np.float32 handle = get_handle() @@ -502,4 +520,4 @@ class TruncatedSVD(Base, ) handle.sync() - return out + return CumlArray(data=out, index=index) diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index 16c5383a08..07a6d6a26e 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -1133,12 +1133,8 @@ - "sklearn.tests.test_common::test_estimators[NearestNeighbors()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[NearestNeighbors()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[NearestNeighbors()-check_estimators_nan_inf]" - - "sklearn.tests.test_common::test_estimators[PCA()-check_dtype_object]" - - "sklearn.tests.test_common::test_estimators[PCA()-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_estimators[PCA()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[PCA()-check_fit2d_1feature]" - "sklearn.tests.test_common::test_estimators[PCA()-check_fit2d_1sample]" - - "sklearn.tests.test_common::test_estimators[PCA()-check_transformer_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_classifier_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_classifiers_multilabel_output_format_decision_function]" - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_classifiers_train(readonly_memmap=True)]" @@ -1160,12 +1156,8 @@ - "sklearn.tests.test_common::test_estimators[TSNE()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[TSNE()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[TSNE()-check_estimators_nan_inf]" - - "sklearn.tests.test_common::test_estimators[TruncatedSVD()-check_dtype_object]" - - "sklearn.tests.test_common::test_estimators[TruncatedSVD()-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_estimators[TruncatedSVD()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[TruncatedSVD()-check_fit2d_1feature]" - "sklearn.tests.test_common::test_estimators[TruncatedSVD()-check_fit2d_1sample]" - - "sklearn.tests.test_common::test_estimators[TruncatedSVD()-check_transformer_data_not_an_array]" - reason: test_estimators checks fail marker: cuml_accel_test_estimators strict: false diff --git a/python/cuml/tests/test_incremental_pca.py b/python/cuml/tests/test_incremental_pca.py index dc5a256c8c..f7ef3b3f31 100644 --- a/python/cuml/tests/test_incremental_pca.py +++ b/python/cuml/tests/test_incremental_pca.py @@ -38,12 +38,6 @@ def test_fit( batch_size_divider, whiten, ): - if sparse_format == "csc": - pytest.skip( - "cupyx.scipy.sparse.csc.csc_matrix does not support" - " indexing as of cupy 7.6.0" - ) - if sparse_input: X = cupyx.scipy.sparse.random( nrows, diff --git a/python/cuml/tests/test_sklearn_compatibility.py b/python/cuml/tests/test_sklearn_compatibility.py index e88be0bf9f..719ef3030f 100644 --- a/python/cuml/tests/test_sklearn_compatibility.py +++ b/python/cuml/tests/test_sklearn_compatibility.py @@ -279,25 +279,16 @@ }, PCA: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_dtype_object": "PCA does not handle object dtype", - "check_estimators_empty_data_messages": "PCA does not handle empty data", - "check_estimators_nan_inf": "PCA does not check for NaN and inf", "check_transformer_data_not_an_array": "PCA does not handle non-array data", "check_fit2d_1sample": "PCA does not handle single sample", "check_fit2d_1feature": "PCA does not handle single feature", }, IncrementalPCA: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_dtype_object": "IncrementalPCA does not handle object dtype", - "check_estimators_empty_data_messages": "IncrementalPCA does not handle empty data", - "check_estimators_nan_inf": "IncrementalPCA does not check for NaN and inf", "check_transformer_data_not_an_array": "IncrementalPCA does not handle non-array data", }, TruncatedSVD: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_dtype_object": "TruncatedSVD does not handle object dtype", - "check_estimators_empty_data_messages": "TruncatedSVD does not handle empty data", - "check_estimators_nan_inf": "TruncatedSVD does not check for NaN and inf", "check_transformer_data_not_an_array": "TruncatedSVD does not handle non-array data", "check_fit2d_1sample": "TruncatedSVD does not handle single sample", "check_fit2d_1feature": "TruncatedSVD does not handle single feature", From eaf6a85eb9a3a07bac29b24d280744f6d378f3b1 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 01:57:40 +0000 Subject: [PATCH 02/10] Optimize PCA.fit sparse ingest. --- python/cuml/cuml/decomposition/pca.pyx | 32 +++++++++----------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index 60d9b19fe8..9ec722b97f 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -381,7 +381,7 @@ class PCA(Base, f"got {self.svd_solver!r}" ) - # Allocate output arrays (F-order: libcuml writes column-major) + # Allocate output arrays (F-order expected by libcuml)) components = cp.zeros( (self.n_components_, self.n_features_in_), dtype=X.dtype, order="F" ) @@ -484,25 +484,15 @@ class PCA(Base, Fit the model with X. y is currently ignored. """ - if (sparse := is_sparse(X)): - X = check_inputs( - self, - X, - accept_sparse=True, - dtype=("float32", "float64"), - convert_dtype=convert_dtype, - reset=True, - ) - X = cupyx.scipy.sparse.coo_matrix(X) - else: - X = check_inputs( - self, - X, - dtype=("float32", "float64"), - convert_dtype=convert_dtype, - order="F", - reset=True, - ) + X = check_inputs( + self, + X, + accept_sparse=["coo"], + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + order="F", + reset=True, + ) n_rows, n_cols = X.shape self.n_samples_ = n_rows @@ -517,7 +507,7 @@ class PCA(Base, else: self.n_components_ = self.n_components - if sparse: + if is_sparse(X): self._fit_sparse(X) else: self._fit_dense(X) From 666bfa05c469216f2bda0109ed982058bba92a55 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 02:09:15 +0000 Subject: [PATCH 03/10] Further streamline pca.pyx --- python/cuml/cuml/decomposition/pca.pyx | 40 ++++++-------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index 9ec722b97f..c86f333913 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -527,8 +527,6 @@ class PCA(Base, return self.fit(X).transform(X) def _inverse_transform_sparse(self, X, return_sparse=False, sparse_tol=1e-10): - X = cupyx.scipy.sparse.coo_matrix(X) - components = self.components_.to_output("cupy") explained_variance = self.explained_variance_.to_output("cupy") mean = self.mean_.to_output("cupy") @@ -608,25 +606,10 @@ class PCA(Base, """ check_is_fitted(self) - dtype = self.components_.dtype - if is_sparse(X): - X = check_array( - X, - accept_sparse=True, - dtype=dtype, - convert_dtype=convert_dtype, - ) - if X.shape[1] != self.n_components_: - raise ValueError( - f"X has {X.shape[1]} features, but PCA is expecting " - f"{self.n_components_} features as input." - ) - return self._inverse_transform_sparse( - X, return_sparse=return_sparse, sparse_tol=sparse_tol - ) X, index = check_array( X, - dtype=dtype, + accept_sparse=True, + dtype=self.components_.dtype, convert_dtype=convert_dtype, order="F", return_index=True, @@ -636,11 +619,13 @@ class PCA(Base, f"X has {X.shape[1]} features, but PCA is expecting " f"{self.n_components_} features as input." ) + if is_sparse(X): + return self._inverse_transform_sparse( + X, return_sparse=return_sparse, sparse_tol=sparse_tol + ) return self._inverse_transform_dense(X, index=index) def _transform_sparse(self, X): - X = cupyx.scipy.sparse.coo_matrix(X) - components = self.components_.to_output("cupy") explained_variance = self.explained_variance_.to_output("cupy") mean = self.mean_.to_output("cupy") @@ -715,22 +700,15 @@ class PCA(Base, """ check_is_fitted(self) - if is_sparse(X): - X = check_inputs( - self, - X, - accept_sparse=True, - dtype=self.components_.dtype, - convert_dtype=convert_dtype, - ) - return self._transform_sparse(X) - X, index = check_inputs( self, X, + accept_sparse=True, dtype=self.components_.dtype, convert_dtype=convert_dtype, order="F", return_index=True, ) + if is_sparse(X): + return self._transform_sparse(X) return self._transform_dense(X, index=index) From 451fba8f3c8af8561f39ff75d18b4941ee8bcbf5 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 02:12:50 +0000 Subject: [PATCH 04/10] Improve incremental_pca.py --- .../cuml/decomposition/incremental_pca.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index 017e25176b..2ae1a7bed1 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -6,7 +6,6 @@ import numbers import cupy as cp -import cupyx import cuml.internals from cuml.common.sparse_utils import is_sparse @@ -16,6 +15,7 @@ from cuml.internals.validation import ( check_array, check_features, + check_inputs, check_is_fitted, ) @@ -219,6 +219,8 @@ def fit(self, X, y=None, *, convert_dtype=True) -> "IncrementalPCA": self.mean_ = 0.0 self.var_ = 0.0 + # Sparse inputs are sliced into row batches below; restrict to CSR/CSC + # which support that. X = check_array( X, accept_sparse=["csr", "csc"], @@ -238,7 +240,7 @@ def fit(self, X, y=None, *, convert_dtype=True) -> "IncrementalPCA": n_samples, self.batch_size_, min_batch_size=self.n_components or 0 ): X_batch = X[batch] - if cupyx.scipy.sparse.issparse(X_batch): + if is_sparse(X_batch): X_batch = X_batch.toarray() self.partial_fit(X_batch, check_input=False) @@ -412,10 +414,13 @@ def transform(self, X, *, convert_dtype=False) -> CumlArray: """ check_is_fitted(self) - check_features(self, X) if is_sparse(X): - X = check_array( + # CSR/CSC support fast row slicing for the per-batch projection + # below. Validate `X` once here and call `_transform_sparse` + # directly per batch to avoid re-validating every slice. + X = check_inputs( + self, X, accept_sparse=["csr", "csc"], dtype=self.components_.dtype, @@ -429,11 +434,10 @@ def transform(self, X, *, convert_dtype=False) -> CumlArray: self.batch_size_, min_batch_size=self.n_components or 0, ): - output.append(super().transform(X[batch])) - return CumlArray( - data=cp.vstack([o.to_output("cupy") for o in output]) - ) + output.append(self._transform_sparse(X[batch])) + return CumlArray(data=cp.vstack(output)) else: + # `PCA.transform` validates `X` itself, so don't re-check here. return super().transform(X) @classmethod From 41710242ff00ee3a716d7ad523aa007945154512 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 02:17:49 +0000 Subject: [PATCH 05/10] improve error message for inverse_transform --- python/cuml/cuml/decomposition/pca.pyx | 4 ++-- python/cuml/cuml/decomposition/tsvd.pyx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index c86f333913..c8c47396f9 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -616,8 +616,8 @@ class PCA(Base, ) if X.shape[1] != self.n_components_: raise ValueError( - f"X has {X.shape[1]} features, but PCA is expecting " - f"{self.n_components_} features as input." + f"X has {X.shape[1]} columns, but PCA.inverse_transform " + f"expects {self.n_components_} (one per fitted component)." ) if is_sparse(X): return self._inverse_transform_sparse( diff --git a/python/cuml/cuml/decomposition/tsvd.pyx b/python/cuml/cuml/decomposition/tsvd.pyx index 73b8d71d0e..8d257738d3 100644 --- a/python/cuml/cuml/decomposition/tsvd.pyx +++ b/python/cuml/cuml/decomposition/tsvd.pyx @@ -412,21 +412,21 @@ class TruncatedSVD(Base, """ check_is_fitted(self) - dtype = self.components_.dtype X, index = check_array( X, - dtype=dtype, + dtype=self.components_.dtype, convert_dtype=convert_dtype, order="F", return_index=True, ) if X.shape[1] != self.n_components: raise ValueError( - f"X has {X.shape[1]} features, but TruncatedSVD is expecting " - f"{self.n_components} features as input." + f"X has {X.shape[1]} columns, but TruncatedSVD.inverse_transform " + f"expects {self.n_components} (one per fitted component)." ) n_rows = X.shape[0] + dtype = X.dtype cdef paramsTSVD params params.n_components = self.n_components From 527798cc01084712cdcd6b098ac68cbc28a62122 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 02:31:41 +0000 Subject: [PATCH 06/10] Do not strip metadata in IncrementalPCA.fit() --- python/cuml/cuml/decomposition/incremental_pca.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index 2ae1a7bed1..dcbf322a86 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -219,6 +219,8 @@ def fit(self, X, y=None, *, convert_dtype=True) -> "IncrementalPCA": self.mean_ = 0.0 self.var_ = 0.0 + check_features(self, X, reset=True) + # Sparse inputs are sliced into row batches below; restrict to CSR/CSC # which support that. X = check_array( @@ -227,7 +229,6 @@ def fit(self, X, y=None, *, convert_dtype=True) -> "IncrementalPCA": dtype=("float32", "float64"), convert_dtype=convert_dtype, ) - check_features(self, X, reset=True) n_samples, n_features = X.shape From 21c53a404ee5eca796368fc74d6ec9b118cf7b97 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 14:59:36 +0000 Subject: [PATCH 07/10] Simplify IncrementalPCA.fit() ingestion. --- python/cuml/cuml/decomposition/incremental_pca.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index dcbf322a86..72c3b4d55b 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -219,15 +219,15 @@ def fit(self, X, y=None, *, convert_dtype=True) -> "IncrementalPCA": self.mean_ = 0.0 self.var_ = 0.0 - check_features(self, X, reset=True) - # Sparse inputs are sliced into row batches below; restrict to CSR/CSC # which support that. - X = check_array( + X = check_inputs( + self, X, accept_sparse=["csr", "csc"], dtype=("float32", "float64"), convert_dtype=convert_dtype, + reset=True, ) n_samples, n_features = X.shape From 3063ec6b292680241960e501a6dd7aa27cf9dd74 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 15:17:25 +0000 Subject: [PATCH 08/10] Make test_sparse_not_implemented_exception tolerant against capitalization --- python/cuml/tests/test_exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cuml/tests/test_exceptions.py b/python/cuml/tests/test_exceptions.py index 114cd8466b..db2a40fb93 100644 --- a/python/cuml/tests/test_exceptions.py +++ b/python/cuml/tests/test_exceptions.py @@ -28,7 +28,7 @@ def test_sparse_not_implemented_exception(estimator_name): y_reg = np.array([0.0, 1.0]) estimator = estimators[estimator_name]() # Fit or fit_transform depending on the estimator type - with pytest.raises(TypeError, match="sparse"): + with pytest.raises(TypeError, match="[Ss]parse"): if isinstance(estimator, (KMeans, DBSCAN, TruncatedSVD)): if hasattr(estimator, "fit_transform"): estimator.fit_transform(X_sparse) From 3f319779e2bb9249151bb6bd2d70fe776fa1bba0 Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 16:23:49 +0000 Subject: [PATCH 09/10] update xfail list --- .../upstream/scikit-learn/xfail-list.yaml | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index 07a6d6a26e..9706266e34 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -481,16 +481,9 @@ - "sklearn.tests.test_common::test_estimators[SpectralCoclustering()-check_fit2d_1feature]" - "sklearn.tests.test_common::test_estimators[SpectralCoclustering()-check_fit2d_1sample]" - "sklearn.tests.test_common::test_estimators[SpectralCoclustering()-check_methods_subset_invariance]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_grid={'logisticregression__C':[0.1,1.0]})-check_classifier_data_not_an_array]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_grid={'logisticregression__C':[0.1,1.0]})-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_grid={'logisticregression__C':[0.1,1.0]})-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_grid={'logisticregression__C':[0.1,1.0]})-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_grid={'logisticregression__C':[0.1,1.0]})-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_fit2d_1feature]" - - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_regressor_data_not_an_array]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_grid={'ridge__alpha':[0.1,1.0]})-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,estimator=LogisticRegression(),param_grid={'C':[0.1,1.0]})-check_classifier_data_not_an_array]" @@ -502,20 +495,13 @@ - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,estimator=Ridge(),param_grid={'alpha':[0.1,1.0]})-check_regressor_data_not_an_array]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,estimator=Ridge(),param_grid={'alpha':[0.1,1.0]})-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[GridSearchCV(cv=2,estimator=Ridge(),param_grid={'alpha':[0.1,1.0]})-check_supervised_y_no_nan]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),min_resources='smallest',param_grid={'logisticregression__C':[0.1,1.0]},random_state=0)-check_classifier_data_not_an_array]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),min_resources='smallest',param_grid={'logisticregression__C':[0.1,1.0]},random_state=0)-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),min_resources='smallest',param_grid={'logisticregression__C':[0.1,1.0]},random_state=0)-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),min_resources='smallest',param_grid={'logisticregression__C':[0.1,1.0]},random_state=0)-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),min_resources='smallest',param_grid={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self(readonly_memmap=True)]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_overwrite_params]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_fit2d_1sample]" - - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_regressor_data_not_an_array]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),min_resources='smallest',param_grid={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,estimator=LogisticRegression(),min_resources='smallest',param_grid={'C':[0.1,1.0]},random_state=0)-check_classifier_data_not_an_array0]" @@ -538,34 +524,20 @@ - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,estimator=Ridge(),min_resources='smallest',param_grid={'alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d1]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,estimator=Ridge(),min_resources='smallest',param_grid={'alpha':[0.1,1.0]},random_state=0)-check_supervised_y_no_nan0]" - "sklearn.tests.test_common::test_search_cv[HalvingGridSearchCV(cv=2,estimator=Ridge(),min_resources='smallest',param_grid={'alpha':[0.1,1.0]},random_state=0)-check_supervised_y_no_nan1]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_classifier_data_not_an_array]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1sample]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self(readonly_memmap=True)]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_fit_returns_self]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_overwrite_params]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_fit2d_1sample]" - - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_regressor_data_not_an_array]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[HalvingRandomSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_no_nan]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_classifier_data_not_an_array]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('logisticregression',LogisticRegression())]),param_distributions={'logisticregression__C':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_dtype_object]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_fit2d_1feature]" - - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_regressor_data_not_an_array]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_2d]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,error_score='raise',estimator=Pipeline(steps=[('pca',PCA()),('ridge',Ridge())]),param_distributions={'ridge__alpha':[0.1,1.0]},random_state=0)-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_search_cv[RandomizedSearchCV(cv=2,estimator=LogisticRegression(),param_distributions={'C':[0.1,1.0]},random_state=0)-check_classifier_data_not_an_array]" From 873b231066436edbcf28b0d5d8b84b171acaf93b Mon Sep 17 00:00:00 2001 From: Simon Adorf Date: Thu, 23 Apr 2026 15:39:04 +0000 Subject: [PATCH 10/10] Fix IncrementalPCA first fit_partial then transform on sparse. --- python/cuml/cuml/decomposition/incremental_pca.py | 7 +++++-- python/cuml/tests/test_incremental_pca.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index 72c3b4d55b..b89a1e73ca 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -428,11 +428,14 @@ def transform(self, X, *, convert_dtype=False) -> CumlArray: convert_dtype=convert_dtype, ) - n_samples = X.shape[0] + n_samples, n_features = X.shape + batch_size = getattr( + self, "batch_size_", self.batch_size or 5 * n_features + ) output = [] for batch in _gen_batches( n_samples, - self.batch_size_, + batch_size, min_batch_size=self.n_components or 0, ): output.append(self._transform_sparse(X[batch])) diff --git a/python/cuml/tests/test_incremental_pca.py b/python/cuml/tests/test_incremental_pca.py index f7ef3b3f31..7871cc2198 100644 --- a/python/cuml/tests/test_incremental_pca.py +++ b/python/cuml/tests/test_incremental_pca.py @@ -133,6 +133,21 @@ def test_exceptions(): cuIPCA(n_components=8).fit(X[:, :5]) +@pytest.mark.parametrize("batch_size", [None, 50]) +def test_partial_fit_then_sparse_transform(batch_size): + X_dense, _ = make_blobs( + n_samples=200, n_features=10, random_state=0, dtype="float64" + ) + X_sparse = cupyx.scipy.sparse.csr_matrix(X_dense) + + ipca = cuIPCA(n_components=4, batch_size=batch_size) + for i in range(0, 200, 50): + ipca.partial_fit(X_dense[i : i + 50]) + + result = ipca.transform(X_sparse) + assert result.shape == (200, 4) + + def test_svd_flip(): x = cp.array(range(-10, 80)).reshape((9, 10)) u, s, v = cp.linalg.svd(x, full_matrices=False)