关于AlphaGo和AlphaZero的消息已经不是新鲜事,似乎大家已经接受这样的东西的存在。这个“下棋机器人”的原理我也在下文中给出了简单的介绍。那么,到底这个“下棋机器人”是怎么运作的,我们就从本文开始,一起动手去实现一下它,帮助大家深入理解神经网络和强化学习。
一然:AlphaZero背后的算法原理解析zhuanlan.zhihu.comAlphaZero涉及以下几个关键点:
- 蒙特卡洛搜索树,一个数据结构,相当于“记忆体”,用来搜索策略,并且探索结果
- 神经网络,用来判断局势,选择可能的策略,并给出获胜的概率
- 强化学习,通过自己跟自己下棋,来训练神经网络,更新其参数,同时优化蒙特卡洛搜索树
我们先从容易的地方着手,来搞一搞蒙特卡洛搜索树。顺便提一句,为什么我选择了Golang,一个是个人兴趣,一个是想用底层一点的语言来让大家看清楚细节,同时也想在Java、Python横行的时候给大家一点别的选择。并且,我不想用TensorFlow,虽然它真的很强大很不错,但是会选择Golang家族自己的机器学习库,也是为了带来一点新鲜感。
闲话少说,进入主题。
蒙特卡洛搜索树是2006年左右由 Rémi Coulom 等人提出的,当然“这棵树”的诞生是为了搜索。众所周知,在策略类游戏中,我们总是要预先考虑未来发生的状况,从而在现在选择较好的策略去应对,在极端状态下,当然是穷举所有的未来情况,一一给出应对策略,然后再选出一个最好的。不过这样做事不切实际的,因为搜索空间太大了,可能的情况太多了。这也就是为什么我们在博弈类游戏中感觉很累的原因:我们不可能穷尽那么大的搜索空间再给出策略。
这样,我们自然会想到,在不穷尽所有可能性的情况下,能不能给出比较好的策略呢?换句话说,有点像”走一步,算一步“,发现好的策略就记住,就很像是动态规划。基于这样的思路,有人找到了蒙特卡洛方法,这个方法基本就是”走着瞧,边看边算“,而把这种方法用在博弈类游戏上,自然就会想到树形结构嘛,于是就搞出来一个蒙特卡洛树,这棵树是用来搜索策略的,就有了蒙特卡洛搜索树。相对应的搜索方法就是蒙特卡洛树搜索法。
同时这类策略性游戏也很多,例如围棋,中国象棋等等,我们这次用AlphaGo来玩中国象棋。
如上图,我们这棵简单的树也是一棵策略树,用来搜索策略,它每个节点表示一个状态,从一个节点到另一个节点,表示状态的转移,也就是选择了一个策略。这棵树从根节点开始遍历,到达叶子节点的时候,也就是一局棋下完了,会有一个结果,从结果,我们就可以评判这个策略”路径“选择得好不好。
前文已经述及,这样一棵策略树的主要问题就是遍历(搜索)的空间太大了,没办法穷尽所有可能再做决策。虽然历史上有剪枝算法并且可以控制树的深度,但是那都是历史了,不做介绍。那么,对于如何选出最好的下一步策略,蒙特卡洛树就给出了一个相当简洁的答案:对于某一个节点,选最优秀的儿子节点,没有儿子的时候就创造一个儿子节点!
What?!简直没说一样,能知道谁最优秀,还整这些个干什么。对了,不知道哪个儿子节点最棒,那就找个明白人问一问。那么谁算是明白人呢?好了,假设大法搬出来,假设有一个神经网络明白哪个儿子节点最棒,问问这个网络不就行了。可是问题又来了,开天辟地之时,棋盘初立,两军对垒,神经网络里面的参数一片空白,怎么可能知道谁优秀呢。答案更简单了,随便选,选了看结果,把结果记住,下次不就知道好坏了。
上面这个过程提炼一下就是,策略树为构成 的时候,不知道选什么策略,就问神经网络;神经网络未经训练的时候,就给随机值;蒙特卡洛树得到结果后,就生长出一个子节点;但是每次出了结果,有了输赢,就反馈给策略树和神经网络,让这个系统无穷无尽的运转下去,就有了优秀的策略树和神经网络。非常简单吧。
下面来点真格的内容。
对于每一个节点,都代表一个策略,都设立一个积分q,分值越高表示这个节点所代表的策略越是优秀,同时设立一个访问次数n,代表这个节点被访问的次数。这时候就有,在选择儿子节点的时候,我们的原则是:分值高的并且访问次数少的。用数学表达一下就是:
上式中,q是分值,n是访问次数,
c是什么?是一个折扣因子,代表第一项和第二项的权重,也就是地位不一样,后面的要打个折扣,免得都去选访问次数少的策略,而陷入无休止的探索中。
忍不住为这个公式上一段 Golang 代码来实现一下:
func (curNode *mcTreeNode) chooseBestChild(c float64) *mcTreeNode {
idx := 0
maxValue := -math.MaxFloat64
var childValue float64
for i, child := range curNode.children {
childValue = (child.score / child.visitCount) +
c*math.Sqrt(math.Log(curNode.visitCount)/child.visitCount)
if childValue > maxValue {
maxValue = childValue
idx = i
}
}
return curNode.children[idx]
}
下面介绍棋局结束以后反馈的过程。某一局棋结束以后,我们总要把结果反馈回去,告诉蒙特卡洛搜索树本次一系列的选择好不好。那么方法也是非常简单的,就是把结果的分数(例如胜:50;负:-50;和:0)逐个累加到路径上的各个节点上,直到根节点;同时把这条路径上各个节点的访问次数也加一。代码实现如下:
func (curNode *mcTreeNode) backPropagate(result float64) {
nodeCursor := curNode
for !nodeCursor.isRoot() {
nodeCursor.score += result
nodeCursor.visitCount++
nodeCursor = nodeCursor.parent
}
nodeCursor.visitCount++
}
有一点好像落下了,那就是如何”生出“一个儿子节点:
func (curNode *mcTreeNode) expand() *mcTreeNode {
child := getNewNode(curNode)
curNode.addChild(&child)
return &child
}
这样就有了这棵树的基本模样了,可以开始遍历了:
func MonteCarloTreeSearch(simulations int) {
root := getRootNode()
var leaf *mcTreeNode
var result float64
for i := 0; i < simulations; i++ {
leaf = root.treePolicy()
// TODO: calculate the result
result = 50
leaf.backPropagate(result)
}
}
一个大大TODO写在那里,意思就是想要计算出结果,是要花点力气的,要给予棋盘现在的状态和下棋的规则,给出一个判断,本次实现中暂且略去。
接下来就是最后一个问题,虽然蒙特卡洛树是”边走边看边想“的,也是在搜索中慢慢生成出来的,但是,何时结束搜索,仍然是一个问题,因为如果搜索规模太大,可能导致每一步棋思考时间太长,如果搜索规模太小,也许结果太粗陋。综合上述情况,一般是把搜索规模做成可以配置的项目,让每一次搜索都在机器硬件资源可以支持的情况下最大化。
最后我们给出比较完整的代码:
package alphazerochinesechess
import "math"
const discountParamC = 1.4
type mcTreeNode struct {
parent *mcTreeNode
children []*mcTreeNode
score float64
visitCount float64
chessBoard [9][9]int8
}
func MonteCarloTreeSearch(simulations int) {
root := getRootNode()
var leaf *mcTreeNode
var result float64
for i := 0; i < simulations; i++ {
leaf = root.treePolicy()
// TODO: calculate the result
result = 50
leaf.backPropagate(result)
}
}
func getNewNode(parent *mcTreeNode) mcTreeNode {
node := mcTreeNode{parent: parent}
node.children = make([]*mcTreeNode, 0, 0)
node.score = 0
node.visitCount = 0
return node
}
func getRootNode() mcTreeNode {
return getNewNode(nil)
}
func (curNode *mcTreeNode) addChild(child *mcTreeNode) {
curNode.children = append(curNode.children, child)
}
func (curNode *mcTreeNode) isRoot() bool {
return curNode.parent == nil
}
func (curNode *mcTreeNode) chooseBestChild(c float64) *mcTreeNode {
idx := 0
maxValue := -math.MaxFloat64
var childValue float64
for i, child := range curNode.children {
childValue = (child.score / child.visitCount) +
c*math.Sqrt(math.Log(curNode.visitCount)/child.visitCount)
if childValue > maxValue {
maxValue = childValue
idx = i
}
}
return curNode.children[idx]
}
func (curNode *mcTreeNode) backPropagate(result float64) {
nodeCursor := curNode
for !nodeCursor.isRoot() {
nodeCursor.score += result
nodeCursor.visitCount++
nodeCursor = nodeCursor.parent
}
nodeCursor.visitCount++
}
func (curNode *mcTreeNode) popPotentialAction() {
// ask CNN for one action
}
func (curNode *mcTreeNode) expand() *mcTreeNode {
child := getNewNode(curNode)
curNode.addChild(&child)
return &child
}
func (curNode *mcTreeNode) isFullyExpanded() bool {
// ask CNN if there is another plan
return true
}
func (curNode *mcTreeNode) isTerminal() bool {
// check if the chess is finished by rules
return true
}
func (curNode *mcTreeNode) treePolicy() *mcTreeNode {
nodeCursor := curNode
for !nodeCursor.isTerminal() {
if !nodeCursor.isFullyExpanded() {
return nodeCursor.expand()
}
nodeCursor = nodeCursor.chooseBestChild(discountParamC)
}
return nodeCursor
}
上面的代码并不完整,因为对于整个这个AlphaZero下象棋的问题,我们的实现才刚刚开始,还缺失关键的一些数据结构,比如棋盘的结构,神经网络的构成,蒙特卡洛树和神经网络的通信体等等,都需要在后续的过程中逐一完成。
可以参考上篇文章一起看:
一然:AlphaZero背后的算法原理解析zhuanlan.zhihu.com以上仅供大家参考,祝大家工作学习愉快,多谢啦 :0