Mathew K Analytics

Lesson 11 · Supply Chain Operations Analytics

Handling Missing and Dirty Operations Data

Learn how to detect, diagnose, and fix missing or dirty values in real operations datasets. Missing and dirty data is a common source of mistakes in supply…

⬇ 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

Handling Missing and Dirty Operations Data#

  • Learn how to detect, diagnose, and fix missing or dirty values in real operations datasets.
  • Missing and dirty data is a common source of mistakes in supply chain analytics.
  • Clean data leads to better forecasting, ordering, fulfillment, and strategic decisions.
  • This lesson teaches you how to identify real-world data problems and apply practical cleaning steps.
  • No fake data - we will use live, public supply chain datasets to practice real analytics skills.
import pandas as pd
import openml
import warnings
warnings.filterwarnings('ignore')

Understanding Operations Data#

  • Operations data includes records of sales, deliveries, inventory, and supplier performance.
  • Data is structured as tables with columns such as order IDs, timestamps, quantities, and customer details.
  • Beginners often do not check for missing values before running analyses.
  • Dirty data can mean wrong date formats, bad numbers, or missing supplier names.
  • Missing or dirty data can ruin demand forecasts or supplier scorecards.
url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/00502/online_retail_II.xlsx'
retail_df = pd.read_excel(url, sheet_name='Year 2010-2011')
retail_df['InvoiceDate'] = pd.to_datetime(retail_df['InvoiceDate'])
print(retail_df.shape)
print(retail_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(retail_df.isnull().sum())
Invoice             0
StockCode           0
Description      1454
Quantity            0
InvoiceDate         0
Price               0
Customer ID    135080
Country             0
dtype: int64
missing_ratio = retail_df.isnull().mean()
print(missing_ratio.sort_values(ascending=False))
Customer ID    0.249266
Description    0.002683
StockCode      0.000000
Invoice        0.000000
Quantity       0.000000
InvoiceDate    0.000000
Price          0.000000
Country        0.000000
dtype: float64
url_orders = 'https://raw.githubusercontent.com/olist/work-at-olist-data/master/datasets/olist_orders_dataset.csv'
orders_df = pd.read_csv(url_orders)
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'])
print(orders_df.shape)
print(orders_df.head(3))
(99441, 8)
                           order_id                       customer_id  \
0  e481f51cbdc54678b7cc49136f2d6af7  9ef432eb6251297304e76186b10a928d   
1  53cdb2fc8bc7dce0b6741e2150273451  b0830fb4747a6c6d20dea0b8c802d7ef   
2  47770eb9100c2d0c44946d9cf07ec65d  41ce2a54c0b03bf3443c3d931a367089   

  order_status order_purchase_timestamp    order_approved_at  \
0    delivered      2017-10-02 10:56:33  2017-10-02 11:07:15   
1    delivered      2018-07-24 20:41:37  2018-07-26 03:24:27   
2    delivered      2018-08-08 08:38:49  2018-08-08 08:55:23   

  order_delivered_carrier_date order_delivered_customer_date  \
0          2017-10-04 19:55:00           2017-10-10 21:25:13   
1          2018-07-26 14:31:00           2018-08-07 15:27:45   
2          2018-08-08 13:50:00           2018-08-17 18:06:29   

  order_estimated_delivery_date  
0           2017-10-18 00:00:00  
1           2018-08-13 00:00:00  
2           2018-09-04 00:00:00  
print(orders_df.isnull().sum())
order_id                            0
customer_id                         0
order_status                        0
order_purchase_timestamp            0
order_approved_at                 160
order_delivered_carrier_date     1783
order_delivered_customer_date    2965
order_estimated_delivery_date       0
dtype: int64
supplier_data = openml.datasets.get_dataset(42125)
supplier_df, _, _, _ = supplier_data.get_data(dataset_format='dataframe')
print(supplier_df.shape)
print(supplier_df.head(3))
(9228, 13)
          full_name gender  current_annual_salary  2016_gross_pay_received  \
0    Aarhus, Pam J.      F               69222.18                 71225.98   
1   Aaron, David J.      M               97392.47                103088.48   
2  Aaron, Marsha M.      F              104717.28                107000.24   

   2016_overtime_pay department                          department_name  \
0             416.10        POL                     Department of Police   
1            3326.19        POL                     Department of Police   
2            1353.32        HHS  Department of Health and Human Services   

                                            division assignment_category  \
0  MSB Information Mgmt and Tech Division Records...    Fulltime-Regular   
1         ISB Major Crimes Division Fugitive Section    Fulltime-Regular   
2      Adult Protective and Case Management Services    Fulltime-Regular   

       employee_position_title underfilled_job_title date_first_hired  \
0  Office Services Coordinator                  None       09/22/1986   
1        Master Police Officer                  None       09/12/1988   
2             Social Worker IV                  None       11/19/1989   

   year_first_hired  
0              1986  
1              1988  
2              1989  
print(supplier_df.isnull().sum())
full_name                     0
gender                       17
current_annual_salary         0
2016_gross_pay_received     100
2016_overtime_pay          2917
department                    0
department_name               0
division                      0
assignment_category           0
employee_position_title       0
underfilled_job_title      8135
date_first_hired              0
year_first_hired              0
dtype: int64
# Drop rows where essential information is missing
cleaned_orders = orders_df.dropna(subset=['order_purchase_timestamp', 'order_delivered_customer_date'])
print(f'Removed {len(orders_df) - len(cleaned_orders)} incomplete rows.')
Removed 2965 incomplete rows.
# Fill missing numerical fields with the median
retail_df['Quantity'] = retail_df['Quantity'].fillna(retail_df['Quantity'].median())
print('Missing Quantity filled with median:', retail_df['Quantity'].isnull().sum() == 0)
Missing Quantity filled with median: True
# Fill missing customer IDs as 'Unknown' in retail data
retail_df['Customer ID'] = retail_df['Customer ID'].fillna('Unknown')
print('All Customer IDs accounted for:', retail_df['Customer ID'].isnull().sum() == 0)
All Customer IDs accounted for: True
# Intermediate: Visualize missing data patterns
import matplotlib.pyplot as plt
import seaborn as sns
sns.heatmap(retail_df.isnull(), cbar=False)
plt.title('Missing Data Heatmap (Retail)')
plt.show()
No description has been provided for this image
# Find how many unique bad entries we have for StockCode
bad_stockcodes = retail_df['StockCode'].isnull().sum() + (retail_df['StockCode'] == '').sum()
print('Bad StockCode entries:', bad_stockcodes)
Bad StockCode entries: 0
# Intermediate: Find outliers or dirty Quantity values
dirty_quantity = retail_df[(retail_df['Quantity'] <= 0) | (retail_df['Quantity'] > 10000)]
print(f'{len(dirty_quantity)} suspicious quantity values found.')
print(dirty_quantity[['Invoice', 'StockCode', 'Quantity']].head())
10627 suspicious quantity values found.
     Invoice StockCode  Quantity
141  C536379         D        -1
154  C536383    35004C        -1
235  C536391     22556       -12
236  C536391     21984       -24
237  C536391     21983       -24
# Intermediate: Validate order and delivery timestamps
bad_time = orders_df[orders_df['order_purchase_timestamp'] > orders_df['order_delivered_customer_date']]
print(f'{len(bad_time)} orders have delivery dates before purchase dates!')
print(bad_time[['order_id', 'order_purchase_timestamp', 'order_delivered_customer_date']])
0 orders have delivery dates before purchase dates!
Empty DataFrame
Columns: [order_id, order_purchase_timestamp, order_delivered_customer_date]
Index: []
# Advanced: Aggregate missing by country in retail data
country_missing = retail_df.groupby('Country')['Customer ID'].apply(lambda x: (x == 'Unknown').mean())
print(country_missing.sort_values(ascending=False).head())
Country
Hong Kong         1.000000
Unspecified       0.452915
United Kingdom    0.269639
Israel            0.158249
Bahrain           0.105263
Name: Customer ID, dtype: float64
# Advanced: Merge orders and products, watch for join errors
url_order_items = 'https://raw.githubusercontent.com/olist/work-at-olist-data/master/datasets/olist_order_items_dataset.csv'
items_df = pd.read_csv(url_order_items)
merged_df = pd.merge(orders_df, items_df, on='order_id', how='left', indicator=True)
bad_joins = merged_df[merged_df['_merge'] != 'both']
print(f'{len(bad_joins)} order rows could not be matched to items!')
print(bad_joins.head())
775 order rows could not be matched to items!
                              order_id                       customer_id  \
306   8e24261a7e58791d10cb1bf9da94df5c  64a254d30eed42cd0e6c36dddb88adf0   
671   c272bcd21c287498b4883c7512019702  9582c5bbecc65eb568e2c1d839b5cba1   
791   37553832a3a89c9b2db59701c357ca67  7607cd563696c27ede287e515812d528   
850   d57e15fb07fd180f06ab3926b39edcd2  470b93b3f1cde85550fc74cd3a476c78   
1294  00b1cb0320190ca0daa2c88b35206009  3532ba38a3fd242259a514ac2b6ae6b6   

     order_status order_purchase_timestamp    order_approved_at  \
306   unavailable      2017-11-16 15:09:28  2017-11-16 15:26:57   
671   unavailable      2018-01-31 11:31:37  2018-01-31 14:23:50   
791   unavailable      2017-08-14 17:38:02  2017-08-17 00:15:18   
850   unavailable      2018-01-08 19:39:03  2018-01-09 07:26:08   
1294     canceled      2018-08-28 15:26:39                  NaN   

     order_delivered_carrier_date order_delivered_customer_date  \
306                           NaN                           NaT   
671                           NaN                           NaT   
791                           NaN                           NaT   
850                           NaN                           NaT   
1294                          NaN                           NaT   

     order_estimated_delivery_date  order_item_id product_id seller_id  \
306            2017-12-05 00:00:00            NaN        NaN       NaN   
671            2018-02-16 00:00:00            NaN        NaN       NaN   
791            2017-09-05 00:00:00            NaN        NaN       NaN   
850            2018-02-06 00:00:00            NaN        NaN       NaN   
1294           2018-09-12 00:00:00            NaN        NaN       NaN   

     shipping_limit_date  price  freight_value     _merge  
306                  NaN    NaN            NaN  left_only  
671                  NaN    NaN            NaN  left_only  
791                  NaN    NaN            NaN  left_only  
850                  NaN    NaN            NaN  left_only  
1294                 NaN    NaN            NaN  left_only  
# Debugging: Investigate why joins failed
missing_items_orders = bad_joins['order_id'].unique()
print('Example unmatched order IDs:', missing_items_orders[:5])
# Find if any key data is missing in these orders
print(orders_df[orders_df['order_id'].isin(missing_items_orders)].head())
Example unmatched order IDs: ['8e24261a7e58791d10cb1bf9da94df5c' 'c272bcd21c287498b4883c7512019702'
 '37553832a3a89c9b2db59701c357ca67' 'd57e15fb07fd180f06ab3926b39edcd2'
 '00b1cb0320190ca0daa2c88b35206009']
                              order_id                       customer_id  \
266   8e24261a7e58791d10cb1bf9da94df5c  64a254d30eed42cd0e6c36dddb88adf0   
586   c272bcd21c287498b4883c7512019702  9582c5bbecc65eb568e2c1d839b5cba1   
687   37553832a3a89c9b2db59701c357ca67  7607cd563696c27ede287e515812d528   
737   d57e15fb07fd180f06ab3926b39edcd2  470b93b3f1cde85550fc74cd3a476c78   
1130  00b1cb0320190ca0daa2c88b35206009  3532ba38a3fd242259a514ac2b6ae6b6   

     order_status order_purchase_timestamp    order_approved_at  \
266   unavailable      2017-11-16 15:09:28  2017-11-16 15:26:57   
586   unavailable      2018-01-31 11:31:37  2018-01-31 14:23:50   
687   unavailable      2017-08-14 17:38:02  2017-08-17 00:15:18   
737   unavailable      2018-01-08 19:39:03  2018-01-09 07:26:08   
1130     canceled      2018-08-28 15:26:39                  NaN   

     order_delivered_carrier_date order_delivered_customer_date  \
266                           NaN                           NaT   
586                           NaN                           NaT   
687                           NaN                           NaT   
737                           NaN                           NaT   
1130                          NaN                           NaT   

     order_estimated_delivery_date  
266            2017-12-05 00:00:00  
586            2018-02-16 00:00:00  
687            2017-09-05 00:00:00  
737            2018-02-06 00:00:00  
1130           2018-09-12 00:00:00  
# Error handling: What if we try to use missing data?
try:
    total_days = (orders_df['order_delivered_customer_date'] - orders_df['order_purchase_timestamp']).dt.days
    print('Min delivery time:', total_days.min())
except Exception as e:
    print('Error encountered:', e)
Min delivery time: 0.0
# Fill missing or negative delivery dates with a default lag
orders_df['order_delivered_customer_date'] = orders_df['order_delivered_customer_date'].fillna(orders_df['order_purchase_timestamp'] + pd.Timedelta(days=5))
orders_df.loc[orders_df['order_delivered_customer_date'] < orders_df['order_purchase_timestamp'], 'order_delivered_customer_date'] = orders_df['order_purchase_timestamp'] + pd.Timedelta(days=2)
print('Missing and backwards delivery dates now handled.')
Missing and backwards delivery dates now handled.
# Best practice: Calculate fulfillment lead time and flag late orders
orders_df['lead_time_days'] = (orders_df['order_delivered_customer_date'] - orders_df['order_purchase_timestamp']).dt.days
late_threshold = 7
orders_df['is_late'] = orders_df['lead_time_days'] > late_threshold
late_orders = orders_df['is_late'].mean()
print(f'{late_orders*100:.1f}% of orders are late (>{late_threshold} days).')
63.1% of orders are late (>7 days).
# Aggregate: Calculate total orders per country (retail example)
country_counts = retail_df.groupby('Country')['Invoice'].count().reset_index()
country_counts = country_counts.sort_values('Invoice', ascending=False)
print(country_counts.head())
           Country  Invoice
36  United Kingdom   495478
14         Germany     9495
13          France     8558
10            EIRE     8196
31           Spain     2533
# Time series group: Order volume by month
retail_df['InvoiceMonth'] = retail_df['InvoiceDate'].dt.to_period('M')
monthly_orders = retail_df.groupby('InvoiceMonth')['Invoice'].count()
print(monthly_orders.tail())
InvoiceMonth
2011-08    35284
2011-09    50226
2011-10    60742
2011-11    84711
2011-12    25526
Freq: M, Name: Invoice, dtype: int64
# END-TO-END: Identify all orders for a specific country and their late delivery rate
country = 'United Kingdom'
uk_orders = pd.merge(orders_df, retail_df[retail_df['Country'] == country], left_on='customer_id', right_on='Customer ID', how='inner')
late_uk = uk_orders['is_late'].mean() if 'is_late' in uk_orders else 0
print(f'UK orders late rate: {late_uk*100:.1f}%')
UK orders late rate: nan%

Congratulations! You can now handle missing and dirty data for real operational analytics.#

  • Try the cleaning techniques here on your own supply chain or operations data.
  • Ready for more? Search YouTube for "operations analytics missing data" and expand your toolkit.
  • Clean data is the foundation of reliable supply chain decisions.

Found this useful?

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