Skip to content

Commit 363ff8e

Browse files
authored
ENH: Allow Series.apply to accept list-like and dict-like (#39141)
1 parent 8e0acdf commit 363ff8e

File tree

4 files changed

+97
-14
lines changed

4 files changed

+97
-14
lines changed

doc/source/whatsnew/v1.3.0.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ Other enhancements
5050
- :func:`pandas.read_excel` can now auto detect .xlsb files (:issue:`35416`)
5151
- :meth:`.Rolling.sum`, :meth:`.Expanding.sum`, :meth:`.Rolling.mean`, :meth:`.Expanding.mean`, :meth:`.Rolling.median`, :meth:`.Expanding.median`, :meth:`.Rolling.max`, :meth:`.Expanding.max`, :meth:`.Rolling.min`, and :meth:`.Expanding.min` now support ``Numba`` execution with the ``engine`` keyword (:issue:`38895`)
5252
- :meth:`DataFrame.apply` can now accept NumPy unary operators as strings, e.g. ``df.apply("sqrt")``, which was already the case for :meth:`Series.apply` (:issue:`39116`)
53-
- :meth:`DataFrame.apply` can now accept non-callable :class:`DataFrame` properties as strings, e.g. ``df.apply("size")``, which was already the case for :meth:`Series.apply` (:issue:`39116`)
53+
- :meth:`DataFrame.apply` can now accept non-callable DataFrame properties as strings, e.g. ``df.apply("size")``, which was already the case for :meth:`Series.apply` (:issue:`39116`)
54+
- :meth:`Series.apply` can now accept list-like or dictionary-like arguments that aren't lists or dictionaries, e.g. ``ser.apply(np.array(["sum", "mean"]))``, which was already the case for :meth:`DataFrame.apply` (:issue:`39140`)
5455

5556
.. ---------------------------------------------------------------------------
5657

pandas/core/aggregation.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,8 @@ def agg_dict_like(
704704
# if we have a dict of any non-scalars
705705
# eg. {'A' : ['mean']}, normalize all to
706706
# be list-likes
707-
if any(is_aggregator(x) for x in arg.values()):
707+
# Cannot use arg.values() because arg may be a Series
708+
if any(is_aggregator(x) for _, x in arg.items()):
708709
new_arg: AggFuncTypeDict = {}
709710
for k, v in arg.items():
710711
if not isinstance(v, (tuple, list, dict)):

pandas/core/apply.py

+20-11
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,20 @@ def maybe_apply_str(self) -> Optional[FrameOrSeriesUnion]:
195195
self.kwds["axis"] = self.axis
196196
return self.obj._try_aggregate_string_function(f, *self.args, **self.kwds)
197197

198+
def maybe_apply_multiple(self) -> Optional[FrameOrSeriesUnion]:
199+
"""
200+
Compute apply in case of a list-like or dict-like.
201+
202+
Returns
203+
-------
204+
result: Series, DataFrame, or None
205+
Result when self.f is a list-like or dict-like, None otherwise.
206+
"""
207+
# Note: dict-likes are list-like
208+
if not is_list_like(self.f):
209+
return None
210+
return self.obj.aggregate(self.f, self.axis, *self.args, **self.kwds)
211+
198212

199213
class FrameApply(Apply):
200214
obj: DataFrame
@@ -248,12 +262,9 @@ def agg_axis(self) -> Index:
248262
def apply(self) -> FrameOrSeriesUnion:
249263
""" compute the results """
250264
# dispatch to agg
251-
if is_list_like(self.f) or is_dict_like(self.f):
252-
# pandas\core\apply.py:144: error: "aggregate" of "DataFrame" gets
253-
# multiple values for keyword argument "axis"
254-
return self.obj.aggregate( # type: ignore[misc]
255-
self.f, axis=self.axis, *self.args, **self.kwds
256-
)
265+
result = self.maybe_apply_multiple()
266+
if result is not None:
267+
return result
257268

258269
# all empty
259270
if len(self.columns) == 0 and len(self.index) == 0:
@@ -587,16 +598,14 @@ def __init__(
587598

588599
def apply(self) -> FrameOrSeriesUnion:
589600
obj = self.obj
590-
func = self.f
591-
args = self.args
592-
kwds = self.kwds
593601

594602
if len(obj) == 0:
595603
return self.apply_empty_result()
596604

597605
# dispatch to agg
598-
if isinstance(func, (list, dict)):
599-
return obj.aggregate(func, *args, **kwds)
606+
result = self.maybe_apply_multiple()
607+
if result is not None:
608+
return result
600609

601610
# if we are a string, try to dispatch
602611
result = self.maybe_apply_str()

pandas/tests/series/apply/test_series_apply.py

+73-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pandas.core.dtypes.common import is_number
88

99
import pandas as pd
10-
from pandas import DataFrame, Index, MultiIndex, Series, isna, timedelta_range
10+
from pandas import DataFrame, Index, MultiIndex, Series, concat, isna, timedelta_range
1111
import pandas._testing as tm
1212
from pandas.core.base import SpecificationError
1313

@@ -827,3 +827,75 @@ def test_apply_to_timedelta(self):
827827
b = Series(list_of_strings).apply(pd.to_timedelta) # noqa
828828
# Can't compare until apply on a Series gives the correct dtype
829829
# assert_series_equal(a, b)
830+
831+
832+
@pytest.mark.parametrize(
833+
"ops, names",
834+
[
835+
([np.sum], ["sum"]),
836+
([np.sum, np.mean], ["sum", "mean"]),
837+
(np.array([np.sum]), ["sum"]),
838+
(np.array([np.sum, np.mean]), ["sum", "mean"]),
839+
],
840+
)
841+
@pytest.mark.parametrize("how", ["agg", "apply"])
842+
def test_apply_listlike_reducer(string_series, ops, names, how):
843+
# GH 39140
844+
expected = Series({name: op(string_series) for name, op in zip(names, ops)})
845+
expected.name = "series"
846+
result = getattr(string_series, how)(ops)
847+
tm.assert_series_equal(result, expected)
848+
849+
850+
@pytest.mark.parametrize(
851+
"ops",
852+
[
853+
{"A": np.sum},
854+
{"A": np.sum, "B": np.mean},
855+
Series({"A": np.sum}),
856+
Series({"A": np.sum, "B": np.mean}),
857+
],
858+
)
859+
@pytest.mark.parametrize("how", ["agg", "apply"])
860+
def test_apply_dictlike_reducer(string_series, ops, how):
861+
# GH 39140
862+
expected = Series({name: op(string_series) for name, op in ops.items()})
863+
expected.name = string_series.name
864+
result = getattr(string_series, how)(ops)
865+
tm.assert_series_equal(result, expected)
866+
867+
868+
@pytest.mark.parametrize(
869+
"ops, names",
870+
[
871+
([np.sqrt], ["sqrt"]),
872+
([np.abs, np.sqrt], ["absolute", "sqrt"]),
873+
(np.array([np.sqrt]), ["sqrt"]),
874+
(np.array([np.abs, np.sqrt]), ["absolute", "sqrt"]),
875+
],
876+
)
877+
def test_apply_listlike_transformer(string_series, ops, names):
878+
# GH 39140
879+
with np.errstate(all="ignore"):
880+
expected = concat([op(string_series) for op in ops], axis=1)
881+
expected.columns = names
882+
result = string_series.apply(ops)
883+
tm.assert_frame_equal(result, expected)
884+
885+
886+
@pytest.mark.parametrize(
887+
"ops",
888+
[
889+
{"A": np.sqrt},
890+
{"A": np.sqrt, "B": np.exp},
891+
Series({"A": np.sqrt}),
892+
Series({"A": np.sqrt, "B": np.exp}),
893+
],
894+
)
895+
def test_apply_dictlike_transformer(string_series, ops):
896+
# GH 39140
897+
with np.errstate(all="ignore"):
898+
expected = concat({name: op(string_series) for name, op in ops.items()})
899+
expected.name = string_series.name
900+
result = string_series.apply(ops)
901+
tm.assert_series_equal(result, expected)

0 commit comments

Comments
 (0)