1"""
2Quantilization functions and related stuff
3"""
4
5from __future__ import annotations
6
7from typing import (
8 TYPE_CHECKING,
9 Any,
10 Literal,
11 cast,
12)
13
14import numpy as np
15
16from pandas._libs import (
17 Timedelta,
18 Timestamp,
19 lib,
20)
21from pandas.util._decorators import set_module
22
23from pandas.core.dtypes.common import (
24 ensure_platform_int,
25 is_bool_dtype,
26 is_integer,
27 is_list_like,
28 is_numeric_dtype,
29 is_scalar,
30)
31from pandas.core.dtypes.dtypes import (
32 CategoricalDtype,
33 DatetimeTZDtype,
34 ExtensionDtype,
35)
36from pandas.core.dtypes.generic import ABCSeries
37from pandas.core.dtypes.missing import isna
38
39from pandas import (
40 Categorical,
41 Index,
42 IntervalIndex,
43)
44import pandas.core.algorithms as algos
45from pandas.core.arrays.datetimelike import dtype_to_unit
46from pandas.core.col import Expression
47
48if TYPE_CHECKING:
49 from collections.abc import Callable
50
51 from pandas._typing import (
52 DtypeObj,
53 IntervalLeftRight,
54 TimeUnit,
55 )
56
57
58@set_module("pandas")
59def cut(
60 x,
61 bins,
62 right: bool = True,
63 labels=None,
64 retbins: bool = False,
65 precision: int = 3,
66 include_lowest: bool = False,
67 duplicates: str = "raise",
68 ordered: bool = True,
69):
70 """
71 Bin values into discrete intervals.
72
73 Use `cut` when you need to segment and sort data values into bins. This
74 function is also useful for going from a continuous variable to a
75 categorical variable. For example, `cut` could convert ages to groups of
76 age ranges. Supports binning into an equal number of bins, or a
77 pre-specified array of bins.
78
79 Parameters
80 ----------
81 x : 1d ndarray or Series
82 The input array to be binned. Must be 1-dimensional.
83 bins : int, sequence of scalars, or IntervalIndex
84 The criteria to bin by.
85
86 * int : Defines the number of equal-width bins in the range of `x`. The
87 range of `x` is extended by .1% on each side to include the minimum
88 and maximum values of `x`.
89 * sequence of scalars : Defines the bin edges allowing for non-uniform
90 width. No extension of the range of `x` is done.
91 * IntervalIndex : Defines the exact bins to be used. Note that
92 IntervalIndex for `bins` must be non-overlapping.
93
94 right : bool, default True
95 Indicates whether `bins` includes the rightmost edge or not. If
96 ``right == True`` (the default), then the `bins` ``[1, 2, 3, 4]``
97 indicate (1,2], (2,3], (3,4]. This argument is ignored when
98 `bins` is an IntervalIndex.
99 labels : array or False, default None
100 Specifies the labels for the returned bins. Must be the same length as
101 the resulting bins. If False, returns only integer indicators of the
102 bins. This affects the type of the output container (see below).
103 This argument is ignored when `bins` is an IntervalIndex. If True,
104 raises an error. When `ordered=False`, labels must be provided.
105 retbins : bool, default False
106 Whether to return the bins or not. Useful when bins is provided
107 as a scalar.
108 precision : int, default 3
109 The precision at which to store and display the bins labels.
110 include_lowest : bool, default False
111 Whether the first interval should be left-inclusive or not.
112 duplicates : {'raise', 'drop'}, default 'raise'
113 If bin edges are not unique, raise ValueError or drop non-uniques.
114 ordered : bool, default True
115 Whether the labels are ordered or not. Applies to returned types
116 Categorical and Series (with Categorical dtype). If True,
117 the resulting categorical will be ordered. If False, the resulting
118 categorical will be unordered (labels must be provided).
119
120 Returns
121 -------
122 out : Categorical, Series, or ndarray
123 An array-like object representing the respective bin for each value
124 of `x`. The type depends on the value of `labels`.
125
126 * None (default) : returns a Series for Series `x` or a
127 Categorical for all other inputs. The values stored within
128 are Interval dtype.
129
130 * sequence of scalars : returns a Series for Series `x` or a
131 Categorical for all other inputs. The values stored within
132 are whatever the type in the sequence is.
133
134 * False : returns a 1d ndarray or Series of integers.
135
136 bins : numpy.ndarray or IntervalIndex.
137 The computed or specified bins. Only returned when `retbins=True`.
138 For scalar or sequence `bins`, this is an ndarray with the computed
139 bins. If set `duplicates=drop`, `bins` will drop non-unique bin. For
140 an IntervalIndex `bins`, this is equal to `bins`.
141
142 See Also
143 --------
144 qcut : Discretize variable into equal-sized buckets based on rank
145 or based on sample quantiles.
146 Categorical : Array type for storing data that come from a
147 fixed set of values.
148 Series : One-dimensional array with axis labels (including time series).
149 IntervalIndex : Immutable Index implementing an ordered, sliceable set.
150 numpy.histogram_bin_edges: Function to calculate only the edges of the bins
151 used by the histogram function.
152
153 Notes
154 -----
155 Any NA values will be NA in the result. Out of bounds values will be NA in
156 the resulting Series or Categorical object.
157
158 ``numpy.histogram_bin_edges`` can be used along with cut to calculate bins according
159 to some predefined methods.
160
161 Reference :ref:`the user guide <reshaping.tile.cut>` for more examples.
162
163 Examples
164 --------
165 Discretize into three equal-sized bins.
166
167 >>> pd.cut(np.array([1, 7, 5, 4, 6, 3]), 3)
168 ... # doctest: +ELLIPSIS
169 [(0.994, 3.0], (5.0, 7.0], (3.0, 5.0], (3.0, 5.0], (5.0, 7.0], ...
170 Categories (3, interval[float64, right]): [(0.994, 3.0] < (3.0, 5.0] ...
171
172 >>> pd.cut(np.array([1, 7, 5, 4, 6, 3]), 3, retbins=True)
173 ... # doctest: +ELLIPSIS
174 ([(0.994, 3.0], (5.0, 7.0], (3.0, 5.0], (3.0, 5.0], (5.0, 7.0], ...
175 Categories (3, interval[float64, right]): [(0.994, 3.0] < (3.0, 5.0] ...
176 array([0.994, 3. , 5. , 7. ]))
177
178 Discovers the same bins, but assign them specific labels. Notice that
179 the returned Categorical's categories are `labels` and is ordered.
180
181 >>> pd.cut(np.array([1, 7, 5, 4, 6, 3]), 3, labels=["bad", "medium", "good"])
182 ['bad', 'good', 'medium', 'medium', 'good', 'bad']
183 Categories (3, str): ['bad' < 'medium' < 'good']
184
185 ``ordered=False`` will result in unordered categories when labels are passed.
186 This parameter can be used to allow non-unique labels:
187
188 >>> pd.cut(np.array([1, 7, 5, 4, 6, 3]), 3, labels=["B", "A", "B"], ordered=False)
189 ['B', 'B', 'A', 'A', 'B', 'B']
190 Categories (2, str): ['A', 'B']
191
192 ``labels=False`` implies you just want the bins back.
193
194 >>> pd.cut([0, 1, 1, 2], bins=4, labels=False)
195 array([0, 1, 1, 3])
196
197 Passing a Series as an input returns a Series with categorical dtype:
198
199 >>> s = pd.Series(np.array([2, 4, 6, 8, 10]), index=["a", "b", "c", "d", "e"])
200 >>> pd.cut(s, 3)
201 ... # doctest: +ELLIPSIS
202 a (1.992, 4.667]
203 b (1.992, 4.667]
204 c (4.667, 7.333]
205 d (7.333, 10.0]
206 e (7.333, 10.0]
207 dtype: category
208 Categories (3, interval[float64, right]): [(1.992, 4.667] < (4.667, ...
209
210 Passing a Series as an input returns a Series with mapping value.
211 It is used to map numerically to intervals based on bins.
212
213 >>> s = pd.Series(np.array([2, 4, 6, 8, 10]), index=["a", "b", "c", "d", "e"])
214 >>> pd.cut(s, [0, 2, 4, 6, 8, 10], labels=False, retbins=True, right=False)
215 ... # doctest: +ELLIPSIS
216 (a 1.0
217 b 2.0
218 c 3.0
219 d 4.0
220 e NaN
221 dtype: float64,
222 array([ 0, 2, 4, 6, 8, 10]))
223
224 Use `drop` optional when bins is not unique
225
226 >>> pd.cut(
227 ... s,
228 ... [0, 2, 4, 6, 10, 10],
229 ... labels=False,
230 ... retbins=True,
231 ... right=False,
232 ... duplicates="drop",
233 ... )
234 ... # doctest: +ELLIPSIS
235 (a 1.0
236 b 2.0
237 c 3.0
238 d 3.0
239 e NaN
240 dtype: float64,
241 array([ 0, 2, 4, 6, 10]))
242
243 Passing an IntervalIndex for `bins` results in those categories exactly.
244 Notice that values not covered by the IntervalIndex are set to NaN. 0
245 is to the left of the first bin (which is closed on the right), and 1.5
246 falls between two bins.
247
248 >>> bins = pd.IntervalIndex.from_tuples([(0, 1), (2, 3), (4, 5)])
249 >>> pd.cut([0, 0.5, 1.5, 2.5, 4.5], bins)
250 [NaN, (0.0, 1.0], NaN, (2.0, 3.0], (4.0, 5.0]]
251 Categories (3, interval[int64, right]): [(0, 1] < (2, 3] < (4, 5]]
252
253 Using np.histogram_bin_edges with cut
254
255 >>> pd.cut(
256 ... np.array([1, 7, 5, 4]),
257 ... bins=np.histogram_bin_edges(np.array([1, 7, 5, 4]), bins="auto"),
258 ... )
259 ... # doctest: +ELLIPSIS
260 [NaN, (5.0, 7.0], (3.0, 5.0], (3.0, 5.0]]
261 Categories (3, interval[float64, right]): [(1.0, 3.0] < (3.0, 5.0] < (5.0, 7.0]]
262 """
263 # NOTE: this binning code is changed a bit from histogram for var(x) == 0
264
265 original = x
266 x_idx = _preprocess_for_cut(x)
267 x_idx, _ = _coerce_to_type(x_idx)
268
269 if not np.iterable(bins):
270 bins = _nbins_to_bins(x_idx, bins, right)
271
272 elif isinstance(bins, IntervalIndex):
273 if bins.is_overlapping:
274 raise ValueError("Overlapping IntervalIndex is not accepted.")
275
276 else:
277 bins = Index(bins)
278 if not bins.is_monotonic_increasing:
279 raise ValueError("bins must increase monotonically.")
280
281 fac, bins = _bins_to_cuts(
282 x_idx,
283 bins,
284 right=right,
285 labels=labels,
286 precision=precision,
287 include_lowest=include_lowest,
288 duplicates=duplicates,
289 ordered=ordered,
290 )
291
292 return _postprocess_for_cut(fac, bins, retbins, original)
293
294
295@set_module("pandas")
296def qcut(
297 x,
298 q,
299 labels=None,
300 retbins: bool = False,
301 precision: int = 3,
302 duplicates: str = "raise",
303):
304 """
305 Quantile-based discretization function.
306
307 Discretize variable into equal-sized buckets based on rank or based
308 on sample quantiles. For example 1000 values for 10 quantiles would
309 produce a Categorical object indicating quantile membership for each data point.
310
311 Parameters
312 ----------
313 x : 1d ndarray or Series
314 Input Numpy array or pandas Series object to be discretized.
315 q : int or list-like of float
316 Number of quantiles. 10 for deciles, 4 for quartiles, etc. Alternately
317 array of quantiles, e.g. [0, .25, .5, .75, 1.] for quartiles.
318 labels : array or False, default None
319 Used as labels for the resulting bins. Must be of the same length as
320 the resulting bins. If False, return only integer indicators of the
321 bins. If True, raises an error.
322 retbins : bool, optional
323 Whether to return the (bins, labels) or not. Can be useful if bins
324 is given as a scalar.
325 precision : int, optional
326 The precision at which to store and display the bins labels.
327 duplicates : {default 'raise', 'drop'}, optional
328 If bin edges are not unique, raise ValueError or drop non-uniques.
329
330 Returns
331 -------
332 out : Categorical or Series or array of integers if labels is False
333 The return type (Categorical or Series) depends on the input: a Series
334 of type category if input is a Series else Categorical. Bins are
335 represented as categories when categorical data is returned.
336 bins : ndarray of floats
337 Returned only if `retbins` is True.
338
339 See Also
340 --------
341 cut : Bin values into discrete intervals.
342 Series.quantile : Return value at the given quantile.
343
344 Notes
345 -----
346 Out of bounds values will be NA in the resulting Categorical object
347
348 Examples
349 --------
350 >>> pd.qcut(range(5), 4)
351 ... # doctest: +ELLIPSIS
352 [(-0.001, 1.0], (-0.001, 1.0], (1.0, 2.0], (2.0, 3.0], (3.0, 4.0]]
353 Categories (4, interval[float64, right]): [(-0.001, 1.0] < (1.0, 2.0] ...
354
355 >>> pd.qcut(range(5), 3, labels=["good", "medium", "bad"])
356 ... # doctest: +SKIP
357 [good, good, medium, bad, bad]
358 Categories (3, str): [good < medium < bad]
359
360 >>> pd.qcut(range(5), 4, labels=False)
361 array([0, 0, 1, 2, 3])
362 """
363 if isinstance(x, Expression):
364 return x._call_with_func(
365 qcut, x=x, q=q, labels=labels, retbins=retbins, precision=precision
366 )
367 original = x
368 x_idx = _preprocess_for_cut(x)
369 x_idx, _ = _coerce_to_type(x_idx)
370
371 if is_integer(q):
372 quantiles = np.linspace(0, 1, q + 1)
373 # Round up rather than to nearest if not representable in base 2
374 np.putmask(
375 quantiles,
376 q * quantiles != np.arange(q + 1),
377 np.nextafter(quantiles, 1),
378 )
379 else:
380 quantiles = q
381
382 bins = x_idx.to_series().dropna().quantile(quantiles)
383
384 fac, bins = _bins_to_cuts(
385 x_idx,
386 Index(bins),
387 labels=labels,
388 precision=precision,
389 include_lowest=True,
390 duplicates=duplicates,
391 )
392
393 return _postprocess_for_cut(fac, bins, retbins, original)
394
395
396def _nbins_to_bins(x_idx: Index, nbins: int, right: bool) -> Index:
397 """
398 If a user passed an integer N for bins, convert this to a sequence of N
399 equal(ish)-sized bins.
400 """
401 if is_scalar(nbins) and nbins < 1:
402 raise ValueError("`bins` should be a positive integer.")
403
404 if x_idx.size == 0:
405 raise ValueError("Cannot cut empty array")
406
407 rng = (x_idx.min(), x_idx.max())
408 mn, mx = rng
409
410 if is_numeric_dtype(x_idx.dtype) and (np.isinf(mn) or np.isinf(mx)):
411 # GH#24314
412 raise ValueError(
413 "cannot specify integer `bins` when input data contains infinity"
414 )
415
416 if mn == mx: # adjust end points before binning
417 if _is_dt_or_td(x_idx.dtype):
418 # using seconds=1 is pretty arbitrary here
419 # error: Argument 1 to "dtype_to_unit" has incompatible type
420 # "dtype[Any] | ExtensionDtype"; expected "DatetimeTZDtype | dtype[Any]"
421 unit = dtype_to_unit(x_idx.dtype) # type: ignore[arg-type]
422 td = Timedelta(seconds=1).as_unit(cast("TimeUnit", unit))
423 # Use DatetimeArray/TimedeltaArray method instead of linspace
424 # error: Item "ExtensionArray" of "ExtensionArray | ndarray[Any, Any]"
425 # has no attribute "_generate_range"
426 bins = x_idx._values._generate_range( # type: ignore[union-attr]
427 start=mn - td, end=mx + td, periods=nbins + 1, freq=None, unit=unit
428 )
429 else:
430 mn -= 0.001 * abs(mn) if mn != 0 else 0.001
431 mx += 0.001 * abs(mx) if mx != 0 else 0.001
432
433 bins = np.linspace(mn, mx, nbins + 1, endpoint=True)
434 else: # adjust end points after binning
435 if _is_dt_or_td(x_idx.dtype):
436 # Use DatetimeArray/TimedeltaArray method instead of linspace
437
438 # error: Argument 1 to "dtype_to_unit" has incompatible type
439 # "dtype[Any] | ExtensionDtype"; expected "DatetimeTZDtype | dtype[Any]"
440 unit = dtype_to_unit(x_idx.dtype) # type: ignore[arg-type]
441 # error: Item "ExtensionArray" of "ExtensionArray | ndarray[Any, Any]"
442 # has no attribute "_generate_range"
443 bins = x_idx._values._generate_range( # type: ignore[union-attr]
444 start=mn, end=mx, periods=nbins + 1, freq=None, unit=unit
445 )
446 else:
447 bins = np.linspace(mn, mx, nbins + 1, endpoint=True)
448 adj = (mx - mn) * 0.001 # 0.1% of the range
449 if right:
450 bins[0] -= adj
451 else:
452 bins[-1] += adj
453
454 return Index(bins, copy=False)
455
456
457def _bins_to_cuts(
458 x_idx: Index,
459 bins: Index,
460 right: bool = True,
461 labels=None,
462 precision: int = 3,
463 include_lowest: bool = False,
464 duplicates: str = "raise",
465 ordered: bool = True,
466):
467 if not ordered and labels is None:
468 raise ValueError("'labels' must be provided if 'ordered = False'")
469
470 if duplicates not in ["raise", "drop"]:
471 raise ValueError(
472 "invalid value for 'duplicates' parameter, valid options are: raise, drop"
473 )
474
475 result: Categorical | np.ndarray
476
477 if isinstance(bins, IntervalIndex):
478 # we have a fast-path here
479 ids = bins.get_indexer(x_idx)
480 cat_dtype = CategoricalDtype(bins, ordered=True)
481 result = Categorical.from_codes(ids, dtype=cat_dtype, validate=False)
482 return result, bins
483
484 unique_bins = algos.unique(bins)
485 if len(unique_bins) < len(bins) and len(bins) != 2:
486 if duplicates == "raise":
487 raise ValueError(
488 f"Bin edges must be unique: {bins!r}.\n"
489 f"You can drop duplicate edges by setting the 'duplicates' kwarg"
490 )
491 bins = unique_bins
492
493 side: Literal["left", "right"] = "left" if right else "right"
494
495 try:
496 ids = bins.searchsorted(x_idx, side=side)
497 except TypeError as err:
498 # e.g. test_datetime_nan_error if bins are DatetimeArray and x_idx
499 # is integers
500 if x_idx.dtype.kind == "m":
501 raise ValueError("bins must be of timedelta64 dtype") from err
502 elif x_idx.dtype.kind == bins.dtype.kind == "M":
503 raise ValueError(
504 "Cannot use timezone-naive bins with timezone-aware values, "
505 "or vice-versa"
506 ) from err
507 elif x_idx.dtype.kind == "M":
508 raise ValueError("bins must be of datetime64 dtype") from err
509 else:
510 raise
511 ids = ensure_platform_int(ids)
512
513 if include_lowest:
514 ids[x_idx == bins[0]] = 1
515
516 na_mask = isna(x_idx) | (ids == len(bins)) | (ids == 0)
517 has_nas = na_mask.any()
518
519 if labels is not False:
520 if not (labels is None or is_list_like(labels)):
521 raise ValueError(
522 "Bin labels must either be False, None or passed in as a "
523 "list-like argument"
524 )
525
526 if labels is None:
527 labels = _format_labels(
528 bins, precision, right=right, include_lowest=include_lowest
529 )
530 elif ordered and len(set(labels)) != len(labels):
531 raise ValueError(
532 "labels must be unique if ordered=True; pass ordered=False "
533 "for duplicate labels"
534 )
535 elif len(labels) != len(bins) - 1:
536 raise ValueError(
537 "Bin labels must be one fewer than the number of bin edges"
538 )
539
540 if not isinstance(getattr(labels, "dtype", None), CategoricalDtype):
541 labels = Categorical(
542 labels,
543 categories=labels if len(set(labels)) == len(labels) else None,
544 ordered=ordered,
545 )
546 # TODO: handle mismatch between categorical label order and pandas.cut order.
547 np.putmask(ids, na_mask, 0)
548 result = algos.take_nd(labels, ids - 1)
549
550 else:
551 result = ids - 1
552 if has_nas:
553 result = result.astype(np.float64)
554 np.putmask(result, na_mask, np.nan)
555
556 return result, bins
557
558
559def _coerce_to_type(x: Index) -> tuple[Index, DtypeObj | None]:
560 """
561 if the passed data is of datetime/timedelta, bool or nullable int type,
562 this method converts it to numeric so that cut or qcut method can
563 handle it
564 """
565 dtype: DtypeObj | None = None
566
567 if _is_dt_or_td(x.dtype):
568 dtype = x.dtype
569 elif is_bool_dtype(x.dtype):
570 # GH 20303
571 x = x.astype(np.int64)
572 # To support cut and qcut for IntegerArray we convert to float dtype.
573 # Will properly support in the future.
574 # https://github.com/pandas-dev/pandas/pull/31290
575 # https://github.com/pandas-dev/pandas/issues/31389
576 elif isinstance(x.dtype, ExtensionDtype) and is_numeric_dtype(x.dtype):
577 x_arr = x.to_numpy(dtype=np.float64, na_value=np.nan)
578 x = Index(x_arr, copy=False)
579
580 return Index(x), dtype
581
582
583def _is_dt_or_td(dtype: DtypeObj) -> bool:
584 # Note: the dtype here comes from an Index.dtype, so we know that that any
585 # dt64/td64 dtype is of a supported unit.
586 return isinstance(dtype, DatetimeTZDtype) or lib.is_np_dtype(dtype, "mM")
587
588
589def _format_labels(
590 bins: Index,
591 precision: int,
592 right: bool = True,
593 include_lowest: bool = False,
594) -> IntervalIndex:
595 """based on the dtype, return our labels"""
596 closed: IntervalLeftRight = "right" if right else "left"
597
598 formatter: Callable[[Any], Timestamp] | Callable[[Any], Timedelta]
599
600 if _is_dt_or_td(bins.dtype):
601 # error: Argument 1 to "dtype_to_unit" has incompatible type
602 # "dtype[Any] | ExtensionDtype"; expected "DatetimeTZDtype | dtype[Any]"
603 unit = dtype_to_unit(bins.dtype) # type: ignore[arg-type]
604 unit = cast("TimeUnit", unit)
605 formatter = lambda x: x
606 adjust = lambda x: x - Timedelta(1, unit=unit).as_unit(unit)
607 else:
608 precision = _infer_precision(precision, bins)
609 formatter = lambda x: _round_frac(x, precision)
610 adjust = lambda x: x - 10 ** (-precision)
611
612 breaks = [formatter(b) for b in bins]
613 if right and include_lowest:
614 # adjust lhs of first interval by precision to account for being right closed
615 breaks[0] = adjust(breaks[0])
616
617 if _is_dt_or_td(bins.dtype):
618 # error: "Index" has no attribute "as_unit"
619 breaks = type(bins)(breaks).as_unit(unit) # type: ignore[attr-defined]
620
621 return IntervalIndex.from_breaks(breaks, closed=closed)
622
623
624def _preprocess_for_cut(x) -> Index:
625 """
626 handles preprocessing for cut where we convert passed
627 input to array, strip the index information and store it
628 separately
629 """
630 # Check that the passed array is a Pandas or Numpy object
631 # We don't want to strip away a Pandas data-type here (e.g. datetimetz)
632 ndim = getattr(x, "ndim", None)
633 if ndim is None:
634 x = np.asarray(x)
635 if x.ndim != 1:
636 raise ValueError("Input array must be 1 dimensional")
637
638 return Index(x, copy=False)
639
640
641def _postprocess_for_cut(fac, bins, retbins: bool, original):
642 """
643 handles post processing for the cut method where
644 we combine the index information if the originally passed
645 datatype was a series
646 """
647 if isinstance(original, ABCSeries):
648 fac = original._constructor(fac, index=original.index, name=original.name)
649
650 if not retbins:
651 return fac
652
653 if isinstance(bins, Index) and is_numeric_dtype(bins.dtype):
654 bins = bins._values
655
656 return fac, bins
657
658
659def _round_frac(x, precision: int):
660 """
661 Round the fractional part of the given number
662 """
663 if not np.isfinite(x) or x == 0:
664 return x
665 else:
666 frac, whole = np.modf(x)
667 if whole == 0:
668 digits = -int(np.floor(np.log10(abs(frac)))) - 1 + precision
669 else:
670 digits = precision
671 return np.around(x, digits)
672
673
674def _infer_precision(base_precision: int, bins: Index) -> int:
675 """
676 Infer an appropriate precision for _round_frac
677 """
678 for precision in range(base_precision, 20):
679 levels = np.asarray([_round_frac(b, precision) for b in bins])
680 if algos.unique(levels).size == bins.size:
681 return precision
682 return base_precision # default