实现一个简单的分层实验系统

前言

所谓一次实验(这里都是指网络实验),即是在一次请求中,应用若干参数,产生某种结果的过程。

而一组实验,即是在若干次请求(流量),进行若干次实验。

而一组对照实验,一般包括一个对照组实验(或控制组control)和若干处理组实验(treatment)。

所以,常见的实验,包括这样一些要素:

  • 流量,其实就是样本
  • 参数,通常是策略优化的对象
  • 结果,映射到用户的行为,我们需分析的日志

当创建一个实验,我们一般会考虑这些问题:

  • 起止时间,实验应该开启多长时间,某些实验是否有周期性,这是个权衡
  • 流量大小,这个和我们关注的指标敏感度以及置信区间有关,这也是个权衡
  • 分配方式(diversion),就是采样方式,按uuid、userid还是随机分配
  • 分配条件(condition),只采用一部分流量,可能按地域、按浏览器类型
  • 流量偏置,会和哪些模块有耦合关系,应该如何分配参数

针对这些问题,分散的实验常常会踩到一些坑:

  • 流量饥渴,这是最容易碰到的。因为我们是在用样本估计,那么显然样本越多越好,我们做ab test,各取50%流量是理想情况。
  • 条件偏置,已经经过一部分条件过滤的流量不应该再分配其他实验,因为这部分样本是有偏的。
  • 参数耦合导致结果有偏。比如,实验A和实验B流量重叠,如果A的参数会影响到B,那么就会导致B的结果有偏。

如果我们各自为战的做对照实验,虽然在共用同一份流量,但互相其实并不清楚各自的影响,那实验结果可想而知。所以,这是分层实验系统要解决的问题。

设计

这里的分层实验系统和上面罗列的概念,主参考了Google的Overlapping Experiment Infrastructure, kdd2010这篇论文,但是做了一些简化,主要是在层和域的嵌套上,这里的设计是域可以嵌套层,但层不能嵌套域。

说明一下论文里关于系统的基本概念:

  • 域(domain) 即流量的划分,一部分流量
  • 层(layer)即参数的集合,不能耦合的参数会在同一层,可以耦合的参数在不同层,所以一般实验中是按业务模块来划分层比较合理
  • 实验(experiment)见上文

在具体实现里数据结构上新增的概念:

  • 块(bucket)为了简化流量划分,把整个网站流量划分了若干块,比如1000块

一个完整实验系统大概分成如下几部分:

  • 实验分配系统:Python实现的后台服务,实时获取数据库里的配置信息,构建实验空间,并序列化到一个json配置文件,同时推送到线上服务器
  • 实验客户端:实现为一个PHP库,读取json配置文件,恢复实验空间,同时为每次请求进行流量划分
  • 实验管理系统:面向产品和实验人员,可以创建并管理各自的实验和参数,同时可以进行实验的审批和上线
  • 数据收集系统:复用已有的日志收集平台,比如给已有的日志增加experiment字段的记录,保存每次生效的参数和值,这样就可以在集群上实时或离线的统计预订指标并写入统计数据库
  • 数据展示平台:复用已有的数据展示平台,从统计数据库读取统计报表并可视化

实验分配系统和实验客户端之间的通信其实有很多种方法,之前在腾讯GDT我们是基于Protobuf RPC。这里一切从简,使用JSON配置文件,好处是解耦,不会因为后端服务的异常影响到线上服务。

具体的部署是侵入式的,就是需要在具体的业务代码里根据参数值实现if-else逻辑。

据说百度和Google的实验部署是非侵入式的,但这需要开发流程是分支开发模式,并且需要实现额外的分流服务,同时代码部署需要和分流服务联动。这种适合大规模服务节点的情况,只有几台服务器就没法这么操作了。

这里所描述的只是系统设计方面,至于如何创建实现,根据要观察的效果改变大小,去选择合适的流量大小和实验时长,这也是个很复杂的话题。

实现

示例,一个buckets_num=1000的实验空间的框架如图:

下面用一个具体的例子来大概说明一下实验系统的工作流程。

类似Airbnb改版搜索页面,我们通常会页面重构,但真正上线前肯定要经过ab test,评估新设计对最终成单率的影响。

假设目前有一些参数:

  • search_modern_design 搜索列表是否使用新设计
  • search_thumb_size 搜索列表中缩略图大小
  • price_base_proportion 价格浮动系数,默认为1.0

现在搜索页面重构完了,我想在北京范围内灰度发布新的搜索页面,与对比一下旧页面设计的成单率的变化。于是我们创建了这样一组处理实验:

  • 参数:search_modern_design=true
  • 流量:buckets_num=50
  • 层:layer='search'
  • 采样:diversion='uuid'
  • 条件:city='beijing'
  • 起止时间:time_range=['2015-12-20 00:00:00', '2015-12-30 00:00:00']

相应另外一组对照实验:

  • 参数:search_modern_design=false

其他参数都一样。

实验分配系统会从数据库获取这些信息,一看总流量一共1000份,search layer属于domain 2流量在200~1000这个区间,于是就在是在这个区间上分配流量;但紧接着发现200~250已经被其他实验占了,于是最终把这个实验分配到了250~260和300~340这两个区间。

实验管理系统审批通过后(设置status='deploy'),当达到时间(设置status='publish'),此时实验在线上生效。

实验客户端开始工作,它首先重新构建实验空间,得到层和流量划分的对应信息。对于某一份对实验生效的流量,会逐层分配,每层会依次按照uuid/userid/random方式采样,实际就是计算 hash(diversion_id, layer_id) % 1000 的值,对于这个生效实验 diversion='uuid'layer_id='search',假设 hash%1000=301,那么就可以继续判断实验条件,如果恰好该流量是北京的,则该实验对应的参数生效。

具体在PHP里就一句调用:

$value = ExpSys::get(“search_modern_design”);

可以得到参数值为true,当请求结束后,日志库会把此次请求应用的所有参数和值写到日志。然后,log agent会把该日志上报到kafka,最终变成Hive里的离线日志表,或者到storm之类的实时系统计算。

这里是怎么解决流量饥渴、条件偏置和参数耦合这几个坑的呢?关键就是分层实验系统把参数进行了分层,实际就是同一模块互相耦合的参数分配在同一层,而互不影响的参数分配在不同层,这样层和层之间的流量是正交的。每一份流量经过每一层会由均匀分布的hash函数重新随机分配到下一层的bucket,这样保证了每个实验进入的流量是同质的,那么对照实验的对比结果就是不会有偏置的。同时每个层上的流量基本是100%复用的,这就解决了流量饥渴问题。然后,由于实验统一管理,上游已经分配过但条件不满足的流量,可以标识不再分配给下游,这样就能解决条件偏置问题。而且统一实验系统可以复用日志收集系统和数据展示系统,可以预先计算常用的pv、uv、ctr、cvr等指标,这样就能更多、更快、更好的做实验了。

示例代码: https://github.com/qxj/exp-sys-toy

参考