《小游戏广告变现设计》 给的是「怎么摆广告位」,但没回答「摆完之后收入预测该给一个数还是一个区间」。3.2 算出 LTV30 = 2.30 元、第 8 天回本 ,但那个结论建立在一整套假设上:eCPM 真的会是 1500 分吗?留存真的撑得住 30 天吗?本节做两件事——扰动单个假设看谁最影响结论,以及让多个假设同时偏,看预测的区间有多宽。
单点预测是骗人的
给老板一个「月收入 39.3 万元」的预测,等于给了一个大概率不会发生的数。真实的假设都有误差:eCPM 会因为季节和竞价波动,留存会因为一次版本改动而塌,CPA 会因为投放竞争而涨。一个有价值的预测必须是一个区间,并告诉你这个区间为什么这么宽。
敏感性分析有两种做法,本节两种都做:
- 单参数龙卷风(tornado):一次只动一个假设,看它单独能把结论推多远。它回答「哪个假设最值得盯」。
- 多参数同时偏:所有假设一起随机偏,看结论的分布。它回答「预测区间有多宽」。后者用可播种的整数 LCG,保证可确定性回放。
D 层:把扰动档位与情景写成配置
下面这份 D 层配置落在 config/l03_sensitivity.json。它给出基准假设、每个参数的扰动档位(±10% / ±20%)、月度增长的整数比(103/100 = 每月 +3%)、6 个月预测,以及悲观 / 基准 / 乐观三档情景。全部整数,无浮点。
{
"version": 1,
"game": "merge-island",
"note": "收入预测与敏感性口径(D 层)。base 给出基准假设;perturbPcts 是对每个参数单独施加的整数百分比扰动档位;params 里 cap 是物理上限(填充率 100、付费率千分比 1000、留存千分比 1000),扰动越界即截断。growthNum/growthDen 是月度自然增长整数比(103/100 = 每月 +3%),用于 6 个月收入预测。retentionPermille 与 cpaFen 与 l03_ltv.json 同口径。全部整数、地板除。真实参数需真实业务环境,命令行不可复现(X)。",
"base": {
"dau": 20000,
"adRequestsPerDau": 26,
"fillPct": 88,
"ecpmFen": 1500,
"payRatePermille": 26,
"arppuFen": 1200
},
"retentionPermille": [1000, 420, 260, 190, 150, 125, 108, 95, 86, 78, 72, 67, 63, 60, 57, 55, 53, 51, 50, 49, 48, 47, 46, 45, 44, 43, 43, 42, 42, 41],
"cpaFen": 150,
"horizonDays": 30,
"perturbPcts": [80, 90, 110, 120],
"params": [
{ "key": "adRequestsPerDau", "cap": null },
{ "key": "fillPct", "cap": 100 },
{ "key": "ecpmFen", "cap": null },
{ "key": "payRatePermille", "cap": 1000 },
{ "key": "arppuFen", "cap": null },
{ "key": "dau", "cap": null }
],
"growthNum": 103,
"growthDen": 100,
"forecastMonths": 6,
"scenarios": [
{ "id": "pessimistic", "name": "悲观", "dau": 8000, "adRequestsPerDau": 22, "fillPct": 80, "ecpmFen": 1100, "payRatePermille": 18, "arppuFen": 1000 },
{ "id": "base", "name": "基准", "dau": 20000, "adRequestsPerDau": 26, "fillPct": 88, "ecpmFen": 1500, "payRatePermille": 26, "arppuFen": 1200 },
{ "id": "optimistic", "name": "乐观", "dau": 40000, "adRequestsPerDau": 30, "fillPct": 94, "ecpmFen": 1900, "payRatePermille": 34, "arppuFen": 1500 }
]
}
S 层:龙卷风与 6 个月预测
下面这段是 S 层仿真脚本 sim/l03_sensitivity.py,逐参数扰动算 LTV 跨度(龙卷风),并按三档情景与月度增长整数比推演 6 个月收入,产出 JSON + 两个 CSV + 纯 Python SVG 龙卷风图。
#!/usr/bin/env python3
# file: sim/l03_sensitivity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l03_sensitivity.py --config config/ --out out/
"""敏感性分析与收入预测(本卷 3.3 的 S 层)。
1) 龙卷风:以 base 为锚,逐一把每个参数单独按 ±10% / ±20%(整数百分比)扰动,重算
30 天 LTV(deciFen),统计每个参数造成的 LTV 跨度(max-min),降序排「哪个假设最
影响结论」。百分比类参数设物理上限(填充率 100、付费率千分比 1000),越界即截断。
2) 6 个月收入预测:悲观 / 基准 / 乐观三档,各按月度自然增长整数比 growthNum/growthDen
(103/100 = 每月 +3%)推演:monthlyFen(m) = dailyFen * 30 * num^m // den^m(Python 大整数)。
产出 out/l03_sensitivity.json + _tornado.csv + _forecast.csv + .svg。
注意:所有输入为示例假设参数;真实参数需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def arpdau_deci_fen(p: dict) -> int:
return p["adRequestsPerDau"] * p["fillPct"] * p["ecpmFen"] // 10000 + \
p["arppuFen"] * p["payRatePermille"] // 100
def ltv_deci_fen(arpdau: int, retention: list[int], days: int) -> int:
return sum(arpdau * retention[d] // 1000 for d in range(days))
def payback_day(arpdau: int, retention: list[int], cpa_fen: int) -> int:
target, cum = cpa_fen * 10, 0
for d in range(len(retention)):
cum += arpdau * retention[d] // 1000
if cum >= target:
return d + 1
return -1
def monthly_fen(p: dict, month: int, gn: int, gd: int) -> int:
return p["dau"] * arpdau_deci_fen(p) // 10 * 30 * gn ** month // gd ** month
def draw_svg(path: str, rows: list[dict], base_ltv: int) -> int:
W, MID, ROW = 760, 380, 44
H = 60 + ROW * len(rows)
hi = max(max(abs(r["lo"] - base_ltv), abs(r["hi"] - base_ltv)) for r in rows) or 1
scale = 300 / hi
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "26", "font-size": "15",
"fill": "#111827"}).text = "LTV30 敏感性龙卷风(±20%,0.1 分,示例参数)"
ET.SubElement(root, "line", {"x1": str(MID), "y1": "40", "x2": str(MID), "y2": str(H - 12),
"stroke": "#94a3b8"})
for i, r in enumerate(rows):
y = 50 + i * ROW
xl, xr = MID - int((base_ltv - r["lo"]) * scale), MID + int((r["hi"] - base_ltv) * scale)
ET.SubElement(root, "rect", {"x": str(xl), "y": str(y + 8), "width": str(MID - xl),
"height": "18", "rx": "3", "fill": "#93c5fd"})
ET.SubElement(root, "rect", {"x": str(MID), "y": str(y + 8), "width": str(xr - MID),
"height": "18", "rx": "3", "fill": "#2563eb"})
ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = r["param"]
ET.SubElement(root, "text", {"x": str(xr + 6), "y": str(y + 22), "font-size": "11",
"fill": "#6b7280"}).text = f'{r["spanPermille"]}‰'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "l03_sensitivity.json"), encoding="utf-8") as f:
cfg = json.load(f)
base, retention = cfg["base"], cfg["retentionPermille"][:cfg["horizonDays"]]
cpa, pcts, gn, gd = cfg["cpaFen"], cfg["perturbPcts"], cfg["growthNum"], cfg["growthDen"]
base_ltv = ltv_deci_fen(arpdau_deci_fen(base), retention, cfg["horizonDays"])
rows = []
for spec in cfg["params"]:
key, cap = spec["key"], spec["cap"]
vals = []
for pct in pcts:
p = dict(base)
v = base[key] * pct // 100
p[key] = min(v, cap) if cap is not None else v
vals.append(ltv_deci_fen(arpdau_deci_fen(p), retention, cfg["horizonDays"]))
lo, hi = min(vals), max(vals)
rows.append({"param": key, "cap": cap, "lo": lo, "hi": hi, "spanDeciFen": hi - lo,
"spanPermille": (hi - lo) * 1000 // base_ltv if base_ltv else 0})
rows.sort(key=lambda r: (-r["spanDeciFen"], r["param"]))
for i, r in enumerate(rows):
r["rank"] = i + 1
forecast = []
for s in cfg["scenarios"]:
a = arpdau_deci_fen(s)
months = [monthly_fen(s, m, gn, gd) for m in range(cfg["forecastMonths"])]
forecast.append({"id": s["id"], "name": s["name"], "arpdauDeciFen": a,
"ltv30DeciFen": ltv_deci_fen(a, retention, cfg["horizonDays"]),
"paybackDay": payback_day(a, retention, cpa), "monthlyFen": months,
"totalFen": sum(months)})
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "l03_sensitivity.svg"), rows, base_ltv)
report = {"version": cfg["version"], "game": cfg["game"], "baseLtv30DeciFen": base_ltv,
"basePaybackDay": payback_day(arpdau_deci_fen(base), retention, cpa),
"perturbPcts": list(pcts), "growthNum": gn, "growthDen": gd,
"forecastMonths": cfg["forecastMonths"], "tornado": rows,
"topParam": rows[0]["param"], "forecast": forecast, "svgBytes": svg_bytes,
"svg": os.path.join(args.out, "l03_sensitivity.svg"),
"csvTornado": os.path.join(args.out, "l03_sensitivity_tornado.csv"),
"csvForecast": os.path.join(args.out, "l03_sensitivity_forecast.csv")}
with open(os.path.join(args.out, "l03_sensitivity.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "l03_sensitivity_tornado.csv"), "w", encoding="utf-8",
newline="") as f:
w = csv.writer(f)
w.writerow(["rank", "param", "cap", "loLtv30DeciFen", "hiLtv30DeciFen", "spanDeciFen",
"spanPermille"])
for r in rows:
w.writerow([r["rank"], r["param"], r["cap"] if r["cap"] is not None else "", r["lo"],
r["hi"], r["spanDeciFen"], r["spanPermille"]])
with open(os.path.join(args.out, "l03_sensitivity_forecast.csv"), "w", encoding="utf-8",
newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "arpdauDeciFen", "ltv30DeciFen", "paybackDay"]
+ [f"m{m}" for m in range(cfg["forecastMonths"])] + ["totalFen"])
for s in forecast:
w.writerow([s["id"], s["name"], s["arpdauDeciFen"], s["ltv30DeciFen"], s["paybackDay"]]
+ s["monthlyFen"] + [s["totalFen"]])
print(json.dumps({"baseLtv30DeciFen": base_ltv, "topParam": report["topParam"],
"tornado": [{"param": r["param"], "spanPermille": r["spanPermille"]} for r in rows],
"forecast": [{"name": s["name"], "totalFen": s["totalFen"],
"paybackDay": s["paybackDay"]} for s in forecast]},
ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
真跑输出(龙卷风按 LTV30 跨度降序):adRequestsPerDau 223‰、ecpmFen 210‰、payRatePermille 203‰、arppuFen 192‰、fillPct 178‰、dau 0‰。前三名全是「人均收入」类参数,而 DAU 的跨度是 0——因为 LTV 是单安装指标,跟你有多少用户毫无关系。这是 3.1 埋下的那个反直觉结论:DAU 决定总收入的量级,但不影响单用户回本。所以「靠堆量摊薄成本」在 LTV 口径下是伪命题,能摊薄的只有 CPA。
B 层:多假设同时偏的稳健性基准
龙卷风一次只动一个参数,现实里六个假设会一起偏。下面这段是 B 层基准 sim/l03_forecast_bench.py,用可播种的整数 LCG 在 base 附近同时扰动六个参数(各在 [80,120]% 间独立抽样),跑 400 个组合,统计 LTV30 与首月收入的 p10 / p50 / p90。它是软告警,不硬失败。
#!/usr/bin/env python3
# file: sim/l03_forecast_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/l03_forecast_bench.py --config config/ --out out/
"""收入预测稳健性基准(B 层,软告警不硬失败)。
3.3 的龙卷风是「单参数」扰动;真实世界里多个假设会同时偏。本基准用可播种的整数 LCG
(配置里的 seed)在 base 附近同时扰动 6 个参数(各在 [80,120]% 之间独立抽样,百分比类
参数越界截断),跑 SAMPLES 个组合,统计 30 天 LTV 与首月收入的 p10 / p50 / p90 分位。
判据(软):p90/p50 或 p50/p10 的千分比超过 SPREAD_WARN_PERMILLE 记为「预测区间过宽」
并告警;无论多少告警,退出码恒为 0——这是基准,不是门禁。随机数全部来自 seed + LCG,
可确定性回放(core/ 禁用 Math.random() 的同一条纪律)。产出 out/l03_forecast_bench.json。
注意:所有输入为示例假设参数;真实参数需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import json
import os
SPREAD_WARN_PERMILLE = 2200
SEED = 20261012
SAMPLES = 400
PARAMS = [("adRequestsPerDau", None), ("fillPct", 100), ("ecpmFen", None),
("payRatePermille", 1000), ("arppuFen", None), ("dau", None)]
def arpdau_deci_fen(p: dict) -> int:
return p["adRequestsPerDau"] * p["fillPct"] * p["ecpmFen"] // 10000 + \
p["arppuFen"] * p["payRatePermille"] // 100
def pct(sorted_vals: list[int], p: int) -> int:
return sorted_vals[min(len(sorted_vals) * p // 100, len(sorted_vals) - 1)] if sorted_vals else 0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "l03_sensitivity.json"), encoding="utf-8") as f:
cfg = json.load(f)
base, retention = cfg["base"], cfg["retentionPermille"][:cfg["horizonDays"]]
state, ltv_vals, rev_vals = SEED, [], []
for _ in range(SAMPLES):
p = dict(base)
for key, cap in PARAMS:
state = (1103515245 * state + 12345) % (2 ** 31)
v = base[key] * (80 + state % 41) // 100
p[key] = min(v, cap) if cap is not None else v
a = arpdau_deci_fen(p)
ltv_vals.append(sum(a * retention[d] // 1000 for d in range(len(retention))))
rev_vals.append(p["dau"] * a // 10 * 30)
ltv_vals.sort()
rev_vals.sort()
p10, p50, p90 = pct(ltv_vals, 10), pct(ltv_vals, 50), pct(ltv_vals, 90)
hi_ratio, lo_ratio = p90 * 1000 // p50, p50 * 1000 // p10
warnings = [] if max(hi_ratio, lo_ratio) < SPREAD_WARN_PERMILLE else [
f"LTV30 区间过宽:p10={p10} / p50={p50} / p90={p90}(0.1 分),单点预测不足以支撑决策"]
report = {"version": cfg["version"], "source": "sim/l03_forecast_bench.py", "seed": SEED,
"samples": SAMPLES, "spreadWarnPermille": SPREAD_WARN_PERMILLE,
"ltv30DeciFen": {"p10": p10, "p50": p50, "p90": p90,
"p50OverP10Permille": lo_ratio, "p90OverP50Permille": hi_ratio},
"monthlyFen": {"p10": pct(rev_vals, 10), "p50": pct(rev_vals, 50),
"p90": pct(rev_vals, 90)},
"warnings": warnings,
"verdict": "PASS(soft)" if not warnings else f"WARN(soft) {len(warnings)} 条"}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "l03_forecast_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"seed": SEED, "samples": SAMPLES, "ltv30DeciFen": report["ltv30DeciFen"],
"monthlyFen": report["monthlyFen"], "verdict": report["verdict"],
"warnings": warnings}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
真跑输出:seed 20261012、400 个组合,LTV30 的 p10 / p50 / p90 = 1830 / 2193 / 2650(即 1.83 / 2.19 / 2.65 元),p50/p10 = 1198‰、p90/p50 = 1208‰——区间并不算宽,判 PASS(soft);首月收入 p10 / p50 / p90 = 29,754,000 / 37,398,000 / 46,159,800 分。
数字与结论
三档情景的 6 个月预测(金额单位「元」):
| 情景 | ARPDAU | LTV30 | 回本日 | 6 个月收入合计 |
|---|---|---|---|---|
| 悲观 | 373 | 1.30 元 | 未回本 | 579,052 |
| 基准 | 655 | 2.30 元 | D8 | 2,542,085 |
| 乐观 | 1045 | 3.67 元 | D3 | 8,111,386 |
基准情景下,6 个月从 39.3 万(首月)按每月 +3% 爬到 45.6 万(第 6 月),合计约 254 万;悲观情景下 ARPDAU 只有 373,LTV30 = 1.30 元 < CPA 1.50 元,30 天内根本回不了本——这正是把预测做成区间的意义:基准看着能赚,但悲观档直接亏损。结论不是「收入 254 万」,而是「区间 58 万 ~ 811 万,且悲观档需要立刻砍 CPA 或换素材」。
这一节要记住的三件事
- 先看龙卷风,再看区间。龙卷风告诉你盯哪个假设(这里是人居广告请求数与 eCPM),区间告诉你预测有多不确定。
- DAU 不影响 LTV,只影响总收入。LTV 是单安装指标,堆量摊薄不了它,能摊薄的只有 CPA。
- 把预测写成区间,并给出触发动作。悲观档回不了本,就是一个明确的行动信号——砍 CPA 或换素材,而不是继续等数据。
必须诚实标注:所有输入参数与留存曲线均为示例假设,真实值需真实业务环境,命令行不可复现(X)。基准的区间宽度依赖所选的扰动范围(±20%),换一个范围会得到不同的区间,但它对「哪个假设更敏感」的排序是稳健的。
阅读导航:上一节:LTV 建模与回本周期 · 下一节:定价锚点与档位设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。