每一个对基于大语言模型的协作功能进行因果推断的产品实验团队最终都会遇到同样的问题:你的用户并非相互独立的。你们的团队将这款人工智能会议总结工具部署到了平台上的一半企业账户中。部署过程进行得很顺利,一半的账户启用了该功能,另一半则没有。你们等待着,希望对照组的任务完成情况保持不变,而实验组的完成速度逐渐加快。然而两周后,对照组的数据也开始发生了变化——虽然变化幅度不大,但确实存在明显趋势。这说明,对于这些账户而言,这个功能显然并不适合他们使用。你们已经检查了两次部署配置,但还是找不到问题所在。
在深入分析日志之前,你们就已经知道了问题的根源:人工智能生成的会议总结被发布到了共享的Slack频道中,由AI起草的文档出现在共享的Google Drive文件夹里,而AI提供的代码审查建议也会出现在所有工程师都会看到的拉取请求中。因此,实验组用户的某些行为发生了变化,而这些变化又会通过协作关系传播到对照组中。
这就是所谓的“协作污染陷阱”。在任何涉及共享内容的生成式人工智能产品中,都会出现这种问题——队友会阅读AI生成的会议记录,同事会编辑AI起草的文档,评审人员会评估AI提供的代码建议,整个团队也会回复AI生成的电子邮件。从用户层面上来看,随机分配实验组的假设认为某个用户的处理方式不会影响其他用户的结果;但在协作环境中,这个假设本身就是错误的。因此,在产品实验中,该功能的实际效果与其在对照组中产生的溢出效应会被混在一起进行评估。
如果在一个基于用户级别的A/B测试环境中部署协作型人工智能功能,那么这种实验方式就违背了“稳定单元处理值假设”(SUTVA)。解决这个问题的方法是采用集群随机化方法:在工作空间层面进行随机分配,使得整个团队要么都启用该功能,要么都不启用它,然后再直接分析不同工作空间之间的溢出效应。
本教程会以一个包含50,000名用户的合成SaaS数据集为例,详细介绍整个实验流程。在这个数据集中,真实的因果关系是已知的。你们将学习如何使用集群随机化方法进行实验设计,包括如何进行集群分配、如何使用有偏见的用户级OLS模型进行分析、如何利用集群加权最小二乘法获得准确的标准误差、如何通过双重暴露分解方法区分直接效应和溢出效应,以及如何使用集群自助法计算置信区间。同时,你们也会了解这种方法在哪些地方会隐含地出现问题。
配套代码:所有代码块都可以在github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/05_cluster_randomization处的配套笔记本中端到端运行。该笔记本(
cluster_randomization_demo.ipynb)中的所有输出结果都已经预先计算好了,因此你们可以在GitHub上先查看这些结果,然后再在本地进行实验。
目录
为什么在协作环境下,用户级别的A/B随机实验会失效
A/B测试的数学模型非常严谨,因为一个用户的实验处理方式不会影响另一个用户的实验结果。比如通过抛硬币来决定哪些用户使用AI功能——这种随机分配方式本身就能消除所有可能的干扰因素。然而,在协作环境中,这种假设会被打破,原因有三点:
共享的资源会带来干扰。由AI生成的摘要会被所有团队成员看到,AI辅助编辑的文档也会被所有人修改,而AI提供的代码审查建议也会被所有评审者参考。即使控制组用户没有使用这些功能,他们依然会接触到这些由AI生成的资源,因此AI内容对他们的行为产生的影响也会反映在实验结果中。
共同的工作流程也会造成干扰。
如果接受实验处理的用户认为团队成员已经阅读了AI生成的摘要,他们可能会写出更简短的后续笔记;而同一团队的控制组用户看到这些简短的笔记后,会花费更少的时间去阅读它们,从而导致他们的实验时长发生变化。这样一来,接受实验处理用户的操作方式就会影响控制组的结果,而这正是SUTVA方法所极力避免的。
信息传播会加速这种效应。在那些采用了AI功能的团队中,资深用户会率先尝试这些新功能,然后通过跨团队的沟通渠道影响其他成员。如果实验组生成的AI辅助内容被控制组阅读并模仿使用,那么控制组实际上就已经在没有明确意识到这一点的情况下接受了同样的“实验处理”。
这三种机制都会导致同样的结果:在用户层面进行直接比较会低估该功能的实际效果,因为对照组已不再是一个纯粹的“反事实组”。在本教程使用的合成数据集中,对于接受处理的用户而言,这一功能的直接效果为会议时间增加0.80分钟;而对于那些在不同工作空间之间合作的对照组用户来说,这种效果的溢出效应为会议时间增加0.20分钟。如果使用简单的用户层面OLS分析方法,计算出的结果仅为0.6723,这实际上是对直接效应的16%的低估;同时,该分析方法得出的标准误差也大约是小实际值的19倍——因为该方法将50,000名用户视为彼此独立的个体,而实际上这种处理方式仅在50个小组中进行了随机分配。这样的错误绝非微不足道,它很可能导致一个有缺陷的功能被错误地投入实际使用。
集群随机化究竟起到了什么作用
集群随机化是在工作空间层面进行随机分配的,因此整个团队都会被分到同一组中,这样就能将大部分干扰效应限制在它们原本应该出现的范围内,从而使得那些跨工作空间产生的影响成为可以直接被模型预测的因素。

图1:集群随机化所针对的SUTVA违规现象示意图。在处于处理组的工作空间中,所有用户都会使用到这一人工智能功能;而在对照组工作空间中,所有用户都应该无法看到这一功能,但那些在不同工作空间之间进行合作的用户仍然会接触到这些通过共享的Slack消息、文档和代码评审等方式传播出来的信息。因此,这些受到溢出效应影响的用户实际上也部分接受了这一处理。集群随机化并不能让干扰效应完全消失,但它能将它们限制在工作空间的范围内,使得那些跨工作空间产生的影响成为可以被直接估计的因素。
如果某个工作空间被纳入处理组,那么该工作空间内的所有用户都会使用到这一功能;而如果它属于对照组,那么里面的用户就都不会使用这一功能。在同一工作空间内部产生干扰效应是完全可以接受的,因为所有团队成员都接受了相同的处理方式,而且工作空间层面的平均值已经能够反映整个处理效果。这种设计的目的就是控制不同工作空间之间的干扰效应。
这种估计方法是在一系列假设的基础上运行的,而每一个假设都有一个值得了解的名称,因为在本教程的最后部分,我们会详细分析这些假设失败时可能导致的具体问题。
-
集群层面的随机分配。处理方式的分配是在集群层面通过完全随机的机制来完成的。哪些工作空间会被分到处理组中,这一决定与这些工作空间内部的潜在结果无关。
-
部分干扰效应。干扰效应仅发生在同一集群内部,而不会跨越不同的集群。Hudgens等人也指出了这一点。处于处理组的工作空间中的用户可以影响同一工作空间内的其他用户,但无法影响到另一个工作空间中的用户。这就是集群随机化所基于的假设。
-
集群层面的SUTVA原则。一个工作空间的处理方式是一个单一的、明确的整体。这个功能只有一个版本,而同一集群内部在接触这一功能方面的差异会被纳入到集群层面的效应中来进行分析。
-
集群之间的可互换性。在进行随机分配之前,处理组和工作对照组的工作空间是可以互相替换的。随机化机制本身就确保了这一点。
-
足够的集群数量。
基于集群的推断方法依赖于各个集群之间的中心极限定理。在实际应用中,人们通常认为至少需要30个集群才能进行有效的分析,不过具体的阈值还会根据集群大小的差异以及所选择的检验统计量而有所不同。如果集群数量较少,就需要使用其他推断方法,比如随机化推断或集群野生自助法。
在这里,部分干扰效应是被视为理所当然的。进行集群随机化的根本目的就在于确保跨集群之间的溢出效应比同一集群内部的溢出效应更小、发生速度也更慢,这样就能将大部分干扰作用准确地限定在应该出现它们的地方(参见Ugander等人的研究)。当跨集群溢出效应确实存在时,采用“双暴露模型”就可以直接识别并估算这种溢出效应的大小。
先决条件
您需要使用Python 3.11或更高版本,并且需要对pandas和线性回归有基本的了解,同时还需要对普通最小二乘法有所认识。
请安装本教程所需的软件包:
pip install numpy pandas statsmodels scipy matplotlib
这些软件包的作用如下:Python 3.11及更高版本是程序运行的基础;pandas用于加载数据并生成集群分配方案;NumPy负责处理数组运算及自助抽样操作;statsmodels能够进行各种回归分析,包括普通的OLS分析、基于集群权重的最小二乘法分析,以及带有集群鲁棒标准误差的双暴露模型分析;Scipy提供了核密度诊断图的功能,而matplotlib则用于将这些图表呈现出来。
请克隆配套代码仓库以获取合成数据集:
git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
执行这些命令后会发生什么:克隆操作会下载配套代码仓库中的内容,而generate_data.py脚本则会生成一个包含50,000名用户的合成数据集。通过设置种子值42,可以确保数据的重复性;由于该数据集包含50,000名用户,因此每个工作空间大约有1,000名用户,这样的规模使得在集群层面进行分析时结果能够趋于稳定。最终生成的CSV文件会保存在data/synthetic_llm_logs.csv路径下。
设置实验环境
这个合成数据集模拟了一个拥有50,000名用户、分布在50个工作空间中的SaaS产品。其中,协作AI功能仅对25个随机选定的工作空间提供支持,而其余25个工作空间则无法使用这一功能。
当控制组用户在不同工作空间之间进行协作时,就会产生溢出效应。在本教程中,opt_in_agent_mode == 1这个参数被用来模拟这种跨工作空间的协作行为:那些主动选择使用AI工具的用户,才会去阅读队友撰写的文档、查看Slack聊天记录,或者处理那些包含AI分析结果的需求提交。在实际情况中,您应该用真实的协作关系图来替代这个模拟参数,例如共享频道的使用情况、文档的共同作者信息,或者审稿人的重叠情况等。因为opt_in_agent_mode反映的是用户的自愿选择行为,并不包含任何随机因素,所以在实际实验中,溢出系数能够消除选择偏差对结果的影响。而在生产环境中,溢出效应的判断应该基于真实的协作关系图;使用行为模拟参数会导致选择偏差,而“双暴露模型”是无法纠正这种偏差的。
本教程通过将已知的真实效应逐层叠加到工作区级别的基线上,从而从零开始构建`session_minutes_obs`。CSV文件中的`session_minutes`列被特意保留了下来。这种分离设计使得你可以验证所有的估算工具是否能够正确地提取这些预设的效应。
在实验场景中预设的真实效应包括:对于接受处理的用户而言,存在+0.80分钟的直接效应;而对于那些受到间接影响的对照组用户,则存在+0.20分钟的溢出效应。只有掌握了这两个数值,你才能确认你的估算工具确实能够正确地识别这些效应。
步骤1:构建群体分配机制及溢出效应模型
第一段代码用于加载数据,在群体层面将工作区分配给不同处理组,标记出受到溢出影响的用户,并生成一个已知真实结果的观测数据集。该数据集以工作区级别的基线值为起点,因此同一工作区内的数据之间的相关性是真实的。随后,代码会添加针对接受处理用户的直接效应、针对受影响对照组的溢出效应,以及高斯噪声。
import numpy as np
import pandas as pd
DIRECT_EFFECT = 0.80
SPILLOVER EFFECT = 0.20
DATA_SEED = 42
OUTCOME_NOISE_SD = 0.30
df = pd.read_csv("data/synthetic_llm_logs.csv")
rng = np.random.default_rng(DATA_SEED)
df["treated_workspace"] = (df["workspace_id"] < 25).astype(int)
df["treated_user"] = df["treatedWorkspace"]
df["spillover_exposed"] = (
(df["treated/workspace"] == 0) & (df["opt_in_agent_mode"] == 1)
).astype(int)
wsbaseline = pd.DataFrame({
"workspace_id": np.arange(50),
"ws_baseline": rng.normal(5.0, 0.30, size=50),
})
df = df.merge(wsBaseline, on="workspace_id")
noise = rng.normal(0, OUTCOME_NOISE_SD, size=len(df))
df["session_minutes_obs"] = (
df["wsbaseline"]
+ DIRECT_EFFECT * df["treated_user"]
+ SPILLOVER EFFECT * df["spillover_exposed"]
+ noise
)
df["exposure"] = np.select(
[df["treated_user"] == 1, df["spillover_exposed"] == 1],
["direct", "spillover"],
default="pure_control",
)
print(f"总用户数: {len(df):,}")
print(f"接受处理的工作区数量: {df[df.treated_workspace == 1].workspace_id.nunique()}")
print(f"对照组工作区数量: {df[df.treatedWorkspace == 0].workspace_id.nunique()}")
print(f"接受处理的用户数量: {df.treated_user.sum():,}")
print(f"纯对照组用户数量: {(df.exposure == 'pure_control').sum():,}")
print(f"受到溢出影响的用户数量:{(df.exposure == 'spillover').sum():,}")
ws_sizes = df.groupby("workspace_id").size()
print(f"工作区规模分布:最小值={wssizes.min()} 中位数={int(ws_sizes.median())} 最大值={ws_sizes.max()}")
预期输出结果:
总用户数: 50,000
接受处理的工作区数量: 25
对照组工作区数量: 25
接受处理的用户数量: 24,937
纯对照组用户数量: 18,319
受到溢出影响的用户数量: 6,744
工作区规模分布:最小值=923 中位数=1002 最大值=1052
具体情况如下:工作空间ID从0到24被划分为实验组,而ID从25到49则被划分为对照组,因此共有24,937名实验组用户和25,063名对照组用户。在对照组中,有6,744名用户被标记为“溢出暴露”用户,因为他们选择了代理模式,并且所处的对照组工作空间可能会让他们通过跨团队渠道看到实验组的工作空间输出结果;其余的18,319名用户则是纯粹的对照组用户,他们的数据没有受到该功能的影响。这些工作空间的用户数量范围在923到1,052人之间,这个范围已经足够接近,因此无论是按照集群权重还是不按权重进行估算,得到的结果都会相似。观察到的结果session_minutes_obs实际上反映了已知的真实情况:实验组用户的工作时间会增加0.80分钟,溢出暴露组用户的工作时间会增加0.20分钟,而所有用户的实际工作时间还会受到标准差为0.30分钟的高斯噪声的影响。

图2(上图):在包含50,000名用户的数据集中所划分的三个组别。上方面板显示了每个组别的观察结果分布情况,其中虚线垂直线表示各组的平均值(纯粹对照组为5.06分钟,溢出暴露组为5.27分钟,实验组为5.79分钟)。溢出暴露组的结果分布位于纯粹对照组和实验组的结果分布之间,这种差异就是那些使用简单用户级估算方法的人会错误地计入对照组基线数据中的部分。下方面板将相同的组别转化为具体的用户数量:纯粹对照组有18,319名用户,溢出暴露组有6,744名用户,实验组有24,937名用户。虽然图1是以示意图的形式展示了SUTVA违规现象,但这张图则是在实际数据层面上展现了这一情况,而这三组数据的结构恰恰就是步骤4中使用的双暴露模型所能够识别出来的。
步骤2:简单的用户级OLS分析(存在偏差且过于自信)
这种简单的分析方法完全忽略了群体聚类的效应,而是直接根据每个用户的实验组归属来预测观察结果,并计算出标准误差,仿佛所有用户都是独立样本一样。这样做会同时导致两个问题。
import statsmodels.formula.api as smf
naive = smf.ols("session_minutes_obs ~ treated_user", data=df).fit()
print(f"简单估计值:{naive.params['treated_user']:+.4f}分钟")
print(f"简单估计的标准误差:{naive.bse['treated_user']:.4f}分钟 (实际数值可能被低估)")
ci = naive.conf_int().loc["treated_user"].tolist()
print(f"简单估计的95%置信区间:[{ci[0]:+.4f}, {ci[1]:+.4f}]")
print(f"真实值:+0.80分钟")
print(f"偏差:{naive.params['treated_user'] - 0.80:+.4f}分钟")
预期输出结果:
简单估计值:+0.6723分钟
简单估计的标准误差:0.0034分钟 (实际数值可能被低估)
简单估计的95%置信区间: [+0.6656, +0.6790]
真实值:+0.80分钟
偏差:-0.1277分钟
实际情况如下: 预测值为+0.6723,这一数值比实际直接效应+0.80低了16%。这种偏差由两部分原因造成。首先是由于“溢出效应”导致的干扰:有6,744名使用受处理工作空间的用户,他们的数据高于纯粹的对照组基线值,这使得对照组的平均值上升,从而缩小了“受处理组-对照组”之间的差距。其次是由于工作空间基线值的不平衡:由于只有50个实验组,随机分配并不能确保受处理组与对照组的工作空间基线值相等。在这个数据集中,特定的随机数生成方式使得受处理组的基线值略低于对照组的基线值,这也进一步降低了预测结果的准确性。这一结论具有普遍性:当实验组数量较小时,只有在实验开始前对可观察的工作空间特征进行平衡性检查,才能有效避免那些无法通过标准误差校正来消除的组间差异。
标准误差才是更值得关注的数据。其值为0.0034,这一数值反映了50,000名受处理用户的个体差异。由此计算出的95%置信区间为[+0.6656, +0.6790],这个区间完全排除了实际真实值的可能性,而且其宽度仅为设计允许的宽度的二十分之一。如果标准误差的实际大小是理论值的19倍,那么相应的t统计量的数值也会被夸大19倍,这样一来,基于这种统计分析得出的p值就会显得比实际情况要显著得多。任何阅读这份报告的人都会误以为直接效应实际上约为0.67,但这个数字是错误的,其精确度也不符合实际。
步骤3:基于群组加权的最小二乘法分析(使用真实标准误差)
要解决标准误差的问题,就需要先将50个工作空间的平均值进行汇总,然后根据这些平均值的大小,再利用工作空间规模作为权重,对这些平均值与处理变量进行回归分析。此时的推断是基于50个观测数据进行的。
import statsmodels.api as sm
ws = (
df.groupby("workspace_id")
.agg(ws_mean=("session_minutes_obs", "mean"),
ws_size>("user_id", "count"),
treated=("treated_workspace", "max"))
.reset_index()
)
X_ws = sm.add_constant(ws["treated"])
wls = sm.WLS(ws["ws_mean"], XWs, weights=ws["ws_size']).fit()
wls_ci = wls.conf_int().loc["treated"].tolist()
print(f"WLS群组均值对比结果:{wls.params['treated']:+.4f} 分钟")
print(f"WLS标准误差: {wls.bse['treated']:.4f} (基于50个实验组的数据)")
print(f"WLS 95%置信区间: [{wls_ci[0]:+.4f}, {wls_ci[1]:+.4f}]")
预期输出结果:
WLS群组均值对比结果:+0.6723 分钟
WLS标准误差: 0.0652 (基于50个实验组的数据)
WLS 95%置信区间: [+0.5412, +0.8035]
实际情况如下:群组均值对比结果与之前的简单估算值相同,均为+0.6723,因为这种基于群组平均值的计算方法实际上也是对同一组用户数据的不同汇总方式。但标准误差却发生了变化:现在的标准误差为0.0652,是之前0.0034的约19倍,这一数值真正反映了50个实验组平均值之间的差异。95%置信区间为[+0.5412, +0.8035],这个区间几乎无法覆盖实际真实值的范围。通过使用群组加权的最小二乘法分析,我们确实解决了推断上的问题,因此现在的标准误差才真正反映了实验设计的特点;但这个问题并没有得到彻底解决——对照组的工作空间平均值仍然包含了那些受到“溢出效应”影响的用户,因此这个估算结果仍然存在偏差,不能被看作是准确的平均处理效应。下一步我们需要进一步分离这两个因素。
步骤4:双暴露分解法(无偏直接效应与溢出效应分析)
这种双暴露模型将每位用户的暴露情况视为三类变量之一(直接效应、溢出效应或纯粹的对照组),并针对其中两种非基线类别对结果进行回归分析。Aronow等人提出了这一方法。所谓“纯粹的对照组”,实际上就是被忽略的参照组;因此这两个回归系数都具有明确的实际意义:其中一个系数代表该特征的直接效应,另一个系数则代表那些在不同工作空间中相互协作的对照组用户所受到的溢出效应。
df["is_direct"] = (df["exposure"] == "direct").astype(int)
df["is_spillover"] = (df["exposure"] == "spillover").astype(int)
two_exp = smf.ols(
"session_minutes_obs ~ is_direct + is_spillover",
data=df,
).fit(cov_type="cluster", cov_kwds={"groups": df["workspace_id"]})
direct = two_exp.params["is_direct"]
spillover = two_exp.params["is_spillover"]
direct_ci = two_exp.conf_int().loc["is_direct"].tolist()
spillover_ci = two_exp.conf_int().loc["is_spillover"].tolist()
print(f"直接效应:{direct:+.4f} 分钟 (真实值 = +0.80)")
print(f"标准误差:{two_exp.bse['is_direct']:.4f}")
print(f"95%置信区间:[{direct_ci[0]:+.4f}, {direct_ci[1]:+.4f}]")
print(f"溢出效应:{spillover:+.4f} 分钟 (真实值 = +0.20)")
print(f"标准误差:{two_exp.bse['is_spillover']:.4f}")
print(f"95%置信区间:[{spillover_ci[0]:+.4f}, {spillover_ci[1]:+.4f}]")
spillover_share = (df["exposure"] == "spillover").mean()
projected = direct + spillover_share * spillover
print(f"所有用户的溢出效应占比:{spillover_share:.4f}")
print(f"在全范围推广情况下的预计总效果:{projected:+.4f} 分钟")
预期输出结果:
直接效应:+0.7284 分钟 (真实值 = +0.80)
标准误差:0.0647
95%置信区间: [+0.6016, +0.8552]
溢出效应:+0.2083 分钟 (真实值 = +0.20)
标准误差:0.0038
95%置信区间: [+0.2008, +0.2158]
所有用户的溢出效应占比:0.1349
全范围推广情况下的预计总效果:+0.7565 分钟
具体分析过程如下:通过针对以workspace_id为分类依据的三种暴露类型进行回归分析,我们得到了两个清晰的回归系数。直接效应为+0.7284,其95%置信区间为 [+0.6016, +0.8552],这一区间包含了真实值+0.80;溢出效应为+0.2083,其95%置信区间为 [+0.2008, +0.2158],这一区间也紧密覆盖了真实值+0.20。由于在本次分析中,所有25个对照组群体所受到的溢出效应是相同的,因此溢出效应的标准误差仅为0.0038;而在实际数据中,如果不同群体的溢出效应强度存在差异,那么标准误差往往会显著增大。预计总效果为+0.7565分钟,这一数值考虑到了在当前部署规模下,会有0.1349的比例的用户受到溢出效应的影响。在实际应用中,你可以根据你的协作关系图预测出的实际占比来替换这个数值。需要注意的是,这个预测值属于你的部署计划中的设计参数,因此在报告结果时必须明确说明所采用的占比数值。
步骤5:聚类自助法置信区间
聚类自助法通过对整个数据集进行重新抽样,来验证在K=50时,步骤4中计算得出的置信区间是否仍然成立,而无需假设中心极限定理已经完全发挥作用。当K值较大且各数据集的大小大致相同时,基于聚类设计的分析方法所计算出的标准误差会较为准确;自助法能够证实这一结论在实际数据中也同样适用。如果对单个用户进行重新抽样,那么计算得出的方差将会被低估,因为同一数据集中的用户会共享相同的聚类分配结果以及数据集层面的基线值;而聚类自助法则能够保留这种相关性结构。
def naive_point(d):
return smf.ols(
"session_minutes_obs ~ treated_user", data=d
).fit().params["treated_user"]
def wls_point(d):
w = (d.groupby("workspace_id").agg(
ws_mean=("session_minutes_obs", "mean"),
ws_size>("user_id", "count"),
treated=("treated_workspace", "max")).reset_index())
X = sm.add_constant(w["treated"])
return sm.WLS(w["ws_mean"], X, weights=w["ws_size']).fit().params["treated"]
def two_exp_point(d):
fit = smf.ols(
"session_minutes_obs ~ is_direct + is_spillover", data=d
).fit(cov_type="cluster", cov_kwds={"groups": d["workspace_id"]})
return fit.params["is_direct"], fit.params["is_spillover"]
rng_boot = np.random.default_rng(7)
ws_ids = df["workspace_id"].unique()
k = len(ws_ids)
reps = {"naive": [], "cluster_wls": [], "direct": [], "spillover": []}
for _ in range(500):
draw = rng.boot.choice(ws_ids, size=k, replace=True)
sample = pd.concat(
[df[df["workspace_id"] == wid] for wid in draw],
ignore_index=True,
)
reps["naive"].append(naive_point(sample))
reps["cluster_wls"].append(wls_point(sample))
d_b, s_b = two_exp_point(sample)
reps["direct"].append(d_b)
reps["spillover"].append(s_b)
for key, truth in [("naive", 0.80), ("cluster_wls", 0.80),
("direct", 0.80), ("spillover", 0.20)]:
arr = np.array(reps[key])
lo, hi = nppercentile(arr, [2.5, 97.5])
covers = "covers" if lo <= truth <= hi else "misses"
print(f"{key:<13} 95%置信区间: [{lo:+.4f}, {hi:+.4f}] ({covers} {truth:+.2f})")
预期输出结果:
naive 95%置信区间: [+0.5386, +0.7966] (未包含+0.80)
cluster_wls 95%置信区间: [+0.5386, +0.7966] (未包含+0.80)
direct 95%置信区间: [+0.5931, +0.8519] (包含+0.80)
spillover 95%置信区间: [+0.2008, +0.2164] (包含+0.20)
具体原理说明:通过重复抽取50个数据集并对每个估计量进行500次重新抽样,我们可以得到每个点估计值的自助法分布。朴素OLS估计量和聚类WLS估计量由于在重新抽样过程中使用的是相同的点估计值,因此它们计算出的置信区间是相同的;而这两个区间都排除了真实值+0.80这一结果,因为步骤2中提到的两种因素(溢出效应和工作集基线不平衡)导致了这些估计量的偏差。双暴露模型计算得出的直接效应区间为[0.5931, 0.8519],这个区间确实包含了+0.80这一值;而溢出效应的置信区间为+[0.2008, +0.2164],这个区间能够很好地覆盖+0.20这一数值。聚类自助法的结果进一步证实了步骤4中通过分析方法得出的结论:在K=50时,即使不依赖渐近近似理论,这些推断结果也是可靠的。在笔记本电脑上运行这段代码大约需要一分钟的时间。
当集群随机化方法失效时
在假设成立的情况下,集群随机化方法能够有效解决SUTVA问题;然而,当这些假设不成立时,该方法会产生有偏的估计结果,而这些结果表面上看似乎并无问题。导致集群随机化方法失效的三种情况都与特定的识别假设相关;第四种情况则与集群规模不等时估计量的效率问题有关。
集群数量过少(未满足最低要求)。集群鲁棒标准误差的计算依赖于各集群之间的中心极限定理,因此实践者通常将K≥30作为最低标准。不过,合适的阈值实际上取决于集群规模的异质性以及所选检验统计量的性质[MacKinnon & Webb, 2017]。如果只有4个客户账户使用了某种协作功能,那么这个标准就无法满足。当K=4时,使用集群鲁棒标准误差计算得出的置信区间会过于狭窄,从而影响分析结果的可靠性;此时,采用随机化推断方法或集群野生自助法才能获得有效的p值。
集群边界无法准确覆盖干扰关系(违反了“部分干扰”假设)。集群随机化方法假定干扰现象仅限于同一工作空间内部。但如果用户通过Slack Connect频道、外部共享文档或客户社区论坛在多个工作空间之间频繁进行协作,那么“部分干扰”的假设就不成立,干扰效应会跨越所有集群边界扩散。在这种情况下,两暴露模型仍能一定程度上解释跨集群的干扰现象,因为该模型能够捕捉到暴露标志所反映的所有干扰信息;但如果是结构性泄漏,就需要利用观测到的协作关系图,并采用基于这种协作结构的集群随机化设计[Ugander et al., 2017]。
集群规模差异会导致汇总结果出现偏差(影响估计量的效率)。如果对所有集群赋予相同的权重,那么一个拥有50名用户的 workspace与一个拥有5,000名用户的workspace会被视为相同对象,这种处理方式会大大降低分析效率——因为工作空间平均值的方差实际上取决于其中用户的数量。为了解决这个问题,可以采用按工作空间规模加权的方法,或者使用包含工作空间随机截距的混合效应模型。这类效率问题与识别问题的本质无关,因此区分这两者非常重要:无论采用哪种加权方法,点估计值的结果都是一致的。
事后调整集群划分方式(违反了数据的可交换性原则)。在观察到实验结果之后再确定集群分配方案,这种做法会使得原本有效的设计方法变成有偏的统计分析手段。正确的做法应该是在随机化操作之前就明确并确定所有集群的划分方案,最好将其提前记录在分析计划中。任何事后调整集群边界的操作——比如剔除那些结果异常的工作空间、将小型工作空间合并成大型群体、或者在检查数据后重新定义干扰关系的范围——都会引入选择偏差,而这种偏差是任何标准误差校正方法都无法消除的。
在实际应用中,还有另外两种威胁需要引起注意。
当部分功能被采用时,集群层面的SUTVA假设会失效。集群层面的SUTVA假设要求一个工作空间的处理方式必须是一个单一的、定义明确的整体。然而,当某个功能在同一个工作空间内以不同的速度被采用,或者多个功能版本同时存在时(例如高级功能适用于高级用户,基础功能适用于普通用户),这种假设就会失效。在这种情况下,集群层面的“处理方式”实际上会合并多种不同的影响因素,从而导致估计结果无法被正确解读。
当随机化过程不够严格时,工作空间层面的干扰因素也会影响实验结果。在企业环境中,被纳入实验组的工作空间选择往往并不完全随机。测试计划通常会吸引那些技术较为先进的用户;客户成功团队也会影响哪些客户能够提前使用新功能。如果在随机化之前就已经存在这些干扰因素,那么基于集群的数据分析就无法纠正实验组与对照组之间原本就存在的系统性差异。因此,通过检查工作空间的可观察特征(如规模、所属行业、初始参与度等),并在集群层面进行回归调整,才是有效的解决办法。
这些问题在回归分析的结果中是看不到的。只有当离线估计值与实际应用结果出现偏差时,这些问题才会显现出来。因此,对集群数量进行统计检查、审核团队之间的协作关系,以及进行事前的注册登记,才是防范这些问题的有效措施。
下一步该怎么做
当工作空间内的协作行为会引发影响用户层面的干扰因素,且这些集群是自然存在且易于识别的对象时(例如工作空间、团队、账户或实体店),集群随机化就是一种合适的方法。如果你所关注的干扰因素涉及不同的地理区域,或者发生在双向市场中,其中参与者会整体地相互作用,那么采用时间槽随机化的实验设计会更加适合。如果你是在个体层面实施干预措施,但怀疑存在未被观察到的跨用户干扰因素,那么使用基于设计的工具变量进行分析,可以更准确地识别这些干扰因素。当干扰因素已知且较为复杂时,采用Horvitz-Thompson加权暴露估计方法的图谱集群随机化技术,能够让你得到无偏的效应估计结果,而无需强制要求每个集群边界都包含所有的干扰路径。
本教程配套的实验代码文件位于github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/05_cluster_randomization地址。你可以克隆这个代码仓库,生成合成数据集,然后运行cluster_randomization_demo.ipynb(或cluster_randomization_demo.py),从而复现本教程中的所有代码示例、计算结果和图表。
当一项协作式人工智能功能被提供给那些需要共同完成工作的团队时,用户层面的A/B测试结果几乎总是不准确的。通过使用集群随机化方法并结合双重暴露模型,就可以分别计算出该功能的直接效果及其带来的间接影响;而通过集群自助法所得到的区间估计值,则可以在利益相关者询问“这种效果中有多少是源于这项功能本身,又有多少是由于团队成员之间的交流所导致的”时,为人们提供可靠的答案。因此,