深度强化学习实用指南第三版-全-
深度强化学习实用指南第三版(全)
原文:
annas-archive.org/md5/28625da26760ed246b61fc08b36918f7译者:飞龙
前言
本书讲述的是强化学习(RL),它是机器学习(ML)的一个子领域;本书专注于学习在复杂环境中学习最优行为这一普遍而具有挑战性的问题。学习过程仅由从环境中获得的奖励值和观察结果驱动。这一模型非常通用,可以应用于许多实际情况,从玩游戏到优化复杂的制造过程。在本书中,我们主要集中在深度强化学习上,深度强化学习是利用深度学习(DL)方法的强化学习。
由于其灵活性和通用性,强化学习领域发展迅速,吸引了大量关注,既来自那些试图改进现有方法或创造新方法的研究人员,也来自那些希望以最有效的方式解决实际问题的实践者。
为什么我写这本书
强化学习(RL)领域在全球范围内有着大量的持续研究活动。几乎每天都有新的研究论文发表,许多深度学习(DL)会议,如神经信息处理系统会议(NeurIPS)或国际学习表示会议(ICLR),都专注于强化学习方法。此外,还有一些大型研究团队专注于将强化学习方法应用于机器人技术、医学、多智能体系统等领域。
然而,尽管关于最新研究的信息已广泛可得,但它们过于专业化和抽象,难以轻松理解。更糟糕的是,强化学习的实际应用方面,往往并不明显如何将一篇研究论文中以数学为主的抽象方法转化为能够解决实际问题的有效实现。
这使得有兴趣的人很难清楚地理解论文和会议报告中方法和思想的背后。虽然有一些关于强化学习各个方面的很好的博客文章,并附带了实际的示例,但博客文章的有限格式使得作者只能描述一两种方法,无法建立一个完整的结构化图像,也不能系统地展示不同方法之间的关系。本书写作的目的就是填补这一强化学习方法和途径的实践性和结构性信息的明显空白。
方法
这本书的一个关键方面是其实践导向。每种方法都适用于各种环境,从非常简单到相当复杂。我尝试使示例简洁易懂,这得益于 PyTorch 的表达力和强大功能。另一方面,示例的复杂性和要求是面向没有访问非常大计算资源(如图形处理单元(GPU)集群或非常强大的工作站)的强化学习(RL)爱好者的。我相信,这将使充满乐趣和激动人心的 RL 领域可以为比研究小组或大型人工智能公司更广泛的受众所接触。另一方面,这仍然是深度强化学习,因此强烈建议使用 GPU,因为加速计算将使实验变得更加方便(等待数周才能完成一次优化并不有趣)。本书中大约一半的示例将在 GPU 上运行时受益。
除了传统的中型 RL 环境示例,如 Atari 游戏或连续控制问题外,本书还包含了若干章节(第 10、13、14、19、20 和 21 章),这些章节包含了更大的项目,展示了如何将 RL 方法应用于更复杂的环境和任务。这些示例仍然不是完整的、现实生活中的项目(这些将占据一本独立的书),但只是一些更大的问题,说明了 RL 范式如何应用于超越公认基准的领域。
另一个需要注意的事情是,本书第一、二、三部分的示例我尽力使其自包含,源代码全部展示。有时这导致代码片段的重复(例如,大多数方法中的训练循环非常相似),但我认为给予你直接跳入你想学习的方法的自由,比避免一些重复更加重要。本书中的所有示例都可以在 GitHub 上找到,网址是 github.com/PacktPublishing/Deep-Reinforcement-Learning-Hands-On-3E/,欢迎你进行分支、实验和贡献。
除了源代码外,几个章节(第 15、16、19 和 22 章)还附带了训练模型的视频录制。这些录制可以在以下 YouTube 播放列表中找到:youtube.com/playlist?list=PLMVwuZENsfJmjPlBuFy5u7c3uStMTJYz7。
本书的目标读者
本书非常适合机器学习工程师、软件工程师和数据科学家,他们希望学习并实际应用深度强化学习。书中假设读者已经熟悉 Python、微积分和机器学习概念。通过实际示例和高级概述,本书也适合有经验的专业人士,帮助他们加深对高级深度强化学习方法的理解,并在各行业中应用,如游戏和金融。
本书内容概览
第一章,《什么是强化学习?》介绍了强化学习(RL)的基本概念和主要的正式模型。
第二章,《OpenAI Gym API 与 Gymnasium》介绍了 RL 的实践方面,使用开源库 Gym 及其后代 Gymnasium。
第三章,《使用 PyTorch 进行深度学习》为你提供了 PyTorch 库的快速概述。
第四章,《交叉熵方法》介绍了 RL 中最简单的方法之一,让你对 RL 方法和问题有个基本了解。
第五章,《表格学习与贝尔曼方程》本章开启了本书的第二部分,专注于基于价值的方法。
第六章,《深度 Q 网络》描述了深度 Q 网络(DQN),这是一种扩展基本价值方法的技术,使我们能够解决复杂的环境问题。
第七章,《更高级的 RL 库》描述了 PTAN 库,我们将在本书中使用该库来简化 RL 方法的实现。
第八章,《DQN 扩展》详细概述了 DQN 方法的现代扩展,以改善其在复杂环境中的稳定性和收敛性。
第九章,《加速 RL 方法的方式》概述了加速 RL 代码执行的几种方法。
第十章,《使用 RL 进行股票交易》是第一个实际项目,重点应用 DQN 方法进行股票交易。
第十一章,《策略梯度》开启了本书的第三部分,并介绍了另一类基于直接优化策略的 RL 方法。
第十二章,《演员-评论员方法:A2C 和 A3C》描述了强化学习中最广泛使用的基于策略的方法之一。
第十三章,《TextWorld 环境》介绍了将 RL 方法应用于互动小说游戏。
第十四章,《网页导航》是另一个长篇项目,应用强化学习(RL)于网页导航,使用 MiniWoB++环境。
第十五章,《连续动作空间》开启了本书的高级 RL 部分,描述了使用连续动作空间的环境的特点和各种方法(广泛应用于机器人技术)。
第十六章,《信任域》是另一章关于连续动作空间的内容,描述了信任域集方法:PPO、TRPO、ACKTR 和 SAC。
第十七章,《RL 中的黑箱优化》展示了另一类不显式使用梯度的优化方法。
第十八章,《高级探索》介绍了更好探索环境的不同方法——这是 RL 中的一个非常重要的方面。
第十九章,《带有人工反馈的强化学习(RLHF)》,介绍并实现了通过给予人类反馈来指导学习过程的最新方法。这种方法在训练大型语言模型(LLMs)中被广泛应用。在这一章中,我们将从零开始实现 RLHF 流程,并检查其效率。
第二十章,《AlphaGo Zero 与 MuZero》,描述了 AlphaGo Zero 方法及其演变为 MuZero,并将这两种方法应用于游戏《四子连珠》。
第二十一章,《离散优化中的强化学习(RL)》,描述了将强化学习方法应用于离散优化领域,使用魔方作为环境。
第二十二章,《多智能体强化学习(Multi-Agent RL)》,介绍了一种相对较新的强化学习方法方向,适用于多个智能体的情境。
为了最大化本书的价值。
本书适合你,如果你使用的是至少 32 GB RAM 的机器。虽然并不严格要求 GPU,但强烈推荐使用 Nvidia GPU。代码已经在 Linux 和 macOS 上进行了测试。有关硬件和软件要求的更多详细信息,请参阅第二章。
本书中所有描述强化学习方法的章节都有相同的结构:一开始,我们讨论该方法的动机、理论基础以及背后的思想。然后,我们通过多个示例,展示该方法应用于不同环境的过程,并附上完整的源代码。
你可以以不同的方式使用本书:
-
为了快速熟悉某一特定方法,你可以仅阅读相关章节的引言部分。
-
为了更深入地理解方法的实现方式,你可以阅读代码及其附带的解释。
-
为了更深入地熟悉该方法(我认为这是最好的学习方式),你可以尝试重新实现该方法并使其正常工作,使用提供的源代码作为参考。
无论你选择哪种方式,我希望本书对你有帮助!
第三版的变化
相较于本书的第二版(2020 年出版),在新版本中对内容做了几个重大更改:
-
所有代码示例的依赖项已更新为最新版本或替换为更好的替代品。例如,OpenAI Gym 不再被支持,但我们有 Farama Foundation 的 Gymnasium 分支。另一个例子是 MiniWoB++库,它替代了 MiniWoB 和 Universe 环境。
-
新增了一章关于 RLHF(人类反馈强化学习),并且将 MuZero 方法加入了 AlphaGo Zero 章节。
-
有很多小的修复和改进——大多数图示已经重新绘制,以使其更清晰、更易理解。
为了更好地满足书籍篇幅的限制,几个章节进行了重新安排,我希望这样能使本书更加一致且易于阅读。
下载示例代码文件
本书的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Deep-Reinforcement-Learning-Hands-On-Third-Edition。我们还提供来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/查看!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:packt.link/gbp/9781835882702。
使用的约定
本书中使用了许多文本约定。CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“对于奖励表,它表示为一个元组,其中[State,Action,State],而对于转换表,则写为[State,Action]。”
代码块设置如下:
import typing as tt
import gymnasium as gym
from collections import defaultdict, Counter
from torch.utils.tensorboard.writer import SummaryWriter
ENV_NAME = "FrozenLake-v1"
GAMMA = 0.9
TEST_EPISODES = 20
任何命令行输入或输出将写成以下形式:
>>> e.action_space
Discrete(2)
>>> e.observation_space
Box([-4.8000002e+00 -3.4028235e+38 -4.1887903e-01 -3.4028235e+38], [4.8000002e+00 3.4028235e+38 4.1887903e-01 3.4028235e+38], (4,), float32)
粗体:表示新术语、重要词汇或屏幕上显示的词语。例如,菜单或对话框中的词汇会以此方式出现在文本中。例如:“第二个术语称为交叉熵,这是深度学习中非常常见的优化目标。”引用使用紧凑的作者-年份格式放在方括号内,类似于[Sut88]或[Kro+11]。您可以在书末的参考书目部分找到相应论文的详细信息。
警告或重要提示将以此方式显示。
提示和技巧将以此方式显示。
联系我们
我们非常欢迎读者的反馈。
一般反馈:电子邮件 feedback@packtpub.com,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 与我们联系。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误难免发生。如果您在本书中发现错误,请向我们报告。请访问www.packtpub.com/submit-errata,点击“提交勘误”,填写表格。
盗版:如果您在互联网上发现我们作品的任何非法副本,请您提供位置地址或网站名称。请通过链接将信息发送至 copyright@packtpub.com。
如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有意撰写或为一本书作贡献,请访问authors.packtpub.com。
留下评论!
感谢你购买 Packt 出版的这本书——希望你喜欢!你的反馈非常宝贵,能够帮助我们改进和成长。阅读完后,请花点时间留下一个亚马逊评论;这只需一分钟,但对像你这样的读者来说,意义重大。
扫描下方二维码,选择一本免费的电子书。

下载此书的免费 PDF 副本
感谢你购买本书!
你喜欢随时阅读,但又不能把纸质书籍带到处走吗?你购买的电子书是否与你选择的设备不兼容?
别担心;每本 Packt 书籍,你现在都能免费获得该书的无 DRM PDF 版本。
在任何设备上随时阅读。直接从你最喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。
特权不止于此!你还可以获得独家折扣、新闻通讯以及每天发送到你邮箱的精彩免费内容。
按照以下简单步骤获得福利:
-
扫描二维码或访问以下链接:
![图片]()
-
提交你的购买凭证。
-
就是这样!我们会直接将你的免费 PDF 和其他福利发送到你的电子邮箱。
第一部分
强化学习简介
第一章:什么是强化学习?
自动学习最优决策的问题是一个普遍且常见的问题,已经在许多科学和工程领域中得到了研究。在我们不断变化的世界中,即使是看似静态的输入输出问题,如果考虑时间因素,也可能变得动态。例如,假设你想解决一个简单的监督学习问题——宠物图片分类,目标类别为狗和猫。你收集训练数据集,并使用你最喜欢的深度学习工具包实现分类器。在训练和验证之后,模型表现非常好。太棒了!你将其部署并让它运行一段时间。然而,经过一段海边度假的时间后,你回到工作中,发现狗狗美容风格发生了变化,导致你的一部分查询被错误分类,因此你需要更新训练图像并重新进行训练。并不是那么棒!
这个例子旨在展示即使是简单的机器学习(ML)问题,往往也有一个隐藏的时间维度。这通常被忽视,且可能在生产系统中成为一个问题。这可以通过强化学习(RL)来解决,强化学习是机器学习的一个子领域,是一种将额外维度(通常是时间,但不一定是)自然融入学习方程的方法。这使得强化学习更接近人类理解人工智能(AI)的方式。在本章中,我们将详细讨论强化学习,并让你熟悉以下内容:
-
强化学习(RL)与其他机器学习(ML)学科的关系与区别:监督学习与无监督学习
-
强化学习的主要形式及其相互关系
-
强化学习的理论基础:马尔科夫过程(MPs)、马尔科夫奖励过程(MRPs)和马尔科夫决策过程(MDPs)
监督学习
你可能熟悉监督学习的概念,这是最常见且研究最深入的机器学习问题。它的基本问题是,当给定一组示例对时,如何自动构建一个函数,将输入映射到输出?听起来很简单,但这个问题包含了许多计算机最近才开始成功解决的棘手问题。监督学习问题有很多例子,包括以下几种:
-
文本分类:这封电子邮件是垃圾邮件吗?
-
图像分类与物体定位:这张图片是猫、狗,还是其他东西?
-
回归问题:根据天气传感器提供的信息,明天的天气如何?
-
情感分析:这条评论的客户满意度如何?
这些问题看起来可能不同,但它们共享相同的思想——我们有许多输入和期望的输出示例,我们想要学习如何为一些未来的、当前看不见的输入生成输出。监督学习这一名称来源于我们从“真实数据”源提供的已知答案中学习。
无监督学习
在另一个极端,我们有所谓的无监督学习,它假设没有监督,也没有已知的标签分配给我们的数据。其主要目标是学习手头数据集的一些隐藏结构。一个常见的无监督学习方法是数据聚类。当我们的算法尝试将数据项合并成一组簇时,就会揭示数据中的关系。例如,你可能想要找到相似的图像或具有共同行为模式的客户。
另一种越来越流行的无监督学习方法是生成对抗网络(GANs)。当我们有两个相互竞争的神经网络(NNs)时,第一个网络试图生成假数据来欺骗第二个网络,而第二个网络试图区分人工生成的数据和从我们数据集中采样的数据。随着时间的推移,两个网络通过捕捉数据集中的微妙特定模式,变得越来越擅长其任务。
强化学习
强化学习(RL)是第三种方法,位于完全监督和完全没有预定义标签之间。一方面,它使用许多已建立的监督学习方法,如深度神经网络用于函数逼近、随机梯度下降和反向传播,来学习数据表示。另一方面,它通常以不同的方式应用这些方法。
在本章的接下来的两节中,我们将探讨 RL 方法的具体细节,包括其严格数学形式中的假设和抽象。现在,为了将 RL 与监督学习和无监督学习进行比较,我们将采取一种不那么正式但更易于理解的方式。
想象一下,你有一个代理需要在某个环境中采取行动。接下来,“代理”和“环境”将在本章中详细定义。一个迷宫中的机器人老鼠就是一个很好的例子,但你也可以想象一个自动直升机试图进行滚转,或者一个国际象棋程序学习如何打败一位国际象棋大师。为了简便,我们就以机器人老鼠为例。

图 1.1:机器人老鼠迷宫世界
在这个例子中,环境是一个迷宫,某些地方有食物,其他地方有电击。机器人老鼠是能够采取行动的代理(agent),比如向左/右转或向前移动。在每一时刻,它可以观察迷宫的完整状态,以决定要采取什么行动。机器人老鼠试图尽可能多地找到食物,同时尽量避免电击。食物和电击信号作为环境对代理(机器人老鼠)行为的额外反馈,充当了奖励的角色。奖励是强化学习中一个非常重要的概念,我们将在本章后面讨论。现在你只需要知道代理的最终目标是尽可能最大化它的奖励。在我们的这个具体例子中,机器人老鼠可能会因为短期的电击而遭遇一点挫折,以便在长期内到达一个食物丰富的地方——对机器人老鼠来说,这将是比停在原地一动不动且什么都得不到更好的结果。
我们不希望在机器人老鼠中硬编码有关环境和每种特定情况最佳行动的知识——这样做会非常费力,而且即使迷宫稍有变化也可能变得毫无用处。我们想要的是一套神奇的方法,使我们的机器人老鼠能够自主学习如何避免电击,并尽可能多地收集食物。强化学习正是这套神奇的工具箱,它与监督学习和无监督学习方法的行为不同;它不像监督学习那样依赖于预定义的标签。没有人会给机器人标注它看到的所有图像是好是坏,也没有人会告诉它应该转向哪个方向。
然而,我们并不像无监督学习那样完全盲目——我们有一个奖励系统。奖励可以是正面的,比如获取食物,负面的,比如电击,或者当什么特别的事情没有发生时是中立的。通过观察奖励并将其与采取的行动联系起来,我们的代理(agent)学习如何更好地执行某个行动,收集更多的食物,减少电击。当然,强化学习(RL)的普遍性和灵活性是有代价的。强化学习被认为是比监督学习或无监督学习更具挑战性的领域。我们来快速讨论一下是什么让强化学习变得棘手。
强化学习中的复杂性
首先需要注意的是,强化学习中的观察结果取决于代理的行为,并在某种程度上是该行为的结果。如果你的代理决定做一些低效的事情,那么观察结果将无法告诉你它做错了什么,以及应该采取什么措施来改进结果(代理将一直得到负面反馈)。如果代理固执己见,持续犯错,那么观察结果会给人一种错误的印象,认为无法获得更大的奖励——生活充满了痛苦——这完全可能是错误的。
在 ML 术语中,这可以重新表述为拥有非 IID 数据。缩写 iid 代表独立同分布,这是大多数监督学习方法的一个要求。
使我们的代理生活变得复杂的第二个因素是,它不仅需要利用已经学到的知识,还需要主动探索环境,因为也许改变做事的方式会显著改善结果。问题是,过多的探索也可能严重降低奖励(更不用说代理可能会忘记之前学到的东西),因此我们需要在这两种活动之间找到某种平衡。这个探索/利用的困境是 RL 中一个开放的基础性问题。人们总是面临这个选择——我应该去一个已经知道的地方吃饭,还是尝试这个新开的餐厅?我应该多频繁地换工作?我应该学习一个新领域,还是继续在我的专业领域工作?这些问题没有普遍的答案。
第三个复杂性在于奖励可能在行动后被严重延迟。例如,在国际象棋中,一步强有力的走棋可能在游戏中段改变局势。在学习过程中,我们需要发现这样的因果关系,而在时间流逝和我们的行动中,辨别这些关系可能非常棘手。
然而,尽管存在这些障碍和复杂性,RL 在近年来取得了巨大的进展,成为了一个越来越活跃的研究和实际应用领域。
想了解更多吗?让我们深入探讨 RL 的形式化理论和游戏规则。
RL 形式化理论
每个科学和工程领域都有其假设和限制。在本章前面,我们讨论了监督学习,在这种方法中,假设是输入输出对的知识。如果你的数据没有标签?你需要弄清楚如何获得标签,或者尝试使用其他理论。这并不意味着监督学习好或不好;它只是让它无法应用于你的问题。
有许多历史上的实际和理论突破,都是当某人试图以创造性方式挑战规则时发生的。然而,我们也必须理解我们的局限性。了解并理解各种方法的游戏规则非常重要,因为这可以帮助你提前节省大量时间。当然,RL 也有相应的形式化理论,我们将在本书的剩余部分从不同角度分析它们。
以下图示展示了两个主要的 RL 实体——代理和环境——以及它们的通信渠道——行动、奖励和观察:

图 1.2:RL 实体及其通信渠道
我们将在接下来的几个章节中详细讨论它们。
奖励
首先,让我们回到奖励的概念。在强化学习中,奖励只是我们从环境中定期获得的一个标量值。如前所述,奖励可以是正的也可以是负的,大小不一,但它只是一个数字。奖励的目的是告诉智能体它的行为有多好。我们并不定义智能体获得奖励的频率;它可以是每秒一次,也可以是智能体一生中仅有一次,尽管通常做法是每固定时间戳或每次与环境交互时给予奖励,以便于操作。在一次性奖励系统的情况下,除了最后一个奖励之外,所有奖励都为零。
正如我所说,奖励的目的是给智能体提供关于其成功的反馈,这是强化学习中的核心概念。基本上,“强化”一词源自于这样一个事实:智能体获得的奖励应该以积极或消极的方式强化其行为。奖励是局部的,意味着它反映了智能体到目前为止所获得的利益和损失。当然,某个动作获得了大奖励并不意味着,过一秒钟后,你就不会因之前的决策面临剧烈后果。这就像抢银行——在你想到后果之前,它看起来可能是个好主意。
智能体试图实现的目标是其一系列动作中累计的最大奖励。为了帮助你更好地理解奖励,这里列出了一些具体的例子及其奖励:
-
财务交易:一笔利润是交易员买卖股票的奖励。
-
国际象棋:奖励在游戏结束时获得,可能是胜利、失败或平局。当然,这取决于解释。例如,对我来说,在与国际象棋大师对弈时取得平局就是一个巨大的奖励。实际上,我们需要指定准确的奖励值,但这可能是一个相当复杂的表达式。例如,在国际象棋中,奖励可能与对手的强度成比例。
-
大脑中的多巴胺系统:大脑中有一部分(边缘系统)每当需要向大脑其他部分发送积极信号时,会产生多巴胺。高浓度的多巴胺会带来愉悦感,这会强化大脑认为有益的活动。不幸的是,边缘系统在它所认为“有益”的事物上非常古老——食物、繁衍和安全——但这是完全不同的故事!
-
电脑游戏:它们通常会给玩家提供明显的反馈,通常是击杀的敌人数或者收集的分数。在这个例子中需要注意的是,奖励已经累计,所以街机游戏中的强化学习奖励应该是分数的导数,也就是说,每当击杀一个敌人时奖励+1,玩家被敌人击杀时奖励- N,其余时间奖励为 0。
-
网络导航:有一些问题,具有很高的实际价值,需要自动提取网络上的信息。搜索引擎通常在尝试解决这一任务,但有时,为了获得所需的数据,你需要填写一些表单、通过一系列链接导航,或完成验证码,这对于搜索引擎来说可能是困难的。针对这些任务,有一种基于强化学习的方法,其中的奖励是你所需的 信息或结果。
-
神经网络架构搜索:强化学习(RL)可用于神经网络架构优化,在这种情况下,模型的质量至关重要,人们努力提升目标指标的额外 1%。在这一应用场景中,目标是通过调整层数或其参数、添加额外的旁路连接或对神经网络架构做出其他更改,从而在某些数据集上获得最佳的性能指标。此时的奖励是性能(准确度或其他衡量神经网络预测准确性的指标)。
-
狗狗训练:如果你曾经尝试训练一只狗,你就知道每当它做对了你要求的事情时,你需要给它一些美味的东西(但不要太多)。当它不听从指令时,惩罚它一点(负奖励)也是常见的做法,尽管近期的研究表明,这种做法并不像正向奖励那样有效。
-
学校成绩:我们都有过这样的经历!学校成绩是一种奖励系统,旨在为学生提供关于他们学习情况的反馈。
正如从前面的例子中可以看出的那样,奖励的概念是智能体表现的一个非常普遍的指示,它可以在我们周围的许多实际问题中找到或人为地注入。
智能体
智能体是指通过执行特定的行动、做出观察,并因此获得最终奖励的人或物。在大多数实际的强化学习场景中,智能体是我们的软件部分,它旨在以或多或少高效的方式解决某个问题。对于我们最初的六个例子,智能体如下:
-
金融交易:一个交易系统或交易员在执行订单(买入、卖出或不做任何操作)时作出的决策。
-
国际象棋:一个玩家或计算机程序。
-
多巴胺系统:大脑本身,根据感官数据决定这是否是一次好的体验。
-
电子游戏:享受游戏或计算机程序的玩家。(Andrej Karpathy 曾在推特上写道:“我们原本是要让 AI 完成所有工作,我们自己玩游戏,但实际上我们做了所有工作,而 AI 正在玩游戏!”)。
-
网络导航:告诉浏览器点击哪些链接、移动鼠标到哪里或输入哪些文本的软件。
-
神经网络架构搜索:控制被评估的神经网络具体架构的软件。
-
狗狗训练:你做出关于行动的决策(喂食/训斥),所以,代理人是你。但原则上,你的狗也可以被视为代理人——狗狗试图通过正确的行为来最大化奖励(食物和/或关注)。严格来说,这里是一个“多代理强化学习”(multi-agent RL)设置,相关内容在第二十二章有简要讨论。
-
学校:学生/学员。
环境
环境是代理之外的一切。从最广义上讲,它是宇宙的其余部分,但这稍微有些夸张,甚至超出了即使是明天的计算机的处理能力,所以我们通常在这里遵循一般意义上的理解。
代理人与环境的互动仅限于奖励(从环境中获得)、行动(由代理执行并发送到环境)和观察(代理从环境中获得的除奖励之外的一些信息)。我们已经讨论了奖励,接下来我们来谈谈行动和观察。我们将在讨论观察时确定每个例子的环境。
行动
行动是代理人在环境中可以执行的事情。例如,行动可以是棋盘上的棋子移动(如果是棋类游戏),或者是做作业(在学校的情况下)。它们可以像将兵前进一格那样简单,也可以像建立一家盈利的初创公司那样复杂。
在强化学习(RL)中,我们区分两种类型的行动——离散的或连续的。离散行动形成了代理可以执行的一组有限的、相互排斥的事情,比如向左或向右移动。连续行动则附带一些数值,例如汽车转动方向盘时有一个角度和方向。不同的角度可能会导致一秒钟后不同的情景,因此单纯的“转动方向盘”肯定不够。
给出具体例子,让我们看看六种情境中的行动:
-
金融交易:行动是买入或卖出股票的决策。“什么也不做,等待”也是一种行动。
-
国际象棋:行动是根据当前棋盘位置进行的有效棋子移动。
-
多巴胺系统:行动是你正在做的事情。
-
电子游戏:行动是按按钮。它们也可以是连续的,比如在汽车模拟器中转动方向盘。
-
网络浏览:行动可能是鼠标点击、滚动和文字输入。
-
神经网络架构搜索:行动是神经网络架构的变化,这些变化可以是离散的(网络中的层数)或连续的(丢弃层中的概率)。
-
狗狗训练:行动是你与狗狗可以做的一切——给它一块美味的食物、抚摸它,甚至用温柔的声音说“乖狗狗!”
-
学校:行动是成绩和其他非正式的信号,比如表扬成功或布置额外的作业。
观察
环境的观察构成了代理的第二个信息通道,第一个通道是奖励。你可能会想,为什么我们需要一个单独的数据源?答案是方便。观察是环境提供给代理的、指示代理周围发生情况的信息。
观察可能与即将到来的奖励相关(例如看到银行通知自己已收到薪水),也可能无关。观察甚至可能以某种模糊或隐晦的形式包含奖励信息,例如计算机游戏屏幕上的得分数字。得分数字只是像素,但我们有可能将它们转化为奖励值;对于现代计算机视觉技术来说,这并不是一个复杂的任务。
另一方面,奖励不应被视为次要或不重要的东西——奖励是驱动代理学习过程的主要力量。如果奖励是错误的、噪声大的,或者与主要目标稍有偏离,那么训练可能会朝着错误的方向发展。
同时,区分环境的状态和观察也很重要。环境的状态大多数时候是环境内部的,可能包括宇宙中的每一个原子,这使得我们不可能测量环境中的所有信息。即使我们将环境的状态限制得足够小,大多数情况下,我们也不可能获得关于它的完整信息,或者我们的测量会包含噪声。然而,这完全没问题,强化学习(RL)就是为了原生支持这种情况而设计的。为了说明这种区别,我们回到我们的示例集:
-
金融交易:在这里,环境是整个金融市场及其一切影响因素。这是一个庞大的清单,包含最新的新闻、经济和政治条件、天气、食物供应、Twitter/X 趋势等。甚至你今天决定待在家里,也可能间接影响世界金融系统(如果你相信“蝴蝶效应”)。然而,我们的观察仅限于股价、新闻等。我们无法访问大部分环境状态,这使得金融预测成为一项非常复杂的任务。
-
国际象棋:这里的环境是你的棋盘加上你的对手,包括他们的棋艺、情绪、大脑状态、选择的战术等。观察是你所看到的(你当前的棋盘局面),但是,在某些层次的比赛中,心理学知识和读取对手情绪的能力可能会提高你的胜算。
-
多巴胺系统:这里的环境是你的大脑、神经系统、器官状态以及你能感知到的整个世界。观察是来自你感官的内在大脑状态和信号。
-
电脑游戏:在这里,环境是你电脑的状态,包括所有内存和磁盘数据。对于联网游戏,你需要包括其他电脑以及它们和你机器之间的所有互联网基础设施。观察数据仅限于屏幕的像素和声音。这些像素并不是少量的信息(有估算认为,所有可能的中等大小图像(1024×768)的总数量远远大于我们银河系中原子的数量),但整个环境状态肯定更大。
-
网络浏览:这里的环境是互联网,包括所有在你代理工作的计算机和网页服务器之间的网络基础设施,这是一个真正庞大的系统,包含了成千上万不同的组件。观察通常是加载在浏览器中的网页。
-
神经网络架构搜索:在这个例子中,环境相对简单,包括执行特定神经网络评估的神经网络工具包,以及用于获得性能度量的数据集。与互联网相比,这看起来像是一个微小的玩具环境。观察数据可能有所不同,包括一些关于测试的信息,例如损失收敛动态或从评估步骤中获得的其他度量。
-
狗狗训练:这里,环境是你的狗(包括它几乎无法观察到的内心反应、情绪和生活经验)以及周围的一切,包括其他狗甚至是藏在灌木丛中的猫。观察数据来自你的感官和记忆。
-
学校:这里的环境是学校本身、国家的教育系统、社会和文化遗产。观察数据与狗狗训练示例中的相同——学生的感官和记忆。
这是我们的“场景布置”,在本书的其余部分我们将围绕它进行讨论。你应该已经注意到,强化学习(RL)模型极其灵活和通用,可以应用于多种场景。现在,让我们在深入探讨 RL 模型的细节之前,先看看强化学习与其他学科的关系。
还有许多其他领域为强化学习做出贡献或与其相关。最重要的几个领域显示在以下图示中,其中包括六个相互重叠的主要领域,这些领域涉及与决策相关的方法和具体话题(显示在内圈内)。

图 1.3:强化学习中的各个领域
在所有这些相关但仍然不同的科学领域的交集处坐落着强化学习(RL),它如此通用和灵活,可以从这些不同的领域中汲取最好的可用信息:
-
机器学习(ML):作为机器学习(ML)的一个子领域,强化学习(RL)借鉴了许多机器学习的工具、技巧和技术。基本上,RL 的目标是学习在给定不完美的观察数据时,代理应如何行动。
-
工程(特别是最优控制):这有助于采取一系列最优的行动,以获得最佳结果。
-
神经科学:我们以多巴胺系统为例,研究表明人类大脑的工作方式与 RL 模型非常相似。
-
心理学:这研究人在各种条件下的行为,比如人们如何反应和适应,这与 RL 主题有很大关联。
-
经济学:经济学中的一个重要话题是如何在不完全知识和现实世界变化条件下最大化回报。
-
数学:这与理想化系统一起工作,并且在运筹学领域也特别关注寻找并达到最优条件。
在本章的下一部分,你将熟悉强化学习(RL)的理论基础,这将使你能够开始朝着解决 RL 问题的方法迈进。接下来的部分对理解本书的其余部分非常重要。
强化学习的理论基础
在这一部分,我将向你介绍我们刚刚讨论的形式化模型(回报、代理、动作、观察和环境)的数学表示和符号。然后,基于这些知识,我们将探讨 RL 语言中的二阶概念,包括状态、回合、历史、价值和收益,这些概念将在本书后续的不同方法中反复使用。
马尔可夫决策过程
在此之前,我们将介绍马尔可夫决策过程(MDPs),它将像俄罗斯套娃一样被描述:我们将从最简单的马尔可夫过程(MP)开始,然后通过加入回报扩展它,变成马尔可夫回报过程(MRP)。接着,我们通过加入动作,再次将这个想法放入一个额外的框架,这样我们就得到了 MDP。
MPs 和 MDPs 在计算机科学和其他工程领域广泛应用。因此,阅读这一章不仅对你在 RL 方面有帮助,也对更广泛的主题有益。如果你已经熟悉 MDPs,那么你可以快速浏览这一章,只关注术语定义,因为我们稍后会用到它们。
马尔可夫过程
让我们从马尔可夫家族中最简单的概念开始:MP,也就是马尔可夫链。假设你面前有一个系统,你只能观察它。你观察到的叫做状态,系统可以根据某些动态法则(大多数情况下你并不知道这些法则)在状态之间切换。再次强调,你不能影响系统,只能观察状态的变化。一个系统的所有可能状态组成一个叫做状态空间的集合。对于 MP,我们要求这个状态集合是有限的(但它可以非常大以弥补这一限制)。你的观察形成一系列状态或链(这也是为什么 MPs 也被称为马尔可夫链)。
例如,考虑某个城市最简单的天气模型,我们可以观察当前是晴天还是雨天,这就是我们的状态空间。随着时间的推移,观察序列形成了一个状态链,如 [晴天, 晴天, 雨天, 晴天, ...],这就是所谓的历史。要将这样的系统称为马尔可夫过程,它需要满足马尔可夫性质,这意味着未来的系统动态仅取决于当前状态,而不取决于历史状态。马尔可夫性质的主要观点是使每个可观察的状态能够独立地描述系统的未来。换句话说,马尔可夫性质要求系统的各个状态彼此可区分且唯一。在这种情况下,仅需一个状态来建模系统的未来动态,而不是整个历史或说最近的 N 个状态。
在我们的天气示例中,马尔可夫性质将我们的模型限制为仅表示晴天之后可能是雨天,且两者的概率相同,不管过去我们经历了多少个晴天。这并不是一个非常现实的模型,因为从常理来看,我们知道第二天的降雨概率不仅取决于当前的天气状况,还取决于许多其他因素,如季节、纬度以及周围是否有山脉或海洋。最近有研究证明,太阳活动也对天气有重要影响。所以,我们的示例其实是很天真的,但它有助于理解模型的局限性,并做出有意识的决策。
当然,如果我们希望让我们的模型更复杂,可以通过扩展状态空间来实现,这样可以在模型中捕获更多的依赖关系,代价是增加了状态空间的规模。例如,如果你想分别捕捉夏季和冬季的雨天概率,那么你可以将季节纳入你的状态空间。
在这种情况下,你的状态空间将是 [晴天+夏季, 晴天+冬季, 雨天+夏季, 雨天+冬季],依此类推。
由于你的系统模型符合马尔可夫性质,你可以通过一个转移矩阵来捕获转移概率,转移矩阵是一个 N × N 的方阵,其中 N 是我们模型中状态的数量。矩阵中第 i 行、第 j 列的每个单元格包含系统从状态 i 转移到状态 j 的概率。
例如,在我们的晴天/雨天示例中,转移矩阵可能如下所示:
| 晴天 | 雨天 | |
|---|---|---|
| 晴天 | 0.8 | 0.2 |
| 雨天 | 0.1 | 0.9 |
在这种情况下,如果我们是晴天,那么第二天晴天的概率是 80%,雨天的概率是 20%。如果我们观察到雨天,那么天气变好的概率是 10%,第二天仍然是雨天的概率是 90%。
所以,就是这样。马尔可夫过程的正式定义如下:
-
系统可以处于的一组状态(S)
-
转移矩阵 (T),包含转移概率,定义了系统的动态
MP 的一个有用的可视化表示是一个图,节点代表系统的状态,边缘则用表示可能从一个状态到另一个状态的转移概率来标注。如果某个转移的概率是 0,我们就不画边(意味着无法从一个状态转移到另一个状态)。这种表示方法在有限状态机表示中也被广泛使用,而有限状态机又是自动机理论中的一个研究领域。对于我们的晴天/雨天天气模型,图示如下:

图 1.4:晴天/雨天天气模型
再次强调,我们仅仅是在谈论观察。我们无法影响天气,只能观察它并记录我们的观察结果。
为了给你一个更复杂的例子,我们来考虑一个名为“办公室员工”的模型(《Dilbert》中的主角迪尔伯特就是一个很好的例子)。在我们的示例中,他的状态空间包括以下状态:
-
家里:他不在办公室
-
计算机:他在办公室使用电脑工作
-
咖啡:他在办公室喝咖啡
-
聊天:他正在与办公室的同事讨论某些事情
状态转移图如下面的图示所示:

图 1.5:我们办公室员工的状态转移图
我们假设我们办公室员工的工作日通常从“家里”状态开始,而且他毫无例外地从“咖啡”状态开始一天(没有“家里 → 计算机”边缘,也没有“家里 → 聊天”边缘)。前面的图示还表明,工作日总是从“计算机”状态结束(也就是说,回到“家里”状态)。
上面图示的转移矩阵如下:
| 家里 | 咖啡 | 聊天 | 计算机 | |
|---|---|---|---|---|
| 家里 | 60% | 40% | 0% | 0% |
| 咖啡 | 0% | 10% | 70% | 20% |
| 聊天 | 0% | 20% | 50% | 30% |
| 计算机 | 20% | 20% | 10% | 50% |
转移概率可以直接标注在状态转移图上,如图 1.6 所示。

图 1.6:带有转移概率的状态转移图
在实际操作中,我们很少有机会知道确切的转移矩阵。一个更为现实的情况是,当我们只能观察到系统的状态,这些状态也称为“情节”时:
-
家里 → 咖啡 → 咖啡 → 聊天 → 聊天 → 咖啡 → 计算机 → 计算机 → 家里
-
计算机 → 计算机 → 聊天 → 聊天 → 咖啡 → 计算机 → 计算机 → 计算机
-
家里 → 家里 → 咖啡 → 聊天 → 计算机 → 咖啡 → 咖啡
从我们的观察中估计转移矩阵并不复杂——我们只需计算每个状态的所有转移,并将它们标准化,使其总和为 1。我们拥有的观察数据越多,我们的估计就会越接近真实的底层模型。
还值得注意的是,马尔可夫性质意味着平稳性(即任何状态的潜在转移分布随时间变化)。非平稳性意味着有某种隐藏因素影响着我们的系统动态,而这个因素未包含在观察中。然而,这与马尔可夫性质相矛盾,后者要求相同状态下的基础概率分布在任何转移历史中都是相同的。
重要的是理解我们在一集观察到的实际转移与转移矩阵中给出的潜在分布之间的差异。我们观察到的具体集是从模型的分布中随机抽样得到的,因此它们可能在每一集之间有所不同。然而,具体转移被抽样的概率保持不变。如果不是这样,马尔可夫链形式化就不适用了。
现在我们可以进一步扩展 MP 模型,使其更接近我们的 RL 问题。让我们在图中加入奖励!
马尔可夫奖励过程
为了引入奖励,我们需要稍微扩展我们的 MP 模型。首先,我们需要为状态之间的转换添加值。我们已经有了概率,但概率用于捕捉系统的动态,所以现在我们额外增加了一个标量数值,且不会增加额外负担。
奖励可以以多种形式表示。最通用的方式是另有一个方阵,类似于转移矩阵,表示从状态 i 到状态 j 的转换奖励,存储在第 i 行第 j 列。
如前所述,奖励可以是正数或负数,可以是大或小。在某些情况下,这种表示是多余的,可以简化。例如,如果无论起始状态如何,达到某个状态都会获得奖励,我们可以仅保留(状态,奖励)对,这是一种更紧凑的表示。然而,只有当奖励值仅依赖于目标状态时,这种表示才适用,但这并不总是成立。
我们添加到模型中的第二个内容是折扣因子 γ(希腊字母“gamma”),它是一个介于 0 到 1 之间的数字(包含 0 和 1)。在定义了我们 MRP 的额外特性后,我们会解释它的意义。
正如你会记得的那样,我们在 MP 中观察到的是一系列状态转移。在 MRP 中也是如此,但对于每一个转移,我们都有额外的量——奖励。因此,现在我们所有的观察都有一个与系统每次转移相关的奖励值。
对于每一集,我们定义在时刻 t 的回报为 G[t]:

上述公式中的 γ 在 RL 中非常重要,我们在接下来的章节中将经常遇到它。目前,可以将它理解为衡量我们预计未来回报时,观察多远未来的一个参数。它的值越接近 1,我们就越会考虑未来更多的步骤。
现在让我们试着理解回报公式的含义。对于每个时间点,我们将回报计算为后续奖励的总和,但距离起始点 t 越远的奖励,会被折扣因子乘以,并且这个折扣因子会根据我们距离起始点的步数的幂次进行调整。折扣因子代表了智能体的远见性。如果 γ = 1,那么回报 G[t]仅仅等于所有后续奖励的总和,代表智能体可以完美预见所有后续奖励。如果 γ = 0,G[t]则只会是立即奖励,没有任何后续状态,代表绝对的短视。
这些极端值仅在特殊情况下有用,大多数时候,γ会设置为介于两者之间的某个值,如 0.9 或 0.99。在这种情况下,我们会展望未来的奖励,但不会太远。γ = 1 的值可能适用于短期有限的情境。
这个回报量在实践中不是很有用,因为它是针对我们从马尔可夫奖励过程(MRP)观察到的每一个特定链定义的,因此即使是相同的状态,它也可能有很大差异。然而,如果我们走到极端,计算任何状态的回报的数学期望(通过对大量链求平均),我们将得到一个更实用的量,这就是状态的价值:

这个解释很简单——对于每个状态 s,值 V(s)是我们通过遵循马尔可夫奖励过程获得的平均(或期望)回报。
为了将这些理论知识实际应用,让我们扩展我们的办公室工作者(Dilbert)过程,加入奖励并将其转化为 Dilbert 奖励过程(DRP)。我们的奖励值将如下所示:
-
家庭 → 家庭:1(因为待在家里是好事)
-
家庭 → 咖啡:1
-
计算机 → 计算机:5(努力工作是好事)
-
计算机 → 聊天:−3(分心不好)
-
聊天 → 计算机:2
-
计算机 → 咖啡:1
-
咖啡 → 计算机:3
-
咖啡 → 咖啡:1
-
咖啡 → 聊天:2
-
聊天 → 咖啡:1
-
聊天 → 聊天:-1(长时间的对话变得无聊)
这一图示见于图 1.7。

图 1.7:带有转移概率和奖励的状态转移图
让我们回到我们的 γ 参数,思考不同 γ 值下状态的值。我们从一个简单的情况开始:γ = 0。如何计算这里的状态值呢?为了解答这个问题,我们固定状态为 Chat。那么接下来的转移可能是什么?答案是这取决于概率。根据我们 Dilbert 过程的转移矩阵,下一状态为 Chat 的概率是 50%,为 Coffee 的概率是 20%,为 Computer 的概率是 30%。当 γ = 0 时,我们的回报只等于下一个即时状态的值。因此,如果我们想计算前面图表中 Chat 状态的值,我们需要将所有转移值相加,并乘以它们的概率:
| V (chat) | = | − 1 ⋅ 0.5 + 2 ⋅ 0.3 + 1 ⋅ 0.2 = 0.3 |
|---|---|---|
| V (coffee) | = | 2 ⋅ 0.7 + 1 ⋅ 0.1 + 3 ⋅ 0.2 = 2.1 |
| V (home) | = | 1 ⋅ 0.6 + 1 ⋅ 0.4 = 1.0 |
| V (computer) | = | 5 ⋅ 0.5 + (−3) ⋅ 0.1 + 1 ⋅ 0.2 + 2 ⋅ 0.2 = 2.8 |
所以,计算机是最有价值的状态(如果我们只关心即时奖励),这并不奇怪,因为计算机 → 计算机是频繁的,且奖励较大,且中断的比例不高。
那么,这是一个更棘手的问题——当 γ = 1 时,值是多少?仔细思考一下。答案是,对于所有状态,值是无限的。我们的图表中没有沉没状态(没有外部转移的状态),而当我们的折扣因子等于 1 时,我们关心的是未来可能的无限次转移。正如你在 γ = 0 的情况下所见,我们的所有值在短期内都是正的,所以无限多个正值的总和将给我们一个无限的值,无论起始状态是什么。
这个无限的结果展示了为何在 MRP 中引入 γ 的原因,而不是仅仅将所有未来奖励加总。在大多数情况下,过程可能有无限(或大量)转移。由于处理无限值并不实际,我们希望限制我们计算值的范围。值小于 1 的 γ 提供了这样的限制,我们将在本书后续部分讨论这一点。另一方面,如果你处理的是有限时域环境(例如井字游戏,最多只有九步),那么使用 γ = 1 是完全可以的。
作为另一个例子,存在一种重要的环境类别,只有一步叫做多臂赌博机 MDP。这意味着在每一步,你需要选择一个替代行为,它会给你一些奖励,然后这一回合结束。
你可以在 Tor Lattimore 和 Csaba Szepesvari 的书《Bandit Algorithms》中了解更多关于赌博算法的方法(tor-lattimore.com/downloads/book/book.pdf)。
如我之前提到的 MRP,γ通常设置为 0 到 1 之间的值。然而,使用这样的值,手动计算几乎变得不可能,即使是像我们的 Dilbert 示例这样的简单 MRP,因为这将需要求和数百个值。计算机擅长处理这类繁琐的任务,并且有几种简单的方法可以快速计算给定转移和奖励矩阵的 MRP 值。我们将在第五章看到并实现其中的一种方法,在这一章我们将开始探讨 Q-learning 方法。
现在,让我们在我们的马尔科夫奖励过程中再增加一层复杂性,引入最后一个缺失的部分:动作。
向 MDP 中添加动作
你可能已经有了如何将我们的 MDP 扩展到包括动作的想法。首先,我们必须添加一组有限的动作(A)。这就是我们的代理的动作空间。其次,我们需要用动作来调整我们的转移矩阵,这基本上意味着我们的矩阵需要额外的动作维度,这使得它变成一个形状为|S|×|S|×|A|的立方体,其中 S 是我们的状态空间,A 是动作空间。
如果你记得,在 MPs 和 MRPs 的情况下,转移矩阵是方阵,源状态在行中,目标状态在列中。因此,每一行 i 包含跳转到每个状态的概率列表,如图 1.8 所示。

图 1.8:马尔科夫过程的转移矩阵
在 MDP 的情况下,代理不再是被动地观察状态转移,而是可以在每次状态转移时主动选择一个动作。因此,对于每个源状态,我们不再只有一个数字列表,而是有一个矩阵,其中深度维度包含代理可以采取的动作,而另一个维度是代理执行动作后目标状态系统将跳转到的状态。以下图表展示了我们新的转移表,它变成了一个立方体,其中源状态是高度维度(由 i 索引),目标状态是宽度(j),而代理可以采取的动作是深度(k)维度:

图 1.9:MDP 的转移概率
因此,通常通过选择一个动作,代理可以影响目标状态的概率,这是一个有用的能力。
为了让你理解为什么我们需要这么多复杂性,假设有一个小型机器人,生活在一个 3×3 的网格中,可以执行左转、右转和前进这些动作。世界的状态是机器人的位置加上方向(上、下、左、右),这给我们 36 个状态(机器人可以在任何位置并处于任何方向),即 3×3×4 = 36 个状态。
此外,请想象机器人具有不完美的电机(在现实世界中经常发生),当它执行左转或右转时,有 90%的概率会发生预期的转向,但有时(10%的概率),车轮会打滑,机器人的位置保持不变。前进时也是一样 —— 在 90%的情况下会成功,但剩下的 10%中,机器人会停留在原地。
在图 1.10 中,显示了转移图的一个小部分,显示了从状态(1, 1)向上的机器人可能的转移。如果机器人试图向前移动,有 90%的概率它会最终处于状态(0, 1)向上,但有 10%的概率车轮会打滑,目标位置将保持为(1, 1)向上。

图 1.10:一个网格世界环境
为了准确捕捉关于环境的所有细节以及对代理动作可能反应的描述,一般的 MDP 具有一个三维过渡矩阵,其维度为源状态、动作和目标状态。
最后,为了将我们的 MRP 转换为 MDP,我们需要以与过渡矩阵相同的方式向我们的奖励矩阵添加动作。我们的奖励矩阵将不仅取决于状态,还取决于动作。换句话说,代理获得的奖励现在不仅取决于它最终处于的状态,还取决于导致该状态的动作。现在,有了一个正式定义的 MDP,我们终于准备好探讨 MDP 和 RL 最重要的事情:策略。
策略
策略的简单定义是一组定义代理行为的规则。即使对于相当简单的环境,我们也可以有多种策略。例如,在前述的网格世界中,代理可以有不同的策略,这将导致不同的访问状态集合。例如,机器人可以执行以下操作:
-
无视一切盲目向前移动
-
通过检查之前的前进动作是否失败来试图绕过障碍物
-
滑稽地绕圈转动,总是向右转以取悦其创造者
-
选择一个动作是随机的,不考虑位置和方向,模拟一个在网格世界场景中的醉酒机器人。
您可能记得,RL 代理的主要目标是尽可能收集更多回报。因此,不同的策略可以带来不同数量的回报,这使得找到一个好策略变得很重要。这就是策略的重要概念。
形式上,策略被定义为每个可能状态下的动作概率分布:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq3.png)
这被定义为概率,而不是具体的动作,以引入随机性到代理的行为中。在本书的第三部分中,我们将讨论为什么这既重要又有用。确定性策略是概率策略的一种特殊情况,所需的动作其概率为 1。
另一个有用的概念是,如果我们的策略在训练过程中是固定的,并且在训练期间没有变化(即,当策略对相同的状态总是返回相同的动作时),那么我们的 MDP 就变成了 MRP,因为我们可以通过策略的概率简化转移矩阵和奖励矩阵,从而去掉动作维度。
恭喜你达到了这一阶段!本章虽然具有挑战性,但对于理解接下来的实践内容非常重要。在关于 OpenAI Gym 和深度学习的两章入门内容之后,我们将最终开始解决这个问题——我们如何教代理解决实际任务?
总结
在本章中,你通过学习强化学习(RL)为何与众不同以及它如何与监督学习和无监督学习范式相关,开始了你的 RL 世界之旅。接着我们学习了基本的 RL 形式化方法以及它们之间的相互作用,之后我们介绍了 MPs、MRPs 和 MDPs。这些知识将为本书接下来部分内容打下基础。
在下一章中,我们将从强化学习的形式化理论转向实际应用。我们将介绍所需的设置和库,然后你将编写你的第一个代理。
加入我们的 Discord 社区
与其他读者、深度学习专家以及作者本人一起阅读本书。提出问题,为其他读者提供解决方案,通过“问我任何问题”环节与作者互动,还有更多内容。扫描二维码或访问链接加入社区。packt.link/rl

第二章:OpenAI Gym API 和 Gymnasium
在第一章中,我们讨论了强化学习(RL)的理论概念后,接下来让我们开始一些实际操作。在本章中,你将学习 Gymnasium 的基础知识,这是一种为 RL 智能体和大量 RL 环境提供统一 API 的库。最初,这个 API 是在 OpenAI Gym 库中实现的,但它不再维护。在本书中,我们将使用 Gymnasium——OpenAI Gym 的一个分支,实现在同一 API 下的功能。无论如何,统一的 API 让环境的细节无须担心,避免了编写冗余代码,从而可以用更通用的方式实现智能体。
你还将编写第一个随机行为的智能体,并进一步熟悉我们迄今为止覆盖的强化学习基本概念。到本章结束时,你将理解:
-
需要实现的高级要求,以便将智能体接入强化学习框架
-
一个基础的纯 Python 实现的随机强化学习智能体
-
OpenAI Gym API 及其实现 —— Gymnasium 库
智能体的结构
如你在上一章中学到的,强化学习中有几个基本概念:
-
智能体:执行主动角色的事物或人。在实践中,智能体是实现某种策略的一段代码。基本上,这个策略决定了在每个时间步上,根据我们的观察需要采取什么行动。
-
环境:一切外部于智能体的事物,负责提供观察和奖励。环境根据智能体的行为改变其状态。
让我们探索如何在 Python 中实现这两者,针对一个简单的情况。我们将定义一个环境,它会根据智能体的行为,在有限的步骤内给智能体随机奖励。这个场景在现实世界中并不十分有用,但它将帮助我们集中精力在环境和智能体类中的特定方法上。
请注意,本书中展示的代码片段并不是完整示例。你可以在 GitHub 页面找到完整的示例:github.com/PacktPublishing/Deep-Reinforcement-Learning-Hands-On-Third-Edition 并运行它们。
让我们从环境开始:
class Environment:
def __init__(self):
self.steps_left = 10
在前面的代码中,我们允许环境初始化其内部状态。在我们的例子中,状态只是一个计数器,限制了智能体与环境交互的时间步数。
get_observation() 方法应该返回当前环境的观察信息给智能体。它通常实现为环境内部状态的某种函数:
def get_observation(self) -> List[float]:
return [0.0, 0.0, 0.0]
如果你对-> List[float]的含义感到好奇,那是 Python 类型注解的一个示例,这一功能是在 Python 3.5 中引入的。你可以在docs.python.org/3/library/typing.xhtml中了解更多信息。在我们的示例中,观察向量始终为零,因为环境基本上没有内部状态。get_actions()方法允许代理查询它可以执行的动作集合:
def get_actions(self) -> List[int]:
return [0, 1]
通常,动作集合不会随时间变化,但某些动作在不同状态下可能变得不可行(例如,在井字棋的任何位置并不是每一步都可以走)。在我们的简单示例中,代理能执行的动作只有两种,它们分别用整数 0 和 1 表示。
以下方法向代理发出回合结束的信号:
def is_done(self) -> bool:
return self.steps_left == 0
正如你在第一章中看到的,环境与代理之间的一系列交互被分为一系列步骤,称为回合(episodes)。回合可以是有限的,比如棋局中的回合,或者是无限的,比如“旅行者 2 号”任务(这是一项著名的太空探测任务,发射已超过 46 年,且已越过我们的太阳系)。为了涵盖这两种情况,环境提供了一种方法,用来检测回合何时结束,并且无法再与其通信。
action()方法是环境功能的核心部分:
def action(self, action: int) -> float:
if self.is_done():
raise Exception("Game is over")
self.steps_left -= 1
return random.random()
它做了两件事——处理代理的动作并返回该动作的奖励。在我们的示例中,奖励是随机的,其动作被丢弃。此外,我们更新了步骤计数,并且不会继续已经结束的回合。
现在,查看代理的部分会简单得多,仅包括两个方法:构造函数和执行环境中一步操作的方法:
class Agent:
def __init__(self):
self.total_reward = 0.0
在构造函数中,我们初始化了一个计数器,用于记录代理在回合过程中累积的总奖励。
step()函数接受环境实例作为参数:
def step(self, env: Environment):
current_obs = env.get_observation()
actions = env.get_actions()
reward = env.action(random.choice(actions))
self.total_reward += reward
该功能允许代理执行以下操作:
-
观察环境
-
根据观察结果做出关于采取哪种动作的决策
-
将动作提交给环境
-
获取当前步骤的奖励
对于我们的示例,代理很迟钝,在做出采取哪个动作的决策过程中忽略了获得的观察结果。相反,每个动作都是随机选择的。最后一部分是粘合代码,它创建了两个类并运行一个回合:
if __name__ == "__main__":
env = Environment()
agent = Agent()
while not env.is_done():
agent.step(env)
print("Total reward got: %.4f" % agent.total_reward)
你可以在本书的 GitHub 仓库中找到完整的代码,地址是 github.com/PacktPublishing/Deep-Reinforcement-Learning-Hands-On-Third-Edition,文件位于 Chapter02/01_agent_anatomy.py 中。它没有外部依赖,并且应该能在任何相对现代的 Python 版本中运行。通过多次运行,你将获得代理收集的不同数量的奖励。以下是我在我的机器上得到的输出:
Chapter02$ python 01_agent_anatomy.py
Total reward got: 5.8832
前述代码的简洁性展示了强化学习(RL)模型中重要的基本概念。环境可以是一个极其复杂的物理模型,而代理可以轻松地是一个大型神经网络(NN),实现最新的 RL 算法,但基本模式始终不变——在每一步,代理会从环境中获取一些观测,进行计算,并选择要执行的动作。这个动作的结果将是一个奖励和一个新的观测。
你可能会问,如果模式是一样的,为什么我们需要从头开始编写它?如果已经有人实现了它并且可以作为库使用呢?当然,确实存在这样的框架,但在我们花时间讨论它们之前,让我们先准备好你的开发环境。
硬件和软件要求
本书中的示例是使用 Python 3.11 版本实现并测试的。我假设你已经熟悉该语言以及虚拟环境等常见概念,因此我不会详细介绍如何安装软件包以及如何以隔离的方式进行操作。示例将使用之前提到的 Python 类型注解,这将使我们能够为函数和类方法提供类型签名。
目前,市面上有很多机器学习(ML)和强化学习(RL)库,但在本书中,我尽量将依赖项的数量保持在最低限度,优先考虑我们自己实现的方法,而不是盲目导入第三方库。
我们在本书中使用的外部库都是开源软件,包括以下内容:
-
NumPy:这是一个用于科学计算和实现矩阵运算及常用函数的库。
-
OpenCV Python 绑定:这是一个计算机视觉库,提供了许多图像处理功能。
-
来自 Farama Foundation 的 Gymnasium(
farama.org):这是 OpenAI Gym 库(github.com/openai/gym)的一个维护版本,它是一个 RL 框架,拥有可以以统一方式进行通信的各种环境。 -
PyTorch:这是一个灵活且富有表现力的深度学习(DL)库。第三章将简要介绍它。
-
PyTorch Ignite: 这是一个基于 PyTorch 的高层次工具集,用于减少样板代码。在第三章中将简要介绍。完整文档可在此处查看:
pytorch-ignite.ai/。 -
PTAN: (
github.com/Shmuma/ptan): 这是我创建的一个开源扩展,用于支持现代深度强化学习方法和构建模块。所有使用的类将详细描述,并附带源代码。
其他库将用于特定章节;例如,我们将使用 Microsoft TextWorld 来玩基于文本的游戏,PyBullet 和 MuJoCo 用于机器人仿真,Selenium 用于基于浏览器的自动化问题,等等。那些专门的章节将包括这些库的安装说明。
本书的很大一部分内容(第 2、3 和 4 部分)专注于过去几年中开发的现代深度强化学习(RL)方法。在这个上下文中,“深度”一词意味着深度学习(DL)的广泛应用。你可能已经知道,深度学习方法对计算资源的需求很高。一块现代图形处理单元(GPU)可以比即使是最快的多核中央处理单元(CPU)系统快 10 到 100 倍。实际上,这意味着在一个 GPU 系统上训练一小时的代码,即使是在最快的 CPU 系统上也可能需要半天到一周的时间。这并不意味着没有 GPU 你就不能尝试本书中的示例,但时间会更长。为了自己进行代码实验(学习任何东西最有用的方式),最好使用有 GPU 的机器。你可以通过以下几种方式来实现:
-
购买适合 CUDA 并支持 PyTorch 框架的现代 GPU。
-
使用云实例。Amazon Web Services 和 Google Cloud Platform 都可以提供 GPU 驱动的实例。
-
Google Colab 提供免费的 GPU 访问权限,适用于其 Jupyter 笔记本。
系统设置的说明超出了本书的范围,但互联网上有很多手册可以参考。在操作系统(OS)方面,你应该使用 Linux 或 macOS。Windows 被 PyTorch 和 Gymnasium 支持,但本书中的示例未在 Windows 操作系统上经过充分测试。
为了给你提供本书中将使用的外部依赖项的准确版本,以下是一个 requirements.txt 文件(请注意,它是用 Python 3.11 测试过的;不同版本可能需要调整依赖项或根本无法工作):
gymnasium[atari]==0.29.1
gymnasium[classic-control]==0.29.1
gymnasium[accept-rom-license]==0.29.1
moviepy==1.0.3
numpy<2
opencv-python==4.10.0.84
torch==2.5.0
torchvision==0.20.0
pytorch-ignite==0.5.1
tensorboard==2.18.0
mypy==1.8.0
ptan==0.8.1
stable-baselines3==2.3.2
torchrl==0.6.0
ray[tune]==2.37.0
pytest
本书中的所有示例都是用 PyTorch 2.5.0 编写和测试的,可以通过访问pytorch.org 网站上的说明进行安装(通常,只需使用 conda install pytorch torchvision -c pytorch 命令,或者根据你的操作系统,直接使用 pip install torch 命令)。
现在,让我们深入了解 OpenAI Gym API,它为我们提供了从简单到挑战性强的各种环境。
OpenAI Gym API 与 Gymnasium
由 OpenAI 开发的 Python 库 Gym (www.openai.com)。第一个版本发布于 2017 年,从那时起,许多环境都已被开发或适配到这个原始 API,后者也成为了强化学习(RL)的事实标准。
在 2021 年,开发 OpenAI Gym 的团队将开发工作转移到了 Gymnasium (github.com/Farama-Foundation/Gymnasium)——原始 Gym 库的一个分支。Gymnasium 提供相同的 API,并被认为是 Gym 的“直接替代品”(你可以写import gymnasium as gym,大部分情况下你的代码将正常运行)。
本书中的示例使用的是 Gymnasium,但为了简洁起见,文中会使用“Gym”。在极少数情况下,当差异确实重要时,我会使用“Gymnasium”。
Gym 的主要目标是通过统一的接口为 RL 实验提供丰富的环境集合。因此,库中的核心类是环境类,称为 Env。该类的实例暴露了几个方法和字段,提供关于其功能的必要信息。从高层次来看,每个环境都提供这些信息和功能:
-
允许在环境中执行的动作集合。Gym 支持离散和连续动作,以及它们的组合。
-
环境向代理提供的观察的形状和边界。
-
一个名为 step 的方法用于执行一个动作,该方法返回当前的观察、奖励以及指示该回合是否结束的标志。
-
一个名为 reset 的方法,它将环境恢复到初始状态并获取第一个观察。
现在,我们来详细讨论一下环境的这些组件。
动作空间
如前所述,代理可以执行的动作可以是离散的、连续的,或者是两者的组合。
离散动作是一组固定的、代理可以执行的动作,例如,在一个网格中的方向:左、右、上或下。另一个例子是按钮,按钮可以是按下或释放。两个状态是互斥的,这是离散动作空间的主要特征,在该空间中,每次只能从有限的动作集合中选择一个动作。
连续动作附带一个值,例如,方向盘可以转动到特定角度,或油门踏板可以以不同的力量踩下。连续动作的描述包括该动作可能具有的值的边界。对于方向盘来说,可能的值范围是-720 度到 720 度。对于油门踏板,通常范围是从 0 到 1。
当然,我们不仅仅局限于单个动作;环境可以执行多个动作,例如同时按下多个按钮或同时转动方向盘和踩两个踏板(刹车和油门)。为了支持这种情况,Gym 定义了一个特殊的容器类,允许将多个动作空间嵌套成一个统一的动作。
观测空间
如第一章所讨论,观测是环境在每个时间戳提供给智能体的信息,除了奖励之外。观测可以像一堆数字一样简单,或者像几个多维张量一样复杂,这些张量包含来自多个相机的彩色图像。观测甚至可以是离散的,类似于动作空间。离散观测空间的一个例子是灯泡,它可以处于两种状态——开或关——并以布尔值的形式给我们提供。
因此,您可以看到动作和观测之间的相似性,这就是它们在 Gym 类中表示的方式。让我们来看一个类图:
![tsuhpalpee::Dl TsaioSuSTmsnBwpppupc:o:alaplrxcecleeinfleeettos[S[(eapin)tatc,e .,..]...] cohnigtha:inflso(axt) seed ()](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/B22150_02_01.png)
图 2.1:Gym 中 Space 类的层级结构
基本的抽象 Space 类包括一个属性和三个对我们有用的方法:
-
shape:此属性包含空间的形状,与 NumPy 数组相同。
-
sample():此方法返回空间中的一个随机样本。
-
contains(x):此方法检查参数 x 是否属于该空间的领域。
-
seed():此方法允许我们为空间及其所有子空间初始化一个随机数生成器。如果您希望在多个运行中获得可重复的环境行为,这非常有用。
所有这些方法都是抽象方法,并在每个 Space 子类中重新实现:
-
Discrete 类表示一个互斥的项目集合,编号从 0 到 n-1。如果需要,您可以通过可选的构造函数参数 start 重新定义起始索引。值 n 是我们 Discrete 对象描述的项目数量。例如,Discrete(n=4)可以用于四个方向的动作空间[左、右、上、下]。
-
Box 类表示一个具有区间[low, high]的有理数 n 维张量。例如,这可以是一个油门踏板,其值介于 0.0 和 1.0 之间,可以通过 Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32)来编码。在这里,shape 参数被赋值为长度为 1 的元组,元组中只有一个值 1,这样就给我们一个一维的张量,其中包含一个值。dtype 参数指定空间的值类型,在这里,我们指定它为 NumPy 32 位浮动类型。另一个 Box 的例子可能是 Atari 屏幕的观察(稍后我们会涉及许多 Atari 环境),它是一个大小为 210 × 160 的 RGB(红色、绿色和蓝色)图像:Box(low=0, high=255, shape=(210, 160, 3), dtype=np.uint8)。在这种情况下,shape 参数是一个包含三个元素的元组:第一个维度是图像的高度,第二个是宽度,第三个是 3,分别对应于红色、绿色和蓝色的三个色彩通道。因此,总体来说,每个观察是一个具有 100,800 字节的三维张量。
-
空间的最终子类是 Tuple 类,它允许我们将多个 Space 类实例组合在一起。这使我们能够创建我们想要的任何复杂度的动作和观察空间。例如,假设我们想为一辆汽车创建一个动作空间的规范。汽车有多个控制项,每个控制项都可以在每个时间戳进行改变,包括方向盘角度、刹车踏板位置和油门踏板位置。这三个控制项可以通过一个单独的 Box 实例中的三个浮动值来指定。除了这些基本的控制项外,汽车还有额外的离散控制项,如转向信号(可以是关闭、右转或左转)或喇叭(开或关)。为了将这一切组合成一个动作空间规范类,我们可以使用以下代码:
Tuple(spaces=( Box(low=-1.0, high=1.0, shape=(3,), dtype=np.float32), Discrete(n=3), Discrete(n=2) ))这种灵活性很少被使用;例如,在本书中,你只会看到 Box 和离散的动作和观察空间,但在某些情况下,Tuple 类会很有用。
在 Gym 中还定义了其他的 Space 子类,例如 Sequence(表示可变长度序列)、Text(字符串)和 Graph(空间是一个节点集合,节点之间有连接)。但我们所描述的这三个子类是最常用的。
每个环境都有两个类型为 Space 的成员:action_space 和 observation_space。这使我们能够创建通用代码,可以与任何环境一起使用。当然,处理屏幕的像素与处理离散观察不同(因为在前一种情况下,我们可能希望通过卷积层或计算机视觉工具箱中的其他方法来预处理图像);因此,大多数时候,这意味着要为特定环境或环境组优化代码,但 Gym 并不禁止我们编写通用代码。
环境
环境在 Gym 中由 Env 类表示,该类具有以下成员:
-
action_space:这是 Space 类的字段,提供有关环境中允许执行的动作的规范。
-
observation_space:这个字段属于相同的 Space 类,但指定了环境提供的观察。
-
reset():此方法将环境重置为初始状态,返回初始观察向量以及来自环境的额外信息字典。
-
step():这个方法允许智能体采取行动并返回有关行动结果的信息:
-
下一个观察
-
本地奖励
-
回合结束标志
-
标志,指示回合是否被截断
-
一个包含环境额外信息的字典
这个方法有点复杂,我们稍后会在本节中详细讨论。
-
在 Env 类中有额外的实用方法,比如 render(),它允许我们以人类友好的形式获取观察数据,但我们不会使用它们。你可以在 Gym 的文档中找到完整列表,但我们将专注于核心的 Env 方法:reset() 和 step()。
由于 reset 方法相对简单,我们将从它开始。reset() 方法没有参数;它指示环境重置为初始状态并获取初始观察。请注意,在创建环境后,你必须调用 reset()。正如你在第一章中记得的那样,智能体与环境的交互可能会有结束(比如“游戏结束”屏幕)。这种会话称为回合,在回合结束后,智能体需要重新开始。此方法返回的值是环境的第一次观察。
除了观察外,reset() 返回第二个值——包含额外环境特定信息的字典。大多数标准环境在此字典中不返回任何内容,但更复杂的环境(如 TextWorld——一个交互式小说游戏的模拟器;我们将在本书后面了解它)可能会返回一些不适合标准观察的数据。
step() 方法是环境功能的核心部分。它在一次调用中执行多个操作,具体如下:
-
告诉环境我们将在下一步执行的动作
-
获取这个行动后从环境中得到的新观察
-
获取智能体通过此步获得的奖励
-
获取回合是否结束的指示
-
获取信号,指示一个回合是否已被截断(例如启用时间限制时)
-
获取包含额外环境特定信息的字典
前述列表中的第一个项目(action)作为唯一参数传递给 step() 方法,其余内容由此方法返回。更准确地说,这是一个包含五个元素(observation, reward, done, truncated 和 info)的元组(Python 元组,而不是我们在上一节讨论的 Tuple 类)。它们具有以下类型和含义:
-
observation:这是一个包含观察数据的 NumPy 向量或矩阵。
-
reward:这是奖励的浮动值。
-
done: 这是一个布尔指示符,当回合结束时值为 True。如果这个值为 True,我们必须在环境中调用 reset(),因为不再可能进行任何动作。
-
truncated: 这是一个布尔指示符,当回合被截断时值为 True。对于大多数环境,这通常是一个 TimeLimit(限制回合时长的方式),但在某些环境中它可能有不同的含义。这个标志与 done 标志分开,因为在某些场景下,区分“代理到达回合结束”与“代理到达环境时间限制”可能会很有用。如果 truncated 为 True,我们还需要在环境中调用 reset(),就像处理 done 标志一样。
-
info: 这可能是与环境特定的额外信息,通常做法是在一般强化学习方法中忽略此值。
你可能已经对环境在代理代码中的使用方式有了一些了解——在循环中,我们调用 step()方法并执行一个动作,直到 done 或 truncated 标志变为 True。然后,我们可以调用 reset()重新开始。还有一个部分缺失——我们如何首先创建 Env 对象。
创建环境
每个环境都有一个唯一的名称,格式为 EnvironmentName-vN,其中 N 是区分同一环境不同版本的数字(例如,当修复了某些错误或做了其他重大更改时)。为了创建一个环境,gymnasium 包提供了 make(name)函数,其唯一参数是环境的名称字符串。
在撰写本文时,Gymnasium 版本 0.29.1(安装了[atari]扩展)包含了 1,003 个不同名称的环境。当然,并非所有这些环境都是独立的,因为这个列表包括了环境的所有版本。此外,相同的环境也可能在设置和观察空间中有所不同。例如,Atari 游戏 Breakout 有以下这些环境名称:
-
Breakout-v0, Breakout-v4: 原版 Breakout,球的位置和方向是随机的。
-
BreakoutDeterministic-v0, BreakoutDeterministic-v4: 初始位置和球速向量相同的 Breakout。
-
BreakoutNoFrameskip-v0, BreakoutNoFrameskip-v4: 每帧都展示给代理的 Breakout 环境。没有这个设置时,每个动作会执行多个连续帧。
-
Breakout-ram-v0, Breakout-ram-v4: 使用完整 Atari 模拟内存(128 字节)而非屏幕像素的 Breakout。
-
Breakout-ramDeterministic-v0, Breakout-ramDeterministic-v4: 使用相同初始状态的内存观察。
-
Breakout-ramNoFrameskip-v0, Breakout-ramNoFrameskip-v4: 无跳帧的内存观察。
总共为一个游戏有 12 个环境。如果你之前没见过,这是它的游戏截图:

图 2.2:Breakout 的游戏画面
即便去除这些重复项,Gymnasium 依然提供了一个令人印象深刻的 198 个独特环境的列表,这些环境可以分为几个组:
-
经典控制问题:这些是玩具任务,用于最优控制理论和强化学习论文中的基准测试或演示。它们通常简单,观察和动作空间的维度较低,但在实现算法时,它们作为快速检查是非常有用的。可以把它们看作是强化学习领域的“MNIST”(MNIST 是 Yann LeCun 提供的手写数字识别数据集,网址是
yann.lecun.com/exdb/mnist/)。 -
Atari 2600:这些是来自 1970 年代经典游戏平台的游戏,共有 63 款独特游戏。
-
算法问题:这些是旨在执行小型计算任务的问题,如复制观察到的序列或加法运算。
-
Box2D:这些是使用 Box2D 物理仿真器来学习行走或汽车控制的环境。
-
MuJoCo:这是另一种物理仿真器,用于解决多个连续控制问题。
-
参数调整:这是利用强化学习来优化神经网络参数。
-
玩具文本:这些是简单的网格世界文本环境。
当然,支持 Gym API 的强化学习环境的总数要大得多。例如,Farama 基金会维护了多个与特殊强化学习主题相关的代码库,如多智能体强化学习、3D 导航、机器人技术和网页自动化。此外,还有许多第三方代码库。你可以查看 gymnasium.farama.org/environments/third_party_environments 了解相关信息。
够了!让我们来看看一个 Python 会话,演示如何使用 Gym 的环境。
CartPole 会话
让我们应用我们的知识,探索 Gym 提供的最简单的强化学习(RL)环境之一。
$ python
>>> import gymnasium as gym
>>> e = gym.make("CartPole-v1")
这里,我们导入了 gymnasium 包并创建了一个名为 CartPole 的环境。这个环境来自经典控制组,核心思想是控制底部附有杆子的平衡平台(见下图)。
这里的难点在于,这根杆子容易向左或向右倒,你需要通过每一步将平台移动到右侧或左侧来保持平衡。

图 2.3:CartPole 环境
这个环境的观察结果是包含有关杆质心 x 坐标、速度、与平台的角度以及角速度的四个浮点数。当然,通过一些数学和物理知识,将这些数字转换为动作来平衡杆并不复杂,但我们的问题是不同的——在不知道观察到的数字确切含义的情况下,只通过获取奖励来学习如何平衡这个系统。这个环境中的奖励为 1,在每个时间步上都会给出。本集结束直到杆子倒下,因此为了获得更多的累积奖励,我们需要以一种避免杆子倒下的方式平衡平台。
这个问题看起来可能很难,但在仅仅两章之内,我们将编写一个算法,能够在几分钟内轻松解决 CartPole,而不需要理解观察到的数字意味着什么。我们将只通过试错和一点强化学习的魔法来完成。
但现在,让我们继续我们的会话。
>>> obs, info = e.reset()
>>> obs
array([ 0.02100407, 0.02762252, -0.01519943, -0.0103739 ], dtype=float32)
>>> info
{}
在这里,我们重置了环境并获得了第一个观察结果(我们始终需要重置新创建的环境)。正如我所说,观察结果是四个数字,所以这里没有什么意外。现在让我们来检查一下环境的动作和观察空间:
>>> e.action_space
Discrete(2)
>>> e.observation_space
Box([-4.8000002e+00 -3.4028235e+38 -4.1887903e-01 -3.4028235e+38], [4.8000002e+00 3.4028235e+38 4.1887903e-01 3.4028235e+38], (4,), float32)
action_space 字段是离散类型,所以我们的动作只能是 0 或 1,其中 0 表示向左推动平台,1 表示向右推动。观察空间是 Box(4,),意味着一个四个数字的向量。在 observation_space 字段中显示的第一个列表是参数的低边界,第二个列表是高边界。
如果你好奇的话,你可以查看 Gymnasium 仓库中 cartpole.py 文件中的环境源代码,位于 github.com/Farama-Foundation/Gymnasium/blob/main/gymnasium/envs/classic_control/cartpole.py#L40。CartPole 类的文档字符串提供了所有细节,包括观察的语义:
-
小车位置:值在 −4.8…4.8 范围内
-
小车速度:值在 −∞…∞ 范围内
-
杆角度:弧度值在 −0.418…0.418 范围内
-
杆角速度:值在 −∞…∞ 范围内
Python 使用 float32 的最大和最小值来表示无穷大,这就是为什么边界向量中的某些条目具有 10³⁸ 规模值的内部细节。这些内部细节很有趣,但绝对不需要使用 RL 方法来解决环境问题。让我们进一步发送一个动作到环境中:
>>> e.step(0)
(array([-0.01254663, -0.22985364, -0.01435183, 0.24902613], dtype=float32), 1.0, False, False, {})
在这里,我们通过执行动作 0 将平台向左推动,并得到了一个五个元素的元组:
-
一个新的观察结果,即一个新的四个数字的向量
-
奖励为 1.0
-
done 标志值为 False,表示本集尚未结束,我们对平衡杆的掌握还算可以。
-
截断标志值为 False,表示本集未被截断
-
关于环境的额外信息,这是一个空字典
接下来,我们将使用 Space 类的 sample() 方法,分别作用于 action_space 和 observation_space。
>>> e.action_space.sample()
0
>>> e.action_space.sample()
1
>>> e.observation_space.sample()
array([-4.05354548e+00, -1.13992760e+38, -1.21235274e-01, 2.89040989e+38],
dtype=float32)
>>> e.observation_space.sample()
array([-3.6149189e-01, -1.0301251e+38, -2.6193827e-01, -2.6395525e+36],
dtype=float32)
这个方法返回了底层空间的一个随机样本,对于我们的离散动作空间来说,意味着一个随机的 0 或 1,而对于观测空间来说,意味着一个四个数字的随机向量。观测空间的随机样本并不特别有用,但来自动作空间的样本可以在我们不确定如何执行某个动作时使用。这个功能特别方便,因为你还不懂任何强化学习方法,但我们仍然想在 Gym 环境中玩玩。既然你已经学到了足够的知识来实现你的第一个随机行为的 CartPole 智能体,那么我们开始吧。
随机 CartPole 智能体
尽管环境比我们在 2.1 节中第一个例子要复杂得多,但智能体的代码要简短得多。这就是可重用性、抽象和第三方库的强大之处!
下面是代码(你可以在 Chapter02/02_cartpole_random.py 中找到它):
import gymnasium as gym
if __name__ == "__main__":
env = gym.make("CartPole-v1")
total_reward = 0.0
total_steps = 0
obs, _ = env.reset()
在这里,我们创建了环境并初始化了步数计数器和奖励累加器。在最后一行,我们重置了环境以获得第一个观测值(我们不会使用它,因为我们的智能体是随机的):
while True:
action = env.action_space.sample()
obs, reward, is_done, is_trunc, _ = env.step(action)
total_reward += reward
total_steps += 1
if is_done:
break
print("Episode done in %d steps, total reward %.2f" % (total_steps, total_reward))
在上面的循环中,采样一个随机动作后,我们要求环境执行该动作并返回下一个观测值(obs)、奖励、is_done 和 is_trunc 标志。如果回合结束,我们就停止循环,并显示我们走了多少步,累计了多少奖励。如果你运行这个示例,你会看到类似这样的输出(虽然不完全相同,因为智能体是随机的):
Chapter02$ python 02_cartpole_random.py
Episode done in 12 steps, total reward 12.00
平均而言,我们的随机智能体在杆子倒下并且回合结束之前大约需要 12 到 15 步。Gym 中的大多数环境都有一个“奖励边界”,这是智能体在 100 个连续回合中应获得的平均奖励,以“解决”该环境。对于 CartPole,这个边界是 195,这意味着,平均而言,智能体必须保持杆子 195 个时间步长或更长时间。用这个角度来看,我们的随机智能体表现得很差。然而,不要失望;我们才刚刚开始,很快你就能解决 CartPole 和许多更有趣、更具挑战性的环境。
额外的 Gym API 功能
到目前为止,我们讨论的内容涵盖了 Gym 核心 API 的三分之二以及开始编写智能体所需的基本功能。其余的 API 你可以不使用,但它会让你的生活更轻松,代码更简洁。所以,让我们简要地讲解一下剩下的 API。
包装器
很多时候,你可能希望以某种通用的方式扩展环境的功能。例如,假设一个环境给你一些观察结果,但你希望将这些结果积累到某个缓冲区中,并提供给智能体最近的 N 个观察结果。这是动态计算机游戏中的常见场景,因为单一的帧画面不足以获取游戏状态的完整信息。另一个例子是,当你希望能够裁剪或预处理图像的像素,使其更方便智能体处理,或者你希望以某种方式对奖励分数进行归一化处理。这类情况有很多,它们的结构相同——你想“包装”现有的环境,并添加一些额外的逻辑来完成某些操作。Gym 提供了一个方便的框架——Wrapper 类。
类的结构如图 2.4 所示。

图 2.4:Gym 中 Wrapper 类的层次结构
Wrapper 类继承自 Env 类。它的构造函数接受一个参数——要“包装”的 Env 类实例。为了添加额外的功能,你需要重新定义想要扩展的方法,例如 step() 或 reset()。唯一的要求是调用父类的原始方法。为了简化对被包装环境的访问,Wrapper 类有两个属性:env,表示我们正在包装的直接环境(它也可以是另一个 wrapper),以及 unwrapped,表示没有任何包装器的 Env 环境。
为了处理更具体的需求,例如一个只想处理环境中的观察结果或仅仅处理动作的 Wrapper 类,Gym 提供了一些 Wrapper 的子类,它们允许过滤特定的信息部分。它们如下所示:
-
ObservationWrapper:你需要重新定义父类的 observation(obs) 方法。obs 参数是来自被包装环境的观察结果,该方法应返回将提供给智能体的观察值。
-
RewardWrapper:这个类暴露了 reward(rew) 方法,可以修改赋予智能体的奖励值,例如,将其缩放到所需的范围,基于某些之前的动作添加折扣,或类似的操作。
-
ActionWrapper:你需要重写 action(a) 方法,它可以调整智能体传递给被包装环境的动作。
为了使其稍微更具实用性,让我们想象一种情况,我们希望干预智能体发送的动作流,并且以 10%的概率将当前动作替换为随机动作。这可能看起来是一个不明智的做法,但这个简单的技巧是我们在第一章提到的探索/利用问题的最实用和最强大的解决方法之一。通过发出随机动作,我们让智能体探索环境,并时不时地偏离其策略的固有轨迹。这是一个通过使用 ActionWrapper 类(完整示例见 Chapter02/03_random_action_wrapper.py)轻松实现的事情:
import gymnasium as gym
import random
class RandomActionWrapper(gym.ActionWrapper):
def __init__(self, env: gym.Env, epsilon: float = 0.1):
super(RandomActionWrapper, self).__init__(env)
self.epsilon = epsilon
在这里,我们通过调用父类的 init 方法并保存 epsilon(随机动作的概率)来初始化我们的包装器。
以下是我们需要从父类重写的方法,用于调整智能体的动作:
def action(self, action: gym.core.WrapperActType) -> gym.core.WrapperActType:
if random.random() < self.epsilon:
action = self.env.action_space.sample()
print(f"Random action {action}")
return action
return action
每次我们掷骰子时,凭借 epsilon 的概率,我们从动作空间中采样一个随机动作并返回,而不是返回智能体发送给我们的动作。请注意,使用 action_space 和包装器抽象,我们能够编写抽象代码,这段代码可以与 Gym 中的任何环境一起工作。我们还在控制台上打印了消息,仅仅是为了说明我们的包装器正在工作。在生产代码中,当然不需要这么做。
现在是时候应用我们的包装器了。我们将创建一个普通的 CartPole 环境,并将其传递给我们的 Wrapper 构造函数:
if __name__ == "__main__":
env = RandomActionWrapper(gym.make("CartPole-v1"))
从现在起,我们将把我们的包装器当作一个普通的 Env 实例来使用,而不是原始的 CartPole。由于 Wrapper 类继承了 Env 类并暴露了相同的接口,我们可以根据需要将包装器嵌套得很深。这是一个强大、优雅和通用的解决方案。
这里的代码几乎与随机智能体中的代码相同,只不过每次我们发出相同的动作 0,所以我们的智能体显得呆板,一直做同样的事情:
obs = env.reset()
total_reward = 0.0
while True:
obs, reward, done, _, _ = env.step(0)
total_reward += reward
if done:
break
print(f"Reward got: {total_reward:.2f}")
运行代码后,你应该能看到包装器确实在工作:
Chapter02$ python 03_random_action_wrapper.py
Random action 0
Random action 0
Reward got: 9.00
现在我们应该继续,看看在执行期间如何渲染你的环境。
渲染环境
另一个你应该了解的可能性是渲染环境。它是通过两个包装器实现的:HumanRendering 和 RecordVideo。
这两个类替代了 OpenAI Gym 库中已被移除的原始 Monitor 包装器。这个类能够将有关智能体表现的信息记录到文件中,并可选地记录智能体动作的视频。
使用 Gymnasium 库,你可以通过两个类来检查环境内部的情况。第一个是 HumanRendering,它打开一个单独的图形窗口,在该窗口中,环境中的图像会以交互方式显示。为了能够渲染环境(在我们的例子中是 CartPole),必须使用 render_mode="rgb_array" 参数进行初始化。这个参数告诉环境返回来自其 render() 方法的像素,而该方法由 HumanRendering 包装器调用。
因此,要使用 HumanRenderer 包装器,你需要修改随机代理的代码(完整代码位于 Chapter02/04_cartpole_random_monitor.py):
if __name__ == "__main__":
env = gym.make("CartPole-v1", render_mode="rgb_array")
env = gym.wrappers.HumanRendering(env)
如果你启动代码,带有环境渲染的窗口将会出现。由于我们的代理无法保持平衡杆太长时间(最多 10-30 步),一旦调用 env.close() 方法,窗口会很快消失。

图 2.5:通过 HumanRendering 渲染的 CartPole 环境
另一个可能有用的包装器是 RecordVideo,它捕获环境中的像素并生成一个展示代理行为的视频文件。它与 human renderer 的使用方式相同,但需要一个额外的参数来指定存储视频文件的目录。如果目录不存在,它会被创建:
if __name__ == "__main__":
env = gym.make("CartPole-v1", render_mode="rgb_array")
env = gym.wrappers.RecordVideo(env, video_folder="video")
启动代码后,它会报告所生成视频的名称:
Chapter02$ python 04_cartpole_random_monitor.py
Moviepy - Building video Chapter02/video/rl-video-episode-0.mp4\.
Moviepy - Writing video Chapter02/video/rl-video-episode-0.mp4
Moviepy - Done !
Moviepy - video ready Chapter02/video/rl-video-episode-0.mp4
Episode done in 30 steps, total reward 30.00
这个包装器特别有用,当你在没有 GUI 的远程机器上运行代理时。
更多包装器
Gymnasium 提供了许多其他的包装器,我们将在接下来的章节中使用。它可以对 Atari 游戏图像进行标准化预处理,进行奖励归一化,堆叠观察帧,进行环境向量化,设置时间限制等。
可用的完整包装器列表可以在文档中找到,gymnasium.farama.org/api/wrappers/,也可以在源代码中查看。
总结
你已经开始学习强化学习的实践部分!在这一章中,我们使用了 Gymnasium,探索了其众多可以使用的环境。我们研究了它的基本 API,并创建了一个随机行为的代理。
你还学习了如何以模块化的方式扩展现有环境的功能,并且熟悉了通过包装器渲染代理活动的方式。这将在接下来的章节中得到广泛应用。
在下一章中,我们将使用 PyTorch 进行快速的深度学习回顾,PyTorch 是最广泛使用的深度学习工具包之一。
第三章:使用 PyTorch 进行深度学习
在前一章中,你已经熟悉了开源库,它们为你提供了一系列强化学习(RL)环境。然而,强化学习的最新发展,特别是与深度学习(DL)结合后,使得现在可以解决比以往更具挑战性的问题。这在某种程度上归功于深度学习方法和工具的发展。本章专门介绍了其中一个工具——PyTorch,它使我们能够用少量的 Python 代码实现复杂的深度学习模型。
本章并不假设自己是一本完整的深度学习手册,因为这一领域非常广泛且动态;然而,我们将涵盖:
-
PyTorch 库的具体细节和实现方式(假设你已经熟悉深度学习的基础)
-
基于 PyTorch 的高级库,旨在简化常见的深度学习问题
-
本章示例中将使用 PyTorch Ignite 库
本章中的所有示例都已更新为最新的(在写作时)PyTorch 2.3.1,相较于第二版书中使用的 1.3.0 版本有所变化。如果你还在使用旧版 PyTorch,建议升级。在本章中,我们将讨论最新版本中的差异。
张量
张量是所有深度学习工具包的基本构建块。这个名字听起来有些神秘,但其背后的基本思想是,张量只是一个多维数组。借用学校数学的类比,一个数字像一个点,是零维的;向量像一个线段,是一维的;矩阵是一个二维对象。三维的数字集合可以通过一个立方体的数字表示,但它们不像矩阵那样有一个独立的名称。我们可以保留“张量”这个术语来表示更高维度的集合。

图 3.1:从一个数字到 n 维张量的转换
关于深度学习中使用的张量,还有一个需要注意的点是,它们与张量微积分或张量代数中使用的张量仅部分相关。在深度学习中,张量是任何多维数组,但在数学中,张量是向量空间之间的映射,在某些情况下可能表现为多维数组,但其背后有更丰富的语义负载。数学家通常会对那些用已建立的数学术语命名不同事物的人表示不满,因此需要警惕!
张量的创建
由于本书中会到处使用张量,我们需要熟悉它们的基本操作,而最基本的操作就是如何创建一个张量。创建张量有几种方式,你的选择可能会影响代码的可读性和性能。
如果你熟悉 NumPy 库(而且你应该熟悉),那么你已经知道它的主要目的是以通用方式处理多维数组。尽管在 NumPy 中,这些数组没有被称为张量,但它们实际上就是张量。张量在科学计算中被广泛使用,作为数据的通用存储方式。例如,一张彩色图像可以被编码为一个三维张量,维度分别是宽度、高度和颜色通道。除了维度,张量还由其元素的类型来表征。PyTorch 支持 13 种类型:
-
四种浮点类型:16 位、32 位和 64 位。16 位浮点数有两种变体:
float16提供更多的精度位,而bfloat16具有更大的指数部分。 -
三种复杂类型:32 位、64 位和 128 位
-
五种整数类型:8 位有符号、8 位无符号、16 位有符号、32 位有符号和 64 位有符号
-
布尔类型
也有四种“量化数值”类型,但它们使用的是前面提到的类型,只是采用不同的位表示和解释方式。
不同类型的张量由不同的类表示,最常用的有 torch.FloatTensor(对应 32 位浮点数)、torch.ByteTensor(8 位无符号整数)和 torch.LongTensor(64 位有符号整数)。你可以在文档中查找其他张量类型的名称。
在 PyTorch 中,有三种创建张量的方法:
-
通过调用所需类型的构造函数来创建。
-
通过让 PyTorch 为你创建一个包含特定数据的张量。例如,你可以使用
torch.zeros()函数创建一个填充零值的张量。 -
通过将 NumPy 数组或 Python 列表转换为张量。在这种情况下,张量的类型将取决于数组的类型。
为了给你展示这些方法的例子,让我们看一个简单的会话:
$ python
>>> import torch
>>> import numpy as np
>>> a = torch.FloatTensor(3, 2)
>>> a
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
在这里,我们导入了 PyTorch 和 NumPy,并创建了一个新的大小为 3 × 2 的浮点张量。正如你所看到的,PyTorch 会用零来初始化内存,这与以前的版本不同。之前,它只是分配了内存并保持未初始化状态,虽然这样更快,但不太安全(因为可能会引入棘手的 bug 和安全问题)。不过,你不应该依赖这种行为,因为它可能会发生变化(或在不同硬件后端上表现不同),所以始终应该初始化张量的内容。为此,你可以使用其中一种张量构造操作符:
>>> torch.zeros(3, 4)
tensor([[0., 0., 0., 0.],
[0., 0., 0., 0.],
[0., 0., 0., 0.]])
或者你可以调用张量修改方法:
>>> a.zero_()
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
张量有两种操作类型:原地操作和函数式操作。原地操作会在名称后附加一个下划线,并对张量的内容进行操作。操作完成后,返回的是原始对象本身。函数式操作则会创建张量的一个副本,并进行修改,原始张量保持不变。从性能和内存角度看,原地操作通常更高效,但修改现有张量(尤其是当它在不同代码片段中共享时)可能会引发潜在的 bug。
通过构造函数创建张量的另一种方法是提供一个 Python 可迭代对象(例如,列表或元组),该对象将作为新创建的张量的内容:
>>> torch.FloatTensor([[1,2,3],[3,2,1]])
tensor([[1., 2., 3.],
[3., 2., 1.]])
在这里,我们通过 NumPy 数组创建相同的零张量:
>>> n = np.zeros(shape=(3, 2))
>>> n
array([[0., 0.],
[0., 0.],
[0., 0.]])
>>> b = torch.tensor(n)
>>> b
tensor([[0., 0.],
[0., 0.],
[0., 0.]], dtype=torch.float64)
torch.tensor 方法接受 NumPy 数组作为参数,并从中创建一个适当形状的张量。在前面的示例中,我们创建了一个初始化为零的 NumPy 数组,默认创建了一个双精度(64 位浮动)数组。因此,生成的张量具有 DoubleTensor 类型(在示例中通过 dtype 值显示)。通常,在深度学习中,不需要双精度,并且它会增加额外的内存和性能开销。常见做法是使用 32 位浮动类型,甚至 16 位浮动类型,这已经足够。要创建这样的张量,您需要明确指定 NumPy 数组的类型:
>>> n = np.zeros(shape=(3, 2), dtype=np.float32)
>>> torch.tensor(n)
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
作为一种选择,所需张量的类型可以通过 dtype 参数提供给 torch.tensor 函数。然而,请小心,因为此参数期望的是 PyTorch 类型规范,而不是 NumPy 类型规范。PyTorch 类型存储在 torch 包中,例如 torch.float32、torch.uint8 等。
>>> n = np.zeros(shape=(3,2))
>>> torch.tensor(n, dtype=torch.float32)
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
兼容性说明
torch.tensor()方法和显式的 PyTorch 类型指定功能是在 0.4.0 版本中添加的,这是简化张量创建的一个步骤。在之前的版本中,推荐使用 torch.from_numpy()函数来转换 NumPy 数组,但它在处理 Python 列表和 NumPy 数组组合时存在问题。为了向后兼容,这个 from_numpy()函数仍然存在,但它已被弃用,推荐使用更灵活的 torch.tensor()方法。
标量张量
从 0.4.0 版本开始,PyTorch 支持零维张量,这些张量对应标量值(如图 3.1 左侧所示)。这类张量可以是某些操作的结果,例如对张量中所有值的求和。此前,此类情况通过创建一个维度为 1 的单维张量(也称为向量)来处理。
这个解决方案有效,但并不简单,因为需要额外的索引才能访问值。现在,零维张量已被原生支持,并由相应的函数返回,可以通过 torch.tensor()函数创建。要访问此类张量的实际 Python 值,可以使用特殊的 item()方法:
>>> a = torch.tensor([1,2,3])
>>> a
tensor([1, 2, 3])
>>> s = a.sum()
>>> s
tensor(6)
>>> s.item()
6
>>> torch.tensor(1)
tensor(1)
张量操作
你可以对张量执行许多操作,操作种类太多,无法一一列举。通常,只需在 PyTorch 文档中搜索pytorch.org/docs/即可。我需要提到的是,有两个地方可以查找操作:
-
torch 包:该函数通常接受张量作为参数。
-
tensor 类:该函数操作于被调用的张量。
大多数时候,PyTorch 中的张量操作都是试图与其 NumPy 对应的功能相匹配,因此,如果 NumPy 中有一些不太特殊的函数,那么很有可能 PyTorch 也会有类似的函数。比如 torch.stack()、torch.transpose() 和 torch.cat()。这非常方便,因为 NumPy 是一个广泛使用的库(尤其在科学界),因此你的 PyTorch 代码可以被任何熟悉 NumPy 的人读取,而无需查阅文档。
GPU 张量
PyTorch 透明地支持 CUDA GPU,这意味着所有操作都有两个版本——CPU 和 GPU——并且会自动选择。这个选择是基于你正在操作的张量类型来决定的。
我提到的每种张量类型都是针对 CPU 的,并且都有其 GPU 对应版本。唯一的区别是,GPU 张量位于 torch.cuda 包中,而不是仅仅在 torch 中。例如,torch.FloatTensor 是一个 32 位浮动张量,驻留在 CPU 内存中,但 torch.cuda.FloatTensor 是它的 GPU 对应张量。
实际上,在 PyTorch 的底层,不仅支持 CPU 和 CUDA,还引入了后端的概念,这是一种带有内存的抽象计算设备。张量可以分配到后端的内存中,并且可以在其上进行计算。例如,在苹果硬件上,PyTorch 支持作为名为 mps 的后端的 Metal 性能着色器(MPS)。在本章中,我们将重点讨论 CPU 和 GPU 作为最常用的后端,但你的 PyTorch 代码也可以在更高级的硬件上执行,而无需做重大修改。
要从 CPU 转换到 GPU,可以使用张量方法 to(device),该方法会将张量的副本创建到指定的设备(可以是 CPU 或 GPU)。如果张量已经在该设备上,则什么也不发生,原始张量将被返回。设备类型可以通过不同方式指定。首先,你可以直接传递设备的字符串名称,对于 CPU 内存是 "cpu",对于 GPU 是 "cuda"。GPU 设备可以在冒号后面指定一个可选的设备索引;例如,系统中的第二张 GPU 卡可以通过 "cuda:1" 来表示(索引是从零开始的)。
在 to() 方法中,指定设备的另一种略微更高效的方式是使用 torch.device 类,它接受设备名称和可选的索引。要访问张量当前所在的设备,可以使用设备属性:
>>> a = torch.FloatTensor([2,3])
>>> a
tensor([2., 3.])
>>> ca = a.to(’cuda’)
>>> ca
tensor([2., 3.], device=’cuda:0’)
在这里,我们创建了一个位于 CPU 上的张量,然后将其复制到 GPU 内存中。两个副本都可以用于计算,并且所有与 GPU 相关的机制对用户是透明的:
>>> a+1
tensor([3., 4.])
>>> ca + 1
tensor([3., 4.], device=’cuda:0’)
>>> ca.device
device(type=’cuda’, index=0)
to() 方法和 torch.device 类在 0.4.0 版本中引入。在早期版本中,CPU 和 GPU 之间的复制是通过单独的张量方法 cpu() 和 cuda() 来完成的,这需要添加额外的代码行来显式地将张量转换为它们的 CUDA 版本。在新的 PyTorch 版本中,你可以在程序开始时创建一个所需的 torch.device 对象,并在每个创建的张量上使用 to(device)。旧的张量方法 cpu() 和 cuda() 仍然存在,并且如果你希望确保张量在 CPU 或 GPU 内存中,不管它原来的位置在哪里,它们仍然可能会派上用场。
梯度
即使有透明的 GPU 支持,所有这些与张量的“跳舞”也毫无意义,除非有一个“杀手级功能” —— 自动计算梯度。这个功能最早在 Caffe 工具包中实现,后来成为了深度学习库中的事实标准。
早期,手动计算梯度是一个非常痛苦的过程,甚至对于最简单的神经网络(NN)来说也是如此。你需要为所有的函数计算导数,应用链式法则,然后实现计算结果,祈祷一切都能正确完成。这可能是理解深度学习核心机制的一个有用练习,但它绝对不是你愿意通过不断尝试不同的神经网络架构来反复做的事。
幸运的是,那些日子已经过去了,就像用烙铁和真空管编程硬件一样!现在,定义一个有数百层的神经网络,仅需要将它从预定义的构建块中组装起来,或者在你做一些特别的事情时,手动定义变换表达式。
所有的梯度将会被仔细计算、反向传播,并应用到网络中。为了实现这一点,你需要使用深度学习库的基本组件来定义你的网络架构。在图 3.2 中,我概述了数据和梯度在优化过程中的流动方向:

图 3.2:数据和梯度流经神经网络
产生根本性差异的因素可能是你计算梯度的方式。这里有两种方法:
-
静态图:在这种方法中,你需要提前定义你的计算过程,并且之后无法更改它们。图形将在任何计算执行之前由深度学习库处理和优化。这种模型在 TensorFlow(2.0 之前的版本)、Theano 和许多其他深度学习工具包中实现。
-
动态图:你不需要提前精确定义你的图形如何执行;你只需要在实际数据上执行你希望用于数据转换的操作。在此过程中,库会记录执行操作的顺序,当你要求它计算梯度时,它会展开其操作历史,累积网络参数的梯度。这个方法也叫做笔记本梯度,它在 PyTorch、Chainer 和其他一些框架中得到了实现。
两种方法各有优缺点。例如,静态图通常更快,因为所有计算可以移动到 GPU 上,从而最小化数据传输开销。此外,在静态图中,库在优化计算顺序,甚至删除图形的一部分时,拥有更多的自由度。
另一方面,尽管动态图具有更高的计算开销,但它为开发者提供了更多的自由度。例如,开发者可以说,“对于这一块数据,我可以应用这个网络两次,而对于另一块数据,我会使用完全不同的模型,并且对梯度进行批均值裁剪”。动态图模型的另一个非常吸引人的优点是,它允许你以更自然、更“Pythonic”的方式表达转换。最终,这不过是一个包含一堆函数的 Python 库,所以只需要调用它们,让库来完成魔法。
自 2.0 版本以来,PyTorch 引入了 torch.compile 函数,通过 JIT 编译将代码转化为优化后的内核,从而加速 PyTorch 代码的执行。这是早期版本中 TorchScript 和 FX Tracing 编译方法的演变。
从历史角度来看,这非常有趣,最初完全不同的 TensorFlow(静态图)和 PyTorch(动态图)方法如何随着时间推移逐渐融合在一起。如今,PyTorch 支持 compile(),而 TensorFlow 则有了“急切执行模式”。
张量与梯度
PyTorch 张量具有内建的梯度计算和跟踪机制,所以你只需要将数据转换为张量,并使用 torch 提供的张量方法和函数进行计算。当然,如果你需要访问底层的细节,也可以,但大多数情况下,PyTorch 会按照你的预期工作。
每个张量都有几个与梯度相关的属性:
-
grad:一个属性,保存一个形状相同的张量,包含计算出的梯度。
-
is_leaf:如果该张量是用户构造的,则为 True;如果该对象是函数转换的结果(换句话说,计算图中有父节点),则为 False。
-
requires_grad:如果这个张量需要计算梯度,则为 True。这个属性从叶张量继承而来,叶张量在构造时就会得到这个值(如 torch.zeros() 或 torch.tensor() 等)。默认情况下,构造函数的 requires_grad=False,因此如果你希望为张量计算梯度,你需要明确指定。
为了让所有这些梯度-叶节点机制更加清晰,让我们考虑一下这个会话:
>>> v1 = torch.tensor([1.0, 1.0], requires_grad=True)
>>> v2 = torch.tensor([2.0, 2.0])
在这里,我们创建了两个张量。第一个需要计算梯度,第二个则不需要。
接下来,我们对两个向量按元素加法(即向量 [3, 3])进行了操作,随后将每个元素乘以 2 并相加:
>>> v_sum = v1 + v2
>>> v_sum
tensor([3., 3.], grad_fn=<AddBackward0>)
>>> v_res = (v_sum*2).sum()
>>> v_res
tensor(12., grad_fn=<SumBackward0>)
结果是一个零维张量,其值为 12。好的,到目前为止这只是一个简单的数学运算。现在,让我们来看看我们表达式所创建的底层图:

图 3.3:表达式的图表示
如果我们检查张量的属性,就会发现 v1 和 v2 是唯一的叶节点,并且除 v2 外的每个变量都需要计算梯度:
>>> v1.is_leaf, v2.is_leaf
(True, True)
>>> v_sum.is_leaf, v_res.is_leaf
(False, False)
>>> v1.requires_grad
True
>>> v2.requires_grad
False
>>> v_sum.requires_grad
True
>>> v_res.requires_grad
True
如你所见,属性 requires_grad 是有“粘性”的:如果参与计算的变量之一将其设置为 True,那么所有后续节点也将继承这个属性。这是合乎逻辑的行为,因为我们通常需要对计算过程中的所有中间步骤计算梯度。但是,“计算”并不意味着它们会被保留在 .grad 字段中。为了内存效率,只有要求计算梯度的叶节点会保存梯度。如果你希望在非叶节点中保留梯度,你需要调用它们的 retain_grad() 方法,这样 PyTorch 就会告诉它们保留梯度。
现在,让我们告诉 PyTorch 计算我们图的梯度:
>>> v_res.backward()
>>> v1.grad
tensor([2., 2.])
通过调用 backward 函数,我们让 PyTorch 计算 v_res 变量相对于图中其他变量的数值导数。换句话说,v_res 变量的小幅变化对图中其他部分的影响是什么?在我们的这个例子中,v1 梯度中的值 2 表示通过将 v1 的任何元素增加 1,v_res 的结果值将增加 2。
如前所述,PyTorch 只计算要求计算梯度的叶张量的梯度。事实上,如果我们尝试检查 v2 的梯度,我们将不会得到任何结果:
>>> v2.grad
这样做的原因是为了提高计算和内存的效率。在实际应用中,我们的网络可能会有数百万个优化参数,并对它们执行数百次中间操作。在梯度下降优化过程中,我们并不关心任何中间矩阵乘法的梯度;我们只关心模型中损失函数相对于模型参数(权重)的梯度。当然,如果你想计算输入数据的梯度(如果你想生成一些对抗样本来欺骗现有的神经网络或调整预训练的词嵌入,这可能是有用的),那么你可以通过在创建张量时传递requires_grad=True来轻松实现。
基本上,你现在已经具备了实现自己神经网络优化器所需的一切。本章的剩余部分将介绍一些额外的、便捷的功能,它们将为你提供更高层次的神经网络架构模块、流行的优化算法和常见的损失函数。然而,不要忘记你可以轻松地以任何方式重新实现所有这些花里胡哨的功能。这就是为什么 PyTorch 在深度学习研究人员中如此受欢迎——因为它的优雅与灵活性。
兼容性
张量中梯度计算的支持是 PyTorch 0.4.0 版本的重大变化之一。在之前的版本中,图追踪和梯度积累是在一个独立且非常薄的类——Variable 中完成的。它作为张量的包装器,自动保存计算历史,以便能够进行反向传播。这个类在 2.2.0 版本中仍然存在(可在 torch.autograd 中找到),但它已被弃用,并将很快被移除,因此新代码应避免使用它。从我的角度来看,这个变化非常好,因为 Variable 的逻辑非常薄弱,但它仍然需要额外的代码以及开发者的注意来包装和解包装张量。现在,梯度已成为张量的内建属性,这使得 API 变得更加简洁。
神经网络构建模块
在 torch.nn 包中,你会发现许多预定义的类,为你提供了基本的功能模块。所有这些类都是从实践出发设计的(例如,它们支持小批量处理,拥有合理的默认值,并且权重得到了适当初始化)。所有模块遵循可调用的约定,这意味着任何类的实例在应用于其参数时可以充当函数。例如,Linear 类实现了一个前馈层,带有可选的偏置:
>>> l = nn.Linear(2, 5)
>>> v = torch.FloatTensor([1, 2])
>>> l(v)
tensor([-0.1039, -1.1386, 1.1376, -0.3679, -1.1161], grad_fn=<ViewBackward0>)
在这里,我们创建了一个随机初始化的前馈层,具有两个输入和五个输出,并将其应用于我们的浮动张量。torch.nn 包中的所有类都继承自 nn.Module 基类,你可以使用它来实现自己的更高层次的神经网络模块。你将在下一节中看到如何做到这一点,但现在,让我们先来看看所有 nn.Module 子类提供的有用方法。它们如下:
-
parameters():此函数返回一个迭代器,包含所有需要计算梯度的变量(即模块权重)。
-
zero_grad():此函数将所有参数的梯度初始化为零。
-
to(device):此函数将所有模块参数移动到给定设备(CPU 或 GPU)。
-
state_dict():此函数返回包含所有模块参数的字典,对于模型序列化非常有用。
-
load_state_dict():此函数使用状态字典初始化模块。
所有可用类的完整列表可以在文档中找到,网址是pytorch.org/docs。
现在,我应该提到一个非常方便的类,它允许你将其他层组合到管道中:Sequential。通过一个示例展示 Sequential 的最佳方式如下:
>>> s = nn.Sequential(
... nn.Linear(2, 5),
... nn.ReLU(),
... nn.Linear(5, 20),
... nn.ReLU(),
... nn.Linear(20, 10),
... nn.Dropout(p=0.3),
... nn.Softmax(dim=1))
>>> s
Sequential(
(0): Linear(in_features=2, out_features=5, bias=True)
(1): ReLU()
(2): Linear(in_features=5, out_features=20, bias=True)
(3): ReLU()
(4): Linear(in_features=20, out_features=10, bias=True)
(5): Dropout(p=0.3, inplace=False)
(6): Softmax(dim=1)
)
这里,我们定义了一个三层神经网络,输出使用 softmax,沿维度 1 进行应用(维度 0 是批样本),使用修正线性单元(ReLU)非线性激活函数,以及 dropout。让我们通过它推送一些数据:
>>> s(torch.FloatTensor([[1,2]]))
tensor([[0.0847, 0.1145, 0.1063, 0.1458, 0.0873, 0.1063, 0.0864, 0.0821, 0.0894,
0.0971]], grad_fn=<SoftmaxBackward0>)
所以,我们的一个向量的迷你批次成功地通过了网络!
自定义层
在前面的部分,我简要提到过 nn.Module 类,它是 PyTorch 暴露的所有神经网络构建块的基类。它不仅仅是现有层的统一父类——它远不止于此。通过子类化 nn.Module 类,你可以创建自己的构建块,这些构建块可以被堆叠在一起,稍后可以重复使用,并无缝地集成到 PyTorch 框架中。
从本质上讲,nn.Module 为其子类提供了非常丰富的功能。
-
它跟踪当前模块包含的所有子模块。例如,你的构建块可能有两个前馈层,用于某种方式执行该块的变换。为了跟踪(注册)子模块,你只需将其分配给类的字段。
-
它提供了处理已注册子模块所有参数的功能。你可以获取模块参数的完整列表(parameters() 方法)、将其梯度归零(zero_grads() 方法)、移动到 CPU 或 GPU(to(device) 方法)、序列化和反序列化模块(state_dict() 和 load_state_dict() 方法),甚至可以使用你自己的可调用函数执行通用变换(apply() 方法)。
-
它建立了模块应用于数据的约定。每个模块都需要通过重写 forward()方法来执行数据变换。
-
还有一些其他功能,比如注册钩子函数以调整模块的变换或梯度流,但这些更多用于高级用例。
这些功能使我们能够以统一的方式将子模型嵌套到更高级别的模型中,这在处理复杂性时非常有用。无论是简单的一层线性变换,还是一个 1001 层的残差神经网络(ResNet)怪兽,只要它们遵循 nn.Module 的约定,那么这两者就可以用相同的方式处理。这对于代码的重用和简化(通过隐藏不相关的实现细节)非常方便。
为了简化我们的工作,遵循上述约定时,PyTorch 的作者通过精心设计和大量 Python 魔法简化了模块的创建。所以,创建自定义模块时,通常只需要做两件事——注册子模块和实现 forward()方法。
让我们看看如何以更通用和可重用的方式来完成之前章节中我们用到的 Sequential 示例(完整示例见 Chapter03/01_modules.py)。以下是我们的模块类,它继承自 nn.Module:
class OurModule(nn.Module):
def __init__(self, num_inputs, num_classes, dropout_prob=0.3):
super(OurModule, self).__init__()
self.pipe = nn.Sequential(
nn.Linear(num_inputs, 5),
nn.ReLU(),
nn.Linear(5, 20),
nn.ReLU(),
nn.Linear(20, num_classes),
nn.Dropout(p=dropout_prob),
nn.Softmax(dim=1)
)
在构造函数中,我们传入三个参数:输入大小、输出大小和可选的 dropout 概率。我们需要做的第一件事是调用父类的构造函数,让它初始化自己。
在前面代码的第二步中,我们创建了一个已经熟悉的 nn.Sequential,并用一堆层来初始化它,然后将其赋值给我们名为 pipe 的类字段。通过将 Sequential 实例赋值给对象的字段,我们将自动注册这个模块(nn.Sequential 继承自 nn.Module,就像 nn 包中的所有模块一样)。为了注册它,我们不需要调用任何东西,只需要将子模块赋值给字段。构造函数完成后,所有这些字段将自动注册。如果你真的需要,也可以通过 nn.Module 中的 add_module()函数来注册子模块。如果你的模块有可变数量的层,且需要通过编程方式创建这些层,这个函数可能会非常有用。
接下来,我们必须用数据转换的实现重写 forward 函数:
def forward(self, x):
return self.pipe(x)
由于我们的模块只是 Sequential 类的一个非常简单的封装,我们只需要让 self.pipe 来转换数据。请注意,要将一个模块应用于数据,我们需要像调用函数一样调用模块(也就是说,将模块实例当作函数来调用,并传入参数),而不是使用 nn.Module 类的 forward()方法。这是因为 nn.Module 重载了 call()方法,当我们将实例当作可调用对象时,这个方法会被使用。这个方法做了一些 nn.Module 的魔法,并调用了我们的 forward()方法。如果直接调用 forward(),我们会干扰 nn.Module 的职责,可能会得到错误的结果。
所以,这就是我们定义自己模块所需要做的事情。现在,让我们使用它:
if __name__ == "__main__":
net = OurModule(num_inputs=2, num_classes=3)
print(net)
v = torch.FloatTensor([[2, 3]])
out = net(v)
print(out)
print("Cuda’s availability is %s" % torch.cuda.is_available())
if torch.cuda.is_available():
print("Data from cuda: %s" % out.to(’cuda’))
我们创建我们的模块,提供所需数量的输入和输出,然后创建一个张量并要求我们的模块对其进行转换,按照将其作为可调用对象的相同约定进行操作。之后,我们打印网络的结构(nn.Module 重写了 str() 和 repr()),以以一种清晰的方式表示内部结构。最后我们展示的是网络转换的结果。我们代码的输出应如下所示:
Chapter03$ python 01_modules.py
OurModule(
(pipe): Sequential(
(0): Linear(in_features=2, out_features=5, bias=True)
(1): ReLU()
(2): Linear(in_features=5, out_features=20, bias=True)
(3): ReLU()
(4): Linear(in_features=20, out_features=3, bias=True)
(5): Dropout(p=0.3, inplace=False)
(6): Softmax(dim=1)
)
)
tensor([[0.3297, 0.3854, 0.2849]], grad_fn=<SoftmaxBackward0>)
Cuda’s availability is False
当然,关于 PyTorch 动态特性的所有说法仍然适用。每处理一批数据,都会调用 forward() 方法,所以如果您想根据需要处理的数据执行一些复杂的转换,例如层次化 Softmax 或随机选择应用的网络,那么没有什么能阻止您这样做。您模块的参数个数也不局限于一个参数。因此,如果您愿意,您可以编写一个需要多个必需参数和数十个可选参数的模块,它也完全没问题。
接下来,我们需要熟悉 PyTorch 库中的两个重要部分,这将简化我们的工作:损失函数和优化器。
损失函数和优化器
将输入数据转换为输出的网络并不是我们训练所需的唯一部分。我们还需要定义学习目标,该目标必须是一个接受两个参数的函数——网络的输出和期望的输出。它的职责是返回一个单一的数值——网络的预测与期望结果的差距。这个函数称为损失函数,它的输出即为损失值。通过损失值,我们计算网络参数的梯度,并调整这些参数以减少损失值,从而推动模型未来取得更好的结果。损失函数和通过梯度调整网络参数的方法如此常见,且以多种形式存在,以至于它们成为 PyTorch 库的重要组成部分。我们从损失函数开始。
损失函数
损失函数位于 nn 包中,并作为 nn.Module 的子类实现。通常,它们接受两个参数:来自网络的输出(预测值)和期望的输出(真实数据,也称为数据样本的标签)。截至本文编写时,PyTorch 2.3.1 包含了超过 20 种不同的损失函数,当然,您也可以编写任何自定义的函数来进行优化。
最常用的标准损失函数有:
-
nn.MSELoss:计算两个参数之间的均方误差,这是回归问题的标准损失。
-
nn.BCELoss 和 nn.BCEWithLogits:二元交叉熵损失。第一种版本期望一个单一的概率值(通常是 Sigmoid 层的输出),而第二种版本假设原始分数作为输入并自行应用 Sigmoid。第二种方式通常在数值上更稳定且更高效。这些损失函数(如其名称所示)通常用于二元分类问题。
-
nn.CrossEntropyLoss 和 nn.NLLLoss:在多类分类问题中使用的著名“最大似然”标准。第一个版本期望每个类的原始得分,并在内部应用 LogSoftmax,而第二个版本期望输入的是对数概率。
还有其他损失函数可供选择,您可以随时编写自己的模块子类来比较输出和目标。现在,让我们看看优化过程的第二部分。
优化器
基本优化器的职责是获取模型参数的梯度,并更改这些参数以减少损失值。通过减少损失值,我们将模型推向期望的输出,这为未来模型表现的提升带来希望。改变参数听起来很简单,但这里有很多细节,优化过程仍然是一个热门的研究课题。在 torch.optim 包中,PyTorch 提供了许多流行的优化器实现,其中最广为人知的如下:
-
SGD:一种常规的随机梯度下降算法,带有可选的动量扩展
-
RMSprop:Geoffrey Hinton 提出的优化器
-
Adagrad:一种自适应梯度优化器
-
Adam:RMSprop 和 Adagrad 的成功且流行的组合
所有优化器都公开统一接口,这使得尝试不同的优化方法变得更加容易(有时候,优化方法确实会对收敛动态和最终结果产生影响)。在构造时,您需要传递一个张量的可迭代对象,这些张量将在优化过程中被修改。通常做法是传递上层 nn.Module 实例的 params()调用结果,该调用将返回所有叶张量(包含梯度)的可迭代对象。
现在,让我们讨论训练循环的常见蓝图:
for batch_x, batch_y in iterate_batches(data, batch_size=N):
batch_x_t = torch.tensor(batch_x)
batch_y_t = torch.tensor(batch_y)
out_t = net(batch_x_t)
loss_t = loss_function(out_t, batch_y_t).
loss_t.backward()
optimizer.step()
optimizer.zero_grad()
通常,您需要反复遍历数据(对整个示例集进行一次迭代称为一个 epoch)。数据通常过大,无法一次性加载到 CPU 或 GPU 内存中,因此它被拆分成大小相等的小批次。每个小批次包含数据样本和目标标签,它们都必须是张量(第 2 行和第 3 行)。
您将数据样本传递给网络(第 4 行),并将网络的输出和目标标签传递给损失函数(第 5 行)。损失函数的结果显示了网络结果相对于目标标签的“差距”。由于网络的输入和权重都是张量,网络的所有变换无非是一个包含中间张量实例的操作图。损失函数也是如此——其结果也是一个单一损失值的张量。
计算图中的每个张量都会记住它的父节点,因此,要计算整个网络的梯度,您只需要对损失函数的结果调用 backward() 函数(第 6 行)。这个调用的结果是展开已执行计算的图并为每个 require_grad=True 的叶子张量计算梯度。通常,这些张量是我们模型的参数,比如前馈网络的权重和偏置,以及卷积滤波器。每次计算梯度时,梯度都会累积到 tensor.grad 字段中,因此一个张量可以参与多次变换,并且它的梯度会被正确地加总。例如,一个单独的递归神经网络(RNN)单元可能会应用于多个输入项。
在调用 loss.backward() 后,我们已经积累了梯度,现在该轮到优化器发挥作用了——它会从构造时传入的参数中获取所有梯度并应用它们。所有这些操作都通过 step() 方法完成(第 7 行)。
训练循环中的最后一步,但并非最不重要的一步,是我们需要将参数的梯度归零。这可以通过在我们的网络上调用 zero_grad() 来完成,但为了方便起见,优化器也提供了这样一个调用,完成相同的操作(第 8 行)。有时,zero_grad() 会被放在训练循环的开始,但这其实并没有太大关系。
上述方案是一种非常灵活的优化方法,即使在复杂的研究中也能满足需求。例如,您可以让两个优化器在相同的数据上调整不同模型的选项(这是生成对抗网络(GAN)训练中的一个真实场景)。
所以,我们已经完成了 PyTorch 中训练神经网络所需的基本功能。本章最后将通过一个实际的中等规模的示例,来展示所有涵盖的概念,但在此之前,我们需要讨论一个对神经网络实践者至关重要的话题——监控学习过程。
使用 TensorBoard 进行监控
如果你曾尝试过自己训练神经网络(NN),那么你一定知道这有多么痛苦和不确定。我并不是说在跟随现有的教程和示范时,那时所有的超参数已经为你调好,而是说从一些数据开始,创造一些全新的东西。即使使用现代深度学习(DL)高层工具包,在这些工具包中,所有最佳实践(如适当的权重初始化;优化器的β、γ及其他选项设置为合理的默认值;以及大量其他隐藏的配置)都已做好准备,但你仍然需要做出许多决策,因此仍有许多可能出错的地方。结果是,你的代码几乎总是在第一次运行时就不工作,这是你必须习惯的事情。
当然,随着实践和经验的积累,你会对问题的可能原因有深入的理解,但这需要有关网络内部情况的输入数据。所以,你需要能够以某种方式窥视你的训练过程,并观察其动态。即使是小型网络(如微型 MNIST 教程网络)也可能拥有数十万参数,且训练动态相当非线性。
深度学习从业者已经开发出了一份你在训练过程中应该观察的事项清单,通常包括以下内容:
-
损失值,通常由几个组件组成,如基础损失和正则化损失。你应该随时间监控总损失和各个组成部分。
-
训练集和测试集上的验证结果。
-
关于梯度和权重的统计信息。
-
网络产生的值。例如,如果你在解决分类问题,肯定希望衡量预测类别概率的熵。如果是回归问题,原始的预测值可以提供大量关于训练的数据。
-
学习率和其他超参数,如果它们随时间调整的话。
这个清单可以更长,包含领域特定的度量指标,比如词嵌入投影、音频样本和 GAN 生成的图像。你也可能想要监控与训练速度相关的值,比如每个 epoch 的时间,以查看优化效果或硬件问题。
长话短说,你需要一个通用的解决方案,来跟踪大量的值,并将它们表示出来以供分析,最好是专门为深度学习开发的(想象一下用 Excel 电子表格查看这些统计数据)。幸运的是,这样的工具是存在的,我们接下来将对它们进行探讨。
TensorBoard 101
当本书的第一版写作时,神经网络监控的选择并不多。随着时间的推移,越来越多的人和公司投入到机器学习和深度学习的追求中,出现了更多的新工具,例如 MLflow mlflow.org/。在本书中,我们仍然会聚焦于 TensorFlow 的 TensorBoard 工具,但你可能会考虑尝试其他替代方案。
从第一个公开版本开始,TensorFlow 就包含了一个名为 TensorBoard 的特别工具,旨在解决我们正在讨论的问题——如何在训练过程中及训练后观察和分析各种神经网络特征。TensorBoard 是一个功能强大的通用解决方案,拥有庞大的社区,界面也相当漂亮:

图 3.4:TensorBoard 的网页界面(为了更好的可视化效果,请参考 https://packt.link/gbp/9781835882702)
从架构的角度来看,TensorBoard 是一个 Python Web 服务,你可以在自己的计算机上启动它,传递包含训练过程保存的值的目录。然后,你可以将浏览器指向 TensorBoard 的端口(通常是 6006),它会显示一个交互式的 Web 界面,实时更新显示数值,如图 3.4 所示。这非常方便,尤其是在你的训练是在云中的远程机器上进行时。
最初,TensorBoard 是作为 TensorFlow 的一部分发布的,但经过一段时间后,它被移到了一个独立的项目中(仍由 Google 维护),并且拥有了自己的包名。不过,TensorBoard 仍然使用 TensorFlow 的数据格式,因此我们需要从 PyTorch 程序中写入这些数据。几年前,这需要安装第三方库,但现在,PyTorch 已经原生支持这种数据格式(可以在 torch.utils.tensorboard 包中找到)。
绘制指标
为了让你了解使用 TensorBoard 有多简单,让我们考虑一个与神经网络无关的小例子,主要目的是将数值写入 TensorBoard(完整的示例代码在 Chapter03/02_tensorboard.py 中)。
在下面的代码中,我们导入所需的包,创建数据写入器,并定义我们要可视化的函数:
import math
from torch.utils.tensorboard.writer import SummaryWriter
if __name__ == "__main__":
writer = SummaryWriter()
funcs = {"sin": math.sin, "cos": math.cos, "tan": math.tan}
默认情况下,SummaryWriter 会为每次启动在 runs 目录中创建一个唯一的目录,以便比较不同轮次的训练。新目录的名称包括当前日期、时间和主机名。要覆盖此行为,你可以将 log_dir 参数传递给 SummaryWriter。你还可以通过传递 comment 参数来为目录名称添加后缀,例如捕获不同实验的语义,如 dropout=0.3 或 strong_regularisation。
接下来,我们循环遍历角度范围(以度为单位):
for angle in range(-360, 360):
angle_rad = angle * math.pi / 180
for name, fun in funcs.items():
val = fun(angle_rad)
writer.add_scalar(name, val, angle)
writer.close()
在这里,我们将角度范围转换为弧度并计算函数值。每个值都会通过 add_scalar 函数添加到写入器中,该函数需要三个参数:参数名称、值和当前迭代(必须是整数)。在循环结束后,我们需要做的最后一件事是关闭写入器。请注意,写入器会定期刷新(默认情况下,每两分钟一次),因此即使在优化过程很长的情况下,你也能看到你的数值。如果你需要显式刷新 SummaryWriter 数据,它有 flush() 方法。
运行此代码的结果是控制台没有输出,但你会看到在 runs 目录内创建了一个新目录,其中包含一个文件。要查看结果,我们需要启动 TensorBoard:
Chapter03$ tensorboard --logdir runs
TensorFlow installation not found - running with reduced feature set.
Serving TensorBoard on localhost; to expose to the network, use a proxy or pass --bind_all
TensorBoard 2.15.1 at http://localhost:6006/ (Press CTRL+C to quit)
如果你在远程服务器上运行 TensorBoard,你需要添加 --bind_all 命令行选项,以便从其他机器访问它。现在你可以在浏览器中打开 http://localhost:6006 来查看类似的内容:

图 3.5:示例生成的图表(欲获得更好的可视化效果,请参考 packt.link/gbp/9781835882702)
图表是交互式的,因此你可以用鼠标悬停在图表上查看实际值,并选择区域进行放大查看细节。要缩小视图,可以在图表内双击。如果你多次运行程序,你会在左侧的“运行”列表中看到多个项目,可以任意组合启用和禁用,方便你比较多个优化过程的动态。TensorBoard 允许你分析不仅是标量值,还包括图像、音频、文本数据和嵌入,并且它甚至可以显示你的网络结构。有关所有这些功能的详细信息,请参阅 TensorBoard 的文档。现在,是时候将本章学到的所有内容结合起来,使用 PyTorch 查看一个真实的神经网络优化问题了。
Atari 图像上的 GAN
几乎每本关于深度学习的书籍都会使用 MNIST 数据集来展示深度学习的强大,而多年来,这个数据集已经变得极其乏味,像是遗传学研究者眼中的果蝇。为了打破这一传统,并给书籍增添一些趣味,我尝试避免老生常谈的路径,并用一些不同的内容来展示 PyTorch。我在本章早些时候简要提到了生成对抗网络(GAN)。在这个例子中,我们将训练一个 GAN 来生成各种 Atari 游戏的屏幕截图。
最简单的 GAN 架构是这样的:我们有两个神经网络,其中第一个充当“作弊者”(也称为生成器),另一个充当“侦探”(另一个名字是判别器)。两个网络相互竞争——生成器试图生成伪造数据,判别器则很难将其与数据集中的真实数据区分开,而判别器则尝试检测生成的数据样本。随着时间的推移,两个网络都在提高它们的技能——生成器生成的伪造数据越来越逼真,判别器则发明了更复杂的方法来区分假数据。
GAN 的实际应用包括图像质量提升、逼真图像生成和特征学习。在我们的示例中,实际的实用性几乎为零,但它将是一个很好的展示,展示我们迄今为止学到的关于 PyTorch 的所有内容。
那么,我们开始吧。整个示例代码在文件 Chapter03/03_atari_gan.py 中。在这里,我们只看代码中最重要的部分,省略了导入部分和常量声明。以下类是对 Gym 游戏的封装:
class InputWrapper(gym.ObservationWrapper):
"""
Preprocessing of input numpy array:
1\. resize image into predefined size
2\. move color channel axis to a first place
"""
def __init__(self, *args):
super(InputWrapper, self).__init__(*args)
old_space = self.observation_space
assert isinstance(old_space, spaces.Box)
self.observation_space = spaces.Box(
self.observation(old_space.low), self.observation(old_space.high),
dtype=np.float32
)
def observation(self, observation: gym.core.ObsType) -> gym.core.ObsType:
# resize image
new_obs = cv2.resize(
observation, (IMAGE_SIZE, IMAGE_SIZE))
# transform (w, h, c) -> (c, w, h)
new_obs = np.moveaxis(new_obs, 2, 0)
return new_obs.astype(np.float32)
上述类包括几个转换:
-
将输入图像从 210×160(标准 Atari 分辨率)调整为 64 × 64 的正方形大小
-
将图像的颜色平面从最后的位置移到第一个位置,以符合 PyTorch 卷积层的惯例,这要求输入张量的形状为通道、高度和宽度
-
将图像从字节转换为浮动类型
然后,我们定义了两个 nn.Module 类:判别器和生成器。第一个类将我们缩放后的彩色图像作为输入,并通过五层卷积将其转换为一个通过 Sigmoid 非线性函数的单一数字。Sigmoid 的输出被解读为判别器认为输入图像来自真实数据集的概率。
生成器则接受一个随机数向量(潜在向量)作为输入,并通过“反卷积”操作(也称为转置卷积),将该向量转换为原始分辨率的彩色图像。由于这些类较长且与我们的示例不太相关,这里我们不再详细介绍;你可以在完整的示例文件中找到它们。
作为输入,我们将使用几个 Atari 游戏的截图,这些截图由一个随机代理同时播放。图 3.6 展示了输入数据的样子。

图 3.6:来自三款 Atari 游戏的截图样本
图像通过以下函数按批次进行组合:
def iterate_batches(envs: tt.List[gym.Env],
batch_size: int = BATCH_SIZE) -> tt.Generator[torch.Tensor, None, None]:
batch = [e.reset()[0] for e in envs]
env_gen = iter(lambda: random.choice(envs), None)
while True:
e = next(env_gen)
action = e.action_space.sample()
obs, reward, is_done, is_trunc, _ = e.step(action)
if np.mean(obs) > 0.01:
batch.append(obs)
if len(batch) == batch_size:
batch_np = np.array(batch, dtype=np.float32)
# Normalising input to [-1..1]
yield torch.tensor(batch_np * 2.0 / 255.0 - 1.0)
batch.clear()
if is_done or is_trunc:
e.reset()
这个函数会从提供的列表中无限地采样环境,发出随机动作,并将观察结果保存在批次列表中。当批次达到所需大小时,我们对图像进行归一化,将其转换为张量,并从生成器中输出。由于某个游戏中的一个 bug,检查观察值的非零均值是必需的,以防止图像闪烁。
现在,让我们看看我们的主函数,它准备了模型并运行训练循环:
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dev", default="cpu", help="Device name, default=cpu")
args = parser.parse_args()
device = torch.device(args.dev)
envs = [
InputWrapper(gym.make(name))
for name in (’Breakout-v4’, ’AirRaid-v4’, ’Pong-v4’)
]
shape = envs[0].observation_space.shape
在这里,我们处理命令行参数(可能只有一个可选参数 --dev,它指定用于计算的设备),并创建我们的环境池,应用了包装器。这个环境数组稍后会传递给 iterate_batches 函数来生成训练数据。
在接下来的部分,我们创建了我们的类——一个总结写入器、两个网络、一个损失函数和两个优化器:
net_discr = Discriminator(input_shape=shape).to(device)
net_gener = Generator(output_shape=shape).to(device)
objective = nn.BCELoss()
gen_optimizer = optim.Adam(params=net_gener.parameters(), lr=LEARNING_RATE,
betas=(0.5, 0.999))
dis_optimizer = optim.Adam(params=net_discr.parameters(), lr=LEARNING_RATE,
betas=(0.5, 0.999))
writer = SummaryWriter()
为什么我们需要两个优化器?这是因为 GANs 的训练方式:训练判别器时,我们需要给它展示真实和虚假的数据样本,并附上适当的标签(真实为 1,虚假为 0)。在这一过程中,我们只更新判别器的参数。
之后,我们再次将真实和虚假样本传入判别器,但这一次,所有样本的标签都是 1,我们只更新生成器的权重。第二次传递教会生成器如何欺骗判别器,并将真实样本与生成的样本混淆。
然后我们定义数组,用来累积损失、迭代器计数器和带有真实与虚假标签的变量。我们还存储当前的时间戳,以便在训练 100 次迭代后报告经过的时间:
gen_losses = []
dis_losses = []
iter_no = 0
true_labels_v = torch.ones(BATCH_SIZE, device=device)
fake_labels_v = torch.zeros(BATCH_SIZE, device=device)
ts_start = time.time()
在接下来的训练循环开始时,我们生成一个随机向量,并将其传递给生成器网络:
for batch_v in iterate_batches(envs):
# fake samples, input is 4D: batch, filters, x, y
gen_input_v = torch.FloatTensor(BATCH_SIZE, LATENT_VECTOR_SIZE, 1, 1)
gen_input_v.normal_(0, 1)
gen_input_v = gen_input_v.to(device)
batch_v = batch_v.to(device)
gen_output_v = net_gener(gen_input_v)
然后,我们通过对判别器应用两次训练来训练它,一次用于批次中的真实数据样本,一次用于生成的数据样本:
dis_optimizer.zero_grad()
dis_output_true_v = net_discr(batch_v)
dis_output_fake_v = net_discr(gen_output_v.detach())
dis_loss = objective(dis_output_true_v, true_labels_v) + \
objective(dis_output_fake_v, fake_labels_v)
dis_loss.backward()
dis_optimizer.step()
dis_losses.append(dis_loss.item())
在前面的代码中,我们需要在生成器的输出上调用 detach()函数,以防止这一轮训练的梯度流入生成器(detach()是 tensor 的一个方法,它会创建一个副本,但不与父操作关联,也就是将 tensor 从父图中分离出来)。
现在是生成器的训练时间:
gen_optimizer.zero_grad()
dis_output_v = net_discr(gen_output_v)
gen_loss_v = objective(dis_output_v, true_labels_v)
gen_loss_v.backward()
gen_optimizer.step()
gen_losses.append(gen_loss_v.item())
我们将生成器的输出传递给判别器,但现在我们不再停止梯度传播。相反,我们应用带有真实标签的目标函数。这会推动我们的生成器朝着一个方向发展,使它生成的样本能让判别器混淆为真实数据。以上是与训练相关的代码,接下来的几行则报告损失并将图像样本传输到 TensorBoard:
iter_no += 1
if iter_no % REPORT_EVERY_ITER == 0:
dt = time.time() - ts_start
log.info("Iter %d in %.2fs: gen_loss=%.3e, dis_loss=%.3e",
iter_no, dt, np.mean(gen_losses), np.mean(dis_losses))
ts_start = time.time()
writer.add_scalar("gen_loss", np.mean(gen_losses), iter_no)
writer.add_scalar("dis_loss", np.mean(dis_losses), iter_no)
gen_losses = []
dis_losses = []
if iter_no % SAVE_IMAGE_EVERY_ITER == 0:
img = vutils.make_grid(gen_output_v.data[:64], normalize=True)
writer.add_image("fake", img, iter_no)
img = vutils.make_grid(batch_v.data[:64], normalize=True)
writer.add_image("real", img, iter_no)
这个示例的训练过程相当漫长。在一块 GTX 1080Ti GPU 上,100 次迭代大约需要 2.7 秒。刚开始时,生成的图像完全是随机噪声,但在经过 10k 到 20k 次迭代后,生成器变得越来越熟练,生成的图像也越来越像真实的游戏截图。
还值得注意的是,软件库的性能改进。在本书的第一版和第二版中,完全相同的示例在我拥有的相同硬件上运行速度要慢得多。在 GTX 1080Ti 上,100 次迭代大约需要 40 秒。而现在,使用 PyTorch 2.2.0 在相同的 GPU 上,100 次迭代仅需 2.7 秒。因此,从原本需要 3-4 小时的时间,现在只需要大约 30 分钟就能获得良好的生成图像。
我的实验在 40k 到 50k 次训练迭代后(大约半小时,在 1080 GPU 上)产生了以下图像:

图 3.7:生成器网络生成的示例图像
如你所见,我们的网络能够很好地再现 Atari 的截图。在接下来的部分,我们将探讨如何通过使用 PyTorch 的附加库 Ignite 来简化代码。
PyTorch Ignite
PyTorch 是一个优雅且灵活的库,这使得它成为成千上万的研究人员、深度学习爱好者、行业开发者等的首选。但灵活性也有其代价:需要编写大量代码来解决你的问题。有时,这种灵活性是非常有益的,比如当你实现一些尚未包含在标准库中的新优化方法或深度学习技巧时。那时,你只需使用 Python 实现公式,而 PyTorch 魔法会为你处理所有的梯度和反向传播机制。另一个例子是当你需要在非常低层次工作时,需要调试梯度、优化器的细节,或者调整神经网络处理数据的方式。
然而,有时你不需要这种灵活性,特别是当你处理常规任务时,比如简单的图像分类器的监督训练。对于这类任务,标准的 PyTorch 可能过于底层,特别是当你需要一遍又一遍地处理相同代码时。以下是一些常见的深度学习(DL)训练过程中必不可少的话题,但需要编写一些代码:
-
数据准备和转换,以及批次的生成
-
计算训练度量指标,如损失值、准确率和 F1 值
-
定期在测试集和验证集上对正在训练的模型进行测试
-
在若干迭代后,或者当达到新的最佳度量时,进行模型检查点保存
-
将度量数据发送到像 TensorBoard 这样的监控工具中
-
超参数随着时间变化,如学习率的下降/上升计划
-
在控制台上写出训练进度信息
当然,使用 PyTorch 完全可以实现这些任务,但可能需要编写大量代码。由于这些任务出现在任何 DL 项目中,重复编写相同的代码很快就会变得繁琐。解决这个问题的常见方法是一次性编写功能,将其封装成库,之后再重用。如果这个库是开源且高质量的(易于使用、提供良好的灵活性、编写得当等),它将随着越来越多的人在项目中使用而变得流行。这个过程不仅仅是深度学习特有的;它在软件行业的各个领域都在发生。
有几个 PyTorch 库可以简化常见任务的解决方案:ptlearn、fastai、ignite 等。当前的“PyTorch 生态系统项目”列表可以在这里找到:pytorch.org/ecosystem。
一开始就使用这些高级库可能很有吸引力,因为它们可以通过几行代码解决常见问题,但这里存在一定的风险。如果你只知道如何使用高级库,而不了解底层细节,可能会在遇到无法仅通过标准方法解决的问题时陷入困境。在机器学习这个高度动态的领域中,这种情况非常常见。
本书的主要重点是确保你理解强化学习(RL)方法、它们的实现和应用性,因此我们将采用逐步推进的方式。最开始,我们将仅使用 PyTorch 代码来实现方法,但随着进展,示例将使用高级库进行实现。对于 RL,我们将使用我编写的小型库:PTAN(github.com/Shmuma/ptan/),并将在第七章介绍。
为了减少深度学习的样板代码,我们将使用一个名为 PyTorch Ignite 的库:pytorch-ignite.ai。在本节中,我们将简要介绍 Ignite,然后我们会查看重写为 Ignite 的 Atari GAN 示例。
Ignite 的概念
从高层次来看,Ignite 简化了 PyTorch 深度学习训练循环的编写。在本章的前面部分(在损失函数和优化器部分),你看到最小的训练循环包括:
-
从训练数据中采样一个批次
-
将神经网络应用于该批次以计算损失函数——我们想要最小化的单一值
-
运行反向传播以获取网络参数相对于损失函数的梯度
-
要求优化器将梯度应用到网络中
-
重复进行,直到我们满意或厌烦等待为止
Ignite 的核心部分是 Engine 类,它循环遍历数据源,将处理函数应用于数据批次。除此之外,Ignite 还提供了在训练循环的特定条件下调用函数的功能。这些条件被称为事件(Events),可能发生在以下几个时刻:
-
整个训练过程的开始/结束
-
单次训练周期的开始/结束(对数据的迭代)
-
单次批次处理的开始/结束
此外,还有自定义事件,它们允许你指定在每 N 次事件时调用你的函数。例如,如果你希望每 100 个批次或每个第二个周期进行一些计算,可以使用自定义事件。
Ignite 在实际应用中的一个非常简单的例子如下所示:
from ignite.engine import Engine, Events
def training(engine, batch):
optimizer.zero_grad()
x, y = prepare_batch()
y_out = model(x)
loss = loss_fn(y_out, y)
loss.backward()
optimizer.step()
return loss.item()
engine = Engine(training)
engine.run(data)
这段代码不能直接运行,因为缺少很多细节,比如数据源、模型和优化器的创建,但它展示了 Ignite 使用的基本思想。Ignite 的主要优势在于它提供了通过现有功能扩展训练循环的能力。你希望每 100 个批次平滑损失值并写入 TensorBoard?没问题!加两行代码就能完成。你希望每 10 个周期运行模型验证?好吧,写一个函数来运行测试并将其附加到 Engine 实例,它就会被调用。
对 Ignite 功能的完整描述超出了本书的范围,但你可以在官方网站上阅读文档:pytorch-ignite.ai。
使用 Ignite 在 Atari 上进行 GAN 训练
为了给你一个 Ignite 的示例,我们将改变 Atari 图像上的 GAN 训练示例。完整的示例代码在 Chapter03/04_atari_gan_ignite.py 中;在这里,我只会展示与前一部分不同的代码。
首先,我们导入几个 Ignite 类:
from ignite.engine import Engine, Events
from ignite.handlers import Timer
from ignite.metrics import RunningAverage
from ignite.contrib.handlers import tensorboard_logger as tb_logger
Engine和Events类已经概述过。ignite.metrics包包含与训练过程性能指标相关的类,如混淆矩阵、精确度和召回率。在我们的示例中,我们将使用RunningAverage类,它提供了一种平滑时间序列值的方法。在之前的示例中,我们通过对损失数组调用np.mean()来实现这一点,但RunningAverage提供了一种更方便(且在数学上更正确)的方法。此外,我们还从 Ignite 贡献包中导入了 TensorBoard 日志记录器(其功能由其他人贡献)。我们还将使用Timer处理程序,它提供了一种简单的方式来计算某些事件之间经过的时间。
下一步,我们需要定义我们的处理函数:
def process_batch(trainer, batch):
gen_input_v = torch.FloatTensor(BATCH_SIZE, LATENT_VECTOR_SIZE, 1, 1)
gen_input_v.normal_(0, 1)
gen_input_v = gen_input_v.to(device)
batch_v = batch.to(device)
gen_output_v = net_gener(gen_input_v)
# train discriminator
dis_optimizer.zero_grad()
dis_output_true_v = net_discr(batch_v)
dis_output_fake_v = net_discr(gen_output_v.detach())
dis_loss = objective(dis_output_true_v, true_labels_v) + \
objective(dis_output_fake_v, fake_labels_v)
dis_loss.backward()
dis_optimizer.step()
# train generator
gen_optimizer.zero_grad()
dis_output_v = net_discr(gen_output_v)
gen_loss = objective(dis_output_v, true_labels_v)
gen_loss.backward()
gen_optimizer.step()
if trainer.state.iteration % SAVE_IMAGE_EVERY_ITER == 0:
fake_img = vutils.make_grid(gen_output_v.data[:64], normalize=True)
trainer.tb.writer.add_image("fake", fake_img, trainer.state.iteration)
real_img = vutils.make_grid(batch_v.data[:64], normalize=True)
trainer.tb.writer.add_image("real", real_img, trainer.state.iteration)
trainer.tb.writer.flush()
return dis_loss.item(), gen_loss.item()
该函数接收数据批次,并对该批次中的判别器和生成器模型进行更新。此函数可以返回任何在训练过程中需要跟踪的数据;在我们的例子中,它将返回两个模型的损失值。在这个函数中,我们还可以保存图像,以便在 TensorBoard 中显示。
完成这一步后,我们需要做的就是创建一个引擎实例,附加所需的处理程序,并运行训练过程:
engine = Engine(process_batch)
tb = tb_logger.TensorboardLogger(log_dir=None)
engine.tb = tb
RunningAverage(output_transform=lambda out: out[1]).\
attach(engine, "avg_loss_gen")
RunningAverage(output_transform=lambda out: out[0]).\
attach(engine, "avg_loss_dis")
handler = tb_logger.OutputHandler(tag="train", metric_names=[’avg_loss_gen’, ’avg_loss_dis’])
tb.attach(engine, log_handler=handler, event_name=Events.ITERATION_COMPLETED)
timer = Timer()
timer.attach(engine)
在前面的代码中,我们创建了引擎,传入了处理函数并附加了两个RunningAverage变换,用于计算两个损失值。每次附加时,RunningAverage会产生一个所谓的“指标”——在训练过程中保持的派生值。我们平滑后的指标名称分别为avg_loss_gen(来自生成器的平滑损失)和avg_loss_dis(来自判别器的平滑损失)。这两个值将在每次迭代后写入到 TensorBoard 中。
我们还附加了定时器,定时器在没有构造函数参数的情况下创建,作为一个简单的手动控制定时器(我们手动调用它的reset()方法),但也可以通过不同的配置选项以更灵活的方式工作。
最后一段代码附加了另一个事件处理程序,这将是我们的函数,并且在每次迭代完成时由引擎调用:
@engine.on(Events.ITERATION_COMPLETED)
def log_losses(trainer):
if trainer.state.iteration % REPORT_EVERY_ITER == 0:
log.info("%d in %.2fs: gen_loss=%f, dis_loss=%f",
trainer.state.iteration, timer.value(),
trainer.state.metrics[’avg_loss_gen’],
trainer.state.metrics[’avg_loss_dis’])
timer.reset()
engine.run(data=iterate_batches(envs))
它将记录一行日志,包含迭代索引、所用时间以及平滑后的指标值。最后一行启动了我们的引擎,将已定义的函数作为数据源传入(iterate_batches函数是一个生成器,返回正常的批次迭代器,因此,将其输出作为数据参数传入是完全可以的)。就这样。如果你运行Chapter03/04_atari_gan_ignite.py示例,它将像我们之前的示例一样工作,这对于这么一个小示例可能不太令人印象深刻,但在实际项目中,Ignite 的使用通常能通过使代码更简洁、更具可扩展性而带来回报。
总结
在本章中,你看到了 PyTorch 功能和特性的快速概览。我们讨论了基本的基础知识,如张量和梯度,并且你了解了如何利用这些基础构建块构建一个神经网络,接着学习了如何自己实现这些构建块。
我们讨论了损失函数和优化器,以及如何监控训练动态。最后,你还了解了 PyTorch Ignite,这是一个用于提供更高层次训练循环接口的库。本章的目标是对 PyTorch 做一个非常快速的介绍,这将在书中的后续章节中使用。
在下一章,我们将开始处理本书的主题:强化学习方法。
第四章:交叉熵方法
在上一章节,您已经了解了 PyTorch。在本章中,我们将结束本书的第一部分,您将熟悉其中一种强化学习方法:交叉熵。
尽管与 RL 从业者工具箱中其他工具(如深度 Q 网络(DQN)或优势演员-评论家(A2C))相比,交叉熵方法的知名度要低得多,但它也有其自身的优势。首先,交叉熵方法非常简单,这使得它成为一种易于遵循的方法。例如,在 PyTorch 上的实现不到 100 行代码。
其次,该方法具有良好的收敛性。在不需要学习复杂、多步策略且具有频繁奖励的简单环境中,交叉熵方法通常表现得非常出色。当然,许多实际问题不属于这一类,但有时候会出现。在这种情况下,交叉熵方法(单独使用或作为更大系统的一部分)可能是完美的选择。
在本章中,我们将涵盖:
-
交叉熵方法的实际应用
-
在 Gym 中两个环境(熟悉的 CartPole 和 FrozenLake 的网格世界)中交叉熵方法的工作原理
-
交叉熵方法的理论背景。本节内容是可选的,需要一些概率和统计知识,但如果您想要理解该方法的工作原理,那么您可以深入研究一下。
RL 方法的分类
交叉熵方法属于无模型、基于策略以及在线策略方法的范畴。这些概念很新,所以让我们花点时间来探索它们。
RL 中的所有方法都可以分为不同的组:
-
无模型或有模型
-
基于价值或基于策略
-
在策略或离策略
还有其他方法可以对 RL 方法进行分类,但是目前我们对上述三种感兴趣。让我们定义它们,因为您的具体问题的特性可能会影响您选择特定方法。
术语“无模型”意味着该方法不会建立环境或奖励的模型;它只是直接将观察结果连接到行动(或与行动相关的值)。换句话说,代理器接受当前观察结果并对其进行一些计算,其结果就是它应该采取的行动。相比之下,模型基方法试图预测接下来的观察结果和/或奖励。基于这一预测,代理器试图选择最佳的可能行动,往往多次进行这样的预测,以查看未来更多步骤。
这两类方法各有优缺点,但通常纯粹的基于模型的方法用于确定性环境,如有严格规则的棋盘游戏。另一方面,基于模型的方法通常更难训练,因为很难构建具有丰富观察的复杂环境的良好模型。本书中描述的所有方法都属于无模型类别,因为这些方法在过去几年里一直是研究的最活跃领域。直到最近,研究人员才开始结合两者的优点(例如,在第二十章中,我们将介绍 AlphaGo Zero 和 MuZero 方法,这些方法将基于模型的方法应用于棋盘游戏和 Atari 游戏)。
从另一个角度来看,基于策略的方法直接近似智能体的策略,即智能体在每一步应该采取什么动作。策略通常通过一个可用动作的概率分布表示。或者,这种方法也可以是基于价值的。在这种情况下,智能体计算每个可能动作的价值,并选择具有最佳价值的动作,而不是选择动作的概率。这两类方法同样受欢迎,我们将在本书的下一部分讨论基于价值的方法。基于策略的方法将在第三部分中讨论。
方法的第三个重要分类是在线策略与离线策略的区别。我们将在本书的第二部分和第三部分深入讨论这一区别,但目前,解释离线策略足以理解它是指方法能够从历史数据中学习(这些数据可能来自智能体的先前版本、由人类演示录制,或仅仅是同一智能体在几次交互之前观察到的数据)。另一方面,在线策略方法需要最新的数据进行训练,这些数据来自我们当前正在更新的策略。它们不能基于旧的历史数据进行训练,因为训练结果将会错误。这使得这类方法的数据效率较低(你需要更多的与环境交互),但在某些情况下,这不是问题(例如,如果我们的环境非常轻量且快速,那么我们可以迅速与其交互)。
因此,我们的交叉熵方法是无模型、基于策略且是在线策略,这意味着以下几点:
-
它并不构建环境的模型;它只是告诉智能体在每一步该做什么。
-
它近似智能体的策略
-
它需要从环境中获取的新数据
交叉熵方法的实际应用
交叉熵方法的解释可以分为两部分:实际部分和理论部分。实际部分是直观的,而交叉熵方法为何有效以及其原理的理论解释则更加复杂。
你可能记得,强化学习中最核心且最棘手的部分是智能体,它试图通过与环境的交互尽可能地积累总奖励。实际上,我们遵循一种常见的机器学习(ML)方法,用某种非线性可训练函数替代智能体的所有复杂性,该函数将智能体的输入(来自环境的观察)映射到某些输出。这种函数所产生的输出的细节可能依赖于特定的方法或方法族(例如基于值的方法或基于策略的方法),正如前一节所描述的那样。由于我们的交叉熵方法是基于策略的,因此我们的非线性函数(神经网络(NN))生成策略,该策略基本上决定了对于每个观察,智能体应该采取哪个动作。在研究论文中,策略表示为 π(a|s),其中 a 是动作,s 是当前状态。以下图所示:

图 4.1:基于策略的强化学习的高级方法
实际上,策略通常表示为一个动作的概率分布,这使得它非常类似于分类问题,其中类别的数量等于我们可以执行的动作数量。
这种抽象使得我们的智能体变得非常简单:它只需要将来自环境的观察传递给神经网络,得到一个动作的概率分布,并使用概率分布进行随机抽样,得到一个需要执行的动作。这种随机抽样为我们的智能体增加了随机性,这是件好事,因为在训练开始时,当我们的权重是随机的,智能体的行为也是随机的。一旦智能体获得了一个需要执行的动作,它就将该动作发送给环境,并获得上一个动作的下一个观察和奖励。然后,循环继续,如图 4.1 所示。
在智能体的生命周期中,它的经验呈现为若干个回合(episodes)。每个回合是智能体从环境中获得的一系列观察、它所执行的动作以及这些动作的奖励。假设我们的智能体已经经历了若干个这样的回合。对于每个回合,我们可以计算智能体所获得的总奖励。它可以是折扣奖励,也可以是不折扣奖励;为了简单起见,假设折扣因子 γ = 1,这意味着每个回合的所有局部奖励的未折扣总和。这个总奖励显示了该回合对智能体来说有多好。它在图 4.2 中有所说明,其中包含了四个回合(注意,不同的回合有不同的 o[i]、a[i] 和 r[i] 的值):

图 4.2:示例回合及其观察、动作和奖励
每个单元格代表代理在回合中的一步。由于环境中的随机性以及代理选择采取行动的方式,一些回合会比其他回合更好。交叉熵方法的核心是丢弃不好的回合,并在更好的回合上进行训练。所以,该方法的步骤如下:
-
使用当前模型和环境播放 N 个回合。
-
计算每个回合的总奖励并确定奖励边界。通常,我们使用所有奖励的百分位数,如第 50 或第 70 百分位数。
-
丢弃所有奖励低于边界的回合。
-
使用观察作为输入,发出的动作作为期望输出,在剩余的“精英”回合(奖励高于边界)上进行训练。
-
从步骤 1 重复,直到对结果感到满意为止。
这就是交叉熵方法的描述。通过上述过程,我们的神经网络学会如何重复动作,从而获得更大的奖励,不断提高边界。尽管该方法非常简单,但在基础环境中效果很好,易于实现,并且对超参数变化具有很强的鲁棒性,这使得它成为一个理想的基准方法。现在,让我们将其应用于我们的 CartPole 环境。
交叉熵方法在 CartPole 上的应用
这个示例的完整代码在 Chapter04/01_cartpole.py 中。这里,我只展示最重要的部分。我们模型的核心是一个单隐藏层的神经网络,使用了修正线性单元(ReLU)和 128 个隐藏神经元(这个数字完全是随意的;你可以尝试增加或减少这个常数——我们将这个作为一个练习留给你)。其他超参数也几乎是随机设置的,并且没有调优,因为该方法具有很强的鲁棒性,且收敛速度非常快。我们在文件顶部定义常数:
import typing as tt
import torch
import torch.nn as nn
import torch.optim as optim
HIDDEN_SIZE = 128
BATCH_SIZE = 16
PERCENTILE = 70
如前面的代码所示,常数包括隐藏层中神经元的数量、每次迭代中我们播放的回合数(16),以及我们用于精英回合筛选的每个回合总奖励的百分位数。我们将采用第 70 百分位数,这意味着我们将保留奖励排序前 30% 的回合。
我们的神经网络没有什么特别之处;它从环境中获取单个观察作为输入向量,并为我们可以执行的每个动作输出一个数字:
class Net(nn.Module):
def __init__(self, obs_size: int, hidden_size: int, n_actions: int):
super(Net, self).__init__()
self.net = nn.Sequential(
nn.Linear(obs_size, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, n_actions)
)
def forward(self, x: torch.Tensor):
return self.net(x)
神经网络的输出是一个动作的概率分布,因此直接的方法是在最后一层之后加入 softmax 非线性激活函数。然而,在代码中,我们并没有应用 softmax,以提高训练过程的数值稳定性。与其计算 softmax(使用指数运算)后再计算交叉熵损失(使用概率的对数),我们将稍后使用 nn.CrossEntropyLoss PyTorch 类,它将 softmax 和交叉熵合并为一个更加数值稳定的表达式。CrossEntropyLoss 需要神经网络的原始未归一化值(也叫 logits)。这样做的缺点是我们每次需要从神经网络的输出中获取概率时,都需要记得应用 softmax。
接下来,我们将定义两个辅助的 dataclass:
@dataclass
class EpisodeStep:
observation: np.ndarray
action: int
@dataclass
class Episode:
reward: float
steps: tt.List[EpisodeStep]
这些 dataclass 的目的如下:
-
EpisodeStep:这个类用于表示代理在 episode 中的单一步骤,它存储了来自环境的观察值以及代理执行的动作。我们将使用精英 episode 的步骤作为训练数据。
-
Episode:这是一个单一的 episode,存储为总的未折扣奖励和一组 EpisodeStep。
让我们看看一个生成包含 episode 的批次的函数:
def iterate_batches(env: gym.Env, net: Net, batch_size: int) -> tt.Generator[tt.List[Episode], None, None]:
batch = []
episode_reward = 0.0
episode_steps = []
obs, _ = env.reset()
sm = nn.Softmax(dim=1)
上述函数接受环境(来自 Gym 库的 Env 类实例)、我们的神经网络以及它在每次迭代时应该生成的 episode 数量。batch 变量将用于累积我们的批次(这是一个 Episode 实例的列表)。我们还声明了当前 episode 的奖励计数器和它的步骤列表(EpisodeStep 对象)。然后,我们重置环境以获取第一个观察值,并创建一个 softmax 层,这将用于将神经网络的输出转换为动作的概率分布。准备工作就绪,我们可以开始环境循环:
while True:
obs_v = torch.tensor(obs, dtype=torch.float32)
act_probs_v = sm(net(obs_v.unsqueeze(0)))
act_probs = act_probs_v.data.numpy()[0]
在每次迭代时,我们将当前观察值转换为 PyTorch 张量,并将其传递给神经网络以获得动作的概率。这里有几点需要注意:
-
PyTorch 中的所有 nn.Module 实例都期望一批数据项,我们的神经网络也不例外,因此我们将观察值(在 CartPole 中是一个包含四个数字的向量)转换成大小为 1 × 4 的张量(为此,我们在张量上调用 unsqueeze(0) 函数,这样会在形状的零维位置添加一个额外的维度)。
-
由于我们在神经网络的输出中没有使用非线性激活函数,它会输出原始的动作评分,我们需要将这些评分通过 softmax 函数处理。
-
我们的神经网络和 softmax 层都返回跟踪梯度的张量,因此我们需要通过访问张量的 data 字段来解包它,然后将张量转换为 NumPy 数组。这个数组将具有与输入相同的二维结构,批次维度在轴 0 上,因此我们需要获取第一个批次元素以获得一个一维的动作概率向量。
现在我们有了动作的概率分布,可以利用它来获得当前步骤的实际动作:
action = np.random.choice(len(act_probs), p=act_probs)
next_obs, reward, is_done, is_trunc, _ = env.step(action)
在这里,我们使用 NumPy 的函数 random.choice() 来采样分布。然后,我们将这个动作传递给环境,以获取下一个观察结果、奖励、回合结束的指示以及截断标志。step() 函数返回的最后一个值是来自环境的额外信息,将被丢弃。
奖励被加入到当前回合的总奖励中,我们的回合步骤列表也会扩展,包含(观察,动作)对:
episode_reward += float(reward)
step = EpisodeStep(observation=obs, action=action)
episode_steps.append(step)
请注意,我们保存的是用于选择动作的观察结果,而不是由环境根据动作返回的观察结果。这些小细节,虽然微小,但非常重要,你需要记住。
代码的后续部分处理当前回合结束时的情况(在 CartPole 问题中,回合在杆子掉下时结束,无论我们是否努力,或者当环境的时间限制到达时结束):
if is_done or is_trunc:
e = Episode(reward=episode_reward, steps=episode_steps)
batch.append(e)
episode_reward = 0.0
episode_steps = []
next_obs, _ = env.reset()
if len(batch) == batch_size:
yield batch
batch = []
我们将完成的回合追加到批次中,保存总奖励(因为回合已经结束,并且我们已经累积了所有奖励)以及我们采取的步骤。然后,我们重置总奖励累加器并清空步骤列表。之后,我们重置环境重新开始。
如果我们的批次已达到期望的回合数,我们将使用 yield 将其返回给调用者进行处理。我们的函数是生成器,因此每次执行 yield 操作符时,控制权将转交给外部迭代循环,然后在 yield 语句后继续执行。如果你不熟悉 Python 的生成器函数,可以参考 Python 文档:wiki.python.org/moin/Generators。处理完后,我们会清理批次。
我们循环中的最后一步,也是非常重要的一步,是将从环境中获得的观察结果赋值给当前的观察变量:
obs = next_obs
之后,一切将无限重复——我们将观察结果传递给神经网络(NN),从中采样执行的动作,请求环境处理该动作,并记住该处理结果。
需要理解的一个非常重要的事实是,在这个函数的逻辑中,我们的神经网络(NN)训练和回合生成是同时进行的。它们并非完全并行,但每当我们的循环累积了足够的回合(16),它会将控制权传递给此函数的调用者,调用者应该使用梯度下降来训练神经网络。因此,当 yield 被返回时,神经网络将表现出不同的、略微更好的(我们希望是这样)行为。正如你从章节开始时应该记得的那样,交叉熵方法属于基于策略(on-policy)类,因此使用新鲜的训练数据对于方法的正常运行至关重要。
由于训练和数据收集发生在同一线程中,因此不需要额外的同步。然而,您应该注意到训练神经网络和使用神经网络之间的频繁切换。好了;现在我们需要定义另一个函数,然后就可以准备切换到训练循环了:
def filter_batch(batch: tt.List[Episode], percentile: float) -> \
tt.Tuple[torch.FloatTensor, torch.LongTensor, float, float]:
rewards = list(map(lambda s: s.reward, batch))
reward_bound = float(np.percentile(rewards, percentile))
reward_mean = float(np.mean(rewards))
这个函数是交叉熵方法的核心——它从给定的回合批次和百分位值中计算一个奖励边界,这个边界用于过滤精英回合进行训练。为了获取奖励边界,我们将使用 NumPy 的percentile函数,它根据数值列表和所需的百分位,计算出该百分位的值。然后,我们将计算平均奖励,仅用于监控。
接下来,我们将过滤掉我们的回合:
train_obs: tt.List[np.ndarray] = []
train_act: tt.List[int] = []
for episode in batch:
if episode.reward < reward_bound:
continue
train_obs.extend(map(lambda step: step.observation, episode.steps))
train_act.extend(map(lambda step: step.action, episode.steps))
对于批次中的每一个回合,我们会检查该回合的总奖励是否高于我们的奖励边界,如果是,我们将填充观察值和动作的列表,这些将用于训练。
以下是该函数的最后步骤:
train_obs_v = torch.FloatTensor(np.vstack(train_obs))
train_act_v = torch.LongTensor(train_act)
return train_obs_v, train_act_v, reward_bound, reward_mean
在这里,我们将把精英回合的观察值和动作转换成张量,并返回一个包含四个元素的元组:观察值、动作、奖励边界和平均奖励。最后两个值不用于训练;我们将它们写入 TensorBoard,以便检查智能体的表现。
现在,整合所有内容的最终代码块,主要由训练循环组成,如下所示:
if __name__ == "__main__":
env = gym.make("CartPole-v1")
assert env.observation_space.shape is not None
obs_size = env.observation_space.shape[0]
assert isinstance(env.action_space, gym.spaces.Discrete)
n_actions = int(env.action_space.n)
net = Net(obs_size, HIDDEN_SIZE, n_actions)
print(net)
objective = nn.CrossEntropyLoss()
optimizer = optim.Adam(params=net.parameters(), lr=0.01)
writer = SummaryWriter(comment="-cartpole")
在开始时,我们创建所有需要的对象:环境、我们的神经网络、目标函数、优化器,以及 TensorBoard 的摘要写入器。
在训练循环中,我们迭代处理批次(即 Episode 对象的列表):
for iter_no, batch in enumerate(iterate_batches(env, net, BATCH_SIZE)):
obs_v, acts_v, reward_b, reward_m = filter_batch(batch, PERCENTILE)
optimizer.zero_grad()
action_scores_v = net(obs_v)
loss_v = objective(action_scores_v, acts_v)
loss_v.backward()
optimizer.step()
我们使用filter_batch函数对精英回合进行过滤。结果是观察值和采取的动作的张量、用于过滤的奖励边界,以及平均奖励。之后,我们将神经网络(NN)的梯度归零,并将观察值传递给神经网络,获取其动作分数。这些分数会传递给目标函数,计算神经网络输出与智能体采取的动作之间的交叉熵。这样做的目的是强化我们的神经网络,执行那些已经导致良好奖励的精英动作。接着,我们计算损失的梯度,并请求优化器调整神经网络。
循环的其余部分主要是进度监控:
print("%d: loss=%.3f, reward_mean=%.1f, rw_bound=%.1f" % (
iter_no, loss_v.item(), reward_m, reward_b))
writer.add_scalar("loss", loss_v.item(), iter_no)
writer.add_scalar("reward_bound", reward_b, iter_no)
writer.add_scalar("reward_mean", reward_m, iter_no)
在控制台上,我们显示迭代次数、损失、批次的平均奖励以及奖励边界。我们还将相同的值写入 TensorBoard,以便获得智能体学习表现的漂亮图表。
循环中的最后一个检查是比较批次回合的平均奖励:
if reward_m > 475:
print("Solved!")
break
writer.close()
当平均奖励超过 475 时,我们停止训练。为什么是 475 呢?在 Gym 中,当过去 100 次训练的平均奖励超过 475 时,CartPole-v1 环境被认为已解决。然而,我们的方法收敛得非常快,通常 100 次训练就足够了。经过适当训练的智能体能够将杆子保持平衡无限长时间(获得任意数量的分数),但在 CartPole-v1 中,一次训练的长度被限制为 500 步(如果你查看 github.com/Farama-Foundation/Gymnasium/blob/main/gymnasium/envs/__init__.py(gymnasium/envs/init.py) 文件,所有环境都在此注册,CartPole v1 的 max_episode_steps 为 500)。考虑到这些因素,当批次的平均奖励超过 475 时,我们就会停止训练,这也是智能体学会像专业人士一样平衡杆子的良好指示。
就是这样。那么,让我们开始第一次强化学习训练吧!
Chapter04$ ./01_cartpole.py
Net(
(net): Sequential(
(0): Linear(in_features=4, out_features=128, bias=True)
(1): ReLU()
(2): Linear(in_features=128, out_features=2, bias=True)
)
)
0: loss=0.683, reward_mean=25.2, rw_bound=24.0
1: loss=0.669, reward_mean=34.3, rw_bound=39.0
2: loss=0.648, reward_mean=37.6, rw_bound=40.0
3: loss=0.647, reward_mean=41.9, rw_bound=43.0
4: loss=0.634, reward_mean=41.2, rw_bound=50.0
....
38: loss=0.537, reward_mean=431.8, rw_bound=500.0
39: loss=0.529, reward_mean=450.1, rw_bound=500.0
40: loss=0.533, reward_mean=456.4, rw_bound=500.0
41: loss=0.526, reward_mean=422.0, rw_bound=500.0
42: loss=0.531, reward_mean=436.8, rw_bound=500.0
43: loss=0.526, reward_mean=475.5, rw_bound=500.0
Solved!
通常,智能体解决问题的训练批次不会超过 50 次。我的实验显示,通常需要 30 到 60 次训练,这是一种非常好的学习表现(记住,我们每个批次只需要训练 16 次)。TensorBoard 显示我们的智能体持续在进步,几乎每个批次都会推动上限(虽然有时会出现下降,但大多数时候它是在提升):

图 4.3:训练过程中平均奖励(左)和损失(右)
图 4.4:训练过程中奖励的边界
为了监控训练过程,你可以通过在 CartPole 环境中设置渲染模式并添加 RecordVideo 包装器来调整环境创建:
env = gym.make("CartPole-v1", render_mode="rgb_array")
env = gym.wrappers.RecordVideo(env, video_folder="video")
在训练过程中,它将创建一个视频目录,其中包含一堆 MP4 电影,供你比较智能体训练的进展:
Chapter04$ ./01_cartpole.py
Net(
(net): Sequential(
(0): Linear(in_features=4, out_features=128, bias=True)
(1): ReLU()
(2): Linear(in_features=128, out_features=2, bias=True)
)
)
Moviepy - Building video Chapter04/video/rl-video-episode-0.mp4\.
Moviepy - Writing video Chapter04/video/rl-video-episode-0.mp4
Moviepy - Done !
Moviepy - video ready Chapter04/video/rl-video-episode-0.mp4
Moviepy - Building video Chapter04/video/rl-video-episode-1.mp4\.
Moviepy - Writing video Chapter04/video/rl-video-episode-1.mp4
...
MP4 电影可能如下所示:

图 4.5:CartPole 训练电影
现在让我们暂停一下,思考一下刚刚发生了什么。我们的神经网络仅通过观察和奖励学习如何玩这个环境,而没有对观察到的值进行任何解释。这个环境可以不是一个带杆的小车,它可以是一个仓库模型,观察值是产品数量,奖励是赚取的金钱。我们的实现并不依赖于环境相关的细节。这就是强化学习模型的魅力,接下来的部分,我们将看看如何将完全相同的方法应用于 Gym 集合中的不同环境。
在 FrozenLake 上使用交叉熵方法
接下来,我们将尝试使用交叉熵方法解决的环境是 FrozenLake。它的世界属于所谓的网格世界类别,在这个世界中,代理生活在一个 4 × 4 的网格中,可以朝四个方向移动:上、下、左、右。代理始终从左上角开始,目标是到达网格的右下角单元格。网格中的固定单元格中有洞,如果代理掉入这些洞中,情节结束,奖励为零。如果代理到达目标单元格,则获得 1.0 的奖励,情节也结束。
为了让事情变得更复杂,世界是滑溜的(毕竟它是一个冰冻的湖泊),因此代理的动作并不总是按预期进行——有 33% 的机会它会向右或向左滑动。例如,如果你希望代理向左移动,那么有 33% 的概率它确实会向左移动,33% 的概率它会移到上方的单元格,另有 33% 的概率它会移到下方的单元格。正如你在本节最后所看到的,这使得进展变得困难。

图 4.6:在人工模式下渲染的 FrozenLake 环境
让我们来看一下这个环境在 Gym API 中是如何表示的:
>>> e = gym.make("FrozenLake-v1", render_mode="ansi")
>>> e.observation_space
Discrete(16)
>>> e.action_space
Discrete(4)
>>> e.reset()
(0, {’prob’: 1})
>>> print(e.render())
SFFF
FHFH
FFFH
HFFG
我们的观察空间是离散的,这意味着它只是一个从 0 到 15 的数字(包括 0 和 15)。显然,这个数字是我们在网格中的当前位置。动作空间也是离散的,但它的值可以从零到三。虽然动作空间与 CartPole 类似,但观察空间的表示方式不同。为了尽量减少我们实现中的所需更改,我们可以应用传统的离散输入的 one-hot 编码,这意味着输入到我们网络的数据将包含 16 个浮动数,其他位置为零,只有表示我们在网格中的当前位置的索引处为 1。
由于这种转换仅影响环境的观察,因此可以将其实现为一个 ObservationWrapper,正如我们在第二章中讨论的那样。我们将其称为 DiscreteOneHotWrapper:
class DiscreteOneHotWrapper(gym.ObservationWrapper):
def __init__(self, env: gym.Env):
super(DiscreteOneHotWrapper, self).__init__(env)
assert isinstance(env.observation_space, gym.spaces.Discrete)
shape = (env.observation_space.n, )
self.observation_space = gym.spaces.Box(0.0, 1.0, shape, dtype=np.float32)
def observation(self, observation):
res = np.copy(self.observation_space.low)
res[observation] = 1.0
return res
在对环境应用了该包装器后,观察空间和动作空间与我们的 CartPole 解决方案(源代码 Chapter04/02_frozenlake_naive.py)100% 兼容。然而,通过启动它,我们可以看到我们的训练过程并没有随着时间的推移提高分数:

图 4.7:FrozenLake 环境中的平均奖励(左)和损失(右)

图 4.8:训练过程中奖励边界(一直是无聊的 0.0)
为了理解发生了什么,我们需要深入研究两个环境的奖励结构。在 CartPole 中,环境的每一步都会给我们 1.0 的奖励,直到杆子倒下为止。因此,我们的代理平衡杆子的时间越长,获得的奖励就越多。由于代理行为的随机性,不同的回合有不同的长度,从而给我们带来了一个比较正常的回合奖励分布。选择奖励边界后,我们会拒绝不太成功的回合,并学习如何重复更好的回合(通过在成功回合的数据上训练)。这一点可以通过下面的图示看到:

图 4.9:CartPole 环境中的奖励分布
在 FrozenLake 环境中,回合和奖励的情况有所不同。我们只有在到达目标时才能获得 1.0 的奖励,而这个奖励并不能说明每个回合的好坏。这个回合是快速有效的,还是我们在湖上绕了四圈后才随机进入最终的格子?我们并不知道;它只是一个 1.0 的奖励,仅此而已。我们的回合奖励分布也存在问题。只有两种可能的回合,一种是零奖励(失败),另一种是奖励为 1.0(成功),而失败的回合显然会在训练开始时占据主导地位,因为这时代理的行为是随机的。因此,我们选择精英回合的百分位数是完全错误的,这会给我们提供不良的训练样本。这就是我们训练失败的原因。

图 4.10:FrozenLake 环境的奖励分布
这个例子展示了交叉熵方法的局限性:
-
对于训练来说,我们的过程必须是有限的(通常它们可以是无限的),并且最好是短暂的。
-
回合的总奖励应当有足够的变异性,以便将好的回合与不好的回合区分开来。
-
在整个过程中有中间奖励是有益的,而不是只在过程结束时获得奖励。
在本书的后续章节中,你将了解其他解决这些局限性的方法。目前,如果你对如何使用交叉熵方法解决 FrozenLake 问题感兴趣,以下是你需要对代码进行的调整(完整示例见 Chapter04/03_frozenlake_tweaked.py):
-
更大的回合批量:在 CartPole 中,每次迭代有 16 个回合就足够了,但 FrozenLake 至少需要 100 个回合才能得到一些成功的回合。
-
奖励的折扣因子:为了让一个回合的总奖励依赖于其长度,并且增加回合的多样性,我们可以使用折扣总奖励,折扣因子γ = 0.9 或 0.95。在这种情况下,短回合的奖励会高于长回合的奖励。这增加了奖励分布的变异性,有助于避免像图 4.10 中所示的情况。
-
长时间保存精英回合:在 CartPole 训练中,我们从环境中采样回合,训练最好的回合,然后丢弃它们。而在 FrozenLake 中,成功回合是非常稀有的,因此我们需要将其保留多个迭代以进行训练。
-
降低学习率:这将给我们的神经网络更多时间来平均更多的训练样本,因为较小的学习率会减小新数据对模型的影响。
-
更长的训练时间:由于成功回合的稀疏性以及我们行动的随机性,我们的神经网络(NN)更难理解在任何特定情况下应该执行的最佳行为。为了达到 50% 的成功回合,约需要 5,000 次训练迭代。
为了将这些内容融入到我们的代码中,我们需要修改 filter_batch 函数来计算折扣奖励并返回精英回合以供我们保存:
def filter_batch(batch: tt.List[Episode], percentile: float) -> \
tt.Tuple[tt.List[Episode], tt.List[np.ndarray], tt.List[int], float]:
reward_fun = lambda s: s.reward * (GAMMA ** len(s.steps))
disc_rewards = list(map(reward_fun, batch))
reward_bound = np.percentile(disc_rewards, percentile)
train_obs: tt.List[np.ndarray] = []
train_act: tt.List[int] = []
elite_batch: tt.List[Episode] = []
for example, discounted_reward in zip(batch, disc_rewards):
if discounted_reward > reward_bound:
train_obs.extend(map(lambda step: step.observation, example.steps))
train_act.extend(map(lambda step: step.action, example.steps))
elite_batch.append(example)
return elite_batch, train_obs, train_act, reward_bound
然后,在训练循环中,我们将存储先前的精英回合,并在下次训练迭代中将其传递给前面的函数:
full_batch = []
for iter_no, batch in enumerate(iterate_batches(env, net, BATCH_SIZE)):
reward_mean = float(np.mean(list(map(lambda s: s.reward, batch))))
full_batch, obs, acts, reward_bound = filter_batch(full_batch + batch, PERCENTILE)
if not full_batch:
continue
obs_v = torch.FloatTensor(obs)
acts_v = torch.LongTensor(acts)
full_batch = full_batch[-500:]
其余的代码保持不变,除了学习率降低了 10 倍,BATCH_SIZE 设置为 100。经过一段耐心等待(新版本大约需要 50 分钟来完成 10,000 次迭代),你可以看到模型的训练在约 55% 已解决的回合后停止了提升:

图 4.11:调整版本的平均奖励(左)和损失(右)

图 4.12:调整版本的奖励边界
有方法可以解决这个问题(例如,通过应用熵损失正则化),但这些技术将在接下来的章节中讨论。
这里最后需要注意的是 FrozenLake 环境中的滑溜效应。我们的每个行动有 33% 的概率被替换为 90^∘ 旋转后的行动(例如,向上行动会以 0.33 的概率成功,而有 0.33 的概率它会被替换为向左行动或向右行动)。
无滑溜版本的代码在 Chapter04/04_frozenlake_nonslippery.py 中,唯一的不同是在环境创建时:
env = DiscreteOneHotWrapper(gym.make("FrozenLake-v1", is_slippery=False))
效果显著!无滑溜版本的环境可以在 120-140 个批次迭代内解决,比噪声环境快了 100 倍:
Chapter04$ ./04_frozenlake_nonslippery.py
2: loss=1.436, rw_mean=0.010, rw_bound=0.000, batch=1
3: loss=1.410, rw_mean=0.010, rw_bound=0.000, batch=2
4: loss=1.391, rw_mean=0.050, rw_bound=0.000, batch=7
5: loss=1.379, rw_mean=0.020, rw_bound=0.000, batch=9
6: loss=1.375, rw_mean=0.010, rw_bound=0.000, batch=10
7: loss=1.367, rw_mean=0.040, rw_bound=0.000, batch=14
8: loss=1.361, rw_mean=0.000, rw_bound=0.000, batch=14
9: loss=1.356, rw_mean=0.010, rw_bound=0.000, batch=15
...
134: loss=0.308, rw_mean=0.730, rw_bound=0.478, batch=93
136: loss=0.440, rw_mean=0.710, rw_bound=0.304, batch=70
137: loss=0.298, rw_mean=0.720, rw_bound=0.478, batch=106
139: loss=0.337, rw_mean=0.790, rw_bound=0.430, batch=65
140: loss=0.295, rw_mean=0.720, rw_bound=0.478, batch=99
142: loss=0.433, rw_mean=0.670, rw_bound=0.000, batch=67
143: loss=0.287, rw_mean=0.820, rw_bound=0.478, batch=114
Solved!
这一点在以下图表中也很明显:

图 4.13:无滑溜版本的平均奖励(左)和损失(右)

图 4.14:无滑溜版本的奖励边界
交叉熵方法的理论背景
本节为可选内容,供希望了解该方法为何有效的读者。如果你愿意,可以参考 Kroese 原文论文,标题为《交叉熵方法》,[Kro+11]。
交叉熵方法的基础在于重要性采样定理,定理内容如下:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq5.png)
在我们的强化学习(RL)案例中,H(x) 是某个策略 x 所获得的奖励值,p(x) 是所有可能策略的分布。我们并不想通过搜索所有可能的策略来最大化我们的奖励;相反,我们想通过 q(x) 来近似 p(x)H(x),并迭代地最小化它们之间的距离。两个概率分布之间的距离通过 Kullback-Leibler (KL) 散度来计算,公式如下:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq7.png)
KL 中的第一个项称为熵,它与 p2 无关,因此在最小化过程中可以省略。第二项称为交叉熵,这是深度学习中非常常见的优化目标。
结合这两个公式,我们可以得到一个迭代算法,起始时 q0 = p(x),并在每一步进行改进。这是 p(x)H(x) 的近似,并伴随着更新:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq78.png)
这是一种通用的交叉熵方法,在我们的 RL 案例中可以大大简化。我们将 H(x) 替换为一个指示函数,当回合的奖励超过阈值时其值为 1,当奖励低于阈值时其值为 0。我们的策略更新将如下所示:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq79.png)
严格来说,前面的公式缺少归一化项,但在实践中没有它仍然能起作用。所以,方法非常明确:我们使用当前策略(从一些随机初始策略开始)采样回合,并最小化最成功样本和我们的策略的负对数似然。
如果你感兴趣,可以参考 Reuven Rubinstein 和 Dirk P. Kroese 编写的书 [RK04],专门讨论这种方法。简短的描述可以在《交叉熵方法》论文中找到 ([Kro+11])。
摘要
在本章中,你已经了解了交叉熵方法,尽管它有一些局限性,但它简单且非常强大。我们将其应用于一个 CartPole 环境(取得了巨大的成功)和 FrozenLake(取得了相对较小的成功)。此外,我们还讨论了 RL 方法的分类,接下来的书中会多次引用这一分类,因为不同的 RL 问题方法具有不同的特性,这会影响它们的适用性。
本章结束了本书的导言部分。在下一部分,我们将转向更加系统地学习 RL 方法,并讨论基于值的算法。在接下来的章节中,我们将探索更复杂但更强大的深度强化学习工具。
加入我们在 Discord 上的社区
与其他用户、深度学习专家以及作者本人一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何问题环节与作者交流,还有更多内容。扫描二维码或访问链接加入社区。packt.link/rl

第二部分
基于价值的方法
第五章:表格学习和贝尔曼方程。
在上一章中,你初步了解了第一个强化学习(RL)算法——交叉熵法,并了解了它的优缺点。在本书的这一部分,我们将介绍另一组方法,它们具有更多的灵活性和强大功能:Q-learning。本章将为这些方法奠定必要的背景知识。
我们还将重新审视 FrozenLake 环境,探讨新概念如何与这个环境契合,并帮助我们解决与其不确定性相关的问题。
在本章中,我们将:
-
回顾状态的价值和行动的价值,并学习如何在简单的情况下计算它们。
-
讨论贝尔曼方程,以及它如何在我们知道状态的价值时建立最优策略。
-
讨论价值迭代方法,并在 FrozenLake 环境中进行尝试。
-
对 Q-迭代方法进行相同的操作。
尽管本章中的环境简单,但它为深度 Q 学习(一个非常强大且通用的强化学习方法)奠定了必要的准备。
价值、状态和最优性。
你可能还记得我们在第一章中对状态价值的定义。这是一个非常重要的概念,现在是时候进一步探讨它了。
本书的这一部分是围绕状态的价值及如何逼近它展开的。我们将该价值定义为从状态中获得的期望总奖励(可选折扣)。从正式的角度来看,状态的价值由以下公式给出:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq8.png)
其中 r[t]是代理在情节的第 t 步获得的局部奖励。
总奖励可以折扣,范围为 0 < γ < 1,或者不折扣(当γ = 1 时);这取决于我们如何定义它。价值始终是根据代理遵循的某个策略计算的。为了解释这一点,让我们考虑一个非常简单的环境,包含三个状态,如图 5.1 所示:

图 5.1:一个环境状态转换及奖励的示例。
-
代理的初始状态。
-
代理在执行“右”动作后从初始状态到达的最终状态。从中获得的奖励是 1。
-
代理在执行“下”动作后的最终状态。从中获得的奖励是 2。
环境始终是确定性的——每个行动都成功,我们总是从状态 1 开始。一旦我们到达状态 2 或状态 3,情节结束。现在,问题是,状态 1 的价值是多少?如果没有关于我们代理行为的信息,或者换句话说,没有其策略,这个问题是没有意义的。即使在一个简单的环境中,我们的代理也可能有无限多的行为,每个行为都有其自身的状态 1 价值。考虑以下例子:
-
代理始终向右移动。
-
代理始终向下移动。
-
代理以 50%的概率向右移动,50%的概率向下移动。
-
代理在 10%的情况下向右,在 90%的情况下执行“向下”动作
为了演示如何计算值,让我们对之前的所有策略进行计算:
-
对于“始终向右”代理,状态 1 的值为 1.0(每次它向左走,获得 1 分,回合结束)
-
对于“始终向下”代理,状态 1 的值为 2.0
-
对于 50%右/50%下代理,值为 1.0⋅0.5 + 2.0⋅0.5 = 1.5
-
对于 10%右/90%下代理,值为 1.0⋅0.1 + 2.0⋅0.9 = 1.9
现在,另一个问题是:这个代理的最优策略是什么?强化学习的目标是获得尽可能多的总奖励。对于这个一步的环境,总奖励等于状态 1 的值,显然,在策略 2(始终向下)下,总奖励是最大的。
不幸的是,具有明显最优策略的简单环境在实际中并不那么有趣。对于有趣的环境,最优策略往往更难制定,甚至更难证明它们的最优性。然而,不要担心;我们正在向着让计算机能够自主学习最优行为的方向前进。
从前面的例子来看,你可能会产生一个误解,认为我们应该总是采取奖励最高的行动。通常来说,事情并没有那么简单。为了证明这一点,让我们在之前的环境中再增加一个状态,这个状态可以从状态 3 到达。状态 3 不再是终结状态,而是一个过渡状态到状态 4,且有一个很差的奖励——-20。一旦我们在状态 1 选择了“向下”这个动作,这个坏奖励是不可避免的,因为从状态 3 开始,我们只有一个出口——到状态 4。所以,对于代理来说,这是一个陷阱,它已经决定“贪婪”是一个好策略。

图 5.2:同样的环境,增加了一个状态
这样一来,我们对于状态 1 的值计算如下:
-
“始终向右”代理的值是:1.0
-
“始终向下”代理的值为 2.0 + (−20) = −18
-
50%/50%代理的值为 0.5 ⋅ 1.0 + 0.5 ⋅ (2.0 + (−20)) = −8.5
-
10%/90%代理的值为 0.1 ⋅ 1.0 + 0.9 ⋅ (2.0 + (−20)) = −16.1
所以,这个新环境的最佳策略现在是策略 1:始终向右。我们花了一些时间讨论天真和简单的环境,这样你就能意识到这个最优问题的复杂性,并更好地理解理查德·贝尔曼的结果。贝尔曼是美国数学家,他提出并证明了著名的贝尔曼方程。我们将在下一节讨论它。
贝尔曼最优性方程
要解释贝尔曼方程,最好抽象一些。不要害怕;我会后面提供具体的例子来支持你的学习!让我们从一个确定性情况开始,当我们的所有行动都有 100% 的保证结果时。想象我们的代理观察到状态 s[0] 并有 N 个可用的行动。每个行动导致另一个状态 s[1]…s[N],并带有相应的奖励 r[1]…r[N]。还假设我们知道与状态 s[0] 相连的所有状态的值 V [i]。在这样的状态下,代理可以采取什么最佳行动?

图 5.3:从初始状态可达的 N 个状态的抽象环境
如果我们选择具体的行动 a[i] 并计算给定该行动的值,那么该值将为 V 0 = r[i] + V [i]。因此,为了选择可能的最佳行动,代理需要计算每个行动的结果值,并选择可能的最大结果。换句话说,V [0] = maxa∈1…N。如果我们使用折现因子 γ,我们需要将下一个状态的值乘以 gamma:V [0] = maxa∈1…N。
这看起来可能与前一节的贪婪示例非常相似,实际上确实如此。然而,有一个区别:当我们贪婪地行动时,我们不仅看即时行动的奖励,还看长期状态值的奖励。这使我们能够避免可能出现的陷阱,即即时奖励很大但状态值很差的情况。
贝尔曼证明了通过这种扩展,我们的行为将获得最佳可能的结果。换句话说,它将是最优的。因此,前述方程被称为值的贝尔曼方程(对于确定性情况)。
这个想法推广到随机情况并不复杂,当我们的行为有可能导致不同状态时。我们需要做的是计算每个行动的期望值,而不仅仅是考虑下一个状态的值。为了说明这一点,让我们考虑从状态 s[0] 可用的单个行动,有三种可能的结果:

图 5.4:在随机情况下从状态转移的示例
这里,我们有一个行动,可以以三种不同的概率导致三个不同的状态。以概率 p[1],该行动可能进入状态 s[1],以 p[2] 进入状态 s[2],以 p[3] 进入状态 s[3](当然,p[1] + p[2] + p[3] = 1)。每个目标状态都有自己的奖励(r[1]、r[2] 或 r[3])。要计算发出行动 1 后的期望值,我们需要将所有值乘以它们的概率并求和:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq9.png)
或者,更正式地说,
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq10.png)
这里,𝔼 [s∼S] 表示在我们的状态空间 S 中所有状态上取期望值。
通过将贝尔曼方程(对于确定性情况)与随机动作的值相结合,我们得到了一般情况的贝尔曼最优性方程:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq11.png)
请注意,p[a,i→j]表示在状态 i 下执行动作 a 后,转移到状态 j 的概率。解释仍然是一样的:状态的最优值对应于能够给我们最大可能的期望立即奖励,加上下一状态的折扣长期奖励的动作。你可能还会注意到,这一定义是递归的:状态的值是通过立即可达状态的值来定义的。这种递归看起来可能像是作弊:我们定义了一个值,假装我们已经知道它。然而,这在计算机科学甚至数学中都是一种非常强大且常见的技巧(数学归纳法就是基于这种技巧)。这个贝尔曼方程不仅是强化学习的基础,还是更为一般的动态规划的基础,动态规划是一种广泛用于解决实际优化问题的方法。
这些值不仅告诉我们可以获得的最佳奖励,而且基本上给出了获取该奖励的最优策略:如果我们的智能体知道每个状态的值,那么它就自动知道如何获取这个奖励。凭借贝尔曼最优性证明,在智能体到达的每个状态中,它需要选择具有最大期望奖励的动作,这个期望奖励是立即奖励与一步折扣后的长期奖励之和——仅此而已。因此,这些值对于了解是非常有用的。在你熟悉一种计算这些值的实际方法之前,我需要介绍一个数学符号。它不像状态值那样基础,但为了方便我们需要它。
动作的值
为了让我们的生活稍微轻松一点,我们可以定义不同的量,除了状态值 V(s)外,还可以定义动作值 Q(s,a)。基本上,这等于我们在状态 s 下执行动作 a 所能获得的总奖励,可以通过 V(s)来定义。作为一个比 V(s)更不基础的量,这个量给整个 Q 学习方法族命名,因为它更方便。在这些方法中,我们的主要目标是获取每对状态和动作的 Q 值:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq12.png)
对于这个状态 s 和动作 a,Q 等于期望的立即奖励和目标状态的折扣长期奖励。我们还可以通过 Q(s,a)来定义 V(s):
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq13.png)
这只是意味着某个状态的值等于我们从该状态执行的最大动作的值。
最后,我们可以递归地表达 Q(s,a)(将在第六章中使用):
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq14.png)
在最后的公式中,立即奖励的索引(s,a)依赖于环境的具体细节:
-
如果在执行特定动作 a 时,立即奖励是在状态 s 下给予我们的,那么使用索引(s,a),公式与上面展示的完全一致。
-
但是如果奖励是通过动作 a′到达某个状态 s′时给予的,奖励将具有索引(s′,a′),并且需要被移入最大值运算符中:
![π (a |s) = P[At = a|St = s]]()
从数学角度来看,这个差异并不大,但在方法实现过程中可能很重要。第一种情况更为常见,因此我们将坚持使用前述公式。
为了给你一个具体的例子,让我们考虑一个类似 FrozenLake 的环境,但结构更简单:我们有一个初始状态(s[0]),周围有四个目标状态 s[1]、s[2]、s[3]、s[4],每个状态的奖励不同:
图 5.5:简化的网格状环境
每个动作的执行都是有概率的,和 FrozenLake 中的方式一样:有 33%的概率我们的动作将按原样执行,但也有 33%的概率我们会相对于目标单元格向左滑动,另外 33%的概率我们会向右滑动。为了简化起见,我们使用折扣因子γ = 1。

图 5.6:网格环境的转移图
让我们先计算这些动作的值。终态 s[1]…s[4]没有外部连接,因此这些状态的 Q 值对所有动作均为零。由于这个原因,终态的值等于它们的即时奖励(我们一旦到达终态,回合就结束,没有后续状态):V [1] = 1,V [2] = 2,V [3] = 3,V [4] = 4。
状态 0 的动作值稍微复杂一些。我们从“向上”动作开始。根据定义,它的值等于立即奖励的期望值加上后续步骤的长期值。对于“向上”动作的任何可能转移,我们没有后续步骤:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq16.png)
对 s[0]的其余动作进行相同的计算,结果如下:
| Q(s[0],left) | = 0.33 ⋅V [1] + 0.33 ⋅V [2] + 0.33 ⋅V [3] = 1.98 |
|---|---|
| Q(s[0],right) | = 0.33 ⋅V [4] + 0.33 ⋅V [1] + 0.33 ⋅V [3] = 2.64 |
| Q(s[0],down) | = 0.33 ⋅V [3] + 0.33 ⋅V [2] + 0.33 ⋅V [4] = 2.97 |
状态 s[0]的最终值是这些动作值中的最大值,即 2.97。
Q 值在实践中要方便得多,因为对于智能体来说,根据 Q 而不是 V 来做决策要简单得多。在 Q 的情况下,智能体只需要使用当前状态计算所有可用动作的 Q 值,并选择具有最大 Q 值的动作。使用状态值来做相同的选择时,智能体不仅需要知道这些值,还需要知道转移的概率。在实践中,我们很少事先知道这些概率,因此智能体需要为每个动作和状态对估计转移概率。在本章稍后的部分,你将通过两种方法解决 FrozenLake 环境,亲自看到这一点。然而,要做到这一点,我们还有一个重要的东西缺失:计算 V[i] 和 Q[i] 的通用方法。
值迭代方法
在你刚才看到的简单例子中,为了计算状态和动作的值,我们利用了环境的结构:转移中没有循环,因此我们可以从终端状态开始,计算它们的值,然后再处理中央状态。然而,环境中的一个循环就构成了我们方法的障碍。让我们考虑一个有两个状态的环境:

图 5.7:具有转移图中循环的示例环境
我们从状态 s[1] 开始,唯一可以采取的动作将我们带到状态 s[2]。我们获得奖励 r = 1,s[2] 中唯一的转移是一个动作,它将我们带回到 s[1]。所以,我们的智能体的生命周期是一个无限的状态序列[s[1], s[2], s[1], s[2], …]。为了处理这个无限循环,我们可以使用折扣因子:γ = 0.9。现在,问题是,两个状态的值是多少?其实,答案并不复杂。每次从 s[1] 转移到 s[2] 都会给我们奖励 1,而每次返回转移都会给我们奖励 2。因此,我们的奖励序列将是[1, 2, 1, 2, 1, 2, 1, 2, …]。由于每个状态只有一个可用的动作,我们的智能体没有选择的余地,因此我们可以在公式中省略最大值操作(因为只有一个选择)。
每个状态的值将等于无限求和:
| V (s[1]) | = 1 + γ(2 + γ(1 + γ(2 + …))) = ∑ [i=0]∞1γ(2i) + 2γ^(2i+1) |
|---|---|
| V (s[2]) | = 2 + γ(1 + γ(2 + γ(1 + …))) = ∑ [i=0]∞2γ(2i) + 1γ^(2i+1) |
严格来说,我们无法计算出我们状态的准确值,但当 γ = 0.9 时,每个转移的贡献会随着时间迅速减少。例如,经过 10 步后,γ¹⁰ = 0.9¹⁰ ≈ 0.349,但经过 100 步后,它就变成了 0.0000266。因此,我们可以在 50 次迭代后停止,仍然可以得到相当精确的估算值:
>>> sum([0.9**(2*i) + 2*(0.9**(2*i+1)) for i in range(50)])
14.736450674121663
>>> sum([2*(0.9**(2*i)) + 0.9**(2*i+1) for i in range(50)])
15.262752483911719
前面的示例可以用来概述一个更一般的过程,称为值迭代算法。该算法使我们能够数值地计算具有已知转移概率和奖励的马尔科夫决策过程(MDP)的状态值和动作值。该过程(针对状态值)包括以下步骤:
-
将所有状态的值 V [i]初始化为某个初始值(通常为零)
-
对于 MDP 中的每个状态 s,执行 Bellman 更新:
![π (a |s) = P[At = a|St = s]]()
-
重复步骤 2,进行大量的迭代,或者直到变化变得非常小
好的,那就是理论。在实际应用中,这种方法有一些明显的局限性。首先,我们的状态空间应该是离散的,并且足够小,以便能够对所有状态进行多次迭代。这对于 FrozenLake-4x4,甚至 FrozenLake-8x8(作为更具挑战性的版本存在于 Gym 中)来说不是问题,但对于 CartPole 来说,应该如何做并不完全清楚。我们的 CartPole 的观察值是四个浮动值,表示系统的一些物理特性。潜在地,即使这些值之间有很小的差异,也可能影响状态的值。解决这个问题的一种方法是对我们的观察值进行离散化;例如,我们可以将 CartPole 的观察空间分成多个区间,并将每个区间当作空间中的一个独立离散状态。然而,这会带来很多实际问题,例如区间的大小应该如何确定,估算值时需要多少来自环境的数据。我将在后续章节中解决这个问题,当我们涉及到神经网络在 Q-learning 中的应用时。
第二个实际问题源于我们很少知道动作和奖励矩阵的转移概率。记住 Gym 提供给代理人编写者的接口:我们观察状态,决定一个动作,然后才获得下一次观察和转移奖励。我们不知道(除非查看 Gym 的环境代码)通过执行动作 a[0]从状态 s[0]进入状态 s[1]的概率是多少。我们拥有的只是代理与环境交互的历史。然而,在 Bellman 更新中,我们需要每个转移的奖励和该转移的概率。因此,解决这个问题的明显方法是将代理的经验作为这两个未知数的估计。奖励可以按原样使用。我们只需要记住在使用动作 a 从 s[0]到 s[1]的转移中获得的奖励,但要估算概率,我们需要为每个元组(s[0],s[1],a)保持计数器并进行归一化。
现在你已经熟悉了理论背景,让我们来看一下这个方法的实际应用。
实践中的值迭代
在这一部分,我们将研究值迭代方法如何在 FrozenLake 中工作。完整的示例位于 Chapter05/01_frozenlake_v_iteration.py 中。该示例中的核心数据结构如下:
-
奖励表:一个字典,键是组合的“源状态”+“动作”+“目标状态”。值是从即时奖励获得的。
-
转移表:一个字典,记录了经历的转移次数。键是组合的“状态”+“动作”,值是另一个字典,将“目标状态”映射到我们看到它的次数。
例如,如果在状态 0 下执行动作 1 十次,三次后会将我们带到状态 4,七次后将带到状态 5。那么,表中键为(0, 1)的条目将是一个字典,内容为{4: 3, 5: 7}。我们可以利用这个表来估计我们的转移概率。
-
值表:一个字典,将一个状态映射到该状态的计算值。
我们代码的整体逻辑很简单:在循环中,我们从环境中执行 100 步随机操作,填充奖励和转移表格。完成这 100 步后,我们对所有状态执行值迭代循环,更新值表。然后我们进行几个完整回合的测试,检查使用更新后的值表后我们有哪些改进。如果这些测试回合的平均奖励超过 0.8 的边界值,我们就停止训练。在测试回合中,我们还会更新奖励和转移表格,以使用来自环境的所有数据。
现在让我们来看代码。我们首先导入所需的包并定义常量。然后我们定义几个类型别名。它们不是必需的,但使我们的代码更具可读性:
import typing as tt
import gymnasium as gym
from collections import defaultdict, Counter
from torch.utils.tensorboard.writer import SummaryWriter
ENV_NAME = "FrozenLake-v1"
GAMMA = 0.9
TEST_EPISODES = 20
对于 FrozenLake 环境,观察和动作空间都属于 Box 类,因此状态和动作由整数值表示。我们还为奖励表和转移表的键定义了类型。对于奖励表,键是一个元组,格式为[状态,动作,状态],而对于转移表,键是[状态,动作]:
State = int
Action = int
RewardKey = tt.Tuple[State, Action, State]
TransitKey = tt.Tuple[State, Action]
然后我们定义了 Agent 类,它将保存我们的表格并包含我们将在训练循环中使用的函数。在类的构造函数中,我们创建了一个用于数据采样的环境,获得了第一个观察值,并为奖励、转移和价值定义了表格:
class Agent:
def __init__(self):
self.env = gym.make(ENV_NAME)
self.state, _ = self.env.reset()
self.rewards: tt.Dict[RewardKey, float] = defaultdict(float)
self.transits: tt.Dict[TransitKey, Counter] = defaultdict(Counter)
self.values: tt.Dict[State, float] = defaultdict(float)
函数 play_n_random_steps 用于从环境中收集随机经验并更新奖励和转移表。需要注意的是,我们不需要等到回合结束才能开始学习;我们只执行 N 步并记录其结果。这是值迭代和交叉熵方法之间的一个区别,后者只能在完整回合中进行学习:
def play_n_random_steps(self, n: int):
for _ in range(n):
action = self.env.action_space.sample()
new_state, reward, is_done, is_trunc, _ = self.env.step(action)
rw_key = (self.state, action, new_state)
self.rewards[rw_key] = float(reward)
tr_key = (self.state, action)
self.transits[tr_key][new_state] += 1
if is_done or is_trunc:
self.state, _ = self.env.reset()
else:
self.state = new_state
下一个函数(calc_action_value())使用我们的转移、奖励和值表来计算从状态出发的动作值。我们将它用于两个目的:从状态中选择最佳动作,并计算值迭代中的状态的新值。
我们做以下操作:
-
我们从转移表中提取给定状态和动作的转移计数器。此表中的计数器采用字典形式,目标状态作为键,经历的转移次数作为值。我们将所有计数器相加,以获得从该状态执行该动作的总次数。稍后我们将使用此总值从单个计数器转换为概率。
-
然后,我们遍历每个目标状态,该状态是我们的动作所到达的,并使用贝尔曼方程计算它对总动作值的贡献。这个贡献等于即时奖励加上目标状态的折扣值。我们将此总和乘以此转移的概率,并将结果加到最终的动作值中。
该逻辑在以下图中进行了说明:
![transit[(s,a)] = {s1:c1,s2:c2} total = c1 + c2 sssaccQ1212(s,a) = tco1tal(rs1 + γVs1)+ tco2tal(rs2 + γVs2)](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/B22150_05_08.png)
图 5.8:状态值的计算
在前面的图中,我们对状态 s 和动作 a 的值进行了计算。假设在我们的经验中,我们已经执行了该动作若干次(c[1] + c[2]),并最终进入了两个状态之一,s[1]或 s[2]。我们切换到这些状态的次数存储在我们的转移表中,形式为字典{s[1]: c[1], s[2]: c[2]}。
然后,状态和动作的近似值 Q(s,a)将等于每个状态的概率,乘以该状态的值。从贝尔曼方程来看,这等于即时奖励与折扣的长期状态值之和:
def calc_action_value(self, state: State, action: Action) -> float:
target_counts = self.transits[(state, action)]
total = sum(target_counts.values())
action_value = 0.0
for tgt_state, count in target_counts.items():
rw_key = (state, action, tgt_state)
reward = self.rewards[rw_key]
val = reward + GAMMA * self.values[tgt_state]
action_value += (count / total) * val
return action_value
下一个函数使用我刚才描述的函数来决定从给定状态采取最佳行动。它遍历环境中的所有可能动作,并计算每个动作的值。值最大的动作胜出,并作为执行的动作返回。这个动作选择过程是确定性的,因为play_n_random_steps()函数引入了足够的探索。因此,我们的智能体将在我们的值近似上表现得贪婪:
def select_action(self, state: State) -> Action:
best_action, best_value = None, None
for action in range(self.env.action_space.n):
action_value = self.calc_action_value(state, action)
if best_value is None or best_value < action_value:
best_value = action_value
best_action = action
return best_action
play_episode()函数使用select_action()来找出最佳的行动,并使用提供的环境播放一个完整的回合。此函数用于播放测试回合,在此期间,我们不希望干扰用于收集随机数据的主要环境的当前状态。因此,我们使用作为参数传递的第二个环境。逻辑非常简单,应该已经很熟悉:我们只需遍历状态并累计一个回合的奖励:
def play_episode(self, env: gym.Env) -> float:
total_reward = 0.0
state, _ = env.reset()
while True:
action = self.select_action(state)
new_state, reward, is_done, is_trunc, _ = env.step(action)
rw_key = (state, action, new_state)
self.rewards[rw_key] = float(reward)
tr_key = (state, action)
self.transits[tr_key][new_state] += 1
total_reward += reward
if is_done or is_trunc:
break
state = new_state
return total_reward
Agent 类的最终方法是我们的价值迭代实现,感谢我们已经定义的函数,这一方法出奇的简单。我们所做的只是循环遍历环境中的所有状态,然后对于每个状态,我们计算从该状态可达的状态的值,获得状态的价值候选值。然后,我们用可从该状态采取的动作的最大值来更新当前状态的值:
def value_iteration(self):
for state in range(self.env.observation_space.n):
state_values = [
self.calc_action_value(state, action)
for action in range(self.env.action_space.n)
]
self.values[state] = max(state_values)
这就是我们代理的所有方法,最后一部分是训练循环和代码的监控:
if __name__ == "__main__":
test_env = gym.make(ENV_NAME)
agent = Agent()
writer = SummaryWriter(comment="-v-iteration")
我们创建了用于测试的环境,Agent 类实例,以及 TensorBoard 的摘要写入器:
iter_no = 0
best_reward = 0.0
while True:
iter_no += 1
agent.play_n_random_steps(100)
agent.value_iteration()
前面代码片段中的最后两行是训练循环的关键部分。我们首先执行 100 次随机步骤,以填充我们的奖励和转移表,并获取新数据,然后对所有状态执行价值迭代。
剩下的代码通过使用价值表作为我们的策略来执行测试回合,然后将数据写入 TensorBoard,跟踪最佳平均奖励,并检查训练循环停止条件:
reward = 0.0
for _ in range(TEST_EPISODES):
reward += agent.play_episode(test_env)
reward /= TEST_EPISODES
writer.add_scalar("reward", reward, iter_no)
if reward > best_reward:
print(f"{iter_no}: Best reward updated {best_reward:.3} -> {reward:.3}")
best_reward = reward
if reward > 0.80:
print("Solved in %d iterations!" % iter_no)
break
writer.close()
好的,让我们运行我们的程序:
Chapter05$ ./01_frozenlake_v_iteration.py
3: Best reward updated 0.0 -> 0.1
4: Best reward updated 0.1 -> 0.15
7: Best reward updated 0.15 -> 0.45
9: Best reward updated 0.45 -> 0.7
11: Best reward updated 0.7 -> 0.9
Solved in 11 iterations!
我们的解决方案是随机的,我的实验通常需要 10 到 100 次迭代才能找到解决方案,但在所有情况下,都在不到一秒的时间内找到一个可以在 80% 的运行中解决环境的良好策略。如果你还记得,使用交叉熵方法需要大约一个小时才能达到 60% 的成功率,所以这是一个重大的改进。原因有两个。
首先,我们动作的随机结果,加上回合的长度(平均 6 到 10 步),使得交叉熵方法很难理解回合中做对了什么,哪个步骤是错误的。价值迭代通过利用每个状态(或动作)的个体值来处理,并通过估计概率和计算期望值自然地结合了动作的概率结果。因此,价值迭代更为简单,且对环境的需求数据量更少(这在强化学习中称为样本效率)。
第二个原因是,价值迭代不需要完整的回合才能开始学习。在极端情况下,我们可以仅从一个例子开始更新我们的值。然而,对于 FrozenLake,由于奖励结构(我们只有成功到达目标状态后才能获得奖励 1),我们仍然需要至少一个成功的回合来开始从有用的价值表中学习,这在更复杂的环境中可能会很难实现。例如,你可以尝试将现有代码切换到一个更大的 FrozenLake 版本,名为 FrozenLake8x8-v1。FrozenLake 的大版本可能需要从 150 次到 1,000 次迭代才能解决,并且根据 TensorBoard 图表,大多数时候它会等待第一次成功的回合,然后非常快速地达到收敛。
以下是两个图表:第一个显示了在 FrozenLake-4x4 上训练过程中的奖励动态,第二个是 8 × 8 版本的奖励动态。

图 5.9:FrozenLake-4x4 的奖励动态

图 5.10:FrozenLake-8x8 的奖励动态
现在是时候将学习状态价值的代码与学习动作价值的代码进行比较了,就像我们刚刚讨论的那样。
FrozenLake 的 Q 迭代
整个例子在 Chapter05/02_frozenlake_q_iteration.py 文件中,差异实际上非常小:
-
最显著的变化是我们的价值表。在前一个例子中,我们保存了状态的价值,因此字典中的键只是一个状态。现在我们需要存储 Q 函数的值,它有两个参数,状态和动作,因此价值表中的键现在是 (状态, 动作) 的组合值。
-
第二个差异出现在我们的
calc_action_value()函数中。我们不再需要它,因为我们的动作值现在存储在价值表中。 -
最后,代码中最重要的变化出现在代理的
value_iteration()方法中。之前,它只是calc_action_value()调用的一个包装器,负责贝尔曼近似的工作。现在,由于这个函数已被移除并由价值表替代,我们需要在value_iteration()方法中执行这个近似。
让我们看看代码。由于几乎完全相同,我将直接跳到最有趣的 value_iteration() 函数:
def value_iteration(self):
for state in range(self.env.observation_space.n):
for action in range(self.env.action_space.n):
action_value = 0.0
target_counts = self.transits[(state, action)]
total = sum(target_counts.values())
for tgt_state, count in target_counts.items():
rw_key = (state, action, tgt_state)
reward = self.rewards[rw_key]
best_action = self.select_action(tgt_state)
val = reward + GAMMA * self.values[(tgt_state, best_action)]
action_value += (count / total) * val
self.values[(state, action)] = action_value
这段代码与前一个例子中的 calc_action_value() 非常相似,实际上它做的几乎是相同的事情。对于给定的状态和动作,它需要使用我们通过该动作到达的目标状态的统计数据来计算这个动作的价值。为了计算这个值,我们使用贝尔曼方程和我们的计数器,这些计数器允许我们近似目标状态的概率。然而,在贝尔曼方程中,我们有状态的值;现在,我们需要以不同的方式来计算它。
之前,我们将其存储在价值表中(因为我们近似了状态的价值),所以我们只需要从这个表中获取它。现在我们不能再这样做了,因此我们必须调用 select_action 方法,它会为我们选择具有最大 Q 值的动作,然后我们将这个 Q 值作为目标状态的值。当然,我们可以实现另一个函数来计算这个状态的值,但 select_action 几乎完成了我们需要的所有工作,所以我们在这里会复用它。
这里还有一个我想强调的例子。让我们来看一下我们的 select_action 方法:
def select_action(self, state: State) -> Action:
best_action, best_value = None, None
for action in range(self.env.action_space.n):
action_value = self.values[(state, action)]
if best_value is None or best_value < action_value:
best_value = action_value
best_action = action
return best_action
正如我所说,我们不再有calc_action_value方法;因此,为了选择一个动作,我们只需要遍历所有动作并在值表中查找它们的值。看起来这可能是一个小小的改进,但如果你考虑我们在calc_action_value中使用的数据,你就能明白为什么 Q 函数的学习在强化学习中比 V 函数的学习更受欢迎。
我们的calc_action_value函数同时使用了关于奖励和概率的信息。对于价值迭代方法来说,这并不是一个大问题,因为该方法在训练过程中依赖这些信息。然而,在下一章中,你将了解一种价值迭代方法的扩展,它不需要概率的近似,而是直接从环境样本中获取。对于这种方法,依赖概率为智能体增加了额外的负担。在 Q 学习中,智能体做决策时所需要的只是 Q 值。
我不想说 V 函数完全没用,因为它们是演员-评论员方法的一个重要部分,而我们将在本书第三部分讨论这一方法。然而,在价值学习领域,Q 函数无疑是更受欢迎的。关于收敛速度,我们的两个版本几乎是相同的(但 Q 学习版本需要的价值表内存是价值迭代版本的四倍)。
以下是 Q 学习版本的输出,它与价值迭代版本没有重大区别:
Chapter05$ ./02_frozenlake_q_iteration.py
8: Best reward updated 0.0 -> 0.35
11: Best reward updated 0.35 -> 0.45
14: Best reward updated 0.45 -> 0.55
15: Best reward updated 0.55 -> 0.65
17: Best reward updated 0.65 -> 0.75
18: Best reward updated 0.75 -> 0.9
Solved in 18 iterations!
总结
恭喜你,你在理解现代最先进的强化学习(RL)方法上又迈出了重要一步!在这一章中,你了解了一些深度强化学习中广泛应用的非常重要的概念:状态值、动作值以及贝尔曼方程的不同形式。
我们还讨论了价值迭代方法,这是 Q 学习领域一个非常重要的构建块。最后,你了解了价值迭代如何改进我们在 FrozenLake 中的解决方案。
在下一章中,你将学习深度 Q 网络(DQN),它通过在 2013 年击败人类玩家的许多 Atari 2600 游戏,开启了深度强化学习的革命。
第六章:深度 Q 网络
在第五章中,你已经熟悉了贝尔曼方程以及其应用的实际方法——价值迭代。这个方法让我们在 FrozenLake 环境中显著提高了速度和收敛性,这很有前景,但我们能进一步提升吗?在本章中,我们将把相同的方法应用到复杂度更高的问题上:来自 Atari 2600 平台的街机游戏,这些游戏是强化学习(RL)研究社区的事实标准。
为了应对这一新的、更具挑战性的目标,本章将:
-
讨论价值迭代方法的问题,并考虑它的变体——Q 学习。
-
将 Q 学习应用于所谓的网格世界环境,这种方法被称为表格 Q 学习。
-
讨论 Q 学习与神经网络(NNs)的结合。这种结合被称为深度 Q 网络(DQN)。
在本章的结尾,我们将重新实现著名论文《Playing Atari with Deep Reinforcement Learning》[Mni13]中的 DQN 算法,该论文于 2013 年发布,并开启了强化学习发展的新纪元。虽然讨论这些基本方法的实际应用还为时过早,但随着你深入阅读本书,你会更清楚地看到这一点。
现实生活中的价值迭代
通过将交叉熵方法转换为价值迭代方法,我们在 FrozenLake 环境中取得的改进非常令人鼓舞,因此将价值迭代方法应用于更具挑战性的问题非常有吸引力。然而,重要的是要查看我们价值迭代方法的假设和局限性。但让我们先快速回顾一下这个方法。在每一步中,价值迭代方法会遍历所有状态,并对每个状态使用贝尔曼近似进行价值更新。对 Q 值(动作的价值)进行的相同方法变体几乎相同,但我们要为每个状态和动作近似并存储价值。那么,这个过程到底有什么问题呢?
第一个明显的问题是环境状态的数量以及我们遍历它们的能力。在价值迭代中,我们假设我们事先知道环境中的所有状态,能够遍历它们,并且可以存储它们的价值近似值。对于 FrozenLake 的简单网格世界环境,这很容易做到,但对于其他任务呢?
为了理解这一点,首先让我们看看价值迭代方法的可扩展性,换句话说,我们在每次循环中能够轻松遍历多少个状态。即使是中等大小的计算机,也可以在内存中存储数十亿个浮点值(在 32GB 的内存中是 85 亿),因此,价值表所需的内存似乎不是一个巨大的限制。遍历数十亿个状态和动作会更加消耗中央处理单元(CPU),但这并不是一个无法克服的问题。
现在,我们有了多核系统,这些系统大多是闲置的,所以通过使用并行处理,我们可以在合理的时间内遍历数十亿个值。真正的问题在于,获取状态转换动态的良好近似所需的样本数量。假设你有一个环境,假设有十亿个状态(这大约对应一个 31600 × 31600 大小的 FrozenLake)。要计算这个环境中每个状态的粗略近似,我们需要数百亿个状态之间均匀分布的转换,这在实践中是不可行的。
为了给你一个更大潜在状态数量的环境示例,我们再来看看 Atari 2600 游戏主机。这款主机在 1980 年代非常流行,并且有许多街机风格的游戏可供选择。虽然按照今天的游戏标准,Atari 主机显得过时,但它的游戏提供了一组很好的强化学习(RL)问题,人类可以相对快速地掌握这些问题,但对计算机来说仍然具有挑战性。不足为奇的是,正如我之前提到的,这个平台(当然是使用模拟器)在强化学习研究中是一个非常受欢迎的基准。
让我们来计算一下 Atari 平台的状态空间。屏幕的分辨率是 210 × 160 像素,每个像素有 128 种颜色。因此,每一帧屏幕有 210 ⋅ 160 = 33600 个像素,而所有可能的不同屏幕总数是 128³³⁶⁰⁰,约等于 10⁷⁰⁸⁰²。如果我们决定只列举一次 Atari 所有可能的状态,即使是最快的超级计算机也需要数十亿年。而且,这项工作中 99(.9)%的时间都将是浪费,因为大多数组合在长时间的游戏过程中从未出现过,我们也不会有这些状态的样本。然而,值迭代方法仍然想遍历这些状态,以防万一。
值迭代方法的第二个主要问题是,它将我们限制在离散动作空间中。实际上,Q(s,a) 和 V(s) 的近似都假设我们的动作是一个相互排斥的离散集合,但对于连续控制问题来说,动作可以表示连续的变量,例如方向盘的角度、执行器上的力,或者加热器的温度,这在此类问题中并不成立。这个问题比第一个问题更具挑战性,我们将在书的最后部分,专门讨论连续动作空间问题的章节中进行讲解。现在,假设我们有一个离散的动作计数,并且这个计数不是很大(即数量级为 10 的数量)。我们应该如何处理状态空间大小的问题呢?
表格 Q 学习
处理状态空间问题时需要关注的关键问题是,我们是否真的需要遍历状态空间中的每个状态?我们有一个环境,可以用作现实生活中状态样本的来源。如果某个状态没有被环境展示出来,为什么我们还需要关心它的价值呢?我们只能使用从环境中获得的状态来更新状态值,这样可以节省大量的工作。
如前所述,这种值迭代方法的修改被称为 Q 学习,对于具有明确状态到值映射的情况,它包括以下步骤:
-
从一个空表开始,将状态映射到动作的值。
-
通过与环境交互,获取元组 s, a, r, s′(状态、动作、奖励和新状态)。在此步骤中,你需要决定采取哪个动作,而且没有单一的正确方法来做出这个决策。我们在第一章中讨论过这个问题,探索与利用,并将在本章中深入讨论。
-
使用 Bellman 近似更新 Q(s,a) 的值:
![π (a |s) = P[At = a|St = s]]()
-
从第 2 步开始重复。
与值迭代一样,结束条件可以是某个更新的阈值,或者我们可以执行测试回合来估算策略的期望奖励。这里需要注意的另一点是如何更新 Q 值。当我们从环境中采样时,直接在现有值上分配新值通常是一个坏主意,因为训练可能会变得不稳定。
实践中通常采用的方法是使用一种“混合”技术更新 Q(s,a),即通过学习率 α 对 Q 的旧值和新值进行平均,α 的值在 0 到 1 之间:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq19.png)
这使得 Q 值能够平稳收敛,即使我们的环境是嘈杂的。最终版本的算法如下:
-
从一个空表开始,表示 Q(s,a)。
-
从环境中获取 (s, a, r, s′)。
-
进行 Bellman 更新:
![π (a |s) = P[At = a|St = s]]()
-
检查收敛条件。如果条件未满足,从第 2 步开始重复。
如前所述,这种方法被称为表格 Q 学习,因为我们维护一个包含状态及其 Q 值的表格。让我们在我们的 FrozenLake 环境中尝试一下。完整的示例代码在 Chapter06/01_frozenlake_q_learning.py 中:
首先,我们导入包并定义常量和使用的类型:
import typing as tt
import gymnasium as gym
from collections import defaultdict
from torch.utils.tensorboard.writer import SummaryWriter
ENV_NAME = "FrozenLake-v1"
GAMMA = 0.9
ALPHA = 0.2
TEST_EPISODES = 20
State = int
Action = int
ValuesKey = tt.Tuple[State, Action]
class Agent:
def __init__(self):
self.env = gym.make(ENV_NAME)
self.state, _ = self.env.reset()
self.values: tt.Dict[ValuesKey] = defaultdict(float)
这里的新内容是 α 的值,它将用作值更新中的学习率。我们现在的 Agent 类初始化更加简化,因为我们不再需要跟踪奖励和过渡计数的历史记录,只需要我们的值表。这将使我们的内存占用更小,虽然对于 FrozenLake 来说这不是一个大问题,但对于更大的环境可能至关重要。
方法 sample_env 用于从环境中获取下一个过渡:
def sample_env(self) -> tt.Tuple[State, Action, float, State]:
action = self.env.action_space.sample()
old_state = self.state
new_state, reward, is_done, is_tr, _ = self.env.step(action)
if is_done or is_tr:
self.state, _ = self.env.reset()
else:
self.state = new_state
return old_state, action, float(reward), new_state
我们从动作空间中随机采样一个动作,并返回包含旧状态、所采取的动作、获得的奖励和新状态的元组。该元组将在后续的训练循环中使用。
下一个方法接收环境的状态:
def best_value_and_action(self, state: State) -> tt.Tuple[float, Action]:
best_value, best_action = None, None
for action in range(self.env.action_space.n):
action_value = self.values[(state, action)]
if best_value is None or best_value < action_value:
best_value = action_value
best_action = action
return best_value, best_action
此方法通过选择在表格中具有最大值的动作来从给定的环境状态中找到最佳动作。如果我们没有与状态和动作对相关的值,则将其视为零。该方法将被使用两次:第一次,在测试方法中,它使用我们当前的值表进行一次回合(以评估我们的策略质量);第二次,在执行值更新的方法中,用于获取下一个状态的值。
接下来,我们使用环境中的一步操作来更新我们的值表:
def value_update(self, state: State, action: Action, reward: float, next_state: State):
best_val, _ = self.best_value_and_action(next_state)
new_val = reward + GAMMA * best_val
old_val = self.values[(state, action)]
key = (state, action)
self.values[key] = old_val * (1-ALPHA) + new_val * ALPHA
在这里,我们首先通过将即时奖励与下一个状态的折扣值相加来计算我们当前状态 s 和动作 a 的贝尔曼近似。然后,我们获得状态和动作对的先前值,并使用学习率将这些值混合在一起。结果是状态 s 和动作 a 的新近似值,并将其存储在我们的表格中。
我们的 Agent 类中的最后一个方法使用提供的测试环境进行一次完整的回合:
def play_episode(self, env: gym.Env) -> float:
total_reward = 0.0
state, _ = env.reset()
while True:
_, action = self.best_value_and_action(state)
new_state, reward, is_done, is_tr, _ = env.step(action)
total_reward += reward
if is_done or is_tr:
break
state = new_state
return total_reward
每一步的动作都是使用我们当前的 Q 值表来选择的。该方法用于评估我们当前的策略,以检查学习的进展。请注意,此方法不会更改我们的值表,它仅仅使用值表来找到最佳动作。
示例的其余部分是训练循环,类似于第五章中的示例:我们创建一个测试环境、代理和摘要写入器,然后在循环中,我们在环境中进行一步操作,并使用获得的数据执行值更新。接下来,我们通过进行多个测试回合来测试我们当前的策略。如果获得了良好的奖励,则停止训练:
if __name__ == "__main__":
test_env = gym.make(ENV_NAME)
agent = Agent()
writer = SummaryWriter(comment="-q-learning")
iter_no = 0
best_reward = 0.0
while True:
iter_no += 1
state, action, reward, next_state = agent.sample_env()
agent.value_update(state, action, reward, next_state)
test_reward = 0.0
for _ in range(TEST_EPISODES):
test_reward += agent.play_episode(test_env)
test_reward /= TEST_EPISODES
writer.add_scalar("reward", test_reward, iter_no)
if test_reward > best_reward:
print("%d: Best test reward updated %.3f -> %.3f" % (iter_no, best_reward, test_reward))
best_reward = test_reward
if test_reward > 0.80:
print("Solved in %d iterations!" % iter_no)
break
writer.close()
示例的结果如下所示:
Chapter06$ ./01_frozenlake_q_learning.py
1149: Best test reward updated 0.000 -> 0.500
1150: Best test reward updated 0.500 -> 0.550
1164: Best test reward updated 0.550 -> 0.600
1242: Best test reward updated 0.600 -> 0.650
2685: Best test reward updated 0.650 -> 0.700
2988: Best test reward updated 0.700 -> 0.750
3025: Best test reward updated 0.750 -> 0.850
Solved in 3025 iterations!
你可能已经注意到,与上一章的值迭代方法相比,这个版本使用了更多的迭代(但你的实验可能有不同的步骤数)来解决问题。原因在于我们不再使用在测试过程中获得的经验。在示例 Chapter05/02_frozenlake_q_iteration.py 中,周期性测试导致 Q 表统计的更新。在这里,我们在测试过程中不触及 Q 值,这导致在环境解决之前需要更多的迭代。
总体而言,从环境中所需的样本总数几乎相同。TensorBoard 中的奖励图也显示了良好的训练动态,这与值迭代方法非常相似(值迭代的奖励图如图 5.9 所示):

图 6.1:FrozenLake 的奖励动态
在下一节中,我们将扩展 Q-learning 方法,结合神经网络(NNs)对环境状态的预处理。这将极大地扩展我们讨论过的方法的灵活性和适用性。
深度 Q 学习
我们刚刚介绍的 Q-learning 方法解决了遍历所有状态集的问题,但当可观察状态集的数量非常大时,它仍然可能遇到困难。例如,Atari 游戏可能有许多不同的屏幕,如果我们决定将原始像素作为单独的状态,我们很快就会意识到我们有太多的状态需要追踪和估算值。
在某些环境中,不同的可观察状态的数量几乎是无限的。例如,在 CartPole 中,环境给我们提供的状态是四个浮动点数。数值组合的数量是有限的(它们以比特表示),但这个数字极其庞大。仅用比特值表示时,约为 2^(4⋅32) ≈ 3.4 ⋅ 10³⁸。实际上,这个值会小一些,因为环境状态的值是有限制的,并非所有的 4 个 float32 值的比特组合都是可能的,但结果的状态空间依然太大。我们可以创建一些箱子来离散化这些值,但这通常会带来比解决更多问题;我们需要决定哪些参数范围重要,需要区分成不同的状态,而哪些范围可以归类在一起。由于我们尝试以一般的方式实现 RL 方法(而不深入了解环境的内部结构),这不是一个很有前景的方向。
在 Atari 游戏的情况下,单一像素的变化并不会造成太大差异,因此我们可能希望将相似的图像视为一个状态。然而,我们仍然需要区分一些状态。
下图展示了 Pong 游戏中的两种不同情况。我们正在与人工智能(AI)对手对战,通过控制一个挡板(我们的挡板在右侧,对手的挡板在左侧)。游戏的目标是将弹跳球送过对手的挡板,同时防止球从我们的挡板旁边飞过。我们可以认为这两种情况是完全不同的。在右侧展示的情况中,球靠近对手,因此我们可以放松并观察。然而,左侧的情况要求更高;假设球从左向右移动,球正朝我们的挡板移动,因此我们需要迅速移动我们的挡板,以避免失分。图 6.2 中的两种情况只是 10⁷⁰⁸⁰²种可能情况中的两种,但我们希望我们的智能体能对这些情况做出不同的反应。

图 6.2:Pong 中观察的模糊性。在左侧的图像中,球正向右移动,朝着我们的挡板,而在右侧,它的方向相反。
作为该问题的解决方案,我们可以使用一个非线性表示,将状态和动作映射到一个值。在机器学习中,这称为“回归问题”。表示和训练这种表示的具体方法可以有所不同,但正如你从本节标题中已经猜到的那样,使用深度神经网络(NN)是最流行的选择之一,尤其是当处理以屏幕图像表示的观察时。考虑到这一点,我们对 Q-learning 算法进行修改:
-
使用一些初始近似值初始化 Q(s,a)。
-
通过与环境交互,获得元组(s, a, r, s′)。
-
计算损失:
![π (a |s) = P[At = a|St = s]]()
![π (a |s) = P[At = a|St = s]]()
-
通过使用随机梯度下降(SGD)算法更新 Q(s,a),通过最小化关于模型参数的损失来进行更新。
-
从步骤 2 开始重复,直到收敛。
这个算法看起来很简单,但不幸的是,它的效果并不好。让我们讨论一些可能出错的方面,以及我们可以如何处理这些情况。
与环境交互
首先,我们需要以某种方式与环境交互,以接收数据进行训练。在简单的环境中,比如 FrozenLake,我们可以随机行动,但这真的是最好的策略吗?想象一下 Pong 游戏。通过随机移动挡板,获得一个单独得分的概率是多少?这个概率不是零,但它极其小,这意味着我们需要等待很长时间,才能遇到这种罕见的情况。作为替代方案,我们可以使用 Q 函数的近似值作为行为的来源(就像我们在价值迭代方法中做的那样,当时我们在测试期间记住了自己的经验)。
如果我们的 Q 表示是好的,那么从环境中获得的经验将向代理提供相关的数据用于训练。然而,当我们的近似不完美时(例如在训练的初期),我们就会遇到问题。在这种情况下,我们的代理可能会在某些状态下一直采取错误的行为,而从未尝试过不同的行为。这就是在第一章中简要提到的探索与利用困境,我们将在此详细讨论。一方面,我们的代理需要探索环境,以建立完整的转移和动作结果的图景。另一方面,我们应当高效地利用与环境的交互;我们不应浪费时间随机尝试我们已经尝试过并且已知结果的动作。
如你所见,在训练初期,当我们的 Q 近似值较差时,随机行为反而更好,因为它能提供更多均匀分布的环境状态信息。随着训练的进展,随机行为变得低效,我们希望回归到 Q 近似值上,以决定如何行动。
执行这种两种极端行为混合的方法被称为 epsilon-贪婪方法,意思是使用概率超参数𝜖在随机行为和 Q 策略之间切换。通过改变𝜖的值,我们可以选择随机行为的比例。通常的做法是从𝜖 = 1.0(100%的随机行为)开始,并逐渐将其减少到一个较小的值,如 5%或 2%的随机行为。使用 epsilon-贪婪方法可以帮助我们在训练初期探索环境,并在训练结束时坚持好的策略。还有其他解决探索与利用问题的方法,我们将在本书的第三部分讨论其中的一些。这个问题是强化学习(RL)中的一个基础性未解问题,也是一个仍在积极研究的领域,离完全解决还远。
SGD 优化
我们的 Q 学习过程的核心借鉴了监督学习。事实上,我们正试图用神经网络(NN)来逼近一个复杂的非线性函数 Q(s,a)。为此,我们必须使用贝尔曼方程计算该函数的目标值,然后假装我们面临的是一个监督学习问题。这是可以的,但 SGD 优化的一个基本要求是训练数据是独立同分布的(通常缩写为 iid),这意味着我们的训练数据是从我们试图学习的底层数据集中随机抽样的。
在我们的情况下,我们将用于 SGD 更新的数据不符合这些标准:
-
我们的样本不是独立的。即使我们累积了大量的数据样本,它们之间也会非常接近,因为它们都属于同一个回合。
-
我们的训练数据的分布将不完全等同于我们想要学习的最优策略提供的样本。我们拥有的数据将是其他策略(我们的当前策略、随机策略,或在 epsilon-贪婪情况下的两者结合)的结果,但我们并不想学习如何随机地行动:我们希望获得一个具有最佳奖励的最优策略。
为了应对这一难题,我们通常需要使用一个包含我们过去经验的大缓冲区,并从中抽取训练数据,而不是仅使用我们最新的经验。这种技术叫做回放缓冲区。最简单的实现方式是一个固定大小的缓冲区,新数据被添加到缓冲区的末尾,从而将最旧的经验推出缓冲区之外。
回放缓冲区允许我们在或多或少独立的数据上进行训练,但这些数据仍然足够新鲜,可以用于训练我们最近的策略生成的样本。在第八章中,我们将检查另一种回放缓冲区,优先级回放,它提供了一种更复杂的采样方法。
步骤之间的相关性
默认训练过程的另一个实际问题也与缺乏独立同分布(iid)数据有关,但方式稍有不同。贝尔曼方程通过 Q(s′,a′)为我们提供 Q(s,a)的值(这个过程称为自举,当我们递归使用该公式时)。然而,状态 s 和 s′之间只有一步之遥。这使得它们非常相似,神经网络很难区分它们。当我们更新神经网络的参数,使 Q(s,a)更接近预期结果时,我们可能会间接地改变 Q(s′,a′)和附近其他状态的值。这可能导致我们的训练非常不稳定,就像在追逐自己的尾巴;当我们更新状态 s 的 Q 值时,在随后的状态中,我们会发现 Q(s′,a′)变得更糟,但尝试更新它可能会进一步破坏 Q(s,a)的近似,依此类推。
为了使训练更加稳定,有一个技巧叫做目标网络,我们保留一份网络的副本,并用它来计算贝尔曼方程中的 Q(s′,a′)值。这个网络与我们的主网络只会定期同步,例如,每隔 N 步同步一次(其中 N 通常是一个较大的超参数,如 1k 或 10k 训练迭代)。
马尔可夫性质
我们的强化学习方法以马尔可夫决策过程(MDP)形式主义为基础,假设环境遵循马尔可夫性质:来自环境的观察是我们采取最优行动所需的全部信息。换句话说,我们的观察允许我们区分不同的状态。
如你从前面的 Pong 游戏截图(图 6.2)所见,一张来自 Atari 游戏的单一图像不足以捕获所有重要信息(仅使用一张图像,我们无法知道物体的速度和方向,比如球和我们对手的挡板)。这显然违反了马尔可夫性质,并将我们的单帧 Pong 环境移入部分可观察马尔可夫决策过程(POMDPs)的范畴。POMDP 基本上是没有马尔可夫性质的 MDP,它在实际应用中非常重要。例如,在大多数扑克牌游戏中,你无法看到对手的牌,这些游戏观察就是 POMDP,因为当前的观察(即你手中的牌和桌面上的牌)可能对应对手手中的不同牌。
本书中我们不会详细讨论部分可观察马尔可夫决策过程(POMDPs),但我们会使用一个小技巧将我们的环境推回到 MDP 领域。解决方案是维护过去的多个观察,并将它们用作状态。在 Atari 游戏的情况下,我们通常将 k 个连续的帧堆叠在一起,并将它们作为每个状态的观察。这让我们的智能体能够推断出当前状态的动态,例如,获取球的速度和方向。对于 Atari 游戏,通常的“经典”k 值是四。当然,这只是一个技巧,因为环境中可能存在更长的依赖关系,但对于大多数游戏来说,它表现得很好。
DQN 训练的最终形式
研究人员已经发现了许多技巧,使得 DQN 训练更加稳定和高效,我们将在第八章介绍其中最好的方法。然而,epsilon-greedy 策略、回放缓冲区和目标网络构成了基础,使得 DeepMind 公司能够成功地在 49 款 Atari 游戏上训练 DQN,展示了这种方法在复杂环境中的效率。
原始论文《Playing Atari with deep reinforcement learning》[Mni13](没有目标网络)发布于 2013 年底,测试使用了七款游戏。后来,在 2015 年初,文章经过修订,标题为《Human-level control through deep reinforcement learning》[Mni+15],此时已使用了 49 款不同的游戏,并发表于《自然》杂志。
来自前述论文的 DQN 算法步骤如下:
-
使用随机权重初始化 Q(s,a) 和 Q̂(s,a) 的参数,𝜖 ← 1.0,并清空回放缓冲区。
-
以概率 𝜖 选择一个随机动作 a;否则,a = arg max[a]Q(s,a)。
-
在模拟器中执行动作 a,并观察奖励 r 和下一个状态 s′。
-
将过渡 (s, a, r, s′) 存储到回放缓冲区中。
-
从回放缓冲区中随机抽取一个小批量的过渡。
-
对于缓冲区中的每个过渡,计算目标:
![π (a |s) = P[At = a|St = s]]()
-
计算损失:ℒ = (Q(s,a) −y)²。
-
通过最小化损失相对于模型参数,使用 SGD 算法更新 Q(s,a)。
-
每隔 N 步,从 Q 复制权重到 Q̂。
-
从第 2 步开始重复,直到收敛。
现在让我们实现这个算法,并尝试击败一些 Atari 游戏!
DQN 在 Pong 游戏中的应用
在我们开始代码之前,需要进行一些介绍。我们的示例变得越来越具有挑战性和复杂性,这并不奇怪,因为我们要解决的问题的复杂性也在增加。尽管例子尽可能简单简洁,但有些代码初看可能难以理解。
另一个需要注意的事项是性能。我们之前的例子(例如 FrozenLake 或 CartPole)从资源角度来看并不苛刻,因为观察值较小,神经网络参数也很小,训练循环中的额外毫秒并不重要。然而,从现在开始,情况就不同了。来自 Atari 环境的每个观察值有 10 万个数据点,这些数据需要预处理、重新缩放并存储在回放缓冲区中。多一份数据副本可能会影响训练速度,这不再是秒和分钟的问题,而是即使是最快的图形处理单元(GPU)也可能需要数小时。
神经网络(NN)训练循环也可能成为瓶颈。当然,强化学习模型并不像最先进的大型语言模型(LLM)那样庞大,但即便是 2015 年的 DQN 模型也有超过 150 万个参数,需要调整数百万次。因此,简而言之,性能非常重要,尤其是在你进行超参数实验时,不仅需要等待一个模型训练完成,而是几十个模型。
PyTorch 相当具有表现力,因此效率较高的处理代码看起来通常不如优化过的 TensorFlow 图那么晦涩,但仍然存在很大机会做得很慢并犯错误。例如,一个简单版的 DQN 损失计算,它对每个批次样本进行循环处理,比并行版本慢大约两倍。然而,仅仅是对数据批次做一个额外的副本,就会使得相同代码的速度变慢 13 倍,这非常显著。
由于其长度、逻辑结构和可重用性,该示例被拆分为三个模块。模块如下:
-
Chapter06/lib/wrappers.py:这些是 Atari 环境的包装器,主要来自 Stable Baselines3(SB3)项目:
github.com/DLR-RM/stable-baselines3。 -
Chapter06/lib/dqn_model.py:这是 DQN 神经网络层,其架构与 DeepMind 在《Nature》论文中的 DQN 相同。
-
Chapter06/02_dqn_pong.py:这是主要模块,包含训练循环、损失函数计算和经验回放缓冲区。
包装器
使用强化学习(RL)解决 Atari 游戏在资源方面是相当有挑战的。为了加快速度,针对 Atari 平台的交互应用了几种转换,这些转换在 DeepMind 的论文中有详细描述。部分转换仅影响性能,而有些则是解决 Atari 平台的特性,这些特性使得学习过程既漫长又不稳定。转换通过不同种类的 Gym 包装器来实现。完整的列表相当长,并且同一个包装器有多个实现版本来自不同来源。我的个人偏好是 SB3 仓库,它是 OpenAI Baselines 代码的演变版本。
SB3 包含大量使用 PyTorch 实现的强化学习方法,旨在作为一个统一的基准,比较各种方法。目前,我们对这些方法的实现不感兴趣(我们打算自己重新实现大多数方法),但一些包装器非常有用。该仓库可以在github.com/DLR-RM/stable-baselines3找到,包装器的文档可以在stable-baselines3.readthedocs.io/en/master/common/atari_wrappers.xhtml查看。强化学习研究人员常用的 Atari 转换列表包括:
-
将游戏中的每个生命转化为单独的回合:一般来说,一个回合包含从游戏开始到“游戏结束”画面所有步骤,这可能会持续数千个游戏步骤(观察和动作)。通常,在街机游戏中,玩家会获得几条命,这提供了几次游戏尝试。这种转换将一个完整回合拆分为每条命对应的单独小回合。在内部,这是通过检查模拟器关于剩余生命的信息来实现的。并非所有游戏都支持此功能(尽管乒乓球游戏支持),但对于支持的环境,这通常有助于加速收敛,因为我们的回合变得更短。此逻辑在 SB3 代码中的 EpisodicLifeEnv 包装类中得到了实现。
-
在游戏开始时执行一个随机数量(最多 30 次)的空操作(也称为“无操作”):这跳过了一些雅达利游戏中的介绍画面,这些画面与游戏玩法无关。它在 NoopResetEnv 包装类中得到了实现。
-
每 K 步做一次动作决策,其中 K 通常是 3 或 4:在中间帧上,所选的动作会被简单地重复。这使得训练能够显著加速,因为使用神经网络处理每一帧是一个非常费时的操作,但连续帧之间的差异通常较小。这在 MaxAndSkipEnv 包装类中得到了实现,该类也包含列表中的下一个转换(两帧之间的最大值)。
-
取每个像素在最后两帧中的最大值并作为观察值:一些雅达利游戏存在闪烁效果,这是由于平台的限制。(雅达利每帧上可以显示的精灵数量是有限的。)对于人眼来说,这种快速变化是不可见的,但它们可能会干扰神经网络(NN)。
-
在游戏开始时按下 FIRE 键:某些游戏(包括乒乓球和打砖块)需要用户按下 FIRE 按钮才能开始游戏。如果没有按下该按钮,环境将变为部分可观测的马尔可夫决策过程(POMDP),因为从观察中,代理无法判断是否已经按下了 FIRE 键。这在 FireResetEnv 包装类中得到了实现。
-
将每帧从 210 × 160 的三色图像缩放为单色的 84 × 84 图像:有不同的方法可以实现。例如,DeepMind 的论文将此转换描述为从 YCbCr 色彩空间中提取 Y 色通道,然后将整个图像重新缩放为 84 × 84 的分辨率。其他一些研究人员进行灰度转换,裁剪掉图像中不相关的部分然后进行缩放。在 SB3 的代码库中,使用了后一种方法。这在 WarpFrame 包装类中得到了实现。
-
将多个(通常是四个)连续的帧堆叠在一起,以向网络提供游戏中物体动态的信息:这种方法已经作为解决单一游戏帧缺乏游戏动态的快速方案进行了讨论。在 SB3 项目中没有现成的包装类,我在 wrappers.BufferWrapper 中实现了我的版本。
-
将奖励裁剪到 -1、0 和 1 的值:获得的分数在不同游戏之间可能差异很大。例如,在 Pong 游戏中,每当你将球打过对方的挡板时,你会获得 1 分。然而,在某些游戏中,如 KungFuMaster,每杀死一个敌人你会获得 100 分。奖励值的这种差异使得我们在不同游戏之间的损失函数尺度完全不同,这使得找到适用于一组游戏的通用超参数变得更加困难。为了解决这个问题,奖励被裁剪到 −1 到 1 的范围内。这在 ClipRewardEnv 封装器中实现。
-
重排观察维度以满足 PyTorch 卷积层的要求:由于我们将使用卷积,张量需要按 PyTorch 期望的方式进行重排。Atari 环境以(高度,宽度,颜色)的格式返回观察数据,但 PyTorch 卷积层要求通道维度排在最前面。这在 wrappers.ImageToPyTorch 中得以实现。
大多数这些封装器都在 stable-baseline3 库中实现,库中提供了 AtariWrapper 类,它根据构造函数的参数按需要的顺序应用封装器。它还会检测底层环境的属性,并在需要时启用 FireResetEnv。并非所有封装器都需要在 Pong 游戏中使用,但你应该了解现有的封装器,以防你决定尝试其他游戏。有时,当 DQN 不收敛时,问题并不在代码中,而是环境封装错误。我曾经花了几天时间调试收敛问题,结果是因为在游戏开始时没有按下 FIRE 按钮!
让我们来看看各个封装器的实现。我们将从 stable-baseline3 提供的类开始:
class FireResetEnv(gym.Wrapper[np.ndarray, int, np.ndarray, int]):
def __init__(self, env: gym.Env) -> None:
super().__init__(env)
assert env.unwrapped.get_action_meanings()[1] == "FIRE"
assert len(env.unwrapped.get_action_meanings()) >= 3
def reset(self, **kwargs) -> AtariResetReturn:
self.env.reset(**kwargs)
obs, _, terminated, truncated, _ = self.env.step(1)
if terminated or truncated:
self.env.reset(**kwargs)
obs, _, terminated, truncated, _ = self.env.step(2)
if terminated or truncated:
self.env.reset(**kwargs)
return obs, {}
上述封装器在需要按下 FIRE 按钮才能开始游戏的环境中按下该按钮。除了按下 FIRE 按钮外,这个封装器还会检查一些在某些游戏中存在的边缘情况。
这个封装器结合了在 K 帧内重复执行的动作和来自两帧之间的像素信息:
class MaxAndSkipEnv(gym.Wrapper[np.ndarray, int, np.ndarray, int]):
def __init__(self, env: gym.Env, skip: int = 4) -> None:
super().__init__(env)
self._obs_buffer = np.zeros((2, *env.observation_space.shape),
dtype=env.observation_space.dtype)
self._skip = skip
def step(self, action: int) -> AtariStepReturn:
total_reward = 0.0
terminated = truncated = False
for i in range(self._skip):
obs, reward, terminated, truncated, info = self.env.step(action)
done = terminated or truncated
if i == self._skip - 2:
self._obs_buffer[0] = obs
if i == self._skip - 1:
self._obs_buffer[1] = obs
total_reward += float(reward)
if done:
break
# Note that the observation on the done=True frame
# doesn’t matter
max_frame = self._obs_buffer.max(axis=0)
return max_frame, total_reward, terminated, truncated, info
以下封装器的目标是将来自模拟器的输入观察转换为一个分辨率为 210 × 160 像素并具有 RGB 颜色通道的图像,转换为一个灰度 84 × 84 的图像。它通过使用 CV2 库中的 cvtColor 函数来实现该操作,cvTColor 函数执行的是色度灰度转换(这种转换比简单的颜色通道平均更接近人类的颜色感知),然后图像会被缩放:
class WarpFrame(gym.ObservationWrapper[np.ndarray, int, np.ndarray]):
def __init__(self, env: gym.Env, width: int = 84, height: int = 84) -> None:
super().__init__(env)
self.width = width
self.height = height
self.observation_space = spaces.Box(
low=0, high=255, shape=(self.height, self.width, 1),
dtype=env.observation_space.dtype,
)
def observation(self, frame: np.ndarray) -> np.ndarray:
frame = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
frame = cv2.resize(frame, (self.width, self.height), interpolation=cv2.INTER_AREA)
return frame[:, :, None]
到目前为止,我们已经使用了 stable-baseline3 中的封装器(我跳过了 EpisodicLifeEnv 封装器,因为它有点复杂且与此不太相关);你可以在仓库 stable_baselines3/common/atari_wrappers.py 中找到其他可用封装器的代码。现在,让我们来看一下来自 lib/wrappers.py 中的两个封装器:
class BufferWrapper(gym.ObservationWrapper):
def __init__(self, env, n_steps):
super(BufferWrapper, self).__init__(env)
obs = env.observation_space
assert isinstance(obs, spaces.Box)
new_obs = gym.spaces.Box(
obs.low.repeat(n_steps, axis=0), obs.high.repeat(n_steps, axis=0),
dtype=obs.dtype)
self.observation_space = new_obs
self.buffer = collections.deque(maxlen=n_steps)
def reset(self, *, seed: tt.Optional[int] = None, options: tt.Optional[dict[str, tt.Any]] = None):
for _ in range(self.buffer.maxlen-1):
self.buffer.append(self.env.observation_space.low)
obs, extra = self.env.reset()
return self.observation(obs), extra
def observation(self, observation: np.ndarray) -> np.ndarray:
self.buffer.append(observation)
return np.concatenate(self.buffer)
BufferWrapper 类创建了一个堆栈(使用 deque 类实现),沿着第一维度堆叠随后的帧,并将它们作为观察值返回。目的是让网络了解物体的动态信息,比如乒乓球的速度和方向,或者敌人是如何移动的。这些信息非常重要,是从单一图像中无法获得的。
关于这个包装器有一个非常重要但不太显眼的细节,那就是观察方法返回的是我们缓冲区中观察值的副本。这一点非常重要,因为我们要将观察值保存在重放缓冲区中,因此需要副本以避免未来环境步骤中对缓冲区的修改。从原则上讲,我们可以通过在其中保存回合的观察值和它们的索引来避免制作副本(并将内存占用减少四倍),但这需要更加复杂的数据结构管理。目前需要注意的是,这个包装器必须是应用于环境的包装器链中的最后一个。
最后的包装器是 ImageToPyTorch,它将观察值的形状从高度、宽度、通道(HWC)格式转换为 PyTorch 所需的通道、高度、宽度(CHW)格式:
class ImageToPyTorch(gym.ObservationWrapper):
def __init__(self, env):
super(ImageToPyTorch, self).__init__(env)
obs = self.observation_space
assert isinstance(obs, gym.spaces.Box)
assert len(obs.shape) == 3
new_shape = (obs.shape[-1], obs.shape[0], obs.shape[1])
self.observation_space = gym.spaces.Box(
low=obs.low.min(), high=obs.high.max(),
shape=new_shape, dtype=obs.dtype)
def observation(self, observation):
return np.moveaxis(observation, 2, 0)
张量的输入形状的最后一维是颜色通道,但 PyTorch 的卷积层假设颜色通道是第一维。
文件的最后是一个简单的函数,它创建一个带有名称的环境,并将所有需要的包装器应用于它:
def make_env(env_name: str, **kwargs):
env = gym.make(env_name, **kwargs)
env = atari_wrappers.AtariWrapper(env, clip_reward=False, noop_max=0)
env = ImageToPyTorch(env)
env = BufferWrapper(env, n_steps=4)
return env
如你所见,我们正在使用来自 stable-baseline3 的 AtariWrapper 类,并禁用了一些不必要的包装器。
这就是包装器的内容;接下来我们来看看我们的模型。
DQN 模型
发表在《自然》杂志上的模型有三个卷积层,后面跟着两个全连接层。所有的层都由修正线性单元(ReLU)非线性函数分隔。该模型的输出是环境中每个可用动作的 Q 值,且没有应用非线性(因为 Q 值可以是任意值)。通过让所有 Q 值通过网络一次计算出来,这种方法相比将 Q(s,a)直接处理并将观察和动作输入网络以获取动作值的方法,显著提高了速度。
模型的代码在 Chapter06/lib/dqn_model.py 中:
import torch
import torch.nn as nn
class DQN(nn.Module):
def __init__(self, input_shape, n_actions):
super(DQN, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 32, kernel_size=8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU(),
nn.Flatten(),
)
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.fc = nn.Sequential(
nn.Linear(size, 512),
nn.ReLU(),
nn.Linear(512, n_actions)
)
为了能够以通用的方式编写我们的网络,它被实现为两部分:卷积部分和线性部分。卷积部分处理输入图像,它是一个 4 × 84 × 84 的张量。最后一个卷积滤波器的输出被展平为一个一维向量,并输入到两个线性层中。
另一个小问题是,我们不知道由给定形状输入产生的卷积层输出中确切的值的数量。然而,我们需要将这个数字传递给第一个全连接层的构造函数。一个可能的解决方案是硬编码这个数字,它是输入形状和最后一个卷积层配置的函数(对于 84 × 84 的输入,卷积层的输出将有 3,136 个值);然而,这不是最好的方式,因为我们的代码会变得不太健壮,无法应对输入形状的变化。更好的解决方案是通过应用卷积部分到一个假输入张量,在运行时获取所需的维度。结果的维度将等于该应用返回的参数数量。这样做非常快速,因为这个调用只会在模型创建时执行一次,而且它还允许我们拥有通用的代码。
模型的最后一部分是 forward() 函数,它接受 4D 输入张量。第一个维度是批量大小,第二个维度是颜色通道,它是我们后续帧的堆叠;第三和第四个维度是图像尺寸:
def forward(self, x: torch.ByteTensor):
# scale on GPU
xx = x / 255.0
return self.fc(self.conv(xx))
在这里,在应用我们的网络之前,我们对输入数据进行了缩放和类型转换。这需要一些解释。
Atari 图像中的每个像素表示为一个无符号字节,值的范围从 0 到 255。这样做有两个好处:内存效率和 GPU 带宽。从内存的角度来看,我们应该尽量保持环境观察数据的大小,因为我们的回放缓冲区会保存成千上万的观察结果,我们希望它保持尽可能小。另一方面,在训练过程中,我们需要将这些观察数据转移到 GPU 内存中,以计算梯度并更新网络参数。主内存和 GPU 之间的带宽是有限资源,因此保持观察数据尽可能小也是有道理的。
这就是为什么我们将观察结果保持为 dtype=uint8 的 numpy 数组,并且网络的输入张量是 ByteTensor。但是 Conv2D 层期望输入的是浮动类型张量,因此通过将输入张量除以 255.0,我们将其缩放到 0…1 范围,并进行类型转换。这是快速的,因为输入字节张量已经在 GPU 内存中。之后,我们将网络的两个部分应用于结果的缩放张量。
训练
第三个模块包含经验回放缓冲区、智能体、损失函数计算和训练循环本身。在进入代码之前,需要先谈一下训练的超参数。
DeepMind 的 Nature 论文中包含了一个表格,列出了用于训练模型并评估所有 49 个 Atari 游戏的超参数的详细信息。DeepMind 对所有游戏保持了相同的参数设置(但每个游戏训练了单独的模型),他们的目的是展示该方法足够稳健,可以使用一个统一的模型架构和超参数解决多种复杂度、动作空间、奖励结构及其他细节各异的游戏。然而,我们的目标要谦逊得多:我们只希望解决 Pong 游戏。
Pong 相比于 Atari 测试集中其他游戏来说相当简单直接,因此文中提到的超参数对于我们的任务来说是过多的。例如,为了在所有 49 个游戏中获得最佳结果,DeepMind 使用了百万次观测的重放缓冲区,这需要大约 20 GB 的内存来存储,并且需要大量的环境样本来填充它。
所使用的 epsilon 衰减计划对于单一的 Pong 游戏也不是最优的。在训练过程中,DeepMind 将 epsilon 从 1.0 线性衰减到 0.1,衰减过程持续了从环境中获得的前百万帧。然而,我自己的实验表明,对于 Pong 游戏,衰减 epsilon 只需要在前 15 万帧内完成,然后保持稳定即可。重放缓冲区也可以更小:10k 次转换就足够了。
在以下示例中,我使用了我的参数。虽然这些参数与论文中的参数不同,但它们能让我们大约以 10 倍的速度解决 Pong 游戏。在 GeForce GTX 1080 Ti 上,以下版本大约 50 分钟就能收敛到 19.0 的平均分数,但使用 DeepMind 的超参数至少需要一天时间。
这种加速当然是针对特定环境的微调,并可能导致在其他游戏中无法收敛。你可以自由地调整选项和尝试 Atari 集合中的其他游戏。
首先,我们导入所需的模块:
import gymnasium as gym
from lib import dqn_model
from lib import wrappers
from dataclasses import dataclass
import argparse
import time
import numpy as np
import collections
import typing as tt
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.tensorboard.writer import SummaryWriter
然后我们定义超参数:
DEFAULT_ENV_NAME = "PongNoFrameskip-v4"
MEAN_REWARD_BOUND = 19
这两个值设置了默认的训练环境以及停止训练的奖励边界(最后 100 回合)。如果需要,你可以通过命令行 --env 参数重新定义环境名称:
GAMMA = 0.99
BATCH_SIZE = 32
REPLAY_SIZE = 10000
LEARNING_RATE = 1e-4
SYNC_TARGET_FRAMES = 1000
REPLAY_START_SIZE = 10000
上述参数定义了以下内容:
-
我们用于贝尔曼近似的 γ 值(GAMMA)
-
从重放缓冲区中采样的批次大小(BATCH_SIZE)
-
缓冲区的最大容量(REPLAY_SIZE)
-
我们在开始训练前等待的帧数,用于填充重放缓冲区(REPLAY_START_SIZE)
-
在这个示例中使用的 Adam 优化器的学习率(LEARNING_RATE)
-
我们将训练模型的权重同步到目标模型的频率,目标模型用于在贝尔曼近似中获取下一个状态的值(SYNC_TARGET_FRAMES)
EPSILON_DECAY_LAST_FRAME = 150000
EPSILON_START = 1.0
EPSILON_FINAL = 0.01
最后一批超参数与 epsilon 衰减调度有关。为了实现适当的探索,我们在训练的早期阶段从 𝜖 = 1.0 开始,这会导致所有动作都被随机选择。然后,在前 150,000 帧中,𝜖 会线性衰减到 0.01,这对应于 1% 的步骤中采取随机动作。原始 DeepMind 论文中也使用了类似的方案,但衰减的持续时间几乎是原来的 10 倍(因此 𝜖 = 0.01 是在一百万帧后达到的)。
在这里,我们定义了类型别名和数据类 Experience,用于保存经验回放缓冲区中的条目。它包含当前状态、采取的动作、获得的奖励、终止或截断标志以及新的状态:
State = np.ndarray
Action = int
BatchTensors = tt.Tuple[
torch.ByteTensor, # current state
torch.LongTensor, # actions
torch.Tensor, # rewards
torch.BoolTensor, # done || trunc
torch.ByteTensor # next state
]
@dataclass
class Experience:
state: State
action: Action
reward: float
done_trunc: bool
new_state: State
下一段代码定义了我们的经验回放缓冲区,目的是保存从环境中获得的转移:
class ExperienceBuffer:
def __init__(self, capacity: int):
self.buffer = collections.deque(maxlen=capacity)
def __len__(self):
return len(self.buffer)
def append(self, experience: Experience):
self.buffer.append(experience)
def sample(self, batch_size: int) -> tt.List[Experience]:
indices = np.random.choice(len(self), batch_size, replace=False)
return [self.buffer[idx] for idx in indices]
每次在环境中执行一步时,我们将转移推入缓冲区,只保留固定数量的步数(在我们的情况下是 10k 次转移)。在训练中,我们从回放缓冲区随机抽取一批转移,这样可以打破环境中后续步骤之间的相关性。
大部分经验回放缓冲区的代码都非常直接:它基本上利用了 deque 类来保持缓冲区中的指定数量的条目。在 sample() 方法中,我们创建一个随机索引的列表,并返回一个包含经验条目的列表,以便重新包装并转换为张量。
接下来我们需要的类是 Agent,它与环境进行交互,并将交互的结果保存到你刚才看到的经验回放缓冲区中:
class Agent:
def __init__(self, env: gym.Env, exp_buffer: ExperienceBuffer):
self.env = env
self.exp_buffer = exp_buffer
self.state: tt.Optional[np.ndarray] = None
self._reset()
def _reset(self):
self.state, _ = env.reset()
self.total_reward = 0.0
在智能体初始化时,我们需要存储对环境和经验回放缓冲区的引用,跟踪当前的观察值和迄今为止累计的总奖励。
智能体的主要方法是在环境中执行一步并将其结果存储在缓冲区中。为此,我们需要先选择动作:
@torch.no_grad()
def play_step(self, net: dqn_model.DQN, device: torch.device,
epsilon: float = 0.0) -> tt.Optional[float]:
done_reward = None
if np.random.random() < epsilon:
action = env.action_space.sample()
else:
state_v = torch.as_tensor(self.state).to(device)
state_v.unsqueeze_(0)
q_vals_v = net(state_v)
_, act_v = torch.max(q_vals_v, dim=1)
action = int(act_v.item())
以概率 epsilon(作为参数传递),我们采取随机动作;否则,我们使用模型来获得所有可能动作的 Q 值,并选择最优的动作。在此方法中,我们使用 PyTorch 的 no_grad() 装饰器在整个方法中禁用梯度追踪,因为我们根本不需要它们。
当动作被选中后,我们将其传递给环境以获取下一个观察值和奖励,将数据存储在经验缓冲区中,然后处理回合结束的情况:
new_state, reward, is_done, is_tr, _ = self.env.step(action)
self.total_reward += reward
exp = Experience(
state=self.state, action=action, reward=float(reward),
done_trunc=is_done or is_tr, new_state=new_state
)
self.exp_buffer.append(exp)
self.state = new_state
if is_done or is_tr:
done_reward = self.total_reward
self._reset()
return done_reward
函数的结果是总的累计奖励,如果我们通过这一步已经到达了回合的结束,则返回奖励,否则返回 None。
函数 batch_to_tensors 接受一批 Experience 对象,并返回一个包含状态、动作、奖励、完成标志和新状态的元组,这些数据会被重新打包为对应类型的 PyTorch 张量:
def batch_to_tensors(batch: tt.List[Experience], device: torch.device) -> BatchTensors:
states, actions, rewards, dones, new_state = [], [], [], [], []
for e in batch:
states.append(e.state)
actions.append(e.action)
rewards.append(e.reward)
dones.append(e.done_trunc)
new_state.append(e.new_state)
states_t = torch.as_tensor(np.asarray(states))
actions_t = torch.LongTensor(actions)
rewards_t = torch.FloatTensor(rewards)
dones_t = torch.BoolTensor(dones)
new_states_t = torch.as_tensor(np.asarray(new_state))
return states_t.to(device), actions_t.to(device), rewards_t.to(device), \
dones_t.to(device), new_states_t.to(device)
当我们处理状态时,我们尽量避免内存复制(通过使用 np.asarray()函数),这是很重要的,因为 Atari 的观测数据量大(每帧有 84 × 84 字节,共四帧),并且我们有 32 个这样的对象。如果没有这个优化,性能会下降大约 20 倍。
现在是训练模块中最后一个函数的时间了,这个函数计算采样批次的损失。这个函数的写法旨在最大化利用 GPU 的并行计算,通过向量化操作处理所有批次样本,这使得它比一个简单的批次循环更难理解。然而,这个优化是值得的:并行版本比显式的循环快了两倍多。
提醒一下,这是我们需要计算的损失表达式:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq24.png)
我们使用前面的方程处理非回合结束的步骤,使用以下方程处理最后的步骤:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq25.png)
def calc_loss(batch: tt.List[Experience], net: dqn_model.DQN, tgt_net: dqn_model.DQN,
device: torch.device) -> torch.Tensor:
states_t, actions_t, rewards_t, dones_t, new_states_t = batch_to_tensors(batch, device)
在这些参数中,我们传入了我们的批次、正在训练的网络和目标网络,目标网络会定期与训练好的网络同步。
第一个模型(作为 net 参数传入)用于计算梯度;第二个模型(在 tgt_net 参数中)用于计算下一个状态的值,这一计算不应影响梯度。为了实现这一点,我们使用 PyTorch 张量的 detach()函数来防止梯度流入目标网络的图中。这个函数在第三章中有描述。
在函数开始时,我们调用 batch_to_tensors 函数将批次重新打包成单独的张量变量。
下一行有点复杂:
state_action_values = net(states_t).gather(
1, actions_t.unsqueeze(-1)
).squeeze(-1)
让我们详细讨论一下。在这里,我们将观测数据传入第一个模型,并使用 gather()张量操作提取已执行动作的特定 Q 值。gather()调用的第一个参数是我们希望进行聚合的维度索引(在我们的例子中,它等于 1,表示动作维度)。
第二个参数是一个元素索引的张量,用来选择需要的元素。为了计算 gather()函数的索引参数并去除我们创建的多余维度,分别需要额外的 unsqueeze()和 squeeze()调用。(索引应与我们处理的数据具有相同的维度数。)在图 6.3 中,您可以看到 gather()在示例中的作用,示例中有六个条目的批次和四个动作。

图 6.3:DQN 损失计算中的张量转换
请记住,gather()应用于张量的结果是一个可微分操作,它会保持与最终损失值相关的所有梯度。
接下来,我们禁用梯度计算(这会带来一些速度提升),将目标网络应用到下一个状态的观测中,并沿着相同行动维度(1)计算最大 Q 值:
with torch.no_grad():
next_state_values = tgt_net(new_states_t).max(1)[0]
函数 max() 返回最大值及其索引(因此它同时计算 max 和 argmax),这非常方便。然而,在这种情况下,我们只对值感兴趣,因此我们取结果中的第一个条目(最大值)。
以下是下一行:
next_state_values[dones_t] = 0.0
在这里,我们进行一个简单但非常重要的转换:如果批次中的过渡来自回合的最后一步,那么我们的动作值就没有下一个状态的折扣奖励,因为没有下一个状态可以获取奖励。这看起来可能是小事,但在实际中非常重要;没有这一点,训练将无法收敛(我个人花了几个小时调试这个问题)。
在下一行中,我们将值从其计算图中分离出来,以防止梯度流入用于计算下一个状态的 Q 近似值的神经网络:
next_state_values = next_state_values.detach()
这很重要,因为如果没有这个,我们的损失反向传播将开始影响当前状态和下一个状态的预测。然而,我们不想触及下一个状态的预测,因为它们在贝尔曼方程中用于计算参考 Q 值。为了阻止梯度流入图的这个分支,我们使用张量的 detach() 方法,该方法返回一个没有连接到计算历史的张量。
最后,我们计算贝尔曼近似值和均方误差损失:
expected_state_action_values = next_state_values * GAMMA + rewards_t
return nn.MSELoss()(state_action_values, expected_state_action_values)
为了全面了解损失函数计算代码,让我们完整查看这个函数:
def calc_loss(batch: tt.List[Experience], net: dqn_model.DQN, tgt_net: dqn_model.DQN,
device: torch.device) -> torch.Tensor:
states_t, actions_t, rewards_t, dones_t, new_states_t = batch_to_tensors(batch, device)
state_action_values = net(states_t).gather(
1, actions_t.unsqueeze(-1)
).squeeze(-1)
with torch.no_grad():
next_state_values = tgt_net(new_states_t).max(1)[0]
next_state_values[dones_t] = 0.0
next_state_values = next_state_values.detach()
expected_state_action_values = next_state_values * GAMMA + rewards_t
return nn.MSELoss()(state_action_values, expected_state_action_values)
这结束了我们的损失函数计算。
剩下的代码是我们的训练循环。首先,我们创建一个命令行参数解析器:
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dev", default="cpu", help="Device name, default=cpu")
parser.add_argument("--env", default=DEFAULT_ENV_NAME,
help="Name of the environment, default=" + DEFAULT_ENV_NAME)
args = parser.parse_args()
device = torch.device(args.dev)
我们的脚本允许我们指定一个用于计算的设备,并在与默认环境不同的环境中进行训练。
在这里,我们创建我们的环境:
env = wrappers.make_env(args.env)
net = dqn_model.DQN(env.observation_space.shape, env.action_space.n).to(device)
tgt_net = dqn_model.DQN(env.observation_space.shape, env.action_space.n).to(device)
我们的环境已经应用了所有必需的包装器,我们将训练的神经网络和具有相同架构的目标网络。最初,它们会用不同的随机权重初始化,但这并不重要,因为我们会每 1k 帧同步一次它们,这大致对应一个 Pong 回合。
然后,我们创建所需大小的经验回放缓冲区,并将其传递给智能体:
writer = SummaryWriter(comment="-" + args.env)
print(net)
buffer = ExperienceBuffer(REPLAY_SIZE)
agent = Agent(env, buffer)
epsilon = EPSILON_START
Epsilon 初始值为 1.0,但会在每次迭代时减小。以下是训练循环开始前我们所做的最后几件事:
optimizer = optim.Adam(net.parameters(), lr=LEARNING_RATE)
total_rewards = []
frame_idx = 0
ts_frame = 0
ts = time.time()
best_m_reward = None
我们创建了一个优化器、一个用于存储完整回合奖励的缓冲区、一个帧计数器和几个变量来跟踪我们的速度,以及达到的最佳平均奖励。每当我们的平均奖励突破记录时,我们会将模型保存到文件中。
在训练循环开始时,我们会计算完成的迭代次数,并根据我们的计划降低 epsilon 值:
while True:
frame_idx += 1
epsilon = max(EPSILON_FINAL, EPSILON_START - frame_idx / EPSILON_DECAY_LAST_FRAME)
Epsilon 会在给定的帧数(EPSILON_DECAY_LAST_FRAME=150k)内线性下降,然后保持在相同的水平,即 EPSILON_FINAL=0.01。
在这段代码中,我们让我们的智能体在环境中执行一步操作(使用当前的网络和 epsilon 的值):
reward = agent.play_step(net, device, epsilon)
if reward is not None:
total_rewards.append(reward)
speed = (frame_idx - ts_frame) / (time.time() - ts)
ts_frame = frame_idx
ts = time.time()
m_reward = np.mean(total_rewards[-100:])
print(f"{frame_idx}: done {len(total_rewards)} games, reward {m_reward:.3f}, "
f"eps {epsilon:.2f}, speed {speed:.2f} f/s")
writer.add_scalar("epsilon", epsilon, frame_idx)
writer.add_scalar("speed", speed, frame_idx)
writer.add_scalar("reward_100", m_reward, frame_idx)
writer.add_scalar("reward", reward, frame_idx)
该函数仅在此步骤为回合的最后一步时返回浮动值。在这种情况下,我们报告我们的进度。具体来说,我们计算并显示以下值,在控制台和 TensorBoard 中展示:
-
速度(每秒处理的帧数)
-
已进行的回合数
-
过去 100 个回合的平均奖励
-
当前 epsilon 的值
每当过去 100 个回合的平均奖励达到最大值时,我们会报告这一情况并保存模型参数:
if best_m_reward is None or best_m_reward < m_reward:
torch.save(net.state_dict(), args.env + "-best_%.0f.dat" % m_reward)
if best_m_reward is not None:
print(f"Best reward updated {best_m_reward:.3f} -> {m_reward:.3f}")
best_m_reward = m_reward
if m_reward > MEAN_REWARD_BOUND:
print("Solved in %d frames!" % frame_idx)
break
如果我们的平均奖励超过了指定的边界,则停止训练。对于 Pong,边界是 19.0,意味着从 21 场比赛中赢得超过 19 场。
在这里,我们检查我们的缓冲区是否足够大,能够进行训练:
if len(buffer) < REPLAY_START_SIZE:
continue
if frame_idx % SYNC_TARGET_FRAMES == 0:
tgt_net.load_state_dict(net.state_dict())
首先,我们应该等待足够的数据积累,在我们的案例中是 10k 次过渡。下一个条件是在每个 SYNC_TARGET_FRAMES(默认是 1k)周期后,从主网络同步参数到目标网络。
训练循环的最后一部分非常简单,但需要花费最多的时间来执行:
optimizer.zero_grad()
batch = buffer.sample(BATCH_SIZE)
loss_t = calc_loss(batch, net, tgt_net, device)
loss_t.backward()
optimizer.step()
在这里,我们将梯度归零,从经验重放缓冲区中采样数据批次,计算损失,并执行优化步骤以最小化损失。
运行与性能
这个例子对资源要求较高。在 Pong 上,它需要大约 400k 帧才能达到平均奖励 17(这意味着赢得超过 80% 的比赛)。为了从 17 提升到 19,类似数量的帧也会被消耗,因为我们的学习进度将饱和,模型很难“润色策略”并进一步提高分数。因此,平均来说,需要一百万个游戏帧来完全训练它。在 GTX 1080Ti 上,我的速度大约是 250 帧每秒,大约需要一个小时的训练。在 CPU(i5-7600k)上,速度要慢得多,大约是 40 帧每秒,训练将需要大约七小时。记住,这个是在 Pong 上,Pong 相对容易解决。其他游戏可能需要数亿帧和一个大 100 倍的经验重放缓冲区。
在第八章中,我们将探讨自 2015 年以来研究人员发现的各种方法,这些方法可以帮助提高训练速度和数据效率。第九章将专注于加速 RL 方法性能的工程技巧。尽管如此,针对 Atari,你仍然需要资源和耐心。以下图表展示了训练过程中奖励动态的变化:

图 6.4:计算过去 100 个回合的平均奖励动态
现在,让我们看一下训练过程中的控制台输出(这里只展示输出的开始部分):
Chapter06$ ./02_dqn_pong.py --dev cuda
A.L.E: Arcade Learning Environment (version 0.8.1+53f58b7)
[Powered by Stella]
DQN(
(conv): Sequential(
(0): Conv2d(4, 32, kernel_size=(8, 8), stride=(4, 4))
(1): ReLU()
(2): Conv2d(32, 64, kernel_size=(4, 4), stride=(2, 2))
(3): ReLU()
(4): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1))
(5): ReLU()
(6): Flatten(start_dim=1, end_dim=-1)
)
(fc): Sequential(
(0): Linear(in_features=3136, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=6, bias=True)
)
)
940: done 1 games, reward -21.000, eps 0.99, speed 1214.95 f/s
1946: done 2 games, reward -20.000, eps 0.99, speed 1420.09 f/s
Best reward updated -21.000 -> -20.000
2833: done 3 games, reward -20.000, eps 0.98, speed 1416.26 f/s
3701: done 4 games, reward -20.000, eps 0.98, speed 1421.84 f/s
4647: done 5 games, reward -20.200, eps 0.97, speed 1421.63 f/s
5409: done 6 games, reward -20.333, eps 0.96, speed 1395.67 f/s
6171: done 7 games, reward -20.429, eps 0.96, speed 1411.90 f/s
7063: done 8 games, reward -20.375, eps 0.95, speed 1404.49 f/s
7882: done 9 games, reward -20.444, eps 0.95, speed 1388.26 f/s
8783: done 10 games, reward -20.400, eps 0.94, speed 1283.64 f/s
9545: done 11 games, reward -20.455, eps 0.94, speed 1376.47 f/s
10307: done 12 games, reward -20.500, eps 0.93, speed 431.94 f/s
11362: done 13 games, reward -20.385, eps 0.92, speed 276.14 f/s
12420: done 14 games, reward -20.214, eps 0.92, speed 276.44 f/s
在前 10k 步中,我们的速度非常快,因为我们没有进行任何训练,而训练是我们代码中最昂贵的操作。10k 步后,我们开始采样训练批次,性能下降到更具代表性的数字。在训练过程中,性能也会略有下降,这只是因为𝜖的减小。当𝜖较高时,动作是随机选择的。当𝜖接近零时,我们需要执行推理来获得 Q 值以进行动作选择,这也会消耗时间。
几十场游戏之后,我们的 DQN 应该开始弄明白如何在 21 场比赛中赢得 1 到 2 场,并且平均奖励开始增长(通常在𝜖 = 0.5 时发生):
66024: done 68 games, reward -20.162, eps 0.56, speed 260.89 f/s
67338: done 69 games, reward -20.130, eps 0.55, speed 257.63 f/s
68440: done 70 games, reward -20.100, eps 0.54, speed 260.17 f/s
69467: done 71 games, reward -20.113, eps 0.54, speed 260.02 f/s
70792: done 72 games, reward -20.125, eps 0.53, speed 258.88 f/s
72031: done 73 games, reward -20.123, eps 0.52, speed 259.54 f/s
73314: done 74 games, reward -20.095, eps 0.51, speed 258.16 f/s
74815: done 75 games, reward -20.053, eps 0.50, speed 257.56 f/s
76339: done 76 games, reward -20.026, eps 0.49, speed 256.79 f/s
77576: done 77 games, reward -20.013, eps 0.48, speed 257.86 f/s
78978: done 78 games, reward -19.974, eps 0.47, speed 255.90 f/s
80093: done 79 games, reward -19.962, eps 0.47, speed 256.84 f/s
81565: done 80 games, reward -19.938, eps 0.46, speed 256.34 f/s
83365: done 81 games, reward -19.901, eps 0.44, speed 254.22 f/s
84841: done 82 games, reward -19.878, eps 0.43, speed 254.80 f/s
最后,经过更多的比赛,我们的 DQN 终于能够主宰并击败(不太复杂的)内置 Pong AI 对手:
737860: done 371 games, reward 18.540, eps 0.01, speed 225.22 f/s
739935: done 372 games, reward 18.650, eps 0.01, speed 232.70 f/s
Best reward updated 18.610 -> 18.650
741910: done 373 games, reward 18.650, eps 0.01, speed 231.66 f/s
743964: done 374 games, reward 18.760, eps 0.01, speed 231.59 f/s
Best reward updated 18.650 -> 18.760
745939: done 375 games, reward 18.770, eps 0.01, speed 223.45 f/s
Best reward updated 18.760 -> 18.770
747950: done 376 games, reward 18.810, eps 0.01, speed 229.84 f/s
Best reward updated 18.770 -> 18.810
749925: done 377 games, reward 18.810, eps 0.01, speed 228.05 f/s
752008: done 378 games, reward 18.910, eps 0.01, speed 225.41 f/s
Best reward updated 18.810 -> 18.910
753983: done 379 games, reward 18.920, eps 0.01, speed 229.75 f/s
Best reward updated 18.910 -> 18.920
755958: done 380 games, reward 19.030, eps 0.01, speed 228.71 f/s
Best reward updated 18.920 -> 19.030
Solved in 755958 frames!
由于训练过程中存在随机性,你的实际动态可能与这里展示的不同。在一些罕见的情况下(根据我的实验,大约 10 次运行中有 1 次),训练根本无法收敛,表现为长时间的奖励始终为−21。这在深度学习中并不罕见(由于训练的随机性),在强化学习中可能会更常见(由于环境交互的额外随机性)。如果你的训练在前 100k 到 200k 次迭代中没有任何正向动态,你应该重新开始训练。
你的模型在实践中的表现
训练过程只是整个过程的一部分。我们的最终目标不仅仅是训练模型;我们还希望我们的模型能以良好的结果来玩游戏。在训练过程中,每次更新过去 100 场游戏的平均奖励最大值时,我们都会将模型保存到文件 PongNoFrameskip-v4-best_
代码非常简单,但看到几个矩阵(仅有百万个参数)通过观察像素,能够以超人精度玩 Pong 游戏,简直像魔法一样。
首先,我们导入熟悉的 PyTorch 和 Gym 模块:
import gymnasium as gym
import argparse
import numpy as np
import typing as tt
import torch
from lib import wrappers
from lib import dqn_model
import collections
DEFAULT_ENV_NAME = "PongNoFrameskip-v4"
脚本接受已保存模型的文件名,并允许指定 Gym 环境(当然,模型和环境必须匹配):
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-m", "--model", required=True, help="Model file to load")
parser.add_argument("-e", "--env", default=DEFAULT_ENV_NAME,
help="Environment name to use, default=" + DEFAULT_ENV_NAME)
parser.add_argument("-r", "--record", required=True, help="Directory for video")
args = parser.parse_args()
此外,你还需要传递选项 -r,并指定一个不存在的目录名,系统将把你游戏的录像保存在该目录下。
以下代码也不太复杂:
env = wrappers.make_env(args.env, render_mode="rgb_array")
env = gym.wrappers.RecordVideo(env, video_folder=args.record)
net = dqn_model.DQN(env.observation_space.shape, env.action_space.n)
state = torch.load(args.model, map_location=lambda stg, _: stg)
net.load_state_dict(state)
state, _ = env.reset()
total_reward = 0.0
c: tt.Dict[int, int] = collections.Counter()
我们创建环境,将其包装在 RecordVideo 封装器中,创建模型,然后从传入的文件中加载权重。传递给 torch.load() 函数的 map_location 参数用于将加载的张量位置从 GPU 映射到 CPU。默认情况下,torch 尝试加载与保存时相同设备上的张量,但如果你将模型从用于训练的机器(有 GPU)复制到没有 GPU 的笔记本电脑上,就需要重新映射位置。我们的示例完全不使用 GPU,因为推理速度足够快,不需要加速。
这是几乎与训练代码中的 Agent 类的 play_step() 方法完全相同的代码,不包含 epsilon-greedy 动作选择:
while True:
state_v = torch.tensor([state])
q_vals = net(state_v).data.numpy()[0]
action = int(np.argmax(q_vals))
c[action] += 1
我们只需将观察结果传递给智能体,并选择具有最大值的动作。
剩余的代码也很简单:
state, reward, is_done, is_trunc, _ = env.step(action)
total_reward += reward
if is_done or is_trunc:
break
print("Total reward: %.2f" % total_reward)
print("Action counts:", c)
env.close()
我们将动作传递给环境,计算总奖励,并在回合结束时停止循环。回合结束后,我们显示总奖励和智能体执行动作的次数。
在这个 YouTube 播放列表中,你可以找到在训练不同阶段的游戏录像:www.youtube.com/playlist?list=PLMVwuZENsfJklt4vCltrWq0KV9aEZ3ylu。
尝试的事项
如果你感到好奇并想自己尝试本章的内容,以下是一些值得探索的方向。需要警告的是:这些实验可能会耗费大量时间,并且在实验过程中可能会让你感到挫折。然而,从实践角度看,这些实验是掌握材料的非常有效的方式:
-
尝试从 Atari 套件中选择其他游戏,例如《Breakout》、《Atlantis》或《River Raid》(我童年的最爱)。这可能需要调整超参数。
-
作为 FrozenLake 的替代方案,还有另一个表格环境,Taxi,它模拟一个出租车司机需要接送乘客并将其送到目的地的场景。
-
尝试调整 Pong 的超参数。是否有可能训练得更快?OpenAI 声称使用异步优势演员-评论家方法(本书第三部分的主题)可以在 30 分钟内解决 Pong 问题。也许用 DQN 也能做到。
-
你能让 DQN 训练代码更快吗?OpenAI Baselines 项目已经在 GTX 1080 Ti 上使用 TensorFlow 实现了 350 FPS。因此,优化 PyTorch 代码是可能的。我们将在第九章讨论这个话题,但同时你也可以进行自己的实验。
-
在视频录制中,你可能会注意到,平均分接近零的模型表现得相当不错。实际上,我有一种印象,平均分在 10-19 之间的模型表现得不如这些模型。这可能是因为模型对特定的游戏情况过拟合。你能尝试修复这个问题吗?也许可以使用生成对抗网络风格的方法,让一个模型与另一个模型对战?
-
你能通过一个平均得分为 21 的最终 Pong 主宰者模型吗?这应该不难——学习率衰减是一个显而易见的尝试方法。
总结
在这一章中,我们涉及了许多新的复杂内容。你已经了解了在具有大观测空间的复杂环境中,值迭代的局限性,并讨论了如何通过 Q 学习来克服这些局限性。我们在 FrozenLake 环境中验证了 Q 学习算法,并讨论了使用神经网络(NNs)对 Q 值进行逼近,以及由此逼近带来的额外复杂性。
我们介绍了多个针对深度 Q 网络(DQNs)的技巧,以提高它们的训练稳定性和收敛性,例如经验回放缓冲区、目标网络和帧堆叠。最后,我们将这些扩展整合到一个单一的 DQN 实现中,成功解决了 Atari 游戏套件中的 Pong 环境。
在下一章中,我们将简要了解一些更高级的强化学习(RL)库,之后,我们将回顾一组自 2015 年以来研究人员发现的技巧,用以改善 DQN 的收敛性和质量,这些技巧(综合起来)可以在大多数 54 款(新添加的)Atari 游戏上实现最先进的成果。这些技巧在 2017 年发布,我们将分析并重新实现所有这些技巧。
第七章:高级 RL 库
在第六章中,我们实现了由 DeepMind 于 2015 年发布的深度 Q 网络(DQN)模型[Mni+15]。这篇论文通过证明尽管普遍认为不可能,但在 RL 中使用非线性近似器是可行的,极大地影响了 RL 领域。这一概念验证激发了深度 Q 学习领域和深度 RL 一般领域的极大兴趣。
在本章中,我们将通过讨论高级 RL 库迈向实际应用的 RL,这将使你能够从更高级的模块构建代码,专注于你正在实现方法的细节,避免重复实现相同的逻辑。本章大部分内容将介绍 PyTorch AgentNet(PTAN)库,它将在本书的其余部分中使用,以防止代码重复,因此会进行详细介绍。
我们将涵盖以下内容:
-
使用高级库的动机,而不是从头重新实现所有内容
-
PTAN 库,包括最重要部分的覆盖,代码示例将加以说明
-
在 CartPole 上实现的 DQN,使用 PTAN 库
-
你可能考虑的其他 RL 库
为什么选择 RL 库?
我们在第六章中实现的基本 DQN 并不长且复杂——大约 200 行训练代码,加上 50 行环境包装代码。当你开始熟悉 RL 方法时,自己实现所有内容非常有用,可以帮助你理解事物是如何运作的。然而,随着你在该领域的深入,你会越来越频繁地意识到自己在一遍又一遍地编写相同的代码。
这种重复来自 RL 方法的通用性。正如我们在第一章中讨论的,RL 非常灵活,许多现实问题都可以归结为环境-代理互动模型。RL 方法对观察和动作的具体内容不做过多假设,因此为 CartPole 环境编写的代码可以适用于 Atari 游戏(可能需要做一些小的调整)。
一遍又一遍地编写相同的代码效率不高,因为每次可能都会引入 bug,这将花费你大量的调试和理解时间。此外,经过精心设计并在多个项目中使用的代码通常在性能、单元测试、可读性和文档方面具有更高的质量。
相较于其他更加成熟的领域,RL 的实际应用在计算机科学标准下相对较年轻,因此你可能没有那么丰富的选择。例如,在 Web 开发中,即便你只使用 Python,你也有数百个非常优秀的库可供选择:Django 用于重型、功能齐全的网站;Flask 用于轻量级的 Web 服务器网关接口(WSGI)应用;还有许多其他大小不一的库。
强化学习(RL)不像 Web 框架那样成熟,但你仍然可以从几个简化强化学习实践者生活的项目中进行选择。此外,你始终可以像我几年前那样编写一套自己的工具。我创建的工具是一个名为 PTAN 的库,正如前面所提到的,它将在本书的后续部分中用于演示实例。
PTAN 库
该库可以在 GitHub 上找到:github.com/Shmuma/ptan。所有后续示例都是使用 PTAN 0.8 版本实现的,可以通过以下命令在虚拟环境中安装:
$ pip install ptan==0.8
PTAN 的最初目标是简化我的强化学习实验,它试图在两种极端之间找到平衡:
-
导入库后,只需写几行包含大量参数的代码,就可以训练提供的某个方法,例如 DQN(一个非常生动的例子是 OpenAI Baselines 和 Stable Baselines3 项目)。这种方法非常不灵活。当你按照库的预期使用时,它能很好地工作。但如果你想做一些复杂的操作,很快你就会发现自己在破解库并与其施加的约束作斗争,而不是解决你想解决的问题。
-
从头开始实现所有方法的逻辑。第二种极端方式提供了过多的自由度,需要一遍又一遍地实现重放缓冲区和轨迹处理,这既容易出错,又乏味且低效。
PTAN 尝试在这两种极端之间找到平衡,提供高质量的构建块来简化你的强化学习代码,同时保持灵活性,不限制你的创造力。
从高层次来看,PTAN 提供了以下实体:
-
Agent:一个知道如何将一批观察转化为一批待执行动作的类。它可以包含一个可选的状态,以便在一个回合内跟踪连续动作之间的信息。(我们将在第十五章的深度确定性策略梯度(DDPG)方法中使用这种方法,其中包括用于探索的 Ornstein–Uhlenbeck 随机过程。)该库为最常见的强化学习案例提供了多个代理,但如果没有预定义的类能满足你的需求,你始终可以编写自己的 BaseAgent 子类。
-
ActionSelector:一小段逻辑,知道如何从网络的某些输出中选择动作。它与 Agent 类协同工作。
-
ExperienceSource 及其子类:Agent 实例和 Gym 环境对象可以提供有关代理在回合中轨迹的信息。它的最简单形式是一次性提供一个(a, r, s′)过渡,但它的功能不仅限于此。
-
ExperienceSourceBuffer 及其子类:具有各种特征的重放缓冲区。它们包括一个简单的重放缓冲区和两个版本的优先级重放缓冲区。
-
各种实用工具类:例如,TargetNet 和用于时间序列预处理的包装器(用于在 TensorBoard 中跟踪训练进度)。
-
PyTorch Ignite 助手:可以用来将 PTAN 集成到 Ignite 框架中。
-
Gym 环境的包装器:例如,针对 Atari 游戏的包装器(与我们在第六章中描述的包装器非常相似)。
基本上就是这样。在接下来的章节中,我们将详细了解这些实体。
动作选择器
在 PTAN 术语中,动作选择器是一个帮助从网络输出到具体动作值的对象。最常见的情况包括:
-
贪婪(或 argmax):Q 值方法常用的,当网络为一组动作预测 Q 值时,所需的动作是具有最大 Q(s,a)的动作。
-
基于策略:网络输出概率分布(以 logits 或归一化分布的形式),需要从该分布中采样一个动作。你在第四章讨论交叉熵方法时已经看过这个。
动作选择器由代理使用,通常不需要自定义(但你有这个选项)。库提供的具体类包括:
-
ArgmaxActionSelector:在传入张量的第二个维度上应用 argmax。它假设矩阵的第一维是 batch 维度。
-
ProbabilityActionSelector:从离散动作集的概率分布中采样。
-
EpsilonGreedyActionSelector:具有 epsilon 参数,指定随机动作被执行的概率。它还持有另一个 ActionSelector 实例,当我们不采样随机动作时使用它。
所有类假设会将 NumPy 数组传递给它们。此章节的完整示例可以在 Chapter07/01_actions.py 中找到。这里,我将向你展示如何使用这些类:
>>> import numpy as np
>>> import ptan
>>> q_vals = np.array([[1, 2, 3], [1, -1, 0]])
>>> q_vals
array([[ 1, 2, 3],
[ 1, -1, 0]])
>>> selector = ptan.actions.ArgmaxActionSelector()
>>> selector(q_vals)
array([2, 0])
正如你所看到的,选择器返回具有最大值的动作的索引。
下一个动作选择器是 EpisilonGreedyActionSelector,它“包装”另一个动作选择器,并根据 epsilon 参数,使用包装的动作选择器或采取随机动作。这个动作选择器在训练过程中用于为代理的动作引入随机性。如果 epsilon 是 0.0,则不会采取随机动作:
>>> selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=0.0, selector=ptan.actions.ArgmaxActionSelector())
>>> selector(q_vals)
array([2, 0])
如果我们将 epsilon 更改为 1,动作将变为随机:
>>> selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=1.0)
>>> selector(q_vals)
array([0, 1])
你还可以通过为动作选择器的属性赋值来更改 epsilon 的值:
>>> selector.epsilon
1.0
>>> selector.epsilon = 0.0
>>> selector(q_vals)
array([2, 0])
使用 ProbabilityActionSelector 的方法是一样的,但输入需要是一个归一化的概率分布:
>>> selector = ptan.actions.ProbabilityActionSelector()
>>> for _ in range(10):
... acts = selector(np.array([
... [0.1, 0.8, 0.1],
... [0.0, 0.0, 1.0],
... [0.5, 0.5, 0.0]
... ]))
... print(acts)
...
[0 2 1]
[1 2 1]
[1 2 1]
[0 2 1]
[2 2 0]
[0 2 0]
[1 2 1]
[1 2 0]
[1 2 1]
[1 2 0]
在前面的示例中,我们从三个分布中进行采样(因为我们在传入的矩阵中有三行):
-
第一个由向量[0.1, 0.8, 0.1]定义;因此,索引为 1 的动作以 80%的概率被选中
-
向量[0.0, 0.0, 1.0]总是给我们动作 2 的索引
-
分布[0.5, 0.5, 0.0]以 50%的几率产生动作 0 和动作 1
代理
智能体实体提供了一种统一的方式,将来自环境的观察与我们想要执行的动作连接起来。到目前为止,你只看到了一个简单的无状态 DQN 智能体,该智能体使用神经网络(NN)从当前观察中获取动作值,并根据这些值贪婪地做出决策。我们使用 epsilon-贪婪策略来探索环境,但这并没有显著改变局面。
在强化学习(RL)领域,这可能会更加复杂。例如,我们的智能体可能不是预测动作的值,而是预测动作上的概率分布。这样的智能体被称为策略智能体,我们将在本书的第三部分讨论这些方法。
在某些情况下,智能体可能需要在观察之间保持状态。例如,通常情况下,单一的观察(甚至是最近的 k 个观察)不足以做出关于动作的决策,我们希望在智能体中保留一些记忆,以捕捉必要的信息。强化学习中有一个子领域试图通过部分可观察马尔可夫决策过程(POMDP)来解决这个问题,我们在第六章中简要提到过,但在本书中没有广泛覆盖。
智能体的第三种变体在连续控制问题中非常常见,这将在本书的第四部分讨论。目前,只需说在这种情况下,动作不再是离散的,而是连续的值,智能体需要根据观察来预测这些值。
为了捕捉所有这些变体并使代码具有灵活性,PTAN 中的智能体是通过一个可扩展的类层次结构实现的,ptan.agent.BaseAgent 抽象类位于顶部。从高层来看,智能体需要接受一批观察(以 NumPy 数组或 NumPy 数组列表的形式),并返回它想要执行的动作批次。批次的使用可以使处理更加高效,因为在图形处理单元(GPU)中一次性处理多个观察通常比逐个处理更快。
抽象基类没有定义输入和输出的类型,这使得它非常灵活且易于扩展。例如,在连续域中,我们的动作不再是离散动作的索引,而是浮动值。在任何情况下,智能体可以被视为一种知道如何将观察转换为动作的实体,如何做到这一点由智能体决定。通常,对于观察和动作类型没有假设,但智能体的具体实现则更具限制性。PTAN 提供了两种将观察转换为动作的常见方法:DQNAgent 和 PolicyAgent。我们将在后续章节中探讨这些方法。
然而,在实际问题中,通常需要定制的智能体。这些是一些原因:
-
神经网络的架构很复杂——它的动作空间是连续和离散的混合,并且它有多模态的观察(例如文本和像素),或者类似的东西。
-
你想使用非标准的探索策略,例如 Ornstein–Uhlenbeck 过程(在连续控制领域中是一种非常流行的探索策略)。
-
你有一个 POMDP 环境,智能体的决策不仅仅由观察定义,还由某些内部状态(这对于 Ornstein–Uhlenbeck 探索也是如此)决定。
所有这些情况都可以通过子类化 BaseAgent 类轻松支持,书中的后续部分将给出几个这样的重定义示例。
现在,让我们看看库中提供的标准智能体:DQNAgent 和 PolicyAgent。完整示例在 Chapter07/02_agents.py 中。
DQNAgent
这个类适用于 Q-learning,当动作空间不是很大的时候,涵盖了 Atari 游戏和许多经典问题。这个表示方式不是普适的,后面书中会介绍如何处理这种情况。DQNAgent 接收一批观察数据作为输入(作为 NumPy 数组),将网络应用到这些数据上以获得 Q 值,然后使用提供的 ActionSelector 将 Q 值转换为动作的索引。
让我们考虑一个简单的例子。为了简化起见,我们的网络始终为输入批次产生相同的输出。
首先,我们定义神经网络类,它应该将观察转换为动作。在我们的例子中,它根本不使用神经网络,并始终产生相同的输出:
class DQNNet(nn.Module):
def __init__(self, actions: int):
super(DQNNet, self).__init__()
self.actions = actions
def forward(self, x):
# we always produce diagonal tensor of shape
# (batch_size, actions)
return torch.eye(x.size()[0], self.actions)
一旦我们定义了模型类,就可以将其用作 DQN 模型:
>>> net = DQNNet(actions=3)
>>> net(torch.zeros(2, 10))
tensor([[1., 0., 0.],
[0., 1., 0.]])
我们从简单的 argmax 策略开始(该策略返回值最大的动作),因此智能体将始终返回与网络输出中对应的动作:
>>> selector = ptan.actions.ArgmaxActionSelector()
>>> agent = ptan.agent.DQNAgent(model=net, action_selector=selector)
>>> agent(torch.zeros(2, 5))
(array([0, 1]), [None, None])
在输入中,给定了一批两条观察数据,每条包含五个值;在输出中,智能体返回了一个包含两个对象的元组:
-
一个数组,表示我们批次中要执行的动作。在我们的例子中,对于第一批样本是动作 0,第二批样本是动作 1。
-
一个包含智能体内部状态的列表。对于有状态的智能体,这个列表很有用,而在我们的例子中,它是一个包含 None 的列表。由于我们的智能体是无状态的,可以忽略它。
现在,让我们使智能体具备 epsilon-greedy 探索策略。为此,我们只需要传递一个不同的动作选择器:
>>> selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=1.0)
>>> agent = ptan.agent.DQNAgent(model=net, action_selector=selector)
>>> agent(torch.zeros(10, 5))[0]
array([2, 0, 0, 0, 1, 2, 1, 2, 2, 1])
由于 epsilon 为 1.0,所有动作都会是随机的,与网络的输出无关。但是我们可以在训练过程中动态地更改 epsilon 的值,这在逐步降低 epsilon 时非常方便:
>>> selector.epsilon = 0.5
>>> agent(torch.zeros(10, 5))[0]
array([0, 1, 2, 2, 0, 0, 1, 2, 0, 2])
>>> selector.epsilon = 0.1
>>> agent(torch.zeros(10, 5))[0]
array([0, 1, 2, 0, 0, 0, 0, 0, 0, 0])
PolicyAgent
PolicyAgent 期望网络为离散的动作集生成一个策略分布。策略分布可以是 logits(未归一化的)或者归一化的分布。实际上,你应该始终使用 logits 以提高训练过程的数值稳定性。
让我们重新实现之前的例子,但这次网络将产生一个概率值。
我们首先定义以下类:
class PolicyNet(nn.Module):
def __init__(self, actions: int):
super(PolicyNet, self).__init__()
self.actions = actions
def forward(self, x):
# Now we produce the tensor with first two actions
# having the same logit scores
shape = (x.size()[0], self.actions)
res = torch.zeros(shape, dtype=torch.float32)
res[:, 0] = 1
res[:, 1] = 1
return res
上述类可用于获取一批观察的动作对数:
>>> net = PolicyNet(actions=5)
>>> net(torch.zeros(6, 10))
tensor([[1., 1., 0., 0., 0.],
[1., 1., 0., 0., 0.],
[1., 1., 0., 0., 0.],
[1., 1., 0., 0., 0.],
[1., 1., 0., 0., 0.],
[1., 1., 0., 0., 0.]])
现在,我们可以将 PolicyAgent 与 ProbabilityActionSelector 结合使用。由于后者期望的是归一化的概率,我们需要要求 PolicyAgent 对网络的输出应用 softmax:
>>> selector = ptan.actions.ProbabilityActionSelector()
>>> agent = ptan.agent.PolicyAgent(model=net, action_selector=selector, apply_softmax=True)
>>> agent(torch.zeros(6, 5))[0]
array([2, 1, 2, 0, 2, 3])
请注意,softmax 操作会对零对数值产生非零概率,因此我们的代理仍然可以选择具有零对数值的动作。
>>> torch.nn.functional.softmax(torch.tensor([1., 1., 0., 0., 0.]))
tensor([0.3222, 0.3222, 0.1185, 0.1185, 0.1185])
经验源
前一节中描述的代理抽象使我们能够以通用的方式实现环境通信。这些通信通过应用代理的动作到 Gym 环境中,形成轨迹。
从高层次来看,经验源类获取代理实例和环境,并为你提供来自轨迹的逐步数据。这些类的功能包括:
-
支持同时与多个环境进行通信。这使得在一次处理一批观察时能够有效利用 GPU。
-
轨迹可以预处理并以方便的形式呈现以供进一步训练。例如,有一种实现子轨迹回滚并累积奖励的方式。对于 DQN 和 n 步 DQN 来说,这种预处理非常方便,因为我们不关心子轨迹中的个别中间步骤,所以可以省略它们。这样可以节省内存,并减少我们需要编写的代码量。
-
支持来自 Gymnasium 的向量化环境(AsyncVectorEnv 和 SyncVectorEnv 类)。我们将在第十七章讨论这个话题。
因此,经验源类充当了一个“魔法黑箱”,隐藏了环境交互和轨迹处理的复杂性,让库用户不必处理这些问题。但整体的 PTAN 哲学是灵活和可扩展的,所以如果你愿意,你可以子类化现有的类或根据需要实现自己的版本。
系统提供了三个类:
-
ExperienceSource:通过使用代理和环境集,它生成包含所有中间步骤的 n 步子轨迹。
-
ExperienceSourceFirstLast:这与 ExperienceSource 相同,但它仅保留第一步和最后一步的子轨迹,并在两者之间进行适当的奖励累积。对于 n 步 DQN 或优势演员-评论家(A2C)回滚,这可以节省大量内存。
-
ExperienceSourceRollouts:这遵循 Mnih 在关于 Atari 游戏的论文中描述的异步优势演员-评论家(A3C)回滚方案(我们将在第十二章讨论这个话题)。
所有的类都被编写得既高效地使用中央处理单元(CPU),也高效地使用内存,这对于玩具问题来说并不重要,但在下一章当我们进入 Atari 游戏时,涉及到需要存储和处理大量数据的问题,这一点就显得非常重要。
玩具环境
为了演示,我们将实现一个非常简单的 Gym 环境,具有一个小而可预测的观察状态,来展示ExperienceSource类如何工作。这个环境的观察值是整数,从 0 到 4,动作也是整数,奖励等于给定的动作。环境产生的所有回合总是有 10 个步骤:
class ToyEnv(gym.Env):
def __init__(self):
super(ToyEnv, self).__init__()
self.observation_space = gym.spaces.Discrete(n=5)
self.action_space = gym.spaces.Discrete(n=3)
self.step_index = 0
def reset(self):
self.step_index = 0
return self.step_index, {}
def step(self, action: int):
is_done = self.step_index == 10
if is_done:
return self.step_index % self.observation_space.n, 0.0, is_done, False, {}
self.step_index += 1
return self.step_index % self.observation_space.n, float(action), \
self.step_index == 10, False, {}
除了这个环境,我们还将使用一个代理,它会根据观察结果始终生成固定的动作:
class DullAgent(ptan.agent.BaseAgent):
def __init__(self, action: int):
self.action = action
def __call__(self, observations: tt.List[int], state: tt.Optional[list] = None) -> \
tt.Tuple[tt.List[int], tt.Optional[list]]:
return [self.action for _ in observations], state
这两个类都定义在Chapter07/lib.py模块中。现在我们已经定义了代理,接下来我们讨论它产生的数据。
ExperienceSource类
我们将讨论的第一个类是ptan.experience.ExperienceSource,它生成给定长度的代理轨迹片段。实现会自动处理回合结束的情况(即环境中的step()方法返回is_done=True),并重置环境。构造函数接受多个参数:
-
将要使用的 Gym 环境。或者,也可以是环境列表。
-
代理实例。
-
steps_count=2:要生成的子轨迹的长度。
该类实例提供标准的 Python 迭代器接口,因此你可以直接迭代它以获取子轨迹:
>>> from lib import *
>>> env = ToyEnv()
>>> agent = DullAgent(action=1)
>>> exp_source = ptan.experience.ExperienceSource(env=env, agent=agent, steps_count=2)
>>> for idx, exp in zip(range(3), exp_source):
... print(exp)
...
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1, reward=1.0, done_trunc=False))
(Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False))
(Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False))
在每次迭代中,ExperienceSource返回代理在与环境交互时的一段轨迹。它看起来可能很简单,但我们的示例背后有几件事在发生:
-
调用了环境中的
reset()以获取初始状态。 -
代理被要求从返回的状态中选择要执行的动作。
-
调用
step()方法以获得奖励和下一个状态。 -
这个下一个状态被传递给代理,以供其执行下一个动作。
-
返回了从一个状态到下一个状态的转移信息。
-
如果环境返回回合结束标志,我们就会输出剩余的轨迹并重置环境以重新开始。
-
在对经验源的迭代过程中,过程继续(从第 3 步开始)。
如果代理改变了它生成动作的方式(我们可以通过更新网络权重、减少 epsilon 或其他方法来实现),它将立即影响我们获得的经验轨迹。
ExperienceSource实例返回的元组的长度等于或小于构造时传入的step_count参数。在我们的例子中,我们要求的是两个步骤的子轨迹,因此元组的长度为 2 或 1(在回合结束时)。元组中的每个对象都是ptan.experience.Experience类的实例,这是一个包含以下字段的数据类:
-
state:我们在采取行动前观察到的状态 -
action:我们完成的动作 -
reward:我们从环境中获得的即时奖励 -
done_trunc:回合是否结束或被截断
如果回合结束,子轨迹将会更短,且底层环境会自动重置,因此我们无需担心这个问题,可以继续迭代:
>>> for idx, exp in zip(range(15), exp_source):
... print(exp)
...
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1, reward=1.0, done_trunc=False))
.......
(Experience(state=3, action=1, reward=1.0, done_trunc=False), Experience(state=4, action=1, reward=1.0, done_trunc=True))
(Experience(state=4, action=1, reward=1.0, done_trunc=True),)
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1, reward=1.0, done_trunc=False))
(Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False))
我们可以向 ExperienceSource 请求任意长度的子轨迹:
>>> exp_source = ptan.experience.ExperienceSource(env=env, agent=agent, steps_count=4)
>>> next(iter(exp_source))
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False))
我们可以传递多个 gym.Env 实例。在这种情况下,它们将按轮流方式使用:
>>> exp_source = ptan.experience.ExperienceSource(env=[ToyEnv(), ToyEnv()], agent=agent, steps_count=4)
>>> for idx, exp in zip(range(5), exp_source):
... print(exp)
...
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False))
(Experience(state=0, action=1, reward=1.0, done_trunc=False), Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False))
(Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False), Experience(state=4, action=1, reward=1.0, done_trunc=False))
(Experience(state=1, action=1, reward=1.0, done_trunc=False), Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False), Experience(state=4, action=1, reward=1.0, done_trunc=False))
(Experience(state=2, action=1, reward=1.0, done_trunc=False), Experience(state=3, action=1, reward=1.0, done_trunc=False), Experience(state=4, action=1, reward=1.0, done_trunc=False), Experience(state=0, action=1, reward=1.0, done_trunc=False))
请注意,当你将多个环境传递给 ExperienceSource 时,它们必须是独立的实例,而不是单一环境实例,否则你的观察将变得混乱。
ExperienceSourceFirstLast 类
ExperienceSource 类为我们提供了指定长度的完整子轨迹,作为 (s, a, r) 对象的列表。下一个状态 s′ 会在下一个元组中返回,这有时不太方便。例如,在 DQN 训练中,我们希望一次性获得 (s, a, r, s′) 元组,以便在训练过程中进行一步 Bellman 近似。此外,DQN 的一些扩展,如 n 步 DQN,可能希望将更长的观察序列合并为 (first-state, action, total-reward-for-n-steps, state-after-step-n)。
为了以通用的方式支持这一点,已经实现了一个 ExperienceSource 的简单子类:ExperienceSourceFirstLast。它在构造函数中接受几乎相同的参数,但返回不同的数据:
>>> exp_source = ptan.experience.ExperienceSourceFirstLast(env, agent, gamma=1.0, steps_count=1)
>>> for idx, exp in zip(range(11), exp_source):
... print(exp)
...
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
ExperienceFirstLast(state=1, action=1, reward=1.0, last_state=2)
ExperienceFirstLast(state=2, action=1, reward=1.0, last_state=3)
ExperienceFirstLast(state=3, action=1, reward=1.0, last_state=4)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=0)
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
ExperienceFirstLast(state=1, action=1, reward=1.0, last_state=2)
ExperienceFirstLast(state=2, action=1, reward=1.0, last_state=3)
ExperienceFirstLast(state=3, action=1, reward=1.0, last_state=4)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=None)
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
现在,它不再返回元组,而是每次迭代返回一个单一的对象,这个对象也是一个数据类,包含以下字段:
-
state: 我们用来决定采取什么动作的状态。
-
action: 我们在这一步骤采取的动作。
-
reward: 对于 steps_count(在我们的案例中,steps_count=1,因此它等于即时奖励)的部分累计奖励。
-
last_state: 执行动作后得到的状态。如果我们的回合结束,这里是 None。
这些数据对于 DQN 训练更为方便,因为我们可以直接应用 Bellman 近似。
让我们检查一下使用更多步数时的结果:
>>> exp_source = ptan.experience.ExperienceSourceFirstLast(env, agent, gamma=1.0, steps_count=2)
>>> for idx, exp in zip(range(11), exp_source):
... print(exp)
...
ExperienceFirstLast(state=0, action=1, reward=2.0, last_state=2)
ExperienceFirstLast(state=1, action=1, reward=2.0, last_state=3)
ExperienceFirstLast(state=2, action=1, reward=2.0, last_state=4)
ExperienceFirstLast(state=3, action=1, reward=2.0, last_state=0)
ExperienceFirstLast(state=4, action=1, reward=2.0, last_state=1)
ExperienceFirstLast(state=0, action=1, reward=2.0, last_state=2)
ExperienceFirstLast(state=1, action=1, reward=2.0, last_state=3)
ExperienceFirstLast(state=2, action=1, reward=2.0, last_state=4)
ExperienceFirstLast(state=3, action=1, reward=2.0, last_state=None)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=None)
ExperienceFirstLast(state=0, action=1, reward=2.0, last_state=2)
所以,现在我们在每次迭代中合并了两步,并计算即时奖励(这就是为什么大多数样本的 reward=2.0)。回合结束时更有趣的样本:
ExperienceFirstLast(state=3, action=1, reward=2.0, last_state=None)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=None)
当回合结束时,我们在这些样本中将 last_state=None,但此外,我们会计算回合尾部的奖励。如果你自己处理所有的轨迹,这些细节非常容易出错。
经验回放缓存
在 DQN 中,我们很少处理即时经验样本,因为它们高度相关,这会导致训练的不稳定。通常,我们有一个大的回放缓存,它充满了经验片段。然后从缓存中进行采样(随机或带优先级权重),以获取训练批次。回放缓存通常有一个最大容量,因此当回放缓存达到上限时,旧样本会被推送出去。
这里有几个实现技巧,当你需要处理大型问题时,这些技巧非常重要:
-
如何高效地从大缓存中采样
-
如何从缓存中推送旧样本
-
在优先级缓存的情况下,如何以最有效的方式维护和处理优先级
如果你想处理 Atari 游戏,保持 10-100M 样本,其中每个样本都是游戏中的一张图片,这一切就变成了一项相当复杂的任务。一个小错误可能导致 10-100 倍的内存增加,并且会严重拖慢训练过程。
PTAN 提供了几种重放缓冲区的变体,它们可以与 ExperienceSource 和 Agent 架构轻松集成。通常,您需要做的是请求缓冲区从源中提取一个新样本并采样训练批次。提供的类包括:
-
ExperienceReplayBuffer:一个简单的、大小预定义的重放缓冲区,采用均匀采样。
-
PrioReplayBufferNaive:一种简单但效率不高的优先级重放缓冲区实现。采样复杂度为 O(n),对于大缓冲区来说可能成为一个问题。这个版本相比优化后的类,代码更简单。对于中等大小的缓冲区,性能仍然可以接受,因此我们会在一些示例中使用它。
-
PrioritizedReplayBuffer:使用线段树进行采样,这使得代码变得晦涩,但采样复杂度为 O(log(n))。
以下展示了如何使用重放缓冲区:
>>> exp_source = ptan.experience.ExperienceSourceFirstLast(env, agent, gamma=1.0, steps_count=1)
>>> buffer = ptan.experience.ExperienceReplayBuffer(exp_source, buffer_size=100)
>>> len(buffer)
0
>>> buffer.populate(1)
>>> len(buffer)
1
所有重放缓冲区提供以下接口:
-
一个 Python 迭代器接口,用于遍历缓冲区中的所有样本。
-
populate(N) 方法用于从经验源中获取 N 个样本并将它们放入缓冲区。
-
方法 sample(N) 用于获取 N 个经验对象的批次。
因此,DQN 的正常训练循环看起来像是以下步骤的无限重复:
-
调用 buffer.populate(1) 从环境中获取一个新样本。
-
调用 batch = buffer.sample(BATCH_SIZE) 从缓冲区中获取批次。
-
计算所采样批次的损失。
-
反向传播。
-
重复直到收敛(希望如此)。
其余的过程自动完成——重置环境、处理子轨迹、维护缓冲区大小等:
>>> for step in range(6):
... buffer.populate(1)
... if len(buffer) < 5:
... continue
... batch = buffer.sample(4)
... print(f"Train time, {len(batch)} batch samples")
... for s in batch:
... print(s)
...
Train time, 4 batch samples
ExperienceFirstLast(state=1, action=1, reward=1.0, last_state=2)
ExperienceFirstLast(state=2, action=1, reward=1.0, last_state=3)
ExperienceFirstLast(state=2, action=1, reward=1.0, last_state=3)
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
Train time, 4 batch samples
ExperienceFirstLast(state=0, action=1, reward=1.0, last_state=1)
ExperienceFirstLast(state=4, action=1, reward=1.0, last_state=0)
ExperienceFirstLast(state=3, action=1, reward=1.0, last_state=4)
ExperienceFirstLast(state=3, action=1, reward=1.0, last_state=4)
TargetNet 类
我们在前一章中提到过自举问题,当用于下一个状态评估的网络受到我们训练过程的影响时,这一问题会出现。通过将当前训练的网络与用于预测下一个状态 Q 值的网络分离,解决了这个问题。
TargetNet 是一个小巧但有用的类,允许我们同步两个相同架构的神经网络。此类支持两种同步模式:
-
sync():源网络的权重被复制到目标网络中。
-
alpha_sync():源网络的权重通过一个 alpha 权重(介于 0 和 1 之间)融合到目标网络中。
第一个模式是执行离散动作空间问题(如 Atari 和 CartPole)中的目标网络同步的标准方法,正如我们在第六章中所做的那样。后者模式用于连续控制问题,这将在本书第四部分中描述。在此类问题中,两个网络参数之间的过渡应是平滑的,因此使用了 alpha 混合,公式为 w[i] = w[i]α + si,其中 w[i] 是目标网络的第 i 个参数,s[i] 是源网络的权重。以下是如何在代码中使用 TargetNet 的一个小示例。假设我们有以下网络:
class DQNNet(nn.Module):
def __init__(self):
super(DQNNet, self).__init__()
self.ff = nn.Linear(5, 3)
def forward(self, x):
return self.ff(x)
目标网络可以通过以下方式创建:
>>> net = DQNNet()
>>> net
DQNNet(
(ff): Linear(in_features=5, out_features=3, bias=True)
)
>>> tgt_net = ptan.agent.TargetNet(net)
目标网络包含两个字段:model,它是对原始网络的引用;target_model,它是原始网络的深拷贝。如果我们检查这两个网络的权重,它们将是相同的:
>>> net.ff.weight
Parameter containing:
tensor([[ 0.2039, 0.1487, 0.4420, -0.0210, -0.2726],
[-0.2020, -0.0787, 0.2852, -0.1565, 0.4012],
[-0.0569, -0.4184, -0.3658, 0.4212, 0.3647]], requires_grad=True)
>>> tgt_net.target_model.ff.weight
Parameter containing:
tensor([[ 0.2039, 0.1487, 0.4420, -0.0210, -0.2726],
[-0.2020, -0.0787, 0.2852, -0.1565, 0.4012],
[-0.0569, -0.4184, -0.3658, 0.4212, 0.3647]], requires_grad=True)
它们相互独立,然而,仅仅有相同的架构:
>>> net.ff.weight.data += 1.0
>>> net.ff.weight
Parameter containing:
tensor([[1.2039, 1.1487, 1.4420, 0.9790, 0.7274],
[0.7980, 0.9213, 1.2852, 0.8435, 1.4012],
[0.9431, 0.5816, 0.6342, 1.4212, 1.3647]], requires_grad=True)
>>> tgt_net.target_model.ff.weight
Parameter containing:
tensor([[ 0.2039, 0.1487, 0.4420, -0.0210, -0.2726],
[-0.2020, -0.0787, 0.2852, -0.1565, 0.4012],
[-0.0569, -0.4184, -0.3658, 0.4212, 0.3647]], requires_grad=True)
要再次同步它们,可以使用 sync() 方法:
>>> tgt_net.sync()
>>> tgt_net.target_model.ff.weight
Parameter containing:
tensor([[1.2039, 1.1487, 1.4420, 0.9790, 0.7274],
[0.7980, 0.9213, 1.2852, 0.8435, 1.4012],
[0.9431, 0.5816, 0.6342, 1.4212, 1.3647]], requires_grad=True)
对于混合同步,你可以使用 alpha_sync() 方法:
>>> net.ff.weight.data += 1.0
>>> net.ff.weight
Parameter containing:
tensor([[2.2039, 2.1487, 2.4420, 1.9790, 1.7274],
[1.7980, 1.9213, 2.2852, 1.8435, 2.4012],
[1.9431, 1.5816, 1.6342, 2.4212, 2.3647]], requires_grad=True)
>>> tgt_net.target_model.ff.weight
Parameter containing:
tensor([[1.2039, 1.1487, 1.4420, 0.9790, 0.7274],
[0.7980, 0.9213, 1.2852, 0.8435, 1.4012],
[0.9431, 0.5816, 0.6342, 1.4212, 1.3647]], requires_grad=True)
>>> tgt_net.alpha_sync(0.1)
>>> tgt_net.target_model.ff.weight
Parameter containing:
tensor([[2.1039, 2.0487, 2.3420, 1.8790, 1.6274],
[1.6980, 1.8213, 2.1852, 1.7435, 2.3012],
[1.8431, 1.4816, 1.5342, 2.3212, 2.2647]], requires_grad=True)
Ignite 辅助工具
PyTorch Ignite 在第三章中简要讨论过,之后在本书的其余部分将用于减少训练循环代码的量。PTAN 提供了几个小的辅助工具,以简化与 Ignite 的集成,这些工具位于 ptan.ignite 包中:
-
EndOfEpisodeHandler:附加到 ignite.Engine,触发 EPISODE_COMPLETED 事件,并在事件中跟踪奖励和步骤数,记录到引擎的指标中。它还可以在最后几集的平均奖励达到预定义边界时触发事件,预定用于在达到某个目标奖励时停止训练。
-
EpisodeFPSHandler:跟踪代理与环境之间执行的交互次数,并计算每秒帧数的性能指标。它还跟踪从训练开始到现在经过的秒数。
-
PeriodicEvents:每 10、100 或 1,000 次训练迭代时触发相应事件。它有助于减少写入 TensorBoard 的数据量。
在下一章中将详细说明如何使用这些类,当时我们将用它们重新实现第六章中的 DQN 训练,然后检查几个 DQN 扩展和调整,以提高基础 DQN 的收敛性。
PTAN CartPole 解算器
现在我们来看看 PTAN 类(目前没有 Ignite),并尝试将所有内容结合起来解决我们的第一个环境:CartPole。完整的代码位于 Chapter07/06_cartpole.py。这里只展示与我们刚刚讨论的材料相关的重要部分代码。
首先,我们创建神经网络(之前用于 CartPole 的简单两层前馈神经网络)并将其目标设为 NN epsilon-greedy 动作选择器和 DQNAgent。接着,创建经验源和回放缓冲区:
net = Net(obs_size, HIDDEN_SIZE, n_actions)
tgt_net = ptan.agent.TargetNet(net)
selector = ptan.actions.ArgmaxActionSelector()
selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=1, selector=selector)
agent = ptan.agent.DQNAgent(net, selector)
exp_source = ptan.experience.ExperienceSourceFirstLast(env, agent, gamma=GAMMA)
buffer = ptan.experience.ExperienceReplayBuffer(exp_source, buffer_size=REPLAY_SIZE)
通过这几行代码,我们已经完成了数据管道的创建。
现在,我们只需要在缓冲区上调用populate()并从中采样训练批次:
while True:
step += 1
buffer.populate(1)
for reward, steps in exp_source.pop_rewards_steps():
episode += 1
print(f"{step}: episode {episode} done, reward={reward:.2f}, "
f"epsilon={selector.epsilon:.2f}")
solved = reward > 150
if solved:
print("Whee!")
break
if len(buffer) < 2*BATCH_SIZE:
continue
batch = buffer.sample(BATCH_SIZE)
在每次训练循环的开始,我们要求缓冲区从经验源中获取一个样本,然后检查是否有已完成的 episode。pop_rewards_steps()方法在ExperienceSource类中返回一个元组列表,其中包含自上次调用该方法以来完成的 episodes 信息。
在训练循环后期,我们将一批ExperienceFirstLast对象转换为适合 DQN 训练的张量:
batch = buffer.sample(BATCH_SIZE)
states_v, actions_v, tgt_q_v = unpack_batch(batch, tgt_net.target_model, GAMMA)
optimizer.zero_grad()
q_v = net(states_v)
q_v = q_v.gather(1, actions_v.unsqueeze(-1)).squeeze(-1)
loss_v = F.mse_loss(q_v, tgt_q_v)
loss_v.backward()
optimizer.step()
selector.epsilon *= EPS_DECAY
if step % TGT_NET_SYNC == 0:
tgt_net.sync()
我们计算损失并进行一次反向传播步骤。最后,我们在我们的动作选择器中衰减 epsilon(使用的超参数使得 epsilon 在第 500 步训练时衰减为零),并要求目标网络每 10 次训练迭代同步一次。
unpack_batch方法是我们实现的最后一部分:
@torch.no_grad()
def unpack_batch(batch: tt.List[ExperienceFirstLast], net: Net, gamma: float):
states = []
actions = []
rewards = []
done_masks = []
last_states = []
for exp in batch:
states.append(exp.state)
actions.append(exp.action)
rewards.append(exp.reward)
done_masks.append(exp.last_state is None)
if exp.last_state is None:
last_states.append(exp.state)
else:
last_states.append(exp.last_state)
states_v = torch.as_tensor(np.stack(states))
actions_v = torch.tensor(actions)
rewards_v = torch.tensor(rewards)
last_states_v = torch.as_tensor(np.stack(last_states))
last_state_q_v = net(last_states_v)
best_last_q_v = torch.max(last_state_q_v, dim=1)[0]
best_last_q_v[done_masks] = 0.0
return states_v, actions_v, best_last_q_v * gamma + rewards_v
它接受一批采样的ExperienceFirstLast对象,并将它们转换为三个张量:状态、动作和目标 Q 值。代码应该在 2000 到 3000 次训练迭代中收敛:
Chapter07$ python 06_cartpole.py
26: episode 1 done, reward=25.00, epsilon=1.00
52: episode 2 done, reward=26.00, epsilon=0.82
67: episode 3 done, reward=15.00, epsilon=0.70
80: episode 4 done, reward=13.00, epsilon=0.62
112: episode 5 done, reward=32.00, epsilon=0.45
123: episode 6 done, reward=11.00, epsilon=0.40
139: episode 7 done, reward=16.00, epsilon=0.34
148: episode 8 done, reward=9.00, epsilon=0.31
156: episode 9 done, reward=8.00, epsilon=0.29
...
2481: episode 113 done, reward=58.00, epsilon=0.00
2544: episode 114 done, reward=63.00, epsilon=0.00
2594: episode 115 done, reward=50.00, epsilon=0.00
2786: episode 116 done, reward=192.00, epsilon=0.00
Whee!
其他的 RL 库
正如我们之前讨论的,市面上有几种专门用于 RL 的库。几年前,TensorFlow 比 PyTorch 更流行,但如今,PyTorch 在该领域占据主导地位,并且最近 JAX 的使用趋势正在上升,因为它提供了更好的性能。以下是我推荐的一些你可能想要考虑在项目中使用的库:
-
stable-baselines3:我们在讨论 Atari 包装器时提到了这个库。它是 OpenAI Stable Baselines 库的一个分支,主要目的是提供一个经过优化且可复现的 RL 算法集,你可以用它来验证你的方法(
github.com/DLR-RM/stable-baselines3)。 -
TorchRL:PyTorch 的 RL 扩展。这个库相对较新——它的第一个版本发布于 2022 年底——但提供了丰富的 RL 帮助类。它的设计理念与 PTAN 非常接近——一个以 Python 为主的灵活类集合,你可以将它们组合和扩展来构建你的系统——所以我强烈推荐你学习这个库。在本书的剩余部分,我们将使用这个库的类。很可能,本书下一版的示例(除非我们迎来了“人工智能奇点”,书籍变得像粘土板一样过时)将不再基于 PTAN,而是基于 TorchRL,它维护得更好。文档:
pytorch.org/rl/,源代码:github.com/pytorch/rl。 -
Spinning Up:这是 OpenAI 的另一个库,但目标不同:提供关于最先进方法的有价值且简洁的教育材料。这个库已经有几年没有更新了(最后的提交是在 2020 年),但仍然提供了关于这些方法的非常有价值的材料。文档:
spinningup.openai.com/。代码:github.com/openai/spinningup。 -
Keras-RL:由 Matthias Plappert 于 2016 年启动,包含基本的深度强化学习方法。正如名称所示,该库是使用 Keras 实现的,Keras 是一个高层次的 TensorFlow 封装器(
github.com/keras-rl/keras-rl)。不幸的是,最后一次提交是在 2019 年,因此该项目已被废弃。 -
Dopamine:谷歌于 2018 年发布的库。它是 TensorFlow 特定的,这对于谷歌发布的库来说并不令人惊讶(
github.com/google/dopamine)。 -
Ray:一个用于分布式执行机器学习代码的库。它包含作为库一部分的强化学习工具(
github.com/ray-project/ray)。 -
TF-Agents:谷歌于 2018 年发布的另一个库(
github.com/tensorflow/agents)。 -
ReAgent:来自 Facebook Research 的库。它内部使用 PyTorch,并采用声明式配置风格(当你创建 JSON 文件来描述问题时),这限制了可扩展性。但当然,由于它是开源的,你总是可以扩展功能(
github.com/facebookresearch/ReAgent)。最近,ReAgent 已经被归档,并由同一团队的 Pearl 库所替代:github.com/facebookresearch/Pearl/。
总结
在这一章中,我们讨论了更高层次的强化学习库、它们的动机和要求。接着,我们深入了解了 PTAN 库,它将在本书的其余部分中用于简化示例代码。专注于方法的细节而非实现,这对于你在本书后续章节学习强化学习时会非常有帮助。
在下一章,我们将通过探索研究人员和实践者自经典 DQN 方法引入以来,为了提高方法的稳定性和性能所发现的扩展,重新回到 DQN 方法。
第八章:DQN 扩展
自从 DeepMind 在 2015 年发布其深度 Q 网络(DQN)模型的论文以来,许多改进方案已经被提出,并对基础架构进行了调整,显著提高了 DeepMind 基础 DQN 的收敛性、稳定性和样本效率。本章将深入探讨其中的一些思想。
2017 年 10 月,DeepMind 的 Hessel 等人发布了一篇名为《Rainbow: Combining improvements in deep reinforcement learning》的论文[Hes+18],介绍了对 DQN 的六个最重要的改进;其中一些是在 2015 年发明的,但其他一些则较为近期。在这篇论文中,通过简单地结合这六个方法,达到了 Atari 游戏套件上的最先进成果。
自 2017 年以来,更多的论文被发表,并且最先进的结果被进一步推动,但论文中介绍的所有方法仍然是相关的,并在实践中广泛使用。例如,在 2023 年,Marc Bellemare 出版了《Distributional reinforcement learning》一书[BDR23],书中讨论了论文中的一种方法。此外,所描述的改进相对简单易于实现和理解,因此在本版中我没有对这一章做重大修改。
我们将熟悉的 DQN 扩展如下:
-
N 步 DQN:如何通过简单地展开贝尔曼方程提高收敛速度和稳定性,以及为什么它不是终极解决方案
-
双 DQN:如何处理 DQN 对动作值的高估
-
噪声网络:如何通过给网络权重添加噪声来提高探索效率
-
优先回放缓冲区:为什么均匀采样我们的经验不是训练的最佳方式
-
对抗 DQN:如何通过使我们的网络架构更紧密地反映我们正在解决的问题,来提高收敛速度
-
分类 DQN:如何超越单一的期望动作值,处理完整的分布
本章将介绍所有这些方法。我们将分析这些方法背后的思想,以及如何实现它们,并与经典的 DQN 性能进行比较。最后,我们将分析结合所有方法的系统表现。
基础 DQN
为了开始,我们将实现与第六章相同的 DQN 方法,但利用第七章中描述的高级原语。这将使我们的代码更加简洁,这是好的,因为无关的细节不会使我们偏离方法的逻辑。同时,本书的目的并非教你如何使用现有的库,而是如何培养对强化学习方法的直觉,必要时,从零开始实现一切。从我的角度来看,这是一个更有价值的技能,因为库会不断变化,但对领域的真正理解将使你能够迅速理解他人的代码,并有意识地应用它。
在基本的 DQN 实现中,我们在本书的 GitHub 仓库中的 Chapter08 文件夹中有三个模块:
-
Chapter08/lib/dqn_model.py: DQN 神经网络(NN),与第六章相同,因此我不会重复它。
-
Chapter08/lib/common.py: 本章代码共享的常用函数和声明。
-
Chapter08/01_dqn_basic.py: 77 行代码,利用 PTAN 和 Ignite 库实现基本的 DQN 方法。
公共库
让我们从 lib/common.py 的内容开始。首先,我们有上一章中为 Pong 环境设置的超参数。这些超参数存储在一个数据类对象中,这是存储一组数据字段及其类型注释的标准方式。这样,我们可以轻松为不同、更复杂的 Atari 游戏添加另一个配置集,并允许我们对超参数进行实验:
@dataclasses.dataclass
class Hyperparams:
env_name: str
stop_reward: float
run_name: str
replay_size: int
replay_initial: int
target_net_sync: int
epsilon_frames: int
learning_rate: float = 0.0001
batch_size: int = 32
gamma: float = 0.99
epsilon_start: float = 1.0
epsilon_final: float = 0.1
tuner_mode: bool = False
episodes_to_solve: int = 500
GAME_PARAMS = {
’pong’: Hyperparams(
env_name="PongNoFrameskip-v4",
stop_reward=18.0,
run_name="pong",
replay_size=100_000,
replay_initial=10_000,
target_net_sync=1000,
epsilon_frames=100_000,
epsilon_final=0.02,
),
lib/common.py 中的下一个函数名为 unpack_batch,它接收转移的批次并将其转换为适合训练的 NumPy 数组集合。来自 ExperienceSourceFirstLast 的每个转移都属于 ExperienceFirstLast 类型,这是一个数据类,包含以下字段:
-
state: 来自环境的观测值。
-
action: 代理执行的整数动作。
-
reward: 如果我们创建了 ExperienceSourceFirstLast 并设置了属性 steps_count=1,那么它只是即时奖励。对于更大的步数计数,它包含了这个步数内的奖励的折扣总和。
-
last_state: 如果转移对应于环境中的最后一步,那么这个字段为 None;否则,它包含经验链中的最后一个观测值。
unpack_batch 的代码如下:
def unpack_batch(batch: tt.List[ExperienceFirstLast]):
states, actions, rewards, dones, last_states = [],[],[],[],[]
for exp in batch:
states.append(exp.state)
actions.append(exp.action)
rewards.append(exp.reward)
dones.append(exp.last_state is None)
if exp.last_state is None:
lstate = exp.state # the result will be masked anyway
else:
lstate = exp.last_state
last_states.append(lstate)
return np.asarray(states), np.array(actions), np.array(rewards, dtype=np.float32), \
np.array(dones, dtype=bool), np.asarray(last_states)
请注意我们如何处理批次中的最终转移。为了避免对这种情况的特殊处理,对于终止转移,我们将初始状态存储在 last_states 数组中。为了使我们的 Bellman 更新计算正确,我们必须在损失计算时使用 dones 数组对这些批次条目进行掩码。另一种解决方案是仅对非终止转移计算最后状态的值,但这会使我们的损失函数逻辑稍微复杂一些。
DQN 损失函数的计算由 calc_loss_dqn 函数提供,代码几乎与第六章相同。唯一的小改动是 torch.no_grad(),它阻止了 PyTorch 计算图被记录到目标网络中:
def calc_loss_dqn(
batch: tt.List[ExperienceFirstLast], net: nn.Module, tgt_net: nn.Module,
gamma: float, device: torch.device) -> torch.Tensor:
states, actions, rewards, dones, next_states = unpack_batch(batch)
states_v = torch.as_tensor(states).to(device)
next_states_v = torch.as_tensor(next_states).to(device)
actions_v = torch.tensor(actions).to(device)
rewards_v = torch.tensor(rewards).to(device)
done_mask = torch.BoolTensor(dones).to(device)
actions_v = actions_v.unsqueeze(-1)
state_action_vals = net(states_v).gather(1, actions_v)
state_action_vals = state_action_vals.squeeze(-1)
with torch.no_grad():
next_state_vals = tgt_net(next_states_v).max(1)[0]
next_state_vals[done_mask] = 0.0
bellman_vals = next_state_vals.detach() * gamma + rewards_v
return nn.MSELoss()(state_action_vals, bellman_vals)
除了核心的 DQN 函数外,common.py 还提供了与训练循环、数据生成和 TensorBoard 跟踪相关的多个实用工具。第一个这样的工具是一个小类,它在训练过程中实现了 epsilon 衰减。Epsilon 定义了代理执行随机动作的概率。它应从 1.0 开始(完全随机的代理),逐渐衰减到某个小值,比如 0.02 或 0.01。这个代码非常简单,但几乎在任何 DQN 中都需要,因此通过以下小类提供:
class EpsilonTracker:
def __init__(self, selector: EpsilonGreedyActionSelector, params: Hyperparams):
self.selector = selector
self.params = params
self.frame(0)
def frame(self, frame_idx: int):
eps = self.params.epsilon_start - frame_idx / self.params.epsilon_frames
self.selector.epsilon = max(self.params.epsilon_final, eps)
另一个小函数是 batch_generator,它接收 ExperienceReplayBuffer(PTAN 类,在第七章中描述)并无限次生成从缓冲区中采样的训练批次。开始时,函数确保缓冲区包含所需数量的样本:
def batch_generator(buffer: ExperienceReplayBuffer, initial: int, batch_size: int) -> \
tt.Generator[tt.List[ExperienceFirstLast], None, None]:
buffer.populate(initial)
while True:
buffer.populate(1)
yield buffer.sample(batch_size)
最后,一个冗长但非常有用的函数叫做 setup_ignite,它附加了所需的 Ignite 处理器,显示训练进度并将度量写入 TensorBoard。让我们一块儿看这个函数:
def setup_ignite(
engine: Engine, params: Hyperparams, exp_source: ExperienceSourceFirstLast,
run_name: str, extra_metrics: tt.Iterable[str] = (),
tuner_reward_episode: int = 100, tuner_reward_min: float = -19,
):
handler = ptan_ignite.EndOfEpisodeHandler(
exp_source, bound_avg_reward=params.stop_reward)
handler.attach(engine)
ptan_ignite.EpisodeFPSHandler().attach(engine)
最初,setup_ignite 附加了 PTAN 提供的两个 Ignite 处理器:
-
EndOfEpisodeHandler,每当游戏回合结束时,它会触发 Ignite 事件。当回合的平均奖励超过某个边界时,它还可以触发事件。我们用它来检测游戏何时最终解决。
-
EpisodeFPSHandler,这是一个小类,跟踪每个回合所花费的时间以及我们与环境交互的次数。根据这些信息,我们计算每秒帧数(FPS),它是一个重要的性能度量指标。
然后,我们安装两个事件处理器:
@engine.on(ptan_ignite.EpisodeEvents.EPISODE_COMPLETED)
def episode_completed(trainer: Engine):
passed = trainer.state.metrics.get(’time_passed’, 0)
print("Episode %d: reward=%.0f, steps=%s, speed=%.1f f/s, elapsed=%s" % (
trainer.state.episode, trainer.state.episode_reward,
trainer.state.episode_steps, trainer.state.metrics.get(’avg_fps’, 0),
timedelta(seconds=int(passed))))
@engine.on(ptan_ignite.EpisodeEvents.BOUND_REWARD_REACHED)
def game_solved(trainer: Engine):
passed = trainer.state.metrics[’time_passed’]
print("Game solved in %s, after %d episodes and %d iterations!" % (
timedelta(seconds=int(passed)), trainer.state.episode,
trainer.state.iteration))
trainer.should_terminate = True
trainer.state.solved = True
其中一个事件处理器会在回合结束时被调用。它将在控制台上显示有关已完成回合的信息。另一个函数会在平均奖励超过超参数中定义的边界时被调用(在 Pong 的情况下是 18.0)。此函数显示关于已解决游戏的消息,并停止训练。
该函数的其余部分与我们想要跟踪的 TensorBoard 数据有关。首先,我们创建一个 TensorboardLogger:
now = datetime.now().isoformat(timespec=’minutes’).replace(’:’, ’’)
logdir = f"runs/{now}-{params.run_name}-{run_name}"
tb = tb_logger.TensorboardLogger(log_dir=logdir)
run_avg = RunningAverage(output_transform=lambda v: v[’loss’])
run_avg.attach(engine, "avg_loss")
这是 Ignite 提供的一个特殊类,用于写入 TensorBoard。我们的处理函数将返回损失值,因此我们附加了 RunningAverage 转换(同样由 Ignite 提供),以获取随时间平滑的损失版本。
接下来,我们将要跟踪的度量值附加到 Ignite 事件:
metrics = [’reward’, ’steps’, ’avg_reward’]
handler = tb_logger.OutputHandler(tag="episodes", metric_names=metrics)
event = ptan_ignite.EpisodeEvents.EPISODE_COMPLETED
tb.attach(engine, log_handler=handler, event_name=event)
TensorboardLogger 可以跟踪来自 Ignite 的两组值:输出(由转换函数返回的值)和度量(在训练过程中计算并保存在引擎状态中)。EndOfEpisodeHandler 和 EpisodeFPSHandler 提供度量,这些度量在每个游戏回合结束时更新。因此,我们附加了 OutputHandler,每当回合完成时,它将把有关该回合的信息写入 TensorBoard。
接下来,我们跟踪训练过程中的另一组值,训练过程中的度量值:损失、FPS,以及可能与特定扩展逻辑相关的自定义度量:
ptan_ignite.PeriodicEvents().attach(engine)
metrics = [’avg_loss’, ’avg_fps’]
metrics.extend(extra_metrics)
handler = tb_logger.OutputHandler(tag="train", metric_names=metrics,
output_transform=lambda a: a)
event = ptan_ignite.PeriodEvents.ITERS_100_COMPLETED
tb.attach(engine, log_handler=handler, event_name=event)
这些值会在每次训练迭代时更新,但我们将进行数百万次迭代,因此我们每进行 100 次训练迭代就将值存储到 TensorBoard;否则,数据文件会非常大。所有这些功能看起来可能很复杂,但它为我们提供了从训练过程中收集的统一度量集。事实上,Ignite 并不复杂,考虑到它所提供的灵活性。common.py 就到这里。
实现
现在,让我们看一下 01_dqn_basic.py,它创建了所需的类并开始训练。我将省略不相关的代码,只关注重要部分(完整版本可以在 GitHub 仓库中找到)。首先,我们创建环境:
env = gym.make(params.env_name)
env = ptan.common.wrappers.wrap_dqn(env)
net = dqn_model.DQN(env.observation_space.shape, env.action_space.n).to(device)
tgt_net = ptan.agent.TargetNet(net)
在这里,我们应用一组标准包装器。我们在第六章中讨论了这些包装器,并且在下一章中,当我们优化 Pong 求解器的性能时,还会再次涉及到它们。然后,我们创建 DQN 模型和目标网络。
接下来,我们创建代理,并传入一个 epsilon-greedy 动作选择器:
selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=params.epsilon_start)
epsilon_tracker = common.EpsilonTracker(selector, params)
agent = ptan.agent.DQNAgent(net, selector, device=device)
在训练过程中,epsilon 将由我们之前讨论过的 EpsilonTracker 类进行减少。这将减少随机选择的动作数量,并给予我们的神经网络更多的控制权。
接下来,两个非常重要的对象是 ExperienceSourceFirstLast 和 ExperienceReplayBuffer:
exp_source = ptan.experience.ExperienceSourceFirstLast(
env, agent, gamma=params.gamma, env_seed=common.SEED)
buffer = ptan.experience.ExperienceReplayBuffer(
exp_source, buffer_size=params.replay_size)
ExperienceSourceFirstLast 接收代理和环境,并在游戏回合中提供过渡。这些过渡将被保存在经验回放缓冲区中。
然后我们创建优化器并定义处理函数:
optimizer = optim.Adam(net.parameters(), lr=params.learning_rate)
def process_batch(engine, batch):
optimizer.zero_grad()
loss_v = common.calc_loss_dqn(batch, net, tgt_net.target_model,
gamma=params.gamma, device=device)
loss_v.backward()
optimizer.step()
epsilon_tracker.frame(engine.state.iteration)
if engine.state.iteration % params.target_net_sync == 0:
tgt_net.sync()
return {
"loss": loss_v.item(),
"epsilon": selector.epsilon,
}
处理函数将在每批过渡时被调用以训练模型。为此,我们调用 common.calc_loss_dqn 函数,然后对结果进行反向传播。该函数还会要求 EpsilonTracker 减少 epsilon,并进行定期的目标网络同步。
最后,我们创建 Ignite Engine 对象:
engine = Engine(process_batch)
common.setup_ignite(engine, params, exp_source, NAME)
engine.run(common.batch_generator(buffer, params.replay_initial, params.batch_size))
我们使用来自 common.py 的函数进行配置,并运行训练过程。
超参数调优
为了使我们对 DQN 扩展的比较更加公平,我们还需要调优超参数。这一点至关重要,因为即使对于相同的游戏(Pong),使用固定的训练参数集可能在我们改变方法细节时给出较差的结果。
原则上,我们代码中的每个显式或隐式常量都可以进行调优,例如:
-
网络配置:层的数量和大小,激活函数,dropout 等
-
优化参数:方法(原生 SGD、Adam、AdaGrad 等)、学习率和其他优化器参数
-
探索参数:𝜖 的衰减率,最终 𝜖 值
-
Bellman 方程中的折扣因子 γ
但是,我们调整的每个新参数都会对所需的试验训练量产生乘法效应,因此调节过多的超参数可能需要进行数百次甚至上千次训练。像 Google 和 Meta 这样的大公司拥有比我们这些个人研究者更多的 GPU 资源,所以我们需要在这里保持平衡。
在我的例子中,我将演示如何进行超参数调优,但我们只会在少数几个值上进行搜索:
-
学习率
-
折扣因子 γ
-
我们正在考虑的 DQN 扩展特定的参数
有几个库可能对超参数调整有所帮助。这里,我使用的是 Ray Tune(docs.ray.io/en/latest/tune/index.xhtml),它是 Ray 项目的一部分——一个用于机器学习和深度学习的分布式计算框架。从高层次来看,你需要定义:
-
你希望探索的超参数空间(值的边界或显式列出的尝试值列表)
-
该函数执行使用特定超参数值的训练,并返回你想要优化的度量。
这可能看起来与机器学习问题非常相似,事实上它确实是——这也是一个优化问题。但它有一些显著的不同:我们正在优化的函数是不可微分的(因此无法执行梯度下降来推动超参数朝向期望的度量方向),而且优化空间可能是离散的(例如,你无法用 2.435 层的神经网络进行训练,因为我们无法对一个不平滑的函数求导)。
在后续章节中,我们会稍微触及一下这个问题,讨论黑箱优化方法(第十七章)和离散优化中的强化学习(第二十一章),但现在我们将使用最简单的方法——超参数的随机搜索。在这种情况下,ray.tune库会随机多次采样具体的参数,并调用函数以获得度量。最小(或最大)的度量值对应于在此次运行中找到的最佳超参数组合。
在这一章中,我们的度量(优化目标)将是代理需要玩多少局游戏才能解决游戏(即在 Pong 中达到大于 18 的平均得分)。
为了说明调整的效果,对于每个 DQN 扩展,我们使用一组固定的参数(与第六章相同)检查训练动态,并使用在 20-30 轮调整后找到的最佳超参数进行训练。如果你愿意,你可以做自己的实验,优化更多的超参数。最有可能的是,这将使你能够找到一个更好的训练配置。
这个过程的核心实现是在common.tune_params函数中。让我们看看它的代码。我们从类型声明和超参数空间开始:
TrainFunc = tt.Callable[
[Hyperparams, torch.device, dict],
tt.Optional[int]
]
BASE_SPACE = {
"learning_rate": tune.loguniform(1e-5, 1e-4),
"gamma": tune.choice([0.9, 0.92, 0.95, 0.98, 0.99, 0.995]),
}
在这里,我们首先定义训练函数的类型,它接收一个Hyperparams数据类、一个要使用的torch.device,以及一个包含额外参数的字典(因为我们即将介绍的某些 DQN 扩展可能需要除了在Hyperparams中声明的参数以外的额外参数)。
函数的结果可以是一个整数值,表示在达到 18 分的得分之前我们玩了多少局游戏,或者是 None,如果我们决定提前停止训练。这是必需的,因为某些超参数组合可能无法收敛或收敛得太慢,因此为了节省时间,我们会在不等待太久的情况下停止训练。
然后我们定义超参数搜索空间——这是一个具有字符串键(参数名)和可能值探索的 tune 声明的字典。它可以是一个概率分布(均匀、对数均匀、正态等)或要尝试的显式值列表。你还可以使用 tune.grid_search 声明,提供一个值列表。在这种情况下,将尝试所有值。
在我们的例子中,我们从对数均匀分布中采样学习率,并从一个包含 6 个值(范围从 0.9 到 0.995)的列表中采样 gamma。
接下来,我们有 tune_params 函数:
def tune_params(
base_params: Hyperparams, train_func: TrainFunc, device: torch.device,
samples: int = 10, extra_space: tt.Optional[tt.Dict[str, tt.Any]] = None,
):
search_space = dict(BASE_SPACE)
if extra_space is not None:
search_space.update(extra_space)
config = tune.TuneConfig(num_samples=samples)
def objective(config: dict, device: torch.device) -> dict:
keys = dataclasses.asdict(base_params).keys()
upd = {"tuner_mode": True}
for k, v in config.items():
if k in keys:
upd[k] = v
params = dataclasses.replace(base_params, **upd)
res = train_func(params, device, config)
return {"episodes": res if res is not None else 10**6}
该函数给定以下参数:
-
用于训练的基础超参数集
-
训练函数
-
使用的 Torch 设备
-
在回合中执行的样本数量
-
具有搜索空间的附加字典
在此函数中,我们有一个目标函数,它从采样的字典中创建 Hyperparameters 对象,调用训练函数,并返回字典(这是 ray.tune 库的要求)。
tune_params 函数的其余部分很简单:
obj = tune.with_parameters(objective, device=device)
if device.type == "cuda":
obj = tune.with_resources(obj, {"gpu": 1})
tuner = tune.Tuner(obj, param_space=search_space, tune_config=config)
results = tuner.fit()
best = results.get_best_result(metric="episodes", mode="min")
print(best.config)
print(best.metrics)
在这里,我们包装目标函数,以传递 Torch 设备并考虑 GPU 资源。这是为了让 Ray 能够正确地并行化调优过程。如果你机器上安装了多个 GPU,它将并行运行多个训练。然后,我们只需创建 Tuner 对象,并要求它执行超参数搜索。
与超参数调优相关的最后一部分代码在 setup_ignite 函数中。它检查训练过程是否没有收敛,如果没有收敛,则停止训练以避免无限等待。为此,我们在超参数调优模式下安装 Ignite 事件处理程序:
if params.tuner_mode:
@engine.on(ptan_ignite.EpisodeEvents.EPISODE_COMPLETED)
def episode_completed(trainer: Engine):
avg_reward = trainer.state.metrics.get(’avg_reward’)
max_episodes = params.episodes_to_solve * 1.1
if trainer.state.episode > tuner_reward_episode and \
avg_reward < tuner_reward_min:
trainer.should_terminate = True
trainer.state.solved = False
elif trainer.state.episode > max_episodes:
trainer.should_terminate = True
trainer.state.solved = False
if trainer.should_terminate:
print(f"Episode {trainer.state.episode}, "
f"avg_reward {avg_reward:.2f}, terminating")
在这里,我们检查两个条件:
-
如果平均奖励低于
tuner_reward_min(这是setup_ignite函数的一个参数,默认为 -19),并且在 100 局游戏后(由tuner_reward_episode参数提供),这意味着我们几乎不可能收敛。 -
我们已经玩了超过
max_episodes局游戏,仍然没有解决游戏。在默认配置中,我们将此限制设置为 500 局游戏。
在这两种情况下,我们都会停止训练并将 solved 属性设置为 False,这将在调优过程中返回一个较高的常数指标值。
这就是超参数调优代码的全部内容。在运行并检查结果之前,让我们首先使用我们在第六章中使用的参数开始一次单次训练。
使用常见参数的结果
如果我们使用参数 --params common 运行训练,我们将使用来自 common.py 模块的超参数训练 Pong 游戏。作为选项,你可以使用 --params best 命令行来训练该 DQN 扩展的最佳值。
好的,让我们使用以下命令开始训练:
Chapter08$ ./01_dqn_basic.py --dev cuda --params common
A.L.E: Arcade Learning Environment (version 0.8.1+53f58b7)
[Powered by Stella]
Episode 1: reward=-21, steps=848, speed=0.0 f/s, elapsed=0:00:11
Episode 2: reward=-21, steps=850, speed=0.0 f/s, elapsed=0:00:11
Episode 3: reward=-19, steps=1039, speed=0.0 f/s, elapsed=0:00:11
Episode 4: reward=-21, steps=884, speed=0.0 f/s, elapsed=0:00:11
Episode 5: reward=-19, steps=1146, speed=0.0 f/s, elapsed=0:00:11
Episode 6: reward=-20, steps=997, speed=0.0 f/s, elapsed=0:00:11
Episode 7: reward=-21, steps=972, speed=0.0 f/s, elapsed=0:00:11
Episode 8: reward=-21, steps=882, speed=0.0 f/s, elapsed=0:00:11
Episode 9: reward=-21, steps=898, speed=0.0 f/s, elapsed=0:00:11
Episode 10: reward=-20, steps=947, speed=0.0 f/s, elapsed=0:00:11
Episode 11: reward=-21, steps=762, speed=227.7 f/s, elapsed=0:00:12
Episode 12: reward=-20, steps=991, speed=227.8 f/s, elapsed=0:00:17
Episode 13: reward=-21, steps=762, speed=227.9 f/s, elapsed=0:00:20
Episode 14: reward=-20, steps=948, speed=227.9 f/s, elapsed=0:00:24
Episode 15: reward=-20, steps=992, speed=228.0 f/s, elapsed=0:00:28
......
输出中的每一行都是在游戏回合结束时写入的,显示回合奖励、步数、速度和总训练时间。对于基础的 DQN 版本和常见的超参数,通常需要大约 70 万帧和约 400 局游戏才能达到 18 的平均奖励,因此需要耐心。在训练过程中,我们可以在 TensorBoard 中查看训练过程的动态,里面显示了ε值、原始奖励值、平均奖励和速度的图表。以下图表显示了每回合的奖励和步数(底部 x 轴表示墙钟时间,顶部 x 轴表示回合数):

图 8.1:奖励图(左)和每回合步数图(右)

图 8.2:训练速度图(左)和平均训练损失图(右)
还值得注意的是每回合步数在训练过程中是如何变化的。最开始时,步数增加,因为我们的网络开始赢得越来越多的游戏,但在达到某个水平后,步数减少了 2 倍并几乎保持不变。这是由我们的γ参数驱动的,它会随着时间的推移折扣智能体的奖励,所以它不仅仅是尽可能多地积累奖励,还要高效地完成任务。
调整过的基准 DQN
在使用命令行参数--tune 30(这在一块 GPU 上花费了大约一天)运行基准 DQN 之后,我找到了以下参数,这可以在 340 回合内解决 Pong 问题(而不是 360 回合):
learning_rate=9.932831968547505e-05,
gamma=0.98,
如你所见,学习率几乎与之前一样(10^(−4)),但γ值较低(0.98 对比 0.99)。这可能表明 Pong 有相对较短的子轨迹与动作-奖励因果关系,因此减少γ对训练有稳定作用。
在下图中,你可以看到调整过和未调整版本的奖励与每个回合步数的比较(区别非常小):

图 8.3:调整过的和未调整超参数的奖励图(左)和每回合步数图(右)
现在我们有了基准 DQN 版本,并准备探索 Hessel 等人提出的改进方法。
N 步 DQN
我们将要实现并评估的第一个改进是一个比较老的方法。它最早由 Sutton 在论文《通过时间差分方法学习预测》[Sut88]中提出。为了理解这个方法,我们再看一遍 Q-learning 中使用的 Bellman 更新:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq26.png)
这个方程是递归的,这意味着我们可以用自身来表示 Q(s[t+1],a[t+1]),从而得到这个结果:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq27.png)
值 r[a,t+1]表示在时间 t + 1 时发出动作 a 后的局部奖励。然而,如果我们假设在 t + 1 步时的动作 a 是最优选择或接近最优选择,我们可以省略 max[a]操作,得到以下结果:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq28.png)
这个值可以反复展开,次数不限。正如你可能猜到的,这种展开可以轻松应用到我们的 DQN 更新中,通过用更长的 n 步转移序列替换一步转移采样。为了理解为什么这种展开可以帮助我们加速训练,让我们考虑图 8.4 中的示例。这里,我们有一个简单的四状态环境(s[1]、s[2]、s[3]、s[4]),除了终止状态 s[4] 外,每个状态都有唯一可执行的动作:

图 8.4:一个简单环境的转移图
那么,一步情况下会发生什么呢?我们总共有三个更新是可能的(我们不使用 max,因为只有一个可执行动作):
-
Q(s[1],a) ←r[1] + γQ(s[2],a)
-
Q(s[2],a) ←r[2] + γQ(s[3],a)
-
Q(s[3],a) ←r[3]
假设在训练开始时,我们按照这个顺序完成之前的更新。前两个更新将没有用,因为我们当前的 Q(s[2],a) 和 Q(s[3],a) 是不正确的,且包含初始的随机值。唯一有用的更新是更新 3,它会将奖励 r[3] 正确地分配给终止状态之前的状态 s[3]。
现在让我们一次次执行这些更新。在第二次迭代时,Q(s[2],a) 会被赋予正确的值,但 Q(s[1],a) 的更新仍然会带有噪声。直到第三次迭代,我们才会为每个 Q 获得有效的值。所以,即使是在一步情况下,也需要三步才能将正确的值传播到所有状态。
现在让我们考虑一个两步的情况。这个情况同样有三个更新:
-
Q(s[1],a) ←r[1] + γr[2] + γ²Q(s[3],a)
-
Q(s[2],a) ←r[2] + γr[3]
-
Q(s[3],a) ←r[3]
在这种情况下,在第一次更新循环中,正确的值将分别分配给 Q(s[2],a) 和 Q(s[3],a)。在第二次迭代中,Q(s[1],a) 的值也将得到正确更新。因此,多步操作提高了值的传播速度,从而改善了收敛性。你可能会想,“如果这样这么有帮助,那我们不妨将 Bellman 方程展开 100 步。这样会让我们的收敛速度加快 100 倍吗?”不幸的是,答案是否定的。尽管我们有所期待,我们的 DQN 完全无法收敛。
为了理解为什么如此,我们再次回到我们的展开过程,特别是我们省略了 max[a]。这样做对吗?严格来说,答案是否定的。我们在中间步骤省略了 max 操作,假设我们在经验收集过程中(或者我们的策略)是最优的。假如不是呢?例如,在训练初期,我们的智能体是随机行为的。在这种情况下,我们计算出的 Q(s[t],a[t]) 值可能小于该状态的最优值(因为某些步骤是随机执行的,而不是通过最大化 Q 值来遵循最有希望的路径)。我们展开 Bellman 方程的步数越多,我们的更新可能就越不准确。
我们的大型经验回放缓冲区将使情况变得更糟,因为它会增加从旧的糟糕策略(由旧的糟糕 Q 近似所决定)获得过渡的机会。这将导致当前 Q 近似的错误更新,从而很容易破坏我们的训练进程。这个问题是强化学习方法的一个基本特征,正如我们在第四章简要提到的,当时我们讨论了强化学习方法的分类。
有两大类方法:
-
基于非策略的方法:第一类基于非策略的方法不依赖于“数据的新鲜度”。例如,简单的 DQN 就是基于非策略的,这意味着我们可以使用几百万步之前从环境中采样的非常旧的数据,这些数据仍然对学习有用。这是因为我们只是用即时奖励加上最佳行动价值的当前折扣近似来更新动作的价值 Q(s[t],a[t])。即使动作 a[t]是随机采样的,也无关紧要,因为对于这个特定的动作 a[t],在状态 s[t]下,我们的更新是正确的。这就是为什么在基于非策略的方法中,我们可以使用一个非常大的经验缓冲区,使我们的数据更接近独立同分布(iid)。
-
基于策略的方法:另一方面,基于策略的方法严重依赖于根据我们正在更新的当前策略来采样的训练数据。这是因为基于策略的方法试图间接(如之前的 n 步 DQN)或直接(本书第三部分的内容完全是关于这种方法)改进当前策略。
那么,哪种方法更好呢?嗯,这取决于。基于非策略的方法允许你在先前的大量数据历史上进行训练,甚至在人工示范上进行训练,但它们通常收敛较慢。基于策略的方法通常更快,但需要更多来自环境的新鲜数据,这可能会很昂贵。试想一下,使用基于策略的方法训练一个自动驾驶汽车。在系统学会避开墙壁和树木之前,你得花费大量的撞车成本!
你可能会有一个问题:为什么我们要讨论一个 n 步 DQN,如果这个“n 步性”会使它变成一个基于策略的方法,这将使我们的大型经验缓冲区变得没用?实际上,这通常不是非黑即白的。你仍然可以使用 n 步 DQN,如果它有助于加速 DQN 的训练,但你需要在选择 n 时保持谨慎。小的值,如二或三,通常效果很好,因为我们在经验缓冲区中的轨迹与一步过渡差别不大。在这种情况下,收敛速度通常会成比例地提高,但 n 值过大可能会破坏训练过程。因此,步数应该进行调优,但加速收敛通常使得这样做是值得的。
实现
由于 ExperienceSourceFirstLast 类已经支持多步 Bellman 展开,因此我们的 n 步版本的 DQN 非常简单。我们只需要对基础 DQN 进行两个修改,就能将其转换为 n 步版本:
-
在 ExperienceSourceFirstLast 创建时,通过 steps_count 参数传递我们希望展开的步骤数。
-
将正确的 gamma 值传递给 calc_loss_dqn 函数。这个修改非常容易被忽视,但却可能对收敛性产生不利影响。由于我们的 Bellman 现在是 n 步的,经验链中最后一个状态的折扣系数将不再是γ,而是γ^n。
你可以在 Chapter08/02_dqn_n_steps.py 中找到整个示例,这里只展示了修改过的行:
exp_source = ptan.experience.ExperienceSourceFirstLast(
env, agent, gamma=params.gamma, env_seed=common.SEED,
steps_count=n_steps
)
n_steps 值是在命令行参数中传递的步数计数;默认使用四步。
另一个修改是在传递给 calc_loss_dqn 函数的 gamma 值:
loss_v = common.calc_loss_dqn(
batch, net, tgt_net.target_model,
gamma=params.gamma**n_steps, device=device)
结果
训练模块 Chapter08/02_dqn_n_steps.py 可以像以前一样启动,增加了命令行选项-n,表示展开 Bellman 方程的步骤数。这些是我们基线和 n 步 DQN 的图表(使用相同的参数集),其中 n 值为 2 和 3。正如你所见,Bellman 展开大大加速了收敛速度:

图 8.5:基本(单步)DQN 和 n 步版本的奖励和步骤数
如图所示,三步 DQN 的收敛速度显著快于简单 DQN,这是一个不错的改进。那么,n 值更大呢?图 8.6 展示了 n = 3…6 的奖励动态:

图 8.6:n = 3…6 的奖励动态,使用相同的超参数
如你所见,从三步到四步有所提升,但远不如之前的改进。n = 5 的变体表现更差,几乎与 n = 2 相当。n = 6 也是如此。所以,在我们的情况下,n = 3 看起来是最优的。
超参数调优
在这个扩展中,超参数调优是针对每个 n 值从 2 到 7 单独进行的。以下表格显示了最佳参数以及它们解决游戏所需的游戏次数:
| n | 学习率 | γ | 游戏次数 |
|---|---|---|---|
| 2 | 3.97 ⋅ 10^(−5) | 0.98 | 293 |
| 3 | 7.82 ⋅ 10^(−5) | 0.98 | 260 |
| 4 | 6.07 ⋅ 10^(−5) | 0.98 | 290 |
| 5 | 7.52 ⋅ 10^(−5) | 0.99 | 268 |
| 6 | 6.78 ⋅ 10^(−5) | 0.995 | 261 |
| 7 | 8.59 ⋅ 10^(−5) | 0.98 | 284 |
表 8.1:每个 n 值的最佳超参数(学习率和 gamma)
这张表格也验证了未调优版本比较的结论——对两步和三步展开 Bellman 方程可以提高收敛性,但进一步增加 n 会导致更差的结果。n = 6 的结果与 n = 3 相当,但 n = 4 和 n = 5 的结果更差,因此我们应该停在 n = 3。
图 8.7 比较了基线和 N 步 DQN 调优版本的训练动态,分别为 n = 2 和 n = 3。

图 8.7:超参数调整后的奖励和步数
Double DQN
如何改进基本 DQN 的下一个富有成效的想法来自 DeepMind 研究人员在标题为深度强化学习中的双重 Q 学习的论文中[VGS16]。在论文中,作者证明了基本 DQN 倾向于高估 Q 值,这可能对训练性能有害,并且有时可能导致次优策略。这背后的根本原因是 Bellman 方程中的 max 操作,但其严格证明有点复杂(您可以在论文中找到完整的解释)。作为解决这个问题的方法,作者建议稍微修改贝尔曼更新。
在基本 DQN 中,我们的 Q 的目标值看起来像这样:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq29.png)
Q′(s[t+1],a) 是使用我们的目标网络计算的 Q 值,其权重每隔 n 步从训练网络复制一次。论文的作者建议选择使用训练网络为下一个状态选择动作,但从目标网络获取 Q 值。因此,目标 Q 值的新表达式如下所示:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq30.png)
作者证明了这个简单的小改进完全修复了高估问题,并称这种新架构为双重 DQN。
实施
核心实现非常简单。我们需要做的是稍微修改我们的损失函数。但是让我们再进一步,比较基本 DQN 和双重 DQN 生成的动作值。根据论文作者的说法,我们的基线 DQN 应该对于相同状态的预测值始终较高。为了做到这一点,我们存储一组随机保留的状态,并周期性地计算评估集中每个状态的最佳动作的均值。
完整的示例位于 Chapter08/03_dqn_double.py 中。让我们先看一下损失函数:
def calc_loss_double_dqn(
batch: tt.List[ptan.experience.ExperienceFirstLast],
net: nn.Module, tgt_net: nn.Module, gamma: float, device: torch.device):
states, actions, rewards, dones, next_states = common.unpack_batch(batch)
states_v = torch.as_tensor(states).to(device)
actions_v = torch.tensor(actions).to(device)
rewards_v = torch.tensor(rewards).to(device)
done_mask = torch.BoolTensor(dones).to(device)
我们将使用这个函数而不是 common.calc_loss_dqn,它们都共享大量代码。主要区别在于下一个 Q 值的估计:
actions_v = actions_v.unsqueeze(-1)
state_action_vals = net(states_v).gather(1, actions_v)
state_action_vals = state_action_vals.squeeze(-1)
with torch.no_grad():
next_states_v = torch.as_tensor(next_states).to(device)
next_state_acts = net(next_states_v).max(1)[1]
next_state_acts = next_state_acts.unsqueeze(-1)
next_state_vals = tgt_net(next_states_v).gather(1, next_state_acts).squeeze(-1)
next_state_vals[done_mask] = 0.0
exp_sa_vals = next_state_vals.detach() * gamma + rewards_v
return nn.MSELoss()(state_action_vals, exp_sa_vals)
前面的代码片段以稍微不同的方式计算损失。在双重 DQN 版本中,我们使用我们的主训练网络计算下一个状态中要采取的最佳动作,但与此动作对应的值来自目标网络。
这部分可以通过将 next_states_v 与 states_v 合并,并仅调用我们的主网络一次来更快地实现,但这会使代码不太清晰。
函数的其余部分与之相同:我们遮盖已完成的剧集,并计算网络预测的 Q 值与近似 Q 值之间的均方误差(MSE)损失。
我们考虑的最后一个函数计算了我们保留状态的值:
@torch.no_grad()
def calc_values_of_states(states: np.ndarray, net: nn.Module, device: torch.device):
mean_vals = []
for batch in np.array_split(states, 64):
states_v = torch.tensor(batch).to(device)
action_values_v = net(states_v)
best_action_values_v = action_values_v.max(1)[0]
mean_vals.append(best_action_values_v.mean().item())
return np.mean(mean_vals)
这里并没有什么复杂的内容:我们只是将保留的状态数组划分成相等的块,并将每个块传递给网络以获得动作值。从这些值中,我们选择最大值的动作(对于每个状态),并计算这些值的平均值。由于我们的状态数组在整个训练过程中是固定的,并且这个数组足够大(在代码中,我们存储了 1,000 个状态),我们可以比较这两个 DQN 变体中的均值动态。03_dqn_double.py 文件中的其余部分几乎相同;两个不同之处是使用了我们调整过的损失函数,并且定期评估时保持了随机抽取的 1,000 个状态。这一过程发生在 process_batch 函数中:
if engine.state.iteration % EVAL_EVERY_FRAME == 0:
eval_states = getattr(engine.state, "eval_states", None)
if eval_states is None:
eval_states = buffer.sample(STATES_TO_EVALUATE)
eval_states = [
np.asarray(transition.state)
for transition in eval_states
]
eval_states = np.asarray(eval_states)
engine.state.eval_states = eval_states
engine.state.metrics["values"] = \
common.calc_values_of_states(eval_states, net, device)
结果
我的实验表明,使用常见的超参数时,双重 DQN 对奖励动态有负面影响。有时,双重 DQN 会导致更好的初始动态,训练的智能体学会如何更快地赢得更多的游戏,但达到最终奖励边界需要更长时间。你可以在其他游戏上进行自己的实验,或者尝试原始论文中的参数。
以下是实验中的奖励图表,其中双重 DQN 稍微优于基线版本:

图 8.8:双重 DQN 和基线 DQN 的奖励动态
除了标准度量外,示例还输出了保留状态集的均值,这些均值显示在图 8.9 中。

图 8.9:网络预测的保留状态的值
如你所见,基本的 DQN 会高估值,因此值在达到某一水平后会下降。相比之下,双重 DQN 则增长得更加稳定。在我的实验中,双重 DQN 对训练时间的影响很小,但这并不一定意味着双重 DQN 没有用,因为 Pong 是一个简单的环境。在更复杂的游戏中,双重 DQN 可能会给出更好的结果。
超参数调节
对于双重 DQN,超参数调节也不是特别成功。经过 30 次实验后,学习率和 gamma 的最佳值能在 412 局游戏内解决 Pong 问题,但这比基线 DQN 更差。
噪声网络
下一步改进是针对另一个 RL 问题:环境探索。我们将参考的论文叫做《Noisy networks for exploration》[For+17],它提出了一个非常简单的想法,即在训练过程中学习探索特征,而不是依赖与探索相关的独立调度。
一个经典的 DQN 通过选择随机动作来实现探索,这依赖于一个特别定义的超参数𝜖,该超参数会随着时间的推移从 1.0(完全随机动作)逐渐降低至一个较小的比率,例如 0.1 或 0.02。这个过程在简单的环境中表现良好,尤其是在游戏中没有太多非平稳性的短期回合内;但是即使是在这些简单的情况下,也需要调参来提高训练过程的效率。
在《噪声网络》论文中,作者提出了一个相当简单的解决方案,尽管如此,它仍然表现得非常有效。他们向网络的全连接层的权重中添加噪声,并在训练过程中通过反向传播调整这些噪声的参数。
这种方法不应与“网络决定在哪些地方探索更多”混淆,这是一种更加复杂的方法,并且得到了广泛的支持(例如,参见关于内在动机和基于计数的探索方法的文章[Ost+17], [Mar+17 ])。我们将在第二十一章讨论高级探索技术。
作者提出了两种添加噪声的方式,实验表明这两种方法都有效,但它们有不同的计算开销:
-
独立高斯噪声:对于每个全连接层的权重,我们都有一个从正态分布中抽取的随机值。噪声的参数μ和σ存储在该层内,并通过反向传播进行训练,就像训练标准线性层的权重一样。这种“噪声层”的输出计算方式与线性层相同。
-
分解高斯噪声:为了最小化需要采样的随机值数量,作者建议只保留两个随机向量:一个是输入大小,另一个是层的输出大小。然后,通过计算这两个向量的外积,创建层的随机矩阵。
实现
在 PyTorch 中,两种方法都可以非常直接地实现。我们需要做的是创建自定义的 nn.Linear 层,权重计算方式为 w[i,j] = μ[i,j] + σ[i,j] ⋅𝜖[i,j],其中μ和σ是可训练参数,𝜖∼𝒩(0,1)是每次优化步骤后从正态分布中采样的随机噪声。
本书的早期版本使用了我自己实现的这两种方法,但现在我们将直接使用我在第七章提到的流行 TorchRL 库中的实现。我们来看一下实现的相关部分(完整代码可以在 TorchRL 仓库中的 torchrl/modules/models/exploration.py 中找到)。以下是 NoisyLinear 类的构造函数,它创建了我们需要优化的所有参数:
class NoisyLinear(nn.Linear):
def __init__(
self, in_features: int, out_features: int, bias: bool = True,
device: Optional[DEVICE_TYPING] = None, dtype: Optional[torch.dtype] = None,
std_init: float = 0.1,
):
nn.Module.__init__(self)
self.in_features = int(in_features)
self.out_features = int(out_features)
self.std_init = std_init
self.weight_mu = nn.Parameter(
torch.empty(out_features, in_features, device=device,
dtype=dtype, requires_grad=True)
)
self.weight_sigma = nn.Parameter(
torch.empty(out_features, in_features, device=device,
dtype=dtype, requires_grad=True)
)
self.register_buffer(
"weight_epsilon",
torch.empty(out_features, in_features, device=device, dtype=dtype),
)
if bias:
self.bias_mu = nn.Parameter(
torch.empty(out_features, device=device, dtype=dtype, requires_grad=True)
)
self.bias_sigma = nn.Parameter(
torch.empty(out_features, device=device, dtype=dtype, requires_grad=True)
)
self.register_buffer(
"bias_epsilon", torch.empty(out_features, device=device, dtype=dtype),
)
else:
self.bias_mu = None
self.reset_parameters()
self.reset_noise()
在构造函数中,我们为μ和σ创建了矩阵。此实现继承自 torch.nn.Linear,但调用了 nn.Module.init()方法,因此不会创建标准 Linear 权重和偏置缓冲区。
为了使新的矩阵可训练,我们需要将它们的张量包装在 nn.Parameter 中。register_buffer 方法在网络中创建一个不会在反向传播期间更新的张量,但会由 nn.Module 机制处理(例如,它会通过 cuda()调用被复制到 GPU)。为层的偏置创建了额外的参数和缓冲区。最后,我们调用 reset_parameters()和 reset_noise()方法,执行创建的可训练参数和带有 epsilon 值的缓冲区的初始化。
在以下三个方法中,我们根据论文初始化可训练参数μ和σ:
def reset_parameters(self) -> None:
mu_range = 1 / math.sqrt(self.in_features)
self.weight_mu.data.uniform_(-mu_range, mu_range)
self.weight_sigma.data.fill_(self.std_init / math.sqrt(self.in_features))
if self.bias_mu is not None:
self.bias_mu.data.uniform_(-mu_range, mu_range)
self.bias_sigma.data.fill_(self.std_init / math.sqrt(self.out_features))
def reset_noise(self) -> None:
epsilon_in = self._scale_noise(self.in_features)
epsilon_out = self._scale_noise(self.out_features)
self.weight_epsilon.copy_(epsilon_out.outer(epsilon_in))
if self.bias_mu is not None:
self.bias_epsilon.copy_(epsilon_out)
def _scale_noise(
self, size: Union[int, torch.Size, Sequence]) -> torch.Tensor:
if isinstance(size, int):
size = (size,)
x = torch.randn(*size, device=self.weight_mu.device)
return x.sign().mul_(x.abs().sqrt_())
μ的矩阵初始化为均匀随机值。σ的初始值是常量,取决于层中神经元的数量。
对于噪声初始化,使用了因式分解高斯噪声——我们采样两个随机向量并计算外积以获得𝜖的矩阵。外积是一个线性代数操作,当两个大小相同的向量产生一个填充了每个向量元素组合乘积的方阵时就会发生。其余的很简单:我们重新定义权重和偏置属性,这些属性在 nn.Linear 层中是预期的,因此 NoisyLinear 可以在任何使用 nn.Linear 的地方使用:
@property
def weight(self) -> torch.Tensor:
if self.training:
return self.weight_mu + self.weight_sigma * self.weight_epsilon
else:
return self.weight_mu
@property
def bias(self) -> Optional[torch.Tensor]:
if self.bias_mu is not None:
if self.training:
return self.bias_mu + self.bias_sigma * self.bias_epsilon
else:
return self.bias_mu
else:
return None
这个实现很简单,但有一个非常微妙的细节——𝜖值在每次优化步骤后并不会更新(文档中没有提到这一点)。这个问题已经在 TorchRL 仓库中报告,但在当前稳定版本中,我们必须显式调用 reset_noise()方法。希望这个问题能得到修复,NoisyLinear 层能够自动更新噪声。
从实现角度来看,就是这样。现在我们需要做的就是将经典的 DQN 转换为噪声网络变体,只需将 nn.Linear(这是我们 DQN 网络中的最后两层)替换为 NoisyLinear 层。当然,您需要移除与 epsilon-greedy 策略相关的所有代码。
为了在训练期间检查内部噪声水平,我们可以监控噪声层的信噪比(SNR),其计算方式为 RMS(μ)∕RMS(σ),其中 RMS 是相应权重的均方根。在我们的例子中,SNR 显示噪声层的静态成分比注入噪声大多少倍。
结果
训练后,TensorBoard 图表显示出更好的训练动态。模型在 250 局游戏后达到了平均得分 18,相比基准 DQN 的 350 分有所提升。但由于噪声网络需要额外的操作,它们的训练速度稍慢(194 FPS 对比基准的 240 FPS),所以在时间上,差异不那么引人注目。但仍然,结果看起来很好:

图 8.10:与基准 DQN 相比的噪声网络
在查看信噪比(SNR)图表(图 8.11)后,您可能会注意到两个层的噪声水平都迅速下降了。

图 8.11:第 1 层(左)和第 2 层(右)的 SNR 变化
第一层的噪声比率已经从
变化到接近
。第二层更有趣,因为它的噪声水平从最初的
降低到了
,但在 450K 帧之后(大致与原始奖励接近 20 分时的时间相同),最后一层的噪声水平开始再次上升,推动代理更深入地探索环境。这是非常有意义的,因为在达到高分水平后,代理基本上已经知道如何玩得很好,但仍然需要“打磨”自己的行动,以进一步提高结果。
超参数调优
调优后,最佳参数集能够在 273 轮后解决游戏问题,相比基准方法有了改进:
learning_rate=7.142520950425814e-05,
gamma=0.99,
以下是调优后的基准 DQN 与调优后的噪声网络奖励动态和步数的比较图:

图 8.12:调优后的基准 DQN 与调优后的噪声网络比较
在两张图中,我们看到噪声网络带来的改进:达到 21 分所需的游戏次数减少,并且在训练过程中,游戏的步数减少。
优先级回放缓冲区
下一项关于如何改进 DQN 训练的非常有用的想法是在 2015 年提出的,出现在论文《优先经验回放》[Sch+15]中。这种方法尝试通过根据训练损失对回放缓冲区中的样本进行优先级排序,从而提高样本的效率。
基本的 DQN 使用回放缓冲区来打破我们回合中即时转移之间的相关性。正如我们在第六章讨论的那样,我们在回合中经历的示例会高度相关,因为大多数时候,环境是“平滑”的,并且根据我们的行动变化不大。然而,随机梯度下降(SGD)方法假设我们用于训练的数据具有独立同分布(iid)特性。为了解决这个问题,经典 DQN 方法使用了一个大容量的转移缓冲区,并通过随机均匀采样来获取下一个训练批次。
论文的作者质疑了这种均匀随机采样策略,并证明通过根据训练损失给缓冲区样本分配优先级,并按优先级比例采样缓冲区样本,我们可以显著提高 DQN 的收敛性和策略质量。该方法的基本思想可以用“对令你感到惊讶的数据进行更多训练”来解释。这里的关键点是保持在“异常”样本上进行训练与在缓冲区其余部分上训练之间的平衡。如果我们仅关注缓冲区的一小部分样本,可能会丧失独立同分布(i.i.d.)特性,简单地在这个子集上过拟合。
从数学角度来看,缓冲区中每个样本的优先级计算公式为
,其中 p[i] 是缓冲区中第 i 个样本的优先级,α 是表示我们对优先级给予多少重视的参数。如果 α = 0,我们的采样将像经典的 DQN 方法一样变得均匀。较大的 α 值则会更加强调高优先级的样本。因此,这是另一个需要调节的超参数,论文中建议的 α 初始值为 0.6。
论文中提出了几种定义优先级的选项,其中最流行的是将其与这个特定样本在贝尔曼更新中的损失成比例。新加入缓冲区的样本需要被赋予一个最大优先级值,以确保它们能尽快被采样。
通过调整样本的优先级,我们实际上是在数据分布中引入偏差(我们比其他转换更频繁地采样某些转换),如果希望 SGD 能够有效工作,我们需要对这种偏差进行补偿。为了得到这个结果,研究的作者使用了样本权重,这些权重需要与单个样本的损失相乘。每个样本的权重值定义为 w[i] = (N ⋅P(i))^(−β),其中 β 是另一个超参数,应该在 0 和 1 之间。
当 β = 1 时,采样引入的偏差得到了完全补偿,但作者表明,开始时将 β 设置在 0 到 1 之间,并在训练过程中逐渐增加到 1,有利于收敛。
实现
为了实现这个方法,我们必须在代码中做出一些特定的修改:
-
首先,我们需要一个新的重放缓冲区,它将跟踪优先级、根据优先级采样批次、计算权重,并在损失值已知后让我们更新优先级。
-
第二个变化将是损失函数本身。现在我们不仅需要为每个样本引入权重,还需要将损失值回传到重放缓冲区,以调整采样转换的优先级。
在主模块 Chapter08/05_dqn_prio_replay.py 中,我们已经实现了所有这些修改。为了简化,新的优先级重放缓冲区类使用与我们之前的重放缓冲区非常相似的存储方案。不幸的是,新的优先级要求使得无法以 𝒪(1) 时间复杂度实现采样(换句话说,采样时间将随着缓冲区大小的增加而增长)。如果我们使用简单的列表,每次采样新的一批样本时,我们需要处理所有优先级,这使得我们的采样时间复杂度与缓冲区大小成正比,达到 𝒪(N)。如果我们的缓冲区很小,比如 100k 样本,这并不是什么大问题,但对于现实中的大型缓冲区,样本数量达到数百万时,这可能成为一个问题。有其他支持在 𝒪(log N) 时间内进行高效采样的存储方案,例如,使用线段树数据结构。各种库中都有这些优化后的缓冲区版本——例如,TorchRL 中就有。
PTAN 库还提供了一个高效的优先级重放缓冲区,位于类 ptan.experience.PrioritizedReplayBuffer 中。您可以更新示例,使用更高效的版本,并检查其对训练性能的影响。
但是,现在让我们先看看朴素版本,其源代码可以在 lib/dqn_extra.py 中找到。
在开始时,我们定义了β增加率的参数:
BETA_START = 0.4
BETA_FRAMES = 100_000
我们的β将在前 100k 帧中从 0.4 变化到 1.0。
接下来是优先级重放缓冲区类:
class PrioReplayBuffer(ExperienceReplayBuffer):
def __init__(self, exp_source: ExperienceSource, buf_size: int,
prob_alpha: float = 0.6):
super().__init__(exp_source, buf_size)
self.experience_source_iter = iter(exp_source)
self.capacity = buf_size
self.pos = 0
self.buffer = []
self.prob_alpha = prob_alpha
self.priorities = np.zeros((buf_size, ), dtype=np.float32)
self.beta = BETA_START
优先级重放缓冲区的类继承自 PTAN 中的简单重放缓冲区,该缓冲区将样本存储在一个循环缓冲区中(它允许我们保持固定数量的条目,而无需重新分配列表)。我们的子类使用 NumPy 数组来保持优先级。
需要定期调用 update_beta()方法,以根据计划增加β值。populate()方法需要从 ExperienceSource 对象中提取给定数量的转换并将其存储在缓冲区中:
def update_beta(self, idx: int) -> float:
v = BETA_START + idx * (1.0 - BETA_START) / BETA_FRAMES
self.beta = min(1.0, v)
return self.beta
def populate(self, count: int):
max_prio = self.priorities.max(initial=1.0)
for _ in range(count):
sample = next(self.experience_source_iter)
if len(self.buffer) < self.capacity:
self.buffer.append(sample)
else:
self.buffer[self.pos] = sample
self.priorities[self.pos] = max_prio
self.pos = (self.pos + 1) % self.capacity
由于我们的转换存储实现为循环缓冲区,因此我们在此缓冲区中有两种不同的情况:
-
当我们的缓冲区尚未达到最大容量时,我们只需要将新的转换追加到缓冲区中。
-
如果缓冲区已经满了,我们需要覆盖最旧的转换,该转换由 pos 类字段跟踪,并调整该位置为缓冲区大小的模。
在示例方法中,我们需要使用我们的α超参数将优先级转换为概率:
def sample(self, batch_size: int) -> tt.Tuple[
tt.List[ExperienceFirstLast], np.ndarray, np.ndarray
]:
if len(self.buffer) == self.capacity:
prios = self.priorities
else:
prios = self.priorities[:self.pos]
probs = prios ** self.prob_alpha
probs /= probs.sum()
然后,使用这些概率,我们从缓冲区中采样,以获得一批样本:
indices = np.random.choice(len(self.buffer), batch_size, p=probs)
samples = [self.buffer[idx] for idx in indices]
最后一步,我们计算批处理中样本的权重:
total = len(self.buffer)
weights = (total * probs[indices]) ** (-self.beta)
weights /= weights.max()
return samples, indices, np.array(weights, dtype=np.float32)
该函数返回三个对象:批处理、索引和权重。批处理样本的索引是更新采样项目优先级所必需的。
优先级重放缓冲区的最后一个函数允许我们更新处理过的批次的新优先级:
def update_priorities(self, batch_indices: np.ndarray, batch_priorities: np.ndarray):
for idx, prio in zip(batch_indices, batch_priorities):
self.priorities[idx] = prio
调用者有责任在批处理的损失计算后使用此函数。
我们示例中的下一个自定义函数是损失计算。由于 PyTorch 中的 MSELoss 类不支持权重(这是可以理解的,因为 MSE 是回归问题中使用的损失,但样本加权通常用于分类损失),我们需要计算 MSE 并显式地将结果与权重相乘:
def calc_loss(batch: tt.List[ExperienceFirstLast], batch_weights: np.ndarray,
net: nn.Module, tgt_net: nn.Module, gamma: float,
device: torch.device) -> tt.Tuple[torch.Tensor, np.ndarray]:
states, actions, rewards, dones, next_states = common.unpack_batch(batch)
states_v = torch.as_tensor(states).to(device)
actions_v = torch.tensor(actions).to(device)
rewards_v = torch.tensor(rewards).to(device)
done_mask = torch.BoolTensor(dones).to(device)
batch_weights_v = torch.tensor(batch_weights).to(device)
actions_v = actions_v.unsqueeze(-1)
state_action_vals = net(states_v).gather(1, actions_v)
state_action_vals = state_action_vals.squeeze(-1)
with torch.no_grad():
next_states_v = torch.as_tensor(next_states).to(device)
next_s_vals = tgt_net(next_states_v).max(1)[0]
next_s_vals[done_mask] = 0.0
exp_sa_vals = next_s_vals.detach() * gamma + rewards_v
l = (state_action_vals - exp_sa_vals) ** 2
losses_v = batch_weights_v * l
return losses_v.mean(), (losses_v + 1e-5).data.cpu().numpy()
在损失计算的最后部分,我们实现了相同的 MSE 损失,但显式地写出了我们的表达式,而不是使用库函数。这样可以考虑样本的权重,并为每个样本保持单独的损失值。这些值将传递给优先级重放缓冲区以更新优先级。每个损失值都会加上一个小值,以处理损失值为零的情况,这种情况会导致重放缓冲区中条目的优先级为零。
在我们程序的主要部分,只有两个更新:回放缓冲区的创建和我们的处理函数。缓冲区创建很简单,所以我们只需要看一下新的处理函数:
def process_batch(engine, batch_data):
batch, batch_indices, batch_weights = batch_data
optimizer.zero_grad()
loss_v, sample_prios = calc_loss(
batch, batch_weights, net, tgt_net.target_model,
gamma=params.gamma, device=device)
loss_v.backward()
optimizer.step()
buffer.update_priorities(batch_indices, sample_prios)
epsilon_tracker.frame(engine.state.iteration)
if engine.state.iteration % params.target_net_sync == 0:
tgt_net.sync()
return {
"loss": loss_v.item(),
"epsilon": selector.epsilon,
"beta": buffer.update_beta(engine.state.iteration),
}
这里有几个变化:
-
现在我们的批次包含三种实体:数据批次、采样项的索引和样本的权重。
-
我们称之为新的损失函数,它接受权重并返回额外项的优先级。这些优先级会传递给
buffer.update_priorities()函数,以便重新调整我们采样的项的优先级。 -
我们调用缓冲区的
update_beta()方法,根据计划改变 beta 参数。
结果
这个例子可以像往常一样训练。根据我的实验,优先级回放缓冲区几乎花费了相同的时间来解决环境:差不多一个小时。但它花费了更少的训练迭代和回合。因此,墙时钟时间几乎相同,主要是由于回放缓冲区效率较低,当然,这可以通过适当的 𝒪(log N) 实现来解决缓冲区的问题。
这里是基线与优先级回放缓冲区(右侧)奖励动态的比较。横坐标是游戏回合:

图 8.13:与基础 DQN 比较的优先级回放缓冲区奖励动态
在 TensorBoard 图表中还可以看到另一个不同之处,就是优先级回放缓冲区的损失值明显较低。以下图表展示了这一比较:

图 8.14:训练过程中损失的比较
较低的损失值也是可以预期的,并且是我们实现有效的良好迹象。优先级的核心思想是更多地训练那些损失值较高的样本,使得训练更加高效。但这里有一个风险:训练中的损失值并不是优化的主要目标;我们可以有非常低的损失值,但由于缺乏探索,最终学习到的策略可能远未达到最优。
超参数调优
对优先级回放缓冲区的超参数调优是通过为 α 引入一个额外的参数进行的,α 的值从 0.3 到 0.9(步长为 0.1)之间的固定列表中采样。最佳组合能够在 330 个回合后解决 Pong 问题,并且 α = 0.6(与论文中的相同):
learning_rate=8.839010139505506e-05,
gamma=0.99,
以下是比较调整后的基线 DQN 与调整后的优先级回放缓冲区的图表:

图 8.15:调整后的基线 DQN 和调整后的优先级回放缓冲区比较
在这里,我们看到优先级回放缓冲区的游戏玩法改进更快,但达到 21 分所需的游戏数量几乎相同。在右边的图表(以游戏步骤为单位)中,优先级回放缓冲区的表现也略优。
对抗 DQN
这一改进于 2015 年在论文《Dueling Network Architectures for Deep Reinforcement Learning》中提出 [Wan+16]。这篇论文的核心观点是,网络试图近似的 Q 值 Q(s,a) 可以分为两个部分:状态的值 V (s) 和该状态下动作的优势 A(s,a)。
你之前见过 V (s) 这一量,它是第五章中值迭代方法的核心。它等于从该状态出发可以获得的折扣预期奖励。优势 A(s,a) 旨在弥合 V (s) 和 Q(s,a) 之间的差距,因为根据定义,Q(s,a) = V (s) + A(s,a)。换句话说,优势 A(s,a) 只是增量,表示从该状态采取某一特定动作带来的额外奖励。优势可以是正值也可以是负值,通常可以具有任何大小。例如,在某个临界点,选择某一动作而非另一动作可能会让我们失去很多总奖励。
Dueling 论文的贡献在于明确区分了网络架构中的价值和优势,这带来了更好的训练稳定性、更快的收敛速度以及在 Atari 基准测试中更好的结果。与经典 DQN 网络的架构差异如下图所示。经典的 DQN 网络(上图)从卷积层提取特征,并通过全连接层将其转换为 Q 值向量,每个动作对应一个 Q 值。另一方面,Dueling DQN(下图)从卷积层提取特征,并通过两条独立的路径处理它们:一条路径负责预测 V (s),即一个单一的数值,另一条路径预测各个动作的优势值,维度与经典情况下的 Q 值相同。之后,我们将 V (s) 加到每个 A(s,a) 的值上,从而得到 Q(s,a),这个值像通常的 Q 值一样被使用并训练。图 8.16(来自论文)比较了基本的 DQN 和 Dueling DQN:

图 8.16:基本的 DQN(上图)和 Dueling 架构(下图)
这些架构的变化并不足以确保网络按我们希望的方式学习 V (s) 和 A(s,a)。例如,网络可能预测某个状态的 V (s) = 0 和 A(s) = [1,2,3,4],这种情况是完全错误的,因为预测的 V (s) 不是该状态的期望值。我们还需要设定一个额外的约束:我们希望任何状态下优势的均值为零。在这种情况下,前述例子的正确预测应该是 V (s) = 2.5 和 A(s) = [−1.5,−0.5,0.5,1.5]。
这个约束可以通过多种方式强制执行,例如通过损失函数;但在 Dueling 论文中,作者提出了一种非常优雅的解决方案:从网络的 Q 表达式中减去优势的均值,这样可以有效地将优势的均值拉至零:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq36.png)
这使得将经典 DQN 转变为双 DQN 的修改非常简单:只需要改变网络架构,而不影响实现的其他部分。
实现
完整的示例可以在 Chapter08/06_dqn_dueling.py 中找到。所有的改动都在网络架构中,因此这里我只展示网络类(位于 lib/dqn_extra.py 模块中)。
卷积部分与之前完全相同:
class DuelingDQN(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...], n_actions: int):
super(DuelingDQN, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 32, kernel_size=8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU(),
nn.Flatten()
)
我们没有定义一个单一的完全连接层路径,而是创建了两种不同的变换:一种用于优势,另一种用于价值预测:
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.fc_adv = nn.Sequential(
nn.Linear(size, 256),
nn.ReLU(),
nn.Linear(256, n_actions)
)
self.fc_val = nn.Sequential(
nn.Linear(size, 256),
nn.ReLU(),
nn.Linear(256, 1)
)
此外,为了保持模型中的参数数量与原始网络相当,两条路径中的内部维度从 512 减少到 256。得益于 PyTorch 的表达能力,forward()函数中的变化也非常简单:
def forward(self, x: torch.ByteTensor):
adv, val = self.adv_val(x)
return val + (adv - adv.mean(dim=1, keepdim=True))
def adv_val(self, x: torch.ByteTensor):
xx = x / 255.0
conv_out = self.conv(xx)
return self.fc_adv(conv_out), self.fc_val(conv_out)
在这里,我们计算批次样本的价值和优势并将它们加在一起,减去优势的均值以获得最终的 Q 值。一个微妙但重要的区别是在计算张量的第二维度上的均值,这会为我们批次中的每个样本生成一个均值优势的向量。
结果
训练一个对抗 DQN 后,我们可以将其与经典 DQN 在 Pong 基准测试中的收敛性进行比较。与基础 DQN 版本相比,对抗架构的收敛速度更快:

图 8.17:对抗 DQN 与基线版本的奖励动态比较
我们的示例还输出了对于一组固定状态的优势和价值,如下图所示。它们符合我们的预期:优势与零差异不大,但随着时间推移,价值在不断提高(并且类似于 Double DQN 部分中的值):

图 8.18:固定状态集上的均值优势(左)和价值(右)
超参数调整
超参数的调整并未带来很大收获。在 30 次调整迭代后,没有任何学习率和 gamma 的组合能够比常用参数组合更快收敛。
类别化 DQN
我们的 DQN 改进工具箱中的最后一种方法,也是最复杂的一种,来自 DeepMind 于 2017 年 6 月发表的论文《强化学习的分布式视角》[BDM17]。尽管这篇论文已经有几年历史,但它仍然非常相关,且这一领域的研究仍在持续进行中。2023 年出版的《分布式强化学习》一书中,作者们更详细地描述了该方法[BDR23]。
在论文中,作者质疑了 Q 学习中的基本元素——Q 值——并尝试用更通用的 Q 值概率分布来替代它们。让我们来理解这个概念。Q 学习和价值迭代方法都使用表示动作或状态的数值,展示从某个状态或某个动作和状态组合中能够获得多少总奖励。然而,将所有未来可能的奖励压缩成一个数字,实际可行吗?在复杂的环境中,未来可能是随机的,会给我们带来不同的值和不同的概率。
比如,想象一下你每天从家里开车去上班的通勤情境。大多数时候,交通不算太堵,通常你能在大约 30 分钟内到达目的地。这并不一定是准确的 30 分钟,但平均下来是 30 分钟。偶尔,也会发生一些情况,比如道路维修或事故,导致交通堵塞,你的通勤时间可能是平常的三倍。你的通勤时间的概率可以用“通勤时间”这一随机变量的分布来表示,分布如下图所示:

图 8.19:通勤时间的概率分布
现在,假设你有另一种上班的方式:坐火车。虽然需要稍微多花点时间,因为你需要从家里到火车站,再从火车站到办公室,但相比开车,火车更可靠(在一些国家,如德国,情况可能不同,但我们假设使用瑞士的火车作为例子)。比如说,火车的通勤时间平均是 40 分钟,偶尔会有火车延误的情况,通常会增加 20 分钟的额外时间。火车通勤时间的分布如下图所示:

图 8.20:开车通勤时间的概率分布
假设现在我们要做出通勤方式的选择。如果我们只知道开车和火车的平均通勤时间,那么开车看起来更有吸引力,因为开车的平均通勤时间是 35.43 分钟,比火车的 40.54 分钟要短。
然而,如果我们看完整的分布图,我们可能会选择坐火车,因为即使在最坏的情况下,火车的通勤时间也只有一个小时,而开车则是一个小时 30 分钟。换成统计语言,开车的分布具有更高的方差,因此在你必须在 60 分钟内到达办公室的情况下,火车更为合适。
在马尔可夫决策过程(MDP)场景中,情况变得更加复杂,因为决策需要按顺序进行,而且每个决策可能会影响未来的情况。比如在通勤例子中,可能是你需要安排一个重要会议的时间,而这个安排可能会受到你选择的通勤方式的影响。在这种情况下,使用均值奖励值可能会丧失关于环境动态的很多信息。
完全相同的观点是由《强化学习的分布式视角》一文的作者提出的[9]。为什么我们要限制自己,试图为一个动作预测一个平均值,而忽略了其潜在值可能具有复杂的分布?也许直接处理分布会对我们有所帮助。论文中展示的结果表明,事实上,这个想法可能是有帮助的,但代价是引入了更复杂的方法。我在这里不会给出严格的数学定义,但总体思路是为每个动作预测值的分布,类似于我们汽车/火车例子中的分布。作为下一步,作者们展示了贝尔曼方程可以推广到分布的情况,并且它的形式为 Z(x,a)
R(x,a) + γZ(x′,a′),这与我们熟悉的贝尔曼方程非常相似,但现在 Z(x,a)和 R(x,a)是概率分布,而不是单一数值。符号 A
B 表示分布 A 和 B 的相等。
得到的分布可以用来训练我们的网络,以便为给定状态下的每个动作提供更好的值分布预测,方法与 Q 学习完全相同。唯一的区别在于损失函数,现在必须用适合分布比较的内容替代它。这里有几个可用的替代方法,例如,Kullback-Leibler(KL)散度(或交叉熵损失),它通常用于分类问题,或者 Wasserstein 度量。在论文中,作者为 Wasserstein 度量提供了理论依据,但在实践中尝试应用时,遇到了一些限制。所以,最终论文中使用了 KL 散度。
实现
如前所述,这个方法相当复杂,所以我花了一些时间来实现它并确保其正常工作。完整代码在 Chapter08/07_dqn_distrib.py 中,其中使用了 lib/dqn_extra.py 中的 distr_projection 函数来执行分布投影。在检查之前,我需要先简单说明一下实现逻辑。
方法的核心部分是我们正在逼近的概率分布。有很多方法可以表示这个分布,但论文的作者选择了一个相当通用的参数化分布,基本上是将一组固定数值均匀分布在一个数值范围上。这个数值范围应该覆盖可能的累计折扣奖励范围。在论文中,作者做了多个不同数量的原子实验,但最佳结果是在值的范围从 Vmin=-10 到 Vmax=10 中将范围划分为 N_ATOMS=51 个区间时获得的。
对于每个原子(我们有 51 个),我们的网络预测未来折扣值落在此原子范围内的概率。方法的核心部分是代码,它执行下一个状态最佳动作的分布收缩,使用 gamma,向分布中添加局部奖励,并将结果投影回到原始原子中。这个逻辑在 dqn_extra.distr_projection 函数中实现。一开始,我们分配了一个数组来保存投影结果:
def distr_projection(next_distr: np.ndarray, rewards: np.ndarray,
dones: np.ndarray, gamma: float):
batch_size = len(rewards)
proj_distr = np.zeros((batch_size, N_ATOMS), dtype=np.float32)
delta_z = (Vmax - Vmin) / (N_ATOMS - 1)
这个函数接受形状为(batch_size, N_ATOMS)的分布批次,奖励数组,已完成回合的标志以及我们的超参数:Vmin, Vmax, N_ATOMS 和 gamma。delta_z 变量表示我们值范围中每个原子的宽度。
在以下代码中,我们遍历原始分布中的每个原子,并计算该原子将由 Bellman 操作符投影到的位置,同时考虑我们的值范围:
for atom in range(N_ATOMS):
v = rewards + (Vmin + atom * delta_z) * gamma
tz_j = np.minimum(Vmax, np.maximum(Vmin, v))
例如,第一个原子,索引为 0,对应的值为 Vmin=-10,但对于奖励 +1 的样本,将投影到值 −10 ⋅ 0.99 + 1 = −8.9。换句话说,它将向右移动(假设 gamma=0.99)。如果该值超出了由 Vmin 和 Vmax 给出的值范围,我们会将其裁剪到边界内。
在下一行,我们计算样本投影到的原子编号:
b_j = (tz_j - Vmin) / delta_z
当然,样本可以投影到原子之间。在这种情况下,我们将源原子中的值分配到其之间的两个原子中。这个分配需要小心处理,因为目标原子可能恰好落在某个原子的位置。在这种情况下,我们只需要将源分布值添加到目标原子。
以下代码处理当投影原子正好落在目标原子上的情况。否则,b_j 将不是整数值,变量 l 和 u(分别对应投影点下方和上方的原子索引):
l = np.floor(b_j).astype(np.int64)
u = np.ceil(b_j).astype(np.int64)
eq_mask = u == l
proj_distr[eq_mask, l[eq_mask]] += next_distr[eq_mask, atom]
当投影点落在原子之间时,我们需要将源原子的概率分配到下方和上方的原子之间。这通过以下代码中的两行来实现:
ne_mask = u != l
proj_distr[ne_mask, l[ne_mask]] += next_distr[ne_mask, atom] * (u - b_j)[ne_mask]
proj_distr[ne_mask, u[ne_mask]] += next_distr[ne_mask, atom] * (b_j - l)[ne_mask]
当然,我们需要正确处理回合的最终过渡。在这种情况下,我们的投影不应考虑下一个分布,而应仅具有与获得的奖励对应的 1 的概率。
然而,我们需要再次考虑原子,并在奖励值落在原子之间时,正确地分配概率。此情况由以下代码分支处理,该分支会为已设置“done”标志的样本将结果分布归零,然后计算最终的投影结果:
if dones.any():
proj_distr[dones] = 0.0
tz_j = np.minimum(Vmax, np.maximum(Vmin, rewards[dones]))
b_j = (tz_j - Vmin) / delta_z
l = np.floor(b_j).astype(np.int64)
u = np.ceil(b_j).astype(np.int64)
eq_mask = u == l
eq_dones = dones.copy()
eq_dones[dones] = eq_mask
if eq_dones.any():
proj_distr[eq_dones, l[eq_mask]] = 1.0
ne_mask = u != l
ne_dones = dones.copy()
ne_dones[dones] = ne_mask
if ne_dones.any():
proj_distr[ne_dones, l[ne_mask]] = (u - b_j)[ne_mask]
proj_distr[ne_dones, u[ne_mask]] = (b_j - l)[ne_mask]
return proj_distr
为了给你演示这个函数的作用,让我们看一下通过该函数处理的人工制作的分布图(图表 8.21)。我用它们来调试函数并确保其按预期工作。这些检查的代码在 Chapter08/adhoc/distr_test.py 中。

图表 8.21:应用于正态分布的概率分布变换的样本
图表顶部的 8.21(名为源)是一个正态分布,其中μ = 0,σ = 3。第二张图(名为投影)是从分布投影得到的,γ = 0.9,并且向右偏移,reward=2。
在我们传递 done=True 的情况下,使用相同数据,结果将会有所不同,并显示在图表 8.22 中。在这种情况下,源分布将被完全忽略,结果将只有预期奖励。

图表 8.22:在剧集最后一步的分布投影
该方法的实现位于 Chapter08/07_dqn_distrib.py 中,它具有一个可选的命令行参数--img-path。如果给出此选项,它必须是一个目录,在训练期间将以固定状态的概率分布存储图像。这对于监视模型如何从开始的均匀概率收敛到更多尖峰概率质量很有用。我的实验中的示例图像显示在图表 8.24 和图表 8.25 中。
我这里只展示实现的基本部分。方法的核心部分,distr_projection 函数已经覆盖过了,它是最复杂的部分。现在缺失的是网络架构和修改的损失函数,我们将在这里描述它们。
让我们从网络开始,该网络位于 lib/dqn_extra.py 中,在 DistributionalDQN 类中:
Vmax = 10
Vmin = -10
N_ATOMS = 51
DELTA_Z = (Vmax - Vmin) / (N_ATOMS - 1)
class DistributionalDQN(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...], n_actions: int):
super(DistributionalDQN, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 32, kernel_size=8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU(),
nn.Flatten()
)
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.fc = nn.Sequential(
nn.Linear(size, 512),
nn.ReLU(),
nn.Linear(512, n_actions * N_ATOMS)
)
sups = torch.arange(Vmin, Vmax + DELTA_Z, DELTA_Z)
self.register_buffer("supports", sups)
self.softmax = nn.Softmax(dim=1)
主要区别在于全连接层的输出。现在它输出 n_actions * N_ATOMS 值的向量,即 6×51 = 306 对于 Pong。对于每个动作,它需要预测 51 个原子上的概率分布。每个原子(称为支持)具有一个值,该值对应于特定的奖励。这些原子的奖励均匀分布在-10 到 10 之间,这给出了步长为 0.4 的网格。这些支持存储在网络的缓冲区中。
forward()方法将预测的概率分布作为 3D 张量(批次,动作和支持)返回:
def forward(self, x: torch.ByteTensor) -> torch.Tensor:
batch_size = x.size()[0]
xx = x / 255
fc_out = self.fc(self.conv(xx))
return fc_out.view(batch_size, -1, N_ATOMS)
def both(self, x: torch.ByteTensor) -> tt.Tuple[torch.Tensor, torch.Tensor]:
cat_out = self(x)
probs = self.apply_softmax(cat_out)
weights = probs * self.supports
res = weights.sum(dim=2)
return cat_out, res
除了 forward(),我们还定义了 both()方法,它一次计算原子和 Q 值的概率分布。
网络还定义了几个辅助函数,以简化 Q 值的计算并在概率分布上应用 softmax:
def qvals(self, x: torch.ByteTensor) -> torch.Tensor:
return self.both(x)[1]
def apply_softmax(self, t: torch.Tensor) -> torch.Tensor:
return self.softmax(t.view(-1, N_ATOMS)).view(t.size())
最后的变化是新的损失函数,它必须应用分布投影,而不是贝尔曼方程,并计算预测分布与投影分布之间的 KL 散度:
def calc_loss(batch: tt.List[ExperienceFirstLast], net: dqn_extra.DistributionalDQN,
tgt_net: dqn_extra.DistributionalDQN, gamma: float,
device: torch.device) -> torch.Tensor:
states, actions, rewards, dones, next_states = common.unpack_batch(batch)
batch_size = len(batch)
states_v = torch.as_tensor(states).to(device)
actions_v = torch.tensor(actions).to(device)
next_states_v = torch.as_tensor(next_states).to(device)
# next state distribution
next_distr_v, next_qvals_v = tgt_net.both(next_states_v)
next_acts = next_qvals_v.max(1)[1].data.cpu().numpy()
next_distr = tgt_net.apply_softmax(next_distr_v)
next_distr = next_distr.data.cpu().numpy()
next_best_distr = next_distr[range(batch_size), next_acts]
proj_distr = dqn_extra.distr_projection(next_best_distr, rewards, dones, gamma)
distr_v = net(states_v)
sa_vals = distr_v[range(batch_size), actions_v.data]
state_log_sm_v = F.log_softmax(sa_vals, dim=1)
proj_distr_v = torch.tensor(proj_distr).to(device)
loss_v = -state_log_sm_v * proj_distr_v
return loss_v.sum(dim=1).mean()
上面的代码并不复杂;它只是准备调用 distr_projection 和 KL 散度,定义如下:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq38.png)
为了计算概率的对数,我们使用 PyTorch 的 log_softmax 函数,它以数值稳定的方式将对数和 softmax 结合在一起。
结果
根据我的实验,分布式版本的 DQN 收敛速度稍慢且不太稳定,低于原始的 DQN,这并不令人惊讶,因为网络输出现在大了 51 倍,且损失函数发生了变化。如果没有进行超参数调优(将在下一小节中描述),分布式版本需要多 20% 的回合数才能解决游戏。
另一个可能重要的因素是,Pong 游戏太简单,难以得出结论。在《A Distributional Perspective》一文中,作者报告了当时(2017 年出版)大部分 Atari 基准游戏的最先进得分(Pong 并不在其中)。
以下是比较分布式 DQN 奖励动态和损失的图表。正如你所看到的,分布式方法的奖励动态比基准 DQN 差:

图 8.23:奖励动态(左)和损失下降(右)
可能有趣的是,观察训练过程中概率分布的动态。如果你使用--img-path参数(提供目录名)开始训练,训练过程将会保存一个固定状态集的概率分布图。例如,以下图示展示了训练开始时(经过 30k 帧)一个状态下所有六个动作的概率分布:

图 8.24:训练开始时的概率分布
所有的分布都很宽(因为网络还未收敛),中间的峰值对应于网络期望从其动作中获得的负奖励。经过 500k 帧训练后的相同状态如下图所示:

图 8.25:训练网络产生的概率分布
现在我们可以看到,不同的动作有不同的分布。第一个动作(对应于 NOOP,即不做任何动作)其分布向左偏移,因此在该状态下通常什么也不做会导致失败。第五个动作,即 RIGHTFIRE,其均值向右偏移,因此这个动作会带来更好的得分。
超参数调优
超参数调优的效果并不显著。经过 30 次调优迭代后,没有任何学习率和 gamma 的组合能够比常规的参数集更快地收敛。
综合所有内容
你现在已经看到了论文《Rainbow: Combining Improvements in Deep Reinforcement Learning》中提到的所有 DQN 改进,但这些改进是以递增方式完成的,(我希望)这种方式有助于理解每个改进的思路和实现。论文的主要内容是将这些改进结合起来并检查结果。在最终的示例中,我决定将类别 DQN 和双重 DQN 从最终系统中排除,因为它们在我们的试验环境中并未带来太大的改进。如果你愿意,你可以将它们添加进来并尝试使用不同的游戏。完整的示例代码可以在 Chapter08/08_dqn_rainbow.py 中找到。
首先,我们需要定义我们的网络架构以及为其做出贡献的方法:
-
对抗 DQN:我们的网络将有两个独立的路径,一个用于状态分布的价值,另一个用于优势分布。在输出端,这两个路径将相加,从而提供动作的最终价值概率分布。为了强制优势分布具有零均值,我们将在每个原子中减去具有均值优势的分布。
-
噪声网络:我们在价值和优势路径中的线性层将是 nn.Linear 的噪声变体。
除了网络架构的变化,我们还将使用优先回放缓冲区来保持环境转移,并按比例从 MSE 损失中采样。
最后,我们将展开 Bellman 方程,使用 n 步法。
我不打算重复所有的代码,因为前面的章节已经给出了各个方法,而且结合这些方法的最终结果应该是显而易见的。如果你遇到任何问题,可以在 GitHub 上找到代码。
结果
以下是与基准 DQN 比较的平滑奖励和步骤计数图表。在这两者中,我们都可以看到游戏数量方面的显著改善:

图 8.26:基准 DQN 与组合系统的比较
除了平均奖励,值得检查一下原始奖励图表,结果比平滑奖励更为戏剧化。它显示我们的系统能够非常迅速地从负面结果跳跃到正面——仅仅经过 100 场游戏,它几乎赢得了每一场比赛。因此,我们又花了 100 场比赛才使平滑奖励达到 +18:

图 8.27:组合系统的原始奖励
作为一个缺点,组合系统的速度比基准系统慢,因为我们采用了更复杂的神经网络架构和优先回放缓冲区。FPS 图表显示,组合系统从 170 FPS 开始,因𝒪(n)缓冲区复杂性而降至 130 FPS:

图 8.28:性能比较(以每秒帧数计)
超参数调优
调优仍然像之前那样进行,且在“解决游戏前玩过的游戏数”方面,能够进一步提升组合系统的训练效果。以下是调优后的基线 DQN 与调优后的组合系统的比较图表:

图 8.29:已调优基线 DQN 与已调优组合系统的对比
另一个显示调优效果的图表是对比调优前后的原始游戏奖励。调优后的系统开始在 40 局游戏后就获得最高分,这非常令人印象深刻:

图 8.30:未调优和已调优组合 DQN 的原始奖励
总结
在本章中,我们回顾并实现了自 2015 年首次发布 DQN 论文以来,研究人员发现的许多 DQN 改进。这份清单远未完整。首先,关于方法列表,我使用了 DeepMind 发布的论文《Rainbow:结合深度强化学习的改进》[Hes+18],因此方法列表无疑偏向于 DeepMind 的论文。其次,强化学习如今发展非常迅速,几乎每天都有新论文发布,即使我们只局限于一种强化学习模型,比如 DQN,也很难跟上进展。本章的目标是让你了解该领域已经发展出的一些不同的实际方法。
在下一章中,我们将继续从工程角度讨论 DQN 的实际应用,谈论如何在不触及底层方法的情况下提升 DQN 的性能。
加入我们的 Discord 社区
与其他用户、深度学习专家以及作者本人一起阅读本书。提问、为其他读者提供解决方案,通过“问我任何问题”环节与作者聊天,更多内容尽在其中。扫描二维码或访问链接加入社区。packt.link/rl

第九章:加速强化学习(RL)的方法
在第八章中,你看到了一些实用的技巧,可以使深度 Q 网络(DQN)方法更加稳定并加速收敛。它们涉及基本的 DQN 方法修改(例如向网络注入噪声或展开 Bellman 方程)来获得更好的策略,同时减少训练所需的时间。但在本章中,我们将探索另一种方法:调整方法的实现细节,以提高训练速度。这是一种纯粹的工程方法,但它同样重要,因为在实践中非常有用。
在本章中,我们将:
-
取用上一章中的 Pong 环境,并尽量以最快速度解决它
-
以逐步的方式,使用完全相同的商品硬件将 Pong 游戏的解决速度提高近 2 倍
为什么速度很重要
首先,我们来谈谈为什么速度如此重要,为什么我们要优化它。也许并不明显,但过去二十年里硬件性能有了巨大的提升。近二十年前,我曾参与一个项目,专注于为一个航空发动机设计公司构建一个用于计算流体力学(CFD)仿真的超级计算机。该系统由 64 台服务器组成,占据了三组 42 英寸的机架,并且需要专门的冷却和电力子系统。仅硬件部分(不包括冷却)就花费了大约 100 万美元。
在 2005 年,这台超级计算机在前苏联超级计算机中排名第四,是业内安装的最快系统。它的理论性能为 922 GFLOPS(几乎每秒一万亿次浮点运算),但与 12 年后发布的 GTX 1080 Ti 相比,这堆铁块的所有能力看起来显得微不足道。
一块单独的 GTX 1080 Ti 能够执行 11,340 GFLOPS,性能是 2005 年的超级计算机的 12.3 倍。而且它发布时,每个 GPU 的价格仅为 $700!如果我们按每花费 $1 的计算能力来算,那么每个 GFLOP 的价格下降了超过 17,500 倍。这个数字在最新的(撰写时)H100 GPU 上更为惊人,H100 提供了 134 teraflops 的性能(使用 FP32 操作)。
许多人多次提到,人工智能(AI)的进步(以及机器学习(ML)的一般进展)是由数据可用性和计算能力的提升所推动的,我认为这绝对是正确的。想象一下,一些计算在一台机器上需要一个月才能完成(这在计算流体力学(CFD)和其他物理仿真中非常常见)。如果我们能将速度提高五倍,那么原本需要耐心等待一个月的时间将缩短为六天。提高 100 倍的速度意味着这个一个月的计算将只需要八小时完成,那么你一天之内就能完成三次计算!如今,只花相同的钱就能获得 20,000 倍的计算能力,真是太酷了。顺便提一下,速度提高 20,000 倍意味着我们原本需要一个月的计算问题只需要两到三分钟就能完成!
这种情况不仅发生在“巨型计算机”(也称为高性能计算)领域;基本上,它无处不在。现代微控制器的性能特点已经与我们 15 年前使用的桌面计算机相当(例如,你可以花 50 美元打造一台便携计算机,配备运行 120 MHz 的 32 位微控制器,能够运行 Atari 2600 模拟器:hackaday.io/project/80627-badge-for-hackaday-conference-2018-in-belgrade) 。我甚至没有提到现代智能手机,它们通常配有四到八个核心、图形处理单元(GPU)以及数 GB 的内存。
当然,那里有很多复杂的因素。这不仅仅是将十年前用过的相同代码现在神奇地让它运行得快上几千倍。可能正好相反:你甚至可能完全无法运行它,因为库、操作系统接口以及其他因素的变化。(你是否曾尝试读取十年前写入的 CD-RW 磁盘?)如今,要充分发挥现代硬件的能力,你需要将代码并行化,这意味着你必须处理大量关于分布式系统、数据局部性、通信以及硬件和库内部特性的细节。高级库尽力隐藏这些复杂性,但如果你想高效地使用这些库,就无法忽视这些问题。然而,这绝对是值得的——记住,一整个月的耐心等待可以缩短为三分钟。另一方面,为什么我们要加速操作,可能并不完全明显。毕竟,一个月并不算太长;只需将计算机锁在服务器室,然后去度个假!但请想一下准备和使这个计算过程正常运行的整个过程。你可能已经注意到,即使是简单的机器学习问题,初次尝试时也几乎不可能做到完美实现。
它们需要多次试运行,才能找到合适的超参数并修复所有的错误和代码,准备好进行干净的发布。在物理仿真、强化学习研究、大数据处理和编程领域,确实存在完全相同的过程。因此,如果我们能够让某个程序运行得更快,它不仅对单次运行有利,还能让我们快速迭代,进行更多的实验,这可能显著加速整个过程,并提高最终结果的质量。
我记得职业生涯中有一个情境,当时我们在部门内部部署了一个 Hadoop 集群,我们当时正在开发一个网页搜索引擎(类似 Google,但用于俄罗斯网站)。在部署之前,即使是进行简单的数据实验,也需要几个星期的时间。几 TB 的数据分布在不同的服务器上;你需要在每台机器上运行多次代码,收集和合并中间结果,处理偶尔发生的硬件故障,并完成许多与问题无关的手动任务。将 Hadoop 平台集成到数据处理后,实验所需的时间减少到了几个小时,这完全改变了游戏规则。从那时起,开发人员能够更轻松、更快速地进行更多实验,而不必为不必要的细节烦恼。实验的数量(以及进行实验的意愿)显著增加,这也提高了最终产品的质量。
另一个支持优化的理由是我们可以处理的问题的规模。让某种方法运行得更快可能意味着两件事:我们可以更快得到结果,或者我们可以增加问题的规模(或其他衡量问题复杂性的标准)。复杂性增加在不同情况下可能有不同的含义,比如得到更准确的结果,减少对现实世界的简化,或者考虑更多的数据,但几乎总是,这是好事。
回到本书的主题,让我们概述一下强化学习方法如何通过加速来受益。首先,即使是最先进的强化学习方法也不是非常高效,这意味着训练需要多次与环境进行交互(在雅达利的情况下是数百万次),才能学到一个好的策略,这可能需要几周的训练。如果我们能加速这个过程,我们就可以更快地得到结果,进行更多的实验,并找到更好的超参数。除此之外,如果我们的代码更快,我们甚至可以增加应用这些方法时问题的复杂性。
在现代强化学习(RL)中,雅达利游戏被认为已经解决;即使是所谓的“困难探索游戏”,如《蒙特祖玛的复仇》,也可以被训练到超人类的准确度。因此,新的研究前沿需要更复杂的问题,具有更丰富的观察和行动空间,这必然需要更多的训练时间和硬件。这样的研究已经由 DeepMind 和 OpenAI 开始(从我的角度来看,这也增加了问题的复杂性,可能有点过头了),他们从雅达利转向了更具挑战性的问题,如蛋白质折叠(AlphaFold 系统)和大型语言模型(LLMs)。这些问题需要成千上万的 GPU 并行工作。
我想以一个小小的警告结束这段介绍:所有的性能优化只有在核心方法正常工作的情况下才有意义(这在强化学习和机器学习的情况下并不总是显而易见)。正如一位在线课程的讲师所说:“有一个慢而正确的程序,比一个快但不正确的程序要好得多。”
基准线
在本章中,我们将使用你已经熟悉的 Atari Pong 环境,并尝试加速其收敛速度。作为基准,我们将使用第八章中使用的相同简单 DQN,超参数也将保持一致。为了比较我们改动的效果,我们将使用两个特征:
-
每秒钟我们从环境中消耗的帧数(FPS)。这表示我们在训练过程中与环境的交互速度。在强化学习的文献中,通常会标明智能体在训练过程中观察到的帧数,正常的数字范围是 2500 万到 5000 万帧。因此,如果我们的 FPS=200,那么需要的时间为
≈ 2.89 天。在这种计算中,需要考虑到强化学习文献通常报告的是原始环境帧数。但如果使用了帧跳跃(几乎总是使用),则帧数需要除以这个因子,通常是 4。在我们的测量中,我们计算的是智能体与环境的交互帧数,因此“原始环境 FPS”将是其四倍。 -
游戏解决之前的墙钟时间。当最后 100 个回合的平滑奖励达到 18 时,我们停止训练(Pong 游戏的最高分是 21)。这个边界值可以提高,但通常 18 已经是一个很好的指标,表明智能体几乎掌握了游戏,进一步精炼策略仅仅是训练时间的问题。我们检查墙钟时间,因为单纯的 FPS 并不是训练加速的最佳指标。
由于我们在代码中进行的操作,我们可以获得非常高的 FPS,但收敛可能会受到影响。仅凭这个数值也不能作为我们改进效果的可靠指标,因为训练过程是随机的。即使指定了随机种子(我们需要明确设置 PyTorch、Gym 和 NumPy 的种子),并行化(在后续步骤中会使用)也会为过程增加随机性,几乎无法避免。所以,我们能做的最好的是多次运行基准测试并取平均值。但单次运行的结果不能作为决策的依据。
由于上述提到的随机性,本章中的所有图表都是通过对同一实验的 5 次运行结果进行平均得到的。所有基准测试都使用相同的机器,配置为 Intel i5-7600K CPU、GTX 1080 Ti GPU,CUDA 版本 12.3,以及 NVIDIA 驱动版本 545.29.06。
我们的第一个基准将是基线版本,位于 Chapter09/01_baseline.py。我这里不提供源代码,因为它已经在前一章中给出,并且在此与前面相同。在训练过程中,代码会向 TensorBoard 写入几个指标:
-
reward: 来自剧集的原始未折扣奖励;x 轴是剧集的编号。
-
avg_reward: 与奖励相同,但通过使用 α = 0.98 的滑动平均进行平滑处理。
-
steps: 剧集持续的步数。通常,在开始时,代理会迅速失败,所以每个剧集大约有 1,000 步。然后,代理学会了更好的行为,所以步数增加到 3,000–4,000,并且奖励也增加;但是,最终,当代理掌握了游戏时,步数会降回到 2,000 步,因为策略被优化到尽可能快地获胜(由于折扣因子 γ)。事实上,这种剧集长度的下降可能表明过拟合了环境,这在强化学习中是一个巨大的问题。然而,处理这个问题超出了我们实验的范围。
-
loss: 训练中的损失,每 100 次迭代采样一次。它应该在 2⋅10(−3)…1⋅10(−2) 之间,偶尔会有增加,当代理发现新的行为时,导致奖励与 Q 值学习的奖励不同。
-
avg_loss: 损失的平滑版本。
-
epsilon: 当前 𝜖 的值——采取随机行动的概率。
-
avg_fps: 代理与环境通信的速度(每秒观察数),通过滑动平均进行平滑处理。
在图 9.1 和图 9.2 中,图表是从 5 次基线运行中平均得出的。如之前所示,每个图表都绘制了两个 x 轴:底部是小时为单位的墙钟时间,上面是步数(图 9.1 中为剧集数,图 9.2 中为训练迭代次数):

图 9.1:基线版本中的奖励和剧集长度

图 9.2:基线版本训练中的损失和 FPS
PyTorch 中的计算图
我们的第一个例子不会集中在加速基线上,而是展示一种常见的、并不总是显而易见的情况,这种情况可能会影响性能。在第 3 章中,我们讨论了 PyTorch 如何计算梯度:它会构建你对张量执行的所有操作的图,当你调用最终损失的 backward() 方法时,所有模型参数中的梯度会被自动计算出来。
这种方法有效,但强化学习代码通常比传统的监督学习训练要复杂得多,因此我们当前训练的强化学习模型也在用于获取代理在环境中需要执行的动作。第 6 章中讨论的目标网络使得这一过程更加复杂。因此,在 DQN 中,神经网络(NN)通常在三种不同的情况中使用:
-
当我们希望计算由网络预测的 Q 值,以根据 Bellman 方程得到相对于参考 Q 值的损失时
-
当我们应用目标网络来获取下一个状态的 Q 值,以计算 Bellman 近似时
-
当代理想要决定执行的动作时
在我们的训练中,我们只需要在第一种情况下计算梯度。在第六章中,我们通过显式调用 detach()来避免计算梯度,这个 detach 非常重要,因为它防止了梯度“从意外的方向”流入我们的模型,如果没有它,DQN 可能根本无法收敛。在第三种情况下,梯度通过将网络结果转换为 NumPy 数组来停止。
我们在第六章中的代码是有效的,但我们错过了一个细节:三种情况下创建的计算图。这个问题不大,但创建计算图仍然会使用一些资源(无论是速度还是内存),而这些资源会浪费,因为即使我们没有对某个图调用 backward(),PyTorch 也会创建这个计算图。为了解决这个问题,有一个非常好的选项:装饰器 torch.no_grad()。
Python 中的装饰器是一个非常广泛的话题。它们为开发者提供了很多功能(如果使用得当),但超出了本书的讨论范围。在这里,我仅给出一个示例,我们定义了两个函数:
>>> import torch
>>> @torch.no_grad
... def fun_a(t):
... return t*2
...
>>> def fun_b(t):
... return t*2
...
这两个函数做的是相同的事情,都是将参数翻倍,但第一个函数使用了 torch.no_grad()装饰器,第二个则是普通函数。这个装饰器暂时禁用传递给函数的所有张量的梯度计算。如你所见,尽管张量 t 需要计算梯度,但从 fun_a(被装饰的函数)返回的结果并没有梯度:
>>> t = torch.ones(3, requires_grad=True)
>>> t
tensor([1., 1., 1.], requires_grad=True)
>>> a = fun_a(t)
>>> b = fun_b(t)
>>> b
tensor([2., 2., 2.], grad_fn=<MulBackward0>)
>>> a
tensor([2., 2., 2.])
但是这个效果仅限于装饰器函数内部:
>>> a*t
tensor([2., 2., 2.], grad_fn=<MulBackward0>)
函数 torch.no_grad()也可以作为上下文管理器使用(这是另一个强大的 Python 概念,我建议你学习它),用于停止某段代码中的梯度计算:
>>> with torch.no_grad():
... c = t*2
...
>>> c
tensor([2., 2., 2.])
这个功能为你提供了一种非常方便的方式,能够指示你代码中应该完全排除梯度计算的部分。这在 ptan.agent.DQNAgent(以及 PTAN 提供的其他代理)和 common.calc_loss_dqn 函数中已经完成。但是如果你正在编写自定义代理或实现自己的代码,很容易忘记这一点。
为了评估不必要的图计算的效果,我在 Chapter09/00_slow_grads.py 中提供了修改后的基准代码,它与原代码完全相同,但代理和损失计算部分没有使用 torch.no_grad()。以下图表展示了这一效果:

图 9.3:基准版本与没有 torch.no_grad()版本之间的奖励和 FPS 比较
正如你所看到的,速度损失并不大(大约 10FPS),但在网络更大且结构更复杂的情况下,这可能会有所不同。我曾看到在更复杂的递归神经网络中,加入 torch.no_grad()后,性能提升了 50%。
多个环境
我们通常用来加速深度学习训练的第一个思路是增加批量大小。这同样适用于深度强化学习领域,但你需要小心。在普通的监督学习中,简单的规则“较大的批量更好”通常是成立的:只要你的 GPU 内存允许,就增加批量,较大的批量通常意味着在单位时间内处理更多样本,这得益于强大的 GPU 并行计算。
强化学习的情况稍有不同。在训练过程中,两个事情是同时发生的:
-
你的网络经过训练,可以在当前数据上获得更好的预测
-
你的代理探索环境
随着代理探索环境并学习其行为的结果,训练数据会发生变化。在射击游戏的例子中,代理可能会随机运行一段时间,被怪物击中并在训练缓冲区中只获得“死亡无处不在”的痛苦经验。但过了一段时间,代理会发现它有一把可以使用的武器。这种新的经验可能会极大地改变我们用于训练的数据。强化学习的收敛通常依赖于训练和探索之间的微妙平衡。如果我们只是增加批量大小而没有调整其他选项,我们很容易在当前数据上过拟合(在射击游戏的例子中,代理可能开始认为“早死”是最小化痛苦的唯一选择,甚至永远不会发现它拥有的枪)。
因此,在 Chapter09/02_n_envs.py 中的示例中,我们的代理使用多个相同环境的副本来收集训练数据。在每次训练迭代中,我们将所有环境中的样本填充到重放缓冲区,然后按比例增大批量大小。这也使得我们能够稍微加速推理时间,因为我们可以在神经网络的一次前向传递中,对所有 N 个环境执行动作决策。在实现方面,前面的逻辑只需要对代码进行几处修改:
-
由于 PTAN 原生支持多个环境,我们需要做的就是将 N 个 Gym 环境传递给 ExperienceSource 实例
-
代理代码(在我们的例子中是 DQNAgent)已经为神经网络的批处理应用进行了优化
为了解决这个问题,修改了几段代码。生成批次的函数现在在每次训练迭代中执行多个步骤(等于环境总数):
def batch_generator(buffer: ptan.experience.ExperienceReplayBuffer,
initial: int, batch_size: int, steps: int):
buffer.populate(initial)
while True:
buffer.populate(steps)
yield buffer.sample(batch_size)
经验源接受多个环境的数组,而不是单一环境:
envs = [
ptan.common.wrappers.wrap_dqn(gym.make(params.env_name))
for _ in range(args.envs)
]
params.batch_size *= args.envs
exp_source = ptan.experience.ExperienceSourceFirstLast(
envs, agent, gamma=params.gamma, env_seed=common.SEED)
其他变化仅是对常量的小调整,用于调整 FPS 追踪器和补偿ε衰减的速度(随机步骤的比例)。由于环境数量是需要调优的新超参数,我进行了几个实验,N 的范围是从 2 到 6。以下图表展示了平均的动态:

图 9.4:基准、两个和三个环境中的奖励与 FPS

图 9.5:n = 3…6 时的奖励与 FPS
正如你从图表中看到的,增加一个额外的环境提供了 47%的 FPS 增益(从 227 FPS 到 335 FPS),并加速了约 10%的收敛速度(从 52 分钟到 48 分钟)。同样的效果也来自于增加第三个环境(398 FPS,36 分钟),但是尽管 FPS 进一步增加,增加更多环境对收敛速度产生了负面影响。因此,看起来 N = 3 差不多是我们的超参数的最优值,但当然,你可以自由调整和实验。这也说明了为什么我们不仅监控 FPS 的原始速度,还要观察智能体解决游戏的速度。
在不同进程中进行游戏和训练
从高层次来看,我们的训练过程包含以下步骤的重复:
-
请求当前网络选择动作,并在我们的环境阵列中执行这些动作。
-
将观察值放入重放缓冲区。
-
从重放缓冲区随机抽取训练批次。
-
在这一批次上进行训练。
前两步的目的是将环境中的样本(包括观察、动作、奖励和下一个观察)填充到重放缓冲区中。最后两步用于训练我们的网络。
以下是前面步骤的示意图,旨在使潜在的并行性更为明显。在左侧显示了训练流程。训练步骤使用了环境、重放缓冲区和我们的神经网络(NN)。实线表示数据和代码流动。虚线代表神经网络在训练和推理中的使用。

图 9.6:训练过程的顺序图
正如你所见,前两个步骤与下部仅通过重放缓冲区和神经网络通信。这使得我们可以将这两部分在不同的并行进程中分开。以下图是该方案的示意图:

图 9.7:训练和游戏步骤的并行版本
在我们的 Pong 环境中,这看起来像是对代码的一个不必要的复杂化,但这种分离在某些情况下可能非常有用。想象一下你有一个非常慢且复杂的环境,每一步都需要数秒的计算。这并不是一个人为的例子;例如,过去的 NeurIPS 竞赛,如 Learning to Run、AI for Prosthetics Challenge 和 Learn to Move(www.aicrowd.com/challenges/neurips-2019-learn-to-move-walk-around)中使用了非常缓慢的神经肌肉模拟器,因此你必须将经验收集与训练过程分开。在这种情况下,你可以有许多并行的环境,将经验传递给中央训练过程。
为了将我们的串行代码转变为并行代码,需要做一些修改。在文件 Chapter09/03_parallel.py 中,你可以找到这个示例的完整源码。接下来,我将只关注主要的区别。
首先,我们使用 torch.multiprocessing 模块来替代标准的 Python multiprocessing 模块:
import torch.multiprocessing as mp
@dataclass
class EpisodeEnded:
reward: float
steps: int
epsilon: float
def play_func(params: common.Hyperparams, net: dqn_model.DQN,
dev_name: str, exp_queue: mp.Queue):
env = gym.make(params.env_name)
env = ptan.common.wrappers.wrap_dqn(env)
device = torch.device(dev_name)
selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=params.epsilon_start)
epsilon_tracker = common.EpsilonTracker(selector, params)
agent = ptan.agent.DQNAgent(net, selector, device=device)
exp_source = ptan.experience.ExperienceSourceFirstLast(
env, agent, gamma=params.gamma, env_seed=common.SEED)
for frame_idx, exp in enumerate(exp_source):
epsilon_tracker.frame(frame_idx//2)
exp_queue.put(exp)
for reward, steps in exp_source.pop_rewards_steps():
ee = EpisodeEnded(reward=reward, steps=steps, epsilon=selector.epsilon)
exp_queue.put(ee)
标准库中的版本提供了几个用于处理在独立进程中执行的代码的原语,例如 mp.Queue(分布式队列)、mp.Process(子进程)等。PyTorch 提供了一个对标准 multiprocessing 库的封装,它允许在进程间共享 torch 张量,而无需复制它们。这是通过共享内存实现的,针对 CPU 张量使用共享内存,或者针对 GPU 上的张量使用 CUDA 引用。这种共享机制消除了在单台计算机内部进行通信时的主要瓶颈。当然,在真正分布式的通信中,你需要自行序列化数据。
函数 play_func 实现了我们的“播放进程”,并将在由主进程启动的单独子进程中运行。它的职责是从环境中获取经验并将其推送到共享队列中。此外,它将关于回合结束的信息封装成一个数据类,并将其推送到同一个队列中,以便训练过程可以获得关于回合奖励和步数的信息。
函数 batch_generator 被类 BatchGenerator 所替代:
class BatchGenerator:
def __init__(self, buffer_size: int, exp_queue: mp.Queue,
fps_handler: ptan_ignite.EpisodeFPSHandler,
initial: int, batch_size: int):
self.buffer = ptan.experience.ExperienceReplayBuffer(
experience_source=None, buffer_size=buffer_size)
self.exp_queue = exp_queue
self.fps_handler = fps_handler
self.initial = initial
self.batch_size = batch_size
self._rewards_steps = []
self.epsilon = None
def pop_rewards_steps(self) -> tt.List[tt.Tuple[float, int]]:
res = list(self._rewards_steps)
self._rewards_steps.clear()
return res
def __iter__(self):
while True:
while self.exp_queue.qsize() > 0:
exp = self.exp_queue.get()
if isinstance(exp, EpisodeEnded):
self._rewards_steps.append((exp.reward, exp.steps))
self.epsilon = exp.epsilon
else:
self.buffer._add(exp)
self.fps_handler.step()
if len(self.buffer) < self.initial:
continue
yield self.buffer.sample(self.batch_size)
这个类提供了一个批次的迭代器,并且通过方法 pop_reward_steps()额外模拟了 ExperienceSource 接口。这个类的逻辑很简单:它消费队列(由“播放进程”填充),如果接收到 EpisodeEnded 对象,它会记住关于 epsilon 的信息和游戏所经历的步骤数;否则,该对象就是一条需要添加到重放缓冲区的经验。从队列中,我们消费当前可用的所有对象,然后从缓冲区中抽样训练批次并返回。
在训练过程的开始,我们需要告诉 torch.multiprocessing 使用哪种启动方法:
if __name__ == "__main__":
warnings.simplefilter("ignore", category=UserWarning)
mp.set_start_method(’spawn’)
它们有几个,但 spawn 是最灵活的。
然后,创建用于通信的队列,并将 play_func 作为单独的进程启动。我们传递的参数包括神经网络(NN)、超参数以及用于经验的队列:
exp_queue = mp.Queue(maxsize=2)
proc_args = (params, net, args.dev, exp_queue)
play_proc = mp.Process(target=play_func, args=proc_args)
play_proc.start()
其余的代码几乎相同,唯一不同的是我们使用 BatchGenerator 实例作为 Ignite 和 EndOfEpisodeHandler 的数据源(后者需要使用方法 pop_rewards_steps())。以下图表是从我的基准测试中获得的:

图 9.8:基线版本和并行版本中的奖励和 FPS
如你所见,在 FPS 方面,我们获得了 27% 的提升:并行版本的 FPS 为 290,而基线为 228。解决环境的平均时间减少了 41%。
就 FPS 增加而言,尽管并行版本比前一节中的最佳结果(使用 3 个游戏环境时,我们获得了接近 400 FPS)看起来要差,但收敛速度更快。
调整封装器
我们实验序列中的最后一步是调整应用于环境的封装器。这个步骤很容易被忽视,因为封装器通常是写一次或从其他代码借用后应用到环境中的,并且被留在那里。但你应该意识到它们在方法的速度和收敛性方面的重要性。例如,DeepMind 风格的正常 Atari 游戏封装器堆栈如下:
-
NoopResetEnv:对游戏重置应用随机数量的 NOOP 操作。在某些 Atari 游戏中,需要这个操作来去除奇怪的初始观察值。
-
MaxAndSkipEnv:对 N 次观察应用最大值(默认四次),并将其作为该步的观察返回。这样解决了某些 Atari 游戏中的“闪烁”问题,因为这些游戏在偶数帧和奇数帧上绘制屏幕的不同部分(这是 Atari 开发者常用的做法,以克服平台的限制并增加游戏精灵的复杂度)。
-
EpisodicLifeEnv:在某些游戏中,这会检测丢失的生命并将其转化为该集的结束。这显著提高了收敛性,因为我们的回合变得更短(一次生命而非游戏逻辑给出的多次生命)。这仅对 Atari 2600 学习环境支持的某些游戏相关。
-
FireResetEnv:在游戏重置时执行 FIRE 操作。有些游戏需要这个操作才能开始游戏。如果没有这个操作,我们的环境就变成了部分可观察的马尔可夫决策过程(POMDP),这使得无法收敛。
-
WarpFrame:也称为 ProcessFrame84, 将图像转换为灰度并将其调整为 84 × 84 大小。
-
ClipRewardEnv:将奖励裁剪到 −1…1 范围,这统一了不同 Atari 游戏中分数的广泛变化。例如,Pong 的分数范围可能是 −21…21,而 River Raid 游戏的分数可能是 0…∞。
-
FrameStack:将 N 个连续的观察堆叠到栈中(默认是四个)。正如我们在第六章中讨论的那样,在某些游戏中,这是实现马尔可夫性质所必需的。例如,在 Pong 游戏中,从单一的一帧图像中无法得知球的运动方向。
这些包装器的代码经过了许多人的精心优化,并且存在多个版本。个人最喜欢的是 Stable Baselines3,它是 OpenAI Baselines 项目的一个分支。你可以在这里找到它:stable-baselines3.readthedocs.io/.
但你不应该将这段代码视为最终的真理源,因为你的具体环境可能有不同的需求和细节。例如,如果你有兴趣加速 Atari 套件中的某个特定游戏,NoopResetEnv 和 MaxAndSkipEnv(更准确地说,是 MaxAndSkipEnv 中的最大池化操作)可能并不需要。另一个可以调整的地方是 FrameStack 包装器中的帧数。通常做法是使用四帧,但你需要理解,这个数字是 DeepMind 和其他研究人员在对完整的 Atari 2600 游戏套件进行训练时使用的,该套件目前包含超过 50 个游戏。对于你的特定情况,使用两帧的历史可能足以提供性能提升,因为神经网络需要处理的数据会更少。
最后,图像调整大小可能是包装器的瓶颈,因此你可能需要优化包装器使用的库,例如重新构建它们或替换为更快速的版本。2020 年之前,将 OpenCV2 库替换为 pillow-simd 库能提高大约 50 帧每秒的速度。如今,OpenCV2 使用了优化的重新缩放操作,因此这种替换已不再有效。但你仍然可以尝试不同的缩放方法和不同的库。
在这里,我们将对 Pong 的包装器应用以下更改:
-
禁用 NoopResetEnv
-
用简化版本替换 MaxAndSkipEnv,只跳过四帧而不进行最大池化。
-
只保留两个帧在 FrameStack 中
为了检查我们调整的综合效果,我们将把上面的更改添加到前两节所做的修改中:多个环境和并行执行游戏与训练。
由于这些更改并不复杂,我们就不展示具体代码了(完整代码可以在文件 Chapter09/04_wrappers_n_env.py、Chapter09/04_wrappers_parallel.py 和 Chapter09/lib/atari_wrappers.py 中找到):
-
库 atari_wrappers.py 相当简单——它包含了 PTAN 中 wrap_dqn 函数的副本和 Stable Baselines3 中的 AtariWrapper 类。
-
在 AtariWrapper 中,MaxAndSkipEnv 类被一个简化版本替代,去除了帧间的最大池化操作。
-
两个模块,04_wrappers_n_env.py 和 04_wrappers_prallel.py,仅仅是我们之前见过的 02_n_env.py 和 03_parallel.py 的副本,环境创建经过调整。
就是这样!以下是两种版本的奖励动态和 FPS 图表:

图 9.9:基准版和“3 个环境与 2 帧版本”的奖励与 FPS

图 9.10:基准版和“并行与 2 帧版本”的奖励与 FPS
出于好奇,我还尝试将 FrameStack 中保持的帧数减少到仅一个帧(你可以通过命令行参数 --stack 1 重复实验)。令人惊讶的是,这样的版本也能解决游戏,但所需的游戏次数显著增加,训练变得不稳定(大约 8 次训练中的 3 次完全没有收敛)。这可能表明,只有一个帧的 Pong 并不是 POMDP,代理仍然可以仅凭一个帧作为观察,学习如何赢得游戏。但训练效率肯定会受到影响。
基准结果
我已将我们的实验总结在以下表格中。百分比显示相对于基准版的变化:
| 步骤 | FPS | FPS Δ | 时间(分钟) | 时间 Δ |
|---|---|---|---|---|
| 基准 | 229 | 52.2 | ||
| 没有 torch.no.grad() | 219 | -4.3% | 51.0 | -2.3% |
| 3 个环境 | 395 | +72.5% | 36.0 | -31.0% |
| 并行版本 | 290 | +26.6% | 31.2 | -40.2% |
| 包装器 + 3 个环境 | 448 | +95.6% | 47.4 | -9.2% |
| 包装器 + 并行 | 325 | +41.9% | 30.0 | -42.5% |
表 9.1:优化结果
摘要
在这一章中,你看到了几种通过纯工程方法提高 RL 方法性能的方式,这与第八章中介绍的“算法”或“理论”方法形成对比。从我的角度来看,这两种方法是互补的,一个优秀的 RL 从业者需要既了解研究人员发现的最新技巧,也要了解实现细节。
在下一章,我们将开始将我们的 DQN 知识应用于股票交易作为实际示例。
第十章:使用强化学习进行股票交易
本章我们不会学习解决玩具强化学习(RL)问题的新方法,而是尝试利用我们在深度 Q 网络(DQN)方面的知识来处理更实际的金融交易问题。我不能保证代码会让你在股市或外汇市场上变得超级富有,因为我的目标远没有那么雄心勃勃:我想展示如何超越雅达利游戏,并将强化学习应用于不同的实际领域。
在本章中,我们将:
-
实现我们自己的 OpenAI Gym 环境以模拟股市
-
应用你在第六章和第八章中学到的 DQN 方法,训练一个智能体进行股票交易,以最大化利润
为什么做交易?
每天在市场上交易的金融工具种类繁多:商品、股票和货币。即使是天气预报也可以通过所谓的“天气衍生品”进行买卖,这只是现代世界和金融市场复杂性的一个表现。如果你的收入取决于未来的天气条件,就像种植作物的企业一样,你可能会通过购买天气衍生品来对冲风险。所有这些不同的物品都有随时间变化的价格。交易是买卖金融工具的活动,目的是为了不同的目标,如获取利润(投资)、从未来的价格波动中获得保护(对冲)或只是获取所需的东西(例如购买钢铁或将美元兑换为日元支付合同)。
自从第一个金融市场建立以来,人们就一直在尝试预测未来的价格走势,因为这能带来很多好处,比如“从无中赚取利润”或保护资本免受突如其来的市场波动。这一问题被认为是复杂的,因此有很多金融顾问、投资基金、银行和个人交易者在尝试预测市场,并寻找最佳的买卖时机以最大化利润。
问题是:我们能否从强化学习的角度来看待这个问题?假设我们对市场有一些观察,并且我们需要做出一个决策:买入、卖出或等待。如果我们在价格上涨之前买入,我们的利润将是正的;否则,我们将获得负奖励。我们试图做的是尽可能获得更多的利润。市场交易和强化学习之间的联系非常明显。首先,让我们更清楚地定义问题陈述。
问题陈述与关键决策
金融领域庞大而复杂,因此你很容易花费几年时间每天学习新的内容。在我们的例子中,我们将仅仅使用强化学习(RL)工具稍微触及一下表面,问题将尽可能简单地被表述,使用价格作为观察值。我们将研究我们的智能体是否能够学习在最佳时机购买一只股票,并在随后平仓以最大化利润。这个例子的目的是展示 RL 模型的灵活性,以及你通常需要采取的第一步来将 RL 应用到实际的使用案例中。
正如你已经知道的,要制定 RL 问题,需要三件事:环境观察、可能的动作和奖励系统。在之前的章节中,所有三者已经给定,并且环境的内部机制是隐藏的。现在我们处于不同的情况,所以我们需要自己决定智能体将看到什么以及它可以采取哪些动作。奖励系统也没有严格的规则,而是由我们对领域的感觉和知识引导,这给了我们很大的灵活性。
灵活性在这种情况下既是好事,也是坏事。好的一面是,我们可以自由地传递一些我们认为对高效学习很重要的信息给智能体。例如,除了价格,你还可以向交易智能体提供新闻或重要统计数据(这些被认为对金融市场有很大影响)。坏的一面是,这种灵活性通常意味着为了找到一个优秀的智能体,你需要尝试许多不同的数据表示方式,而哪些方式更有效通常并不明显。在我们的案例中,我们将实现最基本的交易智能体,以其最简单的形式,就像我们在第一章中讨论的那样:
-
观察: 观察将包括以下信息:
-
N 个过去的时段,其中每个时段都有开盘价、最高价、最低价和收盘价。
-
一个指示,表明股票在一段时间前已被购买(一次只能购买一只股票)。
-
我们当前持仓(已购入的股票)所带来的盈亏。
-
-
动作: 在每一步,每一分钟的时段结束后,智能体可以采取以下之一的动作:
-
什么都不做:跳过当前的时段,不采取任何行动。
-
买入股票:如果智能体已经拥有股票,则不会进行购买;否则,我们将支付佣金,通常是当前价格的一小部分。
-
平仓:如果我们没有之前购买的股票,什么都不会发生;否则,我们将支付交易佣金。
-
-
奖励: 智能体收到的奖励可以通过以下方式表达:
-
作为第一种选择,我们可以将奖励分成多个步骤,在我们持有股票期间每一步的奖励将等于最后一个时段的价格波动。
-
或者,智能体可以在平仓动作之后才收到奖励,并一次性获得全部奖励。
初看起来,两种变体应该有相同的最终结果,只是收敛速度可能不同。然而,在实践中,差异可能是巨大的。我的实现中的环境支持这两种变体,因此您可以实验它们之间的差异。
-
另一个需要做出的决策是如何在我们的环境观察中表示价格。理想情况下,我们希望我们的代理能够独立于实际的价格值,并考虑相对变动,比如“股票在上一根 K 线中增长了 1%”或“股票下降了 5%”。这是合理的,因为不同股票的价格可能不同,但它们可能有类似的变动模式。在金融领域,有一门分析学科叫做技术分析,专门研究这种模式,并通过它们来进行预测。我们希望我们的系统能够发现这些模式(如果它们存在)。为了实现这一点,我们将把每根 K 线的开盘、最高、最低和收盘价格转换为三个数值,表示开盘价的百分比形式的最高价、最低价和收盘价。
这种表示方法有其自身的缺点,因为我们可能会失去关于关键价格水平的信息。例如,已知市场有一个倾向,即从整数价格水平(如每个比特币 70,000 美元)和过去曾经是转折点的价格水平反弹。然而,正如前面所说,我们这里只是玩弄数据并检查这个概念。以相对价格变动的形式表示将有助于系统在价格水平中发现重复的模式(如果存在的话),而不管绝对价格位置如何。潜在地,神经网络(NN)可能会自己学会这一点(只需从绝对价格值中减去均价),但相对表示简化了神经网络的任务。
数据
在我们的例子中,我们将使用 2015-2016 年期间的俄罗斯股市价格,这些数据存放在 Chapter10/data/ch10-small-quotes.tgz 中,模型训练之前需要解压。
在档案中,我们有包含 M1 条形图的 CSV 文件,这意味着每行对应一个时间单位内的单一分钟,并且该分钟内的价格变动由四个价格记录:
-
开盘:一分钟开始时的价格
-
高:区间内的最高价格
-
最低:最低价格
-
收盘:这一分钟时间区间的最后价格
每一分钟的时间间隔称为一个 K 线,它让我们能够了解这一时间段内的价格变动。例如,在 YNDX_160101_161231.csv 文件中(包含 2016 年 Yandex 公司股票数据),我们有 130,000 行数据,格式如下:
<DATE>,<TIME>,<OPEN>,<HIGH>,<LOW>,<CLOSE>,<VOL>
20160104,100100,1148.90000,1148.90000,1148.90000,1148.90000,0
20160104,100200,1148.90000,1148.90000,1148.90000,1148.90000,50
20160104,100300,1149.00000,1149.00000,1149.00000,1149.00000,33
20160104,100400,1149.00000,1149.00000,1149.00000,1149.00000,4
20160104,100500,1153.00000,1153.00000,1153.00000,1153.00000,0
20160104,100600,1156.90000,1157.90000,1153.00000,1153.00000,43
20160104,100700,1150.60000,1150.60000,1150.40000,1150.40000,5
20160104,100800,1150.20000,1150.20000,1150.20000,1150.20000,4
20160104,100900,1150.50000,1150.50000,1150.50000,1150.50000,2
20160104,101000,1150.00000,1150.00000,1150.00000,1150.00000,43
20160104,101100,1149.70000,1149.70000,1149.70000,1149.70000,0
20160104,101200,1150.20000,1150.20000,1149.50000,1149.70000,165
...
前两列是日期和分钟时间;接下来的四列是开盘、最高、最低和收盘价格;最后一个值表示在该 K 线期间执行的买卖订单数量(也称为成交量)。成交量的具体解释取决于市场,但通常它能让你了解市场的活跃度。
表示这些价格的典型方式被称为蜡烛图,每个条形图显示为一根蜡烛。以下是 Yandex 2016 年 2 月一天部分报价的图表:

图 10.1:2016 年 2 月 Yandex 的价格数据
存档中包含 2016 年和 2015 年的 M1 数据文件。我们将使用 2016 年的数据进行模型训练,使用 2015 年的数据进行验证(但顺序是任意的,你可以交换它们,甚至使用不同的时间间隔并检查效果)。
交易环境
由于我们有很多代码需要与 Gym API 配合工作,我们将实现交易功能,遵循 Gym 的 Env 类,您应该已经熟悉这个类了。我们的环境在 Chapter10/lib/environ.py 模块中的 StocksEnv 类中实现。它使用几个内部类来保持其状态并编码观察。
首先让我们看看公共 API 类:
import typing as tt
import gymnasium as gym
from gymnasium import spaces
from gymnasium.utils import seeding
from gymnasium.envs.registration import EnvSpec
import enum
import numpy as np
from . import data
DEFAULT_BARS_COUNT = 10
DEFAULT_COMMISSION_PERC = 0.1
class Actions(enum.Enum):
Skip = 0
Buy = 1
Close = 2
我们将所有可用的操作编码为枚举字段,并仅提供三个操作:什么都不做,买入单一股票,关闭现有仓位。
在我们的市场模型中,我们只允许购买单一股票,不支持扩展现有仓位或开设“卖空仓位”(即当你卖出你没有的股票时,预计未来股价会下降)。这是一个有意的决策,因为我试图保持例子简洁,避免过于复杂。为什么不尝试用其他选项进行实验呢?
接下来,我们有环境类:
class StocksEnv(gym.Env):
spec = EnvSpec("StocksEnv-v0")
字段规范对于 gym.Env 兼容性是必需的,并将我们的环境注册到 Gym 的内部注册表中。
这个类提供了两种方式来创建其实例:
@classmethod
def from_dir(cls, data_dir: str, **kwargs):
prices = {
file: data.load_relative(file)
for file in data.price_files(data_dir)
}
return StocksEnv(prices, **kwargs)
如前面的代码所示,第一种方式是调用类方法 from_dir,将数据目录作为参数。在这种情况下,它将从目录中的 CSV 文件加载所有报价,并构建环境。为了处理我们格式的价格数据,Chapter10/lib/data.py 中有几个辅助函数。另一种方式是直接构造类实例。在这种情况下,你应该传递价格字典,该字典必须将报价名称映射到 data.py 中声明的 Prices 数据类。这个对象有五个字段,包含开盘、最高、最低、收盘和成交量的时间序列,这些字段都是一维的 NumPy 数组。data.py 模块还提供了几个帮助函数,如将价格转换为相对格式、枚举给定目录中的文件等。
以下是环境的构造函数:
def __init__(
self, prices: tt.Dict[str, data.Prices],
bars_count: int = DEFAULT_BARS_COUNT,
commission: float = DEFAULT_COMMISSION_PERC,
reset_on_close: bool = True, state_1d: bool = False,
random_ofs_on_reset: bool = True,
reward_on_close: bool = False, volumes=False
):
它接受很多参数来调整环境的行为和观察表示:
-
prices: 包含一个或多个股票价格的数据字典,其中键是工具的名称,值是一个容器对象 data.Prices,包含价格数据数组。
-
bars_count: 我们在观察中传入的条形数量。默认情况下,这是 10 个条形。
-
commission: 我们在买卖股票时需要支付给经纪人的股价百分比。默认情况下,它是 0.1%。
-
reset_on_close: 如果此参数设置为 True(默认设置),则每当代理请求我们关闭现有仓位(即卖出股票)时,我们将停止当前回合。否则,回合将继续,直到时间序列结束,即一年数据。
-
conv_1d: 这个布尔参数用于在传递给代理的观察值中切换不同的价格数据表示方式。如果设置为 True,观察值将具有二维形状,不同价格成分的后续条目将按行组织。例如,最高价格(该条目的最大价格)放在第一行,最低价格放在第二行,收盘价格放在第三行。这种表示方式适用于对时间序列进行 1D 卷积,在这种情况下,数据中的每一行都像 Atari 2D 图像中的不同色彩平面(红色、绿色或蓝色)。如果我们将此选项设置为 False,我们将得到一个包含每个条目组成部分的单一数据数组。这种组织方式适合全连接网络架构。两种表示方式见图 10.2。
-
random_ofs_on_reset: 如果该参数为 True(默认值),则在每次重置环境时,都会选择时间序列中的随机偏移量。否则,我们将从数据的开头开始。
-
reward_on_close: 这个布尔参数在前面讨论的两种奖励方案之间切换。如果设置为 True,代理仅在“收盘”动作时获得奖励。否则,我们会在每个条目上给予一个小奖励,对应于该条目期间的价格波动。
-
volumes: 这个参数控制观察值中的成交量,默认情况下是禁用的。

图 10.2:神经网络的不同数据表示方式
现在我们将继续查看环境构造器:
self._prices = prices
if state_1d:
self._state = State1D(bars_count, commission, reset_on_close,
reward_on_close=reward_on_close, volumes=volumes)
else:
self._state = State(bars_count, commission, reset_on_close,
reward_on_close=reward_on_close, volumes=volumes)
self.action_space = spaces.Discrete(n=len(Actions))
self.observation_space = spaces.Box(
low=-np.inf, high=np.inf, shape=self._state.shape, dtype=np.float32)
self.random_ofs_on_reset = random_ofs_on_reset
StocksEnv 类的大部分功能实现于两个内部类:State 和 State1D。它们负责观察值的准备、我们购买的股票状态和奖励。它们实现了我们数据在观察值中的不同表示方式,我们稍后会查看它们的代码。在构造器中,我们创建了 Gym 所需的状态对象、动作空间和观察空间字段。
该方法定义了我们环境的 reset()功能:
def reset(self, *, seed: int | None = None, options: dict[str, tt.Any] | None = None):
# make selection of the instrument and it’s offset. Then reset the state
super().reset(seed=seed, options=options)
self._instrument = self.np_random.choice(list(self._prices.keys()))
prices = self._prices[self._instrument]
bars = self._state.bars_count
if self.random_ofs_on_reset:
offset = self.np_random.choice(prices.high.shape[0]-bars*10) + bars
else:
offset = bars
self._state.reset(prices, offset)
return self._state.encode(), {}
根据 gym.Env 的语义,我们随机切换将要处理的时间序列,并选择该时间序列中的起始偏移量。选定的价格和偏移量被传递给我们的内部状态实例,然后使用其 encode()函数请求初始观察值。
该方法需要处理代理选择的动作,并返回下一个观察值、奖励和完成标志:
def step(self, action_idx: int) -> tt.Tuple[np.ndarray, float, bool, bool, dict]:
action = Actions(action_idx)
reward, done = self._state.step(action)
obs = self._state.encode()
info = {
"instrument": self._instrument,
"offset": self._state._offset
}
return obs, reward, done, False, info
所有的实际功能都在我们的状态类中实现,因此这个方法只是对状态方法调用的一个简单包装。
gym.Env 的 API 允许你定义 render() 方法处理器,它应该以人类或机器可读的格式渲染当前状态。通常,这个方法用于查看环境状态的内部内容,对调试或追踪代理行为非常有用。例如,市场环境可以将当前价格渲染为图表,以可视化代理在那一刻所看到的内容。我们的环境不支持渲染(因为这个功能是可选的),所以我们根本不定义这个函数。
现在让我们来看一下内部的 environ.State 类,它实现了环境功能的核心:
class State:
def __init__(self, bars_count: int, commission_perc: float, reset_on_close: bool,
reward_on_close: bool = True, volumes: bool = True):
assert bars_count > 0
assert commission_perc >= 0.0
self.bars_count = bars_count
self.commission_perc = commission_perc
self.reset_on_close = reset_on_close
self.reward_on_close = reward_on_close
self.volumes = volumes
self.have_position = False
self.open_price = 0.0
self._prices = None
self._offset = None
构造函数仅仅是检查并将参数保存在对象的字段中,没有做其他事情。
reset() 方法在每次环境请求重置时被调用,必须保存传入的价格数据和起始偏移量:
def reset(self, prices: data.Prices, offset: int):
assert offset >= self.bars_count-1
self.have_position = False
self.open_price = 0.0
self._prices = prices
self._offset = offset
一开始,我们没有购买任何股票,因此我们的状态中有 have_position=False 和 open_price=0.0。
shape 属性返回包含编码状态的 NumPy 数组维度的元组:
@property
def shape(self) -> tt.Tuple[int, ...]:
# [h, l, c] * bars + position_flag + rel_profit
if self.volumes:
return 4 * self.bars_count + 1 + 1,
else:
return 3 * self.bars_count + 1 + 1,
State 类被编码为一个单一的向量(图 10.2 中的顶部部分),该向量包括价格(可选的成交量)和两个数字,表示是否持有股票以及仓位利润。
encode() 方法将当前偏移量的价格打包成一个 NumPy 数组,这将作为代理的观察值:
def encode(self) -> np.ndarray:
res = np.ndarray(shape=self.shape, dtype=np.float32)
shift = 0
for bar_idx in range(-self.bars_count+1, 1):
ofs = self._offset + bar_idx
res[shift] = self._prices.high[ofs]
shift += 1
res[shift] = self._prices.low[ofs]
shift += 1
res[shift] = self._prices.close[ofs]
shift += 1
if self.volumes:
res[shift] = self._prices.volume[ofs]
shift += 1
res[shift] = float(self.have_position)
shift += 1
if not self.have_position:
res[shift] = 0.0
else:
res[shift] = self._cur_close() / self.open_price - 1.0
return res
这个辅助方法计算当前 K 线的收盘价:
def _cur_close(self) -> float:
open = self._prices.open[self._offset]
rel_close = self._prices.close[self._offset]
return open * (1.0 + rel_close)
传递给 State 类的价格相对于开盘价是相对形式:高、低和收盘价组件是相对于开盘价的比例。我们在讨论训练数据时已经讨论过这种表示法,它(可能)有助于我们的代理学习与实际价格值无关的价格模式。
step() 方法是 State 类中最复杂的代码部分:
def step(self, action: Actions) -> tt.Tuple[float, bool]:
reward = 0.0
done = False
close = self._cur_close()
它负责在我们的环境中执行一步操作。退出时,它必须返回一个百分比形式的奖励,并指示剧集是否结束。
如果代理决定购买一只股票,我们会改变状态并支付佣金:
if action == Actions.Buy and not self.have_position:
self.have_position = True
self.open_price = close
reward -= self.commission_perc
在我们的状态下,我们假设在当前 K 线的收盘价进行即时订单执行,这对我们来说是一个简化;通常,订单可能在不同的价格上执行,这被称为价格滑点。
如果我们有持仓,且代理要求我们平仓,我们需要再次支付佣金,在重置模式下改变已完成标志,给整个仓位一个最终的奖励,并改变我们的状态:
elif action == Actions.Close and self.have_position:
reward -= self.commission_perc
done |= self.reset_on_close
if self.reward_on_close:
reward += 100.0 * (close / self.open_price - 1.0)
self.have_position = False
self.open_price = 0.0
在函数的其余部分,我们修改当前偏移量并给予最后一根 K 线运动的奖励:
self._offset += 1
prev_close = close
close = self._cur_close()
done |= self._offset >= self._prices.close.shape[0]-1
if self.have_position and not self.reward_on_close:
reward += 100.0 * (close / prev_close - 1.0)
return reward, done
这就是 State 类的全部内容,让我们来看一下 State1D,它具有相同的行为,仅仅是重写了传递给代理的状态表示:
class State1D(State):
@property
def shape(self) -> tt.Tuple[int, ...]:
if self.volumes:
return 6, self.bars_count
else:
return 5, self.bars_count
这种表示的形状有所不同,因为我们的价格被编码为适用于 1D 卷积操作符的 2D 矩阵。
该方法根据当前偏移量、是否需要成交量以及是否拥有股票,将价格编码到我们的矩阵中:
def encode(self) -> np.ndarray:
res = np.zeros(shape=self.shape, dtype=np.float32)
start = self._offset-(self.bars_count-1)
stop = self._offset+1
res[0] = self._prices.high[start:stop]
res[1] = self._prices.low[start:stop]
res[2] = self._prices.close[start:stop]
if self.volumes:
res[3] = self._prices.volume[start:stop]
dst = 4
else:
dst = 3
if self.have_position:
res[dst] = 1.0
res[dst+1] = self._cur_close() / self.open_price - 1.0
return res
这就是我们的交易环境。与 Gym API 的兼容性使得我们能够将其插入到我们用来处理 Atari 游戏的熟悉类中。现在我们来做这个。
模型
在本示例中,使用了两种 DQN 架构:一个是简单的三层前馈网络,另一个是使用 1D 卷积作为特征提取器的网络,后接两层全连接层输出 Q 值。它们都使用了第八章中描述的对战架构。同时,也使用了双重 DQN 和两步贝尔曼展开。其余过程与经典 DQN 相同(见第六章)。这两种模型位于 Chapter10/lib/models.py 中,并且非常简单。我们先从前馈模型开始:
class SimpleFFDQN(nn.Module):
def __init__(self, obs_len: int, actions_n: int):
super(SimpleFFDQN, self).__init__()
self.fc_val = nn.Sequential(
nn.Linear(obs_len, 512),
nn.ReLU(),
nn.Linear(512, 512),
nn.ReLU(),
nn.Linear(512, 1)
)
self.fc_adv = nn.Sequential(
nn.Linear(obs_len, 512),
nn.ReLU(),
nn.Linear(512, 512),
nn.ReLU(),
nn.Linear(512, actions_n)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
val = self.fc_val(x)
adv = self.fc_adv(x)
return val + (adv - adv.mean(dim=1, keepdim=True))
前馈模型使用独立的网络进行 Q 值和优势预测。
卷积模型具有一个常见的特征提取层,使用 1D 卷积操作,并且有两个全连接头用于输出状态值和动作优势:
class DQNConv1D(nn.Module):
def __init__(self, shape: tt.Tuple[int, ...], actions_n: int):
super(DQNConv1D, self).__init__()
self.conv = nn.Sequential(
nn.Conv1d(shape[0], 128, 5),
nn.ReLU(),
nn.Conv1d(128, 128, 5),
nn.ReLU(),
nn.Flatten(),
)
size = self.conv(torch.zeros(1, *shape)).size()[-1]
self.fc_val = nn.Sequential(
nn.Linear(size, 512),
nn.ReLU(),
nn.Linear(512, 1)
)
self.fc_adv = nn.Sequential(
nn.Linear(size, 512),
nn.ReLU(),
nn.Linear(512, actions_n)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
conv_out = self.conv(x)
val = self.fc_val(conv_out)
adv = self.fc_adv(conv_out)
return val + (adv - adv.mean(dim=1, keepdim=True))
如你所见,该模型与我们在 Atari 示例中使用的 DQN Dueling 架构非常相似。
训练代码
在本示例中,我们有两个非常相似的训练模块:一个用于前馈模型,另一个用于 1D 卷积。对于这两个模块,除了第八章中提供的内容外,没有任何新的内容:
-
它们使用 epsilon-greedy 动作选择来进行探索。epsilon 在前 100 万步内从 1.0 线性衰减到 0.1。
-
正在使用一个简单的经验回放缓冲区,大小为 100k,最初填充有 10k 个过渡。
-
每 1,000 步,我们会计算固定状态集合的均值,以检查训练过程中 Q 值的动态变化。
-
每 100k 步,我们进行验证:在训练数据和之前未见过的报价上各进行 100 轮测试。验证结果会记录在 TensorBoard 中,包括平均利润、平均条数以及持股比例。此步骤可以帮助我们检查是否存在过拟合情况。
训练模块位于 Chapter10/train_model.py(前馈模型)和 Chapter10/train_model_conv.py(含 1D 卷积层)。两个版本都接受相同的命令行选项。
要开始训练,您需要使用 --data 选项传递训练数据,可以是单个 CSV 文件或包含文件的整个目录。默认情况下,训练模块使用 2016 年的 Yandex 行情(文件 data/YNDX_160101_161231.csv)。对于验证数据,有一个 --val 选项,默认使用 Yandex 2015 年的行情。另一个必需选项是 -r,用于传递运行名称。此名称将用作 TensorBoard 运行名称和用于创建保存模型的目录。
结果
现在我们已经实施了它们,让我们比较一下我们两个模型的表现,首先从前馈变体开始。
前馈模型
在训练过程中,代理每次获得的平均奖励都在缓慢但稳步增长。在 300k 个 episode 后,增长放缓。以下是显示训练期间原始奖励和简单移动平均值的图表(图 10.3):

图 10.3: 训练过程中的奖励。原始值(左)和平滑后(右)
另一对图表(图 10.4)显示了在相同的训练数据上进行测试时,不执行随机动作(𝜖 = 0)所获得的奖励:

图 10.4: 测试的奖励。原始值(左)和平滑后(右)
训练和测试奖励图表显示,代理正在学习如何随时间增加利润。

图 10.5: episode 的长度。原始值(左)和平滑后(右)
每个 episode 的长度在 100k 个 episode 后也有所增加,因为代理学到了持有股份可能是有利的。
此外,我们监控随机状态集的预测值。以下图表显示,在训练过程中网络对这些状态变得越来越乐观:

图 10.6: 随机状态集的预测值
到目前为止,所有图表看起来都很好,但所有这些图表都是使用训练数据获取的。很棒,我们的代理正在学习如何在历史数据上获利。但它会在以前从未见过的数据上工作吗?为了检查这一点,我们在 2,015 年的报价上进行验证,奖励显示在图 10.7 中:

图 10.7: 验证数据集上的奖励。原始值(左)和平滑后(右)
这张图有点令人失望:奖励没有上升趋势。在图表的平滑版本中,我们甚至可能看到相反的情况——奖励在训练的第一小时后缓慢下降(在那个时刻,我们在图 10.5 上有了显著的训练周期增长)。这可能是代理过拟合的表现,过拟合始于 100 万次训练迭代。然而,在训练的前 4 小时,奖励仍然高于-0.2%(这是我们环境中的经纪商佣金——买入股票时是 0.1%,卖出时也是 0.1%),意味着我们的代理比随机的“买入卖出猴子”表现得更好。
在训练过程中,我们的代码会保存模型以供以后实验使用。每当我们持有状态集上的平均 Q 值更新最大值,或当验证集上的奖励突破以前的记录时,它都会这样做。还有一个工具可以加载模型,并使用命令行选项在你提供的价格上进行交易,并绘制利润随时间变化的图表。该工具名为Chapter10/run_model.py,使用方式如下:
Chapter10$ ./run_model.py -d data/YNDX_160101_161231.csv -m saves/simple-t1/mean_value-0.277.data -b 10 -n YNDX16
该工具接受的选项如下:
-
-d:这是用于的报价路径。在所示命令中,我们将模型应用于它训练时的数据。
-
-m:这是模型文件的路径。默认情况下,训练代码会将其保存在
saves目录中。 -
-b:此选项显示在上下文中传递给模型的条形图数。它必须与训练时使用的条形图数量匹配,默认值为 10,可以在训练代码中更改。
-
-n:这是附加到生成的图像上的后缀。
-
--commission:此选项允许你重新定义经纪商的佣金,默认值为 0.1%。
最后,工具会创建一个总利润动态图(以百分比表示)。以下是 Yandex 2016 年报价(用于训练)的奖励图:

图 10.8:训练数据上的交易利润(左)与验证数据上的交易利润(右)
在训练数据上的结果看起来非常惊人:仅仅一年就获得了 150%的利润。然而,在验证数据集上的结果要差得多,正如我们从 TensorBoard 中的验证图表中看到的那样。
为了检查我们的系统在零佣金下是否有盈利,我们可以使用--commission 0.0选项重新运行相同的数据:

图 10.9:没有经纪商佣金的验证数据交易利润
我们有一些回撤较大的糟糕日子,但整体结果还是不错的:没有佣金时,我们的代理是可以盈利的。当然,佣金并不是唯一的问题。我们的订单模拟非常原始,并没有考虑到现实中的情况,例如价格差距和订单执行的滑点。
如果我们选择在验证集上获得最佳奖励的模型,奖励动态会稍好一些。盈利能力较低,但在未见过的报价上的回撤要低得多(并且在以下图表中启用了佣金):

图 10.10:最佳验证奖励模型的奖励。训练数据(左)和验证数据(右)
但是,当然,基于验证数据选择最佳模型是作弊——通过使用验证结果来选择模型,我们实际上破坏了验证的意义。因此,上述图表仅用于说明有些模型即使在未见过的数据上也能表现得还不错。
卷积模型
本示例中实现的第二个模型使用一维卷积滤波器从价格数据中提取特征。这使我们能够在不显著增加网络规模的情况下,增加每步操作中代理所看到的上下文窗口中的条形数量。默认情况下,卷积模型示例使用 50 个条形数据作为上下文。训练代码位于 Chapter10/train_model_conv.py,并且接受与前馈版本相同的一组命令行参数。
训练动态几乎相同,但在验证集上的奖励稍微高一些,并且开始过拟合得更晚:

图 10.11:训练过程中的奖励。原始值(左)和平滑值(右)

图 10.12:验证数据集上的奖励。原始值(左)和平滑值(右)
待尝试的事项
如前所述,金融市场庞大且复杂。我们尝试的方法只是一个开始。使用强化学习(RL)来创建一个完整且有利可图的交易策略是一个庞大的项目,可能需要数月的专注工作。然而,我们可以尝试一些方法来更好地理解这个主题:
-
我们的数据表示方式显然并不完美。我们没有考虑重要的价格水平(支撑位和阻力位)、整数价格值和其他金融市场信息。将这些信息纳入观察范围可能是一个具有挑战性的问题,您可以尝试进一步探索。
-
在不同时间框架下分析市场价格。像一分钟条形数据这样的低级数据非常嘈杂(因为它们包含了由单个交易导致的大量小幅价格波动),就像用显微镜观察市场一样。在更大的尺度下,如一小时或一天的条形数据,您可以看到数据运动中的大规模、长周期趋势,这对价格预测可能非常重要。
原则上,我们的代理可以同时从多个尺度上查看价格,考虑到的不仅仅是最近的低级别波动,还包括整体趋势(近年来的自然语言处理(NLP)创新,如 transformers、注意力机制和长时间上下文窗口,可能在这里非常有帮助)。
-
需要更多的训练数据。单只股票一年的数据仅有 13 万条数据,这可能不足以捕捉所有市场情形。理想情况下,真实生活中的代理应该在更大的数据集上进行训练,例如过去 10 年或更长时间内数百只股票的价格数据。
-
尝试不同的网络架构。卷积模型比前馈模型稍微快一点收敛,但有很多需要优化的地方:层数、卷积核大小、残差架构、注意力机制等等。
-
自然语言处理(NLP)与金融数据分析之间有许多相似之处:两者都处理人类创作的、具有可变长度的数据序列。你可以尝试将价格条表示为某种“金融语言”中的“词汇”(例如,“价格上涨 1%”→符号 A,“价格上涨 2%”→符号 B),然后将 NLP 方法应用于这种语言。例如,从“句子”中训练嵌入,以捕捉金融市场的结构,或者使用变换器(transformers)甚至大规模语言模型(LLMs)进行数据预测和分类。
总结
在本章中,我们看到一个强化学习的实际例子,并实现了一个交易代理和一个自定义的 Gym 环境。我们尝试了两种不同的架构:一种是将价格历史作为输入的前馈网络,另一种是 1D 卷积网络。两种架构都使用了 DQN 方法,并且加入了第八章中描述的一些扩展。
这是本书第二部分的最后一章。在第三部分,我们将讨论另一类强化学习方法:策略梯度。我们已经简单提到过这种方法,但在接下来的章节中,我们将深入探讨这一主题,介绍 REINFORCE 方法以及该家族中最好的方法:异步优势演员-评论员(Asynchronous Advantage Actor-Critic),也称为 A3C。
留下您的评论!
感谢您从 Packt Publishing 购买本书——我们希望您喜欢它!您的反馈对我们至关重要,帮助我们不断改进和成长。阅读完毕后,请花一点时间在亚马逊上留下评论;这只需要一分钟,但对像您这样的读者意义重大。扫描下方二维码,获取您选择的免费电子书。packt.link/NzOWQ

第三部分
基于策略的方法
第十一章:策略梯度
在本书第三部分的第一章中,我们将考虑一种处理马尔可夫决策过程(MDP)问题的替代方法,这些方法形成了一个完整的策略梯度方法系列。在某些情况下,这些方法比基于值的方法效果更好,因此熟悉它们非常重要。
在这一章中,我们将:
-
概述这些方法、它们的动机,以及与我们已知的 Q 学习方法相比,它们的优缺点。
-
从一个简单的策略梯度方法——REINFORCE 开始,尝试将其应用到我们的 CartPole 环境中,并与深度 Q 网络(DQN)方法进行比较。
-
讨论原始 REINFORCE 方法的问题以及如何通过策略梯度(PG)方法来解决这些问题,这是一种向更高级方法 A3C 迈进的步骤,我们将在下一章详细讨论。
值与策略
在进入本章的主要内容——策略梯度之前,让我们回顾一下本书第二部分涵盖的各种方法的共同特征。值迭代和 Q 学习的核心主题是状态的值(V[s])或状态和动作的值(Q[s,a])。值被定义为我们从这个状态中获得的折扣总奖励,或者从这个状态发出特定动作所获得的奖励。如果我们知道这个量,我们在每一步的决策就变得简单且显而易见:我们只需要在值的基础上贪婪地行动,这就能确保我们在整个回合结束时获得一个较好的总奖励。因此,状态的值(在值迭代方法中)或状态+动作的值(在 Q 学习中)在我们与最佳奖励之间架起了一座桥梁。为了得到这些值,我们使用了贝尔曼方程,它通过下一个步骤的值来表示当前步骤的值。
在第一章中,我们定义了在每个状态下告诉我们该做什么的实体为策略。正如 Q 学习方法一样,当值告诉我们如何行动时,它们实际上是在定义我们的策略。正式来说,这可以写成π(s) = arg max[a]Q(s,a),这意味着在每个状态 s 下,我们的策略π的结果是具有最大 Q 值的动作。
策略与值的关系是显而易见的,所以我没有将策略单独作为一个实体进行强调,我们的大部分时间都在讨论值以及如何正确地近似它们。现在是时候关注这个关系以及策略本身了。
为什么是策略?
策略是一个值得深入探讨的有趣话题,原因有很多。首先,当我们处理强化学习问题时,策略正是我们需要寻找的内容。当智能体获得观察结果并需要决定下一步行动时,它需要的是策略,而不是状态或特定动作的值。我们确实关心总奖励,但在每个状态下,我们可能对状态的确切值并不感兴趣。
想象一下这种情况:你正在丛林中走路,突然意识到有一只饥饿的老虎藏在灌木丛中。你有几种选择,比如跑步、躲藏或者试图把背包扔向它,但问“跑步这个动作的确切值是多少?它大于什么都不做的动作值吗?”有点傻。你并不太关心这个值,因为你需要迅速做出决定,仅此而已。我们的 Q 学习方法通过近似状态的值并尝试选择最佳备选方案来间接回答策略问题,但如果我们对值不感兴趣,为什么要做多余的工作呢?
策略可能更受欢迎的另一个原因是当环境有很多动作,或者在极端情况下,具有连续动作空间的问题。为了能够根据 Q(s,a)决定最佳动作,我们需要解决一个小的优化问题,寻找能够最大化 Q(s,a)的 a。在一个具有多个离散动作的 Atari 游戏中,这不是问题:我们只是近似所有动作的值,并选择 Q 值最大的动作。如果我们的动作不是一个小的离散集合,而是附有标量值,如方向盘角度或我们希望从老虎那里逃跑的速度,那么这个优化问题变得非常困难,因为 Q 通常是由一个高度非线性的神经网络(NN)表示的,因此找到能最大化函数值的参数可能会很棘手。在这种情况下,避免使用值并直接处理策略要可行得多。
策略学习的额外好处之一是具有随机性的环境。正如你在第八章看到的,在一个分类 DQN 中,我们的智能体可以通过处理 Q 值的分布而非期望均值,获得很大的收益,因为我们的网络能够更精确地捕捉到潜在的概率分布。正如你在下一节将看到的,策略自然表示为行动的概率,这一步与分类 DQN 方法的方向一致。
策略表示
现在你已经了解了策略的好处,让我们试试看。那么,我们如何表示策略呢?在 Q 值的情况下,它们是由返回动作值的标量的神经网络(NN)参数化的。如果我们希望我们的网络对动作进行参数化,我们有几种选择。最简单的方式可能就是直接返回动作的标识符(在离散动作集合的情况下)。然而,这并不是处理离散集合的最佳方式。一个更常见的解决方案,在分类任务中被广泛使用,是返回我们动作的概率分布。换句话说,对于 N 个互斥的动作,我们返回 N 个数字,表示在给定状态下采取每个动作的概率(我们将状态作为输入传递给网络)。这种表示方法在下图中展示:

图 11.1:离散动作集的神经网络策略近似
将动作表示为概率的这种方式有一个额外的优势,就是平滑的表示:如果我们稍微改变网络的权重,网络的输出也会发生轻微变化。在离散数值输出的情况下,即使权重做了小幅调整,也可能导致跳跃到不同的动作。然而,如果我们的输出是概率分布,权重的微小变化通常会导致输出分布的轻微变化,例如稍微增加某个动作的概率,而其他动作的概率相应减少。这是一个非常好的特性,因为梯度优化方法的核心就是稍微调整模型的参数来改进结果。
策略梯度
我们已经决定了策略的表示方式,但到目前为止我们还没有看到的是如何改变网络的参数来改善策略。如果你还记得第四章,我们通过交叉熵方法解决了一个非常相似的问题:我们的网络将观察值作为输入,并返回动作的概率分布。实际上,交叉熵方法是我们在本书这一部分将讨论的方法的“弟弟”。首先,我们将了解一种叫做 REINFORCE 的方法,它与交叉熵方法仅有细微的区别,但在此之前,我们需要了解一些我们将在本章及后续章节中使用的数学符号。
我们将策略梯度定义为∇J ≈𝔼[Q(s,a)∇log π(a|s)]。当然,这有强有力的证明,但这并不是最重要的。我们更感兴趣的是这个表达式的含义。
策略梯度定义了我们需要改变网络参数的方向,以根据累积的总奖励来改进策略。梯度的大小与所采取的动作的值成正比,这个值在公式中表示为 Q(s,a),梯度等于所采取动作的对数概率的梯度。这意味着我们试图增加那些给我们带来较好总奖励的动作的概率,并减少那些带来较差最终结果的动作的概率。公式中的期望符号𝔼只是表示我们对在环境中采取的几个步骤的梯度进行平均。
从实际应用的角度来看,策略梯度方法可以通过优化这个损失函数来实现:ℒ = −Q(s,a)log π(a|s)。负号很重要,因为在随机梯度下降(SGD)过程中,损失函数是被最小化的,但我们希望最大化我们的策略梯度。你将在本章及后续章节中看到策略梯度方法的代码示例。
REINFORCE 方法
你刚刚看到的策略梯度公式是大多数基于策略的方法所使用的,但具体细节可能会有所不同。一个非常重要的点是如何精确地计算梯度尺度,Q(s,a)。在第四章的交叉熵方法中,我们播放了几个回合,计算了每个回合的总奖励,并训练来自奖励优于平均水平的回合的转移。这种训练过程是一个策略梯度方法,对于来自好回合(有较大总奖励)的状态-动作对,Q(s,a) = 1,而对于来自差回合的状态-动作对,Q(s,a) = 0。
交叉熵方法即使在那些简单的假设下也能起作用,但显而易见的改进是用 Q(s,a)来进行训练,而不仅仅是 0 和 1。为什么这样会有帮助?答案是可以更精细地区分回合。例如,来自总奖励为 10 的回合的转移应该比来自奖励为 1 的回合的转移对梯度贡献更大。另一个使用 Q(s,a)而不仅仅是 0 或 1 常数的原因是,在回合的开始增加好动作的概率,并减少接近回合结束时的动作概率。在交叉熵方法中,我们选取“精英”回合,并训练其动作,而不管动作在回合中的偏移量。通过使用 Q(s,a)(包括折扣因子γ),我们在回合开始时对好动作给予更多重视,而不是回合结束时的动作。这正是 REINFORCE 方法的思想。其步骤如下:
-
使用随机权重初始化网络。
-
播放 N 个完整的回合,保存它们的(s,a,r,s′)转移。
-
对每个回合 k 的每一步 t,计算随后的步骤的折扣总奖励:
![π (a |s) = P[At = a|St = s]]()
-
计算所有转移的损失函数:
![π (a |s) = P[At = a|St = s]]()
-
执行 SGD 更新权重,最小化损失。
-
从第 2 步开始重复,直到收敛为止。
该算法与 Q 学习有几个重要的不同之处:
-
不需要显式探索:在 Q 学习中,我们使用了ε-greedy 策略来探索环境,并防止我们的智能体陷入一个非最优策略中。现在,使用网络返回的概率,探索过程会自动进行。最开始,网络以随机权重初始化,返回均匀概率分布。这种分布对应于智能体的随机行为。
-
不使用回放缓冲区:策略梯度方法属于在线方法类,这意味着我们不能使用旧策略获得的数据进行训练。这既有好的一面,也有坏的一面。好的一面是,这类方法通常收敛较快。坏的一面是,它们通常比离策略方法(如 DQN)需要更多的环境交互。
-
不需要目标网络:在这里,我们使用 Q 值,但它们是从我们在环境中的经验中获得的。在 DQN 中,我们使用目标网络来打破 Q 值逼近中的相关性,但我们不再进行逼近了。在下一章,你会看到目标网络技巧在策略梯度方法中仍然是有用的。
CartPole 示例
为了看到这个方法的实际效果,我们来检查在熟悉的 CartPole 环境中实现 REINFORCE 方法的代码。该示例的完整代码位于 Chapter11/02_cartpole_reinforce.py。
一开始,我们定义了超参数(省略了导入部分):
GAMMA = 0.99
LEARNING_RATE = 0.01
EPISODES_TO_TRAIN = 4
EPISODES_TO_TRAIN 值指定了我们将用于训练的完整回合数。
以下网络也应该对你来说很熟悉:
class PGN(nn.Module):
def __init__(self, input_size: int, n_actions: int):
super(PGN, self).__init__()
self.net = nn.Sequential(
nn.Linear(input_size, 128),
nn.ReLU(),
nn.Linear(128, n_actions)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
注意,尽管我们的网络返回的是概率,但我们并没有对输出应用 softmax 非线性激活函数。这样做的原因是,我们将使用 PyTorch 的 log_softmax 函数一次性计算 softmax 输出的对数。这样计算方法在数值上更稳定;但是,我们需要记住,网络的输出不是概率,而是原始得分(通常称为 logits)。
下一个函数有点棘手:
def calc_qvals(rewards: tt.List[float]) -> tt.List[float]:
res = []
sum_r = 0.0
for r in reversed(rewards):
sum_r *= GAMMA
sum_r += r
res.append(sum_r)
return list(reversed(res))
它接受一个包含整个回合奖励的列表,并需要为每一步计算折扣后的总奖励。为了高效地做到这一点,我们从局部奖励列表的末尾计算奖励。实际上,回合的最后一步将具有与其局部奖励相等的总奖励。倒数第二步的总奖励将是 r[t−1] + γ ⋅r[t](如果 t 是最后一步的索引)。
我们的 sum_r 变量包含前一步的总奖励,因此要获取当前步骤的总奖励,我们需要将 sum_r 乘以 γ 并加上该步骤的局部奖励。
训练循环之前的准备步骤应该对你来说也很熟悉:
if __name__ == "__main__":
env = gym.make("CartPole-v1")
writer = SummaryWriter(comment="-cartpole-reinforce")
net = PGN(env.observation_space.shape[0], env.action_space.n)
print(net)
agent = ptan.agent.PolicyAgent(
net, preprocessor=ptan.agent.float32_preprocessor, apply_softmax=True)
exp_source = ExperienceSourceFirstLast(env, agent, gamma=GAMMA)
optimizer = optim.Adam(net.parameters(), lr=LEARNING_RATE)
唯一的新元素是来自 PTAN 库的 agent 类。在这里,我们使用 ptan.agent.PolicyAgent,它需要为每个观测做出动作决策。由于我们的网络现在返回的是动作的概率分布,为了选择要执行的动作,我们需要从网络中获取概率,然后从该概率分布中进行随机采样。
当我们使用 DQN 时,网络的输出是 Q 值,因此如果某个动作的值为 0.4,另一个动作的值为 0.5,那么第二个动作就会被 100% 的概率优先选择。在概率分布的情况下,如果第一个动作的概率为 0.4,第二个动作的概率为 0.5,我们的智能体应该以 40% 的概率选择第一个动作,以 50% 的概率选择第二个动作。当然,我们的网络也可以决定 100% 选择第二个动作,在这种情况下,第一个动作的概率为 0,第二个动作的概率为 1。
这个差异很重要,需要理解,但实现上的变化不大。我们的 PolicyAgent 内部调用 NumPy 的 random.choice()函数,并使用网络的概率。apply_softmax 参数指示它首先通过调用 softmax 将网络输出转换为概率。第三个参数 preprocessor 是为了绕过 Gymnasium 中的 CartPole 环境返回的观察值是 float64 类型,而 PyTorch 需要 float32 类型的问题。
在开始训练循环之前,我们需要一些变量:
total_rewards = []
done_episodes = 0
batch_episodes = 0
batch_states, batch_actions, batch_qvals = [], [], []
cur_rewards = []
前两个变量 total_rewards 和 done_episodes 用于报告,包含回合的总奖励和已完成回合的计数。接下来的几个变量用于收集训练数据。cur_rewards 列表包含当前正在进行的回合的局部奖励。当该回合结束时,我们使用 calc_qvals()函数从局部奖励计算折扣后的总奖励,并将其添加到 batch_qvals 列表中。batch_states 和 batch_actions 列表包含我们在上次训练中看到的状态和动作。
以下代码片段是训练循环的开始:
for step_idx, exp in enumerate(exp_source):
batch_states.append(exp.state)
batch_actions.append(int(exp.action))
cur_rewards.append(exp.reward)
if exp.last_state is None:
batch_qvals.extend(calc_qvals(cur_rewards))
cur_rewards.clear()
batch_episodes += 1
我们从经验源中获得的每个经验包含状态、动作、局部奖励和下一个状态。如果回合已结束,下一个状态将为 None。对于非终止的经验条目,我们只需将状态、动作和局部奖励保存在列表中。在回合结束时,我们将局部奖励转换为 Q 值,并增加回合计数器。
训练循环的这一部分在回合结束时执行,负责报告当前进展并将指标写入 TensorBoard:
new_rewards = exp_source.pop_total_rewards()
if new_rewards:
done_episodes += 1
reward = new_rewards[0]
total_rewards.append(reward)
mean_rewards = float(np.mean(total_rewards[-100:]))
print(f"{step_idx}: reward: {reward:6.2f}, mean_100: {mean_rewards:6.2f}, "
f"episodes: {done_episodes}")
writer.add_scalar("reward", reward, step_idx)
writer.add_scalar("reward_100", mean_rewards, step_idx)
writer.add_scalar("episodes", done_episodes, step_idx)
if mean_rewards > 450:
print(f"Solved in {step_idx} steps and {done_episodes} episodes!")
break
当自上次训练步骤以来经过足够的回合时,我们可以优化收集到的示例。第一步,我们将状态、动作和 Q 值转换为适当的 PyTorch 格式:
if batch_episodes < EPISODES_TO_TRAIN:
continue
optimizer.zero_grad()
states_t = torch.as_tensor(np.asarray(batch_states))
batch_actions_t = torch.as_tensor(np.asarray(batch_actions))
batch_qvals_t = torch.as_tensor(np.asarray(batch_qvals))
然后,我们根据步骤计算损失:
logits_t = net(states_t)
log_prob_t = F.log_softmax(logits_t, dim=1)
batch_idx = range(len(batch_states))
act_probs_t = log_prob_t[batch_idx, batch_actions_t]
log_prob_actions_v = batch_qvals_t * act_probs_t
loss_t = -log_prob_actions_v.mean()
在这里,我们要求网络将状态计算为 logits,并计算其对数和 softmax。在第三行,我们从所采取的动作中选择对数概率,并用 Q 值进行缩放。在最后一行,我们平均这些缩放后的值并取负数,以获得需要最小化的损失。再强调一下,这个负号非常重要,因为我们的策略梯度需要最大化,以改善策略。由于 PyTorch 中的优化器是最小化损失函数的,因此我们需要取策略梯度的负值。
剩余的代码很清晰:
loss_t.backward()
optimizer.step()
batch_episodes = 0
batch_states.clear()
batch_actions.clear()
batch_qvals.clear()
writer.close()
在这里,我们执行反向传播来收集变量中的梯度,并要求优化器执行 SGD 更新。在训练循环结束时,我们重置回合计数器并清空列表,以便收集新的数据。
结果
作为参考,我在 CartPole 环境中实现了 DQN,使用的超参数几乎与我们的 REINFORCE 示例相同。你可以在 Chapter11/01_cartpole_dqn.py 中找到它。两个示例都不需要任何命令行参数,并且它们应该在不到一分钟的时间内收敛:
Chapter11$ ./02_cartpole_reinforce.py
PGN(
(net): Sequential(
(0): Linear(in_features=4, out_features=128, bias=True)
(1): ReLU()
(2): Linear(in_features=128, out_features=2, bias=True)
)
)
31: reward: 31.00, mean_100: 31.00, episodes: 1
42: reward: 11.00, mean_100: 21.00, episodes: 2
54: reward: 12.00, mean_100: 18.00, episodes: 3
94: reward: 40.00, mean_100: 23.50, episodes: 4
159: reward: 65.00, mean_100: 31.80, episodes: 5
...
65857: reward: 500.00, mean_100: 440.60, episodes: 380
66357: reward: 500.00, mean_100: 442.42, episodes: 381
66857: reward: 500.00, mean_100: 445.59, episodes: 382
67357: reward: 500.00, mean_100: 448.24, episodes: 383
67857: reward: 500.00, mean_100: 451.31, episodes: 384
Solved in 67857 steps and 384 episodes!
DQN 和 REINFORCE 的收敛动态如下图所示。你的训练动态可能会因训练的随机性而有所不同。

图 11.2:随着时间推移的回合数(左)和训练步骤(右)
这两张图表比较了随时间推移和训练步骤的回合数。
下一张图表比较了平滑的回合奖励:

图 11.3:两种方法的奖励动态
如你所见,方法几乎以相同的速度收敛(REINFORCE 略快),但当平均奖励超过 400 时,DQN 出现了问题,必须几乎从头开始。
如果你还记得第四章,交叉熵方法需要大约 40 个批次,每个批次 16 个回合来解决 CartPole 环境,总共是 640 个回合。REINFORCE 方法能够在不到 400 个回合内完成同样的任务,这是一个很好的改进。
基于策略与基于价值的方法
现在让我们暂时回顾一下我们刚才看到的代码,并检查这些方法家族之间的差异:
-
策略方法直接优化我们关心的内容:我们的行为。价值方法,如 DQN,通过间接的方式来做同样的事情,先学习价值,然后根据这个价值提供策略。
-
策略方法是在线的,需要来自环境的新样本。价值方法可以从旧的数据中受益,这些数据来自于旧的策略、人类示范以及其他来源。
-
策略方法通常较为低效,意味着它们需要更多与环境的交互。价值方法可以从大规模的回放缓冲区中受益。然而,样本效率并不意味着价值方法在计算效率上更高,实际上,往往是相反的。
-
在前面的示例中,在训练过程中,我们只需要访问一次神经网络,以获得行动的概率。在 DQN 中,我们需要处理两批状态:一批是当前状态,另一批是贝尔曼更新中的下一个状态。
如你所见,似乎没有强烈的倾向偏向某个方法家族。在某些情况下,策略方法会是更自然的选择,比如在连续控制问题中,或者在环境访问便宜且快速的情况下。然而,也有许多情况是价值方法会大放异彩,比如最近 DQN 变体在 Atari 游戏上的最新成果。理想情况下,你应该熟悉这两种方法家族,并理解它们的优缺点。
在下一节中,我们将讨论 REINFORCE 方法的局限性、改进方法,以及如何将政策梯度方法应用到我们最喜欢的 Pong 游戏中。
REINFORCE 问题
在前一节中,我们讨论了 REINFORCE 方法,它是交叉熵方法的自然扩展。不幸的是,REINFORCE 和交叉熵方法仍然存在一些问题,这使得它们都仅限于简单的环境中。
需要完整的回合
首先,我们仍然需要等待完整的回合结束才能开始训练。更糟糕的是,REINFORCE 和交叉熵方法在使用更多回合进行训练时表现更好(只是因为更多的回合意味着更多的训练数据,这意味着更准确的政策梯度)。这种情况在 CartPole 的短回合中是可以接受的,因为一开始我们几乎无法保持杆子超过 10 步;但在 Pong 中,情况完全不同:每个回合可能持续数百甚至数千帧。从训练的角度来看,这同样糟糕,因为我们的训练批次变得非常大;从样本效率的角度来看,我们需要与环境进行大量的交互才能执行一次训练步骤。
完整回合要求的目的是尽可能准确地获得 Q 估计。当我们讨论 DQN 时,你会看到,实际上,用一阶贝尔曼方程替换折扣奖励的精确值与我们的估计是可以的:Q(s,a) = r[a] + γV (s′)。为了估计 V(s),我们使用了自己的 Q 估计,但在政策梯度的情况下,我们不再有 V(s) 或 Q(s,a)。
为了解决这个问题,存在两种方法:
-
我们可以要求我们的网络估计 V(s),并使用这个估计来获得 Q。这种方法将在下一章讨论,它被称为演员-评论员方法,是政策梯度家族中最流行的方法。
-
另外,我们可以执行贝尔曼方程,展开 N 步,这将有效利用当 γ 小于 1 时,价值贡献递减的事实。事实上,当 γ = 0.9 时,第 10 步的价值系数将是 0.9¹⁰ ≈ 0.35。在第 50 步时,这个系数将是 0.9⁵⁰ ≈ 0.00515,这对总奖励的贡献非常小。当 γ = 0.99 时,所需的步数会变大,但我们仍然可以这样做。
高梯度方差
在策略梯度公式中,∇J ≈𝔼[Q(s,a)∇log π(a|s)],我们得到了与给定状态下的折扣奖励成正比的梯度。然而,这个奖励的范围受到环境的高度依赖。例如,在 CartPole 环境中,我们每当保持杆子垂直时,就会得到 1 的奖励。如果我们能保持五步,我们将获得总的(未折扣的)奖励为 5。如果我们的智能体非常聪明,能够保持杆子,例如 100 步,总奖励将是 100。两者之间的价值差异是 20 倍,这意味着失败样本的梯度尺度将比成功样本低 20 倍。如此巨大的差异会严重影响我们的训练动态,因为一次幸运的经历会在最终梯度中占主导地位。
从数学角度来看,策略梯度具有较高的方差,在复杂环境中我们需要采取措施来解决这个问题;否则,训练过程可能会变得不稳定。通常处理这种情况的方法是从 Q 中减去一个称为基准值的值。基准值的可能选择如下:
-
一个常数值,通常是折扣奖励的平均值
-
折扣奖励的移动平均值
-
状态的价值,V (s)
为了说明基准值对训练的影响,在 Chapter11/03_cartpole_reinforce_baseline.py 中,我实现了第二种计算基准值的方法(奖励的平均值)。与您已经看到的版本唯一不同的是在 calc_qvals()函数中。我这里不打算讨论结果;您可以自己进行实验。
探索问题
即使策略表示为概率分布,智能体仍然有很大概率会收敛到某个局部最优策略,并停止探索环境。在 DQN 中,我们通过 epsilon-greedy 行动选择来解决这个问题:以 epsilon 的概率,智能体采取一个随机行动,而不是由当前策略决定的行动。当然,我们也可以使用相同的方法,但策略梯度方法允许我们走一条更好的路径,称为熵奖励。
在信息论中,熵是衡量系统不确定性的一个指标。应用到智能体的策略中,熵显示了智能体在选择行动时的不确定性。用数学符号表示,策略的熵定义为 H(π) = −∑ π(a|s)log π(a|s)。熵的值总是大于零,当策略均匀时,熵有一个单一的最大值;换句话说,所有动作的概率相同。当我们的策略对于某一动作的概率为 1,其他动作的概率为 0 时,熵最小,这意味着智能体完全确定该做什么。为了防止智能体陷入局部最小值,我们从损失函数中减去熵,惩罚智能体对采取的行动过于确定。
样本的高度相关性
正如我们在第六章中讨论的那样,单个回合中的训练样本通常高度相关,这对于 SGD 训练来说是不好的一点。在 DQN 的情况下,我们通过拥有一个大小从 100,000 到几百万个观察值的大型重放缓冲区来解决这个问题。这个解决方案对于策略梯度类方法就不适用了,因为这些方法属于在策略类。其含义很简单:使用旧策略生成的旧样本,我们会得到该旧策略的策略梯度,而不是当前策略的。
显而易见,但不幸的是错误的解决方案是减少重放缓冲区的大小。这在一些简单的案例中可能有效,但一般来说,我们需要由当前策略生成的新鲜训练数据。为了解决这个问题,通常使用并行环境。其想法很简单:我们不与一个环境进行通信,而是使用多个环境,并利用它们的过渡作为训练数据。
CartPole 上的策略梯度方法
如今,几乎没有人再使用原始的策略梯度方法,因为更稳定的演员-评论家方法已经存在。然而,我仍然想展示策略梯度的实现,因为它建立了非常重要的概念和衡量标准,用于检查策略梯度方法的性能。
实现
我们将从一个简单得多的 CartPole 环境开始,在接下来的部分中,我们将检查其在我们最喜欢的 Pong 环境中的表现。以下示例的完整代码可在 Chapter11/04_cartpole_pg.py 中找到。
除了已经熟悉的超参数外,我们还有两个新的超参数:
GAMMA = 0.99
LEARNING_RATE = 0.001
ENTROPY_BETA = 0.01
BATCH_SIZE = 8
REWARD_STEPS = 10
ENTROPY_BETA 值是熵奖励的规模,REWARD_STEPS 值指定了展开 Bellman 方程的步数,用以估算每个过渡的折扣总奖励。
以下是网络架构:
class PGN(nn.Module):
def __init__(self, input_size: int, n_actions: int):
super(PGN, self).__init__()
self.net = nn.Sequential(
nn.Linear(input_size, 128),
nn.ReLU(),
nn.Linear(128, n_actions)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
这与之前 CartPole 示例中的完全相同:一个隐藏层有 128 个神经元的两层网络。准备代码也与之前相同,除了经验源需要展开 Bellman 方程 10 步。
以下是与 04_cartpole_pg.py 不同的部分:
exp_source = ptan.experience.ExperienceSourceFirstLast(
env, agent, gamma=GAMMA, steps_count=REWARD_STEPS)
在训练循环中,我们维护每个过渡的折扣奖励之和,并用它来计算策略尺度的基准:
for step_idx, exp in enumerate(exp_source):
reward_sum += exp.reward
baseline = reward_sum / (step_idx + 1)
writer.add_scalar("baseline", baseline, step_idx)
batch_states.append(exp.state)
batch_actions.append(int(exp.action))
batch_scales.append(exp.reward - baseline)
在损失计算中,我们使用与之前相同的代码来计算策略损失(即负的策略梯度):
optimizer.zero_grad()
logits_t = net(states_t)
log_prob_t = F.log_softmax(logits_t, dim=1)
act_probs_t = log_prob_t[range(BATCH_SIZE), batch_actions_t]
log_prob_actions_t = batch_scale_t * act_probs_t
loss_policy_t = -log_prob_actions_t.mean()
然后,我们通过计算批次的熵并从损失中减去它,来向损失中添加熵奖励。由于熵对于均匀概率分布有最大值,而我们希望将训练推动到这个最大值,所以我们需要从损失中减去熵。
prob_t = F.softmax(logits_t, dim=1)
entropy_t = -(prob_t * log_prob_t).sum(dim=1).mean()
entropy_loss_t = -ENTROPY_BETA * entropy_t
loss_t = loss_policy_t + entropy_loss_t
loss_t.backward()
optimizer.step()
然后,我们计算新策略与旧策略之间的 Kullback-Leibler (KL) 散度。KL 散度是信息论中的一个概念,用来衡量一个概率分布与另一个预期概率分布的差异,就像我们在第四章中所看到的那样。在我们的例子中,它被用来比较优化步骤前后模型返回的策略:
new_logits_t = net(states_t)
new_prob_t = F.softmax(new_logits_t, dim=1)
kl_div_t = -((new_prob_t / prob_t).log() * prob_t).\
sum(dim=1).mean()
writer.add_scalar("kl", kl_div_t.item(), step_idx)
KL 的高峰通常是一个不好的信号,因为这意味着我们的策略与之前的策略相差太远,这在大多数情况下都是不好的做法(因为我们的神经网络是一个高维空间中的非常非线性函数,模型权重的如此大变化可能会对策略产生非常强的影响)。
最后,我们计算这一步训练中的梯度统计数据。通常,展示梯度的最大值和 L2 范数(即向量的长度)图表是一种好的实践,这可以帮助我们了解训练动态。
grad_max = 0.0
grad_means = 0.0
grad_count = 0
for p in net.parameters():
grad_max = max(grad_max, p.grad.abs().max().item())
grad_means += (p.grad ** 2).mean().sqrt().item()
grad_count += 1
在训练循环结束时,我们将所有希望在 TensorBoard 中监视的值进行转储:
writer.add_scalar("baseline", baseline, step_idx)
writer.add_scalar("entropy", entropy, step_idx)
writer.add_scalar("loss_entropy", l_entropy, step_idx)
writer.add_scalar("loss_policy", l_policy, step_idx)
writer.add_scalar("loss_total", l_total, step_idx)
writer.add_scalar("grad_l2", grad_means / grad_count, step_idx)
writer.add_scalar("grad_max", grad_max, step_idx)
writer.add_scalar("batch_scales", bs_smoothed, step_idx)
batch_states.clear()
batch_actions.clear()
batch_scales.clear()
结果
在这个例子中,我们将在 TensorBoard 中绘制许多图表。让我们从熟悉的图表开始:奖励。如以下图所示,动态和表现与 REINFORCE 方法没有太大不同:

图 11.4:策略梯度法的奖励动态
接下来的两个图表与我们的基线和策略梯度的尺度相关。我们预计基线将收敛到 1 + 0.99 + 0.99² + … + 0.99⁹,大约为 9.56。策略梯度的尺度应围绕零波动。这正是我们在下图中看到的:

图 11.5:基线值(左)与批次尺度(右)
熵随着时间的推移从 0.69 降到 0.52(图 11.6)。起始值对应于具有两个动作的最大熵,大约为 0.69:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq43.png)
熵在训练过程中减少,如下图所示,这表明我们的策略正在从均匀分布转向更确定性的动作:

图 11.6:训练过程中的熵
下一组图表(图 11.7 和图 11.8)与损失相关,包括策略损失、熵损失及其总和。熵损失经过缩放,是前面熵图的镜像版本。策略损失显示了在批次上计算的策略梯度的均值、尺度和方向。在这里,我们应检查两者的相对大小,以防熵损失过度主导。

图 11.7:熵损失(左)与策略损失(右)

图 11.8:总损失
最后一组图表(图 11.9 和图 11.10)显示了梯度的 L2 值、L2 的最大值和 KL 值。我们的梯度在整个训练过程中看起来很健康:它们不太大也不太小,没有出现巨大的波动。KL 图表也看起来正常,虽然有一些波动,但它们并不大,并且没有超过 10^(-3):

图 11.9:梯度 L2(左)和最大值(右)

图 11.10:KL 散度
Pong 上的策略梯度方法
正如我们在前一节中看到的,标准的策略梯度方法在简单的 CartPole 环境中表现良好,但在更复杂的环境中却表现得出奇的差。
对于相对简单的 Atari 游戏《Pong》,我们的 DQN 能够在 100 万帧内完全解决它,并且在仅仅 10 万帧内就显示出了正向奖励动态,而策略梯度方法则未能收敛。由于策略梯度训练的不稳定性,很难找到合适的超参数,并且对初始化非常敏感。这并不意味着策略梯度方法不好,因为正如你在下一章将看到的,只需稍微调整网络架构以获得更好的基线梯度,策略梯度方法就会变成最好的方法之一(异步优势演员评论员方法)。当然,也有很大的可能性我的超参数完全错误,或者代码中存在一些隐藏的 BUG,或者可能有其他未预见的问题。无论如何,失败的结果仍然有价值,至少它能展示不良收敛动态。
实现
你可以在 Chapter11/05_pong_pg.py 中找到完整的示例代码。
与前一个示例代码相比,主要有三个区别:
-
基线是通过对过去 100 万个过渡进行移动平均来估算的,而不是对所有示例进行估算。为了加速移动平均的计算,创建了一个由 deque 支持的缓冲区:
class MeanBuffer: def __init__(self, capacity: int): self.capacity = capacity self.deque = collections.deque(maxlen=capacity) self.sum = 0.0 def add(self, val: float): if len(self.deque) == self.capacity: self.sum -= self.deque[0] self.deque.append(val) self.sum += val def mean(self) -> float: if not self.deque: return 0.0 return self.sum / len(self.deque) -
使用了多个并发环境。这个示例中的第二个区别是使用多个环境,这一功能由 PTAN 库提供支持。我们唯一需要做的就是将 Env 对象数组传递给 ExperienceSource 类,其他的都由系统自动完成。在多个环境的情况下,经验源会以轮询的方式请求它们的过渡,从而为我们提供更少相关的训练样本。
-
梯度被裁剪以提高训练的稳定性。与 CartPole 示例的最后一个区别是梯度裁剪,它是使用 PyTorch 的 clip_grad_norm 函数(来自 torch.nn.utils 包)进行的。
最佳变体的超参数如下:
GAMMA = 0.99
LEARNING_RATE = 0.0001
ENTROPY_BETA = 0.01
BATCH_SIZE = 128
REWARD_STEPS = 10
BASELINE_STEPS = 1000000
GRAD_L2_CLIP = 0.1
ENV_COUNT = 32
结果
尽管我付出了很多努力使示例收敛,但结果并不理想。即使经过超参数调优(约 400 个超参数样本),最佳结果在训练 1 百万步后,平均奖励仍然约为−19.7。
你可以自己尝试,代码位于 Chapter11/05_pong_pg.py 和 Chapter11/05_pong_pg_tune.py 中。但我只能得出结论,Pong 对于原始的 PG 方法来说过于复杂。
总结
在这一章中,你看到了另一种解决强化学习问题的方法:策略梯度方法,它与我们熟悉的 DQN 方法有许多不同之处。我们探索了一种名为 REINFORCE 的基本方法,它是我们在 RL 领域交叉熵方法的一个推广。这个策略梯度方法很简单,但在应用到 Pong 环境时,未能产生良好的结果。
在下一章中,我们将考虑通过结合基于值的方法和基于策略的方法,来提高策略梯度方法的稳定性。
第十二章:演员-评论员方法:A2C 和 A3C
在第十一章中,我们开始研究一种基于策略的方法,作为传统值基方法的替代方案。特别地,我们重点关注了名为 REINFORCE 的方法及其修改版,该方法使用折扣奖励来获得策略的梯度(该梯度告诉我们改善策略的方向)。这两种方法在小型的 CartPole 问题上表现良好,但在更复杂的 Pong 环境中,我们没有得到收敛。
在这里,我们将讨论另一种对普通策略梯度方法的扩展,它神奇地改善了该方法的稳定性和收敛速度。尽管这种修改只是微小的,但新方法有了自己的名字——演员-评论员,它是深度强化学习(RL)中最强大的方法之一。
在本章中,我们将:
-
探索基准方法如何影响统计数据和梯度的收敛性
-
扩展基准方法的概念
-
实现优势演员评论员(A2C)方法,并在 Pong 环境中进行测试
-
使用两种不同的方法:数据并行和梯度并行,为 A2C 方法增加异步执行
方差减少
在前一章中,我简要提到过,改善策略梯度方法稳定性的一种方式是减少梯度的方差。现在让我们尝试理解为什么这很重要,以及减少方差意味着什么。在统计学中,方差是随机变量与该变量的期望值之间的平方偏差的期望值:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq44.png)
方差展示了数值与均值之间的分散程度。当方差较高时,随机变量可能会取到与均值相差较大的值。在下图中,存在一个均值为μ = 10 的正态(高斯)分布,但其方差值不同。

图 12.1:方差对高斯分布的影响
现在让我们回到策略梯度。前一章中提到过,策略梯度的核心思想是提高良好动作的概率并降低不良动作的概率。在数学表示中,我们的策略梯度被写为∇J ≈𝔼[Q(s,a)∇log π(a|s)]。缩放因子 Q(s,a)指定了我们希望在特定状态下增加或减少动作概率的多少。在 REINFORCE 方法中,我们使用折扣总奖励作为梯度的缩放因子。为了提高 REINFORCE 的稳定性,我们从梯度的缩放因子中减去了平均奖励。
为了理解为什么这有帮助,我们来看一个非常简单的优化步骤场景,在这个场景中,我们有三种动作,它们的总折扣奖励不同:Q[1]、Q[2]和 Q[3]。现在让我们检查在关于这些 Q[s]的相对值的情况下,策略梯度会发生什么。
作为第一个例子,假设 Q[1]和 Q[2]都等于某个小的正数,而 Q[3]是一个较大的负数。因此,第一步和第二步的行动获得了一些小的奖励,但第三步的结果并不理想。所有三步的综合梯度将尝试将我们的策略远离第三步的行动,并稍微推动它朝着第一步和第二步的行动方向发展,这完全是合理的做法。
现在让我们假设我们的奖励始终为正且只有数值不同。这相当于在前一个例子中的每个奖励值上添加一个常量:Q[1]、Q[2]和 Q[3]。在这种情况下,Q[1]和 Q[2]将变为大的正数,而 Q[3]将具有一个小的正值。然而,我们的策略更新将变得不同!我们将努力将策略推向第一步和第二步的行动,并稍微推向第三步的行动。因此,严格来说,尽管相对奖励相同,我们不再试图避免第三步的行动。
我们的策略更新依赖于添加到奖励中的常量,这可能会显著减缓训练进度,因为我们可能需要更多的样本来平滑这种策略梯度的变化。更糟的是,随着我们总的折扣奖励随时间变化,代理不断学习如何做得更好,我们的策略梯度方差也可能发生变化。例如,在 Atari Pong 环境中,开始时的平均奖励是−21...−20,因此所有的行动看起来几乎同样糟糕。
为了解决这个问题,在上一章中,我们从 Q 值中减去了总奖励的均值,并称这个均值为基准。这一技巧将我们的策略梯度归一化:例如,当平均奖励为−21 时,获得−20 的奖励看起来像是代理的胜利,这将推动其策略朝着采取的行动方向发展。
CartPole 方差
为了在实践中验证这个理论结论,让我们绘制基准版本和不使用基准版本的训练过程中的策略梯度方差。完整示例位于 Chapter12/01_cartpole_pg.py,且大部分代码与第十一章相同。该版本的不同之处如下:
-
它现在接受命令行选项
--baseline,启用从奖励中减去均值。默认情况下,不使用基准。 -
在每个训练循环中,我们从策略损失中获取梯度,并使用这些数据来计算方差。
为了仅收集来自策略损失的梯度,并排除为了探索而添加的熵奖励的梯度,我们需要分两阶段计算梯度。幸运的是,PyTorch 使得这一操作变得简单。以下代码中仅包含了训练循环的相关部分,用于说明这一思路:
optimizer.zero_grad()
logits_v = net(states_v)
log_prob_v = F.log_softmax(logits_v, dim=1)
log_p_a_v = log_prob_v[range(BATCH_SIZE), batch_actions_t]
log_prob_actions_v = batch_scale_v * log_p_a_v
loss_policy_v = -log_prob_actions_v.mean()
我们像以前一样计算策略损失,通过计算已采取动作的概率的对数并将其乘以策略尺度(如果我们没有使用基准线,它是总折扣奖励,或者是总奖励减去基准线)。
在下一步中,我们请求 PyTorch 反向传播策略损失,计算梯度并将它们保存在模型的缓冲区中:
loss_policy_v.backward(retain_graph=True)
由于我们之前执行了 optimizer.zero_grad(),这些缓冲区将只包含来自策略损失的梯度。这里有一个棘手的地方是我们在调用 backward() 时使用了 retain_graph=True 选项。它指示 PyTorch 保留变量的图结构。通常,调用 backward() 时会销毁图结构,但在我们的情况下,这不是我们想要的。一般来说,当我们需要在调用优化器之前多次反向传播损失时,保留图结构可能会很有用,尽管这不是一种非常常见的情况。
然后,我们遍历模型中的所有参数(模型的每个参数都是一个包含梯度的张量),并将它们的 grad 字段提取到一个展平的 NumPy 数组中:
grads = np.concatenate([p.grad.data.numpy().flatten()
for p in net.parameters()
if p.grad is not None])
这会给我们一个包含模型变量中所有梯度的长数组。然而,我们的参数更新不仅应该考虑策略梯度,还应考虑由熵奖励提供的梯度。为了实现这一点,我们计算熵损失并再次调用 backward()。为了能够第二次执行这一操作,我们需要传递 retain_graph=True。
在第二次调用 backward() 时,PyTorch 将反向传播我们的熵损失,并将梯度添加到内部梯度缓冲区中。因此,我们现在需要做的就是请求优化器使用这些合并的梯度执行优化步骤:
prob_v = F.softmax(logits_v, dim=1)
entropy_v = -(prob_v * log_prob_v).sum(dim=1).mean()
entropy_loss_v = -ENTROPY_BETA * entropy_v
entropy_loss_v.backward()
optimizer.step()
然后,我们需要做的唯一事情就是将我们感兴趣的统计数据写入 TensorBoard:
g_l2 = np.sqrt(np.mean(np.square(grads)))
g_max = np.max(np.abs(grads))
writer.add_scalar("grad_l2", g_l2, step_idx)
writer.add_scalar("grad_max", g_max, step_idx)
writer.add_scalar("grad_var", np.var(grads), step_idx)
通过运行这个示例两次,一次使用 --baseline 命令行选项,一次不使用,我们可以得到策略梯度的方差图。以下图表显示了平滑的奖励(过去 100 集的平均值)和方差(使用窗口 20 平滑):

图 12.2:平滑奖励(左)和方差(右)
接下来的两个图表显示了梯度的大小(L2 范数)和最大值。所有值都经过窗口 20 平滑处理:

图 12.3:梯度的 L2 范数(左)和最大值(右)
如您所见,带有基准线的版本的方差比没有基准线的版本低两个到三个数量级,这有助于系统更快地收敛。
优势行为者-评论员(A2C)
减少方差的下一步是使我们的基准状态依赖性(这是个好主意,因为不同的状态可能具有非常不同的基准)。实际上,为了决定某个状态下某个动作的适用性,我们使用该动作的折扣总奖励。然而,总奖励本身可以表示为状态的值加上动作的优势:Q(s,a) = V (s) + A(s,a)。你在第八章中见过这种方法,当时我们讨论了 DQN 的修改,特别是对抗 DQN。
那么,为什么我们不能用 V(s) 作为基准呢?在这种情况下,我们的梯度规模将只是优势 A(s,a),表示此动作相对于平均状态值的改善。实际上,我们可以这样做,这对于改进策略梯度方法是一个非常好的主意。唯一的问题是我们不知道需要从折扣总奖励 Q(s,a) 中减去的状态值 V(s)。为了解决这个问题,我们使用另一个神经网络,它将为每个观测值近似 V(s)。为了训练它,我们可以利用在 DQN 方法中使用的相同训练过程:我们将执行贝尔曼步骤,然后最小化均方误差来改进 V(s) 的近似。
当我们知道任何状态的值(或至少有一些近似值)时,我们可以利用它来计算策略梯度,并更新我们的策略网络,以增加具有良好优势值的动作的概率,并减少具有不良优势值的动作的机会。策略网络(返回动作概率分布)被称为演员(actor),因为它告诉我们该做什么。另一个网络称为评论员(critic),因为它通过返回 V(s) 让我们了解我们的动作有多好。这种改进有一个独立的名称,称为优势演员-评论员方法,通常缩写为 A2C。图 12.4 是其架构的示意图:

图 12.4:A2C 架构
实际上,策略网络和值网络部分重叠,主要是出于效率和收敛性的考虑。在这种情况下,策略和值被实现为网络的不同“头部”,它们从共享的主体获取输出,并将其转化为概率分布和一个表示状态值的单一数字。
这有助于两个网络共享低层次特征(例如 Atari 代理中的卷积滤波器),但以不同的方式将它们结合起来。下图展示了这种架构:

图 12.5:带有共享网络主体的 A2C 架构
从训练的角度来看,我们完成以下步骤:
-
用随机值初始化网络参数,𝜃。
-
在环境中执行 N 步,使用当前策略 π[𝜃],并保存状态 s[t]、动作 a[t] 和奖励 r[t]。
-
如果到达回合结束或 V 𝜃,则设置 R ← 0。
-
对于 i = t − 1…t[start](注意步骤是逆向处理的):
-
R ←r[i] + γR
-
累积策略梯度:
![π (a |s) = P[At = a|St = s]]()
-
累积值梯度:
![π (a |s) = P[At = a|St = s]]()
-
-
使用累积的梯度更新网络参数,沿着策略梯度 ∂𝜃[π] 的方向移动,反方向则是值梯度 ∂𝜃[v]。
-
从第 2 步开始重复,直到收敛。
这个算法只是一个大致的框架,类似于通常在研究论文中打印的内容。实际上,可能会使用一些扩展方法来提高该方法的稳定性:
-
通常会添加一个熵奖励来改善探索。这通常表现为一个熵值,添加到损失函数中:
![π (a |s) = P[At = a|St = s]]()
当概率分布是均匀时,这个函数有一个最小值,因此通过将其添加到损失函数中,我们可以让智能体避免对自己的动作过于确定。β 的值是一个超参数,用来缩放熵奖励并在训练过程中优先进行探索。通常情况下,它是常数或在训练过程中线性递减的。
-
梯度累积通常作为一个损失函数实现,结合了三个部分:策略损失、值损失和熵损失。你应该注意这些损失的符号,因为策略梯度显示了策略改进的方向,但值损失和熵损失应该最小化。
-
为了提高稳定性,值得使用多个环境,提供并行的观察数据(当你有多个环境时,训练批次将从这些观察数据中创建)。我们将在本章后续讨论 A3C 方法时探讨几种实现方式。
前面方法的版本,通过并行运行多个环境来实现,称为优势异步演员-评论员方法,也被称为 A3C。A3C 方法将在后续讨论,但现在,我们先实现 A2C。
A2C 在 Pong 中的应用
在上一章中,你看到了一次(不太成功的)尝试,使用策略梯度方法解决我们最喜欢的 Pong 环境。让我们再尝试一下,手头有演员-评论员方法。完整的源代码可以在 Chapter12/02_pong_a2c.py 中找到。
我们像往常一样,从定义超参数开始(省略了导入部分):
GAMMA = 0.99
LEARNING_RATE = 0.001
ENTROPY_BETA = 0.01
BATCH_SIZE = 128
NUM_ENVS = 50
REWARD_STEPS = 4
CLIP_GRAD = 0.1
这些值并未调整,这部分留给读者自己完成。这里有一个新的值:CLIP_GRAD。这个超参数指定了梯度裁剪的阈值,基本上它防止了在优化阶段梯度变得过大,从而使我们的策略过于偏离。裁剪是使用 PyTorch 的功能实现的,但这个概念非常简单:如果梯度的 L2 范数大于这个超参数,则梯度向量会被裁剪到这个值。
REWARD_STEPS 超参数确定我们将向前走多少步,以近似每个行动的总折扣奖励。
在策略梯度方法中,我们使用了大约 10 步,但在 A2C 中,我们将使用我们的值近似来获得进一步步骤的状态值,因此减少步数是可以的。以下是我们的网络架构:
class AtariA2C(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...], n_actions: int):
super(AtariA2C, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 32, kernel_size=8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU(),
nn.Flatten(),
)
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.policy = nn.Sequential(
nn.Linear(size, 512),
nn.ReLU(),
nn.Linear(512, n_actions)
)
self.value = nn.Sequential(
nn.Linear(size, 512),
nn.ReLU(),
nn.Linear(512, 1)
)
它具有共享的卷积体和两个头部:第一个返回包含我们行动概率分布的策略,第二个头部返回一个单一数字,该数字将近似于状态的值。它可能看起来与我们在第八章中提到的对抗性 DQN 架构相似,但我们的训练过程不同。
网络的前向传递返回一个包含两个张量的元组——策略和值:
def forward(self, x: torch.ByteTensor) -> tt.Tuple[torch.Tensor, torch.Tensor]:
xx = x / 255
conv_out = self.conv(xx)
return self.policy(conv_out), self.value(conv_out)
现在我们需要讨论一个重要的大函数,它接受环境转移的批次并返回三个张量:状态批次、采取的行动批次和使用公式 Q(s,a) = ∑ [i=0](N−1)γir[i] + γ^NV(s[N])计算的 Q 值批次。这个 Q 值将在两个地方使用:计算均方误差(MSE)损失以改善值的近似,就像 DQN 一样;以及计算行动的优势。
def unpack_batch(batch: tt.List[ExperienceFirstLast], net: AtariA2C,
device: torch.device, gamma: float, reward_steps: int):
states = []
actions = []
rewards = []
not_done_idx = []
last_states = []
for idx, exp in enumerate(batch):
states.append(np.asarray(exp.state))
actions.append(int(exp.action))
rewards.append(exp.reward)
if exp.last_state is not None:
not_done_idx.append(idx)
last_states.append(np.asarray(exp.last_state))
一开始,我们只需要遍历我们的转移批次并将它们的字段复制到列表中。注意,奖励值已经包含了 REWARD_STEPS 的折扣奖励,因为我们使用了 ptan.ExperienceSourceFirstLast 类。我们还需要处理回合结束的情况,并记住非终止回合的批次条目索引。
在以下代码中,我们将收集到的状态和动作转换为 PyTorch 张量,并根据需要将其复制到图形处理单元(GPU)中:
states_t = torch.FloatTensor(np.asarray(states)).to(device)
actions_t = torch.LongTensor(actions).to(device)
在这里,对 np.asarray()的额外调用可能看起来是多余的,但没有它,张量创建的性能会降低 5 到 10 倍。这在 PyTorch 中被称为问题 #13918,并且在写作时尚未解决,因此一种解决方案是传递一个单一的 NumPy 数组,而不是数组列表。
函数的其余部分计算 Q 值,考虑了终止回合的情况:
rewards_np = np.array(rewards, dtype=np.float32)
if not_done_idx:
last_states_t = torch.FloatTensor(
np.asarray(last_states)).to(device)
last_vals_t = net(last_states_t)[1]
last_vals_np = last_vals_v.data.cpu().numpy()[:, 0]
last_vals_np *= gamma ** reward_steps
rewards_np[not_done_idx] += last_vals_np
上面的代码准备了变量,存储我们转移链中的最后一个状态,并查询我们的网络以获取 V(s)的近似值。然后,将该值乘以折扣因子并加上即时奖励。
在函数的末尾,我们将 Q 值打包到张量中并返回:
ref_vals_t = torch.FloatTensor(rewards_np).to(device)
return states_t, actions_t, ref_vals_t
在以下代码中,你可以注意到一种新的创建环境的方式,使用类 gym.vector.SyncVectorEnv,它传入一个包含创建底层环境的 lambda 函数的列表:
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dev", default="cpu", help="Device to use, default=cpu")
parser.add_argument("--use-async", default=False, action=’store_true’,
help="Use async vector env (A3C mode)")
parser.add_argument("-n", "--name", required=True, help="Name of the run")
args = parser.parse_args()
device = torch.device(args.dev)
env_factories = [
lambda: ptan.common.wrappers.wrap_dqn(gym.make("PongNoFrameskip-v4"))
for _ in range(NUM_ENVS)
]
if args.use_async:
env = gym.vector.AsyncVectorEnv(env_factories)
else:
env = gym.vector.SyncVectorEnv(env_factories)
writer = SummaryWriter(comment="-pong-a2c_" + args.name)
类 gym.vector.SyncVectorEnv 是 Gymnasium 提供的,允许将多个环境封装成一个单一的“向量化”环境。底层环境必须具有相同的动作空间和观察空间,这使得向量化环境能够接受一组动作并返回一批观察和奖励。你可以在 Gymnasium 文档中找到更多细节:gymnasium.farama.org/api/vector/。
同步向量化环境(SyncVectorEnv 类)几乎与我们在第九章“多个环境”部分中使用的优化完全相同,当时我们将多个 gym 环境传入经验源以提高 DQN 训练的性能。
但在向量化环境的情况下,必须使用不同的经验源类:VectorExperienceSourceFirstLast,它考虑了向量化,并优化了代理对观察的应用。从外部看,这个经验源的接口与之前完全相同。
命令行参数--use-async(它将我们的包装类从 SyncVectorEnv 切换为 AsyncVectorEnv)目前不相关——我们稍后会使用它,在讨论 A3C 方法时。
然后,我们创建网络、代理和经验源:
net = common.AtariA2C(env.single_observation_space.shape,
env.single_action_space.n).to(device)
print(net)
agent = ptan.agent.PolicyAgent(lambda x: net(x)[0], apply_softmax=True, device=device)
exp_source = VectorExperienceSourceFirstLast(
env, agent, gamma=GAMMA, steps_count=REWARD_STEPS)
optimizer = optim.Adam(net.parameters(), lr=LEARNING_RATE, eps=1e-3)
这里有一个非常重要的细节是将 eps 参数传递给优化器。如果你熟悉 Adam 算法,你可能知道 epsilon 是一个加到分母上的小数,用来防止零除错误。通常,这个值设置为一些小数字,如 10^(-8)或 10^(-10),但在我们的情况下,这些值太小了。我没有严格的数学解释,但使用默认的 epsilon 值时,方法根本无法收敛。很可能,除以一个小值 10^(-8)会导致梯度过大,这对训练稳定性来说是致命的。
另一个细节是使用 VectorExperienceSourceFirstLast 而不是 ExperienceSourceFirstLast。这是必要的,因为向量化环境将多个普通的 Atari 环境封装在一起。向量化环境还暴露了 single_observation_space 和 single_action_space 这两个属性,它们分别是单个环境的观察空间和动作空间。
在训练循环中,我们使用两个包装器:
batch = []
with common.RewardTracker(writer, stop_reward=18) as tracker:
with TBMeanTracker(writer, batch_size=10) as tb_tracker:
for step_idx, exp in enumerate(exp_source):
batch.append(exp)
new_rewards = exp_source.pop_total_rewards()
if new_rewards:
if tracker.reward(new_rewards[0], step_idx):
break
if len(batch) < BATCH_SIZE:
continue
代码中的第一个包装器你已经很熟悉:common.RewardTracker,它计算最后 100 个回合的平均奖励,并告诉我们当这个平均奖励超过所需阈值时。另一个包装器 TBMeanTracker 来自 PTAN 库,负责将最后 10 步中测量的参数的平均值写入 TensorBoard。这是非常有帮助的,因为训练可能需要上百万步,我们不希望每一步都写入 TensorBoard,而是每 10 步写入平滑后的值。
下一段代码负责我们计算损失的部分,这是 A2C 方法的核心。首先,我们使用之前描述的函数解包批次,并要求网络返回该批次的策略和值:
states_t, actions_t, vals_ref_t = common.unpack_batch(
batch, net, device=device, gamma=GAMMA, reward_steps=REWARD_STEPS)
batch.clear()
optimizer.zero_grad()
logits_t, value_t = net(states_t)
策略以未归一化的形式返回,因此为了将其转换为概率分布,我们需要对其应用 softmax。由于策略损失需要概率分布的对数,我们将使用 log_softmax 函数,这比先调用 softmax 再取对数更加稳定。
在价值损失部分,我们计算网络返回的值与我们通过展开四步的贝尔曼方程所进行的近似之间的均方误差(MSE):
loss_value_t = F.mse_loss(value_t.squeeze(-1), vals_ref_t)
接下来,我们计算策略损失以获得策略梯度:
log_prob_t = F.log_softmax(logits_t, dim=1)
adv_t = vals_ref_t - value_t.detach()
log_act_t = log_prob_t[range(BATCH_SIZE), actions_t]
log_prob_actions_t = adv_t * log_act_t
loss_policy_t = -log_prob_actions_t.mean()
前两步获得我们策略的日志并计算行动的优势,优势 A(s,a) = Q(s,a) −V (s)。调用 value_t.detach() 很重要,因为我们不希望将策略梯度传播到我们的价值近似头部。然后,我们对采取的行动的概率取对数,并用优势对其进行缩放。我们的策略梯度损失值将等于该缩放后的策略对数的负均值,因为策略梯度引导我们朝着策略改进的方向,但损失值应该最小化。
我们损失函数的最后一部分是熵损失:
prob_t = F.softmax(logits_t, dim=1)
entropy_loss_t = ENTROPY_BETA * (prob_t * log_prob_t).sum(dim=1).mean()
熵损失等于我们策略的缩放熵,并取其相反符号(熵的计算公式是 H(π) = −∑ π log π)。
在接下来的代码中,我们计算并提取我们策略的梯度,这些梯度将用于追踪最大梯度、其方差和 L2 范数:
loss_policy_t.backward(retain_graph=True)
grads = np.concatenate([
p.grad.data.cpu().numpy().flatten()
for p in net.parameters() if p.grad is not None
])
作为训练的最后一步,我们反向传播熵损失和价值损失,裁剪梯度,并要求优化器更新网络:
loss_v = entropy_loss_t + loss_value_t
loss_v.backward()
nn_utils.clip_grad_norm_(net.parameters(), CLIP_GRAD)
optimizer.step()
loss_v += loss_policy_t
在训练循环的最后,我们追踪所有需要在 TensorBoard 中监控的值:
tb_tracker.track("advantage", adv_t, step_idx)
tb_tracker.track("values", value_t, step_idx)
tb_tracker.track("batch_rewards", vals_ref_t, step_idx)
tb_tracker.track("loss_entropy", entropy_loss_t, step_idx)
tb_tracker.track("loss_policy", loss_policy_t, step_idx)
tb_tracker.track("loss_value", loss_value_t, step_idx)
tb_tracker.track("loss_total", loss_v, step_idx)
tb_tracker.track("grad_l2", np.sqrt(np.mean(np.square(grads))), step_idx)
tb_tracker.track("grad_max", np.max(np.abs(grads)), step_idx)
tb_tracker.track("grad_var", np.var(grads), step_idx)
有很多值需要监控,我们将在下一部分中讨论它们。
结果
要开始训练,请运行 02_pong_a2c.py 并使用 --dev(表示使用 GPU)和 -n 选项(为 TensorBoard 提供一个运行名称):
Chapter12$ ./02_pong_a2c.py --dev cuda -n tt
A.L.E: Arcade Learning Environment (version 0.8.1+53f58b7)
[Powered by Stella]
AtariA2C(
(conv): Sequential(
(0): Conv2d(4, 32, kernel_size=(8, 8), stride=(4, 4))
(1): ReLU()
(2): Conv2d(32, 64, kernel_size=(4, 4), stride=(2, 2))
(3): ReLU()
(4): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1))
(5): ReLU()
(6): Flatten(start_dim=1, end_dim=-1)
)
(policy): Sequential(
(0): Linear(in_features=3136, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=6, bias=True)
)
(value): Sequential(
(0): Linear(in_features=3136, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=1, bias=True)
)
)
37850: done 1 games, mean reward -21.000, speed 1090.79 f/s
39250: done 2 games, mean reward -21.000, speed 1111.24 f/s
39550: done 3 games, mean reward -21.000, speed 1118.06 f/s
40000: done 4 games, mean reward -21.000, speed 1083.18 f/s
40300: done 5 games, mean reward -21.000, speed 1141.46 f/s
40750: done 6 games, mean reward -21.000, speed 1077.44 f/s
40850: done 7 games, mean reward -21.000, speed 940.09 f/s
...
作为警告,训练过程比较漫长。使用原始超参数,它大约需要 1000 万帧来解决问题,大约在 GPU 上需要三个小时。
本章后面,我们将查看 A2C 方法的异步版本,它在一个单独的进程中执行环境(这提高了训练的稳定性和性能)。但首先,让我们集中关注 TensorBoard 中的图表。
奖励动态比上一章的示例要好得多:

图 12.6:平滑奖励(左侧)和平均批次值(右侧)
左侧的图是过去 100 个训练回合的平均奖励。右侧的图,“批次值”,展示了使用贝尔曼方程近似的 Q 值以及 Q 近似的整体正向动态。这表明我们的训练过程在时间上基本上是持续改进的。
接下来的四个图与我们的损失相关,包含了各个损失组件和总损失:

图 12.7:熵损失(左侧)和策略损失(右侧)

图 12.8:价值损失(左侧)和总损失(右侧)
在这里,我们必须注意以下几点:
-
首先,我们的价值损失(图 12.8,在左侧)持续减少,这表明我们的 V(s)近似值在训练过程中得到了改善。
-
第二个观察结果是我们的熵损失(图 12.7,左侧)在训练的中期增长,但它在总损失中并不占主导地位。这基本上意味着随着策略变得不再均匀,我们的代理在其动作上变得更加自信。
-
这里最后需要注意的是,策略损失(图 12.7,右侧)大多数时候在减少,并且与总损失相关联,这是好的,因为我们首先关注的是我们策略的梯度。
最后一组图显示了优势值和策略梯度度量:

图 12.9:优势(左侧)和梯度的 L2 范数(右侧)

图 12.10:梯度的最大值(左侧)和梯度方差(右侧)
优势是我们策略梯度的尺度,它等于 Q(s,a) − V(s)。我们期望它在 0 周围波动(因为从平均而言,单一动作对状态值的影响不应该很大),而图表符合我们的预期。梯度图表表明我们的梯度既不太小也不太大。方差在训练的最初阶段(前 200 万帧)非常小,但后来开始增长,这意味着我们的策略在发生变化。
异步优势演员评论员(A3C)
在本节中,我们将扩展 A2C 方法。这个扩展加入了真正的异步环境交互,被称为异步优势演员评论员(A3C)。该方法是 RL 实践者最广泛使用的算法之一。
我们将介绍两种为基础 A2C 方法添加异步行为的方式:数据级并行和梯度级并行。它们有不同的资源需求和特点,这使得它们适用于不同的情况。
相关性与样本效率
改进策略梯度方法稳定性的一种方式是使用多个环境并行训练。其背后的原因是我们在第六章中讨论的基本问题,即样本之间的相关性,这破坏了独立同分布(iid)假设,而这个假设对于随机梯度下降(SGD)优化至关重要。相关性的负面影响是梯度方差非常大,这意味着我们的训练批次包含了非常相似的样本,它们都会将我们的网络推向相同的方向。然而,这个方向在全局上可能完全是错误的,因为所有这些样本可能来自同一个幸运或不幸运的回合。
使用我们的深度 Q 网络(DQN),我们通过在重放缓冲区中存储大量的历史状态,并从这个缓冲区中抽取训练批次来解决这个问题。如果缓冲区足够大,从中随机抽取的样本将更好地代表状态的整体分布。不幸的是,这个方法不能应用于策略梯度方法。这是因为大多数策略梯度方法是基于当前策略进行训练的,也就是说,我们必须使用当前策略生成的样本来训练,因此不能再记住旧的转换。你可以尝试这样做,但最终得到的策略梯度会是基于旧策略生成样本的梯度,而不是你想更新的当前策略。
研究人员已经研究这个问题多年,提出了几种解决方案,但这个问题仍远未解决。最常用的解决方案是通过多个并行环境收集转换,这些环境都利用当前的策略。这种方法打破了单一回合中的相关性,因为我们现在是在多个不同环境中收集的多个回合上进行训练。同时,我们依然使用当前的策略。这种方法的一个重大缺点是样本效率低,因为我们基本上会丢弃在单一训练轮次中获得的所有经验。
将 DQN 与策略梯度方法进行比较非常简单。例如,对于 DQN,如果我们使用 100 万个回放缓冲区样本,每个新帧的训练批量大小为 32 个样本,那么每个过渡状态大约会在从经验回放中推送之前被使用 32 次。对于优先回放缓冲区(在第八章讨论过),这个数字可能会高得多,因为样本的选择概率不是均匀的。在策略梯度方法的情况下,从环境中获得的每个经验只能使用一次,因为我们的方法需要新鲜的数据,因此策略梯度方法的数据效率可能比基于价值的离线方法低一个数量级。
另一方面,我们的 A2C 代理在 Pong 上收敛用了 800 万帧,这仅是第六章和第八章中基本 DQN 的 100 万帧的八倍。因此,这向我们展示了策略梯度方法并非完全无用;它们只是不同的,并且有其自身的特点,你在选择方法时需要考虑这些特点。如果你的环境在代理交互方面是“便宜”的(环境响应快速,内存占用低,支持并行化等),那么策略梯度方法可能是更好的选择。另一方面,如果环境“昂贵”,并且获取大量经验可能会减慢训练过程,那么基于价值的方法可能是更聪明的选择。
向 A2C 中添加额外的“A”
从实践角度看,与多个并行环境进行通信是简单的。我们已经在第九章和当前章节的前面部分做过这件事,但并没有明确说明。在 A2C 代理中,我们将一个 Gym 环境的数组传递给 ExperienceSource 类,该类将其切换为轮询数据收集模式。这意味着每次我们从经验源请求过渡时,该类会使用我们数组中的下一个环境(当然,会为每个环境保持状态)。这种简单的方法相当于与环境进行并行通信,但有一个小小的区别:通信不是严格意义上的并行,而是以串行的方式进行。然而,我们的经验源中的样本是打乱的。这个思路在下图中展示:

图 12.11:一个代理在多个环境中并行训练
这种方法运行良好,并帮助我们在 A2C 方法中达到了收敛,但在计算资源利用方面仍不完美,因为所有处理都是顺序进行的。即使是现在的一台普通工作站,也拥有多个 CPU 核心,可以用于计算,如训练和环境交互。另一方面,平行编程比传统范式更难,当你有一个清晰的执行流时,传统方法相对更简单。幸运的是,Python 是一种非常具有表达力和灵活性的语言,拥有大量的第三方库,允许你轻松进行平行编程。我们已经在第 9 章中看到过 torch.multiprocessing 库的示例,在 DQN 训练中我们平行化了代理的执行。但还有其他更高级的库,如 ray,它允许我们平行化代码执行,隐藏底层的通信细节。
关于演员-评论家并行化,有两种方法:
-
数据并行性:我们可以有多个进程,每个进程与一个或多个环境进行通信,并提供过渡数据(s,r,a,s′)。所有这些样本将汇聚到一个单独的训练过程中,计算损失并执行 SGD 更新。然后,更新后的神经网络(NN)参数需要广播到所有其他进程,以便在未来的环境通信中使用。这个模型在图 12.12 中有所说明。
-
梯度并行性:由于训练过程的目标是计算梯度来更新我们的神经网络(NN),我们可以有多个进程在各自的训练样本上计算梯度。然后,这些梯度可以汇总在一个进程中执行 SGD 更新。当然,更新后的 NN 权重也必须传播回所有工作进程,以保持数据的一致性。这在图 12.13 中有所说明。

图 12.12:第一种演员-评论家并行化方法,基于分布式训练样本的汇集

图 12.13:第二种并行化方法,为模型汇集梯度
这两种方法之间的差异从图表上看可能并不十分显著,但你需要意识到计算成本的差异。A2C 优化中的最重操作是训练过程,它包括从数据样本中计算损失(前向传播)以及根据该损失计算梯度。SGD 优化步骤相对轻量——基本上只是将缩放后的梯度加到神经网络(NN)的权重上。通过将第二种方法(梯度并行)中的损失计算和梯度计算从中央处理过程移出,我们消除了主要的潜在瓶颈,并使整个过程变得更加可扩展。
实际上,选择哪种方法主要取决于你的资源和目标。如果你有一个单一的优化问题,并且拥有大量分布式计算资源,比如在网络中分布的数十个 GPU,那么梯度并行将是加速训练的最佳方法。
然而,如果只有一个 GPU,两种方法会提供类似的性能,但第一种方法通常更易于实现,因为你无需处理低级别的梯度值。在本章中,我们将通过我们最喜欢的 Pong 游戏比较这两种方法,看看它们的差异,并探索 PyTorch 的多进程能力。
A3C 与数据并行
我们将检查的 A3C 并行化的第一个版本(如图 12.12 所示)有一个主要进程负责执行训练,多个子进程与环境进行通信并收集经验进行训练。
事实上,我们在第九章中已经实现了这个版本,当时我们在子进程中运行多个代理,训练 DQN 模型(那时我们在 FPS 方面获得了 27%的加速)。在这一部分,我不会用 A3C 方法重新实现相同的方法,而是想展示“库的力量”。
我们已经简要提到过 Gymnasium 中的类gym.vector.SyncVectorEnv(它仅存在于 Farama 的分支中,而不在原始的 OpenAI Gym 中)和 PTAN 经验源,它支持“向量化”环境:VectorExperienceSourceFirstLast。类SyncVectorEnv按顺序处理封装的环境,但有一个替代类AsyncVectorEnv,它使用mp.multiprocessing处理子环境。所以,为了获得 A2C 方法的数据并行版本,我们只需要将SyncVectorEnv替换为AsyncVectorEnv,这样就完成了。
第十二章的代码(Chapter12/02_pong_a2c.py)已经支持这个替换操作,方法是传递--use-async命令行选项。
结果
异步版本在 50 个环境下展示了 2000 FPS 的性能,比顺序版本提高了 2 倍。以下图表比较了这两个版本的性能和奖励动态:

图 12.14:A2C 和 A3C 在奖励(左)和速度(右)上的比较
A3C 与梯度并行
接下来,我们将考虑一种并行化 A2C 实现的方法,它将有多个子进程,但它们不是将训练数据传递给中央训练循环,而是使用它们本地的训练数据计算梯度,并将这些梯度发送给中央主进程。这个主进程负责将这些梯度合并(基本上就是求和)并对共享网络进行 SGD 更新。
这个差异看起来可能微不足道,但这种方法的可扩展性更强,特别是当你有多个强大的节点并且这些节点通过多个 GPU 连接到网络时。在这种情况下,数据并行模型中的中央处理过程很快就会成为瓶颈,因为损失计算和反向传播是计算密集型的。梯度并行化可以将负载分配到多个 GPU 上,在中央位置仅执行相对简单的梯度组合操作。
实现
完整的示例在 Chapter12/03_a3c_grad.py 文件中,并且使用我们已经看到的 Chapter12/lib/common.py 模块。
和往常一样,我们首先定义超参数:
GAMMA = 0.99
LEARNING_RATE = 0.001
ENTROPY_BETA = 0.01
REWARD_STEPS = 4
CLIP_GRAD = 0.1
PROCESSES_COUNT = 4
NUM_ENVS = 8
GRAD_BATCH = 64
TRAIN_BATCH = 2
ENV_NAME = "PongNoFrameskip-v4"
NAME = ’pong’
REWARD_BOUND = 18
这些与之前的示例大致相同,唯一的区别是 BATCH_SIZE 被两个参数取代:GRAD_BATCH 和 TRAIN_BATCH。GRAD_BATCH 的值定义了每个子进程用于计算损失并获取梯度值的批次大小。第二个参数 TRAIN_BATCH 指定了每个 SGD 迭代中将结合来自子进程的多少个梯度批次。每个由子进程产生的条目具有与我们的网络参数相同的形状,我们将其 TRAIN_BATCH 的值相加。因此,对于每一步优化,我们使用 TRAIN_BATCH * GRAD_BATCH 个训练样本。由于损失计算和反向传播是相当繁重的操作,我们使用较大的 GRAD_BATCH 来提高它们的效率。
由于这个大批次,我们应该保持相对较低的 TRAIN_BATCH,以保持网络的策略更新。
现在我们有两个函数——make_env(),用于创建一个封装的 Pong 环境,以及 grads_func(),它更加复杂,实现了我们通常在训练循环中执行的大部分训练逻辑。作为补偿,主进程中的训练循环变得几乎是微不足道的:
def make_env() -> gym.Env:
return ptan.common.wrappers.wrap_dqn(gym.make("PongNoFrameskip-v4"))
def grads_func(proc_name: str, net: common.AtariA2C, device: torch.device,
train_queue: mp.Queue):
env_factories = [make_env for _ in range(NUM_ENVS)]
env = gym.vector.SyncVectorEnv(env_factories)
agent = ptan.agent.PolicyAgent(lambda x: net(x)[0], device=device, apply_softmax=True)
exp_source = VectorExperienceSourceFirstLast(
env, agent, gamma=GAMMA, steps_count=REWARD_STEPS)
batch = []
frame_idx = 0
writer = SummaryWriter(comment=proc_name)
在创建子进程时,我们将几个参数传递给 grads_func() 函数:
-
用于创建 TensorBoard 写入器的进程名称。在这个示例中,每个子进程都写入自己的 TensorBoard 数据集。
-
共享神经网络。
-
一个 torch.device 实例,用于指定计算设备。
-
用于将计算出的梯度传递给中央处理过程的队列。
我们的子进程函数与数据并行版本中的主训练循环非常相似,这并不令人惊讶,因为我们的子进程承担的责任增加了。然而,我们并没有要求优化器更新网络,而是收集梯度并将其发送到队列中。其余的代码几乎没有变化:
with common.RewardTracker(writer, REWARD_BOUND) as tracker:
with TBMeanTracker(writer, 100) as tb_tracker:
for exp in exp_source:
frame_idx += 1
new_rewards = exp_source.pop_total_rewards()
if new_rewards and tracker.reward(new_rewards[0], frame_idx):
break
batch.append(exp)
if len(batch) < GRAD_BATCH:
continue
到目前为止,我们已经收集了包含转换的批次并处理了回合结束的奖励。
在函数的下一部分,我们从训练数据中计算组合损失并执行损失的反向传播:
data = common.unpack_batch(batch, net, device=device, gamma=GAMMA,
reward_steps=REWARD_STEPS)
states_v, actions_t, vals_ref_v = data
batch.clear()
net.zero_grad()
logits_v, value_v = net(states_v)
loss_value_v = F.mse_loss(value_v.squeeze(-1), vals_ref_v)
log_prob_v = F.log_softmax(logits_v, dim=1)
adv_v = vals_ref_v - value_v.detach()
log_p_a = log_prob_v[range(GRAD_BATCH), actions_t]
log_prob_actions_v = adv_v * log_p_a
loss_policy_v = -log_prob_actions_v.mean()
prob_v = F.softmax(logits_v, dim=1)
ent = (prob_v * log_prob_v).sum(dim=1).mean()
entropy_loss_v = ENTROPY_BETA * ent
loss_v = entropy_loss_v + loss_value_v + loss_policy_v
loss_v.backward()
在接下来的代码中,我们将传送在训练过程中要监视的中间值到 TensorBoard:
tb_tracker.track("advantage", adv_v, frame_idx)
tb_tracker.track("values", value_v, frame_idx)
tb_tracker.track("batch_rewards", vals_ref_v, frame_idx)
tb_tracker.track("loss_entropy", entropy_loss_v, frame_idx)
tb_tracker.track("loss_policy", loss_policy_v, frame_idx)
tb_tracker.track("loss_value", loss_value_v, frame_idx)
tb_tracker.track("loss_total", loss_v, frame_idx)
在循环结束时,我们需要剪裁梯度,并将其从网络的参数中提取到一个单独的缓冲区中(以防它们被下次循环的迭代损坏)。在这里,我们实际上将梯度存储在每个网络参数的tensor.grad字段中。这可以在不需要与其他工作进程同步的情况下完成,因为我们的网络参数是共享的,但梯度是由每个进程本地分配的:
nn_utils.clip_grad_norm_(
net.parameters(), CLIP_GRAD)
grads = [
param.grad.data.cpu().numpy() if param.grad is not None else None
for param in net.parameters()
]
train_queue.put(grads)
train_queue.put(None)
grads_func中的最后一行将 None 放入队列,表示该子进程已达到游戏解决状态,训练应当停止。
主进程从创建网络并共享其权重开始:
if __name__ == "__main__":
mp.set_start_method(’spawn’)
os.environ[’OMP_NUM_THREADS’] = "1"
parser = argparse.ArgumentParser()
parser.add_argument("--dev", default="cpu", help="Device to use, default=cpu")
parser.add_argument("-n", "--name", required=True, help="Name of the run")
args = parser.parse_args()
device = torch.device(args.dev)
env = make_env()
net = common.AtariA2C(env.observation_space.shape, env.action_space.n).to(device)
net.share_memory()
在这里,与前一部分一样,我们需要为torch.multiprocessing设置启动方法,并限制 OpenMP 启动的线程数量。这是通过设置环境变量OMP_NUM_THREADS来完成的,该变量告诉 OpenMP 库可以启动的线程数。OpenMP(www.openmp.org/)被 Gym 和 OpenCV 库广泛使用,以在多核系统上提供加速,大多数情况下这是好事。默认情况下,使用 OpenMP 的进程会为系统中的每个核心启动一个线程。但在我们的案例中,OpenMP 的效果正好相反:由于我们正在实现自己的并行化,通过启动多个进程,额外的线程会通过频繁的上下文切换给核心带来负担,从而对性能产生负面影响。为避免这种情况,我们明确将线程数限制为一个线程。如果你愿意,可以自己尝试调整这个参数。在我的系统上,没有设置该环境变量时,我体验到了 3-4 倍的性能下降。
然后,我们创建通信队列并生成所需数量的子进程:
optimizer = optim.Adam(net.parameters(), lr=LEARNING_RATE, eps=1e-3)
train_queue = mp.Queue(maxsize=PROCESSES_COUNT)
data_proc_list = []
for proc_idx in range(PROCESSES_COUNT):
proc_name = f"-a3c-grad_pong_{args.name}#{proc_idx}"
p_args = (proc_name, net, device, train_queue)
data_proc = mp.Process(target=grads_func, args=p_args)
data_proc.start()
data_proc_list.append(data_proc)
现在我们可以进入训练循环:
batch = []
step_idx = 0
grad_buffer = None
try:
while True:
train_entry = train_queue.get()
if train_entry is None:
break
与数据并行版本的 A3C 相比,主要的区别在于训练循环,这里更加简单,因为子进程已经为我们完成了所有繁重的计算。在循环开始时,我们处理当某个进程已达到所需的平均奖励时的情况(此时队列中为 None)。在这种情况下,我们直接退出循环以停止训练。
我们将所有网络参数的梯度加总在一起:
step_idx += 1
if grad_buffer is None:
grad_buffer = train_entry
else:
for tgt_grad, grad in zip(grad_buffer, train_entry):
tgt_grad += grad
当我们累积了足够的梯度片段后,我们将梯度总和转换为 PyTorch 的 FloatTensor,并将其赋值给网络参数的grad字段。为了对不同子进程的梯度进行平均,我们会为每个获取到的TRAIN_BATCH梯度调用优化器的 step()函数。对于中间步骤,我们仅仅将对应的梯度加在一起:
if step_idx % TRAIN_BATCH == 0:
for param, grad in zip(net.parameters(), grad_buffer):
param.grad = torch.FloatTensor(grad).to(device)
nn_utils.clip_grad_norm_(net.parameters(), CLIP_GRAD)
optimizer.step()
grad_buffer = None
之后,我们所需要做的就是调用优化器的 step()方法,通过累积的梯度更新网络参数。
当退出训练循环时,我们停止所有子进程,以确保它们已被终止,即使按下 Ctrl + C 停止优化:
finally:
for p in data_proc_list:
p.terminate()
p.join()
这个步骤是为了防止僵尸进程占用 GPU 资源。
结果
这个示例可以像之前的示例一样启动,过一段时间后,应该开始显示速度和平均奖励。然而,你需要注意,显示的信息对于每个子进程都是局部的,这意味着速度、完成的游戏数量和帧数需要乘以进程的数量。我的基准测试显示每个子进程的速度大约是 500-600 FPS,总计大约是 2,000-2,400 FPS。
收敛动态与先前版本非常相似。总的观察次数大约为 800 万到 1000 万,完成这些需要大约 1.5 小时。左侧的奖励图显示了各个进程,右侧的速度图显示了所有进程的总和。如你所见,梯度并行比数据并行略微提高了性能:

图 12.15:A2C 和 A3C 在奖励(左)和速度(右)方面的比较
总结
在本章中,你学习了深度强化学习中最广泛使用的方法之一:A2C,该方法巧妙地将策略梯度更新与状态近似值结合起来。我们分析了基准(baseline)对统计量和梯度收敛的影响。然后,我们检查了基准思想的扩展:A2C,其中一个独立的网络头为我们提供当前状态的基准。此外,我们讨论了为什么对于策略梯度方法来说,从多个环境收集训练数据非常重要,因为它们是基于策略的(on-policy)。我们还实现了两种不同的 A3C 方法,以实现训练过程的并行化和稳定化。并行化将在本书中再次出现,我们将在讨论黑盒方法时提到(第十七章)。
在接下来的两章中,我们将探讨使用策略梯度方法可以解决的实际问题,这也将总结本书关于策略梯度方法的部分内容。
加入我们在 Discord 上的社区
与其他用户、深度学习专家以及作者本人一起阅读本书。提问,向其他读者提供解决方案,通过“问我任何问题”(Ask Me Anything)环节与作者交流,等等。扫描二维码或访问链接加入社区。packt.link/rl

第十三章:TextWorld 环境
本章中,我们将使用强化学习(RL)来解决基于文本的互动小说游戏,使用微软研究院发布的环境——TextWorld。这将很好地展示强化学习如何应用于具有丰富观察空间的复杂环境。此外,我们还将稍微涉及一下深度自然语言处理(NLP)方法,并与大语言模型(LLMs)进行一些互动。
在本章中,我们将:
-
简要回顾互动小说的历史
-
研究 TextWorld 环境
-
实现简单的基准深度 Q 网络(DQN)方法,然后尝试通过对观察进行若干调整来改善它
-
使用 Hugging Face Hub 中的预训练变压器(transformers)为我们的智能体实现句子嵌入
-
使用 OpenAI ChatGPT 检查现代大语言模型(LLMs)在互动小说游戏中的能力
互动小说
正如你已经看到的,电脑游戏不仅对人类有娱乐性,还由于其复杂的观察和行动空间、游戏过程中的长决策序列以及自然的奖励系统,为强化学习研究人员提供了具有挑战性的问题。
像 Atari 2600 上的街机游戏只是游戏行业众多类型中的一种。让我们退一步,从历史的角度快速回顾一下。Atari 2600 平台在 70 年代末和 80 年代初的受欢迎程度达到了巅峰。随后进入了 Z80 及其克隆机的时代,演变成现在的 PC 兼容平台和游戏主机时代。随着时间的推移,电脑游戏在复杂性、色彩和图形细节方面不断发展,这不可避免地提高了硬件需求。这一趋势使得强化学习研究人员和从业者在应用强化学习方法到更现代的游戏时遇到困难。例如,几乎每个人都能训练一个强化学习智能体来解决 Atari 游戏,但对于《星际争霸 II》来说,DeepMind 不得不花费数周的电力,利用图形处理单元(GPU)集群。当然,这项工作对未来的研究至关重要,因为它使我们能够检验想法并优化方法,但《星际争霸 II》和《Dota》这类游戏的复杂性使得它们对大多数人来说费用过高。
有几种方法可以解决这个问题:
-
第一个方法是选择那些“介于”Atari 和《星际争霸》复杂性之间的游戏。幸运的是,来自 Z80、NES、Sega 和 C64 平台的游戏多得不可计数。
-
另一种方法是选择一个具有挑战性的游戏,但简化其环境。有几个 Doom 环境(可以在 Gym 中获得),例如,它们使用游戏引擎作为平台,但目标比原始游戏简单得多,比如导航走廊、收集武器或射击敌人。这些微型游戏在《星际争霸 II》中也可以找到。
-
第三种完全不同的方法是,选择一些可能在观察上并不复杂,但需要长期规划、复杂的状态空间探索,并且具有物体间具有挑战性互动的游戏。这一类的例子是著名的雅达利游戏《Montezuma’s Revenge》,即使对于现代的强化学习方法来说,这款游戏依然具有挑战性。
最后一种方法颇具吸引力,因为它的资源可获得性加上仍然具有达到强化学习方法极限的复杂性。另一个例子是基于文本的游戏,这些游戏也被称为互动小说。这个类型的游戏现在几乎消失了,被现代游戏和硬件的发展所淘汰,但在雅达利和 Z80 时代,互动小说和传统游戏是同时提供的。这些游戏并不依赖丰富的图形来展示游戏状态(70 年代的硬件难以实现这一点),而是依赖玩家的思维和想象力。
游戏过程通过文本传达,当游戏的当前状态描述呈现给玩家时,例如:你正站在一条小路的尽头,前方是一座小砖砌建筑。你周围是一片森林。一条小溪从建筑物中流出,沿着山谷流下。
如图 13.1 所示,这是 1976 年冒险游戏的开端,这也是这一类型游戏的第一款。游戏中的动作通过自由文本命令的形式呈现,通常结构简单,词汇量有限,例如,“动词 + 名词”。

图 13.1:互动小说游戏过程的示例
尽管描述简洁,在 80 年代和 90 年代初,数百款大小游戏由个人开发者和商业工作室开发。这些游戏有时需要数小时的游戏时间,包含成千上万的地点,并有许多物体可供互动。例如,图 13.2 展示了 1980 年 Infocom 发布的 Zork I 游戏地图的一部分。

图 13.2:Zork I 地图的地下部分(为了更好的可视化,请参考 https://packt.link/gbp/9781835882702)
如你所想,这类游戏的挑战几乎可以无限增加,因为它们可以包括物体之间的复杂交互、游戏状态的探索、与其他角色的沟通以及其他现实生活场景。在互动小说档案馆网站上,有许多此类游戏可以体验:ifarchive.org。
2018 年 6 月,微软研究院发布了一个开源项目,旨在为研究人员和强化学习爱好者提供一种简单的方式,使用熟悉的工具实验文本游戏。这个名为 TextWorld 的项目可以在 GitHub 上找到(github.com/microsoft/TextWorld),并提供了以下功能:
-
一个用于基于文本的游戏的 Gym 环境。它支持两种格式的游戏:Z-machine 字节码(支持版本 1-8)和 Glulx 游戏。
-
一个游戏生成器,允许你生成具有预定义复杂度的随机生成任务,如物品数量、描述和任务长度。
-
调整(对于生成的游戏)环境复杂度的能力,可以通过查看游戏状态来实现。例如,可以启用中间奖励,每当代理在正确的方向上迈出一步时,就会给予正向奖励。接下来的部分将描述几个这样的因素。
随着本章内容的推进,我们将尝试几个游戏,以探索环境的能力,并实现多个版本的训练代码来解决生成的游戏。你需要通过提供的脚本生成它们:Chapter13/game/make_games.sh。它将生成 21 个长度为 5 的游戏,使用不同的种子值确保游戏之间的变化性。这些游戏的复杂度不会很高,但你可以将它们作为自己实验和验证想法的基础。
环境
截至目前,TextWorld 环境仅支持 Linux 和 macOS 平台(对于 Windows,你可以使用 Docker 容器),并且内部依赖于 Inform 7 系统(inform7.com)。该项目有两个网页:一个是微软研究网页(www.microsoft.com/en-us/research/project/textworld/),包含有关环境的一般信息;另一个在 GitHub 上(github.com/microsoft/TextWorld),描述了安装和使用方式。让我们从安装开始。
安装
安装可以通过简单的 pip 命令:pip install textworld==1.6.1完成。本章中的所有示例都使用最新的 1.6.1 版本包进行了测试。
安装后,可以在 Python 代码中导入该包,并提供两个命令行工具用于游戏生成和游戏玩法:tw-make 和 tw-play。如果你有雄心勃勃的计划,要解决来自ifarchive.org的全功能交互式小说游戏,则不需要它们,但在我们的例子中,我们将从人工生成的任务开始,以简化流程。
游戏生成
tw-make 工具允许你生成具有以下特点的游戏:
-
游戏场景:例如,你可以选择一个经典任务,目标是使用物品并遵循一系列动作,或是一个“收集金币”场景,玩家需要在各个场景中找到金币。
-
游戏主题:你可以设置游戏的内部环境,但目前只存在“房子”和“基础”两种主题。
-
对象属性:你可以为对象添加形容词;例如,它可能是“绿色的钥匙”打开箱子,而不仅仅是“钥匙”。
-
游戏可以拥有的并行任务数量:默认情况下,游戏中只有一个动作序列可以找到,但你可以更改此设置,允许游戏拥有子目标和替代路径。
-
任务的长度:你可以定义玩家在达到游戏的结局或解决方案之前需要采取多少步。
-
随机种子:你可以使用这些种子来生成可复现的游戏。
生成的游戏可以是 Glulx 或 Z-machine 格式,这些是标准的便携式虚拟机指令,广泛用于普通游戏,并且被多个互动小说解释器支持,因此你可以像玩普通互动小说游戏一样玩生成的游戏。
让我们生成一些游戏,看看它们带来什么:
$ tw-make tw-coin_collector --output t1 --seed 10 --level 5 --format ulx
Global seed: 10
Game generated: t1.ulx
该命令会生成三个文件:t1.ulx、t1.ni 和 t1.json。第一个包含要加载到解释器中的字节码,其他两个是扩展数据,环境可以利用这些数据在游戏过程中提供额外的信息。
要在互动模式下玩游戏,你可以使用任何支持 Glulx 格式的互动小说解释器,或者使用提供的工具 tw-play,这可能不是玩互动小说游戏的最便捷方式,但它可以让你检查结果:
$ tw-play t1.ulx
Using TWInform7\.
...
Hey, thanks for coming over to the TextWorld today, there
is something I need you to do for me. First thing I need you
to do is to try to venture east. Then, venture south. After
that, try to go to the south. Once you succeed at that, try
to go west. If you can finish that, pick-up the coin from
the floor of the chamber. Once that’s all handled, you can stop!
-= Spare Room =-
You are in a spare room. An usual one.
You don’t like doors? Why not try going east, that entranceway
is unblocked.
> _
观察和动作空间。
生成并玩游戏可能很有趣,但 TextWorld 的核心价值在于它能为生成的或现有的游戏提供强化学习接口。让我们来看看我们可以用刚刚在上一节中生成的游戏做些什么:
>>> from textworld import gym
>>> from textworld.gym import register_game
>>> env_id = register_game("t1.ulx")
>>> env_id
’tw-v0’
>>> env = gym.make(env_id)
>>> env
<textworld.gym.envs.textworld.TextworldGymEnv object at 0x102f77350>
>>> r = env.reset()
>>> print(r[1])
{}
>>> print(r[0][1205:])
$$
Hey, thanks for coming over to the TextWorld today, there is something I need you to do for me. First thing I need you to do is to try to venture east. Then, venture south. After that, try to go to the south. Once you succeed at that, try to go west. If you can finish that, pick-up the coin from the floor of the chamber. Once that’s all handled, you can stop!
-= Spare Room =-
You are in a spare room. An usual one.
You don’t like doors? Why not try going east, that entranceway is unblocked.
在这里,我们注册了生成的游戏并创建了环境。你可能注意到我们没有使用 Gymnasium 的 make() 函数,而是使用了来自 textworld 模块的同名函数。这不是错误。事实上,在撰写时,最新的 TextWorld 版本移除了对 Gym API 包的依赖,并提供了自己的环境类,它与 Env 类非常相似(但不完全相同)。
我认为这个移除是暂时的,且是从 OpenAI Gym 向 Farama Gymnasium 过渡的一部分。但目前,在使用 TextWorld 时,我们必须考虑几个方面:
-
你必须使用 textworld.gym.make() 函数来创建游戏,而不是 gym.make()。
-
创建的环境没有观察和动作空间的规格。默认情况下,观察和动作都是字符串类型。
-
环境中的 step() 函数不会返回 is_truncated 标志,只会返回观察、奖励、标志 is_done 和一个包含额外信息的字典。因此,你不能将 Gymnasium 的包装器应用于这个环境——必须创建一个小的“适配器”包装器。
在 TextWorld 的早期版本中,他们提供了分词功能,但这些功能已被移除,因此我们需要自己处理文本预处理。
现在,让我们来看一下游戏引擎提供的额外信息。
额外的游戏信息。
在我们开始规划第一个训练代码之前,我们需要讨论一个我们将使用的 TextWorld 额外功能。正如你可能猜到的,甚至是一个简单的问题也可能对我们来说太具挑战性:
-
观察是由最多 200 个标记组成的文本序列,这些标记来自大小为 1250 的词汇表。动作可以长达 8 个标记。生成的游戏有五个动作需要按正确的顺序执行。因此,我们随机找到正确的 8 × 5 = 40 个标记的正确序列的机会大约是
。即便是最快的 GPU,这个概率也不太乐观。当然,我们有开始和结束序列标记,我们可以考虑这些来提高我们的成功机会;不过,即使如此,随机探索找到正确的动作序列的概率依然很小。 -
另一个挑战是环境的部分可观察马尔可夫决策过程(POMDP)特性,这源于游戏中我们的物品栏通常不显示的事实。在互动小说游戏中,通常只有在某些明确的命令下(如查看物品栏)才会显示角色拥有的物品。但我们的智能体并不了解先前的状态。因此,从它的角度来看,在执行命令 take apple 之后,情况与之前完全相同(唯一的区别是苹果不再出现在场景描述中)。我们可以通过堆叠状态来处理这个问题,就像我们在 Atari 游戏中所做的那样,但我们需要明确地进行,并且智能体需要处理的信息量将显著增加。
说了这么多,我们应该对环境做一些简化。幸运的是,TextWorld 为这种变通提供了方便的手段。在游戏注册过程中,我们可以传递额外的标志,将更多结构化的信息添加到观察空间中。以下是我们可以窥探的内部信息列表:
-
当前房间的独立描述,就像通过 look 命令获得的那样
-
当前物品栏
-
当前地点的名称
-
当前世界状态的事实
-
上一条动作和执行的上一条命令
-
当前状态下可接受的命令列表
-
赢得游戏所需执行的动作序列
此外,除了每一步提供的额外结构化观察之外,我们还可以要求 TextWorld 在我们朝正确方向移动时每次给我们提供中间奖励。正如你可能猜到的,这对于加速收敛非常有帮助。
我们可以添加的附加信息中最有用的功能是可接受的命令,这极大地减少了我们的动作空间,从 1250⁴⁰降到只有十几个,并且中间奖励可以引导训练朝正确的方向发展。为了启用这些额外信息,我们需要向 register_game()方法传递一个可选的参数:
>>> from textworld import gym, EnvInfos
>>> from textworld.gym import register_game
>>> env_id = register_game("t1.ulx", request_infos=EnvInfos(inventory=True, intermediate_reward=True, admissible_commands=True, description=True))
>>> env = gym.make(env_id)
>>> r = env.reset()
>>> r[1]
{’description’: "-= Spare Room =-\nYou are in a spare room. An usual one.\n\n\n\nYou don’t like doors? Why not try going east, that entranceway is unblocked.", ’admissible_commands’: [’go east’, ’inventory’, ’look’], ’inventory’: ’You are carrying nothing.’, ’intermediate_reward’: 0}
如你所见,现在环境中提供了之前为空的字典中的额外信息。在这种状态下,只有三个命令是有意义的(向东走、查看物品和观察)。让我们试试第一个命令:
>>> r = env.step(’go east’)
>>> r[1:]
(0, False, {’description’: "-= Attic =-\nYou make a grand eccentric entrance into an attic.\n\n\n\nYou need an unblocked exit? You should try going south. You don’t like doors? Why not try going west, that entranceway is unblocked.", ’admissible_commands’: [’go south’, ’go west’, ’inventory’, ’look’], ’inventory’: ’You are carrying nothing.’, ’intermediate_reward’: 1})
命令已被接受,我们获得了 1 的中间奖励。好的,太棒了。现在我们已经具备了解决 TextWorld 问题所需的一切来实现我们的第一个基准 DQN 代理!但在此之前,我们需要稍微深入一下自然语言处理(NLP)领域。
深度 NLP 基础
在这一小节中,我将带你了解深度 NLP 的构建模块和标准方法。这个领域正在以惊人的速度发展,尤其是在现在,ChatGPT 和大语言模型(LLMs)已经在聊天机器人和文本处理方面设定了新标准。
本节内容仅涉及表面,涵盖了最常见和标准的构建模块。其中一些,如 RNN 和 LSTM,可能看起来已经过时——但我依然认为这没关系,因为了解历史背景是很重要的。对于简单任务,你可以根据任务的需求选择最合适的工具,即使它们现在不再流行。
循环神经网络(RNN)
NLP 有其独特性,使其与计算机视觉或其他领域不同。其中一个特点是处理可变长度的对象。在不同层次上,NLP 处理的对象可能具有不同的长度;例如,一种语言中的单词可能包含多个字符。句子是由可变长度的单词序列组成的。段落或文档由不同数量的句子构成。这种可变性并非 NLP 特有,它也出现在不同的领域中,比如信号处理或视频处理。即便是标准的计算机视觉问题,也可以视为某种对象的序列,比如图像字幕问题,在这种问题中,神经网络(NN)可以集中关注同一图像的不同区域,以便更好地描述图像。
RNN 提供了应对这种可变性的标准构建模块之一。RNN 是一个具有固定输入和输出的网络,它被应用于一系列对象,并且能够在这个序列中传递信息。这个信息被称为隐藏状态,通常只是一个包含若干数字的向量。
在下图中,我们有一个 RNN,其输入是一个固定大小的数字向量;输出是另一个向量。与标准的前馈神经网络(NN)或卷积神经网络(CNN)不同的是,这里有两个额外的门:一个输入门和一个输出门。额外的输入门将上一个项的隐藏状态传递到 RNN 单元,而额外的输出门则将转换后的隐藏状态提供给下一个序列:

图 13.3:RNN 构建模块的结构
由于 RNN 有两个输入,它可以应用于任何长度的输入序列,只需将前一个输入产生的隐藏状态传递给下一个输入即可。在图 13.4 中,RNN 被应用于句子"this is a cat",并为序列中的每个单词生成输出。在应用过程中,我们对每个输入项都使用相同的 RNN,但通过传递隐藏状态,它现在可以沿着序列传递信息:

图 13.4:RNN 如何应用于句子
这类似于卷积神经网络(CNN),当我们对图像的不同位置应用相同的一组过滤器时,区别在于卷积神经网络无法传递隐藏状态。
尽管这个模型很简单,但它为标准的前馈神经网络模型增加了额外的自由度。前馈神经网络的输出由其输入决定,对于某个固定输入总是产生相同的输出(当然是在推理过程中,而非训练过程中)。RNN 的输出不仅依赖于输入,还依赖于隐藏状态,而隐藏状态可能会被神经网络自身改变。因此,神经网络可以将序列开始时的信息传递到序列末尾,并根据不同的上下文为相同的输入生成不同的输出。这种上下文依赖性在 NLP 中非常重要,因为在自然语言中,单个词语在不同的上下文中可能有完全不同的含义,而整个句子的意义可能仅凭一个词的变化而发生变化。
当然,这种灵活性也有其成本。循环神经网络(RNN)通常需要更多的训练时间,并且可能会产生一些奇怪的行为,如训练过程中的损失波动或突然的遗忘。然而,研究界已经做了大量工作,并且仍在努力使 RNN 变得更加实用和稳定,因此 RNN 及其现代替代品,如变换器(transformers),可以被视为需要处理变长输入的系统的标准构建模块。
在我们的示例中,我们将使用 RNN 的演化版本——长短期记忆(LSTM)模型,该模型最早由 Sepp Hochreiter 和 Jürgen Schmidhuber 于 1995 年在论文《LSTM 可以解决长期延迟问题》中提出,并于 1996 年在神经信息处理系统(NIPS)会议上发布[HS96]。这个模型与我们刚刚讨论的 RNN 非常相似,但它具有更复杂的内部结构,以解决一些 RNN 的问题。
词嵌入
现代深度学习驱动的自然语言处理(NLP)另一个标准构建模块是词嵌入,也称为 word2vec,这是一种最流行的训练方法,用于处理简单任务。这个想法源于在神经网络中表示语言序列的问题。通常,神经网络处理的是固定大小的数字向量,但在 NLP 中,我们通常使用单词或字符作为模型的输入。
虽然像 word2vec 这样的旧方法通常用于较简单的任务,并且在该领域仍然非常相关,但其他方法,如 BERT 和变换器,广泛应用于更复杂的任务。我们将在本章后面简要讨论变换器。
一个可能的解决方案是对我们的词典进行 one-hot 编码,也就是每个单词在输入向量中都有自己独立的位置,当我们在输入序列中遇到这个单词时,将该位置设置为 1。这是神经网络处理一些相对较小的离散项集并希望以神经网络友好的方式表示它们时的标准方法。不幸的是,one-hot 编码由于几个原因并不十分有效:
-
我们的输入集通常不小。如果我们只想对最常用的英语词典进行编码,它至少包含几千个单词。牛津英语词典有 170,000 个常用词和 50,000 个过时或稀有词汇。这仅仅是已建立的词汇表,还不包括俚语、新词、科学术语、缩写、拼写错误、笑话、Twitter/X 的梗等等。而且这只是针对英语语言!
-
与单词的 one-hot 表示相关的第二个问题是词汇表频率的不均衡。有一些非常频繁的单词集,像 "a" 和 "cat",但也有大量不常用的单词,像 "covfefe" 或 "bibliopole",这些罕见的单词可能在一个非常大的文本语料库中只出现一两次。因此,我们的 one-hot 表示在空间方面非常低效。
-
简单的 one-hot 表示的另一个问题是无法捕捉到单词之间的关系。例如,一些单词是同义词,具有相同的含义,但它们会被不同的向量表示。一些单词常常一起使用,如 "United Nations" 或 "fair trade",而这一事实也未能在 one-hot 表示中体现出来。
为了克服这一切,我们可以使用词嵌入,它将某个词汇表中的每个单词映射为一个密集的、固定长度的数字向量。这些数字不是随机的,而是通过大规模文本语料库训练得到的,以捕捉单词的上下文。词嵌入的详细描述超出了本书的范围,但这是一种非常强大且广泛使用的自然语言处理(NLP)技术,用于表示单词、字符和其他序列中的对象。现在,你可以将它们理解为只是将单词映射为数字向量,而这种映射对于神经网络(NN)能够区分单词之间的差异非常方便。为了获得这种映射,有两种方法。首先,你可以下载所需语言的预训练向量。有多个可用的嵌入源,只需在 Google 上搜索“GloVe 预训练向量”或“word2vec 预训练”(GloVe 和 word2vec 是训练这些向量的不同方法,产生相似的结果)。获取嵌入的另一种方法是自行在你的数据集上进行训练。为此,你可以使用特殊工具,如 fastText(fasttext.cc/,Facebook 的开源工具),或直接随机初始化嵌入并允许模型在正常训练过程中调整它们。
此外,LLMs(以及一般的任何序列到序列架构)可以生成非常高质量的文本嵌入。OpenAI 的 ChatGPT API 有一个特殊请求,可以将任何文本转换为嵌入向量。
编码器-解码器架构
另一个在 NLP 中广泛使用的模型是编码器-解码器模型,或称 seq2seq。它最初来自机器翻译,当你的系统需要接受源语言中的一系列单词,并在目标语言中生成另一系列单词时使用。seq2seq 的基本思想是使用一个 RNN 来处理输入序列,并将该序列编码成某个固定长度的表示。这种 RNN 被称为编码器。然后,你将编码后的向量输入到另一个 RNN 中,称为解码器,解码器必须生成目标语言中的结果序列。下面是这个思想的一个示例,我们正在将英文句子翻译成俄语:

图 13.5:机器翻译中的编码器-解码器架构
这个模型(通过大量现代化的调整和扩展)仍然是机器翻译的主要工作马,但它足够通用,可以应用于更广泛的领域,例如音频处理、图像标注和视频字幕生成。在我们的 TextWorld 示例中,我们将使用它来生成来自环境的变大小观察的嵌入。
RNN 在某些上下文中依然非常有效,但近年来,随着更复杂的 Transformer 模型的引入,NLP 领域发生了重大进展。接下来,我们将看看 Transformer 架构。
Transformer 模型
Transformer 是在 2017 年由谷歌的 Vaswani 等人提出的架构,发表于论文《Attention is all you need》[Vas17]。从高层次看,它使用了我们刚刚讨论的相同的编码器-解码器架构,但对底层构建模块进行了若干改进,这些改进对于解决现有 RNN 问题至关重要:
-
位置信息编码:它将关于输入和输出序列位置的信息注入到嵌入中。
-
注意力机制:这个概念是在 2015 年提出的,可以看作是一种可训练的方式,使得系统能够集中注意力于输入序列的特定部分。在 transformer 中,注意力机制得到了广泛应用(从论文标题中可以猜到这一点)。
目前,transformer 是几乎所有自然语言处理(NLP)和深度学习(DL)系统的核心,包括大型语言模型(LLMs)。我不会深入探讨这一架构,因为关于这个话题有很多资源,但如果你感兴趣,可以查看以下文章:jalammar.github.io/illustrated-transformer/。
现在我们已经拥有了实现第一个基线 DQN 智能体来解决 TextWorld 问题所需的一切。
基线 DQN
回到我们的 TextWorld 环境,以下是主要的挑战:
-
文本序列本身可能会成为问题,正如我们在本章前面讨论的那样。序列长度的可变性可能导致 RNN 中的梯度消失和爆炸、训练缓慢以及收敛问题。除此之外,我们的 TextWorld 环境提供了几个需要单独处理的此类序列。例如,我们的场景描述字符串对智能体可能有完全不同的含义,而物品栏字符串则描述我们的物品。
-
另一个障碍是动作空间。正如你在上一节中看到的,TextWorld 可能会为我们提供在每个状态下可以执行的命令列表。它显著减少了我们需要从中选择的动作空间,但也存在其他复杂性。其中之一是可接受命令列表会根据状态的不同而变化(不同位置可能允许执行不同的命令)。另一个问题是,可接受命令列表中的每一项都是一个由单词组成的序列。
我们有可能通过构建一个包含所有可能命令的字典,并将其作为一个离散的、固定大小的动作空间,从而消除这两种变动性。在简单的游戏中,这可能有效,因为位置和物体的数量并不大。你可以尝试这个方法作为练习,但我们将沿着不同的路径前进。
到目前为止,你只见过离散的动作空间,其中包含少量预定义的动作,这影响了 DQN 的架构:网络的输出通过一次传递预测所有动作的 Q 值,这在训练和模型应用过程中都非常方便(因为我们无论如何都需要所有动作的 Q 值来找到最大 Q 值)。但这种 DQN 架构的选择并不是方法决定的,因此如果需要,我们可以进行调整。我们关于动作数量可变的问题可能通过这种方式得到解决。为了更好地理解这种方式,让我们来看一下 TextWorld 基线 DQN 的架构,如下图所示:

图 13.6:TextWorld 基线 DQN 的架构
图中的主要部分由预处理模块占据。在网络的输入端(图左边的模块),我们得到的是由各个观察部分(“原始文本”、“描述”和“库存”)组成的可变序列,以及一个需要评估的动作命令序列。这个命令将从可接受命令列表中选取,网络的目标是为当前游戏状态和该特定命令预测一个 Q 值。这个方法与我们之前使用的 DQN 有所不同,但由于我们无法预先知道每个状态下将评估哪些命令,因此我们将单独评估每个命令。
这四个输入序列(即我们词汇表中词元 ID 的列表)将通过嵌入层,然后输入到不同的 LSTM RNN 中。
LSTM 网络(在图中称为“编码器”,因为 LSTM 是编码器的具体实现)的目标是将可变长度的序列转换为固定大小的向量。每个输入部分都会由其自己的 LSTM 进行处理,使用独立的权重,这使得网络能够捕捉来自不同输入序列的不同数据。在本章后面,我们将用来自 Hugging Face Hub 的预训练变换器替换 LSTM,来验证在相同问题上使用一个更智能、更大模型的效果。
编码器的输出会被拼接成一个单一的向量,并传递给主 DQN 网络。由于我们的可变长度序列已被转换为固定大小的向量,DQN 网络很简单:只有几层前馈层,输出一个 Q 值。这在计算上效率较低,但作为基准来说是可以接受的。
完整的源代码位于 Chapter13 目录中,包含以下模块:
-
train_basic.py:一个基线训练程序
-
lib/common.py:设置 Ignite 引擎和超参数的公共工具
-
lib/preproc.py:包括嵌入和编码器类的预处理管道
-
lib/model.py:包含帮助函数的 DQN 模型和 DQN 代理
本章不会展示完整的源代码。相反,我们将在后续的部分仅解释最重要或最棘手的部分。
观察预处理
让我们从管道的最左侧部分开始(图 13.6)。在输入端,我们将获得多个令牌列表,分别用于单独的状态观察和我们即将评估的命令。正如你已经看到的,TextWorld 环境生成的是字符串和包含扩展信息的字典,所以我们需要对字符串进行分词并去除不相关的信息。这是 TextWorldPreproc 类的职责,该类定义在 lib/preproc.py 模块中:
class TextWorldPreproc(gym.Wrapper):
log = logging.getLogger("TextWorldPreproc")
OBS_FIELD = "obs"
def __init__(
self, env: gym.Env, vocab_rev: tt.Optional[tt.Dict[str, int]],
encode_raw_text: bool = False,
encode_extra_fields: tt.Iterable[str] = (’description’, ’inventory’),
copy_extra_fields: tt.Iterable[str] = (),
use_admissible_commands: bool = True, keep_admissible_commands: bool = False,
use_intermediate_reward: bool = True, tokens_limit: tt.Optional[int] = None,
reward_wrong_last_command: tt.Optional[float] = None
):
super(TextWorldPreproc, self).__init__(env)
self._vocab_rev = vocab_rev
self._encode_raw_text = encode_raw_text
self._encode_extra_field = tuple(encode_extra_fields)
self._copy_extra_fields = tuple(copy_extra_fields)
self._use_admissible_commands = use_admissible_commands
self._keep_admissible_commands = keep_admissible_commands
self._use_intermedate_reward = use_intermediate_reward
self._num_fields = len(self._encode_extra_field) + int(self._encode_raw_text)
self._last_admissible_commands = None
self._last_extra_info = None
self._tokens_limit = tokens_limit
self._reward_wrong_last_command = reward_wrong_last_command
self._cmd_hist = []
该类实现了 gym.Wrapper 接口,因此它将按照我们需要的方式转换 TextWorld 环境中的观察和动作。构造函数接受多个标志,这简化了未来的实验。例如,您可以禁用使用可接受命令或中间奖励、设置令牌的限制或更改要处理的观察字段集。
接下来,num_fields 属性返回观察序列的数量,用于了解编码后的观察形状:
@property
def num_fields(self):
return self._num_fields
def _maybe_tokenize(self, s: str) -> str | tt.List[int]:
if self._vocab_rev is None:
return s
tokens = common.tokenize(s, self._vocab_rev)
if self._tokens_limit is not None:
tokens = tokens[:self._tokens_limit]
return tokens
_maybe_tokenize() 方法执行输入字符串的分词处理。如果没有提供词汇表,则字符串会原样返回。我们将在 transformer 版本中使用此功能,因为 Hugging Face 库会执行它们自己的分词处理。
_encode() 方法是观察数据转换的核心:
def _encode(self, obs: str, extra_info: dict) -> dict:
obs_result = []
if self._encode_raw_text:
obs_result.append(self._maybe_tokenize(obs))
for field in self._encode_extra_field:
extra = extra_info[field]
obs_result.append(self._maybe_tokenize(extra))
result = {self.OBS_FIELD: obs_result}
if self._use_admissible_commands:
result[KEY_ADM_COMMANDS] = [
self._maybe_tokenize(cmd) for cmd in extra_info[KEY_ADM_COMMANDS]
]
self._last_admissible_commands = extra_info[KEY_ADM_COMMANDS]
if self._keep_admissible_commands:
result[KEY_ADM_COMMANDS] = extra_info[KEY_ADM_COMMANDS]
if ’policy_commands’ in extra_info:
result[’policy_commands’] = extra_info[’policy_commands’]
self._last_extra_info = extra_info
for field in self._copy_extra_fields:
if field in extra_info:
result[field] = extra_info[field]
return result
前述方法接受观察字符串和扩展信息字典,并返回一个包含以下键的单一字典:
-
obs:包含输入序列的令牌 ID 列表的列表。 -
admissible_commands:当前状态下可用命令的列表。每个命令都会被分词并转换为令牌 ID 列表。
此外,该方法会记住额外的信息字典和原始的可接受命令列表。这对于训练来说不是必须的,但在模型应用期间非常有用,能够根据命令的索引获取回命令文本。
定义了 _encode() 方法后,reset() 和 step() 方法的实现就很简单了——我们在编码观察数据并处理中间奖励(如果启用):
def reset(self, seed: tt.Optional[int] = None):
res, extra = self.env.reset()
self._cmd_hist = []
return self._encode(res, extra), extra
def step(self, action):
if self._use_admissible_commands:
action = self._last_admissible_commands[action]
self._cmd_hist.append(action)
obs, r, is_done, extra = self.env.step(action)
if self._use_intermedate_reward:
r += extra.get(’intermediate_reward’, 0)
if self._reward_wrong_last_command is not None:
if action not in self._last_extra_info[KEY_ADM_COMMANDS]:
r += self._reward_wrong_last_command
return self._encode(obs, extra), r, is_done, False, extra
值得注意的是,step() 方法期望从封装的环境中返回 4 项,但实际上返回了 5 项。这隐藏了我们之前讨论的 TextWorld 环境与现代 Gym 接口的不兼容问题。
最后,有两个属性可以访问记住的状态:
@property
def last_admissible_commands(self):
if self._last_admissible_commands:
return tuple(self._last_admissible_commands)
return None
@property
def last_extra_info(self):
return self._last_extra_info
为了说明前述类的应用方式及其如何处理观察内容,让我们来看一下下面的小型交互示例。在这里,我们注册了游戏,并请求库存、中间奖励、可接受的命令和场景描述:
>>> from textworld import gym, EnvInfos
>>> from lib import preproc, common
>>> env_id = gym.register_game("games/simple1.ulx", request_infos=EnvInfos(inventory=True, intermediate_reward=True, admissible_commands=True, description=True))
>>> env = gym.make(env_id)
>>> env.reset()[1]
{’intermediate_reward’: 0, ’inventory’: ’You are carrying: a type D latchkey, a teacup and a sponge.’, ’description’: "-= Spare Room =-\nThis might come as a shock to you, but you’ve just walked into a spare room. You can barely contain your excitement.\n\nYou can make out a closed usual looking crate close by. You can make out a rack. However, the rack, like an empty rack, has nothing on it.\n\nThere is an exit to the east. Don’t worry, it is unblocked. You don’t like doors? Why not try going south, that entranceway is unguarded.", ’admissible_commands’: [’drop sponge’, ’drop teacup’, ’drop type D latchkey’, ’examine crate’, ’examine rack’, ’examine sponge’, ’examine teacup’, ’examine type D latchkey’, ’go east’, ’go south’, ’inventory’, ’look’, ’open crate’, ’put sponge on rack’, ’put teacup on rack’, ’put type D latchkey on rack’]}
所以,这就是我们从 TextWorld 环境中获得的原始观察数据。现在让我们提取游戏词汇并应用我们的预处理器:
>>> vocab, action_space, obs_space = common.get_games_spaces(["games/simple1.ulx"])
>>> vocab
{0: ’a’, 1: ’about’, 2: ’accomplished’, 3: ’an’, 4: ’and’, 5: ’appears’, 6: ’are’, 7: ’arrive’, 8: ’as’, 9: ’barely’, 10: ’be’, 11: ’because’, 12: ’begin’, 13: ’being’, 14: ’believe’
....
>>> len(vocab)
192
>>> vocab_rev = common.build_rev_vocab(vocab)
>>> vocab_rev
{’a’: 0, ’about’: 1, ’accomplished’: 2, ’an’: 3, ’and’: 4, ’appears’: 5, ’are’: 6, ’arrive’: 7
...
>>> pr_env = preproc.TextWorldPreproc(env, vocab_rev)
>>> r = pr_env.reset()
>>> r[0]
{’obs’: [[142, 132, 166, 106, 26, 8, 0, 136, 167, 188, 17, 188, 86, 180, 82, 0, 142, 132, 188, 20, 9, 27, 191, 57, 188, 20, 103, 121, 0, 24, 178, 101, 35, 23, 18, 188, 20, 103, 121, 0, 129, 77, 161, 129, 94, 3, 50, 129, 73, 111, 115, 85, 163, 84, 3, 58, 167, 161, 44, 152, 186, 85, 84, 172, 188, 152, 94, 41, 184, 110, 169, 72, 141, 159, 53, 84, 173], [188, 6, 0, 170, 36, 92, 0, 157, 4, 0, 143]], ’admissible_commands’: [[42, 143], [42, 157], [42, 170, 36, 92], [55, 35], [55, 129], [55, 143], [55, 157], [55, 170, 36, 92], [71, 44], [71, 141], [83], [100], [117, 35], [127, 143, 115, 129], [127, 157, 115, 129], [127, 170, 36, 92, 115, 129]]}
>>> r[1]
{’intermediate_reward’: 0, ’inventory’: ’You are carrying: a type D latchkey, a teacup and a sponge.’, ’description’: "-= Spare Room =-\nThis might come as a shock to you, but you’ve just walked into a spare room. You can barely contain your excitement.\n\nYou can make out a closed usual looking crate close by. You can make out a rack. However, the rack, like an empty rack, has nothing on it.\n\nThere is an exit to the east. Don’t worry, it is unblocked. You don’t like doors? Why not try going south, that entranceway is unguarded.", ’admissible_commands’: [’drop sponge’, ’drop teacup’, ’drop type D latchkey’, ’examine crate’, ’examine rack’, ’examine sponge’, ’examine teacup’, ’examine type D latchkey’, ’go east’, ’go south’, ’inventory’, ’look’, ’open crate’, ’put sponge on rack’, ’put teacup on rack’, ’put type D latchkey on rack’]}
让我们尝试执行一个动作。第 0 个动作对应于可接受命令列表中的第一个条目,在我们的例子中是“丢掉海绵”:
>>> r[1][’inventory’]
’You are carrying: a type D latchkey, a teacup and a sponge.’
>>> obs, reward, is_done, _, info = pr_env.step(0)
>>> info[’inventory’]
’You are carrying: a type D latchkey and a teacup.’
>>> reward
0
如你所见,我们不再有海绵,但这并不是正确的动作,因此没有给予中间奖励。
好吧,这个表示法仍然不能直接输入到神经网络中,但它已经比之前更接近我们想要的形式了。
嵌入和编码器
预处理管道中的下一步由两个类实现:
-
Encoder:一个 LSTM 单元的包装器,将一个单一序列(在应用了嵌入后)转换成一个固定大小的向量
-
预处理器:该类负责应用嵌入和使用相应的编码器类转换单个序列
Encoder 类比较简单,所以我们先从它开始:
class Encoder(nn.Module):
def __init__(self, emb_size: int, out_size: int):
super(Encoder, self).__init__()
self.net = nn.LSTM(input_size=emb_size, hidden_size=out_size, batch_first=True)
def forward(self, x):
self.net.flatten_parameters()
_, hid_cell = self.net(x)
return hid_cell[0].squeeze(0)
逻辑是:我们应用 LSTM 层,并在处理完序列后返回其隐藏状态。
Preprocessor 类要复杂一些,因为它结合了多个 Encoder 实例,并且也负责嵌入:
class Preprocessor(nn.Module):
def __init__(self, dict_size: int, emb_size: int, num_sequences: int,
enc_output_size: int, extra_flags: tt.Sequence[str] = ()):
super(Preprocessor, self).__init__()
self._extra_flags = extra_flags
self._enc_output_size = enc_output_size
self.emb = nn.Embedding(num_embeddings=dict_size, embedding_dim=emb_size)
self.encoders = []
for idx in range(num_sequences):
enc = Encoder(emb_size, enc_output_size)
self.encoders.append(enc)
self.add_module(f"enc_{idx}", enc)
self.enc_commands = Encoder(emb_size, enc_output_size)
在构造函数中,我们创建了一个嵌入层,该层将我们词典中的每个标记映射到一个固定大小的稠密向量。然后,我们为每个输入序列创建了num_sequences个 Encoder 实例,并创建了一个额外的实例来编码命令标记。
内部方法_apply_encoder()接受一批序列(每个序列是一个标记 ID 的列表)并使用编码器进行转换:
def _apply_encoder(self, batch: tt.List[tt.List[int]], encoder: Encoder):
dev = self.emb.weight.device
batch_t = [self.emb(torch.tensor(sample).to(dev)) for sample in batch]
batch_seq = rnn_utils.pack_sequence(batch_t, enforce_sorted=False)
return encoder(batch_seq)
在早期版本的 PyTorch 中,我们需要在应用 RNN 之前对可变长度的序列进行排序。从 PyTorch 1.0 版本开始,这已经不再需要,因为排序和转换由 PackedSequence 类内部处理。为了启用此功能,我们需要传递enforce_sorted=False参数。
encode_observations()方法接受一批观察结果(来自 TextWorldPreproc)并将其编码成一个张量:
def encode_observations(self, observations: tt.List[dict]) -> torch.Tensor:
sequences = [obs[TextWorldPreproc.OBS_FIELD] for obs in observations ]
res_t = self.encode_sequences(sequences)
if not self._extra_flags:
return res_t
extra = [[obs[field] for field in self._extra_flags] for obs in observations]
extra_t = torch.Tensor(extra).to(res_t.device)
res_t = torch.cat([res_t, extra_t], dim=1)
return res_t
除了可变序列外,我们还可以将额外的“标志”字段直接传递到编码后的张量中。这个功能将在后续的实验和对基本方法的扩展中使用。
最后,encode_sequences()和encode_commands()两个方法被用来将不同的编码器应用于可变长度序列的批处理:
def encode_sequences(self, batches):
data = []
for enc, enc_batch in zip(self.encoders, zip(*batches)):
data.append(self._apply_encoder(enc_batch, enc))
res_t = torch.cat(data, dim=1)
return res_t
def encode_commands(self, batch):
return self._apply_encoder(batch, self.enc_commands)
DQN 模型和代理
在做了所有这些准备工作之后,让我们看看我们代理的“大脑”:DQN 模型。它应该接受num_sequences × encoder_size的向量并产生一个单一的标量值。但是与其他 DQN 模型的不同之处在于,我们应用模型的方式:
class DQNModel(nn.Module):
def __init__(self, obs_size: int, cmd_size: int, hid_size: int = 256):
super(DQNModel, self).__init__()
self.net = nn.Sequential(
nn.Linear(obs_size + cmd_size, hid_size),
nn.ReLU(),
nn.Linear(hid_size, 1)
)
def forward(self, obs, cmd):
x = torch.cat((obs, cmd), dim=1)
return self.net(x)
@torch.no_grad()
def q_values(self, obs_t, commands_t):
result = []
for cmd_t in commands_t:
qval = self(obs_t, cmd_t.unsqueeze(0))[0].cpu().item()
result.append(qval)
return result
在之前的代码中,forward()方法接受两个批次——观察结果和命令——为每一对生成 Q 值批次。另一个方法q_values()接受由预处理器类生成的一个观察结果和编码后的命令张量,随后应用模型并返回每个命令的 Q 值列表。
在 model.py 模块中,我们有 DQNAgent 类,它接收预处理器并实现 PTAN Agent 接口,以隐藏决策过程中的观察预处理细节。
训练代码
在所有准备和预处理工作完成后,其余的代码与我们在之前章节中实现的几乎相同,因此我不会重复训练代码;我只会描述训练逻辑。
要训练模型,必须使用 Chapter13/train_basic.py 工具。它允许通过几个命令行参数来改变训练行为:
-
-g 或 --game:这是游戏文件的前缀,位于 games 目录中。提供的脚本会生成几个名为 simpleNN.ulx 的游戏,其中 NN 是游戏的种子值。
-
-s 或 --suffices:这是训练期间使用的游戏数量。如果指定 1(默认值),则训练只会在文件 simple1.ulx 上进行。如果指定 -s 10,则会注册并使用编号从 1 到 10 的 10 个游戏进行训练。此选项用于增加训练游戏的多样性,因为我们的目标不仅是学习如何玩具体的游戏,还希望(如果可能的话)学习如何在其他类似的游戏中表现。
-
-v 或 --validation:这是用于验证的游戏的后缀。默认值为 -val,并定义将用于检查我们训练智能体泛化能力的游戏文件。
-
--params:这表示将使用的超参数。lib/common.py 中定义了两组:small 和 medium。第一组具有较少的嵌入和编码向量,非常适合快速解决少数几个游戏;然而,当使用许多游戏进行训练时,这一组会遇到收敛困难的问题。
-
--dev:该选项指定计算使用的设备名称。
-
-r 或 --run:这是运行的名称,通常用作保存目录和 TensorBoard 的名称。
在训练过程中,每进行 100 次训练迭代会执行一次验证,并在当前网络上运行验证游戏。奖励和步骤数会记录在 TensorBoard 中,帮助我们理解智能体的泛化能力。强化学习中的泛化一直是一个大问题,因为在有限的轨迹集上,训练过程有过拟合到某些状态的倾向,这并不能保证在未见过的游戏中表现良好。与 Atari 游戏相比,后者的游戏玩法通常变化不大,互动小说游戏的可变性可能更高,因为它们有不同的任务、物品以及沟通方式。因此,检查我们的智能体能在不同游戏之间如何泛化是一个有趣的实验。
训练结果
默认情况下,脚本 games/make_games.sh 会生成 20 个游戏,名称从 simple1.ulx 到 simple20.ulx,还有一个用于验证的游戏:simple-val.ulx。
首先,让我们在一个游戏上训练智能体,使用较小的超参数设置:
$ ./train_basic.py -s 1 --dev cuda -r t1
Registered env tw-simple-v0 for game files [’games/simple1.ulx’]
Game tw-simple-v1, with file games/simple-val.ulx will be used for validation
Episode 1: reward=0 (avg 0.00), steps=50 (avg 50.00), speed=0.0 f/s, elapsed=0:00:04
Episode 2: reward=1 (avg 0.02), steps=50 (avg 50.00), speed=0.0 f/s, elapsed=0:00:04
1: best avg training reward: 0.020, saved
Episode 3: reward=-2 (avg -0.02), steps=50 (avg 50.00), speed=0.0 f/s, elapsed=0:00:04
Episode 4: reward=6 (avg 0.10), steps=30 (avg 49.60), speed=0.0 f/s, elapsed=0:00:04
...
选项 -s 指定将用于训练的游戏索引数量。在这种情况下,只会使用一个。当游戏中的平均步数降到 15 以下时,训练就会停止,这意味着智能体已经找到了正确的步骤序列,并能够高效地完成游戏。
对于单场游戏,解决游戏只需要 3 分钟和大约 120 回合。下图展示了训练过程中的奖励和步数变化:

图 13.7:在一场游戏中训练的奖励(左)和回合步数(右)
但是,如果我们检查验证奖励(即在游戏 simple-val.ulx 上获得的奖励),我们会看到它随着时间的推移没有任何改善。在我的情况下,验证奖励是零,验证回合的步数是 50(这是默认的时间限制)。这仅仅意味着学到的智能体没有办法进行泛化。
如果我们尝试增加用于训练的游戏数量,收敛将需要更多的时间,因为网络需要在不同的状态中发现更多的动作序列。以下是 20 场游戏(传递选项 -s 20)的奖励和步数图表:

图 13.8:在 20 场游戏中训练的奖励(左)和回合步数(右)
如你所见,收敛大约需要两个小时,但我们的这个小型超参数集依然能够提升在训练过程中进行的 20 场游戏中的表现。
如下图所示,验证指标现在稍微有些有趣 —— 在训练结束时,智能体能够获得 2 分(最大为 6 分),并且在训练的中间阶段,它获得了 4 分。但验证游戏中的步数仍然是 50,这意味着智能体只是半随机地四处走动并执行一些动作。表现并不令人印象深刻。

图 13.9:在 20 场游戏中训练时的验证奖励
我没有在这个智能体上尝试不同的超参数(你可以通过 -s medium 来做到这一点)。
调整观察值
我们的第一次尝试将是在给智能体提供更多的信息。在这里,我将简要介绍所做的更改以及它们对训练结果的影响。你可以在 Chapter13/train_preproc.py 中找到完整的示例。
跟踪访问过的房间
首先,你会注意到我们的智能体无法判断当前房间是否已经访问过。当智能体已经知道通往目标的最佳路径时,可能不需要这个信息(因为生成的游戏总是有不同的房间)。但如果策略不完美,可能会有用,能明确指示我们是否在一遍遍地访问同一个房间。
为了将这些知识输入到观察中,我在 preproc.LocationWrapper 类中实现了一个简单的房间跟踪,它跟踪整个回合中访问过的房间。然后,这个标志会作为一个单一的 1(如果之前访问过该房间)或 0(如果是新位置)被拼接到智能体的观察中。
要使用这个扩展来训练我们的智能体,可以通过额外的命令行选项--seen-rooms 运行 train_preproc.py。
以下是将我们基准版本与这个额外观察在 20 局游戏中的比较图表。如你所见,训练游戏的奖励几乎相同,但验证奖励有所提高——我们几乎在整个训练过程中都能获得非零的验证奖励。不过,验证游戏中的步骤数仍然是 50。

图 13.10:20 局游戏中的训练奖励(左)和验证奖励(右)
但在对 200 局游戏进行尝试后(你需要更改脚本来生成这些游戏),我得到了一个有趣的结果:经过 14 小时的训练和 8000 个回合后,智能体不仅在验证游戏中获得了最高分,而且能够高效地做到这一点(步骤数小于 10)。这在图 13.11 和图 13.12 中展示。

图 13.11:200 局游戏中的训练奖励(左)和回合步骤(右)

图 13.12:验证奖励(左)和回合步骤(右)
相对动作
改进智能体学习的第二次尝试是关于动作空间的。从原则上讲,我们的智能体任务是导航房间并在周围的物体上执行特定的操作(例如打开储物柜并取出东西)。因此,导航在学习过程中是一个非常重要的方面。
目前,我们通过执行“绝对坐标”命令来移动,例如“向北走”或“向东走”,这些命令是特定于房间的,因为不同的房间可能有不同的出口可用。此外,在执行某些动作后,逆动作(返回原房间)依赖于第一个动作。例如,如果我们在一个有北方出口的房间,使用了这个出口后,我们需要执行“向南走”来返回。但我们的智能体没有动作历史的记忆,所以在向北走后,我们不知道如何返回。
在上一节中,我们添加了有关房间是否被访问的信息。现在,我们将绝对动作转化为相对动作。为了实现这一点,我们的包装器preproc.RelativeDirectionsWrapper会跟踪我们的“朝向方向”,并根据朝向方向将“向北走”或“向东走”命令替换为“向左走”、“向右走”、“向前走”或“向后走”。例如,当我们处在一个出口朝北的房间里且我们的朝向是北时,我们需要执行“向前走”命令以使用出口。然后,我们可以执行“向后走”命令返回原来的房间。希望这种转化能让我们的模型更轻松地在 TextWorld 游戏中导航。
要启用此扩展,你需要使用--relative-actions命令行选项运行train_preproc.py。此扩展还需要启用“已见房间”,因此在这里,我们测试了这两项修改结合后的效果。在 20 场游戏中,训练动态和验证结果与基准版本非常相似(如图 13.13 所示):

图 13.13:20 场游戏中的训练奖励(左)和验证奖励(右)
但在 200 场游戏上,智能体在仅 2.5 小时内就能在验证游戏中获得最高分(而不是“已见房间”扩展中的 13 小时)。验证中的步数也减少到了不到 10 步。但不幸的是,经过进一步训练后,验证指标恢复到较低的验证分数,因此智能体对游戏进行了过拟合,并遗忘了它已掌握的技能:

图 13.14:200 场游戏中的验证奖励(左)和回合步数(右)
观察中的目标
另一个想法是将游戏目标传递给智能体的观察值。目标在游戏开始时以文本形式呈现,例如:首先,我需要你做的事情是尝试向东前进。然后,向南前进。之后,尝试向南走。完成后,尝试向西走。如果你能完成这些,捡起房间地板上的硬币。完成所有这些后,你可以停止!
这些信息可能对智能体规划行动有所帮助,因此让我们将其添加到编码向量中。我们不需要实现另一个包装器,因为现有的包装器已经足够灵活。只需要向它们传递几个额外的参数。要启用目标,你需要使用--objective命令行参数运行train_preproc.py。
在 20 场游戏中的结果几乎与基准线完全相同,结果如图 13.15 所示:

图 13.15:20 场游戏中的训练奖励(左)和验证奖励(右)
在 200 场游戏上的训练比之前的修改效果差:在验证过程中,得分在 2 到 4 之间,但从未达到 6 分。奖励和验证奖励的图表如图 13.16 所示:

图 13.16:在 200 局游戏中的训练奖励(左)和验证奖励(右)
Transformer
接下来我们将尝试的是预训练语言模型,这是现代自然语言处理中的事实标准。得益于像 Hugging Face Hub 这样的公共模型库,我们不需要从头开始训练这些模型,这可能会非常耗费资源。我们只需要将预训练模型接入我们的架构,并对网络的一小部分进行微调,适应我们的数据集。
有各种各样的模型——不同的大小、它们所预训练的语料库、训练技术等等。但所有这些模型都使用一个简单的 API,因此将它们接入我们的代码非常简单直接。
首先,我们需要安装库。对于我们的任务,我们将使用包 sentence-transformers==2.6.1,你需要手动安装。一旦安装完成,你就可以用它计算任何给定字符串的嵌入:
>>> from sentence_transformers import SentenceTransformer
>>> tr = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
>>> tr.get_sentence_embedding_dimension()
384
>>> r = tr.encode("You’re standing in an ordinary boring room")
>>> type(r)
<class ’numpy.ndarray’>
>>> r.shape
(384,)
>>> r2 = tr.encode(["sentence 1", "sentence 2"], convert_to_tensor=True)
>>> type(r2)
<class ’torch.Tensor’>
>>> r2.shape
torch.Size([2, 384])
这里我们使用的是 all-MiniLM-L6-v2 模型,它相对较小——22M 参数,训练于 12 亿个标记。你可以在 Hugging Face 网站上找到更多信息:huggingface.co/sentence-transformers/all-MiniLM-L6-v2。
在我们的案例中,我们将使用高级接口,我们将包含句子的字符串输入,库和模型会为我们完成所有转换工作。不过,如果需要,仍然有很多灵活性。
preproc.TransformerPreprocessor 类实现了与我们旧的 Preprocessor 类(使用 LSTM 进行嵌入)相同的接口,我不会展示代码,因为它非常直观。
要使用 Transformer 训练我们的智能体,需要运行 Chapter13/train_tr.py 模块。在训练过程中,Transformer 显得比较慢(在我的机器上是 2 FPS 对比 6 FPS),这并不令人惊讶,因为模型比 LSTM 模型复杂得多。不过,在 20 局和 200 局的训练中,动态表现更好。在图 13.17 中,你可以看到 Transformer 和基线版本的训练奖励及回合步数。基线版本需要 1,000 个回合才能达到 15 步,而 Transformer 只需要 400 个回合。对 20 局游戏的验证结果比基线版本差(最大得分为 2):

图 13.17:在 20 局游戏中的训练奖励(左)和训练回合长度(右)
在 200 局游戏中也是相同的情况——智能体学习效率更高(以游戏数量计算),但验证结果并不理想。这可以通过 Transformer 的容量大得多来解释——它们产生的嵌入几乎是我们的基线模型的 20 倍(384 对比 20),所以智能体更容易记住正确的步骤序列,而不是试图找到高层次的通用观察到行动的映射关系。
ChatGPT
为了结束对 TextWorld 的讨论,让我们尝试另一种方法——使用 LLM。在 2022 年底公开发布后,OpenAI 的 ChatGPT 迅速流行开来,几乎改变了聊天机器人和基于文本的助手的格局。仅仅一年时间,数百个新的使用场景出现,并且数千个使用 LLM 技术的应用程序被开发出来。让我们尝试将这项技术应用到解决 TextWorld 游戏的问题上。
设置
首先,您需要在openai.com上注册一个账号。我们将从一个基于网页的交互式聊天开始实验,目前可以免费试用且无需注册(在撰写时),但接下来的示例将使用 ChatGPT API,您需要在platform.openai.com生成一个 API 密钥。一旦密钥创建完成,您需要将其设置为您正在使用的 shell 中的环境变量 OPENAI_API_KEY。
我们还将使用 langchain 库从 Python 与 ChatGPT 进行通信,因此请使用以下命令安装它:
$ pip install langchain==0.1.15 langchain-openai==0.1.2
请注意,这些包非常动态,新版本可能会破坏兼容性。
交互模式
在我们的第一个示例中,我们将使用基于网页的 ChatGPT 接口,要求其根据房间描述和游戏目标生成游戏指令。代码位于 Chapter13/chatgpt_interactive.py,功能如下:
-
启动命令行中给定的游戏 ID 的 TextWorld 环境
-
为 ChatGPT 创建包含指令、游戏目标和房间描述的提示
-
将该提示写入控制台
-
从控制台读取要执行的指令
-
在环境中执行该指令
-
从第 2 步开始重复,直到达到步数限制或游戏解决
所以,您的任务是复制生成的提示并将其粘贴到chat.openai.com的网页界面中。ChatGPT 将生成必须输入到控制台中的指令。
完整的代码非常简单且简短。它只有一个 play_game 函数,使用创建的环境执行游戏循环:
env_id = register_game(
gamefile=f"games/{args.game}{index}.ulx",
request_infos=EnvInfos(description=True, objective=True),
)
env = gym.make(env_id)
在环境创建过程中,我们只需要额外的两项信息:房间描述和游戏目标。原则上,这两者都可以从自由文本观察中提取,因此我们可以从这些文本中解析它们。但为了方便起见,我们要求 TextWorld 明确提供这些信息。
在 play_game 函数的开头,我们重置环境并生成初始提示:
def play_game(env, max_steps: int = 20) -> bool:
commands = []
obs, info = env.reset()
print(textwrap.dedent("""\
You’re playing the interactive fiction game.
Here is the game objective: %s
Here is the room description: %s
What command do you want to execute next? Reply with
just a command in lowercase and nothing else.
""") % (info[’objective’], info[’description’]))
print("=== Send this to chat.openai.com and type the reply...")
我没有花太多时间设计它,因为基本上,第一次尝试就成功了,我确信它可以得到改进。最后一句话“仅以小写形式回复一个指令,且不包含其他内容”可以防止聊天机器人过于冗长,并帮助我们省去解析输出的麻烦。
然后我们执行循环,直到游戏解决或达到步数限制:
while len(commands) < max_steps:
cmd = input(">>> ")
commands.append(cmd)
obs, r, is_done, info = env.step(cmd)
if is_done:
print(f"You won in {len(commands)} steps! "
f"Don’t forget to congratulate ChatGPT!")
return True
print(textwrap.dedent("""\
Last command result: %s
Room description: %s
What’s the next command?
""") % (obs, info[’description’]))
print("=== Send this to chat.openai.com and type the reply...")
print(f"Wasn’t able to solve after {max_steps} steps, commands: {commands}")
return False
后续提示则简单得多——我们只提供获得的观察结果(命令的结果)和新的房间描述。我们无需再次传递目标,因为网页界面会保留对话的上下文,所以聊天机器人知道我们的先前指令。
让我们来看一个游戏测试(种子 1)。为了减少冗长,我删除了房间描述;否则,内容会占用几页书。但你应该完整复制生成的文本:
$ ./chatgpt_interactive.py 1
Starting game 1
You’re playing the interactive fiction game.
Here is the game objective: Get ready to pick stuff up and put it in places, because you’ve just entered TextWorld! Here is your task for today. First stop, go to the south. And then, head east. With that over with, make an effort to go north. With that accomplished, ensure that the type D locker is open. Then, take the keyboard from the type D locker. That’s it!
Here is the room description: -= Spare Room =-
What command do you want to execute next? Reply with
just a command in lowercase and nothing else.
=== Send this to chat.openai.com and type the reply...
>>> go south
Last command result: -= Dish-Pit =-
Room description: -= Dish-Pit =-
What’s the next command?
=== Send this to chat.openai.com and type the reply...
>>> go east
Last command result: -= Cookhouse =-
Room description: -= Cookhouse =-
What’s the next command?
=== Send this to chat.openai.com and type the reply...
>>> go north
Last command result: -= Studio =-
Room description: -= Studio =-
What’s the next command?
=== Send this to chat.openai.com and type the reply...
>>> open type d locker
Last command result: You open the type D locker, revealing a keyboard.
Room description: -= Studio =-
What’s the next command?
=== Send this to chat.openai.com and type the reply...
>>> take keyboard
You won in 5 steps! Don’t forget to congratulate ChatGPT!
如你所见,LLM 能够完美地解决任务。更令人惊叹的是,整体任务更为复杂——我们要求它生成命令,而不是像本章前面那样从“可接受命令”列表中做出决策。
ChatGPT API
由于复制粘贴既乏味又无聊,让我们通过 ChatGPT API 来自动化我们的代理。我们将使用 langchain 库(python.langchain.com/),它提供了足够的灵活性和控制力,可以充分利用 LLM 功能。
完整的代码示例在 Chapter13/chatgpt_auto.py 中。这里,我将介绍核心函数 play_game():
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
def play_game(env, max_steps: int = 20) -> bool:
prompt_init = ChatPromptTemplate.from_messages([
("system", "You’re playing the interactive fiction game. "
"Reply with just a command in lowercase and nothing else"),
("system", "Game objective: {objective}"),
("user", "Room description: {description}"),
("user", "What command you want to execute next?"),
])
llm = ChatOpenAI()
output_parser = StrOutputParser()
我们的初始提示与之前相同——我们指示聊天机器人游戏的类型,并要求它仅回复将要输入游戏的命令。
然后我们重置环境并生成第一条消息,传递 TextWorld 的信息:
commands = []
obs, info = env.reset()
init_msg = prompt_init.invoke({
"objective": info[’objective’],
"description": info[’description’],
})
context = init_msg.to_messages()
ai_msg = llm.invoke(init_msg)
context.append(ai_msg)
cmd = output_parser.invoke(ai_msg)
变量上下文非常重要,它包含了我们到目前为止所有消息的列表(包括来自人类和聊天机器人的消息)。我们将把这些消息传递给聊天机器人,以保留游戏的过程。这是必要的,因为游戏目标只展示一次,不会重复。如果没有历史记录,代理没有足够的信息来执行所需的步骤序列。另一方面,传递大量文本给聊天机器人可能导致高成本(因为 ChatGPT API 按处理的 token 计费)。我们的游戏不长(5 到 7 步就能完成任务),所以这不是一个主要问题,但对于更复杂的游戏,历史记录可能需要优化。
然后是游戏循环,和我们在交互式版本中看到的非常相似,但没有控制台通信:
prompt_next = ChatPromptTemplate.from_messages([
MessagesPlaceholder(variable_name="chat_history"),
("user", "Last command result: {result}"),
("user", "Room description: {description}"),
("user", "What command you want to execute next?"),
])
for _ in range(max_steps):
commands.append(cmd)
print(">>>", cmd)
obs, r, is_done, info = env.step(cmd)
if is_done:
print(f"I won in {len(commands)} steps!")
return True
user_msgs = prompt_next.invoke({
"chat_history": context,
"result": obs.strip(),
"description": info[’description’],
})
context = user_msgs.to_messages()
ai_msg = llm.invoke(user_msgs)
context.append(ai_msg)
cmd = output_parser.invoke(ai_msg)
在后续提示中,我们传递了对话历史、上一个命令的结果、当前房间的描述,并请求下一条命令。
我们还限制了步骤数量,以防止代理陷入循环(有时会发生)。如果在 20 步之后游戏未完成,我们会退出循环:
print(f"Wasn’t able to solve after {max_steps} steps, commands: {commands}")
return False
我曾在 20 个 TextWorld 游戏(种子 1 到 20)上实验过前面的代码,它成功解决了 20 个游戏中的 9 个。大多数失败的情况是由于代理进入了循环——发出了错误的命令,TextWorld 没有正确解析(例如“拿起钥匙”而不是“从箱子里拿起钥匙”),或者在导航上卡住了。
在两款游戏中,ChatGPT 失败是因为生成了“exit”命令,这会使 TextWorld 立即停止。很可能,检测到这个命令或禁止在提示中生成它,可能会增加解决游戏的数量。但即使如此,代理在没有任何预训练的情况下解决了 9 款游戏,依然是相当令人印象深刻的结果。从 ChatGPT 的成本来看,运行实验的处理费用为 450K 个 token,花费了我$0.20。对于这样有趣的体验,价格并不高!
总结
在本章中,你已经看到 DQN 如何应用于互动小说游戏,这是一个有趣且具有挑战性的领域,位于强化学习(RL)和自然语言处理(NLP)的交叉点。你学习了如何利用 NLP 工具处理复杂的文本数据,并在有趣且充满挑战的互动小说环境中进行实验,未来有许多实际实验的机会。此外,我们还使用了 Hugging Face 库中的 transformer 模型,并与 ChatGPT 进行了实验。
在下一章中,我们将继续探索“野外的 RL”,并检查 RL 方法在网页自动化中的适用性。
第十四章:网络导航
我们将现在看看强化学习(RL)的其他一些实际应用:网页导航和浏览器自动化。这是一个非常实用的例子,展示了 RL 方法如何应用于实际问题,包括你可能遇到的复杂问题以及如何应对它们。
在这一章中,我们将:
-
一般讨论网页导航和浏览器自动化的实际应用
-
探索如何通过强化学习方法解决网页导航问题
-
让我们深入了解一个非常有趣,但常常被忽视且有些被遗弃的强化学习(RL)基准测试,它是由 OpenAI 实现的,叫做 Mini World of Bits(MiniWoB)。
网页导航的演变
当网络被发明时,它起初是一些仅包含文本的网页,通过超链接互相连接。如果你感兴趣,这里是第一篇网页的主页,info.cern.ch,其中包含文本和链接。你能做的唯一事情就是阅读文本并点击链接在页面之间导航。
几年后,1995 年,互联网工程任务组(IETF)发布了 HTML 2.0 规范,这个版本对 Tim Berners-Lee 发明的原始版本进行了大量扩展。其中一些扩展包括表单和表单元素,允许网页作者为他们的网站添加互动功能。用户可以输入和更改文本、切换复选框、选择下拉列表以及点击按钮。这些控件的集合类似于简约的图形用户界面(GUI)应用程序控件。不同之处在于,这一切发生在浏览器窗口内部,用户交互的数据和 UI 控件都是由服务器页面定义的,而不是由本地安装的应用程序定义的。
快进 29 年,现在我们有了 JavaScript、HTML5 画布以及在浏览器中运行的 Microsoft Office 应用程序。桌面和网页之间的边界已经变得如此微薄和模糊,以至于你可能无法分辨你正在使用的应用程序是 HTML 页面还是本地应用程序。然而,仍然是浏览器理解 HTML,并使用 HTTP 与外界通信。
从本质上讲,网页导航被定义为用户与一个或多个网站进行互动的过程。用户可以点击链接、输入文本,或者进行其他任何操作来实现他们的目标,例如发送电子邮件、查找法国大革命的确切日期,或查看最近的 Facebook 通知。所有这些都将通过网页导航来完成,那么问题就来了:我们的程序能学会如何做同样的事情吗?
浏览器自动化与强化学习(RL)
长期以来,自动化网站交互主要集中在网站测试和网页抓取等非常实际的任务上。网站测试在你(或其他人)开发并希望确保其按预期运行的复杂网站上尤为重要。例如,如果你有一个已经重新设计并准备在实际网站上部署的登录页面,那么你希望确保当输入错误密码时,或者用户点击“我忘记密码”等情况时,新设计能够正确处理。一个复杂的网站可能包含数百或数千个需要在每次发布时测试的用例,因此所有这些功能应该实现自动化。
网页抓取解决了大规模从网站提取数据的问题。例如,如果你想建立一个系统,聚合你所在城市所有比萨店的价格,你可能需要处理数百个不同的网站,这样构建和维护起来可能会很有问题。网页抓取工具尝试解决与网站交互的问题,提供从简单的 HTTP 请求和随后的 HTML 解析到完全模拟用户移动鼠标、点击按钮、用户反应延迟等的各种功能。
浏览器自动化的标准方法通常允许你通过程序控制真实的浏览器,如 Chrome 或 Firefox,它能够观察网页数据,如文档对象模型(DOM)树和对象在屏幕上的位置,并执行相应的操作,如移动鼠标、按下某些键、点击返回按钮,或执行一些 JavaScript 代码。与强化学习(RL)问题设置的联系显而易见:我们的代理通过发出操作和观察状态与网页和浏览器互动。奖励并不是那么明显,应该是任务特定的,比如成功填写表单或到达包含所需信息的页面。能够学习浏览器任务的系统的实际应用与之前的用例相关,包括以下内容:
-
在非常大的网站进行网页测试时,使用低级浏览器操作来定义测试过程非常繁琐,比如“将鼠标移动到左边五个像素,再按左键”。你想做的是给系统提供一些示范,让它能够概括并在所有类似的情况下重复所展示的操作,或者至少让它在用户界面重新设计、按钮文本更改等情况下足够健壮。
-
有很多情况下,你无法提前知道问题是什么,例如,当你希望系统探索网站的薄弱环节,如安全漏洞时。在这种情况下,RL(强化学习)代理可以非常快速地尝试大量奇怪的操作,比人类能做到的要快得多。当然,安全测试的操作空间非常庞大,所以随机点击不会像经验丰富的人类测试者那样有效。在这种情况下,基于 RL 的系统可以潜在地结合人类的先验知识和经验,同时保持探索的能力,并从这种探索中学习。
-
另一个可能受益于 RL 浏览器自动化的领域是网页抓取和数据提取。举个例子,你可能需要从成千上万的不同网站上提取数据,比如酒店网站、租车代理商或全球各地的其他企业。通常,在你获取到所需数据之前,需要填写带有参数的表单,考虑到不同网站的设计、布局和自然语言的灵活性,这成为了一个非常复杂的任务。在这样的任务中,RL 代理可以通过可靠地大规模提取数据,节省大量的时间和精力。
浏览器自动化中的挑战
使用 RL 进行浏览器自动化的潜在实际应用非常吸引人,但有一个非常严重的缺点:它们的规模太大,无法用于研究和方法比较。事实上,实施一个完整的网页抓取系统可能需要团队几个月的努力,而且大多数问题与 RL 本身并不直接相关,比如数据收集、浏览器引擎通信、输入和输出表示,以及许多其他涉及实际生产系统开发的问题。
通过解决所有这些问题,我们可能会陷入“只见树木,不见森林”的困境。这也是为什么研究人员喜欢使用基准数据集,如 MNIST、ImageNet 和 Atari 套件的原因。然而,并不是每个问题都适合作为基准。一方面,基准应该足够简单,以便快速实验并进行方法比较;另一方面,基准必须具有挑战性,并留有改进的空间。例如,Atari 基准包括各种各样的游戏,从可以在半小时内解决的非常简单的游戏(比如 Pong),到直到最近才得以完全解决的相对复杂的游戏(比如《蒙特祖玛的复仇》,需要复杂的行动规划)。根据我所知,浏览器自动化领域只有一个这样的基准,这使得这个基准被 RL 社区遗忘的情况变得更加糟糕。为了尝试解决这个问题,我们将在本章中看看这个基准。首先让我们了解它的历史。
MiniWoB 基准
2016 年 12 月,OpenAI 发布了一个名为 MiniWoB 的数据集,其中包含 80 个基于浏览器的任务。这些任务在像素级别上进行观察(严格来说,除了像素,任务的文本描述也会提供给代理),并且应该通过鼠标和键盘操作与虚拟网络计算(VNC)客户端进行交互。VNC 是一种标准的远程桌面协议,VNC 服务器允许客户端通过网络使用鼠标和键盘与服务器的图形用户界面(GUI)应用程序进行交互。
80 个任务在复杂性和所需的代理动作方面差异很大。有些任务即使对于强化学习(RL)来说也非常简单,例如“点击对话框的关闭按钮”或“按下单个按钮”,但有些任务需要多个步骤,例如“展开折叠的组并点击带有某些文本的链接”或“使用日期选择工具选择特定日期”(且该日期在每次执行时随机生成)。有些任务对于人类来说很简单,但需要字符识别,例如“标记带有此文本的复选框”(文本是随机生成的)。以下是一些 MiniWoB 问题的截图:

图 14.1:MiniWoB 环境
不幸的是,尽管 MiniWoB 拥有出色的创意和挑战性,但在最初发布后,几乎被 OpenAI 放弃了。几年后,一组斯坦福大学的研究人员发布了一个更新版,名为 MiniWoB++,它增加了更多的游戏并重构了架构。
MiniWoB++
MiniWoB++ 不再使用 VNC 协议和真实的网页浏览器,而是使用 Selenium(www.selenium.dev)库来实现网页浏览器自动化,这大大提高了环境的性能和稳定性。
目前,MiniWoB++ 正在由 Farama Foundation 进行维护(miniwob.farama.org/),这对 RL 社区来说是一个好消息。在本章中,我们将使用他们的最新版本,但在进入代理的 RL 部分之前,我们需要了解 MiniWoB++ 的工作原理。
安装
原始的 MiniWoB 使用了 VNC 和 OpenAI Universe,这在安装和使用过程中带来了许多复杂性。本书前一版提供了一个定制的 Docker 镜像,并附带详细的安装说明。现在,安装过程简单多了:你不再需要处理 Docker 和 VNC。Selenium 库(它是浏览器自动化的事实标准)隐藏了与浏览器通信的所有复杂性,浏览器在后台以无头模式启动。Selenium 支持多种浏览器,但 MiniWoB++ 开发者推荐使用 Chrome 或 Chromium,因为其他浏览器可能会以不同的方式渲染环境。
除了 MiniWoB++包(可以通过pip install miniwob==1.0进行安装)外,您还需要在机器上安装 chromedriver。ChromeDriver 是一个小型二进制文件,它与浏览器进行通信,并以“测试模式”运行浏览器。ChromeDriver 的版本必须与已安装的 Chrome 版本匹配(可以通过访问 Chrome → 关于 Google Chrome 来检查)。因此,请从以下网站下载适用于您平台和 Chrome 版本的 chromedriver 压缩包:googlechromelabs.github.io/chrome-for-testing/。
注意:除了 ChromeDriver 压缩包外,它们还提供了完整版本的 Chrome 压缩包,您很可能不需要它。例如,针对 Mac M2 硬件的 Chrome v123 的 chromedriver 可以通过此 URL 下载:storage.googleapis.com/chrome-for-testing-public/123.0.6312.122/mac-arm64/chromedriver-mac-arm64.zip。该压缩包中包含一个单独的 chromedriver 二进制文件,您需要将其放到 shell 的某个路径中(在 Mac 和 Linux 机器上,您可以使用which chromedriver命令,它会返回二进制文件的完整路径。如果没有返回,您需要修改 PATH)。要测试您的安装,可以使用一个简单的程序,Chapter14/adhoc/01_wob_create.py。如果一切正常,浏览器窗口会出现任务并显示 2 秒钟。
动作与观察
与我们迄今为止处理过的 Atari 游戏和其他 Gym 环境相比,MiniWoB 展示了一个更加通用的动作空间。Atari 游戏使用六到七个离散动作,对应于控制器的按钮和摇杆方向。CartPole 的动作空间甚至更小,仅有两个动作。然而,浏览器给我们的智能体提供了更多的灵活性,允许它做更多的事情。首先,完整的键盘,包括控制键和每个键的上下状态,都被暴露。因此,从 MiniWoB 的角度来看,您的智能体可以选择同时按下 10 个按钮,这完全没问题。动作空间的第二部分是鼠标:您可以将鼠标移动到任意坐标,并控制其按钮的状态。这显著增加了智能体需要学习如何处理的动作空间维度。此外,鼠标还允许双击和鼠标滚轮上下滚动事件。
在观察空间方面,MiniWoB 也比我们迄今为止处理过的环境更为丰富。完整的观察数据以字典形式表示,包含以下数据:
-
包含任务描述的文本,例如点击按钮 ONE,或者你在井字棋中扮演 X,赢得比赛
-
屏幕的像素值,以 RGB 格式表示
-
包含所有 DOM 元素的列表,来自底层网页,并带有属性(尺寸、颜色、字体等)
除此之外,你还可以访问底层浏览器,获取更多的信息(比如获取一些未直接提供的信息,如 CSS 属性或原始 HTML 数据)。
如你所见,这组任务为实验提供了很大的灵活性:你可以专注于任务的视觉部分,工作在像素级别;你可以使用 DOM 信息(环境允许你点击特定元素);或者使用 NLP 组件——理解任务描述并规划行动。
简单示例
为了获得一些关于 MiniWoB 的实际经验,让我们来看一下你用来验证安装的程序,你可以在 Chapter14/adhoc/01_wob_create.py 中找到它。
首先,我们需要在 Gymnasium 中注册 MiniWoB 环境,这是通过 register_envs()函数完成的:
import time
import gymnasium as gym
import miniwob
from miniwob.action import ActionTypes
RENDER_ENV = True
if __name__ == "__main__":
gym.register_envs(miniwob)
事实上,register_envs()函数什么也不做,因为所有环境都在模块导入时注册。但是现代的 IDE 足够智能,会开始抱怨未使用的模块,因此这个方法给 IDE 留下了模块在代码中被使用的印象。
然后我们使用标准的 gym.make()方法创建一个环境:
env = gym.make(’miniwob/click-test-2-v1’, render_mode=’human’ if RENDER_ENV else None)
print(env)
try:
obs, info = env.reset()
print("Obs keys:", list(obs.keys()))
print("Info dict:", info)
assert obs["utterance"] == "Click button ONE."
assert obs["fields"] == (("target", "ONE"),)
print("Screenshot shape:", obs[’screenshot’].shape)
在我们的示例中,我们使用的是 click-test-2 问题,它要求你随机点击网页上放置的两个按钮中的一个。Farama 网站提供了一个非常方便的环境列表,你可以自己动手实验。click-test-2 问题可以在这里找到:miniwob.farama.org/environments/click-test-2/。
在创建环境时,我们传入了 render_mode 参数。如果它等于’human’,则浏览器窗口将在后台显示。在图 14.2 中,你可以看到窗口:

图 14.2:click-test-2 环境
如果我们运行程序,它会显示环境对象以及关于观察的信息(这是一个相当大的字典,所以我只输出它的键的列表)。下面是代码显示的部分:
$ python adhoc/01_wob_create.py
<OrderEnforcing<PassiveEnvChecker<ClickTest2Env<miniwob/click-test-2-v1>>>>
Obs keys: [’utterance’, ’dom_elements’, ’screenshot’, ’fields’]
Info dict: {’done’: False, ’env_reward’: 0, ’raw_reward’: 0, ’reason’: None, ’root_dom’: [1] body @ (0, 0) classes=[] children=1}
Screenshot shape: (210, 160, 3)
如你所见,我们有话语(这是需要执行的任务)、DOM 元素、与 Atari 平台完全相同尺寸的截图(我不认为这只是巧合!)以及字段列表,字段是 DOM 树中任务特定的重要元素。
现在,让我们回到我们的代码。以下代码片段在 dom_elements 列表中查找我们需要点击的元素以执行任务:
if RENDER_ENV:
time.sleep(2)
target_elems = [e for e in obs[’dom_elements’] if e[’text’] == "ONE"]
assert target_elems
print("Target elem:", target_elems[0])
代码正在遍历 dom_elements 观察对象的字段,筛选出包含文本 ONE 的元素。找到的元素具有相当丰富的属性集:
Target elem: {’ref’: 4, ’parent’: 3, ’left’: array([80.], dtype=float32), ’top’: array([134.], dtype=float32), ’width’: array([40.], dtype=float32), ’height’: array([40.], dtype=float32), ’tag’: ’button’, ’text’: ’ONE’, ’value’: ’’, ’id’: ’subbtn’, ’classes’: ’’, ’bg_color’: array([0.9372549, 0.9372549, 0.9372549, 1\. ], dtype=float32), ’fg_color’: array([0., 0., 0., 1.], dtype=float32), ’flags’: array([0, 0, 0, 1], dtype=int8)}
现在,让我们来看代码的最后一部分,在这里我们获取元素的引用(它是一个整数标识符),并创建 CLICK_ELEMENT 动作:
action = env.unwrapped.create_action(
ActionTypes.CLICK_ELEMENT, ref=target_elems[0]["ref"])
obs, reward, terminated, truncated, info = env.step(action)
print(reward, terminated, info)
finally:
env.close()
正如我们之前提到的,MiniWoB 提供了一组丰富的操作可以执行。这个特别的操作模拟了在特定 DOM 元素上进行鼠标点击。
作为此操作的结果,我们应该获得一个奖励,实际上确实会发生:
0.7936 True {’done’: True, ’env_reward’: 0.7936, ’raw_reward’: 1, ’reason’: None, ’elapsed’: 2.066638231277466}
如果你通过设置 RENDER_ENV = False 禁用渲染,控制台和浏览器中发生的所有事情将不会显示。此模式还会导致更高的奖励,因为奖励会随时间减少。我机器上的完全无头模式在 0.09 秒内获得了 0.9918 的奖励。
简单的点击方法
为了开始网页导航,让我们实现一个简单的 A3C 代理,根据图像观察决定应该点击哪里。这种方法只能解决 MiniWoB 套件的一个小子集,稍后我们将讨论这种方法的局限性。现在,它将帮助我们更好地理解问题。
与上一章一样,我在这里不会讨论完整的源代码。相反,我们将专注于最重要的功能,并简要概述其余部分。完整的源代码可以在 GitHub 仓库中找到。
网格操作
当我们讨论 MiniWoB 的架构和组织时,我们提到过,动作空间的丰富性和灵活性为强化学习代理带来了许多挑战。浏览器中的活动区域只有 210 × 160 像素,但即使是这么小的区域,我们的代理也可能需要执行移动鼠标、点击、拖动对象等操作。仅仅是鼠标就可能成为一个难题,因为在极端情况下,代理可能需要执行几乎无限多种不同的动作,例如在某个位置按下鼠标按钮并拖动鼠标到另一个位置。在我们的示例中,我们通过只考虑在活动网页区域内某些固定网格点的点击来大大简化问题。我们动作空间的示意图如下所示:

图 14.3:网格动作空间
在 MiniWob 的原始版本中,这种操作的封装器已经存在于 OpenAI Universe 中。但由于 MiniWoB++ 中无法使用它,我在 lib/wob.py 模块中自行实现了它。我们来快速查看代码,从构造函数开始:
WIDTH = 160
HEIGHT = 210
X_OFS = 0
Y_OFS = 50
BIN_SIZE = 10
WOB_SHAPE = (3, HEIGHT, WIDTH)
class MiniWoBClickWrapper(gym.ObservationWrapper):
FULL_OBS_KEY = "full_obs"
def __init__(self, env: gym.Env, keep_text: bool = False,
keep_obs: bool = False, bin_size: int = BIN_SIZE):
super(MiniWoBClickWrapper, self).__init__(env)
self.bin_size = bin_size
self.keep_text = keep_text
self.keep_obs = keep_obs
img_space = spaces.Box(low=0, high=255, shape=WOB_SHAPE, dtype=np.uint8)
if keep_text:
self.observation_space = spaces.Tuple(
(img_space, spaces.Text(max_length=1024)))
else:
self.observation_space = img_space
self.x_bins = WIDTH // bin_size
count = self.x_bins * ((HEIGHT - Y_OFS) // bin_size)
self.action_space = spaces.Discrete(count)
在构造函数中,我们创建了观察空间(它是一个 3 × 210 × 160 的张量)和动作空间,动作空间将是 256 个离散动作,针对 10\ 的 bin 大小。作为一个选项,我们可以要求封装器保存要执行的任务的文本。此功能将在本章的后续示例中使用。
然后我们提供一个类方法,用于创建具有特定配置的环境:
@classmethod
def create(cls, env_name: str, bin_size: int = BIN_SIZE, keep_text: bool = False,
keep_obs: bool = False, **kwargs) -> "MiniWoBClickWrapper":
gym.register_envs(miniwob)
x_bins = WIDTH // bin_size
y_bins = (HEIGHT - Y_OFS) // bin_size
act_cfg = ActionSpaceConfig(
action_types=(ActionTypes.CLICK_COORDS, ), coord_bins=(x_bins, y_bins))
env = gym.make(env_name, action_space_config=act_cfg, **kwargs)
return MiniWoBClickWrapper(
env, keep_text=keep_text, keep_obs=keep_obs, bin_size=bin_size)
除了创建环境并进行封装外,我们还请求了一个自定义的 ActionSpaceConfig,它会考虑到我们网格的尺寸。通过这种定制,我们需要传递网格单元的 (x,y) 坐标来执行点击操作。接着,我们定义了一个辅助方法,它将完整的观察字典转换为我们所需的格式。reset() 方法只是调用了这个方法:
def _observation(self, observation: dict) -> np.ndarray | tt.Tuple[np.ndarray, str]:
text = observation[’utterance’]
scr = observation[’screenshot’]
scr = np.transpose(scr, (2, 0, 1))
if self.keep_text:
return scr, text
return scr
def reset(self, *, seed: int | None = None, options: dict[str, tt.Any] | None = None) \
-> tuple[gym.core.WrapperObsType, dict[str, tt.Any]]:
obs, info = self.env.reset(seed=seed, options=options)
if self.keep_obs:
info[self.FULL_OBS_KEY] = obs
return self._observation(obs), info
现在,封装器的最后一部分,step() 方法:
def step(self, action: int) -> tt.Tuple[
gym.core.WrapperObsType, gym.core.SupportsFloat, bool, bool, dict[str, tt.Any]
]:
b_x, b_y = action_to_bins(action, self.bin_size)
new_act = {
"action_type": 0,
"coords": np.array((b_x, b_y), dtype=np.int8),
}
obs, reward, is_done, is_tr, info = self.env.step(new_act)
if self.keep_obs:
info[self.FULL_OBS_KEY] = obs
return self._observation(obs), reward, is_done, is_tr, info
def action_to_bins(action: int, bin_size: int = BIN_SIZE) -> tt.Tuple[int, int]:
row_bins = WIDTH // bin_size
b_y = action // row_bins
b_x = action % row_bins
return b_x, b_y
为了执行动作,我们需要将网格单元的索引(在 0...255 范围内)转换为该单元格的 (x, y) 坐标。然后,作为底层 MiniWoB 环境的动作,我们传递一个包含 action_type=0(这是我们在环境创建中使用的 ActionSpaceConfig 中的索引)和包含这些单元格坐标的 NumPy 数组的字典。
为了说明包装器,GitHub 仓库中的 adhoc/03_clicker.py 文件有一个小程序,它使用暴力破解方法在 click-dialog-v1 任务中。目标是使用带有叉号的角落按钮关闭随机放置的对话框。在这个示例中(我们不在这里展示代码),我们依次点击所有 256 个网格单元,以说明包装器。
我们实现的 RL 部分
随着观察和动作的转换,RL 部分相当简单。我们将使用 A3C 方法来训练代理,代理需要从 160 × 210 的观察中决定点击哪个网格单元。除了策略,它是 256 个网格单元的概率分布,我们的代理还会估算状态的价值,这将在策略梯度估算中作为基准。
这个示例中有几个模块:
-
lib/common.py:本章中各个示例共享的方法,包括已熟悉的 RewardTracker 和 unpack_batch 函数
-
lib/model.py:包含模型的定义,我们将在下一节中查看
-
lib/wob.py:包含 MiniWoB 特定的代码,如环境包装器和其他实用函数
-
wob_click_train.py:用于训练点击器模型的脚本
-
wob_click_play.py:加载模型权重并在单一环境中使用它们,记录观察并统计奖励的脚本。
这些模块中的代码没有新内容,因此这里没有展示。你可以在 GitHub 仓库中找到它。
模型和训练代码
这个模型非常简单,使用了你在其他 A3C 示例中看到的相同模式。我没有花太多时间优化和微调架构和超参数,所以最终结果可能会有显著改进(你可以根据你在本书中学到的内容自己尝试进行改进)。以下是具有两个卷积层、单层策略和价值头的模型定义:
class Model(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...], n_actions: int):
super(Model, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 64, 5, stride=5),
nn.ReLU(),
nn.Conv2d(64, 64, 3, stride=2),
nn.ReLU(),
nn.Flatten(),
)
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.policy = nn.Linear(size, n_actions)
self.value = nn.Linear(size, 1)
def forward(self, x: torch.ByteTensor) -> tt.Tuple[torch.Tensor, torch.Tensor]:
xx = x / 255.0
conv_out = self.conv(xx)
return self.policy(conv_out), self.value(conv_out)
你将在 wob_click_train.py 中找到训练脚本,它与第十二章中的完全相同。我们使用 AsyncVectorEnv 和 8 个并行环境,它会在后台启动 8 个 Chrome 实例。如果你的机器内存允许,你可以增加这个数量,并检查它对训练的影响。
训练结果
默认情况下,训练使用 click-dialog-v1 问题,训练大约花费了 8 分钟,达到了平均奖励 0.9。图 14.4 显示了平均奖励和步骤数的图表:

图 14.4:训练奖励(左)和每集步数计数(右)
右侧的 episode_steps 图表显示了智能体在集结束前应该执行的平均动作次数。理想情况下,对于这个问题,次数应该是 1,因为智能体唯一需要执行的动作是点击对话框的关闭按钮。然而,实际上,智能体在集结束之前会看到七到九帧图像。这发生的原因有两个:对话框关闭按钮上的叉号可能会有延迟出现,并且容器内的浏览器会在智能体点击和奖励获得之间添加一个时间间隙。
要检查已学习的策略,你可以使用 wob_click_play.py 工具,它加载模型并在一个环境中使用它。它可以播放多个回合以测试模型的平均性能:
$ ./wob_click_play.py -m saves/best_0.923_45400.dat --verbose
0 0.0 False {’done’: False, ’env_reward’: 0, ’raw_reward’: 0, ’reason’: None, ’elapsed’: 0.1620042324066162, ’root_dom’: [1] body @ (0, 0) classes=[] children=2}
1 0.9788 True {’done’: True, ’env_reward’: 0.9788, ’raw_reward’: 1, ’reason’: None, ’elapsed’: 0.19491100311279297}
Round 0 done
Done 1 rounds, mean steps 2.00, mean reward 0.979
如果以--render 命令行选项开始,浏览器窗口将在智能体的操作过程中显示。
简单点击限制
不幸的是,所展示的方法只能用来解决相对简单的问题,比如点击对话框。如果你尝试将它用于更复杂的任务,收敛性是很难实现的。原因有很多。
首先,我们的智能体是无状态的,这意味着它仅根据观察结果来决定行动,而不考虑之前的行为。你可能还记得在第一章中,我们讨论了马尔可夫决策过程(MDP)的马尔可夫性质,这个性质使我们能够丢弃所有之前的历史,只保留当前的观察结果。即使在 MiniWoB 中相对简单的问题中,这一马尔可夫性质也可能被违反。例如,有一个问题叫做点击按钮序列(截图见图 14.5,该环境的文档可以在miniwob.farama.org/environments/click-button-sequence/找到),要求我们的智能体先点击按钮 ONE,再点击按钮 TWO。即使我们的智能体幸运地按要求的顺序随机点击按钮,它也无法从单一图像中分辨出下一个需要点击的按钮。

图 14.5:一个无状态智能体可能难以解决的环境示例
尽管这个问题很简单,我们仍然不能使用我们的强化学习方法来解决它,因为 MDP 的形式化不再适用。这样的問題被称为部分可观测的马尔可夫决策过程,或 POMDP(我们在第六章中简要讨论了这些),通常的处理方法是允许智能体保持某种状态。这里的挑战是找到平衡点,即在保持最小相关信息的同时,避免通过将所有内容都加入观察结果而让智能体被不相关信息淹没。
我们在这个例子中可能面临的另一个问题是,解决问题所需的数据可能不在图像中,或者可能以不方便的形式出现。例如,两个问题,点击选项卡 (miniwob.farama.org/environments/click-tab/) 和点击复选框 (miniwob.farama.org/environments/click-checkboxes/),如图 14.6 所示:

图 14.6:文本描述在某些环境中的重要性示例
在第一个例子中,你需要点击三个选项卡中的一个,但每次需要点击的选项卡是随机选择的。需要点击哪个选项卡会在描述中显示(通过一个文本字段提供观察,并显示在环境页面的顶部),但我们的代理只能看到像素,这使得将顶部的数字与随机点击结果的输出联系起来变得复杂。对于点击复选框的问题,情况更为糟糕,因为需要点击几个带有随机生成文本的复选框。防止过拟合问题的一个可能方法是使用某种光学字符识别(OCR)网络,将观察中的图像转换为文本形式。另一种方法(将在下一节中展示)是将文本描述融入到代理的观察中。
另一个问题可能与代理需要探索的动作空间的维度有关。即使是单次点击问题,动作的数量也可能非常庞大,因此代理可能需要很长时间才能发现如何执行操作。这里的一个可能解决方案是将示范引入训练中。例如,在图 14.7 中,有一个名为 count-sides (miniwob.farama.org/environments/count-sides/) 的问题。目标是点击对应于形状边数的按钮:

图 14.7:count-sides 环境示例
通过将人工示范加入训练,解决了这个问题。在我的实验中,从零开始训练经过一天的训练后没有任何进展。然而,在加入几十个正确点击的示范之后,代理在 15 分钟的训练时间内成功解决了这个问题。当然,我们可以花时间进一步微调超参数,但示范的效果确实令人印象深刻。本章后面我们将研究如何记录并注入人工示范以改善收敛性。
添加文本描述
为了改进我们的点击器代理,第一步是将问题的文本描述添加到模型中。我已经提到过,一些问题包含在文本描述中提供的关键信息,比如需要点击的标签索引或代理需要检查的条目列表。这些信息也显示在图像观察的顶部,但像素并不总是简单文本的最佳表示。
为了考虑到这一点,我们需要将模型的输入从仅图片扩展到图片和文本数据。在上一章中我们已经处理了文本,因此递归神经网络(RNN)是一个显而易见的选择(也许对于这样的玩具问题来说不是最佳选择,但它具有灵活性和可扩展性)。
实现
在本节中,我们将只关注实现的最重要部分。你可以在 Chapter16/wob_click_mm_train.py 模块中找到完整的代码。与我们的点击器模型相比,文本扩展并没有增加太多内容。
首先,我们应该要求 MiniWoBClickWrapper 保留从观察中获得的文本。本章前面已经展示了这个类的完整源代码,在 Grid actions 部分。为了保留文本,我们应该将 keep_text=True 传递给包装器构造函数,这样该类将返回一个包含 NumPy 数组和文本字符串的元组,而不仅仅是包含图像的 NumPy 数组。然后,我们需要准备我们的模型,以便能够处理这样的元组,而不是一批 NumPy 数组。这需要在两个地方完成:在我们的代理中(当我们使用模型来选择动作时)以及在训练代码中。为了以适合模型的方式适配观察,我们可以使用 PTAN 库的特殊功能,称为预处理器。其核心思想非常简单:预处理器是一个可调用的函数,需要将观察列表转换为可以传递给模型的形式。默认情况下,预处理器将 NumPy 数组列表转换为 PyTorch 张量,并可以选择将其复制到 GPU 内存中。然而,有时需要更复杂的转换,例如在我们的例子中,当我们需要将图像打包到张量中时,但文本字符串需要特殊处理。在这种情况下,你可以重新定义默认的预处理器并将其传递给 ptan.Agent 类。
理论上,预处理器功能可以移到模型本身中,得益于 PyTorch 的灵活性,但默认的预处理器简化了我们的工作,特别是在观察仅仅是 NumPy 数组的情况下。以下是来自 lib/model.py 模块的预处理器类的源代码:
MM_EMBEDDINGS_DIM = 50
MM_HIDDEN_SIZE = 128
MM_MAX_DICT_SIZE = 100
TOKEN_UNK = "#unk"
class MultimodalPreprocessor:
log = logging.getLogger("MulitmodalPreprocessor")
def __init__(self, max_dict_size: int = MM_MAX_DICT_SIZE,
device: torch.device = torch.device(’cpu’)):
self.max_dict_size = max_dict_size
self.token_to_id = {TOKEN_UNK: 0}
self.next_id = 1
self.tokenizer = TweetTokenizer(preserve_case=True)
self.device = device
def __len__(self):
return len(self.token_to_id)
在前面代码的构造函数中,我们创建了一个从令牌到标识符的映射(该映射将动态扩展),并从 nltk 包中创建了分词器。
接下来,我们有了__call__()方法,它将转换批次:
def __call__(self, batch: tt.Tuple[tt.Any, ...] | tt.List[tt.Tuple[tt.Any, ...]]):
tokens_batch = []
if isinstance(batch, tuple):
batch_iter = zip(*batch)
else:
batch_iter = batch
for img_obs, txt_obs in batch_iter:
tokens = self.tokenizer.tokenize(txt_obs)
idx_obs = self.tokens_to_idx(tokens)
tokens_batch.append((img_obs, idx_obs))
tokens_batch.sort(key=lambda p: len(p[1]), reverse=True)
img_batch, seq_batch = zip(*tokens_batch)
lens = list(map(len, seq_batch))
我们的预处理器的目标是将一批(图像,文本)元组转换为两个对象:第一个是形状为(batch_size, 3, 210, 160)的图像数据张量,第二个包含来自文本描述的令牌批次,形式为打包序列。打包序列是 PyTorch 数据结构,适用于高效的 RNN 处理。我们在第十三章中讨论过这个问题。
事实上,批次可以有两种不同的形式:它可以是一个包含图像批次和文本批次的元组,或者它可以是一个包含单个(图像,文本令牌)样本的元组列表。这是因为 VectorEnv 对 gym.Tuple 观测空间的处理方式不同。但这些细节在这里并不太重要;我们只是通过检查批次变量的类型并进行必要的处理来处理这种差异。
作为转换的第一步,我们对文本字符串进行标记化,并将每个令牌转换为整数 ID 列表。然后,我们按令牌长度降序对批次进行排序,这是底层 cuDNN 库对高效 RNN 处理的要求。
然后,我们将图像转换为张量,将序列转换为填充序列,这是一个批次大小 × 最长序列长度的矩阵。我们在前一章中见过这个:
img_v = torch.FloatTensor(np.asarray(img_batch)).to(self.device)
seq_arr = np.zeros(
shape=(len(seq_batch), max(len(seq_batch[0]), 1)), dtype=np.int64)
for idx, seq in enumerate(seq_batch):
seq_arr[idx, :len(seq)] = seq
if len(seq) == 0:
lens[idx] = 1
seq_v = torch.LongTensor(seq_arr).to(self.device)
seq_p = rnn_utils.pack_padded_sequence(seq_v, lens, batch_first=True)
return img_v, seq_p
以下的 tokens_to_idx() 函数将令牌列表转换为 ID 列表:
def tokens_to_idx(self, tokens):
res = []
for token in tokens:
idx = self.token_to_id.get(token)
if idx is None:
if self.next_id == self.max_dict_size:
self.log.warning("Maximum size of dict reached, token "
"’%s’ converted to #UNK token", token)
idx = 0
else:
idx = self.next_id
self.next_id += 1
self.token_to_id[token] = idx
res.append(idx)
return res
问题在于,我们无法预先知道从文本描述中得到的词典大小。一种方法是按字符级别进行处理,将每个字符输入到 RNN 中,但这将导致序列过长,难以处理。另一种解决方案是硬编码一个合理的词典大小,比如 100 个令牌,并为我们从未见过的令牌动态分配令牌 ID。在这个实现中,采用了后者的方法,但它可能不适用于包含随机生成字符串的 MiniWoB 问题文本描述。对此问题的潜在解决方案是,使用字符级别的标记化或使用预定义的词典。
现在,让我们来看一下我们的模型类:
class ModelMultimodal(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...], n_actions: int,
max_dict_size: int = MM_MAX_DICT_SIZE):
super(ModelMultimodal, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 64, 5, stride=5),
nn.ReLU(),
nn.Conv2d(64, 64, 3, stride=2),
nn.ReLU(),
nn.Flatten(),
)
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.emb = nn.Embedding(max_dict_size, MM_EMBEDDINGS_DIM)
self.rnn = nn.LSTM(MM_EMBEDDINGS_DIM, MM_HIDDEN_SIZE, batch_first=True)
self.policy = nn.Linear(size + MM_HIDDEN_SIZE*2, n_actions)
self.value = nn.Linear(size + MM_HIDDEN_SIZE*2, 1)
区别在于一个新的嵌入层,它将整数令牌 ID 转换为密集的令牌向量,并且使用长短期记忆(LSTM)RNN。卷积层和 RNN 层的输出被拼接并输入到策略和值头部,因此它们输入的维度是图像和文本特征的组合。
这个函数将图像和 RNN 特征拼接成一个单一的张量:
def _concat_features(self, img_out: torch.Tensor,
rnn_hidden: torch.Tensor | tt.Tuple[torch.Tensor, ...]):
batch_size = img_out.size()[0]
if isinstance(rnn_hidden, tuple):
flat_h = list(map(lambda t: t.view(batch_size, -1), rnn_hidden))
rnn_h = torch.cat(flat_h, dim=1)
else:
rnn_h = rnn_hidden.view(batch_size, -1)
return torch.cat((img_out, rnn_h), dim=1)
最后,在 forward() 函数中,我们期望预处理器准备的两个对象:一个包含输入图像的张量和批次的打包序列:
def forward(self, x: tt.Tuple[torch.Tensor, rnn_utils.PackedSequence]):
x_img, x_text = x
emb_out = self.emb(x_text.data)
emb_out_seq = rnn_utils.PackedSequence(emb_out, x_text.batch_sizes)
rnn_out, rnn_h = self.rnn(emb_out_seq)
xx = x_img / 255.0
conv_out = self.conv(xx)
feats = self._concat_features(conv_out, rnn_h)
return self.policy(feats), self.value(feats)
图像通过卷积处理,文本数据则输入到 RNN 中;然后,将结果拼接,并计算策略和值结果。
这就是大部分新代码。训练的 Python 脚本 wob_click_mm_train.py,基本上是 wob_click_train.py 的复制版,仅在包装器创建、模型和预处理器上做了些许修改。
结果
我在点击按钮环境中进行了多个实验(miniwob.farama.org/environments/click-button/),目标是从多个随机按钮中做出选择。在图 14.8 中,展示了该环境中的几种情况:

图 14.8:点击按钮环境中的任务
如图 14.9 所示,经过 3 小时的训练,模型已经学会了如何点击(回合中的平均步骤计数减少到 5-7),并获得了平均奖励 0.2\。但随后训练没有产生明显效果。这可能表明超参数需要调整,或者是环境的模糊性。在这种情况下,我注意到该环境有时会显示多个相同标题的按钮,但只有一个按钮给出正奖励。图 14.8 的第一部分中就有这样的例子,那里有两个相同的“提交”按钮。

图 14.9:点击按钮中的训练奖励(左)和回合中的步骤计数(右)
另一个文本描述重要的环境是点击标签(click-tab),要求智能体随机点击一个特定的标签。截图见图 14.10。

图 14.10:点击标签环境中的任务
在这个环境中,训练并不成功,这有点奇怪,因为这个任务看起来比点击按钮更简单(点击的位置是固定的)。很可能需要进行超参数调优。这是另一个有趣的挑战,你可以通过实验来解决,运用你到目前为止获得的知识。
人类示范
为了改善训练过程,我们尝试加入人类示范。示范的思想很简单:为了帮助我们的智能体发现解决任务的最佳方法,我们展示一些我们认为是解决问题所需的行为示例。这些示例可能不是最佳的解决方案,也不完全准确,但它们应该足够好,能为智能体展示出有前景的探索方向。
事实上,这是一个非常自然的做法,因为所有人类学习都是基于一些由老师、父母或其他人提供的先前示例。这些示例可以是书面形式的(例如,食谱书),或者是以示范的形式给出,你需要多次重复才能掌握(例如,舞蹈课程)。这种形式的训练比随机搜索更有效。试想一下,如果只通过试错来学习如何刷牙,那会有多么复杂和漫长。当然,依赖示范学习也有风险,示范可能是错误的,或者不是解决问题的最有效方式;但总体来说,它比随机搜索要有效得多。
我们之前的所有示例都遵循了这个工作流程:
-
他们没有使用任何先验知识,而是从随机权重初始化开始,这导致在训练初期执行的是随机动作。
-
经过几次迭代后,智能体发现某些状态下的某些动作能带来更有前景的结果(通过更高优势的 Q 值或策略),并开始偏好这些动作而不是其他的动作。
-
最终,这个过程导致了一个或多或少的最优策略,使得智能体在结束时获得了高奖励。
当我们的动作空间维度较低且环境行为不太复杂时,这种方法效果很好,但仅仅是将动作数量翻倍,就需要至少两倍的观察量。以我们的点击器智能体为例,我们在活动区域有 256 种不同的动作,对应 10 × 10 的网格,这比 CartPole 环境中的动作多了 128 倍。由此可见,训练过程时间很长,并且可能根本无法收敛。
维度问题可以通过多种方式解决,比如更智能的探索方法、训练时更高效的采样(一次性训练)、引入先验知识(迁移学习)等手段。当前有大量研究致力于使强化学习更好、更快,我们可以确信,许多突破正在前方等待着我们。在本节中,我们将尝试通过将人类记录的示范融入训练过程来采用更传统的方法。
你可能还记得我们关于策略内和策略外方法的讨论(这些内容在第四章和第八章中有提到)。这与我们的人类演示非常相关,因为严格来说,我们不能将策略外数据(人类观察-行动对)与策略内方法(在我们这里是 A3C)一起使用。这是因为策略内方法的性质:它们使用当前策略收集的样本来估计策略梯度。如果我们仅仅将人类记录的样本输入到训练过程中,估计出的梯度将适用于人类策略,而不适用于我们的神经网络(NN)所给出的当前策略。为了解决这个问题,我们需要稍微“作弊”一下,从监督学习的角度来看待我们的任务。具体来说,我们将使用对数似然目标来推动我们的神经网络根据演示来采取行动。
通过这种方式,我们并不是用监督学习取代强化学习。相反,我们是利用监督学习技术来帮助我们的强化学习方法。从根本上说,这并不是我们第一次做类似的事情;例如,Q-learning 中的价值函数训练就是纯粹的监督学习。
在深入实现细节之前,我们需要解决一个非常重要的问题:我们如何以最方便的形式获取演示?
记录演示
在 MiniWoB++ 和过渡到 Selenium 之前,记录演示在技术上是具有挑战性的。特别是,必须捕获并解码 VNC 协议,才能提取浏览器的屏幕截图以及用户执行的操作。在本书的前一版中,我提供了自己的 VNC 协议解析器版本来记录演示。
幸运的是,这些挑战现在大多已经消失。现在没有 VNC 了,浏览器已经在本地进程中启动(之前是在 Docker 容器内),所以我们几乎可以直接与之通信。
Farama MiniWoB++ 配有一个 Python 脚本,可以将演示记录到 JSON 文件中。该脚本可以通过 python -m miniwob.scripts.record 命令启动,详细文档请见 miniwob.farama.org/content/demonstrations/。
不幸的是,它有一个局限性:在观察数据中,它仅捕捉网页的 DOM 结构,并没有像素级的信息。由于本章的示例大量使用像素,使用此脚本记录的演示是无效的。为了解决这个问题,我实现了自己版本的工具来记录包括浏览器像素在内的演示。它被命名为 Chapter14/record_demo.py,可以通过以下方式启动:
$ ./record_demo.py -o demos/test -g tic-tac-toe-v1 -d 1
Bottle v0.12.25 server starting up (using WSGIRefServer())...
Listening on http://localhost:8032/
Hit Ctrl-C to quit.
WARNING:root:Cannot call {’action_type’: 0} on instance 0, which is already done
127.0.0.1 - - [26/Apr/2024 12:19:49] "POST /record HTTP/1.1" 200 17
Saved in demos/test/tic-tac-toe_0426101949.json
New episode starts in 1 seconds...
该命令以 render_mode='human'启动环境,显示浏览器窗口并允许与页面进行交互。在后台,它记录观测数据(带有截图),当回合结束时,它将截图与您的动作结合,并将所有内容存储在由-o 命令行选项指定的目录中的 JSON 文件中。使用-g 命令行选项可以更改环境,-d 参数设置回合之间的延迟时间(秒)。如果没有给定-d 选项,您需要在控制台按 Enter 键来开始一个新回合。以下截图显示了记录示范的过程:

图 14.11:记录井字棋的人类示范
在 Chapter14/demos 目录中,我存储了用于实验的示范,但当然,您可以使用提供的脚本记录自己的示范。
使用示范进行训练
现在我们知道如何记录示范数据,但还有一个问题没有解答:我们的训练过程需要如何修改才能融入人类示范?最简单的解决方案,尽管如此非常有效,是使用我们在第四章中训练交叉熵方法时使用的对数似然目标。为了实现这一点,我们需要将我们的 A3C 模型视为一个分类问题,在其策略头部产生输入观测的分类。在其最简单的形式中,值头部将保持不变,但实际上,训练它并不困难:我们知道在示范过程中获得的奖励,因此需要做的就是计算每个观测直到回合结束的折扣奖励。
要检查它是如何实现的,让我们看一下 Chapter16/wob_click_train.py 中的相关代码片段。首先,我们可以通过在命令行中传递 demo
demo_samples = None
if args.demo:
demo_samples = demos.load_demo_dir(args.demo, gamma=GAMMA, steps=REWARD_STEPS)
print(f"Loaded {len(demo_samples)} demo samples")
与示范训练相关的第二段代码在训练循环内部,并在任何正常批次之前执行。示范训练是以一定的概率进行的(默认情况下为 0.5),由 DEMO_PROB 超参数指定:
if demo_samples and step_idx < DEMO_FRAMES:
if random.random() < DEMO_PROB:
random.shuffle(demo_samples)
demo_batch = demo_samples[:BATCH_SIZE]
model.train_demo(net, optimizer, demo_batch, writer,
step_idx, device=device)
逻辑很简单:以概率 DEMO_PROB,我们从示范数据中采样 BATCH_SIZE 个样本,并对该批数据进行一轮训练。
实际的训练非常简单明了,通过 model.train_demo()函数执行:
def train_demo(net: Model, optimizer: torch.optim.Optimizer,
batch: tt.List[ptan.experience.ExperienceFirstLast], writer, step_idx: int,
preprocessor=ptan.agent.default_states_preprocessor,
device: torch.device = torch.device("cpu")):
batch_obs, batch_act = [], []
for e in batch:
batch_obs.append(e.state)
batch_act.append(e.action)
batch_v = preprocessor(batch_obs)
if torch.is_tensor(batch_v):
batch_v = batch_v.to(device)
optimizer.zero_grad()
ref_actions_v = torch.LongTensor(batch_act).to(device)
policy_v = net(batch_v)[0]
loss_v = F.cross_entropy(policy_v, ref_actions_v)
loss_v.backward()
optimizer.step()
writer.add_scalar("demo_loss", loss_v.item(), step_idx)
我们将观察和动作列表拆分开来,预处理观察以将其转换为 PyTorch 张量,并将它们放置在 GPU 上。然后,我们请求 A3C 网络返回策略,并计算结果与目标动作之间的交叉熵损失。从优化的角度来看,我们正在推动网络朝着演示中采取的动作前进。
结果
为了检查演示的效果,我在计数侧面问题上进行了两组训练,使用相同的超参数:一组没有演示,另一组使用了 25 个演示回合,这些回合可以在 demos/count-sides 目录中找到。
差异非常显著。从零开始的训练在 12 小时的训练和 400 万帧后,达到了最佳平均奖励 -0.4,但在训练动态上没有任何显著的改善。另一方面,使用演示的训练在仅仅 30,000 帧的训练后,达到了平均奖励 0.5,这仅用了 8 分钟。图 14.12 显示了奖励和步骤数量。

图 14.12:训练奖励(左)和在带有演示的计数侧面的步骤数量(右)
我实验过的一个更具挑战性的问题是井字游戏,可以在 tic-tac-toe 环境中找到。图 14.13 展示了我记录的一个演示游戏过程(可在 demos/tic-tac-toe 目录中找到)。圆点表示点击发生的位置:

图 14.13:演示井字游戏
经过两小时的训练,最佳平均奖励达到了 0.05,这意味着代理可以赢得一些游戏,但有些游戏会失败或以平局结束。在图 14.14 中,展示了奖励动态和回合步骤数量的图表。

图 14.14:带有演示的井字游戏训练奖励(左)和回合步骤数量(右)
尝试的事项
本章中,我们只是开始通过查看 MiniWoB++ 中最简单的环境来进行实验,整个集合包含了 100 多个问题,因此前方还有许多未知的领域。如果你想练习,以下是你可以尝试的几个项目:
-
测试演示在噪声点击下的鲁棒性。
-
点击方法的动作空间可以通过预测点击位置的 x 和 y 坐标来改进。
-
可以使用 DOM 数据来替代(或补充)屏幕像素。然后,预测将是要点击的树的元素。
-
尝试其他问题。这些问题种类繁多,要求生成键盘事件、规划动作序列等。
-
最近,LaVague 项目发布了(
github.com/lavague-ai/LaVague),该项目使用大型语言模型(LLMs)进行网页自动化。他们的方法是要求 LLM 生成 Selenium Python 代码以执行特定任务。将其与 MiniWoB++ 问题进行对比将非常有趣。
摘要
在本章中,您看到了强化学习方法在浏览器自动化中的实际应用,并使用了 MiniWoB++ 基准。我相信,浏览器自动化(以及与人类使用的软件进行交流)是未来人工智能发展的一个重要里程碑。
本章结束了本书的第三部分。下一部分将致力于更复杂和更新的与连续动作空间、非梯度方法以及其他更先进的强化学习方法相关的内容。
在下一章中,我们将讨论连续控制问题,这是强化学习领域中的一个重要子领域,既具有理论意义也具有实践意义。
留下评论!
感谢您购买本书,感谢 Packt 出版社的支持——我们希望您喜欢这本书!您的反馈对我们来说非常宝贵,能够帮助我们改进和成长。阅读完后,请花点时间在亚马逊上留下评论;这只需一分钟,但对像您这样的读者来说却意义重大。扫描下面的二维码,免费领取您选择的电子书。packt.link/NzOWQ

第四部分
高级强化学习(RL)
第十五章:连续动作空间
本章通过探讨一个到目前为止仅简要提到过的问题——当我们的动作空间不是离散的时,如何处理环境,来开启本书的强化学习(RL)高级部分。连续动作空间问题是强化学习中的一个重要子领域,无论在理论上还是实践中,它们在机器人学、控制问题以及与物理对象互动的其他领域都有重要应用。在本章中,你将会熟悉在这种情况下出现的挑战,并学习如何解决这些问题。
本材料甚至可能适用于我们已经遇到的问题和环境。例如,在上一章中,当我们在浏览器环境中实现鼠标点击时,点击位置的 x 和 y 坐标可以看作是两个连续变量,作为动作进行预测。这看起来可能有点人为,但从环境的角度来看,这种表示方式是非常合理的:它更加紧凑,能够自然地捕捉到可能的点击分布。最后,在坐标 (x,y) 上点击与在 (x + 1, y + 1) 位置上点击,对大多数任务来说并没有太大区别。
在本章中,我们将:
-
介绍连续动作空间,解释它的重要性,如何与我们已熟悉的离散动作空间不同,并且如何在 Gym API 中实现
-
讨论使用强化学习方法(RL)解决连续控制领域的问题
-
检查三种不同算法在四足机器人问题上的表现
为什么是连续空间?
到目前为止,我们在本书中看到的所有例子都是离散动作空间,因此你可能会产生一种错误的印象,认为离散动作主导了这个领域。当然,这是一种非常偏颇的观点,反映的只是我们选择的测试问题的领域。除了 Atari 游戏和简单的经典强化学习问题外,还有许多任务需要的不仅仅是从一个小而离散的动作集合中进行选择。
举个例子,想象一个简单的机器人,只有一个可控关节,能够在某个角度范围内旋转。通常,要控制一个物理关节,你必须指定期望的位置或施加的力。在这两种情况下,你都需要做出一个关于连续值的决策。这个值与离散动作空间本质上是不同的,因为你可以做出决策的值集合可能是无限的。例如,你可以要求关节转到 13.5^∘ 角度或 13.512^∘ 角度,结果可能会不同。当然,系统总是存在一些物理限制,你不能以无限精度来指定动作,但潜在值的大小可能会非常大。
事实上,当你需要与物理世界进行交互时,连续动作空间比离散动作集合更为常见。例如,不同种类的机器人控制系统(如加热/冷却控制器)。强化学习的方法可以应用于这一领域,但在使用优势演员-评论员(A2C)或深度 Q 网络(DQN)方法之前,有一些细节需要考虑。
本章我们将探讨如何处理这一系列问题。这将作为学习强化学习这一非常有趣且重要领域的良好起点。
动作空间
与连续动作空间的根本且明显的区别在于其连续性。与离散动作空间相对,当动作被定义为一个离散的、互斥的选项集合(例如 {left, right},仅包含两个元素)时,连续动作则是某个范围内的值(例如 [0…1],包含无限多个元素,如 0.5、
和
)。在每个时间步骤中,代理需要为动作选择一个具体的值并将其传递给环境。
在 Gym 中,连续动作空间由 gym.spaces.Box 类表示,这在我们讨论观察空间时已有描述。你可能还记得,Box 包括一组具有形状和边界的值。例如,从 Atari 模拟器得到的每个观察值被表示为 Box(low=0, high=255, shape=(210, 160, 3)),这意味着 100,800 个值以 3D 张量的形式组织,值的范围为 0 到 255。对于动作空间来说,你不太可能处理如此大量的动作。例如,我们将用作测试环境的四足机器人有八个连续动作,分别对应每条腿上的两个马达。对于这个环境,动作空间将被定义为 Box(low=-1, high=1, shape=(8,)),这意味着每个时间戳需要选择来自 −1 到 1 范围内的八个值来控制机器人。
在这种情况下,传递给 env.step() 的动作在每一步将不再是整数,而是具有某种形状的 NumPy 向量,包含单独的动作值。当然,也可能出现更复杂的情况,当动作空间是离散动作和连续动作的组合时,可能会用 gym.spaces.Tuple 类来表示。
环境
大多数包含连续动作空间的环境与物理世界相关,因此通常使用物理仿真。现在有许多软件包可以模拟物理过程,从非常简单的开源工具到复杂的商业软件包,这些包可以模拟多物理过程(如流体、燃烧和强度仿真)。
在机器人领域,最受欢迎的一个软件包是 MuJoCo,它代表了带有接触的多关节动力学(www.mujoco.org)。这是一个物理引擎,你可以在其中定义系统的各个组件及其交互和属性。然后,模拟器负责根据你的干预解决系统,找到组件的参数(通常是位置、速度和加速度)。这使得它成为强化学习环境的理想测试场,因为你可以定义相当复杂的系统(如多足机器人、机械臂或人形机器人),然后将观察结果输入到强化学习代理中,获得反馈的动作。
长时间以来,MuJoCo 是一个商业软件包,需要购买昂贵的许可证。虽然有试用许可证和教育许可证,但它们限制了该软件的受众。但在 2022 年,DeepMind 收购了 MuJoCo,并将源代码公开给所有人使用,这无疑是一次伟大而慷慨的举动。Farama Gymnasium 包含了多个 MuJoCo 环境(gymnasium.farama.org/environments/mujoco/),开箱即用;要使它们正常工作,你需要安装 gymnasium[mujoco]包。
除了 MuJoCo 外,还有其他物理模拟器可以用于强化学习。其中一个最受欢迎的是 PyBullet(pybullet.org/),它从一开始就是开源的。在本章中,我们将使用 PyBullet 进行实验,稍后的章节中,我们也会介绍 MuJoCo。要安装 PyBullet,你需要在 Python 环境中执行 pip install pybullet==3.2.6。由于 PyBullet 没有更新到 Gymnasium API,我们还需要安装 OpenAI Gym 以确保兼容性:
pip install gym==0.25.1
我们使用版本 0.25.1,因为 OpenAI Gym 的后续版本与 PyBullet 的最新版本不兼容。
以下代码(位于 Chapter15/01_check_env.py 中)允许你检查 PyBullet 是否正常工作。它会查看动作空间,并渲染出我们将在本章中作为实验对象使用的环境图像:
import gymnasium as gym
ENV_ID = "MinitaurBulletEnv-v0"
ENTRY = "pybullet_envs.bullet.minitaur_gym_env:MinitaurBulletEnv"
RENDER = True
if __name__ == "__main__":
gym.register(ENV_ID, entry_point=ENTRY, max_episode_steps=1000,
reward_threshold=15.0, disable_env_checker=True)
env = gym.make(ENV_ID, render=RENDER)
print("Observation space:", env.observation_space)
print("Action space:", env.action_space)
print(env)
print(env.reset())
input("Press any key to exit\n")
env.close()
启动该工具后,它应当打开图形用户界面(GUI)窗口,显示我们的四足机器人,如下图所示,我们将训练它进行移动:

图 15.1:PyBullet GUI 中的 Minitaur 环境(欲更好地可视化,参考 https://packt.link/gbp/9781835882702)
该环境提供 28 个数字作为观察值,它们对应机器人不同的物理参数:速度、位置和加速度。(你可以查看 MinitaurBulletEnv-v0 的源代码以获取详细信息。)动作空间是 8 个数字,定义了电机的参数。每条腿上有两个电机(每个膝盖一个)。该环境的奖励是机器人行进的距离减去消耗的能量。
A2C 方法
我们将应用于我们的行走机器人问题的第一个方法是 A2C,这是我们在本书第三部分中进行实验的内容。选择这个方法是显而易见的,因为 A2C 非常容易适应连续动作域。简要回顾一下,A2C 的理念是估计我们策略的梯度,即∇J = ∇[𝜃] log π𝜃(R −V 𝜃)。策略π𝜃应该提供给定观察状态下的动作概率分布。量 V 𝜃称为评论员,等于状态的值,并使用评论员回报与由 Bellman 方程估计的值之间的均方误差(MSE)损失进行训练。为了提高探索性,通常会在损失中添加熵奖励 L[H] = π𝜃log π𝜃。
显然,对于连续动作,演员-评论员的值头将保持不变。唯一受到影响的是策略的表示。在我们之前看到的离散情况中,只有一个动作具有多个互斥的离散值。对于这种情况,策略的明显表示就是所有动作的概率分布。
在连续情况下,我们通常有多个动作,每个动作都可以从某个范围内取值。考虑到这一点,最简单的策略表示将是每个动作返回的值。这些值不应与状态的值 V(s)混淆,后者表示我们可以从该状态获得多少奖励。为了说明这两者的区别,我们可以想象一个简单的汽车转向案例,在该案例中,我们只能转动方向盘。每时每刻的动作将是方向盘的角度(动作值),但每个状态的值将是该状态下潜在的折扣奖励(例如,汽车可以行驶的距离),这完全是不同的概念。
回到我们的动作表示选项,如果你记得我们在第十一章的策略表示部分中讨论的内容,作为一个具体值表示的动作有不同的缺点,主要与环境探索有关。一个更好的选择是随机的,例如,网络返回高斯分布的参数。对于 N 个动作,这些参数将是两个大小为 N 的向量。第一个向量将是均值μ,第二个向量将包含方差σ²。在这种情况下,我们的策略将表示为一个随机的 N 维无相关的正态分布随机变量向量,我们的网络可以对每个变量的均值和方差做出选择。
根据定义,Gaussian 分布的概率密度函数由下式给出:

我们可以直接使用这个公式来获取概率,但为了提高数值稳定性,值得进行一些数学推导并简化 log π𝜃的表达式。
最终结果将是这个:

高斯分布的熵可以通过微分熵的定义获得,其结果为
。现在我们已经具备了实现 A2C 方法所需的一切,接下来我们开始实现。
实现
完整的源代码位于 02_train_a2c.py、lib/model.py 和 lib/common.py 中。你会熟悉大部分代码,所以下面只列出了不同的部分。我们从 lib/model.py 中定义的模型类开始:
HID_SIZE = 128
class ModelA2C(nn.Module):
def __init__(self, obs_size: int, act_size: int):
super(ModelA2C, self).__init__()
self.base = nn.Sequential(
nn.Linear(obs_size, HID_SIZE),
nn.ReLU(),
)
self.mu = nn.Sequential(
nn.Linear(HID_SIZE, act_size),
nn.Tanh(),
)
self.var = nn.Sequential(
nn.Linear(HID_SIZE, act_size),
nn.Softplus(),
)
self.value = nn.Linear(HID_SIZE, 1)
如你所见,我们的网络有三个头,而不是 A2C 离散版本中的两个。前两个头返回动作的均值和方差,而最后一个是返回状态值的评论员头。返回的均值使用双曲正切激活函数,该激活函数将输出压缩到范围−1…1。方差使用 softplus 激活函数进行变换,该函数为 log(1 + e^x),其形状类似于平滑的修正线性单元(ReLU)函数。这个激活函数有助于确保我们的方差为正。值头与往常一样,没有应用激活函数。
前向传播是显而易见的;我们首先应用常规层,然后计算各个头:
def forward(self, x: torch.Tensor):
base_out = self.base(x)
return self.mu(base_out), self.var(base_out), self.value(base_out)
下一步是实现 PTAN 代理类,它用于将观察值转换为动作:
class AgentA2C(ptan.agent.BaseAgent):
def __init__(self, net: ModelA2C, device: torch.device):
self.net = net
self.device = device
def __call__(self, states: ptan.agent.States, agent_states: ptan.agent.AgentStates):
states_v = ptan.agent.float32_preprocessor(states)
states_v = states_v.to(self.device)
mu_v, var_v, _ = self.net(states_v)
mu = mu_v.data.cpu().numpy()
sigma = torch.sqrt(var_v).data.cpu().numpy()
actions = np.random.normal(mu, sigma)
actions = np.clip(actions, -1, 1)
return actions, agent_states
在离散情况下,我们使用了 ptan.agent.DQNAgent 和 ptan.agent.PolicyAgent 类,但对于我们的这个问题,我们需要自己编写一个,编写并不复杂:你只需要编写一个类,继承自 ptan.agent.BaseAgent,并重写 call 方法,该方法需要将观察值转换为动作。
在这个类中,我们从网络中获取均值和方差,并使用 NumPy 函数对正态分布进行采样。为了防止动作超出环境的−1…1 范围,我们使用 np.clip(),它将所有小于-1 的值替换为-1,将所有大于 1 的值替换为 1。agent_states 参数没有使用,但它需要与所选择的动作一起返回,因为我们的 BaseAgent 支持保持代理的状态。我们现在不需要这个功能,但它将在下一部分的深度确定性策略梯度中派上用场,届时我们将需要使用奥恩斯坦-乌伦贝克(OU)过程实现随机探索。
有了模型和代理之后,我们现在可以进入训练过程,这部分在 02_train_a2c.py 中定义。它包括训练循环和两个函数。第一个函数用于在独立的测试环境中定期测试我们的模型。在测试过程中,我们不需要进行任何探索;我们将直接使用模型返回的均值,不进行任何随机采样。测试函数如下所示:
def test_net(net: model.ModelA2C, env: gym.Env, count: int = 10,
device: torch.device = torch.device("cpu")):
rewards = 0.0
steps = 0
for _ in range(count):
obs, _ = env.reset()
while True:
obs_v = ptan.agent.float32_preprocessor([obs])
obs_v = obs_v.to(device)
mu_v = net(obs_v)[0]
action = mu_v.squeeze(dim=0).data.cpu().numpy()
action = np.clip(action, -1, 1)
obs, reward, done, is_tr, _ = env.step(action)
rewards += reward
steps += 1
if done or is_tr:
break
return rewards / count, steps / count
训练模块中定义的第二个函数实现了根据策略计算所采取动作的概率的对数。该函数是我们之前看到的公式的直接实现:

def calc_logprob(mu_v: torch.Tensor, var_v: torch.Tensor, actions_v: torch.Tensor):
p1 = - ((mu_v - actions_v) ** 2) / (2*var_v.clamp(min=1e-3))
p2 = - torch.log(torch.sqrt(2 * math.pi * var_v))
return p1 + p2
唯一的微小区别是在使用 torch.clamp()函数,以防当返回的方差太小时发生除零错误。
训练循环照常创建网络和代理,然后实例化两步经验源和优化器。使用的超参数如下所示。它们没有经过太多调整,因此仍有很大的优化空间:
GAMMA = 0.99
REWARD_STEPS = 2
BATCH_SIZE = 32
LEARNING_RATE = 5e-5
ENTROPY_BETA = 1e-4
TEST_ITERS = 1000
用于对收集到的批次执行优化步骤的代码与我们在第十二章中实现的 A2C 训练非常相似。唯一的区别是使用了我们的 calc_logprob()函数以及不同的熵奖励表达式,接下来会展示:
states_v, actions_v, vals_ref_v = common.unpack_batch_a2c(
batch, net, device=device, last_val_gamma=GAMMA ** REWARD_STEPS)
batch.clear()
optimizer.zero_grad()
mu_v, var_v, value_v = net(states_v)
loss_value_v = F.mse_loss(value_v.squeeze(-1), vals_ref_v)
adv_v = vals_ref_v.unsqueeze(dim=-1) - value_v.detach()
log_prob_v = adv_v * calc_logprob(mu_v, var_v, actions_v)
loss_policy_v = -log_prob_v.mean()
ent_v = -(torch.log(2*math.pi*var_v) + 1)/2
entropy_loss_v = ENTROPY_BETA * ent_v.mean()
loss_v = loss_policy_v + entropy_loss_v + loss_value_v
loss_v.backward()
optimizer.step()
每隔 TEST_ITERS 帧,模型会进行一次测试,并在获得最佳奖励时保存模型权重。
结果
与我们在本章中将要探讨的其他方法相比,A2C 的表现最差,无论是在最佳奖励还是收敛速度上。这很可能是因为经验收集仅使用了单一环境,而这是策略梯度(PG)方法的一个弱点。因此,你可能想要检查多个并行环境对 A2C 的影响。
要开始训练,我们传递-n 参数以及运行名称,该名称将在 TensorBoard 中使用,并且会创建一个新的目录以保存模型。可以使用--dev 选项启用 GPU,但由于输入的维度较小且网络规模较小,它只会带来微小的速度提升。
在经过 9M 帧(16 小时的优化过程)后,训练过程在测试中达到了最佳得分 0.35,虽然不算很出色。如果我们让它运行一两周,可能能获得更好的分数。训练和测试中的奖励以及回合步骤如下图所示:

图 15.2:训练回合的奖励(左)和步骤(右)

图 15.3:测试回合的奖励(左)和步骤(右)
该图中的回合步骤图(右侧的图表)显示了回合结束前每个回合执行的平均步数。环境的时间限制为 1,000 步,因此低于 1,000 的值表示回合因环境检查而停止。对于大多数 PyBullet 环境,内部实现了自我损害检查,这会停止仿真。
使用模型和录制视频
正如你之前所看到的,物理模拟器可以呈现环境的状态,这使得我们可以观察训练后的模型行为。为了实现这一点,对于我们的 A2C 模型,提供了一个工具:03_play_a2c.py。其逻辑与 test_net()函数相同,因此这里不展示代码。
要启动它,你需要传递 -m 选项和模型文件,以及可选的 -r 参数,后者是一个目录名称,将用于保存视频,使用我们在第二章中讨论的 RecordVideo 包装器。
在模拟结束时,效用显示了步骤数和累积奖励。例如,我的训练中最好的 A2C 模型能够获得 0.312 的奖励,而视频只有 2 秒长(你可以在这里找到它:youtu.be/s9BReDUtpQs)。图 15.4 显示了视频的最后一帧,看起来我们的模型在保持平衡方面遇到了一些问题。

图 15.4:A2C 模型模拟的最后一帧
深度确定性策略梯度(DDPG)
接下来我们将看看的一种方法叫做深度确定性策略梯度(DDPG),它是一种演员-评论员方法,但有一个非常好的特性——它是脱离策略的。以下是对严格证明的简化解释。如果你有兴趣深入理解这种方法的核心,可以随时参考 Silver 等人于 2014 年发表的名为《确定性策略梯度算法》[Sil+14]的文章,以及 Lillicrap 等人于 2015 年发表的名为《使用深度强化学习进行连续控制》[Lil15]的论文。
说明这种方法最简单的方式是与我们已经熟悉的 A2C 方法进行比较。在这种方法中,演员估计的是随机策略,该策略返回离散动作的概率分布,或者像我们在上一节中讲解的那样,正态分布的参数。在这两种情况下,我们的策略都是随机的,换句话说,我们采取的动作是从这个分布中采样的。
确定性策略梯度也属于 A2C 家族,但其策略是确定性的,这意味着它直接提供我们在某一状态下应采取的动作。这使得可以对 Q 值应用链式法则,通过最大化 Q,策略也会得到改进。为了理解这一点,我们来看一下演员和评论员在连续动作域中是如何连接的。
我们先从演员(actor)开始,因为它是两者中更简单的一个。我们从它这里需要的是在每个给定状态下采取的动作。在连续动作域中,每个动作都是一个数字,因此演员网络会将状态作为输入,并返回 N 个值,每个值对应一个动作。这个映射是确定性的,因为相同的网络如果输入相同,始终返回相同的输出。(我们不会使用 dropout 或任何增加推断随机性的技术;我们只会使用普通的前馈网络。)
现在让我们来看一下评论员。评论员的作用是估算 Q 值,即在某个状态下采取的动作的折扣奖励。然而,我们的动作是一个数字向量,所以我们的评论员网络现在接受两个输入:状态和动作。评论员的输出将是一个数字,表示 Q 值。这个架构不同于 DQN,当时我们的动作空间是离散的,并且为了提高效率,我们在一次传递中返回所有动作的值。这个映射也是确定性的。
所以,我们有两个函数:
-
演员,我们称之为μ(s),将状态转换为动作
-
评论员通过状态和动作,给我们 Q 值:Q(s,a)
我们可以将演员函数代入评论员,并得到只有一个输入参数的表达式:Q(s,μ(s))。最终,神经网络只是函数。
现在,评论员的输出给出了我们最初想要最大化的实体的近似值:折扣总奖励。这个值不仅依赖于输入状态,还依赖于𝜃[μ]演员和𝜃[Q]评论员网络的参数。在我们优化的每一步中,我们都希望改变演员的权重,以提高我们获得的总奖励。从数学角度看,我们希望得到我们策略的梯度。
在他的确定性策略梯度定理中,Silver 等人证明了随机策略梯度等同于确定性策略梯度。换句话说,想要改进策略,我们只需要计算 Q(s,μ(s))函数的梯度。通过应用链式法则,我们得到梯度:∇[a]Q(s,a)∇[𝜃[μ]]μ(s)。
注意,尽管 A2C 和 DDPG 方法都属于 A2C 家族,但评论员的使用方式是不同的。在 A2C 中,我们使用评论员作为经验轨迹中奖励的基准,因此评论员是一个可选部分(没有它,我们将得到 REINFORCE 方法),并用于提高稳定性。这是因为 A2C 中的策略是随机的,这在我们的反向传播能力中建立了一个屏障(我们无法区分随机采样步骤)。
在 DDPG 中,评论员以不同的方式使用。由于我们的策略是确定性的,我们现在可以从 Q 中计算梯度,Q 是从评论员网络获得的,评论员使用由演员产生的动作(见图 15.5),因此整个系统是可微的,并且可以通过随机梯度下降(SGD)进行端到端优化。为了更新评论员网络,我们可以使用贝尔曼方程来找到 Q(s,a)的近似值并最小化均方误差(MSE)目标。
所有这些可能看起来有些晦涩,但背后是一个相当简单的思想:评论员像我们在 A2C 中做的那样进行更新,演员则通过最大化评论员输出的方式进行更新。这种方法的优点在于它是脱离策略的,这意味着我们现在可以拥有一个巨大的重放缓冲区,以及在 DQN 训练中使用的其他技巧。不错吧?
探索
我们为所有这些好处付出的代价是我们的策略现在是确定性的,因此我们必须以某种方式探索环境。我们可以通过在将动作传递给环境之前,向演员返回的动作中添加噪声来实现这一点。这里有几种选择。最简单的方法就是直接将随机噪声添加到动作中:μ(s) + 𝜖𝒩。我们将在本章中考虑的下一个方法中使用这种方式。
一种更先进的(有时能获得更好结果的)探索方法是使用之前提到的 Ornstein-Uhlenbeck 过程,这在金融领域及其他处理随机过程的领域中非常流行。这个过程模拟了一个大质量布朗粒子在摩擦力作用下的速度,并通过以下随机微分方程定义:
∂x[t] = 𝜃(μ −x[t])∂t + σ∂W,
其中 𝜃、μ 和 σ 是过程的参数,W[t] 是维纳过程。在离散时间的情况下,OU 过程可以写作:
x[t+1] = x[t] + 𝜃(μ −x) + σ𝒩。
这个方程表示通过加入正态噪声 𝒩 来生成过程的下一个值。在我们的探索中,我们将把 OU 过程的值添加到演员返回的动作中。
实现
这个例子由三个源文件组成:
-
lib/model.py 包含模型和 PTAN 代理
-
lib/common.py 中有一个用于解包批次的函数
-
04_train_ddpg.py 包含启动代码和训练循环
在这里,我将只展示代码中的重要部分。模型由两个独立的网络组成,分别是演员和评论员,它遵循 Lillicrap 等人论文中的架构[Lil15]。演员非常简单,是一个具有两个隐藏层的前馈网络。输入是一个观察向量,而输出是一个包含 N 个值的向量,每个动作对应一个值。输出的动作经过双曲正切非线性变换,将值压缩到 −1…1 范围内。
评论员有些不寻常,因为它包括观察和动作的两个独立路径,这些路径被连接在一起并转化为评论员的输出结果——一个数字。图 15.5 展示了两个网络的结构:

图 15.5:DDPG 演员和评论员网络
演员的代码包括一个三层网络,用于产生动作值:
class DDPGActor(nn.Module):
def __init__(self, obs_size: int, act_size: int):
super(DDPGActor, self).__init__()
self.net = nn.Sequential(
nn.Linear(obs_size, 400),
nn.ReLU(),
nn.Linear(400, 300),
nn.ReLU(),
nn.Linear(300, act_size),
nn.Tanh()
)
def forward(self, x: torch.Tensor):
return self.net(x)
类似地,以下是用于评论员的代码:
class DDPGCritic(nn.Module):
def __init__(self, obs_size: int, act_size: int):
super(DDPGCritic, self).__init__()
self.obs_net = nn.Sequential(
nn.Linear(obs_size, 400),
nn.ReLU(),
)
self.out_net = nn.Sequential(
nn.Linear(400 + act_size, 300),
nn.ReLU(),
nn.Linear(300, 1)
)
def forward(self, x: torch.Tensor, a: torch.Tensor):
obs = self.obs_net(x)
return self.out_net(torch.cat([obs, a], dim=1))
critic 的 forward()函数首先通过其小型网络转化观察结果,然后将输出和给定的动作拼接起来,转化为一个 Q 值。为了使用 PTAN 经验源中的演员网络,我们需要定义一个智能体类,该类必须将观察结果转化为动作。这个类是实现 OU 探索过程的最方便的地方,但为了正确实现这一点,我们应该使用 PTAN 智能体尚未使用的功能:可选的状态性。
这个想法很简单:我们的智能体将观察转化为动作。但是,如果它需要在观察之间记住某些信息呢?到目前为止,我们的所有示例都是无状态的,但有时这并不够。OU 的问题在于我们需要在观察之间跟踪 OU 值。
另一种非常有用的状态智能体应用场景是部分可观察的马尔可夫决策过程(POMDP),该过程在第六章和第十四章中简要提及。POMDP 是一个马尔可夫决策过程,其中智能体观察到的状态不符合马尔可夫性质,且不包含区分一个状态与另一个状态所需的完整信息。在这种情况下,我们的智能体需要跟踪状态以便能够采取适当的行动。
因此,实现 OU 探索的智能体代码如下:
class AgentDDPG(ptan.agent.BaseAgent):
def __init__(self, net: DDPGActor, device: torch.device = torch.device(’cpu’),
ou_enabled: bool = True, ou_mu: float = 0.0, ou_teta: float = 0.15,
ou_sigma: float = 0.2, ou_epsilon: float = 1.0):
self.net = net
self.device = device
self.ou_enabled = ou_enabled
self.ou_mu = ou_mu
self.ou_teta = ou_teta
self.ou_sigma = ou_sigma
self.ou_epsilon = ou_epsilon
def initial_state(self):
return None
构造函数接受很多参数,其中大部分是来自论文《Continuous Control with Deep Reinforcement Learning》的 OU 过程的默认超参数。
initial_state()方法源自 BaseAgent 类,当一个新回合开始时,它必须返回智能体的初始状态。由于我们的初始状态必须与动作具有相同的维度(我们希望为环境的每个动作拥有独立的探索轨迹),因此我们通过返回 None 作为初始状态来推迟初始化。
在 call 方法中,我们会考虑到这一点:
def __call__(self, states: ptan.agent.States, agent_states: ptan.agent.AgentStates):
states_v = ptan.agent.float32_preprocessor(states)
states_v = states_v.to(self.device)
mu_v = self.net(states_v)
actions = mu_v.data.cpu().numpy()
该方法是智能体的核心,目的是将观察到的状态和内部智能体状态转化为动作。作为第一步,我们将观察结果转化为适当的形式,并请求演员网络将其转化为确定性动作。该方法的其余部分是通过应用 OU 过程来添加探索噪声。
在这个循环中,我们遍历观察的批次和来自上次调用的智能体状态列表,并更新 OU 过程值,这是对已展示公式的简单实现:
if self.ou_enabled and self.ou_epsilon > 0:
new_a_states = []
for a_state, action in zip(agent_states, actions):
if a_state is None:
a_state = np.zeros(shape=action.shape, dtype=np.float32)
a_state += self.ou_teta * (self.ou_mu - a_state)
a_state += self.ou_sigma * np.random.normal(size=action.shape)
action += self.ou_epsilon * a_state
new_a_states.append(a_state)
为了完成这个循环,我们将 OU 过程中的噪声添加到我们的动作中,并将噪声值保存到下一个步骤。
最后,我们对动作进行裁剪,确保它们落入−1 到 1 的范围内;否则,PyBullet 将抛出异常:
else:
new_a_states = agent_states
actions = np.clip(actions, -1, 1)
return actions, new_a_states
DDPG 实现的最后一部分是 04_train_ddpg.py 文件中的训练循环。为了提高稳定性,我们使用了具有 100,000 个转换的重放缓冲区,并为演员和评论员都使用了目标网络(我们在第六章中讨论了这两者)。
act_net = model.DDPGActor(env.observation_space.shape[0],
env.action_space.shape[0]).to(device)
crt_net = model.DDPGCritic(env.observation_space.shape[0],
env.action_space.shape[0]).to(device)
print(act_net)
print(crt_net)
tgt_act_net = ptan.agent.TargetNet(act_net)
tgt_crt_net = ptan.agent.TargetNet(crt_net)
writer = SummaryWriter(comment="-ddpg_" + args.name)
agent = model.AgentDDPG(act_net, device=device)
exp_source = ptan.experience.ExperienceSourceFirstLast(
env, agent, gamma=GAMMA, steps_count=1)
buffer = ptan.experience.ExperienceReplayBuffer(exp_source, buffer_size=REPLAY_SIZE)
act_opt = optim.Adam(act_net.parameters(), lr=LEARNING_RATE)
crt_opt = optim.Adam(crt_net.parameters(), lr=LEARNING_RATE)
我们还使用了两种不同的优化器,以简化我们处理演员和评论员训练步骤的梯度方式。最有趣的代码在训练循环内部。在每次迭代时,我们将经验存储到重放缓冲区,并抽取训练批次:
batch = buffer.sample(BATCH_SIZE)
states_v, actions_v, rewards_v, dones_mask, last_states_v = \
common.unpack_batch_ddqn(batch, device)
然后,执行两个独立的训练步骤。为了训练评论员,我们需要使用一步贝尔曼方程计算目标 Q 值,其中目标评论员网络作为下一个状态的近似:
crt_opt.zero_grad()
q_v = crt_net(states_v, actions_v)
last_act_v = tgt_act_net.target_model(last_states_v)
q_last_v = tgt_crt_net.target_model(last_states_v, last_act_v)
q_last_v[dones_mask] = 0.0
q_ref_v = rewards_v.unsqueeze(dim=-1) + q_last_v * GAMMA
当我们得到参考后,我们可以计算 MSE 损失,并请求评论员的优化器调整评论员的权重。整个过程类似于 DQN 的训练,所以这里没有什么新东西:
critic_loss_v = F.mse_loss(q_v, q_ref_v.detach())
critic_loss_v.backward()
crt_opt.step()
tb_tracker.track("loss_critic", critic_loss_v, frame_idx)
tb_tracker.track("critic_ref", q_ref_v.mean(), frame_idx)
在演员的训练步骤中,我们需要更新演员的权重,朝着增加评论员输出的方向进行。由于演员和评论员都表示为可微函数,我们需要做的只是将演员的输出传递给评论员,然后最小化评论员返回的负值:
act_opt.zero_grad()
cur_actions_v = act_net(states_v)
actor_loss_v = -crt_net(states_v, cur_actions_v)
actor_loss_v = actor_loss_v.mean()
评论员的负输出可以作为损失,用于反向传播到评论员网络,最终再传播到演员。我们不希望触碰评论员的权重,因此只要求演员的优化器执行优化步骤。这时,评论员的权重仍然保持着来自此调用的梯度,但它们会在下一次优化步骤中被丢弃:
actor_loss_v.backward()
act_opt.step()
tb_tracker.track("loss_actor", actor_loss_v, frame_idx)
作为训练循环的最后一步,我们以一种不寻常的方式更新目标网络:
tgt_act_net.alpha_sync(alpha=1 - 1e-3)
tgt_crt_net.alpha_sync(alpha=1 - 1e-3)
之前,我们定期将优化后的网络权重同步到目标网络。在连续动作问题中,这种同步比所谓的“软同步”效果差。软同步在每一步进行,但仅将优化网络权重的一小部分添加到目标网络中。这使得从旧权重到新权重的过渡变得平滑而缓慢。
结果与视频
代码可以像 A2C 示例那样启动:你需要传递运行名称和可选的 --dev 标志。我的实验表明,使用 GPU 可以提高约 30% 的速度,因此如果你赶时间,使用 CUDA 可能是个好主意,但增速并不像在 Atari 游戏中看到的那样剧烈。
在 5M 次观察之后,耗时大约 20 小时,DDPG 算法能够在 10 次测试中达到 4.5 的平均奖励,这比 A2C 的结果有所提升。训练动态显示在图 15.6 和图 15.7 中。

图 15.6:训练回合的奖励(左)和步数(右)

图 15.7:训练过程中的演员损失(左)和评论员损失(右)
“Episode steps”图显示了我们用于训练的实验平均步数。评论者损失是均方误差(MSE)损失,应该较低,而演员损失,正如你所记得的,是评论者输出的负值,因此它越小,演员能(潜在地)获得的奖励就越好。
在图 15.8 中,所示的数值是在测试过程中获得的(这些是 10 次实验的平均值)。

图 15.8:测试实验的奖励(左)和步数(右)
要测试保存的模型并像我们对 A2C 模型所做的那样记录视频,你可以使用实用程序 05_play_ddpg.py。它使用相同的命令行选项,但应该加载 DDPG 模型。在图 15.9 中,展示了我的视频的最后一帧:

图 15.9:DDPG 模型仿真最后一帧
测试过程中的得分为 3.033,视频可以在youtu.be/vVnd0Nu1d9s观看。现在该视频时长为 11 秒,模型在前倾后失败。
分布式策略梯度
作为本章的最后一种方法,我们将看看 Barth-Maron 等人于 2018 年发布的论文《Distributed distributional deterministic policy gradients》[Bar+18]。
该方法的全名是分布式分布式深度确定性策略梯度,简称 D4PG。作者对 DDPG 方法提出了几项改进,以提高稳定性、收敛性和样本效率。
首先,他们采用了 Bellemare 等人于 2017 年提出的 Q 值分布表示方法,该方法在论文《A distributional perspective on reinforcement learning》中进行了阐述[BDM17]。我们在第八章讨论了这种方法,讲解了 DQN 的改进,因此可以参考该章或 Bellemare 的原始论文以获取更多细节。核心思想是将评论者的单一 Q 值替换为一个概率分布。Bellman 方程被 Bellman 算子所取代,它以类似的方式转化这种分布式表示。第二个改进是使用 n 步 Bellman 方程,通过展开加速收敛。我们在第八章也详细讨论了这个问题。
与原始 DDPG 方法的另一个不同之处是使用了优先重放缓冲区,而不是均匀采样缓冲区。因此,严格来说,作者从 Hassel 等人的论文《Rainbow: Combining Improvements in Deep Reinforcement Learning》中吸取了相关的改进,并将其适配到 DDPG 方法中,该论文于 2017 年发布[Hes+18]。结果令人印象深刻:这种组合在一系列连续控制问题上展现了最先进的结果。让我们尝试重新实现这个方法,并自己检查一下。
架构
D4PG 和 DDPG 之间最显著的变化是评论员的输出。评论员不再返回给定状态和动作的单一 Q 值,而是返回 N_ATOMS 个值,这些值对应于预定义范围内各个值的概率。在我的代码中,我使用了 N_ATOMS=51 和分布范围 Vmin=-10,Vmax=10,因此评论员返回了 51 个数字,表示折扣奖励落入[−10,−9.6,−9.2,…,9.6,10]区间的概率。
D4PG 和 DDPG 之间的另一个区别是探索。DDPG 使用 OU 过程进行探索,但根据 D4PG 论文的作者所述,他们尝试了 OU 和向动作中添加简单随机噪声两种方式,结果是相同的。因此,他们在论文中使用了更简单的探索方法。
代码中的最后一个显著差异与训练有关,因为 D4PG 使用交叉熵损失来计算两个概率分布之间的差异(一个是评论员返回的,另一个是通过贝尔曼算子得到的)。为了使这两个分布对齐到相同的支持原子,使用了分布投影,方法与 Bellemare 等人在原始论文中使用的相同。
实现
完整的源代码位于 06_train_d4pg.py、lib/model.py 和 lib/common.py 中。如前所述,我们首先从模型类开始。演员类的架构与 DDPG 完全相同,因此在训练类中,使用了 DDPGActor。评论员的隐藏层数量和大小与之前相同;然而,输出不是一个数字,而是 N_ATOMS:
class D4PGCritic(nn.Module):
def __init__(self, obs_size: int, act_size: int,
n_atoms: int, v_min: float, v_max: float):
super(D4PGCritic, self).__init__()
self.obs_net = nn.Sequential(
nn.Linear(obs_size, 400),
nn.ReLU(),
)
self.out_net = nn.Sequential(
nn.Linear(400 + act_size, 300),
nn.ReLU(),
nn.Linear(300, n_atoms)
)
delta = (v_max - v_min) / (n_atoms - 1)
self.register_buffer("supports", torch.arange(v_min, v_max + delta, delta))
我们还创建了一个带有奖励支持的辅助 PyTorch 缓冲区,它将用于从概率分布中获取单一的均值 Q 值:
def forward(self, x: torch.Tensor, a: torch.Tensor):
obs = self.obs_net(x)
return self.out_net(torch.cat([obs, a], dim=1))
def distr_to_q(self, distr: torch.Tensor):
weights = F.softmax(distr, dim=1) * self.supports
res = weights.sum(dim=1)
return res.unsqueeze(dim=-1)
如您所见,softmax() 的应用不是网络 forward() 方法的一部分,因为在训练期间我们将使用更稳定的 log_softmax() 函数。因此,softmax() 需要在我们想要获取实际概率时应用。
对于 D4PG,智能体类要简单得多,并且没有需要跟踪的状态:
class AgentD4PG(ptan.agent.BaseAgent):
def __init__(self, net: DDPGActor, device: torch.device = torch.device("cpu"),
epsilon: float = 0.3):
self.net = net
self.device = device
self.epsilon = epsilon
def __call__(self, states: ptan.agent.States, agent_states: ptan.agent.AgentStates):
states_v = ptan.agent.float32_preprocessor(states)
states_v = states_v.to(self.device)
mu_v = self.net(states_v)
actions = mu_v.data.cpu().numpy()
actions += self.epsilon * np.random.normal(size=actions.shape)
actions = np.clip(actions, -1, 1)
return actions, agent_states
为了将每个状态转换为动作,智能体应用演员网络并向动作中添加高斯噪声,噪声大小由 epsilon 值缩放。在训练代码中,我们有以下超参数:
GAMMA = 0.99
BATCH_SIZE = 64
LEARNING_RATE = 1e-4
REPLAY_SIZE = 100000
REPLAY_INITIAL = 10000
REWARD_STEPS = 5
TEST_ITERS = 1000
Vmax = 10
Vmin = -10
N_ATOMS = 51
DELTA_Z = (Vmax - Vmin) / (N_ATOMS - 1)
我使用了一个较小的回放缓冲区,大小为 100,000,效果良好。(在 D4PG 论文中,作者使用了 1M 的过渡数据存储在缓冲区中。)缓冲区预先填充了来自环境的 10,000 个样本,然后开始训练。
对于每个训练循环,我们执行与之前相同的两个步骤:训练评论员和演员。区别在于评论员损失计算的方式:
batch = buffer.sample(BATCH_SIZE)
states_v, actions_v, rewards_v, dones_mask, last_states_v = \
common.unpack_batch_ddqn(batch, device)
crt_opt.zero_grad()
crt_distr_v = crt_net(states_v, actions_v)
last_act_v = tgt_act_net.target_model(last_states_v)
last_distr_v = F.softmax(
tgt_crt_net.target_model(last_states_v, last_act_v), dim=1)
作为评论者训练的第一步,我们要求它返回状态和采取的动作的概率分布。这个概率分布将作为输入用于交叉熵损失的计算。为了获得目标概率分布,我们需要计算批次中最后状态的分布,然后执行分布的贝尔曼投影:
proj_distr = distr_projection(
last_distr_v.detach().cpu().numpy(), rewards_v.detach().cpu().numpy(),
dones_mask.detach().cpu().numpy(), gamma=GAMMA**REWARD_STEPS)
proj_distr_v = torch.tensor(proj_distr).to(device)
这个投影函数有点复杂,完全与第八章中详细解释的实现相同。简要地说,它计算最后状态的概率分布的变换,该分布根据即时奖励进行了偏移,并按折扣因子进行缩放。结果是我们希望网络返回的目标概率分布。由于 PyTorch 中没有通用的交叉熵损失函数,我们通过将输入概率的对数与目标概率相乘来手动计算它:
prob_dist_v = -F.log_softmax(crt_distr_v, dim=1) * proj_distr_v
critic_loss_v = prob_dist_v.sum(dim=1).mean()
critic_loss_v.backward()
crt_opt.step()
演员的训练要简单得多,唯一与 DDPG 方法的区别是使用模型的 distr_to_q() 方法,将概率分布转换为单一的均值 Q 值,使用支持原子:
act_opt.zero_grad()
cur_actions_v = act_net(states_v)
crt_distr_v = crt_net(states_v, cur_actions_v)
actor_loss_v = -crt_net.distr_to_q(crt_distr_v)
actor_loss_v = actor_loss_v.mean()
actor_loss_v.backward()
act_opt.step()
结果
D4PG 方法在收敛速度和获得的奖励方面都表现最好。经过 20 小时的训练,大约 350 万次观测后,它能够达到 17.912 的平均测试奖励。鉴于“gym 环境阈值”是 15.0(这是环境认为任务已解决时的分数),这是一个很棒的结果。而且这个结果还可以进一步提高,因为步骤数不到 1,000(这是环境的时间限制)。这意味着我们的模型因内部环境检查而被提前终止。在图 15.10 和图 15.11 中,我们展示了训练和测试的指标。

图 15.10:训练回合的奖励(左)和步骤(右)

图 15.11:测试回合的奖励(左)和步骤(右)
为了比较实现的方法,图 15.12 包含了三种方法的测试回合指标。

图 15.12:测试回合的奖励(左)和步骤(右)
要检查模型的“实际表现”,你可以使用相同的工具 05_play_ddpg.py(因为演员网络结构与 DDPG 中的相同)。现在,最佳模型生成的视频时长为 33 秒,最终得分为 17.827。你可以在这里观看:youtu.be/XZdVrGPaI0M。
需要尝试的事项
以下是可以帮助你提升对该主题理解的事项:
-
在 D4PG 代码中,我使用了一个简单的重放缓冲区,这足以比 DDPG 获得更好的改进。你可以尝试将示例切换为优先重放缓冲区,就像我们在第八章中做的那样。
-
周围有很多有趣且具有挑战性的环境。例如,你可以从其他 PyBullet 环境开始,但也有 DeepMind 控制套件(Tassa 等人,DeepMind 控制套件,arXiv abs/1801.00690(2018)),Gym 中的基于 MuJoCo 的环境,以及其他许多环境。
-
你可以尝试参加非常具有挑战性的“学习跑步”竞赛,该竞赛源自 NIPS-2017(并且在 2018 年和 2019 年也有举办,问题更具挑战性),在这个竞赛中,你将获得一个人体仿真器,而你的智能体需要弄清楚如何使其运动。
总结
在本章中,我们快速浏览了使用强化学习方法进行连续控制的非常有趣的领域,并在四足机器人这一问题上检查了三种不同的算法。在我们的训练中,我们使用了一个仿真器,但 Ghost Robotics 公司制造了这种机器人的真实模型。(你可以在 YouTube 上查看这个很酷的视频:youtu.be/bnKOeMoibLg。)我们将三种训练方法应用于这个环境:A2C,DDPG 和 D4PG(后者显示出了最好的结果)。
在接下来的章节中,我们将继续探索连续动作领域,并查看一组不同的改进方法:信任域扩展。
第十六章:信任区域方法
在本章中,我们将探讨一些改进随机策略梯度方法稳定性的策略。已有一些尝试使得策略改进更加稳定,本章我们将重点介绍三种方法:
-
近端策略优化(PPO)
-
信任区域策略优化(TRPO)
-
使用 Kronecker 分解信任区域的优势演员-评论员方法(A2C).
此外,我们还将这些方法与一种相对较新的离策略方法——软演员-评论员方法(SAC)进行比较,SAC 是深度确定性策略梯度方法(DDPG)的演变,DDPG 方法在第十五章中有详细描述。为了与 A2C 基准方法进行比较,我们将使用所谓的“运动训练环境”中的几个环境——这些环境与 Farama Gymnasium 一起提供(使用 MuJoCo 和 PyBullet)。我们还将对 PyBullet 和 MuJoCo 进行正面比较(我们在第十五章中讨论了这些内容)。
我们将要讨论的方法的目的是提高训练过程中策略更新的稳定性。这里存在一个两难困境:一方面,我们希望尽可能快地训练,在随机梯度下降(SGD)更新过程中采取较大的步伐。另一方面,策略的大幅更新通常是个坏主意。策略是一个高度非线性的事物,因此大幅更新可能会破坏我们刚刚学习到的策略。
在强化学习(RL)领域,情况可能会变得更糟,因为你无法通过后续的更新从一个不好的策略更新中恢复过来。相反,糟糕的策略会提供不良的经验样本,这些样本会在后续的训练步骤中使用,可能会彻底破坏我们的策略。因此,我们要尽一切可能避免进行大的更新。一个简单的解决方案是使用较小的学习率,在随机梯度下降(SGD)过程中采取小步伐,但这会显著减慢收敛速度。
为了打破这个恶性循环,研究人员已做出多次尝试,评估我们的策略更新对未来结果的影响。一个流行的方法是信任区域优化扩展,它限制了优化过程中采取的步伐,从而限制对策略的影响。其主要思想是在损失优化过程中通过检查旧策略和新策略之间的 Kullback-Leibler(KL)散度来防止剧烈的策略更新。当然,这只是一个非正式的解释,但它可以帮助你理解这一思想,特别是因为这些方法相当数学化(尤其是 TRPO)。
环境
本书的早期版本使用了来自 OpenAI 的 Roboschool 库(openai.com/index/roboschool)来说明信任区域方法。但是最终,OpenAI 停止了对 Roboschool 的支持并弃用了该库。
但其他来源仍然提供这些环境:
-
PyBullet:我们在前一章中实验过的物理模拟器,包含支持 Gym 的各种环境。PyBullet 可能有些过时(最新版本发布于 2022 年),但通过一些小修改,它仍然可以正常工作。
-
Farama Gymnasium MuJoCo 环境:MuJoCo 是我们在第十五章中讨论的物理模拟器。自从它开源以来,MuJoCo 已被应用到多个产品中,包括 Gymnasium,它提供了多个环境:
gymnasium.farama.org/environments/mujoco/。
在本章中,我们将探讨两个问题:HalfCheetah-v4,模拟一个两条腿的生物,和 Ant-v4,模拟一个四条腿的生物。它们的状态和动作空间与我们在第十五章中看到的 Minitaur 环境非常相似:状态包括关节的特征,而动作是这些关节的激活。每个问题的目标是尽可能地移动,同时最小化能量消耗。下图展示了这两个环境的截图:

图 16.1:猎豹和蚂蚁环境的截图
在我们的实验中,我们将使用 PyBullet 和 MuJoCo 对这两个模拟器进行速度和训练动态方面的比较(但请注意,PyBullet 和 MuJoCo 环境的内部结构可能不同,因此训练动态的比较可能并不总是可靠的)。要安装带有 MuJoCo 扩展的 Gymnasium,你需要在 Python 环境中运行以下命令:pip install gymnasium[mujoco]==0.29.0。
A2C 基准
为了建立基准结果,我们将以与前一章非常相似的方式使用 A2C 方法。完整的源代码位于 Chapter16/01_train_a2c.py 和 Chapter16/lib/model.py 文件中。这个基准与我们之前使用的版本有一些区别:
-
训练过程中使用 16 个并行环境来收集经验。
-
它们在模型结构和我们进行探索的方式上有所不同。
实现
为了说明这个基准和之前讨论的版本之间的区别,我们来看看模型和代理类。
Actor 和 Critic 被放置在不同的网络中,且不共享权重。它们遵循第十五章中使用的方法,我们的 Critic 估计动作的均值和方差。然而,现在,方差不再是基础网络的单独头部;它只是模型的一个参数。这个参数将在训练过程中通过 SGD 进行调整,但它不依赖于观察结果。
Actor 网络有两个 64 个神经元的隐藏层,每个层都有 tanh 非线性(将输出压缩到−1…1 范围内)。方差被建模为一个单独的网络参数,并被解释为标准差的对数:
HID_SIZE = 64
class ModelActor(nn.Module):
def __init__(self, obs_size: int, act_size: int):
super(ModelActor, self).__init__()
self.mu = nn.Sequential(
nn.Linear(obs_size, HID_SIZE),
nn.Tanh(),
nn.Linear(HID_SIZE, HID_SIZE),
nn.Tanh(),
nn.Linear(HID_SIZE, act_size),
nn.Tanh(),
)
self.logstd = nn.Parameter(torch.zeros(act_size))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.mu(x)
评论网络也有两个相同大小的隐藏层,并且只有一个输出值,即 V(s)的估计值,这是状态的折扣值:
class ModelCritic(nn.Module):
def __init__(self, obs_size: int):
super(ModelCritic, self).__init__()
self.value = nn.Sequential(
nn.Linear(obs_size, HID_SIZE),
nn.ReLU(),
nn.Linear(HID_SIZE, HID_SIZE),
nn.ReLU(),
nn.Linear(HID_SIZE, 1),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.value(x)
将状态转换为动作的智能体也仅通过从状态中获得预测均值,并根据当前 logstd 参数的值应用具有方差的噪声来工作:
class AgentA2C(ptan.agent.BaseAgent):
def __init__(self, net, device: torch.device):
self.net = net
self.device = device
def __call__(self, states: ptan.agent.States, agent_states: ptan.agent.AgentStates):
states_v = ptan.agent.float32_preprocessor(states)
states_v = states_v.to(self.device)
mu_v = self.net(states_v)
mu = mu_v.data.cpu().numpy()
logstd = self.net.logstd.data.cpu().numpy()
rnd = np.random.normal(size=logstd.shape)
actions = mu + np.exp(logstd) * rnd
actions = np.clip(actions, -1, 1)
return actions, agent_states
结果
训练工具 01_train_a2c.py 可以以两种不同的模式启动:使用 PyBullet 作为物理模拟器(无需额外的命令行选项)或使用 MuJoCo(如果提供了--mujoco 参数)。
默认情况下,使用 HalfCheetah 环境,它模拟了一个可以用腿跳跃的平地双足生物。通过-e ant 选项,你可以切换到 Ant 环境,这是一个三维的四足蜘蛛。你还可以尝试 Gymnasium 和 PyBullet 随附的其他环境,但这需要调整 common.py 模块。
PyBullet 中 HalfCheetah 的结果如图 16.2 所示。我机器上的表现(使用 GPU)在训练过程中大约是 1,600 帧每秒,因此 100M 的训练步骤总共花费了 20 小时。

图 16.2:PyBullet 中 HalfCheetah 训练过程中的奖励(左)和测试奖励(右)
动力学表明,通过给予优化更多时间,策略可能会进一步改进,但对于我们进行方法比较的目的来说,现有的结果应该足够。当然,如果你感兴趣且时间充裕,可以运行更长时间,找到策略停止改进的点。根据研究论文,HalfCheetah 的最高分数大约在 4,000 到 5,000 之间。
要使用 MuJoCo 作为物理仿真引擎,必须使用--mujoco 命令行选项启动训练。MuJoCo 的性能为 5,100 帧每秒,比 PyBullet 快三倍,这非常棒。此外,训练的动态性更好,因此在 90M 训练步骤(大约需要 5 小时)中,模型得到了 4,500 的奖励。MuJoCo 的图形显示在图 16.3 中:

图 16.3:MuJoCo 中 HalfCheetah 训练过程中的奖励(左)和测试奖励(右)
差异可以通过更准确的仿真来解释,但也可以归因于观察空间的不同和底层模型的差异。PyBullet 的模型为智能体提供了 26 个观察参数,而 MuJoCo 只有 17 个,因此这两个模型并不完全相同。
要在 Ant 环境中测试我们的模型,必须将-e ant 命令行选项传递给训练过程。该模型更为复杂(由于模型的三维特性和使用了更多的关节),因此仿真速度较慢。在 PyBullet 上,速度约为 1,400 帧每秒。在 MuJoCo 上,速度为 2,500 帧每秒。
MuJoCo Ant 环境还额外检查“健康状态”——如果模拟生物的倾斜角度超过某个特定角度,回合将被终止。默认启用此检查,并且它对训练有非常负面的影响——在训练的早期阶段,我们的方法无法弄清楚如何让蚂蚁站立起来。环境中的奖励是旅行的距离,但由于这个提前终止,我们的训练没有机会发现这一点。结果,训练过程永远停滞在局部最小值中,无法取得进展。为了克服这个问题,我们需要通过传递--no-unhealthy 命令行选项来禁用此健康检查(仅在 MuJoCo 训练中需要执行此操作)。
原则上,您可以实现更高级的探索方法,如 OU 过程(在第十五章中讨论)或其他方法(在第十八章中讨论)来解决我们刚刚讨论的问题。
Ant 环境的训练结果如图 16.4 和图 16.5 所示。

图 16.4:PyBullet 上 Ant 训练过程中的奖励(左)与测试奖励(右)

图 16.5:MuJoCo 上 Ant 训练过程中的奖励(左)与测试奖励(右)
正如您从图 16.5 中的 MuJoCo 图表中看到的,测试奖励在训练的前 1 亿步几乎没有增加,但随后增长到 5,000 分(最佳模型在测试中得到了 5,380 分)。这个结果相当令人印象深刻。根据paperswithcode.com网站的数据,Ant 在 MuJoCo 环境中的最新技术水平是 4,362.9,由 IQ-Learn 在 2021 年获得:paperswithcode.com/sota/mujoco-games-on-ant。
视频记录
与上一章一样,存在一个工具可以基准测试训练好的模型并录制代理的动作视频。由于本章中的所有方法共享相同的演员网络,因此该工具对本章所示的所有方法都是通用的:02_play.py。
在训练过程中,您需要传递存储在saves目录中的模型文件,通过-e ant 命令行更改环境,并使用--mujoco 参数启用 MuJoCo 引擎。这一点很重要,因为 PyBullet 和 MuJoCo 中的相同环境有不同的观察量,因此物理引擎必须与模型匹配。
您可以找到最佳 A2C 模型的单独视频,网址如下:
-
HalfCheetah 在 PyBullet 上的表现(得分 2,189):
youtu.be/f3ZhjnORQm0 -
HalfCheetah 在 MuJoCo 上的表现(得分 4,718):
youtube.com/shorts/SpaWbS0hM8I -
PyBullet 上的 Ant(得分 2,425):
youtu.be/SIUM_Q24zSk -
Ant 在 MuJoCo 上的表现(得分 5380):
youtube.com/shorts/mapOraGKtG0
PPO
PPO 方法来自 OpenAI 团队,在 TRPO 之后提出,TRPO 是 2015 年提出的。然而,我们将从 PPO 开始,因为它比 TRPO 简单得多。它最早是在 2017 年由 Schulman 等人提出的论文《Proximal Policy Optimization Algorithms》中提出的[Sch+17]。
相对于经典的 A2C 方法,核心的改进在于估计策略梯度时所使用的公式发生了变化。PPO 方法并不是使用所采取动作的对数概率的梯度,而是使用了一个不同的目标:新旧策略之间的比率,按优势进行缩放。
数学形式中,A2C 的目标可以写作这样
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq60.png)
这意味着我们对模型𝜃的梯度被估计为策略π的对数与优势 A 的乘积。
在 PPO 中提出的新目标是以下内容:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq55.png)
改变目标函数的原因与第四章中涉及的交叉熵方法相同:重要性采样。然而,如果我们只是盲目地开始最大化这个值,它可能会导致策略权重的非常大更新。为了限制更新,采用了剪切目标。如果我们将新旧策略之间的比率写为
,那么剪切目标可以写为
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq57.png)
这个目标将旧策略和新策略之间的比率限制在区间[1 −𝜖,1 + 𝜖]内,因此通过变化𝜖,我们可以限制更新的大小。
与 A2C 方法的另一个区别在于我们如何估计优势。在 A2C 论文中,从 T 步的有限视野估计中获得的优势是以下形式
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq58.png)
在 PPO 论文中,作者使用了更一般的估计方法
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq59.png)
其中σ[t] = r[t] + γV (s[t+1]) −V (s[t])。
原始的 A2C 估计是提出的方法的一个特例,当λ = 1 时。PPO 方法还使用了稍微不同的训练程序:从环境中获取一长串样本,然后在执行多个训练周期之前,估算整个序列的优势。
实现
示例的代码被分布在两个源代码文件中:Chapter16/04_train_ppo.py 和 Chapter16/lib/model.py。演员、评论员和代理类与我们在 A2C 基线中的类完全相同。
区别在于训练程序和我们计算优势的方式,但让我们从超参数开始:
GAMMA = 0.99
GAE_LAMBDA = 0.95
TRAJECTORY_SIZE = 2049
LEARNING_RATE_ACTOR = 1e-5
LEARNING_RATE_CRITIC = 1e-4
PPO_EPS = 0.2
PPO_EPOCHES = 10
PPO_BATCH_SIZE = 64
GAMMA 的值已经很熟悉,但 GAE_LAMBDA 是一个新的常数,用于指定优势估计中的λ因子。作者在 PPO 论文中选择了 0.95 的值。
该方法假设在每次子迭代中将从环境中获得大量的过渡。(如本节前面所述,描述 PPO 时提到,在训练过程中,它对采样的训练批次执行多次 epoch。)我们还为演员和评论者使用两个不同的优化器(因为它们没有共享权重)。
对于每一批 TRAJECTORY_SIZE 样本,我们执行 PPO_EPOCHES 次 PPO 目标的迭代,每次使用 64 个样本的小批量。值 PPO_EPS 指定新旧策略比率的裁剪值。以下函数接受带有步骤的轨迹,并计算演员的优势值和评论者训练的参考值。我们的轨迹不是单个回合,而是可以由多个回合连接而成:
def calc_adv_ref(trajectory: tt.List[ptan.experience.Experience],
net_crt: model.ModelCritic, states_v: torch.Tensor, gamma: float,
gae_lambda: float, device: torch.device):
values_v = net_crt(states_v)
values = values_v.squeeze().data.cpu().numpy()
第一阶段,我们要求评论者将状态转换为值。
下一个循环将获得的值与经验点结合起来:
last_gae = 0.0
result_adv = []
result_ref = []
for val, next_val, (exp,) in zip(
reversed(values[:-1]), reversed(values[1:]), reversed(trajectory[:-1])):
对于每个轨迹步骤,我们需要当前值(从当前状态获得)和后续步骤的值(以使用 Bellman 方程进行估计)。我们还以反向顺序遍历轨迹,以便在一步中计算更近期的优势值。
if exp.done_trunc:
delta = exp.reward - val
last_gae = delta
else:
delta = exp.reward + gamma * next_val - val
last_gae = delta + gamma * gae_lambda * last_gae
在每一步中,我们的行动依赖于该步骤的 done_trunc 标志。如果这是回合的终止步骤,我们不需要考虑之前的奖励。(记住,我们是以反向顺序处理轨迹的。)因此,我们在该步骤的 delta 值只是即时奖励减去该步骤预测的值。如果当前步骤不是终止步骤,delta 将等于即时奖励加上后续步骤的折扣值,再减去当前步骤的值。在经典的 A2C 方法中,这个 delta 用作优势估计,但这里使用的是平滑版本,因此优势估计(在 last_gae 变量中跟踪)是通过折扣因子γ^λ计算的所有 delta 的总和。
该函数的目标是为评论者计算优势值和参考值,因此我们将它们保存在列表中:
result_adv.append(last_gae)
result_ref.append(last_gae + val)
在函数的最后,我们将值转换为张量并返回:
adv_v = torch.FloatTensor(np.asarray(list(reversed(result_adv))))
ref_v = torch.FloatTensor(np.asarray(list(reversed(result_ref))))
return adv_v.to(device), ref_v.to(device)
在训练循环中,我们使用 PTAN 库中的 ExperienceSource(steps_count=1)类收集所需大小的轨迹。此配置提供来自环境的单个步骤,存储在 Experience 数据类实例中,其中包含状态、动作、奖励和终止标志。以下是训练循环中的相关部分:
trajectory.append(exp)
if len(trajectory) < TRAJECTORY_SIZE:
continue
traj_states = [t[0].state for t in trajectory]
traj_actions = [t[0].action for t in trajectory]
traj_states_v = torch.FloatTensor(np.asarray(traj_states))
traj_states_v = traj_states_v.to(device)
traj_actions_v = torch.FloatTensor(np.asarray(traj_actions))
traj_actions_v = traj_actions_v.to(device)
traj_adv_v, traj_ref_v = common.calc_adv_ref(
trajectory, net_crt, traj_states_v, GAMMA, GAE_LAMBDA, device=device)
当我们拥有足够大的轨迹用于训练时(由 TRAJECTORY_SIZE 超参数给出),我们将状态和所采取的动作转换为张量,并使用已经描述的函数来获取优势和参考值。尽管我们的轨迹相当长,但我们的环境观察足够小,因此可以一次性处理我们的批次。在 Atari 帧的情况下,可能会导致 GPU 内存错误。接下来的步骤中,我们计算所采取动作的概率的对数。这个值将作为目标函数中 PPO 的 π[𝜃[old]]。此外,我们还将优势的均值和方差归一化,以提高训练稳定性:
mu_v = net_act(traj_states_v)
old_logprob_v = model.calc_logprob(mu_v, net_act.logstd, traj_actions_v)
traj_adv_v = traj_adv_v - torch.mean(traj_adv_v)
traj_adv_v /= torch.std(traj_adv_v)
接下来的两行删除了轨迹中的最后一个条目,以反映我们的优势和参考值比轨迹长度少一步的事实(因为我们在 calc_adv_ref 函数内部的循环中移动了值):
trajectory = trajectory[:-1]
old_logprob_v = old_logprob_v[:-1].detach()
当所有准备工作完成后,我们对轨迹进行多个训练周期。对于每个批次,我们从相应的数组中提取部分数据,并分别进行评论员和演员训练:
for epoch in range(PPO_EPOCHES):
for batch_ofs in range(0, len(trajectory), PPO_BATCH_SIZE):
batch_l = batch_ofs + PPO_BATCH_SIZE
states_v = traj_states_v[batch_ofs:batch_l]
actions_v = traj_actions_v[batch_ofs:batch_l]
batch_adv_v = traj_adv_v[batch_ofs:batch_l]
batch_adv_v = batch_adv_v.unsqueeze(-1)
batch_ref_v = traj_ref_v[batch_ofs:batch_l]
batch_old_logprob_v = old_logprob_v[batch_ofs:batch_l]
要训练评论员,我们需要做的就是计算之前计算好的参考值的均方误差(MSE)损失:
opt_crt.zero_grad()
value_v = net_crt(states_v)
loss_value_v = F.mse_loss(value_v.squeeze(-1), batch_ref_v)
loss_value_v.backward()
opt_crt.step()
在演员训练中,我们最小化了负剪切目标:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq62.png)
为了实现这一点,我们使用以下代码:
opt_act.zero_grad()
mu_v = net_act(states_v)
logprob_pi_v = model.calc_logprob(mu_v, net_act.logstd, actions_v)
ratio_v = torch.exp(logprob_pi_v - batch_old_logprob_v)
surr_obj_v = batch_adv_v * ratio_v
c_ratio_v = torch.clamp(ratio_v, 1.0 - PPO_EPS, 1.0 + PPO_EPS)
clipped_surr_v = batch_adv_v * c_ratio_v
loss_policy_v = -torch.min(surr_obj_v, clipped_surr_v).mean()
loss_policy_v.backward()
opt_act.step()
结果
在我们两个测试环境中训练后,PPO 方法的收敛速度明显快于 A2C 方法。在 PyBullet 上使用 HalfCheetah 时,PPO 在 8 小时的训练和 25M 训练步后,达到了 1,800 的平均训练奖励和 2,500 的测试奖励。A2C 在 110M 步和 20 小时后得到了较低的结果。图 16.6 显示了比较图表。

图 16.6:在 PyBullet 上训练期间的奖励(左)和测试奖励(右)对于 HalfCheetah
但在使用 MuJoCo 的 HalfCheetah 上,情况正好相反——PPO 的增长速度要慢得多,我在 50M 训练步(12 小时)后停止了训练。图 16.7 显示了这些图表。

图 16.7:在 MuJoCo 上训练期间的奖励(左)和测试奖励(右)对于 HalfCheetah
在查看了模型的视频后(稍后会提供链接),我们可能猜测低分的原因——我们的智能体学会了如何将猎豹翻转到背部并在这个位置前进。在训练过程中,它无法从这个次优的“局部最大值”中脱离出来。很可能多次运行训练会得到更好的策略。另一种解决方法可能是优化超参数。同样,这也是你可以尝试实验的内容。
在 Ant 环境中,PPO 在 PyBullet 和 MuJoCo 上的表现都优于 A2C,并且能够几乎是 A2C 的两倍速度达到相同的奖励水平。这个对比展示在图 16.8 和图 16.9 中:

图 16.8:PyBullet 上 Ant 训练过程中的奖励(左)和测试奖励(右)

图 16.9:MuJoCo 上 Ant 训练过程中的奖励(左)和测试奖励(右)
如之前所述,您可以使用 02_play.py 工具来基准测试保存的模型,并录制学习到的策略在实际中的表现。这是我训练实验中最佳模型的列表:
-
PyBullet 上的 HalfCheetah(得分 2,567):
youtu.be/Rai-smyfyeE。代理学会了如何用后腿做远跳。 -
MuJoCo 上的 HalfCheetah(得分 1,623):
youtube.com/shorts/VcyzNtbVzd4。这是一段非常有趣的视频:猎豹翻身并以这种方式向前移动。 -
PyBullet 上的 Ant(得分 2,560):
youtu.be/8lty_Mdjnfs。Ant 策略比 A2C 好得多——它能稳定地向前移动。 -
MuJoCo 上的 Ant(得分 5,108):
youtube.com/shorts/AcXxH2f_KWs。这个模型更快;很可能,MuJoCo 模型中的蚂蚁重量低于 PyBullet 模型中的蚂蚁。
TRPO
TRPO 由伯克利研究人员于 2015 年在 Schulman 等人的论文《信任区域策略优化》(Trust region policy optimization)中提出[Sch15]。这篇论文是提升随机策略梯度优化的稳定性和一致性的一个步骤,并在各种控制任务中取得了良好的结果。
不幸的是,论文和方法相当数学化,因此理解细节可能比较困难。实现部分也存在同样的问题,它使用共轭梯度法来高效地解决约束优化问题。
作为第一步,TRPO 方法将状态的折扣访问频率定义如下:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq63.png)
在这个方程中,P(s[i] = s) 等于在采样轨迹的第 i 个位置上遇到状态 s 的采样概率。
然后,TRPO 将优化目标定义为
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq64.png)
其中
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq65.png)
是策略的期望折扣奖励,π̃ = arg max[a]Aπ 定义了确定性策略。为了解决大规模策略更新的问题,TRPO 对策略更新定义了额外的约束,该约束表示为旧策略与新策略之间的最大 KL 散度,形式如下:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq66.png)
提醒一下,KL 散度衡量的是概率分布之间的相似度,计算公式如下:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq67.png)
我们在第四章和第十一章中遇到过 KL 散度。
实现
GitHub 或其他开源代码库中大多数可用的 TRPO 实现都非常相似,这可能是因为它们都源自最初的 John Schulman TRPO 实现:github.com/joschu/modular_rl。我版本的 TRPO 也没有太大不同,使用了该代码库中的核心函数,这些函数实现了共轭梯度方法(TRPO 用于解决约束优化问题):github.com/ikostrikov/pytorch-trpo。
完整的例子可以在 03_train_trpo.py 和 lib/trpo.py 中找到,训练循环与 PPO 的示例非常相似:我们采样预定义长度的轨迹转移,并使用 PPO 部分讨论的平滑公式计算优势估计(历史上,这个估计最早是在 TRPO 论文中提出的)。接下来,我们用计算出的参考值进行一次使用 MSE 损失的评论者训练步骤,并执行一次 TRPO 更新步骤,这一步骤包括使用共轭梯度方法找到我们应走的方向,并在该方向上进行线性搜索,找到一个保持所需 KL 散度的步长。
以下是执行这两个步骤的训练循环部分:
opt_crt.zero_grad()
value_v = net_crt(traj_states_v)
loss_value_v = F.mse_loss(value_v.squeeze(-1), traj_ref_v)
loss_value_v.backward()
opt_crt.step()
为了执行 TRPO 步骤,我们需要提供两个函数:第一个函数计算当前演员策略的损失,这个损失使用新的和旧的策略之间的比例,乘以优势估计。第二个函数则计算旧策略和当前策略之间的 KL 散度:
def get_loss():
mu_v = net_act(traj_states_v)
logprob_v = model.calc_logprob(mu_v, net_act.logstd, traj_actions_v)
dp_v = torch.exp(logprob_v - old_logprob_v)
action_loss_v = -traj_adv_v.unsqueeze(dim=-1)*dp_v
return action_loss_v.mean()
def get_kl():
mu_v = net_act(traj_states_v)
logstd_v = net_act.logstd
mu0_v = mu_v.detach()
logstd0_v = logstd_v.detach()
std_v = torch.exp(logstd_v)
std0_v = std_v.detach()
v = (std0_v ** 2 + (mu0_v - mu_v) ** 2) / (2.0 * std_v ** 2)
kl = logstd_v - logstd0_v + v - 0.5
return kl.sum(1, keepdim=True)
trpo.trpo_step(net_act, get_loss, get_kl, args.maxkl,
TRPO_DAMPING, device=device)
换句话说,PPO 方法实际上就是 TRPO,它使用简单的策略比例剪切来限制策略更新,而不是使用复杂的共轭梯度和线性搜索。
结果
在 HalfCheetah 环境中,TRPO 能够获得比 PPO 和 A2C 更好的奖励。在图 16.10 中,展示了 PyBullet 训练的结果。在 MuJoCo 上,结果更加令人印象深刻——最佳奖励超过了 5000。MuJoCo 的图示见图 16.11:

图 16.10:PyBullet 环境中 HalfCheetah 的训练奖励(左)和测试奖励(右)

图 16.11:MuJoCo 环境中 HalfCheetah 的训练奖励(左)和测试奖励(右)
不幸的是,Ant 环境显示出远不如预期的稳定收敛性。图 16.12 和图 16.13 比较了 A2C 和 TRPO 在训练和测试奖励上的表现:

图 16.12:PyBullet 环境中 Ant 的训练奖励(左)和测试奖励(右)

图 16.13:MuJoCo 环境中 Ant 的训练奖励(左)和测试奖励(右)
最佳动作的视频记录可以像以前一样进行。这里有一些最佳 TRPO 模型的视频:
-
PyBullet 中的半豹(得分 2,419):
youtu.be/NIfkt2lVT74。前腿关节没有使用。 -
MuJoCo 中的半豹(得分 5,753):
youtube.com/shorts/FLM2t-XWDLc?feature=share。这真是一只飞快的豹子! -
PyBullet 中的蚂蚁(得分 834):
youtu.be/Ny1WBPVluNQ。训练卡在了一个“静止不动”的局部最小值。 -
MuJoCo 中的蚂蚁(得分 993):
youtube.com/shorts/9sybZGvXQFs。与 PyBullet 相同——智能体只是站着不动,哪里也不去。
ACKTR
我们将比较的第三种方法 ACKTR,采用了不同的方式来解决 SGD 稳定性问题。在吴等人于 2017 年发表的论文《一种用于深度强化学习的可扩展信任域方法,基于克罗内克近似》(Scalable trust-region method for deep reinforcement learning using Kronecker-factored approximation)[Wu+17]中,作者结合了二阶优化方法和信任域方法。
二阶方法的思想是通过对优化函数进行二阶导数(换句话说,就是它的曲率)的计算,来改进传统的 SGD,从而提高优化过程的收敛性。为了让事情更复杂,处理二阶导数通常需要构建并反转一个 Hessian 矩阵,而这个矩阵可能非常庞大,因此实际的方法通常会以某种方式对其进行近似。这个领域目前在研究中非常活跃,因为开发稳健且可扩展的优化方法对于整个机器学习领域至关重要。
一种二阶方法叫做克罗内克近似曲率(K-FAC),由 James Martens 和 Roger Grosse 在他们 2015 年发表的论文《使用克罗内克近似曲率优化神经网络》(Optimizing neural networks with Kronecker-factored approximate curvature)[MG15]中提出。然而,详细描述这种方法远远超出了本书的范围。
实现
目前这个方法的实现并不多,而且没有任何一个是 PyTorch 的官方实现(很遗憾)。据我所知,有两个版本的 K-FAC 优化器可以与 PyTorch 一起使用;一个来自 Ilya Kostrikov(github.com/ikostrikov/pytorch-a2c-ppo-acktr),另一个来自 Nicholas Gao(github.com/n-gao/pytorch-kfac)。我只试过第一个版本;你可以尝试第二个版本。K-FAC 也有 TensorFlow 版本,随 OpenAI Baselines 提供,但将其移植并在 PyTorch 上测试可能会有难度。
对于我的实验,我采用了 Kostrikov 的 K-FAC 实现并将其适配到现有代码中,这需要替换优化器并额外调用 backward()来收集 Fisher 信息。评论员的训练方式与 A2C 相同。完整的示例代码位于 05_train_acktr.py 中,本文未展示,因为它基本上与 A2C 相同。唯一的区别是使用了不同的优化器。
结果
总体来看,ACKTR 方法在这两种环境和物理引擎中都非常不稳定。这可能是由于超参数的调优不足,或者实现中存在一些 bug。
HalfCheetah 实验的结果如图 16.14 和图 16.15 所示。

图 16.14:训练期间的奖励(左)和在 PyBullet 上测试的奖励(右),针对 HalfCheetah

图 16.15:训练期间的奖励(左)和在 MuJoCo 上测试的奖励(右),针对 HalfCheetah
在 Ant 环境中,ACKTR 方法在 PyBullet 上的表现较差,与在 MuJoCo 上训练相比没有奖励改进。图 16.16 展示了 PyBullet 的图表。

图 16.16:训练期间的奖励(左)和在 PyBullet 上测试的奖励(右),针对 Ant
SAC
在最后一节中,我们将检查一种相对较新的方法,称为 SAC,该方法由伯克利研究人员提出,并在 2018 年 Haarnoja 等人发布的论文《Soft actor-critic: Off-policy maximum entropy deep reinforcement learning》中介绍[Haa+18]。
目前,这被认为是解决连续控制问题的最佳方法之一,并且得到了广泛应用。该方法的核心思想与 DDPG 方法更接近,而不是 A2C 策略梯度方法。我们将与 PPO 的表现进行直接比较,PPO 长期以来被认为是连续控制问题的标准方法。
SAC 方法的核心思想是熵正则化,它在每个时间戳上添加一个与该时间戳策略熵成正比的奖励。从数学符号表示,我们要寻找的策略是:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq68.png)
这里,H(P) = 𝔼 [x∼P] [−log P(x)] 是分布 P 的熵。换句话说,我们通过奖励智能体进入熵值最大化的状态,类似于第十八章中提到的高级探索方法。此外,SAC 方法融合了剪切双 Q 技巧,在此技巧中,除了值函数外,我们还学习两个预测 Q 值的网络,并选择其中的最小值进行 Bellman 近似。研究人员认为,这有助于解决训练过程中 Q 值过度估计的问题。这个问题在第八章中已讨论过,但采用了不同的方法进行处理。
因此,总共我们训练四个网络:策略网络π(s)、值网络 V(s,a)和两个 Q 网络 Q1 与 Q2。对于值网络 V(s,a),使用目标网络。因此,SAC 训练流程总结如下:
-
Q 网络通过使用目标值网络进行 Bellman 近似,使用 MSE 目标进行训练:yq = r+γV tgt(对于非终止步骤)
-
V 网络通过使用 MSE 目标和以下目标进行训练:yv = min[i=1,2]Qi −α log π𝜃,其中ã是从策略π𝜃中采样的
-
策略网络π[𝜃]通过最大化以下目标来以 DDPG 风格进行训练:Q1) −α log π𝜃|s),其中ã[𝜃]是从π𝜃中采样的
实现
SAC 方法的实现位于 06_train_sac.py 中。该模型由以下网络组成,这些网络在 lib/model.py 中定义:
-
ModelActor:这是我们在本章前面示例中使用的相同策略。由于策略方差没有通过状态来参数化(logstd 字段不是网络,只是一个张量),因此训练目标并不完全符合 SAC 方法。从一方面来说,这可能会影响收敛性和性能,因为 SAC 方法的核心思想是熵正则化,而没有参数化方差的话无法实现这一点。另一方面,它减少了模型中的参数数量。如果你感兴趣的话,可以通过对策略进行参数化方差扩展该示例,并实现一个完整的 SAC 方法。
-
ModelCritic:这是与前面示例相同的值网络。
-
ModelSACTwinQ:这两个网络将状态和动作作为输入,预测 Q 值。
实现该方法的第一个函数是 unpack_batch_sac(),它定义在 lib/common.py 中。它的目标是获取轨迹步骤的批次并为 V 网络和双 Q 网络计算目标值:
@torch.no_grad()
def unpack_batch_sac(batch: tt.List[ptan.experience.ExperienceFirstLast],
val_net: model.ModelCritic, twinq_net: model.ModelSACTwinQ,
policy_net: model.ModelActor, gamma: float, ent_alpha: float,
device: torch.device):
states_v, actions_v, ref_q_v = unpack_batch_a2c(batch, val_net, gamma, device)
mu_v = policy_net(states_v)
act_dist = distr.Normal(mu_v, torch.exp(policy_net.logstd))
acts_v = act_dist.sample()
q1_v, q2_v = twinq_net(states_v, acts_v)
ref_vals_v = torch.min(q1_v, q2_v).squeeze() - \
ent_alpha * act_dist.log_prob(acts_v).sum(dim=1)
return states_v, actions_v, ref_vals_v, ref_q_v
函数的第一步使用已经定义的 unpack_batch_a2c()方法,该方法解包批次,将状态和动作转换为张量,并通过 Bellman 近似计算 Q 网络的参考值。完成此步骤后,我们需要从双 Q 值的最小值减去缩放的熵系数来计算 V 网络的参考值。熵是通过当前策略网络计算的。如前所述,我们的策略具有参数化的均值,但方差是全局的,并不依赖于状态。
在主要训练循环中,我们使用先前定义的函数,并进行三种不同的优化步骤:V 网络、Q 网络和策略网络。以下是 06_train_sac.py 中定义的训练循环的相关部分:
batch = buffer.sample(BATCH_SIZE)
states_v, actions_v, ref_vals_v, ref_q_v = common.unpack_batch_sac(
batch, tgt_crt_net.target_model, twinq_net, act_net, GAMMA,
SAC_ENTROPY_ALPHA, device)
一开始,我们解包批次以获取 Q 和 V 网络的张量和目标。
双 Q 网络通过相同的目标值进行优化:
twinq_opt.zero_grad()
q1_v, q2_v = twinq_net(states_v, actions_v)
q1_loss_v = F.mse_loss(q1_v.squeeze(), ref_q_v.detach())
q2_loss_v = F.mse_loss(q2_v.squeeze(), ref_q_v.detach())
q_loss_v = q1_loss_v + q2_loss_v
q_loss_v.backward()
twinq_opt.step()
评论者网络也通过使用已经计算的目标值和简单的 MSE 目标进行优化:
crt_opt.zero_grad()
val_v = crt_net(states_v)
v_loss_v = F.mse_loss(val_v.squeeze(), ref_vals_v.detach())
v_loss_v.backward()
crt_opt.step()
最后,我们优化行为者网络:
act_opt.zero_grad()
acts_v = act_net(states_v)
q_out_v, _ = twinq_net(states_v, acts_v)
act_loss = -q_out_v.mean()
act_loss.backward()
act_opt.step()
与之前给出的公式相比,代码缺少了熵正则化项,实际上对应的是 DDPG 训练。由于我们的方差不依赖于状态,它可以从优化目标中省略。
结果
我在 HalfCheetah 和 Ant 环境中进行了 9 到 13 小时的 SAC 训练,观察数据为 5M。结果有点矛盾。一方面,SAC 的样本效率和奖励增长动态优于 PPO 方法。例如,SAC 只需 0.5M 观察就能在 HalfCheetah 上达到 900 的奖励,而 PPO 需要超过 1M 观察才能达到相同的策略。在 MuJoCo 环境中,SAC 找到了获得 7,063 奖励的策略,这是一个绝对的记录(展示了该环境上的最先进表现)。
另一方面,由于 SAC 是离策略方法,训练速度较慢,因为我们进行了比传统的在策略方法更多的计算。在我的机器上,5M 帧的 HalfCheetah 训练花费了 10 小时。作为提醒,A2C 在同样时间内完成了 50M 观察。
这展示了在本书中你已多次看到的在策略和离策略方法之间的权衡:如果你的环境反应速度快,且观察数据易得,那么像 PPO 这样的在策略方法可能是最佳选择。但如果你的观察数据难以获得,离策略方法将更有效,但需要更多的计算。
图 16.17 和图 16.18 展示了 HalfCheetah 上的奖励动态:

图 16.17:PyBullet 中 HalfCheetah 的训练奖励(左)和测试奖励(右)

图 16.18:MuJoCo 中 HalfCheetah 的训练奖励(左)和测试奖励(右)
在 Ant 环境中的结果则差得多——根据得分,学习到的策略几乎无法维持。PyBullet 的图像见图 16.19;MuJoCo 的图像见图 16.20:

图 16.19:PyBullet 中 Ant 的训练奖励(左)和测试奖励(右)

图 16.20:MuJoCo 中 Ant 的训练奖励(左)和测试奖励(右)
这里是最佳 SAC 模型的视频:
-
PyBullet 中的 HalfCheetah(得分 1,765):
youtu.be/80afu9OzQ5s。我们的生物在这里显得有点笨拙。 -
MuJoCo 中的 HalfCheetah(得分 7,063):
youtube.com/shorts/0Ywn3LTJxxs。这个结果非常令人印象深刻——一只超快的猎豹。 -
PyBullet 中的 Ant(得分 630):
youtu.be/WHqXJ3VqX4k。在几步之后,蚂蚁因某种原因被卡住了。
总体结果
为了简化方法的比较,我将所有与最佳奖励相关的数据汇总在下面的表格中:
| 方法 | HalfCheetah | Ant |
|---|---|---|
| PyBullet | MuJoCo | |
| A2C | 2,189 | 4,718 |
| PPO | 2,567 | 1,623 |
| TRPO | 2,419 | 5,753 |
| ACKTR | 250 | 3,100 |
| SAC | 1,765 | 7,063 |
表 16.1:总结表
如你所见,没有单一的获胜方法——某些方法在某些环境中表现良好,但在其他环境中效果较差。原则上,我们可以称 A2C 和 PPO 为相当一致的方法,因为它们在各个环境中都能取得不错的结果(PPO 在 MuJoCo 上的“后空翻猎豹”可能归因于不好的初始种子,因此重新训练可能会产生更好的策略)。
总结
在这一章中,我们检查了三种不同的方法,目的是提高随机策略梯度的稳定性,并将它们与 A2C 在两个连续控制问题上的实现进行比较。连同上一章介绍的方法(DDPG 和 D4PG),这些方法是处理连续控制领域的基本工具。最后,我们检查了一种相对较新的离策略方法,它是 DDPG 的扩展:SAC。我们只是触及了这个话题的表面,但这可能是一个很好的起点,可以进一步深入研究。这些方法在机器人技术及相关领域中广泛应用。
在下一章中,我们将转向最近越来越流行的另一类强化学习方法:黑箱或无梯度方法。
加入我们的 Discord 社区
与其他用户、深度学习专家以及作者本人一起阅读本书。提问、为其他读者提供解决方案、通过问我任何问题环节与作者互动,等等。扫描二维码或访问链接加入社区。packt.link/rl

第十七章:强化学习中的黑盒优化
在本章中,我们将再次改变对强化学习(RL)训练的看法,转向所谓的黑盒优化方法。这些方法至少已有十年历史,但最近进行的一些研究表明,它们在大规模 RL 问题中的适用性,并且与价值迭代和策略梯度方法具有竞争力。尽管它们已经有些年头,但在某些情况下,这类方法仍然更为高效。本章将介绍两种黑盒优化方法的例子:
-
进化策略
-
遗传算法
黑盒方法
首先,让我们讨论一下黑盒方法的整体家族,以及它与我们之前讨论过的内容的区别。黑盒优化方法是优化问题的通用方法,它将你正在优化的目标视为黑盒,不对目标的可微性、价值函数、目标的平滑性等做任何假设。唯一的要求是这些方法能够计算适应度函数,这应该为我们提供优化实体的特定实例的适应度度量。这个家族中最简单的一个例子是随机搜索,它是指你随机选择你要寻找的对象(在 RL 中是策略π(a|s)),检查该候选对象的适应度,如果结果足够好(根据某些奖励标准),那么你就完成了。否则,你会一遍又一遍地重复这个过程。尽管这种方法简单,甚至有些天真,特别是与到目前为止你所看到的复杂方法相比,但它是一个很好的例子,能说明黑盒方法的思想。
此外,通过一些修改,正如你很快会看到的,这种简单的方法在效率和生成的策略质量方面,可以与深度 Q 网络(DQN)和策略梯度方法相比较。除此之外,黑盒方法还有一些非常吸引人的特性:
-
它们至少比基于梯度的方法快两倍,因为我们不需要执行反向传播步骤来获得梯度。
-
对于优化目标和作为黑盒处理的策略,几乎没有任何假设。传统方法在奖励函数不平滑或策略中包含随机选择步骤时会遇到困难。而这一切对于黑盒方法来说都不是问题,因为它们对黑盒的内部实现没有太多要求。
-
这些方法通常可以很好地并行化。例如,上述的随机搜索可以轻松扩展到数千个中央处理单元(CPU)或图形处理单元(GPU)并行工作,且彼此之间没有任何依赖关系。对于 DQN 或策略梯度方法而言,情况则不同,因为需要积累梯度并将当前策略传播给所有并行工作者,这会降低并行性。
前述方法的缺点通常是样本效率较低。特别是,使用具有五十万个参数的神经网络(NN)进行的天真随机搜索策略,成功的概率非常低。
进化策略
黑盒优化方法的一类被称为进化策略(ES),它的灵感来源于进化过程。在 ES 中,最成功的个体对搜索的整体方向有最大的影响。这个类别下有许多不同的方法,在本章中,我们将讨论 OpenAI 研究人员 Salimans 等人在他们的论文《进化策略作为强化学习的可扩展替代方法》[Sal+17]中提出的方法,该论文于 2017 年 3 月发布。
ES 方法的基本思想是在每次迭代时,我们对当前的策略参数进行随机扰动,并评估由此产生的策略适应度函数。然后,我们根据相对的适应度函数值调整策略权重。
Salimans 等人使用的具体方法被称为协方差矩阵适应进化策略(CMA-ES),其中执行的扰动是从均值为零、方差为单位矩阵的正态分布中采样的随机噪声。然后,我们计算具有权重等于原始策略权重加上缩放噪声的策略的适应度函数值。接下来,根据得到的值,我们通过将噪声乘以适应度函数值来调整原始策略权重,这样可以使我们的策略朝着具有更高适应度函数值的权重方向移动。为了提高稳定性,权重更新是通过对具有不同随机噪声的多个步骤批次进行平均来完成的。
更正式地说,这个方法可以表达为以下步骤序列:
-
初始化学习率 α、噪声标准差 σ 和初始策略参数 𝜃[0]。
-
对 t = 0,1,… 执行:
-
噪声样本批次,其形状与来自均值为零、方差为一的正态分布的权重相同:𝜖[1],…,𝜖[n] ∼𝒩(0,I)
-
计算回报 F[i] = F(𝜃[t] + σ𝜖[i]),其中 i = 1,…,n
-
更新权重:
![π (a |s) = P[At = a|St = s]]()
-
该算法是论文中提出方法的核心,但像强化学习领域中的常见情况一样,单靠这个方法不足以获得良好的结果。因此,论文中包含了几个调整以改善方法,尽管核心方法保持不变。
在 CartPole 上实现 ES
让我们在我们的果蝇环境中实现并测试论文中的方法:倒立摆(CartPole)。你可以在 Chapter17/01_cartpole_es.py 文件中找到完整的例子。
在这个例子中,我们将使用单一环境来检查扰动后的网络权重的适应度。我们的适应度函数将是该回合的未折扣总奖励。
我们从导入开始:
import gymnasium as gym
import time
import numpy as np
import typing as tt
import torch
import torch.nn as nn
from torch.utils.tensorboard.writer import SummaryWriter
从导入语句中,你可以看到我们的例子是多么自包含。我们没有使用 PyTorch 优化器,因为我们根本不进行反向传播。事实上,我们完全可以避免使用 PyTorch,只使用 NumPy,因为我们唯一用 PyTorch 的地方就是执行前向传播并计算网络的输出。
接下来,我们定义超参数:
MAX_BATCH_EPISODES = 100
MAX_BATCH_STEPS = 10000
NOISE_STD = 0.001
LEARNING_RATE = 0.001
TNoise = tt.List[torch.Tensor]
超参数的数量也很少,包括以下几个值:
-
MAX_BATCH_EPISODES 和 MAX_BATCH_STEPS:用于训练的回合数和步骤数的限制
-
NOISE_STD:用于权重扰动的噪声标准差,σ
-
LEARNING_RATE:用于调整训练步骤中权重的系数
我们还定义了一个类型别名,表示包含权重噪声的张量列表。这样可以简化代码,因为我们将会频繁处理噪声。
现在让我们检查网络:
class Net(nn.Module):
def __init__(self, obs_size: int, action_size: int):
super(Net, self).__init__()
self.net = nn.Sequential(
nn.Linear(obs_size, 32),
nn.ReLU(),
nn.Linear(32, action_size),
nn.Softmax(dim=1)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
我们使用的模型是一个简单的单隐层神经网络(NN),它根据观察结果给出需要采取的行动。我们在这里使用 PyTorch 的神经网络模块仅是为了方便,因为我们只需要前向传播,但它也可以通过矩阵乘法和非线性应用来替代。
evaluate() 函数使用给定的策略进行完整的回合,并返回总奖励和步数:
def evaluate(env: gym.Env, net: Net) -> tt.Tuple[float, int]:
obs, _ = env.reset()
reward = 0.0
steps = 0
while True:
obs_v = torch.FloatTensor(np.expand_dims(obs, 0))
act_prob = net(obs_v)
acts = act_prob.max(dim=1)[1]
obs, r, done, is_tr, _ = env.step(acts.data.numpy()[0])
reward += r
steps += 1
if done or is_tr:
break
return reward, steps
奖励将作为适应度值,而步数则用于限制我们花费在形成批次上的时间。行动选择通过计算网络输出的最大值(argmax)以确定性方式进行。原则上,我们可以从分布中进行随机采样,但我们已经通过向网络参数添加噪声来进行探索,因此在这里进行确定性行动选择是可以的。
在 sample_noise() 函数中,我们创建了均值为零、方差为一的随机噪声,其形状与我们的网络参数相同:
def sample_noise(net: Net) -> tt.Tuple[TNoise, TNoise]:
pos = []
neg = []
for p in net.parameters():
noise = np.random.normal(size=p.data.size())
noise_t = torch.FloatTensor(noise)
pos.append(noise_t)
neg.append(-noise_t)
return pos, neg
该函数返回两组噪声张量:一组是正噪声,另一组是取负号后的相同随机值。这两组样本稍后会作为独立样本放入一个批次中。这种技术称为镜像采样,用来提高收敛的稳定性。实际上,如果没有负噪声,收敛会变得非常不稳定,因为正噪声会将权重推向单一方向。
eval_with_noise() 函数接受由 sample_noise() 创建的噪声数组,并在添加噪声后评估网络:
def eval_with_noise(env: gym.Env, net: nn.Module, noise: TNoise, noise_std: float,
get_max_action: bool = True, device: torch.device = torch.device("cpu")
old_params = net.state_dict()
for p, p_n in zip(net.parameters(), noise):
p.data += NOISE_STD * p_n
r, s = evaluate(env, net)
net.load_state_dict(old_params)
return r, s
为了实现这一点,我们将噪声加到网络的参数上,并调用评估函数来获得奖励和所采取的步骤数。之后,我们需要恢复网络的权重到其原始状态,这通过加载网络的状态字典来完成。
方法的最后一个也是核心的函数是train_step(),它接受带噪声和相应奖励的批次,并通过应用公式计算对网络参数的更新:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq69.png)
这可以通过以下方式实现:
def train_step(net: Net, batch_noise: tt.List[common.TNoise], batch_reward: tt.List[float],
writer: SummaryWriter, step_idx: int):
weighted_noise = None
norm_reward = np.array(batch_reward)
norm_reward -= np.mean(norm_reward)
s = np.std(norm_reward)
if abs(s) > 1e-6:
norm_reward /= s
在开始时,我们对奖励进行归一化,使其均值为零,方差为一,这有助于提高方法的稳定性。
然后,我们遍历批次中的每一对(噪声,奖励),将噪声值与归一化的奖励相乘,并将每个参数的相应噪声相加:
for noise, reward in zip(batch_noise, norm_reward):
if weighted_noise is None:
weighted_noise = [reward * p_n for p_n in noise]
else:
for w_n, p_n in zip(weighted_noise, noise):
w_n += reward * p_n
最后一步是使用积累的缩放噪声来调整网络参数:
m_updates = []
for p, p_update in zip(net.parameters(), weighted_noise):
update = p_update / (len(batch_reward) * NOISE_STD)
p.data += LEARNING_RATE * update
m_updates.append(torch.norm(update))
writer.add_scalar("update_l2", np.mean(m_updates), step_idx)
从技术上讲,我们在这里做的是梯度上升,尽管梯度并不是通过反向传播获得的,而是通过随机采样(也称为蒙特卡洛采样)得到的。Salimans 等人也证明了这一点,作者展示了 CMA-ES 与策略梯度方法非常相似,区别仅在于我们获得梯度估计的方式。
训练循环前的准备工作很简单;我们创建环境和网络:
if __name__ == "__main__":
writer = SummaryWriter(comment="-cartpole-es")
env = gym.make("CartPole-v1")
net = Net(env.observation_space.shape[0], env.action_space.n)
print(net)
每次训练循环的迭代都从批次创建开始,在这里我们对噪声进行采样并获得正向和反向噪声的奖励:
step_idx = 0
while True:
t_start = time.time()
batch_noise = []
batch_reward = []
batch_steps = 0
for _ in range(MAX_BATCH_EPISODES):
noise, neg_noise = sample_noise(net)
batch_noise.append(noise)
batch_noise.append(neg_noise)
reward, steps = eval_with_noise(env, net, noise)
batch_reward.append(reward)
batch_steps += steps
reward, steps = eval_with_noise(env, net, neg_noise)
batch_reward.append(reward)
batch_steps += steps
if batch_steps > MAX_BATCH_STEPS:
break
当我们达到批次的最大集数,或者总步数的上限时,我们停止收集数据并进行训练更新。
要执行网络的更新,我们调用已经看到的train_step()函数:
step_idx += 1
m_reward = float(np.mean(batch_reward))
if m_reward > 199:
print("Solved in %d steps" % step_idx)
break
train_step(net, batch_noise, batch_reward, writer, step_idx)
train_step()函数的目标是根据总奖励来缩放噪声,然后将策略权重调整到平均噪声的方向。
训练循环的最后步骤将度量数据写入 TensorBoard,并在控制台上显示训练进度:
writer.add_scalar("reward_mean", m_reward, step_idx)
writer.add_scalar("reward_std", np.std(batch_reward), step_idx)
writer.add_scalar("reward_max", np.max(batch_reward), step_idx)
writer.add_scalar("batch_episodes", len(batch_reward), step_idx)
writer.add_scalar("batch_steps", batch_steps, step_idx)
speed = batch_steps / (time.time() - t_start)
writer.add_scalar("speed", speed, step_idx)
print("%d: reward=%.2f, speed=%.2f f/s" % (
step_idx, m_reward, speed))
CartPole 结果
训练可以通过直接运行程序而不需要参数来开始:
Chapter17$ ./01_cartpole_es.py
Net(
(net): Sequential(
(0): Linear(in_features=4, out_features=32, bias=True)
(1): ReLU()
(2): Linear(in_features=32, out_features=2, bias=True)
(3): Softmax(dim=1)
)
)
1: reward=10.00, speed=7458.03 f/s
2: reward=11.93, speed=8454.54 f/s
3: reward=13.71, speed=8677.55 f/s
4: reward=15.96, speed=8905.25 f/s
5: reward=18.75, speed=9098.71 f/s
6: reward=22.08, speed=9220.68 f/s
7: reward=23.57, speed=9272.45 f/s
...
根据我的实验,ES 通常需要大约 40 到 60 个批次来解决 CartPole。前面运行的收敛动态如图 17.1 和图 17.2 所示。

图 17.1:ES 在 CartPole 上的最大奖励(左)和策略更新(右)

图 17.2:ES 在 CartPole 上的奖励均值(左)和标准差(右)
前面的图表看起来相当不错——在 30 秒内解决环境问题与第四章中的交叉熵方法相当。
ES 在 HalfCheetah 上的表现
在下一个示例中,我们将超越最简单的 ES 实现,探讨如何使用 Salimans 等人提出的共享种子策略高效地并行化该方法。为了展示这种方法,我们将使用 MuJoCo 物理模拟器中的 HalfCheetah 环境。我们已经在前一章中进行了实验,因此如果你还没有安装 gymnasium[mujoco]包,应该先安装。
首先,让我们讨论共享种子的概念。ES 算法的性能主要取决于我们收集训练批次的速度,训练批次由采样噪声和检查扰动噪声的总奖励组成。由于我们的训练批次项是独立的,我们可以轻松地将这一步并行化到大量位于远程机器上的工人(这有点类似于第十二章中的示例,当时我们从 A3C 工人那里收集梯度)。然而,天真的并行化实现将需要将大量数据从工人进程传输到中央主进程,主进程应当合并工人检查过的噪声并执行策略更新。大部分数据是噪声向量,其大小等于我们策略参数的大小。
为了避免这种开销,Salimans 等人提出了一个相当优雅的解决方案。由于工人上采样的噪声是由伪随机数生成器产生的,这使得我们可以设置随机种子并重现生成的随机序列,因此工人只需将用于生成噪声的种子传输给主进程。然后,主进程可以使用该种子再次生成相同的噪声向量。当然,每个工人的种子需要随机生成,以保持随机优化过程的性质。这样做的效果是显著减少了需要从工人传输到主进程的数据量,从而提高了方法的可扩展性。例如,Salimans 等人报告了在云端使用 1,440 个 CPU 进行优化时的线性加速。在我们的示例中,我们将使用相同的方法进行本地并行化。
在 HalfCheetah 上实现 ES
代码位于 Chapter17/02_cheetah_es.py 中。由于代码与 CartPole 版本有显著重叠,我们在此只关注其中的差异。
我们将从工人开始,工人作为单独的进程使用 PyTorch 的多进程封装器启动。工人的职责很简单:每次迭代时,它从主进程获取网络参数,然后执行固定次数的迭代,在每次迭代中它采样噪声并评估奖励。带有随机种子的结果通过队列发送给主进程。
以下数据类由工人使用,用于将扰动策略评估的结果发送到主进程:
@dataclass(frozen=True)
class RewardsItem:
seed: int
pos_reward: float
neg_reward: float
steps: int
它包括随机种子、通过正负噪声获得的奖励,以及我们在两个测试中执行的总步骤数。
每次训练迭代时,工作程序会等待主控程序广播网络参数:
def worker_func(params_queue: mp.Queue, rewards_queue: mp.Queue,
device: torch.device, noise_std: float):
env = make_env()
net = Net(env.observation_space.shape[0], env.action_space.shape[0]).to(device)
net.eval()
while True:
params = params_queue.get()
if params is None:
break
net.load_state_dict(params)
None 的值表示主控程序希望停止工作程序。
其余部分几乎与前一个示例相同,主要的区别在于在噪声生成之前生成并分配的随机种子。这使得主控程序能够重新生成相同的噪声,只是从种子开始:
for _ in range(ITERS_PER_UPDATE):
seed = np.random.randint(low=0, high=65535)
np.random.seed(seed)
noise, neg_noise = common.sample_noise(net, device=device)
pos_reward, pos_steps = common.eval_with_noise(env, net, noise, noise_std,
get_max_action=False, device=device)
neg_reward, neg_steps = common.eval_with_noise(env, net, neg_noise, noise_std,
get_max_action=False, device=device)
rewards_queue.put(RewardsItem(seed=seed, pos_reward=pos_reward,
neg_reward=neg_reward, steps=pos_steps+neg_steps))
另一个区别在于主控程序执行训练步骤时使用的函数:
def train_step(optimizer: optim.Optimizer, net: Net, batch_noise: tt.List[common.TNoise],
batch_reward: tt.List[float], writer: SummaryWriter, step_idx: int,
noise_std: float):
weighted_noise = None
norm_reward = compute_centered_ranks(np.array(batch_reward))
在 CartPole 示例中,我们通过减去均值并除以标准差对奖励批次进行了归一化。根据 Salimans 等人的说法,使用秩而非实际奖励可以获得更好的结果。由于 ES 对适应度函数(在我们的案例中即为奖励)没有假设,我们可以对奖励进行任何重新排列,这在 DQN 的情况下是不可行的。
在这里,数组的秩变换意味着用排序数组的索引替换原数组。例如,数组 [0.1, 10, 0.5] 将变为秩数组 [0, 2, 1]。compute_centered_ranks 函数接受一个包含批次总奖励的数组,计算数组中每个项目的秩,然后对这些秩进行归一化。例如,输入数组 [21.0, 5.8, 7.0] 将得到秩 [2, 0, 1],最终的居中秩将是 [0.5, -0.5, 0.0]。
训练函数的另一个主要区别是使用了 PyTorch 优化器:
for noise, reward in zip(batch_noise, norm_reward):
if weighted_noise is None:
weighted_noise = [reward * p_n for p_n in noise]
else:
for w_n, p_n in zip(weighted_noise, noise):
w_n += reward * p_n
m_updates = []
optimizer.zero_grad()
for p, p_update in zip(net.parameters(), weighted_noise):
update = p_update / (len(batch_reward) * noise_std)
p.grad = -update
m_updates.append(torch.norm(update))
writer.add_scalar("update_l2", np.mean(m_updates), step_idx)
optimizer.step()
为了理解为什么使用这些方法以及如何在不进行反向传播的情况下实现这一点,必须做一些解释。
首先,Salimans 等人表明,ES 算法使用的优化方法与梯度上升法非常相似,区别在于梯度的计算方式。通常应用随机梯度下降(SGD)方法时,梯度是通过计算网络参数关于损失值的导数从损失函数中获得的。这要求网络和损失函数是可微的,但这并非总是成立;例如,ES 方法执行的秩变换是不可微的。
另一方面,ES 执行的优化过程则有所不同。我们通过向当前参数添加噪声并计算适应度函数,随机采样周围的邻域。根据适应度函数的变化,我们调整参数,推动参数朝着更高的适应度函数方向前进。其结果与基于梯度的方法非常相似,但对适应度函数的要求要宽松得多:唯一的要求是能够计算它。
然而,如果我们通过随机采样适应度函数来估计某种梯度,我们可以使用 PyTorch 中的标准优化器。通常,优化器使用积累在参数 grad 字段中的梯度来调整网络的参数。
这些梯度在反向传播步骤后被积累,但由于 PyTorch 的灵活性,优化器不关心梯度的来源。因此,我们需要做的唯一事情就是将估计的参数更新复制到 grad 字段,并要求优化器更新它们。请注意,更新是带有负号的,因为优化器通常执行梯度下降(如同正常操作中我们最小化损失函数一样),但在这种情况下,我们希望执行梯度上升。这与我们在第十二章中涉及的演员-评论员方法非常相似,其中估计的策略梯度带有负号,因为它显示了改进策略的方向。代码中的最后一部分差异来自主进程执行的训练循环。它的职责是等待来自工作进程的数据,执行参数的训练更新,并将结果广播到工作进程。主进程和工作进程之间的通信通过两组队列来完成。第一组队列是每个工作进程的队列,用于主进程发送当前使用的策略参数。第二组队列是由工作进程共享的,用于发送已经提到的 RewardItem 结构,其中包含随机种子和奖励:
params_queues = [mp.Queue(maxsize=1) for _ in range(PROCESSES_COUNT)]
rewards_queue = mp.Queue(maxsize=ITERS_PER_UPDATE)
workers = []
for params_queue in params_queues:
p_args = (params_queue, rewards_queue, device, args.noise_std)
proc = mp.Process(target=worker_func, args=p_args)
proc.start()
workers.append(proc)
print("All started!")
optimizer = optim.Adam(net.parameters(), lr=args.lr)
在主进程开始时,我们创建所有队列,启动工作进程,并创建优化器。
每次训练迭代开始时,网络参数会广播到工作进程:
for step_idx in range(args.iters):
params = net.state_dict()
for q in params_queues:
q.put(params)
然后,在循环中,主进程等待从工作进程获取足够的数据:
t_start = time.time()
batch_noise = []
batch_reward = []
results = 0
batch_steps = 0
while True:
while not rewards_queue.empty():
reward = rewards_queue.get_nowait()
np.random.seed(reward.seed)
noise, neg_noise = common.sample_noise(net)
batch_noise.append(noise)
batch_reward.append(reward.pos_reward)
batch_noise.append(neg_noise)
batch_reward.append(reward.neg_reward)
results += 1
batch_steps += reward.steps
if results == PROCESSES_COUNT * ITERS_PER_UPDATE:
break
time.sleep(0.01)
每当新结果到达时,我们使用随机种子重新生成噪声。
作为训练循环的最后一步,我们调用 train_step() 函数:
train_step(optimizer, net, batch_noise, batch_reward,
writer, step_idx, args.noise_std)
你已经见过这个函数,它计算来自噪声和奖励的更新,并调用优化器调整权重。
HalfCheetah 结果
该代码支持可选的 --dev 标志,但根据我的实验,如果启用了 GPU,速度会变慢:没有 GPU 时,平均速度是每秒 20-21k 次观察,但启用 CUDA 后只有 9k。这看起来可能有些反直觉,但我们可以用非常小的网络和单次观察的批量大小来解释这一点。可能通过增加批量大小来减少这一差距(甚至可能实现加速),但这会使我们的代码变得更加复杂。
在训练过程中,我们展示平均奖励、训练速度(每秒观察次数)以及两个时间值(显示收集数据和执行训练步骤所花费的时间):
$ ./02_cheetah_es.py
Net(
(mu): Sequential(
(0): Linear(in_features=17, out_features=64, bias=True)
(1): Tanh()
(2): Linear(in_features=64, out_features=6, bias=True)
(3): Tanh()
)
)
All started!
0: reward=-505.09, speed=17621.60 f/s, data_gather=6.792, train=0.018
1: reward=-440.50, speed=20609.56 f/s, data_gather=5.815, train=0.007
2: reward=-383.76, speed=20568.74 f/s, data_gather=5.827, train=0.007
3: reward=-326.02, speed=20413.63 f/s, data_gather=5.871, train=0.007
4: reward=-259.58, speed=20181.74 f/s, data_gather=5.939, train=0.007
5: reward=-198.80, speed=20496.81 f/s, data_gather=5.848, train=0.007
6: reward=-113.22, speed=20467.71 f/s, data_gather=5.856, train=0.007
训练的动态显示出开始时策略的快速改进:仅在 100 次更新内,训练 9 分钟,代理就能够达到 1,500-1,600 的分数。30 分钟后,峰值奖励为 2,833;但随着更多训练,策略开始退化。
奖励的最大值、均值和标准差如图 17.3 和图 17.4 所示。

图 17.3:ES 在 HalfCheetah 上的最大奖励(左)和策略更新(右)

图 17.4:ES 在 HalfCheetah 上的奖励均值(左)和标准差(右)
遗传算法
另一类流行的黑箱方法是遗传算法(GA)。它是一个历史悠久、拥有二十多年历史的大型优化方法家族,核心思想简单,即生成一个 N 个个体(具体模型参数)的人口,每个个体都通过适应度函数进行评估。然后,部分表现最好的个体用于生成下一代种群(此过程称为变异)。这一过程会一直重复,直到我们对种群的表现感到满意为止。
遗传算法(GA)家族中有许多不同的方法,例如,如何执行个体的变异以生成下一代,或者如何对表现者进行排名。在这里,我们将考虑一些扩展的简单 GA 方法,最早由 Such 等人发布,名为“深度神经进化:遗传算法是训练深度神经网络进行强化学习的有力竞争者”[Suc+17]。
在本文中,作者分析了简单的 GA 方法,该方法通过对父代权重施加高斯噪声扰动来执行变异。在每次迭代中,表现最好的个体会被复制且不做修改。简单 GA 方法的步骤可以用算法形式写成如下:
-
初始化变异强度 σ、种群大小 N、选择个体的数量 T 和初始种群 P⁰,其中 P⁰ 是随机初始化的 N 个策略及其适应度:F⁰ =
-
对于代数 g = 1…G:
-
按照适应度函数值 F^(g−1) 的降序对上一代 P^(n−1) 进行排序
-
复制精英 P[1]^g = P[1](g−1),F[1]g = F[1]^(g−1)
-
对于个体 i = 2…N:
-
选择 k:从 1…T 中随机选择父代
-
采样 𝜖 ∼𝒩(0,I)
-
变异父代:P[i]^g = P[i]^(g−1) + σ𝜖
-
获取其适应度:F[i]^g = F(P[i]^g)
-
-
来自文献[2]的基础方法已有多个改进,我们将在后文讨论。现在,让我们检查一下核心算法的实现。
CartPole 上的 GA
源代码位于 Chapter17/03_cartpole_ga.py,与我们的 ES 示例有很多相似之处。不同之处在于缺少梯度上升代码,而是用网络变异函数代替:
def mutate_parent(net: Net) -> Net:
new_net = copy.deepcopy(net)
for p in new_net.parameters():
noise = np.random.normal(size=p.data.size())
noise_t = torch.FloatTensor(noise)
p.data += NOISE_STD * noise_t
return new_net
该函数的目标是通过向所有权重添加随机噪声,创建给定策略的变异副本。父代的权重保持不变,因为父代是通过替换方式随机选择的,因此该网络稍后可能会再次使用。
超参数的数量甚至比 ES 方法还要少,包括变异时添加噪声的标准差、种群大小和用于生成后续世代的顶级表现者数量:
NOISE_STD = 0.01
POPULATION_SIZE = 50
PARENTS_COUNT = 10
在训练循环之前,我们创建随机初始化的网络种群,并获取它们的适应度:
if __name__ == "__main__":
env = gym.make("CartPole-v1")
writer = SummaryWriter(comment="-cartpole-ga")
gen_idx = 0
nets = [
Net(env.observation_space.shape[0], env.action_space.n)
for _ in range(POPULATION_SIZE)
]
population = [
(net, common.evaluate(env, net))
for net in nets
]
在每一代开始时,我们根据上一代的适应度对其进行排序,并记录关于未来父代的统计数据:
while True:
population.sort(key=lambda p: p[1], reverse=True)
rewards = [p[1] for p in population[:PARENTS_COUNT]]
reward_mean = np.mean(rewards)
reward_max = np.max(rewards)
reward_std = np.std(rewards)
writer.add_scalar("reward_mean", reward_mean, gen_idx)
writer.add_scalar("reward_std", reward_std, gen_idx)
writer.add_scalar("reward_max", reward_max, gen_idx)
print("%d: reward_mean=%.2f, reward_max=%.2f, reward_std=%.2f" % (
gen_idx, reward_mean, reward_max, reward_std))
if reward_mean > 199:
print("Solved in %d steps" % gen_idx)
break
在一个单独的循环中,我们随机选取一个父代,进行变异,并评估其适应度得分:
prev_population = population
population = [population[0]]
for _ in range(POPULATION_SIZE-1):
parent_idx = np.random.randint(0, PARENTS_COUNT)
parent = prev_population[parent_idx][0]
net = mutate_parent(parent)
fitness = common.evaluate(env, net)
population.append((net, fitness))
gen_idx += 1
启动实现后,你应该能看到如下内容(具体输出和步骤数可能因执行中的随机性而有所不同):
Chapter17$ ./03_cartpole_ga.py
0: reward_mean=29.50, reward_max=109.00, reward_std=27.86
1: reward_mean=65.50, reward_max=111.00, reward_std=27.61
2: reward_mean=149.10, reward_max=305.00, reward_std=57.76
3: reward_mean=175.00, reward_max=305.00, reward_std=47.35
4: reward_mean=200.50, reward_max=305.00, reward_std=39.98
Solved in 4 steps
如你所见,GA 方法比 ES 方法更高效。
GA 改进
Such 等人提出了对基本 GA 算法的两项改进:
-
第一个方法,名为深度 GA,旨在提高实现的可扩展性。我们将在后面的 GA on HalfCheetah 部分实现这一点。
-
第二个方法,叫做新颖性搜索,是尝试用不同的指标替代奖励目标。我们将这一部分留给你作为一个练习来尝试。
在接下来的 GA on HalfCheetah 部分使用的示例中,我们将实现第一个改进,而第二个改进则作为一个可选练习。
深度 GA
作为一种无梯度方法,GA 在速度上可能比 ES 方法更具可扩展性,因为优化过程涉及更多的 CPU。然而,你看到的简单 GA 算法在与 ES 方法相似的瓶颈上也存在问题:策略参数必须在工作者之间交换。Such 等人(作者)提出了一个类似于共享种子方法的技巧,但他们将其推向了极限(因为我们使用种子来跟踪成千上万的变异)。他们称之为深度 GA,其核心思想是,策略参数被表示为一组随机种子的列表,这些种子用于创建该策略的权重。
事实上,初始网络的权重是在第一次种群中随机生成的,因此列表中的第一个种子定义了这种初始化。在每一代种群中,变异也完全由每个变异的随机种子来指定。因此,我们需要重构权重的唯一信息就是这些种子本身。在这种方法中,我们需要在每个工作者上重构权重,但通常,这种开销远小于在网络中传输完整权重的开销。
新颖性搜索
基本遗传算法(GA)方法的另一个修改是新颖性搜索(NS),这是 Lehman 和 Stanley 在他们的论文《放弃目标:仅通过寻找新颖性进行进化》(Abandoning objectives: Evolution through the search for novelty alone)中提出的,该论文于 2011 年发布[LS11]。
NS 的思想是改变我们优化过程中的目标。我们不再试图增加来自环境的总奖励,而是奖励代理探索它以前从未检查过的行为(即新颖的行为)。根据作者在迷宫导航问题中的实验,迷宫中有许多陷阱,NS 比其他基于奖励的算法表现得更好。
为了实现新颖性搜索(NS),我们定义了所谓的行为特征(BC)(π),它描述了策略的行为和两个 BC 之间的距离。然后,使用 k 近邻方法检查新策略的新颖性,并根据这个距离驱动遗传算法。在 Such 等人的论文中,代理的充分探索是必需的。NS 方法显著优于进化策略(ES)、遗传算法(GA)和其他更传统的强化学习(RL)方法。
半猎豹上的遗传算法(GA)
在本章的最后一个例子中,我们将在半猎豹环境中实现并行化深度遗传算法(GA)。完整代码见 04_cheetah_ga.py。架构与并行进化策略(ES)版本非常相似,有一个主进程和多个工作进程。每个工作进程的目标是评估一批网络并将结果返回给主进程,主进程将部分结果合并成完整的种群,并根据获得的奖励对个体进行排序,生成下一个待评估的种群。
每个个体由一个随机种子列表编码,用于初始化初始网络权重和所有后续变异。这种表示方式允许非常紧凑地编码网络,即使在策略中参数数量不多的情况下也是如此。例如,在我们有一个包含 64 个神经元的隐藏层的网络中,我们有 1542 个浮动值(输入为 17 个值,动作为 6 个浮动值,因此 17 × 64 + 64 + 64 × 6 + 6 = 1542)。每个浮动值占用 4 个字节,这与随机种子使用的大小相同。因此,论文提出的深度遗传算法表示方式将使优化过程中的种群规模最多缩小到 1542 代。
实现
在我们的例子中,我们将在本地 CPU 上进行并行化处理,因此数据来回传输的数量并不太重要;然而,如果你有几百个核心可用,那么这种表示方式可能会成为一个显著的问题。
超参数集与 CartPole 示例相同,唯一的区别是种群规模较大:
NOISE_STD = 0.01
POPULATION_SIZE = 2000
PARENTS_COUNT = 10
WORKERS_COUNT = 6
SEEDS_PER_WORKER = POPULATION_SIZE // WORKERS_COUNT
MAX_SEED = 2**32 - 1
有两个函数用于根据给定的种子构建网络。第一个函数对已经创建的策略网络执行一次变异操作:
def mutate_net(net: Net, seed: int, copy_net: bool = True) -> Net:
new_net = copy.deepcopy(net) if copy_net else net
np.random.seed(seed)
for p in new_net.parameters():
noise = np.random.normal(size=p.data.size())
noise_t = torch.FloatTensor(noise)
p.data += NOISE_STD * noise_t
return new_net
前面的函数可以原地执行变异,或者根据参数复制目标网络(对于第一代需要复制)。
第二个函数从头开始使用种子列表创建网络:
def build_net(env: gym.Env, seeds: tt.List[int]) -> Net:
torch.manual_seed(seeds[0])
net = Net(env.observation_space.shape[0], env.action_space.shape[0])
for seed in seeds[1:]:
net = mutate_net(net, seed, copy_net=False)
return net
这里,第一个种子传递给 PyTorch,用于影响网络初始化,后续的种子用于应用网络突变。
worker 函数获取待评估的种子列表,并为每个获得的结果输出单独的 OutputItem 数据类项:
@dataclass
class OutputItem:
seeds: tt.List[int]
reward: float
steps: int
def worker_func(input_queue: mp.Queue, output_queue: mp.Queue):
env = gym.make("HalfCheetah-v4")
cache = {}
while True:
parents = input_queue.get()
if parents is None:
break
new_cache = {}
for net_seeds in parents:
if len(net_seeds) > 1:
net = cache.get(net_seeds[:-1])
if net is not None:
net = mutate_net(net, net_seeds[-1])
else:
net = build_net(env, net_seeds)
else:
net = build_net(env, net_seeds)
new_cache[net_seeds] = net
reward, steps = common.evaluate(env, net, get_max_action=False)
output_queue.put(OutputItem(seeds=net_seeds, reward=reward, steps=steps))
cache = new_cache
这个函数维护了网络的缓存,以最小化重新创建种子列表中参数所花费的时间。每次生成都会清除缓存,因为每一代新网络都是从当前代的赢家中创建的,所以旧网络从缓存中复用的可能性非常小。
主进程的代码也很简单:
batch_steps = 0
population = []
while len(population) < SEEDS_PER_WORKER * WORKERS_COUNT:
out_item = output_queue.get()
population.append((out_item.seeds, out_item.reward))
batch_steps += out_item.steps
if elite is not None:
population.append(elite)
population.sort(key=lambda p: p[1], reverse=True)
elite = population[0]
for worker_queue in input_queues:
seeds = []
for _ in range(SEEDS_PER_WORKER):
parent = np.random.randint(PARENTS_COUNT)
next_seed = np.random.randint(MAX_SEED)
s = list(population[parent][0]) + [next_seed]
seeds.append(tuple(s)
对于每一代,我们将当前种群的种子发送给工作者进行评估,并等待结果。然后,我们对结果进行排序,并基于表现最好的个体生成下一代。在主进程端,突变只是一个随机生成的种子编号,追加到父代的种子列表中。
结果
在这个例子中,我们使用的是 MuJoCo HalfCheetah 环境,它内部没有健康检查,因此每个回合需要 2,000 步。由于这个原因,每个训练步骤大约需要一分钟,因此需要耐心等待。在 300 轮突变后(大约用了 7 小时),最佳策略获得了 6454 的奖励,这是一个很好的结果。如果你还记得我们在上一章的实验,只有 SAC 方法能在 MuJoCo HalfCheetah 上获得更高的奖励 7063。当然,HalfCheetah 的挑战性不大,但仍然——非常好。
图表见图 17.5 和图 17.6。

图 17.5:GA 在 HalfCheetah 上的最大(左)和平均(右)奖励

图 17.6:GA 在 HalfCheetah 上的奖励标准差
总结
在这一章中,你看到了两种黑盒优化方法的示例:进化策略和遗传算法,它们可以与其他分析梯度方法竞争。它们的优势在于可以在大量资源上进行良好的并行化,并且对奖励函数的假设较少。
在下一章,我们将探讨强化学习中的一个非常重要的方面:高级探索方法。
第十八章:高级探索
在本章中,我们将讨论强化学习(RL)中的探索主题。书中多次提到,探索/利用困境是强化学习中的一个基本问题,对于高效学习非常重要。然而,在之前的例子中,我们使用了一种相当简单的探索环境的方法,即大多数情况下的 𝜖-greedy 行动选择。现在是时候深入探讨强化学习中的探索子领域,因为更复杂的环境可能需要比 𝜖-greedy 方法更好的探索策略。
更具体地,我们将涵盖以下关键主题:
-
为什么探索是强化学习中如此基本的话题
-
𝜖-greedy 方法的有效性
-
替代方法及其在不同环境中的工作原理
我们将实现所描述的方法,解决一个名为 MountainCar 的玩具问题,尽管它依然具有挑战性。这将帮助我们更好地理解这些方法、它们如何实现以及它们的行为。之后,我们将尝试解决一个来自 Atari 套件的更难问题。
为什么探索很重要
本书讨论了许多环境和方法,几乎每一章都提到了探索。很可能你已经对为什么有效地探索环境很重要有了一些想法,所以我将只讨论主要的原因。
在此之前,定义“有效探索”可能会有帮助。在理论强化学习中,已有严格的定义,但高层次的概念既简单又直观。当我们不再浪费时间在已经被智能体见过并且熟悉的环境状态中时,探索就是有效的。智能体不应一遍遍做相同的动作,而是需要寻找新的经验。正如我们之前讨论过的,探索必须与利用相平衡,后者是相反的概念,指的是利用我们的知识以最有效的方式获得最好的奖励。现在让我们快速讨论一下为什么我们最初会对有效探索感兴趣。
首先,良好的环境探索可能对我们学习良好策略的能力产生根本性影响。如果奖励稀疏,且智能体只有在某些罕见条件下才能获得良好的奖励,那么它可能在许多回合中只会经历一次正奖励,因此学习过程有效且充分地探索环境的能力,可能会带来更多能够从中学习到的良好奖励样本。
在一些情况下,这种情况在强化学习的实际应用中非常常见,缺乏良好的探索可能意味着代理根本无法体验到正向奖励,这样其他一切就变得无用。如果你没有好的样本来学习,你可以拥有最有效的强化学习方法,但它唯一能学到的就是没有办法获得好的奖励。这正是许多实际中有趣的问题的情况。例如,我们将在本章稍后详细了解 MountainCar 环境,它的动力学非常简单,但由于奖励稀疏,解决起来相当棘手。
另一方面,即使奖励不是稀疏的,有效的探索也能提高训练速度,因为它有助于更好的收敛性和训练稳定性。这是因为我们从环境中采样变得更加多样化,且与环境的通信需求减少。因此,我们的强化学习方法有机会在更短的时间内学习到更好的策略。
𝜖-greedy 有什么问题吗?
在全书中,我们使用了𝜖-greedy 探索策略作为一种简单但仍然可接受的环境探索方法。𝜖-greedy 背后的基本思想是以𝜖的概率采取随机动作;否则,(以 1 −𝜖的概率)我们按照策略(贪婪地)执行动作。通过调整超参数 0 ≤𝜖 ≤ 1,我们可以改变探索的比例。这种方法在本书中描述的大多数基于值的方法中都有使用。类似的思想也被应用于基于策略的方法,当我们的网络返回一个动作的概率分布时。为了防止网络对动作变得过于确定(通过为某个特定动作返回 1 的概率,为其他动作返回 0 的概率),我们添加了熵损失,它实际上是概率分布的熵乘以某个超参数。在训练的早期阶段,这个熵损失推动我们的网络采取随机动作(通过正则化概率分布)。但在后期,当我们足够探索了环境且奖励相对较高时,策略梯度就主导了这种熵正则化。但是,这个超参数需要调整才能正常工作。
从高层次来看,两种方法做的事情是相同的:为了探索环境,我们将随机性引入到我们的动作中。然而,最近的研究表明,这种方法距离理想状态还有很大差距:
-
在值迭代方法中,轨迹中的某些随机动作会引入偏差,影响我们对 Q 值的估计。贝尔曼方程假设下一个状态的 Q 值是通过选择 Q 值最大的动作来获得的。换句话说,轨迹的其余部分应来自我们的最优行为。然而,使用𝜖-贪婪策略时,我们可能不会选择最优动作,而是随机选择一个动作,这段轨迹将会长期保存在回放缓冲区中,直到我们的𝜖值衰减并且旧样本被从缓冲区中删除。在此之前,我们将学习到错误的 Q 值。
-
随着随机动作的注入,我们的策略在每一步都会发生变化。根据𝜖值或熵损失系数定义的频率,我们的轨迹会不断地在随机策略和当前策略之间切换。这可能导致在需要多个步骤才能到达环境状态空间中某些孤立区域时,状态空间的覆盖不充分。
为了说明最后一个问题,让我们考虑一个简单的例子,取自 Strehl 和 Littman 的论文《基于模型的区间估计分析:马尔可夫决策过程》,该论文于 2008 年发表[SL08]。这个例子称为“River Swim”,它模拟了一个智能体需要跨越的河流。环境包含六个状态和两个动作:左移和右移。状态 1 和状态 6 位于河流的两侧,状态 2 到状态 5 位于水中。
图 18.1 显示了前两个状态(状态 1 和状态 2)的转移图:

图 18.1:River Swim 环境的前两个状态的转移
在第一个状态(标有“1”的圆圈)中,智能体站在河岸上。唯一的动作是右移(通过实线表示),意味着进入河流并逆流游泳到达状态 2。然而,水流很强,从状态 1 向右游泳的动作成功的概率只有 60%(从状态 1 到状态 2 的实线)。以 40%的概率,水流将我们留在状态 1(连接状态 1 与自身的实线)。
在第二个状态(标有“2”的圆圈)中,我们有两个动作:左移,通过虚线连接状态 2 和状态 1(该动作总是成功的,因为当前的流水会将我们冲回河岸),以及右移(虚线),意味着逆流游泳到达状态 3。如前所述,逆流游泳很困难,因此从状态 2 到状态 3 的概率仅为 35%(连接状态 2 和状态 3 的虚线)。以 60%的概率,我们的左移动作最终会停留在同一状态(连接状态 2 和状态 2 的弯曲虚线)。但有时,尽管我们努力,左移动作最终会使我们回到状态 1,这种情况发生的概率为 5%(连接状态 2 和状态 1 的弯曲虚线)。
如我所说,River Swim 有六个状态,但状态 3、4 和 5 的转换与状态 2 相同。最后一个状态 6 与状态 1 相似,因此在该状态下只有一个动作可用:左,即游回去。在图 18.2 中,你可以看到完整的转换图(这只是我们之前见过的图的克隆,右转动作的转换用实线表示,左转动作的转换用虚线表示):

图 18.2:River Swim 环境的完整转换图
就奖励而言,代理在状态 1 到状态 5 之间的转换获得 1 的小奖励,但进入状态 6 时会获得 1,000 的高奖励,作为对逆流游泳所有努力的补偿。
尽管环境本身很简单,但其结构为 𝜖-贪婪策略能够完全探索状态空间带来了问题。为了检查这一点,我实现了这个环境的一个非常简单的模拟,你可以在 Chapter18/riverswim.py 中找到它。模拟中的代理总是随机行动(𝜖 = 1),模拟结果是各种状态访问的频率。代理在一个回合中可以采取的步数限制为 10,但可以通过命令行进行更改。我们不会在这里详细讲解整个代码;你可以在 GitHub 仓库中查看。现在,我们来看一下实验结果:
Chapter18$ ./riverswim.py
1: 40
2: 39
3: 17
4: 3
5: 1
6: 0
在之前的输出中,每一行显示了状态编号以及在模拟过程中访问该状态的次数。使用默认的命令行选项,进行了 100 步(10 个回合)的模拟。正如你所看到的,代理从未到达状态 6,并且仅在状态 5 中出现过一次。通过增加回合数,情况稍有改善,但并没有太大变化:
Chapter18$ ./riverswim.py -n 1000
1: 441
2: 452
3: 93
4: 12
5: 2
6: 0
模拟了 10 倍回合后,我们仍然没有访问状态 6,因此代理完全不知道那里有如此高的奖励。
只有在模拟了 10,000 个回合后,我们才成功到达状态 6,但仅仅 5 次,占所有步骤的 0.05%:
Chapter18$ ./riverswim.py -n 10000
1: 4056
2: 4506
3: 1095
4: 281
5: 57
6: 5
因此,即使采用最好的强化学习方法,训练的效率也不太可能很高。此外,在这个例子中,我们只有六个状态。想象一下,如果有 20 或 50 个状态,效率会低到什么程度,而这并非不可能;例如,在 Atari 游戏中,可能需要做出数百个决策才能发生一些有趣的事情。如果你愿意,可以使用 riverswim.py 工具进行实验,工具允许你更改随机种子、回合中的步数、总步数,甚至环境中的状态数。
这个简单的例子说明了在探索中随机动作的问题。通过随机行动,我们的智能体并没有积极地去探索环境,它只是希望随机动作能为其经验带来一些新东西,但这并不总是最好的做法。
现在让我们讨论一些更高效的探索方法。
探索的替代方法
在本节中,我们将为您提供一组探索问题的替代方法的概述。这并不是现有方法的详尽列表,而是提供一个领域概况。
我们将探索以下三种探索方法:
-
策略中的随机性,当我们在获取样本时向所使用的策略中添加随机性。本方法家族中的方法是噪声网络,我们在第八章中已经讲过。
-
基于计数的方法,它们记录智能体在特定状态下出现的次数。我们将检查两种方法:直接计数状态和伪计数方法。
-
基于预测的方法,它们尝试根据状态和预测的质量来预测某些内容。我们可以判断智能体对该状态的熟悉程度。为了说明这种方法,我们将通过观察策略蒸馏方法来进行说明,该方法在像《蒙特祖玛的复仇》这样的难度较大的 Atari 游戏中取得了最先进的成果。
在实现这些方法之前,让我们尝试更详细地理解它们。
噪声网络
让我们从一个我们已经熟悉的方法开始。我们在第八章中提到过噪声网络方法,当时我们提到 Hessel 等人[Hes+18]并讨论了深度 Q 网络(DQN)的扩展。其思路是向网络的权重中添加高斯噪声,并通过反向传播来学习噪声参数(均值和方差),这与我们学习模型的权重的方式相同。在那一章中,这种简单的方法显著提升了 Pong 游戏的训练效果。
从高层次看,这可能看起来与𝜖-贪婪方法非常相似,但 Fortunato 等人[For+17]声称存在差异。这个差异在于我们如何将随机性应用到网络中。在𝜖-贪婪方法中,随机性是添加到动作中的。而在噪声网络中,随机性被注入到网络的部分(接近输出的几个全连接层),这意味着将随机性添加到我们当前的策略中。此外,噪声的参数可能会在训练过程中学习,因此训练过程可能会根据需要增加或减少这种策略的随机性。
根据论文,噪声层中的噪声需要不时进行采样,这意味着我们的训练样本不是由当前策略生成的,而是由多个策略的集成生成的。这样一来,我们的探索变得有针对性,因为加到权重上的随机值会产生不同的策略。
基于计数的方法
这一类方法基于一个直觉:访问那些之前没有被探索过的状态。在简单的情况下,当状态空间不太大并且不同的状态很容易区分时,我们只需计算看到状态或状态+动作的次数,并倾向于前往那些计数较低的状态。
这可以作为额外的奖励来实现,这种奖励不是来自环境,而是来自状态的访问次数。在文献中,这种奖励被称为内在奖励。在这个语境中,环境中的奖励被称为外在奖励。制定这种奖励的一种方式是使用强盗探索方法:
。这里,Ñ(s)是我们看到状态 s 的次数或伪计数,值 c 定义了内在奖励的权重。
如果状态的数量很少,比如在表格学习的情况下(我们在第五章讨论过),我们可以直接对其进行计数。在更困难的情况下,当状态太多时,需要引入一些对状态的转换,例如哈希函数或某些状态的嵌入(我们稍后会在本章更详细地讨论)。
对于伪计数方法,Ñ(s)被分解为密度函数和访问的状态总数,给定Ñ(s) = ρ(x)n(x),其中ρ(x)是“密度函数”,表示状态 x 的可能性,并通过神经网络进行近似。有几种不同的方法可以做到这一点,但它们可能很难实现,所以我们在本章不会讨论复杂的情况。如果你感兴趣,可以参考 Georg Ostrovski 等人发表的《基于计数的探索与神经密度模型》[Ost+17]。
引入内在奖励的一个特殊情况叫做好奇心驱动的探索,当我们完全不考虑来自环境的奖励时。在这种情况下,训练和探索完全由智能体经验的新颖性驱动。令人惊讶的是,这种方法可能非常有效,不仅能发现环境中的新状态,还能学习出相当不错的策略。
基于预测的方法
第三类探索方法基于从环境数据中预测某些东西的另一个想法。如果智能体能够做出准确的预测,意味着智能体已经在这种情况下经历了足够多的时间,因此不值得再去探索它。
但是如果发生了一些不寻常的情况,且我们的预测偏差很大,这可能意味着我们需要关注当前所处的状态。做这件事有很多不同的方式,但在本章中,我们将讨论如何实现这一方法,正如 Burda 等人在 2018 年提出的《通过随机网络蒸馏进行探索》一文中所提出的那样[Bur+18]。作者们在所谓的硬探索游戏中(如 Atari)达到了最先进的结果。
论文中使用的方法非常简单:我们添加了内在奖励,该奖励通过一个神经网络(NN)(正在训练中)从另一个随机初始化(未训练)神经网络预测输出的能力来计算。两个神经网络的输入是当前的观察值,内在奖励与预测的均方误差(MSE)成正比。
MountainCar 实验
在这一部分,我们将尝试在一个简单但仍具有挑战性的环境中实现并比较不同探索方法的效果,这个环境可以归类为一个“经典强化学习”问题,与我们熟悉的 CartPole 问题非常相似。但与 CartPole 相比,MountainCar 问题在探索角度上要困难得多。
问题的示意图如图 18.3 所示,图中有一辆小车从山谷的底部开始。汽车可以向左或向右移动,目标是到达右侧山顶。

图 18.3:MountainCar 环境
这里的诀窍在于环境的动态和动作空间。为了到达山顶,动作需要以特定的方式应用,使汽车前后摆动以加速。换句话说,智能体需要在多个时间步骤内应用动作,使汽车加速并最终到达山顶。
显然,这种动作协调并不是通过随机动作轻松实现的,因此从探索的角度来看,这个问题很难,且与我们的 River Swim 示例非常相似。
在 Gym 中,这个环境的名称是 MountainCar-v0,且它有一个非常简单的观察和动作空间。观察值只有两个数字:第一个数字表示汽车的水平位置,第二个数字表示汽车的速度。动作可以是 0、1 或 2,其中 0 表示将汽车推向左侧,1 表示不施加任何力量,2 表示将汽车推向右侧。以下是一个在 Python REPL 中非常简单的示意:
>>> import gymnasium as gym
>>> e = gym.make("MountainCar-v0")
>>> e.reset()
(array([-0.56971574, 0\. ], dtype=float32), {})
>>> e.observation_space
Box([-1.2 -0.07], [0.6 0.07], (2,), float32)
>>> e.action_space
Discrete(3)
>>> e.step(0)
(array([-0.570371 , -0.00065523], dtype=float32), -1.0, False, False, {})
>>> e.step(0)
(array([-0.57167655, -0.00130558], dtype=float32), -1.0, False, False, {})
>>> e.step(0)
(array([-0.57362276, -0.00194625], dtype=float32), -1.0, False, False, {})
正如你所看到的,在每一步中,我们获得的奖励是-1,因此智能体需要学习如何尽快到达目标,以便获得尽可能少的总负奖励。默认情况下,步数限制为 200,所以如果我们没有达到目标(这通常是发生的情况),我们的总奖励就是−200。
DQN + 𝜖-greedy
我们将使用的第一个方法是我们传统的 𝜖-greedy 探索方法。它在源文件 Chapter18/mcar_dqn.py 中实现。我不会在这里包含源代码,因为你已经很熟悉它了。这个程序在 DQN 方法的基础上实现了各种探索策略,允许我们通过 -p 命令行选项在它们之间进行选择。要启动正常的 𝜖-greedy 方法,需要传递 -p egreedy 选项。在训练过程中,我们将 𝜖 从 1.0 降低到 0.02,持续进行 10⁵ 步训练。
训练速度相当快;进行 10⁵ 步训练只需两到三分钟。但从图 18.4 和图 18.5 中展示的图表可以明显看出,在这 10⁵ 步(即 500 回合)中,我们一次都没有达到目标状态。这是个坏消息,因为我们的 𝜖 已经衰减,意味着我们在未来不会进行更多的探索。

图 18.4:在 DQN 训练过程中使用 𝜖-greedy 策略时的奖励(左)和步数(右)

图 18.5:训练过程中 𝜖(左)和损失(右)的变化
我们仍然执行的 2% 随机动作远远不够,因为达到山顶需要数十步协调的动作(MountainCar 上的最佳策略总奖励大约为 -80)。现在我们可以继续训练几百万步,但我们从环境中获得的数据将只是回合,每回合需要 200 步,且总奖励为 -200。这再次说明了探索的重要性。无论我们使用什么训练方法,如果没有适当的探索,我们可能根本无法训练成功。那么,我们应该怎么做呢?如果我们想继续使用 𝜖-greedy,唯一的选择就是进行更长时间的探索(通过调整 𝜖 衰减的速度)。你可以尝试调整 -p egreedy 模式的超参数,但我走到了极端,实施了 -p egreedy-long 超参数集。在这个方案中,我们将 𝜖 保持为 1.0,直到至少有一个回合的总奖励超过 -200。完成这个目标后,我们开始正常训练,将 𝜖 从 1.0 降低到 0.02,持续训练 10⁶ 帧。在初始探索阶段,由于没有进行训练,这个过程通常会比正常训练快 5 到 10 倍。要在这种模式下开始训练,我们使用以下命令行:./mcar_dqn.py -n t1 -p egreedy-long。
不幸的是,即使改进了 𝜖-greedy 策略,仍然由于环境的复杂性未能解决问题。我让这个版本运行了五个小时,但在 500k 个回合后,它仍然没有遇到过一次目标,所以我放弃了。当然,你可以尝试更长时间。
DQN + 噪声网络
为了将噪声网络方法应用于我们的 MountainCar 问题,我们只需将网络中的两层之一替换为 NoisyLinear 类,最终架构如下:
MountainCarNoisyNetDQN(
(net): Sequential(
(0): Linear(in_features=2, out_features=128, bias=True)
(1): ReLU()
(2): NoisyLinear(in_features=128, out_features=3, bias=True)
)
)
NoisyLinear 类与第八章版本的唯一区别在于,此版本有一个显式的方法 sample_noise() 来更新噪声张量,因此我们需要在每次训练迭代时调用此方法;否则,噪声将在训练过程中保持不变。这个修改是为了未来与基于策略的方法进行实验所需的,这些方法要求噪声在相对较长的轨迹期间保持恒定。无论如何,这个修改很简单,我们只需不时调用这个方法。在 DQN 方法中,它会在每次训练迭代时被调用。和第八章一样,NoisyLinear 的实现来自于 TorchRL 库。代码与之前相同,所以要激活噪声网络,你需要使用 -p noisynet 命令行来运行训练。
在图 18.6 中,你可以看到三小时训练的图表:

图 18.6:DQN 带噪声网络探索的训练奖励(左)和测试步骤(右)
如你所见,训练过程未能达到代码中要求的平均测试奖励 -130,但在仅仅 7k 训练步(20 分钟训练)后,我们发现了目标状态,相比于 𝜖-greedy 方法在经过 5 小时的试错后依然没有找到任何目标状态,这已是很大的进步。
从测试步骤图(图 18.6 右侧)中我们可以看到,有一些测试的步骤数不到 100 步,这非常接近最优策略。但这些测试次数不足以将平均测试奖励推低到 -130 以下。
DQN + 状态计数
我们将应用于 DQN 方法的最后一种探索技术是基于计数的。由于我们的状态空间只有两个浮点值,我们将通过将数值四舍五入到小数点后三位来离散化观察,这应该能够提供足够的精度来区分不同的状态,但仍能将相似的状态聚集在一起。对于每个单独的状态,我们将记录该状态出现的次数,并利用这个计数为智能体提供额外的奖励。对于一个离策略方法来说,在训练过程中修改奖励可能不是最好的做法,但我们将会考察其效果。
如同之前一样,我不会提供完整的源代码;我只会强调与基础版本的不同之处。首先,我们为环境应用包装器,以跟踪计数器并计算内在奖励值。你可以在 lib/common.py 模块中找到包装器的代码,下面是它的展示。
让我们先来看一下构造函数:
class PseudoCountRewardWrapper(gym.Wrapper):
def __init__(self, env: gym.Env, hash_function = lambda o: o,
reward_scale: float = 1.0):
super(PseudoCountRewardWrapper, self).__init__(env)
self.hash_function = hash_function
self.reward_scale = reward_scale
self.counts = collections.Counter()
在构造函数中,我们传入要包装的环境、可选的哈希函数(用于观察结果)以及固有奖励的规模。我们还创建了一个计数器容器,它将哈希后的状态映射为我们看到该状态的次数。
然后,我们定义辅助函数:
def _count_observation(self, obs) -> float:
h = self.hash_function(obs)
self.counts[h] += 1
return np.sqrt(1/self.counts[h])
这个函数将计算状态的固有奖励值。它对观察结果应用哈希,更新计数器,并使用我们已经看到的公式计算奖励。
包装器的最后一个方法负责环境的步骤:
def step(self, action):
obs, reward, done, is_tr, info = self.env.step(action)
extra_reward = self._count_observation(obs)
return obs, reward + self.reward_scale * extra_reward, done, is_tr, info
在这里,我们调用辅助函数来获取奖励,并返回外部奖励和固有奖励组件的总和。
要应用这个包装器,我们需要将哈希函数传递给它:
def counts_hash(obs: np.ndarray):
r = obs.tolist()
return tuple(map(lambda v: round(v, 3), r))
三位数字可能太多了,所以你可以尝试使用另一种方式来哈希状态。
要开始训练,请将 -p counts 参数传递给训练程序。在图 18.7 中,你可以看到带有训练和测试奖励的图表。由于训练环境被我们包装在伪计数奖励包装器中,因此训练期间的值高于测试期间的值。

图 18.7:DQN 训练奖励(左)和测试奖励(右),带伪计数奖励加成
如你所见,我们未能通过这种方法获得 -130 的平均测试奖励,但我们非常接近。它只用了 10 分钟就发现了目标状态,这也是相当令人印象深刻的。
PPO 方法
我们将在 MountainCar 问题上进行的另一组实验与在线策略方法 Proximal Policy Optimization(PPO)相关,我们在第十六章中讨论过。选择这个方法的动机有几个:
-
首先,正如你在 DQN 方法 + 噪声网络的案例中看到的那样,当好的示例很少时,DQN 在快速适应这些示例方面会遇到困难。这可以通过增加重放缓冲区的大小并切换到优先缓冲区来解决,或者我们可以尝试使用在线策略方法,这些方法根据获得的经验立即调整策略。
-
选择这个方法的另一个原因是训练过程中奖励的修改。基于计数的探索和策略蒸馏引入了固有奖励组件,这个组件可能会随着时间的推移而变化。基于值的方法可能对基础奖励的修改比较敏感,因为它们基本上需要在训练过程中重新学习值。而在线策略方法不应该有任何问题,因为奖励的增加只是使具有较高奖励的样本在策略梯度中更加重要。
-
最后,检查我们的探索策略在两种 RL 方法家族中的表现是很有趣的。
为了实现这种方法,在文件 Chapter18/mcar_ppo.py 中,我们有一个结合了各种探索策略的 PPO 实现,应用于 MountainCar。代码与第十六章中的 PPO 实现差别不大,所以我不会在这里重复。要启动没有额外探索调整的普通 PPO,你应该运行命令./mcar_ppo.py -n t1 -p ppo。在这个版本中,没有专门做探索的操作——我们完全依赖于训练开始时的随机权重初始化。
提醒一下,PPO 属于策略梯度方法家族,在训练过程中限制旧策略与新策略之间的 Kullback-Leibler 散度,避免了剧烈的策略更新。我们的网络有两个部分:演员和评论家。演员网络返回我们行为的概率分布(我们的策略),评论家估计状态的价值。评论家使用均方误差(MSE)损失进行训练,而演员则由我们在第十六章讨论的 PPO 代理目标驱动。除了这两种损失,我们通过应用由超参数β缩放的熵损失来对策略进行正则化。到目前为止没有什么新内容。以下是 PPO 网络结构:
MountainCarBasePPO(
(actor): Sequential(
(0): Linear(in_features=2, out_features=64, bias=True)
(1): ReLU()
(2): Linear(in_features=64, out_features=3, bias=True)
)
(critic): Sequential(
(0): Linear(in_features=2, out_features=64, bias=True)
(1): ReLU()
(2): Linear(in_features=64, out_features=1, bias=True)
)
)
我在训练了三个小时后停止了训练,因为没有看到任何改进。目标状态在一个小时和 30k 轮次后找到了。图 18.8 中的图表展示了训练过程中的奖励动态:

图 18.8:在普通 PPO 上的训练奖励(左)和测试奖励(右)
由于 PPO 的结果并不十分令人印象深刻,让我们尝试通过额外的探索技巧来扩展它。
PPO + 噪声网络
与 DQN 方法类似,我们可以将噪声网络探索方法应用到 PPO 方法中。为此,我们需要用 NoisyLinear 层替换演员网络的输出层。只有演员网络需要受到影响,因为我们只希望将噪声注入到策略中,而不是价值估计中。
有一个微妙的细节与噪声网络的应用有关:即随机噪声需要在哪个地方进行采样。在第八章中,当你首次接触噪声网络时,噪声是在每次 forward() 通过 NoisyLinear 层时进行采样的。根据原始研究论文,对于离策略方法,这是可以的,但对于在策略方法,它需要以不同的方式进行。实际上,当我们进行在策略训练时,我们获得的是当前策略产生的训练样本,并计算策略梯度,这应该推动策略朝着改进的方向前进。噪声网络的目标是注入随机性,但正如我们所讨论的,我们更倾向于有针对性的探索,而不是在每一步之后就随机地改变策略。考虑到这一点,NoisyLinear 层中的随机成分不需要在每次 forward() 传递之后更新,而应该更少的频率进行更新。在我的代码中,我在每个 PPO 批次(即 2,048 次转换)时重新采样噪声。
和之前一样,我训练了 PPO+NoisyNets 3 小时。但在这种情况下,目标状态在 30 分钟和 18k 回合后就被找到,这是一个更好的结果。此外,根据训练步数统计,训练过程成功地让小车以最优方式行驶了几次(步数小于 100)。但是,这些成功并没有导致最终的最优策略。图 18.9 中的图表展示了训练过程中的奖励动态:

图 18.9:PPO 使用噪声网络的训练奖励(左)和测试奖励(右)
PPO + 状态计数
在这种情况下,使用三位数哈希的基于计数的方式在 PPO 方法中得到了完全相同的实现,并可以通过在训练过程中传递 -p counts 来触发。
在我的实验中,该方法能够在 1.5 小时内解决环境问题(获得平均奖励高于 -130),并且需要 61k 回合。以下是控制台输出的最后部分:
Episode 61454: reward=-159.17, steps=168, speed=4581.6 f/s, elapsed=1:37:18
Episode 61455: reward=-158.46, steps=164, speed=4609.0 f/s, elapsed=1:37:18
Episode 61456: reward=-158.41, steps=164, speed=4582.3 f/s, elapsed=1:37:18
Episode 61457: reward=-152.73, steps=158, speed=4556.4 f/s, elapsed=1:37:18
Episode 61458: reward=-154.08, steps=159, speed=4548.1 f/s, elapsed=1:37:18
Episode 61459: reward=-154.85, steps=162, speed=4513.0 f/s, elapsed=1:37:18
Test done: got -91.000 reward after 91 steps, avg reward -129.999
Reward boundary has crossed, stopping training. Congrats!
如图 18.10 所示,从图表中可以看到,训练在 23k 回合后发现了目标状态。之后又花了 40k 回合来优化策略,达到了最优步数:

图 18.10:PPO 使用伪计数奖励加成的训练奖励(左)和测试奖励(右)
PPO + 网络蒸馏
作为我们 MountainCar 实验中的最终探索方法,我实现了 Burda 等人提出的网络蒸馏方法[Bur+18]。在这种方法中,引入了两个额外的神经网络(NN)。这两个网络都需要将观察值映射为一个数字,方式与我们的价值头部相同。不同之处在于它们的使用方式。第一个神经网络是随机初始化并保持未训练的,这将成为我们的参考神经网络。第二个神经网络经过训练,以最小化第二个和第一个神经网络之间的均方误差(MSE)损失。此外,神经网络输出之间的绝对差异作为内在奖励组件。
这背后的想法是,代理程序探索某些状态得越好,我们第二个(训练过的)神经网络就越能预测第一个(未经训练的)神经网络的输出。这将导致将较小的内在奖励添加到总奖励中,从而减少样本分配的策略梯度。
在论文中,作者建议训练单独的价值头来预测内在和外在奖励成分,但在这个例子中,我决定保持简单,只是在包装器中添加了这两个奖励,就像我们在基于计数的探索方法中所做的那样。这样可以最小化代码的修改数量。
关于那些额外的神经网络架构,我做了一个小实验,并尝试了两个神经网络的几种架构。最佳结果是参考神经网络具有三层,训练神经网络只有一层。这有助于防止训练神经网络的过拟合,因为我们的观察空间并不是很大。两个神经网络都实现在 lib/ppo.py 模块的 MountainCarNetDistillery 类中:
class MountainCarNetDistillery(nn.Module):
def __init__(self, obs_size: int, hid_size: int = 128):
super(MountainCarNetDistillery, self).__init__()
self.ref_net = nn.Sequential(
nn.Linear(obs_size, hid_size),
nn.ReLU(),
nn.Linear(hid_size, hid_size),
nn.ReLU(),
nn.Linear(hid_size, 1),
)
self.ref_net.train(False)
self.trn_net = nn.Sequential(
nn.Linear(obs_size, 1),
)
def forward(self, x):
return self.ref_net(x), self.trn_net(x)
def extra_reward(self, obs):
r1, r2 = self.forward(torch.FloatTensor([obs]))
return (r1 - r2).abs().detach().numpy()[0][0]
def loss(self, obs_t):
r1_t, r2_t = self.forward(obs_t)
return F.mse_loss(r2_t, r1_t).mean()
除了返回两个神经网络输出的 forward()方法外,该类还包括两个帮助方法,用于计算内在奖励和获取两个神经网络之间的损失。
要开始训练,需要将参数 -p distill 传递给 mcar_ppo.py 程序。在我的实验中,解决问题需要 33k 个周期,比噪声网络少了近两倍。正如早些时候讨论的那样,我的实现中可能存在一些错误和低效性,因此欢迎您修改以使其更快更高效:
Episode 33566: reward=-93.27, steps=149, speed=2962.8 f/s, elapsed=1:23:48
Episode 33567: reward=-82.13, steps=144, speed=2968.6 f/s, elapsed=1:23:48
Episode 33568: reward=-83.77, steps=143, speed=2973.7 f/s, elapsed=1:23:48
Episode 33569: reward=-93.59, steps=160, speed=2974.0 f/s, elapsed=1:23:48
Episode 33570: reward=-83.04, steps=143, speed=2979.7 f/s, elapsed=1:23:48
Episode 33571: reward=-97.96, steps=158, speed=2984.5 f/s, elapsed=1:23:48
Episode 33572: reward=-92.60, steps=150, speed=2989.8 f/s, elapsed=1:23:48
Test done: got -87.000 reward after 87 steps, avg reward -129.549
Reward boundary has crossed, stopping training. Congrats!
显示有关训练和测试奖励的图表如图 18.11 所示。在图 18.12 中,显示了总损失和蒸馏损失。

Figure 18.11: PPO 与网络蒸馏的训练奖励(左)和测试奖励(右)

Figure 18.12: 总损失(左)和蒸馏损失(右)
与之前一样,由于内在奖励成分的存在,训练周期在图中有更高的奖励。从蒸馏损失图中可以明显看出,在代理程序发现目标状态之前,一切都是无聊和可预测的,但一旦它找出如何在 200 步之前结束这一情况,损失就显著增加。
方法比较
为了简化我们在 MountainCar 上进行的实验比较,我将所有数字放入以下表格中:
| Method | 找到目标状态 | 解决 |
|---|---|---|
| Episodes | Time | |
| DQN + 𝜖-greedy | x | x |
| DQN + noisy nets | 8k | 15 min |
| PPO | 40k | 60 min |
| PPO + noisy nets | 20k | 30 min |
| PPO + counts | 25k | 36 min |
| PPO + distillation | 16k | 36 min |
Table 18.1: 实验总结
正如你所看到的,带有探索扩展的 DQN 和 PPO 都能够解决 MountainCar 环境。具体方法的选择取决于你和你具体的情况,但重要的是要意识到你可能会使用不同的探索方法。
Atari 实验
MountainCar 环境是一个非常好的快速实验探索方法,但为了总结这一章,我包含了带有我们描述过的探索调整的 DQN 和 PPO 方法的 Atari 版本,以便检查一个更复杂的环境。
作为主要环境,我使用了 Seaquest,这是一个潜艇需要击败鱼类和敌人潜艇,并拯救水下宇航员的游戏。这个游戏没有像《蒙特祖玛的复仇》那么有名,但它仍然可以算作是中等难度的探索,因为要继续游戏,你需要控制氧气的水平。当氧气变低时,潜艇需要升到水面一段时间。如果没有这个操作,游戏将在 560 步后结束,且最大奖励为 20。然而,一旦智能体学会如何补充氧气,游戏几乎可以无限继续,并为智能体带来 10k-100k 的分数。令人惊讶的是,传统的探索方法在发现这一点时有困难;通常,训练会在 560 步时卡住,之后氧气耗尽,潜艇就会死掉。
Atari 的一个负面方面是每次实验至少需要半天的训练才能检查效果,因此我的代码和超参数距离最佳状态还有很大差距,但它们可能作为你自己实验的起点是有用的。当然,如果你发现了改进代码的方法,请在 GitHub 上分享你的发现。
和之前一样,有两个程序文件:atari_dqn.py,实现了带有𝜖-贪婪和噪声网络探索的 DQN 方法;atari_ppo.py,实现了 PPO 方法,带有可选的噪声网络和网络蒸馏方法。要在超参数之间切换,需要使用命令行选项-p。
在接下来的章节中,让我们看看我通过几次代码运行得到的结果。
DQN + 𝜖-贪婪
与其他在 Atari 上尝试过的方法相比,𝜖-贪婪表现最好,这可能会让人感到惊讶,因为它在本章前面的 MountainCar 实验中给出了最差的结果。但这在现实中是很常见的,并且可能会带来新的研究方向,甚至突破。经过 13 小时的训练,它能够达到 18 的平均奖励,最大奖励为 25。根据显示步骤数的图表,只有少数几个回合能够发现如何获取氧气,因此,或许经过更多的训练,这种方法可以突破 560 步的限制。在图 18.13 中,显示了平均奖励和步骤数的图表:

图 18.13:DQN 与𝜖-贪婪的平均训练奖励(左)和步骤数(右)
DQN + 噪声网络
带有噪声网络的 DQN 表现更差——经过 6 小时的训练,它的奖励值只能达到 6。在图 18.14 中,显示了相关的图表:

图 18.14:在带有噪声网络的 DQN 上的平均训练奖励(左)和步数(右)
PPO
PPO 实验的表现更差——所有的组合(原生 PPO、噪声网络和网络蒸馏)都没有奖励进展,平均奖励只能达到 4。这有点令人惊讶,因为在本书的上一版中,使用相同代码的实验能获得更好的结果。这可能表明代码或我使用的训练环境中存在一些细微的 bug。你可以自己尝试这些方法!
概述
在本章中,我们讨论了为什么𝜖-贪心探索在某些情况下不是最佳方法,并检查了现代的替代探索方法。探索的主题要广泛得多,还有很多有趣的方法未被涉及,但我希望你能够对这些新方法以及它们如何在自己的问题中实施和使用有一个整体的印象。
在下一章中,我们将探讨另一种在复杂环境中探索的方法:带有人工反馈的强化学习(RLHF)。
第十九章:通过人类反馈的强化学习
在本章中,我们将介绍一种相对较新的方法,解决了当期望的行为很难通过明确的奖励函数定义时的情况——通过人类反馈的强化学习(RLHF)。这也与探索相关(因为该方法允许人类推动学习朝着新的方向发展),这是我们在第十八章中讨论过的问题。令人惊讶的是,这种方法最初是为强化学习领域中的一个非常特定的子问题开发的,结果在大型语言模型(LLM)中取得了巨大的成功。如今,RLHF 已成为现代 LLM 训练流程的核心,没有它,近期的惊人进展是不可能实现的。
由于本书并不涉及 LLM 和现代聊天机器人,我们将纯粹聚焦于 OpenAI 和 Google 的 Christiano 等人所提出的原始论文《来自人类偏好的深度强化学习》[Chr+17],该论文描述了 RLHF 方法如何应用于强化学习问题和环境。但在方法概述中,我会简要解释这种方法是如何在 LLM 训练中使用的。
在本章中,我们将:
-
看看人类反馈在强化学习中的应用,以解决奖励目标不明确和探索的问题。
-
从零开始实现一个 RLHF 流程,并在 SeaQuest Atari 游戏中进行测试,以教会它新的行为。
复杂环境中的奖励函数
在深入讨论 RLHF 方法之前,让我们先讨论一下这一概念背后的动机。正如我们在第一章中讨论的,奖励是强化学习的核心概念。没有奖励,我们就像瞎子——我们已经讨论过的所有方法都严重依赖于环境提供的奖励值:
-
在基于价值的方法(本书第二部分)中,我们使用奖励来近似 Q 值,以评估行为并选择最优的行动。
-
在基于策略的方法(第三部分)中,奖励的使用更加直接——作为策略梯度的尺度。去掉所有数学内容后,我们基本上优化了我们的策略,以偏好那些能够带来更多累计未来奖励的行为。
-
在黑箱方法(第十七章)中,我们使用奖励来做出关于代理变体的决策:应该保留它们以供将来使用,还是丢弃?
在我们实验过的几乎所有强化学习环境中,奖励函数都是预定义的——在 Atari 游戏中,我们有得分;在 FrozenLake 环境中,它是一个明确的目标位置;在模拟机器人中,它是行进的距离,等等。唯一的例外是在第十章,我们自己实现了环境(股票交易系统),并且必须决定如何设计奖励。即便在那个例子中,应该使用什么作为奖励也相当明显。
不幸的是,在现实生活中,确定应作为奖励的内容并非总是那么简单。让我们来看几个例子。如果我们在训练聊天机器人解决一组任务时,除了确保任务正确完成外,还必须考虑完成任务的方式。如果我们问系统“明天的天气预报是什么?”它回答正确但语气粗鲁,应该因其不礼貌的回答而受到负面奖励吗?如果是相反的情况——回答非常礼貌,但信息错误呢?如果我们只优化一个标准(比如信息的正确性),我们可能会得到一个“能工作”的系统,但它在现实生活中却不可用——因为它太笨拙,没人愿意使用。
另一个“单一优化因素”的例子是从 A 点到 B 点的货物运输。运输公司并不仅仅通过一切手段最大化他们的利润。此外,他们还面临着大量的限制和规定,如驾驶规则、工作时间、劳动法规等。如果我们仅在系统中优化一个标准,最终可能会得到“穿越邻居的栅栏——这是最快的路。”因此,在现实生活中,追求单一的最大化标准是例外而非常态。大多数情况下,我们有多个参数共同作用于最终结果,我们需要在它们之间找到某种平衡。即使在我们之前见过的雅达利游戏中,分数也可能是不同“子目标”之和的结果。一个非常好的例子是我们在上一章实验过的《SeaQuest》游戏。如果你以前没玩过,可以在浏览器中进行体验,以更好地理解:www.retrogames.cz/play_221-Atari2600.php。
在这款游戏中,你控制潜艇,并根据以下活动获得分数:
-
射击邪恶的鱼类和敌方潜艇
-
救援潜水员并将他们带回水面
-
避免敌人火力和水面上的船只(它们出现在游戏的后期关卡)
由于氧气有限,潜艇必须定期上浮以补充氧气。大多数现代强化学习方法在发现射击鱼类和潜艇的奖励时没有问题——从试错开始,经过几小时的训练,智能体就能学会如何通过射击获得奖励。
但发现通过拯救潜水员来得分要困难得多,因为只有在收集了六个潜水员并成功到达水面后才会给予奖励。通过试错法发现氧气补充也很困难,因为我们的神经网络对氧气、潜水艇以及潜水艇突然死亡如何与屏幕底部的仪表相关联没有先验知识。我们的强化学习方法与𝜖-贪婪探索可以看作是一个刚出生的婴儿随机按按钮并因正确的动作序列而获得奖励,这可能需要很长时间才能执行正确的长序列。
结果是,在《SeaQuest》中的大多数训练回合都受到平均得分 300 和 500 游戏步骤的限制。潜水艇因缺氧而死,随机的表面访问过于稀少,以至于无法发现游戏可以玩得更久。同时,从未见过这个游戏的人能够在几分钟的游戏时间里找出如何补充氧气并拯救潜水员。
潜在地,我们可以通过将氧气纳入奖励函数(例如作为补充氧气的额外奖励)来帮助我们的智能体,并以某种方式解释氧气为何重要,但这可能会引发环境调整的恶性循环——正是我们通过使用强化学习方法所试图避免的那些努力。
如你所料,RLHF 正是能够让我们避免这种低级奖励函数调整的方法,使得人类能够对智能体的行为提供反馈。
理论背景
让我们来看一下 OpenAI 和 Google 研究人员在 2017 年发布的原始 RLHF 方法[Chr+17]。自从这篇论文发布后(尤其是在 ChatGPT 发布之后),该方法成为了一个活跃的研究领域。有关最新的进展,你可以查看github.com/opendilab/awesome-RLHF上的论文。此外,我们还将讨论 RLHF 在大语言模型(LLM)训练过程中的作用。
方法概述
论文的作者实验了两类问题:几种来自 MuJoCo 模拟机器人环境(类似于我们在第十五章和第十六章讨论的连续控制问题)和几种 Atari 游戏。
核心思想是保持原始的强化学习模型,但用一个神经网络替代来自环境的奖励,这个神经网络叫做奖励预测器,它是通过人类收集的数据进行训练的。这个网络(在论文中表示为 r̂(o,a))接受观察和动作,并返回该动作的即时奖励浮动值。
该奖励预测器的训练数据并非直接由人类提供,而是从人类偏好中推断出来:人们会看到两个短视频片段,其中展示了智能体的行为,并被问到“哪一个更好?”换句话说,奖励预测器的训练数据是两个情节片段 σ¹ 和 σ²(包含观察和动作的固定长度序列 (o[t],a[t])) 和来自人类的标签 μ,表示哪个片段更受偏好。给定的答案选项有“第一个”,“第二个”,“两个都好”和“无法判断”。
网络 r̂ (o,a) 是通过使用标签与函数 p̂[σ¹ ≻σ²] 之间的交叉熵损失来训练的,p̂[σ¹ ≻σ²] 是对人类偏好 σ¹ 相较于 σ² 的概率的估计:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq71.png)
换句话说,我们对片段中的每一步预测奖励进行求和,取每个奖励的指数,然后对总和进行归一化。交叉熵损失是使用二分类的标准公式计算的:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq72.png)
μ[1] 和 μ[2] 的值是根据人类的判断分配的。如果第一个片段比第二个片段更受偏好,则 μ[1] = 1,μ[2] = 0。若第二个片段更好,则 μ[2] = 1,μ[1] = 0。如果人类认为两个片段都很好,则两个 μ 都设置为 0.5。与其他方法相比,这种奖励模型有几个优点:
-
通过使用神经网络进行奖励预测,我们可以显著减少所需的标签数量。极端情况下,可能要求人类标注策略的每个动作,但在强化学习的情况下,这是不可行的,因为在环境中会有数百万次交互发生。在高层目标的情况下,这几乎是不可能完成的任务。
-
我们不仅给网络反馈好的行为,还给它反馈我们不喜欢的行为。如果你记得,在第十四章中,我们使用记录下来的人工示范来训练网络自动化代理。但人工示范只展示了正面例子(“做这个”),没有办法包含负面例子(“不要做那个”)。此外,人工示范更难收集,且可能包含更多的错误。
-
通过询问人类偏好,我们可以处理那些人类能够识别我们想要的行为,但不一定能复制的情况。例如,控制第十六章中的四足蚂蚁机器人对人类来说可能非常具有挑战性。同时,我们也没有检测出机器人行为正常或策略错误时的困难。
在 RLHF 论文中,作者实验了不同的奖励模型训练方法及其在强化学习训练过程中的使用。在他们的设置中,三种不同的过程同时运行:
-
使用的 RL 训练方法(A2C)使用当前的 r̂ (o, a) 网络进行奖励预测。随机轨迹段 σ = (o[i], a[i]) 被存储在标注数据库中。
-
人类标注者采样了一对段落(σ¹, σ²),并为其分配标签 μ,标签被存储在标注数据库中。
-
奖励模型 r̂ (o, a) 会定期在来自数据库的标注对上进行训练,并发送到 RL 训练过程中。
该过程如图 19.1 所示。

图 19.1: RLHF 结构
如前所述,本文讨论了两类问题:Atari 游戏和连续控制。在这两类问题上,结果并不特别显著——有时传统的 RL 比 RLHF 更好,有时则相反。但 RLHF 真正突出的地方是在大语言模型(LLM)的训练流程中。我们在开始 RLHF 实验之前,简要讨论一下为什么会发生这种情况。
RLHF 和 LLMs
ChatGPT 于 2022 年底发布,很快成为了一个大热话题。对于普通用户来说,它甚至比 2012 年的 AlexNet 还要有影响力,因为 AlexNet 是“技术性的东西”——它推动了边界,但很难解释它到底有多特别。ChatGPT 不一样:发布仅一个月后,它的用户数量就突破了 1 亿,而几乎每个人都在谈论它。
ChatGPT(以及任何现代 LLM)训练流程的核心是 RLHF。因此,这种微调大模型的方法迅速流行开来,并且在研究兴趣上也有所增长。由于这不是一本关于 LLM 的书,我将简要描述该流程以及 RLHF 是如何融入其中的,因为从我的角度来看,这是一个有趣的应用案例。
从高层次来看,LLM 训练由三个阶段组成:
-
预训练:在这里,我们在一个庞大的文本语料库上对语言模型进行初步训练。基本上,我们会尽可能获取所有的信息并进行无监督的语言模型训练。数据量(及其成本)是巨大的——用于 LLaMA 训练的 RedPajama 数据集包含 1.2 万亿个标记(大约相当于 1500 万本书)。
在这个阶段,我们的随机初始化模型学习语言的规律性和深层次的联系。但由于数据量庞大,我们不能仅仅挑选这些数据——它们可能是假新闻、仇恨言论帖子,或是你在互联网上随便可以找到的其他怪异内容。
-
监督微调:在这一步,我们会在预定义的精选示例对话上对模型进行微调。此处使用的数据集是手动创建并验证正确性的,数据量显著较小——大约为 10K-100K 个示例对话。
这些数据通常由该领域的专家创建,需要大量的精力来制作并进行复核。
-
RLHF 微调(也称为“模型对齐”):这一步使用了我们已经描述过的相同过程:生成的对话对呈现给用户进行标注,奖励模型基于这些标签进行训练,并在 RL 算法中使用这个奖励模型来微调 LLM 模型,使其遵循人类的偏好。标注样本的数量比监督微调步骤要多(大约 1M 对),但因为比较两个对话要比从头开始创建一个合适的对话简单得多,所以这不是问题。
正如你可能猜到的,第一步是最耗费资源和时间的:你必须处理大量的文本并通过变换器进行处理。但同时,这些步骤的重要性是完全不同的。在最后一步,系统不仅学习如何解决呈现的问题,还会得到生成问题答案时是否符合社会接受方式的反馈。
RLHF 方法非常适合这个任务——只需要一对对话,它就能学习代表标注者隐式“偏好模型”的奖励模型,应用于像聊天机器人这样复杂的事物。显式地做这件事(例如通过奖励函数)可能是一个具有很大不确定性的挑战性问题。
RLHF 实验
为了更好地理解我们刚才讨论的流程,让我们自己动手实现它(因为“做是最好的学习方法”)。在上一章中,我们尝试了 Atari SeaQuest 环境,从探索角度来看,这个环境有一定难度,因此利用这个环境并检查我们能通过人类反馈取得什么成就是合乎逻辑的。
为了限制本章的范围并使例子更具可复现性,我对 RLHF 论文 [Chr+17] 中描述的实验进行了以下修改:
-
我专注于单一的 SeaQuest 环境。目标是提高代理在与第十八章中 A2C 结果的对比中的游戏表现——平均得分为 400,回合步数为 500 步(由于缺氧)。
-
我将其从异步标注和奖励模型训练的过程,分成了单独的步骤:
-
执行了 A2C 训练,将轨迹段存储在本地文件中。此训练可选择性地加载并使用奖励模型网络,这使得我们可以在训练后迭代奖励模型,标记更多的样本。
-
Web UI 让我可以为随机的轨迹段对打上标签,并将标签存储在一个 JSON 文件中。
-
奖励模型在这些段落和标签上进行了训练。训练结果被存储在磁盘上。
-
-
我避免了所有与奖励模型训练相关的变体:没有 L2 正则化,没有集成方法等。
-
标签的数量显著减少:在每次实验中,我标记了额外的 100 对回合段,并重新训练了模型。
-
动作明确地加入了奖励模型中。详情请参阅“奖励模型”一节。
-
奖励模型在 A2C 训练中用于对保存的最佳模型进行微调。为了说明背景,在论文中,模型是从零开始训练的,并通过并行的 RLHF 标注和奖励模型重训练得到了改善。
使用 A2C 进行初始训练
为了获得第一个模型(我们称之为“版本 0”或简称 v0),我使用了标准的 A2C 代码,并配合本书前面已经多次讨论过的 Atari 包装器。
要开始训练,您需要运行 Chapter19/01_a2c.py 模块,除了基本的 A2C 训练外,它还包含一个命令行选项,用于启用奖励模型(我们在前面的章节中介绍过),但在此步骤中我们不需要它。
目前,要开始基本模型的训练,请使用以下命令行:
Chapter19$ ./01_a2c.py --dev cuda -n v0 --save save/v0 --db-path db-v0
A.L.E: Arcade Learning Environment (version 0.8.1+53f58b7)
[Powered by Stella]
AtariA2C(
(conv): Sequential(
(0): Conv2d(4, 32, kernel_size=(8, 8), stride=(4, 4))
(1): ReLU()
(2): Conv2d(32, 64, kernel_size=(4, 4), stride=(2, 2))
(3): ReLU()
(4): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1))
(5): ReLU()
(6): Flatten(start_dim=1, end_dim=-1)
)
(policy): Sequential(
(0): Linear(in_features=3136, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=18, bias=True)
)
(value): Sequential(
(0): Linear(in_features=3136, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=1, bias=True)
)
)
0: Testing model...
Got best reward 40.00 and steps 213.0 in 10 episodes
1024: done 1 games, mean reward 0.000, steps 70, speed 312.22 f/s
1056: done 2 games, mean reward 0.000, steps 72, speed 1188.69 f/s
1104: done 3 games, mean reward 0.000, steps 75, speed 1216.18 f/s
以下是命令行选项的描述:
-
--dev: 用于计算的设备名称。
-
-n: 运行的名称,用于 TensorBoard。
-
--save: 在测试后将存储最佳模型的目录名称。每训练 100 批次,我们会对当前模型在 SeaQuest 上进行 10 次测试剧集,禁用奖励剪切(以获取原始分数范围),如果这 10 轮中的最佳奖励或步骤数超过我们之前的记录,我们会将模型保存到文件中。这些文件稍后将用于微调。
-
--db-path: 在训练过程中将存储随机剧集片段的目录名称。这些数据稍后将用于奖励模型的标注和训练。
让我们讨论一下剧集片段数据库(简称 DB)。其结构非常简单:每个用于训练的环境(总共有 16 个)都有一个从 0 到 15 的标识符,这个标识符用作 --db-path 命令行参数所给定目录下的子目录。因此,每个环境都会在自己的目录中独立存储随机片段。存储逻辑是通过 Gym API Wrapper 子类实现的,这个子类叫做 EpisodeRecorderWrapper,位于 lib/rlhf.py 模块中。
让我们来看一下包装器的源代码。最初,我们声明了两个超参数,EPISODE_STEPS,它定义了片段的长度,以及 START_PROB,它表示开始剧集记录的概率:
# how many transitions to store in episode
EPISODE_STEPS = 50
# probability to start episode recording
START_PROB = 0.00005
@dataclass(frozen=True)
class EpisodeStep:
obs: np.ndarray
act: int
class EpisodeRecorderWrapper(gym.Wrapper):
def __init__(self, env: gym.Env, db_path: pathlib.Path, env_idx: int,
start_prob: float = START_PROB, steps_count: int = EPISODE_STEPS):
super().__init__(env)
self._store_path = db_path / f"{env_idx:02d}"
self._store_path.mkdir(parents=True, exist_ok=True)
self._start_prob = start_prob
self._steps_count = steps_count
self._is_storing = False
self._steps: tt.List[EpisodeStep] = []
self._prev_obs = None
self._step_idx = 0
我们将剧集片段存储为一系列 EpisodeStep 对象,这些对象只是我们在该步骤中所采取的观察和动作。重置环境的方法非常简单——它会更新包装器的 _step_idx 字段(这是我们在该环境中已执行步骤的计数器),并根据 _is_store 字段将观察值存储在 _prev_obs 字段中。如果 _is_store 字段为 True,则表示我们正在进行片段记录。
我们的片段有固定数量的环境步骤(默认为 50 步),它们独立于剧集边界进行记录(换句话说,如果我们在潜艇死亡前不久开始片段记录,那么在调用 reset() 方法后,我们会记录下一剧集的开始):
def reset(self, *, seed: int | None = None, options: dict[str, tt.Any] | None = None) \
-> tuple[WrapperObsType, dict[str, tt.Any]]:
self._step_idx += 1
res = super().reset(seed=seed, options=options)
if self._is_storing:
self._prev_obs = deepcopy(res[0])
return res
如果你愿意,你可以尝试这种逻辑,因为原则上,剧集结束后的观察数据与剧集结束前的观察和动作是独立的。但这样会使剧集片段数据的处理更复杂,因为数据长度将变得可变。
包装器的主要逻辑在 step()方法中,也不是很复杂。每次动作时,如果我们正在录制,就存储该步骤;否则,我们会生成一个随机数来决定是否开始录制:
def step(self, action: WrapperActType) -> tuple[
WrapperObsType, SupportsFloat, bool, bool, dict[str, tt.Any]
]:
self._step_idx += 1
obs, r, is_done, is_tr, extra = super().step(action)
if self._is_storing:
self._steps.append(EpisodeStep(self._prev_obs, int(action)))
self._prev_obs = deepcopy(obs)
if len(self._steps) >= self._steps_count:
store_segment(self._store_path, self._step_idx, self._steps)
self._is_storing = False
self._steps.clear()
elif random.random() <= self._start_prob:
# start recording
self._is_storing = True
self._prev_obs = deepcopy(obs)
return obs, r, is_done, is_tr, extra
默认情况下,开始录制的概率很小(START_PROB = 0.00005,即 0.005%的几率),但由于训练过程中我们进行的大量步骤,我们仍然有足够的片段可以标注。例如,在 1200 万环境步骤(约 5 小时的训练)之后,数据库中包含了 2,500 个录制的片段,占用了 12GB 的磁盘空间。
方法 step()使用函数 store_segment()存储 EpisodeStep 对象的列表,这实际上是对步骤列表的 pickle.dumps()调用:
def store_segment(root_path: pathlib.Path, step_idx: int, steps: tt.List[EpisodeStep]):
out_path = root_path / f"{step_idx:08d}.dat"
dat = pickle.dumps(steps)
out_path.write_bytes(dat)
print(f"Stored {out_path}")
在讨论训练结果之前,我需要提到一个关于包装器使用的小细节,虽然它不大,但很重要。为了让标注更容易,我们存储在数据库中的观察数据是来自标准 Atari 包装器之前的。这虽然增加了我们需要存储的数据量,但人工标注者将看到原始的、色彩丰富的 Atari 屏幕,分辨率为原始的 160 × 192,而不是降级后的灰度图像。
为了实现这一点,包装器在原始 Gymnasium 环境之后、Atari 包装器之前应用。以下是 01_a2c.py 模块中的相关代码片段:
def make_env() -> gym.Env:
e = gym.make("SeaquestNoFrameskip-v4")
if reward_path is not None:
p = pathlib.Path(reward_path)
e = rlhf.RewardModelWrapper(e, p, dev=dev, metrics_queue=metrics_queue)
if db_path is not None:
p = pathlib.Path(db_path)
p.mkdir(parents=True, exist_ok=True)
e = rlhf.EpisodeRecorderWrapper(e, p, env_idx=env_idx)
e = ptan.common.wrappers.wrap_dqn(e)
# add time limit after all wrappers
e = gym.wrappers.TimeLimit(e, TIME_LIMIT)
return e
训练过程的超参数来自论文(学习率下降计划、网络架构、环境数量等)。我让它训练了 5 小时,进行了 1200 万次观察。测试结果的图表显示在图 19.2 中。

图 19.2:A2C 训练过程中的奖励(左)和步骤(右)
最佳模型能够达到 460 的奖励水平(环境中没有奖励裁剪),虽然很不错,但与时不时补充氧气所能达到的结果相比要差得多。
该模型的游戏视频可以在youtu.be/R_H3pXu-7cw观看。正如你从视频中看到的,我们的智能体几乎完美地掌握了射击鱼类的技巧,但它在浮在底部的局部最优解上卡住了(可能因为在那里更安全,敌方潜艇不在那里),并且对氧气补充一无所知。
你可以使用工具 01_play.py 从模型文件录制自己的视频,输入模型文件名即可。
标注过程
在 A2C 训练过程中,我们获得了 12GB 的 2,500 个随机剧集片段。每个片段包含 50 个步骤,包含屏幕观察和智能体在每一步采取的动作。现在我们已经准备好进行 RLHF 管道的标注过程。
在标注过程中,我们需要随机抽取剧集片段对并展示给用户,询问“哪个更好?”。答案应存储用于奖励模型的训练。正是这个逻辑在 02_label_ui.py 中实现。
标注过程的 UI 作为一个 web 应用实现,使用了 NiceGUI 库(nicegui.io/)。NiceGUI 允许用 Python 实现现代 web 应用 UI,并提供了一套丰富的交互式 UI 控件,如按钮、列表、弹出对话框等。原则上,你不需要了解 JavaScript 和 CSS(但如果你熟悉它们也无妨)。如果你以前从未使用过 NiceGUI,也没问题;你只需在 Python 环境中通过以下命令安装它:
pip install nicegui==1.4.26
要启动标注 UI(在安装 NiceGUI 包之后),你需要指定存储剧集片段的数据库路径:
Chapter19$ ./02_label_ui.py -d db-v0
NiceGUI ready to go on http://localhost:8080, http://172.17.0.1:8080, http://172.18.0.1:8080, and http://192.168.10.8:8080
界面通过 HTTP 提供服务(所以,可以在浏览器中打开),并监听所有机器接口上的 8080 端口,这在你将其部署到远程服务器时非常方便(但你需要意识到可能的外部访问风险,因为标注 UI 完全没有身份验证和授权)。如果你想更改端口或将范围限制到特定的网络接口,只需修改 02_label_ui.py。让我们看一下标注界面的截图:

图 19.3:带有数据库信息的标注 UI 部分
这个界面非常基础:左侧有三个链接,指向 UI 功能的不同部分:
-
概览显示数据库路径、其中包含的片段总数以及已创建的标签数量。
-
标注新数据样本随机配对片段并允许你为其添加标签。
-
“现有标签”显示所有标签,并允许在需要时修改标签。
如有需要,可以通过点击左上角的按钮(带有三个横线)隐藏或显示包含链接的列表。最多的时间花费在“标注新数据”部分,见图 ??:

图 19.4:添加新标签的界面(为了更好地可视化,参考 https://packt.link/gbp/9781835882702)
这里我们有一个包含 20 对随机抽取的剧集片段的列表,可以进行标注。当列表中的条目被选择时,界面会显示这两段片段(作为代码实时生成的动画 GIF)。用户可以点击三个按钮中的一个来添加标签:
-
1 更好(1):将第一个片段标记为首选。在奖励模型训练过程中,这样的条目会有 μ[1] = 1.0 和 μ[2] = 0.0。
-
两者都好(0):将两个片段标记为同样好(或差),赋值 μ[1] = 0.5 和 μ[2] = 0.5。
-
2 更好(2):将第二个片段标记为首选(μ[1] = 0.0 和 μ[2] = 1.0)。
你可以通过使用键盘上的 0(“两者都好”)、1(“第一个更好”)或 2(“第二个更好”)来分配标签,而无需点击 UI 按钮。标签分配完成后,UI 会自动选择列表中的下一个未标记条目,这样整个标记过程仅使用键盘就能完成。当你完成列表中的所有标签后,可以点击 RESAMPLE LIST 按钮加载 20 个新的样本进行标记。
在每个标签被分配后(通过点击 UI 按钮或按下键盘键),这些标签会存储在 DB 目录根目录下的 JSON 文件 labels.json 中。该文件采用简单的 JSON 行格式,每行都是一个包含段落路径(相对于 DB 根目录)和已分配标签的条目:
Chapter19$ head db-v0/labels.json
{"sample1":"14/00023925.dat","sample2":"10/00606788.dat","label":0}
{"sample1":"02/01966114.dat","sample2":"10/01667833.dat","label":2}
{"sample1":"00/02432057.dat","sample2":"06/01410909.dat","label":1}
{"sample1":"01/02293138.dat","sample2":"11/00997214.dat","label":0}
{"sample1":"10/00091149.dat","sample2":"11/01262679.dat","label":2}
{"sample1":"12/01394239.dat","sample2":"04/01792088.dat","label":2}
{"sample1":"10/01390371.dat","sample2":"09/00077676.dat","label":0}
{"sample1":"10/01390371.dat","sample2":"09/00077676.dat","label":1}
{"sample1":"12/02339611.dat","sample2":"00/02755898.dat","label":2}
{"sample1":"06/00301623.dat","sample2":"06/00112361.dat","label":2}
如果需要,可以通过使用“现有标签”链接(如图 19.5 所示)来查看现有标签,该界面几乎与“标记新数据”相同,不同之处在于它显示的不是 20 个新采样的对,而是已经标记的对。这些对可以通过点击按钮或使用前面描述的键盘快捷键进行更改。

图 19.5:查看和编辑旧标签的界面(为了更好的可视化,参见 https://packt.link/gbp/9781835882702 )
在我的实验中,我进行了第一轮标记,共标记了 100 对样本,主要关注潜水艇出现在水面上的罕见情况(标记为好)和氧气不足时更为常见的情况(标记为坏)。在其他情况下,我更倾向于选择那些鱼群被正确击中的段落。有了这些标签,我们就可以进入下一步:奖励模型训练。
奖励模型训练
奖励模型网络大多数结构来自论文,唯一的不同在于如何处理动作。在论文中,作者没有明确说明如何考虑动作,只是提到“对于奖励预测器,我们使用 84 × 84 的图像作为输入(与策略的输入相同),并将 4 帧图像堆叠在一起,形成总共 84 × 84 × 4 的输入张量。”根据这一点,我假设奖励模型通过帧之间的动态“隐式”地扣除动作。我在实验中没有尝试这种方法,而是决定通过将 one-hot 编码与从卷积层获得的向量拼接在一起,显式地向网络展示动作。作为一个练习,你可以修改我的代码,使用论文中的方法并比较结果。其余的架构和训练参数与论文中的相同。接下来,让我们看一下奖励模型网络的代码:
class RewardModel(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...], n_actions: int):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 16, kernel_size=7, stride=3),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Conv2d(16, 16, kernel_size=5, stride=2),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=1),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=1),
nn.BatchNorm2d(16),
nn.Dropout(p=0.5),
nn.LeakyReLU(),
nn.Flatten(),
)
size = self.conv(torch.zeros(1, *input_shape)).size()[-1]
self.out = nn.Sequential(
nn.Linear(size + n_actions, 64),
nn.LeakyReLU(),
nn.Linear(64, 1),
)
def forward(self, obs: torch.ByteTensor, acts: torch.Tensor) -> torch.Tensor:
conv_out = self.conv(obs / 255)
comb = torch.hstack((conv_out, acts))
out = self.out(comb)
return out
正如你所看到的,卷积层与批量归一化、丢弃层和 leaky ReLU 激活函数结合使用。
奖励模型的训练在 03_reward_train.py 中实现,过程没有什么复杂的。我们从 JSON 文件中加载标注数据(你可以在命令行中传递多个数据库来用于训练),使用 20% 的数据进行测试,并计算二元交叉熵目标,这在 calc_loss() 函数中实现:
def calc_loss(model: rlhf.RewardModel, s1_obs: torch.ByteTensor,
s1_acts: torch.Tensor, s2_obs: torch.ByteTensor,
s2_acts: torch.Tensor, mu: torch.Tensor) -> torch.Tensor:
batch_size, steps = s1_obs.size()[:2]
s1_obs_flat = s1_obs.flatten(0, 1)
s1_acts_flat = s1_acts.flatten(0, 1)
r1_flat = model(s1_obs_flat, s1_acts_flat)
r1 = r1_flat.view((batch_size, steps))
R1 = torch.sum(r1, 1)
s2_obs_flat = s2_obs.flatten(0, 1)
s2_acts_flat = s2_acts.flatten(0, 1)
r2_flat = model(s2_obs_flat, s2_acts_flat)
r2 = r2_flat.view((batch_size, steps))
R2 = torch.sum(r2, 1)
R = torch.hstack((R1.unsqueeze(-1), R2.unsqueeze(-1)))
loss_t = F.binary_cross_entropy_with_logits(R, mu)
return loss_t
最初,我们的观察和动作张量具有以下结构:观察为(batch,time,colors,height,width),动作为(batch,time,actions),其中 time 是序列的时间维度。更具体地说,观察张量的大小为 64 × 50 × 3 × 210 × 160,动作的大小为 64 × 50 × 18。
作为损失计算的第一步,我们展平前两个维度,去除时间维度,并应用模型计算奖励值 r̂(o,a)。之后,我们恢复时间维度,并根据我们已经讨论过的论文公式沿时间维度求和。然后,我们的损失计算是应用 torch 函数来计算二元交叉熵。
在每个训练周期中,我们计算测试损失(基于 20% 的数据),并在新损失低于先前测试损失的最小值时保存奖励模型。如果训练损失连续四个周期增长,我们将停止训练。
在前一节中设置的标签数量(几百个)下,训练非常快速——大约十几个周期和几分钟时间。以下是示例训练过程。命令行参数 -o 指定保存最佳模型的目录名称:
Chapter19$ ./03_reward_train.py --dev cuda -n v0-rw -o rw db-v0
Namespace(dev=’cuda’, name=v0-rw’, out=’rw’, dbs=[’db-v0’])
Loaded DB from db-v0 with 149 labels and 2534 paths
RewardModel(
(conv): Sequential(
(0): Conv2d(3, 16, kernel_size=(7, 7), stride=(3, 3))
(1): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): Dropout(p=0.5, inplace=False)
(3): LeakyReLU(negative_slope=0.01)
(4): Conv2d(16, 16, kernel_size=(5, 5), stride=(2, 2))
(5): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(6): Dropout(p=0.5, inplace=False)
(7): LeakyReLU(negative_slope=0.01)
(8): Conv2d(16, 16, kernel_size=(3, 3), stride=(1, 1))
(9): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(10): Dropout(p=0.5, inplace=False)
(11): LeakyReLU(negative_slope=0.01)
(12): Conv2d(16, 16, kernel_size=(3, 3), stride=(1, 1))
(13): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(14): Dropout(p=0.5, inplace=False)
(15): LeakyReLU(negative_slope=0.01)
(16): Flatten(start_dim=1, end_dim=-1)
)
(out): Sequential(
(0): Linear(in_features=8978, out_features=64, bias=True)
(1): LeakyReLU(negative_slope=0.01)
(2): Linear(in_features=64, out_features=1, bias=True)
)
)
Epoch 0 done, train loss 0.131852, test loss 0.132976
Save model for 0.13298 test loss
Epoch 1 done, train loss 0.104426, test loss 0.354560
Epoch 2 done, train loss 0.159513, test loss 0.170160
Epoch 3 done, train loss 0.054362, test loss 0.066557
Save model for 0.06656 test loss
Epoch 4 done, train loss 0.046695, test loss 0.121662
Epoch 5 done, train loss 0.055446, test loss 0.064895
Save model for 0.06490 test loss
Epoch 6 done, train loss 0.024505, test loss 0.025308
Save model for 0.02531 test loss
Epoch 7 done, train loss 0.015864, test loss 0.045814
Epoch 8 done, train loss 0.024745, test loss 0.054631
Epoch 9 done, train loss 0.027670, test loss 0.054107
Epoch 10 done, train loss 0.025979, test loss 0.048673
Best test loss was less than current for 4 epoches, stop
将 A2C 与奖励模型相结合
一旦奖励模型训练完成,我们最终可以尝试将其用于 RL 训练。为此,我们使用相同的工具 01_a2c.py,但提供几个额外的参数:
-
-r 或 --reward:这是奖励模型的路径,用于加载和使用。通过此选项,我们不使用环境奖励,而是使用模型从我们决定采取的观察和动作中获得奖励。这作为额外的环境包装器实现;我们稍后会详细介绍。
-
-m 或 --model:这是要加载的演员模型的路径(存储在先前 A2C 训练轮次中)。由于我正在使用 RLHF 进行微调,而不是从头开始使用奖励模型训练,因此需要演员模型。原则上,你可以尝试使用奖励模型从零开始训练,但我的实验结果并不十分成功。
-
--finetune:启用微调模式:卷积层被冻结,学习率降低 10 倍。没有这些修改,演员很快就会忘记所有先前的知识,奖励几乎降到零。
因此,要使用我们刚刚训练的奖励模型,命令行看起来像这样:
./01_a2c.py --dev cuda -n v1 -r rw/reward-v0.dat --save save/v1 -m save/v0/model_rw=460-steps=580.dat --finetune
在检查实验结果之前,让我们看看奖励模型如何在 RL 训练过程中使用。为了最小化所需的改动,我实现了一个环境包装器,它被添加在原始环境和 Atari 包装器之间,因为奖励模型需要一个未经缩放的全彩游戏图像。
包装器的代码在 lib/rlhf.py 中,名为 RewardModelWrapper。包装器的构造函数从数据文件中加载模型并分配一些字段。根据论文,奖励模型预测的奖励经过标准化,使其均值为零,方差为一。因此,为了进行标准化,包装器维护了最后 100 个奖励值,使用 collections.deque。此外,包装器还可以有一个队列,用于发送指标。该指标包含关于标准化值和来自底层环境的真实总和的信息:
class RewardModelWrapper(gym.Wrapper):
KEY_REAL_REWARD_SUM = "real_reward_sum"
KEY_REWARD_MU = "reward_mu"
KEY_REWARD_STD = "reward_std"
def __init__(self, env: gym.Env, model_path: pathlib.Path, dev: torch.device,
reward_window: int = 100, metrics_queue: tt.Optional[queue.Queue] = None):
super().__init__(env)
self.device = dev
assert isinstance(env.action_space, gym.spaces.Discrete)
s = env.observation_space.shape
self.total_actions = env.action_space.n
self.model = RewardModel(
input_shape=(s[2], s[0], s[1]), n_actions=self.total_actions)
self.model.load_state_dict(torch.load(model_path, map_location=torch.device(’cpu’),
weights_only=True))
self.model.eval()
self.model.to(dev)
self._prev_obs = None
self._reward_window = collections.deque(maxlen=reward_window)
self._real_reward_sum = 0.0
self._metrics_queue = metrics_queue
在 reset()方法中,我们只需要记住观察并重置奖励计数器:
def reset(self, *, seed: int | None = None, options: dict[str, tt.Any] | None = None) \
-> tuple[WrapperObsType, dict[str, tt.Any]]:
res = super().reset(seed=seed, options=options)
self._prev_obs = deepcopy(res[0])
self._real_reward_sum = 0.0
return res
包装器的主要逻辑在 step()函数中,但并不复杂:我们将模型应用于观察和动作,标准化奖励,并返回它,而不是返回真实的奖励。从性能角度来看,模型应用效率不是很高,可能需要优化(因为我们有多个环境并行运行),但我决定先实现简单版本,把优化留给你作为练习:
def step(self, action: WrapperActType) -> tuple[
WrapperObsType, SupportsFloat, bool, bool, dict[str, tt.Any]
]:
obs, r, is_done, is_tr, extra = super().step(action)
self._real_reward_sum += r
p_obs = np.moveaxis(self._prev_obs, (2, ), (0, ))
p_obs_t = torch.as_tensor(p_obs).to(self.device)
p_obs_t.unsqueeze_(0)
act = np.eye(self.total_actions)[[action]]
act_t = torch.as_tensor(act, dtype=torch.float32).to(self.device)
new_r_t = self.model(p_obs_t, act_t)
new_r = float(new_r_t.item())
# track reward for normalization
self._reward_window.append(new_r)
if len(self._reward_window) == self._reward_window.maxlen:
mu = np.mean(self._reward_window)
std = np.std(self._reward_window)
new_r -= mu
new_r /= std
self._metrics_queue.put((self.KEY_REWARD_MU, mu))
self._metrics_queue.put((self.KEY_REWARD_STD, std))
if is_done or is_tr:
self._metrics_queue.put((self.KEY_REAL_REWARD_SUM, self._real_reward_sum))
self._prev_obs = deepcopy(obs)
return obs, new_r, is_done, is_tr, extra
剩下的训练部分相同。我们只需在环境创建函数中注入新的包装器(如果命令行中给定了奖励模型文件):
def make_env() -> gym.Env:
e = gym.make("SeaquestNoFrameskip-v4")
if reward_path is not None:
p = pathlib.Path(reward_path)
e = rlhf.RewardModelWrapper(e, p, dev=dev, metrics_queue=metrics_queue)
if db_path is not None:
p = pathlib.Path(db_path)
p.mkdir(parents=True, exist_ok=True)
e = rlhf.EpisodeRecorderWrapper(e, p, env_idx=env_idx)
e = ptan.common.wrappers.wrap_dqn(e)
# add time limit after all wrappers
e = gym.wrappers.TimeLimit(e, TIME_LIMIT)
return e
使用这段代码,我们现在可以将之前的模型与之前制作的标签结合起来。
使用 100 个标签进行微调
我使用从基本 A2C 训练中得到的最佳模型进行了训练,在测试中,该模型在 580 步内获得了 460 的奖励。此外,我启用了将回合片段采样到新 DB 目录(此处为 v1)的功能,因此完整的命令行如下:
./01_a2c.py --dev cuda -n v1 -r rw/reward-v0.dat --save save/v1 -m save/v0/model_rw=460-steps=580.dat --finetune --db-path v1 该模型很快就开始过拟合,在 2M 步(3 小时)后,我停止了训练。图 19.6 显示了测试结果(奖励和步骤数):

图 19.6:微调过程中测试奖励(左)和步骤(右)
图 19.7 显示了训练奖励(由模型预测)和总损失:

图 19.7:微调过程中训练奖励(左)和总损失(右)
最佳模型保存在 500K 训练步时,它能够在 1,120 步内获得 900 的奖励。与原始模型相比,这是一个相当大的改进。
该模型的视频记录可以在这里查看:youtu.be/LnPwuyVrj9g。从游戏玩法来看,我们看到代理学会了如何补充氧气,并且现在在屏幕中央停留了一段时间。我也有印象它更有意地选择了潜水员(但我并没有为这种行为做具体标注)。总体来说,这个方法有效,并且仅凭 100 个标签就能教会代理一些新东西,真的很令人印象深刻。
让我们通过更多的标注进一步改进模型。
第二轮实验
在第二轮实验中,我做了更多的标注:50 对来自 v0 数据库,50 对来自微调过程中存储的片段(v1 数据库)。在微调过程中生成的数据库(v1)包含了更多的潜艇漂浮在水面的片段,这证明我们的管道运行正常。在标注时,我也更加重视氧气补充的片段。
标注后,我重新训练了奖励模型,这只用了几分钟。然后,使用奖励模型对最佳 v1 模型(奖励为 900,步数为 1,120)进行了微调。
图 19.8 和图 19.9 包含了测试结果的图表、奖励训练和损失:

图 19.8:微调过程中的测试奖励(左)和步数(右)

图 19.9:微调过程中的训练奖励(左)和总损失(右)
在 1.5M 步(2 小时)之后,训练停滞了,但最佳模型并不比 v1 的最佳模型更好:最佳模型在 1,084 步中获得了 860 的奖励。
第三轮实验
在这里,我在标注时更加注意,不仅优先考虑氧气补充,还考虑了更好的鱼类射击和潜水员接取。不幸的是,100 对标签中只出现了几个潜水员的例子,因此需要更多的标注来教会代理这种行为。
关于潜水员,代理可能没有接取他们,因为潜水员与背景非常难以区分,在灰度图像中是不可见的。为了解决这个问题,我们可以调整 Atari 包装器中的对比度。
在奖励模型重新训练后,开始了 A2C 的微调。我也运行了大约 2M 步,持续了 3 小时,结果很有趣。在训练结束时(查看图 19.10 和图 19.11),测试中的船只达到了 5,000 步(这是我在环境中设定的限制),但得分相对较低。很可能,潜艇只是停留在水面上,这是非常安全的,但这不是我们想要的——这可能是由于标注样本的原因。奇怪的是,当我尝试录制这些后期模型的视频时,它们的行为发生了变化,步数也明显较低,这可能是测试中的某个 bug。

图 19.10:微调过程中的测试奖励(左)和步骤数(右)

图 19.11:微调过程中训练奖励(左)和总损失(右)
在过拟合之前,训练生成了几种比 v2 模型更好的策略。例如,在这个录音中,代理进行了两次氧气补充,并在 1,613 步中获得了 1,820 分:youtu.be/DVe_9b3gdxU。
总体结果
在下表中,我总结了实验回合的相关信息和我们得到的结果。
| 步骤 | 标签 | 奖励 | 步骤数 | 视频 |
|---|---|---|---|---|
| 初始 | 无 | 460 | 580 | youtu.be/R_H3pXu-7cw |
| v1 | 100 | 900 | 1120 | youtu.be/LnPwuyVrj9g |
| v2 | 200 | 860 | 1083 | |
| v3 | 300 | 1820 | 1613 | youtu.be/DVe_9b3gdxU |
表 19.1:实验回合总结
正如你所看到的,凭借仅仅 300 个标签,我们成功将分数提高了近 4 倍。作为一个练习,你可以尝试教代理捡起潜水员,如果做得好,可能会得到更好的成绩。
另一个可能值得尝试的实验是微调原始 v0 模型,而不是前一步中的最佳模型。这可能会导致更好的结果,因为训练在过拟合之前有更多时间。
总结
在本章中,我们了解了 RLHF 在 RL 工具箱中的新加入。这种方法是 LLM 训练流程的核心,可以提高模型的质量。在本章中,我们实现了 RLHF,并将其应用于 SeaQuest Atari 游戏,这应该向你展示了这种方法如何在 RL 流水线中用于模型改进。
在下一章中,我们将讨论另一类 RL 方法:AlphaGo、AlphaZero 和 MuZero。
第二十章:AlphaGo Zero 和 MuZero
基于模型的方法通过建立环境模型并在训练过程中使用它,帮助我们减少与环境的通信量。在本章中,我们通过探讨在我们拥有环境模型,但这个环境被两个竞争方使用的情况,来了解基于模型的方法。这种情况在棋类游戏中非常常见,游戏规则固定且整个局面可观察,但我们有一个对手,其主要目标是阻止我们赢得比赛。
几年前,DeepMind 提出了一个非常优雅的解决此类问题的方法。该方法不需要任何先前的领域知识,且智能体仅通过自我对弈来改善其策略。这个方法被称为 AlphaGo Zero,并在 2017 年推出。随后,在 2020 年,他们通过去除对环境模型的要求,扩展了该方法,使其能够应用于更广泛的强化学习问题(包括 Atari 游戏)。这个方法叫做 MuZero,我们也将详细探讨它。正如你将在本章中看到的,MuZero 比 AlphaGo Zero 更通用,但也伴随着更多需要训练的网络,可能导致更长的训练时间和更差的结果。从这个角度来看,我们将详细讨论这两种方法,因为在某些情况下,AlphaGo Zero 可能更具应用价值。
在本章中,我们将:
-
讨论 AlphaGo Zero 方法的结构
-
实现连接 4(Connect 4)游戏的玩法方法
-
实现 MuZero 并与 AlphaGo Zero 进行比较
比较基于模型和非基于模型的方法
在第四章中,我们看到了几种不同的方式来分类强化学习方法。我们区分了三大类:
-
基于价值和基于策略
-
基于策略和离策略
-
非基于模型和基于模型
到目前为止,我们已经涵盖了第一类和第二类中方法的足够示例,但我们迄今为止讨论的所有方法都是 100% 非基于模型的。然而,这并不意味着非基于模型的方法比基于模型的方法更重要或更好。从历史上看,由于样本效率高,基于模型的方法一直被应用于机器人领域和其他工业控制中。这也部分是因为硬件的成本以及从真实机器人中获得的样本的物理限制。具有较大自由度的机器人并不容易获得,因此强化学习研究者更专注于计算机游戏和其他样本相对便宜的环境。然而,机器人学的理念正渗透到强化学习中,因此,谁知道呢,也许基于模型的方法很快会成为关注的重点。首先,让我们讨论一下我们在本书中使用的非基于模型方法与基于模型方法的区别,包括它们的优缺点以及它们可能的应用场景。
在这两种方法的名称中,“模型”指的是环境的模型,它可以有多种形式,例如,通过当前状态和动作为我们提供新的状态和奖励。迄今为止涵盖的所有方法都没有做出任何努力去预测、理解或模拟环境。我们感兴趣的是正确的行为(以最终奖励为准),无论是直接指定(策略)还是间接指定(价值),这些都是基于观察得出的。观察和奖励的来源是环境本身,在某些情况下可能非常缓慢和低效。
在基于模型的方法中,我们试图学习环境模型,以减少对“真实环境”的依赖。总体而言,模型是一种黑箱,近似我们在第一章中讨论过的真实环境。如果我们有一个准确的环境模型,我们的智能体可以通过使用这个模型,而非在现实世界中执行动作,轻松地产生它所需的任何轨迹。
在某种程度上,强化学习研究的常见试验场也是现实世界的模型;例如,MuJoCo 和 PyBullet 是物理模拟器,用来避免我们需要构建拥有真实驱动器、传感器和摄像头的真实机器人来训练我们的智能体。这个故事在 Atari 游戏或 TORCS(开放赛车模拟器)中也是一样:我们使用模拟某些过程的计算机程序,这些模型可以快速而廉价地执行。即使是我们的 CartPole 例子,也是对一个附有杆的真实小车的简化近似。(顺便提一句,在 PyBullet 和 MuJoCo 中,还有更真实的 CartPole 版本,具备 3D 动作和更精确的模拟。)
使用基于模型的方法而非无模型方法有两个动机:
-
第一个也是最重要的原因是样本效率,源于对真实环境的依赖较少。理想情况下,通过拥有一个精确的模型,我们可以避免接触真实世界,仅使用训练好的模型。在实际应用中,几乎不可能拥有一个精确的环境模型,但即便是一个不完美的模型,也能显著减少所需样本的数量。
例如,在现实生活中,你不需要对某个动作(如系鞋带或过马路)有绝对精确的心理图像,但这个图像有助于你进行规划和预测结果。
-
基于模型方法的第二个原因是环境模型在不同目标之间的可转移性。如果你拥有一个优秀的机器人操控臂模型,你可以在不重新训练的情况下,利用它完成各种不同的目标。
这类方法有很多细节,但本章的目的是为你提供一个概览,并更深入地探讨应用于棋盘游戏的基于模型的方法。
基于模型的方法在棋盘游戏中的应用
大多数棋类游戏提供了与街机场景不同的设定。Atari 游戏系列假设一个玩家在某个环境中做决策,该环境具有复杂的动态。通过从他们的行动结果中进行泛化和学习,玩家能够提升技能,增加最终得分。然而,在棋类游戏的设定中,游戏规则通常非常简单和紧凑。使游戏复杂的因素是棋盘上不同位置的数量,以及存在一个对手,他有着未知的策略,试图赢得比赛。
对于棋类游戏,观察游戏状态的能力和明确规则的存在使得分析当前局势成为可能,这在 Atari 游戏中并不适用。这种分析意味着我们需要获取当前的游戏状态,评估我们可以进行的所有可能动作,然后选择最好的行动作为我们的动作。为了能够评估所有可能的动作,我们需要某种游戏模型来捕捉游戏规则。
评估的最简单方法是遍历所有可能的动作,并在执行动作后递归地评估该位置。最终,这个过程将引导我们到达最终位置,届时将不再有可能的移动。通过将游戏结果反向传播,我们可以估算任何位置上任何动作的预期值。这种方法的一种变体叫做极小极大法(minimax),它的核心是在我们试图做出最强的移动时,对手则试图为我们做出最坏的移动,因此我们在游戏状态树中迭代地最小化和最大化最终的游戏目标(该过程将在后面详细描述)。
如果不同位置的数量足够小,可以完全分析,比如井字游戏(只有 138 个终局状态),那么从我们当前拥有的任何状态出发,遍历这个游戏树并找出最佳行动并不成问题。不幸的是,这种暴力破解方法即使对于中等复杂度的游戏也不可行,因为配置数量呈指数增长。例如,在跳棋游戏中,整个游戏树有 5 ⋅ 10²⁰ 个节点,这对于现代硬件来说是一个相当大的挑战。在更复杂的游戏中,比如国际象棋或围棋,这个数字更大,因此完全分析从每个状态可以到达的所有位置几乎是不可能的。为了解决这个问题,通常会使用某种近似方法,在某个深度上分析游戏树。通过结合精心的搜索和停止标准(称为树剪枝)以及智能的预定义位置评估,我们可以制作一个能在相当高水平上进行复杂游戏的计算机程序。
AlphaGo Zero 方法
2017 年底,DeepMind 在《自然》杂志上发表了由 Silver 等人撰写的文章《无须人类知识的围棋游戏掌握》[SSa17],介绍了一种名为 AlphaGo Zero 的新方法,该方法能够在没有任何先验知识(除了规则)的情况下,达到超越人类的水平来玩复杂的游戏,如围棋和国际象棋。该代理能够通过不断自我对弈并反思结果来改进其策略。不需要大型的游戏数据库、手工特征或预训练的模型。该方法的另一个优点是其简洁性和优雅性。
在本章的示例中,我们将尝试理解并实现这种方法,应用于游戏“连接四”(也叫“四连棋”或“直线四”),以便自行评估其效果。
首先,我们将讨论该方法的结构。整个系统包含几个部分,在我们实现它们之前,需要先理解这些部分。
概述
从高层次来看,该方法由三个组件组成,所有这些将在后面详细解释,因此如果这一部分没有完全清楚,不必担心:
-
我们不断地使用蒙特卡罗树搜索(MCTS)算法遍历游戏树,其核心思想是半随机地走过游戏状态,扩展它们并收集关于每一步动作和潜在游戏结果的统计数据。由于游戏树庞大,深度和宽度都非常大,我们并不尝试构建完整的树,而是随机抽样其最有前景的路径(这就是该方法名称的来源)。
-
在每一时刻,我们都有当前最强的玩家,这是通过自我对弈生成数据的模型(这一概念将在后面详细讨论,但现在你只需要知道它指的是同一个模型与自己对弈)。最初,这个模型具有随机的权重,因此它的行动是随机的,就像一个四岁的孩子在学习棋子如何移动。然而,随着时间的推移,我们用它的更好变种替换这个最强的玩家,生成越来越有意义和复杂的游戏场景。自我对弈意味着同一个当前最强的模型在棋盘的两边同时使用。这看起来可能没什么用处,因为让同一个模型与自己对弈的结果大约是 50%的概率,但实际上这正是我们所需要的:我们的最佳模型能够展示其最佳技能的游戏样本。这个类比很简单:通常看外围选手与领头选手的比赛并不特别有趣;领头选手会轻松获胜。更有趣、更吸引人的情景是大致相等技能的选手对抗。因此,任何锦标赛的决赛总是比之前的比赛更受关注:决赛中的两个队伍或选手通常都擅长比赛,因此他们需要发挥出最佳水平才能获胜。
-
方法中的第三个组成部分是学徒模型的训练过程,该模型是在最佳模型通过自我对弈所收集的数据上训练的。这个模型可以比作一个孩子,坐在旁边不断分析两位成年人下的棋局。定期地,我们会进行几场这位训练模型与我们当前最佳模型的比赛。当学徒能够在大多数游戏中击败最佳模型时,我们宣布该训练模型为新的最佳模型,然后继续这一过程。
尽管这看起来简单甚至有些天真,AlphaGo Zero 仍然能够击败所有之前的 AlphaGo 版本,成为世界上最强的围棋玩家,且没有任何先验知识,只有规则。Silver 等人发布的论文[SSa17]之后,DeepMind 将该方法适应于国际象棋,并发布了名为《通过自我对弈和通用强化学习算法掌握国际象棋和将棋》的论文[Sil+17],其中从零开始训练的模型击败了当时最强的国际象棋程序 Stockfish,而 Stockfish 是经过十多年人类专家开发的。
现在,让我们详细了解该方法的三个组成部分。
MCTS
为了理解 MCTS 的工作原理,我们来考虑井字游戏的一个简单子树,如图 20.1 所示。开始时,游戏场地为空,交叉玩家(X)需要选择一个位置。第一次移动有九个不同的选择,所以我们的根节点有九个不同的分支,指向相应的状态。

图 20.1:井字游戏的游戏树
在任何游戏状态下的可选动作数量称为分支因子,它展示了游戏树的分支密度。一般来说,这个值不是常数,可能会有所变化,因为并不是所有的动作都是可行的。在井字游戏的情况下,可用的动作数量可能从游戏开始时的九个变化到叶节点时的零个。分支因子可以帮助我们估计游戏树的增长速度,因为每一个可用动作都会导致下一层的可执行动作。
对于我们的例子,在交叉玩家(X)走完一步后,零(0)在每个九个位置上有八个选择,这使得在树的第二层共有 9 × 8 个位置。树中节点的总数最多可达 9! = 362880,但实际数量较少,因为并非所有的游戏都会走到最大深度。
井字游戏虽然很简单,但如果我们考虑更复杂的游戏,比如思考一下在国际象棋游戏开始时白方的第一步可以走的数量(是 20),或者在围棋中白方棋子可以放置的位置数量(19 × 19 棋盘上总共有 361 个位置),整个树中游戏位置的数量迅速变得庞大。每新增一层,状态数量就会被上一层的平均动作数所乘。
为了应对这种组合爆炸,随机采样开始发挥作用。在一般的 MCTS 中,我们进行多次深度优先搜索,从当前游戏状态开始,随机选择动作或使用某些策略,策略中应该包含足够的随机性。每次搜索都继续进行,直到达到游戏的结束状态,然后根据游戏的结果更新访问过的树分支的权重。这个过程类似于值迭代方法,当我们玩过回合后,回合的最后一步会影响所有前面步骤的值估计。这是一个通用的 MCTS 方法,还有许多与扩展策略、分支选择策略及其他细节相关的变种。在 AlphaGo Zero 中,使用的是 MCTS 的变种。对于每个边(表示从某个位置的走法),这组统计信息被存储:
-
边的先验概率,P(s,a)
-
一个访问计数,N(s,a)
-
一个动作值,Q(s,a)
每次搜索从根状态开始,沿着最有前途的动作前进,这些动作是根据效用值 U(s,a)选择的,效用值与
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq73.png)
在选择过程中加入随机性,以确保足够探索游戏树。每次搜索可能会有两种结果:游戏的最终状态被达成,或者我们遇到一个尚未探索的状态(换句话说,尚未有已知的值)。在后一种情况下,策略神经网络(NN)用于获得先验概率和状态估计值,然后创建一个新的树节点,其中 N(s,a) ← 0,P(s,a) ← p[net](这是网络返回的走法概率),且 Q(s,a) ← 0。除了动作的先验概率外,网络还返回游戏结果的估计值(或从当前玩家视角来看状态的价值)。
一旦我们获得了该值(通过达到最终游戏状态或通过使用神经网络扩展节点),会执行一个叫做值备份的过程。在此过程中,我们遍历游戏路径并更新每个访问过的中间节点的统计信息;特别地,访问计数 N(s,a)会增加 1,Q(s,a)会更新为当前状态下游戏结果的值。由于两个玩家交替进行操作,最终的游戏结果会在每次备份步骤中改变符号。
这个搜索过程会进行多次(在 AlphaGo Zero 中,进行 1,000 到 2,000 次搜索),收集足够的关于动作的统计信息,以便在根节点使用 N(s,a)计数器作为选择的动作概率。
自对弈
在 AlphaGo Zero 中,神经网络用于近似动作的先验概率并评估位置,这与优势演员-评论员(A2C)双头设置非常相似。在网络的输入中,我们传入当前的游戏位置(并加入若干个先前的局面),并返回两个值:
-
策略头返回行动的概率分布。
-
值头估算从玩家视角看待的游戏结果。这个值是未折扣的,因为围棋中的每一步都是确定性的。当然,如果在某个游戏中存在随机性,比如在西洋双陆棋中,就应该使用折扣。
如前所述,我们保持当前最优网络,该网络不断进行自对弈以收集用于训练学徒网络的数据。每一步自对弈游戏都从当前状态开始,执行多次蒙特卡洛树搜索(MCTS),以收集足够的游戏子树统计信息来选择最佳行动。选择依赖于当前的棋步和设置。对于自对弈游戏,为了在训练数据中产生足够的方差,初始几步的选择是随机的。然而,在经过一定数量的步骤后(这也是方法中的超参数),行动选择变得确定性,并且我们选择访问计数最大的行动,N(s,a)。在评估游戏中(当我们对训练中的网络与当前最优模型进行对比时),所有步骤都是确定性的,仅根据最大访问计数来选择行动。
一旦自对弈游戏结束并且最终结果已知,游戏的每一步都会被加入到训练数据集中,数据集是一个元组列表(s[t],π[t],r[t]),其中 s[t]是游戏状态,π[t]是通过 MCTS 采样计算的行动概率,r[t]是玩家在步骤 t 时的游戏结果。
训练与评估
当前最优网络的两个克隆之间的自对弈过程为我们提供了一系列训练数据,这些数据包含了通过自对弈游戏获得的状态、行动概率和位置值。凭借这些数据,在训练过程中,我们从重放缓冲区中抽取小批量的训练样本,并最小化值头预测与实际位置值之间的均方误差(MSE),以及预测概率与采样概率之间的交叉熵损失,π。
如前所述,在几个训练步骤后,训练好的网络会被评估,这包括当前最优网络与训练网络之间的多轮对弈。一旦训练网络的表现显著优于当前最优网络,我们将训练网络复制到最优网络中,并继续该过程。
用 AlphaGo Zero 玩“连接四”
为了观察该方法的实际应用,我们可以为一个相对简单的游戏——Connect 4 实现 AlphaGo Zero。这个游戏是两人对战,棋盘大小为 6 × 7。每位玩家有不同颜色的棋盘,轮流将棋子放入七列中的任何一列。棋子会下落到最底部,垂直堆叠。游戏的目标是率先形成一条水平、垂直或对角线,由四个相同颜色的棋子组成。为了展示游戏,图 20.2 中显示了两个位置。在第一种情况下,第一位玩家刚刚获胜,而在第二种情况下,第二位玩家即将形成一组。

图 20.2:Connect 4 中的两个游戏位置
尽管游戏简单,但它大约有 4.5 × 10¹² 种不同的游戏状态,这对于计算机来说,使用暴力破解是具有挑战性的。这个示例由几个工具和库模块组成:
-
Chapter20/lib/game.py:一个低级别的游戏表示,包含用于执行移动、编码和解码游戏状态以及其他与游戏相关的功能。
-
Chapter20/lib/mcts.py:MCTS 实现,支持 GPU 加速扩展叶节点和节点备份。这里的核心类还负责保持游戏节点统计数据,这些数据在搜索过程中会被重复使用。
-
Chapter20/lib/model.py:神经网络及其他与模型相关的功能,例如游戏状态与模型输入之间的转换,以及单局游戏的进行。
-
Chapter20/train.py:将所有内容连接起来的主要训练工具,并生成新最佳网络的模型检查点。
-
Chapter20/play.py:组织模型检查点之间自动化比赛的工具。它接受多个模型文件,并进行一定数量的对局,以形成排行榜。
-
Chapter20/telegram-bot.py:这是一个用于 Telegram 聊天平台的机器人,允许用户与任何模型文件对战并记录统计数据。此机器人曾用于示例结果的人类验证。
现在让我们讨论一下游戏的核心——游戏模型。
游戏模型
整个方法依赖于我们预测行动结果的能力;换句话说,我们需要能够在执行一步之后,得到最终的游戏状态。这比我们在 Atari 环境和 Gym 中遇到的要求要强得多,因为在这些环境中,你无法指定一个想要从其进行行动的状态。因此,我们需要一个包含游戏规则和动态的模型。幸运的是,大多数棋盘游戏都有简单且紧凑的规则集,这使得模型实现变得直截了当。
在我们的示例中,Connect 4 的完整游戏状态由 6 × 7 游戏场地单元的状态和谁将要移动的指示符表示。对我们的示例来说,重要的是使游戏状态表示占用尽可能少的内存,同时仍能高效工作。内存需求由在 MCTS(蒙特卡洛树搜索)过程中存储大量游戏状态的必要性决定。由于我们的游戏树非常庞大,在 MCTS 过程中能够保持的节点越多,最终对移动概率的近似就越准确。因此,理论上,我们希望能够在内存中保留数百万甚至数十亿个游戏状态。
考虑到这一点,游戏状态表示的紧凑性可能会对内存需求和训练过程的性能产生巨大影响。然而,游戏状态表示必须便于操作,例如,在检查棋盘是否有获胜位置、进行操作或从某个状态找到所有有效的操作时。
为了保持这一平衡,两个游戏场地的表示在 Chapter20/lib/game.py 中实现:
-
第一个编码形式非常节省内存,只需 63 位即可编码完整的场地,这使得它在 64 位架构的机器中非常快速且轻量。
-
另一种解码后的游戏场地表示形式是一个长度为 7 的列表,每个条目是一个表示某列磁盘的整数列表。这种形式需要更多内存,但操作起来很方便。
我不会展示 Chapter20/lib/game.py 的完整代码,但如果需要,可以在仓库中找到。这里,我们只需快速查看它提供的常量和函数列表:
GAME_ROWS = 6
GAME_COLS = 7
BITS_IN_LEN = 3
PLAYER_BLACK = 1
PLAYER_WHITE = 0
COUNT_TO_WIN = 4
INITIAL_STATE = encode_lists([[]] * GAME_COLS)
前面代码中的前两个常量定义了游戏场地的维度,并且在代码中到处使用,因此你可以尝试更改它们,实验更大或更小的游戏变体。BITS_IN_LEN 值用于状态编码函数,并指定用于编码列高度(即当前磁盘数)的位数。在 6 × 7 的游戏中,每列最多可以有六个磁盘,因此三个位足以表示从零到七的值。如果更改了行数,您需要相应地调整 BITS_IN_LEN。
PLAYER_BLACK 和 PLAYER_WHITE 值定义了在解码游戏表示中使用的值,最后,COUNT_TO_WIN 设置了获胜所需形成的连线长度。因此,理论上你可以通过在 game.py 中更改四个数字,尝试修改代码并训练代理进行例如在 20 × 40 场地上五子连珠的游戏。
INITIAL_STATE 值包含了一个初始游戏状态的编码表示,其中 GAME_COLS 为空列表。
剩下的代码由函数组成。其中一些是内部使用的,但有些则提供了一个在示例中到处使用的游戏接口。让我们快速列出它们:
-
encode_lists(state_lists):此函数将游戏状态从解码表示转换为编码表示。参数必须是一个包含 GAME_COLS 列表的列表,每个列的内容按从底到顶的顺序指定。换句话说,要将新棋子放置在堆栈的顶部,我们只需要将其附加到相应的列表中。该函数的结果是一个具有 63 位的整数,表示游戏状态。
-
decode_binary(state_int):此函数将字段的整数表示转换回列表形式。
-
possible_moves(state_int):此函数返回一个列表,其中包含可用于从给定编码游戏状态移动的列的索引。列从左到右编号,从零到六。
-
move(state_int, col, player):文件中的核心函数,结合游戏动态和胜负检查。在参数中,它接受编码形式的游戏状态、放置棋子的列以及当前移动的玩家索引。列索引必须有效(即存在于 possible_moves(state_int) 的结果中),否则会引发异常。该函数返回一个包含两个元素的元组:执行移动后的新编码游戏状态以及一个布尔值,表示该移动是否导致玩家获胜。由于玩家只能在自己移动后获胜,因此一个布尔值足够了。当然,也有可能出现平局状态(当没有人获胜,但没有剩余的有效移动时)。此类情况需要在调用 move() 函数后,调用 possible_moves() 函数进行检查。
-
render(state_int):此函数返回一个字符串列表,表示字段的状态。该函数在 Telegram 机器人中用于将字段状态发送给用户。
实现 MCTS
MCTS 在 Chapter20/lib/mcts.py 中实现,并由一个名为 MCTS 的类表示,该类负责执行一批 MCTS 并保持在过程中收集的统计数据。代码不算很大,但仍有一些棘手的部分,所以让我们仔细检查一下。
构造函数没有任何参数,除了 c_puct 常量,它在节点选择过程中使用。Silver 等人 [SSa17] 提到过可以调整它以增加探索性,但我并没有在任何地方重新定义它,也没有对此进行实验。构造函数的主体创建了一个空容器,用于保存有关状态的统计信息:
class MCTS:
def __init__(self, c_puct: float = 1.0):
self.c_puct = c_puct
# count of visits, state_int -> [N(s, a)]
self.visit_count: tt.Dict[int, tt.List[int]] = {}
# total value of the state’s act, state_int -> [W(s, a)]
self.value: tt.Dict[int, tt.List[float]] = {}
# average value of actions, state_int -> [Q(s, a)]
self.value_avg: tt.Dict[int, tt.List[float]] = {}
# prior probability of actions, state_int -> [P(s,a)]
self.probs: tt.Dict[int, tt.List[float]] = {}
所有字典中的关键字都是编码后的游戏状态(整数),值是列表,保存我们拥有的各种动作参数。每个容器上方的注释使用的值符号与 AlphaGo Zero 论文中的符号相同。
clear() 方法清除状态,但不会销毁 MCTS 对象。当我们将当前最佳模型切换为新模型时,收集的统计数据会变得过时,从而触发这一过程:
def clear(self):
self.visit_count.clear()
self.value.clear()
self.value_avg.clear()
self.probs.clear()
find_leaf() 方法在搜索过程中使用,用于对游戏树进行单次遍历,从由 state_int 参数提供的根节点开始,一直向下遍历,直到遇到以下两种情况之一:到达最终游戏状态或发现一个尚未探索的叶节点。在搜索过程中,我们会跟踪访问过的状态和执行过的动作,以便稍后更新节点的统计信息:
def find_leaf(self, state_int: int, player: int):
states = []
actions = []
cur_state = state_int
cur_player = player
value = None
每次循环迭代都处理我们当前所在的游戏状态。对于该状态,我们提取做出决策所需的统计信息:
while not self.is_leaf(cur_state):
states.append(cur_state)
counts = self.visit_count[cur_state]
total_sqrt = m.sqrt(sum(counts))
probs = self.probs[cur_state]
values_avg = self.value_avg[cur_state]
动作的决策基于动作效用(action utility),它是 Q(s,a) 和根据访问次数缩放的先验概率的和。搜索过程的根节点会向概率中添加额外的噪声,以提高搜索过程的探索性。当我们从不同的游戏状态执行 MCTS 时,这种额外的 Dirichlet 噪声(根据论文中使用的参数)确保我们沿路径尝试了不同的动作:
if cur_state == state_int:
noises = np.random.dirichlet([0.03] * game.GAME_COLS)
probs = [0.75 * prob + 0.25 * noise for prob, noise in zip(probs, noises)]
score = [
value + self.c_puct*prob*total_sqrt/(1+count)
for value, prob, count in zip(values_avg, probs, counts)
]
在我们计算出动作的得分后,我们需要为该状态屏蔽无效的动作。(例如,当列已满时,我们不能在顶部再放一个棋盘。)之后,选择得分最高的动作并记录下来:
invalid_actions = set(range(game.GAME_COLS)) - \
set(game.possible_moves(cur_state))
for invalid in invalid_actions:
score[invalid] = -np.inf
action = int(np.argmax(score))
actions.append(action)
为了结束循环,我们请求游戏引擎进行一步操作,返回新的状态以及玩家是否赢得游戏的标识。最终的游戏状态(胜、负或平)不会被添加到 MCTS 统计信息中,因此它们将始终是叶节点。该函数返回叶节点玩家的游戏值(如果尚未到达最终状态,则为 None)、叶节点状态下的当前玩家、搜索过程中访问过的状态列表以及所执行的动作列表:
cur_state, won = game.move(cur_state, action, cur_player)
if won:
value = -1.0
cur_player = 1-cur_player
# check for the draw
moves_count = len(game.possible_moves(cur_state))
if value is None and moves_count == 0:
value = 0.0
return value, cur_state, cur_player, states, actions
MCTS 类的主要入口点是 search_batch() 函数,该函数执行多个批次的搜索。每个搜索包括找到树的叶节点、可选地扩展叶节点以及进行回溯。这里的主要瓶颈是扩展操作,这需要使用神经网络(NN)来获取动作的先验概率和估计的游戏值。为了提高扩展的效率,我们在搜索多个叶节点时使用小批量(mini-batch)方法,但然后在一次神经网络执行中进行扩展。这种方法有一个缺点:由于在一个批次中执行多个 MCTS,我们得到的结果与串行执行时的结果不同。
确实,最初当我们在 MCTS 类中没有存储任何节点时,我们的第一次搜索将扩展根节点,第二次搜索将扩展它的一些子节点,依此类推。然而,单个搜索批次最初只能扩展一个根节点。当然,后来批次中的单独搜索可以沿着不同的游戏路径进行扩展,但最初,mini-batch 扩展在探索方面远不如顺序 MCTS 高效。
为了补偿这一点,我仍然使用小批量,但执行几个小批量:
def is_leaf(self, state_int):
return state_int not in self.probs
def search_batch(self, count, batch_size, state_int, player, net, device="cpu"):
for _ in range(count):
self.search_minibatch(batch_size, state_int, player, net, device)
在小批量搜索中,我们首先执行叶节点搜索,从相同的状态开始。如果搜索已找到最终的游戏状态(此时返回值不等于 None),则不需要扩展,并且我们将结果保存用于备份操作。否则,我们存储叶节点以便稍后扩展:
def search_minibatch(self, count, state_int, player, net, device="cpu"):
backup_queue = []
expand_states = []
expand_players = []
expand_queue = []
planned = set()
for _ in range(count):
value, leaf_state, leaf_player, states, actions = \
self.find_leaf(state_int, player)
if value is not None:
backup_queue.append((value, states, actions))
else:
if leaf_state not in planned:
planned.add(leaf_state)
leaf_state_lists = game.decode_binary(leaf_state)
expand_states.append(leaf_state_lists)
expand_players.append(leaf_player)
expand_queue.append((leaf_state, states, actions))
为了扩展,我们将状态转换为模型所需的形式(在 model.py 库中有一个特殊的函数),并请求我们的网络返回该批状态的先验概率和值。我们将使用这些概率创建节点,并将在最终的统计更新中备份这些值。
if expand_queue:
batch_v = model.state_lists_to_batch(expand_states, expand_players, device)
logits_v, values_v = net(batch_v)
probs_v = F.softmax(logits_v, dim=1)
values = values_v.data.cpu().numpy()[:, 0]
probs = probs_v.data.cpu().numpy()
节点创建仅仅是为每个动作在访问计数和动作值(总值和平均值)中存储零。在先验概率中,我们存储从网络中获得的值:
for (leaf_state, states, actions), value, prob in \
zip(expand_queue, values, probs):
self.visit_count[leaf_state] = [0]*game.GAME_COLS
self.value[leaf_state] = [0.0]*game.GAME_COLS
self.value_avg[leaf_state] = [0.0]*game.GAME_COLS
self.probs[leaf_state] = prob
backup_queue.append((value, states, actions))
备份操作是 MCTS 中的核心过程,它在搜索过程中更新已访问状态的统计数据。所采取动作的访问计数会递增,总值会相加,并且通过访问计数对平均值进行归一化。
在备份过程中正确跟踪游戏的价值非常重要,因为我们有两个对手,并且在每一轮中,价值的符号都会发生变化(因为当前玩家的胜利位置对对手来说是一个失败的游戏状态):
for value, states, actions in backup_queue:
cur_value = -value
for state_int, action in zip(states[::-1], actions[::-1]):
self.visit_count[state_int][action] += 1
self.value[state_int][action] += cur_value
self.value_avg[state_int][action] = self.value[state_int][action] / \
self.visit_count[state_int][action]
cur_value = -cur_value
类中的最终函数返回动作的概率和游戏状态的动作值,使用在 MCTS 过程中收集的统计数据:
def get_policy_value(self, state_int, tau=1):
counts = self.visit_count[state_int]
if tau == 0:
probs = [0.0] * game.GAME_COLS
probs[np.argmax(counts)] = 1.0
else:
counts = [count ** (1.0 / tau) for count in counts]
total = sum(counts)
probs = [count / total for count in counts]
values = self.value_avg[state_int]
return probs, values
在这里,有两种概率计算模式,由τ参数指定。如果τ等于零,选择变得确定性,因为我们选择访问频率最高的动作。在其他情况下,使用的分布为
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq74.png)
使用了该方法,这同样提高了探索性。
模型
使用的神经网络是一个残差卷积网络,具有六层,是原始 AlphaGo Zero 方法中使用的网络的简化版。对于输入,我们传递编码后的游戏状态,该状态由两个 6 × 7 的通道组成。第一个通道包含当前玩家的棋子位置,第二个通道在对手的棋子位置处值为 1.0。这样的表示方式使我们能够使网络对于玩家不变,并从当前玩家的视角分析局面。
网络由常见的主体部分与残差卷积滤波器组成。由它们产生的特征被传递到策略头和价值头,这两个部分是卷积层和全连接层的结合。策略头返回每个可能动作(放置棋子的列)的 logits 和一个单一的浮动值。详细内容请见 lib/model.py 文件。
除了模型外,这个文件还包含两个函数。第一个名为 state_lists_to_batch(),它将以列表形式表示的游戏状态批次转换为模型的输入格式。此函数使用一个辅助函数 _encode_list_state,它将状态转换为 NumPy 数组:
def _encode_list_state(dest_np, state_list, who_move):
assert dest_np.shape == OBS_SHAPE
for col_idx, col in enumerate(state_list):
for rev_row_idx, cell in enumerate(col):
row_idx = game.GAME_ROWS - rev_row_idx - 1
if cell == who_move:
dest_np[0, row_idx, col_idx] = 1.0
else:
dest_np[1, row_idx, col_idx] = 1.0
def state_lists_to_batch(state_lists, who_moves_lists, device="cpu"):
assert isinstance(state_lists, list)
batch_size = len(state_lists)
batch = np.zeros((batch_size,) + OBS_SHAPE, dtype=np.float32)
for idx, (state, who_move) in enumerate(zip(state_lists, who_moves_lists)):
_encode_list_state(batch[idx], state, who_move)
return torch.tensor(batch).to(device)
第二种方法叫做 play_game,对于训练和测试过程都非常重要。它的目的是模拟两个神经网络(NNs)之间的游戏,执行 MCTS,并可选地将采取的步骤存储在回放缓冲区中:
def play_game(mcts_stores: tt.Optional[mcts.MCTS | tt.List[mcts.MCTS]],
replay_buffer: tt.Optional[collections.deque], net1: Net, net2: Net,
steps_before_tau_0: int, mcts_searches: int, mcts_batch_size: int,
net1_plays_first: tt.Optional[bool] = None,
device: torch.device = torch.device("cpu")):
if mcts_stores is None:
mcts_stores = [mcts.MCTS(), mcts.MCTS()]
elif isinstance(mcts_stores, mcts.MCTS):
mcts_stores = [mcts_stores, mcts_stores]
如您在前面的代码中看到的,函数接受许多参数:
-
MCTS 类实例,它可以是单个实例、两个实例的列表或 None。我们需要在这里保持灵活性,以适应此函数的不同用途。
-
一个可选的回放缓冲区。
-
在游戏中使用的神经网络(NNs)。
-
在进行行动概率计算的参数从 1 更改为 0 之前,需要进行的游戏步骤数。
-
要执行的 MCTS 数量。
-
MCTS 批量大小。
-
哪个玩家先行动。
在游戏循环之前,我们初始化游戏状态并选择第一个玩家。如果没有提供谁先行动的信息,则随机选择:
state = game.INITIAL_STATE
nets = [net1, net2]
if net1_plays_first is None:
cur_player = np.random.choice(2)
else:
cur_player = 0 if net1_plays_first else 1
step = 0
tau = 1 if steps_before_tau_0 > 0 else 0
game_history = []
在每一回合,我们执行 MCTS 以填充统计数据,然后获取行动的概率,随后通过采样得到行动:
result = None
net1_result = None
while result is None:
mcts_stores[cur_player].search_batch(
mcts_searches, mcts_batch_size, state,
cur_player, nets[cur_player], device=device)
probs, _ = mcts_stores[cur_player].get_policy_value(state, tau=tau)
game_history.append((state, cur_player, probs))
action = np.random.choice(game.GAME_COLS, p=probs)
然后,使用游戏引擎模块中的函数更新游戏状态,并处理不同的游戏结束情况(如胜利或平局):
if action not in game.possible_moves(state):
print("Impossible action selected")
state, won = game.move(state, action, cur_player)
if won:
result = 1
net1_result = 1 if cur_player == 0 else -1
break
cur_player = 1-cur_player
# check the draw case
if len(game.possible_moves(state)) == 0:
result = 0
net1_result = 0
break
step += 1
if step >= steps_before_tau_0:
tau = 0
在函数的末尾,我们将从当前玩家的视角填充回放缓冲区,记录行动的概率和游戏结果。这些数据将用于训练网络:
if replay_buffer is not None:
for state, cur_player, probs in reversed(game_history):
replay_buffer.append((state, cur_player, probs, result))
result = -result
return net1_result, step
训练
拥有所有这些功能后,训练过程只需将它们按正确顺序组合。训练程序可以在 train.py 中找到,里面包含的逻辑已经描述过:在循环中,我们当前最好的模型不断地与自己对弈,将步骤保存到回放缓冲区。另一个网络使用这些数据进行训练,最小化从 MCTS 采样的行动概率和策略头结果之间的交叉熵。同时,价值头预测的均方误差(MSE),即游戏结果与实际游戏结果之间的误差,也会加入到总损失中。
定期地,正在训练的网络和当前最佳网络进行 100 场比赛,如果当前网络能够赢得其中超过 60%的比赛,则会同步网络的权重。这个过程会不断重复,最终希望找到越来越精通游戏的模型。
测试与比较
在训练过程中,每当当前最佳模型被训练好的模型替换时,都会保存模型的权重。因此,我们得到了多个强度不同的智能体。从理论上讲,后来的模型应该比前面的模型更好,但我们希望亲自验证这一点。为此,有一个工具 play.py,它接受多个模型文件,并进行锦标赛,每个模型与其他所有模型进行指定回合数的比赛。每个模型的获胜次数将代表该模型的相对强度。
结果
为了加快训练速度,我故意将训练过程中的超参数设置为较小的值。例如,在自对弈的每一步中,只执行了 10 次 MCTS,每次使用一个批量大小为 8 的小批次。这与高效的小批次 MCTS 和快速的游戏引擎相结合,使得训练非常迅速。
基本上,在仅仅进行了一小时的训练和 2,500 场自对弈比赛后,产生的模型已经足够复杂,可以让人享受对战的乐趣。当然,它的水平远低于一个孩子的水平,但它展现出一些基本的策略,而且每隔一回合才犯一次错误,这已经是很好的进步。
我已经进行了两轮训练,第一次学习率为 0.1,第二次学习率为 0.001。每个实验训练了 10 小时,进行了 40K 场游戏。在图 20.3 中,您可以看到关于胜率的图表(当前评估策略与当前最佳策略的胜负比)。如您所见,两个学习率值都在 0.5 附近波动,有时会激增到 0.8-0.9:

图 20.3:使用两种学习率进行训练的胜率;学习率=0.1(左)和学习率=0.001(右)
图 20.4 显示了两次实验的总损失情况,没有明显的趋势。这是由于当前最佳策略的不断切换,导致训练好的模型不断被重新训练。

图 20.4:使用两种学习率进行训练的总损失;学习率=0.1(左)和学习率=0.001(右)
锦标赛验证因模型种类繁多而变得复杂,因为每对模型需要进行若干场比赛以评估它们的强度。一开始,我为每个在每次实验中存储的模型运行了 10 轮(分别进行)。为此,您可以像这样运行 play.py 工具:
./play.py --cuda -r 10 saves/v2/best\_* > semi-v2.txt
但是对于 100 个模型来说,可能需要一些时间,因为每个模型需要与其他所有模型进行 10 回合的比赛。
所有测试结束后,该工具会在控制台上打印所有比赛的结果以及模型的排行榜。以下是实验 1(学习率=0.1)的前 10 名:
saves/t1/best_088_39300.dat: w=1027, l=732, d=1
saves/t1/best_025_09900.dat: w=1024, l=735, d=1
saves/t1/best_022_08200.dat: w=1023, l=737, d=0
saves/t1/best_021_08100.dat: w=1017, l=743, d=0
saves/t1/best_009_03400.dat: w=1010, l=749, d=1
saves/t1/best_014_04700.dat: w=1003, l=757, d=0
saves/t1/best_008_02700.dat: w=998, l=760, d=2
saves/t1/best_010_03500.dat: w=997, l=762, d=1
saves/t1/best_029_11800.dat: w=991, l=768, d=1
saves/t1/best_007_02300.dat: w=980, l=779, d=1
以下是实验 2(学习率=0.001)的前 10 名:
saves/t2/best_069_41500.dat: w=1023, l=757, d=0
saves/t2/best_070_42200.dat: w=1016, l=764, d=0
saves/t2/best_066_38900.dat: w=1005, l=775, d=0
saves/t2/best_071_42600.dat: w=1003, l=777, d=0
saves/t2/best_059_33700.dat: w=999, l=781, d=0
saves/t2/best_049_27500.dat: w=990, l=790, d=0
saves/t2/best_068_41300.dat: w=990, l=789, d=1
saves/t2/best_048_26700.dat: w=983, l=796, d=1
saves/t2/best_058_32100.dat: w=982, l=797, d=1
saves/t2/best_076_45200.dat: w=982, l=795, d=3
为了检查我们的训练是否生成了更好的模型,我在图 20.5 中绘制了模型的胜率与其索引的关系。Y 轴是相对胜率,X 轴是索引(训练过程中索引会增加)。如你所见,每个实验中的模型质量都在提高,但学习率较小的实验有更一致的表现。

图 20.5:训练过程中最佳模型的胜率,学习率=0.1(左)和学习率=0.001(右)
我没有对训练做太多的超参数调优,所以它们肯定可以改进。你可以自己尝试实验一下。
将结果与不同学习率进行比较也很有趣。为此,我选取了每个实验中的 10 个最佳模型,并进行了 10 轮比赛。以下是该比赛的前 10 名排行榜:
saves/t2/best_059_33700.dat: w=242, l=138, d=0
saves/t2/best_058_32100.dat: w=223, l=157, d=0
saves/t2/best_071_42600.dat: w=217, l=163, d=0
saves/t2/best_068_41300.dat: w=210, l=170, d=0
saves/t2/best_076_45200.dat: w=208, l=171, d=1
saves/t2/best_048_26700.dat: w=202, l=178, d=0
saves/t2/best_069_41500.dat: w=201, l=179, d=0
saves/t2/best_049_27500.dat: w=199, l=181, d=0
saves/t2/best_070_42200.dat: w=197, l=183, d=0
saves/t1/best_021_08100.dat: w=192, l=188, d=0
如你所见,使用学习率为 0.001 的模型在联合比赛中领先,优势明显。
MuZero
AlphaGo Zero(2017 年发布)的继任者是 MuZero,这一方法由 DeepMind 的 Schrittwieser 等人在 2020 年发布的论文《通过学习的模型规划掌握 Atari、围棋、国际象棋和将棋》[Sch+20]中描述。在该方法中,作者尝试通过去除对精确游戏模型的需求来泛化该方法,但仍将其保持在基于模型的范畴内。正如我们在 AlphaGo Zero 的描述中所见,游戏模型在训练过程中被广泛使用:在 MCTS 阶段,我们使用游戏模型来获取当前状态下的可用动作以及应用该动作后的新游戏状态。此外,游戏模型还提供了最终的游戏结果:我们是赢了还是输了游戏。
乍一看,似乎几乎不可能从训练过程中去除模型,但 MuZero 不仅展示了如何做到这一点,而且还打破了先前 AlphaGo Zero 在围棋、国际象棋和将棋中的记录,并在 57 个 Atari 游戏中建立了最先进的成果。
在本章的这一部分,我们将详细讨论该方法,实现它,并与使用 Connect 4 的 AlphaGo Zero 进行比较。
高级模型
首先,让我们从高层次来看 MuZero。与 AlphaGo Zero 一样,核心是 MCTS,它会被多次执行,用于计算关于当前位于树根的游戏状态可能未来结果的统计数据。在这个搜索之后,我们计算访问计数器,指示动作执行的频率。
但与其使用游戏模型来回答“如果我从这个状态执行这个动作,我会得到什么状态?”这个问题,MuZero 引入了两个额外的神经网络:
-
表示 h𝜃 →s:计算游戏观察的隐藏状态
-
动力学 g𝜃 →r,s′:将动作 a 应用于隐藏状态 s,将其转化为下一个状态 s′(并获得即时奖励 r)
如你所记得,在 AlphaGo Zero 中,只使用了一个网络 f𝜃 →π,v,它预测了当前状态 s 的策略π和值 v。MuZero 的操作使用了三个网络,它们同时进行训练。我稍后会解释训练是如何进行的,但现在我们先集中讨论 MCTS。
在图 20.6 中,MCTS 过程以示意图的方式展示,指明了我们使用神经网络计算的值。作为第一步,我们使用表示网络 h[𝜃],计算当前游戏观察 o 的隐藏状态 s⁰。
得到隐藏状态后,我们可以使用网络 f[𝜃]来计算该状态的策略π⁰和值 v⁰——这些量表示我们应该采取的动作(π⁰)以及这些动作的预期结果(v⁰)。
我们使用策略和价值(结合动作的访问计数统计)来计算该动作的效用值 U(s,a),与 AlphaGo Zero 中的方法类似。然后,选择具有最大效用值的动作进行树的下降。如果这是我们第一次从此状态节点选择该动作(换句话说,该节点尚未展开),我们使用神经网络 g𝜃 →r¹,s¹来获得即时奖励 r¹和下一个隐藏状态 s¹。

图 20.6:MuZero 中的蒙特卡洛树搜索
这个过程会一遍又一遍地重复数百次,累积动作的访问计数器,不断扩展树中的节点。在每次节点扩展时,从 f[𝜃]获得的节点值会被添加到沿着搜索路径的所有节点中,直到树的根部。在 AlphaGo Zero 的论文中,这个过程被称为“备份”,而在 MuZero 的论文中则使用了“反向传播”这个术语。但本质上,含义是相同的——将扩展节点的值添加到树的根部,改变符号。
经过一段时间(在原始 MuZero 方法中是 800 次搜索),动作的访问次数已经足够准确(或者我们认为它们足够准确),可以用作选择动作和训练时策略的近似值。
训练过程
如上所述,MCTS 用于单一的游戏状态(位于树的根部)。在所有搜索轮次结束后,我们根据搜索过程中执行的动作频率,从该根状态中选择一个动作。然后,在环境中执行选定的动作,获得下一个状态和奖励。之后,使用下一个状态作为搜索树的根,执行另一个 MCTS。
这个过程允许我们生成回合。我们将它们存储在回放缓存中并用于训练。为了准备训练批次,我们从回放缓存中抽取一个回合并随机选择回合中的偏移量。然后,从回合中的这个位置开始,我们展开回合直到固定的步数(在 MuZero 论文中,使用的是五步展开)。在展开的每个步骤中,以下数据会被累积:
-
从 MCTS 获取的动作频率作为策略目标(使用交叉熵损失训练)。
-
到回合结束为止的折扣奖励和奖励总和被用作价值目标(使用均方误差损失训练)。
-
即时奖励被用作动态网络预测的奖励值的目标(同样使用均方误差损失进行训练)。
除此之外,我们记住在每个展开步骤中采取的动作,这将作为动态网络的输入,g𝜃 →r,s′。
一旦批次生成,我们将表示网络 h𝜃 应用到游戏观察值(展开回合的第一个步骤)。然后,我们通过计算当前隐藏状态下的策略 π 和价值 v 来重复展开过程,计算它们的损失,并执行动态网络步骤以获得下一个隐藏状态。这个过程会重复五步(展开的长度)。Schrittwieser 等人通过将梯度按 0.5 的比例缩放来处理展开的步骤,但在我的实现中,我只是将损失乘以这个常数来获得相同的效果。
使用 MuZero 的 Connect 4
现在我们已经讨论了方法,接下来让我们查看其在 Connect 4 中的实现及结果。实现由几个模块组成:
-
lib/muzero.py:包含 MCTS 数据结构和函数、神经网络和批次生成逻辑
-
train-mu.py:训练循环,实现自我对弈以生成回合,训练,并定期验证当前训练的模型与最佳模型的对比(与 AlphaGo Zero 方法相同)。
-
play-mu.py:执行一系列模型对战,以获得它们的排名
超参数和 MCTS 树节点
大部分 MuZero 超参数被放入一个单独的数据类中,以简化在代码中传递它们:
@dataclass
class MuZeroParams:
actions_count: int = game.GAME_COLS
max_moves: int = game.GAME_COLS * game.GAME_ROWS >> 2 + 1
dirichlet_alpha: float = 0.3
discount: float = 1.0
unroll_steps: int = 5
pb_c_base: int = 19652
pb_c_init: float = 1.25
dev: torch.device = torch.device("cpu")
我不会在这里解释这些参数。我们在讨论相关代码片段时会进行解释。
MuZero 的 MCTS 实现与 AlphaGo Zero 的实现有所不同。在我们的 AlphaGo Zero 实现中,每个 MCTS 节点都有一个唯一的游戏状态标识符,这个标识符是一个整数。因此,我们将整个树保存在多个字典中,将游戏状态映射到节点的属性,比如访问计数器、子节点的状态等等。每次看到游戏状态时,我们只需更新这些字典。
然而,在 MuZero 中,每个 MCTS 节点现在由一个隐藏状态标识,该隐藏状态是一个浮点数列表(因为隐藏状态是由神经网络生成的)。因此,我们无法直接比较两个隐藏状态以检查它们是否相同。为了解决这个问题,我们现在以“正确”的方式存储树——作为引用子节点的节点,这从内存的角度来看效率较低。以下是核心 MCTS 数据结构:表示树节点的对象。对于构造函数,我们只需创建一个空的未展开节点:
class MCTSNode:
def __init__(self, prior: float, first_plays: bool):
self.first_plays: bool = first_plays
self.visit_count = 0
self.value_sum = 0.0
self.prior = prior
self.children: tt.Dict[Action, MCTSNode] = {}
# node is not expanded, so has no hidden state
self.h = None
# predicted reward
self.r = 0.0
节点的扩展在 expand_node 方法中实现,这将在介绍模型之后展示。现在,如果节点有子节点(动作),并且通过神经网络计算出了隐藏状态、策略和价值,则节点会被扩展。节点的价值是通过将所有子节点的价值求和并除以访问次数得到的:
@property
def is_expanded(self) -> bool:
return bool(self.children)
@property
def value(self) -> float:
return 0 if not self.visit_count else self.value_sum / self.visit_count
select_child 方法在 MCTS 搜索过程中执行动作选择。这个选择通过选择由 ucb_value 函数返回的最大值对应的子节点来完成,这个函数将在稍后展示:
def select_child(self, params: MuZeroParams, min_max: MinMaxStats) -> \
tt.Tuple[Action, "MCTSNode"]:
max_ucb, best_action, best_node = None, None, None
for action, node in self.children.items():
ucb = ucb_value(params, self, node, min_max)
if max_ucb is None or max_ucb < ucb:
max_ucb = ucb
best_action = action
best_node = node
return best_action, best_node
ucb_value 方法实现了节点的上置信界(UCB)计算,它与我们为 AlphaGo Zero 讨论的公式非常相似。UCB 是从节点的价值和先验乘以一个系数计算得到的:
def ucb_value(params: MuZeroParams, parent: MCTSNode, child: MCTSNode,
min_max: MinMaxStats) -> float:
pb_c = m.log((parent.visit_count + params.pb_c_base + 1) /
params.pb_c_base) + params.pb_c_init
pb_c *= m.sqrt(parent.visit_count) / (child.visit_count + 1)
prior_score = pb_c * child.prior
value_score = 0.0
if child.visit_count > 0:
value_score = min_max.normalize(child.value + child.r)
return prior_score + value_score
MCTSNode 类的另一个方法是 get_act_probs(),它返回从访问计数器获得的近似概率。这些概率作为策略网络训练的目标。这个方法有一个特殊的“温度系数”,允许我们在训练的不同阶段调整熵:如果温度接近零,我们会将较高的概率分配给访问次数最多的动作。如果温度较高,分布会变得更加均匀:
def get_act_probs(self, t: float = 1) -> tt.List[float]:
child_visits = sum(map(lambda n: n.visit_count, self.children.values()))
p = np.array([(child.visit_count / child_visits) ** (1 / t)
for _, child in sorted(self.children.items())])
p /= sum(p)
return list(p)
MCTSNode 的最后一个方法是 select_action(),它使用 get_act_probs()方法来选择动作,并处理以下几种特殊情况:
-
如果节点中没有子节点,则动作是随机执行的
-
如果温度系数太小,我们选择访问次数最多的动作
-
否则,我们使用 get_act_probs()根据温度系数获取每个动作的概率,并根据这些概率选择动作
def select_action(self, t: float, params: MuZeroParams) -> Action:
act_vals = list(sorted(self.children.keys()))
if not act_vals:
res = np.random.choice(params.actions_count)
elif t < 0.0001:
res, _ = max(self.children.items(), key=lambda p: p[1].visit_count)
else:
p = self.get_act_probs(t)
res = int(np.random.choice(act_vals, p=p))
return res
前面的代码可能看起来有点复杂且与当前内容无关,但当我们讨论 MuZero 模型和 MCTS 搜索过程时,它会变得更加清晰:
模型
正如我们之前提到的,MuZero 使用了三个神经网络(NN)用于不同的目的。让我们来看看它们。你可以在 GitHub 的 lib/muzero.py 模块中找到所有相关代码。
第一个模型是表示模型,h𝜃 →s,它将游戏观测映射到隐藏状态。观测与 AlphaGo Zero 代码中的完全相同——我们有一个 2 × 6 × 7 大小的张量,其中 6 × 7 是棋盘的大小,两个平面分别是当前玩家和对手棋子的独热编码位置。隐藏状态的维度由超参数 HIDDEN_STATE_SIZE=64 给出:
class ReprModel(nn.Module):
def __init__(self, input_shape: tt.Tuple[int, ...]):
super(ReprModel, self).__init__()
self.conv_in = nn.Sequential(
nn.Conv2d(input_shape[0], NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
# layers with residual
self.conv_1 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_2 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_3 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_4 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU()
)
self.conv_5 = nn.Sequential(
nn.Conv2d(NUM_FILTERS, NUM_FILTERS, kernel_size=3, padding=1),
nn.BatchNorm2d(NUM_FILTERS),
nn.LeakyReLU(),
)
self.conv_out = nn.Sequential(
nn.Conv2d(NUM_FILTERS, 16, kernel_size=1),
nn.BatchNorm2d(16),
nn.LeakyReLU(),
nn.Flatten()
)
body_shape = (NUM_FILTERS,) + input_shape[1:]
size = self.conv_out(torch.zeros(1, *body_shape)).size()[-1]
self.out = nn.Sequential(
nn.Linear(size, 128),
nn.ReLU(),
nn.Linear(128, HIDDEN_STATE_SIZE),
)
网络的结构几乎与 AlphaGo Zero 示例中的相同,唯一的区别是它返回隐藏状态向量,而不是策略和价值。
由于网络块是残差的,每一层需要特殊处理:
def forward(self, x):
v = self.conv_in(x)
v = v + self.conv_1(v)
v = v + self.conv_2(v)
v = v + self.conv_3(v)
v = v + self.conv_4(v)
v = v + self.conv_5(v)
c_out = self.conv_out(v)
out = self.out(c_out)
return out
第二个模型是预测模型,f𝜃 →π,v,它接受隐藏状态并返回策略和值。在我的示例中,我为策略和值使用了两层头:
class PredModel(nn.Module):
def __init__(self, actions: int):
super(PredModel, self).__init__()
self.policy = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE, 128),
nn.ReLU(),
nn.Linear(128, actions),
)
self.value = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE, 128),
nn.ReLU(),
nn.Linear(128, 1),
)
def forward(self, x) -> tt.Tuple[torch.Tensor, torch.Tensor]:
return self.policy(x), self.value(x).squeeze(1)
我们的第三个模型是动态模型,g𝜃 →r,s′,它接受隐藏状态和独热编码的动作,并返回即时奖励和下一个状态:
class DynamicsModel(nn.Module):
def __init__(self, actions: int):
super(DynamicsModel, self).__init__()
self.reward = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE + actions, 128),
nn.ReLU(),
nn.Linear(128, 1),
)
self.hidden = nn.Sequential(
nn.Linear(HIDDEN_STATE_SIZE + actions, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, HIDDEN_STATE_SIZE),
)
def forward(self, h: torch.Tensor, a: torch.Tensor) -> \
tt.Tuple[torch.Tensor, torch.Tensor]:
x = torch.hstack((h, a))
return self.reward(x).squeeze(1), self.hidden(x)
为了方便起见,所有三个网络都保存在 MuZeroModels 类中,它提供了所需的功能:
class MuZeroModels:
def __init__(self, input_shape: tt.Tuple[int, ...], actions: int):
self.repr = ReprModel(input_shape)
self.pred = PredModel(actions)
self.dynamics = DynamicsModel(actions)
def to(self, dev: torch.device):
self.repr.to(dev)
self.pred.to(dev)
self.dynamics.to(dev)
该类提供了从其他实例同步网络的方法。我们将使用它来存储验证后的最佳模型。
此外,还有两个方法用于存储和加载网络的权重:
def sync(self, src: "MuZeroModels"):
self.repr.load_state_dict(src.repr.state_dict())
self.pred.load_state_dict(src.pred.state_dict())
self.dynamics.load_state_dict(src.dynamics.state_dict())
def get_state_dict(self) -> tt.Dict[str, dict]:
return {
"repr": self.repr.state_dict(),
"pred": self.pred.state_dict(),
"dynamics": self.dynamics.state_dict(),
}
def set_state_dict(self, d: dict):
self.repr.load_state_dict(d[’repr’])
self.pred.load_state_dict(d[’pred’])
self.dynamics.load_state_dict(d[’dynamics’])
现在我们已经了解了模型,接下来就可以进入实现 MCTS 逻辑和游戏循环的函数。
MCTS 搜索
首先,我们有两个执行类似任务的函数,但在不同的情况下:
-
make_expanded_root()从给定的游戏状态创建 MCTS 树的根节点。对于根节点,我们没有父节点,因此不需要应用动态神经网络;相反,我们通过表示网络从编码的游戏观测中获取节点的隐藏状态。
-
expand_node()扩展非根 MCTS 节点。在这种情况下,我们使用神经网络通过父节点的隐藏状态生成子节点的隐藏状态。
在第一个函数的开始,我们创建一个新的 MCTSNode,将游戏状态解码为列表表示,并将其转换为张量。然后,使用表示网络获取节点的隐藏状态:
def make_expanded_root(player_idx: int, game_state_int: int, params: MuZeroParams,
models: MuZeroModels, min_max: MinMaxStats) -> MCTSNode:
root = MCTSNode(1.0, player_idx == 0)
state_list = game.decode_binary(game_state_int)
state_t = state_lists_to_batch([state_list], [player_idx], device=params.dev)
h_t = models.repr(state_t)
root.h = h_t[0].cpu().numpy()
使用隐藏状态,我们获得节点的策略和价值,并将策略的对数值转化为概率,然后添加一些随机噪声以增加探索性:
p_t, v_t = models.pred(h_t)
# logits to probs
p_t.exp_()
probs_t = p_t.squeeze(0) / p_t.sum()
probs = probs_t.cpu().numpy()
# add dirichlet noise
noises = np.random.dirichlet([params.dirichlet_alpha] * params.actions_count)
probs = probs * 0.75 + noises * 0.25
由于我们得到了概率,我们创建了子节点并反向传播节点的价值。反向传播(backpropagate())方法稍后会进行讨论;它会沿着搜索路径增加节点的价值。对于根节点,我们的搜索路径只有根节点,所以只有一步(在下一个方法 expand_node()中,路径可能会更长):
for a, prob in enumerate(probs):
root.children[a] = MCTSNode(prob, not root.first_plays)
v = v_t.cpu().item()
backpropagate([root], v, root.first_plays, params, min_max)
return root
expand_node()方法类似,但用于非根节点,因此它使用父节点的隐藏状态执行动态步骤:
def expand_node(parent: MCTSNode, node: MCTSNode, last_action: Action,
params: MuZeroParams, models: MuZeroModels) -> float:
h_t = torch.as_tensor(parent.h, dtype=torch.float32, device=params.dev)
h_t.unsqueeze_(0)
p_t, v_t = models.pred(h_t)
a_t = torch.zeros(params.actions_count, dtype=torch.float32, device=params.dev)
a_t[last_action] = 1.0
a_t.unsqueeze_(0)
r_t, h_next_t = models.dynamics(h_t, a_t)
node.h = h_next_t[0].cpu().numpy()
node.r = float(r_t[0].cpu().item())
其余的逻辑相同,唯一不同的是非根节点没有添加噪声:
p_t.squeeze_(0)
p_t.exp_()
probs_t = p_t / p_t.sum()
probs = probs_t.cpu().numpy()
for a, prob in enumerate(probs):
node.children[a] = MCTSNode(prob, not node.first_plays)
return float(v_t.cpu().item())
backpropagate()函数用于将折扣值添加到搜索路径上的节点。每个级别的值符号会发生变化,以表明玩家的回合正在变化。所以,我们的正值意味着对手的负值,反之亦然:
def backpropagate(search_path: tt.List[MCTSNode], value: float, first_plays: bool,
params: MuZeroParams, min_max: MinMaxStats):
for node in reversed(search_path):
node.value_sum += value if node.first_plays == first_plays else -value
node.visit_count += 1
value = node.r + params.discount * value
min_max.update(value)
MinMaxStats类的实例用于在搜索过程中保存树的最小值和最大值。然后,这些极值被用来规范化结果值。
有了这些函数,让我们现在来看一下实际的 MCTS 搜索逻辑。首先,我们创建一个根节点,然后执行几轮搜索。在每一轮中,我们通过跟随 UCB 值函数来遍历树。当我们找到一个未展开的节点时,我们展开它,并将值回传到树的根节点。
@torch.no_grad()
def run_mcts(player_idx: int, root_state_int: int, params: MuZeroParams,
models: MuZeroModels, min_max: MinMaxStats,
search_rounds: int = 800) -> MCTSNode:
root = make_expanded_root(player_idx, root_state_int, params, models, min_max)
for _ in range(search_rounds):
search_path = [root]
parent_node = None
last_action = 0
node = root
while node.is_expanded:
action, new_node = node.select_child(params, min_max)
last_action = action
parent_node = node
node = new_node
search_path.append(new_node)
value = expand_node(parent_node, node, last_action, params, models)
backpropagate(search_path, value, node.first_plays, params, min_max)
return root
如你所见,这个实现使用了神经网络,但没有对节点进行批处理。MuZero 的 MCTS 过程的问题在于搜索过程是确定性的,并且由节点的值(当节点被展开时更新)和访问计数器驱动。因此,批处理没有效果,因为如果不展开节点,重复搜索将导致树中的相同路径,因此必须一个个地展开。这是使用神经网络的一个非常低效的方式,负面地影响了整体性能。在这里,我的目的不是实现 MuZero 的最优版本,而是为你展示一个可行的原型,所以我没有进行优化。作为一个练习,你可以修改实现,使得多个进程并行进行 MCTS 搜索。作为另一种选择(或者附加功能),你可以在 MCTS 搜索过程中添加噪声,并像我们讨论 AlphaGo Zero 时那样使用批处理。
训练数据和游戏过程
为了存储训练数据,我们有一个Episode类,它保存一系列EpisodeStep对象,并附带额外的信息:
@dataclass
class EpisodeStep:
state: int
player_idx: int
action: int
reward: int
class Episode:
def __init__(self):
self.steps: tt.List[EpisodeStep] = []
self.action_probs: tt.List[tt.List[float]] = []
self.root_values: tt.List[float] = []
def __len__(self):
return len(self.steps)
def add_step(self, step: EpisodeStep, node: MCTSNode):
self.steps.append(step)
self.action_probs.append(node.get_act_probs())
self.root_values.append(node.value)
现在,让我们来看一下play_game()函数,它使用 MCTS 搜索多次来玩完整的一局游戏。在函数的开始部分,我们创建游戏状态和所需的对象:
@torch.no_grad()
def play_game(
player1: MuZeroModels, player2: MuZeroModels, params: MuZeroParams,
temperature: float, init_state: tt.Optional[int] = None
) -> tt.Tuple[int, Episode]:
episode = Episode()
state = game.INITIAL_STATE if init_state is None else init_state
players = [player1, player2]
player_idx = 0
reward = 0
min_max = MinMaxStats()
在游戏循环的开始,我们检查游戏是否平局,然后运行 MCTS 搜索以积累统计数据。之后,我们使用从动作频率(而不是 UCB 值)中随机采样来选择一个动作:
while True:
possible_actions = game.possible_moves(state)
if not possible_actions:
break
root_node = run_mcts(player_idx, state, params, players[player_idx], min_max)
action = root_node.select_action(temperature, params)
# act randomly on wrong move
if action not in possible_actions:
action = int(np.random.choice(possible_actions))
一旦选择了动作,我们就会在游戏环境中执行一个动作,并检查是否有胜负情况。然后,过程会重复:
new_state, won = game.move(state, action, player_idx)
if won:
if player_idx == 0:
reward = 1
else:
reward = -1
step = EpisodeStep(state, player_idx, action, reward)
episode.add_step(step, root_node)
if won:
break
player_idx = (player_idx + 1) % 2
state = new_state
return reward, episode
最后,我们有一个方法从回放缓冲区中抽取一批训练数据(回放缓冲区是一个Episode对象的列表)。如果你记得,训练数据是通过从一个随机位置开始展开随机回合来创建的。这是为了应用动态网络并用实际数据优化它。因此,我们的批量数据不是一个张量,而是一个张量的列表,每个张量都是展开过程中一个步骤的表示。
为了准备批量采样,我们创建了所需大小的空列表:
def sample_batch(
episode_buffer: tt.Deque[Episode], batch_size: int, params: MuZeroParams,
) -> tt.Tuple[
torch.Tensor, tt.Tuple[torch.Tensor, ...], tt.Tuple[torch.Tensor, ...],
tt.Tuple[torch.Tensor, ...], tt.Tuple[torch.Tensor, ...],
]:
states = []
player_indices = []
actions = [[] for _ in range(params.unroll_steps)]
policy_targets = [[] for _ in range(params.unroll_steps)]
rewards = [[] for _ in range(params.unroll_steps)]
values = [[] for _ in range(params.unroll_steps)]
然后我们随机抽取一个回合,并在这个回合中选择一个偏移位置:
for episode in np.random.choice(episode_buffer, batch_size):
assert isinstance(episode, Episode)
ofs = np.random.choice(len(episode) - params.unroll_steps)
state = game.decode_binary(episode.steps[ofs].state)
states.append(state)
player_indices.append(episode.steps[ofs].player_idx)
之后,我们会展开一个特定步数(本文中为五步)。在每一步,我们记住动作、即时奖励和动作的概率。之后,我们通过对直到回合结束的折扣奖励求和来计算值目标:
for s in range(params.unroll_steps):
full_ofs = ofs + s
actions[s].append(episode.steps[full_ofs].action)
rewards[s].append(episode.steps[full_ofs].reward)
policy_targets[s].append(episode.action_probs[full_ofs])
value = 0.0
for step in reversed(episode.steps[full_ofs:]):
value *= params.discount
value += step.reward
values[s].append(value)
在数据准备好后,我们将其转换为张量。动作使用 eye() NumPy 函数和索引进行独热编码:
states_t = state_lists_to_batch(states, player_indices, device=params.dev)
res_actions = tuple(
torch.as_tensor(np.eye(params.actions_count)[a],
dtype=torch.float32, device=params.dev)
for a in actions
)
res_policies = tuple(
torch.as_tensor(p, dtype=torch.float32, device=params.dev)
for p in policy_targets
)
res_rewards = tuple(
torch.as_tensor(r, dtype=torch.float32, device=params.dev)
for r in rewards
)
res_values = tuple(
torch.as_tensor(v, dtype=torch.float32, device=params.dev)
for v in values
)
return states_t, res_actions, res_policies, res_rewards, res_values
我这里不打算展示完整的训练循环;我们使用当前最佳模型进行自我对弈,以填充回放缓冲区。完整的训练代码在 train-mu.py 模块中。以下代码用于优化网络:
states_t, actions, policy_tgt, rewards_tgt, values_tgt = \
mu.sample_batch(replay_buffer, BATCH_SIZE, params)
optimizer.zero_grad()
h_t = net.repr(states_t)
loss_p_full_t = None
loss_v_full_t = None
loss_r_full_t = None
for step in range(params.unroll_steps):
policy_t, values_t = net.pred(h_t)
loss_p_t = F.cross_entropy(policy_t, policy_tgt[step])
loss_v_t = F.mse_loss(values_t, values_tgt[step])
# dynamic step
rewards_t, h_t = net.dynamics(h_t, actions[step])
loss_r_t = F.mse_loss(rewards_t, rewards_tgt[step])
if step == 0:
loss_p_full_t = loss_p_t
loss_v_full_t = loss_v_t
loss_r_full_t = loss_r_t
else:
loss_p_full_t += loss_p_t * 0.5
loss_v_full_t += loss_v_t * 0.5
loss_r_full_t += loss_r_t * 0.5
loss_full_t = loss_v_full_t + loss_p_full_t + loss_r_full_t
loss_full_t.backward()
optimizer.step()
MuZero 结果
我进行了 15 小时的训练,进行了 3400 个回合(你看,训练速度并不快)。策略和价值损失如图 20.7 所示。正如自我对弈训练中常见的那样,图表没有明显的趋势:

图 20.7:MuZero 训练的策略(左)和值(右)损失
在训练过程中,存储了近 200 个当前最好的模型,我通过使用 play-mu.py 脚本在比赛模式下进行检查。以下是前 10 个模型:
saves/mu-t5-6/best_010_00210.dat: w=339, l=41, d=0
saves/mu-t5-6/best_015_00260.dat: w=298, l=82, d=0
saves/mu-t5-6/best_155_02510.dat: w=287, l=93, d=0
saves/mu-t5-6/best_150_02460.dat: w=273, l=107, d=0
saves/mu-t5-6/best_140_02360.dat: w=267, l=113, d=0
saves/mu-t5-6/best_145_02410.dat: w=266, l=114, d=0
saves/mu-t5-6/best_165_02640.dat: w=253, l=127, d=0
saves/mu-t5-6/best_005_00100.dat: w=250, l=130, d=0
saves/mu-t5-6/best_160_02560.dat: w=236, l=144, d=0
saves/mu-t5-6/best_135_02310.dat: w=220, l=160, d=0
如你所见,最佳模型是训练初期存储的模型,这可能表明了收敛性不良(因为我并没有调节很多超参数)。
图 20.8 展示了模型胜率与模型索引的关系图,这个图与策略损失有很大关联,这是可以理解的,因为较低的策略损失应当带来更好的游戏表现:

图 20.8:训练过程中存储的最佳模型的胜率
MuZero 与 Atari
在我们的示例中,我们使用了“连接 4”这款两人棋盘游戏,但我们不应忽视 MuZero 的泛化能力(使用隐藏状态),使得它能够应用于更经典的强化学习场景。在 Schrittwieser 等人[Sch+20]的论文中,作者成功地将这一方法应用于 57 款 Atari 游戏。当然,这个方法需要针对这些场景进行调优和适配,但核心思想是相同的。这部分留给你作为练习,自己尝试。
总结
在这一章节中,我们实现了由 DeepMind 创建的 AlphaGo Zero 和 MuZero 基于模型的方法,这些方法旨在解决棋类游戏。该方法的核心思想是通过自我对弈来提升智能体的实力,而无需依赖于人类游戏或其他数据源的先验知识。这类方法在多个领域具有实际应用,如医疗(蛋白质折叠)、金融和能源管理。在下一章中,我们将讨论另一种实际 RL 方向:离散优化问题,它在各种现实问题中发挥着重要作用,从调度优化到蛋白质折叠。
加入我们的 Discord 社区
与其他用户、深度学习专家以及作者本人一起阅读本书。提出问题,为其他读者提供解决方案,通过“问我任何问题”环节与作者互动,还有更多内容。扫描二维码或访问链接加入社区。packt.link/rl

第二十一章:离散优化中的强化学习(RL)
深度强化学习(RL)的普遍看法是它主要用于玩游戏。考虑到历史上该领域的首次成功是在 2015 年由 DeepMind 在雅达利游戏套件上取得的(deepmind.com/research/dqn/),这并不令人惊讶。雅达利基准测试套件对于 RL 问题非常成功,直到现在,许多研究论文仍然用它来展示他们方法的效率。随着 RL 领域的发展,经典的 53 款雅达利游戏逐渐变得越来越不具挑战性(在撰写本文时,几乎所有游戏都已被超人级精度解决),研究人员正在转向更复杂的游戏,如《星际争霸》和《Dota 2》。
这种观点,尤其是在媒体中较为常见,是我在本书中试图加以平衡的。我通过将雅达利游戏与其他领域的实例结合起来,包括股票交易、自然语言处理(NLP)问题、网页导航自动化、连续控制、棋盘游戏和机器人技术,来对其进行补充。事实上,RL 非常灵活的马尔可夫决策过程(MDP)模型潜在地可以应用于各种领域;计算机游戏只是复杂决策的一种便捷且引人注目的示例。
在本章中,我们将探索 RL 应用中的一个新领域:离散优化(这是一门研究离散结构上的优化问题的数学分支),通过著名的魔方谜题来展示。我尝试提供对 UCI 研究员 McAleer 等人撰写的论文《无人工知识解决魔方》的详细描述[McA+18]。此外,我们还将介绍我对论文中所述方法的实现(该实现位于本书 GitHub 仓库的 Chapter21 目录中),并讨论改进该方法的方向。我将论文方法的描述与我版本中的代码片段结合起来,以通过具体实现来说明这些概念。
更具体地说,在本章中,我们将:
-
简要讨论离散优化的基础知识
-
逐步讲解 McAleer 等人[McA+18]应用 RL 方法解决魔方优化问题的过程
-
探讨我在尝试重现论文结果过程中所做的实验以及未来方法改进的方向
让我们从魔方和离散优化的一般概述开始。
魔方与离散优化
我相信你知道魔方是什么,因此我不会过多介绍这个谜题的通用描述(en.wikipedia.org/wiki/Rubik\%27s_Cube),而是专注于它与数学和计算机科学的联系。
如果没有明确说明,"魔方"指的是 3 × 3 × 3 经典的鲁比克魔方。基于原始的 3 × 3 × 3 谜题有很多变种,但它们仍然远不如经典版本流行。
尽管从机械原理和任务本身来看,魔方相对简单,但在所有通过旋转面实现的变换中,魔方却是一个相当棘手的物体。经过计算,总共通过旋转魔方可以到达约 4.33 ⋅ 10¹⁹个不同的状态。这些只是通过旋转魔方能到达的状态,而不需要拆解魔方;如果将魔方拆解后重新组装,最终可以获得多出 12 倍的状态,总数约为 5.19 ⋅ 10²⁰,但这些“额外”状态使得魔方在不拆解的情况下无法解决。
所有这些状态通过魔方面的旋转密切交织在一起。例如,如果我们在某个状态下将左侧顺时针旋转,则会到达一个状态,在该状态下,逆时针旋转同一面会取消变换的效果,并恢复到原始状态。
但是,如果我们连续三次进行左侧顺时针旋转,那么返回到原始状态的最短路径只需进行一次左侧顺时针旋转,而不是三次逆时针旋转(虽然逆时针旋转也是可能的,但并不是最优的)。由于魔方有 6 条边,每条边可以旋转 2 个方向,因此总共有 12 种可能的旋转。有时,半圈旋转(即两次连续的同向旋转)也被视为不同的旋转,但为了简便起见,我们将其视为魔方的两种不同变换。
在数学中,有几个领域研究这类对象。其中之一是抽象代数,这是数学中一个非常广泛的分支,研究带有运算的抽象对象集合。从这些角度来看,鲁比克魔方是一个相当复杂的群体(en.wikipedia.org/wiki/Group_theory),具有许多有趣的性质。
魔方不仅仅是状态和变换,它是一个谜题,主要目标是找到一个旋转序列,以解决魔方作为最终目标。这类问题通过组合优化进行研究,组合优化是应用数学和理论计算机科学的一个子领域。这个学科有很多具有高实际价值的著名问题,例如:
-
旅行商问题 (
en.wikipedia.org/wiki/Travelling_salesman_problem):在图中找到最短的闭合路径 -
蛋白质折叠模拟 (
en.wikipedia.org/wiki/Protein_folding):寻找蛋白质的可能三维结构 -
资源分配:如何在消费者之间分配固定的资源集,以达到最佳目标
这些问题的共同点是状态空间巨大,单纯通过检查所有可能的组合来找到最佳解是不切实际的。我们的“玩具魔方问题”也属于同类问题,因为 4.33 ⋅ 10¹⁹ 的状态空间使得暴力破解方法非常不实用。
最优性与上帝之数
使得组合优化问题棘手的原因在于,我们并不是在寻找任何解法;我们实际上关注的是问题的最优解。那么,二者有什么区别呢?魔方发明之后,人们就知道如何达到目标状态(但 Ernő Rubik 花了大约一个月的时间才弄明白他自己发明的魔方的第一个解法,这应该是一次令人沮丧的经历)。如今,有很多不同的魔方解法或方案:初学者方法(逐层法)、Jessica Fridrich 方法(在速解者中非常流行)等。
所有这些方法的差异在于所需的步骤数。例如,一个非常简单的初学者方法需要大约 100 次旋转才能解出魔方,只需记住 5…7 种旋转序列。相比之下,目前的世界纪录是在 3.13 秒内解出魔方,这需要更少的步骤,但需要记住更多的旋转序列。Fridrich 方法的平均步数大约是 55 步,但你需要熟悉 120 种不同的旋转序列。当然,关键问题是:解决任何给定魔方状态的最短动作序列是什么?令人惊讶的是,魔方发明 50 年后,人类仍然不知道这个问题的完整答案。直到 2010 年,谷歌的研究人员才证明,解决任何魔方状态所需的最小步数是 20 步。这个数字也被称为上帝之数(不要与自然界中到处可见的“黄金比例”或“神圣比例”混淆)。当然,平均而言,最优解的步数更短,因为只有少数几个状态需要 20 步,而某个单一状态根本不需要任何步骤(即已解状态)。这个结果仅证明了最小步数;它并没有找到具体的解法。如何为任何给定的状态找到最优解仍然是一个悬而未决的问题。
魔方求解方法
在 McAleer 等人发表论文之前,解决魔方问题的研究方向主要有两个:
-
通过使用群论,可以显著减少需要检查的状态空间。使用这种方法的最流行的解决方案之一是 Kociemba 算法(
en.wikipedia.org/wiki/Optimal_solutions_for_Rubik\%27s_Cube#Kociemba’s_algorithm)。 -
通过使用暴力搜索并辅以人工编写的启发式方法,我们可以将搜索引导至最有前景的方向。一个生动的例子是 Korf 的算法(
en.wikipedia.org/wiki/Optimal_solutions_for_Rubik\%27s_Cube#Korf’s_algorithm),它使用 A* 搜索和一个庞大的模式数据库来排除不良的方向。
McAleer 等人 [McA+18] 提出了第三种方法(称为自学迭代,或 ADI):通过对大量随机打乱的魔方进行神经网络(NN)训练,可以得到一个策略,该策略会指引我们朝着解决状态前进。该训练不依赖于领域的先验知识;所需的唯一条件是魔方本身(不是物理魔方,而是其计算机模型)。这与前两种方法形成对比,后者需要大量的领域知识并付出人力将其实现为计算机代码。
这种方法与我们在前一章讨论的 AlphaGo Zero 方法有很多相似之处:我们需要一个环境模型,并使用蒙特卡洛树搜索(MCTS)来避免对完整状态空间的探索。
在随后的章节中,我们将详细介绍这一方法;我们将从数据表示开始。在我们的魔方问题中,有两个实体需要被编码:动作和状态。
动作
动作是我们可以从任何给定魔方状态执行的旋转,如前所述,我们总共有 12 个动作。对于每一面,我们有两个不同的动作,分别对应于该面的顺时针和逆时针旋转(90^∘ 或 −90^∘)。一个小但非常重要的细节是,旋转是从希望的面朝向你的位置执行的。对于前面来说,这一点显而易见,但对于后面来说,由于旋转的镜像特性,可能会产生混淆。
动作的名称取自我们旋转的魔方面:左、右、上、下、前和后。面名称的第一个字母被用来表示。例如,右侧顺时针旋转的动作命名为 R。逆时针旋转有不同的表示方法;有时用撇号(R′)、小写字母(r)或波浪号(R̃)来表示。前两种表示方法在计算机代码中不太实用,因此在我的实现中,我使用小写字母表示逆时针旋转。对于右侧,我们有两个动作:R 和 r;左侧也有两个动作:L 和 l,依此类推。
在我的代码中,动作空间是通过 Python 枚举在 libcube/cubes/cube3x3.py 文件中的 Action 类实现的,其中每个动作都映射为唯一的整数值:
class Action(enum.Enum):
R = 0
L = 1
T = 2
D = 3
F = 4
B = 5
r = 6
l = 7
t = 8
d = 9
f = 10
b = 11
此外,我们描述了一个包含反向动作的字典:
_inverse_action = {
Action.R: Action.r, Action.r: Action.R,
Action.L: Action.l, Action.l: Action.L,
Action.T: Action.t, Action.t: Action.T,
Action.D: Action.d, Action.d: Action.D,
Action.F: Action.f, Action.f: Action.F,
Action.B: Action.b, Action.b: Action.B,
}
状态
状态是立方体上彩色贴纸的特定配置,正如之前讨论的那样,我们的状态空间非常大(4.33 ⋅ 10¹⁹ 种不同状态)。但是,状态的数量并不是我们面临的唯一复杂性;此外,在选择特定状态表示方式时,我们有不同的目标希望达成:
-
避免冗余:在极端情况下,我们可以仅通过记录每一面上每个贴纸的颜色来表示立方体的状态。但如果仅计算这种组合的数量,我们得到的是 6^(6⋅8) = 6⁴⁸ ≈ 2.25 ⋅ 10³⁷,这比我们立方体的状态空间大小要大得多,这意味着这种表示方式具有高度冗余;例如,它允许立方体的所有面都有相同的颜色(除了中心的小立方体)。如果你想知道我是如何得到 6^(6⋅8) 的,这很简单:我们有六个面,每个面有八个小立方体(我们不算中心立方体),所以我们总共有 48 个贴纸,每个贴纸可以涂成六种颜色之一。
-
内存效率:如你即将看到的,在训练过程中,尤其是在模型应用期间,我们将需要在计算机内存中保持大量不同的立方体状态,这可能会影响过程的性能。因此,我们希望表示方式尽可能紧凑。
-
变换的性能:另一方面,我们需要实现所有应用于状态的操作,这些操作需要快速执行。如果我们的表示方式在内存上非常紧凑(例如使用位编码),但每次旋转立方体的面时都需要进行冗长的解包过程,那么我们的训练可能会变得过于缓慢。
-
神经网络友好性:并不是每种数据表示方式都同样适合作为神经网络的输入。这不仅在我们的案例中成立,在机器学习中普遍如此。例如,在自然语言处理(NLP)中,常用词袋模型或词嵌入;在计算机视觉中,图像从 JPEG 解码为原始像素;随机森林则要求数据经过大量特征工程;等等。
在论文中,立方体的每个状态都表示为一个 20 × 24 的张量,采用 one-hot 编码。为了理解这是如何实现的,以及为什么它具有这种形状,让我们从论文中所示的图 21.1 开始:

图 21.1:我们需要在立方体上跟踪的贴纸被标记为较浅的颜色
在这里,浅色标记了我们需要追踪的块的贴纸;其他贴纸(以较深的颜色显示)是冗余的,无需追踪。如你所知,立方体由三种类型的块组成:8 个角块,每个有 3 个贴纸;12 个侧块,每个有 2 个贴纸;以及 6 个中心块,每个有 1 个贴纸。乍一看,可能不太明显,但中心块完全不需要追踪,因为它们不能改变相对位置,只能旋转。因此,关于中心块,我们只需要就立方体的对齐方式(立方体在空间中的方向)达成一致并保持一致。
在我的实现中,白色面始终在顶部,前面是红色,左面是绿色,依此类推。这样使得我们的状态具有旋转不变性,基本上意味着整个立方体的所有可能旋转被视为相同的状态。
由于中心块完全不被追踪,在图中,它们被标记为较深的颜色。那么剩下的块呢?显然,每种类型的块(角块或侧块)都有其独特的颜色组合。例如,我的方向(白色在顶部,红色在前面,依此类推)下,组装后的立方体中,左上角的角块正对我们,颜色是绿色、白色和红色。没有其他角块有这些颜色组合(如有疑问请检查)。侧块也是如此。
由于这个原因,要找到某个特定角块的位置,我们只需要知道其中一个贴纸的位置。选择哪个贴纸完全是任意的,但一旦选择了,就必须坚持下去。如前图所示,我们追踪来自顶部的八个贴纸,来自底部的八个贴纸,以及四个额外的侧面贴纸:两个在前面,两个在后面。这样我们就有了 20 个需要追踪的贴纸。
现在,让我们讨论一下张量维度中的 24 是怎么来的。总的来说,我们有 20 个不同的贴纸需要追踪,那么它们在哪些位置可能会出现,取决于立方体的变换?这取决于我们正在追踪的块的类型。我们从角块开始讲起。总共有八个角块,立方体的变换可以把它们重新排列成任何顺序。所以,任何特定的角块都可以出现在八个可能的角位置中。
此外,每个角块都可以旋转,所以我们的“绿色、白色和红色”角块可能有三种不同的方向:
-
白色在顶部,绿色在左侧,红色在前面
-
绿色在顶部,红色在左侧,白色在前面
-
红色在顶部,白色在左侧,绿色在前面
因此,为了准确指示角块的定位和方向,我们有 8 × 3 = 24 种不同的组合。对于 12 个边块,它们只有两个贴纸,因此只有两种可能的方向,这同样给我们提供了 24 种组合,但它们来自不同的计算:12 × 2 = 24。最后,我们有 20 个立方体小块需要跟踪,8 个角块和 12 个边块,每个小块都有 24 个可能的状态。
一种非常流行的将此类数据输入神经网络的方法是独热编码(one-hot encoding),即对象的具体位置为 1,其他位置填充为 0。这样我们最终得到的状态表示是一个形状为 20 × 24 的张量。
从冗余度的角度来看,这种表示法与总状态空间更加接近;可能的组合数量为 24²⁰ ≈ 4.02 ⋅ 10²⁷。它仍然大于立方体状态空间(可以说它大得多,因为 10⁸的因子非常大),但比编码每个贴纸的所有颜色要好。这种冗余来自于立方体变换的复杂特性;例如,不能仅旋转一个角块(或翻转一个边块),而使其他所有块保持不变。数学特性超出了本书的范围,但如果你有兴趣,我推荐亚历山大·弗雷(Alexander Frey)和大卫·辛格马斯特(David Singmaster)所著的《魔方数学手册》[FS20]。
你可能已经注意到,立方体状态的张量表示有一个显著的缺点:内存低效。实际上,通过将状态保持为 20 × 24 的浮点张量,我们使用了 4 × 20 × 24 = 1,920 字节的内存,这在训练过程中需要保持成千上万的状态以及在解立方体时需要保持百万个状态的情况下非常庞大(正如你稍后会了解的那样)。为了克服这个问题,在我的实现中,我使用了两种表示法:一种张量用于神经网络输入,另一种更紧凑的表示法则用于长期存储不同的状态。这种紧凑的状态被保存为一组列表,编码角块和边块的置换及其方向。这个表示法不仅更加节省内存(160 字节),而且在实现变换时也更为方便。
为了说明这一点,接下来是立方体 3 × 3 库 libcube/cubes/cube3x3.py 的部分内容,负责紧凑表示。
变量initial_state是立方体已解状态的编码。在其中,我们跟踪的角块和边块贴纸处于其原始位置,两个方向列表都设置为 0,表示立方体小块的初始方向:
State = collections.namedtuple("State", field_names=[
’corner_pos’, ’side_pos’, ’corner_ort’, ’side_ort’])
initial_state = State(corner_pos=tuple(range(8)), side_pos=tuple(range(12)),
corner_ort=tuple([0]*8), side_ort=tuple([0]*12))
立方体的变换有点复杂,包含了许多表格,记录了应用不同旋转后的立方体块重新排列。我不会把这段代码放在这里;如果你感兴趣,可以从 libcube/cubes/cube3x3.py 中的 transform(state, action) 函数开始。检查该代码的单元测试也可能会有所帮助。
除了动作、紧凑状态表示和变换外,模块 cube3x3.py 还包括一个将立方体状态的紧凑表示(作为名为 State 的元组)转换为张量形式的函数。这个功能由 encode_inplace() 方法提供。
另一个已实现的功能是通过应用 render() 函数将紧凑的状态渲染为人类友好的形式。这个功能对于调试立方体变换非常有用,但在训练代码中并未使用。
训练过程
现在你知道了如何将立方体的状态编码成 20 × 24 的张量,让我们来探索神经网络架构,理解它是如何训练的。
神经网络架构
图 21.2,来自 McAleer 等人的论文,展示了网络架构:

图 21.2:神经网络架构将观察(顶部)转化为动作和值(底部)
作为输入,它接受已经熟悉的立方体状态表示形式,作为一个 20 × 24 的张量,并输出两个结果:
-
策略是一个包含 12 个数字的向量,表示我们行动的概率分布。
-
值是一个标量,估计传递的状态的“好坏”。值的具体含义将在下一节中讨论。
在我的实现中,架构与论文中的完全一致,模型位于模块 libcube/model.py 中。在输入和输出之间,网络有多个全连接层,使用指数线性单元(ELU)激活函数,如论文中所讨论的:
class Net(nn.Module):
def __init__(self, input_shape, actions_count):
super(Net, self).__init__()
self.input_size = int(np.prod(input_shape))
self.body = nn.Sequential(
nn.Linear(self.input_size, 4096),
nn.ELU(),
nn.Linear(4096, 2048),
nn.ELU()
)
self.policy = nn.Sequential(
nn.Linear(2048, 512),
nn.ELU(),
nn.Linear(512, actions_count)
)
self.value = nn.Sequential(
nn.Linear(2048, 512),
nn.ELU(),
nn.Linear(512, 1)
)
def forward(self, batch, value_only=False):
x = batch.view((-1, self.input_size))
body_out = self.body(x)
value_out = self.value(body_out)
if value_only:
return value_out
policy_out = self.policy(body_out)
return policy_out, value_out
forward() 调用可以有两种模式:既可以获取策略和值,也可以在 value_only=True 时,仅获取值。这在只有值头部结果需要关注时可以节省一些计算。
训练
在这个网络中,策略告诉我们应该对状态应用什么变换,而值则估计状态的好坏。但是,大问题仍然存在:我们如何训练这个网络?
如前所述,论文中提出的训练方法称为自学迭代(ADI)。让我们来看看它的结构。我们从目标状态(已组装的立方体)开始,应用一系列预定义长度 N 的随机变换。这样我们就得到了一个包含 N 个状态的序列。
对于序列中的每个状态 s,我们执行以下过程:
-
对 s 应用所有可能的变换(共 12 种)。
-
将这 12 个状态传递给我们当前的神经网络,要求输出值。这为 s 的每个子状态提供了 12 个值。
-
s 的目标值计算公式为 y[v[i]] = maxa+R(A(s,a))),其中 A(s,a) 是对状态 s 执行动作 a 后的状态,R(s) 等于 1 如果 s 是目标状态,其他情况为 -1。
-
s 的目标策略使用相同的公式进行计算,但我们取的是 argmax 而非 max:y[p[i]] = arg maxa + R(A(s,a)))。这意味着我们的目标策略将在子状态的最大值位置上为 1,在其他所有位置上为 0。
这个过程如图 21.3 所示,取自论文。生成了一个混乱序列,x[0],x[1],…x[N],其中魔方 x[i] 被展开显示。对于这个状态 x[i],我们通过应用前述公式,从展开状态中为策略头和值头生成目标。

图 21.3:训练数据生成
使用这个过程,我们可以生成任何我们需要的训练数据。
模型应用
好的,假设我们已经使用刚才描述的过程训练好了模型。我们应该如何使用它来解决打乱的魔方呢?从网络的结构上看,你可能会想出一个明显的,但并不成功的方法:
-
将我们想要解决的魔方的当前状态输入模型。
-
从策略头获取最大动作(或从结果分布中采样)。
-
对魔方执行该动作。
-
重复该过程,直到达到已解决的状态。
理论上,这种方法应该可行,但在实践中,它存在一个严重的问题:它不可行!主要原因在于我们的模型质量。由于状态空间的庞大和神经网络的性质,我们无法训练出一个神经网络,在任何输入状态下都能准确地返回最优动作。我们的模型并不是直接告诉我们如何做才能得到已解决的状态,而是展示了我们应该探索的有前景的方向。这些方向可能会把我们带得更接近解决方案,但有时也可能会误导我们,因为这个特定状态在训练过程中从未见过。别忘了,状态空间有 4.33 ⋅ 10¹⁹ 个,即使使用每秒数十万个状态的图形处理单元(GPU)训练速度,经过一个月的训练,我们也只能看到状态空间中的一小部分,大约为 0.0000005%。因此,必须使用更复杂的方法。
有一类非常流行的方法,称为 MCTS,其中一种方法在上一章中已有介绍。这些方法有很多变种,但总体思路可以通过与众所周知的暴力搜索方法进行比较来描述,比如广度优先搜索(BFS)或深度优先搜索(DFS)。在 BFS 和 DFS 中,我们通过尝试所有可能的动作并探索从这些动作得到的所有状态,来对我们的状态空间进行穷举搜索。这种行为与之前描述的过程正好相反(当我们有某种东西可以告诉我们在每个状态下应该去哪里时)。但 MCTS 在这些极端之间提供了一种选择:我们想进行搜索,并且有一些关于我们应该去哪里的信息,但在某些情况下,这些信息可能不可靠、嘈杂,甚至完全错误。然而,有时这些信息能够帮助我们发现可能加速搜索过程的有前景的方向。
正如我提到的,MCTS 是一系列方法,它们在具体细节和特点上有所不同。在论文中,使用了一种叫做上置信界 1(Upper Confidence Bound 1)的方法。这种方法作用于树形结构,其中节点代表状态,边表示连接这些状态的动作。在大多数情况下,整个树是巨大的,因此我们不能尝试构建整个树,而只能构建其中的一小部分。
一开始,我们从一个只包含一个节点的树开始,这个节点就是我们当前的状态。在每一步的 MCTS 中,我们沿着树向下走,探索树中的某条路径,可能会遇到两种选择:
-
我们当前的节点是叶节点(我们还没有探索这个方向)
-
我们当前的节点位于树的中间,并且有子节点。
对于叶节点,我们通过对状态应用所有可能的动作来“扩展”它。所有结果状态都会被检查是否是目标状态(如果已找到已解的魔方的目标状态,我们的搜索就结束了)。叶节点状态会被传递到模型,并且来自值头和策略头的输出会被存储以供后续使用。
如果节点不是叶节点,我们就知道它的子节点(可达的状态),并且我们从网络中获得了值和策略的输出。因此,我们需要做出决定,选择应该跟随哪条路径(换句话说,选择哪一个动作更有可能被探索)。这个决策并非易事,这就是我们在本书中先前讲过的探索与利用问题。一方面,来自网络的策略告诉我们该怎么做。但如果它是错误的呢?这个问题可以通过探索周围的状态来解决,但我们不希望总是进行探索(因为状态空间是巨大的)。因此,我们应该保持平衡,这直接影响到搜索过程的性能和结果。
为了解决这个问题,对于每个状态,我们保持一个计数器,记录每个可能的动作(共有 12 个),每当该动作在搜索过程中被选择时,计数器会增加。为了决定跟随哪个动作,我们使用这个计数器;一个动作被采取得越多,它在未来被选择的可能性就越小。
此外,模型返回的值也被用于这个决策过程中。这个值作为当前状态的值与其子状态的最大值进行跟踪。这使得最有前景的路径(从模型的角度来看)能够从父状态中被看到。
总结来说,从非叶节点的树中选择的动作是通过以下公式来选择的:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq76.png)
这里,Ns[t]表示在状态 s[t]中选择动作 a 的次数。Ps[t]是模型为状态 s[t]返回的策略,Ws[t]是模型对于状态 s[t]在分支 a 下所有子状态的最大值。
这个过程会一直重复,直到找到解决方案或耗尽时间预算。为了加速这一过程,MCTS 通常以并行方式实现,由多个线程执行多个搜索。在这种情况下,可能会从 A[t]中减去一些额外的损失,以防止多个线程探索树的相同路径。
解决这个过程难题的最后一部分是,一旦我们到达目标状态,如何从 MCTS 树中获取解决方案。论文的作者尝试了两种方法:
-
初步方法:一旦我们遇到目标状态,我们就使用从根状态到目标状态的路径作为解决方案。
-
BFS 方法:在达到目标状态后,在 MCTS 树上执行 BFS,以找到从根节点到该状态的最短路径。
根据作者的说法,第二种方法比初步方法找到的解决方案更短,这并不令人惊讶,因为 MCTS 过程的随机性可能会在解决路径中引入循环。
结果
论文中发布的最终结果相当令人印象深刻。在一个配有三块 GPU 的机器上训练了 44 小时后,网络学会了解决魔方,达到了与人类设计的求解器相同的水平(有时甚至更好)。最终模型与前面提到的两种求解器进行了比较:Kociemba 两阶段求解器和 Korf。论文中提出的方法名为 DeepCube。
为了比较效率,所有方法使用了 640 个随机打乱的魔方。打乱的深度为 1,000 步。解决方案的时间限制为一小时,DeepCube 和 Kociemba 求解器都能在该时间限制内解决所有魔方。Kociemba 求解器非常快速,其中位解决时间仅为一秒,但由于该方法中硬编码规则的实现,它的解决方案并不总是最短的。
DeepCube 方法花费了更多的时间,中位数时间大约为 10 分钟,但它能够与 Kociemba 解法的解答长度相匹配,或者在 55% 的情况下表现得更好。从个人角度来看,55% 的表现并不足以证明神经网络在性能上有显著优势,但至少它们并不逊色。
在图 21.4 中,展示了所有求解器的解答长度分布。正如你所看到的,由于 Korf 求解器解决魔方所需的时间过长,它没有在 1,000 次混乱测试中进行比较。为了将 DeepCube 的表现与 Korf 求解器进行比较,创建了一个更简单的 15 步混乱测试集:

图 21.4:不同求解器找到的解答长度
代码大纲
现在你已经了解了一些背景,让我们切换到代码部分,代码位于书籍 GitHub 仓库的 Chapter21 目录中。在本节中,我将简要概述我的实现和关键设计决策,但在此之前,我必须强调关于代码的重要细节,以便设定正确的期望:
-
我不是一个研究人员,所以这段代码的最初目标只是重新实现论文中的方法。不幸的是,论文中关于超参数的细节非常少,所以我不得不做很多实验,尽管如此,我的结果与论文中发布的结果差异很大。
-
与此同时,我尽量将所有内容实现得更通用,以简化后续的实验。例如,魔方状态和变换的具体细节被抽象化,这使得我们可以通过添加新模块来实现更多类似于 3×3 魔方的谜题。在我的代码中,已实现了 2×2 和 3×3 魔方,但任何具有固定可预测动作集合的完全可观察环境都可以被实现并进行实验。具体细节将在本节稍后(在“魔方环境”小节中)给出。
-
代码的清晰性和简洁性被置于性能之前。当然,当有机会在不引入过多开销的情况下提高性能时,我会这么做。例如,仅仅通过将混乱魔方的生成和前向网络传递分开,训练过程的速度提高了五倍。但如果性能要求将一切重构为多 GPU 和多线程模式,我宁愿保持简单。一个很明确的例子是 MCTS 过程,通常会实现为多线程代码共享树结构。它通常可以加速数倍,但需要在进程之间进行复杂的同步。因此,我的 MCTS 版本是串行的,仅对批量搜索做了微小的优化。
总体而言,代码包含以下部分:
-
魔方环境,定义了观察空间、可能的动作以及状态到网络的准确表示。这个部分在 libcube/cubes 模块中实现。
-
神经网络部分,描述了我们将要训练的模型、训练样本的生成和训练循环。它包括训练工具 train.py 和模块 libcube/model.py。
-
立方体的求解器或搜索过程,包括求解器(solver.py)工具和实现 MCTS 的 libcube/mcts.py 模块。
-
各种工具被用来将其他部分粘合在一起,如包含超参数的配置文件和用于生成立方体问题集的工具。
立方体环境
正如你已经看到的,组合优化问题是相当庞大和多样的。即使是狭义的立方体类谜题,也包括了几十种变体。最流行的包括 2 × 2 × 2、3 × 3 × 3 和 4 × 4 × 4 的魔方,Square-1 和 Pyraminx(ruwix.com/twisty-puzzles/)。与此同时,本文中提出的方法是相当通用的,不依赖于先验领域知识、动作数量和状态空间大小。对问题的关键假设包括:
-
环境的状态需要是完全可观察的,观察结果需要能够区分不同的状态。这对魔方来说是成立的,因为我们可以看到所有面的状态,但对于大多数扑克牌变体来说,这不成立,例如我们看不到对手的牌。
-
动作的数量需要是离散且有限的。我们可以对魔方采取的动作数量是有限的,但如果我们的动作空间是“将方向盘旋转角度 α ∈ [−120∘…120∘]”,那么我们面对的将是一个不同的问题领域,正如你在涉及连续控制问题的章节中已经看到的那样。
-
我们需要有一个可靠的环境模型;换句话说,我们必须能够回答类似“将动作 a[i] 应用于状态 s[j] 后会得到什么结果?”这样的问题。如果没有这个,ADI 和 MCTS 都无法应用。这是一个强要求,对于大多数问题,我们没有这样的模型,或者其输出是相当嘈杂的。另一方面,在像国际象棋或围棋这样的游戏中,我们有这样的模型:游戏规则。
与此同时,正如我们在上一章(关于 MuZero 方法)中看到的,你可以使用神经网络来逼近模型,但代价是性能会降低。
-
此外,我们的领域是确定性的,因为对相同的状态应用相同的动作总是会得到相同的最终状态。反例可能是西洋双陆棋,每回合玩家投掷骰子来决定他们可能进行的步数。很可能,这种方法也可以推广到这种情况。
为了简化方法在与 3 × 3 立方体不同领域中的应用,所有具体的环境细节被移到单独的模块中,并通过抽象接口 CubeEnv 与其余代码进行通信,该接口在 libcube/cubes/_env.py 模块中进行了描述。让我们来看看它的接口。
如下代码片段所示,类的构造函数接受一组参数:
-
环境的名称。
-
环境状态的类型。
-
魔方的初始(已组装)状态实例。
-
检查特定状态是否表示已组装魔方的谓词函数。对于 3×3 魔方来说,这可能是多余的,因为我们可以直接将其与传递给
initial_state参数的初始状态进行比较;但对于 2×2 和 4×4 等魔方,可能有多个最终状态,因此需要单独的谓词函数来处理这种情况。 -
可以应用于状态的动作枚举。
-
转换函数,接受状态和动作,并返回结果状态。
-
逆函数,将每个动作映射到其逆操作。
-
渲染函数,用于以人类可读的形式表示状态。
-
编码状态张量的形状。
-
将紧凑状态表示编码为适合神经网络的形式的函数。
class CubeEnv:
def __init__(self, name, state_type, initial_state, is_goal_pred,
action_enum, transform_func, inverse_action_func,
render_func, encoded_shape, encode_func):
self.name = name
self._state_type = state_type
self.initial_state = initial_state
self._is_goal_pred = is_goal_pred
self.action_enum = action_enum
self._transform_func = transform_func
self._inverse_action_func = inverse_action_func
self._render_func = render_func
self.encoded_shape = encoded_shape
self._encode_func = encode_func
如你所见,魔方环境与 Gym API 不兼容;我故意使用这个例子来说明如何超越 Gym 的限制。
CubeEnv API 中的一些方法仅仅是构造函数传递的函数的包装器。这允许新的环境在一个单独的模块中实现,注册到环境注册表中,并为其余代码提供一致的接口:
def __repr__(self):
return "CubeEnv(%r)" % self.name
def is_goal(self, state):
assert isinstance(state, self._state_type)
return self._is_goal_pred(state)
def transform(self, state, action):
assert isinstance(state, self._state_type)
assert isinstance(action, self.action_enum)
return self._transform_func(state, action)
def inverse_action(self, action):
return self._inverse_action_func(action)
def render(self, state):
assert isinstance(state, self._state_type)
return self._render_func(state)
def encode_inplace(self, target, state):
assert isinstance(state, self._state_type)
return self._encode_func(target, state)
类中的所有其他方法提供基于这些原始操作的扩展统一功能。
方法sample_action()提供了随机选择一个动作的功能。如果传递了prev_action参数,我们会排除逆向动作,从而避免生成短循环,例如 R →r 或 L →l:
def sample_action(self, prev_action=None):
while True:
res = self.action_enum(random.randrange(len(self.action_enum)))
if prev_action is None or self.inverse_action(res) != prev_action:
return res
方法scramble()将一系列动作(作为参数传递)应用于魔方的初始状态,并返回最终状态:
def scramble(self, actions):
s = self.initial_state
for action in actions:
s = self.transform(s, action)
return s
方法scramble_cube()提供了随机打乱魔方的功能,并返回所有中间状态。如果return_inverse参数为 False,函数返回包含每一步打乱过程的(depth, state)元组列表。如果参数为 True,它返回一个包含三个值的元组:(depth, state, inv_action),这些值在某些情况下是必需的:
def scramble_cube(self, scrambles_count, return_inverse=False, include_initial=False):
assert isinstance(scrambles_count, int)
assert scrambles_count > 0
state = self.initial_state
result = []
if include_initial:
assert not return_inverse
result.append((1, state))
prev_action = None
for depth in range(scrambles_count):
action = self.sample_action(prev_action=prev_action)
state = self.transform(state, action)
prev_action = action
if return_inverse:
inv_action = self.inverse_action(action)
res = (depth+1, state, inv_action)
else:
res = (depth+1, state)
result.append(res)
return result
方法explore_states()实现了 ADI 的功能,并对给定的魔方状态应用所有可能的动作。返回值是一个元组,其中第一个列表包含扩展状态,第二个列表包含标记这些状态是否为目标状态的标志:
def explore_state(self, state):
res_states, res_flags = [], []
for action in self.action_enum:
new_state = self.transform(state, action)
is_init = self.is_goal(new_state)
res_states.append(new_state)
res_flags.append(is_init)
return res_states, res_flags
通过这种通用功能,类似的环境可以被实现并轻松地插入到现有的训练和测试方法中,只需要非常少的样板代码。作为示例,我提供了我在实验中使用的 2 × 2 × 2 立方体和 3 × 3 × 3 立方体。它们的内部实现位于 libcube/cubes/cube2x2.py 和 libcube/cubes/cube3x3.py,你可以将它们作为基础来实现你自己的此类环境。每个环境需要通过创建 CubeEnv 类的实例并将该实例传递给 libcube/cubes/_env.py 中定义的 register() 函数来注册自己。以下是来自 cube2x2.py 模块的相关代码:
_env.register(_env.CubeEnv(name="cube2x2", state_type=State, initial_state=initial_state,
is_goal_pred=is_initial, action_enum=Action,
transform_func=transform, inverse_action_func=inverse_action,
render_func=render, encoded_shape=encoded_shape,
encode_func=encode_inplace))
完成此步骤后,可以通过使用 libcube.cubes.get() 方法来获取立方体环境,该方法以环境名称作为参数。其余的代码仅使用 CubeEnv 类的公共接口,这使得代码与立方体类型无关,并简化了可扩展性。
训练
训练过程在工具 train.py 和模块 libcube/model.py 中实现,它是对论文中描述的训练过程的直接实现,有一个区别:该代码支持两种计算网络值头目标值的方法。方法之一与论文中描述的完全相同,另一种是我的修改,我将在后续部分详细解释。
为了简化实验并使结果可复现,所有训练的参数都在单独的 .ini 文件中指定,该文件提供了以下训练选项:
-
将要使用的环境名称;目前,cube2x2 和 cube3x3 可用。
-
运行的名称,在 TensorBoard 名称和目录中用于保存模型。
-
在 ADI 中将使用哪种目标值计算方法。我实现了两种方法:一种是论文中描述的,另一种是我的修改方法,从我的实验来看,这种方法具有更稳定的收敛性。
-
训练参数:批量大小、CUDA 使用、学习率、学习率衰减等。
你可以在仓库的 ini 文件夹中找到我的实验示例。在训练过程中,参数的 TensorBoard 指标会被写入 runs 文件夹。具有最佳损失值的模型会保存在 saves 目录中。
为了让你了解配置文件的样子,以下是 ini/cube2x2-paper-d200.ini,它定义了一个使用论文中的值计算方法和 200 步混合的 2 × 2 立方体实验:
[general]
cube_type=cube2x2
run_name=paper
[train]
cuda=True
lr=1e-5
batch_size=10000
scramble_depth=200
report_batches=10
checkpoint_batches=100
lr_decay=True
lr_decay_gamma=0.95
lr_decay_batches=1000
要开始训练,你需要将 .ini 文件传递给 train.py 工具;例如,以下是如何使用前述 .ini 文件来训练模型:
$ ./train.py -i ini/cube2x2-paper-d200.ini -n t1
额外的参数 -n 用于指定运行的名称,该名称将与 .ini 文件中的名称结合,作为 TensorBoard 系列的名称。
搜索过程
训练的结果是一个包含网络权重的模型文件。该文件可以用于使用 MCTS 求解魔方,MCTS 实现位于工具 solver.py 和模块 libcube/mcts.py 中。
求解工具非常灵活,可以在多种模式下使用:
-
通过传递 -p 选项来解决给定的一个打乱的魔方,作为逗号分隔的动作索引列表。例如,-p 1,6,1 表示通过应用第二个动作、然后第七个动作,最后再次应用第二个动作来打乱魔方。动作的具体含义依赖于环境,通过 -e 选项传递。你可以在魔方环境模块中找到动作及其索引。例如,2×2 魔方上,动作 1,6,1 表示 L → R′ → L 的变换。
-
从文本文件中读取排列(每行一个魔方),并解决它们。文件名通过 -i 选项传递。文件夹 cubes_tests 中有几个示例问题。你可以使用 gen_cubes.py 工具生成自己的随机问题集,该工具允许你设置随机种子、打乱深度以及其他选项。
-
生成一个随机的打乱,给定的深度并解决它。
-
通过增加复杂度(打乱深度)运行一系列测试,解决问题,并写入包含结果的 CSV 文件。通过传递 -o 选项启用此模式,它对于评估训练模型的质量非常有用,但完成这些操作可能需要大量时间。可选地,可以生成带有这些测试结果的图表。
在所有情况下,你需要通过 -e 选项传递环境名称,并通过 -m 选项传递模型的权重文件。此外,还有其他参数,允许你调整 MCTS 选项以及时间或搜索步数的限制。你可以在 solver.py 的代码中找到这些选项的名称。
实验结果
不幸的是,论文没有提供关于该方法的许多重要细节,如训练超参数、训练过程中魔方被打乱的深度以及获得的收敛性。为了填补这些空白,我尝试了不同的超参数值(.ini 文件可在 GitHub 仓库中找到),但我的结果与论文中发布的结果差异很大。我观察到,原始方法的训练收敛性非常不稳定。即使使用较小的学习率和较大的批量大小,训练最终仍会发散,值损失成分会呈指数增长。图 21.5 和图 21.6 展示了这种行为的例子(来自 2×2 环境):

图 21.5:在论文方法训练期间,值头预测的值

图 21.6:论文方法典型运行中的策略损失(左)和值损失(右)
在进行多次实验后,我得出结论,这种行为是由于方法中提出了错误的值目标。实际上,在公式 y[v[i]] = maxa + R(A(s,a))) 中,网络返回的值 vs 总是加到实际奖励 R(s) 上,即使是目标状态。这样,网络返回的实际值可能是任何值:−100、10⁶,或者 3.1415。这对于神经网络训练来说并不是一个理想的情况,尤其是当使用均方误差(MSE)目标时。
为了检查这一点,我通过为目标状态分配一个 0 目标,修改了目标值计算的方法:
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq77.png)
可以通过在 .ini 文件中指定参数 value_targets_method 为 zero_goal_value 来启用这个目标,而不是默认的 value_targets_method=paper。
通过这个简单的修改,训练过程更快地收敛到了网络值头返回的稳定值。这种收敛的一个例子展示在图 21.7 和图 21.8 中:

图 21.7:训练期间值头预测的值

图 21.8:修改值计算后,策略损失(左)和值损失(右)
2 × 2 立方体
在论文中,作者报告了在一台配有三块 Titan Xp GPU 的机器上训练了 44 小时。在训练过程中,他们的模型查看了 80 亿个立方体状态。这些数字对应于训练速度 50,000 立方体/秒。我的实现使用单个 GTX 1080 Ti 显示为 15,000 立方体/秒,速度相当。因此,要在单个 GPU 上重复训练过程,我们需要等待近六天,这对于实验和超参数调优来说并不实用。
为了克服这一点,我实现了一个简单得多的 2 × 2 立方体环境,训练只需要一两个小时。为了重现我的训练,仓库中有两个 .ini 文件:
-
ini/cube2x2-paper-d200.ini:使用了论文中描述的值目标方法
-
ini/cube2x2-zero-goal-d200.ini: 目标值被设置为 0,用于目标状态
两种配置都使用了 10k 状态的批次和 200 的打乱深度,训练参数相同。训练结束后,使用这两种配置分别生成了两个模型:
-
论文方法:损失 0.032572
-
零目标方法:损失 0.012226
为了进行公平的比较,我为深度 1…50(共 1000 个测试立方体)生成了 20 个测试打乱,保存在 cubes_test/3ed 中,并在每种方法生成的最佳模型上运行了 solver.py 工具。对于每个测试打乱,搜索的限制被设置为 30,000。该工具生成了 CSV 文件(保存在 csvs/3ed 中),其中包含每个测试结果的详细信息。
我的实验表明,论文中描述的模型能够解决 55%的测试魔方,而带零目标修改的模型解决了 100%。两种模型在不同混乱深度下的结果如图 21.9 所示。在左图中,显示了解决魔方的比例。在右图中,显示了每个混乱深度所需的平均 MCTS 搜索步数。正如你所见,修改版本在找到解时需要显著(3 倍至 5 倍)更少的 MCTS 搜索次数,因此学到的策略更好。

图 21.9:已解 2 × 2 魔方的比例(左)和不同混乱深度下需要的平均 MCTS 搜索次数
最后,让我们检查一下找到的解的长度。在图 21.10 中,绘制了天真的方法和 BFS 解的长度。从这些图中可以看出,天真的解比 BFS 找到的解长得多(长了 10 倍)。这样的差异可能是未调优 MCTS 参数的迹象,这些参数是可以改进的。在天真的解中,零目标找到的解更短(这可能再次表明一个更好的策略)。

图 21.10:天真方法(左)和 BFS 方法(右)生成解法的比较
3 × 3 魔方
3 × 3 魔方模型的训练要重得多;我们这里仅仅是刚刚触及表面。但我有限的实验表明,零目标修改训练方法大大提高了训练稳定性和最终模型质量。训练大约需要 20 小时,因此进行大量实验需要时间和耐心。
我的结果没有论文中报告的那样亮眼:我能够获得的最佳模型可以解决最多12…15深度的魔方混乱问题,但在更复杂的问题上总是失败。可能,利用更多的中央处理单元(CPU)核心和并行 MCTS,这些数字可以得到改进。为了获取数据,搜索过程限制为 100k 步,并且每个混乱深度生成了五个随机混乱(可在 repo 中的 cubes_tests/3ed 找到)。但再次强调,修改版本显示出了更好的结果——使用论文方法训练的模型只能解决混乱深度为 9 的问题,而修改版本能够达到 13 的深度。
图 21.11 显示了论文中提出的方法和带零值目标的修改版本的解法率比较(左图)。图的右侧显示了平均 MCTS 搜索次数。

图 21.11:两种方法解决的 3 × 3 魔方比例(左)和平均 MCTS 搜索次数
图 21.12 显示了找到的最优解的长度。如前所述,天真搜索产生的解比 BFS 优化后的解长。BFS 的解长几乎完美地与混乱深度对齐:

图 21.12:3 × 3 魔方的朴素(左)与广度优先搜索(右)解法长度对比
理论上,在深度达到 20 后,它应该会饱和(因为“神数”是 20),但我的版本无法解决任何打乱深度超过 13 的魔方,因此很难判断。
进一步的改进和实验
有许多方向和方法可以尝试:
-
更多输入和网络工程:魔方是一个复杂的物体,因此简单的前馈神经网络可能不是最佳模型。可能,网络可以从卷积中大大受益。
-
训练中的振荡和不稳定性可能是强化学习中常见的步骤间相关性问题的信号。通常的方法是目标网络,我们使用旧版本的网络来获取自举值。
-
优先重放缓冲区可能有助于提高训练速度。
-
我的实验显示,样本的加权(与打乱深度成反比)有助于获得一个更好的策略,能够解决稍微打乱的魔方,但可能会减慢对更深状态的学习。可能,随着训练的进行,这种加权可以做成自适应的,使得它在后期训练阶段不那么激进。
-
可以将熵损失加入训练中,以规范化我们的策略。
-
2×2 立方体模型没有考虑到立方体没有中央小方块这一事实,因此整个立方体可以旋转。对于 2 × 2 立方体来说,这可能并不十分重要,因为状态空间较小,但对于 4 × 4 立方体来说,相同的观察将变得至关重要。
-
需要更多实验以获得更好的训练和蒙特卡罗树搜索(MCTS)参数。
摘要
在这一章中,我们讨论了离散优化问题——优化领域的一个子领域,处理像图或集合这样的离散结构。我们通过使用魔方这一众所周知但仍具挑战性的问题来检验强化学习的适用性。但总体来说,这一话题远比拼图问题广泛——同样的方法可以用于优化日程安排、最优路径规划和其他实际问题。
在本书的最后一章,我们将讨论强化学习中的多智能体问题。
第二十二章:多智能体强化学习
在上一章中,我们讨论了离散优化问题。在本章中,我们将介绍多智能体强化学习(有时缩写为 MARL),这是一种相对较新的强化学习(RL)和深度强化学习(Deep RL)方向,涉及多个智能体在环境中进行交互的情况。现实生活中,这类问题出现在拍卖、宽带通信网络、物联网等场景中。
在本章中,我们将快速浏览一下 MARL,并在简单环境中进行一些实验;但当然,如果你对此感兴趣,仍有很多事情可以尝试。在我们的实验中,我们将采用一种直接的方式,智能体共享我们正在优化的策略,但观察结果将基于智能体的视角,并包括关于其他智能体位置的信息。通过这种简化,我们的 RL 方法将保持不变,唯一需要预处理的是环境,并且必须处理多个智能体的存在。
更具体来说,我们将:
-
从经典单一智能体强化学习问题与多智能体强化学习(MARL)之间的相似性和差异性概述开始
-
探索由 Geek.AI 英中研究小组实现并开源,后来被 Farama 基金会采纳的 MAgent 环境
-
使用 MAgent 在不同的环境中训练多个智能体群体的模型
什么是多智能体强化学习?
多智能体设置是我们在第一章中讨论的熟悉 RL 模型的自然扩展。在经典 RL 设置中,我们有一个智能体通过观察、奖励和行动与环境进行交互。但在一些经常出现在现实生活中的问题中,我们有多个智能体参与到环境交互中。为了举一些具体的例子:
-
一场象棋比赛,当我们的程序试图击败对手时
-
一个市场模拟,如产品广告或价格变化,当我们的行动可能引发其他参与者的反应时
-
多人游戏,如《Dota 2》或《StarCraft II》,当智能体需要控制多个单位与其他玩家的单位竞争时(在这种情况下,单一玩家控制的多个单位也可能会合作以实现目标)
如果其他智能体不在我们的控制范围内,我们可以将其视为环境的一部分,并继续坚持使用单智能体的常规 RL 模型。如你在第二十章所见,通过自对弈训练是一种非常强大的技术,可以在环境方面不需过多复杂化的情况下获得良好的策略。但在某些情况下,这种方法过于局限,并不是我们所需要的。
此外,研究表明,一组简单的智能体可能表现出远比预期更为复杂的协作行为。一些例子包括 OpenAI 博客文章 openai.com/blog/emergent-tool-use/ 和由 Baker 等人撰写的论文《多智能体自动课程中的突现工具使用》[Bak+20],其中讨论了“捉迷藏”游戏,在这个游戏中,一组智能体合作并逐渐发展出越来越复杂的策略和反策略,以战胜另一组智能体。例如,“用现有物品建造栅栏”和“使用蹦床抓住栅栏后面的智能体”。
在智能体可能进行交流的不同方式方面,可以将其分为两类:
-
竞争性:当两个或更多的智能体试图互相击败以最大化自己的奖励时。最简单的设置是双人游戏,如象棋、双陆棋或 Atari Pong。
-
协作性:当一组智能体需要共同努力以达成某个目标时。
有很多例子属于这些群体中的一种,但最有趣且最接近现实生活的场景通常是两种行为的混合。例子数不胜数,从一些允许你结盟的棋盘游戏到现代公司,在这些公司中,假设 100% 的合作是理所当然的,但现实生活通常比这复杂得多。
从理论角度来看,博弈论为这两种交流形式提供了相当成熟的基础,但为了简洁起见,我们不会深入探讨这一领域,它庞大且有不同的术语。如果你感兴趣,可以找到大量的书籍和课程,深入探讨这个话题。举个例子,最小最大算法是博弈论中的一个著名结果,你在第二十章看到过它的应用。
MARL 是一个相对较年轻的领域,但随着时间的推移,活动逐渐增多;因此,关注这个领域可能会很有趣。
开始使用环境
在我们开始第一个 MARL 示例之前,先来看看我们可以使用的环境。如果你想玩 MARL,选择有些有限。Gym 提供的所有环境只支持一个智能体。虽然有一些针对 Atari Pong 的补丁,可以将其切换为双人模式,但它们并不是标准的,而是例外而非常规。
DeepMind 与 Blizzard 一起使《星际争霸 II》公开可用(github.com/deepmind/pysc2),为实验提供了一个非常有趣且具有挑战性的环境。然而,对于刚接触 MARL 的人来说,这可能过于复杂。在这方面,我发现由 Geek.AI 最初开发的 MAgent 环境非常合适;它简单、快速,并且依赖最小,但仍然允许您模拟不同的多智能体场景进行实验。它没有提供与 Gym 兼容的 API,但我们将自行实现。
如果你对 MARL 感兴趣,你还可以查看 Farama Foundation 提供的 PettingZoo 包:pettingzoo.farama.org。它包含更多的环境和统一的 API 用于通信,但在本章中,我们只关注 MAgent 环境。
MAgent 概览
让我们从高层次来看 MAgent。它提供了一个二维智能体栖息的网格世界的模拟。这些智能体可以观察周围的事物(根据它们的感知范围),移动到离当前位置一定距离的地方,并攻击周围的其他智能体。
可能会有不同的智能体群体,具有不同的特征和交互参数。例如,我们将考虑的第一个环境是一个捕食者-猎物模型,其中“老虎”捕猎“鹿”并为此获得奖励。在环境配置中,您可以指定群体的许多方面,如感知、移动、攻击距离、群体中每个智能体的初始健康、它们在移动和攻击时消耗多少健康等等。除了智能体,环境中可能还包含墙壁,智能体无法穿越这些墙壁。
MAgent 的优点在于它非常具有可扩展性,因为它内部是用 C++实现的,只暴露 Python 接口。这意味着环境中可以有数千个智能体,提供观察并处理智能体的行为。
安装 MAgent
正如常常发生的那样,MAgent 的原始版本已经有一段时间没有维护了。幸运的是,Farama Foundation 分叉了原始的代码库并且目前在维护它,提供了大部分原始功能。他们的版本叫做 MAgent2,文档可以在这里找到:magent2.farama.org/。GitHub 代码库在这里:github.com/Farama-Foundation/magent2。要安装 MAgent2,您需要运行以下命令:
pip install magent2==0.3.3
设置随机环境
为了快速理解 MAgent API 和逻辑,我实现了一个简单的环境,其中有“老虎”和“鹿”智能体,两个组都由随机策略驱动。从强化学习的角度来看,它可能不太有趣,但它将帮助我们快速了解足够的 API 内容,以实现 Gym 环境包装器。示例可以在 Chapter22/forest_random.py 中找到,我们将在这里逐步讲解。
我们从 lib/data.py 中定义的 ForestEnv 开始,它定义了环境。这个类继承自 magent_parallel_env(是的,类名是小写的,违反了 Python 风格指南,但它在库中就是这样定义的),这是 MAgent 环境的基类:
class ForestEnv(magent_parallel_env, EzPickle):
metadata = {
"render_modes": ["human", "rgb_array"],
"name": "forest_v4",
"render_fps": 5,
}
这个类模拟了 Gym API,但它不是 100% 兼容的,因此我们稍后需要在代码中处理这个问题。
在构造函数中,我们实例化了 GridWorld 类,它作为 Python 适配器围绕低级的 MAgent C++ 库 API 工作:
def __init__(self, map_size: int = MAP_SIZE, max_cycles: int = MAX_CYCLES,
extra_features: bool = False, render_mode: tt.Optional[str] = None,
seed: tt.Optional[int] = None, count_walls: int = COUNT_WALLS,
count_deer: int = COUNT_DEER, count_tigers: int = COUNT_TIGERS):
EzPickle.__init__(self, map_size, max_cycles, extra_features, render_mode, seed)
env = GridWorld(self.get_config(map_size), map_size=map_size)
handles = env.get_handles()
self.count_walls = count_walls
self.count_deer = count_deer
self.count_tigers = count_tigers
names = ["deer", "tiger"]
super().__init__(env, handles, names, map_size, max_cycles, [-1, 1],
False, extra_features, render_mode)
在前面的代码中,我们实例化了 GridWorld 类,它实现了我们环境的大部分逻辑。
GridWorld 类由 get_config 函数返回的 Config 实例配置:
@classmethod
def get_config(cls, map_size: int):
# Standard forest config, but deer get reward after every step
cfg = forest_config(map_size)
cfg.agent_type_dict["deer"]["step_reward"] = 1
return cfg
这个函数使用了来自 magent.builtin.config.forest 包的 forest_config 函数,并调整配置,在每一步都为鹿添加奖励。当我们开始训练鹿模型时,这将非常重要,因此每一步奖励 1 会激励智能体活得更久。
其余的配置没有在这里包含,因为它大部分没有变化,定义了许多关于环境的细节,包括以下内容:
-
环境中有多少组智能体?在我们的例子中,有两组: “鹿” 和 “老虎”。
-
每组的属性是什么——它们从当前位置能看到多远?举个例子,鹿可以看到一格远,而老虎可以看到四格。它们能攻击其他人吗,攻击范围多远?每个智能体的初始生命值是多少?它们从伤害中恢复的速度有多快?你可以指定很多参数。
-
它们如何攻击其他组,以及造成什么样的伤害?这有很多灵活性——例如,你可以模拟捕食者仅成对狩猎的场景(我们稍后在本章会做这个实验)。在我们当前的设置中,情况很简单——任何一只老虎都可以攻击任何一只鹿,没有限制。
ForestEnv 类中的最后一个函数是 generate_map,它会将墙壁、鹿和老虎随机放置在地图上:
def generate_map(self):
env, map_size = self.env, self.map_size
handles = env.get_handles()
env.add_walls(method="random", n=self.count_walls)
env.add_agents(handles[0], method="random", n=self.count_deer)
env.add_agents(handles[1], method="random", n=self.count_tigers)
现在让我们来看一下 forest_random.py 的源代码。一开始,我们导入 lib.data 包和来自 Gymnasium 的 VideoRecorder 类:
from gymnasium.wrappers.monitoring.video_recorder import VideoRecorder
from lib import data
RENDER_DIR = "render"
在第二章中,我们使用了 RecordVideo 封装器来自动捕获环境观察,但在 MAgent 环境中,由于返回值不同(所有方法一次返回所有代理的字典而不是单个值),这无法实现。为了解决这个问题,我们将使用 VideoRecorder 类来捕获视频并写入 RENDER_DIR 目录。
首先,我们创建一个 ForestEnv 实例和视频记录器。环境对象包含属性 agents,保存所有代理的字符串标识符。在我们的案例中,它将是一个类似 deer_12 或 tiger_3 的值列表。使用默认配置,在 64×64 的地图上,我们有 204 只鹿代理和 40 只老虎,因此 env.agents 列表包含 244 项:
if __name__ == "__main__":
env = data.ForestEnv(render_mode="rgb_array")
recorder = VideoRecorder(env, RENDER_DIR + "/forest-random.mp4")
sum_rewards = {agent_id: 0.0 for agent_id in env.agents}
sum_steps = {agent_id: 0 for agent_id in env.agents}
我们使用 reset()方法重置环境,但现在它返回一个值(而不是 Gym API 中的两个值)。返回的值是一个字典,键是代理的 ID,值是观察张量:
obs = env.reset()
recorder.capture_frame()
assert isinstance(obs, dict)
print(f"tiger_0: obs {obs[’tiger_0’].shape}, act: {env.action_space(’tiger_0’)}")
print(f"deer_0: obs {obs[’deer_0’].shape}, act: {env.action_space(’deer_0’)}\n")
step = 0
上述代码产生以下输出:
tiger_0: obs (9, 9, 5), act: Discrete(9)
deer_0: obs (3, 3, 5), act: Discrete(5)
行为空间包含五个互斥的行为,适用于鹿(四个方向+一个“不作为”行为)。老虎也可以执行这些动作,但它们还可以在四个方向上进行攻击。
就观察而言,每只老虎会得到一个 9×9 的矩阵,其中包含五个不同的观察平面。鹿的视野较短,所以他们的观察范围仅为 3×3。观察总是以代理为中心,因此它显示的是围绕该特定代理的网格。五个观察平面包括:
-
墙壁:如果该单元格包含墙壁,则为 1,否则为 0。
-
第一组(代理所属组):如果单元格中包含来自代理组的代理,则为 1,否则为 0。
-
第一组健康:该单元格中代理的相对健康值。
-
第二组(敌方代理所在组):如果单元格中有敌人,则为 1,否则为 0。
-
第二组健康:敌人的相对健康值,如果没有敌人则为 0。
如果配置了更多的组,观察将包含更多的观察平面。此外,MAgent 有一个“迷你地图”功能,可以显示每个组的代理的“缩小视图”位置。这个迷你地图功能在我的示例中被禁用,但你可以试着开启它,检查它对训练的影响。如果没有这个功能,每个代理只能看到周围有限范围内的单元格,但迷你地图允许他们更全面地了解环境。
第 1 组和第 2 组是相对于代理组的;因此,在第二平面中,鹿获取其他鹿的信息,而老虎则获得其他老虎的信息。这使得观察独立于组,从而如果需要的话,我们可以为两个组训练一个单一的策略。
观察的另一个可选部分是所谓的“额外特征”,包括代理的 ID、上一个行为、上一个奖励和归一化位置。具体细节可以在 MAgent 源代码中找到,但我们在示例中不使用此功能。
让我们继续描述我们的代码。我们有一个循环,直到环境中没有活着的代理。在每次迭代中,我们为所有代理随机采样动作,并在环境中执行它们:
while env.agents:
actions = {agent_id: env.action_space(agent_id).sample() for agent_id in env.agents}
all_obs, all_rewards, all_dones, all_trunc, all_info = env.step(actions)
recorder.capture_frame()
从env.step()函数返回的所有值都是以agent_id为键的字典。关于 MAgent 环境的另一个非常重要的细节是,代理集是易变的:代理可以从环境中消失(例如,当它们死亡时)。在我们的“森林”环境中,老虎每步失去 0.1 点健康值,吃掉鹿后这一损失可能会增加。鹿只有在受到攻击后才会失去健康值,而在每步中会恢复健康(很可能是因为吃了草)。
当代理死亡(如老虎因饥饿死亡或鹿因老虎的攻击死亡)时,all_dones字典中的相应条目被设置为 True,并且在下一次迭代中,代理会从所有字典和env.agents列表中消失。因此,在一个代理死亡后,整个回合会继续,我们需要在训练过程中考虑到这一点。
在前面的例子中,循环会一直执行,直到没有代理存活。由于老虎和鹿都在随机行动(而且老虎每步都在失去健康),因此所有老虎很可能会因饥饿而死亡,而幸存的鹿将无限期地快乐生活下去。但环境被配置为在没有老虎的情况下自动移除所有鹿,因此我们的程序在 30-40 步后结束。
在循环结束时,我们将代理获得的奖励加总,并跟踪它们存活的步数:
for agent_id, r in all_rewards.items():
sum_rewards[agent_id] += r
sum_steps[agent_id] += 1
step += 1
循环结束后,我们展示按奖励排序的前 20 个代理:
final_rewards = list(sum_rewards.items())
final_rewards.sort(key=lambda p: p[1], reverse=True)
for agent_id, r in final_rewards[:20]:
print(f"{agent_id}: got {r:.2f} in {sum_steps[agent_id]} steps")
recorder.close()
此工具的输出可能如下所示:
$ ./forest_random.py
tiger_0: obs (9, 9, 5), act: Discrete(9)
deer_0: obs (3, 3, 5), act: Discrete(5)
tiger_5: got 34.80 in 37 steps
tiger_37: got 19.70 in 21 steps
tiger_31: got 19.60 in 21 steps
tiger_9: got 19.50 in 21 steps
tiger_24: got 19.40 in 21 steps
tiger_36: got 19.40 in 21 steps
tiger_38: got 19.40 in 21 steps
tiger_1: got 19.30 in 21 steps
tiger_3: got 19.30 in 21 steps
tiger_11: got 19.30 in 21 steps
tiger_12: got 19.30 in 21 steps
tiger_17: got 19.30 in 21 steps
tiger_19: got 19.30 in 21 steps
tiger_26: got 19.30 in 21 steps
tiger_32: got 19.30 in 21 steps
tiger_2: got 19.20 in 21 steps
tiger_8: got 19.20 in 21 steps
tiger_10: got 19.20 in 21 steps
tiger_23: got 19.20 in 21 steps
tiger_25: got 19.20 in 21 steps
Moviepy - Building video render/forest-random.mp4\.
Moviepy - Writing video render/forest-random.mp4
在我的模拟中,有一个代理(tiger_5)特别幸运,活得比其他的都久。最后,程序保存了这一回合的视频。我运行的结果可以在这里查看:youtube.com/shorts/pH-Rz9Q4yrI。
在图 22.1 中,显示了两种不同的状态:游戏开始时和接近结束时。老虎用蓝点表示(如果你是用灰度模式查看,则是较深的蓝点),红点表示鹿,灰点表示墙壁(你可以参考书的数字版查看截图中的颜色)。攻击方向用小黑箭头表示。

图 22.1:森林环境的两种状态:在回合开始时(左侧)和接近结束时(右侧)
在这个例子中,两个代理组的行为是随机的,这并不特别有趣。在接下来的章节中,我们将应用深度 Q 网络(DQN)来提高老虎的狩猎技能。
老虎的深度 Q 网络
在这里,我们将应用 DQN 模型到老虎代理组,检查它们是否能学会更好地狩猎。所有代理共享网络,因此它们的行为将是相同的。鹿群将在本示例中保持随机行为,以便简化处理;我们将在本章后面训练它们。
训练代码可以在 Chapter22/forest_tigers_dqn.py 中找到;它与前几章的其他 DQN 版本没有太大区别。
理解代码
为了使 MAgent 环境与我们的类兼容,实施了一个专门版本的 ExperienceSourceFirstLast 来处理环境的特定情况。这个类被称为 MAgentExperienceSourceFirstLast,可以在 lib/data.py 中找到。我们来查看一下它,了解它如何融入代码的其他部分。
我们定义的第一个类是由我们的 ExperienceSource 生成的条目。正如我们在第七章中讨论的,ExperienceFirstLast 类的实例包含以下字段:
-
state: 当前步骤中来自环境的观测数据
-
action: 我们执行的动作
-
reward: 我们获得的奖励数量
-
last_state: 执行动作后的观测数据
在多代理设置中,每个代理都会生成相同的数据集,但我们还需要能够识别该代理属于哪个组(在这个老虎-鹿的例子中,这个经验是属于老虎还是鹿的轨迹?)。为了保留这一信息,我们定义了一个子类 ExperienceFirstLastMARL,并添加了一个新字段来保存组名:
@dataclass(frozen=True)
class ExperienceFirstLastMARL(ExperienceFirstLast):
group: str
class MAgentExperienceSourceFirstLast:
def __init__(self, env: magent_parallel_env, agents_by_group: tt.Dict[str, BaseAgent],
track_reward_group: str, env_seed: tt.Optional[int] = None,
filter_group: tt.Optional[str] = None):
在 MAgentExperienceSourceFirstLast 的构造函数中,我们传递了以下参数:
-
magent_parallel_env: MAgent 并行环境(我们在前一节中进行了实验)。
-
agents_by_group: 每个代理组的 PTAN BaseAgent 对象。在我们的老虎 DQN 示例中,老虎将由神经网络(ptan.agent.DQNAgent)控制,而鹿则是随机行为。
-
track_reward_group: 指定我们要追踪的组的参数,该组用于追踪回合的奖励。
-
filter_group: 可选的组过滤器,用于生成经验的组。在我们当前的示例中,我们只需要来自老虎的观测数据(因为我们只训练老虎),但在下一节中,我们将训练一个同时适用于老虎和鹿的 DQN,因此该过滤器将被禁用。
在随后的构造函数代码中,我们存储参数并为代理创建两个有用的映射:从代理 ID 到组名,再从组名回到代理 ID:
self.env = env
self.agents_by_group = agents_by_group
self.track_reward_group = track_reward_group
self.env_seed = env_seed
self.filter_group = filter_group
self.total_rewards = []
self.total_steps = []
# forward and inverse map of agent_id -> group
self.agent_groups = {
agent_id: self.agent_group(agent_id)
for agent_id in self.env.agents
}
self.group_agents = collections.defaultdict(list)
for agent_id, group in self.agent_groups.items():
self.group_agents[group].append(agent_id)
@classmethod
def agent_group(cls, agent_id: str) -> str:
a, _ = agent_id.split("_", maxsplit=1)
return a
我们还定义了一个实用方法来提取代理的数字 ID,从而获得组名(在我们的例子中,它将是老虎或鹿)。
接下来是该类的主要方法:迭代器接口,用于从环境中生成经验条目:
def __iter__(self) -> tt.Generator[ExperienceFirstLastMARL, None, None]:
# iterate episodes
while True:
# initial observation
cur_obs = self.env.reset(self.env_seed)
# agent states are kept in groups
agent_states = {
prefix: [self.agents_by_group[prefix].initial_state() for _ in group]
for prefix, group in self.group_agents.items()
}
在此,我们在开始时重置环境,并为代理创建初始状态(以防我们的代理会保持某些状态,但在本章的示例中,它们是无状态的)。
然后,我们迭代本集,直到有存活的代理(就像我们在上一节的示例中所做的那样)。在这个循环中,我们将动作填充到字典中,将代理 ID 映射到动作。为此,我们使用 PTAN BaseAgent 实例,它们可以处理一批观察数据,因此可以非常高效地为整个代理组生成动作:
episode_steps = 0
episode_rewards = 0.0
# steps while we have alive agents
while self.env.agents:
# calculate actions for the whole group and unpack
actions = {}
for prefix, group in self.group_agents.items():
gr_obs = [
cur_obs[agent_id]
for agent_id in group if agent_id in cur_obs
]
gr_actions, gr_states = self.agents_by_groupprefix
agent_states[prefix] = gr_states
idx = 0
for agent_id in group:
if agent_id not in cur_obs:
continue
actions[agent_id] = gr_actions[idx]
idx += 1
一旦我们有了要执行的动作,我们将其发送到环境中,并获得包含新观察值、奖励、完成标志和截断标志的字典。然后,我们为每个当前存活的代理生成经验项:
new_obs, rewards, dones, truncs, _ = self.env.step(actions)
for agent_id, reward in rewards.items():
group = self.agent_groups[agent_id]
if group == self.track_reward_group:
episode_rewards += reward
if self.filter_group is not None:
if group != self.filter_group:
continue
last_state = new_obs[agent_id]
if dones[agent_id] or truncs[agent_id]:
last_state = None
yield ExperienceFirstLastMARL(
state=cur_obs[agent_id], action=actions[agent_id],
reward=reward, last_state=last_state, group=group
)
cur_obs = new_obs
episode_steps += 1
在本节的结尾,我们记录了小组的步数和获得的平均奖励:
self.total_steps.append(episode_steps)
tr_group = self.group_agents[self.track_reward_group]
self.total_rewards.append(episode_rewards / len(tr_group))
有了这个类,我们的 DQN 训练代码几乎与单代理 RL 情况相同。这个示例的完整源代码可以在forest_tigers_dqn.py中找到。在这里,我将仅展示一部分代码,展示如何创建 PTAN 代理和经验源(以说明MAgentExperienceSourceFirstLast是如何使用的):
action_selector = ptan.actions.EpsilonGreedyActionSelector(epsilon=PARAMS.epsilon_start)
epsilon_tracker = common.EpsilonTracker(action_selector, PARAMS)
tiger_agent = ptan.agent.DQNAgent(net, action_selector, device)
deer_agent = data.RandomMAgent(env, env.handles[0])
exp_source = data.MAgentExperienceSourceFirstLast(
env,
agents_by_group={’deer’: deer_agent, ’tiger’: tiger_agent},
track_reward_group="tiger",
filter_group="tiger",
)
buffer = ptan.experience.ExperienceReplayBuffer(exp_source, PARAMS.replay_size)
如你所见,老虎是由神经网络控制的(这个网络非常简单,由一个两层卷积层和一个两层全连接层组成)。一群鹿是由随机数生成器控制的。经验回放缓冲区只会填充老虎的经验,这是因为使用了filter_group="tiger"的参数。
训练与结果
要开始训练,请运行./forest_tigers_dqn.py -n run_name --dev cuda。在一个小时的训练中,老虎的测试奖励达到了最佳得分 82,相较于随机基准,取得了显著的提升。随机行动下,大多数老虎在 20 步后死亡,只有少数幸运的老虎能活得更久。
让我们来计算为了获得这个分数,吃掉了多少只鹿。最初,每只老虎的生命值为 10,每一步消耗 0.5 的生命值。总共有 40 只老虎和 204 只鹿在地图上(你可以通过命令行参数来改变这个数量)。每吃掉一只鹿,老虎获得 8 点生命值,使它们可以多存活 16 步。每步每只老虎获得 1 点奖励,所以 40 只老虎吃掉鹿所带来的“超额奖励”是 82 ⋅ 40 − 20 ⋅ 40 = 2480。每只鹿提供 8 点生命值,这转化为老虎多活 16 步,因此吃掉的鹿的数量是 2480∕16 = 155。所以,差不多 76%的鹿是被我们获得的最佳策略猎杀的。考虑到鹿是随机放置在地图上的,而老虎需要找到它们并进行攻击,这个成绩不错。
很可能是由于老虎视野有限,策略停止了改进。如果你感兴趣,可以在环境设置中启用小地图并进行实验。通过获得更多关于食物位置的信息,策略有可能得到进一步的改善。
在图 22.2 中,显示了训练期间的平均奖励和步数。从中可以看出,主要的增长发生在前 300 个回合,而后来的训练进展几乎为 0:

图 22.2:训练回合中的平均奖励(左)和步数(右)
然而,图 22.3 显示了测试奖励和步数的图表,表明即使在 300 个回合后(大约 0.4 小时的训练),策略仍在持续改进:

图 22.3:测试回合的平均奖励(左)和步数(右)
图 22.4 中的最后一对图表显示了训练过程中的训练损失和 epsilon。两个图表是相关的,这表明训练过程中大部分的新颖性是在探索阶段获得的(因为损失值较高,意味着在训练中会出现新情况)。这可能表明更好的探索方法可能对最终策略有所帮助。

图 22.4:训练期间的平均损失(左)和 epsilon(右)
像往常一样,除了训练外,我还实现了一个工具来检查模型的实际表现。它叫做 forest_tigers_play.py,加载经过训练的模型并在回合中使用,生成观察视频记录。最佳模型的视频(测试得分 82.89)可以在此观看:www.youtube.com/shorts/ZZf80AHk538。如您所见,老虎的狩猎技巧现在明显优于随机策略:在回合结束时,初始的 204 只鹿中只剩下 53 只。
老虎之间的合作
我实施的第二个实验旨在使老虎的生活变得更加复杂,并鼓励它们之间的合作。训练和游戏代码是相同的;唯一的区别在于 MAgent 环境的配置。
如果将参数--mode double_attack 传递给训练工具,则将使用环境数据.DoubleAttackEnv。唯一的区别是配置对象,它为老虎的攻击设置了额外的约束。在新的设置中,它们只能成对攻击鹿,并且必须同时进行。单只老虎的攻击没有任何效果。这无疑使训练和狩猎变得更加复杂,因为现在老虎从吃鹿中获得奖励变得更加困难。要开始训练,可以运行相同的训练工具,但需要额外的命令行参数:
./forest_tigers_dqn.py -n run-name --dev cuda --mode double_attack
让我们来看一下结果。
在图 22.5 中,显示了训练回合中的奖励和步骤图。正如你所见,即使经过 2 小时的训练,奖励仍在提高。同时,回合中的步骤数从未超过 300,这可能表明老虎没有附近的鹿可以吃,最终因饥饿而死(也有可能只是环境中步骤的内部限制)。

图 22.5:双重攻击模式下训练回合的平均奖励(左)和步骤计数(右)
与单只老虎狩猎模式相比,训练过程中的损失没有减少(如图 22.6 所示),这可能表明训练超参数可以改进:

图 22.6:训练过程中的平均损失
要测试模型的实际效果,你可以使用之前相同的工具;只需传入 --mode double_attack 参数即可。我获得的最佳模型的视频记录可以在这里查看:youtu.be/VjGbzP1r7HY。正如你所见,老虎现在成双成对地移动,一起攻击鹿。
训练老虎和鹿
下一个示例是老虎和鹿由不同的 DQN 模型控制并同时训练的场景。老虎因生存时间长而获得奖励,这刺激它们吃更多的鹿,因为在模拟的每一步中,它们都会失去健康点。鹿在每个时间戳也会获得奖励。
代码位于 forest_both_dqn.py 中,是前一个示例的扩展。对于两组智能体,我们分别有一个 DQNAgent 类实例,它使用独立的神经网络将观察转化为动作。经验源是相同的,但现在我们不再过滤老虎的组经验(即参数 filter_group=None)。因此,我们的回放缓冲区现在包含来自环境中所有智能体的观察,而不仅仅是像前一个示例那样来自老虎的观察。在训练过程中,我们会抽取一个批次,并将老虎和鹿的例子分成两个独立的批次,用于训练它们的网络。
我这里不会包含所有代码,因为与前一个示例相比,差别仅在一些细节上。如果你感兴趣,可以查看 GitHub 仓库中的源代码。图 22.7 显示了老虎的训练奖励和步骤。你可以看到,最初,老虎能够稳定地提高它们的奖励,但后来增长停止了:

图 22.7:老虎的平均奖励(左)和训练回合中的步骤计数(右)
在图 22.8 的接下来的两个图中,显示了老虎和鹿在测试中的奖励。这里没有明确的趋势;两组都在竞争,试图击败对方:

图 22.8:老虎(左)和鹿(右)的测试奖励
正如你从图 22.8 中看到的,鹿比老虎更成功,这并不令人惊讶,因为两者的速度相同,鹿只需要一直移动,等待老虎因饥饿而死亡。如果你愿意,可以通过提高老虎的速度或墙体密度来实验环境设置。
和之前一样,你可以使用 utility 工具 forest_both_play.py 来可视化学习到的策略,但现在需要传递两个模型文件。这里有一个视频,比较了鹿的最佳模型和老虎的最佳模型:youtube.com/shorts/vuVL1e26KqY。在视频中,所有鹿都只是朝场地的左边移动。很可能,老虎能够利用这一简单的策略来为自己谋取利益。
战斗环境
除了老虎-鹿环境外,MAgent 还包含了其他一些预定义的配置,你可以在 magent2.builtin.config 和 magent2.environment 包中找到它们。本章的最后一个示例,我们将看看“战斗”配置,其中两组智能体互相对抗(幸好没有吃掉对方)。两个智能体的健康值都是 10,每次攻击造成 2 点健康损失,因此需要 5 次连续攻击才能获得奖励。
你可以在 battel_dqn.py 中找到代码。在这个设置中,一组是随机行为,另一组使用 DQN 来改进策略。训练持续了两小时,DQN 成功找到了一种不错的策略,但最终,训练过程出现了发散。在图 22.9 中,展示了训练和测试的奖励图:

图 22.9:在战斗场景中的训练期间(左)和测试期间(右)平均奖励
视频记录(由工具 battle_play.py 生成)可以在这里找到:youtube.com/shorts/ayfCa8xGY2k。蓝队是随机的,红队由 DQN 控制。
总结
在本章中,我们仅简单介绍了一个非常有趣且充满活力的领域——多智能体强化学习(MARL),该领域在交易仿真、通信网络等方面有着多个实际应用。你可以在 MAgent 环境或其他环境(如 PySC2)中自行尝试许多实验。
恭喜你读完了这本书!我希望这本书对你有所帮助,并且你像我在收集材料和撰写章节时一样,享受阅读它。最后,我希望你在这个令人兴奋且充满活力的强化学习领域好运。这个领域正在快速发展,但只要掌握了基础,追踪该领域的新进展和研究将变得更加简单。
还有许多非常有趣的话题没有覆盖,例如部分可观察的马尔可夫决策过程(环境观察不满足马尔可夫性质)或近期的探索方法,比如基于计数的方法。最近围绕多智能体方法也有很多活动,其中多个智能体需要学习如何协调合作解决共同的问题。我也没有提到基于记忆的强化学习方法,在这种方法中,智能体可以维持某种记忆以保存其知识和经验。为了提高强化学习的样本效率,付出了大量努力,理想情况下,未来有一天它将接近人类的学习表现,但目前这仍是一个遥远的目标。当然,仅仅一本书是不可能覆盖完整领域的,因为几乎每天都有新思想出现。然而,本书的目标是为您提供该领域的实践基础,简化您对常用方法的学习。
我想引用 Volodymyr Mnih 在 2017 年伯克利深度强化学习训练营上发表的《深度强化学习的最新进展、前沿与未来》演讲中的话,这些话仍然非常相关:“深度强化学习领域非常新,所有的东西都令人兴奋。字面上说,什么都还没有解决!”
留下评论!
感谢您从 Packt 出版公司购买本书——我们希望您喜欢这本书!您的反馈对于我们来说非常宝贵,它帮助我们改进和成长。在您读完本书后,请花一点时间留下一个亚马逊评论,这只需几分钟,但对像您这样的读者来说意义重大。
扫描下面的二维码,领取您选择的免费电子书。

第二十三章:参考文献
[Sut88]
Richard S Sutton. “通过时间差方法学习预测”。发表于:机器学习 3 (1988),第 9–44 页。
[HS96]
Sepp Hochreiter 和 Jürgen Schmidhuber. “LSTM 可以解决难度较大的长时延问题”。发表于:神经信息处理系统进展 9 (1996)。
[RK04]
Reuven Y Rubinstein 和 Dirk P Kroese. 《交叉熵方法:组合优化、蒙特卡洛模拟与机器学习的统一方法》。第 133 卷,Springer 出版社,2004 年。
[SL08]
Alexander L Strehl 和 Michael L Littman. “基于模型的马尔科夫决策过程区间估计分析”。发表于:计算机与系统科学杂志 74.8 (2008),第 1309–1331 页。
[Kro+11]
Dirk P Kroese 等. “交叉熵方法”。发表于:欧洲运筹学杂志 31 (2011),第 276–283 页。
[LS11]
Joel Lehman 和 Kenneth O Stanley. “放弃目标:仅通过寻找新奇性进化”。发表于:进化计算 19.2 (2011),第 189–223 页。
[Mni13]
Volodymyr Mnih. “通过深度强化学习玩 Atari 游戏”。发表于:arXiv 预印本 arXiv:1312.5602 (2013)。
[Sil+14]
David Silver 等. “确定性策略梯度算法”。发表于:国际机器学习会议。PMLR。2014 年,第 387–395 页。
[Lil15]
TP Lillicrap. “通过深度强化学习进行连续控制”。发表于:arXiv 预印本 arXiv:1509.02971 (2015)。
[MG15]
James Martens 和 Roger Grosse. “通过克罗内克分解近似曲率优化神经网络”。发表于:国际机器学习会议。PMLR。2015 年,第 2408–2417 页。
[Mni+15]
Volodymyr Mnih 等. “通过深度强化学习实现人类级控制”。发表于:自然期刊 518.7540 (2015),第 529–533 页。
[Sch+15]
Tom Schaul 等. “优先经验回放”。发表于:(2015)。arXiv: 1511.05952 [cs.LG]。网址: arxiv.org/abs/1511.05952。
[Sch15]
John Schulman. “信任区域策略优化”。发表于:arXiv 预印本 arXiv:1502.05477 (2015)。
[VGS16]
Hado Van Hasselt, Arthur Guez, 和 David Silver. “基于双 Q 学习的深度强化学习”。发表于:人工智能 AAAI 会议论文集。第 30 卷,1 号,2016 年。
[Wan+16]
Ziyu Wang 等. “用于深度强化学习的对抗性网络架构”。发表于:国际机器学习会议。PMLR。2016 年,第 1995–2003 页。
[BDM17]
Marc G Bellemare, Will Dabney, 和 Rémi Munos. “从分布视角看强化学习”。发表于:国际机器学习会议。PMLR。2017 年,第 449–458 页。
[Chr+17]
Paul Christiano 等. 《基于人类偏好的深度强化学习》。2017 年。电子印本: arXiv:1706.03741。
[For+17]
Meire Fortunato 等. “探索中的噪声网络”。发表于:(2017)。arXiv: 1706.10295 [cs.LG]。网址: arxiv.org/abs/1706.10295。
[Mar+17]
Jarryd Martin 等人。“基于计数的特征空间探索用于强化学习”。收录于:arXiv 预印本 arXiv:1706.08090 (2017)。
[Ost+17]
Georg Ostrovski 等人。“基于计数的探索与神经密度模型”。收录于:国际机器学习会议。PMLR。2017 年,页码:2721–2730。
[Sal+17]
Tim Salimans 等人。“进化策略:作为强化学习的可扩展替代方法”。收录于:arXiv 预印本 arXiv:1703.03864 (2017)。
[Sch+17]
John Schulman 等人。“近端策略优化算法”。收录于:arXiv 预印本 arXiv:1707.06347 (2017)。
[SSa17]
David Silver、Julian Schrittwieser 和 Karen Simonyan 等人。无需人类知识的围棋游戏掌握。2017. eprint: 10.1038/nature24270。
[Sil+17]
David Silver 等人。通过自我对弈与通用强化学习算法掌握国际象棋和将棋。2017. arXiv: 1712.01815 [cs.AI]。网址:arxiv.org/abs/1712.01815。
[Suc+17]
Felipe Petroski Such 等人。“深度神经进化:遗传算法是训练深度神经网络进行强化学习的竞争性替代方法”。收录于:arXiv 预印本 arXiv:1712.06567 (2017)。
[Vas17]
A Vaswani。“注意力即你所需要的”。收录于:神经信息处理系统进展 (2017)。
[Wu+17]
Yuhuai Wu 等人。“基于克罗内克近似的可扩展信任域方法用于深度强化学习”。收录于:神经信息处理系统进展 30 (2017)。
[Bar+18]
Gabriel Barth-Maron 等人。“分布式分布式确定性策略梯度”。收录于:arXiv 预印本 arXiv:1804.08617 (2018)。
[Bur+18]
Yuri Burda 等人。“通过随机网络蒸馏进行探索”。收录于:arXiv 预印本 arXiv:1810.12894 (2018)。
[Haa+18]
Tuomas Haarnoja 等人。“软演员-评论家:具有随机演员的非策略最大熵深度强化学习”。收录于:国际机器学习会议。PMLR。2018 年,页码:1861–1870。
[Hes+18]
Matteo Hessel 等人。“彩虹:结合深度强化学习的改进”。收录于:人工智能学会年会论文集。第 32 卷,第 1 期,2018 年。
[McA+18]
Stephen McAleer 等人。“无需人类知识解决魔方”。收录于:arXiv 预印本 arXiv:1805.07470 (2018)。
[Bak+20]
Bowen Baker 等人。来自多智能体自动课程的工具使用演化。2020. arXiv: 1909.07528 [cs.LG]。网址:arxiv.org/abs/1909.07528。
[FS20]
Alexander H Frey Jr 和 David Singmaster。“立方体数学手册”。出版于:(2020)。
[Sch+20]
Julian Schrittwieser 等人。“通过规划与学习的模型掌握 Atari、围棋、国际象棋和将棋”。发表于:Nature 588.7839 (2020 年 12 月),页码:604–609. issn: 1476-4687. doi: 10.1038/s41586-020-03051-4。网址:dx.doi.org/10.1038/s41586-020-03051-4。
[BDR23]
Marc G Bellemare, Will Dabney, and Mark Rowland. 分布式强化学习. MIT Press, 2023.

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助您规划个人发展并推动职业发展。如需更多信息,请访问我们的网站。
为什么要订阅?
-
减少学习时间,更多时间编码,享受来自 4000 多位行业专家的实用电子书和视频
-
通过为您量身定制的技能计划提升学习效果
-
每月获取一本免费的电子书或视频
-
完全可搜索,轻松访问关键信息
-
复制、粘贴、打印和收藏内容
在www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费的新闻通讯,并获得 Packt 图书和电子书的独家折扣和优惠。
您可能会喜欢的其他书籍
如果您喜欢这本书,您可能会对 Packt 的这些其他书籍感兴趣:
《掌握 PyTorch》
Ashish Ranjan Jha
ISBN: 9781801074308
-
使用 PyTorch 实现文本、视觉和音乐生成模型
-
在 PyTorch 中构建深度 Q 网络(DQN)模型
-
在移动设备(Android 和 iOS)上部署 PyTorch 模型
-
熟练使用 PyTorch 和 fastai 进行快速原型设计
-
使用 AutoML 高效进行神经网络架构搜索
-
使用 Captum 轻松解读机器学习模型
-
设计 ResNets、LSTMs 和图神经网络(GNNs)
-
使用 Hugging Face 创建语言和视觉转换器模型
《算法交易食谱中的 Python》
Jason Strimpel
ISBN: 9781835084700
-
使用 OpenBB 平台获取并处理自由可用的市场数据
-
构建一个研究环境,并用金融市场数据填充它
-
使用机器学习识别阿尔法因子并将其转化为信号
-
使用 VectorBT 通过步进优化找到策略参数
-
使用 Zipline Reloaded 构建生产级回测并评估因子表现
-
设置代码框架以连接并向 Interactive Brokers 发送订单
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并立即申请。我们与成千上万的开发人员和技术专业人士合作,帮助他们与全球技术社区分享见解。您可以进行一般申请,申请特定的热门话题,或提交您自己的创意。



![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq15.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq17.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq18.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq20.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq21.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq23.png)
≈ 2.89 天。在这种计算中,需要考虑到强化学习文献通常报告的是原始环境帧数。但如果使用了帧跳跃(几乎总是使用),则帧数需要除以这个因子,通常是 4。在我们的测量中,我们计算的是智能体与环境的交互帧数,因此“原始环境 FPS”将是其四倍。![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq40.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq41.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq45.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq46.png)
![π (a |s) = P[At = a|St = s]](https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/drl-hsn/img/eq47.png)
。即便是最快的 GPU,这个概率也不太乐观。当然,我们有开始和结束序列标记,我们可以考虑这些来提高我们的成功机会;不过,即使如此,随机探索找到正确的动作序列的概率依然很小。
浙公网安备 33010602011771号