Skip to content

Commit c8f2a90

Browse files
authored
PRF: Optimize binning in HGBT (scikit-learn#34248)
1 parent 52c2525 commit c8f2a90

5 files changed

Lines changed: 99 additions & 31 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- Improved the performance of binning in
2+
:class:`ensemble.HistGradientBoostingClassifier` and
3+
:class:`ensemble.HistGradientBoostingRegressor`. Fitting with sample weights
4+
could be slow for datasets with fewer than 200,000 samples; this is now fixed.
5+
Binning without sample weights was also optimized.
6+
By :user:`Arthur Lacote <cakedev0>`.

sklearn/ensemble/_hist_gradient_boosting/binning.py

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from sklearn.utils._bitset import set_bitset_memoryview
2525
from sklearn.utils._openmp_helpers import _openmp_effective_n_threads
2626
from sklearn.utils.parallel import Parallel, delayed
27-
from sklearn.utils.stats import _weighted_percentile
27+
from sklearn.utils.stats import _weighted_percentile_1d_sorted
2828
from sklearn.utils.validation import check_is_fitted
2929

3030

@@ -50,35 +50,42 @@ def _find_binning_thresholds(col_data, max_bins, sample_weight=None):
5050
A given value x will be mapped into bin value i iff
5151
bining_thresholds[i - 1] < x <= binning_thresholds[i]
5252
"""
53-
# ignore missing values when computing bin thresholds
54-
missing_mask = np.isnan(col_data)
55-
any_missing = missing_mask.any()
56-
if any_missing:
57-
col_data = col_data[~missing_mask]
58-
59-
# If sample_weight is not None and 0-weighted values exist, we need to
60-
# remove those before calculating the distinct points.
61-
if sample_weight is not None:
62-
if any_missing:
53+
# The data will be sorted anyway to find distinct values and again in percentile,
54+
# so we do it here, like once and for all. Sorting also returns a contiguous array.
55+
if sample_weight is None:
56+
col_data = np.sort(col_data)
57+
# ignore missing values when computing bin thresholds
58+
idx_nan = np.searchsorted(col_data, np.nan)
59+
col_data = col_data[:idx_nan]
60+
else:
61+
# First, remove missing values because argsort is much slower when missing
62+
# values are present (which is not the case for sort).
63+
missing_mask = np.isnan(col_data)
64+
if missing_mask.any():
65+
col_data = col_data[~missing_mask]
6366
sample_weight = sample_weight[~missing_mask]
67+
68+
# If 0-weighted values exist, we need to remove those
69+
# before calculating the distinct points.
6470
nnz_sw = sample_weight != 0
6571
col_data = col_data[nnz_sw]
6672
sample_weight = sample_weight[nnz_sw]
6773

68-
# The data will be sorted anyway in np.unique and again in percentile, so we do it
69-
# here. Sorting also returns a contiguous array.
70-
sort_idx = np.argsort(col_data)
71-
col_data = col_data[sort_idx]
72-
if sample_weight is not None:
74+
sort_idx = np.argsort(col_data)
75+
col_data = col_data[sort_idx]
7376
sample_weight = sample_weight[sort_idx]
7477

75-
distinct_values = np.unique(col_data).astype(X_DTYPE)
78+
# fast way for n_distinct = len(np.unique(col_data))
79+
distinct_mask = np.empty(len(col_data), dtype=bool)
80+
distinct_mask[0] = True
81+
distinct_mask[1:] = col_data[1:] != col_data[:-1]
82+
n_distincts = distinct_mask.sum()
7683

77-
if len(distinct_values) == 1:
84+
if n_distincts == 1:
7885
return np.asarray([])
79-
80-
if len(distinct_values) <= max_bins:
86+
elif n_distincts <= max_bins:
8187
# Calculate midpoints if distinct values <= max_bins
88+
distinct_values = col_data[distinct_mask]
8289
bin_thresholds = sliding_window_view(distinct_values, 2).mean(axis=1)
8390
elif sample_weight is None:
8491
# We compute bin edges using the output of np.percentile with
@@ -93,17 +100,12 @@ def _find_binning_thresholds(col_data, max_bins, sample_weight=None):
93100
else:
94101
percentiles = np.linspace(0, 100, num=max_bins + 1)
95102
percentiles = percentiles[1:-1]
96-
bin_thresholds = np.array(
97-
[
98-
_weighted_percentile(col_data, sample_weight, percentile, average=True)
99-
for percentile in percentiles
100-
]
103+
bin_thresholds = _weighted_percentile_1d_sorted(
104+
col_data, sample_weight, percentiles
101105
)
102106
assert bin_thresholds.shape[0] == max_bins - 1
103107
# Remove duplicated thresholds if they exist.
104-
unique_bin_values = np.unique(bin_thresholds)
105-
if unique_bin_values.shape[0] != bin_thresholds.shape[0]:
106-
bin_thresholds = unique_bin_values
108+
bin_thresholds = np.unique(bin_thresholds)
107109

108110
# We avoid having +inf thresholds: +inf thresholds are only allowed in
109111
# a "split on nan" situation.

sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,17 +220,27 @@ def test_bin_mapper_repeated_values_invariance(n_distinct):
220220

221221

222222
@pytest.mark.parametrize("n_bins", [50, 250])
223-
def test_binmapper_weighted_vs_repeated_equivalence(global_random_seed, n_bins):
223+
@pytest.mark.parametrize("with_missing_values", [False, True])
224+
def test_binmapper_weighted_vs_repeated_equivalence(
225+
global_random_seed, n_bins, with_missing_values
226+
):
224227
rng = np.random.RandomState(global_random_seed)
225228

226229
n_samples = 200
227230
X = rng.randn(n_samples, 3)
231+
if with_missing_values:
232+
missing_mask = rng.rand(*X.shape) < 0.1
233+
X[missing_mask] = np.nan
234+
228235
sw = rng.randint(0, 5, size=n_samples)
229236
X_repeated = np.repeat(X, sw, axis=0)
230237

231238
est_weighted = _BinMapper(n_bins=n_bins).fit(X, sample_weight=sw)
232239
est_repeated = _BinMapper(n_bins=n_bins).fit(X_repeated, sample_weight=None)
233-
assert_allclose(est_weighted.bin_thresholds_, est_repeated.bin_thresholds_)
240+
for thresholds_weighted, thresholds_repeated in zip(
241+
est_weighted.bin_thresholds_, est_repeated.bin_thresholds_
242+
):
243+
assert_allclose(thresholds_weighted, thresholds_repeated)
234244

235245
X_trans_weighted = est_weighted.transform(X)
236246
X_trans_repeated = est_repeated.transform(X)

sklearn/utils/stats.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Authors: The scikit-learn developers
22
# SPDX-License-Identifier: BSD-3-Clause
33

4+
import numpy as np
5+
46
from sklearn.utils._array_api import (
57
_find_matching_floating_dtype,
68
get_namespace_and_device,
@@ -214,3 +216,30 @@ def _weighted_percentile(
214216
result = result[..., 0]
215217

216218
return result[0, ...] if n_dim == 1 else result
219+
220+
221+
def _weighted_percentile_1d_sorted(array, sample_weight, percentile_rank):
222+
"""Compute weighted percentiles for sorted 1D data and percentile ranks.
223+
224+
This implements the "averaged_inverted_cdf" method as computed by
225+
`_weighted_percentile(..., average=True)`, but it expects:
226+
- `array` is sorted
227+
- `array` has no NaN values
228+
- `sample_weight` has strictly positive values
229+
- `percentile_rank` is a 1D array
230+
"""
231+
weight_cdf = np.cumsum(sample_weight, dtype=array.dtype)
232+
adjusted_percentile_rank = percentile_rank / 100 * weight_cdf[-1]
233+
234+
percentile_indices = np.searchsorted(weight_cdf, adjusted_percentile_rank)
235+
percentile_indices = np.clip(percentile_indices, 0, array.shape[0] - 1)
236+
237+
fraction_above = weight_cdf[percentile_indices] - adjusted_percentile_rank
238+
is_fraction_above = fraction_above > np.finfo(array.dtype).eps
239+
percentile_plus_one_indices = np.clip(percentile_indices + 1, 0, array.shape[0] - 1)
240+
241+
return np.where(
242+
is_fraction_above,
243+
array[percentile_indices],
244+
(array[percentile_indices] + array[percentile_plus_one_indices]) / 2,
245+
)

sklearn/utils/tests/test_stats.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
from sklearn.utils.estimator_checks import _array_api_for_tests
1414
from sklearn.utils.fixes import np_version, parse_version
15-
from sklearn.utils.stats import _weighted_percentile
15+
from sklearn.utils.stats import _weighted_percentile, _weighted_percentile_1d_sorted
1616

1717

1818
@pytest.mark.parametrize("average", [True, False])
@@ -183,6 +183,27 @@ def test_weighted_percentile_constant_multiplier(
183183
assert percentile == approx(percentile_multiplier)
184184

185185

186+
@pytest.mark.parametrize("percentile_rank", [np.array([0]), np.array([20, 50, 100])])
187+
def test_weighted_percentile_1d_sorted_matches_weighted_percentile(
188+
global_random_seed, percentile_rank
189+
):
190+
"""Check sorted 1D helper against `_weighted_percentile`."""
191+
rng = np.random.RandomState(global_random_seed)
192+
for x in [
193+
np.sort(rng.uniform(size=100)),
194+
np.array([0, 1, 1, 2], dtype=float),
195+
np.repeat([0, 1, 2], rng.choice(10, size=3)).astype(np.float32),
196+
]:
197+
sample_weight = rng.uniform(low=0.1, high=10, size=x.shape[0])
198+
199+
percentile = _weighted_percentile_1d_sorted(x, sample_weight, percentile_rank)
200+
expected_percentile = _weighted_percentile(
201+
x, sample_weight, percentile_rank, average=True
202+
)
203+
204+
assert_allclose(percentile, expected_percentile)
205+
206+
186207
@pytest.mark.parametrize("percentile_rank", [50, [20, 35, 50]])
187208
@pytest.mark.parametrize("average", [True, False])
188209
def test_weighted_percentile_2d(global_random_seed, percentile_rank, average):

0 commit comments

Comments
 (0)