12.3 留存与付费的联合分析

12.1 看留存、12.2 看流失,这一节把留存和付费放在一起。用一个潜在活跃度同时驱动留存与付费,先量出「付费玩家 D7 留存更高」的原始溢价,再切活跃度十分位做分组内对照,把溢价里的选择效应分离出来,并给出收入结构与档位留存交叉表,最后讨论这张表怎么用于召回优先级。

12.1 看了留存,12.2 看了流失,这一节把留存和付费放在一起。运营里最常见的一句话是「付费玩家留存更好,所以要多做付费引导」——这句话的前半句通常对,后半句通常错。本节用一个潜在活跃度 e 同时驱动留存与付费,先跑出「付费留存溢价」,再用活跃度十分位内的对照把这个溢价拆开,量出其中有多少只是选择效应。LTV 建模在第 11 章,这里不重复。

一份配置,一个潜在变量

模型的核心是一行:e = rng()。e 是每名玩家的潜在活跃度(0~1),它同时决定三件事:D1 存活率(d1BasePct + e*d1SpanPct)、逐日存活率、以及付费概率与金额。换句话说,活跃度是留存和付费的共同原因——这正是我们要观察的现象。配置在 config/n12_joint.json(D 层):

{
  "version": 1,
  "note": "第 12 章留存与付费联合分析参数(n12 前缀)。用潜在活跃度 e 同时驱动留存与付费,观察两者的相关性、付费留存溢价与选择效应。比率一律整数百分比,禁止浮点;随机用自实现 mulberry32 确定性 PRNG。",
  "prngSeed": 20261013,
  "cohortSize": 20000,
  "horizonDays": 30,
  "engagementDeciles": 10,
  "retention": { "d1BasePct": 30, "d1SpanPct": 45, "dailyBasePct": 84, "dailySpanPct": 12, "decayStepPct": 1, "capPct": 97 },
  "payment": { "payBasePct": 5, "paySpanPct": 35, "amountMaxCents": 200000,
               "amountNote": "付费金额(分)= floor(e * rng()^2 * amountMaxCents):重尾且随活跃度单调抬升" },
  "payerTiers": [
    { "id": "non_payer", "name": "非付费", "minCents": 0 },
    { "id": "minnow", "name": "小额", "minCents": 1 },
    { "id": "dolphin", "name": "中额", "minCents": 10000 },
    { "id": "whale", "name": "大额", "minCents": 50000 }
  ]
}

付费金额用 floor(e * rng()^2 * amountMaxCents):rng()^2 造出重尾(多数人小额、少数人大额),乘 e 让高活跃玩家更容易出大额。四个档位(非付费 / 小额 / 中额 / 大额)按累计金额切分。

S 层仿真

# file: sim/n12_joint.py
# tier: S
# verify: python3 sim/n12_joint.py --config config/ --out out/
"""留存与付费的联合分析:用一个潜在活跃度 e 同时驱动留存与付费,观察两者的相关性。

读 config/n12_joint.json:留存由 e 决定(e 越高留存越好),付费概率与金额也由 e 决定。
按付费档位交叉统计 D1/D7/D30 与收入贡献,再做「活跃度十分位内」的付费 / 非付费对照,
把「付费留存溢价」里的选择效应量出来。产出 out/n12_joint.json + .csv + .svg。
"""

from __future__ import annotations

import argparse
import csv
import json
import os

from svg_line import render

MASK = 0xFFFFFFFF


def imul(a: int, b: int) -> int:
    return ((a & MASK) * (b & MASK)) & MASK


def mulberry32(seed: int):
    a = seed & MASK

    def nxt() -> float:
        nonlocal a
        a = (a + 0x6D2B79F5) & MASK
        t = imul(a ^ (a >> 15), a | 1)
        t = (t ^ ((t + imul(t ^ (t >> 7), t | 61)) & MASK)) & MASK
        return ((t ^ (t >> 14)) & MASK) / 4294967296.0

    return nxt


def simulate(cfg: dict, rng) -> list[dict]:
    rt, pay = cfg["retention"], cfg["payment"]
    players = []
    for _ in range(cfg["cohortSize"]):
        e = rng()
        d1 = rt["d1BasePct"] + int(e * rt["d1SpanPct"])
        daily = rt["dailyBasePct"] + int(e * rt["dailySpanPct"])
        amount = 0
        if int(rng() * 100) < pay["payBasePct"] + int(e * pay["paySpanPct"]):
            raw = rng()
            amount = int(e * raw * raw * pay["amountMaxCents"])
        alive = [0] * (cfg["horizonDays"] + 1)
        for day in range(1, cfg["horizonDays"] + 1):
            p = d1 if day == 1 else min(rt["capPct"], daily + (day - 2) * rt["decayStepPct"])
            if int(rng() * 100) >= p:
                break
            alive[day] = 1
        players.append({"e": e, "d1": alive[1], "d7": alive[7], "d30": alive[30], "amount": amount})
    return players


def tier_of(amount: int, tiers: list[dict]) -> str:
    t = tiers[0]["id"]
    for x in tiers:
        if amount >= x["minCents"]:
            t = x["id"]
    return t


def agg(players: list[dict]) -> dict:
    n = len(players)
    if n == 0:
        return {"count": 0, "d1Permille": 0, "d7Permille": 0, "d30Permille": 0, "revenueCents": 0}
    return {"count": n, "d1Permille": sum(p["d1"] for p in players) * 1000 // n,
            "d7Permille": sum(p["d7"] for p in players) * 1000 // n,
            "d30Permille": sum(p["d30"] for p in players) * 1000 // n,
            "revenueCents": sum(p["amount"] for p in players)}


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)
    with open(os.path.join(args.config, "n12_joint.json"), encoding="utf-8") as fh:
        cfg = json.load(fh)

    players = simulate(cfg, mulberry32(cfg["prngSeed"]))
    tiers = cfg["payerTiers"]
    for p in players:
        p["tier"] = tier_of(p["amount"], tiers)
    total_rev = sum(p["amount"] for p in players)

    tier_rows = []
    for t in tiers:
        a = agg([p for p in players if p["tier"] == t["id"]])
        a.update({"id": t["id"], "name": t["name"], "sharePermille": a["count"] * 1000 // len(players),
                  "revenueSharePermille": a["revenueCents"] * 1000 // total_rev if total_rev else 0,
                  "arppuCents": a["revenueCents"] // a["count"] if a["count"] else 0})
        tier_rows.append(a)

    ap_, anp = agg([p for p in players if p["tier"] != "non_payer"]), agg([p for p in players if p["tier"] == "non_payer"])

    order = sorted(players, key=lambda p: p["e"])
    step = len(order) // cfg["engagementDeciles"]
    deciles = []
    for d in range(cfg["engagementDeciles"]):
        grp = order[d * step:(d + 1) * step] if d < cfg["engagementDeciles"] - 1 else order[d * step:]
        gp = [p for p in grp if p["tier"] != "non_payer"]
        gn = [p for p in grp if p["tier"] == "non_payer"]
        d7p = sum(p["d7"] for p in gp) * 1000 // len(gp) if gp else 0
        d7n = sum(p["d7"] for p in gn) * 1000 // len(gn) if gn else 0
        deciles.append({"decile": d + 1, "payerN": len(gp), "nonPayerN": len(gn), "payerD7Permille": d7p,
                        "nonPayerD7Permille": d7n, "liftPermille": d7p * 1000 // d7n if d7n else 0})

    cells = {"payer_retained": 0, "payer_churned": 0, "nonpayer_retained": 0, "nonpayer_churned": 0}
    for p in players:
        cells[("payer" if p["tier"] != "non_payer" else "nonpayer") + ("_retained" if p["d30"] else "_churned")] += 1

    within = [d["liftPermille"] for d in deciles if d["liftPermille"]]
    report = {"version": 1, "cohortSize": len(players), "horizonDays": cfg["horizonDays"],
              "totalRevenueCents": total_rev, "tiers": tier_rows, "payer": ap_, "nonPayer": anp,
              "rawRetentionLiftPermille": ap_["d7Permille"] * 1000 // anp["d7Permille"] if anp["d7Permille"] else 0,
              "avgWithinDecileLiftPermille": sum(within) // len(within) if within else 0,
              "deciles": deciles, "jointCells": cells,
              "payerSharePermille": ap_["count"] * 1000 // len(players),
              "payerRevenueSharePermille": ap_["revenueCents"] * 1000 // total_rev if total_rev else 0}
    with open(os.path.join(args.out, "n12_joint.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n12_joint.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["tier", "name", "count", "sharePermille", "d1Permille", "d7Permille", "d30Permille",
                    "revenueCents", "revenueSharePermille", "arppuCents"])
        for r in tier_rows:
            w.writerow([r["id"], r["name"], r["count"], r["sharePermille"], r["d1Permille"], r["d7Permille"],
                        r["d30Permille"], r["revenueCents"], r["revenueSharePermille"], r["arppuCents"]])
    render([r["d7Permille"] for r in tier_rows], os.path.join(args.out, "n12_joint.svg"),
           "D7 retention permille by payer tier (non_payer -> whale)")
    print(json.dumps({"tiers": [{k: r[k] for k in ("id", "count", "d7Permille", "d30Permille",
                                                    "revenueSharePermille", "arppuCents")} for r in tier_rows],
                      "payerSharePermille": report["payerSharePermille"],
                      "payerRevenueSharePermille": report["payerRevenueSharePermille"],
                      "rawRetentionLiftPermille": report["rawRetentionLiftPermille"],
                      "avgWithinDecileLiftPermille": report["avgWithinDecileLiftPermille"],
                      "jointCells": cells}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

脚本做四件事:按 e 仿真每名玩家的存活与付费;按档位交叉统计留存与收入;按 e 排序切十分位,在每个十分位内部单独比较付费与非付费的 D7 留存;最后统计付费 × 留存的 2×2 联合单元格。avgWithinDecileLiftPermille 是那十个内部比值的平均——它就是本节的主角。

实跑结果:档位交叉表

python3 sim/n12_joint.py --config config/ --out out/ 的 stdout:

{"tiers": [{"id": "non_payer", "count": 15497, "d7Permille": 317, "d30Permille": 152, "revenueSharePermille": 0, "arppuCents": 0}, {"id": "minnow", "count": 1421, "d7Permille": 356, "d30Permille": 171, "revenueSharePermille": 24, "arppuCents": 3250}, {"id": "dolphin", "count": 1570, "d7Permille": 359, "d30Permille": 166, "revenueSharePermille": 217, "arppuCents": 26342}, {"id": "whale", "count": 1512, "d7Permille": 462, "d30Permille": 232, "revenueSharePermille": 758, "arppuCents": 95536}], "payerSharePermille": 225, "payerRevenueSharePermille": 1000, "rawRetentionLiftPermille": 1239, "avgWithinDecileLiftPermille": 1010, "jointCells": {"payer_retained": 856, "payer_churned": 3647, "nonpayer_retained": 2360, "nonpayer_churned": 13137}}

按档位汇总(out/n12_joint.csv):

档位人数占比D1D7D30收入占比ARPPU(分)
非付费1549777.4%5103171520%0
小额14217.1%5353561712.4%3250
中额15707.8%54035916621.7%26342
大额15127.5%62546223275.8%95536

三件事一眼可见:①付费玩家整体占 22.5%,收入 100% 来自他们(非付费档按定义收入为 0);②收入极度集中——大额档 7.5% 的人贡献 75.8% 的收入,ARPPU 95536 分;③留存随档位抬升:非付费 D7 31.7% → 大额 D7 46.2%。第 ③ 条正是要小心的:留存随档位抬升,不代表「付费让人留存」。

选择效应:把溢价拆开

全样本的付费 / 非付费 D7 对照:付费玩家 39.3%、非付费 31.7%,原始溢价 1.239 倍。看起来「付费让 D7 留存涨了两成多」。但如果把样本按活跃度 e 切成十个十分位,在每个十分位内部再比一次呢?

十分位内的付费 / 非付费 D7 比值分别是 1.221、0.905、1.257、0.912、0.980、0.985、1.015、0.901、1.032、0.923,平均 1.010。也就是说,在同活跃度的人里,付费者与非付费者的 D7 留存几乎一样(平均比值 1.010,贴近 1)。原始那 1.239 倍的溢价,绝大部分来自「本来就活跃的人既更容易付费、也更容易留存」——这就是选择效应。用辛普森悖论的话说:分组前付费看起来更留存,分组后这个差异基本消失。

这不代表付费对留存一定无影响——本节模型里确实没有让付费反过来影响留存(付费与留存都由 e 驱动,付费不改变 e),所以这里的 1.239 倍可以干净地全部归给选择效应。真实项目里两种效应会同时存在,本节的价值是给出一套分离它们的方法。

联合单元格

把付费状态与 D30 是否留存做成 2×2(out/n12_joint.json 的 jointCells):付费且留存 856、付费未留存 3647、非付费且留存 2360、非付费未留存 13137。换算成 D30 留存率:付费 19.0%、非付费 15.2%。这 3.8 个百分点的差异在十分位内几乎消失,这就是选择效应的量级——先分组,再看它还在不在。

怎么用这张表

这张联合分析表在运营里有三个直接用途:①收入结构:大额档 7.5% 的人贡献 75.8% 收入,说明留存工作的收益不是均匀的,多留住一个大额玩家等于留住很多非付费玩家;②别被溢价骗:看到「付费玩家留存高」先别急着加付费弹窗,先做十分位内对照;③给 12.2 的召回定优先级:召回成本固定时,优先召回「高活跃度 + 历史付费」的人,期望价值更高。

但要强调:本节的一切都是同机相对量级。金额上限、档位切分、活跃度与留存的耦合强度都是配置里的假设值,换一份配置绝对数字全变。能搬走的是方法:一个共同原因 + 分组内对照 = 选择效应可量化。

小结

留存与付费不是两个独立的指标,它们常常有同一个原因(活跃度)。本节用潜在活跃度 e 同时驱动两者,跑出付费 D7 溢价 1.239 倍,但活跃度十分位内的平均比值只有 1.010——溢价几乎全是选择效应。联合表还给出了收入结构:付费玩家占 22.5%、贡献 100% 收入,其中大额档 7.5% 的人贡献 75.8%。数字不可移植,方法是可移植的。

一句话记住本节:看到「付费玩家留存更好」,先问一句「同一活跃度下还更好吗」。下一章把「相关」升级成「因果」——用随机分流做 A/B 实验。

阅读导航:上一节:12.2 流失预警与召回 · 下一节:13.1 实验设计与分流 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.2 流失预警与召回
  2. 附录 C:全书公式、参数与数据集索引
  3. 9.3 付费节奏与体验保护