Mathew K Analytics

Lesson 27 · Pandas Full Course

Pandas groupby Tutorial: Summarise Any Data with agg and transform | Pandas Course #27

In this lesson you'll be able to: Split a table into groups and summarise each one with groupby Count groups correctly, missing values included 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 27: Grouping Data with groupby() and Aggregations#

In this lesson you'll be able to:

  • Split a table into groups and summarise each one with groupby
  • Count groups correctly, missing values included
  • Build summary tables with agg and named aggregation
  • Group by two columns and reshape the result
  • Keep every row with transform, and drop groups with filter Dataset: the Titanic passenger list (data/pandas/titanic.csv)

Warm-up: one group at a time#

Lesson 26 transformed and filtered data. Same skills, new dataset.

import pandas as pd
df = pd.read_csv("data/pandas/titanic.csv")
print(df.shape)
df[["Survived", "Pclass", "Sex", "Age", "Fare", "Embarked"]].head()
(891, 12)
Survived Pclass Sex Age Fare Embarked
0 0 3 male 22.0 7.2500 S
1 1 1 female 38.0 71.2833 C
2 1 3 female 26.0 7.9250 S
3 1 1 female 35.0 53.1000 S
4 0 3 male 35.0 8.0500 S
names = {1: "First", 2: "Second", 3: "Third"}
df["Class"] = df["Pclass"].map(names)
first = df[df["Class"] == "First"]
print(round(first["Survived"].mean(), 3))
0.63

Split, apply, combine with groupby#

Problem: compare one number across every group in a single line.

  • Split the rows into groups by a key column
  • Apply a calculation to each group
  • Combine the answers into one result
df.groupby("Class")["Survived"].mean()
Class
First     0.629630
Second    0.472826
Third     0.242363
Name: Survived, dtype: float64
df.groupby("Class")["Survived"].sum()
Class
First     136
Second     87
Third     119
Name: Survived, dtype: int64
by_sex = df.groupby("Sex")
print(by_sex.ngroups)
by_sex.size()
2
Sex
female    314
male      577
dtype: int64
for name, group in by_sex:
    avg_age = round(group["Age"].mean(), 1)
    print(name, len(group), avg_age)
female 314 27.9
male 577 30.7

Quick check: which sex had the better survival rate, and by how much?

by_sex["Survived"].mean().round(3)
Sex
female    0.742
male      0.189
Name: Survived, dtype: float64

Common mistake: calling mean() on the whole grouped table

df.groupby("Class").mean()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[9], line 1
----> 1 df.groupby("Class").mean()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\groupby.py:2285, in GroupBy.mean(self, numeric_only, skipna, engine, engine_kwargs)
   2277     return self._numba_agg_general(
   2278         grouped_mean,
   2279         executor.float_dtype_mapping,
   (...)
   2282         skipna=skipna,
   2283     )
   2284 else:
-> 2285     result = self._cython_agg_general(
   2286         "mean",
   2287         alt=lambda x: Series(x, copy=False).mean(
   2288             numeric_only=numeric_only, skipna=skipna
   2289         ),
   2290         numeric_only=numeric_only,
   2291         skipna=skipna,
   2292     )
   2293     return result.__finalize__(self.obj, method="groupby")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\groupby.py:1808, in GroupBy._cython_agg_general(self, how, alt, numeric_only, min_count, **kwargs)
   1805     result = self._agg_py_fallback(how, values, ndim=data.ndim, alt=alt)
   1806     return result
-> 1808 new_mgr = data.grouped_reduce(array_func)
   1809 res = self._wrap_agged_manager(new_mgr)
   1810 if how in ["idxmin", "idxmax"]:
   1811     # mypy expects how to be Literal["idxmin", "idxmax"].

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\managers.py:1667, in BlockManager.grouped_reduce(self, func)
   1665             result_blocks = extend_blocks(applied, result_blocks)
   1666     else:
-> 1667         applied = blk.apply(func)
   1668         result_blocks = extend_blocks(applied, result_blocks)
   1670 if len(result_blocks) == 0:

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\blocks.py:347, in Block.apply(self, func, **kwargs)
    341 @final
    342 def apply(self, func, **kwargs) -> list[Block]:
    343     """
    344     apply the function to my values; return a block if we are not
    345     one
    346     """
--> 347     result = func(self.values, **kwargs)
    349     result = maybe_coerce_values(result)
    350     return self._split_op_result(result)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\groupby.py:1783, in GroupBy._cython_agg_general.<locals>.array_func(values)
   1781 def array_func(values: ArrayLike) -> ArrayLike:
   1782     try:
-> 1783         result = self._grouper._cython_operation(
   1784             "aggregate",
   1785             values,
   1786             how,
   1787             axis=data.ndim - 1,
   1788             min_count=min_count,
   1789             **kwargs,
   1790         )
   1791     except NotImplementedError:
   1792         # generally if we have numeric_only=False
   1793         # and non-applicable functions
   1794         # try to python agg
   1795         # TODO: shouldn't min_count matter?
   1796         # TODO: avoid special casing SparseArray here
   1797         if how in ["any", "all"] and isinstance(values, SparseArray):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\ops.py:963, in BaseGrouper._cython_operation(self, kind, values, how, axis, min_count, **kwargs)
    959 assert kind in ["transform", "aggregate"]
    961 cy_op = WrappedCythonOp(kind=kind, how=how, has_dropped_na=self.has_dropped_na)
--> 963 return cy_op.cython_operation(
    964     values=values,
    965     axis=axis,
    966     min_count=min_count,
    967     comp_ids=self.ids,
    968     ngroups=self.ngroups,
    969     **kwargs,
    970 )

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\ops.py:553, in WrappedCythonOp.cython_operation(self, values, axis, min_count, comp_ids, ngroups, **kwargs)
    549 self._validate_axis(axis, values)
    551 if not isinstance(values, np.ndarray):
    552     # i.e. ExtensionArray
--> 553     return values._groupby_op(
    554         how=self.how,
    555         has_dropped_na=self.has_dropped_na,
    556         min_count=min_count,
    557         ngroups=ngroups,
    558         ids=comp_ids,
    559         **kwargs,
    560     )
    562 return self._cython_op_ndim_compat(
    563     values,
    564     min_count=min_count,
   (...)
    568     **kwargs,
    569 )

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\arrow\array.py:2748, in ArrowExtensionArray._groupby_op(self, how, has_dropped_na, min_count, ngroups, ids, **kwargs)
   2736 if isinstance(self.dtype, StringDtype):
   2737     if how in [
   2738         "prod",
   2739         "mean",
   (...)
   2746         "skew",
   2747     ]:
-> 2748         raise TypeError(
   2749             f"dtype '{self.dtype}' does not support operation '{how}'"
   2750         )
   2751     return super()._groupby_op(
   2752         how=how,
   2753         has_dropped_na=has_dropped_na,
   (...)
   2757         **kwargs,
   2758     )
   2760 # maybe convert to a compatible dtype optimized for groupby

TypeError: dtype 'str' does not support operation 'mean'
cols = ["Survived", "Age", "Fare"]
df.groupby("Class")[cols].mean().round(2)
Survived Age Fare
Class
First 0.63 38.23 84.15
Second 0.47 29.88 20.66
Third 0.24 25.14 13.68

Count groups with size and count#

Problem: know how many rows each group has, and how many are missing.

df.groupby("Class")["Age"].agg(["size", "count"])
size count
Class
First 216 186
Second 184 173
Third 491 355
df.groupby("Class")["Ticket"].agg(["size", "nunique"])
size nunique
Class
First 216 147
Second 184 140
Third 491 394
df.groupby("Embarked").size()
Embarked
C    168
Q     77
S    644
dtype: int64

Quick check: those ports add up to 889, but there are 891 passengers. Where did 2 go?

df.groupby("Embarked", dropna=False).size()
Embarked
C      168
Q       77
S      644
NaN      2
dtype: int64
queens = df.groupby("Embarked").get_group("Q")
queens["Class"].value_counts()
Class
Third     72
Second     3
First      2
Name: count, dtype: int64
ports = df.groupby("Class")["Embarked"]
ports.value_counts(normalize=True).round(2)
Class   Embarked
First   S           0.59
        C           0.40
        Q           0.01
Second  S           0.89
        C           0.09
        Q           0.02
Third   S           0.72
        Q           0.15
        C           0.13
Name: proportion, dtype: float64

Several statistics at once with agg#

Problem: one tidy summary table instead of many separate lines.

fares = df.groupby("Class")["Fare"]
fares.agg(["mean", "median", "max"]).round(2)
mean median max
Class
First 84.15 60.29 512.33
Second 20.66 14.25 73.50
Third 13.68 8.05 69.55
df.groupby("Sex")["Age"].describe().round(1)
count mean std min 25% 50% 75% max
Sex
female 261.0 27.9 14.1 0.8 18.0 27.0 37.0 63.0
male 453.0 30.7 14.7 0.4 21.0 29.0 39.0 80.0
summary = df.groupby("Class").agg(
    passengers=("PassengerId", "count"),
    survival=("Survived", "mean"),
    avg_age=("Age", "mean"),
    avg_fare=("Fare", "mean"),
)
summary.round(2)
passengers survival avg_age avg_fare
Class
First 216 0.63 38.23 84.15
Second 184 0.47 29.88 20.66
Third 491 0.24 25.14 13.68
df.groupby("Class").agg({"Age": ["min", "max"], "Fare": "median"})
Age Fare
min max median
Class
First 0.92 80.0 60.2875
Second 0.67 70.0 14.2500
Third 0.42 74.0 8.0500

Common mistake: a column name with the wrong capital letter

df.groupby("Class").agg(avg_fare=("fare", "mean"))
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[21], line 1
----> 1 df.groupby("Class").agg(avg_fare=("fare", "mean"))

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\generic.py:2291, in DataFrameGroupBy.aggregate(self, func, engine, engine_kwargs, *args, **kwargs)
   2288     kwargs["engine_kwargs"] = engine_kwargs
   2290 op = GroupByApply(self, func, args=args, kwargs=kwargs)
-> 2291 result = op.agg()
   2292 if not is_dict_like(func) and result is not None:
   2293     # GH #52849
   2294     if not self.as_index and is_list_like(func):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:294, in Apply.agg(self)
    291     return self.apply_str()
    293 if is_dict_like(func):
--> 294     return self.agg_dict_like()
    295 elif is_list_like(func):
    296     # we require a list, but not a 'str'
    297     return self.agg_list_like()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:511, in Apply.agg_dict_like(self)
    503 def agg_dict_like(self) -> DataFrame | Series:
    504     """
    505     Compute aggregation in the case of a dict-like argument.
    506 
   (...)
    509     Result of aggregation.
    510     """
--> 511     return self.agg_or_apply_dict_like(op_name="agg")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:1677, in GroupByApply.agg_or_apply_dict_like(self, op_name)
   1672     kwargs.update({"engine": engine, "engine_kwargs": engine_kwargs})
   1674 with com.temp_setattr(
   1675     obj, "as_index", True, condition=hasattr(obj, "as_index")
   1676 ):
-> 1677     result_index, result_data = self.compute_dict_like(
   1678         op_name, selected_obj, selection, kwargs
   1679     )
   1680 result = self.wrap_results_dict_like(selected_obj, result_index, result_data)
   1681 return result

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:550, in Apply.compute_dict_like(self, op_name, selected_obj, selection, kwargs)
    548 is_groupby = isinstance(obj, (DataFrameGroupBy, SeriesGroupBy))
    549 func = cast(AggFuncTypeDict, self.func)
--> 550 func = self.normalize_dictlike_arg(op_name, selected_obj, func)
    552 is_non_unique_col = (
    553     selected_obj.ndim == 2
    554     and selected_obj.columns.nunique() < len(selected_obj.columns)
    555 )
    557 if selected_obj.ndim == 1:
    558     # key only used for output

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\apply.py:762, in Apply.normalize_dictlike_arg(self, how, obj, func)
    759     cols = Index(list(func.keys())).difference(obj.columns, sort=True)
    760     if len(cols) > 0:
    761         # GH 58474
--> 762         raise KeyError(f"Label(s) {list(cols)} do not exist")
    764 aggregator_types = (list, tuple, dict)
    766 # if we have a dict of any non-scalars
    767 # eg. {'A' : ['mean']}, normalize all to
    768 # be list-likes
    769 # Cannot use func.values() because arg may be a Series

KeyError: "Label(s) ['fare'] do not exist"
df.groupby("Class").agg(avg_fare=("Fare", "mean")).round(2)
avg_fare
Class
First 84.15
Second 20.66
Third 13.68

Your turn#

Find the youngest and oldest survivor in each class.

survivors = df[df["Survived"] == 1]
survivors.groupby("Class")["Age"].agg(["min", "max"])
min max
Class
First 0.92 80.0
Second 0.67 62.0
Third 0.42 63.0

Group by two columns and reshape#

Problem: see how class and sex combine.

rates = df.groupby(["Class", "Sex"])["Survived"].mean()
rates.round(3)
Class   Sex   
First   female    0.968
        male      0.369
Second  female    0.921
        male      0.157
Third   female    0.500
        male      0.135
Name: Survived, dtype: float64
table = rates.unstack().round(3)
table
Sex female male
Class
First 0.968 0.369
Second 0.921 0.157
Third 0.500 0.135

Common mistake: forgetting the list brackets around two keys

df.groupby("Class", "Sex").size()
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[26], line 1
----> 1 df.groupby("Class", "Sex").size()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\util\_decorators.py:336, in deprecate_nonkeyword_arguments.<locals>.decorate.<locals>.wrapper(*args, **kwargs)
    330 if len(args) > num_allow_args:
    331     warnings.warn(
    332         msg.format(arguments=_format_argument_list(allow_args)),
    333         klass,
    334         stacklevel=find_stack_level(),
    335     )
--> 336 return func(*args, **kwargs)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py:10835, in DataFrame.groupby(self, by, level, as_index, sort, group_keys, observed, dropna)
  10832 if level is None and by is None:
  10833     raise TypeError("You have to supply one of 'by' and 'level'")
> 10835 return DataFrameGroupBy(
  10836     obj=self,
  10837     keys=by,
  10838     level=level,
  10839     as_index=as_index,
  10840     sort=sort,
  10841     group_keys=group_keys,
  10842     observed=observed,
  10843     dropna=dropna,
  10844 )

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\groupby.py:1095, in GroupBy.__init__(self, obj, keys, level, grouper, exclusions, selection, as_index, sort, group_keys, observed, dropna)
   1092 self.dropna = dropna
   1094 if grouper is None:
-> 1095     grouper, exclusions, obj = get_grouper(
   1096         obj,
   1097         keys,
   1098         level=level,
   1099         sort=sort,
   1100         observed=observed,
   1101         dropna=self.dropna,
   1102     )
   1104 self.observed = observed
   1105 self.obj = obj

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\grouper.py:789, in get_grouper(obj, key, level, sort, observed, validate, dropna)
    787 if isinstance(level, str):
    788     if obj.index.name != level:
--> 789         raise ValueError(f"level name {level} is not the name of the index")
    790 elif level > 0 or level < -1:
    791     raise ValueError("level > 0 or level < -1 only valid with MultiIndex")

ValueError: level name Sex is not the name of the index
df.groupby(["Class", "Sex"]).size()
Class   Sex   
First   female     94
        male      122
Second  female     76
        male      108
Third   female    144
        male      347
dtype: int64

Quick check: which class and sex had the lowest survival rate?

print(rates.idxmin())
print(round(rates.min(), 3))
('Third', 'male')
0.135
import matplotlib.pyplot as plt
ax = table.plot(kind="bar", figsize=(7, 4), rot=0)
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
flat = df.groupby(["Class", "Sex"], as_index=False)["Survived"]
flat.mean().round(3)
Class Sex Survived
0 First female 0.968
1 First male 0.369
2 Second female 0.921
3 Second male 0.157
4 Third female 0.500
5 Third male 0.135

Your turn#

Build a grid of survival rate by port (Embarked) and sex.

port_sex = df.groupby(["Embarked", "Sex"])["Survived"].mean()
port_sex.unstack().round(2)
Sex female male
Embarked
C 0.88 0.31
Q 0.75 0.07
S 0.69 0.17

Keep every row with transform, drop groups with filter#

Problem: compare each passenger with their own group.

grp_fare = df.groupby("Class")["Fare"]
df["class_fare"] = grp_fare.transform("mean")
df["above_avg"] = df["Fare"] > df["class_fare"]
df[["Class", "Fare", "class_fare", "above_avg"]].head().round(2)
Class Fare class_fare above_avg
0 Third 7.25 13.68 False
1 First 71.28 84.15 False
2 Third 7.92 13.68 False
3 First 53.10 84.15 False
4 Third 8.05 13.68 False
df.groupby("Class")["above_avg"].mean().round(2)
Class
First     0.31
Second    0.43
Third     0.31
Name: above_avg, dtype: float64

Quick check: why not use plain mean() for that new column?

print(grp_fare.mean().shape)
print(grp_fare.transform("mean").shape)
(3,)
(891,)
class_median = df.groupby("Class")["Age"].transform("median")
df["Age"] = df["Age"].fillna(class_median)
print(df["Age"].isna().sum())
0
big = df.groupby("Embarked").filter(lambda g: len(g) > 100)
big["Embarked"].value_counts()
Embarked
S    644
C    168
Name: count, dtype: int64

Your turn#

Keep only the classes whose survival rate is above 0.4.

def good_odds(g):
    return g["Survived"].mean() > 0.4

kept = df.groupby("Class").filter(good_odds)
kept["Class"].value_counts()
Class
First     216
Second    184
Name: count, dtype: int64

Sort and pick the top rows in each group#

Problem: find the biggest or oldest inside every group, not just overall.

medians = df.groupby("Class")["Fare"].median().round(2)
medians.sort_values(ascending=False)
Class
First     60.29
Second    14.25
Third      8.05
Name: Fare, dtype: float64

Common mistake: sorting the grouped object instead of the data

df.groupby("Class").sort_values("Fare")
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[39], line 1
----> 1 df.groupby("Class").sort_values("Fare")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\groupby\groupby.py:1115, in GroupBy.__getattr__(self, attr)
   1112 if attr in self.obj:
   1113     return self[attr]
-> 1115 raise AttributeError(
   1116     f"'{type(self).__name__}' object has no attribute '{attr}'"
   1117 )

AttributeError: 'DataFrameGroupBy' object has no attribute 'sort_values'
by_fare = df.sort_values("Fare", ascending=False)
top2 = by_fare.groupby("Class").head(2)
top2[["Class", "Name", "Fare"]].round(2)
Class Name Fare
679 First Cardeza, Mr. Thomas Drake Martinez 512.33
258 First Ward, Miss. Anna 512.33
665 Second Hickman, Mr. Lewis 73.50
385 Second Davies, Mr. Charles Henry 73.50
180 Third Sage, Miss. Constance Gladys 69.55
201 Third Sage, Mr. Frederick 69.55

Your turn#

Show the oldest passenger in each class, with their name. Hint: idxmax.

oldest = df.groupby("Class")["Age"].idxmax()
df.loc[oldest, ["Class", "Name", "Age"]]
Class Name Age
630 First Barkworth, Mr. Algernon Henry Wilson 80.0
672 Second Mitchell, Mr. Henry Michael 70.0
851 Third Svensson, Mr. Johan 74.0

Mini-project: did travelling with family help?#

Family size = siblings or spouses + parents or children + yourself.

Plan: build the feature, group it, test hidden causes, conclude.

df["family_size"] = df["SibSp"] + df["Parch"] + 1
by_size = df.groupby("family_size")["Survived"]
by_size.agg(["size", "mean"]).round(3)
size mean
family_size
1 537 0.304
2 161 0.553
3 102 0.578
4 29 0.724
5 15 0.200
6 22 0.136
7 12 0.333
8 6 0.000
11 7 0.000
labels = ["Alone", "Small (2-4)", "Large (5+)"]
df["family"] = pd.cut(df["family_size"], bins=[0, 1, 4, 11],
                      labels=labels)
family = df.groupby("family", observed=True).agg(
    passengers=("Survived", "size"),
    survival=("Survived", "mean"),
)
family.round(3)
passengers survival
family
Alone 537 0.304
Small (2-4) 292 0.579
Large (5+) 62 0.161
both = df.groupby(["Class", "family"], observed=True)["Survived"]
both.mean().unstack().round(2)
family Alone Small (2-4) Large (5+)
Class
First 0.53 0.73 0.67
Second 0.35 0.63 1.00
Third 0.21 0.41 0.07
sex_fam = df.groupby(["Sex", "family"], observed=True)["Survived"]
sex_fam.agg(["size", "mean"]).round(2)
size mean
Sex family
female Alone 126 0.79
Small (2-4) 155 0.81
Large (5+) 33 0.27
male Alone 411 0.16
Small (2-4) 137 0.32
Large (5+) 29 0.03
ax = family["survival"].plot(kind="bar", rot=0, figsize=(7, 4))
ax.set_title("Titanic survival rate by family size")
ax.set_xlabel("Travelling party")
ax.set_ylabel("Share who survived")
plt.show()
No description has been provided for this image
best = family["survival"].idxmax()
rate = family.loc[best, "survival"]
alone = family.loc["Alone", "survival"]
print(f"{best}: {rate:.0%} survived, alone: {alone:.0%}")
Small (2-4): 58% survived, alone: 30%

Recap#

  • groupby splits rows, applies a calculation, and combines the results
  • Select columns before mean(); text columns can't be averaged
  • size counts rows, count skips gaps, dropna=False keeps missing keys
  • agg with named pairs builds a clean summary table
  • transform keeps every row; filter drops whole groups

Challenges#

  1. Average fare and survival rate by port of embarkation.
  2. Use agg to find the median fare and survival rate per family band.
  3. Use transform to flag passengers older than their class median.

What's next#

Lesson 28: Multi-level Grouping and Custom Aggregations Write your own aggregation functions and work with grouped indexes.

Found this useful?

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