import polars as plimport numpy as npfrom sklearn.neighbors import KernelDensityfrom scipy.optimize import minimizefrom scipy.special import gammalnfrom utils import CDNOW, modified_silvermanimport altair as altfrom IPython.display import display_markdownalt.renderers.enable("html")
RendererRegistry.enable('html')
cdnow = CDNOW(master=False, calib_p=273)# For the Gamma-Gamma model, we need to filter out customers who have made only one purchase.rfm_data = cdnow.rfm_summary().filter(pl.col('P1X') >0)rfm_data_array = rfm_data.select('P1X', 't_x', 'T', 'zbar').collect().to_numpy()x = rfm_data_array[:,0] # frequencyzbar = rfm_data_array[:,3] /100# monetary valuet_x = rfm_data_array[:,1]T = rfm_data_array[:,2]
The Gamma-Gamma model assumes that there is no relationship between the monetary value and the purchase frequency. We can check this assumption by calculating the correlation between the average spend and the frequency of purchases.
corr_data = rfm_data.select('P1X', 'zbar').collect()( corr_data.corr() .with_columns(pl.Series(corr_data.columns).alias("index")) .style.tab_header(title="Correlations Between Frequency & Monetary Value") .tab_stub(rowname_col="index") .fmt_number(decimals=3))# The value of this correlation is close to 0.11, which in practice is considered low enough to proceed with the model.
Correlations Between Frequency & Monetary Value
P1X
zbar
P1X
1.000
0.114
zbar
0.114
1.000
# Descriptive statistics of the average spend per repeat transactionsummary = rfm_data.select('zbar').with_columns(pl.col('zbar') /100).describe()summary# We note that the distribution of observed individual means is highly skewed to the right.
shape: (9, 2)
statistic
zbar
str
f64
"count"
946.0
"null_count"
0.0
"mean"
35.077848
"std"
30.283506
"min"
2.99
"25%"
15.76
"50%"
27.54
"75%"
41.79
"max"
299.63381
Probability density estimate of the sample
m = np.arange(2.5, 301, 2.5) # Average transaction value range# Apply log transformation for boundary correctionm_log = np.log(m)zbar_log = np.log(zbar)bw = modified_silverman(zbar_log)print('Kernel Smoothing Bandwidth:', bw)
Kernel Smoothing Bandwidth: 0.18800626075684287
# Estimate the probability density function# Method 1 - Using sklearnkde = KernelDensity(kernel='gaussian', bandwidth=bw).fit(zbar_log.reshape(-1,1))log_density = kde.score_samples(m_log.reshape(-1,1))f = np.exp(log_density) / m # Transform the density back to the original scale# Method 2 - Using statsmodels# import statsmodels.api as sm# kde = sm.nonparametric.KDEUnivariate(zbar_log)# kde.fit(kernel='gau', bw=bw)# f_log = kde.evaluate(m_log)# f = f_log / m
The distribution of average spend per (repeat) transaction across the 946 individuals who made a repeat transaction in the calibration period. Each customer’s average is computed across a (typically very) small number of transactions.
act_dist_plot = ( alt.Chart(pl.DataFrame({'Average Transaction Value (z)': m, 'f(z)': f})) .mark_line().encode( x=alt.X('Average Transaction Value (z)', axis=alt.Axis(values=np.arange(0, 301, 50), labelExpr='"$"+datum.value')), y=alt.Y('f(z)', scale=alt.Scale(domain=[0, 0.04])) ))act_dist_plot.properties( width=500, height=400, title='Observed distribution of average transaction values across customers').configure_view(stroke=None).configure_axisY(grid=False).configure_axisX(grid=False)
The distribution where the means have been computed across \(x → ∞\) transactions
zeta = np.arange(300) +1f_zeta = (p * gamma)**q * zeta**(-q-1) * np.exp(-p*gamma/zeta) / np.exp(gammaln(q))( alt.Chart(pl.DataFrame({'Unobserved mean transaction value (ζ)': zeta, 'f(ζ)': f_zeta})) .mark_line().encode( x=alt.X('Unobserved mean transaction value (ζ)', axis=alt.Axis(values=np.arange(0, 301, 50), labelExpr='"$"+datum.value')), y=alt.Y('f(ζ)', scale=alt.Scale(domain=[0, 0.04])) ).properties( width=500, height=400, title='Distribution of the (unobserved) mean transaction value (ζ)' ).configure_view(stroke=None).configure_axisY(grid=False).configure_axisX(grid=False) )
# compute the density of average transaction value# how many people with each level of x?repeat_trans_dist = ( cdnow.rfm_summary() .group_by('P1X') .agg(pl.len().alias('Count')) .sort('P1X') .collect() .to_numpy())nx = repeat_trans_dist[1:, 1]x_trans = repeat_trans_dist[1:, 0]# compute the density of zbar for each xy = np.arange(300) +1x_trans, y = np.meshgrid(x_trans,y)a1 = gammaln(p*x_trans+q)-gammaln(p*x_trans)-gammaln(q)a2 = q*np.log(gamma)a3 = (p*x_trans-1)*np.log(y)a4 = (p*x_trans)*np.log(x_trans)a5 = (p*x_trans+q)*np.log(gamma+y*x_trans)g1 = np.exp(a1+a2+a3+a4-a5)# compute the weighted averageg = np.dot(nx, g1.T) / np.sum(nx)
est_dist_plot = ( alt.Chart(pl.DataFrame({'Average Transaction Value (z)': np.arange(300) +1, 'f(z)': g})) .mark_line(strokeDash=[4,4]).encode( x=alt.X('Average Transaction Value (z)', axis=alt.Axis(values=np.arange(0, 301, 50), labelExpr='"$"+datum.value')), y=alt.Y('f(z)', scale=alt.Scale(domain=[0, 0.04])) ))chart = act_dist_plot + est_dist_plotchart.properties( width=500, height=400, title='Observed versus theoretical distribution of average transaction value across customers').configure_view(stroke=None).configure_axisY(grid=False).configure_axisX(grid=False)