Mathew K Analytics

Lesson 9 · Supply Chain Operations Analytics

Inventory Level Tracking with Pandas

Tracking inventory is a foundation of supply chain and operations analytics. Inventory data shows what products are in stock, what has been sold, and what…

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

Inventory Level Tracking with Pandas#

  • Tracking inventory is a foundation of supply chain and operations analytics.
  • Inventory data shows what products are in stock, what has been sold, and what needs to be reordered.
  • Mistakes in inventory tracking lead to lost sales, excess costs, and unhappy customers.
  • In this lesson, you will learn how to analyze current and past inventory levels using real retail and manufacturing datasets.
  • You will gain practical skills in loading, shaping, and visualizing inventory data with pandas.
  • By the end, you will be able to build key metrics, plot trends, and spot issues in real supply chain data.
import pandas as pd
import numpy as np
import warnings
warnings.filterwarnings('ignore')

Understanding Inventory Data in Supply Chains#

  • Operational datasets may record sales, deliveries, returns, and inventory-on-hand.
  • Each row can represent a transaction or a snapshot.
  • Key columns often include product ID, date, quantity, and transaction type.
  • Mix-ups between transaction and stock balance tables are common beginner errors.
  • Forgetting time zones and date formats can lead to wrong stock calculations.
  • Learning to join, group, and aggregate data is essential in operational analytics.
# Load REAL retail demand data from UCI Online Retail II
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  
# Calculate daily item sales volume
sales_volume = df.groupby(['InvoiceDate', 'StockCode'])['Quantity'].sum().reset_index()
print(sales_volume.head())
          InvoiceDate StockCode  Quantity
0 2010-12-01 08:26:00     21730         6
1 2010-12-01 08:26:00     22752         2
2 2010-12-01 08:26:00     71053         6
3 2010-12-01 08:26:00    84029E         6
4 2010-12-01 08:26:00    84029G         6
# Plot inventory activity for a single item
import matplotlib.pyplot as plt
item_code = sales_volume['StockCode'].iloc[0]
item_sales = sales_volume[sales_volume['StockCode'] == item_code]
plt.figure(figsize=(10,4))
plt.plot(item_sales['InvoiceDate'], item_sales['Quantity'], marker='o')
plt.title(f'Daily Quantity for Item {item_code}')
plt.xlabel('Date')
plt.ylabel('Net Quantity Sold')
plt.grid(True)
plt.show()
No description has been provided for this image
# Compute cumulative inventory change for an example item
item_sales = item_sales.sort_values('InvoiceDate')
item_sales['cum_qty'] = item_sales['Quantity'].cumsum()
plt.figure(figsize=(10,4))
plt.step(item_sales['InvoiceDate'], item_sales['cum_qty'], where='mid')
plt.title(f'Cumulative Inventory Change: Item {item_code}')
plt.xlabel('Date')
plt.ylabel('Cumulative Net Units')
plt.grid(True)
plt.show()
No description has been provided for this image
# Beginner example: Identify stockouts (quantity reaches zero or negative)
item_sales['stockout'] = item_sales['cum_qty'] <= 0
print(item_sales[item_sales['stockout']][['InvoiceDate', 'cum_qty']])
Empty DataFrame
Columns: [InvoiceDate, cum_qty]
Index: []
# Beginner: What is the most commonly sold item by transaction count?
top_item = df['StockCode'].value_counts().idxmax()
print(f'Most common StockCode: {top_item}')
Most common StockCode: 85123A
# Beginner: Identify returns (negative Quantity)
returns = df[df['Quantity'] < 0]
print(returns[['InvoiceDate', 'StockCode', 'Quantity']].head())
            InvoiceDate StockCode  Quantity
141 2010-12-01 09:41:00         D        -1
154 2010-12-01 09:49:00    35004C        -1
235 2010-12-01 10:24:00     22556       -12
236 2010-12-01 10:24:00     21984       -24
237 2010-12-01 10:24:00     21983       -24
# Intermediate: Track inventory changes for multiple SKUs over time
sku_subset = df['StockCode'].unique()[:3]
multi_items = df[df['StockCode'].isin(sku_subset)]
multi_sales = multi_items.groupby(['InvoiceDate', 'StockCode'])['Quantity'].sum().reset_index()
multi_sales['cum_qty'] = multi_sales.groupby('StockCode')['Quantity'].cumsum()
for code in sku_subset:
    plt.plot(multi_sales[multi_sales['StockCode'] == code]['InvoiceDate'],
             multi_sales[multi_sales['StockCode'] == code]['cum_qty'], label=f'StockCode {code}')
plt.legend()
plt.title('Multi-SKU Cumulative Inventory Change')
plt.xlabel('Date')
plt.ylabel('Cumulative Net Units')
plt.show()
No description has been provided for this image
# Intermediate: Aggregate net inventory change by week
df['Week'] = df['InvoiceDate'].dt.to_period('W').apply(lambda r: r.start_time)
weekly_inv = df.groupby(['Week', 'StockCode'])['Quantity'].sum().reset_index()
weekly_inv['cum_qty'] = weekly_inv.groupby('StockCode')['Quantity'].cumsum()
print(weekly_inv.head())
        Week StockCode  Quantity  cum_qty
0 2010-11-29     10002        70       70
1 2010-11-29     10120         3        3
2 2010-11-29     10125         2        2
3 2010-11-29     10133        20       20
4 2010-11-29     10135        23       23
# Intermediate: Check for data gaps (missing transaction dates)
all_dates = pd.date_range(df['InvoiceDate'].min(), df['InvoiceDate'].max())
dates_present = pd.to_datetime(df['InvoiceDate'].dt.date.unique())
missing_dates = set(all_dates.date) - set(dates_present)
print(f'Missing transaction dates: {sorted(list(missing_dates))[:5]} ...')
Missing transaction dates: [datetime.date(2010, 12, 1), datetime.date(2010, 12, 2), datetime.date(2010, 12, 3), datetime.date(2010, 12, 4), datetime.date(2010, 12, 5)] ...
# Advanced: Use OpenML operational dataset for additional inventory patterns
import openml
dataset = openml.datasets.get_dataset(43900)
X, y, _, _ = dataset.get_data(dataset_format='dataframe')
manufacturing_df = X.copy() if y is None else pd.concat([X, y], axis=1)
print(manufacturing_df.shape)
print(manufacturing_df.head(3))
(32769, 10)
  ACTION RESOURCE MGR_ID ROLE_ROLLUP_1 ROLE_ROLLUP_2 ROLE_DEPTNAME ROLE_TITLE  \
0      1    39353  85475        117961        118300        123472     117905   
1      1    17183   1540        117961        118343        123125     118536   
2      1    36724  14457        118219        118220        117884     117879   

  ROLE_FAMILY_DESC ROLE_FAMILY ROLE_CODE  
0           117906      290919    117908  
1           118536      308574    118539  
2           267952       19721    117880  
# Advanced: Detect negative inventory in OpenML manufacturing data (if present)
if 'inventory' in manufacturing_df.columns:
    negatives = manufacturing_df[manufacturing_df['inventory'] < 0]
    print(f'Negative inventory events: {len(negatives)}')
    print(negatives.head())
else:
    print('No inventory column available in this manufacturing dataset.')
No inventory column available in this manufacturing dataset.
# Advanced: Merge retail transactions with product information (simulated lookup)
if 'Description' in df.columns:
    items_info = df[['StockCode', 'Description']].drop_duplicates()
    merged = pd.merge(sales_volume, items_info, on='StockCode', how='left')
    print(merged.head())
else:
    print('No Description column found.')
          InvoiceDate StockCode  Quantity                        Description
0 2010-12-01 08:26:00     21730         6  GLASS STAR FROSTED T-LIGHT HOLDER
1 2010-12-01 08:26:00     22752         2       SET 7 BABUSHKA NESTING BOXES
2 2010-12-01 08:26:00     71053         6                WHITE METAL LANTERN
3 2010-12-01 08:26:00     71053         6       WHITE MOROCCAN METAL LANTERN
4 2010-12-01 08:26:00    84029E         6     RED WOOLLY HOTTIE WHITE HEART.
# Error handling: Missing quantities
missing_qty = df[df['Quantity'].isnull()]
print(f'Rows with missing Quantity: {len(missing_qty)}')
Rows with missing Quantity: 0
# Error handling: Incorrect join keys (simulate a mismatch)
bad_lookup = items_info.rename(columns={'StockCode': 'SKU'})
merged_bad = pd.merge(sales_volume, bad_lookup, left_on='StockCode', right_on='SKU', how='left')
nulls = merged_bad['Description'].isnull().sum()
print(f'Rows with missing Description after join: {nulls}')
Rows with missing Description after join: 82611
# Error handling: Misinterpreting negative quantities as positive movements
mistake = df.copy()
mistake['fixed_qty'] = mistake['Quantity'].abs()
pos_sum = mistake[mistake['Quantity'] > 0]['fixed_qty'].sum()
neg_sum = mistake[mistake['Quantity'] < 0]['fixed_qty'].sum()
print(f'Wrongly treating all movement as positive: {pos_sum + neg_sum}')
Wrongly treating all movement as positive: 6145513
# Best practice: Use time series aggregation to spot seasonal trends
monthly_qty = df.groupby(df['InvoiceDate'].dt.to_period('M'))['Quantity'].sum()
monthly_qty.plot(kind='bar', figsize=(12,6), title='Monthly Net Inventory Change')
plt.xlabel('Month')
plt.ylabel('Net Units Sold (or Returned)')
plt.tight_layout()
plt.show()
No description has been provided for this image
# Best practice: Calculate inventory turnover KPI
total_units_sold = df[df['Quantity'] > 0]['Quantity'].sum()
avg_inventory = sales_volume['Quantity'].mean()
turnover = total_units_sold / avg_inventory if avg_inventory != 0 else np.nan
print(f'Inventory Turnover Ratio: {turnover:.2f}')
Inventory Turnover Ratio: 578386.91
# End-to-end: Build daily inventory position for one SKU
item = top_item
sku_hist = df[df['StockCode'] == item]
date_range = pd.date_range(sku_hist['InvoiceDate'].min(), sku_hist['InvoiceDate'].max())
pos = sku_hist.groupby(sku_hist['InvoiceDate'].dt.date)['Quantity'].sum().reindex(date_range.date, fill_value=0).cumsum()
inventory_df = pd.DataFrame({'Date': date_range.date, 'InventoryPosition': pos.values})
print(inventory_df.head())
         Date  InventoryPosition
0  2010-12-01                454
1  2010-12-02                763
2  2010-12-03                788
3  2010-12-04                788
4  2010-12-05                986
# Write the SKU inventory position to a CSV so it can be visualized or shared
csvfile = 'sku_inventory_position.csv'
inventory_df.to_csv(csvfile, index=False)
print(f'File saved: {csvfile}')
File saved: sku_inventory_position.csv

Lesson Summary: Key Patterns#

  • Inventory level tracking supports efficient supply chain operations.
  • Use grouping and cumsum to track running inventory changes.
  • Always keep signs (+/-) and join keys clear in analytics.
  • Check for missing data and spot outliers such as negative stock.
  • Use aggregation for weekly/monthly monitoring and KPIs.
  • Save results for sharing in the wider operations team.

Found this useful?

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