前面我们了解了AlphaGo的原理,它通过结合监督学习和强化学习,并且基于蒙特卡洛树搜索展现出了非凡的围棋能力,不仅很好的继承了人类的下棋策略,甚至创造出了很多人们不曾使用过的新动作。而2017年发布的AlphaGoZero则更让人意外,它不仅没有使用任何人类的棋局数据进行初始化训练,而且也不需要在进行蒙特卡洛推演。AlphaGoZero从最开始就将树搜索与强化学习集成到了一起,它使用了更少的代码却比原先的AlphaGo更加强大,它是如何做到的呢?本篇文章就和大家一起探索AlphaGoZero背后的原理。(在阅读本篇博客之前,建议读者先阅读之前介绍AlphaGo实现原理的博客:AlphaGo原理讲解 )
和之前介绍AlphaGo一样,我们首先来了解一下AlphaGoZero的棋盘编码器。与AlphaGo不同,AlphaGoZero最新版本的棋盘编码器也做了很大的调整,使用的是一个19×19×17的张量,其中当前黑色棋子的位置用一个平面来表示,前七次黑色棋子的位置用另外七个平面来表示;类似地,用另外八个平面表示白色棋子最近八步的位置信息。最后,还有一个平面用来表示当前执子方,如果该下黑色棋子了,该平面的值全为1;如果该下白子了,该平面的值则全为0。事实上,棋盘编码器的特征内容并不是固定不变的,我们完全可以尝试其他的平面组合,比如可以引进贴目的概念等。
除此之外,在AlphaGo中实现模拟两个AI进行自我对弈的代码时,我们直接把跳过回合的逻辑显示地写了出来;而在AlphaGoZero中,由于自我对弈采用了树搜索的算法,我们可以把跳过回合看作与其他落子动作一样作为一个动作选项,因此网络的输出尺寸就变成了19×19+1=362。相应的,我们要把之前在AlphaGo中实现的向量元素索引和棋盘交叉点坐标相互转换的函数进行略微的调整,如下所示:
def encode_move(self, move):
if move.is_play:
return (self.board_size * (move.point.row - 1) +
(move.point.col - 1))
# add the pass as the 361th move
elif move.is_pass:
return self.board_size * self.board_size
raise ValueError('Cannot encode resign move')
def decode_move_index(self, index):
# check whether the move is pass first
if index == self.board_size * self.board_size:
return Move.pass_turn()
row = index // self.board_size
col = index % self.board_size
return Move.play(Point(row=row + 1, col=col + 1))
从算法层面来讲,AlphaGoZero与AlphaGo最大的不同在于:无论AlphaGo是先用人类的棋谱对策略网络进行预训练,还是让预训练好的策略网络进行自我对弈,并使用生成的对弈棋谱训练策略网络和价值网络,需要注意的是,当将这些网络用于改进MCTS的时候,这些网络已经是训练好的了,在使用过程中是不能够再次训练的。而AlphaGoZero却与之相反,其先将神经网络融入到树搜索之中,指导树搜索,然后用这些对弈生成的棋谱再训练神经网络。从功能方面来讲,AlphaGoZero中神经网络的作用是指导树搜索,而不是直接选择或评估动作。接下来,我们将详细地讲解这一过程。
虽然不同的树搜索算法都有其各自的不同之处,但是核心思想都是在棋盘游戏中找到一个能产生最佳结果的动作。通常情况下,我们通过对选定动作进行推演来判断动作的好坏。但是由于要探索动作的深度和广度太大,导致时间复杂度过高,因此选择探索那个最合适的分支就成了树搜索算法要解决的核心问题。
和MCTS一样,AlphaGoZero的树搜索算法也会运行固定的轮次,每一轮都会向搜索树添加一个新的节点,这颗搜索树的每个节点都代表一个可能的棋局。与AlphaGo不同的是,每个节点不仅要储存下一个子节点,还要储存以该节点为当前状态,所有合法的下棋动作,无论这些动作是否被访问过,都会以该动作创建一个分支类,该分支类储存有以下信息:
[ 1 ] [1] [1] 先验概率:表示对于当前状态,该动作的好坏;
[ 2 ] [2] [2] 访问次数:表示在树搜索的过程中访问这个分支的次数,其初始化为0;
[ 3 ] [3] [3] 经过这个分支的所有访问的期望值:这个值是所有经过该分支的访问的平均值(每访问一次该分支,就会产生一个期望值)。
除了要储存所有动作的分支外,树节点还要出巡当前状态,上一步动作等信息。动作分支和树节点的代码表示如下(由于代码较为简单,这里将不做讲解):
class Branch:
def __init__(self, prior):
self.prior = prior
self.visit_num = 0
self.total_value = 0.0
class TreeNode:
def __init__(self, board_state, state_value, priors, parent, last_move):
self.board_state = board_state
self.state_value = state_value
self.parent = parent
self.last_move = last_move
self.total_visit_count = 1
self.branches = {}
for move, prob in priors.items():
if board_state.is_valid_move(move):
self.branches[move] = Branch(prob)
self.children = {}
def get_moves(self):
return self.branches.keys()
def add_child(self, move, child_node):
self.children[move] = child_node
def has_child(self, move):
return move in self.children
def get_child(self, move):
return self.children[move]
def get_move_expected_value(self, move):
branch = self.branches[move]
if branch.visit_num == 0:
return 0.0
else:
return branch.total_value / branch.visit_num
def get_move_prior(self, move):
return self.branches[move].prior
def get_move_visit_num(self, move):
if move in self.branches:
return self.branches[move].visit_num
else:
return 0
def record_visit(self, move, value):
self.total_visit_count += 1
self.branches[move].visit_num += 1
self.branches[move].total_value += value
那么,我们该如何选择要探索的分支呢?在AlphaGoZero树搜索中,首先同样需要平衡深入挖掘(Exploitation)和广泛探索(Exploration)这两个目标。具体来说,我们既可以在几个最好的分支中选择一个进行更加深入的探索,进一步提高其估计的准确性;又可以深入探索那些访问次数少,但有可能对未来棋局具有良好影响的分支,来改善他们的估计水平。在之前介绍AlphaGo的时候我们知道,MCTS算法通过使用搜索树最大置信上界(UCT)来平衡这两个目标,而在AlphaGoZero中,我们使用如下公式对动作分支进行评估:
a ′ = a r g m a x a [ Q ( a ) + c ⋅ N ⋅ π ( a ∣ s ; θ ) 1 + n ] a^{\prime }=argmax_{a}\left[ Q\left( a\right) +c\cdot \sqrt{N} \cdot \frac{\pi \left( a\mid s;\theta \right) }{1+n} \right] a′=argmaxa[Q(a)+c⋅N⋅1+nπ(a∣s;θ)]
各个变量的具体说明请见以下表格:
变量 | 解释 |
---|---|
Q ( a ) Q\left( a\right) Q(a) | 经过一个分支所有访问的期望值的平均值,该值初始化为0 |
π ( a ∣ s ; θ ) \pi \left( a\mid s;\theta \right) π(a∣s;θ) | 当前动作的先验概率 |
N N N | 当前节点的父节点的被访问次数 |
n n n | 当前节点的访问次数 |
c c c | 平衡深入挖掘和广泛搜索的权重因子,需要我们自行调节 |
这个公式我们该怎么理解呢?该评分公式和AlphaGo中采用的评分公式类似,其功能也相近。和AlphaGo中的评分公式一样,如果一个分支已经被访问很多次了,那么它的期望值将更加可信;如果一个分支的访问次数很少,那么他的期望值可能又很大的偏差,因此我们希望多访问该分支以改善它的估计。此外,这个公式为AlphaGoZero提供了第三个评估指标,即在访问次数少的那些分支里面,我们应当倾向探索具有更高先验概率的动作分支。这些分支对应的动作,通过神经网络的计算从直觉上已经显得很不错了。基于以上思路,选择分支的代码实现如下:
def select_branch(self, node):
N = node.total_visit_count
def score_branch(move):
"""
Scoring function:
Branch Value = Q + c * P * sqrt(N) / (1 + n)
Q: the average of the expected values of the branched through the move;
P: the prior probability of the move;
N: the visit number of the parent node of the current move branch;
n: the visit number of the current move branch;
c: the weighted parameter
:param move: the move candidate branch
:return: the value of the scoring function of a branch
"""
q = node.get_move_expected_value(move)
p = node.get_move_prior(move)
n = node.get_move_visit_num(move)
return q + self.c * p * np.sqrt(N) / (n + 1)
return max(node.get_moves(), key=score_branch)
注意,在选择动作分支的时候,很有可能遇到之前已经作为节点的动作,这个时候,我们要向下遍历子节点,直到找到没有子节点的树节点为止,代码实现如下:
def find_branch(self, node):
next_move = self.select_branch(node)
# the move has already been added to the search tree
while node.has_child(next_move):
node = node.get_child(next_move)
next_move = self.select_branch(node)
return next_move
在找到要探索的分支之后,接下来我们就要为该动作分支创建节点加入到搜索树之中。前面我们已经给出了搜索树节点的定义,接下来我们将会以此来创建新的树节点。需要注意的是,新创建的节点所对应的动作的先验概率和对游戏状态的估计值由我们的神经网络计算得出,创建节点的代码如下:
def create_node(self, game_state, move=None, parent=None):
'''
implement the last move and get the new game state,
then input the new game state into the network so that
the priors value are obtained.
:param game_state: the new game state
:param move: the last move
:param parent: the parent of this node
:return: the new node
'''
encoded_state = self.encoder.encode(game_state)
input_data = np.array([encoded_state])
priors, values = self.model.predict(input_data)
priors = priors[0]
value = values[0][0]
move_priors = {
self.encoder.decode_move_index(index): prior for index, prior in enumerate(priors)
}
new_node = TreeNode(game_state, value, move_priors, parent, move)
if parent is not None:
parent.add_child(move, new_node)
return new_node
在创建好新节点并加入搜索树后,还需要沿着这个新节点一路回到树根,并更新沿途各个节点储存的统计信息。值得注意的是,每经过一个节点,视角都会从黑方切换成白方,因此每一步都要切换新加入值的正负号。这很好理解:以期望值为例,如果对于黑方这盘棋的趋势是良好的,那么对于白方来说,这盘棋就是不利于其获胜的。最后选择动作分支并且将该动作加入到搜索树的整体代码如下:
root = self.create_node(game_state)
for i in range(self.num_rounds):
# 1. find the next move branch to be added to the tree
node = root
next_move = self.find_branch(node)
# 2. create the new tree node
new_state = node.board_state.after_move(next_move)
child_node = self.create_node(new_state, parent=node)
# 3. add the new node to the tree
node.add_child(next_move, child_node)
# 4. update the stored data
move = next_move
value = -1 * child_node.state_value
while node is not None:
node.record_visit(move, value)
move = node.last_move
node = node.parent
value = -1 * value
与AlphaGo一样,在扩充玩搜索树之后,我们需要真正地为当前棋局选择一个动作,而选择动作的指标依然是该动作节点的访问次数。因为根据前面的动作分支评估公式,我们可以知道当分支的访问次数不断增加时,因子 1 n + 1 \frac{1}{n+1} n+11也会不断地变小,分支便会更多地倾向于只根据 Q ( a ) Q(a) Q(a)的值来进行选择,因此, Q ( a ) Q(a) Q(a)的值越大,该分支被访问的可能性就越大。综上,我们为当前棋局选择理想动作的逻辑实现如下:
def select_move(self, game_state):
root = self.create_node(game_state)
for i in range(self.num_rounds):
# 1. find the next move branch to be added to the tree
node = root
next_move = self.find_branch(node)
# 2. create the new tree node
new_state = node.board_state.after_move(next_move)
child_node = self.create_node(new_state, parent=node)
# 3. add the new node to the tree
node.add_child(next_move, child_node)
# 4. update the stored data
move = next_move
value = -1 * child_node.state_value
while node is not None:
node.record_visit(move, value)
move = node.last_move
node = node.parent
value = -1 * value
return max(root.get_moves(), key=root.get_move_visit_num)
当现在为止我们已经讲解完了AlphGoZero树搜索的核心算法,大家可能也注意到了在创建新节点的时候我们要为新的棋盘计算其各个动作的先验概率和期望值,这里我们使用神经网络去完成这个任务。
与AlphaGo类似,AlphaGoZero也需要策略网络输出各个动作先验概率以及价值网络评判当前游戏状态的好坏;但不同的是,在AlphaGoZero中,这两套神经网络将共享一部分的卷机层,而不是像AlphaGo需要两套独立的神经网络。AlphaGoZero的神经网络结构示意图如下所示:
值得注意的是,DeepMind发布的AlphaGoZero中使用的卷积网络层数达到了80多层,巨大的网络拥有强大的能力但也需要更多的计算,这对于硬件要求是非常严格的。如果没有DeepMind那样的硬件条件,我们最好尝试较小的网络。网络模型的示例代码如下,在示例代码中,我们将共享的卷积层设置为8层:
class Actor_Critic_Go(keras.Model):
def __init__(self, policy_output_dims):
super(Actor_Critic_Go, self).__init__()
self.policy_output_dims = policy_output_dims
self.conv1 = layers.Conv2D(64, (3,3), padding='same',
data_format='channels_last', activation='relu')
self.conv2 = layers.Conv2D(64, (3, 3), padding='same',
data_format='channels_last', activation='relu')
self.conv3 = layers.Conv2D(64, (3, 3), padding='same',
data_format='channels_last', activation='relu')
self.conv4 = layers.Conv2D(64, (3, 3), padding='same',
data_format='channels_last', activation='relu')
self.conv5 = layers.Conv2D(64, (3, 3), padding='same',
data_format='channels_last', activation='relu')
self.conv6 = layers.Conv2D(64, (3, 3), padding='same',
data_format='channels_last', activation='relu')
self.conv7 = layers.Conv2D(64, (3, 3), padding='same',
data_format='channels_last', activation='relu')
self.conv8 = layers.Conv2D(64, (3, 3), padding='same',
data_format='channels_last', activation='relu')
self.policy_conv = layers.Conv2D(2, (1,1), data_format='channels_last', activation='relu')
self.policy_flat = layers.Flatten()
self.policy_output = layers.Dense(self.policy_output_dims, activation='softmax')
self.value_conv = layers.Conv2D(1, (1,1), data_format='channels_last', activation='relu')
self.value_flat = layers.Flatten()
self.value_hidden = layers.Dense(256, activation='relu')
self.value_output = layers.Dense(1, activation='tanh')
def call(self, board_input):
x = self.conv1(board_input)
x = self.conv2(x)
x = self.conv3(x)
x = self.conv4(x)
x = self.conv5(x)
x = self.conv6(x)
x = self.conv7(x)
x = self.conv8(x)
policy_conv = self.policy_conv(x)
policy_flat = self.policy_flat(policy_conv)
moves_priors = self.policy_output(policy_flat)
value_conv = self.value_conv(x)
value_flat = self.value_flat(value_conv)
value_hidden = self.value_hidden(value_flat)
move_value = self.value_output(value_hidden)
model = keras.models.Model(inputs=[board_input], outputs=[moves_priors, move_value])
return model
在讲解神经网络的训练目标之前,我们需要知道训练数据从何而来。AlphaGo的训练数据有两大来源,一是人类对弈的棋谱数据,二是策略网络自我对弈产生的棋谱数据。策略网络和价值网络通过这些数据的训练从而逐渐变得强大,并最终应用于树搜索的算法之中。AlphaGoZero则与之不同,它所使用的训练数据是黑白双方通过使用之前我们讲解的AlphaGoZero树搜索算法相互对弈产生的,这就意味着,在初始阶段,神经网络并没有经过什么训练,其对弈能力非常的脆弱,但是经过不断的训练,最终AlphaGoZero的能力却能赶超人类水平,令人惊叹。
AlphaGoZero也需要序列收集器储存对弈时产生的序列,参考代码如下:
class AlphaGoZeroExperienceCollector:
def __init__(self):
self.states = []
self.visit_number = []
self.rewards = []
self._current_episode_states = []
self._current_episode_visit_number = []
def begin_episode(self):
self._current_episode_states = []
self._current_episode_visit_number = []
def record_decision(self, state, visit_number):
self._current_episode_states.append(state)
self._current_episode_visit_number.append(visit_number)
def complete_episode(self, reward):
num_states = len(self._current_episode_states)
self.states += self._current_episode_states
self.visit_number += self._current_episode_visit_number
self.rewards += [reward for i in range(num_states)]
self._current_episode_visit_number = []
self._current_episode_states = []
class ExperienceBuffer:
def __init__(self, states, visit_numbers, rewards):
self.states = states
self.visit_numbers = visit_numbers
self.rewards = rewards
def serialize(self, h5file):
h5file.create_group('experience')
h5file['experience'].create_dataset('states', data=self.states)
h5file['experience'].create_dataset('visit_numbers', data=self.visit_numbers)
h5file['experience'].create_dataset('rewards', data=self.rewards)
def load_experience(h5file):
return ExperienceBuffer(
states=np.array(h5file['experience']['states']),
visit_numbers=np.array(h5file['experience']['visit_numbers']),
rewards=np.array(h5file['experience']['rewards'])
)
def combine_experience(collectors):
combined_states = np.concatenate([np.array(c.states) for c in collectors])
combined_visit_number = np.concatenate([np.array(c.visit_number) for c in collectors])
combined_rewards = np.concatenate([np.array(c.rewards) for c in collectors])
return ExperienceBuffer(combined_states, combined_visit_number, combined_rewards)
有了储存容器,我们就可以收集经验数据了,那么该在哪里收集这些数据呢?由于我们需要在搜索树新添加节点的时候收集更新后的数据,因此收集经验数据的代码应该放在添加节点并更新完数据之后,因此我们可以在之前’select_move’函数中续写这部分的代码,如下所示:
if self.collector is not None:
root_state = self.encoder.encode(game_state)
moves = [index for index in range(self.encoder.num_moves())]
visit_number = np.array([root.get_move_visit_num(index) for index in moves])
self.collector.record_decision(root_state, visit_number)
最后,我们给出黑白双方互相对弈的逻辑代码:
def game_simulation(board_size,
black_agent,
black_collector,
white_agent,
white_collector):
print('**********game start***********')
game = GameState.new_game(board_size)
agents = {
Player.black: black_agent,
Player.white: white_agent
}
black_collector.begin_episode()
white_collector.begin_episode()
while not game.is_over():
next_move = agents[game.next_player].select_move(game)
game = game.after_move(next_move)
game_result = scoring.compute_game_result(game)
if game_result.winner == Player.black:
black_collector.complete_episode(1)
white_collector.complete_episode(-1)
else:
black_collector.complete_episode(-1)
white_collector.complete_episode(1)
AlphaGoZero在训练策略网络的时候,他的训练目标是匹配树搜索过程中每个动作的访问次数,而不像AlphaGo中,策略网络的训练目标是匹配获胜时所选择的落子动作。为什么要做这个改变呢?我们可以从MCTS风格的搜索算法的工作原理寻找答案。
暂时假定我们已经拥有了一个能够粗略区分出胜局还是败局的价值函数,接着我们完全抛弃先验概率而直接运行搜索算法。在这样的情况下, Q ( a ) Q(a) Q(a)值越大的动作分支将会更多地被访问,假设搜索时间无限长,最终搜索树便会找到最佳动作。之前说过,先验函数的目标就是为了判断在当前状态下,该动作的好坏程度。由于我们已经使用价值函数检验过执行各个动作的胜负结果,换句话说,也就知道了执行各个动作的好坏;因此在经过足够轮次的树搜索之后,我们就可以把访问计数当作检验指标了。
值得注意的是,由于策略网络的输出值的总和为1,因此我们需要将各个动作的访问次数进行归一化,如下图所示:
价值网络的训练目标和AlphaGo中的类似,读者可以自行查看之前AlphaGo原理的讲解:
AlphaGo 原理讲解
训练部分代码如下:
def train(self, experience):
num_examples = experience.states.shape[0]
input_data = experience.states
visit_sums = np.sum(experience.visit_number, axis=1).reshape((num_examples, 1))
action_target = experience.visit_number / visit_sums
value_target = experience.rewards
self.model.compile(keras.optimizers.SGD(lr=self.lr), loss=['categorical_crossentropy', 'mse'])
self.model.fit(input_data, [action_target, value_target], batch_size=self.batch_size)
本文为大家详细地介绍了AlphaGoZero的算法原理以及代码实现。看完本篇文章,相信读者对如何实现自己的AlphaGoZero已经有了思路。虽然AlphaGoZero的代码量比AlphaGo要小很多,但是如果我们想要训练出足够强大的围棋对弈AI,它所需要的算力支持却不容小觑。此外,应用一些神经网络训练时的技巧也能帮助我们改进训练过程,例如为了防止机器人在训练中陷入僵局,我们可以使用dirichlet噪声改进探索;此外,深度卷积网络的构建也很灵活,我们可以尝试使用批量归一化以及残差网络等方法改进网络,或许会有意想不到的效果。