How I Built an MLP Evaluator for a Novel Board Game
How I Built an MLP Evaluator for a Novel Board Game
我是如何为一款新颖的棋盘游戏构建 MLP 评估器的
When you build a chess engine, you stand on the shoulders of giants. Piece values were tuned decades ago. King safety, pawn structure, mobility, Stockfish has canonical implementations of all of them. You can crack open any modern engine and read a decades-long conversation about what makes a chess position good. 当你构建一个国际象棋引擎时,你是站在巨人的肩膀上。棋子的价值在几十年前就已经被调整好了。国王安全、兵形、机动性,Stockfish 对所有这些都有经典的实现。你可以打开任何现代引擎,阅读关于“什么构成了好的棋局”长达几十年的讨论。
When you build an engine for a game you invented six months ago, none of that exists. I’ve been building Larss, a 1v1 tetromino placement game that feels like Block Blast but plays like chess. Two players alternate placing pieces on a shared 11×11 to 14×14 board, filling rows, columns, and diagonals to clear cells and score. Matches take 5-10 minutes and are ~99% skill, the same piece sequence goes to both players, so wins emerge from position, not luck. 当你为你六个月前发明的游戏构建引擎时,这些都不存在。我一直在开发 Larss,这是一款 1v1 的俄罗斯方块放置游戏,感觉像《Block Blast》,但玩法像国际象棋。两名玩家轮流在 11×11 到 14×14 的共享棋盘上放置棋子,通过填满行、列和对角线来消除格子并得分。比赛耗时 5-10 分钟,约 99% 靠技术,因为双方玩家面对的是相同的棋子序列,所以胜利源于位置,而非运气。
Then came the engine. Rust, iterative deepening, alpha-beta pruning, the search side was familiar territory. The hard part was the evaluation function: given a position, how good is it? This is a story about the multi-layer perceptron I built to answer that question, why I couldn’t just use NNUE, and what I learned when a much simpler model beat it. 接下来是引擎部分。Rust、迭代加深、Alpha-Beta 剪枝,搜索方面是我熟悉的领域。最难的部分是评估函数:给定一个局面,它有多好?这是一个关于我如何构建多层感知机(MLP)来回答这个问题的故事,包括为什么我不能直接使用 NNUE,以及当一个更简单的模型击败它时我学到了什么。
The problem: no priors, no theory, no dataset. In chess, if I tell you the black queen is under attack and the white king is exposed, you don’t need a computer to know who’s winning. Larss has no such shared intuition. What does “development” mean when your pieces are randomized tetrominoes? What’s the equivalent of a backward pawn? 问题在于:没有先验知识,没有理论,没有数据集。在国际象棋中,如果我告诉你黑方皇后受到攻击,白方国王暴露,你不需要电脑也能知道谁占优势。Larss 没有这种共识。当你的棋子是随机的俄罗斯方块时,“发展”意味着什么?什么相当于“落后兵”?
I had a few obvious features: Score difference. Number of legal placements per player (mobility). Cell safety: for every empty cell, (% of line completed) × (% of that line your color). High values mean you’re one placement away from letting your opponent close a line and score. But I didn’t know how to combine them. A weighted sum? A polynomial? Something learned? And even if I picked a functional form, I had no ground truth. There were no grandmaster games to imitate. There wasn’t a single Larss game on the internet that wasn’t one I’d played myself. So the plan became: pick an architecture flexible enough to learn the combinations, and bootstrap the training data from self-play. 我有几个显而易见的特征:分数差;每位玩家的合法放置次数(机动性);格子安全性:对于每个空位,计算(该行/列/对角线完成百分比)×(该线中属于你的颜色百分比)。高分意味着你只需再放一个棋子,就可能让对手完成一行并得分。但我不知道如何组合它们。加权求和?多项式?还是通过学习得到?即使我选定了一种函数形式,我也没有“真理”。没有大师级的对局可以模仿。互联网上除了我自己玩的对局外,没有任何 Larss 游戏记录。因此计划变成了:选择一种足够灵活的架构来学习这些组合,并通过自我对弈来引导训练数据。
Why not NNUE? The obvious question: why not NNUE? Efficiently Updatable Neural Networks are the current champion in computer chess. Small networks, incrementally updated as moves are played, absurdly fast to evaluate. Stockfish switched to NNUE and gained hundreds of Elo. If it worked for chess, why not here? 为什么不用 NNUE?一个显而易见的问题:为什么不用 NNUE?高效可更新神经网络(NNUE)是目前计算机国际象棋的冠军。小型网络,随着走棋进行增量更新,评估速度极快。Stockfish 切换到 NNUE 后 Elo 分数提升了数百点。如果它对国际象棋有效,为什么对这里不行?
Two reasons. Fixed input dimensions. Classic NNUE encodings — HalfKP, HalfKA, are designed around an 8×8 board and chess’s piece set. Larss boards range from 11×11 to 14×14, and I want the engine to play all of them. I’d either need one network per size (four to train, maintain, and version) or a padding scheme that wastes capacity and muddies training. 两个原因。第一,固定的输入维度。经典的 NNUE 编码(如 HalfKP、HalfKA)是围绕 8×8 棋盘和国际象棋棋子集设计的。Larss 的棋盘大小从 11×11 到 14×14 不等,我希望引擎能玩所有尺寸。我要么需要为每种尺寸训练一个网络(四个网络需要训练、维护和版本控制),要么需要一种浪费容量且干扰训练的填充方案。
Incremental updates depend on locality. NNUE’s speed comes from only recomputing the neurons affected by a move. In chess, one move changes a small, bounded number of input features. In Larss, a single tetromino placement can clear an entire row, column, or diagonal, dozens of cells flip at once, plus every empty cell’s safety score potentially shifts. The bookkeeping wins less. I could have forced NNUE to work. But I’d be paying its complexity cost without collecting its speed benefit. I went a different way. 第二,增量更新依赖于局部性。NNUE 的速度源于只重新计算受走棋影响的神经元。在国际象棋中,一步棋只改变少量、有限的输入特征。而在 Larss 中,放置一个俄罗斯方块可能会消除整行、整列或整条对角线,几十个格子同时翻转,此外每个空位的安全分数都可能发生变化。这种记账方式带来的收益较小。我本可以强行使用 NNUE,但我会付出其复杂性的代价,却无法获得其速度优势。我选择了另一条路。
Bitmask popcount architecture. Larss already runs on bitboards. Each cell state (empty, player 1, player 2, blocked) is packed across a handful of wide integers. Bitboards let me evaluate any line — row, column, or diagonal — with a couple of instructions: mask the line, popcount to get occupied cells, popcount an AND with the player mask to get that player’s cells. The insight for the evaluator was: don’t feed the network raw board state. Feed it bitmask-derived features that are already dimensionless with respect to board size. 位掩码位计数(Bitmask popcount)架构。Larss 已经在位棋盘(bitboards)上运行。每个格子的状态(空、玩家 1、玩家 2、阻塞)被打包在几个宽整数中。位棋盘让我可以用几条指令评估任何线(行、列或对角线):掩码该线,通过位计数(popcount)获取占用格子,通过与玩家掩码进行 AND 运算后的位计数获取该玩家的格子。评估器的洞察点在于:不要将原始棋盘状态输入网络,而是输入从位掩码派生出的、与棋盘尺寸无关的特征。
Concretely, each position produces a fixed-length feature vector: 具体来说,每个局面都会产生一个固定长度的特征向量:
struct Features {
score_diff: f32,
mobility_ratio: f32,
// Bucketed by line length, because a 14x14 board
// has longer diagonals than an 11x11.
filled_fraction: [f32; 3],
own_cell_fraction: [f32; 3],
// Histogram: how many empty cells sit in each danger bucket?
danger_histogram: [f32; 8],
// ... a handful more
}
Every feature is either a normalized ratio or a bucketed count. An 11×11 board and a 14×14 board produce vectors with the same shape and comparable magnitudes. The MLP behind the two hidden layers, 128 and 64 units, ReLU, never touches the raw board. Evaluation pipeline: bitboards -> popcount into features -> forward pass -> scalar. About 40k parameters total. Fast enough to sit inside alpha-beta at reasonable depths. 每个特征要么是归一化的比率,要么是分桶计数。11×11 和 14×14 的棋盘产生的向量形状相同且量级可比。MLP 包含两个隐藏层(128 和 64 个单元,ReLU 激活),从不接触原始棋盘。评估流水线:位棋盘 -> 位计数生成特征 -> 前向传播 -> 标量。总共约 4 万个参数。速度足够快,可以在合理的搜索深度下嵌入 Alpha-Beta 算法中。
Training with texel tuning. I still needed data. I bootstrapped it the way chess engines have for years: texel tuning. The idea, borrowed from the Texel engine, is to treat the evaluator as a probabilistic predictor of game outcomes. Given a position and its final result (win, loss, draw), the evaluation should approximate the log-odds of winning. Squash the scalar output through a sigmoid, compare it to the actual result, backpropagate. 使用 Texel 调优进行训练。我仍然需要数据。我采用了国际象棋引擎多年来使用的方法:Texel 调优。这个想法借鉴自 Texel 引擎,即将评估器视为游戏结果的概率预测器。给定一个局面及其最终结果(胜、负、平),评估值应近似于获胜的对数几率。将标量输出通过 Sigmoid 函数压缩,与实际结果进行比较,然后进行反向传播。
The pipeline: Self-play a bunch of games with a weak baseline evaluator (initially just score difference). For every position along every game, record (features, final_outcome). Train the MLP to minimize MSE(sigmoid(k * eval), outcome), where k is a scaling constant that’s also learned. Replace the evaluator with the trained one. Self-play again. Repeat. A few passes in, the network was playing “positional” moves I hadn’t hand-coded, leaving lines partially completed to bait the opponent, avoiding placements that handed the opponent easy safety improvements. It felt like it had actually learned something about Larss. 流水线如下:使用一个较弱的基准评估器(最初仅为分数差)进行大量自我对弈。记录每场比赛中每个局面的(特征,最终结果)。训练 MLP 以最小化 MSE(sigmoid(k * eval), outcome),其中 k 是一个同样需要学习的缩放常数。用训练好的评估器替换旧的。再次自我对弈。重复上述过程。几轮之后,网络开始走出我没有手动编写过的“位置型”棋步,比如故意留下未完成的行来诱导对手,或者避免做出让对手轻易改善安全性的放置。感觉它确实学到了一些关于 Larss 的东西。
Results, and the lesson I didn’t want to learn. The MLP worked. It played real Larss. Then I trained a linear model on the same features, the same data, the same texel loss. Just a dot product between the feature vector and a weight vector. It was better. Not enormously, but consistently. 结果,以及我不想学到的教训。MLP 成功了,它能玩真正的 Larss。然后,我用相同的特征、相同的数据和相同的 Texel 损失函数训练了一个线性模型。仅仅是特征向量和权重向量的点积。结果它表现得更好。虽然提升不是巨大,但非常稳定。