Mathew K Analytics

Lesson 24 · Pandas Full Course

Lambda and Vectorized Operations in Pandas: Faster, Cleaner Code | Pandas Course #24

In this lesson you'll be able to: Write one-line rules with lambda in apply, map, agg and assign Do maths and comparisons on whole columns at once Build…

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 24: Lambda Functions and Vectorized Operations#

In this lesson you'll be able to:

  • Write one-line rules with lambda in apply, map, agg and assign
  • Do maths and comparisons on whole columns at once
  • Build conditional columns with np.where and np.select
  • Measure why vectorised code beats apply for speed
  • Handle zeros and missing values safely in column maths Dataset: 53,940 diamonds with prices (data/pandas/diamonds.csv)

Warm-up: map from lesson 23#

Lesson 23 mapped labels to new values. Same move, new data.

import pandas as pd
import numpy as np
df = pd.read_csv("data/pandas/diamonds.csv")
print(df.shape)
df.head()
(53940, 10)
carat cut color clarity depth table price x y z
0 0.23 Ideal E SI2 61.5 55.0 326 3.95 3.98 2.43
1 0.21 Premium E SI1 59.8 61.0 326 3.89 3.84 2.31
2 0.23 Good E VS1 56.9 65.0 327 4.05 4.07 2.31
3 0.29 Premium I VS2 62.4 58.0 334 4.20 4.23 2.63
4 0.31 Good J SI2 63.3 58.0 335 4.34 4.35 2.75
grades = {"Fair": 1, "Good": 2, "Very Good": 3,
          "Premium": 4, "Ideal": 5}
df["cut_score"] = df["cut"].map(grades)
df["cut_score"].value_counts().sort_index()
cut_score
1     1610
2     4906
3    12082
4    13791
5    21551
Name: count, dtype: int64

Write one-line rules with lambda#

Problem: a full def is too much ceremony for a tiny rule.

  • def to_mg(c): return c * 200 is a named function
  • lambda c: c * 200 is the same rule with no name
  • One carat weighs 200 milligrams
def to_mg(c):
    return c * 200

print(df["carat"].apply(to_mg).head(3).tolist())
print(df["carat"].apply(lambda c: c * 200).head(3).tolist())
[46.0, 42.0, 46.0]
[46.0, 42.0, 46.0]

Quick check: what does (lambda c: c * 200)(1.5) give?

print((lambda c: c * 200)(1.5))
300.0
size = df["carat"].apply(lambda c: "1ct+" if c >= 1 else "small")
size.value_counts()
carat
small    34880
1ct+     19060
Name: count, dtype: int64
family = df["clarity"].map(lambda g: g[:2])
family.value_counts()
clarity
SI    22259
VS    20429
VV     8721
IF     1790
I1      741
Name: count, dtype: int64
cuts = df["cut"].unique().tolist()
print(cuts)
print(sorted(cuts, key=lambda c: grades[c]))
['Ideal', 'Premium', 'Good', 'Very Good', 'Fair']
['Fair', 'Good', 'Very Good', 'Premium', 'Ideal']

Common mistake: writing return inside a lambda

df["carat"].apply(lambda c: return c * 200)
  Cell In[9], line 1
    df["carat"].apply(lambda c: return c * 200)
                                ^
SyntaxError: invalid syntax
print(df["carat"].apply(lambda c: c * 200).max())
1002.0

Use lambda with rows, groups and assign#

Problem: quick rules that need several columns or a whole group.

dims = df[['x', 'y', 'z']].head()
dims.apply(lambda r: r['x'] * r['y'] * r['z'], axis=1).round(1)
0    38.2
1    34.5
2    38.1
3    46.7
4    51.9
dtype: float64
top10 = df.groupby("cut")["price"].agg(lambda s: s.quantile(0.9))
top10.sort_values()
cut
Good          8736.0
Ideal         9077.0
Fair          9133.7
Very Good     9680.9
Premium      11413.0
Name: price, dtype: float64
priced = df.assign(
    per_carat=lambda t: t["price"] / t["carat"],
)
priced[["carat", "price", "per_carat"]].head(3).round(2)
carat price per_carat
0 0.23 326 1417.39
1 0.21 326 1552.38
2 0.23 327 1421.74
rel = df.groupby("cut")["price"].transform(lambda s: s / s.median())
print(rel.head(3).round(2).tolist())
[0.18, 0.1, 0.11]
pricey = df.loc[lambda t: t["price"] > 15000]
print(len(pricey), pricey["carat"].median())
1655 2.01

Your turn#

With a lambda in agg, find the price range (max minus min) for each color.

spread = df.groupby("color")["price"].agg(lambda s: s.max() - s.min())
spread
color
D    18336
E    18405
F    18449
G    18464
H    18466
I    18489
J    18375
Name: price, dtype: int64

Do maths on whole columns at once#

Problem: apply runs Python once per row, which is slow.

df["per_carat"] = df["price"] / df["carat"]
df["per_carat"].describe().round(0)
count    53940.0
mean      4008.0
std       2013.0
min       1051.0
25%       2478.0
50%       3495.0
75%       4950.0
max      17829.0
Name: per_carat, dtype: float64
vat = df["price"] * 1.2
print(vat.head(3).tolist())
[391.2, 391.2, 392.4]
big = df["carat"] >= 1
print(big.sum())
print(round(big.mean(), 3))
19060
0.353
combo = df["cut"] + " / " + df["color"]
combo.value_counts().head(3)
Ideal / G    4884
Ideal / E    3903
Ideal / F    3826
Name: count, dtype: int64

Your turn#

Depth should equal z / average of x and y, times 100. Check it, vectorised.

calc = df["z"] / ((df["x"] + df["y"]) / 2) * 100
diff = (calc - df["depth"]).abs()
print(round(diff.median(), 2), (diff > 1).sum())
0.03 93

Common mistake: using and between two columns

df[(df["carat"] >= 1) and (df["price"] < 5000)]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_24424\802462619.py in ?()
----> 1 df[(df["carat"] >= 1) and (df["price"] < 5000)]

~\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().
bargain = (df["carat"] >= 1) & (df["price"] < 5000)
print(bargain.sum())
4582

Quick check: of the one carat stones, what share cost under 5000?

print(round(bargain.sum() / big.sum(), 3))
0.24

Build conditional columns with np.where and np.select#

Problem: an if-else rule for every row, at vectorised speed.

df["size"] = np.where(df["carat"] >= 1, "1ct+", "small")
df["size"].value_counts()
size
small    34880
1ct+     19060
Name: count, dtype: int64
rules = [
    df["price"] < 1000,
    df["price"] < 5000,
]
names = ["budget", "mid"]
df["tier"] = np.select(rules, names, default="luxury")
df["tier"].value_counts()
tier
mid       24714
luxury    14727
budget    14499
Name: count, dtype: int64
cuts_tier = pd.cut(df["price"], bins=[0, 1000, 5000, np.inf],
                   right=False, labels=names + ["luxury"])
print((cuts_tier.astype(str) == df["tier"]).all())
True

Common mistake: leaving out the brackets around each condition

np.where(df["carat"] >= 1 & df["price"] < 5000, "deal", "")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_24424\2378895559.py in ?()
----> 1 np.where(df["carat"] >= 1 & df["price"] < 5000, "deal", "")

~\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().
cond = (df["carat"] >= 1) & (df["price"] < 5000)
deal = pd.Series(np.where(cond, "deal", "other"))
deal.value_counts()
other    49358
deal      4582
Name: count, dtype: int64

Your turn#

Use np.select to label depth: under 60 shallow, over 64 deep, else ideal.

depth_rules = [df["depth"] < 60, df["depth"] > 64]
depth_names = ["shallow", "deep"]
df["depth_band"] = np.select(depth_rules, depth_names, "ideal")
df["depth_band"].value_counts()
depth_band
ideal      46956
shallow     5114
deep        1870
Name: count, dtype: int64

Measure the speed, and keep the maths safe#

Problem: prove vectorised is faster, and avoid nonsense from bad values.

import timeit
def slow_way():
    return df.apply(lambda r: r["price"] / r["carat"], axis=1)
def fast_way():
    return df["price"] / df["carat"]

print(slow_way().equals(fast_way()))
True
slow = timeit.timeit(slow_way, number=1)
fast = min(timeit.repeat(fast_way, number=1, repeat=3))
print("Vectorised at least 20x faster:", slow > 20 * fast)
Vectorised at least 20x faster: True
print(df["clarity"].str[:2].equals(family))
True
flat = (df[["x", "y", "z"]] == 0).sum()
flat
x     8
y     7
z    20
dtype: int64

Quick check: what volume does a stone with z = 0 get?

vol = df["x"] * df["y"] * df["z"]
print((vol == 0).sum())
20
sizes = df[["x", "y", "z"]].replace(0, np.nan)
df["volume"] = sizes["x"] * sizes["y"] * sizes["z"]
print(df["volume"].isna().sum(), round(df["volume"].min(), 1))
20 31.7
print(round(vol.mean(), 2), round(df["volume"].mean(), 2))
129.85 129.9

Mini-project: is the Ideal cut really cheaper?#

Plan: compare prices, check size, compare like with like, conclude.

by_cut = df.groupby("cut").agg(
    median_price=("price", "median"),
    median_carat=("carat", "median"),
)
by_cut.sort_values("median_price")
median_price median_carat
cut
Ideal 1810.0 0.54
Very Good 2648.0 0.71
Good 3050.5 0.82
Premium 3185.0 0.86
Fair 3282.0 1.00
fair = df["cut"] == "Fair"
ideal = df["cut"] == "Ideal"
print(round(df.loc[fair, "size"].eq("1ct+").mean(), 2))
print(round(df.loc[ideal, "size"].eq("1ct+").mean(), 2))
0.51
0.27
grid = df.groupby(["size", "cut"])["per_carat"].median()
grid = grid.unstack().round(0)
grid[["Fair", "Good", "Very Good", "Premium", "Ideal"]]
cut Fair Good Very Good Premium Ideal
size
1ct+ 4295.0 5096.0 5702.0 5555.0 6183.0
small 2889.0 2674.0 2708.0 2756.0 2827.0
counts = df.groupby(["size", "cut"]).size().unstack()
counts[["Fair", "Good", "Very Good", "Premium", "Ideal"]]
cut Fair Good Very Good Premium Ideal
size
1ct+ 817 1912 4270 6191 5870
small 793 2994 7812 7600 15681
import matplotlib.pyplot as plt
big_ones = grid.loc["1ct+", ["Fair", "Good", "Very Good",
                             "Premium", "Ideal"]]
ax = big_ones.plot(kind="bar", rot=0, figsize=(7, 4))
ax.set_title("Price per carat, stones of 1 carat or more")
ax.set_xlabel("Cut grade")
ax.set_ylabel("Median price per carat ($)")
plt.show()
No description has been provided for this image
gap = big_ones["Ideal"] / big_ones["Fair"] - 1
print(f"Ideal costs {gap:.0%} more per carat than Fair at 1ct+")
Ideal costs 44% more per carat than Fair at 1ct+

Recap#

  • lambda writes a one-line function: input, colon, result
  • Lambdas fit anywhere a function is expected: apply, map, agg, assign
  • Whole-column maths and comparisons are vectorised and fast
  • Combine conditions with & and |, with brackets around each one
  • np.where and np.select replace if-else lambdas at full speed

Challenges#

  1. Add a column with the price in thousands, rounded to 1 decimal.
  2. Use np.where to flag stones whose table is above 60.
  3. Use a lambda in agg to find each clarity's median carat weight.

What's next#

Lesson 25: Chaining Operations and Method Pipelines Join many steps into one clear, readable pipeline.

Found this useful?

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