Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

逐段解读一份真实清单

入门套件里训练好的模型(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"}]}
]}

Studio 会对 JSON 部分求值,到张量部分就停下。它展示每个张量调用及其算好的参数(那些点、那个形状、那个数据类型),也就是节点上的操作符会收到的参数,但从不真正构建张量。在 DataLogic Studio 中查看说明了该看什么,以及 Studio 与竞技场在哪些地方不一致。

下一个链接用同一个程序处理一个真实输入:审核时每个参赛模型都要过一遍的 207 个观测之一。它是三席位基础地图上的一个席位,所以敌方蚂蚁标为 1 和 2:

七个平面

每个平面都是一张由 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"}]]}

在 map 的主体里,文档是一只敌方蚂蚁,所以 {"var": "0"} 是它的行,{"var": "1"} 是它的列。

按所有者拆分蚁丘

hills 包含双方尚存的蚁丘,所有者是相对于你的:0 永远是你,1 往上是对手(所有权标签)。对第三个元素做 filter 选出一方,再用同一个 map 去掉所有者:

{"map": [
  {"filter": [{"var": "hills"}, {"==": [{"var": "2"}, 0]}]},
  [{"var": "0"}, {"var": "1"}]
]}

敌方平面是同一个表达式换成 !=。两个平面要两个表达式,因为适配器只返回一个张量,而装着两个张量的对象不是张量(原因)。

水域与你能看到的

两者都是行优先顺序的游程掩码,[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 × 245768,107 – 8,1311%
48 × 643,07243,061 – 43,1134%
120 × 12414,880208,365 – 208,42721%

你为地图的格子付费。构建平面的操作符每产出一格,节点就计一次费,不管那一格有没有写入东西。五次散布和两次游程展开各自产出一整张网格,堆叠又把七个平面全部读一遍:每格十四次计费,在 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)

提交前的测试给出了命令。