DS 227 · Week 6

Data Preprocessing II

Integration, transformation and reduction — many tables into one, values into useful shapes, and big into thinkable.

Knowledge Discovery in Data · University of the Philippines Cebu

Session Map

One table to rule them — then less of it

Where We Left Off

Clean — but scattered

Sales in one file, city metadata in another, last month's records in a third. Analysis needs them in one frame, at one grain (what one row stands for), in comparable units. Getting there is today.

Why data arrives scattered

Different systems, different owners, different months. Nobody designed your analysis table — you build it.

Where it can go wrong

Joins that silently drop rows, duplicates that double your totals, labels that almost match. All quiet, all deadly.

Words for Today · 1 of 2

Eight words for Parts A and B: combining and reshaping

concatenate

Join tables end to end: the rows of one go under the rows of the other.

e.g. pd.concat([jan, feb])

key

The column two tables share, used to decide which rows belong together.

e.g. city, or a project’s contract_id

merge / join

Put two tables side by side by matching their key values, like Excel’s VLOOKUP.

e.g. pd.merge(sales, meta, on="city")

inner / left / outer join

Which rows survive a merge: only matches; every row of the left table; everything.

e.g. how="left"

derived column

A new column computed from existing ones.

e.g. duration = completion date − start date

scaling

Rescaling columns so that big-number columns do not drown small-number ones.

e.g. pesos in millions next to percent

normalisation

The min–max kind of scaling: smallest value → 0, largest → 1.

e.g. (x - x.min()) / (x.max() - x.min())

encoding (one-hot)

Turning a text category into numbers: one 0/1 column per category.

e.g. status_Completed = 1 or 0

Words for Today · 2 of 2

Seven words for Part C: making it smaller

binning

Sorting numbers into ranges (bins) so you can count them as categories.

e.g. duration → “<3mo”, “3-12mo”…

aggregation

A summary that turns many values into one: sum, count, mean.

e.g. one total per region

grain

What one row stands for: a project, a region, a region-year.

e.g. 34,079 project rows vs 18 region rows

long vs wide

Long: one measurement per row. Wide: a grid with one category across the columns.

e.g. region-year rows vs a years-across table

dimensionality reduction

Keeping fewer columns while losing as little information as possible.

e.g. dropping a column that is 0 on every row

sampling

Taking a random subset of rows to look at.

e.g. df.sample(500, random_state=42)

random seed

A fixed starting number for random picks, so the same “random” rows come back every time.

e.g. random_state=42

Part A

Integration

Two moves: stack rows, or join columns on a key.

Last week you cleaned one table. This part combines several: putting tables under each other, or side by side by matching a shared column.

Move 1 · Stack

Same columns? Stack the rows

Two months of records with identical columns belong on top of each other — that's pd.concat. To concatenate is to join end to end: here, the February rows go under the January rows.

Why ignore_index=True

Without it, both frames keep their own 0, 1 labels — and a frame with duplicate index labels ambushes you later (.loc[0] returns two rows).

both = pd.concat([jan, feb], ignore_index=True) print(both) city sales 0 Cebu 10 1 Davao 22 2 Cebu 14 3 Iloilo 9
  • jan, febtwo small tables with the same two columns, city and sales (Jan: Cebu, Davao; Feb: Cebu, Iloilo)
  • [jan, feb]a list of the tables to stack, in order: January first
  • ignore_index=Truenumber the rows afresh, 0 to 3 (the row labels are the index)
  • 4 rows, 2 columns2 + 2 rows; the columns did not change
Move 2 · Join

Different facts? Join on a key

Sales knows numbers; metadata (data about the cities) knows regions. The city column is the key: the column both tables share, used to match rows. A merge (databases say join) puts the matching rows side by side.

Like Excel’s VLOOKUP: for each sales row, look up its city in the other table and copy the region across.

merged = pd.merge(sales, meta, on="city", how="left") print(merged) city sales region 0 Cebu 24 Visayas 1 Davao 22 NaN ← no match 2 Iloilo 9 Visayas
  • sales, metasales: Cebu, Davao, Iloilo and their sales; meta: a lookup table of regions for Cebu and Iloilo only
  • pd.merge(sales, meta, …)the left table first, then the right table
  • on="city"the key: match rows whose city is the same
  • how="left"keep every row of the left table, matched or not

Read the NaN

Davao simply wasn't in the metadata table. After every merge, that's the first thing to look for — unmatched keys.

Before Any Join

Check the key is a key

A key should name each row once (a primary key). A join silently multiplies rows when the key repeats. Two minutes of checking here saves an afternoon of wondering why your totals doubled.

The flood data passes

34,079 ids for 34,079 rows. That is what a real primary key looks like — and you should confirm it, not assume it.

df.contract_id.nunique(), len(df) (34079, 34079) # equal => safe to join on # and if they were not equal: (df[df.contract_id.duplicated(keep=False)] .sort_values("contract_id").shape) (0, 15)
  • .nunique()how many different values the column holds
  • (34079, 34079)as many different ids as rows: no id repeats
  • duplicated(keep=False)mark every copy of a repeated id, not just the second one
  • .shape → (0, 15)0 rows (and the usual 15 columns): no id repeats
What A Repeated Key Costs You

The row count is the warning light

Left rowsRight rowsKey repeats?Result rows
34,07918no (1 per region)34,079 — correct
34,07936yes (2 per region)68,158 — every project doubled
34,07918right missing 3 regions34,079, but 3 regions now NaN
Which Join

The four kinds, and the question each answers

The choice is not stylistic. It decides which rows survive, and therefore what population your conclusion is about.

Default to how="left" while exploring

It keeps your main table intact and makes unmatched keys visible as NaN, instead of silently deleting them.

  • regionsa small lookup table, one row per region (shown here as an idea)
  • indicator=Trueadds a column _merge saying where each row matched: both or left_only
  • m[m._merge == "left_only"]the rows that found no partner
# keep all projects, attach region info df.merge(regions, on="region", how="left") # → 34,079 rows: none lost # only projects whose region matched df.merge(regions, on="region", how="inner") # → 34,079 here, but it would silently drop # any project with an unknown region # find what did NOT match m = df.merge(regions, how="left", indicator=True) m[m._merge == "left_only"]
The how= Decision

Which rows survive the join?

left keeps all your main rows and marks gaps with NaN; inner silently deletes the non-matches. Choose by asking: can I afford to lose rows?

Two class lists, one per subject. Inner: students in both. Left: everyone on the first list, blank where the second has no mark. Outer: everyone on either list.

how=KeepsDavao's fate
leftevery left rowkept, region = NaN
inneronly matchesgone — silently
outereverything from bothkept, plus right-only rows

Habit

Print len() before and after every merge. A changed row count you can't explain is a bug in your data's story.

Integration's Side Effect

Combining sources breeds duplicates

The same record arrives from two files, and suddenly Cebu's sales count double. drop_duplicates() keeps one copy of each fully identical row. (A duplicate, from week 5: a row identical to an earlier one.)

city year sales 0 Cebu 2024 24 1 Cebu 2024 24 ← exact repeat: drop 2 Davao 2024 22 3 Cebu 2025 30 ← same city, new year: KEEP clean = df.drop_duplicates()

The subset= trap

drop_duplicates(subset=["city"]) would merge 2024 and 2025 Cebu into one row — deleting a real year of data. Match on the columns that define "the same record," not fewer.

Quick Check

Tap to reveal

You left-join 100 sales rows to a city table — and the result has 130 rows. What happened?

A · Impossible — left joins never add rows
B · Some cities appear more than once in the right table, so rows multiplied
C · pandas added 30 blank rows for safety
D · ignore_index was forgotten

B — duplicate keys multiply rows.

A left join keeps every left row at least once — but a key that matches three right-table rows comes back three times. Sum that frame and your totals are silently inflated. This is why the row-count habit exists.

Break

  Five minutes

Then: making the values themselves usable.

Part B

Transformation

The table is assembled. Now shape the values to fit the question.

You have one table. This part changes its values: the right data types, consistent labels, new columns computed from old ones, numbers on a common scale, and categories turned into numbers.

Foundations First

Numbers stored as text can't do math

Scraped and merged data loves to arrive as strings — "31", "₱1,204", "2024-01-05". Fix types before any arithmetic touches them.

df["sales"] = pd.to_numeric(df["sales"], errors="coerce") df["date"] = pd.to_datetime(df["date"]) # coerce: unparseable → NaN (week 5's territory)
  • pd.to_numeric(…, errors="coerce")text → numbers; anything unreadable becomes NaN instead of an error
  • pd.to_datetime(…)text like "2024-01-05" → real dates you can subtract and sort

And check the units

One source logs millimeters, another meters — both parse fine, and the merged column is nonsense. Unit mismatches are the outliers you met last week, wearing a disguise.

Everything Arrives As Text

A CSV has no types — only characters

Read the file without help (Python’s built-in csv reader, not pandas) and every column is a string (text), including the ones that look like money.

Why it matters immediately

Sorting text puts ₱9M above ₱80M, because "9" > "8". Your "top projects" list would be nonsense and nothing would raise an error.

# straight from csv.DictReader rows[0]["budget"] '279839557.31' <- a str rows[0]["year"] '2022' <- also a str # text sorting is alphabetical sorted(['9000000', '80000000']) ['80000000', '9000000'] # 80M sorts BEFORE 9M
  • rowsthe file read by csv.DictReader: a list with one dictionary (column name → value) per line
  • rows[0]["budget"]the first row’s budget, exactly as stored in the file
  • '279839557.31'the quote marks mean text (a str), not a number
  • sorted([…])puts values in order; text is ordered character by character, like words in a dictionary
Convert, And Watch What Fails

errors="coerce" turns a crash into a countable NaN

Forcing a type on dirty data either raises, or quietly produces nonsense. coerce gives you a third option: fail visibly, then count the failures.

Count before and after

If isna().sum() jumped after a conversion, those rows had values that were not numbers. Go look at them.

df["budget"] = pd.to_numeric( df.budget, errors="coerce") # how many refused to convert? df.budget.isna().sum() 0 # all 34,079 parsed df["start_date"] = pd.to_datetime( df.start_date, errors="coerce") df.start_date.isna().sum() 973 # the ones already blank
  • df["budget"] = …replace the column with its converted version
  • .isna().sum()count the NaNs after converting: new ones are values that refused
The Point Of A Derived Column

It answers your question — and audits the data

duration = completion_date − start_date

Derive It

Two date columns become one number

A derived column is one you compute from other columns. "How long does a flood control project take?" is not a column. You have to make it — and the making is one line once the types are right.

27,664 projects can answer

The rest are missing one of the two dates — which, from last week, you already know means they are unfinished.

df["duration"] = (df.completion_date - df.start_date).dt.days df.duration.describe() count 27,664 mean 233 50% 202 max 2,688 min -17 # (selected lines, rounded)
  • completion_date - start_datesubtracting two dates gives a length of time
  • .dt.days.dt reaches date parts; .days turns each length into a whole number of days
  • count 27,664only rows with both dates get a duration; the rest are NaN
Now Read The Minimum

A project that finished before it started

The median is a sensible 202 days. The maximum, 2,688 days, is a long project but a possible one. The minimum is −17, and that is not possible at all.

One row in 27,664

You would never have found it by eye. The derived column found it for you the moment you called .describe().

cols = ["contract_id", "status", "start_date", "completion_date", "duration", "region"] print(df.loc[df.duration < 0, cols].iloc[0]) contract_id 16DC0070 status Completed start_date 2016-05-23 00:00:00 completion_date 2016-05-06 00:00:00 duration -17.0 region Region IV-A Name: 27601, dtype: object # marked Completed, finishing # 17 days before it began
  • df.loc[df.duration < 0, cols]rows where the test is True, and only the columns named in cols
  • .iloc[0]the first (here the only) such row, printed down the page, one column per line
  • 00:00:00 / -17.0dates carry a time of day (midnight); the duration is a decimal because the column also holds NaN
Why This Matters Beyond One Row

Neither source column is wrong on its own

The Habit

Every derived column gets a sanity range

Write the check in the same cell as the calculation, while you still remember what "impossible" means for that quantity.

Three questions per derived column

Can it be negative? Is there a physical maximum? How many rows violate either?

  • assert bad == 0, "…"assert checks a claim; if it is False, the program stops with your message
  • AssertionError: 1 negative durationsread the last line of the error: the type (a failed assert) and your own message
df["duration"] = (df.completion_date - df.start_date).dt.days # sanity check, same breath bad = (df.duration < 0).sum() assert bad == 0, f"{bad} negative durations" AssertionError: 1 negative durations # now you MUST decide what to do # rather than never noticing
The Join Killer

"Cebu City" ≠ "cebu" ≠ "CEBU "

To you, one place. To merge, three unrelated keys — and three rows of NaN. Harmonize labels before integrating, not after the damage.

df["city"] = (df["city"] .str.strip() .str.lower() .replace({"cebu city": "cebu"}))
  • .str.strip()remove spaces at both ends of each text value
  • .str.lower()make every letter small
  • .replace({"cebu city": "cebu"})swap one spelling for another, using a dict: old → new

Find the near-misses

df["city"].unique() on each source, eyes on, before every join. Thirty seconds that saves the whole afternoon.

Categories

Verify the labels before you group by them

A groupby treats "Cebu" and "cebu " as two different places. The flood data happens to be clean — but you only know that because you checked.

18 regions, all consistent

No case variants, no stray whitespace. Run this on any column you are about to group on; it costs one line.

df.region.nunique() 18 # the check that would catch a mess: # normalise, then re-count df.region.str.strip().str.lower().nunique() 18 # same => already clean # if it came back 15, you would have # had 3 pairs of near-duplicate labels
Adding Meaning

Derive the column your question is about

Raw columns record what was measured. Derived columns express what you're asking — a rate, a ratio, a flag (a True/False column), an age from a date.

df["sales_per_capita"] = ( df["sales"] / df["population"]) df["is_visayas"] = ( df["region"] == "Visayas")

Why per-capita matters

Raw totals mostly measure city size. Dividing by population turns "Cebu is biggest" into a fair comparison — often reversing the ranking entirely.

Comparing Fairly

Put columns on the same scale before comparing

Pesos in the millions next to percentages in single digits: any distance or average across them is dominated by the big-number column. Scaling (rescaling each column to a common range) puts them on equal footing.

Comparing a height in centimetres with a weight in tonnes: the bigger numbers win every comparison unless you first convert both to “how big for its kind”.

Min–max (normalisation)

Normalisation squeezes each column to 0–1: (x − min) / (max − min). Simple, bounded, sensitive to outliers.

Z-score

Center at 0, spread of 1: (x − mean) / std. "How unusual is this value for its own column?"

When it matters

Any time columns are combined or compared across units — clustering and modeling later in your DS path depend on it.

Categories As Numbers

One-hot encoding: one yes/no column per category

Many methods only accept numbers. Encoding turns a text category into numbers. One-hot encoding makes one new column per category, holding 1 where the row is that category and 0 elsewhere.

A checklist with one box per option: exactly one box is ticked on each row.

Why not just number them 1, 2, 3?

Then “On-Going” (2) would count as twice “Completed” (1): an order and a distance that the categories do not have.

t = pd.DataFrame({"city": ["Cebu", "Davao", "Iloilo"], "status": ["Completed", "On-Going", "Completed"]}) print(pd.get_dummies(t, columns=["status"], dtype=int)) city status_Completed status_On-Going 0 Cebu 1 0 1 Davao 0 1 2 Iloilo 1 0 # the flood data: 5 statuses -> 5 columns print(pd.get_dummies(df.status, dtype=int).shape) (34079, 5)
  • pd.get_dummies(…)pandas’ one-hot encoder (“dummy” columns is another name)
  • columns=["status"]encode this column; city is left alone
  • dtype=intwrite 1 and 0 instead of True and False
  • (34079, 5)every project gets five yes/no columns, one per status
Part C

Reduction

Not every column — or row — earns its place.

Your table now has the right values. This part makes it smaller on purpose: fewer columns, fewer rows, or fewer distinct values.

Reduce Width

Select the columns that serve the question

Internal IDs, empty notes, audit fields — dead weight for an analysis of sales by region. A column list is the simplest reduction there is.

keep = df[["city", "region", "sales"]] # internal_id, notes: dropped

The safety net

Dropping from the working frame is reversible precisely because week 5's rule holds: the raw file still has everything. Reduce copies, never sources.

The risk, named

The column you dropped is the confounder you'll need in week 9. When unsure, keep it — columns are cheap; re-collection isn't.

Fewer Dimensions

A column that never changes tells you nothing

Each column is one dimension of the data. Dimensionality reduction means keeping fewer columns while losing as little information as possible. The simplest case: a column with one value on every row.

Week 4’s trap, again

amount_paid has a single value, 0, on all 34,079 rows. It cannot tell one project from another, so it can go from the working copy.

Further down this road

Methods such as PCA combine many related columns into a few new ones. Same idea, more maths: your ML courses.

print(df.nunique().sort_values().head(4)) amount_paid 1 status 5 year 11 category 17 dtype: int64 lean = df.drop(columns=["amount_paid"])
  • df.nunique()for every column, how many different values it holds
  • .sort_values().head(4)the four columns with the fewest different values
  • amount_paid 1one value everywhere: no information
  • df.drop(columns=[…])a new table without that column; the raw file keeps it
Stack vs Join

Concatenating adds rows; merging adds columns

Both "combine two tables". They do opposite things to the shape, and the row count tells you which one you actually did.

Check the arithmetic

After a concat, rows should be the sum of the inputs. After a merge, columns should be the sum minus the shared key.

# same columns, more rows pd.concat([y2024, y2025]) # 2024 and 2025 projects # → 5,553 + 3,475 = 9,028 rows, same 15 columns # same rows, more columns df.merge(regions, on="region", how="left") # → 34,079 rows (unchanged) # → 15 + 3 - 1 = 17 columns
Binning

Turn a number into a category you can count

Binning sorts numbers into ranges (bins). Sometimes the useful question is not "how many days" but "short, normal, or dragging". pd.cut takes the edges you choose.

  • bins = [-9999, 0, 90, …]the edges: below 0, 0–89, 90–364 days…
  • right=Falseeach bin includes its left edge, not its right: 90 days counts as 3–12 months
  • .value_counts().sort_index()count per bin, in bin order rather than biggest first

cut vs qcut

cut uses edges you pick — good when the boundaries mean something. qcut makes equal-sized groups — good when you just want quartiles.

bins = [-9999, 0, 90, 365, 730, 9999] labels = ["impossible", "<3mo", "3-12mo", "1-2yr", ">2yr"] (pd.cut(df.duration, bins, labels=labels, right=False) .value_counts().sort_index()) duration impossible 1 <3mo 3049 3-12mo 21101 1-2yr 3080 >2yr 433 Name: count, dtype: int64
The Bin Speaks

One bin has a single member, and that is the finding

BucketProjectsReading
impossible (< 0 days)1A data error — contract 16DC0070
under 3 months3,049Small works; 11% of finished projects
3–12 months21,101The norm — 76% land here
1–2 years3,080Large works
over 2 years433Worth a second look — 1.6%
Same Scale

Comparing columns that are not in the same units

Budget is in pesos, duration in days, progress in percent. Anything that compares them — distance, clustering, a combined score — needs them rescaled first.

Which one to use

Min-max when you need a fixed 0–1 range. Z-score when outliers matter and you want distance from typical. On skewed money, z-score is safer.

# min-max -> every value in [0, 1] (x - x.min()) / (x.max() - x.min()) # largest, ₱1.45B -> 1.00 # median, ₱37.7M -> 0.026 # z-score -> "how many SDs from mean" (x - x.mean()) / x.std() # largest, ₱1.45B -> +28.8 # median, ₱37.7M -> -0.19 # min-max squashes everything below # the max into a tiny band when skewed
  • xthe budget column, df.budget
  • (x - x.min()) / (x.max() - x.min())normalisation: smallest → 0, largest → 1
  • (x - x.mean()) / x.std()standardising (z-scores, week 5): the largest budget is 28.8 SDs above the mean
  • 37.7M → 0.026the median project sits near 0 on the 0–1 scale: skew squashes it
Wide and Long

The same numbers, two shapes, two purposes

Long is one row per observation — what most plotting and grouping wants. Wide is one row per subject — what a reader wants in a table.

Reshape at the end, not the start

Do the analysis long, pivot to wide only for presentation. Wide tables are hard to filter and easy to break.

  • .reset_index()turn the group labels back into ordinary columns
  • long.pivot(index=…, columns=…, values=…)regions become rows, years become columns, budgets fill the cells
  • / 1e9 … round(2)show billions of pesos with two decimals
  • wide.melt(…)the reverse: every cell of the grid becomes its own row again
# long: region, year, total — 180 rows long = (df.groupby(["region", "year"]) .budget.sum().reset_index()) # wide: regions down, years across wide = long.pivot(index="region", columns="year", values="budget") # 18 regions x 11 years = 198 cells, # but only 180 pairs exist -> 18 NaN print((wide.loc[["Region I", "Region III"], [2016, 2017]] / 1e9).round(2)) year 2016 2017 region Region I 5.08 5.90 Region III 8.00 9.97 # and back again wide.melt(ignore_index=False)
Reduce Height 1

Aggregation changes the grain

Aggregation turns many values into one summary (a sum, a count, a mean). Group by region and sum, and many city-rows become one region-row. Smaller, clearer — and the city detail is gone from that frame.

by_region = (df .groupby("region")["sales"] .sum() .reset_index()) print(by_region) region sales 0 Mindanao 22 1 Visayas 33
  • .groupby("region")sort the rows into piles, one per region
  • ["sales"].sum()add up the sales in each pile
  • .reset_index()make region an ordinary column again

Grain = unit of analysis

"One row per city" and "one row per region" answer different questions. Know which grain each frame is at — and don't mix them in one table.

Grain

Every aggregation answers a different question

Group byRows outThe question it answers
nothing34,079What did each individual project cost?
region18Which parts of the country get the money?
year11How has spending moved over time?
contractor4,841Who is doing the work?
Aggregate, Then Name It

One row per region, and say so

Give the output an explicit name. Half of all aggregation bugs are someone treating a summary table as if it were still the original.

  • .agg(projects=("contract_id", "count"), …)several summaries at once; each is new name = (column, summary)
  • len(by_region) → 18one row per region now: the grain changed

Count as well as sum

A region with a huge total and 12 projects tells a different story from one with the same total across 900.

by_region = df.groupby("region").agg( projects=("contract_id", "count"), total=("budget", "sum"), typical=("budget", "median"), ) len(by_region) 18 # grain changed by_region.sort_values("total", ascending=False).head(3) Region III 5412 267.7B NCR 3925 159.2B Region V 2814 155.6B # (tidied: NCR = National Capital Region; # totals shown in billions)
Sampling

A peek you can reproduce tomorrow

Sampling means taking a random subset of rows. Sampling is for looking, not for concluding. The one thing that makes it defensible is the seed.

Tasting a spoonful of soup to judge the pot. Stir first (random), and note which spoon you used (the seed) so someone else can taste the same spoonful.

Without random_state it is not reproducible

You would report a number nobody — including future you — can obtain again.

df.sample(500, random_state=42) # → 500 rows, the SAME 500 # every time you run it # the same number from every region df.groupby("region").sample(n=50, random_state=42) # → 18 regions x 50 = 900 rows
  • random_state=42the seed: a fixed starting number for the random picks, so the same rows come back
  • .groupby("region").sample(n=50, …)take 50 random rows from each region’s pile (the smallest region has 180 projects, so all can give 50)
Your Turn · 8 min

Derive a column and let it find a bug

1 · Convert properly

Load the flood CSV. Convert budget with pd.to_numeric and both date columns with pd.to_datetime, all with errors="coerce".

2 · Count the failures

How many became NaN in each conversion? Does the number match what week 5 told you about missing dates?

3 · Derive and check

Make duration. Run .describe(). Find the negative one yourself.

4 · Make a second derived column

Try cost_per_day = budget / duration. What goes wrong, and which rows cause it?

Quick Check

Tap to reveal

You join 34,079 projects to a region table and get 68,158 rows back. What almost certainly happened?

A · The join worked; merge always returns both tables’ rows
B · The region table has 2 rows per region, so every project matched twice
C · You used how="left" instead of how="inner"
D · pandas duplicated the rows to align the indexes
B. A repeated key on the right multiplies matching rows on the left. Every budget is now counted twice and every total downstream is double. Check with regions.region.duplicated().any() before joining, and compare len(df) before and after.
Quick Check

Tap to reveal

Why did computing duration reveal an error that checking start_date and completion_date separately did not?

A · The date columns had invalid formats that only parsing exposes
B · The error is in the relationship between the columns, not in either value
C · Subtraction repairs corrupted dates
D · describe() checks more thoroughly than isna()
B. 2016-05-23 and 2016-05-06 are both perfectly valid dates. Only together do they say a project finished before it started. A derived column is a free consistency test — which is why you check its range the moment you make it.
Reduce Height 2

Sampling: a reproducible peek at the big

A million rows won't fit in your head. A random sample shows the texture — and random_state makes it the same sample every run.

df.sample(n=5, random_state=0) # same 5 rows, every time, on every machine

Why fix the seed

Your classmate reruns the notebook and sees your exact rows; your claims about them can be checked. Unseeded randomness makes findings that vanish on rerun.

Further down this road

Techniques like PCA reduce dimensions mathematically — a story for your ML courses. The idea is the same: keep the signal, shed the bulk.

Quick Check

Tap to reveal

You aggregated to one row per region and deleted the city-level frame to save space. Your professor asks: "Which city drove the Visayas total?" Can you answer?

A · Yes — divide the total by the number of cities
B · Yes — pandas remembers the original rows
C · No — aggregation is one-way; the detail is gone from that frame
D · Yes, if you sampled first

C — you summed away the answer.

A total of 33 can't tell you it was 24+9. Aggregation destroys within-group detail — which is fine, if the detailed frame still exists. Reduce the working copy; keep the fine-grained one.

Zooming Out

Weeks 3–6 were one pipeline

1

Acquire

Scrape or call an API — weeks 3–4.

2

Clean

Gaps and outliers, decided on purpose — week 5.

3

Integrate & transform

Merge, harmonize, derive — today.

4

Reduce

The frame that fits the question — today.

Glossary Recap

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

Combining tables

concatenate
stack tables end to end: pd.concat
index
the row labels down the left; ignore_index renumbers them
key
the shared column used to match rows
primary key
a key that names each row exactly once
merge / join
side by side by matching keys (VLOOKUP)
inner / left / outer join
matches only / every left row / everything
duplicate
a row identical to an earlier one
fan-out
a repeated key multiplying rows in a merge

Transforming values

data type
what a column holds: number, text, date
derived column
a column computed from other columns
assert
check a claim; stop with a message if it is False
harmonise labels
make spellings identical before grouping or joining
scaling
rescale columns to a comparable range
normalisation
min–max scaling to 0–1
standardising
z-scores: mean 0, standard deviation 1
encoding (one-hot)
one 0/1 column per category
Glossary Recap

Every new word, one line each · Part C

Making it smaller

binning
numbers sorted into ranges you choose: pd.cut
aggregation
many values → one summary: sum, count, mean
grain
what one row stands for
long vs wide
one measurement per row vs a grid
pivot / melt
long → wide / wide → long

Fewer columns, fewer rows

dimensionality reduction
fewer columns, as little information lost as possible
dimension
one column (feature) of the data
sampling
a random subset of rows, for looking
random seed
fixed start for random picks: random_state=42
reproducible
anyone can rerun it and get the same result
This Week's Lab

Assemble, dedupe, reduce

Two months of sales to stack, a metadata table to join (watch Davao's NaN), duplicates to drop without losing 2025, and a frame to slim down. ~45 minutes, in the portal’s lab page: fill each ____, press Run, then Check.

You'll practise

pd.concat with ignore_index, pd.merge with how=, drop_duplicates and its subset= trap, and column selection.

Stretch, if you're quick

Aggregate cities into regions with groupby, and take a seeded sample — then say why the seed matters.

Recap

Four things to carry out

Readings

Before next week

Next Week

Data Exploration I

Descriptive statistics and univariate EDA — your analysis-ready table starts answering questions, one variable at a time.

DS 227 · Knowledge Discovery in Data