13.3 收入预测与情景分析

把收入模型从单点变成区间:用悲观 / 基准 / 乐观三档整数假设参数跑出月收入对照表与区间比,再用 B 层敏感性基准把六个输入参数按「对结论的影响」排序,回答哪个数错了最致命、哪个数可以放心估,并说明排名要结合扰动口径一起读,给收入预测一个可决策的区间。

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 }
  ]
}

三档的假设差异可以并排看:

参数悲观基准乐观跨度
dau800020000400005 倍
adRequestsPerDau222630+36%
fillPct808894+18%
ecpmFen110015001900+73%
payRatePermille182634+89%
arppuFen100012001500+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 }
}

整理成对照表(同机相对量级,绝对数值不可移植):

情景DAUARPDAU日收入月收入年收入
悲观800037.3 分2984 元89520 元1089160 元
基准2000065.5 分13100 元393000 元4781500 元
乐观40000104.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 给区间与敏感性。下一章转入开发预算与资源计划,看这笔账的另一半——要花多少钱、多少人、多久。

阅读导航:上一节:混合变现的收入结构 · 下一节:人力与工时估算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制