Mathew K Analytics

Lesson 8 · Real-World Data Analytics

Python Data Analytics #08: Product Category Performance & ABC Analysis in Python

Video eight of the hundred-video real-world data analytics series. Deriving real product categories straight from real description text, then running a real…

What you'll learn

Datasets used in this lesson

Save these next to the notebook. In Google Colab, upload them with the 📁 icon on the left first.

📓 Full notebook

Download .ipynb

Data Analytics 100, Video 8: Product Category Performance and ABC Analysis#

  • Video eight of the hundred-video real-world data analytics series.
  • Deriving real product categories straight from real description text, then running a real classic ABC analysis on top of it.
  • Let's get into it.

Part 1: This Real Dataset Has No Category Column#

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
clean = pd.read_csv('online_retail_clean.csv', parse_dates=['InvoiceDate'])
clean.shape
(391150, 9)

Part 2: Real Keyword List for Deriving Categories#

keywords = ['BAG', 'MUG', 'CANDLE', 'LIGHT', 'BOX', 'CARD', 'FRAME', 'CLOCK', 'BOTTLE', 'NECKLACE', 'BRACELET', 'CUSHION', 'LANTERN', 'JAR', 'TIN', 'BOWL', 'PLATE', 'APRON', 'NOTEBOOK', 'MIRROR']
len(keywords)
20

Part 3: Real Category Assignment Function#

desc_upper = clean['Description'].astype(str).str.upper()
def categorize(desc):
    for kw in keywords:
        if kw in desc:
            return kw.title()
    return 'Other'

Part 4: Applying the Real Categorization#

clean['Category'] = desc_upper.apply(categorize)
clean['Category'].value_counts().head(10)
Category
Other     224742
Bag        38035
Tin        21058
Box        18952
Light      17605
Card       12938
Bottle      8688
Candle      8182
Mug         6114
Clock       5800
Name: count, dtype: int64

Part 5: Being Real Honest About Coverage#

coverage = (clean['Category'] != 'Other').mean()
print(f'Real keyword categorization covers {round(coverage * 100, 1)}% of transactions; the rest fall into an honest Other bucket.')
Real keyword categorization covers 42.5% of transactions; the rest fall into an honest Other bucket.

Part 6: Real Revenue by Category#

category_revenue = clean.groupby('Category')['Revenue'].sum().sort_values(ascending=False)
category_revenue.head(10).round(0)
Category
Other     4770332.0
Bag        890133.0
Tin        579462.0
Light      575857.0
Box        444710.0
Bottle     221032.0
Frame      181913.0
Jar        162903.0
Card       150706.0
Clock      147603.0
Name: Revenue, dtype: float64

Part 7: Visualizing Real Category Revenue#

plt.figure(figsize=(10, 6))
top_categories = category_revenue[category_revenue.index != 'Other'].head(12)
plt.barh(top_categories.index[::-1], top_categories.values[::-1], color='teal')
plt.xlabel('Real Total Revenue (GBP)')
plt.title('Real Revenue by Derived Product Category')
plt.tight_layout()
plt.savefig('category_revenue.png', dpi=120)
plt.close()

Part 8: Real Average Order Value by Category#

category_stats = clean[clean['Category'] != 'Other'].groupby('Category').agg(AvgUnitPrice=('UnitPrice', 'mean'), TotalQty=('Quantity', 'sum'))
category_stats.sort_values('AvgUnitPrice', ascending=False).round(2)
AvgUnitPrice TotalQty
Category
Necklace 6.33 920
Clock 5.55 34771
Bracelet 4.85 3249
Frame 4.77 49896
Bottle 4.21 61944
Mirror 3.99 22755
Tin 3.77 209583
Cushion 3.58 17154
Lantern 3.51 19850
Jar 3.00 117661
Box 2.99 224944
Apron 2.82 16598
Light 2.52 316766
Bowl 2.30 50263
Candle 1.97 104221
Bag 1.89 541048
Mug 1.78 81242
Plate 1.74 43252
Card 1.00 205748
Notebook 0.99 27180

Part 9: Switching to ABC Analysis at the Real Product Level#

product_revenue = clean.groupby('StockCode')['Revenue'].sum().sort_values(ascending=False)
product_revenue.shape[0]
3659

Part 10: Real Cumulative Revenue Share#

cumulative_revenue = product_revenue.cumsum()
cumulative_pct = cumulative_revenue / product_revenue.sum() * 100
cumulative_pct.head(5).round(2)
StockCode
23843     1.93
22423     3.56
85123A    4.71
85099B    5.68
23166     6.61
Name: Revenue, dtype: float64

Part 11: Assigning Real A, B, and C Tiers#

def assign_tier(pct):
    if pct <= 80:
        return 'A'
    elif pct <= 95:
        return 'B'
    else:
        return 'C'
tiers = cumulative_pct.apply(assign_tier)
tiers.value_counts()
Revenue
C    1956
B     920
A     783
Name: count, dtype: int64

Part 12: Real Revenue Share per Tier#

abc_table = pd.DataFrame({'Revenue': product_revenue, 'Tier': tiers})
abc_table.groupby('Tier')['Revenue'].sum().round(0)
(abc_table.groupby('Tier')['Revenue'].sum() / abc_table['Revenue'].sum() * 100).round(1)
Tier
A    80.0
B    15.0
C     5.0
Name: Revenue, dtype: float64

Part 13: Validating the Real Pareto Principle#

pct_products_in_a = (tiers == 'A').mean() * 100
print(f'Just {round(pct_products_in_a, 1)}% of products in tier A generate 80% of total real revenue.')
Just 21.4% of products in tier A generate 80% of total real revenue.

Part 14: Visualizing the Real Pareto Curve#

plt.figure(figsize=(9, 6))
rank = np.arange(1, len(cumulative_pct) + 1)
plt.plot(rank, cumulative_pct.values, color='navy')
plt.axhline(80, color='red', linestyle='--', label='Real 80% revenue line')
plt.xlabel('Real Product Rank (by Revenue)')
plt.ylabel('Real Cumulative % of Total Revenue')
plt.title('Real Pareto Curve of Product Revenue')
plt.legend()
plt.tight_layout()
plt.savefig('pareto_curve.png', dpi=120)
plt.close()

Part 15: Real Top A-Tier Products#

desc_lookup = clean.groupby('StockCode')['Description'].agg(lambda s: s.mode().iloc[0])
a_tier_top = abc_table[abc_table['Tier'] == 'A'].head(10)
pd.DataFrame({'Description': desc_lookup.reindex(a_tier_top.index), 'Revenue': a_tier_top['Revenue']}).round(0)
Description Revenue
StockCode
23843 PAPER CRAFT , LITTLE BIRDIE 168470.0
22423 REGENCY CAKESTAND 3 TIER 142265.0
85123A WHITE HANGING HEART T-LIGHT HOLDER 100547.0
85099B JUMBO BAG RED RETROSPOT 85041.0
23166 MEDIUM CERAMIC TOP STORAGE JAR 81417.0
47566 PARTY BUNTING 68785.0
84879 ASSORTED COLOUR BIRD ORNAMENT 56413.0
23084 RABBIT NIGHT LIGHT 51251.0
22502 PICNIC BASKET WICKER SMALL 47348.0
79321 CHILLI LIGHTS 46265.0

Part 16: Real Long-Tail C-Tier Products#

c_tier = abc_table[abc_table['Tier'] == 'C']
c_tier.shape[0], c_tier['Revenue'].sum().round(0)
c_tier['Revenue'].mean().round(2)
np.float64(223.37)

Part 17: Real Category Mix Within Tier A#

a_tier_categories = clean[clean['StockCode'].isin(abc_table[abc_table['Tier'] == 'A'].index)]['Category']
a_tier_categories.value_counts().head(8)
Category
Other     131726
Bag        33891
Tin        18043
Box        14411
Light      13091
Bottle      7474
Clock       4844
Frame       4359
Name: count, dtype: int64

Part 18: Real Order Frequency by Tier#

orders_per_product = clean.groupby('StockCode')['InvoiceNo'].nunique()
abc_table['Orders'] = abc_table.index.map(orders_per_product)
abc_table.groupby('Tier')['Orders'].mean().round(1)
Tier
A    314.0
B    104.5
C     22.7
Name: Orders, dtype: float64

Part 19: Real Inventory Implication#

avg_qty_per_order = clean.groupby('StockCode').agg(TotalQty=('Quantity', 'sum'), Orders=('InvoiceNo', 'nunique'))
avg_qty_per_order['QtyPerOrder'] = avg_qty_per_order['TotalQty'] / avg_qty_per_order['Orders']
abc_table['QtyPerOrder'] = abc_table.index.map(avg_qty_per_order['QtyPerOrder'])
abc_table.groupby('Tier')['QtyPerOrder'].mean().round(2)
Tier
A    118.85
B     12.68
C      9.53
Name: QtyPerOrder, dtype: float64

Part 20: Real Sanity Check on the Tier Cutoffs#

boundary_product = abc_table[abc_table['Tier'] == 'A'].index[-1]
round(cumulative_pct[boundary_product], 2) <= 80
np.True_

Part 21: Saving the Real ABC Classification#

abc_output = abc_table.copy()
abc_output['Description'] = desc_lookup.reindex(abc_output.index)
abc_output.round(2).to_csv('product_abc_classification.csv')
reloaded = pd.read_csv('product_abc_classification.csv')
reloaded.shape[0] == abc_output.shape[0]
True

Part 22: Real Recap Print#

print(f'Classified {len(abc_table)} real products into ABC tiers; tier A holds just {round(pct_products_in_a, 1)}% of products but drives 80% of real revenue.')
Classified 3659 real products into ABC tiers; tier A holds just 21.4% of products but drives 80% of real revenue.

Part 23: Real Category Share Including the Other Bucket#

category_share = (category_revenue / category_revenue.sum() * 100).round(1)
category_share.head(6)
Category
Other     54.6
Bag       10.2
Tin        6.6
Light      6.6
Box        5.1
Bottle     2.5
Name: Revenue, dtype: float64

Part 24: Real Preview of Tier B Products#

b_tier_preview = abc_table[abc_table['Tier'] == 'B'].head(5)
pd.DataFrame({'Description': desc_lookup.reindex(b_tier_preview.index), 'Revenue': b_tier_preview['Revenue']}).round(0)
Description Revenue
StockCode
22906 12 MESSAGE CARDS WITH ENVELOPES 2579.0
23212 HEART WREATH DECORATION WITH BELL 2578.0
21115 ROSE CARAVAN DOORSTOP 2568.0
22187 GREEN CHRISTMAS TREE CARD HOLDER 2562.0
23100 SILVER BELLS TABLE DECORATION 2562.0

Part 25: Real Revenue Concentration at the Very Top#

top_1pct_count = max(1, int(len(product_revenue) * 0.01))
top_1pct_share = product_revenue.head(top_1pct_count).sum() / product_revenue.sum() * 100
print(f'The top {top_1pct_count} real products, just 1% of the catalog, generate {round(top_1pct_share, 1)}% of total real revenue.')
The top 36 real products, just 1% of the catalog, generate 18.7% of total real revenue.

Part 26: Visualizing the Real Revenue Distribution#

plt.figure(figsize=(9, 5))
plt.hist(np.log10(product_revenue[product_revenue > 0]), bins=40, color='crimson', edgecolor='white')
plt.xlabel('Real Log10 of Product Revenue')
plt.ylabel('Real Number of Products')
plt.title('Real Distribution of Revenue Across the Product Catalog')
plt.tight_layout()
plt.savefig('product_revenue_distribution.png', dpi=120)
plt.close()

Part 27: Real Categories Entirely Missing from Tier A#

all_categories = set(clean[clean['Category'] != 'Other']['Category'].unique())
categories_in_a = set(a_tier_categories.unique())
all_categories - categories_in_a
{'Bracelet', 'Necklace'}

Part 28: Real Median Revenue by Tier#

abc_table.groupby('Tier')['Revenue'].median().round(2)
Tier
A    5695.86
B    1309.14
C     143.05
Name: Revenue, dtype: float64

Part 29: Real Countries Buying the Top Product#

top_product_code = product_revenue.index[0]
clean[clean['StockCode'] == top_product_code]['Country'].value_counts().head(5)
Country
United Kingdom    1
Name: count, dtype: int64

Part 30: Real Total Revenue Cross-Check#

abc_table['Revenue'].sum().round(0) == clean['Revenue'].sum().round(0)
np.True_

Wrap-Up: What You Learned#

  • When a real dataset has no category field, a real transparent keyword-based scheme can derive one honestly, coverage gaps and all.
  • ABC analysis ranks real products by revenue and splits them into real A, B, and C tiers based on real cumulative contribution.
  • The real Pareto pattern, a small share of products driving most revenue, held up directly against this real dataset's own numbers.
  • Tier A products are genuinely both high-revenue and high-frequency, not just a few real lucky big sales.
  • Real long-tail C-tier products individually contribute very little, informing real decisions about what to keep in stock.
  • Next video: real scraping product prices for competitive analysis, comparing this catalog against real external pricing.

Found this useful?

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