Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pandas/core/groupby/ops.py: 26%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

551 statements  

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) 

19 

20import numpy as np 

21 

22from pandas._libs import ( 

23 NaT, 

24 lib, 

25) 

26import pandas._libs.groupby as libgroupby 

27from pandas._typing import ( 

28 ArrayLike, 

29 AxisInt, 

30 NDFrameT, 

31 Shape, 

32 npt, 

33) 

34from pandas.errors import AbstractMethodError 

35from pandas.util._decorators import cache_readonly 

36 

37from pandas.core.dtypes.cast import ( 

38 maybe_downcast_to_dtype, 

39) 

40from pandas.core.dtypes.common import ( 

41 ensure_float64, 

42 ensure_int64, 

43 ensure_platform_int, 

44 ensure_uint64, 

45 is_1d_only_ea_dtype, 

46) 

47from pandas.core.dtypes.missing import ( 

48 isna, 

49 maybe_fill, 

50) 

51 

52from pandas.core.arrays import Categorical 

53from pandas.core.frame import DataFrame 

54from pandas.core.groupby import grouper 

55from pandas.core.indexes.api import ( 

56 CategoricalIndex, 

57 Index, 

58 MultiIndex, 

59 ensure_index, 

60) 

61from pandas.core.series import Series 

62from pandas.core.sorting import ( 

63 compress_group_index, 

64 decons_obs_group_ids, 

65 get_group_index, 

66 get_group_index_sorter, 

67 get_indexer_dict, 

68) 

69 

70if TYPE_CHECKING: 

71 from collections.abc import ( 

72 Callable, 

73 Generator, 

74 Hashable, 

75 Iterator, 

76 ) 

77 

78 from pandas.core.generic import NDFrame 

79 

80 

81def check_result_array(obj, dtype) -> None: 

82 # Our operation is supposed to be an aggregation/reduction. If 

83 # it returns an ndarray, this likely means an invalid operation has 

84 # been passed. See test_apply_without_aggregation, test_agg_must_agg 

85 if isinstance(obj, np.ndarray): 

86 if dtype != object: 

87 # If it is object dtype, the function can be a reduction/aggregation 

88 # and still return an ndarray e.g. test_agg_over_numpy_arrays 

89 raise ValueError("Must produce aggregated value") 

90 

91 

92def extract_result(res): 

93 """ 

94 Extract the result object, it might be a 0-dim ndarray 

95 or a len-1 0-dim, or a scalar 

96 """ 

97 if hasattr(res, "_values"): 

98 # Preserve EA 

99 res = res._values 

100 if res.ndim == 1 and len(res) == 1: 

101 # see test_agg_lambda_with_timezone, test_resampler_grouper.py::test_apply 

102 res = res[0] 

103 return res 

104 

105 

106class WrappedCythonOp: 

107 """ 

108 Dispatch logic for functions defined in _libs.groupby 

109 

110 Parameters 

111 ---------- 

112 kind: str 

113 Whether the operation is an aggregate or transform. 

114 how: str 

115 Operation name, e.g. "mean". 

116 has_dropped_na: bool 

117 True precisely when dropna=True and the grouper contains a null value. 

118 """ 

119 

120 # Functions for which we do _not_ attempt to cast the cython result 

121 # back to the original dtype. 

122 cast_blocklist = frozenset( 

123 ["any", "all", "rank", "count", "size", "idxmin", "idxmax"] 

124 ) 

125 

126 def __init__(self, kind: str, how: str, has_dropped_na: bool) -> None: 

127 self.kind = kind 

128 self.how = how 

129 self.has_dropped_na = has_dropped_na 

130 

131 _CYTHON_FUNCTIONS: dict[str, dict] = { 

132 "aggregate": { 

133 "any": functools.partial(libgroupby.group_any_all, val_test="any"), 

134 "all": functools.partial(libgroupby.group_any_all, val_test="all"), 

135 "sum": "group_sum", 

136 "prod": "group_prod", 

137 "idxmin": functools.partial(libgroupby.group_idxmin_idxmax, name="idxmin"), 

138 "idxmax": functools.partial(libgroupby.group_idxmin_idxmax, name="idxmax"), 

139 "min": "group_min", 

140 "max": "group_max", 

141 "mean": "group_mean", 

142 "median": "group_median_float64", 

143 "var": "group_var", 

144 "std": functools.partial(libgroupby.group_var, name="std"), 

145 "sem": functools.partial(libgroupby.group_var, name="sem"), 

146 "skew": "group_skew", 

147 "kurt": "group_kurt", 

148 "first": "group_nth", 

149 "last": "group_last", 

150 "ohlc": "group_ohlc", 

151 }, 

152 "transform": { 

153 "cumprod": "group_cumprod", 

154 "cumsum": "group_cumsum", 

155 "cummin": "group_cummin", 

156 "cummax": "group_cummax", 

157 "rank": "group_rank", 

158 }, 

159 } 

160 

161 _cython_arity = {"ohlc": 4} # OHLC 

162 

163 @classmethod 

164 def get_kind_from_how(cls, how: str) -> str: 

165 if how in cls._CYTHON_FUNCTIONS["aggregate"]: 

166 return "aggregate" 

167 return "transform" 

168 

169 # Note: we make this a classmethod and pass kind+how so that caching 

170 # works at the class level and not the instance level 

171 @classmethod 

172 @functools.cache 

173 def _get_cython_function( 

174 cls, kind: str, how: str, dtype: np.dtype, is_numeric: bool 

175 ): 

176 dtype_str = dtype.name 

177 ftype = cls._CYTHON_FUNCTIONS[kind][how] 

178 

179 # see if there is a fused-type version of function 

180 # only valid for numeric 

181 if callable(ftype): 

182 f = ftype 

183 else: 

184 f = getattr(libgroupby, ftype) 

185 if is_numeric: 

186 return f 

187 elif dtype == np.dtype(object): 

188 if how in ["median", "cumprod"]: 

189 # no fused types -> no __signatures__ 

190 raise NotImplementedError( 

191 f"function is not implemented for this dtype: " 

192 f"[how->{how},dtype->{dtype_str}]" 

193 ) 

194 elif how in ["std", "sem", "idxmin", "idxmax"]: 

195 # We have a partial object that does not have __signatures__ 

196 return f 

197 elif how in ["skew", "kurt"]: 

198 # _get_cython_vals will convert to float64 

199 pass 

200 elif "object" not in f.__signatures__: 

201 # raise NotImplementedError here rather than TypeError later 

202 raise NotImplementedError( 

203 f"function is not implemented for this dtype: " 

204 f"[how->{how},dtype->{dtype_str}]" 

205 ) 

206 return f 

207 else: 

208 raise NotImplementedError( 

209 "This should not be reached. Please report a bug at " 

210 "github.com/pandas-dev/pandas/", 

211 dtype, 

212 ) 

213 

214 def _get_cython_vals(self, values: np.ndarray) -> np.ndarray: 

215 """ 

216 Cast numeric dtypes to float64 for functions that only support that. 

217 

218 Parameters 

219 ---------- 

220 values : np.ndarray 

221 

222 Returns 

223 ------- 

224 values : np.ndarray 

225 """ 

226 how = self.how 

227 

228 if how in ["median", "std", "sem", "skew", "kurt"]: 

229 # median only has a float64 implementation 

230 # We should only get here with is_numeric, as non-numeric cases 

231 # should raise in _get_cython_function 

232 values = ensure_float64(values) 

233 

234 elif values.dtype.kind in "iu": 

235 if how in ["var", "mean"] or ( 

236 self.kind == "transform" and self.has_dropped_na 

237 ): 

238 # has_dropped_na check need for test_null_group_str_transformer 

239 # result may still include NaN, so we have to cast 

240 values = ensure_float64(values) 

241 

242 elif how in ["sum", "ohlc", "prod", "cumsum", "cumprod"]: 

243 # Avoid overflow during group op 

244 if values.dtype.kind == "i": 

245 values = ensure_int64(values) 

246 else: 

247 values = ensure_uint64(values) 

248 

249 return values 

250 

251 def _get_output_shape(self, ngroups: int, values: np.ndarray) -> Shape: 

252 how = self.how 

253 kind = self.kind 

254 

255 arity = self._cython_arity.get(how, 1) 

256 

257 out_shape: Shape 

258 if how == "ohlc": 

259 out_shape = (ngroups, arity) 

260 elif arity > 1: 

261 raise NotImplementedError( 

262 "arity of more than 1 is not supported for the 'how' argument" 

263 ) 

264 elif kind == "transform": 

265 out_shape = values.shape 

266 else: 

267 out_shape = (ngroups, *values.shape[1:]) 

268 return out_shape 

269 

270 def _get_out_dtype(self, dtype: np.dtype) -> np.dtype: 

271 how = self.how 

272 

273 if how == "rank": 

274 out_dtype = "float64" 

275 elif how in ["idxmin", "idxmax"]: 

276 # The Cython implementation only produces the row number; we'll take 

277 # from the index using this in post processing 

278 out_dtype = "intp" 

279 elif dtype.kind in "iufcb": 

280 out_dtype = f"{dtype.kind}{dtype.itemsize}" 

281 else: 

282 out_dtype = "object" 

283 return np.dtype(out_dtype) 

284 

285 def _get_result_dtype(self, dtype: np.dtype) -> np.dtype: 

286 """ 

287 Get the desired dtype of a result based on the 

288 input dtype and how it was computed. 

289 

290 Parameters 

291 ---------- 

292 dtype : np.dtype 

293 

294 Returns 

295 ------- 

296 np.dtype 

297 The desired dtype of the result. 

298 """ 

299 how = self.how 

300 

301 if how in ["sum", "cumsum", "sum", "prod", "cumprod"]: 

302 if dtype == np.dtype(bool): 

303 return np.dtype(np.int64) 

304 elif how in ["mean", "median", "var", "std", "sem"]: 

305 if dtype.kind in "fc": 

306 return dtype 

307 elif dtype.kind in "iub": 

308 return np.dtype(np.float64) 

309 return dtype 

310 

311 @final 

312 def _cython_op_ndim_compat( 

313 self, 

314 values: np.ndarray, 

315 *, 

316 min_count: int, 

317 ngroups: int, 

318 comp_ids: np.ndarray, 

319 mask: npt.NDArray[np.bool_] | None = None, 

320 result_mask: npt.NDArray[np.bool_] | None = None, 

321 initial: Any = 0, 

322 **kwargs, 

323 ) -> np.ndarray: 

324 if values.ndim == 1: 

325 # expand to 2d, dispatch, then squeeze if appropriate 

326 values2d = values[None, :] 

327 if mask is not None: 

328 mask = mask[None, :] 

329 if result_mask is not None: 

330 result_mask = result_mask[None, :] 

331 res = self._call_cython_op( 

332 values2d, 

333 min_count=min_count, 

334 ngroups=ngroups, 

335 comp_ids=comp_ids, 

336 mask=mask, 

337 result_mask=result_mask, 

338 initial=initial, 

339 **kwargs, 

340 ) 

341 if res.shape[0] == 1: 

342 return res[0] 

343 

344 # otherwise we have OHLC 

345 return res.T 

346 

347 return self._call_cython_op( 

348 values, 

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 

358 @final 

359 def _call_cython_op( 

360 self, 

361 values: np.ndarray, # np.ndarray[ndim=2] 

362 *, 

363 min_count: int, 

364 ngroups: int, 

365 comp_ids: np.ndarray, 

366 mask: npt.NDArray[np.bool_] | None, 

367 result_mask: npt.NDArray[np.bool_] | None, 

368 initial: Any = 0, 

369 **kwargs, 

370 ) -> np.ndarray: # np.ndarray[ndim=2] 

371 orig_values = values 

372 

373 dtype = values.dtype 

374 is_numeric = dtype.kind in "iufcb" 

375 

376 is_datetimelike = dtype.kind in "mM" 

377 

378 if self.how in ["any", "all"]: 

379 if mask is None: 

380 mask = isna(values) 

381 

382 if is_datetimelike: 

383 values = values.view("int64") 

384 is_numeric = True 

385 elif dtype.kind == "b": 

386 values = values.view("uint8") 

387 if values.dtype == "float16": 

388 values = values.astype(np.float32) 

389 

390 if self.how in ["any", "all"]: 

391 if dtype == object: 

392 if kwargs["skipna"]: 

393 # GH#37501: don't raise on pd.NA when skipna=True 

394 if mask is not None and mask.any(): 

395 # mask on original values computed separately 

396 values = values.copy() 

397 values[mask] = True 

398 values = values.astype(bool, copy=False).view(np.int8) 

399 is_numeric = True 

400 

401 values = values.T 

402 if mask is not None: 

403 mask = mask.T 

404 if result_mask is not None: 

405 result_mask = result_mask.T 

406 

407 out_shape = self._get_output_shape(ngroups, values) 

408 func = self._get_cython_function(self.kind, self.how, values.dtype, is_numeric) 

409 values = self._get_cython_vals(values) 

410 out_dtype = self._get_out_dtype(values.dtype) 

411 

412 result = maybe_fill(np.empty(out_shape, dtype=out_dtype)) 

413 if self.kind == "aggregate": 

414 counts = np.zeros(ngroups, dtype=np.int64) 

415 if self.how in [ 

416 "idxmin", 

417 "idxmax", 

418 "min", 

419 "max", 

420 "mean", 

421 "last", 

422 "first", 

423 "sum", 

424 "median", 

425 ]: 

426 if self.how == "sum": 

427 # pass in through kwargs only for sum (other functions don't have 

428 # the keyword) 

429 kwargs["initial"] = initial 

430 func( 

431 out=result, 

432 counts=counts, 

433 values=values, 

434 labels=comp_ids, 

435 min_count=min_count, 

436 mask=mask, 

437 result_mask=result_mask, 

438 is_datetimelike=is_datetimelike, 

439 **kwargs, 

440 ) 

441 elif self.how in ["sem", "std", "var", "ohlc", "prod"]: 

442 if self.how in ["std", "sem"]: 

443 kwargs["is_datetimelike"] = is_datetimelike 

444 func( 

445 result, 

446 counts, 

447 values, 

448 comp_ids, 

449 min_count=min_count, 

450 mask=mask, 

451 result_mask=result_mask, 

452 **kwargs, 

453 ) 

454 elif self.how in ["any", "all"]: 

455 func( 

456 out=result, 

457 values=values, 

458 labels=comp_ids, 

459 mask=mask, 

460 result_mask=result_mask, 

461 **kwargs, 

462 ) 

463 result = result.astype(bool, copy=False) 

464 elif self.how in ["skew", "kurt"]: 

465 func( 

466 out=result, 

467 counts=counts, 

468 values=values, 

469 labels=comp_ids, 

470 mask=mask, 

471 result_mask=result_mask, 

472 **kwargs, 

473 ) 

474 if dtype == object: 

475 result = result.astype(object) 

476 

477 else: 

478 raise NotImplementedError(f"{self.how} is not implemented") 

479 else: 

480 # TODO: min_count 

481 if self.how != "rank": 

482 # TODO: should rank take result_mask? 

483 kwargs["result_mask"] = result_mask 

484 func( 

485 out=result, 

486 values=values, 

487 labels=comp_ids, 

488 ngroups=ngroups, 

489 is_datetimelike=is_datetimelike, 

490 mask=mask, 

491 **kwargs, 

492 ) 

493 

494 if self.kind == "aggregate" and self.how not in ["idxmin", "idxmax"]: 

495 # i.e. counts is defined. Locations where count<min_count 

496 # need to have the result set to np.nan, which may require casting, 

497 # see GH#40767. For idxmin/idxmax is handled specially via post-processing 

498 if result.dtype.kind in "iu" and not is_datetimelike: 

499 # if the op keeps the int dtypes, we have to use 0 

500 cutoff = max(0 if self.how in ["sum", "prod"] else 1, min_count) 

501 empty_groups = counts < cutoff 

502 if empty_groups.any(): 

503 if result_mask is not None: 

504 assert result_mask[empty_groups].all() 

505 else: 

506 # Note: this conversion could be lossy, see GH#40767 

507 result = result.astype("float64") 

508 result[empty_groups] = np.nan 

509 

510 result = result.T 

511 

512 if self.how not in self.cast_blocklist: 

513 # e.g. if we are int64 and need to restore to datetime64/timedelta64 

514 # "rank" is the only member of cast_blocklist we get here 

515 # Casting only needed for float16, bool, datetimelike, 

516 # and self.how in ["sum", "prod", "ohlc", "cumprod"] 

517 res_dtype = self._get_result_dtype(orig_values.dtype) 

518 op_result = maybe_downcast_to_dtype(result, res_dtype) 

519 else: 

520 op_result = result 

521 

522 return op_result 

523 

524 @final 

525 def _validate_axis(self, axis: AxisInt, values: ArrayLike) -> None: 

526 if values.ndim > 2: 

527 raise NotImplementedError("number of dimensions is currently limited to 2") 

528 if values.ndim == 2: 

529 assert axis == 1, axis 

530 elif not is_1d_only_ea_dtype(values.dtype): 

531 # Note: it is *not* the case that axis is always 0 for 1-dim values, 

532 # as we can have 1D ExtensionArrays that we need to treat as 2D 

533 assert axis == 0 

534 

535 @final 

536 def cython_operation( 

537 self, 

538 *, 

539 values: ArrayLike, 

540 axis: AxisInt, 

541 min_count: int = -1, 

542 comp_ids: np.ndarray, 

543 ngroups: int, 

544 **kwargs, 

545 ) -> ArrayLike: 

546 """ 

547 Call our cython function, with appropriate pre- and post- processing. 

548 """ 

549 self._validate_axis(axis, values) 

550 

551 if not isinstance(values, np.ndarray): 

552 # i.e. ExtensionArray 

553 return values._groupby_op( 

554 how=self.how, 

555 has_dropped_na=self.has_dropped_na, 

556 min_count=min_count, 

557 ngroups=ngroups, 

558 ids=comp_ids, 

559 **kwargs, 

560 ) 

561 

562 return self._cython_op_ndim_compat( 

563 values, 

564 min_count=min_count, 

565 ngroups=ngroups, 

566 comp_ids=comp_ids, 

567 mask=None, 

568 **kwargs, 

569 ) 

570 

571 

572class BaseGrouper: 

573 """ 

574 This is an internal Grouper class, which actually holds 

575 the generated groups 

576 

577 Parameters 

578 ---------- 

579 axis : Index 

580 groupings : Sequence[Grouping] 

581 all the grouping instances to handle in this grouper 

582 for example for grouper list to groupby, need to pass the list 

583 sort : bool, default True 

584 whether this grouper will give sorted result or not 

585 

586 """ 

587 

588 axis: Index 

589 

590 def __init__( 

591 self, 

592 axis: Index, 

593 groupings: list[grouper.Grouping], 

594 sort: bool = True, 

595 dropna: bool = True, 

596 ) -> None: 

597 assert isinstance(axis, Index), axis 

598 

599 self.axis = axis 

600 self._groupings = groupings 

601 self._sort = sort 

602 self.dropna = dropna 

603 

604 @property 

605 def groupings(self) -> list[grouper.Grouping]: 

606 return self._groupings 

607 

608 def __iter__(self) -> Iterator[Hashable]: 

609 return iter(self.indices) 

610 

611 @property 

612 def nkeys(self) -> int: 

613 return len(self.groupings) 

614 

615 def get_iterator(self, data: NDFrameT) -> Iterator[tuple[Hashable, NDFrameT]]: 

616 """ 

617 Groupby iterator 

618 

619 Returns 

620 ------- 

621 Generator yielding sequence of (name, subsetted object) 

622 for each group 

623 """ 

624 splitter = self._get_splitter(data) 

625 # TODO: Would be more efficient to skip unobserved for transforms 

626 keys = self.result_index 

627 yield from zip(keys, splitter, strict=True) 

628 

629 @final 

630 def _get_splitter(self, data: NDFrame) -> DataSplitter: 

631 """ 

632 Returns 

633 ------- 

634 Generator yielding subsetted objects 

635 """ 

636 if isinstance(data, Series): 

637 klass: type[DataSplitter] = SeriesSplitter 

638 else: 

639 # i.e. DataFrame 

640 klass = FrameSplitter 

641 

642 return klass( 

643 data, 

644 self.ngroups, 

645 sorted_ids=self._sorted_ids, 

646 sort_idx=self.result_ilocs, 

647 ) 

648 

649 @cache_readonly 

650 def indices(self) -> dict[Hashable, npt.NDArray[np.intp]]: 

651 """dict {group name -> group indices}""" 

652 if len(self.groupings) == 1 and isinstance(self.result_index, CategoricalIndex): 

653 # This shows unused categories in indices GH#38642 

654 result = self.groupings[0].indices 

655 else: 

656 codes_list = [ping.codes for ping in self.groupings] 

657 result = get_indexer_dict(codes_list, self.levels) 

658 if not self.dropna: 

659 has_mi = isinstance(self.result_index, MultiIndex) 

660 if not has_mi and self.result_index.hasnans: 

661 result = { 

662 np.nan if isna(key) else key: value for key, value in result.items() 

663 } 

664 elif has_mi: 

665 # MultiIndex has no efficient way to tell if there are NAs 

666 result = { 

667 # error: "Hashable" has no attribute "__iter__" (not iterable) 

668 tuple(np.nan if isna(comp) else comp for comp in key): value # type: ignore[attr-defined] 

669 for key, value in result.items() 

670 } 

671 

672 return result 

673 

674 @final 

675 @cache_readonly 

676 def result_ilocs(self) -> npt.NDArray[np.intp]: 

677 """ 

678 Get the original integer locations of result_index in the input. 

679 """ 

680 # Original indices are where group_index would go via sorting. 

681 # But when dropna is true, we need to remove null values while accounting for 

682 # any gaps that then occur because of them. 

683 ids = self.ids 

684 

685 if self.has_dropped_na: 

686 mask = np.where(ids >= 0) 

687 # Count how many gaps are caused by previous null values for each position 

688 null_gaps = np.cumsum(ids == -1)[mask] 

689 ids = ids[mask] 

690 

691 result = get_group_index_sorter(ids, self.ngroups) 

692 

693 if self.has_dropped_na: 

694 # Shift by the number of prior null gaps 

695 result += np.take(null_gaps, result) 

696 

697 return result 

698 

699 @property 

700 def codes(self) -> list[npt.NDArray[np.signedinteger]]: 

701 return [ping.codes for ping in self.groupings] 

702 

703 @property 

704 def levels(self) -> list[Index]: 

705 if len(self.groupings) > 1: 

706 # mypy doesn't know result_index must be a MultiIndex 

707 return list(self.result_index.levels) # type: ignore[attr-defined] 

708 else: 

709 return [self.result_index] 

710 

711 @property 

712 def names(self) -> list[Hashable]: 

713 return [ping.name for ping in self.groupings] 

714 

715 @final 

716 def size(self) -> Series: 

717 """ 

718 Compute group sizes. 

719 """ 

720 ids = self.ids 

721 ngroups = self.ngroups 

722 out: np.ndarray | list 

723 if ngroups: 

724 out = np.bincount(ids[ids != -1], minlength=ngroups) 

725 else: 

726 out = [] 

727 return Series(out, index=self.result_index, dtype="int64", copy=False) 

728 

729 @cache_readonly 

730 def groups(self) -> dict[Hashable, Index]: 

731 """dict {group name -> group labels}""" 

732 if len(self.groupings) == 1: 

733 return self.groupings[0].groups 

734 result_index, ids = self.result_index_and_ids 

735 values = result_index._values 

736 categories = Categorical.from_codes(ids, categories=range(len(result_index))) 

737 result = { 

738 # mypy is not aware that group has to be an integer 

739 values[group]: self.axis.take(axis_ilocs) # type: ignore[call-overload] 

740 for group, axis_ilocs in categories._reverse_indexer().items() 

741 } 

742 return result 

743 

744 @final 

745 @cache_readonly 

746 def is_monotonic(self) -> bool: 

747 # return if my group orderings are monotonic 

748 return Index(self.ids, copy=False).is_monotonic_increasing 

749 

750 @final 

751 @cache_readonly 

752 def has_dropped_na(self) -> bool: 

753 """ 

754 Whether grouper has null value(s) that are dropped. 

755 """ 

756 return bool((self.ids < 0).any()) 

757 

758 @cache_readonly 

759 def codes_info(self) -> npt.NDArray[np.intp]: 

760 # return the codes of items in original grouped axis 

761 return self.ids 

762 

763 @final 

764 @cache_readonly 

765 def ngroups(self) -> int: 

766 return len(self.result_index) 

767 

768 @property 

769 def result_index(self) -> Index: 

770 return self.result_index_and_ids[0] 

771 

772 @property 

773 def ids(self) -> npt.NDArray[np.intp]: 

774 return self.result_index_and_ids[1] 

775 

776 @cache_readonly 

777 def result_index_and_ids(self) -> tuple[Index, npt.NDArray[np.intp]]: 

778 levels = [ 

779 Index._with_infer(ping.uniques, copy=False) for ping in self.groupings 

780 ] 

781 obs = [ 

782 ping._observed or not ping._passed_categorical for ping in self.groupings 

783 ] 

784 sorts = [ping._sort for ping in self.groupings] 

785 # When passed a categorical grouping, keep all categories 

786 for k, (ping, level) in enumerate(zip(self.groupings, levels, strict=True)): 

787 if ping._passed_categorical: 

788 levels[k] = level.set_categories(ping._orig_cats) 

789 

790 if len(self.groupings) == 1: 

791 result_index = levels[0] 

792 result_index.name = self.names[0] 

793 ids = ensure_platform_int(self.codes[0]) 

794 elif all(obs): 

795 result_index, ids = self._ob_index_and_ids( 

796 levels, self.codes, self.names, sorts 

797 ) 

798 elif not any(obs): 

799 result_index, ids = self._unob_index_and_ids(levels, self.codes, self.names) 

800 else: 

801 # Combine unobserved and observed parts 

802 names = self.names 

803 codes = [ping.codes for ping in self.groupings] 

804 ob_indices = [idx for idx, ob in enumerate(obs) if ob] 

805 unob_indices = [idx for idx, ob in enumerate(obs) if not ob] 

806 ob_index, ob_ids = self._ob_index_and_ids( 

807 levels=[levels[idx] for idx in ob_indices], 

808 codes=[codes[idx] for idx in ob_indices], 

809 names=[names[idx] for idx in ob_indices], 

810 sorts=[sorts[idx] for idx in ob_indices], 

811 ) 

812 unob_index, unob_ids = self._unob_index_and_ids( 

813 levels=[levels[idx] for idx in unob_indices], 

814 codes=[codes[idx] for idx in unob_indices], 

815 names=[names[idx] for idx in unob_indices], 

816 ) 

817 

818 result_index_codes = np.concatenate( 

819 [ 

820 np.tile(unob_index.codes, len(ob_index)), 

821 np.repeat(ob_index.codes, len(unob_index), axis=1), 

822 ], 

823 axis=0, 

824 ) 

825 _, index = np.unique(unob_indices + ob_indices, return_index=True) 

826 result_index = MultiIndex( 

827 levels=list(unob_index.levels) + list(ob_index.levels), 

828 codes=result_index_codes, 

829 names=list(unob_index.names) + list(ob_index.names), 

830 ).reorder_levels(index) 

831 

832 # The sum here will get -1 values wrong when dropna=True; 

833 # we will fix at the end. 

834 ids = len(unob_index) * ob_ids + unob_ids 

835 

836 if any(sorts): 

837 # Sort result_index and recode ids using the new order 

838 n_levels = len(sorts) 

839 drop_levels = [ 

840 n_levels - idx 

841 for idx, sort in enumerate(reversed(sorts), 1) 

842 if not sort 

843 ] 

844 if len(drop_levels) > 0: 

845 sorter = result_index._drop_level_numbers(drop_levels).argsort() 

846 else: 

847 sorter = result_index.argsort() 

848 result_index = result_index.take(sorter) 

849 _, index = np.unique(sorter, return_index=True) 

850 ids = ensure_platform_int(ids) 

851 ids = index.take(ids) 

852 else: 

853 # Recode ids and reorder result_index with observed groups up front, 

854 # unobserved at the end 

855 ids, uniques = compress_group_index(ids, sort=False) 

856 ids = ensure_platform_int(ids) 

857 taker = np.concatenate( 

858 [uniques, np.delete(np.arange(len(result_index)), uniques)] 

859 ) 

860 result_index = result_index.take(taker) 

861 

862 if self.dropna: 

863 ids = np.where((ob_ids < 0) | (unob_ids < 0), -1, ids) 

864 

865 return result_index, ids 

866 

867 @property 

868 def observed_grouper(self) -> BaseGrouper: 

869 if all(ping._observed for ping in self.groupings): 

870 return self 

871 

872 return self._observed_grouper 

873 

874 @cache_readonly 

875 def _observed_grouper(self) -> BaseGrouper: 

876 groupings = [ping.observed_grouping for ping in self.groupings] 

877 grouper = BaseGrouper(self.axis, groupings, sort=self._sort, dropna=self.dropna) 

878 return grouper 

879 

880 def _ob_index_and_ids( 

881 self, 

882 levels: list[Index], 

883 codes: list[npt.NDArray[np.intp]], 

884 names: list[Hashable], 

885 sorts: list[bool], 

886 ) -> tuple[MultiIndex, npt.NDArray[np.intp]]: 

887 consistent_sorting = all(sorts[0] == sort for sort in sorts[1:]) 

888 sort_in_compress = sorts[0] if consistent_sorting else False 

889 shape = tuple(len(level) for level in levels) 

890 group_index = get_group_index(codes, shape, sort=True, xnull=True) 

891 ob_ids, obs_group_ids = compress_group_index(group_index, sort=sort_in_compress) 

892 ob_ids = ensure_platform_int(ob_ids) 

893 ob_index_codes = decons_obs_group_ids( 

894 ob_ids, obs_group_ids, shape, codes, xnull=True 

895 ) 

896 ob_index = MultiIndex( 

897 levels=levels, 

898 codes=ob_index_codes, 

899 names=names, 

900 verify_integrity=False, 

901 ) 

902 if not consistent_sorting and len(ob_index) > 0: 

903 # Sort by the levels where the corresponding sort argument is True 

904 n_levels = len(sorts) 

905 drop_levels = [ 

906 n_levels - idx 

907 for idx, sort in enumerate(reversed(sorts), 1) 

908 if not sort 

909 ] 

910 if len(drop_levels) > 0: 

911 sorter = ob_index._drop_level_numbers(drop_levels).argsort() 

912 else: 

913 sorter = ob_index.argsort() 

914 ob_index = ob_index.take(sorter) 

915 _, index = np.unique(sorter, return_index=True) 

916 ob_ids = np.where(ob_ids == -1, -1, index.take(ob_ids)) 

917 ob_ids = ensure_platform_int(ob_ids) 

918 return ob_index, ob_ids 

919 

920 def _unob_index_and_ids( 

921 self, 

922 levels: list[Index], 

923 codes: list[npt.NDArray[np.intp]], 

924 names: list[Hashable], 

925 ) -> tuple[MultiIndex, npt.NDArray[np.intp]]: 

926 shape = tuple(len(level) for level in levels) 

927 unob_ids = get_group_index(codes, shape, sort=True, xnull=True) 

928 unob_index = MultiIndex.from_product(levels, names=names) 

929 unob_ids = ensure_platform_int(unob_ids) 

930 return unob_index, unob_ids 

931 

932 @final 

933 def get_group_levels(self) -> Generator[Index]: 

934 # Note: only called from _insert_inaxis_grouper, which 

935 # is only called for BaseGrouper, never for BinGrouper 

936 result_index = self.result_index 

937 if len(self.groupings) == 1: 

938 yield result_index 

939 else: 

940 for level in range(result_index.nlevels - 1, -1, -1): 

941 yield result_index.get_level_values(level) 

942 

943 # ------------------------------------------------------------ 

944 # Aggregation functions 

945 

946 @final 

947 def _cython_operation( 

948 self, 

949 kind: str, 

950 values, 

951 how: str, 

952 axis: AxisInt, 

953 min_count: int = -1, 

954 **kwargs, 

955 ) -> ArrayLike: 

956 """ 

957 Returns the values of a cython operation. 

958 """ 

959 assert kind in ["transform", "aggregate"] 

960 

961 cy_op = WrappedCythonOp(kind=kind, how=how, has_dropped_na=self.has_dropped_na) 

962 

963 return cy_op.cython_operation( 

964 values=values, 

965 axis=axis, 

966 min_count=min_count, 

967 comp_ids=self.ids, 

968 ngroups=self.ngroups, 

969 **kwargs, 

970 ) 

971 

972 @final 

973 def agg_series( 

974 self, obj: Series, func: Callable, preserve_dtype: bool = False 

975 ) -> ArrayLike: 

976 """ 

977 Parameters 

978 ---------- 

979 obj : Series 

980 func : function taking a Series and returning a scalar-like 

981 preserve_dtype : bool 

982 Whether the aggregation is known to be dtype-preserving. 

983 

984 Returns 

985 ------- 

986 np.ndarray or ExtensionArray 

987 """ 

988 result = self._aggregate_series_pure_python(obj, func) 

989 return obj.array._cast_pointwise_result(result) 

990 

991 @final 

992 def _aggregate_series_pure_python( 

993 self, obj: Series, func: Callable 

994 ) -> npt.NDArray[np.object_]: 

995 result = np.empty(self.ngroups, dtype="O") 

996 initialized = False 

997 

998 splitter = self._get_splitter(obj) 

999 

1000 for i, group in enumerate(splitter): 

1001 res = func(group) 

1002 res = extract_result(res) 

1003 

1004 if not initialized: 

1005 # We only do this validation on the first iteration 

1006 check_result_array(res, group.dtype) 

1007 initialized = True 

1008 

1009 result[i] = res 

1010 

1011 return result 

1012 

1013 @final 

1014 def apply_groupwise( 

1015 self, f: Callable, data: DataFrame | Series 

1016 ) -> tuple[list, bool]: 

1017 mutated = False 

1018 splitter = self._get_splitter(data) 

1019 group_keys = self.result_index 

1020 result_values = [] 

1021 

1022 # This calls DataSplitter.__iter__ 

1023 zipped = zip(group_keys, splitter, strict=True) 

1024 

1025 for key, group in zipped: 

1026 # Pinning name is needed for 

1027 # test_group_apply_once_per_group, 

1028 # test_inconsistent_return_type, test_set_group_name, 

1029 # test_group_name_available_in_inference_pass, 

1030 # test_groupby_multi_timezone 

1031 object.__setattr__(group, "name", key) 

1032 

1033 # group might be modified 

1034 group_axes = group.axes 

1035 res = f(group) 

1036 if not mutated and not _is_indexed_like(res, group_axes): 

1037 mutated = True 

1038 result_values.append(res) 

1039 # getattr pattern for __name__ is needed for functools.partial objects 

1040 if len(group_keys) == 0 and getattr(f, "__name__", None) in [ 

1041 "skew", 

1042 "kurt", 

1043 "sum", 

1044 "prod", 

1045 ]: 

1046 # If group_keys is empty, then no function calls have been made, 

1047 # so we will not have raised even if this is an invalid dtype. 

1048 # So do one dummy call here to raise appropriate TypeError. 

1049 f(data.iloc[:0]) 

1050 

1051 return result_values, mutated 

1052 

1053 # ------------------------------------------------------------ 

1054 # Methods for sorting subsets of our GroupBy's object 

1055 

1056 @final 

1057 @cache_readonly 

1058 def _sorted_ids(self) -> npt.NDArray[np.intp]: 

1059 result = self.ids.take(self.result_ilocs) 

1060 if getattr(self, "dropna", True): 

1061 # BinGrouper has no dropna 

1062 result = result[result >= 0] 

1063 return result 

1064 

1065 

1066class BinGrouper(BaseGrouper): 

1067 """ 

1068 This is an internal Grouper class 

1069 

1070 Parameters 

1071 ---------- 

1072 bins : the split index of binlabels to group the item of axis 

1073 binlabels : the label list 

1074 indexer : np.ndarray[np.intp], optional 

1075 the indexer created by Grouper 

1076 some groupers (TimeGrouper) will sort its axis and its 

1077 group_info is also sorted, so need the indexer to reorder 

1078 

1079 Examples 

1080 -------- 

1081 bins: [2, 4, 6, 8, 10] 

1082 binlabels: DatetimeIndex(['2005-01-01', '2005-01-03', 

1083 '2005-01-05', '2005-01-07', '2005-01-09'], 

1084 dtype='datetime64[ns]', freq='2D') 

1085 

1086 the group_info, which contains the label of each item in grouped 

1087 axis, the index of label in label list, group number, is 

1088 

1089 (array([0, 0, 1, 1, 2, 2, 3, 3, 4, 4]), array([0, 1, 2, 3, 4]), 5) 

1090 

1091 means that, the grouped axis has 10 items, can be grouped into 5 

1092 labels, the first and second items belong to the first label, the 

1093 third and forth items belong to the second label, and so on 

1094 

1095 """ 

1096 

1097 bins: npt.NDArray[np.int64] 

1098 binlabels: Index 

1099 

1100 def __init__( 

1101 self, 

1102 bins, 

1103 binlabels, 

1104 indexer=None, 

1105 ) -> None: 

1106 self.bins = ensure_int64(bins) 

1107 self.binlabels = ensure_index(binlabels) 

1108 self.indexer = indexer 

1109 

1110 # These lengths must match, otherwise we could call agg_series 

1111 # with empty self.bins, which would raise later. 

1112 assert len(self.binlabels) == len(self.bins) 

1113 

1114 @cache_readonly 

1115 def groups(self): 

1116 """dict {group name -> group labels}""" 

1117 # this is mainly for compat 

1118 # GH 3881 

1119 result = { 

1120 key: value 

1121 for key, value in zip(self.binlabels, self.bins, strict=True) 

1122 if key is not NaT 

1123 } 

1124 return result 

1125 

1126 @property 

1127 def nkeys(self) -> int: 

1128 # still matches len(self.groupings), but we can hard-code 

1129 return 1 

1130 

1131 @cache_readonly 

1132 def codes_info(self) -> npt.NDArray[np.intp]: 

1133 # return the codes of items in original grouped axis 

1134 ids = self.ids 

1135 if self.indexer is not None: 

1136 sorter = np.lexsort((ids, self.indexer)) 

1137 ids = ids[sorter] 

1138 return ids 

1139 

1140 def get_iterator(self, data: NDFrame): 

1141 """ 

1142 Groupby iterator 

1143 

1144 Returns 

1145 ------- 

1146 Generator yielding sequence of (name, subsetted object) 

1147 for each group 

1148 """ 

1149 slicer = lambda start, edge: data.iloc[start:edge] 

1150 

1151 start: np.int64 | int = 0 

1152 for edge, label in zip(self.bins, self.binlabels, strict=True): 

1153 if label is not NaT: 

1154 yield label, slicer(start, edge) 

1155 start = edge 

1156 

1157 if start < len(data): 

1158 yield self.binlabels[-1], slicer(start, None) 

1159 

1160 @cache_readonly 

1161 def indices(self): 

1162 indices = collections.defaultdict(list) 

1163 

1164 i: np.int64 | int = 0 

1165 for label, bin in zip(self.binlabels, self.bins, strict=True): 

1166 if i < bin: 

1167 if label is not NaT: 

1168 indices[label] = list(range(i, bin)) 

1169 i = bin 

1170 return indices 

1171 

1172 @cache_readonly 

1173 def codes(self) -> list[npt.NDArray[np.intp]]: 

1174 return [self.ids] 

1175 

1176 @cache_readonly 

1177 def result_index_and_ids(self): 

1178 result_index = self.binlabels 

1179 if len(self.binlabels) != 0 and isna(self.binlabels[0]): 

1180 result_index = result_index[1:] 

1181 

1182 ngroups = len(result_index) 

1183 rep = np.diff(np.r_[0, self.bins]) 

1184 

1185 rep = ensure_platform_int(rep) 

1186 if ngroups == len(self.bins): 

1187 ids = np.repeat(np.arange(ngroups), rep) 

1188 else: 

1189 ids = np.repeat(np.r_[-1, np.arange(ngroups)], rep) 

1190 ids = ensure_platform_int(ids) 

1191 

1192 return result_index, ids 

1193 

1194 @property 

1195 def levels(self) -> list[Index]: 

1196 return [self.binlabels] 

1197 

1198 @property 

1199 def names(self) -> list[Hashable]: 

1200 return [self.binlabels.name] 

1201 

1202 @property 

1203 def groupings(self) -> list[grouper.Grouping]: 

1204 lev = self.binlabels 

1205 codes = self.ids 

1206 labels = lev.take(codes) 

1207 ping = grouper.Grouping( 

1208 labels, labels, in_axis=False, level=None, uniques=lev._values 

1209 ) 

1210 return [ping] 

1211 

1212 @property 

1213 def observed_grouper(self) -> BinGrouper: 

1214 return self 

1215 

1216 

1217def _is_indexed_like(obj, axes) -> bool: 

1218 if isinstance(obj, Series): 

1219 if len(axes) > 1: 

1220 return False 

1221 return obj.index.equals(axes[0]) 

1222 elif isinstance(obj, DataFrame): 

1223 return obj.index.equals(axes[0]) 

1224 

1225 return False 

1226 

1227 

1228# ---------------------------------------------------------------------- 

1229# Splitting / application 

1230 

1231 

1232class DataSplitter(Generic[NDFrameT]): 

1233 def __init__( 

1234 self, 

1235 data: NDFrameT, 

1236 ngroups: int, 

1237 *, 

1238 sort_idx: npt.NDArray[np.intp], 

1239 sorted_ids: npt.NDArray[np.intp], 

1240 ) -> None: 

1241 self.data = data 

1242 self.ngroups = ngroups 

1243 

1244 self._slabels = sorted_ids 

1245 self._sort_idx = sort_idx 

1246 

1247 def __iter__(self) -> Iterator: 

1248 if self.ngroups == 0: 

1249 # we are inside a generator, rather than raise StopIteration 

1250 # we merely return signal the end 

1251 return 

1252 

1253 starts, ends = lib.generate_slices(self._slabels, self.ngroups) 

1254 sdata = self._sorted_data 

1255 for start, end in zip(starts, ends, strict=True): 

1256 yield self._chop(sdata, slice(start, end)) 

1257 

1258 @cache_readonly 

1259 def _sorted_data(self) -> NDFrameT: 

1260 return self.data.take(self._sort_idx, axis=0) 

1261 

1262 def _chop(self, sdata, slice_obj: slice) -> NDFrame: 

1263 raise AbstractMethodError(self) 

1264 

1265 

1266class SeriesSplitter(DataSplitter): 

1267 def _chop(self, sdata: Series, slice_obj: slice) -> Series: 

1268 # fastpath equivalent to `sdata.iloc[slice_obj]` 

1269 mgr = sdata._mgr.get_slice(slice_obj) 

1270 ser = sdata._constructor_from_mgr(mgr, axes=mgr.axes) 

1271 ser._name = sdata.name 

1272 return ser.__finalize__(sdata, method="groupby") 

1273 

1274 

1275class FrameSplitter(DataSplitter): 

1276 def _chop(self, sdata: DataFrame, slice_obj: slice) -> DataFrame: 

1277 # Fastpath equivalent to: 

1278 # return sdata.iloc[slice_obj] 

1279 mgr = sdata._mgr.get_slice(slice_obj, axis=1) 

1280 df = sdata._constructor_from_mgr(mgr, axes=mgr.axes) 

1281 return df.__finalize__(sdata, method="groupby")