1"""
2Provide classes to perform the groupby aggregate operations.
3
4These are not exposed to the user and provide implementations of the grouping
5operations, primarily in cython. These classes (BaseGrouper and BinGrouper)
6are contained *in* the SeriesGroupBy and DataFrameGroupBy objects.
7"""
8
9from __future__ import annotations
10
11import collections
12import functools
13from typing import (
14 TYPE_CHECKING,
15 Any,
16 Generic,
17 final,
18)
19import warnings
20
21import numpy as np
22
23from pandas._libs import (
24 NaT,
25 lib,
26)
27import pandas._libs.groupby as libgroupby
28from pandas._typing import (
29 ArrayLike,
30 AxisInt,
31 NDFrameT,
32 Shape,
33 npt,
34)
35from pandas.errors import (
36 AbstractMethodError,
37 Pandas4Warning,
38)
39from pandas.util._decorators import cache_readonly
40from pandas.util._exceptions import find_stack_level
41
42from pandas.core.dtypes.cast import (
43 maybe_downcast_to_dtype,
44)
45from pandas.core.dtypes.common import (
46 ensure_float64,
47 ensure_int64,
48 ensure_platform_int,
49 ensure_uint64,
50 is_1d_only_ea_dtype,
51)
52from pandas.core.dtypes.missing import (
53 isna,
54 maybe_fill,
55)
56
57from pandas.core.arrays import Categorical
58from pandas.core.frame import DataFrame
59from pandas.core.groupby import grouper
60from pandas.core.indexes.api import (
61 CategoricalIndex,
62 Index,
63 MultiIndex,
64 ensure_index,
65)
66from pandas.core.series import Series
67from pandas.core.sorting import (
68 compress_group_index,
69 decons_obs_group_ids,
70 get_group_index,
71 get_group_index_sorter,
72)
73
74if TYPE_CHECKING:
75 from collections.abc import (
76 Callable,
77 Generator,
78 Hashable,
79 Iterator,
80 )
81
82 from pandas.core.generic import NDFrame
83
84
85def check_result_array(obj, dtype) -> None:
86 # Our operation is supposed to be an aggregation/reduction. If
87 # it returns an ndarray, this likely means an invalid operation has
88 # been passed. See test_apply_without_aggregation, test_agg_must_agg
89 if isinstance(obj, np.ndarray):
90 if dtype != object:
91 # If it is object dtype, the function can be a reduction/aggregation
92 # and still return an ndarray e.g. test_agg_over_numpy_arrays
93 raise ValueError("Must produce aggregated value")
94
95
96def extract_result(res):
97 """
98 Extract the result object, it might be a 0-dim ndarray
99 or a len-1 0-dim, or a scalar
100 """
101 if hasattr(res, "_values"):
102 # Preserve EA
103 res = res._values
104 if res.ndim == 1 and len(res) == 1:
105 warnings.warn(
106 "Converting a Series or array of length 1 into a scalar is "
107 "deprecated and will be removed in a future version. If you wish "
108 "to preserve the current behavior, have ``func`` return scalars.",
109 category=Pandas4Warning,
110 stacklevel=find_stack_level(),
111 )
112 # see test_agg_lambda_with_timezone, test_resampler_grouper.py::test_apply
113 res = res[0]
114 return res
115
116
117_REDUCEAT_UFUNCS: dict[str, np.ufunc] = {
118 "sum": np.add,
119 "prod": np.multiply,
120 "min": np.minimum,
121 "max": np.maximum,
122 "mean": np.add,
123}
124
125
126class WrappedCythonOp:
127 """
128 Dispatch logic for functions defined in _libs.groupby
129
130 Parameters
131 ----------
132 how: str
133 Operation name, e.g. "mean".
134 has_dropped_na: bool
135 True precisely when dropna=True and the grouper contains a null value.
136 """
137
138 # Functions for which we do _not_ attempt to cast the cython result
139 # back to the original dtype.
140 cast_blocklist = frozenset(
141 ["any", "all", "rank", "count", "size", "idxmin", "idxmax"]
142 )
143
144 def __init__(self, how: str, has_dropped_na: bool) -> None:
145 self.kind = (
146 "aggregate" if how in self._CYTHON_FUNCTIONS["aggregate"] else "transform"
147 )
148 self.how = how
149 self.has_dropped_na = has_dropped_na
150
151 _CYTHON_FUNCTIONS: dict[str, dict] = {
152 "aggregate": {
153 "any": functools.partial(libgroupby.group_any_all, val_test="any"),
154 "all": functools.partial(libgroupby.group_any_all, val_test="all"),
155 "sum": "group_sum",
156 "prod": "group_prod",
157 "idxmin": functools.partial(libgroupby.group_idxmin_idxmax, name="idxmin"),
158 "idxmax": functools.partial(libgroupby.group_idxmin_idxmax, name="idxmax"),
159 "min": "group_min",
160 "max": "group_max",
161 "mean": "group_mean",
162 "median": "group_median_float64",
163 "var": "group_var",
164 "std": functools.partial(libgroupby.group_var, name="std"),
165 "sem": functools.partial(libgroupby.group_var, name="sem"),
166 "skew": "group_skew",
167 "kurt": "group_kurt",
168 "first": "group_nth",
169 "last": "group_last",
170 "ohlc": "group_ohlc",
171 },
172 "transform": {
173 "cumprod": "group_cumprod",
174 "cumsum": "group_cumsum",
175 "cummin": "group_cummin",
176 "cummax": "group_cummax",
177 "rank": "group_rank",
178 },
179 }
180
181 _cython_arity = {"ohlc": 4} # OHLC
182
183 # Note: we make this a classmethod and pass kind+how so that caching
184 # works at the class level and not the instance level
185 @classmethod
186 @functools.cache
187 def _get_cython_function(
188 cls, kind: str, how: str, dtype: np.dtype, is_numeric: bool
189 ):
190 dtype_str = dtype.name
191 ftype = cls._CYTHON_FUNCTIONS[kind][how]
192
193 # see if there is a fused-type version of function
194 # only valid for numeric
195 if callable(ftype):
196 f = ftype
197 else:
198 f = getattr(libgroupby, ftype)
199 if is_numeric:
200 return f
201 elif dtype == np.dtype(object):
202 if how in ["median", "cumprod"]:
203 # no fused types -> no __signatures__
204 raise NotImplementedError(
205 f"function is not implemented for this dtype: "
206 f"[how->{how},dtype->{dtype_str}]"
207 )
208 elif how in ["std", "sem", "idxmin", "idxmax"]:
209 # We have a partial object that does not have __signatures__
210 return f
211 elif how in ["skew", "kurt"]:
212 # _get_cython_vals will convert to float64
213 pass
214 elif "object" not in f.__signatures__:
215 # raise NotImplementedError here rather than TypeError later
216 raise NotImplementedError(
217 f"function is not implemented for this dtype: "
218 f"[how->{how},dtype->{dtype_str}]"
219 )
220 return f
221 else:
222 raise NotImplementedError(
223 "This should not be reached. Please report a bug at "
224 "github.com/pandas-dev/pandas/",
225 dtype,
226 )
227
228 def _get_cython_vals(
229 self, values: np.ndarray, uses_mask: bool = False
230 ) -> np.ndarray:
231 """
232 Cast numeric dtypes to float64 for functions that only support that.
233
234 Parameters
235 ----------
236 values : np.ndarray
237
238 Returns
239 -------
240 values : np.ndarray
241 """
242 how = self.how
243
244 if how in ["median", "std", "sem", "skew", "kurt"]:
245 # median only has a float64 implementation
246 # We should only get here with is_numeric, as non-numeric cases
247 # should raise in _get_cython_function
248 values = ensure_float64(values)
249
250 elif values.dtype.kind in "iu":
251 if how in ["var", "mean"] or (
252 self.kind == "transform" and self.has_dropped_na and not uses_mask
253 ):
254 # has_dropped_na check need for test_null_group_str_transformer
255 # result may still include NaN, so we have to cast
256 values = ensure_float64(values)
257
258 elif how in ["sum", "ohlc", "prod", "cumsum", "cumprod"]:
259 # Avoid overflow during group op
260 if values.dtype.kind == "i":
261 values = ensure_int64(values)
262 else:
263 values = ensure_uint64(values)
264
265 return values
266
267 def _get_output_shape(self, ngroups: int, values: np.ndarray) -> Shape:
268 how = self.how
269 kind = self.kind
270
271 arity = self._cython_arity.get(how, 1)
272
273 out_shape: Shape
274 if how == "ohlc":
275 out_shape = (ngroups, arity)
276 elif arity > 1:
277 raise NotImplementedError(
278 "arity of more than 1 is not supported for the 'how' argument"
279 )
280 elif kind == "transform":
281 out_shape = values.shape
282 else:
283 out_shape = (ngroups, *values.shape[1:])
284 return out_shape
285
286 def _get_out_dtype(self, dtype: np.dtype) -> np.dtype:
287 how = self.how
288
289 if how == "rank":
290 out_dtype = "float64"
291 elif how in ["idxmin", "idxmax"]:
292 # The Cython implementation only produces the row number; we'll take
293 # from the index using this in post processing
294 out_dtype = "intp"
295 elif dtype.kind in "iufcb":
296 out_dtype = f"{dtype.kind}{dtype.itemsize}"
297 else:
298 out_dtype = "object"
299 return np.dtype(out_dtype)
300
301 def _get_result_dtype(self, dtype: np.dtype) -> np.dtype:
302 """
303 Get the desired dtype of a result based on the
304 input dtype and how it was computed.
305
306 Parameters
307 ----------
308 dtype : np.dtype
309
310 Returns
311 -------
312 np.dtype
313 The desired dtype of the result.
314 """
315 how = self.how
316
317 if how in ["sum", "cumsum", "sum", "prod", "cumprod"]:
318 if dtype == np.dtype(bool):
319 return np.dtype(np.int64)
320 elif how in ["mean", "median", "var", "std", "sem"]:
321 if dtype.kind in "fc":
322 return dtype
323 elif dtype.kind in "iub":
324 return np.dtype(np.float64)
325 return dtype
326
327 @final
328 def _cython_op_ndim_compat(
329 self,
330 values: np.ndarray,
331 *,
332 min_count: int,
333 ngroups: int,
334 comp_ids: np.ndarray,
335 mask: npt.NDArray[np.bool_] | None = None,
336 result_mask: npt.NDArray[np.bool_] | None = None,
337 initial: Any = 0,
338 **kwargs,
339 ) -> np.ndarray:
340 if values.ndim == 1:
341 # expand to 2d, dispatch, then squeeze if appropriate
342 values2d = values[None, :]
343 if mask is not None:
344 mask = mask[None, :]
345 if result_mask is not None:
346 result_mask = result_mask[None, :]
347 res = self._call_cython_op(
348 values2d,
349 min_count=min_count,
350 ngroups=ngroups,
351 comp_ids=comp_ids,
352 mask=mask,
353 result_mask=result_mask,
354 initial=initial,
355 **kwargs,
356 )
357 if res.shape[0] == 1:
358 return res[0]
359
360 # otherwise we have OHLC
361 return res.T
362
363 return self._call_cython_op(
364 values,
365 min_count=min_count,
366 ngroups=ngroups,
367 comp_ids=comp_ids,
368 mask=mask,
369 result_mask=result_mask,
370 initial=initial,
371 **kwargs,
372 )
373
374 @final
375 def _call_cython_op(
376 self,
377 values: np.ndarray, # np.ndarray[ndim=2]
378 *,
379 min_count: int,
380 ngroups: int,
381 comp_ids: np.ndarray,
382 mask: npt.NDArray[np.bool_] | None,
383 result_mask: npt.NDArray[np.bool_] | None,
384 initial: Any = 0,
385 **kwargs,
386 ) -> np.ndarray: # np.ndarray[ndim=2]
387 orig_values = values
388
389 dtype = values.dtype
390 is_numeric = dtype.kind in "iufcb"
391
392 is_datetimelike = dtype.kind in "mM"
393
394 if self.how in ["any", "all"]:
395 if mask is None:
396 mask = isna(values)
397
398 if is_datetimelike:
399 values = values.view("int64")
400 is_numeric = True
401 elif dtype.kind == "b":
402 values = values.view("uint8")
403 if values.dtype == "float16":
404 values = values.astype(np.float32)
405
406 if self.how in ["any", "all"]:
407 if dtype == object:
408 if kwargs["skipna"]:
409 # GH#37501: don't raise on pd.NA when skipna=True
410 if mask is not None and mask.any():
411 # mask on original values computed separately
412 values = values.copy()
413 values[mask] = True
414 values = values.astype(bool, copy=False).view(np.int8)
415 is_numeric = True
416
417 values = values.T
418 if mask is not None:
419 mask = mask.T
420 if result_mask is not None:
421 result_mask = result_mask.T
422
423 out_shape = self._get_output_shape(ngroups, values)
424 func = self._get_cython_function(self.kind, self.how, values.dtype, is_numeric)
425 values = self._get_cython_vals(values, uses_mask=mask is not None)
426 out_dtype = self._get_out_dtype(values.dtype)
427
428 result = maybe_fill(np.empty(out_shape, dtype=out_dtype))
429 if self.kind == "aggregate":
430 counts = np.zeros(ngroups, dtype=np.int64)
431 if self.how in [
432 "idxmin",
433 "idxmax",
434 "min",
435 "max",
436 "mean",
437 "last",
438 "first",
439 "sum",
440 "median",
441 ]:
442 if self.how == "sum":
443 # pass in through kwargs only for sum (other functions don't have
444 # the keyword)
445 kwargs["initial"] = initial
446 func(
447 out=result,
448 counts=counts,
449 values=values,
450 labels=comp_ids,
451 min_count=min_count,
452 mask=mask,
453 result_mask=result_mask,
454 is_datetimelike=is_datetimelike,
455 **kwargs,
456 )
457 elif self.how in ["sem", "std", "var", "ohlc", "prod"]:
458 if self.how in ["std", "sem"]:
459 kwargs["is_datetimelike"] = is_datetimelike
460 func(
461 result,
462 counts,
463 values,
464 comp_ids,
465 min_count=min_count,
466 mask=mask,
467 result_mask=result_mask,
468 **kwargs,
469 )
470 elif self.how in ["any", "all"]:
471 func(
472 out=result,
473 values=values,
474 labels=comp_ids,
475 mask=mask,
476 result_mask=result_mask,
477 **kwargs,
478 )
479 result = result.astype(bool, copy=False)
480 elif self.how in ["skew", "kurt"]:
481 func(
482 out=result,
483 counts=counts,
484 values=values,
485 labels=comp_ids,
486 mask=mask,
487 result_mask=result_mask,
488 **kwargs,
489 )
490 if dtype == object:
491 result = result.astype(object)
492
493 else:
494 raise NotImplementedError(f"{self.how} is not implemented")
495 else:
496 # TODO: min_count
497 if self.how != "rank":
498 # TODO: should rank take result_mask?
499 kwargs["result_mask"] = result_mask
500 func(
501 out=result,
502 values=values,
503 labels=comp_ids,
504 ngroups=ngroups,
505 is_datetimelike=is_datetimelike,
506 mask=mask,
507 **kwargs,
508 )
509
510 if self.kind == "aggregate" and self.how not in ["idxmin", "idxmax"]:
511 # i.e. counts is defined. Locations where count<min_count
512 # need to have the result set to np.nan, which may require casting,
513 # see GH#40767. For idxmin/idxmax is handled specially via post-processing
514 if result.dtype.kind in "iu" and not is_datetimelike:
515 # if the op keeps the int dtypes, we have to use 0
516 cutoff = max(0 if self.how in ["sum", "prod"] else 1, min_count)
517 empty_groups = counts < cutoff
518 if empty_groups.any():
519 if result_mask is not None:
520 assert result_mask[empty_groups].all()
521 else:
522 # Note: this conversion could be lossy, see GH#40767
523 result = result.astype("float64")
524 result[empty_groups] = np.nan
525
526 result = result.T
527
528 if self.how not in self.cast_blocklist:
529 # e.g. if we are int64 and need to restore to datetime64/timedelta64
530 # "rank" is the only member of cast_blocklist we get here
531 # Casting only needed for float16, bool, datetimelike,
532 # and self.how in ["sum", "prod", "ohlc", "cumprod"]
533 res_dtype = self._get_result_dtype(orig_values.dtype)
534 op_result = maybe_downcast_to_dtype(result, res_dtype)
535 else:
536 op_result = result
537
538 return op_result
539
540 @final
541 def _validate_axis(self, axis: AxisInt, values: ArrayLike) -> None:
542 if values.ndim > 2:
543 raise NotImplementedError("number of dimensions is currently limited to 2")
544 if values.ndim == 2:
545 assert axis == 1, axis
546 elif not is_1d_only_ea_dtype(values.dtype):
547 # Note: it is *not* the case that axis is always 0 for 1-dim values,
548 # as we can have 1D ExtensionArrays that we need to treat as 2D
549 assert axis == 0
550
551 @final
552 def cython_operation(
553 self,
554 *,
555 values: ArrayLike,
556 axis: AxisInt,
557 min_count: int = -1,
558 comp_ids: np.ndarray,
559 ngroups: int,
560 **kwargs,
561 ) -> ArrayLike:
562 """
563 Call our cython function, with appropriate pre- and post- processing.
564 """
565 self._validate_axis(axis, values)
566
567 if not isinstance(values, np.ndarray):
568 # i.e. ExtensionArray
569 return values._groupby_op(
570 how=self.how,
571 has_dropped_na=self.has_dropped_na,
572 min_count=min_count,
573 ngroups=ngroups,
574 ids=comp_ids,
575 **kwargs,
576 )
577
578 return self._cython_op_ndim_compat(
579 values,
580 min_count=min_count,
581 ngroups=ngroups,
582 comp_ids=comp_ids,
583 mask=None,
584 **kwargs,
585 )
586
587
588class BaseGrouper:
589 """
590 This is an internal Grouper class, which actually holds
591 the generated groups
592
593 Parameters
594 ----------
595 axis : Index
596 groupings : Sequence[Grouping]
597 all the grouping instances to handle in this grouper
598 for example for grouper list to groupby, need to pass the list
599 sort : bool, default True
600 whether this grouper will give sorted result or not
601
602 """
603
604 axis: Index
605
606 def __init__(
607 self,
608 axis: Index,
609 groupings: list[grouper.Grouping],
610 sort: bool = True,
611 dropna: bool = True,
612 ) -> None:
613 assert isinstance(axis, Index), axis
614
615 self.axis = axis
616 self._groupings = groupings
617 self._sort = sort
618 self.dropna = dropna
619 self._is_resample = False
620
621 @property
622 def groupings(self) -> list[grouper.Grouping]:
623 return self._groupings
624
625 def __iter__(self) -> Iterator[Hashable]:
626 return iter(self.indices)
627
628 @property
629 def nkeys(self) -> int:
630 return len(self.groupings)
631
632 def get_iterator(self, data: NDFrameT) -> Iterator[tuple[Hashable, NDFrameT]]:
633 """
634 Groupby iterator
635
636 Returns
637 -------
638 Generator yielding sequence of (name, subsetted object)
639 for each group
640 """
641 splitter = self._get_splitter(data)
642 # TODO: Would be more efficient to skip unobserved for transforms
643 keys = self.result_index
644 yield from zip(keys, splitter, strict=True)
645
646 @final
647 def _get_splitter(self, data: NDFrame) -> DataSplitter:
648 """
649 Returns
650 -------
651 Generator yielding subsetted objects
652 """
653 if isinstance(data, Series):
654 klass: type[DataSplitter] = SeriesSplitter
655 else:
656 # i.e. DataFrame
657 klass = FrameSplitter
658
659 return klass(
660 data,
661 self.ngroups,
662 sorted_ids=self._sorted_ids,
663 sort_idx=self.result_ilocs,
664 )
665
666 @cache_readonly
667 def indices(self) -> dict[Hashable, npt.NDArray[np.intp]]:
668 """dict {group name -> group indices}"""
669 if len(self.groupings) == 1 and isinstance(self.result_index, CategoricalIndex):
670 # This shows unused categories in indices GH#38642
671 result = self.groupings[0].indices
672 else:
673 result_index, ids = self.result_index_and_ids
674 values = result_index._values
675 categories = Categorical.from_codes(
676 ids, categories=range(len(result_index))
677 )
678 result = {
679 # mypy is not aware that group has to be an integer
680 values[group]: axis_ilocs # type: ignore[call-overload]
681 for group, axis_ilocs in categories._reverse_indexer().items()
682 if len(axis_ilocs)
683 }
684 if not self.dropna:
685 has_mi = isinstance(self.result_index, MultiIndex)
686 if not has_mi and self.result_index.hasnans:
687 result = {
688 np.nan if isna(key) else key: value for key, value in result.items()
689 }
690 elif has_mi:
691 # MultiIndex has no efficient way to tell if there are NAs
692 result = {
693 # error: "Hashable" has no attribute "__iter__" (not iterable)
694 tuple(np.nan if isna(comp) else comp for comp in key): value # type: ignore[attr-defined]
695 for key, value in result.items()
696 }
697
698 return result
699
700 @final
701 @cache_readonly
702 def result_ilocs(self) -> npt.NDArray[np.intp]:
703 """
704 Get the original integer locations of result_index in the input.
705 """
706 # Original indices are where group_index would go via sorting.
707 # But when dropna is true, we need to remove null values while accounting for
708 # any gaps that then occur because of them.
709 ids = self.ids
710
711 if self.has_dropped_na:
712 mask = np.where(ids >= 0)
713 # Count how many gaps are caused by previous null values for each position
714 null_gaps = np.cumsum(ids == -1)[mask]
715 ids = ids[mask]
716
717 result = get_group_index_sorter(ids, self.ngroups)
718
719 if self.has_dropped_na:
720 # Shift by the number of prior null gaps
721 result += np.take(null_gaps, result)
722
723 return result
724
725 @property
726 def codes(self) -> list[npt.NDArray[np.signedinteger]]:
727 return [ping.codes for ping in self.groupings]
728
729 @property
730 def levels(self) -> list[Index]:
731 if len(self.groupings) > 1:
732 # mypy doesn't know result_index must be a MultiIndex
733 return list(self.result_index.levels) # type: ignore[attr-defined]
734 else:
735 return [self.result_index]
736
737 @property
738 def names(self) -> list[Hashable]:
739 return [ping.name for ping in self.groupings]
740
741 @final
742 def size(self) -> Series:
743 """
744 Compute group sizes.
745 """
746 ids = self.ids
747 ngroups = self.ngroups
748 out: np.ndarray | list
749 if ngroups:
750 if self.has_dropped_na:
751 out = np.bincount(ids + 1, minlength=ngroups + 1)[1:]
752 else:
753 out = np.bincount(ids, minlength=ngroups)
754 else:
755 out = []
756 return Series(out, index=self.result_index, dtype="int64", copy=False)
757
758 @cache_readonly
759 def groups(self) -> dict[Hashable, Index]:
760 """dict {group name -> group labels}"""
761 if len(self.groupings) == 1:
762 return self.groupings[0].groups
763 result_index, ids = self.result_index_and_ids
764 values = result_index._values
765 categories = Categorical.from_codes(ids, categories=range(len(result_index)))
766 result = {
767 # mypy is not aware that group has to be an integer
768 values[group]: self.axis.take(axis_ilocs) # type: ignore[call-overload]
769 for group, axis_ilocs in categories._reverse_indexer().items()
770 }
771 return result
772
773 @final
774 @cache_readonly
775 def is_monotonic(self) -> bool:
776 # return if my group orderings are monotonic
777 return Index(self.ids, copy=False).is_monotonic_increasing
778
779 @final
780 @cache_readonly
781 def has_dropped_na(self) -> bool:
782 """
783 Whether grouper has null value(s) that are dropped.
784 """
785 return bool((self.ids < 0).any())
786
787 @final
788 @cache_readonly
789 def _reduceat_segment_info(
790 self,
791 ) -> tuple[int, npt.NDArray[np.intp], npt.NDArray[np.intp], np.ndarray] | None:
792 """
793 Precompute segment boundaries for _reduceat_aggregate.
794
795 Returns (na_count, group_starts, segment_ids, segment_sizes),
796 or None when comp_ids is empty after dropping NA rows.
797 """
798 comp_ids = self.ids
799
800 if self.has_dropped_na:
801 na_count = int((comp_ids < 0).sum())
802 comp_ids = comp_ids[na_count:]
803 else:
804 na_count = 0
805
806 if comp_ids.size == 0:
807 return None
808
809 group_starts = np.flatnonzero(np.r_[True, comp_ids[1:] != comp_ids[:-1]])
810 segment_ids = comp_ids[group_starts]
811 segment_sizes = np.diff(group_starts, append=comp_ids.size)
812
813 return na_count, group_starts, segment_ids, segment_sizes
814
815 @cache_readonly
816 def codes_info(self) -> npt.NDArray[np.intp]:
817 # return the codes of items in original grouped axis
818 return self.ids
819
820 @final
821 @cache_readonly
822 def ngroups(self) -> int:
823 return len(self.result_index)
824
825 @property
826 def result_index(self) -> Index:
827 return self.result_index_and_ids[0]
828
829 @property
830 def ids(self) -> npt.NDArray[np.intp]:
831 return self.result_index_and_ids[1]
832
833 @cache_readonly
834 def result_index_and_ids(self) -> tuple[Index, npt.NDArray[np.intp]]:
835 levels = [
836 Index._with_infer(ping.uniques, copy=False) for ping in self.groupings
837 ]
838 obs = [
839 ping._observed or not ping._passed_categorical for ping in self.groupings
840 ]
841 sorts = [ping._sort for ping in self.groupings]
842 # When passed a categorical grouping, keep all categories
843 for k, (ping, level) in enumerate(zip(self.groupings, levels, strict=True)):
844 if ping._passed_categorical:
845 levels[k] = level.set_categories(ping._orig_cats) # type: ignore[attr-defined]
846
847 if len(self.groupings) == 1:
848 result_index = levels[0]
849 result_index.name = self.names[0]
850 ids = ensure_platform_int(self.codes[0])
851 elif all(obs):
852 result_index, ids = self._ob_index_and_ids(
853 levels, self.codes, self.names, sorts
854 )
855 elif not any(obs):
856 result_index, ids = self._unob_index_and_ids(levels, self.codes, self.names)
857 else:
858 # Combine unobserved and observed parts
859 names = self.names
860 codes = [ping.codes for ping in self.groupings]
861 ob_indices = [idx for idx, ob in enumerate(obs) if ob]
862 unob_indices = [idx for idx, ob in enumerate(obs) if not ob]
863 ob_index, ob_ids = self._ob_index_and_ids(
864 levels=[levels[idx] for idx in ob_indices],
865 codes=[codes[idx] for idx in ob_indices],
866 names=[names[idx] for idx in ob_indices],
867 sorts=[sorts[idx] for idx in ob_indices],
868 )
869 unob_index, unob_ids = self._unob_index_and_ids(
870 levels=[levels[idx] for idx in unob_indices],
871 codes=[codes[idx] for idx in unob_indices],
872 names=[names[idx] for idx in unob_indices],
873 )
874
875 result_index_codes = np.concatenate(
876 [
877 np.tile(unob_index.codes, len(ob_index)),
878 np.repeat(ob_index.codes, len(unob_index), axis=1),
879 ],
880 axis=0,
881 )
882 _, index = np.unique(unob_indices + ob_indices, return_index=True)
883 result_index = MultiIndex(
884 levels=list(unob_index.levels) + list(ob_index.levels),
885 codes=result_index_codes,
886 names=list(unob_index.names) + list(ob_index.names),
887 ).reorder_levels(index)
888
889 # A dropped NA key is -1 in ob_ids/unob_ids and needs to come through
890 # as -1 in `ids`; the sum can be non-negative, so test the operands.
891 ids = len(unob_index) * ob_ids + unob_ids
892 if self.dropna:
893 ids = np.where((ob_ids < 0) | (unob_ids < 0), -1, ids)
894
895 if any(sorts):
896 # Sort result_index and recode ids using the new order
897 n_levels = len(sorts)
898 drop_levels = [
899 n_levels - idx
900 for idx, sort in enumerate(reversed(sorts), 1)
901 if not sort
902 ]
903 if len(drop_levels) > 0:
904 sorter = result_index._drop_level_numbers(drop_levels).argsort()
905 else:
906 sorter = result_index.argsort()
907 result_index = result_index.take(sorter)
908 _, index = np.unique(sorter, return_index=True)
909 # ids is -1 for the dropped NA keys; the sentinel at the end
910 # of recode maps those back to -1 instead of to a position.
911 recode = np.empty(len(index) + 1, dtype=np.intp)
912 recode[:-1] = index
913 recode[-1] = -1
914 ids = recode.take(ids)
915 else:
916 # Recode ids and reorder result_index with observed groups up front,
917 # unobserved at the end
918 ids, uniques = compress_group_index(ids, sort=False)
919 ids = ensure_platform_int(ids)
920 taker = np.concatenate(
921 [uniques, np.delete(np.arange(len(result_index)), uniques)]
922 )
923 result_index = result_index.take(taker)
924
925 return result_index, ids
926
927 @property
928 def observed_grouper(self) -> BaseGrouper:
929 if all(ping._observed for ping in self.groupings):
930 return self
931
932 return self._observed_grouper
933
934 @cache_readonly
935 def _observed_grouper(self) -> BaseGrouper:
936 groupings = [ping.observed_grouping for ping in self.groupings]
937 grouper = BaseGrouper(self.axis, groupings, sort=self._sort, dropna=self.dropna)
938 return grouper
939
940 def _ob_index_and_ids(
941 self,
942 levels: list[Index],
943 codes: list[npt.NDArray[np.intp]],
944 names: list[Hashable],
945 sorts: list[bool],
946 ) -> tuple[MultiIndex, npt.NDArray[np.intp]]:
947 consistent_sorting = all(sorts[0] == sort for sort in sorts[1:])
948 sort_in_compress = sorts[0] if consistent_sorting else False
949 shape = tuple(len(level) for level in levels)
950 group_index = get_group_index(codes, shape, sort=True, xnull=True)
951 ob_ids, obs_group_ids = compress_group_index(group_index, sort=sort_in_compress)
952 ob_ids = ensure_platform_int(ob_ids)
953 ob_index_codes = decons_obs_group_ids(
954 ob_ids, obs_group_ids, shape, codes, xnull=True
955 )
956 ob_index = MultiIndex(
957 levels=levels,
958 codes=ob_index_codes,
959 names=names,
960 verify_integrity=False,
961 )
962 if not consistent_sorting and len(ob_index) > 0:
963 # Sort by the levels where the corresponding sort argument is True
964 n_levels = len(sorts)
965 drop_levels = [
966 n_levels - idx
967 for idx, sort in enumerate(reversed(sorts), 1)
968 if not sort
969 ]
970 if len(drop_levels) > 0:
971 sorter = ob_index._drop_level_numbers(drop_levels).argsort()
972 else:
973 sorter = ob_index.argsort()
974 ob_index = ob_index.take(sorter)
975 _, index = np.unique(sorter, return_index=True)
976 ob_ids = np.where(ob_ids == -1, -1, index.take(ob_ids))
977 ob_ids = ensure_platform_int(ob_ids)
978 return ob_index, ob_ids
979
980 def _unob_index_and_ids(
981 self,
982 levels: list[Index],
983 codes: list[npt.NDArray[np.intp]],
984 names: list[Hashable],
985 ) -> tuple[MultiIndex, npt.NDArray[np.intp]]:
986 shape = tuple(len(level) for level in levels)
987 unob_ids = get_group_index(codes, shape, sort=True, xnull=True)
988 unob_index = MultiIndex.from_product(levels, names=names)
989 unob_ids = ensure_platform_int(unob_ids)
990 return unob_index, unob_ids
991
992 @final
993 def get_group_levels(self) -> Generator[Index]:
994 # Note: only called from _insert_inaxis_grouper, which
995 # is only called for BaseGrouper, never for BinGrouper
996 result_index = self.result_index
997 if len(self.groupings) == 1:
998 yield result_index
999 else:
1000 for level in range(result_index.nlevels - 1, -1, -1):
1001 yield result_index.get_level_values(level)
1002
1003 # ------------------------------------------------------------
1004 # Aggregation functions
1005
1006 @final
1007 def _cython_operation(
1008 self,
1009 values,
1010 how: str,
1011 axis: AxisInt,
1012 min_count: int = -1,
1013 **kwargs,
1014 ) -> ArrayLike:
1015 """
1016 Returns the values of a cython operation.
1017 """
1018 if (
1019 how in _REDUCEAT_UFUNCS
1020 and isinstance(values, np.ndarray)
1021 and values.dtype.kind in "iufb"
1022 and self.is_monotonic
1023 ):
1024 result = self._reduceat_aggregate(values, how, axis, min_count)
1025 if result is not None:
1026 return result
1027
1028 cy_op = WrappedCythonOp(how=how, has_dropped_na=self.has_dropped_na)
1029
1030 return cy_op.cython_operation(
1031 values=values,
1032 axis=axis,
1033 min_count=min_count,
1034 comp_ids=self.ids,
1035 ngroups=self.ngroups,
1036 **kwargs,
1037 )
1038
1039 @final
1040 def _reduceat_aggregate(
1041 self,
1042 values: np.ndarray,
1043 how: str,
1044 axis: AxisInt,
1045 min_count: int,
1046 ) -> np.ndarray | None:
1047 """
1048 Use ufunc.reduceat for sorted group ids. Returns None to fall back.
1049 """
1050 info = self._reduceat_segment_info
1051 if info is None:
1052 return None
1053
1054 na_count, group_starts, segment_ids, segment_sizes = info
1055 ngroups = self.ngroups
1056 orig_dtype = values.dtype
1057
1058 # reduceat propagates NaN, but the Cython kernels skip it
1059 if orig_dtype.kind == "f" and np.isnan(values).any():
1060 return None
1061
1062 # dropped-NA rows (comp_ids < 0) are at the front when monotonic
1063 if na_count > 0:
1064 if axis == 0:
1065 values = values[na_count:]
1066 else:
1067 values = values[:, na_count:]
1068
1069 # avoid overflow, matching _get_cython_vals
1070 work_dtype: np.dtype
1071 if how == "mean":
1072 work_dtype = np.dtype(np.float64)
1073 elif how in ("sum", "prod") and orig_dtype.kind in "ib":
1074 work_dtype = np.dtype(np.int64)
1075 elif how in ("sum", "prod") and orig_dtype.kind == "u":
1076 work_dtype = np.dtype(np.uint64)
1077 else:
1078 work_dtype = orig_dtype
1079
1080 work_values = values.astype(work_dtype, copy=False)
1081
1082 ufunc = _REDUCEAT_UFUNCS[how]
1083 seg = ufunc.reduceat(work_values, group_starts, axis=axis)
1084
1085 if how == "mean":
1086 sizes = segment_sizes.astype(np.float64)
1087 if seg.ndim == 1:
1088 seg = seg / sizes
1089 else:
1090 shape = [1] * seg.ndim
1091 shape[axis] = len(sizes)
1092 seg = seg / sizes.reshape(shape)
1093
1094 # inline _get_result_dtype for the ops we handle
1095 res_dtype: np.dtype
1096 if how == "mean":
1097 res_dtype = np.dtype(np.float64)
1098 elif how in ("sum", "prod") and orig_dtype.kind == "b":
1099 res_dtype = np.dtype(np.int64)
1100 else:
1101 res_dtype = orig_dtype
1102
1103 # ngroups may exceed present segments
1104 out_shape = list(values.shape)
1105 out_shape[axis] = ngroups
1106
1107 if how == "sum":
1108 result = np.zeros(out_shape, dtype=work_dtype)
1109 elif how == "prod":
1110 result = np.ones(out_shape, dtype=work_dtype)
1111 elif work_dtype.kind == "f":
1112 # mean, or float min/max — NaN for empty groups
1113 result = np.full(out_shape, np.nan, dtype=work_dtype)
1114 # int/bool min/max: fill with dtype extreme; empty groups
1115 # will be converted to NaN below
1116 elif work_dtype.kind == "b":
1117 result = np.full(out_shape, how == "min", dtype=work_dtype)
1118 else:
1119 iinfo = np.iinfo(work_dtype)
1120 fill = iinfo.max if how == "min" else iinfo.min
1121 result = np.full(out_shape, fill, dtype=work_dtype)
1122
1123 indexer: list[Any] = [slice(None)] * len(out_shape)
1124 indexer[axis] = segment_ids
1125 result[tuple(indexer)] = seg
1126
1127 # see _call_cython_op for the min_count logic
1128 if how in ("sum", "prod"):
1129 cutoff = max(0, min_count)
1130 else:
1131 cutoff = max(1, min_count)
1132
1133 if cutoff > 0:
1134 group_counts = np.zeros(ngroups, dtype=np.int64)
1135 group_counts[segment_ids] = segment_sizes
1136 empty_mask = group_counts < cutoff
1137 if empty_mask.any():
1138 if result.dtype.kind in "iub":
1139 result = result.astype(np.float64)
1140 indexer2: list[Any] = [slice(None)] * result.ndim
1141 indexer2[axis] = empty_mask
1142 result[tuple(indexer2)] = np.nan
1143
1144 result = maybe_downcast_to_dtype(result, res_dtype)
1145
1146 return result
1147
1148 @final
1149 def agg_series(self, obj: Series, func: Callable) -> ArrayLike:
1150 """
1151 Parameters
1152 ----------
1153 obj : Series
1154 func : function taking a Series and returning a scalar-like
1155
1156 Returns
1157 -------
1158 np.ndarray or ExtensionArray
1159 """
1160 result = self._aggregate_series_pure_python(obj, func)
1161 return obj.array._cast_pointwise_result(result)
1162
1163 @final
1164 def _aggregate_series_pure_python(
1165 self, obj: Series, func: Callable
1166 ) -> npt.NDArray[np.object_]:
1167 result = np.empty(self.ngroups, dtype="O")
1168 initialized = False
1169
1170 splitter = self._get_splitter(obj)
1171
1172 for i, group in enumerate(splitter):
1173 res = func(group)
1174 res = extract_result(res)
1175
1176 if self._is_resample and not initialized:
1177 # We only do this validation on the first iteration
1178 check_result_array(res, group.dtype)
1179 initialized = True
1180
1181 result[i] = res
1182
1183 return result
1184
1185 @final
1186 def apply_groupwise(
1187 self, f: Callable, data: DataFrame | Series
1188 ) -> tuple[list, bool]:
1189 mutated = False
1190 splitter = self._get_splitter(data)
1191 group_keys = self.result_index
1192 result_values = []
1193
1194 # This calls DataSplitter.__iter__
1195 zipped = zip(group_keys, splitter, strict=True)
1196
1197 for key, group in zipped:
1198 # Pinning name is needed for
1199 # test_group_apply_once_per_group,
1200 # test_inconsistent_return_type, test_set_group_name,
1201 # test_group_name_available_in_inference_pass,
1202 # test_groupby_multi_timezone
1203 object.__setattr__(group, "name", key)
1204
1205 # group might be modified
1206 group_axes = group.axes
1207 res = f(group)
1208 if not mutated and not _is_indexed_like(res, group_axes):
1209 mutated = True
1210 result_values.append(res)
1211 # getattr pattern for __name__ is needed for functools.partial objects
1212 if len(group_keys) == 0 and getattr(f, "__name__", None) in [
1213 "skew",
1214 "kurt",
1215 "sum",
1216 "prod",
1217 ]:
1218 # If group_keys is empty, then no function calls have been made,
1219 # so we will not have raised even if this is an invalid dtype.
1220 # So do one dummy call here to raise appropriate TypeError.
1221 f(data.iloc[:0])
1222
1223 return result_values, mutated
1224
1225 # ------------------------------------------------------------
1226 # Methods for sorting subsets of our GroupBy's object
1227
1228 @final
1229 @cache_readonly
1230 def _sorted_ids(self) -> npt.NDArray[np.intp]:
1231 result = self.ids.take(self.result_ilocs)
1232 if getattr(self, "dropna", True):
1233 # BinGrouper has no dropna
1234 result = result[result >= 0]
1235 return result
1236
1237
1238class BinGrouper(BaseGrouper):
1239 """
1240 This is an internal Grouper class
1241
1242 Parameters
1243 ----------
1244 bins : the split index of binlabels to group the item of axis
1245 binlabels : the label list
1246 indexer : np.ndarray[np.intp], optional
1247 the indexer created by Grouper
1248 some groupers (TimeGrouper) will sort its axis and its
1249 group_info is also sorted, so need the indexer to reorder
1250
1251 Examples
1252 --------
1253 bins: [2, 4, 6, 8, 10]
1254 binlabels: DatetimeIndex(['2005-01-01', '2005-01-03',
1255 '2005-01-05', '2005-01-07', '2005-01-09'],
1256 dtype='datetime64[ns]', freq='2D')
1257
1258 the group_info, which contains the label of each item in grouped
1259 axis, the index of label in label list, group number, is
1260
1261 (array([0, 0, 1, 1, 2, 2, 3, 3, 4, 4]), array([0, 1, 2, 3, 4]), 5)
1262
1263 means that, the grouped axis has 10 items, can be grouped into 5
1264 labels, the first and second items belong to the first label, the
1265 third and forth items belong to the second label, and so on
1266
1267 """
1268
1269 bins: npt.NDArray[np.int64]
1270 binlabels: Index
1271
1272 def __init__(
1273 self,
1274 bins,
1275 binlabels,
1276 indexer=None,
1277 ) -> None:
1278 self.bins = ensure_int64(bins)
1279 self.binlabels = ensure_index(binlabels)
1280 self.indexer = indexer
1281
1282 # These lengths must match, otherwise we could call agg_series
1283 # with empty self.bins, which would raise later.
1284 assert len(self.binlabels) == len(self.bins)
1285
1286 @cache_readonly
1287 def groups(self):
1288 """dict {group name -> group labels}"""
1289 # this is mainly for compat
1290 # GH 3881
1291 result = {
1292 key: value
1293 for key, value in zip(self.binlabels, self.bins, strict=True)
1294 if key is not NaT
1295 }
1296 return result
1297
1298 @property
1299 def nkeys(self) -> int:
1300 # still matches len(self.groupings), but we can hard-code
1301 return 1
1302
1303 @cache_readonly
1304 def codes_info(self) -> npt.NDArray[np.intp]:
1305 # return the codes of items in original grouped axis
1306 ids = self.ids
1307 if self.indexer is not None:
1308 sorter = np.lexsort((ids, self.indexer))
1309 ids = ids[sorter]
1310 return ids
1311
1312 def get_iterator(self, data: NDFrame):
1313 """
1314 Groupby iterator
1315
1316 Returns
1317 -------
1318 Generator yielding sequence of (name, subsetted object)
1319 for each group
1320 """
1321 slicer = lambda start, edge: data.iloc[start:edge]
1322
1323 start: np.int64 | int = 0
1324 for edge, label in zip(self.bins, self.binlabels, strict=True):
1325 if label is not NaT:
1326 yield label, slicer(start, edge)
1327 start = edge
1328
1329 if start < len(data):
1330 yield self.binlabels[-1], slicer(start, None)
1331
1332 @cache_readonly
1333 def indices(self):
1334 indices = collections.defaultdict(list)
1335
1336 i: np.int64 | int = 0
1337 for label, bin in zip(self.binlabels, self.bins, strict=True):
1338 if i < bin:
1339 if label is not NaT:
1340 indices[label] = list(range(i, bin))
1341 i = bin
1342 return indices
1343
1344 @cache_readonly
1345 def codes(self) -> list[npt.NDArray[np.intp]]:
1346 return [self.ids]
1347
1348 @cache_readonly
1349 def result_index_and_ids(self):
1350 result_index = self.binlabels
1351 if len(self.binlabels) != 0 and isna(self.binlabels[0]):
1352 result_index = result_index[1:]
1353
1354 ngroups = len(result_index)
1355 rep = np.diff(np.r_[0, self.bins])
1356
1357 rep = ensure_platform_int(rep)
1358 if ngroups == len(self.bins):
1359 ids = np.repeat(np.arange(ngroups), rep)
1360 else:
1361 ids = np.repeat(np.r_[-1, np.arange(ngroups)], rep)
1362 ids = ensure_platform_int(ids)
1363
1364 return result_index, ids
1365
1366 @property
1367 def levels(self) -> list[Index]:
1368 return [self.binlabels]
1369
1370 @property
1371 def names(self) -> list[Hashable]:
1372 return [self.binlabels.name]
1373
1374 @property
1375 def groupings(self) -> list[grouper.Grouping]:
1376 lev = self.binlabels
1377 codes = self.ids
1378 labels = lev.take(codes)
1379 ping = grouper.Grouping(
1380 labels, labels, in_axis=False, level=None, uniques=lev._values
1381 )
1382 return [ping]
1383
1384 @property
1385 def observed_grouper(self) -> BinGrouper:
1386 return self
1387
1388
1389def _is_indexed_like(obj, axes) -> bool:
1390 if isinstance(obj, Series):
1391 if len(axes) > 1:
1392 return False
1393 return obj.index.equals(axes[0])
1394 elif isinstance(obj, DataFrame):
1395 return obj.index.equals(axes[0])
1396
1397 return False
1398
1399
1400# ----------------------------------------------------------------------
1401# Splitting / application
1402
1403
1404class DataSplitter(Generic[NDFrameT]):
1405 def __init__(
1406 self,
1407 data: NDFrameT,
1408 ngroups: int,
1409 *,
1410 sort_idx: npt.NDArray[np.intp],
1411 sorted_ids: npt.NDArray[np.intp],
1412 ) -> None:
1413 self.data = data
1414 self.ngroups = ngroups
1415
1416 self._slabels = sorted_ids
1417 self._sort_idx = sort_idx
1418
1419 def __iter__(self) -> Iterator:
1420 if self.ngroups == 0:
1421 # we are inside a generator, rather than raise StopIteration
1422 # we merely return signal the end
1423 return
1424
1425 starts, ends = lib.generate_slices(self._slabels, self.ngroups)
1426 sdata = self._sorted_data
1427 # __finalize__ is a no-op for an exact Series/DataFrame with empty
1428 # attrs and default flags; skip it in that case. Safe to compute the
1429 # gate once because sdata is splitter-owned and its type/attrs/flags
1430 # can't be mutated through the yielded chunks.
1431 needs_finalize = (
1432 type(sdata) is not self._sorted_cls
1433 or bool(sdata.attrs)
1434 or not sdata._flags._allows_duplicate_labels
1435 )
1436 for start, end in zip(starts, ends, strict=True):
1437 yield self._chop(sdata, slice(start, end), needs_finalize)
1438
1439 @cache_readonly
1440 def _sorted_data(self) -> NDFrameT:
1441 return self.data.take(self._sort_idx, axis=0)
1442
1443 _sorted_cls: type[NDFrame]
1444
1445 def _chop(self, sdata, slice_obj: slice, needs_finalize: bool) -> NDFrame:
1446 raise AbstractMethodError(self)
1447
1448
1449class SeriesSplitter(DataSplitter):
1450 _sorted_cls = Series
1451
1452 def _chop(self, sdata: Series, slice_obj: slice, needs_finalize: bool) -> Series:
1453 # fastpath equivalent to `sdata.iloc[slice_obj]`
1454 mgr = sdata._mgr.get_slice(slice_obj)
1455 ser = sdata._constructor_from_mgr(mgr, axes=mgr.axes)
1456 # Use object.__setattr__ to bypass NDFrame.__setattr__ overhead
1457 object.__setattr__(ser, "_name", sdata.name)
1458 if needs_finalize:
1459 return ser.__finalize__(sdata, method="groupby")
1460 return ser
1461
1462
1463class FrameSplitter(DataSplitter):
1464 _sorted_cls = DataFrame
1465
1466 def _chop(
1467 self, sdata: DataFrame, slice_obj: slice, needs_finalize: bool
1468 ) -> DataFrame:
1469 # Fastpath equivalent to:
1470 # return sdata.iloc[slice_obj]
1471 mgr = sdata._mgr.get_slice(slice_obj, axis=1)
1472 df = sdata._constructor_from_mgr(mgr, axes=mgr.axes)
1473 if needs_finalize:
1474 return df.__finalize__(sdata, method="groupby")
1475 return df