Data Preparation / Data Preprocessing Slides 📂 Introduction · 5 of 13 53 min read

Data Cleaning in Python — Handling Missing Values & Removing Duplicates

A practical, visual guide to cleaning data with pandas — where data scientists spend most of their time. Learn to audit a dataset, diagnose why values are missing (MCAR/MAR/MNAR), choose the right fix (dropna, fillna, interpolate, KNN), remove exact, key and fuzzy duplicates, and validate the result before modelling.

Data Cleaning in Python

Handling missing values and removing duplicates with pandas — where 60–80% of a data scientist's time is spent, and where the most important analytical decisions get made.
Audit Missing Values Duplicates Validate

Press Next → or use ← → arrow keys

Section 01

The Dirty Data Problem

A hospital model that quietly underestimated the sickest patients
A U.S. hospital deployed an ML model to predict patient urgency — and it systematically under-rated severity for certain groups. The cause wasn't the algorithm: the training data had structurally biased missing values. Some patient groups had fewer recorded diagnoses because of unequal access to care, not better health.

The missing values weren't random — they were biased. Data cleaning has an ethical dimension.
🗑️Raw / Messy 🔍Audit 🩹Fill Missing 🧹De-dupe Clean / Ready
⚠️
Garbage in, garbage out

A model trained on dirty data produces dirty predictions. Missing values bias statistics, duplicates inflate patterns, and inconsistent entries invent phantom categories — none of which a better algorithm can fix.

Section 02 · Step 1

Audit Before You Clean

Auditing first gives you a complete map of the problem — the X-ray you take before surgery. Never clean a column you haven't measured.

import pandas as pd

df = pd.read_csv('sales_data.csv')
df.shape; df.info()

# Missing-value audit
null_report = pd.DataFrame({
  'missing': df.isnull().sum(),
  'pct': (df.isnull().mean()*100).round(2),
  'dtype': df.dtypes
}).query('missing > 0') \
  .sort_values('pct', ascending=False)

# Duplicate audit
df.duplicated().sum()
df['customer_id'].duplicated().sum()
Null Audit — % missing by column 5% threshold age 15.7% rating 9.8% income 6.2% city 3.1% region 1.5% ← priority
📊
The 5% rule of thumb

Columns above ~5% missing (here age and rating) need an active imputation strategy. Below that, simple deletion rarely hurts — but always confirm why it's missing first.

Section 03

Understanding WHY Data Is Missing

The mechanism determines the method. Diagnose why a value is absent before choosing how to fill it.

🎲
MCAR
Missing Completely At Random
No link to any variable — like random sensor packet loss. Safe to delete or simple-impute with minimal bias.
🔗
MAR
Missing At Random
Depends on observed variables — e.g. younger users skip "income". Impute using the other features it correlates with.
⚠️
MNAR
Missing Not At Random
Depends on the hidden value itself — high earners hide income. Simple fills inject bias; needs domain expertise.
MCAR — missing scattered randomly MNAR — missing clusters at the extremes high-value zone
Section 04

Missing-Value Treatment — Decision Tree

How much missing?audit the column < 5% & MCAR 5 – 50% > 50% Drop rowsdropna() Numeric → medianCat. → modetime series → interpolate MAR/MNAR →KNN / Iterative + flag Drop columntoo sparse to trust
🚩
Always flag before you fill (MAR / MNAR)

Add a binary indicator before imputing — df['col_missing'] = df['col'].isnull().astype(int). The fact that a value was missing is itself predictive information.

Section 04 · Strategy 1

Drop Rows or Columns — dropna()

When dropping is the right call
A 50,000-response retail survey had 1,400 missing satisfaction scores (2.8%) — and that column was the target. Targets can't be imputed, so dropping those rows was correct; the remaining 48,600 rows showed no statistical impact.
# Drop rows missing ANY value
df_clean = df.dropna()

# Drop only where SPECIFIC columns are missing
df_clean = df.dropna(subset=['age', 'purchase_amount'])

# Drop columns that are >50% empty
df_clean = df.dropna(axis=1, thresh=len(df)*0.5)

# ALWAYS log what you lost
print(f"Before: {len(df):,}  After: {len(df_clean):,}")
✂️
Use when

Missingness is under ~5% and MCAR, the affected column is the target, or a column is so sparse (>50% empty) that imputing it would be fiction.

Section 04 · Strategy 2

Fill with Statistics — fillna()

An Indian e-commerce set had 15.7% missing ages (MCAR). Median (34) beat the mean because age is right-skewed — and downstream model accuracy rose 4%.

# numeric → median (robust to skew)
df['age'].fillna(df['age'].median(),
              inplace=True)
# categorical → most frequent
df['city'].fillna(df['city'].mode()[0],
               inplace=True)
# group-wise → smarter than global
df['age'] = df.groupby('bracket')['age'] \
    .transform(lambda x: x.fillna(x.median()))
Before
agecityrating
28Mumbai4.5
NaNDelhiNaN
42NaN3.8
NaNChennai5.0
After
agecityrating
28Mumbai4.5
34Delhi4.2
42Mumbai3.8
34Chennai5.0
📐
Median > mean for skewed data

Income, house prices, revenue and age all have long right tails — the mean is dragged up by extremes, so the median is the honest centre for imputation.

Section 04 · Strategy 3

Interpolation — for Time Series

Filling a 6-hour temperature outage missing gap actual interpolate ✓ mean-fill = flat, wrong ✗
# straight-line between neighbours
df['temp'] = df['temp'].interpolate('linear')
# time-aware (respects unequal gaps)
df = df.set_index('timestamp')
df['temp'] = df['temp'].interpolate('time')
# carry last / next value
df['price'] = df['price'].ffill()  # stocks
df['price'] = df['price'].bfill()
📈
Preserve the pattern

Mean-fill flatlines the signal into an unnatural plateau. Interpolation follows the data's real curve between the last reading (31.2°C) and the next (34.1°C) — keeping the pattern the model must learn.

Section 04 · Strategy 4

Model-Based Imputation — KNN & Iterative

When missingness is MAR and accuracy matters, borrow information from similar rows instead of a single global statistic.

from sklearn.impute import KNNImputer
# average of k most-similar rows
imp = KNNImputer(n_neighbors=5,
                 weights='distance')
df[num] = imp.fit_transform(df[num])

from sklearn.impute import IterativeImputer
# regress each column on the others
it = IterativeImputer(max_iter=10,
                      random_state=42)
df[num] = it.fit_transform(df[num])
PICK-A-STRATEGY CHEAT-SHEET
medianMCAR + numeric + low %
modeMCAR + categorical
interptime series
KNNMAR + numeric + model matters
flag+expertMNAR — never fill blindly
dropcolumn > 50% missing
Section 05

Removing Duplicates — Know the Three Types

The retailer's phantom sales
A chain merged its North, South and West databases. Because all three shared one payment processor, 23% of transactions appeared more than once. The forecast model massively overestimated demand — ₹2.4 crore of excess inventory. One df.duplicated().sum() would have caught it in seconds.
👯
Exact
every column identical
102Arjun8200
102Arjun8200
Fix → drop_duplicates()
🔑
Key conflict
same ID, different values
1028200 (v1)
1028950 (v2)
Fix → keep='last' / groupby
🌀
Fuzzy
typos & near-matches
Mumbai
mumbai / Mum.
Fix → str.lower().str.strip()
Section 05 · Code

Detecting & Dropping Duplicates

# inspect BEFORE you delete
df.duplicated().sum()
df[df.duplicated(keep=False)]     # all copies

# exact duplicates
df = df.drop_duplicates()          # keep first
df = df.drop_duplicates(keep='last')

# key-based: newest row per customer
df = (df.sort_values('order_date',
                     ascending=False)
        .drop_duplicates(
            subset=['customer_id'],
            keep='first'))

# fuzzy: normalise text first
df['city'] = df['city'].str.lower().str.strip()
df['city'] = df['city'].replace(city_map)
Before · 6 rows
idnameamt
102Arjun8200
102Arjun8200
103Deepa6100
103Deepa6100
After · 4 rows
idnameamt
102Arjun8200
103Deepa6100
Section 06 · Step 3

Validate the Clean Dataset

A cleaning step that silently introduces a new problem is worse than no cleaning at all. Prove your result with hard assertions.

# post-cleaning assertions
assert df.isnull().sum().sum() == 0
assert df.duplicated().sum()   == 0
assert df['age'].between(18,100).all()
assert df['purchase_amount'].ge(0).all()
assert df['rating'].between(1,5).all()

# reproducible summary report
report = {
  'rows_before': len(df_raw),
  'rows_after':  len(df),
  'nulls_remaining': df.isnull().sum().sum(),
  'dupes_remaining': df.duplicated().sum()}
0Nulls remaining
0Dupes remaining
9,713Clean rows kept
2,287Duplicates removed
🧾
Save the report as JSON

Store it beside every cleaned dataset — it's your audit trail for reproducibility, compliance, and handing work to teammates.

Section 07

A Production-Ready Cleaning Function

def clean_dataframe(df, id_col=None, date_col=None, verbose=True):
    df_clean = df.copy()                      # Rule 1: never touch the original
    report = {'rows_start': len(df_clean)}

    df_clean = df_clean.dropna(how='all').dropna(axis=1, how='all')   # empties
    df_clean = df_clean.dropna(axis=1, thresh=len(df_clean)*0.5)      # >50% cols

    for c in df_clean.select_dtypes('number').columns:       # numeric → median
        df_clean[c] = df_clean[c].fillna(df_clean[c].median())
    for c in df_clean.select_dtypes(['object','category']).columns:  # cat → mode
        df_clean[c] = df_clean[c].fillna(df_clean[c].mode()[0])

    if id_col and date_col:                            # keep most recent per key
        df_clean = (df_clean.sort_values(date_col, ascending=False)
                            .drop_duplicates(subset=[id_col], keep='first'))
    else:
        df_clean = df_clean.drop_duplicates()
    return df_clean, report

df_clean, report = clean_dataframe(df_raw, id_col='customer_id', date_col='order_date')
⚙️
One call, a model-ready frame

Run on a 12,000-row messy dataset, this returns zero nulls, zero duplicates, and a DataFrame that drops straight into an sklearn pipeline — with a report dict for your audit trail.

Pro · Leakage

The #1 Silent Mistake — Imputation Leakage

Compute a median (or fit KNN) on the whole dataset and then split, and test-set information has already leaked into training. Your validation score looks great — and lies.

✗ WRONG — impute, then split Full dataset median(ALL rows)sees test data Split train/test 💥leak ✓ RIGHT — split first, fit on train only Split first fit on TRAINlearn the median here transform TEST honest
🕳️
The rule: fit on train, transform everything

Any statistic used to fill values — median, mode, KNN neighbours, scaler ranges — must be learned from training data only, then applied to validation and test. The cleanest way to guarantee it is a scikit-learn Pipeline (next slide).

Pro · sklearn

Leak-Proof Cleaning with a Pipeline

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import (
    StandardScaler, OneHotEncoder)

num = Pipeline([
  ('imp', SimpleImputer(strategy='median')),
  ('sc',  StandardScaler())])
cat = Pipeline([
  ('imp', SimpleImputer(strategy='most_frequent')),
  ('oh',  OneHotEncoder(handle_unknown='ignore'))])

pre = ColumnTransformer([
  ('num', num, num_cols),
  ('cat', cat, cat_cols)])

model = Pipeline([('pre', pre), ('clf', clf)])
model.fit(X_train, y_train)   # fit = train only ✓
Raw X numeric colsSimpleImputer(median)→ StandardScaler categorical colsSimpleImputer(most_frequent)→ OneHotEncoder ColumnTransformer Estimator / model
🔒
One object, zero leakage

Because every statistic is learned inside .fit(), cross-validation and GridSearchCV stay honest, and the exact same cleaning is guaranteed at inference time.

Pro · Outliers

Outlier-Aware Cleaning

A handful of extreme values silently wreck the mean, inflate variance and stretch every scaler. Detect them, then decide deliberately: cap, remove, or keep.

IQR fences — everything beyond the whiskers is an outlier Q1 median Q3 Q1−1.5·IQR Q3+1.5·IQR outlier
📏
IQR Fence
Q1−1.5·IQR … Q3+1.5·IQR
Distribution-free and robust. The default for skewed, non-normal data.
📈
Z-Score
|z| > 3
Fast for roughly normal columns — but the mean/σ it uses are themselves distorted by outliers.
✂️
Winsorize / Clip
clip to 1st / 99th pct
Keep the row, cap the value. Safer than deletion when every record matters.
Pro · Debug

Common Pitfalls & Their Fixes

SymptomThe trapThe fix
Great CV, awful in prodImpute before train/test splitFit on train only — use a Pipeline
Centre pulled offfillna(mean) on skewed dataUse the median
SettingWithCopyWarningChained df[a][b] = …Assign via df.loc[rows, col]
Numbers won't compute"1,200" stored as textpd.to_numeric(…, errors='coerce')
Phantom categories"Mumbai""mumbai "str.lower().str.strip() + map
Valid rows vanishdrop_duplicates() on all colsDedupe on a subset=[key]
Dates as objectsLeft as stringspd.to_datetime(…, errors='coerce')
Section 08 · Part 1

The Golden Rules — 1 to 4

🧹 DATA-CLEANING DISCIPLINE · RULES 1–4
1
Never overwrite the original. Work on df.copy() — the raw file is your single source of truth.
2
Audit before you clean. .info(), .isnull().sum(), .duplicated().sum() — measure the problem before touching it.
3
Know WHY it's missing. MCAR, MAR or MNAR — the mechanism determines the method.
4
Flag before you fill. For MAR/MNAR add df['col_missing'] — the absence itself is predictive.
Section 08 · Part 2

The Golden Rules — 5 to 8 & Takeaway

🧹 DATA-CLEANING DISCIPLINE · RULES 5–8
5
Median, not mean, for skewed numerics — income, prices, revenue are pulled up by extremes.
6
Inspect duplicates first with df[df.duplicated(keep=False)] — exact, key-conflict and fuzzy each need different handling.
7
Validate after every step with assert — a silent new bug is worse than no cleaning.
8
Document every decision — which rows dropped, which values imputed, and why. That's your compliance trail.
🎯
Cleaning is where the real decisions live

Every choice about a missing value or duplicate row encodes an assumption about the world. Make those assumptions consciously, document them clearly, validate them rigorously. Clean data isn't just technically correct — it's ethically sound.

🧹 End of tutorial · Press to review, or click Restart