11.1 把留存曲线摊开了:D1 450‰、D7 200‰、D30 90‰。本节回答一个更值钱的问题——这 90‰ 留下来的玩家,一共能贡献多少收入。这个「一共」就是 LTV(用户终身价值)。它和 11.1 是同一件事的两面:留存曲线是横轴(待多久),变现口径是纵轴(每天值多少),LTV 是曲线下的面积。付费点本身怎么设计在第 9 章(如首充、月卡与通行证 ),本节只管把「人」和「钱」两条线相乘。留存预警(谁快流失)留第 12 章。
LTV 是「留存面积 × 单用户价值」
一个常见的误区是把 LTV 当成一个静态的数:「人均 LTV 5 元」。真实的 LTV 是一条随时间增长的曲线:LTV(d) 表示一个安装到第 d 天为止累计贡献了多少收入。它必然单调上升,且上升速度越来越慢——因为留存的人在变少。
这条曲线可以用一句话写出来:
LTV(N) = Σ_{d=1}^{N} ( 留存率(d) × ARPDAU )
留存率(d) 是 11.1 那条曲线的第 d 天(千分比 / 1000),ARPDAU 是每个日活用户的日均收入(average revenue per daily active user)。两项相乘就是第 d 天每安装的收入,再对 d 累加,就是 LTV。这个公式把两个独立的口径拼在一起:留存来自产品体验,ARPDAU 来自商业化设计。
建模:把留存曲线折成每安装收入
本套的 ARPDAU 取自 config/n11_ltv.json 的 monetization.arpdauCents = 40(即 0.40 元 / 日活 / 天)。把 11.1 的留存曲线按上面的公式累加,就得到 LTV 曲线。下面这段是 S 层仿真脚本,它读同一份配置,用微千分比留存算累计收入,输出 LTV 曲线、回本周期与两条口径的交叉核对:
# file: sim/n11_ltv_payback.py
# tier: S
# verify: python3 sim/n11_ltv_payback.py --config config/ --out out/
"""LTV 建模与回本周期仿真:读 config/n11_ltv.json 的留存曲线与变现/买量口径,把每日留存
折算成每安装的累计收入(分),得到 LTV 曲线与回本周期(天),并算 LTV/CAC 与
ARPDAU、付费转化×ARPPU 两条口径的交叉核对。产出 out/n11_ltv.json + .csv + .svg。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from n11_funnel_retention import MICRO, build_retention_micro, load
from svg_line import render
def ltv_curve(cfg: dict, micro: list[int]) -> list[int]:
"""每安装累计收入(分):cum[d] = (sum_{i<=d} micro[i]*arpdau) // (1000*1000)。
用微千分比累加分子再整除,避免逐日地板除丢精度。"""
arpdau, horizon = cfg["monetization"]["arpdauCents"], cfg["retention"]["horizonDays"]
cum, curve = 0, [0] * (horizon + 1)
for d in range(1, horizon + 1):
cum += micro[d] * arpdau
curve[d] = cum // (MICRO * 1000)
return curve
def payback_day(curve: list[int], cac_cents: int) -> int:
"""回本周期:累计每安装收入首次 >= CAC 的天数;horizon 内未回本返回 0。"""
for d in range(1, len(curve)):
if curve[d] >= cac_cents:
return d
return 0
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")
micro = build_retention_micro(cfg)
ret = [m // MICRO for m in micro]
curve = ltv_curve(cfg, micro)
mon, acq = cfg["monetization"], cfg["acquisition"]
cac = acq["cacCentsPerInstall"]
horizon = cfg["retention"]["horizonDays"]
with open(os.path.join(args.out, "n11_ltv.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["day", "retainedPermille", "ltvCents"])
for d in range(1, horizon + 1):
w.writerow([d, ret[d], curve[d]])
render(curve[1:], os.path.join(args.out, "n11_ltv.svg"), f"cumulative LTV per install (cents), CAC={cac}")
ltv_from_payer = mon["payerConversionPermille"] * mon["arppuCents"] // 1000
report = {
"version": 1, "arpdauCents": mon["arpdauCents"], "cacCentsPerInstall": cac,
"ltvCents": {f"d{d}": curve[d] for d in (30, 90, 180, 365)},
"paybackDay": payback_day(curve, cac), "ltvCacPermille": curve[horizon] * 1000 // cac,
"targetLtvCacPermille": acq["targetLtvCacPermille"],
"ltvFromPayerCents": ltv_from_payer,
"crossCheckGapPermille": (curve[horizon] - ltv_from_payer) * 1000 // ltv_from_payer,
}
with open(os.path.join(args.out, "n11_ltv.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/)输出:
{"version": 1, "arpdauCents": 40, "cacCentsPerInstall": 400,
"ltvCents": {"d30": 212, "d90": 361, "d180": 467, "d365": 570},
"paybackDay": 116, "ltvCacPermille": 1425, "targetLtvCacPermille": 2000,
"ltvFromPayerCents": 546, "crossCheckGapPermille": 43}
几个读法(同机相对量级,绝对数值不可移植):
- LTV 曲线:D30 是 212 分(2.12 元),D90 是 361 分,D180 是 467 分,D365 是 570 分。前 30 天赚到 212 分,占整生命周期 570 分的 37%;D30 到 D365 的 335 天只贡献剩下 63%。这就是 SLG 的典型形状——收入被拉长,急不得。
- 回本周期 D116:
cacCentsPerInstall = 400(4 元)时,累计 LTV 在第 116 天首次越过 400 分。SLG 的回本周期以「月」计,不是「天」;如果你在按 D7、D14 看回本,那是在看休闲游戏的口径。 - LTV/CAC = 1.425(1425‰),低于本套设的目标 2000‰(2.0)。这条差额说明:当前口径下这门生意只是「微赚」,还够不上健康的买量标准——11.3 会把它拆成三种买量情景再看。
回本周期:CAC 第几天赚回来
回本周期(payback period)的定义很简单:累计 LTV 第一次追平 CAC 的那一天。它是买量决策里最硬的数——一个游戏的 LTV 再高,如果回本周期长到钱烧完了还没回来,它一样活不下去。
这里有个容易被忽略的口径问题:回本周期依赖 CAC,而 LTV 曲线不依赖。同一款游戏,CAC 定 2.5 元时回本周期是第 42 天,定 4 元时是第 116 天,定 7 元时 365 天内根本回不了本。产品团队能改的是 LTV 曲线(留存、付费),买量团队能改的是 CAC,回本周期是这两条线的交点。11.3 会把三种 CAC 情景逐个跑出来。
B 基准:逐日递推 vs 闭式几何级数
上面那条 LTV 曲线是「逐日累加」出来的——一天一天地把 留存 × ARPDAU 加起来。但分段几何衰减的留存曲线其实有闭式解:每一段都是等比数列,sum_{i=0}^{n-1} S·r^i = S·(r^n−1)/(r−1)。两种算法应该给出同一个数。下面这段是 B 层基准脚本(软告警,不硬失败),它把逐日递推的留存面积与闭式几何级数对照,量化地板除漂移:
# file: sim/n11_ltv_bench.py
# tier: B
# verify: python3 sim/n11_ltv_bench.py --config config/ --out out/
"""LTV 基准(B 层,软告警不硬失败):把 S 层逐日地板除递推得到的留存面积,与分段几何级数
闭式(无地板除)对照,量化地板除漂移;并给出两条口径的 LTV 交叉核对偏差。
产出 out/n11_ltv_bench.json。退出码恒为 0。"""
from __future__ import annotations
import argparse
import json
import os
from fractions import Fraction
from n11_funnel_retention import MICRO, build_retention_micro, load
DRIFT_THRESHOLD_PERMILLE = 10
CROSSCHECK_THRESHOLD_PERMILLE = 100
def closed_form_micro(cfg: dict) -> Fraction:
"""分段几何级数闭式:每日保留率 r=k/1000,sum_{i=0}^{n-1} S*r^i = S*(r^n-1)/(r-1)。
整数通分:S*(k^n - 1000^n) / (1000^(n-1) * (k-1000)),S 为微千分比,用 Fraction 精确。"""
ret, micro = cfg["retention"], build_retention_micro(cfg)
total = Fraction(0)
for s in ret["segments"]:
ds, de, k = s["dayFrom"], s["dayTo"], s["keepPermille"]
n = de - ds + 1
total += Fraction(micro[ds - 1] * (k ** n - 1000 ** n), 1000 ** (n - 1) * (k - 1000))
return total
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")
mon = cfg["monetization"]
loop_micro = sum(build_retention_micro(cfg)[1:])
closed_micro = int(closed_form_micro(cfg))
drift = (loop_micro - closed_micro) * 1000 // closed_micro
arpdau = mon["arpdauCents"]
ltv_loop = loop_micro * arpdau // (MICRO * 1000)
ltv_payer = mon["payerConversionPermille"] * mon["arppuCents"] // 1000
cross_gap = (ltv_loop - ltv_payer) * 1000 // ltv_payer
report = {"version": 1, "kind": "baseline",
"loopMicro": loop_micro, "closedMicro": closed_micro,
"driftPermille": drift, "driftThresholdPermille": DRIFT_THRESHOLD_PERMILLE,
"ltvLoopCents": ltv_loop, "ltvClosedCents": closed_micro * arpdau // (MICRO * 1000),
"ltvPayerCents": ltv_payer, "crossCheckGapPermille": cross_gap,
"crossCheckThresholdPermille": CROSSCHECK_THRESHOLD_PERMILLE,
"pass": abs(drift) <= DRIFT_THRESHOLD_PERMILLE and abs(cross_gap) <= CROSSCHECK_THRESHOLD_PERMILLE}
with open(os.path.join(args.out, "n11_ltv_bench.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(f"{'OK' if report['pass'] else 'WARN'} drift={drift}permille crossGap={cross_gap}permille "
f"ltvLoop={ltv_loop} ltvPayer={ltv_payer}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出:
OK drift=-1permille crossGap=43permille ltvLoop=570 ltvPayer=546
逐日递推得到的留存面积是 14274938 微千分比,闭式解是 14275077 微千分比,相差 −1‰——两者在同一份参数下几乎完全一致。这个 −1‰ 正是 11.1 说的地板除漂移:递推每天向下舍掉不到 1 的零头,365 天累积下来少了 139 个微单位(约 0.14‰)。闭式解是无地板除的「真值」,递推是工程实现,两者对得上,说明模型自洽。
两条口径交叉核对:ARPDAU vs 转化 × ARPPU
LTV 还有第二种算法:付费转化 × ARPPU。如果 42‰ 的安装会付费、每个付费用户平均贡献 13000 分(130 元),那么 LTV = 42‰ × 13000 = 546 分。这和 ARPDAU 口径算出的 570 分相差 43‰,落在 100‰ 的告警线内。
两条口径为什么会有差?因为它们切的是不同的东西:
- ARPDAU 口径(570 分)是自下而上:每天活多少人、每人每天贡献多少,逐日累加。它天然包含「付费也发生在长尾」这件事。
- 转化 × ARPPU 口径(546 分)是自上而下:多少比例的人会付费、每个付费的人总共给多少。它是静态的,不区分付费发生在第几天。
两条口径差 43‰ 是健康的——说明 arpdauCents、payerConversionPermille、arppuCents 这三个假设互相不打架。如果两条口径差出两三倍,那一定是某个假设填错了(比如 ARPPU 写成了「人均付费」而不是「付费人均」)。用两条独立路径互相验证,是 LTV 建模最便宜的纠错手段。
跨语言对照:P 层测试
11.1 给出了 proto/src/n11_retention.ts。下面这段是 P 层测试,它 import 那份模块,断言 TS 侧的留存微千分比、留存千分比、漏斗转换、LTV 曲线与回本周期都与 Python 逐位一致:
// file: proto/test/n11.test.ts
// tier: P
// verify: node --test proto/test/n11.test.ts
// 跨语言对照:TS 侧与 Python 侧(sim/n11_funnel_retention.py + sim/n11_ltv_payback.py)
// 消费同一份 config/n11_ltv.json,断言留存微千分比、漏斗转换、LTV 曲线与回本周期逐位一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import {
loadLtv, buildRetentionMicro, buildRetention, funnelSteps, ltvCurve, paybackDay,
} from "../src/n11_retention.ts";
const cfg = loadLtv();
test("留存微千分比与 Python 逐位一致", () => {
const micro = buildRetentionMicro(cfg);
assert.equal(micro[1], 450000);
assert.equal(micro[7], 200576);
assert.equal(micro[30], 90514);
assert.equal(micro[90], 41263);
assert.equal(micro[180], 19992);
assert.equal(micro[365], 9456);
});
test("留存千分比与 Python 逐位一致(D1/D7/D30/D90/D180/D365)", () => {
const ret = buildRetention(cfg);
assert.deepEqual([ret[1], ret[7], ret[30], ret[90], ret[180], ret[365]], [450, 200, 90, 41, 19, 9]);
});
test("漏斗整体/单步转换与 Python 逐位一致", () => {
const steps = funnelSteps(cfg);
assert.deepEqual(steps.map((s) => s.overallPermille), [1000, 930, 720, 450, 200, 42]);
assert.deepEqual(steps.map((s) => s.stepPermille), [1000, 930, 774, 625, 444, 210]);
});
test("LTV 曲线与回本周期与 Python 逐位一致", () => {
const curve = ltvCurve(cfg, buildRetentionMicro(cfg));
assert.deepEqual([curve[30], curve[90], curve[180], curve[365]], [212, 361, 467, 570]);
assert.equal(paybackDay(curve, cfg.acquisition.cacCentsPerInstall), 116);
});
本机实跑 node --test proto/test/n11.test.ts(Node 26)输出:
✔ 留存微千分比与 Python 逐位一致
✔ 留存千分比与 Python 逐位一致(D1/D7/D30/D90/D180/D365)
✔ 漏斗整体/单步转换与 Python 逐位一致
✔ LTV 曲线与回本周期与 Python 逐位一致
ℹ tests 4 / ℹ pass 4 / ℹ fail 0
四条断言全过。跨语言逐位一致不是形式主义:LTV 是要拿去和真实买量花销对账的数,如果 Python 后台算出 570 分、前端看板用 TS 算出 569 分,对账时就会多出一堆「差一分」的噪音。系数用整数千分比、除法一律地板除、幂运算避开浮点,是本套从卷一贯穿到本卷的硬规则。
小结
LTV 是一条曲线,不是一句话:LTV(N) = Σ 留存(d) × ARPDAU。本节把它跑成了可复算的输出——D30 212 分、D90 361 分、D180 467 分、D365 570 分,回本周期 D116,LTV/CAC 1.425。B 基准证明逐日递推与闭式几何级数差 −1‰,两条口径(ARPDAU vs 转化 × ARPPU)差 43‰,都在健康区间内。
一句话记住本节:LTV 是留存曲线的面积乘以单用户价值,回本周期是它与 CAC 的交点。下一节把 CAC 这一头拆开——买量口径怎么算、哪些数字命令行里根本复现不了。
阅读导航:上一节:11.1 漏斗与留存曲线 · 下一节:11.3 CAC 与买量口径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。