跳到主要内容

那个按会话分桶并导致 A/B 测试分群漂移的模型发布标志

· 阅读需 12 分钟
Tian Pan
Software Engineer

事后分析会以一句房间里所有人都希望是真的话开头:新模型在满意度上赢得了 4 %,p 小于 0.01,发布吧。一个月后,一项更冷静的分析发现,这种提升其实是一个混杂因素,模型表现实际上持平或略差,而团队在中间几周一直在争论哪个 prompt 更改“导致”了这一胜利。模型本身并没有导致任何结果。实验衡量错了对象,因为标志服务(flag service)和分析流水线在静默状态下对“分群(cohort)”的定义产生了分歧。

这是 A/B 测试中最昂贵的故障模式之一,因为系统中没有任何东西是损坏的。标志服务工作正常。实验追踪器工作正常。仪表板能正常渲染。统计数据是根据接收到的数据正确计算的。故障存在于三个组件之间的缝隙中,每个组件对身份都有不同的假设,而且除非你主动寻找,否则这个缝隙是不可见的。

配置读起来正确,行为却发生了偏差

标志的配置方式与每份新手指南告诉你的完全一致。变体 A 分配给 50 % 的用户,变体 B 分配给 50 %,分桶属性设置为 user_id,哈希种子固定为实验键。一位高级工程师审查了 YAML。一位数据科学家审查了实验设计文档。大声朗读定向谓词时听起来很正确:对用户进行稳定的哈希处理,对 100 取模,将前半部分发送到 A,后半部分发送到 B。

设计文档上的图表没有显示的是标志 SDK 的内部缓存。为了避免每次页面加载都进行往返通信,SDK 每个会话评估一次标志,并将结果变体存储在会话作用域的对象上。该缓存纯粹是性能优化,对产品代码不可见,且失效规则是默认的:会话结束时过期。在像开发者工具或服务台应用这样的长会话产品上,“会话结束时过期”近似于“永不过期”。在像聊天助手这样用户打开标签页、问一个问题、关闭并在二十分钟后返回的短会话产品上,“会话结束时过期”意味着“每二十分钟重新从头评估一次”。

重新评估本身并不是 bug。对稳定的 user_id 进行确定性哈希,即使你重新评估一万次,每次都应该产生相同的分桶。bug 出在当输入空间发生变化时,确定性哈希会发生什么。

悄悄重新平衡哈希空间的流量增长

实验进行到一半时,团队将变体 B 的发布比例从 50 % 提高到 60 %,以便收集更多关于长尾细分市场的数据。大多数标志服务都说明,只要分桶方案在实现时考虑到了这一特性,粘性确定性哈希将在小比例变化中保持已分桶用户在原始变体中。例如,LaunchDarkly 的文档将百分比发布描述为 1 到 100,000 之间的分区,每个上下文都落在一个稳定的插槽中,因此增加变体 A 的分配只需扩展映射到 A 的连续范围,而不受影响已分配的用户。(LaunchDarkly 的百分比发布)

只有在实现严谨的情况下,这一特性才成立。它并非在每个标志服务或每个 SDK 中都默认成立,而且当底层其他变量发生变化时,它也不成立。如果盐(salt)发生了变化、添加了新的分桶属性、变体顺序被重新排列,或者发布拆分了两个以上的变体并引入了第三个变体,分区可能会发生洗牌。GrowthBook 明确说明,粘性分桶(sticky bucketing)是一个独立的功能,你必须在默认确定性哈希的基础上启用它,正是因为跨配置更改的一致性分配并非没有代价。(GrowthBook 粘性分桶) Statsig 发布了类似的“持久分配(persistent assignment)”功能,并采用了相同的框架:确定性哈希处理简单情况,持久分配处理实验配置在运行中发生突变的情况。(Statsig 持久分配)

在这个团队的案例中,流量增长(ramp)调整与在同一窗口期发生的标志服务迁移相结合,导致了底层盐的旋转。盐的旋转被记录在标志服务的发布说明中,位于一个没人阅读的标题下。结果是,周一处于变体 A 的用户在周二可能会被分配到变体 B,而 SDK 的会话作用域缓存忠实地捕获了用户在该会话第一次调用时碰巧抽中的变体。user_id 是稳定的。哈希是确定性的。但分群分配却不是。

将每个事件视为其自身分配的分析

运行该分析的数据科学家所做的正是大多数 A/B 测试教程中描述的操作。对于每个事件,查找事件发生时的 flag 值,根据该 flag 值关联指标,计算 lift(提升),计算 p 值,然后报告。指标流水线的构建初衷就是为了让这一切变得简单。每个事件都携带一个 flag_value 字段,该字段在事件触发时写入,而分析只是简单地按该字段进行分组。

当分配是粘性(sticky)且事件发生时的 flag 值等于用户首次分配的实验组(cohort)时,这种分组逻辑是正确的。但当两者发生偏离时,它就不再正确。在粘性的世界里,任何给定用户的事件时 flag_value 是一个常量,因此按事件时 flag_value 分组与按用户首次分配的 flag_value 分组会产生相同的划分。在发生漂移(drift)的情况下,事件时的 flag_value 是两个因素的函数:用户潜在的偏好,以及他们相对于 salt 轮换(salt rotation)恰好登录的周日期模式。此时按事件时的 flag_value 分组就是一种混杂操作:它通过实验未能控制的路径,将事件按一个与结果相关的变量进行了排序。

该团队案例中混杂因素的具体表现是:salt 轮换发生在周日晚上。主要在工作日登录的用户最终在轮换后的分桶中占比过高。主要在周末登录的用户在轮换前的分桶中占比过高。这两个群体具有明显不同的基准满意度得分,因为他们将产品用于不同类型的工作。所谓的“模型胜出 4%”,很大程度上其实是“工作日用户的满意度基准比周末用户高 4%”,分析过程却将其呈现为似乎是模型导致的。正如统计学家所言:这种 lift 估算精确、高度显著,但指向了错误的方向。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates