Data cleaning¶
Level 2 · Chapter 2 · ⏱️ ~60 min read · Prerequisites: SQL and data acquisition and pandas
Raw data is never ready to analyze. This chapter teaches a systematic way to clean it: reshape it into tidy form, parse types, understand why values are missing before you fill them, handle outliers, duplicates, inconsistent strings, dates, and time zones, and write validation checks so bad data can't sneak back in. You'll clean the ShopCo tables from Chapter 1 end to end and turn the result into a reusable function.
Why it matters¶
ShopCo's marketing team wanted to send promotional emails at the hour customers are most likely to buy. Priya pulled a year of orders and plotted orders by hour of day. The timestamps came from two systems: the website wrote UTC times ending in Z, and the mobile app wrote local times with an offset such as +05:30. To get the hour, Priya took characters 11 and 12 of each string. That's quick, and it ignores the offsets.
The chart looked plausible: a broad, flat hump with its tallest bar at 19:00. The emails went out at 19:00 UTC. Conversion barely moved. A month later, Wei re-parsed the timestamps properly and converted each order to the customer's own time zone. The real peak was a sharp one at 20:00 to 21:00 local time. For customers in the US, India, and Brazil, most of the customer base, 19:00 UTC was hours away from it. The first analysis had mixed instants from five time zones, half of them with the offset ignored, into one meaningless histogram.
Nothing crashed. Every step "worked". That's the nature of dirty data: it rarely causes errors. It causes plausible, wrong answers. Cleaning is how you make sure that the numbers mean what you think they mean.
Concepts¶
A cleaning workflow¶
Cleaning goes best when it's systematic rather than reactive. A good order of operations:
flowchart LR
A["Load raw<br/>(read-only)"] --> B["Profile:<br/>types, nulls,<br/>uniques, ranges"]
B --> C["Fix types<br/>and parse"]
C --> D["Remove<br/>duplicates"]
D --> E["Standardize<br/>strings and<br/>categories"]
E --> F["Handle missing<br/>values and<br/>outliers"]
F --> G["Validate<br/>against a<br/>contract"]
G --> H["Save clean data<br/>+ cleaning log"]
Four principles make the process trustworthy:
- Never edit raw data. Keep the original files or tables untouched, and produce cleaned copies. If a cleaning decision turns out wrong, you can redo it.
- Clean with code, not by hand. A fix made by editing a spreadsheet cell is invisible and unrepeatable. A fix in a script can be reviewed, rerun on next month's data, and corrected.
- Log every decision with counts. "Removed 71 duplicate orders" is a fact you can check and report. Silent row loss is one of the most common analysis bugs.
- Clean for the question. Whether a USD 4,000 order is an error depends on the business. Whether a missing age matters depends on whether you need age. Decide with the question in mind, and write the reason next to the rule.
Start by generating the ShopCo data again. It's the same generator as Chapter 1.
The ShopCo data generator (expand and run this first)
import numpy as np
import pandas as pd
def make_shop(n_users=5000, seed=42):
"""Messy, seeded synthetic data for ShopCo: returns (users, events, orders)."""
rng = np.random.default_rng(seed)
start = pd.Timestamp("2024-01-01", tz="UTC")
end = pd.Timestamp("2025-01-01", tz="UTC")
# ---------- users ----------
uid = np.arange(1001, 1001 + n_users)
country = rng.choice(["US", "UK", "DE", "IN", "BR"], n_users, p=[.40, .20, .15, .15, .10])
p_mobile = pd.Series(country).map({"US": .35, "UK": .80, "DE": .45, "IN": .80, "BR": .60}).to_numpy()
device = np.where(rng.random(n_users) < p_mobile, "mobile",
rng.choice(["desktop", "tablet"], n_users, p=[.85, .15]))
channel = rng.choice(["organic", "paid_search", "social", "email", "referral"],
n_users, p=[.35, .25, .20, .10, .10])
signup = start + pd.to_timedelta(rng.uniform(0, 330, n_users), unit="D")
age_true = np.clip(rng.normal(36, 11, n_users), 18, 80).round()
age = age_true.copy()
age[rng.random(n_users) < np.where(device == "mobile", .15, .04)] = np.nan # mobile form skips age
age[rng.choice(n_users, 25, replace=False)] = -1 # "unknown" sentinel
age[rng.choice(n_users, 4, replace=False)] = [230, 199, 340, 2] # typos
variants = {"US": ["us", "USA", "United States"], "UK": ["uk", "U.K.", "United Kingdom"],
"DE": ["de", "Germany"], "IN": ["India", " IN"], "BR": ["Brazil", "br "]}
messy = rng.random(n_users) < 0.2
country_raw = [rng.choice(variants[c]) if m else c for c, m in zip(country, messy)]
users = pd.DataFrame({
"user_id": uid,
"signup_at": signup.strftime("%Y-%m-%d %H:%M:%S"), # UTC, stored as text
"country": country_raw, "device": device, "channel": channel, "age": age,
})
users = pd.concat([users, users.sample(60, random_state=seed)]) # double-submitted forms
# ---------- sessions and funnel events ----------
lam = pd.Series(channel).map({"email": 9, "referral": 7, "organic": 6,
"social": 4, "paid_search": 4}).to_numpy()
n_sess = rng.poisson(lam * rng.gamma(2, 0.5, n_users))
s_user = np.repeat(np.arange(n_users), n_sess)
s_dev, s_cty = device[s_user], country[s_user]
# visits follow a daily rhythm in the customer's *local* time, peaking in the evening
hour_w = np.array([3, 2, 1, 1, 1, 1, 2, 3, 4, 5, 5, 6, 7, 6, 6, 6, 6, 7, 8, 10, 11, 10, 8, 5])
local_s = rng.choice(24, len(s_user), p=hour_w / hour_w.sum()) * 3600 + rng.uniform(0, 3600, len(s_user))
utc_off = pd.Series(s_cty).map({"US": -5, "UK": 0, "DE": 1, "IN": 5.5, "BR": -3}).to_numpy()
day = (signup[s_user] + (end - signup[s_user]) * rng.random(len(s_user))).floor("D")
s_start = day + pd.to_timedelta(local_s - utc_off * 3600, unit="s")
s_start = s_start.where(s_start > signup[s_user], signup[s_user] + pd.Timedelta(minutes=5))
s_start = s_start.where(s_start < end - pd.Timedelta(hours=1), end - pd.Timedelta(hours=1))
p_buy = np.select([s_dev == "desktop", s_dev == "tablet"], [.85, .70], .40) + .12 * (s_cty == "UK")
cart = rng.random(len(s_user)) < .35
checkout = cart & (rng.random(len(s_user)) < .60)
purchase = checkout & (rng.random(len(s_user)) < p_buy)
t_cart = s_start + pd.to_timedelta(rng.uniform(30, 600, len(s_user)), unit="s")
t_chk = t_cart + pd.to_timedelta(rng.uniform(30, 300, len(s_user)), unit="s")
t_buy = t_chk + pd.to_timedelta(rng.uniform(10, 120, len(s_user)), unit="s")
sid = np.arange(1, len(s_user) + 1)
parts = []
for name, mask, t in [("page_view", np.ones(len(s_user), bool), s_start), ("add_to_cart", cart, t_cart),
("checkout", checkout, t_chk), ("purchase", purchase, t_buy)]:
parts.append(pd.DataFrame({"session_id": sid[mask], "user_id": uid[s_user][mask],
"event_type": name, "ts_ms": t[mask].as_unit("ms").asi8}))
events = pd.concat(parts).sort_values("ts_ms", ignore_index=True)
events = pd.concat([events, events.sample(frac=.01, random_state=seed)]) # client retries
events = events.sort_values("ts_ms", ignore_index=True)
events.insert(0, "event_id", np.arange(1, len(events) + 1))
# ---------- orders (one per purchase session) ----------
o_idx = np.flatnonzero(purchase)
n_o = len(o_idx)
cats = np.array(["electronics", "home", "fashion", "books", "beauty"])
cat = rng.choice(cats, n_o, p=[.20, .25, .25, .15, .15])
median = pd.Series(cat).map({"electronics": 120, "home": 60, "fashion": 45,
"books": 18, "beauty": 30}).to_numpy()
n_items = 1 + rng.poisson(.6, n_o)
spend = median * n_items ** .7 * np.exp(.01 * (age_true[s_user][o_idx] - 36))
amount = np.round(spend * rng.lognormal(0, .5, n_o), 2)
status = rng.choice(["completed", "cancelled", "refunded"], n_o, p=[.90, .05, .05])
amount[(status == "refunded") & (rng.random(n_o) < .5)] *= -1 # some refunds stored negative
amount[rng.random(n_o) < .015] = np.nan # lost in a migration
amount[rng.choice(n_o, 4, replace=False)] = 99999.0 # QA test orders
cat_raw = cat.astype(object)
r = rng.random(n_o)
cat_raw[r < .10] = np.char.title(cat[r < .10].astype(str))
cat_raw[(r >= .10) & (r < .15)] = np.char.upper(cat[(r >= .10) & (r < .15)].astype(str))
cat_raw[(r >= .15) & (r < .20)] = np.char.add(cat[(r >= .15) & (r < .20)].astype(str), " ")
coupon = rng.choice(np.array([None, "WELCOME10", "SPRING20", "FREESHIP"], dtype=object),
n_o, p=[.80, .08, .06, .06])
# timestamps: web writes UTC with "Z"; the mobile app writes local time with an offset
t = pd.Series(t_buy[o_idx])
o_dev, o_cty = s_dev[o_idx], s_cty[o_idx]
ts = t.dt.strftime("%Y-%m-%dT%H:%M:%SZ")
tzs = {"US": "America/New_York", "UK": "Europe/London", "DE": "Europe/Berlin",
"IN": "Asia/Kolkata", "BR": "America/Sao_Paulo"}
for c, tz in tzs.items():
m = (o_dev == "mobile") & (o_cty == c)
local = t[m].dt.tz_convert(tz).dt.strftime("%Y-%m-%d %H:%M:%S%z")
ts[m] = local.str[:-2] + ":" + local.str[-2:]
orders = pd.DataFrame({
"order_id": np.arange(500001, 500001 + n_o), "user_id": uid[s_user][o_idx],
"order_ts": ts.to_numpy(), "category": cat_raw, "amount_usd": amount,
"n_items": n_items, "status": status, "coupon": coupon,
})
orphans = orders.sample(15, random_state=seed).assign(user_id=lambda d: d.user_id + 90000,
order_id=lambda d: d.order_id + 50000)
orders = pd.concat([orders, orphans, orders.sample(frac=.02, random_state=seed + 1)])
orders = orders.sample(frac=1, random_state=seed).reset_index(drop=True)
return users.reset_index(drop=True), events, orders
import numpy as np
import pandas as pd
pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 20)
users, events, orders = make_shop()
Tidy data¶
Before fixing individual values, make sure the table has a sensible shape. Tidy data, a term popularized by Hadley Wickham, follows three rules:
- Each variable is a column.
- Each observation is a row.
- Each type of observational unit is its own table.
ShopCo's three tables are tidy: users, events, and orders are different units, and each row is one of them. But data often arrives untidy, especially from spreadsheets and reports. A typical example is a "wide" report with one column per month:
wide = pd.DataFrame({
"category": ["books", "home"],
"2024-01": [310.5, 920.0],
"2024-02": [298.0, 1012.4],
"2024-03": [355.2, 980.9],
})
long = wide.melt(id_vars="category", var_name="month", value_name="revenue_usd")
long["month"] = pd.to_datetime(long["month"])
print(long)
category month revenue_usd
0 books 2024-01-01 310.5
1 home 2024-01-01 920.0
2 books 2024-02-01 298.0
3 home 2024-02-01 1012.4
4 books 2024-03-01 355.2
5 home 2024-03-01 980.9
In the wide table, "month" is a variable hidden in the column headers. In the long (tidy) table, it's a column you can filter, group, join, and plot. Other common untidy patterns are several values packed into one cell ("red;blue"), one variable split across columns (first_name, last_name is fine; revenue_q1, revenue_q2 is not), and two units mixed in one table (customer details repeated on every order row).
Tidy is the right form for analysis. Wide is often the right form for presentation, such as a report table with months across the top. Use pivot_table to go back when you present.
Profiling: look before you fix¶
Profiling means summarizing every column before changing anything: its type, how many values are missing, how many distinct values it has, and a few examples. A small function does it for any table:
def profile(df):
"""One row per column: dtype, missing count and share, distinct count, and examples."""
return pd.DataFrame({
"dtype": df.dtypes.astype(str),
"n_missing": df.isna().sum(),
"pct_missing": (100 * df.isna().mean()).round(1),
"n_unique": df.nunique(),
"examples": [df[c].dropna().unique()[:3].tolist() for c in df.columns],
})
print(profile(users))
print(profile(orders))
dtype n_missing pct_missing n_unique examples
user_id int64 0 0.0 5000 [1001, 1002, 1003]
signup_at str 0 0.0 5000 [2024-09-01 10:55:12, 2024-11-24 14:47:13, 202...
country str 0 0.0 17 [IN, UK, IN]
device str 0 0.0 3 [mobile, desktop, tablet]
channel str 0 0.0 5 [organic, email, paid_search]
age float64 529 10.5 58 [25.0, 42.0, 36.0]
dtype n_missing pct_missing n_unique examples
order_id int64 0 0.0 3565 [501290, 500121, 500840]
user_id int64 0 0.0 2169 [2835, 1182, 2227]
order_ts str 0 0.0 3550 [2024-10-19T22:41:44Z, 2024-11-23 11:49:42+05:...
category str 0 0.0 20 [electronics, books , fashion]
amount_usd float64 53 1.5 3162 [105.67, 10.36, 59.82]
n_items int64 0 0.0 5 [1, 2, 3]
status str 0 0.0 3 [completed, refunded, cancelled]
coupon str 2915 80.2 3 [SPRING20, WELCOME10, FREESHIP]
Read the profile line by line and write down everything that looks wrong:
signup_atandorder_tsare strings, not timestamps.countryhas far more than five distinct values for a store that sells in five countries.agehas missing values, and it's a float, partly because pandas usesNaN(a float) to mark missing numbers.categoryhas more distinct spellings than there are categories.couponis mostly missing. Is that unknown, or "no coupon"?
Then add the numeric ranges, which is where impossible values show up:
count 4531.0
mean 36.4
std 12.8
min -1.0
25% 29.0
50% 36.0
75% 44.0
max 340.0
Name: age, dtype: float64
count 3583.00
mean 195.19
std 3338.29
min -311.22
25% 32.83
50% 59.94
75% 110.50
max 99999.00
Name: amount_usd, dtype: float64
An age of -1 and an age of 340 are not real ages. A negative order amount and a maximum of exactly 99,999 need explaining. Profiling took two minutes and found most of this chapter's work.
Types and parsing¶
Getting types right is the foundation, because every later operation depends on them. A date stored as a string sorts alphabetically, can't be subtracted, and can't be grouped by month. A number stored as a string can't be summed.
The tools:
pd.to_datetimeparses dates and times. Passutc=Truewhen strings carry offsets, andformat=...when you know the format (faster, and it fails loudly instead of guessing).pd.to_numeric(..., errors="coerce")parses numbers and turns anything unparseable intoNaN..astype("category")stores a low-cardinality string column as integer codes plus a lookup table: smaller and faster..astype("Int64")(capital I) is pandas' nullable integer type, which can hold missing values without turning integers into floats.
errors="coerce" is convenient and dangerous, because it converts every bad value into a missing one silently. Always count what was coerced:
raw = pd.Series(["1,234.50", "USD 99", " 42 ", "N/A", "", "3.5e2", "twelve"])
cleaned = raw.str.replace("USD", "").str.replace(",", "").str.strip()
parsed = pd.to_numeric(cleaned.replace({"": None, "N/A": None}), errors="coerce")
print(pd.DataFrame({"raw": raw, "parsed": parsed}))
failed = parsed.isna() & ~raw.str.strip().isin(["", "N/A"])
print("values that failed to parse:", raw[failed].tolist())
raw parsed
0 1,234.50 1234.5
1 USD 99 99.0
2 42 42.0
3 N/A NaN
4 NaN
5 3.5e2 350.0
6 twelve NaN
values that failed to parse: ['twelve']
The explicit missing markers ("" and "N/A") become NaN on purpose. "twelve" becomes NaN by accident, and the last line catches it. In real data, look at the failures before deciding what to do with them.
For ShopCo, three columns need parsing. signup_at is a UTC timestamp written without an offset. ts_ms is Unix epoch milliseconds. order_ts mixes formats and offsets, which the time zone section handles below.
signup = pd.to_datetime(users["signup_at"], format="%Y-%m-%d %H:%M:%S", utc=True)
event_ts = pd.to_datetime(events["ts_ms"], unit="ms", utc=True)
print(signup.dtype, signup.min(), signup.max())
print(event_ts.dtype, event_ts.min(), event_ts.max())
print(users["age"].astype("Int64").head(3).tolist())
datetime64[us, UTC] 2024-01-01 01:21:20+00:00 2024-11-25 23:51:40+00:00
datetime64[ms, UTC] 2024-01-02 12:49:54.797000+00:00 2024-12-31 23:12:46.186000+00:00
[25, <NA>, 42]
Check the minimum and maximum of every parsed date. A parsing mistake, such as reading epoch milliseconds as seconds or swapping day and month, usually produces dates in 1970, the far future, or the wrong season.
Duplicates¶
Duplicates come in three kinds, and each needs a different fix:
- Exact duplicates: every column is identical. Usually a pipeline loaded the same data twice, or a form was submitted twice. Drop all but one.
- Key duplicates: the same primary key with different content, such as an order that appears once as
completedand once asrefunded. These are often updates, and you need a rule for which version wins, such as "keep the row with the latestupdated_at". - Near duplicates: the same real-world entity under slightly different records, such as one customer with two accounts. These need entity resolution (matching on name, email, address), which is a whole field of its own.
A subtle case: ShopCo's events have a unique event_id, so no two rows are exact duplicates. But when the mobile app retries a request, the server records the same event twice and gives each copy a new ID. You only see these duplicates when you compare the columns that describe the event itself:
event_cols = ["session_id", "user_id", "event_type", "ts_ms"]
print("users, exact duplicates: ", users.duplicated().sum())
print("users, duplicate user_id: ", users["user_id"].duplicated().sum())
print("orders, exact duplicates: ", orders.duplicated().sum())
print("orders, duplicate order_id: ", orders["order_id"].duplicated().sum())
print("events, exact duplicates: ", events.duplicated().sum())
print("events, duplicate content: ", events.duplicated(subset=event_cols).sum())
users, exact duplicates: 60
users, duplicate user_id: 60
orders, exact duplicates: 71
orders, duplicate order_id: 71
events, exact duplicates: 0
events, duplicate content: 462
For users and orders, the key duplicates and the exact duplicates are the same rows, which means every duplicate is an identical copy. That's the easy case: drop_duplicates() is safe. If there had been more key duplicates than exact duplicates, some keys would have conflicting versions, and you'd need to look at them before choosing.
Common mistake: deduplicating on the wrong columns
drop_duplicates() with no arguments compares every column, including surrogate IDs like event_id that are unique by construction. It then finds nothing. Deduplicate on the columns that define the thing (the event's content), and check how many rows you removed against what you expected.
Inconsistent categories and strings¶
Free text and loosely controlled fields drift: "US", "us", "USA", "United States", and " US" with a leading space. Grouping treats each as a separate value. The fix has three steps:
- Normalize mechanically: strip whitespace and fix case. This alone collapses many variants.
- Map explicitly: a dictionary from every known variant to its canonical value.
- Check for leftovers: anything not covered by the map is either a new variant to add or a genuinely invalid value. Never let unmapped values pass silently.
COUNTRY_MAP = {"US": "US", "USA": "US", "UNITED STATES": "US", "UK": "UK", "U.K.": "UK",
"UNITED KINGDOM": "UK", "DE": "DE", "GERMANY": "DE", "IN": "IN", "INDIA": "IN",
"BR": "BR", "BRAZIL": "BR"}
step1 = users["country"].str.strip().str.upper()
print("distinct before:", users["country"].nunique(), " after strip+upper:", step1.nunique())
country = step1.map(COUNTRY_MAP)
print("unmapped values:", step1[country.isna()].unique().tolist())
print(country.value_counts())
distinct before: 17 after strip+upper: 12
unmapped values: []
country
US 2045
UK 1038
IN 756
DE 721
BR 500
Name: count, dtype: int64
When you don't know the variants in advance, Python's difflib can suggest close matches to a list of canonical values, which helps you build the map. Treat its suggestions as candidates for a human to approve, not as automatic fixes: "Austria" and "Australia" are close strings and different countries.
import difflib
canonical = ["electronics", "home", "fashion", "books", "beauty"]
for raw_value in ["Electronics ", "HOME", "fashon", "boks", "garden"]:
match = difflib.get_close_matches(raw_value.strip().lower(), canonical, n=1, cutoff=0.75)
print(f"{raw_value!r:15} -> {match}")
'Electronics ' -> ['electronics']
'HOME' -> ['home']
'fashon' -> ['fashion']
'boks' -> ['books']
'garden' -> []
Two more string traps are worth knowing. Unicode: the same visible text can have different underlying characters (an accented "é" can be one code point or two), so normalize with unicodedata.normalize("NFKC", s) when text comes from many sources. And encodings: a file read with the wrong encoding turns "Zürich" into "Zürich". When you see that pattern, re-read the file as UTF-8.
Missing data: mechanisms first, then imputation¶
Before you fill a missing value, ask why it's missing. The answer decides which fixes are valid. Statisticians (following Donald Rubin) classify the reasons into three missing data mechanisms.
Let \(Y\) be a variable with some values missing, such as age, and let \(R\) be the missingness indicator: \(R = 1\) when the value is missing and \(R = 0\) when it's observed. Let \(X\) be other variables we observe fully, such as device.
-
MCAR (missing completely at random): missingness is unrelated to anything, observed or not.
\[ P(R = 1 \mid X, Y) = P(R = 1) \]Example: a server randomly dropped 2% of form submissions. The observed values are a random sample of all values.
-
MAR (missing at random): missingness depends only on observed variables.
\[ P(R = 1 \mid X, Y) = P(R = 1 \mid X) \]Example: ShopCo's mobile signup form makes age optional and many mobile users skip it, while the desktop form requires it. Missingness depends on device, which we observe. The name is confusing: MAR does not mean random overall, only random within groups defined by observed variables.
-
MNAR (missing not at random): missingness depends on the missing value itself, or on something we don't observe.
\[ P(R = 1 \mid X, Y) \text{ depends on } Y \]Example: older customers are more reluctant to state their age, or high earners skip the income question.
Why it matters: under MCAR, dropping incomplete rows (complete-case analysis) gives unbiased estimates; you just lose sample size. Under MAR, complete-case estimates can be biased, but you can correct the bias using the observed variables that drive missingness. Under MNAR, the data alone can't tell you the answer; you need assumptions or extra information.
You can't prove which mechanism holds from the data, because the missing values are, by definition, missing. But you can test MCAR against MAR: if missingness differs across groups of an observed variable, it isn't MCAR.
Age is missing about three and a half times as often for mobile signups. That rules out MCAR, and it's consistent with MAR given device (it could still be MNAR as well; no test on observed data can rule that out).
A simulation shows what each mechanism does to the simplest estimate, the mean. Below, mobile users are younger than desktop users, which is realistic. We hide ages under each mechanism and compare three estimates of the true mean age: the complete-case mean, the mean after filling with the overall mean (which equals the complete-case mean), and the mean after group-wise imputation, filling each missing age with the median of its device group.
rng = np.random.default_rng(0)
n = 100_000
mobile = rng.random(n) < 0.5
age = np.where(mobile, rng.normal(30, 8, n), rng.normal(42, 10, n))
true_mean = age.mean()
masks = {
"MCAR": rng.random(n) < 0.20,
"MAR (by device)": rng.random(n) < np.where(mobile, 0.35, 0.05),
"MNAR (older skip)": rng.random(n) < 1 / (1 + np.exp(-(age - 50) / 5)),
}
rows = []
for name, miss in masks.items():
s = pd.Series(np.where(miss, np.nan, age))
group_fill = s.fillna(s.groupby(mobile).transform("median"))
rows.append({"mechanism": name, "pct_missing": 100 * miss.mean(),
"complete_case": s.mean(), "group_imputed": group_fill.mean()})
res = pd.DataFrame(rows).set_index("mechanism")
print(f"true mean age: {true_mean:.2f}")
print(res.round(2))
true mean age: 35.97
pct_missing complete_case group_imputed
mechanism
MCAR 20.04 35.97 35.96
MAR (by device) 20.05 37.08 35.96
MNAR (older skip) 15.99 33.38 34.03
Under MCAR, every method is fine. Under MAR, the complete-case mean is biased upward, because the younger mobile users are underrepresented among the observed ages; filling within device groups removes the bias. Under MNAR, both are biased, because the people missing are systematically older than the observed people in the same group, and nothing in the observed data reveals by how much.
Now the menu of imputation methods (filling in missing values), with their costs:
| Method | What it does | Valid under | Cost |
|---|---|---|---|
| Drop rows (complete case) | Remove rows with any missing value | MCAR | Loses data; biased under MAR |
| Mean or median | Fill with one global value | MCAR, roughly | Shrinks variance, weakens correlations |
| Group-wise median | Fill with the median of a related group | MAR given the group | Needs a good grouping variable |
| Model-based (regression, kNN, iterative) | Predict the missing value from other columns | MAR | More complex; can still understate uncertainty |
| Multiple imputation | Impute several times with noise, analyze each, combine | MAR | The statistically principled choice for inference |
| Missing indicator | Add a column age_missing and fill with a constant |
Any (for prediction) | Lets a model learn from missingness itself |
Why mean imputation shrinks variance. Suppose a fraction \(m\) of \(n\) values are missing, the observed values have mean \(\bar{y}\) and variance \(\sigma^2\), and you fill the missing ones with \(\bar{y}\). The filled values contribute zero to the sum of squared deviations, so the total sum of squares stays at about \((1 - m)\,n\,\sigma^2\), now divided by \(n\):
With 20% missing, the apparent variance drops by 20%, and the standard deviation by about 11% (since \(\sqrt{0.8} \approx 0.89\)). Confidence intervals become too narrow, and correlations with other variables are pulled toward zero, because the filled-in values don't vary with anything.
Structural missingness is a different thing entirely. ShopCo's coupon column is missing for about 80% of orders because those orders didn't use a coupon. That's not unknown information; it's a known "none". Fill it with an explicit value such as "none", and don't count it as a data quality problem.
Common mistake: imputing with information from the test set
When you build a model, compute imputation values (medians, group medians, fitted imputation models) on the training data only, then apply them to validation and test data. Computing a median on the full dataset leaks information about the test set into training. Chapter 4 covers this kind of leakage, and scikit-learn pipelines prevent it.
For ShopCo, we won't impute age at all during cleaning. We'll mark invalid values as missing, keep them missing, and let each analysis decide. A cleaned dataset should record what's known; imputation is a modeling decision.
Outliers¶
An outlier is a value far from the bulk of the data. Outliers come from two very different sources, and the first job is telling them apart:
- Errors: typos (age 340), test records (a USD 99,999 order), unit mistakes (cents stored as dollars), and sentinel codes (age -1 meaning "unknown"). These should be fixed or removed.
- Genuine extremes: a real customer who spent USD 1,500. Removing these throws away true information, often the most important information (fraud, whales, failures).
Three detection rules are common. Each gives a fence: values beyond it get flagged for a look.
- Z-score: \(z_i = (x_i - \bar{x}) / s\), where \(\bar{x}\) is the mean and \(s\) the standard deviation. Flag \(|z_i| > 3\). The weakness: the outliers inflate \(\bar{x}\) and \(s\) themselves, so a few huge values can hide each other. This is called masking.
-
Robust z-score: replace the mean with the median and the standard deviation with the median absolute deviation, \(\text{MAD} = \operatorname{median}_i\left(|x_i - \operatorname{median}(x)|\right)\). For normal data, \(\text{MAD} \approx 0.6745\,\sigma\), so the robust z-score is
\[ z_i^{\text{robust}} = \frac{x_i - \operatorname{median}(x)}{1.4826 \cdot \text{MAD}} \]where \(1.4826 \approx 1/0.6745\) makes it comparable to an ordinary z-score. Medians barely move when a few extreme values are added, so this rule doesn't suffer from masking. 3. IQR fences (Tukey's rule): with quartiles \(Q_1\) and \(Q_3\) and interquartile range \(\text{IQR} = Q_3 - Q_1\), flag values below \(Q_1 - 1.5\,\text{IQR}\) or above \(Q_3 + 1.5\,\text{IQR}\). This is what a box plot's whiskers show.
All three assume roughly symmetric data. Order amounts are strongly right-skewed (many small orders, a long tail of large ones), so on the raw scale these rules flag hundreds of perfectly real orders. A better approach for skewed, positive data is to apply the rules to \(\log(x)\), where the distribution is closer to symmetric:
amt = orders["amount_usd"].dropna()
pos = amt[amt > 0]
def iqr_flags(x):
q1, q3 = x.quantile([0.25, 0.75])
return (x < q1 - 1.5 * (q3 - q1)) | (x > q3 + 1.5 * (q3 - q1))
def robust_z(x):
med = x.median()
mad = (x - med).abs().median()
return (x - med) / (1.4826 * mad)
print("negative amounts: ", (amt < 0).sum())
print("z-score |z| > 3 (raw): ", (((pos - pos.mean()) / pos.std()).abs() > 3).sum())
print("IQR flags (raw scale): ", iqr_flags(pos).sum())
print("IQR flags (log scale): ", iqr_flags(np.log(pos)).sum())
print("robust |z| > 3.5 (log scale): ", (robust_z(np.log(pos)).abs() > 3.5).sum())
print("largest values:", pos.nlargest(6).tolist())
negative amounts: 98
z-score |z| > 3 (raw): 4
IQR flags (raw scale): 216
IQR flags (log scale): 18
robust |z| > 3.5 (log scale): 4
largest values: [99999.0, 99999.0, 99999.0, 99999.0, 1019.12, 1019.12]
The raw z-score flags exactly the four test orders and nothing else. They inflate the standard deviation to over USD 3,000, so a genuine USD 1,000 order gets a z-score well below 1: the extremes hide everything less extreme. With several outliers of different sizes, that's how they mask each other. The raw IQR rule has the opposite problem and flags over 200 ordinary large orders, because the distribution is skewed. On the log scale, the IQR rule flags 18 orders (unusually small or large, but plausible), and the robust z-score at a threshold of 3.5 flags just the four test orders, which stand far apart from everything else.
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].hist(pos, bins=60, color="#4C72B0")
axes[0].set_title("Raw scale: test orders squash everything")
axes[0].set_xlabel("amount (USD)")
axes[1].hist(np.log10(pos), bins=60, color="#4C72B0")
q1, q3 = np.log10(pos).quantile([0.25, 0.75])
for fence in [q1 - 1.5 * (q3 - q1), q3 + 1.5 * (q3 - q1)]:
axes[1].axvline(fence, color="#C44E52", linestyle="--")
axes[1].set_title("Log scale with IQR fences")
axes[1].set_xlabel("log10(amount in USD)")
for ax in axes:
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.show()

On the raw scale, four test orders stretch the axis so far that the real distribution collapses into one bar. On the log scale the shape is visible, and the test orders sit far beyond the fence.
Once you've found outliers, the options are:
- Fix them if you know the true value (a refund stored as a negative amount can become positive with a
refundedstatus). - Remove them if they're not real (test orders) and log how many you removed.
- Cap (or winsorize) them: replace values beyond a percentile, such as the 1st and 99th, with that percentile. This limits their influence on means and models without deleting rows.
- Keep them and use robust methods: medians instead of means, log transforms, or rank-based statistics.
Whatever you choose, decide using domain knowledge and write the rule down. "Ages outside 13 to 100 are invalid because the site requires users to be 13 or older" is a rule someone can check and challenge.
Dates and time zones¶
Time is the messiest data type. Get the concepts straight first:
- An instant is a single point in time, the same everywhere: "the moment the order was placed". Store instants in UTC (Coordinated Universal Time).
- A wall-clock time (or local time) is what a clock on the wall showed somewhere: "20:15 in Mumbai". It means nothing without a place.
- A UTC offset like
+05:30says how far a local time is from UTC at that moment. - A time zone like
Europe/Berlinis a set of rules for which offset applies when, including daylight saving time (DST). Berlin is+01:00in winter and+02:00in summer. Time zones are named with IANA identifiers (Region/City), and an offset alone isn't enough to recover them.
The rule of thumb: parse with offsets, store in UTC, convert to local time only for questions about local behavior ("what hour do people shop?").
ShopCo's order_ts mixes 2024-10-19T22:41:44Z (UTC) and 2024-11-23 11:49:42+05:30 (local with offset). pd.to_datetime(..., utc=True, format="ISO8601") parses both, applies each offset, and returns UTC instants. Compare that with the naive approach from the story, which reads the clock digits and ignores the offset:
TIMEZONES = {"US": "America/New_York", "UK": "Europe/London", "DE": "Europe/Berlin",
"IN": "Asia/Kolkata", "BR": "America/Sao_Paulo"}
o = orders.drop_duplicates()
naive_hour = o["order_ts"].str[11:13].astype(int) # the wrong way
ts_utc = pd.to_datetime(o["order_ts"], utc=True, format="ISO8601") # the right way
print(o["order_ts"].iloc[1], "->", ts_utc.iloc[1])
o_country = o["user_id"].map(users.drop_duplicates().set_index("user_id")["country"]
.str.strip().str.upper().map(COUNTRY_MAP))
local_hour = pd.Series(-1, index=o.index)
for c, tz in TIMEZONES.items():
m = o_country == c
local_hour[m] = ts_utc[m].dt.tz_convert(tz).dt.hour
local_hour = local_hour[local_hour >= 0] # orphan orders have no country
print("naive parse disagrees with UTC for", (naive_hour != ts_utc.dt.hour).mean().round(3), "of orders")
print("peak hour naive:", naive_hour.mode()[0], " UTC:", ts_utc.dt.hour.mode()[0],
" customer-local:", local_hour.mode()[0])
2024-11-23 11:49:42+05:30 -> 2024-11-23 06:19:42+00:00
naive parse disagrees with UTC for 0.317 of orders
peak hour naive: 19 UTC: 19 customer-local: 20
fig, axes = plt.subplots(1, 3, figsize=(12, 3.2), sharey=True)
for ax, (title, h) in zip(axes, [("Naive (offsets ignored)", naive_hour),
("UTC", ts_utc.dt.hour),
("Customer's local time", local_hour)]):
ax.bar(range(24), h.value_counts().reindex(range(24), fill_value=0), color="#4C72B0")
ax.set_title(title)
ax.set_xlabel("hour of day")
ax.spines[["top", "right"]].set_visible(False)
axes[0].set_ylabel("orders")
plt.tight_layout()
plt.show()

The same orders, three ways. Only the customer-local view answers "when do people shop?", and it shows a clear evening peak that the other two blur.
The UTC chart is correct as a record of instants, and it's the right basis for questions like "how many orders happened during the outage from 14:00 to 15:00 UTC". It's just the wrong view for a question about human behavior, because 20:00 in Mumbai and 20:00 in New York are ten and a half hours apart.
Two DST traps catch everyone eventually. When clocks spring forward, some local times don't exist (in Berlin, 02:30 on 31 March 2024 never happened). When clocks fall back, some local times happen twice (01:30 on 3 November 2024 in New York is ambiguous). If you store local times without offsets, you can't tell those apart. That's one more reason to store UTC.
Common mistake: naive and aware timestamps
pandas distinguishes naive timestamps (no time zone) from aware ones (with a time zone). Comparing or subtracting a naive and an aware timestamp raises an error, which is good. Worse is when everything is naive but different columns silently mean different zones. Make every timestamp column aware, in UTC, as early as possible.
Validation and data contracts¶
Cleaning once isn't enough. Next month's data will have new problems. Validation means checking data against explicit expectations every time it arrives, and failing loudly when they're violated.
A data contract is an agreement between the producer of a dataset (an app team, a vendor) and its consumers (you) about what the data looks like: column names and types, which columns can be null, allowed values, ranges, uniqueness, and freshness. Written as code, it becomes a test that runs in the pipeline. Tools such as pandera, Great Expectations, and dbt tests do this at scale, but the idea fits in a few lines of pandas:
ORDERS_CONTRACT = {
"order_id": {"dtype": "int64", "unique": True, "nullable": False},
"user_id": {"dtype": "int64", "nullable": False},
"order_ts": {"dtype": "datetime64[us, UTC]", "nullable": False},
"category": {"allowed": {"electronics", "home", "fashion", "books", "beauty"}},
"amount_usd": {"min": 0, "max": 10_000},
"status": {"allowed": {"completed", "cancelled", "refunded"}},
}
def validate(df, contract):
"""Return a list of human-readable contract violations (empty means valid)."""
problems = []
for col, rules in contract.items():
if col not in df:
problems.append(f"{col}: missing column")
continue
s = df[col]
if "dtype" in rules and str(s.dtype) != rules["dtype"]:
problems.append(f"{col}: dtype {s.dtype}, expected {rules['dtype']}")
if rules.get("nullable") is False and s.isna().any():
problems.append(f"{col}: {s.isna().sum()} nulls")
if rules.get("unique") and s.duplicated().any():
problems.append(f"{col}: {s.duplicated().sum()} duplicate values")
if "allowed" in rules:
bad = set(s.dropna().astype(str)) - rules["allowed"]
if bad:
problems.append(f"{col}: {len(bad)} unexpected values, e.g. {sorted(bad)[:3]}")
if "min" in rules and (s < rules["min"]).any():
problems.append(f"{col}: {(s < rules['min']).sum()} values below {rules['min']}")
if "max" in rules and (s > rules["max"]).any():
problems.append(f"{col}: {(s > rules['max']).sum()} values above {rules['max']}")
return problems
for p in validate(orders, ORDERS_CONTRACT):
print("FAIL", p)
FAIL order_id: 71 duplicate values
FAIL order_ts: dtype str, expected datetime64[us, UTC]
FAIL category: 15 unexpected values, e.g. ['BEAUTY', 'BOOKS', 'Beauty']
FAIL amount_usd: 98 values below 0
FAIL amount_usd: 4 values above 10000
Every failure is one of the problems we found by profiling, now expressed as a check that runs automatically. In a production pipeline, a failure stops the load and alerts someone, rather than letting bad data flow into dashboards. Notice that amount_usd is allowed to be null here: we decided missing amounts are acceptable (and will be excluded from revenue), so the contract records that decision.
In practice¶
A complete, reusable cleaning function¶
Here's everything above as one function. Each rule has a comment explaining why. Later chapters reuse this exact function.
def clean_shop(users, events, orders):
"""Apply the Chapter 2 cleaning rules. Returns clean (users, events, orders)."""
u = users.drop_duplicates().copy()
u["signup_at"] = pd.to_datetime(u["signup_at"], utc=True)
u["country"] = u["country"].str.strip().str.upper().map(COUNTRY_MAP)
u["age"] = u["age"].where(u["age"].between(13, 100)) # sentinels and typos -> NaN
u = u.astype({"device": "category", "channel": "category", "country": "category"})
e = events.drop_duplicates(subset=["session_id", "user_id", "event_type", "ts_ms"]).copy()
e["ts"] = pd.to_datetime(e["ts_ms"], unit="ms", utc=True)
e = e.drop(columns="ts_ms")
o = orders.drop_duplicates().copy()
o = o[o["user_id"].isin(u["user_id"])] # drop orphans
o = o[o["amount_usd"].ne(99999.0)] # drop QA test orders
o["order_ts"] = pd.to_datetime(o["order_ts"], utc=True, format="ISO8601")
o["category"] = o["category"].str.strip().str.lower().astype("category")
o["amount_usd"] = o["amount_usd"].abs() # refunds stored as negatives
o["coupon"] = o["coupon"].fillna("none")
country = o["user_id"].map(u.set_index("user_id")["country"])
o["local_hour"] = -1
for c, tz in TIMEZONES.items():
m = (country == c).to_numpy()
o.loc[m, "local_hour"] = o.loc[m, "order_ts"].dt.tz_convert(tz).dt.hour
return u.reset_index(drop=True), e.reset_index(drop=True), o.reset_index(drop=True)
u, e, o = clean_shop(users, events, orders)
for name, before, after in [("users", users, u), ("events", events, e), ("orders", orders, o)]:
print(f"{name:7s} {len(before):>6,} -> {len(after):>6,} rows ({len(before) - len(after):,} removed)")
print("contract violations after cleaning:", validate(o, ORDERS_CONTRACT))
print(profile(o)[["dtype", "n_missing", "n_unique"]])
users 5,060 -> 5,000 rows (60 removed)
events 46,621 -> 46,159 rows (462 removed)
orders 3,636 -> 3,546 rows (90 removed)
contract violations after cleaning: []
dtype n_missing n_unique
order_id int64 0 3546
user_id int64 0 2153
order_ts datetime64[us, UTC] 0 3546
category category 0 5
amount_usd float64 52 3136
n_items int64 0 5
status str 0 3
coupon str 0 4
local_hour int64 0 24
Two details deserve a comment. The test-order filter uses ne(99999.0) rather than < 10000 because a missing amount should survive cleaning; NaN < 10000 is False and would silently drop those rows. And category is stored as a pandas categorical, which is smaller and makes the allowed set explicit.
The orders that remain with a missing amount_usd are still real orders: they count toward order volume and conversion, but not toward revenue. Write that down in the analysis, too.
A cleaning log and before/after metrics¶
The final check is to compare the headline numbers before and after cleaning. Large changes are expected here; the point is that each one is explained by a logged rule.
def headline(o_df, u_df):
amt = pd.to_numeric(o_df["amount_usd"], errors="coerce")
done = o_df["status"].eq("completed")
return pd.Series({
"user_rows": len(u_df),
"orders": len(o_df),
"revenue_usd": amt[done].sum().round(2),
"mean_order_usd": amt[done].mean().round(2),
"median_order_usd": amt[done].median().round(2),
})
print(pd.DataFrame({"raw": headline(orders, users), "clean": headline(o, u)}))
raw clean
user_rows 5060.00 5000.00
orders 3636.00 3546.00
revenue_usd 683479.40 277159.97
mean_order_usd 213.19 88.49
median_order_usd 62.24 62.24
The four test orders alone had inflated revenue by roughly USD 400,000, more than doubling it, and the mean order value with it. The median barely moved, which is a good reminder of why medians are robust. Save the clean tables to Parquet so the types survive, and keep the cleaning script next to them.
u.to_parquet("users_clean.parquet")
e.to_parquet("events_clean.parquet")
o.to_parquet("orders_clean.parquet")
Common mistake: cleaning that changes the question
Every cleaning rule changes the population you're analyzing. Dropping orders with missing amounts means "revenue among orders with a recorded amount". Dropping users with missing age biases toward desktop users. Report the rules alongside the results, so readers know which population the numbers describe.
Exercises¶
Exercise 1: Tidy a report (easy)¶
A finance spreadsheet has one row per country and one column per quarter: country, Q1_2024, Q2_2024, Q3_2024, Q4_2024, with revenue values. Convert it to a tidy table with columns country, quarter (as a pandas Period), and revenue_usd, then compute each country's revenue growth from Q1 to Q4. Use this data: US 100, 120, 150, 180; DE 40, 42, 41, 50.
Solution
wide = pd.DataFrame({"country": ["US", "DE"],
"Q1_2024": [100, 40], "Q2_2024": [120, 42],
"Q3_2024": [150, 41], "Q4_2024": [180, 50]})
tidy = wide.melt(id_vars="country", var_name="quarter", value_name="revenue_usd")
tidy["quarter"] = pd.PeriodIndex(tidy["quarter"].str[3:] + tidy["quarter"].str[:2], freq="Q")
tidy = tidy.sort_values(["country", "quarter"]).reset_index(drop=True)
print(tidy)
growth = tidy.groupby("country")["revenue_usd"].agg(lambda s: s.iloc[-1] / s.iloc[0] - 1)
print(growth.round(3))
country quarter revenue_usd
0 DE 2024Q1 40
1 DE 2024Q2 42
2 DE 2024Q3 41
3 DE 2024Q4 50
4 US 2024Q1 100
5 US 2024Q2 120
6 US 2024Q3 150
7 US 2024Q4 180
country
DE 0.25
US 0.80
Name: revenue_usd, dtype: float64
The header Q1_2024 holds two variables (quarter and year) glued together. Reordering it to 2024Q1 lets pandas parse it as a quarterly period, which sorts correctly and supports date arithmetic.
Exercise 2: Masking, by hand (easy)¶
For the values \(x = [10, 11, 12, 12, 13, 14, 200, 250]\), compute by hand: the mean and standard deviation (use the sample standard deviation), the z-score of 200, the median, the MAD, and the robust z-score of 200. Which rule flags 200 at a threshold of 3? Check with NumPy.
Solution
The mean is \(522 / 8 = 65.25\). The squared deviations from the mean sum to about \(69{,}313.5\), dominated by 200 and 250, so the sample standard deviation is \(\sqrt{69{,}313.5 / 7} \approx 99.5\). So \(z_{200} = (200 - 65.25)/99.5 \approx 1.35\): not flagged. The two outliers inflate the standard deviation enough to hide each other.
The median is the average of the middle two sorted values, \((12 + 13)/2 = 12.5\). The absolute deviations from 12.5 are \(2.5, 1.5, 0.5, 0.5, 0.5, 1.5, 187.5, 237.5\); sorted, the middle two are \(1.5\) and \(1.5\), so \(\text{MAD} = 1.5\). The robust z-score is \((200 - 12.5)/(1.4826 \times 1.5) \approx 84.3\): flagged by a mile.
Exercise 3: Testing the missingness mechanism (medium)¶
Use a chi-square test of independence (from Level 1) to test whether age missingness in the raw users table (after removing duplicates and treating -1 as missing) is independent of device. Then repeat for channel. What do the results say about MCAR?
Solution
from scipy.stats import chi2_contingency
uu = users.drop_duplicates().copy()
uu["age_missing"] = uu["age"].isna() | uu["age"].eq(-1)
for col in ["device", "channel"]:
table = pd.crosstab(uu[col], uu["age_missing"])
chi2, p, dof, _ = chi2_contingency(table)
rates = uu.groupby(col)["age_missing"].mean().round(3).to_dict()
print(f"{col:8s} chi2={chi2:7.1f} dof={dof} p={p:.2g} missing rate: {rates}")
device chi2= 140.2 dof=2 p=3.5e-31 missing rate: {'desktop': 0.05, 'mobile': 0.155, 'tablet': 0.05}
channel chi2= 4.1 dof=4 p=0.4 missing rate: {'email': 0.117, 'organic': 0.104, 'paid_search': 0.121, 'referral': 0.093, 'social': 0.106}
Missingness strongly depends on device (a tiny p-value), so the data are not MCAR. There's no evidence that it depends on channel. A reasonable working assumption is MAR given device, which suggests imputing within device groups if you need age. No test can rule out MNAR, so if age matters for a decision, say so.
Exercise 4: Daylight saving traps (medium)¶
Localize these wall-clock times to Europe/Berlin with tz_localize: 2024-03-31 02:30 and 2024-10-27 02:30. What happens, and why? Then localize them using nonexistent="shift_forward" and ambiguous=True, and print the UTC equivalents.
Solution
for s in ["2024-03-31 02:30", "2024-10-27 02:30"]:
try:
print(pd.Timestamp(s).tz_localize("Europe/Berlin"))
except Exception as err:
print(type(err).__name__, "-", err)
spring = pd.Timestamp("2024-03-31 02:30").tz_localize("Europe/Berlin", nonexistent="shift_forward")
autumn = pd.Timestamp("2024-10-27 02:30").tz_localize("Europe/Berlin", ambiguous=True) # True = DST reading
print(spring, "=", spring.tz_convert("UTC"))
print(autumn, "=", autumn.tz_convert("UTC"))
ValueError - 2024-03-31 02:30:00 is a nonexistent time due to daylight savings time. Try using the 'nonexistent' argument.
ValueError - Cannot infer dst time from 2024-10-27 02:30:00, try using the 'ambiguous' argument
2024-03-31 03:00:00+02:00 = 2024-03-31 01:00:00+00:00
2024-10-27 02:30:00+02:00 = 2024-10-27 00:30:00+00:00
On 31 March, Berlin's clocks jumped from 02:00 to 03:00, so 02:30 never existed. On 27 October, clocks went from 03:00 back to 02:00, so 02:30 happened twice, once at +02:00 and once at +01:00, and pandas refuses to guess which. Instants stored in UTC (or with an offset) never have this problem.
Exercise 5: The cost of mean imputation (hard)¶
Simulate 50,000 pairs \((x, y)\) with \(y = 2x + \varepsilon\), \(x \sim \mathcal{N}(0, 1)\) and \(\varepsilon \sim \mathcal{N}(0, 1)\). Make 30% of \(x\) missing completely at random. Compare, for the complete cases and for mean-imputed \(x\): the standard deviation of \(x\), the correlation between \(x\) and \(y\), and the slope of a least-squares regression of \(y\) on \(x\). Check the variance against the formula \((1 - m)\sigma^2\), and explain the slope result.
Solution
rng = np.random.default_rng(1)
n = 50_000
x = rng.normal(0, 1, n)
y = 2 * x + rng.normal(0, 1, n)
miss = rng.random(n) < 0.30
x_obs = np.where(miss, np.nan, x)
x_imp = np.where(miss, np.nanmean(x_obs), x_obs)
def summary(xv, yv):
slope = np.cov(xv, yv)[0, 1] / np.var(xv, ddof=1)
return np.std(xv, ddof=1), np.corrcoef(xv, yv)[0, 1], slope
cc = ~miss
for name, (sd, r, b) in [("complete case", summary(x[cc], y[cc])), ("mean imputed", summary(x_imp, y))]:
print(f"{name:14s} sd(x)={sd:.3f} corr={r:.3f} slope={b:.3f}")
print("predicted sd after imputation:", round(np.sqrt(1 - miss.mean()) * np.nanstd(x_obs, ddof=1), 3))
complete case sd(x)=0.995 corr=0.896 slope=2.016
mean imputed sd(x)=0.832 corr=0.750 slope=2.016
predicted sd after imputation: 0.832
Complete-case analysis is unbiased here because the data are MCAR: the slope is close to the true 2 and the correlation close to the true \(2/\sqrt{5} \approx 0.894\). Mean imputation shrinks the standard deviation of \(x\) to about \(\sqrt{0.7} \approx 0.84\) of its value, as the formula predicts, and it drags the correlation toward zero, because 30% of rows now have a constant \(x\) paired with a \(y\) that still varies. The slope stays near 2 in this particular setup (filling \(x\) at its mean adds points that sit at the center), but the residual variance balloons, so standard errors and any inference built on them are wrong. In general, single imputation understates uncertainty; multiple imputation fixes that.
Check yourself¶
-
What are the three rules of tidy data?
Answer
Each variable is a column, each observation is a row, and each type of observational unit is its own table.
-
Why should you count values after
pd.to_numeric(..., errors="coerce")?Answer
Coercion silently turns every unparseable value into
NaN. Counting (and looking at) the values that failed tells you whether they were intended missing markers or real data in an unexpected format. -
Define MCAR, MAR, and MNAR in one sentence each.
Answer
MCAR: missingness is unrelated to any variable. MAR: missingness depends only on observed variables. MNAR: missingness depends on the missing value itself or on something unobserved.
-
Why does complete-case analysis give biased results under MAR?
Answer
The observed rows are no longer a random sample: groups with more missingness are underrepresented. If those groups differ on the variable of interest (mobile users are younger), the observed mean is pulled toward the overrepresented groups.
-
Why can a plain z-score fail to detect outliers?
Answer
The outliers inflate the mean and standard deviation used to compute the z-score, so extreme values can look ordinary. This is masking. Robust z-scores use the median and MAD, which barely move when outliers are present.
-
What's the difference between a UTC offset and a time zone?
Answer
An offset is a fixed difference from UTC at one moment (
+01:00). A time zone (Europe/Berlin) is a set of rules that determines the offset at any date, including daylight saving changes. -
When is a missing value not a data quality problem?
Answer
When it's structural: the value doesn't exist rather than being unknown, such as a coupon code on an order that used no coupon. Fill it with an explicit value like
"none". -
What is a data contract, and why write it as code?
Answer
An agreement between data producers and consumers about schema, types, nullability, allowed values, ranges, uniqueness, and freshness. As code, it runs automatically on every new batch and stops bad data before it reaches analyses.
Key takeaways¶
- Clean systematically: profile, fix types, deduplicate, standardize, handle missing values and outliers, then validate. Never edit raw data, and log every rule with counts.
- Tidy data (variables as columns, observations as rows) is the right shape for analysis; reshape with
meltandpivot_table. - Ask why data is missing before filling it. Complete-case analysis is fine under MCAR, biased under MAR, and no method fixes MNAR without assumptions. Mean imputation shrinks variance by about \((1-m)\).
- Separate errors from genuine extremes. Use robust rules (median and MAD, IQR) on a sensible scale, such as log for skewed amounts, and decide with domain knowledge.
- Parse timestamps with their offsets, store instants in UTC, and convert to local time only for questions about local behavior.
- Turn your cleaning knowledge into a data contract that runs on every new batch.
Further reading¶
- "Tidy Data" by Hadley Wickham, Journal of Statistical Software 59(10), 2014.
- Flexible Imputation of Missing Data by Stef van Buuren (2nd edition, CRC Press), also freely readable online, for missing data mechanisms and multiple imputation.
- Statistical Analysis with Missing Data by Roderick Little and Donald Rubin (Wiley), the classic reference.
- The pandas user guide sections "Working with missing data" and "Time series / date functionality" (time zone handling), at pandas.pydata.org/docs.
Next¶
With clean data, you can start asking questions: Exploratory data analysis.