5.3 期望、方差与体验区间

把出货抽数当成随机变量:几何分布的解析期望 33.33 抽、方差 1077.78 与仿真的 33.25 / 1071.86 对照。再按体验区间目标判定纯基础与保底两种设计,并用 30 个种子的蒙特卡洛均值偏差做 B 基准,证明仿真无系统性偏。

5.2 把出货抽数的形状改了。这一节给它配一把尺子:期望、方差,以及一个产品化的判据——体验区间。期望告诉你「平均花多少」,方差告诉你「有人会比平均惨多少」,体验区间则把「惨」写成一个可判定的数字。

这一节的落点是「数值怎么被设计」,不是「这一抽值多少钱」——抽卡的成本与定价在第 6 章 。

把出货抽数当随机变量

纯基础模式下,出货所需抽数 X 服从参数 p = 0.03 的几何分布,有两个闭式解:

  • 期望:E[X] = 1/p = 33.333…
  • 方差:Var(X) = (1-p)/p² = 1077.777…,标准差 ≈ 32.83

再看一眼这组数字:标准差 32.83 几乎等于均值 33.33。这是几何分布的招牌特征——不是「大多数人在 33 抽附近出货」,而是少数人抽很久把均值拽了上去。所以只看期望做数值,等于蒙着眼开车。

保底模式没有闭式解(概率逐抽变化),只能仿真。下面这段是 S 层脚本,同一份 config/,跑 20000 名玩家,输出两种模式的期望、方差、分位数,并按体验区间目标判定。

# file: sim/n05_expect.py
# tier: S
# verify: python3 sim/n05_expect.py --config config/ --out out/
"""期望、方差与体验区间(5.3):把「出货抽数」当成随机变量,算它的期望与方差。

对纯基础模式给出解析解(几何分布:均值 1/p、方差 (1-p)/p^2)并与仿真对照;
对保底模式给仿真解。再按 config 的体验区间目标(分位数上界)判定哪种设计「体感可控」。

产出:out/n05_expect.json + out/n05_expect.csv + out/n05_expect.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import os
import sys
import xml.etree.ElementTree as ET

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n05_pity import mulberry32, pulls_base, pulls_pity  # noqa: E402
from svg_line import polyline_points  # noqa: E402

SVG_NS = "http://www.w3.org/2000/svg"


def load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def quantile(sorted_vals, q):
    """离散分位:最小的 n 使经验 CDF(n) >= q。"""
    n = len(sorted_vals)
    target = math.ceil(q / 100.0 * n)
    return sorted_vals[min(n - 1, max(0, target - 1))]


def summarize(vals):
    s = sorted(vals)
    n = len(s)
    mean = sum(s) / n
    var = sum((v - mean) ** 2 for v in s) / n
    return {"mean": round(mean, 4), "variance": round(var, 4), "std": round(var ** 0.5, 4),
            "p50": quantile(s, 50), "p90": quantile(s, 90), "p99": quantile(s, 99), "max": s[-1]}


def render_multi(path, series, title):
    allv = [v for _, ys, _ in series for v in ys]
    lo, hi = min(allv), max(allv)
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": "0 0 640 360", "width": "640", "height": "360"})
    ET.SubElement(svg, "rect", {"width": "640", "height": "360", "fill": "#ffffff"})
    for _, ys, color in series:
        ET.SubElement(svg, "polyline", {"points": polyline_points(ys, lo, hi),
                                        "fill": "none", "stroke": color, "stroke-width": "2"})
    t = ET.SubElement(svg, "text", {"x": "56", "y": "16", "font-size": "13", "fill": "#111"})
    t.text = title
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return path


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    full = load(os.path.join(args.config, "n05_drops.json"))
    pity_cfg, band, players = full["pity"], full["experienceBand"], full["sim"]["players"]
    base = pity_cfg["basePct"]

    rng = mulberry32(20261012)
    base_vals = [pulls_base(base, rng) for _ in range(players)]
    pity_vals = [pulls_pity(pity_cfg, rng) for _ in range(players)]

    p = base / 100.0
    analytic = {"mean": round(1.0 / p, 4), "variance": round((1 - p) / (p * p), 4)}
    analytic["std"] = round(analytic["variance"] ** 0.5, 4)

    base_s, pity_s = summarize(base_vals), summarize(pity_vals)
    band_check = {}
    for mode, s in (("base", base_s), ("pity", pity_s)):
        band_check[mode] = {
            "p50": s["p50"], "p50Pass": s["p50"] <= band["p50Max"],
            "p90": s["p90"], "p90Pass": s["p90"] <= band["p90Max"],
            "p99": s["p99"], "p99Pass": s["p99"] <= band["p99Max"],
        }
    report = {"version": 1, "players": players, "basePct": base, "analyticBase": analytic,
              "simBase": base_s, "simPity": pity_s, "band": band, "bandCheck": band_check}

    # PMF:恰好第 n 抽出货的概率
    max_n = max(max(base_vals), max(pity_vals))
    hb = [0] * (max_n + 1)
    hp = [0] * (max_n + 1)
    for v in base_vals:
        hb[v] += 1
    for v in pity_vals:
        hp[v] += 1
    pmf_base = [100.0 * hb[n] / players for n in range(1, max_n + 1)]
    pmf_pity = [100.0 * hp[n] / players for n in range(1, max_n + 1)]

    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n05_expect.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n05_expect.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["pulls", "pmfBasePct", "pmfPityPct"])
        for n in range(max_n):
            w.writerow([n + 1, round(pmf_base[n], 4), round(pmf_pity[n], 4)])
    render_multi(os.path.join(args.out, "n05_expect.svg"),
                 [("纯基础 PMF", pmf_base, "#d1242f"), ("软/硬保底 PMF", pmf_pity, "#1f6feb")],
                 f"出货抽数分布:基础均值 {base_s['mean']} / 保底均值 {pity_s['mean']}")

    print(f"解析(几何): 均值 {analytic['mean']}  方差 {analytic['variance']}  标准差 {analytic['std']}")
    print(f"仿真基础    : 均值 {base_s['mean']}  方差 {base_s['variance']}  标准差 {base_s['std']}")
    print(f"仿真保底    : 均值 {pity_s['mean']}  方差 {pity_s['variance']}  标准差 {pity_s['std']}")
    for mode in ("base", "pity"):
        c = band_check[mode]
        print(f"体验区间[{mode}] p50={c['p50']}({'>' if c['p50Pass'] else '×'}) "
              f"p90={c['p90']}({'>' if c['p90Pass'] else '×'}) p99={c['p99']}({'>' if c['p99Pass'] else '×'})")
    return 0


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

本机实跑输出(同机相对量级,绝对数值不可移植):

解析(几何): 均值 33.3333  方差 1077.7778  标准差 32.8295
仿真基础    : 均值 33.2456  方差 1071.8583  标准差 32.7392
仿真保底    : 均值 24.5429  方差 238.9427  标准差 15.4578
体验区间[base] p50=23(>) p90=76(×) p99=151(×)
体验区间[pity] p50=23(>) p90=44(>) p99=48(>)

仿真的 33.2456 与解析的 33.3333 只差 0.26%,方差 1071.86 对 1077.78 差 0.55%。解析解和仿真解对上了,模型就是可信的。 保底把方差从 1071.86 压到 238.94(降到约 22%),标准差从 32.74 抽压到 15.46 抽——方差比均值掉得更狠,这正是保底「稳体验」的量化证据。

体验区间:把「可接受」写成判据

数值设计的最后一公里是把主观感受写成数字。体验区间就是给出分位数的上界:出货抽数的 p50 / p90 / p99 各自不得超过多少。用 config/ 里的目标判定:

分位目标上界纯基础判定软/硬保底判定
p502523通过23通过
p905576不通过44通过
p9960151不通过48通过

结论很干净:纯基础在 p90 与 p99 上双双越界,保底全部通过。 注意 p50 两者都是 23、都通过——体验区间不是用来筛「平均体验」,而是用来筛「尾部体验」的。若只看均值,两种设计都「33 抽上下」,看不出差别;把判据放到 p90/p99,保底的必要性立刻显形。

out/n05_expect.svg 画的是两条 PMF:纯基础是一条从 3% 单调衰减的红线,保底在 40 抽后鼓出一个 2.9%–5% 的概率包——那就是软保底把概率「搬」过去的痕迹。

B 基准:期望与模拟对照

上面的 33.2456 只是一个种子的结果。仿真实现到底有没有系统性偏差?这是 B 层要回答的:换 30 个种子各跑一遍纯基础模式,把蒙特卡洛均值与解析期望 33.3333 对照,并看经验分位数命中解析值的比例。

# file: sim/n05_expect_bench.py
# tier: B
# verify: python3 sim/n05_expect_bench.py --config config/ --out out/
"""期望与模拟对照基准(B 层,软告警不硬失败)。

单跑一个种子得到的基础均值(33.25)只是一次抽样。换 30 个种子各跑一遍纯基础模式,
把蒙特卡洛均值与几何分布解析期望(1/p)对照,并看经验 p90/p99 与解析分位数是否吻合。
偏差超阈值只告警——保底模式没有闭式解,这里量的是「仿真实现有没有系统性偏」。

产出:out/n05_expect_bench.json
"""

from __future__ import annotations

import argparse
import json
import math
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n05_pity import mulberry32, pulls_base  # noqa: E402
from n05_expect import quantile  # noqa: E402


def geo_quantile(p, q):
    """几何分布的第 q 百分位抽数:最小 n 使 1-(1-p)^n >= q。"""
    return math.ceil(math.log(1 - q / 100.0) / math.log(1 - p))


def pctl(sorted_vals, p):
    return sorted_vals[(p * (len(sorted_vals) - 1)) // 100]


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, "n05_drops.json"), encoding="utf-8") as f:
        full = json.load(f)
    base = full["pity"]["basePct"]
    players = full["sim"]["players"]
    trials = full["sim"]["trials"]
    p = base / 100.0
    analytic_mean = 1.0 / p
    aq90, aq99 = geo_quantile(p, 90), geo_quantile(p, 99)

    devs, p90_hits, p99_hits = [], 0, 0
    for t in range(trials):
        rng = mulberry32(full["seed"] + t * 7919)
        vals = [pulls_base(base, rng) for _ in range(players)]
        s = sorted(vals)
        mc_mean = sum(vals) / players
        devs.append(100.0 * abs(mc_mean - analytic_mean) / analytic_mean)
        if quantile(s, 90) == aq90:
            p90_hits += 1
        if quantile(s, 99) == aq99:
            p99_hits += 1

    devs.sort()
    warn_pct = 2.0
    report = {
        "version": 1, "trials": trials, "players": players, "basePct": base,
        "analyticMean": round(analytic_mean, 4),
        "analyticP90": aq90, "analyticP99": aq99,
        "meanDevMinPct": round(devs[0], 4), "meanDevP50Pct": round(pctl(devs, 50), 4),
        "meanDevP95Pct": round(pctl(devs, 95), 4), "meanDevMaxPct": round(devs[-1], 4),
        "p90MatchTrials": p90_hits, "p99MatchTrials": p99_hits,
        "warnPct": warn_pct, "withinWarn": pctl(devs, 95) <= warn_pct,
    }
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n05_expect_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(f"解析期望 {report['analyticMean']}(p90={aq90} p99={aq99})")
    print(f"{trials} 个种子的蒙特卡洛均值偏差:min={report['meanDevMinPct']}% "
          f"p50={report['meanDevP50Pct']}% p95={report['meanDevP95Pct']}% max={report['meanDevMaxPct']}%")
    print(f"经验分位数命中解析值:p90 {p90_hits}/{trials}  p99 {p99_hits}/{trials}")
    print(f"OK p95 偏差 {report['meanDevP95Pct']}% 在 {warn_pct}% 以内:仿真无系统性偏"
          if report["withinWarn"] else
          f"WARN p95 偏差 {report['meanDevP95Pct']}% 超过 {warn_pct}%:检查抽样或 PRNG")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
解析期望 33.3333(p90=76 p99=152)
30 个种子的蒙特卡洛均值偏差:min=0.0021% p50=0.4144% p95=1.1133% max=1.6152%
经验分位数命中解析值:p90 13/30  p99 6/30
OK p95 偏差 1.1133% 在 2.0% 以内:仿真无系统性偏

读这份基准:

  • 均值偏差 p95 只有 1.11%、最大 1.62%,全部落在 2% 告警线内——仿真的期望没有系统性偏,20000 人的样本量足够。
  • 经验 p90 命中解析值 76 的比例是 13/30,p99 命中 152 是 6/30。命中率低不是错——分位数是离散的,相邻两抽的经验 CDF 可能同时跨过 90%,谁被选中取决于样本抖动。这正是「单次分位数不可当铁律」的证据,也说明为什么体验区间要留余量。

阈值超了只告警不硬失败:线上还有活动、补偿、版本更新,出货体验本来就会漂。B 层的作用是把「仿真实现对不对」和「设计好不好」分开——前者用偏差量,后者用体验区间。

小结

这一节把出货抽数彻底量化了:

  1. 期望告诉你平均成本(33.33 抽),方差告诉你波动(1071.86,标准差 ≈ 均值)。
  2. 解析解与仿真对照(0.26% / 0.55% 偏差)证明模型可信。
  3. 体验区间把「可接受」写成 p90/p99 的上界,纯基础越界、保底通过——判据筛的是尾部,不是均值。
  4. B 基准用 30 个种子证明仿真无系统性偏,同时提醒离散分位数本身就有抖动。

到这里,概率这条线就闭环了:5.1 定义掉什么、5.2 决定最坏掉多少次、5.3 给整条分布配上期望与判据。下一章换一个视角——卡池本身怎么搭、概率怎么公示,以及「多少抽出货」在定价语境下意味着多少钱。

阅读导航:上一节:5.2 伪随机与保底机制 · 下一节:6.1 卡池结构与概率公示 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引