diff --git a/cpp/include/cuml/tree/decisiontree.hpp b/cpp/include/cuml/tree/decisiontree.hpp index 74374cfab0..f617737154 100644 --- a/cpp/include/cuml/tree/decisiontree.hpp +++ b/cpp/include/cuml/tree/decisiontree.hpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2023, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. * SPDX-License-Identifier: Apache-2.0 */ @@ -17,7 +17,8 @@ namespace DT { struct DecisionTreeParams { /** - * Maximum tree depth. Unlimited (e.g., until leaves are pure), If `-1`. + * Maximum tree depth. Set to INT32_MAX for unlimited depth + * (i.e., until leaves are pure or other stopping criteria are met). */ int max_depth; /** diff --git a/cpp/src/decisiontree/batched-levelalgo/builder.cuh b/cpp/src/decisiontree/batched-levelalgo/builder.cuh index 509eb1e4e1..e9a7996b65 100644 --- a/cpp/src/decisiontree/batched-levelalgo/builder.cuh +++ b/cpp/src/decisiontree/batched-levelalgo/builder.cuh @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. * SPDX-License-Identifier: Apache-2.0 */ @@ -468,8 +468,7 @@ struct Builder { // create child nodes (or make the current ones leaf) raft::common::nvtx::push_range("nodeSplitKernel @builder.cuh [batched-levelalgo]"); - launchNodeSplitKernel(params.max_depth, - params.min_samples_leaf, + launchNodeSplitKernel(params.min_samples_leaf, params.min_samples_split, params.max_leaves, params.min_impurity_decrease, @@ -525,7 +524,6 @@ struct Builder { raft::common::nvtx::range kernel_scope("computeSplitKernel @builder.cuh [batched-levelalgo]"); launchComputeSplitKernel(histograms, params.max_n_bins, - params.max_depth, params.min_samples_split, params.max_leaves, dataset, diff --git a/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels.cuh b/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels.cuh index bd384f3eb9..ce3dc8b79c 100644 --- a/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels.cuh +++ b/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels.cuh @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. * SPDX-License-Identifier: Apache-2.0 */ @@ -64,8 +64,7 @@ DI OutT* alignPointer(InT dataset) } template -void launchNodeSplitKernel(const IdxT max_depth, - const IdxT min_samples_leaf, +void launchNodeSplitKernel(const IdxT min_samples_leaf, const IdxT min_samples_split, const IdxT max_leaves, const DataT min_impurity_decrease, @@ -388,7 +387,6 @@ template void launchComputeSplitKernel(BinT* histograms, IdxT n_bins, - IdxT max_depth, IdxT min_samples_split, IdxT max_leaves, const Dataset& dataset, diff --git a/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh b/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh index 638bf523e7..59677b6caf 100644 --- a/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh +++ b/cpp/src/decisiontree/batched-levelalgo/kernels/builder_kernels_impl.cuh @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. * SPDX-License-Identifier: Apache-2.0 */ #pragma once @@ -78,8 +78,7 @@ DI void partitionSamples(const Dataset& dataset, } } template -static __global__ void nodeSplitKernel(const IdxT max_depth, - const IdxT min_samples_leaf, +static __global__ void nodeSplitKernel(const IdxT min_samples_leaf, const IdxT min_samples_split, const IdxT max_leaves, const DataT min_impurity_decrease, @@ -98,8 +97,7 @@ static __global__ void nodeSplitKernel(const IdxT max_depth, } template -void launchNodeSplitKernel(const IdxT max_depth, - const IdxT min_samples_leaf, +void launchNodeSplitKernel(const IdxT min_samples_leaf, const IdxT min_samples_split, const IdxT max_leaves, const DataT min_impurity_decrease, @@ -111,8 +109,7 @@ void launchNodeSplitKernel(const IdxT max_depth, { auto constexpr smem_size = 2 * sizeof(IdxT) * TPB; nodeSplitKernel - <<>>(max_depth, - min_samples_leaf, + <<>>(min_samples_leaf, min_samples_split, max_leaves, min_impurity_decrease, @@ -204,7 +201,6 @@ template static __global__ void computeSplitKernel(BinT* histograms, IdxT max_n_bins, - IdxT max_depth, IdxT min_samples_split, IdxT max_leaves, const Dataset dataset, @@ -337,7 +333,6 @@ template void launchComputeSplitKernel(BinT* histograms, IdxT max_n_bins, - IdxT max_depth, IdxT min_samples_split, IdxT max_leaves, const Dataset& dataset, @@ -359,7 +354,6 @@ void launchComputeSplitKernel(BinT* histograms, computeSplitKernel <<>>(histograms, max_n_bins, - max_depth, min_samples_split, max_leaves, dataset, @@ -377,7 +371,6 @@ void launchComputeSplitKernel(BinT* histograms, } template void launchNodeSplitKernel<_DataT, _LabelT, _IdxT, TPB_DEFAULT>( - const _IdxT max_depth, const _IdxT min_samples_leaf, const _IdxT min_samples_split, const _IdxT max_leaves, @@ -401,7 +394,6 @@ template void launchLeafKernel<_DatasetT, _NodeT, _ObjectiveT, _DataT>( template void launchComputeSplitKernel<_DataT, _LabelT, _IdxT, TPB_DEFAULT, _ObjectiveT, _BinT>( _BinT* histograms, _IdxT n_bins, - _IdxT max_depth, _IdxT min_samples_split, _IdxT max_leaves, const Dataset<_DataT, _LabelT, _IdxT>& dataset, diff --git a/python/cuml/cuml/dask/ensemble/randomforestclassifier.py b/python/cuml/cuml/dask/ensemble/randomforestclassifier.py index 0257b0827d..bb89bb1fcb 100755 --- a/python/cuml/cuml/dask/ensemble/randomforestclassifier.py +++ b/python/cuml/cuml/dask/ensemble/randomforestclassifier.py @@ -68,10 +68,10 @@ class RandomForestClassifier( * If ``False``, the whole dataset is used to build each tree. max_samples : float (default = 1.0) Ratio of dataset rows used while fitting each tree. - max_depth : int (default = 16) - Maximum tree depth. Must be greater than 0. - Unlimited depth (i.e, until leaves are pure) - is not supported.\n + max_depth : int or None (default = 16) + Maximum tree depth. Use ``None`` for unlimited depth (trees grow + until all leaves are pure). Must be a positive integer or ``None``. + .. note:: This default differs from scikit-learn's random forest, which defaults to unlimited depth. max_leaves : int (default = -1) diff --git a/python/cuml/cuml/dask/ensemble/randomforestregressor.py b/python/cuml/cuml/dask/ensemble/randomforestregressor.py index 7524c9f934..fb45e14a2e 100755 --- a/python/cuml/cuml/dask/ensemble/randomforestregressor.py +++ b/python/cuml/cuml/dask/ensemble/randomforestregressor.py @@ -58,10 +58,10 @@ class RandomForestRegressor( * If ``False``, the whole dataset is used to build each tree. max_samples : float (default = 1.0) Ratio of dataset rows used while fitting each tree. - max_depth : int (default = 16) - Maximum tree depth. Must be greater than 0. - Unlimited depth (i.e, until leaves are pure) - is not supported.\n + max_depth : int or None (default = 16) + Maximum tree depth. Use ``None`` for unlimited depth (trees grow + until all leaves are pure). Must be a positive integer or ``None``. + .. note:: This default differs from scikit-learn's random forest, which defaults to unlimited depth. max_leaves : int (default = -1) diff --git a/python/cuml/cuml/ensemble/randomforest_common.pyx b/python/cuml/cuml/ensemble/randomforest_common.pyx index 657d6d7625..454f665db6 100644 --- a/python/cuml/cuml/ensemble/randomforest_common.pyx +++ b/python/cuml/cuml/ensemble/randomforest_common.pyx @@ -220,12 +220,10 @@ class BaseRandomForestModel(Base, InteropMixin): elif model.max_samples is not None: conditional_params["max_samples"] = model.max_samples - if model.max_depth is not None: - conditional_params["max_depth"] = model.max_depth - return { "n_estimators": model.n_estimators, "split_criterion": split_criterion, + "max_depth": model.max_depth, "min_samples_split": model.min_samples_split, "min_samples_leaf": model.min_samples_leaf, "max_features": model.max_features, @@ -418,8 +416,16 @@ class BaseRandomForestModel(Base, InteropMixin): cdef level_enum verbose = self._verbose_level cdef int n_classes = self.n_classes_ if is_classifier else 0 - if self.max_depth <= 0: - raise ValueError("Must specify max_depth > 0") + cdef int max_depth_c + if self.max_depth is None: + max_depth_c = np.iinfo(np.int32).max + elif not isinstance(self.max_depth, int) or self.max_depth <= 0: + raise ValueError( + f"max_depth must be a positive integer or None (unlimited); " + f"got {self.max_depth!r}" + ) + else: + max_depth_c = self.max_depth # Validate OOB score parameter if callable(self.oob_score): @@ -456,7 +462,7 @@ class BaseRandomForestModel(Base, InteropMixin): n_bins = self.n_bins cdef RF_params params = set_rf_params( - self.max_depth, + max_depth_c, self.max_leaves, max_features, n_bins, diff --git a/python/cuml/cuml/ensemble/randomforestclassifier.py b/python/cuml/cuml/ensemble/randomforestclassifier.py index ab5d30895f..1df2edde04 100644 --- a/python/cuml/cuml/ensemble/randomforestclassifier.py +++ b/python/cuml/cuml/ensemble/randomforestclassifier.py @@ -70,12 +70,12 @@ class RandomForestClassifier(BaseRandomForestModel, ClassifierMixin): * If ``False``, the whole dataset is used to build each tree. max_samples : float (default = 1.0) Ratio of dataset rows used while fitting each tree. - max_depth : int (default = 16) - Maximum tree depth. Must be greater than 0. - Unlimited depth (i.e, until leaves are pure) - is not supported.\n - .. note:: This default differs from scikit-learn's - random forest, which defaults to unlimited depth. + max_depth : int or None (default = 16) + Maximum tree depth. Use ``None`` for unlimited depth (trees grow + until all leaves are pure). Must be a positive integer or ``None``. + + .. note:: This default differs from scikit-learn's random forest, + which defaults to unlimited depth. max_leaves : int (default = -1) Maximum leaf nodes per tree. Soft constraint. Unlimited, If ``-1``. diff --git a/python/cuml/cuml/ensemble/randomforestregressor.py b/python/cuml/cuml/ensemble/randomforestregressor.py index c4294f99f2..aacac40579 100644 --- a/python/cuml/cuml/ensemble/randomforestregressor.py +++ b/python/cuml/cuml/ensemble/randomforestregressor.py @@ -65,12 +65,12 @@ class RandomForestRegressor(BaseRandomForestModel, RegressorMixin): * If ``False``, the whole dataset is used to build each tree. max_samples : float (default = 1.0) Ratio of dataset rows used while fitting each tree. - max_depth : int (default = 16) - Maximum tree depth. Must be greater than 0. - Unlimited depth (i.e, until leaves are pure) - is not supported.\n - .. note:: This default differs from scikit-learn's - random forest, which defaults to unlimited depth. + max_depth : int or None (default = 16) + Maximum tree depth. Use ``None`` for unlimited depth (trees grow + until all leaves are pure). Must be a positive integer or ``None``. + + .. note:: This default differs from scikit-learn's random forest, + which defaults to unlimited depth. max_leaves : int (default = -1) Maximum leaf nodes per tree. Soft constraint. Unlimited, If ``-1``. diff --git a/python/cuml/tests/dask/test_dask_random_forest.py b/python/cuml/tests/dask/test_dask_random_forest.py index 6094ba6a56..5c67bcc0b5 100644 --- a/python/cuml/tests/dask/test_dask_random_forest.py +++ b/python/cuml/tests/dask/test_dask_random_forest.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 import json @@ -357,6 +357,36 @@ def check_count(node, nodes): check_count(node, nodes) +def test_unlimited_max_depth_classifier(client): + n_workers = len(client.scheduler_info(n_workers=-1)["workers"]) + X, y = make_classification( + n_samples=n_workers * 200, n_features=10, random_state=42 + ) + X = X.astype(np.float32) + y = y.astype(np.int32) + + X_dask, y_dask = _prep_training_data(client, X, y, partitions_per_worker=1) + clf = cuRFC_mg(n_estimators=n_workers * 5, max_depth=None) + clf.fit(X_dask, y_dask) + preds = cp.asnumpy(cp.array(clf.predict(X_dask).compute())) + assert len(preds) == len(y) + + +def test_unlimited_max_depth_regressor(client): + n_workers = len(client.scheduler_info(n_workers=-1)["workers"]) + X, y = make_regression( + n_samples=n_workers * 200, n_features=10, random_state=42 + ) + X = X.astype(np.float32) + y = y.astype(np.float32) + + X_dask, y_dask = _prep_training_data(client, X, y, partitions_per_worker=1) + reg = cuRFR_mg(n_estimators=n_workers * 5, max_depth=None) + reg.fit(X_dask, y_dask) + preds = cp.asnumpy(cp.array(reg.predict(X_dask).compute())) + assert len(preds) == len(y) + + @pytest.mark.parametrize("estimator_type", ["regression", "classification"]) def test_rf_get_combined_model_right_aftter_fit(client, estimator_type): max_depth = 3 diff --git a/python/cuml/tests/test_random_forest.py b/python/cuml/tests/test_random_forest.py index b9b067813d..3e93eb077c 100644 --- a/python/cuml/tests/test_random_forest.py +++ b/python/cuml/tests/test_random_forest.py @@ -776,6 +776,39 @@ def test_create_classification_model( assert params["n_bins"] == verfiy_params["n_bins"] +def test_unlimited_max_depth_classifier(): + X, y = make_classification(n_samples=500, n_features=10, random_state=42) + + clf = curfc(n_estimators=10, max_depth=None, random_state=42) + clf.fit(X, y) + preds = clf.predict(X) + assert len(preds) == len(y) + + params = clf.get_params() + assert params["max_depth"] is None + clf2 = curfc() + clf2.set_params(**params) + assert clf2.get_params()["max_depth"] is None + + shallow = curfc(n_estimators=10, max_depth=2, random_state=42) + shallow.fit(X, y) + assert accuracy_score(y, preds) >= accuracy_score(y, shallow.predict(X)) + + +def test_unlimited_max_depth_regressor(): + X, y = make_regression(n_samples=500, n_features=10, random_state=42) + + reg = curfr(n_estimators=10, max_depth=None, random_state=42) + reg.fit(X, y) + assert len(reg.predict(X)) == len(y) + + params = reg.get_params() + assert params["max_depth"] is None + reg2 = curfr() + reg2.set_params(**params) + assert reg2.get_params()["max_depth"] is None + + @pytest.mark.parametrize("n_estimators", [10, 20, 100]) @pytest.mark.parametrize("n_bins", [8, 9, 10]) def test_multiple_fits_classification(large_clf, n_estimators, n_bins): diff --git a/python/cuml/tests/test_sklearn_import_export.py b/python/cuml/tests/test_sklearn_import_export.py index 9762ade6aa..7c07cc4aee 100644 --- a/python/cuml/tests/test_sklearn_import_export.py +++ b/python/cuml/tests/test_sklearn_import_export.py @@ -772,9 +772,13 @@ def test_random_forest_classifier(random_state, oob_score): n_samples=200, n_features=5, n_informative=3, random_state=random_state ) - cu_model = cuml.RandomForestClassifier(oob_score=oob_score).fit(X, y) + cu_model = cuml.RandomForestClassifier( + oob_score=oob_score, + max_depth=None, + ).fit(X, y) sk_model = sklearn.ensemble.RandomForestClassifier( - oob_score=oob_score + oob_score=oob_score, + max_depth=None, ).fit(X, y) sk_model2 = cu_model.as_sklearn() @@ -819,10 +823,14 @@ def test_random_forest_regressor(random_state, oob_score): X, y = make_regression(n_samples=200, random_state=random_state) X = X.astype("float32") - cu_model = cuml.RandomForestRegressor(oob_score=oob_score).fit(X, y) - sk_model = sklearn.ensemble.RandomForestRegressor(oob_score=oob_score).fit( - X, y - ) + cu_model = cuml.RandomForestRegressor( + oob_score=oob_score, + max_depth=None, + ).fit(X, y) + sk_model = sklearn.ensemble.RandomForestRegressor( + oob_score=oob_score, + max_depth=None, + ).fit(X, y) sk_model2 = cu_model.as_sklearn() cu_model2 = cuml.RandomForestRegressor.from_sklearn(sk_model)