13.1 和 13.2 都建立在一组确定的参数上:DAU 20000、填充率 88%、eCPM 15 元……可立项时你并不知道这些数是多少,你只知道它们大概在什么范围。站内 小游戏平台专题(minigame) 那 25 篇是平台型 SaaS 的商业计划与财务口径(BP 里的收入预测怎么搭、融资叙事怎么写);本节是单款小游戏的收入预测——把不确定的参数变成区间,再排出「哪个参数最影响结论」。与第 10 章的分工依旧是:第 10 章定形态,本章把这些形态在不确定下算成区间。LTV 与回本周期仍是卷三第 3 章的主题,本节只做收入侧的情景与敏感性。
一个点不够,要给区间
13.1 给出的 39.3 万元月收入,是一个点估计。点估计的问题是它假装自己很确定——而 DAU、填充率、eCPM、付费率这些数,立项时全都是猜的。诚实的做法是给出区间:最差情况多少、基准多少、最好情况多少。
情景分析(scenario analysis)就是干这个的。它不追求「预测得准」,而追求「把不确定性摆到桌面上」:三档情景各自的假设是什么、区间有多宽、哪一档假设更值得担心。决策者拿到的不再是一个孤零零的数字,而是一张有上下限的表。
三档情景配置(D 层)
下面这段是 D 层配置:悲观 / 基准 / 乐观三档,每档给一组整数假设参数。参数区间的设定遵循一个朴素原则——悲观档把所有不利因素同时压到底,乐观档把所有有利因素同时拉到顶。这样得到的是「最坏情况」与「最好情况」的边界,而不是「平均情况」的扰动。
{
"version": 1,
"game": "merge-island",
"note": "收入预测三档情景(D 层)。悲观 / 基准 / 乐观各给一组整数假设参数,参数区间按整数化给出;真实 DAU、填充率、eCPM、付费率与 ARPPU 需真实业务环境,命令行不可复现(X)。金额一律「分」,比例一律整数百分比或千分比。",
"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 }
]
}
三档的假设差异可以并排看:
| 参数 | 悲观 | 基准 | 乐观 | 跨度 |
|---|---|---|---|---|
dau | 8000 | 20000 | 40000 | 5 倍 |
adRequestsPerDau | 22 | 26 | 30 | +36% |
fillPct | 80 | 88 | 94 | +18% |
ecpmFen | 1100 | 1500 | 1900 | +73% |
payRatePermille | 18 | 26 | 34 | +89% |
arppuFen | 1000 | 1200 | 1500 | +50% |
注意 DAU 的跨度是 5 倍,其余参数都在 2 倍以内。这不是随意设的——它反映了一个经验判断:早期产品最大的不确定性在「有多少人来」,而不是「每个人贡献多少」。下一节的敏感性基准会用数字验证这个判断是否成立。
情景仿真(S 层)
下面这段是 S 层仿真脚本,读 config/ch13_scenario.json,对每档用与 13.1 完全相同的整数公式算出 IAA / IAP 分量、日 / 月 / 年收入,并给出三档之间的区间比,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/ch13_scenario.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch13_scenario.py --config config/ --out out/
"""收入预测:悲观 / 基准 / 乐观三档情景。
对 config/ch13_scenario.json 的三组整数假设参数,各算出 IAA / IAP 两个 ARPDAU
分量与日 / 月 / 年收入,并给出三档之间的区间比(最高 / 最低)。
iaa = adRequestsPerDau * fillPct * ecpmFen // 10000 (0.1 分/DAU)
iap = arppuFen * payRatePermille // 100 (0.1 分/DAU)
dailyFen = dau * (iaa + iap) // 10;monthlyFen = dailyFen * 30
全程整数、地板除,无浮点。产出 out/ch13_scenario.json + .csv + .svg。
参数均为示例假设;真实数值需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
SCEN_COLOR = {"pessimistic": "#94a3b8", "base": "#2563eb", "optimistic": "#10b981"}
def evaluate(s: dict) -> dict:
iaa = s["adRequestsPerDau"] * s["fillPct"] * s["ecpmFen"] // 10000
iap = s["arppuFen"] * s["payRatePermille"] // 100
daily = s["dau"] * (iaa + iap) // 10
return dict(s, iaaArpdauDeciFen=iaa, iapArpdauDeciFen=iap, arpdauDeciFen=iaa + iap,
dailyFen=daily, monthlyFen=daily * 30, annualFen=daily * 365)
def draw_svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:三档月收入横条(单位万元),颜色区分情景。"""
W, H, PAD, ROW = 760, 60 + 50 * len(rows), 150, 50
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 = "《合成小岛》三档情景月收入(万元)"
hi = max(r["monthlyFen"] for r in rows) or 1
for i, r in enumerate(rows):
y = 50 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = f'{r["name"]}情景'
w = (W - PAD - 24) * r["monthlyFen"] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 8), "width": str(w),
"height": "24", "rx": "4",
"fill": SCEN_COLOR.get(r["id"], "#6b7280")})
ET.SubElement(root, "text", {"x": str(PAD + w + 8), "y": str(y + 25), "font-size": "12",
"fill": "#6b7280"}).text = (
f'{r["monthlyFen"] / 1000000:.2f}万元 · ARPDAU {r["arpdauDeciFen"] / 10:.1f}分')
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, "ch13_scenario.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = [evaluate(s) for s in cfg["scenarios"]]
monthly = [r["monthlyFen"] for r in rows]
spread = {"minMonthlyFen": min(monthly), "maxMonthlyFen": max(monthly),
"ratioPermille": max(monthly) * 1000 // min(monthly) if min(monthly) else 0}
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch13_scenario.svg"), rows)
report = {"version": cfg["version"], "game": cfg["game"], "scenarioCount": len(rows),
"scenarios": rows, "spread": spread,
"csv": os.path.join(args.out, "ch13_scenario.csv"),
"svg": os.path.join(args.out, "ch13_scenario.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "ch13_scenario.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch13_scenario.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "dau", "adRequestsPerDau", "fillPct", "ecpmFen",
"payRatePermille", "arppuFen", "iaaArpdauDeciFen", "iapArpdauDeciFen",
"arpdauDeciFen", "dailyFen", "monthlyFen", "annualFen"])
for r in rows:
w.writerow([r[k] for k in ("id", "name", "dau", "adRequestsPerDau", "fillPct",
"ecpmFen", "payRatePermille", "arppuFen", "iaaArpdauDeciFen",
"iapArpdauDeciFen", "arpdauDeciFen", "dailyFen",
"monthlyFen", "annualFen")])
print(json.dumps({"scenarios": [{"name": r["name"], "arpdauDeciFen": r["arpdauDeciFen"],
"monthlyFen": r["monthlyFen"]} for r in rows],
"spread": spread}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
脚本最后算了一个 spread.ratioPermille:三档月收入的区间比(最高 / 最低),用千分比表示。它是「不确定性有多宽」的单一数字。
实跑结果:三档对照
执行 /opt/homebrew/bin/python3 sim/ch13_scenario.py --config config/ --out out/,输出:
{
"scenarios": [
{ "name": "悲观", "arpdauDeciFen": 373, "monthlyFen": 8952000 },
{ "name": "基准", "arpdauDeciFen": 655, "monthlyFen": 39300000 },
{ "name": "乐观", "arpdauDeciFen": 1045, "monthlyFen": 125400000 }
],
"spread": { "minMonthlyFen": 8952000, "maxMonthlyFen": 125400000, "ratioPermille": 14008 }
}
整理成对照表(同机相对量级,绝对数值不可移植):
| 情景 | DAU | ARPDAU | 日收入 | 月收入 | 年收入 |
|---|---|---|---|---|---|
| 悲观 | 8000 | 37.3 分 | 2984 元 | 89520 元 | 1089160 元 |
| 基准 | 20000 | 65.5 分 | 13100 元 | 393000 元 | 4781500 元 |
| 乐观 | 40000 | 104.5 分 | 41800 元 | 1254000 元 | 15257000 元 |
区间比 14008‰ ≈ 14 倍。悲观 8.95 万元/月,乐观 125.4 万元/月——这个跨度看起来很大,但拆开看就明白了:DAU 单独就差了 5 倍,ARPDAU 又差了约 2.8 倍(37.3 → 104.5),两者相乘得到 14 倍。区间宽不是模型不靠谱,而是两个乘数的不确定性被乘在了一起。
这正是情景分析的价值:它不假装能预测准确,而是诚实地告诉你「如果最坏情况全中,你是 8.95 万;如果最好情况全中,你是 125.4 万」。决策者要判断的是:悲观档 8.95 万/月,这门生意还做不做?
敏感性基准:哪个参数最影响结论(B 层)
情景分析给了区间,但没回答一个更实用的问题:六个参数里,哪个最值得花力气去搞清楚?下面这段是 B 层基准脚本:以基准情景为锚,逐一把每个参数单独 ±10% / ±20%,重算月收入,统计每个参数能造成的月收入跨度,按跨度降序排出「谁最影响结论」。注意它是基准——无论多少告警,退出码恒为 0。
#!/usr/bin/env python3
# file: sim/ch13_sensitivity.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch13_sensitivity.py --config config/ --out out/
"""收入预测敏感性基准(B 层,软告警不硬失败)。
以 ch13_scenario.json 的「基准」情景为锚,逐一把 6 个输入参数单独 ±10% / ±20%
(整数百分比、地板除),重算月收入,统计每个参数造成的月收入跨度(max-min),
按跨度降序排出「哪个参数最影响结论」。
百分比类参数设物理上限(填充率 100、付费率千分比 1000),扰动越界即截断。
判据(软):跨度占基准月收入 >= SPAN_WARN_PERMILLE 记为「高影响」并告警;
无论多少告警,退出码恒为 0——这是基准,不是门禁。产出 out/ch13_sensitivity.json。
"""
from __future__ import annotations
import argparse
import json
import os
SPAN_WARN_PERMILLE = 300
# (参数名, 上限):百分比类参数有物理上限,扰动越界即截断
PARAMS = [("dau", None), ("adRequestsPerDau", None), ("fillPct", 100),
("ecpmFen", None), ("payRatePermille", 1000), ("arppuFen", None)]
PCTS = (80, 90, 110, 120)
def monthly_fen(p: dict) -> int:
iaa = p["adRequestsPerDau"] * p["fillPct"] * p["ecpmFen"] // 10000
iap = p["arppuFen"] * p["payRatePermille"] // 100
return p["dau"] * (iaa + iap) // 10 * 30
def perturb(base: dict, key: str, pct: int, cap) -> int:
p = dict(base)
v = base[key] * pct // 100
p[key] = min(v, cap) if cap is not None else v
return monthly_fen(p)
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, "ch13_scenario.json"), encoding="utf-8") as f:
cfg = json.load(f)
base = next(s for s in cfg["scenarios"] if s["id"] == "base")
base_monthly = monthly_fen(base)
rows = []
for key, cap in PARAMS:
vals = [perturb(base, key, pct, cap) for pct in PCTS]
lo, hi = min(vals), max(vals)
rows.append({"param": key, "cap": cap, "loMonthlyFen": lo, "hiMonthlyFen": hi,
"spanFen": hi - lo,
"spanPermille": (hi - lo) * 1000 // base_monthly if base_monthly else 0})
rows.sort(key=lambda r: (-r["spanFen"], r["param"]))
for i, r in enumerate(rows):
r["rank"] = i + 1
warnings = [f'{r["param"]} 跨度 {r["spanPermille"]}‰(>= {SPAN_WARN_PERMILLE}‰),对结论高影响'
for r in rows if r["spanPermille"] >= SPAN_WARN_PERMILLE]
report = {
"version": cfg["version"], "source": "sim/ch13_sensitivity.py",
"baseMonthlyFen": base_monthly,
"baseArpdauDeciFen": (base["adRequestsPerDau"] * base["fillPct"] * base["ecpmFen"] // 10000
+ base["arppuFen"] * base["payRatePermille"] // 100),
"perturbPcts": list(PCTS), "spanWarnPermille": SPAN_WARN_PERMILLE,
"ranking": rows, "topParam": rows[0]["param"] if rows else None,
"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, "ch13_sensitivity.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"baseMonthlyFen": base_monthly,
"ranking": [{"param": r["param"], "spanPermille": r["spanPermille"]}
for r in rows],
"topParam": report["topParam"], "verdict": report["verdict"],
"warnings": warnings}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
扰动是单变量的:一次只动一个参数,其余保持基准值。这比「同时随机扰动所有参数」更容易归因——排名变了,你能直接说清是哪个参数造成的。百分比类参数(填充率、付费率)设了物理上限,越界即截断,避免出现「填充率 105%」这种不可能的输入。
结论与决策含义
执行 /opt/homebrew/bin/python3 sim/ch13_sensitivity.py --config config/ --out out/,输出:
{
"baseMonthlyFen": 39300000,
"ranking": [
{ "param": "dau", "spanPermille": 400 },
{ "param": "adRequestsPerDau", "spanPermille": 221 },
{ "param": "ecpmFen", "spanPermille": 209 },
{ "param": "payRatePermille", "spanPermille": 201 },
{ "param": "arppuFen", "spanPermille": 190 },
{ "param": "fillPct", "spanPermille": 178 }
],
"topParam": "dau",
"verdict": "WARN(soft) 1 条",
"warnings": ["dau 跨度 400‰(>= 300‰),对结论高影响"]
}
六档跨度排序(占基准月收入的千分比):DAU 400‰ > 广告请求 221‰ > eCPM 209‰ > 付费率 201‰ > ARPPU 190‰ > 填充率 178‰。三条结论:
- DAU 的影响是其他任何参数的近两倍。同样 ±20% 的扰动,DAU 能让月收入摆动 400‰(即上下 20%,因为它是线性乘数),而任何 ARPDAU 分量只能摆动约 200‰。原因是结构性的:DAU 直接乘在总收入上,而每个 ARPDAU 分量只占 ARPDAU 的一半左右(13.2 的 523‰ : 477‰),所以它对总收入的影响被折半了。这印证了 13.3 开头配置里的直觉——早期最大的不确定性在「有多少人来」。
- ARPDAU 的五个参数影响彼此接近,都在 178–221‰ 之间。这说明在 ARPDAU 内部,没有哪个参数是「一票否决」的:广告请求、eCPM、付费率、ARPPU 的重要性差不多。真要排优先级,可以按「可控性」而非「影响大小」来排——付费率与 ARPPU 是你能设计的,eCPM 与填充率是平台给的(X,需真实业务环境),所以该盯的是前者。
- 填充率垫底(178‰),部分原因是它的 +20% 扰动被 100% 上限截断了(88 → 105 被截到 100,实际只涨了约 13.6%)。这个「垫底」是模型设计造成的假象,不是填充率不重要——它提醒我们:敏感性排名要结合扰动口径一起读,别把截断造成的低位当成真实结论。
verdict 是 WARN(soft) 1 条——只有 DAU 一条告警。这不是说模型有问题,而是说「DAU 是唯一一个单独就能把结论搅动 300‰ 以上的参数」。软告警的含义正在于此:它标出最该被质疑的那个假设,而不是判模型不合格。
小结
本节把收入模型从单点变成了区间:悲观 8.95 万元/月、基准 39.3 万元/月、乐观 125.4 万元/月,区间比约 14 倍,其中 DAU 与 ARPDAU 各贡献了一部分不确定性。敏感性基准进一步排出了六个参数的影响次序——DAU 第一,且几乎是第二名的两倍——并提醒:排名要结合扰动口径读,填充率垫底是上限截断造成的假象。本章到此把「收入侧」算清楚了:13.1 给拆解,13.2 给结构,13.3 给区间与敏感性。下一章转入开发预算与资源计划,看这笔账的另一半——要花多少钱、多少人、多久。
阅读导航:上一节:混合变现的收入结构 · 下一节:人力与工时估算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。