Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

提交前的测试

测试的必须是你将要提交的那两个 ONNX 和清单文件。先检查接口,再检查预算和时间,最后检查实际对局:计算图能运行、适配器能返回,都不能证明你的参赛模型给出的动作是有效的或有用的。

参考观测

审核用卡带的参考集探测每个参赛模型:由引擎生成的 207 个观测,覆盖每张基础地图的每个席位,取第 20、150 和 400 回合(或对局仍在进行的最后一个回合),各用三个种子。它们涵盖从 24 × 24 到 120 × 124 的地图、两到八个席位、从一只蚂蚁都不剩到二十九只蚂蚁的蚁群,以及编号最高到 7 的对手。每个赛季的地图都必须落在基础地图所张成的限制之内,所以在它们上面通过审核的模型,也能在赛季之后新增的地图上对局。审核只运行其中前六十四个,别的一概不管,它们没覆盖到的情况就无人检查。下面的 tinybrains 命令读取的是同一个文件,全部 207 个。

你自己的测试应当覆盖参考集没触及的部分:超过三十只蚂蚁的蚁群,以及你自己的参赛模型会走入的局面。参考集的对局是由一个简单的贪心行走脚本打的,所以其中没有任何局面是模型选出来的。hills 和 foes 中的归属是相对于你的,所以一场对局的两个席位看到的编码是一样的。

tinybrains CLI

tinybrains 是平台的命令行工具:它在你的机器上打对局,并且不需要服务器就能做出审核所做的那些测量,而且它链接的是节点所链接的那两个库,处理清单中适配器的 datalogic-rs 和运行计算图的 tract-onnx,所以它报告的数字就是平台将会报告的数字。

它是一个单独的二进制文件,发布版本覆盖 Apple 芯片上的 macOS 26 或更新版本,以及 arm64 和 x86-64 上的 Linux(glibc 2.35 或更新)和 Windows,不需要检出任何其他仓库。在 macOS 或 Linux 上:

brew tap tiny-brains/cli https://github.com/Tiny-Brains/cli
brew install tiny-brains/cli/tinybrains
git clone https://github.com/Tiny-Brains/ants-starter && cd ants-starter
tinybrains games

在 Windows 上,或者不用 Homebrew 的话,从发布版本下载你平台对应的压缩包(tinybrains-x86_64-pc-windows-msvc.zip、tinybrains-aarch64-unknown-linux-gnu.tar.gz 等),用发布版本的 SHA256SUMS 核对它,再把里面的二进制文件放到你的 PATH 上。有 Rust 工具链的话,cargo install --locked --git https://github.com/Tiny-Brains/cli 会从源码构建出同一个二进制文件。

每个游戏都有一个入门套件 <game>-starter,里面有一个训练好的参赛模型、train.py、两个对局文件和一个 games.toml。请在那里运行 CLI:它从注册表读取游戏,依次查找 $TINYBRAINS_REGISTRY,然后是运行目录下的 games.toml 和 tinybrains.toml,最后是其缓存中的 registry.toml。入门套件里的那份注册表用两个摘要(压缩包的和引擎的)固定了卡带的一个发布版本。第一个需要这个游戏的命令会把它下载一次到 CLI 的缓存中(macOS 上是 ~/Library/Caches/tinybrains/cartridges/,Linux 上是 ~/.cache/tinybrains/cartridges/ 或 $XDG_CACHE_HOME,Windows 上是 %LOCALAPPDATA%\tinybrains\cartridges,或者 TINYBRAINS_HOME 指向的任何位置),并且只有两个摘要都匹配才会接受。把那个文件复制到任何别的仓库,它在那里也一样能用。

tinybrains games 会打印每个已注册的游戏、它的来源和引擎摘要。当本地结果与天梯上的结果不一致时,先检查这个。

按审核的方式检查

tinybrains check model.onnx manifest.json

check 从 protobuf 读取计算图,在参考集上对你的清单求值,再用清单产出的张量运行计算图。它会打印两个哈希、opset、参数数量、节点数、所用算子、大小指标、相对预算的最坏操作数,以及最慢的一次推理。凡是审核仅凭文件本身就会拒绝的,它也拒绝:不在允许列表中的算子是 OP_NOT_ALLOWED,13 到 19 之外的 opset 是 OPSET。最后它像节点那样运行审核的探测:在你的 probe_dims 下对全零输入做五次推理,其中位数必须在游戏的回合时限之内,Ants 是 1,000 ms:

model.onnx against ants's reference set
    weights          sha256:…
    manifest         sha256:…

graph
    opset            17
    parameters       3006
    nodes            46
    size metric      12280 bytes  (artifact + manifest -- the platform classifies it, this does not)
    operators        Cast, Concat, Constant, Conv, Relu, Slice
    inputs           board

adapters  (207 reference observations, budget 1000000, turn 1000 ms)
    PASSED
    worst case       208427 operations, 20% of the budget
    slowest graph    7.01 ms of inference  (measured here, not a threshold: the probe below is admission's timing gate)

probe  (5 zero-filled inferences at H = 128, W = 128, as admission runs them, turn 1000 ms)
    PASSED
    median           6.07 ms  (measured here; the admitting runner measures again)

This is not admission. It has no download allowlist and does not decide a size class,
so a pass here is necessary and not sufficient.

--json 把这次运行打印成一个对象,如果有观测失败,failing_case 会指出是哪一个。不过通过是必要条件,而非充分条件:审核是对照你所在赛季的表来判定重量级的,而你的机器上没有赛季。审核运行器还会在自己的硬件上再测一次探测,所以接近回合时限的中位数可能在这里通过、在那里失败。

带记忆的模型还能从 check 得到两样东西。它会打印记忆的开销,固定部分和每格部分各是多少,以及在最小和最大地图上的总量。--memory-flat-bytes N 和 --memory-cell-bytes N 用重量级的两个数字评判这个总量(省略的数字视为 0),给出的结论与审核相同:MEMORY_NOT_ALLOWED、MEMORY_TOO_LARGE 和 MEMORY_SHAPE。参考观测也会像审核那样串联运行:只要前后两个观测大小相同,每次调用就会被喂入上一次调用写出的记忆。被喂入记忆后失败的调用是 MEMORY_ROUND_TRIP,check 会指出它在哪个观测上失败。

查看适配器构建的张量

tinybrains adapt model.onnx manifest.json --out tensors

adapt 通过 datalogic 在每个参考观测上对每个适配器求值,并把每个输入张量写成 numpy 的 .npy 文件,放在产生它的那个观测旁边:

tensors/
  index.json               the budget, and each case's ops and tensor shapes
  case-0/observation.json
  case-0/board.npy         one file for each input your manifest declares
  case-1/…

--obs FILE 改为运行你自己的观测:一个观测、一个观测列表,或者 {"observations": [...]}。观测可以带有 memory 或 ant_memory,既可以是嵌套数组(会按你清单为该输出声明的数据类型解码),也可以是运行器交回的传输格式。这样你就能在设置了记忆的情况下,比较训练器的张量和适配器的张量。

天梯喂给你计算图的正是这些张量,所以训练之前,先断言你训练器的编码器产出的张量与之完全相同,逐个元素一致。如果你的编码器和适配器不一致,你训练出的模型面对的输入在天梯上从不会出现,而且什么都不会报错:只是你的评分比训练时承诺的要低。一个真实的清单展示了平台自己的基线所运行的这项测试。

打一场对局

tinybrains matches/self-play.json      # 在入门套件的检出目录中
tinybrains view replays/self-play.json

对局文件为每个席位指定一个模型和一个清单。在你自己的机器上,你的参赛模型可以通过真实的卡带和真实的求值器,对战入门套件 models/ 中训练好的对手、它自己,或者上周的版本。每次运行都会打印每席位回合的平均操作数和平均推理时间,以及最慢的一次占回合时限的比例。-v 会在判罚、判负、推理错误和超出回合时限的警告发生时逐条打印;--timings 测量运行的每个阶段;--out DIR 把回放写到 replays/ 以外的地方。view 在浏览器中打开回放,下方附有对局图表:每个席位存活的蚂蚁、尚存的蚁丘或得分,逐回合显示。

对局文件

对局文件保存的是天梯运行器所认领的那种行,外加对局运行时的设置,所以你在自己机器上打的对局和天梯上的形式一致。

{
  "game": "ants",
  "vars": { "max_turns": 300 },
  "rows": [
    { "id": "self-play", "seed": 42, "map": "basic-tiny-2p", "seat_count": 2,
      "seats": [
        { "seat": 0, "weights": "../model.onnx", "manifest": "../manifest.json", "label": "mine" },
        { "seat": 1, "weights": "../model.onnx", "manifest": "../manifest.json", "label": "mine-again" }
      ] }
  ]
}

席位可以用三种方式之一指定它的模型:

字段含义
weights + manifest各是一个路径或 URL。路径相对于对局文件所在目录解析。CLI 会在任何运行之前把文件字节哈希进本地存储,所以两个席位指向相同的文件就是同一个模型
weights_hash + manifest_hash已在本地存储中的文件的两个 sha256: 摘要:真实天梯行采用的形式
script用预先写好的指令代替模型,每回合一项:给所有蚂蚁同一条指令("E"),或按 mine 顺序每只蚂蚁一条(["E", "W"])。脚本用完之后席位原地不动。脚本不经过任何模型,但仍然经过卡带

缺少的字段取以下默认值:

字段缺省时
game"ants"
idmatch-<index>;它决定回放文件的名字
seed必填:没有种子的行不会运行
seat_count席位的数量;若填写,必须与席位数相等
seat席位在列表中的位置
label权重文件的名字、一个短哈希,或 scripted
map必填:每场对局都需要一张地图,没有什么会替你挑选

vars 会覆盖卡带的设置,且只覆盖你写了的那些。缺少 max_turns 或 turn_ms 时回落到游戏的限制,缺少 budget_ops 时回落到它的适配器预算,缺少 strike_ceiling 时回落到五次;运行模型的一次运行会打印它所用的回合时限和预算。入门套件只写了 max_turns: 300,为了让本地对局短一些,别的都没写:你写下的数字就不再跟随平台变化。文件还可以指定它期望的 engine_digest,当实际运行的卡带不是它时 CLI 会发出警告。

行的 map 可以用三种方式之一指定地图:发布版本自带地图的 id(tinybrains maps 会列出它们:五张基础地图);一个以 .json 结尾的路径,像席位的权重一样相对于对局文件所在目录解析(想打某个赛季的地图就用这种方式,从该赛季的地图列表保存下来);或者直接内联地图本身。CLI 拒绝指定了 preset 的行:这种格式没有预设。在天梯上你从不选择地图;配对会连同种子一起,从赛季启用中的地图里挑选。seat_count 必须等于地图的 players。一条命令会运行文件中的每一行,只要席位数相同,各行可以使用不同的地图。天梯一次只运行一行,所以长文件只是增加了对局数量,并不能演练生产环境的批量方式。

用真实引擎训练

tinybrains env

训练循环需要推进世界。如果你用 Python 再写一份规则实现,你的模型最终会在你那份游戏复制品上很强,而在真实游戏上很弱。env 把真实的卡带放在一个基于标准输入和标准输出的 JSON Lines 协议之后,每行一个对象。输出的第一行是 hello,带有引擎摘要、求值器版本、池中的地图、游戏的限制和预算,以及池本身的设置。之后,{"op": "observe"} 返回每个存活席位的观测,{"op": "step", "actions": [...]} 推进它们;env 在 ended 中报告已结束的对局,并从池中补上新的对局。

← {"ok":true,"hello":{"game":"ants","engine_digest":"sha256:…","evaluator":"datalogic …","maps":[{"id":"basic-tiny-2p","players":2,"rows":24,"cols":24},…],"waves":4,"matches_per_wave":16,…}}
→ {"op":"observe"}
← {"ok":true,"turn":0,"seats":[{"w":0,"m":0,"ep":0,"seat":0,"turn":0,"obs":{…}},…],"scores":[…],"ended":[]}
→ {"op":"step","actions":["NNE-","-W",…]}
← {"ok":true,"turn":1,"seats":[…],"scores":[…],"ended":[{"w":0,"m":3,"ep":3,"turns":…,"ranks":[1,2],"reason":"lone_survivor","map":"basic-tiny-2p",…}]}
→ {"op":"close"}

它运行一个由多个波次组成的池,让一批对局一起推进,并在某个波次中的所有对局都结束后重新填满它。--waves、--matches-per-wave、--max-turns、--seed 和 --scores(every 回合都给分数,或只在 end 时给)设置这个池。每个错误都是致命的:env 写出一行 {"ok": false, "error": …} 然后退出。丢失了席位顺序的循环没有任何正确的继续方式,而一个看似可恢复的失败会让你的训练在错位的动作上继续,且没有任何迹象。把 hello 那一行的摘要记进你的模型卡:说不出训练时所用引擎的参赛模型,是无法复现的。

在这里由你选择地图池,因为训练不打排名对局:--maps 接受 id(--maps basic-tiny-2p,basic-small-3p)或路径,或者一个装有地图文件的目录(赛季的地图也可以),默认是基础地图。每个波次只用一张地图,所以一个波次的观测大小相同,可以堆叠成一个张量。下一个波次轮到下一张地图,结束的对局会报告它所用的 map。

env 不是裁判。它没有回合时限、没有判罚上限、也不对适配器求值:它只推进世界,别的什么都不做。不要把它的结果当作成绩;tinybrains check 和一场真正打完的对局才是把关的关卡。

获取地图

tinybrains maps                  # 基础地图、它们的大小和席位数,以及赛季地图必须满足的限制
tinybrains maps export ants out  # 把它们写出来,与发布版本中的字节完全一致
tinybrains maps check board.json # 这张地图能通过赛季的上传检查吗?

发布版本不包含赛季自己的地图。每张地图从管理员上传那一刻起就是公开的:GET /v1/games/ants/seasons/{slug}/maps?boards=true 返回全部地图,tinybrains 可以把每一张当作路径指向的地图文件来打。maps check 运行上传时的那些检查(文件头、游戏的限制和引擎自己的校验),所以你可以检查训练器生成的地图是否符合赛季可用的条件。它还检查赛季地图上传时的命名规则:id 为 size-terrain-Np-Hh,N 等于文件的 players,H × 玩家数等于其 hills 的长度。失败时会打印 Soma 拒绝所用的代码(map_name_pattern、map_name_players 或 map_name_hills)。basic-* 地图只会得到一个警告,因为它们随发布版本提供,从不以那种名字上传。

验证回放可复现

tinybrains conform replays/self-play.json

conform 仅凭回放的封包信息重建一场对局,在你的机器上重打一遍,并把地图、种子、结束方式、名次、得分和每回合的动作与录像比较,在第一个不同的回合停下。它需要每个席位的模型都在本地存储中,并且拒绝在另一个引擎上打的回放。平台自己也运行它,以确保本地运行器和对局工作流在相同种子下结果一致。也拿你自己参赛模型的回放跑一次:出现差异就意味着两个引擎不一致,这是一个应该报告的 bug。

本地无法检查的事

原因
你的重量级审核是对照你所在赛季的表来判定的,而你的机器上没有这张表。check 打印的是指标;赛季才把它变成重量级
你的文件是否在平台期望的位置平台从你上传到的存储桶读取它们,从不读你的磁盘
对局后期的回合是否在预算之内参考集只是每张基础地图的三个回合。拥挤的地图可能比其中任何一个都费
你的参赛模型评分如何只有天梯能测出来,要靠对阵众多对手的许多场对局
你的策略头是否是裁判能读取的形状已覆盖:check 会解码它,但也只有 check 会。env 不对清单求值

也要检查动作

check 确认你的策略头在每个参考观测上都能解码成有效动作。它说不了这个动作好不好:平台按固定的通道顺序读取你的策略头,所以按错位顺序训练的计算图会朝错误的方向给出有效走法,并通过所有检查。打一场对局,数一数走法。原地不动通道处处胜出的模型,动作全部有效,却从不移动:

python3 -c "import json,collections; d=json.load(open('replays/self-play.json')); \
  [print('seat', i, collections.Counter(c for t in d['deltas'] for c in t['a'][i])) \
   for i in range(len(d['deltas'][0]['a']))]"

按席位分别统计。把各席位合计会让一个席位的走法掩盖另一个席位的,从未移动过的蚁群会被当成一场激烈对局的一部分蒙混过去。

1 号席位六个回合都有应答,却一步未动。它的蚂蚁站在出生的那一格上,蚁丘旁的食物进了它无法消耗的蚁巢,因为那只蚂蚁占着它的新蚂蚁唯一能出现的格子。同一张地图、同样规则下的 0 号席位采集了两次、出生了两次,有三只蚂蚁沿着它那一行向东走。在这份回放上,上面的统计给出 0 号席位十一次向东、一次向西和若干次原地不动,1 号席位则是六次原地不动,别无其他。

提交之前

确认你的形状在每张基础地图上都成立、每个适配器的预算在最大地图上够用,并确认通道顺序、大小指标和回合用时。此外,还要每次编辑后都重新计算最终文件的哈希:重新格式化清单会改变它的哈希和大小,而审核会按你声明的哈希核对上传的文件。把两个哈希和训练笔记放在一起,这样任何结果都能追溯到产生它的版本。