Mathew K Analytics

Lesson 23 · Pandas Full Course

Pandas map vs replace vs apply: Which One to Use and When | Pandas Course #23

In this lesson you'll be able to: Look up new values for a column with map Swap selected values and keep the rest with replace Run your own rules per value,…

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 23: Mapping, Replacing, and Applying Functions#

In this lesson you'll be able to:

  • Look up new values for a column with map
  • Swap selected values and keep the rest with replace
  • Run your own rules per value, per column or per row with apply
  • Put categories in a sensible order with a mapping
  • Pick the right tool for each clean-up job Dataset: restaurant bills and tips (data/pandas/tips.csv)

Warm-up: apply from lesson 22#

Lesson 22 ran functions across columns and rows. One quick recall.

import pandas as pd
df = pd.read_csv("data/pandas/tips.csv")
print(df.shape)
df.head()
(244, 7)
total_bill tip sex smoker day time size
0 16.99 1.01 Female No Sun Dinner 2
1 10.34 1.66 Male No Sun Dinner 3
2 21.01 3.50 Male No Sun Dinner 3
3 23.68 3.31 Male No Sun Dinner 2
4 24.59 3.61 Female No Sun Dinner 4
def spread(col):
    return col.max() - col.min()

df[["total_bill", "tip"]].apply(spread)
total_bill    47.74
tip            9.00
dtype: float64

Look up new values with map#

Problem: turn short codes like Thur into readable labels.

day_names = {
    "Thur": "Thursday", "Fri": "Friday",
    "Sat": "Saturday", "Sun": "Sunday",
}
df["day_name"] = df["day"].map(day_names)
df[["day", "day_name"]].drop_duplicates()
day day_name
0 Sun Sunday
19 Sat Saturday
77 Thur Thursday
90 Fri Friday
weekend = {
    "Thur": False, "Fri": False,
    "Sat": True, "Sun": True,
}
df["weekend"] = df["day"].map(weekend)
df["weekend"].value_counts()
weekend
True     163
False     81
Name: count, dtype: int64

Quick check: what does map do with a code the dictionary doesn't know?

short = {"Sat": "Saturday", "Sun": "Sunday"}
partial = df["day"].map(short)
print(partial.isna().sum())
print(df.loc[partial.isna(), "day"].unique().tolist())
81
['Thur', 'Fri']
filled = partial.fillna(df["day"])
filled.value_counts()
day
Saturday    87
Sunday      76
Thur        62
Fri         19
Name: count, dtype: int64

Common mistake: calling map on a whole DataFrame with a dictionary

df[["day", "time"]].map(day_names)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_20932\3351729301.py in ?()
----> 1 df[["day", "time"]].map(day_names)

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py in ?(self, func, na_action, **kwargs)
  12577 
  12578         if self.empty:
  12579             return self.copy()
  12580 
> 12581         func = functools.partial(func, **kwargs)
  12582 
  12583         def infer(x):
  12584             return x._map_values(func, na_action=na_action)

TypeError: the first argument must be callable
df["day"].map(day_names).value_counts()
day
Saturday    87
Sunday      76
Thursday    62
Friday      19
Name: count, dtype: int64

Map with a function or a Series#

Problem: some lookups need a rule, or numbers from another table.

def party(n):
    if n == 1:
        return "Solo"
    if n == 2:
        return "Couple"
    return "Group"

df["party"] = df["size"].map(party)
df["party"].value_counts()
party
Couple    156
Group      84
Solo        4
Name: count, dtype: int64
avg_tip = df.groupby("day")["tip"].mean()
print(avg_tip.round(2))
day
Fri     2.73
Sat     2.99
Sun     3.26
Thur    2.77
Name: tip, dtype: float64
df["day_avg"] = df["day"].map(avg_tip)
df["vs_day"] = df["tip"] - df["day_avg"]
df[["day", "tip", "day_avg", "vs_day"]].head(3).round(2)
day tip day_avg vs_day
0 Sun 1.01 3.26 -2.25
1 Sun 1.66 3.26 -1.60
2 Sun 3.50 3.26 0.24

Your turn#

Map each bill's time (Lunch or Dinner) to that meal's average bill.

avg_bill = df.groupby("time")["total_bill"].mean()
df["meal_avg"] = df["time"].map(avg_bill)
df[["time", "meal_avg"]].drop_duplicates().round(2)
time meal_avg
0 Dinner 20.80
77 Lunch 17.17

Swap values with replace#

Problem: tidy a few labels and leave everything else untouched.

Quick check: map and replace get the same one-entry dictionary. What differs?

fix = {"Thur": "Thu"}
print(df["day"].map(fix).unique().tolist())
print(df["day"].replace(fix).unique().tolist())
[nan, 'Thu']
['Sun', 'Sat', 'Thu', 'Fri']
labels = {
    "smoker": {"Yes": "Smoker", "No": "Non-smoker"},
    "sex": {"Female": "F", "Male": "M"},
}
tidy = df.replace(labels)
tidy[["sex", "smoker", "day"]].head(3)
sex smoker day
0 F Non-smoker Sun
1 M Non-smoker Sun
2 M Non-smoker Sun
df["day"].replace(["Sat", "Sun"], "Weekend").value_counts()
day
Weekend    163
Thur        62
Fri         19
Name: count, dtype: int64

Common mistake: two lists of different lengths

df["day"].replace(["Thur", "Fri"], ["Thursday"])
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_20932\4079769750.py in ?()
----> 1 df["day"].replace(["Thur", "Fri"], ["Thursday"])

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py in ?(self, to_replace, value, inplace, regex)
   7792                     value = [value] * len(to_replace)
   7793 
   7794                 # e.g. we have to_replace = [NA, ''] and value = [0, 'missing']
   7795                 if len(to_replace) != len(value):
-> 7796                     raise ValueError(
   7797                         f"Replacement lists must match in length. "
   7798                         f"Expecting {len(to_replace)} got {len(value)} "
   7799                     )

ValueError: Replacement lists must match in length. Expecting 2 got 1 
old, new = ["Thur", "Fri"], ["Thursday", "Friday"]
df["day"].replace(old, new).value_counts()
day
Sat         87
Sun         76
Thursday    62
Friday      19
Name: count, dtype: int64

Quick check: to turn Yes and No into 1 and 0, should you use replace or map?

flags = {"Yes": 1, "No": 0}
print(df["smoker"].replace(flags).dtype)
print(df["smoker"].map(flags).dtype)
object
int64
df["smoker_flag"] = df["smoker"].map(flags)
print(round(df["smoker_flag"].mean(), 2))
0.38

Run custom logic with apply#

Problem: a rule needs several steps, or reads several columns.

df["tip_pct"] = (df["tip"] / df["total_bill"] * 100).round(1)
df["tip_pct"].describe().round(1)
count    244.0
mean      16.1
std        6.1
min        3.6
25%       12.9
50%       15.4
75%       19.1
max       71.0
Name: tip_pct, dtype: float64
def tip_band(pct):
    if pct < 12:
        return "Low"
    if pct < 20:
        return "Standard"
    return "Generous"

df["tip_band"] = df["tip_pct"].apply(tip_band)
df["tip_band"].value_counts()
tip_band
Standard    157
Low          47
Generous     40
Name: count, dtype: int64
def at_least(pct, limit):
    return pct >= limit

big = df["tip_pct"].apply(at_least, limit=25)
print(big.sum())
10
def profile(col):
    return pd.Series({
        "low": col.min(),
        "typical": col.median(),
        "high": col.max(),
    })

df[["total_bill", "tip", "tip_pct"]].apply(profile).round(2)
total_bill tip tip_pct
low 3.07 1.0 3.60
typical 17.80 2.9 15.45
high 50.81 10.0 71.00
def service_charge(row):
    if row["size"] >= 6:
        return round(row["total_bill"] * 0.18, 2)
    return 0.0

df["charge"] = df.apply(service_charge, axis=1)
df.loc[df["charge"] > 0, ["size", "total_bill", "tip", "charge"]]
size total_bill tip charge
125 6 29.80 4.2 5.36
141 6 34.30 6.7 6.17
143 6 27.05 5.0 4.87
156 6 48.17 5.0 8.67

Common mistake: forgetting axis=1 for a row rule

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

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[25], line 2, in service_charge(row)
      1 def service_charge(row):
----> 2     if row["size"] >= 6:
      3         return round(row["total_bill"] * 0.18, 2)
      4     return 0.0

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: 'size'
print(round(df.apply(service_charge, axis=1).sum(), 2))
25.07

Your turn#

Use apply with axis=1 to work out the bill per person. Show the top 3.

def per_person(row):
    return row["total_bill"] / row["size"]

df["per_head"] = df.apply(per_person, axis=1).round(2)
df.nlargest(3, "per_head")[["total_bill", "size", "per_head"]]
total_bill size per_head
184 40.55 2 20.27
179 34.63 2 17.32
170 50.81 3 16.94
fast = (df["total_bill"] / df["size"]).round(2)
print(fast.equals(df["per_head"]))
True

Put categories in order with a mapping#

Problem: groupby sorts days alphabetically, not by the calendar.

by_day = df.groupby("day")["tip_pct"].mean().round(2)
by_day
day
Fri     17.00
Sat     15.31
Sun     16.69
Thur    16.13
Name: tip_pct, dtype: float64

Quick check: why does Thursday come last, and how could map fix it?

order = {"Thur": 4, "Fri": 5, "Sat": 6, "Sun": 7}
def calendar(idx):
    return idx.map(order)

week = by_day.sort_index(key=calendar)
week.rename(index=day_names)
day
Thursday    16.13
Friday      17.00
Saturday    15.31
Sunday      16.69
Name: tip_pct, dtype: float64
Tool Use it when
map every value gets a new value (dict, Series, function)
replace only some values change, the rest stay
apply the rule needs steps, or a whole row

Your turn#

Sort the average bill by time, Lunch first, with readable labels.

meals = df.groupby("time")["total_bill"].mean().round(2)
meal_order = {"Lunch": 1, "Dinner": 2}
def by_clock(idx):
    return idx.map(meal_order)

meals = meals.sort_index(key=by_clock)
meals.rename(index={"Lunch": "Midday", "Dinner": "Evening"})
time
Midday     17.17
Evening    20.80
Name: total_bill, dtype: float64

Mini-project: do bigger parties tip a smaller share?#

Plan: band the party sizes, summarise tips, check the bands, conclude.

bands = {
    1: "1-2", 2: "1-2",
    3: "3-4", 4: "3-4",
    5: "5-6", 6: "5-6",
}
df["band"] = df["size"].map(bands)
print(df["band"].isna().sum())
0
summary = df.groupby("band").agg(
    bills=("tip_pct", "size"),
    mean_pct=("tip_pct", "mean"),
    median_pct=("tip_pct", "median"),
)
summary.round(2)
bills mean_pct median_pct
band
1-2 160 16.70 15.7
3-4 75 14.91 15.0
5-6 9 14.81 14.1
mix = df.groupby("band")["tip_band"].value_counts(normalize=True)
mix.unstack().round(2)
tip_band Generous Low Standard
band
1-2 0.20 0.16 0.64
3-4 0.09 0.24 0.67
5-6 0.11 0.33 0.56
by_meal = df.groupby(["time", "band"])["tip_pct"].median()
by_meal.unstack().round(1)
band 1-2 3-4 5-6
time
Dinner 15.7 15.0 10.7
Lunch 15.6 14.8 16.3
import matplotlib.pyplot as plt
ax = summary["median_pct"].plot(kind="bar", rot=0, figsize=(7, 4))
ax.set_title("Median tip by party size")
ax.set_xlabel("Diners at the table")
ax.set_ylabel("Tip as % of bill")
plt.show()
No description has been provided for this image
small = summary.loc["1-2", "median_pct"]
large = summary.loc["5-6", "median_pct"]
print(f"1-2 diners: {small:.1f}%, 5-6 diners: {large:.1f}%")
1-2 diners: 15.7%, 5-6 diners: 14.1%

Recap#

  • map gives every value a new value: dict, Series or function
  • Values missing from a map dictionary become NaN
  • replace swaps only what you name; lists must match in length
  • apply runs your function per value, per column, or per row with axis=1
  • Mapping to positions puts categories in a real-world order

Challenges#

  1. Map sex to F and M, then find the average tip % for each.
  2. Use replace to merge Thur and Fri into Weekday, and count the bills.
  3. Write a row rule with apply that flags dinners with a tip under 10%.

What's next#

Lesson 24: Lambda Functions and Vectorized Operations Write rules in one line, and make them run far faster.

Found this useful?

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