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):
| 档位 | 人数 | 占比 | D1 | D7 | D30 | 收入占比 | ARPPU(分) |
|---|---|---|---|---|---|---|---|
| 非付费 | 15497 | 77.4% | 510 | 317 | 152 | 0% | 0 |
| 小额 | 1421 | 7.1% | 535 | 356 | 171 | 2.4% | 3250 |
| 中额 | 1570 | 7.8% | 540 | 359 | 166 | 21.7% | 26342 |
| 大额 | 1512 | 7.5% | 625 | 462 | 232 | 75.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 实验设计与分流 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。