Mathew K Analytics

Lesson 15 · Pandas Full Course

Pandas Mini Project: Analyse the Titanic Dataset From Scratch | Pandas Course #15

In this lesson you'll be able to: Inspect and tidy a real dataset before you analyse it Answer questions with filters, query and isin Sort, rank and slice…

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 15: Mini Project - Analysing Titanic Dataset Basics#

In this lesson you'll be able to:

  • Inspect and tidy a real dataset before you analyse it
  • Answer questions with filters, query and isin
  • Sort, rank and slice to find the stories in the data
  • Test a famous claim: was it really women and children first? Data: the Titanic passenger list, 891 passengers

Warm-up: a cross-section from last lesson#

In lesson 14 we sliced a MultiIndex with xs. Same trick, new data.

import pandas as pd

df = pd.read_csv("data/pandas/titanic.csv")
by_sex = df.set_index(["Sex", "Pclass"]).sort_index()
women = by_sex.xs("female", level="Sex")
print(len(women), "women on board")
314 women on board

Step 1: get to know the data#

Before answering anything, check the size, the columns and the types.

print(df.shape)
df.dtypes
(891, 12)
PassengerId      int64
Survived         int64
Pclass           int64
Name               str
Sex                str
Age            float64
SibSp            int64
Parch            int64
Ticket             str
Fare           float64
Cabin              str
Embarked           str
dtype: object
df.iloc[0]
PassengerId                          1
Survived                             0
Pclass                               3
Name           Braund, Mr. Owen Harris
Sex                               male
Age                               22.0
SibSp                                1
Parch                                0
Ticket                       A/5 21171
Fare                              7.25
Cabin                              NaN
Embarked                             S
Name: 0, dtype: object
df = df.rename(columns={"Pclass": "Class", "SibSp": "Siblings",
                        "Parch": "Parents"})
print(df.columns.tolist())
['PassengerId', 'Survived', 'Class', 'Name', 'Sex', 'Age', 'Siblings', 'Parents', 'Ticket', 'Fare', 'Cabin', 'Embarked']

Quick check: what share of passengers do you think survived?

print(df["Survived"].value_counts())
print("Survival rate:", round(df["Survived"].mean(), 3))
Survived
0    549
1    342
Name: count, dtype: int64
Survival rate: 0.384
df[["Age", "Fare", "Siblings", "Parents"]].describe().round(1)
Age Fare Siblings Parents
count 714.0 891.0 891.0 891.0
mean 29.7 32.2 0.5 0.4
std 14.5 49.7 1.1 0.8
min 0.4 0.0 0.0 0.0
25% 20.1 7.9 0.0 0.0
50% 28.0 14.5 0.0 0.0
75% 38.0 31.0 1.0 0.0
max 80.0 512.3 8.0 6.0
df.isna().sum().sort_values(ascending=False).head(3)
Cabin       687
Age         177
Embarked      2
dtype: int64

Step 2: filter to answer questions#

Boolean masks, query and isin turn questions into code.

is_woman = df["Sex"] == "female"
print("Women:", round(df.loc[is_woman, "Survived"].mean(), 3))
print("Men:  ", round(df.loc[~is_woman, "Survived"].mean(), 3))
Women: 0.742
Men:   0.189
is_child = df["Age"] < 16
print(is_child.sum(), "children under 16")
print("Children:", round(df.loc[is_child, "Survived"].mean(), 3))
83 children under 16
Children: 0.59
twenties = df[df["Age"].between(20, 29)]
print(len(twenties), "passengers in their twenties")
print("Survival:", round(twenties["Survived"].mean(), 3))
220 passengers in their twenties
Survival: 0.35

Common mistake: combining masks without brackets.

df[df["Sex"] == "female" & df["Age"] < 16]
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\ops\array_ops.py:374, in na_logical_op(x, y, op)
    365 try:
    366     # For exposition, write:
    367     #  yarr = isinstance(y, np.ndarray)
   (...)
    372     # Then Cases where this goes through without raising include:
    373     #  (xint or xbool) and (yint or bool)
--> 374     result = op(x, y)
    375 except TypeError:

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\roperator.py:55, in rand_(left, right)
     54 def rand_(left, right):
---> 55     return operator.and_(right, left)

TypeError: ufunc 'bitwise_and' not supported for the input types, and the inputs could not be safely coerced to any supported types according to the casting rule ''safe''

During handling of the above exception, another exception occurred:

ValueError                                Traceback (most recent call last)
File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\ops\array_ops.py:388, in na_logical_op(x, y, op)
    387 try:
--> 388     result = libops.scalar_binop(x, y, op)
    389 except (
    390     TypeError,
    391     ValueError,
   (...)
    394     NotImplementedError,
    395 ) as err:

File pandas/_libs/ops.pyx:178, in pandas._libs.ops.scalar_binop()

ValueError: Buffer dtype mismatch, expected 'Python object' but got 'double'

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

TypeError                                 Traceback (most recent call last)
Cell In[12], line 1
----> 1 df[df["Sex"] == "female" & df["Age"] < 16]

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\ops\common.py:85, in _unpack_zerodim_and_defer.<locals>.new_method(self, other)
     82     other = sanitize_array(other, None)
     83     other = ensure_wrapped_if_datetimelike(other)
---> 85 return method(self, other)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arraylike.py:76, in OpsMixin.__rand__(self, other)
     74 @unpack_zerodim_and_defer("__rand__")
     75 def __rand__(self, other):
---> 76     return self._logical_method(other, roperator.rand_)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\series.py:6746, in Series._logical_method(self, other, op)
   6743 lvalues = self._values
   6744 rvalues = extract_array(other, extract_numpy=True, extract_range=True)
-> 6746 res_values = ops.logical_op(lvalues, rvalues, op)
   6747 return self._construct_result(res_values, name=res_name, other=other)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\ops\array_ops.py:464, in logical_op(left, right, op)
    460 else:
    461     # i.e. scalar
    462     is_other_int_dtype = lib.is_integer(rvalues)
--> 464 res_values = na_logical_op(lvalues, rvalues, op)
    466 # For int vs int `^`, `|`, `&` are bitwise operators and return
    467 #   integer dtypes.  Otherwise these are boolean ops
    468 if not (left.dtype.kind in "iu" and is_other_int_dtype):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\ops\array_ops.py:397, in na_logical_op(x, y, op)
    389         except (
    390             TypeError,
    391             ValueError,
   (...)
    394             NotImplementedError,
    395         ) as err:
    396             typ = type(y).__name__
--> 397             raise TypeError(
    398                 f"Cannot perform '{op.__name__}' with a dtyped [{x.dtype}] array "
    399                 f"and scalar of type [{typ}]"
    400             ) from err
    402 return result.reshape(x.shape)

TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool]
girls = df[(df["Sex"] == "female") & (df["Age"] < 16)]
print(len(girls), "girls")
print("Survival:", round(girls["Survived"].mean(), 3))
43 girls
Survival: 0.651
rich_women = df.query("Class == 1 and Sex == 'female'")
print(len(rich_women), "first class women")
print("Survival:", round(rich_women["Survived"].mean(), 3))
94 first class women
Survival: 0.968
irish_french = df[df["Embarked"].isin(["C", "Q"])]
irish_french["Embarked"].value_counts()
Embarked
C    168
Q     77
Name: count, dtype: int64
for port in ["S", "C", "Q"]:
    rate = df.loc[df["Embarked"] == port, "Survived"].mean()
    print(port, round(rate, 3))
S 0.337
C 0.554
Q 0.39

Your turn: what was the survival rate of men in first class?

first_men = df.query("Class == 1 and Sex == 'male'")
print(len(first_men), "men,",
      round(first_men["Survived"].mean(), 3), "survived")
122 men, 0.369 survived

Step 3: build a tidy working table#

Keep the useful columns, set a meaningful index, add new columns.

keep = ["PassengerId", "Name", "Class", "Sex", "Age", "Siblings",
        "Parents", "Fare", "Embarked", "Survived"]
tidy = df[keep].set_index("PassengerId")
print(tidy.shape)
tidy.iloc[:3, 1:6]
(891, 9)
Class Sex Age Siblings Parents
PassengerId
1 3 male 22.0 1 0
2 1 female 38.0 1 0
3 3 female 26.0 0 0
tidy.loc[[1, 2, 3], ["Class", "Sex", "Fare", "Survived"]]
Class Sex Fare Survived
PassengerId
1 3 male 7.2500 0
2 1 female 71.2833 1
3 3 female 7.9250 1
tidy["Family"] = tidy["Siblings"] + tidy["Parents"] + 1
tidy["Family"].value_counts().sort_index().head(5)
Family
1    537
2    161
3    102
4     29
5     15
Name: count, dtype: int64
bins = [0, 15, 59, 100]
labels = ["child", "adult", "senior"]
tidy["AgeGroup"] = pd.cut(tidy["Age"], bins=bins, labels=labels)
tidy["AgeGroup"].value_counts(dropna=False)
AgeGroup
adult     605
NaN       177
child      83
senior     26
Name: count, dtype: int64

Common mistake: using an old column name after renaming.

tidy["Pclass"].value_counts()
---------------------------------------------------------------------------
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: 'Pclass'

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

KeyError                                  Traceback (most recent call last)
Cell In[22], line 1
----> 1 tidy["Pclass"].value_counts()

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: 'Pclass'
tidy["Class"].value_counts().sort_index()
Class
1    216
2    184
3    491
Name: count, dtype: int64

Your turn: what share of passengers travelled alone?

alone = tidy["Family"] == 1
print(round(alone.mean(), 3), "travelled alone")
print(round(tidy.loc[alone, "Survived"].mean(), 3), "of them survived")
0.603 travelled alone
0.304 of them survived

Step 4: sort and rank to find stories#

Who paid the most, and did money buy a seat in a lifeboat?

cols = ["Name", "Class", "Fare", "Survived"]
tidy.nlargest(5, "Fare")[cols]
Name Class Fare Survived
PassengerId
259 Ward, Miss. Anna 1 512.3292 1
680 Cardeza, Mr. Thomas Drake Martinez 1 512.3292 1
738 Lesurer, Mr. Gustave J 1 512.3292 1
28 Fortune, Mr. Charles Alexander 1 263.0000 0
89 Fortune, Miss. Mabel Helen 1 263.0000 1
tidy["Fare_pct"] = tidy["Fare"].rank(pct=True)
top_q = tidy["Fare_pct"] > 0.75
print("Top quarter:   ", round(tidy.loc[top_q, "Survived"].mean(), 3))
print("Bottom quarter:", round(tidy.loc[tidy["Fare_pct"] <= 0.25,
                                         "Survived"].mean(), 3))
Top quarter:    0.581
Bottom quarter: 0.197
quarters = ["cheapest", "lower", "upper", "dearest"]
tidy["FareBand"] = pd.qcut(tidy["Fare"], 4, labels=quarters)
band = tidy.groupby("FareBand", observed=True)["Survived"]
band.mean().round(3)
FareBand
cheapest    0.197
lower       0.304
upper       0.455
dearest     0.581
Name: Survived, dtype: float64
by_age = tidy.sort_values("Age", ascending=False)
by_age[["Name", "Class", "Age", "Survived"]].head(5)
Name Class Age Survived
PassengerId
631 Barkworth, Mr. Algernon Henry Wilson 1 80.0 1
852 Svensson, Mr. Johan 3 74.0 0
494 Artagaveytia, Mr. Ramon 1 71.0 0
97 Goldschmidt, Mr. George B 1 71.0 0
117 Connors, Mr. Patrick 3 70.5 0

Common mistake: asking nlargest for a text column.

tidy.nlargest(3, "Name")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[29], line 1
----> 1 tidy.nlargest(3, "Name")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py:8846, in DataFrame.nlargest(self, n, columns, keep)
   8709 def nlargest(
   8710     self, n: int, columns: IndexLabel, keep: NsmallestNlargestKeep = "first"
   8711 ) -> DataFrame:
   8712     """
   8713     Return the first `n` rows ordered by `columns` in descending order.
   8714 
   (...)
   8844     Brunei      434000    12128      BN
   8845     """
-> 8846     return selectn.SelectNFrame(self, n=n, keep=keep, columns=columns).nlargest()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\methods\selectn.py:73, in SelectN.nlargest(self)
     71 @final
     72 def nlargest(self) -> NDFrameT:
---> 73     return self.compute("nlargest")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\methods\selectn.py:232, in SelectNFrame.compute(self, method)
    230     dtype = frame[column].dtype
    231     if not self.is_valid_dtype_n_method(dtype):
--> 232         raise TypeError(
    233             f"Column {column!r} has dtype {dtype}, "
    234             f"cannot use method {method!r} with this dtype"
    235         )
    237 def get_indexer(current_indexer: Index, other_indexer: Index) -> Index:
    238     """
    239     Helper function to concat `current_indexer` and `other_indexer`
    240     depending on `method`
    241     """

TypeError: Column 'Name' has dtype str, cannot use method 'nlargest' with this dtype
tidy.sort_values("Name", ascending=False)["Name"].head(3)
PassengerId
869        van Melkebeke, Mr. Philemon
154    van Billiard, Mr. Austin Blyler
362          del Carlo, Mr. Sebastiano
Name: Name, dtype: str
big = tidy.sort_values(["Family", "Fare"], ascending=False)
big[["Name", "Family", "Survived"]].head(4)
Name Family Survived
PassengerId
160 Sage, Master. Thomas Henry 11 0
181 Sage, Miss. Constance Gladys 11 0
202 Sage, Mr. Frederick 11 0
325 Sage, Mr. George John Jr 11 0

Quick check: do small families survive more often than solo travellers?

fam = tidy.groupby("Family")["Survived"].agg(["size", "mean"])
fam.round(2)
size mean
Family
1 537 0.30
2 161 0.55
3 102 0.58
4 29 0.72
5 15 0.20
6 22 0.14
7 12 0.33
8 6 0.00
11 7 0.00

Quick check: among children, did the youngest survive more often?

kids = tidy[tidy["AgeGroup"] == "child"]
young = kids["Age"] <= 5
print("Age 5 and under:", round(kids.loc[young, "Survived"].mean(), 3))
print("Age 6 to 15:    ", round(kids.loc[~young, "Survived"].mean(), 3))
Age 5 and under: 0.705
Age 6 to 15:     0.462

Step 5: slice survival by class and sex#

A MultiIndex table lets us read any combination in one line.

rates = tidy.groupby(["Class", "Sex"])["Survived"].mean()
rates.round(2)
Class  Sex   
1      female    0.97
       male      0.37
2      female    0.92
       male      0.16
3      female    0.50
       male      0.14
Name: Survived, dtype: float64
tidy.groupby(["Class", "Sex"]).size().unstack("Sex")
Sex female male
Class
1 94 122
2 76 108
3 144 347
rates.xs("female", level="Sex").round(2)
Class
1    0.97
2    0.92
3    0.50
Name: Survived, dtype: float64

Your turn: use xs to get men's survival in each class.

rates.xs("male", level="Sex").round(2)
Class
1    0.37
2    0.16
3    0.14
Name: Survived, dtype: float64
table = rates.unstack("Sex").round(2)
table["gap"] = table["female"] - table["male"]
table
Sex female male gap
Class
1 0.97 0.37 0.60
2 0.92 0.16 0.76
3 0.50 0.14 0.36

Quick check: who survived more often, a 3rd class woman or a 1st class man?

print("3rd class women:", rates.loc[(3, "female")].round(2))
print("1st class men:  ", rates.loc[(1, "male")].round(2))
3rd class women: 0.5
1st class men:   0.37
import matplotlib.pyplot as plt

ax = table[["female", "male"]].plot.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

Mini-project finale: women and children first?#

Did the famous rule hold, and did it hold equally in every class?

tidy["Group"] = "man"
tidy.loc[tidy["Sex"] == "female", "Group"] = "woman"
tidy.loc[tidy["AgeGroup"] == "child", "Group"] = "child"
tidy["Group"].value_counts()
Group
man      537
woman    271
child     83
Name: count, dtype: int64
tidy.groupby("Group")["Survived"].mean().sort_values().round(3)
Group
man      0.164
child    0.590
woman    0.756
Name: Survived, dtype: float64
tidy.groupby("Survived")["Age"].median()
Survived
0    28.0
1    28.0
Name: Age, dtype: float64
grid = tidy.pivot_table(index="Class", columns="Group",
                        values="Survived", aggfunc="mean")
grid[["child", "woman", "man"]].round(2)
Group child woman man
Class
1 0.83 0.98 0.35
2 1.00 0.91 0.08
3 0.43 0.49 0.12
ax = grid[["child", "woman", "man"]].plot.bar(figsize=(7, 4), rot=0)
ax.set_title("Survival by class: children, women and men")
ax.set_xlabel("Passenger class")
ax.set_ylabel("Share who survived")
plt.show()
No description has been provided for this image
third_kids = tidy.query("Class == 3 and Group == 'child'")
print(len(third_kids), "third class children")
print(third_kids["Survived"].value_counts().sort_index())
58 third class children
Survived
0    33
1    25
Name: count, dtype: int64
kid_sex = third_kids.groupby("Sex")["Survived"].agg(["size", "mean"])
kid_sex.round(2)
size mean
Sex
female 30 0.53
male 28 0.32
import os
os.makedirs("output/pandas", exist_ok=True)
tidy.to_csv("output/pandas/l15_titanic_tidy.csv")
back = pd.read_csv("output/pandas/l15_titanic_tidy.csv",
                   index_col="PassengerId")
print(back.shape)
(891, 14)

Finding: women and children first largely held, but class decided how well. In 1st and 2nd class nearly all women and children lived; in 3rd, fewer than half.

Recap#

  • Start every project with shape, dtypes, head and isna().sum()
  • Masks need brackets: (a) & (b); query and isin read clearly
  • A tidy table: chosen columns, a meaningful index, new columns
  • nlargest and rank(pct=True) reveal who paid most, and why it mattered
  • xs, unstack and tuples slice a two-key table in any direction

Challenges#

  1. Repeat the finale for each port. Did Cherbourg passengers fare better?
  2. Rank fares within each class. Did top-fare passengers survive more?
  3. Take the first letter of Cabin as the deck. Which deck survived best?

What's next#

Lesson 16: Identifying and Handling Missing Data. 177 ages were missing today. Next, we find and fix gaps properly.

Found this useful?

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