Python-强化学习项目-全-
Python 强化学习项目(全)
原文:
annas-archive.org/md5/8a22ccc4f94e0a5a98e16b22a2b1f959译者:飞龙
序言
强化学习是机器学习中最令人兴奋且发展最快的领域之一。这得益于近年来开发出许多新颖的算法和发布的令人惊叹的结果。
本书将帮助你了解强化学习的核心概念,包括 Q 学习、策略梯度、蒙特卡洛过程以及多个深度强化学习算法。在阅读本书时,你将参与使用图像、文本和视频等多种模态的数据集进行项目工作。你将积累在多个领域的经验,包括游戏、图像处理和物理仿真。你还将探索如 TensorFlow 和 OpenAI Gym 等技术,以实现深度学习强化学习算法,这些算法还可以预测股价、生成自然语言,甚至构建其他神经网络。
本书结束时,你将通过八个强化学习项目获得实践经验,每个项目涉及不同的主题和/或算法。我们希望这些实用的练习能够帮助你更好地理解强化学习领域,并且学会如何将其算法应用到现实生活中的各种问题。
本书适用对象
Python 强化学习项目适合那些具有机器学习技术基础的数据显示分析师、数据科学家和机器学习专业人士,特别是那些希望探索机器学习新兴领域(如强化学习)的人。想要进行实际项目实现的个人也会发现本书非常有用。
本书内容概述
第一章,强化学习入门,介绍了人工智能、强化学习、深度学习、该领域的历史/应用及其他相关话题。它还将提供一个高层次的深度学习和 TensorFlow 概念概述,特别是与强化学习相关的部分。
第二章,平衡小车杆,将引导你实现第一个强化学习算法,使用 Python 和 TensorFlow 来解决小车平衡问题。
第三章,玩 ATARI 游戏,将引导你创建第一个深度强化学习算法来玩 ATARI 游戏。
第四章,模拟控制任务,简要介绍了用于连续控制问题的演员-评论家算法。你将学习如何模拟经典控制任务,如何实现基础的演员-评论家算法,并了解控制问题的最先进算法。
第五章,在 Minecraft 中构建虚拟世界,将前面章节中讲解的高级概念应用到 Minecraft 中,这是一个比 ATARI 游戏更复杂的游戏。
第六章,学习下围棋,让您构建一个能够下围棋的模型,这款流行的亚洲棋类游戏被认为是世界上最复杂的游戏之一。
第七章,创建聊天机器人,将教你如何在自然语言处理中应用深度强化学习。我们的奖励函数将是一个未来导向的函数,您将学习如何在创建此函数时考虑概率。
第八章,生成深度学习图像分类器,介绍了强化学习中最新和最激动人心的进展之一:使用强化学习生成深度学习模型。我们探索了由 Google Brain 发布的尖端研究,并实施了介绍的算法。
第九章,预测未来股票价格,讨论了构建能够预测股票价格的代理。
第十章,展望未来,通过讨论强化学习的真实世界应用以及介绍未来学术工作的潜在领域,结束了本书。
要充分利用本书
本书涵盖的示例可以在 Windows、Ubuntu 或 macOS 上运行。所有安装说明都已涵盖。需要基本的 Python 和机器学习知识。建议您拥有 GPU 硬件,但不是必需的。
下载示例代码文件
您可以从您的帐户在www.packt.com下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件获取文件。
您可以按照以下步骤下载代码文件:
- 
在www.packt.com登录或注册。
 - 
选择“支持”选项卡。
 - 
点击“代码下载与勘误”。
 - 
在搜索框中输入书名,并按照屏幕上的说明操作。
 
下载文件后,请确保使用最新版本的以下工具解压缩文件夹:
- 
Windows 的 WinRAR/7-Zip
 - 
Mac 上的 Zipeg/iZip/UnRarX
 - 
Linux 的 7-Zip/PeaZip
 
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-Reinforcement-Learning-Projects。如果代码有更新,将在现有的 GitHub 仓库中更新。
我们还从我们丰富的书籍和视频目录中提供了其他代码包,可在github.com/PacktPublishing/查看!
使用的约定
本书采用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账户名。例如:“gym-minecraft包的接口与其他 Gym 环境相同。”
代码块设置如下:
import logging
import minecraft_py
logging.basicConfig(level=logging.DEBUG)
任何命令行输入或输出如下所示:
python3 -m pip install gym
python3 -m pip install pygame
粗体:表示新术语、重要单词或在屏幕上显示的单词。例如,菜单或对话框中的单词在文本中会这样显示。举个例子:“从管理面板中选择系统信息。”
警告或重要说明以这种形式出现。
提示和技巧通常以这种形式出现。
联系我们
我们总是欢迎读者的反馈。
一般反馈:如果您对本书的任何部分有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com与我们联系。
勘误表:虽然我们已经尽最大努力确保内容的准确性,但错误难免发生。如果您在本书中发现错误,我们将非常感激您能向我们报告。请访问www.packt.com/submit-errata,选择您的书籍,点击“勘误表提交表单”链接,并填写详细信息。
盗版:如果您在互联网上遇到任何形式的我们作品的非法复制品,我们将感激您提供相关的网址或网站名称。请通过copyright@packt.com联系,并附上该材料的链接。
如果您有兴趣成为作者:如果您在某个领域拥有专业知识,并且有意撰写或贡献书籍内容,请访问authors.packtpub.com。
评论
请留下评论。在您阅读并使用本书后,为什么不在您购买的站点上留下评论呢?潜在读者可以查看并参考您的公正意见来做出购买决策,我们 Packt 可以了解您对我们产品的看法,作者也可以看到您对他们书籍的反馈。谢谢!
获取更多关于 Packt 的信息,请访问packt.com。
第一章:强化学习快速入门
人工智能(AI)在未来将会是什么样子?随着 AI 算法和软件应用的日益突出,这个问题应当引起许多人的关注。AI 的研究人员和从业者面临着进一步的相关问题:我们如何实现我们设想的目标并解决已知的问题?还有哪些创新和算法尚未开发出来?机器学习中的几个子领域展示了极大的潜力,能够解答我们许多问题。本书将聚焦于其中的一个子领域——强化学习,也许它是机器学习中最令人兴奋的话题之一。
强化学习的动机来自于通过与环境互动来学习的目标。想象一下一个婴儿是如何在其环境中活动的。通过四处移动并与周围环境互动,婴儿学习物理现象、因果关系以及与其互动的物体的各种属性和特性。婴儿的学习通常受到达成某个目标的驱动,比如玩周围的物体或满足某种好奇心。在强化学习中,我们追求类似的目标;我们采用一种计算方法来学习环境。换句话说,我们的目标是设计出通过与环境互动来完成任务的算法。
这些算法的用途是什么?通过拥有一种通用的学习算法,我们可以为解决多个现实世界中的问题提供有效的解决方案。一个显著的例子是强化学习算法用于自动驾驶汽车。尽管尚未完全实现,但这样的应用场景将为社会带来巨大益处,因为强化学习算法已经在实证中证明了其在多个任务中超越人类水平的能力。一个标志性的时刻发生在 2016 年,当时 DeepMind 的 AlphaGo 程序以四比一击败了 18 次围棋世界冠军李世石。AlphaGo 本质上能够在几个月内学习并超越人类三千年来积累的围棋智慧。最近,强化学习算法已被证明在玩更复杂的实时多智能体游戏(如 Dota)中也非常有效。这些驱动游戏算法的同样算法也成功地控制机器人手臂拾取物体,并指引无人机通过迷宫。这些例子不仅展示了这些算法的能力,还暗示了它们未来可能完成的任务。
本书简介
本书为那些渴望学习强化学习的读者提供了实用的指南。我们将通过大量的算法实例及其应用,采取动手实践的方式学习强化学习。每一章都专注于一个特定的应用案例,并介绍用于解决该问题的强化学习算法。这些应用案例中有些依赖于最先进的算法;因此,通过本书,我们将学习并实现一些业内最优表现的算法和技术。
随着你阅读本书,项目的难度/复杂度会逐渐增加。下表描述了你将从每一章节中学到的内容:
| 章节名称 | 应用案例/问题 | 讨论和使用的概念/算法/技术 | 
|---|---|---|
| 平衡推车杆 | 控制推车的水平移动以平衡竖直的杆 | OpenAI Gym 框架,Q 学习 | 
| 玩 Atari 游戏 | 在人类水平的熟练度下玩各种 Atari 游戏 | 深度 Q 网络 | 
| 模拟控制任务 | 控制一个在连续动作空间中的智能体,而非离散空间 | 确定性策略梯度(DPG)、信任域策略优化(TRPO)、多任务学习 | 
| 在 Minecraft 中构建虚拟世界 | 在 Minecraft 的虚拟世界中操作一个角色 | 异步优势 Actor-Critic(A3C) | 
| 学习围棋 | 围棋,世界上最古老且最复杂的棋盘游戏之一 | 蒙特卡罗树搜索、策略和价值网络 | 
| 创建聊天机器人 | 在对话环境中生成自然语言 | 策略梯度方法、长短期记忆网络(LSTM) | 
| 自动生成深度学习图像分类器 | 创建一个生成神经网络来解决特定任务的智能体 | 循环神经网络、策略梯度方法(REINFORCE) | 
| 预测未来股票价格 | 预测股票价格并做出买卖决策 | Actor-Critic 方法、时间序列分析、经验回放 | 
期望
本书最适合以下读者:
- 
具备中级 Python 编程能力
 - 
具备基本的机器学习和深度学习理解,特别是以下主题:
- 
神经网络
 - 
反向传播
 - 
卷积
 - 
提高泛化能力和减少过拟合的技术
 
 - 
 - 
喜欢动手实践的学习方式
 
由于本书旨在作为该领域的实用入门指南,我们尽量将理论内容控制在最小范围。然而,建议读者具备一些机器学习领域所依赖的基础数学和统计学概念的知识。这些包括以下内容:
- 
微积分(单变量和多变量)
 - 
线性代数
 - 
概率论
 - 
图论
 
对这些主题有一定了解将极大帮助读者理解本书中将涉及的概念和算法。
硬件和软件要求
接下来的章节将要求你实现各种强化学习算法。因此,为了顺利学习,必须有一个合适的开发环境。特别是,你应该具备以下条件:
- 
一台运行 macOS 或 Linux 操作系统的计算机(对于 Windows 用户,建议尝试设置一个安装了 Linux 镜像的虚拟机)
 - 
稳定的互联网连接
 - 
一块 GPU(最好有)
 
我们将 exclusively 使用 Python 编程语言来实现我们的强化学习和深度学习算法。此外,我们将使用 Python 3.6。我们将使用的库的列表可以在官方 GitHub 仓库中找到,网址是 (github.com/PacktPublishing/Python-Reinforcement-Learning-Projects)。你还可以在这个仓库中找到我们将在本书中讨论的每个算法的实现。
安装软件包
假设你已经有一个正常工作的 Python 安装,你可以通过我们的仓库中的 requirements.txt 文件安装所有所需的软件包。我们还建议你创建一个 virtualenv 来将开发环境与主操作系统隔离开来。以下步骤将帮助你构建环境并安装所需的包:
# Install virtualenv using pip
$ pip install virtualenv
# Create a virtualenv
$ virtualenv rl_projects
# Activate virtualenv
$ source rl_projects/bin/activate
# cd into the directory with our requirements.txt
(rl_projects) $ cd /path/to/requirements.txt
# pip install the required packages
(rl_projects) $ pip install -r requirements.txt
现在你已经准备好开始了!本章接下来的几节将介绍强化学习领域,并对深度学习进行回顾。
什么是强化学习?
我们的旅程始于了解强化学习的基本概念。那些熟悉机器学习的人可能已经知道几种学习范式,即监督学习和无监督学习。在监督学习中,机器学习模型有一个监督者,提供每个数据点的真实标签。模型通过最小化其预测与真实标签之间的差距来学习。因此,数据集需要对每个数据点进行标注,例如,每张狗狗和猫的图片都会有相应的标签。在无监督学习中,模型无法访问数据的真实标签,因此必须在没有标签的情况下学习数据的分布和模式。
在强化学习中,代理指的是学习完成特定任务的模型/算法。代理主要通过接收奖励信号来学习,奖励信号是衡量代理完成任务表现的标量指标。
假设我们有一个代理,负责控制机器人行走的动作;如果代理成功朝目标行走,则会获得正向奖励,而如果摔倒或未能取得进展,则会获得负向奖励。
此外,与监督学习不同,这些奖励信号不会立即返回给模型;相反,它们是作为代理执行一系列动作的结果返回的。动作就是代理在其环境中可以做的事情。环境指的是代理所处的世界,主要负责向代理返回奖励信号。代理的动作通常会根据代理从环境中感知到的信息来决定。代理感知到的内容被称为观察或状态。强化学习与其他学习范式的一个重要区别在于,代理的动作可以改变环境及其随后产生的响应。
例如,假设一个代理的任务是玩《太空入侵者》,这是一款流行的 Atari 2600 街机游戏。环境就是游戏本身,以及它运行的逻辑。在游戏过程中,代理向环境发出查询以获取观察结果。观察结果仅仅是一个(210, 160, 3)形状的数组,代表游戏屏幕,屏幕上显示代理的飞船、敌人、得分以及任何投射物。根据这个观察结果,代理做出一些动作,可能包括向左或向右移动、发射激光或什么也不做。环境接收代理的动作作为输入,并对状态进行必要的更新。
举例来说,如果激光击中敌方飞船,它会从游戏中消失。如果代理决定仅仅向左移动,游戏会相应更新代理的坐标。这个过程会一直重复,直到达到终止状态,即表示序列结束的状态。在《太空入侵者》游戏中,终止状态对应的是代理的飞船被摧毁,游戏随后返回记录的得分,该得分是根据代理成功摧毁的敌方飞船数量计算得出的。
请注意,一些环境没有终止状态,例如股票市场。这些环境会一直运行,直到它们结束。
让我们回顾一下到目前为止学习的术语:
| 术语 | 描述 | 示例 | 
|---|---|---|
| 代理 | 负责学习完成任务的模型/算法。 | 自动驾驶汽车、行走机器人、视频游戏玩家 | 
| 环境 | 代理所处的世界。它负责控制代理的感知内容,并提供关于代理执行特定任务表现的反馈。 | 汽车行驶的道路、视频游戏、股票市场 | 
| 动作 | 代理在环境中做出的决策,通常取决于代理的感知。 | 驾驶汽车、买卖特定股票、从代理控制的太空船上发射激光 | 
| 奖励信号 | 一个标量值,表示智能体在执行特定任务时的表现如何。 | 《太空入侵者》的得分、某只股票的投资回报、学习行走的机器人走过的距离 | 
| 观察/状态 | 智能体可以感知的环境描述。 | 从仪表盘摄像头拍摄的视频、游戏屏幕、股市统计数据 | 
| 终止状态 | 智能体无法再进行任何操作的状态。 | 迷宫的尽头,或者《太空入侵者》中的飞船被摧毁 | 
更正式地说,在给定的时间步t,以下内容发生在智能体P和环境E之间:
- P queries E for some observation 
- P decides to take action  based on observation 
- E receives  and returns reward  based on the action
- P receives 
- E updates  to  based on  and other factors
环境是如何计算
和
的?环境通常有自己的算法,根据多个输入/因素计算这些值,包括智能体采取的行动。
有时候,环境由多个智能体组成,它们试图最大化自己的奖励。重力作用于从高处落下的球的方式,很好地表现了环境是如何运作的;就像我们的周围环境遵循物理定律一样,环境也有一些内部机制来计算奖励和下一个状态。这个内部机制通常对智能体是隐藏的,因此我们的任务是构建能够在这种不确定性中依然能做好各自任务的智能体。
在接下来的章节中,我们将更详细地讨论每个强化学习问题的主要主体——智能体(Agent)。
智能体
强化学习智能体的目标是在环境中学会出色地执行任务。从数学角度来看,这意味着最大化累计奖励R,可以通过以下公式表示:

我们只是计算每个时间步获得奖励的加权总和。
被称为折扣因子,它是一个介于 0 和 1 之间的标量值。其概念是奖励来得越晚,它的价值就越低。这也反映了我们对奖励的看法;比如,我们宁愿现在收到$100,也不愿一年后再收到,这表明同样的奖励信号可以根据其接近现在的程度而具有不同的价值。
由于环境的机制对智能体来说并非完全可观察或已知,它必须通过执行行动并观察环境如何反应来获取信息。这与人类如何学习执行某些任务的方式非常相似。
假设我们正在学习下棋。虽然我们并未将所有可能的棋步记在脑中,或者完全知道对手会如何下棋,但我们能够随着时间的推移提高我们的技巧。特别是,我们能够在以下方面变得熟练:
- 
学习如何应对对手做出的行动
 - 
评估我们在赢得游戏中的位置有多好
 - 
预测对手接下来会做什么,并利用该预测来决定行动
 - 
理解别人如何在类似情况下进行游戏
 
事实上,强化学习代理可以学习做类似的事情。特别是,一个代理可以由多个功能和模型组成,以辅助其决策。一个代理可以包含三个主要组件:策略、价值函数和模型。
策略
策略是一个算法或一组规则,用来描述一个代理如何做出决策。例如,投资者在股市中交易时使用的策略就是一种策略,其中投资者在股价下跌时购买股票,在股价上涨时卖出股票。
更正式地说,策略是一个函数,通常表示为
,它将一个状态
映射到一个行动
:

这意味着一个代理根据其当前状态来决定其行动。这个函数可以表示任何内容,只要它能够接收状态作为输入并输出一个行动,无论是表格、图表还是机器学习分类器。
例如,假设我们有一个代理需要导航一个迷宫。我们进一步假设代理知道迷宫的样子;以下是代理策略的表示方式:

图 1:一个迷宫,其中每个箭头表示代理下一个可能的行动方向
这个迷宫中的每个白色方块代表代理可能处于的状态。每个蓝色箭头指示代理在相应方块中会采取的行动。这本质上表示了代理在这个迷宫中的策略。此外,这也可以视为一个确定性策略,因为从状态到行动的映射是确定的。这与随机策略相对,后者会在给定某种状态时输出一个关于可能行动的概率分布:

这里,
是所有可能行动的标准化概率向量,如以下示例所示:

图 2:一个将游戏状态(屏幕)映射到行动(概率)的策略
玩《Breakout》游戏的代理有一个策略,该策略以游戏屏幕为输入,并返回每个可能行动的概率。
价值函数
智能体的第二个组成部分称为值函数。如前所述,值函数有助于评估智能体在某一状态下的位置,无论是好是坏。在国际象棋中,玩家希望了解他们在当前棋盘状态下获胜的可能性。而在迷宫中,智能体则希望知道自己离目标有多近。值函数正是为此服务;它预测智能体在某一状态下将获得的预期未来奖励。换句话说,它衡量某一状态对智能体的吸引力。更正式地说,值函数将状态和策略作为输入,并返回一个标量值,表示预期的累计奖励:

以我们的迷宫示例为例,假设智能体每走一步就会收到-1 的惩罚奖励。智能体的目标是以尽可能少的步骤完成迷宫。每个状态的值可以如下表示:

图 3:一个迷宫,其中每个方格表示处于该状态时的值
每个方格基本上表示从当前位置到达迷宫终点所需的步数。如你所见,达到目标所需的最少步数为 15 步。
除了告诉我们某一状态的吸引力外,值函数还能如何帮助智能体更好地完成任务呢?正如我们将在接下来的章节中看到的,值函数在预测一系列动作是否能够成功之前起着至关重要的作用。这类似于国际象棋选手预想一系列未来动作如何有助于提高获胜的机会。为了做到这一点,智能体还需要了解环境是如何运作的。这时,智能体的第三个组成部分——模型——变得非常重要。
模型
在前面的章节中,我们讨论了环境对智能体的不可完全知晓。换句话说,智能体通常不知道环境内部算法的具体情况。因此,智能体需要与环境互动,以获取信息并学习如何最大化预期的累计奖励。然而,智能体可能会有一个环境的内部副本或模型。智能体可以使用该模型预测环境在给定状态下对某个动作的反应。例如,股市模型的任务是预测未来的价格。如果模型准确,智能体便可以利用其值函数评估未来状态的吸引力。更正式地说,模型可以表示为一个函数,
,它预测在当前状态和某个动作下,下一状态的概率:

在其他场景中,环境的模型可以用来列举可能的未来状态。这通常用于回合制游戏,如国际象棋和井字游戏,其中规则和可能动作的范围已明确规定。树状图常用于展示回合制游戏中可能的动作和状态序列:

图 4:一个使用其价值函数评估可能动作的模型
在前面的井字游戏示例中,
表示在给定状态下采取
动作(表示为阴影圆圈)可能带来的结果,状态为
。此外,我们可以使用代理的价值函数计算每个状态的值。中间和底部的状态将产生较高的值,因为代理离胜利仅一步之遥,而顶部的状态将产生中等值,因为代理需要阻止对手获胜。
让我们回顾一下到目前为止涵盖的术语:
| 术语 | 描述 | 它输出什么? | 
|---|---|---|
| 策略 | 算法或函数,用于输出代理做出的决策 | 输出单一决策的标量(确定性策略)或关于可能动作的概率向量(随机策略) | 
| 价值函数 | 描述某个状态好坏的函数 | 表示期望累积奖励的标量值 | 
| 模型 | 代理对环境的表示,预测环境如何对代理的行为作出反应 | 给定动作和当前状态下的下一个状态的概率,或根据环境规则列出可能的状态 | 
在接下来的章节中,我们将利用这些概念来学习强化学习中最基础的框架之一:马尔可夫决策过程。
马尔可夫决策过程(MDP)
马尔可夫决策过程是一个用来表示强化学习问题环境的框架。它是一个有向图模型(意味着图中的一个节点指向另一个节点)。每个节点代表环境中的一个可能状态,每个从状态指向外部的边代表在给定状态下可以采取的动作。例如,考虑以下的 MDP:

图 5:一个示例马尔可夫决策过程
前面的 MDP 表示程序员典型一天的情景。每个圆圈代表程序员可能处于的某个状态,其中蓝色状态(醒来)是初始状态(即代理在 t=0 时的状态),而橙色状态(发布代码)表示终止状态。每个箭头表示程序员可以在状态之间进行的转换。每个状态都有与之相关的奖励,奖励越高,该状态越具吸引力。
我们也可以将奖励制成邻接矩阵:
| 状态\动作 | 醒来 | Netflix | 编写代码和调试 | 小睡 | 部署 | 睡觉 | 
|---|---|---|---|---|---|---|
| 醒来 | N/A | -2 | -3 | N/A | N/A | N/A | 
| Netflix | N/A | -2 | N/A | N/A | N/A | N/A | 
| 编写代码和调试 | N/A | N/A | N/A | 1 | 10 | 3 | 
| 小睡 | 0 | N/A | N/A | N/A | N/A | N/A | 
| 部署 | N/A | N/A | N/A | N/A | N/A | 3 | 
| 睡觉 | N/A | N/A | N/A | N/A | N/A | N/A | 
左列表示可能的状态,顶行表示可能的动作。N/A 意味着在给定状态下无法执行该动作。该系统基本上表示程序员在一天中的决策过程。
当程序员醒来时,他们可以选择工作(编写和调试代码)或观看 Netflix。请注意,观看 Netflix 的奖励高于编写和调试代码的奖励。对于这个程序员来说,观看 Netflix 似乎是更有回报的活动,而编写和调试代码可能是一项苦差事(希望读者不会是这种情况!)。然而,这两种行为都会导致负奖励,尽管我们的目标是最大化累积奖励。如果程序员选择观看 Netflix,他们将陷入一个无休止的刷剧循环,这会不断降低奖励。相反,如果他们决定认真编写代码,更多有回报的状态将会向他们开放。我们来看看程序员可以采取的可能轨迹——即一系列动作:
- 
醒来 | Netflix | Netflix | ...
 - 
醒来 | 编写代码和调试 | 小睡 | 醒来 | 编写代码和调试 | 小睡 | ...
 - 
醒来 | 编写代码和调试 | 睡觉
 - 
醒来 | 编写代码和调试 | 部署 | 睡觉
 
第一和第二条轨迹都代表了无限循环。让我们计算每条轨迹的累积奖励,假设我们设置了 
:
很容易看出,尽管第一条和第二条轨迹没有达到终止状态,但它们永远不会返回正奖励。第四条轨迹带来了最高的奖励(成功部署代码是一个非常有回报的成就!)。
我们所计算的是四种策略的价值函数,程序员可以根据这些策略来规划他们的日常工作。回想一下,价值函数是指从给定状态出发并遵循某一策略的期望累计奖励。我们已经观察到四种可能的策略,并评估了每种策略如何导致不同的累计奖励;这个过程也叫做策略评估。此外,我们用于计算期望奖励的方程也被称为贝尔曼期望方程。贝尔曼方程是一组用于评估和改进策略与价值函数的方程,帮助强化学习智能体更好地学习。虽然本书并不深入介绍贝尔曼方程,但它们是构建强化学习理论理解的基础。我们鼓励读者深入研究这一主题。
虽然我们不会深入讲解贝尔曼方程,但我们强烈建议读者学习贝尔曼方程,以便建立强化学习的扎实理解。有关更多信息,请参见理查德·S·萨顿和安德鲁·巴托所著的《强化学习:导论》(本章末尾有参考文献)。
现在你已经了解了强化学习的一些关键术语和概念,或许你会想知道我们是如何教导强化学习智能体去最大化其奖励,换句话说,如何确定第四条轨迹是最优的。在本书中,你将通过解决许多任务和问题来实现这一目标,所有这些任务都将使用深度学习。虽然我们鼓励你熟悉深度学习的基础知识,但以下部分将作为该领域的轻度复习。
深度学习
深度学习已成为机器学习和计算机科学中最受欢迎且最具辨识度的领域之一。得益于可用数据和计算资源的增加,深度学习算法在无数任务中成功超越了以往的最先进成果。在多个领域,包括图像识别和围棋,深度学习甚至超越了人类的能力。
因此,许多强化学习算法开始利用深度学习来提高性能也就不足为奇了。本章开头提到的许多强化学习算法都依赖于深度学习。本书也将围绕深度学习算法展开,以解决强化学习问题。
以下部分将作为深度学习一些最基本概念的复习,包括神经网络、反向传播和卷积。然而,如果你不熟悉这些主题,我们强烈建议你寻求其他来源,获取更深入的介绍。
神经网络
神经网络是一种计算架构,由多层感知机组成。感知机由 Frank Rosenblatt 于 1950 年代首次构思,模拟生物神经元并计算输入向量的线性组合。它还使用非线性激活函数(如 sigmoid 函数)对线性组合进行转换并输出结果。假设一个感知机接收输入向量为
。感知机的输出a如下:


其中
是感知机的权重,b是常数,称为偏置,而
是 sigmoid 激活函数,它输出一个介于 0 和 1 之间的值。
感知机被广泛用作决策的计算模型。假设任务是预测第二天晴天的可能性。每一个
都代表一个变量,比如当天的温度、湿度或前一天的天气。然后,
将计算一个值,反映明天晴天的可能性。如果模型对于
有一组好的值,它就能做出准确的决策。
在典型的神经网络中,有多层神经元,每一层的每个神经元都与前一层和后一层的所有神经元相连接。因此,这些层也被称为全连接层。给定层的权重,l,可以表示为矩阵W^l:


其中,每个w[ij]表示前一层的第i个神经元与当前层的第j个神经元之间的权重。Bl*表示偏置向量,每个神经元都有一个偏置。于是,给定层*l*的激活值*al可以定义如下:


其中a⁰(x)只是输入。具有多层神经元的神经网络被称为多层感知机(MLP)。一个 MLP 有三个组件:输入层、隐藏层和输出层。数据从输入层开始,通过隐藏层中的一系列线性和非线性函数进行转换,然后从输出层输出为决策或预测。因此,这种架构也被称为前馈网络。以下图示展示了一个完全连接的网络的样子:

图 6:多层感知机的示意图
反向传播
如前所述,神经网络的性能取决于 W 的值有多好(为简便起见,我们将权重和偏差统称为 W)。当整个网络的规模增长时,手动为每一层的每个神经元确定最优权重变得不可行。因此,我们依赖于反向传播算法,它迭代且自动地更新每个神经元的权重。
为了更新权重,我们首先需要地面真实值,或者说神经网络尝试输出的目标值。为了理解这个地面真实值是什么样子,我们设定了一个样本问题。MNIST 数据集是一个包含 28x28 手写数字图像的大型库。它总共有 70,000 张图像,并且是机器学习模型的一个常见基准。给定十个不同的数字类别(从 0 到 9),我们希望识别给定图像属于哪个数字类别。我们可以将每张图像的地面真实值表示为一个长度为 10 的向量,其中类别的索引(从 0 开始)被标记为 1,其余为 0。例如,图像 x 的类别标签为 5,则其地面真实值为 
,其中 y 是我们要逼近的目标函数。
神经网络应该是什么样的?如果我们将图像中的每个像素看作输入,我们将会在输入层中拥有 28x28 个神经元(每张图像将被展平,变为一个 784 维的向量)。此外,由于有 10 个数字类别,我们在输出层中有 10 个神经元,每个神经元为给定类别产生一个 sigmoid 激活函数。隐藏层的神经元数量可以是任意的。
让 f 代表神经网络计算的转换序列,参数化为权重 W。f 本质上是目标函数 y 的近似,并将 784 维的输入向量映射到 10 维的输出预测。我们根据最大 sigmoid 输出的索引来对图像进行分类。
现在我们已经制定了地面真实值,我们可以衡量它与网络预测之间的距离。这个误差使得网络能够更新它的权重。我们将误差函数 E(W) 定义如下:

反向传播的目标是通过找到合适的 W 来最小化 E。这个最小化是一个优化问题,我们使用梯度下降法迭代地计算 E 相对于 W 的梯度,并从输出层开始将其传播通过网络。
不幸的是,反向传播的深入解释超出了本章节的范围。如果你对这个概念不熟悉,我们强烈建议你首先学习它。
卷积神经网络
通过反向传播,我们现在能够自动训练大型网络。这导致了越来越复杂的神经网络架构的出现。一个例子是卷积神经网络(CNN)。CNN 中主要有三种类型的层:卷积层、池化层和全连接层。全连接层与之前讨论的标准神经网络相同。在卷积层中,权重是卷积核的一部分。对二维图像像素数组的卷积定义如下:

其中,f(u, v)是输入在坐标(u, v)处的像素强度,g(x-u, y-v)是该位置卷积核的权重。
卷积层由一组卷积核组成;因此,卷积层的权重可以视为一个三维的盒子,而不是我们为全连接层定义的二维数组。应用于输入的单个卷积核的输出也是二维映射,我们称之为滤波器。由于有多个卷积核,卷积层的输出再次是一个三维的盒子,可以称之为体积。
最后,池化层通过对mm局部像素块进行操作,并输出一个标量,来减小输入的大小。最大池化层对mm像素块进行操作,并输出该像素块中的最大值。
给定一个形状为(32, 32, 3)的输入体积——对应于高度、宽度和深度(通道)——一个池化大小为 2x2 的最大池化层将输出形状为(16, 16, 3)的体积。CNN 的输入通常是图像,也可以看作是深度对应于 RGB 通道的体积。
以下是典型卷积神经网络的示意图:

图 7:一个示例卷积神经网络
神经网络的优势
CNN 相比标准神经网络的主要优势在于,前者能够学习输入的视觉和空间特征,而后者由于将输入数据展平为向量,丢失了这类信息。CNN 在计算机视觉领域取得了重大进展,最早体现在对MNIST数据的分类准确率提升,以及物体识别、语义分割等领域。CNN 在现实生活中有广泛的应用,从社交媒体中的人脸检测到自动驾驶汽车。最近的研究还将 CNN 应用于自然语言处理和文本分类任务,取得了最先进的成果。
现在我们已经介绍了机器学习的基础知识,接下来我们将进行第一次实现练习。
在 TensorFlow 中实现卷积神经网络
在本节中,我们将使用 TensorFlow 实现一个简单的卷积神经网络,以解决图像分类任务。由于本书其余部分将大量依赖于 TensorFlow 和 CNNs,我们强烈建议你熟悉如何使用该框架实现深度学习算法。
TensorFlow
TensorFlow 是由 Google 于 2015 年开发的,是世界上最流行的深度学习框架之一。它被广泛应用于研究和商业项目,并拥有丰富的 API 和功能,帮助研究人员和从业人员开发深度学习模型。TensorFlow 程序可以在 GPU 和 CPU 上运行,从而将 GPU 编程进行了抽象化,使开发变得更加便捷。
本书中,我们将专门使用 TensorFlow,因此在继续阅读本书的章节时,确保你熟悉基本知识。
访问 www.tensorflow.org/ 获取完整的文档和其他教程。
Fashion-MNIST 数据集
有深度学习经验的人大多听说过 MNIST 数据集。它是最广泛使用的图像数据集之一,作为图像分类和图像生成等任务的基准,并且被许多计算机视觉模型使用:

图 8:MNIST 数据集(本章末尾有参考文献)
然而,MNIST 数据集存在一些问题。首先,数据集过于简单,因为一个简单的卷积神经网络就能实现 99% 的测试准确率。尽管如此,该数据集在研究和基准测试中仍被过度使用。由在线时尚零售商 Zalando 提供的 F-MNIST 数据集,是对 MNIST 数据集的一个更复杂、更具挑战性的升级版本:

图 9:Fashion-MNIST 数据集(来源于 github.com/zalandoresearch/fashion-mnist,本章末尾有参考文献)
与数字不同,F-MNIST 数据集包含了十种不同类型的服装照片(从 T 恤到鞋子不等),这些照片被压缩为 28x28 的单色缩略图。因此,F-MNIST 作为 MNIST 的一个方便的替代品,越来越受到社区的欢迎。因此,我们也将在 F-MNIST 上训练我们的 CNN。前面的表格将每个标签索引映射到对应的类别:
| 索引 | 类别 | 
|---|---|
| 0 | T 恤/上衣 | 
| 1 | 长裤 | 
| 2 | 套头衫 | 
| 3 | 连衣裙 | 
| 4 | 外套 | 
| 5 | 凉鞋 | 
| 6 | 衬衫 | 
| 7 | 运动鞋 | 
| 8 | 包 | 
| 9 | 高帮靴 | 
在接下来的子章节中,我们将设计一个卷积神经网络,它将学习如何对来自该数据集的数据进行分类。
构建网络
多个深度学习框架已经实现了加载F-MNIST数据集的 API,包括 TensorFlow。在我们的实现中,我们将使用 Keras,这是另一个与 TensorFlow 集成的流行深度学习框架。Keras 的数据集模块提供了一个非常方便的接口,将数据集加载为numpy数组。
最后,我们可以开始编写代码了!对于本次练习,我们只需要一个 Python 模块,我们将其命名为cnn.py。打开你喜欢的文本编辑器或 IDE,开始吧。
我们的第一步是声明我们将使用的模块:
import logging
import os
import sys
logger = logging.getLogger(__name__)
import tensorflow as tf
import numpy as np
from keras.datasets import fashion_mnist
from keras.utils import np_utils
以下描述了每个模块的用途以及我们将如何使用它:
| 模块 | 用途 | 
|---|---|
logging | 
用于在运行代码时打印统计信息 | 
os, sys | 
用于与操作系统交互,包括写文件 | 
tensorflow | 
主要的 TensorFlow 库 | 
numpy | 
一个优化过的向量计算和简单数据处理库 | 
keras | 
用于下载 F-MNIST 数据集 | 
我们将实现一个名为SimpleCNN的类。__init__构造函数接受多个参数:
class SimpleCNN(object):
    def __init__(self, learning_rate, num_epochs, beta, batch_size):
        self.learning_rate = learning_rate
        self.num_epochs = num_epochs
        self.beta = beta
        self.batch_size = batch_size
        self.save_dir = "saves"
        self.logs_dir = "logs"
        os.makedirs(self.save_dir, exist_ok=True)
        os.makedirs(self.logs_dir, exist_ok=True)
        self.save_path = os.path.join(self.save_dir, "simple_cnn")
        self.logs_path = os.path.join(self.logs_dir, "simple_cnn")
我们的SimpleCNN初始化时的参数在此进行描述:
| 参数 | 用途 | 
|---|---|
learning_rate | 
优化算法的学习率 | 
num_epochs | 
训练网络所需的 epoch 次数 | 
beta | 
一个浮动值(介于 0 和 1 之间),控制 L2 惩罚的强度 | 
batch_size | 
每次训练中处理的图像数量 | 
此外,save_dir和save_path指代的是我们将存储网络参数的位置,logs_dir和logs_path则指代存储训练过程统计信息的位置(稍后我们会展示如何获取这些日志)。
构建网络的方法
在这一部分,我们将看到两种可以用于构建该功能的方法,它们分别是:
- 
构建方法
 - 
拟合方法
 
构建方法
我们为SimpleCNN类定义的第一个方法是build方法,它负责构建 CNN 的架构。我们的build方法接受两个输入:输入张量和它应该预期的类别数:
def build(self, input_tensor, num_classes):
    """
    Builds a convolutional neural network according to the input shape and the number of classes.
    Architecture is fixed.
    Args:
        input_tensor: Tensor of the input
        num_classes: (int) number of classes
    Returns:
        The output logits before softmax
    """
我们首先初始化tf.placeholder,命名为is_training。TensorFlow 的占位符类似于没有值的变量,只有在实际训练网络并调用相关操作时,我们才会给它赋值:
with tf.name_scope("input_placeholders"):
    self.is_training = tf.placeholder_with_default(True, shape=(), name="is_training")
tf.name_scope(...)块允许我们为操作和张量命名。虽然这不是绝对必要的,但它有助于更好地组织代码,也有助于我们可视化网络。在这里,我们定义了一个名为is_training的tf.placeholder_with_default,其默认值为True。这个占位符将用于我们的 dropout 操作(因为 dropout 在训练和推理阶段有不同的模式)。
为操作和张量命名被认为是一种好习惯,它有助于你更好地组织代码。
下一步是定义我们 CNN 的卷积层。我们使用三种不同类型的层来创建多个卷积层:tf.layers.conv2d、tf.max_pooling2d和tf.layers.dropout:
with tf.name_scope("convolutional_layers"):
    conv_1 = tf.layers.conv2d(
        input_tensor,
        filters=16,
        kernel_size=(5, 5),
        strides=(1, 1),
        padding="SAME",
        activation=tf.nn.relu,
        kernel_regularizer=tf.contrib.layers.l2_regularizer(scale=self.beta),
        name="conv_1")
    conv_2 = tf.layers.conv2d(
        conv_1,
        filters=32,
        kernel_size=(3, 3),
        strides=(1, 1),
        padding="SAME",
        activation=tf.nn.relu,
        kernel_regularizer=tf.contrib.layers.l2_regularizer(scale=self.beta),
        name="conv_2")
    pool_3 = tf.layers.max_pooling2d(
        conv_2,
        pool_size=(2, 2),
        strides=1,
        padding="SAME",
        name="pool_3"
    )
    drop_4 = tf.layers.dropout(pool_3, training=self.is_training, name="drop_4")
    conv_5 = tf.layers.conv2d(
        drop_4,
        filters=64,
        kernel_size=(3, 3),
        strides=(1, 1),
        padding="SAME",
        activation=tf.nn.relu,
        kernel_regularizer=tf.contrib.layers.l2_regularizer(scale=self.beta),
        name="conv_5")
    conv_6 = tf.layers.conv2d(
        conv_5,
        filters=128,
        kernel_size=(3, 3),
        strides=(1, 1),
        padding="SAME",
        activation=tf.nn.relu,
        kernel_regularizer=tf.contrib.layers.l2_regularizer(scale=self.beta),
        name="conv_6")
    pool_7 = tf.layers.max_pooling2d(
        conv_6,
        pool_size=(2, 2),
        strides=1,
        padding="SAME",
        name="pool_7"
    )
    drop_8 = tf.layers.dropout(pool_7, training=self.is_training, name="drop_8")
下面是一些参数的解释:
| 参数 | 类型 | 描述 | 
|---|---|---|
filters | 
int | 
卷积层输出的过滤器数量。 | 
kernel_size | 
int元组 | 
卷积核的形状。 | 
pool_size | 
int元组 | 
最大池化窗口的形状。 | 
strides | 
int | 
每次卷积/最大池化操作时,滑动的像素数量。 | 
padding | 
str | 
是否添加填充(SAME)或者不添加(VALID)。如果添加填充,卷积的输出形状将与输入形状保持一致。 | 
activation | 
func | 
一个 TensorFlow 激活函数。 | 
kernel_regularizer | 
op | 
使用哪种正则化方法来约束卷积核。默认值是None。 | 
training | 
op | 
一个张量/占位符,用于告诉 dropout 操作当前是用于训练还是推理。 | 
在上面的表格中,我们指定了卷积架构的层次顺序如下:
CONV | CONV | POOL | DROPOUT | CONV | CONV | POOL | DROPOUT
然而,我们鼓励您探索不同的配置和架构。例如,您可以添加批归一化层,以提高训练的稳定性。
最后,我们添加全连接层,将网络的输出生成最终结果:
with tf.name_scope("fully_connected_layers"):
    flattened = tf.layers.flatten(drop_8, name="flatten")
    fc_9 = tf.layers.dense(
        flattened,
        units=1024,
        activation=tf.nn.relu,
        kernel_regularizer=tf.contrib.layers.l2_regularizer(scale=self.beta),
        name="fc_9"
    )
    drop_10 = tf.layers.dropout(fc_9, training=self.is_training, name="drop_10")
    logits = tf.layers.dense(
        drop_10,
        units=num_classes,
        kernel_regularizer=tf.contrib.layers.l2_regularizer(scale=self.beta),
        name="logits"
    )
return logits
tf.layers.flatten将卷积层的输出(即 3 维)转化为一个单一的向量(1 维),这样我们就可以将它们传入tf.layers.dense层。经过两个全连接层后,我们返回最终的输出,这个输出我们定义为logits。
请注意,在最终的tf.layers.dense层中,我们没有指定activation。当我们开始指定网络的训练操作时,您会明白为什么这么做。
接下来,我们实现几个辅助函数。_create_tf_dataset接受两个numpy.ndarray实例,并将它们转换为 TensorFlow 张量,可以直接传入网络。_log_loss_and_acc则简单地记录训练统计数据,如损失和准确率:
def _create_tf_dataset(self, x, y):
    dataset = tf.data.Dataset.zip((
            tf.data.Dataset.from_tensor_slices(x),
            tf.data.Dataset.from_tensor_slices(y)
        )).shuffle(50).repeat().batch(self.batch_size)
    return dataset
def _log_loss_and_acc(self, epoch, loss, acc, suffix):
    summary = tf.Summary(value=[
        tf.Summary.Value(tag="loss_{}".format(suffix), simple_value=float(loss)),
        tf.Summary.Value(tag="acc_{}".format(suffix), simple_value=float(acc))
    ])
    self.summary_writer.add_summary(summary, epoch)
fit 方法
我们为SimpleCNN实现的最后一个方法是fit方法。这个函数触发我们的 CNN 训练。我们的fit方法有四个输入:
| 参数 | 描述 | 
|---|---|
X_train | 
训练数据 | 
y_train | 
训练标签 | 
X_test | 
测试数据 | 
y_test | 
测试标签 | 
fit的第一步是初始化tf.Graph和tf.Session。这两个对象是任何 TensorFlow 程序的核心。tf.Graph表示定义所有 CNN 操作的图。你可以把它看作是一个沙箱,我们在其中定义所有层和函数。tf.Session是实际执行tf.Graph中定义操作的类:
def fit(self, X_train, y_train, X_valid, y_valid):
    """
    Trains a CNN on given data
    Args:
        numpy.ndarrays representing data and labels respectively
    """
    graph = tf.Graph()
    with graph.as_default():
        sess = tf.Session()
然后,我们使用 TensorFlow 的 Dataset API 和之前定义的_create_tf_dataset方法来创建数据集:
train_dataset = self._create_tf_dataset(X_train, y_train)
valid_dataset = self._create_tf_dataset(X_valid, y_valid)
# Creating a generic iterator
iterator = tf.data.Iterator.from_structure(train_dataset.output_types,
                                           train_dataset.output_shapes)
next_tensor_batch = iterator.get_next()
# Separate training and validation set init ops
train_init_ops = iterator.make_initializer(train_dataset)
valid_init_ops = iterator.make_initializer(valid_dataset)
input_tensor, labels = next_tensor_batch
tf.data.Iterator构建一个迭代器对象,每次我们调用iterator.get_next()时,它都会输出一批图像。我们为训练数据和测试数据分别初始化数据集。iterator.get_next()的结果是输入图像和相应标签的元组。
前者是input_tensor,我们将其输入到build方法中。后者用于计算损失函数和反向传播:
num_classes = y_train.shape[1]
# Building the network
logits = self.build(input_tensor=input_tensor, num_classes=num_classes)
logger.info('Built network')
prediction = tf.nn.softmax(logits, name="predictions")
loss_ops = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(
    labels=labels, logits=logits), name="loss")
logits(网络的非激活输出)输入到两个其他操作中:prediction,它只是对logits进行 softmax 处理,以获得类的归一化概率,以及loss_ops,它计算预测和标签之间的平均类别交叉熵。
然后我们定义了用于训练网络的反向传播算法和用于计算准确度的操作:
optimizer = tf.train.AdamOptimizer(learning_rate=self.learning_rate)
train_ops = optimizer.minimize(loss_ops)
correct = tf.equal(tf.argmax(prediction, 1), tf.argmax(labels, 1), name="correct")
accuracy_ops = tf.reduce_mean(tf.cast(correct, tf.float32), name="accuracy")
现在我们已经完成了网络和其优化算法的构建。我们使用tf.global_variables_initializer()来初始化网络的权重和操作。我们还初始化了tf.train.Saver和tf.summary.FileWriter对象。tf.train.Saver对象用于保存网络的权重和架构,而后者则跟踪各种训练统计数据:
initializer = tf.global_variables_initializer()
logger.info('Initializing all variables')
sess.run(initializer)
logger.info('Initialized all variables')
sess.run(train_init_ops)
logger.info('Initialized dataset iterator')
self.saver = tf.train.Saver()
self.summary_writer = tf.summary.FileWriter(self.logs_path)
最后,一旦我们设置好所需的一切,就可以实现实际的训练循环。每个 epoch,我们跟踪训练的交叉熵损失和网络的准确度。在每个 epoch 结束时,我们将更新后的权重保存到磁盘。我们还会每 10 个 epoch 计算一次验证损失和准确度。这是通过调用sess.run(...)来完成的,其中该函数的参数是sess对象应该执行的操作:
logger.info("Training CNN for {} epochs".format(self.num_epochs))
for epoch_idx in range(1, self.num_epochs+1):
    loss, _, accuracy = sess.run([
        loss_ops, train_ops, accuracy_ops
    ])
    self._log_loss_and_acc(epoch_idx, loss, accuracy, "train")
    if epoch_idx % 10 == 0:
        sess.run(valid_init_ops)
        valid_loss, valid_accuracy = sess.run([
            loss_ops, accuracy_ops
        ], feed_dict={self.is_training: False})
        logger.info("=====================> Epoch {}".format(epoch_idx))
        logger.info("\tTraining accuracy: {:.3f}".format(accuracy))
        logger.info("\tTraining loss: {:.6f}".format(loss))
        logger.info("\tValidation accuracy: {:.3f}".format(valid_accuracy))
        logger.info("\tValidation loss: {:.6f}".format(valid_loss))
        self._log_loss_and_acc(epoch_idx, valid_loss, valid_accuracy, "valid")
    # Creating a checkpoint at every epoch
    self.saver.save(sess, self.save_path)
到这里,我们完成了fit函数。我们的最后一步是创建脚本,实例化数据集、神经网络,并运行训练,这将在cnn.py的底部编写:
我们首先配置日志记录器,并使用 Keras 的fashion_mnist模块加载数据集,该模块加载训练数据和测试数据:
if __name__ == "__main__":
    logging.basicConfig(stream=sys.stdout,
                        level=logging.DEBUG,
                        format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s')
    logger = logging.getLogger(__name__)
    logger.info("Loading Fashion MNIST data")
    (X_train, y_train), (X_test, y_test) = fashion_mnist.load_data()
然后我们对数据进行一些简单的预处理。Keras API 返回形状为(Number of images, 28, 28)的numpy数组。
然而,我们实际上需要的是(Number of images, 28, 28, 1),其中第三个轴是通道轴。这是必须的,因为我们的卷积层期望输入具有三个轴。此外,像素值本身的范围是[0, 255]。我们将它们除以 255,以获得[0, 1]的范围。这是一种常见的技术,有助于稳定训练。
此外,我们将标签(它只是标签索引的数组)转换为 one-hot 编码:
logger.info('Shape of training data:')
logger.info('Train: {}'.format(X_train.shape))
logger.info('Test: {}'.format(X_test.shape))
logger.info('Adding channel axis to the data')
X_train = X_train[:,:,:,np.newaxis]
X_test = X_test[:,:,:,np.newaxis]
logger.info("Simple transformation by dividing pixels by 255")
X_train = X_train / 255.
X_test = X_test / 255.
X_train = X_train.astype(np.float32)
X_test = X_test.astype(np.float32)
y_train = y_train.astype(np.float32)
y_test = y_test.astype(np.float32)
num_classes = len(np.unique(y_train))
logger.info("Turning ys into one-hot encodings")
y_train = np_utils.to_categorical(y_train, num_classes=num_classes)
y_test = np_utils.to_categorical(y_test, num_classes=num_classes)
然后,我们定义了SimpleCNN构造函数的输入。可以自由调整这些数字,看看它们如何影响模型的性能:
cnn_params = {
    "learning_rate": 3e-4,
    "num_epochs": 100,
    "beta": 1e-3,
    "batch_size": 32
}
最后,我们实例化SimpleCNN并调用其fit方法:
logger.info('Initializing CNN')
simple_cnn = SimpleCNN(**cnn_params)
logger.info('Training CNN')
simple_cnn.fit(X_train=X_train,
               X_valid=X_test,
               y_train=y_train,
               y_valid=y_test)
要运行整个脚本,你只需运行模块:
$ python cnn.py
就是这样!你已经成功地在 TensorFlow 中实现了一个卷积神经网络,并在 F-MNIST 数据集上进行训练。要跟踪训练进度,你只需查看终端/编辑器中的输出。你应该看到类似以下的输出:
$ python cnn.py
Using TensorFlow backend.
2018-07-29 21:21:55,423 __main__ INFO Loading Fashion MNIST data
2018-07-29 21:21:55,686 __main__ INFO Shape of training data:
2018-07-29 21:21:55,687 __main__ INFO Train: (60000, 28, 28)
2018-07-29 21:21:55,687 __main__ INFO Test: (10000, 28, 28)
2018-07-29 21:21:55,687 __main__ INFO Adding channel axis to the data
2018-07-29 21:21:55,687 __main__ INFO Simple transformation by dividing pixels by 255
2018-07-29 21:21:55,914 __main__ INFO Turning ys into one-hot encodings
2018-07-29 21:21:55,914 __main__ INFO Initializing CNN
2018-07-29 21:21:55,914 __main__ INFO Training CNN
2018-07-29 21:21:58,365 __main__ INFO Built network
2018-07-29 21:21:58,562 __main__ INFO Initializing all variables
2018-07-29 21:21:59,284 __main__ INFO Initialized all variables
2018-07-29 21:21:59,639 __main__ INFO Initialized dataset iterator
2018-07-29 21:22:00,880 __main__ INFO Training CNN for 100 epochs
2018-07-29 21:24:23,781 __main__ INFO =====================> Epoch 10
2018-07-29 21:24:23,781 __main__ INFO Training accuracy: 0.406
2018-07-29 21:24:23,781 __main__ INFO Training loss: 1.972021
2018-07-29 21:24:23,781 __main__ INFO Validation accuracy: 0.500
2018-07-29 21:24:23,782 __main__ INFO Validation loss: 2.108872
2018-07-29 21:27:09,541 __main__ INFO =====================> Epoch 20
2018-07-29 21:27:09,541 __main__ INFO Training accuracy: 0.469
2018-07-29 21:27:09,541 __main__ INFO Training loss: 1.573592
2018-07-29 21:27:09,542 __main__ INFO Validation accuracy: 0.500
2018-07-29 21:27:09,542 __main__ INFO Validation loss: 1.482948
2018-07-29 21:29:57,750 __main__ INFO =====================> Epoch 30
2018-07-29 21:29:57,750 __main__ INFO Training accuracy: 0.531
2018-07-29 21:29:57,750 __main__ INFO Training loss: 1.119335
2018-07-29 21:29:57,750 __main__ INFO Validation accuracy: 0.625
2018-07-29 21:29:57,750 __main__ INFO Validation loss: 0.905031
2018-07-29 21:32:45,921 __main__ INFO =====================> Epoch 40
2018-07-29 21:32:45,922 __main__ INFO Training accuracy: 0.656
2018-07-29 21:32:45,922 __main__ INFO Training loss: 0.896715
2018-07-29 21:32:45,922 __main__ INFO Validation accuracy: 0.719
2018-07-29 21:32:45,922 __main__ INFO Validation loss: 0.847015
另一个需要查看的工具是 TensorBoard,这是由 TensorFlow 开发者开发的可视化工具,用于绘制模型的准确度和损失图。我们使用的 tf.summary.FileWriter 对象用于此目的。你可以通过以下命令运行 TensorBoard:
$ tensorboard --logdir=logs/
logs 是我们的 SimpleCNN 模型写入统计数据的地方。TensorBoard 是一个很棒的工具,用于可视化我们的 tf.Graph 结构,并观察准确度和损失等统计数据随时间的变化。默认情况下,TensorBoard 日志可以通过将浏览器指向 localhost:6006 进行访问:

图 10:TensorBoard 及其对我们 CNN 的可视化
恭喜!我们已经成功使用 TensorFlow 实现了一个卷积神经网络。然而,我们实现的 CNN 相当基础,且只实现了中等的准确度——挑战在于,读者可以调整架构以提高其性能。
总结
在本章中,我们迈出了强化学习世界的第一步。我们介绍了一些该领域的基本概念和术语,包括智能体、策略、价值函数和奖励。同时,我们也涵盖了深度学习的基本主题,并使用 TensorFlow 实现了一个简单的卷积神经网络。
强化学习的领域非常广阔且不断扩展;在一本书中无法涵盖所有内容。然而,我们希望能为你提供必要的实践技能和经验,以便在这个领域中导航。
接下来的章节将包含独立的项目——我们将使用强化学习和深度学习算法的结合来解决多个任务和问题。我们将构建智能体,让它们学习下围棋、探索 Minecraft 世界并玩 Atari 电子游戏。我们希望你已经准备好踏上这段激动人心的学习旅程!
参考文献
Sutton, Richard S., 和 Andrew G. Barto. 强化学习:导论. MIT 出版社,1998 年。
Y. LeCun, L. Bottou, Y. Bengio, 和 P. Haffner. 基于梯度的学习应用于文档识别. 《IEEE 会议录》,86(11):2278-2324,1998 年 11 月。
Xiao, Han, Kashif Rasul, 和 Roland Vollgraf. Fashion-mnist:一种用于基准测试机器学习算法的新型图像数据集. arXiv 预印本 arXiv:1708.07747 (2017)。
第二章:平衡 CartPole
在本章中,您将学习关于 CartPole 平衡问题的内容。CartPole 是一个倒立摆,杆子与重力平衡。传统上,这个问题通过控制理论和分析方程解决。然而,在本章中,我们将通过机器学习来解决这个问题。
本章将涵盖以下主题:
- 
安装 OpenAI Gym
 - 
Gym 的不同环境
 
OpenAI Gym
OpenAI 是一个致力于人工智能研究的非盈利组织。访问 openai.com 了解更多关于 OpenAI 使命的信息。OpenAI 开发的技术对任何人都免费使用。
Gym
Gym 提供了一个基准工具包,用于评估基于人工智能的任务。该接口易于使用,目标是实现可重复的研究。访问gym.openai.com了解更多关于 Gym 的信息。一个智能体可以在gym中学习,并掌握诸如玩游戏或走路等活动。环境是一个问题库。
Gym 提供的标准问题集如下:
- 
CartPole
 - 
摆锤
 - 
Space Invaders
 - 
Lunar Lander
 - 
蚂蚁
 - 
Mountain Car
 - 
Acrobot
 - 
赛车
 - 
双足行走者
 
任何算法都可以在 Gym 中通过训练这些活动来运行。所有问题都具有相同的接口。因此,任何通用强化学习算法都可以通过该接口使用。
安装
Gym 的主要接口通过 Python 使用。一旦你在一个带有 pip 安装器的环境中拥有 Python3,就可以通过以下方式安装 Gym:
sudo pip install gym 
高级用户如果想要修改源代码,可以通过以下命令从源代码编译:
git clone https://github.com/openai/gym 
cd gym 
pip install -e .
可以通过源代码将一个新环境添加到 gym 中。有些环境需要更多的依赖项。对于 macOS,使用以下命令安装依赖项:
brew install cmake boost boost-python sdl2 swig wget
对于 Ubuntu,使用以下命令:
apt-get install -y python-numpy python-dev cmake zlib1g-dev libjpeg-dev xvfb libav-tools xorg-dev python-opengl libboost-all-dev libsdl2-dev swig
一旦依赖项准备好,按照以下方式安装完整的 gym:
pip install 'gym[all]'
这将安装大多数所需的环境。
运行一个环境
任何 Gym 环境都可以通过简单的接口初始化并运行。让我们从导入 gym 库开始,如下所示:
- 首先我们导入 
gym库: 
import gym 
- 接下来,通过传递参数给 
gym.make创建一个环境。以下代码以 CartPole 为例: 
environment = gym.make('CartPole-v0') 
- 接下来,重置环境:
 
environment.reset() 
- 然后,开始迭代并渲染环境,如下所示:
 
for dummy in range(100):
    environment.render() 
    environment.step(environment.action_space.sample())
此外,在每一步都改变动作空间,以观察 CartPole 的移动。运行前面的程序应该会生成一个可视化界面。场景应以以下的可视化效果开始:

上面的图像称为 CartPole。CartPole 由一个可以水平移动的车厢和一个相对于车厢中心可以旋转的杆组成。
杆子被固定在小车上。经过一段时间,您会发现杆子开始向一侧倾斜,如下图所示:

在经过几次迭代后,杆子会摆回,如下图所示。所有的动作都受到物理定律的约束。步骤是随机进行的:

其他环境可以通过类似的方式显示,只需替换 Gym 环境的参数,如MsPacman-v0或MountainCar-v0。对于其他环境,可能需要其他许可证。接下来,我们将介绍其余的环境。
Atari
要玩 Atari 游戏,可以调用任何环境。以下代码表示游戏《太空侵略者》:
environment = gym.make('SpaceInvaders-v0')
执行前面的命令后,您将看到以下屏幕:

可以在这个环境中玩 Atari 游戏。
算法任务
有一些算法任务可以通过强化学习来学习。可以调用一个副本环境,如下所示:
environment = gym.make('Copy-v0')
复制字符串的过程在下图中显示:

MuJoCo
MuJoCo 代表多关节动力学与接触。它是一个用于机器人和多体动力学的仿真环境:
environment = gym.make('Humanoid-v2')
以下是人形机器人的仿真可视化:

人形机器人仿真
在这个环境中可以模拟机器人和其他物体。
机器人技术
也可以创建一个机器人环境,如下所示:
environment = gym.make('HandManipulateBlock-v0')
以下是机器人手部的可视化:

OpenAI Gym 可以用于多个环境。
马尔可夫模型
问题被设定为强化学习问题,采用试错法。环境通过state_values state_values (?)来描述,state_values会受到动作的影响。动作由一个算法确定,基于当前的state_value,以实现一个特定的state_value,这被称为马尔可夫模型。在理想情况下,过去的state_values确实会影响未来的state_values,但在这里,我们假设当前的state_value已经包含了所有以前的state_values。state_values有两种类型;一种是可观察的,另一种是不可观察的。模型也必须考虑不可观察的state_values。这被称为隐马尔可夫模型。
CartPole
在每个小车和杆子的步骤中,可以观察到多个变量,如位置、速度、角度和角速度。小车的state_values可以向左右移动:
- 
state_values:四维连续值。 - 
Actions:两个离散值。 - 
维度或空间,可以称之为
state_value空间和动作空间。我们首先导入所需的库,如下所示: 
import gym
import numpy as np
import random
import math
- 接下来,创建用于玩 CartPole 的环境,如下所示:
 
environment = gym.make('CartPole-v0')
- 接下来,定义桶的数量和动作的数量,如下所示:
 
no_buckets = (1, 1, 6, 3)
no_actions = environment.action_space.n
- 接下来,定义 
state_value_bounds,如下所示: 
state_value_bounds = list(zip(environment.observation_space.low, environment.observation_space.high))
state_value_bounds[1] = [-0.5, 0.5]
state_value_bounds[3] = [-math.radians(50), math.radians(50)]
- 接下来,定义 
action_index,如下所示: 
action_index = len(no_buckets)
- 接下来,定义 
q_value_table,如下所示: 
q_value_table = np.zeros(no_buckets + (no_actions,))
- 接下来,定义最小探索率和最小学习率,如下所示:
 
min_explore_rate = 0.01
min_learning_rate = 0.1
- 接下来,定义最大回合数、最大时间步数、解决到达的连续次数、解决时间、折扣因子和连续次数作为常量:
 
max_episodes = 1000
max_time_steps = 250
streak_to_end = 120
solved_time = 199
discount = 0.99
no_streaks = 0
- 接下来,定义 
select动作来决定行动,如下所示: 
def select_action(state_value, explore_rate):
    if random.random() < explore_rate:
        action = environment.action_space.sample()
    else:
        action = np.argmax(q_value_table[state_value])
    return action
- 接下来,选择探索状态,如下所示:
 
def select_explore_rate(x):
    return max(min_explore_rate, min(1, 1.0 - math.log10((x+1)/25)))
- 接下来,选择学习率,如下所示:
 
def select_learning_rate(x):
    return max(min_learning_rate, min(0.5, 1.0 - math.log10((x+1)/25)))
- 接下来,
bucketizestate_value,如下所示: 
def bucketize_state_value(state_value):
    bucket_indexes = []
    for i in range(len(state_value)):
        if state_value[i] <= state_value_bounds[i][0]:
           bucket_index = 0
        elif state_value[i] >= state_value_bounds[i][1]:
            bucket_index = no_buckets[i] - 1
        else:
            bound_width = state_value_bounds[i][1] - state_value_bounds[i][0]
            offset = (no_buckets[i]-1)*state_value_bounds[i][0]/bound_width
            scaling = (no_buckets[i]-1)/bound_width
            bucket_index = int(round(scaling*state_value[i] - offset))
        bucket_indexes.append(bucket_index)
    return tuple(bucket_indexes)
- 接下来,训练各个回合,如下所示:
 
for episode_no in range(max_episodes):
    explore_rate = select_explore_rate(episode_no)
    learning_rate = select_learning_rate(episode_no)
    observation = environment.reset()
    start_state_value = bucketize_state_value(observation)
    previous_state_value = start_state_value
    for time_step in range(max_time_steps):
        environment.render()
        selected_action = select_action(previous_state_value, explore_rate)
        observation, reward_gain, completed, _ = environment.step(selected_action)
        state_value = bucketize_state_value(observation)
        best_q_value = np.amax(q_value_table[state_value])
        q_value_table[previous_state_value + (selected_action,)] += learning_rate * (
                reward_gain + discount * (best_q_value) - q_value_table[previous_state_value + (selected_action,)])
- 接下来,打印所有相关的训练过程指标,如下所示:
 
        print('Episode number : %d' % episode_no)
        print('Time step : %d' % time_step)
        print('Selection action : %d' % selected_action)
        print('Current state : %s' % str(state_value))
        print('Reward obtained : %f' % reward_gain)
        print('Best Q value : %f' % best_q_value)
        print('Learning rate : %f' % learning_rate)
        print('Explore rate : %f' % explore_rate)
        print('Streak number : %d' % no_streaks)
        if completed:
            print('Episode %d finished after %f time steps' % (episode_no, time_step))
            if time_step >= solved_time:
                no_streaks += 1
            else:
                no_streaks = 0
            break
        previous_state_value = state_value
    if no_streaks > streak_to_end:
        break
- 经过一段时间的训练后,CartPole 将能够自我保持平衡,如下图所示:
 

你已经学会了一个程序,它可以使 CartPole 稳定。
总结
在这一章中,你了解了 OpenAI Gym,它被用于强化学习项目。你看到了几个提供开箱即用的训练平台示例。然后,我们提出了 CartPole 问题,并通过试错法使 CartPole 保持平衡。
在下一章,你将学习如何使用 Gym 和强化学习方法来玩 Atari 游戏。
第三章:玩 Atari 游戏
一个机器如何自己学习玩视频游戏并击败人类玩家?解决这个问题是通向游戏领域人工智能(AI)的第一步。创建 AI 玩家所需的关键技术是深度强化学习。2015 年,谷歌的 DeepMind(该团队因开发击败围棋冠军李世石的机器 AlphaGo 而闻名)提出了深度 Q 学习算法,用于构建一个能够学习玩 Atari 2600 游戏,并在多个游戏中超越人类专家的 AI 玩家。这项工作对 AI 研究产生了重大影响,展示了构建通用 AI 系统的可能性。
在本章中,我们将介绍如何使用 gym 来玩 Atari 2600 游戏,然后解释为什么深度 Q 学习算法有效,并且如何使用 TensorFlow 实现它。目标是能够理解深度强化学习算法以及如何应用它们来解决实际任务。本章将为理解后续章节奠定坚实的基础,后续章节将介绍更复杂的方法。
本章将覆盖的主题如下:
- 
Atari 游戏介绍
 - 
深度 Q 学习
 - 
DQN 实现
 
Atari 游戏介绍
Atari, Inc. 是一家美国的视频游戏开发公司和家用计算机公司,由 Nolan Bushnell 和 Ted Dabney 于 1972 年创立。1976 年,Bushnell 开发了 Atari 视频计算机系统(或 Atari VCS,后来更名为 Atari 2600)。Atari VCS 是一款灵活的游戏主机,能够播放现有的 Atari 游戏,包括主机、两个摇杆、一对控制器和一张战斗游戏卡带。以下截图展示了 Atari 主机:

Atari 2600 拥有超过 500 款游戏,由 Atari、Sears 和一些第三方公司发布。一些著名的游戏包括《打砖块》(Breakout)、《吃豆人》(Pac-Man)、《陷阱》(Pitfall!)、《亚特兰蒂斯》(Atlantis)、《海底探险》(Seaquest)和《太空侵略者》(Space Invaders)。
由于 1983 年北美视频游戏崩溃的直接结果,Atari, Inc. 于 1984 年关闭,并将其资产拆分。Atari 的家用计算机和游戏主机部门于 1984 年 7 月被 Jack Tramiel 以 Atari Corporation 名义收购。
对于那些有兴趣玩 Atari 游戏的读者,这里有几个在线 Atari 2600 模拟器网站,您可以在这些网站上找到许多流行的 Atari 2600 游戏:
因为我们的目标是为这些游戏开发一个 AI 玩家,所以最好先玩这些游戏并了解它们的难点。最重要的是:放松并享受乐趣!
构建 Atari 模拟器
OpenAI gym 提供了一个具有 Python 接口的 Atari 2600 游戏环境。这些游戏由街机学习环境模拟,街机学习环境使用 Stella Atari 模拟器。有关更多细节,请阅读以下论文:
- 
MG Bellemare, Y Naddaf, J Veness 和 M Bowling,街机学习环境:通用代理的评估平台,《人工智能研究杂志》(2012)
 - 
Stella:一个多平台的 Atari 2600 VCS 模拟器,
stella.sourceforge.net/ 
快速入门
如果你没有完整安装 OpenAI gym,可以通过以下方式安装 Atari 环境的依赖项:
pip install gym[atari]
这需要 cmake 工具。此命令将自动编译街机学习环境及其 Python 接口 atari-py。编译将在普通笔记本上花费几分钟时间,因此可以去喝杯咖啡。
安装完 Atari 环境后,可以尝试以下操作:
import gym
atari = gym.make('Breakout-v0')
atari.reset()
atari.render()
如果运行成功,将会弹出一个小窗口,显示 Breakout 游戏的屏幕,如下所示的截图所示:

Breakout 的 rom 名称中的 v0 后缀的含义将在稍后解释。我们将使用 Breakout 来测试我们的 AI 游戏玩家训练算法。在 Breakout 中,几层砖块位于屏幕的顶部。一颗球在屏幕上移动,撞击屏幕的顶部和侧壁。当球击中砖块时,砖块会被销毁,球会反弹,并根据砖块的颜色为玩家提供一定的分数。当球触及屏幕底部时,玩家失去一次回合。为了避免这种情况,玩家需要移动挡板将球弹回。
Atari VCS 使用摇杆作为控制 Atari 2600 游戏的输入设备。摇杆和挡板能够提供的总输入数为 18。 在 gym Atari 环境中,这些动作被标记为从 0 到 17 的整数。每个动作的含义如下:
| 0 | 1 | 2 | 3 | 4 | 5 | 
|---|---|---|---|---|---|
| 无操作 | 开火 | 上 | 右 | 左 | 下 | 
| 6 | 7 | 8 | 9 | 10 | 11 | 
| 上+右 | 上+左 | 下+右 | 下+左 | 上+开火 | 右+开火 | 
| 12 | 13 | 14 | 15 | 16 | 17 | 
| 左+开火 | 下+开火 | 上+右+开火 | 上+左+开火 | 下+右+开火 | 下+左+开火 | 
可以使用以下代码获取游戏中有效动作的含义:
actions = atari.env.get_action_meanings()
对于 Breakout,动作包括以下内容:
[0, 1, 3, 4] or ['NOOP', 'FIRE', 'RIGHT', 'LEFT']
要获取动作的数量,也可以使用以下代码:
num_actions = atari.env.action_space.n
在这里,atari.env中的成员变量action_space存储了有关游戏有效动作的所有信息。通常,我们只需要知道有效动作的总数。
我们现在知道如何访问 Atari 环境中的动作信息。但是,给定这些动作,如何控制游戏呢?要执行一个动作,可以调用 step 函数:
observation, reward, done, info = atari.step(a)
输入参数a是你想要执行的动作,它是有效动作列表中的索引。例如,如果想执行LEFT动作,输入应该是3而不是4,或者如果不执行任何动作,输入应该是0。step函数返回以下四个值之一:
- 
Observation:一个环境特定的对象,表示你对环境的观察。对于 Atari 来说,它是执行动作后屏幕帧的图像。 - 
Reward:由动作获得的奖励数量。 - 
Done:是否到了重新初始化环境的时间。在 Atari 游戏中,如果你失去了最后一条生命,done会为真,否则为假。 - 
Info:有助于调试的诊断信息。不能在学习算法中使用这些信息,所以通常我们可以忽略它。 
Atari 模拟器的实现
我们现在准备使用 gym 构建一个简单的 Atari 模拟器。和其他电脑游戏一样,用于控制 Atari 游戏的键盘输入如下所示:
| w | a | s | d | space | 
|---|---|---|---|---|
| 上 | 左 | 下 | 右 | 发射 | 
为了检测键盘输入,我们使用pynput.keyboard包,它允许我们控制和监控键盘(pythonhosted.org/pynput/)。如果没有安装pynput包,请运行以下命令:
pip install pynput
pynput.keyboard提供了一个键盘监听器,用于捕获键盘事件。在创建键盘监听器之前,应该导入Listener类:
import gym
import queue, threading, time
from pynput.keyboard import Key, Listener
除了Listener类,程序中还需要其他包,如gym和threading。
以下代码展示了如何使用Listener来捕获键盘输入,即按下了W、A、S、D或space键时的情况:
def keyboard(queue):
    def on_press(key):
        if key == Key.esc:
            queue.put(-1)
        elif key == Key.space:
            queue.put(ord(' '))
        else:
            key = str(key).replace("'", '')
            if key in ['w', 'a', 's', 'd']:
                queue.put(ord(key))
    def on_release(key):
        if key == Key.esc:
            return False
    with Listener(on_press=on_press, on_release=on_release) as listener:
        listener.join()
实际上,键盘监听器是一个 Python 的threading.Thread对象,所有的回调都会从该线程中调用。在keyboard函数中,监听器注册了两个回调:on_press,当按下一个键时被调用,以及on_release,当一个键被释放时调用。该函数使用一个同步队列在不同线程之间共享数据。当W、A、S、D或space被按下时,其 ASCII 值会被发送到队列中,可以从另一个线程中访问。如果按下了esc键,一个终止信号*-*会被发送到队列中。然后,监听器线程会在esc键被释放时停止。
启动键盘监听器在 macOS X 上有一些限制;即以下条件之一应当为真:
- 
进程必须以 root 权限运行
 - 
应用程序必须在辅助设备访问权限中列入白名单
 
详情请访问pythonhosted.org/pynput/keyboard.html。
使用 gym 的 Atari 模拟器
模拟器的另一部分是gym Atari 模拟器:
def start_game(queue):
    atari = gym.make('Breakout-v0')
    key_to_act = atari.env.get_keys_to_action()
    key_to_act = {k[0]: a for k, a in key_to_act.items() if len(k) > 0}
    observation = atari.reset()
    import numpy
    from PIL import Image
    img = numpy.dot(observation, [0.2126, 0.7152, 0.0722])
    img = cv2_resize_image(img)
    img = Image.fromarray(img)
    img.save('save/{}.jpg'.format(0))
    while True:
        atari.render()
        action = 0 if queue.empty() else queue.get(block=False)
        if action == -1:
            break
        action = key_to_act.get(action, 0)
        observation, reward, done, _ = atari.step(action)
        if action != 0:
            print("Action {}, reward {}".format(action, reward))
        if done:
            print("Game finished")
            break
        time.sleep(0.05)
第一步是使用 gym.make 创建一个 Atari 环境。如果你有兴趣玩其他游戏,比如 Seaquest 或 Pitfall,只需将 Breakout-v0 改为 Seaquest-v0 或 Pitfall-v0。然后,调用 get_keys_to_action 获取 key to action 映射,该映射将 w、a、s、d 和 space 的 ASCII 值映射到内部动作。在 Atari 模拟器启动之前,必须调用 reset 函数来重置游戏参数和内存,并返回第一帧游戏画面。在主循环中,render 会在每一步渲染 Atari 游戏。输入动作从队列中拉取,且不会阻塞。如果动作是终止信号 -1,游戏将退出。否则,执行当前步骤的动作,通过运行 atari.step。
要启动模拟器,请运行以下代码:
if __name__ == "__main__":
    queue = queue.Queue(maxsize=10)
    game = threading.Thread(target=start_game, args=(queue,))
    game.start()
    keyboard(queue)
按下射击按钮开始游戏并享受它!这个模拟器提供了一个用于在 gym Atari 环境中测试 AI 算法的基本框架。稍后,我们将用我们的 AI 玩家替换 keyboard 功能。
数据准备
仔细的读者可能会注意到每个游戏名称后都有一个后缀 v0,并产生以下问题:v0 的意思是什么? 是否可以将其替换为 v1 或 v2? 实际上,这个后缀与从 Atari 环境提取的屏幕图像(观察)进行数据预处理的步骤有关。
每个游戏有三种模式,例如 Breakout、BreakoutDeterministic 和 BreakoutNoFrameskip,每种模式有两个版本,例如 Breakout-v0 和 Breakout-v4。三种模式的主要区别在于 Atari 环境中 frameskip 参数的值。这个参数表示一个动作重复的帧数(步骤数)。这就是所谓的 帧跳过 技术,它让我们能够在不显著增加运行时间的情况下玩更多游戏。
对于 Breakout,frameskip 是从 2 到 5 随机抽样的。以下截图显示了当提交 LEFT 动作时,step 函数返回的帧画面:

对于 BreakoutDeterministic,Space Invaders 游戏的 frameskip 被设置为 3,其他游戏的 frameskip 为 4。在相同的 LEFT 动作下,step 函数返回如下:

对于 BreakoutNoFrameskip,所有游戏的 frameskip 始终为 1,意味着没有帧跳过。类似地,LEFT 动作在每一步都会执行:

这些截图展示了尽管步进函数在相同的动作LEFT下被调用了四次,最终的球板位置却大不相同。由于 BreakoutDeterministic 的帧跳跃为 4,所以它的球板离左墙最近。而 BreakoutNoFrameskip 的帧跳跃为 1,因此它的球板离左墙最远。对于 Breakout,球板处于中间位置,因为在每一步中,帧跳跃是从[2, 5]中采样的。
从这个简单的实验中,我们可以看到帧跳跃参数的效果。它的值通常设置为 4,以便进行快速学习。回想一下,每个模式都有两个版本,v0 和 v4。它们的主要区别在于repeat_action_probability参数。这个参数表示尽管提交了另一个动作,仍然有概率采取无操作(NOOP)动作。对于 v0,它的值设置为 0.25,v4 的值为 0.0。由于我们希望得到一个确定性的 Atari 环境,本章选择了 v4 版本。
如果你玩过一些 Atari 游戏,你可能注意到游戏画面的顶部区域通常包含记分板,显示你当前的得分和剩余生命数。这些信息与游戏玩法无关,因此顶部区域可以被裁剪掉。另外,通过步进函数返回的帧图像是 RGB 图像。实际上,在 Atari 环境中,彩色图像并不提供比灰度图像更多的信息;换句话说,使用灰度屏幕也可以照常玩 Atari 游戏。因此,有必要通过裁剪帧图像并将其转换为灰度图像来保留有用的信息。
将 RGB 图像转换为灰度图像非常简单。灰度图像中每个像素的值表示光强度,可以通过以下公式计算:

这里,R、G 和 B 分别是 RGB 图像的红色、绿色和蓝色通道。给定一个形状为(height, width, channel)的 RGB 图像,可以使用以下 Python 代码将其转换为灰度图像:
def rgb_to_gray(self, im):
    return numpy.dot(im, [0.2126, 0.7152, 0.0722])
以下图片给出了一个示例:

对于裁剪帧图像,我们使用opencv-python包,或者称为cv2,它是一个 Python 包装器,封装了原始的 C++ OpenCV 实现。欲了解更多信息,请访问opencv-python的官方网站:opencv-python-tutroals.readthedocs.io/en/latest/index.html。opencv-python包提供了基本的图像转换操作,如图像缩放、平移和旋转。在本章中,我们只需要使用图像缩放函数 resize,该函数接受输入图像、图像大小和插值方法作为输入参数,并返回缩放后的图像。
以下代码展示了图像裁剪操作,涉及两个步骤:
- 
重塑输入图像,使得最终图像的宽度等于通过
resized_shape参数指定的调整后的宽度84。 - 
使用
numpy切片裁剪重塑图像的顶部区域: 
def cv2_resize_image(image, resized_shape=(84, 84), 
                     method='crop', crop_offset=8):
    height, width = image.shape
    resized_height, resized_width = resized_shape
    if method == 'crop':
        h = int(round(float(height) * resized_width / width))
        resized = cv2.resize(image, 
                             (resized_width, h), 
                             interpolation=cv2.INTER_LINEAR)
        crop_y_cutoff = h - crop_offset - resized_height
        cropped = resized[crop_y_cutoff:crop_y_cutoff+resized_height, :]
        return numpy.asarray(cropped, dtype=numpy.uint8)
    elif method == 'scale':
        return numpy.asarray(cv2.resize(image, 
                                        (resized_width, resized_height), 
                                        interpolation=cv2.INTER_LINEAR), 
                                        dtype=numpy.uint8)
    else:
        raise ValueError('Unrecognized image resize method.')
例如,给定一张灰度输入图像,cv2_resize_image函数会返回一张裁剪后的图像,大小为 
,如下图所示:

到目前为止,我们已经完成了数据准备。数据现在已经可以用来训练我们的 AI 玩家了。
深度 Q 学习
现在是有趣的部分——我们 AI Atari 玩家的大脑设计。核心算法基于深度强化学习(Deep RL)。为了更好地理解它,需要一些基本的数学公式。深度强化学习是深度学习和传统强化学习的完美结合。如果不理解强化学习的基本概念,很难在实际应用中正确使用深度强化学习。例如,可能会有人在没有正确定义状态空间、奖励和转移的情况下尝试使用深度强化学习。
好了,别害怕这些公式的难度。我们只需要高中水平的数学知识,不会深入探讨为什么传统强化学习算法有效的数学证明。本章的目标是学习基本的 Q 学习算法,了解如何将其扩展为深度 Q 学习算法(DQN),并理解这些算法背后的直觉。此外,你还将学习 DQN 的优缺点,什么是探索与开发,为什么需要重放记忆,为什么需要目标网络,以及如何设计一个卷积神经网络来表示状态特征。
看起来很有趣,对吧?我们希望本章不仅帮助你理解如何应用深度强化学习来解决实际问题,也为深度强化学习研究打开了一扇门。对于已经熟悉卷积神经网络、马尔可夫决策过程和 Q 学习的读者,可以跳过第一部分,直接进入 DQN 的实现。
强化学习的基本元素
首先,让我们回顾一下在第一章中讨论的一些强化学习的基本元素:
- 
状态:状态空间定义了环境的所有可能状态。在 Atari 游戏中,状态是玩家在某一时刻观察到的屏幕图像或几张连续的屏幕图像,表示当时的游戏状态。
 - 
奖励函数:奖励函数定义了强化学习问题的目标。它将环境的状态或状态-动作对映射到一个实数,表示该状态的可取性。在 Atari 游戏中,奖励是玩家在采取某个动作后获得的分数。
 - 
策略函数:策略函数定义了玩家在特定时间的行为,它将环境的状态映射到在这些状态下应该采取的动作。
 - 
价值函数:价值函数表示在长期内哪个状态或状态-动作对是好的。一个状态的价值是玩家从该状态开始,未来可以预期积累的奖励的总和(或折扣后的总和)。
 
演示基本的 Q 学习算法
为了演示基本的 Q 学习算法,我们来看一个简单的问题。假设我们的智能体(玩家)生活在一个网格世界中。一天,她被困在一个奇怪的迷宫中,如下图所示:

迷宫包含六个房间。我们的智能体出现在房间 1,但她对迷宫一无所知,也就是说,她不知道房间 6 有能够将她送回家的“心爱之物”,或者房间 4 有一个会击打她的闪电。因此,她必须小心地探索迷宫,尽快逃脱。那么,我们如何让我们可爱的智能体通过经验学习呢?
幸运的是,她的好朋友 Q 学习可以帮助她生存下来。这个问题可以表示为一个状态图,其中每个房间作为一个状态,智能体从一个房间到另一个房间的移动视为一个动作。状态图如下所示:

在这里,动作用箭头表示,箭头上标记的数字是该状态-动作对的奖励。例如,当我们的智能体从房间 5 移动到房间 6 时,由于达到了目标,她会获得 100 分。当她从房间 3 移动到房间 4 时,她会得到一个负奖励,因为闪电击中了她。这个状态图也可以用矩阵表示:
| 状态\动作 | 1 | 2 | 3 | 4 | 5 | 6 | 
|---|---|---|---|---|---|---|
| 1 | - | 0 | - | - | - | - | 
| 2 | 0 | - | 0 | - | 0 | - | 
| 3 | - | 0 | - | -50 | - | - | 
| 4 | - | - | 0 | - | - | - | 
| 5 | - | 0 | - | - | - | 100 | 
| 6 | - | - | - | - | - | - | 
矩阵中的虚线表示在该状态下该动作不可用。例如,我们的智能体不能直接从房间 1 移动到房间 6,因为两者之间没有连接的门。
让 
 是一个状态,
 是一个动作,
 是奖励函数,
 是价值函数。回忆一下,
 是状态-动作对 
 在长期内的期望回报,这意味着我们的智能体能够根据 
 来决定进入哪个房间。Q 学习算法非常简单,它通过以下更新规则来估计每个状态-动作对的 
:

在这里,
 是当前状态,
 是采取行动后进入的下一个状态,
 是在 
 时的动作,
 是在 
 时可用的动作集, 是折扣因子,
 是学习率。折扣因子 
 的值位于 [0,1] 范围内。折扣因子小于 1 意味着我们的智能体更偏好当前的奖励,而非过去的奖励。
一开始,我们的智能体对价值函数一无所知,因此 
 被初始化为所有状态-动作对的 0。她将从一个状态探索到另一个状态,直到达到目标。我们将每一次探索称为一个回合,它由从初始状态(例如,房间 1)到最终状态(例如,房间 6)组成。Q 学习算法如下所示:
Initialize  to zero and set parameters ,;
Repeat for each episode:
   Randomly select an initial state ;
   While the goal state hasn't been reached:
       Select action  among all the possible actions in state  (e.g., using greedy);
       Take action  and observe reward , next state ;
       Update ;
       Set the current state ;
   End while
小心的读者可能会问一个问题:如何选择在状态
下的行动 
,例如,行动 
是从所有可能的行动中随机选择,还是根据当前估算的价值函数 
派生的策略来选择?什么是
贪心策略?这些问题涉及到两个重要的概念,即探索和利用。探索意味着尝试新事物以收集更多的环境信息,而利用则是根据你已有的信息做出最佳决策。例如,尝试一家新餐厅是探索,而去你最喜欢的餐厅则是利用。在我们的迷宫问题中,探索是我们的代理尝试进入一个她之前没有去过的新房间,而利用则是她根据从环境中收集到的信息选择她最喜欢的房间。
探索和利用在强化学习中都是必要的。如果没有探索,我们的代理就无法获得关于环境的新知识,因此她将一遍又一遍地做出错误决策。如果没有利用,她从探索中获得的信息就会变得毫无意义,因为她无法从中学习以做出更好的决策。因此,探索和利用之间的平衡或权衡是必不可少的。
贪心策略是实现这种权衡的最简单方式:
| 以概率 | 从所有可能的行动中随机选择一个行动 | 
|---|---|
以概率![]()  | 
基于 选择最佳行动,即选择使得  在状态  下所有可能的行动中最大 | 
为了进一步理解 Q 学习的工作原理,我们通过几个步骤来手动演示。为清晰起见,我们设置学习率 
和折扣因子 
。以下代码展示了 Q 学习在 Python 中的实现:
import random, numpy
def Q_learning_demo():
    alpha = 1.0
    gamma = 0.8
    epsilon = 0.2
    num_episodes = 100
    R = numpy.array([
        [-1, 0, -1, -1, -1, -1],
        [ 0, -1, 0, -1, 0, -1],
        [-1, 0, -1, -50, -1, -1],
        [-1, -1, 0, -1, -1, -1],
        [-1, 0, -1, -1, -1, 100],
        [-1, -1, -1, -1, -1, -1]
        ])
    # Initialize Q
    Q = numpy.zeros((6, 6))
    # Run for each episode
    for _ in range(num_episodes):
        # Randomly choose an initial state
        s = numpy.random.choice(5)
        while s != 5:
            # Get all the possible actions
            actions = [a for a in range(6) if R[s][a] != -1]
            # Epsilon-greedy
            if numpy.random.binomial(1, epsilon) == 1:
                a = random.choice(actions)
            else:
                a = actions[numpy.argmax(Q[s][actions])]
            next_state = a
            # Update Q(s,a)
            Q[s][a] += alpha * (R[s][a] + gamma * numpy.max(Q[next_state]) - Q[s][a])
            # Go to the next state
            s = next_state
    return Q
经过 100 轮训练后,价值函数 
收敛到以下结果(对于那些对为什么该算法会收敛的读者,请参考《强化学习:导论》,作者为 Andrew Barto 和 Richard S. Sutton):
| 状态\行动 | 1 | 2 | 3 | 4 | 5 | 6 | 
|---|---|---|---|---|---|---|
| 1 | - | 64 | - | - | - | - | 
| 2 | 51.2 | - | 51.2 | - | 80 | - | 
| 3 | - | 64 | - | -9.04 | - | - | 
| 4 | - | - | 51.2 | - | - | - | 
| 5 | - | 64 | - | - | - | 100 | 
| 6 | - | - | - | - | - | - | 
因此,得到的状态图变为:

这表明从其他所有状态到目标状态的最佳路径如下:

基于这些知识,我们的智能体能够返回家中,无论她处于哪个房间。更重要的是,她变得更加聪明和快乐,实现了我们训练智能 AI 代理或玩家的目标。
这个最简单的 Q 学习算法只能处理离散的状态和动作。对于连续状态,它无法处理,因为由于存在无限的状态,收敛性不能得到保证。我们如何在像 Atari 游戏这样的无限状态空间中应用 Q 学习?答案是用神经网络代替表格来近似动作-价值函数!。这就是谷歌 DeepMind 论文《Playing Atari with deep reinforcement learning》背后的直觉。
为了将基本的 Q 学习算法扩展到深度 Q 学习算法,需要回答两个关键问题:
- 
可以使用什么样的神经网络来从 Atari 环境中的观察数据(如屏幕图像)中提取高级特征?
 - 
如何在每个训练步骤中更新动作-价值函数,
? 
对于第一个问题,有几种方法可以近似动作-价值函数,
。一种方法是将状态和动作都作为神经网络的输入,网络输出它们的 Q 值标量估计,如下图所示:

这种方法的主要缺点是需要额外的前向传播来计算!,因为动作被作为输入之一传递到网络中,这会导致计算成本与所有可能动作的数量成线性关系。另一种方法是只将状态作为神经网络的输入,而每个可能的动作都有一个独立的输出:

这种方法的主要优点是能够通过网络的单次前向传播计算出给定状态下所有可能动作的 Q 值,而且通过选择相应的输出头可以轻松获取某个动作的 Q 值。
在深度 Q 网络中,应用了第二种架构。回顾一下,数据预处理步骤中的输出是一个 
 灰度帧图像。然而,当前的屏幕图像不足以进行 Atari 游戏,因为它不包含游戏状态的动态信息。以 Breakout 为例;如果我们只看到一帧,我们只能知道球和球拍的位置,但无法得知球的方向或速度。实际上,方向和速度对于决定如何移动球拍至关重要。如果没有它们,游戏就无法进行。因此,输入网络的不仅是单独的一帧图像,而是历史中的最后四帧图像被堆叠在一起,形成网络的输入。这四帧组成一个 
 图像。除了输入层,Q 网络包含三层卷积层和一层全连接层,如下所示:

第一层卷积层有 64 个 
 卷积核,步长为 4,之后接一个整流线性单元(RELU)。第二层卷积层有 64 个 
 卷积核,步长为 2,之后接 RELU。第三层卷积层有 64 个 
 卷积核,步长为 2,之后接 RELU。全连接的隐藏层有 512 个隐藏单元,再次接 RELU。输出层也是一个全连接层,每个动作对应一个输出。
熟悉卷积神经网络的读者可能会问,为什么第一层卷积层使用了一个 
 卷积核,而不是广泛应用于计算机视觉中的 
 卷积核或 
 卷积核。使用大卷积核的主要原因是,Atari 游戏通常包含非常小的物体,如球、子弹或弹丸。使用较大的卷积核的卷积层能够放大这些小物体,有助于学习状态的特征表示。对于第二层和第三层卷积层,较小的卷积核足以捕捉到有用的特征。
到目前为止,我们已经讨论了 Q 网络的架构。那么,我们如何在具有无限状态空间的 Atari 环境中训练这个 Q 网络呢?是否可以基于基本的 Q 学习算法来训练它?幸运的是,答案是肯定的。回顾一下,基本 Q 学习中 
 的更新规则如下:

当学习率为 
 时,该更新规则变为如下形式:

这就是所谓的贝尔曼方程。实际上,贝尔曼方程是许多强化学习算法的核心。使用贝尔曼方程作为迭代更新的算法称为值迭代算法。在本书中,我们不会详细讨论值迭代或策略迭代。如果你对它们感兴趣,可以参考 Andrew Barto 和 Richard S. Sutton 的《强化学习:导论》。
刚才显示的方程仅适用于确定性环境,其中给定当前状态 
和动作 
,下一个状态 
是固定的。在非确定性环境中,贝尔曼方程应该如下:

这里,右侧是关于下一个状态 
的期望值(例如,
的分布由 Atari 模拟器确定)。对于无限状态空间,通常使用函数逼近器(如 Q 网络)来估计动作价值函数 
。然后,Q 网络可以通过最小化以下损失函数,在第i次迭代中进行训练,而不是迭代更新 
:

这里,Q(s,a;)表示由 
参数化的 Q 网络,
是第i次迭代的目标,
是序列和动作的概率分布。在优化损失函数 
时,来自前一次迭代i-1的参数是固定的,损失函数是关于 
的。在实际应用中,无法精确计算 
中的期望值。因此,我们不会直接优化 
,而是最小化 
的经验损失,它通过从概率分布 
和 Atari 模拟器中获得的样本 
来替代期望值。与其他深度学习算法一样,经验损失函数可以通过随机梯度下降法进行优化。
这个算法不需要构建仿真器的估计,例如,它不需要知道 Atari 仿真器的内部游戏机制,因为它仅使用来自仿真器的样本来解决强化学习问题。这个特性称为无模型,即它可以将底层模型视为黑盒。这个算法的另一个特性是离策略。它学习贪婪策略
,同时遵循平衡探索与开发的状态空间概率分布
。如前所述,
可以作为一种
贪婪策略进行选择。
深度 Q 学习算法的推导对于不熟悉强化学习或马尔科夫决策过程的读者来说可能有点困难。为了使其更容易理解,让我们来看一下下面的图示:

我们 AI 玩家的大脑是 Q 网络控制器。在每个时间步 t,她观察屏幕图像
(回想一下,st 是一个
堆叠了最后四帧的图像)。然后,她的大脑分析这个观察结果,并做出一个动作,
。Atari 仿真器接收到这个动作,并返回下一个屏幕图像
,以及奖励
。四元组
被存储在内存中,并作为样本用于通过随机梯度下降最小化经验损失函数来训练 Q 网络。
我们如何从存储在内存中的四元组中抽取样本?一种方法是,这些样本,
,是通过我们的 AI 玩家与环境的互动得出的。例如,样本
用于训练 Q 网络。这个方法的主要缺点是,一批中的样本具有强烈的相关性。强相关性破坏了构建经验损失函数时样本独立性的假设,导致训练过程不稳定,表现不佳:

深度 Q 学习算法应用了另一种方法,利用了一种叫做经验回放的技术。AI 玩家在每个时间步骤 
 的经验被存储在回放记忆中,从中随机抽取一批样本以训练 Q 网络。从数学上讲,我们无法保证抽取样本之间的独立性。但在实际操作中,这种方法能够稳定训练过程并产生合理的结果:

到目前为止,我们已经讨论了深度 Q 学习算法中的所有组件。完整的算法如下所示:
Initialize replay memory  to capacity ;
Initialize the Q-network  with random weights ;
Repeat for each episode:
    Set time step ;
    Receive an initial screen image  and do preprocessing ;
    While the terminal state hasn't been reached:
        Select an action at via greedy, i.e., select a random action with probability , otherwise select ;
        Execute action at in the emulator and observe reward  and image ;
        Set  and store transition  into replay memory ;
        Randomly sample a batch of transitions  from ;
        Set  if  is a terminal state or  if  is a non-terminal state;
        Perform a gradient descent step on ;
    End while
该算法在一些 Atari 游戏中表现良好,例如《打砖块》、《海底探险》、《乒乓》和《Qbert》,但仍然无法达到人类水平的控制。一个缺点是计算目标 
 时使用了当前的动作值函数估计 
,这使得训练步骤变得不稳定,即一个增加 
 的更新通常也会增加所有的 
,因此也增加了目标 
,这可能导致策略的振荡或发散。
为了解决这个问题,谷歌 DeepMind 在他们的论文《通过深度强化学习实现人类水平的控制》中引入了目标网络,该论文发表于《自然》杂志。目标网络背后的理念相当简单:使用一个独立的网络来生成 Q 学习更新中的目标 
。更准确地说,对于每个 
 Q 学习更新,网络 Q 被克隆以获得目标网络 Q,并用于生成接下来的 Q 更新中的目标 
。因此,深度 Q 学习算法变为如下:
Initialize replay memory  to capacity ;
Initialize the Q-network  with random weights ;
Initialize the target network  with weights ;
Repeat for each episode:
Set time step ;
    Receive an initial screen image  and do preprocessing ;
    While the terminal state hasn't been reached:
        Select an action at via greedy, i.e., select a random action with probability , otherwise select ;
        Execute action at in the emulator and observe reward  and image ;
        Set  and store transition  into replay memory ;
        Randomly sample a batch of transitions  from ;
        Set  if  is a terminal state or  if  is a non-terminal state;
        Perform a gradient descent step on ;
        Set  for every  steps;
 End while
使用目标网络,通过深度 Q 学习算法训练的 AI 玩家能够超越大多数先前强化学习算法的表现,并在 49 款 Atari 2600 游戏中实现了人类水平的表现,例如《星际枪手》、《亚特兰蒂斯》、《攻击》和《太空侵略者》。
深度 Q 学习算法在通用人工智能方面迈出了重要一步。尽管它在 Atari 2600 游戏中表现良好,但仍然存在许多未解决的问题:
- 
收敛速度慢:它需要很长时间(在一块 GPU 上需要 7 天)才能达到人类水平的表现。
 - 
稀疏奖励失败:它在《蒙特祖玛的复仇》游戏中无法发挥作用,因为该游戏需要长期规划。
 - 
需要大量数据:这是大多数强化学习算法常见的问题。
 
为了解决这些问题,最近提出了深度 Q 学习算法的不同变种,例如双重 Q 学习、优先经验回放、引导式 DQN 和对抗网络架构。我们在本书中不讨论这些算法。对于想要深入了解 DQN 的读者,请参考相关论文。
DQN 的实现
本章将展示如何使用 Python 和 TensorFlow 实现深度 Q 学习算法的所有组件,例如 Q 网络、回放记忆、训练器和 Q 学习优化器。
我们将实现QNetwork类,这是我们在上一章中讨论的 Q 网络,其定义如下:
class QNetwork:
    def __init__(self, input_shape=(84, 84, 4), n_outputs=4, 
                 network_type='cnn', scope='q_network'):
        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.scope = scope
        # Frame images
        self.x = tf.placeholder(dtype=tf.float32, 
                                shape=(None, self.channel, 
                                       self.width, self.height))
        # Estimates of Q-value
        self.y = tf.placeholder(dtype=tf.float32, shape=(None,))
        # Selected actions
        self.a = tf.placeholder(dtype=tf.int32, shape=(None,))
        with tf.variable_scope(scope):
            self.build()
            self.build_loss()
构造函数需要四个参数,input_shape、n_outputs、network_type和scope。input_shape是输入图像的大小。经过数据预处理后,输入是一个
图像,因此默认参数是
。n_outputs是所有可能动作的数量,例如在 Breakout 游戏中,n_outputs为四。network_type表示我们要使用的 Q 网络类型。我们的实现包含三种不同的网络,其中两种是由 Google DeepMind 提出的卷积神经网络,另一个是用于测试的前馈神经网络。scope是 Q 网络对象的名称,可以设置为q_network或target_network。
在构造函数中,创建了三个输入张量。x变量表示输入状态(一批
图像)。y和a变量分别表示动作价值函数的估计值和与输入状态对应的选定动作,用于训练 Q 网络。创建输入张量后,调用build和build_loss两个函数来构建 Q 网络。
使用 TensorFlow 构建 Q 网络非常简单,如下所示:
    def build(self):
        self.net = {}
        self.net['input'] = tf.transpose(self.x, perm=(0, 2, 3, 1))
        init_b = tf.constant_initializer(0.01)
        if self.network_type == 'cnn':
            self.net['conv1'] = conv2d(self.net['input'], 32, 
                                       kernel=(8, 8), stride=(4, 4), 
                                       init_b=init_b, name='conv1')
            self.net['conv2'] = conv2d(self.net['input'], 64, 
                                       kernel=(4, 4), stride=(2, 2), 
                                       init_b=init_b, name='conv2')
            self.net['conv3'] = conv2d(self.net['input'], 64, 
                                       kernel=(3, 3), stride=(1, 1), 
                                       init_b=init_b, name='conv3')
            self.net['feature'] = dense(self.net['conv2'], 512, 
                                        init_b=init_b, name='fc1')
        elif self.network_type == 'cnn_nips':
            self.net['conv1'] = conv2d(self.net['input'], 16, 
                                       kernel=(8, 8), stride=(4, 4), 
                                       init_b=init_b, name='conv1')
            self.net['conv2'] = conv2d(self.net['conv1'], 32, 
                                       kernel=(4, 4), stride=(2, 2), 
                                       init_b=init_b, name='conv2')
            self.net['feature'] = dense(self.net['conv2'], 256, 
                                        init_b=init_b, name='fc1')
        elif self.network_type == 'mlp':
            self.net['fc1'] = dense(self.net['input'], 50, 
                                    init_b=init_b), name='fc1')
            self.net['feature'] = dense(self.net['fc1'], 50, 
                                        init_b=init_b, name='fc2')
        else:
            raise NotImplementedError('Unknown network type')
        self.net['values'] = dense(self.net['feature'], 
                                   self.n_outputs, activation=None,
                                   init_b=init_b, name='values')
        self.net['q_value'] = tf.reduce_max(self.net['values'], 
                                            axis=1, name='q_value')
        self.net['q_action'] = tf.argmax(self.net['values'], 
                                         axis=1, name='q_action', 
                                         output_type=tf.int32)
        self.vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, 
                                      tf.get_variable_scope().name)
如前一章所讨论的,Atari 环境的 Q 网络包含三个卷积层和一个隐藏层,当network_type为cnn时可以构建该网络。cnn_nips类型是为 Atari 游戏简化的 Q 网络,只包含两个卷积层和一个隐藏层,且具有较少的滤波器和隐藏单元。mlp类型是一个具有两个隐藏层的前馈神经网络,用于调试。vars变量是 Q 网络中所有可训练变量的列表。
回顾一下,损失函数为
,可以按如下方式实现:
    def build_loss(self):
        indices = tf.transpose(tf.stack([tf.range(tf.shape(self.a)[0]), 
                                         self.a], axis=0))
        value = tf.gather_nd(self.net['values'], indices)
        self.loss = 0.5 * tf.reduce_mean(tf.square((value - self.y)))
        self.gradient = tf.gradients(self.loss, self.vars)
        tf.summary.scalar("loss", self.loss, collections=['q_network'])
        self.summary_op = tf.summary.merge_all('q_network')
tf.gather_nd函数用于获取给定动作批次的动作值
。变量 loss 表示损失函数,gradient 是损失函数相对于可训练变量的梯度。summary_op用于 TensorBoard 可视化。
回放记忆的实现不涉及 TensorFlow:
class ReplayMemory:
    def __init__(self, history_len=4, capacity=1000000, 
                 batch_size=32, input_scale=255.0):
        self.capacity = capacity
        self.history_length = history_len
        self.batch_size = batch_size
        self.input_scale = input_scale
        self.frames = deque([])
        self.others = deque([])
ReplayMemory类接受四个输入参数,即history_len、capacity、batch_size和input_scale。history_len是堆叠在一起的帧数。通常,history_len在 Atari 游戏中设置为 4,形成一个
输入图像。capacity是回放记忆的容量,即可以存储的最大帧数。batch_size是训练时的一批样本大小。input_scale是输入图像的归一化因子,例如,对于 RGB 图像,它设置为 255。变量 frames 记录所有帧图像,变量 others 记录对应的动作、奖励和终止信号。
ReplayMemory提供了一个将记录(帧图像、动作、奖励、终止信号)添加到内存中的功能:
    def add(self, frame, action, r, termination):
        if len(self.frames) == self.capacity:
            self.frames.popleft()
            self.others.popleft()
        self.frames.append(frame)
        self.others.append((action, r, termination))
    def add_nullops(self, init_frame):
        for _ in range(self.history_length):
            self.add(init_frame, 0, 0, 0)
它还提供了一个功能,通过连接历史中的最后四帧图像来构建一个
输入图像:
    def phi(self, new_frame):
        assert len(self.frames) > self.history_length
        images = [new_frame] + [self.frames[-1-i] for i in range(self.history_length-1)]
        return numpy.concatenate(images, axis=0)
以下函数从回放记忆中随机抽取一个过渡(状态、动作、奖励、下一个状态、终止信号):
    def sample(self):
        while True:
            index = random.randint(a=self.history_length-1, 
                                   b=len(self.frames)-2)
            infos = [self.others[index-i] for i in range(self.history_length)]
            # Check if termination=1 before "index"
            flag = False
            for i in range(1, self.history_length):
                if infos[i][2] == 1:
                    flag = True
                    break
            if flag:
                continue
            state = self._phi(index)
            new_state = self._phi(index+1)
            action, r, termination = self.others[index]
            state = numpy.asarray(state / self.input_scale, 
                                  dtype=numpy.float32)
            new_state = numpy.asarray(new_state / self.input_scale, 
                                      dtype=numpy.float32)
            return (state, action, r, new_state, termination)
请注意,只有对应状态中最后一帧的终止信号可以为 True。_phi(index)函数将四帧图像堆叠在一起:
    def _phi(self, index):
        images = [self.frames[index-i] for i in range(self.history_length)]
        return numpy.concatenate(images, axis=0)
Optimizer类用于训练 Q 网络:
class Optimizer:
    def __init__(self, config, feedback_size, 
                 q_network, target_network, replay_memory):
        self.feedback_size = feedback_size
        self.q_network = q_network
        self.target_network = target_network
        self.replay_memory = replay_memory
        self.summary_writer = None
        self.gamma = config['gamma']
        self.num_frames = config['num_frames']
        optimizer = create_optimizer(config['optimizer'], 
                                     config['learning_rate'], 
                                     config['rho'], 
                                     config['rmsprop_epsilon'])
        self.train_op = optimizer.apply_gradients(
                 zip(self.q_network.gradient, 
                 self.q_network.vars))
它接受 Q 网络、目标网络、回放记忆和输入图像的大小作为输入参数。在构造函数中,它创建一个优化器(如 ADAM、RMSPROP 或 MOMENTUM 等流行优化器),然后构建一个用于训练的操作符。
要训练 Q 网络,需要构建一个与
、
和
对应的迷你批样本(状态、动作、目标)来计算损失函数
:
    def sample_transitions(self, sess, batch_size):
        w, h = self.feedback_size
        states = numpy.zeros((batch_size, self.num_frames, w, h), 
                             dtype=numpy.float32)
        new_states = numpy.zeros((batch_size, self.num_frames, w, h), 
                                 dtype=numpy.float32)
        targets = numpy.zeros(batch_size, dtype=numpy.float32)
        actions = numpy.zeros(batch_size, dtype=numpy.int32)
        terminations = numpy.zeros(batch_size, dtype=numpy.int32)
        for i in range(batch_size):
            state, action, r, new_state, t = self.replay_memory.sample()
            states[i] = state
            new_states[i] = new_state
            actions[i] = action
            targets[i] = r
            terminations[i] = t
        targets += self.gamma * (1 - terminations) * self.target_network.get_q_value(sess, new_states)
        return states, actions, targets
请注意,目标
是由目标网络计算的,而不是 Q 网络。给定一批状态、动作或目标,Q 网络可以通过以下方式轻松训练:
    def train_one_step(self, sess, step, batch_size):
        states, actions, targets = self.sample_transitions(sess, batch_size)
        feed_dict = self.q_network.get_feed_dict(states, actions, targets)
        if self.summary_writer and step % 1000 == 0:
            summary_str, _, = sess.run([self.q_network.summary_op, 
                                        self.train_op], 
                                       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)
除了训练过程外,每 1000 步会将摘要写入日志文件。这个摘要用于监控训练过程,帮助调整参数和调试。
将这些模块结合在一起,我们可以实现用于主要深度 Q 学习算法的类 DQN:
class DQN:
    def __init__(self, config, game, directory, 
                 callback=None, summary_writer=None):
        self.game = game
        self.actions = game.get_available_actions()
        self.feedback_size = game.get_feedback_size()
        self.callback = callback
        self.summary_writer = summary_writer
        self.config = config
        self.batch_size = config['batch_size']
        self.n_episode = config['num_episode']
        self.capacity = config['capacity']
        self.epsilon_decay = config['epsilon_decay']
        self.epsilon_min = config['epsilon_min']
        self.num_frames = config['num_frames']
        self.num_nullops = config['num_nullops']
        self.time_between_two_copies = config['time_between_two_copies']
        self.input_scale = config['input_scale']
        self.update_interval = config['update_interval']
        self.directory = directory
        self._init_modules()
在这里,config 包含 DQN 的所有参数,例如训练的批量大小和学习率。game 是 Atari 环境的一个实例。在构造函数中,回放记忆、Q 网络、目标网络和优化器被初始化。要开始训练过程,可以调用以下函数:
    def train(self, sess, saver=None):
        num_of_trials = -1
        for episode in range(self.n_episode):
            self.game.reset()
            frame = self.game.get_current_feedback()
            for _ in range(self.num_nullops):
                r, new_frame, termination = self.play(action=0)
                self.replay_memory.add(frame, 0, r, termination)
                frame = new_frame
            for _ in range(self.config['T']):
                num_of_trials += 1
                epsilon_greedy = self.epsilon_min + \
                    max(self.epsilon_decay - num_of_trials, 0) / \
                    self.epsilon_decay * (1 - self.epsilon_min)
                if num_of_trials % self.update_interval == 0:
                    self.optimizer.train_one_step(sess, 
                                                  num_of_trials, 
                                                  self.batch_size)
                state = self.replay_memory.phi(frame)
                action = self.choose_action(sess, state, epsilon_greedy) 
                r, new_frame, termination = self.play(action)
                self.replay_memory.add(frame, action, r, termination)
                frame = new_frame
                if num_of_trials % self.time_between_two_copies == 0:
                    self.update_target_network(sess)
                    self.save(sess, saver)
                if self.callback:
                    self.callback()
                if termination:
                    score = self.game.get_total_reward()
                    summary_str = sess.run(self.summary_op, 
                                           feed_dict={self.t_score: score})
                    self.summary_writer.add_summary(summary_str, 
                                                    num_of_trials)
                    self.summary_writer.flush()
                    break
这个函数很容易理解。在每一轮中,它调用 replay_memory.phi 来获取当前状态,并通过 choose_action 函数选择一个动作,该函数使用 
贪婪策略。这个动作通过调用 play 函数提交到 Atari 模拟器,后者返回相应的奖励、下一帧图像和终止信号。然后,过渡(当前帧图像、动作、奖励、终止)被存储到回放记忆中。对于每一个 update_interval 步骤(默认 update_interval = 1),Q 网络会用从回放记忆中随机采样的一批过渡数据进行训练。对于每 time_between_two_copies 步骤,目标网络会复制 Q 网络,并将 Q 网络的权重保存到硬盘。
在训练步骤之后,可以调用以下函数来评估 AI 玩家表现:
    def evaluate(self, sess):
        for episode in range(self.n_episode):
            self.game.reset()
            frame = self.game.get_current_feedback()
            for _ in range(self.num_nullops):
                r, new_frame, termination = self.play(action=0)
                self.replay_memory.add(frame, 0, r, termination)
                frame = new_frame
            for _ in range(self.config['T']):
                state = self.replay_memory.phi(frame)
                action = self.choose_action(sess, state, self.epsilon_min) 
                r, new_frame, termination = self.play(action)
                self.replay_memory.add(frame, action, r, termination)
                frame = new_frame
                if self.callback:
                    self.callback()
                    if termination:
                        break
现在,我们准备好训练我们的第一个 Atari 游戏 AI 玩家了。如果你理解算法背后的直觉,实现并不难,不是吗?现在是时候运行程序,见证魔力了!
实验
深度 Q 学习算法的完整实现可以从 GitHub 下载(链接 xxx)。要训练我们的 Breakout AI 玩家,请在 src 文件夹下运行以下命令:
python train.py -g Breakout -d gpu
train.py 中有两个参数。一个是 -g 或 --game,表示要测试的游戏名称。另一个是 -d 或 --device,指定要使用的设备(CPU 或 GPU)来训练 Q 网络。
对于 Atari 游戏,即使使用高端 GPU,也需要 4-7 天才能让我们的 AI 玩家达到人类水平的表现。为了快速测试算法,实现了一个名为 demo 的特殊游戏作为轻量级基准。可以通过以下方式运行 demo:
python train.py -g demo -d cpu
演示游戏基于网站上的 GridWorld 游戏,网址为 cs.stanford.edu/people/karpathy/convnetjs/demo/rldemo.html:

在这个游戏中,2D 网格世界中的一个机器人有九只眼睛,分别指向不同的角度,每只眼睛沿着其方向感知三个值:距离墙壁的距离、距离绿豆的距离或距离红豆的距离。它通过使用五个不同的动作之一来导航,每个动作使它转向不同的角度。它吃到绿豆时会获得正奖励(+1),而吃到红豆时会获得负奖励(-1)。目标是在一次游戏中尽可能多地吃绿豆。
训练将持续几分钟。在训练过程中,你可以打开一个新的终端,输入以下命令来可视化 Q 网络的架构和训练过程:
tensorboard --logdir=log/demo/train
这里,logdir 指向存储示例日志文件的文件夹。一旦 TensorBoard 启动,你可以在浏览器中输入 localhost:6006 来查看 TensorBoard:

这两张图分别绘制了损失和得分与训练步数的关系。显然,经过 10 万步训练后,机器人表现变得稳定,例如得分约为 40。
你还可以通过 TensorBoard 可视化 Q 网络的权重。更多详情,请访问 TensorBoard 指南 www.tensorflow.org/programmers_guide/summaries_and_tensorboard。这个工具对于调试和优化代码非常有用,特别是像 DQN 这样复杂的算法。
概述
恭喜你!你刚刚学习了四个重要的内容。第一个是如何使用 gym 实现一个 Atari 游戏模拟器,并如何玩 Atari 游戏来放松和娱乐。第二个是你学会了如何在强化学习任务中处理数据,比如 Atari 游戏。对于实际的机器学习应用,你将花费大量时间去理解和优化数据,而数据对 AI 系统的表现有很大的影响。第三个是深度 Q 学习算法。你了解了它的直觉,比如为什么需要重放记忆,为什么需要目标网络,更新规则的来源等等。最后一个是你学会了如何使用 TensorFlow 实现 DQN,并且如何可视化训练过程。现在,你已经准备好深入探讨我们在接下来章节中要讨论的更高级的主题。
在下一章,你将学习如何模拟经典的控制任务,以及如何实现最先进的演员-评论家算法来进行控制。
第四章:控制任务的仿真
在上一章中,我们看到了深度 Q 学习(DQN)在训练 AI 代理玩 Atari 游戏中的显著成功。DQN 的一个局限性是,动作空间必须是离散的,也就是说,代理可以选择的动作数量是有限的,并且动作的总数不能太大。然而,许多实际任务需要连续动作,这使得 DQN 难以应用。在这种情况下,对 DQN 的一种简单补救方法是将连续动作空间离散化。但由于维度灾难,这种补救方法并不起作用,意味着 DQN 很快变得不可行,并且无法很好地泛化。
本章将讨论用于具有连续动作空间的控制任务的深度强化学习算法。首先将介绍几个经典控制任务,如 CartPole、Pendulum 和 Acrobot。你将学习如何使用 Gym 仿真这些任务,并理解每个任务的目标和奖励。接下来,将介绍一种基本的演员-评论家算法,称为确定性策略梯度(DPG)。你将学习什么是演员-评论家架构,为什么这类算法能够处理连续控制任务。除此之外,你还将学习如何通过 Gym 和 TensorFlow 实现 DPG。最后,将介绍一种更高级的算法,称为信任区域策略优化(TRPO)。你将理解为什么 TRPO 的表现比 DPG 更好,以及如何通过应用共轭梯度法来学习策略。
本章需要一些数学编程和凸/非凸优化的背景知识。别担心,我们会一步步地讨论这些算法,确保你能充分理解它们背后的机制。理解它们为何有效、何时无法工作以及它们的优缺点,比仅仅知道如何用 Gym 和 TensorFlow 实现它们要重要得多。完成本章后,你将明白深度强化学习的魔法表演是由数学和深度学习共同指导的。
本章将涵盖以下主题:
- 
经典控制任务介绍
 - 
确定性策略梯度方法
 - 
复杂控制任务的信任区域策略优化
 
控制任务介绍
OpenAI Gym 提供了经典强化学习文献中的经典控制任务。这些任务包括 CartPole、MountainCar、Acrobot 和 Pendulum。欲了解更多信息,请访问 OpenAI Gym 网站:gym.openai.com/envs/#classic_control。此外,Gym 还提供了在流行物理模拟器 MuJoCo 中运行的更复杂的连续控制任务。MuJoCo 的主页地址是:www.mujoco.org/。MuJoCo 代表多关节动力学与接触,是一个用于机器人学、图形学和动画研究与开发的物理引擎。Gym 提供的任务包括 Ant、HalfCheetah、Hopper、Humanoid、InvertedPendulum、Reacher、Swimmer 和 Walker2d。这些名字很难理解,不是吗?有关这些任务的更多细节,请访问以下链接:gym.openai.com/envs/#mujoco.
快速入门
如果你没有完整安装 OpenAI Gym,可以按如下方式安装classic_control和mujoco环境的依赖:
pip install gym[classic_control]
pip install gym[mujoco]
MuJoCo 不是开源的,因此你需要按照mujoco-py中的说明(可在github.com/openai/mujoco-py#obtaining-the-binaries-and-license-key获取)进行设置。安装经典控制环境后,尝试以下命令:
import gym
atari = gym.make('Acrobot-v1')
atari.reset()
atari.render()
如果成功运行,一个小窗口将弹出,显示 Acrobot 任务的屏幕:

除了 Acrobot,你可以将Acrobot-v1任务名称替换为CartPole-v0、MountainCarContinuous-v0和Pendulum-v0,以查看其他控制任务。你可以运行以下代码来模拟这些任务,并尝试对它们的物理属性有一个高层次的理解:
import gym
import time
def start(task_name):
    task = gym.make(task_name)
    observation = task.reset()
    while True:
        task.render()
        action = task.env.action_space.sample()
        observation, reward, done, _ = task.step(action)
        print("Action {}, reward {}, observation {}".format(action, reward, observation))
        if done:
            print("Game finished")
            break
        time.sleep(0.05)
    task.close()
if __name__ == "__main__":
    task_names = ['CartPole-v0', 'MountainCarContinuous-v0', 
                  'Pendulum-v0', 'Acrobot-v1']
    for task_name in task_names:
        start(task_name)
Gym 使用相同的接口来处理所有任务,包括 Atari 游戏、经典控制任务和 MuJoCo 控制任务。在每一步中,动作是从动作空间中随机抽取的,方法是调用task.env.action_space.sample(),然后这个动作通过task.step(action)提交给模拟器,告诉模拟器执行该动作。step函数返回与该动作对应的观察值和奖励。
经典控制任务
我们现在将详细介绍每个控制任务,并回答以下问题:
- 
控制输入是什么?对应的反馈是什么?
 - 
奖励函数是如何定义的?
 - 
动作空间是连续的还是离散的?
 
理解这些控制任务的细节对于设计合适的强化学习算法非常重要,因为它们的规格(例如动作空间的维度和奖励函数)会对性能产生很大影响。
CartPole 是控制和强化学习领域非常著名的控制任务。Gym 实现了由Barto, Sutton, 和 Anderson在其论文《Neuronlike Adaptive Elements That Can Solve Difficult Learning Control Problem》(1983 年)中描述的 CartPole 系统。在 CartPole 中,一根杆子通过一个没有驱动的关节连接到一辆小车上,小车在一条无摩擦轨道上移动,如下所示:

下面是 CartPole 的规格:
| 目标 | 目标是防止杆子倒下。 | 
|---|---|
| 动作 | 动作空间是离散的,即通过对小车施加+1(右方向)和-1(左方向)的力量来控制系统。 | 
| 观察 | 观察值是一个包含四个元素的向量,例如[ 0.0316304, -0.1893631, -0.0058115, 0.27025422],表示杆子和小车的位置。 | 
| 奖励 | 每当杆子保持竖直时,都会获得+1 的奖励。 | 
| 终止条件 | 当杆子的角度超过 15 度或小车移动超过 2.4 个单位时,回合结束。 | 
因为本章讲解的是解决连续控制任务,接下来我们将设计一个包装器用于 CartPole,将其离散的动作空间转换为连续的动作空间。
MountainCar 首次由 Andrew Moore 在其博士论文《A. Moore, Efficient Memory-Based Learning for Robot Control》中描述,该论文于 1990 年发表,广泛应用于控制、马尔科夫决策过程(MDP)和强化学习算法的基准测试。在 MountainCar 中,一辆小车在一条一维轨道上移动,在两座山之间,试图到达黄色旗帜,如下所示:

以下表格提供了其规格:
| 目标 | 目标是到达右侧山顶。然而,小车的发动机不足以一次性爬上山顶。因此,成功的唯一方式是前后行驶以积累动能。 | 
|---|---|
| 动作 | 动作空间是连续的。输入的动作是施加于小车的发动机力量。 | 
| 观察 | 观察值是一个包含两个元素的向量,例如[-0.46786288, -0.00619457],表示小车的速度和位置。 | 
| 奖励 | 奖励越大,表示用更少的能量达成目标。 | 
| 终止条件 | 回合在小车到达目标旗帜或达到最大步数时结束。 | 
Pendulum 摆动问题是控制文献中的经典问题,并作为测试控制算法的基准。在 Pendulum 中,一根杆子固定在一个支点上,如下所示:

下面是 Pendulum 的规格:
| 目标 | 目标是将杆子摆起并保持竖直,防止其倒下。 | 
|---|---|
| 动作 | 动作空间是连续的。输入动作是施加在杆上的扭矩。 | 
| 观察 | 观察是一个包含三个元素的向量,例如,[-0.19092327, 0.98160496, 3.36590881],表示杆的角度和角速度。 | 
| 奖励 | 奖励由一个函数计算,该函数的输入包括角度、角速度和扭矩。 | 
| 终止 | 当达到最大步数时,任务结束。 | 
Acrobot 最早由 Sutton 在 1996 年的论文《强化学习中的泛化:使用稀疏粗编码的成功案例》中描述。Acrobot 系统包含两个关节和两个链接,其中两个链接之间的关节是驱动的:

以下是 Acrobot 的设置:
| 目标 | 目标是将下部链接的末端摆动到指定高度。 | 
|---|---|
| 动作 | 动作空间是离散的,也就是说,系统是通过向链接施加 0、+1 和-1 的扭矩来控制的。 | 
| 观察 | 观察是一个包含六个元素的向量,例如,[0.9926474, 0.12104186, 0.99736744, -0.07251337, 0.47965018, -0.31494488],表示两个链接的位置。 | 
| 奖励 | 当下部链接位于给定高度时,每一步将获得+1 奖励,否则为-1 奖励。 | 
| 终止 | 当下部链接的末端达到指定高度,或达到最大步数时,本轮任务结束。 | 
请注意,在 Gym 中,CartPole 和 Acrobot 都有离散的动作空间,这意味着这两个任务可以通过应用深度 Q 学习算法来解决。由于本章讨论的是连续控制任务,我们需要将它们的动作空间转换为连续的。以下类为 Gym 经典控制任务提供了一个包装器:
class Task:
    def __init__(self, name):
        assert name in ['CartPole-v0', 'MountainCar-v0', 
                        'Pendulum-v0', 'Acrobot-v1']
        self.name = name
        self.task = gym.make(name)
        self.last_state = self.reset()
    def reset(self):
        state = self.task.reset()
        self.total_reward = 0
        return state
    def play_action(self, action):
        if self.name not in ['Pendulum-v0', 'MountainCarContinuous-v0']:
            action = numpy.fmax(action, 0)
            action = action / numpy.sum(action)
            action = numpy.random.choice(range(len(action)), p=action)
        else:
            low = self.task.env.action_space.low
            high = self.task.env.action_space.high
            action = numpy.fmin(numpy.fmax(action, low), high)
        state, reward, done, _ = self.task.step(action)
        self.total_reward += reward
        termination = 1 if done else 0
        return reward, state, termination
    def get_total_reward(self):
        return self.total_reward
    def get_action_dim(self):
        if self.name not in ['Pendulum-v0', 'MountainCarContinuous-v0']:
            return self.task.env.action_space.n
        else:
            return self.task.env.action_space.shape[0]
    def get_state_dim(self):
        return self.last_state.shape[0]
    def get_activation_fn(self):
        if self.name not in ['Pendulum-v0', 'MountainCarContinuous-v0']:
            return tf.nn.softmax
        else:
            return None
对于 CartPole 和 Acrobot,输入动作应该是一个概率向量,表示选择每个动作的概率。在play_action函数中,动作会根据这个概率向量随机采样并提交给系统。get_total_reward函数返回一个回合中的总奖励。get_action_dim和get_state_dim函数分别返回动作空间和观察空间的维度。get_activation_fn函数用于演员网络的输出层,我们将在后续讨论中提到。
确定性策略梯度
正如前一章所讨论的,DQN 使用 Q 网络来估计 状态-动作值 函数,该函数对每个可用动作都有一个单独的输出。因此,由于动作空间是连续的,Q 网络无法应用。细心的读者可能还记得,Q 网络的另一种架构是将状态和动作作为输入,输出相应的 Q 值估计。这种架构不要求可用动作的数量是有限的,并且能够处理连续的输入动作:

如果我们使用这种网络来估计 状态-动作值 函数,那么必定还需要另一个网络来定义智能体的行为策略,即根据观察到的状态输出合适的动作。事实上,这正是演员-评论员强化学习算法的直观理解。演员-评论员架构包含两个部分:
- 
演员:演员定义了智能体的行为策略。在控制任务中,它根据系统的当前状态输出控制信号。
 - 
评论员:评论员估计当前策略的 Q 值。它可以判断策略是否优秀。
 
因此,如果演员和评论员都能像在 DQN 中训练 Q 网络一样,利用系统反馈(状态、奖励、下一个状态、终止信号)进行训练,那么经典的控制任务就能得到解决。那么我们该如何训练它们呢?
策略梯度背后的理论
一种解决方案是 深度确定性策略梯度 (DDPG) 算法,它将演员-评论员方法与 DQN 成功的经验相结合。相关论文如下:
- 
D. Silver, G. Lever, N. Heess, T. Degris, D. Wierstra 和 M. Riedmiller. 确定性策略梯度算法. 见于 ICML,2014。
 - 
T. P. Lillicrap, J. J. Hunt, A. Pritzel, N. Heess, T. Erez, Y. Tassa, D. Silver 和 D. Wierstra. 深度强化学习中的连续控制. 见于 ICLR,2016。
 
引入 DDPG 的原因首先是它与 DQN 非常相似,因此在完成前一章内容后,你可以更轻松地理解其背后的机制。回顾一下,DQN 能够以稳定且健壮的方式训练 Q 网络,原因如下:
- 
Q 网络通过从回放记忆中随机抽取样本进行训练,以最小化样本之间的相关性。
 - 
使用目标网络来估计目标 Q 值,从而减少策略发生振荡或发散的概率。DDPG 采用了相同的策略,这意味着 DDPG 也是一种无模型且离线的算法。
 
我们在强化学习设置中使用与上一章相同的符号。在每个时间步 
,智能体观察状态 
,采取动作 
,然后从函数 
 生成的奖励 
 中获得相应的回报。与使用 
 表示状态 
 下所有可用动作的集合不同,这里我们使用 
 来表示智能体的策略,它将状态映射到动作的概率分布。许多强化学习方法,如 DQN,都使用贝尔曼方程作为基础:
。
这个公式与 DQN 中的公式唯一的区别是这里的策略 
 是随机的,因此对 
 的期望是通过 
 来计算的。如果目标策略 
 是确定性的,可以通过函数 
 来描述,那么就可以避免这个内部的期望:
。
期望仅依赖于环境。这意味着可以使用 状态-动作值 函数 
 进行离策略学习,使用从其他策略生成的转换,就像我们在 DQN 中做的那样。函数 
 作为评估器,可以通过神经网络进行近似,该神经网络由 
 参数化,而策略 
 作为演员,也可以通过另一神经网络来表示,且该神经网络由 
 参数化(在 DQN 中,
 只是 
)。然后,可以通过最小化以下损失函数来训练评估器 
:
。
这里,
。如同 DQN 中一样,
 可以通过目标网络来估计,而用于近似 
 的样本可以从重放记忆中随机抽取。
为了训练演员 
,我们通过最小化损失函数 
来固定评论员 
,并尝试最大化 
相对于 
的值,因为较大的 Q 值意味着更好的策略。这可以通过应用链式法则来计算相对于演员参数的期望回报:
。
以下图展示了 DDPG 的高层架构:

与 DQN 相比,更新目标网络的方式有所不同。不是在几次迭代后直接将 
的权重复制到目标网络,而是使用软更新:

这里, 
表示目标网络的权重。这个更新意味着目标值的变化受到约束,变化速度较慢,从而大大提高了学习的稳定性。这个简单的变化将学习值函数的相对不稳定问题,拉近到了监督学习的情况。
与 DQN 类似,DDPG 在训练过程中也需要平衡探索和开发。由于由策略生成的动作 
是连续的,因此无法应用 
-贪婪策略。相反,我们可以通过向演员策略 
中加入从分布 
中采样的噪声来构造一个探索策略 
:
 其中 
 可以选择为 
,其中 
是标准高斯分布,且 
在每个训练步骤中递减。另一种选择是应用奥恩斯坦-乌伦贝克过程生成探索噪声 
。
DPG 算法
以下伪代码展示了 DDPG 算法:
Initialize replay memory  to capacity ;
Initialize the critic network  and actor network  with random weights  and ;
Initialize the target networks  and  with weights  and ;
Repeat for each episode:
    Set time step ;
    Initialize a random process  for action exploration noise;
    Receive an initial observation state ;
    While the terminal state hasn't been reached:
        Select an action  according to the current policy and exploration noise;
        Execute action  in the simulator and observe reward  and the next state ;
        Store transition  into replay memory ;
        Randomly sample a batch of  transitions  from ;
        Set  if  is a terminal state or  if  is a non-terminal state;
        Update critic by minimizing the loss:
                      ;
        Update the actor policy using the sampled policy gradient:
                      ;
        Update the target networks:
                      ,
                      ;
    End while
通过将用于近似演员和评论员的前馈神经网络替换为递归神经网络,DDPG 自然扩展出了一个变体。这个扩展被称为递归确定性策略梯度算法(RDPG),在论文《N. Heess, J. J. Hunt, T. P. Lillicrap 和 D. Silver. 基于记忆的控制与递归神经网络》中有讨论,发表于 2015 年。
循环评论家和演员通过时间反向传播(BPTT)进行训练。对于有兴趣的读者,可以从arxiv.org/abs/1512.04455下载相关论文。
DDPG 实现
本节将向你展示如何使用 TensorFlow 实现演员-评论家架构。代码结构几乎与上一章展示的 DQN 实现相同。
ActorNetwork是一个简单的多层感知机(MLP),它将观测状态作为输入:
class ActorNetwork:
    def __init__(self, input_state, output_dim, hidden_layers, activation=tf.nn.relu):
        self.x = input_state
        self.output_dim = output_dim
        self.hidden_layers = hidden_layers
        self.activation = activation
        with tf.variable_scope('actor_network'):
            self.output = self._build()
            self.vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, 
                                          tf.get_variable_scope().name)
    def _build(self):
        layer = self.x
        init_b = tf.constant_initializer(0.01)
        for i, num_unit in enumerate(self.hidden_layers):
            layer = dense(layer, num_unit, init_b=init_b, name='hidden_layer_{}'.format(i))
        output = dense(layer, self.output_dim, activation=self.activation, init_b=init_b, name='output')
        return output
构造函数需要四个参数:input_state、output_dim、hidden_layers和activation。input_state是观测状态的张量。output_dim是动作空间的维度。hidden_layers指定隐藏层的数量和每层的单元数。activation表示输出层的激活函数。
CriticNetwork也是一个多层感知机(MLP),足以应对经典控制任务:
class CriticNetwork:
    def __init__(self, input_state, input_action, hidden_layers):
        assert len(hidden_layers) >= 2
        self.input_state = input_state
        self.input_action = input_action
        self.hidden_layers = hidden_layers
        with tf.variable_scope('critic_network'):
            self.output = self._build()
            self.vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, 
                                          tf.get_variable_scope().name)
    def _build(self):
        layer = self.input_state
        init_b = tf.constant_initializer(0.01)
        for i, num_unit in enumerate(self.hidden_layers):
            if i != 1:
                layer = dense(layer, num_unit, init_b=init_b, name='hidden_layer_{}'.format(i))
            else:
                layer = tf.concat([layer, self.input_action], axis=1, name='concat_action')
                layer = dense(layer, num_unit, init_b=init_b, name='hidden_layer_{}'.format(i))
        output = dense(layer, 1, activation=None, init_b=init_b, name='output')
        return tf.reshape(output, shape=(-1,))
网络将状态和动作作为输入。它首先将状态映射为一个隐藏特征表示,然后将该表示与动作进行拼接,接着通过几个隐藏层。输出层生成与输入对应的 Q 值。
演员-评论家网络将演员网络和评论家网络结合在一起:
class ActorCriticNet:
    def __init__(self, input_dim, action_dim, 
                 critic_layers, actor_layers, actor_activation, 
                 scope='ac_network'):
        self.input_dim = input_dim
        self.action_dim = action_dim
        self.scope = scope
        self.x = tf.placeholder(shape=(None, input_dim), dtype=tf.float32, name='x')
        self.y = tf.placeholder(shape=(None,), dtype=tf.float32, name='y')
        with tf.variable_scope(scope):
            self.actor_network = ActorNetwork(self.x, action_dim, 
                                              hidden_layers=actor_layers, 
                                              activation=actor_activation)
            self.critic_network = CriticNetwork(self.x, 
                                                self.actor_network.get_output_layer(),
                                                hidden_layers=critic_layers)
            self.vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, 
                                          tf.get_variable_scope().name)
            self._build()
    def _build(self):
        value = self.critic_network.get_output_layer()
        actor_loss = -tf.reduce_mean(value)
        self.actor_vars = self.actor_network.get_params()
        self.actor_grad = tf.gradients(actor_loss, self.actor_vars)
        tf.summary.scalar("actor_loss", actor_loss, collections=['actor'])
        self.actor_summary = tf.summary.merge_all('actor')
        critic_loss = 0.5 * tf.reduce_mean(tf.square((value - self.y)))
        self.critic_vars = self.critic_network.get_params()
        self.critic_grad = tf.gradients(critic_loss, self.critic_vars)
        tf.summary.scalar("critic_loss", critic_loss, collections=['critic'])
        self.critic_summary = tf.summary.merge_all('critic')
构造函数需要六个参数,分别为:input_dim和action_dim是状态空间和动作空间的维度,critic_layers和actor_layers指定评论家网络和演员网络的隐藏层,actor_activation表示演员网络输出层的激活函数,scope是用于scope TensorFlow 变量的作用域名称。
构造函数首先创建一个self.actor_network演员网络的实例,输入为self.x,其中self.x表示当前状态。然后,它使用以下输入创建评论家网络的实例:self.actor_network.get_output_layer()作为演员网络的输出,self.x作为当前状态。给定这两个网络,构造函数调用self._build()来构建我们之前讨论过的演员和评论家的损失函数。演员损失是-tf.reduce_mean(value),其中value是评论家网络计算的 Q 值。评论家损失是0.5 * tf.reduce_mean(tf.square((value - self.y))),其中self.y是由目标网络计算的预测目标值的张量。
ActorCriticNet类提供了在给定当前状态的情况下计算动作和 Q 值的功能,即get_action和get_value。它还提供了get_action_value,该函数根据当前状态和代理执行的动作计算状态-动作值函数:
class ActorCriticNet:
    def get_action(self, sess, state):
        return self.actor_network.get_action(sess, state)
    def get_value(self, sess, state):
        return self.critic_network.get_value(sess, state)
    def get_action_value(self, sess, state, action):
        return self.critic_network.get_action_value(sess, state, action)
    def get_actor_feed_dict(self, state):
        return {self.x: state}
    def get_critic_feed_dict(self, state, action, target):
        return {self.x: state, self.y: target, 
                self.critic_network.input_action: action}
    def get_clone_op(self, network, tau=0.9):
        update_ops = []
        new_vars = {v.name.replace(network.scope, ''): v for v in network.vars}
        for v in self.vars:
            u = (1 - tau) * v + tau * new_vars[v.name.replace(self.scope, '')]
            update_ops.append(tf.assign(v, u))
        return update_ops
由于 DPG 与 DQN 的架构几乎相同,本章没有展示回放记忆和优化器的实现。欲了解更多细节,您可以参考前一章或访问我们的 GitHub 仓库(github.com/PacktPublishing/Python-Reinforcement-Learning-Projects)。通过将这些模块结合在一起,我们可以实现用于确定性策略梯度算法的DPG类:
class DPG:
    def __init__(self, config, task, directory, callback=None, summary_writer=None):
        self.task = task
        self.directory = directory
        self.callback = callback
        self.summary_writer = summary_writer
        self.config = config
        self.batch_size = config['batch_size']
        self.n_episode = config['num_episode']
        self.capacity = config['capacity']
        self.history_len = config['history_len']
        self.epsilon_decay = config['epsilon_decay']
        self.epsilon_min = config['epsilon_min']
        self.time_between_two_copies = config['time_between_two_copies']
        self.update_interval = config['update_interval']
        self.tau = config['tau']
        self.action_dim = task.get_action_dim()
        self.state_dim = task.get_state_dim() * self.history_len
        self.critic_layers = [50, 50]
        self.actor_layers = [50, 50]
        self.actor_activation = task.get_activation_fn()
        self._init_modules()
这里,config包含 DPG 的所有参数,例如训练时的批量大小和学习率。task是某个经典控制任务的实例。在构造函数中,回放记忆、Q 网络、目标网络和优化器通过调用_init_modules函数进行初始化:
    def _init_modules(self):
        # Replay memory
        self.replay_memory = ReplayMemory(history_len=self.history_len, 
                                          capacity=self.capacity)
        # Actor critic network
        self.ac_network = ActorCriticNet(input_dim=self.state_dim, 
                                         action_dim=self.action_dim, 
                                         critic_layers=self.critic_layers, 
                                         actor_layers=self.actor_layers, 
                                         actor_activation=self.actor_activation,
                                         scope='ac_network')
        # Target network
        self.target_network = ActorCriticNet(input_dim=self.state_dim, 
                                             action_dim=self.action_dim, 
                                             critic_layers=self.critic_layers, 
                                             actor_layers=self.actor_layers, 
                                             actor_activation=self.actor_activation,
                                             scope='target_network')
        # Optimizer
        self.optimizer = Optimizer(config=self.config, 
                                   ac_network=self.ac_network, 
                                   target_network=self.target_network, 
                                   replay_memory=self.replay_memory)
        # Ops for updating target network
        self.clone_op = self.target_network.get_clone_op(self.ac_network, tau=self.tau)
        # For tensorboard
        self.t_score = tf.placeholder(dtype=tf.float32, shape=[], name='new_score')
        tf.summary.scalar("score", self.t_score, collections=['dpg'])
        self.summary_op = tf.summary.merge_all('dpg')
    def choose_action(self, sess, state, epsilon=0.1):
        x = numpy.asarray(numpy.expand_dims(state, axis=0), dtype=numpy.float32)
        action = self.ac_network.get_action(sess, x)[0]
        return action + epsilon * numpy.random.randn(len(action))
    def play(self, action):
        r, new_state, termination = self.task.play_action(action)
        return r, new_state, termination
    def update_target_network(self, sess):
        sess.run(self.clone_op)
choose_action函数根据当前演员-评论员网络的估计和观察到的状态选择一个动作。
请注意,添加了由epsilon控制的高斯噪声以进行探索。
play函数将一个动作提交给模拟器,并返回模拟器的反馈。update_target_network函数从当前的演员-评论员网络中更新目标网络。
为了开始训练过程,可以调用以下函数:
    def train(self, sess, saver=None):
        num_of_trials = -1
        for episode in range(self.n_episode):
            frame = self.task.reset()
            for _ in range(self.history_len+1):
                self.replay_memory.add(frame, 0, 0, 0)
            for _ in range(self.config['T']):
                num_of_trials += 1
                epsilon = self.epsilon_min + \
                          max(self.epsilon_decay - num_of_trials, 0) / \
                          self.epsilon_decay * (1 - self.epsilon_min)
                if num_of_trials % self.update_interval == 0:
                    self.optimizer.train_one_step(sess, num_of_trials, self.batch_size)
                state = self.replay_memory.phi(frame)
                action = self.choose_action(sess, state, epsilon) 
                r, new_frame, termination = self.play(action)
                self.replay_memory.add(frame, action, r, termination)
                frame = new_frame
                if num_of_trials % self.time_between_two_copies == 0:
                    self.update_target_network(sess)
                    self.save(sess, saver)
                if self.callback:
                    self.callback()
                if termination:
                    score = self.task.get_total_reward()
                    summary_str = sess.run(self.summary_op, feed_dict={self.t_score: score})
                    self.summary_writer.add_summary(summary_str, num_of_trials)
                    self.summary_writer.flush()
                    break
在每一轮中,它调用replay_memory.phi来获取当前状态,并调用choose_action函数根据当前状态选择一个动作。然后,通过调用play函数将该动作提交给模拟器,该函数返回相应的奖励、下一个状态和终止信号。接着,(当前状态, 动作, 奖励, 终止)的转移将被存储到回放记忆中。每当update_interval步(默认为update_interval = 1)时,演员-评论员网络会通过从回放记忆中随机抽取的一批转移进行训练。每当经过time_between_two_copies步时,目标网络将更新,并将 Q 网络的权重保存到硬盘。
在训练步骤之后,可以调用以下函数来评估我们训练好的代理的表现:
    def evaluate(self, sess):
        for episode in range(self.n_episode):
            frame = self.task.reset()
            for _ in range(self.history_len+1):
                self.replay_memory.add(frame, 0, 0, 0)
            for _ in range(self.config['T']):
                print("episode {}, total reward {}".format(episode, 
                                                           self.task.get_total_reward()))
                state = self.replay_memory.phi(frame)
                action = self.choose_action(sess, state, self.epsilon_min) 
                r, new_frame, termination = self.play(action)
                self.replay_memory.add(frame, action, r, termination)
                frame = new_frame
                if self.callback:
                    self.callback()
                    if termination:
                        break
实验
DPG 的完整实现可以从我们的 GitHub 仓库下载(github.com/PacktPublishing/Python-Reinforcement-Learning-Projects)。要训练一个用于 CartPole 的代理,请在src文件夹下运行以下命令:
python train.py -t CartPole-v0 -d cpu
train.py有两个参数。一个是-t或--task,表示您要测试的经典控制任务的名称。另一个是-d或--device,指定您要使用的设备(CPU 或 GPU)来训练演员-评论员网络。由于这些经典控制任务的状态空间维度相较于 Atari 环境较低,使用 CPU 训练代理已经足够快速,通常只需几分钟即可完成。
在训练过程中,你可以打开一个新的终端并输入以下命令,以可视化演员-评论员网络的架构和训练过程:
tensorboard --logdir=log/CartPole-v0/train
这里,logdir 指向存储 CartPole-v0 日志文件的文件夹。TensorBoard 运行后,打开浏览器并导航到 localhost:6006 来查看 TensorBoard:

Tensorboard 视图
前两个图显示了演员损失和评论员损失相对于训练步骤的变化。对于经典控制任务,演员损失通常会持续下降,而评论员损失则会有较大的波动。在 60,000 个训练步骤后,分数变得稳定,达到了 200,这是 CartPole 仿真器中能达到的最高分数。
使用类似的命令,你也可以为 Pendulum 任务训练一个智能体:
python train.py -t Pendulum-v0 -d cpu
然后,通过 Tensorboard 检查训练过程:
tensorboard --logdir=log/Pendulum-v0/train
以下截图显示了训练过程中分数的变化:

训练过程中分数的变化
一位细心的读者可能会注意到,Pendulum 的分数波动比 CartPole 的分数要大。造成这个问题的原因有两个:
- 
在 Pendulum 中,杆子的起始位置是不确定的,即它可能在两次尝试之间有所不同
 - 
DPG 的训练过程可能并不总是稳定的,尤其是对于复杂任务,如 MuJoCo 控制任务
 
Gym 提供的 MuJoCo 控制任务,例如 Ant、HalfCheetah、Hopper、Humanoid、InvertedPendulum、Reacher、Swimmer 和 Walker2d,具有高维度的状态和动作空间,这使得 DPG 无法正常工作。如果你对在运行 DPG 时 Hopper-v0 任务会发生什么感到好奇,你可以尝试以下操作:
python train.py -t Hopper-v0 -d cpu
几分钟后,你会看到 DPG 无法教会 Hopper 如何行走。DPG 失败的主要原因是,本文讨论的简单的演员和评论员更新在高维输入下变得不稳定。
信任区域策略优化
信任区域策略优化(TRPO)算法旨在解决复杂的连续控制任务,相关论文如下:Schulman, S. Levine, P. Moritz, M. Jordan 和 P. Abbeel. 信任区域策略优化,发表于 ICML,2015。
要理解为什么 TRPO 有效,需要一些数学背景。主要思路是,最好确保由一个训练步骤优化后的新策略 
,不仅在单调减少优化损失函数(从而改进策略)的同时,还不会偏离之前的策略 
 太远,这意味着应该对 
 和 
 之间的差异施加约束,例如对某个约束函数 
 施加常数 
。
TRPO 背后的理论
让我们来看一下 TRPO 背后的机制。如果你觉得这一部分难以理解,可以跳过它,直接看如何运行 TRPO 来解决 MuJoCo 控制任务。考虑一个无限期折扣的马尔科夫决策过程,记作 
,其中 
 是状态的有限集合, 
 是行动的有限集合, 
 是转移概率分布, 
 是成本函数, 
 是初始状态的分布, 
 是折扣因子。设 
 为我们希望通过最小化以下预期折扣成本来学习的随机策略:

这里是 
, 
 和 
。在策略 
 下,状态-行动值函数 
,值函数 
,以及优势函数 
 的定义如下:



这里是 
 和 
。
我们的目标是在每个训练步骤中,通过减少预期折扣成本,改善策略 
。为了设计一个单调改善 
的算法,让我们考虑以下方程:

这里,表示的是 
,
 和 
。这个方程对于任何策略 
 都成立。对于有兴趣了解此方程证明的读者,请参阅 TRPO 论文的附录或 Kakade 和 Langford 撰写的论文《大致最优的近似强化学习》。为了简化这个方程,设 
 为折扣访问频率:

通过将前面的方程重排,以对状态而不是时间步进行求和,它变为以下形式:

从这个方程我们可以看出,任何策略更新 
,只要在每个状态 
 上的期望优势非正,即 
,都能保证减少成本 
。因此,对于像 Atari 环境这样的离散动作空间,DQN 中选择的确定性策略 
 保证如果至少有一个状态-动作对的优势值为负且状态访问概率非零,就能改进策略。然而,在实际问题中,特别是当策略通过神经网络近似时,由于近似误差,某些状态的期望优势可能为正。除此之外,
 对 
 的依赖使得该方程难以优化,因此 TRPO 考虑通过将 
 替换为 
 来优化以下函数:

Kakade 和 Langford 显示,如果我们有一个参数化策略,
,这是参数 
 的可微函数,那么对于任何参数 
:


这意味着,改进 
 也会通过对 
 的足够小的更新来改进 
。基于这个思想,Kakade 和 Langford 提出了一个叫做保守策略迭代的策略更新方案:

这里,
 是当前策略,
 是新策略,
 是通过求解 
 得到的。它们证明了以下更新的界限:
,其中 
请注意,这个界限仅适用于通过前述更新生成的混合策略。在 TRPO 中,作者将这个界限扩展到一般的随机策略,而不仅仅是混合策略。其主要思想是用 
 替换混合权重,使用 
 和 
 之间的距离度量。一个有趣的距离度量选项是总变差散度。以两个离散分布 
 和 
 为例,总变差散度定义如下:

对于策略 
 和 
,令 
 为所有状态的最大总变差散度:

使用 
 和 
,可以证明:
,其中 
。
实际上,总变差散度可以被 KL 散度上界,即 
,这意味着:
,其中 
。
TRPO 算法
基于前述的策略改进界限,开发了以下算法:
Initialize policy ;
Repeat for each step :
    Compute all advantage values ;
    Solve the following optimization problem:
    ;
Until convergence
在每一步中,该算法最小化 
 的上界,使得:

最后一条等式由此得出,
 对于任何策略 
 都成立。这意味着该算法保证生成一系列单调改进的策略。
在实践中,由于难以计算 
 的确切值,并且很难通过惩罚项来控制每次更新的步长,TRPO 将惩罚项替换为 KL 散度受限于常数 
 的约束:

但由于约束条件过多,这个问题仍然很难解决。因此,TRPO 使用了一种启发式近似方法,考虑了平均 KL 散度:

这导致了以下优化问题:

换句话说,通过展开
,我们需要解决以下问题:

现在,问题是:我们如何优化这个问题?一个直接的想法是通过模拟策略
进行若干步,然后使用这些轨迹来逼近该问题的目标函数。由于优势函数
,我们将目标函数中的
替换为 Q 值
,这只是使目标函数变化一个常数。此外,注意以下几点:

因此,给定一个在策略
下生成的轨迹
,我们将按以下方式进行优化:

对于 MuJoCo 控制任务,策略
和状态-动作值函数
都通过神经网络进行逼近。为了优化这个问题,KL 散度约束可以通过 Fisher 信息矩阵来近似。然后,可以通过共轭梯度算法来求解此问题。有关更多详细信息,您可以从 GitHub 下载 TRPO 的源代码,并查看optimizer.py,该文件实现了使用 TensorFlow 的共轭梯度算法。
MuJoCo 任务实验
Swimmer任务是测试 TRPO 的一个很好的例子。这个任务涉及一个在粘性流体中游泳的三连杆机器人,目标是通过驱动两个关节,使其尽可能快地游向前方(gym.openai.com/envs/Swimmer-v2/)。下面的截图显示了Swimmer在 MuJoCo 模拟器中的样子:

要训练一个Swimmer代理,请在src文件夹下运行以下命令:
CUDA_VISIBLE_DEVICES= python train.py -t Swimmer
train.py中有两个参数。一个是-t,或--task,表示您要测试的 MuJoCo 或经典控制任务的名称。由于这些控制任务的状态空间相比于 Atari 环境维度较低,单独使用 CPU 进行训练就足够了,只需将CUDA_VISIBLE_DEVICES设置为空,这将花费 30 分钟到两小时不等。
在训练过程中,您可以打开一个新的终端,并输入以下命令来可视化训练过程:
tensorboard --logdir=log/Swimmer
在这里,logdir指向存储Swimmer日志文件的文件夹。一旦 TensorBoard 正在运行,使用网页浏览器访问localhost:6006以查看 TensorBoard:

很明显,经过 200 个回合后,每个回合的总奖励变得稳定,即大约为 366。要检查训练后Swimmer如何移动,运行以下命令:
CUDA_VISIBLE_DEVICES= python test.py -t Swimmer
你将看到一个看起来很有趣的Swimmer对象在地面上行走。
总结
本章介绍了 Gym 提供的经典控制任务和 MuJoCo 控制任务。你已经了解了这些任务的目标和规格,以及如何为它们实现一个模拟器。本章最重要的部分是用于连续控制任务的确定性 DPG 和 TRPO。你了解了它们背后的理论,这也解释了它们为什么在这些任务中表现良好。你还学习了如何使用 TensorFlow 实现 DPG 和 TRPO,以及如何可视化训练过程。
在下一章,我们将学习如何将强化学习算法应用于更复杂的任务,例如,玩 Minecraft。我们将介绍异步演员-评论员(A3C)算法,它在复杂任务中比 DQN 更快,并且已广泛应用于许多深度强化学习算法作为框架。
第五章:在 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)
通过传递模型的路径,我们可以测试聊天机器人以获取各种回应。
总结
聊天机器人正在迅速席卷全球,预计在未来几年将变得更加普及。如果要获得广泛的接受,这些聊天机器人通过对话得到的结果的连贯性必须不断提高。实现这一目标的一种方式是通过使用强化学习。
在本章中,我们实现了在创建聊天机器人过程中使用强化学习。该学习方法基于一种政策梯度方法,重点关注对话代理的未来方向,以生成连贯且有趣的互动。我们使用的数据集来自电影对话。我们对数据集进行了清理和预处理,从中获取了词汇表。然后,我们制定了我们的政策梯度方法。我们的奖励函数通过一个序列到序列模型表示。接着,我们训练并测试了我们的数据,获得了非常合理的结果,证明了使用强化学习进行对话代理的可行性。
第八章:生成深度学习图像分类器
在过去的十年里,深度学习凭借在计算机视觉、自然语言处理、语音识别等多个应用领域取得的卓越成果,赢得了广泛的声誉。一些人类研究者设计并开发的模型也获得了广泛的关注,包括 AlexNet、Inception、VGGNet、ResNet 和 DenseNet;其中一些模型现在已成为各自任务的标准。然而,似乎模型越优秀,其架构就越复杂,特别是在卷积层之间引入残差连接后。设计一个高性能神经网络的任务因此变得异常艰巨。因此,问题随之而来:是否有可能让一个算法学会生成神经网络架构?
正如本章标题所示,确实有可能训练一个神经网络来生成在给定任务中表现良好的神经网络。在本章中,我们将介绍由 Google Brain 团队的 Barret Zoph 和 Quoc V. Le 开发的一个新型框架——神经架构搜索(以下简称NAS),它利用深度强化学习训练一个控制器,生成子网络来学习完成任务。我们将学习策略梯度方法(特别是 REINFORCE)如何训练这样的控制器。随后,我们将实现一个控制器,使用 NAS 生成在CIFAR-10数据集上进行训练的子网络。
在本章中,我们将涵盖以下内容:
- 
理解 NAS 以及它如何学会生成其他神经网络
 - 
实现一个简单的 NAS 框架,用于生成神经网络并在
CIFAR-10数据集上进行训练 
你可以从以下来源找到后续主题的原始资料:
- 
Zoph, B., 和 Le, Q. V. (2016). 通过强化学习的神经架构搜索。arXiv 预印本 arXiv:1611.01578。
 - 
Pham, H., Guan, M. Y., Zoph, B., Le, Q. V., 和 Dean, J. (2018). 通过参数共享的高效神经架构搜索。arXiv 预印本 arXiv:1802.03268。
 
神经架构搜索
接下来的几个部分将描述 NAS 框架。你将了解该框架如何使用一种叫做REINFORCE的强化学习方案来学习生成其他神经网络,完成任务。REINFORCE是一种策略梯度算法。
生成并训练子网络
生成神经网络架构的算法研究自 1970 年代以来便存在。NAS 与之前的研究不同之处在于,它能够应对大规模深度学习算法,并将任务表述为强化学习问题。更具体地说,代理,我们称之为控制器,是一个递归神经网络,它生成一系列值。你可以把这些值看作是子网络的某种遗传码,定义了子网络的架构;它设置了每个卷积核的大小、每个核的长度、每层的滤波器数量等等。在更先进的框架中,这些值还决定了各层之间的连接,从而生成残差层:

图 1:NAS 框架概览
此外,控制器输出的每个遗传码值都算作一个动作,a,它是以概率p采样的。由于控制器是一个递归神经网络,我们可以将t^(th)动作表示为
。一旦我们拥有了动作列表,
—其中T是一个预定义的参数,用来设置遗传码的最大大小—我们就可以根据指定的架构生成子网络A:

图 2:控制器架构
一旦控制器生成了一个子网络,我们就对其在给定任务上进行训练,直到满足某些终止标准(例如,在指定的轮次后)。然后我们在验证集上评估子网络,得到一个验证准确率R。验证准确率作为控制器的奖励信号。所以,控制器的目标是最大化期望奖励:

这里,J是奖励函数(也称为拟合函数),
是控制器的参数,方程右侧是给定子网络架构A时,奖励的期望值。在实际操作中,这个期望值是通过对控制器在一批次中生成的m个子网络模型的奖励进行平均来计算的:

训练控制器
我们如何使用这个奖励信号来更新控制器?请记住,这个奖励信号不像监督学习中的损失函数那样可微;我们不能仅仅通过控制器进行反向传播。相反,我们使用一种叫做REINFORCE的策略梯度方法,迭代地更新控制器的参数,
。在 REINFORCE 中,奖励函数的梯度,J,相对于控制器参数,
,定义如下:

你可能记得在第六章《学习下围棋》中看到过类似的表达式。确实,这是 AlphaGo 和 AlphaGo Zero 用于更新其强化学习策略网络权重的策略梯度方法。我们当时简单介绍了这种方法,今天我们将更深入地探讨它。
让我们分解一下前面的方程。在右侧,我们希望表示选择某个架构 A 的概率。具体来说,
 代表控制器在给定所有之前的动作、
 和控制器参数 
 后采取某个动作的概率。再次强调,动作 
 对应于基因序列中代表子网络架构的 t^(th) 值。选择所有动作的联合概率,
,可以按照以下方式表示:

通过将这个联合概率转换到对数空间,我们可以将乘积转化为概率的和:

一般来说,我们希望最大化采取某个动作的对数条件概率。换句话说,我们希望提高控制器生成特定基因代码序列的可能性。因此,我们对这个目标执行梯度上升,求取关于控制器参数的对数概率的导数:

但是,我们如何更新控制器的参数,以便生成更好的架构呢?这时我们利用奖励信号 R。通过将前面的结果与奖励信号相乘,我们可以控制策略梯度的大小。换句话说,如果某个架构达到了较高的验证准确率(最高为 1.0),那么该策略的梯度将相对较强,控制器将学习生成类似的架构。相反,较小的验证准确率将意味着较小的梯度,这有助于控制器忽略这些架构。
REINFORCE 算法的一个问题是奖励信号 R 的方差可能很大,这会导致训练曲线的不稳定。为了减少方差,通常会从奖励中减去一个值 b,我们称之为基准函数。在 Zoph 等人的研究中,基准函数定义为过去奖励的指数移动平均。因此,我们的 REINFORCE 策略梯度现在定义为:

一旦得到这个梯度,我们就应用常规的反向传播算法来更新控制器的参数, 
。
训练算法
控制器的训练步骤如下:
- 
对于每一轮,执行以下操作:
- 
生成 m 个子网络架构
 - 
在给定任务上训练子网络并获得 m 个验证准确度
 - 
计算
![]()
 - 
更新
![]()
 
 - 
 
在 Zoph 等人的研究中,训练过程通过多个控制器副本完成。每个控制器通过 
 参数化,而该参数本身以分布式方式存储在多个服务器中,这些服务器我们称之为参数服务器。
在每一轮训练中,控制器创建若干个子架构并独立训练。计算出的策略梯度随后被发送到参数服务器,以更新控制器的参数:

图 3:训练架构
控制器的参数在多个参数服务器之间共享。此外,多个控制器副本并行训练,每个副本为其各自的子网络架构批次计算奖励和梯度。
这种架构使得控制器可以在资源充足的情况下快速训练。然而,对于我们的目的,我们将坚持使用一个控制器来生成 m 个子网络架构。一旦我们训练了控制器指定的轮数,我们通过选择验证精度最好的子网络架构来计算测试准确度,并在测试集上测量其性能。
实现 NAS
在这一部分,我们将实现 NAS。具体来说,我们的控制器负责生成子网络架构,用于学习从CIFAR-10数据集中分类图像。子网络的架构将由一个数字列表表示。这个列表中的每四个值代表子网络中的一个卷积层,每个值描述卷积核大小、步长、滤波器数量和随后的池化层的池化窗口大小。此外,我们将子网络中的层数作为超参数指定。例如,如果我们的子网络有三层,那么它的架构将表示为一个长度为 12 的向量。如果我们的架构表示为[3, 1, 12, 2, 5, 1, 24, 2],那么这个子网络是一个两层网络,其中第一层的卷积核大小为 3,步长为 1,12 个滤波器,最大池化窗口大小为 2;第二层的卷积核大小为 5,步长为 1,24 个滤波器,最大池化窗口大小为 2。我们将每一层之间的激活函数设置为 ReLU。最后一层将对最后一个卷积层的输出进行展平,并应用一个线性层,宽度为类别数,之后应用 Softmax 激活。以下部分将带你完成实现。
child_network.py
我们将首先实现我们的子网络模块。这个模块包含一个名为ChildCNN的类,它根据某些架构配置构建子网络,这些配置我们称之为cnn_dna。如前所述,cnn_dna只是一个数字列表,每个值代表其对应卷积层的参数。在我们的config.py中,我们指定了子网络最多可以有多少层。对于我们的实现,每个卷积层由四个参数表示,每个参数分别对应卷积核大小、步长、滤波器数量和随后的最大池化窗口大小。
我们的ChildCNN是一个类,它的构造函数接受以下参数:
- 
cnn_dna:网络架构 - 
child_id:一个字符串,用于标识子网络架构 - 
beta:L2 正则化的权重参数 - 
drop_rate:丢弃率 
import logging
import tensorflow as tf
logger = logging.getLogger(__name__)
class ChildCNN(object):
    def __init__(self, cnn_dna, child_id, beta=1e-4, drop_rate=0.2, **kwargs):
        self.cnn_dna = self.process_raw_controller_output(cnn_dna)
        self.child_id = child_id
        self.beta = beta
        self.drop_rate = drop_rate
        self.is_training = tf.placeholder_with_default(True, shape=None, name="is_training_{}".format(self.child_id))
        self.num_classes = 10
我们还实现了一个辅助函数proces_raw_controller_output(),它解析控制器输出的cnn_dna:
def process_raw_controller_output(self, output):
    """
    A helper function for preprocessing the output of the NASCell
    Args:
        output (numpy.ndarray) The output of the NASCell
    Returns:
        (list) The child network's architecture
    """
    output = output.ravel()
    cnn_dna = [list(output[x:x+4]) for x in range(0, len(output), 4)]
    return cnn_dna
最后,我们包含了build方法,它使用给定的cnn_dna构建我们的子网络。你会注意到,尽管我们让控制器决定我们子网络的架构,但我们仍然硬编码了几个部分,例如激活函数tf.nn.relu以及卷积核的初始化方式。我们在每个卷积层后添加最大池化层的做法也是硬编码的。一个更复杂的 NAS 框架还会让控制器决定这些架构组件,代价是更长的训练时间:
def build(self, input_tensor):
    """
    Method for creating the child neural network
    Args:
        input_tensor: The tensor which represents the input
    Returns:
        The tensor which represents the output logit (pre-softmax activation)
    """
    logger.info("DNA is: {}".format(self.cnn_dna))
    output = input_tensor
    for idx in range(len(self.cnn_dna)):
        # Get the configuration for the layer
        kernel_size, stride, num_filters, max_pool_size = self.cnn_dna[idx]
        with tf.name_scope("child_{}_conv_layer_{}".format(self.child_id, idx)):
            output = tf.layers.conv2d(output,
                    # Specify the number of filters the convolutional layer will output
                    filters=num_filters,
                    # This specifies the size (height, width) of the convolutional kernel
                    kernel_size=(kernel_size, kernel_size),
                    # The size of the stride of the kernel
                    strides=(stride, stride),
                    # We add padding to the image
                    padding="SAME",
                    # It is good practice to name your layers
                    name="conv_layer_{}".format(idx),
                    activation=tf.nn.relu,
                    kernel_initializer=tf.contrib.layers.xavier_initializer(),
                    bias_initializer=tf.zeros_initializer(),
                    kernel_regularizer=tf.contrib.layers.l2_regularizer(scale=self.beta))
每个卷积层后面都跟着一个最大池化层和一个丢弃层:
            # We apply 2D max pooling on the output of the conv layer
            output = tf.layers.max_pooling2d(
                output, pool_size=(max_pool_size, max_pool_size), strides=1,
                padding="SAME", name="pool_out_{}".format(idx)
            )
            # Dropout to regularize the network further
            output = tf.layers.dropout(output, rate=self.drop_rate, training=self.is_training)
最后,在经过几个卷积层、池化层和丢弃层之后,我们将输出体积展平并连接到一个全连接层:
    # Lastly, we flatten the outputs and add a fully-connected layer
    with tf.name_scope("child_{}_fully_connected".format(self.child_id)):
        output = tf.layers.flatten(output, name="flatten")
        logits = tf.layers.dense(output, self.num_classes)
    return logits
我们的build方法的参数是一个输入张量,默认形状为(32, 32, 3),这是CIFAR-10数据的形状。读者可以自由调整此网络的架构,包括添加更多的全连接层或在卷积之间插入批量归一化层。
cifar10_processor.py
该模块包含处理CIFAR-10数据的代码,我们使用这些数据来训练我们的子网络。特别地,我们使用 TensorFlow 的原生tf.data.Dataset API 构建输入数据管道。那些已经使用 TensorFlow 一段时间的人可能更熟悉创建tf.placeholder张量并通过sess.run(..., feed_dict={...})提供数据。然而,这已经不是将数据输入网络的首选方式;事实上,它是训练网络最慢的方式,因为从numpy格式的数据到原生 TensorFlow 格式的重复转换会导致显著的计算开销。tf.data.Dataset通过将输入管道转化为 TensorFlow 操作,这些操作是符号图的一部分,解决了这个问题。换句话说,数据从一开始就直接转换为张量。这使得输入管道更加流畅,并能加速训练。
有关tf.data.Dataset API 的更多信息,请参考这个官方教程(www.tensorflow.org/guide/datasets_for_estimators)。
cifar10_processor.py包含一个方法,用于将CIFAR-10数据转换为张量。我们首先实现一个辅助函数来创建tf.data.Dataset对象:
import logging
import numpy as np
import tensorflow as tf
from keras.datasets import cifar10
from keras.utils import np_utils
logger = logging.getLogger(__name__)
def _create_tf_dataset(x, y, batch_size):
    return tf.data.Dataset.zip((tf.data.Dataset.from_tensor_slices(x),
                                tf.data.Dataset.from_tensor_slices(y))).shuffle(500).repeat().batch(batch_size)
在主数据处理函数中,我们首先加载CIFAR-10数据。我们使用keras.datasets API 来完成这项工作(如果没有 Keras,请在终端中运行pip install keras):
def get_tf_datasets_from_numpy(batch_size, validation_split=0.1):
    """
    Main function getting tf.Data.datasets for training, validation, and testing
    Args:
        batch_size (int): Batch size
        validation_split (float): Split for partitioning training and validation sets. Between 0.0 and 1.0.
    """
    # Load data from keras datasets api
    (X, y), (X_test, y_test) = cifar10.load_data()
    logger.info("Dividing pixels by 255")
    X = X / 255.
    X_test = X_test / 255.
    X = X.astype(np.float32)
    X_test = X_test.astype(np.float32)
    y = y.astype(np.float32)
    y_test = y_test.astype(np.float32)
    # Turn labels into onehot encodings
    if y.shape[1] != 10:
        y = np_utils.to_categorical(y, num_classes=10)
        y_test = np_utils.to_categorical(y_test, num_classes=10)
    logger.info("Loaded data from keras")
    split_idx = int((1.0 - validation_split) * len(X))
    X_train, y_train = X[:split_idx], y[:split_idx]
    X_valid, y_valid = X[split_idx:], y[split_idx:]
然后我们将这些 NumPy 数组转换为 TensorFlow 张量,直接将其输入网络。实际上,我们的_create_tf_dataset辅助函数中发生了什么?我们使用tf.dataset.Dataset.from_tensor_slices()函数将数据和标签(它们都是 NumPy 数组)转换为 TensorFlow 张量。然后通过将这些张量打包创建原生数据集。打包后的shuffle、repeat和batch函数定义了我们希望输入管道如何工作。在我们的案例中,我们对输入数据进行随机洗牌,当达到数据集末尾时重复数据集,并以给定的批量大小进行分批。我们还计算每个数据集的批次数并返回它们:
train_dataset = _create_tf_dataset(X_train, y_train, batch_size)
valid_dataset = _create_tf_dataset(X_valid, y_valid, batch_size)
test_dataset = _create_tf_dataset(X_test, y_test, batch_size)
# Get the batch sizes for the train, valid, and test datasets
num_train_batches = int(X_train.shape[0] // batch_size)
num_valid_batches = int(X_valid.shape[0] // batch_size)
num_test_batches = int(X_test.shape[0] // batch_size)
return train_dataset, valid_dataset, test_dataset, num_train_batches, num_valid_batches, num_test_batches
就这样,我们得到了一个比使用feed_dict更快的优化输入数据管道。
controller.py
controller.py模块是所有内容汇聚的地方。我们将实现控制器,负责训练每个子网络以及它自己的参数更新。首先,我们实现一个辅助函数,计算一组数字的指数移动平均值。我们使用这个作为 REINFORCE 梯度计算的基准函数,如前所述,用来计算过去奖励的指数移动平均值:
import logging
import numpy as np
import tensorflow as tf
from child_network import ChildCNN
from cifar10_processor import get_tf_datasets_from_numpy
from config import child_network_params, controller_params
logger = logging.getLogger(__name__)
def ema(values):
    """
    Helper function for keeping track of an exponential moving average of a list of values.
    For this module, we use it to maintain an exponential moving average of rewards
    Args:
        values (list): A list of rewards 
    Returns:
        (float) The last value of the exponential moving average
    """
    weights = np.exp(np.linspace(-1., 0., len(values)))
    weights /= weights.sum()
    a = np.convolve(values, weights, mode="full")[:len(values)]
    return a[-1]
接下来,我们定义我们的Controller类:
class Controller(object):
    def __init__(self):
        self.graph = tf.Graph()
        self.sess = tf.Session(graph=self.graph)
        self.num_cell_outputs = controller_params['components_per_layer'] * controller_params['max_layers']
        self.reward_history = []
        self.architecture_history = []
        self.divison_rate = 100
        with self.graph.as_default():
            self.build_controller()
有几个属性需要注意:self.num_cell_outputs表示我们递归神经网络(RNN)应该输出的值的数量,并对应子网络架构配置的长度。self.reward_history和self.architecture_history只是缓冲区,允许我们跟踪 RNN 生成的奖励和子网络架构。
生成控制器的方法
接下来,我们实现一个用于生成控制器的方法,称为build_controller。构建控制器的第一步是定义输入占位符。我们创建了两个占位符——一个用于子网络 DNA,作为输入传递给 RNN,以生成新的子网络 DNA,另一个是用于存储折扣奖励的列表,以便在计算 REINFORCE 梯度时使用:
def build_controller(self):
    logger.info('Building controller network')
    # Build inputs and placeholders
    with tf.name_scope('controller_inputs'):
        # Input to the NASCell
        self.child_network_architectures = tf.placeholder(tf.float32, [None, self.num_cell_outputs], 
                                                          name='controller_input')
        # Discounted rewards
        self.discounted_rewards = tf.placeholder(tf.float32, (None, ), name='discounted_rewards')
然后,我们定义 RNN 的输出张量(将在此处实现)。注意,RNN 的输出值较小,范围在(-1, 1)之间。所以,我们将输出乘以 10,以便生成子网络的 DNA:
# Build controller
with tf.name_scope('network_generation'):
    with tf.variable_scope('controller'):
        self.controller_output = tf.identity(self.network_generator(self.child_network_architectures), 
                                             name='policy_scores')
        self.cnn_dna_output = tf.cast(tf.scalar_mul(self.divison_rate, self.controller_output), tf.int32,
                                      name='controller_prediction')
然后,我们定义损失函数和优化器。我们使用RMSPropOptimizer作为反向传播算法,其中学习率按指数衰减。与通常在其他神经网络模型中调用optimizer.minimize(loss)不同,我们调用compute_gradients方法来获得计算 REINFORCE 梯度所需的梯度:
# Set up optimizer
self.global_step = tf.Variable(0, trainable=False)
self.learning_rate = tf.train.exponential_decay(0.99, self.global_step, 500, 0.96, staircase=True)
self.optimizer = tf.train.RMSPropOptimizer(learning_rate=self.learning_rate)
# Gradient and loss computation
with tf.name_scope('gradient_and_loss'):
    # Define policy gradient loss for the controller
    self.policy_gradient_loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(
        logits=self.controller_output[:, -1, :],
        labels=self.child_network_architectures))
    # L2 weight decay for Controller weights
    self.l2_loss = tf.reduce_sum(tf.add_n([tf.nn.l2_loss(v) for v in
                                           tf.trainable_variables(scope="controller")]))
    # Add the above two losses to define total loss
    self.total_loss = self.policy_gradient_loss + self.l2_loss * controller_params["beta"]
    # Compute the gradients
    self.gradients = self.optimizer.compute_gradients(self.total_loss)
    # Gradients calculated using REINFORCE
    for i, (grad, var) in enumerate(self.gradients):
        if grad is not None:
            self.gradients[i] = (grad * self.discounted_rewards, var)
最后,我们在控制器参数上应用 REINFORCE 梯度:
with tf.name_scope('train_controller'):
    # The main training operation. This applies REINFORCE on the weights of the Controller
    self.train_op = self.optimizer.apply_gradients(self.gradients, global_step=self.global_step)
logger.info('Successfully built controller')
实际的控制器网络是通过network_generator函数创建的。如前所述,控制器是一个具有特殊类型单元的递归神经网络。然而,我们不必从头实现这一点,因为 TensorFlow 的开发者已经实现了一个自定义的tf.contrib.rnn.NASCell。我们只需要使用这个来构建我们的递归神经网络并获得输出:
def network_generator(self, nas_cell_hidden_state):
    # number of output units we expect from a NAS cell
    with tf.name_scope('network_generator'):
        nas = tf.contrib.rnn.NASCell(self.num_cell_outputs)
        network_architecture, nas_cell_hidden_state = tf.nn.dynamic_rnn(nas, tf.expand_dims(
            nas_cell_hidden_state, -1), dtype=tf.float32)
        bias_variable = tf.Variable([0.01] * self.num_cell_outputs)
        network_architecture = tf.nn.bias_add(network_architecture, bias_variable)
        return network_architecture[:, -1:, :]
使用控制器生成子网络
现在,我们实现一个方法,通过控制器生成一个子网络:
def generate_child_network(self, child_network_architecture):
    with self.graph.as_default():
        return self.sess.run(self.cnn_dna_output, {self.child_network_architectures: child_network_architecture})
一旦我们生成了子网络,就调用train_child_network函数来训练它。该函数接受child_dna和child_id,并返回子网络达到的验证精度。首先,我们实例化一个新的tf.Graph()和一个新的tf.Session(),这样子网络就与控制器的图分开:
def train_child_network(self, cnn_dna, child_id):
    """
    Trains a child network and returns reward, or the validation accuracy
    Args:
        cnn_dna (list): List of tuples representing the child network's DNA
        child_id (str): Name of child network
    Returns:
        (float) validation accuracy
    """
    logger.info("Training with dna: {}".format(cnn_dna))
    child_graph = tf.Graph()
    with child_graph.as_default():
        sess = tf.Session()
        child_network = ChildCNN(cnn_dna=cnn_dna, child_id=child_id, **child_network_params)
接着我们定义输入数据管道,使用我们在此实现的tf.data.Dataset创建器。具体来说,我们使用tf.data.Iterator创建一个生成器,每次调用iterator.get_next()时都会生成一批输入张量。我们分别为训练集和验证集初始化一个迭代器。这一批输入张量包含CIFAR-10图像及其对应的标签,我们会在最后解包它们:
# Create input pipeline
train_dataset, valid_dataset, test_dataset, num_train_batches, num_valid_batches, num_test_batches = \
    get_tf_datasets_from_numpy(batch_size=child_network_params["batch_size"])
# Generic iterator
iterator = tf.data.Iterator.from_structure(train_dataset.output_types, train_dataset.output_shapes)
next_tensor_batch = iterator.get_next()
# Separate train and validation set init ops
train_init_ops = iterator.make_initializer(train_dataset)
valid_init_ops = iterator.make_initializer(valid_dataset)
# Build the graph
input_tensor, labels = next_tensor_batch
input_tensor成为子网络build方法的参数。接着我们定义了训练所需的所有 TensorFlow 操作,包括预测、损失、优化器和精度操作:
# Build the child network, which returns the pre-softmax logits of the child network
logits = child_network.build(input_tensor)
# Define the loss function for the child network
loss_ops = tf.nn.softmax_cross_entropy_with_logits_v2(labels=labels, logits=logits, name="loss")
# Define the training operation for the child network
train_ops = tf.train.AdamOptimizer(learning_rate=child_network_params["learning_rate"]).minimize(loss_ops)
# The following operations are for calculating the accuracy of the child network
pred_ops = tf.nn.softmax(logits, name="preds")
correct = tf.equal(tf.argmax(pred_ops, 1), tf.argmax(labels, 1), name="correct")
accuracy_ops = tf.reduce_mean(tf.cast(correct, tf.float32), name="accuracy")
initializer = tf.global_variables_initializer()
接着我们训练子网络。请注意,在调用sess.run(...)时,我们不再传递feed_dict参数。相反,我们只是调用想要运行的操作(loss_ops、train_ops和accuracy_ops)。这是因为输入已经在子网络的计算图中以张量的形式表示:
# Training
sess.run(initializer)
sess.run(train_init_ops)
logger.info("Training child CNN {} for {} epochs".format(child_id, child_network_params["max_epochs"]))
for epoch_idx in range(child_network_params["max_epochs"]):
    avg_loss, avg_acc = [], []
    for batch_idx in range(num_train_batches):
        loss, _, accuracy = sess.run([loss_ops, train_ops, accuracy_ops])
        avg_loss.append(loss)
        avg_acc.append(accuracy)
    logger.info("\tEpoch {}:\tloss - {:.6f}\taccuracy - {:.3f}".format(epoch_idx,
                                                                       np.mean(avg_loss), np.mean(avg_acc)))
训练完成后,我们计算验证精度并返回:
    # Validate and return reward
    logger.info("Finished training, now calculating validation accuracy")
    sess.run(valid_init_ops)
    avg_val_loss, avg_val_acc = [], []
    for batch_idx in range(num_valid_batches):
        valid_loss, valid_accuracy = sess.run([loss_ops, accuracy_ops])
        avg_val_loss.append(valid_loss)
        avg_val_acc.append(valid_accuracy)
    logger.info("Valid loss - {:.6f}\tValid accuracy - {:.3f}".format(np.mean(avg_val_loss),
                                                                      np.mean(avg_val_acc)))
return np.mean(avg_val_acc)
最后,我们实现了一个用于训练 Controller 的方法。由于计算资源的限制,我们不会并行化训练过程(即每个 Controller 周期内并行训练m个子网络)。相反,我们会顺序生成这些子网络,并跟踪它们的均值验证精度。
train_controller 方法
train_controller方法在构建 Controller 之后被调用。因此,第一步是初始化所有变量和初始状态:
def train_controller(self):
    with self.graph.as_default():
        self.sess.run(tf.global_variables_initializer())
    step = 0
    total_rewards = 0
    child_network_architecture = np.array([[10.0, 128.0, 1.0, 1.0] *
                                           controller_params['max_layers']], dtype=np.float32)
第一个child_network_architecture是一个类似架构配置的列表,将作为参数传递给NASCell,从而输出第一个子网络 DNA。
训练过程由两个for循环组成:一个是 Controller 的周期数,另一个是每个 Controller 周期内生成的子网络数。在内层for循环中,我们使用NASCell生成新的child_network_architecture,并基于它训练一个子网络以获得验证精度:
for episode in range(controller_params['max_episodes']):
    logger.info('=============> Episode {} for Controller'.format(episode))
    step += 1
    episode_reward_buffer = []
    for sub_child in range(controller_params["num_children_per_episode"]):
        # Generate a child network architecture
        child_network_architecture = self.generate_child_network(child_network_architecture)[0]
        if np.any(np.less_equal(child_network_architecture, 0.0)):
            reward = -1.0
        else:
            reward = self.train_child_network(cnn_dna=child_network_architecture,
                                              child_id='child/{}'.format("{}_{}".format(episode, sub_child)))
        episode_reward_buffer.append(reward)
在获得m次验证精度后,我们使用均值奖励和相对于上一个子网络 DNA 计算出的梯度来更新 Controller。同时,我们还会记录过去的均值奖励。通过之前实现的ema方法,我们计算出基准值,并将其从最新的均值奖励中减去。然后我们调用self.sess.run([self.train_op, self.total_loss]...)来更新 Controller 并计算 Controller 的损失:
mean_reward = np.mean(episode_reward_buffer)
self.reward_history.append(mean_reward)
self.architecture_history.append(child_network_architecture)
total_rewards += mean_reward
child_network_architecture = np.array(self.architecture_history[-step:]).ravel() / self.divison_rate
child_network_architecture = child_network_architecture.reshape((-1, self.num_cell_outputs))
baseline = ema(self.reward_history)
last_reward = self.reward_history[-1]
rewards = [last_reward - baseline]
logger.info("Buffers before loss calculation")
logger.info("States: {}".format(child_network_architecture))
logger.info("Rewards: {}".format(rewards))
with self.graph.as_default():
    _, loss = self.sess.run([self.train_op, self.total_loss],
                            {self.child_network_architectures: child_network_architecture,
                             self.discounted_rewards: rewards})
logger.info('Episode: {} | Loss: {} | DNA: {} | Reward : {}'.format(
    episode, loss, child_network_architecture.ravel(), mean_reward))
就是这样!你可以在主 GitHub 仓库中找到controller.py的完整实现。
测试 ChildCNN
既然我们已经实现了child_network和controller,接下来就可以通过我们的Controller测试ChildCNN的训练,使用自定义的子网络配置。我们希望确保,在合理的架构下,ChildCNN能够充分学习。
要做到这一点,首先打开你最喜欢的终端,并启动一个 Jupyter 控制台:
$ ipython
Python 3.6.4 (default, Jan 6 2018, 11:49:38)
Type 'copyright', 'credits' or 'license' for more information
IPython 6.4.0 -- An enhanced Interactive Python. Type '?' for help.
我们首先配置日志记录器,这样就能在终端上看到输出:
In [1]: import sys
In [2]: import logging
In [3]: logging.basicConfig(stream=sys.stdout,
   ...: level=logging.DEBUG,
   ...: format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s')
   ...:
In [4]:
接下来,我们从controller.py导入Controller类:
In [4]: import numpy as np
In [5]: from controller import Controller
In [6]:
然后,我们手工设计一些子网络架构,并将其传递给 Controller 的train_child_network函数:
In [7]: dna = np.array([[3, 1, 30, 2], [3, 1, 30, 2], [3, 1, 40, 2]])
最后,我们实例化我们的Controller并调用train_child_network方法:
In [8]: controller = Controller()
...
2018-09-16 01:58:54,978 controller INFO Successfully built controller
In [9]: controller.train_child_network(dna, "test")
2018-09-16 01:58:59,208 controller INFO Training with dna: [[ 3 1 30 2]
 [ 3 1 30 2]
 [ 3 1 40 2]]
2018-09-16 01:58:59,605 cifar10_processor INFO Dividing pixels by 255
2018-09-16 01:59:01,289 cifar10_processor INFO Loaded data from keras
2018-09-16 01:59:03,150 child_network INFO DNA is: [[3, 1, 30, 2], [3, 1, 30, 2], [3, 1, 40, 2]]
2018-09-16 01:59:14,270 controller INFO Training child CNN first for 1000 epochs
如果成功,经过若干轮训练后,你应该会看到不错的准确度:
2018-09-16 06:25:01,927 controller INFO Epoch 436: loss - 1.119608 accuracy - 0.663
2018-09-16 06:25:19,310 controller INFO Epoch 437: loss - 0.634937 accuracy - 0.724
2018-09-16 06:25:36,438 controller INFO Epoch 438: loss - 0.769766 accuracy - 0.702
2018-09-16 06:25:53,413 controller INFO Epoch 439: loss - 0.760520 accuracy - 0.711
2018-09-16 06:26:10,530 controller INFO Epoch 440: loss - 0.606741 accuracy - 0.812
config.py
config.py模块包含了 Controller 和子网络使用的配置。在这里,你可以调整多个训练参数,比如训练轮数、学习率以及 Controller 每个 epoch 生成的子网络数量。你还可以尝试调整子网络的大小,但请注意,子网络越大,训练所需的时间就越长,包括 Controller 和子网络的训练时间:
child_network_params = {
    "learning_rate": 3e-5,
    "max_epochs": 100,
    "beta": 1e-3,
    "batch_size": 20
}
controller_params = {
    "max_layers": 3,
    "components_per_layer": 4,
    'beta': 1e-4,
    'max_episodes': 2000,
    "num_children_per_episode": 10
}
这些数字中的一些(例如max_episodes)是任意选择的。我们鼓励读者调整这些数字,以理解它们如何影响 Controller 和子网络的训练。
train.py
这个train.py模块充当我们训练 Controller 的顶层入口:
import logging
import sys
from .controller import Controller
if __name__ == '__main__':
    # Configure the logger
    logging.basicConfig(stream=sys.stdout,
                        level=logging.DEBUG,
                        format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s')
    controller = Controller()
    controller.train_controller()
就这样;一个生成其他神经网络的神经网络!确保你的实现有以下目录结构:
src
|-- __init__.py
|-- child_network.py
|-- cifar10_processor.py
|-- config.py
|-- constants.py
|-- controller.py
`-- train.py
要执行训练,只需运行以下命令:
$ python train.py
如果一切顺利,你应该会看到如下输出:
2018-09-16 04:13:45,484 src.controller INFO Successfully built controller 
2018-09-16 04:13:45,542 src.controller INFO =============> Episode 0 for Controller 
2018-09-16 04:13:45,952 src.controller INFO Training with dna: [[ 2 10 2 4 1 1 12 14 7 1 1 1]] 2018-09-16 04:13:45.953482: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1484] Adding visible gpu devices: 0 
2018-09-16 04:13:45.953530: I tensorflow/core/common_runtime/gpu/gpu_device.cc:965] Device interconnect StreamExecutor with strength 1 edge matrix: 
2018-09-16 04:13:45.953543: I tensorflow/core/common_runtime/gpu/gpu_device.cc:971] 0 
2018-09-16 04:13:45.953558: I tensorflow/core/common_runtime/gpu/gpu_device.cc:984] 0: N 
2018-09-16 04:13:45.953840: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1097] Created TensorFlow device (/job:localhost/replica:0/task:0/device:GPU:0 wi th 21618 MB memory) -> physical GPU (device: 0, name: Tesla M40 24GB, pci bus id: 0000:03:00.0, compute capability: 5.2) 
2018-09-16 04:13:47,143 src.cifar10_processor INFO Dividing pixels by 255 
2018-09-16 04:13:55,119 src.cifar10_processor INFO Loaded data from keras 
2018-09-16 04:14:09,050 src.child_network INFO DNA is: [[2, 10, 2, 4], [1, 1, 12, 14], [7, 1, 1, 1]] 
2018-09-16 04:14:21,326 src.controller INFO Training child CNN child/0_0 for 100 epochs 
2018-09-16 04:14:32,830 src.controller INFO Epoch 0: loss - 2.351300 accuracy - 0.100
2018-09-16 04:14:43,976 src.controller INFO Epoch 1: loss - 2.202928 accuracy - 0.180 
2018-09-16 04:14:53,412 src.controller INFO Epoch 2: loss - 2.102713 accuracy - 0.220 
2018-09-16 04:15:03,704 src.controller INFO Epoch 3: loss - 2.092676 accuracy - 0.232 
2018-09-16 04:15:14,349 src.controller INFO Epoch 4: loss - 2.092633 accuracy - 0.240
你应该会看到每个子网络架构的 CIFAR-10 训练日志中的日志语句。在 CIFAR-10 训练过程中,我们会打印每一轮的损失和准确度,以及返回给 Controller 的验证准确度。
额外的练习
在这一部分,我们实现了适用于CIFAR-10数据集的 NAS 框架。虽然这是一个很好的开始,但还有其他功能可以实现,我们将其留给读者作为练习:
- 
我们如何让 Controller 创建能够解决其他领域问题的子网络,例如文本和语音识别?
 - 
我们如何让 Controller 并行训练多个子网络,以加快训练过程?
 - 
我们如何使用 TensorBoard 可视化训练过程?
 - 
我们如何让 Controller 设计包含残差连接的子网络?
 
其中一些练习可能需要对代码库做出显著修改,但对加深你对 NAS 的理解是有帮助的。我们强烈推荐尝试这些练习!
NAS 的优势
NAS 最大的优势在于无需花费大量时间为特定问题设计神经网络。这也意味着即使不是数据科学家的人,只要能够准备数据,也能创建机器学习代理。事实上,谷歌已经将这个框架产品化为 Cloud AutoML,允许任何人以最小的努力训练定制化的机器学习模型。根据谷歌的说法,Cloud AutoML 提供了以下优势:
- 
用户只需与简单的图形界面交互即可创建机器学习模型。
 - 
如果用户的数据集尚未标注,他们可以让 Cloud AutoML 为其数据集添加标注。这与亚马逊的 Mechanical Turk 服务类似。
 - 
由 Cloud AutoML 生成的模型保证具有高准确性和快速性能。
 - 
一个简单的端到端管道,用于上传数据、训练和验证模型、部署模型以及创建用于获取预测的 REST 端点。
 
当前,Cloud AutoML 可用于图像分类/检测、自然语言处理(文本分类)和翻译。
欲了解更多关于 Cloud AutoML 的信息,请访问他们的官方网站:cloud.google.com/automl/
NAS 的另一个优势是能够生成比人工设计的模型更紧凑的模型。根据 Hieu Pham 等人所著的《通过参数共享实现高效的神经架构搜索》一文,最新的最先进的CIFAR-10分类神经网络有 2620 万个参数,而一个 NAS 生成的神经网络,其测试准确率与人工设计的网络相当(人工设计网络为 97.44%,NAS 生成网络为 97.35%),但只有 330 万个参数。值得注意的是,像 VGG16、ResNet50 和 InceptionV3 这样的旧模型,分别有 1.38 亿、2500 万和 2300 万个参数。参数规模的大幅减少使得推理时间和模型存储更加高效,这两者在将模型部署到生产环境时都非常重要。
总结
在本章中,我们实现了 NAS,这是一个框架,其中强化学习代理(控制器)生成子神经网络来完成特定任务。我们研究了控制器如何通过策略梯度方法学习生成更好的子网络架构的理论。接着,我们实现了一个简化版本的 NAS,该版本生成能够学习分类CIFAR-10图像的子网络。
欲了解更多相关话题,请参考以下链接列表:
- 
通过强化学习实现的 NAS:
arxiv.org/abs/1611.01578 - 
高效的 NAS 通过参数共享:
arxiv.org/pdf/1802.03268 - 
Google Cloud AutoML:
cloud.google.com/automl/ - 
极棒的架构搜索——一个关于生成神经网络的论文精选列表:
github.com/markdtw/awesome-architecture-search 
NAS 框架标志着深度学习领域的一个令人兴奋的发展,因为我们已经弄清楚了如何自动设计神经网络架构,这一决定以前是由人类做出的。现在已经有了改进版的 NAS 和其他能够自动生成神经网络的算法,我们鼓励读者也去了解这些内容。
第九章:预测未来股价
金融市场是任何经济的重要组成部分。一个经济要繁荣,其金融市场必须稳固。自从机器学习的出现以来,许多公司开始在股票和其他金融资产的购买中采用算法交易。这个方法已被证明是成功的,并且随着时间的推移而变得更加重要。由于其迅速崛起,已经开发并采用了多种机器学习模型来进行算法交易。一种流行的用于交易的机器学习模型是时间序列分析。你已经学习过强化学习和 Keras,在本章中,它们将被用来开发一个可以预测股价的模型。
背景问题
自动化几乎渗透到了每个行业,金融市场也不例外。创建自动化的算法交易模型可以在购买前对股票进行更快速、更准确的分析。多个指标可以以人类无法达到的速度进行分析。而且,在交易中,情绪化的操作是危险的。机器学习模型可以解决这个问题。同时,交易成本也减少了,因为不再需要持续的监督。
在本教程中,你将学习如何将强化学习与时间序列建模结合起来,以便基于真实数据预测股票价格。
使用的数据
我们将使用的数据是标准普尔 500 指数。根据维基百科,它是 基于 500 家大型公司的市值的美国股票市场指数,这些公司在纽约证券交易所或纳斯达克上市并拥有普通股。 这里是数据的链接(ca.finance.yahoo.com/quote/%255EGSPC/history?p=%255EGSPC)。
数据包含以下列:
- 
日期:这表示考虑的日期
 - 
开盘价:这表示当天市场开盘时的价格
 - 
最高价:这表示当天市场的最高价格
 - 
低价:这表示当天市场的最低价格
 - 
收盘价:这表示当天市场收盘时的价格,经过拆股调整
 - 
调整后收盘价:这表示经过拆股和分红调整后的收盘价
 - 
交易量:这表示可用的股票总量
 
用于训练数据的日期如下:
Start: 14 August 2006
End: 13th August 2015
在网站上,按以下方式筛选日期,并下载数据集:

对于测试,我们将使用以下日期范围:
Start: 14 August 2015
End: 14 August 2018
相应地更改网站上的日期,并下载用于测试的数据集,如下所示:

在接下来的部分,我们将定义一些代理可以执行的可能操作。
分步指南
我们的解决方案使用了一个基于演员-评论员的强化学习模型,并结合了时间序列,帮助我们基于股票价格预测最佳操作。可能的操作如下:
- 
持有:这意味着根据价格和预期利润,交易者应该持有股票。
 - 
卖出:这意味着根据价格和预期利润,交易者应该卖出股票。
 - 
购买:这意味着根据价格和预期利润,交易者应该购买股票。
 
演员-评论员网络是一类基于两个交互网络模型的强化学习方法。这些模型有两个组成部分:演员和评论员。在我们的案例中,我们将使用神经网络作为网络模型。我们将使用你已经学过的 Keras 包来创建神经网络。我们希望改进的奖励函数是利润。
演员接受环境状态,然后返回最佳动作,或者返回一个指向动作的概率分布的策略。这似乎是执行强化学习的一种自然方式,因为策略是作为状态的函数直接返回的。
评论员评估由演员网络返回的动作。这类似于传统的深度 Q 网络;在环境状态和一个动作下,返回一个分数,表示在给定状态下采取该动作的价值。评论员的工作是计算一个近似值,然后用它来根据梯度更新演员。评论员本身是通过时序差分算法进行训练的。
这两个网络是同时训练的。随着时间的推移,评论员网络能够改善其Q_value预测,演员也学会了如何根据状态做出更好的决策。
这个解决方案由五个脚本组成,它们将在接下来的章节中描述。
演员脚本
演员脚本是在这里定义策略模型的。我们首先从 Keras 中导入某些模块:layers、optimizers、models 和 backend。这些模块将帮助我们构建神经网络:让我们从导入 Keras 的所需函数开始。
from keras import layers, models, optimizers
from keras import backend as K
- 我们创建了一个名为
Actor的类,它的对象接受state和action大小的参数: 
class Actor:
  # """Actor (policy) Model. """
    def __init__(self, state_size, action_size):
        self.state_size = state_size
        self.action_size = action_size
- 上述代码展示了状态大小,表示每个状态的维度,以及动作大小,表示动作的维度。接下来,调用一个函数来构建模型,如下所示:
 
        self.build_model()
- 构建一个策略模型,将状态映射到动作,并从定义输入层开始,如下所示:
 
    def build_model(self):
        states = layers.Input(shape=(self.state_size,), name='states')         
- 向模型中添加隐藏层。共有两层密集层,每一层后面跟着批归一化和激活层。这些密集层是正则化的。两层分别有 16 个和 32 个隐藏单元:
 
        net = layers.Dense(units=16,kernel_regularizer=layers.regularizers.l2(1e-6))(states)
        net = layers.BatchNormalization()(net)
        net = layers.Activation("relu")(net)
        net = layers.Dense(units=32,kernel_regularizer=layers.regularizers.l2(1e-6))(net)
        net = layers.BatchNormalization()(net)
        net = layers.Activation("relu")(net)
- 最终的输出层将预测具有
softmax激活函数的动作概率: 
        actions = layers.Dense(units=self.action_size, activation='softmax', name = 'actions')(net)
        self.model = models.Model(inputs=states, outputs=actions)
- 通过使用动作值(
Q_value)的梯度来定义损失函数,如下所示: 
        action_gradients = layers.Input(shape=(self.action_size,))
        loss = K.mean(-action_gradients * actions)
- 定义
optimizer和训练函数,如下所示: 
        optimizer = optimizers.Adam(lr=.00001)
        updates_op = optimizer.get_updates(params=self.model.trainable_weights, loss=loss)
        self.train_fn = K.function(
            inputs=[self.model.input, action_gradients, K.learning_phase()],
            outputs=[],
            updates=updates_op)
演员网络的自定义训练函数,利用与动作概率相关的 Q 梯度进行训练。通过这个自定义函数,训练的目标是最大化利润(换句话说,最小化Q_values的负值)。
评论家脚本
我们首先导入 Keras 的一些模块:layers、optimizers、models 和 backend。这些模块将帮助我们构建神经网络:
from keras import layers, models, optimizers
from keras import backend as K
- 我们创建了一个名为
Critic的类,其对象接收以下参数: 
class Critic:
    """Critic (Value) Model."""
    def __init__(self, state_size, action_size):
        """Initialize parameters and build model.
        Params
        ======
            state_size (int): Dimension of each state
            action_size (int): Dimension of each action
        """
        self.state_size = state_size
        self.action_size = action_size
        self.build_model()
- 构建一个评论家(价值)网络,它将
state和action对(Q_values)映射,并定义输入层,如下所示: 
    def build_model(self):
        states = layers.Input(shape=(self.state_size,), name='states')
        actions = layers.Input(shape=(self.action_size,), name='actions')
- 为状态路径添加隐藏层,如下所示:
 
        net_states = layers.Dense(units=16,kernel_regularizer=layers.regularizers.l2(1e-6))(states)
        net_states = layers.BatchNormalization()(net_states)
        net_states = layers.Activation("relu")(net_states)
        net_states = layers.Dense(units=32, kernel_regularizer=layers.regularizers.l2(1e-6))(net_states)
- 为动作路径添加隐藏层,如下所示:
 
        net_actions = layers.Dense(units=32,kernel_regularizer=layers.regularizers.l2(1e-6))(actions)
- 合并状态路径和动作路径,如下所示:
 
        net = layers.Add()([net_states, net_actions])
        net = layers.Activation('relu')(net)
- 添加最终输出层,以产生动作值(
Q_values): 
        Q_values = layers.Dense(units=1, name='q_values',kernel_initializer=layers.initializers.RandomUniform(minval=-0.003, maxval=0.003))(net)
- 创建 Keras 模型,如下所示:
 
        self.model = models.Model(inputs=[states, actions], outputs=Q_values)
- 定义
optimizer并编译一个模型以进行训练,使用内置的损失函数: 
        optimizer = optimizers.Adam(lr=0.001)
        self.model.compile(optimizer=optimizer, loss='mse')
- 计算动作梯度(
Q_values相对于actions的导数): 
        action_gradients = K.gradients(Q_values, actions)
- 定义一个附加函数来获取动作梯度(供演员模型使用),如下所示:
 
        self.get_action_gradients = K.function(
            inputs=[*self.model.input, K.learning_phase()],
            outputs=action_gradients)
到此为止,评论家脚本已完成。
代理脚本
在这一部分,我们将训练一个代理,该代理将基于演员和评论家网络执行强化学习。我们将执行以下步骤以实现此目标:
- 
创建一个代理类,其初始化函数接收批次大小、状态大小和一个评估布尔函数,用于检查训练是否正在进行中。
 - 
在代理类中,创建以下方法:
 - 
导入
actor和critic脚本: 
from actor import Actor
from critic import Critic
- 导入
numpy、random、namedtuple和deque,这些模块来自collections包: 
import numpy as np
from numpy.random import choice
import random
from collections import namedtuple, deque
- 创建一个
ReplayBuffer类,该类负责添加、抽样和评估缓冲区: 
class ReplayBuffer:
    #Fixed sized buffer to stay experience tuples
    def __init__(self, buffer_size, batch_size):
    #Initialize a replay buffer object.
    #parameters
    #buffer_size: maximum size of buffer. Batch size: size of each batch
        self.memory = deque(maxlen = buffer_size)  #memory size of replay buffer
        self.batch_size = batch_size               #Training batch size for Neural nets
        self.experience = namedtuple("Experience", field_names = ["state", "action", "reward", "next_state", "done"])                                           #Tuple containing experienced replay
- 向重放缓冲区记忆中添加一个新的经验:
 
    def add(self, state, action, reward, next_state, done):
        e = self.experience(state, action, reward, next_state, done)
        self.memory.append(e)
- 随机从记忆中抽取一批经验元组。在以下函数中,我们从记忆缓冲区中随机抽取状态。这样做是为了确保我们输入模型的状态在时间上没有相关性,从而减少过拟合:
 
    def sample(self, batch_size = 32):
        return random.sample(self.memory, k=self.batch_size)
- 返回当前缓冲区内存的大小,如下所示:
 
    def __len__(self):
        return len(self.memory)
- 使用演员-评论家网络进行学习的强化学习代理如下所示:
 
class Agent:
    def __init__(self, state_size, batch_size, is_eval = False):
        self.state_size = state_size #
- 动作数量定义为 3:坐下、买入、卖出
 
        self.action_size = 3 
- 定义重放记忆的大小:
 
        self.buffer_size = 1000000
        self.batch_size = batch_size
        self.memory = ReplayBuffer(self.buffer_size, self.batch_size)
        self.inventory = []
- 定义训练是否正在进行中的变量。在训练和评估阶段,此变量将会变化:
 
        self.is_eval = is_eval    
- Bellman 方程中的折扣因子:
 
        self.gamma = 0.99        
- 可以通过以下方式对演员和评论家网络进行软更新:
 
        self.tau = 0.001   
- 演员策略模型将状态映射到动作,并实例化演员网络(本地和目标模型,用于软更新参数):
 
        self.actor_local = Actor(self.state_size, self.action_size) 
        self.actor_target = Actor(self.state_size, self.action_size)    
- 评论家(价值)模型,它将状态-动作对映射到
Q_values,如下所示: 
        self.critic_local = Critic(self.state_size, self.action_size)
- 实例化评论模型(使用本地和目标模型以允许软更新),如下所示:
 
        self.critic_target = Critic(self.state_size, self.action_size)    
        self.critic_target.model.set_weights(self.critic_local.model.get_weights()) 
- 以下代码将目标模型参数设置为本地模型参数:
 
      self.actor_target.model.set_weights(self.actor_local.model.get_weights()
- 给定一个状态,使用演员(策略网络)和演员网络的
softmax层输出返回一个动作,返回每个动作的概率。一个返回给定状态的动作的方法如下: 
 def act(self, state):
        options = self.actor_local.model.predict(state) 
        self.last_state = state
        if not self.is_eval:
            return choice(range(3), p = options[0])     
        return np.argmax(options[0])
- 基于训练模型中的动作概率返回随机策略,并在测试期间返回与最大概率对应的确定性动作。智能体在每一步执行的动作集如下所示:
 
    def step(self, action, reward, next_state, done):
- 以下代码向记忆中添加一个新经验:
 
        self.memory.add(self.last_state, action, reward, next_state, 
          done) 
- 以下代码断言记忆中有足够的经验以进行训练:
 
        if len(self.memory) > self.batch_size:               
- 以下代码从记忆中随机抽取一批样本进行训练:
 
       experiences = self.memory.sample(self.batch_size)
- 从采样的经验中学习,如下所示:
 
        self.learn(experiences)                                 
- 以下代码将状态更新到下一个状态:
 
        self.last_state = next_state                   
- 通过演员和评论者从采样的经验中学习。创建一个方法,通过演员和评论者从采样的经验中学习,如下所示:
 
    def learn(self, experiences):               
        states = np.vstack([e.state for e in experiences if e is not None]).astype(np.float32).reshape(-1,self.state_size)    
        actions = np.vstack([e.action for e in experiences if e is not None]).astype(np.float32).reshape(-1,self.action_size)
        rewards = np.array([e.reward for e in experiences if e is not None]).astype(np.float32).reshape(-1,1)
        dones = np.array([e.done for e in experiences if e is not None]).astype(np.float32).reshape(-1,1)
        next_states = np.vstack([e.next_state for e in experiences if e is not None]).astype(np.float32).reshape(-1,self.state_size) 
- 返回回放组件中每个经验的独立数组,并基于下一个状态预测动作,如下所示:
 
        actions_next = self.actor_target.model.predict_on_batch(next_states)    
- 预测演员输出的
Q_value,用于下一个状态,如下所示: 
        Q_targets_next = self.critic_target.model.predict_on_batch([next_states, actions_next])  
- 以基于时间差的
Q_value作为评论网络的标签,如下所示: 
        Q_targets = rewards + self.gamma * Q_targets_next * (1 - dones)   
- 将评论模型拟合到目标的时间差,如下所示:
 
        self.critic_local.model.train_on_batch(x = [states, actions], y = Q_targets) 
- 使用评论网络输出关于动作概率的梯度训练演员模型(本地模型):
 
        action_gradients = np.reshape(self.critic_local.get_action_gradients([states, actions, 0]),(-1, self.action_size))
- 接下来,定义一个自定义的训练函数,如下所示:
 
        self.actor_local.train_fn([states, action_gradients, 1])  
- 接下来,初始化两个网络的参数软更新,如下所示:
 
        self.soft_update(self.actor_local.model, self.actor_target.model)
- 该方法根据参数
tau对模型参数进行软更新,以避免模型发生剧烈变化。通过执行基于参数tau的软更新来更新模型(以避免剧烈的模型变化)的方法如下: 
    def soft_update(self, local_model, target_model):
        local_weights = np.array(local_model.get_weights())
        target_weights = np.array(target_model.get_weights())
        assert len(local_weights) == len(target_weights)
        new_weights = self.tau * local_weights + (1 - self.tau) * target_weights
        target_model.set_weights(new_weights)
这结束了智能体脚本。
辅助脚本
在这个脚本中,我们将通过以下步骤创建一些有助于训练的函数:
- 导入
numpy和math模块,如下所示: 
import numpy as np
import math
- 接下来,定义一个将价格格式化为两位小数的函数,以减少数据的歧义性:
 
def formatPrice(n):
    if n>=0:
        curr = "$"
    else:
        curr = "-$"
    return (curr +"{0:.2f}".format(abs(n)))
- 从 CSV 文件中返回一个股票数据向量。将数据中的收盘股价转换为向量,并返回所有股价的向量,如下所示:
 
def getStockData(key):
    datavec = []
    lines = open("data/" + key + ".csv", "r").read().splitlines()
    for line in lines[1:]:
        datavec.append(float(line.split(",")[4]))
    return datavec
- 接下来,定义一个函数来根据输入向量生成状态。通过生成上一阶段创建的向量中的状态来创建时间序列。这个函数有三个参数:数据;时间 t(你想预测的日期);以及窗口(回溯多少天)。然后,将衡量这些向量之间的变化率,并基于 sigmoid 函数:
 
def getState(data, t, window):    
    if t - window >= -1:
        vec = data[t - window+ 1:t+ 1]
    else: 
        vec = -(t-window+1)*[data[0]]+data[0: t + 1]
    scaled_state = []
    for i in range(window - 1):
- 接下来,用 sigmoid 函数将状态向量从 0 到 1 进行缩放。sigmoid 函数可以将任何输入值映射到 0 到 1 范围内。这有助于将值标准化为概率:
 
        scaled_state.append(1/(1 + math.exp(vec[i] - vec[i+1])))  
    return np.array([scaled_state])
所有必要的函数和类现在都已定义,因此我们可以开始训练过程。
训练数据
我们将基于代理和辅助方法来训练数据。这将为我们提供三种操作之一,基于当天股票价格的状态。这些状态可以是买入、卖出或保持。在训练过程中,将预测每一天的预定操作,并计算该操作的价格(利润、损失或不变)。在训练期结束时,将计算累计总和,我们将看到是否有盈利或亏损。目标是最大化总利润。
让我们从导入开始,如下所示:
from agent import Agent
from helper import getStockData, getState
import sys
- 接下来,定义要考虑的市场交易天数作为窗口大小,并定义神经网络训练的批量大小,如下所示:
 
window_size = 100                         
batch_size = 32
- 使用窗口大小和批量大小实例化股票代理,如下所示:
 
agent = Agent(window_size, batch_size)  
- 接下来,从 CSV 文件中读取训练数据,使用辅助函数:
 
data = getStockData("^GSPC")
l = len(data) - 1
- 接下来,将回合数定义为
300。代理将在数据上查看这么多次。一个回合表示对数据的完整遍历: 
episode_count = 300
- 接下来,我们可以开始遍历回合,如下所示:
 
for e in range(episode_count):
    print("Episode " + str(e) + "/" + str(episode_count))
- 每个回合必须以基于数据和窗口大小的状态开始。库存初始化后,再遍历数据:
 
    state = getState(data, 0, window_size + 1)
    agent.inventory = []
    total_profit = 0
    done = False
- 接下来,开始遍历每一天的股票数据。基于
state,代理预测动作的概率: 
    for t in range(l):
        action = agent.act(state)
        action_prob = agent.actor_local.model.predict(state)
        next_state = getState(data, t + 1, window_size + 1)
        reward = 0
- 如果代理决定不对股票进行任何操作,则
action可以保持不变。另一个可能的操作是买入(因此,股票将被加入库存),如下所示: 
        if action == 1:
            agent.inventory.append(data[t])
            print("Buy:" + formatPrice(data[t]))
- 如果
action为2,代理卖出股票并将其从库存中移除。根据销售情况,计算利润(或损失): 
        elif action == 2 and len(agent.inventory) > 0:  # sell
            bought_price = agent.inventory.pop(0)
            reward = max(data[t] - bought_price, 0)
            total_profit += data[t] - bought_price
            print("sell: " + formatPrice(data[t]) + "| profit: " + 
              formatPrice(data[t] - bought_price))
        if t == l - 1:
            done = True
        agent.step(action_prob, reward, next_state, done)
        state = next_state
        if done:
            print("------------------------------------------")
            print("Total Profit: " + formatPrice(total_profit))
            print("------------------------------------------")
- 在训练过程中,你可以看到类似下面的日志。股票在特定的价格下被买入和卖出:
 
sell: $2102.15| profit: $119.30
sell: $2079.65| profit: $107.36
Buy:$2067.64
sell: $2108.57| profit: $143.75
Buy:$2108.63
Buy:$2093.32
Buy:$2099.84
Buy:$2083.56
Buy:$2077.57
Buy:$2104.18
sell: $2084.07| profit: $115.18
sell: $2086.05| profit: $179.92
------------------------------------------
Total Profit: $57473.53
- 接下来,从 CSV 文件中读取测试数据。初始状态由数据推断得出。这些步骤与训练过程中的单一回合非常相似:
 
test_data = getStockData("^GSPC Test")
l_test = len(test_data) - 1
state = getState(test_data, 0, window_size + 1)
- 利润从
0开始。代理初始化时库存为零,并且处于测试模式: 
total_profit = 0
agent.inventory = []
agent.is_eval = False
done = False
- 接下来,迭代交易的每一天,代理可以根据数据做出决策。每天,代理决定一个动作。根据动作,股票会被持有、卖出或买入:
 
for t in range(l_test):
    action = agent.act(state)
- 如果操作是
0,则没有交易。在这期间,状态可以被称为持有。 
    next_state = getState(test_data, t + 1, window_size + 1)
    reward = 0
- 如果操作是
1,则通过将股票加入库存来购买股票,操作如下: 
    if action == 1:  
        agent.inventory.append(test_data[t])
        print("Buy: " + formatPrice(test_data[t]))
- 如果操作是
2,代理通过将股票从库存中移除来卖出股票。价格差异被记录为利润或亏损: 
    elif action == 2 and len(agent.inventory) > 0: 
        bought_price = agent.inventory.pop(0)
        reward = max(test_data[t] - bought_price, 0)
        total_profit += test_data[t] - bought_price
        print("Sell: " + formatPrice(test_data[t]) + " | profit: " + formatPrice(test_data[t] - bought_price))
    if t == l_test - 1:
        done = True
    agent.step(action_prob, reward, next_state, done)
    state = next_state
    if done:
        print("------------------------------------------")
        print("Total Profit: " + formatPrice(total_profit))
        print("------------------------------------------")
- 一旦脚本开始运行,模型将随着训练逐步变得更好。你可以查看日志,内容如下:
 
Sell: $2818.82 | profit: $44.80
Sell: $2802.60 | profit: $4.31
Buy: $2816.29
Sell: $2827.22 | profit: $28.79
Buy: $2850.40
Sell: $2857.70 | profit: $53.21
Buy: $2853.58
Buy: $2833.28
------------------------------------------
Total Profit: $10427.24
模型已进行了交易,总利润为$10,427。请注意,这种交易方式不适合现实世界,因为交易涉及更多成本和不确定性;因此,这种交易风格可能会产生不利影响。
最终结果
在训练数据之后,我们用test数据集对模型进行了测试。我们的模型总共获得了$10427.24的利润。模型的最佳之处在于,利润随着时间的推移不断增加,表明它正在有效学习并采取更好的行动。
总结
总之,机器学习可以应用于多个行业,并且在金融市场中可以非常高效地应用,正如你在本章中所见。我们可以结合不同的模型,正如我们结合了强化学习和时间序列,以生成更强大的模型来满足我们的用例。我们讨论了使用强化学习和时间序列来预测股市。我们使用了一个演员-评论家模型,根据股票价格的状态来决定最佳行动,目的是最大化利润。最终,我们获得了一个结果,展示了总体利润,并且利润随时间增加,表明代理在每个状态中学得更多。
在下一章中,你将学习未来的工作领域。
第十章:展望未来
在过去的几百页中,我们面临了许多挑战,并应用了强化学习和深度学习算法。为了总结我们的强化学习(RL)之旅,本章将探讨我们尚未涵盖的该领域的几个方面。我们将从讨论强化学习的几个缺点开始,任何从业者或研究人员都应该对此有所了解。为了以积极的语气结束,我们将描述该领域近年来所看到的许多令人兴奋的学术进展和成就。
强化学习的缺点
到目前为止,我们只讨论了强化学习算法能做什么。对于读者而言,强化学习可能看起来是解决各种问题的灵丹妙药。但为什么我们在现实生活中并没有看到强化学习算法的广泛应用呢?现实情况是,该领域存在许多缺点,阻碍了其商业化应用。
为什么有必要谈论该领域的缺陷?我们认为这将帮助你建立一个更全面、更客观的强化学习观念。此外,理解强化学习和机器学习的弱点是一个优秀的机器学习研究员或从业者的重要素质。在接下来的小节中,我们将讨论强化学习目前面临的一些最重要的局限性。
资源效率
当前的深度强化学习算法需要大量的时间、训练数据和计算资源,才能达到理想的熟练程度。对于像 AlphaGo Zero 这样的算法,它的强化学习算法在没有任何先验知识和经验的情况下学习围棋,资源效率成为了将此类算法推广到商业规模的主要瓶颈。回想一下,当 DeepMind 实现 AlphaGo Zero 时,他们需要在数千万场游戏中使用数百个 GPU 和数千个 CPU 来训练代理。为了让 AlphaGo Zero 达到合理的熟练度,它需要进行数百万场游戏,相当于数十万人的一生所进行的游戏数量。
除非未来普通消费者能够轻松利用像谷歌和英伟达今天所提供的庞大计算能力,否则开发超人类的强化学习算法的能力仍将远远超出公众的掌控。这意味着,强大的、资源密集型的强化学习算法将被少数几家机构垄断,这可能并非一件好事。
因此,在有限资源下使强化学习算法可训练将继续是社区必须解决的重要问题。
可重复性
在众多科学研究领域,一个普遍存在的问题是无法重复学术论文和期刊中所声称的实验结果。在 2016 年《自然》杂志(世界上最著名的科学期刊)进行的一项调查中,70%的受访者表示他们未能重复自己或其他研究者的实验结果。此外,对于无法重复实验结果的态度十分严峻,90%的研究人员认为确实存在可重复性危机。
《自然》报道的原始工作可以在这里找到:www.nature.com/news/1-500-scientists-lift-the-lid-on-reproducibility-1.19970。
尽管这项调查面向多个学科的研究人员,包括生物学和化学,但强化学习也面临类似的问题。在论文《深度强化学习的重要性》(参考文献见本章末尾;你可以在arxiv.org/pdf/1709.06560.pdf查看在线版本)中,Peter Henderson 等人研究了深度强化学习算法的不同配置对实验结果的影响。这些配置包括超参数、随机数生成器的种子以及网络架构。
在极端情况下,他们发现,在对同一个模型进行训练时,使用两组五个不同的随机种子配置,最终得到的两个模型的平均回报存在显著差异。此外,改变其他设置,如 CNN 架构、激活函数和学习率,也对结果产生深远影响。
不一致和无法重复的结果意味着什么呢?随着强化学习和机器学习的应用和普及以接近指数的速度增长,互联网上可自由获取的强化学习算法实现数量也在增加。如果这些实现无法重现它们声称能够达到的结果,这将会在现实应用中引发重大问题和潜在危险。毫无疑问,没有人希望他们的自动驾驶汽车被实现得无法做出一致的决策!
可解释性/可追溯性
我们已经看到,代理的策略可以返回单一的动作或一组可能动作的概率分布,而它的价值函数可以返回某一状态的期望程度。那么,模型如何解释它是如何得出这些预测的呢?随着强化学习变得更加流行并有可能在现实应用中得到广泛应用,将会有越来越大的需求去解释强化学习算法的输出。
今天,大多数先进的强化学习算法都包含深度神经网络,而这些网络目前只能通过一组权重和一系列非线性函数来表示。此外,由于神经网络的高维特性,它们无法提供任何有意义的、直观的输入与相应输出之间的关系,普通人难以理解。因此,深度学习算法通常被称为“黑盒”,因为我们很难理解神经网络内部究竟发生了什么。
为什么强化学习算法需要具有可解释性?假设一辆自动驾驶汽车发生了车祸(假设这只是两辆车之间无害的小碰撞,驾驶员没有受伤)。人类驾驶员可以解释导致事故发生的原因;他们能够说明为什么采取某个特定的操作,以及事故发生时究竟发生了什么。这将帮助执法部门确定事故原因,并可能追究责任。然而,即使我们使用现有的算法创造出一个能够驾驶汽车的智能体,这依然是做不到的。
如果不能解释预测结果,用户和大众将难以信任任何使用机器学习的软件,尤其是在算法需要为做出重要决策负责的应用场景中。这对强化学习算法在实际应用中的普及构成了严重障碍。
易受攻击的风险
深度学习算法在多个任务中展现了惊人的成果,包括计算机视觉、自然语言处理和语音识别。在一些任务中,深度学习已经超越了人类的能力。然而,最近的研究表明,这些算法对攻击极为脆弱。所谓攻击,指的是对输入进行难以察觉的修改,从而导致模型表现出不同的行为。举个例子:

对抗攻击的示意图。通过对图像添加难以察觉的扰动,攻击者可以轻易欺骗深度学习图像分类器。
最右侧的图片是通过将左侧的原始图像和中间的扰动图像相加得到的结果。即便是最准确、表现最好的深度神经网络图像分类器,也无法将右侧的图像识别为山羊,反而将其误判为烤面包机。
这些例子让许多研究人员感到震惊,因为人们没想到深度学习算法如此脆弱,并容易受到此类攻击。这一领域现在被称为对抗性机器学习,随着越来越多的研究者关注深度学习算法的鲁棒性和漏洞,它的知名度和重要性也在迅速提升。
强化学习算法同样无法避免这些结果和攻击。根据 Anay Pattanaik 等人撰写的题为《带有对抗攻击的鲁棒深度强化学习》(arxiv.org/abs/1712.03632)的论文,对抗性攻击强化学习算法可以定义为任何可能的扰动,导致智能体在该状态下采取最差行动的概率增加。例如,我们可以在 Atari 游戏的屏幕上添加噪声,目的是欺骗玩游戏的 RL 智能体做出错误的决策,从而导致更低的分数。
更为严重的应用包括向街道标志添加噪声,以欺骗自动驾驶汽车将 STOP 标志误认为速度限制标志,或让 ATM 识别$100 支票为$1,000,000 支票,甚至欺骗面部识别系统将攻击者的面孔识别为其他用户的面孔。
不用多说,这些漏洞进一步增加了在实际、关乎安全的使用场景中采用深度学习算法的风险。虽然目前已有大量努力在应对对抗性攻击,但深度学习算法要足够强大以适应这些使用场景,仍然有很长的路要走。
强化学习的未来发展
前几节可能为深度学习(DL)和强化学习(RL)描绘了一个严峻的前景。然而,不必感到完全沮丧;事实上,现在正是深度学习和强化学习的激动人心时刻,许多重大的研究进展正在持续塑造该领域,并促使其以飞快的速度发展。随着计算资源和数据的不断增加,扩展和改进深度学习和强化学习算法的可能性也在不断扩展。
解决局限性
首先,前述问题已被研究界认识和承认,正在有多个方向进行解决。在 Pattanaik 等人研究中,作者不仅展示了当前深度强化学习算法容易受到对抗性攻击的影响,还提出了可以使这些算法对这些攻击更具鲁棒性的方法。特别是,通过在经过对抗性扰动的示例上训练深度 RL 算法,模型能够提高其对类似攻击的鲁棒性。这一技术通常被称为对抗训练。
此外,研究界正在积极采取行动解决可复现性问题。ICLR 和 ICML 是机器学习领域两个最大的会议,它们举办了挑战赛,邀请参与者重新实现并重新运行已提交论文中的实验,以复制报告的结果。参与者随后需要通过撰写可复现性报告来批评原始工作,报告应描述问题陈述、实验方法、实施细节、分析以及原始论文的可复现性。该挑战由 Joelle Pineau 和麦吉尔大学组织,旨在促进实验和学术工作的透明度,确保结果的可复现性和完整性。
关于 ICLR 2018 可复现性挑战的更多信息可以在此找到:www.cs.mcgill.ca/~jpineau/ICLR2018-ReproducibilityChallenge.html。同样,关于 ICML 原始可复现性研讨会的信息可以在此找到:sites.google.com/view/icml-reproducibility-workshop/home。
迁移学习
另一个越来越受到关注的重要话题是迁移学习。迁移学习是机器学习中的一种范式,其中在一个任务上训练的模型经过微调后,用于完成另一个任务。
例如,我们可以训练一个模型来识别汽车图像,并使用该模型的权重来初始化一个相同的模型,该模型学习识别卡车。主要的直觉是,通过在一个任务上进行训练学到的某些抽象概念和特征,可以迁移到其他类似的任务。这一思想同样适用于许多强化学习问题。一个学会玩特定 Atari 游戏的智能体应该能够熟练地玩其他 Atari 游戏,而不需要从头开始训练,就像人类一样。
Demis Hassabis,DeepMind 的创始人和深度强化学习的先驱,在最近的一次演讲中提到,迁移学习是实现通用智能的关键。而我认为,成功实现迁移学习的关键在于获取概念性知识,这些知识是从你学习的地方的感知细节中抽象出来的。
Demis Hassabis 的引用和相关演讲可以在此找到:www.youtube.com/watch?v=YofMOh6_WKo
在计算机视觉和自然语言处理领域,已经有多个进展,其中利用从一个领域初始化的知识和先验知识来学习另一个领域的数据。
这在第二领域缺乏数据时尤其有用。被称为少样本或单样本学习,这些技术允许模型即使在数据集较小的情况下,也能很好地学习执行任务,如下图所示:

一个关于少样本学习分类器学习如何为数据量较少的类别划分良好决策边界的示例
强化学习中的少样本学习涉及让智能体在给定任务上达到高水平的熟练度,而不依赖于大量的时间、数据和计算资源。设想一个可以轻松微调以在任何其他视频游戏中表现良好的通用游戏玩家智能体,且使用现成的计算资源;这将使强化学习算法的训练更加高效,从而更易于让更广泛的受众访问。
多智能体强化学习
另一个取得显著进展的有前景领域是多智能体强化学习。与我们之前看到的只有一个智能体做出决策的问题不同,这一主题涉及多个智能体同时并协作地做出决策,以实现共同目标。与此相关的最重要的工作之一是 OpenAI 的 Dota2 对战系统,名为OpenAI Five。Dota2 是世界上最受欢迎的大型多人在线角色扮演游戏(MMORPGs)之一。与围棋和 Atari 等传统的强化学习游戏相比,Dota2 由于以下原因更为复杂:
- 
多个智能体:Dota2 游戏包含两支五人队伍,每支队伍争夺摧毁对方的基地。因此,决策不仅仅由一个智能体做出,而是由多个智能体同时做出。
 - 
可观察性:屏幕仅显示智能体角色的周围环境,而不是整个地图。这意味着游戏的整体状态,包括对手的位置和他们的行动,是不可观察的。在强化学习中,我们称这种情况为部分可观察状态。
 - 
高维度性:Dota2 智能体的观察可以包括 20,000 个数据点,每个点展示了人类玩家可能在屏幕上看到的内容,包括健康状态、控制角色的位置、敌人的位置以及任何攻击。而围棋则需要更少的数据点来构建一个观察(19 x 19 棋盘,历史走法)。因此,观察具有高维度性和复杂性。这同样适用于决策,Dota2 AI 的动作空间包含 17 万个可能性,包括移动、施放技能和使用物品的决策。
 
要了解更多关于 OpenAI 的 Dota2 AI 的信息,请查看他们的项目博客:blog.openai.com/openai-five/。
此外,通过对传统强化学习算法进行创新升级,OpenAI Five 中的每个智能体都能够学会与其他智能体协作,共同实现摧毁敌方基地的目标。它们甚至能够学习到一些经验丰富的玩家使用的团队策略。以下是 Dota2 玩家队伍与 OpenAI Five 之间比赛的一张截图:

OpenAI 对抗人类玩家(来源:www.youtube.com/watch?v=eaBYhLttETw)
尽管这个项目需要极高的资源要求(240 个 GPU、120,000 个 CPU 核心、约 200 年人类游戏时间),它展示了当前的 AI 算法确实能够在一个极为复杂的环境中互相合作,达成共同目标。这项工作象征着 AI 和强化学习研究的另一个重要进展,并展示了当前技术的潜力。
摘要
这标志着我们在强化学习的入门之旅的结束。在本书的过程中,我们学习了如何实现能够玩 Atari 游戏、在 Minecraft 中导航、预测股市价格、玩复杂的围棋棋盘游戏,甚至生成其他神经网络来训练CIFAR-10数据的智能体。在此过程中,您已经掌握并习惯了许多基础的和最先进的深度学习与强化学习算法。简而言之,您已经取得了很多成就!
但这段旅程并不会也不应当就此结束。我们希望,凭借您新获得的技能和知识,您将继续利用深度学习和强化学习算法,解决本书之外的实际问题。更重要的是,我们希望本指南能激励您去探索机器学习的其他领域,进一步发展您的知识和经验。
强化学习社区面临许多障碍需要克服。然而,未来值得期待。随着该领域的日益流行和发展,我们迫不及待想要看到该领域将取得的新进展和里程碑。我们希望读者在完成本指南后,能够感到更加充实并准备好构建强化学习算法,并为该领域做出重要贡献。
参考文献
Open Science Collaboration. (2015)。估算心理学科学的可重复性。Science, 349(6251), aac4716。
Henderson, P., Islam, R., Bachman, P., Pineau, J., Precup, D., 和 Meger, D. (2017)。真正重要的深度强化学习。arXiv 预印本 arXiv:1709.06560。
Pattanaik, A., Tang, Z., Liu, S., Bommannan, G., 和 Chowdhary, G. (2018 年 7 月)。抗干扰的强大深度强化学习。载于第 17 届国际自主代理与多智能体系统会议论文集(第 2040-2042 页)。国际自主代理与多智能体系统基金会。

                    
                





选择最佳行动,即选择使得 
在状态 
下所有可能的行动中最大
?
的平均奖励(例如,胜率)
的次数
:是当前状态下所有已做步棋的总数(包括步棋 
)
:是一个探索参数
:选择特定动作的平均行动价值
:由较大的监督学习策略网络给定的特定棋盘状态下采取某个动作的概率
:尚未探索的状态(叶节点)的价值评估
:给定状态下选择特定动作的次数
                
            
        
浙公网安备 33010602011771号