Mathew K Analytics

Lesson 4 · Real-World Data Analytics

Python Data Analytics #04: Cohort Analysis & Customer Retention in Python

Video four of the hundred-video real-world data analytics series. Grouping real customers by their real first purchase month, and tracking how each 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 4: Cohort Analysis and Customer Retention#

  • Video four of the hundred-video real-world data analytics series.
  • Grouping real customers by their real first purchase month, and tracking how each real cohort actually retains over time.
  • Let's get into it.

Part 1: What a Cohort Actually Is#

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

Part 2: Real Invoice Month#

clean['InvoiceMonth'] = clean['InvoiceDate'].values.astype('datetime64[M]')
clean[['InvoiceDate', 'InvoiceMonth']].head(3)
InvoiceDate InvoiceMonth
0 2010-12-01 08:26:00 2010-12-01
1 2010-12-01 08:26:00 2010-12-01
2 2010-12-01 08:26:00 2010-12-01

Part 3: Real Cohort Month per Customer#

first_purchase = clean.groupby('CustomerID')['InvoiceMonth'].min()
first_purchase = first_purchase.rename('CohortMonth')
first_purchase.head()
CustomerID
12346   2011-01-01
12347   2010-12-01
12348   2010-12-01
12349   2011-11-01
12350   2011-02-01
Name: CohortMonth, dtype: datetime64[s]

Part 4: Attaching Cohort Month Back to Every Transaction#

merged = clean.merge(first_purchase, on='CustomerID')
merged[['CustomerID', 'InvoiceMonth', 'CohortMonth']].head(3)
CustomerID InvoiceMonth CohortMonth
0 17850 2010-12-01 2010-12-01
1 17850 2010-12-01 2010-12-01
2 17850 2010-12-01 2010-12-01

Part 5: Real Cohort Index#

year_diff = merged['InvoiceMonth'].dt.year - merged['CohortMonth'].dt.year
month_diff = merged['InvoiceMonth'].dt.month - merged['CohortMonth'].dt.month
merged['CohortIndex'] = year_diff * 12 + month_diff
merged['CohortIndex'].describe()
count    391150.000000
mean          4.147833
std           3.850590
min           0.000000
25%           0.000000
50%           3.000000
75%           7.000000
max          12.000000
Name: CohortIndex, dtype: float64

Part 6: Real Active Customers per Cohort per Month#

cohort_data = merged.groupby(['CohortMonth', 'CohortIndex'])['CustomerID'].nunique().reset_index()
cohort_data.head()
CohortMonth CohortIndex CustomerID
0 2010-12-01 0 884
1 2010-12-01 1 323
2 2010-12-01 2 286
3 2010-12-01 3 339
4 2010-12-01 4 320

Part 7: Real Cohort Pivot Table#

cohort_pivot = cohort_data.pivot(index='CohortMonth', columns='CohortIndex', values='CustomerID')
cohort_pivot.shape
cohort_pivot.iloc[:5, :6]
CohortIndex 0 1 2 3 4 5
CohortMonth
2010-12-01 884.0 323.0 286.0 339.0 320.0 352.0
2011-01-01 416.0 91.0 111.0 95.0 132.0 120.0
2011-02-01 380.0 71.0 71.0 109.0 103.0 93.0
2011-03-01 452.0 67.0 114.0 90.0 101.0 76.0
2011-04-01 300.0 63.0 61.0 63.0 59.0 68.0

Part 8: Real Cohort Sizes#

cohort_sizes = cohort_pivot.iloc[:, 0]
cohort_sizes
CohortMonth
2010-12-01    884.0
2011-01-01    416.0
2011-02-01    380.0
2011-03-01    452.0
2011-04-01    300.0
2011-05-01    284.0
2011-06-01    242.0
2011-07-01    187.0
2011-08-01    169.0
2011-09-01    299.0
2011-10-01    357.0
2011-11-01    323.0
2011-12-01     41.0
Name: 0, dtype: float64

Part 9: Real Retention Rate Table#

retention = cohort_pivot.divide(cohort_sizes, axis=0)
retention.round(3).iloc[:6, :6]
CohortIndex 0 1 2 3 4 5
CohortMonth
2010-12-01 1.0 0.365 0.324 0.383 0.362 0.398
2011-01-01 1.0 0.219 0.267 0.228 0.317 0.288
2011-02-01 1.0 0.187 0.187 0.287 0.271 0.245
2011-03-01 1.0 0.148 0.252 0.199 0.223 0.168
2011-04-01 1.0 0.210 0.203 0.210 0.197 0.227
2011-05-01 1.0 0.190 0.173 0.173 0.208 0.232

Part 10: Real Month-1 Retention Across Cohorts#

month1_retention = retention[1].dropna().sort_index()
month1_retention.round(3)
month1_retention.mean().round(3)
np.float64(0.204)

Part 11: Visualizing the Real Retention Heatmap#

plt.figure(figsize=(11, 7))
plt.imshow(retention, cmap='YlGnBu', aspect='auto', vmin=0, vmax=1)
plt.colorbar(label='Real Retention Rate')
plt.yticks(range(len(retention.index)), [d.strftime('%Y-%m') for d in retention.index])
plt.xlabel('Real Months Since First Purchase')
plt.title('Real Customer Retention Heatmap by Cohort')
plt.tight_layout()
plt.savefig('cohort_retention_heatmap.png', dpi=120)
plt.close()

Part 12: Real Retention Curves for a Few Cohorts#

plt.figure(figsize=(9, 6))
for cohort in retention.index[:6]:
    plt.plot(retention.columns, retention.loc[cohort], marker='o', label=cohort.strftime('%Y-%m'))
plt.xlabel('Real Months Since First Purchase')
plt.ylabel('Real Retention Rate')
plt.title('Real Retention Curves for the First Six Cohorts')
plt.legend()
plt.tight_layout()
plt.savefig('cohort_retention_curves.png', dpi=120)
plt.close()

Part 13: Real Revenue per Cohort per Month#

revenue_data = merged.groupby(['CohortMonth', 'CohortIndex'])['Revenue'].sum().reset_index()
revenue_pivot = revenue_data.pivot(index='CohortMonth', columns='CohortIndex', values='Revenue')
revenue_pivot.iloc[:5, :6].round(0)
CohortIndex 0 1 2 3 4 5
CohortMonth
2010-12-01 565200.0 273650.0 231915.0 296402.0 201988.0 325803.0
2011-01-01 289033.0 53129.0 62412.0 64893.0 79926.0 83210.0
2011-02-01 157250.0 27563.0 38886.0 48019.0 39916.0 33846.0
2011-03-01 196767.0 28718.0 58372.0 42367.0 50245.0 39776.0
2011-04-01 119956.0 28545.0 24751.0 24105.0 26091.0 29370.0

Part 14: Real Average Revenue per Retained Customer#

avg_revenue_per_active = revenue_pivot / cohort_pivot
avg_revenue_per_active.iloc[:5, :6].round(2)
CohortIndex 0 1 2 3 4 5
CohortMonth
2010-12-01 639.37 847.21 810.89 874.34 631.21 925.58
2011-01-01 694.79 583.83 562.27 683.09 605.50 693.41
2011-02-01 413.82 388.22 547.69 440.54 387.54 363.93
2011-03-01 435.32 428.63 512.03 470.74 497.47 523.37
2011-04-01 399.85 453.10 405.75 382.62 442.22 431.92

Part 15: Best and Worst Real Cohorts by Month-1 Retention#

best_cohort = month1_retention.idxmax()
worst_cohort = month1_retention.idxmin()
print(f'Best real cohort: {best_cohort.strftime("%Y-%m")} at {round(month1_retention[best_cohort]*100,1)}% month-1 retention')
print(f'Weakest real cohort: {worst_cohort.strftime("%Y-%m")} at {round(month1_retention[worst_cohort]*100,1)}% month-1 retention')
Best real cohort: 2010-12 at 36.5% month-1 retention
Weakest real cohort: 2011-11 at 11.1% month-1 retention

Part 16: Is Retention Actually Trending Over Time#

early_cohorts = month1_retention.iloc[:len(month1_retention)//2]
late_cohorts = month1_retention.iloc[len(month1_retention)//2:]
round(early_cohorts.mean(), 3), round(late_cohorts.mean(), 3)
(np.float64(0.22), np.float64(0.189))

Part 17: Real Long-Term Retention at Month 6#

month6_retention = retention[6].dropna()
month6_retention.round(3)
month6_retention.mean().round(3)
np.float64(0.244)

Part 18: Real Cumulative Retained Customer-Months#

customer_months = cohort_pivot.sum(axis=1)
customer_months.sort_values(ascending=False).round(0)
CohortMonth
2010-12-01    4801.0
2011-01-01    1628.0
2011-03-01    1290.0
2011-02-01    1264.0
2011-04-01     779.0
2011-05-01     663.0
2011-06-01     545.0
2011-09-01     493.0
2011-10-01     482.0
2011-07-01     372.0
2011-11-01     359.0
2011-08-01     306.0
2011-12-01      41.0
dtype: float64

Part 19: Real Sanity Check on the Pivot Math#

manual_check = merged[(merged['CohortMonth'] == cohort_pivot.index[0]) & (merged['CohortIndex'] == 1)]['CustomerID'].nunique()
manual_check == cohort_pivot.iloc[0, 1]
np.True_

Part 20: Saving the Real Retention Table#

retention.round(4).to_csv('cohort_retention_table.csv')
reloaded = pd.read_csv('cohort_retention_table.csv', index_col=0)
reloaded.shape == retention.shape
True

Part 21: Real One-Time vs Real Repeat Customers#

months_active = merged.groupby('CustomerID')['CohortIndex'].apply(lambda s: (s == 0).sum() == len(s))
one_time_pct = months_active.mean()
round(one_time_pct * 100, 1)
np.float64(37.8)

Part 22: Real Cohort Size Trend Over Time#

plt.figure(figsize=(9, 5))
plt.bar([d.strftime('%Y-%m') for d in cohort_sizes.index], cohort_sizes.values, color='steelblue')
plt.xticks(rotation=45)
plt.ylabel('Real New Customers')
plt.title('Real New Customer Acquisition by Cohort Month')
plt.tight_layout()
plt.savefig('cohort_acquisition_sizes.png', dpi=120)
plt.close()

Part 23: Real Overall Repeat Purchase Rate#

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

Part 24: Real Time Between First and Second Purchase#

repeat_customers = orders_per_customer[orders_per_customer > 1].index
first_two = clean[clean['CustomerID'].isin(repeat_customers)].sort_values('InvoiceDate')
def gap_to_second_visit(dates):
    uniq = sorted(dates.unique())
    return (uniq[1] - uniq[0]) / pd.Timedelta(days=1) if len(uniq) > 1 else None
gap_days = first_two.groupby('CustomerID')['InvoiceDate'].apply(gap_to_second_visit).dropna()
gap_days.median().round(1)
np.float64(51.0)

Part 25: Do Month-1 Returners Actually Spend More Overall#

month0_month1_customers = merged[(merged['CohortIndex'] == 1)]['CustomerID'].unique()
total_revenue_per_customer = clean.groupby('CustomerID')['Revenue'].sum()
returners_revenue = total_revenue_per_customer.loc[total_revenue_per_customer.index.isin(month0_month1_customers)].mean()
non_returners_revenue = total_revenue_per_customer.loc[~total_revenue_per_customer.index.isin(month0_month1_customers)].mean()
round(returners_revenue, 2), round(non_returners_revenue, 2)
(np.float64(4717.98), np.float64(1238.93))

Part 26: Real Matured Cohorts Only#

matured = retention[retention[6].notna()]
matured.shape[0]
matured[[0, 1, 3, 6]].round(3)
CohortIndex 0 1 3 6
CohortMonth
2010-12-01 1.0 0.365 0.383 0.362
2011-01-01 1.0 0.219 0.228 0.248
2011-02-01 1.0 0.187 0.287 0.255
2011-03-01 1.0 0.148 0.199 0.268
2011-04-01 1.0 0.210 0.210 0.217
2011-05-01 1.0 0.190 0.173 0.264
2011-06-01 1.0 0.174 0.264 0.095

Part 27: Real Country Mix Within the Largest Cohort#

largest_cohort_month = cohort_sizes.idxmax()
largest_cohort_customers = first_purchase[first_purchase == largest_cohort_month].index
clean[clean['CustomerID'].isin(largest_cohort_customers)]['Country'].value_counts().head(5)
Country
United Kingdom    147183
EIRE                7126
Germany             3475
France              3076
Netherlands         2061
Name: count, dtype: int64

Part 28: Real Retention Improvement Recommendation#

gap = round((returners_revenue - non_returners_revenue) / non_returners_revenue * 100, 1)
print(f'Customers retained through month one are worth {gap}% more in real lifetime revenue on average, making early-lifecycle retention campaigns a genuinely high-value real investment.')
Customers retained through month one are worth 280.8% more in real lifetime revenue on average, making early-lifecycle retention campaigns a genuinely high-value real investment.

Part 29: Real Verification the Full Table Reconstructs Correctly#

total_from_pivot = cohort_pivot.iloc[:, 0].sum()
total_unique_customers = clean['CustomerID'].nunique()
total_from_pivot == total_unique_customers
np.True_

Part 30: Real Cohort Count Recap#

print(f'Analyzed {len(cohort_pivot)} real monthly cohorts covering {total_unique_customers} real unique customers.')
Analyzed 13 real monthly cohorts covering 4334 real unique customers.

Wrap-Up: What You Learned#

  • A cohort groups real customers by their real first purchase month, tracked together going forward.
  • The real cohort index measures months elapsed since that first purchase, letting different real cohorts be compared fairly.
  • A retention table divides real active counts by each real cohort's starting size, turning raw headcounts into a real comparable percentage.
  • Real revenue-per-cohort tracking shows whether retained customers are genuinely still worth as much, not just whether they came back.
  • Comparing real early cohorts to real later ones reveals whether the business is actually improving at keeping customers over time.
  • Next video: real sales forecasting, predicting real future retail demand from this same historical data.

Found this useful?

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