Skip to content

Commit 6e9ef2b

Browse files
samronsinglemaitre
andauthored
ENH Support for monotonic trees with missing values (scikit-learn#27630)
Co-authored-by: Guillaume Lemaitre <g.lemaitre58@gmail.com>
1 parent 5a73062 commit 6e9ef2b

4 files changed

Lines changed: 76 additions & 55 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
- :class:`tree.DecisionTreeClassifier`, :class:`tree.DecisionTreeRegressor`,
2+
:class:`tree.ExtraTreeClassifier`, :class:`tree.ExtraTreeRegressor`,
3+
:class:`ensemble.RandomForestClassifier`,
4+
:class:`ensemble.RandomForestRegressor`, :class:`ensemble.ExtraTreesClassifier`,
5+
and :class:`ensemble.ExtraTreesRegressor` now support combining
6+
`monotonic_cst` with missing values in dense training data. This builds on
7+
the improvements to missing-value support for dense training data in
8+
:pr:`32119`.
9+
By :user:`Samuel O. Ronsin <samronsin>`.

sklearn/ensemble/_forest.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,8 +1387,7 @@ class RandomForestClassifier(ForestClassifier):
13871387
13881388
Monotonicity constraints are not supported for:
13891389
- multiclass classifications (i.e. when `n_classes > 2`),
1390-
- multioutput classifications (i.e. when `n_outputs_ > 1`),
1391-
- classifications trained on data with missing values.
1390+
- multioutput classifications (i.e. when `n_outputs_ > 1`).
13921391
13931392
The constraints hold over the probability of the positive class.
13941393
@@ -1779,8 +1778,7 @@ class RandomForestRegressor(ForestRegressor):
17791778
If monotonic_cst is None, no constraints are applied.
17801779
17811780
Monotonicity constraints are not supported for:
1782-
- multioutput regressions (i.e. when `n_outputs_ > 1`),
1783-
- regressions trained on data with missing values.
1781+
- multioutput regressions (i.e. when `n_outputs_ > 1`).
17841782
17851783
Read more in the :ref:`User Guide <monotonic_cst_gbdt>`.
17861784
@@ -2174,8 +2172,7 @@ class ExtraTreesClassifier(ForestClassifier):
21742172
21752173
Monotonicity constraints are not supported for:
21762174
- multiclass classifications (i.e. when `n_classes > 2`),
2177-
- multioutput classifications (i.e. when `n_outputs_ > 1`),
2178-
- classifications trained on data with missing values.
2175+
- multioutput classifications (i.e. when `n_outputs_ > 1`).
21792176
21802177
The constraints hold over the probability of the positive class.
21812178
@@ -2549,8 +2546,7 @@ class ExtraTreesRegressor(ForestRegressor):
25492546
If monotonic_cst is None, no constraints are applied.
25502547
25512548
Monotonicity constraints are not supported for:
2552-
- multioutput regressions (i.e. when `n_outputs_ > 1`),
2553-
- regressions trained on data with missing values.
2549+
- multioutput regressions (i.e. when `n_outputs_ > 1`).
25542550
25552551
Read more in the :ref:`User Guide <monotonic_cst_gbdt>`.
25562552

sklearn/tree/_classes.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,7 @@ def get_n_leaves(self):
184184
return self.tree_.n_leaves
185185

186186
def _support_missing_values(self, X):
187-
return (
188-
not issparse(X)
189-
and self.__sklearn_tags__().input_tags.allow_nan
190-
and self.monotonic_cst is None
191-
)
187+
return not issparse(X) and self.__sklearn_tags__().input_tags.allow_nan
192188

193189
def _compute_missing_values_in_feature_mask(self, X, estimator_name=None):
194190
"""Return boolean mask denoting if there are missing values for each feature.
@@ -849,8 +845,7 @@ class DecisionTreeClassifier(ClassifierMixin, BaseDecisionTree):
849845
850846
Monotonicity constraints are not supported for:
851847
- multiclass classifications (i.e. when `n_classes > 2`),
852-
- multioutput classifications (i.e. when `n_outputs_ > 1`),
853-
- classifications trained on data with missing values.
848+
- multioutput classifications (i.e. when `n_outputs_ > 1`).
854849
855850
The constraints hold over the probability of the positive class.
856851
@@ -1243,8 +1238,7 @@ class DecisionTreeRegressor(RegressorMixin, BaseDecisionTree):
12431238
If monotonic_cst is None, no constraints are applied.
12441239
12451240
Monotonicity constraints are not supported for:
1246-
- multioutput regressions (i.e. when `n_outputs_ > 1`),
1247-
- regressions trained on data with missing values.
1241+
- multioutput regressions (i.e. when `n_outputs_ > 1`).
12481242
12491243
Read more in the :ref:`User Guide <monotonic_cst_gbdt>`.
12501244
@@ -1601,8 +1595,7 @@ class ExtraTreeClassifier(DecisionTreeClassifier):
16011595
16021596
Monotonicity constraints are not supported for:
16031597
- multiclass classifications (i.e. when `n_classes > 2`),
1604-
- multioutput classifications (i.e. when `n_outputs_ > 1`),
1605-
- classifications trained on data with missing values.
1598+
- multioutput classifications (i.e. when `n_outputs_ > 1`).
16061599
16071600
The constraints hold over the probability of the positive class.
16081601
@@ -1877,8 +1870,7 @@ class ExtraTreeRegressor(DecisionTreeRegressor):
18771870
If monotonic_cst is None, no constraints are applied.
18781871
18791872
Monotonicity constraints are not supported for:
1880-
- multioutput regressions (i.e. when `n_outputs_ > 1`),
1881-
- regressions trained on data with missing values.
1873+
- multioutput regressions (i.e. when `n_outputs_ > 1`).
18821874
18831875
Read more in the :ref:`User Guide <monotonic_cst_gbdt>`.
18841876

sklearn/tree/tests/test_monotonic_tree.py

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,22 @@
3030

3131

3232
@pytest.mark.parametrize("TreeClassifier", TREE_BASED_CLASSIFIER_CLASSES)
33+
@pytest.mark.parametrize(
34+
"sparse_splitter, with_missing",
35+
[
36+
(False, False),
37+
(True, False),
38+
(False, True),
39+
],
40+
ids=["dense-without-missing", "sparse-without-missing", "dense-with-missing"],
41+
)
3342
@pytest.mark.parametrize("depth_first_builder", (True, False))
34-
@pytest.mark.parametrize("sparse_splitter", (True, False))
3543
@pytest.mark.parametrize("csc_container", CSC_CONTAINERS)
3644
def test_monotonic_constraints_classifications(
3745
TreeClassifier,
38-
depth_first_builder,
3946
sparse_splitter,
47+
depth_first_builder,
48+
with_missing,
4049
global_random_seed,
4150
csc_container,
4251
):
@@ -72,9 +81,13 @@ def test_monotonic_constraints_classifications(
7281
max_leaf_nodes=n_samples_train,
7382
)
7483
if hasattr(est, "random_state"):
75-
est.set_params(**{"random_state": global_random_seed})
84+
est.set_params(random_state=global_random_seed)
7685
if hasattr(est, "n_estimators"):
77-
est.set_params(**{"n_estimators": 5})
86+
est.set_params(n_estimators=5)
87+
if with_missing:
88+
generator = np.random.default_rng(seed=global_random_seed)
89+
mask = generator.choice(2, size=X_train.shape).astype(bool)
90+
X_train[mask] = np.nan
7891
if sparse_splitter:
7992
X_train = csc_container(X_train)
8093
est.fit(X_train, y_train)
@@ -95,14 +108,23 @@ def test_monotonic_constraints_classifications(
95108

96109

97110
@pytest.mark.parametrize("TreeRegressor", TREE_BASED_REGRESSOR_CLASSES)
111+
@pytest.mark.parametrize(
112+
"sparse_splitter, with_missing",
113+
[
114+
(False, False),
115+
(True, False),
116+
(False, True),
117+
],
118+
ids=["dense-without-missing", "sparse-without-missing", "dense-with-missing"],
119+
)
98120
@pytest.mark.parametrize("depth_first_builder", (True, False))
99-
@pytest.mark.parametrize("sparse_splitter", (True, False))
100121
@pytest.mark.parametrize("criterion", ("absolute_error", "squared_error"))
101122
@pytest.mark.parametrize("csc_container", CSC_CONTAINERS)
102123
def test_monotonic_constraints_regressions(
103124
TreeRegressor,
104-
depth_first_builder,
105125
sparse_splitter,
126+
depth_first_builder,
127+
with_missing,
106128
criterion,
107129
global_random_seed,
108130
csc_container,
@@ -145,7 +167,11 @@ def test_monotonic_constraints_regressions(
145167
if hasattr(est, "random_state"):
146168
est.set_params(random_state=global_random_seed)
147169
if hasattr(est, "n_estimators"):
148-
est.set_params(**{"n_estimators": 5})
170+
est.set_params(n_estimators=5)
171+
if with_missing:
172+
generator = np.random.default_rng(seed=global_random_seed)
173+
mask = generator.choice(2, size=X_train.shape).astype(bool)
174+
X_train[mask] = np.nan
149175
if sparse_splitter:
150176
X_train = csc_container(X_train)
151177
est.fit(X_train, y_train)
@@ -190,29 +216,6 @@ def test_multiple_output_raises(TreeClassifier):
190216
est.fit(X, y)
191217

192218

193-
@pytest.mark.parametrize(
194-
"Tree",
195-
[
196-
DecisionTreeClassifier,
197-
DecisionTreeRegressor,
198-
ExtraTreeClassifier,
199-
ExtraTreeRegressor,
200-
],
201-
)
202-
def test_missing_values_raises(Tree):
203-
X, y = make_classification(
204-
n_samples=100, n_features=5, n_classes=2, n_informative=3, random_state=0
205-
)
206-
X[0, 0] = np.nan
207-
monotonic_cst = np.zeros(X.shape[1])
208-
monotonic_cst[0] = 1
209-
est = Tree(max_depth=None, monotonic_cst=monotonic_cst, random_state=0)
210-
211-
msg = "Input X contains NaN"
212-
with pytest.raises(ValueError, match=msg):
213-
est.fit(X, y)
214-
215-
216219
@pytest.mark.parametrize("TreeClassifier", TREE_BASED_CLASSIFIER_CLASSES)
217220
def test_bad_monotonic_cst_raises(TreeClassifier):
218221
X = [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]
@@ -307,11 +310,17 @@ def test_1d_opposite_monotonicity_cst_data(TreeRegressor):
307310

308311

309312
@pytest.mark.parametrize("TreeRegressor", TREE_REGRESSOR_CLASSES)
313+
@pytest.mark.parametrize("with_missing", (True, False))
310314
@pytest.mark.parametrize("monotonic_sign", (-1, 1))
311315
@pytest.mark.parametrize("depth_first_builder", (True, False))
312316
@pytest.mark.parametrize("criterion", ("absolute_error", "squared_error"))
313317
def test_1d_tree_nodes_values(
314-
TreeRegressor, monotonic_sign, depth_first_builder, criterion, global_random_seed
318+
TreeRegressor,
319+
with_missing,
320+
monotonic_sign,
321+
depth_first_builder,
322+
criterion,
323+
global_random_seed,
315324
):
316325
# Adaptation from test_nodes_values in test_monotonic_constraints.py
317326
# in sklearn.ensemble._hist_gradient_boosting
@@ -351,10 +360,15 @@ def test_1d_tree_nodes_values(
351360
criterion=criterion,
352361
random_state=global_random_seed,
353362
)
363+
if with_missing:
364+
generator = np.random.default_rng(seed=global_random_seed)
365+
mask = generator.choice(2, size=X.shape, p=[0.8, 0.2]).astype(bool)
366+
X[mask] = np.nan
354367
clf.fit(X, y)
355368

356369
assert_1d_reg_tree_children_monotonic_bounded(clf.tree_, monotonic_sign)
357-
assert_1d_reg_monotonic(clf, monotonic_sign, np.min(X), np.max(X), 100)
370+
min_x, max_x = np.nanmin(X), np.nanmax(X)
371+
assert_1d_reg_monotonic(clf, monotonic_sign, min_x, max_x, 100)
358372

359373

360374
def assert_nd_reg_tree_children_monotonic_bounded(tree_, monotonic_cst):
@@ -379,7 +393,7 @@ def assert_nd_reg_tree_children_monotonic_bounded(tree_, monotonic_cst):
379393
# Split node: check and update bounds for the children.
380394
i_left = tree_.children_left[i]
381395
i_right = tree_.children_right[i]
382-
# unpack value from nx1x1 array
396+
# unpack value from nx1x1 array (middle_value after clipping)
383397
middle_value = (tree_.value[i_left][0][0] + tree_.value[i_right][0][0]) / 2
384398

385399
if monotonic_cst[feature] == 0:
@@ -460,11 +474,17 @@ def test_assert_nd_reg_tree_children_monotonic_bounded():
460474

461475

462476
@pytest.mark.parametrize("TreeRegressor", TREE_REGRESSOR_CLASSES)
477+
@pytest.mark.parametrize("with_missing", (True, False))
463478
@pytest.mark.parametrize("monotonic_sign", (-1, 1))
464479
@pytest.mark.parametrize("depth_first_builder", (True, False))
465480
@pytest.mark.parametrize("criterion", ("absolute_error", "squared_error"))
466481
def test_nd_tree_nodes_values(
467-
TreeRegressor, monotonic_sign, depth_first_builder, criterion, global_random_seed
482+
TreeRegressor,
483+
with_missing,
484+
monotonic_sign,
485+
depth_first_builder,
486+
criterion,
487+
global_random_seed,
468488
):
469489
# Build tree with several features, and make sure the nodes
470490
# values respect the monotonicity constraints.
@@ -508,5 +528,9 @@ def test_nd_tree_nodes_values(
508528
criterion=criterion,
509529
random_state=global_random_seed,
510530
)
531+
if with_missing:
532+
generator = np.random.default_rng(seed=global_random_seed)
533+
mask = generator.choice(2, size=X.shape, p=[0.8, 0.2]).astype(bool)
534+
X[mask] = np.nan
511535
clf.fit(X, y)
512536
assert_nd_reg_tree_children_monotonic_bounded(clf.tree_, monotonic_cst)

0 commit comments

Comments
 (0)