Aluode/PerceptionLabPortable
0
1# Authors: The scikit-learn developers
2# SPDX-License-Identifier: BSD-3-Clause
3
4
5import warnings
6from numbers import Integral
7
8import numpy as np
9
10from ..base import BaseEstimator, TransformerMixin, _fit_context
11from ..utils import resample
12from ..utils._param_validation import Interval, Options, StrOptions
13from ..utils.stats import _averaged_weighted_percentile, _weighted_percentile
14from ..utils.validation import (
15 _check_feature_names_in,
16 _check_sample_weight,
17 check_array,
18 check_is_fitted,
19 validate_data,
20)
21from ._encoders import OneHotEncoder
22
23
24class KBinsDiscretizer(TransformerMixin, BaseEstimator):
25 """
26 Bin continuous data into intervals.
27
28 Read more in the :ref:`User Guide <preprocessing_discretization>`.
29
30 .. versionadded:: 0.20
31
32 Parameters
33 ----------
34 n_bins : int or array-like of shape (n_features,), default=5
35 The number of bins to produce. Raises ValueError if ``n_bins < 2``.
36
37 encode : {'onehot', 'onehot-dense', 'ordinal'}, default='onehot'
38 Method used to encode the transformed result.
39
40 - 'onehot': Encode the transformed result with one-hot encoding
41 and return a sparse matrix. Ignored features are always
42 stacked to the right.
43 - 'onehot-dense': Encode the transformed result with one-hot encoding
44 and return a dense array. Ignored features are always
45 stacked to the right.
46 - 'ordinal': Return the bin identifier encoded as an integer value.
47
48 strategy : {'uniform', 'quantile', 'kmeans'}, default='quantile'
49 Strategy used to define the widths of the bins.
50
51 - 'uniform': All bins in each feature have identical widths.
52 - 'quantile': All bins in each feature have the same number of points.
53 - 'kmeans': Values in each bin have the same nearest center of a 1D
54 k-means cluster.
55
56 For an example of the different strategies see:
57 :ref:`sphx_glr_auto_examples_preprocessing_plot_discretization_strategies.py`.
58
59 quantile_method : {"inverted_cdf", "averaged_inverted_cdf",
60 "closest_observation", "interpolated_inverted_cdf", "hazen",
61 "weibull", "linear", "median_unbiased", "normal_unbiased"},
62 default="linear"
63 Method to pass on to np.percentile calculation when using
64 strategy="quantile". Only `averaged_inverted_cdf` and `inverted_cdf`
65 support the use of `sample_weight != None` when subsampling is not
66 active.
67
68 .. versionadded:: 1.7
69
70 dtype : {np.float32, np.float64}, default=None
71 The desired data-type for the output. If None, output dtype is
72 consistent with input dtype. Only np.float32 and np.float64 are
73 supported.
74
75 .. versionadded:: 0.24
76
77 subsample : int or None, default=200_000
78 Maximum number of samples, used to fit the model, for computational
79 efficiency.
80 `subsample=None` means that all the training samples are used when
81 computing the quantiles that determine the binning thresholds.
82 Since quantile computation relies on sorting each column of `X` and
83 that sorting has an `n log(n)` time complexity,
84 it is recommended to use subsampling on datasets with a
85 very large number of samples.
86
87 .. versionchanged:: 1.3
88 The default value of `subsample` changed from `None` to `200_000` when
89 `strategy="quantile"`.
90
91 .. versionchanged:: 1.5
92 The default value of `subsample` changed from `None` to `200_000` when
93 `strategy="uniform"` or `strategy="kmeans"`.
94
95 random_state : int, RandomState instance or None, default=None
96 Determines random number generation for subsampling.
97 Pass an int for reproducible results across multiple function calls.
98 See the `subsample` parameter for more details.
99 See :term:`Glossary <random_state>`.
100
101 .. versionadded:: 1.1
102
103 Attributes
104 ----------
105 bin_edges_ : ndarray of ndarray of shape (n_features,)
106 The edges of each bin. Contain arrays of varying shapes ``(n_bins_, )``
107 Ignored features will have empty arrays.
108
109 n_bins_ : ndarray of shape (n_features,), dtype=np.int64
110 Number of bins per feature. Bins whose width are too small
111 (i.e., <= 1e-8) are removed with a warning.
112
113 n_features_in_ : int
114 Number of features seen during :term:`fit`.
115
116 .. versionadded:: 0.24
117
118 feature_names_in_ : ndarray of shape (`n_features_in_`,)
119 Names of features seen during :term:`fit`. Defined only when `X`
120 has feature names that are all strings.
121
122 .. versionadded:: 1.0
123
124 See Also
125 --------
126 Binarizer : Class used to bin values as ``0`` or
127 ``1`` based on a parameter ``threshold``.
128
129 Notes
130 -----
131
132 For a visualization of discretization on different datasets refer to
133 :ref:`sphx_glr_auto_examples_preprocessing_plot_discretization_classification.py`.
134 On the effect of discretization on linear models see:
135 :ref:`sphx_glr_auto_examples_preprocessing_plot_discretization.py`.
136
137 In bin edges for feature ``i``, the first and last values are used only for
138 ``inverse_transform``. During transform, bin edges are extended to::
139
140 np.concatenate([-np.inf, bin_edges_[i][1:-1], np.inf])
141
142 You can combine ``KBinsDiscretizer`` with
143 :class:`~sklearn.compose.ColumnTransformer` if you only want to preprocess
144 part of the features.
145
146 ``KBinsDiscretizer`` might produce constant features (e.g., when
147 ``encode = 'onehot'`` and certain bins do not contain any data).
148 These features can be removed with feature selection algorithms
149 (e.g., :class:`~sklearn.feature_selection.VarianceThreshold`).
150
151 Examples
152 --------
153 >>> from sklearn.preprocessing import KBinsDiscretizer
154 >>> X = [[-2, 1, -4, -1],
155 ... [-1, 2, -3, -0.5],
156 ... [ 0, 3, -2, 0.5],
157 ... [ 1, 4, -1, 2]]
158 >>> est = KBinsDiscretizer(
159 ... n_bins=3, encode='ordinal', strategy='uniform'
160 ... )
161 >>> est.fit(X)
162 KBinsDiscretizer(...)
163 >>> Xt = est.transform(X)
164 >>> Xt # doctest: +SKIP
165 array([[ 0., 0., 0., 0.],
166 [ 1., 1., 1., 0.],
167 [ 2., 2., 2., 1.],
168 [ 2., 2., 2., 2.]])
169
170 Sometimes it may be useful to convert the data back into the original
171 feature space. The ``inverse_transform`` function converts the binned
172 data into the original feature space. Each value will be equal to the mean
173 of the two bin edges.
174
175 >>> est.bin_edges_[0]
176 array([-2., -1., 0., 1.])
177 >>> est.inverse_transform(Xt)
178 array([[-1.5, 1.5, -3.5, -0.5],
179 [-0.5, 2.5, -2.5, -0.5],
180 [ 0.5, 3.5, -1.5, 0.5],
181 [ 0.5, 3.5, -1.5, 1.5]])
182 """
183
184 _parameter_constraints: dict = {
185 "n_bins": [Interval(Integral, 2, None, closed="left"), "array-like"],
186 "encode": [StrOptions({"onehot", "onehot-dense", "ordinal"})],
187 "strategy": [StrOptions({"uniform", "quantile", "kmeans"})],
188 "quantile_method": [
189 StrOptions(
190 {
191 "warn",
192 "inverted_cdf",
193 "averaged_inverted_cdf",
194 "closest_observation",
195 "interpolated_inverted_cdf",
196 "hazen",
197 "weibull",
198 "linear",
199 "median_unbiased",
200 "normal_unbiased",
201 }
202 )
203 ],
204 "dtype": [Options(type, {np.float64, np.float32}), None],
205 "subsample": [Interval(Integral, 1, None, closed="left"), None],
206 "random_state": ["random_state"],
207 }
208
209 def __init__(
210 self,
211 n_bins=5,
212 *,
213 encode="onehot",
214 strategy="quantile",
215 quantile_method="warn",
216 dtype=None,
217 subsample=200_000,
218 random_state=None,
219 ):
220 self.n_bins = n_bins
221 self.encode = encode
222 self.strategy = strategy
223 self.quantile_method = quantile_method
224 self.dtype = dtype
225 self.subsample = subsample
226 self.random_state = random_state
227
228 @_fit_context(prefer_skip_nested_validation=True)
229 def fit(self, X, y=None, sample_weight=None):
230 """
231 Fit the estimator.
232
233 Parameters
234 ----------
235 X : array-like of shape (n_samples, n_features)
236 Data to be discretized.
237
238 y : None
239 Ignored. This parameter exists only for compatibility with
240 :class:`~sklearn.pipeline.Pipeline`.
241
242 sample_weight : ndarray of shape (n_samples,)
243 Contains weight values to be associated with each sample.
244
245 .. versionadded:: 1.3
246
247 .. versionchanged:: 1.7
248 Added support for strategy="uniform".
249
250 Returns
251 -------
252 self : object
253 Returns the instance itself.
254 """
255 X = validate_data(self, X, dtype="numeric")
256
257 if self.dtype in (np.float64, np.float32):
258 output_dtype = self.dtype
259 else: # self.dtype is None
260 output_dtype = X.dtype
261
262 n_samples, n_features = X.shape
263
264 if sample_weight is not None:
265 sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype)
266
267 if self.subsample is not None and n_samples > self.subsample:
268 # Take a subsample of `X`
269 # When resampling, it is important to subsample **with replacement** to
270 # preserve the distribution, in particular in the presence of a few data
271 # points with large weights. You can check this by setting `replace=False`
272 # in sklearn.utils.test.test_indexing.test_resample_weighted and check that
273 # it fails as a justification for this claim.
274 X = resample(
275 X,
276 replace=True,
277 n_samples=self.subsample,
278 random_state=self.random_state,
279 sample_weight=sample_weight,
280 )
281 # Since we already used the weights when resampling when provided,
282 # we set them back to `None` to avoid accounting for the weights twice
283 # in subsequent operations to compute weight-aware bin edges with
284 # quantiles or k-means.
285 sample_weight = None
286
287 n_features = X.shape[1]
288 n_bins = self._validate_n_bins(n_features)
289
290 bin_edges = np.zeros(n_features, dtype=object)
291
292 # TODO(1.9): remove and switch to quantile_method="averaged_inverted_cdf"
293 # by default.
294 quantile_method = self.quantile_method
295 if self.strategy == "quantile" and quantile_method == "warn":
296 warnings.warn(
297 "The current default behavior, quantile_method='linear', will be "
298 "changed to quantile_method='averaged_inverted_cdf' in "
299 "scikit-learn version 1.9 to naturally support sample weight "
300 "equivalence properties by default. Pass "
301 "quantile_method='averaged_inverted_cdf' explicitly to silence this "
302 "warning.",
303 FutureWarning,
304 )
305 quantile_method = "linear"
306
307 if (
308 self.strategy == "quantile"
309 and quantile_method not in ["inverted_cdf", "averaged_inverted_cdf"]
310 and sample_weight is not None
311 ):
312 raise ValueError(
313 "When fitting with strategy='quantile' and sample weights, "
314 "quantile_method should either be set to 'averaged_inverted_cdf' or "
315 f"'inverted_cdf', got quantile_method='{quantile_method}' instead."
316 )
317
318 if self.strategy != "quantile" and sample_weight is not None:
319 # Prepare a mask to filter out zero-weight samples when extracting
320 # the min and max values of each columns which are needed for the
321 # "uniform" and "kmeans" strategies.
322 nnz_weight_mask = sample_weight != 0
323 else:
324 # Otherwise, all samples are used. Use a slice to avoid creating a
325 # new array.
326 nnz_weight_mask = slice(None)
327
328 for jj in range(n_features):
329 column = X[:, jj]
330 col_min = column[nnz_weight_mask].min()
331 col_max = column[nnz_weight_mask].max()
332
333 if col_min == col_max:
334 warnings.warn(
335 "Feature %d is constant and will be replaced with 0." % jj
336 )
337 n_bins[jj] = 1
338 bin_edges[jj] = np.array([-np.inf, np.inf])
339 continue
340
341 if self.strategy == "uniform":
342 bin_edges[jj] = np.linspace(col_min, col_max, n_bins[jj] + 1)
343
344 elif self.strategy == "quantile":
345 percentile_levels = np.linspace(0, 100, n_bins[jj] + 1)
346
347 # method="linear" is the implicit default for any numpy
348 # version. So we keep it version independent in that case by
349 # using an empty param dict.
350 percentile_kwargs = {}
351 if quantile_method != "linear" and sample_weight is None:
352 percentile_kwargs["method"] = quantile_method
353
354 if sample_weight is None:
355 bin_edges[jj] = np.asarray(
356 np.percentile(column, percentile_levels, **percentile_kwargs),
357 dtype=np.float64,
358 )
359 else:
360 # TODO: make _weighted_percentile and
361 # _averaged_weighted_percentile accept an array of
362 # quantiles instead of calling it multiple times and
363 # sorting the column multiple times as a result.
364 percentile_func = {
365 "inverted_cdf": _weighted_percentile,
366 "averaged_inverted_cdf": _averaged_weighted_percentile,
367 }[quantile_method]
368 bin_edges[jj] = np.asarray(
369 [
370 percentile_func(column, sample_weight, percentile_rank=p)
371 for p in percentile_levels
372 ],
373 dtype=np.float64,
374 )
375 elif self.strategy == "kmeans":
376 from ..cluster import KMeans # fixes import loops
377
378 # Deterministic initialization with uniform spacing
379 uniform_edges = np.linspace(col_min, col_max, n_bins[jj] + 1)
380 init = (uniform_edges[1:] + uniform_edges[:-1])[:, None] * 0.5
381
382 # 1D k-means procedure
383 km = KMeans(n_clusters=n_bins[jj], init=init, n_init=1)
384 centers = km.fit(
385 column[:, None], sample_weight=sample_weight
386 ).cluster_centers_[:, 0]
387 # Must sort, centers may be unsorted even with sorted init
388 centers.sort()
389 bin_edges[jj] = (centers[1:] + centers[:-1]) * 0.5
390 bin_edges[jj] = np.r_[col_min, bin_edges[jj], col_max]
391
392 # Remove bins whose width are too small (i.e., <= 1e-8)
393 if self.strategy in ("quantile", "kmeans"):
394 mask = np.ediff1d(bin_edges[jj], to_begin=np.inf) > 1e-8
395 bin_edges[jj] = bin_edges[jj][mask]
396 if len(bin_edges[jj]) - 1 != n_bins[jj]:
397 warnings.warn(
398 "Bins whose width are too small (i.e., <= "
399 "1e-8) in feature %d are removed. Consider "
400 "decreasing the number of bins." % jj
401 )
402 n_bins[jj] = len(bin_edges[jj]) - 1
403
404 self.bin_edges_ = bin_edges
405 self.n_bins_ = n_bins
406
407 if "onehot" in self.encode:
408 self._encoder = OneHotEncoder(
409 categories=[np.arange(i) for i in self.n_bins_],
410 sparse_output=self.encode == "onehot",
411 dtype=output_dtype,
412 )
413 # Fit the OneHotEncoder with toy datasets
414 # so that it's ready for use after the KBinsDiscretizer is fitted
415 self._encoder.fit(np.zeros((1, len(self.n_bins_))))
416
417 return self
418
419 def _validate_n_bins(self, n_features):
420 """Returns n_bins_, the number of bins per feature."""
421 orig_bins = self.n_bins
422 if isinstance(orig_bins, Integral):
423 return np.full(n_features, orig_bins, dtype=int)
424
425 n_bins = check_array(orig_bins, dtype=int, copy=True, ensure_2d=False)
426
427 if n_bins.ndim > 1 or n_bins.shape[0] != n_features:
428 raise ValueError("n_bins must be a scalar or array of shape (n_features,).")
429
430 bad_nbins_value = (n_bins < 2) | (n_bins != orig_bins)
431
432 violating_indices = np.where(bad_nbins_value)[0]
433 if violating_indices.shape[0] > 0:
434 indices = ", ".join(str(i) for i in violating_indices)
435 raise ValueError(
436 "{} received an invalid number "
437 "of bins at indices {}. Number of bins "
438 "must be at least 2, and must be an int.".format(
439 KBinsDiscretizer.__name__, indices
440 )
441 )
442 return n_bins
443
444 def transform(self, X):
445 """
446 Discretize the data.
447
448 Parameters
449 ----------
450 X : array-like of shape (n_samples, n_features)
451 Data to be discretized.
452
453 Returns
454 -------
455 Xt : {ndarray, sparse matrix}, dtype={np.float32, np.float64}
456 Data in the binned space. Will be a sparse matrix if
457 `self.encode='onehot'` and ndarray otherwise.
458 """
459 check_is_fitted(self)
460
461 # check input and attribute dtypes
462 dtype = (np.float64, np.float32) if self.dtype is None else self.dtype
463 Xt = validate_data(self, X, copy=True, dtype=dtype, reset=False)
464
465 bin_edges = self.bin_edges_
466 for jj in range(Xt.shape[1]):
467 Xt[:, jj] = np.searchsorted(bin_edges[jj][1:-1], Xt[:, jj], side="right")
468
469 if self.encode == "ordinal":
470 return Xt
471
472 dtype_init = None
473 if "onehot" in self.encode:
474 dtype_init = self._encoder.dtype
475 self._encoder.dtype = Xt.dtype
476 try:
477 Xt_enc = self._encoder.transform(Xt)
478 finally:
479 # revert the initial dtype to avoid modifying self.
480 self._encoder.dtype = dtype_init
481 return Xt_enc
482
483 def inverse_transform(self, X):
484 """
485 Transform discretized data back to original feature space.
486
487 Note that this function does not regenerate the original data
488 due to discretization rounding.
489
490 Parameters
491 ----------
492 X : array-like of shape (n_samples, n_features)
493 Transformed data in the binned space.
494
495 Returns
496 -------
497 X_original : ndarray, dtype={np.float32, np.float64}
498 Data in the original feature space.
499 """
500
501 check_is_fitted(self)
502
503 if "onehot" in self.encode:
504 X = self._encoder.inverse_transform(X)
505
506 Xinv = check_array(X, copy=True, dtype=(np.float64, np.float32))
507 n_features = self.n_bins_.shape[0]
508 if Xinv.shape[1] != n_features:
509 raise ValueError(
510 "Incorrect number of features. Expecting {}, received {}.".format(
511 n_features, Xinv.shape[1]
512 )
513 )
514
515 for jj in range(n_features):
516 bin_edges = self.bin_edges_[jj]
517 bin_centers = (bin_edges[1:] + bin_edges[:-1]) * 0.5
518 Xinv[:, jj] = bin_centers[(Xinv[:, jj]).astype(np.int64)]
519
520 return Xinv
521
522 def get_feature_names_out(self, input_features=None):
523 """Get output feature names.
524
525 Parameters
526 ----------
527 input_features : array-like of str or None, default=None
528 Input features.
529
530 - If `input_features` is `None`, then `feature_names_in_` is
531 used as feature names in. If `feature_names_in_` is not defined,
532 then the following input feature names are generated:
533 `["x0", "x1", ..., "x(n_features_in_ - 1)"]`.
534 - If `input_features` is an array-like, then `input_features` must
535 match `feature_names_in_` if `feature_names_in_` is defined.
536
537 Returns
538 -------
539 feature_names_out : ndarray of str objects
540 Transformed feature names.
541 """
542 check_is_fitted(self, "n_features_in_")
543 input_features = _check_feature_names_in(self, input_features)
544 if hasattr(self, "_encoder"):
545 return self._encoder.get_feature_names_out(input_features)
546
547 # ordinal encoding
548 return input_features
549 