Mathew K Analytics

Lesson 21 · Pandas Full Course

Clean a Messy Real-World Dataset in Pandas (Full Hands-on Project) | Pandas Course #21

In this lesson you'll be able to: Audit a messy dataset: size, gaps, labels and types Standardise free-text answers into clean categories Fix types, ordered…

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 21: Hands-on - Cleaning a Real-World Dataset#

In this lesson you'll be able to:

  • Audit a messy dataset: size, gaps, labels and types
  • Standardise free-text answers into clean categories
  • Fix types, ordered bands and duplicate submissions
  • Validate values, save the result and answer a real question Data: the 2021 Ask a Manager salary survey (26,232 answers)

Warm-up: fences on salaries#

Last lesson we flagged outliers with IQR fences. Try them here.

import pandas as pd
import numpy as np

raw = pd.read_csv("data/pandas/salary_survey.csv")
q1, q3 = raw["annual_salary"].quantile([0.25, 0.75])
upper = q3 + 1.5 * (q3 - q1)
print("Upper fence:", upper)
print("Above it:", (raw["annual_salary"] > upper).sum())
print("Largest salary:", raw["annual_salary"].max())
Upper fence: 194000.0
Above it: 1112
Largest salary: 870000000

Audit first: measure the mess#

Before changing anything, check the size, the gaps and the labels.

print(raw.shape)
raw.columns.tolist()
(26232, 18)
['timestamp',
 'how_old_are_you',
 'industry',
 'job_title',
 'additional_context_on_job_title',
 'annual_salary',
 'other_monetary_comp',
 'currency',
 'currency_other',
 'additional_context_on_income',
 'country',
 'state',
 'city',
 'overall_years_of_professional_experience',
 'years_of_experience_in_field',
 'highest_level_of_education_completed',
 'gender',
 'race']
raw.isna().mean().mul(100).round(1).sort_values(ascending=False).head(6)
currency_other                          99.4
additional_context_on_income            89.1
additional_context_on_job_title         74.2
other_monetary_comp                     25.9
state                                   17.9
highest_level_of_education_completed     0.7
dtype: float64
raw["currency"].value_counts().head(6)
currency
USD        21852
CAD         1564
GBP         1521
EUR          585
AUD/NZD      469
Other        133
Name: count, dtype: int64
counts = raw["industry"].value_counts()
print(len(counts), "different industries")
print((counts == 1).sum(), "typed by only one person")
1068 different industries
841 typed by only one person
names = {"how_old_are_you": "age", "annual_salary": "salary",
         "other_monetary_comp": "bonus",
         "overall_years_of_professional_experience": "years_total",
         "years_of_experience_in_field": "years_field",
         "highest_level_of_education_completed": "education"}
keep = ["timestamp", "age", "industry", "job_title", "salary",
        "bonus", "currency", "country", "years_total",
        "years_field", "education", "gender"]
df = raw.rename(columns=names)[keep].copy()
df.head(3).T
0 1 2
timestamp 4/27/2021 11:02:10 4/27/2021 11:02:22 4/27/2021 11:02:38
age 25-34 25-34 25-34
industry Education (Higher Education) Computing or Tech Accounting, Banking & Finance
job_title Research and Instruction Librarian Change & Internal Communications Manager Marketing Specialist
salary 55000 54600 34000
bonus 0.0 4000.0 NaN
currency USD GBP USD
country United States United Kingdom US
years_total 5-7 years 8 - 10 years 2 - 4 years
years_field 5-7 years 5-7 years 2 - 4 years
education Master's degree College degree College degree
gender Woman Non-binary Woman

Quick check: how many different ways did people write their country?

print(df["country"].nunique(), "distinct country values")
df["country"].value_counts().head(8)
294 distinct country values
country
United States               9010
USA                         7918
US                          2485
Canada                      1543
United Kingdom               584
UK                           579
U.S.                         569
United States of America     425
Name: count, dtype: int64

Standardise free-text labels#

Trim, lower-case and map every variant to one agreed label.

country = df["country"].str.strip().str.lower()
country = country.str.replace(".", "", regex=False)
print(country.nunique(), "after strip, lower and dots removed")
country.value_counts().head(5)
229 after strip, lower and dots removed
country
united states    9328
usa              8561
us               3213
canada           1571
uk                682
Name: count, dtype: int64
us = ["united states", "usa", "us", "united states of america",
      "america", "united state", "unites states", "united stated",
      "u s"]
uk = ["uk", "united kingdom", "england", "scotland", "wales",
      "northern ireland"]
fix = {**dict.fromkeys(us, "united states"),
       **dict.fromkeys(uk, "united kingdom")}
df["country"] = country.replace(fix).str.title()
df["country"].value_counts().head(5)
country
United States     21616
Canada             1571
United Kingdom     1493
Australia           371
Germany             181
Name: count, dtype: int64

Your turn: find the remaining countries that contain "unit" but weren't mapped.

done = ["United States", "United Kingdom"]
has_unit = df["country"].str.contains("unit", case=False)
left = df.loc[has_unit & ~df["country"].isin(done), "country"]
print(len(left), "rows still unmapped")
left.value_counts().head(6)
59 rows still unmapped
country
The United States          6
United Sates               5
Unitedstates               4
United Statea              3
United Stares              3
England, United Kingdom    3
Name: count, dtype: int64
us_like = df["country"].str.contains(r"^(?:the )?unit\w* ?sta",
                                    case=False)
uk_like = df["country"].str.contains("kingdom|kindom", case=False)
df.loc[us_like, "country"] = "United States"
df.loc[uk_like, "country"] = "United Kingdom"
left = df.loc[df["country"].str.contains("unit", case=False)
              & ~df["country"].isin(done), "country"]
print(len(left), "rows still unmapped")
12
 rows still unmapped
df["gender"] = df["gender"].replace(
    {"Prefer not to answer": "Other or prefer not to answer"})
df["gender"].value_counts(dropna=False)
gender
Woman                            20359
Man                               4743
Non-binary                         713
Other or prefer not to answer      269
NaN                                148
Name: count, dtype: int64

Fix the types: dates, numbers and ordered bands#

Each column should hold the type that matches its meaning.

df["timestamp"] = pd.to_datetime(df["timestamp"],
                                 format="%m/%d/%Y %H:%M:%S")
df["timestamp"].agg(["min", "max"])
min   2021-04-27 11:02:10
max   2021-05-17 19:48:52
Name: timestamp, dtype: datetime64[us]

Common mistake: assuming numbers arrived as text and reaching for .str.

df["salary"].str.replace(",", "")
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[15], line 1
----> 1 df["salary"].str.replace(",", "")

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py:6194, in NDFrame.__getattr__(self, name)
   6187 if (
   6188     name not in self._internal_names_set
   6189     and name not in self._metadata
   6190     and name not in self._accessors
   6191     and self._info_axis._can_hold_identifiers_and_holds_name(name)
   6192 ):
   6193     return self[name]
-> 6194 return object.__getattribute__(self, name)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\accessor.py:230, in Accessor.__get__(self, obj, cls)
    227 if obj is None:
    228     # we're accessing the attribute of the class, i.e., Dataset.geo
    229     return self._accessor
--> 230 return self._accessor(obj)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\strings\accessor.py:194, in StringMethods.__init__(self, data)
    191 def __init__(self, data) -> None:
    192     from pandas.core.arrays.string_ import StringDtype
--> 194     self._inferred_dtype = self._validate(data)
    195     self._is_categorical = isinstance(data.dtype, CategoricalDtype)
    196     self._is_string = isinstance(data.dtype, StringDtype)

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\strings\accessor.py:248, in StringMethods._validate(data)
    245 inferred_dtype = lib.infer_dtype(values, skipna=True)
    247 if inferred_dtype not in allowed_types:
--> 248     raise AttributeError(
    249         f"Can only use .str accessor with string values, not {inferred_dtype}"
    250     )
    251 return inferred_dtype

AttributeError: Can only use .str accessor with string values, not integer
print("salary is stored as", df["salary"].dtype)
df["bonus"] = df["bonus"].fillna(0)
df["total_pay"] = df["salary"] + df["bonus"]
df[["salary", "bonus", "total_pay"]].head(3)
salary is stored as int64
salary bonus total_pay
0 55000 0.0 55000.0
1 54600 4000.0 58600.0
2 34000 0.0 34000.0
big_bonus = df["bonus"] > df["salary"]
print(big_bonus.sum(), "people report a bonus bigger than salary")
df.loc[big_bonus, ["job_title", "salary", "bonus"]].head(4)
208 people report a bonus bigger than salary
job_title salary bonus
321 Software Engineer 156000 189000.0
862 Application Specialist 52500 55000.0
1196 Genomics Sales Specialist 95000 100000.0
1240 Product Manager - Technical 128000 137000.0
df["years_field"].unique()
<ArrowStringArray>
[       '5-7 years',      '2 - 4 years',    '21 - 30 years',
    '11 - 20 years',   '1 year or less',     '8 - 10 years',
    '31 - 40 years', '41 years or more']
Length: 8, dtype: str
bands = ["1 year or less", "2-4 years", "5-7 years", "8-10 years",
         "11-20 years", "21-30 years", "31-40 years",
         "41 years or more"]
matches = df["years_total"].isin(bands)
print((~matches).sum(), "labels do not match a band")
21171 labels do not match a band

Quick check: why did thousands of answers vanish?

for col in ["years_total", "years_field"]:
    tidy = df[col].str.replace(" - ", "-")
    df[col] = pd.Categorical(tidy, categories=bands, ordered=True)
    print(col, "missing:", df[col].isna().sum())
df["years_field"].value_counts(sort=False)
years_total missing: 0
years_field missing: 0
years_field
1 year or less      1249
2-4 years           5737
5-7 years           6114
8-10 years          4701
11-20 years         6245
21-30 years         1782
31-40 years          366
41 years or more      38
Name: count, dtype: int64
ages = ["under 18", "18-24", "25-34", "35-44", "45-54", "55-64",
        "65 or over"]
df["age"] = pd.Categorical(df["age"], categories=ages, ordered=True)
print(df["age"].isna().sum(), "unmatched")
df.dtypes
0 unmatched
timestamp      datetime64[us]
age                  category
industry                  str
job_title                 str
salary                  int64
bonus                 float64
currency                  str
country                   str
years_total          category
years_field          category
education                 str
gender                    str
total_pay             float64
dtype: object

Remove duplicate submissions#

People often press Submit twice. Same answers, different time.

print(df.duplicated().sum(), "exact duplicate rows")
0
 exact duplicate rows

Quick check: zero exact duplicates. Did nobody submit twice?

answers = df.columns.drop("timestamp")
dup = df.duplicated(subset=answers, keep=False)
print(df.duplicated(subset=answers).sum(), "repeat submissions")
cols = ["timestamp", "job_title", "salary"]
df[dup].sort_values(["job_title", "salary"])[cols].head(4)
181 repeat submissions
timestamp job_title salary
17081 2021-04-28 19:20:50 - 43680
17088 2021-04-28 19:21:37 - 43680
2189 2021-04-27 11:39:45 Account Executive 55000
25999 2021-05-10 17:09:00 Account Executive 55000
before = len(df)
df = df.drop_duplicates(subset=answers, keep="first")
print(before, "->", len(df), "rows")
26232
 -> 26051 rows

Validate salaries and keep comparable rows#

Compare like with like: one country, one currency, believable pay.

in_us = df["country"] == "United States"
other = (in_us & (df["currency"] != "USD")).sum()
print(other, "US answers in another currency")
us = df[in_us & (df["currency"] == "USD")].copy()
print(len(us), "US answers paid in dollars")
22 US answers in another currency
21469 US answers paid in dollars
us["salary"].describe().round(0)
count      21469.0
mean       91942.0
std        63714.0
min            0.0
25%        56500.0
50%        79000.0
75%       112000.0
max      3000000.0
Name: salary, dtype: float64
q1, q3 = us["salary"].quantile([0.25, 0.75])
fence = q3 + 1.5 * (q3 - q1)
high = us[us["salary"] > fence]
print(len(high), "above the fence of", fence)
high["industry"].value_counts().head(4)
852 above the fence of 195250.0
industry
Computing or Tech                361
Law                              143
Health care                       71
Accounting, Banking & Finance     52
Name: count, dtype: int64
us.nlargest(5, "salary")[["job_title", "industry", "salary"]]
job_title industry salary
2124 Owner and CEO Art & Design 3000000
5755 Attending Physician (general internal medicine) Health care 1900000
6783 Principal Software Engineer Computing or Tech 1650000
9238 Senior Policy Advisor Government and Public Administration 1334782
17708 Senior Consultant Business or Consulting 1260000

Your turn: a full-time US minimum wage job pays about 15,000 a year. How many fall below?

too_low = us["salary"] < 15_000
print(too_low.sum(), "salaries below 15,000")
us.loc[too_low, ["job_title", "salary"]].head(6)
100 salaries below 15,000
job_title salary
97 Quality Assurance Lead 58
895 Special Education Teacher 38
968 Managing Editor 61
1206 Bookkkeeper 12000
1607 Chief Data Scientist 130
1767 Senior Tech 14500
low = us.loc[too_low, "salary"]
print((low < 1_000).sum(), "of them are under 1,000")
low.describe().round(0)
56 of them are under 1,000
count      100.0
mean      4525.0
std       5681.0
min          0.0
25%         55.0
50%        138.0
75%      12000.0
max      14850.0
Name: salary, dtype: float64
clean = us[~too_low]
print(len(clean), "rows kept out of", len(raw))
clean.isna().sum()[lambda s: s > 0]
21369 rows kept out of 26232
industry      45
education    132
gender       122
dtype: int64

Save the clean table, and check it survives#

A clean table is only useful if the next person gets the same one.

import os
os.makedirs("output/pandas", exist_ok=True)
clean.to_csv("output/pandas/l21_salary_clean.csv", index=False)
back = pd.read_csv("output/pandas/l21_salary_clean.csv")
print(back.shape)
(21369, 13)

Quick check: does the ordered experience band survive the CSV round trip?

print("years_field:", back["years_field"].dtype)
print("timestamp:  ", back["timestamp"].dtype)
years_field: str
timestamp:   str
clean.to_parquet("output/pandas/l21_salary_clean.parquet")
back = pd.read_parquet("output/pandas/l21_salary_clean.parquet")
print("years_field:", back["years_field"].dtype)
print("timestamp:  ", back["timestamp"].dtype)
print(back["years_field"].cat.ordered, "ordered")
years_field: category
timestamp:   datetime64[us]
True ordered
log = pd.Series({"raw answers": len(raw),
                 "after duplicates": len(df),
                 "US, paid in USD": len(us),
                 "believable salary": len(clean)})
pd.DataFrame({"rows": log, "percent": (log / len(raw) * 100).round(1)})
rows percent
raw answers 26232 100.0
after duplicates 26051 99.3
US, paid in USD 21469 81.8
believable salary 21369 81.5

Mini-project: does experience pay in the US?#

Median salary by years in the field, on the clean table.

pay = clean.groupby("years_field", observed=True)["salary"]
by_years = pay.agg(["median", "count"])
by_years
median count
years_field
1 year or less 56000.0 969
2-4 years 64000.0 4518
5-7 years 75000.0 5018
8-10 years 85000.0 3907
11-20 years 95000.0 5142
21-30 years 104000.0 1471
31-40 years 100000.0 313
41 years or more 108000.0 31
import matplotlib.pyplot as plt

ax = by_years["median"].plot.bar(figsize=(9, 4), rot=30)
ax.set_title("Median US salary by years in field (2021 survey)")
ax.set_xlabel("Years of experience in the field")
ax.set_ylabel("Median salary (USD)")
plt.show()
No description has been provided for this image
top = clean["industry"].value_counts().head(3).index
subset = clean[clean["industry"].isin(top)]
table = subset.pivot_table(index="years_field", columns="industry",
                           values="salary", aggfunc="median",
                           observed=True)
table.round(-3)
industry Computing or Tech Education (Higher Education) Nonprofits
years_field
1 year or less 85000.0 50000.0 46000.0
2-4 years 97000.0 52000.0 52000.0
5-7 years 115000.0 58000.0 62000.0
8-10 years 130000.0 65000.0 74000.0
11-20 years 145000.0 72000.0 80000.0
21-30 years 155000.0 81000.0 83000.0
31-40 years 145000.0 93000.0 102000.0
41 years or more 145000.0 102000.0 74000.0
gap = table["Computing or Tech"] / table["Nonprofits"]
gap.round(2)
years_field
1 year or less      1.85
2-4 years           1.86
5-7 years           1.85
8-10 years          1.76
11-20 years         1.81
21-30 years         1.87
31-40 years         1.43
41 years or more    1.95
dtype: float64
pay_cols = ["salary", "total_pay"]
clean.groupby("industry")[pay_cols].median().loc[top].round(-3)
salary total_pay
industry
Computing or Tech 123000.0 130000.0
Nonprofits 65000.0 65000.0
Education (Higher Education) 62000.0 63000.0

Finding: in this survey, US pay nearly doubles from the first year to 21-30 years. Tech pays the most at every stage of a career.

Recap#

  • Audit first: shape, missing share, distinct labels
  • Strip, lower and replace with a mapping to unify free text
  • Check the dtype before cleaning; ordered categories need exact labels
  • Duplicates may differ only by timestamp: use subset
  • Validate with written rules, then save as Parquet to keep types

Challenges#

  1. Clean education and find the median US salary for each level.
  2. Map the Canadian spellings of country, then compare CAD salaries.
  3. Compare median pay by gender within Computing or Tech.

What's next#

Lesson 22: Applying Functions across Columns and Rows. Write your own functions and run them over a whole table.

Found this useful?

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