dict_keys(['campaign_descriptions', 'coupons', 'promotions', 'campaigns', 'demographics', 'transactions', 'coupon_redemptions', 'products'])
Week 4: Data Wrangling in Python
Today’s plan:
Share with the Class
Open floor for questions about:
.loc[]Stuck on a homework problem?
Let me know which one — we can work through it together in Thursday’s lab.
One year of real grocery purchases from 2,000+ households, spread across several related tables.
dict_keys(['campaign_descriptions', 'coupons', 'promotions', 'campaigns', 'demographics', 'transactions', 'coupon_redemptions', 'products'])
In your group:
Example questions:
Real data rarely arrives ready for analysis. Import the raw Ames housing data in your notebook:
Spend a couple of minutes looking through it with your Week 3 tools — .head(), .columns, .info(), .describe().
What would you want to change before you start an analysis?
Note
Datasets can be messy in many different ways. Today we’ll illustrate four common data issues you’ll often need to address.
MS SubClass, SalePrice, Year Remod/AddOrder, PIDNaN| Order | PID | MS SubClass | MS Zoning | Lot Frontage | Lot Area | Street | Alley | Lot Shape | Land Contour | ... | Pool Area | Pool QC | Fence | Misc Feature | Misc Val | Mo Sold | Yr Sold | Sale Type | Sale Condition | SalePrice | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 526301100 | 20 | RL | 141.0 | 31770 | Pave | NaN | IR1 | Lvl | ... | 0 | NaN | NaN | NaN | 0 | 5 | 2010 | WD | Normal | 215000 |
| 1 | 2 | 526350040 | 20 | RH | 80.0 | 11622 | Pave | NaN | Reg | Lvl | ... | 0 | NaN | MnPrv | NaN | 0 | 6 | 2010 | WD | Normal | 105000 |
| 2 | 3 | 526351010 | 20 | RL | 81.0 | 14267 | Pave | NaN | IR1 | Lvl | ... | 0 | NaN | NaN | Gar2 | 12500 | 6 | 2010 | WD | Normal | 172000 |
| 3 | 4 | 526353030 | 20 | RL | 93.0 | 11160 | Pave | NaN | Reg | Lvl | ... | 0 | NaN | NaN | NaN | 0 | 4 | 2010 | WD | Normal | 244000 |
| 4 | 5 | 527105010 | 60 | RL | 74.0 | 13830 | Pave | NaN | IR1 | Lvl | ... | 0 | NaN | MnPrv | NaN | 0 | 3 | 2010 | WD | Normal | 189900 |
5 rows × 82 columns
A few columns → rename() with an {old: new} dict
Assign it back — or nothing changes
pandas methods return a new result; they don’t change ames on their own. Running ames.rename(...) by itself prints a renamed copy and throws it away. That’s why every example today starts with ames = ... or ames.columns = ....
Last week you selected the columns you wanted. Sometimes it’s easier to drop the ones you don’t.
Keep columns of interest
Tip
Rule of thumb: keeping a few columns → select. Removing a few columns → drop.
Build a new metric from existing columns
Translate values with .map() and a dict
Note
The pattern is always the same: df['new_col'] = <something computed from other columns>.
Step 1 — Find it
pool_qc 2917
misc_feature 2824
alley 2732
fence 2358
mas_vnr_type 1775
dtype: int64
Step 2 — Ask why. 2,917 homes are missing pool_qc… and 2,917 homes have a pool_area of 0.
Important
Missing isn’t always an error. Here NaN means “there’s no pool” — so the right fix is a label, not a deleted row or a made-up average. Understand why data is missing before you decide how to handle it.
Back to Complete Journey. In your notebook:
products, create a clean_category column: product_category in lowercase with spaces replaced by underscores.
"unknown".transactions, create a unit_price column: sales_value divided by quantity.transactions by unit_price, highest first. What looks strange?1 — Chain .str methods on a column, then assign the result to a new column:
| product_category | clean_category | |
|---|---|---|
| 0 | FRZN ICE | frzn_ice |
| 1 | None | None |
| 2 | BREAD | bread |
Optional — fill the missing categories. .fillna() is just one more link in the chain:
Missing categories filled: 540
| product_category | clean_category | |
|---|---|---|
| 0 | FRZN ICE | frzn_ice |
| 1 | None | unknown |
| 2 | BREAD | bread |
2 & 3 — Compute, then sort:
| product_id | sales_value | quantity | unit_price | |
|---|---|---|---|---|
| 255710 | 1102453 | 1.000000e-02 | 0 | inf |
| 1222646 | 836043 | 8.881784e-16 | 0 | inf |
| 434804 | 878234 | 1.000000e-02 | 0 | inf |
Warning
inf? Some transactions have a quantity of 0, and dividing by zero gives infinity. The code ran fine — the data surprised us. Always sanity-check a new column before you build an analysis on it.
transactions has 1.4 million rows. Nobody wants to read them — they want answers:
Note
Most business questions are aggregate questions — they collapse many rows into a few numbers, often one number per group.
One statistic, one column
Tip
.agg() takes a dict of {column: statistic(s)}. Common statistics: 'sum', 'mean', 'median', 'min', 'max', 'count', 'nunique'.
Grouped aggregation always follows the same three steps: split the rows into groups, apply a summary to each group, combine the results into a new DataFrame.
Business Question: Who are our top 5 households by total spend — and how many shopping trips did it take them?
| household_id | sales_value | n_baskets | |
|---|---|---|---|
| 1015 | 1023 | 24879.75 | 375 |
| 1593 | 1609 | 16581.36 | 245 |
| 1440 | 1453 | 13376.23 | 502 |
| 2294 | 2322 | 13187.54 | 178 |
| 1417 | 1430 | 12849.31 | 202 |
Tip
'nunique' counts distinct baskets — a basket with 20 items is still one trip..rename() it to say what it holds..groupby(['household_id', 'store_id']).as_index=False keeps the group labels as a normal column instead of the index.Using transactions:
product_id) by total sales_value.1 — Top 10 products
| product_id | sales_value | |
|---|---|---|
| 42369 | 6534178 | 303116.02 |
| 42340 | 6533889 | 27467.61 |
| 23098 | 1029743 | 22729.71 |
| 42365 | 6534166 | 20477.54 |
| 42333 | 6533765 | 19451.66 |
| 27971 | 1082185 | 17219.59 |
| 12526 | 916122 | 16120.01 |
| 30193 | 1106523 | 15629.95 |
| 19879 | 995242 | 15602.59 |
| 39480 | 5569230 | 13410.46 |
2 — Store performance
| store_id | sales_value | n_baskets | |
|---|---|---|---|
| 103 | 367 | 148169.67 | 4194 |
| 124 | 406 | 120904.39 | 3037 |
| 135 | 429 | 86883.92 | 2323 |
| 97 | 361 | 81196.08 | 2850 |
| 87 | 343 | 80700.57 | 2929 |
Important
3 — Product 6534178 brings in about 10× more than any other product. So… what is it?
transactions can’t tell us — it only stores the ID. The product details live in a different table.
Organizations store data in separate tables — it’s more efficient, and different teams collect different pieces.
To answer a question that spans tables, we join them on a key: a column that appears in both.
product_id → transactions + productshousehold_id → transactions + demographicscoupon_upc → coupons + coupon_redemptionsWarning
A key needs the same meaning and the same data type in both tables — otherwise rows fail to match, or match wrong.
The join type decides what happens to rows that don’t find a match.
Inner — only rows with a match in both tables
Right — all rows from the right table, matches from the left
Left — all rows from the left table, matches from the right
Outer — all rows from both tables
.merge()Tip
pd.merge(left_df, right_df, on='key_column', how='inner') does the same thing — you’ll see both.
Mystery solved: summarize first, then join on the product details.
| product_id | sales_value | department | product_type | |
|---|---|---|---|---|
| 0 | 6534178 | 303116.02 | FUEL | GASOLINE-REG UNLEADED |
| 1 | 6533889 | 27467.61 | MISCELLANEOUS | GASOLINE-REG UNLEADED |
| 2 | 1029743 | 22729.71 | GROCERY | FLUID MILK WHITE ONLY |
| 3 | 6534166 | 20477.54 | MISCELLANEOUS | GASOLINE-REG UNLEADED |
| 4 | 6533765 | 19451.66 | FUEL | GASOLINE-REG UNLEADED |
Important
Four of our top five “products” are gasoline — the kiosk at the store. Our real grocery best seller is a gallon of milk. A join turned an ID into an insight.
transactions has 1,469,307 rows from 2,469 households.demographics has one row for each of 801 households.If we join them on household_id, how many rows come back?
transactions on the left)?Write down your guesses — then run both in your notebook.
demographics = cj_data['demographics']
inner = transactions.merge(demographics, on='household_id', how='inner')
left = transactions.merge(demographics, on='household_id', how='left')
print(f"transactions: {len(transactions):,} rows")
print(f"inner join: {len(inner):,} rows")
print(f"left join: {len(left):,} rows ({left['age'].isnull().sum():,} with no demographics)")transactions: 1,469,307 rows
inner join: 828,850 rows
left join: 1,469,307 rows (640,457 with no demographics)
Important
Only 801 of 2,469 households filled out a demographic survey.
NaN.Neither one is wrong — but you have to choose, and know what you’ve chosen, before you summarize.
Business Question: Our marketing team wants to know which age group spends the most.
transactions and demographics. Which join type makes sense here?age group, compute total sales and the number of unique households.spend_per_household column. Does the same age group still come out on top?Join → group → aggregate — then use last hour’s skill to build a new column:
age_spend = (
transactions
.merge(demographics, on='household_id', how='inner')
.groupby('age', as_index=False)
.agg({'sales_value': 'sum', 'household_id': 'nunique'})
.rename(columns={'household_id': 'n_households'})
)
age_spend['spend_per_household'] = age_spend['sales_value'] / age_spend['n_households']
age_spend.sort_values('spend_per_household', ascending=False)| age | sales_value | n_households | spend_per_household | |
|---|---|---|---|---|
| 2 | 35-44 | 724357.40 | 194 | 3733.801031 |
| 3 | 45-54 | 971822.19 | 288 | 3374.382604 |
| 1 | 25-34 | 453372.46 | 142 | 3192.763803 |
| 4 | 55-64 | 173153.70 | 59 | 2934.808475 |
| 0 | 19-24 | 125673.05 | 46 | 2732.022826 |
| 5 | 65+ | 176600.84 | 72 | 2452.789444 |
Important
45–54 spends the most in total — but they’re also the biggest group. Per household, 35–44 comes out on top.
Total vs. per-household is exactly the kind of decision that changes a marketing recommendation. Every step today — manipulate, summarize, join — played a part in getting there.
| Code | What it does |
|---|---|
df = df.rename(columns={'old': 'new'}) |
Rename specific columns |
df.columns = df.columns.str.lower().str.replace(' ', '_') |
Clean every column name at once |
df = df.drop(columns=['a', 'b']) |
Remove columns you don’t need |
df['new'] = df['a'] / df['b'] |
Create a new column from existing ones |
df['col'].map({old: new}) |
Translate values using a dict |
df.isnull().sum() |
Count missing values in each column |
df['col'] = df['col'].fillna(value) |
Fill missing values — once you know why they’re missing |
df.agg({'col': ['sum', 'mean']}) |
Several statistics across the whole table |
df.groupby('grp', as_index=False).agg({...}) |
One row of statistics per group |
'nunique' |
Count distinct values (baskets, households) |
df.sort_values('col', ascending=False) · df.nlargest(n, 'col') |
Rank your results |
left.merge(right, on='key', how='inner') |
Join tables — how decides what happens to unmatched rows |
Important
The ideas underneath all of it:
This Week’s Lab: RetailMax Analytics
You’ll play a junior analyst answering questions from leadership with the Complete Journey data — manipulating, summarizing, and joining to find the answers.
Finish the Week 4 readings before Thursday so you can hit the ground running!
Any final questions?
BANA 4080 | Week 4