作者 | Sergey Gitlin、Krishna Puttaswamy、Luke Duncan、Deepak Bobbarjung、Arun Babu A S P
译者 | 平川
策划 | Tina
摘要:经过一年多的努力,我们为 Uber 的试验和特性标记生态打下了坚实的基础,相关的一切都已经转移到了新系统上,包括 2000 多名开发人员、集成的超过 15 个合作伙伴的系统、10 多个移动应用、350 多个服务。我们弃用了 Morpheus 中超过 5 万个过时的试验。
本文最初发布于 Uber 工程博客。
简介
“在劣质数据上费力进行计算,也许可以将收益从 95% 提高到 100%。5% 的收益,总数也许并没有多少。但在时间和人力成本相同的情况下,对收集过程或是试验设计来一次能力范围内的彻底整改,经常可以将收益提高 10 到 12 倍。在试验结束后咨询统计学家,就好比要让他做尸检。或许他可以告诉你死因。但是,为了利用他在这方面的经验,就必须诱导他发挥想象力,让他可以提前预知其中的困难和不确定性,如若不然,他的调查也会困难重重。”(R. A. Fisher 在第一届印度统计学大会上的主席致辞)https://www.gwern.net/docs/statistics/decision/1938-fisher.pdf
尽管 A/B 测试的统计学基础已有百年之久,但在规模很大的情况下,构建一种正确可靠的 A/B 测试平台和文化仍然是一项巨大的挑战。按照 Fisher 的上述观点,仔细构建 A/B 测试平台的构建块,确保收集到的数据都是正确的,对于保证试验结果的正确性至关重要,但这个过程很容易出错。Uber 经历过一段类似的旅程,本文就是介绍我们为什么以及如何重建 Uber 的 A/B 测试平台。
Uber 有一个名为 Morpheus 的试验平台,是 7 年多以前构建的,兼具特性标记和 A/B 测试功能。从那时开始,Uber 就在范围、用户、用例等方面对 Morpheus 做了大幅扩展。
2020 年初,我们在深入观察这个生态系统之后发现,有很大比例的试验存在致命的问题,经常需要重跑。要想获得高质量的结果,就需要对试验和统计学有专家级的理解,还需要完成大量艰辛的工作(自定义分析、管道化等)。这会降低决策速度,而且经常需要重新运行效果不佳的试验。
在评估了用户问题和 Morpheus 的内部结构后,我们得出结论,其核心抽象所能支持的试验设计非常有限,即使与这些设计的差异很小,也会导致对照组(control group)和试验组(treatment group)的用户无法对比,对试验结果产生不利的影响。举一个非常简单的例子,在逐步推广一个对照组用户和试验组用户三七分的试验时,由于推广和试验组分配逻辑的特殊性,推广到 10% 的用户没有问题,但推广到 5% 的用户就不行。此外,该系统无法支持 Uber 多样化的用例所需要的高级试验配置,或其他大规模试验所需要的高级功能,如监控 / 回滚对业务指标产生负面影响的试验。因此,我们决定用恰当的抽象从头开始重新构建一个平台。
我们要在系统中实现什么目标?
我们的目标是让公司可以敏捷地、高质量地运行各种试验。
高水平的质量保障
在公司中,试验系统的作用是通过提供试验结果的可信洞察来为决策赋能。为此,一个好的试验系统应该始终提供正确的结果,保证:
决策过程有良好的信息支撑,使所做的决策(有望)是好决策;
结果普遍可信,使团队能够结合实际情况迅速采取行动,而不需要无休止地重新调查令人惊讶的试验结果,重新运行有问题的试验,并基于二次猜测做出决策。
Morpheus 的工作方式使它只适用于简单的试验,除此之外就很难保证结果是正确的。加之 Uber 的试验要求多样而复杂,这导致很多数据有问题,造成了巨大的浪费:人工调查和分析以及重新运行试验都很常见,这会拖慢开发速度,并分散对其他优先事项的注意力。有时,试验结果有问题,却没有人意识到。
新系统应该提供有保证的正确结果:无论用户选择什么样的试验设计,任何人都可以相信试验结果,不需要自定义的验证,也不需要对统计学的深刻理解或对平台的详细了解。
除了统计学上的正确性,系统可靠性也是试验平台所必须的,因为那是公司运营中非常核心的内容。我们现有的试验系统是 7 年多以前开发的,当时是作为软件栈中的一个可选依赖。然而,多年来,特性标记已成常态,试验系统成为移动应用和后台服务的必备依赖。用户思维发生了这样的转变,但系统却没有及时跟进。当后台没有响应时,客户端就会因为故障自动关闭,导致所有移动应用和后台服务都因试验栈的故障而严重瘫痪;多年来,我们已经多次遇到过这样的故障。我们希望,新系统能提供适当的保护措施,以确保 Uber 的服务对试验系统的故障有很好的容错性。
高水平的用户生产力
最终用户主要通过移动应用来使用 Uber。但 Morpheus 的性质使我们很难在这些应用上进行试验。在 Morpheus 的编程模型中,试验组是在客户端代码中指定的(见下图)。因此,试验的修改(如新增一个试验组或修改现有的试验组)需要一个构建 - 发布周期,这会把我们的速度拖慢 2-4 周。为了解决这个问题,我们希望尽可能地将试验与代码变更解耦,以便创建 / 删除 / 修改试验时可以不用等待移动应用构建 / 发布。此外,我们希望简化客户端接口,并对客户端隐藏试验配置和服务的复杂性。
图 1:试验感知伪代码
第二个问题是,在 Uber 快速发展期间,我们的配置栈出现了碎片化。我们有一个针对试验和移动特性标记的配置系统,还有一个针对后端配置和后端特性标记的软件。移动和后端用户工作流完全隔开了,使得同时涉及移动和后端服务的试验很难做,我们需要付出双倍的努力来添加与安全 / 合规等相关的特性,而且,由于系统的细微差别所导致的问题也很难调试。此外,碎片化意味着更高的维护成本。
第三,试验分析的局限性成了数据科学家最大的辛苦之源。构建这个分析生态系统只是为了支持用户随机试验。对任何新的随机化单元做分析都需要自定义管道和设置,使得管道在不同的组织出现了多种变体,导致了不一致。分析中使用的指标也没有标准化。用户使用他们自定义的指标,不同的组织有不同的指标,无法横向比较。领导层很难推断和比较各组织的试验影响情况,甚至很难对跨组织的试验做出一致性的评价,然而,随着 Uber 成为一个业务线交叉的统一平台,这种需求却越来越常见。此外,由于该系统在生成对照组 / 试验组时经常会出现系统性的不平衡,所以进一步开发分析功能并不可行(例如,我们以前尝试开发过监控试验负面影响的功能,那从一开始就注定要失败)。
灵活支持多样化的试验设计,匹配 Uber 的各种产品开发需求
虽然有一些试验用例可以通过将分组比例固定为 50/50 来解决,但许多用例都需要更复杂的设置。新系统设计的一个关键需求是支持 Uber 遇到的各种用例,并能满足未来的潜在需求。
试验通常需要运行复杂的逻辑,而这些逻辑决定了试验何时何地上线,以及在试验过程中哪一部分用户可以获得新产品。在试验过程中,有时会因为外部因素修改这些逻辑,有时候,这些随时间推移而进行的修改成了试验设计的关键组成部分。
需要在不同的粒度级别上运行试验;用户需要随机化的单元类型可以随用例变化。
需要跨后端、移动端、Web 界面或它们的组合运行试验。
有许多用例需要分层试验,如:
涉及多个自定义特性的保留分组;
依赖试验和特性标识;
在多个不同的试验之间划分流量;
用于不同试验的多个保留分组。
在某些情况下,同一个特性需要单独在不同的区域 /App/ 操作系统上测试。
所有这些灵活性需求的满足都应该以不增加客户端代码开发的复杂性为前提。
除了上面提到的功能,新的系统设计应该能够满足未来广泛的扩展需求,而且不需要进行大的架构调整。
架构
在这一节中,我们将介绍新平台架构的核心概念。
参数——将代码和试验解耦
图 2:配置驱动的 A/B 测试伪代码
利用参数将代码和试验解耦。客户端(移动或后端)代码不会引用试验名称或试验组,而是根据参数值生成分支。参数总是会有一个安全的默认值(通常相当于“对照”路径),以确保在没有重写值或由于网络问题而未能收到重写值时,客户端可以顺畅运行。
试验被设置为在后端重写参数的值。参数是客户端唯一可见的概念——可以在后端设置任意数量的试验,为给定的参数或参数集提供不同的值,但客户端并不知道这些试验的存在。根据调用期间传递的上下文,不同的客户端可能接收到不同的参数重写值。后端服务根据上下文向客户端传递参数值,与参数值分支相关的客户端代码就会执行。如果试验人员确定需要一个与起初完全不同的试验设计,那么他们只需禁用当前试验,并使用相同的参数设置一个新试验——不需要修改代码。同样,在旧试验结束后,可以使用相同的参数运行新试验以测试新想法。
上图展示了客户端和服务器之间的调用流,以及参数和试验之间的关系。
统一配置和试验栈
图 3:统一 A/B& 远程配置架构
我们选择将试验构建为 Flipr 之上的一个覆盖层。Flipr 是 Uber 的后端配置系统,其中存放了参数,如果要基于参数运行一个试验,就可以自动调用试验系统获取参数的重写值。要不然就使用默认参数值。这一决策将移动端、后端和试验配置全部统一到了一个系统中,提供了重用支持,统一了开发工作流,使得跨不同端面(surface)运行试验变得简单了。
试验
我们的试验系统只基于一个概念,即试验(experiment),完全没有其他结构:没有单独的保留结构(holdout constructs)、流量分割器、层 / 域等。更复杂的试验功能可以以试验为基础构建出来,如下所述。
试验的核心包括 3 个关键组成部分:
随机化:单元映射到试验组的方式;
试验计划(treatment plan):从上下文和单元随机化(试验组)到行动(参数值)的映射;
日志:记录试验附加信息的辅助结构。
随机化
试验基础:我们需要一些随机的东西,和试验单元、环境或其他任何信息都没有关系;这种随机性有助于我们区分,在其他条件完全相同的环境中,针对其他条件完全相同的试验单元所采取的行动。
在特定的试验中,我们使用一个由试验键值决定的盐对单元标识符进行散列,从而随机化单元。试验键值是唯一的,这就可以保证,只要 Uber 的其他系统没有使用相同的散列逻辑,所有试验经过随机化后都是彼此独立的。
更具体地说,在我们的构建中,单元的桶(bucket )为该单元的哈希值与试验中指定的特定模数(通常为 100)的整除残差。从结构上看,单元的桶在给定的试验中永远不会变,而且很容易复制。
试验组是多套桶。在实践中,我们将所有试验组组织成一棵树,树中的每个节点(试验组)都是一组范围连续的桶。例如,在一个将单元分成 100 个桶的试验中,根节点是 all_units,桶为 [0…99],可划分为 control[0…49] 和 treatment[50…99]。试验组可以根据需要进一步拆分,例如,可以将 treatment[50…99] 分为 t1 [50…59]、t2 [60…69] 和 t3[70…99]。
图 4:试验组分配
拆分试验组的能力让我们能够设计更复杂的试验,在试验执行过程中并入更改(例如,在试验运行时将控制体验反馈给更多的用户)。
试验计划
试验计划规定了在每种上下文中对每个试验组做什么。
上下文(Context)是我们所掌握的关于一个试验单元的知识,包括地理信息,如用户所在的城市或国家,设备特征,如操作系统(iOS 或 Android),以及其他在试验评估时可能用到的信息。
在我们系统中,行动(Action)就是我们返回的参数值,即受试验影响的参数(一个试验有一个或多个受影响的参数)。
例如,我们可以考虑在旧金山运行一个和特定按钮颜色有关的试验。在这种情况下,试验将声明基于参数 button_color 进行操作,与 context.city = San Francisco 匹配的对照单元 button_color = green,同一上下文下的试验单元 button_color = black。所有不在旧金山的单元,按钮会有一个默认的颜色,可以是绿色、黑色、蓝色或其他任何颜色。
上下文的某些方面也可以随机生成:例如,为了逐步推广试验,我们使用(伪)随机生成的推广桶,其中,推广随机化与试验组分配无关。
相同参数的多个试验
作为试验计划的一部分,试验人员可以声明试验需要捕获上下文空间的哪些部分供其专用,以及哪些部分可以用于运行相同参数的其他试验。这样,就可以在不同城市、不同设备操作系统或其他需要的上下文子集上独立运行不同的试验,只要相同参数的试验不存在重叠即可。我们利用一个自定义的逻辑引擎,在配置时检测是否有两个影响相同参数的试验存在重叠,只要不重叠,就可以创建或更新试验。
日志记录
要想具备强大的试验分析功能,正确的数据不可或缺。试验日志的目标是找出该单元什么时候第一次暴露在给定是试验中——例如,单元处于这样一个情况:在不同的试验组中为单元提供了不同的参数值。实现方法是在访问被试验重写的参数时发出日志消息。在一个分布式系统中,要知道哪是第一次暴露并不容易,所以我们会在每次访问参数时发出日志消息,并稍后在数据管道中对这些日志做去重处理。
在单元所处的上下文中,如果所有试验组指定的参数值都相同(即单元体验没有差异),则不会发出日志消息。这样做的原因是,如果体验没有差异,也就不会有需要评估的影响,所以就没必要记录日志了。最后,日志发送对客户端代码来说是透明的。
虽然日志记录的理念很简单,但在工程上,要保证任何日志数据都不丢失是一项不小的挑战,尤其是在规模很大(远大于每秒 1M 条消息)的情况下。我们需要仔细优化和组织日志记录代码,尽可能避免未向用户提供体验却记录了数据的情况,反之亦然。但是,一旦记录了数据,基于日志对试验做实际的分析就完全与服务和配置层解耦了。只要配置和服务层记录并提供试验组分配外生的单元簇(cohorts),分析就可以以此为基础,专注于从有效的试验中获取最大价值。
正确性
虽然正式阐述系统特性超出了本文的范围,但值得注意的是,在这里介绍的架构中,无论用户选择什么样的试验设计(配置),所有的试验组都可以相互比较——除了实际的效果之外,它们之间不会有系统性的差异。只要在第一次接触到有差别的体验之前(这在我们的系统中就是返回有差别的参数值),描述上下文和 / 或决定分析中包含哪些单元(例如,第一条日志)的任何东西都独立于试验组分配,就可以保证这一点。
参数约束
上一小节介绍了试验是什么。我们如何在这个简单的结构与复杂的分层设计之间架起一座桥梁呢?答案就是参数约束。
试验可以把城市位置或设备操作系统作为其试验计划的约束条件(例如,试验只在美国的 iOS 系统上运行),类似地,它也可以将其他参数作为该逻辑的一部分。例如,用户可以选择只在满足约束条件 parameter_a == true 时运行试验。鉴于参数可以由试验来控制,这就为各种有细微差别的设置打提供了可能,满足了现实世界的用例——由于试验是随机化的,而且相互独立,即使试验设置很复杂也没问题。唯一的限制是,要避免循环依赖(确保依赖图是一个 DAG)。
以下是参数约束支持的部分用例:
流量划分。将这一特性与在相同参数上运行多个试验的能力相结合,我们就可以利用 traffic_splitter 试验控制 traffic_slice 参数将流量随机分片,并运行不同的下游试验,每个试验都包含一个约束条件,如 traffic_slice==“A”,并且只控制其所在分片的参数值。
保留组(Holdouts):这个基元也使得保留组成为可能。我们可以有个试验创建一个 Uber 级的保留组(通过设置参数 uber_holdout = true),然后是组织级的保留组,用于未被 uber_holdout 捕获的单元,再然后是更小的团队级的保留组。保留试验本身也可以有复杂的配置,如指定 Uber 员工或高管不包含在保留组中,以便他们可以快速体验产品变化,或者只在特定的地理区域应用保留组逻辑。
依赖试验 / 特性标识。有时候,只有在启用了特定特性时试验才能运行——但该特性本身可能还在试验阶段,或者有复杂的推广逻辑,用于确定哪里可以用,哪里不可以用。在这种情况下,下游试验可以引用启用主特性的参数,并且只在这个参数设置为 true 时才运行。
数据管道泛化
考虑到新增随机化单元是一个常见用例,我们将管道设计成了通用的,不对随机化单元做任何假设。试验日志以及管道为所有随机化单元生成的数据都相同,包括:试验键值、unit_id、单元暴露于特定试验的时间戳、单元暴露时传递的上下文、访问的参数名,以及一些杂项元数据。所有分析库也都是对这些通用数据进行分析,而不对管道层中的单元做任何假设。只有在分析时,用户才会选择与该单元相关的指标来连接底层数据。保持管道通用的决定使得引入新的试验单元变成了简单的配置更改,并且无需任何更改就可以利用其余的基础设施和管道。
分析引擎泛化
Morpheus 提供了一个生成和查看分析结果的 UI,但后台的统计分析程序包是用 Scala 编写的,并且隐藏在服务中,这使得数据科学家很难使用这个包进行自定义分析,导致统计分析工作重复进行且不标准。
在新系统中,我们将统计分析程序包构建成了一个 Python 程序包,并构建了一个 UI 分析层来调用这个包。我们在内部与数据科学社区分享了这个 Python 包,因此,数据科学家可以用它在 Jupyter 笔记本中做探索性分析,或者用它进行在 UI 层难以实现的深层次分析。虽然 UI 层已经能够支持大多数分析用例,但通过 DS 原生工具链提供相同的分析引擎,使有能力的用户可以进行更复杂的分析,也加深了用户对系统工作机制的了解。
分析程序包集成了 uMetric,并将其作为唯一的指标来源。鉴于试验是使用指标的主要用例,这使我们能够巩固 uMetric 作为公司指标单一来源的地位,并进一步提升了用户和团队之间的指标共享 / 标准化水平。
为了支持团队各种各样的试验分析需求,我们提供了高度灵活的按需分析,让用户可以回答有关其创新效果的细粒度问题。
每项分析都由两个关键部分组成:要分析的单元簇(cohort )和分析该单元簇时使用的指标。
单元簇是一项特定分析所聚焦的单元集合。我们为以下两种单元簇定义提供了广泛的支持:
有日志单元簇:基于试验日志(如,在 3 月 1 日到 3 月 30 日之间进行试验的单元);
无日志单元簇:单元簇的定义方式不依赖于日志(如,在试验开始前 28 天内至少出行 1 次的用户)。
对于 Uber 乃至整个行业运行试验来说,有日志单元簇分析可能是更标准的方法。它让用户能够准确关注受试验影响的单元,通常提供的结果比较精确。
另一方面,无日志单元簇分析为我们提供了一种分析试验的备用方法,可以在因为中断及其他问题导致部分日志丢失时使用。它还提供了一种方法,让我们可以分析不同试验中单元簇的试验结果。这样的分析可能不那么强大,但在我们的架构中,却是非常健壮。
为了支持细粒度的洞察,我们为用户提供了很大的灵活性,让他们可以划分单元簇和指标。
本质上讲,单元簇可以根据单元首次进入试验之前定义的任何数据进行划分;这让我们能够提供对比结果,诸如对 iOS 和 Android 用户的影响比较、对新用户和现有用户的影响比较,同时保证,即使进行了单元簇细分,不同试验组之间的单元簇仍然具有可比性。
对于给定的单元簇或单元簇的某一划分,也可以利用指标维度对指标进行细分,因此,我们可以评估 UberX 和 UberXL 对完成行程的影响。由于通过 uMetric 进行了指标标准化,试验中报告的对指标的影响,与世界各地的团队所依赖的报告仪表板中的指标,计算方式是一致的。
可靠性:SDK 和参数值回退
鉴于移动端和后端开发都非常依赖试验系统,我们希望这个系统在可靠性上能有大幅提升,以消除我们之前看到的一类影响非常大的中断事件。为此,我们做了很多工作。
首先,如前所述,参数总有一个安全默认值。对于移动应用,这个默认值是随移动应用本身一起提供的。对于后端服务,默认值是由 Flipr 在本地(在主机上)提供的。这样,在出现网络故障或延迟的情况下,就有一个值可以保证客户端运行在安全的代码路径上。
其次,我们为 Uber 使用的所有语言(Go/Java/Android/iOS/JS/ 等)和客户端(Web/ 移动 / 后端)构建了 SDK。移动端 SDK 会缓存从后端接收到的前一个有效负载;如果出现问题,移动应用会回退到这个缓存,如果缓存不存在且后端不可用,则它们会回退到默认值。对于后端,如果试验不可达或出现超时,客户端会回退到 Flipr 默认值(在本地提供)。这种多层回退显著提高了可靠性。
第三,SDK 会在体验分叉之前自动发出试验日志。自动记录日志消除了手工记录日志带来的麻烦和缺陷。随着时间的推移,我们已经对 SDK 做了大量的优化(缓存 / 删除 / 日志批处理等),这让所有的试验用户无需额外付出任何努力就能从中受益。
第四,SDK 支持一个名为参数预取的特性——提前获取一批参数以降低延迟,但要到稍后访问它们时才会发出日志(将获取与日志记录解耦)。这就解决了这样一种用例:正在试验的特性对参数访问延迟很敏感,但在用户流的早期对延迟不那么敏感。在需要显示特性之前支付延迟成本,在实际需要时提供对参数的即时访问——并在那个时间点自动发出日志——可以提供响应式的用户体验和准确有效的试验。
挑战及经验总结
一个团队:构建试验平台需要多种技能。系统作为一个整体从从统计学的角度来看应该是正确的,而且需要具备可扩展性和可靠性,用户体验要流畅,以保证用户的生产力。系统设计决策不能简单地分为工程和 DS:错误处理和服务性能优化的微小细节就可能会对结果的统计属性产生重大的影响,因此需要 DS 的参与——另一方面,统计分析实现的细节关系到可靠性,扩展到更大数据量的方式,大规模执行需要多少计算资源,等等。我们真正作为一个团队成功地开展了相关工作,特别是工程团队和数据科学团队之间的紧密协作。我们有共同的架构审查、联合代码所有权、同一个团队路线图 / 优先级,以及共同的社交活动。
合作伙伴集成:该试验平台与许多合作伙伴的系统实现了紧密集成:推送 / 电子邮件沟通、目标定位、市场细分、活动管理、无代码工作流等。这些系统贡献了所有试验的近 40%。尽早集成所有这些系统,经常使用其中多个系统的客户就更容易过渡到新系统。协作规划与集成优先显著缩短了采用时间,这项工作很值得。
沟通:Uber 的大多数人每天都在使用这个平台,对于这项规模如此大的基础性变革来说,沟通并让客户参与进来至关重要。为此,我们做了以下几件事:
在问题发现和设计阶段听证会上仔细聆听客户声音,理解需要解决的痛点;
尽早广泛地向关键客户展示新的架构和特性,说明能给他们带来什么好处,并根据他们的反馈进行设计迭代;
在推出平台时做好演示,增进人们对新平台的理解,推动新平台的顺利采用;
定期召开听证会,获得产品反馈——修改路线图,并重新排列优先级,首先解决最迫切的需求。
采用、迁移和弃用:认真考虑新平台的采用策略,将有价值的试验移植到新平台,并从早期就开始弃用数以万计的过时试验(多年之前的),这些都非常关键。我们将弃用过程分为多个阶段,从风险最高的试验开始,然后扩展到其他试验。我们有自定义的程序,专门用于弃用特定类别的试验。我们构建了一套工具来迁移、监控和禁用大部分过时试验。这大大减少了分布式团队所必须完成的工作,让他们只关注不能自动迁移或集中弃用的试验。对于分布式团队来说,这仍然非常痛苦。新平台的采用是同步进行的。
小结
经过一年多的努力,我们为 Uber 的试验和特性标识生态打下了坚实的基础,相关的一切都已经转移到了新系统上,包括 2000 多名开发人员、集成的超过 15 个合作伙伴的系统、10 多个移动应用、350 多个服务。我们弃用了 Morpheus 中超过 5 万个过时的试验。
下一阶段,我们将致力于构建许多特性,以提高可用性、用户体验、性能、自动化监控、自动化推广,以及我们总体的试验能力,并将产品更快地提升到下一个等级。我们会在未来的博文中分享更多的细节。敬请期待!
今日好文推荐
英伟达回应“对中国断供部分高端 GPU”;月薪 3.6 万工程师日均写 7 行代码被开;12 年黑进 40 多家金融机构老板赚百万获刑 |Q 资讯
在阿里达摩院搞了四年数据库,我来聊聊实际情况 | 卓越技术团队访谈录
评论