Mathew K Analytics

Lesson 26 · Pandas Full Course

Pandas Data Transformation Challenge: Use Every Tool on One Dataset | Pandas Course #26

In this lesson you'll be able to: Inspect a new table before changing anything Build new columns with fast, vectorised maths Recode values with map,…

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 26: Hands-on - Data Transformation Challenge#

In this lesson you'll be able to:

  • Inspect a new table before changing anything
  • Build new columns with fast, vectorised maths
  • Recode values with map, replace, cut and apply
  • Filter rows with masks, isin and query
  • Chain every step into one readable pipeline Dataset: 244 restaurant bills and tips (data/pandas/tips.csv)

Warm-up: a quick pipeline#

Lesson 25 chained methods into pipelines. One more, on new data.

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
(df
    .query("time == 'Lunch'")
    .sort_values("tip", ascending=False)
    .head(3))
total_bill tip sex smoker day time size
141 34.30 6.70 Male No Thur Lunch 6
88 24.71 5.85 Male No Thur Lunch 2
85 34.83 5.17 Female No Thur Lunch 4

Inspect before you transform#

Problem: know the types and quirks of a table before changing it.

df.info()
<class 'pandas.DataFrame'>
RangeIndex: 244 entries, 0 to 243
Data columns (total 7 columns):
 #   Column      Non-Null Count  Dtype  
---  ------      --------------  -----  
 0   total_bill  244 non-null    float64
 1   tip         244 non-null    float64
 2   sex         244 non-null    str    
 3   smoker      244 non-null    str    
 4   day         244 non-null    str    
 5   time        244 non-null    str    
 6   size        244 non-null    int64  
dtypes: float64(2), int64(1), str(4)
memory usage: 17.3 KB
df.describe().round(2)
total_bill tip size
count 244.00 244.00 244.00
mean 19.79 3.00 2.57
std 8.90 1.38 0.95
min 3.07 1.00 1.00
25% 13.35 2.00 2.00
50% 17.80 2.90 2.00
75% 24.13 3.56 3.00
max 50.81 10.00 6.00
df.value_counts(["day", "time"])
day   time  
Sat   Dinner    87
Sun   Dinner    76
Thur  Lunch     61
Fri   Dinner    12
      Lunch      7
Thur  Dinner     1
Name: count, dtype: int64

Quick check: what does df.size return: party sizes, or something else?

print(df.size)
print(df["size"].head(3).tolist())
1708
[2, 3, 3]
print(df.duplicated().sum())
df[df.duplicated(keep=False)]
1
total_bill tip sex smoker day time size
198 13.0 2.0 Female Yes Thur Lunch 2
202 13.0 2.0 Female Yes Thur Lunch 2

Build new columns with vectorised maths#

Problem: a 5 dollar tip means little until you know the bill.

pct = df["tip"] / df["total_bill"] * 100
df["tip_pct"] = pct.round(1)
df[["total_bill", "tip", "tip_pct"]].head()
total_bill tip tip_pct
0 16.99 1.01 5.9
1 10.34 1.66 16.1
2 21.01 3.50 16.7
3 23.68 3.31 14.0
4 24.59 3.61 14.7
df["per_person"] = (df["total_bill"] / df["size"]).round(2)
df[["total_bill", "size", "per_person"]].head(3)
total_bill size per_person
0 16.99 2 8.49
1 10.34 3 3.45
2 21.01 3 7.00
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

Common mistake: using a whole column in a plain if

if df["tip_pct"] > 20:
    print("generous")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_15012\3598751365.py in ?()
----> 1 if df["tip_pct"] > 20:
      2     print("generous")

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py in ?(self)
   1499     @final
   1500     def __bool__(self) -> NoReturn:
-> 1501         raise ValueError(
   1502             f"The truth value of a {type(self).__name__} is ambiguous. "
   1503             "Use a.empty, a.bool(), a.item(), a.any() or a.all()."
   1504         )

ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
df["generous"] = df["tip_pct"] > 20
print(df["generous"].sum())
print(round(df["generous"].mean(), 3))
38
0.156

Your turn#

Add tip_each: the tip divided by party size. Show the top 3.

df["tip_each"] = (df["tip"] / df["size"]).round(2)
df.nlargest(3, "tip_each")[["tip", "size", "tip_each"]]
tip size tip_each
170 10.00 3 3.33
88 5.85 2 2.92
181 5.65 2 2.82

Recode values with map, replace, cut and apply#

Problem: short codes and raw numbers are hard to read in a report.

print(df["day"].unique().tolist())
names = {"Thur": "Thursday", "Fri": "Friday",
         "Sat": "Saturday", "Sun": "Sunday"}
df["day_name"] = df["day"].map(names)
print(df["day_name"].value_counts())
['Sun', 'Sat', 'Thur', 'Fri']
day_name
Saturday    87
Sunday      76
Thursday    62
Friday      19
Name: count, dtype: int64

Quick check: what does map do with a code that's missing from the dictionary?

only_wknd = {"Sat": "Saturday", "Sun": "Sunday"}
print(df["day"].map(only_wknd).isna().sum())
81
labels = {"Yes": "Smoker", "No": "Non-smoker"}
df["smoker"] = df["smoker"].replace(labels)
df["smoker"].value_counts()
smoker
Non-smoker    151
Smoker         93
Name: count, dtype: int64
bands = [0, 10, 15, 20, 100]
band_names = ["Low", "Fair", "Good", "Generous"]
df["tip_band"] = pd.cut(df["tip_pct"], bins=bands,
                        labels=band_names)
df["tip_band"].value_counts(sort=False)
tip_band
Low         27
Fair        84
Good        95
Generous    38
Name: count, dtype: int64
def party_type(n):
    return "Group" if n >= 4 else "Small"

df["party"] = df["size"].apply(party_type)
df["party"].value_counts()
party
Small    198
Group     46
Name: count, dtype: int64

Common mistake: calling the function inside apply

df["size"].apply(party_type())
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[20], line 1
----> 1 df["size"].apply(party_type())

TypeError: party_type() missing 1 required positional argument: 'n'
df["size"].apply(party_type).head(3)
0    Small
1    Small
2    Small
Name: size, dtype: str

Your turn#

Label bills over 30 dollars as Big, the rest Regular. Count them.

import numpy as np
big = df["total_bill"] > 30
df["bill_size"] = np.where(big, "Big", "Regular")
df["bill_size"].value_counts()
bill_size
Regular    212
Big         32
Name: count, dtype: int64

Filter rows with masks, isin and query#

Problem: pull out exactly the tables a question is about.

cols = ["total_bill", "tip", "tip_pct", "day", "time"]
generous = df[df["tip_pct"] > 20]
print(len(generous))
generous[cols].head()
38
total_bill tip tip_pct day time
6 8.77 2.00 22.8 Sun Dinner
9 14.78 3.23 21.9 Sun Dinner
14 14.83 3.02 20.4 Sun Dinner
17 16.29 3.71 22.8 Sun Dinner
18 16.97 3.50 20.6 Sun Dinner

Common mistake: joining two conditions with and

df[df["tip_pct"] > 20 and df["time"] == "Dinner"]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_15012\1381233816.py in ?()
----> 1 df[df["tip_pct"] > 20 and df["time"] == "Dinner"]

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py in ?(self)
   1499     @final
   1500     def __bool__(self) -> NoReturn:
-> 1501         raise ValueError(
   1502             f"The truth value of a {type(self).__name__} is ambiguous. "
   1503             "Use a.empty, a.bool(), a.item(), a.any() or a.all()."
   1504         )

ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
mask = (df["tip_pct"] > 20) & (df["time"] == "Dinner")
print(mask.sum())
27
df.query("tip_pct > 20 and time == 'Dinner'").shape
(27, 15)
weekend = df[df["day"].isin(["Sat", "Sun"])]
weekday = df[~df["day"].isin(["Sat", "Sun"])]
print(len(weekend), len(weekday))
163 81

Quick check: do weekend tables tip a bigger share than weekday ones?

print(round(weekend["tip_pct"].mean(), 2))
print(round(weekday["tip_pct"].mean(), 2))
15.96
16.33

Your turn#

How many Saturday tables of 3 or more tipped under 10 percent?

sat = df.query("day == 'Sat' and size >= 3")
low = sat.query("tip_pct < 10")
print(len(low), len(sat))
5 32

Chain the steps into one pipeline#

Problem: loose lines are hard to rerun. A chain reads top to bottom.

raw = pd.read_csv("data/pandas/tips.csv")
print(raw.columns.tolist())
['total_bill', 'tip', 'sex', 'smoker', 'day', 'time', 'size']

Common mistake: using a new column in the same assign without a lambda

raw.assign(
    tip_pct=raw["tip"] / raw["total_bill"] * 100,
    high=raw["tip_pct"] > 20,
)
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\indexes\base.py:3641, in Index.get_loc(self, key)
   3640 try:
-> 3641     return self._engine.get_loc(casted_key)
   3642 except KeyError as err:

File pandas/_libs/index.pyx:168, in pandas._libs.index.IndexEngine.get_loc()

File pandas/_libs/index.pyx:197, in pandas._libs.index.IndexEngine.get_loc()

File pandas/_libs/hashtable_class_helper.pxi:7668, in pandas._libs.hashtable.PyObjectHashTable.get_item()

File pandas/_libs/hashtable_class_helper.pxi:7676, in pandas._libs.hashtable.PyObjectHashTable.get_item()

KeyError: 'tip_pct'

The above exception was the direct cause of the following exception:

KeyError                                  Traceback (most recent call last)
Cell In[31], line 3
      1 raw.assign(
      2     tip_pct=raw["tip"] / raw["total_bill"] * 100,
----> 3     high=raw["tip_pct"] > 20,
      4 )

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py:4378, in DataFrame.__getitem__(self, key)
   4376 if self.columns.nlevels > 1:
   4377     return self._getitem_multilevel(key)
-> 4378 indexer = self.columns.get_loc(key)
   4379 if is_integer(indexer):
   4380     indexer = [indexer]

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\indexes\base.py:3648, in Index.get_loc(self, key)
   3643     if isinstance(casted_key, slice) or (
   3644         isinstance(casted_key, abc.Iterable)
   3645         and any(isinstance(x, slice) for x in casted_key)
   3646     ):
   3647         raise InvalidIndexError(key) from err
-> 3648     raise KeyError(key) from err
   3649 except TypeError:
   3650     # If we have a listlike key, _check_indexing_error will raise
   3651     #  InvalidIndexError. Otherwise we fall through and re-raise
   3652     #  the TypeError.
   3653     self._check_indexing_error(key)

KeyError: 'tip_pct'
tidy = raw.assign(
    tip_pct=lambda d: d["tip"] / d["total_bill"] * 100,
    high=lambda d: d["tip_pct"] > 20,
)
tidy[["tip", "tip_pct", "high"]].head(3).round(1)
tip tip_pct high
0 1.0 5.9 False
1 1.7 16.1 False
2 3.5 16.7 False
report = (
    raw.assign(tip_pct=lambda d: d["tip"] / d["total_bill"] * 100)
    .query("size >= 4")
    .sort_values("tip_pct", ascending=False)
    .loc[:, ["day", "size", "total_bill", "tip_pct"]]
    .round(1)
)
report.head()
day size total_bill tip_pct
183 Sun 4 23.2 28.1
185 Sun 5 20.7 24.2
63 Sat 4 18.3 20.6
211 Sat 4 25.9 19.9
141 Thur 6 34.3 19.5
def add_tip_cols(d):
    d = d.assign(tip_pct=d["tip"] / d["total_bill"] * 100)
    return d.assign(tip_each=d["tip"] / d["size"])

raw.pipe(add_tip_cols).nlargest(3, "tip_each").round(2)
total_bill tip sex smoker day time size tip_pct tip_each
170 50.81 10.00 Male Yes Sat Dinner 3 19.68 3.33
88 24.71 5.85 Male No Thur Lunch 2 23.67 2.92
181 23.33 5.65 Male Yes Sun Dinner 2 24.22 2.82

Your turn#

From raw: dinner only, add per_person, show the top 3 spenders.

top = (
    raw.query("time == 'Dinner'")
    .assign(per_person=lambda d: d["total_bill"] / d["size"])
    .nlargest(3, "per_person")
)
top[["day", "size", "total_bill", "per_person"]].round(2)
day size total_bill per_person
184 Sun 2 40.55 20.27
179 Sun 2 34.63 17.32
170 Sat 3 50.81 16.94

Mini-project: are smokers more generous tippers?#

Plan: compare averages, check the extremes, then conclude.

is_smoker = df["smoker"] == "Smoker"
smoke = df.loc[is_smoker, "tip_pct"]
other = df.loc[~is_smoker, "tip_pct"]
print(round(smoke.mean(), 2), round(other.mean(), 2))
16.32 15.93
print(smoke.median(), other.median())
15.4 15.6
show = ["total_bill", "tip", "tip_pct", "smoker"]
df.nlargest(3, "tip_pct")[show]
total_bill tip tip_pct smoker
172 7.25 5.15 71.0 Smoker
178 9.60 4.00 41.7 Smoker
67 3.07 1.00 32.6 Smoker
print(round(smoke[smoke < 50].mean(), 2))
print(round(smoke.std(), 2), round(other.std(), 2))
15.73
8.51 3.99
spend = df.groupby("smoker")["per_person"].median()
spend
smoker
Non-smoker    6.97
Smoker        7.76
Name: per_person, dtype: float64
import matplotlib.pyplot as plt
ax = df.boxplot(column="tip_pct", by="smoker", figsize=(7, 4))
plt.suptitle("")
ax.set_title("Tip percentage: smokers vs non-smokers")
ax.set_xlabel("Table")
ax.set_ylabel("Tip as % of bill")
plt.show()
No description has been provided for this image
gap = smoke.median() - other.median()
print(f"Median gap: {gap:.1f} points")
Median gap: -0.2 points

Recap#

  • Inspect types, ranges and duplicates before transforming
  • Column maths is vectorised; never put a column in a plain if
  • map, replace, cut and apply turn codes into readable labels
  • Combine masks with & and |, or use query
  • assign with lambdas and pipe build a rerunnable pipeline

Challenges#

  1. Is the lunch tip percentage higher than dinner? Use masks.
  2. Band per_person into Low, Mid and High with cut.
  3. Write one chain: Saturday only, tip_pct, top 5 by tip_pct.

What's next#

Lesson 27: Grouping Data with groupby() and Aggregations Summarise every group in one line, on the Titanic passenger list.

Found this useful?

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