写点什么

AB 测试平台的设计与实现

发布于: 2020 年 05 月 28 日
AB 测试平台的设计与实现

AB 测试(以下称为「试验」),本质是把选择权交给用户,让用户决定什么是最好的。我们给参与试验的不同用户,分配不同的方案,收集用户数据并加以分析,最终确定最优方案。



试验方案的分配可抽象为一个哈希函数,它将一个用户映射为一个试验方案。用户数据一般通过用户行为隐式收集,比如点击、购买、停留时长等。数据的分析则需要依据统计学的假设检验,确保结论的合理性。

需求,从简到繁

这里涉及的概念有「试验」(experiment)和「用户」(user),而逻辑则有「分配方案」、「收集数据」和「分析并得出结论」等。我们从最简单的系统开始,以各种问题挑战这个设计及其背后的假设,以此逐步完善它。



第一个问题是关于用户的,如何支持未登录情况下的试验?一般来说用户是通过 uid 标识的,但未登录时无法得知 uid。如果要支持未登录情况,势必不能以 uid 为用户标识。未登录情况,常规做法是以设备标识 deviceid 来做区分,而现实情况是,公司范围内 Web/Android/iOS 三端还未统一 deviceid。看来无解了?

换个角度,既然无法控制,就不要控制。平台大可以只定义用户标识(名为 client_id),但其取值完全交由业务方决定。



第二个问题,在开发/测试/验收过程中,PM/QA 可能想通过「白名单」,直接把用户分配到特定试验方案中,绕开「分配方案」逻辑。这是很实用的需求。在分配方案之前,先行检查用户标识是否在某个方案的白名单之中,即可做到。



第三个问题,试验方案是否支持设置权重?从科学的角度,试验期间不支持权重是最为合理的。用户在试验方案之间均匀分配,对于数据分析来说最简单。而且试验未得出结论之前,随意设置权重等于瞎撞,是没有意义的。但是,如果把确定最优方案之后的步骤也考虑进来,权重就有意义了。比如,现确定方案 B 是最优方案,那么自然希望尽快将 B 方案全量部署到线上。如果可以设置权重,直接把方案 B 的权重设置为 100% 就行了。否则的话,就得改代码并且上线,慢很多。这里,权重的意义在于试验之后的行为,达到灰度放量的效果。



第四个问题,试验状态及起止时间。这同样是出于实用的考虑,但这导致有新状况需要处理。比如试验状态被置为禁用,或者当前时间不在试验的起止时间范围之内时,试验方案应当怎样分配?对于这种边角情况,常见的有两种处理方式,一是返回特定错误码,二是返回一个特殊的方案来标记这种情况。我们的 AB 平台选择增加一个特殊的方案,名为 fallback,即兜底方案。每个试验都有 fallback 方案,其权重为 0,且不可删改。(但允许为 fallback 方案设置白名单)



第五个问题,定向试验。假如有个试验是「确定不同的法拉利优惠券(10/50/100元),对于从业 3 年以上程序员的课程购买转化率的影响」,如何提供支持?这是一个典型的定向试验,即面向特定属性的用户的试验。这个例子就包含两个属性,即职业为程序员,且从业年限大于 3。用户属性一般由用户画像之类的服务来提供,作为 AB 平台只需要接入它即可。但还有另一个问题,前面已经提到为支持未登录用户,传入的 clientid 是否表示 uid 都无法确定了,又该如何与用户画像打通呢?这里看来只能诉诸约定了。业务方创建试验时,是知道 clientid 的含义的。若为 uid,就应选择用户属性作为定向标准,若为设备信息,则应选择设备属性作为定向的标准。AB 平台无法确知业务的意图,只能交由业务自行保证。

如何「分配方案」?



「分配方案」是最核心的逻辑,因此单独讨论。



首先,分配结果必须是稳定的。比如,对 Web 端页面改版的两个设计进行试验,如果打开页面时展示 A 方案,刷新页面后可能会变成 B 方案,用户一定会觉得莫名其妙。为保证稳定,前面已提到将方案分配的过程抽象为哈希函数(而非随机挑选)。另一种做法,是每次分配就就结果存到 DB,下次请求时,先从 DB 查询,这也保证了结果的稳定性。



其次,多个试验可能相互干扰。举个例子,Web 端进行两个试验,一个试验用于确定文字的颜色,另一个则在确定背景的颜色。如果一个用户在两个试验中都被分配到“蓝色”,虽然是合法的结果,但对用户来说,页面就完全没法看了。这个例子,正是 Google 2010 年发布的一篇关于 AB 测试的论文 Overlapping Experiment Infrastructure: More, Better, Faster Experimentation中所举的例子。这个例子说明,试验之间存在着联系,如果忽略这种联系,就会导致问题。应当怎样处置这种联系呢?退回到一次只能进行一个试验的情况,肯定是不可接受的。Google 在论文中提出的解决方案是增加一个概念:「层」(layer)。一个试验必须属于某一个层。同一层的多个试验,只能参加其中一个,确保互斥。不同层的试验之间,完全独立。(论文中实际上提出了 domain 和 layer 两个概念,而且 domain 可以包含 layer,layer 也可包含 domain,因此更加复杂。这里去掉 domain 概念,以简化复杂度。)另外,这里因为同层试验互斥导致用户可能无法参加某些试验的情况,与第四个问题类似,都强化了我们对于特定错误码或者特殊方案的需要。



然后,用户在参与不同层的多个试验时,也可能存在 carryover effect。一个典型的例子是,有两个试验,第一个试验中分配到方案 A1 的用户,在第二个试验中也全都被分配到同一个方案(比如 A2),这会严重降低统计结果的可信度。即使统计结果显示试验的不同方案间有明显差异,也无法排除是不是受到另一个试验的影响。这个问题,可以通过哈希时加 salt 来解决。比如,试验创建时即随机生成 salt,分配试验的哈希函数,需要将 salt 考虑进去。这保证了,即使两个试验的方案数量及权重都完全相同,同一个 client_id 在两个试验中被分配的方案也被充分打散。



最后,同一层各试验之间是否需要设置权重?有些 AB 平台允许设置权重,但个人认为是没有必要的。因为由于试验有状态及起止时间,同一层包含哪些试验,已是随着时间动态变化的了(禁止或已完成的试验,可视为不存在),权重设置已不再有意义。即使强行设置权重,各试验之间的相对权重,也会随着旧试验的结束和新试验的创建而无法保持。



另一种做法,是给流量分桶,将 client_id 哈希到桶 id 并据此确定分配层内哪个试验的哪个方案。这与同一层试验之间设置权重是类似的,但更加不灵活。比如,分为 100 个桶,而层内有两个试验,各自占据 40 和 60 个桶,这将导致新创建的试验无桶可用,只能先调整其他试验的桶。试验结束,其占据的桶就又被空置了,这其实是导致了流量的浪费。总之,分桶导致同一层的试验之间耦合得更加紧密了,且与试验的状态不能很好地整合。



鉴于以上原因,我们的 AB 平台,同一层的试验之间,没有权重或者分桶设置。同一层所有试验均匀共享流量。

实现细节



综合考虑前面提到的需求及需要解决的问题,便可动手实现了。



非核心功能方面,增加了「App」概念,一个试验属于某一个 App。App 可增删成员。App 的成员可查看/创建/修改 App 下所有的试验。加上 App 概念之后,就与 Google 论文中的 Domain 和 Layer 比较一致了,只是二者之间不能相互包含而已。Layer 必须从属于某一个 App,一个 App 可有多个 Layer。

至于前面提到的「定向试验」,因开发时用户画像服务还未上线,因此只预留了扩展接口而已。



「数据收集」包括两部分,一是每个试验可自定义指标,二是这些指标的数据可通过 report 服务上报,大数据组同学对这些指标有近似实时统计。

「数据分析与结论」方面,一期只提供了统计数据查看与下载功能,其他交由后期迭代。



核心功能方面,目前试验包括所属应用,所属分层,状态,起止时间,方案列表,指标定义等属性。



方案分配的逻辑如下:

  1. 首先查 DB,若 DB 中查到有参与记录,则从 DB 返回参与的方案。

  2. 若 DB 中无参与记录,则:

1. 若 clientId 在白名单中,则返回对应方案(不论是否为 fallback)

2. 若试验非处于进行中状态(进行中,指试验状态为已开启,且处于其起止时间范围内),fallback。非进行中状态的试验,逻辑上并不存在。

3. 过 filter: 若不通过,fallback。(一期全都通过)。这里 filter 即指定向试验中的用户属性过滤。

4. 过 layer: 若应参与同 layer 其他试验,fallback

5. 分配试验方案: 按权重分配方案

6. 若分配的方案不是 fallback,则存到 DB



第 5 步中的「按权重分配方案」,即前述哈希函数。唯一需要注意的是,权重可为 0。



AB 平台只对业务暴露一个 Grpc 接口:



syntax = "proto3";
import "GrpcUtil.proto";
package abexperiment;
message GetVariantRequest {
string clientId = 1;
string experimentKey = 2;
}
message GetVariantResponse {
AssignedVariant assignedVariant = 1;
util.grpcutil.ErrInfo errinfo = 2;
}
message AssignedVariant {
string variant = 1;
string extraInfo = 2;
}
service AbexperimentService {
rpc GetVariant(GetVariantRequest) returns (GetVariantResponse);
}



总结

通过如上的分析,我们看到了 AB 平台在设计和实现时考虑的问题,及所做的权衡。相信某些问题在不同前提之下,可能会有不同的选择。希望随着更多的接入,AB 平台将会迭代得更好。

花边

谷歌一直非常重视 AB 测试,甚至曾惹得其第一位 Visual Design Lead 愤而离职。Douglas Bowman 在其博客 上愤怒地写道:

Yes, it’s true that a team at Google couldn’t decide between two blues, so they’re testing 41 shades between each blue to see which one performs better. I had a recent debate over whether a border should be 3, 4 or 5 pixels wide, and was asked to prove my case.



这里的 testing 就是 AB 测试。



一晃十一年过去了,希望谷歌设计师们的日子变得好过了一点。

发布于: 2020 年 05 月 28 日阅读数: 1207
用户头像

一群崇尚简单与极致效率的工程师~ 2018.01.26 加入

伴鱼少儿英语是目前飞速成长的互联网在线英语教育品牌,我们期望打造更创新、更酷、让学英语更有效的新一代互联网产品。(技术博客官网:https://tech.ipalfish.com/blog/)

评论 (1 条评论)

发布
用户头像
“(论文中实际上提出了 domain 和 layer 两个概念,而且 domain 可以包含 layer,layer 也可包含 domain,因此更加复杂。这里去掉 domain 概念,以简化复杂度。)” 这才是 Paper 中的精华,除了实验的严谨性,也跟后续实现灰度发布有一定相关相关性。
2020 年 05 月 29 日 22:17
回复
没有更多了
AB 测试平台的设计与实现