第 9、10 章把付费点摆好了位置、标好了价。但从这一章起要回答一个更冷的问题:这笔生意划不划算。一个付费点值多少钱、多久能回本,取决于两件更前置的事——有多少人走到它面前(漏斗),以及这些人愿意待多久(留存曲线)。指标本身的定义(ARPU、LTV、CAC 分别是什么、怎么算)在既有文章 游戏商业核心指标详解 里已经给全,那是一份指标词典;本章不重复定义,而是做建模——把漏斗与留存变成能复算的曲线,再用它推 LTV 与回本周期。留存预警(谁快流失、怎么召回)留第 12 章,本节只管一件事:曲线长什么样、能不能算。
漏斗:六个环节,两处掉队
获客漏斗是一条逐级减员的链:投出去的钱买来安装,安装里有一部分真的启动,启动里有一部分走完新手,走完新手里有一部分第二天还回来,回来的里有一部分掏了第一笔钱。每一个环节都在筛人,筛得最狠的那一环,就是产品的命门。
本套用 config/n11_ltv.json 把这条链固定成六段,以每 10000 次安装为基数:
| 环节 | 人数 | 整体转化 | 单步转化 |
|---|---|---|---|
| 安装 | 10000 | 1000‰ | — |
| 首次启动 | 9300 | 930‰ | 930‰ |
| 完成新手引导 | 7200 | 720‰ | 774‰ |
| 次日回访 D1 | 4500 | 450‰ | 625‰ |
| 第 7 日回访 D7 | 2000 | 200‰ | 444‰ |
| 首次付费 | 420 | 42‰ | 210‰ |
「整体转化」是相对安装的累计比例,「单步转化」是相对上一环节的比例。两个数要分开看:整体转化回答「漏斗有多窄」,单步转化回答「哪一节最漏」。这张表里,安装→启动只掉 7%(930‰),说明包体和首屏没问题;而新手引导→D1 这一节单步只有 625‰,掉了 37.5%,是全链最陡的悬崖。D1 才是 SLG 真正的分水岭,不是安装量。
留存曲线:为什么用分段几何衰减
漏斗只回答「第几天还在」,不回答「第 30 天、第 90 天还在多少」。后者要靠留存曲线:R(d) 表示第 d 日仍在留存的玩家比例。真实的留存曲线有三个特征:早期陡降、中期趋缓、长尾拖得很长——D1 到 D7 掉得最快,D30 以后每天几乎不掉。它更像幂律,不像单纯的指数衰减。
本套用分段几何衰减逼近它:把 365 天切成若干段,每段给一个每日保留率(keepPermille),R(d) = R(d-1) × keep / 1000。段越靠后 keep 越接近 1000,曲线就越平。这个模型的好处是全程整数、跨语言逐位一致——幂律要算 d^b,会引入浮点,跨语言必然漂移;分段几何只用地板除,Python 与 TypeScript 算出来一个数。
下面是本节的 D 层配置,定义漏斗六段、留存分段与变现/买量口径(后两者 11.2、11.3 会用):
{
"version": 1,
"note": "第 11 章 LTV / 留存 / 买量参数(n11 前缀,独立于卷一 ch*)。比率一律整数千分比(permille),金额一律整数分,时长整数天。留存曲线用分段几何衰减(每日保留率 keepPermille),跨语言整数地板除可逐位对照。",
"funnel": {
"note": "获客漏斗:以每 10000 次安装为基数,各环节人数为整数计数;转换率由脚本算成千分比。",
"installs": 10000,
"stages": [
{ "id": "install", "name": "安装", "count": 10000 },
{ "id": "launch", "name": "首次启动", "count": 9300 },
{ "id": "tutorial", "name": "完成新手引导", "count": 7200 },
{ "id": "d1", "name": "次日回访 D1", "count": 4500 },
{ "id": "d7", "name": "第 7 日回访 D7", "count": 2000 },
{ "id": "first_pay", "name": "首次付费", "count": 420 }
]
},
"retention": {
"note": "分段几何衰减:R(1)=d1Permille,其后每一天按所在段的 keepPermille 衰减。内部用「微千分比」(permille×1000)递推 R(d)=R(d-1)*keep//1000,最后 //1000 输出千分比,避免逐日地板除的长尾漂移。",
"d1Permille": 450,
"segments": [
{ "dayFrom": 2, "dayTo": 7, "keepPermille": 874 },
{ "dayFrom": 8, "dayTo": 30, "keepPermille": 966 },
{ "dayFrom": 31, "dayTo": 90, "keepPermille": 987 },
{ "dayFrom": 91, "dayTo": 180, "keepPermille": 992 },
{ "dayFrom": 181, "dayTo": 365, "keepPermille": 996 }
],
"horizonDays": 365
},
"monetization": {
"note": "变现口径(设计假设,非实测):arpdauCents 为每个日活用户的日均收入(分);payerConversionPermille 为安装到首次付费的转化(千分比);arppuCents 为付费用户平均收入(分)。三条口径互为交叉核对。",
"arpdauCents": 40,
"payerConversionPermille": 42,
"arppuCents": 13000
},
"acquisition": {
"note": "买量口径(设计假设,非实测;真实 CAC / ROI 属 X 层,需真实业务环境):cacCentsPerInstall 为每安装成本(分)。scenarios 为公开口径下的 CAC 情景假设。",
"cacCentsPerInstall": 400,
"targetLtvCacPermille": 2000,
"scenarios": [
{ "id": "organic", "name": "自然量为主", "cacCentsPerInstall": 250 },
{ "id": "mixed", "name": "混合量", "cacCentsPerInstall": 400 },
{ "id": "paid_heavy", "name": "买量为主", "cacCentsPerInstall": 700 }
]
}
}
注意留存里有个容易忽略的细节:递推用「微千分比」(permille × 1000)做,最后才 //1000 输出千分比。原因见下一节的实跑——如果直接在千分比上逐日地板除,D365 会被舍入误差啃到 0。
仿真:一条能算的留存曲线
下面这段是 S 层仿真脚本。它读上面的配置,把漏斗各环节算成整体/单步转换率,再把留存分段几何衰减展开成 D1..D365 的每日千分比,输出 JSON + CSV + SVG:
# file: sim/n11_funnel_retention.py
# tier: S
# verify: python3 sim/n11_funnel_retention.py --config config/ --out out/
"""漏斗与留存曲线仿真:读 config/n11_ltv.json 的漏斗阶段与留存分段参数,把漏斗各环节
算成整体/单步转换率(千分比),并用分段几何衰减展开 D1..D365 的每日留存千分比。
产出 out/n11_funnel.json + out/n11_funnel.csv + out/n11_retention.csv + out/n11_retention.svg。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from svg_line import render
MICRO = 1000 # 每千分比的微单位:内部用「微千分比」递推,避免逐日地板除的长尾漂移
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as fh:
return json.load(fh)
def keep_for_day(segments: list[dict], day: int) -> int:
"""第 day 天所在段的每日保留率(千分比);落在段外返回最后一段。"""
for s in segments:
if s["dayFrom"] <= day <= s["dayTo"]:
return s["keepPermille"]
return segments[-1]["keepPermille"]
def build_retention_micro(cfg: dict) -> list[int]:
"""以微千分比(permille×1000)递推每日留存,整数地板除,跨语言可逐位对照。"""
ret_cfg = cfg["retention"]
horizon = ret_cfg["horizonDays"]
micro = [0] * (horizon + 1)
micro[1] = ret_cfg["d1Permille"] * MICRO
for d in range(2, horizon + 1):
micro[d] = micro[d - 1] * keep_for_day(ret_cfg["segments"], d) // MICRO
return micro
def build_retention(cfg: dict) -> list[int]:
"""展开每日留存千分比:ret[d] 为第 d 日仍在留存的千分比(d 从 1 起)。"""
return [m // MICRO for m in build_retention_micro(cfg)]
def funnel_steps(cfg: dict) -> list[dict]:
"""漏斗:每个阶段的整体转换率与相对上一阶段的单步转换率(千分比)。"""
stages, base = cfg["funnel"]["stages"], cfg["funnel"]["installs"]
rows = []
for i, st in enumerate(stages):
rows.append({"id": st["id"], "name": st["name"], "count": st["count"],
"overallPermille": st["count"] * 1000 // base,
"stepPermille": 1000 if i == 0 else st["count"] * 1000 // stages[i - 1]["count"]})
return rows
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n11_ltv")
ret, steps, horizon = build_retention(cfg), funnel_steps(cfg), cfg["retention"]["horizonDays"]
with open(os.path.join(args.out, "n11_retention.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["day", "retainedPermille"])
for d in range(1, horizon + 1):
w.writerow([d, ret[d]])
with open(os.path.join(args.out, "n11_funnel.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["id", "name", "count", "overallPermille", "stepPermille"])
for r in steps:
w.writerow([r["id"], r["name"], r["count"], r["overallPermille"], r["stepPermille"]])
render(ret[1:], os.path.join(args.out, "n11_retention.svg"), f"daily retention permille (D1..D{horizon})")
report = {"version": 1, "horizonDays": horizon,
"retentionAt": {f"d{d}": ret[d] for d in (1, 3, 7, 14, 30, 60, 90, 180, 365)},
"funnel": steps, "d1Check": ret[1] == cfg["retention"]["d1Permille"]}
with open(os.path.join(args.out, "n11_funnel.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(--config config/ --out out/,Python 3,零依赖)输出:
{"version": 1, "horizonDays": 365,
"retentionAt": {"d1": 450, "d3": 343, "d7": 200, "d14": 157, "d30": 90, "d60": 61,
"d90": 41, "d180": 19, "d365": 9},
"funnel": [
{"id": "install", "count": 10000, "overallPermille": 1000, "stepPermille": 1000},
{"id": "launch", "count": 9300, "overallPermille": 930, "stepPermille": 930},
{"id": "tutorial", "count": 7200, "overallPermille": 720, "stepPermille": 774},
{"id": "d1", "count": 4500, "overallPermille": 450, "stepPermille": 625},
{"id": "d7", "count": 2000, "overallPermille": 200, "stepPermille": 444},
{"id": "first_pay", "count": 420, "overallPermille": 42, "stepPermille": 210}],
"d1Check": true}
注意 d1Check: true——漏斗里的 D1 人数(4500)与留存曲线的 D1(450‰)是同一个数。两条口径必须在 D1 上对齐,否则「漏斗里说 45% 的人次日回来」和「留存曲线从 45% 起步」就是两套自相矛盾的数。这是本套所有曲线的一条硬约束:共享的锚点必须逐位相等。
读这条曲线:三个拐点
把每日留存按段拆开看,曲线有三个明显拐点:
- D1→D7(陡降段,keep 874‰):每天留 87.4%,六天从 450‰ 砍到 200‰。这段是「尝鲜玩家」的流失——他们被买来、装了、玩了两天,然后走了。这段的陡峭是正常的,问题不在陡,而在 D1 的起点够不够高。
- D7→D30(趋缓段,keep 966‰):每天留 96.6%,23 天从 200‰ 降到 90‰。留下来的已经是「有留存意向」的人,曲线开始平。
- D31 以后(长尾段,keep 987→992→996‰):每天几乎不掉,D90 还有 41‰、D180 有 19‰、D365 还有 9‰。SLG 的钱主要来自这条长尾——第 30 天只剩 9% 的人,但他们待得久、付费深。
这也是为什么留存曲线不能用「指数衰减」一笔带过:如果全程按 D1→D7 的 874‰ 衰减,D30 会掉到 8‰(几乎归零),而真实曲线 D30 还有 90‰——差了十倍。分段的意义就是把「早期陡、后期平」这两个斜率分开建模。
回头说那个「微千分比」的细节。如果直接写 ret[d] = ret[d-1] * keep // 1000,D365 会是什么?逐日地板除每次都向下舍掉不到 1 的零头,365 天累积下来会把长尾啃光——实测这个写法下 D180 只剩 14‰、D365 直接归零。改成先用 permille × 1000 的微单位递推、最后才 //1000,舍入误差被稀释到千分之一,D180 稳在 19‰、D365 稳在 9‰。这是「整数建模」必须付的注意力成本:地板除方向固定向下,长链累积就会漂。
跨语言对照:TS 侧逐位一致
同一份 config/n11_ltv.json,TypeScript 侧要算出完全相同的留存表与漏斗转换。下面这段是 P 层原型,与 Python 侧用同样的整数地板除:
// file: proto/src/n11_retention.ts
// tier: P
// verify: node --test proto/test/n11.test.ts
// 留存 / LTV 原型:与 sim/n11_funnel_retention.py、sim/n11_ltv_payback.py 消费同一份
// config/n11_ltv.json,用整数地板除逐位复刻分段几何衰减、漏斗转换与 LTV 累计。
import { loadConfig } from "./core.ts";
export const MICRO = 1000;
export interface Seg { dayFrom: number; dayTo: number; keepPermille: number }
export interface Stage { id: string; name: string; count: number }
export interface LtvConfig {
funnel: { installs: number; stages: Stage[] };
retention: { d1Permille: number; segments: Seg[]; horizonDays: number };
monetization: { arpdauCents: number; payerConversionPermille: number; arppuCents: number };
acquisition: {
cacCentsPerInstall: number; targetLtvCacPermille: number;
scenarios: { id: string; name: string; cacCentsPerInstall: number }[];
};
}
export function keepForDay(segments: Seg[], day: number): number {
let keep = segments[segments.length - 1].keepPermille;
for (const s of segments) {
if (s.dayFrom <= day && day <= s.dayTo) return s.keepPermille;
}
return keep;
}
export function buildRetentionMicro(cfg: LtvConfig): number[] {
const { d1Permille, segments, horizonDays } = cfg.retention;
const micro = new Array<number>(horizonDays + 1).fill(0);
micro[1] = d1Permille * MICRO;
for (let d = 2; d <= horizonDays; d++) {
micro[d] = Math.floor((micro[d - 1] * keepForDay(segments, d)) / MICRO);
}
return micro;
}
export function buildRetention(cfg: LtvConfig): number[] {
return buildRetentionMicro(cfg).map((m) => Math.floor(m / MICRO));
}
export function funnelSteps(cfg: LtvConfig) {
const stages = cfg.funnel.stages;
const base = cfg.funnel.installs;
return stages.map((st, i) => ({
id: st.id,
count: st.count,
overallPermille: Math.floor((st.count * 1000) / base),
stepPermille: i === 0 ? 1000 : Math.floor((st.count * 1000) / stages[i - 1].count),
}));
}
export function ltvCurve(cfg: LtvConfig, micro: number[]): number[] {
const arpdau = cfg.monetization.arpdauCents;
const horizon = cfg.retention.horizonDays;
const curve = new Array<number>(horizon + 1).fill(0);
let cum = 0;
for (let d = 1; d <= horizon; d++) {
cum += micro[d] * arpdau;
curve[d] = Math.floor(cum / (MICRO * 1000));
}
return curve;
}
export function paybackDay(curve: number[], cacCents: number): number {
for (let d = 1; d < curve.length; d++) {
if (curve[d] >= cacCents) return d;
}
return 0;
}
export function loadLtv(): LtvConfig {
return loadConfig<LtvConfig>("n11_ltv");
}
Math.floor((a * k) / MICRO) 与 Python 的 a * k // MICRO 是同一件事(两者都正数,地板除等价于截断)。11.2 会给出对应的 P 层测试,断言 TS 侧的留存表、漏斗转换、LTV 曲线与 Python 侧逐位相同。
小结
漏斗和留存曲线是 LTV 的两个输入,本节把它们都变成了可复算的表:漏斗六段,最陡的一节是新手引导→D1(单步 625‰);留存曲线用分段几何衰减展开 D1..D365,跑出 D1 450‰、D7 200‰、D30 90‰、D180 19‰。这两个表合起来,就是 11.2 推 LTV 与回本周期的底座。
一句话记住本节:漏斗管「有多少人进来」,留存管「他们待多久」,两把尺子在 D1 上必须对齐。下一节把这把人留住的曲线,折算成每安装值多少钱。
阅读导航:上一节:目录 · 下一节:11.2 LTV 建模与回本周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。