15.3 反推的代价与取舍
《小游戏开发实战》卷一 12.3 原型迭代决策
里,迭代决策的最后一步是「值不值得再改一轮」——那是一个产品取舍。本节把同一句取舍翻译成可算的代价:反推一次要跑多少次 run_spec、花多少毫秒、在给定预算内能钉到多细。反推的代价 = 正推次数 × 每次正推的成本;两个因子都能精确算出来,于是「值不值得」就不再是拍脑袋。
代价的三个旋钮
| 旋钮 | 符号 | 影响 | 代价怎么涨 |
|---|---|---|---|
| 精度 | ε | 参数钉到多细 | 一维二分 O(log(R/ε));二维全网格 O((R/ε)²) |
| 种子数 | S | 指标分辨率(台阶 = 1000/S ‰) | 每次正推 = 5S 局,成本与 S 成正比 |
| 维数 | d | 反推几个参数 | 每加一维,同样的 ε 多乘一个 R/ε |
一维二分的代价是对数的,二维全网格是平方的——维数一上去,精度要求就从对数变成平方,这是反推最大的代价来源。种子数是线性取舍:S 翻倍,成本翻倍,指标分辨率也只翻倍(台阶从 1000/S 变成 1000/2S)。
用实测标定代价模型
代价模型与预算闸门写在配置 config/gd_play_15_3_cost.json(precisions 是待评估的精度档,budget.maxRuns / maxMs 是预算闸门)。脚本 sim/gd_play_15_3_cost.py 先实测一维二分在 5 个精度上的真实正推次数与耗时,用它标定「每次正推多少微秒」,再据此把二维全网格的代价算出来,最后给预算判据。
#!/usr/bin/env python3
# file: sim/gd_play_15_3_cost.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_15_3_cost.py
"""卷二 15.3 反推的代价与取舍(S 层):代价 = 正推次数 × 每次正推的成本。
三个旋钮:① 精度 ε——一维二分 O(log(R/ε)),二维全网格 O((R/ε)²),维数一上去就从对数变平方;
② 种子数 S——每次正推 5S 局,成本与 S 成正比、分辨率台阶 1000/S ‰ 只与 S 成正比(线性取舍);
③ 维数——15.1 一维与 15.2 二维,同精度差一个 R/ε 因子。脚本实测一维二分在 5 个精度上的真实
正推次数与耗时标定成本,再算二维全网格代价,最后给预算判据(maxRuns/maxMs → 最细 ε)。
指标是**代理指标**,只用于相对比较。产物 out/play15_cost.json + .csv + .svg。全整数、无浮点。
"""
from __future__ import annotations
import copy, json, os, sys, 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_3_cost.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def strategy_seq(k, turns, slots):
return (["p" + str(k)] * slots + ["e"]) * turns
def winrate(spec, cfg, over, seeds):
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 bisect_to(spec, cfg, param, lo, hi, target, eps, seeds, cache):
"""整数二分到精度 eps(收敛到区间宽度 <= eps)。返回 (值, 正推次数)。"""
def w(v):
return cache.setdefault(v, winrate(spec, cfg, {param: v}, seeds))
if w(lo) <= target or w(hi) > target:
return None, 0
a, b, calls = lo, hi, 0
while b - a > eps:
mid = (a + b) // 2
calls += 1
if w(mid) <= target:
b = mid
else:
a = mid
return b, calls
def ceil_log2(x):
k, p = 0, 1
while p < x:
p *= 2
k += 1
return k
def draw(rows, path):
W, H, x0, y0 = 660, 320, 90, 50
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 20, 24, "反推代价:一维二分(对数)vs 二维全网格(平方)", 15)
ashlib.svg_text(root, 20, 44, "纵轴 = 相对正推次数(对数刻度,以二维 ε=16 为基准 1000)", 11, "#94a3b8")
top = max(max(r["gridRuns"], r["bisectRuns"]) for r in rows) or 1
bw = (W - 140) // (2 * len(rows))
for i, r in enumerate(rows):
x1 = x0 + i * 2 * bw
x2 = x1 + bw
ashlib.svg_bar(root, x1, y0, bw - 6, 220, r["bisectRuns"], top, "#38bdf8")
ashlib.svg_bar(root, x2, y0, bw - 6, 220, r["gridRuns"], top, "#f59e0b")
ashlib.svg_text(root, x1 + bw - 3, H - 40, f"ε={r['eps']}", 11, "#e2e8f0", "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["param"], (cfg["range"]["from"], cfg["range"]["to"]), cfg["targetPermille"]
span, G = hi - lo, len(cfg["strategies"]) * len(seeds)
budget = cfg["budget"]
rows, csv = [], []
winrate(spec, cfg, {param: 55}, seeds) # 预热:排除首次调用的固定开销
t_cal = time.perf_counter_ns()
for v in range(56, 61):
winrate(spec, cfg, {param: v}, seeds)
us_per_eval = (time.perf_counter_ns() - t_cal) // 5000 # 5 次正推的整数微秒/次
for eps in cfg["precisions"]:
t1 = time.perf_counter_ns()
val, calls = bisect_to(spec, cfg, param, lo, hi, target, eps, seeds, {})
bisect_ms = (time.perf_counter_ns() - t1) // 1000000
bisect_evals = calls + 2
bisect_runs = bisect_evals * G
side = span // eps
grid_cells = side * side
grid_runs = grid_cells * G
grid_ms = grid_cells * us_per_eval // 1000
rows.append({"eps": eps, "bisectValue": val, "bisectEvals": bisect_evals, "bisectRuns": bisect_runs,
"bisectMs": bisect_ms, "usPerEval": us_per_eval, "gridSide": side,
"gridCells": grid_cells, "gridRuns": grid_runs, "gridMsEst": grid_ms,
"log2Bound": ceil_log2(span // eps)})
csv.append([eps, bisect_evals, bisect_runs, bisect_ms, grid_cells, grid_runs, grid_ms])
# 种子数取舍:分辨率台阶 vs 成本倍数
seed_rows = [{"seedCount": s, "resolutionPermille": 1000 // s, "gamesPerEval": len(cfg["strategies"]) * s,
"costMultiplier": s} for s in cfg["seedCounts"]]
# 预算判据:在 maxRuns 内,二维全网格能支撑的最细 eps(eps 越大越省)
fitting = [r for r in rows if r["gridRuns"] <= budget["maxRuns"]]
grid_fit_eps = min(r["eps"] for r in fitting) if fitting else None
fit1d = [r for r in rows if r["bisectRuns"] <= budget["maxRuns"]]
bisect_fit_eps = min(r["eps"] for r in fit1d) if fit1d else None
elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
n = ashlib.write_csv(os.path.join(OUT, "play15_cost.csv"),
["eps", "bisectEvals", "bisectRuns", "bisectMs", "gridCells", "gridRuns", "gridMsEst"], csv)
svg = draw(rows, os.path.join(OUT, "play15_cost.svg"))
report = {"id": "gd_play_15_3_cost", "param": param, "target": target, "seeds": len(seeds),
"gamesPerEval": G, "span": span, "budget": budget, "rows": rows, "seedRows": seed_rows,
"gridFitEps": grid_fit_eps, "bisectFitEps": bisect_fit_eps, "elapsedMs": elapsed_ms}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play15_cost.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"每次正推 = {len(cfg['strategies'])} 策略 × {len(seeds)} 种子 = {G} 局 区间跨度 R={span}")
print(f"{'ε':>4}{'二分值':>8}{'二分次':>8}{'二分runs':>10}{'二分ms':>8}{'网格cells':>10}{'网格runs':>10}{'网格ms估':>10}")
for r in rows:
print(f"{r['eps']:>4}{str(r['bisectValue']):>8}{r['bisectEvals']:>8}{r['bisectRuns']:>10}"
f"{r['bisectMs']:>8}{r['gridCells']:>10}{r['gridRuns']:>10}{r['gridMsEst']:>10}")
print(f"预算 maxRuns={budget['maxRuns']} maxMs={budget['maxMs']}")
print(f"二维全网格可支撑最细 ε = {grid_fit_eps} 一维二分可支撑最细 ε = {bisect_fit_eps}")
print("种子数取舍:")
for r in seed_rows:
print(f" S={r['seedCount']:>2} 分辨率台阶={r['resolutionPermille']}‰ 每次正推={r['gamesPerEval']} 局")
print(f"csv={n} 行 svg={svg} bytes elapsed={elapsed_ms} ms")
print("wrote out/play15_cost.json out/play15_cost.csv out/play15_cost.svg")
ET.parse(os.path.join(OUT, "play15_cost.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_15_3_cost.py:
每次正推 = 5 策略 × 24 种子 = 120 局 区间跨度 R=60
ε 二分值 二分次 二分runs 二分ms 网格cells 网格runs 网格ms估
1 67 8 960 19 3600 432000 9291
2 68 7 840 17 900 108000 2322
4 70 6 720 16 225 27000 580
8 70 5 600 12 49 5880 126
16 70 4 480 10 9 1080 23
预算 maxRuns=200000 maxMs=30000
二维全网格可支撑最细 ε = 2 一维二分可支撑最细 ε = 1
种子数取舍:
S= 8 分辨率台阶=125‰ 每次正推=40 局
S=16 分辨率台阶=62‰ 每次正推=80 局
S=24 分辨率台阶=41‰ 每次正推=120 局
S=48 分辨率台阶=20‰ 每次正推=240 局
csv=5 行 svg=2661 bytes elapsed=218 ms
wrote out/play15_cost.json out/play15_cost.csv out/play15_cost.svg
读数:ε=1 时一维二分只要 960 runs(8 次正推),二维全网格要 432000 runs(3600 格)——同样的精度,二维比一维贵 450 倍。这正是 R/ε 因子(R=60、ε=1)在起作用。预算判据:maxRuns=200000 下,一维二分能钉到 ε=1,二维全网格只能到 ε=2(108000 runs)——想要细,就得放弃维数。(表中「网格ms估」由标定出的微秒/次外推,逐次跑会有几毫秒抖动;「二分ms」是实测墙钟,同样有抖动。)
还有一处必须诚实:ε 从 1 放到 4,二分值从 67 变到 70——精度越粗,反推越偏。这不是 bug,是二分提前停止的必然结果:ε=4 时只要区间宽度 ≤4 就收工,返回值可以落在真值 4 以内。种子数取舍同理:想要分辨率台阶从 41‰ 降到 20‰,就得把 S 从 24 翻到 48,每次正推从 120 局涨到 240 局——分辨率是花钱买的。
B 层:预算给多少能反推到多细
B 层脚本 sim/gd_play_15_3_cost_warn.py 对 3 个预算档各问一次「能支撑的最细 ε 是多少」,并报出一维与二维的差距。当预算不足以让二维全网格达到 ε=1、或连最粗的一维反推都跑不起时,只打印软告警,进程始终 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_15_3_cost_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_15_3_cost_warn.py
"""卷二 15.3 反推的代价与取舍(B 层):预算变化下「能反推到多细」——软告警,不硬失败。
代价模型:一维二分 runs(ε) = (⌈log2(R/ε)⌉+2)·G;二维全网格 runs(ε) = (R//ε)²·G,G=5S。
本脚本对 3 个预算档各问一次「能支撑的最细 ε 是多少」,并报出一维与二维的差距。当预算不足以
让二维全网格达到 ε=1、或连最粗的一维反推都跑不起时,只**打印软告警**,进程始终 exit 0。
产出 out/play15_cost_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_3_cost.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
from gd_play_15_3_cost import winrate, ceil_log2 # noqa: E402
def measure_us_per_eval(spec, cfg, seeds):
"""跑一小批正推(含预热),标定每次正推的整数微秒耗时。"""
winrate(spec, cfg, {"enemyHp": 55}, seeds) # 预热:排除首次调用的固定开销
n = 5
t = time.perf_counter_ns()
for v in range(56, 56 + n):
winrate(spec, cfg, {"enemyHp": v}, seeds)
return (time.perf_counter_ns() - t) // (n * 1000)
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds = cfg["seedPool"]
span, G = cfg["range"]["to"] - cfg["range"]["from"], len(cfg["strategies"]) * len(seeds)
us = measure_us_per_eval(spec, cfg, seeds)
rows, warnings = [], []
for bud in cfg["robustness"]["budgetCandidates"]:
fit = {}
for kind in ("bisect", "grid"):
best = None
for eps in cfg["robustness"]["precisionCandidates"]:
runs = ((ceil_log2(span // eps) + 2) * G if kind == "bisect"
else (span // eps) ** 2 * G)
if runs <= bud:
best = eps
break
fit[kind] = best
grid1_runs = span * span * G
rows.append({"budget": bud, "bisectFitEps": fit["bisect"], "gridFitEps": fit["grid"],
"gridEps1Runs": grid1_runs})
if fit["bisect"] is None:
warnings.append(f"预算 {bud}:连最粗的一维反推(ε=32)都跑不起——反推不可行")
if grid1_runs > bud:
warnings.append(f"预算 {bud}:二维全网格在预算内达不到 ε=1(需 {grid1_runs} runs)——退回一维+切片")
report = {"id": "gd_play_15_3_cost_warn", "span": span, "gamesPerEval": G, "usPerEval": us,
"rows": rows, "warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play15_cost_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"R={span} G={G} 局/次 二维 ε=1 需 {span * span * G} runs")
print(f"{'预算':>10}{'一维最细ε':>12}{'二维最细ε':>12}")
for r in rows:
print(f"{r['budget']:>10}{str(r['bisectFitEps']):>12}{str(r['gridFitEps']):>12}")
print(f"warnings={len(warnings)}")
for wmsg in warnings:
print(f" [WARN] {wmsg}")
print(f"elapsed={report['elapsedMs']} ms exit=0(软告警不硬失败)")
print("wrote out/play15_cost_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
R=60 G=120 局/次 二维 ε=1 需 432000 runs
预算 一维最细ε 二维最细ε
20000 1 8
100000 1 4
1000000 1 1
warnings=2
[WARN] 预算 20000:二维全网格在预算内达不到 ε=1(需 432000 runs)——退回一维+切片
[WARN] 预算 100000:二维全网格在预算内达不到 ε=1(需 432000 runs)——退回一维+切片
elapsed=17 ms exit=0(软告警不硬失败)
wrote out/play15_cost_warn.json
读数:三个预算档下,一维二分都能钉到 ε=1(它太便宜了);二维全网格却要等到预算 1000000 才够 ε=1。前两档触发「退回一维 + 切片」的告警——当二维反推超预算时,正确做法不是硬跑,而是先一维二分定位一条切片、再在切片上逐行细化,这就是 15.2 用的扫法。取舍的结论很清楚:反推的精度、维数、种子数三者抢同一份预算,任何一项加倍都要从另两项里扣。
自检表
| 检查项 | 合格标准 |
|---|---|
| 代价实测 | 一维二分的正推次数与耗时是实跑出来的,不是估的 |
| 模型成文 | 一维对数、二维平方、种子线性,三条都写清楚 |
| 预算判据 | 给 maxRuns / maxMs,输出能支撑的最细 ε |
| 精度诚实 | 报出粗精度下反推值的偏移,不假装 ε 无关 |
| 采样判据 | 超预算时给「退回一维 + 切片」的退路 |
| 代理指标声明 | 胜率是 proxy,只做相对比较 |
三节合起来是一条完整的反推链:15.1 把点反推出来、15.2 把域圈出来、15.3 把账算清楚。反推是「动态到设计」的回程——它把可算的指标翻译回可动的参数,让设计者知道往哪个方向、动多少、值不值。
阅读导航:上一节:15.2 反推的可行域 · 下一节:16.1 动态度量装配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。