Module 6 — Data cleaning
Module 8 of the introduction course established it: data preparation absorbs most of a project, and no algorithm recovers from wrong data. This module turns that observation into concrete moves. The guiding thread: every cleaning decision is a methodological choice that deserves a line of justification, not an automatic reflex.
Diagnose first, treat second
The most common mistake is cleaning before measuring. Three commands draw up the inventory:
df.isna().mean().sort_values(ascending=False) # missing rate PER COLUMN
df.duplicated().sum() # strictly identical rows
df.dtypes # actual types vs expected types
Complete them with value_counts() on every categorical (typos, case variants) and describe() on every numeric (impossible values: negative ages, zero amounts, future dates).
Missing values: three questions before any treatment
Why are they missing? The answer changes everything. A sensor down for a week (accidental absence), an optional form field (structural absence), income undeclared precisely by high earners (informative absence). In the third case, deleting or imputing erases a real signal — a boolean income_missing column is sometimes better.
How many are missing? At 2%, almost any reasonable treatment works. At 40%, the column itself is in question. In between, the trade-off depends on the variable's importance.
Drop or impute?
df = df.dropna(subset=["amount"]) # drop rows — if rare and uninformative
df["age"] = df["age"].fillna(df["age"].median()) # impute the median — robust to extremes
df["country"] = df["country"].fillna("Unknown") # explicit category
df["temperature"] = df["temperature"].interpolate() # time series
If the data feeds a model, the imputation statistics (median, mean) must be computed on the training set only, then applied to the test set. Computing them on the full dataset is data leakage — the introduction course (module 5) showed what that costs. This is exactly what scikit-learn pipelines automate.
Duplicates: exact and approximate
df = df.drop_duplicates() # strictly identical rows
df = df.drop_duplicates(subset=["client_id", "date"]) # "business" duplicates
df = df.sort_values("updated").drop_duplicates(subset=["client_id"], keep="last") # keep the latest
The strict duplicate is easy. The business duplicate — two different rows describing the same reality (same client, addresses typed differently) — requires defining the uniqueness key with the business, and the survival rule (keep). The prior question in all cases: is the duplicate a collection error or a reality (a client can legitimately order twice on the same day)?
Types: the column that lies about its nature
A badly typed column silently corrupts everything downstream: text amounts do not sum, text dates do not compare, describe() ignores them.
df["amount"] = pd.to_numeric(df["amount"], errors="coerce") # unconvertible → NaN
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df["zip_code"] = df["zip_code"].astype("string") # a code is NOT a number
df["segment"] = df["segment"].astype("category") # categorical: memory and semantics
errors="coerce" turns the unconvertible into NaN which you then recount: if 3,000 values become null, the problem is upstream (decimal separator, literal "N/A", units glued to numbers) and gets fixed at the source, often right in read_csv (decimal=",", na_values=["N/A", "-"]).
The special case of identifiers: zip codes, company registration numbers, phone numbers are strings. Typing them as integers destroys leading zeros — a classic and irreversible loss once the file is rewritten.
Entry inconsistencies: the .str accessor
df["city"] = df["city"].str.strip().str.title() # whitespace, case
df["country"] = df["country"].replace({"FR": "France", "france": "France"})
The working method: value_counts() before to see the variants, normalize, value_counts() after to verify. For significant reference data (countries, regions), an explicit mapping table beats ad-hoc fixes: it is reviewable and reusable.
Keeping a trace of what you did
Honest cleaning leaves a trail. The minimal form — a few log lines in the script or the notebook:
n0 = len(df)
df = df.dropna(subset=["amount"])
print(f"Missing amount: {n0 - len(df)} rows dropped ({(n0-len(df))/n0:.1%})")
If an analysis result surprises anyone, the first question will be "what did the cleaning do?"; these traces answer in seconds. Cleaning functions gathered into a module (cleaning.py, module 3) make the whole thing replayable on next month's data — the criterion that separates a cleaning process from a one-off hack.
Key takeaways
- Diagnose before treating: missing rate per column, duplicates, actual types,
value_countsof categoricals. - Missing values: understand the absence mechanism before choosing between dropping, imputing or flagging; impute from the training set only if a model follows.
- Business duplicates: define the uniqueness key and the survival rule with the business.
to_numeric/to_datetimewitherrors="coerce"then recount the NaN; identifiers as strings, never integers.- Every decision traced in one line; replayable cleaning is worth ten manual ones.
Next module: combining tables — joins, grouping and pivot tables, the trio that turns scattered files into answers.