12.1 体验指标的选取
《小游戏开发实战》卷一 12.2 可玩性指标测试 那篇做的是 S 层模拟采样加置信区间,指标偏占比类——胜率、完成率这种「多少比例」的量。本节做的是另一件事:先决定该看哪些量。前者回答「这个占比有多准」,后者回答「占比之外还有哪些量值得看、它们会不会互相重复」。指标池里既会有占比类(可解率、可用策略占比),也会有机制量(策略胜率熵、胜率带宽、支配缺失度),本节把它们放在同一张判据下筛。
指标不是越多越好
一个自然的冲动是「多量几个总没坏处」。恰恰相反:指标之间会同序——两个指标在每一对配置上都给出同样的排序,那它们其实是同一个指标的两种写法,多量一个只是多一行数字,不提供新信息。更糟的是,两个高相关的指标同时进入一份复合评分时,会暗中把权重翻倍:你以为给了「平衡」和「多样性」各一半权重,实际给了同一件事两倍权重。
所以选取要做两件事:淘汰分不开配置的(没有信息量),淘汰和别人几乎同序的(重复信息)。两件事都可以用整数判据说清楚,不必靠口味。
候选池:八个可算的量
候选全部从同一次仿真的策略 × 种子胜率矩阵里数出来(怎么构造矩阵见 2.2 检测算法与复杂度 )。八个量、三种口径:
| 指标 | 定义 | 口径 |
|---|---|---|
solvability 可解率 | 至少有一条策略赢下的种子占比(‰) | 占比 |
entropy 策略胜率熵 | 策略行均值归一化后的 Shannon 熵 ÷ log2(策略数)(‰) | 分布形状 |
band 胜率带宽 | 策略行均值的极差(‰) | 分布形状 |
domFree 支配缺失度 | 未被任何策略严格支配的策略占比(‰) | 占比 |
viable 可用策略占比 | 行均值 ≥ 最高 − 容差的策略占比(‰) | 占比 |
mutual 互有胜负对占比 | 互有胜负的策略对占比(‰) | 占比 |
turnMean 平均回合 | 全部(策略,种子)对的末回合均值 | 机制量 |
dealtFrac 输出完成度 | 平均已造成伤害 ÷ 敌人血量(‰) | 机制量 |
八个量全是代理指标(proxy):它们不等于「好玩」,只用于配置之间的相对比较。本节禁止任何绝对化说法——不说「熵高 200‰ 就更耐玩」,只说「配置 A 的熵高于配置 B」。
两条判据
区分度 discrimination(m) = max(m) − min(m):指标跨全部配置的极差(千分比)。一个在这张配置网格上纹丝不动的指标,无论多有道理都没用——它没有能力把任何两个配置分开。区分度低于阈值的候选直接淘汰。
冗余度 concordance(a, b):把全部配置两两配对,数出两指标给出同向排序的对数占比(千分比)。1000‰ 表示两指标在所有配置对上都同序,是同一个指标的两种写法。冗余度 ≥ 阈值即视为冗余,后来者淘汰。
筛选是贪心的:按区分度降序扫描候选,若它与每一个已选指标的冗余度都低于阈值就入选,否则淘汰。贪心不保证最优(这是集合覆盖问题),但它是可解释、可复现的:每一步为什么入选、为什么淘汰都写得出。
{ "seeds": [501, "…", 516], "cellsPerConfig": 16, "maxSteps": 40,
"select": { "redundancyThresholdPermille": 800, "minDiscriminationPermille": 20,
"viableTolPermille": 200 } }
S 层:把候选池跑一遍
脚本对 12 个配置各跑 6 条确定性策略 × 16 个种子,算出八个候选指标,再量区分度与冗余度、执行贪心。熵只在脚本内用 math.log2 求,落盘一律取整千分比;全程无浮点、无 random。
#!/usr/bin/env python3
# file: sim/gd_play_12_select.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_12_select.py
"""卷二 12.1 体验指标的选取(S 层):从候选池里选一组不冗余的代理指标。
候选指标池(全部千分比整数):solvability 可解率 · entropy 策略胜率熵 · band 胜率带宽 ·
domFree 支配缺失度 · viable 可用策略占比 · mutual 互有胜负对占比 · turnMean 平均回合 ·
dealtFrac 输出完成度。对 8 个配置各算一遍,得到「指标 × 配置」矩阵。
选取只看两条整数判据:
区分度 discrimination = 该指标跨配置的极差(千分比);
冗余度 concordance(a,b) = 两指标在全部配置对上给出同向排序的比例(千分比)。
贪心:按区分度降序扫描,若候选与已选每个指标的 concordance 都 < 阈值则入选。
所有指标都是**代理指标(proxy)**:只用于配置之间的相对比较,禁绝对化。
产出 out/play12_select.json + out/play12_select.csv + out/play12_select.svg。
全整数、无浮点、无 random;纯 xml.etree 出 SVG。
"""
import copy
import json
import math
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_12_select.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
KEYS = ("enemyHp", "turnLimit", "enemyAttack")
METRICS = ("solvability", "entropy", "band", "domFree", "viable", "mutual", "turnMean", "dealtFrac")
LABEL = {
"solvability": "可解率", "entropy": "策略胜率熵", "band": "胜率带宽",
"domFree": "支配缺失度", "viable": "可用策略占比", "mutual": "互有胜负对占比",
"turnMean": "平均回合", "dealtFrac": "输出完成度",
}
def choose(rule, s, cost, dmg, blk, ea):
"""确定性策略规则:读当前快照,返回下一步命令(pK 出第 K 张,e 结束回合)。"""
hand, en = s["hand"], s["energy"]
aff = [i for i, c in enumerate(hand) if cost[c] <= en]
if not aff:
return "e"
if rule == "max_damage":
i = max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))
elif rule == "block_first":
g = [i for i in aff if blk[hand[i]] > 0]
i = g[0] if g and s["block"] < ea else max(aff, key=lambda i: dmg[hand[i]])
elif rule == "min_cost":
i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
elif rule == "even_turn_big":
big = [i for i in aff if cost[hand[i]] >= 2]
if s["turn"] % 2 == 0:
return f"p{big[0]}" if big else "e"
i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
elif rule == "first_slot":
i = aff[0]
else:
i = aff[-1]
return f"p{i}"
def play(spec, pol, cfg, seeds, max_steps):
"""跑一条策略:返回每种子是否胜、末回合、已造成伤害(供多指标复用)。"""
cost = {c["id"]: c["cost"] for c in spec["cards"]}
dmg = {c["id"]: c["damage"] for c in spec["cards"]}
blk = {c["id"]: c["block"] for c in spec["cards"]}
res = []
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update({k: cfg[k] for k in KEYS})
seq = []
for _ in range(max_steps):
sn = ashlib.run_spec(s, seq)
if sn["over"]:
break
seq.append(choose(pol["rule"], sn, cost, dmg, blk, cfg["enemyAttack"]))
fin = ashlib.run_spec(s, seq)
res.append({"won": fin["won"], "turn": fin["turn"], "dealt": fin["dealt"]})
return res
def config_metrics(spec, cfg, policies, seeds, tol, max_steps):
"""一个配置下算全部候选指标(全整数千分比)。"""
runs = {p["id"]: play(spec, p, cfg, seeds, max_steps) for p in policies}
n, np_ = len(seeds), len(policies)
wins = {k: sum(1 for r in runs[k] if r["won"]) for k in runs}
means = {k: 1000 * wins[k] // n for k in runs}
# 可解率:至少有一条策略赢下的种子占比
solvable = sum(1 for i in range(n) if any(runs[k][i]["won"] for k in runs))
# 策略胜率熵:行均值归一化后的 Shannon 熵 ÷ log2(策略数) ×1000(脚本内用 math.log2,落盘取整)
tot = sum(means.values())
ent = 0
if tot > 0:
h = sum(-(v / tot) * math.log2(v / tot) for v in means.values() if v > 0)
ent = int(round(1000 * h / math.log2(np_)))
# 支配:策略 a 在每个种子上都 ≥ b,且至少一个种子严格 >
def dom(a, b):
return (all(runs[a][i]["won"] >= runs[b][i]["won"] for i in range(n))
and any(runs[a][i]["won"] > runs[b][i]["won"] for i in range(n)))
ids = list(runs)
dominant = {a for a in ids if any(dom(a, b) for b in ids if b != a)}
best = max(means.values())
viable = sum(1 for v in means.values() if v > 0 and v >= best - tol)
mutual = sum(1 for i in range(np_) for j in range(i + 1, np_)
if any(runs[ids[i]][k]["won"] > runs[ids[j]][k]["won"] for k in range(n))
and any(runs[ids[i]][k]["won"] < runs[ids[j]][k]["won"] for k in range(n)))
flat = [r for k in runs for r in runs[k]]
turn_mean = sum(r["turn"] for r in flat) // len(flat)
dealt_frac = 1000 * (sum(r["dealt"] for r in flat) // len(flat)) // cfg["enemyHp"]
return {
"solvability": 1000 * solvable // n,
"entropy": ent,
"band": max(means.values()) - min(means.values()),
"domFree": 1000 * (np_ - len(dominant)) // np_,
"viable": 1000 * viable // np_,
"mutual": 2000 * mutual // (np_ * (np_ - 1)),
"turnMean": turn_mean,
"dealtFrac": dealt_frac,
}
def concordance(a, b):
"""两指标在全部配置对上的同向排序比例(千分比):1000 = 完全同序。"""
m = len(a)
same = tot = 0
for i in range(m):
for j in range(i + 1, m):
da, db = a[i] - a[j], b[i] - b[j]
if da == 0 or db == 0:
continue
tot += 1
same += 1 if (da > 0) == (db > 0) else 0
return 1000 if tot == 0 else 1000 * same // tot
def draw(curves, ids, sel, disc, path):
W, H, x0, y0, y1 = 700, 340, 96, 44, 250
lo, hi = 0, len(ids) - 1
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 20, 26, "候选指标 × 8 个配置:入选者加粗", 15)
ashlib.svg_text(root, 20, 46, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
colors = {"selected": "#38bdf8", "rejected": "#475569"}
for name in METRICS:
vals = [curves[c][name] for c in ids]
mx = max(vals) or 1
pts = " ".join(f"{x0 + i * (W - 120) // hi},{y1 - v * (y1 - y0) // mx}" for i, v in enumerate(vals))
ET.SubElement(root, "polyline", {
"points": pts, "fill": "none",
"stroke": colors["selected" if name in sel else "rejected"],
"stroke-width": "3" if name in sel else "1",
})
ashlib.svg_text(root, 20, y0 + 18 * METRICS.index(name), f"{LABEL[name]} {disc[name]}", 11,
"#e2e8f0" if name in sel else "#64748b")
return ashlib.svg_save(root, path)
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["seeds"][: cfg["cellsPerConfig"]]
sel_cfg = cfg["select"]
tol = sel_cfg["viableTolPermille"]
ids = [g["id"] for g in cfg["grid"]]
curves = {g["id"]: config_metrics(spec, g, cfg["policies"], seeds, tol, cfg["maxSteps"])
for g in cfg["grid"]}
disc = {m: max(curves[c][m] for c in ids) - min(curves[c][m] for c in ids) for m in METRICS}
conc = {a: {b: concordance([curves[c][a] for c in ids], [curves[c][b] for c in ids])
for b in METRICS if b != a} for a in METRICS}
order = sorted(METRICS, key=lambda m: (-disc[m], m))
selected, rejected = [], []
for m in order:
if disc[m] < sel_cfg["minDiscriminationPermille"]:
rejected.append({"metric": m, "reason": "discrimination", "discrimination": disc[m]})
continue
red = {s: conc[m][s] for s in selected}
if red and max(red.values()) >= sel_cfg["redundancyThresholdPermille"]:
rejected.append({"metric": m, "reason": "redundant", "discrimination": disc[m],
"redundantWith": max(red, key=red.get), "concordance": max(red.values())})
else:
selected.append(m)
rows = [[g["id"], g["enemyHp"], g["turnLimit"], g["enemyAttack"]] + [curves[g["id"]][m] for m in METRICS]
for g in cfg["grid"]]
n = ashlib.write_csv(os.path.join(OUT, "play12_select.csv"),
["config", "enemyHp", "turnLimit", "enemyAttack", *METRICS], rows)
size = draw(curves, ids, selected, disc, os.path.join(OUT, "play12_select.svg"))
report = {
"id": "gd_play_12_select", "seedsPerConfig": len(seeds), "configs": ids,
"metrics": list(METRICS), "curves": curves, "discrimination": disc,
"concordance": conc, "selectionOrder": order, "selected": selected, "rejected": rejected,
"thresholds": sel_cfg, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play12_select.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'指标':<14}{'区分度':>7} " + " ".join(f"{c:>5}" for c in ids))
for m in METRICS:
print(f"{LABEL[m]:<14}{disc[m]:>7} " + " ".join(f"{curves[c][m]:>5}" for c in ids))
print(f"选取顺序(按区分度降序)={order}")
print(f"入选={selected}")
for r in rejected:
if r["reason"] == "redundant":
print(f" 剔除 {r['metric']}:与 {r['redundantWith']} 同序 {r['concordance']}‰"
f"(阈值 {sel_cfg['redundancyThresholdPermille']}‰)")
else:
print(f" 剔除 {r['metric']}:区分度 {r['discrimination']}‰ 低于阈值")
print(f"csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play12_select.csv out/play12_select.svg out/play12_select.json")
ET.parse(os.path.join(OUT, "play12_select.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_12_select.py(elapsed 是墙钟采样,每次运行都不同,下同):
指标 区分度 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11
可解率 1000 1000 1000 1000 1000 1000 1000 750 1000 1000 1000 0 0
策略胜率熵 1000 1000 1000 1000 1000 973 644 420 1000 973 644 0 0
胜率带宽 1000 0 0 0 0 563 1000 750 0 563 1000 0 0
支配缺失度 667 1000 1000 1000 1000 333 333 500 1000 333 333 1000 1000
可用策略占比 1000 1000 1000 1000 1000 666 333 166 1000 666 333 0 0
互有胜负对占比 133 0 0 0 0 133 66 0 0 133 66 0 0
平均回合 8 3 4 4 4 4 4 3 4 4 4 11 11
输出完成度 158 1000 1000 1000 1000 971 900 842 1000 971 900 975 866
选取顺序(按区分度降序)=['band', 'entropy', 'solvability', 'viable', 'domFree', 'dealtFrac', 'mutual', 'turnMean']
入选=['band', 'entropy', 'domFree']
剔除 solvability:与 entropy 同序 1000‰(阈值 800‰)
剔除 viable:与 entropy 同序 1000‰(阈值 800‰)
剔除 dealtFrac:与 entropy 同序 886‰(阈值 800‰)
剔除 mutual:与 band 同序 833‰(阈值 800‰)
剔除 turnMean:区分度 8‰ 低于阈值
csv=12 rows svg=2885 bytes elapsed=171 ms
wrote out/play12_select.csv out/play12_select.svg out/play12_select.json
读表:八进三
入选三条:band(胜率带宽)、entropy(策略胜率熵)、domFree(支配缺失度)。三者的跨配置曲线形状互不相同——band 在 c4(enemyAttack=14)就开始抬、到 c5 封顶;entropy 是从 c4 起持续下滑的单调曲线;domFree 则在两端配置取满、中间配置掉到 333‰。三条各说一件事:分布有多宽、分布有多匀、有没有谁被压死。
淘汰五条,成因分三类,必须分开读:
| 类别 | 条目 | 成因 |
|---|---|---|
| 与入选者同序 | solvability(1000‰)、viable(1000‰) | 在这张网格上,可解率与可用策略占比的排序和 entropy 完全一致——它们量的是同一件事 |
| 近似同序 | dealtFrac(886‰)、mutual(833‰) | 高于 800‰ 阈值但未到 1000‰,属于「几乎重复、偶尔分岔」 |
| 分不开配置 | turnMean(区分度 8‰) | 平均回合在 12 个配置里几乎不变——它在这个机制、这张网格上没有分辨力 |
turnMean 这一类最容易被忽略:它是机制量、可算、有物理意义,但 12 个配置的读数只在 3 到 4 之间晃(唯一例外是 c10/c11 的 11)。这不是说「平均回合」是坏指标,而是说它的分辨力出现在别的参数方向上——本节的网格把难度加在敌人血量和攻击上,没有加在回合预算上,于是它对回合数不敏感。指标的可用性依赖网格,这一点和 12.3 指标的置信区间
里「区间依赖样本」是同一类限制。
判据与阈值都要入档
| 检查项 | 合格标准 |
|---|---|
| 候选可算 | 每个候选都能从胜率矩阵直接数出来,不引入新数据源 |
| 判据全整数 | 区分度与冗余度都是千分比整数,无浮点;阈值写进 config,改口径只改配置 |
| 代理指标声明 | 每个指标声明为 proxy,只做相对比较、禁绝对化 |
| 网格入档 | 配置网格写进 config;「分不开」是相对这张网格说的,不是绝对结论 |
筛选做完,手里有一组不冗余的指标——但它们还只是原始读数。从仿真快照的一个字段到一条能进决策的指标,中间还有一段管线要走:聚合、归一化、减基线。下一节把这段管线拆开。
阅读导航:上一节:11.3 天花板与瓶颈 · 下一节:12.2 从机制仿真到体验指标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。