15.3 反推的代价与取舍

反推不是免费的:代价 = 正推次数 × 每次正推的成本。本节实测一维二分在 5 个精度上的真实正推次数与耗时,据此标定代价模型,再算出二维全网格在同样精度下要贵一个 R/ε 的因子;最后给预算判据——同样 20 万 runs,一维能钉到 ε=1,二维只能到 ε=2。

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 动态度量装配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡