11.2 LTV 建模与回本周期

LTV 不是一句「人均值多少」,而是一条逐日累计的曲线。本节用 config/n11_ltv.json 的留存曲线与 ARPDAU 口径,把 D1 到 D365 的每安装收入累加成 LTV 曲线,跑出 D180 4.67 元、回本周期第 116 天,再用 B 基准把逐日递推与闭式几何级数对照,并用 P 层测试守住跨语言逐位一致。

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 与买量口径 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引