🐼 Section 2 · Pandas 🟠 Project MODULE 13

Project 1 — Exploratory Data Analysis on a Real Dataset

⏱️ 75 min · hands-on
📖 Full EDA Walkthrough
🧩 3 Quiz Questions
🏗️ 1 Challenge · 3 tasks
Your progress in Section 2100%
🎯 The Project: This is the capstone of Section 2 — everything you've learned about pandas, all in one workflow. You'll load a raw retail sales CSV, get familiar with its shape and quirks with .shape/.dtypes/.isnull().sum(), clean up the messy parts, then use groupby() and sort_values() to answer real business questions: which region sells the most, and which products are the top performers. We'll close by writing up a few findings — the actual point of doing EDA in the first place.

The Project Brief

Imagine you've been handed a CSV export from a small retail chain's point-of-sale system — one row per line item sold, across a handful of regions, over about a year. Nobody has cleaned it for you. Your job, like on any real data project, is to turn it into something trustworthy enough to draw conclusions from.

We'll use a plausible, illustrative dataset called retail_sales.csv with five columns. Everything you see in this lesson — the exact numbers, the missing values, the findings — is a worked example built to teach the workflow, not a real published dataset. The methods are 100% real pandas; the numbers are a stand-in for whatever CSV lands on your desk.

📅
order_date
The date each line item was sold, stored as plain text in the raw CSV.
📦
product
The product name — e.g. Laptop, Monitor, Keyboard, Mouse.
🗺️
region
Which of four sales regions the order came from — North, South, East, West.
💰
sales
Revenue in dollars for that line item.
🔢
quantity
Units sold in that line item.
1
Load and take a first look
Read the CSV, check its shape, preview rows, and inspect column dtypes.
2
Find the missing data
Use .isnull().sum() to see exactly which columns and how many rows are affected.
3
Clean it up
Fill or drop missing values sensibly, and fix the date column's dtype.
4
Summarize with groupby()
Total sales by region, average order value by product.
5
Sort to find top performers
sort_values() and nlargest() to rank regions and products.
6
Write up findings
Turn the numbers into a few plain-English takeaways.

Load the Data and Take a First Look

Every EDA starts the same way: read the file, then immediately check its size and preview a few rows before doing anything else. Never trust a dataset you haven't looked at yet.

load_data.py
PYTHON
import pandas as pd

df = pd.read_csv("retail_sales.csv")

print(df.shape)
# (1450, 5)

print(df.head())
df.head()
order_dateproductregionsalesquantity
02024-01-03LaptopWest1200.01.0
12024-01-03MouseWest45.03.0
22024-01-04MonitorNaN910.02.0
32024-01-05KeyboardSouth65.0NaN
42024-01-06LaptopEast1150.01.0

Already there are two things worth noticing: row 2 is missing its region, and row 3 is missing its quantity. Next, check the dtypes pandas inferred for each column — this is where you often catch a second problem before you even start cleaning.

inspect_dtypes.py
PYTHON
print(df.dtypes)
# order_date     object
# product        object
# region         object
# sales         float64
# quantity      float64
# dtype: object

print(df.info())
# RangeIndex: 1450 entries, 0 to 1449
# Data columns (total 5 columns):
#   order_date  1450 non-null   object
#   product     1450 non-null   object
#   region      1432 non-null   object
#   sales       1450 non-null   float64
#   quantity    1424 non-null   float64
⚠️
Two dtype problems, hiding in plain sight
order_date is object (plain text), not a real date — pandas can't do date math or sort chronologically on it until we convert it. quantity is float64 instead of a whole-number int64 — that's not a mistake in the file, it's a side effect of missing values, which you'll see explained in the next section.

Find the Missing Data

.info() already hinted at missing values through the non-null counts, but .isnull().sum() gives you the exact picture, column by column, in a format you can act on directly.

check_missing.py
PYTHON
print(df.isnull().sum())
# order_date     0
# product        0
# region        18
# sales          0
# quantity      26
# dtype: int64

# As a share of all rows, so you know if it's worth worrying about
print((df.isnull().sum() / len(df) * 100).round(2))
# region      1.24
# quantity    1.79
📝
Why quantity became float64
NumPy's integer dtype has no way to represent a missing value, so whenever a numeric column contains at least one NaN, pandas silently upgrades the whole column to float64 so the NaNs fit. You'll get int64 back once every missing value is filled in and you explicitly convert with .astype(int).

Under 2% missing in either column is small enough to handle with simple fills rather than dropping rows outright — dropping would throw away real revenue data in the sales column just because a neighboring cell was blank.

Clean the Data

Two different missing-value problems get two different fixes. A missing region is categorical — there's no sensible number to compute, so it gets an explicit placeholder label. A missing quantity is numeric, so a sensible statistic (the median) fills the gap without distorting the total too much.

clean_data.py
PYTHON
# Missing region: label it rather than guess or drop the row
df["region"] = df["region"].fillna("Unknown")

# Missing quantity: fill with the column's median, a robust "typical" value
df["quantity"] = df["quantity"].fillna(df["quantity"].median())

# Now that there are no NaNs left in quantity, it can safely become int
df["quantity"] = df["quantity"].astype(int)

# Fix the dtype problem: parse order_date into real datetime values
df["order_date"] = pd.to_datetime(df["order_date"])
⚠️
fillna() before astype() — order matters
Calling .astype(int) while NaNs are still present raises an error — NaN is a float concept and simply has no integer equivalent. Always fill or drop the missing values first, then convert the dtype.

Verify the cleanup actually worked before moving on — this habit catches typos in column names and forgotten steps early.

verify_clean.py
PYTHON
print(df.isnull().sum().sum())
# 0   ← zero missing values left, anywhere in the DataFrame

print(df.dtypes)
# order_date    datetime64[ns]
# product               object
# region                object
# sales                float64
# quantity               int64
# dtype: object

Summarize with groupby()

With clean data in hand, it's time to actually answer questions. First: how does total revenue break down by region, and how many orders and what average order value sit behind that total? Named aggregation with .agg() computes all three at once, one row per region.

groupby_region.py
PYTHON
region_summary = (
    df.groupby("region")
      .agg(
          total_sales=("sales", "sum"),
          orders=("sales", "count"),
          avg_order_value=("sales", "mean"),
      )
      .round(2)
      .sort_values("total_sales", ascending=False)
)
print(region_summary)
region_summary
regiontotal_salesordersavg_order_value
West198450.0402493.66
East176220.0388454.18
South151300.0361419.11
North139560.0281496.66
Unknown9840.018546.67

Next, the same idea applied to product instead of region — this time to see which products carry the highest average order value, not just the highest total.

groupby_product.py
PYTHON
product_aov = (
    df.groupby("product")["sales"]
      .mean()
      .round(2)
      .sort_values(ascending=False)
      .rename("avg_order_value")
)
print(product_aov)
product_aov
productavg_order_value
Laptop1178.35
Monitor897.50
Keyboard68.20
Mouse44.90
Total vs. average tell different stories
Notice North has a slightly higher avg_order_value than West despite a much lower total_sales — West simply has far more orders. Reporting only the total, or only the average, would each hide half the picture. That's exactly why region_summary computes both side by side.

Sort to Find Top Performers

The region_summary table above is already sorted, but sometimes you want the single biggest rows out of the raw, un-grouped data — for example, the five highest-value individual orders. .nlargest() is a shortcut for exactly that, faster and shorter to write than a full sort_values().head().

top_performers.py
PYTHON
# Top 5 individual orders by revenue
top_orders = df.nlargest(5, "sales")[["order_date", "product", "region", "sales"]]
print(top_orders)

# Equivalent, longer way of writing the same thing:
# df.sort_values("sales", ascending=False).head(5)

# High-value orders worth a closer look — anything over $1,000
big_orders = df[df["sales"] > 1000]
print(f"{len(big_orders)} orders were over $1,000")
top_orders
order_dateproductregionsales
8122024-08-14LaptopWest1420.0
2042024-03-02LaptopEast1390.0
11032024-11-21LaptopNorth1360.0
672024-01-19MonitorWest1290.0
9552024-10-03LaptopSouth1275.0
📝
nlargest() vs. sort_values().head()
df.nlargest(5, "sales") and df.sort_values("sales", ascending=False).head(5) return the same rows. nlargest() is worth reaching for as the shorter, slightly more efficient option when you only need the top N and don't need the whole DataFrame sorted.

The Complete Script, Start to Finish

Here's every step from this lesson combined into one script you could actually run against a CSV shaped like retail_sales.csv.

eda_retail_sales.py — COMPLETE PROGRAM
PYTHON
import pandas as pd

# 1. Load and take a first look
df = pd.read_csv("retail_sales.csv")
print("Shape:", df.shape)
print(df.head())
print(df.dtypes)

# 2. Find the missing data
print(df.isnull().sum())

# 3. Clean it up
df["region"] = df["region"].fillna("Unknown")
df["quantity"] = df["quantity"].fillna(df["quantity"].median())
df["quantity"] = df["quantity"].astype(int)
df["order_date"] = pd.to_datetime(df["order_date"])
assert df.isnull().sum().sum() == 0, "Still missing values!"

# 4. Summarize with groupby()
region_summary = (
    df.groupby("region")
      .agg(
          total_sales=("sales", "sum"),
          orders=("sales", "count"),
          avg_order_value=("sales", "mean"),
      )
      .round(2)
      .sort_values("total_sales", ascending=False)
)

product_aov = (
    df.groupby("product")["sales"]
      .mean()
      .round(2)
      .sort_values(ascending=False)
      .rename("avg_order_value")
)

# 5. Sort to find top performers
top_orders = df.nlargest(5, "sales")[["order_date", "product", "region", "sales"]]

print("\n=== Total Sales by Region ===")
print(region_summary)

print("\n=== Avg Order Value by Product ===")
print(product_aov)

print("\n=== Top 5 Individual Orders ===")
print(top_orders)

Writing Up Findings

Numbers on their own aren't an analysis — the last step is turning them into plain statements someone could act on. Here's what this particular (illustrative) run of the workflow suggests. Treat these as an example of the kind of finding EDA produces, not as claims about any real business.

🗺️
West leads on total revenue
West has both the highest total_sales and the most orders in this sample — its lead comes mainly from volume, not a higher price per order.
💻
Laptops drive average order value
Laptop and Monitor sit far above Keyboard and Mouse in avg_order_value — a handful of high-ticket items pull the per-order average up a lot.
🔍
North punches above its total
North has the smallest total_sales of the four named regions but the highest avg_order_value — worth investigating whether that's fewer, larger-ticket customers.
⚠️
A finding is a hypothesis, not a conclusion
"North has a high average order value" is something worth asking about — is it seasonal, one big client, a pricing difference? It isn't proof of anything by itself. Good EDA produces questions to chase down, not final answers to publish.
🧩 Knowledge Check — Lesson 13
A shorter check-in for this project lesson — 3 questions about the EDA workflow.
1. Why did the quantity column show up as float64 instead of int64, even though every actual value was a whole number?
2. In the cleaning step, why is .fillna() called on quantity before .astype(int), and not the other way around?
3. The region_summary table shows North has fewer total sales than West but a higher avg_order_value. What does that combination tell you?
💪
Try It Yourself — Lesson 13
Extend the project · Intermediate Level

The base project works end to end — now push it further. Use the cleaned df from Section 7 as your starting point for each task below.

Task 1: Find the best month 📅

Now that order_date is a real datetime column, extract the month with df["order_date"].dt.month (or build a "2024-01"-style label with .dt.to_period("M")), add it as a new column, then groupby() that column and sum sales to find which month had the highest total revenue.
Task 2: Rank regions two different ways 🏆

Sort region_summary by total_sales and separately by avg_order_value. Do the top regions match in both rankings? Write one sentence explaining, in your own words, why they might not.
Task 3: Zoom into one region 🔎

Filter df down to a single region with boolean indexing (e.g. df[df["region"] == "West"]), then re-run the product group-by summary from Section 5 on just that subset. Does the top product change compared to the full-dataset result?
💡 Show hints if you're stuck
  • Task 1: df["month"] = df["order_date"].dt.to_period("M"), then df.groupby("month")["sales"].sum().sort_values(ascending=False)
  • Task 2: region_summary.sort_values("avg_order_value", ascending=False) — compare its top row to region_summary's existing total_sales order
  • Task 3: west = df[df["region"] == "West"], then west.groupby("product")["sales"].mean().sort_values(ascending=False)
Finished this project?
Mark it complete to track your progress.
🎉

Section 2 Complete!

You've taken a raw CSV all the way through loading, cleaning, and group-by analysis — the core loop of real-world data work. Next up: Section 3, where you'll start turning DataFrames into charts with Matplotlib.

Module 13 of 13 Section 2 — Pandas: Data Analysis Powerhouse