Mathew K Analytics

Lesson 1 · Real-World Data Analytics

Python Data Analytics #01: Acquiring & Auditing a Real Retail Dataset (Pandas Tutorial)

Video one of a hundred-video real-world data analytics series. The real dataset: over half a million actual transaction line items from a real UK-based…

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 1: Acquiring and Auditing a Real Retail Transactions Dataset#

  • Video one of a hundred-video real-world data analytics series.
  • The real dataset: over half a million actual transaction line items from a real UK-based online gift retailer.
  • Before any analysis, a real analyst always audits the data first. Let's get into it.

Part 1: Where This Real Data Actually Comes From#

import pandas as pd
df = pd.read_csv('online_retail.csv', parse_dates=['InvoiceDate'])
df.shape
(541909, 8)

Part 2: The Real Columns and Their Types#

df.columns.tolist()
df.dtypes
InvoiceNo              object
StockCode              object
Description            object
Quantity                int64
InvoiceDate    datetime64[ns]
UnitPrice             float64
CustomerID            float64
Country                object
dtype: object

Part 3: A Real First Look at the Rows#

df.head(10)
InvoiceNo StockCode Description Quantity InvoiceDate UnitPrice CustomerID Country
0 536365 85123A WHITE HANGING HEART T-LIGHT HOLDER 6 2010-12-01 08:26:00 2.55 17850.0 United Kingdom
1 536365 71053 WHITE METAL LANTERN 6 2010-12-01 08:26:00 3.39 17850.0 United Kingdom
2 536365 84406B CREAM CUPID HEARTS COAT HANGER 8 2010-12-01 08:26:00 2.75 17850.0 United Kingdom
3 536365 84029G KNITTED UNION FLAG HOT WATER BOTTLE 6 2010-12-01 08:26:00 3.39 17850.0 United Kingdom
4 536365 84029E RED WOOLLY HOTTIE WHITE HEART. 6 2010-12-01 08:26:00 3.39 17850.0 United Kingdom
5 536365 22752 SET 7 BABUSHKA NESTING BOXES 2 2010-12-01 08:26:00 7.65 17850.0 United Kingdom
6 536365 21730 GLASS STAR FROSTED T-LIGHT HOLDER 6 2010-12-01 08:26:00 4.25 17850.0 United Kingdom
7 536366 22633 HAND WARMER UNION JACK 6 2010-12-01 08:28:00 1.85 17850.0 United Kingdom
8 536366 22632 HAND WARMER RED POLKA DOT 6 2010-12-01 08:28:00 1.85 17850.0 United Kingdom
9 536367 84879 ASSORTED COLOUR BIRD ORNAMENT 32 2010-12-01 08:34:00 1.69 13047.0 United Kingdom

Part 4: Auditing Real Missing Values#

df.isna().sum()
df.isna().mean().round(3) * 100
InvoiceNo       0.0
StockCode       0.0
Description     0.3
Quantity        0.0
InvoiceDate     0.0
UnitPrice       0.0
CustomerID     24.9
Country         0.0
dtype: float64

Part 5: Investigating the Real Missing Customer IDs#

missing_cust = df[df['CustomerID'].isna()]
missing_cust.shape[0]
missing_cust['Country'].value_counts().head()
Country
United Kingdom    133600
EIRE                 711
Hong Kong            288
Unspecified          202
Switzerland          125
Name: count, dtype: int64

Part 6: Real Cancelled Invoices#

is_cancelled = df['InvoiceNo'].astype(str).str.startswith('C')
is_cancelled.sum()
df[is_cancelled].head()
InvoiceNo StockCode Description Quantity InvoiceDate UnitPrice CustomerID Country
141 C536379 D Discount -1 2010-12-01 09:41:00 27.50 14527.0 United Kingdom
154 C536383 35004C SET OF 3 COLOURED FLYING DUCKS -1 2010-12-01 09:49:00 4.65 15311.0 United Kingdom
235 C536391 22556 PLASTERS IN TIN CIRCUS PARADE -12 2010-12-01 10:24:00 1.65 17548.0 United Kingdom
236 C536391 21984 PACK OF 12 PINK PAISLEY TISSUES -24 2010-12-01 10:24:00 0.29 17548.0 United Kingdom
237 C536391 21983 PACK OF 12 BLUE PAISLEY TISSUES -24 2010-12-01 10:24:00 0.29 17548.0 United Kingdom

Part 7: Real Negative Quantities#

(df['Quantity'] < 0).sum()
(df.loc[df['Quantity'] < 0, 'InvoiceNo'].astype(str).str.startswith('C')).mean().round(3) * 100
np.float64(87.4)

Part 8: Real Zero and Negative Prices#

(df['UnitPrice'] <= 0).sum()
df.loc[df['UnitPrice'] <= 0, 'Description'].value_counts().head(10)
Description
check                     159
?                          47
damages                    45
damaged                    43
found                      25
sold as set on dotcom      20
adjustment                 16
Damaged                    14
thrown away                 9
Unsaleable, destroyed.      9
Name: count, dtype: int64

Part 9: Real Duplicate Rows#

df.duplicated().sum()
df[df.duplicated(keep=False)].sort_values('InvoiceNo').head(6)
InvoiceNo StockCode Description Quantity InvoiceDate UnitPrice CustomerID Country
485 536409 22111 SCOTTIE DOG HOT WATER BOTTLE 1 2010-12-01 11:45:00 4.95 17908.0 United Kingdom
489 536409 22866 HAND WARMER SCOTTY DOG DESIGN 1 2010-12-01 11:45:00 2.10 17908.0 United Kingdom
494 536409 21866 UNION JACK FLAG LUGGAGE TAG 1 2010-12-01 11:45:00 1.25 17908.0 United Kingdom
517 536409 21866 UNION JACK FLAG LUGGAGE TAG 1 2010-12-01 11:45:00 1.25 17908.0 United Kingdom
521 536409 22900 SET 2 TEA TOWELS I LOVE LONDON 1 2010-12-01 11:45:00 2.95 17908.0 United Kingdom
527 536409 22866 HAND WARMER SCOTTY DOG DESIGN 1 2010-12-01 11:45:00 2.10 17908.0 United Kingdom

Part 10: Real Non-Product Stock Codes#

non_numeric = df[~df['StockCode'].astype(str).str[0].str.isdigit()]
non_numeric['StockCode'].value_counts().head(10)
non_numeric.shape[0]
2995

Part 11: Real Country Distribution#

df['Country'].nunique()
df['Country'].value_counts().head(10)
(df['Country'] == 'United Kingdom').mean().round(3) * 100
np.float64(91.4)

Part 12: The Real Date Range#

df['InvoiceDate'].min(), df['InvoiceDate'].max()
df.set_index('InvoiceDate').resample('ME').size()
InvoiceDate
2010-12-31    42481
2011-01-31    35147
2011-02-28    27707
2011-03-31    36748
2011-04-30    29916
2011-05-31    37030
2011-06-30    36874
2011-07-31    39518
2011-08-31    35284
2011-09-30    50226
2011-10-31    60742
2011-11-30    84711
2011-12-31    25525
Freq: ME, dtype: int64

Part 13: The Naive Revenue Trap#

naive_revenue = (df['Quantity'] * df['UnitPrice']).sum()
round(naive_revenue, 2)
cancelled_revenue = (df.loc[is_cancelled, 'Quantity'] * df.loc[is_cancelled, 'UnitPrice']).sum()
round(cancelled_revenue, 2)
np.float64(-896812.49)

Part 14: Building a Real Cleaning Function#

def clean_retail(raw):
    out = raw.copy()
    out = out[~out['InvoiceNo'].astype(str).str.startswith('C')]
    out = out[out['Quantity'] > 0]
    out = out[out['UnitPrice'] > 0]
    out = out[out['StockCode'].astype(str).str[0].str.isdigit()]
    out = out.dropna(subset=['CustomerID'])
    out = out.drop_duplicates()
    out['CustomerID'] = out['CustomerID'].astype(int)
    out['Revenue'] = out['Quantity'] * out['UnitPrice']
    return out.reset_index(drop=True)

Part 15: Applying It to the Real Data#

clean = clean_retail(df)
clean.shape
round(100 * (1 - clean.shape[0] / df.shape[0]), 2)
27.82
clean_revenue = clean['Revenue'].sum()
round(clean_revenue, 2)
round(naive_revenue - clean_revenue, 2)
np.float64(1010520.29)

Part 16: Real Top Products, Now That the Data Is Trustworthy#

clean.groupby('Description')['Revenue'].sum().sort_values(ascending=False).head(10)
Description
PAPER CRAFT , LITTLE BIRDIE           168469.60
REGENCY CAKESTAND 3 TIER              142264.75
WHITE HANGING HEART T-LIGHT HOLDER    100392.10
JUMBO BAG RED RETROSPOT                85040.54
MEDIUM CERAMIC TOP STORAGE JAR         81416.73
PARTY BUNTING                          68785.23
ASSORTED COLOUR BIRD ORNAMENT          56413.03
RABBIT NIGHT LIGHT                     51251.24
CHILLI LIGHTS                          46265.11
PAPER CHAIN KIT 50'S CHRISTMAS         42584.13
Name: Revenue, dtype: float64
clean.groupby('CustomerID')['Revenue'].sum().sort_values(ascending=False).head(10)
CustomerID
14646    279138.02
18102    259657.30
17450    194390.79
16446    168472.50
14911    136161.83
12415    124564.53
14156    116560.08
17511     91062.38
12346     77183.60
16029     72708.09
Name: Revenue, dtype: float64

Part 17: The Real Scope Now on the Table#

clean['CustomerID'].nunique()
clean['InvoiceNo'].nunique()
clean['InvoiceDate'].min(), clean['InvoiceDate'].max()
(Timestamp('2010-12-01 08:26:00'), Timestamp('2011-12-09 12:50:00'))

Part 18: Saving the Real Cleaned Dataset#

clean.to_csv('online_retail_clean.csv', index=False)
reloaded = pd.read_csv('online_retail_clean.csv', parse_dates=['InvoiceDate'])
reloaded.shape == clean.shape
True

Part 19: One Real Chart Before We Wrap Up#

import matplotlib.pyplot as plt
top_countries = clean['Country'].value_counts().head(10)
plt.figure(figsize=(9, 5))
plt.bar(top_countries.index, top_countries.values, color='steelblue')
plt.xticks(rotation=45, ha='right')
plt.title('Real Cleaned Transaction Rows by Country')
plt.tight_layout()
plt.savefig('retail_top_countries.png', dpi=120)
plt.close()

Part 20: Real Basket Size per Invoice#

basket_size = clean.groupby('InvoiceNo').size()
basket_size.describe()
basket_size.sort_values(ascending=False).head(5)
InvoiceNo
576339    541
579196    532
580727    528
578270    441
573576    434
dtype: int64

Part 21: Real Repeat vs One-Time Customers#

orders_per_customer = clean.groupby('CustomerID')['InvoiceNo'].nunique()
(orders_per_customer == 1).sum()
(orders_per_customer > 1).sum()
round(100 * (orders_per_customer > 1).mean(), 2)
np.float64(65.27)

Part 22: Real StockCode-to-Description Consistency#

desc_per_code = clean.groupby('StockCode')['Description'].nunique()
(desc_per_code > 1).sum()
desc_per_code.sort_values(ascending=False).head(5)
clean.loc[clean['StockCode'] == desc_per_code.idxmax(), 'Description'].unique()
array(['RETRO LEAVES MAGNETIC NOTEPAD',
       'RETO LEAVES MAGNETIC SHOPPING LIST',
       'LEAVES MAGNETIC  SHOPPING LIST', 'VINTAGE LEAF MAGNETIC NOTEPAD'],
      dtype=object)

Part 23: Real Average Order Value#

order_value = clean.groupby('InvoiceNo')['Revenue'].sum()
order_value.mean().round(2)
order_value.median().round(2)
np.float64(301.65)

Part 24: Real Revenue by Day of the Week#

clean['DayOfWeek'] = clean['InvoiceDate'].dt.day_name()
clean.groupby('DayOfWeek')['Revenue'].sum().sort_values(ascending=False)
DayOfWeek
Thursday     1939228.91
Tuesday      1672493.12
Wednesday    1559469.25
Friday       1459797.08
Monday       1326500.48
Sunday        779738.80
Name: Revenue, dtype: float64

Part 25: Real Quantity and Price Extremes#

clean[['Quantity', 'UnitPrice']].describe()
clean.sort_values('Quantity', ascending=False)[['Description', 'Quantity', 'CustomerID']].head(5)
Description Quantity CustomerID
390689 PAPER CRAFT , LITTLE BIRDIE 80995 16446
36375 MEDIUM CERAMIC TOP STORAGE JAR 74215 12346
303431 WORLD WAR 2 GLIDERS ASSTD DESIGNS 4800 12901
140979 SMALL POPCORN HOLDER 4300 13135
60437 EMPIRE DESIGN ROSETTE 3906 18087

Part 26: A Real Written Summary, Saved to Disk#

summary_lines = [
    f'Raw rows: {df.shape[0]}',
    f'Cleaned rows: {clean.shape[0]}',
    f'Rows removed by the audit: {df.shape[0] - clean.shape[0]}',
    f'Naive revenue: {round(naive_revenue, 2)}',
    f'Trustworthy cleaned revenue: {round(clean_revenue, 2)}',
    f'Unique real customers: {clean["CustomerID"].nunique()}',
]
with open('online_retail_audit_summary.txt', 'w') as f:
    for line in summary_lines:
        f.write(line + chr(10))
for line in summary_lines:
    print(line)
Raw rows: 541909
Cleaned rows: 391150
Rows removed by the audit: 150759
Naive revenue: 9747747.93
Trustworthy cleaned revenue: 8737227.64
Unique real customers: 4334

Part 27: Confirming the Real Summary File#

import os
os.path.exists('online_retail_audit_summary.txt')
os.path.getsize('online_retail_audit_summary.txt') > 0
True

Part 28: One Last Real Sanity Check#

clean.isna().sum().sum()
(clean['Quantity'] > 0).all() and (clean['UnitPrice'] > 0).all()
np.True_

Wrap-Up: What You Learned#

  • Real production data always needs an audit before any number gets reported: nulls, cancellations, negative quantities, zero prices, non-product codes, and duplicates.
  • This real UK online retailer's cancelled invoices genuinely start with the letter C, and negative quantities mostly, but not entirely, overlap with them.
  • A naive revenue sum on unaudited real data can be meaningfully wrong; the real cleaned figure is the one worth trusting.
  • The real cleaned dataset is now saved once, so the rest of this retail series builds on trustworthy data from here on.
  • Next video: real RFM segmentation, turning this same cleaned customer-and-revenue data into actual customer segments.

Found this useful?

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