构建自己的AlphaGo

新的旅程

目的

我们的终极目标非常简单:那就是做一个自己的AlphaGo!

为了让我们的工作更简单些,我们把对弈的对象改成五子棋。由于五子棋的问题空间小上一些(五子棋棋盘15x15,围棋19x19),这可以让我们不用拥有DeepMind那么烧钱的硬件也能训练出能看的AI,同时也会出现一些领域相关的小细节和挑战。最后我们会去在线游戏平台上面看看它到底水准如何。

这篇半教程半工程日志的读者可以是没有任何机器学习基础的计算机学院学生(因为至少你得看得懂人话和Python代码,对吧?),我们会从最简单的井字棋游戏和深度优先搜索一直介绍到由卷积神经网络和蒙特卡罗搜索树构建的AI。半途中可能会有一些概念需要你放下手头的工作先去读读其他的教程,因此这可能是一段比较长的旅程……不过,千里之行始于足下。

让我们开始吧!

完全信息博弈

了解问题是我们解决问题的第一步。

首先,我们的研究的五子棋游戏有一个很明显的特征,它是回合制的(turn-based),也就是说我们走一步,对方走一步,嗯,这很好理解。

其次,五子棋游戏还有这样的特点:盘面任何时候都是为双方所知的,两边棋手的每一个动作也都被双方清晰地看到,所有的信息都是共享的;另外,也不存在随机事件,譬如说丢个骰子或者用一把随机上了三发子弹的左轮往自己脑门开一枪之类的,一切都是明确的。这种游戏被称为“完全信息博弈”(perfect information game)。当然,我个人更喜欢“信息完全博弈”这个译法——信息对于游戏者来说是完全的。

像是井字棋(TIC-TAC-TOE)、五子棋、象棋、围棋,都是这样的游戏。

(如果你不知道井字棋是什么我建议你赶紧Google一下)

状态树

对于完全信息博弈的回合制棋类游戏来说,学过代码的人马上就能想出一个最朴素暴力的做法——为游戏构建一个状态树。具体来说,根节点是空白(或者初始)棋盘,每一条边代表一个合法的走法,由此达到下一个节点,这样每一个节点就是一个游戏的“状态”。

对于井字棋游戏,这颗树的一部分可能是这个样子:

我们可以利用深度优先或者广度优先来探索这一整颗树,这就导致第一层子节点有九个节点,第二层中,每个第一层节点可以伸出八个节点,也就是一共72个……

这样依次下去,可以得到整个游戏的搜索树。接下来,我们只要根据一些方法(比如看子孙中叶节点胜负的比率),来选取一个最好的走法就可以了。这种做法叫Minimax。

新问题,新方案

本节大量参考Introduction to Monte Carlo Tree Search by Jeff Bradberry

传统方法的不足

当然了,这样暴力的搜索很明显是指数级的,在井字棋这样的游戏上或许行得通,但是问题空间一大上去就不行了。这就要求我们使用各种各样的高级策略比如深度限制、剪枝,或许还有神奇的问题转换、动态规划、状态压缩?

好吧好吧,我知道你要说什么。如果咱们有这个脑力,早就去ACM Final拿冠军了好吗。事实上,利用hard-code的策略和超级计算机,代码天才们确实能做到这一步(比如你熟知的“深蓝”系统和大部分的游戏AI)。然而在像围棋这样近乎无限的策略空间下,再怎么苦思冥想也很难得出高效地捷径了。另外就是,写策略需要很强的领域知识,而围棋本身的复杂性导致其是一个很讲“感觉”的游戏,有些规矩根本说不清道不明。

到这里,我们需要一个算法,让我们能除了了解游戏规则以外,不依靠任何的其他知识,就能窥探到游戏策略的一隅(嗯,有点儿机器学习的意思了)。

上限置信区间

让我们来假设你是一个天性好赌却逻辑清楚的赌徒,有一天你走进了一家赌场,那里有很多老虎机。每一台老虎机的胜率都是不同但是衡定的。作为一个理智的赌徒,你想要最大化的你的收益,你该怎么做?

首先你想到的是玩每个老虎机一百次,估计出一个百分比胜率——可是那样成本太高了。显然,你需要一个更好的方法,既要考虑不要太过于广泛地探索机器以至于浪费游戏币,又不要太专注于一台老虎机而错失了其它有更高赢钱几率的机器。

有一个概念,叫做“上限置信区间”(Upper Confidence Bound,简称UCB),这个精巧的数学公式通过平衡你尝试一个机器的次数和你在这些次数中赢的钱,来决定你下一次应该使用哪台老虎机。

其中各个参数的意义是:

$\bar{x}_i $:机器$i$的平均回报

$n_i$:试玩机器$i$的次数

$n$:试玩的总次数

我们的策略就是下一次选UCB最高的那个机器。动用起高中数学的知识就能发现:对于每一台机器,如果试玩的次数一样,那么我们倾向于选平均历史回报最高的那个——很合情合理。随着对于一台机器的选择越来越多,右边的那一项逐渐减小,总会有一个时候,另一个机器的UCB超过了先前试的那台,于是我们便选择新的优胜者——这便是探索。

假设你开了作弊模式,一下就找到了并一直在玩最优的机器(而且没被老板打死),得到了收益WA;而UCB算法下你的收益是WB。我们把WA-WB,也就是你和最优值之间的差距,这个数字叫做regret。可以数学上证明,regret的增长率是$\mathcal{O}(\ln n)$——还不赖,对吧?我是说如果你没有ACM复杂度分析恐惧症的话。

事实上,从$\mathcal{O}()$的级别上来说,这个复杂度是理论最优的。

蒙特卡洛树搜索

了解了上面的UCB算法,我们可以开始介绍蒙特卡洛搜索树(Monte Carlo Tree Search,MCTS)算法。

蒙特卡洛算法本身是一种随机统计算法,在这里我们不去深究。但是我想你心里有一个问题:为什么这个算法叫蒙特卡洛呢,听起来像什么拗口的法文。其实,这是因为蒙特卡洛大赌场是世界上最著名的赌场之一。:)

闲话少说,我们先来介绍一下MCTS的四个基本过程:选择、扩展、仿真、反向传播。

嗯……其实一开始看到我也不知道这玩意儿是几个意思。不如我们回到最开始的那个主意——把UCB算法和树搜索结合起来,看看怎么引入蒙特卡洛树搜索这个主意。

我们拿8x8的五子棋作为对象:

一开始,你有一个根节点:空棋盘。

下一步,你想要决定在这64个位置中找一个地方下第一颗子,而且你还想要运用UCB,可是你发现一个让人头疼的事情:每一个子节点的$n_i$都为0,这导致UCB公式失去数学意义(除0)。

没有办法,你只好随便选一个位置,譬如说,(0, 0)吧。你在那里放了一个黑子。

新的问题又来了(真是命途多舛)。你只是在一个空棋盘上下了一步棋而已,不像老虎机有一个即时的回报,你根本不知道这个动作给你带来了什么。于是你想出了一个绝妙的主意——我可以把这个残局交给两个AI,让他们迅速对弈,得出这一盘的胜负。如果是黑棋赢了,那说明统计学上来说,这一步还是有它的好处的。反之则反。此时,假设黑棋赢了,你得到如下结论:$n_i=1$,$n=1$,(0, 0)这个点的$x_i=1$。

你满怀欣喜地给剩下63个位置用了同样的做法。至此,所有的子节点都有了统计学意义。然后,你运用UCB算法,选出一个最好的点(有相同的UCB值就随便选一个就行,下一次它的UCB便会降下去)。

选出了第一层的走法之后,你当然知道第二层还有同样的问题。不过,你可以对第二层做同样的事情,也就是把刚刚选出来的第一层子节点作为根节点,看成和上面一样的问题,继续往下做。当然了,也不要忘了,当下面的层数获得了一次模拟胜利,不仅它自己,它上面的一路下来的所有节点的$x_i$都要增加,毕竟他们也为这次胜利做出了贡献。

反复如此,到一个特定的阶段,这一颗树已经枝繁叶茂,每一个节点的数据也非常具有统计学意义,我们的AI实力也就提升上去了。最后,由于探索了很多遍,我们假设当前盘面的所有子节点都得到了合理的探索,于是,从这些子节点中选择胜率最高的一个,返回。这就是整个搜索和决策流程。

好了,接下来,我们把这个过程抽象成蒙特卡罗树搜索的四个过程:

1.选择

从当前的搜索树中找到你当前的状态,作为根节点。对它的子一级计算UCB值。选择最大的那一个。对选择出来的那一个采用递归的做法继续往下一级找。

2.扩展

选到了一个叶节点。这个叶结点的子节点没有具有数学意义的UCB值,于是对他们全部(这里只画了一个)进行第三步和第四步。

3.模拟

采用已有的一些经验算法进行结果的模拟。(甚至可以随机下法,毕竟只要有统计学意义就可以。)

4.更新(反向传播)

将对弈的结果更新到整个路径。

至此,子节点全部具有统计学意义。继续扩展该搜索树。

当然,在第二步的末尾,你也可以真的只扩展一个点。毕竟,扩展完这个点并反向传播之后,下一次模拟可能不再进入这条路线,而进入了一条老路线,这样能够帮你省下一些计算时间,同时也有实现上的好处。

细节与讨论

向下走一层 vs 向下走N层

在这个问题上,我吃了很大的亏……

考虑我们的第一步,选择。一个很令人难受的问题是,往下面探索到第几层?可能光看上一小节的介绍,你甚至不容易意识到这个问题——不是说探索到叶结点为止吗?

然而如果你已经自己迫不及待地开始写代码了,你可能就会觉得有点儿难受了。假设你用了上一小节最后一段的技巧,每次只扩展一个节点吧。不限制深度,意味着几乎每一次(除非你能深到一个出现胜利者或者棋盘被占满的位置,这在前期几乎是不可能的)都扩展了一个新的节点。在我的实验中,8x8的棋盘,五子棋,一万次模拟的棋力和我水平差不多。那么,也就是说,机器每次下一步,都扩展出了一万个节点!但是,这些节点有多少有用呢?

假设根节点是初始棋盘,有64个子节点,等它做完决定,棋盘上还剩63个点位,如果MCTS-UCB的组合运行正确,那么我们探索的总是那些平均回报偏高的点位。轮到对手下棋了,可是谁知道人类对手下子和UCB值有没有关联呢?一来,我们的模拟次数可能不够,只能一定程度上反映落子的好与坏;二来,就算我们算得很对,对手也可能下一着总体回报只是中游的子。两个变量结合起来,结果就是总的来说,我们的第二层探索有效性估摸着也就1/63,大量的节点在对手落子后会被抛弃,而维护这些节点的空间成本(我用了一个字典)以及每一次向下时探索计算UCB、找子节点的时间成本就显得真的是得不偿失了。

保留树 vs 不保留树

可能有的同学觉得,这也不一定吧!对于这一局来说,好像确实是废了,但是对于下一局呢?不是可以把这些节点再利用起来吗?这也确实,问题是,对于前面几层节点好像确实如此,但是对于中盘的节点呢?下了个几十个子之后,一个节点有历史数据的可能性分母沿着指数曲线上升了几个等级,几乎不可能了。

或许在基于MCTS的围棋实现中,利用C/C++这样的高效语言,和查并集这样的方法,我们可以预训练这个AI,很紧凑地存储大量的盘面历史信息,一次性把他们读到内存,然后结合hash之类的方法快速存取,使得历史树的重用可能性增大?笔者也没有实践过,希望有经验的同学指教。

指导实现

总的来说,结论有两点:1.每次下棋前,放弃历史树。我们不预训练这个ai,撞上同样状态的可能性太小了;2.只向下探索一层,直接开始随机对弈,统计结果,避免维护大量的无用节点。这放弃了维护节点和(很小的)历史优化,毕竟我们只是搞通原理,而不是要在具体细节上弄得太优秀。

五子棋实现蒙特卡洛树搜索

这一节我们来用自己的代码实现基于MCST的五子棋AI,不加任何与五子棋领域相关的tricks,到这里可能工程日记的性质会强一些,对具体实现完全没有兴趣的同学完全可以跳过。

思路

先讲一下实现的逻辑。

首先我们会实现一个Game类,用来控制游戏流程。Board类,用来控制盘面的状况。Player类,是”玩家“的抽象。AI类和Human类是具体的玩家,继承Player类。Record类,用来维护状态树。Node类,是Record树中具体的节点。

当轮到AI下棋,AI先清空状态树,接下来便接收一个当前盘面的state数组,然后在一定的时间内(称为think_time)进行蒙特卡罗树搜索;时间到达后,选一个胜率高的子节点。

这里我只贴一下核心代码,具体的可以在github repo看到。

AI类的部分代码:

'''...'''
    def act(self, think_time=30):
        '''返回一个下棋位置action'''
        action = self.mcts(think_time)
        return action

    def mcts(self, think_time):
        self.record.clear() # 清空状态树
        current_root = Node(self.board.state)
        self.record.add(current_root)
        current_root = self.record.find(current_root)
        # 把现在的盘面状况作为根节点,插进树里面

        board = Board()
        board.set_with_state(current_root.state)
        legal_action_list = board.legal_list
        # 把现在的盘面复制一份,然后得到所有可以下的位置
        # 通过这些个位置得到所有可能的子状态
        possible_children = []
        for action in legal_action_list:
            board.set_with_state(current_root.state)
            player_id = self.check_player(board.state)
            board.update(player_id, action)
            child = Node(board.state)
            possible_children.append(child)

        for child in possible_children:
            self.record.add(child)
            child_inside = self.record.find(child)
            tmp_board = Board()
            tmp_board.set_with_state(child_inside.state)
            favourable = self.simulate(board)
            if favourable:
                child_inside.win_i += 1
            child_inside.n_i += 1
            self.record.n += 1
        # 直接先对所有子状态都模拟更新一遍,省得再出现分母为0的子状态

        self.simulation_count = 0
        start_time = time.time()
        # 主循环
        while time.time() - start_time < think_time:
            one, _ = self.select(current_root)
            #select找到UCB最大的节点
            self.simulation_count += 1
            tmp_board = Board()
            tmp_board.set_with_state(one.state)
            favourable = self.simulate(tmp_board)
            if favourable:
                one.win_i += 1
            one.n_i += 1
            self.record.n += 1
            #模拟更新一次

        children = [self.record.find(node) for node in possible_children]
        value, node = max(
            [node.x_i, node] for node in children
        )
        # 找到x_i最大的
        diff = (np.array(current_root.state) - np.array(node.state)).tolist()
        print(diff)
        action = diff.index(min(diff))
        # 通过状态差反推action
        print("simulation_count:", self.simulation_count)
        return action
    def simulate(self, board):
        copy_board = copy.deepcopy(board)
        current_player = self.check_player(copy_board.state)

        # 模拟
        over, winner = copy_board.check_game_process()
        player_id = current_player
        while not over:
            action = random.choice(copy_board.legal_list)
            copy_board.update(player_id, action)
            over, winner = copy_board.check_game_process()
            player_id = self.check_player(copy_board.state)
        # 模拟

        # 传进来的是一个子状态,也就是轮到对方下子,如果模拟到最后对方(current_player)没赢,那就对我有利返回True,反之False。
        return winner != current_player
'''...'''

在第8行Board类里面可以调整棋盘宽高和几子连珠胜利;302行左右的think_time参数可以调整AI思考时间。

实验结果

这个程序放弃了历史状态和深层MCTS,而且没有对五子棋这个游戏做任何的领域知识优化。在我的实验里,8x8的棋盘,五子棋,基本上思考时间90s他可以完全不负于我。……好吧在一个这么小的棋盘上下五子棋还要跟围棋选手似的想上一分半钟可以说是非常智障了。

令人欣喜的是,我们验证了MCTS这一算法的有效性。这何以令人兴奋呢?一个重点是,我们完全没有对五子棋做任何领域相关的编码,也即是说,没有告诉AI任何人类知识,除了五子棋规则——我反复强调这一点,是因为MCTS由此成为了一个框架,它将可以被应用于象棋、围棋,或者文明6。而到时候再根据领域知识去优化它,就是一件很顺水推舟的事情了。

展望未来

具体来说,上面我们用的方法叫Upper Confidence bound applied to Trees (UCT),是蒙特卡罗树的一个类型,因为它用了UCB来进行下一次测试的选择。

再回想一下UCT的主要工作过程:选取一个UCB最高的值,进行随机对弈,得到一个结果,更新树,重复。

这里有几个让人遐想的点,首先就是UCB。为什么是UCB?我们论证过UCB在数学上是有效的,但这毕竟只是个概率公式,有没有更好的办法?比如说,或许我们可以考虑一下人类对手倾向于下哪些位置,然后优先往那里发展发展?这样,假如说我算力非常充裕,或者在人类棋手下棋的时候(如果他想上三分钟那我简直浪费不知道多少算力啊),就可以向预测的局面方向先探索个两三层,这样可以使下一次的探索得到更好的结果。

更让人觉得有爆点的是随机对弈,随机对弈?这也太随便了!就不能至少弄点指导算法吗,比如五子棋,谁会在棋盘中间鏖战的时候去(0, 0)这个位置下一子?能优化的地方这里很大嘛。

确实,利用人类知识,我们可以改进这两个点,在UCB的过程中,我们可以去和海量的人类棋谱对比(有些象棋程序是这么做的),比对到了的话直接返回一个好的决策;我们还可以指定一些基本的规则,虽然我们不能明确的说明一些局势走法,却可以给不同的地方加上不同的人为值,使模拟过程大大缩短,有效性大大提高,等等。

不过等一等,这不是又回归人力了吗?嗯,少量的人类知识似乎是不可或缺的。但是一个好消息是,利用机器学习,我们能够仅仅通过大量的训练,就达到上面说的效果。事实上,AlphaGo正是利用两个卷积神经网络(CNN)代替了UCB和随机对弈,从而达到了天下无敌的水准。你总是在新闻里听到的那两个名词——策略网络(policy network)和估值网络(value network),即将粉墨登场。

AlphaGo的进步

改进UCT

还是那句话,了解问题是解决问题的第一步。让我们回想一下上一节最后一小节说的两个改进点。

第一,UCB。在基于MCTS(UCT版本)的五子棋AI中,UCB值解决的主要问题是:下一步我该探索哪一个点?通过平衡历史数据中尝试某个点的次数,和这些次数的平均回报,UCB公式算出一个估量值,然后我们选取估量值最高的。

UCB的问题是,这个值完全是基于随机统计的。这就导致我们没有办法高效地向下探索状态树,基本上往下探索的都是废数据,所以我们就只好探索一层。

第二,随机对弈。在模拟中我们是随机对弈的,这导致数据有效性很低,需要模拟成千上万次我们才能得出一个较好的结果。另外,模拟的复杂度增长是爆炸性的,15 x 15的标准五子棋和8 x 8根本不可同日而语,这就要求我们使用更高效的语言、并行的MCTS、并行的计算机……等等。

话又说回来,这些围棋AI都做了,还是只有业余水平。虽然对五子棋或许可以解决,不过毕竟在追随AlphaGo的脚步,让我们假设我们也和围棋专家们面临着一样的问题。

做个白日梦

不少好主意都从畅想开始。

不知道你有没有看过《棋魂》?平安时代的天才棋士藤原佐为附身到二货少年进藤光身上,然后带着他下围棋,吊打同年的天才小朋友,甚至吊打各路职业选手,好好装逼了一把。

我们顺着这个思路想下去,得到一个思路A:如果我们的AI身后也有个藤原,那不就天下无敌了吗?可惜,好像没人能做到这个……废话,这不就是为什么我们要想各种各样的搜索和指导算法嘛。

不过我们可以接受一个弱一点的思路B:假如说藤原佐为这个人吧,他非常地有逼格,教你下棋这种事情他是不干的。不过呢,他愿意看你和现代人下棋,于是给了你一个回报:他会告诉你,对于一个指定的谱面,对方下一步有可能会下哪里。我们拿到了他的预测之后,我们可以修改UCB公式,给这些可能的点位一些较高的加成,这样,如果藤原是对的,那我们就能把计算资源集中到探索那些点位。

嗯,很棒,对吧?我们继续,思路C:藤原作为世纪棋手,那见得可多了,它随手拿起一个盘面,就可以判断出,这盘棋我看黑棋是要输。当然了,你隔壁的老大爷也常常这么做,问题是,藤原预言的那基本都对。一个激进的想法是,还要什么MCTS啊!我直接这样,把每一个可能下子的点都试着下一下,然后这n个谱面我全部去问藤原,我胜率多少?然后我选一个最高的。这个问题就完事儿了。

神经网络和CNN

好消息是,这个想法不是不能实现;不仅如此,我们还可以进行一些改进。首先,我们要谈一谈神经网络。

你或许对机器学习和神经网络已经有了一定程度的了解,不过如果不懂,你也可以把这种算法当成一种黑盒子。

这个黑盒的做法是这样的:给定一定格式的输入数据(比如棋盘谱面),一定格式的预测数据(比如,下一步人类最可能下哪里,或者,这个谱面黑方的胜率);算法构造一个非常非常复杂的函数,将输入扔进去,得到一个输出,并看看这个输出和真实值的差距是多少,根据这个差距,算法精心地调整上文所述复杂函数,使输出更加接近真实值;反复这个过程,通过大量数据的输入和预测以及调整,使基本上所有的输入都能得到一个与真实值差距不大的输出。

神经网络是这么一种算法,而卷积神经网络(CNN)则是这种算法的升级,它充分利用了二维图形的一些特征,能够对图片有很好的学习性。

对于网络围棋对战平台上的大量数据,如果构造一个结构合理的CNN,那么在一定程度上,这个CNN会将它的那个“复杂函数”的参数调整到一个很微妙的值,以至于能够比较合理的预测我们想要的值(我不是说很高,只是合理)。

很遗憾,机器学习是一个大的学科,这篇博文不可能完全谈完它。不过网络上现在有很多相关资源,如果你不了解机器学习又对于捏着一个不了解的强大方法走下去感到不安,你可以先去学学那些东西。

不过如果你感到OK,或者你对机器学习已经有了简单的相关了解,那就让我们捏着CNN继续走下去吧。

CNN和思路ABCD

思路A是一个很自然美好的想法——训练这样一个CNN,给一个棋盘输入,直接输出一个AI应该走的位置。这种思路被称为“端到端”的(end-to-end),即是说,任务的输入就是CNN的输入,任务的输出也即是CNN的输出。这种做法在图像分类等领域已经大有成效(接受一张原始图片,输出它是猫还是狗还是人),遗憾的是,由于围棋的复杂性,end-to-end的网络效果不怎么好。更重要的是,不要忘了,你只能从标记好的信息里面学习东西,如果你拿大量人类棋谱去训练,最后就算你得到了一个训练好的CNN,那也只有人类的中等水平。(RL这些概念我们暂时不谈的话)

思路B是比较容易实现的——输入一个谱面,输出一个人类下一步走棋的位置。这个可以用大量的人类数据来训练,得出来正好是我们想要的——告诉我们人类对手下一步会下哪里。前提是有大量的数据,幸好,DeepMind从网上搞来了大约16万局6至9段人类选手的围棋对弈棋谱,一共三千万个谱面,数据量基本不是问题了。

思路C的实现有些难办,毕竟你很难拿到大量标好胜率的谱面,是吧?有个比较暴力的做法,如果一盘棋黑棋赢了,那么这一盘棋所有的谱面都被标为对黑棋有利。很可能,在前期实际上根本就是可能性一人一半,在中间也可能出现三十年河东三十年河西的情况,这种标记就是错的。不过,假设我们有很多很多的对弈数据,那么,那些局面很焦灼的盘面,在总样本中被标记为黑有利和白有利的数目也是一半一半,训练会是一个扯来扯去的过程,但结果也还就是一半一半。对于其它的局面同理。因此,如果你有天量的数据,这个做法还是可行的。

还有一个隐藏思路D:我们可以预测,光用C代替MCTS的效果可能不会太好,那么我们还是要进行MCTS。我们可以不用随机对弈,而用一个上面训练出来的人类行为预测网络去下棋,这得出来的数据有效性可比随机下棋高多了。不过呢,这个网络还是挺复杂的,做起来太费时间,我们可以用一个简化版的预测模型。两个小学生下棋,嗯,总比随机好,是吧?

AlphaGo的做法

这一小节我们来介绍AlphaGo里面的几个网络和它的做法。训练过程中,DeepMind还用到了增强学习,根据Paper说的,效果上来看不是重点,我们略去,后话再讲。

为了叙述的方便,我们先来进行一些符号的简写。

1.利用大量的人类下棋数据,训练出一个CNN。这个网络可以预测对于一个盘面,人类棋手下一步有可能下哪里。这个网络叫做$p_σ$,也就是策略网络(policy network)。一个可能的误解是,策略是指AlphaGo下棋的策略,正相反,这个网络预测的是人类的策略。

2.为了进行简单但是比随机好的模拟对弈,把$p_σ$简化,得到一个简化版的人类行为预测网络,称为$p_π$。

3.一个能判断一个谱面胜率的网络,$v_θ$,称为估值网络(value network)。

现在,$p_σ$和$p_π$我们有了,但是上面说了,我们缺少天量的数据,训练不出$v_θ$。一个想法是随机对弈得到数据,不过显然效率太低。那怎么办呢——还记得我们怎么解决MCTS里面的随机过程的吗,$p_π$。是的,既然我们相信两个$p_π$互相对弈下出来的结果是有统计学意义的,那么它们俩下出来的盘面就可以用来训练$v_θ$,这样我们就可以生成天量的数据来训练了。

好了,现在我们得到了三个网络,$p_σ$、$p_π$和$v_θ$。让我们来看看AlphaGo是怎么和人类棋手B对弈的。(为了方便理解有轻微删改,并且只向下探索三个回合)

1.B先手执黑,下一子。

2.AlphaGo执白,将当前盘面插入状态树,作为根节点。

3.对于剩余的所有可能位点,放上一个白子,得到19*19-1个子状态r。

4.对于每个子状态r,进行如下算法:

①.将r送进$p_σ$,得到对于这个子状态,人类最可能走的几个位置。

对于这些位置,放上一个黑子,得到一系列子盘面p。

②.对所有这些位置p,用$p_π$进行模拟,得到胜率$a_i$。

③.用$v_θ$看一眼p,得到胜率$v_i$。

④.p的胜率是$(a_i + v_i) / 2$。

⑤.对所有p的胜率取平均,得到r的胜率a。

⑥.用$v_θ$看一眼r,得到胜率v。

⑦.取(a + v) / 2为r的胜率。

5.取一个胜率最高的r。落子。

6.轮到B下子,此时仍可以继续探索p中的状态。

7.B落子后,如果当前盘面在p中(正如$p_σ$所预料的),将这个盘面作为根节点,清空同级无关数据,利用历史数据继续。否则,清空状态树,将这个盘面作为根节点,继续。

再厘清一下这几个网络:

$p_σ$:保证可以预想好几步棋(多探索几层),却保证预想的数据不被大量浪费。

$p_π$:保证MCTS模拟过程的有效性,使胜率预测相比随机对弈精确。

$v_θ$:一个独立的胜率预测网络,用来和MCTS模拟结果的胜率取平均。

就这样,利用几个神经网络和MCTS的结合,再加上几百路并行的TPU,AlphaGo最终被证明能够征服人类世界围棋的最强者。

用AlphaGo原理实现五子棋AI

特征工程

到这里,就必须开始利用和五子棋有关的领域知识了。在上面的分析中,我们谈论把一个“谱面”送进神经网络,然后让它去调整那个”复杂函数“。理论上,这个神经网络如果足够复杂是可以拟合任何的非线性函数的。当然了,那只是理论,如果我们能够预先帮它提取一些特征,总归只有好处。

实际上,特征工程是机器学习的一个重要组成部分,它很大程度上决定了最终学习的效果。在DeepMind的工作中,他们为每个棋点提供了下图的数据。可以看到,利用了”气“、”目“、”空“等等领域概念,甚至还加了个全是0的偏置特征。最后他们用了一个十三层的CNN就做到了预测人类棋手落子准确率57%,特征工程还是起了很大的功劳。要知道,图像分类领域里面ResNet这种带残差模块的网络,动不动给你来个一百层一千层的……

如果你对CNN图像分类不熟悉,可能会觉得,不是一个15 x 15(围棋是19 x 19)的棋盘,然后用-1、1、2来表示空白、黑、白吗?那你这么多数据怎么放到一个点?

实际上,考虑一个图片分类问题,如果分类的是彩色图片,那你总要表示颜色吧?如果你用一个RGB值,那不就是一个点位3个值了?CNN二维卷积层的运算方法决定了它可以接受任意深度的二维数据,并进行学习,这一点不必担心。

我为五子棋棋盘抽取了十七个特征,所以这个CNN接受的实际上是15 x 15 x 17的输入。具体如下:

特征 抽取方式 其他说明
棋点类型 表示这个点是空白位点还是黑子白子 取-1为空,1为黑,2为白
竖直相连数 这个点位竖直方向上有多少个子相连 取一到五,然后除以五,故为0到1(做个简单的数据归一化,下同,不再赘述)
水平相连数 类似上一项 类似上一项
左上到右下相连数 类似上一项 类似上一项
右上到左下相连数 类似上一项 类似上一项
上方是否被挡住 如果上方是异色子或者棋盘边界取1,否则0;此处无子取-1
下方是否被挡住 类似上一项
左边是否被挡住 类似上一项
右边是否被挡住 类似上一项
右上方是否被挡住 类似上一项
左下方是否被挡住 类似上一项
右下方是否被挡住 类似上一项
左上方是否被挡住 类似上一项
决定落子的位置与上次落子的位置 本次落子位置与上次落子位置的欧拉距离,第一次取-1 求出后除以14根号2,故为0-1,这个数据对于每个子都是一样的
以自己为中心7x7范围内的黑子数 统计一下 除以经验量度15,基本上分布在0~3
以自己为中心7x7范围内的白子数 类似上一项 类似上一项
以自己为中心7x7范围内的空白数 类似上一项 类似上一项

特征提取的代码就是纯工程实现了,没什么好说的。

随机数据+小网络测试

一般来说,这时候我们应该去学DeepMind上网找数据了。不过笔者对于这种工作一向是比较懒的,让我们先来做一个BaseLine系统测试一下吧。

没有数据我们肯定是不能训练$p_σ$和$p_π$的,不过倒是可以测试一下训练$v_θ$,我们可以让程序在棋盘上随机下棋,然后根据上面说的,黑胜全部标黑,白胜全部标白,然后训练这个网络,最后看看它和随机对手的对弈效果先。

这里我用了keras来建立卷积网络,如果你对CNN比较了解的话,可以看一下如下的参数

卷积核数目 卷积核尺寸 边缘处理(是否补零) 激活函数
512 5 x 5 ReLu
256 2 x 2 ReLu
256 2 x 2 ReLu
128 2 x 2 ReLu
128 2 x 2 ReLu
128 3 x 3 ReLu
Max Pooling 池化大小:2 x 2
64 3 x 3 ReLu
Max Pooling 池化大小:2 x 2
全连接 512单元 激活函数:ReLu
全连接 256单元 激活函数:ReLu
全连接 1单元 激活函数:Sigmoid

这个网络大约有143万的参量,超参数选得比较随意。

(说个题外话,后来我发现生成数据的代码有些小问题,特征中“如果下了这一步,离上一步的距离”,这个值只和选择下的点以及上一步有关,对于每个点应该是一样的;结果变成了“如果下了这一步,这一步和这个点的距离”,这个数据每个点都不一样(离选择的位置近就小),而且好像基本没意义……不过幸好还有其它维度的数据,测试也没出什么大问题,汗。)

我用一台服务器随机生成了200~300万个局面,过程是让两个AI随机在棋盘上乱下,直到得出一个胜者。整个过程中的盘面都被记录下来,最后,若黑棋是胜者,则除了终盘的所有盘面被标记为0.75,终盘(即黑五连珠的盘面)被标记为1。对于白,对应的数据是0.25和0。

我使用了一个Adam优化器来优化这个网络,最后平均误差下降到0.23左右终止(看起来这个值还蛮差劲的……)。

接下来,让网络执黑,每次选择预测出来对黑棋最有利的盘面;对手执白,随机落子。

一个测试的GIF,颜色值是网络预测当前玩家下一步如果在这里落子,黑方的胜率。两步一截图,既然白子是随机下我们就不浪费时间去为它预测了。

使用这个网络测试1000次,962胜38负。只能说,这是一个”好的随机“,如果使用的训练集是人类的数据的话,可能我们也能得到差不多的效果吧(希望,毕竟DL是炼丹:))。

2017-08-21 17:59808
  • Wolfpan2017-08-24 17:29

    谢谢博主共享,学习

  • cosformula2017-08-28 20:40

    谢谢博主共享,学习

  • Wolfpan2017-08-29 16:40

    请教博主,怎么处理平局和胜负之间的关系?围棋平局非常罕见,可以忽略不计,但是象棋这种和局比较多的情况下,如果计算机算出胜率非常高,那应该忽略走棋网络里平局所计算出的概率,如果不忽略,AI会把赢棋走成和棋。相反如果计算机算出胜率非常低,那应该加上平局的概率,让AI去争取和棋。

  • Npa2017-08-29 21:17

    不好意思,因觉得五子棋可能实现起来比较相似,适合学习;而且我的硬件暂时不太跟得上……所以改成五子棋了。

    至于你说的这种情况,没有实践过,所以只能说说大致的想法。我是这样理解的,不管是随机模拟还是用价值网络直接看,最后得到结果都是一个可能性w∈(0, 1)代表了我方的胜率。假定我永远选所有子盘面中p最高的那个。当w∈(0, 0.5),就是劣势,那么选最偏向0.5的那个,就是争取和棋;当p∈(0.5, 1),就是优势,那么选最偏向1的那个,就是争取胜利。这不正好是你说的决策吗?

    我对你说的思路的另一层理解是,对于子盘面r的一系列子子盘面p,由于取了平均,所以在优势局面下和棋的预测(w≈0.5)“拖累”了r的平均胜率。在子盘面总体w偏高的情况下,把各个r下面的p中,w≈0.5的全部砍掉,然后重新计算r的平均胜率。没有太过缜密的思考,我不太确定这样做在统计意义上是否有效,可能还是要实验看看。

    如果你实现了中国象棋的版本,或者在这个思路有什么进展,请留博文给我拜读哦。:)

  • Wolfpan2017-08-29 22:48

    “对于子盘面r的一系列子子盘面p,由于取了平均,所以在优势局面下和棋的预测(w≈0.5)“拖累”了r的平均胜率。在子盘面总体w偏高的情况下,把各个r下面的p中,w≈0.5的全部砍掉,然后重新计算r的平均胜率。”博主我就是这个意思。

    我还是用博主你的代码测试出来的,你把五子棋棋盘改成3*3的,然后通3个子的一测就测效果来了。对应代码就是simulate函数里return winner != current_player,这个返回值计算了和局的情况,如果return (winner != current_player) and (winner!=-1)那就是剔出了和局。

  • Wolfpan2017-08-29 22:51

    还有博主,代码159行的代码,favourable = self.simulate(board),是否应该是favourable = self.simulate(tmp_board),如果是favourable = self.simulate(board),那不是第157和158行没有意义了?

  • Npa2017-08-29 22:51

    (此回复顺序已被修改)

    确实是你说的这样,是我疏忽了。幸好这里只是先随便训练一下所有的子节点以便使其有数学意义,没有对后面造成太大的影响。已经修改,感谢。

  • Wolfpan2017-08-29 22:54

    还有第288行play_with_human()函数里的,start_time = time.time(),好像也没什么用

  • Npa2017-08-29 23:14

    哈哈哈,忙着实现idea,代码有些地方改来改去没有实现得很漂亮,见谅。先记着了,整个项目写完就修正代码,可能还会修整一些地方的效率,提前谢了。(另外repo的位置动了一下,文内的url改了,可以从那里点进去。)

    另外就是,3*3的特殊情况似乎不能说明什么问题……(我也只是不确定)我建议你将修改过的和没修改过的两个ai,在正常的棋盘上互相对弈,统计一下胜率,看看有没有明显差别。

    如果确实如此,我们可以再深入分析:)

  • Wolfpan2017-08-30 21:51

    不好意思,又来骚扰博主。博主在这里使用的CNN卷积神经网络是用来处理分类问题,还是处理回归问题?看到博主的loss函数使用了crossentropy,感觉像是个分类问题。但是博主全连接层的最后一层却是1单元,激活函数又使用了Sigmoid,好像看上去又像是在处理回归问题。我不太懂keras,只学了点皮毛tensorflow,如果是tensorflow用CNN来处理分类问题,全连接层的最后肯定不是这样写的,而是比如你想把图片分成10个类别,那个mnist的0到9的数字识别,最后是10个神经单元,tf.layers.dense(fclayer, 10)这样的写法,然后使用softmax分类器,取这10个值里面最大的那个值,来预测他属于哪个数字。

  • Npa2017-08-31 09:15

    我用的是binary_crossentropy,keras应该还有category_crossentropy.

    关于这个问题我是这样理解的哈,分类问题本质上是一个回归问题,在用了sigmoid函数的前提下尤其如此,sigmoid的输出逻辑上可以被解释为“是本类的可能性”。

    binary的交叉熵是0-1分类问题,在这里我并不想得到它对黑还是白有利这个结果,而是想得到它对黑有利的程度,所以直接就拿sigmoid的输出了事了。

    另外,softmax的主要作用是对各个类的输出数据归一化(可以参考我的tensorflow入门那篇博文)如果你对MNIST数据用了交叉熵做loss,sigmoid激活做倒数第二层,softmax做倒数第一层,那么你的loss是十个binary交叉熵加和,倒数第二层的输出是十个类各自“是本类”的可能性,也就是说天然已经规约到0~1内了,到这里实际上不用softmax直接取一个最大值都可以的。

  • Wolfpan2017-08-31 14:28

    哦,是这样啊。最后,若黑棋是胜者,则除了终盘的所有盘面被标记为0.75,终盘(即黑五连珠的盘面)被标记为1。对于白,对应的数据是0.25和0。博主我感觉这样定义损失函数的标签值不是很正确,博主你有空的话,看看这篇文章,他也是用CNN,跟你的CNN基本上一样的,区别就是他最后的损失函数的定义跟你的不太一样,他好像是最大最小值原理来定义最后CNN的输出。

    https://zhuanlan.zhihu.com/p/27297546 中文翻译

    https://github.com/erikbern/deep-pink 作者源代码(当然我看不懂,呵呵)

    https://erikbern.com/2014/11/29/deep-learning-for-chess.html,作者原文

  • Npa2017-08-31 18:03

    学习了,确实有些道理。可惜他的是人落子的谱面……他推理的一个前提条件是“玩家会选择最优或者近似最优的落子”。这个对我的数据集不成立。

    后面如果用了人类对弈数据会找机会实验一下这个标注方法。感谢!