Mathew K Analytics

Lesson 27 · Supply Chain Operations Analytics

ABC Inventory Classification in Supply Chain Analytics

We will learn how to use ABC analysis to classify inventory items based on sales and demand impact. This technique helps businesses identify their most…

⬇ 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

ABC Inventory Classification in Supply Chain Analytics#

  • We will learn how to use ABC analysis to classify inventory items based on sales and demand impact.
  • This technique helps businesses identify their most valuable inventory and optimize control methods.
  • You will load real retail sales data, compute sales value, classify items, and extract actionable business insights.
  • ABC classification drives better purchasing, forecasting, and stocking strategies in real-world supply chains.
import pandas as pd
import numpy as np
import warnings
warnings.filterwarnings('ignore')

Understanding the Data for ABC Analysis#

  • We will work with real retail transaction data containing product codes, quantities, and sales values.
  • Each row logs a transaction with an item, customer, amount, and date.
  • Common supply chain mistakes include ignoring returns, misreading date columns, or double-counting cancelled transactions.
  • Accurate ABC analysis relies on clean, complete, and deduplicated sales records.
url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/00502/online_retail_II.xlsx'
df = pd.read_excel(url, sheet_name='Year 2010-2011')
df['InvoiceDate'] = pd.to_datetime(df['InvoiceDate'])
print(df.shape)
print(df.head(3))
(541910, 8)
  Invoice StockCode                         Description  Quantity  \
0  536365    85123A  WHITE HANGING HEART T-LIGHT HOLDER         6   
1  536365     71053                 WHITE METAL LANTERN         6   
2  536365    84406B      CREAM CUPID HEARTS COAT HANGER         8   

          InvoiceDate  Price  Customer ID         Country  
0 2010-12-01 08:26:00   2.55      17850.0  United Kingdom  
1 2010-12-01 08:26:00   3.39      17850.0  United Kingdom  
2 2010-12-01 08:26:00   2.75      17850.0  United Kingdom  
print('Unique product codes:', df['StockCode'].nunique())
print('Number of countries:', df['Country'].nunique())
Unique product codes: 4070
Number of countries: 38
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 541910 entries, 0 to 541909
Data columns (total 8 columns):
 #   Column       Non-Null Count   Dtype         
---  ------       --------------   -----         
 0   Invoice      541910 non-null  object        
 1   StockCode    541910 non-null  object        
 2   Description  540456 non-null  object        
 3   Quantity     541910 non-null  int64         
 4   InvoiceDate  541910 non-null  datetime64[ns]
 5   Price        541910 non-null  float64       
 6   Customer ID  406830 non-null  float64       
 7   Country      541910 non-null  object        
dtypes: datetime64[ns](1), float64(2), int64(1), object(4)
memory usage: 33.1+ MB
df = df[df['Quantity'] > 0]
df = df[df['Invoice'].apply(lambda x: not str(x).startswith('C'))]
print('Shape after removing returns:', df.shape)
Shape after removing returns: (531286, 8)
# Compute total sales per product
df['TotalSales'] = df['Quantity'] * df['Price']
sales_per_item = df.groupby('StockCode')['TotalSales'].sum().sort_values(ascending=False)
print(sales_per_item.head(10))
StockCode
DOT       206248.77
22423     174484.74
23843     168469.60
85123A    104518.80
47566      99504.33
85099B     94340.05
23166      81700.92
POST       78119.88
M          78110.27
23084      66964.99
Name: TotalSales, dtype: float64
# Calculate cumulative sales percentage
total_sales = sales_per_item.sum()
cum_sales = sales_per_item.cumsum()
cum_perc = cum_sales / total_sales
sales_abc = pd.DataFrame({'TotalSales': sales_per_item, 'CumPerc': cum_perc})
print(sales_abc.head(10))
           TotalSales   CumPerc
StockCode                      
DOT         206248.77  0.019376
22423       174484.74  0.035768
23843       168469.60  0.051595
85123A      104518.80  0.061414
47566        99504.33  0.070761
85099B       94340.05  0.079624
23166        81700.92  0.087300
POST         78119.88  0.094639
M            78110.27  0.101977
23084        66964.99  0.108268
# Assign ABC category
def get_abc_class(cum_perc):
    if cum_perc <= 0.8:
        return 'A'
    elif cum_perc <= 0.95:
        return 'B'
    else:
        return 'C'
sales_abc['ABC_Class'] = sales_abc['CumPerc'].apply(get_abc_class)
print(sales_abc['ABC_Class'].value_counts())
ABC_Class
C    2168
B     973
A     800
Name: count, dtype: int64
# Add ABC back to original cleaned product master
abc_master = sales_abc.reset_index()
abc_master = abc_master[['StockCode', 'ABC_Class']]
df = df.merge(abc_master, on='StockCode', how='left')
print(df[['StockCode', 'ABC_Class']].drop_duplicates().head(10))
  StockCode ABC_Class
0    85123A         A
1     71053         A
2    84406B         A
3    84029G         A
4    84029E         A
5     22752         A
6     21730         B
7     22633         A
8     22632         A
9     22960         A
# Example: Analyze sales by ABC class for a selected country
selected_country = 'United Kingdom'
country_sales = df[df['Country'] == selected_country].groupby('ABC_Class')['TotalSales'].sum()
print(country_sales)
ABC_Class
A    7200924.030
B    1338824.790
C     463349.144
Name: TotalSales, dtype: float64
# Beginner: Count distinct products per ABC class
distinct_counts = df[['StockCode', 'ABC_Class']].drop_duplicates().groupby('ABC_Class').count()
print(distinct_counts.rename(columns={'StockCode':'ProductCount'}))
           ProductCount
ABC_Class              
A                   800
B                   973
C                  2168
# Beginner: Average order size for each ABC class
avg_order_size = df.groupby('ABC_Class')['Quantity'].mean()
print(avg_order_size)
ABC_Class
A    11.672454
B     9.477486
C     8.323228
Name: Quantity, dtype: float64
# Intermediate: Monthly sales by ABC class
df['YearMonth'] = df['InvoiceDate'].dt.to_period('M')
monthly_sales = df.groupby(['YearMonth', 'ABC_Class'])['TotalSales'].sum().unstack()
print(monthly_sales.tail(6))
ABC_Class           A          B          C
YearMonth                                  
2011-07     583743.89   97520.63  37956.671
2011-08     613324.43  102789.11  20900.720
2011-09     865223.96  150832.37  42533.842
2011-10     895008.09  207932.70  52038.510
2011-11    1217381.19  226160.93  65954.210
2011-12     543998.86   69979.16  24832.660
# Intermediate: Plot cumulative distribution for ABC analysis
import matplotlib.pyplot as plt
plt.figure(figsize=(8,5))
plt.plot(sales_abc['CumPerc'].values, marker='o')
plt.xlabel('Ranked Products')
plt.ylabel('Cumulative Sales %')
plt.title('ABC Distribution Curve')
plt.grid(True)
plt.show()
No description has been provided for this image
# Intermediate: Which items changed ABC class between periods?
month1 = df[df['YearMonth']=='2010-12']
month2 = df[df['YearMonth']=='2011-01']
def abc_by_period(subdf):
    tp = subdf.groupby('StockCode')['TotalSales'].sum().sort_values(ascending=False)
    total = tp.sum()
    cum = tp.cumsum()/total
    return pd.DataFrame({'StockCode': tp.index, 'ABC': cum.apply(get_abc_class)})
abc1 = abc_by_period(month1).set_index('StockCode')
abc2 = abc_by_period(month2).set_index('StockCode')
abc_compare = abc1.join(abc2, lsuffix='_2010_12', rsuffix='_2011_01', how='inner')
changed = abc_compare[abc_compare['ABC_2010_12'] != abc_compare['ABC_2011_01']]
print('Number of items with class change:', len(changed))
print(changed.head())
Number of items with class change: 847
          ABC_2010_12 ABC_2011_01
StockCode                        
84029E              A           B
22086               A           B
22114               A           C
21623               A           B
84347               A           B
# Advanced: Link ABC class to fulfillment time using Olist order delivery data
del_url = 'https://raw.githubusercontent.com/olist/work-at-olist-data/master/datasets/olist_orders_dataset.csv'
orders_df = pd.read_csv(del_url)
orders_df['order_purchase_timestamp'] = pd.to_datetime(orders_df['order_purchase_timestamp'])
orders_df['order_delivered_customer_date'] = pd.to_datetime(orders_df['order_delivered_customer_date'])
orders_df['delivery_leadtime'] = (orders_df['order_delivered_customer_date'] - orders_df['order_purchase_timestamp']).dt.days
print(orders_df[['order_id','delivery_leadtime']].head())
                           order_id  delivery_leadtime
0  e481f51cbdc54678b7cc49136f2d6af7                8.0
1  53cdb2fc8bc7dce0b6741e2150273451               13.0
2  47770eb9100c2d0c44946d9cf07ec65d                9.0
3  949d5b44dbf5de918fe9c16f97b45f8a               13.0
4  ad21c59c0840e6cb83a9ceb5573f8159                2.0
# Advanced: Export ABC classified product list for further operational planning
export_cols = ['StockCode', 'ABC_Class', 'Country']
abc_master_wide = df[export_cols].drop_duplicates()
abc_master_wide.to_csv('abc_classified_items.csv', index=False)
print('Exported to abc_classified_items.csv')
Exported to abc_classified_items.csv
# Error example: Missing dates in InvoiceDate
df_with_missing = df.copy()
df_with_missing.loc[df_with_missing.sample(frac=0.01, random_state=42).index, 'InvoiceDate'] = pd.NaT
missing_count = df_with_missing['InvoiceDate'].isna().sum()
print(f'Rows with missing InvoiceDate: {missing_count}')
try:
    df_with_missing['YearMonth'] = df_with_missing['InvoiceDate'].dt.to_period('M')
except Exception as e:
    print('Error:', e)
Rows with missing InvoiceDate: 5313
# Error example: Incorrect join (merge) on product codes with spaces
df_err = df.copy()
df_err['StockCode'] = df_err['StockCode'].astype(str) + ' '
try:
    df_err = df_err.merge(abc_master, on='StockCode', how='left', suffixes=('', '_abc'))
    print('Merge successful:', True)
except Exception as e:
    print('Merge failed:', e)
missing_after_merge = df_err['ABC_Class'].isna().sum()
print('Rows with missing ABC class after incorrect join:', missing_after_merge)
Merge successful: True
Rows with missing ABC class after incorrect join: 0
# Error example: Misinterpreting lead time or item quantities
false_df = df.copy()
false_df['Quantity'] = -false_df['Quantity']
false_df['TotalSales'] = false_df['Quantity'] * false_df['Price']
negative_sales = false_df[false_df['TotalSales'] < 0].shape[0]
print(f'Negative total sales rows: {negative_sales}')
Negative total sales rows: 530105

Best Practices for ABC Inventory Analytics#

  • Remove returns, handle missing and invalid data before any classification.
  • Aggregate at the level needed by the business problem: monthly, country, or warehouse.
  • Always test merges and classification logic using value counts and cross-tabulation.
  • Use plots to communicate skew and action priorities to operations managers.
  • Include ABC class in master data for order routing, supplier negotiation, and reporting.
# Operational pattern: Time-based ABC class count as a simple KPI
abc_time_kpi = df.groupby(['YearMonth', 'ABC_Class'])['StockCode'].nunique().unstack()
print(abc_time_kpi.tail())
ABC_Class    A    B     C
YearMonth                
2011-08    743  831  1023
2011-09    757  874  1110
2011-10    777  896  1197
2011-11    773  890  1283
2011-12    738  800   938
# Complete end-to-end supply chain example: Alert when A-class items are out of stock
stock_levels = df.groupby('StockCode')['Quantity'].sum()
a_class_codes = abc_master[abc_master['ABC_Class']=='A']['StockCode']
a_stock = stock_levels[a_class_codes]
low_stock = a_stock[a_stock <= 10]
print('Number of A-class items below threshold:', low_stock.shape[0])
print('A-class items at risk of stock-out:')
print(low_stock)
Number of A-class items below threshold: 1
A-class items at risk of stock-out:
StockCode
AMAZONFEE    2
Name: Quantity, dtype: int64
 

Found this useful?

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