Python for AI and Data Science

Data Cleaning and Handling Missing Values


You load a customer dataset: 10,000 rows, 14 columns. A few cells are blank, so you type the obvious fix.

Python
df = df.dropna()print(len(df))     # 1,247

You have just thrown away 87% of your data — and not a random 87%. The column with the most blanks was income, and people decline to state their income for reasons that correlate strongly with their income. What survived is a biased sample of unusually forthcoming customers, and any model trained on it will be confidently wrong about everybody else.

That is the shape of nearly every data cleaning mistake. The operation succeeds. The output looks tidier than the input. The damage is invisible until much later, when the numbers turn out not to describe the world. Cleaning is not about making data look neat; it is about making a series of judgement calls, deliberately, and knowing what each one costs.

Dropping and filling both cost you somethingDrop the rows• Keeps every remaining value honest• Loses whatever else those rows held• Biases the sample if gaps are not random• Fine at 1% missing, reckless at 30%Fill the blanks• Keeps the sample size intact• Shrinks the variance of that column• Invents values the model then trusts• Add an indicator column for was_missing
Fit the filler on training rows only — imputing before the split leaks the test set into every score you report.

What actually goes wrong in real data

ProblemLooks likeDamage if ignored
Missing valuesNaN, None, "", "N/A", -999Crashes, or silently biased statistics
Duplicate rowsSame record loaded twiceInflated counts; the model memorises repeats
Inconsistent categories"UK", "uk", "U.K.", " UK "One real group split into four
Wrong dtypePrices stored as text (str or object)Arithmetic fails or concatenates text
OutliersAn age of 999; a price of 0Means and variances dragged off; models distorted
Placeholder codes-1 or 9999 meaning "unknown"Treated as a real number in every calculation

The last row is worth pausing on. Legacy systems often encode "missing" as a sentinel number. If -999 means "no reading" and you compute a mean without noticing, your average temperature comes out below freezing and every downstream decision inherits the error.

Python
df = pd.read_csv("data.csv", na_values=["N/A", "-", "unknown", -999, 9999])

Missing values

First, find out how much and where

Python
missing = pd.DataFrame({    "n_missing": df.isna().sum(),    "pct": (df.isna().mean() * 100).round(1),}).query("n_missing > 0").sort_values("pct", ascending=False)print(missing)print(f"rows with any missing value: {df.isna().any(axis=1).sum()}")print(f"rows that are entirely complete: {df.notna().all(axis=1).sum()}")

That second pair of numbers is what would have saved the opening disaster. Ten per cent missing spread across fourteen columns can still mean that almost no single row is complete.

Why it is missing changes what you should do

PatternPlain EnglishExampleSafe to drop?
Missing at randomBlanks are unrelated to anythingA sensor dropped packetsYes, if the volume is small
Missing for a reason you can seeRelates to another column you haveYounger users skip "job title"Fill using the related column
Missing because of its own valueRelates to the hidden value itselfHigh earners withhold incomeNo — dropping biases everything

You usually cannot prove which case you are in, but you can look. Compare the rows with a blank against the rows without one:

Python
flag = df.income.isna()print(df.groupby(flag)[["age", "spend", "tenure"]].mean())

If the two groups look alike, dropping is defensible. If the missing-income group spends twice as much, missingness carries information — and throwing those rows away throws the information away with them.

Missingness is data. Before you fill or drop a column, check whether the blanks themselves predict your target.

Python
df["income_missing"] = df.income.isna().astype(int)   # keep the signaldf["income"] = df.income.fillna(df.income.median())   # then fill the hole

Dropping, when it is the right call

Python
df.dropna(subset=["target"])              # never impute the thing you predictdf.dropna(thresh=int(0.7 * df.shape[1]))  # drop rows missing over 30% of fieldsdf.drop(columns=df.columns[df.isna().mean() > 0.6])   # drop mostly-empty columns

A bare dropna() is almost never right. dropna(subset=[...]) — dropping only where a specific critical column is blank — usually is. The one place dropping is unambiguously correct is the target column: a row with no label teaches a supervised model nothing, and inventing a label is manufacturing evidence.

Filling, and what each choice does to your data

StrategyCodeGood forCost
Meanfillna(s.mean())Roughly symmetric numeric dataDragged around by outliers; shrinks variance
Medianfillna(s.median())Skewed data — income, pricesAlso shrinks variance; safer default
Modefillna(s.mode()[0])CategoricalOver-inflates the commonest class
Constantfillna("Unknown")Categorical where blank is meaningfulNone, and it is honest
Forward fillffill()Time series where the last value holdsWrong across a gap of months
Interpolateinterpolate()Smooth continuous signalsInvents a trend that may not exist
Group-wisegroupby(g).transform("median")When groups differ a lotNeeds enough rows per group

Group-wise filling is usually the biggest single improvement over a global fill, and it is one line:

Python
df["salary"] = df.groupby("job_title")["salary"].transform(    lambda s: s.fillna(s.median()))

Filling a junior developer's missing salary with the median across all job titles pulls them towards the company average, which is wrong in a predictable direction. Filling with the median of their own role is far closer to the truth.

Every fill method shares one cost: it reduces variance. Replace 200 blanks with the same median and you have added 200 identical points at the centre of the distribution, so the standard deviation drops and the data looks more certain than it is. With 2% missing that is negligible. With 40% missing you have largely invented the column, and adding the indicator flag is more honest than pretending.

The mistake that inflates every score you report

Compute the median over the whole dataset, fill with it, then split into training and test sets — and you have leaked information from the test set into training. The model has effectively seen a summary of data it is meant to be judged on, and your validation score comes out higher than reality.

Python
# WRONGdf["income"] = df.income.fillna(df.income.median())train, test = train_test_split(df, test_size=0.2)# RIGHTtrain, test = train_test_split(df, test_size=0.2, random_state=42)fill_value = train.income.median()          # learned from training data onlytrain["income"] = train.income.fillna(fill_value)test["income"] = test.income.fillna(fill_value)   # applied to test

Split first, then learn every cleaning parameter from the training half alone. A statistic computed over the whole dataset is a channel through which the answers leak.

Duplicates

Python
print(df.duplicated().sum())              # exact copies across every columndf = df.drop_duplicates()# Usually you care about a business key, not the whole rowprint(df.duplicated(subset=["customer_id"]).sum())df = df.sort_values("updated_at").drop_duplicates(subset=["customer_id"], keep="last")

Exact duplicates are the easy case and often mean a file was ingested twice. The harder case is a duplicated key with differing details — the same customer with two email addresses — where keep="last" after sorting by timestamp keeps the most recent version rather than an arbitrary one.

Then there are the duplicates that are not textually identical at all:

Python
df["company_key"] = (df.company    .str.strip()    .str.lower()    .str.replace(r"\b(ltd|limited|inc|plc)\b\.?", "", regex=True)    .str.replace(r"[^a-z0-9]", "", regex=True))print(df.duplicated(subset=["company_key"]).sum())

"Acme Ltd", "acme limited" and "ACME Ltd." are one company. Until you normalise them they are three, and every per-company aggregate is wrong.

Outliers

An outlier is a value far from the rest. Whether it is an error or the most important row in your table is a judgement you must make, not one a formula can make for you.

Two ways to find them

The interquartile range method flags anything more than 1.5 box-widths beyond the quartiles. Worked through, on a column with Q1=30,000Q_1 = 30{,}000 and Q3=70,000Q_3 = 70{,}000:

IQR=70,000−30,000=40,000IQR = 70{,}000 - 30{,}000 = 40{,}000

lower=30,000−1.5×40,000=−30,000upper=70,000+1.5×40,000=130,000\text{lower} = 30{,}000 - 1.5 \times 40{,}000 = -30{,}000 \qquad \text{upper} = 70{,}000 + 1.5 \times 40{,}000 = 130{,}000

So a salary of £250,000 is flagged and one of £120,000 is not. Note the lower bound came out negative, meaning nothing is flagged at the bottom — which is correct here, because salaries cannot be negative.

Python
def iqr_bounds(s, k=1.5):    q1, q3 = s.quantile([0.25, 0.75])    iqr = q3 - q1    return q1 - k * iqr, q3 + k * iqrlow, high = iqr_bounds(df.salary)outliers = df[(df.salary < low) | (df.salary > high)]print(f"{len(outliers)} outliers ({len(outliers)/len(df):.1%})")

The z-score method flags points more than about 3 standard deviations from the mean, using z=x−μσz = \frac{x - \mu}{\sigma}. It assumes a roughly bell-shaped distribution, and it has a self-defeating flaw: the outliers themselves inflate σ\sigma, so a few extreme values raise the threshold and hide themselves. On skewed data such as income or web traffic, the IQR method is the more reliable of the two.

MethodAssumesWeakness
IQRNothing about the shapeFlags a lot on heavily skewed data
Z-scoreRoughly normalOutliers inflate the threshold that should catch them
Domain ruleYou know the valid rangeRequires you to know the field — and is the best method when you do

Deciding what to do

ActionCodeWhen
Removedf[(s >= low) & (s <= high)]It is impossible — a negative age
Cap (winsorise)s.clip(low, high)Extreme but plausible; you want to limit its influence
Transformnp.log1p(s)The whole column is skewed, not a few points
Keep and flagdf["is_extreme"] = ...The outliers are the phenomenon — fraud, failures

The strongest habit here is to check what the flagged rows have in common before removing them. If every "outlier" comes from one store, one device or one date, you have found a data collection problem, and deleting the evidence is the worst available response.

Text and type problems

Python
df["city"] = df.city.str.strip().str.title()df["email"] = df.email.str.strip().str.lower()df["phone"] = df.phone.str.replace(r"[^0-9+]", "", regex=True)df["country"] = df.country.replace({    "uk": "United Kingdom", "U.K.": "United Kingdom", "GB": "United Kingdom",})print(df.country.value_counts(dropna=False))

value_counts(dropna=False) on every categorical column is five seconds of work that finds most category problems immediately: variants that should be one group, an unexpected blank count, a category with a single row that turns out to be a typo.

Python
df["price"] = pd.to_numeric(    df.price.astype(str).str.replace(r"[£$,]", "", regex=True),    errors="coerce",          # anything unparseable becomes NaN, not an exception)df["order_date"] = pd.to_datetime(df.order_date, errors="coerce")df["category"] = df.category.astype("category")   # big memory saving

errors="coerce" is the key to bulk conversion: one bad cell in a million rows turns into a NaN you can count, rather than an exception that kills the run. Always count them afterwards — a coercion that quietly produces 40,000 new blanks needs investigating, not accepting.

Making cleaning repeatable

Cleaning done by hand in a notebook is cleaning you cannot reproduce next month. Put it in a function that reports what it did:

Python
import pandas as pd, numpy as npdef clean(df, fill_stats=None):    """Clean a customer table. Pass fill_stats from training data to avoid leakage."""    report = {"rows_in": len(df)}    df = df.copy()    df = df.drop_duplicates()    report["dupes_removed"] = report["rows_in"] - len(df)    for col in df.select_dtypes(include=["object", "str"]):        df[col] = df[col].str.strip()    df["price"] = pd.to_numeric(df.price, errors="coerce")    df["order_date"] = pd.to_datetime(df.order_date, errors="coerce")    if fill_stats is None:                       # training pass: learn the values        fill_stats = {c: df[c].median() for c in df.select_dtypes("number")}    for col, value in fill_stats.items():        df[f"{col}_was_missing"] = df[col].isna().astype(int)        df[col] = df[col].fillna(value)    df = df[df.age.between(0, 120)]              # domain rule, not a statistic    report["rows_out"] = len(df)    report["kept_pct"] = round(100 * len(df) / report["rows_in"], 1)    return df, fill_stats, reporttrain_clean, stats, rep = clean(train)test_clean, _, _ = clean(test, fill_stats=stats)   # reuse training statisticsprint(rep)

Then check the result rather than trusting it:

Python
assert train_clean.isna().sum().sum() == 0, "blanks survived cleaning"assert train_clean.duplicated().sum() == 0assert rep["kept_pct"] > 80, f"only kept {rep['kept_pct']}% of rows"

That last assertion is the one that would have caught the disaster at the top of this page. It is three seconds to write and it turns a silent 87% data loss into a loud failure at the moment it happens.

The judgement never goes away — no function can tell you whether a £250,000 salary is a typo or your best customer. What the function does is make your judgement explicit, repeatable, and auditable by someone else. That is the actual deliverable of cleaning: not a tidier table, but a written record of every decision you made about the data, and the numbers showing what each one cost.