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/ 里的目标判定:
| 分位 | 目标上界 | 纯基础 | 判定 | 软/硬保底 | 判定 |
|---|---|---|---|---|---|
| p50 | 25 | 23 | 通过 | 23 | 通过 |
| p90 | 55 | 76 | 不通过 | 44 | 通过 |
| p99 | 60 | 151 | 不通过 | 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 层的作用是把「仿真实现对不对」和「设计好不好」分开——前者用偏差量,后者用体验区间。
小结
这一节把出货抽数彻底量化了:
- 期望告诉你平均成本(33.33 抽),方差告诉你波动(1071.86,标准差 ≈ 均值)。
- 解析解与仿真对照(0.26% / 0.55% 偏差)证明模型可信。
- 体验区间把「可接受」写成 p90/p99 的上界,纯基础越界、保底通过——判据筛的是尾部,不是均值。
- B 基准用 30 个种子证明仿真无系统性偏,同时提醒离散分位数本身就有抖动。
到这里,概率这条线就闭环了:5.1 定义掉什么、5.2 决定最坏掉多少次、5.3 给整条分布配上期望与判据。下一章换一个视角——卡池本身怎么搭、概率怎么公示,以及「多少抽出货」在定价语境下意味着多少钱。
阅读导航:上一节:5.2 伪随机与保底机制 · 下一节:6.1 卡池结构与概率公示 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。