完全信息对抗性博弈算法从入门到初识
前言
说到博弈理论,大家作为 Oier 第一时间想到的一定是 OI 题目中最常涉及的公平组合游戏,或更为具体地,nim 游戏,sg 函数或纳什均衡等等。
然而现实中真正“有意思”的博弈情景,却甚少属于这些理想化的博弈论模型,而是独自拥有一类自己的理论框架。
与那些充满数学性的博弈结论截然不同的是,这些理论的“算法味”是相当浓厚的,同时,它又毫不缺乏在实践意义上的直观性,这两点可能便是它曾激发了我探索欲望的原因。
本文的目的正是向读者介绍这一研究领域的一些有趣的研究成果与理论方法。由于我自身水平的限制,大部分内容只能做到浅尝辄止,因此对本文的创作期望仅仅是一篇在部分内容处略有深入的科普短文。同时疏漏之处可能在所难免,欢迎读者在评论区指正。
概述
如上文所述,“对抗性博弈”最根本的研究动机就是实用。什么是实用?我们可以概括成,我们的研究对象就是日常生活中最常见的 棋牌,而非理想化后的数学模型。
在“棋牌“当中,牌一般是非对称信息的,比如打扑克就肯定互相看不到牌。这个其实是很难处理的,虽然近年来也有不少对这种非对称信息游戏的研究成果出现,但本文略过不谈。
因此我们的研究对象就再次缩小到了“棋”,而且是完全信息的棋。
对于这样的一类游戏,我们很容易发现,要求出某个局面的最优策略是没法取巧的,只能使用搜索 + 评估的方式。搜索不需要解释,评估指的是对一个局面的好坏程度进行估计,因为你没可能直接搜到游戏结束,所以评估方法的好坏显然也是极度重要的。
迄今为止的一切完全信息棋类引擎,都逃不过使用以下两类大的框架中的一种:要么是 AlphaBeta
框架,要么是 MCTS
框架。这里对它们的特点进行一个大致概括。
alpha-beta
就是我们所说的“爆搜”,当然我们会进行无穷多的剪枝与精细实现来将爆搜的效率提升得非常非常高。举一个例子,国际象棋每个局面可能都有个十几种走法,但采用这个框架的国际象棋最强引擎 Stockfish 最新版分支因子只有 1.6 不到,就是说每多搜索一步它的搜索树平均只会扩大 1.6 倍,而剩余的十几种着法则由于各种原因被剪枝,这是非常恐怖的。
alpha-beta
框架由于重心更偏向搜索与速度,相对没有那么看重评估函数的质量,因此适用于高效率,低精准度的评估函数。
MCTS
框架就截然不同,它是一种相对“离散”的搜索框架,你可以理解成它进行评估的位置实际上是很稀疏的。因此它适用于高精准度,但速度较慢的评估函数。
本文会对这两个方向的引擎算法都进行科普性质的讨论。两个方向相重叠的部分不多,你可以单独选择你感兴趣的部分进行阅读。
Alpha-Beta 方向
有关搜索优化
从最基础的想法开始 -- MiniMax
最基础的想法当然就是最简单的爆搜。不过这个爆搜应该如何实现,也许还是值得考虑一下的。
我们假设我们有一个给定的估值函数 Eval(GameState)
用来给出一个局面对先手方的评分,越高代表先手方越占优势,那么在先手方回合时我们当然想最大化这个值,后手方想最小化这个值,这也是其名字的由来。
一个最基础的 MiniMax
实现就是上述过程套上一个固定深度,如果深度已经为零则返回估值函数结果,否则枚举当前行棋方走法进行向下的迭代。当然在实际运用时,我们一般需要最优走法,所以你还需要记录一下它;同时正常的计算机博弈比赛都是限定时间而非层数,所以我们一般会在外面套一层迭代加深(每次把深度加一),考虑到搜索的指数性,就能发现其实这并不会增加多少时间开销。
这样的实现实际上是让双方都站在先手方的视角进行考虑;你可能已经敏锐地发现,如果将返回值变为“当前行棋方的最高分”,再加上要求 Eval
函数有对称性,即一个局面对先手的评分取相反数就可以得到局面对后手的评分(一般都会满足),就可以有效地简化代码。这样的改动被称为 NegaMax
搜索。这个修改并非结构性的,所以基本只属于个人习惯问题(NegaMax
在实现多线程时会方便一些)。
利用一下搜索树的性质
读者们应该会有一种预感,即由于我们只要求当前局面的最优解而非整个搜索树,应当存在一种无损的,从搜索树本身性质出发的,因此是普适的剪枝方法来优化最朴素的 mini-max
搜索算法。
这个东西确实是存在的,一些熟读 oiwiki 或参加过 thuwc2024 的同学应该也知道这个东西,它就是 alpha-beta
剪枝。
我们从一个“显然可以进行剪枝”的例子来看看这个优化的动机。我们现在在搜索一个 max
层,已经搜索好了一些移动,那么现在就有了一个对该局面估值的下界 min
层),而这个儿子在搜索完一些移动后就会产生一个上界
一些细致的读者也许会立刻意识到,其实如上的剪枝完全不只会出现在父亲儿子间,也有可能出现在跨越多层的节点间,所以我们应该尝试找到一个更一般性的剪枝理论。
alpha-beta
剪枝就是我们在寻觅的东西,而且它的原理其实很简单。
我们在搜索时额外传入两个值 alpha
与 beta
,意义是“如果该儿子的搜索值已经小于等于 alpha
或大于等于 beta
,则该儿子已经无用。
进行简单讨论,如果目前层为 max 层,我们会不断将 alpha
取 max,min 层则是不断将 beta
取 min,每次搜索儿子时传入该节点的最新 alpha
与 beta
。剪枝在哪里呢?每次搜索好一个儿子,如果已经有 alpha
beta
,则直接 break
即可。
alpha-beta
剪枝的形式是简洁优美的,且我们也不难验证它的正确性与无损性。它的效果非常强力,一般认为,它可以直接给分支因子开个根号(也就是将搜索深度乘二)。
alpha-beta
还我们带来了一份“小礼物”。这一点我们会在后续某个部分进行说明,容许我在此处先卖一下关子。
处理重复局面
在进入到下一个硬核算法之前,让我们先来一点点的幕间小菜。
这个东西是一个容易被想到的 "low-hanged fruit"。其动机很简单,我们应当有一个数据结构来处理搜索时遇到的相同局面情况(一般来说你的引擎水平越高遇到的这种情况就会越多)。
我们要处理的唯一工作就是找到一个易于增量,回撤,查询的哈希函数了。一般我们会使用 Zobrist-hash
置换表。换成 oier 容易理解的语言,这就是一种异或哈希。
假设你的棋一共有
别急,还有一些细节要处理。首先是大家容易想到的一个细节,假如你现在必须再搜索 alpha-beta
参数搜了一个局面得到一个结果,那么换成大窗口后你这个结果还正确吗?显然是不一定的。因此当将局面计入哈希表时,你还必须保证该节点并未被从外面带进来的 alpha-beta
窗口影响。(即 alpha < result < beta
)。
进行一点点的人类智慧
大家在思考“如何缩减搜索树大小”时,基本的第一印象肯定是,那些显然垃圾的着法就不应该被搜索。这个是对的,但是我们目前仍然在讨论无损的剪枝方法,所以表述应当被替换为:让显然垃圾的着法有着非常大的剪枝率。
考虑这个东西应该怎么实现。既然我们有显然垃圾的着法,那当然也会有显然更好的着法。如果我们在先前的“显然更好”的着法已经获得了一个很不错的评估分数,那此时再带着这个 alpha-beta
参数去搜索显然辣鸡的着法时的剪枝率就会非常高。
那么这个人类智慧就呼之欲出了。直接将所有着法按照(走这一步后的)评估函数进行排序即可(红方走就从大到小,反之亦然)。一般意义下,你的评估函数的质量越高,带来的剪枝力度就会越狠(实际上考量的是“当前的评估值”和“走一些步后的评估值”的接近程度),所以,如果你的评估函数巨烂无比,很可能这个玩意带来的甚至是负优化(因为要排序)。
以上方法被称为“静态着法启发”。其实我们还有各种妙妙小启发,基本原理和上面这个东西类似,都是试图把“可能有用”的着法先拎出来搜,这里再给出两个例子。
第一个是“置换表启发”。顾名思义,要用到我们上一章才讲了的置换表。如果现在表内有我的当前局面但是深度不够高,这个表就完全没用了吗?既然“静态着法启发”是根据走一步的评估值来排序,那么我们自然也可以根据置换表内的低深度评估值来进行排序,这样的效果显然会比静态着法启发更优秀。
第二个是“杀手着法启发”。这个略微复杂一点。如果在某个局面时搜了某个着法后直接触发了 alpha-beta
剪枝,那么它的兄弟们搜了该着法(如果存在)后也很可能会被直接剪掉。所以我们可以额外记录一个所谓的“杀手着法”,让后面的兄弟优先对它作搜索。
人类智慧是无穷的,所以“搜索启发”也是无穷的。但是对于每种棋类,一般都会有效果最好的“启发组合”,你甚至可以针对你正在开发 AI 的棋类做定制化的“启发”,这些就只有实战中才能学习得到了。
beyond alpha-beta
这个章节所阐述的算法被发明的动机是基于我们发现, alpha-beta
剪枝其实还有一些性质没有被完全利用,这也是之前我所说的它给我们留下的一些"小礼物"。
考虑执行一个带窗口的搜索 alphabeta(game,dep,alpha,beta)
和执行朴素的 minmax(game,dep)
的返回值差异。我们对 alpha-beta
自身的定义初衷(即小于等于 alpha
或大于等于 beta
可以直接剪掉)进行一个感性理解,能发现前者实际上就是对后者做了一个分段函数处理,也就是说,如果后者结果为
那么此时我们会想,既然 alpha
和 beta
都是可以自己定义的,我们能不能利用一些特殊的 alpha-beta
值组来做一些事情呢?
考虑所谓的“零窗口搜索”,即如果我们令 beta = alpha + 1
,那么根据这个东西的返回值我们就能知道其真实的结果与 alpha
的大小关系,且一次零窗口搜索一般是非常快的(基本都被剪掉了)。
对的,此时精通 OI 的你一定想到了,我们能不能利用这个玩意来做二分?恭喜你发明了 MTD-F
算法。
不过其实这个只是 MTD-F
的一个骨干思路,它还采取了一些额外优化,比如由浅一层的 MTD-F
先得出二分的大致界,对正负评分分别二分以减少在零附近的搜索开销之类,由它的全名 Memory-enhanced Test Driver
还能看出,这个算法的效率必须有高效的置换表作为支撑;此外该算法其实还有一堆细节要处理,因此虽然 MTD-F
可能比接下来要介绍的算法在性能上略高一筹(其实甚至都不一定),后一种算法细节更少,更便于实现,也是个不劣的选择。
这个算法就是 PVS(主变例搜索)
。我们先进行一个静态搜索评估或者置换表评估,反正就是给着法从可能优到可能劣排个序,把第一个着法当作“主变例”做一个正常的 alpha-beta
搜索。
对于后面的着法,我们提出以下疑问:“这会比我们的主变例更优秀吗?”,即先以目前的最优估值为零窗口搜一遍,如果它已经被证明了不如主变例,就不用搜了,否则它成为新的主变例,再重新做一遍完整搜索即可。
很容易看出,使用 PVS 算法后,搜索的效率就更加依赖于启发性排序的效果优劣了。因此不管从什么意义上讲,估值函数的设计都是制作一个强力的 alpha-beta
引擎过程中相当重要的一环,我们会在更后面的章节对其进行讨论。
提速--增量化与位棋盘
这一章我们来讨论一点不那么算法性,但也同样重要的问题 -- 提速。
不难看出,搜索的一个比较大的性能开销在于对叶子节点计算估值函数(个别棋类还需要涉及到落子处理,比如黑白棋,但大部分棋这一方面是简单的),那么有一个朴实的想法就是将估值函数变得“可随单步移动快速更新”,比如一个五子棋引擎可能会统计棋盘上所有长度为五的连续段并对所有可能出现的模式给予不同评分,那么进行一步落子后,只需要更新与新落子点有交的连续段即可。
我们有可能仍然不满于更新的效率。但是很明显,使用普通的数据结构来维护更新的时间瓶颈已经摆在这里了,此时位棋盘堂堂登场。
这个东西可能同大家熟悉的 bitset 还是挺相似的。我们仍然以上述的五子棋评估函数来举例子,我们直接对于棋盘的每一行每一列每一个正反对角线记录代表它们的两个 16 位整数(分别代表黑子白子集合),然后如果我们要统计某一行有多少个连三的黑子,就可以直接用 x & (x>>1) & (x>>2)
状物来搞定了;如果你想要统计的是“活三”(即两头都没敌方子的),也可以通过再与代表敌方子的集合做一些位运算来搞定了,然后统计分数就是一个 popcount
的事情,优化了 棋盘边长 = 15
倍常数。
在你动手去实现一个位棋盘前,还有一件事情必须要阐明,就是位棋盘在带来强大的加速的同时,其实也是在给你的估值函数的形式施加限制。比如不用位棋盘时你可以对每种长度为 5 的 pattern
都给出评估,但如果用了位棋盘技术,每种 pattern
的统计都要付出棋盘边长次位运算的代价,可能就得不偿失了。极为适应位棋盘的五子棋尚且如此,像象棋这种看上去就一点不“位”的棋,要在保持评估函数质量的同时令其适应位棋盘,就需要高度精巧的位棋盘设计方法和丰富的经验了。
突破水平线 - 启发式延伸
固定深度的 alpha-beta
搜索,其本质劣势实际上是一个被称为“水平线效应”的东西,即以对应层数为水平线,在这一个线之后的东西引擎就无法看到了,这就会造成短视。
这个效应在实战中经常体现为:引擎可能会通过不断将劣势局面往后推,来回避在当前深度发现的不利局面。比如,它可能会不断将自己的子送到对方面前,逼迫对方吃子,这样就能把真正的劣势推到搜索深度之外。这就是为什么我们需要一种机制来突破这个"水平线"的限制。
而 启发式延伸 就是对此的一个很重要(根据 Stockfish 源代码,这个优化价值 200 elo) 的缓解方式。
我们之前已经讲过了一个“启发式”,所以大家应该已经知道“启发式 XX“这种命名就代表着这个优化是没有一个统一的最优定论的。比如我们刚才提到的 Stockfish,它所采取的延伸标准就比我们下面要介绍的版本要复杂很多,所以我们这里只是讲一个”动机性“的极简版本。
先以象棋为例子,比如我们仅仅以当前子力价值为评估标准,那么如果我们走好这一步刚好碰到了层数上限,但是我们此时走的这一步送了个子(对手下一步才能吃),引擎就不会看出我们这一步其实是送子的大劣着。又比如如果这一步是兑子,我们吃了对手子对手下一回合才能吃回来,引擎可能就会误以为我们直接赚了对手一子。这两种所述情况当然可以通过评估函数的优化来解决,但与这些类似的情况其实是无穷无尽的(比如将军,或者将军抽子之类)。
所以我们考虑一个更本质的解法,遇到这种所谓“比较复杂”的局面,不妨直接多搜一层(令 目前的 depth
升高 1),直到局面相对平静下来为止。当然对于我们的极简版本,我们可以把“比较复杂”的局面定义为有吃子或将军的局面。又比如换成五子棋的话,我们可以定义比较复杂的局面为有冲三或冲四的局面。
能够多延伸固然是一个好事,但是也需要有所平衡,如果延伸的时间开销足够你全部多搜一层了,那这时它就成为了累赘。
摆烂的智慧 - 空着裁减
这个东西被称为“无需结构性更改就能获得额外速度”的天上掉馅饼优化,我们来看看它是什么一个东西。
在很多局面下我们会发现,对手已经无可救药了,以至于我们这一步直接空着不走都能够获得一个比较好的结果。这便是空着裁减的动机 -- 如果 skip
这一步得到的结果都能触发 alpha-beta
剪枝了,我们就可以直接返回。
你可能发现,如果空着裁减时调用的是 alpha_beta(dep-1)
,好像并没有明显的速度提升。但是我们这个时候其实完全可以激进一点 -- 我们都不走棋了,少搜两层怎么了?形式化来说,可以定义一个不小于 alpha_beta(dep-1-2*d)
。如果你对你的评估函数质量不是很自信,或者你搜索深度还不够深,1
,否则取 2
,3
甚至 4
都是可行的。
不过你先别急。敏锐的读者应该已经发现这个优化其实并不完全是“天上掉馅饼” -- 有些棋的某些局面中,不走反而成为了优势(比如中国象棋的困毙)。这个细节的处理可能是利用空着裁减过程中最为复杂的部分。专业引擎为了充分利用空着裁减的速度加成,一般会仔细设定“何时可以空着裁减”的决策表,但我建议你在编写时简单粗暴一些 -- 举中国象棋的例子的话,在残局前启用空着裁减,在残局时关闭空着裁减就是不错的想法。
提速的杂项
剪枝与优化是永无止境的话题,但碍于篇幅与本人水平,本文对 alpha-beta
路线提速方法的概述只能行至此处。这一章节会简单提及一些比较简短或不太好归类的搜索技巧。
期望窗口
不难发现在迭代加深过程中,对每个深度都进行完整窗口(即 (-inf,inf)
) 的搜索是有一些浪费的。我们其实有充分的理由相信,提高一层后的搜索估值并不会比降低一层后的估值偏差多少,因此设上一层返回的评估值为
算杀
在部分棋类(最为典型的,五子棋)引擎的制作中格外有用的一个部分。
其实可以被理解成大号的 单步延伸。它出现的动机是,传统估值函数对于五子棋的“强制杀”(即连续冲三冲四),尤其是较长步数的强制杀的预见能力是相当差劲的。(甚至更优秀的估值方法,例如我们后面会提及的 NNUE 都摆脱不了这一点)。
同时我们发现,在对这些“强制杀”的计算中,其实可行的步数并不多(必须每步都形成冲三冲四),所以我们完全可以在不那么损耗计算性能的情况下计算到一个相当深的深度的杀(实现的好的话,四五十步),这可以用来纠正搜索过程中存在的这一短视。
算杀的实现中,也有很多很多的巧妙技巧,有兴趣的读者可以到 github 上寻找 wine 或 rapfi 的代码学习,这里就不过深涉及了。
有损剪枝
在前面的篇幅中,我们一直都在讨论无损或几乎无损(即希望达到无损,如空着裁剪)的搜索优化方法。这不是因为我是完美主义者,而是 这一类的有损剪枝 几乎都不是普适的,而是针对目标棋类而设计的。
不过可能必须要承认,这一类的剪枝应该是一个正常人类对着一个具象棋类看到“优化搜索”这一任务时得到的第一反应,比如只保留静态评估前 alpha-beta
搜索后可能会立刻想到的,静态评估比 alpha
小多少或比 beta
大多少就立刻返回。
如果你要问这些东西有没有用,当然是有用的,不过我要强调的有两点。首先大部分的“朴素想法”必须经过精心的仔细考量与参数调整后加入你的引擎,才能起到提升棋力的作用,否则发生的情况几乎会是既没优化到速度,又让引擎漏算很多有用分支。其次,如果你真的认认真真把上述章节所提到的优化都加了进来的话,你会发现你想到的大部分有损优化实际上也不能提升多少速度 -- 事实上,Stockfish 搜索部分的所有有损剪枝加起来也占到 100 elo,而一个精心优化的启发式延伸就值了 200+ elo。
以下是一些个人理解。ab
系引擎的漏着主要来源于其天生拥有的难以根除的水平线效应(AlphaZero 可以从原理上缓解这一点)。此时若再过分地削弱自己在宽度上的无损性,导致的必然是得不偿失。
估值函数相关
虽然名字是 alpha-beta
,但估值函数的设计有可能是 ab
系列引擎设计过程中最为重要困难的部分。正如上面的搜索部分所提及的,只有拥有一个优秀可靠的估值函数,你才能放心地去进行一些激进的剪枝。
必须承认,要找到设计估值函数过程的“共性”其实并不是容易的事。存在不少评估方法对于个别棋类有奇效,对于其它棋类则效果平平或无法实现,因此如果你对某种特定棋的估值函数设计有很浓厚兴趣,推荐你寻找针对该棋类撰写的领域论文进行更深入研究。
手工估值函数 -- 设计要求与常见内容
毫无疑问,我们希望估值函数尽可能精准。但是这不能以速度的无限牺牲为代价。也就是,我们应该在“知识量-速度”二维曲线上找到一个最大化引擎棋力的点。
不过让我们暂时看看一个朴素估值函数的“知识量”可能指的是哪些东西。
子力。毫无疑问。
空间。比如先给每个棋盘格赋权,然后某一方占据或控制该格子就获得对应权值等等。
机动性。最朴素的想法就是局面下某方的可走步数。虽然听起来有些扯淡,进行足够多比赛后一方的平均可走步数如果比对方多,就说明该方某种意义上地占据了更主动的位置。著名国际象棋引擎联赛 TCEC 就存在某些平局情况使用机动性作为判决依据的规定。不过事实上这个东西在国际象棋中还没那么显著,相关性最显著的应该是黑白棋及变种。
当然你把这个东西写进评估函数时肯定不能这么朴素。
特定数学特征。如果你使用 alpha-beta
方法写过 THUWC 那个四子棋 AI,你会发现它在残局就是下不过 MCTS
。原因其实不复杂,一般人在设计四子棋的评估函数时大概只会像五子棋一样考虑连二连三个数这种事情,但事实上残局中这些因素作用反而很小,最重要的则是““控制”(剩余空格数为)奇数/偶数的列的个数”,而这些东西带来的优势同时又是很难被搜索所发现的(反而连三连二容易发现)。因此在此时加入人类研究的这种“棋理”就是很有必要的了。
威胁/牵制/保护 etc.. 这些东西都是象棋术语,并不普适。但是它们延拓的意思可以被理解为,己方子力与敌方子力的相互关系。
形状/图案 和“数学特征”其实可以被统称为“棋理”。比如中国象棋引擎 (曾经)普遍有以下两个盲点 -- 窝心马与空头炮。不太深的搜索难以发现其坏处,但长远来看它们会对运子产生严重的弊端,因此现在大多数引擎会直接对这一类“不好”的形状做罚分。
一个合格的估值函数大致就是根据速度需求对以上这些估值项目做取舍了。当然也许你还记得我们在前文提到的,如果你使用了位棋盘技术,那么还需要考虑到哪些项目是适应你的位棋盘形式的,哪一些是难以用位棋盘表示或会较大拖累速度的,那么这些就必须割舍了。
超参数优化方法?
这种东西其实就是纯纯的,盲人摸象,摸到什么有用就上什么。
靠人类经验手动调试 不用说了。
约束法缩小调整范围然后网格/随机搜索 类似于利用人类经验优化搜索。比如你认为一个车价值小于两个马,那么就约束马的价值大于车的价值的一半;或者你认为引擎宁愿亏一个兵也不应该形成某个糟糕棋型,这也是一个约束。
更大的超参数空间 + 遗传算法/其它最优化算法 有一点后面我们要提的 NNUE 算法的雏形了。你需要意识到人类的智慧终归是有极限的,我们总是要利用到一些黑盒的。不过此处暂时还只是黑盒,并没有丧失可解释性。也就是我们的“评估项目”仍然是具有可解释性的人工制定的产物,但是将具体细节大量交给可变的超参数。在 NNUE 出现前的较强引擎普遍使用的是这个算法。
其它神秘优化方法 盲人摸象可以摸出千奇百怪的结果。比如黑白棋有个很神秘的超参数设定法,你对于所有 只剩 3 个空格 的棋盘暴搜出必胜必败,然后再设置一些 pattern,你就拥有了一车 “双方各自拥有某个 pattern 集合情况下,这一方能够必胜” 的数据,对于这个东西做最优化的拟合即可。
最新成果 NNUE
看完以上这些乱七八糟的,你是不是会觉得这个玩意好像就是纯人类智慧,没有一点普适规律?
所以 NNUE 横空出世证伪了这个结论。光是原型版的国际象棋 NNUE 就把最人类智慧的朴素评估函数干掉了 200 个等级分,同时也使得 alpha-beta
系引擎仍然维持着对 alphazero
系引擎在个别棋类上的优势。
NNUE 是受 AlphaZero 对神经网络博弈算法的研究成果启发而生的。这个技术简单来说,就是我们抛弃传统的人造估值函数,同样求助于神经网络来进行估值;但是我们使用的神经网络与 Alphazero 的大网络有很大区别。它具有的特点,一个是很小,一个是具有“可快速更新”性(一般来说就是第一层很宽,后面很窄,这样可以直接对第一层打表,后面指令集之类的),还有一个就是需要进行高度人类智慧的特征工程(人为提取最可能有用的棋局特征,不能直接把整个棋盘扔过去让他自己找有用的用)。这样这个神经网络就适合我们 mini-max 宝宝体质了。不搜索单独使用训练出来的 NNUE 网络,能够发现它其实挺菜的,但是它高效的本钱在于它有着比起传统估值函数高得多的精准度与比起大型神经网络快得多的速度,这形成了一个平衡。
受限于篇幅与作者水平的双重限制,在此处并不能对这个算法做进一步深入。鳕鱼团队公开了其 NNUE 的开发文档,如果你感兴趣,可以进行进一步探究,然后来教我。
NNUE 是一个很厉害的成果。它改变了估值函数设计的"盲人摸象"状态。但是,这个改变是“如改”。因为此时网络的特征工程方法成为了新的一个需要人类智慧的地方。(虽然有一些经验可以借鉴,比如所有“杀王”棋都可以仿照国象 NNUE 的“棋子与王相对位置"进行设计)。同时由于 NNUE 的网络和平时使用的神经网络有较大区别,我们并不能通过现成的深度学习库来训练和加速 NNUE 网络,这无疑都提升了它的使用门槛。
那么什么是真正彻底的变革呢?我们马上要告别 alpha-beta
,来到 MCTS-AlphZero
的章节。
AlphaZero 方向
如果你突发奇想,设计了一种全新的完全信息棋类游戏。你简单口胡了一下,觉得平衡性不差,且挺有意思,你现在很想知道该棋的最优解大致是什么,一个很强的 AI 会下成什么样,此时你的第一选择会是什么?对于大多数棋盘或决策空间不小,对“大战略”有需求的棋类,这个选择永远是 AlphaZero
。
AlphaZero 最了不起的地方在于它兼顾了 延拓性 和 最大强度 ,这是十分令人诧异的。论延拓性,AlphaZero 一般不需要除了对弈规则实现以外的任何信息(针对特定棋类进行优化可能仍然有助于棋力提升,但这只是尖端棋力之间的微小差别罢了)。论最大强度,且不谈众所周知的围棋等 AlphaZero 拥有无可比拟优势的棋类,在 Alpha-beta
的传统阵地上 AlphaZero
同样能成为有力的挑战者,甚至远远超过之。最显著的例子(虽然没那么知名)其实是五子棋(尽管大家拿来练手写 alpha-beta
的第一个棋好像一般都是五子棋,但是采用 AlphaZero 原理的五子棋引擎 katagomo 的的确确将最强的 NNUE 甩了几百 elo)。
当然,这样一个伟大的成果自不可能是空中楼阁。所以在涉足它之前,我们需要先扫清其周边的算法基石。
朴素 MCTS
我们有无数种方法来证明,人脑在处理我们讨论的博弈问题时使用的方法不同于 alpha-beta
。例如从事实上看,存在一类人类能够处理而 alpha-beta
不能够处理的完全信息棋类。或者从直觉上,人脑像 alpha-beta
那样做地毯搜索看起来就非常难以实现。
但是差别在哪?
在国际象棋中,鳕鱼可以轻松地从当前局面向后算十几步;在慢棋中给定足够长时间,人类特级大师也可以算到这个深度。但两者的不同点在于,算到这个深度时,鳕鱼需要遍历近百万个节点(在未引入 NNUE 技术时,这个数字还要更大),而人类考虑的分支(变例)则不会超过两位数。
因此我们可以就此做出猜测,人类的思考方式更近似一种“偏离散”,对宽度上的分支做了高度剪枝的算法。
当然,“高度剪枝”的同时还能不会漏招满天飞,这体现的是人脑经过训练能达到一个很厉害的估值精度。这一点其实也是不证自明的,不然我们也不会用人类经验来编写传统 alpha-beta
引擎的估值函数了。
不过在这一个章节,我们暂时忽略后者,而注重于这一搜索方式本身。
朴素 MCTS
就是对这一思考方式的一种初步模拟尝试,让我们看一看它的四个步骤。
选择。从根节点出发,按照某一方法不断选择所谓"最有潜力"的节点,直到到达叶节点。
扩展。若到达的节点并非终止节点,对其所有走法扩展出叶子节点,随机选择一个进行下一步骤。
模拟。随机落子直到游戏结束,记录胜负结果。
回溯。使用胜负结果更新所有相关节点的信息。
“选择”阶段,原始 MCTS
的公式是最朴素的贪心,但是这样显然会导致严重的局部最小值风险,因此我们现在一般使用 UCB(上置信界) 公式: