数据驱动分析实践(九)
A/B测试设计和实施
作为一个数据驱动增长的实施者,一个最主要的责任就是实践新的想法和持续学习。实践是检验机器学习模型的最有力的手段,新方法改善旧方法,举例来看:
如果你有一个具有95%正确率的留存模型,从中获得最可能流失的客户,并给这些客户一个诱人的优惠政策。你认为这些客户的10%会留下来,且每人每月为你带来20元的收入。
这里有很多假设,让我们来详细讨论一下:
- 模型的正确率为95%,这一点是真实的吗?模型是根据上个月的数据训练得到的。到了下个月,新的用户、新的产品特性、市场活动、季节效应等因素都会使模型不那么可信。历史上正确不代表未来。所以,我们不能在未经测试的情况下得到这个结论。
- 你假设10%的转化率是基于上次的促销活动。这并不能保证在新的活动中还能够获得同样的转化率。进一步说,由于情况已经改变,客户的反应很大程度上是难以预测的。
- 最后,这些客户在今天为你带来了每天20元的收入并不代表以后也是如此。
如果希望知道下一步会发生什么,我们需要构造一个A/B测试。下面我们将会通过程序来设计和执行A/B测试。在此之前,我们需要了解关于A/B测试的两个关键点。
- 假设是什么?
继续刚才的例子中的假设,测试组会有更多留存: 组A -> 促销策略 -> 更高的留存
组B -> 无策略 -> 低留存
这样做也可以帮助我们测试我们的模型的正确率。如果组B的留存率为50%,很明显我们的模型无效。同样的判断也适用于测量收入。 - 成功指标是什么? 每组的留存率
A/B测试
#import libraries
from __future__ import division
from datetime import datetime, timedelta,date
import pandas as pd
from sklearn.metrics import classification_report,confusion_matrix
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
from sklearn.cluster import KMeans
import chart_studio.plotly as py
import plotly.offline as pyoff
import plotly.graph_objs as go
import plotly.figure_factory as ff
import sklearn
import xgboost as xgb
from sklearn.model_selection import KFold, cross_val_score, train_test_split
import warnings
%matplotlib inline
warnings.filterwarnings("ignore")
#initiate plotly
pyoff.init_notebook_mode()
现在我们要创建数据集,这个虚拟的数据集会包含四列:
- 客户ID
- 客户分段:高价值或低价值
- 组:控制组或者测试组
- 购买次数
df_hv = pd.DataFrame()
df_hv['customer_id'] = np.array([count for count in range(20000)])
df_hv['segment'] = np.array(['high-value' for _ in range(20000)])
df_hv['group'] = 'control'
df_hv.loc[df_hv.index<10000,'group'] = 'test'
一般情况下,购买次数会呈现泊松分布
df_hv.loc[df_hv.group == 'test', 'purchase_count'] = np.random.poisson(0.6, 10000)
df_hv.loc[df_hv.group == 'control', 'purchase_count'] = np.random.poisson(0.5, 10000)
df_hv.query('group == "test"').head()
df_hv.query('group == "control"').head()
假如我们为50%的高价值客户提供了促销优惠,观察他们的购买次数。
test_results = df_hv[df_hv.group == 'test'].purchase_count
control_results = df_hv[df_hv.group == 'control'].purchase_count
hist_data = [test_results, control_results]
group_labels = ['test', 'control']
# Create distplot with curve_type set to 'normal'
fig = ff.create_distplot(hist_data, group_labels, bin_size=.5,
curve_type='normal',show_rug=False)
fig.layout = go.Layout(
title='High Value Customers Test vs Control',
plot_bgcolor = 'rgb(243,243,243)',
paper_bgcolor = 'rgb(243,243,243)',
)
# Plot!
pyoff.iplot(fig)
结果看上去不错。测试组的密度从1开始就好于控制组。但是我们是否可以肯定这个实验是成功的,其他因素不会带来任何差异?为了回答这个问题,我们需要检查结果是否具有统计显著性。
from scipy import stats
test_result = stats.ttest_ind(test_results, control_results)
print(test_result)
Ttest_indResult(statistic=9.178854780044302, pvalue=4.770085341930782e-20)
ttest_ind输出两个结果:
- t-statistics:表示测试组和控制组的平均差异,以标准差计量。高t-statistics值意味着很大的差异。从上面的结果上看是支持我们的假设的。
- p-value:表示原假设为真的概率
这里的原假设为真就意味着测试组和控制组没有区别。所以低p-value对我们来说是好事情。从行业标准上来说,我们接受p-value < 5%就意味着具备统计显著性。但这一点往往需要依赖于你的业务逻辑。
为了理解我们的测试是否具备统计显著性,我们对我们的数据集应用如下函数。
def eval_test(test_results,control_results):
test_result = stats.ttest_ind(test_results, control_results)
if test_result[1] < 0.05:
print('result is significant')
else:
print('result is not significant')
eval_test(test_results,control_results)
result is significant
看上去非常好!
不幸的是事情并没有这么简单。如果你选择了一个有偏的测试组,结果大概率是具备统计显著性的。举例来说,如果测试组的高价值客户更多而控制组的低价值客户更多,那么这个实验从一开始就错了。这就是为什么组的选择是A/B测试的一个关键点。
选择测试组和控制组
最常用的选择测试组和控制组的方法是随机采样。让我们通过程序来实现这一点,现在我们的数据集中将包含20k高价值客户和80k低价值客户。
#create hv segment
df_hv = pd.DataFrame()
df_hv['customer_id'] = np.array([count for count in range(20000)])
df_hv['segment'] = np.array(['high-value' for _ in range(20000)])
df_hv['prev_purchase_count'] = np.random.poisson(0.9, 20000)
df_lv = pd.DataFrame()
df_lv['customer_id'] = np.array([count for count in range(20000,100000)])
df_lv['segment'] = np.array(['low-value' for _ in range(80000)])
df_lv['prev_purchase_count'] = np.random.poisson(0.3, 80000)
df_customers = pd.concat([df_hv,df_lv],axis=0)
df_customers.head()
df_customers.tail()
假设我们需要测试组中有90%的客户,而控制组中有10%的客户。
df_test = df_customers.sample(frac=0.9)
df_control = df_customers[~df_customers.customer_id.isin(df_test.customer_id)]
这里还有一个小问题。我们并不能保证实际上高价值客户数量 / 低价值客户数量 = 测试组数量 / 控制组数量
所以我们需要进行一下优化,
df_test_hv = df_customers[df_customers.segment == 'high-value'].sample(frac=0.9)
df_test_lv = df_customers[df_customers.segment == 'low-value'].sample(frac=0.9)
df_test = pd.concat([df_test_hv,df_test_lv],axis=0)
df_control = df_customers[~df_customers.customer_id.isin(df_test.customer_id)]
df_test.segment.value_counts()
low-value 72000
high-value 18000
Name: segment, dtype: int64
df_control.segment.value_counts()
low-value 8000
high-value 2000
Name: segment, dtype: int64
数据比例正确。
我们已经探索了如何分组和进行t检验。但是如果我们需要做A/B/C测试或者在多个组上做A/B测试,就需要引入方差分析(ANOVA)
单因素方差分析(One Way ANOVA)
假如我们正在同样的多个组上测试变量,而该变量有多于2个取值(例如,2个不同的优惠策略和没有优惠),那么我们需要应用单因素方差分析来评估我们的实验。
#create hv segment
df_hv = pd.DataFrame()
df_hv['customer_id'] = np.array([count for count in range(30000)])
df_hv['segment'] = np.array(['high-value' for _ in range(30000)])
df_hv['group'] = 'A'
df_hv.loc[df_hv.index>=10000,'group'] = 'B'
df_hv.loc[df_hv.index>=20000,'group'] = 'C'
df_hv.loc[df_hv.group == 'A', 'purchase_count'] = np.random.poisson(0.4, 10000)
df_hv.loc[df_hv.group == 'B', 'purchase_count'] = np.random.poisson(0.6, 10000)
df_hv.loc[df_hv.group == 'C', 'purchase_count'] = np.random.poisson(0.2, 10000)
a_stats = df_hv[df_hv.group=='A'].purchase_count
b_stats = df_hv[df_hv.group=='B'].purchase_count
c_stats = df_hv[df_hv.group=='C'].purchase_count
hist_data = [a_stats, b_stats, c_stats]
group_labels = ['A', 'B','C']
# Create distplot with curve_type set to 'normal'
fig = ff.create_distplot(hist_data, group_labels, bin_size=.5,
curve_type='normal',show_rug=False)
fig.layout = go.Layout(
title='Test vs Control Stats',
plot_bgcolor = 'rgb(243,243,243)',
paper_bgcolor = 'rgb(243,243,243)',
)
# Plot!
pyoff.iplot(fig)
为了评估结果,我们使用方差分析,
def one_anova_test(a_stats,b_stats,c_stats):
test_result = stats.f_oneway(a_stats, b_stats, c_stats)
if test_result[1] < 0.05:
print('result is significant')
else:
print('result is not significant')
其逻辑与t检验类似,好的结果是具备统计显著性。
one_anova_test(a_stats,b_stats,c_stats)
result is significant
我们看一下如果没有统计显著性的例子。
df_hv.loc[df_hv.group == 'A', 'purchase_count'] = np.random.poisson(0.5, 10000)
df_hv.loc[df_hv.group == 'B', 'purchase_count'] = np.random.poisson(0.5, 10000)
df_hv.loc[df_hv.group == 'C', 'purchase_count'] = np.random.poisson(0.5, 10000)
a_stats = df_hv[df_hv.group=='A'].purchase_count
b_stats = df_hv[df_hv.group=='B'].purchase_count
c_stats = df_hv[df_hv.group=='C'].purchase_count
hist_data = [a_stats, b_stats, c_stats]
group_labels = ['A', 'B','C']# Create distplot with curve_type set to 'normal'
fig = ff.create_distplot(hist_data, group_labels, bin_size=.5,
curve_type='normal',show_rug=False)
fig.layout = go.Layout(
title='Test vs Control Stats',
plot_bgcolor = 'rgb(243,243,243)',
paper_bgcolor = 'rgb(243,243,243)',
)# Plot!
pyoff.iplot(fig)
one_anova_test(a_stats,b_stats,c_stats)
result is not significant
如果我们想看看A,B,C三组是否存在差别,我们可以使用前面的t检验。
双因素方差分析(Two-Way ANOVA)
如果我们需要同时对高价值客户和低价值客户做上面的测试,我们使用双因素方差分析。
#create hv segment
df_hv = pd.DataFrame()
df_hv['customer_id'] = np.array([count for count in range(20000)])
df_hv['segment'] = np.array(['high-value' for _ in range(20000)])
df_hv['group'] = 'control'
df_hv.loc[df_hv.index<10000,'group'] = 'test'
df_hv.loc[df_hv.group == 'control', 'purchase_count'] = np.random.poisson(0.6, 10000)
df_hv.loc[df_hv.group == 'test', 'purchase_count'] = np.random.poisson(0.8, 10000)
df_lv = pd.DataFrame()
df_lv['customer_id'] = np.array([count for count in range(20000,100000)])
df_lv['segment'] = np.array(['low-value' for _ in range(80000)])
df_lv['group'] = 'control'
df_lv.loc[df_lv.index<40000,'group'] = 'test'
df_lv.loc[df_lv.group == 'control', 'purchase_count'] = np.random.poisson(0.2, 40000)
df_lv.loc[df_lv.group == 'test', 'purchase_count'] = np.random.poisson(0.3, 40000)
df_customers = pd.concat([df_hv,df_lv],axis=0)
import statsmodels.formula.api as smf
from statsmodels.stats.anova import anova_lm
model = smf.ols(formula='purchase_count ~ segment + group ', data=df_customers).fit()
aov_table = anova_lm(model, typ=2)
aov_table帮助我们检查实验是否成功。
print(np.round(aov_table,4))
最后一列告诉我们组间的差别是具备统计显著性的。
现在我们已经了解了如何分组且评估结果,但是另外一个事情是:为了达到统计显著性,我们的采样数据必须足够多。让我们来看一下如何计算采样样本大小。
样本大小计算
首先,需要了解两个概念:
- 效应量(Effec Size): 代表测试组和控制组平均差别的量级,是平均差别的方差除以控制组的标准差。
- 功效(Power):测试中发现统计显著性的概率,0.8是常被采用的一个值。
from statsmodels.stats import power
ss_analysis = power.TTestIndPower()#create hv segment
df_hv = pd.DataFrame()
df_hv['customer_id'] = np.array([count for count in range(20000)])
df_hv['segment'] = np.array(['high-value' for _ in range(20000)])
df_hv['prev_purchase_count'] = np.random.poisson(0.7, 20000)
purchase_mean = df_hv.prev_purchase_count.mean()
purchase_std = df_hv.prev_purchase_count.std()
print(purchase_mean,purchase_std)
0.69945 0.8428257324388024
这个例子中,购买次数平均值是0.7,标准差为0.84。
如果我们想在此实验中把平均值提升到0.75,我们计算效应量如下:
effect_size = (0.75 - purchase_mean)/purchase_std
print(effect_size)
0.059976811402908156
之后,样本大小非常容易计算,
alpha = 0.05
power = 0.8
ratio = 1
ss_result = ss_analysis.solve_power(effect_size=effect_size, power=power,alpha=alpha, ratio=ratio , nobs1=None)
print(ss_result)
4364.811097627248
alpha是统计显著性门槛值0.05,测试中测试组和控制组的比为1,结果我们得到了所需的样本大小为4365。
我们来写一个封装的函数,
def calculate_sample_size(c_data, column_name, target,ratio):
value_mean = c_data[column_name].mean()
value_std = c_data[column_name].std()
value_target = value_mean * target
effect_size = (value_target - value_mean)/value_std
power = 0.8
alpha = 0.05
ss_result = ss_analysis.solve_power(effect_size=effect_size, power=power,alpha=alpha, ratio=ratio , nobs1=None)
print(int(ss_result))
如果我们希望测试为购买次数带来5%的增长,
calculate_sample_size(df_hv, 'prev_purchase_count', 1.05,1)
9118
所需样本大小为9118。
总结
本系列暂时完结。整体来说,我们经历了一个完整的数据驱动分析过程。它包括数据处理、数据分析、机器学习等多种技术方法。但笔者认为这些方法还在其次,最为重要的是提出正确的问题、分析和解决问题的思路和如何回答问题。也就是说,我们需要抓住问题的本质和底层逻辑。