Mathew K Analytics

Lesson 17 · Pandas Full Course

Remove Duplicates and Fix Inconsistent Data in Pandas | Pandas Course #17

In this lesson you'll be able to: Find and count duplicate rows, and measure what they cost Remove duplicates, choosing which copy to keep Decide which…

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 17: Dealing with Duplicates and Inconsistent Data#

In this lesson you'll be able to:

  • Find and count duplicate rows, and measure what they cost
  • Remove duplicates, choosing which copy to keep
  • Decide which columns define a duplicate, with subset
  • Tidy labels that mean the same thing but are spelled differently Data: a year of online retail sales and a 2021 salary survey

Warm-up: back on the Titanic#

Last lesson we counted the gaps. Is anything repeated?

import pandas as pd

titanic = pd.read_csv("data/pandas/titanic.csv")
titanic.isna().sum().sort_values(ascending=False).head(3)
Cabin       687
Age         177
Embarked      2
dtype: int64
print(titanic.duplicated().sum(), "duplicate rows")
print(titanic["PassengerId"].is_unique)
0 duplicate rows
True

Spot the copies: find exact duplicate rows#

A row entered twice counts twice in every total.

retail = pd.read_csv("data/pandas/online_retail.csv.gz")
print(retail.shape)
retail.head(3)
(541909, 8)
InvoiceNo StockCode Description Quantity InvoiceDate UnitPrice CustomerID Country
0 536365 85123A WHITE HANGING HEART T-LIGHT HOLDER 6 12/1/2010 8:26 2.55 17850.0 United Kingdom
1 536365 71053 WHITE METAL LANTERN 6 12/1/2010 8:26 3.39 17850.0 United Kingdom
2 536365 84406B CREAM CUPID HEARTS COAT HANGER 8 12/1/2010 8:26 2.75 17850.0 United Kingdom
dup_mask = retail.duplicated()
print(dup_mask.sum(), "duplicate rows")
print(round(dup_mask.mean() * 100, 2), "percent of the table")
5268 duplicate rows
0.97 percent of the table
copies = retail[retail.duplicated(keep=False)]
print(len(copies), "rows are involved")
copies.sort_values(["InvoiceNo", "StockCode"]).head(4)
10147 rows are involved
InvoiceNo StockCode Description Quantity InvoiceDate UnitPrice CustomerID Country
494 536409 21866 UNION JACK FLAG LUGGAGE TAG 1 12/1/2010 11:45 1.25 17908.0 United Kingdom
517 536409 21866 UNION JACK FLAG LUGGAGE TAG 1 12/1/2010 11:45 1.25 17908.0 United Kingdom
485 536409 22111 SCOTTIE DOG HOT WATER BOTTLE 1 12/1/2010 11:45 4.95 17908.0 United Kingdom
539 536409 22111 SCOTTIE DOG HOT WATER BOTTLE 1 12/1/2010 11:45 4.95 17908.0 United Kingdom

Quick check: 5,268 extra copies but only 10,147 rows involved. Why not 10,536?

copies.value_counts(dropna=False).value_counts().sort_index()
count
2     4588
3      238
4       39
5        5
6        6
8        1
12       1
20       1
Name: count, dtype: int64
revenue = retail["Quantity"] * retail["UnitPrice"]
print("Total revenue:", round(revenue.sum(), 2))
print("From copies:  ", round(revenue[dup_mask].sum(), 2))
Total revenue: 9747747.93
From copies:   21740.98

Your turn: how many different invoices contain at least one duplicate row?

print(retail.loc[dup_mask, "InvoiceNo"].nunique(), "invoices hit")
print(retail["InvoiceNo"].nunique(), "invoices in total")
1933 invoices hit
25900 invoices in total

Remove the copies, and choose which one stays#

drop_duplicates keeps one row from each group of copies.

clean = retail.drop_duplicates()
print(len(retail), "rows before")
print(len(clean), "rows after")
print(clean.duplicated().sum(), "duplicates left")
541909 rows before
536641 rows after
0 duplicates left
print(clean.index[-3:].tolist())
clean = retail.drop_duplicates(ignore_index=True)
print(clean.index[-3:].tolist())
[541906, 541907, 541908]
[536638, 536639, 536640]
orders = clean[clean["InvoiceNo"] == "536446"]
orders = orders[["StockCode", "Description", "Quantity"]]
orders = orders.iloc[[0, 5, 6, 9, 29]]
orders
StockCode Description Quantity
717 85172 HYACINTH BULB T-LIGHT CANDLES 32
722 22294 HEART FILIGREE DOVE  SMALL 48
723 21156 RETROSPOT CHILDRENS APRON 1
726 22294 HEART FILIGREE DOVE  SMALL 24
746 85172 HYACINTH BULB T-LIGHT CANDLES 16
orders.drop_duplicates(subset="StockCode")
StockCode Description Quantity
717 85172 HYACINTH BULB T-LIGHT CANDLES 32
722 22294 HEART FILIGREE DOVE  SMALL 48
723 21156 RETROSPOT CHILDRENS APRON 1
orders.drop_duplicates(subset="StockCode", keep="last")
StockCode Description Quantity
723 21156 RETROSPOT CHILDRENS APRON 1
726 22294 HEART FILIGREE DOVE  SMALL 24
746 85172 HYACINTH BULB T-LIGHT CANDLES 16
orders.drop_duplicates(subset="StockCode", keep=False)
StockCode Description Quantity
723 21156 RETROSPOT CHILDRENS APRON 1

Common mistake: naming a column in subset that isn't there.

retail.drop_duplicates(subset=["Invoice", "StockCode"])
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_29384\986832528.py in ?()
----> 1 retail.drop_duplicates(subset=["Invoice", "StockCode"])

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py in ?(self, subset, keep, inplace, ignore_index)
   7951 
   7952         inplace = validate_bool_kwarg(inplace, "inplace")
   7953         ignore_index = validate_bool_kwarg(ignore_index, "ignore_index")
   7954 
-> 7955         result = self[-self.duplicated(subset, keep=keep)]
   7956         if ignore_index:
   7957             result.index = default_index(len(result))
   7958 

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\frame.py in ?(self, subset, keep)
   8081         # Otherwise, raise a KeyError, same as if you try to __getitem__ with a
   8082         # key that doesn't exist.
   8083         diff = set(subset) - set(self.columns)
   8084         if diff:
-> 8085             raise KeyError(Index(diff))
   8086 
   8087         if len(subset) == 1 and self.columns.is_unique:
   8088             # GH#45236 This is faster than get_group_index below

KeyError: Index(['Invoice'], dtype='str')
print(retail.columns.tolist())
pairs = retail.drop_duplicates(subset=["InvoiceNo", "StockCode"])
print(len(pairs), "rows")
['InvoiceNo', 'StockCode', 'Description', 'Quantity', 'InvoiceDate', 'UnitPrice', 'CustomerID', 'Country']
531225 rows

Decide what counts as a duplicate#

Two rows can record the same thing even when one column differs.

survey = pd.read_csv("data/pandas/salary_survey.csv")
print(survey.shape)
print(survey.duplicated().sum(), "exact duplicates")
(26232, 18)
0 exact duplicates
answers = survey.columns.drop("timestamp")
repeats = survey.duplicated(subset=answers)
print(repeats.sum(), "repeat submissions")
148 repeat submissions
twins = survey[survey.duplicated(subset=answers, keep=False)]
twins = twins[twins["job_title"] != "-"]
cols = ["timestamp", "job_title", "annual_salary"]
twins.sort_values(cols[1:] + ["timestamp"])[cols].head(4)
timestamp job_title annual_salary
15181 4/28/2021 16:33:34 Account Executive 75000
16475 4/28/2021 18:00:33 Account Executive 75000
14541 4/28/2021 14:56:34 Affiliate account manager 53500
14839 4/28/2021 15:33:03 Affiliate account manager 53500
survey = survey.drop_duplicates(subset=answers, keep="first")
print(len(survey), "responses kept")
26084 responses kept

Quick check: in the shop data, InvoiceNo + StockCode repeats far more often. Are those all errors?

same_item = retail.duplicated(["InvoiceNo", "StockCode"], keep=False)
differs = same_item & ~retail.duplicated(keep=False)
print(differs.sum(), "rows share an item but differ elsewhere")
show = ["InvoiceNo", "StockCode", "Quantity", "UnitPrice"]
retail.loc[differs, show].sort_values(show[:2]).head(4)
10231 rows share an item but differ elsewhere
InvoiceNo StockCode Quantity UnitPrice
113 536381 71270 1 1.25
125 536381 71270 3 1.25
491 536409 85116 1 2.10
502 536409 85116 5 2.10

Keep one row per customer#

Sort first, then keep the newest record for each customer.

buyers = clean.dropna(subset=["CustomerID"])
print(buyers["CustomerID"].nunique(), "customers")
moves = buyers.groupby("CustomerID")["Country"].nunique()
moves[moves > 1]
4372 customers
CustomerID
12370.0    2
12394.0    2
12417.0    2
12422.0    2
12429.0    2
12431.0    2
12455.0    2
12457.0    2
Name: Country, dtype: int64
when = pd.to_datetime(buyers["InvoiceDate"], format="%m/%d/%Y %H:%M")
buyers = buyers.assign(InvoiceDate=when)
latest = (buyers.sort_values("InvoiceDate")
                .drop_duplicates("CustomerID", keep="last"))
print(len(latest), "rows,", latest["CustomerID"].is_unique)
4372 rows, True
latest.loc[latest["CustomerID"] == 12394,
           ["CustomerID", "Country", "InvoiceDate"]]
CustomerID Country InvoiceDate
380074 12394.0 Denmark 2011-10-07 08:08:00

Your turn: build a list of each customer's FIRST order. Where did 12394 start?

first = (buyers.sort_values("InvoiceDate")
               .drop_duplicates("CustomerID", keep="first"))
first.loc[first["CustomerID"] == 12394,
          ["CustomerID", "Country", "InvoiceDate"]]
CustomerID Country InvoiceDate
176373 12394.0 Belgium 2011-05-06 14:01:00

Tidy labels that mean the same thing#

US, USA and United States are one country, but pandas sees three.

survey["country"].value_counts().head(8)
country
United States               8935
USA                         7891
US                          2467
Canada                      1536
United Kingdom               581
UK                           579
U.S.                         567
United States of America     423
Name: count, dtype: int64
print(survey["country"].nunique(), "different spellings")
294 different spellings

Common mistake: calling a text method straight on a column.

survey["country"].lower()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_29384\1898481386.py in ?()
----> 1 survey["country"].lower()

~\AppData\Local\Programs\Python\Python312\Lib\site-packages\pandas\core\generic.py in ?(self, name)
   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)

AttributeError: 'Series' object has no attribute 'lower'
country = survey["country"].str.strip().str.lower()
print(country.nunique(), "after strip and lower")
241 after strip and lower
country = country.str.replace(".", "", regex=False)
print(country.nunique(), "after removing dots")
country.value_counts().head(6)
229 after removing dots
country
united states     9252
usa               8531
us                3192
canada            1564
uk                 682
united kingdom     598
Name: count, dtype: int64
us_names = {"usa": "united states", "us": "united states",
            "united states of america": "united states",
            "america": "united states", "u s": "united states",
            "united state": "united states",
            "unites states": "united states"}
country = country.replace(us_names)
print((country == "united states").sum(), "in the United States")
21477 in the United States

Your turn: fold uk, england, scotland and wales into united kingdom.

uk_names = {"uk": "united kingdom", "england": "united kingdom",
            "scotland": "united kingdom",
            "wales": "united kingdom"}
country = country.replace(uk_names)
country.value_counts().head(5)
country
united states     21477
canada             1564
united kingdom     1486
australia           369
germany             181
Name: count, dtype: int64
survey["country_clean"] = country
print(survey["country"].nunique(), "->",
      survey["country_clean"].nunique(), "values")
294 -> 218 values
country.value_counts().tail(5)
country
jordan                                                                   1
united statss                                                            1
argentina but my org is in thailand                                      1
i work for a uae-based organization, though i am personally in the us    1
united  states                                                           1
Name: count, dtype: int64
main = ["united states", "canada", "united kingdom", "australia"]
odd = ~survey["country_clean"].isin(main)
print(odd.sum(), "answers outside the main four")
survey.loc[odd, "country_clean"].value_counts().head(4)
1188
 answers outside the main four
country_clean
germany        181
ireland        120
new zealand    103
france          56
Name: count, dtype: int64

Quick check: the experience column looks consistent. Is it?

exp = survey["overall_years_of_professional_experience"]
print(exp.unique()[:4])
exp = exp.str.replace(" - ", "-", regex=False)
print(exp.unique()[:4])
<ArrowStringArray>
['5-7 years', '8 - 10 years', '2 - 4 years', '21 - 30 years']
Length: 4, dtype: str
<ArrowStringArray>
['5-7 years', '8-10 years', '2-4 years', '21-30 years']
Length: 4, dtype: str
survey["gender"] = survey["gender"].replace(
    "Prefer not to answer", "Other or prefer not to answer")
survey["gender"].value_counts(dropna=False)
gender
Woman                            20249
Man                               4712
Non-binary                         708
Other or prefer not to answer      268
NaN                                147
Name: count, dtype: int64
names = clean.groupby("StockCode")["Description"].nunique()
print(names.max(), "names for one stock code")
clean.loc[clean["StockCode"] == "20713", "Description"].value_counts()
8 names for one stock code
Description
JUMBO BAG OWLS                  668
wrongly marked. 23343 in box      1
wrongly coded-23343               1
found                             1
Found                             1
wrongly marked 23343              1
Marked as 23343                   1
wrongly coded 23343               1
Name: count, dtype: int64

Mini-project: who really answered the survey?#

Compare the raw country labels with the cleaned ones.

raw_top = survey["country"].value_counts(normalize=True)
raw_top.head(3).mul(100).round(1)
country
United States    34.3
USA              30.3
US                9.5
Name: proportion, dtype: float64
clean_top = survey["country_clean"].value_counts(normalize=True)
clean_top.head(4).mul(100).round(1)
country_clean
united states     82.3
canada             6.0
united kingdom     5.7
australia          1.4
Name: proportion, dtype: float64
naive = survey["country"] == "United States"
tidy = survey["country_clean"] == "united states"
usd = survey["currency"] == "USD"
print(f"{naive.sum()} vs {tidy.sum()} US respondents")
print(survey.loc[naive & usd, "annual_salary"].median())
print(survey.loc[tidy & usd, "annual_salary"].median())
8935 vs 21477 US respondents
77500.0
78999.0
import os
os.makedirs("output/pandas", exist_ok=True)
survey.to_csv("output/pandas/l17_survey_clean.csv", index=False)
check = pd.read_csv("output/pandas/l17_survey_clean.csv")
print(check.shape, check.duplicated(subset=answers).sum(), "repeats")
(26084, 19) 0 repeats

Finding: the raw labels show the US as under 35% of the survey. Cleaned, it's over four in five. The labels changed the story.

Recap#

  • duplicated() flags copies; keep=False shows every member
  • drop_duplicates() removes them; keep picks first or last
  • subset decides which columns make two rows the same
  • Sort, then drop_duplicates(keep="last") for latest records
  • Tidy labels with .str.strip().str.lower(), then replace a map
  • Always compare counts before and after cleaning

Challenges#

  1. In the shop data, how many stock codes have more than one Description?
  2. Clean the survey's currency column. Which rows say Other?
  3. Fold Canada's spellings together. How many Canadians are there?

What's next#

Lesson 18: Changing Data Types and Converting Units. Text dates, numbers stored as text, and miles to kilometres.

Found this useful?

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