Mathew K Analytics

Lesson 20 · Pandas Full Course

Detect Outliers in Pandas (IQR Rule) and Validate Your Data | Pandas Course #20

In this lesson you'll be able to: Spot extreme values with summaries and box plots Flag outliers with the IQR rule, overall and within groups Choose between…

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 20: Handling Outliers and Data Validation#

In this lesson you'll be able to:

  • Spot extreme values with summaries and box plots
  • Flag outliers with the IQR rule, overall and within groups
  • Choose between keeping, capping and removing outliers
  • Write validation rules that catch impossible values Data: the Titanic passenger list and New York taxi trips

Warm-up: titles hidden in names#

Last lesson we cleaned text with .str. A quick reminder on Titanic.

import pandas as pd
import numpy as np

df = pd.read_csv("data/pandas/titanic.csv")
titles = df["Name"].str.extract(r",\s*([^.]+)\.")[0]
titles.value_counts().head()
0
Mr        517
Miss      182
Mrs       125
Master     40
Dr          7
Name: count, dtype: int64

Spot the extremes before you fix anything#

Summaries and a box plot show which values sit far from the rest.

df[["Age", "Fare"]].describe().round(2)
Age Fare
count 714.00 891.00
mean 29.70 32.20
std 14.53 49.69
min 0.42 0.00
25% 20.12 7.91
50% 28.00 14.45
75% 38.00 31.00
max 80.00 512.33
df["Fare"].sort_values(ascending=False).head(6)
679    512.3292
258    512.3292
737    512.3292
88     263.0000
438    263.0000
341    263.0000
Name: Fare, dtype: float64
import matplotlib.pyplot as plt
import seaborn as sns

fig, ax = plt.subplots(figsize=(8, 4))
sns.boxplot(data=df, x="Pclass", y="Fare", ax=ax)
ax.set_title("Titanic fares by passenger class")
ax.set_xlabel("Passenger class")
ax.set_ylabel("Fare (pounds)")
plt.show()
No description has been provided for this image

Quick check: is the 512 pound fare a typing mistake, or a real ticket?

top = df.loc[df["Fare"] > 500, ["Name", "Pclass", "Ticket", "Fare"]]
top
Name Pclass Ticket Fare
258 Ward, Miss. Anna 1 PC 17755 512.3292
679 Cardeza, Mr. Thomas Drake Martinez 1 PC 17755 512.3292
737 Lesurer, Mr. Gustave J 1 PC 17755 512.3292
free = df.loc[df["Fare"] == 0, ["Name", "Pclass", "Ticket"]]
print(len(free), "passengers paid nothing")
free.head()
15 passengers paid nothing
Name Pclass Ticket
179 Leonard, Mr. Lionel 3 LINE
263 Harrison, Mr. William 1 112059
271 Tornquist, Mr. William Henry 3 LINE
277 Parkes, Mr. Francis "Frank" 2 239853
302 Johnson, Mr. William Cahoone Jr 3 LINE

Flag outliers with the IQR rule#

Anything more than 1.5 IQRs outside the middle half gets flagged.

q1 = df["Fare"].quantile(0.25)
q3 = df["Fare"].quantile(0.75)
iqr = q3 - q1
print("Q1:", q1, " Q3:", q3, " IQR:", round(iqr, 2))
Q1: 7.9104  Q3: 31.0  IQR: 23.09
lower = q1 - 1.5 * iqr
upper = q3 + 1.5 * iqr
print("Fences:", round(lower, 2), "to", round(upper, 2))
Fences: -26.72 to 65.63

Common mistake: joining two conditions with or.

df[(df["Fare"] < lower) or (df["Fare"] > upper)]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_3100\2792416043.py in ?()
----> 1 df[(df["Fare"] < lower) or (df["Fare"] > upper)]

~\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().
is_out = (df["Fare"] < lower) | (df["Fare"] > upper)
print(is_out.sum(), "fare outliers")
print(round(is_out.mean() * 100, 1), "percent of passengers")
116 fare outliers
13.0 percent of passengers
is_out.groupby(df["Pclass"]).sum()
Pclass
1    104
2      5
3      7
Name: Fare, dtype: int64

Your turn: write iqr_bounds(s) that returns both fences, then count Age outliers.

def iqr_bounds(s, k=1.5):
    q1, q3 = s.quantile([0.25, 0.75])
    return q1 - k * (q3 - q1), q3 + k * (q3 - q1)

ages = df["Age"].dropna()
lo, hi = iqr_bounds(ages)
print("Age fences:", lo, "to", hi)
print((~ages.between(lo, hi)).sum(), "age outliers")
Age fences: -6.6875 to 64.8125
11 age outliers

Judge each value against its own group#

A 100 pound fare is normal in first class but extreme in third.

z = (df["Fare"] - df["Fare"].mean()) / df["Fare"].std()
print((z.abs() > 3).sum(), "fares more than 3 std devs out")
20 fares more than 3 std devs out
by_class = df.groupby("Pclass")["Fare"]
q1_c = by_class.transform("quantile", 0.25)
q3_c = by_class.transform("quantile", 0.75)
iqr_c = q3_c - q1_c
out_c = ((df["Fare"] < q1_c - 1.5 * iqr_c)
         | (df["Fare"] > q3_c + 1.5 * iqr_c))
out_c.groupby(df["Pclass"]).sum()
Pclass
1    20
2     7
3    52
Name: Fare, dtype: int64

Quick check: who are the extreme fares in third class?

cols = ["Name", "Ticket", "Fare"]
df.loc[out_c & (df["Pclass"] == 3), cols].head(6)
Name Ticket Fare
13 Andersson, Mr. Anders Johan 347082 31.2750
16 Rice, Master. Eugene 382652 29.1250
25 Asplund, Mrs. Carl Oscar (Selma Augusta Emilia... 347077 31.3875
50 Panula, Master. Juha Niilo 3101295 39.6875
59 Goodwin, Master. William Frederick CA 2144 46.9000
63 Skoog, Master. Harald 347088 27.9000

Keep, cap or remove: fix the cause first#

Fares are per ticket, not per person. That changes everything.

ticket_size = df.groupby("Ticket")["Ticket"].transform("size")
df["Fare_pp"] = df["Fare"] / ticket_size
df[["Fare", "Fare_pp"]].describe().round(2)
Fare Fare_pp
count 891.00 891.00
mean 32.20 17.79
std 49.69 21.22
min 0.00 0.00
25% 7.91 7.76
50% 14.45 8.85
75% 31.00 24.29
max 512.33 221.78
lo, hi = iqr_bounds(df["Fare_pp"])
pp_out = ~df["Fare_pp"].between(lo, hi)
print(pp_out.sum(), "outliers per person, was", is_out.sum())
59 outliers per person, was 116
cap = df["Fare"].quantile(0.99)
capped = df["Fare"].clip(upper=cap)
print("Cap at the 99th percentile:", round(cap, 2))
print("Max before:", df["Fare"].max(), " after:", round(capped.max(), 2))
Cap at the 99th percentile: 249.01
Max before: 512.3292  after: 249.01
removed = df.loc[~is_out, "Fare"]
summary = pd.Series({"original": df["Fare"].mean(),
                     "capped": capped.mean(),
                     "removed": removed.mean(),
                     "median": df["Fare"].median()})
summary.round(2)
original    32.20
capped      31.22
removed     17.82
median      14.45
dtype: float64
fig, ax = plt.subplots(figsize=(8, 4))
df["Fare"].plot.hist(bins=50, alpha=0.5, ax=ax, label="Original")
capped.plot.hist(bins=50, alpha=0.5, ax=ax, label="Capped at 99%")
ax.set_title("Titanic fares before and after capping")
ax.set_xlabel("Fare (pounds)")
ax.set_ylabel("Passengers")
ax.legend()
plt.show()
No description has been provided for this image

Quick check: if we delete fare outliers, does first class survival change?

rates = pd.DataFrame({
    "all": df.groupby("Pclass")["Survived"].mean(),
    "no_outliers": df[~is_out].groupby("Pclass")["Survived"].mean()})
rates.round(2)
all no_outliers
Pclass
1 0.63 0.51
2 0.47 0.49
3 0.24 0.25

Validate with rules: catch impossible values#

Unusual can be real. Impossible never is. Write the rules down.

taxis = pd.read_csv("data/pandas/taxis.csv")
print(taxis.shape)
taxis[["pickup", "dropoff", "passengers", "distance", "fare"]].head(3)
(6433, 14)
pickup dropoff passengers distance fare
0 2019-03-23 20:21:09 2019-03-23 20:27:24 1 1.60 7.0
1 2019-03-04 16:11:55 2019-03-04 16:19:00 1 0.79 5.0
2 2019-03-27 17:53:01 2019-03-27 18:00:25 1 1.37 7.5

Common mistake: doing date maths on columns that are still text.

taxis["dropoff"] - taxis["pickup"]
---------------------------------------------------------------------------
ArrowNotImplementedError                  Traceback (most recent call last)
File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\arrow\array.py:1097, in ArrowExtensionArray._evaluate_op_method(self, other, op, arrow_funcs)
   1096 try:
-> 1097     result = pc_func(self._pa_array, other)
   1098 except pa.ArrowNotImplementedError as err:

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\compute.py:255, in _make_generic_wrapper.<locals>.wrapper(memory_pool, *args)
    254     return Expression._call(func_name, list(args))
--> 255 return func.call(args, None, memory_pool)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\_compute.pyx:405, in pyarrow._compute.Function.call()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\error.pxi:155, in pyarrow.lib.pyarrow_internal_check_status()

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pyarrow\error.pxi:92, in pyarrow.lib.check_status()

ArrowNotImplementedError: Function 'subtract_checked' has no kernel matching input types (large_string, large_string)

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

TypeError                                 Traceback (most recent call last)
Cell In[24], line 1
----> 1 taxis["dropoff"] - taxis["pickup"]

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:198, in OpsMixin.__sub__(self, other)
    196 @unpack_zerodim_and_defer("__sub__")
    197 def __sub__(self, other):
--> 198     return self._arith_method(other, operator.sub)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\series.py:6751, in Series._arith_method(self, other, op)
   6749 def _arith_method(self, other, op):
   6750     self, other = self._align_for_op(other)
-> 6751     return base.IndexOpsMixin._arith_method(self, other, op)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\base.py:1644, in IndexOpsMixin._arith_method(self, other, op)
   1641     rvalues = np.arange(rvalues.start, rvalues.stop, rvalues.step)
   1643 with np.errstate(all="ignore"):
-> 1644     result = ops.arithmetic_op(lvalues, rvalues, op)
   1646 return self._construct_result(result, name=res_name, other=other)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\ops\array_ops.py:279, in arithmetic_op(left, right, op)
    270 right = ensure_wrapped_if_datetimelike(right)
    272 if (
    273     should_extension_dispatch(left, right)
    274     or isinstance(right, (Timedelta, BaseOffset, Timestamp))
   (...)
    277     # Timedelta/Timestamp and other custom scalars are included in the check
    278     # because numexpr will fail on it, see GH#31457
--> 279     res_values = op(left, right)
    280 else:
    281     # TODO we should handle EAs consistently and move this check before the if/else
    282     # (https://github.com/pandas-dev/pandas/issues/41165)
    283     # error: Argument 2 to "_bool_arith_check" has incompatible type
    284     # "Union[ExtensionArray, ndarray[Any, Any]]"; expected "ndarray[Any, Any]"
    285     _bool_arith_check(op, left, right)  # type: ignore[arg-type]

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:198, in OpsMixin.__sub__(self, other)
    196 @unpack_zerodim_and_defer("__sub__")
    197 def __sub__(self, other):
--> 198     return self._arith_method(other, operator.sub)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\arrow\array.py:1158, in ArrowExtensionArray._arith_method(self, other, op)
   1154 if pa.types.is_string(self._pa_array.type) or pa.types.is_large_string(
   1155     self._pa_array.type
   1156 ):
   1157     try:
-> 1158         result = self._evaluate_op_method(other, op, ARROW_ARITHMETIC_FUNCS)
   1159     except (pa.ArrowInvalid, pa.ArrowTypeError):
   1160         result = self._str_arith_method_object_fallback(other, op)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\arrays\arrow\array.py:1099, in ArrowExtensionArray._evaluate_op_method(self, other, op, arrow_funcs)
   1097     result = pc_func(self._pa_array, other)
   1098 except pa.ArrowNotImplementedError as err:
-> 1099     raise TypeError(self._op_method_error_message(other_original, op)) from err
   1100 return self._from_pyarrow_array(result)

TypeError: operation 'sub' not supported for dtype 'str' with dtype 'str'
taxis = pd.read_csv("data/pandas/taxis.csv",
                    parse_dates=["pickup", "dropoff"])
trip = taxis["dropoff"] - taxis["pickup"]
taxis["minutes"] = trip.dt.total_seconds() / 60
taxis["minutes"].describe().round(2)
count    6433.00
mean       14.35
std        11.64
min         0.00
25%         6.50
50%        10.90
75%        18.52
max       107.67
Name: minutes, dtype: float64
checks = pd.DataFrame({
    "no passengers": taxis["passengers"] == 0,
    "zero distance": taxis["distance"] <= 0,
    "zero minutes": taxis["minutes"] <= 0,
    "fare below 2.50": taxis["fare"] < 2.5,
    "total below fare": taxis["total"] < taxis["fare"]})
checks.sum()
no passengers       96
zero distance       51
zero minutes         6
fare below 2.50      1
total below fare     0
dtype: int64
valid_pay = ["credit card", "cash"]
bad_pay = ~taxis["payment"].isin(valid_pay)
print(bad_pay.sum(), "trips with an unknown payment type")
taxis["payment"].value_counts(dropna=False)
44 trips with an unknown payment type
payment
credit card    4577
cash           1812
NaN              44
Name: count, dtype: int64

Your turn: check that every pickup_borough is one of New York's five boroughs.

boroughs = ["Manhattan", "Brooklyn", "Queens", "Bronx",
            "Staten Island"]
bad_boro = ~taxis["pickup_borough"].isin(boroughs)
print(bad_boro.sum(), "trips fail the borough rule")
taxis.loc[bad_boro, ["pickup_zone", "pickup_borough"]].head(3)
26 trips fail the borough rule
pickup_zone pickup_borough
42 NaN NaN
606 NaN NaN
622 NaN NaN

Turn the rules into a reusable report#

One function you can run on every new batch of data.

def validate(d):
    return pd.DataFrame({
        "no passengers": d["passengers"] == 0,
        "zero distance": d["distance"] <= 0,
        "zero minutes": d["minutes"] <= 0,
        "fare below 2.50": d["fare"] < 2.5,
        "unknown payment": ~d["payment"].isin(valid_pay),
        "unknown borough": ~d["pickup_borough"].isin(boroughs)})
report = validate(taxis)
summary = report.sum().to_frame("failures")
summary["percent"] = (summary["failures"] / len(taxis) * 100).round(1)
summary.sort_values("failures", ascending=False)
failures percent
no passengers 96 1.5
zero distance 51 0.8
unknown payment 44 0.7
unknown borough 26 0.4
zero minutes 6 0.1
fare below 2.50 1 0.0
bad = report.any(axis=1)
print(bad.sum(), "trips fail at least one rule")
report.sum(axis=1).value_counts().sort_index()
194 trips fail at least one rule
0    6239
1     169
2      20
3       5
Name: count, dtype: int64

Quick check: will assert let the raw taxi data through?

assert not bad.any(), f"{bad.sum()} trips failed validation"
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[32], line 1
----> 1 assert not bad.any(), f"{bad.sum()} trips failed validation"

AssertionError: 194 trips failed validation
valid = taxis[~bad].copy()
assert not validate(valid).any().any()
print("All rules pass on", len(valid), "of", len(taxis), "trips")
All rules pass on 6239 of 6433 trips

Mini-project: what does a taxi mile really cost?#

Compare fare per mile on the raw trips and the validated trips.

per_mile = taxis["fare"] / taxis["distance"]
print("Infinite values:", np.isinf(per_mile).sum())
print("Raw mean:", per_mile.mean())
Infinite values: 51
Raw mean: inf
speed = valid["distance"] / (valid["minutes"] / 60)
print((speed > 60).sum(), "valid trips faster than 60 mph")
valid[speed > 60][["distance", "minutes", "fare"]].round(2).head()
6 valid trips faster than 60 mph
distance minutes fare
400 0.90 0.13 2.50
2387 1.80 0.55 69.06
3241 0.34 0.03 65.00
3336 1.70 0.32 14.00
3571 0.81 0.15 66.00
clean = valid[speed <= 60].copy()
clean["per_mile"] = clean["fare"] / clean["distance"]
lo, hi = iqr_bounds(clean["per_mile"])
print(len(clean), "trips; fences", round(lo, 2), "to", round(hi, 2))
clean["per_mile"].describe().round(2)
6233 trips; fences 0.34 to 10.78
count    6233.00
mean        6.08
std         7.85
min         1.72
25%         4.26
50%         5.42
75%         6.87
max       472.73
Name: per_mile, dtype: float64
typical = clean["per_mile"].between(lo, hi)
print((~typical).sum(), "fare-per-mile outliers")
print("Mean with them:", round(clean["per_mile"].mean(), 2))
print("Mean without:", round(clean.loc[typical, "per_mile"].mean(), 2))
233 fare-per-mile outliers
Mean with them: 6.08
Mean without: 5.54
by_boro = clean[typical].groupby("pickup_borough")["per_mile"]
by_boro.agg(["median", "mean", "count"]).round(2)
median mean count
pickup_borough
Bronx 4.17 4.44 93
Brooklyn 4.66 4.89 360
Manhattan 5.56 5.78 4937
Queens 3.56 4.15 610

Finding: a clean New York taxi mile costs about 5.4 dollars at the median. Raw data gave an infinite mean. Validate first, then summarise.

Recap#

  • describe, sorting and box plots reveal extremes fast
  • IQR fences: Q1 minus 1.5 IQR to Q3 plus 1.5 IQR; combine with |
  • Compare within groups using groupby().transform()
  • Fix the cause first, then keep, clip or remove, and say which
  • Validation rules are boolean columns; isin, between, assert

Challenges#

  1. Apply the IQR rule to taxi tip. Which payment type holds the outliers?
  2. Add a rule: tip must be 0 for cash trips. How many fail?
  3. Repeat the class-based fences for Titanic Fare_pp.

What's next#

Lesson 21: Hands-on - Cleaning a Real-World Dataset. Every cleaning tool so far, on one genuinely messy survey.

Found this useful?

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