Mathew K Analytics

Lesson 6 · Python for Data Analysts

End-to-End Data Analyst Capstone

One messy, realistic dataset, taken all the way from raw and broken to cleaned, analyzed, visualized, and reported. This capstone assumes you've already…

⬇ Download notebookOpen in Colab ↗

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

End-to-End Data Analyst Capstone#

  • One messy, realistic dataset, taken all the way from raw and broken to cleaned, analyzed, visualized, and reported.
  • This capstone assumes you've already covered pandas, numpy, and matplotlib or seaborn basics; we're combining all of it into one real project.

Before You Start#

  • Open a new Jupyter Notebook in VS Code and select your Python interpreter as the kernel.
  • If anything's missing, open a terminal in VS Code and run: pip install pandas numpy matplotlib seaborn openpyxl

Part 1: The Business Question#

Scenario#

  • We're handed a raw export of a small retailer's transactions, and asked one question: which regions and categories are actually driving revenue?
  • The export is realistically messy: missing prices, missing regions, duplicate rows, and a handful of obvious data-entry error outliers.
  • We'll generate that messy export ourselves, with a fixed seed, so it's reproducible, then clean and analyze it exactly as if it had landed in our inbox.
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style='whitegrid')
print('Libraries ready')
Libraries ready
rng = np.random.default_rng(seed=13)
n = 500

regions = ['North', 'South', 'East', 'West']
categories = ['Electronics', 'Home Goods', 'Apparel', 'Sporting Goods']

dates = pd.date_range('2023-01-01', '2023-12-31', periods=n)
customer_ids = rng.integers(1000, 1200, size=n)
region_col = rng.choice(regions, size=n)
category_col = rng.choice(categories, size=n)
quantity_col = rng.integers(1, 6, size=n)
unit_price_col = rng.uniform(8, 200, size=n).round(2)

raw = pd.DataFrame({
    'transaction_id': range(1, n + 1),
    'date': dates,
    'customer_id': customer_ids,
    'region': region_col,
    'category': category_col,
    'quantity': quantity_col,
    'unit_price': unit_price_col,
})
print(raw.shape)
(500, 7)
messy = raw.copy()

missing_price_idx = rng.choice(n, size=20, replace=False)
messy.loc[missing_price_idx, 'unit_price'] = np.nan

missing_region_idx = rng.choice(n, size=15, replace=False)
messy.loc[missing_region_idx, 'region'] = None

outlier_idx = rng.choice(n, size=8, replace=False)
messy.loc[outlier_idx, 'unit_price'] = messy.loc[outlier_idx, 'unit_price'] * 50

duplicate_rows = messy.sample(10, random_state=13)
messy = pd.concat([messy, duplicate_rows], ignore_index=True)

print(messy.shape)
(510, 7)
messy.to_csv('raw_transactions.csv', index=False)
print('Saved raw_transactions.csv')
Saved raw_transactions.csv

Part 2: Initial Inspection#

df = pd.read_csv('raw_transactions.csv', parse_dates=['date'])
print(df.head())
print(df.shape)
   transaction_id                          date  customer_id region  \
0               1 2023-01-01 00:00:00.000000000         1179   West   
1               2 2023-01-01 17:30:25.250501002         1172  South   
2               3 2023-01-02 11:00:50.501002004         1163  North   
3               4 2023-01-03 04:31:15.751503006         1171  South   
4               5 2023-01-03 22:01:41.002004008         1013  South   

         category  quantity  unit_price  
0  Sporting Goods         3         NaN  
1  Sporting Goods         5      159.69  
2      Home Goods         3       74.91  
3      Home Goods         5       97.43  
4         Apparel         3      178.49  
(510, 7)
print(df.info())
print(df.describe())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 510 entries, 0 to 509
Data columns (total 7 columns):
 #   Column          Non-Null Count  Dtype         
---  ------          --------------  -----         
 0   transaction_id  510 non-null    int64         
 1   date            510 non-null    datetime64[ns]
 2   customer_id     510 non-null    int64         
 3   region          495 non-null    object        
 4   category        510 non-null    object        
 5   quantity        510 non-null    int64         
 6   unit_price      489 non-null    float64       
dtypes: datetime64[ns](1), float64(1), int64(3), object(2)
memory usage: 28.0+ KB
None
       transaction_id                           date  customer_id    quantity  \
count      510.000000                            510   510.000000  510.000000   
mean       249.854902  2023-07-01 12:42:22.534480896  1102.952941    2.982353   
min          1.000000            2023-01-01 00:00:00  1000.000000    1.000000   
25%        124.250000  2023-03-31 21:44:22.124248320  1057.000000    2.000000   
50%        250.500000            2023-07-02 00:00:00  1104.000000    3.000000   
75%        374.750000  2023-09-30 15:14:47.374749696  1149.750000    4.000000   
max        500.000000            2023-12-31 00:00:00  1199.000000    5.000000   
std        145.087341                            NaN    56.727988    1.390991   

        unit_price  
count   489.000000  
mean    185.228446  
min       8.110000  
25%      57.040000  
50%     108.110000  
75%     153.820000  
max    9086.000000  
std     711.776334  
print(df.isna().sum())
print('Duplicate rows:', df.duplicated().sum())
transaction_id     0
date               0
customer_id        0
region            15
category           0
quantity           0
unit_price        21
dtype: int64
Duplicate rows: 10
print(df['unit_price'].describe())
print(df.nlargest(5, 'unit_price')[['transaction_id', 'category', 'unit_price']])
count     489.000000
mean      185.228446
std       711.776334
min         8.110000
25%        57.040000
50%       108.110000
75%       153.820000
max      9086.000000
Name: unit_price, dtype: float64
     transaction_id        category  unit_price
117             118         Apparel      9086.0
231             232      Home Goods      7597.5
391             392  Sporting Goods      6732.0
287             288         Apparel      4935.5
409             410      Home Goods      4791.5

Part 3: Cleaning#

before_dedup = len(df)
df = df.drop_duplicates()
print(f'Removed {before_dedup - len(df)} duplicate rows')
Removed 10 duplicate rows
print(df['region'].isna().sum())
df['region'] = df['region'].fillna('Unknown')
print(df['region'].value_counts())
15
region
South      139
North      130
East       113
West       103
Unknown     15
Name: count, dtype: int64
median_price_by_category = df.groupby('category')['unit_price'].transform('median')
df['unit_price'] = df['unit_price'].fillna(median_price_by_category)
print(df['unit_price'].isna().sum())
0
q1 = df['unit_price'].quantile(0.25)
q3 = df['unit_price'].quantile(0.75)
iqr = q3 - q1
upper_bound = q3 + 1.5 * iqr
print(f'Upper bound: {upper_bound:.2f}')

outlier_count = (df['unit_price'] > upper_bound).sum()
print(f'{outlier_count} price outliers found')
df['unit_price'] = df['unit_price'].clip(upper=upper_bound)
Upper bound: 292.81
7 price outliers found
df['total_amount'] = (df['quantity'] * df['unit_price']).round(2)
print(df[['quantity', 'unit_price', 'total_amount']].head())
   quantity  unit_price  total_amount
0         3       94.84        284.52
1         5      159.69        798.45
2         3       74.91        224.73
3         5       97.43        487.15
4         3      178.49        535.47

Part 4: Feature Engineering#

df['month'] = df['date'].dt.month_name()
df['day_of_week'] = df['date'].dt.day_name()
df['is_weekend'] = df['date'].dt.dayofweek >= 5
print(df[['date', 'month', 'day_of_week', 'is_weekend']].head())
                           date    month day_of_week  is_weekend
0 2023-01-01 00:00:00.000000000  January      Sunday        True
1 2023-01-01 17:30:25.250501002  January      Sunday        True
2 2023-01-02 11:00:50.501002004  January      Monday       False
3 2023-01-03 04:31:15.751503006  January     Tuesday       False
4 2023-01-03 22:01:41.002004008  January     Tuesday       False
price_bins = [0, 50, 100, 1000]
price_labels = ['Budget', 'Mid-range', 'Premium']
df['price_tier'] = pd.cut(df['unit_price'], bins=price_bins, labels=price_labels)
print(df['price_tier'].value_counts())
price_tier
Premium      267
Mid-range    131
Budget       102
Name: count, dtype: int64

Part 5: Exploratory Analysis#

revenue_by_region = df.groupby('region')['total_amount'].sum().sort_values(ascending=False)
print(revenue_by_region)
region
South      43438.44
North      42046.34
East       37593.03
West       34317.48
Unknown     3986.55
Name: total_amount, dtype: float64
revenue_by_category = df.groupby('category')['total_amount'].agg(total_revenue='sum', avg_order='mean', order_count='count').sort_values('total_revenue', ascending=False)
print(revenue_by_category)
                total_revenue   avg_order  order_count
category                                              
Apparel              43459.50  312.658273          139
Home Goods           41889.78  335.118240          125
Electronics          41053.26  333.766341          123
Sporting Goods       34979.30  309.551327          113
monthly_revenue = df.groupby('month')['total_amount'].sum()
month_order = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
monthly_revenue = monthly_revenue.reindex(month_order)
print(monthly_revenue)
month
January      13775.25
February     10295.64
March        17800.16
April        14070.72
May          15423.40
June         11205.77
July         12772.91
August       13151.83
September    11768.58
October      17391.83
November     12198.83
December     11526.92
Name: total_amount, dtype: float64
region_category_pivot = df.pivot_table(values='total_amount', index='region', columns='category', aggfunc='sum', fill_value=0)
print(region_category_pivot)
category   Apparel  Electronics  Home Goods  Sporting Goods
region                                                     
East       7632.86      8277.65    12885.98         8796.54
North     12491.20     11564.25    11200.32         6790.57
South     11505.39     10204.21    11332.15        10396.69
Unknown     965.95      1223.38      570.89         1226.33
West      10864.10      9783.77     5900.44         7769.17

Part 6: Visualization#

fig, ax = plt.subplots(figsize=(8, 5))
revenue_by_region.plot(kind='bar', ax=ax, color='#1F4E78')
ax.set_title('Total Revenue by Region')
ax.set_ylabel('Revenue ($)')
ax.set_xlabel('Region')
plt.tight_layout()
plt.savefig('revenue_by_region.png', dpi=150)
plt.close(fig)
print('Saved revenue_by_region.png')
Saved revenue_by_region.png
fig, ax = plt.subplots(figsize=(8, 5))
sns.boxplot(data=df, x='category', y='unit_price', ax=ax)
ax.set_title('Unit Price Distribution by Category (After Cleaning)')
plt.xticks(rotation=20)
plt.tight_layout()
plt.savefig('price_distribution.png', dpi=150)
plt.close(fig)
print('Saved price_distribution.png')
Saved price_distribution.png
fig, ax = plt.subplots(figsize=(10, 5))
sns.lineplot(x=monthly_revenue.index, y=monthly_revenue.values, marker='o', ax=ax)
ax.set_title('Monthly Revenue Trend')
ax.set_ylabel('Revenue ($)')
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig('monthly_trend.png', dpi=150)
plt.close(fig)
print('Saved monthly_trend.png')
Saved monthly_trend.png

Part 7: Statistical Summary#

correlation = df[['quantity', 'unit_price', 'total_amount']].corr()
print(correlation)
              quantity  unit_price  total_amount
quantity      1.000000    0.042916      0.639485
unit_price    0.042916    1.000000      0.726041
total_amount  0.639485    0.726041      1.000000
avg_order_value = df['total_amount'].mean()
std_order_value = df['total_amount'].std()
weekend_share = df['is_weekend'].mean() * 100
print(f'Average order value: ${avg_order_value:.2f}')
print(f'Standard deviation: ${std_order_value:.2f}')
print(f'Weekend share of orders: {weekend_share:.1f}%')
Average order value: $322.76
Standard deviation: $242.92
Weekend share of orders: 28.8%

Part 8: Building the Final Report#

from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
from openpyxl.drawing.image import Image as XLImage

wb = Workbook()
summary_sheet = wb.active
summary_sheet.title = 'Summary'
header_fill = PatternFill(start_color='1F4E78', end_color='1F4E78', fill_type='solid')
header_font = Font(color='FFFFFF', bold=True)

summary_sheet['A1'] = 'Retail Transactions Report'
summary_sheet['A1'].font = Font(size=16, bold=True)
summary_sheet.append([])
summary_sheet.append(['Metric', 'Value'])
for cell in summary_sheet[3]:
    cell.fill = header_fill
    cell.font = header_font

summary_sheet.append(['Total Revenue', f'${df["total_amount"].sum():,.2f}'])
summary_sheet.append(['Average Order Value', f'${avg_order_value:.2f}'])
summary_sheet.append(['Total Transactions', len(df)])
summary_sheet.append(['Weekend Order Share', f'{weekend_share:.1f}%'])
print('Summary sheet built')
Summary sheet built
region_sheet = wb.create_sheet('By Region')
region_sheet.append(['Region', 'Total Revenue'])
for region, revenue in revenue_by_region.items():
    region_sheet.append([region, round(revenue, 2)])
for cell in region_sheet[1]:
    cell.fill = header_fill
    cell.font = header_font

charts_sheet = wb.create_sheet('Charts')
charts_sheet.add_image(XLImage('revenue_by_region.png'), 'A1')
charts_sheet.add_image(XLImage('monthly_trend.png'), 'A25')

wb.save('retail_analysis_report.xlsx')
print('Saved retail_analysis_report.xlsx')
Saved retail_analysis_report.xlsx

Capstone: A Reusable Analysis Pipeline#

def analyze_retail_transactions(csv_path):
    data = pd.read_csv(csv_path, parse_dates=['date'])
    data = data.drop_duplicates()
    data['region'] = data['region'].fillna('Unknown')

    category_median = data.groupby('category')['unit_price'].transform('median')
    data['unit_price'] = data['unit_price'].fillna(category_median)

    q1, q3 = data['unit_price'].quantile([0.25, 0.75])
    upper = q3 + 1.5 * (q3 - q1)
    data['unit_price'] = data['unit_price'].clip(upper=upper)

    data['total_amount'] = (data['quantity'] * data['unit_price']).round(2)
    data['month'] = data['date'].dt.month_name()

    by_region = data.groupby('region')['total_amount'].sum().sort_values(ascending=False)
    by_category = data.groupby('category')['total_amount'].sum().sort_values(ascending=False)

    summary = {
        'total_revenue': round(data['total_amount'].sum(), 2),
        'avg_order_value': round(data['total_amount'].mean(), 2),
        'transaction_count': len(data),
        'top_region': by_region.index[0],
        'top_category': by_category.index[0],
    }

    return {
        'cleaned_data': data,
        'revenue_by_region': by_region,
        'revenue_by_category': by_category,
        'summary': summary,
    }
result = analyze_retail_transactions('raw_transactions.csv')
print(result['summary'])
print(result['revenue_by_region'])
{'total_revenue': np.float64(161381.84), 'avg_order_value': np.float64(322.76), 'transaction_count': 500, 'top_region': 'South', 'top_category': 'Apparel'}
region
South      43438.44
North      42046.34
East       37593.03
West       34317.48
Unknown     3986.55
Name: total_amount, dtype: float64

Wrap-Up: What You Learned#

  • Framing a business question, and generating a realistically messy dataset to answer it.
  • Inspecting raw data thoroughly before touching a single value.
  • Cleaning: duplicates, missing values filled sensibly by group, and IQR-based outlier handling.
  • Feature engineering: date parts, boolean flags, and binning with cut.
  • Exploratory analysis with groupby, named aggregations, and pivot_table.
  • Visualization with matplotlib and seaborn, saved as real image files.
  • Statistical summaries: correlation, mean, standard deviation.
  • Packaging findings into a genuine, shareable Excel report with embedded charts.
  • A capstone pipeline compressing the entire workflow into one reusable function.
  • You went from a messy raw export to a polished, reusable analysis pipeline in one sitting. This wraps up the whole series, thank you for building all of this with me. If you want to catch whatever comes next, subscribing is the move see you in the next one.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.