Mathew K Analytics

Lesson 22 · Pandas Full Course

Pandas apply() Explained: Run Your Own Function on Every Row or Column | Pandas Course #22

In this lesson you'll be able to: Run your own function on every value with Series.apply Apply functions down columns and across rows with axis Change every…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Pandas 22: Applying Functions across Columns and Rows#

In this lesson you'll be able to:

  • Run your own function on every value with Series.apply
  • Apply functions down columns and across rows with axis
  • Change every cell with DataFrame.map
  • Choose between agg, transform and plain vectorised code Data: the Titanic passenger list and the Palmer penguins

Warm-up: one cleaning step from last time#

In lesson 21 we cleaned the salary survey. Here's the country trick.

import pandas as pd
import numpy as np

raw = pd.read_csv("data/pandas/salary_survey.csv")
tidy = raw["country"].str.strip().str.lower()
print(raw["country"].nunique(), "->", tidy.nunique(), "spellings")
294 -> 241 spellings

Run your own function on every value#

Series.apply calls your function once per value in a column.

df = pd.read_csv("data/pandas/titanic.csv")
name_len = df["Name"].apply(len)
df.assign(name_len=name_len)[["Name", "name_len"]].head(3)
Name name_len
0 Braund, Mr. Owen Harris 23
1 Cumings, Mrs. John Bradley (Florence Briggs Th... 51
2 Heikkinen, Miss. Laina 22
def age_group(age):
    if pd.isna(age):
        return "unknown"
    if age < 13:
        return "child"
    if age < 20:
        return "teen"
    return "adult" if age < 60 else "senior"

df["age_group"] = df["Age"].apply(age_group)
df["age_group"].value_counts()
age_group
adult      524
unknown    177
teen        95
child       69
senior      26
Name: count, dtype: int64
df.groupby("age_group")["Survived"].mean().round(2)
age_group
adult      0.39
child      0.58
senior     0.27
teen       0.41
unknown    0.29
Name: Survived, dtype: float64
def is_young(age, limit=13):
    return age < limit

print(df["Age"].apply(is_young).sum(), "under 13")
print(df["Age"].apply(is_young, limit=18).sum(), "under 18")
69 under 13
113 under 18

Quick check: what would happen to missing ages without the isna check?

def no_check(age):
    return "child" if age < 13 else "adult"

missing = df.loc[df["Age"].isna(), "Age"]
missing.apply(no_check).value_counts()
Age
adult    177
Name: count, dtype: int64

Common mistake: a function that assumes every value is text.

df["Cabin"].apply(lambda c: c[0])
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 1
----> 1 df["Cabin"].apply(lambda c: c[0])

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\series.py:5084, in Series.apply(self, func, args, by_row, **kwargs)
   4960 def apply(
   4961     self,
   4962     func: AggFuncType,
   (...)
   4966     **kwargs,
   4967 ) -> DataFrame | Series:
   4968     """
   4969     Invoke function on values of Series.
   4970 
   (...)
   5076     dtype: float64
   5077     """
   5078     return SeriesApply(
   5079         self,
   5080         func,
   5081         by_row=by_row,
   5082         args=args,
   5083         kwargs=kwargs,
-> 5084     ).apply()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:1520, in SeriesApply.apply(self)
   1517     return self.apply_compat()
   1519 # self.func is Callable
-> 1520 return self.apply_standard()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:1578, in SeriesApply.apply_standard(self)
   1576 else:
   1577     curried = func
-> 1578 mapped = obj._map_values(mapper=curried)
   1580 if len(mapped) and isinstance(mapped[0], ABCSeries):
   1581     # GH#43986 Need to do list(mapped) in order to get treated as nested
   1582     #  See also GH#25959 regarding EA support
   1583     return obj._constructor_expanddim(list(mapped), index=obj.index)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\base.py:1020, in IndexOpsMixin._map_values(self, mapper, na_action)
   1017 arr = self._values
   1019 if isinstance(arr, ExtensionArray):
-> 1020     return arr.map(mapper, na_action=na_action)
   1022 return algorithms.map_array(arr, mapper, na_action=na_action)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\arrow\array.py:1827, in ArrowExtensionArray.map(self, mapper, na_action)
   1821     return map_array(self.to_numpy(), mapper, na_action=na_action)
   1822 else:
   1823     # For "mM" cases, the super() method passes `self` without the
   1824     #  to_numpy call, which inside map_array casts to ndarray[object].
   1825     #  Without the to_numpy() call, NA is preserved instead of changed
   1826     #  to None.
-> 1827     return super().map(mapper, na_action)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\base.py:2745, in ExtensionArray.map(self, mapper, na_action)
   2725 def map(self, mapper, na_action: Literal["ignore"] | None = None):
   2726     """
   2727     Map values using an input mapping or function.
   2728 
   (...)
   2743         a MultiIndex will be returned.
   2744     """
-> 2745     return map_array(self, mapper, na_action=na_action)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\algorithms.py:1715, in map_array(arr, mapper, na_action)
   1713 values = arr.astype(object, copy=False)
   1714 if na_action is None:
-> 1715     return lib.map_infer(values, mapper)
   1716 else:
   1717     return lib.map_infer_mask(values, mapper, mask=isna(values).view(np.uint8))

File pandas/_libs/lib.pyx:3071, in pandas._libs.lib.map_infer()

Cell In[8], line 1, in <lambda>(c)
----> 1 df["Cabin"].apply(lambda c: c[0])

TypeError: 'float' object is not subscriptable
def deck(cabin):
    return cabin[0] if isinstance(cabin, str) else "none"

df["deck"] = df["Cabin"].apply(deck)
df["deck"].value_counts().head(5)
deck
none    687
C        59
B        47
D        33
E        32
Name: count, dtype: int64
vectorised = df["Cabin"].str[0].fillna("none")
print(vectorised.equals(df["deck"]))
True

Apply a function down every column#

DataFrame.apply passes each whole column to your function.

penguins = pd.read_csv("data/pandas/penguins.csv")
nums = penguins.select_dtypes("number")
print(penguins.shape)
nums.head(3)
(344, 7)
bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
0 39.1 18.7 181.0 3750.0
1 39.5 17.4 186.0 3800.0
2 40.3 18.0 195.0 3250.0
nums.apply(lambda col: col.max() - col.min())
bill_length_mm         27.5
bill_depth_mm           8.4
flipper_length_mm      59.0
body_mass_g          3600.0
dtype: float64
def summary(col):
    return pd.Series({"min": col.min(), "mean": col.mean(),
                      "max": col.max()})

nums.apply(summary).round(1)
bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
min 32.1 13.1 172.0 2700.0
mean 43.9 17.2 200.9 4201.8
max 59.6 21.5 231.0 6300.0
nums.agg(["min", "mean", "max"]).round(1)
bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
min 32.1 13.1 172.0 2700.0
mean 43.9 17.2 200.9 4201.8
max 59.6 21.5 231.0 6300.0

Quick check: which measurement varies most relative to its average?

cv = nums.apply(lambda col: col.std() / col.mean() * 100)
cv.round(1).sort_values(ascending=False)
body_mass_g          19.1
bill_length_mm       12.4
bill_depth_mm        11.5
flipper_length_mm     7.0
dtype: float64

Your turn: count the values in each column that are above that column's mean.

nums.apply(lambda col: (col > col.mean()).sum())
bill_length_mm       175
bill_depth_mm        182
flipper_length_mm    148
body_mass_g          149
dtype: int64

Apply a function across each row#

With axis=1, your function gets one row and can combine columns.

def family_size(row):
    return row["SibSp"] + row["Parch"] + 1

df["family"] = df.apply(family_size, axis=1)
df[["SibSp", "Parch", "family"]].head()
SibSp Parch family
0 1 0 2
1 1 0 2
2 0 0 1
3 1 0 2
4 0 0 1

Common mistake: forgetting axis=1.

df.apply(family_size)
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[18], line 1
----> 1 df.apply(family_size)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py:12437, in DataFrame.apply(self, func, axis, raw, result_type, args, by_row, engine, engine_kwargs, **kwargs)
  12423         raise ValueError(f"Unknown engine '{engine}'")
  12425     op = frame_apply(
  12426         self,
  12427         func=func,
   (...)
  12435         kwargs=kwargs,
  12436     )
> 12437     return op.apply().__finalize__(self, method="apply")
  12438 elif hasattr(engine, "__pandas_udf__"):
  12439     if result_type is not None:

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:1015, in FrameApply.apply(self)
   1012 elif self.raw:
   1013     return self.apply_raw(engine=self.engine, engine_kwargs=self.engine_kwargs)
-> 1015 return self.apply_standard()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:1167, in FrameApply.apply_standard(self)
   1165 def apply_standard(self):
   1166     if self.engine == "python":
-> 1167         results, res_index = self.apply_series_generator()
   1168     else:
   1169         results, res_index = self.apply_series_numba()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:1183, in FrameApply.apply_series_generator(self)
   1180 results = {}
   1182 for i, v in enumerate(series_gen):
-> 1183     results[i] = self.func(v, *self.args, **self.kwargs)
   1184     if isinstance(results[i], ABCSeries):
   1185         # If we have a view on v, we need to make a copy because
   1186         #  series_generator will swap out the underlying data
   1187         results[i] = results[i].copy(deep=False)

Cell In[17], line 2, in family_size(row)
      1 def family_size(row):
----> 2     return row["SibSp"] + row["Parch"] + 1

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\series.py:959, in Series.__getitem__(self, key)
    954     key = unpack_1tuple(key)
    956 elif key_is_scalar:
    957     # Note: GH#50617 in 3.0 we changed int key to always be treated as
    958     #  a label, matching DataFrame behavior.
--> 959     return self._get_value(key)
    961 # Convert generator to list before going through hashable part
    962 # (We will iterate through the generator there to check for slices)
    963 if is_iterator(key):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\series.py:1046, in Series._get_value(self, label, takeable)
   1043     return self._values[label]
   1045 # Similar to Index.get_value, but we do not fall back to positional
-> 1046 loc = self.index.get_loc(label)
   1048 if is_integer(loc):
   1049     return self._values[loc]

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\indexes\range.py:535, in RangeIndex.get_loc(self, key)
    533         raise KeyError(key) from err
    534 if isinstance(key, Hashable):
--> 535     raise KeyError(key)
    536 self._check_indexing_error(key)
    537 raise KeyError(key)

KeyError: 'SibSp'
fast = df["SibSp"] + df["Parch"] + 1
print(len(fast), "rows; identical:", fast.equals(df["family"]))
891 rows; identical: True
def ticket_type(row):
    if row["Fare"] == 0:
        return "free"
    if row["Pclass"] == 1 and row["Fare"] > 100:
        return "luxury"
    return "standard"

df["ticket_type"] = df.apply(ticket_type, axis=1)
df.groupby("ticket_type")["Survived"].agg(["mean", "count"]).round(2)
mean count
ticket_type
free 0.07 15
luxury 0.74 53
standard 0.37 823
def fare_info(row):
    per_head = row["Fare"] / row["family"]
    return pd.Series({"per_head": round(per_head, 2),
                      "pricey": per_head > 50})

df.apply(fare_info, axis=1).head(4)
per_head pricey
0 3.62 False
1 35.64 False
2 7.92 False
3 26.55 False

Your turn: build a short description for each passenger, like "Braund, male, 22".

def describe(row):
    surname = row["Name"].split(",")[0]
    age = "?" if pd.isna(row["Age"]) else int(row["Age"])
    return f"{surname}, {row['Sex']}, {age}"

df.apply(describe, axis=1).head(6)
0         Braund, male, 22
1      Cumings, female, 38
2    Heikkinen, female, 26
3     Futrelle, female, 35
4          Allen, male, 35
5           Moran, male, ?
dtype: str
parts = df["Name"].str.split(", ", n=1, expand=True)
parts.columns = ["surname", "rest"]
parts["title"] = parts["rest"].apply(lambda s: s.split(".")[0])
parts[["surname", "title"]].head(3)
surname title
0 Braund Mr
1 Cumings Mrs
2 Heikkinen Miss

Change every cell with DataFrame.map#

map on a DataFrame runs a function on each cell, one by one.

Common mistake: copying applymap from older tutorials.

cols = ["bill_length_mm", "bill_depth_mm"]
penguins[cols].applymap(lambda x: f"{x:.0f} mm")
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_21492\2551726428.py in ?()
      1 cols = ["bill_length_mm", "bill_depth_mm"]
----> 2 penguins[cols].applymap(lambda x: f"{x:.0f} mm")

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py in ?(self, name)
   6190             and name not in self._accessors
   6191             and self._info_axis._can_hold_identifiers_and_holds_name(name)
   6192         ):
   6193             return self[name]
-> 6194         return object.__getattribute__(self, name)

AttributeError: 'DataFrame' object has no attribute 'applymap'
labels = penguins[cols].map(lambda x: f"{x:.0f} mm",
                            na_action="ignore")
labels.head(4)
bill_length_mm bill_depth_mm
0 39 mm 19 mm
1 40 mm 17 mm
2 40 mm 18 mm
3 NaN NaN
text = df[["Name", "Ticket", "Cabin"]]
widths = text.map(lambda v: len(v) if isinstance(v, str) else 0)
widths.max()
Name      82
Ticket    18
Cabin     15
dtype: int64

Quick check: do we need map to turn millimetres into centimetres?

in_cm = penguins[cols] / 10
in_cm.head(4)
bill_length_mm bill_depth_mm
0 3.91 1.87
1 3.95 1.74
2 4.03 1.80
3 NaN NaN

agg summarises, transform keeps the shape#

Both take functions; they differ in how many rows come back.

mass = penguins.groupby("species")["body_mass_g"]
mass.agg(["mean", "std", "count"]).round(1)
mean std count
species
Adelie 3700.7 458.6 151
Chinstrap 3733.1 384.3 68
Gentoo 5076.0 504.1 123

Quick check: agg gave 3 rows. How many will transform give?

z = mass.transform(lambda s: (s - s.mean()) / s.std())
print(len(z), "rows, same as", len(penguins), "penguins")
penguins.assign(z=z.round(2))[["species", "body_mass_g", "z"]].head()
344 rows, same as 344 penguins
species body_mass_g z
0 Adelie 3750.0 0.11
1 Adelie 3800.0 0.22
2 Adelie 3250.0 -0.98
3 Adelie NaN NaN
4 Adelie 3450.0 -0.55
penguins["mass_z"] = z
cols = ["species", "sex", "body_mass_g", "mass_z"]
penguins.nlargest(3, "mass_z")[cols].round(2)
species sex body_mass_g mass_z
189 Chinstrap MALE 4800.0 2.78
237 Gentoo MALE 6300.0 2.43
109 Adelie MALE 4775.0 2.34
pair = penguins.groupby(["species", "sex"])["body_mass_g"]
penguins["vs_pair"] = penguins["body_mass_g"] - pair.transform("mean")
penguins[["species", "sex", "body_mass_g", "vs_pair"]].round(0).head(4)
species sex body_mass_g vs_pair
0 Adelie MALE 3750.0 -293.0
1 Adelie FEMALE 3800.0 431.0
2 Adelie FEMALE 3250.0 -119.0
3 Adelie NaN NaN NaN

Your turn: give each penguin its flipper length minus its species median.

flip = penguins.groupby("species")["flipper_length_mm"]
penguins["flip_diff"] = (penguins["flipper_length_mm"]
                         - flip.transform("median"))
penguins.groupby("species")["flip_diff"].agg(["min", "max"])
min max
species
Adelie -18.0 20.0
Chinstrap -18.0 16.0
Gentoo -13.0 15.0
heavy = penguins.groupby("species")["body_mass_g"].transform("max")
penguins["share_of_max"] = (penguins["body_mass_g"] / heavy).round(2)
penguins[["species", "body_mass_g", "share_of_max"]].head(3)
species body_mass_g share_of_max
0 Adelie 3750.0 0.79
1 Adelie 3800.0 0.80
2 Adelie 3250.0 0.68

Mini-project: does family size change survival odds?#

Use our family column and a custom function to find out.

def family_kind(n):
    if n == 1:
        return "1 alone"
    return "2-4 small" if n <= 4 else "5+ large"

df["family_kind"] = df["family"].apply(family_kind)
df.groupby("family_kind")["Survived"].agg(["mean", "count"]).round(2)
mean count
family_kind
1 alone 0.30 537
2-4 small 0.58 292
5+ large 0.16 62
by_class = df.pivot_table(index="family_kind", columns="Pclass",
                          values="Survived", aggfunc="mean")
by_class.round(2)
Pclass 1 2 3
family_kind
1 alone 0.53 0.35 0.21
2-4 small 0.73 0.63 0.41
5+ large 0.67 1.00 0.07
by_sex = df.pivot_table(index="family_kind", columns="Sex",
                        values="Survived", aggfunc="mean")
by_sex.round(2)
Sex female male
family_kind
1 alone 0.79 0.16
2-4 small 0.81 0.32
5+ large 0.27 0.03
pd.crosstab(df["family_kind"], df["Pclass"])
Pclass 1 2 3
family_kind
1 alone 109 104 324
2-4 small 101 78 113
5+ large 6 2 54
import matplotlib.pyplot as plt

rate = df.groupby("family")["Survived"].mean()
ax = rate.plot.bar(figsize=(8, 4), rot=0)
ax.set_title("Titanic survival rate by family size")
ax.set_xlabel("Family size (including the passenger)")
ax.set_ylabel("Share who survived")
plt.show()
No description has been provided for this image

Finding: small families of 2 to 4 survived most often. Travelling alone or in a family of 5 or more was far riskier.

Recap#

  • Series.apply(func) runs your function on every value
  • Handle missing values inside the function, or they slip through
  • DataFrame.apply: columns by default, rows with axis=1
  • DataFrame.map changes every cell (it replaced applymap)
  • agg shrinks groups, transform keeps one value per row
  • Prefer vectorised code when it exists: it's simpler and faster

Challenges#

  1. Write fare_band(fare) with 4 bands and compare survival by band.
  2. For penguins, use apply(axis=1) to compute bill length / depth. Then do it vectorised and check they match.
  3. Use transform to find each passenger's fare minus their class median.

What's next#

Lesson 23: Mapping, Replacing, and Applying Functions. Swap values with dictionaries using map and replace.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.