Skip to content

Commit b350599

Browse files
jbrockmendelclaude
andcommitted
BUG: object/EA reductions honour skipna=False (GH#4147, GH#18588)
Accumulated review fixes across the object-dtype reduction work: idxmin/idxmax object fill, the groupby _agg_py_fallback skipna closures, nansum/nanprod, the whole-array short-circuit, and the accompanying tests and whatsnew entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4908b2e commit b350599

6 files changed

Lines changed: 519 additions & 52 deletions

File tree

doc/source/whatsnew/v3.1.0.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,8 +380,12 @@ Timezones
380380

381381
Numeric
382382
^^^^^^^
383-
- Bug in :meth:`DataFrame.min`, :meth:`DataFrame.max`, :meth:`Series.min`, and :meth:`Series.max` on object-dtype data raising ``TypeError`` when missing values were present alongside values that do not support comparison with ``float`` (e.g. strings, :class:`datetime.date` objects, or mixed-timezone :class:`Timestamp` objects) (:issue:`4147`, :issue:`18588`, :issue:`24109`, :issue:`58707`, :issue:`61204`, :issue:`65500`)
384-
- Bug in :meth:`DataFrame.min`, :meth:`DataFrame.max`, :meth:`Series.min`, and :meth:`Series.max` on object-dtype data with ``skipna=False`` failing to propagate missing values, either raising ``TypeError`` or returning a value computed as though the missing values were absent; they now return a missing value like other dtypes do (:issue:`4147`)
383+
- Bug in :meth:`DataFrame.idxmin`, :meth:`DataFrame.idxmax`, :meth:`Series.idxmin`, :meth:`Series.idxmax`, :meth:`Series.argmin`, :meth:`Series.argmax`, :meth:`Index.argmin`, and :meth:`Index.argmax` on object-dtype data raising ``TypeError`` when missing values were present alongside values that do not support comparison with ``float`` (e.g. strings or :class:`datetime.date` objects); with ``skipna=False`` they now raise ``ValueError`` like other dtypes instead of ``TypeError`` (:issue:`4147`)
384+
- Bug in :meth:`DataFrame.min`, :meth:`DataFrame.max`, :meth:`Series.min`, :meth:`Series.max`, :meth:`Index.min`, and :meth:`Index.max` on object-dtype data raising ``TypeError`` when missing values were present alongside values that do not support comparison with ``float`` (e.g. strings, :class:`datetime.date` objects, or mixed-timezone :class:`Timestamp` objects) (:issue:`4147`, :issue:`18588`, :issue:`24109`, :issue:`58707`, :issue:`61204`, :issue:`65500`)
385+
- Bug in :meth:`DataFrame.min` and :meth:`DataFrame.max` with ``axis=1`` ignoring ``skipna=False`` for :class:`StringDtype`, :class:`IntervalDtype`, and other extension dtypes, returning a value where a missing value was expected (:issue:`18588`)
386+
- Bug in :meth:`DataFrame.min`, :meth:`DataFrame.max`, :meth:`Series.min`, and :meth:`Series.max` on object-dtype data with ``skipna=False`` failing to propagate missing values, either raising ``TypeError`` or returning a value computed as though the missing values were absent; they now return a missing value (:issue:`4147`)
387+
- Bug in :meth:`DataFrame.sum` and :meth:`DataFrame.prod` with ``axis=1`` ignoring ``skipna=False`` for extension dtypes, returning a value where a missing value was expected (:issue:`18588`)
388+
- Bug in :meth:`DataFrame.sum`, :meth:`DataFrame.prod`, :meth:`Series.sum`, and :meth:`Series.prod` on object-dtype data with ``skipna=False`` raising ``TypeError`` instead of returning a missing value (:issue:`4147`)
385389
- Fixed bug in :func:`read_excel` where having a column with mixture of numeric and boolean values will typecast the values based on the first appearance data type since 1==True and 0==False (:issue:`60088`)
386390
- Fixed bug in :meth:`DataFrame.idxmax` and :meth:`DataFrame.idxmin` returning incorrect row labels for nullable ``UInt64`` columns containing missing values alongside values above ``2**53`` (:issue:`64478`)
387391
- Fixed bug in :meth:`DataFrame.idxmax` and :meth:`DataFrame.idxmin` with ``axis=1`` and ``skipna=False`` returning incorrect column labels for extension array dtypes (e.g. :class:`BooleanDtype`, nullable integer/float, :class:`ArrowDtype`) (:issue:`56903`)
@@ -572,6 +576,8 @@ Groupby/resample/rolling
572576
- Bug in :meth:`.DataFrameGroupBy.apply` with ``as_index=False`` where applying on an empty :class:`DataFrame` returned inconsistent index metadata compared to non-empty results (:issue:`48135`)
573577
- Bug in :meth:`.DataFrameGroupBy.cumprod`, :meth:`.DataFrameGroupBy.cummin`, and :meth:`.DataFrameGroupBy.cummax` (and Series variants) returning ``Float64`` instead of preserving the nullable integer dtype (e.g. ``Int64``) when the group key contains ``NA`` (:issue:`65550`)
574578
- Bug in :meth:`.DataFrameGroupBy.idxmax`, :meth:`.DataFrameGroupBy.idxmin` (and :class:`Series` variants) with ``skipna=False`` returning incorrect results when the input contained no ``NA`` values (:issue:`56903`)
579+
- Bug in :meth:`.DataFrameGroupBy.min` and :meth:`.DataFrameGroupBy.max` (and :class:`Series` variants) ignoring ``skipna=False`` for object, :class:`StringDtype`, :class:`IntervalDtype`, and other extension dtypes, returning a value where a missing value was expected (:issue:`18588`)
580+
- Bug in :meth:`.DataFrameGroupBy.sum` and :meth:`.DataFrameGroupBy.prod` (and :class:`Series` variants) ignoring ``skipna=False`` for extension dtypes, and for object dtype with ``prod``, returning a value where a missing value was expected (:issue:`18588`)
575581
- Bug in :meth:`.GroupBy.any` and :meth:`.GroupBy.all` returning ``NaN`` with ``float64`` dtype for unobserved categorical groups on NumPy ``bool`` data instead of the boolean identity value with ``bool`` dtype (:issue:`65100`)
576582
- Bug in :meth:`.GroupBy.quantile` returning incorrect results for groups containing a non-null ``NaN`` value (e.g. from a pyarrow or masked float array), and returning the Unix epoch instead of ``NaT`` for all-``NaT`` datetime-like groups on some platforms (:issue:`64330`)
577583
- Bug in :meth:`.Resampler.agg` raising ``ValueError`` with a dict of aggregations when applied to a :meth:`DataFrame.groupby` with ``as_index=False`` (:issue:`52397`)

pandas/core/groupby/groupby.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2876,6 +2876,11 @@ def sum(
28762876
skipna=skipna,
28772877
)
28782878
else:
2879+
2880+
def sum_compat(obj: NDFrameT):
2881+
# GH#18588: see min_compat below
2882+
return obj.sum(skipna=skipna)
2883+
28792884
# If we are grouping on categoricals we want unobserved categories to
28802885
# return zero, rather than the default of NaN which the reindexing in
28812886
# _agg_general() returns. GH #31422
@@ -2884,7 +2889,7 @@ def sum(
28842889
numeric_only=numeric_only,
28852890
min_count=min_count,
28862891
alias="sum",
2887-
npfunc=np.sum,
2892+
npfunc=sum_compat,
28882893
skipna=skipna,
28892894
)
28902895

@@ -2965,12 +2970,17 @@ def prod(
29652970
1 16 10
29662971
2 30 72
29672972
"""
2973+
2974+
def prod_compat(obj: NDFrameT):
2975+
# GH#18588: see min_compat below
2976+
return obj.prod(skipna=skipna)
2977+
29682978
return self._agg_general(
29692979
numeric_only=numeric_only,
29702980
min_count=min_count,
29712981
skipna=skipna,
29722982
alias="prod",
2973-
npfunc=np.prod,
2983+
npfunc=prod_compat,
29742984
)
29752985

29762986
@final
@@ -3082,12 +3092,19 @@ def min(
30823092
skipna=skipna,
30833093
)
30843094
else:
3095+
3096+
def min_compat(obj: NDFrameT):
3097+
# GH#18588: object/string dtypes have no cython group_min_max
3098+
# and reduce through this alt instead, so it has to apply
3099+
# skipna itself; np.min would always skip.
3100+
return obj.min(skipna=skipna)
3101+
30853102
return self._agg_general(
30863103
numeric_only=numeric_only,
30873104
min_count=min_count,
30883105
skipna=skipna,
30893106
alias="min",
3090-
npfunc=np.min,
3107+
npfunc=min_compat,
30913108
)
30923109

30933110
@final
@@ -3199,12 +3216,17 @@ def max(
31993216
skipna=skipna,
32003217
)
32013218
else:
3219+
3220+
def max_compat(obj: NDFrameT):
3221+
# GH#18588: see min_compat above
3222+
return obj.max(skipna=skipna)
3223+
32023224
return self._agg_general(
32033225
numeric_only=numeric_only,
32043226
min_count=min_count,
32053227
skipna=skipna,
32063228
alias="max",
3207-
npfunc=np.max,
3229+
npfunc=max_compat,
32083230
)
32093231

32103232
@final

pandas/core/nanops.py

Lines changed: 160 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,30 @@ def nansum(
663663
np.float64(3.0)
664664
"""
665665
dtype = values.dtype
666-
values, mask = _get_values(values, skipna, fill_value=0, mask=mask)
666+
if dtype == object and not skipna:
667+
if mask is None:
668+
mask = isna(values)
669+
if mask.any():
670+
# GH#4147: an object NA that supports addition propagates on its
671+
# own and carries more type information than a bare nan would
672+
# (pd.NA, Decimal("NaN"), NaT), so prefer it. Step in only when it
673+
# does not, e.g. None, or values that cannot be added at all.
674+
try:
675+
the_sum = values.sum(axis, dtype=_get_dtype_max(dtype))
676+
except TypeError:
677+
pass
678+
else:
679+
return _maybe_null_out(
680+
the_sum, axis, mask, values.shape, min_count=min_count
681+
)
682+
if values.ndim == 1 or axis is None:
683+
# error: Incompatible return value type (got "Union[Scalar,
684+
# ndarray]", expected "Union[ndarray, float, NaTType]")
685+
return _na_for_min_count(values, axis) # type: ignore[return-value]
686+
values = _blank_object_na_slices(values, mask, axis, 0)
687+
min_count = _na_min_count(values, axis, min_count)
688+
else:
689+
values, mask = _get_values(values, skipna, fill_value=0, mask=mask)
667690
dtype_sum = _get_dtype_max(dtype)
668691
if dtype.kind == "f":
669692
# GH#43929 float16 sum overflows easily; upcast like numpy does
@@ -1131,6 +1154,102 @@ def nansem(
11311154
return np.sqrt(var) / np.sqrt(count)
11321155

11331156

1157+
def _blank_object_na_slices(
1158+
values: np.ndarray,
1159+
mask: npt.NDArray[np.bool_],
1160+
axis: AxisInt,
1161+
fill_value: Any,
1162+
) -> np.ndarray:
1163+
"""
1164+
Replace every reduction slice of an object-dtype array that holds an NA
1165+
with ``fill_value``.
1166+
1167+
For ``skipna=False`` those slices are nulled out afterwards, so their values
1168+
never reach the result and need not even support the operation being applied
1169+
(GH#4147). A float NaN propagates through arithmetic and comparison on its
1170+
own; an object NA does not, and raises instead.
1171+
1172+
Only for reductions over an axis of a 2-D array. When the reduction covers
1173+
the whole array the result is simply NA, and callers short-circuit instead.
1174+
"""
1175+
any_na = np.expand_dims(mask.any(axis=axis), axis)
1176+
return np.where(any_na, fill_value, values)
1177+
1178+
1179+
def _na_min_count(values: np.ndarray, axis: AxisInt, min_count: int) -> int:
1180+
"""
1181+
``min_count`` that nulls out any slice holding an NA, without weakening an
1182+
explicit larger ``min_count`` from the caller (GH#4147).
1183+
"""
1184+
return max(min_count, values.shape[axis])
1185+
1186+
1187+
def _fill_object_na(
1188+
values: np.ndarray,
1189+
mask: npt.NDArray[np.bool_],
1190+
axis: AxisInt | None,
1191+
fill_value: Any,
1192+
) -> np.ndarray:
1193+
"""
1194+
Replace NA entries in an object-dtype array with a valid value taken from
1195+
the same reduction slice.
1196+
1197+
GH#65500: the +/-inf fill used for other dtypes is not comparable with
1198+
arbitrary objects (Timestamps, strings, tuples) and raises. Substituting a
1199+
value already present in the slice cannot change a min/max, and for
1200+
argmin/argmax a result landing on a filled position is corrected by
1201+
``_maybe_fix_arg_at_na``. Entirely-NA slices get ``fill_value``, since
1202+
their result is discarded by the caller either way.
1203+
"""
1204+
if mask.all():
1205+
return np.full(values.shape, fill_value, dtype=object)
1206+
if values.ndim == 1 or axis is None:
1207+
fill = np.empty(1, dtype=object)
1208+
# wrap in an array so that e.g. tuple fill values
1209+
# are not broadcast by np.where
1210+
fill[0] = values[~mask][0]
1211+
return np.where(mask, fill, values)
1212+
indexer = np.expand_dims((~mask).argmax(axis=axis), axis)
1213+
fill = np.take_along_axis(values, indexer, axis=axis)
1214+
# argmax selects an NA entry for entirely-NA slices; fill
1215+
# those with +/-inf instead so the reduction does not warn
1216+
all_na = np.expand_dims(mask.all(axis=axis), axis)
1217+
fill = np.where(all_na, fill_value, fill)
1218+
return np.where(mask, fill, values)
1219+
1220+
1221+
def _get_arg_values(
1222+
values: np.ndarray,
1223+
*,
1224+
fill_value_typ: str,
1225+
mask: npt.NDArray[np.bool_] | None,
1226+
skipna: bool,
1227+
axis: AxisInt | None = None,
1228+
) -> tuple[np.ndarray, npt.NDArray[np.bool_] | None]:
1229+
"""
1230+
Prepare values for nanargmin/nanargmax by filling NA entries so that the
1231+
comparison does not see them, and raise for ``skipna=False``.
1232+
1233+
Mirrors ``_get_values(values, True, ...)`` except for object dtype, where
1234+
the +/-inf fill is not comparable with arbitrary objects (GH#4147). The
1235+
object fill is a same-slice value, which *can* win the comparison; a result
1236+
landing on it is corrected by ``_maybe_fix_arg_at_na``.
1237+
"""
1238+
if values.dtype == object:
1239+
if mask is None:
1240+
mask = isna(values)
1241+
if not skipna and mask.any():
1242+
# _maybe_arg_null_out would raise this once the argmin/argmax is
1243+
# known, but the comparison must not run first: values in a slice
1244+
# holding an NA need not be comparable with each other (GH#4147)
1245+
raise ValueError("Encountered an NA value with skipna=False")
1246+
if mask.any():
1247+
fill_value = _get_fill_value(values.dtype, fill_value_typ=fill_value_typ)
1248+
values = _fill_object_na(values, mask, axis, fill_value)
1249+
return values, mask
1250+
return _get_values(values, True, fill_value_typ=fill_value_typ, mask=mask)
1251+
1252+
11341253
def _nanminmax(meth, fill_value_typ):
11351254
@bottleneck_switch(name=f"nan{meth}")
11361255
@_datetimelike_compat
@@ -1148,35 +1267,21 @@ def reduction(
11481267
min_count = 1
11491268
if dtype == object:
11501269
# GH#65500: _get_values' +/-inf fill isn't comparable with arbitrary
1151-
# objects (Timestamps, strings) and raises. Fill NAs from the same
1152-
# slice instead (leaves min/max unchanged); all-NA slices keep the
1153-
# +/-inf fill since _maybe_null_out discards them anyway.
1270+
# objects (Timestamps, strings) and raises; see _fill_object_na.
11541271
if mask is None:
11551272
mask = isna(values)
1156-
if not skipna:
1157-
# GH#4147: a float NaN propagates through the comparison on its
1158-
# own, but an object NA does not, so it used to be compared
1159-
# as-is. Fill it like the skipna case so nothing raises, then
1160-
# null out every slice that held one to match float64/str/dt64.
1161-
min_count = values.size if axis is None else values.shape[axis]
11621273
if mask.any():
11631274
fill_value = _get_fill_value(dtype, fill_value_typ=fill_value_typ)
1164-
if mask.all():
1165-
values = np.full(values.shape, fill_value, dtype=object)
1275+
if skipna:
1276+
values = _fill_object_na(values, mask, axis, fill_value)
11661277
elif values.ndim == 1 or axis is None:
1167-
fill = np.empty(1, dtype=object)
1168-
# wrap in an array so that e.g. tuple fill values
1169-
# are not broadcast by np.where
1170-
fill[0] = values[~mask][0]
1171-
values = np.where(mask, fill, values)
1278+
# GH#4147: an object NA does not propagate through the
1279+
# comparison the way a float NaN does, so null out every
1280+
# slice that holds one, matching float64/str/dt64.
1281+
return _na_for_min_count(values, axis)
11721282
else:
1173-
indexer = np.expand_dims((~mask).argmax(axis=axis), axis)
1174-
fill = np.take_along_axis(values, indexer, axis=axis)
1175-
# argmax selects an NA entry for entirely-NA slices; fill
1176-
# those with +/-inf instead so the reduction does not warn
1177-
all_na = np.expand_dims(mask.all(axis=axis), axis)
1178-
fill = np.where(all_na, fill_value, fill)
1179-
values = np.where(mask, fill, values)
1283+
values = _blank_object_na_slices(values, mask, axis, fill_value)
1284+
min_count = _na_min_count(values, axis, min_count)
11801285
else:
11811286
values, mask = _get_values(
11821287
values, skipna, fill_value_typ=fill_value_typ, mask=mask
@@ -1237,7 +1342,9 @@ def nanargmax(
12371342
>>> nanops.nanargmax(arr, axis=1)
12381343
array([2, 2, 1, 1])
12391344
"""
1240-
values, mask = _get_values(values, True, fill_value_typ="-inf", mask=mask)
1345+
values, mask = _get_arg_values(
1346+
values, fill_value_typ="-inf", mask=mask, skipna=skipna, axis=axis
1347+
)
12411348
result = values.argmax(axis)
12421349
# error: Argument 1 to "_maybe_fix_arg_at_na" has incompatible type "Any |
12431350
# signedinteger[Any]"; expected "ndarray[Any, Any]"
@@ -1284,7 +1391,9 @@ def nanargmin(
12841391
>>> nanops.nanargmin(arr, axis=1)
12851392
array([0, 0, 1, 1])
12861393
"""
1287-
values, mask = _get_values(values, True, fill_value_typ="+inf", mask=mask)
1394+
values, mask = _get_arg_values(
1395+
values, fill_value_typ="+inf", mask=mask, skipna=skipna, axis=axis
1396+
)
12881397
result = values.argmin(axis)
12891398
# error: Argument 1 to "_maybe_fix_arg_at_na" has incompatible type "Any |
12901399
# signedinteger[Any]"; expected "ndarray[Any, Any]"
@@ -1443,7 +1552,26 @@ def nanprod(
14431552
"""
14441553
mask = _maybe_get_mask(values, skipna, mask)
14451554

1446-
if skipna and mask is not None:
1555+
if values.dtype == object and not skipna:
1556+
# GH#4147: see nansum
1557+
if mask is None:
1558+
mask = isna(values)
1559+
if mask.any():
1560+
try:
1561+
result = values.prod(axis)
1562+
except TypeError:
1563+
pass
1564+
else:
1565+
return _maybe_null_out( # type: ignore[return-value]
1566+
result, axis, mask, values.shape, min_count=min_count
1567+
)
1568+
if values.ndim == 1 or axis is None:
1569+
# error: Incompatible return value type (got "Union[Scalar,
1570+
# ndarray]", expected "float")
1571+
return _na_for_min_count(values, axis) # type: ignore[return-value]
1572+
values = _blank_object_na_slices(values, mask, axis, 1)
1573+
min_count = _na_min_count(values, axis, min_count)
1574+
elif skipna and mask is not None:
14471575
values = values.copy()
14481576
values[mask] = 1
14491577
result = values.prod(axis)
@@ -1459,10 +1587,11 @@ def _maybe_fix_arg_at_na(
14591587
mask: npt.NDArray[np.bool_] | None,
14601588
axis: AxisInt | None,
14611589
) -> np.ndarray:
1462-
# helper function for nanargmin/nanargmax: an argmin/argmax that landed on
1463-
# a masked position means the extremum equals the sentinel fill, so every
1464-
# unmasked value ties with the fill; the first unmasked position is the
1465-
# correct result (GH#64478)
1590+
# helper function for nanargmin/nanargmax. Masked positions carry either
1591+
# the +/-inf sentinel or, for object dtype, the value at the first unmasked
1592+
# position (GH#4147). Either way an argmin/argmax landing on a masked
1593+
# position means the fill is the extremum, so the first unmasked position
1594+
# holds it and is the correct result (GH#64478)
14661595
if mask is None or not mask.any():
14671596
return result
14681597
if axis is None or mask.ndim == 1:

0 commit comments

Comments
 (0)