From ecdf895632bb64a00356f882353a5eaae981409f Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:06:30 -0500 Subject: [PATCH 01/12] Use `check_array` in `cuml.fil` --- python/cuml/cuml/fil/fil.pyx | 47 +++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/python/cuml/cuml/fil/fil.pyx b/python/cuml/cuml/fil/fil.pyx index ce2f0371eb..1497d69cc9 100644 --- a/python/cuml/cuml/fil/fil.pyx +++ b/python/cuml/cuml/fil/fil.pyx @@ -6,19 +6,21 @@ import itertools import pathlib from time import perf_counter +import cupy as cp import numpy as np import treelite.sklearn +from cuda.bindings import runtime import cuml.internals.nvtx as nvtx from cuml.internals.array import CumlArray from cuml.internals.base import Base, get_handle from cuml.internals.device_type import DeviceType, DeviceTypeError from cuml.internals.global_settings import GlobalSettings -from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.mem_type import MemoryType from cuml.internals.mixins import CMajorInputTagMixin from cuml.internals.outputs import reflect from cuml.internals.treelite import safe_treelite_call +from cuml.internals.validation import check_array from libc.stdint cimport uint32_t, uintptr_t from libcpp cimport bool @@ -41,8 +43,6 @@ from cuml.internals.treelite cimport ( TreeliteModelHandle, ) -from cuda.bindings import runtime - cdef extern from "cuml/fil/forest_model.hpp" namespace "ML::fil" nogil: cdef cppclass forest_model: @@ -124,12 +124,15 @@ def get_fil_device_type() -> DeviceType: cdef raft_proto_device_t get_fil_raft_proto_device_type(arr): """Get the current FIL device type as a raft_proto_device_t""" - cdef raft_proto_device_t dev - if arr.mem_type is MemoryType.device: - dev = raft_proto_device_t.gpu + if isinstance(arr, cp.ndarray): + return raft_proto_device_t.gpu + elif isinstance(arr, np.ndarray): + return raft_proto_device_t.cpu else: - dev = raft_proto_device_t.cpu - return dev + if arr.mem_type is MemoryType.device: + return raft_proto_device_t.gpu + else: + return raft_proto_device_t.cpu cdef class ForestInference_impl(): @@ -249,18 +252,22 @@ cdef class ForestInference_impl(): def _predict(self, X, *, predict_type="default", preds=None, chunk_size=None): model_dtype = self.get_dtype() + mem_type = GlobalSettings().fil_memory_type - cdef uintptr_t in_ptr - in_arr, n_rows, _, _ = input_to_cuml_array( + X, index = check_array( X, - order='C', - convert_to_dtype=model_dtype, - convert_to_mem_type=GlobalSettings().fil_memory_type, - check_dtype=model_dtype + dtype=model_dtype, + order="C", + mem_type=mem_type.name, + return_index=True, + input_name="X", + ) + n_rows = X.shape[0] + + cdef raft_proto_device_t in_dev = get_fil_raft_proto_device_type(X) + cdef uintptr_t in_ptr = ( + X.data.ptr if isinstance(X, cp.ndarray) else X.ctypes.data ) - cdef raft_proto_device_t in_dev - in_dev = get_fil_raft_proto_device_type(in_arr) - in_ptr = in_arr.ptr cdef uintptr_t out_ptr cdef infer_kind infer_type_enum @@ -283,14 +290,14 @@ cdef class ForestInference_impl(): output_shape, model_dtype, order='C', - index=in_arr.index, - mem_type=GlobalSettings().fil_memory_type, + index=index, + mem_type=mem_type, ) else: # TODO(wphicks): Handle incorrect dtype/device/layout in C++ if preds.shape != output_shape: raise ValueError(f"If supplied, preds argument must have shape {output_shape}") - preds.index = in_arr.index + preds.index = index cdef raft_proto_device_t out_dev out_dev = get_fil_raft_proto_device_type(preds) out_ptr = preds.ptr From a57e41347e8d79de8141b00de6b929b3003e6f0f Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:07:37 -0500 Subject: [PATCH 02/12] Use `check_inputs` in `cuml.ensemble` --- .../cuml/ensemble/randomforest_common.pyx | 4 +-- .../cuml/ensemble/randomforestclassifier.py | 28 ++++++++----------- .../cuml/ensemble/randomforestregressor.py | 27 ++++++------------ 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/python/cuml/cuml/ensemble/randomforest_common.pyx b/python/cuml/cuml/ensemble/randomforest_common.pyx index 921608252e..ca5a741840 100644 --- a/python/cuml/cuml/ensemble/randomforest_common.pyx +++ b/python/cuml/cuml/ensemble/randomforest_common.pyx @@ -414,8 +414,8 @@ class BaseRandomForestModel(Base, InteropMixin): cdef bool is_classifier = self._estimator_type == "classifier" cdef bool is_float32 = X.dtype == np.float32 - cdef uintptr_t X_ptr = X.ptr - cdef uintptr_t y_ptr = y.ptr + cdef uintptr_t X_ptr = X.data.ptr + cdef uintptr_t y_ptr = y.data.ptr cdef int n_rows = X.shape[0] cdef int n_cols = X.shape[1] cdef level_enum verbose = self._verbose_level diff --git a/python/cuml/cuml/ensemble/randomforestclassifier.py b/python/cuml/cuml/ensemble/randomforestclassifier.py index 3ec5bc7405..104f187b69 100644 --- a/python/cuml/cuml/ensemble/randomforestclassifier.py +++ b/python/cuml/cuml/ensemble/randomforestclassifier.py @@ -1,9 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 -import cupy as cp -import numpy as np - import cuml.internals import cuml.internals.nvtx as nvtx from cuml.common.array_descriptor import CumlArrayDescriptor @@ -11,10 +8,9 @@ from cuml.common.doc_utils import generate_docstring, insert_into_docstring from cuml.ensemble.randomforest_common import BaseRandomForestModel from cuml.internals.array import CumlArray -from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.interop import UnsupportedOnGPU from cuml.internals.mixins import ClassifierMixin -from cuml.internals.validation import check_features, check_y +from cuml.internals.validation import check_features, check_inputs from cuml.metrics import accuracy_score @@ -225,19 +221,20 @@ def fit(self, X, y, *, convert_dtype=True) -> "RandomForestClassifier": y to be of dtype int32. This will increase memory used for the method. """ - y, classes = check_y(y, dtype=cp.int32, return_classes=True) - X_m = input_to_cuml_array( + X, y, classes = check_inputs( + self, X, - convert_to_dtype=(np.float32 if convert_dtype else None), - check_dtype=[np.float32, np.float64], + y, + dtype=("float32", "float64"), + convert_dtype=convert_dtype, order="F", - check_rows=y.shape[0], - ).array + y_dtype="int32", + return_classes=True, + reset=True, + ) self.classes_ = classes self.n_classes_ = len(classes) - y_m = CumlArray(data=y) - - return self._fit_forest(X_m, y_m) + return self._fit_forest(X, y) @nvtx.annotate( message="predict RF-Classifier @randomforestclassifier.pyx", @@ -309,8 +306,7 @@ def predict_proba( align_bytes=None, ) -> CumlArray: """ - Predicts class probabilities for X. This function uses the GPU - implementation of predict. + Predicts class probabilities for X. Parameters ---------- diff --git a/python/cuml/cuml/ensemble/randomforestregressor.py b/python/cuml/cuml/ensemble/randomforestregressor.py index d232a9f633..ef31f4c216 100644 --- a/python/cuml/cuml/ensemble/randomforestregressor.py +++ b/python/cuml/cuml/ensemble/randomforestregressor.py @@ -1,16 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 -import numpy as np - import cuml.internals.nvtx as nvtx -from cuml.common import input_to_cuml_array from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.common.doc_utils import generate_docstring, insert_into_docstring from cuml.ensemble.randomforest_common import BaseRandomForestModel from cuml.internals.array import CumlArray from cuml.internals.mixins import RegressorMixin from cuml.internals.outputs import reflect, run_in_internal_context -from cuml.internals.validation import check_features +from cuml.internals.validation import check_features, check_inputs from cuml.metrics import r2_score @@ -187,22 +184,16 @@ def fit(self, X, y, *, convert_dtype=True) -> "RandomForestRegressor": Perform Random Forest Regression on the input data """ - X_m = input_to_cuml_array( + X, y = check_inputs( + self, X, - convert_to_dtype=(np.float32 if convert_dtype else None), - check_dtype=[np.float32, np.float64], - order="F", - ).array - - y_m = input_to_cuml_array( y, - convert_to_dtype=(X_m.dtype if convert_dtype else None), - check_dtype=X_m.dtype, - check_rows=X_m.shape[0], - check_cols=1, - ).array - - return self._fit_forest(X_m, y_m) + dtype=("float32", "float64"), + convert_dtype=convert_dtype, + order="F", + reset=True, + ) + return self._fit_forest(X, y) @nvtx.annotate( message="predict RF-Regressor @randomforestclassifier.pyx", From 971e268df410f93c382cf0a2f4864d6916b0237c Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:20:38 -0500 Subject: [PATCH 03/12] Update sklearn compatibility xfails --- .../cuml/tests/test_sklearn_compatibility.py | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/python/cuml/tests/test_sklearn_compatibility.py b/python/cuml/tests/test_sklearn_compatibility.py index ed0fc0b84f..398e1d24f3 100644 --- a/python/cuml/tests/test_sklearn_compatibility.py +++ b/python/cuml/tests/test_sklearn_compatibility.py @@ -118,17 +118,14 @@ RandomForestRegressor: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", "check_do_not_raise_errors_in_init_or_set_params": "RandomForestRegressor raises errors in init or set_params", - "check_dtype_object": "RandomForestRegressor does not handle object dtype", - "check_estimators_empty_data_messages": "RandomForestRegressor does not handle empty data", - "check_estimators_nan_inf": "RandomForestRegressor does not check for NaN and inf", - "check_regressors_train": "RandomForestRegressor does not handle list inputs", - "check_regressors_train(readonly_memmap=True)": "RandomForestRegressor does not handle readonly memmap", - "check_regressors_train(readonly_memmap=True,X_dtype=float32)": "RandomForestRegressor does not handle readonly memmap with float32", "check_regressor_data_not_an_array": "RandomForestRegressor does not handle non-array data", - "check_supervised_y_2d": "RandomForestRegressor does not handle 2D y", - "check_supervised_y_no_nan": "RandomForestRegressor does not check for NaN in y", "check_dict_unchanged": "RandomForestRegressor modifies input dictionaries", - "check_requires_y_none": "RandomForestRegressor does not handle y=None", + }, + RandomForestClassifier: { + "check_estimator_tags_renamed": "No support for modern tags infrastructure", + "check_do_not_raise_errors_in_init_or_set_params": "RandomForestClassifier raises errors in init or set_params", + "check_classifier_data_not_an_array": "RandomForestClassifier does not handle non-array data", + "check_dict_unchanged": "RandomForestClassifier modifies input dictionaries", }, KNeighborsClassifier: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", @@ -139,18 +136,6 @@ "check_classifier_data_not_an_array": "KNeighborsClassifier does not handle non-array data", "check_classifiers_train": "KNeighborsClassifier does not validate input data properly", }, - RandomForestClassifier: { - "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_do_not_raise_errors_in_init_or_set_params": "RandomForestClassifier raises errors in init or set_params", - "check_dtype_object": "RandomForestClassifier does not handle object dtype", - "check_estimators_empty_data_messages": "RandomForestClassifier does not handle empty data", - "check_estimators_nan_inf": "RandomForestClassifier does not check for NaN and inf", - "check_classifier_data_not_an_array": "RandomForestClassifier does not handle non-array data", - "check_classifiers_train": "RandomForestClassifier does not handle list inputs", - "check_classifiers_train(readonly_memmap=True)": "RandomForestClassifier does not handle readonly memmap", - "check_classifiers_train(readonly_memmap=True,X_dtype=float32)": "RandomForestClassifier does not handle readonly memmap with float32", - "check_dict_unchanged": "RandomForestClassifier modifies input dictionaries", - }, KNeighborsRegressor: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", "check_do_not_raise_errors_in_init_or_set_params": "KNeighborsRegressor raises errors in init or set_params", From 9b373253b74a672afca68d326d06a4febaf16356 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:13:19 -0500 Subject: [PATCH 04/12] Add `RandomForestClassifier.predict_log_proba` --- .../cuml/ensemble/randomforestclassifier.py | 52 +++++++++++++++++++ python/cuml/tests/test_random_forest.py | 9 ++++ 2 files changed, 61 insertions(+) diff --git a/python/cuml/cuml/ensemble/randomforestclassifier.py b/python/cuml/cuml/ensemble/randomforestclassifier.py index 104f187b69..4634915692 100644 --- a/python/cuml/cuml/ensemble/randomforestclassifier.py +++ b/python/cuml/cuml/ensemble/randomforestclassifier.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 +import cupy as cp import cuml.internals import cuml.internals.nvtx as nvtx @@ -339,6 +340,57 @@ def predict_proba( check_features(self, X) return fil.predict_proba(X) + @insert_into_docstring( + parameters=[("dense", "(n_samples, n_features)")], + return_values=[("dense", "(n_samples, 1)")], + ) + @cuml.internals.reflect + def predict_log_proba( + self, + X, + *, + convert_dtype=True, + layout="depth_first", + default_chunk_size=None, + align_bytes=None, + ) -> CumlArray: + """ + Predicts log class probabilities for X. + + Parameters + ---------- + X : {} + convert_dtype : bool (default = True) + When True, automatically convert the input to the data type used + to train the model. This may increase memory usage. + layout : string (default = 'depth_first') + Specifies the in-memory layout of nodes in FIL forests. Options: + 'depth_first', 'layered', 'breadth_first'. + default_chunk_size : int, optional (default = None) + Determines how batches are further subdivided for parallel processing. + The optimal value depends on hardware, model, and batch size. + If None, will be automatically determined. + align_bytes : int, optional (default = None) + If specified, trees will be padded such that their in-memory size is + a multiple of this value. This can improve performance by guaranteeing + that memory reads from trees begin on a cache line boundary. + Typical values are 0 or 128 on GPU and 0 or 64 on CPU. + + Returns + ------- + y : {} + """ + preds = self.predict_proba( + X, + convert_dtype=convert_dtype, + layout=layout, + default_chunk_size=default_chunk_size, + align_bytes=align_bytes, + ) + out = preds.to_output("cupy") + cp.log(out, out=out) + return CumlArray(data=out, index=preds.index) + @nvtx.annotate( message="score RF-Classifier @randomforestclassifier.pyx", domain="cuml_python", diff --git a/python/cuml/tests/test_random_forest.py b/python/cuml/tests/test_random_forest.py index 2c9b68a98e..5c1e9c043e 100644 --- a/python/cuml/tests/test_random_forest.py +++ b/python/cuml/tests/test_random_forest.py @@ -984,6 +984,15 @@ def test_rf_multiclass_classifier_gtil_integration(tmpdir): np.testing.assert_almost_equal(out_prob, expected_prob, decimal=5) +def test_classifier_predict_log_proba(): + X, y = make_classification(random_state=42) + model = curfc(random_state=42).fit(X, y) + proba = model.predict_proba(X) + sol = np.log(proba) + log_proba = model.predict_log_proba(X) + np.testing.assert_allclose(log_proba, sol, rtol=1e-5) + + @pytest.mark.parametrize( "estimator, make_data", [ From bb440d8b73887b393b4bc18cf049a0e015c72010 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:20:16 -0500 Subject: [PATCH 05/12] Cleanup overrides for `sklearn.ensemble` --- .../cuml/accel/_overrides/sklearn/ensemble.py | 83 +++++++++++-------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py b/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py index a2c84d54c7..72dcb353ee 100644 --- a/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py +++ b/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py @@ -1,39 +1,67 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # import cuml.ensemble from cuml.accel.estimator_proxy import ProxyBase -from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.interop import UnsupportedOnGPU +from cuml.internals.validation import check_array __all__ = ("RandomForestRegressor", "RandomForestClassifier") -class RandomForestRegressor(ProxyBase): - _gpu_class = cuml.ensemble.RandomForestRegressor +class _RandomForestMixin: + def _check_inputs(self, X, y=None, sample_weight=None): + # Fallback to CPU if NaN in X + try: + check_array( + X, mem_type=None, order=None, ensure_2d=False, input_name="X" + ) + except ValueError as exc: + if "NaN" in str(exc): + raise UnsupportedOnGPU( + "Missing values are not supported" + ) from None + raise - def _gpu_fit(self, X, y, sample_weight=None): if sample_weight is not None: raise UnsupportedOnGPU("`sample_weight` is not supported") - try: - y = input_to_cuml_array(y, convert_to_mem_type=False)[0] - except ValueError: - raise - else: + if y is not None: + y = check_array( + y, + mem_type=None, + order=None, + ensure_2d=False, + ensure_all_finite=False, + input_name="y", + ) if len(y.shape) > 1 and y.shape[1] > 1: + if isinstance(self, RandomForestClassifier) and self.oob_score: + raise ValueError( + "The type of target cannot be used to compute OOB estimates" + ) raise UnsupportedOnGPU( "Multi-output targets are not supported" ) + + def _gpu_fit(self, X, y, sample_weight=None): + self._check_inputs(X, y, sample_weight=sample_weight) return self._gpu.fit(X, y) + def _gpu_predict(self, X): + self._check_inputs(X) + return self._gpu.predict(X) + def _gpu_score(self, X, y, sample_weight=None): - if sample_weight is not None: - raise UnsupportedOnGPU("`sample_weight` is not supported") + self._check_inputs(X, y, sample_weight=sample_weight) return self._gpu.score(X, y) + +class RandomForestRegressor(ProxyBase, _RandomForestMixin): + _gpu_class = cuml.ensemble.RandomForestRegressor + def __len__(self): return self._call_method("__len__") @@ -44,33 +72,16 @@ def __getitem__(self, index): return self._call_method("__getitem__", index) -class RandomForestClassifier(ProxyBase): +class RandomForestClassifier(ProxyBase, _RandomForestMixin): _gpu_class = cuml.ensemble.RandomForestClassifier - def _gpu_fit(self, X, y, sample_weight=None): - if sample_weight is not None: - raise UnsupportedOnGPU("`sample_weight` is not supported") + def _gpu_predict_proba(self, X): + self._check_inputs(X) + return self._gpu.predict_proba(X) - try: - y = input_to_cuml_array(y, convert_to_mem_type=False)[0] - except ValueError: - raise - else: - if len(y.shape) > 1 and y.shape[1] > 1: - if self.oob_score: - raise ValueError( - "The type of target cannot be used to compute OOB estimates" - ) - else: - raise UnsupportedOnGPU( - "Multi-output targets are not supported" - ) - return self._gpu.fit(X, y) - - def _gpu_score(self, X, y, sample_weight=None): - if sample_weight is not None: - raise UnsupportedOnGPU("`sample_weight` is not supported") - return self._gpu.score(X, y) + def _gpu_predict_log_proba(self, X): + self._check_inputs(X) + return self._gpu.predict_log_proba(X) def __len__(self): return self._call_method("__len__") From 37595cc0cf4b7571fa47d0666b9f6b99429c21b5 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:19:41 -0500 Subject: [PATCH 06/12] Don't reload CPU fit models on GPU in cuml.accel --- python/cuml/cuml/accel/estimator_proxy.py | 19 ++++++++++++------- .../cuml_accel_tests/test_estimator_proxy.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/python/cuml/cuml/accel/estimator_proxy.py b/python/cuml/cuml/accel/estimator_proxy.py index 1bbf5e2c00..0a923390b5 100644 --- a/python/cuml/cuml/accel/estimator_proxy.py +++ b/python/cuml/cuml/accel/estimator_proxy.py @@ -60,7 +60,7 @@ def _reconstruct(self): # will always serialize it by value rather than by reference. This allows # the saved model to be loaded in an environment without cuml installed, # where it will load as the CPU model directly. - def reconstruct(cls_path, cpu_model): + def reconstruct(cls_path, cpu_model, load_on_gpu): """Reconstruct a serialized estimator. Returns a proxy estimator if `cuml.accel` is installed, falling back @@ -82,7 +82,7 @@ def reconstruct(cls_path, cpu_model): # Return the CPU estimator directly return cpu_model # `cuml.accel` is installed, reconstruct a proxy estimator - return cls._reconstruct_from_cpu(cpu_model) + return cls._reconstruct_from_cpu(cpu_model, load_on_gpu) return reconstruct @@ -241,7 +241,7 @@ def _sync_attrs_to_cpu(self) -> None: ) @classmethod - def _reconstruct_from_cpu(cls, cpu): + def _reconstruct_from_cpu(cls, cpu, load_on_gpu=True): """Reconstruct a proxy estimator from its CPU counterpart. Primarily used when unpickling serialized proxy estimators.""" @@ -249,8 +249,9 @@ def _reconstruct_from_cpu(cls, cpu): self = cls.__new__(cls) self._cpu = cpu self._synced = False - if is_fitted(self._cpu): - # This is a fit estimator. Try to convert model back to GPU + if load_on_gpu and is_fitted(self._cpu): + # This estimator is fit and should be loaded on GPU. Try to convert + # model back to GPU. try: self._gpu = self._gpu_class.from_sklearn(self._cpu) except UnsupportedOnGPU: @@ -259,7 +260,7 @@ def _reconstruct_from_cpu(cls, cpu): # Supported on GPU, clear fit attributes from CPU to release host memory self._cpu = sklearn.clone(self._cpu) else: - # Estimator is unfit, delay GPU init until needed + # Estimator is unfit or should remain on CPU self._gpu = None return self @@ -427,7 +428,11 @@ def __reduce__(self): self._sync_attrs_to_cpu() return ( _reconstruct_proxy, - (self._gpu_class._cpu_class_path, self._cpu), + ( + self._gpu_class._cpu_class_path, + self._cpu, + self._gpu is not None, + ), ) def __getattr__(self, name: str) -> Any: diff --git a/python/cuml/cuml_accel_tests/test_estimator_proxy.py b/python/cuml/cuml_accel_tests/test_estimator_proxy.py index 0effc9819f..5579bcd6c5 100644 --- a/python/cuml/cuml_accel_tests/test_estimator_proxy.py +++ b/python/cuml/cuml_accel_tests/test_estimator_proxy.py @@ -407,6 +407,23 @@ def test_pickle_cpu_fit(): assert not model2._synced +def test_pickle_cpu_fit_but_params_supported(): + """Test that a model fit on CPU isn't reloaded to the GPU on unpickle, even + if the hyperparameters are supported.""" + X, y = make_regression(n_samples=10) + X[0, 0] = np.nan + model = RandomForestRegressor().fit(X, y) + # ensure the test case is one that isn't GPU accelerated + assert model._gpu is None + + model2 = pickle.loads(pickle.dumps(model)) + # GPU model doesn't exist + assert model2._gpu is None + # CPU model has fit attributes + assert model2._cpu.n_features_in_ + assert not model2._synced + + def test_unpickle_cuml_accel_not_active(): """Unpickling in an process without cuml.accel enabled uses the CPU model""" X, y = make_classification(n_samples=10) From 8f98440d421ac3d1da002b4f0b23d5cb48eba43c Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:20:53 -0500 Subject: [PATCH 07/12] Update xfail list --- .../upstream/scikit-learn/xfail-list.yaml | 17 ----------------- 1 file changed, 17 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 bf3f648ec9..8d9778285e 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 @@ -207,13 +207,9 @@ - "sklearn.ensemble.tests.test_forest::test_min_samples_leaf[RandomForestRegressor]" - "sklearn.ensemble.tests.test_forest::test_min_samples_split[RandomForestClassifier]" - "sklearn.ensemble.tests.test_forest::test_min_samples_split[RandomForestRegressor]" - - "sklearn.ensemble.tests.test_forest::test_missing_value_is_predictive[RandomForestClassifier]" - - "sklearn.ensemble.tests.test_forest::test_missing_value_is_predictive[RandomForestRegressor]" - - "sklearn.ensemble.tests.test_forest::test_missing_values_is_resilient[make_regression-RandomForestRegressor]" - "sklearn.ensemble.tests.test_forest::test_oob_not_computed_twice[RandomForestClassifier]" - "sklearn.ensemble.tests.test_forest::test_oob_not_computed_twice[RandomForestRegressor]" - "sklearn.ensemble.tests.test_forest::test_poisson_y_positive_check" - - "sklearn.ensemble.tests.test_forest::test_probability[RandomForestClassifier]" - "sklearn.ensemble.tests.test_forest::test_warm_start[RandomForestClassifier]" - "sklearn.ensemble.tests.test_forest::test_warm_start[RandomForestRegressor]" - "sklearn.ensemble.tests.test_forest::test_warm_start_oob[RandomForestRegressor]" @@ -914,11 +910,6 @@ - "sklearn.neighbors.tests.test_neighbors::test_neighbors_metrics[42-float64-canberra]" - "sklearn.neighbors.tests.test_neighbors::test_neighbors_metrics[42-float64-haversine]" - "sklearn.neighbors.tests.test_neighbors::test_neighbors_metrics[42-float64-minkowski]" -- reason: ValueError when input data is non-standard type - marker: cuml_accel_rf_input_validation_non_standard_types - tests: - - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_requires_y_none]" - - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_requires_y_none]" - reason: Multi-output targets not supported marker: cuml_accel_rf_multioutput_targets_not_supported tests: @@ -1016,16 +1007,8 @@ - "sklearn.tests.test_common::test_estimators[NearestNeighbors()-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[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)]" - - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_classifiers_train]" - - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_dtype_object]" - - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_dtype_object]" - - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_regressor_data_not_an_array]" - - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_estimators[SpectralEmbedding()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[SpectralEmbedding()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[TSNE()-check_dtype_object]" From 9855936dad3484a035597171cfc617db7a329012 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 15:21:02 -0500 Subject: [PATCH 08/12] Update ensemble limitations --- docs/source/cuml-accel/limitations.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/cuml-accel/limitations.rst b/docs/source/cuml-accel/limitations.rst index a41ba733df..e2c6152278 100644 --- a/docs/source/cuml-accel/limitations.rst +++ b/docs/source/cuml-accel/limitations.rst @@ -197,6 +197,7 @@ RandomForestClassifier - If ``class_weight`` is not ``None``. - If ``sample_weight`` is passed to ``fit`` or ``score``. - If ``X`` is sparse. +- If ``X`` contains missing values (represented as ``NaN``). - If ``y`` is a multi-output target. RandomForestRegressor @@ -213,6 +214,7 @@ RandomForestRegressor - If ``ccp_alpha`` is not ``0``. - If ``sample_weight`` is passed to ``fit`` or ``score``. - If ``X`` is sparse. +- If ``X`` contains missing values (represented as ``NaN``). - If ``y`` is a multi-output target. From 137be01c8ba70209af736a0874570da4f7a49259 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 28 Apr 2026 16:22:53 -0500 Subject: [PATCH 09/12] Fixup estimator proxy pickle change --- python/cuml/cuml/accel/estimator_proxy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/accel/estimator_proxy.py b/python/cuml/cuml/accel/estimator_proxy.py index 0a923390b5..4c4f8b40a4 100644 --- a/python/cuml/cuml/accel/estimator_proxy.py +++ b/python/cuml/cuml/accel/estimator_proxy.py @@ -96,8 +96,8 @@ def __reduce__(self): return (pickle.loads, (cloudpickle.dumps(self._reconstruct),)) - def __call__(self, cls_path, cpu): - return self._reconstruct(cls_path, cpu) + def __call__(self, cls_path, cpu, load_on_gpu): + return self._reconstruct(cls_path, cpu, load_on_gpu) _reconstruct_proxy = _ReconstructProxy() From 7e67063d195592e966226b7b89e1ae678fc04ee8 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Wed, 29 Apr 2026 11:03:28 -0500 Subject: [PATCH 10/12] Only disallow NaN in cuml.ensemble --- python/cuml/cuml/ensemble/randomforest_common.pyx | 1 + python/cuml/cuml/fil/fil.pyx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/python/cuml/cuml/ensemble/randomforest_common.pyx b/python/cuml/cuml/ensemble/randomforest_common.pyx index ca5a741840..9f2e213531 100644 --- a/python/cuml/cuml/ensemble/randomforest_common.pyx +++ b/python/cuml/cuml/ensemble/randomforest_common.pyx @@ -408,6 +408,7 @@ class BaseRandomForestModel(Base, InteropMixin): layout=layout, default_chunk_size=default_chunk_size, align_bytes=align_bytes, + allow_nan=False, ) def _fit_forest(self, X, y): diff --git a/python/cuml/cuml/fil/fil.pyx b/python/cuml/cuml/fil/fil.pyx index 1497d69cc9..fab537df52 100644 --- a/python/cuml/cuml/fil/fil.pyx +++ b/python/cuml/cuml/fil/fil.pyx @@ -139,6 +139,7 @@ cdef class ForestInference_impl(): cdef forest_model model cdef raft_proto_handle_t raft_proto_handle cdef object raft_handle + cdef bool allow_nan def __cinit__( self, @@ -149,6 +150,7 @@ cdef class ForestInference_impl(): use_double_precision=None, mem_type=None, device_id=None, + allow_nan=True, ): # Store reference to RAFT handle to control lifetime, since raft_proto # handle keeps a pointer to it @@ -156,6 +158,7 @@ cdef class ForestInference_impl(): self.raft_proto_handle = raft_proto_handle_t( self.raft_handle.getHandle() ) + self.allow_nan = allow_nan if mem_type is None: mem_type = GlobalSettings().fil_memory_type else: @@ -260,6 +263,7 @@ cdef class ForestInference_impl(): order="C", mem_type=mem_type.name, return_index=True, + ensure_all_finite="allow-nan" if self.allow_nan else True, input_name="X", ) n_rows = X.shape[0] @@ -460,6 +464,9 @@ class ForestInference(Base, CMajorInputTagMixin): For GPU execution, the device on which to load and execute this model. If set to None, use the currently active device. For CPU execution, this value is currently ignored. + allow_nan : bool, default=True + Whether to allow NaN values in X during inference. If False, an error + will be raised in the presence of NaN inputs. """ def _reload_model(self): @@ -614,6 +621,7 @@ class ForestInference(Base, CMajorInputTagMixin): align_bytes=None, precision='single', device_id=None, + allow_nan=True, ): super().__init__(verbose=verbose, output_type=output_type) self.is_classifier = is_classifier @@ -623,6 +631,7 @@ class ForestInference(Base, CMajorInputTagMixin): self.precision = precision self.device_id = device_id self.treelite_model = treelite_model + self.allow_nan = allow_nan self._load_to_fil(device_id=self.device_id) def _load_to_fil(self, mem_type=None, device_id=None): @@ -657,7 +666,8 @@ class ForestInference(Base, CMajorInputTagMixin): align_bytes=self.align_bytes, use_double_precision=self._use_double_precision_, mem_type=mem_type, - device_id=self.device_id + device_id=self.device_id, + allow_nan=self.allow_nan ) if mem_type is MemoryType.device: @@ -1356,4 +1366,5 @@ class ForestInference(Base, CMajorInputTagMixin): "align_bytes", "precision", "device_id", + "allow_nan", ] From 1e69ba8556b0c22544ced469cae96f8ab910ccb1 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Wed, 29 Apr 2026 11:23:31 -0500 Subject: [PATCH 11/12] Respond to feedback --- python/cuml/cuml/fil/fil.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/fil/fil.pyx b/python/cuml/cuml/fil/fil.pyx index fab537df52..6f2aeb66df 100644 --- a/python/cuml/cuml/fil/fil.pyx +++ b/python/cuml/cuml/fil/fil.pyx @@ -329,8 +329,8 @@ cdef class ForestInference_impl(): out_ptr, in_ptr, n_rows, - in_dev, out_dev, + in_dev, infer_type_enum, chunk_specification ) @@ -630,8 +630,8 @@ class ForestInference(Base, CMajorInputTagMixin): self.layout = layout self.precision = precision self.device_id = device_id - self.treelite_model = treelite_model self.allow_nan = allow_nan + self.treelite_model = treelite_model self._load_to_fil(device_id=self.device_id) def _load_to_fil(self, mem_type=None, device_id=None): From 2bd239531aad6010389cd95bfb61651d7542e0a6 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Wed, 29 Apr 2026 18:41:18 -0500 Subject: [PATCH 12/12] FIL allows infinity by default --- .../cuml/ensemble/randomforest_common.pyx | 2 +- python/cuml/cuml/fil/fil.pyx | 23 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/python/cuml/cuml/ensemble/randomforest_common.pyx b/python/cuml/cuml/ensemble/randomforest_common.pyx index 9f2e213531..a886aeb4b7 100644 --- a/python/cuml/cuml/ensemble/randomforest_common.pyx +++ b/python/cuml/cuml/ensemble/randomforest_common.pyx @@ -408,7 +408,7 @@ class BaseRandomForestModel(Base, InteropMixin): layout=layout, default_chunk_size=default_chunk_size, align_bytes=align_bytes, - allow_nan=False, + ensure_all_finite=True, ) def _fit_forest(self, X, y): diff --git a/python/cuml/cuml/fil/fil.pyx b/python/cuml/cuml/fil/fil.pyx index 6f2aeb66df..446443dd74 100644 --- a/python/cuml/cuml/fil/fil.pyx +++ b/python/cuml/cuml/fil/fil.pyx @@ -139,7 +139,7 @@ cdef class ForestInference_impl(): cdef forest_model model cdef raft_proto_handle_t raft_proto_handle cdef object raft_handle - cdef bool allow_nan + cdef object ensure_all_finite def __cinit__( self, @@ -150,7 +150,7 @@ cdef class ForestInference_impl(): use_double_precision=None, mem_type=None, device_id=None, - allow_nan=True, + ensure_all_finite=False, ): # Store reference to RAFT handle to control lifetime, since raft_proto # handle keeps a pointer to it @@ -158,7 +158,7 @@ cdef class ForestInference_impl(): self.raft_proto_handle = raft_proto_handle_t( self.raft_handle.getHandle() ) - self.allow_nan = allow_nan + self.ensure_all_finite = ensure_all_finite if mem_type is None: mem_type = GlobalSettings().fil_memory_type else: @@ -263,7 +263,7 @@ cdef class ForestInference_impl(): order="C", mem_type=mem_type.name, return_index=True, - ensure_all_finite="allow-nan" if self.allow_nan else True, + ensure_all_finite=self.ensure_all_finite, input_name="X", ) n_rows = X.shape[0] @@ -464,9 +464,10 @@ class ForestInference(Base, CMajorInputTagMixin): For GPU execution, the device on which to load and execute this model. If set to None, use the currently active device. For CPU execution, this value is currently ignored. - allow_nan : bool, default=True - Whether to allow NaN values in X during inference. If False, an error - will be raised in the presence of NaN inputs. + ensure_all_finite : bool or 'allow-nan', default=False + If True, an error will be raised if non-finite values are found in the + input. If 'allow-nan', an error will be raised if infinite values are + found (but not for NaN). If False then ``check_all_finite`` is skipped. """ def _reload_model(self): @@ -621,7 +622,7 @@ class ForestInference(Base, CMajorInputTagMixin): align_bytes=None, precision='single', device_id=None, - allow_nan=True, + ensure_all_finite=False, ): super().__init__(verbose=verbose, output_type=output_type) self.is_classifier = is_classifier @@ -630,7 +631,7 @@ class ForestInference(Base, CMajorInputTagMixin): self.layout = layout self.precision = precision self.device_id = device_id - self.allow_nan = allow_nan + self.ensure_all_finite = ensure_all_finite self.treelite_model = treelite_model self._load_to_fil(device_id=self.device_id) @@ -667,7 +668,7 @@ class ForestInference(Base, CMajorInputTagMixin): use_double_precision=self._use_double_precision_, mem_type=mem_type, device_id=self.device_id, - allow_nan=self.allow_nan + ensure_all_finite=self.ensure_all_finite ) if mem_type is MemoryType.device: @@ -1366,5 +1367,5 @@ class ForestInference(Base, CMajorInputTagMixin): "align_bytes", "precision", "device_id", - "allow_nan", + "ensure_all_finite", ]