1"""
2Provide user facing operators for doing the split part of the
3split-apply-combine paradigm.
4"""
5
6from __future__ import annotations
7
8from itertools import pairwise
9from typing import (
10 TYPE_CHECKING,
11 final,
12)
13import warnings
14
15import numpy as np
16
17from pandas._libs import (
18 algos as libalgos,
19)
20from pandas._libs.tslibs import OutOfBoundsDatetime
21from pandas.errors import (
22 InvalidIndexError,
23 Pandas4Warning,
24)
25from pandas.util._decorators import (
26 cache_readonly,
27 set_module,
28)
29from pandas.util._exceptions import find_stack_level
30
31from pandas.core.dtypes.common import (
32 ensure_int64,
33 ensure_platform_int,
34 is_list_like,
35 is_scalar,
36)
37from pandas.core.dtypes.dtypes import CategoricalDtype
38
39from pandas.core import algorithms
40from pandas.core.arrays import (
41 Categorical,
42 ExtensionArray,
43)
44import pandas.core.common as com
45from pandas.core.frame import DataFrame
46from pandas.core.groupby import ops
47from pandas.core.groupby.categorical import recode_for_groupby
48from pandas.core.indexes.api import (
49 Index,
50 MultiIndex,
51 default_index,
52)
53from pandas.core.series import Series
54
55from pandas.io.formats.printing import (
56 PrettyDict,
57 pprint_thing,
58)
59
60if TYPE_CHECKING:
61 from collections.abc import (
62 Hashable,
63 Iterator,
64 )
65
66 from pandas._typing import (
67 ArrayLike,
68 NDFrameT,
69 npt,
70 )
71
72 from pandas.core.generic import NDFrame
73
74
75@set_module("pandas")
76class Grouper:
77 """
78 A Grouper allows the user to specify a groupby instruction for an object.
79
80 This specification will select a column via the key parameter, or if the
81 level parameter is given, a level of the index of the target
82 object.
83
84 If ``level`` is passed as a keyword to both `Grouper` and
85 `groupby`, the values passed to `Grouper` take precedence.
86
87 Parameters
88 ----------
89 *args
90 Currently unused, reserved for future use.
91 **kwargs
92 Dictionary of the keyword arguments to pass to Grouper.
93
94 Attributes
95 ----------
96 key : str, defaults to None
97 Groupby key, which selects the grouping column of the target.
98 level : name/number, defaults to None
99 The level for the target index.
100 freq : str / frequency object, defaults to None
101 This will groupby the specified frequency if the target selection
102 (via key or level) is a datetime-like object. For full specification
103 of available frequencies, please see :ref:`here<timeseries.offset_aliases>`.
104 sort : bool, default False
105 Whether to sort the resulting labels.
106 closed : {'left' or 'right'}
107 Closed end of interval. Only when `freq` parameter is passed.
108 label : {'left' or 'right'}
109 Interval boundary to use for labeling.
110 Only when `freq` parameter is passed.
111 convention : {'start', 'end', 'e', 's'}
112 If grouper is PeriodIndex and `freq` parameter is passed.
113
114 origin : Timestamp or str, default 'start_day'
115 The timestamp on which to adjust the grouping. The timezone of origin must
116 match the timezone of the index.
117 If string, must be one of the following:
118
119 - 'epoch': `origin` is 1970-01-01
120 - 'start': `origin` is the first value of the timeseries
121 - 'start_day': `origin` is the first day at midnight of the timeseries
122
123 - 'end': `origin` is the last value of the timeseries
124 - 'end_day': `origin` is the ceiling midnight of the last day
125
126 offset : Timedelta or str, default None
127 An offset timedelta added to the origin.
128
129 dropna : bool, default True
130 If True, and if group keys contain NA values, NA values together with
131 row/column will be dropped. If False, NA values will also be treated as
132 the key in groups.
133
134 Returns
135 -------
136 Grouper or pandas.api.typing.TimeGrouper
137 A TimeGrouper is returned if ``freq`` is not ``None``. Otherwise, a Grouper
138 is returned.
139
140 See Also
141 --------
142 Series.groupby : Apply a function groupby to a Series.
143 DataFrame.groupby : Apply a function groupby.
144
145 Examples
146 --------
147 ``df.groupby(pd.Grouper(key="Animal"))`` is equivalent to ``df.groupby('Animal')``
148
149 >>> df = pd.DataFrame(
150 ... {
151 ... "Animal": ["Falcon", "Parrot", "Falcon", "Falcon", "Parrot"],
152 ... "Speed": [100, 5, 200, 300, 15],
153 ... }
154 ... )
155 >>> df
156 Animal Speed
157 0 Falcon 100
158 1 Parrot 5
159 2 Falcon 200
160 3 Falcon 300
161 4 Parrot 15
162 >>> df.groupby(pd.Grouper(key="Animal")).mean()
163 Speed
164 Animal
165 Falcon 200.0
166 Parrot 10.0
167
168 Specify a resample operation on the column 'Publish date'
169
170 >>> df = pd.DataFrame(
171 ... {
172 ... "Publish date": [
173 ... pd.Timestamp("2000-01-02"),
174 ... pd.Timestamp("2000-01-02"),
175 ... pd.Timestamp("2000-01-09"),
176 ... pd.Timestamp("2000-01-16"),
177 ... ],
178 ... "ID": [0, 1, 2, 3],
179 ... "Price": [10, 20, 30, 40],
180 ... }
181 ... )
182 >>> df
183 Publish date ID Price
184 0 2000-01-02 0 10
185 1 2000-01-02 1 20
186 2 2000-01-09 2 30
187 3 2000-01-16 3 40
188 >>> df.groupby(pd.Grouper(key="Publish date", freq="1W")).mean()
189 ID Price
190 Publish date
191 2000-01-02 0.5 15.0
192 2000-01-09 2.0 30.0
193 2000-01-16 3.0 40.0
194
195 If you want to adjust the start of the bins based on a fixed timestamp:
196
197 >>> start, end = "2000-10-01 23:30:00", "2000-10-02 00:30:00"
198 >>> rng = pd.date_range(start, end, freq="7min")
199 >>> ts = pd.Series(np.arange(len(rng)) * 3, index=rng)
200 >>> ts
201 2000-10-01 23:30:00 0
202 2000-10-01 23:37:00 3
203 2000-10-01 23:44:00 6
204 2000-10-01 23:51:00 9
205 2000-10-01 23:58:00 12
206 2000-10-02 00:05:00 15
207 2000-10-02 00:12:00 18
208 2000-10-02 00:19:00 21
209 2000-10-02 00:26:00 24
210 Freq: 7min, dtype: int64
211
212 >>> ts.groupby(pd.Grouper(freq="17min")).sum()
213 2000-10-01 23:14:00 0
214 2000-10-01 23:31:00 9
215 2000-10-01 23:48:00 21
216 2000-10-02 00:05:00 54
217 2000-10-02 00:22:00 24
218 Freq: 17min, dtype: int64
219
220 >>> ts.groupby(pd.Grouper(freq="17min", origin="epoch")).sum()
221 2000-10-01 23:18:00 0
222 2000-10-01 23:35:00 18
223 2000-10-01 23:52:00 27
224 2000-10-02 00:09:00 39
225 2000-10-02 00:26:00 24
226 Freq: 17min, dtype: int64
227
228 >>> ts.groupby(pd.Grouper(freq="17min", origin="2000-01-01")).sum()
229 2000-10-01 23:24:00 3
230 2000-10-01 23:41:00 15
231 2000-10-01 23:58:00 45
232 2000-10-02 00:15:00 45
233 Freq: 17min, dtype: int64
234
235 If you want to adjust the start of the bins with an `offset` Timedelta, the two
236 following lines are equivalent:
237
238 >>> ts.groupby(pd.Grouper(freq="17min", origin="start")).sum()
239 2000-10-01 23:30:00 9
240 2000-10-01 23:47:00 21
241 2000-10-02 00:04:00 54
242 2000-10-02 00:21:00 24
243 Freq: 17min, dtype: int64
244
245 >>> ts.groupby(pd.Grouper(freq="17min", offset="23h30min")).sum()
246 2000-10-01 23:30:00 9
247 2000-10-01 23:47:00 21
248 2000-10-02 00:04:00 54
249 2000-10-02 00:21:00 24
250 Freq: 17min, dtype: int64
251
252 To replace the use of the deprecated `base` argument, you can now use `offset`,
253 in this example it is equivalent to have `base=2`:
254
255 >>> ts.groupby(pd.Grouper(freq="17min", offset="2min")).sum()
256 2000-10-01 23:16:00 0
257 2000-10-01 23:33:00 9
258 2000-10-01 23:50:00 36
259 2000-10-02 00:07:00 39
260 2000-10-02 00:24:00 24
261 Freq: 17min, dtype: int64
262 """
263
264 sort: bool
265 dropna: bool
266 _grouper: Index | None
267
268 _attributes: tuple[str, ...] = ("key", "level", "freq", "sort", "dropna")
269
270 def __new__(cls, *args, **kwargs):
271 if kwargs.get("freq") is not None:
272 from pandas.core.resample import TimeGrouper
273
274 cls = TimeGrouper
275 return super().__new__(cls)
276
277 def __init__(
278 self,
279 key=None,
280 level=None,
281 freq=None,
282 sort: bool = False,
283 dropna: bool = True,
284 ) -> None:
285 self.key = key
286 self.level = level
287 self.freq = freq
288 self.sort = sort
289 self.dropna = dropna
290
291 self._indexer_deprecated: npt.NDArray[np.intp] | None = None
292 self.binner = None
293 self._grouper = None
294 self._indexer: npt.NDArray[np.intp] | None = None
295
296 def _get_grouper(
297 self, obj: NDFrameT, validate: bool = True, observed: bool = True
298 ) -> tuple[ops.BaseGrouper, NDFrameT]:
299 """
300 Parameters
301 ----------
302 obj : Series or DataFrame
303 Object being grouped.
304 validate : bool, default True
305 If True, validate the grouper.
306 observed : bool, default True
307 Whether only observed groups should be in the result. Only
308 has an impact when grouping on categorical data.
309
310 Returns
311 -------
312 A tuple of grouper, obj (possibly sorted)
313 """
314 obj, _, _ = self._set_grouper(obj)
315 grouper, _, obj = get_grouper(
316 obj,
317 [self.key],
318 level=self.level,
319 sort=self.sort,
320 validate=validate,
321 dropna=self.dropna,
322 observed=observed,
323 )
324
325 return grouper, obj
326
327 def _set_grouper(
328 self, obj: NDFrameT, sort: bool = False, *, gpr_index: Index | None = None
329 ) -> tuple[NDFrameT, Index, npt.NDArray[np.intp] | None]:
330 """
331 given an object and the specifications, setup the internal grouper
332 for this particular specification
333
334 Parameters
335 ----------
336 obj : Series or DataFrame
337 sort : bool, default False
338 whether the resulting grouper should be sorted
339 gpr_index : Index or None, default None
340
341 Returns
342 -------
343 NDFrame
344 Index
345 np.ndarray[np.intp] | None
346 """
347 assert obj is not None
348
349 if self.key is not None and self.level is not None:
350 raise ValueError("The Grouper cannot specify both a key and a level!")
351
352 # Keep self._grouper value before overriding
353 if self._grouper is None:
354 # TODO: What are we assuming about subsequent calls?
355 self._grouper = gpr_index
356 self._indexer = self._indexer_deprecated
357
358 # the key must be a valid info item
359 if self.key is not None:
360 key = self.key
361 # The 'on' is already defined
362 if getattr(gpr_index, "name", None) == key and isinstance(obj, Series):
363 # Sometimes self._grouper will have been resorted while
364 # obj has not. In this case there is a mismatch when we
365 # call self._grouper.take(obj.index) so we need to undo the sorting
366 # before we call _grouper.take.
367 assert self._grouper is not None
368 if self._indexer is not None:
369 reverse_indexer = self._indexer.argsort()
370 unsorted_ax = self._grouper.take(reverse_indexer)
371 ax = unsorted_ax.take(obj.index) # type: ignore[arg-type]
372 else:
373 ax = self._grouper.take(obj.index) # type: ignore[arg-type]
374 else:
375 if key not in obj._info_axis:
376 raise KeyError(f"The grouper name {key} is not found")
377 ax = Index(obj[key], name=key)
378
379 else:
380 ax = obj.index
381 if self.level is not None:
382 level = self.level
383
384 # if a level is given it must be a mi level or
385 # equivalent to the axis name
386 if isinstance(ax, MultiIndex):
387 level = ax._get_level_number(level)
388 ax = Index(ax._get_level_values(level), name=ax.names[level])
389
390 elif level not in (0, ax.name):
391 raise ValueError(f"The level {level} is not valid")
392
393 # possibly sort
394 indexer: npt.NDArray[np.intp] | None = None
395 if (self.sort or sort) and not ax.is_monotonic_increasing:
396 # use stable sort to support first, last, nth
397 # TODO: why does putting na_position="first" fix datetimelike cases?
398 indexer = self._indexer_deprecated = ax.array.argsort(
399 kind="mergesort", na_position="first"
400 )
401 ax = ax.take(indexer)
402 obj = obj.take(indexer, axis=0)
403
404 return obj, ax, indexer
405
406 @final
407 def __repr__(self) -> str:
408 attrs_list = (
409 f"{attr_name}={getattr(self, attr_name)!r}"
410 for attr_name in self._attributes
411 if getattr(self, attr_name) is not None
412 )
413 attrs = ", ".join(attrs_list)
414 cls_name = type(self).__name__
415 return f"{cls_name}({attrs})"
416
417
418@final
419class Grouping:
420 """
421 Holds the grouping information for a single key
422
423 Parameters
424 ----------
425 index : Index
426 grouper :
427 obj : DataFrame or Series
428 name : Label
429 level :
430 observed : bool, default True
431 If we are a Categorical, use the observed values
432 in_axis : if the Grouping is a column in self.obj and hence among
433 Groupby.exclusions list
434 dropna : bool, default True
435 Whether to drop NA groups.
436 uniques : Array-like, optional
437 When specified, will be used for unique values. Enables including empty groups
438 in the result for a BinGrouper. Must not contain duplicates.
439
440 Attributes
441 -------
442 indices : dict
443 Mapping of {group -> index_list}
444 codes : ndarray
445 Group codes
446 group_index : Index or None
447 unique groups
448 groups : dict
449 Mapping of {group -> label_list}
450 """
451
452 _codes: npt.NDArray[np.signedinteger] | None = None
453 _orig_cats: Index | None
454 _index: Index
455
456 def __init__(
457 self,
458 index: Index,
459 grouper=None,
460 obj: NDFrame | None = None,
461 level=None,
462 sort: bool = True,
463 observed: bool = True,
464 in_axis: bool = False,
465 dropna: bool = True,
466 uniques: ArrayLike | None = None,
467 ) -> None:
468 if isinstance(grouper, Series):
469 grouper = grouper.copy(deep=False)
470 self.level = level
471 self._orig_grouper = grouper
472 grouping_vector = _convert_grouper(index, grouper)
473 self._orig_cats = None
474 self._index = index
475 self._sort = sort
476 self.obj = obj
477 self._observed = observed
478 self.in_axis = in_axis
479 self._dropna = dropna
480 self._uniques = uniques
481
482 # we have a single grouper which may be a myriad of things,
483 # some of which are dependent on the passing in level
484
485 ilevel = self._ilevel
486 if ilevel is not None:
487 # In extant tests, the new self.grouping_vector matches
488 # `index.get_level_values(ilevel)` whenever
489 # mapper is None and isinstance(index, MultiIndex)
490 if isinstance(index, MultiIndex):
491 index_level = index.get_level_values(ilevel)
492 else:
493 index_level = index
494
495 if grouping_vector is None:
496 grouping_vector = index_level
497 else:
498 mapper = grouping_vector
499 grouping_vector = index_level.map(mapper)
500
501 # a passed Grouper like, directly get the grouper in the same way
502 # as single grouper groupby, use the group_info to get codes
503 elif isinstance(grouping_vector, Grouper):
504 # get the new grouper; we already have disambiguated
505 # what key/level refer to exactly, don't need to
506 # check again as we have by this point converted these
507 # to an actual value (rather than a pd.Grouper)
508 assert self.obj is not None # for mypy
509 newgrouper, newobj = grouping_vector._get_grouper(self.obj, validate=False)
510 self.obj = newobj
511
512 if isinstance(newgrouper, ops.BinGrouper):
513 # TODO: can we unwrap this and get a tighter typing
514 # for self.grouping_vector?
515 grouping_vector = newgrouper
516 else:
517 # ops.BaseGrouper
518 # TODO: 2023-02-03 no test cases with len(newgrouper.groupings) > 1.
519 # If that were to occur, would we be throwing out information?
520 # error: Cannot determine type of "grouping_vector" [has-type]
521 ng = newgrouper.groupings[0].grouping_vector # type: ignore[has-type]
522 # use Index instead of ndarray so we can recover the name
523 grouping_vector = Index(
524 ng, name=newgrouper.result_index.name, copy=False
525 )
526
527 elif not isinstance(
528 grouping_vector, (Series, Index, ExtensionArray, np.ndarray)
529 ):
530 # no level passed
531 if getattr(grouping_vector, "ndim", 1) != 1:
532 t = str(type(grouping_vector))
533 raise ValueError(f"Grouper for '{t}' not 1-dimensional")
534
535 grouping_vector = index.map(grouping_vector)
536
537 if not (
538 hasattr(grouping_vector, "__len__")
539 and len(grouping_vector) == len(index)
540 ):
541 grper = pprint_thing(grouping_vector)
542 errmsg = (
543 f"Grouper result violates len(labels) == len(data)\nresult: {grper}"
544 )
545 raise AssertionError(errmsg)
546
547 if isinstance(grouping_vector, np.ndarray):
548 if grouping_vector.dtype.kind in "mM":
549 # if we have a date/time-like grouper, make sure that we have
550 # Timestamps like
551 # TODO 2022-10-08 we only have one test that gets here and
552 # values are already in nanoseconds in that case.
553 grouping_vector = Series(grouping_vector).to_numpy()
554 elif isinstance(getattr(grouping_vector, "dtype", None), CategoricalDtype):
555 # a passed Categorical
556 self._orig_cats = grouping_vector.categories
557 grouping_vector = recode_for_groupby(grouping_vector, sort, observed)
558
559 self.grouping_vector = grouping_vector
560
561 def __repr__(self) -> str:
562 return f"Grouping({self.name})"
563
564 def __iter__(self) -> Iterator[Hashable]:
565 return iter(self.indices)
566
567 @cache_readonly
568 def _passed_categorical(self) -> bool:
569 dtype = getattr(self.grouping_vector, "dtype", None)
570 return isinstance(dtype, CategoricalDtype)
571
572 @cache_readonly
573 def name(self) -> Hashable:
574 ilevel = self._ilevel
575 if ilevel is not None:
576 return self._index.names[ilevel]
577
578 if isinstance(self._orig_grouper, (Index, Series)):
579 return self._orig_grouper.name
580
581 elif isinstance(self.grouping_vector, ops.BaseGrouper):
582 return self.grouping_vector.result_index.name
583
584 elif isinstance(self.grouping_vector, Index):
585 return self.grouping_vector.name
586
587 # otherwise we have ndarray or ExtensionArray -> no name
588 return None
589
590 @cache_readonly
591 def _ilevel(self) -> int | None:
592 """
593 If necessary, converted index level name to index level position.
594 """
595 level = self.level
596 if level is None:
597 return None
598 if not isinstance(level, int):
599 index = self._index
600 if level not in index.names:
601 raise AssertionError(f"Level {level} not in index")
602 if isinstance(index, MultiIndex) and index.names.count(level) > 1:
603 warnings.warn(
604 f"Grouping by index level '{level}' which matches multiple "
605 f"index levels is ambiguous. Currently the first matching "
606 f"level is used. In a future version of pandas, this will "
607 f"raise a ValueError. Use the level number instead.",
608 Pandas4Warning,
609 stacklevel=find_stack_level(),
610 )
611 return index.names.index(level)
612 return level
613
614 @property
615 def ngroups(self) -> int:
616 return len(self.uniques)
617
618 @cache_readonly
619 def indices(self) -> dict[Hashable, npt.NDArray[np.intp]]:
620 # we have a list of groupers
621 if isinstance(self.grouping_vector, ops.BaseGrouper):
622 return self.grouping_vector.indices
623
624 values = Categorical(self.grouping_vector)
625 return values._reverse_indexer()
626
627 @property
628 def codes(self) -> npt.NDArray[np.signedinteger]:
629 return self._codes_and_uniques[0]
630
631 @property
632 def uniques(self) -> ArrayLike:
633 return self._codes_and_uniques[1]
634
635 @cache_readonly
636 def _codes_and_uniques(self) -> tuple[npt.NDArray[np.signedinteger], ArrayLike]:
637 uniques: ArrayLike
638 if self._passed_categorical:
639 # we make a CategoricalIndex out of the cat grouper
640 # preserving the categories / ordered attributes;
641 # doesn't (yet - GH#46909) handle dropna=False
642 cat = self.grouping_vector
643 categories = cat.categories
644
645 if self._observed:
646 ucodes = algorithms.unique1d(cat.codes)
647 ucodes = ucodes[ucodes != -1]
648 if self._sort:
649 ucodes = np.sort(ucodes)
650 else:
651 ucodes = np.arange(len(categories))
652
653 has_dropped_na = False
654 if not self._dropna:
655 na_mask = cat.isna()
656 if np.any(na_mask):
657 has_dropped_na = True
658 if self._sort:
659 # NA goes at the end, gets `largest non-NA code + 1`
660 na_code = len(categories)
661 else:
662 # Insert NA in result based on first appearance, need
663 # the number of unique codes prior
664 na_idx = na_mask.argmax()
665 na_code = algorithms.nunique_ints(cat.codes[:na_idx])
666 ucodes = np.insert(ucodes, na_code, -1)
667
668 uniques = Categorical.from_codes(
669 codes=ucodes, categories=categories, ordered=cat.ordered, validate=False
670 )
671 codes = cat.codes
672
673 if has_dropped_na:
674 if not self._sort:
675 # NA code is based on first appearance, increment higher codes
676 codes = np.where(codes >= na_code, codes + 1, codes)
677 codes = np.where(na_mask, na_code, codes)
678
679 return codes, uniques
680
681 elif isinstance(self.grouping_vector, ops.BaseGrouper):
682 # we have a list of groupers
683 codes = self.grouping_vector.codes_info
684 # Pass the full Index (not ._values) so DatetimeIndex/TimedeltaIndex
685 # freq survives the trip through _with_infer.
686 uniques = self.grouping_vector.result_index # type: ignore[assignment]
687 elif self._uniques is not None:
688 # GH#50486 Code grouping_vector using _uniques; allows
689 # including uniques that are not present in grouping_vector.
690 cat = Categorical(self.grouping_vector, categories=self._uniques)
691 codes = cat.codes
692 uniques = self._uniques
693 else:
694 result = _factorize_monotonic(self.grouping_vector, self._sort)
695 if result is not None:
696 codes, uniques = result
697 else:
698 # GH35667, replace dropna=False with use_na_sentinel=False
699 # error: Incompatible types in assignment (expression has type
700 # "Union[ndarray[Any, Any], Index]", variable has type
701 # "Categorical")
702 codes, uniques = algorithms.factorize( # type: ignore[assignment]
703 self.grouping_vector,
704 sort=self._sort,
705 use_na_sentinel=self._dropna,
706 )
707 return codes, uniques
708
709 @cache_readonly
710 def groups(self) -> dict[Hashable, Index]:
711 codes, uniques = self._codes_and_uniques
712 uniques = Index._with_infer(uniques, name=self.name, copy=False) # type: ignore[assignment]
713
714 r, counts = libalgos.groupsort_indexer(ensure_platform_int(codes), len(uniques))
715 counts = ensure_int64(counts).cumsum()
716 _result = (r[start:end] for start, end in pairwise(counts))
717 # map to the label
718 result = {k: self._index.take(v) for k, v in zip(uniques, _result, strict=True)}
719
720 return PrettyDict(result)
721
722 @property
723 def observed_grouping(self) -> Grouping:
724 if self._observed:
725 return self
726
727 return self._observed_grouping
728
729 @cache_readonly
730 def _observed_grouping(self) -> Grouping:
731 grouping = Grouping(
732 self._index,
733 self._orig_grouper,
734 obj=self.obj,
735 level=self.level,
736 sort=self._sort,
737 observed=True,
738 in_axis=self.in_axis,
739 dropna=self._dropna,
740 uniques=self._uniques,
741 )
742 return grouping
743
744
745def get_grouper(
746 obj: NDFrameT,
747 key=None,
748 level=None,
749 sort: bool = True,
750 observed: bool = True,
751 validate: bool = True,
752 dropna: bool = True,
753) -> tuple[ops.BaseGrouper, frozenset[Hashable], NDFrameT]:
754 """
755 Create and return a BaseGrouper, which is an internal
756 mapping of how to create the grouper indexers.
757 This may be composed of multiple Grouping objects, indicating
758 multiple groupers
759
760 Groupers are ultimately index mappings. They can originate as:
761 index mappings, keys to columns, functions, or Groupers
762
763 Groupers enable local references to level,sort, while
764 the passed in level, and sort are 'global'.
765
766 This routine tries to figure out what the passing in references
767 are and then creates a Grouping for each one, combined into
768 a BaseGrouper.
769
770 If observed & we have a categorical grouper, only show the observed
771 values.
772
773 If validate, then check for key/level overlaps.
774
775 """
776 group_axis = obj.index
777
778 # validate that the passed single level is compatible with the passed
779 # index of the object
780 if level is not None:
781 # TODO: These if-block and else-block are almost same.
782 # MultiIndex instance check is removable, but it seems that there are
783 # some processes only for non-MultiIndex in else-block,
784 # eg. `obj.index.name != level`. We have to consider carefully whether
785 # these are applicable for MultiIndex. Even if these are applicable,
786 # we need to check if it makes no side effect to subsequent processes
787 # on the outside of this condition.
788 # (GH 17621)
789 if isinstance(group_axis, MultiIndex):
790 if is_list_like(level) and len(level) == 1:
791 level = level[0]
792
793 if key is None and is_scalar(level):
794 # Get the level values from group_axis
795 key = group_axis.get_level_values(level)
796 level = None
797
798 else:
799 # allow level to be a length-one list-like object
800 # (e.g., level=[0])
801 # GH 13901
802 if is_list_like(level):
803 nlevels = len(level)
804 if nlevels == 1:
805 level = level[0]
806 elif nlevels == 0:
807 raise ValueError("No group keys passed!")
808 else:
809 raise ValueError("multiple levels only valid with MultiIndex")
810
811 if isinstance(level, str):
812 if obj.index.name != level:
813 raise ValueError(f"level name {level} is not the name of the index")
814 elif level > 0 or level < -1:
815 raise ValueError("level > 0 or level < -1 only valid with MultiIndex")
816
817 # NOTE: `group_axis` and `group_axis.get_level_values(level)`
818 # are same in this section.
819 level = None
820 key = group_axis
821
822 # a passed-in Grouper, directly convert
823 if isinstance(key, Grouper):
824 grouper, obj = key._get_grouper(obj, validate=False, observed=observed)
825 if key.key is None:
826 return grouper, frozenset(), obj
827 else:
828 return grouper, frozenset({key.key}), obj
829
830 # already have a BaseGrouper, just return it
831 elif isinstance(key, ops.BaseGrouper):
832 return key, frozenset(), obj
833
834 if not isinstance(key, list):
835 keys = [key]
836 match_axis_length = False
837 else:
838 keys = key
839 match_axis_length = len(keys) == len(group_axis)
840
841 # what are we after, exactly?
842 any_callable = any(callable(g) or isinstance(g, dict) for g in keys)
843 any_groupers = any(isinstance(g, (Grouper, Grouping)) for g in keys)
844 any_arraylike = any(
845 isinstance(g, (list, tuple, Series, Index, np.ndarray)) for g in keys
846 )
847
848 # is this an index replacement?
849 if (
850 not any_callable
851 and not any_arraylike
852 and not any_groupers
853 and match_axis_length
854 and level is None
855 ):
856 if isinstance(obj, DataFrame):
857 all_in_columns_index = all(
858 g in obj.columns or g in obj.index.names for g in keys
859 )
860 else:
861 assert isinstance(obj, Series)
862 all_in_columns_index = all(g in obj.index.names for g in keys)
863
864 if not all_in_columns_index:
865 keys = [com.asarray_tuplesafe(keys)]
866
867 if isinstance(level, (tuple, list)):
868 if key is None:
869 keys = [None] * len(level)
870 levels = level
871 else:
872 levels = [level] * len(keys)
873
874 groupings: list[Grouping] = []
875 exclusions: set[Hashable] = set()
876
877 # if the actual grouper should be obj[key]
878 def is_in_axis(key) -> bool:
879 if not _is_label_like(key):
880 if obj.ndim == 1:
881 return False
882
883 # items -> .columns for DataFrame, .index for Series
884 items = obj.axes[-1]
885 try:
886 items.get_loc(key)
887 except (KeyError, TypeError, InvalidIndexError):
888 # TypeError shows up here if we pass e.g. an Index
889 return False
890
891 return True
892
893 # if the grouper is obj[name]
894 def is_in_obj(gpr) -> bool:
895 if not hasattr(gpr, "name"):
896 return False
897 # We check the references to determine if the
898 # series is part of the object
899 try:
900 obj_gpr_column = obj[gpr.name]
901 except (KeyError, IndexError, InvalidIndexError, OutOfBoundsDatetime):
902 return False
903 if isinstance(gpr, Series) and isinstance(obj_gpr_column, Series):
904 return gpr._mgr.references_same_values(obj_gpr_column._mgr, 0)
905 return False
906
907 for gpr, level in zip(keys, levels, strict=True):
908 if is_in_obj(gpr): # df.groupby(df['name'])
909 in_axis = True
910 exclusions.add(gpr.name)
911
912 elif is_in_axis(gpr): # df.groupby('name')
913 if obj.ndim != 1 and gpr in obj:
914 if validate:
915 obj._check_label_or_level_ambiguity(gpr, axis=0)
916 in_axis, name, gpr = True, gpr, obj[gpr]
917 if gpr.ndim != 1:
918 # non-unique columns; raise here to get the name in the
919 # exception message
920 raise ValueError(f"Grouper for '{name}' not 1-dimensional")
921 exclusions.add(name)
922 elif obj._is_level_reference(gpr, axis=0):
923 in_axis, level, gpr = False, gpr, None
924 else:
925 raise KeyError(gpr)
926 elif isinstance(gpr, Grouper) and gpr.key is not None:
927 # Add key to exclusions
928 exclusions.add(gpr.key)
929 in_axis = True
930 else:
931 in_axis = False
932
933 # create the Grouping
934 # allow us to passing the actual Grouping as the gpr
935 ping = (
936 Grouping(
937 group_axis,
938 gpr,
939 obj=obj,
940 level=level,
941 sort=sort,
942 observed=observed,
943 in_axis=in_axis,
944 dropna=dropna,
945 )
946 if not isinstance(gpr, Grouping)
947 else gpr
948 )
949
950 groupings.append(ping)
951
952 if len(groupings) == 0 and len(obj):
953 raise ValueError("No group keys passed!")
954 if len(groupings) == 0:
955 groupings.append(Grouping(default_index(0), np.array([], dtype=np.intp)))
956
957 # create the internals grouper
958 grouper = ops.BaseGrouper(group_axis, groupings, sort=sort, dropna=dropna)
959 return grouper, frozenset(exclusions), obj
960
961
962def _is_label_like(val) -> bool:
963 return isinstance(val, (str, tuple)) or (val is not None and is_scalar(val))
964
965
966def _factorize_monotonic(
967 grouping_vector,
968 sort: bool,
969) -> tuple[npt.NDArray[np.intp], np.ndarray] | None:
970 """
971 Fast-path factorization for monotonic (sorted) grouping vectors.
972
973 Uses adjacent-element comparison instead of hash table construction.
974 Returns (codes, uniques) or None if the fast path is not applicable.
975
976 Since monotonic arrays contain no NA values (NAs break monotonicity
977 checks for n >= 2), NA handling is not needed here.
978 """
979 if isinstance(grouping_vector, (Series, Index)):
980 # Bail before np.asarray for extension dtypes (PeriodDtype,
981 # DatetimeTZDtype, etc.) — converting them would box every element.
982 dtype = grouping_vector.dtype
983 if not isinstance(dtype, np.dtype) or dtype.kind not in "iufmMb":
984 return None
985 if isinstance(grouping_vector, Index) and dtype.kind in "mM":
986 # DatetimeIndex/TimedeltaIndex.factorize has its own fastpaths
987 # (freq-based and monotonic) that retain freq and Index uniques
988 return None
989 ascending = grouping_vector.is_monotonic_increasing
990 if not ascending and not grouping_vector.is_monotonic_decreasing:
991 return None
992 arr = np.asarray(grouping_vector)
993 elif isinstance(grouping_vector, np.ndarray):
994 arr = grouping_vector
995 if arr.dtype.kind not in "iufmMb":
996 return None
997 if len(arr) <= 1:
998 return None
999 # Quick sample check: compare a few spaced elements to avoid
1000 # a full O(n) scan on clearly unsorted data.
1001 sample_idx = np.linspace(0, len(arr) - 1, num=min(8, len(arr)), dtype=np.intp)
1002 sample = arr[sample_idx]
1003 if not bool(np.all(sample[1:] >= sample[:-1])):
1004 if not bool(np.all(sample[1:] <= sample[:-1])):
1005 return None
1006 ascending = bool(np.all(arr[1:] >= arr[:-1]))
1007 if not ascending:
1008 if not bool(np.all(arr[1:] <= arr[:-1])):
1009 return None
1010 else:
1011 return None
1012
1013 if len(arr) <= 1:
1014 return None
1015
1016 codes, uniques_indexer = algorithms.factorize_monotonic_codes(arr, ascending, sort)
1017 return codes, arr[uniques_indexer]
1018
1019
1020def _convert_grouper(axis: Index, grouper):
1021 if isinstance(grouper, dict):
1022 return grouper.get
1023 elif isinstance(grouper, Series):
1024 if grouper.index.equals(axis):
1025 return grouper._values
1026 else:
1027 return grouper.reindex(axis)._values
1028 elif isinstance(grouper, MultiIndex):
1029 return grouper._values
1030 elif isinstance(grouper, (list, tuple, Index, Categorical, np.ndarray)):
1031 if len(grouper) != len(axis):
1032 raise ValueError("Grouper and axis must be same length")
1033
1034 if isinstance(grouper, (list, tuple)):
1035 grouper = com.asarray_tuplesafe(grouper)
1036 return grouper
1037 else:
1038 return grouper