下载 App
10/213 浏览案例分享
【案例分享】无头跑批做回归与数值验证:从「跑一次」到「每次都跑」
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:《别停下》无头模拟的实测记录;数字全部来自项目里已有的实测与质量审计输出
一句话结论
单次跑出来的数字不是判据,是轶事。
把无头模拟固定成「一张种子表、两臂各跑一批、一局一行 CSV、看分布不看单值」,改动的效果才有读数。
「6 分钟只杀 3 只怪」能从异常变成结论,靠的不是手感,是同一套读数在改前、改后各有一份。
一次跑和每次都跑,差在哪
手玩一局看不出三件事:这一局的运气、改动的真实效果、有没有把别处改坏。
02 篇里那些数字之所以站得住,是因为它们差了一个数量级:
击杀 3 → 约 19000,经验球堆积峰值 733 → 41,40 秒等级 13 → 20,动量峰值 2.4 → 9.8 倍。
这种粗差,跑一遍就能看见。
回归要判的却是细差:调了一个小参数之后,是变好了,还是撞上了噪声?
单次结果回答不了 —— 换一局,读数就换个样。
所以「跑一次」到「每次都跑」不是勤快程度的问题,是判据能不能成立的问题:
先有噪声基准,才有资格谈「没退步」。
跑批骨架:种子表、一局一行
-- 离线跑批:同一张种子表,两臂各跑一遍,一局一行 CSV
local SEEDS = { 101, 202, 303, 404, 505 } -- 常量表,跟着代码进版本库
local ARMS = { "before", "after" } -- 只有这一处不同
local function runOne(seed, arm)
math.randomseed(seed) -- ★ 每局开头重设,不是批次开头设一次
local world = NewWorld(arm) -- 臂在这里分叉,其余代码完全同一份
local s = { t = 0, steps = 0, kills = 0, lv = 1, dmg = 0, peak = 1.0, orbs = 0 }
while s.t < 360 and not world.over and s.steps < 360 * 60 do
world:step(1 / 60) -- 完全相同的逻辑步进,没有渲染
s.steps = s.steps + 1
s.t = s.steps / 60
s.lv = world.level
s.kills = world.kills
s.dmg = s.dmg + world.dmgThisFrame
s.peak = math.max(s.peak, world.momentum)
s.orbs = math.max(s.orbs, world.orbCount)
end
s.capped = (s.steps >= 360 * 60) -- 撞到上限 = 卡住,按异常局处理
return s
end
function Start()
local ok, err = pcall(function()
local f = File(GetContext(), "/workspace/.tmp/batch.csv", FILE_WRITE)
f:WriteString("seed,arm,t_end,kills,damage,level,peak_momentum,max_orbs,error\n")
for _, arm in ipairs(ARMS) do
for _, seed in ipairs(SEEDS) do
local rok, s = pcall(runOne, seed, arm)
if rok and not s.capped then
f:WriteString(string.format("%d,%s,%.2f,%d,%d,%d,%.2f,%d,\n",
seed, arm, s.t, s.kills, s.dmg, s.lv, s.peak, s.orbs))
else
local why = rok and "capped" or tostring(s)
why = why:gsub("[,\r\n]", " ") -- 错误串里的逗号会撑破 CSV 列
f:WriteString(string.format("%d,%s,0,0,0,0,0,0,%s\n", seed, arm, why))
end
end
end
f:Close()
end)
if not ok then log:Write(LOG_ERROR, "[batch] " .. tostring(err)) end
engine:Exit() -- 不调就卡主循环,表现为超时
end
三个固定项:
1. SEEDS 是常量表,跟着代码进版本库 —— 两年后还能拿同一批种子对比。
2. 臂只在 NewWorld(arm) 一处分叉,其余代码完全是同一份。
3. 每局一行、跑完立即落盘 —— 中途崩了,前面几十行也还能用。
固定种子:把差异压到只剩一个来源
同一种子下,两臂只差那处改动,其余全等,这才叫配对比较。
种子不固定,差值 = 改动 + 运气,读数就没意义。
两个容易漏的点:
- 按局重设,不是按进程设一次。 math.randomseed(seed) 写在每局开头。
在批次开头设一次的话,先跑的局把随机数序列消耗掉了,后跑的局起点已经不同,「同种子」名存实亡。
- 种子、臂、配置版本都要进 CSV。 事后拿到一个离谱数字,缺这三样,就只剩「我记得好像…」。
两臂对照:只让一个变量动
同一张种子表跑两遍,只有臂不同,然后按 seed 配对:
import csv
from collections import defaultdict
from statistics import median
def load(path):
rows = defaultdict(dict)
with open(path, encoding="utf-8") as f:
for r in csv.DictReader(f):
rows[(r["seed"], r["arm"])] = r
return rows
def q(xs, p):
xs = sorted(xs)
return xs[round(p * (len(xs) - 1))]
rows = load("/workspace/.tmp/batch.csv")
seeds = sorted({k[0] for k in rows} - {""})
pairs = []
for s in seeds:
if all((s, a) in rows for a in ("before", "after")):
before, after = rows[(s, "before")], rows[(s, "after")]
if before["error"] == "" and after["error"] == "":
pairs.append((float(after["kills"]), float(before["kills"])))
delta = [a - b for a, b in pairs]
print("配对局数 %d / 种子 %d" % (len(pairs), len(seeds)))
print("Δ 中位数 %.0f" % median(delta))
print("Δ P10 / P90 %.0f / %.0f" % (q(delta, .10), q(delta, .90)))
print("改后更好 %d / %d" % (sum(1 for d in delta if d > 0), len(delta)))
配对之后看三个读数:
读数:回答什么
配对差的中位数:这次改动整体往哪走
配对差的 P10 / P90:有没有一批局被改坏 —— 尾部变差比中位数下降更该查
改后更好的局数 / 总配对:效果是普遍的,还是被几局拉起来的
N 取多少:先用少量种子摸方向,方向不清再加。
N 不等于覆盖:02 里的缺陷 11 只在长局出现 —— 升级池总容量 79 次,经验曲线却允许升到 85 级。
局数再多,局内时长不够也撞不上。
判据用分布,不用单次
三类判据,优先级从高到低:
1. 硬不变量:固定 bot 集合下完赛率必须 100%(站桩这种故意送死的打法不进回归集,
它自己的判据是「7.6 秒必死」),CSV 里不允许出现 ERROR 行,击杀数只能增不能减。一条不成立就停,不看统计。
2. 噪声带:同一份代码跑两臂(A/A 对照),量出「什么都不改」时配对差能抖多大。
之后的 Δ 超出这条带才算变化 —— 不先量噪声就定阈值,等于自己骗自己。
3. 分位数回归:中位数持平而 P10 掉了,说明有一部分种子走进了坏分支。
02 里 733 是峰值、410 是均值:只报均值,「偶尔爆一次」的问题整片消失。
击杀、伤害、经验球都是长尾量,一局跑满就能把均值拉走。
报 P50 + P10 + P90 三个数,比报一个平均值有用。
CSV 字段设计
一行一局,字段固定。改名等于历史数据作废,所以一次想清楚:
字段:为什么必须有
seed / arm:配对与复现的唯一钥匙
build:臂的身份(提交号或配置哈希),否则「改前」指哪份代码说不清
t_end / end_state:是否跑满 360 秒;卡死和完赛必须能区分
kills / damage / level:主读数
peak_momentum / max_orbs:02 里抓到平衡点错误与经验球堆积,靠的就是这两个读数
playstyle:打法分支(02 跑了六种打法,除站桩外全部 100% 完赛)
error:空 = 正常;有值 = 这一局异常,但整批不中断
两条纪律:缺失写空,不要写 0 —— 0 会被后续脚本当成真实读数;
汇总不进 CSV —— 分布必须从原始行算,写死一个均值等于丢掉证据。
异常局:留住那一局
跑完发现异常,不是「重跑一遍看看」,得把它钉住。
异常由分布定义:跌出 P10、超过 P90、end_state 不是完赛。
留住三样:种子、臂、每帧输入(或确定性 bot 的策略版本)。有这三样才能原帧回放:
-- 好局只留汇总行;异常局把「种子 + 每帧输入」写盘,这是唯一能回放的东西
local trace = {}
local function record(t, input)
trace[#trace + 1] = string.format("%.3f,%.3f,%.3f,%d",
t, input.moveX, input.moveY, input.skill)
end
local function saveTrace(seed, arm)
local path = string.format("/workspace/.tmp/trace_%s_%d.txt", arm, seed)
local f = File(GetContext(), path, FILE_WRITE)
f:WriteString(table.concat(trace, "\n"))
f:Close()
end
-- 回放:同种子、同臂、同输入,读到与记录一致的读数才说明定位到了
local function replay(seed, arm, lines)
math.randomseed(seed)
local world = NewWorld(arm)
for i = 1, #lines do
local x, y, k = lines[i]:match("^[^,]+,([^,]+),([^,]+),([^,]+)$")
world:step(1 / 60, { moveX = tonumber(x), moveY = tonumber(y), skill = tonumber(k) })
if i % 60 == 0 then
print(string.format("t=%.0f 击杀=%d 动量=%.2f 球=%d",
i / 60, world.kills, world.momentum, world.orbCount))
end
end
end
每局还要加迭代上限。02 里缺陷 11 是死循环:升级池空了、pendingLevels 永不减少,
状态机永久停在 levelup —— 一局跑不完,整批挂在那里。
没有上限时,你看到的是「跑批超时」,真正的错因被埋掉(08 篇同一个坑:
engine:Exit() 没被执行,报错表现也是超时)。
踩过的坑
- 种子按批次设一次。 后面每局的随机起点都不同,配对直接废掉。
- 改了随机调用的次数或顺序,同种子也不再同轨迹。 这类改动别再指望逐局对齐,改成两臂各自多种子取分布。
- 只看均值。 733 是峰值、410 是均值;均值把「偶尔爆一次」抹平了。
- 只看峰值。 追着一个种子调参,下一局又正常了 —— 单点极值不是证据。
- 一局报错带走整批。 每局 pcall,错误写进 error 列,批次继续。
- 异常局没留种子。 跑完只剩下「好像有一局不太对」,复现不了,等于没发现。
- 字段和口径中途漂移。 同一列今天算击杀数、明天算击杀加助攻,前后两批 CSV 就没法比了。
适用边界
该跑批:不该跑批
数值改动(伤害、掉率、经验曲线):表现类改动(叠字、配色、动画生硬)—— 模拟器不产像素
机制改动(判定方式、状态机分支):手感问题(跟手、打击感)—— 判据是人的反应时间
长局才触发的边界(升级池、容量上限):新手引导体验 —— bot 不会卡在引导里
修完硬缺陷后的回归:纯视觉特效的帧率 —— 模拟器没有渲染
需要对外的可复现证据:一局定生死的小改动 —— 跑批成本高过改动本身
边界不是「能跑就算」。bot 是零失误操作:02 里六种打法除站桩外全部 100% 完赛,
这只说明机制上允许,不说明难度合适。跑批验的是机制对不对,不是玩起来爽不爽。
这套写法也不挑对象。本系列的发布质检是同一套:
28 篇稿子过一遍审计脚本,得到汉字最小 / 中位 / 最大 940 / 2,011 / 2,852,
约 330 个代码块,禁词 0 命中,四个结构项的齐全度是 28/28、20/28、14/28、12/28。
它输出的不是一句「通过」,是一张「汉字 < 1500」的风险清单。
可迁移的经验
1. 判据要能重跑。 不能重跑的读数只是故事;种子、臂、字段是让读数变成证据的三件套。
2. 先量噪声,再定阈值。 A/A 对照跑一遍,比拍一个「看着差不多」靠谱。
3. 分布优先于均值。 长尾量报 P50 / P10 / P90;异常局从尾部挑,不从印象里挑。
4. 一局失败不该带走整批。 每局 pcall,迭代上限,错误进 CSV。
5. 异常要留可复现的最小集。 种子加输入轨迹,比录屏有用。
6. 覆盖不是局数,是状态空间。 长局边界只能用长局去撞。
(完)




