每一个对基于大语言模型的特征进行因果推断的产品实验团队最终都会遇到同样的问题:当供应商发布新的模型版本时,没有任何团队会拒绝使用这个新版本。
你们的基础设施团队在一夜之间将所有工作空间从Claude 4.5升级到了Claude 4.6。全部50个生产环境的工作空间同时使用了新模型。一周后,各项任务的完成率都大幅上升。产品负责人认为这是一次成功的尝试。
但你们却察觉到有些不对劲——在升级期间,没有任何团队继续使用旧版本4.5。那些采用“前后对比”方法进行分析的团队,其实把那周发生的其他变化也纳入了分析范围,比如新的入职流程、季节性的数据波动,或者某个重要客户的加入等等。
这就是所谓的“全局推广问题”。每当一个团队向所有用户同时推送模型升级版本时,这种问题就会出现。对于那些开发生成式AI功能的团队来说,这是最常见的测量陷阱之一。分阶段推广可以确保拥有对照组,而全局推广则会使得这一机制失效。
到了2026年,全球范围内的模型升级已经成为了常态:所有的API供应商都会发布新版本,而所有使用Claude、GPT或Gemini的团队都经历过这种“突然从旧版本切换到新版本”的情况,而且用户没有任何选择权来拒绝这次升级。
当没有对照组可供使用时,数据科学家会使用“合成控制”这种方法。具体来说,就是构建一组未接受升级处理的样本(比如其他工作空间或地区),这些样本在升级前的表现与需要分析的样本相同。然后比较升级前后这两个样本的表现差异,由此得出的结果就可以作为因果效应的估计值——当然,这一结论是在满足某些前提假设的前提下得出的。
在本教程中,你们将使用Python和`scipy.optimize`库从零开始构建合成控制模型,将其应用到一个包含50,000个用户的模拟SaaS数据集上,并通过安慰剂置换检验、留一法捐赠者敏感性分析以及聚类自助法95%置信区间验证其有效性。
配套代码:所有的代码块都可以在github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/04.synthetic_control处的配套笔记本中完整运行。该笔记本中的`synthetic_control_demo.ipynb`文件已经预先运行过所有代码,因此你们可以在GitHub上先查看结果,然后再在本地进行测试。
目录
为什么全局推广会破坏简单的测量方法
A/B测试的数学模型之所以简洁,是因为它基于一个假设:实验组的分配与其他所有因素都是相互独立的。比如抛硬币来决定:一半的工作区使用Claude 4.6版本,另一半则继续使用4.5版本。这种随机分配方式能够消除所有可能的干扰因素。然而,在全局推广的环境中,却不存在这样的随机性。
有三种机制会导致基于“实验前/后数据”的简单分析产生误导。
-
产品同时发生的变化:模型升级的部署很少是单独进行的。在同一周内,培训团队可能会发布新的教程,定价团队会推出促销活动,客户支持团队也会向企业客户介绍新功能。因此,“实验前/后数据”实际上反映的是这些多重变化的综合效果。
-
季节性因素和市场波动:每周的使用频率、每月的计费周期以及每季度的采购流程都会影响测量结果。比如在第20周,使用率突然上升3个百分点,这看起来似乎是模型升级带来的效果,但实际上可能是由于用户们在春假后回到了工作岗位。
-
竞争对手的影响:如果竞争对手发布了有缺陷的更新版本,你的用户可能会暂时转而使用他们的产品。这时,你的任务完成率会急剧上升,因为新用户的操作更加简单,但这其实与模型本身的改进无关。
以上三种情况都会导致同样的结果:简单的“实验前/后数据”分析会将模型升级带来的效果与其他各种因素的影响混在一起来计算。
在这个教程的数据集中,使用简单方法计算得出的差异值为+0.0515,这个数值几乎与真实值+0.05相等。这种巧合恰恰是最可怕的错误来源:有时候,简单的计算方法会偶然得到正确的结果,但如果没有反事实数据作为参考,就根本无法区分这是运气还是真正的效果。
合成控制方法究竟能起到什么作用

图1:合成控制方法的构建原理。灰色曲线代表那些继续使用旧版本的 workspace,虚线蓝色曲线则是通过加权计算得出的、能够在治疗前阶段最佳反映实验组表现的组合值。
在开始治疗的第20周之后,这些权重值保持不变,虚线曲线继续作为反事实数据被用来预测后续结果,而实验组的表现则会上升。治疗后期,两条曲线之间的差距就是模型升级带来的效果估计值。
该图重点说明了一个设计原则:权重值只根据治疗前的数据一次性计算确定,之后就不会再使用治疗后的数据来重新调整这些权重值。
合成控制方法就是寻找那些在治疗前阶段的表现与实验组最为相似的未处理对象,然后通过加权计算得出它们的组合值。一旦这些权重值被确定下来,就可以将这个“合成对象”的发展轨迹延伸到治疗后期,从而计算出两者之间的差距。
在您的人工智能产品环境中:如果第二波工作区没有与第一波工作区同时获得模型升级,那么每一个第二波工作区都可能成为“捐赠者”。优化器会寻找那些其升级前的轨迹权重组合最接近第一波工作区的第二波工作区组合。在第20周之后(也就是第一波工作区完成升级的时候),如果满足以下三个识别假设,那么第一波工作区与其“合成对应体”之间的差距就代表了因果效应的大小。
这些识别假设是相互关联、共同起作用的。
-
首先,前期适配性要求:被处理对象在处理前的发展轨迹必须位于所有“捐赠者”轨迹的凸包范围内,这一要求是通过非负性和总和为1的限制条件来确保的。
-
其次,捐赠者不受影响:对被处理对象的干预措施不得影响到那些作为“捐赠者”的对象。如果使用共享的API速率限制机制,或者用户在不同工作区之间切换,都会破坏这一要求。
-
第三,捐赠者群体保持稳定:在后期观察期内,“捐赠者”群体的构成不得出现与干预措施无关的变化。只要有任何一条假设被违反,即使前期适配性看起来完全符合要求,所得到的结果也会出现偏差。后续的部分会详细解释每一条假设的具体含义。
需要说明的一点是:当处理前周期数为T₀,而“捐赠者”对象的数量为J时,如果J接近T₀,就会出现严重的过拟合现象。本教程中设定的参数为T₀=20、J=25,这个数值处于容易引发过拟合的危险区间。后续进行的LOO敏感性分析正是用来判断这种拟合结果究竟是反映了真正的可比性,还是仅仅是过拟合的表现。
先决条件
您需要使用Python 3.11或更高版本,并且需要对pandas、numpy等工具熟悉掌握,同时还需要了解基本的约束优化算法。
请安装本教程所需的软件包:
pip install numpy pandas scipy matplotlib
这些软件包的作用如下:pandas用于加载用户级别的数据日志;NumPy负责处理多维数据的算术运算;SciPy提供了SLSQP求解器,用于确保“捐赠者”权重组合满足凸组合约束条件;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脚本则会生成整个系列实验中都会使用的合成数据集。参数“seed 42”确保了数据集的可重复性,而50,000个用户的样本量为估计器提供了可靠的数据支持。最终生成的CSV文件会保存在data/synthetic_llm_logs.csv路径下。
设置工作示例
这个合成数据集模拟了一个SaaS产品,该产品有50,000名用户,这些用户分布在50个工作空间中。其中,工作空间0到24属于第一阶段,它们在第20周接受了模型升级;而工作空间25到49属于第二阶段,在第29周之前仍使用旧模型。
数据生成器中预设的因果效应是:在第一阶段的用户中,治疗期后任务完成率会增加5个百分点。由于你知道这一真实数值,因此可以验证合成控制方法所得出的结果是否与此相符。
请加载数据,并将其整理成按工作空间和周数划分的面板格式:
import numpy as np
import pandas as pd
df = pd.read_csv("data/synthetic_llm_logs.csv")
PRE = 20 # 第0到19周为治疗前阶段
WINDOW = 30 # 分析窗口为第0到29周
df_window = df[dfsignup_week < WINDOW].copy()
panel = (
df_window.groupby(["workspace_id", "signup_week"])
["task_completed"].mean().reset_index()
)
panel.columns = ["workspace_id", "week", "task_completed"]
pivot = panel.pivot(
index="week", columns="workspace_id", values="task_completed"
)
pivot = pivot.interpolate(method="linear", axis=0).ffill().bfill()
ws_wave = df.groupby("workspace_id").wave.first()
wave1_ws = sorted(ws-wave[ws_wave == 1].index.tolist())
wave2Ws = sorted(ws-wave[ws_wave == 2].index.tolist())
treated_series = pivot[wave1_ws].mean(axis=1).values
donor_matrix = pivot[wave2_ws].values
print(f"治疗组数据集的维度:{treated_series.shape}")
print(f"对照组数据矩阵的维度:{donor_matrix.shape}")
print(f"每个工作空间每周的平均用户数:约{len(df_window) / (50 * WINDOW):.1f}")
print(f"治疗前阶段的平均值(第0到19周):{treated_series[:PRE].mean():.4f}")
print(f"治疗后阶段的平均值(第20到29周):{treated_series[PRE:].mean():.4f}")
预期输出结果:
治疗组数据集的维度:(30,)
对照组数据矩阵的维度:(30, 25)
每个工作空间每周的平均用户数:约19.2
治疗前阶段的平均值(第0到19周):0.5927
治疗后阶段的平均值(第20到29周):0.6421
数据处理的流程如下:首先将数据范围限定在30周内,然后将用户信息整理成按工作空间和周数划分的面板格式;接着通过插值方法填充缺失的数据(每个单元格对应的平均用户数为19人)。治疗组数据表示的是25个第一阶段工作空间的平均值,这些数据汇总了大约480名用户的每周使用情况,从而有效降低了单元格级别的误差。对照组数据矩阵中,每个第二阶段的工作空间都被单独列出来,形成了25个时间序列,每个序列覆盖第0到29周的数据。治疗前后的平均值分别为0.5927和0.6421,由此计算出的差距为+5.15个百分点,这个数值与预设的+5个百分点相近;不过由于第20到29周期间还存在其他影响因素,因此这一结果其实受到了一定程度的干扰。

图2:在真实的50,000个用户数据集上进行的诊断分析。上方面板:红色线条表示第1波数据的变化轨迹,深蓝色虚线表示拟合得到的合成控制变量;处理前的均方根误差为3.74个百分点,处理后的差距平均值为+8.29个百分点。下方面板:通过使用25个不同的“捐赠者工作空间”分别作为对照组,重新拟合合成控制变量,从而得到了对照组分布;观察到的效果超出了对照组的范围,这一因素影响了第3步中计算出的伪p值。
图1以示意图的形式展示了这种方法的运作过程,而这张图则进一步说明了该方法能够产生一个足够精确的処理前拟合结果,从而使处理后的差距具有可解释性;同时,该方法生成的对照组分布也能帮助区分观察到的实际效果与随机噪声。
步骤1:使用SLSQP方法拟合捐赠者权重
合成控制变量的权重向量w是以下约束优化问题的解:在满足每个权重都在[0, 1]区间内且所有权重之和为1的条件下,最小化处理前序列与这些捐赠者序列的加权组合之间的均方误差。这种非负性约束及权重和为1的限制共同确保了最终得到的权重向量是一个凸组合,从而避免了超出捐赠者数据范围的情况。
from scipy.optimize import minimize
n_donors = len(wave2_ws)
Y_pre = treated_series[:PRE]
D_pre = donor_matrix[:PRE, :]
def objective(w):
return np.mean((Y_pre - D_pre @ w) ** 2)
w0 = np.ones(n_donors) / n_donors
bounds = [(0, 1)] * n_donors
constraints = [{"type": "eq", "fun": lambda w: w.sum() - 1}]
result = minimize(
objective, w0, method="SLSQP", bounds=bounds,
constraints=constraints,
options={"ftol": 1e-12, "maxiter": 5000},
)
w_opt = result.x
pre_mse = float(np.mean((Y_pre - D_pre @ w_opt) ** 2))
pre_rmse = float(np.sqrt(pre_mse))
nz = int((w_opt > 0.001).sum())
print(f"优化过程成功完成:{result.success}")
print(f>非零权重值(|w| > 0.001)的数量:{nz}")
print(f>处理前的均方误差:{pre_mse:.6f}")
print(f>处理前的均方根误差:{pre_rmse:.4f} "
f"(相当于{pre_rmse * 100:.2f}个百分点)")
synth_full = donor_matrix @ w_opt
gap = float((treated_series[PRE:] - synth_full[PRE:]).mean())
print(f\n观察到的处理后差距:{gap:+.4f} (真实值 = +0.0500)
预期输出结果:
优化过程成功完成:True
>非零权重值(|w| > 0.001)的数量:12
处理前的均方误差:0.001400
处理前的均方根误差:0.0374 (相当于3.74个百分点)
观察到的处理后差距:+0.0829 (真实值 = +0.0500)
前5个权重最大的捐赠者工作空间:
工作空间35:权重值为0.2016
工作空间40:权重值为0.1900
工作空间25:权重值为0.1638
工作空间32:权重值为0.0872
工作空间36:权重值为0.0784
具体过程如下: `objective`函数用于计算处理前序列与捐赠矩阵与权重向量的点积之间的均方误差。
SLSQP算法同时处理非负性约束条件以及总和为1的等式约束。`w > 0.001`这一阈值将12个捐赠变量分类为非零值。由于SLSQP无法保证不活跃约束变量的值为零,因此设置这一阈值仅是一种显示上的惯例。处理前的RMSE值为3.74个百分点,这一数值反映了这些加权捐赠变量在升级前与被处理单元的走势有多接近;观察到的处理后差距为+0.0829个百分点,这一数值超出了实际值+5个百分点的范围,不过第5步分析中已经通过置信区间对这一差异进行了量化。
权重值在处理前期就已经确定下来,之后也不会使用处理后的数据来重新计算这些权重。如果在第20周之后出现任何偏差,那就说明优化算法没有机会捕捉到这些变化。
步骤2:绘制被处理单元与合成控制组的轨迹图
对于合成控制方法而言,最直观的诊断手段就是通过绘制两条曲线的叠加图来观察它们的走势:将两个序列同时绘在图表上,标出处理开始的日期,然后确认在处理前期合成对照组确实能够跟随被处理单元的走势变化,而在处理后期两者之间会出现差距。
如果处理前两者的轨迹贴合得非常紧密,那就说明识别条件得到了满足;如果轨迹线条参差不齐,那就意味着被处理单元位于捐赠变量的凸包之外,整个分析结果也就值得怀疑了。
import matplotlib.pyplot as plt
weeks = np.arange(WINDOW)
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(weeks, treated_series, marker="o", linewidth=1.8,
color="#C44E52", label="处理组")
ax.plot(weeks, synth_full, marker="s", linestyle="--",
linewidth=1.8, color="#4C72B0", label="合成对照组")
ax.axvline(PRE, color="#555555", linestyle=":", linewidth=1.4,
label="模型升级时间(第20周)")
ax.set_xlabel("注册周数")
ax.set_ylabel("平均任务完成率")
ax.set_title("被处理单元与合成对照组的表现")
ax.legend(frameon=False)
plt.tight_layout()
plt.show()
post_gap = treated_series[PRE:] - synth_full[PRE:]
print("处理后的每周差距值(被处理组减去合成对照组):")
for wk, g in zip(range(PRE, WINDOW), post-gap):
print(f"第{wk}周:{g:+.4f}")
print(f"\n平均差距值:{post_gap.mean():+.4f}")
预期输出结果:
处理后的每周差距值(被处理组减去合成对照组):
第20周:+0.0398
第21周:+0.1663
第22周:+0.1019
第23周:+0.1535
第24周:+0.1071
第25周:+0.1047
第26周:+0.0424
第27周:+0.0326
第28周:+0.0327
第29周:+0.0479
平均差距值:+0.0829
具体过程说明:在处理前期,这两条曲线确实呈平行趋势,这验证了模型假设的正确性;但在第20周之后,被处理组的数值开始高于合成对照组的数值,此后每周的差距值均为正值,其平均值为+8.29个百分点。
这些数值在不同周数之间的变化范围(从+3.26个百分点到+16.63个百分点)反映了估计值所吸收的周与周之间的随机波动。如果某一周的数据出现异常,就可能导致平均值发生变化一个百分点,因此后续进行的安慰剂组分析和留一法分析比任何单一的点估计结果都更为重要。
步骤3:空间内安慰剂置换检验
对于单个被处理对象来说,是无法进行标准的t检验的。因为在这个实验设置中,合成对照组只包含一个被处理对象(第1组数据)以及25个“供体”对象,而这种结构并不符合任何常规的p值计算方法。
标准的数据验证方法是空间内安慰剂置换检验。具体操作是:依次将每个“供体”对象视为被处理对象,使用剩余的24个“供体”对象作为安慰剂组,重新进行合成控制模型的拟合,记录下这些安慰剂组在处理后的数据差距,然后将这些观察结果与安慰剂组的实际数据分布进行比较。
placebo_gaps = []
for j in range(n_donors):
placebo_treated = donor_matrix[:, j]
placebo_pool = np.delete(donor_matrix, j, axis=1)
n_p = placebo_pool.shape[1]
def obj_p(w):
return np.mean((placebo_treated[:PRE] - placebo_pool[:PRE] @ w) ** 2)
res_p = minimize(
obj_p, np.ones(n_p) / n_p, method="SLSQP",
bounds=[(0, 1)] * n_p,
constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}],
options={"ftol": 1e-12, "maxiter": 5000},
)
synth_p = placebo_pool @ res_p.x
placebo_gaps.append((placebo_treated[PRE:] - synth_p[PRE:]).mean())
placebo_gaps = np.arrayPLACEbo_gaps)
observed_gap = gap
rank = int((np.abs PLACEbo_gaps) >= abs(observed_gap)).sum())
pseudo_p = (rank + 1) / (len/placebo_gaps) + 1)
print(f"观察到的数据差距:{observed_gap:+.4f}")
print(f"安慰剂组平均数据差距:{placebo_gaps.mean():+.4f}")
print(f"安慰剂组标准差数据差距:{placebo_gaps.std():.4f}")
print(f"安慰剂组数据差距范围:[{placebo_gaps.min():+.4f}, "
f"{placebo_gaps.max():+.4f}]")
print(f"|安慰剂组数据差距| ≥ |观察到的数据差距|:在25个样本中排名第{rank})
print(f"伪p值:{pseudo_p:.4f}")
预期输出结果:
观察到的数据差距:+0.0829
安慰剂组平均数据差距:-0.0008
安慰剂组标准差数据差距:0.0380
安慰剂组数据差距范围:[-0.0748, +0.0707]
|安慰剂组数据差距| ≥ |观察到的数据差距|:在25个样本中排名第0
伪p值:0.0385
具体操作过程如下:循环遍历所有25个“第2组数据空间”。对于每一个数据空间,都将其从“供体”列表中移除,将其视为被处理对象,然后重新运行SLSQP优化算法。在进行了25次这样的操作后,统计有多少次安慰剂组的数据差距在绝对值上等于或大于观察到的数据差距,并根据保守的公式((计数+1) / (总数+1))对结果进行校正。
在这25次实验中,没有一次产生的数据差距比观察到的+0.0829更极端,因此伪p值为0.0385。这一结果在5%的显著性水平上否定了“无效应”这一零假设。此外,安慰剂组的数据分布主要集中在接近零的值附近(平均值为-0.0008,标准差为0.0380个百分点),这个范围就是用来与观察到的数据差距进行比较的基准。
正确的统计结论是:所观察到的差异在5%的显著性水平上,比从未经治疗的捐赠者中抽取的任何安慰剂组所显示的差异都要极端。排列检验的功效取决于捐赠者群体的规模:当捐赠者数量为25人时,最小的伪p值仅为1/26,即0.0385;因此,在这样的样本量下,不可能得到更小的p值。如果安慰剂组的分布范围较广,或者观察到的差异较小,那么这一观测结果就会被归入安慰剂组的数据范围内,从而导致伪p值超过任何有意义的显著性阈值。
**步骤4:剔除单个捐赠者对结果的影响进行敏感性分析**
即使得到了一个较为精确的估计值,但如果这个估计值依赖于某个特定的捐赠者,那么这个估计值仍然可能不够可靠。通过“剔除单个捐赠者”来进行敏感性分析时,会依次剔除每个非零权重的捐赠者,然后重新利用剩余的捐赠者数据来计算合成对照组,并记录下此时出现的差异。
Abadie(2021)建议将这种分析方法作为首选的稳健性检验手段。如果去除任何一个捐赠者后,观察到的差异会发生显著变化,那就说明你使用的并不是一个真正的合成对照组——实际上你得到的只是一个被赋予了额外权重的单例捐赠者对比结果罢了。
```python
def fit_and_gap(treated, donors, pre=PRE):
n = donors.shape[1]
def obj(w):
return np.mean((treated[:pre] - donors[:pre] @ w) ** 2)
res = minimize(
obj, np.ones(n) / n, method="SLSQP",
bounds=[(0, 1)] * n,
constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}],
options={"ftol": 1e-12, "maxiter": 5000},
)
synth = donors @ res.x
return float((treated[pre:] - synth[pre:]).mean())
nz_idx = np.where(w_opt > 0.001)[0]
loo_rows = []
for j in nz_idx:
kept = np.delete(donor_matrix, j, axis=1)
gap_new = fit_and_gap(treated_series, kept)
loo_rows.append({
"dropped_workspace": int(wave2_ws[j]),
"dropped_weight": float(w_opt[j]),
"new-gap": gap_new,
})
loo_df = pd.DataFrame(loo_rows).sort_values("dropped_weight", ascending=False)
print(loo_df.round(4).to_string(index=False))
print(f"\nLOO差异范围: [{loo_df.new_gap.min():+.4f}, "
f"{loo_df.new-gap.max():+.4f}]")
print(f"原始差异值: {gap:+.4f}")
```
**预期输出结果:**
```
dropped_workspace dropped_weight new_gap
35 0.2016 0.0945
40 0.1900 0.0756
25 0.1638 0.0932
32 0.0872 0.0868
36 0.0784 0.0739
31 0.0718 0.0858
29 0.0648 0.0782
26 0.0439 0.0786
27 0.0364 0.0867
46 0.0350 0.0794
39 0.0192 0.0848
42 0.0078 0.0839
LOO差异范围: [+0.0739, +0.0945]
原始差异值: +0.0829
```
**解释:**
在这个分析过程中,程序会依次剔除每个非零权重的捐赠者,然后重新进行计算。12次这样的分析结果都显示出了正的差异值,其范围为 [+7.39%, +9.45%],这个范围与原始的+8.29%相比,上下各相差了大约一个百分点。
没有哪一个单一的捐赠因素能够单独决定最终的结果。即使将权重最高的因素“workspace 35”的权重降低0.2016,这一变化也只会使差距变为+9.45个百分点,因为优化工具会重新分配剩余因素的权重。
这种权重重新分配正是凸组合加权方法的核心所在:许多几乎等同的捐赠因素组合都会产生相似的结果。
步骤5:使用聚类自助法计算95%置信区间
仅凭点估计值是无法完全了解情况的。当利益相关者询问“你有多确定”时,他们实际上需要的是一个置信区间。传统的非参数自助法并不适用于针对单个受处理对象的合成控制分析,因为对这一时间序列进行有放回的重采样会破坏估计结果所依赖的时间顺序。
一种有效的替代方法是使用用户级别的聚类自助法:对用户数据进行有放回的重采样,根据重采样的数据重新构建按周划分的面板,然后根据处理前的数据重新计算各捐赠因素的权重,并记录处理后的差距变化。
重复这一过程500次,最终得到的分布中第2.5和第97.5百分位数就是所需的95%置信区间。
def build_panel(df_inner):
dfw = df_inner[df_inner.signup_week < WINDOW].copy()
panel = (dfw.groupby(["workspace_id", "signup_week"])
["task_completed"].mean().reset_index())
panel.columns = ["workspace_id", "week", "task_completed"]
piv = panel.pivot(index="week", columns="workspace_id",
values="task_completed")
piv = piv.interpolate(method="linear", axis=0).ffill().bfill()
ws_wave_b = df_inner.groupby("workspace_id").wave.first()
w1 = sorted(ws-wave_b[ws_wave_b == 1].index.tolist())
w2 = sorted(ws-wave_b[ws_wave_b == 2].index.tolist())
return piv[w1].mean(axis=1).values, piv[w2].values
rng = np.random.default_rng(7)
n = len(df)
n_reps = 500
gaps_boot = np.empty(n_reps)
for i in range(n_reps):
sample = df.iloc[rng.integers(0, n, size=n)]
t_b, d_b = build_panel(sample)
gaps.boot[i] = fit_and_gap(t_b, d_b)
lo = float(np.percentile(gaps_boot, 2.5))
hi = float(nppercentile(gaps/boot, 97.5))
print(f"处理后差距的95%置信区间为:[{lo:+.4f}, {hi:+.4f}]")
print(f"观察得到的点估计值为:{gap:+.4f}")
print(f"如果真实值+0.0500落在置信区间内,则输出'YES',否则输出'NO'")
print(f"如果差距为0,则输出'YES',否则输出'NO'")
预期输出结果:
处理后差距的95%置信区间为: [+0.0511, +0.1215]
观察得到的点估计值为:+0.0829
如果真实值+0.0500落在置信区间内,则输出'YES',否则输出'NO'
如果差距为0,则输出'YES',否则输出'NO'
具体操作过程如下:对用户数据进行了500次有放回的重采样,每次重采样后都重新构建了相关面板,并根据处理前的数据重新计算了各捐赠因素的权重。最终取这500次测量结果中第2.5和第97.5百分位数作为95%置信区间。该置信区间的范围为 [+5.11个百分点, +12.15个百分点],由于这个区间完全排除了差距为零的情况,因此这一统计结果具有实际意义。
下限略高于+5个百分点的真实值:这种由于样本量有限而产生的正向偏差,在针对小型捐赠者群体的合成控制研究中十分常见。在这种研究中,每个捐赠者群体(每周约19名用户)所产生的数据噪声通常会比那些包含25个捐赠者群体的研究结果更大。
安慰剂组实验、留一法检验以及自助法分析共同证实了这种方法的真实有效性。然而,使用单一捐赠者群体进行实验时,会不可避免地出现点估计偏差这一问题。
在编写利益相关者报告时,除了给出点估计值外,还应同时提供相应的区间范围,并明确指出偏差的方向,这样团队才能正确理解这些数据的含义。
当合成控制方法失效时
合成控制是一种精确的研究工具,但其失效模式通常比较有限。其中四种最常见的失效原因直接与三个基本假设有关。
1. 捐赠者群体受到污染(违反了“无干扰”假设)
如果第一阶段实施的改进措施影响到了第二阶段的研究对象(例如,共享API速率限制机制、共享提示缓存系统,或者用户在不同捐赠者群体之间迁移),那么这些捐赠者群体的数据就会受到“污染”,从而导致研究结果低估了实际效果。
为了解决这个问题,需要从制度层面进行审查:具体检查在处理日期前后,这些捐赠者群体的数据发生了哪些变化,尤其是那些在模型层面上产生的影响,比如共享路由机制、共享缓存系统以及共享监控数据等。
2. 单位之间存在本质差异(违反了“处理前数据匹配”假设)
根据合成控制方法的原理,被处理的对象必须属于捐赠者群体的覆盖范围之内。如果被处理的对象在结构上与捐赠者群体存在显著差异(例如,企业客户与小型企业客户之间的差异),那么无论处理前数据之间的匹配程度有多高,任何加权方法都无法得出可信的对比结果。
需要仔细检查权重分配情况:如果优化器给某个单一捐赠者群体分配了80%的权重,那就意味着这个群体承担了全部的研究工作,这时就需要思考这样的比较是否真的具有意义。
3. 处理后捐赠者群体发生变动(违反了“捐赠者群体构成稳定”假设)
合成控制方法是根据处理前的权重来预测后续捐赠者群体的行为的。如果某个关键捐赠者在处理后遇到了重大变故(例如客户流失、系统故障或竞争对手推出新产品),那么它之后的行为轨迹就不再能作为可靠的对比基准了。因此,需要仔细检查那些权重较高的捐赠者群体在处理后的数据变化情况,看是否存在异常现象。
4>当J接近T₀时,过拟合风险会增加,导致处理前数据匹配效果变差
当J大于或等于T₀时,优化器可能会仅根据噪声数据来拟合处理前的数据,从而产生一种虚假的可比性假象。本教程中设定的T₀/J值为20/25,即0.8,这个数值处于风险区域。通过留一法敏感性检验可以有效地验证这种可比性的真实性:如果在不同捐赠者群体的数据发生变化后,这种差异仍然存在,那么就说明这种比较确实是合理的。
这些失效原因在点估计值中是无法被发现的。只有当使用合成控制方法进行研究时,这些问题才会显现出来——虽然这些方法在理论上看似非常有效,但当实际应用到下一阶段的研究对象时,所得到的结果往往并不可靠。因此,安慰剂组测试、留一法敏感性检验以及自助法分析共同构成了保护我们免受这些问题的影响的有效手段。
下一步该怎么做
当你的研究结果在全球范围内被应用时,如果存在一批与已接受处理的样本相似但尚未接受处理的样本,那么合成控制方法就是一种非常合适的工具。
如果接受处理过的样本与对照组样本在操作规模上存在差异,增强型合成控制方法会利用线性结果模型中的偏差校正项来进行分析;如果你有大量分阶段接受处理的样本,广义合成控制方法(即R语言包`gsynth`)能够进一步扩展这一分析框架。
对于实际的生产环境而言,R语言包`pysyncon`实现了Abadie-Diamond-Hainmueller估计方法,它通过V矩阵外循环对预测变量进行加权处理,并且还包含了实时安慰剂检验机制(即将处理措施应用到试验前的某个时间点,然后检测是否会出现异常的数据差异)——这些内容在本教程中并未涉及。这里提供的从头实现的代码示例,可以帮助你了解`pysyncon`的实际使用方法。
本教程配套的笔记本文件位于github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/04_synthetic_control地址。你可以克隆该仓库,生成合成数据集,然后运行`synthetic_control_demo.ipynb`(或`synthetic_control_demo.py`)文件,从而复现本教程中的所有代码示例、计算结果及图表。
当模型更新版本同时推送给所有用户时,使用传统的“处理前/处理后对比”方法通常会得到错误的结果。而合成控制方法则可以利用你已有的数据,构建出“那些没有接受更新的用户群体”,锁定在处理措施实施前的各种权重值,从而为你提供可靠的安慰剂组数据以及置信区间——当相关方询问你对研究结果的信心程度时,这些数据就能为你提供有力的支持。