实验组点击率涨了 8%,次留跌了 2%
「回声」在月初上线了一版新排序,实验组使用“热度加亲和度”的组合,对照组保持原逻辑。两周后,数据同学给出结论:实验组人均点击率相对提升 8%,建议全量。评审会上没有人反对,因为 8% 看起来足够大,样本量也够。
全量一周后,次留数据出来了:实验组次留相对下降 2%。团队回头复盘,发现实验期间还混入了另外两个改动:推送时间调整和评论区折叠。三个改动同时生效,没人能说清 2% 是哪一个带来的。更尴尬的是,实验组的点击率提升有一部分来自“同一内容被点击多次”,而次留下降恰恰可能是内容重复曝光导致的疲劳。
排序实验最难的部分不是分桶,而是先定义“成功”。点击率是即时指标,留存是滞后指标,两者经常反向。只看点击就全量,等于用一个短期代理指标替代了产品目标。
分桶、指标与实验隔离
一个可解释的排序实验,至少要在开始前固定三件事:谁能进组、成功怎么衡量、实验期间还能不能改别的。
| 决策点 | 必须固定什么 | 常见错误 | 直接后果 |
|---|---|---|---|
| 分桶 | 哈希输入、盐值、桶数 | 每次请求随机、盐值随版本变化 | 同一用户来回跳组,指标被稀释 |
| 流量比例 | rollout 的语义是 0 到 100 | 把 0.1 当成 10%,或把 50 当成 50% | 实际进组比例与预期差一个数量级 |
| 成功指标 | 主指标、护栏指标、观察窗口 | 上线后才补指标,或只看点击 | 结论无法归因,滞后指标被忽略 |
| 实验隔离 | 同时只改一个排序变量 | 实验期间混入推送、UI、召回改动 | 效果无法拆分,全量后回退困难 |
| 效果回收 | 分群、分层、显著性 | 只看总体均值,不看新老用户 | 总体涨、核心人群跌,被平均值掩盖 |
| 停止规则 | 何时看、看几次、何时停 | 每天偷看,涨了就停 | 假阳性概率大幅上升 |
分桶要稳定,本质是同一个用户在同一实验里永远落在同一组。常见做法是把 userID + salt 做哈希再对 100 取模。盐值标识实验版本,不能随发布变化;一旦盐值变了,所有用户都会重新洗牌,实验组和对照组的差异就不再是同一个总体。
rollout 的语义要写清楚。rollout=10 表示 10% 的用户进入实验组,不是“10 倍”也不是“0.1”。如果接口参数是整数百分比,就不要在调用方再乘一次 100;如果参数是小数比例,命名上要能看出来。单位错误通常不会报错,只会让实验悄悄变成全量或空跑。
指标要先定,而且要同时定主指标和护栏指标。排序实验常见的主指标是点击率、消费时长或互动率,护栏指标是次留、负反馈率、取关率。只定主指标,团队会不自觉地把主指标优化到损害护栏指标;只定护栏指标,又会导致实验过于保守。
实验隔离是最容易被忽视的一条。排序实验期间,推送时间、评论区样式、召回策略都不应该同时改。如果业务上无法避免,至少要用正交实验设计,让每个改动有独立的实验标签,否则回收阶段只能看到一锅粥。
效果回收要看分布,不只看总体。新用户和老用户对热度的敏感度不同,重度和轻度用户对已读内容的容忍度也不同。总体点击率涨 8%,可能是重度用户涨了 20%、新用户跌了 5% 的平均结果;全量后新用户占比上升,效果就会反转。
失败场景:点击涨 8%,次留跌 2%,团队只看了点击
回到「回声」的例子。实验组点击率从 5.0% 升到 5.4%,相对提升 8%;次留从 42% 降到 41.2%,相对下降约 2%。如果只看点击,结论是全量;如果同时看护栏指标,结论应该是“暂停并拆分原因”。
进一步的拆分显示,点击提升主要来自同一批内容的重复点击:新排序把用户已经互动过的作者内容反复排到前面,用户点开同一作者的多条更新,点击次数上升,但消费内容的广度下降。次留下降的用户集中在“关注作者少于 10 个”的轻度用户,他们的首页被少数作者占满,第二天回来的意愿降低。
这个案例的教训不是“点击率没用”,而是“点击率不能单独用”。点击是手段,留存是目标;手段涨、目标跌,说明排序在局部最优里牺牲了长期价值。正确的做法是把次留设为护栏指标,实验组次留下降超过阈值就停止扩量,而不是等全量后才发现。
另一个问题是实验期间混入了推送改动。推送时间调整会直接影响次留,而且影响方向与新排序可能相反。两个改动叠加后,即使新排序本身中性,次留也可能下降;团队却把账算在排序头上,差点回退了一个正确的改动。
用 Bucket、Assign 与 Lift 固定口径
下面的实现来自 webapp-lab 的 internal/rank 包。Bucket 用 FNV-1a 把 salt + userID 哈希到 0 到 99,Assign 按 rollout 判断是否进组,Lift 计算实验组相对对照组的点击率变化。
三个函数都很小,但它们把实验口径固定下来:同一用户同一盐值永远同桶;rollout 小于等于 0 时不进组,大于等于 100 时全量;对照组零点击时相对提升无定义,直接报错而不是返回 0 或无穷大。
// file: internal/rank/experiment.go
// tier: L1
// verify: go test ./internal/rank/
package rank
import (
"fmt"
"hash/fnv"
)
// Bucket 把用户稳定地分到 0..99 的桶。同一 userID 与 salt 永远得到同一结果。
func Bucket(userID, salt string) int {
h := fnv.New32a()
_, _ = h.Write([]byte(salt))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(userID))
return int(h.Sum32() % 100)
}
// Assign 判断用户是否进入实验组。rollout 是实验组比例(0..100)。
func Assign(userID, salt string, rollout int) bool {
switch {
case rollout <= 0:
return false
case rollout >= 100:
return true
default:
return Bucket(userID, salt) < rollout
}
}
// Stats 是实验组或对照组的一段汇总指标。
type Stats struct {
Users int
Clicks int
}
// Lift 返回实验组点击率相对对照组的变化比例。
// 例如对照组 5%、实验组 5.4%,Lift 为 0.08。
func Lift(control, treatment Stats) (float64, error) {
if control.Users <= 0 {
return 0, fmt.Errorf("control users must be > 0, got %d", control.Users)
}
if treatment.Users <= 0 {
return 0, fmt.Errorf("treatment users must be > 0, got %d", treatment.Users)
}
if control.Clicks < 0 || treatment.Clicks < 0 {
return 0, fmt.Errorf("clicks must be >= 0, got control=%d treatment=%d",
control.Clicks, treatment.Clicks)
}
if control.Clicks == 0 {
return 0, fmt.Errorf("control has no clicks, relative lift is undefined")
}
controlRate := float64(control.Clicks) / float64(control.Users)
treatmentRate := float64(treatment.Clicks) / float64(treatment.Users)
return (treatmentRate - controlRate) / controlRate, nil
}
Bucket 先把盐值写入哈希,再写一个分隔字节,最后写用户 ID。分隔字节是为了避免 salt="ab", user="c" 和 salt="a", user="bc" 拼出同一个输入。这个细节不影响大多数用户,但会让边界上的分桶结果不可复现。
Assign 对 rollout 做了钳制,而不是报错。原因是调用方通常从配置读取比例,配置错误时更希望实验安全地不扩量,而不是让整个首页请求失败。代价是错误会被隐藏,所以配置校验应该在启动阶段单独做一次。
Lift 返回的是相对变化,不是绝对差值。对照组 5%、实验组 5.4%,相对提升是 8%,绝对提升是 0.4 个百分点。两个数字都真实,但混用会让结论放大或缩小十倍。接口只返回相对值,调用方如果需要绝对值,应该自己从两组 Stats 里算。
测试:稳定分桶、rollout 边界与零点击
实验代码的 bug 往往不会让请求失败,而是让分组悄悄错误。下面的测试固定三个性质:分桶稳定且有界、rollout 边界正确、零点击时报错。
// file: internal/rank/experiment_test.go
// tier: L1
// verify: go test ./internal/rank/
package rank
import "testing"
func TestBucketIsStableAndBounded(t *testing.T) {
for i := 0; i < 200; i++ {
user := "user-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i%10))
first := Bucket(user, "feed-rank-v1")
second := Bucket(user, "feed-rank-v1")
if first != second {
t.Fatalf("Bucket(%q) changed from %d to %d", user, first, second)
}
if first < 0 || first > 99 {
t.Fatalf("Bucket(%q) = %d, want 0..99", user, first)
}
}
}
func TestBucketDependsOnSalt(t *testing.T) {
found := false
for i := 0; i < 20; i++ {
user := "user-" + string(rune('a'+i))
if Bucket(user, "salt-a") != Bucket(user, "salt-b") {
found = true
break
}
}
if !found {
t.Fatal("changing the salt did not move any of the sampled users")
}
}
func TestAssignMatchesBucket(t *testing.T) {
user := "user-42"
salt := "feed-rank-v1"
bucket := Bucket(user, salt)
for rollout := 0; rollout <= 100; rollout++ {
want := bucket < rollout
if got := Assign(user, salt, rollout); got != want {
t.Fatalf("Assign(rollout=%d) = %v, want %v (bucket=%d)",
rollout, got, want, bucket)
}
}
if Assign(user, salt, 0) {
t.Fatal("rollout 0 must not include anyone")
}
if !Assign(user, salt, 100) {
t.Fatal("rollout 100 must include everyone")
}
}
func TestLiftComputesRelativeChange(t *testing.T) {
control := Stats{Users: 1000, Clicks: 50}
treatment := Stats{Users: 1000, Clicks: 54}
got, err := Lift(control, treatment)
if err != nil {
t.Fatalf("Lift returned error: %v", err)
}
if !almostEqual(got, 0.08) {
t.Fatalf("Lift = %f, want 0.08", got)
}
}
func TestLiftNegativeWhenTreatmentIsWorse(t *testing.T) {
got, err := Lift(
Stats{Users: 1000, Clicks: 50},
Stats{Users: 1000, Clicks: 45},
)
if err != nil {
t.Fatalf("Lift returned error: %v", err)
}
if !almostEqual(got, -0.1) {
t.Fatalf("Lift = %f, want -0.1", got)
}
}
func TestLiftRejectsNoControlClicks(t *testing.T) {
_, err := Lift(Stats{Users: 1000}, Stats{Users: 1000, Clicks: 10})
if err == nil {
t.Fatal("expected an error when the control group has no clicks")
}
}
func TestLiftRejectsInvalidStats(t *testing.T) {
cases := []struct {
control Stats
treatment Stats
}{
{Stats{Users: 0, Clicks: 1}, Stats{Users: 100, Clicks: 1}},
{Stats{Users: -1, Clicks: 1}, Stats{Users: 100, Clicks: 1}},
{Stats{Users: 100, Clicks: -1}, Stats{Users: 100, Clicks: 1}},
{Stats{Users: 100, Clicks: 1}, Stats{Users: 100, Clicks: -1}},
{Stats{Users: 100, Clicks: 1}, Stats{Users: -1, Clicks: 1}},
{Stats{Users: 100, Clicks: 1}, Stats{Users: 0, Clicks: 0}},
}
for _, c := range cases {
if _, err := Lift(c.control, c.treatment); err == nil {
t.Fatalf("expected an error for control=%+v treatment=%+v", c.control, c.treatment)
}
}
}
TestBucketIsStableAndBounded 对同一批用户重复计算,要求结果不变且落在 0 到 99。这个测试不能证明哈希分布均匀,但能防止“用了随机数”或“取模写错”这类致命错误。
TestAssignMatchesBucket 遍历 0 到 100 的所有 rollout,要求 Assign 的结果严格等于 bucket < rollout。它同时覆盖了边界:rollout 为 0 时没人进组,为 100 时所有人进组。这个性质比单独测两个端点更有价值,因为它固定了分桶与进组之间的一致性。
TestLiftComputesRelativeChange 用 1000 用户、50 次点击对 1000 用户、54 次点击,得到 0.08。TestLiftRejectsNoControlClicks 则要求对照组零点击时报错。相对提升在分母为 0 时没有定义,返回 0 会被误读成“没有变化”,返回无穷大又会污染后续计算。
这些测试不验证实验是否显著,也不验证样本量是否足够。那些是统计层面的判断,单元测试只负责把分桶和指标口径固定下来。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/rank/ -count=1 -run 'TestBucket|TestAssign|TestLift' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestBucketIsStableAndBounded
--- PASS: TestBucketIsStableAndBounded (0.00s)
=== RUN TestBucketDependsOnSalt
--- PASS: TestBucketDependsOnSalt (0.00s)
=== RUN TestAssignMatchesBucket
--- PASS: TestAssignMatchesBucket (0.00s)
=== RUN TestLiftComputesRelativeChange
--- PASS: TestLiftComputesRelativeChange (0.00s)
=== RUN TestLiftNegativeWhenTreatmentIsWorse
--- PASS: TestLiftNegativeWhenTreatmentIsWorse (0.00s)
=== RUN TestLiftRejectsNoControlClicks
--- PASS: TestLiftRejectsNoControlClicks (0.00s)
=== RUN TestLiftRejectsInvalidStats
--- PASS: TestLiftRejectsInvalidStats (0.00s)
PASS
ok webapp/internal/rank 0.242s
-run 的正则覆盖分桶、进组和提升计算三组测试。输出里的 ok webapp/internal/rank 表示包级通过;具体的 0.00s 只代表这次本机运行,和线上实验的观察窗口没有关系。
小结
排序实验的成败在开始前就决定了一半:分桶是否稳定、rollout 语义是否清楚、主指标和护栏指标是否同时定义、实验期间是否混入其他改动。任何一项没固定,回收阶段都无法把效果归因到排序本身。
Bucket、Assign 和 Lift 把这三件事写成可测试的函数。它们不解决统计显著性和样本量,但能保证“同一个用户不会来回跳组”“10% 就是 10%”“零点击不会被算成没有变化”。这些是实验可信的最低条件。
阅读导航:上一节:4.2 去重、折叠与已读 · 下一节:5.1 关注、好友与双向关系 。
常见错误与边界
- 每次请求随机分组。用户会在实验组和对照组之间来回跳,指标被严重稀释。
- 盐值随发布变化。所有用户重新洗牌,实验前后的总体不再可比。
- 把 rollout 的小数当百分比。0.1 表示 0.1%,不是 10%,而且不会报错。
- 上线后才补指标。已经发生的曝光无法回填护栏指标,结论只能看即时点击。
- 实验期间同时改推送、UI 或召回。效果无法拆分,回退时容易误伤正确改动。
- 只看总体均值。新老用户、重度轻度用户的方向可能相反,平均值会掩盖问题。
- 对照组零点击时返回 0。相对提升在分母为 0 时无定义,应该显式报错。
- 每天偷看并提前停止。多次检验会显著抬高假阳性概率,停止规则要事先约定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。