Python 强化学习项目(二)
原文:
annas-archive.org/md5/8a22ccc4f94e0a5a98e16b22a2b1f959译者:飞龙
第五章:在 Minecraft 中构建虚拟世界
在前两章中,我们讨论了深度 Q 学习(DQN)算法,用于玩 Atari 游戏,以及信任域策略优化(TRPO)算法,用于连续控制任务。我们看到这些算法在解决复杂问题时取得了巨大成功,尤其是与传统强化学习算法相比,后者并未使用深度神经网络来逼近价值函数或策略函数。它们的主要缺点,尤其是对于 DQN 来说,是训练步骤收敛得太慢,例如,训练一个代理玩 Atari 游戏需要大约一周时间。对于更复杂的游戏,即使一周的训练时间也不够。
本章将介绍一个更复杂的例子——Minecraft,这是由瑞典游戏开发者 Markus Persson 创作并由 Mojang 开发的热门在线视频游戏。你将学习如何使用 OpenAI Gym 启动 Minecraft 环境,并完成不同的任务。为了构建一个 AI 玩家来完成这些任务,你将学习异步优势行为者-批评者(A3C)算法,这是一个轻量级的深度强化学习框架,使用异步梯度下降优化深度神经网络控制器。A3C 是广泛应用的深度强化学习算法,可以在单个多核 CPU 上训练一半的时间,而不是 GPU。对于像 Breakout 这样的 Atari 游戏,A3C 在训练 3 小时后即可达到人类水平的表现,远比 DQN 需要的 3 天训练时间要快。你将学习如何使用 Python 和 TensorFlow 实现 A3C。本章对数学背景的要求不如上一章那么高——尽情享受吧!
本章将涵盖以下主题:
-
Minecraft 环境简介
-
在 Minecraft 环境中为训练 AI 机器人准备数据
-
异步优势行为者-批评者框架
-
A3C 框架的实现
Minecraft 环境简介
原始的 OpenAI Gym 不包含 Minecraft 环境。我们需要安装一个 Minecraft 环境包,地址为github.com/tambetm/gym-minecraft。这个包是基于微软的 Malmö构建的,Malmö是一个建立在 Minecraft 之上的 AI 实验和研究平台。
在安装gym-minecraft包之前,首先需要从github.com/Microsoft/malmo下载 Malmö。我们可以从github.com/Microsoft/malmo/releases下载最新的预构建版本。解压包后,进入Minecraft文件夹,在 Windows 上运行launchClient.bat,或者在 Linux/MacOS 上运行launchClient.sh,以启动 Minecraft 环境。如果成功启动,我们现在可以通过以下脚本安装gym-minecraft:
python3 -m pip install gym
python3 -m pip install pygame
git clone https://github.com/tambetm/minecraft-py.git
cd minecraft-py
python setup.py install
git clone https://github.com/tambetm/gym-minecraft.git
cd gym-minecraft
python setup.py install
然后,我们可以运行以下代码来测试gym-minecraft是否已经成功安装:
import logging
import minecraft_py
logging.basicConfig(level=logging.DEBUG)
proc, _ = minecraft_py.start()
minecraft_py.stop(proc)
gym-minecraft包提供了 15 个不同的任务,包括MinecraftDefaultWorld1-v0和MinecraftBasic-v0。例如,在MinecraftBasic-v0中,代理可以在一个小房间内移动,房间角落放置着一个箱子,目标是到达这个箱子的位置。以下截图展示了gym-minecraft中可用的几个任务:
gym-minecraft包与其他 Gym 环境(例如 Atari 和经典控制任务)具有相同的接口。你可以运行以下代码来测试不同的 Minecraft 任务,并尝试对它们的属性(例如目标、奖励和观察)进行高层次的理解:
import gym
import gym_minecraft
import minecraft_py
def start_game():
env = gym.make('MinecraftBasic-v0')
env.init(start_minecraft=True)
env.reset()
done = False
while not done:
env.render(mode='human')
action = env.action_space.sample()
obs, reward, done, info = env.step(action)
env.close()
if __name__ == "__main__":
start_game()
在每个步骤中,通过调用env.action_space.sample()从动作空间中随机抽取一个动作,然后通过调用env.step(action)函数将该动作提交给系统,该函数会返回与该动作对应的观察结果和奖励。你也可以通过将MinecraftBasic-v0替换为其他名称来尝试其他任务,例如,MinecraftMaze1-v0和MinecraftObstacles-v0。
数据准备
在 Atari 环境中,请记住每个 Atari 游戏都有三种模式,例如,Breakout、BreakoutDeterministic 和 BreakoutNoFrameskip,每种模式又有两个版本,例如,Breakout-v0 和 Breakout-v4。三种模式之间的主要区别是 frameskip 参数,它表示一个动作在多少帧(步长)上重复。这就是跳帧技术,它使我们能够在不显著增加运行时间的情况下玩更多游戏。
然而,在 Minecraft 环境中,只有一种模式下 frameskip 参数等于 1。因此,为了应用跳帧技术,我们需要在每个时间步中显式地重复某个动作多个 frameskip 次数。除此之外,step函数返回的帧图像是 RGB 图像。类似于 Atari 环境,观察到的帧图像会被转换为灰度图像,并且被调整为 84x84 的大小。以下代码提供了gym-minecraft的包装器,其中包含了所有的数据预处理步骤:
import gym
import gym_minecraft
import minecraft_py
import numpy, time
from utils import cv2_resize_image
class Game:
def __init__(self, name='MinecraftBasic-v0', discrete_movement=False):
self.env = gym.make(name)
if discrete_movement:
self.env.init(start_minecraft=True, allowDiscreteMovement=["move", "turn"])
else:
self.env.init(start_minecraft=True, allowContinuousMovement=["move", "turn"])
self.actions = list(range(self.env.action_space.n))
frame = self.env.reset()
self.frame_skip = 4
self.total_reward = 0
self.crop_size = 84
self.buffer_size = 8
self.buffer_index = 0
self.buffer = [self.crop(self.rgb_to_gray(frame)) for _ in range(self.buffer_size)]
self.last_frame = frame
def rgb_to_gray(self, im):
return numpy.dot(im, [0.2126, 0.7152, 0.0722])
def reset(self):
frame = self.env.reset()
self.total_reward = 0
self.buffer_index = 0
self.buffer = [self.crop(self.rgb_to_gray(frame)) for _ in range(self.buffer_size)]
self.last_frame = frame
def add_frame_to_buffer(self, frame):
self.buffer_index = self.buffer_index % self.buffer_size
self.buffer[self.buffer_index] = frame
self.buffer_index += 1
def get_available_actions(self):
return list(range(len(self.actions)))
def get_feedback_size(self):
return (self.crop_size, self.crop_size)
def crop(self, frame):
feedback = cv2_resize_image(frame,
resized_shape=(self.crop_size, self.crop_size),
method='scale', crop_offset=0)
return feedback
def get_current_feedback(self, num_frames=4):
assert num_frames < self.buffer_size, "Frame buffer is not large enough."
index = self.buffer_index - 1
frames = [numpy.expand_dims(self.buffer[index - k], axis=0) for k in range(num_frames)]
if num_frames > 1:
return numpy.concatenate(frames, axis=0)
else:
return frames[0]
def play_action(self, action, num_frames=4):
reward = 0
termination = 0
for i in range(self.frame_skip):
a = self.actions[action]
frame, r, done, _ = self.env.step(a)
reward += r
if i == self.frame_skip - 2:
self.last_frame = frame
if done:
termination = 1
self.add_frame_to_buffer(self.crop(numpy.maximum(self.rgb_to_gray(frame), self.rgb_to_gray(self.last_frame))))
r = numpy.clip(reward, -1, 1)
self.total_reward += reward
return r, self.get_current_feedback(num_frames), termination
在构造函数中,Minecraft 的可用动作被限制为move和turn(不考虑其他动作,如相机控制)。将 RGB 图像转换为灰度图像非常简单。给定一个形状为(高度,宽度,通道)的 RGB 图像,rgb_to_gray 函数用于将图像转换为灰度图像。对于裁剪和重塑帧图像,我们使用opencv-python或cv2包,它们包含原始 C++ OpenCV 实现的 Python 封装,即crop 函数将图像重塑为 84x84 的矩阵。与 Atari 环境不同,在 Atari 环境中,crop_offset设置为8,以去除屏幕上的得分板,而在这里,我们将crop_offset设置为0,并只是重塑帧图像。
play_action 函数将输入的动作提交给 Minecraft 环境,并返回相应的奖励、观察值和终止信号。默认的帧跳参数设置为4,意味着每次调用play_action时,动作会重复四次。get_current_feedback 函数返回将最后四帧图像堆叠在一起的观察值,因为仅考虑当前帧图像不足以玩 Minecraft,因为它不包含关于游戏状态的动态信息。
这个封装器与 Atari 环境和经典控制任务的封装器具有相同的接口。因此,你可以尝试在 Minecraft 环境中运行 DQN 或 TRPO,而无需做任何更改。如果你有一块空闲的 GPU,最好先运行 DQN,然后再尝试我们接下来讨论的 A3C 算法。
异步优势演员-评论员算法
在前面的章节中,我们讨论了用于玩 Atari 游戏的 DQN,以及用于连续控制任务的 DPG 和 TRPO 算法。回顾一下,DQN 的架构如下:
在每个时间步长,智能体观察到帧图像
,并根据当前学习到的策略选择一个动作
。模拟器(Minecraft 环境)执行该动作并返回下一帧图像
以及相应的奖励
。然后,将四元组
存储在经验记忆中,并作为训练 Q 网络的样本,通过最小化经验损失函数进行随机梯度下降。
基于经验回放的深度强化学习算法在玩 Atari 游戏方面取得了前所未有的成功。然而,经验回放有几个缺点:
-
它在每次真实交互中需要更多的内存和计算
-
它需要能够从由旧策略生成的数据中进行更新的离策略学习算法
为了减少内存消耗并加速 AI 智能体的训练,Mnih 等人提出了一种 A3C 框架,用于深度强化学习,能够显著减少训练时间而不会损失性能。该工作《深度强化学习的异步方法》发表于 2016 年 ICML。
A3C 不是使用经验回放,而是异步地在多个环境实例上并行执行多个智能体,如 Atari 或 Minecraft 环境。由于并行智能体经历了多种不同的状态,这种并行性打破了训练样本之间的相关性,从而稳定了训练过程,这意味着可以去除经验记忆。这个简单的想法使得许多基础的强化学习算法(如 Sarsa 和 actor-critic 方法)以及离策略强化学习算法(如 Q-learning)能够通过深度神经网络得到强健且有效的应用。
另一个优势是,A3C 能够在标准的多核 CPU 上运行,而不依赖于 GPU 或大规模分布式架构,并且在应用于 Atari 游戏时,所需的训练时间比基于 GPU 的算法(如 DQN)要少得多。A3C 适合深度强化学习的初学者,因为你可以在具有多个核心的标准 PC 上应用它,进行 Atari 游戏的训练。例如,在 Breakout 中,当执行八个智能体并行时,只需两到三小时就能达到 300 分。
在本章中,我们将使用与之前相同的符号。在每个时间步 ,智能体观察到状态
,采取行动
,然后从函数
生成的相应奖励
中获得反馈。我们使用
来表示智能体的策略,该策略将状态映射到动作的概率分布。贝尔曼方程如下:
状态-行动值函数 可以通过由
参数化的神经网络来逼近,策略
也可以通过另一个由
参数化的神经网络来表示。然后,
可以通过最小化以下损失函数来训练:
是在第
步的近似状态-动作值函数。在单步 Q 学习(如 DQN)中,
等于
,因此以下公式成立:
使用单步 Q 学习的一个缺点是,获得的奖励 只直接影响导致该奖励的状态动作对
的值。这可能导致学习过程变慢,因为需要进行大量更新才能将奖励传播到相关的前置状态和动作。加快奖励传播的一种方法是使用 n 步回报。在 n 步 Q 学习中,
可以设置为:
与基于价值的方法不同,基于策略的方法,例如 TRPO,直接优化策略网络 。除了 TRPO,更简单的方法是 REINFORCE,它通过更新策略参数
在方向
上进行更新,其中
是在状态
下采取动作
的优势。该方法属于演员-评论员方法,因为它需要估计价值函数
和策略
。
异步强化学习框架可以应用于之前讨论过的方法中。其主要思想是我们并行运行多个代理,每个代理拥有自己独立的环境实例,例如,多个玩家使用自己的游戏机玩同一款游戏。这些代理可能在探索环境的不同部分。参数 和
在所有代理之间共享。每个代理异步地更新策略和价值函数,而不考虑读写冲突。虽然没有同步更新策略似乎很奇怪,但这种异步方法不仅消除了发送梯度和参数的通信成本,而且还保证了收敛性。更多细节请参阅以下论文:《一种无锁方法并行化随机梯度下降》,Recht 等人。本章聚焦于 A3C,即我们在 REINFORCE 中应用异步强化学习框架。下图展示了 A3C 架构:
对于 A3C,策略 和价值函数
是通过两个神经网络来近似的。A3C 更新策略参数
的方向是
,其中
是固定的,估算方法如下:
A3C 通过最小化损失来更新价值函数参数 :
是通过之前的估算计算得出的。为了在训练过程中鼓励探索,策略的熵
也被加入到策略更新中,作为正则化项。然后,策略更新的梯度变成以下形式:
以下伪代码展示了每个代理(线程)的 A3C 算法:
Initialize thread step counter ;
Initialize global shared parameters and ;
Repeat for each episode:
Reset gradients and ;
Synchronize thread-specific parameters and ;
Set the start time step ;
Receive an observation state ;
While is not the terminal state and :
Select an action according to ;
Execute action in the simulator and observe reward and the next state ;
Set ;
End While
Set if is the terminal state or otherwise;
For do
Update ;
Accumulate gradients wrt : ;
Accumulate gradients wrt : ;
End For
Perform asynchronous update of using and of using .
A3C 使用 ADAM 或 RMSProp 来执行参数的异步更新。对于不同的环境,很难判断哪种方法能带来更好的性能。我们可以在 Atari 和 Minecraft 环境中使用 RMSProp。
A3C 的实现
现在我们来看如何使用 Python 和 TensorFlow 实现 A3C。在这里,策略网络和价值网络共享相同的特征表示。我们实现了两种不同的策略:一种基于 DQN 中使用的 CNN 架构,另一种基于 LSTM。
我们实现了基于 CNN 的策略的 FFPolicy 类:
class FFPolicy:
def __init__(self, input_shape=(84, 84, 4), n_outputs=4, network_type='cnn'):
self.width = input_shape[0]
self.height = input_shape[1]
self.channel = input_shape[2]
self.n_outputs = n_outputs
self.network_type = network_type
self.entropy_beta = 0.01
self.x = tf.placeholder(dtype=tf.float32,
shape=(None, self.channel, self.width, self.height))
self.build_model()
构造函数需要三个参数:
-
input_shape -
n_outputs -
network_type
input_shape 是输入图像的大小。经过数据预处理后,输入为 84x84x4 的图像,因此默认参数为(84, 84, 4)。n_outputs 是所有可用动作的数量。network_type 指示我们希望使用的特征表示类型。我们的实现包含两种不同的网络。一种是 DQN 中使用的 CNN 架构,另一种是用于测试的前馈神经网络。
- 在构造函数中,
x变量表示输入状态(一个 84x84x4 的图像批次)。在创建输入张量之后,调用build_model函数来构建策略和价值网络。以下是build_model:
def build_model(self):
self.net = {}
self.net['input'] = tf.transpose(self.x, perm=(0, 2, 3, 1))
if self.network_type == 'cnn':
self.net['conv1'] = conv2d(self.net['input'], 16, kernel=(8, 8), stride=(4, 4), name='conv1')
self.net['conv2'] = conv2d(self.net['conv1'], 32, kernel=(4, 4), stride=(2, 2), name='conv2')
self.net['feature'] = linear(self.net['conv2'], 256, name='fc1')
else:
self.net['fc1'] = linear(self.net['input'], 50, init_b = tf.constant_initializer(0.0), name='fc1')
self.net['feature'] = linear(self.net['fc1'], 50, init_b = tf.constant_initializer(0.0), name='fc2')
self.net['value'] = tf.reshape(linear(self.net['feature'], 1, activation=None, name='value',
init_b = tf.constant_initializer(0.0)),
shape=(-1,))
self.net['logits'] = linear(self.net['feature'], self.n_outputs, activation=None, name='logits',
init_b = tf.constant_initializer(0.0))
self.net['policy'] = tf.nn.softmax(self.net['logits'], name='policy')
self.net['log_policy'] = tf.nn.log_softmax(self.net['logits'], name='log_policy')
self.vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, tf.get_variable_scope().name)
CNN 架构包含两个卷积层和一个隐藏层,而前馈架构包含两个隐藏层。如前所述,策略网络和价值网络共享相同的特征表示。
- 用于更新网络参数的损失函数可以通过以下函数构建:
def build_gradient_op(self, clip_grad=None):
self.action = tf.placeholder(dtype=tf.float32, shape=(None, self.n_outputs), name='action')
self.reward = tf.placeholder(dtype=tf.float32, shape=(None,), name='reward')
self.advantage = tf.placeholder(dtype=tf.float32, shape=(None,), name='advantage')
value = self.net['value']
policy = self.net['policy']
log_policy = self.net['log_policy']
entropy = -tf.reduce_sum(policy * log_policy, axis=1)
p_loss = -tf.reduce_sum(tf.reduce_sum(log_policy * self.action, axis=1) * self.advantage + self.entropy_beta * entropy)
v_loss = 0.5 * tf.reduce_sum((value - self.reward) ** 2)
total_loss = p_loss + v_loss
self.gradients = tf.gradients(total_loss, self.vars)
if clip_grad is not None:
self.gradients, _ = tf.clip_by_global_norm(self.gradients, clip_grad)
tf.summary.scalar("policy_loss", p_loss, collections=['policy_network'])
tf.summary.scalar("value_loss", v_loss, collections=['policy_network'])
tf.summary.scalar("entropy", tf.reduce_mean(entropy), collections=['policy_network'])
self.summary_op = tf.summary.merge_all('policy_network')
return self.gradients
-
此函数创建三个输入张量:
-
action -
reward -
advantage
-
-
action变量表示选择的动作!。reward变量是前述 A3C 算法中的折扣累计奖励!。advantage变量是通过!计算的优势函数。在这个实现中,策略损失和价值函数损失被合并,因为特征表示层是共享的。 -
因此,我们的实现不是分别更新
policy参数和value参数,而是同时更新这两个参数。这个函数还为 TensorBoard 可视化创建了summary_op。
LSTM 策略的实现与前馈策略相似,主要区别在于build_model函数:
def build_model(self):
self.net = {}
self.net['input'] = tf.transpose(self.x, perm=(0, 2, 3, 1))
if self.network_type == 'cnn':
self.net['conv1'] = conv2d(self.net['input'], 16, kernel=(8, 8), stride=(4, 4), name='conv1')
self.net['conv2'] = conv2d(self.net['conv1'], 32, kernel=(4, 4), stride=(2, 2), name='conv2')
self.net['feature'] = linear(self.net['conv2'], 256, name='fc1')
else:
self.net['fc1'] = linear(self.net['input'], 50, init_b = tf.constant_initializer(0.0), name='fc1')
self.net['feature'] = linear(self.net['fc1'], 50, init_b = tf.constant_initializer(0.0), name='fc2')
num_units = self.net['feature'].get_shape().as_list()[-1]
self.lstm = tf.contrib.rnn.BasicLSTMCell(num_units=num_units, forget_bias=0.0, state_is_tuple=True)
self.init_state = self.lstm.zero_state(batch_size=1, dtype=tf.float32)
step_size = tf.shape(self.x)[:1]
feature = tf.expand_dims(self.net['feature'], axis=0)
lstm_outputs, lstm_state = tf.nn.dynamic_rnn(self.lstm, feature,
initial_state=self.init_state,
sequence_length=step_size,
time_major=False)
outputs = tf.reshape(lstm_outputs, shape=(-1, num_units))
self.final_state = lstm_state
self.net['value'] = tf.reshape(linear(outputs, 1, activation=None, name='value',
init_b = tf.constant_initializer(0.0)),
shape=(-1,))
self.net['logits'] = linear(outputs, self.n_outputs, activation=None, name='logits',
init_b = tf.constant_initializer(0.0))
self.net['policy'] = tf.nn.softmax(self.net['logits'], name='policy')
self.net['log_policy'] = tf.nn.log_softmax(self.net['logits'], name='log_policy')
self.vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, tf.get_variable_scope().name)
在这个函数中,LSTM 层紧随特征表示层。在 TensorFlow 中,可以通过构造BasicLSTMCell并调用tf.nn.dynamic_rnn来轻松创建 LSTM 层,进而得到层的输出。tf.nn.dynamic_rnn返回每个时间步的输出和最终的单元状态。
现在我们实现 A3C 算法的主要部分——A3C类:
class A3C:
def __init__(self, system, directory, param, agent_index=0, callback=None):
self.system = system
self.actions = system.get_available_actions()
self.directory = directory
self.callback = callback
self.feedback_size = system.get_feedback_size()
self.agent_index = agent_index
self.set_params(param)
self.init_network()
system参数是模拟器,可以是 Atari 环境或 Minecraft 环境。directory表示保存模型和日志的文件夹。param包含 A3C 的所有训练参数,例如批量大小和学习率。agent_index是智能体的标签。构造函数调用init_network来初始化策略网络和值网络。以下是init_network的实现:
def init_network(self):
input_shape = self.feedback_size + (self.num_frames,)
worker_device = "/job:worker/task:{}/cpu:0".format(self.agent_index)
with tf.device(tf.train.replica_device_setter(1, worker_device=worker_device)):
with tf.variable_scope("global"):
if self.use_lstm is False:
self.shared_network = FFPolicy(input_shape, len(self.actions), self.network_type)
else:
self.shared_network = LSTMPolicy(input_shape, len(self.actions), self.network_type)
self.global_step = tf.get_variable("global_step", shape=[],
initializer=tf.constant_initializer(0, dtype=tf.int32),
trainable=False, dtype=tf.int32)
self.best_score = tf.get_variable("best_score", shape=[],
initializer=tf.constant_initializer(-1e2, dtype=tf.float32),
trainable=False, dtype=tf.float32)
with tf.device(worker_device):
with tf.variable_scope('local'):
if self.use_lstm is False:
self.network = FFPolicy(input_shape, len(self.actions), self.network_type)
else:
self.network = LSTMPolicy(input_shape, len(self.actions), self.network_type)
# Sync params
self.update_local_ops = update_target_graph(self.shared_network.vars, self.network.vars)
# Learning rate
self.lr = tf.get_variable(name='lr', shape=[],
initializer=tf.constant_initializer(self.learning_rate),
trainable=False, dtype=tf.float32)
self.t_lr = tf.placeholder(dtype=tf.float32, shape=[], name='new_lr')
self.assign_lr_op = tf.assign(self.lr, self.t_lr)
# Best score
self.t_score = tf.placeholder(dtype=tf.float32, shape=[], name='new_score')
self.assign_best_score_op = tf.assign(self.best_score, self.t_score)
# Build gradient_op
self.increase_step = self.global_step.assign_add(1)
gradients = self.network.build_gradient_op(clip_grad=40.0)
# Additional summaries
tf.summary.scalar("learning_rate", self.lr, collections=['a3c'])
tf.summary.scalar("score", self.t_score, collections=['a3c'])
tf.summary.scalar("best_score", self.best_score, collections=['a3c'])
self.summary_op = tf.summary.merge_all('a3c')
if self.shared_optimizer:
with tf.device(tf.train.replica_device_setter(1, worker_device=worker_device)):
with tf.variable_scope("global"):
optimizer = create_optimizer(self.update_method, self.lr, self.rho, self.rmsprop_epsilon)
self.train_op = optimizer.apply_gradients(zip(gradients, self.shared_network.vars))
else:
with tf.device(worker_device):
with tf.variable_scope('local'):
optimizer = create_optimizer(self.update_method, self.lr, self.rho, self.rmsprop_epsilon)
self.train_op = optimizer.apply_gradients(zip(gradients, self.shared_network.vars))
这个函数中比较棘手的部分是如何实现全局共享参数。在 TensorFlow 中,我们可以通过tf.train.replica_device_setter函数来实现这一点。我们首先创建一个在所有智能体之间共享的global设备。在这个设备内,创建了全局共享网络。然后,为每个智能体创建一个本地设备和本地网络。为了同步全局和本地参数,通过调用update_target_graph函数来创建update_local_ops:
def update_target_graph(from_vars, to_vars):
op_holder = []
for from_var, to_var in zip(from_vars, to_vars):
op_holder.append(to_var.assign(from_var))
return op_holder
然后,通过调用build_gradient_op构建gradients操作,该操作用于计算每个智能体的梯度更新。通过gradients,使用create_optimizer函数构建优化器,用于更新全局共享参数。create_optimizer函数的使用方式如下:
def create_optimizer(method, learning_rate, rho, epsilon):
if method == 'rmsprop':
opt = tf.train.RMSPropOptimizer(learning_rate=learning_rate,
decay=rho,
epsilon=epsilon)
elif method == 'adam':
opt = tf.train.AdamOptimizer(learning_rate=learning_rate,
beta1=rho)
else:
raise
return opt
A3C 中的主要功能是run,用于启动并训练智能体:
def run(self, sess, saver=None):
num_of_trials = -1
for episode in range(self.num_episodes):
self.system.reset()
cell = self.network.run_initial_state(sess)
state = self.system.get_current_feedback(self.num_frames)
state = numpy.asarray(state / self.input_scale, dtype=numpy.float32)
replay_memory = []
for _ in range(self.T):
num_of_trials += 1
global_step = sess.run(self.increase_step)
if len(replay_memory) == 0:
init_cell = cell
sess.run(self.update_local_ops)
action, value, cell = self.choose_action(sess, state, cell)
r, new_state, termination = self.play(action)
new_state = numpy.asarray(new_state / self.input_scale, dtype=numpy.float32)
replay = (state, action, r, new_state, value, termination)
replay_memory.append(replay)
state = new_state
if len(replay_memory) == self.async_update_interval or termination:
states, actions, rewards, advantages = self.n_step_q_learning(sess, replay_memory, cell)
self.train(sess, states, actions, rewards, advantages, init_cell, num_of_trials)
replay_memory = []
if global_step % 40000 == 0:
self.save(sess, saver)
if self.callback:
self.callback()
if termination:
score = self.system.get_total_reward()
summary_str = sess.run(self.summary_op, feed_dict={self.t_score: score})
self.summary_writer.add_summary(summary_str, global_step)
self.summary_writer.flush()
break
if global_step - self.eval_counter > self.eval_frequency:
self.evaluate(sess, n_episode=10, saver=saver)
self.eval_counter = global_step
在每个时间步,它调用choose_action根据当前策略选择一个动作,并通过调用play执行这个动作。然后,将接收到的奖励、新的状态、终止信号,以及当前状态和选择的动作存储在replay_memory中,这个内存记录了智能体访问的轨迹。给定这条轨迹,它接着调用n_step_q_learning来估计累计奖励和advantage函数:
def n_step_q_learning(self, sess, replay_memory, cell):
batch_size = len(replay_memory)
w, h = self.system.get_feedback_size()
states = numpy.zeros((batch_size, self.num_frames, w, h), dtype=numpy.float32)
rewards = numpy.zeros(batch_size, dtype=numpy.float32)
advantages = numpy.zeros(batch_size, dtype=numpy.float32)
actions = numpy.zeros((batch_size, len(self.actions)), dtype=numpy.float32)
for i in reversed(range(batch_size)):
state, action, r, new_state, value, termination = replay_memory[i]
states[i] = state
actions[i][action] = 1
if termination != 0:
rewards[i] = r
else:
if i == batch_size - 1:
rewards[i] = r + self.gamma * self.Q_value(sess, new_state, cell)
else:
rewards[i] = r + self.gamma * rewards[i+1]
advantages[i] = rewards[i] - value
return states, actions, rewards, advantages
然后,它通过调用train来更新全局共享参数:
def train(self, sess, states, actions, rewards, advantages, init_cell, iter_num):
lr = self.anneal_lr(iter_num)
feed_dict = self.network.get_feed_dict(states, actions, rewards, advantages, init_cell)
sess.run(self.assign_lr_op, feed_dict={self.t_lr: lr})
step = int((iter_num - self.async_update_interval + 1) / self.async_update_interval)
if self.summary_writer and step % 10 == 0:
summary_str, _, step = sess.run([self.network.summary_op, self.train_op, self.global_step],
feed_dict=feed_dict)
self.summary_writer.add_summary(summary_str, step)
self.summary_writer.flush()
else:
sess.run(self.train_op, feed_dict=feed_dict)
注意,模型将在 40,000 次更新后保存到磁盘,并且在self.eval_frequency次更新后开始评估过程。
要启动一个智能体,我们可以运行以下写在worker.py文件中的代码:
import numpy, time, random
import argparse, os, sys, signal
import tensorflow as tf
from a3c import A3C
from cluster import cluster_spec
from environment import new_environment
def set_random_seed(seed):
random.seed(seed)
numpy.random.seed(seed)
def delete_dir(path):
if tf.gfile.Exists(path):
tf.gfile.DeleteRecursively(path)
tf.gfile.MakeDirs(path)
return path
def shutdown(signal, frame):
print('Received signal {}: exiting'.format(signal))
sys.exit(128 + signal)
def train(args, server):
os.environ['OMP_NUM_THREADS'] = '1'
set_random_seed(args.task * 17)
log_dir = os.path.join(args.log_dir, '{}/train'.format(args.env))
if not tf.gfile.Exists(log_dir):
tf.gfile.MakeDirs(log_dir)
game, parameter = new_environment(args.env)
a3c = A3C(game, log_dir, parameter.get(), agent_index=args.task, callback=None)
global_vars = [v for v in tf.global_variables() if not v.name.startswith("local")]
ready_op = tf.report_uninitialized_variables(global_vars)
config = tf.ConfigProto(device_filters=["/job:ps", "/job:worker/task:{}/cpu:0".format(args.task)])
with tf.Session(target=server.target, config=config) as sess:
saver = tf.train.Saver()
path = os.path.join(log_dir, 'log_%d' % args.task)
writer = tf.summary.FileWriter(delete_dir(path), sess.graph_def)
a3c.set_summary_writer(writer)
if args.task == 0:
sess.run(tf.global_variables_initializer())
else:
while len(sess.run(ready_op)) > 0:
print("Waiting for task 0 initializing the global variables.")
time.sleep(1)
a3c.run(sess, saver)
def main():
parser = argparse.ArgumentParser(description=None)
parser.add_argument('-t', '--task', default=0, type=int, help='Task index')
parser.add_argument('-j', '--job_name', default="worker", type=str, help='worker or ps')
parser.add_argument('-w', '--num_workers', default=1, type=int, help='Number of workers')
parser.add_argument('-l', '--log_dir', default="save", type=str, help='Log directory path')
parser.add_argument('-e', '--env', default="demo", type=str, help='Environment')
args = parser.parse_args()
spec = cluster_spec(args.num_workers, 1)
cluster = tf.train.ClusterSpec(spec)
signal.signal(signal.SIGHUP, shutdown)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
if args.job_name == "worker":
server = tf.train.Server(cluster,
job_name="worker",
task_index=args.task,
config=tf.ConfigProto(intra_op_parallelism_threads=0,
inter_op_parallelism_threads=0)) # Use default op_parallelism_threads
train(args, server)
else:
server = tf.train.Server(cluster,
job_name="ps",
task_index=args.task,
config=tf.ConfigProto(device_filters=["/job:ps"]))
# server.join()
while True:
time.sleep(1000)
if __name__ == "__main__":
main()
主函数将在job_name参数为worker时创建一个新的智能体并开始训练过程。否则,它将启动 TensorFlow 参数服务器,用于全局共享参数。注意,在启动多个智能体之前,我们需要先启动参数服务器。在train函数中,通过调用new_environment来创建环境,然后为该环境构建智能体。智能体成功创建后,初始化全局共享参数,并通过调用a3c.run(sess, saver)开始训练过程。
由于手动启动 8 或 16 个智能体非常不方便,可以通过以下脚本自动执行此操作:
import argparse, os, sys, cluster
from six.moves import shlex_quote
parser = argparse.ArgumentParser(description="Run commands")
parser.add_argument('-w', '--num_workers', default=1, type=int,
help="Number of workers")
parser.add_argument('-e', '--env', type=str, default="demo",
help="Environment")
parser.add_argument('-l', '--log_dir', type=str, default="save",
help="Log directory path")
def new_cmd(session, name, cmd, logdir, shell):
if isinstance(cmd, (list, tuple)):
cmd = " ".join(shlex_quote(str(v)) for v in cmd)
return name, "tmux send-keys -t {}:{} {} Enter".format(session, name, shlex_quote(cmd))
def create_commands(session, num_workers, logdir, env, shell='bash'):
base_cmd = ['CUDA_VISIBLE_DEVICES=',
sys.executable,
'worker.py',
'--log_dir', logdir,
'--num_workers', str(num_workers),
'--env', env]
cmds_map = [new_cmd(session, "ps", base_cmd + ["--job_name", "ps"], logdir, shell)]
for i in range(num_workers):
cmd = base_cmd + ["--job_name", "worker", "--task", str(i)]
cmds_map.append(new_cmd(session, "w-%d" % i, cmd, logdir, shell))
cmds_map.append(new_cmd(session, "htop", ["htop"], logdir, shell))
windows = [v[0] for v in cmds_map]
notes = ["Use `tmux attach -t {}` to watch process output".format(session),
"Use `tmux kill-session -t {}` to kill the job".format(session),
"Use `ssh -L PORT:SERVER_IP:SERVER_PORT username@server_ip` to remote Tensorboard"]
cmds = ["kill $(lsof -i:{}-{} -t) > /dev/null 2>&1".format(cluster.PORT, num_workers+cluster.PORT),
"tmux kill-session -t {}".format(session),
"tmux new-session -s {} -n {} -d {}".format(session, windows[0], shell)]
for w in windows[1:]:
cmds.append("tmux new-window -t {} -n {} {}".format(session, w, shell))
cmds.append("sleep 1")
for _, cmd in cmds_map:
cmds.append(cmd)
return cmds, notes
def main():
args = parser.parse_args()
cmds, notes = create_commands("a3c", args.num_workers, args.log_dir, args.env)
print("Executing the following commands:")
print("\n".join(cmds))
os.environ["TMUX"] = ""
os.system("\n".join(cmds))
print("Notes:")
print('\n'.join(notes))
if __name__ == "__main__":
main()
这个脚本创建了用于创建参数服务器和一组智能体的 bash 命令。为了处理所有智能体的控制台,我们使用 TMUX(更多信息请参见github.com/tmux/tmux/wiki)。TMUX 是一个终端复用工具,允许我们在一个终端中轻松切换多个程序,分离它们,并将它们重新附加到不同的终端上。TMUX 对于检查 A3C 的训练状态是一个非常方便的工具。请注意,由于 A3C 在 CPU 上运行,我们将CUDA_VISIBLE_DEVICES设置为空。
与 DQN 相比,A3C 对训练参数更加敏感。随机种子、初始权重、学习率、批量大小、折扣因子,甚至 RMSProp 的超参数都会极大地影响性能。在不同的 Atari 游戏上测试后,我们选择了 Parameter 类中列出的以下超参数:
class Parameter:
def __init__(self, lr=7e-4, directory=None):
self.directory = directory
self.learning_rate = lr
self.gamma = 0.99
self.num_history_frames = 4
self.iteration_num = 100000
self.async_update_interval = 5
self.rho = 0.99
self.rmsprop_epsilon = 1e-1
self.update_method = 'rmsprop'
self.clip_delta = 0
self.max_iter_num = 10 ** 8
self.network_type = 'cnn'
self.input_scale = 255.0
这里,gamma是折扣因子,num_history_frames是参数 frameskip,async_update_interval是训练更新的批量大小,rho和rmsprop_epsilon是 RMSProp 的内部超参数。这组超参数可以用于 Atari 和 Minecraft。
实验
A3C 算法的完整实现可以从我们的 GitHub 仓库下载(github.com/PacktPublishing/Python-Reinforcement-Learning-Projects)。在我们的实现中,有三个环境可以进行测试。第一个是特别的游戏demo,它在第三章《玩 Atai 游戏》中介绍。对于这个游戏,A3C 只需要启动两个智能体就能取得良好的表现。在src文件夹中运行以下命令:
python3 train.py -w 2 -e demo
第一个参数-w或--num_workers表示启动的代理数量。第二个参数-e或--env指定环境,例如demo。其他两个环境是 Atari 和 Minecraft。对于 Atari 游戏,A3C 要求至少有 8 个代理并行运行。通常,启动 16 个代理可以获得更好的性能:
python3 train.py -w 8 -e Breakout
对于 Breakout,A3C 大约需要 2-3 小时才能达到 300 分。如果你有一台性能不错的 PC,且有超过 8 个核心,最好使用 16 个代理进行测试。要测试 Minecraft,运行以下命令:
python3 train.py -w 8 -e MinecraftBasic-v0
Gym Minecraft 环境提供了超过 10 个任务。要尝试其他任务,只需将MinecraftBasic-v0替换为其他任务名称。
运行上述命令之一后,输入以下命令来监控训练过程:
tmux attach -t a3c
在控制台窗口之间切换,按Ctrl + b,然后按0 - 9。窗口 0 是参数服务器。窗口 1-8 显示 8 个代理的训练统计数据(如果启动了 8 个代理)。最后一个窗口运行 htop。要分离 TMUX,按Ctrl,然后按b。
tensorboard日志保存在save/<environment_name>/train/log_<agent_index>文件夹中。要使用 TensorBoard 可视化训练过程,请在该文件夹下运行以下命令:
tensorboard --logdir=.
总结
本章介绍了 Gym Minecraft 环境,网址为github.com/tambetm/gym-minecraft。你已经学会了如何启动 Minecraft 任务以及如何为其实现一个模拟器。本章最重要的部分是异步强化学习框架。你了解了 DQN 的不足之处,以及为什么 DQN 难以应用于复杂任务。接着,你学会了如何在行为者-评论员方法 REINFORCE 中应用异步强化学习框架,这引出了 A3C 算法。最后,你学会了如何使用 TensorFlow 实现 A3C 以及如何使用 TMUX 处理多个终端。实现中的难点是全局共享参数,这与创建 TensorFlow 服务器集群有关。对于想深入了解的读者,请访问www.tensorflow.org/deploy/distributed。
在接下来的章节中,你将学习更多关于如何在其他任务中应用强化学习算法,例如围棋和生成深度图像分类器。这将帮助你深入理解强化学习,并帮助你解决实际问题。
第六章:学习围棋
在考虑 AI 的能力时,我们常常将其在特定任务中的表现与人类能达到的水平进行比较。如今,AI 代理已经能够在更复杂的任务中超越人类水平。在本章中,我们将构建一个能够学习如何下围棋的代理,而围棋被认为是史上最复杂的棋盘游戏。我们将熟悉最新的深度强化学习算法,这些算法能够实现超越人类水平的表现,即 AlphaGo 和 AlphaGo Zero,这两者都是由谷歌的 DeepMind 开发的。我们还将了解蒙特卡罗树搜索(Monte Carlo tree search),这是一种流行的树搜索算法,是回合制游戏代理的核心组成部分。
本章将涵盖以下内容:
-
围棋介绍及 AI 相关研究
-
AlphaGo 与 AlphaGo Zero 概述
-
蒙特卡罗树搜索算法
-
AlphaGo Zero 的实现
围棋简介
围棋是一种最早在两千年前中国有记载的棋盘游戏。与象棋、将棋和黑白棋等其他常见棋盘游戏类似,围棋有两位玩家轮流在 19x19 的棋盘上放置黑白棋子,目标是通过围住尽可能多的区域来捕获更多的领土。玩家可以通过用自己的棋子围住对方的棋子来捕获对方的棋子。被捕获的棋子会从棋盘上移除,从而形成一个空白区域,除非对方的领土被重新夺回,否则对方无法在该区域放置棋子。
当双方玩家都拒绝落子或其中一方认输时,比赛结束。比赛结束时,胜者通过计算每位玩家的领土和捕获的棋子数量来决定。
围棋及其他棋盘游戏
研究人员已经创建出能够超越最佳人类选手的 AI 程序,用于象棋、跳棋等棋盘游戏。1992 年,IBM 的研究人员开发了 TD-Gammon,采用经典的强化学习算法和人工神经网络,在跳棋比赛中达到了顶级玩家的水平。1997 年,由 IBM 和卡内基梅隆大学开发的国际象棋程序 Deep Blue,在六局对战中击败了当时的世界冠军加里·卡斯帕罗夫。这是第一次计算机程序在国际象棋中击败世界冠军。
开发围棋下棋智能体并不是一个新话题,因此人们可能会想,为什么研究人员花了这么长时间才在围棋领域复制出这样的成功。答案很简单——围棋,尽管规则简单,却远比国际象棋复杂。试想将一个棋盘游戏表示为一棵树,每个节点是棋盘的一个快照(我们也称之为棋盘状态),而它的子节点则是对手可能的下一步落子。树的高度本质上是游戏持续的步数。一场典型的国际象棋比赛大约进行 80 步,而一场围棋比赛则持续 150 步;几乎是国际象棋的两倍。此外,国际象棋每一步的平均可选步数为 35,而围棋每步可下的棋局则多达 250 种可能。根据这些数字,围棋的总游戏可能性为 10⁷⁶¹,而国际象棋的则为 10¹²⁰。在计算机中枚举围棋的每一种可能状态几乎是不可能的,这使得研究人员很难开发出能够在世界级水平上进行围棋对弈的智能体。
围棋与人工智能研究
2015 年,谷歌 DeepMind 的研究人员在《自然》杂志上发表了一篇论文,详细介绍了一种新型的围棋强化学习智能体——AlphaGo。同年 10 月,AlphaGo 以 5-0 战胜了欧洲冠军范辉(Fan Hui)。2016 年,AlphaGo 挑战了拥有 18 次世界冠军头衔的李世石,李世石被认为是现代围棋史上最伟大的选手之一。AlphaGo 以 4-1 获胜,标志着深度学习研究和围棋历史的一个分水岭。次年,DeepMind 发布了 AlphaGo 的更新版本——AlphaGo Zero,并在 100 场比赛中以 100 战全胜的成绩击败了其前身。在仅仅几天的训练后,AlphaGo 和 AlphaGo Zero 就学会并超越了人类数千年围棋智慧的积累。
接下来的章节将讨论 AlphaGo 和 AlphaGo Zero 的工作原理,包括它们用于学习和下棋的算法和技术。紧接着将介绍 AlphaGo Zero 的实现。我们的探索从蒙特卡洛树搜索算法开始,这一算法对 AlphaGo 和 AlphaGo Zero 在做出落子决策时至关重要。
蒙特卡洛树搜索
在围棋和国际象棋等游戏中,玩家拥有完美的信息,这意味着他们可以访问完整的游戏状态(棋盘和棋子的摆放位置)。此外,游戏状态不受随机因素的影响;只有玩家的决策能影响棋盘。这类游戏通常被称为完全信息游戏。在完全信息游戏中,理论上可以枚举所有可能的游戏状态。如前所述,这些状态可以表现为一棵树,其中每个子节点(游戏状态)是父节点的可能结果。在两人对弈的游戏中,树的交替层次表示两个竞争者所做的步棋。为给定状态找到最佳的步棋,实际上就是遍历树并找到哪一系列步棋能够导致胜利。我们还可以在每个节点存储给定状态的价值,或预期结果或奖励(胜利或失败)。
然而,对于围棋等游戏来说,构建一个完美的树是不现实的。那么,代理如何在没有这种知识的情况下学会如何下棋呢?蒙特卡洛树搜索(MCTS)算法提供了一个高效的近似完美树的方法。简而言之,MCTS 涉及反复进行游戏,记录访问过的状态,并学习哪些步骤更有利/更可能导致胜利。MCTS 的目标是尽可能构建一个近似前述完美树的树。游戏中的每一步对应 MCTS 算法的一次迭代。该算法有四个主要步骤:选择、扩展、模拟和更新(也称为反向传播)。我们将简要说明每个过程。
选择
MCTS 的第一步是智能地进行游戏。这意味着算法具有足够的经验来根据状态确定下一步棋。确定下一步棋的方法之一叫做上置信界限 1 应用于树(UCT)。简而言之,这个公式根据以下内容对步棋进行评分:
-
每个棋局中某一步棋所获得的平均奖励
-
该步棋被选择的频率
每个节点的评分可以表示如下:
其中:
-
:是选择步棋
的平均奖励(例如,胜率)
-
:是算法选择步棋
的次数
-
:是当前状态下所有已做步棋的总数(包括步棋
)
-
:是一个探索参数
下图展示了选择下一个节点的示例。在每个节点中,左边的数字代表节点的评分,右边的数字代表该节点的访问次数。节点的颜色表示轮到哪位玩家:
图 1:MCTS 中的选择
在选择过程中,算法会选择对前一个表达式具有最高价值的动作。细心的读者可能会注意到,虽然高平均奖励的动作 得到高度评价,但访问次数较少的动作
也同样如此。这是为什么呢?在 MCTS 中,我们不仅希望算法选择最有可能带来胜利的动作,还希望它尝试那些不常被选择的动作。这通常被称为开发与探索之间的平衡。如果算法仅仅依赖开发,那么结果树将会非常狭窄且经验不足。鼓励探索可以让算法从更广泛的经验和模拟中学习。在前面的例子中,我们简单地选择了评分为 7 的节点,然后是评分为 4 的节点。
扩展
我们应用选择方法来决定动作,直到算法无法再应用 UCT 来评估下一组动作。特别是,当某一状态的所有子节点没有记录(访问次数、平均奖励)时,我们就无法再应用 UCT。这时,MCTS 的第二阶段——扩展阶段就会发生。在这个阶段,我们简单地查看给定状态下所有可能的未访问子节点,并随机选择一个。然后,我们更新树结构以记录这个新的子节点。下图说明了这一过程:
图 2:扩展
你可能会好奇,为什么在前面的图示中,我们初始化访问次数为零,而不是一。这个新节点的访问次数以及我们已经遍历过的节点的统计数据将在更新步骤中增加,这是 MCTS 迭代的最后一步。
模拟
扩展后,游戏的其余部分通过随机选择后续动作来进行。这也通常被称为游戏展开(playout)或回滚(rollout)。根据不同的游戏,可能会应用一些启发式方法来选择下一步动作。例如,在 DeepBlue 中,模拟依赖于手工制作的启发式方法来智能地选择下一步动作,而不是随机选择。这也被称为重度回滚(heavy rollouts)。虽然这种回滚提供了更真实的游戏体验,但它们通常计算开销较大,可能会减慢 MCTS 树的学习进程。
图 3:模拟
在我们前面的示例中,我们扩展一个节点并进行游戏,直到游戏结束(由虚线表示),最终得出胜利或失败的结果。模拟过程会产生奖励,在这个案例中,奖励为 1 或 0。
更新
最终,更新步骤发生在算法达到终止状态时,或者当任一玩家获胜或游戏以平局结束时。在这一轮迭代过程中,算法会更新每个访问过的节点/状态的平均奖励,并增加该状态的访问计数。这也被称为反向传播:
图 4:更新
在前面的图示中,由于我们到达了一个返回 1(胜利)的终止状态,因此我们会相应地为每个沿路径从根节点到达的节点增加访问计数和奖励。
这就是一次 MCTS 迭代中的四个步骤。正如蒙特卡洛方法的名字所示,我们会进行多次搜索,然后决定下一步走哪步。迭代次数是可配置的,通常取决于可用的时间或资源。随着时间的推移,树会学习出一种接近完美树的结构,进而可以用来引导智能体做出决策。
AlphaGo 和 AlphaGo Zero,DeepMind 的革命性围棋对弈智能体,依赖 MCTS 来选择棋步。在接下来的部分,我们将探讨这两种算法,了解它们如何将神经网络和 MCTS 结合起来,以超人的水平下围棋。
AlphaGo
AlphaGo 的主要创新在于它如何将深度学习和蒙特卡洛树搜索相结合来下围棋。AlphaGo 架构由四个神经网络组成:一个小型的监督学习策略网络,一个大型的监督学习策略网络,一个强化学习策略网络和一个价值网络。我们训练这四个网络以及 MCTS 树。接下来的章节将详细介绍每个训练步骤。
监督学习策略网络
AlphaGo 训练的第一步涉及对两位职业选手下的围棋进行训练(在棋类游戏如国际象棋和围棋中,通常会记录历史比赛、棋盘状态和每一步棋的玩家动作)。主要思路是让 AlphaGo 学习并理解人类专家如何下围棋。更正式地说,给定一个棋盘状态,,和一组动作,
,我们希望一个策略网络,
,预测人类的下一步棋。数据由从 KGS 围棋服务器上 30,000,000 多场历史比赛中采样得到的棋盘状态对组成。网络的输入包括棋盘状态以及元数据。AlphaGo 有两个不同大小的监督学习策略网络。大型网络是一个 13 层的卷积神经网络,隐藏层使用 ReLU 激活函数,而较小的网络是一个单层的 softmax 网络。
为什么我们训练两个相似的网络?较大的策略网络初始化强化学习策略网络的权重,后者通过一种叫做策略梯度的 RL 方法进一步优化。较小的网络在 MCTS 的仿真步骤中使用。记住,虽然 MCTS 中的大多数仿真依赖于随机选择动作,但也可以利用轻度或重度启发式方法来进行更智能的仿真。较小的网络虽然缺乏较大监督网络的准确性,但推理速度更快,为回滚提供轻度启发式。
强化学习策略网络
一旦较大的监督学习策略网络训练完成,我们通过让 RL 策略网络与自己之前的版本进行对抗,进一步改进模型。网络的权重通过一种叫做策略梯度的方法进行更新,这是一种用于普通神经网络的梯度下降变种。从形式上来说,我们的 RL 策略网络的权重更新规则可以表示如下:
这里, 是 RL 策略网络的权重,
,和
是在时间步
的预期奖励。奖励就是游戏的结果,胜利得 +1,失败得 -1。在这里,监督学习策略网络和强化学习策略网络的主要区别在于:前者的目标是最大化给定状态下选择某个特定动作的概率,换句话说,就是简单地模仿历史游戏中的动作。由于没有奖励函数,它并不关心游戏的最终结果。
另一方面,强化学习策略网络在更新权重时考虑了最终结果。更具体地说,它尝试最大化那些有助于获得更高奖励(即获胜动作)的动作的对数似然性。这是因为我们将对数似然的梯度与奖励(+1 或-1)相乘,从而决定了调整权重的方向。若某个动作不好,其权重会朝相反方向调整,因为我们可能会将梯度与-1 相乘。总结来说,网络不仅试图找出最可能的动作,还试图找出能够帮助它获胜的动作。根据 DeepMind 的论文,强化学习策略网络在与其监督学习对手及其他围棋程序(如 Pachi)对抗时,赢得了绝大多数(80%~85%)的比赛。
值网络
管道的最后一步涉及训练一个价值网络来评估棋盘状态,换句话说,就是确定某一特定棋盘状态对赢得游戏的有利程度。严格来说,给定特定的策略, 和状态,
,我们希望预测预期奖励,
。通过最小化均方误差(MSE)来训练网络,其中预测值,
,与最终结果之间的差异:
其中 是网络的参数。实际上,网络是在 30,000,000 对状态-奖励对上训练的,每一对都来自于一局独立的游戏。数据集是这样构建的,因为同一游戏中的棋盘状态可能高度相关,可能导致过拟合。
结合神经网络和蒙特卡洛树搜索(MCTS)
在 AlphaGo 中,策略网络和价值网络与 MCTS 相结合,在选择游戏中的行动时提供前瞻性搜索。之前,我们讨论了 MCTS 如何追踪每个节点的平均奖励和访问次数。在 AlphaGo 中,我们还需要追踪以下几个值:
-
:选择特定动作的平均行动价值
-
:由较大的监督学习策略网络给定的特定棋盘状态下采取某个动作的概率
-
:尚未探索的状态(叶节点)的价值评估
-
:给定状态下选择特定动作的次数
在我们树搜索的单次模拟过程中,算法为给定的状态,,在特定的时间步,
,选择一个动作,
,根据以下公式:
其中
因此 是一个值,偏向于由较大的策略网络判定为更可能的走法,但也通过惩罚那些被更频繁访问的走法来支持探索。
在扩展过程中,当我们没有给定棋盘状态和棋步的前置统计信息时,我们使用价值网络和模拟来评估叶子节点。特别地,我们对价值网络给出的预期值和模拟结果进行加权求和:
其中, 是价值网络的评估,
是搜索的最终奖励,
是通常称为混合参数的权重项。
是在展开后获得的,其中的模拟是通过使用较小且更快速的监督学习策略网络进行的。快速展开非常重要,尤其是在决策时间有限的情况下,因此需要较小的策略网络。
最后,在 MCTS 的更新步骤中,每个节点的访问计数会更新。此外,行动值通过计算所有包含给定节点的模拟的平均奖励来重新计算:
其中, 是在
轮次中 MCTS 所采取的总奖励,
是在节点
采取的行动。经过 MCTS 搜索后,模型在实际对弈时选择最常访问的棋步。
这就是 AlphaGo 的基本概述。虽然对其架构和方法论的深入讲解超出了本书的范围,但希望这能作为介绍 AlphaGo 工作原理的入门指南。
AlphaGo Zero
在我们开始编写代码之前,我们将介绍 AlphaGo Zero,这一其前身的升级版。AlphaGo Zero 的主要特点解决了 AlphaGo 一些缺点,包括它对大量人类专家对弈数据的依赖。
AlphaGo Zero 和 AlphaGo 之间的主要区别如下:
-
AlphaGo Zero 完全通过自我对弈强化学习进行训练,这意味着它不依赖于任何人类生成的数据或监督,而这些通常用于训练 AlphaGo。
-
策略和价值网络合并为一个网络,并通过两个输出头表示,而不是两个独立的网络。
-
网络的输入是棋盘本身,作为图像输入,比如二维网格;该网络不依赖于启发式方法,而是直接使用原始的棋盘状态。
-
除了寻找最佳走法外,蒙特卡洛树搜索还用于策略迭代和评估;此外,AlphaGo Zero 在搜索过程中不进行展开。
训练 AlphaGo Zero
由于我们在训练或监督过程中不使用人类生成的数据,那么 AlphaGo Zero 是如何学习的呢?DeepMind 开发的这一新型强化学习算法涉及使用 MCTS 作为神经网络的教师,而该网络同时表示策略和价值函数。
特别地,MCTS 的输出包括 1)每次在模拟过程中选择移动的概率,,以及 2)游戏的最终结果,
。神经网络,
,接受一个棋盘状态,
,并输出一个元组,
,其中,
是一个表示移动概率的向量,
是
的值。根据这些输出,我们希望训练我们的网络,使得网络的策略,
,向由 MCTS 生成的策略,
,靠近,并且网络的值,
,向最终结果,
,靠近。请注意,在 MCTS 中,算法不进行滚动扩展,而是依赖于
来进行扩展,并模拟整个游戏直到结束。因此,在 MCTS 结束时,算法将策略从
改进为
,并能够作为
的教师。网络的损失函数由两部分组成:一部分是
与
之间的交叉熵,另一部分是
与
之间的均方误差。这个联合损失函数如下所示:
其中,是网络参数,
是 L2 正则化的参数。
与 AlphaGo 的对比
根据 DeepMind 的论文,AlphaGo Zero 能在 36 小时内超越 AlphaGo,而后者则需要数月时间进行训练。在与击败李世石版本的 AlphaGo 进行的一对一比赛中,AlphaGo Zero 赢得了 100 场比赛中的 100 场。值得注意的是,尽管没有初始的人类监督,这个围棋程序能够更加高效地达到超越人类的水平,并发现人类在数千年的时间里通过数百万局游戏培养出的大量知识和智慧。
在接下来的章节中,我们将最终实现这个强大的算法。我们将在代码实现过程中涵盖 AlphaGo Zero 的其他技术细节。
实现 AlphaGo Zero
最后,我们将在这一部分实现 AlphaGo Zero。除了实现比 AlphaGo 更好的性能外,实际上它的实现相对容易一些。这是因为,如前所述,AlphaGo Zero 仅依赖selfplay数据进行学习,从而减轻了我们寻找大量历史数据的负担。此外,我们只需要实现一个神经网络,它既作为策略函数,也作为价值函数。以下实现做了一些进一步的简化——例如,我们假设围棋棋盘的大小是 9,而不是 19,以便加速训练。
我们实现的目录结构如下所示:
alphago_zero/
|-- __init__.py
|-- config.py
|-- constants.py
|-- controller.py
|-- features.py
|-- go.py
|-- mcts.py
|-- alphagozero_agent.py
|-- network.py
|-- preprocessing.py
|-- train.py
`-- utils.py
我们将特别关注network.py和mcts.py,它们包含了双网络和 MCTS 算法的实现。此外,alphagozero_agent.py包含了将双网络与 MCTS 结合以创建围棋对弈代理的实现。
策略和价值网络
让我们开始实现双网络,我们将其称为PolicyValueNetwork。首先,我们将创建一些模块,其中包含我们的PolicyValueNetwork将使用的配置和常量。
preprocessing.py
preprocessing.py模块主要处理从TFRecords文件的读取和写入,TFRecords是 TensorFlow 的原生数据表示文件格式。在训练 AlphaGo Zero 时,我们存储了 MCTS 自对弈结果和棋步。如前所述,这些数据成为PolicyValueNetwork学习的真实数据。TFRecords提供了一种方便的方式来保存来自 MCTS 的历史棋步和结果。当从磁盘读取这些数据时,preprocessing.py将TFRecords转换为tf.train.Example,这是一种内存中的数据表示,可以直接输入到tf.estimator.Estimator中。
tf_records通常以*.tfrecord.zz为文件名后缀。
以下函数用于从TFRecords文件中读取数据。我们首先将给定的TFRecords列表转换为tf.data.TFRecordDataset,这是在将其转换为tf.train.Example之前的中间表示:
def process_tf_records(list_tf_records, shuffle_records=True,
buffer_size=GLOBAL_PARAMETER_STORE.SHUFFLE_BUFFER_SIZE,
batch_size=GLOBAL_PARAMETER_STORE.TRAIN_BATCH_SIZE):
if shuffle_records:
random.shuffle(list_tf_records)
list_dataset = tf.data.Dataset.from_tensor_slices(list_tf_records)
tensors_dataset = list_dataset.interleave(map_func=lambda x: tf.data.TFRecordDataset(x, compression_type='ZLIB'),
cycle_length=GLOBAL_PARAMETER_STORE.CYCLE_LENGTH,
block_length=GLOBAL_PARAMETER_STORE.BLOCK_LENGTH)
tensors_dataset = tensors_dataset.repeat(1).shuffle(buffer_siz=buffer_size).batch(batch_size)
return tensors_dataset
下一步是解析这个数据集,以便将数值输入到PolicyValueNetwork中。我们关心的有三个数值:输入,整个实现过程中我们称之为x或board_state,策略pi,以及输出结果z,这两个值都是由 MCTS 算法输出的:
def parse_batch_tf_example(example_batch):
features = {
'x': tf.FixedLenFeature([], tf.string),
'pi': tf.FixedLenFeature([], tf.string),
'z': tf.FixedLenFeature([], tf.float32),
}
parsed_tensors = tf.parse_example(example_batch, features)
# Get the board state
x = tf.cast(tf.decode_raw(parsed_tensors['x'], tf.uint8), tf.float32)
x = tf.reshape(x, [GLOBAL_PARAMETER_STORE.TRAIN_BATCH_SIZE, GOPARAMETERS.N,
GOPARAMETERS.N, FEATUREPARAMETERS.NUM_CHANNELS])
# Get the policy target, which is the distribution of possible moves
# Each target is a vector of length of board * length of board + 1
distribution_of_moves = tf.decode_raw(parsed_tensors['pi'], tf.float32)
distribution_of_moves = tf.reshape(distribution_of_moves,
[GLOBAL_PARAMETER_STORE.TRAIN_BATCH_SIZE, GOPARAMETERS.N * GOPARAMETERS.N + 1])
# Get the result of the game
# The result is simply a scalar
result_of_game = parsed_tensors['z']
result_of_game.set_shape([GLOBAL_PARAMETER_STORE.TRAIN_BATCH_SIZE])
return (x, {'pi_label': distribution_of_moves, 'z_label': result_of_game})
前面的两个函数将在以下函数中结合,构造要输入网络的输入张量:
def get_input_tensors(list_tf_records, buffer_size=GLOBAL_PARAMETER_STORE.SHUFFLE_BUFFER_SIZE):
logger.info("Getting input data and tensors")
dataset = process_tf_records(list_tf_records=list_tf_records,
buffer_size=buffer_size)
dataset = dataset.filter(lambda input_tensor: tf.equal(tf.shape(input_tensor)[0],
GLOBAL_PARAMETER_STORE.TRAIN_BATCH_SIZE))
dataset = dataset.map(parse_batch_tf_example)
logger.info("Finished parsing")
return dataset.make_one_shot_iterator().get_next()
最后,以下函数用于将自对弈结果写入磁盘:
def create_dataset_from_selfplay(data_extracts):
return (create_tf_train_example(extract_features(board_state), pi, result)
for board_state, pi, result in data_extracts)
def shuffle_tf_examples(batch_size, records_to_shuffle):
tf_dataset = process_tf_records(records_to_shuffle, batch_size=batch_size)
iterator = tf_dataset.make_one_shot_iterator()
next_dataset_batch = iterator.get_next()
sess = tf.Session()
while True:
try:
result = sess.run(next_dataset_batch)
yield list(result)
except tf.errors.OutOfRangeError:
break
def create_tf_train_example(board_state, pi, result):
board_state_as_tf_feature = tf.train.Feature(bytes_list=tf.train.BytesList(value=[board_state.tostring()]))
pi_as_tf_feature = tf.train.Feature(bytes_list=tf.train.BytesList(value=[pi.tostring()]))
value_as_tf_feature = tf.train.Feature(float_list=tf.train.FloatList(value=[result]))
tf_example = tf.train.Example(features=tf.train.Features(feature={
'x': board_state_as_tf_feature,
'pi': pi_as_tf_feature,
'z': value_as_tf_feature
}))
return tf_example
def write_tf_examples(record_path, tf_examples, serialize=True):
with tf.python_io.TFRecordWriter(record_path, options=TF_RECORD_CONFIG) as tf_record_writer:
for tf_example in tf_examples:
if serialize:
tf_record_writer.write(tf_example.SerializeToString())
else:
tf_record_writer.write(tf_example)
这些函数中的一些将在后续生成自对弈结果的训练数据时使用。
features.py
该模块包含将围棋棋盘表示转化为适当 TensorFlow 张量的辅助代码,这些张量可以提供给PolicyValueNetwork。主要功能extract_features接收board_state(即围棋棋盘的表示),并将其转换为形状为[batch_size, N, N, 17]的张量,其中N是棋盘的形状(默认为9),17是特征通道的数量,表示过去的着棋和当前要下的颜色:
import numpy as np
from config import GOPARAMETERS
def stone_features(board_state):
# 16 planes, where every other plane represents the stones of a particular color
# which means we track the stones of the last 8 moves.
features = np.zeros([16, GOPARAMETERS.N, GOPARAMETERS.N], dtype=np.uint8)
num_deltas_avail = board_state.board_deltas.shape[0]
cumulative_deltas = np.cumsum(board_state.board_deltas, axis=0)
last_eight = np.tile(board_state.board, [8, 1, 1])
last_eight[1:num_deltas_avail + 1] -= cumulative_deltas
last_eight[num_deltas_avail +1:] = last_eight[num_deltas_avail].reshape(1, GOPARAMETERS.N, GOPARAMETERS.N)
features[::2] = last_eight == board_state.to_play
features[1::2] = last_eight == -board_state.to_play
return np.rollaxis(features, 0, 3)
def color_to_play_feature(board_state):
# 1 plane representing which color is to play
# The plane is filled with 1's if the color to play is black; 0's otherwise
if board_state.to_play == GOPARAMETERS.BLACK:
return np.ones([GOPARAMETERS.N, GOPARAMETERS.N, 1], dtype=np.uint8)
else:
return np.zeros([GOPARAMETERS.N, GOPARAMETERS.N, 1], dtype=np.uint8)
def extract_features(board_state):
stone_feat = stone_features(board_state=board_state)
turn_feat = color_to_play_feature(board_state=board_state)
all_features = np.concatenate([stone_feat, turn_feat], axis=2)
return all_features
extract_features函数将被preprocessing.py和network.py模块使用,以构建特征张量,这些张量要么写入TFRecord文件,要么输入到神经网络中。
network.py
本文件包含我们对PolicyValueNetwork的实现。简而言之,我们构建一个tf.estimator.Estimator,该估算器使用围棋棋盘状态、策略和通过 MCTS 自对弈生成的自对弈结果进行训练。该网络有两个头:一个作为价值函数,另一个作为策略网络。
首先,我们定义一些将被PolicyValueNetwork使用的层:
import functools
import logging
import os.path
import tensorflow as tf
import features
import preprocessing
import utils
from config import GLOBAL_PARAMETER_STORE, GOPARAMETERS
from constants import *
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
def create_partial_bn_layer(params):
return functools.partial(tf.layers.batch_normalization,
momentum=params["momentum"],
epsilon=params["epsilon"],
fused=params["fused"],
center=params["center"],
scale=params["scale"],
training=params["training"]
)
def create_partial_res_layer(inputs, partial_bn_layer, partial_conv2d_layer):
output_1 = partial_bn_layer(partial_conv2d_layer(inputs))
output_2 = tf.nn.relu(output_1)
output_3 = partial_bn_layer(partial_conv2d_layer(output_2))
output_4 = tf.nn.relu(tf.add(inputs, output_3))
return output_4
def softmax_cross_entropy_loss(logits, labels):
return tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=labels['pi_label']))
def mean_squared_loss(output_value, labels):
return tf.reduce_mean(tf.square(output_value - labels['z_label']))
def get_losses(logits, output_value, labels):
ce_loss = softmax_cross_entropy_loss(logits, labels)
mse_loss = mean_squared_loss(output_value, labels)
return ce_loss, mse_loss
def create_metric_ops(labels, output_policy, loss_policy, loss_value, loss_l2, loss_total):
return {'accuracy': tf.metrics.accuracy(labels=labels['pi_label'], predictions=output_policy, name='accuracy'),
'loss_policy': tf.metrics.mean(loss_policy),
'loss_value': tf.metrics.mean(loss_value),
'loss_l2': tf.metrics.mean(loss_l2),
'loss_total': tf.metrics.mean(loss_total)}
接下来,我们有一个函数,用于创建tf.estimator.Estimator。虽然 TensorFlow 提供了几个预构建的估算器,如tf.estimator.DNNClassifier,但我们的架构相当独特,这就是我们需要构建自己的Estimator的原因。这可以通过创建tf.estimator.EstimatorSpec来完成,它是一个骨架类,我们可以在其中定义输出张量、网络架构、损失函数和评估度量等:
def generate_network_specifications(features, labels, mode, params, config=None):
batch_norm_params = {"epsilon": 1e-5, "fused": True, "center": True, "scale": True, "momentum": 0.997,
"training": mode==tf.estimator.ModeKeys.TRAIN
}
我们的generate_network_specifications函数接收多个输入:
-
features:围棋棋盘的张量表示(形状为[batch_size, 9, 9, 17]) -
labels:我们的pi和z张量 -
mode:在这里,我们可以指定我们的网络是处于训练模式还是测试模式 -
params:指定网络结构的附加参数(例如,卷积滤波器大小)
然后我们实现网络的共享部分,策略输出头、价值输出头以及损失函数:
with tf.name_scope("shared_layers"):
partial_bn_layer = create_partial_bn_layer(batch_norm_params)
partial_conv2d_layer = functools.partial(tf.layers.conv2d,
filters=params[HYPERPARAMS.NUM_FILTERS], kernel_size=[3, 3], padding="same")
partial_res_layer = functools.partial(create_partial_res_layer, batch_norm=partial_bn_layer,
conv2d=partial_conv2d_layer)
output_shared = tf.nn.relu(partial_bn_layer(partial_conv2d_layer(features)))
for i in range(params[HYPERPARAMS.NUMSHAREDLAYERS]):
output_shared = partial_res_layer(output_shared)
# Implement the policy network
with tf.name_scope("policy_network"):
conv_p_output = tf.nn.relu(partial_bn_layer(partial_conv2d_layer(output_shared, filters=2,
kernel_size=[1, 1]),
center=False, scale=False))
logits = tf.layers.dense(tf.reshape(conv_p_output, [-1, GOPARAMETERS.N * GOPARAMETERS.N * 2]),
units=GOPARAMETERS.N * GOPARAMETERS.N + 1)
output_policy = tf.nn.softmax(logits,
name='policy_output')
# Implement the value network
with tf.name_scope("value_network"):
conv_v_output = tf.nn.relu(partial_bn_layer(partial_conv2d_layer(output_shared, filters=1, kernel_size=[1, 1]),
center=False, scale=False))
fc_v_output = tf.nn.relu(tf.layers.dense(
tf.reshape(conv_v_output, [-1, GOPARAMETERS.N * GOPARAMETERS.N]),
params[HYPERPARAMS.FC_WIDTH]))
fc_v_output = tf.layers.dense(fc_v_output, 1)
fc_v_output = tf.reshape(fc_v_output, [-1])
output_value = tf.nn.tanh(fc_v_output, name='value_output')
# Implement the loss functions
with tf.name_scope("loss_functions"):
loss_policy, loss_value = get_losses(logits=logits,
output_value=output_value,
labels=labels)
loss_l2 = params[HYPERPARAMS.BETA] * tf.add_n([tf.nn.l2_loss(v)
for v in tf.trainable_variables() if not 'bias' in v.name])
loss_total = loss_policy + loss_value + loss_l2
然后我们指定优化算法。这里,我们使用tf.train.MomentumOptimizer。我们还会在训练过程中调整学习率;由于一旦创建了Estimator我们不能直接更改学习率,因此我们将学习率更新转化为 TensorFlow 操作。我们还将多个度量记录到 TensorBoard 中:
# Steps and operations for training
global_step = tf.train.get_or_create_global_step()
learning_rate = tf.train.piecewise_constant(global_step, GLOBAL_PARAMETER_STORE.BOUNDARIES,
GLOBAL_PARAMETER_STORE.LEARNING_RATE)
update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
with tf.control_dependencies(update_ops):
train_op = tf.train.MomentumOptimizer(learning_rate,
params[HYPERPARAMS.MOMENTUM]).minimize(loss_total, global_step=global_step)
metric_ops = create_metric_ops(labels=labels,
output_policy=output_policy,
loss_policy=loss_policy,
loss_value=loss_value,
loss_l2=loss_l2,
loss_total=loss_total)
for metric_name, metric_op in metric_ops.items():
tf.summary.scalar(metric_name, metric_op[1])
最后,我们创建一个tf.estimator.EstimatorSpec对象并返回。创建时我们需要指定几个参数:
-
mode:训练模式或测试模式,如前所述。 -
predictions:一个字典,将字符串(名称)映射到网络的输出操作。注意,我们可以指定多个输出操作。 -
loss:损失函数操作。 -
train_op:优化操作。 -
eval_metrics_op:运行以存储多个度量的操作,如损失、准确率和变量权重值。
对于predictions参数,我们提供政策网络和值网络的输出:
return tf.estimator.EstimatorSpec(
mode=mode,
predictions={
'policy_output': output_policy,
'value_output': output_value,
},
loss=loss_total,
train_op=train_op,
eval_metric_ops=metric_ops,
)
在训练 AlphaGo Zero 的第一步中,我们必须用随机权重初始化模型。以下函数实现了这一点:
def initialize_random_model(estimator_dir, **kwargs):
sess = tf.Session(graph=tf.Graph())
params = utils.parse_parameters(**kwargs)
initial_model_path = os.path.join(estimator_dir, PATHS.INITIAL_CHECKPOINT_NAME)
# Create the first model, where all we do is initialize random weights and immediately write them to disk
with sess.graph.as_default():
features, labels = get_inference_input()
generate_network_specifications(features, labels, tf.estimator.ModeKeys.PREDICT, params)
sess.run(tf.global_variables_initializer())
tf.train.Saver().save(sess, initial_model_path)
我们使用以下函数根据给定的一组参数创建tf.estimator.Estimator对象:
def get_estimator(estimator_dir, **kwargs):
params = utils.parse_parameters(**kwargs)
return tf.estimator.Estimator(generate_network_specifications, model_dir=estimator_dir, params=params)
tf.estimator.Estimator期望一个提供tf.estimator.EstimatorSpec的函数,这就是我们的generate_network_specifications函数。这里,estimator_dir指的是存储我们网络检查点的目录。通过提供此参数,我们的tf.estimator.Estimator对象可以加载之前训练迭代的权重。
我们还实现了用于训练和验证模型的函数:
def train(estimator_dir, tf_records, model_version, **kwargs):
"""
Main training function for the PolicyValueNetwork
Args:
estimator_dir (str): Path to the estimator directory
tf_records (list): A list of TFRecords from which we parse the training examples
model_version (int): The version of the model
"""
model = get_estimator(estimator_dir, **kwargs)
logger.info("Training model version: {}".format(model_version))
max_steps = model_version * GLOBAL_PARAMETER_STORE.EXAMPLES_PER_GENERATION // \
GLOBAL_PARAMETER_STORE.TRAIN_BATCH_SIZE
model.train(input_fn=lambda: preprocessing.get_input_tensors(list_tf_records=tf_records),
max_steps=max_steps)
logger.info("Trained model version: {}".format(model_version))
def validate(estimator_dir, tf_records, checkpoint_path=None, **kwargs):
model = get_estimator(estimator_dir, **kwargs)
if checkpoint_path is None:
checkpoint_path = model.latest_checkpoint()
model.evaluate(input_fn=lambda: preprocessing.get_input_tensors(
list_tf_records=tf_records,
buffer_size=GLOBAL_PARAMETER_STORE.VALIDATION_BUFFER_SIZE),
steps=GLOBAL_PARAMETER_STORE.VALIDATION_NUMBER_OF_STEPS,
checkpoint_path=checkpoint_path)
tf.estimator.Estimator.train函数期望一个提供训练数据批次的函数(input_fn)。input_data使用我们在preprocessing.py模块中的get_input_tensors函数解析TFRecords数据并将其转化为输入张量。tf.estimator.Estimator.evaluate函数也期望相同的输入函数。
最后,我们将估算器封装到我们的PolicyValueNetwork中。这个类使用网络的路径(model_path)并加载其权重。它使用该网络来预测给定棋盘状态的价值和最可能的下一步棋:
class PolicyValueNetwork():
def __init__(self, model_path, **kwargs):
self.model_path = model_path
self.params = utils.parse_parameters(**kwargs)
self.build_network()
def build_session(self):
config = tf.ConfigProto()
config.gpu_options.allow_growth = True
return tf.Session(graph=tf.Graph(), config=config)
def build_network(self):
self.sess = self.build_session()
with self.sess.graph.as_default():
features, labels = get_inference_input()
model_spec = generate_network_specifications(features, labels,
tf.estimator.ModeKeys.PREDICT, self.params)
self.inference_input = features
self.inference_output = model_spec.predictions
if self.model_path is not None:
self.load_network_weights(self.model_path)
else:
self.sess.run(tf.global_variables_initializer())
def load_network_weights(self, save_file):
tf.train.Saver().restore(self.sess, save_file)
传递给构造函数的model_path参数是模型特定版本的目录。当此参数为None时,我们初始化随机权重。以下函数用于预测下一步动作的概率和给定棋盘状态的价值:
def predict_on_single_board_state(self, position):
probs, values = self.predict_on_multiple_board_states([position])
prob = probs[0]
value = values[0]
return prob, value
def predict_on_multiple_board_states(self, positions):
symmetries, processed = utils.shuffle_feature_symmetries(list(map(features.extract_features, positions)))
network_outputs = self.sess.run(self.inference_output, feed_dict={self.inference_input: processed})
action_probs, value_pred = network_outputs['policy_output'], network_outputs['value_output']
action_probs = utils.invert_policy_symmetries(symmetries, action_probs)
return action_probs, value_pred
请检查 GitHub 仓库,以获取该模块的完整实现。
蒙特卡洛树搜索(Monte Carlo tree search)
我们的 AlphaGo Zero 代理的第二个组件是 MCTS 算法。在我们的mcts.py模块中,我们实现了一个MCTreeSearchNode类,该类表示 MCTS 树中每个节点在搜索过程中的状态。然后,该类被alphagozero_agent.py中实现的代理使用,利用我们刚才实现的PolicyValueNetwork来执行 MCTS。
mcts.py
mcts.py包含了我们对蒙特卡洛树搜索的实现。我们的第一个类是RootNode,它用于表示模拟开始时 MCTS 树的根节点。根据定义,根节点没有父节点。为根节点创建一个单独的类并非绝对必要,但这样可以使代码更清晰:
import collections
import math
import numpy as np
import utils
from config import MCTSPARAMETERS, GOPARAMETERS
class RootNode(object):
def __init__(self):
self.parent_node = None
self.child_visit_counts = collections.defaultdict(float)
self.child_cumulative_rewards = collections.defaultdict(float)
接下来,我们实现MCTreeSearchNode类。该类具有多个属性,其中最重要的几个如下:
-
parent_node: 父节点 -
previous_move: 导致此节点棋盘状态的上一步棋 -
board_state: 当前棋盘状态 -
is_visited: 是否展开了叶子(子节点);当节点初始化时,这个值为False。 -
child_visit_counts: 一个numpy.ndarray,表示每个子节点的访问次数 -
child_cumulative_rewards: 一个numpy.ndarray,表示每个子节点的累计奖励 -
children_moves: 子节点走法的字典
我们还定义了一些参数,比如 loss_counter、original_prior 和 child_prior。这些与 AlphaGo Zero 实现的高级 MCTS 技术相关,例如并行搜索过程以及向搜索中加入噪声。为了简洁起见,我们不会详细讨论这些技术,因此现在可以忽略它们。
这是 MCTreeSearchNode 类的 __init__ 函数:
class MCTreeSearchNode(object):
def __init__(self, board_state, previous_move=None, parent_node=None):
"""
A node of a MCTS tree. It is primarily responsible with keeping track of its children's scores
and other statistics such as visit count. It also makes decisions about where to move next.
board_state (go.BoardState): The Go board
fmove (int): A number which represents the coordinate of the move that led to this board state. None if pass
parent (MCTreeSearchNode): The parent node
"""
if parent_node is None:
parent_node = RootNode()
self.parent_node = parent_node
self.previous_move = previous_move
self.board_state = board_state
self.is_visited = False
self.loss_counter = 0
self.illegal_moves = 1000 * (1 - self.board_state.enumerate_possible_moves())
self.child_visit_counts = np.zeros([GOPARAMETERS.N * GOPARAMETERS.N + 1], dtype=np.float32)
self.child_cumulative_rewards = np.zeros([GOPARAMETERS.N * GOPARAMETERS.N + 1], dtype=np.float32)
self.original_prior = np.zeros([GOPARAMETERS.N * GOPARAMETERS.N + 1], dtype=np.float32)
self.child_prior = np.zeros([GOPARAMETERS.N * GOPARAMETERS.N + 1], dtype=np.float32)
self.children_moves = {}
每个节点会跟踪每个子节点的平均奖励和动作值。我们将这些设置为属性:
@property
def child_action_score(self):
return self.child_mean_rewards * self.board_state.to_play + self.child_node_scores - self.illegal_moves
@property
def child_mean_rewards(self):
return self.child_cumulative_rewards / (1 + self.child_visit_counts)
@property
def child_node_scores(self):
# This scores each child according to the UCT scoring system
return (MCTSPARAMETERS.c_PUCT * math.sqrt(1 + self.node_visit_count) * self.child_prior /
(1 + self.child_visit_counts))
当然,我们还会跟踪节点自身的动作值、访问次数和累积奖励。请记住,child_mean_rewards 是平均奖励,child_visit_counts 是子节点被访问的次数,child_cumulative_rewards 是节点的总奖励。我们通过添加 @property 和 @*.setter 装饰器为每个属性实现 getter 和 setter 方法:
@property
def node_mean_reward(self):
return self.node_cumulative_reward / (1 + self.node_visit_count)
@property
def node_visit_count(self):
return self.parent_node.child_visit_counts[self.previous_move]
@node_visit_count.setter
def node_visit_count(self, value):
self.parent_node.child_visit_counts[self.previous_move] = value
@property
def node_cumulative_reward(self):
return self.parent_node.child_cumulative_rewards[self.previous_move]
@node_cumulative_reward.setter
def node_cumulative_reward(self, value):
self.parent_node.child_cumulative_rewards[self.previous_move] = value
@property
def mean_reward_perspective(self):
return self.node_mean_reward * self.board_state.to_play
在 MCTS 的选择步骤中,算法会选择具有最大动作值的子节点。这可以通过对子节点动作得分矩阵调用 np.argmax 来轻松完成:
def choose_next_child_node(self):
current = self
pass_move = GOPARAMETERS.N * GOPARAMETERS.N
while True:
current.node_visit_count += 1
# We stop searching when we reach a new leaf node
if not current.is_visited:
break
if (current.board_state.recent
and current.board_state.recent[-1].move is None
and current.child_visit_counts[pass_move] == 0):
current = current.record_child_node(pass_move)
continue
best_move = np.argmax(current.child_action_score)
current = current.record_child_node(best_move)
return current
def record_child_node(self, next_coordinate):
if next_coordinate not in self.children_moves:
new_board_state = self.board_state.play_move(
utils.from_flat(next_coordinate))
self.children_moves[next_coordinate] = MCTreeSearchNode(
new_board_state, previous_move=next_coordinate, parent_node=self)
return self.children_moves[next_coordinate]
正如我们在讨论 AlphaGo Zero 部分中提到的,PolicyValueNetwork 用于在 MCTS 迭代中进行模拟。同样,网络的输出是节点的概率和预测值,然后我们将其反映在 MCTS 树中。特别地,预测值通过 back_propagate_result 函数在树中传播:
def incorporate_results(self, move_probabilities, result, start_node):
if self.is_visited:
self.revert_visits(start_node=start_node)
return
self.is_visited = True
self.original_prior = self.child_prior = move_probabilities
self.child_cumulative_rewards = np.ones([GOPARAMETERS.N * GOPARAMETERS.N + 1], dtype=np.float32) * result
self.back_propagate_result(result, start_node=start_node)
def back_propagate_result(self, result, start_node):
"""
This function back propagates the result of a match all the way to where the search started from
Args:
result (int): the result of the search (1: black, -1: white won)
start_node (MCTreeSearchNode): the node to back propagate until
"""
# Keep track of the cumulative reward in this node
self.node_cumulative_reward += result
if self.parent_node is None or self is start_node:
return
self.parent_node.back_propagate_result(result, start_node)
请参考 GitHub 仓库,查看我们 MCTreeSearchNode 类及其函数的完整实现。
结合 PolicyValueNetwork 和 MCTS
我们将 PolicyValueNetwork 和 MCTS 实现结合在 alphagozero_agent.py 中。该模块实现了 AlphaGoZeroAgent,它是主要的 AlphaGo Zero 代理,使用 PolicyValueNetwork 进行 MCTS 搜索和推理,以进行游戏。
alphagozero_agent.py
最后,我们实现了一个代理,作为围棋游戏和算法之间的接口。我们将实现的主要类是 AlphaGoZeroAgent。同样,这个类将 PolicyValueNetwork 与我们的 MCTS 模块结合,正如 AlphaGo Zero 中所做的那样,用于选择走法并模拟游戏。请注意,任何缺失的模块(例如,go.py,它实现了围棋本身)可以在主 GitHub 仓库中找到:
import logging
import os
import random
import time
import numpy as np
import go
import utils
from config import GLOBAL_PARAMETER_STORE, GOPARAMETERS
from mcts import MCTreeSearchNode
from utils import make_sgf
logger = logging.getLogger(__name__)
class AlphaGoZeroAgent:
def __init__(self, network, player_v_player=False, workers=GLOBAL_PARAMETER_STORE.SIMULTANEOUS_LEAVES):
self.network = network
self.player_v_player = player_v_player
self.workers = workers
self.mean_reward_store = []
self.game_description_store = []
self.child_probability_store = []
self.root = None
self.result = 0
self.logging_buffer = None
self.conduct_exploration = True
if self.player_v_player:
self.conduct_exploration = True
else:
self.conduct_exploration = False
我们通过初始化我们的代理和游戏本身来开始围棋游戏。这是通过 initialize_game 方法完成的,该方法初始化了 MCTreeSearchNode 和用于跟踪网络输出的走法概率和动作值的缓冲区:
def initialize_game(self, board_state=None):
if board_state is None:
board_state = go.BoardState()
self.root = MCTreeSearchNode(board_state)
self.result = 0
self.logging_buffer = None
self.game_description_store = []
self.child_probability_store = []
self.mean_reward_store = []
在每一回合中,我们的代理会进行 MCTS 并使用 select_move 函数选择一个走法。注意,在游戏的早期阶段,我们允许一定的探索,通过选择一个随机节点来进行。
play_move(coordinates) 方法接受由 select_move 返回的坐标,并更新 MCTS 树和棋盘状态:
def play_move(self, coordinates):
if not self.player_v_player:
self.child_probability_store.append(self.root.get_children_as_probability_distributions())
self.mean_reward_store.append(self.root.node_mean_reward)
self.game_description_store.append(self.root.describe())
self.root = self.root.record_child_node(utils.to_flat(coordinates))
self.board_state = self.root.board_state
del self.root.parent_node.children_moves
return True
def select_move(self):
# If we have conducted enough moves and this is single player mode, we turn off exploration
if self.root.board_state.n > GLOBAL_PARAMETER_STORE.TEMPERATURE_CUTOFF and not self.player_v_player:
self.conduct_exploration = False
if self.conduct_exploration:
child_visits_cum_sum = self.root.child_visit_counts.cumsum()
child_visits_cum_sum /= child_visits_cum_sum[-1]
coorindate = child_visits_cum_sum.searchsorted(random.random())
else:
coorindate = np.argmax(self.root.child_visit_counts)
return utils.from_flat(coorindate)
这些函数被封装在search_tree方法中,该方法使用网络进行 MCTS 迭代,以选择下一步棋:
def search_tree(self):
child_node_store = []
iteration_count = 0
while len(child_node_store) < self.workers and iteration_count < self.workers * 2:
iteration_count += 1
child_node = self.root.choose_next_child_node()
if child_node.is_done():
result = 1 if child_node.board_state.score() > 0 else -1
child_node.back_propagate_result(result, start_node=self.root)
continue
child_node.propagate_loss(start_node=self.root)
child_node_store.append(child_node)
if len(child_node_store) > 0:
move_probs, values = self.network.predict_on_multiple_board_states(
[child_node.board_state for child_node in child_node_store])
for child_node, move_prob, result in zip(child_node_store, move_probs, values):
child_node.revert_loss(start_node=self.root)
child_node.incorporate_results(move_prob, result, start_node=self.root)
注意,一旦我们拥有叶子节点(在这些节点上无法根据访问次数选择节点),我们使用PolicyValueNetwork.predict_on_multiple_board_states(board_states)函数输出每个叶子节点的下一步概率和价值。然后,使用这个AlphaGoZeroAgent来进行与另一个网络的对弈或与自身的自对弈。我们为每种情况实现了独立的函数。对于play_match,我们首先为黑白棋子各初始化一个代理:
def play_match(black_net, white_net, games, readouts, sgf_dir):
# Create the players for the game
black = AlphaGoZeroAgent(black_net, player_v_player=True, workers=GLOBAL_PARAMETER_STORE.SIMULTANEOUS_LEAVES)
white = AlphaGoZeroAgent(white_net, player_v_player=True, workers=GLOBAL_PARAMETER_STORE.SIMULTANEOUS_LEAVES)
black_name = os.path.basename(black_net.model_path)
white_name = os.path.basename(white_net.model_path)
在游戏中,我们跟踪每一步的着棋数量,这也帮助我们判断当前轮到哪个代理。每当代理轮到时,我们使用 MCTS 和网络来选择下一步棋:
for game_num in range(games):
# Keep track of the number of moves made in the game
num_moves = 0
black.initialize_game()
white.initialize_game()
while True:
start = time.time()
active = white if num_moves % 2 else black
inactive = black if num_moves % 2 else white
current_readouts = active.root.node_visit_count
while active.root.node_visit_count < current_readouts + readouts:
active.search_tree()
一旦树搜索完成,我们查看代理是否已投降或游戏是否已通过其他方式结束。如果是,我们记录结果并结束游戏:
logger.info(active.root.board_state)
# Check whether a player should resign
if active.should_resign():
active.set_result(-1 * active.root.board_state.to_play, was_resign=True)
inactive.set_result(active.root.board_state.to_play, was_resign=True)
if active.is_done():
sgf_file_path = "{}-{}-vs-{}-{}.sgf".format(int(time.time()), white_name, black_name, game_num)
with open(os.path.join(sgf_dir, sgf_file_path), 'w') as fp:
game_as_sgf_string = make_sgf(active.board_state.recent, active.logging_buffer,
black_name=black_name,
white_name=white_name)
fp.write(game_as_sgf_string)
print("Game Over", game_num, active.logging_buffer)
break
move = active.select_move()
active.play_move(move)
inactive.play_move(move)
make_sgf方法将游戏结果写入一种在其他围棋 AI 和计算机程序中常用的格式。换句话说,这个模块的输出与其他围棋软件兼容!虽然我们不会深入技术细节,但这将帮助您创建一个可以与其他代理甚至人类玩家对弈的围棋机器人。
SGF代表智能棋局格式,是一种流行的存储围棋等棋类游戏结果的格式。您可以在此了解更多信息:senseis.xmp.net/?SmartGameFormat。
play_against_self()用于训练中的自对弈模拟,而play_match()则用于将最新模型与早期版本进行对比评估。同样,关于模块的完整实现,请参考代码库。
将一切整合在一起
现在我们已经实现了 AlphaGo Zero 的两个主要组件——PolicyValueNetwork和 MCTS 算法——我们可以构建处理训练的控制器。在训练过程的最开始,我们用随机权重初始化一个模型。接下来,我们生成 100 个自对弈游戏。其中 5%的游戏及其结果会用于验证,其余的则用于训练网络。在首次初始化和自对弈迭代之后,我们基本上会循环执行以下步骤:
-
生成自对弈数据
-
整理自对弈数据以创建
TFRecords -
使用整理后的自对弈数据训练网络
-
在
holdout数据集上进行验证
每执行完第 3 步后,结果模型会存储在目录中,作为最新版本。训练过程和逻辑由controller.py处理。
controller.py
首先,我们从一些导入语句和帮助函数开始,这些帮助函数帮助我们检查目录路径并找到最新的模型版本:
import argparse
import logging
import os
import random
import socket
import sys
import time
import argh
import tensorflow as tf
from tensorflow import gfile
from tqdm import tqdm
import alphagozero_agent
import network
import preprocessing
from config import GLOBAL_PARAMETER_STORE
from constants import PATHS
from alphagozero_agent import play_match
from network import PolicyValueNetwork
from utils import logged_timer as timer
from utils import print_flags, generate, detect_model_name, detect_model_version
logging.basicConfig(
level=logging.DEBUG,
handlers=[logging.StreamHandler(sys.stdout)],
format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
)
logger = logging.getLogger(__name__)
def get_models():
"""
Get all model versions
"""
all_models = gfile.Glob(os.path.join(PATHS.MODELS_DIR, '*.meta'))
model_filenames = [os.path.basename(m) for m in all_models]
model_versionbers_names = sorted([
(detect_model_version(m), detect_model_name(m))
for m in model_filenames])
return model_versionbers_names
def get_latest_model():
"""
Get the latest model
Returns:
Tuple of <int, str>, or <model_version, model_name>
"""
return get_models()[-1]
每次训练运行的第一步是初始化一个随机模型。请注意,我们将模型定义和权重存储在PATHS.MODELS_DIR目录中,而由估算器对象输出的检查点结果则存储在PATHS.ESTIMATOR_WORKING_DIR:
def initialize_random_model():
bootstrap_name = generate(0)
bootstrap_model_path = os.path.join(PATHS.MODELS_DIR, bootstrap_name)
logger.info("Bootstrapping with working dir {}\n Model 0 exported to {}".format(
PATHS.ESTIMATOR_WORKING_DIR, bootstrap_model_path))
maybe_create_directory(PATHS.ESTIMATOR_WORKING_DIR)
maybe_create_directory(os.path.dirname(bootstrap_model_path))
network.initialize_random_model(PATHS.ESTIMATOR_WORKING_DIR)
network.export_latest_checkpoint_model(PATHS.ESTIMATOR_WORKING_DIR, bootstrap_model_path)
接下来,我们实现执行自对弈模拟的函数。如前所述,自对弈的输出包括每个棋盘状态及由 MCTS 算法生成的相关棋步和游戏结果。大多数输出存储在PATHS.SELFPLAY_DIR,而一些存储在PATHS.HOLDOUT_DIR以供验证。自对弈涉及初始化一个AlphaGoZeroAgent,并让它与自己对弈。这时我们使用了在alphagozero_agent.py中实现的play_against_self函数。在我们的实现中,我们根据GLOBAL_PARAMETER_STORE.NUM_SELFPLAY_GAMES参数执行自对弈游戏。更多的自对弈游戏能让我们的神经网络从更多经验中学习,但请记住,训练时间也会相应增加:
def selfplay():
_, model_name = get_latest_model()
try:
games = gfile.Glob(os.path.join(PATHS.SELFPLAY_DIR, model_name, '*.zz'))
if len(games) > GLOBAL_PARAMETER_STORE.MAX_GAMES_PER_GENERATION:
logger.info("{} has enough games ({})".format(model_name, len(games)))
time.sleep(600)
sys.exit(1)
except:
pass
for game_idx in range(GLOBAL_PARAMETER_STORE.NUM_SELFPLAY_GAMES):
logger.info('================================================')
logger.info("Playing game {} with model {}".format(game_idx, model_name))
logger.info('================================================')
model_save_path = os.path.join(PATHS.MODELS_DIR, model_name)
game_output_dir = os.path.join(PATHS.SELFPLAY_DIR, model_name)
game_holdout_dir = os.path.join(PATHS.HOLDOUT_DIR, model_name)
sgf_dir = os.path.join(PATHS.SGF_DIR, model_name)
clean_sgf = os.path.join(sgf_dir, 'clean')
full_sgf = os.path.join(sgf_dir, 'full')
os.makedirs(clean_sgf, exist_ok=True)
os.makedirs(full_sgf, exist_ok=True)
os.makedirs(game_output_dir, exist_ok=True)
os.makedirs(game_holdout_dir, exist_ok=True)
在自对弈过程中,我们实例化一个带有之前生成的模型权重的代理,并让它与自己对弈,游戏的数量由GLOBAL_PARAMETER_STORE.NUM_SELFPLAY_GAMES定义:
with timer("Loading weights from %s ... " % model_save_path):
network = PolicyValueNetwork(model_save_path)
with timer("Playing game"):
agent = alphagozero_agent.play_against_self(network, GLOBAL_PARAMETER_STORE.SELFPLAY_READOUTS)
代理与自己对弈后,我们将其生成的棋步存储为游戏数据,用来训练我们的策略网络和价值网络:
output_name = '{}-{}'.format(int(time.time()), socket.gethostname())
game_play = agent.extract_data()
with gfile.GFile(os.path.join(clean_sgf, '{}.sgf'.format(output_name)), 'w') as f:
f.write(agent.to_sgf(use_comments=False))
with gfile.GFile(os.path.join(full_sgf, '{}.sgf'.format(output_name)), 'w') as f:
f.write(agent.to_sgf())
tf_examples = preprocessing.create_dataset_from_selfplay(game_play)
# We reserve 5% of games played for validation
holdout = random.random() < GLOBAL_PARAMETER_STORE.HOLDOUT
if holdout:
to_save_dir = game_holdout_dir
else:
to_save_dir = game_output_dir
tf_record_path = os.path.join(to_save_dir, "{}.tfrecord.zz".format(output_name))
preprocessing.write_tf_examples(tf_record_path, tf_examples)
请注意,我们保留了一部分对弈作为验证集。
在生成自对弈数据后,我们预计大约 5%的自对弈游戏会存储在holdout目录中,用于验证。大多数自对弈数据用于训练神经网络。我们添加了另一个步骤,叫做aggregate,它将最新的模型版本及其自对弈数据用于构建TFRecords,这是我们神经网络所要求的格式。这里我们使用了在preprocessing.py中实现的函数。
def aggregate():
logger.info("Gathering game results")
os.makedirs(PATHS.TRAINING_CHUNK_DIR, exist_ok=True)
os.makedirs(PATHS.SELFPLAY_DIR, exist_ok=True)
models = [model_dir.strip('/')
for model_dir in sorted(gfile.ListDirectory(PATHS.SELFPLAY_DIR))[-50:]]
with timer("Finding existing tfrecords..."):
model_gamedata = {
model: gfile.Glob(
os.path.join(PATHS.SELFPLAY_DIR, model, '*.zz'))
for model in models
}
logger.info("Found %d models" % len(models))
for model_name, record_files in sorted(model_gamedata.items()):
logger.info(" %s: %s files" % (model_name, len(record_files)))
meta_file = os.path.join(PATHS.TRAINING_CHUNK_DIR, 'meta.txt')
try:
with gfile.GFile(meta_file, 'r') as f:
already_processed = set(f.read().split())
except tf.errors.NotFoundError:
already_processed = set()
num_already_processed = len(already_processed)
for model_name, record_files in sorted(model_gamedata.items()):
if set(record_files) <= already_processed:
continue
logger.info("Gathering files for %s:" % model_name)
for i, example_batch in enumerate(
tqdm(preprocessing.shuffle_tf_examples(GLOBAL_PARAMETER_STORE.EXAMPLES_PER_RECORD, record_files))):
output_record = os.path.join(PATHS.TRAINING_CHUNK_DIR,
'{}-{}.tfrecord.zz'.format(model_name, str(i)))
preprocessing.write_tf_examples(
output_record, example_batch, serialize=False)
already_processed.update(record_files)
logger.info("Processed %s new files" %
(len(already_processed) - num_already_processed))
with gfile.GFile(meta_file, 'w') as f:
f.write('\n'.join(sorted(already_processed)))
在生成训练数据后,我们训练一个新的神经网络版本。我们搜索最新版本的模型,加载使用最新版本权重的估算器,并执行另一次训练迭代:
def train():
model_version, model_name = get_latest_model()
logger.info("Training on gathered game data, initializing from {}".format(model_name))
new_model_name = generate(model_version + 1)
logger.info("New model will be {}".format(new_model_name))
save_file = os.path.join(PATHS.MODELS_DIR, new_model_name)
try:
logger.info("Getting tf_records")
tf_records = sorted(gfile.Glob(os.path.join(PATHS.TRAINING_CHUNK_DIR, '*.tfrecord.zz')))
tf_records = tf_records[
-1 * (GLOBAL_PARAMETER_STORE.WINDOW_SIZE // GLOBAL_PARAMETER_STORE.EXAMPLES_PER_RECORD):]
print("Training from:", tf_records[0], "to", tf_records[-1])
with timer("Training"):
network.train(PATHS.ESTIMATOR_WORKING_DIR, tf_records, model_version+1)
network.export_latest_checkpoint_model(PATHS.ESTIMATOR_WORKING_DIR, save_file)
except:
logger.info("Got an error training")
logging.exception("Train error")
最后,每次训练迭代后,我们希望用holdout数据集验证模型。当有足够的数据时,我们会取最后五个版本的holdout数据:
def validate(model_version=None, validate_name=None):
if model_version is None:
model_version, model_name = get_latest_model()
else:
model_version = int(model_version)
model_name = get_model(model_version)
models = list(
filter(lambda num_name: num_name[0] < (model_version - 1), get_models()))
if len(models) == 0:
logger.info('Not enough models, including model N for validation')
models = list(
filter(lambda num_name: num_name[0] <= model_version, get_models()))
else:
logger.info('Validating using data from following models: {}'.format(models))
tf_record_dirs = [os.path.join(PATHS.HOLDOUT_DIR, pair[1])
for pair in models[-5:]]
working_dir = PATHS.ESTIMATOR_WORKING_DIR
checkpoint_name = os.path.join(PATHS.MODELS_DIR, model_name)
tf_records = []
with timer("Building lists of holdout files"):
for record_dir in tf_record_dirs:
tf_records.extend(gfile.Glob(os.path.join(record_dir, '*.zz')))
with timer("Validating from {} to {}".format(os.path.basename(tf_records[0]), os.path.basename(tf_records[-1]))):
network.validate(working_dir, tf_records, checkpoint_path=checkpoint_name, name=validate_name)
最后,我们实现了evaluate函数,该函数让一个模型与另一个模型进行多场对弈:
def evaluate(black_model, white_model):
os.makedirs(PATHS.SGF_DIR, exist_ok=True)
with timer("Loading weights"):
black_net = network.PolicyValueNetwork(black_model)
white_net = network.PolicyValueNetwork(white_model)
with timer("Playing {} games".format(GLOBAL_PARAMETER_STORE.EVALUATION_GAMES)):
play_match(black_net, white_net, GLOBAL_PARAMETER_STORE.EVALUATION_GAMES,
GLOBAL_PARAMETER_STORE.EVALUATION_READOUTS, PATHS.SGF_DIR)
evaluate方法接受两个参数,black_model和white_model,每个参数都指向用于对弈的代理路径。我们使用black_model和white_model来实例化两个PolicyValueNetworks。通常,我们希望评估最新的模型版本,它会作为黑方或白方进行对弈。
train.py
最终,train.py是我们在控制器中实现的所有函数的调用和协调的地方。更具体地说,我们通过subprocess执行每一步:
import subprocess
import sys
from utils import timer
import os
from constants import PATHS
import logging
logger = logging.getLogger(__name__)
def main():
if not os.path.exists(PATHS.SELFPLAY_DIR):
with timer("Initialize"):
logger.info('==========================================')
logger.info("============ Initializing...==============")
logger.info('==========================================')
res = subprocess.call("python controller.py initialize-random-model", shell=True)
with timer('Initial Selfplay'):
logger.info('=======================================')
logger.info('============ Selplaying...=============')
logger.info('=======================================')
subprocess.call('python controller.py selfplay', shell=True)
假设尚未训练任何模型,我们使用随机权重初始化一个模型,并让它与自己对弈,从而为我们的策略网络和价值网络生成一些数据。奖励后,我们重复以下步骤:
-
汇总数据自我对弈数据
-
训练网络
-
让代理与自己对弈
-
在验证数据上进行验证
实现方法如下:
while True:
with timer("Aggregate"):
logger.info('=========================================')
logger.info("============ Aggregating...==============")
logger.info('=========================================')
res = subprocess.call("python controller.py aggregate", shell=True)
if res != 0:
logger.info("Failed to gather")
sys.exit(1)
with timer("Train"):
logger.info('=======================================')
logger.info("============ Training...===============")
logger.info('=======================================')
subprocess.call("python controller.py train", shell=True)
with timer('Selfplay'):
logger.info('=======================================')
logger.info('============ Selplaying...=============')
logger.info('=======================================')
subprocess.call('python controller.py selfplay', shell=True)
with timer("Validate"):
logger.info('=======================================')
logger.info("============ Validating...=============")
logger.info('=======================================')
subprocess.call("python controller.py validate", shell=True)
最后,由于这是主模块,我们在文件末尾添加以下内容:
if __name__ == '__main__':
main()
终于,我们完成了!
要运行 AlphaGo Zero 的训练,你所需要做的就是调用这个命令:
$ python train.py
如果一切实现正确,你应该开始看到模型训练的过程。然而,读者需要注意,训练将需要很长的时间。为了让你有个大致的概念,DeepMind 使用了 64 个 GPU 工作节点和 19 个 CPU 服务器,花费了 40 天时间训练 AlphaGo Zero。如果你希望看到你的模型达到高水平的熟练度,预计需要等待很长时间。
请注意,训练 AlphaGo Zero 需要非常长的时间。不要期望模型很快达到职业级水平!
你应该能够看到如下所示的输出:
2018-09-14 03:41:27,286 utils INFO Playing game: 342.685 seconds
2018-09-14 03:41:27,332 __main__ INFO ================================================
2018-09-14 03:41:27,332 __main__ INFO Playing game 9 with model 000010-pretty-tetra
2018-09-14 03:41:27,332 __main__ INFO ================================================
INFO:tensorflow:Restoring parameters from models/000010-pretty-tetra
2018-09-14 03:41:32,352 tensorflow INFO Restoring parameters from models/000010-pretty-tetra
2018-09-14 03:41:32,624 utils INFO Loading weights from models/000010-pretty-tetra ... : 5.291 seconds
你还将能够看到棋盘状态,当代理与自己或其他代理对弈时:
A B C D E F G H J
9 . . . . . . . . X 9
8 . . . X . . O . . 8
7 . . . . X O O . . 7
6 O . X X X<. . . . 6
5 X . O O . . O X . 5
4 . . X X . . . O . 4
3 . . X . X . O O . 3
2 . . . O . . . . X 2
1 . . . . . . . . . 1
A B C D E F G H J
Move: 25\. Captures X: 0 O: 0
-5.5
A B C D E F G H J
9 . . . . . . . . X 9
8 . . . X . . O . . 8
7 . . . . X O O . . 7
6 O . X X X . . . . 6
5 X . O O . . O X . 5
4 . . X X . . . O . 4
3 . . X . X . O O . 3
2 . . . O . . . . X 2
1 . . . . . . . . . 1
A B C D E F G H J
Move: 26\. Captures X: 0 O: 0
如果你想让一个模型与另一个模型对弈,可以运行以下命令(假设模型存储在models/目录下):
python controller.py evaluate models/{model_name_1} models/{model_name_2}
总结
在这一章中,我们研究了强化学习算法,专门用于世界上最复杂、最困难的游戏之一——围棋。特别是,我们探索了蒙特卡洛树搜索(MCTS),一种流行的算法,它通过时间积累学习最佳棋步。在 AlphaGo 中,我们观察到 MCTS 如何与深度神经网络结合,使学习变得更加高效和强大。然后,我们研究了 AlphaGo Zero 如何通过完全依赖自我对弈经验而彻底改变围棋代理,且超越了所有现有的围棋软件和玩家。接着,我们从零开始实现了这个算法。
我们还实现了 AlphaGo Zero,它是 AlphaGo 的简化版,因为它不依赖于人类的游戏数据。然而,如前所述,AlphaGo Zero 需要大量的计算资源。此外,正如你可能已经注意到的,AlphaGo Zero 依赖于众多超参数,所有这些都需要进行精细调优。简而言之,完全训练 AlphaGo Zero 是一项极具挑战的任务。我们并不期望读者实现最先进的围棋代理;相反,我们希望通过本章,读者能够更好地理解围棋深度强化学习算法的工作原理。对这些技术和算法的更深理解,已经是本章一个有价值的收获和成果。当然,我们鼓励读者继续探索这一主题,并构建出一个更好的 AlphaGo Zero 版本。
要获取更多关于我们在本章中涉及主题的深入信息和资源,请参考以下链接:
-
AlphaGo 主页:
deepmind.com/research/alphago/ -
AlphaGo 论文:
storage.googleapis.com/deepmind-media/alphago/AlphaGoNaturePaper.pdf -
AlphaGo Zero 论文:
www.nature.com/articles/nature24270 -
DeepMind 发布的 AlphaGo Zero 博客文章:
deepmind.com/blog/alphago-zero-learning-scratch/ -
MCTS 方法调查:
mcts.ai/pubs/mcts-survey-master.pdf
现在计算机在棋盘游戏中超越了人类表现,人们可能会问,接下来是什么?这些结果有什么影响?仍然有很多工作要做;围棋作为一个完全信息且逐轮进行的游戏,与许多现实生活中的情况相比仍然被认为是简单的。可以想象,自动驾驶汽车的问题由于信息不完全和更多的变量而面临更大的挑战。尽管如此,AlphaGo 和 AlphaGo Zero 已经迈出了实现这些任务的关键一步,人们对这一领域的进一步发展肯定是兴奋的。
参考文献
-
Silver, D., Huang, A., Maddison, C. J., Guez, A., Sifre, L., Van Den Driessche, G., ... 和 Dieleman, S. (2016). 使用深度神经网络和树搜索掌握围棋. 自然, 529(7587), 484.
-
Silver, D., Schrittwieser, J., Simonyan, K., Antonoglou, I., Huang, A., Guez, A., ... 和 Chen, Y. (2017). 不借助人类知识掌握围棋. 自然, 550(7676), 354.
-
Browne, C. B., Powley, E., Whitehouse, D., Lucas, S. M., Cowling, P. I., Rohlfshagen, P., ... 和 Colton, S. (2012). 蒙特卡洛树搜索方法调查. IEEE 计算智能与 AI 在游戏中的应用, 4(1), 1-43.
第七章:创建聊天机器人
对话代理和聊天机器人近年来不断崛起。许多企业已开始依靠聊天机器人来回答客户的咨询,这一做法取得了显著成功。聊天机器人在过去一年增长了 5.6 倍 (chatbotsmagazine.com/chatbot-report-2018-global-trends-and-analysis-4d8bbe4d924b)。聊天机器人可以帮助组织与客户进行沟通和互动,且无需人工干预,成本非常低廉。超过 51%的客户表示,他们希望企业能够 24/7 提供服务,并期望在一小时内得到回复。为了以一种负担得起的方式实现这一成功,尤其是在拥有大量客户的情况下,企业必须依赖聊天机器人。
背景问题
许多聊天机器人是使用常规的机器学习自然语言处理算法创建的,这些算法侧重于即时响应。一个新的概念是使用深度强化学习来创建聊天机器人。这意味着我们会考虑即时响应的未来影响,以保持对话的连贯性。
本章中,你将学习如何将深度强化学习应用于自然语言处理。我们的奖励函数将是一个面向未来的函数,您将通过创建该函数学会如何从概率的角度思考。
数据集
我们将使用的这个数据集主要由选定电影中的对话组成。这个数据集有助于激发并理解聊天机器人的对话方法。此外,其中还包含电影台词,这些台词与电影中的对话本质相同,不过是人与人之间较简短的交流。其他将使用的数据集还包括一些包含电影标题、电影角色和原始剧本的数据集。
分步指南
我们的解决方案将使用建模方法,重点关注对话代理的未来方向,从而生成连贯且有趣的对话。该模型将模拟两个虚拟代理之间的对话,使用策略梯度方法。这些方法旨在奖励显示出对话三个重要特性的交互序列:信息性(不重复的回合)、高度连贯性和简洁的回答(这与面向未来的函数相关)。在我们的解决方案中,动作将被定义为聊天机器人生成的对话或交流话语。此外,状态将被定义为之前的两轮互动。为了实现这一目标,我们将使用以下章节中的剧本。
数据解析器
数据解析脚本旨在帮助清理和预处理我们的数据集。此脚本有多个依赖项,如pickle、codecs、re、OS、time和numpy。该脚本包含三个功能。第一个功能帮助通过预处理词频并基于词频阈值创建词汇表来过滤词汇。第二个功能帮助解析所有词汇到此脚本中,第三个功能帮助从数据中提取仅定义的词汇:
import pickle
import codecs
import re
import os
import time
import numpy as np
以下模块清理并预处理训练数据集中的文本:
def preProBuildWordVocab(word_count_threshold=5, all_words_path='data/all_words.txt'):
# borrowed this function from NeuralTalk
if not os.path.exists(all_words_path):
parse_all_words(all_words_path)
corpus = open(all_words_path, 'r').read().split('\n')[:-1]
captions = np.asarray(corpus, dtype=np.object)
captions = map(lambda x: x.replace('.', ''), captions)
captions = map(lambda x: x.replace(',', ''), captions)
captions = map(lambda x: x.replace('"', ''), captions)
captions = map(lambda x: x.replace('\n', ''), captions)
captions = map(lambda x: x.replace('?', ''), captions)
captions = map(lambda x: x.replace('!', ''), captions)
captions = map(lambda x: x.replace('\\', ''), captions)
captions = map(lambda x: x.replace('/', ''), captions)
接下来,遍历字幕并创建词汇表。
print('preprocessing word counts and creating vocab based on word count threshold %d' % (word_count_threshold))
word_counts = {}
nsents = 0
for sent in captions:
nsents += 1
for w in sent.lower().split(' '):
word_counts[w] = word_counts.get(w, 0) + 1
vocab = [w for w in word_counts if word_counts[w] >= word_count_threshold]
print('filtered words from %d to %d' % (len(word_counts), len(vocab)))
ixtoword = {}
ixtoword[0] = '<pad>'
ixtoword[1] = '<bos>'
ixtoword[2] = '<eos>'
ixtoword[3] = '<unk>'
wordtoix = {}
wordtoix['<pad>'] = 0
wordtoix['<bos>'] = 1
wordtoix['<eos>'] = 2
wordtoix['<unk>'] = 3
for idx, w in enumerate(vocab):
wordtoix[w] = idx+4
ixtoword[idx+4] = w
word_counts['<pad>'] = nsents
word_counts['<bos>'] = nsents
word_counts['<eos>'] = nsents
word_counts['<unk>'] = nsents
bias_init_vector = np.array([1.0 * word_counts[ixtoword[i]] for i in ixtoword])
bias_init_vector /= np.sum(bias_init_vector) # normalize to frequencies
bias_init_vector = np.log(bias_init_vector)
bias_init_vector -= np.max(bias_init_vector) # shift to nice numeric range
return wordtoix, ixtoword, bias_init_vector
接下来,解析所有电影台词中的词汇。
def parse_all_words(all_words_path):
raw_movie_lines = open('data/movie_lines.txt', 'r', encoding='utf-8', errors='ignore').read().split('\n')[:-1]
with codecs.open(all_words_path, "w", encoding='utf-8', errors='ignore') as f:
for line in raw_movie_lines:
line = line.split(' +++$+++ ')
utterance = line[-1]
f.write(utterance + '\n')
仅提取数据中的词汇部分,如下所示:
def refine(data):
words = re.findall("[a-zA-Z'-]+", data)
words = ["".join(word.split("'")) for word in words]
# words = ["".join(word.split("-")) for word in words]
data = ' '.join(words)
return data
接下来,创建并存储话语字典。
if __name__ == '__main__':
parse_all_words('data/all_words.txt')
raw_movie_lines = open('data/movie_lines.txt', 'r', encoding='utf-8', errors='ignore').read().split('\n')[:-1]
utterance_dict = {}
with codecs.open('data/tokenized_all_words.txt', "w", encoding='utf-8', errors='ignore') as f:
for line in raw_movie_lines:
line = line.split(' +++$+++ ')
line_ID = line[0]
utterance = line[-1]
utterance_dict[line_ID] = utterance
utterance = " ".join([refine(w) for w in utterance.lower().split()])
f.write(utterance + '\n')
pickle.dump(utterance_dict, open('data/utterance_dict', 'wb'), True)
数据已解析,并可以在后续步骤中使用。
数据读取
数据读取脚本帮助从数据解析脚本预处理后的训练文本中生成可训练的批次。我们首先通过导入所需的方法开始:
import pickle
import random
此辅助模块帮助从预处理后的训练文本中生成可训练的批次。
class Data_Reader:
def __init__(self, cur_train_index=0, load_list=False):
self.training_data = pickle.load(open('data/conversations_lenmax22_formersents2_with_former', 'rb'))
self.data_size = len(self.training_data)
if load_list:
self.shuffle_list = pickle.load(open('data/shuffle_index_list', 'rb'))
else:
self.shuffle_list = self.shuffle_index()
self.train_index = cur_train_index
以下代码从数据中获取批次号:
def get_batch_num(self, batch_size):
return self.data_size // batch_size
以下代码打乱来自数据的索引:
def shuffle_index(self):
shuffle_index_list = random.sample(range(self.data_size), self.data_size)
pickle.dump(shuffle_index_list, open('data/shuffle_index_list', 'wb'), True)
return shuffle_index_list
以下代码基于之前获取的批次号生成批次索引:
def generate_batch_index(self, batch_size):
if self.train_index + batch_size > self.data_size:
batch_index = self.shuffle_list[self.train_index:self.data_size]
self.shuffle_list = self.shuffle_index()
remain_size = batch_size - (self.data_size - self.train_index)
batch_index += self.shuffle_list[:remain_size]
self.train_index = remain_size
else:
batch_index = self.shuffle_list[self.train_index:self.train_index+batch_size]
self.train_index += batch_size
return batch_index
以下代码生成训练批次:
def generate_training_batch(self, batch_size):
batch_index = self.generate_batch_index(batch_size)
batch_X = [self.training_data[i][0] for i in batch_index] # batch_size of conv_a
batch_Y = [self.training_data[i][1] for i in batch_index] # batch_size of conv_b
return batch_X, batch_Y
以下函数使用前者生成训练批次。
def generate_training_batch_with_former(self, batch_size):
batch_index = self.generate_batch_index(batch_size)
batch_X = [self.training_data[i][0] for i in batch_index] # batch_size of conv_a
batch_Y = [self.training_data[i][1] for i in batch_index] # batch_size of conv_b
former = [self.training_data[i][2] for i in batch_index] # batch_size of former utterance
return batch_X, batch_Y, former
以下代码生成测试批次:
def generate_testing_batch(self, batch_size):
batch_index = self.generate_batch_index(batch_size)
batch_X = [self.training_data[i][0] for i in batch_index] # batch_size of conv_a
return batch_X
这部分内容结束于数据读取。
辅助方法
此脚本由一个Seq2seq对话生成模型组成,用于反向模型的逆向熵损失。它将确定政策梯度对话的语义连贯性奖励。实质上,该脚本将帮助我们表示未来的奖励函数。该脚本将通过以下操作实现:
-
编码
-
解码
-
生成构建
所有先前的操作都基于长短期记忆(LSTM)单元。
特征提取脚本帮助从数据中提取特征和特性,以便更好地训练它。我们首先通过导入所需的模块开始。
import tensorflow as tf
import numpy as np
import re
接下来,定义模型输入。如果强化学习被设置为 True,则基于语义连贯性和回答损失字幕的易用性计算标量。
def model_inputs(embed_dim, reinforcement= False):
word_vectors = tf.placeholder(tf.float32, [None, None, embed_dim], name = "word_vectors")
reward = tf.placeholder(tf.float32, shape = (), name = "rewards")
caption = tf.placeholder(tf.int32, [None, None], name = "captions")
caption_mask = tf.placeholder(tf.float32, [None, None], name = "caption_masks")
if reinforcement: #Normal training returns only the word_vectors, caption and caption_mask placeholders,
#With reinforcement learning, there is an extra placeholder for rewards
return word_vectors, caption, caption_mask, reward
else:
return word_vectors, caption, caption_mask
接下来,定义执行序列到序列网络编码的编码层。输入序列传递给编码器,并返回 RNN 输出和状态。
def encoding_layer(word_vectors, lstm_size, num_layers, keep_prob,
vocab_size):
cells = tf.contrib.rnn.MultiRNNCell([tf.contrib.rnn.DropoutWrapper(tf.contrib.rnn.LSTMCell(lstm_size), keep_prob) for _ in range(num_layers)])
outputs, state = tf.nn.dynamic_rnn(cells,
word_vectors,
dtype=tf.float32)
return outputs, state
接下来,定义使用 LSTM 单元的解码器训练过程,结合编码器状态和解码器输入。
def decode_train(enc_state, dec_cell, dec_input,
target_sequence_length,output_sequence_length,
output_layer, keep_prob):
dec_cell = tf.contrib.rnn.DropoutWrapper(dec_cell, #Apply dropout to the LSTM cell
output_keep_prob=keep_prob)
helper = tf.contrib.seq2seq.TrainingHelper(dec_input, #Training helper for decoder
target_sequence_length)
decoder = tf.contrib.seq2seq.BasicDecoder(dec_cell,
helper,
enc_state,
output_layer)
# unrolling the decoder layer
outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder,
impute_finished=True,
maximum_iterations=output_sequence_length)
return outputs
接下来,定义一个类似于训练时使用的推理解码器。使用贪心策略辅助工具,将解码器的最后输出作为下一个解码器输入。返回的输出包含训练 logits 和样本 ID。
def decode_generate(encoder_state, dec_cell, dec_embeddings,
target_sequence_length,output_sequence_length,
vocab_size, output_layer, batch_size, keep_prob):
dec_cell = tf.contrib.rnn.DropoutWrapper(dec_cell,
output_keep_prob=keep_prob)
helper = tf.contrib.seq2seq.GreedyEmbeddingHelper(dec_embeddings,
tf.fill([batch_size], 1), #Decoder helper for inference
2)
decoder = tf.contrib.seq2seq.BasicDecoder(dec_cell,
helper,
encoder_state,
output_layer)
outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(decoder,
impute_finished=True,
maximum_iterations=output_sequence_length)
return outputs
接下来,创建解码层。
def decoding_layer(dec_input, enc_state,
target_sequence_length,output_sequence_length,
lstm_size,
num_layers,n_words,
batch_size, keep_prob,embedding_size, Train = True):
target_vocab_size = n_words
with tf.device("/cpu:0"):
dec_embeddings = tf.Variable(tf.random_uniform([target_vocab_size,embedding_size], -0.1, 0.1), name='Wemb')
dec_embed_input = tf.nn.embedding_lookup(dec_embeddings, dec_input)
cells = tf.contrib.rnn.MultiRNNCell([tf.contrib.rnn.LSTMCell(lstm_size) for _ in range(num_layers)])
with tf.variable_scope("decode"):
output_layer = tf.layers.Dense(target_vocab_size)
if Train:
with tf.variable_scope("decode"):
train_output = decode_train(enc_state,
cells,
dec_embed_input,
target_sequence_length, output_sequence_length,
output_layer,
keep_prob)
with tf.variable_scope("decode", reuse=tf.AUTO_REUSE):
infer_output = decode_generate(enc_state,
cells,
dec_embeddings, target_sequence_length,
output_sequence_length,
target_vocab_size,
output_layer,
batch_size,
keep_prob)
if Train:
return train_output, infer_output
return infer_output
接下来,创建 bos 包含部分,将对应于 的索引添加到每个批次的标题张量的第一个索引, 表示句子的开始。
def bos_inclusion(caption,batch_size):
sliced_target = tf.strided_slice(caption, [0,0], [batch_size, -1], [1,1])
concat = tf.concat([tf.fill([batch_size, 1],1), sliced_target],1)
return concat
接下来,定义 pad 序列,该方法通过用零填充或在必要时截断每个问题,创建大小为 maxlen 的数组。
def pad_sequences(questions, sequence_length =22):
lengths = [len(x) for x in questions]
num_samples = len(questions)
x = np.zeros((num_samples, sequence_length)).astype(int)
for idx, sequence in enumerate(questions):
if not len(sequence):
continue # empty list/array was found
truncated = sequence[-sequence_length:]
truncated = np.asarray(truncated, dtype=int)
x[idx, :len(truncated)] = truncated
return x
如果数据中存在非词汇部分,请忽略它们,只保留所有字母。
def refine(data):
words = re.findall("[a-zA-Z'-]+", data)
words = ["".join(word.split("'")) for word in words]
data = ' '.join(words)
return data
接下来,创建批次,将词向量表示送入网络。
def make_batch_input(batch_input, input_sequence_length, embed_dims, word2vec):
for i in range(len(batch_input)):
batch_input[i] = [word2vec[w] if w in word2vec else np.zeros(embed_dims) for w in batch_input[i]]
if len(batch_input[i]) >input_sequence_length:
batch_input[i] = batch_input[i][:input_sequence_length]
else:
for _ in range(input_sequence_length - len(batch_input[i])):
batch_input[i].append(np.zeros(embed_dims))
return np.array(batch_input)
def replace(target,symbols): #Remove symbols from sequence
for symbol in symbols:
target = list(map(lambda x: x.replace(symbol,''),target))
return target
def make_batch_target(batch_target, word_to_index, target_sequence_length):
target = batch_target
target = list(map(lambda x: '<bos> ' + x, target))
symbols = ['.', ',', '"', '\n','?','!','\\','/']
target = replace(target, symbols)
for idx, each_cap in enumerate(target):
word = each_cap.lower().split(' ')
if len(word) < target_sequence_length:
target[idx] = target[idx] + ' <eos>' #Append the end of symbol symbol
else:
new_word = ''
for i in range(target_sequence_length-1):
new_word = new_word + word[i] + ' '
target[idx] = new_word + '<eos>'
target_index = [[word_to_index[word] if word in word_to_index else word_to_index['<unk>'] for word in
sequence.lower().split(' ')] for sequence in target]
#print(target_index[0])
caption_matrix = pad_sequences(target_index,target_sequence_length)
caption_matrix = np.hstack([caption_matrix, np.zeros([len(caption_matrix), 1])]).astype(int)
caption_masks = np.zeros((caption_matrix.shape[0], caption_matrix.shape[1]))
nonzeros = np.array(list(map(lambda x: (x != 0).sum(), caption_matrix)))
#print(nonzeros)
#print(caption_matrix[1])
for ind, row in enumerate(caption_masks): #Set the masks as an array of ones where actual words exist and zeros otherwise
row[:nonzeros[ind]] = 1
#print(row)
print(caption_masks[0])
print(caption_matrix[0])
return caption_matrix,caption_masks
def generic_batch(generic_responses, batch_size, word_to_index, target_sequence_length):
size = len(generic_responses)
if size > batch_size:
generic_responses = generic_responses[:batch_size]
else:
for j in range(batch_size - size):
generic_responses.append('')
return make_batch_Y(generic_responses, word_to_index, target_sequence_length)
接下来,从预测的索引生成句子。每当预测时,将 和 替换为具有下一个最高概率的单词。
def index2sentence(generated_word_index, prob_logit, ixtoword):
generated_word_index = list(generated_word_index)
for i in range(len(generated_word_index)):
if generated_word_index[i] == 3 or generated_word_index[i] == 0:
sort_prob_logit = sorted(prob_logit[i])
curindex = np.where(prob_logit[i] == sort_prob_logit[-2])[0][0]
count = 1
while curindex <= 3:
curindex = np.where(prob_logit[i] == sort_prob_logit[(-2)-count])[0][0]
count += 1
generated_word_index[i] = curindex
generated_words = []
for ind in generated_word_index:
generated_words.append(ixtoword[ind])
generated_sentence = ' '.join(generated_words)
generated_sentence = generated_sentence.replace('<bos> ', '') #Replace the beginning of sentence tag
generated_sentence = generated_sentence.replace('<eos>', '') #Replace the end of sentence tag
generated_sentence = generated_sentence.replace('--', '') #Replace the other symbols predicted
generated_sentence = generated_sentence.split(' ')
for i in range(len(generated_sentence)): #Begin sentences with Upper case
generated_sentence[i] = generated_sentence[i].strip()
if len(generated_sentence[i]) > 1:
generated_sentence[i] = generated_sentence[i][0].upper() + generated_sentence[i][1:] + '.'
else:
generated_sentence[i] = generated_sentence[i].upper()
generated_sentence = ' '.join(generated_sentence)
generated_sentence = generated_sentence.replace(' i ', ' I ')
generated_sentence = generated_sentence.replace("i'm", "I'm")
generated_sentence = generated_sentence.replace("i'd", "I'd")
return generated_sentence
这结束了所有辅助函数。
聊天机器人模型
以下脚本包含策略梯度模型,它将用于结合强化学习奖励与交叉熵损失。依赖项包括 numpy 和 tensorflow。我们的策略梯度基于 LSTM 编码器-解码器。我们将使用策略梯度的随机演示,这将是一个关于指定状态的动作概率分布。该脚本表示了这一切,并指定了需要最小化的策略梯度损失。
通过第二个单元运行第一个单元的输出;输入与零拼接。响应的最终状态通常由两个部分组成——编码器对输入的潜在表示,以及基于选定单词的解码器状态。返回的内容包括占位符张量和其他张量,例如损失和训练优化操作。让我们从导入所需的库开始。
import tensorflow as tf
import numpy as np
import helper as h
我们将创建一个聊天机器人类来构建模型。
class Chatbot():
def __init__(self, embed_dim, vocab_size, lstm_size, batch_size, input_sequence_length, target_sequence_length, learning_rate =0.0001, keep_prob = 0.5, num_layers = 1, policy_gradients = False, Training = True):
self.embed_dim = embed_dim
self.lstm_size = lstm_size
self.batch_size = batch_size
self.vocab_size = vocab_size
self.input_sequence_length = tf.fill([self.batch_size],input_sequence_length+1)
self.target_sequence_length = tf.fill([self.batch_size],target_sequence_length+1)
self.output_sequence_length = target_sequence_length +1
self.learning_rate = learning_rate
self.keep_prob = keep_prob
self.num_layers = num_layers
self.policy_gradients = policy_gradients
self.Training = Training
接下来,创建一个构建模型的方法。如果请求策略梯度,则根据需要获取输入。
def build_model(self):
if self.policy_gradients:
word_vectors, caption, caption_mask, rewards = h.model_inputs(self.embed_dim, True)
place_holders = {'word_vectors': word_vectors,
'caption': caption,
'caption_mask': caption_mask, "rewards": rewards
}
else:
word_vectors, caption, caption_mask = h.model_inputs(self.embed_dim)
place_holders = {'word_vectors': word_vectors,
'caption': caption,
'caption_mask': caption_mask}
enc_output, enc_state = h.encoding_layer(word_vectors, self.lstm_size, self.num_layers,
self.keep_prob, self.vocab_size)
#dec_inp = h.bos_inclusion(caption, self.batch_size)
dec_inp = caption
接下来,获取推理层。
if not self.Training:
print("Test mode")
inference_out = h.decoding_layer(dec_inp, enc_state,self.target_sequence_length,
self.output_sequence_length,
self.lstm_size, self.num_layers,
self.vocab_size, self.batch_size,
self.keep_prob, self.embed_dim, False)
logits = tf.identity(inference_out.rnn_output, name = "train_logits")
predictions = tf.identity(inference_out.sample_id, name = "predictions")
return place_holders, predictions, logits
接下来,获取损失层。
train_out, inference_out = h.decoding_layer(dec_inp, enc_state,self.target_sequence_length,
self.output_sequence_length,
self.lstm_size, self.num_layers,
self.vocab_size, self.batch_size,
self.keep_prob, self.embed_dim)
training_logits = tf.identity(train_out.rnn_output, name = "train_logits")
prediction_logits = tf.identity(inference_out.sample_id, name = "predictions")
cross_entropy = tf.contrib.seq2seq.sequence_loss(training_logits, caption, caption_mask)
losses = {"entropy": cross_entropy}
根据策略梯度的状态,选择最小化交叉熵损失或策略梯度损失。
if self.policy_gradients:
pg_loss = tf.contrib.seq2seq.sequence_loss(training_logits, caption, caption_mask*rewards)
with tf.variable_scope(tf.get_variable_scope(), reuse=False):
optimizer = tf.train.AdamOptimizer(self.learning_rate).minimize(pg_loss)
losses.update({"pg":pg_loss})
else:
with tf.variable_scope(tf.get_variable_scope(), reuse=False):
optimizer = tf.train.AdamOptimizer(self.learning_rate).minimize(cross_entropy)
return optimizer, place_holders,prediction_logits,training_logits, losses
现在我们已经有了训练所需的所有方法。
训练数据
之前编写的脚本与训练数据集结合起来。让我们通过导入在前面章节中开发的所有模块来开始训练,如下所示:
from data_reader import Data_Reader
import data_parser
from gensim.models import KeyedVectors
import helper as h
from seq_model import Chatbot
import tensorflow as tf
import numpy as np
接下来,让我们创建一组在原始 seq2seq 模型中观察到的通用响应,策略梯度会避免这些响应。
generic_responses = [
"I don't know what you're talking about.",
"I don't know.",
"You don't know.",
"You know what I mean.",
"I know what you mean.",
"You know what I'm saying.",
"You don't know anything."
]
接下来,我们将定义训练所需的所有常量。
checkpoint = True
forward_model_path = 'model/forward'
reversed_model_path = 'model/reversed'
rl_model_path = "model/rl"
model_name = 'seq2seq'
word_count_threshold = 20
reversed_word_count_threshold = 6
dim_wordvec = 300
dim_hidden = 1000
input_sequence_length = 22
output_sequence_length = 22
learning_rate = 0.0001
epochs = 1
batch_size = 200
forward_ = "forward"
reverse_ = "reverse"
forward_epochs = 50
reverse_epochs = 50
display_interval = 100
接下来,定义训练函数。根据类型,加载前向或反向序列到序列模型。数据也根据模型读取,反向模型如下所示:
def train(type_, epochs=epochs, checkpoint=False):
tf.reset_default_graph()
if type_ == "forward":
path = "model/forward/seq2seq"
dr = Data_Reader(reverse=False)
else:
dr = Data_Reader(reverse=True)
path = "model/reverse/seq2seq"
接下来,按照以下方式创建词汇表:
word_to_index, index_to_word, _ = data_parser.preProBuildWordVocab(word_count_threshold=word_count_threshold)
上述命令的输出应打印以下内容,表示已过滤的词汇表大小。
preprocessing word counts and creating vocab based on word count threshold 20
filtered words from 76029 to 6847
word_to_index 变量被填充为过滤后单词到整数的映射,如下所示:
{'': 4,
'deposition': 1769,
'next': 3397,
'dates': 1768,
'chance': 2597,
'slipped': 4340,...
index_to_word 变量被填充为从整数到过滤后的单词的映射,这将作为反向查找。
5: 'tastes',
6: 'shower',
7: 'agent',
8: 'lack',
接下来,从gensim库加载词到向量的模型。
word_vector = KeyedVectors.load_word2vec_format('model/word_vector.bin', binary=True)
接下来,实例化并构建聊天机器人模型,使用所有已定义的常量。如果有之前训练的检查点,则恢复它;否则,初始化图。
model = Chatbot(dim_wordvec, len(word_to_index), dim_hidden, batch_size,
input_sequence_length, output_sequence_length, learning_rate)
optimizer, place_holders, predictions, logits, losses = model.build_model()
saver = tf.train.Saver()
sess = tf.InteractiveSession()
if checkpoint:
saver.restore(sess, path)
print("checkpoint restored at path: {}".format(path))
else:
tf.global_variables_initializer().run()
接下来,通过迭代纪元并开始批量处理来启动训练。
for epoch in range(epochs):
n_batch = dr.get_batch_num(batch_size=batch_size)
for batch in range(n_batch):
batch_input, batch_target = dr.generate_training_batch(batch_size)
batch_input包含来自训练集的单词列表。batch_target包含输入的句子列表,这些句子将作为目标。单词列表通过辅助函数转换为向量形式。使用转换后的输入、掩码和目标构建图的喂入字典。
inputs_ = h.make_batch_input(batch_input, input_sequence_length, dim_wordvec, word_vector)
targets, masks = h.make_batch_target(batch_target, word_to_index, output_sequence_length)
feed_dict = {
place_holders['word_vectors']: inputs_,
place_holders['caption']: targets,
place_holders['caption_mask']: masks
}
接下来,通过调用优化器并输入训练数据来训练模型。在某些间隔记录损失值,以查看训练的进展。训练结束后保存模型。
_, loss_val, preds = sess.run([optimizer, losses["entropy"], predictions],
feed_dict=feed_dict)
if batch % display_interval == 0:
print(preds.shape)
print("Epoch: {}, batch: {}, loss: {}".format(epoch, batch, loss_val))
print("===========================================================")
saver.save(sess, path)
print("Model saved at {}".format(path))
print("Training done")
sess.close()
输出应如下所示。
(200, 23)
Epoch: 0, batch: 0, loss: 8.831538200378418
===========================================================
模型经过正向和反向训练,相应的模型被存储。在下一个函数中,模型被恢复并重新训练,以创建聊天机器人。
def pg_train(epochs=epochs, checkpoint=False):
tf.reset_default_graph()
path = "model/reinforcement/seq2seq"
word_to_index, index_to_word, _ = data_parser.preProBuildWordVocab(word_count_threshold=word_count_threshold)
word_vector = KeyedVectors.load_word2vec_format('model/word_vector.bin', binary=True)
generic_caption, generic_mask = h.generic_batch(generic_responses, batch_size, word_to_index,
output_sequence_length)
dr = Data_Reader()
forward_graph = tf.Graph()
reverse_graph = tf.Graph()
default_graph = tf.get_default_graph()
创建两个图表以加载训练好的模型。
with forward_graph.as_default():
pg_model = Chatbot(dim_wordvec, len(word_to_index), dim_hidden, batch_size,
input_sequence_length, output_sequence_length, learning_rate, policy_gradients=True)
optimizer, place_holders, predictions, logits, losses = pg_model.build_model()
sess = tf.InteractiveSession()
saver = tf.train.Saver()
if checkpoint:
saver.restore(sess, path)
print("checkpoint restored at path: {}".format(path))
else:
tf.global_variables_initializer().run()
saver.restore(sess, 'model/forward/seq2seq')
# tf.global_variables_initializer().run()
with reverse_graph.as_default():
model = Chatbot(dim_wordvec, len(word_to_index), dim_hidden, batch_size,
input_sequence_length, output_sequence_length, learning_rate)
_, rev_place_holders, _, _, reverse_loss = model.build_model()
sess2 = tf.InteractiveSession()
saver2 = tf.train.Saver()
saver2.restore(sess2, "model/reverse/seq2seq")
print("reverse model restored")
dr = Data_Reader(load_list=True)
接下来,加载数据以批量训练数据。
for epoch in range(epochs):
n_batch = dr.get_batch_num(batch_size=batch_size)
for batch in range(n_batch):
batch_input, batch_caption, prev_utterance = dr.generate_training_batch_with_former(batch_size)
targets, masks = h.make_batch_target(batch_caption, word_to_index, output_sequence_length)
inputs_ = h.make_batch_input(batch_input, input_sequence_length, dim_wordvec, word_vector)
word_indices, probabilities = sess.run([predictions, logits],
feed_dict={place_holders['word_vectors']: inputs_
, place_holders["caption"]: targets})
sentence = [h.index2sentence(generated_word, probability, index_to_word) for
generated_word, probability in zip(word_indices, probabilities)]
word_list = [word.split() for word in sentence]
generic_test_input = h.make_batch_input(word_list, input_sequence_length, dim_wordvec, word_vector)
forward_coherence_target, forward_coherence_masks = h.make_batch_target(sentence,
word_to_index,
output_sequence_length)
generic_loss = 0.0
同时,学习何时说出通用文本,如下所示:
for response in generic_test_input:
sentence_input = np.array([response] * batch_size)
feed_dict = {place_holders['word_vectors']: sentence_input,
place_holders['caption']: generic_caption,
place_holders['caption_mask']: generic_mask,
}
generic_loss_i = sess.run(losses["entropy"], feed_dict=feed_dict)
generic_loss -= generic_loss_i / batch_size
# print("generic loss work: {}".format(generic_loss))
feed_dict = {place_holders['word_vectors']: inputs_,
place_holders['caption']: forward_coherence_target,
place_holders['caption_mask']: forward_coherence_masks,
}
forward_entropy = sess.run(losses["entropy"], feed_dict=feed_dict)
previous_utterance, previous_mask = h.make_batch_target(prev_utterance,
word_to_index, output_sequence_length)
feed_dict = {rev_place_holders['word_vectors']: generic_test_input,
rev_place_holders['caption']: previous_utterance,
rev_place_holders['caption_mask']: previous_mask,
}
reverse_entropy = sess2.run(reverse_loss["entropy"], feed_dict=feed_dict)
rewards = 1 / (1 + np.exp(-reverse_entropy - forward_entropy - generic_loss))
feed_dict = {place_holders['word_vectors']: inputs_,
place_holders['caption']: targets,
place_holders['caption_mask']: masks,
place_holders['rewards']: rewards
}
_, loss_pg, loss_ent = sess.run([optimizer, losses["pg"], losses["entropy"]], feed_dict=feed_dict)
if batch % display_interval == 0:
print("Epoch: {}, batch: {}, Entropy loss: {}, Policy gradient loss: {}".format(epoch, batch, loss_ent,
loss_pg))
print("rewards: {}".format(rewards))
print("===========================================================")
saver.save(sess, path)
print("Model saved at {}".format(path))
print("Training done")
接下来,按顺序调用已定义的函数。首先训练正向模型,然后训练反向模型,最后训练策略梯度。
train(forward_, forward_epochs, False)
train(reverse_, reverse_epochs, False)
pg_train(100, False)
这标志着聊天机器人的训练结束。模型通过正向和反向训练
测试和结果
训练模型后,我们用测试数据集进行了测试,得到了相当连贯的对话。有一个非常重要的问题:交流的上下文。因此,根据所使用的数据集,结果会有其上下文。就我们的上下文而言,获得的结果非常合理,并且满足了我们的三项性能指标——信息量(无重复回合)、高度连贯性和回答的简洁性(这与前瞻性功能有关)。
import data_parser
from gensim.models import KeyedVectors
from seq_model import Chatbot
import tensorflow as tf
import numpy as np
import helper as h
接下来,声明已经训练好的各种模型的路径。
reinforcement_model_path = "model/reinforcement/seq2seq"
forward_model_path = "model/forward/seq2seq"
reverse_model_path = "model/reverse/seq2seq"
接下来,声明包含问题和回应的文件路径。
path_to_questions = 'results/sample_input.txt'
responses_path = 'results/sample_output_RL.txt'
接下来,声明模型所需的常量。
word_count_threshold = 20
dim_wordvec = 300
dim_hidden = 1000
input_sequence_length = 25
target_sequence_length = 22
batch_size = 2
接下来,加载数据和模型,如下所示:
def test(model_path=forward_model_path):
testing_data = open(path_to_questions, 'r').read().split('\n')
word_vector = KeyedVectors.load_word2vec_format('model/word_vector.bin', binary=True)
_, index_to_word, _ = data_parser.preProBuildWordVocab(word_count_threshold=word_count_threshold)
model = Chatbot(dim_wordvec, len(index_to_word), dim_hidden, batch_size,
input_sequence_length, target_sequence_length, Training=False)
place_holders, predictions, logits = model.build_model()
sess = tf.InteractiveSession()
saver = tf.train.Saver()
saver.restore(sess, model_path)
接下来,打开回应文件,并准备如下所示的问题列表:
with open(responses_path, 'w') as out:
for idx, question in enumerate(testing_data):
print('question =>', question)
question = [h.refine(w) for w in question.lower().split()]
question = [word_vector[w] if w in word_vector else np.zeros(dim_wordvec) for w in question]
question.insert(0, np.random.normal(size=(dim_wordvec,))) # insert random normal at the first step
if len(question) > input_sequence_length:
question = question[:input_sequence_length]
else:
for _ in range(input_sequence_length - len(question)):
question.append(np.zeros(dim_wordvec))
question = np.array([question])
feed_dict = {place_holders["word_vectors"]: np.concatenate([question] * 2, 0),
}
word_indices, prob_logit = sess.run([predictions, logits], feed_dict=feed_dict)
# print(word_indices[0].shape)
generated_sentence = h.index2sentence(word_indices[0], prob_logit[0], index_to_word)
print('generated_sentence =>', generated_sentence)
out.write(generated_sentence + '\n')
test(reinforcement_model_path)
通过传递模型的路径,我们可以测试聊天机器人以获取各种回应。
总结
聊天机器人正在迅速席卷全球,预计在未来几年将变得更加普及。如果要获得广泛的接受,这些聊天机器人通过对话得到的结果的连贯性必须不断提高。实现这一目标的一种方式是通过使用强化学习。
在本章中,我们实现了在创建聊天机器人过程中使用强化学习。该学习方法基于一种政策梯度方法,重点关注对话代理的未来方向,以生成连贯且有趣的互动。我们使用的数据集来自电影对话。我们对数据集进行了清理和预处理,从中获取了词汇表。然后,我们制定了我们的政策梯度方法。我们的奖励函数通过一个序列到序列模型表示。接着,我们训练并测试了我们的数据,获得了非常合理的结果,证明了使用强化学习进行对话代理的可行性。