15.1 从指标反推参数
《小游戏开发实战》卷一 12.3 原型迭代决策 回答的是产品口径的问题:原型跑出数据后,什么时候该改、什么时候该砍、什么时候该收。那是一个决策问题,读的是「要不要动」。本节问的是可计算的问题:如果已经定了要动,动到哪个值?给定一个目标指标,反推出满足它的参数——这是反推(inverse problem),不是决策。两者接口相邻但工具完全不同:迭代决策要的是判断标准,反推要的是求解器、可行域与计算代价。
正推与反推
正推 f:参数 → 指标。给一个 enemyHp,跑一批对局,得到一个集成胜率。反推就是解 f(p) = T:给一个目标胜率 T,求 p。
反推能做的第一个前提是 f 单调——只要 f 在区间上单调,就能用二分把求解从「逐点试」换成「逐层缩区间」。第二个前提是 f 可算——每次求值都要真跑 run_spec,所以「问 f 多少次」直接等于计算代价。这两条决定了本节的全部结构:先证明单调,再算代价。
指标取策略族 × 种子池的集成胜率:5 个固定下标策略 × 24 个种子 = 120 局,取胜率千分比。它比单策略胜率平滑得多——单策略曲线是 {1000, 958, 0} 的阶跃,反推会落到台阶上;集成胜率才有可解的连续区间。
{
"id": "gd_play_15_1_invert",
"note": "卷二 15.1 从指标反推参数:策略族 × 种子池的集成胜率(代理指标)+ 单参数整数二分反推 + 全网格基准 + 稳健性扰动(全整数,禁浮点)",
"seedPool": [501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524],
"turns": 5,
"slotsPerTurn": 4,
"strategies": [
{ "name": "slot0", "handIndex": 0 },
{ "name": "slot1", "handIndex": 1 },
{ "name": "slot2", "handIndex": 2 },
{ "name": "slot3", "handIndex": 3 },
{ "name": "slot4", "handIndex": 4 }
],
"targetPermille": 500,
"sweptParam": "enemyHp",
"range": { "from": 40, "to": 100 },
"bisect": { "maxRounds": 12 },
"robustness": { "poolOffsets": [0, 100, 200, 400], "driftWarn": 1, "seedCounts": [8, 16, 24] }
}
targetPermille=500 是目标胜率,bisect.maxRounds 是二分轮数上限,robustness 段给 B 层用。脚本 sim/gd_play_15_1_invert.py 把二分反推与全网格扫描并排跑:两者各求一次「最小的 enemyHp 使胜率 ≤ 500‰」,比较结果是否一致、各跑多少次 run_spec、耗时多少,并报单调性违反对数。
#!/usr/bin/env python3
# file: sim/gd_play_15_1_invert.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_15_1_invert.py
"""卷二 15.1 从指标反推参数(S 层):把「正推」翻成「反推」。
正推:参数 → 指标(给定 enemyHp,跑一批对局,得到**集成胜率**‰)。
反推:指标 → 参数(给定目标胜率 T,求哪个 enemyHp 让胜率落在 T)。
指标是**策略族 × 种子池**的集成胜率:5 个固定下标策略 × 24 个种子 = 120 局,取胜率千分比。
它比单策略胜率平滑得多(单策略曲线是 {1000,958,0} 的阶跃),因此反推有解。
反推对**单调递减**的曲线做整数二分:找最小的 p 使 winrate(p) <= T。二分只跑 O(log 区间)
次正推,全网格要跑 O(区间) 次——脚本把两者并排跑,比较反推值是否一致、各跑多少次
run_spec、耗时多少。另报**单调性违反对数**:曲线若在某处回升,二分前提即不成立,必须打印。
集成胜率是**代理指标**(proxy):只用于相对比较,禁绝对化——「胜率 500‰ 的 enemyHp」不等于
「体验最好的 enemyHp」。产物 out/play15_invert.json + .csv + .svg。全整数、无浮点、无 random。
"""
from __future__ import annotations
import copy
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_15_1_invert.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def strategy_seq(k, turns, slots):
"""固定下标策略:每回合打第 k 张牌 slots 次,再结束回合(与 2.1 的策略族同定义)。"""
return (["p" + str(k)] * slots + ["e"]) * turns
def winrate(spec, cfg, over, seeds):
"""正推:参数覆盖 over 下,策略族 × 种子池的集成胜率(千分比,整数地板除)。"""
wins = games = 0
for st in cfg["strategies"]:
seq = strategy_seq(st["handIndex"], cfg["turns"], cfg["slotsPerTurn"])
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
wins += 1 if ashlib.run_spec(s, seq)["won"] else 0
games += 1
return 1000 * wins // games
def invert(spec, cfg, param, lo, hi, target, seeds, cache):
"""整数二分:找最小的 p 使 winrate(p) <= target(曲线单调递减)。返回 (p, 二分正推次数)。"""
def w(v):
return cache.setdefault(v, winrate(spec, cfg, {param: v}, seeds))
if w(lo) <= target:
return lo, 0
if w(hi) > target:
return None, 0
a, b, calls = lo, hi, 0
while b - a > 1:
mid = (a + b) // 2
calls += 1
if w(mid) <= target:
b = mid
else:
a = mid
return b, calls
def draw(points, target, hit, path):
W, H, x0, y0, y1 = 640, 300, 70, 40, 240
xs = [p["value"] for p in points]
ys = [p["permille"] for p in points]
xlo, xhi, yhi = min(xs), max(xs), max(ys) or 1
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 20, 24, "正推曲线 winrate(enemyHp):反推 = 求与目标线的交点", 15)
ty = y1 - target * (y1 - y0) // yhi
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(ty), "x2": str(W - 30), "y2": str(ty),
"stroke": "#f59e0b", "stroke-width": "1", "stroke-dasharray": "4 4"})
ashlib.svg_text(root, W - 30, ty - 6, f"目标 {target}‰", 11, "#f59e0b", "end")
ET.SubElement(root, "polyline", {"points": " ".join(
f"{x0 + (p['value'] - xlo) * (W - 110) // (xhi - xlo or 1)},"
f"{y1 - p['permille'] * (y1 - y0) // yhi}" for p in points),
"fill": "none", "stroke": "#38bdf8", "stroke-width": "2"})
if hit is not None:
hx = x0 + (hit - xlo) * (W - 110) // (xhi - xlo or 1)
ET.SubElement(root, "circle", {"cx": str(hx), "cy": str(ty), "r": "5", "fill": "#f59e0b"})
ashlib.svg_text(root, hx, ty + 20, f"enemyHp={hit}", 11, "#f59e0b", "middle")
return ashlib.svg_save(root, path)
def main() -> int:
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds = cfg["seedPool"]
param, (lo, hi), target = cfg["sweptParam"], (cfg["range"]["from"], cfg["range"]["to"]), cfg["targetPermille"]
t1 = time.perf_counter_ns()
cache = {}
hit, bisect_calls = invert(spec, cfg, param, lo, hi, target, seeds, cache)
t_bisect = (time.perf_counter_ns() - t1) // 1000000
bisect_runs = len(cache) * len(seeds) * len(cfg["strategies"])
t2 = time.perf_counter_ns()
full = [{"value": v, "permille": winrate(spec, cfg, {param: v}, seeds)} for v in range(lo, hi + 1)]
t_full = (time.perf_counter_ns() - t2) // 1000000
full_hit = next((p["value"] for p in full if p["permille"] <= target), None)
full_runs = len(full) * len(seeds) * len(cfg["strategies"])
violations = sum(1 for i in range(len(full) - 1) if full[i]["permille"] < full[i + 1]["permille"])
distinct = len({p["permille"] for p in full})
agree = hit == full_hit
elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
n = ashlib.write_csv(os.path.join(OUT, "play15_invert.csv"), [param, "winratePermille"],
[[p["value"], p["permille"]] for p in full])
size = draw(full, target, hit, os.path.join(OUT, "play15_invert.svg"))
report = {"id": "gd_play_15_1_invert", "param": param, "target": target, "seeds": len(seeds),
"strategies": len(cfg["strategies"]), "gamesPerEval": len(seeds) * len(cfg["strategies"]),
"inverted": hit, "fullScanInverted": full_hit, "agree": agree,
"bisectCalls": bisect_calls, "bisectRuns": bisect_runs, "bisectMs": t_bisect,
"fullCells": len(full), "fullRuns": full_runs, "fullMs": t_full,
"runRatioPermille": 1000 * bisect_runs // full_runs,
"monotonicViolations": violations, "distinctValues": distinct, "curve": full,
"elapsedMs": elapsed_ms}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play15_invert.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"目标集成胜率 {target}‰ param={param} 区间[{lo},{hi}] 每格 {report['gamesPerEval']} 局")
print(f"二分反推:{param}={hit} 二分正推={bisect_calls} 次 总正推格={len(cache)} runs={bisect_runs} {t_bisect} ms")
print(f"全网格 :{param}={full_hit} 格数={len(full)} runs={full_runs} {t_full} ms")
print(f"agree={agree} runRatio={1000 * bisect_runs // full_runs}‰ 单调性违反={violations} 不同取值={distinct}")
print(f"csv={n} 行 svg={size} bytes elapsed={elapsed_ms} ms")
print("wrote out/play15_invert.json out/play15_invert.csv out/play15_invert.svg")
ET.parse(os.path.join(OUT, "play15_invert.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_15_1_invert.py:
目标集成胜率 500‰ param=enemyHp 区间[40,100] 每格 120 局
二分反推:enemyHp=65 二分正推=6 次 总正推格=8 runs=960 20 ms
全网格 :enemyHp=65 格数=61 runs=7320 157 ms
agree=True runRatio=131‰ 单调性违反=0 不同取值=17
csv=61 行 svg=1325 bytes elapsed=183 ms
wrote out/play15_invert.json out/play15_invert.csv out/play15_invert.svg
读数三行。agree=True:二分反推的 65 与全网格的 65 相同——跳过格子没有漏掉交点。runRatio=131‰:二分只花全网格 131‰ 的算力(960 对 7320 runs),省下的 869‰ 就是二分的收益。单调性违反=0:曲线在 61 个格子上无一处回升,二分的前提成立;若某处回升,脚本会打印出来,提醒这时的二分结果不可信。反推值、格数、runs、比值这些由机制决定,每次跑完全一致;末尾的 ms 是墙钟耗时,逐次跑会有几毫秒抖动,只作量级参考。
不同取值=17 说明集成胜率不是阶跃而是有 17 个不同取值的阶梯——这正是反推有解的原因。这个 65 是代理指标口径下的 65:它只说「在这套指标、这套种子池下,胜率约 500‰ 对应的 enemyHp 是 65」,不能说成「最优 enemyHp」——换指标、换种子池,数字会动。
B 层:反推值有多稳
反推值依赖用哪些种子与用多少种子。B 层脚本 sim/gd_play_15_1_invert_warn.py 把种子池整体偏移 +0/+100/+200/+400 各反推一次,再把种子数从 24 缩到 16、8 各反推一次,报出反推值的漂移。漂移超阈值只打印软告警,进程始终 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_15_1_invert_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_15_1_invert_warn.py
"""卷二 15.1 从指标反推参数(B 层):反推值的稳健性——软告警,不硬失败。
反推值 `enemyHp*` 依赖两件事:**用哪些种子**(环境)与**用多少种子**(分辨率)。本脚本对
4 个种子池偏移(种子 +0/+100/+200/+400)各反推一次,报出反推值漂移;再对 3 个种子数
(8/16/24)各反推一次,报出分辨率对反推值的影响。漂移超过阈值只**打印软告警**,进程始终
exit 0。产出 out/play15_invert_warn.json。全整数、无浮点、无 random。
"""
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_15_1_invert.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
from gd_play_15_1_invert import invert # noqa: E402
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
param, (lo, hi), target = cfg["sweptParam"], (cfg["range"]["from"], cfg["range"]["to"]), cfg["targetPermille"]
base_pool, rb = cfg["seedPool"], cfg["robustness"]
base, _ = invert(spec, cfg, param, lo, hi, target, base_pool, {})
rows, warnings = [], []
for off in rb["poolOffsets"]:
pool = [sd + off for sd in base_pool]
val, _ = invert(spec, cfg, param, lo, hi, target, pool, {})
drift = None if val is None else val - base
rows.append({"poolOffset": off, "inverted": val, "drift": drift})
if drift is None or abs(drift) > rb["driftWarn"]:
warnings.append(f"种子池偏移 +{off}:反推值 {val}(漂移 {drift})超阈值 {rb['driftWarn']}")
res_rows = []
for cnt in rb["seedCounts"]:
val, _ = invert(spec, cfg, param, lo, hi, target, base_pool[:cnt], {})
res_rows.append({"seedCount": cnt, "inverted": val,
"drift": None if val is None else val - base})
drifts = [r["drift"] for r in res_rows if r["drift"] is not None]
res_spread = (max(drifts) - min(drifts)) if drifts else 0
if res_spread > rb["driftWarn"]:
warnings.append(f"种子数 8→24:反推值极差 {res_spread} 超阈值 {rb['driftWarn']}——分辨率不足")
report = {"id": "gd_play_15_1_invert_warn", "param": param, "target": target,
"baseline": base, "poolRows": rows, "resolutionRows": res_rows,
"resolutionSpread": res_spread, "driftWarn": rb["driftWarn"],
"warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play15_invert_warn.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"基线(偏移 +0,24 种子):{param}*={base}")
print(f"{'种子池偏移':>10}{'反推值':>8}{'漂移':>6}")
for r in rows:
print(f"{r['poolOffset']:>10}{str(r['inverted']):>8}{str(r['drift']):>6}")
print(f"{'种子数':>8}{'反推值':>8}{'漂移':>6}")
for r in res_rows:
print(f"{r['seedCount']:>8}{str(r['inverted']):>8}{str(r['drift']):>6}")
print(f"种子数极差={res_spread} 阈值={rb['driftWarn']} warnings={len(warnings)}")
for wmsg in warnings:
print(f" [WARN] {wmsg}")
print(f"elapsed={report['elapsedMs']} ms exit=0(软告警不硬失败)")
print("wrote out/play15_invert_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
基线(偏移 +0,24 种子):enemyHp*=65
种子池偏移 反推值 漂移
0 65 0
100 67 2
200 67 2
400 67 2
种子数 反推值 漂移
8 65 0
16 65 0
24 65 0
种子数极差=0 阈值=1 warnings=3
[WARN] 种子池偏移 +100:反推值 67(漂移 2)超阈值 1
[WARN] 种子池偏移 +200:反推值 67(漂移 2)超阈值 1
[WARN] 种子池偏移 +400:反推值 67(漂移 2)超阈值 1
elapsed=149 ms exit=0(软告警不硬失败)
wrote out/play15_invert_warn.json
读数:换一套种子,反推值从 65 漂到 67——±2 的不确定度。所以 15.1 的反推值必须写成「65 附近」,而不是「65」。种子数从 8 到 24 反推值不变(极差 0),说明 24 个种子已足够稳,缩到 8 也不崩——这条曲线对分辨率不敏感。
自检表
| 检查项 | 合格标准 |
|---|---|
| 单调前提 | 打印单调性违反对数,非 0 时二分结果不可信 |
| 有基准 | 与全网格逐格比对反推值,agree=True 才算可信 |
| 代价成文 | 报出二分与全网格各跑多少 runs、耗时、比值 |
| 稳健性 | 种子池偏移与种子数各扫一遍,漂移超阈值软告警 |
| 无浮点 | 胜率是千分比整数,比值是千分比整数 |
| 代理指标声明 | 集成胜率是 proxy,只做相对比较 |
反推给了一个点。但只要把「等于 T」放宽成「落在 [T−Δ, T+Δ]」,或同时反推两个参数,得到的就不再是点,而是一片可行域——下一节把它画出来。
阅读导航:上一节:14.3 相变图的读法 · 下一节:15.2 反推的可行域 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。