Welcome to Week 4

  • Quick overview of today’s plan:

    • Discuss last week’s homework & content
    • Load & explore the Complete Journey datasets
    • Brainstorm analysis questions
    • Manipulating Data (15–20 min)
    • Summarizing Data (15–20 min)
    • Joining Data (15–20 min)

Discussion: Homework & Questions

Questions from Week 3?

  • DataFrames vs. Series?
  • Subsetting procedures?
  • Anything confusing in the quiz or class lab?
  • Time to ask!

Complete Journey Data

Intro Activity – Load the Data

Follow along

Open up Colab and load the Complete Journey data: tinyurl.com/bana4080-wk4-lecture


# you may need to pip install first
# !pip install completejourney-py

from completejourney_py import get_data

# Load all datasets
cj_data = get_data()
cj_data.keys()
dict_keys(['campaign_descriptions', 'coupons', 'promotions', 'campaigns', 'demographics', 'transactions', 'coupon_redemptions', 'products'])

Note

Complete Journey Docs: bit.ly/completejourney_py

Small Group Brainstorm

In your group, come up with 2–3 questions you’d like to answer using these datasets. Think about business insights a grocery retailer might want.

Example questions:

  • What income level is buying the most?
  • Do families with kids spend more than families without kids?
  • Which department and product is the most commonly purchased?
  • Which coupon was used the most?

Then we’ll take a few responses…

From Questions to Analysis

A lot of the insights you’ve just brainstormed will require:

  • Manipulating & wrangling data to prepare it for analysis
    • Cleaning up column names for clarity
    • Creating new columns based on existing ones
  • Aggregating data
    • Summarizing at different levels (by product, by customer, by time)
    • Computing summary statistics
  • Joining datasets
    • Combining related tables to get a complete picture


💡 And that’s exactly what we’re going to cover this week!

Manipulating Data

Why Is This Important?

  • Data rarely comes perfectly ready for analysis.
  • Column names might be unclear or inconsistent.
  • Some columns might not be needed at all.
  • You might need to create new columns from existing ones.
  • Clean, well-structured data = easier, faster, and more accurate analysis.

Messy Data

Our complete journey data is not too bad; however, let’s look at this raw Ames data.

What do you notice?

import pandas as pd

ames = pd.read_csv("../data/ames_raw.csv")
ames.head()
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

Examples from Our Data


  • Columns differ (Order, MS SubClass, SalePrice) - might want to standardize.
  • Do we really need all these columns (Order, PID) - might want to drop irrelevant ones.
  • Could we add new columns (price_per_sqft)
  • We’ve got missing values - (houses without pools or misc features)

Renaming Columns

We can easily rename specific columns

  • Use rename to rename certain columns
  • Feed it dict with {old: new} pairings
  • Note the use of inplace=True

Caution

Great, but we have 82 columns!

ames.rename(columns={
    'MS SubClass': 'ms_subclass',
    'MS Zoning': 'ms_zoning'
    }, inplace=True)

ames.head()
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

Reformatting Many Columns

😳

(
  ames.columns
  .str.lower()               # convert to lowercase
  .str.replace(' ', '_')     # replace spaces with underscores
  .str.replace('-', '_')     # replace hyphens with underscores
  .str.strip()               # strip out extra leading/ending spaces
)
Index(['order', 'pid', 'ms_subclass', 'ms_zoning', 'lot_frontage', 'lot_area',
       'street', 'alley', 'lot_shape', 'land_contour', 'utilities',
       'lot_config', 'land_slope', 'neighborhood', 'condition_1',
       'condition_2', 'bldg_type', 'house_style', 'overall_qual',
       'overall_cond', 'year_built', 'year_remod/add', 'roof_style',
       'roof_matl', 'exterior_1st', 'exterior_2nd', 'mas_vnr_type',
       'mas_vnr_area', 'exter_qual', 'exter_cond', 'foundation', 'bsmt_qual',
       'bsmt_cond', 'bsmt_exposure', 'bsmtfin_type_1', 'bsmtfin_sf_1',
       'bsmtfin_type_2', 'bsmtfin_sf_2', 'bsmt_unf_sf', 'total_bsmt_sf',
       'heating', 'heating_qc', 'central_air', 'electrical', '1st_flr_sf',
       '2nd_flr_sf', 'low_qual_fin_sf', 'gr_liv_area', 'bsmt_full_bath',
       'bsmt_half_bath', 'full_bath', 'half_bath', 'bedroom_abvgr',
       'kitchen_abvgr', 'kitchen_qual', 'totrms_abvgrd', 'functional',
       'fireplaces', 'fireplace_qu', 'garage_type', 'garage_yr_blt',
       'garage_finish', 'garage_cars', 'garage_area', 'garage_qual',
       'garage_cond', 'paved_drive', 'wood_deck_sf', 'open_porch_sf',
       'enclosed_porch', '3ssn_porch', 'screen_porch', 'pool_area', 'pool_qc',
       'fence', 'misc_feature', 'misc_val', 'mo_sold', 'yr_sold', 'sale_type',
       'sale_condition', 'saleprice'],
      dtype='object')

Reformatting Many Columns

😎

ames.columns = (
  ames.columns.
  str.lower()               # convert to lowercase
  .str.replace(' ', '_')    # replace spaces with underscores
  .str.replace('-', '_')    # replace hyphens with underscores
  .str.strip()              # strip out extra leading/ending spaces
)

ames.head()
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

Dropping Columns

Last week we learned how to select columns of interest.

But we may also want to invert that thinking and just drop columns of disinterest!


Selecting columns of interest

cols = ['gr_liv_area', 'saleprice', 'overall_qual']

ames[cols].head()
gr_liv_area saleprice overall_qual
0 1656 215000 6
1 896 105000 5
2 1329 172000 6
3 2110 244000 7
4 1629 189900 5

Dropping columns of disinterest

cols = ['order', 'pid', 'ms_subclass']

ames.drop(columns=cols, inplace=True)
ames.head()
ms_zoning lot_frontage lot_area street alley lot_shape land_contour utilities lot_config land_slope ... pool_area pool_qc fence misc_feature misc_val mo_sold yr_sold sale_type sale_condition saleprice
0 RL 141.0 31770 Pave NaN IR1 Lvl AllPub Corner Gtl ... 0 NaN NaN NaN 0 5 2010 WD Normal 215000
1 RH 80.0 11622 Pave NaN Reg Lvl AllPub Inside Gtl ... 0 NaN MnPrv NaN 0 6 2010 WD Normal 105000
2 RL 81.0 14267 Pave NaN IR1 Lvl AllPub Corner Gtl ... 0 NaN NaN Gar2 12500 6 2010 WD Normal 172000
3 RL 93.0 11160 Pave NaN Reg Lvl AllPub Corner Gtl ... 0 NaN NaN NaN 0 4 2010 WD Normal 244000
4 RL 74.0 13830 Pave NaN IR1 Lvl AllPub Inside Gtl ... 0 NaN MnPrv NaN 0 3 2010 WD Normal 189900

5 rows × 79 columns

Adding/Modifying New Columns

Why we might do this:

  • To create new metrics that don’t exist in the raw data (e.g., price_per_sqft, unit_price).
  • To transform existing data into a more useful format (e.g., converting grams to pounds).
  • To transform values (e.g., months 1, 2, 3"Jan", "Feb", "Mar").

Adding/Modifying New Columns

Why we might do this:

  • To create new metrics that don’t exist in the raw data (e.g., price_per_sqft, unit_price).
  • To transform existing data into a more useful format (e.g., converting grams to pounds).
  • To transform values (e.g., months 1, 2, 3"Jan", "Feb", "Mar").
# Create a price per sqft column
ames['price_per_sqft'] = ames['saleprice'] / ames['gr_liv_area']

ames.head()
ms_zoning lot_frontage lot_area street alley lot_shape land_contour utilities lot_config land_slope ... pool_qc fence misc_feature misc_val mo_sold yr_sold sale_type sale_condition saleprice price_per_sqft
0 RL 141.0 31770 Pave NaN IR1 Lvl AllPub Corner Gtl ... NaN NaN NaN 0 5 2010 WD Normal 215000 129.830918
1 RH 80.0 11622 Pave NaN Reg Lvl AllPub Inside Gtl ... NaN MnPrv NaN 0 6 2010 WD Normal 105000 117.187500
2 RL 81.0 14267 Pave NaN IR1 Lvl AllPub Corner Gtl ... NaN NaN Gar2 12500 6 2010 WD Normal 172000 129.420617
3 RL 93.0 11160 Pave NaN Reg Lvl AllPub Corner Gtl ... NaN NaN NaN 0 4 2010 WD Normal 244000 115.639810
4 RL 74.0 13830 Pave NaN IR1 Lvl AllPub Inside Gtl ... NaN MnPrv NaN 0 3 2010 WD Normal 189900 116.574586

5 rows × 80 columns

Adding/Modifying New Columns

Why we might do this:

  • To create new metrics that don’t exist in the raw data (e.g., price_per_sqft, unit_price).
  • To transform existing data into a more useful format (e.g., converting grams to pounds).
  • To transform values (e.g., months 1, 2, 3"Jan", "Feb", "Mar").
# dict containing mapping of {old values: new values}
months = {
    1: 'Jan', 2: 'Feb', 3: 'Mar', 4: 'Apr', 5: 'May', 6: 'Jun', 
    7: 'Jul', 8: 'Aug', 9: 'Sep', 10: 'Oct', 11: 'Nov', 12: 'Dec'
}

# use map function to apply changes to a column
ames['mo_sold'] = ames['mo_sold'].map(months)
ames.head()
ms_zoning lot_frontage lot_area street alley lot_shape land_contour utilities lot_config land_slope ... pool_qc fence misc_feature misc_val mo_sold yr_sold sale_type sale_condition saleprice price_per_sqft
0 RL 141.0 31770 Pave NaN IR1 Lvl AllPub Corner Gtl ... NaN NaN NaN 0 May 2010 WD Normal 215000 129.830918
1 RH 80.0 11622 Pave NaN Reg Lvl AllPub Inside Gtl ... NaN MnPrv NaN 0 Jun 2010 WD Normal 105000 117.187500
2 RL 81.0 14267 Pave NaN IR1 Lvl AllPub Corner Gtl ... NaN NaN Gar2 12500 Jun 2010 WD Normal 172000 129.420617
3 RL 93.0 11160 Pave NaN Reg Lvl AllPub Corner Gtl ... NaN NaN NaN 0 Apr 2010 WD Normal 244000 115.639810
4 RL 74.0 13830 Pave NaN IR1 Lvl AllPub Inside Gtl ... NaN MnPrv NaN 0 Mar 2010 WD Normal 189900 116.574586

5 rows × 80 columns

Handling Missing Values

We can always check for missing values with isnull().

ames.isnull().sum().sort_values(ascending=False)
pool_qc           2917
misc_feature      2824
alley             2732
fence             2358
mas_vnr_type      1775
                  ... 
yr_sold              0
sale_type            0
sale_condition       0
saleprice            0
price_per_sqft       0
Length: 80, dtype: int64
  • Understanding why data is missing helps choose the right strategy to handle it.
  • Different causes of missingness require different approaches.

Warning

Why are there so many pool quality (pool_qc) values missing?

Handling Missing Values

ames[['pool_area', 'pool_qc']].head()
pool_area pool_qc
0 0 NaN
1 0 NaN
2 0 NaN
3 0 NaN
4 0 NaN


ames['pool_qc'].value_counts()
pool_qc
Ex    4
Gd    4
TA    3
Fa    2
Name: count, dtype: int64
ames['pool_qc'].fillna('no pool', inplace=True)
ames[['pool_area', 'pool_qc']].head()
pool_area pool_qc
0 0 no pool
1 0 no pool
2 0 no pool
3 0 no pool
4 0 no pool


ames[['pool_qc']].value_counts()
pool_qc
no pool    2917
Ex            4
Gd            4
TA            3
Fa            2
Name: count, dtype: int64

🧑‍💻 Code With Me: Clean Product Names

Business Question: Our marketing team wants cleaner product category names for their dashboard. Can we standardize the Complete Journey product categories?

# Load the products data
products = cj_data["products"]
products['product_category'].value_counts()
product_category
GREETING CARDS/WRAP/PARTY SPLY    2785
CANDY - PACKAGED                  2475
MAKEUP AND TREATMENT              2467
HAIR CARE PRODUCTS                1744
SOFT DRINKS                       1704
                                  ... 
BOUQUET (NON ROSE)                   1
MISCELLANEOUS CROUTONS               1
EASTER LILY                          1
PKG.SEAFOOD MISC                     1
FROZEN PACKAGE MEAT                  1
Name: count, Length: 303, dtype: int64

Your turn: Help me clean these category names by:

  1. Converting to lowercase
  2. Replacing spaces/hyphens with underscores
  3. Creating a new column called clean_category
# Fill in the blanks together!
products['clean_category'] = (
    products['product_category']
    .str.______()                    # convert to lowercase
    .str.replace(' ', '_')           # replace spaces
    .str.replace(__, __)             # replace hyphens
)

products[['product_category', 'clean_category']].head()

🧑‍💻 Code With Me: Create Business Metrics

Business Question: Our analytics team wants to calculate unit prices to identify premium vs budget products.

# Let's look at our transaction data
transactions = cj_data["transactions"]
transactions[['sales_value', 'quantity']].head()
sales_value quantity
0 0.50 1
1 0.99 1
2 1.43 1
3 1.50 1
4 2.78 2

Your turn: Help me create a unit_price column:

# Fill in the blanks together!
transactions['unit_price'] = transactions['_______'] / transactions['_______']

# Let's see which products have the highest unit prices
transactions[['product_id', 'sales_value', 'quantity', 'unit_price']].head()

Summarizing Data

Why Is This Important?

  • Raw data often contains many individual records that need to be condensed for analysis.
  • Summaries reveal patterns and trends that are hard to spot in row-level data.
  • Many business questions are aggregate in nature:
    • Total sales by product category
    • Average spend per customer
    • Most frequent coupon usage

Simple Aggregation

Last week we saw how we can compute various summary stats for a given column:

avg_price = ames['saleprice'].mean()
print(f"Avg Sale Price: ${avg_price:,.2f}")
Avg Sale Price: $180,796.06


min_ppsqft = ames['price_per_sqft'].min()
max_ppsqft = ames['price_per_sqft'].max()
print(f"Min & Max Price per Sqft: ${min_ppsqft:,.2f} - ${max_ppsqft:,.2f}")
Min & Max Price per Sqft: $15.37 - $276.25


We can even get summary stats for multiple columns:

cols = ['gr_liv_area', 'saleprice', 'price_per_sqft']
ames[cols].mean()
gr_liv_area         1499.690444
saleprice         180796.060068
price_per_sqft       121.303619
dtype: float64

Multiple Aggregations

But, when we want to get more complicated and get:

  • Multiple stats for different columns and…
  • Multiple types of stats per column

Then we should start using .aggregate() / .agg()

ames.aggregate({
    'saleprice': ['mean', 'median'],
    'price_per_sqft': ['mean', 'min', 'max']
})
saleprice price_per_sqft
mean 180796.060068 121.303619
median 160000.000000 NaN
min NaN 15.371394
max NaN 276.250881
ames.agg({
    'saleprice': ['mean', 'median'],
    'price_per_sqft': ['mean', 'min', 'max']
})
saleprice price_per_sqft
mean 180796.060068 121.303619
median 160000.000000 NaN
min NaN 15.371394
max NaN 276.250881

Group-level Aggregations

That’s great and all but in many real-world analyses, we’re interested in summarizing within groups rather than across the whole dataset.

  • Total home sales by neighborhood
  • Average square footage by number of bedrooms
  • Median sale price by year
  • Maximum temperature by month

The Groupby Model

Grouped aggregation in Pandas always follows the same three-step process:

  1. Group the data using groupby()
  2. Apply a summary method like .sum(), .agg(), or .describe()
  3. Return a DataFrame of group-level summaries

Avg Sale Price By Neighborhood

(
  ames.
  groupby('neighborhood', as_index=False).
  agg({'saleprice': ['mean', 'median']})
)
neighborhood saleprice
mean median
0 Blmngtn 196661.678571 191500.0
1 Blueste 143590.000000 130500.0
2 BrDale 105608.333333 106000.0
3 BrkSide 124756.250000 126750.0
4 ClearCr 208662.090909 197500.0
5 CollgCr 201803.434457 200000.0
6 Crawfor 207550.834951 200624.0
7 Edwards 130843.381443 125000.0
8 Gilbert 190646.575758 183000.0
9 Greens 193531.250000 198000.0
10 GrnHill 280000.000000 280000.0
11 IDOTRR 103752.903226 106500.0
12 Landmrk 137000.000000 137000.0
13 MeadowV 95756.486486 88250.0
14 Mitchel 162226.631579 153500.0
15 NAmes 145097.349887 140000.0
16 NPkVill 140710.869565 143750.0
17 NWAmes 188406.908397 181000.0
18 NoRidge 330319.126761 302000.0
19 NridgHt 322018.265060 317750.0
20 OldTown 123991.891213 119900.0
21 SWISU 135071.937500 136200.0
22 Sawyer 136751.152318 135000.0
23 SawyerW 184070.184000 180000.0
24 Somerst 229707.324176 225500.0
25 StoneBr 324229.196078 319000.0
26 Timber 246599.541667 232106.5
27 Veenker 248314.583333 250250.0

Group-by Multiple Variables

(
  ames.
  groupby(['neighborhood', 'mo_sold'], as_index=False).
  agg({'saleprice': 'mean'})
)
neighborhood mo_sold saleprice
0 Blmngtn Apr 202745.000000
1 Blmngtn Aug 186828.333333
2 Blmngtn Feb 194201.000000
3 Blmngtn Jan 160000.000000
4 Blmngtn Jun 167773.333333
... ... ... ...
281 Veenker Jul 234666.666667
282 Veenker Jun 205308.333333
283 Veenker Mar 314000.000000
284 Veenker May 268800.000000
285 Veenker Nov 385000.000000

286 rows × 3 columns

Get Familiar!

Important

We can answer so many typical business questions with just this skillset!

🧑‍💻 Code With Me: Top Revenue Products

Business Question: Which products generate the most revenue? Our merchandising team needs this for inventory planning.

# Let's look at our transaction data
transactions = cj_data["transactions"]
transactions[['product_id', 'sales_value', 'quantity']].head()
product_id sales_value quantity
0 1095275 0.50 1
1 9878513 0.99 1
2 1041453 1.43 1
3 1020156 1.50 1
4 1053875 2.78 2

Your turn: Help me find the top revenue-generating products:

# Fill in the blanks together!
product_revenue = (
    transactions
    .groupby('_______', as_index=False)
    .agg({'sales_value': '_______'})
    .sort_values('sales_value', ascending=False)
)

product_revenue.head(10)

🧑‍💻 Code With Me: Store Performance

Business Question: Which stores are performing best? Our operations team wants to understand store-level performance.

# Look at store information in our transactions
transactions['store_id'].value_counts().head()
store_id
367    41334
406    32602
356    27910
292    26692
381    24398
Name: count, dtype: int64

Your turn: Help me compare total sales and transaction counts by store:

# Fill in the blanks together!
store_performance = (
    transactions
    .groupby('_______', as_index=False)
    .agg({
        'sales_value': ['_______', '_______'],  # sum, mean
        'basket_id': '_______'                  # count (for # of transactions)
    })
)

store_performance.head()

Joining Data

Complete Journey

Think back to your brainstorm from earlier - Which of your group’s questions require information from more than one dataset?


  • Comparing spend by income level → needs transactions + demographics.
  • Most commonly purchased product → needs transactions + products.
  • Most used coupons on products → needs coupon_redemptions + coupons.

Joining Data

Important

Most organizations store data in separate tables for:

  • Storage efficiency
  • Different data collection processes
  • Security and access control

Being able to combine datasets is essential to answer more complex questions and see the bigger picture.

The Importance of Keys

  • Keys are the columns used to match rows between two datasets.
  • Without a reliable, unique key, joins may:
    • Fail to match rows (missing data)
    • Match incorrectly (wrong data)
    • Duplicate rows unexpectedly

In the Complete Journey data:

  • household_id connects transactions with demographics
  • product_id connects transactions with products
  • coupon_upc connects coupons with coupon_redemptions

The Importance of Keys

  • Keys are the columns used to match rows between two datasets.

Good key characteristics:

  • Consistent naming across datasets
  • Same data type in both tables (e.g., both are integers or strings)
  • Unique values when needed (e.g., a customer_id in a customer table)
  • Stable over time (values don’t change)

Types of Joins

There are 4 primary types of joins you’ll read about this week:

  • Inner join
  • Left join
  • Right join
  • Outer join

Types of Joins

There are 4 primary types of joins you’ll read about this week:

  • Inner join → Only rows with matching keys in both tables
  • Left join
  • Right join
  • Outer join

Types of Joins

There are 4 primary types of joins you’ll read about this week:

  • Inner join
  • Left join → All rows from left table, matching from right
  • Right join
  • Outer join

Types of Joins

There are 4 primary types of joins you’ll read about this week:

  • Inner join
  • Left join
  • Right join → All rows from right table, matching from left
  • Outer join

Types of Joins

There are 4 primary types of joins you’ll read about this week:

  • Inner join
  • Left join
  • Right join
  • Outer join → All rows from both tables

Pandas merge() Basics

We use merge() to join datasets.

pd.merge(
  left_df,          # left DF
  right_df,         # right DF
  on='key_column',  # column(s) to join on
  how='inner'       # type of join
  )      
(
  left_df.               # start with left DF
  merge(right_df,        # right DF
        on='key_column', # column(s) to join on
        how='inner'      # type of join
        )           
)


Note

Two general approaches your see. Either is fine.

Example

What is the total sales value for the top 10 selling products?


transactions = cj_data["transactions"]
products = cj_data["products"]

(
    transactions
    .merge(products, how='inner', on='product_id')
    .groupby(['product_id', 'product_category'], as_index=False)
    .agg({'sales_value': 'sum'})
    .nlargest(10, 'sales_value')
)
product_id product_category sales_value
42214 6534178 COUPON/MISC ITEMS 303116.02
42185 6533889 COUPON/MISC ITEMS 27467.61
23017 1029743 FLUID MILK PRODUCTS 22729.71
42210 6534166 COUPON/MISC ITEMS 20477.54
42178 6533765 FUEL 19451.66
27873 1082185 TROPICAL FRUIT 17219.59
12489 916122 CHICKEN 16120.01
30086 1106523 FLUID MILK PRODUCTS 15629.95
19812 995242 FLUID MILK PRODUCTS 15602.59
39340 5569230 SOFT DRINKS 13410.46

🧑‍💻 Code With Me: Customer Demographics Analysis

Business Question: Do families with kids spend more than families without kids? Our marketing team wants to target family-friendly promotions.

# Let's explore what we have
transactions = cj_data["transactions"]
demographics = cj_data["demographics"] 

demographics[['household_id', 'kids_count']].head()
household_id kids_count
0 1 0
1 1001 0
2 1003 0
3 1004 0
4 101 2

Your turn: Help me join transactions with demographics:

# Fill in the blanks together!
family_data = (
    transactions
    .merge(demographics, on='_______', how='_______')
)

family_data[['household_id', 'sales_value', 'kids_count']].head()

🧑‍💻 Code With Me: Family Spending Analysis

Continuing our analysis: Now let’s compare average spending by family type.

# Fill in the blanks together!
family_spending = (
    family_data
    .groupby('_______', as_index=False)
    .agg({'sales_value': ['_______', '_______', 'count']})  # mean, sum
)

family_spending

Discussion: What does this tell us about family spending patterns?

Let’s Wrap This Up

Recap: What Did We Learn?

  • Why clean, well-structured data matters for reliable analysis
  • Manipulating Data: rename/add/drop columns; handle missing values
  • Summarizing Data: describe(), groupby(), and aggregations
  • Joining Data: combine related tables with merge() (inner/left/right/outer)
  • Used the Ames Housing Data and Complete Journey datasets to anchor real questions

🧾 Quick Reference

Task Syntax Example
Rename columns df.rename(columns={"old":"new"}, inplace=True)
Create a new column df["unit_price"] = df["sales_value"] / df["quantity"]
Drop column(s) df.drop(columns=["col1","col2"], inplace=True)
Fill missing values df["col"].fillna(0, inplace=True)
Group and single aggregation df.groupby("dept")["sales_value"].sum()
Group with multiple aggregations df.groupby("dept").agg({"sales_value":["sum","mean"], "quantity":"sum"})
Sort results df.sort_values(["sales_value"], ascending=False)
Most frequent items df["product_id"].value_counts()
Join two tables (inner) pd.merge(left_df, right_df, on="key", how="left)

Key Takeaways

  • Most orgs store data across multiple tables → joins are essential
  • Clean columns + clear keys → fewer surprises when aggregating/joining
  • groupby + agg unlocks “who/what/where/how much” business questions
  • Handling missingness requires reasoning (MCAR/MAR/MNAR), not just .fillna()
  • Build analysis from your questions → manipulate → summarize → join → summarize → interpret

Coming Up Next…

  • Thursday Lab: come ready to answer your group’s questions using joins & aggregations
  • Homework: apply this week’s skills to manipulate, summarize, and join data

Important

Be sure to finish the Week 4 readings before Thursday’s lab so you can hit the ground running!

Q&A 🙋🏻

Open floor for any questions regarding…

  • Today’s manipulation / aggregation / joining topics
  • What to do before Thursday’s lab
  • Reading clarifications or edge cases you’ve run into
  • Anything else on your mind