逐段解读一份真实清单
入门套件里训练好的模型(ants-starter/models 中的 nano-bc 和 micro-bc,由 ants/baselines 训练)共用一份清单:nano-bc 的是 930 字节,micro-bc 的多一个字节,因为它的 name 长一个字符。清单声明一个输入和一个输出,唯一的适配器把观测变成堆叠为一个张量的七个平面,board: i8[1, 7, H, W]。计算图回答 policy: f32[1, 5, H, W],由裁判读取。
这份清单由基线生成。ants/baselines/src/tb_baselines/planes.py 把每个平面只声明一次,并排给出两种写法(下面的 JSONLogic,以及训练器所用的 numpy),再用一个测试证明两者一致。训练器里的同一套编码解释了为什么你的训练器也需要这个测试。
下面每个片段都带一个链接,可以在 DataLogic Studio 中打开,针对的是这个示意性的观测:
{"size": [64, 96], "mine": [[12, 30], [13, 30], [43, 66]], "foes": [[12, 33, 1]],
"food": [[11, 31]], "hills": [[12, 30, 0], [44, 70, 1]],
"water": {"rle": [0, 1250, 1, 3, 0, 4891]},
"vis": {"rle": [0, 1054, 1, 11, 0, 5079]}}
它包含三只蚂蚁(一只在你位于 [12, 30] 的蚁丘上,一只在它旁边,第三只在能看到位于 [44, 70] 的敌方蚁丘的位置)、一只紧挨着前两只的敌方蚂蚁、一份食物、第 13 行上三格已知的水域,以及本回合可见的十一格。
声明
"inputs": [{ "name": "board", "dtype": "i8", "shape": [1, 7, "H", "W"], "adapter": … }],
"outputs": [{ "name": "policy", "dtype": "f32", "shape": [1, 5, "H", "W"] }],
"probe_dims": { "H": 128, "W": 128 }
H 和 W 是名字。它们绑定到每次调用带来的地图尺寸,而由于输出也用了同样的两个名字,计算图必须按收到的尺寸作答。一个赛季的地图边长可以是 24 到 124 之间的任意值,最多 14,880 格,一个通过审核的会话就能应对全部。这份清单的 probe_dims 让它按 128 × 128、即 16,384 格通过审核,比游戏允许的任何地图都大,所以它的探测覆盖了每一张地图。最大的地图是 14,880 格,即 120 × 124 的基础地图:在你自己的清单里至少要声明这么大(原因)。
完整的适配器
{"reshape": [
{"stack": [
[
{"scatter": [{"var": "mine"}, {"var": "size"}, "i8"]},
{"scatter": [
{"map": [{"var": "foes"}, [{"var": "0"}, {"var": "1"}]]},
{"var": "size"},
"i8"
]},
{"scatter": [{"var": "food"}, {"var": "size"}, "i8"]},
{"rle_expand": [{"var": "water.rle"}, {"var": "size"}, "i8"]},
{"scatter": [
{"map": [
{"filter": [{"var": "hills"}, {"==": [{"var": "2"}, 0]}]},
[{"var": "0"}, {"var": "1"}]
]},
{"var": "size"},
"i8"
]},
{"scatter": [
{"map": [
{"filter": [{"var": "hills"}, {"!=": [{"var": "2"}, 0]}]},
[{"var": "0"}, {"var": "1"}]
]},
{"var": "size"},
"i8"
]},
{"rle_expand": [{"var": "vis.rle"}, {"var": "size"}, "i8"]}
],
0
]},
{"merge": [[1, 7], {"var": "size"}]}
]}
在 DataLogic Studio 中打开——数据:一份示例观测:三只蚂蚁、一只敌蚁、一个食物、双方蚁丘、三格水域
Studio 会对 JSON 部分求值,到张量部分就停下。它展示每个张量调用及其算好的参数(那些点、那个形状、那个数据类型),也就是节点上的操作符会收到的参数,但从不真正构建张量。在 DataLogic Studio 中查看说明了该看什么,以及 Studio 与竞技场在哪些地方不一致。
下一个链接用同一个程序处理一个真实输入:审核时每个参赛模型都要过一遍的 207 个观测之一。它是三席位基础地图上的一个席位,所以敌方蚂蚁标为 1 和 2:
在 DataLogic Studio 中打开——数据:审核探测所用的一份参考观测——basic-small-3p 地图第 20 回合的一个席位——与卡带自带的完全一致
七个平面
每个平面都是一张由 0 和 1 组成的 H × W 网格,由观测的一个字段构建:
| # | 平面 | 构建方式 | 告诉计算图 |
|---|---|---|---|
| 0 | 你的蚂蚁 | {"scatter": [{"var": "mine"}, {"var": "size"}, "i8"]} | 你的蚂蚁:游戏唯一完整给出的位置 |
| 1 | 敌方蚂蚁 | 对 foes 做散布,去掉所有者 | 本回合你能看到的敌方蚂蚁 |
| 2 | 食物 | {"scatter": [{"var": "food"}, {"var": "size"}, "i8"]} | 你能看到的食物 |
| 3 | 水域 | {"rle_expand": [{"var": "water.rle"}, {"var": "size"}, "i8"]} | 已知的水域:游戏唯一替你记住的字段 |
| 4 | 你的蚁丘 | 对 hills 中所有者为 0 的项做散布 | 每一个被敌人摧毁都让你失一分 |
| 5 | 敌方蚁丘 | 对 hills 中所有者不为 0 的项做散布 | 每摧毁一个让你得两分 |
| 6 | 可见 | {"rle_expand": [{"var": "vis.rle"}, {"var": "size"}, "i8"]} | 本回合你能看到的每一格 |
scatter 从一张给定形状的全零网格开始,在每个 [row, col] 点写入 1。形状是 {"var": "size"},由适配器从观测中读取。把 [80, 80] 写死在自己里面的适配器,在其他每一种地图尺寸上都会失败。
去掉所有者
敌方蚂蚁以 [row, col, owner] 的形式到达,而 scatter 把第三个元素当作要写入的值。在两席位的地图上,每只敌方蚂蚁都标为 1,所以直接散布原始三元组碰巧写入了正确的值。在席位更多的地图上,标签最大可到 7,散布就会把这个标签写进平面。去掉所有者,让平面里的 1 对每只敌方蚂蚁都表示“这里有敌蚁”。用一个 map 把每个三元组重建为二元组:
{"map": [{"var": "foes"}, [{"var": "0"}, {"var": "1"}]]}
在 DataLogic Studio 中打开——数据:一份示例观测:三只蚂蚁、一只敌蚁、一个食物、双方蚁丘、三格水域
在 map 的主体里,文档是一只敌方蚂蚁,所以 {"var": "0"} 是它的行,{"var": "1"} 是它的列。
按所有者拆分蚁丘
hills 包含双方尚存的蚁丘,所有者是相对于你的:0 永远是你,1 往上是对手(所有权标签)。对第三个元素做 filter 选出一方,再用同一个 map 去掉所有者:
{"map": [
{"filter": [{"var": "hills"}, {"==": [{"var": "2"}, 0]}]},
[{"var": "0"}, {"var": "1"}]
]}
在 DataLogic Studio 中打开——数据:一份示例观测:三只蚂蚁、一只敌蚁、一个食物、双方蚁丘、三格水域
敌方平面是同一个表达式换成 !=。两个平面要两个表达式,因为适配器只返回一个张量,而装着两个张量的对象不是张量(原因)。
水域与你能看到的
两者都是行优先顺序的游程掩码,[value, count, value, count, …],rle_expand 把每一个展开成平面,不需要用 JSON 循环遍历格子。各段游程之和不得超过 H × W,而游戏给出的游程之和总是恰好等于 H × W。
要读懂 water 里的 0,两个平面都需要。它表示已知的陆地或从未见过,游戏不区分这两者。vis 回答了观测所能回答的那一半:在可见的 1 之下,水域的 0 是你正看着的陆地;在 0 之下,它只表示那里没有已知的水域。
vis由游戏发来,你的适配器推导不出来。推导它意味着在一张环绕的地图上,标出你每只蚂蚁距离平方 77 以内的每一格。这门语言表达不了这件事:这是逐只蚂蚁画出的圆盘,而内层迭代器看不到外层的那只蚂蚁(作用域)。引擎反正会计算这个掩码并发过来,一个rle_expand就把它变成平面。
堆叠与批次轴
{"reshape": [
{"stack": [[<plane 0>, <plane 1>, …, <plane 6>], 0]},
{"merge": [[1, 7], {"var": "size"}]}
]}
在第 0 轴上 stack,把七个 [H, W] 平面变成一个 [7, H, W] 张量。reshape 加上前导的 1 轴,形状在运行时拼出([1, 7] 与尺寸 merge 得到 [1, 7, 64, 96]),只花一次操作,因为 reshape 不移动任何元素。
前导的 1 是批次轴。清单把它声明为字面量 1,因为一场对局每次调用只跑一个席位,没有任何批处理。你仍然可以在导出计算图时把这一轴设为动态:这不花任何代价,还能让模型文件在带批处理的训练器里继续可用。
适配器的结果就是张量本身。声明里输入的 name 就是它的名字。返回 {"board": …} 的适配器,是在调用一个名为 board 的操作符。
开销
tinybrains adapt 用这份清单跑一遍卡带的参考观测,并打印每一个的计费。在参考集所取自的五张基础地图上,2026 年 10 月 1 日测得这个适配器在最小、中等和最大地图上的计费如下:
| 地图 | 格数 | 操作数 | 占预算 |
|---|---|---|---|
| 24 × 24 | 576 | 8,107 – 8,131 | 1% |
| 48 × 64 | 3,072 | 43,061 – 43,113 | 4% |
| 120 × 124 | 14,880 | 208,365 – 208,427 | 21% |
你为地图的格子付费。构建平面的操作符每产出一格,节点就计一次费,不管那一格有没有写入东西。五次散布和两次游程展开各自产出一整张网格,堆叠又把七个平面全部读一遍:每格十四次计费,在 120 × 124 的地图上就是 208,320 次。蚂蚁、敌方蚂蚁、食物和蚁丘各自再加几次操作,其余都是零头。
裁判在你的蚂蚁处取策略值,不向你的预算收一分钱(原因)。如果由你自己的程序读回策略,就要为每一格的全部五个通道付费:在 128 × 128 的地图上又是 82,000 次操作,占总数的三分之一。
预算列出了每个操作符的计费。
策略头的去向
计算图回答 policy: f32[1, 5, H, W],每一格每种走法各一个分数。裁判在你每只蚂蚁所在的格子上取出五个通道,按 mine 的顺序,对每一个取 argmax。通道顺序是 N, E, S, W, -,公布在模型回答什么里。
你的计算图的各个通道必须按这个顺序对应这些字母。基线把原地不动 - 放在最后。一个最后一个通道处处胜出的计算图,走的都是合法动作,而整个蚁群会整场对局一动不动。测试展示了这样一个例子,以及如何统计你自己的走法。
如果你的计算图自己对地图做索引,按 mine 的顺序回答 [N, 5],那就改为声明这个形状,裁判会跳过取值这一步。它在大地图上更便宜,但会限制你可以给哪些轴起名字(原因)。
训练器里的同一套编码
你用 Python 训练的模型,通过 numpy 编码器看到观测,通过 manifest.json 参赛:同一套编码,写了两遍。如果两者不一致,不会有任何报错。模型在一个分布上训练,在另一个分布上比赛,唯一的症状是评分低于训练时的预期。
照抄基线的做法。planes.py 把每个平面只声明一次,两种写法并排放在一起;tests/test_adapter_conformance.py 运行 tinybrains adapt(datalogic,节点运行清单所用的求值器)处理卡带的参考观测,并断言适配器的张量与 numpy 编码器的张量逐个元素相等:
theirs = np.load(out / f"case-{i}" / "board.npy") # 天梯自己的张量
ours = planes.encode(json.loads((out / f"case-{i}" / "observation.json").read_text()))
assert np.array_equal(ours, theirs)
提交前的测试给出了命令。