drl-hsn-merge-0

57 阅读1小时+

深度强化学习实用指南第三版(一)

原文:annas-archive.org/md5/28625da26760ed246b61fc08b36918f7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书讲述的是强化学习(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 出版的这本书——希望你喜欢!你的反馈非常宝贵,能够帮助我们改进和成长。阅读完后,请花点时间留下一个亚马逊评论;这只需一分钟,但对像你这样的读者来说,意义重大。

扫描下方二维码,选择一本免费的电子书。

图片

packt.link/NzOWQ

下载此书的免费 PDF 副本

感谢你购买本书!

你喜欢随时阅读,但又不能把纸质书籍带到处走吗?你购买的电子书是否与你选择的设备不兼容?

别担心;每本 Packt 书籍,你现在都能免费获得该书的无 DRM PDF 版本。

在任何设备上随时阅读。直接从你最喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。

特权不止于此!你还可以获得独家折扣、新闻通讯以及每天发送到你邮箱的精彩免费内容。

按照以下简单步骤获得福利:

  1. 扫描二维码或访问以下链接:

    图片

    packt.link/free-ebook/…

  2. 提交你的购买凭证。

  3. 就是这样!我们会直接将你的免费 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 实体——代理和环境——以及它们的通信渠道——行动、奖励和观察:

AERAOgnecbevwtsniaietrrorodnvnsamteionnts

图 1.2:RL 实体及其通信渠道

我们将在接下来的几个章节中详细讨论它们。

奖励

首先,让我们回到奖励的概念。在强化学习中,奖励只是我们从环境中定期获得的一个标量值。如前所述,奖励可以是正的也可以是负的,大小不一,但它只是一个数字。奖励的目的是告诉智能体它的行为有多好。我们并不定义智能体获得奖励的频率;它可以是每秒一次,也可以是智能体一生中仅有一次,尽管通常做法是每固定时间戳或每次与环境交互时给予奖励,以便于操作。在一次性奖励系统的情况下,除了最后一个奖励之外,所有奖励都为零。

正如我所说,奖励的目的是给智能体提供关于其成功的反馈,这是强化学习中的核心概念。基本上,“强化”一词源自于这样一个事实:智能体获得的奖励应该以积极或消极的方式强化其行为。奖励是局部的,意味着它反映了智能体到目前为止所获得的利益和损失。当然,某个动作获得了大奖励并不意味着,过一秒钟后,你就不会因之前的决策面临剧烈后果。这就像抢银行——在你想到后果之前,它看起来可能是个好主意。

智能体试图实现的目标是其一系列动作中累计的最大奖励。为了帮助你更好地理解奖励,这里列出了一些具体的例子及其奖励:

  • 财务交易:一笔利润是交易员买卖股票的奖励。

  • 国际象棋:奖励在游戏结束时获得,可能是胜利、失败或平局。当然,这取决于解释。例如,对我来说,在与国际象棋大师对弈时取得平局就是一个巨大的奖励。实际上,我们需要指定准确的奖励值,但这可能是一个相当复杂的表达式。例如,在国际象棋中,奖励可能与对手的强度成比例。

  • 大脑中的多巴胺系统:大脑中有一部分(边缘系统)每当需要向大脑其他部分发送积极信号时,会产生多巴胺。高浓度的多巴胺会带来愉悦感,这会强化大脑认为有益的活动。不幸的是,边缘系统在它所认为“有益”的事物上非常古老——食物、繁衍和安全——但这是完全不同的故事!

  • 电脑游戏:它们通常会给玩家提供明显的反馈,通常是击杀的敌人数或者收集的分数。在这个例子中需要注意的是,奖励已经累计,所以街机游戏中的强化学习奖励应该是分数的导数,也就是说,每当击杀一个敌人时奖励+1,玩家被敌人击杀时奖励- N,其余时间奖励为 0。

  • 网络导航:有一些问题,具有很高的实际价值,需要自动提取网络上的信息。搜索引擎通常在尝试解决这一任务,但有时,为了获得所需的数据,你需要填写一些表单、通过一系列链接导航,或完成验证码,这对于搜索引擎来说可能是困难的。针对这些任务,有一种基于强化学习的方法,其中的奖励是你所需的 信息或结果。

  • 神经网络架构搜索:强化学习(RL)可用于神经网络架构优化,在这种情况下,模型的质量至关重要,人们努力提升目标指标的额外 1%。在这一应用场景中,目标是通过调整层数或其参数、添加额外的旁路连接或对神经网络架构做出其他更改,从而在某些数据集上获得最佳的性能指标。此时的奖励是性能(准确度或其他衡量神经网络预测准确性的指标)。

  • 狗狗训练:如果你曾经尝试训练一只狗,你就知道每当它做对了你要求的事情时,你需要给它一些美味的东西(但不要太多)。当它不听从指令时,惩罚它一点(负奖励)也是常见的做法,尽管近期的研究表明,这种做法并不像正向奖励那样有效。

  • 学校成绩:我们都有过这样的经历!学校成绩是一种奖励系统,旨在为学生提供关于他们学习情况的反馈。

正如从前面的例子中可以看出的那样,奖励的概念是智能体表现的一个非常普遍的指示,它可以在我们周围的许多实际问题中找到或人为地注入。

智能体

智能体是指通过执行特定的行动、做出观察,并因此获得最终奖励的人或物。在大多数实际的强化学习场景中,智能体是我们的软件部分,它旨在以或多或少高效的方式解决某个问题。对于我们最初的六个例子,智能体如下:

  • 金融交易:一个交易系统或交易员在执行订单(买入、卖出或不做任何操作)时作出的决策。

  • 国际象棋:一个玩家或计算机程序。

  • 多巴胺系统:大脑本身,根据感官数据决定这是否是一次好的体验。

  • 电子游戏:享受游戏或计算机程序的玩家。(Andrej Karpathy 曾在推特上写道:“我们原本是要让 AI 完成所有工作,我们自己玩游戏,但实际上我们做了所有工作,而 AI 正在玩游戏!”)。

  • 网络导航:告诉浏览器点击哪些链接、移动鼠标到哪里或输入哪些文本的软件。

  • 神经网络架构搜索:控制被评估的神经网络具体架构的软件。

  • 狗狗训练:你做出关于行动的决策(喂食/训斥),所以,代理人是你。但原则上,你的狗也可以被视为代理人——狗狗试图通过正确的行为来最大化奖励(食物和/或关注)。严格来说,这里是一个“多代理强化学习”(multi-agent RL)设置,相关内容在第二十二章有简要讨论。

  • 学校:学生/学员。

环境

环境是代理之外的一切。从最广义上讲,它是宇宙的其余部分,但这稍微有些夸张,甚至超出了即使是明天的计算机的处理能力,所以我们通常在这里遵循一般意义上的理解。

代理人与环境的互动仅限于奖励(从环境中获得)、行动(由代理执行并发送到环境)和观察(代理从环境中获得的除奖励之外的一些信息)。我们已经讨论了奖励,接下来我们来谈谈行动和观察。我们将在讨论观察时确定每个例子的环境。

行动

行动是代理人在环境中可以执行的事情。例如,行动可以是棋盘上的棋子移动(如果是棋类游戏),或者是做作业(在学校的情况下)。它们可以像将兵前进一格那样简单,也可以像建立一家盈利的初创公司那样复杂。

在强化学习(RL)中,我们区分两种类型的行动——离散的或连续的。离散行动形成了代理可以执行的一组有限的、相互排斥的事情,比如向左或向右移动。连续行动则附带一些数值,例如汽车转动方向盘时有一个角度和方向。不同的角度可能会导致一秒钟后不同的情景,因此单纯的“转动方向盘”肯定不够。

给出具体例子,让我们看看六种情境中的行动:

  • 金融交易:行动是买入或卖出股票的决策。“什么也不做,等待”也是一种行动。

  • 国际象棋:行动是根据当前棋盘位置进行的有效棋子移动。

  • 多巴胺系统:行动是你正在做的事情。

  • 电子游戏:行动是按按钮。它们也可以是连续的,比如在汽车模拟器中转动方向盘。

  • 网络浏览:行动可能是鼠标点击、滚动和文字输入。

  • 神经网络架构搜索:行动是神经网络架构的变化,这些变化可以是离散的(网络中的层数)或连续的(丢弃层中的概率)。

  • 狗狗训练:行动是你与狗狗可以做的一切——给它一块美味的食物、抚摸它,甚至用温柔的声音说“乖狗狗!”

  • 学校:行动是成绩和其他非正式的信号,比如表扬成功或布置额外的作业。

观察

环境的观察构成了代理的第二个信息通道,第一个通道是奖励。你可能会想,为什么我们需要一个单独的数据源?答案是方便。观察是环境提供给代理的、指示代理周围发生情况的信息。

观察可能与即将到来的奖励相关(例如看到银行通知自己已收到薪水),也可能无关。观察甚至可能以某种模糊或隐晦的形式包含奖励信息,例如计算机游戏屏幕上的得分数字。得分数字只是像素,但我们有可能将它们转化为奖励值;对于现代计算机视觉技术来说,这并不是一个复杂的任务。

另一方面,奖励不应被视为次要或不重要的东西——奖励是驱动代理学习过程的主要力量。如果奖励是错误的、噪声大的,或者与主要目标稍有偏离,那么训练可能会朝着错误的方向发展。

同时,区分环境的状态和观察也很重要。环境的状态大多数时候是环境内部的,可能包括宇宙中的每一个原子,这使得我们不可能测量环境中的所有信息。即使我们将环境的状态限制得足够小,大多数情况下,我们也不可能获得关于它的完整信息,或者我们的测量会包含噪声。然而,这完全没问题,强化学习(RL)就是为了原生支持这种情况而设计的。为了说明这种区别,我们回到我们的示例集:

  • 金融交易:在这里,环境是整个金融市场及其一切影响因素。这是一个庞大的清单,包含最新的新闻、经济和政治条件、天气、食物供应、Twitter/X 趋势等。甚至你今天决定待在家里,也可能间接影响世界金融系统(如果你相信“蝴蝶效应”)。然而,我们的观察仅限于股价、新闻等。我们无法访问大部分环境状态,这使得金融预测成为一项非常复杂的任务。

  • 国际象棋:这里的环境是你的棋盘加上你的对手,包括他们的棋艺、情绪、大脑状态、选择的战术等。观察是你所看到的(你当前的棋盘局面),但是,在某些层次的比赛中,心理学知识和读取对手情绪的能力可能会提高你的胜算。

  • 多巴胺系统:这里的环境是你的大脑、神经系统、器官状态以及你能感知到的整个世界。观察是来自你感官的内在大脑状态和信号。

  • 电脑游戏:在这里,环境是你电脑的状态,包括所有内存和磁盘数据。对于联网游戏,你需要包括其他电脑以及它们和你机器之间的所有互联网基础设施。观察数据仅限于屏幕的像素和声音。这些像素并不是少量的信息(有估算认为,所有可能的中等大小图像(1024×768)的总数量远远大于我们银河系中原子的数量),但整个环境状态肯定更大。

  • 网络浏览:这里的环境是互联网,包括所有在你代理工作的计算机和网页服务器之间的网络基础设施,这是一个真正庞大的系统,包含了成千上万不同的组件。观察通常是加载在浏览器中的网页。

  • 神经网络架构搜索:在这个例子中,环境相对简单,包括执行特定神经网络评估的神经网络工具包,以及用于获得性能度量的数据集。与互联网相比,这看起来像是一个微小的玩具环境。观察数据可能有所不同,包括一些关于测试的信息,例如损失收敛动态或从评估步骤中获得的其他度量。

  • 狗狗训练:这里,环境是你的狗(包括它几乎无法观察到的内心反应、情绪和生活经验)以及周围的一切,包括其他狗甚至是藏在灌木丛中的猫。观察数据来自你的感官和记忆。

  • 学校:这里的环境是学校本身、国家的教育系统、社会和文化遗产。观察数据与狗狗训练示例中的相同——学生的感官和记忆。

这是我们的“场景布置”,在本书的其余部分我们将围绕它进行讨论。你应该已经注意到,强化学习(RL)模型极其灵活和通用,可以应用于多种场景。现在,让我们在深入探讨 RL 模型的细节之前,先看看强化学习与其他学科的关系。

还有许多其他领域为强化学习做出贡献或与其相关。最重要的几个领域显示在以下图示中,其中包括六个相互重叠的主要领域,这些领域涉及与决策相关的方法和具体话题(显示在内圈内)。

PIC

图 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.80.2
雨天0.10.9

在这种情况下,如果我们是晴天,那么第二天晴天的概率是 80%,雨天的概率是 20%。如果我们观察到雨天,那么天气变好的概率是 10%,第二天仍然是雨天的概率是 90%。

所以,就是这样。马尔可夫过程的正式定义如下:

  • 系统可以处于的一组状态(S)

  • 转移矩阵 (T),包含转移概率,定义了系统的动态

MP 的一个有用的可视化表示是一个图,节点代表系统的状态,边缘则用表示可能从一个状态到另一个状态的转移概率来标注。如果某个转移的概率是 0,我们就不画边(意味着无法从一个状态转移到另一个状态)。这种表示方法在有限状态机表示中也被广泛使用,而有限状态机又是自动机理论中的一个研究领域。对于我们的晴天/雨天天气模型,图示如下:

SRppppuani====nnyy0000....2189

图 1.4:晴天/雨天天气模型

再次强调,我们仅仅是在谈论观察。我们无法影响天气,只能观察它并记录我们的观察结果。

为了给你一个更复杂的例子,我们来考虑一个名为“办公室员工”的模型(《Dilbert》中的主角迪尔伯特就是一个很好的例子)。在我们的示例中,他的状态空间包括以下状态:

  • 家里:他不在办公室

  • 计算机:他在办公室使用电脑工作

  • 咖啡:他在办公室喝咖啡

  • 聊天:他正在与办公室的同事讨论某些事情

状态转移图如下面的图示所示:

CCCHhoooaffmmtepeeuter

图 1.5:我们办公室员工的状态转移图

我们假设我们办公室员工的工作日通常从“家里”状态开始,而且他毫无例外地从“咖啡”状态开始一天(没有“家里 → 计算机”边缘,也没有“家里 → 聊天”边缘)。前面的图示还表明,工作日总是从“计算机”状态结束(也就是说,回到“家里”状态)。

上面图示的转移矩阵如下:

家里咖啡聊天计算机
家里60%40%0%0%
咖啡0%10%70%20%
聊天0%20%50%30%
计算机20%20%10%50%

转移概率可以直接标注在状态转移图上,如图 1.6 所示。

CCCHpppppppppppphooo = = = = = = = = = = = =affmmtepeeu000000000000t.5.3.1.1.2.7.5.2.2.6.2.4er

图 1.6:带有转移概率的状态转移图

在实际操作中,我们很少有机会知道确切的转移矩阵。一个更为现实的情况是,当我们只能观察到系统的状态,这些状态也称为“情节”时:

  • 家里 → 咖啡 → 咖啡 → 聊天 → 聊天 → 咖啡 → 计算机 → 计算机 → 家里

  • 计算机 → 计算机 → 聊天 → 聊天 → 咖啡 → 计算机 → 计算机 → 计算机

  • 家里 → 家里 → 咖啡 → 聊天 → 计算机 → 咖啡 → 咖啡

从我们的观察中估计转移矩阵并不复杂——我们只需计算每个状态的所有转移,并将它们标准化,使其总和为 1。我们拥有的观察数据越多,我们的估计就会越接近真实的底层模型。

还值得注意的是,马尔可夫性质意味着平稳性(即任何状态的潜在转移分布随时间变化)。非平稳性意味着有某种隐藏因素影响着我们的系统动态,而这个因素未包含在观察中。然而,这与马尔可夫性质相矛盾,后者要求相同状态下的基础概率分布在任何转移历史中都是相同的。

重要的是理解我们在一集观察到的实际转移与转移矩阵中给出的潜在分布之间的差异。我们观察到的具体集是从模型的分布中随机抽样得到的,因此它们可能在每一集之间有所不同。然而,具体转移被抽样的概率保持不变。如果不是这样,马尔可夫链形式化就不适用了。

现在我们可以进一步扩展 MP 模型,使其更接近我们的 RL 问题。让我们在图中加入奖励!

马尔可夫奖励过程

为了引入奖励,我们需要稍微扩展我们的 MP 模型。首先,我们需要为状态之间的转换添加值。我们已经有了概率,但概率用于捕捉系统的动态,所以现在我们额外增加了一个标量数值,且不会增加额外负担。

奖励可以以多种形式表示。最通用的方式是另有一个方阵,类似于转移矩阵,表示从状态 i 到状态 j 的转换奖励,存储在第 i 行第 j 列。

如前所述,奖励可以是正数或负数,可以是大或小。在某些情况下,这种表示是多余的,可以简化。例如,如果无论起始状态如何,达到某个状态都会获得奖励,我们可以仅保留(状态,奖励)对,这是一种更紧凑的表示。然而,只有当奖励值仅依赖于目标状态时,这种表示才适用,但这并不总是成立。

我们添加到模型中的第二个内容是折扣因子 γ(希腊字母“gamma”),它是一个介于 0 到 1 之间的数字(包含 0 和 1)。在定义了我们 MRP 的额外特性后,我们会解释它的意义。

正如你会记得的那样,我们在 MP 中观察到的是一系列状态转移。在 MRP 中也是如此,但对于每一个转移,我们都有额外的量——奖励。因此,现在我们所有的观察都有一个与系统每次转移相关的奖励值。

对于每一集,我们定义在时刻 t 的回报为 G[t]:

 ∞∑ Gt = Rt+1 + γRt+2 + ⋅⋅⋅ = γkRt+k+1 k=0

上述公式中的 γ 在 RL 中非常重要,我们在接下来的章节中将经常遇到它。目前,可以将它理解为衡量我们预计未来回报时,观察多远未来的一个参数。它的值越接近 1,我们就越会考虑未来更多的步骤。

现在让我们试着理解回报公式的含义。对于每个时间点,我们将回报计算为后续奖励的总和,但距离起始点 t 越远的奖励,会被折扣因子乘以,并且这个折扣因子会根据我们距离起始点的步数的幂次进行调整。折扣因子代表了智能体的远见性。如果 γ = 1,那么回报 G[t]仅仅等于所有后续奖励的总和,代表智能体可以完美预见所有后续奖励。如果 γ = 0,G[t]则只会是立即奖励,没有任何后续状态,代表绝对的短视。

这些极端值仅在特殊情况下有用,大多数时候,γ会设置为介于两者之间的某个值,如 0.9 或 0.99。在这种情况下,我们会展望未来的奖励,但不会太远。γ = 1 的值可能适用于短期有限的情境。

这个回报量在实践中不是很有用,因为它是针对我们从马尔可夫奖励过程(MRP)观察到的每一个特定链定义的,因此即使是相同的状态,它也可能有很大差异。然而,如果我们走到极端,计算任何状态的回报的数学期望(通过对大量链求平均),我们将得到一个更实用的量,这就是状态的价值:

 ∞∑ Gt = Rt+1 + γRt+2 + ⋅⋅⋅ = γkRt+k+1 k=0

这个解释很简单——对于每个状态 s,值 V(s)是我们通过遵循马尔可夫奖励过程获得的平均(或期望)回报。

为了将这些理论知识实际应用,让我们扩展我们的办公室工作者(Dilbert)过程,加入奖励并将其转化为 Dilbert 奖励过程(DRP)。我们的奖励值将如下所示:

  • 家庭 → 家庭:1(因为待在家里是好事)

  • 家庭 → 咖啡:1

  • 计算机 → 计算机:5(努力工作是好事)

  • 计算机 → 聊天:−3(分心不好)

  • 聊天 → 计算机:2

  • 计算机 → 咖啡:1

  • 咖啡 → 计算机:3

  • 咖啡 → 咖啡:1

  • 咖啡 → 聊天:2

  • 聊天 → 咖啡:1

  • 聊天 → 聊天:-1(长时间的对话变得无聊)

这一图示见于图 1.7。

pppppppppppp = = = = = = = = = = = = 000000000000.5.3.1.1.2.7.5.2.2.6.2.4 CCCHrrhooo =r =rrrrrrrrraffmm = = = = = = = = = =tepeeu−2−112531121t 1 3er

图 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 所示。

i → j 转移的概率

图 1.8:马尔科夫过程的转移矩阵

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

i → j 转移的概率 STAoacurtrgigceoietnveststknaa attceetiijon 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,1) 012012 向上

图 1.10:一个网格世界环境

为了准确捕捉关于环境的所有细节以及对代理动作可能反应的描述,一般的 MDP 具有一个三维过渡矩阵,其维度为源状态、动作和目标状态。

最后,为了将我们的 MRP 转换为 MDP,我们需要以与过渡矩阵相同的方式向我们的奖励矩阵添加动作。我们的奖励矩阵将不仅取决于状态,还取决于动作。换句话说,代理获得的奖励现在不仅取决于它最终处于的状态,还取决于导致该状态的动作。现在,有了一个正式定义的 MDP,我们终于准备好探讨 MDP 和 RL 最重要的事情:策略。

策略

策略的简单定义是一组定义代理行为的规则。即使对于相当简单的环境,我们也可以有多种策略。例如,在前述的网格世界中,代理可以有不同的策略,这将导致不同的访问状态集合。例如,机器人可以执行以下操作:

  • 无视一切盲目向前移动

  • 通过检查之前的前进动作是否失败来试图绕过障碍物

  • 滑稽地绕圈转动,总是向右转以取悦其创造者

  • 选择一个动作是随机的,不考虑位置和方向,模拟一个在网格世界场景中的醉酒机器人。

您可能记得,RL 代理的主要目标是尽可能收集更多回报。因此,不同的策略可以带来不同数量的回报,这使得找到一个好策略变得很重要。这就是策略的重要概念。

形式上,策略被定义为每个可能状态下的动作概率分布:

π (a |s) = P[At = a|St = s]

这被定义为概率,而不是具体的动作,以引入随机性到代理的行为中。在本书的第三部分中,我们将讨论为什么这既重要又有用。确定性策略是概率策略的一种特殊情况,所需的动作其概率为 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-Foun…)——原始 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 ()

图 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 个环境。如果你之前没见过,这是它的游戏截图:

PIC

图 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 的环境。这个环境来自经典控制组,核心思想是控制底部附有杆子的平衡平台(见下图)。

这里的难点在于,这根杆子容易向左或向右倒,你需要通过每一步将平台移动到右侧或左侧来保持平衡。

PIC

图 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 所示。

ObAsRceoetrbwivsaWeoaaerrrnnctrdeEavWtivWwnp:rioaravpaontareEpnWiopdrnp(rnp(veaa(err)por)pbesr) unwrapped: Env

图 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() 方法,窗口会很快消失。

PIC

图 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,建议升级。在本章中,我们将讨论最新版本中的差异。

张量

张量是所有深度学习工具包的基本构建块。这个名字听起来有些神秘,但其背后的基本思想是,张量只是一个多维数组。借用学校数学的类比,一个数字像一个点,是零维的;向量像一个线段,是一维的;矩阵是一个二维对象。三维的数字集合可以通过一个立方体的数字表示,但它们不像矩阵那样有一个独立的名称。我们可以保留“张量”这个术语来表示更高维度的集合。

19473 3nD-t8267218391527931650-ten1181171216134951es2341506nosror 3nvmueaamctabtreoirrx aaa i,ij,,jk,k,...ii,j

图 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 中,我概述了数据和梯度在优化过程中的流动方向:

IOTDLGnuaaorpLLLtrtsauaaapgasdtyyyueieeetterrrn 1 2 3ts

图 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。好的,到目前为止这只是一个简单的数学运算。现在,让我们来看看我们表达式所创建的底层图:

vv+v×Σv2 12sruesm

图 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 是一个功能强大的通用解决方案,拥有庞大的社区,界面也相当漂亮:

PIC

图 3.4:TensorBoard 的网页界面(为了更好的可视化效果,请参考 packt.link/gbp/9781835…

从架构的角度来看,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 来查看类似的内容:

PIC

图 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 上)产生了以下图像:

PIC

图 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

EngineEvents类已经概述过。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 是当前状态。以下图所示:

SaTmrpalienabalcteion EfOPAnubocvnaslticeiirt∼rcooivynnoπamn(tπae (ai(nN|oatNsn|s)))s

图 4.1:基于策略的强化学习的高级方法

实际上,策略通常表示为一个动作的概率分布,这使得它非常类似于分类问题,其中类别的数量等于我们可以执行的动作数量。

这种抽象使得我们的智能体变得非常简单:它只需要将来自环境的观察传递给神经网络,得到一个动作的概率分布,并使用概率分布进行随机抽样,得到一个需要执行的动作。这种随机抽样为我们的智能体增加了随机性,这是件好事,因为在训练开始时,当我们的权重是随机的,智能体的行为也是随机的。一旦智能体获得了一个需要执行的动作,它就将该动作发送给环境,并获得上一个动作的下一个观察和奖励。然后,循环继续,如图 4.1 所示。

在智能体的生命周期中,它的经验呈现为若干个回合(episodes)。每个回合是智能体从环境中获得的一系列观察、它所执行的动作以及这些动作的奖励。假设我们的智能体已经经历了若干个这样的回合。对于每个回合,我们可以计算智能体所获得的总奖励。它可以是折扣奖励,也可以是不折扣奖励;为了简单起见,假设折扣因子 γ = 1,这意味着每个回合的所有局部奖励的未折扣总和。这个总奖励显示了该回合对智能体来说有多好。它在图 4.2 中有所说明,其中包含了四个回合(注意,不同的回合有不同的 o[i]、a[i] 和 r[i] 的值):

第 1 集:o1,a1,r1 o2,a2,r2 o3,a3,r3 o4,a4,rR4= r1 + r2 + r3 + r4 第 2 集:o1,a1,r1 o2,a2,r2 o3,a3,r3 R = r1 + r2 + r3 第 3 集:o1,a1,r1 o2,a2,r2 R = r1 + r2 第 4 集:o1,a1,r1 o2,a2,r2 o3,a3,r3 R = r1 + r2 + r3

图 4.2:示例回合及其观察、动作和奖励

每个单元格代表代理在回合中的一步。由于环境中的随机性以及代理选择采取行动的方式,一些回合会比其他回合更好。交叉熵方法的核心是丢弃不好的回合,并在更好的回合上进行训练。所以,该方法的步骤如下:

  1. 使用当前模型和环境播放 N 个回合。

  2. 计算每个回合的总奖励并确定奖励边界。通常,我们使用所有奖励的百分位数,如第 50 或第 70 百分位数。

  3. 丢弃所有奖励低于边界的回合。

  4. 使用观察作为输入,发出的动作作为期望输出,在剩余的“精英”回合(奖励高于边界)上进行训练。

  5. 从步骤 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 显示我们的智能体持续在进步,几乎每个批次都会推动上限(虽然有时会出现下降,但大多数时候它是在提升):

PIC

图 4.3:训练过程中平均奖励(左)和损失(右)PIC 图 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 电影可能如下所示:

PIC

图 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% 已解决的回合后停止了提升:

PIC

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

PIC

图 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!

这一点在以下图表中也很明显:

PIC

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

PIC

图 4.14:无滑溜版本的奖励边界

交叉熵方法的理论背景

本节为可选内容,供希望了解该方法为何有效的读者。如果你愿意,可以参考 Kroese 原文论文,标题为《交叉熵方法》,[Kro+11]。

交叉熵方法的基础在于重要性采样定理,定理内容如下:

π (a |s) = P[At = a|St = s] π (a |s) = P[At = a|St = s]

在我们的强化学习(RL)案例中,H(x) 是某个策略 x 所获得的奖励值,p(x) 是所有可能策略的分布。我们并不想通过搜索所有可能的策略来最大化我们的奖励;相反,我们想通过 q(x) 来近似 p(x)H(x),并迭代地最小化它们之间的距离。两个概率分布之间的距离通过 Kullback-Leibler (KL) 散度来计算,公式如下:

π (a |s) = P[At = a|St = s] π (a |s) = P[At = a|St = s]

KL 中的第一个项称为熵,它与 p2 无关,因此在最小化过程中可以省略。第二项称为交叉熵,这是深度学习中非常常见的优化目标。

结合这两个公式,我们可以得到一个迭代算法,起始时 q0 = p(x),并在每一步进行改进。这是 p(x)H(x) 的近似,并伴随着更新:

π (a |s) = P[At = a|St = s]

这是一种通用的交叉熵方法,在我们的 RL 案例中可以大大简化。我们将 H(x) 替换为一个指示函数,当回合的奖励超过阈值时其值为 1,当奖励低于阈值时其值为 0。我们的策略更新将如下所示:

π (a |s) = P[At = a|St = s]

严格来说,前面的公式缺少归一化项,但在实践中没有它仍然能起作用。所以,方法非常明确:我们使用当前策略(从一些随机初始策略开始)采样回合,并最小化最成功样本和我们的策略的负对数似然。

如果你感兴趣,可以参考 Reuven Rubinstein 和 Dirk P. Kroese 编写的书 [RK04],专门讨论这种方法。简短的描述可以在《交叉熵方法》论文中找到 ([Kro+11])。

摘要

在本章中,你已经了解了交叉熵方法,尽管它有一些局限性,但它简单且非常强大。我们将其应用于一个 CartPole 环境(取得了巨大的成功)和 FrozenLake(取得了相对较小的成功)。此外,我们还讨论了 RL 方法的分类,接下来的书中会多次引用这一分类,因为不同的 RL 问题方法具有不同的特性,这会影响它们的适用性。

本章结束了本书的导言部分。在下一部分,我们将转向更加系统地学习 RL 方法,并讨论基于值的算法。在接下来的章节中,我们将探索更复杂但更强大的深度强化学习工具。

加入我们在 Discord 上的社区

与其他用户、深度学习专家以及作者本人一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何问题环节与作者交流,还有更多内容。扫描二维码或访问链接加入社区。packt.link/rl

PIC

第二部分

基于价值的方法