Mathew K Analytics

Lesson 16 · Pandas Full Course

Handle Missing Data in Pandas: Find, Drop, Fill and Flag NaN Values | Pandas Course #16

In this lesson you'll be able to: Count and locate the missing values in any DataFrame Spot patterns in what's missing, with a quick chart Drop, fill or…

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 16: Identifying and Handling Missing Data#

In this lesson you'll be able to:

  • Count and locate the missing values in any DataFrame
  • Spot patterns in what's missing, with a quick chart
  • Drop, fill or flag gaps, and know which to choose
  • Patch the gaps in a time series with forward fill Data: the Titanic passenger list, Apple daily share prices and a salary survey

Warm-up: back on the Titanic#

Last lesson we explored this passenger list. Let's reload it.

import pandas as pd
import numpy as np

df = pd.read_csv("data/pandas/titanic.csv")
df.groupby("Pclass")["Survived"].mean().round(2)
Pclass
1    0.63
2    0.47
3    0.24
Name: Survived, dtype: float64
print(len(df), "passengers")
print(df["Age"].count(), "ages recorded")
891 passengers
714 ages recorded

Find the gaps: how much is missing?#

Before cleaning anything, measure the problem column by column.

df.info()
<class 'pandas.DataFrame'>
RangeIndex: 891 entries, 0 to 890
Data columns (total 12 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   PassengerId  891 non-null    int64  
 1   Survived     891 non-null    int64  
 2   Pclass       891 non-null    int64  
 3   Name         891 non-null    str    
 4   Sex          891 non-null    str    
 5   Age          714 non-null    float64
 6   SibSp        891 non-null    int64  
 7   Parch        891 non-null    int64  
 8   Ticket       891 non-null    str    
 9   Fare         891 non-null    float64
 10  Cabin        204 non-null    str    
 11  Embarked     889 non-null    str    
dtypes: float64(2), int64(5), str(5)
memory usage: 118.9 KB
df.isna().sum()
PassengerId      0
Survived         0
Pclass           0
Name             0
Sex              0
Age            177
SibSp            0
Parch            0
Ticket           0
Fare             0
Cabin          687
Embarked         2
dtype: int64
missing_pct = df.isna().mean().mul(100).round(1)
missing_pct.sort_values(ascending=False).head(4)
Cabin          77.1
Age            19.9
Embarked        0.2
PassengerId     0.0
dtype: float64
has_gap = df.isna().any(axis=1)
print(has_gap.sum(), "rows have at least one gap")
df.loc[has_gap, ["Pclass", "Age", "Cabin", "Embarked"]].head()
708 rows have at least one gap
Pclass Age Cabin Embarked
0 3 22.0 NaN S
2 3 26.0 NaN S
4 3 35.0 NaN S
5 3 NaN NaN Q
7 3 2.0 NaN S
gaps_per_row = df.isna().sum(axis=1)
gaps_per_row.value_counts().sort_index()
0    183
1    550
2    158
Name: count, dtype: int64

Quick check: how many missing ages does df["Age"] == np.nan find?

print((df["Age"] == np.nan).sum(), "found with ==")
print(df["Age"].isna().sum(), "found with isna")
0 found with ==
177 found with isna

Missing values in disguise#

Real files often type Na, N/a or - instead of leaving a cell empty.

survey = pd.read_csv("data/pandas/salary_survey.csv")
city = survey["city"]
print(city.isna().sum(), "missing before")
fakes = ["N/a", "Na", "na", "-", "--"]
city[city.isin(fakes)].value_counts()
81 missing before
city
N/a    10
Na     10
-       9
na      4
--      4
Name: count, dtype: int64
city = city.replace(fakes, np.nan)
print(city.isna().sum(), "missing after")
118 missing after
path = "data/pandas/salary_survey.csv"
fixed = pd.read_csv(path, na_values=fakes)
print(fixed["city"].isna().sum(), "missing with na_values")
118 missing with na_values

See the pattern: where do the gaps cluster?#

Missing values are rarely random. A picture shows where they bunch up.

import matplotlib.pyplot as plt
import seaborn as sns

fig, ax = plt.subplots(figsize=(10, 4))
sns.heatmap(df.isna(), cbar=False, yticklabels=False, ax=ax)
ax.set_title("Missing values in the Titanic data (light = missing)")
ax.set_xlabel("Column")
ax.set_ylabel("Passenger (one row each)")
plt.show()
No description has been provided for this image
cabin_gap = df["Cabin"].isna()
cabin_gap.groupby(df["Pclass"]).mean().round(2)
Pclass
1    0.19
2    0.91
3    0.98
Name: Cabin, dtype: float64
df.groupby(cabin_gap)["Survived"].mean().round(2)
Cabin
False    0.67
True     0.30
Name: Survived, dtype: float64

Your turn: find the share of missing ages in each passenger class.

df["Age"].isna().groupby(df["Pclass"]).mean().round(2)
Pclass
1    0.14
2    0.06
3    0.28
Name: Age, dtype: float64

Drop with care#

Deleting rows or columns is quick, but it can quietly throw away most of your data.

age_known = df.dropna(subset=["Age"])
print("Before:", df.shape)
print("After: ", age_known.shape)
Before: (891, 12)
After:  (714, 12)

Quick check: how many rows survive a plain df.dropna()?

df.dropna().shape
(183, 12)
min_filled = int(len(df) * 0.7)
slim = df.dropna(axis=1, thresh=min_filled)
print("Need at least", min_filled, "values")
print("Dropped:", set(df.columns) - set(slim.columns))
Need at least 623 values
Dropped: {'Cabin'}

Your turn: drop only the passengers with no port. How many rows are left?

port_known = df.dropna(subset=["Embarked"])
print(len(df) - len(port_known), "rows dropped")
print(len(port_known), "rows left")
2 rows dropped
889 rows left

Fill the gaps with sensible values#

When a row is too valuable to lose, replace the gap with a reasonable estimate.

median_age = df["Age"].median()
age_filled = df["Age"].fillna(median_age)
print("Median age:", median_age)
print("Missing now:", age_filled.isna().sum())
Median age: 28.0
Missing now: 0
print(age_filled.value_counts().head(3))
print("Spread before:", df["Age"].std().round(2))
print("Spread after: ", age_filled.std().round(2))
Age
28.0    202
24.0     30
22.0     27
Name: count, dtype: int64
Spread before: 14.53
Spread after:  13.02
df.groupby("Pclass")["Age"].median()
Pclass
1    37.0
2    29.0
3    24.0
Name: Age, dtype: float64
class_median = df.groupby("Pclass")["Age"].transform("median")
df["Age_filled"] = df["Age"].fillna(class_median)
df.loc[df["Age"].isna(), ["Pclass", "Age", "Age_filled"]].head()
Pclass Age Age_filled
5 3 NaN 24.0
17 2 NaN 29.0
19 3 NaN 24.0
26 3 NaN 24.0
28 3 NaN 24.0
df["Age_filled"].value_counts().head(3)
Age_filled
24.0    166
37.0     36
29.0     31
Name: count, dtype: int64
fig, ax = plt.subplots(figsize=(8, 4))
age_filled.plot.hist(bins=40, alpha=0.6, ax=ax, label="One median")
df["Age_filled"].plot.hist(bins=40, alpha=0.6, ax=ax,
                           label="Class medians")
ax.set_title("Filled ages: one median vs class medians")
ax.set_xlabel("Age (years)")
ax.set_ylabel("Passengers")
ax.legend()
plt.show()
No description has been provided for this image
ages = pd.DataFrame({"original": df["Age"], "one median": age_filled,
                     "class medians": df["Age_filled"]})
ages.agg(["mean", "std"]).round(2)
original one median class medians
mean 29.70 29.36 29.07
std 14.53 13.02 13.24

Your turn: fill the 2 missing Embarked values with the most common port.

top_port = df["Embarked"].mode()[0]
df["Embarked"] = df["Embarked"].fillna(top_port)
print("Most common port:", top_port)
print("Missing now:", df["Embarked"].isna().sum())
Most common port: S
Missing now: 0
labelled = df.fillna({"Cabin": "Unknown", "Age": median_age})
labelled[["Age", "Cabin"]].isna().sum()
Age      0
Cabin    0
dtype: int64

Keep the gap, but flag it#

Sometimes the fact that a value is missing is the most useful thing about it.

df["Cabin_known"] = df["Cabin"].notna().astype(int)
df[["Cabin", "Cabin_known"]].head()
Cabin Cabin_known
0 NaN 0
1 C85 1
2 NaN 0
3 C123 1
4 NaN 0

Quick check: inside each class, does a known cabin still go with survival?

df.groupby(["Pclass", "Cabin_known"])["Survived"].mean().unstack().round(2)
Cabin_known 0 1
Pclass
1 0.48 0.66
2 0.44 0.81
3 0.24 0.50

Common mistake: turning a column with gaps into plain integers.

df["Age"].astype(int)
---------------------------------------------------------------------------
IntCastingNaNError                        Traceback (most recent call last)
Cell In[32], line 1
----> 1 df["Age"].astype(int)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py:6529, in NDFrame.astype(self, dtype, copy, errors)
   6525     results = [ser.astype(dtype, errors=errors) for _, ser in self.items()]
   6527 else:
   6528     # else, only a single dtype is given
-> 6529     new_data = self._mgr.astype(dtype=dtype, errors=errors)
   6530     res = self._constructor_from_mgr(new_data, axes=new_data.axes)
   6531     return res.__finalize__(self, method="astype")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\managers.py:627, in BaseBlockManager.astype(self, dtype, errors)
    626 def astype(self, dtype, errors: str = "raise") -> Self:
--> 627     return self.apply("astype", dtype=dtype, errors=errors)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\managers.py:445, in BaseBlockManager.apply(self, f, align_keys, **kwargs)
    443         applied = b.apply(f, **kwargs)
    444     else:
--> 445         applied = getattr(b, f)(**kwargs)
    446     result_blocks = extend_blocks(applied, result_blocks)
    448 out = type(self).from_blocks(result_blocks, [ax.view() for ax in self.axes])

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\internals\blocks.py:607, in Block.astype(self, dtype, errors, squeeze)
    604         raise ValueError("Can not squeeze with more than one column.")
    605     values = values[0, :]  # type: ignore[call-overload]
--> 607 new_values = astype_array_safe(values, dtype, errors=errors)
    609 new_values = maybe_coerce_values(new_values)
    611 refs = None

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:240, in astype_array_safe(values, dtype, copy, errors)
    237     dtype = dtype.numpy_dtype
    239 try:
--> 240     new_values = astype_array(values, dtype, copy=copy)
    241 except (ValueError, TypeError):
    242     # e.g. _astype_nansafe can fail on object-dtype of strings
    243     #  trying to convert to float
    244     if errors == "ignore":

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:185, in astype_array(values, dtype, copy)
    182     values = values.astype(dtype, copy=copy)
    184 else:
--> 185     values = _astype_nansafe(values, dtype, copy=copy)
    187 # in pandas we don't store numpy str dtypes, so convert to object
    188 if isinstance(dtype, np.dtype) and issubclass(values.dtype.type, str):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:102, in _astype_nansafe(arr, dtype, copy, skipna)
     97     return lib.ensure_string_array(
     98         arr, skipna=skipna, convert_na_value=False
     99     ).reshape(shape)
    101 elif np.issubdtype(arr.dtype, np.floating) and dtype.kind in "iu":
--> 102     return _astype_float_to_int_nansafe(arr, dtype, copy)
    104 elif arr.dtype == object:
    105     # if we have a datetime/timedelta array of objects
    106     # then coerce to datetime64[ns] and use DatetimeArray.astype
    108     if lib.is_np_dtype(dtype, "M"):

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\dtypes\astype.py:146, in _astype_float_to_int_nansafe(values, dtype, copy)
    142 """
    143 astype with a check preventing converting NaN to a meaningless integer value.
    144 """
    145 if not np.isfinite(values).all():
--> 146     raise IntCastingNaNError(
    147         "Cannot convert non-finite values (NA or inf) to integer."
    148         "Replace or remove non-finite values or cast to an integer type"
    149         "that supports these values (e.g. 'Int64')"
    150     )
    151 if dtype.kind == "u":
    152     # GH#45151
    153     if not (values >= 0).all():

IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer.Replace or remove non-finite values or cast to an integer typethat supports these values (e.g. 'Int64')
age_whole = df["Age"].round().astype("Int64")
print(age_whole.head(6))
print(age_whole.isna().sum(), "still missing")
0      22
1      38
2      26
3      35
4      35
5    <NA>
Name: Age, dtype: Int64
177 still missing

Fill gaps in a time series#

Markets close at weekends, so a daily calendar has holes. Forward fill carries the last price on.

aapl = pd.read_csv("data/pandas/aapl.csv", parse_dates=["Date"],
                   index_col="Date")
close = aapl["AAPL.Close"]
print(len(close), "trading days")
506 trading days
daily = close.asfreq("D")
print(len(daily), "calendar days,", daily.isna().sum(), "empty")
daily.head(8).round(2)
731 calendar days, 225 empty
Date
2015-02-17    127.83
2015-02-18    128.72
2015-02-19    128.45
2015-02-20    129.50
2015-02-21       NaN
2015-02-22       NaN
2015-02-23    133.00
2015-02-24    132.17
Freq: D, Name: AAPL.Close, dtype: float64
daily[daily.isna()].index.day_name().value_counts()
Date
Saturday    104
Sunday      104
Monday       10
Friday        5
Thursday      2
Name: count, dtype: int64

Common mistake: copying old code that writes fillna(method="ffill").

daily.fillna(method="ffill")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[37], line 1
----> 1 daily.fillna(method="ffill")

TypeError: NDFrame.fillna() got an unexpected keyword argument 'method'
compare = pd.DataFrame({"gap": daily, "ffill": daily.ffill(),
                        "bfill": daily.bfill()})
compare.loc["2015-02-20":"2015-02-23"].round(2)
gap ffill bfill
Date
2015-02-20 129.5 129.5 129.5
2015-02-21 NaN 129.5 133.0
2015-02-22 NaN 129.5 133.0
2015-02-23 133.0 133.0 133.0

Quick check: for Saturday's price, which fill is honest, ffill or bfill?

daily_filled = daily.ffill()
print(daily_filled.isna().sum(), "gaps left")
daily_filled.loc["2015-02-20":"2015-02-23"].round(2)
0 gaps left
Date
2015-02-20    129.5
2015-02-21    129.5
2015-02-22    129.5
2015-02-23    133.0
Freq: D, Name: AAPL.Close, dtype: float64
smooth = daily.interpolate()
smooth.loc["2015-02-20":"2015-02-23"].round(2)
Date
2015-02-20    129.50
2015-02-21    130.67
2015-02-22    131.83
2015-02-23    133.00
Freq: D, Name: AAPL.Close, dtype: float64
span = slice("2015-02-17", "2015-03-08")
fig, ax = plt.subplots(figsize=(8, 4))
daily_filled.loc[span].plot(ax=ax, drawstyle="steps-post",
                            label="Forward fill")
smooth.loc[span].plot(ax=ax, style="--", label="Interpolate")
ax.set_title("Apple close over weekends: ffill vs interpolate")
ax.set_xlabel("Date")
ax.set_ylabel("Closing price (USD)")
ax.legend()
plt.show()
No description has been provided for this image

Your turn: forward fill with limit=1. How many gaps remain, and on which days?

short_fill = daily.ffill(limit=1)
left = short_fill[short_fill.isna()]
print(len(left), "gaps left")
left.index.day_name().value_counts()
119 gaps left
Date
Sunday      104
Monday       10
Saturday      5
Name: count, dtype: int64

Mini-project: do missing ages hide a pattern?#

Passengers without an age seem to survive less often. Is that about age, or about class?

age_gap = df["Age"].isna()
df.groupby(age_gap)["Survived"].mean().round(2)
Age
False    0.41
True     0.29
Name: Survived, dtype: float64
df.loc[age_gap, "Pclass"].value_counts(normalize=True).round(2)
Pclass
3    0.77
1    0.17
2    0.06
Name: proportion, dtype: float64
by_class = df.groupby(["Pclass", age_gap])["Survived"].mean()
by_class.unstack().round(2)
Age False True
Pclass
1 0.66 0.47
2 0.48 0.36
3 0.24 0.25
clean = df.assign(Age=df["Age_filled"],
                  Age_missing=age_gap.astype(int))
clean = clean.drop(columns=["Cabin", "Age_filled"])
print(clean.isna().sum().sum(), "missing values left")
print(clean.shape)
0 missing values left
(891, 13)
import os
os.makedirs("output/pandas", exist_ok=True)
clean.to_csv("output/pandas/l16_titanic_clean.csv", index=False)
check = pd.read_csv("output/pandas/l16_titanic_clean.csv")
print(check.shape, check.isna().sum().sum(), "missing")
(891, 13) 0 missing

Finding: a missing age mostly signals third class, not worse luck. Fill ages by class, and keep the flag so nothing is hidden.

Recap#

  • isna().sum() and isna().mean() count and measure the gaps
  • NaN never equals anything: test with isna, never ==
  • dropna is quick but can delete most rows; use subset, thresh
  • fillna with a median, group median or mode, but watch the spike
  • Flag gaps with notna(); Int64 keeps integer columns with gaps
  • In time series, ffill carries the last known value forward

Challenges#

  1. Fill missing ages by class AND sex. How far apart are the medians?
  2. In data/pandas/penguins.csv, find the rows missing sex. Drop or flag?
  3. In the penguins data, fill missing body_mass_g with each species' median.

What's next#

Lesson 17: Dealing with Duplicates and Inconsistent Data. The gaps are handled. Next: repeated rows and messy labels.

Found this useful?

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