DS 227 · Week 5

Data Preprocessing I

Cleaning, missing data and outliers — the raw material is in hand; now you decide what it's allowed to say.

Knowledge Discovery in Data · University of the Philippines Cebu

Session Map

From raw to trustworthy

Where We Left Off

Scraped and fetched — but not yet believable

Whatever door you came through — parsed HTML, an API's JSON — the rows arrive with holes, duplicates and values that make no sense. Raw data is a rumor until you've checked it.

What raw really means

Nobody promised these values are complete, consistent, or even possible.

Why models can't save you

Every analysis downstream inherits your data's flaws — garbage in, confident-looking garbage out.

This week's two monsters

Values that are absent, and values that are absurd.

Expectation Setting

Preparation eats the calendar

"Ask any working data scientist what they actually do all day, and the honest answer is mostly this week and next: finding gaps, chasing weird values, and defending the choices they made about both."
Why CRISP-DM draws Data Preparation as the biggest loop
Words for Today · 1 of 2

Seven words for Part A: missing and messy values

missing value

A cell where nothing was recorded. It can mean “unknown”, “not yet” or “refused”.

e.g. an unfinished project has no completion date

NaN

“Not a Number”: how pandas marks an empty cell. Summaries skip it; .isna() finds it.

e.g. df.isna().sum() counts NaNs per column

null

The general word for “no value”: JSON’s null, Python’s None. pandas treats both as missing.

e.g. week 4’s "closed_on": null

imputation

Filling a gap with a stand-in value you chose, instead of dropping the row.

e.g. fillna(df.temp.mean())

mean / median / mode

Three “typical values”: the average; the middle value once sorted; the most common value.

e.g. for 8, 12, 12, 45, 80: 31.4 / 12 / 12

duplicate

A row identical to an earlier one. It double-counts everything it touches.

e.g. the same project scraped twice

data type

What kind of values a column holds: whole numbers, decimals, text, dates. pandas calls it dtype.

e.g. int64, float64, object (text)

Words for Today · 2 of 2

Six words for Part B: surprising values

outlier

A value far from the others. Far is not the same as wrong: it may be a typo or the discovery.

e.g. 98 in a row of 12s

quartile

The three cut points that split sorted data into four equal groups: Q1, the median, Q3.

e.g. Q1 = 12 and Q3 = 14 for our toy series

IQR

Interquartile range, Q3 − Q1: the width of the middle half. Fences sit 1.5 IQRs beyond it.

e.g. IQR = 2, fences at 9 and 17

standard deviation

The typical distance of values from their mean. Big SD = widely spread values.

e.g. s.std()

z-score

How many standard deviations a value sits from the mean. Beyond 2 or 3 is the usual flag.

e.g. the 98 has z = 2.66

standardise / normalise

Rescale a column: to z-scores (mean 0, SD 1), or squeeze it into 0 to 1.

e.g. week 6 uses both before comparing columns

Part A

Missing Data

The values that aren't there — and what their absence means.

You can already load a table into pandas and look at its columns. This part adds the first question to ask of any new table: which cells are empty, why, and what to do about them.

Origins

Holes are made, not born

First Move

Count the holes — per column

isna().sum() is the first thing to run on any new dataset. Per-column counts tell you where the problem lives; a single total hides it.

Read it like a triage board

A column 2% empty and a column 60% empty are different diseases — one gets a fill, the other might get dropped entirely.

df = pd.DataFrame({"city": ["Cebu", "Davao", "Iloilo", "Baguio", "Tacloban"], "temp": [31, np.nan, 33, 29, np.nan], "rain_mm": [12, 4, np.nan, 8, 0]}) print(df.isna().sum()) city 0 temp 2 rain_mm 1 dtype: int64 # and the actual damaged rows print(df[df.isna().any(axis=1)]) city temp rain_mm 1 Davao NaN 4.0 2 Iloilo 33.0 NaN 4 Tacloban NaN 0.0
  • pd.DataFrame({…})a tiny weather table: five cities, three columns; np.nan is an empty cell
  • df.isna()a same-shaped table of True (empty) / False (filled)
  • .sum()adds each column’s Trues (True counts as 1): the gaps per column
  • temp 2two cities (Davao, Tacloban) have no temperature; city is complete
  • .any(axis=1)True for a row with at least one gap; axis=1 means “look across each row”
Same Data, New Job

The flood projects come back — dirty this time

Last week you pulled these through an API and counted budgets. Today you ask the question that should always come first: what is missing, and why?

34,079 rows, 15 columns

Real DPWH flood control projects, 2016–2026. Public domain. Every number on the next slides is computed from the copy in this repo.

import pandas as pd df = pd.read_csv("dpwh_flood_control.csv.gz") df.shape (34079, 15) # 15 columns, and not one of them is clean
  • pd.read_csv("….csv.gz")load a CSV file (a plain-text table) into a DataFrame; .gz means compressed, and pandas unzips it for you
  • df.shape → (34079, 15)34,079 rows (projects) and 15 columns
First Move, For Real

Count the holes — on 34,079 rows

Same one-liner as the toy frame. Different feeling when the answer is about public money.

Five columns have gaps

The other ten are complete. That alone is worth knowing before you plan any cleaning at all.

df.isna().sum().sort_values(ascending=False) completion_date 6414 longitude 3195 latitude 3195 start_date 973 contractor 876 description 0 contract_id 0 # … the other 8 columns are 0 too # 18.8% of completion_date is empty
  • .sort_values(ascending=False)sort the counts, biggest first, so the worst columns are on top
  • 64146,414 of 34,079 projects have no completion date: 18.8%
Look Again

Two columns went missing together, 3,195 times

The Diagnostic, Settled

Cross-tab the gap against something you trust

"Is it random?" is not a matter of opinion. Put the missingness next to another column and look: a cross-tab is exactly that, one column’s pattern broken down by another’s groups.

One line decides it

If the gap were random, every status would show a similar percentage. Watch what actually happens.

df.groupby("status")["completion_date"].apply( lambda s: s.isna().mean()) status Completed 0.0 For Procurement 1.0 Not Yet Started 1.0 On-Going 1.0 Terminated 0.0 Name: completion_date, dtype: float64
  • df.groupby("status")split the rows into piles, one per status
  • ["completion_date"]in each pile, look only at the completion date column
  • .apply(lambda s: s.isna().mean())run a tiny nameless function (lambda) on each pile: the share of empty cells (mean of True/False = fraction True)
  • 0.0 / 1.00% missing or 100% missing: nothing in between
The Answer

The gap is not noise — it is the status column, restated

The Same Story, Three Columns

Not yet awarded means nobody to name, nothing to pay

The 876 For Procurement rows are missing three fields at once — and it is the same 876 rows each time.

A project before its contract

No contractor has been chosen, no amount agreed, no completion date possible. The row is honest; it is just early.

fp = df[df.status == "For Procurement"] len(fp) 876 fp.contractor.isna().sum() 876 (fp.budget == 0).sum() 876 fp.completion_date.isna().sum() 876 # three columns, one cause
  • df[df.status == "For Procurement"]a boolean (True/False) filter: test every row, keep only the rows where the status matches
  • fp.contractor.isna().sum()how many of those rows have no contractor
  • (fp.budget == 0).sum()how many have a budget of exactly 0 (True counts as 1)
The Diagnostic Question

Is the gap random — or is it a message?

Before choosing a treatment, ask why the value is missing. Three broad cases, from harmless to dangerous.

A missing exam score can mean the paper got lost (random), the student was sick during flu season (explained by something you know), or the student skipped the exam they expected to fail (the gap itself is the message).

Random glitch

The gap has nothing to do with anything — a dropped packet. Safest to drop or fill.

Explained by other columns

Older stations miss more readings. Predictable — a group-aware fill can work.

The gap is the information

High earners skip the income question. Dropping them silently biases everything after. Handle with care — and say so.

Treatment 1

Drop: honest, but it shrinks the data

dropna() invents nothing — it just discards. The cost is every other value in those rows, gone with the gap.

When dropping is fine

Few rows affected, and the gaps look random. When 40% of a column vanishes — or the missing rows share a pattern — it isn't.

df = pd.DataFrame( {"temp": [31, np.nan, 33, 29, np.nan]}) df.dropna() temp 0 31.0 2 33.0 3 29.0 ← 5 rows in, 3 rows out
  • np.nanNumPy’s missing-value marker (from import numpy as np)
  • df.dropna()a new table with every row that has a gap removed; df itself is unchanged
  • 0, 2, 3the row labels keep their old numbers, so you can see rows 1 and 4 were dropped
Treatment 2

Fill: keeps the rows, invents the values

Filling a gap with a stand-in value is called imputation. fillna() plugs each hole with a stand-in — often the column mean or median. Nothing is lost; something is made up.

df.fillna(df["temp"].mean()) temp 0 31.0 1 31.0 ← invented 2 33.0 3 29.0 4 31.0 ← invented
  • df["temp"].mean()the average of the values that exist: (31 + 33 + 29) / 3 = 31.0
  • df.fillna(…)put that number into every empty cell

The fine print

Mean-filling drags rows toward the average and quietly shrinks the spread. The more you fill, the more average — and less real — the column looks.

Which Stand-In?

Mean, median or mode

Three different “typical values” you can fill with. They agree on tidy data and disagree loudly when one value is extreme, like the 80 mm day here.

New words

mean
add everything, divide by how many: (12+45+80+8+12) / 5 = 31.4
median
the middle value once sorted: 8, 12, 12, 45, 80
mode
the most common value (12 appears twice); the only one that works for text

The mode is the sari-sari store’s best-selling item; the median is the customer in the middle of the queue; the mean is what you get by sharing the whole bill equally.

rain = pd.Series([12, 45, np.nan, 80, 8, 12]) print(round(rain.mean(), 1)) print(rain.median()) print(rain.mode()[0]) 31.4 12.0 12.0
  • pd.Series([…])one column of values; np.nan is the gap
  • rain.mean() → 31.4pulled up by the 80: higher than four of the five readings
  • rain.mode()[0]mode() returns a list-like Series (there can be ties); [0] takes the first
Treatment 2, Sharpened

Fill from the right neighborhood

One global mean says a missing Baguio temperature is "average for the Philippines." A group mean says it's "average for Baguio" — a far better guess.

If you do not know one Baguio reading, you guess from other Baguio days, not from Cebu’s. The group is the neighbourhood you borrow from.

df["temp"] = df["temp"].fillna( df.groupby("region")["temp"] .transform("mean"))

How to read it

transform("mean") builds a column of each row's own group average — then fillna uses it only where the holes are.

In the lab's stretch

You'll write exactly this — and argue why it beats the global mean.

The Tempting One-Liner

df.dropna()

It runs instantly. It returns a clean frame. It is about to lie to you.

The Cost

One method call, 8,709 rows gone

Dropping every row with any gap looks tidy. Here is the receipt.

25.6% of the dataset

That is a quarter of the public record, removed without a decision being written down anywhere.

clean = df.dropna() len(df), len(clean) (34079, 25370) 34079 - 25370 8709 # 25.6% dropped
  • clean = df.dropna()a new table without any row that had a gap; df keeps all rows
  • len(df), len(clean)two row counts side by side: before and after
The Damage

Look at which rows it took

df.status.value_counts() status Completed 27534 On-Going 5442 For Procurement 876 Terminated 131 Not Yet Started 96 Name: count, dtype: int64
clean.status.value_counts() status Completed 25274 Terminated 96 Name: count, dtype: int64 # On-Going: GONE # For Procurement: GONE # Not Yet Started: GONE
The Lie It Manufactures

Your clean data now says 99.6% of projects are complete

Nothing in clean is false on its own. Every row is real. But the set of rows now describes a country where flood control almost never stalls.

This is survivorship bias

Survivorship bias: drawing conclusions only from the cases that made it through a filter. In WWII, analysts studied bullet holes on the planes that came back; the planes hit elsewhere never returned to be counted.

round(25274 / len(clean), 3) 0.996 # "99.6% completed!" # the real figure, before cleaning round(27534 / len(df), 3) 0.808 # 80.8% # you moved it 19 points by typing # six characters
The Rule

Drop rows for a reason you could write in a footnote

Your Turn · 8 min

Find a gap that means something

1 · Load and count

Read the CSV. Run isna().sum(). Which column is worst?

2 · Pick a suspect

Take start_date (973 missing). Cross-tab it against status and against year.

3 · Decide and justify

Random, or a message? Write the one sentence you would put in a footnote.

4 · Compare

Does your neighbour’s sentence match yours? If not, one of you has found something.

Quick Check

Tap to reveal

You run df.dropna() on the flood data and 8,709 rows disappear. What has actually happened to your analysis?

A · A random quarter of rows was removed, so percentages are unchanged
B · Every unfinished project was removed, so the data now describes only completed work
C · Only rows with typos were removed
D · Nothing important — 25% is a normal loss
B. Missingness tracked status exactly: On-Going, For Procurement and Not Yet Started are 100% missing a completion date, so all of them were dropped. The survivors say 99.6% of projects are complete; the true figure is 80.8%.
Quick Check

Tap to reveal

A survey's income column is 35% empty — and you suspect high earners are the ones skipping it. Fill with the column mean?

A · Yes — mean fill keeps all the rows
B · No — the gaps aren't random; a mean fill biases income downward
C · Yes, but round to the nearest peso
D · Drop the whole column silently

B — this gap is a message.

If the missing values were mostly high, filling them with the average of the answered ones writes systematically low numbers into exactly the rows that were highest. Whatever you do here, it must be declared — not hidden inside a one-liner.

Quiet Problem 1

Duplicates: the same row counted twice

A duplicate is a row identical to an earlier one. Scrapes that fetch a page twice, or files glued together twice, create them silently, and every count and sum after that is inflated.

A class list where one student was typed in twice: the headcount, the average grade and the pass rate are all quietly wrong.

The flood file is clean here

No repeated rows, and no contract id used twice. Checking took one line; not checking is how double-counting ships.

t = pd.DataFrame({"city": ["Cebu", "Davao", "Cebu"], "temp": [31, 30, 31]}) print(t.duplicated().tolist()) print(len(t.drop_duplicates())) [False, False, True] 2 # the flood data print(df.duplicated().sum()) print(df.contract_id.duplicated().sum()) 0 0
  • t.duplicated()True for each row that repeats an earlier row; the first copy stays False
  • .drop_duplicates()a new table with the repeats removed: 3 rows in, 2 out
  • df.contract_id.duplicated()repeats in one column only: a contract id should never appear twice
Quiet Problem 2

Every column has a data type

A column’s data type (pandas says dtype) decides what you can do with it: add numbers, sort dates, search text. Load a CSV and pandas guesses each type from what it sees.

Reading the output

float64
numbers with decimals (budgets in pesos and centavos)
int64
whole numbers (the year)
object
pandas’ word for text

Dates arrived as text

start_date looks like 2022-07-14 but is stored as text: you cannot subtract two of them until you convert the column.

print(df[["budget", "year", "start_date", "status"]].dtypes) budget float64 year int64 start_date object status object dtype: object
  • df[[…]]keep four columns (a list of names inside the brackets)
  • .dtypesthe data type of each column
Reading An Error

When a number column contains “n/a”

pd.to_numeric converts text such as "31" into the number 31. One cell that is not a number stops it with an error. Read the traceback (the error report) from the bottom up:

  • ValueErrorlast line first: the type of error. A ValueError means the right kind of input with an unusable value
  • "n/a" at position 1the message: which value, and where (counting from 0: the second cell)
  • line 3your own line that triggered it: the to_numeric call

The fix

errors="coerce" turns anything unreadable into NaN instead of stopping: the bad cell becomes a missing value you can count, as in Part A.

import pandas as pd raw = pd.Series(["31", "n/a", "33"]) nums = pd.to_numeric(raw) Traceback (most recent call last): File "clean.py", line 3, in <module> nums = pd.to_numeric(raw) … pandas’ own inner lines (trimmed) … ValueError: Unable to parse string "n/a" at position 1 nums = pd.to_numeric(raw, errors="coerce") print(nums.tolist()) [31.0, nan, 33.0]
Break

  Five minutes

Then: the values that showed up — and shouldn't have.

Part B

Outliers

Far from the rest — which is not the same as wrong.

Part A dealt with values that are absent. This part deals with values that are present but surprising: how to flag them with a rule, then how to judge them.

Definition, Carefully

An outlier is a point far from the rest

An outlier is a value far from the others. That's the whole definition — a statement about distance, not about truth. The 98 in a row of 12s might be a typo. It might be the discovery. (pd.Series makes one column of values.)

s = pd.Series( [12, 14, 13, 15, 11, 14, 98, 13, 12]) ↑ this one

Two very different stories

A data-entry slip (9.8 typed as 98)… or a real reading from an extraordinary day. Same number, opposite meanings.

So the job splits in two

Flag mechanically (this part), then judge honestly (the next part).

The Standard Flag

The IQR rule draws the fences

Sort the values. The quartiles cut them into four equal groups: Q1 is a quarter of the way up, Q3 three quarters. The middle 50% runs from Q1 to Q3, and its width is the IQR (interquartile range). Anything beyond 1.5 × IQR outside that box gets flagged. It's the rule behind every boxplot's whiskers.

Line the class up by height. The middle half of the line is the box; a fence sits one and a half box-widths beyond each end. Anyone past a fence gets a second look.

q1, q3 = s.quantile(0.25), s.quantile(0.75) iqr = q3 - q1 low = q1 - 1.5 * iqr high = q3 + 1.5 * iqr s[(s < low) | (s > high)] 6 98 ← flagged dtype: int64
  • s.quantile(0.25), s.quantile(0.75)Q1 = 12.0 and Q3 = 14.0 for this series
  • iqr = q3 - q114 − 12 = 2: the width of the middle half
  • low, highfences at 12 − 3 = 9.0 and 14 + 3 = 17.0
  • (s < low) | (s > high)below the low fence or (|) above the high one
  • 6 98row label 6 holds the value 98: the only point past a fence

Why quartiles, not the mean

Quartiles barely move when an extreme value appears — so the fence itself isn't bent by the very point it's trying to catch.

Fine Print

1.5 is a convention, not a law

"The 1.5×IQR fence comes from John Tukey, who reportedly chose it because 1 was too tight and 2 was too loose. It earns its keep by being useful — not by being sacred."
Flag first, then look — the rule finds candidates, not culprits
The Other Yardstick

A z-score counts standard deviations

The standard deviation (SD) is the typical distance of values from their mean. A z-score says how many SDs a value sits from the mean; past 2 (or 3 on big data) is the usual flag. Turning every value into its z-score is called standardising; squeezing values into 0 to 1 is normalising (week 6).

Why IQR stays the default

The 98 is inside the mean and SD it is measured against. Without it the SD is 1.31; with it, 28.36. The outlier stretches its own yardstick, so a few big outliers can hide each other.

z = (s - s.mean()) / s.std() print(round(s.mean(), 2), round(s.std(), 2)) print(z.round(2).tolist()) print(s[z.abs() > 2].tolist()) 22.44 28.36 [-0.37, -0.3, -0.33, -0.26, -0.4, -0.3, 2.66, -0.33, -0.37] [98]
  • s - s.mean()each value’s distance from the mean (22.44)
  • / s.std()measured in standard deviations (28.36) instead of in millimetres
  • z.abs() > 2abs drops the minus sign; keep values more than 2 SDs away, either side
  • 2.66the 98 is 2.66 SDs above the mean; every other value is within half an SD
The Judgment Call

Flagged means "look here" — not "delete me"

Three Treatments

Keep · Cap · Remove — on purpose

Robustness

The mean listens to outliers; the median doesn't

Every value moves the mean — so one huge value moves it a lot. The median only cares about order, and one extreme point barely changes the order.

Ten jeepney passengers each earn ₱20,000 a month. A billionaire climbs aboard: the mean income jumps into the millions, while the median passenger earns exactly what they did before.

round(s.mean(), 2) 22.44 ← dragged up by the 98 s.median() 13.0 ← what a typical value looks like

Practical rule

Suspect outliers? Report the median (and fill gaps with it, too). If mean and median disagree loudly, that disagreement is itself a finding.

Real Money

What the budget column actually looks like

Before flagging anything, look at the shape. A long right tail (most values small, a few huge ones stretching the range to the right: the data is skewed) is normal for money — and it is why the mean is the wrong summary.

Skewed, not broken

Half of all projects sit under ₱37.7M. The largest is ₱1.45B. Both facts are true and neither is an error.

pd.set_option("display.float_format", "{:,.0f}".format) print(df.budget.describe()) count 34,079 mean 46,952,340 std 48,594,319 min 0 25% 14,649,406 50% 37,676,233 75% 67,549,270 max 1,447,499,996 Name: budget, dtype: float64 # max is 38x the median
  • pd.set_option(…)display setting: show numbers with commas and no decimals
  • .describe()an eight-number summary: count, mean, SD, smallest, quartiles, largest
  • 25% / 50% / 75%Q1, the median, Q3
The Fence, On Real Numbers

IQR applied to 34,079 real contracts

Same formula as the toy example. Now the number it produces is a peso amount you could argue about in public.

798 projects flagged

2.34% of the dataset sits above the upper fence. That is a reading list, not a delete list.

q1, q3 = df.budget.quantile([.25, .75]) iqr = q3 - q1 fence = q3 + 1.5 * iqr print(round(q1), round(q3), round(iqr), round(fence)) 14649406 67549270 52899864 146899067 (df.budget > fence).sum() 798
  • quantile([.25, .75])both quartiles at once: about ₱14.6M and ₱67.5M
  • fenceQ3 + 1.5 × IQR: about ₱146.9M
  • (df.budget > fence).sum()how many budgets are above it: 798
Look Before You Cut

The three biggest are not typos

BudgetRegionDescription
₱1,447,499,996Central OfficeConstruction of small river impounding project
₱1,096,255,230Central OfficeConstruction of bank protection works
₱1,042,782,802Central OfficeConstruction of bank protection works
Why Not The Mean

One column, two summaries, a 25% gap

The mean is dragged upward by the same 798 projects the fence flagged. The median does not move.

Report the median for skewed money

Say "typical project" and you mean the median. Say "average" and half your audience will picture the median anyway — so give them the one they imagine.

round(df.budget.mean()) 46952340 round(df.budget.median()) 37676233 round(46952340 / 37676233, 2) 1.25 # mean is 25% higher
  • round(df.budget.mean())the average budget, rounded to whole pesos: about ₱47.0M
  • round(df.budget.median())the middle budget: about ₱37.7M
  • 1.25the mean is 1.25 times the median, i.e. 25% higher
The Other Kind Of Outlier

939 projects with a budget of exactly zero

Not large — impossible. The 0 is a placeholder: a value typed in to fill a slot, not a measurement. A zero-peso construction contract is not a cheap project; it is a project that has no contract yet.

Same cause as the missing dates

876 are For Procurement. The remaining 63 are Terminated. Both are "no money moved", recorded as 0 rather than as blank.

z = df[df.budget == 0] len(z) 939 z.status.value_counts() status For Procurement 876 Terminated 63 Name: count, dtype: int64 # 0 here means "not applicable", # NOT "free"
Zero Is Not A Number Here

Decide what a placeholder means before you average it

Your Turn · 6 min

Flag, then read

1 · Flag by region

Compute the IQR fence within each region instead of across the whole country. Does Region III flag different projects?

2 · Read three

Open the descriptions of any three flagged rows. Do they look like errors or like big projects?

3 · Decide

Keep, cap, or remove — and write the reason next to each.

Hint

df.groupby("region").budget.transform(lambda s: s.quantile(.75) + 1.5*(s.quantile(.75)-s.quantile(.25)))

Quick Check

Tap to reveal

939 flood projects have budget == 0. Should they be included when you compute the average project budget?

A · Yes — zero is a real number and dropping it is cherry-picking
B · No — they are 'For Procurement' or 'Terminated'; 0 means not applicable, not free
C · Yes, but only if you also drop the outliers
D · It makes no difference at this sample size
B. All 939 are projects with no awarded contract (876 For Procurement) or a cancelled one (63 Terminated). Averaging them in answers a question nobody asked: "what does a contract that does not exist cost?"
Quick Check

Tap to reveal

Daily rainfall for Tacloban, November 2013. One day reads 600 mm — flagged hard by the IQR rule. Best move?

A · Remove it — the rule flagged it
B · Cap it to the fence value
C · Check the date against events — it may be Typhoon Yolanda, i.e. real
D · Replace with the November mean

C — investigate before you touch it.

Yolanda made landfall that month; an extreme reading is plausibly the single most important value in the column. Delete or cap it and your "cleaned" dataset says the storm never happened.

Part C

The Discipline

Cleaning changes the story. Do it accountably.

You now know how to drop, fill, flag and cap. This part is about keeping a record of each of those choices so someone else can check them.

The Honest Frame

You're not fixing data — you're making decisions on its behalf

"Drop, fill, cap, remove — each one changes what the dataset is able to say. Two honest analysts can clean the same file differently and reach different conclusions. What makes either defensible is that the choices are visible."
Cleaning is analysis — it just happens earlier
The Working Rules

Three rules that make cleaning auditable

Make It Reproducible

A cleaning log is code, not a memory

Every decision above changed the row count or a column’s meaning. If it is not written down, next month you cannot tell what you did — and neither can anyone checking you.

Write the count at every step

Rows in, rows out, and the reason. Three numbers and a sentence.

# cleaning log — keep it in the notebook log = [] def step(name, before, after, why): log.append({"step": name, "dropped": before - after, "why": why}) step("no budget", 34079, 33140, "cannot answer a spend question")
  • log = []an empty list that will collect one entry per cleaning step
  • def step(name, before, after, why):define a small function (a reusable recipe) with four inputs
  • log.append({…})add one dict (a dictionary: named values in { }, like a form with labelled boxes) to the list: one row of the log
  • step("no budget", 34079, 33140, …)record that 939 rows were dropped, and why
The Audit Question

Could a stranger rebuild your table from your notebook?

Glossary Recap

Every new word, one line each · Part A

Missing data

missing value
a cell where nothing was recorded
NaN
pandas’ empty-cell marker; find it with .isna()
null
“no value” in general: JSON null, Python None
missing at random
gaps that fall anywhere, unrelated to other columns
structural gap
a gap caused by how the data was built
cross-tab
one column’s pattern broken down by another’s groups
survivorship bias
conclusions drawn only from the rows that survived a filter
placeholder
a value typed in to fill a slot, e.g. a budget of 0

Treatments and tools

imputation
filling a gap with a chosen stand-in value
mean / median / mode
average / middle value / most common value
group mean
the average within a group, e.g. per region
duplicate
a row identical to an earlier one
data type
what a column holds: int64, float64, object (text)
ValueError
right kind of input, unusable value, e.g. "n/a" as a number
errors="coerce"
turn unconvertible values into NaN instead of stopping
lambda
a tiny one-line function with no name
Glossary Recap

Every new word, one line each · Parts B–C

Outliers

outlier
a value far from the others; not necessarily wrong
quartile
cut points splitting sorted data into four equal groups
IQR
Q3 − Q1; fences sit 1.5 IQRs beyond the box
standard deviation
typical distance of values from the mean
z-score
distance from the mean, in standard deviations
standardise / normalise
rescale to z-scores / squeeze into 0 to 1

Judging and recording

skewed
a few huge values stretch one side, e.g. budgets
capping (winsorizing)
pull extremes in to the fence with clip()
leverage
one value’s power to move a result
robust
barely moved by extremes: the median is, the mean is not
cleaning log
rows in, rows out and the reason, for every step
reproducible
someone else can rerun it and get the same table
This Week's Lab

Holes and a suspicious 98

A small weather frame with gaps, and a series with one value that doesn't belong. You'll count, drop, fill, fence and cap — and defend each choice in writing. ~45 minutes, in the portal’s lab page: fill each ____, press Run, then Check.

You'll practise

isna().sum(), dropna(), fillna(), quantiles and the IQR fence, z-scores, clip() for capping, duplicates, and to_numeric(errors="coerce").

Stretch, if you're quick

Median vs mean on the outlier series, and a groupby().transform() fill that beats the global mean.

Recap

Four things to carry out

Readings

Before next week

Next Week

Data Preprocessing II

Integration, transformation and reduction — merging sources, reshaping columns, and making big data small enough to think about.

DS 227 · Knowledge Discovery in Data