Code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import gcimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import gc# Import transaction log, identify & set header, omit customer source data, set order_date as datetime dtype
df = pd.read_csv(
"data/transaction-log-example.csv",
header=0,
delimiter=",",
usecols=['cust_id','order_date', 'net_sales', 'source'],
parse_dates=['order_date'],
dtype={
'cust_id': 'str',
'net_sales': 'float64',
'source': 'category'},
index_col=['order_date'])
df.head()| cust_id | net_sales | source | |
|---|---|---|---|
| order_date | |||
| 2018-02-16 | cust16647 | 108.784 | web |
| 2018-03-28 | cust17852 | 117.000 | web |
| 2018-09-27 | cust17852 | 315.900 | web |
| 2019-03-08 | cust17852 | 159.250 | web |
| 2016-05-18 | cust588 | 298.350 | web |
df_mkt = pd.read_csv(
"data/mktg-spend-example.csv",
header=0,
delimiter=",",
usecols=['month','total_marketing_spend'],
parse_dates=['month'],
dtype={'total_marketing_spend': 'float64'},
index_col=['month'])
df_mkt.head()| total_marketing_spend | |
|---|---|
| month | |
| 2016-01-01 | 648.653944 |
| 2016-02-01 | 988.526343 |
| 2016-03-01 | 928.085792 |
| 2016-04-01 | 685.889176 |
| 2016-05-01 | 1724.866225 |
Calculate “from scratch”: 1. Monthly sales over time 2. Total customers acquired 3. Customer acquisition cost (CAC) 4. Distribution of spend per purchase 5. Initial versus repeat sales volume 6. Initial versus repeat average order value (AOV) 7. Sales and AOV by source 8. First-purchase profitability 9. Cohorted sales (the “C3”) 10. Revenue retention curves 11. Cumulative spend per customer 12. Distribution of total spend by customer 13. Customer concentration (“Pareto”) chart
What they summarize: 1. Growth 2. Unit costs 3. Unit profitability (unit economic performance) 4. Retention 5. Heterogeneity (customers, time)
Analysis: Data shows seasonality with relatively higher sales from October through December as compared to the rest of the year. Data also shows sales are rising year-on-year. Time series decomposition analysis below demonstrates the rising trend and seasonality in the data.
sales = df.groupby([(df.index.year), (df.index.month)])['net_sales'].sum()
sales.unstack()| order_date | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| order_date | ||||||||||||
| 2016 | 21395.010 | 31661.305 | 38094.407 | 24398.595 | 36170.030 | 83308.498 | 81053.024 | 111414.979 | 119505.412 | 161253.287 | 223141.646 | 243675.627 |
| 2017 | 124408.180 | 132210.728 | 195413.972 | 202013.188 | 237466.827 | 241425.470 | 222988.766 | 234852.293 | 284023.233 | 411017.581 | 548646.332 | 757308.032 |
| 2018 | 307756.319 | 316357.886 | 376486.838 | 429182.598 | 555779.198 | 542620.845 | 563051.736 | 614858.790 | 693434.443 | 738637.185 | 733041.049 | 1013547.405 |
| 2019 | 510834.298 | 560820.260 | 691195.674 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
from matplotlib.ticker import FuncFormatter
ax = sales.plot(kind='bar',
figsize=(12,4),
title="Monthly Spend Over time",
xlabel='Month/Year',
ylabel='Total Spend',
fontsize=8)
ax.set_xticklabels(
[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in sales.index],
rotation=90,
ha='center')
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'${y:,.0f}'))
def addlabels(x,y):
for i in range(len(x)):
plt.text(i, y[i]+50000, f'{y[i]:,.0f}', ha='center', rotation ='vertical', snap=True, size=8)
addlabels(sales.index, sales.values)
ax.set_ylim(0, 1_300_000)Time Series Decomposition: Breaking Down the Components in Time Series Decomposition
When a time series is decomposed, it is often represented as:
\[ \text{Observed Data} = \text{Trend} + \text{Seasonality} + \text{Remainder (Residual/Error)} \]
Each component has a role:
from statsmodels.tsa.seasonal import seasonal_decompose
result = seasonal_decompose(sales, model='additive', period=12)fig, ax = plt.subplots(1, 1, figsize=(12,4))
ax.plot(sales.values, color='b')
ax.set_xlabel('Month, Year')
ax.set_ylabel('Total Spend')
ax.yaxis.set_major_formatter(FuncFormatter(FuncFormatter(lambda y, _: f'${y:,.0f}')))
plt.show()def effects(ax, values, color, label):
ax.plot(values, color=color, lw=0.8, ms=0, label=label)
ax.yaxis.set_major_formatter(FuncFormatter(FuncFormatter(lambda y, _: f'${y:,.0f}')))
ax.legend(loc='lower right', fontsize=8)
return
fig, axes = plt.subplots(4, 1, figsize=(11,8), dpi=140)
effects(axes[0], sales.values, 'b', 'Time Series')
effects(axes[1], result.trend.values, 'r', 'Decomposed Trend')
effects(axes[2], result.seasonal.values, 'r', 'Decomposed Seasonality')
effects(axes[3], result.resid.values, 'r', 'Decomposed Residual')
plt.show()Seasonal Strength:
from scipy.stats import f_oneway, linregress
stat, pvalue = f_oneway(result.seasonal, result.resid, nan_policy='omit')
print(f'F-statistics: \n {stat = :0.4f}\n {pvalue = :0.4f}')
coeff_det = linregress(result.seasonal, sales).rvalue
print(f'Coefficient of Determination: \n {coeff_det = :0.4f}')F-statistics:
stat = 0.0755
pvalue = 0.7844
Coefficient of Determination:
coeff_det = 0.4570
Seasonal Variation Measure:
seasonal_amp = np.max(result.seasonal) - np.min(result.seasonal)
print(f'Seasonal Amplitude = {seasonal_amp:,.0f}')
seasonal_std = np.std(result.seasonal)
print(f'St. Dev. Seasonal Component = {seasonal_std:,.0f}')Seasonal Amplitude = 316,348
St. Dev. Seasonal Component = 86,575
# Free-up memory - Garbage collect
del result, sales, seasonal_amp, seasonal_std, coeff_det, stat, pvalue, fig, axes, ax
gc.collect()14636
Analysis: First purchase of a unique customer ID is used here as a proxy for customer acquisition. Data shows that new customer acquisitions (or initial customer transactions) have trended upwards. Similar to the total monthly sales, the data shows seasonality; with acquisitions in November and December higher than the yearly average.
df = df.sort_values(by=['cust_id', 'order_date'])
df['initial_purch'] = (~df['cust_id'].duplicated()).astype(int)
acquired = df.groupby([(df.index.year), (df.index.month)])['initial_purch'].sum()
acquired.unstack()| order_date | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| order_date | ||||||||||||
| 2016 | 108.0 | 129.0 | 168.0 | 92.0 | 161.0 | 389.0 | 310.0 | 525.0 | 460.0 | 673.0 | 796.0 | 1086.0 |
| 2017 | 395.0 | 520.0 | 622.0 | 625.0 | 733.0 | 767.0 | 699.0 | 778.0 | 808.0 | 809.0 | 1449.0 | 2339.0 |
| 2018 | 786.0 | 819.0 | 903.0 | 1166.0 | 1588.0 | 1486.0 | 1499.0 | 1655.0 | 1661.0 | 1455.0 | 1758.0 | 2838.0 |
| 2019 | 1126.0 | 1103.0 | 1382.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
ax = acquired.plot(kind='bar',
figsize=(12,4),
title="Acquisition Over time",
xlabel='Month/Year',
ylabel='Acquisitions',
fontsize=8)
ax.set_xticklabels(
[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in acquired.index],
rotation=90,
ha='center')
def addlabels(x,y):
for i in range(len(x)):
plt.text(i, y[i]+100, f'{y[i]:,.0f}', ha='center', rotation ='vertical', snap=True, size=8)
addlabels(acquired.index, acquired.values)
ax.set_ylim(0, 3500)
plt.show()Analysis: Cost of acquiring new customers has increased over time. The average CAC in 2016 was $8.35 per customer, $19.72 in 2017, $36.23 in 2018, and $43.61 in 2019 (beginning of the year)
mktg_spend = df_mkt.groupby([(df_mkt.index.year), (df_mkt.index.month)])['total_marketing_spend'].sum()
cac = mktg_spend / acquired
cac.unstack()| month | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| month | ||||||||||||
| 2016 | 6.006055 | 7.662995 | 5.524320 | 7.455317 | 10.713455 | 7.342899 | 5.825060 | 6.969569 | 11.078503 | 11.868985 | 11.472615 | 8.332260 |
| 2017 | 10.097597 | 22.269116 | 12.645246 | 10.920224 | 29.646548 | 11.624200 | 12.745645 | 21.543598 | 17.848617 | 13.117770 | 34.319686 | 39.849331 |
| 2018 | 40.691258 | 21.341423 | 38.487964 | 41.052574 | 33.107560 | 48.839061 | 27.466391 | 37.088632 | 21.555379 | 52.222357 | 28.952526 | 43.926150 |
| 2019 | 29.666842 | 56.401616 | 44.772407 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
fig, ax1 = plt.subplots(figsize=(12,4))
ax1.set_ylabel('Customers Acquired', color='blue') # we already handled the x-label with ax1
acquired.plot(ax=ax1, color='blue', kind='bar', fontsize=8, title="CAC Vs. Total Customers Acquired Over Time")
ax1.tick_params(axis='y', labelcolor='blue')
ax2 = ax1.twinx() # instantiate a second Axes that shares the same x-axis
ax2.set_ylabel('CAC ($)', color='red')
cac.plot(ax=ax2, color='red', fontsize=8)
ax2.tick_params(axis='y', labelcolor='red')
def addlabels(x,y):
for i in range(len(x)):
ax2.text(i, y[i]+2, f'{y[i]:.1f}', ha='center', rotation ='horizontal', snap=True, size=8, color='black')
addlabels(cac.index, cac.values)
ax1.set_xticklabels([f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in acquired.index],
rotation=90,
ha='center')
ax1.set_xlabel('Month/Year')
ax2.set_ylim(0, 65)
fig.tight_layout()
plt.show()# Yearly average CAC
cac.groupby(level=0).mean()month
2016 8.354336
2017 19.718965
2018 36.227606
2019 43.613622
dtype: float64
# Y-o-Y monthly average CAC
cac.groupby(level=1).mean()month
1 21.615438
2 26.918787
3 25.357485
4 19.809372
5 24.489188
6 22.602054
7 15.345699
8 21.867266
9 16.827500
10 25.736371
11 24.914942
12 30.702580
dtype: float64
# Free-up memory - Garbage collect
del ax1, ax2, fig, mktg_spend
gc.collect()15890
Analysis: histogram of the transaction data shows a highly skewed, long-tailed distribution of spend per transaction. About 80% of the transactions are below $292.77 with a median of $127.08, and an average spend of $189.19.
from scipy.stats import describe
max_spend = df['net_sales'].max()
min_spend = df['net_sales'].min()
print(f'{max_spend = }')
print(f'{min_spend = }')
p80th = np.percentile(df['net_sales'], 80)
print(f'80th Percentile of Spends = ${p80th:0.2f}')
median = np.median(df['net_sales'])
print(f'Median = ${median:0.2f}')
print(describe(df['net_sales']))max_spend = np.float64(19305.0)
min_spend = np.float64(-517.569)
80th Percentile of Spends = $292.77
Median = $127.08
DescribeResult(nobs=70904, minmax=(np.float64(-517.569), np.float64(19305.0)), mean=np.float64(189.1917373631953), variance=np.float64(41018.6299560282), skewness=np.float64(17.867584403877846), kurtosis=np.float64(1366.6957992074858))
bins = list(range(-50, 820, 37))
plt.figure(figsize=(12,4))
plt.hist(df['net_sales'], bins=bins, range=(-50, ), edgecolor="black")
plt.xticks(bins)
plt.title('Distribution of Spend per Purchase')
plt.show()# Free-up memory - Garbage collect
del bins
gc.collect()5636
Analysis: Although customer acquisitions (and thus initial purchases) have steadily increased over time, contribution to overall sales from repeat purchases (existing customers) have increased at the same pace - reaching over 60% of total monthly sales by 2019.
initVrep = df.groupby([df.index.year, df.index.month])['initial_purch'].value_counts(normalize=True)
initVrep = initVrep.unstack()ax = initVrep.plot.bar(
stacked=True,
figsize=(13,4),
title="Total Sales from Repeat Vs. New Customer (% of Total)",
xlabel='Month/Year',
fontsize=8)
ax.set_xticklabels(
[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in initVrep.index],
rotation=90,
ha='center')
ax.legend(['Repeat', 'Initial'], title='Purchase Type')
ax.set_ylim((0,1))
plt.show()# Free-up memory - Garbage collect
del initVrep, ax
gc.collect()21098
Anaylsis: Repeat purchases exhibit greater variation over time, whereas initial purchases have remained relatively stable. Since November 2017, AOV of initial purchases have been higher than AOV of repeat purchases.
initVrepAOV = df.groupby([df.index.year, df.index.month, df['initial_purch']])['net_sales'].mean()
initAOV = initVrepAOV.xs(1, level='initial_purch')
repAOV = initVrepAOV.xs(0, level='initial_purch')fig, ax = plt.subplots(figsize=(12, 4))
initAOV.plot(ax=ax, label='Initial Purchase AOV', color='blue')
repAOV.plot(ax=ax, label='Repeat Purchase AOV', color='orange')
ax.set_title("Initial Versus Repeat Purchase AOV Over Time ($)")
ax.set_xlabel("Month/Year")
ax.set_ylabel("AOV ($)")
ax.set_xticks(range(len(initAOV.index)))
ax.set_xticklabels(
[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in initAOV.index],
rotation=90,
ha='center')
ax.legend(title='AOV Type')
ax.set_ylim(0,350)
plt.show()# Free-up memory - Garbage collect
del repAOV, initAOV, initVrepAOV, ax, fig
gc.collect()6720
Analysis: between online and physical store, online sales contributes the most to total sales. Average order value on web is relatively stable while average order value in store is slightly higher than web.
# Total sales by each source
sales_source = df.groupby([(df.index.year), (df.index.month), df['source']], observed=True)['net_sales'].sum()
sales_source = sales_source.unstack().fillna(0)
# Percentage contribution of each source to the total sales
sales_source_pct = sales_source.div(sales_source.sum(axis=1), axis=0)
# AOV by each source
aov_source = df.groupby([(df.index.year), (df.index.month), df['source']], observed=True)['net_sales'].mean()
aov_source = aov_source.unstack().fillna(0)fig, axes = plt.subplots(3, 1, figsize=(12, 12))
ax = axes[0]
sales_source.plot(ax=ax, label='$ Sales by Source', color=['green', 'red', 'blue'])
ax.set_title("Total Sales ($) by Source")
ax.set_xlabel("Month/Year")
ax.set_ylabel("Total Sales ($)")
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'${y:,.0f}'))
ax.set_xticks(range(len(sales_source.index)))
ax.set_xticklabels(
[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in sales_source.index],
rotation=90,
ha='center')
ax.legend(title='source', loc='upper left')
ax = axes[1]
sales_source_pct.plot(ax=ax, label='% Sales by Source', color=['green', 'red', 'blue'])
ax.set_title("% Sales by Source")
ax.set_xlabel("Month/Year")
ax.set_ylabel("% of Sales")
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{y:.1%}'))
ax.set_xticks(range(len(sales_source.index)))
ax.set_xticklabels(
[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in sales_source.index],
rotation=90,
ha='center')
ax=axes[2]
aov_source_sub = aov_source.loc[(aov_source.index >= (2018, 2))]
aov_source_sub.plot(y=['store', 'web'], ax=ax, color=['red', 'blue'])
ax.set_title("AOV by Source")
ax.set_xlabel("Month/Year")
ax.set_ylabel("AOV ($)")
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'${y:,.0f}'))
ax.set_xticks(range(len(aov_source_sub.index)))
ax.set_xticklabels(
[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in aov_source_sub.index],
rotation=40,
ha='center')
ax.set_ylim((0,600))
fig.tight_layout()# Free-up memory - Garbage collect
del sales_source, sales_source_pct, aov_source, aov_source_sub, axes, fig, ax
gc.collect()17471
In an ideal scenario, we would have the complete variable expense at a transaction level. However, this is often not the case.
Analysis: Average initial contribution profit has remained stable over time given the single contribution margin assumption (40% in this case). As CAC has increased over time, average initial profit (contribution profit net of CAC) has decreased, but still remains positive, suggesting that (on average) marketing spend on customer acquisition is recovered on initial purchase.
# Contribution Margin
cm = 0.40
# Compute and add contribution profit
df['contribution_profit'] = df['net_sales'] * cm
# Filter dataframe for initial purchase and compute average contribution profit per period
initial_purchase = df[df['initial_purch'] == 1]
avg_cp = initial_purchase.groupby([initial_purchase.index.year, initial_purchase.index.month])['contribution_profit'].mean()
# Compute average initial purchase profitability
avg_profit = avg_cp - cacfig, axes = plt.subplots(2, 1, figsize=(12,8))
ax = axes[0]
avg_cp.plot(ax=ax, color='green', fontsize=8, title="Average Initial Purchase Contribution Profit Vs. CAC")
cac.plot(ax=ax, color='red', fontsize=8)
ax.set_ylabel('USD ($)')
ax.set_xticks(range(len(cac.index)))
ax.set_xticklabels([f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in cac.index],
rotation=90,
ha='center')
ax.set_xlabel('Month/Year')
ax.set_ylim(0, 100)
ax.legend(['Avg. Initial Contribution Profit', 'CAC'])
ax = axes[1]
avg_profit.plot(ax=ax, color='blue', fontsize=8, title="Average Initial Purchase Profit Vs. CAC")
cac.plot(ax=ax, color='red', fontsize=8)
ax.set_ylabel('USD ($)')
ax.set_xticks(range(len(cac.index)))
ax.set_xticklabels([f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in cac.index],
rotation=90,
ha='center')
ax.set_xlabel('Month/Year')
ax.set_ylim(0, 100)
ax.legend(['Avg. Initial Purchase Profit', 'CAC'])
fig.tight_layout() # Free-up memory - Garbage collect
del avg_profit, avg_cp, axes, fig, cac
gc.collect()178
Analysis: visual representation of the cohort chart shows that newly acquired customers contribute the highest portion to sales every period. After the initial purchase, sales from previously acquired cohort reduces. However, over time, as more customers are acquired in earlier periods, their transactions in aggregate contribute to higher portion of sales. The repeat purchase of previously acquired customers acts as a base/foundation for total sales in that period.
# Add 'birthday' column to transaction log as date of customer acquisition across all transactions
initial_purchase = initial_purchase.reset_index().set_index('cust_id')['order_date']
df['birthday'] = df['cust_id'].map(initial_purchase)
# Calculate sum of net sales and present a cross-tabular dataset, grouped by transaction date and birthday.
# Dates on both axis converted to quarters
cohort = df.groupby([df.index.to_period('Q'), df['birthday'].dt.to_period('Q')])['net_sales'].sum()
cohort = cohort.unstack()
cohort| birthday | 2016Q1 | 2016Q2 | 2016Q3 | 2016Q4 | 2017Q1 | 2017Q2 | 2017Q3 | 2017Q4 | 2018Q1 | 2018Q2 | 2018Q3 | 2018Q4 | 2019Q1 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| order_date | |||||||||||||
| 2016Q1 | 91150.722 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2016Q2 | 15735.811 | 128141.312 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2016Q3 | 11181.131 | 34875.373 | 265916.911 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2016Q4 | 19506.162 | 23338.224 | 61185.982 | 524040.192 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2017Q1 | 9278.386 | 14089.400 | 29077.997 | 68122.275 | 331464.822 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2017Q2 | 11493.482 | 17048.967 | 39519.597 | 65624.689 | 81457.194 | 465761.556 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2017Q3 | 7929.636 | 16451.474 | 34250.281 | 54746.133 | 51252.578 | 83127.330 | 494106.860 | NaN | NaN | NaN | NaN | NaN | NaN |
| 2017Q4 | 19973.083 | 24671.361 | 49586.693 | 103926.537 | 70408.221 | 98304.180 | 147478.929 | 1202622.941 | NaN | NaN | NaN | NaN | NaN |
| 2018Q1 | 7336.641 | 9563.281 | 23702.666 | 36546.601 | 42145.116 | 43817.566 | 62672.324 | 148597.943 | 626218.905 | NaN | NaN | NaN | NaN |
| 2018Q2 | 11999.273 | 17183.140 | 33417.397 | 38420.772 | 50609.962 | 58695.702 | 70155.215 | 137284.862 | 139950.902 | 969865.416 | NaN | NaN | NaN |
| 2018Q3 | 7819.487 | 16051.178 | 42660.878 | 47002.085 | 46477.093 | 63337.859 | 67001.636 | 133244.709 | 84548.503 | 222379.027 | 1140822.514 | NaN | NaN |
| 2018Q4 | 10906.493 | 17638.114 | 30373.382 | 64923.599 | 61026.303 | 51674.220 | 66788.137 | 175532.266 | 92828.229 | 153412.740 | 227999.759 | 1532122.397 | NaN |
| 2019Q1 | 9743.526 | 16241.563 | 35903.010 | 38276.667 | 36791.339 | 51933.856 | 50985.649 | 87960.353 | 90161.851 | 101627.461 | 153787.517 | 164887.255 | 924550.185 |
ax = cohort.plot.area(
stacked=True,
figsize=(12,4),
title="C3 - Total Quarterly Sales by Acquisition Cohort Over Time")
ax.set_xlabel('Calendar Quarter')
ax.set_ylabel('Total Sales ($)')
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'${y:,.0f}'))
ax.legend(title='Acquisition Cohort', loc="upper left", ncol=3)# Free-up memory - Garbage collect
del initial_purchase, ax, cohort
gc.collect()14070
Analysis: Revenue generated by monthly cohorts of customers reduces dramatically relative to initial purchase for all cohorts. Cohort revenue retention is low immediately following the acquisition period and then gradually plateaus with repeat purchases.
# Months since acquisition
df['months_acq'] = np.ceil(((df.index - df['birthday']).dt.days / 30.3)).astype(int)
# Spend by customer tenure
spend_tenur = df.groupby([df['birthday'].dt.year, df['birthday'].dt.month, df['months_acq']])['net_sales'].sum().unstack()
# Spend by tenure as a % of initial spend
spend_tenur_pct = spend_tenur.div(spend_tenur[0], axis=0)ax = spend_tenur_pct.T.plot(
figsize=(12,4),
title="Revenue Retention\nRevenue by month since acquisition for each cohort, % of month 0 revenue")
ax.set_xlabel('Months Since Acquisition')
ax.set_ylabel('Revenue as a % of the Initial Revenue')
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{y:.1%}'))
ax.legend(
title='Acquisition Cohort', loc="upper right", ncol=6, fontsize=8,
labels=[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in spend_tenur_pct.index])# Free-up memory - Garbage collect
del spend_tenur_pct, ax
gc.collect()3756
# Cumulative spend across each cohort per customer
cum_spend = spend_tenur.cumsum(axis=1).div(acquired, axis=0)%matplotlib inline
ax = cum_spend.T.plot(
figsize=(12,4),
title="Cumulative Spend per Acquired Customer\nby Cohort and by Months since Acquisition")
ax.set_xlabel('Months Since Acquisition')
ax.set_ylabel('Cumulative Spend per Customer')
ax.legend(
title='Acquisition Cohort', loc="lower right", ncol=6, fontsize=6,
labels=[f'{pd.Timestamp(year=y, month=m, day=1):%b/%Y}' for y, m in spend_tenur.index])
ax.set_xlim(0, 40)
ax.set_ylim(0, 700)# Free-up memory - Garbage collect
del cum_spend, spend_tenur, acquired, ax
gc.collect()13880
Analysis:
spend_dist = df.groupby([df['birthday'].dt.to_period('Q'), df['cust_id']])['net_sales'].sum()
spend_dist = spend_dist.unstack().Tselect = '2017Q3'
data = spend_dist[select].dropna()
bins = np.histogram_bin_edges(data, bins=10, range=(0, 3000))
bins = np.append(bins, np.max(data))
counts, bins = np.histogram(data, bins=bins)
plt.figure(figsize=(6, 3), dpi=120)
plt.stairs(counts, bins, fill=True)
plt.xlim(0,3200)
bin_midpoints = 0.5 * (bins[1:] + bins[:-1])
bin_labels = [f"{int(bins[i])}-{int(bins[i+1])}" if bins[i+1] < np.max(data) else f"{int(bins[i])}+" for i in range(len(bins) - 1)]
plt.xticks(list(bin_midpoints[:-1]) + [3200], bin_labels[:-1] + ["3000+"], rotation=45, ha='right', fontsize=8)
plt.yticks(fontsize=8)
plt.title(f"Customer Spend Histogram, {select} Cohort", fontsize=8)
plt.xlabel("Spend Amount ($)", fontsize=8)
plt.ylabel("Frequency", fontsize=8)
plt.show()data = spend_dist[select].dropna()
bins = np.histogram_bin_edges(data, bins=10, range=(0, 3000))
bins = np.append(bins, np.max(data))
counts, bins = np.histogram(data, bins=bins)
plt.figure(figsize=(6, 3), dpi=120)
plt.hist(data, bins=bins, edgecolor="black")
plt.xlim(0, 3200)
bin_midpoints = 0.5 * (bins[1:] + bins[:-1])
bin_labels = [f"{int(bins[i])}-{int(bins[i+1])}" if bins[i+1] < np.max(data) else f"{int(bins[i])}+" for i in range(len(bins) - 1)]
plt.xticks(list(bin_midpoints[:-1]) + [3200], bin_labels[:-1] + ["3000+"], rotation=45, ha='right', fontsize=8)
plt.yticks(fontsize=8)
plt.title(f"Customer Spend Histogram, {select} Cohort", fontsize=8)
plt.xlabel("Spend Amount ($)", fontsize=8)
plt.ylabel("Frequency", fontsize=8)
plt.show()Analysis:
# Customer spend distribution
select = '2017Q1'
selected_spend = spend_dist[select].dropna()
# Customer spend distribution ranked - rank method='first' breaks tie when data is sorted
rank_percentile = selected_spend.sort_values(ascending=True).rank(method='first', ascending=False, pct=True)
# Round up rank_percentile to the nearest 10th
rank_percentile = np.ceil(rank_percentile * 10) / 10
# Group data by rank_percentile and calculate the sum of net sales for each percentile
spend_by_percentile = selected_spend.groupby(rank_percentile).sum()
spend_cumsum = spend_by_percentile.cumsum()
spend_cumsum_pct = spend_cumsum / spend_by_percentile.sum()
spend_cumsum_pct = pd.concat([pd.Series([0], index=[0]), spend_cumsum_pct])plt.figure(figsize=(12, 5), dpi=120)
plt.plot(spend_cumsum_pct, '--o')
for i, value in enumerate(spend_cumsum_pct):
plt.text(
spend_cumsum_pct.index[i], value+0.01, f'{value:.1%}',
ha='center', va='bottom', fontsize=8)
plt.title(f'Pareto Chart for Sales for {select} Cohort')
plt.xlabel('Top x% of Customers')
plt.ylabel('Total Sales From Top x% of Customers, % of Total')
plt.xlim(0,1.01)
plt.ylim(0,1.02)
plt.show()