转化率涨了四个点,留存掉了一截
「刚好」做过一次注册页改版实验:对照组用原来的表单,实验组把手机号、验证码、密码三步合并成一步,并加了一个「首单立减」的提示。实验计划跑两周,主指标是注册转化率,护栏指标是 7 日留存和投诉率。这些数字是案例假设值,用于说明结构,不是线上实测。
实验第一周,注册转化率从 8% 提升到 12%,相对提升 50%,p 值小于 0.05。团队很兴奋,第三天就全量上线,没有等第二周结束。
活动结束后的第二周,数据开始回落:注册转化率回到 9% 左右,7 日留存从 35% 掉到 22%,首单率没有变化。进一步拆分发现,实验组多出来的注册用户里,很大一部分只完成了注册,没有后续行为;他们是被「首单立减」的文案吸引进来的,注册后发现优惠门槛比预期高,直接离开。
这不是实验做错了,而是度量做窄了。主指标只回答了「注册这一步是否更顺」,没有回答「拉进来的人是否更有价值」。护栏指标虽然定义了,却没有在决策时被真正使用。
指标要在实验开始前定义
增长实验最常见的争议不是数据算错,而是实验结束后才决定看哪个指标。看到注册转化涨了,就说实验成功;看到留存跌了,就说留存是滞后指标、先不看了。这种事后选择指标的做法,会让任何实验都能得出「显著正向」的结论。
指标定义至少包含五件事:分子、分母、时间窗口、去重键、排除项。五件事没写全,指标名就只是一个词。比如「注册转化率」可以指「注册用户 / 访问用户」「注册用户 / 点击广告用户」「完成注册 / 开始注册」,三个口径回答的是不同问题。
| 指标 | 分子 | 分母 | 窗口 | 回答的问题 |
|---|---|---|---|---|
| 注册转化率 | 注册成功用户 | 进入注册页的去重用户 | 当日 | 注册流程是否顺畅 |
| 首单转化率 | 完成首单用户 | 注册成功用户 | 注册后 7 日 | 拉来的人是否有购买意愿 |
| 7 日留存 | 第 7 日回访用户 | 注册成功用户 | 滚动 7×24 小时 | 产品是否留下长期价值 |
| 人均首单金额 | 首单金额合计 | 完成首单用户 | 注册后 7 日 | 用户质量是否变化 |
| 投诉率 | 投诉工单数 | 支付订单数 | 自然日 | 体验是否被改坏 |
主指标只能有一个。一个实验承诺多个主指标,等于没有主指标;看到哪个涨了就报哪个,显著性会失去意义。护栏指标可以有多個,它们不参与「实验是否成功」的判定,但只要有一个明显变差,就应该阻止全量。
指标还要在实验开始前确定样本量和停止规则。中途反复查看、看到显著就停,会显著抬高假阳性率。规范做法是跑满计划样本量或计划周期,再统一判定。
转化率的分母是谁
转化率的分母决定了它回答什么问题,也决定了它能被谁使用。注册转化率的分母如果是「进入注册页的用户」,它衡量的是表单和流程;如果是「点击广告的用户」,它衡量的是投放素材和落地页的一致性;如果是「全站访问用户」,它混入了大量没有注册意图的人,数值会被稀释。
分母还会影响实验的可比性。实验组和对照组的流量来源必须同分布,否则分母结构不同,转化率差异可能只是渠道差异。分流时按用户稳定分桶,不要按请求分桶;按请求分桶会让同一个用户在两套界面之间跳来跳去,污染体验和指标。
时间窗口同样属于分母的一部分。「7 日首单转化率」和「当日首单转化率」不是同一个指标。窗口越长,数值越高,但归因越难,越容易把自然转化算成实验效果。窗口一旦确定,实验组和对照组必须使用同一窗口,历史对比也要使用同一窗口。
去重键要写在指标定义里。按用户去重、按设备去重、按订单去重,结果可能差很多。跨端产品尤其要注意:用户在手机上下单、在电脑上注册,如果两端 ID 没有打通,就会被算成两个人。
新奇效应与长期留存
新奇效应是指用户因为「不一样」而产生了短期行为变化,而不是因为新方案本身更好。改版后的界面更醒目、文案更有诱导性,第一周的数据通常最好看;等用户习惯了,效果会回落。
判断新奇效应,至少要看两个东西:时间曲线和留存队列。时间曲线看实验期内每天的效应是否逐日衰减;留存队列看实验组多出来的用户在第 7 日、第 28 日是否还在。如果注册转化提升只发生在第一周,且新增用户的留存明显低于对照组,大概率是新奇效应加低质流量。
长期留存不是「等一个月再看」这么简单。实验期间的外部事件会污染长期数据:大促、版本发布、竞品活动、季节变化。要看长期效果,最好保留一个 holdback 组,长期不切新方案,用同一时间窗对比;没有 holdback,就只能做观察性分析,结论强度要降级。
| 现象 | 短期指标 | 长期指标 | 可能解释 |
|---|---|---|---|
| 真实提升 | 注册转化上升 | 留存、首单同步上升 | 方案确实降低了摩擦 |
| 新奇效应 | 第一周显著,随后衰减 | 留存持平或下降 | 用户被新鲜感吸引 |
| 低质流量 | 注册转化上升 | 首单率、留存下降 | 文案诱导了无意图用户 |
| 渠道差异 | 总体上升 | 分渠道有正有负 | 分母结构不同,辛普森悖论 |
失败场景:只看注册转化,不看 7 日留存
回到开头的实验。主指标是注册转化率,护栏指标定义了 7 日留存,但团队在第三天看到主指标显著,就决定全量。决策时没有人打开护栏指标,因为「注册转化已经涨了 50%,留存以后再看」。
第一个后果是低质用户进入。合并表单和「首单立减」的提示降低了注册门槛,也吸引了一批只想领优惠的用户;他们注册后没有首单,7 日留存自然下降。第二个后果是后续投放被误导:团队按「注册转化提升 50%」追加预算,买来的流量和实验组一样,注册多、留存低,获客成本实际上升。
第三个后果是实验结论无法复用。实验只证明了「这个改版能提高注册转化」,没有证明「这个改版能带来更多有效用户」。下一个团队看到结论,继续沿着「降低注册门槛」的方向优化,把更多低质用户拉进来,形成负向循环。
正确的做法不是放弃注册转化,而是把它和首单率、7 日留存放在同一张决策表里:主指标显著正向,护栏指标没有明显变差,才允许全量;如果主指标正向、留存明显负向,应该先做分群分析,确认是全部用户变差还是只有低质渠道变差,再决定是否按渠道灰度。
用 Rate 与 Lift 收口转化率
实验分析里最基础的两个计算是转化率和相对提升。它们看起来只是除法,但分母为零、转化人数越界、对照组转化率为零这些边界,必须在代码里显式处理,不能让 NaN 或 Inf 流进报表。
// file: internal/growth/metrics.go
// tier: L1
// verify: go test ./internal/growth/
package growth
import (
"errors"
"fmt"
)
var (
// ErrNoUsers 表示分组的用户数为零或负数,无法计算转化率。
ErrNoUsers = errors.New("growth: group has no users")
// ErrBadConverted 表示转化人数不在 0..Users 之间。
ErrBadConverted = errors.New("growth: converted out of range")
// ErrZeroControlRate 表示对照组转化率为零,相对提升没有定义。
ErrZeroControlRate = errors.New("growth: control rate is zero")
)
// Group 是一个实验分组。Users 是进入分组的用户数,Converted 是在
// 观察窗口内完成目标行为的用户数。两个字段都按去重后的用户计。
type Group struct {
Users int
Converted int
}
// Rate 返回分组的转化率。Users 必须大于零,Converted 必须落在
// 0..Users 之间;非法输入返回错误,而不是悄悄算出负数或超过 1 的值。
func Rate(g Group) (float64, error) {
if g.Users <= 0 {
return 0, fmt.Errorf("%w: users=%d", ErrNoUsers, g.Users)
}
if g.Converted < 0 || g.Converted > g.Users {
return 0, fmt.Errorf("%w: converted=%d users=%d", ErrBadConverted, g.Converted, g.Users)
}
return float64(g.Converted) / float64(g.Users), nil
}
// Lift 返回实验组相对对照组的转化率相对提升:
//
// (treatRate - controlRate) / controlRate
//
// 对照组转化率为零时相对提升没有定义,返回 ErrZeroControlRate。
// 任一组的计数非法时返回对应错误;两个入参都按值传递,不会被修改。
func Lift(control, treat Group) (float64, error) {
controlRate, err := Rate(control)
if err != nil {
return 0, fmt.Errorf("control: %w", err)
}
treatRate, err := Rate(treat)
if err != nil {
return 0, fmt.Errorf("treat: %w", err)
}
if controlRate == 0 {
return 0, ErrZeroControlRate
}
return (treatRate - controlRate) / controlRate, nil
}
Rate 先校验 Users > 0,再校验 Converted 落在 0..Users 之间。非法输入返回错误,而不是算出负数或大于 1 的转化率。Lift 先算两组转化率,再算 (treatRate-controlRate)/controlRate;对照组转化率为零时相对提升没有定义,返回 ErrZeroControlRate。
两个函数都按值接收 Group,不会修改入参。这个约束看起来多余,但在分析流水线里很重要:一个函数偷偷把 Converted 归一化,后续再算绝对差值就会得到错误结果。
Lift 只给出点估计,不给出置信区间和 p 值。点估计不能替代显著性判断:12% 对 10% 的提升,在样本量足够时显著,在样本量不足时可能只是噪声。样本量、置信区间和多重比较校正属于分析层,不应该塞进这个纯函数;但调用方必须知道它只负责算数,不负责下结论。
测试:分母为零、越界与对照组零转化
测试要覆盖正常转化率、非法分组、正负提升、对照组零转化,以及入参不被修改。
// file: internal/growth/metrics_test.go
// tier: L1
// verify: go test ./internal/growth/
package growth
import (
"errors"
"math"
"testing"
)
func almostEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func TestRate(t *testing.T) {
cases := []struct {
name string
g Group
want float64
}{
{"quarter", Group{Users: 100, Converted: 25}, 0.25},
{"all", Group{Users: 40, Converted: 40}, 1},
{"none", Group{Users: 40, Converted: 0}, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Rate(c.g)
if err != nil {
t.Fatalf("Rate = %v, want nil", err)
}
if !almostEqual(got, c.want) {
t.Fatalf("Rate = %v, want %v", got, c.want)
}
})
}
}
func TestRateRejectsInvalidGroups(t *testing.T) {
cases := []struct {
name string
g Group
want error
}{
{"zero-users", Group{Users: 0}, ErrNoUsers},
{"negative-users", Group{Users: -1}, ErrNoUsers},
{"negative-converted", Group{Users: 10, Converted: -1}, ErrBadConverted},
{"converted-over-users", Group{Users: 10, Converted: 11}, ErrBadConverted},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := Rate(c.g); !errors.Is(err, c.want) {
t.Fatalf("Rate = %v, want %v", err, c.want)
}
})
}
}
func TestLift(t *testing.T) {
cases := []struct {
name string
control Group
treat Group
want float64
}{
{"positive", Group{Users: 100, Converted: 10}, Group{Users: 100, Converted: 12}, 0.2},
{"negative", Group{Users: 100, Converted: 10}, Group{Users: 100, Converted: 8}, -0.2},
{"flat", Group{Users: 50, Converted: 5}, Group{Users: 200, Converted: 20}, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Lift(c.control, c.treat)
if err != nil {
t.Fatalf("Lift = %v, want nil", err)
}
if !almostEqual(got, c.want) {
t.Fatalf("Lift = %v, want %v", got, c.want)
}
})
}
}
func TestLiftRejectsZeroControl(t *testing.T) {
control := Group{Users: 100, Converted: 0}
treat := Group{Users: 100, Converted: 5}
if _, err := Lift(control, treat); !errors.Is(err, ErrZeroControlRate) {
t.Fatalf("Lift = %v, want ErrZeroControlRate", err)
}
}
func TestLiftPropagatesGroupErrors(t *testing.T) {
good := Group{Users: 100, Converted: 10}
if _, err := Lift(Group{Users: 0}, good); !errors.Is(err, ErrNoUsers) {
t.Fatalf("control error = %v, want ErrNoUsers", err)
}
if _, err := Lift(good, Group{Users: 10, Converted: 11}); !errors.Is(err, ErrBadConverted) {
t.Fatalf("treat error = %v, want ErrBadConverted", err)
}
}
func TestLiftDoesNotMutateGroups(t *testing.T) {
control := Group{Users: 100, Converted: 10}
treat := Group{Users: 100, Converted: 12}
beforeControl, beforeTreat := control, treat
if _, err := Lift(control, treat); err != nil {
t.Fatalf("Lift = %v, want nil", err)
}
if control != beforeControl || treat != beforeTreat {
t.Fatalf("inputs mutated: control=%+v treat=%+v", control, treat)
}
}
TestRate 覆盖 25%、100% 和 0% 三种正常情况。TestRateRejectsInvalidGroups 覆盖用户数为零、用户数为负、转化人数为负、转化人数大于用户数。TestLift 覆盖正提升、负提升和持平,其中持平用不同用户数的分组验证相对提升确实为零。
TestLiftRejectsZeroControl 验证对照组转化率为零时返回 ErrZeroControlRate,而不是 Inf。TestLiftPropagatesGroupErrors 验证实验组或对照组的非法计数会原样向上传递。TestLiftDoesNotMutateGroups 在调用前后比较两个结构体,确保纯函数没有副作用。
这些测试固定的是计算口径,不是统计结论。一个实验是否值得全量,还要看样本量、置信区间、护栏指标和外部干扰;代码只能保证数字算对,不能保证决策正确。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/growth/ -run 'TestRate|TestLift' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRate
=== RUN TestRate/quarter
=== RUN TestRate/all
=== RUN TestRate/none
--- PASS: TestRate (0.00s)
--- PASS: TestRate/quarter (0.00s)
--- PASS: TestRate/all (0.00s)
--- PASS: TestRate/none (0.00s)
=== RUN TestRateRejectsInvalidGroups
=== RUN TestRateRejectsInvalidGroups/zero-users
=== RUN TestRateRejectsInvalidGroups/negative-users
=== RUN TestRateRejectsInvalidGroups/negative-converted
=== RUN TestRateRejectsInvalidGroups/converted-over-users
--- PASS: TestRateRejectsInvalidGroups (0.00s)
--- PASS: TestRateRejectsInvalidGroups/zero-users (0.00s)
--- PASS: TestRateRejectsInvalidGroups/negative-users (0.00s)
--- PASS: TestRateRejectsInvalidGroups/negative-converted (0.00s)
--- PASS: TestRateRejectsInvalidGroups/converted-over-users (0.00s)
=== RUN TestLift
=== RUN TestLift/positive
=== RUN TestLift/negative
=== RUN TestLift/flat
--- PASS: TestLift (0.00s)
--- PASS: TestLift/positive (0.00s)
--- PASS: TestLift/negative (0.00s)
--- PASS: TestLift/flat (0.00s)
=== RUN TestLiftRejectsZeroControl
--- PASS: TestLiftRejectsZeroControl (0.00s)
=== RUN TestLiftPropagatesGroupErrors
--- PASS: TestLiftPropagatesGroupErrors (0.00s)
=== RUN TestLiftDoesNotMutateGroups
--- PASS: TestLiftDoesNotMutateGroups (0.00s)
PASS
ok webapp/internal/growth 0.287s
输出里的 TestRate、TestLift 及其子用例覆盖了正常值和边界值。PASS 只说明公式和校验正确;实验结论仍然需要分析层结合样本量与置信区间判断。
小结
增长实验的指标必须在开始前定义:分子、分母、窗口、去重键、排除项,一个都不能少。主指标只设一个,护栏指标要真正参与决策;转化率的分母决定了它回答什么问题,也决定了实验组和对照组是否可比。
短期指标容易受新奇效应和低质流量影响,长期留存要用 holdback 或队列分析验证。Rate 和 Lift 把转化率与相对提升收成可测试的纯函数,显式处理零分母、越界和对照组零转化;它们算的是点估计,显著性判断仍然属于分析层。
阅读导航:上一节:7.2 奖励发放与反作弊 · 下一节:8.1 实验分流与一致性 。
常见错误与边界
- 实验结束后才选主指标。看到哪个涨了就报哪个,显著性失去意义,任何实验都能「成功」。
- 转化率不写分母。注册转化率的分母可以是访问用户、点击用户或开始注册用户,三者回答的问题不同。
- 实验组和对照组分母结构不同。渠道占比、新老用户占比不一致时,总体差异可能只是结构差异。
- 按请求分流。同一个用户在两套界面之间切换,体验被污染,指标也无法归因到人。
- 只看第一周数据。新奇效应会让短期指标虚高,至少要看完整周期和留存队列。
- 护栏指标只定义不使用。主指标显著就全量,留存和投诉率变差也照上,损失会滞后暴露。
- 把点估计当成结论。
Lift返回的是相对提升,不是显著性;样本量不足时 20% 的提升也可能只是噪声。 - 对照组转化率为零时直接相除。会得到 Inf 或 NaN,必须在代码里显式报错并单独分析。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。