A/B测试,一种随机对照试验(Randomized Controlled Trial, RCT),是通过随机分配实验组和对照组来评估干预措施因果效应的一种实验设计方法。它被广泛应用于各个领域,特别是在营销、产品设计以及医学研究中。A/B测试的核心在于通过对照组和实验组的比较来确定干预措施的效果,从而帮助决策者做出数据驱动的决策。
1. 随机化与因果推断
A/B测试之所以被称为因果推断的“黄金标准”,是因为其独特的随机化设计。通过将样本随机分配到不同的实验组和对照组,A/B测试能够有效消除选择偏差和混杂因素的影响,确保干预效果的可靠性。随机化确保了每个个体被分配到任意组的概率相等,从而消除了自选择偏差和未观测到的外部变量的干扰,保证实验结果更接近因果关系。
2. 样本量的计算
在进行A/B测试之前,必须确保样本量足够大,以便能够获得具有统计显著性的结果。样本量的计算需要考虑到多个因素,如预期的效果大小、显著性水平、统计功效等。常见的样本量计算公式为:
其中:
- n:每组的样本量。
- Z_{\alpha/2}:表示显著性水平(α)的Z值,通常对于α=0.05,Z_{\alpha/2}约为1.96。
- Z_{\beta}:表示统计功效(1-β)的Z值,通常对于功效为80%时,Z_{\beta}约为0.84。
- σ:实验组和对照组的标准差,衡量数据的波动程度。
- d:实验组与对照组之间的期望均值差异。
该公式帮助我们确定所需的样本量,以确保实验能够检测到预期的效果差异,并且在给定的显著性水平和统计功效下,结果具有高可信度。
3. 第一类错误和第二类错误
A/B测试中需要考虑两种错误类型:
-
第一类错误(Type I Error):拒绝原假设时,实际上原假设为真。换句话说,错误地认为有显著差异存在。第一类错误的概率由显著性水平(α)控制,通常设置为0.05。
-
第二类错误(Type II Error):未拒绝原假设时,实际上原假设为假。换句话说,错误地认为没有显著差异。第二类错误的概率由功效(1 - β)控制,通常设置为80%(β = 0.2)。
4. A/B测试的应用场景
A/B测试在实践中有广泛的应用。例如,在网站优化中,可以通过对照组和实验组对比不同的网页设计,以评估哪一种设计能有效提高用户点击率(CTR)或转化率。同样,在营销中,通过A/B测试对比不同的广告投放策略,可以帮助优化广告效果。
5. A/B测试的局限性
尽管A/B测试是因果推断的强有力工具,但它也有其局限性。首先,A/B测试需要大量的样本才能获得统计显著的结果,这对于一些小型项目可能不现实。其次,A/B测试只能在特定的环境下进行因果推断,对于复杂的长期效应或外部干预的影响可能无法准确评估。
6. 注意点
1、分流和分层的区别
-
分流:指的是将流量或样本随机分配到实验组和对照组中,确保每个组都有足够的代表性。通常的分配比例是50%/50%或其他根据实验需求的比例。
-
分层:是指在实验设计中根据某些特征(如性别、年龄、地域等)将样本分成不同的层次,然后在每个层次内进行随机分配,以确保各个层次的影响因素得到控制。分层可以提高实验结果的准确性和稳健性,减少潜在的混杂变量影响。
2、分流和分层的区别
A/B测试要求每天的样本量都达到最小样本量。
3、灰度测试
灰度测试是逐步扩展实验范围的测试方法,通常用于减少产品发布时的风险和优化用户体验。有时用于将实验组的用户逐步引入,避免一次性大量变动的影响。
A/B测试python案例(数据及代码取自kaggleA/B TESTING DATA FOR A CONSERVATION CAMPAIGN | Kaggle)
注意:这只是AB测试的其中一部分,简化了前期的工作。以下只是最后的分析部分。并且这个A/B测试数据属于自然分组,个人认为应该引入合适的观察性因果推断方法,如PSM/因果森林等,此处仅作为简单示例。
导入库
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
import statsmodels.formula.api as smf
import scipy.stats as stats
from statsmodels.stats.power import NormalIndPower
读取实验数据集并进行初步的数据结构检查,包括查看数据维度、变量名、缺失值情况以及样本唯一性(如 user id),以验证数据质量并为后续分析做准备。
df = pd.read_csv('./data/conservation_dataset.csv')
print(df.head())
#%%
print("columns is:",df.columns)
#%%
print("shape is:",df.shape)
#%%
# 检查缺失值和数据类型
print("info is:",df.info())
#%%
print(df['user id'].nunique()) ## ## 检查user id是不是都是独立的
#%%
df.drop(['Unnamed: 0', 'user id'],axis=1,inplace=True)
print("drop_data",df.head())
#%%
## 计算每个值的占比
print("每个值的占比为:",df['message_type'].value_counts(normalize=True) )
构造列联表并计算期望频数,以验证卡方检验的适用前提是否满足(期望频数需大于等于5),从而判断是否可对“消息类型”和“参与行为”之间的关联性进行卡方检验。
# 第一步:创建列联表
observed_frequencies = pd.crosstab(df['message_type'], df['engaged'])
# 第二步:计算期望频率
row_totals = observed_frequencies.sum(axis=1) # 每行的总和
col_totals = observed_frequencies.sum(axis=0) # 每列的总和
grand_total = observed_frequencies.values.sum() # 总样本量
# 使用广播计算期望频率
expected = np.outer(row_totals, col_totals) / grand_total
expected_frequencies = pd.DataFrame(expected, index=observed_frequencies.index, columns=observed_frequencies.columns)
# 第三步:检查所有期望频率是否 ≥ 5
min_expected_frequency = expected_frequencies.min().min() # 找出最小的期望频率
print("观察频数:")
print(observed_frequencies)
print("\n期望频数:")
print(expected_frequencies)
print(f"\n最小期望频率:{min_expected_frequency}")
# 决策:是否可以进行卡方检验?
if min_expected_frequency >= 5:
print("可以进行卡方检验。")
else:
print("不能进行卡方检验。考虑使用费舍尔精确检验。")
执行卡方检验以评估“消息类型”与“用户参与度”之间是否存在统计学上的显著关联,通过检验观测频率与期望频率之间的差异是否大于随机误差所能解释的范围。
#%%
# 确保导入库
from scipy.stats import chi2_contingency
observed = [
[23104, 420], # 普通组
[550154, 14423] # 个性化组
]
# 执行卡方检验
chi2, p_value, dof, expected = chi2_contingency(observed)
print("卡方统计量:", chi2)
print("P值:", p_value)
print("自由度:", dof)
print("\n期望频率(由SciPy计算):")
print(expected)
# 结果解释
if p_value < 0.05:
print("结果:检测到显著差异(拒绝原假设)。")
else:
print("结果:未检测到显著差异(无法拒绝原假设)。")
通过自助法(Bootstrap)估计普通组与个性化组的用户参与度均值分布,为后续的独立样本t检验提供稳健的均值估计,同时可视化其概率密度以直观观察组间差异。
# 用自助法(Bootstrap)来估计两个不同组(普通组和个性化组)的用户参与度(engaged)的平均值,并绘制这两个组的平均值的密度图。
# 测试组的子集
generic_group = df[df['message_type']=='Generic']['engaged']
personalized_group = df[df['message_type']=='Personalized']['engaged']
boot_personalized=[]
boot_generic=[]
# 自助法采样
for i in range (1000):
boot_mean=personalized_group.sample(frac=1,replace=True).mean() # 从personalized_group中随机抽取与原数据等量的样本,允许重复(replace=True)
boot_personalized.append(boot_mean) # 将每次采样的平均值存储到boot_personalized列表中
boot_mean=generic_group.sample(frac=1,replace=True).mean()
boot_generic.append(boot_mean)
# 绘制两个组的平均值的密度图
boot_personalized=pd.DataFrame(boot_personalized)
boot_generic=pd.DataFrame(boot_generic)
plt.figure(figsize=(7,7))
boot_personalized.plot(kind='density')
plt.title("boot_personalized")
plt.show()
plt.figure(figsize=(7,7))
boot_generic.plot(kind='density')
plt.title("boot_generic")
plt.show()
基于自助法采样结果执行Welch's T检验(即假设方差不等的t检验),检验两个组(普通组与个性化组)之间的平均参与度是否存在显著性差异。
from scipy.stats import ttest_ind
# 执行Welch的T检验(假设方差不相等)
t_stat, p_value = ttest_ind(boot_personalized[0], boot_generic[0], equal_var=False)
# 显示结果
print("对自助法样本进行Welch的T检验:")
print("T统计量:", t_stat)
print("P值:", p_value)
# 结果解释
if p_value < 0.05:
print("结果:检测到显著差异(拒绝原假设)。")
else:
print("结果:未检测到显著差异(无法拒绝原假设)。")
通过条形图对比“观测频率”与“期望频率”,直观呈现不同消息类型下用户参与与未参与的分布差异,以辅助理解卡方检验结果并揭示数据中潜在模式。
plt.rcParams['font.sans-serif'] = ['KaiTi'] # 使用黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
# 创建图形和坐标轴
fig, ax = plt.subplots(1, 2, figsize=(12, 6))
# 观测频率图
sns.barplot(x=observed_frequencies.index, y=observed_frequencies[False], ax=ax[0], color="red", label="未参与")
sns.barplot(x=observed_frequencies.index, y=observed_frequencies[True], ax=ax[0], color="blue", label="参与")
ax[0].set_title("观测到的参与频率")
ax[0].set_xlabel("消息类型")
ax[0].set_ylabel("计数")
ax[0].legend()
# 期望频率图
sns.barplot(x=expected_frequencies.index, y=expected_frequencies[False], ax=ax[1], color="red", label="未参与")
sns.barplot(x=expected_frequencies.index, y=expected_frequencies[True], ax=ax[1], color="blue", label="参与")
ax[1].set_title("期望的参与频率")
ax[1].set_xlabel("消息类型")
ax[1].set_ylabel("计数")
ax[1].legend()
# 调整布局并显示
plt.tight_layout()
plt.show()
使用箱线图对比参与与未参与用户在“看到的消息总数”上的分布差异,评估信息接触量是否对用户参与行为存在潜在影响。
# 绘制箱线图比较参与和未参与用户的“total_messages_seen”
plt.figure(figsize=(16, 12)) # 增大图形尺寸以提高清晰度
# 绘制带有均值标记的箱线图
sns.boxplot(x='engaged', y='total_messages_seen', data=df, showmeans=True,
meanprops={"marker": "o", "markerfacecolor": "red", "markeredgecolor": "black"})
# 如有需要,调整y轴范围
plt.ylim(0, 2000) # 根据数据范围进行调整
plt.title("按参与状态比较看到的总消息数", fontsize=14)
plt.xlabel("参与", fontsize=12)
plt.ylabel("看到的总消息数", fontsize=12)
plt.show()
构建并可视化“最活跃日期”与“用户参与行为”之间的列联关系,识别在不同日期发送消息时,用户响应程度的差异,以支持后续策略优化。
# 绘制带有注释的列联表
plt.figure(figsize=(10, 6))
sns.heatmap(contingency_table, annot=True, fmt="d", cmap="coolwarm", cbar=True)
plt.title("'engaged'和'most engagement day'的列联表")
plt.xlabel("最活跃的日期")
plt.ylabel("参与")
plt.savefig('heatmap_most_engagement_day.png', dpi=300)
plt.show()
#%%
# 创建列联表
contingency_table = pd.crosstab([df['most engagement hour'], df['message_type']], df['engaged'])
# 执行卡方检验
chi2, p_value, dof, expected = chi2_contingency(contingency_table)
# 显示结果
print("卡方统计量:", chi2)
print("P值:", p_value)
print("自由度:", dof)
print("\n期望频率:")
print(expected)
# 结果解释
if p_value < 0.05:
print("结果:检测到显著差异(拒绝原假设)。")
else:
print("结果:未检测到显著差异(无法拒绝原假设)。")
通过热力图分析一天中不同时间段的用户参与热度,结合消息类型维度,以识别用户参与的高峰时段,优化推送策略。
# 寻找最优的发送消息的时段
# 用于热力图的透视表
heatmap_data = df.pivot_table(values='engaged', index='message_type', columns='most engagement hour', aggfunc='sum')
# 创建热力图
plt.figure(figsize=(10, 6))
sns.heatmap(heatmap_data, annot=True, fmt="d", cmap="coolwarm", cbar=True)
plt.title("按消息类型划分的高峰参与时段")
plt.xlabel("一天中的小时")
plt.ylabel("消息类型")
plt.savefig('heatmap_most_engagement_hours.png')
plt.show()
构建逻辑回归模型以预测用户参与概率,将“看到的消息总数”和“用户最常参与的时间段”作为解释变量,拟合并分析其对用户参与行为的影响程度和方向。
# 选择自变量(X)和因变量(Y)
X = df[['total_messages_seen', 'most engagement hour']] # 自变量
Y = df['engaged'] # 因变量(参与)
# 添加常数项以用于截距
X = sm.add_constant(X)
# 拟合逻辑回归模型
logit_model = sm.Logit(Y, X)
result = logit_model.fit()
# 打印摘要
print(result.summary())
#%%
# 生成预测概率
predictions = result.predict(X)
# 对值进行排序以获得平滑的线
sorted_idx = np.argsort(df['total_messages_seen'])
sorted_messages = df['total_messages_seen'][sorted_idx]
sorted_predictions = predictions[sorted_idx]
# 折线图
plt.figure(figsize=(10, 6))
sns.lineplot(x=sorted_messages, y=sorted_predictions, color='blue')
plt.title("看到的总消息数与参与的平滑预测概率")
plt.xlabel("看到的总消息数")
plt.ylabel("参与的预测概率")
plt.grid(True)
# plt.savefig('predictplot.png', dpi=300)
plt.show()