
前三篇主要介绍了在给定一个训练好的权重的前提下,Stockfish NNUE是如何使用它的。接下来的两篇我们会着重介绍一下如何训练一个NNUE网络的权重。
训练一个NNUE网络权重一般要经过两步:一,生成训练数据;二使用训练数据训练网络。如果你曾经编译过Stockfish NNUE,或者尝试训练NNUE权重的话,你就会知道,Stockfish NNUE从训练到实际应用,包含了三个可执行文件,分别对应三种不同的功能:
- nnue:使用了NNUE网络的Stockfish,用图形界面加载他就可以下棋了。
- nnue-gen-sfen:NNUE训练数据生成器。
- nnue-learn:NNUE网络权重训练器。
这其中,第二个可执行文件就是用来生成训练数据文件的(末尾我会简要介绍如何从源码编译得到这几个可执行文件)。本篇就着重介绍一下NNUE训练数据的生成方法。
PackedSfenValue结构体
NNUE的训练数据存储在一个二进制文件中。与所有有监督学习数据集类似,文件的内容就是一个巨大的训练样本序列。其中,每一个样本包含输入(已经提取特征了的feature vector或未经处理的原始数据)和标签。在NNUE的代码中,每个训练样本被称为PackedSfenValue。PackedSfenValue是一个40字节的结构体,包含了经过压缩的棋盘局面、胜负,搜索depth层返回的估值,搜索depth层返回的move,以及步数gamePly。每当我们跑一次NNUE的训练数据生成,就会生成这样一个xxx.bin的文件。由于此文件是由若干PackedSfenValue的字节块组成,因此它的大小一定是40字节的整数倍。例如,生成一个包含1000000样本(棋盘局面)的文件,其大小一定是40MB。
下面详细介绍一下PackedSfenValue结构体。PackedSfenValue包含了以下六个成员:
- PackedSfen:int8[32],这是已经经过压缩的棋盘局面的fen字符串。fen是国象中常用的表示棋盘局面的字符串格式。然而原始fen字符串长度不定,且有很多允余信息。NNUE将其压缩为了32字节的紧凑格式。
- score:int16,从当前局面出发,使用Stockfish搜索depth层返回的估值。这里Stockfish执行alpha-beta搜索时使用的是Stockfish原版手写的估值函数(NNUE也提供了使用已训练好的NNUE网络来搜索的编译选项,但最后并没有使用)。一般来说,从当前局面经过depth层alpha-beta搜索时所返回的估值,要比直接调用估值函数评估当前局面的值相对来说更准确,且depth越大准确度越高。这是因为alpha-beta搜索对当前局面的动态分支都进行了探索,而手写的估值函数则往往只对局面的静态特征有较好的描述。(跑个题,这个获得score的过程其实正反映了我们对估值函数的期望:对当前局面的估值能够尽可能准确的反映搜索N层之后棋局的发展趋势,且N越大越好。可以想象一种极端完美的情况:估值函数的返回值与搜索到棋局结束之后的返回值相同——这个时候我们就不需要搜索了,直接用估值函数挑选最好的子节点返回就完事了。当然实际要做到这一点是几乎不可能的)
- move:uint16,上面提到的搜索返回score同时返回的对应的最佳着法。alpha-beta搜索不但能返回最佳子节点的值,还能顺便返回最佳子节点对应的着法,类似Dijkstra算法不但能返回最短路长度,还能返回最短路径本身。不过move并不参与训练,只用于评估。在训练过程中,每隔几个epoch,训练算法就会在校验集上应用刚训练好的NNUE网络,通过alpha-beta搜索得到一个最佳着法,然后比对该着法与move是否一致,并计在最后计算在整校验集中一致的样本占整个校验集的百分比。在训练的早期,NNUE网络的权重还很不靠谱时,这个百分比能一定程度的反映训练的进度。
- gamePly:uint16,对局步数,也就是当前对局已经到了第几步。
- game_result:int8,对局结果,1表示“己方”最终获胜,-1表示“对方”最终获胜,0表示和起。“己方”的定义与第二篇介绍的一样,就是当前走棋的一方。
- padding:uint8,补充字节,目前未使用。
单从PackedSfenValue的内容,我们依旧能够看到一些NNUE训练数据的特点。其中最重要的一点就是,NNUE的训练样本中