Data Cleaning in Python
Press Next → or use ← → arrow keys
The Dirty Data Problem
The missing values weren't random — they were biased. Data cleaning has an ethical dimension.
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.
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()
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.
Understanding WHY Data Is Missing
The mechanism determines the method. Diagnose why a value is absent before choosing how to fill it.
Missing-Value Treatment — Decision Tree
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.
Drop Rows or Columns — dropna()
# 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):,}")
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.
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()))
| age | city | rating |
|---|---|---|
| 28 | Mumbai | 4.5 |
| NaN | Delhi | NaN |
| 42 | NaN | 3.8 |
| NaN | Chennai | 5.0 |
| age | city | rating |
|---|---|---|
| 28 | Mumbai | 4.5 |
| 34 | Delhi | 4.2 |
| 42 | Mumbai | 3.8 |
| 34 | Chennai | 5.0 |
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.
Interpolation — for Time Series
# 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()
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.
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])
Removing Duplicates — Know the Three Types
df.duplicated().sum() would have caught it in seconds.| 102 | Arjun | 8200 |
| 102 | Arjun | 8200 |
drop_duplicates()| 102 | 8200 (v1) |
| 102 | 8950 (v2) |
keep='last' / groupby| Mumbai |
| mumbai / Mum. |
str.lower().str.strip()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)
| id | name | amt |
|---|---|---|
| 102 | Arjun | 8200 |
| 102 | Arjun | 8200 |
| 103 | Deepa | 6100 |
| 103 | Deepa | 6100 |
| id | name | amt |
|---|---|---|
| 102 | Arjun | 8200 |
| 103 | Deepa | 6100 |
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()}
Store it beside every cleaned dataset — it's your audit trail for reproducibility, compliance, and handing work to teammates.
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')
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.
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.
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).
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 ✓
Because every statistic is learned inside .fit(), cross-validation and GridSearchCV stay honest, and the exact same cleaning is guaranteed at inference time.
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.
Common Pitfalls & Their Fixes
| Symptom | The trap | The fix |
|---|---|---|
| Great CV, awful in prod | Impute before train/test split | Fit on train only — use a Pipeline |
| Centre pulled off | fillna(mean) on skewed data | Use the median |
SettingWithCopyWarning | Chained df[a][b] = … | Assign via df.loc[rows, col] |
| Numbers won't compute | "1,200" stored as text | pd.to_numeric(…, errors='coerce') |
| Phantom categories | "Mumbai" ≠ "mumbai " | str.lower().str.strip() + map |
| Valid rows vanish | drop_duplicates() on all cols | Dedupe on a subset=[key] |
| Dates as objects | Left as strings | pd.to_datetime(…, errors='coerce') |
The Golden Rules — 1 to 4
df.copy() — the raw file is your single source of truth..info(), .isnull().sum(), .duplicated().sum() — measure the problem before touching it.df['col_missing'] — the absence itself is predictive.The Golden Rules — 5 to 8 & Takeaway
df[df.duplicated(keep=False)] — exact, key-conflict and fuzzy each need different handling.assert — a silent new bug is worse than no cleaning.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