Mathew K Analytics

Lesson 25 · Pandas Full Course

Method Chaining in Pandas: Write Clean, Readable Data Pipelines | Pandas Course #25

In this lesson you'll be able to: Turn a pile of temporary variables into one readable chain Filter and sort mid-chain with query and loc with a lambda Add…

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 25: Chaining Operations and Method Pipelines#

In this lesson you'll be able to:

  • Turn a pile of temporary variables into one readable chain
  • Filter and sort mid-chain with query and loc with a lambda
  • Add columns mid-chain with assign and lambda
  • Group, reshape and plot at the end of a pipeline
  • Plug in your own steps with pipe, and debug a chain Dataset: the Titanic passenger list (data/pandas/titanic.csv)

Warm-up: vectorised flags from lesson 24#

Lesson 24 built columns with vectorised maths. One quick recall.

import pandas as pd
import numpy as np
df = pd.read_csv("data/pandas/titanic.csv")
print(df.shape)
df[["Survived", "Pclass", "Sex", "Age", "Fare"]].head(3)
(891, 12)
Survived Pclass Sex Age Fare
0 0 3 male 22.0 7.2500
1 1 1 female 38.0 71.2833
2 1 3 female 26.0 7.9250
df["child"] = np.where(df["Age"] < 16, "child", "adult")
df.groupby("child")["Survived"].mean().round(2)
child
adult    0.36
child    0.59
Name: Survived, dtype: float64

From temporary variables to one chain#

Problem: many steps leave a trail of half-finished variables.

step1 = df[df["Embarked"] == "S"]
step2 = step1[step1["Age"] >= 18]
step3 = step2.groupby("Pclass")["Age"].mean()
step4 = step3.sort_values(ascending=False)
step4.round(1)
Pclass
1    40.7
2    33.7
3    29.5
Name: Age, dtype: float64
(
    df
    .query("Embarked == 'S' and Age >= 18")
    .groupby("Pclass")["Age"]
    .mean()
    .sort_values(ascending=False)
    .round(1)
)
Pclass
1    40.7
2    33.7
3    29.5
Name: Age, dtype: float64
  • Each line is one step, read top to bottom
  • Every method returns a new object, so the next one can use it
  • No temporary names, and the original df is untouched

Common mistake: a multi-line chain without the outer brackets

result = df
    .query("Age > 60")
  Cell In[6], line 2
    .query("Age > 60")
    ^
IndentationError: unexpected indent
result = (df.query("Age > 60")
          .sort_values("Age", ascending=False))
result[["Name", "Age", "Survived"]].head(3)
Name Age Survived
630 Barkworth, Mr. Algernon Henry Wilson 80.0 1
851 Svensson, Mr. Johan 74.0 0
96 Goldschmidt, Mr. George B 71.0 0

Your turn#

Write one chain: the median fare paid by women in first class.

fare = (df
    .query("Sex == 'female' and Pclass == 1")
    ["Fare"]
    .median())
print(round(fare, 2))
82.66

Filter and sort inside a chain#

Problem: pick rows mid-chain without breaking the flow.

min_fare = 100
(df
    .query("Fare > @min_fare and Pclass == 1")
    .sort_values("Fare", ascending=False)
    [["Name", "Fare", "Survived"]]
    .head(3))
Name Fare Survived
258 Ward, Miss. Anna 512.3292 1
679 Cardeza, Mr. Thomas Drake Martinez 512.3292 1
737 Lesurer, Mr. Gustave J 512.3292 1
(df
    .loc[lambda d: d["Cabin"].notna()]
    .groupby("Pclass")
    .size())
Pclass
1    176
2     16
3     12
dtype: int64
(df
    .query("Embarked in ['C', 'Q']")
    .groupby("Embarked")["Survived"]
    .mean()
    .round(2))
Embarked
C    0.55
Q    0.39
Name: Survived, dtype: float64

Quick check: what share of third class passengers have a recorded cabin?

share = (df
    .query("Pclass == 3")
    ["Cabin"]
    .notna()
    .mean())
print(round(share, 3))
0.024
(df
    .sort_values(["Pclass", "Age"])
    .groupby("Pclass")
    .head(1)
    .reset_index(drop=True)
    [["Pclass", "Name", "Age", "Survived"]])
Pclass Name Age Survived
0 1 Allison, Master. Hudson Trevor 0.92 1
1 2 Hamalainen, Master. Viljo 0.67 1
2 3 Thomas, Master. Assad Alexander 0.42 1

Add columns mid-chain with assign#

Problem: create new columns without leaving the chain.

titles = (df
    .assign(Title=lambda d: d["Name"].str.extract(
        r",\s*([^.]+)\.", expand=False))
    ["Title"]
    .value_counts()
    .head(5))
titles
Title
Mr        517
Miss      182
Mrs       125
Master     40
Dr          7
Name: count, dtype: int64
(df
    .assign(family=lambda d: d["SibSp"] + d["Parch"] + 1,
            alone=lambda d: d["family"] == 1)
    .groupby("alone")["Survived"]
    .mean()
    .round(2))
alone
False    0.51
True     0.30
Name: Survived, dtype: float64
(df
    .assign(Age=lambda d: d["Age"].fillna(d["Age"].median()))
    ["Age"]
    .describe()
    .round(1))
count    891.0
mean      29.4
std       13.0
min        0.4
25%       22.0
50%       28.0
75%       35.0
max       80.0
Name: Age, dtype: float64
print(df["Age"].isna().sum())
177

Common mistake: using df instead of a lambda inside assign

(df.assign(family=lambda d: d["SibSp"] + d["Parch"] + 1)
   .assign(alone=df["family"] == 1))
---------------------------------------------------------------------------
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: 'family'

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

KeyError                                  Traceback (most recent call last)
Cell In[18], line 2
      1 (df.assign(family=lambda d: d["SibSp"] + d["Parch"] + 1)
----> 2    .assign(alone=df["family"] == 1))

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: 'family'
n_alone = (df.assign(family=lambda d: d["SibSp"] + d["Parch"] + 1)
             .assign(alone=lambda d: d["family"] == 1)
             ["alone"].sum())
print(n_alone)
537

Your turn#

In one chain: add a fare per person column, then show the 3 highest.

(df
    .assign(family=lambda d: d["SibSp"] + d["Parch"] + 1,
            per_person=lambda d: d["Fare"] / d["family"])
    .nlargest(3, "per_person")
    [["Name", "Fare", "family", "per_person"]]
    .round(2))
Name Fare family per_person
258 Ward, Miss. Anna 512.33 1 512.33
737 Lesurer, Mr. Gustave J 512.33 1 512.33
679 Cardeza, Mr. Thomas Drake Martinez 512.33 2 256.16

Group, reshape and plot at the end of a chain#

Problem: go from raw rows to a finished summary in one flow.

summary = (df
    .dropna(subset=["Embarked"])
    .groupby(["Embarked", "Pclass"])
    .agg(rate=("Survived", "mean"),
         people=("Survived", "size"))
    .reset_index()
    .sort_values("rate", ascending=False))
summary.head().round(2)
Embarked Pclass rate people
0 C 1 0.69 85
4 Q 2 0.67 3
6 S 1 0.58 127
1 C 2 0.53 17
3 Q 1 0.50 2
(df
    .merge(summary, on=["Embarked", "Pclass"], how="left")
    [["Embarked", "Pclass", "Survived", "rate"]]
    .head(3)
    .round(2))
Embarked Pclass Survived rate
0 S 3 0 0.19
1 C 1 1 0.69
2 S 3 1 0.19
(df
    .dropna(subset=["Embarked"])
    .pivot_table(index="Pclass", columns="Embarked",
                 values="Fare", aggfunc="median")
    .round(2))
Embarked C Q S
Pclass
1 78.27 90.00 52.00
2 24.00 12.35 13.50
3 7.90 7.75 8.05
import matplotlib.pyplot as plt
ax = (df
    .groupby(["Pclass", "Sex"])["Survived"]
    .mean()
    .unstack()
    .plot(kind="bar", rot=0, figsize=(7, 4)))
ax.set_title("Titanic survival rate by class and sex")
ax.set_xlabel("Passenger class")
ax.set_ylabel("Share who survived")
plt.show()
No description has been provided for this image

Your turn#

One chain: median age by port, without missing ports, oldest first.

(df
    .dropna(subset=["Embarked"])
    .groupby("Embarked")["Age"]
    .median()
    .sort_values(ascending=False))
Embarked
C    29.0
S    28.0
Q    27.0
Name: Age, dtype: float64

Custom steps with pipe, and debugging a chain#

Problem: reuse your own functions and see inside a long chain.

def add_age_band(d):
    bins = [0, 12, 18, 35, 60, 100]
    names = ["child", "teen", "young", "adult", "senior"]
    band = pd.cut(d["Age"], bins=bins, labels=names)
    return d.assign(age_band=band)

(df.pipe(add_age_band)["age_band"]
   .value_counts(sort=False))
age_band
child      69
teen       70
young     358
adult     195
senior     22
Name: count, dtype: int64
def survival_by(d, col):
    return d.groupby(col, observed=True)["Survived"].mean()

(df
    .pipe(add_age_band)
    .pipe(survival_by, col="age_band")
    .round(2))
age_band
child     0.58
teen      0.43
young     0.38
adult     0.40
senior    0.23
Name: Survived, dtype: float64
def peek(d, label):
    print(label, d.shape)
    return d

rate = (df
    .pipe(peek, "start")
    .dropna(subset=["Age"])
    .pipe(peek, "with age")
    .query("Sex == 'female'")
    .pipe(peek, "women")["Survived"].mean())
print(round(rate, 2))
start (891, 13)
with age (714, 13)
women (261, 13)
0.75
(df
    .pipe(lambda d: d[d["Fare"] > d["Fare"].median()])
    ["Pclass"]
    .value_counts())
Pclass
1    210
3    142
2     92
Name: count, dtype: int64

Common mistake: inplace=True in the middle of a chain

(df.copy()
    .dropna(subset=["Embarked"], inplace=True)
    .groupby("Embarked").size())
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[30], line 3
      1 (df.copy()
      2     .dropna(subset=["Embarked"], inplace=True)
----> 3     .groupby("Embarked").size())

AttributeError: 'NoneType' object has no attribute 'groupby'
(df
    .dropna(subset=["Embarked"])
    .groupby("Embarked").size())
Embarked
C    168
Q     77
S    644
dtype: int64

Quick check: which step removed the most rows?

print(df["Age"].isna().sum(), (df["Sex"] == "male").sum())
177 577

Mini-project: did a pricier ticket buy survival?#

Plan: band the fares, rate survival by band and sex, check class, conclude.

bands = ["Low", "Mid", "High", "Top"]
fares = (df
    .assign(band=lambda d: pd.qcut(d["Fare"], 4, labels=bands))
    .groupby(["band", "Sex"], observed=True)["Survived"]
    .mean()
    .unstack()
    .round(2))
fares
Sex female male
band
Low 0.70 0.08
Mid 0.64 0.16
High 0.70 0.28
Top 0.86 0.31
(df
    .assign(band=lambda d: pd.qcut(d["Fare"], 4, labels=bands))
    ["band"]
    .value_counts(sort=False))
band
Low     223
Mid     224
High    222
Top     222
Name: count, dtype: int64
(df
    .assign(band=lambda d: pd.qcut(d["Fare"], 4, labels=bands))
    .groupby("band", observed=True)["Pclass"]
    .value_counts()
    .unstack(fill_value=0))
Pclass 1 2 3
band
Low 6 6 211
Mid 0 86 138
High 51 70 101
Top 159 22 41
(df
    .query("Pclass == 3")
    .assign(band=lambda d: pd.qcut(d["Fare"], 2,
                                   labels=["cheaper", "dearer"]))
    .groupby(["band", "Sex"], observed=True)["Survived"]
    .mean().unstack().round(2))
Sex female male
band
cheaper 0.67 0.11
dearer 0.41 0.18
ax = fares.plot(kind="bar", rot=0, figsize=(7, 4))
ax.set_title("Survival rate by fare band and sex")
ax.set_xlabel("Fare band (quarters of passengers)")
ax.set_ylabel("Share who survived")
plt.show()
No description has been provided for this image
low, top = fares.loc["Low", "male"], fares.loc["Top", "male"]
print(f"Men: {low:.0%} in the Low band, {top:.0%} in the Top band")
Men: 8% in the Low band, 31% in the Top band

Recap#

  • Wrap a chain in brackets and put one step on each line
  • query and loc with a lambda filter rows mid-chain
  • assign adds columns; use a lambda to see columns made earlier
  • groupby, agg, pivot_table and plot can end a pipeline
  • pipe plugs in your own functions, including a peek for debugging

Challenges#

  1. In one chain, find the survival rate by title for the top 5 titles.
  2. Write a pipe function that fills missing ages with the class median.
  3. Chain query, assign and plot to chart average fare by family size.

What's next#

Lesson 26: Hands-on - Data Transformation Challenge Put map, apply, lambdas, vectorised maths and chains to work together.

Found this useful?

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