强化学习研讨会-全-
强化学习研讨会(全)
原文:
annas-archive.org/md5/e0caa69bfbd246ee6119f0157bdca923译者:飞龙
前言
关于本书
各种智能应用,如视频游戏、库存管理软件、仓库机器人和翻译工具,利用强化学习(RL)做出决策并执行动作,以最大化期望结果的概率。本书将帮助你掌握在机器学习模型中实现强化学习的技术和算法。
从强化学习的介绍开始,你将被引导了解不同的强化学习环境和框架。你将学习如何实现自己的自定义环境,并使用 OpenAI 基线运行强化学习算法。在你探索了经典的强化学习技术,如动态规划、蒙特卡洛方法和时序差分学习后,你将理解在强化学习中何时应用不同的深度学习方法,并进阶到深度 Q 学习。书中甚至会通过在热门视频游戏《Breakout》上使用 DARQN,帮助你理解基于机器的问题解决不同阶段。最后,你将了解在何时使用基于策略的方法来解决强化学习问题。
完成强化学习工作坊后,你将掌握利用强化学习解决具有挑战性的机器学习问题所需的知识和技能。
读者对象
如果你是数据科学家、机器学习爱好者,或者是想学习从基础到高级的深度强化学习算法的 Python 开发者,本工作坊适合你。需要具备基础的 Python 语言理解。
关于各章
第一章,强化学习简介,将带你了解强化学习,这是机器学习和人工智能中最令人兴奋的领域之一。
第二章,马尔可夫决策过程与贝尔曼方程,教你关于马尔可夫链、马尔可夫奖励过程和马尔可夫决策过程的知识。你将学习状态值和动作值,并使用贝尔曼方程计算这些量。
第三章,使用 TensorFlow 2 进行深度学习实践,介绍了 TensorFlow 和 Keras,概述了它们的关键特性、应用及其如何协同工作。
第四章,开始使用 OpenAI 和 TensorFlow 进行强化学习,带你了解两个流行的 OpenAI 工具,Gym 和 Universe。你将学习如何将这些环境的接口形式化,如何与它们交互,以及如何为特定问题创建自定义环境。
第五章,动态规划,教你如何使用动态规划解决强化学习中的问题。你将学习策略评估、策略迭代和价值迭代的概念,并学习如何实现它们。
第六章,蒙特卡洛方法,教你如何实现各种类型的蒙特卡洛方法,包括“首次访问”和“每次访问”技术。你将学习如何使用这些蒙特卡洛方法解决冰湖问题。
第七章,时序差分学习,为你准备实现 TD(0)、SARSA 和 TD(λ) Q 学习算法,适用于随机和确定性环境。
第八章,多臂老丨虎丨机问题,介绍了流行的多臂老丨虎丨机问题,并展示了几种最常用的算法来解决该问题。
第九章,什么是深度 Q 学习?,向你讲解深度 Q 学习,并介绍一些深度 Q 学习的高级变体实现,如双重深度 Q 学习,使用 PyTorch 实现。
第十章,使用深度循环 Q 网络玩 Atari 游戏,介绍了 深度循环 Q 网络 及其变体。你将通过实际操作,训练强化学习代理程序来玩 Atari 游戏。
第十一章,基于策略的方法进行强化学习,教你如何实现不同的强化学习策略方法,如策略梯度、深度确定性策略梯度、信任区域策略优化和近端策略优化。
第十二章,进化策略与强化学习,将进化策略与传统的机器学习方法结合,特别是在神经网络超参数选择方面。你还将识别这些进化方法的局限性。
注意
《强化学习研讨会》的互动版本包含了一个额外章节,近期进展 和 下一步。本章教授了强化学习算法的新方法,重点探讨进一步探索的领域,如单次学习和可迁移的领域先验。你可以在这里找到互动版本:courses.packtpub.com。
约定
文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号如下所示:“请记住,一个算法类的实现需要两个特定的方法与 bandit API 进行交互,decide() 和 update(),后者更简单且已经实现。”
屏幕上显示的单词(例如,菜单或对话框中的内容)也以这种方式出现在文本中:“DISTRIBUTIONS 标签提供了模型参数在各个 epoch 之间如何分布的概述。”
一段代码设置如下:
class Greedy:
def __init__(self, n_arms=2):
self.n_arms = n_arms
self.reward_history = [[] for _ in range(n_arms)]
新术语和重要单词以此方式显示:“它的架构允许用户在各种硬件上运行,从 CPU 到 张量处理单元 (TPUs),包括 GPU 以及移动和嵌入式平台。”
代码呈现
跨越多行的代码使用反斜杠 (\) 进行分割。当代码执行时,Python 会忽略反斜杠,并将下一行的代码视为当前行的直接延续。
例如:
history = model.fit(X, y, epochs=100, batch_size=5, verbose=1, \
validation_split=0.2, shuffle=False)
注释被添加到代码中,以帮助解释特定的逻辑部分。单行注释使用 # 符号表示,如下所示:
# Print the sizes of the dataset
print("Number of Examples in the Dataset = ", X.shape[0])
print("Number of Features for each example = ", X.shape[1])
多行注释被三引号包围,如下所示:
"""
Define a seed for the random number generator to ensure the
result will be reproducible
"""
seed = 1
np.random.seed(seed)
random.set_seed(seed)
设置你的环境
在我们详细探索本书之前,需要先设置特定的软件和工具。在接下来的部分,我们将展示如何操作。
为 Jupyter Notebook 安装 Anaconda
安装 Anaconda 后,你可以使用 Jupyter notebooks。可以按照 docs.anaconda.com/anaconda/install/windows/ 上的步骤在 Windows 系统上安装 Anaconda。
对于其他系统,请访问 docs.anaconda.com/anaconda/install/ 获取相应的安装指南。
安装虚拟环境
通常来说,在安装 Python 模块时使用独立的虚拟环境是个好习惯,以确保不同项目的依赖项不会发生冲突。因此,建议你在执行这些指令之前采用这种方法。
由于我们在这里使用 Anaconda,强烈建议使用基于 conda 的环境管理。请在 Anaconda Prompt 中运行以下命令以创建并激活环境:
conda create --name [insert environment name here]
conda activate [insert environment name here]
安装 Gym
要安装 Gym,请确保你的系统中已安装 Python 3.5+。你可以通过 pip 简单地安装 Gym。按以下代码片段中的步骤在 Anaconda Prompt 中运行代码:
pip install gym
你也可以通过直接克隆 Gym Git 仓库来从源代码构建 Gym 安装。当需要修改 Gym 或添加环境时,这种安装方式非常有用。使用以下代码从源代码安装 Gym:
git clone https://github.com/openai/gym
cd gym
pip install -e .
运行以下代码以完成 Gym 的完整安装。此安装可能需要你安装其他依赖项,包括cmake和最新版本的pip:
pip install -e .[all]
在第十一章,基于策略的强化学习方法中,你将使用 Gym 中提供的 Box2D 环境。你可以通过以下命令安装 Box2D 环境:
pip install gym "gym[box2d]"
安装 TensorFlow 2
要安装 TensorFlow 2,请在 Anaconda Prompt 中运行以下命令:
pip install tensorflow
如果你正在使用 GPU,可以使用以下命令:
pip install tensorflow-gpu
安装 PyTorch
可以按照 pytorch.org/ 上的步骤在 Windows 上安装 PyTorch。
如果你的系统没有 GPU,可以通过在 Anaconda Prompt 中运行以下代码安装 PyTorch 的 CPU 版本:
conda install pytorch-cpu torchvision-cpu -c pytorch
安装 OpenAI Baselines
可以按照 github.com/openai/baselines 上的说明安装 OpenAI Baselines。
下载 OpenAI Baselines 仓库,切换到 TensorFlow 2 分支,然后按照以下步骤安装:
git clone https://github.com/openai/baselines.git
cd baselines
git checkout tf2
pip install -e .
我们在第一章 强化学习简介和第四章 与 OpenAI 及 TensorFlow 一起入门中使用了 OpenAI Baselines 进行强化学习。由于 OpenAI Baselines 使用的 Gym 版本不是最新版本0.14,您可能会遇到如下错误:
AttributeError: 'EnvSpec' object has no attribute '_entry_point'
解决此 bug 的方法是将baselines/run.py中的两个env.entry_point属性改回env._entry_point。
详细的解决方案请参见github.com/openai/baselines/issues/977#issuecomment-518569750。
另外,您也可以使用以下命令来升级该环境中的 Gym 安装:
pip install --upgrade gym
安装 Pillow
在 Anaconda 提示符中使用以下命令安装 Pillow:
conda install -c anaconda pillow
另外,您也可以运行以下命令使用pip:
pip install pillow
您可以在pypi.org/project/Pillow/2.2.1/了解更多关于 Pillow 的信息。
安装 Torch
使用以下命令通过pip安装torch:
pip install torch==0.4.1 -f https://download.pytorch.org/whl/torch_stable.html
请注意,您将只在第十一章 强化学习的基于策略的方法中使用版本0.4.1的torch。对于其他章节,您可以通过安装 PyTorch部分中的命令恢复到 PyTorch 的更新版本。
安装其他库
pip在 Anaconda 中是预装的。安装好 Anaconda 后,所有必需的库可以通过pip安装,例如,pip install numpy。另外,您也可以使用pip install –r requirements.txt安装所有必需的库。您可以在packt.live/311jlIu找到requirements.txt文件。
练习和活动将通过 Jupyter Notebook 执行。Jupyter 是一个 Python 库,可以像其他 Python 库一样安装——也就是使用pip install jupyter,但幸运的是,它已经预装在 Anaconda 中。要打开一个 Notebook,只需在终端或命令提示符中运行命令jupyter notebook。
访问代码文件
您可以在packt.live/2V1MwHi找到本书的完整代码文件。
我们尽力为所有活动和练习提供互动版本的支持,但我们建议您也进行本地安装,以便在无法使用该支持时可以正常进行。
如果您在安装过程中遇到任何问题或有任何疑问,请通过电子邮件联系我们:workshops@packt.com。
第一章:1. 强化学习简介
概述
本章介绍了强化学习(RL)框架,这是机器学习和人工智能领域中最令人兴奋的领域之一。你将学习如何描述 RL 的特征和高级应用,展示在这个框架下能够实现的目标。你还将学会如何区分 RL 与其他学习方法。你将从理论角度和实践角度(使用 Python 及其他有用的库)学习这门学科的主要概念。
到本章结束时,你将理解什么是 RL,并了解如何使用 Gym 工具包和 Baselines 这两个在该领域中流行的库,来与环境进行互动并实现一个简单的学习循环。
介绍
学习和适应新环境是人类乃至所有动物的关键过程。通常,学习被视为一种通过反复试错的过程,通过这个过程我们在特定任务中提升表现。我们的生活是一个持续的学习过程,我们从简单的目标(例如,走路)开始,最终追求困难且复杂的任务(例如,参加体育运动)。作为人类,我们始终受到奖励机制的驱动,奖励好行为并惩罚不良行为。
强化学习(RL),受人类学习过程的启发,是机器学习的一个子领域,涉及通过互动进行学习。这里的“互动”指的是我们作为人类,通过试错过程来理解我们行为的后果,并积累我们的经验。
RL,特别是,关注的是序列决策问题。这些问题中,代理需要做出一系列决策,也就是一系列行动,以最大化某个性能指标。
RL 将任务视为马尔可夫决策过程(MDPs),这些问题在许多现实世界场景中都会出现。在这种环境下,决策者(即代理)必须做出决策,考虑环境的不确定性和经验。代理是目标导向的;它们只需要一个目标的概念,比如一个需要最大化的数值信号。与监督学习不同,在 RL 中,不需要提供好的示例;是代理自己学习如何将情境映射到行动。情境(状态)到行动的映射在文献中被称为“策略”,它代表了代理的行为或策略。解决一个 MDP 意味着通过最大化期望的结果(即总奖励)来找到代理的策略。我们将在未来的章节中更详细地研究 MDP。
强化学习(RL)已成功应用于各种问题和领域,取得了令人兴奋的结果。本章是强化学习的入门,旨在从直观的角度和数学的角度解释一些应用和概念。这两个方面在学习新学科时都非常重要。没有直观的理解,就无法理解公式和算法;没有数学背景,实施现有或新算法就会变得困难。
在本章中,我们将首先比较三种主要的机器学习范式,即监督学习、强化学习(RL)和无监督学习。我们将讨论它们的异同,并定义一些示例问题。
第二,我们将进入一个包含强化学习理论及其符号表示的部分。我们将学习像代理、环境以及如何参数化不同策略等概念。本节内容是这一学科的基础。
第三,我们将开始使用两个强化学习框架,即 Gym 和 Baselines。我们将学习与 Gym 环境的交互非常简单,使用 Baselines 算法学习任务也是如此。
最后,我们将探索一些强化学习应用,激励你学习这一学科,展示可用于应对现实世界问题的各种技术。强化学习不仅局限于学术界,但从工业角度来看,仍然至关重要,它使得你能够解决那些几乎无法通过其他技术解决的问题。
学习范式
在本节中,我们将讨论在机器学习范畴下,三种主要学习范式的相似性与差异性。我们将分析一些代表性问题,以更好地理解这些框架的特征。
学习范式简介
对于一个学习范式,我们实现一个问题和解决方法。通常,学习范式处理数据,并以一种可以通过寻找参数并最大化目标函数的方式重新表述问题。在这些设置中,问题可以通过数学和优化工具来解决,从而允许进行正式的研究。术语“学习”通常用于表示一种动态过程,在这个过程中,算法的参数以优化它们在给定任务上的表现(即“学习”)的方式进行调整。Tom Mitchell 以以下精确的方式定义了学习:
“如果一个计算机程序在任务类别 T 和性能度量 P 下,随着经验 E 的积累,它在任务 T 中的表现通过 P 得到改善,那么我们说这个程序从经验 E 中学习。”
让我们更直观地重新表述上述定义。要定义一个程序是否在学习,我们需要设置一个任务;这就是程序的目标。任务可以是我们希望程序完成的任何事情,比如下国际象棋、进行自动驾驶或执行图像分类。问题应当伴随一个表现度量,即一个函数,返回程序在该任务上的表现如何。对于国际象棋游戏,表现函数可以简单地通过以下方式表示:

图 1.1:国际象棋游戏的表现函数
在此背景下,经验是程序在特定时刻收集的数据量。对于国际象棋,经验可以通过程序进行的游戏集合来表示。
在学习阶段的开始或结束时呈现的相同输入,可能会导致算法产生不同的响应(即输出);这些差异是由于算法的参数在过程中得到了更新。
在下表中,我们可以看到一些关于经验、任务和表现元组的例子,以便更好地理解它们的具体实例化:

图 1.2:实例化的表格
我们可以根据学习算法的输入和它们收到的反馈来分类这些算法。在接下来的部分,我们将基于这种分类方法,探讨机器学习中的三种主要学习范式。
监督学习与无监督学习与强化学习
三种主要的学习范式是监督学习、无监督学习和强化学习(RL)。下图表示了每种学习范式的通用结构:

图 1.3:学习范式的表示
从前面的图表中,我们可以得出以下信息:
-
监督学习通过最小化模型输出相对于训练集中特定目标的误差来进行优化。
-
强化学习(RL)通过最大化行为的奖励信号来进行优化。
-
无监督学习没有目标也没有奖励,它尝试学习一个可能有用的数据表示。
让我们更深入地探讨并进一步阐述这些概念,特别是从数学角度来看。
有监督学习处理的是通过映射输入到输出来学习一个函数,当输入与输出(样本,标签)之间的对应关系由外部教师(监督者)给出,并包含在训练集中时。有监督学习的目标是能够推广到数据集中未包含的未见样本,从而使系统(例如,一个函数)能够在新情况下做出正确的响应。在这里,样本与标签之间的对应关系通常是已知的(例如,在训练集中),并且已给定给系统。监督学习任务的例子包括回归和分类问题。在回归任务中,学习者必须找到一个函数,
,这个函数接收输入,
,并生成一个(或一般是
)实数输出,
。用数学符号表示,我们需要找到
,使得:

图 1.4:回归
这里,
表示训练集中的示例。在分类任务中,待学习的函数是一个离散的映射;
属于一个有限的离散集合。通过将问题形式化,我们搜索一个离散值的函数,
,使得:

图 1.5:分类
这里,集合,
表示可能的类别或分类集合。
无监督学习处理的是在目标标签不存在或未知的情况下,学习数据中的模式。无监督学习的目标是找到数据的新表示,通常是更小的表示。无监督学习算法的例子包括聚类和主成分分析(PCA)。
在聚类任务中,学习者应该根据某些相似性度量,将数据集划分为聚类(元素组)。乍一看,聚类可能与分类非常相似;然而,作为一种无监督学习任务,标签或类别并没有在训练集中给算法。实际上,应该是算法本身通过学习输入空间的表示,从而让相似的样本彼此靠近,来理解其输入。
例如,在下图中,我们在左侧看到的是原始数据;右侧是聚类算法的可能输出。不同的颜色代表不同的聚类:

图 1.6:聚类应用示例
在上述例子中,输入空间由两个维度组成,也就是
,并且算法找到了三个聚类,或者说是三组相似的元素。
主成分分析(PCA)是一种用于降维和特征提取的无监督算法。PCA 试图通过寻找一种表示方式来理解数据,该表示方式包含了给定数据中的大部分信息。
强化学习(RL)不同于监督学习和无监督学习。RL 处理的是一个序列决策问题中的控制动作学习。问题的序列结构使得 RL 具有挑战性,并且与其他两种范式不同。此外,在监督学习和无监督学习中,数据集是固定的。而在 RL 中,数据集是不断变化的,数据集的创建本身就是智能体的任务。在 RL 中,不同于监督学习,没有教师为给定样本提供正确的值或为给定情境提供正确的行动。RL 基于一种不同形式的反馈,即环境对智能体行为的反馈。正是这种反馈的存在,使得 RL 不同于无监督学习。
我们将在未来的章节中更详细地探讨这些概念:

图 1.7:机器学习范式及其关系
强化学习和监督学习也可能会混淆。一种常见的技术(也被 AlphaGo Zero 使用)被称为模仿学习(或行为克隆)。我们不是从头开始学习任务,而是以监督的方式教会智能体如何在给定情境中表现(或采取哪种行动)。在这种情况下,我们有一个专家(或多个专家),他们向智能体展示期望的行为。通过这种方式,智能体可以开始构建其内部表示和初始知识。当 RL 部分开始时,它的行动将不再是随机的,行为将更加专注于专家展示的行动。
现在,让我们看几个情境,帮助我们更好地分类这些问题。
将常见问题分类为学习情境
在本节中,我们将了解如何通过定义所需的元素,将一些常见的现实世界问题框架化为学习框架。
预测图像中是否包含狗或猫
预测图像内容是一个标准的分类示例,因此,它属于监督学习的范畴。在这里,我们给定一张图片,算法应该判断该图像中是狗还是猫。输入是图片,相关标签可以是 0 表示猫,1 表示狗。
对于人类来说,这是一个直接的任务,因为我们有关于猫狗的内部表征(以及对世界的内部表征),并且我们在一生中经过大量训练,能够识别猫狗。尽管如此,编写一个能够识别图像中是否包含猫狗的算法,在没有机器学习技术的情况下是一个困难的任务。对人类来说,知道图像是猫还是狗是很简单的;同时,创建一个简单的猫狗图像数据集也很容易。
为什么不是无监督学习?
无监督学习不适合这种类型的任务,因为我们有一个从输入中需要获得的定义输出。当然,监督学习方法会构建输入数据的内部表示,在这种表示中,相似性得到了更好的利用。这个表示是隐式的;它不是算法的输出,正如在无监督学习中那样。
为什么不是强化学习?
强化学习按定义考虑的是顺序决策问题。预测图像内容不是一个顺序问题,而是一个一次性任务。
检测与分类图像中的所有猫狗
检测与分类是监督学习问题的两个例子。然而,这个任务比前一个更复杂。检测部分可以同时看作是回归问题和分类问题。输入始终是我们想要分析的图像,输出是每个狗或猫的边界框坐标。与每个边界框相关联的是一个标签,用于将兴趣区域内的内容分类为狗或猫:

图 1.8:猫狗检测与分类
为什么不是无监督学习?
如前所述,在这里,我们有一个给定输入(图像)后的确定输出。我们不想从数据中提取未知的模式。
为什么不是强化学习?
检测与分类不是适合强化学习框架的任务。我们没有一套需要采取的行动来解决问题。此外,在这种情况下,缺乏顺序结构。
下棋
下棋可以视为一个强化学习(RL)问题。程序可以感知棋盘的当前状态(例如,棋子的类型和位置),并基于此决定采取什么行动。在这里,可能的行动数量非常庞大。选择一个行动意味着要理解并预见这一动作的后果,以击败对手:

图 1.9:下棋作为强化学习问题
为什么不是监督学习?
我们可以将下棋视为一个监督学习问题,但我们需要一个数据集,并且应该将游戏的顺序结构融入到监督学习问题中。在强化学习中,不需要数据集;是算法本身通过交互并可能通过自我对弈来构建数据集。
为什么不是无监督学习?
无监督学习不适用于这个问题,因为我们并不是在学习数据的表示;我们有一个明确的目标,那就是赢得游戏。
在本节中,我们比较了三种主要的学习范式。我们看到了它们所拥有的数据类型、每个算法与外部世界的交互方式,并分析了一些特定问题,以理解哪种学习范式最为适合。
在面对现实世界问题时,我们总是要记住这些技术之间的区别,并根据我们的目标、数据以及问题结构选择最合适的技术。
强化学习基础
在强化学习中,主要目标是通过交互来学习。我们希望智能体在给定的情境下学习一种行为,即选择行动的方式,以实现某个目标。与经典编程或规划的主要区别在于,我们不想显式地自己编码规划软件,因为这需要巨大的努力;它可能非常低效,甚至不可能完成。强化学习正是为了这个原因而诞生的。
强化学习(RL)智能体通常一开始并不知道该做什么。它们通常不知道目标是什么,不知道游戏规则,也不了解环境的动态或自己的行为如何影响状态。
强化学习有三个主要组成部分:感知、行动和目标。
智能体应该能够感知当前的环境状态以完成任务。这种感知,也叫做观察,可能与实际的环境状态不同,可能受到噪音的干扰,或者可能是部分的。
例如,想象一个机器人在一个未知环境中移动。对于机器人应用,通常机器人通过摄像头感知环境。这种感知并不能完全代表环境状态,可能会受到遮挡、光线不足或不利条件的影响。系统应该能够处理这种不完整的表示,并学会在环境中移动的方式。
智能体的另一个主要组成部分是执行能力;智能体应该能够采取影响环境状态或自身状态的行动。
智能体还应该有一个通过环境状态定义的目标。目标通过高层次的概念来描述,例如赢得比赛、在环境中移动或正确驾驶。
强化学习(RL)的一个挑战是探索与利用的权衡,这个问题在其他类型的学习中并不存在。为了提高,智能体必须利用已有的知识;它应该偏好那些在过去证明有用的动作。这里有一个问题:为了发现更好的动作,智能体应该继续探索,尝试那些它以前从未做过的动作。为了可靠地估计一个动作的效果,智能体必须多次执行每个动作。需要注意的是,单独进行探索或利用都不能有效地学习任务。
上述情况非常类似于我们在婴儿时期学习走路时面临的挑战。最初,我们尝试不同类型的运动,并从一种简单且能带来令人满意结果的运动开始:爬行。然后,我们希望通过改进行为变得更加高效。为了学习新行为,我们不得不做一些以前从未做过的动作:我们尝试走路。刚开始,我们执行不同的动作,结果不尽如人意:我们摔倒了很多次。一旦我们发现了正确的腿部运动方式并学会了保持平衡,我们的走路效率就提高了。如果我们没有进一步探索,只停留在第一个带来满意结果的行为上,我们就会永远爬行。通过探索,我们学会了有些行为更高效。一旦我们学会了走路,就可以停止探索,开始利用已有的知识。
强化学习的元素
让我们直观地介绍强化学习框架的主要元素。
智能体
在强化学习中,智能体是指在世界中移动、执行动作并达成目标的实体的抽象概念。智能体可以是自动驾驶软件、象棋玩家、围棋玩家、算法交易员或机器人。智能体是能够感知并影响环境状态的所有事物,因此可以用来完成目标。
动作
智能体可以根据当前的情况执行动作。动作的形式可以根据具体任务的不同而有所不同。
在自动驾驶的背景下,动作可以是转向、踩下油门踏板或踩下刹车踏板。其他动作的例子包括在象棋中将马移到 H5 位置,或将国王移到 A5 位置。
动作可以是低级的,例如控制车辆电机的电压,但也可以是高级的,或者是规划动作,例如决定去哪儿。动作层级的决定是算法设计师的责任。过于高级的动作可能难以在低层级实现;它们可能需要在较低层级进行大量规划。同时,低级动作使得问题学习变得更加困难。
环境
环境代表了智能体移动并做出决策的上下文。一个环境由三个主要元素组成:状态、动态和奖励。它们可以如下解释:
-
状态:这表示描述在特定时间步环境的所有信息。状态通过观察可供智能体获取,观察可以是部分或完整的表示。
-
动态:环境的动态描述了行动如何影响环境的状态。环境的动态通常是非常复杂或未知的。使用环境动态信息来学习如何实现目标的 RL 算法属于基于模型的 RL 类别,其中模型表示环境的数学描述。大多数时候,环境动态对智能体是不可用的。在这种情况下,算法属于无模型类别。即使环境模型不可用、过于复杂或过于近似,智能体仍然可以在训练过程中学习到环境的模型。即使如此,这种算法也被认为是基于模型的。
-
奖励:奖励是与每个时间步相关的标量值,用于描述智能体的目标。奖励也可以描述为环境反馈,向智能体提供关于其行为的信息;因此,它对于使学习成为可能是必要的。如果智能体获得高奖励,意味着它做出了一个好的动作,一个将其带得更接近目标的动作。
策略
策略描述了智能体的行为。智能体通过遵循策略来选择行动。从数学上讲,策略是一个将状态映射到动作的函数。这是什么意思呢?就是说,策略的输入是当前的状态,而它的输出是需要采取的动作。策略可以有不同的形式。它可以是简单的一组规则、查找表、神经网络或任何函数近似器。策略是强化学习(RL)框架的核心,所有 RL 算法(无论是隐式的还是显式的)的目标都是改进智能体的策略,以最大化智能体在任务(或一组任务)上的表现。策略可以是随机的,涉及到动作的分布,也可以是确定性的。在后者的情况下,所选动作是由环境的状态唯一决定的。
一个自动驾驶环境的例子
为了更好地理解环境在 RL 框架中的作用及其特性,让我们形式化一个自动驾驶环境,如下图所示:

图 1.10:一个自动驾驶场景
考虑前面的图,我们现在来看看环境的每个组成部分:
-
状态:状态可以通过我们汽车周围 360 度的街景图像来表示。在这种情况下,状态是一个图像,即一个像素矩阵。它也可以通过一系列覆盖汽车周围整个空间的图像来表示。另一种可能性是使用特征而非图像来描述状态。状态可以是我们车辆的当前速度和加速度、与其他车辆的距离,或者与街道边缘的距离。在这种情况下,我们使用预处理过的信息来更轻松地表示状态。这些特征可以从图像或其他类型的传感器中提取(例如,激光雷达(LIDAR))。
-
动态:在自动驾驶汽车场景中,环境的动态通过描述汽车加速、刹车或转向时系统如何变化的方程来表示。例如,车辆当前以 30 km/h 的速度行驶,前方的车辆距离其 100 米。状态由汽车的速度和与前方车辆的距离信息表示。如果汽车加速,速度会根据汽车的属性(包含在环境动态中)发生变化。此外,由于速度的变化,距离信息也会发生变化,前方车辆可能会更近或更远。在这种情况下,下一个时间步,汽车的速度可能为 35 km/h,前方车辆可能更近,例如仅 90 米远。
-
奖励:奖励可以代表智能体驾驶的表现。形式化奖励函数并不容易。一个自然的奖励函数应该在汽车与街道对齐时给予奖励,并且应避免在汽车发生碰撞或驶出道路时给奖励。奖励函数的定义仍然是一个未解的问题,研究人员正在努力开发不需要奖励函数的算法(自我激励或好奇心驱动的智能体),让智能体通过示范学习(模仿学习)来学习,或者让智能体从示范中恢复奖励函数(逆向强化学习或 IRL)。
注意
有关好奇心驱动的智能体的更多阅读,请参考以下论文:
pathak22.github.io/large-scale-curiosity/resources/largeScaleCuriosity2018.pdf。
我们现在准备使用 Python 设计和实现我们的第一个环境类。在接下来的练习中,我们将展示如何实现一个玩具问题的状态、动态和奖励。
练习 1.01:使用 Python 实现一个玩具环境
在这个练习中,我们将使用 Python 实现一个简单的玩具环境。环境如 图 1.11 所示。它由三个状态(1、2、3)和两个动作(A 和 B)组成。初始状态为状态 1。状态由节点表示。边表示状态之间的转换。在边上,我们有导致转换的动作和相关的奖励。
图 1.11 中环境的表示是强化学习中标准的环境表示。在这个练习中,我们将了解环境的概念及其实现:

图 1.11: 由三个状态(1、2、3)和两个动作(A 和 B)组成的玩具环境
在上图中,奖励与每个状态-动作对相关联。
此练习的目标是实现一个 Environment 类,其中包含一个 step() 方法,该方法接受代理的操作作为输入,并返回一个状态-动作对(下一个状态,奖励)。除此之外,我们将编写一个 reset() 方法来重置环境的状态:
-
创建一个新的 Jupyter 笔记本或简单的 Python 脚本来输入代码。
-
从
typing导入Tuple类型:from typing import Tuple -
通过初始化其属性来定义类构造函数:
class Environment: def __init__(self): """ Constructor of the Environment class. """ self._initial_state = 1 self._allowed_actions = [0, 1] # 0: A, 1: B self._states = [1, 2, 3] self._current_state = self._initial_state current_state variable to be equal to the initial state (state 1). -
定义步骤函数,负责根据代理以前的状态和采取的动作更新当前状态:
def step(self, action: int) -> Tuple[int, int]: """ Step function: compute the one-step dynamic from the \ given action. Args: action (int): the action taken by the agent. Returns: The tuple current_state, reward. """ # check if the action is allowed if action not in self._allowed_actions: raise ValueError("Action is not allowed") reward = 0 if action == 0 and self._current_state == 1: self._current_state = 2 reward = 1 elif action == 1 and self._current_state == 1: self._current_state = 3 reward = 10 elif action == 0 and self._current_state == 2: self._current_state = 1 reward = 0 elif action == 1 and self._current_state == 2: self._current_state = 3 reward = 1 elif action == 0 and self._current_state == 3: self._current_state = 2 reward = 0 elif action == 1 and self._current_state == 3: self._current_state = 3 reward = 10 return self._current_state, reward注意
以上代码片段中的
#符号表示代码注释。注释用于帮助解释特定逻辑的部分。我们首先检查动作是否允许。然后,我们根据前一个状态和动作查看上图中的转换来定义新的当前状态和奖励。
-
现在,我们需要定义
reset函数,简单地重置环境状态:def reset(self) -> int: """ Reset the environment starting from the initial state. Returns: The environment state after reset (initial state). """ self._current_state = self._initial_state return self._current_state -
我们可以使用我们的环境类来理解我们的实现是否适用于指定的环境。我们可以通过一个简单的循环来做到这一点,使用预定义的一组操作来测试环境的转换。在这种情况下,可能的操作集是
[0, 0, 1, 1, 0, 1]。使用这个集合,我们将测试环境的所有转换:env = Environment() state = env.reset() actions = [0, 0, 1, 1, 0, 1] print(f"Initial state is {state}") for action in actions: next_state, reward = env.step(action) print(f"From state {state} to state {next_state} \ with action {action}, reward: {reward}") state = next_state Initial state is 1 From state 1 to state 2 with action 0, reward: 1 From state 2 to state 1 with action 0, reward: 0 From state 1 to state 3 with action 1, reward: 10 From state 3 to state 3 with action 1, reward: 10 From state 3 to state 2 with action 0, reward: 0 From state 2 to state 3 with action 1, reward: 1
要更好地理解这一点,请将输出与 图 1.11 进行比较,以发现所选操作的转换和奖励是否兼容。
注意
要访问此特定部分的源代码,请参阅 packt.live/2Arr9rO。
您还可以在 packt.live/2zpMul0 上线运行此示例。
在这个练习中,我们通过定义步骤函数和重置函数来实现了一个简单的强化学习环境。这些函数是每个环境的核心,代表了代理与环境之间的交互。
代理-环境接口
强化学习考虑的是顺序决策问题。在这种背景下,我们可以将智能体称为“决策者”。在顺序决策问题中,决策者所采取的动作不仅会影响即时奖励和即时环境状态,还会影响未来的奖励和状态。马尔可夫决策过程(MDP)是形式化顺序决策问题的自然方式。在 MDP 中,智能体通过动作与环境交互,并根据动作、当前环境状态以及环境的动态来获得奖励。决策者的目标是最大化在给定时间跨度(可能是无限的)下的累积奖励。智能体必须学习的任务通过它收到的奖励来定义,正如你在下图中所看到的:

图 1.12:智能体-环境接口
在强化学习(RL)中,一个回合被划分为一系列离散的时间步:
。这里,
表示可能是无限的时间跨度。智能体与环境的交互发生在每个时间步。在每个时间步,智能体接收到当前环境状态的表示,
。基于这个状态,它选择一个动作,
,这个动作属于给定当前状态下的动作空间,
。该动作会影响环境,导致环境根据其动态变化状态,过渡到下一个状态,
。同时,智能体会收到一个标量奖励,
,用来量化在该状态下所采取的动作的效果。
现在我们来尝试理解前面例子中使用的数学符号:
-
时间跨度
:如果任务具有有限的时间跨度,则
是一个整数,表示回合的最大持续时间。在无限任务中,
也可以是
。 -
动作
是智能体在时间步 t 采取的动作。该动作属于由当前状态
定义的动作空间,
。 -
状态
是智能体在时间 t 接收到的环境状态的表示。它属于由环境定义的状态空间,
。状态可以用图像、图像序列或假设有不同形状的简单向量表示。请注意,实际的环境状态可能与智能体感知到的状态不同,且更为复杂。 -
奖励
由一个实数表示,描述所采取的行动有多好。高奖励对应于一个好的行动。奖励对于代理理解如何实现目标至关重要。
在情节化的强化学习(episodic RL)中,代理与环境的交互被划分为多个情节;代理必须在情节内实现目标。交互结束后,代理可以通过结合过去交互的知识来更新其行为。经过若干情节后,代理会根据行动对环境的影响以及获得的奖励,执行更多频繁的行动以获得更高的奖励。
什么是代理?什么是环境?
在处理强化学习(RL)时,必须考虑一个重要的方面,那就是代理和环境之间的区别。这个区别通常不是通过物理上的区分来定义的。通常,我们将环境建模为不受代理控制的所有事物。环境可以包括物理法则、其他代理,或者一个代理的属性或特征。
然而,这并不意味着代理不了解环境。代理也可以意识到环境以及其行动对环境的影响,但它无法改变环境的反应方式。此外,奖励计算属于环境,因为它必须完全在代理控制之外。如果不是这样,代理就可以学习如何修改奖励函数,以便在不学习任务的情况下最大化其表现。代理与环境之间的边界是一个控制边界,意味着代理无法控制环境的反应。这不是一个知识边界,因为代理可以完全了解环境模型,但仍然会在学习任务时遇到困难。
环境类型
本节中,我们将探讨一些可能的环境二分法。环境的特征取决于状态空间(有限或连续)、转移类型(确定性或随机性)、代理可获得的信息(完全可观察或部分可观察)以及参与学习问题的代理数量(单一代理与多代理)。
有限与连续
状态空间给出了第一个区分。状态空间可以分为两大类:有限状态空间和连续状态空间。有限状态空间包含有限数量的可能状态,代理可以处于其中,这是较为简单的情况。而具有连续状态空间的环境则有无限多个可能的状态。在这些类型的环境中,代理的泛化能力对于解决任务至关重要,因为达到相同状态的概率几乎为零。在连续环境中,代理无法利用先前在该状态中的经验;它必须基于某种与先前经验状态的相似性进行泛化。请注意,对于具有大量状态的有限状态空间(例如,当状态空间由所有可能图像的集合表示时),泛化同样至关重要。
考虑以下示例:
-
国际象棋是有限的。代理可以处于的可能状态数量是有限的。对于国际象棋而言,状态由给定时刻的棋盘局面表示。我们可以通过变化棋盘局面来计算所有可能的状态。状态的数量非常高,但仍然是有限的。
-
自动驾驶可以定义为一个连续问题。如果我们将自动驾驶问题描述为代理根据传感器输入做出驾驶决策的问题,那么我们得到的是一个连续问题。传感器提供给定范围内的连续输入。在这种情况下,代理的状态可以由代理的速度、加速度或每分钟车轮转速来表示。
确定性与随机性
确定性环境是指在给定一个状态的情况下,代理执行一个动作后,接下来的状态和奖励是唯一确定的。确定性环境是简单类型的环境,但由于其在现实世界中的适用性有限,因此很少使用。
几乎所有现实世界中的环境都是随机的。在随机环境中,一个状态和代理执行的动作决定了下一状态和下一奖励的概率分布。下一状态不是唯一确定的,而是不确定的。在这些类型的环境中,代理应该多次行动,以便获得可靠的后果估计。
请注意,在确定性环境中,智能体可以在每个状态下执行每个动作一次,基于获得的知识,它可以解决任务。同时,请注意,解决任务并不意味着采取能带来最高即时回报的动作,因为这种动作也可能将智能体带到环境中不方便的部分,在那里未来的回报始终很低。为了正确地解决任务,智能体应该采取与最高未来回报相关的动作(称为状态-动作值)。状态-动作值不仅考虑即时回报,还考虑未来的回报,给予智能体远见的视角。我们稍后将定义什么是状态-动作值。
考虑以下示例:
-
魔方是确定性的。对于一个给定的动作,它对应一个定义的状态转换。
-
国际象棋是确定性的,但依赖于对手。后续的状态不仅依赖于智能体的动作,还依赖于对手的动作。
-
德州扑克牌是随机的且依赖于对手。到下一个状态的转换是随机的,并且依赖于扑克牌堆,而扑克牌堆是智能体无法知晓的。
完全可观察与部分可观察
为了规划动作,智能体必须接收到环境状态的表示,
(参见图 1.12,智能体-环境接口)。如果智能体接收到的状态表示完全定义了环境的状态,那么环境是完全可观察的。如果环境的某些部分超出了智能体观察到的范围,那么环境是部分可观察的,也叫做部分可观察的马尔可夫决策过程(POMDP)。部分可观察环境的例子有多智能体环境。在部分可观察环境的情况下,智能体所感知的信息和所采取的动作不足以确定环境的下一个状态。提高智能体感知准确性的一个方法是保持已执行动作和观察的历史,但这需要一些记忆技术(例如递归神经网络(RNN)或长短期记忆网络(LSTM))嵌入在智能体的策略中。
注意
有关 LSTM 的更多信息,请参考www.bioinf.jku.at/publications/older/2604.pdf。
POMDP 与 MDP
考虑以下图示:

图 1.13:部分可观察环境的示意图
在前面的图示中,智能体没有接收到完整的环境状态,而是只接收到一个观察,
。
为了更好地理解这两种类型环境的区别,让我们看看图 1.13。在部分可观察环境(POMDP)中,给代理的表示只是实际环境状态的一部分,而且仅凭此表示无法在没有不确定性的情况下理解实际环境状态。
在完全可观察环境(MDP)中,给定代理的状态表示与环境的状态在语义上是等价的。请注意,在这种情况下,给代理的状态可以采用不同的形式(例如,图像、向量、矩阵或张量)。然而,从这个表示中,总是可以重建环境的实际状态。状态的意义是完全相同的,即使是在不同的形式下。
请考虑以下示例:
-
国际象棋(一般来说,棋盘游戏)是完全可观察的。代理可以感知整个环境状态。在国际象棋游戏中,环境状态由棋盘表示,代理可以精确感知每个棋子的的位置。
-
扑克是部分可观察的。扑克代理无法感知游戏的全部状态,包括对手的牌和牌堆中的牌。
单代理与多代理
环境的另一个有用特征是参与任务的代理数量。如果只有一个代理,我们的研究对象,环境就是单代理环境。如果代理数量超过一个,环境就是多代理环境。多个代理的存在增加了问题的复杂性,因为影响状态的动作变成了联合动作,即所有代理的动作集合。通常,代理只知道自己个体的动作,而不知道其他代理的动作。因此,多代理环境是 POMDP 的一个实例,其中部分可见性是由于其他代理的存在。请注意,每个代理都有自己的观察,这可能与其他代理的观察不同,如下图所示:

图 1.14:多代理去中心化 MDP 的示意图
请考虑以下示例:
-
机器人导航通常是单代理任务。我们可能只有一个代理在可能未知的环境中移动。代理的目标可以是在尽量避免碰撞的情况下,以最短的时间到达给定的位置。
-
扑克是一个多代理任务,其中有两个代理相互竞争。在这种情况下,感知到的状态不同,感知到的奖励也不同。
一个动作及其类型
智能体在环境中的动作集可以是有限的,也可以是连续的。如果动作集是有限的,那么智能体可以使用的动作数量是有限的。以 MountainCar-v0(离散)示例为例,稍后将详细描述。它具有离散的动作集;智能体只需要选择加速的方向,而加速度是恒定的。
如果动作集是连续的,智能体可以从无限多的动作中选择最合适的动作。通常,具有连续动作集的任务比那些动作有限的任务更具挑战性。
让我们来看一下 MountainCar-v0 的例子:

图 1.15:MountainCar-v0 任务
如前图所示,一辆车位于两座山之间的山谷中。车的目标是到达右边山上的旗帜。
MountainCar-v0 例子是一个标准的强化学习基准任务,其中有一辆车试图将自己推上山坡。车的引擎没有足够的力量往上坡推,因此它应该利用山谷的形状提供的惯性,也就是它应该先向左移动以获得速度。状态由车的速度、加速度和位置 x组成。根据我们定义的动作集,这个任务有两个版本,如下所示:
-
MountainCar-v0 离散:我们只有两个可能的动作,(-1, +1)或(0, 1),取决于参数化。
-
MountainCar-v0 连续:一个从-1 到+1 的连续动作集。
策略
我们将策略定义为智能体的行为。严格来说,策略是一个函数,它以当前回合的历史为输入,并输出当前的动作。策略在强化学习中具有重要意义;所有强化学习算法都专注于为给定任务学习最佳策略。
一个成功的 MountainCar-v0 任务策略的例子是,首先将智能体带到左边的山上,然后利用积累的势能爬升右边的山。对于负的速度,最佳的动作是向左(LEFT),因为智能体应该尽可能地往左山上爬。对于正的速度,智能体应该采取向右(RIGHT)的动作,因为它的目标是往右边的山上爬。
马尔科夫策略只是一个仅依赖于当前状态而非整个历史的策略。
我们用
表示一个静态的马尔科夫策略,如下所示:

图 1.16:静态马尔科夫策略
马尔科夫策略从状态空间映射到动作空间。如果我们在给定状态下评估该策略,
,我们得到在该状态下选择的动作
:

图 1.17:在状态
下的静态马尔科夫策略
策略可以通过不同的方式实现。最简单的策略就是基于规则的策略,实际上就是一套规则或启发式方法。
在强化学习(RL)中,通常关注的策略是参数化的。参数化策略是依赖于一组参数的(可微分)函数。通常,策略参数被表示为!一个包含物体的图片,时钟
自动生成的描述:

图 1.18:参数化策略
策略参数集可以通过一个 d 维空间中的向量来表示。所选的动作由策略结构(稍后我们将探讨一些可能的策略结构)、策略参数,当然,还有当前环境状态决定。
随机策略
到目前为止展示的策略仅仅是确定性策略,因为输出是一个确定的动作。随机策略则是输出一个关于动作空间的分布。随机策略通常是强大的策略,能够结合探索与利用。通过随机策略,能够获得复杂的行为。
随机策略为每个动作分配一个特定的概率。动作将根据其关联的概率被选择。
图 1.19 通过图示和示例,解释了随机策略和确定性策略之间的差异。图中的策略有三种可能的动作。
随机策略(上部分)分别为动作分配了 0.2、0.7 和 0.1 的概率。最可能的动作是第二个动作,它与最高的概率相关联。然而,所有的动作也都有可能被选择。
在底部部分,我们有相同的一组动作与确定性策略。在这种情况下,策略仅选择一个动作(图中的第二个动作),其概率为 1\。此时,动作 1 和动作 3 将不会被选择,它们的关联概率为 0。
请注意,我们可以通过选择与最高概率关联的动作,从随机策略中得到确定性策略:

图 1.19:随机策略与确定性策略
策略参数化
在这一部分,我们将分析一些可能的策略参数化。参数化策略意味着为策略函数赋予一个结构,并考虑参数如何影响我们的输出动作。根据参数化,能够从相同的输入状态出发,获得简单的策略,甚至是复杂的随机策略。
线性(确定性)
结果动作是状态特征的线性组合,!一个包含画图、动物的图片
自动生成的描述:

图 1.20:线性策略的表达式
线性策略是一种非常简单的策略,通过矩阵乘法表示。
考虑 MountainCar-v0 的例子。状态空间由位置、速度和加速度表示:
。我们通常会添加一个常数 1,它对应偏置项。因此,
。策略参数由
。我们可以简单地使用恒等函数作为状态特征,
。
得到的策略如下:

图 1.21:MountainCar-v0 的线性策略
注释
使用逗号,,我们可以表示列分隔符,使用分号;,我们可以表示行分隔符。
因此,
是行向量,
是列向量,相当于
。
如果环境状态是[1, 2, 0.1],则小车处于位置
,速度为
,加速度为
,并且策略参数由[4, 5, 1, 1]定义,我们得到一个动作,
。
由于 MountainCar-v0 的动作空间定义在区间[-1, +1]内,我们需要使用像
(双曲正切)这样的压缩函数来压缩得到的动作。在我们的案例中,应用
到乘法的输出结果大约是+1:
![图 1.22:双曲正切图;双曲正切将
区间[-1, +1]中的实数
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_01_22.jpg)
图 1.22:双曲正切图;双曲正切将区间[-1, +1]中的实数压缩
即使线性策略很简单,但通常足以解决大多数任务,因为状态特征已经代表了问题。
高斯策略
在高斯参数化的情况下,得到的动作遵循高斯分布,其中均值
和方差
,依赖于状态特征:

图 1.23:高斯策略的表达式
在这里,使用符号
,我们表示条件分布;因此,使用
,我们表示在状态
下的条件分布。
记住,高斯分布的函数形式,
,如下所示:

图 1.24:高斯分布
在高斯策略的情况下,这变为以下形式:

图 1.25:高斯策略
高斯参数化适用于连续动作空间。请注意,我们还赋予智能体改变分布方差的可能性。这意味着它可以决定增加方差,从而探索它不确定最佳动作的场景,或者当它对在给定状态下应采取的动作非常确定时,可以减少方差,从而增加利用。方差的影响可以通过以下方式可视化:

图 1.26:方差对高斯策略的影响
在前面的图中,如果方差增加(下曲线),策略变得更加探索性。此外,远离均值的动作具有非零概率。当方差较小(上曲线)时,策略高度偏向利用。这意味着只有那些非常接近均值的动作才具有非零概率。
在前面的图示中,较小的高斯分布表示相对于较大的策略,具有高度探索性的策略。在这里,我们可以看到方差对策略探索态度的影响。
在学习任务时,在前几个训练回合中,策略需要具有较高的方差,以便探索不同的动作。一旦智能体获得了一些经验,并且越来越有信心知道最佳动作是什么,方差将会减少。
Boltzmann 策略
Boltzmann 参数化用于离散动作空间。生成的动作是一个对加权状态特征作用的 softmax 函数,如下所示:

图 1.27:Boltzmann 策略的表达式
这里,
是与动作
相关联的参数集。
Boltzmann 策略是一个随机策略。其背后的动机非常简单;让我们对所有动作求和(分母与动作无关,
),如下所示:

图 1.28:所有动作的 Boltzmann 策略
如果我们选择具有最高概率的动作,Boltzmann 策略变得确定性,这相当于在高斯分布中选择均值动作。Boltzmann 参数化所表示的仅仅是值的归一化,
,对应于动作的得分
。因此,得分通过考虑所有其他动作的值来进行归一化,从而获得一个分布。
在所有这些参数化中,状态特征可能是非线性特征,这取决于多个参数,例如它是否来自神经网络、径向基函数 (RBF) 特征或平铺编码特征。
练习 1.02:实现线性策略
在本次练习中,我们将练习实现线性策略。目标是编写在由
组件组成的状态情况下呈现的参数化。第一种情况下,特征可以通过恒等函数表示;第二种情况下,特征通过二阶多项式函数表示:
-
打开一个新的 Jupyter 笔记本并导入 NumPy 以实现所有请求的策略:
from typing import Callable, List import matplotlib from matplotlib import pyplot as plt import numpy as np import scipy.stats -
现在我们来实现线性策略。线性策略可以通过策略参数和状态特征之间的点积来有效表示。第一步是编写构造函数:
class LinearPolicy: def __init__( self, parameters: np.ndarray, \ features: Callable[[np.ndarray], np.ndarray]): """ Linear Policy Constructor. Args: parameters (np.ndarray): policy parameters as np.ndarray. features (Callable[[np.ndarray], np.ndarray]): function used to extract features from the state representation. """ self._parameters = parameters self._features = features构造函数仅设置属性的参数和特征。特征参数实际上是一个可调用的函数,它接受一个 NumPy 数组作为输入,并返回另一个 NumPy 数组。输入是环境状态,而输出是状态特征。
-
接下来,我们将实现
__call__方法。__call__方法以状态作为输入,并根据策略参数返回所选动作。调用代表了一个真实的策略实现。在线性情况下,我们需要先应用特征函数,然后计算参数和特征之间的点积。call函数的一个可能实现如下:def __call__(self, state: np.ndarray) -> np.ndarray: """ Call method of the Policy. Args: state (np.ndarray): environment state. Returns: The resulting action. """ # calculate state features state_features = self._features(state) """ the parameters shape [0] should be the same as the state features as they must be multiplied """ assert state_features.shape[0] == self._parameters.shape[0] # dot product between parameters and state features return np.dot(self._parameters.T, state_features) -
让我们尝试用一个由 5 维数组组成的状态来定义策略。随机采样一组参数和一个随机状态向量。创建策略对象。构造函数需要可调用的特征,在这种情况下是恒等函数。调用策略以获得结果动作:
# sample a random set of parameters parameters = np.random.rand(5, 1) # define the state features as identity function features = lambda x: x # define the policy pi: LinearPolicy = LinearPolicy(parameters, features) # sample a state state = np.random.rand(5, 1) # Call the policy obtaining the action action = pi(state) print(action)输出将如下所示:
[[1.33244481]]
该值是我们的智能体在给定状态和策略参数的情况下选择的动作。在此案例中,所选动作是 [[1.33244481]]。该动作的含义取决于强化学习任务。
当然,你将根据采样的参数和采样的状态获得不同的结果。始终可以为 NumPy 随机数生成器设置种子,以获得可复现的结果。
注意
要访问此特定部分的源代码,请参阅 packt.live/2Yvrku7。你还可以查看在同一笔记本中实现的高斯策略和玻尔兹曼策略。
你也可以在网上运行这个示例,网址为 packt.live/3dXc4Nc。
在本次练习中,我们使用了不同的策略和参数化。这些是简单的策略,但它们是更复杂策略的构建块。关键在于将状态特征替换为神经网络或其他特征提取器。
目标和奖励
在强化学习中,代理的目标是最大化其在一个回合中收到的总奖励。
这基于著名的萨顿和巴托 1998年的奖励假设:
“我们所说的目标和目的,可以很好地理解为期望值最大化,即接收到的标量信号(称为奖励)累积和的最大化。”
这里重要的是,奖励不应该描述如何实现目标;而应该描述代理的目标。奖励函数是环境的一个元素,但它也可以针对特定任务进行设计。原则上,每个任务都有无限多的奖励函数。通常,包含大量信息的奖励函数有助于代理学习。稀疏奖励函数(没有信息)会使学习变得困难,甚至有时是不可能的。稀疏奖励函数是指大多数时候奖励是常数(或零)的函数。
我们之前解释过的萨顿假设是强化学习框架的基础。这个假设可能是错误的;可能一个标量奖励信号(及其最大化)不足以定义复杂目标;然而,尽管如此,这个假设仍然非常灵活、简单,且可以应用于广泛的任务。在写作时,奖励函数的设计更多的是一种艺术,而非工程;关于如何编写奖励函数没有正式的实践方法,只有基于经验的最佳实践。通常,一个简单的奖励函数效果很好。我们通常会将正值与良好的行为和动作关联,将负值与不好的行为或在特定时刻不重要的行为关联。
在一个运动任务中(例如,教机器人如何移动),奖励可能被定义为与机器人前进的运动成正比。在国际象棋中,奖励可以定义为每个时间步长为 0:如果代理获胜,则奖励+1;如果代理失败,则奖励-1。如果我们希望代理解决魔方,奖励可以类似地定义:每一步为 0,且如果魔方被解开则奖励+1。
有时,正如我们之前所学的,为任务定义一个标量奖励函数并不容易,今天,它更多的是一种艺术,而非工程或科学。
在这些任务中,最终的目标是学习一个策略,即选择动作的方式,最大化代理收到的总奖励。任务可以是回合性的或连续的。回合性任务有一个有限的长度,即有限的时间步长(例如,T 是有限的)。连续任务可以永远持续,直到代理达到目标。在第一种情况下,我们可以简单地将代理收到的总奖励(回报)定义为各个奖励的总和:

图 1.29:总奖励的表达式
通常,我们关心的是从某个时间步
开始的回报。换句话说,回报
量化了代理的长期表现,并且可以计算为从时间 t 开始直到本集结束(时间步
)的即时奖励之和:
图 1.30:从时间步 t 开始的回报表达式
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_01_30.jpg)
图 1.30:从时间步 t 开始的回报表达式
很容易看出,在这个公式下,连续任务的回报会趋向于无穷大。
为了处理连续任务,我们需要引入折扣回报的概念。这个概念在数学上形式化了即时奖励(有时)比许多步骤后的相同奖励更有价值的原则。这个原则在经济学中被广泛知道。折扣因子
量化了未来奖励的现值。我们已经准备好展示用于情景任务和连续任务的统一回报符号。
折扣回报是直到本集结束的奖励的累计折扣和。从数学上讲,可以形式化如下:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_01_31.jpg)
图 1.31:从时间步 t 开始的折扣回报表达式
为了理解折扣如何影响回报,可以看出,接收奖励
后经过
时间步的回报是
,因为
小于或等于
。值得引入折扣对回报的影响。如果
,即使回报由一个无穷和组成,它也有一个有界值。如果
,则代理是近视的,因为它只关心即时奖励,而不关心未来奖励。近视代理可能会引发问题:它所学到的唯一一件事就是选择产生最高即时回报的动作。例如,一个近视的国际象棋选手可能会吃掉对方的兵,导致游戏失利。请注意,对于某些任务,这并不总是问题。这些任务包括当前动作不会影响未来回报,也不会对代理的未来产生影响。这些任务可以通过为每个状态独立找到一个产生更高即时回报的动作来解决。大多数情况下,当前的动作会影响代理及其未来的奖励。如果折扣因子接近 1,代理是远视的;它有可能为了未来更高的奖励,牺牲当前产生良好即时回报的动作。
理解不同时间步的回报关系非常重要,不仅从理论角度看,也从算法角度看,因为许多强化学习(RL)算法都基于这一原理:

图 1.32:不同时间步的回报关系
通过以下这些简单的步骤,我们可以看到某一时间步的回报等于即时奖励加上下一个时间步的回报乘以折扣因子。这一简单的关系将在强化学习算法中广泛使用。
为什么要折扣?
以下描述了为什么许多强化学习问题需要折扣的动机:
-
从数学角度来看,拥有一个有界的回报是方便的,尤其是在处理持续任务时。
-
如果任务是金融任务,即时奖励可能比延迟奖励更具吸引力。
-
动物和人类行为偏好即时奖励。
-
折扣奖励也可以表示对未来的不确定性。
-
如果所有的回合在有限步骤后终止,也可以使用未折扣的回报!d
本节介绍了强化学习的主要元素,包括智能体、动作、环境、转移函数和策略。在接下来的章节中,我们将通过定义智能体、环境,并在一些任务上评估智能体的表现来实践这些概念。
强化学习框架
在前面的章节中,我们学习了强化学习背后的基本理论。原则上,智能体或环境可以用任何方式或任何语言来实现。对于强化学习,学术界和工业界使用的主要语言是 Python,因为它让你能够专注于算法,而不必关注语言的细节,从而使得使用变得非常简单。从零开始实现一个算法或复杂的环境(例如自动驾驶环境)可能非常困难且容易出错。为此,一些经过良好验证的库使得强化学习对于新手来说变得非常简单。在本节中,我们将探索主要的 Python 强化学习库。我们将介绍 OpenAI Gym,这是一组现成可用且易于修改的环境,和 OpenAI Baselines,这是一组高质量、最前沿的算法。在本章结束时,你将学习并实践环境和智能体的使用。
OpenAI Gym
OpenAI Gym (gym.openai.com)是一个 Python 库,提供了一套 RL 环境,从玩具环境到 Atari 环境,再到更复杂的环境,如MuJoCo和Robotics环境。除了提供这一大套任务外,OpenAI Gym 还提供了一个统一的接口,用于与 RL 任务进行交互,并提供了一套有助于描述环境特性的接口,如动作空间和状态空间。Gym 的一个重要特点是,它只专注于环境;它不对你使用的智能体类型或计算框架做任何假设。为了便于展示,我们在本章中不会详细介绍安装细节。相反,我们将专注于主要概念,并学习如何与这些库进行交互。
Gym 入门 – CartPole
CartPole 是 Gym 提供的一个经典控制环境,研究人员常常用它作为算法的起点。它由一个沿水平轴(1 维)移动的推车和一个固定在推车一端的杆组成:

图 1.33:CartPole 环境表示
智能体必须学会如何移动推车以保持杆子的平衡(即防止杆子倒下)。当杆子的角度 (
) 超过某个阈值 (
) 时,回合结束。状态空间由推车在轴上的位置表示!73;轴上的速度,
;杆子的角度,
;和杆子的角速度,
。在这种情况下,状态空间是连续的,但也可以离散化以简化学习。
在接下来的步骤中,我们将实践 Gym 及其环境。
让我们使用 Gym 创建一个 CartPole 环境,并在 Jupyter Notebook 中分析它的属性。关于 Gym 安装的说明,请参阅前言部分:
# Import the gym Library
import gym
# Create the environment using gym.make(env_name)
env = gym.make('CartPole-v1')
"""
Analyze the action space of cart pole using the property action_space
"""
print("Action Space:", env.action_space)
"""
Analyze the observation space of cartpole using the property observation_space
"""
print("Observation Space:", env.observation_space)
如果你运行这些代码行,你将得到以下输出:
Action Space: Discrete(2)
Observation Space: Box(4,)
Discrete(2)表示 CartPole 的动作空间是一个由两种动作组成的离散动作空间:向左移动和向右移动。这些动作是智能体可用的唯一动作。在这种情况下,向左移动的动作由动作 0 表示,向右移动的动作由动作 1 表示。
Box(4,)表示环境的状态空间(观察空间)由一个 4 维的盒子表示,这是
的一个子空间。形式上,它是n个区间的笛卡尔积。状态空间有一个下界和一个上界。界限也可以是无限的,从而创建一个无界盒子。
为了更好地检查观察空间,我们可以使用high和low的属性:
# Analyze the bounds of the observation space
print("Lower bound of the Observation Space:", \
env.observation_space.low)
print("Upper bound of the Observation Space:", \
env.observation_space.high)
这将打印以下内容:
Lower bound of the Observation Space: [-4.8000002e+00 -3.4028235e+38
-4.1887903e-01 -3.4028235e+38]
Upper bound of the Observation Space: [4.8000002e+00 3.4028235e+38
4.1887903e-01 3.4028235e+38]
在这里,我们可以看到上下边界是包含 4 个元素的数组;每个元素代表一个状态维度。以下是一些观察:
-
小车位置(第一个状态维度)的下限为 -4.8,上限为 4.8\。
-
速度(第二个状态维度)的下限为 -3.1038,基本上是
;上限为 +3.1038,基本上是
。 -
杆角度(第三个状态维度)的下限为 -0.4 弧度,表示角度为 -24 度。上限为 0.4 弧度,表示角度为 +24 度。
-
杆角速度(第四个状态维度)的下限和上限分别为
和
,类似于小车政策的角速度的下限和上限。
Gym 空间
Gym Space 类表示 Gym 描述动作和状态空间的方式。最常用的空间是 Discrete 和 Box 空间。
离散空间由固定数量的元素组成。它既可以表示状态空间,也可以表示动作空间,并通过 n 属性描述元素的数量。其元素的范围从 0 到 n-1。
Box 空间通过 shape 属性描述其形状。它可以具有与 n 维盒子对应的 n 维形状。Box 空间也可以是无界的。每个区间的形式类似于
。
可以从动作空间中采样,以了解其组成元素,使用 space.sample() 方法。
注意
对于盒子环境的采样分布,为了创建一个盒子的样本,每个坐标是根据以下分布中的区间形式来采样的:
-
:一个均匀分布 -
:一个平移的指数分布 -
:一个平移的负指数分布 -
:一个正态分布
现在让我们演示如何创建简单的空间以及如何从空间中采样:
# Type hinting
from typing import Tuple
import gym
# Import the spaces module
from gym import spaces
# Create a discrete space composed by N-elements (5)
n: int = 5
discrete_space = spaces.Discrete(n=n)
# Sample from the space using .sample method
print("Discrete Space Sample:", discrete_space.sample())
"""
Create a Box space with a shape of (4, 4)
Upper and lower Bound are 0 and 1
"""
box_shape: Tuple[int, int] = (4, 4)
box_space = spaces.Box(low=0, high=1, shape=box_shape)
# Sample from the space using .sample method
print("Box Space Sample:", box_space.sample())
这将打印出我们空间中的样本:
Discrete Space Sample: 4
Box Space Sample: [[0.09071387 0.4223234 0.09272052 0.15551752]
[0.8507258 0.28962377 0.98583364 0.55963445]
[0.4308358 0.8658449 0.6882108 0.9076272 ]
[0.9877584 0.7523759 0.96407163 0.630859 ]]
当然,样本会根据你的种子值发生变化。
如你所见,我们从由 5 个元素(从 0 到 4)组成的离散空间中抽取了元素 4。我们随机抽取了一个 4 x 4 的矩阵,其元素值介于 0 和 1 之间,分别对应我们空间的下限和上限。
为了获得可复现的结果,也可以通过 seed 方法设置环境的种子:
# Seed spaces to obtain reproducible samples
discrete_space.seed(0)
box_space.seed(0)
# Sample from the seeded space
print("Discrete Space (seed=0) Sample:", discrete_space.sample())
# Sample from the seeded space
print("Box Space (seed=0) Sample:", box_space.sample())
这将打印以下内容:
Discrete Space (seed=0) Sample: 0
Box Space (seed=0) Sample: [[0.05436005 0.9653909
0.63269097 0.29001734]
[0.10248426 0.67307633 0.39257675 0.66984606]
[0.05983897 0.52698725 0.04029069 0.9779441 ]
0.46293673 0.6296479 0.9470484 0.6992778 ]]
由于我们将种子设置为 0,上述语句将始终打印相同的样本。为环境设置种子非常重要,以确保结果的可复现性。
练习 1.03:为图像观测创建一个空间
在本练习中,我们将创建一个空间来表示图像观测。基于图像的观测在强化学习中至关重要,因为它们允许智能体从像素中学习,并且需要最少的特征工程,或者不需要经过特征提取阶段。智能体可以专注于对其任务重要的内容,而不受人工决策启发式限制。我们将创建一个代表 RGB 图像的空间,尺寸为 256 x 256:
-
打开一个新的 Jupyter notebook 并导入所需的模块 –
gym和 NumPy:import gym from gym import spaces import matplotlib.pyplot as plt %matplotlib inline import numpy as np # used for the dtype of the space -
我们处理的是 256 x 256 的 RGB 图像,因此空间的形状为 (256, 256, 3)。此外,图像的像素值范围是 0 到 255(如果我们考虑
uint8类型的图像):""" since the Space is RGB images with shape 256x256 the final shape is (256, 256, 3) """ shape = (256, 256, 3) # If we consider uint8 images the bounds are 0-255 low = 0 high = 255 # Space type: unsigned int dtype = np.uint8 -
我们现在准备好创建空间了。图像是一个
Box空间,因为它有定义的边界:# create the space space = spaces.Box(low=low, high=high, shape=shape, dtype=dtype) # Print space representation print("Space", space)这将打印我们空间的表示:
Space Box(256, 256, 3)第一个维度是图像宽度,第二个维度是图像高度,第三个维度是通道数。
-
这是来自空间的一个样本:
# Sample from the space sample = space.sample() print("Space Sample", sample)这将返回空间样本;在这个例子中,它是一个 256 x 256 x 3 的大张量,包含无符号整数(介于 0 和 255 之间)。输出(此处仅显示部分行)应该类似于以下内容:
Space Sample [[[ 37 254 243] [134 179 12] [238 32 0] ... [100 61 73] [103 164 131] [166 31 68]] [[218 109 213] [190 22 130] [ 56 235 167] -
要可视化返回的样本,可以使用以下代码:
plt.imshow(sample)输出将如下所示:
![图 1.34:来自 (256, 256) RGB 的 Box 空间样本]()
图 1.34:来自 (256, 256) RGB 的 Box 空间样本
上述内容并不非常有信息性,因为它是一张随机图像。
-
现在,假设我们希望让我们的智能体看到最近的
n=4帧。通过添加时间维度,我们可以获得由四个维度组成的状态表示。第一个维度是时间维度,第二个是宽度,第三个是高度,最后一个是通道数。这是一个非常有用的技巧,可以让智能体理解它的运动:# we want a space representing the last n=4 frames n_frames = 4 # number of frames width = 256 # image width height = 256 # image height channels = 3 # number of channels (RGB) shape_temporal = (n_frames, width, height, channels) # create a new instance of space space_temporal = spaces.Box(low=low, high=high, \ shape=shape_temporal, dtype=dtype) print("Space with temporal component", space_temporal)这将打印以下内容:
Space with temporal component Box(4, 256, 256, 3)
如你所见,我们成功创建了一个空间,并且在检查空间的表示时,我们注意到它有另一个维度:时间维度。
注意
要访问此特定部分的源代码,请参考 packt.live/2AwJm7x。
你也可以在 packt.live/2UzxoAY 在线运行这个示例。
基于图像的环境在强化学习(RL)中非常重要。它们允许智能体直接从原始像素中学习显著特征,从而解决任务,而不需要任何预处理。在本练习中,我们学习了如何为图像观测创建一个 Gym 空间,以及如何处理图像空间。
渲染环境
在Gym 入门——CartPol**e部分,我们看到了从 CartPole 状态空间中的一个样本。然而,从向量表示中可视化或理解 CartPole 状态并不是一件容易的事,至少对于人类来说是这样的。Gym 还允许你通过env.render()函数可视化给定的任务(如果可能的话)。
注意
env.render()函数通常较慢。渲染环境主要是为了理解智能体在训练后学习到的行为,或在多个训练步骤的间隔中进行观察。通常,我们在训练时不会渲染环境状态,以提高训练速度。
如果我们只是调用env.render()函数,我们将始终看到相同的场景,也就是说,环境状态没有变化。为了查看环境随时间的演变,我们必须调用env.step()函数,它接受一个属于动作空间的动作作为输入,并在环境中应用该动作。
渲染 CartPole
以下代码演示了如何渲染 CartPole 环境。动作是从动作空间中采样的。对于强化学习算法,动作将根据策略智能地选择:
# Create the environment using gym.make(env_name)
env = gym.make("CartPole-v1")
# reset the environment (mandatory)
env.reset()
# render the environment for 100 steps
n_steps = 100
for i in range(n_steps):
action = env.action_space.sample()
env.step(action)
env.render()
# close the environment correctly
env.close()
如果你运行这个脚本,你会看到gym打开一个窗口,显示 CartPole 环境,并且执行随机动作,如下图所示:
![图 1.35:在 Gym 中渲染的 CartPole 环境(初始状态)]
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_01_35.jpg)
图 1.35:在 Gym 中渲染的 CartPole 环境(初始状态)
使用 Gym 的强化学习循环
为了理解一个动作的后果,并提出更好的策略,智能体观察其新的状态和奖励。使用gym实现这个循环非常简单。关键元素是env.step()函数。这个函数接受一个动作作为输入。它应用该动作并返回四个值,具体描述如下:
-
观察:观察是下一个环境状态。这是环境的观察空间中的一个元素。
-
奖励:与步骤相关的奖励是一个浮动值,它与输入到函数中的动作相关。
-
在回合结束时返回
True值,这时需要调用env.reset()函数来重置环境状态。 -
信息:这是一个包含调试信息的字典;通常它会被忽略。
现在,让我们在 Gym 环境中实现强化学习循环。
练习 1.04:使用 Gym 实现强化学习循环
在这项练习中,我们将使用 CartPole 环境实现一个基本的强化学习循环,包含回合和时间步。你也可以更改环境,使用其他环境;没有任何变化,因为 Gym 的主要目标是统一所有可能环境的接口,以便构建尽可能与环境无关的智能体。对于环境的透明度是强化学习中的一个独特之处:算法通常不是专门为特定任务设计的,而是任务无关的,这样它们可以成功应用于多种环境并解决问题。
我们需要像之前一样使用gym.make()函数创建 Gym 的 CartPole 环境。之后,我们可以进行一个定义好的回合数循环;对于每个回合,我们进行一个定义好的步数循环,或者直到回合结束(通过检查done值)。对于每个时间步,我们必须调用env.step()函数并传入一个动作(目前我们会传入一个随机动作),然后我们收集所需的信息:
-
打开一个新的 Jupyter 笔记本,定义导入项、环境和所需的步数:
import gym import matplotlib.pyplot as plt %matplotlib inline env = gym.make("CartPole-v1") # each episode is composed by 100 timesteps # define 10 episodes n_episodes = 10 n_timesteps = 100 -
对每个回合进行循环:
# loop for the episodes for episode_number in range(n_episodes): # here we are inside an episode -
重置环境并获取第一次观测:
""" the reset function resets the environment and returns the first environment observation """ observation = env.reset() -
对每个时间步进行循环:
""" loop for the given number of timesteps or until the episode is terminated """ for timestep_number in range(n_timesteps): -
渲染环境,选择动作(通过使用
env.action_space.sample()方法随机选择),然后执行该动作:# render the environment env.render(mode="rgb-array") # select the action action = env.action_space.sample() # apply the selected action by calling env.step observation, reward, done, info = env.step(action) -
使用
done变量检查回合是否结束:"""if done the episode is terminated, we have to reset the environment """ if done: print(f"Episode Number: {episode_number}, \ Timesteps: {timestep_number}") # break from the timestep loop break -
在回合循环结束后,关闭环境以释放相关内存:
# close the environment env.close()如果你运行之前的代码,输出应该大致如下:
Episode Number: 0, Timesteps: 34 Episode Number: 1, Timesteps: 10 Episode Number: 2, Timesteps: 12 Episode Number: 3, Timesteps: 21 Episode Number: 4, Timesteps: 16 Episode Number: 5, Timesteps: 17 Episode Number: 6, Timesteps: 12 Episode Number: 7, Timesteps: 15 Episode Number: 8, Timesteps: 16 Episode Number: 9, Timesteps: 16
我们有回合号和该回合中采取的步数。我们可以看到,平均每个回合的时间步数大约是 17。这意味着,使用随机策略后,平均经过 17 回合,杆子会倒下,回合结束。
注意
要访问这个特定部分的源代码,请参考packt.live/2MOs5t5。
本节目前没有在线互动示例,需要在本地运行。
这项练习的目标是理解每种强化学习算法的基本框架。唯一不同的地方是,动作选择阶段应考虑环境状态,这样才有用,而不是随机的。
现在,让我们继续完成一个活动,来测量智能体的表现。
活动 1.01:测量随机智能体的表现
测量性能和设计智能体是每个强化学习实验中的重要阶段。此活动的目标是通过设计一个能够使用随机策略与环境交互的智能体,然后测量其表现,来练习这两个概念。
你需要设计一个随机代理,使用 Python 类进行模块化,并保持代理与主循环的独立性。之后,你需要通过一批 100 个回合来测量折扣回报的均值和方差。你可以使用任何环境,但要确保代理的动作与环境兼容。你可以为离散动作空间和连续动作空间设计两种不同类型的代理。以下步骤将帮助你完成此任务:
-
导入所需的库:
abc、numpy和gym。 -
以非常简单的方式定义
Agent抽象类,仅定义表示策略的pi()函数。输入应为环境状态。__init__方法应该以动作空间为输入,并根据此构建分布。 -
定义一个从
Agent抽象类派生的ContinuousAgent。该代理应该检查动作空间是否与其一致,并且必须是连续动作空间。代理还应该初始化一个概率分布来进行动作采样(你可以使用 NumPy 来定义概率分布)。连续代理可以根据 Gym 空间定义的分布类型改变分布类型。 -
定义一个从
Agent抽象类派生的DiscreteAgent。离散代理应该初始化一个均匀分布。 -
为两个代理实现
pi()函数。这个函数非常简单,只需要从构造函数中定义的分布中采样并返回,忽略环境状态。当然,这是一个简化版本。你也可以在Agent基类中实现pi()函数。 -
在另一个文件中定义主 RL 循环,并导入代理。
-
根据选定的环境实例化正确的代理。环境的示例包括“CartPole-v1”或“MountainCar-Continuous-v0”。
-
根据代理的
pi函数采取行动。 -
通过收集每个回合的折扣回报(可以存储在列表或 NumPy 数组中)来衡量代理的表现。然后,计算平均值和标准差(你可以使用 NumPy 来实现)。记得对即时奖励应用折扣因子(用户定义)。你需要通过在每个时间步乘以折扣因子来保持累积折扣因子。
输出应类似于以下内容:
Episode Number: 0, Timesteps: 27, Return: 28.0 Episode Number: 1, Timesteps: 9, Return: 10.0 Episode Number: 2, Timesteps: 13, Return: 14.0 Episode Number: 3, Timesteps: 16, Return: 17.0 Episode Number: 4, Timesteps: 31, Return: 32.0 Episode Number: 5, Timesteps: 10, Return: 11.0 Episode Number: 6, Timesteps: 14, Return: 15.0 Episode Number: 7, Timesteps: 11, Return: 12.0 Episode Number: 8, Timesteps: 10, Return: 11.0 Episode Number: 9, Timesteps: 30, Return: 31.0 Statistics on Return: Average: 18.1, Variance: 68.89000000000001注意
此任务的解决方案可以在第 680 页找到。
OpenAI Baselines
OpenAI Baselines(github.com/openai/baselines)是一组最先进的强化学习算法。Baselines 的主要目标是更轻松地重现一组基准结果,评估新想法,并将其与现有算法进行比较。在本节中,我们将学习如何使用 Baselines 在从 Gym 获取的环境上运行现有算法(参考前一节),以及如何可视化代理学到的行为。至于 Gym,我们将不涉及安装说明;这些可以在前言部分找到。Baselines 算法的实现基于 TensorFlow,这是机器学习中最流行的库之一。
入门 Baselines – 在 CartPole 上使用 DQN
使用 Baselines 在 CartPole 上训练 Deep Q Network (DQN) 非常简单;我们只需用一行 Bash 命令就可以完成。
只需使用终端并运行以下命令:
# Train model and save the results to cartpole_model.pkl
python -m baselines.run –alg=deepq –env=CartPole-v0 –save_path=./cartpole_model.pkl –num_timesteps=1e5
让我们理解以下参数:
-
--alg=deepq指定要用于训练代理的算法。在我们的例子中,我们选择了deepq,即 DQN。 -
--env=CartPole-v0指定要使用的环境。我们选择了 CartPole,但我们也可以选择许多其他环境。 -
--save_path=./cartpole_model.pkl指定了保存训练好的代理的位置。 -
--num_timesteps=1e5是训练步骤的数量。
在训练完代理之后,还可以使用以下方法可视化学到的行为:
# Load the model saved in cartpole_model.pkl
# and visualize the learned policy
python -m baselines.run --alg=deepq --env=CartPole-v0 --load_path=./cartpole_model.pkl --num_timesteps=0 --play
DQN 是一个非常强大的算法;在像 CartPole 这样简单的任务上使用它几乎有些杀鸡用牛刀的感觉。我们可以看到代理学习了一个稳定的策略,杆子几乎不会倒下。我们将在接下来的章节中更详细地探讨 DQN。
在接下来的步骤中,我们将使用 Baselines 在 CartPole 环境上训练一个 DQN 代理:
-
首先,我们导入
gym和baselines:import gym # Import the desired algorithm from baselines from baselines import deepq -
定义一个回调函数来告知
baselines何时停止训练。如果奖励令人满意,则回调函数应返回True:def callback(locals, globals): """ function called at every step with state of the algorithm. If callback returns true training stops. stop training if average reward exceeds 199 time should be greater than 100 and the average of last 100 returns should be >= 199 """ is_solved = (locals["t"] > 100 and \ sum(locals["episode_rewards"]\ [-101:-1]) / 100 >= 199) return is_solved -
现在,让我们创建环境并准备算法的参数:
# create the environment env = gym.make("CartPole-v0") """ Prepare learning parameters: network and learning rate the policy is a multi-layer perceptron """ network = "mlp" # set learning rate of the algorithm learning_rate = 1e-3 -
我们可以使用
deep.learn()方法来开始训练并解决任务:""" launch learning on this environment using DQN ignore the exploration parameter for now """ actor = deepq.learn(env, network=network, lr=learning_rate, \ total_timesteps=100000, buffer_size=50000, \ exploration_fraction=0.1, \ exploration_final_eps=0.02, print_freq=10, \ callback=callback,)
一段时间后,根据您的硬件(通常需要几分钟),学习阶段终止,并且您将在当前工作目录中保存 CartPole 代理。
我们应该看到 baselines 日志报告代理随时间的表现。
考虑以下示例:
--------------------------------------
| % time spent exploring | 2 |
| episodes | 770 |
| mean 100 episode reward | 145 |
| steps | 6.49e+04 |
以下是从先前日志中的观察结果:
-
episodes参数报告了我们所指的回合数。 -
mean 100 episode reward是最近 100 个回合的平均回报。 -
steps是算法执行的训练步数。
现在我们可以保存我们的 actor,这样我们就可以在不重新训练的情况下重复使用它:
print("Saving model to cartpole_model.pkl")
actor.save("cartpole_model.pkl")
actor.save 函数之后,"cartpole_model.pkl" 文件包含了训练好的模型。
现在可以使用模型并可视化代理的行为。
deepq.learn 返回的演员实际上是一个可调用对象,给定当前的观察,它返回一个动作 —— 这是代理的策略。我们可以通过传入当前观察来使用它,它会返回选择的动作:
# Visualize the policy
n_episodes = 5
n_timesteps = 1000
for episode in range(n_episodes):
observation = env.reset()
episode_return = 0
for timestep in range(n_timesteps):
# render the environment
env.render()
# select the action according to the actor
action = actor(observation[None])[0]
# call env.step function
observation, reward, done, _ = env.step(action)
"""
since the reward is undiscounted we can simply add
the reward to the cumulated return
"""
episode_return += reward
if done:
break
# here an episode is terminated, print the return
print("Episode return", episode_return)
"""
here an episode is terminated, print the return
and the number of steps
"""
print(f"Episode return {episode_return}, \
Number of steps: {timestep}")
如果你运行前面的代码,你应该看到代理在 CartPole 任务上的表现。
你应该得到每一轮的回报输出;它应该类似于以下内容:
Episode return 200.0, Number of steps: 199
Episode return 200.0, Number of steps: 199
Episode return 200.0, Number of steps: 199
Episode return 200.0, Number of steps: 199
Episode return 200.0, Number of steps: 199
这意味着我们的代理总是能够达到 CartPole 的最大回报(200.0)和最大步数(199)。
我们可以比较使用训练后的 DQN 代理获得的回报与使用随机代理获得的回报(活动 1.01,衡量随机代理的表现)。随机代理的平均回报是 20.0,而 DQN 取得了 CartPole 的最大回报 200.0。
在本节中,我们介绍了 OpenAI Gym 和 OpenAI Baselines,这两个是强化学习(RL)研究和实验的主要框架。还有许多其他的 RL 框架,各有优缺点。Gym 特别适合使用,因为它在 RL 循环中提供了统一的接口,而 OpenAI Baselines 对于理解如何实现先进的 RL 算法以及如何将新算法与现有算法进行比较非常有用。
在接下来的章节中,我们将探索一些有趣的 RL 应用,以更好地理解该框架所提供的可能性以及其灵活性。
强化学习的应用
强化学习在许多不同的场景中具有令人兴奋且有用的应用。最近,深度神经网络的使用大大增加了可能应用的数量。
当在深度学习背景下使用时,RL 也可以称为深度 RL。
这些应用从游戏和视频游戏到现实世界的应用,如机器人技术和自动驾驶。在这些应用中,RL 是一项革命性的技术,使得没有这些技术的情况下几乎不可能(或至少非常困难)解决的任务变得可行。
在本节中,我们将介绍一些 RL 应用,描述每个应用的挑战,并开始理解为什么 RL 在其他方法中被首选,以及它的优缺点。
游戏
如今,RL 在视频游戏和棋盘游戏中被广泛使用。
游戏被用来对 RL 算法进行基准测试,因为通常它们非常复杂,难以解决,但又容易实现和评估。游戏也代表了一个模拟的现实,代理可以在其中自由移动和行为,而不会影响真实环境:

]
图 1.36:打砖块 – 最著名的雅达利游戏之一
注意
前面的截图来源于 OpenAI Gym 的官方文档。有关更多示例,请参阅以下链接:gym.openai.com/envs/#atari。
尽管游戏看起来似乎是次要的或用途有限的应用,但它们为强化学习和一般的人工智能算法提供了一个有用的基准。由于这些场景中出现的重大挑战,人工智能算法常常在游戏中进行测试。
玩游戏所需的两个主要特征是规划和实时控制。
一个无法进行规划的算法将无法在战略性游戏中获胜。在游戏的早期阶段,拥有一个长期计划也是至关重要的。规划在现实世界应用中也非常关键,因为采取的行动可能会带来长期的后果。
实时控制是另一个基本挑战,它要求算法能够在短时间内做出反应。这个挑战类似于算法在应用于现实世界的案例时所面临的挑战,例如自动驾驶、机器人控制等。在这些情况下,算法不能评估所有可能的行动或这些行动的所有可能后果;因此,算法应该学习一个高效的(可能是压缩的)状态表示,并且应理解其行动的后果,而无需模拟所有可能的情景。
最近,得益于 DeepMind 和 OpenAI 的工作,强化学习在围棋、Dota II 和星际争霸等电子游戏中已经超越了人类表现。
围棋
围棋是一款非常复杂、高度战略性的棋盘游戏。在围棋中,两名玩家相互竞争。目标是使用棋子(也叫做“石子”)围住比对方更多的领土。在每一回合,玩家可以在棋盘上的一个空交叉点放置自己的石子。游戏结束时,当没有玩家可以再放置石子时,围住更多领土的玩家获胜。
围棋已经被研究了很多年,以理解带领玩家走向胜利所需的策略和棋步。直到最近,没有算法能够成功地培养出强大的玩家——即使是那些在类似游戏(如国际象棋)中表现非常好的算法。这一困难源于围棋庞大的搜索空间、可行走棋的多样性以及围棋比赛的平均长度(按走棋步数计算),例如,围棋比赛的平均时长要长于国际象棋比赛的平均时长。强化学习,特别是由 DeepMind 开发的AlphaGo,最近成功击败了标准棋盘上的人类玩家。AlphaGo 实际上是强化学习、监督学习和树搜索算法的结合,经过大量人类和人工玩家对弈的数据训练。AlphaGo 标志着人工智能历史上的一个真正里程碑,这主要得益于强化学习算法的进展及其提高的效率。
AlphaGo的继任者是AlphaGo Zero。AlphaGo Zero 完全通过自我对弈的方式进行训练,完全从自身学习,没有任何人类干预(Zero 源自这一特性)。它目前是围棋和国际象棋领域的世界顶级选手:

图 1.37:围棋棋盘
AlphaGo 和 AlphaGo Zero 都使用了深度 卷积神经网络(CNN) 从“原始”棋盘开始学习合适的游戏表示。这一特点表明,深度 CNN 也能够从稀疏表示(如围棋棋盘)中提取特征。强化学习的主要优势之一在于,它能够以透明的方式使用在其他领域或问题中广泛研究的机器学习模型。
深度卷积网络通常用于分类或分割问题,这些问题乍一看似乎与强化学习问题非常不同。实际上,CNN 在强化学习中的使用方式与分类或回归问题非常相似。例如,AlphaGo Zero 的 CNN 接受原始棋盘表示,并输出每个可能行动的概率以及每个行动的价值。它可以同时看作是一个分类和回归问题。不同之处在于,RL 中的标签或行动并没有在训练集中给出,而是算法本身需要通过交互发现真实标签。AlphaGo(AlphaGo Zero 的前身)使用了两种不同的网络:一个用于行动概率,另一个用于价值估计。这项技术被称为演员-评论家(actor-critic)。负责预测行动的网络称为演员(actor),负责评估行动的网络称为评论家(critic)。
Dota 2
Dota 2 是一款复杂的实时战略游戏,其中有两个五人队伍对抗,每个玩家控制一个“英雄”。从强化学习(RL)的角度来看,Dota 的特点如下:
-
长时间跨度:一场 Dota 游戏大约有 20,000 步棋,并且可以持续 45 分钟。作为参考,一场国际象棋比赛在 40 步之前结束,而一场围棋比赛在 150 步之前结束。
-
部分可观察状态:在 Dota 中,智能体只能看到完整地图的一小部分,也就是周围的区域。一位强大的玩家应该能够预测敌人的位置及其行动。作为参考,围棋和国际象棋是完全可观察的游戏,智能体可以看到整个局势和对手的行动。
-
高维连续行动空间:Dota 中每个玩家在每一步都可以选择大量的行动。研究人员已经将可能的行动离散化,约有 170,000 种行动,每一步平均有 1,000 种可能的行动。相比之下,国际象棋的平均行动数为 35,围棋为 250。如此庞大的行动空间使得学习变得非常困难。
-
高维连续观察空间:虽然国际象棋和围棋有离散化的观察空间,但 Dota 拥有大约 20,000 维的连续状态空间。正如我们稍后在本书中将要学习的那样,状态空间包括所有玩家在选择行动时必须考虑的信息。在视频游戏中,状态空间由敌人的特征和位置、当前玩家的状态(包括其能力、装备和健康状况)以及其他领域特定的特征组成。
OpenAI Five 是一个强化学习算法,能够在《Dota》游戏中超越人类表现,它由五个神经网络协同工作组成。该算法通过自我对弈进行学习,每天进行相当于 180 年的对弈。用于训练这五个神经网络的算法叫做近端策略优化(Proximal Policy Optimization),它代表了当前强化学习算法的最新技术。
注意
如需了解更多关于 OpenAI Five 的信息,请参考以下链接:openai.com/blog/openai-five/
星际争霸
星际争霸具有许多与 Dota 相似的特点,包括每场游戏中大量的操作、玩家可获取的信息不完全,以及高度维度的状态和动作空间。AlphaStar,由 DeepMind 开发的玩家,是第一个能够在没有任何游戏限制的情况下进入顶级联赛的人工智能代理。AlphaStar 使用了机器学习技术,如神经网络、通过强化学习进行自我对弈、多智能体学习方法和模仿学习,从其他人类玩家那里以监督的方式进行学习。
注意
想要进一步了解 AlphaStar,请参考以下论文:arxiv.org/pdf/1902.01724.pdf
机器人控制
机器人正在逐渐变得无处不在,并广泛应用于各种行业,因为它们能够以精确和高效的方式执行重复任务。强化学习(RL)对于机器人应用非常有帮助,因为它可以简化复杂行为的开发。同时,机器人应用也为强化学习算法提供了一系列基准和现实世界的验证。研究人员将算法应用于机器人任务,如运动(例如,学习如何移动)或抓取(例如,学习如何抓取物体)。机器人技术提出了独特的挑战,比如维度灾难、样本的有效使用(也称为样本效率)、从相似或模拟任务中转移知识的可能性,以及安全性需求:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_01_38.jpg)
图 1.38:来自 Gym 机器人套件的机器人任务
注意
上面的图表来自 OpenAI Gym 的官方文档:gym.openai.com/envs/#robotics
请参考链接了解更多机器人物控制的例子。
维度灾难是一个在监督学习应用中也能找到的挑战。然而,在这些情况下,通过限制可能解决方案的空间至有限的函数类别,或者通过在模型中注入先验知识元素,通常可以缓解这个问题。机器人通常有许多自由度,这使得可能的状态空间和动作空间非常庞大。
机器人本质上是通过与物理环境互动来进行工作的。真实机器人与环境的互动通常是耗时且可能危险的。通常,强化学习(RL)算法需要数百万个样本(或回合)才能变得高效。样本效率在这一领域是一个问题,因为所需的时间可能不切实际。以智能的方式使用收集到的样本是成功的基于 RL 的机器人应用的关键。可以在这些情况下使用的技术是所谓的sim2real,即初始学习阶段在模拟环境中进行,模拟环境通常比真实环境更安全、更快速。经过这一阶段,学习到的行为会被转移到真实环境中的真实机器人上。此技术需要一个与真实环境非常相似的模拟环境,或者要求算法具备较强的泛化能力。
自动驾驶
自动驾驶是 RL 的另一个令人兴奋的应用。这项任务的主要挑战在于缺乏精确的规格。在自动驾驶中,如何定义“驾驶得好”是一个难题,是否在某种情境下转向是好还是坏,驾驶员是否应该加速或刹车也难以界定。与机器人应用类似,自动驾驶也可能是危险的。在驾驶任务上测试 RL 算法,或者更广泛地说,机器学习算法,存在许多问题,并引发了诸多顾虑。
除了这些顾虑,自动驾驶场景非常适合 RL 框架。正如我们将在本书中后续部分探讨的,我们可以把驾驶员看作是决策者。在每一步中,驾驶员都会接收到一个观测信息。这个观测信息包括道路的状态、当前的速度、加速度以及车辆的所有特征。驾驶员需要根据当前状态作出相应的决策,决定如何控制车辆的命令、转向、刹车和加速。设计一个基于规则的系统来在实际情况下进行驾驶是很复杂的,因为可能会遇到无穷多种不同的情形。因此,基于学习的系统在此类任务中会更高效、更有效。
注意
有许多模拟环境可供开发高效的自动驾驶算法,列举如下:
Voyage Deepdrive: news.voyage.auto/introducing-voyage-deepdrive-69b3cf0f0be6
AWS DeepRacer: aws.amazon.com/fr/deepracer/
在本节中,我们分析了一些有趣的强化学习应用、它们面临的主要挑战以及研究人员使用的主要技术。游戏、机器人技术和自动驾驶只是强化学习在现实世界中的一些应用示例,但还有许多其他应用。在本书的其余部分,我们将深入探讨强化学习;我们将了解其组成部分以及本章介绍的技术。
摘要
强化学习是机器学习大伞下的基本范式之一。强化学习的原则非常普适和跨学科,并不局限于特定的应用领域。
强化学习考虑了代理与外部环境的交互,灵感来自人类学习过程。强化学习明确目标是有效探索和几乎所有人类问题中出现的探索-利用权衡;这是区别于其他学科的一个特点。
我们从高层次描述强化学习开始本章,展示了一些有趣的应用。然后,我们介绍了强化学习的主要概念,描述了代理是什么、环境是什么以及代理如何与其环境交互。最后,我们通过展示这些库如何使强化学习变得极其简单,实现了 Gym 和 Baselines。
在下一章中,我们将深入了解强化学习背后的理论,从马尔可夫链开始,到马尔可夫决策过程。我们将介绍几乎所有强化学习算法核心的两个函数:状态值函数,评估状态的好坏,以及动作值函数,评估状态-动作对的质量。
第二章:2. 马尔可夫决策过程与贝尔曼方程
概述
本章将更深入地探讨强化学习背后的理论。我们将涵盖马尔可夫链、马尔可夫奖励过程和马尔可夫决策过程的内容。我们将学习状态值和动作值的概念,以及如何通过贝尔曼方程计算这些量。到本章结束时,您将能够使用线性编程方法求解马尔可夫决策过程。
介绍
在上一章中,我们研究了强化学习(RL)的主要元素。我们将智能体描述为一个能够感知环境状态并通过修改环境状态来实现目标的实体。智能体通过策略进行行为,策略代表其行为方式,而智能体选择动作的方式则基于环境状态。在上一章的后半部分,我们介绍了 Gym 和 Baselines,这两个 Python 库分别简化了环境表示和算法实现。
我们提到,强化学习将问题视为马尔可夫决策过程(MDP),但没有深入细节,也没有给出正式的定义。
本章将正式描述 MDP 的定义、特性和属性。在面对强化学习(RL)中的新问题时,我们必须确保该问题能够形式化为 MDP;否则,应用强化学习技术是不可行的。
在正式定义 MDP 之前,我们需要理解马尔可夫链(MCs)和马尔可夫奖励过程(MRPs)。MCs 和 MRPs 是 MDP 的特定情况(简化版本)。MC 只关注状态转移,不建模奖励和动作。考虑经典的蛇梯游戏,其中下一步动作完全依赖于骰子上显示的数字。MRPs 则在状态转移中也包含了奖励成分。MRPs 和 MCs 有助于逐步理解 MDP 的特性。我们将在本章后面介绍 MCs 和 MRPs 的具体示例。
本章除了介绍马尔可夫决策过程(MDP)外,还介绍了状态值函数和动作值函数的概念,这些概念用于评估一个状态对于智能体的好坏以及在给定状态下采取的动作的好坏。状态值函数和动作值函数是用于解决实际问题的算法的构建模块。状态值函数和动作值函数的概念与智能体的策略和环境动态密切相关,正如我们将在本章后面学到的那样。
本章的最后部分介绍了两个贝尔曼方程,即贝尔曼期望方程和贝尔曼最优性方程。这些方程在强化学习中有助于评估智能体的行为,并找到一个能够最大化智能体在 MDP 中表现的策略。
在本章中,我们将通过一些 MDP 的例子进行实践,比如学生 MDP 和 Gridworld。我们将使用 Python、SciPy 和 NumPy 实现本章中解释的求解方法和方程。
马尔可夫过程
在前一章中,我们描述了强化学习(RL)循环:智能体观察环境状态的表示,通过行动与环境互动,并根据行动和环境状态获得奖励。这个互动过程称为 MDP。在这一节中,我们将从 MDP 的最简单形式——马尔可夫链(MC)开始,理解什么是 MDP。在描述不同类型的 MDP 之前,有必要将所有这些过程的基础性质——马尔可夫性质——形式化。
马尔可夫性质
让我们从两个例子开始,帮助我们理解什么是马尔可夫性质。考虑一个魔方。当将解魔方的任务形式化为强化学习任务时,我们可以将环境状态定义为魔方的状态。智能体可以执行相应的动作来旋转魔方的面。这些动作会导致状态转移,改变魔方的状态。在这里,历史并不重要——也就是说,决定当前状态的动作序列——因为它不会影响下一个状态。当前状态和当前行动是唯一影响未来状态的因素:

图 2.1:魔方表示
看看上面的图,假设当前环境状态是标记为Present的立方体。当前状态可以通过左边标记为Past #1和Past #2的两个状态到达,并且使用两个不同的动作(用黑色箭头表示)。通过将左边的面旋转向下,在当前状态下,我们得到右边的未来状态,标记为Future。在这种情况下,下一个状态与过去无关,因为只有当前状态和行动决定了它。过去的状态无关紧要,不管是Past #1还是Past #2,我们最终都会到达相同的未来状态。
现在我们再考虑一个经典的例子:Breakout 游戏。
Breakout 是一个经典的 Atari 游戏。在这个游戏中,屏幕顶部有一层砖块;目标是使用球打破砖块,同时不让球触碰到屏幕底部。玩家只能水平移动挡板。当将 Breakout 游戏形式化为一个强化学习任务时,我们可以将环境状态定义为某一时刻的图像像素。智能体可以选择三种可能的动作:"左"、"右"和"无",分别对应挡板的移动。
在这里,与魔方的例子有所不同。图 2.2 直观地解释了这个区别。如果我们仅使用当前帧表示环境状态,那么未来不仅仅由当前状态和当前动作决定。我们可以通过观察球来轻松地可视化这个问题。
在图 2.2的左侧部分,我们可以看到两个可能的过去状态,它们都导致相同的当前状态。通过箭头表示球的运动。在这两种情况下,代理的动作都是“左”。
在图的右侧部分,我们有两个可能的未来状态,未来 #1和未来 #2,它们从当前状态开始并执行相同的动作(“左”动作)。
仅通过观察当前状态,我们无法确定下一个未来状态是哪一个,因为我们无法推断出球的运动方向,它是朝着屏幕的顶部还是底部。我们需要了解历史,即需要知道两个前一个状态中的哪一个是真正的前一个状态,才能理解下一个状态将是什么。
在这种情况下,未来状态并不独立于过去:
注意
注意,箭头实际上并不存在于环境状态中。我们在图框中绘制它是为了方便展示。

图 2.2:雅达利游戏表示
在魔方的例子中,当前状态包含了足够的信息,结合当前的动作,可以确定下一个状态。而在雅达利的例子中,这并不成立。当前状态并不包含一个至关重要的信息:运动成分。在这种情况下,我们不仅需要当前状态,还需要过去的状态来确定下一个状态。
马尔可夫性质恰好用数学术语解释了两个例子之间的区别。马尔可夫性质表明“在给定当前状态的情况下,未来与过去无关。”
这意味着未来状态仅依赖于当前状态,当前状态是唯一影响未来状态的因素,我们可以忽略过去的状态。马尔可夫性质可以通过以下方式形式化:

图 2.3:马尔可夫性质的表达式
给定当前状态
,下一个状态
的概率等于给定状态历史
下下一个状态的概率
。这意味着过去的状态
对下一个状态分布没有影响。
换句话说,要描述下一个状态的概率分布,我们只需要当前状态中的信息。几乎所有的强化学习(RL)环境,作为马尔可夫决策过程(MDP),都假设马尔可夫性质成立。在设计 RL 任务时,我们需要记住这一性质;否则,主要的 RL 假设将不再成立,导致算法失败。
注意
在统计学中,术语“给定”意味着概率受某些信息的影响。换句话说,概率函数依赖于一些其他信息。
大多数情况下,马尔可夫性质成立;然而,也有一些情况需要我们设计环境状态,以确保下一个状态与过去的状态相互独立。这正是《Breakout》游戏中的情况。为了恢复马尔可夫性质,我们可以将状态定义为多个连续帧,这样就可以推断球的方向。请参见以下图示:

图 2.4:Breakout 的马尔可夫状态
如前图所示,状态由三个连续的帧表示。
注意
你可以使用其他技巧来恢复马尔可夫性质。其中一种技巧是使用作为循环神经网络(RNN)表示的策略。通过使用 RNN,代理在确定当前行动时,也可以考虑过去的状态。本书后续将讨论将 RNN 作为 RL 策略的使用。
在 MDP 的背景下,给定当前状态下一个状态的概率,
被称为过渡函数。
如果状态空间是有限的,由 N 个状态组成,我们可以将为每一对状态计算出的过渡函数排列在一个 N x N 的矩阵中,其中所有列的和为 1,因为我们是在对过渡函数元素的概率分布求和:

图 2.5:过渡概率矩阵
行表示源状态,列表示目标状态。
概率矩阵总结了过渡函数。它可以按以下方式读取:
是从状态
开始,落入状态
的概率。
马尔可夫链
马尔可夫链(MC),或者简而言之,马尔可夫过程,被定义为状态空间
和过渡函数
的元组。状态空间与过渡函数一起定义了一个无记忆的随机状态序列,
,满足马尔可夫性质。从马尔可夫过程中的一个样本简单来说就是一系列状态,这在 RL 上下文中也被称为一个回合:

图 2.6:具有三个状态的马尔可夫链
考虑前述的 MC。正如您所见,我们有三个用圆圈表示的状态。针对连接不同状态的边缘报告的概率函数被评估为状态对。查看从每个状态开始的边缘,我们可以看到与每个边缘相关联的概率之和为 1,因为它定义了概率分布。对于未通过边缘连接的一对状态的转移函数为 0。
转移函数可以按矩阵方式排列,如下所示:

图 2.7:图 2.6 中 MC 的转移矩阵
从编程角度来看,转移函数的矩阵形式非常方便,因为它使我们能够轻松进行计算。
马尔可夫奖励过程
MRP 是具有与状态转换相关联的值的 MC,称为奖励。奖励函数评估从一个状态转移到另一个状态的实用性。
MRP 是满足
的元组,使以下条件成立:
-
S 是有限状态集。
-
P 是转移概率,其中
是从状态
过渡到状态
的概率。 -
R 是奖励函数,其中
是从状态
过渡到状态
的奖励。 -
是与未来奖励相关的折扣因子,
:

图 2.8:MRP 的一个示例
正如您在前面的图中所见,奖励由 r 表示,并与状态转换相关联。
让我们考虑图 2.8 中的 MRP。最高奖励(10)与转换 1->3 和自环 3->3 相关联。最低奖励与转换 3->2 相关,等于 -1。
在 MRP 中,可以计算折扣回报作为折扣奖励的累积和。
在这种情况下,我们使用术语“轨迹”或“剧集”来表示过程遍历的状态序列。
现在让我们计算给定轨迹的折扣回报;例如,带有折扣因子
的 1-2-3-3-3 轨迹。
折扣回报如下:

图 2.9:1-2-3-3-3 轨迹的折扣回报
我们还可以计算不同轨迹的折扣回报,例如 1-3-3-3-3:

图 2.10:1-3-3-3-3 轨迹的折扣回报
在此示例中,第二个轨迹比第一个轨迹更方便,具有更高的回报。这意味着相应的路径比第一个路径更好。回报并不代表轨迹的绝对特征;它表示相对于其他轨迹的相对优越性。不同 MRP 的轨迹回报不能相互比较。
考虑由 N 个状态组成的 MRP,奖励函数可以表示为一个 N x N 矩阵,类似于转移矩阵:

图 2.11:奖励矩阵
在行中,我们表示源状态,在列中,我们表示目标状态。
奖励矩阵可以按如下方式读取:
是与状态转移相关的奖励,
。
对于图 2.11中的示例,按矩阵排列的奖励函数如下所示:

图 2.12:MRP 示例的奖励矩阵
当奖励未指定时,我们假设奖励为0。
使用 Python 和 NumPy,我们可以以这种方式表示转移矩阵:
n_states = 3
P = np.zeros((n_states, n_states), np.float)
P[0, 1] = 0.7
P[0, 2] = 0.3
P[1, 0] = 0.5
P[1, 2] = 0.5
P[2, 1] = 0.1
P[2, 2] = 0.9
类似地,奖励矩阵可以这样表示:
R = np.zeros((n_states, n_states), np.float)
R[0, 1] = 1
R[0, 2] = 10
R[1, 0] = 0
R[1, 2] = 1
R[2, 1] = -1
R[2, 2] = 10
我们现在可以引入 MRP 的价值函数和贝尔曼方程的概念。
MRP 的价值函数和贝尔曼方程
MRP 中的价值函数评估给定状态的长期价值,表示从该状态出发的预期回报。通过这种方式,价值函数表达了对状态的偏好。与另一个状态相比,具有更高价值的状态代表着一个更好的状态——换句话说,一个在其中待着更有回报的状态。
在数学上,价值函数的形式化表示如下:

图 2.13:价值函数的表达式
状态
的价值函数表示为
。方程右侧的期望是回报的期望值,表示为
,考虑到当前状态恰好等于状态
——我们正在评估该状态的价值函数。期望是根据转移函数计算的。
通过考虑即时奖励和后继状态的折扣价值函数,价值函数可以分解为两部分:

图 2.14:价值函数的分解
最后一方程是一个递归方程,称为MRP 的贝尔曼期望方程,其中给定状态的价值函数依赖于后继状态的价值函数。
为了突出方程对转移函数的依赖性,我们可以将期望重写为可能状态的加权求和,权重由转移概率决定。我们使用
定义状态
中的奖励函数期望,也可以将其定义为平均奖励。
我们可以将
写成以下几种形式:

图 2.15:状态 s 中奖励函数期望的表达式
我们现在可以以更便捷的方式重写价值函数:

图 2.16:状态 s 中价值函数期望的修正表达式
这个表达式可以转换为如下的代码:
R_expected = np.sum(P * R, axis=1, keepdims=True)
在之前的代码中,我们通过逐元素相乘概率矩阵和奖励矩阵,计算了每个状态的期望奖励。请注意,keepdims 参数是必须的,用来获得列向量。
这种形式使得我们可以使用矩阵表示法重写贝尔曼方程:

图 2.17:贝尔曼方程的矩阵形式
在这里,V 是包含状态值的列向量,
是每个状态的期望奖励,P 是转移矩阵:

图 2.18:贝尔曼方程的矩阵形式
使用矩阵表示法,也可以求解贝尔曼方程,得到与每个状态关联的 V 值函数:

图 2.19:使用贝尔曼方程的价值函数
在这里,I 是大小为 N x N 的单位矩阵,N 是 MRP 中的状态数量。
使用 SciPy 求解线性方程组
SciPy (github.com/scipy/scipy) 是一个基于 NumPy 的用于科学计算的 Python 库。SciPy 在 linalg 模块(线性代数)中提供了用于求解方程组的有用方法。
特别地,我们可以使用 linalg.solve(A, b) 来求解形式为
的方程组。这正是我们可以用来求解系统
的方法,其中
是矩阵 A,V 是变量向量 x,而
是向量 b。
转换为代码时,应该如下所示:
gamma = 0.9
A = np.eye(n_states) - gamma * P
B = R_states
# solve using scipy linalg solve
V = linalg.solve(A, B)
如你所见,我们已经声明了贝尔曼方程的各个元素,并使用 scipy.linalg 来计算 value 函数。
现在,让我们通过完成一个练习来进一步加强我们的理解。
练习 2.01:在 MRP 中找到值函数
在这个练习中,我们将通过找到以下图中 MRP 的值函数来解决 Bellman 期望方程。我们将使用scipy和linalg模块来解决前一节中提出的线性方程。我们还将演示如何定义转移概率矩阵以及如何计算每个状态的预期奖励:

图 2.20:具有三个状态的 MRP 示例
-
导入所需的 NumPy 和 SciPy 包:
import numpy as np from scipy import linalg -
定义转移概率矩阵:
# define the Transition Probability Matrix n_states = 3 P = np.zeros((n_states, n_states), np.float) P[0, 1] = 0.7 P[0, 2] = 0.3 P[1, 0] = 0.5 P[1, 2] = 0.5 P[2, 1] = 0.1 P[2, 2] = 0.9 print(P)您应该获得以下输出:
array([[0\. , 0.7, 0.3], [0.5, 0\. , 0.5], [0\. , 0.1, 0.9]])让我们检查矩阵的正确性。从状态 1 到状态 1 的转移概率是
。由于状态 1 没有自环,这是正确的。从状态 1 到状态 2 的转移概率是
,因为它是与边缘1->2相关联的概率。这可以应用于转移矩阵的所有元素。请注意,在这里,转移矩阵的元素按状态而不是矩阵中的位置索引。这意味着对于
,我们指的是矩阵的 0,0 元素。 -
检查所有列的总和是否完全等于
1,作为概率矩阵:# the sum over columns is 1 for each row being a probability matrix assert((np.sum(P, axis=1) == 1).all())assert函数用于确保特定条件返回true。在这种情况下,assert函数将确保所有列的总和恰好为1。 -
我们可以使用奖励矩阵和转移概率矩阵来计算每个状态的预期即时奖励:
# define the reward matrix R = np.zeros((n_states, n_states), np.float) R[0, 1] = 1 R[0, 2] = 10 R[1, 0] = 0 R[1, 2] = 1 R[2, 1] = -1 R[2, 2] = 10 """ calculate expected reward for each state by multiplying the probability matrix for each reward """ #keepdims is required to obtain a column vector R_expected = np.sum(P * R, axis=1, keepdims=True) # The matrix R_expected R_expected您应该获得以下列向量:
array([[3.7], [0.5], [8.9]])R_expected向量是每个状态的预期即时奖励。状态 1 的预期奖励为3.7,这恰好等于0.7 * 1 + 0.3 * 10。状态 2 和状态 3 也适用相同的逻辑。 -
现在我们需要定义
gamma,并且准备将 Bellman 方程作为线性方程求解,
。我们有
和
:# define the discount factor gamma = 0.9 # Now it is possible to solve the Bellman Equation A = np.eye(n_states) - gamma * P B = R_expected # solve using scipy linalg V = linalg.solve(A, B) V您应该获得以下输出:
array([[65.540732 ], [64.90791027], [77.5879575 ]])向量
V表示每个状态的值。状态 3 具有最高的值(77.58)。这意味着状态 3 提供了最高的预期回报。在这个 MRP 中,状态 3 是最佳状态。直觉上,状态 3 是最佳状态,因为转移高概率(0.9)会将代理带到同一状态,并且与转移相关联的奖励很高(+10)。注意
要访问此特定部分的源代码,请参阅
packt.live/37o5ZH4。您也可以在
packt.live/3dU8cfW上在线运行此示例。
在本练习中,我们通过为我们的玩具问题求解贝尔曼方程,找到 MRPs 的状态值。状态值定量描述了处于每个状态时的收益。我们通过转移概率矩阵和奖励矩阵来描述 MRP。这两个矩阵使我们能够求解与贝尔曼方程相关的线性系统。
注意
贝尔曼方程的计算复杂度是 O(n³);它与状态数量的立方成正比。因此,只适用于小型 MRP。
在下一节中,我们将考虑一个可以执行动作的主动智能体,从而得出 MDP 的描述。
马尔可夫决策过程(Markov Decision Processes)
MDP 是带有决策的 MRP。在这种情况下,我们有一组可供智能体执行的动作,这些动作可以影响到下一个状态的转移概率。与 MRP 中转移概率仅依赖于环境状态不同,在 MDP 中,智能体可以执行影响转移概率的动作。通过这种方式,智能体成为框架中的主动实体,通过行动与环境互动。
严格来说,MDP 是一个元组,
,其中以下条件成立:
-
是状态集。 -
是动作集。 -
是奖励函数,
。
是在执行动作
和处于状态
时的期望奖励。 -
是转移概率函数,其中
是从当前状态
出发,执行动作
,并且到达状态
的概率。 -
是与未来奖励相关的折扣因子,
。
MRP 和 MDP 之间的区别在于,智能体可以选择一组动作来调节转移概率,从而提高进入良好状态的可能性。如果 MRP 和 MC 只是对马尔可夫过程的描述,而没有目标,那么 MDP 就包含了策略和目标的概念。在 MDP 中,智能体需要对采取何种行动做出决策,以最大化折扣后的回报:

图 2.21:学生 MDP
图 2.21 是一个代表大学生一天活动的 MDP 示例。共有六个可能的状态:Class 1、Class 2、Class 3、Social、Bed 和 Pub。状态之间的边表示状态转换。边上标注了动作和奖励,奖励用r表示。可能的动作有Study、Social、Beer 和 Sleep。初始状态由输入箭头表示,为Class 1。学生的目标是在每个状态中选择最佳动作,从而最大化他们的回报。
在接下来的段落中,我们将讨论这个 MDP 的一些可能策略。
一名学生代理从Class 1开始。他们可以决定学习并完成所有课程。每次学习的决策都会带来一个小的负奖励,-2。如果学生在Class 3之后决定睡觉,他们将进入吸收状态Bed,并获得一个高的正奖励+10。这代表了日常生活中非常常见的情况:为了获得未来更高的回报,你必须牺牲一些即时的奖励。在这种情况下,通过决定在Class 1和Class 2学习,虽然获得了负奖励,但在Class 3之后通过高额的正奖励得到了补偿。
这个 MDP 中另一种可能的策略是在Class 1状态后立刻选择Social行动。这个行动带来一个小的负奖励。学生可以继续做同样的动作,每次都会获得相同的奖励。学生还可以选择从Social状态(注意,Social既是一个状态,也是一个行动)回到Class 1继续学习。在Class 1感到内疚时,学生可以决定继续学习。学习一会儿后,他们可能会感到疲倦并决定稍微休息,最终进入Bed状态。执行了Social行动后,代理已累积了负回报。
让我们评估一下这个例子的可能策略。我们假设折扣因子为
,即无折扣:
- 策略:优秀学生。优秀学生策略是第一个描述的策略。假设学生最终会回到
Class 1,他们可以执行以下动作:Study、Study、Study。因此,相关的状态序列是Class 1、Class 2、Class 3和Sleep。因此,相关的回报是沿着轨迹奖励的总和:![Figure 2.22: 优秀学生的回报]()
图 2.22:优秀学生的回报
- 策略:社交学生。社交学生策略是第二种描述的策略。学生可以执行以下动作:
Social、Social、Social、Study、Study和Sleep。因此,相关的状态序列是Class 1、Social、Social、Social、Class 1、Class 2和Bed。在这种情况下,相关的回报如下:![Figure 2.23: 社交学生的回报]()
图 2.23:社交学生的回报
通过查看相关回报,我们可以看到,优秀学生策略相比社交学生策略具有更高的回报,因此是一个更好的策略。
你此时可能会问,智能体如何决定采取哪个动作以最大化回报呢?为了回答这个问题,我们需要引入两个有用的函数:状态值函数和动作值函数。
状态值函数和动作值函数
在 MDP 的背景下,我们可以通过评估在某个给定状态下有多好的状态来定义一个函数。然而,我们应该考虑到智能体的策略,因为策略定义了智能体的决策并调节了轨迹上的概率,即未来状态的序列。因此,值函数依赖于智能体的策略,
。
s并遵循策略,
:

图 2.24:状态值函数的定义
在 MDP(马尔科夫决策过程)中,我们还希望定义在给定状态下执行某个动作的收益。这个函数称为动作值函数。
动作值函数,
,(也称为 q 函数),可以定义为从状态
开始,执行动作
并遵循策略
时的期望回报:

图 2.25:动作值函数的定义
如我们将在书中稍后学习的那样,状态值函数提供的信息仅在评估策略时有用。动作值函数则提供关于控制的信息,也就是在状态中选择动作的信息。
假设我们知道 MDP 的动作值函数。如果我们处于给定状态
,哪个动作是最佳选择?
好的,最佳动作是产生最高折扣回报的动作。动作值函数衡量的是从某个状态开始并执行一个动作所获得的折扣回报。通过这种方式,动作值函数为状态中的动作提供了一个排序(或偏好)。要执行的最佳动作是具有最高 q 值的动作:

图 2.26:使用动作值函数选择最佳动作
请注意,在这种情况下,我们只是对当前策略进行一步优化;也就是说,我们可能会在假设后续动作遵循当前策略的前提下,修改某个给定状态下的动作。如果我们这样做,我们不会在这个状态下选择最佳动作,而是选择在此策略下的最佳动作。
就像在 MRP 中一样,在 MDP 中,状态值函数和动作值函数也可以递归地分解:

图 2.27:MDP 中的状态值函数

图 2.28:MDP 中的动作值函数
这些方程被称为 MDP 的贝尔曼期望方程。
贝尔曼期望方程是递归的,因为给定状态的状态值函数依赖于另一个状态的状态值函数。对于动作值函数来说也是如此。
在动作值函数方程中,我们正在评估的动作,
,是一个任意的动作。这个动作不是从策略定义的动作分布中获取的。相反,接下来一步采取的动作,
,是根据在状态
下由动作分布定义的。
让我们重写状态值函数和动作值函数,以突出代理策略的贡献,
:

图 2.29:突出显示策略贡献的状态值函数
让我们分析方程的两个项:
-
:这个项是给定代理策略定义的动作分布时即时奖励的期望值。每个状态-动作对的即时奖励都会根据给定状态下的动作概率加权,这个概率定义为
。 -
是给定转移函数定义的状态分布下,状态值函数的折扣期望值。请注意,这里的动作,a,是由代理的策略定义的。作为期望值,每个状态值,
,都会根据从状态
到状态
的转移概率加权,给定动作
。这由
表示。
动作值函数可以重写,以突出它对转移函数和值函数的依赖:

图 2.30:动作值函数,突出显示下一个状态的转移函数和值函数的依赖性
因此,动作值函数是立即奖励和后继状态的状态值函数在环境动态(P)下的期望值之和。
通过比较这两个方程,我们可以得到状态值和动作值之间的重要关系:

图 2.31:基于动作值函数的状态值函数表达式
换句话说,状态值状态
,在策略下,
图 2.32:状态值函数的矩阵形式
有一个直接的解法,如下所示:

图 2.33:状态值的直接解法
在这里,你可以看到以下内容:
(列向量)是由策略引导的每个状态的即时奖励的期望值:

图 2.34:期望即时奖励
-
是每个状态的状态值列向量。 -
是基于动作分布的转移矩阵。它是一个
矩阵,其中
是 MDP 中状态的数量。给定两个状态
和
,我们得到以下内容:![图 2.35:基于动作分布的转移矩阵]()
图 2.35:基于动作分布的转移矩阵
因此,转移矩阵是从状态
到状态
的转移概率,这取决于策略选择的动作和由 MDP 定义的转移函数。
按照相同的步骤,我们也可以找到动作值函数的矩阵形式:

图 2.36:动作值函数的矩阵形式方程
这里,
是一个列向量,包含有
个条目。
是即时奖励的向量,形状与
相同。
是转移矩阵,形状为
行和
列。
表示每个状态的状态值。
和
的显式形式如下:

图 2.37:动作值函数和转移函数的显式矩阵形式
在这里,状态
相关的动作数量由
表示,因此是
。MDP 的动作数量是通过对每个状态关联的动作数量求和得出的。
现在,让我们实现对学生 MDP 示例的状态-和动作-值函数的理解。在这个例子中,我们将使用 图 2.21 中的状态值函数和动作值函数计算。我们将考虑一个尚未决定的学生,也就是对于每个状态都有随机策略的学生。这意味着每个状态的每个动作的概率正好是 0.5。
我们将在接下来的示例中考察一个近视学生的不同情况。
按如下方式导入所需的库:
import numpy as np
from scipy import linalg
定义环境属性:
n_states = 6
# transition matrix together with policy
P_pi = np.zeros((n_states, n_states))
R = np.zeros_like(P_pi)
P_pi 包含了转移矩阵和代理的策略的贡献。R 是奖励矩阵。
我们将使用以下状态编码:
-
0: 类别 1 -
1: 类别 2 -
2: 类别 3 -
3: 社交 -
4: 酒吧 -
5: 床
按照随机策略创建转移矩阵:
P_pi[0, 1] = 0.5
P_pi[0, 3] = 0.5
P_pi[1, 2] = 0.5
P_pi[1, 5] = 0.5
P_pi[2, 4] = 0.5
P_pi[2, 5] = 0.5
P_pi[4, 5] = 0.5
P_pi[4, 0] = 0.5
P_pi[3, 0] = 0.5
P_pi[3, 3] = 0.5
P_pi[5, 5] = 1
打印 P_pi:
P_pi
输出将如下所示:
array([[0\. , 0.5, 0\. , 0.5, 0\. , 0\. ],
[0\. , 0\. , 0.5, 0\. , 0\. , 0.5],
[0\. , 0\. , 0\. , 0\. , 0.5, 0.5],
[0.5, 0\. , 0\. , 0.5, 0\. , 0\. ],
[0.5, 0\. , 0\. , 0\. , 0\. , 0.5],
[0\. , 0\. , 0\. , 0\. , 0\. , 1\. ]])
创建奖励矩阵 R:
R[0, 1] = -2
R[0, 3] = -1
R[1, 2] = -2
R[1, 5] = 0
R[2, 4] = 15
R[2, 5] = 10
R[4, 5] = 10
R[4, 0] = -10
R[3, 3] = -1
R[3, 0] = -3
打印 R:
R
输出将如下所示:
array([[ 0., -2., 0., -1., 0., 0.],
[ 0., 0., -2., 0., 0., 0.],
[ 0., 0., 0., 0., 15., 10.],
[ -3., 0., 0., -1., 0., 0.],
[-10., 0., 0., 0., 0., 10.],
[ 0., 0., 0., 0., 0., 0.]])
作为一个概率矩阵,P_pi 的所有列的总和应为 1:
# check the correctness of P_pi
assert((np.sum(P_pi, axis=1) == 1).all())
应该验证该断言。
我们现在可以使用 R 和 P_pi 来计算每个状态的期望奖励:
# expected reward for each state
R_expected = np.sum(P_pi * R, axis=1, keepdims=True)
R_expected
期望奖励,在这种情况下如下所示:
array([[-1.5],
[-1\. ],
[12.5],
[-2\. ],
[ 0\. ],
[ 0\. ]])
R_expected 向量包含每个状态的期望即时奖励。
我们准备好求解贝尔曼方程,以找出每个状态的值。为此,我们可以使用 scipy.linalg.solve:
# Now it is possible to solve the Bellman Equation
gamma = 0.9
A = np.eye(n_states, n_states) - gamma * P_pi
B = R_expected
# solve using scipy linalg
V = linalg.solve(A, B)
V
向量 V 包含以下值:
array([[-1.78587056],
[ 4.46226255],
[12.13836121],
[-5.09753046],
[-0.80364175],
[ 0\. ]])
这是状态值的向量。状态 0 的值为 -1.7,状态 1 的值为 4.4,以此类推:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_38.jpg)
图 2.38:学生 MDP 的状态值 
让我们检查一下结果是如何随着
的变化而变化的,这是为近视随机学生假设的条件:
gamma = 0.
A = np.eye(n_states, n_states) - gamma * P_pi
B = R_expected
# solve using scipy linalg
V_gamma_zero = linalg.solve(A, B)
V_gamma_zero
输出将如下所示:
array([[-1.5],
[-1\. ],
[12.5],
[-2\. ],
[ 0\. ],
[ 0\. ]])
其视觉表示如下:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_39.jpg)
图 2.39:学生 MDP 的状态值 γ=0
如你所见,使用
,每个状态的值正好等于根据策略计算出的期望即时奖励。
现在我们可以计算动作值函数。我们需要使用一个不同形式的即时奖励,使用一个形状为
的矩阵。每一行对应一个状态-动作对,值为该对的即时奖励:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_40.jpg)
图 2.40:即时奖励
按如下方式将其转换为代码:
R_sa = np.zeros((n_states*2, 1))
R_sa[0] = -2 # study in state 0
R_sa[1] = -1 # social in state 0
R_sa[2] = -2 # study in state 1
R_sa[3] = 0 # sleep in state 1
R_sa[4] = 10 # sleep in state 2
R_sa[5] = +15 # beer in state 2
R_sa[6] = -1 # social in state 3 (social)
R_sa[7] = -3 # study in state 3 (social)
R_sa[8] = 10 # sleep in state 4 (pub)
R_sa[9] = -10 # study in state 4 (pub)
R_sa.shape
输出将如下所示:
(10, 1)
我们现在需要定义学生 MDP 的转移矩阵。转移矩阵包含了从一个状态和一个动作出发,到达给定状态的概率。在行中,我们有源状态和动作,在列中,我们有目标状态:

图 2.41:学生 MDP 的转移矩阵
当将概率转移矩阵转换为代码时,您应该看到以下内容:
# Transition Matrix (states x action, states)
P = np.zeros((n_states*2, n_states))
P[0, 1] = 1 # study in state 0 -> state 1
P[1, 3] = 1 # social in state 0 -> state 3
P[2, 2] = 1 # study in state 1 -> state 2
P[3, 5] = 1 # sleep in state 1 -> state 5 (bed)
P[4, 5] = 1 # sleep in state 2 -> state 5 (bed)
P[5, 4] = 1 # beer in state 2 -> state 4 (pub)
P[6, 3] = 1 # social in state 3 -> state 3 (social)
P[7, 0] = 1 # study in state 3 -> state 0 (Class 1)
P[8, 5] = 1 # sleep in state 4 -> state 5 (bed)
P[9, 0] = 1 # study in state 4 -> state 0 (class 1)
我们现在可以使用
来计算动作-价值函数:
gamma = 0.9
Q_sa_pi = R_sa + gamma * P @ V
Q_sa_pi
动作-价值向量包含以下值:
array([[ 2.01603629],
[ -5.58777741],
[ 8.92452509],
[ 0\. ],
[ 10\. ],
[ 14.27672242],
[ -5.58777741],
[ -4.60728351],
[ 10\. ],
[-11.60728351]])
Q_sa_pi是动作-价值向量。对于每一对状态-动作,我们都有该状态下的动作价值。动作-价值函数在下图中表示。动作价值通过
表示:

图 2.42:学生 MDP 的动作值
我们现在感兴趣的是为每个状态提取最佳动作:
"""
reshape the column so that we obtain a vector with shape (n_states, n_actions)
"""
n_actions = 2
Q_sa_pi2 = np.reshape(Q_sa_pi, (-1, n_actions))
Q_sa_pi2
输出将如下所示:
array([[ 2.01603629, -5.58777741],
[ 8.92452509, 0\. ],
[ 10\. , 14.27672242],
[ -5.58777741, -4.60728351],
[ 10\. , -11.60728351]])
通过执行argmax函数,我们可以获得每个状态中最佳动作的索引:
best_actions = np.reshape(np.argmax(Q_sa_pi2, -1), (-1, 1))
best_actions
best_actions向量包含以下值:
array([[0],
[0],
[1],
[1],
[0]])
最佳动作可以如下可视化:

图 2.43:学生 MDP 最佳动作
在图 2.43中,虚线箭头表示每个状态中的最佳动作。我们可以通过查看每个状态中最大化q函数的动作,轻松找到它们。
从动作-价值计算中,我们可以看到当
时,动作-价值函数等于期望的即时奖励:
Q_sa_pi_gamma_zero = R_sa
Q_sa_pi_gamma_zero
输出将如下所示:
array([[ -2.],
[ -1.],
[ -2.],
[ 0.],
[ 10.],
[ 15.],
[ -1.],
[ -3.],
[ 10.],
[-10.]])
使用n_actions = 2重新排列列,如下所示:
n_actions = 2
Q_sa_pi_gamma_zero2 = np.reshape(Q_sa_pi_gamma_zero, \
(-1, n_actions))
Q_sa_pi_gamma_zero2
输出将如下所示:
array([[ -2., -1.],
[ -2., 0.],
[ 10., 15.],
[ -1., -3.],
[ 10., -10.]])
通过执行argmax函数,我们可以获得每个状态中最佳动作的索引,如下所示:
best_actions_gamma_zero = np.reshape(np.argmax\
(Q_sa_pi_gamma_zero2, -1), \
(-1, 1))
best_actions_gamma_zero
输出将如下所示:
array([[1],
[1],
[1],
[0],
[0]])
状态图可以如下可视化:

图 2.44:学生 MDP 的最佳动作和动作-价值函数,当
有趣的是,最佳动作是如何仅通过修改折扣因子发生变化的。在这里,代理从Class 1开始时,最佳动作是Social,因为它比Study动作提供了更大的即时奖励。Social动作将代理带到状态Social。在这里,代理能做的最好的事情是重复Social动作,累积负奖励。
在这个例子中,我们学习了如何使用scipy.linalg.solve计算状态-价值函数,以及如何使用矩阵形式计算动作-价值函数。我们注意到,状态值和动作值都依赖于折扣因子。
在下一节中,我们将阐明贝尔曼最优方程,它使得通过找到最佳策略和最佳状态值来解决 MDP 成为可能。
贝尔曼最优方程
自然地,我们会问是否有可能为策略定义一个顺序,来判断一个策略是否优于另一个策略。结果证明,值函数提供了一个对策略的排序。
策略
可以被认为优于或等于
策略
,如果该策略的期望回报在所有状态下大于或等于
的期望回报:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_45.jpg)
图 2.45:策略的偏好
在这个例子中,我们用状态值函数替代了在一个状态中的期望回报,使用了状态值函数的定义。
根据前面的定义,最优策略是指在所有状态下,优于或等于其他所有策略的策略。最优状态值函数,
,和最优动作值函数,
,简单来说,就是与最佳策略相关联的函数:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_46.jpg)
图 2.46:最优状态值函数

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_47.jpg)
图 2.47:最优动作值函数
MDP 的一些重要属性如下:
-
每个状态下总是至少存在一个最优(确定性)策略,最大化状态值函数。
-
所有最优策略共享相同的最优状态值函数。
如果我们知道最优状态值函数和最优动作值函数,则 MDP 已被解决。
通过知道最优值函数
,可以通过最大化
来找到 MDP 的最优策略。我们可以按以下方式定义与最优动作值函数相关联的最优策略:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_48.jpg)
图 2.48:与最优动作值函数相关联的最优策略
正如你所看到的,这个策略实际上是在告诉我们,在动作
能最大化该状态的动作值函数时,以 1 的概率(本质上是确定性的)执行该动作
。换句话说,我们需要采取能保证最高折扣回报的动作,遵循最优策略。所有其他子最优动作的概率为 0,因此基本上是从不执行的。请注意,这种方式得到的策略是确定性的,而不是随机性的。
分析这个结果,我们揭示了两个重要事实:
-
对于任何 MDP,总是存在一个确定性的最优策略。
-
最优策略由最优动作-值函数
的知识决定。
最优值函数与贝尔曼最优性方程相关。贝尔曼最优性方程表明,某一状态下的最优状态值函数等于该状态下最优动作-值函数的整体最大值:

图 2.49:贝尔曼最优性方程
使用动作-值函数的定义,我们可以将前面的方程展开为更显式的形式:

图 2.50:基于动作-值函数的贝尔曼最优性方程
前面的方程告诉我们,某一状态的最优值函数等于对所有动作的即时奖励的最大值,
,加上折扣后的
,即后继状态的期望最优值,
,其中期望值由转移函数确定。
此外,最优动作-值函数具有显式的形式,被称为贝尔曼最优性方程,用于
:

图 2.51:q 的贝尔曼最优性方程
通过使用
与
之间的关系,可以将此公式仅以
的形式重写:

图 2.52:使用
与
之间的关系的贝尔曼最优性方程
贝尔曼最优性方程对于
表达了这样一个事实:最优状态值函数必须等于该状态下最佳动作的期望回报。类似地,贝尔曼最优性方程对于
表达了这样一个事实:最优 q-函数必须等于即时奖励加上根据环境动态折扣后的下一个状态中最佳动作的回报。
求解贝尔曼最优性方程
由于存在最大化操作,贝尔曼最优性方程是非线性的。这意味着在一般情况下我们没有这些方程的封闭形式解。然而,存在许多迭代求解方法,我们将在接下来的部分和章节中进行分析。
主要方法包括值迭代、策略迭代、Q 学习和 SARSA,我们将在后续章节中学习这些方法。
求解 MDPs
现在我们已经充分理解了所有重要的概念和方程,接下来我们将开始求解实际的 MDP 问题。
算法分类
在考虑解决 MDP 的不同算法之前,理解算法家族及其优缺点对我们是有益的。了解主要的算法家族使我们能够根据任务选择正确的算法家族:

图 2.53:强化学习算法的分类
第一个主要区分是基于模型的算法和无模型的算法:
-
基于模型的算法需要了解环境的动态(模型)。这是一个很强的要求,因为环境模型通常是未知的。让我们考虑一个自动驾驶问题。在这里,了解环境动态意味着我们应该准确知道智能体的行为如何影响环境以及下一个状态的分布。这取决于许多因素:街道状况、天气条件、汽车特性等等。对于许多问题,动态是未知的、过于复杂的,或过于不准确,无法成功使用。尽管如此,动态提供了有益的信息来解决任务。
当模型已知时(
模型利用),基于模型的算法比其对手更受欢迎,因为它们的样本效率更高,需要更少的样本来学习良好的策略。在这些情况下,环境模型也可以是未知的;算法本身明确地学习环境模型(
模型学习),并利用它来规划自己的行动。动态规划算法使用这些模型知识来执行自助式学习,即利用先前的估算来估算其他量。 -
无模型算法不需要环境模型。因此,这些类型的算法更适合用于现实世界的应用。请注意,这些算法可能会在内部构建一个环境表示,考虑到环境动态。然而,通常这个过程是隐式的,用户并不关注这些方面。
无模型算法也可以被归类为基于价值的算法或策略搜索算法。
基于价值的算法
基于价值的算法专注于学习动作价值函数和状态价值函数。学习价值函数是通过使用前面章节介绍的贝尔曼方程完成的。一个基于价值的算法示例是 Q 学习,其目标是学习动作价值函数,进而用于控制。深度 Q 网络是 Q 学习的扩展,其中使用神经网络来近似 q 函数。基于价值的算法通常是脱离策略的,这意味着它们可以重用之前收集的样本,这些样本是使用与当前正在优化的策略不同的策略收集的。这是一个非常强大的特性,因为它允许我们在样本方面获得更高效的算法。我们将在第九章中更详细地学习 Q 学习和深度 Q 网络,什么是深度 Q 学习?。
策略搜索算法
策略搜索(PS)方法直接探索策略空间。在 PS 中,强化学习问题被形式化为依赖于策略参数的性能度量最大化。你将在第十一章,基于策略的强化学习方法中更详细地学习 PS 方法和策略梯度。
线性规划
线性规划是一种优化技术,用于具有线性约束和线性目标函数的问题。目标函数描述了需要优化的数量。在强化学习(RL)的情况下,这个数量是所有状态的期望折扣回报,按初始状态分布加权,即在该状态开始一个回合的概率。
当起始状态恰好为 1 时,这简化为优化从初始状态开始的期望折扣回报。
线性规划是一种基于模型并利用模型的技术。因此,使用线性规划求解 MDP 需要对环境动态的完美知识,这转化为对转移概率矩阵
的了解。通过使用线性规划,我们可以通过找到每个状态的最佳状态值来解决 MDP。从对状态值的了解中,我们可以推导出最优的动作值函数。通过这种方式,我们可以为代理找到一个控制策略,并最大化其在给定任务中的表现。
基本思想源自对策略排序的定义;我们希望通过最大化每个状态的价值,按初始状态分布加权,来找到状态价值函数,
,并满足可行性约束:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_54.jpg)
图 2.54:求解 MDP 的线性规划公式
在这里,我们有
个变量和
个约束。变量是每个状态S中的值,
,s为状态空间中的每个状态。
注意,最大化作用由约束承担,而我们需要最小化目标函数,否则,最优解会使所有变量的值无限大,
。
约束基于这样的思想:一个状态的价值必须大于或等于即时奖励加上后继状态的折扣期望值。这对于所有状态和所有动作都必须成立。
由于变量和约束的数量庞大,线性规划技术仅适用于有限状态和有限动作的 MDP。
我们将使用以下符号:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_55.jpg)
图 2.55:线性规划符号
在前述表示法中,c 是目标函数的系数向量,
是上界约束的矩阵,
是关联的系数向量。
在 Python 中,SciPy 提供了 linprog 函数(在 optimize 模块中,scipy.optimize.linprog),该函数根据目标函数和约束条件优化线性规划。
该函数的签名为 scipy.optimize.linprog(c, A_ub, b_ub)。
为了使用上界重新表述问题,我们有以下内容:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_56.jpg)
图 2.56:使用上界的线性规划约束
注意
如需进一步了解线性规划在 MDP 中的应用,请参阅 de Farias, D. P. (2002): 近似动态规划的线性规划方法:理论与应用 一文:www.mit.edu/~pucci/discountedLP.pdf。
让我们现在做一个简单的练习,以加深我们对线性规划的理解。
练习 2.02:使用线性规划确定 MDP 的最佳策略
本次练习的目标是使用线性规划求解下图中的 MDP。在这个 MDP 中,环境模型非常简单,转移函数是确定性的,由动作唯一决定。我们将使用线性规划找到代理所采取的最佳动作(即具有最大奖励的动作),从而确定环境的最佳策略:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_57.jpg)
图 2.57:具有三个状态和两个动作的简单 MDP
线性规划的变量是状态值。系数由初始状态分布给出,在我们这里它是一个确定性函数,因为状态 1 是初始状态。因此,目标函数的系数为 [1, 0, 0]。
我们现在已经准备好解决我们的实际问题:
-
一如既往,导入所需的库:
import numpy as np import scipy.optimize -
定义该问题的状态数、动作数和折扣因子:
# number of states and number of actions n_states = 3 n_actions = 2 -
定义初始状态分布。在我们这里,它是一个确定性函数:
# initial state distribution mu = np.array([[1, 0, 0]]).T # only state 1 mu输出结果如下:
array([[1], [0], [0]]) -
现在我们需要为动作
A构建上界系数:# Build the upper bound coefficients for the action A # define the reward matrix for action A R_A = np.zeros((n_states, 1), np.float) R_A[0, 0] = 1 R_A[1, 0] = 0 R_A[2, 0] = 0 R_A输出结果如下:
array([[1.], [0.], [0.]]) -
定义动作
A的转移矩阵:# Define the transition matrix for action A P_A = np.zeros((n_states, n_states), np.float) P_A[0, 1] = 1 P_A[1, 0] = 1 P_A[2, 1] = 1 P_A输出结果如下:
array([[0., 1., 0.], [1., 0., 0.], [0., 1., 0.]]) -
我们已经准备好为动作
A构建上界矩阵:gamma = 0.9 # Upper bound A matrix for action A A_up_A = gamma * P_A - np.eye(3,3) A_up_A输出结果如下:
array([[-1\. , 0.9, 0\. ], [ 0.9, -1\. , 0\. ], [ 0\. , 0.9, -1\. ]]) -
我们需要对动作
B做同样的事情:# The same for action B # define the reward matrix for action B R_B = np.zeros((n_states, 1), np.float) R_B[0, 0] = 10 R_B[1, 0] = 1 R_B[2, 0] = 10 # Define the transition matrix for action B P_B = np.zeros((n_states, n_states), np.float) P_B[0, 2] = 1 P_B[1, 2] = 1 P_B[2, 2] = 1 # Upper bound A matrix for action B A_up_B = gamma * P_B - np.eye(3,3) A_up_B输出结果如下:
array([[-1\. , 0\. , 0.9], [ 0\. , -1\. , 0.9], [ 0\. , 0\. , -0.1]]) -
我们已经准备好将两个动作的结果进行拼接:
# Upper bound matrix for all actions and all states A_up = np.vstack((A_up_A, A_up_B)) """ verify the shape: number of constraints are equal to |actions| * |states| """ assert(A_up.shape[0] == n_states * n_actions) # Reward vector is obtained by stacking the two vectors R = np.vstack((R_A, R_B)) -
我们现在唯一需要做的就是使用
scipy.optimize.linprog求解线性规划:c = mu b_up = -R # Solve the linear program res = scipy.optimize.linprog(c, A_up, b_up) -
让我们收集结果:
# Obtain the results: state values V_ = res.x V_ V = V_.reshape((-1, 1)) V np.savetxt("solution/V.txt", V)让我们分析一下结果。我们可以看到状态 2 的值是最低的,正如预期的那样。状态 1 和状态 3 的值非常接近,且大约等于 1e+2:
![图 2.58:MDP 的最优价值函数表示]()
图 2.58:MDP 的最优价值函数表示
-
现在我们可以通过计算每个状态-动作对的最优动作-价值函数来计算最优策略:
""" transition matrix. On the rows, we have states and actions, and on the columns, we have the next states """ P = np.vstack((P_A, P_B)) P输出结果如下:
array([[0., 1., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., 1.], [0., 0., 1.]]) -
使用动作-价值公式计算每个状态-动作对的动作值:
""" Use the action value formula to calculate the action values for each state action pair. """ Q_sa = R + gamma * P.dot(V) """ The first three rows are associated to action A, the last three are associated # to action B """ Q_sa输出结果如下:
array([[ 88.32127683], [ 89.99999645], [ 87.32127683], [100.00000622], [ 91.00000622], [100.00000622]]) -
重新构造并使用
argmax函数,以更好地理解最佳动作:Q_sa_2 = np.stack((Q_sa[:3, 0], Q_sa[3:, 0]), axis=1) Q_sa_2输出结果如下:
array([[ 88.32127683, 100.00000622], [ 89.99999645, 91.00000622], [ 87.32127683, 100.00000622]])使用以下代码来更好地理解最佳动作:
best_actions = np.reshape(np.argmax(Q_sa_2, axis=1), (3, 1)) best_actions输出结果如下:
array([[1], [1], [1]])通过直观地检查结果,我们可以看到,动作
B是所有状态的最佳动作,因为它为所有状态获得了最高的 q 值。因此,最优策略决定始终执行动作B。这样,我们将进入状态3,并沿着自环进行,累积大量正奖励:

图 2.59:MDP 的最优策略和最优价值函数表示
最优策略在 图 2.59 中表示。虚线箭头代表每个状态的最佳动作。
注意
要访问此特定部分的源代码,请参考 packt.live/2Arr9rO。
你也可以在线运行这个示例,网址是 packt.live/2Ck6neR。
在这个练习中,我们使用了线性规划技术来解决一个具有有限状态和动作的简单 MDP。通过利用状态-价值函数和动作-价值函数之间的对应关系,我们提取了每个状态-动作对的价值。根据这些知识,我们提取了该环境的最优策略。在这种情况下,最好的策略始终是执行动作 B。
在下一个活动中,我们将使用贝尔曼期望方程评估一个更复杂任务的策略。在此之前,让我们先了解我们将在活动中使用的环境——网格世界。
网格世界
网格世界是一个经典的强化学习环境,有许多变种。下图展示了该环境的视觉表示:

图 2.60:网格世界环境
如你所见,状态通过网格中的单元格表示,且有 25 个状态按 5 x 5 网格排列。共有四个可用动作:左、右、上、下。这些动作会将当前状态移向动作的方向,并且所有动作的奖励为 0。例外情况如下:
-
边界单元格:如果一个动作将智能体带出网格,智能体的状态不会改变,且智能体将获得 -1 奖励。
-
好单元格:
和
是好单元格。对于这些单元格,每个动作将代理引导到状态
和
,对应的回报分别是:从状态
外移的回报是 +10,从状态
外移的回报是 +5。 -
坏单元格:
和
是坏单元格。对于这些单元格,所有动作的回报都是 -1。
现在我们已经了解了环境,让我们尝试一个实现它的活动。
活动 2.01:解决 Gridworld 问题
在本活动中,我们将处理 Gridworld 环境。活动的目标是计算并可视化一个随机策略下的状态值,其中代理在所有状态下以相等的概率(1/4)选择每个动作。假设折扣因子为 0.9。
以下步骤将帮助你完成该活动:
-
导入所需的库。导入
Enum和auto从enum,matplotlib.pyplot,scipy和numpy,以及从typing导入tuple。 -
定义可视化函数和代理可能的动作。
-
编写一个策略类,该类返回给定状态下的动作概率;对于随机策略,状态可以被忽略。
-
编写一个
Environment类,并实现一个步进函数,该函数根据当前状态和动作返回下一个状态及其相关的回报。 -
对所有状态和动作进行循环,构建一个转换矩阵(宽度 * 高度,宽度 * 高度)和一个相同维度的回报矩阵。转换矩阵包含从一个状态到另一个状态的概率,因此第一轴的所有行的和应该等于 1。
-
使用贝尔曼期望方程的矩阵形式来计算每个状态的状态值。你可以使用
scipy.linalg.solve或直接计算逆矩阵并解系统方程。输出将如下所示:

图 2.61:Gridworld 的状态值
注意
可视化状态值和期望回报是有用的,因此编写一个函数来可视化计算得到的矩阵。
该活动的解答可以在第 689 页找到。
总结
在本章中,我们了解了 MC、MRP 和 MDP 之间的区别。MC 是对通用过程的最简单描述,它由状态和描述状态之间转换的概率函数组成。MRP 包括回报的概念,作为衡量转换质量的标准。MDP 是我们最感兴趣的,它包括动作、策略和目标的概念。
在马尔可夫过程的背景下,我们介绍了不同形式的贝尔曼方程,并分析了状态值函数和动作值函数之间的关系。
我们讨论了解决 MDP 的各种方法,基于所需信息和使用的方法对算法进行了分类。接下来的章节将更详细地介绍这些算法。我们重点介绍了线性规划,展示了如何使用这些技术解决 MDP 问题。
在下一章中,你将学习如何使用 TensorFlow 2 来实现深度学习算法和机器学习模型。
第三章:3. 使用 TensorFlow 2 的深度学习实践
概述
本章将介绍 TensorFlow 和 Keras,并提供它们的主要功能和应用的概述,及其如何协同工作。通过本章,你将能够使用 TensorFlow 实现深度神经网络,涉及的主要主题包括模型创建、训练、验证和测试。你将完成一个回归任务并解决一个分类问题,从而获得使用这些框架的实践经验。最后,你将构建并训练一个模型,以高准确率分类衣物图像。到本章结束时,你将能够设计、构建并训练使用最先进的机器学习框架的深度学习模型。
引言
在上一章中,我们讲解了强化学习(RL)背后的理论,涉及了马尔可夫链和马尔可夫决策过程(MDPs)、贝尔曼方程以及一些可以用来求解 MDPs 的技术。在本章中,我们将探讨深度学习方法,这些方法在构建强化学习的近似函数中起着关键作用。具体来说,我们将研究不同类型的深度神经网络:全连接网络、卷积网络和递归网络。这些算法具备一个关键能力,即通过实例学习编码知识,并以紧凑和有效的方式进行表示。在强化学习中,它们通常用于近似所谓的策略函数和值函数,分别用于表示强化学习代理如何在给定当前状态及与之相关的状态价值的情况下选择行动。我们将在接下来的章节中深入研究策略函数和值函数。
数据是新的石油:这句名言如今越来越频繁地出现在科技和经济领域,特别是在技术和经济行业。随着今天可用的大量数据,如何利用这些庞大的信息量,从中创造价值和机会,已成为关键的竞争因素和必须掌握的技能。所有免费提供给用户的产品和平台(从社交网络到与可穿戴设备相关的应用)都利用用户提供的数据来创造收入:想想他们每天收集的关于我们的习惯、偏好,甚至体重趋势的庞大信息量。这些数据提供了高价值的见解,广告商、保险公司和本地企业可以利用这些数据来改进他们的产品和服务,使其更符合市场需求。
由于计算能力的显著提升以及基于反向传播的训练方法等理论突破,深度学习在过去 10 年里经历了爆炸式的发展,在许多领域取得了前所未有的成果,从图像处理到语音识别,再到自然语言处理与理解。实际上,现在可以通过利用海量数据并克服过去几十年阻碍其普及的实际障碍,成功地训练大规模和深度的神经网络。这些模型展现了在速度和准确性方面超越人类的能力。本章将教你如何利用深度学习框架解决实际问题。TensorFlow 和 Keras 是行业中事实上的生产标准。它们的成功主要与两个方面有关:TensorFlow 在生产环境中的无与伦比的性能,特别是在速度和可扩展性方面,以及 Keras 的易用性,它提供了一个非常强大、高级的接口,可以用来创建深度学习模型。
现在,让我们来看看这些框架。
TensorFlow 和 Keras 简介
本节将介绍这两个框架,为你提供它们的架构概览、组成的基本元素,并列出一些典型的应用场景。
TensorFlow
TensorFlow 是一个开源的数值计算软件库,利用数据流计算图进行运算。其架构使得用户能够在各种硬件平台上运行,包括从 CPU 到张量处理单元(TPUs),以及 GPU、移动设备和嵌入式平台。三者之间的主要区别在于计算速度和它们能够执行的计算类型(如乘法和加法),这些差异在追求最大性能时至关重要。
注意
在本章的Keras部分,我们将查看一些针对 TensorFlow 的代码实现示例。
你可以通过以下链接参考 TensorFlow 的官方文档,获取更多信息:www.tensorflow.org/
如果你希望了解更多关于 GPU 与 TPU 之间差异的内容,以下文章是一个非常好的参考:iq.opengenus.org/cpu-vs-gpu-vs-tpu/
TensorFlow 基于一个高性能的核心,该核心用 C++ 实现,并由一个分布式执行引擎提供支持,该引擎作为对其支持的众多设备的抽象。我们将使用最近发布的 TensorFlow 2 版本,它代表了 TensorFlow 的一个重要里程碑。与版本 1 相比,它的主要区别在于更高的易用性,特别是在模型构建方面。事实上,Keras 已成为用来轻松创建模型并进行实验的主要工具。TensorFlow 2 默认使用即时执行(eager execution)。这使得 TensorFlow 的创建者能够消除以前基于构建计算图并在会话中执行的复杂工作流程。通过即时执行,这一步骤不再是必须的。最后,数据管道通过 TensorFlow 数据集得到了简化,这是一个常见的接口,用于引入标准或自定义数据集,无需定义占位符。
执行引擎接着与 Python 和 C++ 前端接口,这些前端是深度学习模型常见层的 API 接口——层 API 的基础。这个层次结构继续向更高级的 API 发展,包括 Keras(我们将在本节后面描述)。最后,还提供了一些常见的模型,可以开箱即用。
以下图表概述了不同 TensorFlow 模块的层次结构,从最低层(底部)到最高层(顶部):

图 3.1:TensorFlow 架构
TensorFlow 的历史执行模型基于计算图。使用这种方法,构建模型的第一步是创建一个完整描述我们要执行的计算的计算图。第二步是执行计算图。此方法的缺点是,相比常见的实现,它不够直观,因为在执行之前,图必须是完整的。与此同时,这种方法也有许多优点,使得算法具有高度的可移植性,能够部署到不同类型的硬件平台上,并且可以在多个实例上并行运行。
在 TensorFlow 的最新版本中(从 v.1.7 开始),引入了一种新的执行模型,称为“即时执行”(eager execution)。这是一种命令式编程风格,允许编写代码时直接执行所有算法操作,而不需要首先构建计算图再执行。这个新方法受到了热烈的欢迎,并且有几个非常重要的优点:首先,它使得检查和调试算法、更容易访问中间值变得非常简单;其次,可以直接在 TensorFlow API 中使用 Python 控制流;最后,它使得构建和训练复杂算法变得非常容易。
此外,一旦使用即时执行(eager execution)创建的模型满足要求,就可以将其自动转换为图形,这样就能够利用我们之前讨论的所有优点,如模型保存、迁移和最优分发等。
与其他机器学习框架一样,TensorFlow 提供了大量现成的模型,并且对于许多模型,它还提供了训练好的模型权重和模型图,这意味着我们可以直接运行这些模型,甚至为特定的应用场景进行调整,利用诸如迁移学习和微调等技术。我们将在接下来的章节中介绍这些内容。
提供的模型涵盖了广泛的不同应用,例如:
-
图像分类:能够将图像分类到不同类别中。
-
物体检测:能够在图像中检测和定位多个物体。
-
语言理解与翻译:执行自然语言处理任务,如单词预测和翻译。
-
补丁协调与风格迁移:该算法能够将特定的风格(例如通过画作表现的风格)应用到给定的照片上(参见以下示例)。
正如我们之前提到的,许多模型都包括训练好的权重和使用说明示例。因此,采用“迁移学习”变得非常简单,也就是通过创建新的模型并仅在新数据集上对网络的一部分进行再训练,从而利用这些预训练的模型。这相比于从零开始训练整个网络要小得多。
TensorFlow 模型也可以部署到移动设备上。在大型系统上进行训练后,它们经过优化,以减小其占用空间,确保不会超出平台的限制。例如,TensorFlow 项目中的 MobileNet 正在开发一套专为优化速度/准确度权衡的计算机视觉模型。这些模型通常用于嵌入式设备和移动应用。
以下图像展示了一个典型的物体检测应用示例,其中输入图像被处理,检测到了三个物体,并进行了定位和分类:

图 3.2:物体检测
以下图像展示了风格迁移的工作原理:著名画作《神奈川冲浪里》的风格被应用到了西雅图天际线的照片上。结果保持了图像的关键部分(大部分建筑物、山脉等),但通过从参考图像中提取的风格元素进行了呈现:

图 3.3:风格迁移
现在,让我们来了解一下 Keras。
Keras
构建深度学习模型相当复杂,特别是当我们需要处理所有主要框架的典型底层细节时,这也是机器学习领域新手面临的最相关障碍之一。例如,以下代码展示了如何使用低级 TensorFlow API 创建一个简单的神经网络(一个隐藏层,输入大小为100,输出大小为10)。
在以下代码片段中,定义了两个函数。第一个构建了一个网络层的权重矩阵,而第二个创建了偏置向量:
def weight_variable(shape):
shape = tf.TensorShape(shape)
initial_values = tf.truncated_normal(shape, stddev=0.1)
return tf.Variable(initial_values)
def bias_variable(shape):
initial_values = tf.zeros(tf.TensorShape(shape))
return tf.Variable(initial_values)
接下来,创建输入(X)和标签(y)的占位符。它们将包含用于拟合模型的训练样本:
# Define placeholders
X = tf.placeholder(tf.float32, shape=[None, 100])
y = tf.placeholder(tf.int32, shape=[None, 10])
创建两个矩阵和两个向量,每对分别对应网络中要创建的两个隐藏层,使用之前定义的函数。这些将包含可训练参数(网络权重):
# Define variables
w1 = weight_variable([X_input.shape[1], 64])
b1 = bias_variable([64])
w2 = weight_variable([64, 10])
b2 = bias_variable([10])
通过它们的数学定义来定义两个网络层:矩阵乘法,加上偏置和应用于结果的激活函数:
# Define network
# Hidden layer
z1 = tf.add(tf.matmul(X, w1), b1)
a1 = tf.nn.relu(z1)
# Output layer
z2 = tf.add(tf.matmul(a1, w2), b2)
y_pred = tf.nn.softmax(z2)
y_one_hot = tf.one_hot(y, 10)
loss 函数已经定义,优化器已初始化,训练指标已选择。最后,执行图形以进行训练:
# Define loss function
loss = tf.losses.softmax_cross_entropy(y, y_pred, \
reduction=tf.losses.Reduction.MEAN)
# Define optimizer
optimizer = tf.train.AdamOptimizer(0.01).minimize(loss)
# Metric
accuracy = tf.reduce_mean(tf.cast(tf.equal(tf.argmax(y, axis=1), \
tf.argmax(y_pred, axis=1)), tf.float32))
for _ in range(n_epochs):
sess.run(optimizer, feed_dict={X: X_train, y: y_train})
如你所见,我们需要手动管理许多不同的方面:变量声明、权重初始化、层创建、层相关的数学操作,以及损失函数、优化器和指标的定义。为了比较,本节后面将使用 Keras 创建相同的神经网络。
注意
Exercise 3.01, Building a Sequential Model with the Keras High-Level API, you will see how much more straightforward it is to do the same job using a Keras high-level API.
在众多不同的提议中,Keras 已经成为高层 API 的主要参考之一,尤其是在创建神经网络的上下文中。它是用 Python 编写的,可以与不同的后端计算引擎进行接口,其中一个引擎当然是 TensorFlow。
注意
你可以参考 Keras 的官方文档进行进一步阅读:keras.io/。
Keras 的设计理念遵循了一些明确的原则,特别是模块化、用户友好、易于扩展,并且与 Python 的直接集成。它的目标是促进新手和非经验用户的采用,并且具有非常平缓的学习曲线。它提供了许多不同的独立模块,从神经网络层到优化器,从初始化方案到成本函数。这些模块可以轻松创建,以便快速构建深度学习模型,并直接用 Python 编码,无需使用单独的配置文件。鉴于这些特点,Keras 的广泛应用,以及它能够与大量不同的后端引擎(例如 TensorFlow、CNTK、Theano、MXNet 和 PlaidML)进行接口,并提供多种部署选项,它已经成为该领域的标准选择。
由于它没有自己的低级实现,Keras 需要依赖外部元素。这可以通过编辑(对于 Linux 用户)$HOME/.keras/keras.json文件轻松修改,在该文件中可以指定后端名称。也可以通过KERAS_BACKEND环境变量指定。
Keras 的基础类是Model。有两种不同类型的模型可供选择:顺序模型(我们将广泛使用)和Model类,它与功能性 API 一起使用。
顺序模型可以看作是层的线性堆叠,层与层之间按非常简单的方式一层接一层堆叠,且这些层可以非常容易地描述。以下练习展示了如何通过 Python 脚本在 Keras 中使用model.add()构建一个深度神经网络,以定义一个顺序模型中的两个密集层。
练习 3.01:使用 Keras 高级 API 构建顺序模型
本练习演示了如何一步步使用 Keras 高级 API 轻松构建一个包含两个密集层的顺序模型:
-
导入 TensorFlow 模块并打印其版本:
import tensorflow as tf from __future__ import absolute_import, division, \ print_function, unicode_literals import tensorflow as tf print("TensorFlow version: {}".format(tf.__version__))这将输出以下内容:
TensorFlow version: 2.1.0 -
使用 Keras 的
sequential和add方法构建模型并打印网络摘要。为了与低级 API 并行使用,使用了相同的激活函数。这里我们使用ReLu,它是典型的用于隐藏层的激活函数。它是一个关键元素,通过其非线性形状为模型提供了非线性特性。我们还使用Softmax,它是典型用于分类问题中输出层的激活函数。它接收来自前一层的输出值(所谓的“logits”),并对其进行加权,定义所有输出类别的概率。input_dim是输入特征向量的维度,假设其维度为100:model = tf.keras.Sequential() model.add(tf.keras.layers.Dense(units=64, \ activation='relu', input_dim=100)) model.add(tf.keras.layers.Dense(units=10, activation='softmax')) -
打印标准的模型架构:
model.summary()在我们的案例中,网络模型的摘要如下:
Model: "sequential_1" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= dense_2 (Dense) (None, 64) 6464 _________________________________________________________________ dense_3 (Dense) (None, 10) 650 ================================================================= Total params: 7,114 Trainable params: 7,114 Non-trainable params: 0 _________________________________________________________________上述输出是一个有用的可视化,帮助我们清楚地了解各个层次、它们的类型和形状,以及网络参数的数量。
注意
要访问此特定部分的源代码,请参考
packt.live/30A9Dw9。你还可以在
packt.live/3cT0cKL在线运行这个示例。
正如预期的那样,这个练习向我们展示了如何创建一个顺序模型,并如何以非常简单的方式向其中添加两个层。
我们将在后续处理中解决其余方面,但仍值得注意的是,训练我们刚创建的模型并进行推理只需要非常少的代码行,如以下代码片段所示,代码片段需要附加到练习 3.01,使用 Keras 高级 API 构建顺序模型的代码片段中:
model.compile(loss='categorical_crossentropy', optimizer='sgd', \
metrics=['accuracy'])
model.fit(x_train, y_train, epochs=5, batch_size=32)
loss_and_metrics = model.evaluate(x_test, y_test, batch_size=128)
classes = model.predict(x_test, batch_size=128)
如果需要更复杂的模型,顺序 API 就显得过于有限。针对这些需求,Keras 提供了功能性 API,它允许我们创建能够管理复杂网络图的模型,例如具有多个输入和/或多个输出的网络、数据处理不是顺序的而是循环的递归神经网络(RNN),以及上下文,其中层的权重在网络的不同部分之间共享。为此,Keras 允许我们使用与顺序模型相同的层集,但在组合层时提供了更多的灵活性。首先,我们必须定义层并将它们组合在一起。以下代码片段展示了一个例子。
首先,在导入 TensorFlow 后,创建一个维度为784的输入层:
import tensorflow as tf
inputs = tf.keras.layers.Input(shape=(784,))
输入由第一个隐藏层处理。它们通过 ReLu 激活函数,并作为输出返回。这个输出然后成为第二个隐藏层的输入,第二个隐藏层与第一个完全相同,并返回另一个输出,依然存储在x变量中:
x = tf.keras.layers.Dense(64, activation='relu')(inputs)
x = tf.keras.layers.Dense(64, activation='relu')(x)
最终,x变量作为输入进入最终的输出层,该层具有softmax激活函数,并返回预测值:
predictions = tf.keras.layers.Dense(10, activation='softmax')(x)
一旦完成所有步骤,模型就可以通过告诉 Keras 模型的起始点(输入变量)和终止点(预测变量)来创建:
model = tf.keras.models.Model(inputs=inputs, outputs=predictions)
在模型构建完成后,通过指定优化器、损失函数和评估指标来编译模型。最后,它会拟合到训练数据上:
model.compile(optimizer='rmsprop', \
loss='categorical_crossentropy', \
metrics=['accuracy'])
model.fit(data, labels) # starts training
Keras 提供了大量的预定义层,并且可以编写自定义层。在这些层中,以下是已提供的层:
-
全连接层,通常用于全连接神经网络。它们由一个权重矩阵和一个偏置组成。
-
卷积层是由特定内核定义的滤波器,然后与应用的输入进行卷积。它们适用于不同输入维度,从 1D 到 3D,包括可以在其中嵌入复杂操作的选项,如裁剪或转置。
-
局部连接层与卷积层类似,因为它们仅作用于输入特征的子集,但与卷积层不同,它们不共享权重。
-
池化层是用于降低输入尺寸的层。作为卷积层,它们适用于维度从 1D 到 3D 的输入。它们包括大多数常见的变种,例如最大池化和平均池化。
-
循环层用于递归神经网络,其中一个层的输出也会被反向传递到网络中。它们支持先进的单元,如门控递归单元(GRU)、长短期记忆(LSTM)单元等。
-
激活函数也可以作为层的形式存在。这些函数应用于层的输出,如
ReLu、Elu、Linear、Tanh和Softmax。 -
Lambda 层是用于嵌入任意用户定义表达式的层。
-
Dropout 层是特殊对象,它会在每次训练更新时随机将一部分输入单元设为
0,以避免过拟合(稍后会详细介绍)。 -
噪声层是额外的层,例如 dropout,旨在避免过拟合。
Keras 还提供了常见的数据集和著名的模型。对于与图像相关的应用,许多网络是可用的,例如 Xception、VGG16、VGG19、ResNet50、InceptionV3、InceptionResNetV2、MobileNet、DenseNet、NASNet 和 MobileNetV2TK,它们都在 ImageNet 上进行了预训练。Keras 还提供文本和序列及生成模型,总共超过 40 种算法。
正如我们看到的,对于 TensorFlow,Keras 模型有广泛的部署平台选择,包括通过 CoreML(Apple 支持)在 iOS 上;通过 TensorFlow Android 运行时在 Android 上;通过 Keras.js 和 WebDNN 在浏览器中;通过 TensorFlow-Serving 在 Google Cloud 上;在 Python Web 应用后端;通过 DL4J 模型导入在 JVM 上;以及在 Raspberry Pi 上。
现在我们已经了解了 TensorFlow 和 Keras,从下一部分开始,我们的主要焦点将是如何将它们结合使用来创建深度神经网络。Keras 将作为高级 API 使用,因其用户友好性,而 TensorFlow 将作为后端。
如何使用 TensorFlow 实现神经网络
在这一部分,我们将讨论实现深度神经网络时需要考虑的最重要的方面。从最基本的概念开始,我们将经历所有步骤,直到创建出最先进的深度学习模型。我们将涵盖网络架构的定义、训练策略和性能提升技术,理解它们如何工作,并为你准备好,帮助你完成接下来的练习,在那里这些概念将被应用来解决现实世界的问题。
为了成功实现 TensorFlow 中的深度神经网络,我们必须完成一定数量的步骤。这些步骤可以总结并分组如下:
-
模型创建:网络架构定义、输入特征编码、嵌入、输出层
-
模型训练:损失函数定义、优化器选择、特征标准化、反向传播
-
模型验证:策略和关键元素
-
模型优化:防止过拟合的对策
-
模型测试和推理:性能评估和在线预测
让我们详细了解每一个步骤。
模型创建
第一步是创建一个模型。选择架构几乎不能 先验 在纸面上完成。这是一个典型的过程,需要实验,反复在模型设计和领域验证、测试之间进行调整。这是所有网络层创建并正确链接的阶段,生成一个完整的处理操作集,从输入到输出。
最底层是与输入数据接口的层,特别是所谓的“输入特征”。例如,在图像的情况下,输入特征就是图像像素。根据层的性质,输入特征的维度需要被考虑。在接下来的章节中,你将学习如何根据层的性质选择层的维度。
最后一层叫做输出层。它生成模型的预测,因此它的维度取决于问题的性质。例如,在分类问题中,模型必须预测一个给定实例属于哪个类别(假设是 10 个类别中的一个),输出层将有 10 个神经元,每个神经元提供一个得分(每个类别一个)。在接下来的章节中,我们将说明如何创建具有正确维度的输出层。
在第一层和最后一层之间,是中间层,称为隐藏层。这些层构成了网络架构,并负责模型的核心处理能力。目前为止,还没有可以用来选择最佳网络架构的规则;这个过程需要大量的实验,并且需要遵循一些通用原则的指导。
一种非常强大且常见的方法是利用来自学术论文的经过验证的模型,作为起点,然后根据具体问题适当调整架构并进行微调。当使用预训练的文献模型并进行微调时,这个过程被称为“迁移学习”,意味着我们利用已经训练好的模型并将其知识转移到新模型中,后者就不需要从头开始训练了。
一旦模型被创建,所有参数(权重/偏置)必须初始化(对于所有未预训练的层)。你可能会想将它们都设为零,但这并不是一个好选择。有许多不同的初始化方案可以使用,而选择哪一种需要经验和实验。在接下来的章节中,这一点会变得更清楚。实现将依赖于 Keras/TensorFlow 执行的默认初始化,通常这是一个好的且安全的起点。
模型创建的典型代码示例可以在下面的代码片段中看到,这是我们在前一节中学习过的:
inputs = tf.keras.layers.Input(shape=(784,))
x = tf.keras.layers.Dense(64, activation='relu')(inputs)
x = tf.keras.layers.Dense(64, activation='relu')(x)
predictions = tf.keras.layers.Dense(10, activation='softmax')(x)
model = tf.keras.models.Model(inputs=inputs, outputs=predictions)
模型训练
当模型被初始化并应用于输入数据而没有经过训练阶段时,它输出的是随机值。为了提高性能,我们需要调整其参数(权重),以最小化误差。这是模型训练阶段的目标,包含以下步骤:
-
首先,我们必须评估模型在给定参数配置下的“错误”程度,通过计算所谓的“损失”,即模型预测误差的度量。
-
第二步,计算一个高维梯度,告诉我们模型需要在哪个方向上调整参数,以提高当前性能,从而最小化损失函数(这确实是一个优化过程)。
-
最后,通过沿负梯度方向“步进”(遵循一些精确规则),更新模型参数,并且整个过程从损失评估阶段重新开始。
这个过程会重复进行,直到系统收敛并且模型达到最佳性能(最小损失)。
下面是一个典型的模型训练代码示例,我们在之前的章节中已经学习过:
model.compile(optimizer='rmsprop', \
loss='categorical_crossentropy', \
metrics=['accuracy'])
model.fit(data, labels) # starts training
损失函数定义
模型错误可以通过不同的损失函数来度量。如何选择最好的损失函数需要经验。对于复杂的应用,通常需要仔细调整损失函数,以引导训练朝着我们感兴趣的方向进行。举个例子,让我们看看如何定义一个常用于分类问题的典型损失:稀疏类别交叉熵。在 Keras 中创建它,我们可以使用以下指令:
loss_CatCrossEntropy = tf.keras.losses\
.SparseCategoricalCrossentropy()
该函数操作于两个输入:真实标签和预测标签。根据它们的值,计算与模型相关的损失:
loss_CatCrossEntropy(y_true=groundTruth, y_pred=predictions)
优化器选择
第二步和第三步,分别是估算梯度和更新参数,这由优化器处理。这些对象计算梯度并执行沿梯度方向的更新步骤,以最小化模型损失。可以选择许多优化器,从最简单的到最先进的(参见下图)。它们提供了不同的性能,选择哪一个,仍然是经验和试错的过程。举个例子,下面的代码选择了Adam优化器,并为其指定了0.01的学习率。该参数调节沿梯度方向“步进”的大小:
optimizer = tf.keras.optimizers.Adam(learning_rate=0.01)
optimizer = tf.keras.optimizers.Adadelta(learning_rate=0.01)
optimizer = tf.keras.optimizers.Adagrad(learning_rate=0.01)
optimizer = tf.keras.optimizers.Adamax(learning_rate=0.01)
optimizer = tf.keras.optimizers.Ftrl(learning_rate=0.01)
optimizer = tf.keras.optimizers.Nadam(learning_rate=0.01)
optimizer = tf.keras.optimizers.RMSprop(learning_rate=0.01)
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01)
以下图表是一个即时快照,比较了不同优化器的表现。它显示了它们是如何快速地朝着最小值移动的,所有优化器同时开始。我们可以看到一些优化器比其他的更快:

图 3.4:优化器最小化步骤比较
注意
前面的图表由 Alec Radford 创建(twitter.com/alecrad)。
学习率调度
在大多数情况下,对于大多数深度学习模型,最佳效果通常是在训练过程中逐渐减小学习率。这个原因可以从以下图表中看到:

图 3.5:使用不同学习率值时的优化行为
当接近损失函数的最小值时,我们希望采取越来越小的步伐,以高效地达到超维凹形的最底部。
使用 Keras,可以通过调度器为学习率的趋势在各个 epoch 中指定许多不同的递减函数。一种常见的选择是 InverseTimeDecay。它可以通过以下方式实现:
lr_schedule = tf.keras.optimizers.schedules\
.InverseTimeDecay(0.001,\
decay_steps=STEPS_PER_EPOCH*1000,\
decay_rate=1, staircase=False)
上述代码通过 InverseTimeDecay 设置了一个递减函数,采用双曲线方式使学习率在 1,000 个 epoch 时降至基础学习率的 1/2,在 2,000 个 epoch 时降至 1/3,依此类推。以下图表展示了这一变化:

图 3.6:反时间衰减学习率调度
然后,它被作为参数应用于优化器,如下所示的 Adam 优化器代码片段所示:
tf.keras.optimizers.Adam(lr_schedule)
每次优化步骤都会使损失减少,从而改进模型。然后可以重复相同的过程,直到收敛并且损失停止减少。执行的优化步骤次数通常被称为 epoch 数。
特征归一化
深度神经网络的广泛应用使得它们能够处理各种不同类型的输入,从图像像素到信用卡交易历史,从社交账号的个人资料习惯到音频记录。从这些可以看出,原始输入特征的数值范围差异很大。如前所述,训练这些模型需要通过损失梯度计算来解决优化问题。因此,数值方面至关重要,它不仅加速了过程,还使其更具鲁棒性。在这种背景下,最重要的实践之一就是特征归一化或标准化。最常见的方法包括对每个特征执行以下步骤:
-
使用所有训练集实例计算均值和标准差。
-
减去均值并除以标准差。计算得出的值必须应用于训练集、验证集和测试集。
通过这种方式,所有特征将具有零均值和标准差为 1。不同但相似的方法会将特征值缩放到用户定义的最小-最大范围(例如,从 -1 到 1),或应用类似的转换(例如,对数缩放)。如同往常一样,在实际应用中,哪种方法更有效是很难预测的,需要经验和反复试验。
以下代码片段展示了如何进行数据归一化,其中计算了原始值的均值和标准差,然后从原始值中减去均值,最后将结果除以标准差:
train_stats = train_dataset.describe()
train_stats = train_stats.transpose()
def norm(x):
return (x - train_stats['mean']) / train_stats['std']
normed_train_data = norm(train_dataset)
模型验证
如前述各节所述,大多数选择需要实验,这意味着我们必须选择一个特定的配置并评估相应模型的表现。为了计算该性能度量,必须将候选模型应用于一组实例,并将其输出与真实值进行比较。根据我们希望比较的不同配置数量,这一过程可能会重复多次。从长远来看,这些配置选择可能会受到用于衡量模型性能的实例集的过度影响。出于这个原因,为了得到最终准确的模型性能度量,必须在一个从未见过的新实例集上进行测试。第一个实例集被称为“验证集”,而最后一个则被称为“测试集”。
在定义训练集、验证集和测试集时,我们可以采取不同的选择,如下所示:
-
70:20:10:初始数据集被分解为三个部分,即训练集、验证集和测试集,比例分别为 70:20:10。
-
80:20 + k-折叠:初始数据集被分解为两个部分,分别是 80% 的训练集和 20% 的测试集。通过在训练数据集上使用 k-折叠进行验证:它被分为“k”个折叠,接着在“k-1”个折叠上进行训练,而在第 k 个折叠上进行验证。'K' 的值从 1 到 k 不等,度量标准被平均以获得全局指标。
可以使用许多前述方法的变种。选择严格与问题和可用的数据集相关。
以下代码片段展示了如何在训练数据集上拟合模型时,规定 80:20 的验证集划分:
model.fit(normed_train_data, train_labels, epochs=epochs, \
validation_split = 0.2, verbose=2)
性能度量
除了损失函数外,通常还会采用其他度量标准来衡量性能。可用的度量标准种类繁多,应该使用哪一种取决于许多因素,包括问题类型、数据集特征等。以下是最常用的一些:
-
均方误差 (MSE):用于回归问题。
-
均方绝对误差 (MAE):用于回归问题。
-
准确率:正确预测的数量除以总测试实例的数量。这用于分类问题。
-
接收操作特征曲线下的面积 (ROC AUC):用于二分类,特别是在数据高度不平衡的情况下。
-
其他:Fβ 分数、精准度和召回率。
模型改进
在本节中,我们将介绍一些可以用于提高模型性能的技术。
过拟合
在训练深度神经网络时,我们常常遇到的一个问题是,模型性能(当然是通过验证集或测试集来衡量)会在训练轮次超过某一阈值后急剧下降,即使在此时训练损失仍在减少。这个现象被称为过拟合。可以这样定义:一个非常具代表性的模型,即拥有相关自由度数量的模型(例如,具有多个层和神经元的神经网络),如果被“过度训练”,会趋向于紧贴训练数据,试图最小化训练损失。这会导致较差的泛化性能,从而使验证和/或测试错误增高。深度学习模型由于其高维参数空间,通常非常擅长拟合训练数据,但构建机器学习模型的实际目标是能够泛化已学到的知识,而不仅仅是拟合数据集。
在此阶段,我们可能会倾向于显著减少模型参数的数量,以避免过拟合。但这会引发不同的问题。实际上,参数数量不足的模型会导致欠拟合。基本上,它将无法正确拟合数据,结果会导致性能差,训练集和验证/测试集的表现都会不理想。
正确的解决方案是在训练数据完全拟合的大量参数和具有过小模型自由度、导致无法捕捉数据中的重要信息之间找到适当的平衡。目前无法确定模型的最佳规模,以避免过拟合或欠拟合问题。实验是解决这个问题的关键因素,因此数据工程师需要构建并测试不同的架构。一个好的规则是从参数相对较少的模型开始,然后逐步增加它们,直到泛化性能提升。
对抗过拟合的最佳解决方案是通过新数据丰富训练数据集。目标是完全覆盖模型所支持并期望的输入范围。新数据还应包含额外的信息,以便有效地对比过拟合,并实现更好的泛化误差。当无法收集额外数据或成本过高时,必须采用特定的、非常强大的技术。最重要的技术将在此描述。
正则化
正则化是用来对抗过拟合的最强大工具之一。给定一个网络架构和一组训练数据,有一个包含所有可能权重的空间,它们会产生相同的结果。这个空间中每一个权重组合定义了一个特定的模型。正如我们在前面的章节中看到的,作为一般原则,我们需要偏向简单模型而非复杂模型。实现这一目标的常用方法是强制网络权重采用较小的值,从而对权重的分布进行正则化。这可以通过“权重正则化”来实现。权重正则化通过调整损失函数,使其考虑权重的值,并添加一个与权重大小成正比的新项。常见的两种方法是:
-
L1 正则化:添加到损失函数中的项与权重系数的绝对值成正比,通常被称为权重的“L1 范数”。
-
L2 正则化:添加到损失函数中的项与权重系数值的平方成正比,通常被称为权重的“L2 范数”。
这两者都能限制权重的大小,但 L1 正则化往往会使权重趋向于零,而 L2 正则化则对权重施加较宽松的约束,因为附加的损失项增长得更快。通常情况下,L2 正则化更为常见。
Keras 包含了预构建的 L1 和 L2 正则化对象。用户需要将它们作为参数传递给希望应用该技术的网络层。下面的代码展示了如何将其应用于一个常见的全连接层:
tf.keras.layers.Dense(512, activation='relu', \
kernel_regularizer=tf.keras\
.regularizers.l2(0.001))
传递给 L2 正则化器的参数(0.001)表明,网络中的每个权重系数都会额外添加一个损失项 0.001 * weight_coefficient_value**2,以此来增加网络的总损失。
早停
早停是正则化的一种特定形式。其思想是在训练过程中同时跟踪训练和验证误差,并继续训练模型,直到训练和验证损失都减少为止。这样我们可以找到训练损失下降后的阈值,此时继续训练会以增加泛化误差为代价,因此当验证/测试性能达到最大时,我们可以停止训练。采用此技术时,用户需要选择的一个典型参数是系统在停止迭代前应等待和监控的轮次,如果验证误差没有改善。这个参数通常被称为“耐心”。
Dropout
神经网络中最流行且有效的正则化技术之一是 Dropout。它是由多伦多大学的 Hinton 教授及其研究小组开发的。
当 Dropout 应用到一层时,在训练过程中,该层输出特征的某个百分比会被随机设置为零(它们被丢弃)。例如,如果给定一组输入特征,在训练时某层的输出通常为 [0.3, 0.4, 1.2, 0.1, 1.5],应用 dropout 后,相同的输出向量会有一些零项随机分布,例如 [0.3, 0, 1.2, 0.1, 0]。
dropout 背后的理念是鼓励每个节点输出具有高度信息量和独立意义的值,而不依赖于其邻近的节点。
插入 dropout 层时需要设置的参数为 0.2 和 0.5。在进行推理时,dropout 被停用,需要执行额外的操作,以考虑到相对于训练时会有更多的单元处于激活状态。为了在这两种情况之间重新建立平衡,层的输出值会乘以一个与 dropout 率相等的因子,形成缩放操作。在 Keras 中,可以通过 dropout 层将 dropout 引入网络,它会应用于紧接其前面的层的输出。考虑以下代码片段:
dropout_model = tf.keras.Sequential([
#[...]
tf.keras.layers.Dense(512, activation='relu'), \
tf.keras.layers.Dropout(0.5), \
tf.keras.layers.Dense(256, activation='relu'), \
#[...]
])
如你所见,dropout 被应用于 512 个神经元的层,在训练时将其 50%的值设为 0.0,在推理时将其值乘以 0.5。
数据增强
数据增强在训练实例有限的情况下尤其有用。在图像处理的背景下,理解其实现和工作原理非常简单。假设我们想训练一个网络来分类不同品种的特定物种的图像,而每个品种的示例数量有限。那么,我们如何扩大数据集以帮助模型更好地泛化呢?在这种情况下,数据增强起着重要作用:其理念是从我们已有的数据出发,适当地调整它们,从而生成新的训练实例。在图像的情况下,我们可以通过以下方式对其进行处理:
-
相对于中心附近的某一点进行随机旋转
-
随机裁剪
-
随机仿射变换(剪切、缩放等)
-
随机水平/垂直翻转
-
白噪声叠加
-
盐和胡椒噪声叠加
这些是一些可以用于图像的数据增强技术的示例,当然,在其他领域也有对应的方法。这种方法使得模型更加健壮,并改善其泛化能力,通过赋予最具信息量的输入特征优先权,使其能够以更一般的方式抽象出有关其所面临的特定问题的概念和知识。
批量归一化
批量归一化是一种技术,涉及对每个数据批次应用归一化转换。例如,在训练一个批次大小为 128 的深度网络时,意味着系统将一次处理 128 个训练样本,批量归一化层按以下方式工作:
-
它使用给定批次的所有样本计算每个特征的均值和方差。
-
它从每个批次样本的每个特征中减去先前计算的相应特征均值。
-
它将每个批次样本的每个特征除以相应特征方差的平方根。
批量归一化有许多好处。它最初是为了解决内部协变量偏移问题而提出的。在训练深度网络时,层的参数不断变化,导致内部层必须不断适应和重新调整,以适应来自前一层的新分布。对于深度网络来说,这是特别关键的,因为第一层的小变化会通过网络被放大。对层输出进行归一化有助于限制这些变化,加速训练并生成更可靠的模型。
此外,通过使用批量归一化,我们可以做到以下几点:
-
我们可以采用更高的学习率,而不必担心出现梯度消失或爆炸的问题。
-
我们可以通过改善网络的泛化能力来有利于网络正则化,从而减轻过拟合问题。
-
我们可以使模型对不同的初始化方案和学习率变得更加稳健。
模型测试与推理
一旦模型训练完成并且验证性能令人满意,我们可以进入最终阶段。如前所述,准确的最终模型性能评估要求我们在从未见过的实例集上测试模型:测试集。性能确认后,模型可以投入生产,用于在线推理,此时它将按设计提供服务:新实例将提供给模型,模型将根据它所设计和训练的知识输出预测。
在接下来的子章节中,将描述三种具有特定元素/层的神经网络。它们将提供一些简单的示例,展示在该领域广泛应用的不同技术。
标准全连接神经网络
全连接神经网络一词通常用于表示仅由全连接层组成的深度神经网络。全连接层是指神经元与上一层所有神经元以及下一层所有神经元相连的层,如下图所示:

图 3.7:一个全连接神经网络
本章将主要讨论全连接网络。它们通过一系列中间隐藏层将输入映射到输出。这些架构能够处理各种问题,但在输入维度、层数和神经元数目方面有一定的限制,因为参数数量会随着这些变量的增加而迅速增长。
一个将在稍后遇到的全连接神经网络示例如下所示,该网络使用 Keras API 构建。它通过两个隐藏层(每层包含64个神经元)将输入层(维度等于len(train_dataset.keys()))连接到输出层(维度为1):
model = tf.keras.Sequential([tf.keras.layers.Dense\
(64, activation='relu',\
input_shape=[len(train_dataset.keys())]),\
tf.keras.layers.Dense(64, activation='relu'),\
tf.keras.layers.Dense(1)])
现在,让我们快速完成一个练习,以帮助理解全连接神经网络。
练习 3.02:使用 Keras 高级 API 构建全连接神经网络模型
在本练习中,我们将构建一个全连接神经网络,输入维度为100,包含 2 个隐藏层,输出层为10个神经元。完成此练习的步骤如下:
-
导入
TensorFlow模块并打印其版本:from __future__ import absolute_import, division, \ print_function, unicode_literals import tensorflow as tf print("TensorFlow version: {}".format(tf.__version__))这将输出以下行:
TensorFlow version: 2.1.0 -
使用 Keras
sequential模块创建网络。这允许我们通过将一系列层按顺序堆叠来构建模型。在此特定案例中,我们使用了两个隐藏层和一个输出层:INPUT_DIM = 100 OUTPUT_DIM = 10 model = tf.keras.Sequential([tf.keras.layers.Dense\ (128, activation='relu', \ input_shape=[INPUT_DIM]), \ tf.keras.layers.Dense(256, activation='relu'), \ tf.keras.layers.Dense(OUTPUT_DIM, activation='softmax')]) -
打印摘要以查看模型描述:
model.summary()输出结果如下所示:
Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= dense (Dense) (None, 128) 12928 _________________________________________________________________ dense_1 (Dense) (None, 256) 33024 _________________________________________________________________ dense_2 (Dense) (None, 10) 2570 ================================================================= Total params: 48,522 Trainable params: 48,522 Non-trainable params: 0 _________________________________________________________________
如您所见,模型已经创建,摘要为我们提供了对各层、层类型和形状以及网络参数数量的清晰理解,这在实际构建神经网络时非常有用。
注意
要访问此特定部分的源代码,请参考packt.live/37s1M5w。
您也可以在线运行此示例,访问packt.live/3f9WzSq。
现在,我们继续深入理解卷积神经网络。
卷积神经网络
卷积神经网络(CNN)一词通常指代由以下组成部分组合而成的深度神经网络:
-
卷积层
-
池化层
-
全连接层
卷积神经网络(CNN)最成功的应用之一是在图像和视频处理任务中。实际上,卷积神经网络相比于全连接神经网络,在处理高维输入(如图像)时更加高效。它们也广泛应用于异常检测任务中,常用于自编码器,以及强化学习算法的编码器,特别是策略和值网络。
卷积层可以被认为是对输入层应用(卷积)的一系列滤波器,以生成层的输出。这些层的主要参数是滤波器的数量和卷积核的维度。
池化层减少数据的维度;它们将一层中的神经元群体输出合并为下一层的一个神经元。池化层可以计算最大值(MaxPooling),即从前一层每个神经元群体中选取最大值,或者计算平均值(AveragePooling),即从前一层每个神经元群体中计算平均值。
这些卷积/池化操作将输入信息编码成压缩的表示,直到这些新的深度特征,也称为嵌入,通常作为标准全连接层的输入,出现在网络的最后。经典的卷积神经网络示意图如下所示:

图 3.8:卷积神经网络示意图
以下练习展示了如何使用 Keras 高级 API 创建一个卷积神经网络。
练习 3.03:使用 Keras 高级 API 构建卷积神经网络模型
这个练习将向你展示如何构建一个具有三层卷积层(每层的滤波器数量分别为16、32和64,卷积核大小为3)的卷积神经网络,卷积层与三层MaxPooling层交替,最后是两个全连接层,分别具有512和1个神经元。以下是逐步过程:
-
导入
TensorFlow模块并打印其版本:from __future__ import absolute_import, division, \ print_function, unicode_literals import tensorflow as tf print("TensorFlow version: {}".format(tf.__version__))这将打印出以下行:
TensorFlow version: 2.1.0 -
使用 Keras 的顺序模块创建网络:
IMG_HEIGHT = 480 IMG_WIDTH = 680 model = tf.keras.Sequential([tf.keras.layers.Conv2D\ (16, 3, padding='same',\ activation='relu',\ input_shape=(IMG_HEIGHT, IMG_WIDTH, 3)),\ tf.keras.layers.MaxPooling2D(),\ tf.keras.layers.Conv2D(32, 3, padding='same',\ activation='relu'),\ tf.keras.layers.MaxPooling2D(),\ tf.keras.layers.Conv2D(64, 3, padding='same',\ activation='relu'),\ tf.keras.layers.MaxPooling2D(),\ tf.keras.layers.Flatten(),\ tf.keras.layers.Dense(512, activation='relu'),\ tf.keras.layers.Dense(1)]) model.summary()前面的代码让我们通过一系列层逐个堆叠来构建模型。在这个特定的案例中,三组卷积层和最大池化层之后接着一个展平层和两个全连接层。
这将输出以下模型描述:
Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= conv2d (Conv2D) (None, 480, 680, 16) 448 _________________________________________________________________ max_pooling2d (MaxPooling2D) (None, 240, 340, 16) 0 _________________________________________________________________ conv2d_1 (Conv2D) (None, 240, 340, 32) 4640 _________________________________________________________________ max_pooling2d_1 (MaxPooling2 (None, 120, 170, 32) 0 _________________________________________________________________ conv2d_2 (Conv2D) (None, 120, 170, 64) 18496 _________________________________________________________________ max_pooling2d_2 (MaxPooling2 (None, 60, 85, 64) 0 _________________________________________________________________ flatten (Flatten) (None, 326400) 0 _________________________________________________________________ dense (Dense) (None, 512) 167117312 _________________________________________________________________ dense_1 (Dense) (None, 1) 513 ================================================================= Total params: 167,141,409 Trainable params: 167,141,409 Non-trainable params: 0
因此,我们成功地使用 Keras 创建了一个 CNN。前面的总结为我们提供了有关网络层和不同参数的关键信息。
注意
要访问该特定部分的源代码,请参考packt.live/2AZJqwn。
你还可以在packt.live/37p1OuX上在线运行此示例。
现在我们已经处理了卷积神经网络,让我们关注另一个重要的架构家族:循环神经网络。
循环神经网络
循环神经网络是由特定单元组成的模型,它们与前馈网络类似,能够处理从输入到输出的数据,但与前馈网络不同的是,它们还能够通过反馈回路处理反向数据流。它们的基本设计是使一层的输出被重定向并成为该层的输入,利用特定的内部状态来“记住”先前的状态。
这一特性使得它们特别适合解决具有时间/序列发展的任务。比较 CNN 和 RNN 可以帮助理解它们各自更适合解决哪些问题。CNN 最适合解决局部一致性较强的问题,尤其是在图像/视频处理方面。局部一致性被利用来大幅减少处理高维输入所需的权重数目。而 RNN 则在处理具有时间序列数据的问题时表现最好,这意味着任务可以通过时间序列来表示。这对于自然语言处理或语音识别尤为重要,因为单词和声音只有在特定顺序中才有意义。
递归架构可以被看作是一系列操作,它们非常适合追踪历史数据:

图 3.9:递归神经网络框图
它们最重要的组成部分是 GRU 和 LSTM。这些模块包含专门用于追踪解决任务时重要信息的内部元素和状态。它们都成功地解决了在训练机器学习算法时学习长期依赖性的问题,尤其是在时间数据上。它们通过存储过去数据中的“记忆”来帮助网络对未来进行预测。
GRU 和 LSTM 之间的主要区别在于门的数量、单元的输入和单元状态,后者是构成单元记忆的内部元素。GRU 只有一个门,而 LSTM 有三个门,分别称为输入门、遗忘门和输出门。由于 LSTM 拥有更多的参数,它们比 GRU 更加灵活,但这也使得 LSTM 在内存和时间效率上不如 GRU。
这些网络已经在语音识别、自然语言处理、文本转语音、机器翻译、语言建模以及许多其他类似任务的领域中取得了巨大的进展。
以下是典型 GRU 的框图:

图 3.10:GRU 的框图
以下是典型 LSTM 的框图:

图 3.11:LSTM 的框图
以下练习展示了如何使用 Keras API 创建一个包含 LSTM 单元的递归网络。
练习 3.04:使用 Keras 高级 API 构建一个递归神经网络模型
在本练习中,我们将使用 Keras 高级 API 创建一个递归神经网络。它将具有以下架构:第一层只是一个编码层,使用特定规则对输入特征进行编码,从而生成一组嵌入向量。第二层是一个包含64个 LSTM 单元的层。它们被添加到一个双向包装器中,这个特定的层用于通过将其作用于的单元加倍来加速学习,第一个单元直接使用输入数据,第二个单元则使用反向输入(例如,按从右到左的顺序读取句子中的单词)。然后,输出会被拼接起来。证明这种技术能够生成更快、更好的学习效果。最后,添加了两个全连接层,分别包含64和1个神经元。请按照以下步骤完成本练习:
-
导入
TensorFlow模块并打印其版本:from __future__ import absolute_import, division, \ print_function, unicode_literals import tensorflow as tf print("TensorFlow version: {}".format(tf.__version__))这将输出以下内容:
TensorFlow version: 2.1.0 -
使用 Keras 的
sequential方法构建模型并打印网络摘要:EMBEDDING_SIZE = 8000 model = tf.keras.Sequential([\ tf.keras.layers.Embedding(EMBEDDING_SIZE, 64),\ tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(64)),\ tf.keras.layers.Dense(64, activation='relu'),\ tf.keras.layers.Dense(1)]) model.summary()在前面的代码中,模型通过堆叠连续的层来构建。首先是嵌入层,然后是双向层,它作用于 LSTM 层,最后是模型末尾的两个全连接层。
模型摘要将如下所示:
Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= embedding (Embedding) (None, None, 64) 512000 _________________________________________________________________ bidirectional (Bidirectional (None, 128) 66048 _________________________________________________________________ dense (Dense) (None, 64) 8256 _________________________________________________________________ dense_1 (Dense) (None, 1) 65 ================================================================= Total params: 586,369 Trainable params: 586,369 Non-trainable params: 0 _________________________________________________________________注意
要访问此特定部分的源代码,请参考
packt.live/3cX01OO。你也可以在
packt.live/37nw1ud上在线运行这个例子。
通过了解如何使用 TensorFlow 实现神经网络,接下来的章节将展示如何将所有这些概念结合起来,解决典型的机器学习问题,包括回归和分类问题。
使用 TensorFlow 进行简单回归
本节将逐步解释如何成功解决回归问题。你将学习如何初步查看数据集,以了解其最重要的属性,并了解如何为训练、验证和推理做好准备。接下来,将使用 TensorFlow 通过 Keras API 从零开始构建深度神经网络。然后,训练该模型并评估其性能。
在回归问题中,目标是预测一个连续值的输出,例如价格或概率。在本练习中,将使用经典的 Auto MPG 数据集,并在其上训练一个深度神经网络,以准确预测汽车的燃油效率,使用的特征仅限于以下七个:气缸数、排量、马力、重量、加速度、模型年份和原产地。
数据集可以看作是一个具有八列(七个特征和一个目标值)的表格,并且有与数据集实例数量相同的行数。根据我们在前面的章节中讨论的最佳实践,它将按如下方式划分:20%的实例将用作测试集,剩余的部分将再次按 80:20 的比例划分为训练集和验证集。
作为第一步,将检查训练集中的缺失值,并在需要时进行清理。接下来,将绘制一个展示变量相关性的图表。唯一存在的类别变量将通过独热编码转换为数值形式。最后,所有特征将进行标准化。
然后,将创建深度学习模型。使用三层全连接架构:第一层和第二层各有 64 个节点,而最后一层作为回归问题的输出层,只有一个节点。
标准的损失函数(均方误差)和优化器(RMSprop)将被应用。接下来,将进行训练,分别带有和不带有早停机制,以突出它们对训练和验证损失的不同影响。
最后,模型将应用于测试集,以评估性能并进行预测。
练习 3.05:创建一个深度神经网络来预测汽车的燃油效率
在本练习中,我们将构建、训练并评估一个深度神经网络模型,利用七个汽车特征预测汽车的燃油效率:Cylinders、Displacement、Horsepower、Weight、Acceleration、Model Year 和 Origin。
该过程的步骤如下:
-
导入所有所需模块,并打印出最重要模块的版本:
from __future__ import absolute_import, division, \ print_function, unicode_literals import matplotlib.pyplot as plt import numpy as np import pandas as pd import seaborn as sns import tensorflow as tf print("TensorFlow version: {}".format(tf.__version__))输出结果如下:
TensorFlow version: 2.1.0 -
导入 Auto MPG 数据集,使用 pandas 读取并显示最后五行:
dataset_path = tf.keras.utils.get_file("auto-mpg.data", \ "https://raw.githubusercontent.com/"\ "PacktWorkshops/"\ "The-Reinforcement-Learning-Workshop/master/"\ "Chapter03/Dataset/auto-mpg.data") column_names = ['MPG','Cylinders','Displacement','Horsepower',\ 'Weight', 'Acceleration', 'Model Year', 'Origin'] raw_dataset = pd.read_csv(dataset_path, names=column_names,\ na_values = "?", comment='\t',\ sep=" ", skipinitialspace=True) dataset = raw_dataset.copy() dataset.tail()注意
注意下面字符串中的斜杠。记住,反斜杠(
\)用于将代码拆分到多行,而正斜杠(/)是 URL 的一部分。输出结果如下:
![图 3.12:数据集导入 pandas 后的最后五行]()
图 3.12:数据集导入 pandas 后的最后五行
-
清理数据中的未知值。检查有多少
Not available数据以及其所在位置:dataset.isna().sum()这将产生以下输出:
MPG 0 Cylinders 0 Displacement 0 Horsepower 6 Weight 0 Acceleration 0 Model Year 0 Origin 0 dtype: int64 -
鉴于未知值的行数较少,只需将其删除:
dataset = dataset.dropna() -
对
Origin变量使用独热编码,它是类别型变量:dataset['Origin'] = dataset['Origin']\ .map({1: 'USA', 2: 'Europe', 3: 'Japan'}) dataset = pd.get_dummies(dataset, prefix='', prefix_sep='') dataset.tail()输出结果如下:
![图 3.13:使用独热编码将数据集导入 pandas 后的最后五行]()
图 3.13:使用独热编码将数据集导入 pandas 后的最后五行
-
将数据按 80:20 的比例分为训练集和测试集:
train_dataset = dataset.sample(frac=0.8,random_state=0) test_dataset = dataset.drop(train_dataset.index) -
现在,让我们看一下训练数据统计,即使用
seaborn模块展示训练集中的一些特征对的联合分布。pairplot命令将数据集的特征作为输入进行评估,逐对处理。在对角线上(其中一对由相同特征的两个实例组成),显示变量的分布,而在非对角项中,显示这两个特征的散点图。如果我们希望突出显示相关性,这会非常有用:sns.pairplot(train_dataset[["MPG", "Cylinders", "Displacement", \ "Weight"]], diag_kind="kde")这将生成以下图像:
![图 3.14:训练集中的一些特征对的联合分布]()
图 3.14:训练集中的一些特征对的联合分布
-
现在让我们来看一下整体统计数据:
train_stats = train_dataset.describe() train_stats.pop("MPG") train_stats = train_stats.transpose() train_stats输出将如下所示:
![图 3.15:整体训练集统计]()
图 3.15:整体训练集统计
-
将特征与标签分开并对数据进行归一化:
train_labels = train_dataset.pop('MPG') test_labels = test_dataset.pop('MPG') def norm(x): return (x - train_stats['mean']) / train_stats['std'] normed_train_data = norm(train_dataset) normed_test_data = norm(test_dataset) -
现在,让我们查看模型的创建及其摘要:
def build_model(): model = tf.keras.Sequential([ tf.keras.layers.Dense(64, activation='relu',\ input_shape=[len\ (train_dataset.keys())]),\ tf.keras.layers.Dense(64, activation='relu'),\ tf.keras.layers.Dense(1)]) optimizer = tf.keras.optimizers.RMSprop(0.001) model.compile(loss='mse', optimizer=optimizer,\ metrics=['mae', 'mse']) return model model = build_model() model.summary()这将生成以下输出:
![图 3.16:模型摘要]()
图 3.16:模型摘要
-
使用
fit模型函数,通过使用 20%的验证集来训练网络 1000 个周期:epochs = 1000 history = model.fit(normed_train_data, train_labels,\ epochs=epochs, validation_split = 0.2, \ verbose=2)这将产生非常长的输出。我们这里只报告最后几行:
Epoch 999/1000251/251 - 0s - loss: 2.8630 - mae: 1.0763 - mse: 2.8630 - val_loss: 10.2443 - val_mae: 2.3926 - val_mse: 10.2443 Epoch 1000/1000251/251 - 0s - loss: 2.7697 - mae: 0.9985 - mse: 2.7697 - val_loss: 9.9689 - val_mae: 2.3709 - val_mse: 9.9689 -
通过绘制平均绝对误差(MAE)和均方误差(MSE)来可视化训练和验证指标。
以下代码段绘制了平均绝对误差(MAE):
hist = pd.DataFrame(history.history) hist['epoch'] = history.epoch plt.plot(hist['epoch'],hist['mae']) plt.plot(hist['epoch'],hist['val_mae']) plt.ylim([0, 10]) plt.ylabel('MAE [MPG]') plt.legend(["Training", "Validation"])输出将如下所示:
![图 3.17:每个周期图中的平均绝对误差]()
图 3.17:每个周期图中的平均绝对误差
前面的图表显示了增加训练周期数如何导致验证误差增加,这意味着系统正经历过拟合问题。
-
现在,让我们通过绘制图表来可视化均方误差(MSE):
plt.plot(hist['epoch'],hist['mse']) plt.plot(hist['epoch'],hist['val_mse']) plt.ylim([0, 20]) plt.ylabel('MSE [MPG²]') plt.legend(["Training", "Validation"])输出将如下所示:
![图 3.18:每个周期图中的均方误差]()
图 3.18:每个周期图中的均方误差
此外,在这种情况下,图表显示了增加训练周期数如何导致验证误差增加,这意味着系统正经历过拟合问题。
-
使用 Keras 回调来添加早停(耐心参数设置为 10 个周期)以避免过拟合。首先,构建模型:
model = build_model() -
然后,定义一个早停回调。这个实体将被传递到
model.fit函数中,并在每次拟合步骤中被调用,以检查验证误差是否在超过10个连续周期后停止下降:early_stop = tf.keras.callbacks\ .EarlyStopping(monitor='val_loss', patience=10) -
最后,调用带有早停回调的
fit方法:early_history = model.fit(normed_train_data, train_labels,\ epochs=epochs, validation_split=0.2,\ verbose=2, callbacks=[early_stop])输出的最后几行如下:
Epoch 42/1000251/251 - 0s - loss: 7.1298 - mae: 1.9014 - mse: 7.1298 - val_loss: 8.1151 - val_mae: 2.1885 - val_mse: 8.1151 Epoch 43/1000251/251 - 0s - loss: 7.0575 - mae: 1.8513 - mse: 7.0575 - val_loss: 8.4124 - val_mae: 2.2669 - val_mse: 8.4124 -
可视化训练和验证指标以进行早停。首先,收集所有的训练历史数据,并将其放入一个 pandas DataFrame 中,包括指标和周期值:
early_hist = pd.DataFrame(early_history.history) early_hist['epoch'] = early_history.epoch -
然后,绘制训练和验证的平均绝对误差(MAE)与周期的关系,并将最大
y值限制为10:plt.plot(early_hist['epoch'],early_hist['mae']) plt.plot(early_hist['epoch'],early_hist['val_mae']) plt.ylim([0, 10]) plt.ylabel('MAE [MPG]') plt.legend(["Training", "Validation"])前面的代码将产生以下输出:
![图 3.19:在训练轮次图中的平均绝对误差(早停法)]()
图 3.19:在训练轮次图中的平均绝对误差(早停法)
如前图所示,训练会在验证误差停止下降时停止,从而避免过拟合。
-
在测试集上评估模型的准确性:
loss, mae, mse = model.evaluate(normed_test_data, \ test_labels, verbose=2) print("Testing set Mean Abs Error: {:5.2f} MPG".format(mae))输出将如下所示:
78/78 - 0s - loss: 6.3067 - mae: 1.8750 - mse: 6.3067 Testing set Mean Abs Error: 1.87 MPG注意
由于随机抽样并使用可变的随机种子,准确度可能会显示略有不同的值。
-
最后,通过预测所有测试实例的 MPG 值来执行模型推断。然后,将这些值与它们的真实值进行比较,从而得到模型误差的视觉估计:
test_predictions = model.predict(normed_test_data).flatten() a = plt.axes(aspect='equal') plt.scatter(test_labels, test_predictions) plt.xlabel('True Values [MPG]') plt.ylabel('Predictions [MPG]') lims = [0, 50] plt.xlim(lims) plt.ylim(lims) _ = plt.plot(lims, lims)输出将如下所示:
![图 3.20:预测值与真实值的散点图]()
图 3.20:预测值与真实值的散点图
散点图将预测值与真实值进行对应,这意味着点越接近对角线,预测越准确。可以明显看出点的聚集程度,说明预测非常准确。
注意
要访问此特定部分的源代码,请参考 packt.live/3feCLNN。
你也可以在网上运行这个示例,访问 packt.live/37n5WeM。
本节展示了如何成功地解决回归问题。所选的数据集已经导入、清洗并细分为训练集、验证集和测试集。然后,进行了简要的探索性数据分析,接着创建了一个三层全连接的深度神经网络。该网络已经成功训练,并且在测试集上评估了其表现。
现在,让我们使用 TensorFlow 来研究分类问题。
使用 TensorFlow 进行简单分类
本节将帮助你理解并解决一个典型的监督学习问题,这个问题属于传统上称为分类的类别。
分类任务在其最简单的通用形式中,旨在将一个类别与一组预定义的实例关联起来。一个常用于入门课程的直观分类任务示例是将家庭宠物的图片分类到它们所属的正确类别中,例如“猫”或“狗”。分类在许多日常活动中发挥着基础性作用,且在不同的情境中很容易遇到。前面的例子是一个特定的分类任务,称为图像分类,在这一类别中可以找到许多类似的应用。
然而,分类不仅限于图像。以下是一些例子:
-
视频推荐系统的客户分类(回答问题:“该用户属于哪个市场细分?”)
-
垃圾邮件过滤器(“这封邮件是垃圾邮件的可能性有多大?”)
-
恶意软件检测("这个程序是网络威胁吗?")
-
医学诊断("这个病人有病吗?")
对于图像分类任务,图像作为输入传递给分类算法,算法返回它们所属的类别作为输出。图像是三维数组,表示每个像素的亮度(高度 x 宽度 x 通道数,其中彩色图像有三个通道——红色、绿色、蓝色(RGB),而灰度图像只有一个),这些数字是算法用来确定图像所属类别的特征。
处理其他类型的输入时,特征可能有所不同。例如,在医学诊断分类系统中,血液检查参数、年龄、性别等可以作为特征,供算法用来识别实例所属的类别,即“生病”或“未生病”。
在以下练习中,我们将基于前面部分的内容创建一个深度神经网络。它将在对 ATLAS 实验中检测到的信号进行分类时达到约 70%的准确率,区分背景噪声与希格斯玻色子τ-τ衰变,使用 28 个特征:没错,机器学习也可以应用于粒子物理学!
注
有关数据集的更多信息,请访问官方网站:archive.ics.uci.edu/ml/datasets/HIGGS。
鉴于数据集的巨大规模,为了使练习便于运行并且仍然有意义,我们将对数据进行子采样:训练集将使用 10,000 行,验证集和测试集各使用 1,000 行。将训练三种不同的模型:一个小模型作为参考(两层,每层分别为 16 和 1 个神经元),一个不带防止过拟合措施的大模型(五层;四层有 512 个神经元,最后一层有 1 个神经元),用以展示在这种情况下可能遇到的问题,随后将向大模型添加正则化和 dropout,有效限制过拟合并提高性能。
练习 3.06:创建一个深度神经网络,分类 ATLAS 实验中为寻找希格斯玻色子而生成的事件
在这个练习中,我们将构建、训练并测量深度神经网络的性能,以通过使用带有特征的模拟数据来提高 ATLAS 实验的发现显著性,从而对事件进行分类。任务是将事件分类为两类:“希格斯玻色子的τ衰变”与“背景”。
此数据集可以在 TensorFlow 数据集(www.tensorflow.org/datasets)中找到,它是一个现成的可用数据集集合。可以通过处理管道进行下载和接口。由于我们当前的用途,原始数据集太大,因此我们将在本章活动中使用该数据集时再使用它。现在,我们将使用通过仓库直接提供的数据集子集。
注意
您可以在本书的 GitHub 仓库中找到数据集,链接地址是:packt.live/3dUfYq8。
步骤逐一描述如下:
-
导入所有必需的模块并打印最重要模块的版本:
from __future__ import absolute_import, division, \ print_function, unicode_literals from IPython import display from matplotlib import pyplot as plt from scipy.ndimage.filters import gaussian_filter1d import pandas as pd import numpy as np import tensorflow as tf print("TensorFlow version: {}".format(tf.__version__))输出将如下所示:
TensorFlow version: 2.1.0 -
导入数据集并为预处理准备数据。
对于本次练习,我们将下载一个从原始数据集中提取的小型自定义子集:
higgs_path = tf.keras.utils.get_file('HIGGSSmall.csv.gz', \ 'https://github.com/PacktWorkshops/'\ 'The-Reinforcement-Learning-Workshop/blob/'\ 'master/Chapter03/Dataset/HIGGSSmall.csv.gz?raw=true') -
将 CSV 数据集读取为 TensorFlow 数据集类,并重新打包成包含元组(
features,labels)的形式:N_TEST = int(1e3) N_VALIDATION = int(1e3) N_TRAIN = int(1e4) BUFFER_SIZE = int(N_TRAIN) BATCH_SIZE = 500 STEPS_PER_EPOCH = N_TRAIN//BATCH_SIZE N_FEATURES = 28 ds = tf.data.experimental\ .CsvDataset(higgs_path,[float(),]*(N_FEATURES+1), \ compression_type="GZIP") def pack_row(*row): label = row[0] features = tf.stack(row[1:],1) return features, label packed_ds = ds.batch(N_TRAIN).map(pack_row).unbatch()查看特征的值分布:
for features,label in packed_ds.batch(1000).take(1): print(features[0]) plt.hist(features.numpy().flatten(), bins = 101)输出将如下所示:
tf.Tensor( [ 0.8692932 -0.6350818 0.22569026 0.32747006 -0.6899932 0.7542022 -0.2485731 -1.0920639 0\. 1.3749921 -0.6536742 0.9303491 1.1074361 1.1389043 -1.5781983 -1.0469854 0\. 0.65792954 -0.01045457 -0.04576717 3.1019614 1.35376 0.9795631 0.97807616 0.92000484 0.72165745 0.98875093 0.87667835], shape=(28,), dtype=float32)绘图将如下所示:
![图 3.21:第一特征值分布]()
图 3.21:第一特征值分布
在前面的图中,x轴表示给定值的训练样本数量,而y轴表示第一个特征的数值。
-
创建训练集、验证集和测试集:
validate_ds = packed_ds.take(N_VALIDATION).cache() test_ds = packed_ds.skip(N_VALIDATION).take(N_TEST).cache() train_ds = packed_ds.skip(N_VALIDATION+N_TEST)\ .take(N_TRAIN).cache() -
定义特征、标签和类别名称:
feature_names = ["lepton pT", "lepton eta", "lepton phi",\ "missing energy magnitude", \ "missing energy phi",\ "jet 1 pt", "jet 1 eta", "jet 1 phi",\ "jet 1 b-tag",\ "jet 2 pt", "jet 2 eta", "jet 2 phi",\ "jet 2 b-tag",\ "jet 3 pt", "jet 3 eta", "jet 3 phi",\ "jet 3 b-tag",\ "jet 4 pt", "jet 4 eta", "jet 4 phi",\ "jet 4 b-tag",\ "m_jj", "m_jjj", "m_lv", "m_jlv", "m_bb",\ "m_wbb", "m_wwbb"] label_name = ['Measure'] class_names = ['Signal', 'Background'] print("Features: {}".format(feature_names)) print("Label: {}".format(label_name)) print("Class names: {}".format(class_names))输出将如下所示:
Features: ['lepton pT', 'lepton eta', 'lepton phi', 'missing energy magnitude', 'missing energy phi', 'jet 1 pt', 'jet 1 eta', 'jet 1 phi', 'jet 1 b-tag', 'jet 2 pt', 'jet 2 eta', 'jet 2 phi', 'jet 2 b-tag', 'jet 3 pt', 'jet 3 eta', 'jet 3 phi', 'jet 3 b-tag', 'jet 4 pt', 'jet 4 eta', 'jet 4 phi', 'jet 4 b-tag', 'm_jj', 'm_jjj', 'm_lv', 'm_jlv', 'm_bb', 'm_wbb', 'm_wwbb'] Label: ['Measure'] Class names: ['Signal', 'Background'] -
显示一个训练实例的特征和标签示例:
features, labels = next(iter(train_ds)) print("Features =") print(features.numpy()) print("Labels =") print(labels.numpy())输出将如下所示:
Features = [ 0.3923715 1.3781117 1.5673449 0.17123567 1.6574531 0.86394763 0.88821083 1.4797885 2.1730762 1.2008675 0.9490923 -0.30092147 2.2148721 1.277294 0.4025028 0.50748837 0\. 0.50555664 -0.55428815 -0.7055601 0\. 0.94152564 0.9448251 0.9839765 0.7801499 1.4989641 0.91668195 0.8027126 ] Labels = 0.0 -
给数据集分配批次大小:
test_ds = test_ds.batch(BATCH_SIZE) validate_ds = validate_ds.batch(BATCH_SIZE) train_ds = train_ds.shuffle(BUFFER_SIZE).repeat()\ .batch(BATCH_SIZE) -
现在,让我们开始创建模型并进行训练。创建一个递减学习率:
lr_schedule = tf.keras.optimizers.schedules\ .InverseTimeDecay(0.001,\ decay_steps=STEPS_PER_EPOCH*1000, \ decay_rate=1, staircase=False) -
定义一个函数,该函数将使用
Adam优化器编译模型,使用二元交叉熵作为loss函数,并通过在验证数据集上使用早停法对训练数据进行拟合。该函数以模型作为输入,选择
Adam优化器,并使用二元交叉熵损失和准确度指标对模型进行编译:def compile_and_fit(model, name, max_epochs=3000): optimizer = tf.keras.optimizers.Adam(lr_schedule) model.compile(optimizer=optimizer,\ loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),\ metrics=[tf.keras.losses.BinaryCrossentropy(from_logits=True,\ name='binary_crossentropy'),'accuracy'])然后打印模型的摘要,如下所示:
model.summary() -
然后,使用验证数据集和早停回调在训练数据集上拟合模型。训练的
history被保存并作为输出返回:history = model.fit(train_ds, \ steps_per_epoch = STEPS_PER_EPOCH,\ epochs=max_epochs, validation_data=validate_ds, \ callbacks=[tf.keras.callbacks\ .EarlyStopping\ (monitor='val_binary_crossentropy',\ patience=200)],verbose=2) return history -
创建一个仅有两层的小型模型,分别为 16 个和 1 个神经元,并对其进行编译并在数据集上进行拟合:
small_model = tf.keras.Sequential([\ tf.keras.layers.Dense(16, activation='elu',\ input_shape=(N_FEATURES,)),\ tf.keras.layers.Dense(1)]) size_histories = {} size_histories['small'] = compile_and_fit(small_model, 'sizes/small')这将产生一个较长的输出,其中最后两行将类似于以下内容:
Epoch 1522/3000 20/20 - 0s - loss: 0.5693 - binary_crossentropy: 0.5693 - accuracy: 0.6846 - val_loss: 0.5841 - val_binary_crossentropy: 0.5841 - val_accuracy: 0.6640 Epoch 1523/3000 20/20 - 0s - loss: 0.5695 - binary_crossentropy: 0.5695 - accuracy: 0.6822 - val_loss: 0.5845 - val_binary_crossentropy: 0.5845 - val_accuracy: 0.6600 -
检查模型在测试集上的表现:
test_accuracy = tf.keras.metrics.Accuracy() for (features, labels) in test_ds: logits = small_model(features) probabilities = tf.keras.activations.sigmoid(logits) predictions = 1*(probabilities.numpy() > 0.5) test_accuracy(predictions, labels) small_model_accuracy = test_accuracy.result() print("Test set accuracy:{:.3%}".format(test_accuracy.result()))输出将如下所示:
Test set accuracy: 68.200%注意
由于使用了具有可变随机种子的随机抽样,准确率可能会显示略有不同的值。
-
创建一个具有五层的大型模型——前四层分别为
512个神经元,最后一层为1个神经元——并对其进行编译和拟合:large_model = tf.keras.Sequential([\ tf.keras.layers.Dense(512, activation='elu',\ input_shape=(N_FEATURES,)),\ tf.keras.layers.Dense(512, activation='elu'),\ tf.keras.layers.Dense(512, activation='elu'),\ tf.keras.layers.Dense(512, activation='elu'),\ tf.keras.layers.Dense(1)]) size_histories['large'] = compile_and_fit(large_model, "sizes/large")这将产生一个较长的输出,最后两行将类似于以下内容:
Epoch 221/3000 20/20 - 0s - loss: 1.0285e-04 - binary_crossentropy: 1.0285e-04 - accuracy: 1.0000 - val_loss: 2.5506 - val_binary_crossentropy: 2.5506 - val_accuracy: 0.6660 Epoch 222/3000 20/20 - 0s - loss: 1.0099e-04 - binary_crossentropy: 1.0099e-04 - accuracy: 1.0000 - val_loss: 2.5586 - val_binary_crossentropy: 2.5586 - val_accuracy: 0.6650 -
检查模型在测试集上的表现:
test_accuracy = tf.keras.metrics.Accuracy() for (features, labels) in test_ds: logits = large_model(features) probabilities = tf.keras.activations.sigmoid(logits) predictions = 1*(probabilities.numpy() > 0.5) test_accuracy(predictions, labels) large_model_accuracy = test_accuracy.result() regularization_model_accuracy = test_accuracy.result() print("Test set accuracy: {:.3%}"\ . format(regularization_model_accuracy))输出将如下所示:
Test set accuracy: 65.200%注意
由于使用带有可变随机种子的随机抽样,准确度可能会显示出略有不同的值。
-
创建与之前相同的大型模型,但添加正则化项,如 L2 正则化和 dropout。然后,编译并将模型拟合到数据集上:
regularization_model = tf.keras.Sequential([\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu', \ input_shape=(N_FEATURES,)),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu'),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu'),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu'),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(1)]) size_histories['regularization'] = compile_and_fit\ (regularization_model,\ "regularizers/regularization",\ max_epochs=9000)这将产生一个较长的输出,最后两行将类似于以下内容:
Epoch 1264/9000 20/20 - 0s - loss: 0.5873 - binary_crossentropy: 0.5469 - accuracy: 0.6978 - val_loss: 0.5819 - val_binary_crossentropy: 0.5416 - val_accuracy: 0.7030 Epoch 1265/9000 20/20 - 0s - loss: 0.5868 - binary_crossentropy: 0.5465 - accuracy: 0.7024 - val_loss: 0.5759 - val_binary_crossentropy: 0.5356 - val_accuracy: 0.7100 -
检查模型在测试集上的表现:
test_accuracy = tf.keras.metrics.Accuracy() for (features, labels) in test_ds: logits = regularization_model (features) probabilities = tf.keras.activations.sigmoid(logits) predictions = 1*(probabilities.numpy() > 0.5) test_accuracy(predictions, labels) print("Test set accuracy: {:.3%}".format(test_accuracy.result()))输出将如下所示:
Test set accuracy: 69.300%注意
由于使用带有可变随机种子的随机抽样,准确度可能会显示出略有不同的值。
-
比较三种模型在训练轮次中的二元交叉熵趋势:
histSmall = pd.DataFrame(size_histories["small"].history) histSmall['epoch'] = size_histories["small"].epoch histLarge = pd.DataFrame(size_histories["large"].history) histLarge['epoch'] = size_histories["large"].epoch histReg = pd.DataFrame(size_histories["regularization"].history) histReg['epoch'] = size_histories["regularization"].epoch trainSmoothSmall = gaussian_filter1d\ (histSmall['binary_crossentropy'], sigma=3) testSmoothSmall = gaussian_filter1d\ (histSmall['val_binary_crossentropy'], sigma=3) trainSmoothLarge = gaussian_filter1d\ (histLarge['binary_crossentropy'], sigma=3) testSmoothLarge = gaussian_filter1d\ (histLarge['val_binary_crossentropy'], sigma=3) trainSmoothReg = gaussian_filter1d\ (histReg['binary_crossentropy'], sigma=3) testSmoothReg = gaussian_filter1d\ (histReg['val_binary_crossentropy'], sigma=3) plt.plot(histSmall['epoch'], trainSmoothSmall, '-', \ histSmall['epoch'], testSmoothSmall, '--') plt.plot(histLarge['epoch'], trainSmoothLarge, '-', \ histLarge['epoch'], testSmoothLarge, '--') plt.plot(histReg['epoch'], trainSmoothReg, '-', \ histReg['epoch'], testSmoothReg, '--',) plt.ylim([0.5, 0.7]) plt.ylabel('Binary Crossentropy') plt.legend(["Small Training", "Small Validation", \ "Large Training", "Large Validation", \ "Regularization Training", \ "Regularization Validation"])这将生成以下图表:
![图 3.22:二元交叉熵比较]()
图 3.22:二元交叉熵比较
上述图表展示了不同模型在训练和验证误差方面的比较,以演示过拟合的工作原理。每个模型的训练误差随着训练轮次的增加而下降。而大型模型的验证误差则在经过一定轮次后迅速增加。在小型模型中,验证误差下降,紧跟着训练误差,并最终表现出比带有正则化的模型更差的结果,后者避免了过拟合,并在三者中表现最佳。
-
比较三种模型在训练轮次中的准确度趋势:
trainSmoothSmall = gaussian_filter1d\ (histSmall['accuracy'], sigma=6) testSmoothSmall = gaussian_filter1d\ (histSmall['val_accuracy'], sigma=6) trainSmoothLarge = gaussian_filter1d\ (histLarge['accuracy'], sigma=6) testSmoothLarge = gaussian_filter1d\ (histLarge['val_accuracy'], sigma=6) trainSmoothReg = gaussian_filter1d\ (histReg['accuracy'], sigma=6) testSmoothReg = gaussian_filter1d\ (histReg['val_accuracy'], sigma=6) plt.plot(histSmall['epoch'], trainSmoothSmall, '-', \ histSmall['epoch'], testSmoothSmall, '--') plt.plot(histLarge['epoch'], trainSmoothLarge, '-', \ histLarge['epoch'], testSmoothLarge, '--') plt.plot(histReg['epoch'], trainSmoothReg, '-', \ histReg['epoch'], testSmoothReg, '--',) plt.ylim([0.5, 0.75]) plt.ylabel('Accuracy') plt.legend(["Small Training", "Small Validation", \ "Large Training", "Large Validation",\ "Regularization Training", \ "Regularization Validation",])这将生成以下图表:
![图 3.23:准确度比较]()
图 3.23:准确度比较
与之前的图表以镜像方式类似,这个图表再次展示了不同模型的比较,但从准确度的角度来看。当训练的轮次增加时,每个模型的训练准确度都会提高。另一方面,大型模型的验证准确度在经过若干轮次后停止增长。而在小型模型中,验证准确度上升,并紧跟着训练准确度,最终的表现差于带有正则化的模型,后者避免了过拟合,并在三者中达到了最佳表现。
注意
要获取此特定部分的源代码,请参考packt.live/37m9huu。
您还可以在packt.live/3hhIDaZ在线运行此示例。
在这一部分中,我们解决了一个复杂的分类问题,从而创建了一个深度学习模型,能够在使用模拟的 ATLAS 实验数据对希格斯玻色子相关信号进行分类时达到约 70% 的准确率。经过对数据集的初步概览,了解了它的组织方式以及特征和标签的性质后,使用 Keras API 创建了三层深度全连接神经网络。这些模型经过训练和测试,并比较了它们在各个周期中的损失和准确率,从而使我们牢牢掌握了过拟合问题,并知道哪些技术有助于解决该问题。
TensorBoard - 如何使用 TensorBoard 可视化数据
TensorBoard 是一个嵌入在 TensorFlow 中的基于 Web 的工具。它提供了一套方法,我们可以用来深入了解 TensorFlow 会话和图,从而使用户能够检查、可视化并深刻理解它们。它以直观的方式提供许多功能,如下所示:
-
它允许我们探索 TensorFlow 模型图的详细信息,使用户能够缩放到特定的块和子部分。
-
它可以生成我们在训练过程中可以查看的典型量的图表,如损失和准确率。
-
它提供了直方图可视化,展示张量随时间变化的情况。
-
它提供了层权重和偏置在各个周期中的变化趋势。
-
它存储运行时元数据,例如总内存使用情况。
-
它可视化嵌入。
TensorBoard 读取包含有关当前训练过程的摘要信息的 TensorFlow 日志文件。这些信息是通过适当的回调生成的,然后传递给 TensorFlow 作业。
以下截图展示了 TensorBoard 提供的一些典型可视化内容。第一个是“标量”部分,展示了与训练阶段相关的标量量。在这个例子中,准确率和二进制交叉熵被表示出来:

图 3.24:TensorBoard 标量
第二种视图提供了计算图的框图可视化,所有层及其关系都被一起呈现,如下图所示:

图 3.25:TensorBoard 图
DISTRIBUTIONS 标签提供了模型参数在各个周期中的分布概览,如下图所示:

图 3.26:TensorBoard 分布
最后,HISTOGRAMS 标签提供与 DISTRIBUTIONS 标签类似的信息,但以 3D 展示,如下图所示:

图 3.27:TensorBoard 直方图
在本节中,特别是在接下来的练习中,将利用 TensorBoard 轻松地可视化指标,如趋势、张量图、分布和直方图。
为了专注于 TensorBoard,我们将使用在上一部分中执行的相同分类练习。只会训练大型模型。我们需要做的就是导入 TensorBoard 并激活它,同时定义日志文件目录。
然后创建一个 TensorBoard 回调并将其传递给模型的 fit 方法。这将生成所有 TensorBoard 文件并保存在日志目录中。一旦训练完成,日志目录路径将作为参数传递给 TensorBoard。这将打开一个基于 Web 的可视化工具,用户可以深入了解模型及其训练相关的各个方面。
练习 3.07:创建一个深度神经网络,用于分类 ATLAS 实验中生成的事件,以寻找希格斯玻色子,并使用 TensorBoard 进行可视化
在本练习中,我们将构建、训练并测量一个深度神经网络的表现,目标与练习 3.06,创建一个深度神经网络,用于分类 ATLAS 实验中生成的事件,以寻找希格斯玻色子相同,但这次我们将利用 TensorBoard,从中获得更多的训练洞察。
为了完成这个练习,需要实现以下步骤:
-
导入所有必需的模块:
from __future__ import absolute_import, division, \ print_function, unicode_literals from IPython import display from matplotlib import pyplot as plt from scipy.ndimage.filters import gaussian_filter1d import pandas as pd import numpy as np import datetime import tensorflow as tf !rm -rf ./logs/ # Load the TensorBoard notebook extension %load_ext tensorboard -
下载原始数据集的定制小子集:
higgs_path = tf.keras.utils.get_file('HIGGSSmall.csv.gz', \ 'https://github.com/PacktWorkshops/'\ 'The-Reinforcement-Learning-Workshop/blob/master/'\ 'Chapter03/Dataset/HIGGSSmall.csv.gz?raw=true') -
将 CSV 数据集读入 TensorFlow 数据集类,并重新打包,以便它具有元组(
features,labels):N_TEST = int(1e3) N_VALIDATION = int(1e3) N_TRAIN = int(1e4) BUFFER_SIZE = int(N_TRAIN) BATCH_SIZE = 500 STEPS_PER_EPOCH = N_TRAIN//BATCH_SIZE N_FEATURES = 28 ds = tf.data.experimental.CsvDataset\ (higgs_path,[float(),]*(N_FEATURES+1), \ compression_type="GZIP") def pack_row(*row): label = row[0] features = tf.stack(row[1:],1) return features, label packed_ds = ds.batch(N_TRAIN).map(pack_row).unbatch() -
创建训练集、验证集和测试集,并为它们分配
BATCH_SIZE参数:validate_ds = packed_ds.take(N_VALIDATION).cache() test_ds = packed_ds.skip(N_VALIDATION).take(N_TEST).cache() train_ds = packed_ds.skip(N_VALIDATION+N_TEST)\ .take(N_TRAIN).cache() test_ds = test_ds.batch(BATCH_SIZE) validate_ds = validate_ds.batch(BATCH_SIZE) train_ds = train_ds.shuffle(BUFFER_SIZE)\ .repeat().batch(BATCH_SIZE) -
现在,让我们开始创建模型并进行训练。创建一个衰减学习率:
lr_schedule = tf.keras.optimizers.schedules\ .InverseTimeDecay(0.001, \ decay_steps=STEPS_PER_EPOCH*1000,\ decay_rate=1, staircase=False) -
定义一个函数,该函数将使用
Adam优化器编译模型,并使用二元交叉熵作为loss函数。然后,使用验证数据集通过早停法拟合训练数据,并使用 TensorBoard 回调:log_dir = "logs/fit/" + datetime.datetime.now()\ .strftime("%Y%m%d-%H%M%S") def compile_and_fit(model, name, max_epochs=3000): optimizer = tf.keras.optimizers.Adam(lr_schedule) model.compile(optimizer=optimizer,\ loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),\ metrics=[tf.keras.losses.BinaryCrossentropy\ (from_logits=True, name='binary_crossentropy'),\ 'accuracy']) model.summary() tensorboard_callback = tf.keras.callbacks.TensorBoard\ (log_dir=log_dir,\ histogram_freq=1,\ profile_batch=0) history = model.fit\ (train_ds,\ steps_per_epoch = STEPS_PER_EPOCH,\ epochs=max_epochs,\ validation_data=validate_ds,\ callbacks=[tf.keras.callbacks.EarlyStopping\ (monitor='val_binary_crossentropy',\ patience=200),\ tensorboard_callback], verbose=2) return history -
创建与之前相同的大型模型,并加入正则化项,如 L2 正则化和丢弃法,然后对其进行编译,并在数据集上拟合:
regularization_model = tf.keras.Sequential([\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu', \ input_shape=(N_FEATURES,)),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu'),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu'),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(512,\ kernel_regularizer=tf.keras.regularizers\ .l2(0.0001),\ activation='elu'),\ tf.keras.layers.Dropout(0.5),\ tf.keras.layers.Dense(1)]) compile_and_fit(regularization_model,\ "regularizers/regularization", max_epochs=9000)最后一行输出将如下所示:
Epoch 1112/9000 20/20 - 1s - loss: 0.5887 - binary_crossentropy: 0.5515 - accuracy: 0.6949 - val_loss: 0.5831 - val_binary_crossentropy: 0.5459 - val_accuracy: 0.6960 -
检查模型在测试集上的表现:
test_accuracy = tf.keras.metrics.Accuracy() for (features, labels) in test_ds: logits = regularization_model(features) probabilities = tf.keras.activations.sigmoid(logits) predictions = 1*(probabilities.numpy() > 0.5) test_accuracy(predictions, labels) print("Test set accuracy: {:.3%}".format(test_accuracy.result()))输出将如下所示:
Test set accuracy: 69.300%注意
由于随机抽样和可变的随机种子,准确度可能会显示略微不同的值。
-
使用 TensorBoard 可视化变量:
%tensorboard --logdir logs/fit此命令启动基于 Web 的可视化工具。下图表示四个主要窗口,按顺时针顺序从左上角开始,显示有关损失和准确度、模型图、直方图和分布的信息:

图 3.28:TensorBoard 可视化
使用 TensorBoard 的优点非常明显:所有训练信息都集中在一个地方,方便用户轻松浏览。左上角的SCALARS标签允许用户监控损失和准确度,从而能够以更简便的方式查看我们之前看到的相同图表。
在右上角,显示了模型图,因此可以通过经过每个模块来可视化输入数据如何流入计算图。
底部的两个视图以两种不同的表示方式显示相同的信息:所有模型参数(网络权重和偏差)的分布在训练周期中得以展示。左侧的DISTRIBUTIONS标签以 2D 展示参数,而HISTOGRAMS标签则以 3D 展开参数。两者都允许用户监控训练过程中可训练参数的变化。
注意
要访问此部分的源代码,请参考packt.live/2AWGjFv。
你还可以在线运行此示例,访问packt.live/2YrWl2d。
在这一部分,我们主要讨论了如何使用 TensorBoard 可视化与训练相关的模型参数。我们看到,从一个已经熟悉的问题出发,加入 TensorBoard 的基于 Web 的可视化工具并直接在 Python 笔记本内浏览所有插件变得非常简单。
现在,让我们通过一个活动来检验我们所有的知识。
活动 3.01:使用 TensorFlow 数据集和 TensorFlow 2 对时尚服装进行分类
假设你需要为一个拥有服装仓库的公司编写图像处理算法。公司希望根据摄像头输出自动分类服装,从而实现无人工干预地将服装分组。
在本活动中,我们将创建一个深度全连接神经网络,能够完成此类任务,即通过将图像分配到它们所属的类别来准确分类服装。
以下步骤将帮助你完成此活动:
-
导入所有必需的模块,如
numpy、matplotlib.pyplot、tensorflow和tensorflow_datasets,并打印出它们的主模块版本。 -
使用 TensorFlow 数据集导入 Fashion MNIST 数据集,并将其拆分为训练集和测试集。
-
探索数据集,熟悉输入特征,即形状、标签和类别。
-
可视化一些训练集的实例。
-
通过构建分类模型进行数据归一化。
-
训练深度神经网络。
-
测试模型的准确性。你应该获得超过 88% 的准确率。
-
执行推理并检查预测结果与实际标签的对比。
到本活动结束时,训练好的模型应该能够以超过 88% 的准确率分类所有时尚物品(服装、鞋子、包包等),从而生成类似于以下图像所示的结果:

图 3.29:使用深度神经网络输出进行衣物分类
注意
本活动的解决方案可以在第 696 页找到。
摘要
在这一章中,我们介绍了使用 TensorFlow 2 和 Keras 进行实用深度学习的内容,讨论了它们的关键特性和应用,以及它们如何协同工作。我们熟悉了低级 API 和高级 API 之间的区别,并了解了如何利用最先进的模块简化深度模型的创建。接着,我们讨论了如何使用 TensorFlow 实现深度神经网络,并涵盖了一些主要话题:从模型创建、训练、验证到测试,我们强调了避免陷阱时需要考虑的最重要方面。我们展示了如何通过 Keras API 构建不同类型的深度学习模型,如全连接、卷积和递归神经网络。我们解决了回归任务和分类问题,从中获得了实践经验。我们还学习了如何利用 TensorBoard 可视化与训练趋势相关的多种指标和模型参数。最后,我们构建并训练了一个能够高准确率地分类时尚物品图像的模型,这项活动展示了如何借助最先进的深度学习技术解决一个可能的现实世界问题。
在下一章中,我们将研究 OpenAI Gym 环境以及如何使用 TensorFlow 2 进行强化学习。
第四章:4. 使用 OpenAI 和 TensorFlow 进行强化学习入门
概述
本章将介绍一些关键技术和概念,帮助你入门强化学习。你将熟悉并使用两个 OpenAI 工具:Gym 和 Universe。你将学习如何处理这些环境的接口,以及如何为特定问题创建自定义环境。你将构建一个策略网络,使用 TensorFlow,将环境状态输入其中以获取相应的动作,并保存策略网络的权重。你还将学习如何使用另一个 OpenAI 资源——Baselines,并用它来训练强化学习智能体解决经典的控制问题。在本章结束时,你将能够使用我们介绍的所有元素,构建并训练一个智能体,玩经典的 Atari 视频游戏,从而实现超越人类的表现。
介绍
在上一章中,你已经了解了 TensorFlow 和 Keras,以及它们的关键特性和应用,并了解了它们如何协同工作。你学习了如何使用 TensorFlow 实现深度神经网络,涵盖了所有主要主题,即模型创建、训练、验证和测试,使用的是最先进的机器学习框架。在本章中,我们将利用这些知识构建能够解决一些经典强化学习问题的模型。
强化学习是机器学习的一个分支,它最接近人工智能的理念。训练一个人工系统来学习某个任务,既没有任何先验信息,也只是通过与环境的互动经验来完成,这一目标代表了复制人类学习的雄心壮志。将深度学习技术应用于该领域,最近大大提高了性能,使我们能够解决各个领域的问题,从经典的控制问题到视频游戏,甚至是机器人运动控制。本章将介绍你可以使用的各种资源、方法和工具,帮助你熟悉在该领域起步时通常遇到的背景和问题。特别是,我们将关注OpenAI Gym和OpenAI Universe,这两个库允许我们轻松创建环境,以便训练强化学习(RL)智能体,以及 OpenAI Baselines,一个为最先进的强化学习算法提供清晰简单接口的工具。在本章结束时,你将能够利用顶尖的库和模块,轻松训练一个最先进的强化学习智能体,解决经典的控制问题,并在经典的视频游戏中实现超越人类的表现。
现在,让我们开始我们的旅程,从第一个重要的概念开始:如何正确地建模一个适合强化学习的环境,以便我们可以训练一个智能体。为此,我们将使用 OpenAI Gym 和 Universe。
OpenAI Gym
在本节中,我们将学习 OpenAI Gym 工具。我们将探讨它创建的动机及其主要元素,学习如何与它们互动,以正确训练强化学习算法,解决最前沿的基准问题。最后,我们将构建一个具有相同标准化接口的自定义环境。
共享标准基准对于衡量机器学习算法的性能和最新进展至关重要。虽然在监督学习领域,自学以来已有许多不同的示例,但在强化学习领域却并非如此。
为了满足这一需求,OpenAI 于 2016 年发布了 OpenAI Gym(gym.openai.com/)。它的构思是让它成为强化学习领域的标准,就像 ImageNet 和 COCO 数据集之于监督学习一样:一个标准的共享环境,强化学习方法的性能可以在其中直接衡量和比较,以识别出最优方法并监控当前的进展。
OpenAI Gym 作为强化学习问题的典型马尔科夫决策过程(MDP)模型和多种环境之间的接口,涵盖了智能体必须解决的不同类型问题(从经典控制到 Atari 视频游戏),以及不同的观察和动作空间。Gym 完全独立于与之接口的智能体结构以及用于构建和运行智能体的机器学习框架。
这是 Gym 提供的环境类别列表,涵盖从简单到困难的任务,涉及多种不同的数据类型:
-
经典控制与玩具文本:小规模、简单的任务,常见于强化学习文献中。这些环境是开始熟悉 Gym 并与智能体训练建立信心的最佳场所。
下图展示了经典控制问题的一个示例——CartPole:
![图 4.1:经典控制问题 - CartPole]()
图 4.1:经典控制问题 - CartPole
下图展示了经典控制问题的一个示例——MountainCar:

图 4.2:经典控制问题 - 山地车
-
算法:在这些环境中,系统必须从示例中自学,独立地执行计算任务,从多位数加法到字母数字字符序列反转等。
下图展示了代表算法问题集实例的截图:
![图 4.3:算法问题 - 复制输入序列的多个实例]()
图 4.3:算法问题 - 复制输入序列的多个实例
下图展示了代表算法问题集实例的截图:

图 4.4:算法问题——复制输入序列的实例
-
雅达利:Gym 集成了 Arcade Learning Environment(ALE),这是一个软件库,提供了一个接口,可以用来训练代理玩经典的雅达利视频游戏。它在强化学习研究中起到了重要作用,帮助实现了杰出的成果。
以下图展示了由 ALE 提供的雅达利视频游戏 Breakout:
![图 4.5:雅达利视频游戏 Breakout]()
图 4.5:雅达利视频游戏 Breakout
以下图展示了由 ALE 提供的雅达利视频游戏 Pong:

图 4.6:雅达利视频游戏 Pong
注意
前述图像来自 OpenAI Gym 的官方文档。请参阅以下链接以获取更多雅达利游戏的视觉示例:gym.openai.com/envs/#atari。
-
MuJoCo 和机器人技术:这些环境展示了在机器人控制领域中常见的挑战。其中一些环境利用了 MuJoCo 物理引擎,MuJoCo 专为快速准确的机器人仿真而设计,并提供免费的试用许可证。
以下图展示了三个 MuJoCo 环境,它们提供了机器人运动任务的有意义概述:
![图 4.7:三个 MuJoCo 驱动的环境——Ant(左),Walker(中), 和 Humanoid(右)]()
图 4.7:三个 MuJoCo 驱动的环境——Ant(左),Walker(中),Humanoid(右)
注意
前述图像来自 OpenAI Gym 的官方文档。请参阅以下链接以获取更多 MuJoCo 环境的视觉示例:gym.openai.com/envs/#mujoco。
- 以下图展示了“机器人技术”类别中的两个环境,在这些环境中,强化学习代理被训练执行机器人操作任务:

图 4.8:两个机器人环境——FetchPickAndPlace(左)和 HandManipulateEgg(右)
注意
前述图像来自 OpenAI Gym 的官方文档。请参阅以下链接以获取更多机器人环境的视觉示例:gym.openai.com/envs/#robotics。
- 第三方环境:第三方开发的环境也可以使用,涵盖了非常广泛的应用场景、复杂度和数据类型(
github.com/openai/gym/blob/master/docs/environments.md#third-party-environments)。
如何与 Gym 环境互动
为了与 Gym 环境交互,首先必须创建和初始化环境。Gym 模块使用make方法,并将环境的 ID 作为参数,来创建并返回其新实例。要列出给定 Gym 安装中所有可用的环境,只需运行以下代码:
from gym import envs
print(envs.registry.all())
这将输出以下内容:
[EnvSpec(DoubleDunk-v0), EnvSpec(InvertedDoublePendulum-v0),
EnvSpec(BeamRider-v0), EnvSpec(Phoenix-ram-v0), EnvSpec(Asterix-v0),
EnvSpec(TimePilot-v0), EnvSpec(Alien-v0), EnvSpec(Robotank-ram-v0),
EnvSpec(CartPole-v0), EnvSpec(Berzerk-v0), EnvSpec(Berzerk-ram-v0),
EnvSpec(Gopher-ram-v0), ...
这是一个所谓的EnvSpec对象的列表。它们定义了特定的环境相关参数,比如要实现的目标、定义任务何时完成的奖励阈值,以及单个回合允许的最大步数。
一个有趣的地方是,可以轻松添加自定义环境,正如我们稍后将看到的那样。得益于这一点,用户可以使用标准接口实现自定义问题,从而使标准化的现成强化学习算法可以轻松处理这些问题。
环境的基本元素如下:
-
观察 (object):一个特定于环境的对象,表示可以观察到的环境内容;例如,机械系统的运动学变量(即速度和位置)、棋盘游戏中的棋子位置,或视频游戏中的像素帧。
-
动作 (object):一个特定于环境的对象,表示智能体可以在环境中执行的动作;例如,机器人的关节旋转和/或关节扭矩、棋盘游戏中的合法移动,或视频游戏中按下的多个按钮组合。
-
奖励 (float):通过执行指定动作完成最后一步所获得的奖励量。不同任务的奖励范围不同,但为了完成环境任务,目标始终是增加奖励,因为这是强化学习智能体试图最大化的内容。
-
完成 (bool):这表示回合是否已结束。如果为 true,环境需要重置。大多数(但不是所有)任务被划分为明确的回合,其中一个终止回合可能表示机器人已摔倒、棋盘游戏已达到最终状态,或智能体在视频游戏中失去了最后一条生命。
-
信息 (dict):这包含了环境内部的诊断信息,对于调试和强化学习(RL)智能体训练都很有用,即使在标准基准测试比较中不允许使用。
环境的基本方法如下:
-
reset():输入:无,输出:观察。重置环境,将其带回起始点。该方法没有输入并输出相应的观察。它必须在环境创建后立即调用,并且每当达到最终状态时(done标志为True)都要调用。 -
step(action):输入:动作,输出:观察 – 奖励 – 结束 – 信息。通过应用选定的输入动作,推进环境一步。返回新状态的观察结果,该状态是从先前状态到新状态的过渡所获得的奖励。done标志用于指示新状态是否是终止状态(分别为True/False),以及包含环境内部信息的Info字典。 -
render():输入:无,输出:环境渲染。渲染环境,仅用于可视化/展示目的。在代理训练过程中不使用该功能,代理只需要观察来了解环境的状态。例如,它通过动画图形展示机器人运动,或输出视频游戏视频流。 -
close():输入:无,输出:无。优雅地关闭环境。
这些元素使我们能够通过执行随机输入、训练代理并运行它,简单地与环境进行完全交互。事实上,这是标准强化学习情境化的实现,描述了代理与环境的交互。在每个时间步,代理执行一个动作。与环境的这种交互会导致从当前状态到新状态的过渡,产生对新状态的观察和奖励,并将这些作为结果返回。作为初步步骤,以下练习展示了如何创建一个 CartPole 环境,重置它,在每个步骤随机采样一个动作后运行 1,000 步,最后关闭它。
练习 4.01:与 Gym 环境交互
在本练习中,我们将通过查看经典控制示例 CartPole 来熟悉 Gym 环境。按照以下步骤完成此练习:
-
导入 OpenAI Gym 模块:
import gym -
实例化环境并重置它:
env = gym.make('CartPole-v0') env.reset()输出将如下所示:
array([ 0.03972635, 0.00449595, 0.04198141, -0.01267544]) -
运行环境
1000步,在遇到终止状态时渲染并重置。如果所有步骤完成,关闭环境:for _ in range(1000): env.render() # take a random action _, _, done, _ = env.step(env.action_space.sample()) if done: env.reset() env.close()它渲染环境并播放 1,000 步。下图展示了从整个序列的第 12 步中提取的一帧:

图 4.9:CartPole 环境中 1,000 步渲染的其中一帧
注意
要访问本节的源代码,请参考packt.live/30yFmOi。
本节目前没有在线交互示例,需要在本地运行。
这表明黑色小车可以沿着轨道(水平线)移动,杆子通过铰链固定在小车上,允许它自由旋转。目标是控制小车,左右推动它,以保持杆子的垂直平衡,如前图所示。
动作空间和观察空间
为了与环境适当地交互并在其上训练智能体,一个基本的初步步骤是熟悉其动作空间和观察空间。例如,在前面的练习中,动作是从环境的动作空间中随机采样的。
每个环境都由 action_space 和 observation_space 特征定义,它们是 Space 类的实例,描述了 Gym 所要求的动作和观察。以下代码片段打印了 CartPole 环境的相关信息:
import gym
env = gym.make('CartPole-v0')
print("Action space =", env.action_space)
print("Observation space =", env.observation_space)
输出如下两行:
Action space = Discrete(2)
Observation space = Box(4,)
Discrete 空间表示非负整数集合(自然数加上 0)。它的维度定义了哪些数字代表有效的动作。例如,在 CartPole 案例中,它的维度是 2,因为智能体只能将小车向左或向右推,因此可接受的值为 0 或 1。Box 空间可以看作是一个 n 维数组。在 CartPole 案例中,系统状态由四个变量定义:小车位置和速度,以及杆子相对于竖直方向的角度和角速度。因此,“盒子观察”空间的维度为 4,有效的观察将是一个包含四个实数的数组。在后面的情况下,检查其上下限是很有用的。可以通过以下方式进行:
print("Observations superior limit =", env.observation_space.high)
print("Observations inferior limit =", env.observation_space.low)
它会打印出如下内容:
Observations superior limit = array([ 2.4, inf, 0.20943951, inf])
Observations inferior limit = array([-2.4, -inf,-0.20943951, -inf])
有了这些新元素,就可以编写一个更完整的代码片段来与环境交互,利用所有之前介绍的接口。以下代码展示了一个完整的循环,执行 20 轮,每轮 100 步,渲染环境,获取观察结果,并在执行随机动作时打印它们,一旦到达终止状态则重置:
import gym
env = gym.make('CartPole-v0')
for i_episode in range(20):
observation = env.reset()
for t in range(100):
env.render()
print(observation)
action = env.action_space.sample()
observation, reward, done, info = env.step(action)
if done:
print("Episode finished after {} timesteps".format(t+1))
break
env.close()
前面的代码运行了 20 轮,每轮 100 步,同时呈现环境,正如我们在 练习 4.01,与 Gym 环境交互 中所见。
注意
在前面的案例中,我们将每轮运行 100 步,而不是之前的 1,000 步。这样做没有特别的原因,只是因为我们运行了 20 个不同的轮次,而不是一个,所以我们选择 100 步以保持代码执行时间足够短。
除此之外,这段代码还会打印出每一步操作后环境返回的观察序列。以下是一些作为输出的行:
[-0.061586 -0.75893141 0.05793238 1.15547541]
[-0.07676463 -0.95475889 0.08104189 1.46574644]
[-0.0958598 -1.15077434 0.11035682 1.78260485]
[-0.11887529 -0.95705275 0.14600892 1.5261692 ]
[-0.13801635 -0.7639636 0.1765323 1.28239155]
[-0.15329562 -0.57147373 0.20218013 1.04977545]
Episode finished after 14 timesteps
[-0.02786724 0.00361763 -0.03938967 -0.01611184]
[-0.02779488 -0.19091794 -0.03971191 0.26388759]
[-0.03161324 0.00474768 -0.03443415 -0.04105167]
从之前的代码示例来看,我们可以看到目前的动作选择是完全随机的。正是在这一点上,经过训练的智能体会有所不同:它应该根据环境观察选择动作,从而恰当地响应它所处的状态。因此,通过用经过训练的智能体替换随机动作选择来修改之前的代码,如下所示:
-
导入 OpenAI Gym 和 CartPole 模块:
import gym env = gym.make('CartPole-v0') -
运行
20轮,每轮100步:for i_episode in range(20): observation = env.reset() for t in range(100): -
渲染环境并打印观察结果:
env.render() print(observation) -
使用智能体的知识来选择行动,前提是给定当前环境状态:
action = RL_agent.select_action(observation) -
步进环境:
observation, reward, done, info = env.step(action) -
如果成功,则跳出内部循环并开始一个新的回合:
if done: print("Episode finished after {} timesteps"\ .format(t+1)) break env.close()
在训练好的智能体下,行动将被最优选择,因为会利用当前智能体所处状态的函数来最大化期望的奖励。此代码将产生类似于之前的输出。
那么,我们该如何从零开始训练一个智能体呢?正如你在本书中将学到的那样,存在许多不同的方法和算法可以用来实现这个相当复杂的任务。通常,它们都需要以下元素的元组:当前状态、选择的动作、执行选择动作后获得的奖励,以及执行选择动作后到达的新状态。
因此,基于前面的代码片段再次展开,加入智能体训练步骤,代码如下所示:
import gym
env = gym.make('CartPole-v0')
for i_episode in range(20):
observation = env.reset()
for t in range(100):
env.render()
print(observation)
action = RL_agent.select_action(observation)
new_observation, reward, done, info = env.step(action)
RL_agent.train(observation, action, reward, \
new_observation)
observation = new_observation
if done:
print("Episode finished after {} timesteps"\
.format(t+1))
break
env.close()
与前一个代码块的唯一区别在于以下这一行:
RL_agent.train(observation, action, reward, new_observation)
这指的是智能体训练步骤。此代码的目的是让我们对训练一个 RL 智能体在给定环境中所涉及的所有步骤有一个高层次的了解。
这是采用的方法背后的高层次思路,用于在 Gym 环境中进行强化学习智能体的训练。它通过一个非常简洁的标准接口提供访问所有所需的细节,从而使我们能够访问一个极其庞大的不同问题集,利用这些问题来衡量算法和技术的效果。
如何实现一个自定义的 Gym 环境
Gym 提供的所有环境都非常适合用于学习,但最终你需要训练一个智能体来解决一个自定义问题。一种实现这一目标的好方法是创建一个专门针对问题领域的自定义环境。
为了做到这一点,必须创建一个派生自gym.Env的类。它将实现前一节中描述的所有对象和方法,以支持典型的强化学习环境中,智能体与环境之间的交互周期。
以下代码片段展示了一个框架,指导自定义环境的开发:
import gym
from gym import spaces
class CustomEnv(gym.Env):
"""Custom Environment that follows gym interface"""
metadata = {'render.modes': ['human']}
def __init__(self, arg1, arg2, ...):
super(CustomEnv, self).__init__()
# Define action and observation space
# They must be gym.spaces objects
# Example when using discrete actions:
self.action_space = spaces.Discrete(N_DISCRETE_ACTIONS)
# Example for using image as input:
self.observation_space = spaces.Box\
(low=0, high=255, \
shape=(HEIGHT, WIDTH, \
N_CHANNELS), \
dtype=np.uint8)
def step(self, action):
# Execute one time step within the environment
...
# Compute reward
...
# Check if in final state
...
return observation, reward, done, info
def reset(self):
# Reset the state of the environment to an initial state
...
return observation
def render(self, mode='human', close=False):
# Render the environment to the screen
...
return
在构造函数中,定义了action_space和observation_space。如前所述,它们将包含智能体在环境中可以执行的所有可能动作,以及智能体能够观察到的所有环境数据。它们将归属于特定问题:特别地,action_space将反映智能体可以控制的与环境互动的元素,而observation_space将包含我们希望智能体在选择行动时考虑的所有变量。
reset方法将被调用以定期将环境重置为初始状态,通常在第一次初始化后以及每次回合结束后。它将返回观测值。
step 方法接收一个动作作为输入并执行它。这将导致环境从当前状态过渡到新状态,并返回与新状态相关的观察结果。这也是奖励计算的方法,奖励是由动作产生的状态转换的结果。新状态会被检查是否为终止状态,如果是,返回的 done 标志会被设置为 true。最后,所有有用的内部信息会以 info 字典的形式返回。
最后,render 方法负责渲染环境。其复杂性可能从简单的打印语句到使用 OpenGL 渲染 3D 环境的复杂操作不等。
在本节中,我们研究了 OpenAI Gym 工具。我们概述了它的背景和构思动机,详细介绍了其主要元素,并展示了如何与这些元素交互,从而正确地训练一个强化学习算法来解决最前沿的基准问题。最后,我们展示了如何构建一个具有相同标准化接口的自定义环境。
OpenAI Universe – 复杂环境
OpenAI Universe 是 OpenAI 在 Gym 发布几个月后推出的。它是一个软件平台,用于在不同应用程序上衡量和训练通用人工智能,应用领域涵盖从视频游戏到网站的各种内容。它使得 AI 代理能够像人类一样使用计算机:环境状态通过屏幕像素表示,动作是所有可以通过操作虚拟键盘和鼠标执行的操作。
使用 Universe,可以将任何程序适配成 Gym 环境,从而将程序转化为 Gym 环境。它使用 虚拟网络计算(VNC)技术执行程序,这是一种允许通过网络共享图形桌面来远程控制计算机系统的软件技术,传输键盘和鼠标事件并接收屏幕帧。通过模拟远程桌面背后的执行,它不需要访问程序内存状态、定制源代码或特定的 API。
以下代码片段展示了如何在一个简单的 Python 程序中使用 Universe,其中一个脚本化的动作会在每一步中执行:
-
导入 OpenAI Gym 和 OpenAI Universe 模块:
import gym # register Universe environments into Gym import universe -
实例化 OpenAI Universe 环境并重置它:
# Universe env ID here env = gym.make('flashgames.DuskDrive-v0') observation_n = env.reset() -
执行预定的动作与环境进行交互并渲染环境:
while True: # agent which presses the Up arrow 60 times per second action_n = [[('KeyEvent', 'ArrowUp', True)] \ for _ in observation_n] observation_n, reward_n, done_n, info = env.step(action_n) env.render()
上述代码成功地在浏览器中运行了一个 Flash 游戏。
Universe 的目标是促进 AI 代理的发展,使其能够将过去的经验应用到掌握复杂的新环境中,这将是实现人工通用智能的一个关键步骤。
尽管近年来人工智能取得了巨大成功,但所有开发的系统仍然可以被认为是“狭义人工智能”。这是因为它们只能在有限的领域内实现超过人类的表现。构建一个具有通用问题解决能力、与人类常识相当的系统,需要克服将代理经验带到全新任务中的目标。这将使代理避免从零开始训练,随机进行数千万次试验。
现在,让我们看看 OpenAI Universe 的基础设施。
OpenAI Universe 基础设施
以下图示有效描述了 OpenAI Universe 的工作原理:它通过一个通用接口公开所有环境,这些环境将在后文详细描述:通过利用 VNC 技术,它使环境充当服务器,代理充当客户端,后者通过观察屏幕的像素(环境的观察)并产生键盘和鼠标命令(代理的行动)来操作远程桌面。VNC 是一项成熟的技术,是通过网络与计算机进行远程交互的标准技术,例如云计算系统或去中心化基础设施中的情况:

图 4.10:VNC 服务器-客户端 Universe 基础设施
Universe 的实现具有以下一些显著特点:
-
通用性:通过采用 VNC 接口,它无需模拟器或访问程序的源代码或内存状态,从而在计算机游戏、网页浏览、CAD 软件使用等领域开辟了大量机会。
-
对人类的熟悉性:人类可以轻松地使用它为 AI 算法提供基准,这对于通过记录 VNC 流量的方式初始化代理并提供人类演示非常有用。例如,人类可以通过 VNC 使用 OpenAI Universe 提供的任务之一并记录相应的流量。然后,它可以用来训练代理,提供良好的策略学习范例。
-
标准化:利用 VNC 技术确保了在所有主要操作系统中都具有可移植性,这些操作系统默认安装有 VNC 软件。
-
调试的简便性:通过简单地将客户端连接到环境的 VNC 共享服务器进行可视化,可以轻松地在训练或评估过程中观察代理的状态。节省 VNC 流量也很有帮助。
环境
本节中,我们将探讨 Universe 中已经提供的最重要的几类问题。每个环境由一个 Docker 镜像组成,并托管一个 VNC 服务器。该服务器作为接口,负责以下任务:
-
发送观察信息(屏幕像素)
-
接收操作(键盘/鼠标命令)
-
通过 Web Socket 服务器为强化学习任务提供信息(奖励信号、诊断元素等)
现在,让我们来看看不同类别的环境。
Atari 游戏
这些是 ALE 中的经典 Atari 2600 游戏。在 OpenAI Gym 中已经遇到过,它们也是 Universe 的一部分。
Flash 游戏
Flash 游戏的景观提供了大量相较于 Atari 更先进图形的游戏,但仍然具备简单的机制和目标。Universe 的初始版本包含了 1,000 个 Flash 游戏,其中 100 个还提供了作为功能的奖励。
在 Universe 方法中,有一个重要的方面需要解决:代理如何知道自己表现得如何,这与与环境交互后返回的奖励相关。如果你无法访问应用程序的内部状态(即其 RAM 地址),那么唯一的方法就是从屏幕上的像素中提取这些信息。许多游戏都有与之关联的分数,这些分数会在每一帧中打印出来,可以通过某些图像处理算法解析。例如,Atari Pong 在画面的顶部显示两位玩家的分数,因此可以解析这些像素来获取分数。Universe 开发了一个基于卷积神经网络的高性能图像到文本模型,该模型嵌入到 Python 控制器中,并在 Docker 容器内运行。在可以应用的环境中,它从帧缓存中提取用户的分数,并通过 Web Socket 提供这些信息,从帧缓存中获取并通过 Web Socket 提供分数信息。
浏览器任务
Universe 基于使用网页浏览器的方式,增加了一组独特的任务。这些环境将 AI 代理放置在常见的网页浏览器前,呈现需要使用网络的问题:阅读内容、浏览网页和点击按钮,同时只观察像素,使用键盘和鼠标。根据任务的复杂度,从概念上看,这些任务可以分为两类:迷你比特世界和现实世界浏览器任务:
-
迷你比特世界:
这些环境就像 MNIST 数据集对图像识别的作用一样,属于浏览器任务的基本构建块:它们是可以在复杂的浏览问题中找到的基础组件,训练更容易,但也富有洞察力。它们是难度各异的环境,例如,你需要点击特定的按钮或使用电子邮件客户端回复消息。
-
现实世界浏览器任务:
相对于前一类别,这些环境要求代理解决更现实的问题,通常以向代理发出的指令形式呈现,代理必须在网站上执行一系列操作。例如,要求代理预订特定航班,这需要它与平台进行交互,以找到正确的答案。
运行 OpenAI Universe 环境
作为一个通过公共接口可以访问的大量任务集合,运行环境只需要执行几个步骤:
-
安装 Docker 和 Universe,可以使用以下命令完成:
git clone https://github.com/openai/universe && pip install -e universe -
启动一个运行时,这是一个将相似环境集合在一起的服务器,暴露两个端口:
5900和15900。端口5900用于 VNC 协议交换像素信息或键盘/鼠标操作,而15900用于维护WebSocket控制协议。以下代码片段展示了如何从 PC 控制台启动运行时(例如 Linux shell):# -p 5900:5900 and -p 15900:15900 # expose the VNC and WebSocket ports # --privileged/--cap-add/--ipc=host # needed to make Selenium work $ docker run --privileged --cap-add=SYS_ADMIN --ipc=host \ -p 5900:5900 -p 15900:15900 quay.io/openai/universe.flashgames
使用此命令,Flash 游戏的 Docker 容器将被下载。然后,您可以使用 VNC 查看器查看并控制创建的远程桌面。目标端口是5900。也可以通过 Web 服务器使用端口15900和密码openai使用基于浏览器的 VNC 客户端。
以下代码片段与我们之前看到的完全相同,唯一的区别是它新增了 VNC 连接步骤。这意味着输出结果也相同,因此这里不再重复报告。如我们所见,编写自定义代理非常简单。观察包括一个 NumPy 像素数组,操作是 VNC 事件(鼠标/键盘交互)的列表:
import gym
import universe # register Universe environments into Gym
# Universe [environment ID]
env = gym.make('flashgames.DuskDrive-v0')
"""
If using docker-machine, replace "localhost" with specific Docker IP
"""
env.configure(remotes="vnc://localhost:5900+15900")
observation_n = env.reset()
while True:
# agent which presses the Up arrow 60 times per second
action_n = [[('KeyEvent', 'ArrowUp', True)] \
for _ in observation_n]
observation_n, reward_n, done_n, info = env.step(action_n)
env.render()
通过利用相同的 VNC 连接,用户可以观看代理的操作,并使用键盘和鼠标发送操作命令。VNC 界面将环境管理作为服务器进程,使我们能够在远程机器上运行环境,从而可以利用内部计算集群或云解决方案。更多信息,请参阅 OpenAI Universe 网站(openai.com/blog/universe/)。
验证 Universe 基础设施
Universe 的一个固有问题是架构选择带来的观察和执行操作的延迟。事实上,代理必须实时运行,并对波动的操作和观察延迟负责。大多数环境目前无法用现有技术解决,但 Universe 的创建者进行了测试,确保强化学习代理确实能够学习。在这些测试中,针对 Atari 游戏、Flash 游戏和浏览器任务的奖励趋势验证了即使在如此复杂的环境下,仍然能够取得成果。
现在我们已经介绍了 OpenAI 的强化学习工具,我们可以继续学习如何在这个上下文中使用 TensorFlow。
用于强化学习的 TensorFlow
在本节中,我们将学习如何使用 TensorFlow 创建、运行和保存一个策略网络。策略网络是强化学习的基础之一,甚至可以说是最重要的一部分。正如本书中所展示的,它们是知识容器的强大实现,帮助代理基于环境观察选择行动。
使用 TensorFlow 实现策略网络
构建策略网络与构建常见的深度学习模型并没有太大的不同。它的目标是根据其接收到的输入输出“最佳”动作,这代表环境的观察。因此,它充当环境状态与与之关联的最优代理行为之间的链接。在这里,最优意味着做出最大化代理累计预期奖励的行为。
为了尽可能清晰,我们将重点放在这里的一个特定问题上,但是相同的方法可以用来解决其他任务,比如控制机器人手臂或教授人形机器人步态。我们将看到如何为经典控制问题创建策略网络,这也将是本章后续练习的核心。这个问题是“CartPole”问题:目标是保持垂直杆的平衡,使其始终保持直立。在这里,唯一的方法是沿 x 轴的任一方向移动小车。下图显示了这个问题的一个帧:

图 4.11:CartPole 控制问题
正如我们之前提到的,策略网络将环境的观察与代理可以采取的动作联系起来。因此,它们分别充当输入和输出。
正如我们在前一章中看到的,这是构建神经网络所需的第一个信息。要检索输入和输出维度,您必须实例化环境(在本例中,通过 OpenAI Gym 实现),并打印关于观察和动作空间的信息。
让我们通过完成以下练习来执行这个第一个任务。
练习 4.02:使用 TensorFlow 构建策略网络
在这个练习中,我们将学习如何为给定的 Gym 环境使用 TensorFlow 构建策略网络。我们将学习如何考虑其观察空间和动作空间,这构成了网络的输入和输出。然后,我们将创建一个深度学习模型,该模型能够根据环境观察生成代理的行动。这个网络是需要训练的部分,也是每个强化学习算法的最终目标。按照以下步骤完成这个练习:
-
导入所需的模块:
import numpy as np import gym import tensorflow as tf -
实例化环境:
env = gym.make('CartPole-v0') -
打印出动作和观测空间:
print("Action space =", env.action_space) print("Observation space =", env.observation_space)这将打印如下内容:
Action space = Discrete(2) Observation space = Box(4,) -
打印出动作和观测空间的维度:
print("Action space dimension =", env.action_space.n) print("Observation space dimension =", \ env.observation_space.shape[0])输出如下所示:
Action space dimension = 2 Observation space dimension = 4正如您从前面的输出中可以看到的那样,动作空间是一个离散空间,维度为
2,意味着它可以取值0或1。观测空间是Box类型,维度为4,意味着它由四个实数组成,位于下限和上限之内,正如我们已经在 CartPole 环境中看到的那样,它们是 [±2.4,± inf,±0.20943951,±inf]。有了这些信息,现在可以构建一个可以与 CartPole 环境交互的策略网络。以下代码块展示了多种可能的选择之一:它使用了两个具有
64个神经元的隐藏层和一个具有2个神经元的输出层(因为这是动作空间的维度),并使用softmax激活函数。模型总结打印出了模型的大致结构。 -
构建策略网络并打印其总结:
model = tf.keras.Sequential\ ([tf.keras.layers.Dense(64, activation='relu', \ input_shape=[env.observation_space.shape[0]]), \ tf.keras.layers.Dense(64, activation='relu'), \ tf.keras.layers.Dense(env.action_space.n, \ activation="softmax")]) model.summary()输出结果如下:
Model: "sequential_2" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= dense (Dense) (None, 64) 320 _________________________________________________________________ dense_1 (Dense) (None, 64) 4160 _________________________________________________________________ dense_2 (Dense) (None, 2) 130 ================================================================= Total params: 4,610 Trainable params: 4,610 Non-trainable params: 0
如你所见,模型已经创建,并且我们也有一个详细的模型总结,它为我们提供了关于模型的重要信息,包括网络的层次结构、参数等。
注意
要访问此特定部分的源代码,请参考packt.live/3fkxfce。
你也可以在packt.live/2XSXHnF上在线运行这个示例。
一旦策略网络构建并初始化完成,就可以输入数据了。当然,由于网络尚未训练,它会生成随机的输出,但仍然可以使用,例如在选择的环境中运行一个随机智能体。这正是我们在接下来的练习中将要实现的:神经网络模型将通过predict方法接受环境步骤或reset函数提供的观察,并输出动作的概率。选择具有最高概率的动作,并用它在环境中进行步进,直到回合结束。
练习 4.03:用环境状态表示喂给策略网络
在本练习中,我们将用环境状态表示喂给策略网络。这个练习是练习 4.02,使用 TensorFlow 构建策略网络的延续,因此,为了完成它,你需要执行前一个练习的所有步骤,然后直接开始这个练习。按照以下步骤完成本练习:
-
重置环境:
t = 1 observation = env.reset() -
启动一个循环,直到回合完成。渲染环境并打印观察结果:
while True: env.render() # Print the observation print("Observation = ", observation) -
将环境观察输入网络,让它选择合适的动作并打印:
action_probabilities =model.predict\ (np.expand_dims(observation, axis=0)) action = np.argmax(action_probabilities) print("Action = ", action) -
通过所选的动作步进环境。打印接收到的奖励,并在终止状态达到时关闭环境:
observation, reward, done, info = env.step(action) # Print received reward print("Reward = ", reward) # If terminal state reached, close the environment if done: print("Episode finished after {} timesteps".format(t+1)) break t += 1 env.close()这将产生以下输出(仅显示最后几行):
Observation = [-0.00324467 -1.02182257 0.01504633 1.38740738] Action = 0 Reward = 1.0 Observation = [-0.02368112 -1.21712879 0.04279448 1.684757 ] Action = 0 Reward = 1.0 Observation = [-0.0480237 -1.41271906 0.07648962 1.99045154] Action = 0 Reward = 1.0 Observation = [-0.07627808 -1.60855467 0.11629865 2.30581208] Action = 0 Reward = 1.0 Observation = [-0.10844917 -1.80453455 0.16241489 2.63191088] Action = 0 Reward = 1.0 Episode finished after 11 timesteps注意
要访问此特定部分的源代码,请参考
packt.live/2AmwUHw。你也可以在
packt.live/3kvuhVQ上在线运行这个示例。
通过完成这个练习,我们已经构建了一个策略网络,并用它来指导智能体在 Gym 环境中的行为。目前,它的行为是随机的,但除了策略网络训练(将在后续章节中解释)之外,整体框架的其他部分已经就绪。
如何保存一个策略网络
强化学习的目标是有效地训练网络,使其学会如何在每个给定的环境状态下执行最优动作。RL 理论研究的是如何实现这一目标,正如我们将看到的那样,不同的方法已经取得了成功。如果其中一种方法已经应用于之前的网络,那么训练后的模型需要被保存,以便在每次运行智能体时加载。
要保存策略网络,我们需要遵循保存普通神经网络的相同步骤,将所有层的权重保存到文件中,以便在以后加载到网络中。以下代码展示了这一实现的例子:
save_dir = "./"
model_name = "modelName"
print("Saving best model to {}".format(save_dir))
model.save_weights(os.path.join(save_dir,\
'model_{}.h5'.format(model_name)))
这将产生以下输出:
Saving best model to ./
在这一部分,我们学习了如何使用 TensorFlow 创建、运行并保存一个策略网络。一旦输入(环境状态/观察)和输出(智能体可以执行的动作)明确后,它与标准的深度神经网络没有太大区别。该模型也被用于运行智能体。当输入环境状态时,它为智能体生成了需要执行的动作。由于网络尚未经过训练,智能体的行为是随机的。本节唯一缺少的部分是如何有效地训练策略网络,这也是强化学习的目标,书中将在后续章节中详细讲解。
现在我们已经学会了如何使用 TensorFlow 构建一个策略网络,接下来让我们深入探讨另一个 OpenAI 资源,它将帮助我们轻松训练一个强化学习(RL)智能体。
OpenAI Baselines
到目前为止,我们已经研究了两种不同的框架,它们使我们能够解决强化学习问题(OpenAI Gym 和 OpenAI Universe)。我们还研究了如何使用 TensorFlow 创建智能体的大脑——策略网络。
下一步是训练智能体,并让它仅通过经验学会如何采取最优行动。学习如何训练一个 RL 智能体是本书的终极目标。我们将看到最先进的方法是如何工作的,并了解它们所有的内部元素和算法。但即使在我们深入了解这些方法如何实现之前,也可以依靠一些工具来简化任务。
OpenAI Baselines 是一个基于 Python 的工具,构建于 TensorFlow 之上,提供了一套高质量、最先进的强化学习算法实现库。它可以作为开箱即用的模块使用,也可以进行定制和扩展。我们将使用它来解决一个经典控制问题和一个经典的 Atari 视频游戏,通过训练一个定制的策略网络。
注意
请确保在继续之前,按照前言中提到的说明安装了 OpenAI Baselines。
近端策略优化
了解 近端策略优化(PPO)的高级概念是值得的。在描述这一最先进的强化学习算法时,我们将保持在最高的层级,因为要深入理解它的工作原理,你需要熟悉接下来几章中将介绍的主题,从而为你在本书结束时学习和构建其他最先进的强化学习方法做好准备。
PPO 是一种强化学习方法,属于策略梯度家族。该类算法的目标是直接优化策略,而不是构建一个值函数来生成策略。为了做到这一点,它们会实例化一个策略(在我们的案例中,是深度神经网络的形式),并构建一种计算梯度的方法,用于定义如何调整策略函数的近似参数(在我们这里是深度神经网络的权重),以直接改进策略。词语“proximal”(近端)暗示了这些方法的一个特定特点:在策略更新步骤中,调整策略参数时,更新会受到约束,从而避免策略偏离“起始策略”太远。所有这些方面都对用户透明,感谢 OpenAI Baselines 工具,它会在后台处理这些任务。你将在接下来的章节中了解这些方面。
注意
请参考以下论文以了解更多关于 PPO 的内容:arxiv.org/pdf/1707.06347.pdf。
命令行使用
如前所述,OpenAI Baselines 使我们能够轻松训练最先进的强化学习算法,用于 OpenAI Gym 问题。例如,以下代码片段在 Pong Gym 环境中训练一个 PPO 算法,训练步数为 2000 万步:
python -m baselines.run --alg=ppo2 --env=PongNoFrameskip-v4
--num_timesteps=2e7 --save_path=./models/pong_20M_ppo2
--log_path=./logs/Pong/
它将模型保存在用户定义的保存路径中,以便可以通过以下命令行指令重新加载策略网络的权重,并将训练好的智能体部署到环境中:
python -m baselines.run --alg=ppo2 --env=PongNoFrameskip-v4
--num_timesteps=0 --load_path=./models/pong_20M_ppo2 --play
通过仅修改命令行参数,你可以轻松地在每个 OpenAI Gym 环境中训练所有可用方法,而不需要了解它们的内部工作原理。
OpenAI Baselines 中的方法
OpenAI Baselines 为我们提供了以下强化学习算法实现:
-
A2C:优势行为者-评论家
-
ACER:具有经验回放的行为者-评论家
-
ACKTR:使用 Kronecker 因子化信赖域的行为者-评论家
-
DDPG:深度确定性策略梯度
-
DQN:深度 Q 网络
-
GAIL:生成对抗模仿学习
-
HER:后视经验回放
-
PPO2:近端策略优化
-
TRPO:信赖域策略优化
对于即将进行的练习和活动,我们将使用 PPO。
自定义策略网络架构
尽管 OpenAI Baselines 具有开箱即用的可用性,但它也可以进行自定义和扩展。特别是,作为本章接下来两节内容中将使用的部分,可以为策略网络架构的模块提供自定义定义。
有一个方面需要明确,那就是网络将作为环境状态或观测的编码器。OpenAI Baselines 将负责创建最终层,这一层负责将潜在空间(嵌入空间)与适当的输出层连接。后者的选择取决于所选环境的动作空间类型(是离散还是连续?可用动作有多少个?)。
首先,用户需要导入 Baselines 注册表,这允许他们定义一个自定义网络,并使用自定义名称进行注册。然后,他们可以通过使用自定义架构定义一个自定义的深度学习模型。通过这种方式,我们能够随意更改策略网络架构,测试不同的解决方案,以找到适合特定问题的最佳方案。实践示例将在接下来的练习中呈现。
现在,我们准备训练我们的第一个 RL 代理,并解决一个经典的控制问题。
训练 RL 代理解决经典控制问题
在本节中,我们将学习如何训练一个能够解决经典控制问题 CartPole 的强化学习代理,所有的学习都基于前面解释的概念。我们将利用 OpenAI Baselines,并根据前面部分的步骤,使用自定义的全连接网络作为策略网络,将其作为输入传递给 PPO 算法。
让我们快速回顾一下 CartPole 控制问题。这是一个经典的控制问题,具有连续的四维观测空间和离散的二维动作空间。记录的观测值包括小车沿运动轨迹的位移和速度,以及杆的角度和角速度。动作是小车在轨道上的左右移动。奖励是每一步不导致终止状态时为 +1.0,当杆的角度超过 15 度或小车移出设定的轨道边界(+/- 2.4)时,环境进入终止状态。如果在完成 200 步之前没有结束,环境就被认为是已解决的。
现在,让我们通过完成一个练习来将这些概念结合起来。
练习 4.04:使用 PPO 算法解决 CartPole 环境
本次练习中的 CartPole 问题将使用 PPO 算法解决。我们将采用两种略有不同的方法,以便学习使用 OpenAI Baselines 的两种方法。第一种方法将利用 Baselines 的基础设施,但采用自定义路径,其中使用用户定义的网络作为策略网络。在经过“手动”训练后,它将在环境中运行,而不依赖于 Baselines 的自动化。这将让你有机会深入了解底层发生了什么。第二种方法会更简单,我们将直接采用 Baselines 预定义的命令行接口。
将构建一个自定义的深度网络,该网络将编码环境状态并在潜在空间中创建嵌入。然后,OpenAI Baselines 模块将负责创建策略(和值)网络的剩余层,以将嵌入空间与动作空间连接起来。
我们还将创建一个特定的函数,该函数是通过自定义 OpenAI Baselines 函数创建的,目的是构建符合基础设施要求的 Gym 环境。虽然它本身没有特别的价值,但为了便于使用所有 Baselines 模块,这是必须的。
注意
为了正确运行此练习,您需要安装 OpenAI Baselines。请参阅前言以获取安装说明。
此外,为了正确训练 RL 智能体,需要多个回合,因此训练阶段可能需要几个小时才能完成。将在本次练习结束时提供预训练智能体的权重集,以便您可以查看训练好的智能体如何运行。
按照以下步骤完成此练习:
-
打开一个新的 Jupyter Notebook,并导入所有所需的模块,包括 OpenAI Baselines 和 TensorFlow,以使用 PPO 算法:
from baselines.ppo2.ppo2 import learn from baselines.ppo2 import defaults from baselines.common.vec_env import VecEnv, VecFrameStack from baselines.common.cmd_util import make_vec_env, make_env from baselines.common.models import register import tensorflow as tf -
定义并注册一个自定义的多层感知器作为策略网络。在此,定义了一些参数,使得您可以轻松控制网络架构,用户可以指定隐藏层的数量、每个隐藏层的神经元数量以及它们的激活函数:
@register("custom_mlp") def custom_mlp(num_layers=2, num_hidden=64, activation=tf.tanh): """ Stack of fully-connected layers to be used in a policy / q-function approximator Parameters: ---------- num_layers: int number of fully-connected layers (default: 2) num_hidden: int size of fully-connected layers (default: 64) activation: activation function (default: tf.tanh) Returns: ------- function that builds fully connected network with a given input tensor / placeholder """ def network_fn(input_shape): print('input shape is {}'.format(input_shape)) x_input = tf.keras.Input(shape=input_shape) h = x_input for i in range(num_layers): h = tf.keras.layers.Dense\ (units=num_hidden, \ name='custom_mlp_fc{}'.format(i),\ activation=activation)(h) network = tf.keras.Model(inputs=[x_input], outputs=[h]) network.summary() return network return network_fn -
创建一个函数,构建符合 OpenAI Baselines 要求格式的环境:
def build_env(env_id, env_type): if env_type in {'atari', 'retro'}: env = make_vec_env\ (env_id, env_type, 1, None, gamestate=None,\ reward_scale=1.0) env = VecFrameStack(env, 4) else: env = make_vec_env\ (env_id, env_type, 1, None,\ reward_scale=1.0, flatten_dict_observations=True) return env -
构建
CartPole-v0环境,选择必要的策略网络参数,并使用已导入的特定 PPOlearn函数进行训练:env_id = 'CartPole-v0' env_type = 'classic_control' print("Env type = ", env_type) env = build_env(env_id, env_type) hidden_nodes = 64 hidden_layers = 2 model = learn(network="custom_mlp", env=env, \ total_timesteps=1e4, num_hidden=hidden_nodes, \ num_layers=hidden_layers)在训练过程中,模型将产生类似于以下的输出:
Env type = classic_control Logging to /tmp/openai-2020-05-11-16-00-34-432546 input shape is (4,) Model: "model" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= input_1 (InputLayer) [(None, 4)] 0 _________________________________________________________________ custom_mlp_fc0 (Dense) (None, 64) 320 _________________________________________________________________ custom_mlp_fc1 (Dense) (None, 64) 4160 ================================================================= Total params: 4,480 Trainable params: 4,480 Non-trainable params: 0 _________________________________________________________________ ------------------------------------------- | eplenmean | 22.3 | | eprewmean | 22.3 | | fps | 696 | | loss/approxkl | 0.00013790815 | | loss/clipfrac | 0.0 | | loss/policy_entropy | 0.6929994 | | loss/policy_loss | -0.0029695872 | | loss/value_loss | 44.237858 | | misc/explained_variance | 0.0143 | | misc/nupdates | 1 | | misc/serial_timesteps | 2048 | | misc/time_elapsed | 2.94 | | misc/total_timesteps | 2048 |这显示了策略网络的架构,以及一些与训练过程相关的量的记账信息,其中前两个是,例如,平均回合长度和平均回合奖励。
-
在环境中运行训练好的智能体并打印累计奖励:
obs = env.reset() if not isinstance(env, VecEnv): obs = np.expand_dims(np.array(obs), axis=0) episode_rew = 0 while True: actions, _, state, _ = model.step(obs) obs, reward, done, info = env.step(actions.numpy()) if not isinstance(env, VecEnv): obs = np.expand_dims(np.array(obs), axis=0) env.render() print("Reward = ", reward) episode_rew += reward if done: print('Episode Reward = {}'.format(episode_rew)) break env.close()输出应类似于以下内容:
#[...] Reward = [1.] Reward = [1.] Reward = [1.] Reward = [1.] Reward = [1.] Reward = [1.] Reward = [1.] Reward = [1.] Reward = [1.] Reward = [1.] Episode Reward = [28.] -
使用内置的 OpenAI Baselines 运行脚本,在
CartPole-v0环境中训练 PPO:!python -m baselines.run --alg=ppo2 --env=CartPole-v0 --num_timesteps=1e4 --save_path=./models/CartPole_2M_ppo2 --log_path=./logs/CartPole/输出的最后几行应该类似于以下内容:
------------------------------------------- | eplenmean | 20.8 | | eprewmean | 20.8 | | fps | 675 | | loss/approxkl | 0.00041882397 | | loss/clipfrac | 0.0 | | loss/policy_entropy | 0.692711 | | loss/policy_loss | -0.004152138 | | loss/value_loss | 42.336742 | | misc/explained_variance | -0.0112 | | misc/nupdates | 1 | | misc/serial_timesteps | 2048 | | misc/time_elapsed | 3.03 | | misc/total_timesteps | 2048 | ------------------------------------------- -
使用内置的 OpenAI Baselines 运行脚本在
CartPole-v0环境中运行训练好的模型:!python -m baselines.run --alg=ppo2 --env=CartPole-v0 --num_timesteps=0 --load_path=./models/CartPole_2M_ppo2 --play输出的最后几行应类似于以下内容:
episode_rew=27.0 episode_rew=27.0 episode_rew=11.0 episode_rew=11.0 episode_rew=13.0 episode_rew=29.0 episode_rew=28.0 episode_rew=14.0 episode_rew=18.0 episode_rew=25.0 episode_rew=49.0 episode_rew=26.0 episode_rew=59.0 -
使用提供的预训练权重查看训练代理的表现:
!wget -O cartpole_1M_ppo2.tar.gz \ https://github.com/PacktWorkshops/The-Reinforcement-Learning-\ Workshop/blob/master/Chapter04/cartpole_1M_ppo2.tar.gz?raw=true输出将类似于以下内容:
Saving to: 'cartpole_1M_ppo2.tar.gz' cartpole_1M_ppo2.ta 100%[===================>] 53,35K --.-KB/s in 0,05s 2020-05-11 15:57:07 (1,10 MB/s) - 'cartpole_1M_ppo2.tar.gz' saved [54633/54633]你可以使用以下命令读取
.tar文件:!tar xvzf cartpole_1M_ppo2.tar.gz输出的最后几行应类似于以下内容:
cartpole_1M_ppo2/ckpt-1.index cartpole_1M_ppo2/ckpt-1.data-00000-of-00001 cartpole_1M_ppo2/ cartpole_1M_ppo2/checkpoint -
使用内置的 OpenAI Baselines 运行脚本在 CartPole 环境上训练 PPO:
!python -m baselines.run --alg=ppo2 --env=CartPole-v0 --num_timesteps=0 --load_path=./cartpole_1M_ppo2 –play输出将类似于以下内容:
episode_rew=16.0 episode_rew=200.0 episode_rew=200.0 episode_rew=200.0 episode_rew=26.0 episode_rew=176.0这一步将向你展示一个训练好的代理如何行为,从而能够解决 CartPole 环境。它使用一组为策略网络准备好的权重。输出将类似于步骤 5中所示,确认环境已被解决。
注意
若要访问此特定部分的源代码,请参阅
packt.live/2XS69n8。本节目前没有在线交互示例,需要在本地运行。
在本次练习中,我们学习了如何训练一个强化学习代理,解决经典的 CartPole 控制问题。我们成功地使用了一个自定义的全连接网络作为策略网络。这使我们得以窥见 OpenAI Baselines 命令行界面提供的自动化背后的运行机制。在这个实践练习中,我们还熟悉了 OpenAI Baselines 的现成方法,确认它是一个可以轻松使用来训练强化学习代理的简单资源。
活动 4.01:训练强化学习代理来玩经典视频游戏
在本次活动中,挑战是采用我们在练习 4.04中使用的方法,使用 PPO 算法解决 CartPole 环境,创建一个能够在经典的 Atari 视频游戏 Pong 中实现超越人类表现的强化学习机器人。游戏表示方式如下:两个挡板,每个玩家一个,可以上下移动。目标是让白色球通过对方的挡板来得分。游戏在其中一个玩家的得分达到21时结束。
需要采用类似于我们在练习 4.04中看到的方法,使用 PPO 算法解决 CartPole 环境,并使用自定义卷积神经网络,它将作为环境观察(像素帧)的编码器:

图 4.12:Pong 游戏的一帧画面
将使用 OpenAI Gym 创建环境,同时使用 OpenAI Baselines 模块训练一个自定义的策略网络,利用 PPO 算法。
正如我们在练习 4.04中看到的,使用 PPO 算法解决 CartPole 环境,自定义方法(即使用特定的 OpenAI 模块)和简单方法(即使用内置的一般命令行接口)都会实现(分别在步骤 1到5和步骤 6中)。
注意
为了运行这个练习,你需要安装 OpenAI Baselines。请参考前言中的安装说明。
为了正确训练 RL 代理,需要多个回合,因此训练阶段可能需要几个小时才能完成。你可以在这个地址找到一组你可以使用的预训练代理权重:packt.live/2XSY4yz。使用它们来查看训练好的代理的表现。
以下步骤将帮助你完成这个活动:
-
从 OpenAI Baselines 和 TensorFlow 中导入所有需要的模块,以便使用
PPO算法。 -
定义并注册一个自定义的卷积神经网络作为策略网络。
-
创建一个函数来构建符合 OpenAI Baselines 要求的环境格式。
-
构建
PongNoFrameskip-v4环境,选择所需的策略网络参数并进行训练。 -
在环境中运行训练好的代理,并打印累计奖励。
-
使用内置的 OpenAI Baselines 运行脚本在
PongNoFrameskip-v0环境中训练 PPO。 -
使用内置的 OpenAI Baselines 运行脚本在
PongNoFrameskip-v0环境中运行训练好的模型。 -
使用提供的预训练权重来查看训练好的代理的表现。
在这个活动的结束时,代理应该能够大部分时间轻松获胜。
代理的最终得分大部分时间应类似于以下帧所表示的得分:
![图 4.13:渲染后的实时环境的一帧]()
图 4.13:渲染后的实时环境的一帧
注意
本活动的解决方案可以在第 704 页找到。
总结
本章向我们介绍了一些关键的技术和概念,让我们可以开始学习强化学习。前两节介绍了两个 OpenAI 工具,OpenAI Gym 和 OpenAI Universe。这些都是包含大量控制问题的集合,涵盖了广泛的背景和情境,从经典任务到视频游戏,从浏览器使用到算法推导。我们学习了这些环境的接口是如何被规范化的,如何与它们进行交互,以及如何为特定问题创建自定义环境。接着,我们学习了如何使用 TensorFlow 构建策略网络,如何根据环境状态输入以获取相应的动作,并且如何保存策略网络的权重。我们还研究了另一个 OpenAI 资源,Baselines。我们解决了一些问题,演示了如何训练强化学习代理程序来解决经典的控制任务。最后,在本章介绍的所有元素的基础上,我们构建了一个代理程序,并训练它玩经典的 Atari 视频游戏,从而实现了超越人类的表现。
在下一章中,我们将深入探讨强化学习中的动态规划。
第五章:5. 动态规划
概述
在本章中,您将了解动态规划的驱动原理。您将了解经典的零钱兑换问题,并将其作为动态规划的应用。此外,您还将学习如何实现策略评估、策略迭代和价值迭代,并了解它们之间的差异。到本章结束时,您将能够使用强化学习(RL)中的动态规划来解决问题。
引言
在上一章中,我们介绍了 OpenAI Gym 环境,并学习了如何根据应用需求实现自定义环境。您还了解了 TensorFlow 2 的基础知识,如何使用 TensorFlow 2 框架实现策略,以及如何使用 TensorBoard 可视化学习成果。在本章中,我们将从计算机科学的角度,了解动态规划(DP)的一般工作原理。接着,我们将讨论它在强化学习中的使用方式及其原因。然后,我们将深入探讨经典的动态规划算法,如策略评估、策略迭代和价值迭代,并进行比较。最后,我们将实现经典零钱兑换问题中的算法。
动态规划是计算机科学中最基本和最基础的主题之一。此外,强化学习算法,如价值迭代、策略迭代等,正如我们将看到的,使用相同的基本原理:避免重复计算以节省时间,这正是动态规划的核心。动态规划的哲学并不新鲜;一旦学会了解决方法,它是显而易见且普遍的。真正困难的部分是识别一个问题是否可以用动态规划来解决。
这个基本原理也可以用简单的方式向孩子解释。想象一下在一个盒子里数糖果的数量。如果你知道盒子里有 100 颗糖果,而店主又给了你 5 颗额外的糖果,你就不会重新开始数糖果。你会利用已有的信息,将 5 颗糖果加到原来的数量上,并说:“我有 105 颗糖果。”这就是动态规划的核心:保存中间信息并在需要时重新利用,以避免重复计算。虽然听起来简单,但如前所述,真正困难的部分是确定一个问题是否可以用动态规划来解决。正如我们稍后在识别动态规划问题一节中所看到的,问题必须满足特定的前提条件,如最优子结构和重叠子问题,才能用动态规划解决,我们将在识别动态规划问题一节中详细研究。一旦一个问题符合要求,就有一些著名的技术,比如自顶向下的备忘录法,即以无序的方式保存中间状态,以及自底向上的表格法,即将状态保存在有序的数组或矩阵中。
结合这些技巧可以显著提升性能,相比使用暴力算法进行求解。另外,随着操作次数的增加,时间差异也会变得更加明显。从数学角度来说,使用动态规划求解的方案通常在 O(n²)时间内运行,而暴力算法则需要 O(2ⁿ)时间,其中"O"(大 O 符号)可以粗略理解为执行的操作次数。所以,举个例子,如果 N=500,这是一个相对较小的数字,动态规划算法大约需要执行 500²次操作,而暴力算法则需要执行 2500 次操作。作为参考,太阳中有 280 个氢原子,这个数字无疑要比 2500 小得多。
以下图展示了两种算法执行操作次数的差异:

图 5.1:可视化大 O 值
现在我们开始研究求解动态规划问题的方法。
求解动态规划问题
解决动态规划问题的两种常见方法是:表格法和备忘录法。在表格法中,我们构建一个矩阵,在查找表中逐一存储中间值。另一方面,在备忘录法中,我们以非结构化的方式存储相同的值。这里所说的非结构化方式是指查找表可能一次性填满所有内容。
想象你是一个面包师,正在向商店出售蛋糕。你的工作是出售蛋糕并获得最大利润。为了简化问题,我们假设所有其他成本都是固定的,而你产品的最高价格就是利润的唯一指标,这在大多数商业案例中是合理的假设。所以,自然地,你会希望把所有蛋糕卖给提供最高价格的商店,但你需要做出决定,因为有多个商店提供不同价格和不同大小的蛋糕。因此,你有两个选择:卖多少蛋糕,和选择哪家商店进行交易。为了这个例子,我们将忽略其他变量,假设没有额外的隐藏成本。我们将使用表格法和备忘录法来解决这个问题。
正式描述问题时,你有一个重量为 W 的蛋糕,以及一个各个商店愿意提供的价格数组,你需要找出能够获得最高价格(根据之前的假设,也就是最高利润)的最优配置。
注意
在接下来本节列出的代码示例中,我们将利润和价格互换使用。所以,例如,如果你遇到一个变量,如best_profit,它也可以表示最佳价格,反之亦然。
比如说,假设 W = 5,意味着我们有一个重 5 千克的蛋糕,以下表格中列出的价格是餐馆所提供的价格:

图 5.2:不同重量蛋糕的不同价格
现在考虑餐厅 A 支付 10 美元购买 1 千克蛋糕,但支付 40 美元购买 2 千克蛋糕。那么问题是:我应该将 5 千克的蛋糕分割成 5 个 1 千克的切片出售,总价为 45 美元,还是应该将整个 5 千克的蛋糕作为一个整体卖给餐厅 B,后者提供 80 美元?在这种情况下,最优的配置是将蛋糕分割成 3 千克的部分,售价 50 美元,和 2 千克的部分,售价 40 美元,总计 90 美元。以下表格显示了各种分割方式及其对应的价格:

图 5.3:蛋糕分割的不同组合
从前面的表格来看,显然最佳的价格由 2 千克+3 千克的组合提供。但为了真正理解暴力破解法的局限性,我们假设我们不知道哪个组合能够获得最大价格。我们将尝试用代码实现暴力破解法。实际上,对于一个实际的商业问题,观察的数据量可能过大,以至于你无法像在这里一样快速得到答案。前面的表格只是一个例子,帮助你理解暴力破解法的局限性。
那么,让我们尝试使用暴力破解法解决这个问题。我们可以稍微重新表述这个问题:在每个决策点,我们有一个选择——分割或不分割。如果我们首先选择将蛋糕分割成两部分不等的部分,左侧部分可以视为蛋糕的一部分,右侧部分则视为独立分割。在下一次迭代中,我们只集中于右侧部分/其他部分。然后,我们再次可以对右侧部分进行分割,右侧部分成为进一步分割的蛋糕部分。这种模式也称为递归。

图 5.4:蛋糕分割成几块
在前面的图中,我们可以看到蛋糕被分割成多个部分。对于一块 5 千克重的蛋糕(假设你可以按照每个分割部分至少 1 千克的重量进行分割,因此每个分割部分的重量只能是 1 的整数倍),我们会看到"分割或不分割"一共出现了 32 次;如下所示:
2 x 2 x 2 x 2 x 2 = 25= 32
所以,首先让我们这样做:对于每一种 32 种可能的组合,计算总价,最后报告价格最高的组合。我们已经定义了价格列表,其中索引表示切片的重量:
PRICES = ["NA", 9, 40, 50, 70, 80]
例如,出售一整个 1 千克的蛋糕,售价为 9 美元;而出售一个 2 千克的蛋糕/切片,售价为 40 美元。零索引处的价格为 NA,因为我们不可能有 0 千克重的蛋糕。以下是实现上述情境的伪代码:
def partition(cake_size):
"""
Partitions a cake into different sizes, and calculates the
most profitable cut configuration
Args:
cake_size: size of the cake
Returns:
the best profit possible
"""
if cake_size == 0:
return 0
best_profit = -1
for i in range(1, cake_size + 1):
best_profit = max(best_profit, PRICES[i] \
+ partition(cake_size - i))
return best_profit
上面的partition函数,cake_size将接受一个整数输入:蛋糕的大小。然后,在for循环中,我们会以每种可能的方式切割蛋糕并计算最佳利润。由于我们对每个位置都做出分割/不分割的决策,代码运行的时间复杂度是 O(2n)。现在让我们使用以下代码来调用该函数。if __name__块将确保代码仅在运行脚本时执行(而不是在导入时):
if __name__ == '__main__':
size = 5
best_profit_result = partition(size)
print(f"Best profit: {best_profit_result}")
运行后,我们可以看到大小为5的蛋糕的最佳利润:
Best profit: 90
上述方法解决了计算最大利润的问题,但它有一个巨大的缺陷:非常慢。我们正在进行不必要的计算,并且要遍历整个搜索树(所有可能的组合)。为什么这是个坏主意?想象一下你从 A 点旅行到 C 点,费用是$10。你会考虑从 A 到 B,再到 D,再到 F,最后到 C,可能需要花费$150 吗?当然不会,对吧?这个思路是类似的:如果我知道当前的路径不是最优路径,为什么还要去探索那条路?
为了更高效地解决这个问题,我们将研究两种优秀的技术:表格法和备忘录法。它们的原理相同:避免无效的探索。但它们使用略有不同的方式来解决问题,正如你将看到的那样。
接下来我们将深入研究备忘录法。
备忘录法
备忘录法是指一种方法,在这种方法中,我们将中间输出的结果保存在一个字典中,供以后使用,也就是所谓的备忘录。因此得名“备忘录法”。
回到我们的蛋糕分割示例,如果我们修改partition函数,并打印cake_size的值以及该大小的最佳解决方案,就会发现一个新的模式。使用之前暴力方法中相同的代码,我们添加一个print语句来显示蛋糕大小及对应的利润:
def partition(cake_size):
"""
Partitions a cake into different sizes, and calculates the
most profitable cut configuration
Args:
cake_size: size of the cake
Returns:
the best profit possible
"""
if cake_size == 0:
return 0
best_profit = -1
for i in range(1, cake_size + 1):
best_profit = max(best_profit, PRICES[i] \
+ partition(cake_size - i))
print(f"Best profit for size {cake_size} is {best_profit}")
return best_profit
使用main块调用函数:
if __name__ == '__main__':
size = 5
best_profit_result = partition(size)
print(f"Best profit: {best_profit_result}")
然后我们会看到如下输出:
Best profit for size 1 is 9
Best profit for size 2 is 40
Best profit for size 1 is 9
Best profit for size 3 is 50
Best profit for size 1 is 9
Best profit for size 2 is 40
Best profit for size 1 is 9
Best profit for size 4 is 80
Best profit for size 1 is 9
Best profit for size 2 is 40
Best profit for size 1 is 9
Best profit for size 3 is 50
Best profit for size 1 is 9
Best profit for size 2 is 40
Best profit for size 1 is 9
Best profit for size 5 is 90
Best profit: 90
正如前面的输出所示,这里有一个模式——对于给定大小的最佳利润保持不变,但我们计算了多次。特别需要注意的是计算的大小和顺序。它会先计算大小为 1 的利润,然后是 2,当它要计算大小为 3 时,它会从头开始计算,首先是 1,然后是 2,最后是 3。这种情况会不断重复,因为它不存储任何中间结果。一个显而易见的改进是将利润存储在一个备忘录中,然后稍后使用它。
我们在这里做了一个小修改:如果给定cake_size的best_profit已经计算过,我们就直接使用它,而不再重新计算,代码如下所示:
if cake_size == 0:
return 0
if cake_size in memo:
return memo[cake_size]
现在让我们看一下完整的代码片段:
def memoized_partition(cake_size, memo):
"""
Partitions a cake into different sizes, and calculates the
most profitable cut configuration using memoization.
Args:
cake_size: size of the cake
memo: a dictionary of 'best_profit' values indexed
by 'cake_size'
Returns:
the best profit possible
"""
if cake_size == 0:
return 0
if cake_size in memo:
return memo[cake_size]
else:
best_profit = -1
for i in range(1, cake_size + 1):
best_profit = max(best_profit, \
PRICES[i] + memoized_partition\
(cake_size - i, memo))
print(f"Best profit for size {cake_size} is {best_profit}")
memo[cake_size] = best_profit
return best_profit
现在如果我们运行这个程序,我们将得到以下输出:
Best profit for size 1 is 9
Best profit for size 2 is 40
Best profit for size 3 is 50
Best profit for size 4 is 80
Best profit for size 5 is 90
Best profit: 90
在这里,我们不是运行计算 2n 次,而是只运行n次。这是一个巨大的改进。我们所需要做的只是将输出结果保存在字典或备忘录中,因此这种方法叫做备忘录化。在这种方法中,我们本质上将中间解保存到字典中,以避免重新计算。这个方法也被称为自顶向下方法,因为我们遵循自然顺序,类似于在二叉树中查找,例如。
接下来,我们将探讨表格方法。
表格方法
使用备忘录化方法,我们随意地存储中间计算结果。表格方法几乎做了相同的事情,只是方式稍有不同:它按预定顺序进行,这几乎总是固定的——从小到大。这意味着,为了获得最有利的切割,我们将首先获得 1 公斤蛋糕的最有利切割,然后是 2 公斤蛋糕,接着是 3 公斤蛋糕,依此类推。通常使用矩阵完成此操作,这被称为自底向上方法,因为我们先解决较小的问题。
考虑以下代码片段:
def tabular_partition(cake_size):
"""
Partitions a cake into different sizes, and calculates the
most profitable cut configuration using tabular method.
Args:
cake_size: size of the cake
Returns:
the best profit possible
"""
profits = [0] * (cake_size + 1)
for i in range(1, cake_size + 1):
best_profit = -1
for current_size in range(1, i + 1):
best_profit = max(best_profit,\
PRICES[current_size] \
+ profits[i - current_size])
profits[i] = best_profit
return profits[cake_size]
输出结果如下:
Best profit: 90
在前面的代码中,我们首先遍历尺寸,然后是切割。一个不错的练习是使用 IDE 和调试器运行代码,查看 profits 数组是如何更新的。首先,它会找到大小为 1 的蛋糕的最大利润,然后找到大小为 2 的蛋糕的最大利润。但是在这里,第二个 for 循环会尝试两种配置:一种是切割(两块大小为 1 的蛋糕),另一种是不切割(一个大小为 2 的蛋糕),由 profits[i – current_size] 指定。现在,对于每个尺寸,它都会尝试在所有可能的配置中切割蛋糕,而不会重新计算较小部分的利润。例如,profits[i – current_size] 会返回最佳配置,而无需重新计算。
练习 5.01:实践中的备忘录化
在这个练习中,我们将尝试使用备忘录化方法解决一个动态规划问题。问题如下:
给定一个数字 n,打印第 n 个三斐波那契数。三斐波那契数列类似于斐波那契数列,但使用三个数字而不是两个。这意味着,第 n 个三斐波那契数是前面三个数字的和。以下是一个示例:
斐波那契数列 0, 1, 2, 3, 5, 8……定义如下:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_05_05.jpg)
图 5.5:斐波那契数列
三斐波那契数列 0, 0, 1, 1, 2, 4, 7……定义如下:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_05_06.jpg)
图 5.6:三斐波那契数列
三斐波那契数列的广义公式如下:
Fibonacci(n) = Fibonacci(n – 1) + Fibonacci(n – 2)
Tribonacci(n) = Tribonacci(n – 1) \
+ Tribonacci(n – 2) + Tribonacci(n – 3)
以下步骤将帮助你完成练习:
-
现在我们知道了公式,第一步是用 Python 创建一个简单的递归实现。使用描述中的公式并将其转换为 Python 函数。你可以选择在 Jupyter notebook 中做,或者直接用一个简单的
.pyPython 文件:def tribonacci_recursive(n): """ Uses recursion to calculate the nth tribonacci number Args: n: the number Returns: nth tribonacci number """ if n <= 1: return 0 elif n == 2: return 1 else: return tribonacci_recursive(n - 1) \ + tribonacci_recursive(n - 2) \ + tribonacci_recursive(n - 3)在前面的代码中,我们递归地计算 Tibonacci 数的值。此外,如果数字小于或等于 1,我们知道答案将是 0,2 的答案将是 1,因此我们添加了
if-else条件来处理边缘情况。要测试前面的代码,只需在main块中调用它,并检查输出是否符合预期:if __name__ == '__main__': print(tribonacci_recursive(6)) -
正如我们所学到的,这个实现非常慢,并且随着
n的增加,增长速度呈指数级。现在,使用备忘录法,存储中间结果,以便它们不被重新计算。创建一个字典来检查该第n个 Tibonacci 数的答案是否已经添加到字典中。如果是,则直接返回;否则,尝试计算:def tribonacci_memo(n, memo): """ Uses memoization to calculate the nth tribonacci number Args: n: the number memo: the dictionary that stores intermediate results Returns: nth tribonacci number """ if n in memo: return memo[n] else: ans1 = tribonacci_memo(n - 1, memo) ans2 = tribonacci_memo(n - 2, memo) ans3 = tribonacci_memo(n - 3, memo) res = ans1 + ans2 + ans3 memo[n] = res return res -
现在,使用前面的代码片段,不使用递归来计算第
n个 Tibonacci 数。运行代码并确保输出与预期相符,通过在main块中运行它:if __name__ == '__main__': memo = {0: 0, 1: 0, 2: 1} print(tribonacci_memo(6, memo))输出结果如下:
7
如您在输出中看到的,和是7。我们已经学会了如何将一个简单的递归函数转换为记忆化的动态规划代码。
注意
要访问此特定部分的源代码,请参考packt.live/3dghMJ1。
您还可以在线运行此示例,网址为packt.live/3fFE7RK。
接下来,我们将尝试使用表格方法做同样的事情。
练习 5.02:表格法在实践中的应用
在这个练习中,我们将使用表格方法解决一个动态规划问题。练习的目标是识别两个字符串之间的最长公共子串的长度。例如,如果两个字符串分别是BBBABDABAA和AAAABDABBAABB,那么最长的公共子串是ABDAB。其他公共子串有AA、BB和BA,以及BAA,但它们不是最长的:
-
导入
numpy库:import numpy as np -
实现暴力法,首先计算两个字符串的最长公共子串。假设我们有两个变量
i和j,它们表示子串的开始和结束位置。使用这些指针来指示两个字符串中子串的开始和结束位置。您可以使用 Python 中的==运算符来查看字符串是否匹配:def lcs_brute_force(first, second): """ Use brute force to calculate the longest common substring of two strings Args: first: first string second: second string Returns: the length of the longest common substring """ len_first = len(first) len_second = len(second) max_lcs = -1 lcs_start, lcs_end = -1, -1 # for every possible start in the first string for i1 in range(len_first): # for every possible end in the first string for j1 in range(i1, len_first): # for every possible start in the second string for i2 in range(len_second): # for every possible end in the second string for j2 in range(i2, len_second): """ start and end position of the current candidates """ slice_first = slice(i1, j1) slice_second = slice(i2, j2) """ if the strings match and the length is the highest so far """ if first[slice_first] == second[slice_second] \ and j1 - i1 > max_lcs: # save the lengths max_lcs = j1 - i1 lcs_start = i1 lcs_end = j1 print("LCS: ", first[lcs_start: lcs_end]) return max_lcs -
使用
main块调用函数:if __name__ == '__main__': a = "BBBABDABAA" b = "AAAABDABBAABB" lcs_brute_force(a, b)我们可以验证输出是否正确:
LCS: ABDAB -
让我们实现表格方法。现在我们有了一个简单的解决方案,我们可以继续优化它。看看主循环,它嵌套了四次。这意味着该解决方案的运行时间是
O(N⁴)。无论我们是否有最长公共子串,解决方案都会执行相同的计算。使用表格方法得出更多的解决方案:def lcs_tabular(first, second): """ Calculates the longest common substring using memoization. Args: first: the first string second: the second string Returns: the length of the longest common substring. """ # initialize the table using numpy table = np.zeros((len(first), len(second)), dtype=int) for i in range(len(first)): for j in range(len(second)): if first[i] == second[j]: table[i][j] += 1 + table[i - 1][j - 1] print(table) return np.max(table)这个问题具有天然的矩阵结构。将其中一个字符串的长度视为矩阵的行,另一个字符串的长度视为矩阵的列。将该矩阵初始化为
0。矩阵中位置i, j的值将表示第一个字符串的第i个字符是否与第二个字符串的第j个字符相同。现在,最长公共子串将具有在对角线上最多的 1 个数字。利用这个事实,如果当前位子匹配且
i-1和j-1位置上有1,则将最大子串的长度增加 1。这将表明有两个连续的匹配。使用np.max(table)返回矩阵中的max元素。我们也可以查看对角线递增的序列,直到该值达到5。 -
使用
main模块调用该函数:if __name__ == '__main__': a = "BBBABDABAA" b = "AAAABDABBAABB" lcs_tabular(a, b)输出结果如下:
![图 5.7:LCS 输出结果]()
图 5.7:LCS 输出结果
如你所见,行(第一列)和列(第二列)之间存在直接的映射关系,因此 LCS(最长公共子序列)字符串只会是从 LCS 长度开始倒数的对角线元素。在前面的输出中,你可以看到最高的元素是 5,因此你知道长度是 5。LCS 字符串将是从元素5开始的对角线元素。字符串的方向总是向上对角线,因为列总是从左到右排列。请注意,解决方案仅仅是计算 LCS 的长度,而不是找到实际的 LCS。
注意
要访问这一特定部分的源代码,请参考 packt.live/3fD79BC。
你也可以在网上运行这个示例,网址是 packt.live/2UYVIfK。
现在我们已经学会了如何解决动态规划问题,接下来我们应该学习如何识别这些问题。
识别动态规划问题
虽然一旦识别出问题如何递归,解决动态规划问题就变得很容易,但确定一个问题是否可以通过动态规划来解决却是很困难的。例如,旅行商问题,你被给定一个图,并希望在最短的时间内覆盖所有的顶点,这是一个无法用动态规划解决的问题。每个动态规划问题必须满足两个先决条件:它应该具有最优子结构,并且应该有重叠子问题。我们将在接下来的部分中详细了解这些条件的含义以及如何解决它们。
最优子结构
回想一下我们之前讨论的最佳路径示例。如果你想从 A 点通过 B 点到达 C 点,并且你知道这是最佳路径,那么就没有必要探索其他路径。换句话说:如果我想从 A 点到达 D 点,而我知道从 A 点到 C 点的最佳路径,那么从 A 点到 D 点的最佳路径一定会包含从 A 点到 C 点的路径。这就是所谓的最优子结构。本质上,它意味着问题的最优解包含了子问题的最优解。记得我们在知道一个大小为n的蛋糕的最佳利润后,就不再重新计算它吗?因为我们知道,大小为n+1的蛋糕的最佳利润会包括n,这是在考虑切分蛋糕为大小n和1时得到的。再重复一遍,如果我们要使用动态规划(DP)解决问题,最优子结构的属性是一个必要条件。
重叠子问题
记得我们最初在设计蛋糕分配问题的暴力解法时,后来又采用了备忘录法。最初,暴力解法需要 32 步才能得到解,而备忘录法只需要 5 步。这是因为暴力解法重复执行相同的计算:对于大小为 3 的问题,它需要先解决大小为 2 和 1 的问题。然后,对于大小为 4 的问题,它又需要解决大小为 3、2 和 1 的问题。这个递归重计算是由于问题的性质:重叠子问题。这也是我们能够将答案保存在备忘录中,之后使用相同的解而不再重新计算的原因。重叠子问题是使用动态规划(DP)来解决问题的另一个必要条件。
硬币换零钱问题
硬币换零钱问题是软件工程面试中最常被问到的题目之一。题目很简单:给定一个硬币面额的列表,以及一个总和 N,找出到达该总和的不同方式的数量。例如,如果 N = 3 且硬币面额 D = {1, 2},那么答案是 2。也就是说,有两种方式可以得到 3:{1, 1, 1} 和 {2, 1}:
-
为了解决这个问题,你需要准备一个递归公式,计算得到一个总和的不同方式数。为此,你可以从一个简单的版本开始,先解决一个数字的情况,再尝试将其转换为更一般的解法。
-
最终输出可能是如下图所示的表格,可用于总结结果。在下表中,第一行表示面额,第一列表示总和。更具体地说,第一行的 0、1、2、3、4、5 表示总和,第一列表示可用的面额。我们将基础情况初始化为 1 而非 0,因为如果面额小于总和,则我们只是将之前的组合复制过来。
下表表示如何使用硬币 [1, 2] 来计算得到 5 的方法数:
![图 5.8:计算使用面额为 1 和 2 的硬币得到总和 5 的方法数]()
图 5.8:计算使用面额为 1 和 2 的硬币得到总和 5 的方法数
-
所以,我们可以看到使用面额为 1 和 2 的硬币得到总和 5 的方法数是 3,具体来说就是 1+1+1+1+1、2+1+1+1 和 2+2+1。记住,我们只考虑独特的方式,也就是说,2+2+1 和 1+2+2 是相同的。
让我们通过一个练习来解决硬币换零钱问题。
练习 5.03:解决硬币换零钱问题
在这个练习中,我们将解决经典且非常流行的硬币换零钱问题。我们的目标是找到用面额为 1、2 和 3 的硬币组合得到总和 5 的不同排列数。以下步骤将帮助你完成这个练习:
-
导入
numpy和pandas库:import numpy as np import pandas as pd -
现在,让我们尝试识别重叠子问题。如之前所述,有一个共同点:我们必须搜索所有可能的面额,并检查它们是否能加起来得到某个数。此外,这比蛋糕示例稍微复杂一些,因为我们有两个变量需要迭代:首先是面额,其次是总和(在蛋糕示例中,只有一个变量,即蛋糕大小)。因此,我们需要一个二维数组或矩阵。
在列上,我们将展示我们试图达到的和,而在行上,我们将考虑可用的各种面额。当我们遍历面额(列)时,我们将通过首先计算不考虑当前面额时达到某个和的方式数量,然后再加上考虑当前面额的方式数量,来计算合计数。这类似于蛋糕示例,其中我们首先进行切割,计算利润,然后不切割并计算利润。然而,区别在于这次我们会从上方的行中获取之前的最佳配置,并且我们会将这两个数相加,而不是选择其中的最大值,因为我们关心的是到达和的所有可能方式的总数。例如,使用 {1, 2} 求和为 4 的方式是首先使用 {2},然后加上求和为 4 - 2 = 2 的方式数量。我们可以从同一行获取这个值,索引为 2。我们还会将第一行初始化为 1,因为它们要么是无效的(使用 1 到达零的方式数量),要么是有效的,并且只有一个解决方案:
![图 5.9:算法的初始设置]()
def count_changes(N, denominations): """ Counts the number of ways to add the coin denominations to N. Args: N: number to sum up to denominations: list of coins Returns: """ print(f"Counting number of ways to get to {N} using coins:\ {denominations}") -
接下来,我们将初始化一个尺寸为
len(denomination)x(N + 1)的表格。列数是N + 1,因为索引包括零:table = np.ones((len(denominations), N + 1)).astype(int) # run the loop from 1 since the first row will always 1s for i in range(1, len(denominations)): for j in range(N + 1): if j < denominations[i]: """ If the index is less than the denomination then just copy the previous best """ table[i, j] = table[i - 1, j] else: """ If not, the add two things: 1\. The number of ways to sum up to N *without* considering the existing denomination. 2\. And, the number of ways to sum up to N minus the value of the current denomination (by considering the current and the previous denominations) """ table[i, j] = table[i - 1, j] \ + table[i, j - denominations[i]] -
现在,最后我们将打印出这个表格:
# print the table print_table(table, denominations) -
创建一个带有以下实用功能的 Python 脚本,它可以漂亮地打印表格。这对于调试非常有用。漂亮打印本质上是用来以更易读和更全面的方式呈现数据。通过将面额作为索引,我们可以更清晰地查看输出:
def print_table(table, denominations): """ Pretty print a numpy table Args: table: table to print denominations: list of coins Returns: """ df = pd.DataFrame(table) df = df.set_index(np.array(denominations)) print(df)注意
欲了解更多关于漂亮打印的细节,您可以参考以下链接的官方文档:
docs.python.org/3/library/pprint.html。 -
使用以下配置初始化脚本:
if __name__ == '__main__': N = 5 denominations = [1, 2] count_changes(N, denominations)输出将如下所示:
Counting number of ways to get to 5 using coins: [1, 2] 0 1 2 3 4 5 1 1 1 1 1 1 1 2 1 1 2 2 3 3
如我们在最后一行和列的条目中看到的,使用 [1, 2] 获得 5 的方式有 3 种。我们现在已经详细了解了动态规划(DP)的概念。
注意
要访问此特定部分的源代码,请参阅 packt.live/2NeU4lT。
您也可以在网上运行这个示例,访问 packt.live/2YUd6DD。
接下来,让我们看看它是如何用于解决强化学习中的问题的。
强化学习中的动态规划
DP 在 RL 中扮演着重要角色,因为在给定的时刻你所面临的选择太多。例如,机器人在当前环境状态下应该向左转还是向右转。为了求解此类问题,通过蛮力计算每个状态的结果是不可行的。然而,通过 DP,我们可以使用前一节中学到的方法来解决这一问题。
我们在前面的章节中已经看过贝尔曼方程。让我们重述一下基本内容,看看贝尔曼方程如何具备 DP 所需的两个属性。
假设环境是一个有限的马尔可夫决策过程(MDP),我们用一个有限的状态集 S 来定义环境的状态。这表示状态配置,例如机器人的当前位置。有限的动作集 A 给出了动作空间,有限的奖励集 R。我们用
来表示折扣率,这个值介于 0 和 1 之间。
给定一个状态 S,该算法使用一个确定性策略从 A 中选择一个动作,
。该策略仅仅是状态 S 和动作 A 之间的映射,例如,机器人可能做出的选择,如向左或向右。确定性策略允许我们以非随机的方式选择动作(与随机策略相对,后者包含显著的随机成分)。
为了具体化我们的理解,假设一个简单的自动驾驶汽车。为了简化起见,我们将在这里做一些合理的假设。动作空间可以定义为 {左转,右转,直行,倒退}。一个确定性策略是:如果地面上有个坑,向左或右转以避免它。然而,一个随机策略会说:如果地面上有个坑,以 80% 的概率向左转,这意味着汽车有小概率故意进入坑中。虽然这个动作目前看起来可能没有意义,但我们稍后会看到,在第七章,时间差学习中,这实际上是一个非常重要的举措,并且解决了 RL 中的一个关键概念:探索与利用的困境。
回到使用 DP 在 RL 中的原始点,下面是简化版的贝尔曼方程:

图 5.10:简化贝尔曼方程
完整方程与简化方程的唯一区别在于我们没有对
进行求和,这在非确定性环境下是有效的。以下是完整的贝尔曼方程:

图 5.11:完整的贝尔曼方程
在前面的方程中,
是值函数,表示处于特定状态时的奖励。我们稍后会更深入地探讨它。
是采取动作a的奖励,
是下一个状态的奖励。你可以观察到以下两点:
-
在
和
之间的递归关系,意味着
具有最优子结构。 -
计算
将在某些时刻需要重新计算,这意味着它存在重叠子问题。动态规划(DP)的两个条件都符合,因此我们可以利用它来加速解决方案。
如我们稍后将看到的,值函数的结构与我们在硬币面额问题中看到的类似。不同之处在于,我们不再保存到达和为的方式数量,而是保存最佳
,即能够带来最高回报的值函数的最佳值。接下来,我们将探讨策略迭代和价值迭代,它们是帮助我们解决 RL 问题的基本算法。
策略和价值迭代
解决强化学习(RL)问题的主要思路是利用值函数寻找最佳策略(决策方式)。这种方法对于简单的 RL 问题效果很好,因为我们需要了解整个环境的信息:状态的数量和动作空间。我们甚至可以在连续空间中使用此方法,但并不是在所有情况下都能得到精确的解。在更新过程中,我们必须遍历所有可能的场景,这也是当状态和动作空间过大时,使用该方法变得不可行的原因:
-
策略迭代:从一个随机策略开始,逐步收敛到最佳策略。
-
价值迭代:使用随机值初始化状态,并逐步更新它们直到收敛。
状态值函数
状态值函数是一个数组,表示处于该状态时的奖励。假设在一个特定的游戏中有四个可能的状态:S1、S2、S3和S4,其中S4是终止状态(结束状态)。状态值表可以通过一个数组表示,如下表所示。请注意,值只是示例。每个状态都有一个“值”,因此称为状态值函数。此表格可以用于游戏中稍后的决策:

图 5.12:状态值函数的示例表格
例如,如果你处于状态S3,你有两个可能的选择,S4和S2;你会选择S4,因为在那个状态中的值比S2更高。
动作值函数
动作-值函数是一个矩阵,表示每个状态-动作对的奖励。这同样可以用来选择在特定状态下应该采取的最佳动作。与之前的状态-动作表不同,这次我们为每个动作也关联了奖励,具体如下表所示:

图 5.13:动作-值函数的示例表格
请注意,这些只是示例值,实际计算时会使用特定的更新策略。我们将在策略改进部分查看更新策略的更具体示例。这个表格将稍后用于值迭代算法,因此我们可以迭代地更新表格,而不是等到最后一步。更多内容请参考值迭代部分。
OpenAI Gym:Taxi-v3 环境
在前面的章节中,我们已经了解了什么是 OpenAI Gym 环境,但这次我们将玩一个不同的游戏:Taxi-v3。在这个游戏中,我们将教导我们的代理司机接送乘客。黄色方块代表出租车。环境中有四个可能的地点,分别用不同的字符标记:R、G、B 和 Y,分别代表红色、绿色、蓝色和黄色,具体如下图所示。代理需要在某个地点接乘客并将其送到另一个地点。此外,环境中有用 | 表示的墙壁。每当有墙壁时,可能的动作数量就会受到限制,因为出租车不能穿越墙壁。这使得问题变得有趣,因为代理必须巧妙地在网格中导航,同时避开墙壁,找到最佳的(最短的)解决方案:

图 5.14:Taxi-v3 环境
以下是每个动作对应的奖励列表:
-
+20:成功接送时的奖励。
-
-1:每一步都会发生。这一点很重要,因为我们关注的是找到最短的路径。
-
-10:非法的接送操作。
策略
环境中的每个状态都由一个数字表示。例如,前一张照片中的状态可以用54来表示。在这个游戏中有 500 个这样的独特状态。对于每一个状态,我们都有相应的策略(即,应该执行的动作)。
现在,让我们自己尝试一下这个游戏。
初始化环境并打印可能的状态数和动作空间,当前分别为 500 和 6。在现实问题中,这个数字会非常庞大(可能达到数十亿),我们无法使用离散的代理。但为了简化问题,我们假设这些并进行求解:
def initialize_environment():
"""initialize the OpenAI Gym environment"""
env = gym.make("Taxi-v3")
print("Initializing environment")
# reset the current environment
env.reset()
# show the size of the action space
action_size = env.action_space.n
print(f"Action space: {action_size}")
# Number of possible states
state_size = env.observation_space.n
print(f"State space: {state_size}")
return env
上述代码将输出以下内容:

图 5.15:启动 Taxi-v3 环境
如你所见,网格表示当前(初始)状态的环境。黄色框代表出租车。六个可能的选择是:左、右、上、下、接客和放客。现在,让我们看看如何控制出租车。
使用以下代码,我们将随机地在环境中执行步骤并查看输出。env.step函数用于从一个状态转移到另一个状态。它接受的参数是其动作空间中的有效动作之一。在执行一步后,它会返回几个值,如下所示:
-
new_state:新的状态(一个表示下一个状态的整数) -
reward:从转移到下一个状态中获得的奖励 -
done:如果环境需要重置(意味着你已经到达了终止状态) -
info:表示转移概率的调试信息
由于我们使用的是确定性环境,因此转移概率始终为1.0。还有其他环境具有非 1 的转移概率,表示如果你做出某个决策;例如,如果你右转,环境将以相应的概率右转,这意味着即使做出特定的行动后,你也有可能停留在原地。代理在与环境互动时不能学习这些信息,否则如果代理知道环境信息,将会是不公平的:
def random_step(n_steps=5):
"""
Steps through the taxi v3 environment randomly
Args:
n_steps: Number of steps to step through
"""
# reset the environment
env = initialize_environment()
state = env.reset()
for i in range(n_steps):
# choose an action at random
action = env.action_space.sample()
env.render()
new_state, reward, done, info = env.step(action)
print(f"New State: {new_state}\n"\
f"reward: {reward}\n"\
f"done: {done}\n"\
f"info: {info}\n")\
print("*" * 20)
使用这段代码,我们将在环境中随机(但有效)地执行步骤,并在到达终止状态时停止。如果执行代码,我们将看到以下输出:

图 5.16:随机遍历环境
通过查看输出,我们可以看到在执行某个动作后,所经历的新状态以及执行该动作所获得的奖励;done会指示我们已经到达了终止阶段;还有一些环境信息,例如转移概率。接下来,我们将查看我们的第一个强化学习算法:策略迭代。
策略迭代
正如其名所示,在策略迭代中,我们会遍历多个策略,然后进行优化。策略迭代算法分为两步:
-
策略评估
-
策略改进
策略评估计算当前策略的值函数,初始时是随机的。然后,我们使用贝尔曼最优性方程更新每个状态的值。接着,一旦我们得到了新的值函数,就更新策略以最大化奖励并进行策略改进。现在,如果策略发生了更新(即使策略中的一个决策发生了变化),这个更新后的策略保证比旧的策略更好。如果策略没有更新,则意味着当前的策略已经是最优的(否则它会被更新并找到更好的策略)。
以下是策略迭代算法的工作步骤:
-
从一个随机策略开始。
-
计算所有状态的值函数。
-
更新策略,选择能够最大化奖励的行动(策略改进)。
-
当策略不再变化时停止。这表明已经获得了最优策略。
让我们手动通过算法进行一次干运行,看看它是如何更新的,使用一个简单的例子:
-
从一个随机策略开始。下表列出了代理在 Taxi-v3 环境中给定位置可以采取的可能行动:
![图 5.17:代理的可能行动]()
图 5.17:代理的可能行动
在前面的图中,表格表示环境,框表示选择。箭头表示如果代理处于该位置,应采取的行动。
-
计算所有唯一状态的值函数。下表列出了每个状态的样本状态值。值初始化为零(某些算法的变体也使用接近零的小随机值):
![图 5.18:每个状态的奖励值]()
图 5.18:每个状态的奖励值
为了直观地理解更新规则,我们使用一个极其简单的例子:
![图 5.19:理解更新规则的示例策略]()
图 5.19:理解更新规则的示例策略
从蓝色位置开始,经过第一步
policy_evaluation后,策略将到达绿色(终止)位置。值将按照以下方式更新(每次迭代都有一个图示):![图 5.20:每步奖励的乘法]()
图 5.20:每步奖励的乘法
每一步,奖励都会乘以 gamma(在此示例中为
0.9)。此外,在这个例子中,我们已经从最优策略开始,因此更新后的策略将与当前策略完全相同。 -
更新策略。让我们通过一个小例子来看看更新规则。假设以下是当前的值函数及其对应的策略:
![图 5.21:样本值函数及其对应的策略。]()
图 5.21:样本值函数及其对应的策略。
如前图所示,左侧的表格表示值,右侧的表格表示策略(决策)。
一旦我们执行更新,假设值函数变为如下所示:
![图 5.22:样本值函数的更新值]()
图 5.22:样本值函数的更新值
现在,策略将在每个单元格中更新,使得行动会带领代理到达能够提供最高奖励的状态,因此对应的策略将类似于以下内容:
![图 5.23:更新值函数对应的策略]()
图 5.23:更新值函数对应的策略
-
重复步骤 1-3,直到策略不再变化。
我们将训练算法,通过回合迭代地逼近真实的价值函数,并且每个回合都给我们提供最优策略。一个回合是智能体执行一系列动作直到达到终止状态。这可以是目标状态(例如,在 Taxi-v3 环境中的乘客下车状态),也可以是定义智能体可以采取的最大步数的数字,以避免无限循环。
我们将使用以下代码初始化环境和价值函数表。我们将把价值函数保存在变量
V中。此外,根据算法的第一步,我们将使用env.action_space.sample()方法从一个随机策略开始,这个方法每次调用时都会返回一个随机动作:def policy_iteration(env): """ Find the most optimal policy for the Taxi-v3 environment using Policy Iteration Args: env: Taxi=v3 environment Returns: policy: the most optimal policy """ V = dict() -
现在,在下一节中,我们将定义并初始化变量:
""" initially the value function for all states will be random values close to zero """ state_size = env.observation_space.n for i in range(state_size): V[i] = np.random.random() # when the change is smaller than this, stop small_change = 1e-20 # future reward coefficient gamma = 0.9 episodes = 0 # train for this many episodes max_episodes = 50000 # initially we will start with a random policy current_policy = dict() for s in range(state_size): current_policy[s] = env.action_space.sample() -
现在进入主循环,它将执行迭代:
while episodes < max_episodes: episodes += 1 # policy evaluation V = policy_evaluation(V, current_policy, \ env, gamma, small_change) # policy improvement current_policy, policy_changed = policy_improvement\ (V, current_policy, \ env, gamma) # if the policy didn't change, it means we have converged if not policy_changed: break print(f"Number of episodes trained: {episodes}") return current_policy -
现在我们已经准备好了基本设置,我们将首先使用以下代码进行策略评估步骤:
def policy_evaluation(V, current_policy, env, gamma, \ small_change): """ Perform policy evaluation iterations until the smallest change is less than 'smallest_change' Args: V: the value function table current_policy: current policy env: the OpenAI Tax-v3 environment gamma: future reward coefficient small_change: how small should the change be for the iterations to stop Returns: V: the value function after convergence of the evaluation """ state_size = env.observation_space.n -
在以下代码中,我们将循环遍历状态并更新
:while True: biggest_change = 0 # loop through every state present for state in range(state_size): old_V = V[state] # take the action according to the current policy action = current_policy[state] prob, new_state, reward, done = env.env.P[state]\ [action][0] -
接下来,我们将使用贝尔曼最优方程更新
:V[state] = reward + gamma * V[new_state] """ if the biggest change is small enough then it means the policy has converged, so stop. """ biggest_change = max(biggest_change, \ abs(V[state] – old_V)) if biggest_change < small_change: break return V -
一旦完成策略评估步骤,我们将使用以下代码进行策略改进:
def policy_improvement(V, current_policy, env, gamma): """ Perform policy improvement using the Bellman Optimality Equation. Args: V: the value function table current_policy: current policy env: the OpenAI Tax-v3 environment gamma: future reward coefficient Returns: current_policy: the updated policy policy_changed: True, if the policy was changed, else, False """ -
我们首先定义所有必需的变量:
state_size = env.observation_space.n action_size = env.action_space.n policy_changed = False for state in range(state_size): best_val = -np.inf best_action = -1 # loop over all actions and select the best one for action in range(action_size): prob, new_state, reward, done = env.env.P[state]\ [action][0] -
现在,在这里,我们将通过采取这个动作来计算未来的奖励。请注意,我们使用的是简化的方程,因为我们没有非一的转移概率:
future_reward = reward + gamma * V[new_state] if future_reward > best_val: best_val = future_reward best_action = action """ using assert statements we can avoid getting into unwanted situations """ assert best_action != -1 if current_policy[state] != best_action: policy_changed = True # update the best action for this current state current_policy[state] = best_action # if the policy didn't change, it means we have converged return current_policy, policy_changed -
一旦最优策略被学习,我们将在新的环境中对其进行测试。现在,两个部分都已准备好。让我们通过
main代码块调用它们:if __name__ == '__main__': env = initialize_environment() policy = value_iteration(env) play(policy, render=True) -
接下来,我们将添加一个
play函数,用于在新的环境中测试策略:def play(policy, render=False): """ Perform a test pass on the Taxi-v3 environment Args: policy: the policy to use render: if the result should be rendered at every step. False by default """ env = initialize_environment() rewards = [] -
接下来,让我们定义
max_steps。这基本上是智能体允许采取的最大步数。如果在此时间内没有找到解决方案,我们将其称为一个回合并继续:max_steps = 25 test_episodes = 2 for episode in range(test_episodes): # reset the environment every new episode state = env.reset() total_rewards = 0 print("*" * 100) print("Episode {}".format(episode)) for step in range(max_steps):在这里,我们将采取之前保存在策略中的动作:
action = policy[state] new_state, reward, done, info = env.step(action) if render: env.render() total_rewards += reward if done: rewards.append(total_rewards) print("Score", total_rewards) break state = new_state env.close() print("Average Score", sum(rewards) / test_episodes)运行主代码块后,我们看到如下输出:
![图 5.24:智能体将乘客送到正确的位置]()
图 5.24:智能体将乘客送到正确的位置
如你所见,智能体将乘客送到正确的位置。请注意,输出已被截断以便于展示。
价值迭代
如你在前一节看到的,我们在几次迭代后得到了最优解,但策略迭代有一个缺点:我们只能在多次评估迭代后改进一次策略。
简化的贝尔曼方程可以通过以下方式更新。请注意,这与策略评估步骤相似,唯一的不同是采取所有可能动作的价值函数的最大值:

图 5.25:更新的贝尔曼方程
该方程可以理解为如下:
"对于给定的状态,采取所有可能的动作,然后存储具有最高 V[s] 值的那个。"
就这么简单。使用这种技术,我们可以将评估和改进结合在一个步骤中,正如你现在将看到的那样。
我们将像往常一样,先定义一些重要的变量,比如 gamma、state_size 和 policy,以及值函数字典:
def value_iteration(env):
"""
Performs Value Iteration to find the most optimal policy for the
Tax-v3 environment
Args:
env: Taxiv3 Gym environment
Returns:
policy: the most optimum policy
"""
V = dict()
gamma = 0.9
state_size = env.observation_space.n
action_size = env.action_space.n
policy = dict()
# initialize the value table randomly
# initialize the policy randomly
for x in range(state_size):
V[x] = 0
policy[x] = env.action_space.sample()
使用之前定义的公式,我们将采用相同的循环,并在
计算部分做出更改。现在,我们使用的是之前定义的更新后的 Bellman 方程:
"""
this loop repeats until the change in value function
is less than delta
"""
while True:
delta = 0
for state in reversed(range(state_size)):
old_v_s = V[state]
best_rewards = -np.inf
best_action = None
# for all the actions in current state
for action in range(action_size):
# check the reward obtained if we were to perform
# this action
prob, new_state, reward, done =
env.env.P[state][action][0]
potential_reward = reward + gamma * V[new_state]
# select the one that has the best reward
# and also save the action to the policy
if potential_reward > best_rewards:
best_rewards = potential_reward
best_action = action
policy[state] = best_action
V[state] = best_rewards
# terminate if the change is not high
delta = max(delta, abs(V[state] - old_v_s))
if delta < 1e-30:
break
if __name__ == '__main__':
env = initialize_environment()
# policy = policy_iteration(env)
policy = value_iteration(env)
play(policy, render=True)
因此,我们已经成功实现了 Taxi-v3 环境中的策略迭代和值迭代。
在下一个活动中,我们将使用非常流行的 FrozenLake-v0 环境来进行策略迭代和值迭代。在我们开始之前,让我们快速了解一下该环境的基本情况。
FrozenLake-v0 环境
该环境基于一个场景,场景中有一个冰冻湖,除了部分地方冰面已经融化。假设一群朋友在湖边玩飞盘,其中一个人投了一个远离的飞盘,飞盘正好落在湖中央。目标是穿越湖面并取回飞盘。现在,必须考虑的事实是,冰面非常滑,你不能总是按照预期的方向移动。这个表面用以下网格描述:
SFFF (S: starting point, safe)
FHFH (F: frozen surface, safe)
FFFH (H: hole, fall to your doom)
HFFG (G: goal, where the frisbee is located)
请注意,当其中一名玩家到达目标或掉进洞里时,回合结束。玩家分别会获得 1 或 0 的奖励。
现在,在 Gym 环境中,代理应该相应地控制玩家的移动。正如你所知,网格中的某些方格可以踩上去,而有些方格可能会把你直接带到冰面融化的洞里。因此,玩家的移动非常不可预测,部分取决于代理选择的方向。
注意
更多关于 FrozenLake-v0 环境的信息,请参见以下链接:gym.openai.com/envs/FrozenLake-v0/
现在,让我们实现策略迭代和值迭代技术来解决问题并取回飞盘。
活动 5.01:在 FrozenLake-v0 环境中实现策略迭代和值迭代
在本活动中,我们将通过策略迭代和值迭代来解决 FrozenLake-v0。该活动的目标是定义穿越冰冻湖的安全路径并取回飞盘。当目标达成或代理掉进洞里时,回合结束。以下步骤将帮助你完成此活动:
-
导入所需的库:
numpy和gym。 -
初始化环境并重置当前环境。在初始化器中设置
is_slippery=False。显示动作空间的大小和可能的状态数量。 -
执行策略评估迭代,直到最小的变化小于
smallest_change。 -
使用贝尔曼最优性方程进行策略改进。
-
使用策略迭代找到 FrozenLake-v0 环境的最优策略。
-
在 FrozenLake-v0 环境上执行测试。
-
随机通过 FrozenLake-v0 环境进行步进。
-
执行值迭代,以找到 FrozenLake-v0 环境的最优策略。请注意,这里目标是确保每个行动的奖励值为 1(或接近 1),以确保最大奖励。
输出应类似于以下内容:

图 5.26:期望输出平均分(1.0)
注意
本活动的解决方案可以在第 711 页找到。
因此,通过这项活动,我们成功地在 FrozenLake-v0 环境中实现了策略迭代和值迭代方法。
到此为止,我们已经完成了本章内容,你现在可以自信地将本章所学的技术应用于各种环境和场景中。
总结
在本章中,我们探讨了解决动态规划(DP)问题的两种最常用技术。第一种方法是备忘录法,也叫做自顶向下法,它使用字典(或类似 HashMap 的结构)以自然(无序)的方式存储中间结果。第二种方法是表格法,也叫自底向上的方法,它按顺序从小到大解决问题,并通常将结果保存在类似矩阵的结构中。
接下来,我们还探讨了如何使用动态规划(DP)通过策略和值迭代解决强化学习(RL)问题,以及如何通过使用修改后的贝尔曼方程克服策略迭代的缺点。我们在两个非常流行的环境中实现了策略迭代和值迭代:Taxi-v3 和 FrozenLake-v0。
在下一章,我们将学习蒙特卡罗方法,它用于模拟现实世界的场景,并且是金融、机械学和交易等领域中最广泛使用的工具之一。
第六章:6. 蒙特卡洛方法
概述
在这一章中,你将学习各种类型的蒙特卡洛方法,包括首次访问和每次访问技术。如果环境的模型未知,你可以通过生成经验样本或通过仿真来使用蒙特卡洛方法学习环境。本章将教授你重要性采样,并教你如何应用蒙特卡洛方法解决冰湖问题。到本章结束时,你将能够识别可以应用蒙特卡洛方法的强化学习问题。你将能够使用蒙特卡洛强化学习解决预测、估计和控制问题。
介绍
在上一章中,我们学习了动态规划。动态规划是一种在已知环境模型的情况下进行强化学习的方法。强化学习中的智能体可以学习策略、价值函数和/或模型。动态规划帮助解决已知的马尔可夫决策过程(MDP)。在 MDP 中,所有可能转换的概率分布都是已知的,并且这是动态规划所必需的。
那么,当环境模型未知时会发生什么呢?在许多现实生活中的情况下,环境模型是事先未知的。那么算法是否能够学习到环境的模型呢?强化学习中的智能体是否仍然能够学会做出正确的决策呢?
蒙特卡洛方法是在环境模型未知时的一种学习方式,因此它们被称为无模型学习。我们可以进行无模型预测,估计未知 MDP 的价值函数。我们还可以使用无模型控制,优化未知 MDP 的价值函数。蒙特卡洛方法也能够处理非马尔可夫领域。
在许多情况下,状态之间的转换概率是未知的。你需要先进行试探,熟悉环境,然后才能学会如何玩好这个游戏。蒙特卡洛方法可以通过经历环境来学习环境的模型。蒙特卡洛方法通过实际或随机仿真场景来获得样本回报的平均值。通过使用来自与环境实际或模拟交互的状态、动作和回报的样本序列,蒙特卡洛方法可以通过经验学习。当蒙特卡洛方法工作时,需要一个明确的回报集合。这个标准仅在情节任务中满足,其中经验被划分为明确定义的情节,并且无论选择的动作如何,情节最终都会终止。一个应用示例是 AlphaGo,它是最复杂的游戏之一;任何状态下可能的动作数量超过 200。用来解决它的关键算法之一是基于蒙特卡洛的树搜索。
在本章中,我们将首先了解蒙特卡洛强化学习方法。我们将把它们应用到 OpenAI 的二十一点环境中。我们将学习各种方法,如首次访问法和每次访问法。我们还将学习重要性采样,并在本章后面重新审视冻结湖问题。在接下来的部分中,我们将介绍蒙特卡洛方法的基本原理。
蒙特卡洛方法的原理
蒙特卡洛方法通过对每个状态-动作对的样本回报进行平均,来解决强化学习问题。蒙特卡洛方法仅适用于情节任务。这意味着经验被分成多个情节,所有情节最终都会结束。只有在情节结束后,价值函数才会被重新计算。蒙特卡洛方法可以逐集优化,但不能逐步优化。
让我们以围棋为例。围棋有数百万种状态;在事先学习所有这些状态及其转移概率将会很困难。另一种方法是反复进行围棋游戏,并为胜利分配正奖励,为失败分配负奖励。
由于我们不了解模型的策略,需要使用经验样本来学习。这种技术也是一种基于样本的模型。我们称之为蒙特卡洛中的情节直接采样。
蒙特卡洛是无模型的。由于不需要了解 MDP(马尔科夫决策过程),模型是从样本中推断出来的。你可以执行无模型的预测或无模型的估计。我们可以对一个策略进行评估,也称为预测。我们还可以评估并改进一个策略,这通常被称为控制或优化。蒙特卡洛强化学习只能从终止的情节中学习。
例如,如果你玩的是一盘棋,按照一套规则或策略进行游戏,那么你就是根据这些规则或策略进行多个情节,并评估策略的成功率。如果我们根据某个策略进行游戏,并根据游戏的结果调整该策略,那就属于策略改进、优化或控制。
通过二十一点理解蒙特卡洛方法
二十一点是一种简单的卡牌游戏,在赌场中非常流行。这是一款非常棒的游戏,因为它简单易模拟并且容易进行采样,适合蒙特卡洛方法。二十一点也可以作为 OpenAI 框架的一部分。玩家和庄家各发两张牌。庄家亮出一张牌,另一张牌面朝下。玩家和庄家可以选择是否要继续发牌:
-
游戏的目标:获得一副卡牌,其点数之和接近或等于 21,但不超过 21。
-
玩家:有两个玩家,分别称为玩家和庄家。
-
游戏开始:玩家被发两张牌,庄家也被发两张牌,剩余的牌堆放在一边。庄家的其中一张牌展示给玩家。
-
可能的行动 – 停牌或要牌:"停牌"是指停止要求更多的牌。"要牌"是指要求更多的牌。如果玩家手牌的总和小于 17,玩家将选择"要牌"。如果手牌总和大于或等于 17,玩家将选择停牌。是否要牌或停牌的阈值为 17,可以根据需要在不同版本的二十一点中进行调整。在本章中,我们将始终保持这个 17 的阈值,决定是否要牌或停牌。
-
奖励:赢得一局为 +1,输掉一局为 -1,平局为 0。
-
策略:玩家需要根据庄家的手牌决定是否停牌或要牌。根据其他牌的点数,王牌可以被视为 1 或 11。
我们将在下表中解释二十一点游戏。该表包含以下列:
-
游戏:游戏编号和游戏的子状态:i、ii 或 iii
-
玩家手牌:玩家拥有的牌;例如,K♣, 8♦ 表示玩家有一张梅花国王和一张方块八。
-
庄家手牌:庄家获得的牌。例如,8♠, Xx 表示庄家有一张黑桃八和一张隐藏牌。
-
行动:这是玩家决定选择的行动。
-
结果:根据玩家的行动和庄家手牌的情况,游戏的结果。
-
玩家手牌总和:玩家两张牌的总和。请注意,国王(K)、皇后(Q)和杰克(J)面牌的点数为 10。
-
评论:解释为什么采取了某个特定行动或宣布了某个结果。
在游戏 1 中,玩家选择了停牌,因为手牌总和为 18。 "停牌"意味着玩家将不再接收牌。现在庄家展示了隐藏牌。由于庄家和玩家的手牌总和都是 18,结果为平局。在游戏 2 中,玩家的手牌总和为 15,小于 17。玩家要牌并获得另一张牌,总和变为 17。然后玩家停牌,不再接收牌。庄家展示了手牌,由于手牌总和小于 17,庄家要牌。庄家得到新的一张牌,总和为 25,超过了 21。游戏的目标是尽量接近或等于 21,而不超过 21。庄家失败,玩家赢得了第二局。以下图展示了此游戏的总结:

图 6.1:二十一点游戏的解释
接下来,我们将使用 OpenAI 框架实现一款二十一点游戏。这将作为蒙特卡洛方法的模拟和应用的基础。
练习 6.01:在二十一点中实现蒙特卡洛方法
我们将学习如何使用 OpenAI 框架来玩二十一点,并了解观察空间、动作空间和生成回合。此练习的目标是在二十一点游戏中实现蒙特卡罗技术。
执行以下步骤完成练习:
-
导入必要的库:
import gym import numpy as np from collections import defaultdict from functools import partialgym是 OpenAI 框架,numpy是数据处理框架,defaultdict用于字典支持。 -
我们使用
gym.make()启动Blackjack环境,并将其分配给env:#set the environment as blackjack env = gym.make('Blackjack-v0')找出观察空间和动作空间的数量:
#number of observation space value print(env.observation_space) #number of action space value print(env.action_space)你将得到以下输出:
Tuple(Discrete(32), Discrete(11), Discrete(2)) Discrete(2)观察空间的数量是状态的数量。动作空间的数量是每个状态下可能的动作数。输出结果显示为离散型,因为二十一点游戏中的观察和动作空间不是连续的。例如,OpenAI 中还有其他游戏,如平衡杆和摆钟,这些游戏的观察和动作空间是连续的。
-
编写一个函数来玩游戏。如果玩家的卡片总和大于或等于 17,则停牌(不再抽卡);否则,抽牌(选择更多卡片),如以下代码所示:
def play_game(state): player_score, dealer_score, usable_ace = state #if player_score is greater than 17, stick if (player_score >= 17): return 0 # don't take any cards, stick else: return 1 # take additional cards, hit在这里,我们初始化回合,选择初始状态,并将其分配给
player_score、dealer_score和usable_ace。 -
添加一个字典
action_text,它将两个动作整数映射到相应的动作文本。以下是将动作的整数值转换为文本格式的代码:for game_num in range(100): print('***Start of Game:', game_num) state = env.reset() action_text = {1:'Hit, Take more cards!!', \ 0:'Stick, Dont take any cards' } player_score, dealer_score, usable_ace = state print('Player Score=', player_score,', \ Dealer Score=', dealer_score, ', \ Usable Ace=', usable_ace) -
以每 100 个回合的批次玩游戏,并计算
state、reward和action:for i in range(100): action = play_game(state) state, reward, done, info = env.step(action) player_score, dealer_score, usable_ace = state print('Action is', action_text[action]) print('Player Score=', player_score,', \ Dealer Score=', dealer_score, ', \ Usable Ace=', usable_ace, ', Reward=', reward) if done: if (reward == 1): print('***End of Game:', game_num, \ ' You have won Black Jack!\n') elif (reward == -1): print('***End of Game:', game_num, \ ' You have lost Black Jack!\n') elif (reward ==0): print('***End of Game:', game_num, \ ' The game is a Draw!\n') break你将得到以下输出:
![图 6.2:输出的是正在进行的二十一点游戏的回合]()
图 6.2:输出的是正在进行的二十一点游戏的回合
注意
蒙特卡罗技术基于生成随机样本。因此,同一段代码的两次执行结果值可能不同。所以,你可能会得到类似的输出,但并不完全相同,适用于所有练习和活动。
在代码中,done的值为True或False。如果done为True,游戏结束,我们记录奖励值并打印游戏结果。在输出中,我们使用蒙特卡罗方法模拟了二十一点游戏,并记录了不同的动作、状态和游戏完成情况。我们还模拟了游戏结束时的奖励。
注意
要访问此特定章节的源代码,请参考packt.live/2XZssYh。
你也可以在packt.live/2Ys0cMJ在线运行这个示例。
接下来,我们将描述两种不同的蒙特卡罗方法,即首次访问法和每次访问法,这些方法将用于估计值函数。
蒙特卡罗方法的类型
我们使用蒙特卡洛实现了黑杰克游戏。通常,蒙特卡洛轨迹是一个状态、动作和奖励的序列。在多个回合中,可能会出现状态重复。例如,轨迹可能是 S0,S1,S2,S0,S3。我们如何在状态多次访问时处理奖励函数的计算呢?
从广义上讲,这突出了两种蒙特卡洛方法——首次访问和每次访问。我们将理解这两种方法的含义。
如前所述,在蒙特卡洛方法中,我们通过平均奖励来逼近值函数。在首次访问蒙特卡洛方法中,只有在一个回合中首次访问某个状态时才会被用来计算平均奖励。例如,在某个迷宫游戏中,你可能会多次访问同一个地方。使用首次访问蒙特卡洛方法时,只有首次访问时的奖励才会被用于计算奖励。当智能体在回合中重新访问相同的状态时,奖励不会被纳入计算平均奖励中。
在每次访问蒙特卡洛中,每次智能体访问相同的状态时,奖励都会被纳入计算平均回报。例如,使用相同的迷宫游戏。每次智能体到达迷宫中的相同位置时,我们都会将该状态下获得的奖励纳入奖励函数的计算。
首次访问和每次访问都会收敛到相同的值函数。对于较少的回合,首次访问和每次访问之间的选择取决于具体的游戏和游戏规则。
让我们通过理解首次访问蒙特卡洛预测的伪代码来深入了解。
首次访问蒙特卡洛预测用于估算值函数
在用于估算值函数的首次访问蒙特卡洛预测的伪代码中,关键是计算值函数V(s)。Gamma 是折扣因子。折扣因子用于将未来的奖励减少到低于即时奖励:

图 6.3:首次访问蒙特卡洛预测的伪代码
在首次访问中,我们所做的就是生成一个回合,计算结果值,并将结果附加到奖励中。然后我们计算平均回报。在接下来的练习中,我们将通过遵循伪代码中的步骤应用首次访问蒙特卡洛预测来估算值函数。首次访问算法的关键代码块是仅通过首次访问来遍历状态:
if current_state not in states[:i]:
考虑那些尚未访问过的states。我们通过1增加states的数量,使用增量方法计算值函数,并返回值函数。实现方式如下:
"""
only include the rewards of the states that have not been visited before
"""
if current_state not in states[:i]:
#increasing the count of states by 1
num_states[current_state] += 1
#finding the value_function by incremental method
value_function[current_state] \
+= (total_rewards - value_function[current_state]) \
/ (num_states[current_state])
return value_function
让我们通过下一个练习更好地理解这一点。
练习 6.02:使用首次访问蒙特卡洛预测估算黑杰克中的值函数
本次练习旨在理解如何应用首次访问蒙特卡洛预测来估计黑杰克游戏中的价值函数。我们将按照伪代码中概述的步骤一步步进行。
执行以下步骤以完成练习:
-
导入必要的库:
import gym import numpy as np from collections import defaultdict from functools import partialgym是 OpenAI 的框架,numpy是数据处理框架,defaultdict用于字典支持。 -
在 OpenAI 中选择环境为
Blackjack:env = gym.make('Blackjack-v0') -
编写
policy_blackjack_game函数,该函数接受状态作为输入,并根据player_score返回0或1的动作:def policy_blackjack_game(state): player_score, dealer_score, usable_ace = state if (player_score >= 17): return 0 # don't take any cards, stick else: return 1 # take additional cards, hit在该函数中,如果玩家分数大于或等于
17,则不再抽取更多牌。但如果player_score小于 17,则抽取更多牌。 -
编写一个生成黑杰克回合的函数。初始化
episode、states、actions和rewards:def generate_blackjack_episode(): #initializing the value of episode, states, actions, rewards episode = [] states = [] actions = [] rewards = [] -
重置环境,并将
state的值设置为player_score、dealer_score和usable_ace:#starting the environment state = env.reset() """ setting the state value to player_score, dealer_score and usable_ace """ player_score, dealer_score, usable_ace = state -
编写一个函数从状态中生成动作。然后我们执行该动作,找到
next_state和reward:while (True): #finding the action by passing on the state action = policy_blackjack_game(state) next_state, reward, done, info = env.step(action) -
创建一个
episode、state、action和reward的列表,将它们附加到现有列表中:#creating a list of episodes, states, actions, rewards episode.append((state, action, reward)) states.append(state) actions.append(action) rewards.append(reward)如果这一集已完成(
done为 true),我们就break跳出循环。如果没有,我们更新state为next_state并重复循环:if done: break state = next_state -
我们从函数中返回
episodes、states、actions和rewards:return episode, states, actions, rewards -
编写一个计算黑杰克价值函数的函数。第一步是初始化
total_rewards、num_states和value_function的值:def black_jack_first_visit_prediction(policy, env, num_episodes): """ initializing the value of total_rewards, number of states, and value_function """ total_rewards = 0 num_states = defaultdict(float) value_function = defaultdict(float) -
生成一个
episode,对于每个episode,我们按逆序查找所有states的总rewards:for k in range (0, num_episodes): episode, states, actions, rewards = \ generate_blackjack_episode() total_rewards = 0 for i in range(len(states)-1, -1,-1): current_state = states[i] #finding the sum of rewards total_rewards += rewards[i] -
考虑未访问过的
states。我们将states的计数增加1,并使用增量方法计算价值函数,然后返回价值函数:""" only include the rewards of the states that have not been visited before """ if current_state not in states[:i]: #increasing the count of states by 1 num_states[current_state] += 1 #finding the value_function by incremental method value_function[current_state] \ += (total_rewards \ - value_function[current_state]) \ / (num_states[current_state]) return value_function -
现在,执行首次访问预测 10,000 次:
black_jack_first_visit_prediction(policy_blackjack_game, env, 10000)你将获得以下输出:
![图 6.4:首次访问价值函数]()
图 6.4:首次访问价值函数
首次访问的价值函数被打印出来。对于所有的状态,player_score、dealer_score和usable_space的组合都有一个来自首次访问评估的价值函数值。以(16, 3, False): -0.625为例。这意味着玩家分数为16、庄家分数为3、可用的 A 牌为False的状态的价值函数为-0.625。集数和批次数是可配置的。
注意
要访问此特定部分的源代码,请参考packt.live/37zbza1。
你也可以在在线运行这个例子:packt.live/2AYnhyH。
本节我们已经覆盖了首次访问蒙特卡洛方法。下一节我们将理解每次访问蒙特卡洛预测以估计价值函数。
每次访问蒙特卡洛预测用于估计价值函数
在每次访问蒙特卡洛预测中,每次访问状态都用于奖励计算。我们有一个 gamma 因子作为折扣因子,用于相对于近期奖励对未来奖励进行折扣:

图 6.5:每次访问蒙特卡洛预测的伪代码
主要的区别在于每次访问每一步,而不仅仅是第一次,来计算奖励。代码与第一次访问的练习类似,唯一不同的是在 Blackjack 预测函数中计算奖励。
以下这一行在第一次访问实现中检查当前状态是否之前未被遍历。这个检查在每次访问算法中不再需要:
if current_state not in states[:i]:
计算值函数的代码如下:
#all the state values of every visit are considered
#increasing the count of states by 1
num_states[current_state] += 1
#finding the value_function by incremental method
value_function[current_state] \
+= (total_rewards - value_function[current_state]) \
/ (num_states[current_state])
return value_function
在本练习中,我们将使用每次访问蒙特卡洛方法来估计值函数。
练习 6.03:用于估计值函数的每次访问蒙特卡洛预测
本练习旨在帮助理解如何应用每次访问蒙特卡洛预测来估计值函数。我们将一步一步地应用伪代码中概述的步骤。执行以下步骤以完成练习:
-
导入必要的库:
import gym import numpy as np from collections import defaultdict from functools import partial -
在 OpenAI 中选择环境为
Blackjack:env = gym.make('Blackjack-v0') -
编写
policy_blackjack_game函数,接受状态作为输入,并根据player_score返回action为0或1:def policy_blackjack_game(state): player_score, dealer_score, usable_ace = state if (player_score >= 17): return 0 # don't take any cards, stick else: return 1 # take additional cards, hit在该函数中,如果玩家的分数大于或等于
17,则不再抽取牌。但如果player_score小于17,则会继续抽取牌。 -
编写一个生成 Blackjack 回合的函数。初始化
episode、states、actions和rewards:def generate_blackjack_episode(): #initializing the value of episode, states, actions, rewards episode = [] states = [] actions = [] rewards = [] -
我们重置环境,并将
state的值设置为player_score、dealer_score和usable_ace,如以下代码所示:#starting the environment state = env.reset() """ setting the state value to player_score, dealer_score and usable_ace """ player_score, dealer_score, usable_ace = state -
编写一个函数,通过
state生成action,然后通过action步骤找到next_state和reward:while (True): #finding the action by passing on the state action = policy_blackjack_game(state) next_state, reward, done, info = env.step(action) -
通过将
episode、state、action和reward添加到现有列表中,创建一个列表:#creating a list of episodes, states, actions, rewards episode.append((state, action, reward)) states.append(state) actions.append(action) rewards.append(reward) -
如果回合完成(
done为真),我们就break循环。如果没有完成,我们更新state为next_state并重复循环:if done: break state = next_state -
从函数中返回
episodes、states、actions和rewards:return episode, states, actions, rewards -
编写用于计算 Blackjack 值函数的函数。第一步是初始化
total_rewards、num_states和value_function的值:def black_jack_every_visit_prediction\ (policy, env, num_episodes): """ initializing the value of total_rewards, number of states, and value_function """ total_rewards = 0 num_states = defaultdict(float) value_function = defaultdict(float) -
生成一个
episode,对于该episode,我们在episode中逆序找到所有states的总rewards:for k in range (0, num_episodes): episode, states, actions, rewards = \ generate_blackjack_episode() total_rewards = 0 for i in range(len(states)-1, -1,-1): current_state = states[i] #finding the sum of rewards total_rewards += rewards[i] -
考虑每个访问的
state。我们将states的计数增加1,并通过增量方法计算值函数,然后返回该值函数:#all the state values of every visit are considered #increasing the count of states by 1 num_states[current_state] += 1 #finding the value_function by incremental method value_function[current_state] \ += (total_rewards - value_function[current_state]) \ / (num_states[current_state]) return value_function -
现在,执行每次访问预测 10,000 次:
black_jack_every_visit_prediction(policy_blackjack_game, \ env, 10000)你将得到以下输出:
![图 6.6:每次访问值函数]()
图 6.6:每次访问值函数
每次访问的价值函数都会被打印出来。对于所有状态,player_score、dealer_score和usable_space的组合都有来自每次访问评估的价值函数值。我们还可以增加训练回合数,并再次运行此操作。随着回合数的增大,首次访问和每次访问的函数将逐渐收敛。
注意
要访问此特定部分的源代码,请参考packt.live/2C0wAP4。
您还可以在packt.live/2zqXsH3在线运行此示例。
在下一节中,我们将讨论蒙特卡洛强化学习的一个关键概念,即探索与利用的平衡需求。这也是蒙特卡洛方法的贪婪ε策略的基础。平衡探索和利用有助于我们改进策略函数。
探索与利用的权衡
学习是通过探索新事物以及利用或应用之前学到的知识来进行的。这两者的正确结合是任何学习的核心。同样,在强化学习的背景下,我们也有探索和利用。探索是尝试不同的动作,而利用则是采取已知能带来良好奖励的动作。
强化学习必须在探索和利用之间取得平衡。每个智能体只能通过尝试某个动作的经验来学习。探索有助于尝试新的动作,这可能使智能体在未来做出更好的决策。利用是基于经验选择那些能带来良好奖励的动作。智能体需要在通过探索实验来获取奖励和通过利用已知路径来获得奖励之间做出权衡。如果智能体更多地进行利用,可能会错过学习其他更有回报的策略的机会。如果智能体更多地进行探索,可能会错失利用已知路径并失去奖励的机会。
例如,想象一个学生正在努力在大学中最大化自己的成绩。这个学生可以通过选修新学科的课程来“探索”,或者通过选修自己喜欢的课程来“利用”。如果学生倾向于“利用”,他可能会错过在新学科课程中获得好成绩和整体学习的机会。如果学生通过选修太多不同的学科课程来进行探索,这可能会影响他的成绩,并且可能让学习变得过于宽泛。
类似地,如果你选择阅读书籍,你可以通过阅读同一类型或同一作者的书籍来进行“开发”或通过跨越不同类型和作者的书籍来进行“探索”。类似地,当你从一个地方开车到另一个地方时,你可以通过基于过去经验沿用相同的已知路线来进行“开发”或通过选择不同的路线来进行“探索”。在下一部分中,我们将了解“在政策学习”和“脱政策学习”的技术。然后,我们将了解一个名为重要性采样的关键因素,它对于脱政策学习非常重要。
探索与开发是强化学习中常用的技术。在脱政策学习中,你可以将开发技术作为目标策略,而将探索技术作为行为策略。我们可以把贪婪策略作为开发技术,把随机策略作为探索技术。
重要性采样
蒙特卡洛方法可以是“在政策”或“脱政策”的。在在政策学习中,我们从代理遵循的策略经验中进行学习。在脱政策学习中,我们学习如何从遵循不同行为策略的经验中估计目标策略。重要性采样是脱政策学习的关键技术。下图展示了在政策与脱政策学习的对比:

图 6.7:在政策与脱政策的比较
你可能会认为,在政策学习是在玩耍时学习,而脱政策学习是在观看别人玩耍时学习。你可以通过自己玩板球来提高你的板球水平。这有助于你从自己的错误和最佳行动中学习。这就是在政策学习。你也可以通过观察别人玩板球来学习,并从他们的错误和最佳行动中学习。这就是脱政策学习。
人类通常会同时进行在政策和脱政策学习。例如,骑自行车主要是属于在政策学习。我们通过学习在骑车时保持平衡来学习骑车。跳舞则是一种脱政策学习;你通过观察别人跳舞来学习舞步。
与脱政策方法相比,在政策方法较为简单。脱政策方法更强大,因为它具有“迁移学习”的效果。在脱政策方法中,你是从不同的策略中学习,收敛速度较慢,方差较大。
脱政策学习的优点在于,行为策略可以非常具有探索性,而目标策略可以是确定性的,并贪婪地优化奖励。
脱政策强化方法基于一个名为重要性采样的概念。该方法帮助在一个政策概率分布下估计值,前提是你拥有来自另一个政策概率分布的样本。让我们通过详细的伪代码理解蒙特卡洛脱政策评估。接着我们将在 OpenAI 框架中将其应用到 21 点游戏。
蒙特卡洛脱策略评估的伪代码
我们在下图中看到的是,我们正在通过从行为策略b中学习来估计Q(s,a)。

图 6.8:蒙特卡洛脱策略评估的伪代码
目标策略是贪婪策略;因此,我们通过使用argmax Q(s,a)选择具有最大回报的动作。Gamma 是折扣因子,它使我们能够将远期奖励与未来即时奖励进行折扣。累积价值函数C(s,a)通过加权W来计算。Gamma 用于折扣奖励。
脱策略蒙特卡洛的核心是遍历每个回合:
for step in range(len(episode))[::-1]:
state, action, reward = episode[step]
#G <- gamma * G + Rt+1
G = discount_factor * G + reward
# C(St, At) = C(St, At) + W
C[state][action] += W
#Q (St, At) <- Q (St, At) + W / C (St, At)
Q[state][action] += (W / C[state][action]) \
* (G - Q[state][action])
"""
If action not equal to argmax of target policy
proceed to next episode
"""
if action != np.argmax(target_policy(state)):
break
# W <- W * Pi(At/St) / b(At/St)
W = W * 1./behaviour_policy(state)[action]
让我们通过使用重要性采样来理解蒙特卡洛脱策略方法的实现。这个练习将帮助我们学习如何设置目标策略和行为策略,并从行为策略中学习目标策略。
练习 6.04:使用蒙特卡洛进行重要性采样
这个练习的目标是通过使用蒙特卡洛方法进行脱策略学习。我们选择了一个贪婪的目标策略。我们也有一个行为策略,即任何软性、非贪婪策略。通过从行为策略中学习,我们将估计目标策略的价值函数。我们将把这种重要性采样技术应用到 Blackjack 游戏环境中。我们将按步骤执行伪代码中概述的步骤。
执行以下步骤以完成该练习:
-
导入必要的库:
import gym import numpy as np from collections import defaultdict from functools import partial -
使用
gym.make选择 OpenAI 中的Blackjack环境:env = gym.make('Blackjack-v0') -
创建两个策略函数。一个是随机策略。随机策略选择一个随机动作,它是一个大小为 n 的列表,每个动作有 1/n 的概率,其中 n 是动作的数量:
""" creates a random policy which is a linear probability distribution num_Action is the number of Actions supported by the environment """ def create_random_policy(num_Actions): #Creates a list of size num_Actions, with a fraction 1/num_Actions. #If 2 is numActions, the array value would [1/2, 1/2] Action = np.ones(num_Actions, dtype=float)/num_Actions def policy_function(observation): return Action return policy_function -
编写一个函数来创建贪婪策略:
#creates a greedy policy, """ sets the value of the Action at the best_possible_action, that maximizes the Q, value to be 1, rest to be 0 """ def create_greedy_policy(Q): def policy_function(state): #Initializing with zero the Q Action = np.zeros_like(Q[state], dtype = float) #find the index of the max Q value best_possible_action = np.argmax(Q[state]) #Assigning 1 to the best possible action Action[best_possible_action] = 1.0 return Action return policy_function贪婪策略选择一个最大化奖励的动作。我们首先识别
best_possible_action,即Q在所有状态中的最大值。然后,我们将值分配给对应于best_possible_action的Action。 -
定义一个用于 Blackjack 重要性采样的函数,该函数以
env、num_episodes、behaviour_policy和discount_factor作为参数:def black_jack_importance_sampling\ (env, num_episodes, behaviour_policy, discount_factor=1.0): #Initialize the value of Q Q = defaultdict(lambda: np.zeros(env.action_space.n)) #Initialize the value of C C = defaultdict(lambda: np.zeros(env.action_space.n)) #target policy is the greedy policy target_policy = create_greedy_policy(Q)我们初始化
Q和C的值,并将目标策略设为贪婪策略。 -
我们按回合数循环,初始化 episode 列表,并通过
env.reset()声明初始状态集:for i_episode in range(1, num_episodes + 1): episode = [] state = env.reset() -
对于 100 个批次,在某个状态下应用行为策略来计算概率:
for i in range(100): probability = behaviour_policy(state) action = np.random.choice\ (np.arange(len(probability)), p=probability) next_state, reward, done, info = env.step(action) episode.append((state, action, reward))我们从列表中随机选择一个动作。用随机动作执行一步,返回
next_state和reward。将state、action和reward附加到 episode 列表中。 -
如果
episode完成,我们跳出循环并将next_state赋值给state:if done: break state = next_state -
初始化
G,结果为0,并将W和权重设为1:# G <- 0 G = 0.0 # W <- 0 W = 1.0 -
使用
for循环执行伪代码中详细描述的步骤,如下代码所示:""" Loop for each step of episode t=T-1, T-2,...,0 while W != 0 """ for step in range(len(episode))[::-1]: state, action, reward = episode[step] #G <- gamma * G + Rt+1 G = discount_factor * G + reward # C(St, At) = C(St, At) + W C[state][action] += W #Q (St, At) <- Q (St, At) + W / C (St, At) Q[state][action] += (W / C[state][action]) \ * (G - Q[state][action]) """ If action not equal to argmax of target policy proceed to next episode """ if action != np.argmax(target_policy(state)): break # W <- W * Pi(At/St) / b(At/St) W = W * 1./behaviour_policy(state)[action] -
返回
Q和target_policy:return Q, target_policy -
创建一个随机策略:
#create random policy random_policy = create_random_policy(env.action_space.n) """ using importance sampling evaluates the target policy by learning from the behaviour policy """ Q, policy = black_jack_importance_sampling\ (env, 50000, random_policy)随机策略作为行为策略使用。我们传入行为策略,并使用重要性采样方法,获得
Q值函数或目标策略。 -
遍历
Q中的项,然后找到具有最大值的动作。然后将其作为相应状态的值函数存储:valuefunction = defaultdict(float) for state, action_values in Q.items(): action_value = np.max(action_values) valuefunction[state] = action_value print("state is", state, "value is", valuefunction[state])你将得到如下输出:
![图 6.9:离策略蒙特卡罗评估输出]()
图 6.9:离策略蒙特卡罗评估输出
离策略评估已经计算并返回了每个状态-动作对的值函数。在这个练习中,我们使用行为策略应用了重要性采样的概念,并将学习应用于目标策略。输出为每个状态-动作对的组合提供了结果。这帮助我们理解了离策略学习。我们有两个策略——行为策略和目标策略。我们通过遵循行为策略学习目标策略。
注意
要访问此部分的源代码,请参考 packt.live/3hpOOKa。
你也可以在线运行此示例:packt.live/2B1GQGa。
在接下来的章节中,我们将学习如何使用蒙特卡罗技术解决 OpenAI 框架中的冰冻湖问题。
使用蒙特卡罗解决冰冻湖问题
冰冻湖是 OpenAI 框架中另一个简单的游戏。这是一个经典游戏,你可以用蒙特卡罗强化学习进行采样和模拟。我们已经在 第五章,动态规划 中描述并使用了冰冻湖环境。在这里,我们将快速复习游戏的基础知识,以便在接下来的活动中使用蒙特卡罗方法解决它。
我们有一个 4x4 的网格,这就是整个冰冻湖。它包含 16 个格子(一个 4x4 的网格)。这些格子标记为 S – 起始点,F – 冰冻区域,H – 坑洞,G – 目标。玩家需要从起始格子 S 移动到目标格子 G,并且穿过冰冻区域(F 格子),避免掉进坑洞(H 格子)。下图直观地展示了上述信息:

图 6.10:冰冻湖游戏
下面是游戏的一些基本信息:
-
S)到达目标(G格子)。 -
状态 = 16
-
动作 = 4
-
总状态-动作对 = 64
-
F)避免掉进湖中的坑洞(H格子)。到达目标(G格子)或掉进任何坑洞(H格子)都会结束游戏。 -
动作:在任意一个格子中可以执行的动作有:左、下、右、上。
-
玩家:这是一个单人游戏。
-
F),到达目标(G格子)得 +1,掉进坑洞(H格子)得 0。 -
配置:你可以配置冰湖是滑的还是不滑的。如果冰湖是滑的,那么预期的动作和实际动作可能会有所不同,因此如果有人想向左移动,他们可能最终会向右、向下或向上移动。如果冰湖是非滑的,预期的动作和实际动作始终对齐。该网格有 16 个可能的单元,代理可以在任何时刻处于其中一个单元。代理可以在每个单元中执行 4 种可能的动作。因此,游戏中有 64 种可能性,这些可能性会根据学习过程不断更新。在下一次活动中,我们将深入了解 Frozen Lake 游戏,并了解其中的各种步骤和动作。
活动 6.01:探索 Frozen Lake 问题 – 奖励函数
Frozen Lake 是 OpenAI Gym 中的一款游戏,有助于应用学习和强化学习技术。在本次活动中,我们将解决 Frozen Lake 问题,并通过蒙特卡罗方法确定各种状态和动作。我们将通过一批批的回合来跟踪成功率。
执行以下步骤以完成活动:
-
我们导入必要的库:
gym用于 OpenAI Gym 框架,numpy,以及处理字典所需的defaultdict。 -
下一步是选择环境为
FrozenLake,并将is_slippery设置为False。通过env.reset()重置环境,并通过env.render()渲染环境。 -
观察空间中可能的值的数量通过
print(env.observation_space)打印输出。同样,动作空间中的值的数量通过print(env.action_space)命令打印输出。 -
下一步是定义一个函数来生成一个冰湖
episode。我们初始化回合和环境。 -
我们通过使用蒙特卡罗方法模拟不同的回合。然后我们逐步导航,存储
episode并返回reward。通过env.action_space.sample()获取动作。next_state、action和reward通过调用env_step(action)函数获得。然后将它们附加到回合中。现在,回合变成了一个包含状态、动作和奖励的列表。 -
关键是计算成功率,即一批回合的成功概率。我们的方法是计算一批回合中的总尝试次数。我们计算其中有多少次成功到达目标。代理成功到达目标的次数与代理尝试次数的比率即为成功率。首先,我们初始化总奖励。
-
我们为每次迭代生成
episode和reward,并计算总reward。 -
成功率是通过将
total_reward除以100来计算的,并打印输出。 -
冰湖预测通过
frozen_lake_prediction函数计算得出。最终输出将展示游戏的默认成功率,即在没有任何强化学习的情况下,游戏随机进行时的成功率。你将获得以下输出:
![图 6.11:没有学习的冰湖输出]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_06_11.jpg)
图 6.11:没有学习的冰湖输出
注
该活动的解决方案可以在第 719 页找到。
在下一节中,我们将详细介绍如何通过平衡探索和利用,使用 epsilon 软策略和贪婪策略来实现改进。这可以确保我们平衡探索和利用。
每次访问蒙特卡洛控制伪代码(用于 epsilon 软)
我们之前已经实现了每次访问蒙特卡洛算法来估算价值函数。在本节中,我们将简要描述用于 epsilon 软的每次访问蒙特卡洛控制,以便我们可以在本章的最终活动中使用它。下图展示了通过平衡探索和利用,针对 epsilon 软的每次访问伪代码:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_06_12.jpg)
图 6.12:蒙特卡洛每次访问的伪代码(用于 epsilon 软)
以下代码以 epsilon 概率选择一个随机动作,并以 1-epsilon 概率选择一个具有最大 Q(s,a) 的动作。因此,我们可以在以 epsilon 概率进行探索和以 1-epsilon 概率进行利用之间做出选择:
while not done:
#random action less than epsilon
if np.random.rand() < epsilon:
#we go with the random action
action = env.action_space.sample()
else:
"""
1 - epsilon probability, we go with the greedy algorithm
"""
action = np.argmax(Q[state, :])
在下一项活动中,我们将通过实现蒙特卡洛控制每次访问的 epsilon 软方法来评估和改进冰湖问题的策略。
活动 6.02 使用蒙特卡洛控制每次访问解决冰湖问题(epsilon 软)
本活动的目标是通过使用每次访问 epsilon 软方法来评估和改进冰湖问题的策略。
您可以通过导入 gym 并执行 gym.make() 来启动冰湖游戏:
import gym
env = gym.make("FrozenLake-v0", is_slippery=False)
执行以下步骤以完成活动:
-
导入必要的库。
-
选择环境为
FrozenLake。is_slippery设置为False。 -
将
Q值和num_state_action初始化为零。 -
将
num_episodes的值设置为100000,并创建rewardsList。将 epsilon 设置为0.30。 -
循环运行直到
num_episodes。初始化环境、results_List和result_sum为零。同时重置环境。 -
现在我们需要同时进行探索和利用。探索将是一个具有 epsilon 概率的随机策略,利用将是一个具有 1-epsilon 概率的贪婪策略。我们开始一个
while循环,并检查是否需要以 epsilon 的概率选择一个随机值,或者以 1-epsilon 的概率选择一个贪婪策略。 -
逐步执行
action并获得new_state和reward。 -
结果列表将被附加,包括状态和动作对。
result_sum会根据结果的值递增。 -
将
new_state赋值给state,并将result_sum添加到rewardsList中。 -
使用增量方法计算
Q[s,a],公式为Q[s,a] + (result_sum – Q[s,a]) / N(s,a)。 -
打印每 1000 次的成功率值。
-
打印最终的成功率。
您将最初获得以下输出:
![图 6.13:Frozen Lake 成功率的初始输出]()
图 6.13:Frozen Lake 成功率的初始输出
最终你将得到以下输出:

图 6.14:Frozen Lake 成功率的最终输出
注意
该活动的解决方案可以在第 722 页找到。
总结
蒙特卡洛方法通过样本情节的形式从经验中学习。在没有环境模型的情况下,智能体通过与环境互动,可以学习到一个策略。在多次仿真或抽样的情况下,情节是可行的。我们了解了首次访问和每次访问评估的方法。同时,我们也学习了探索与利用之间的平衡。这是通过采用一个ε软策略来实现的。接着,我们了解了基于策略学习和非基于策略学习,并且学习了重要性抽样在非基于策略方法中的关键作用。我们通过将蒙特卡洛方法应用于《黑杰克》游戏和 OpenAI 框架中的 Frozen Lake 环境来学习这些方法。
在下一章中,我们将学习时间学习及其应用。时间学习结合了动态规划和蒙特卡洛方法的优点。它可以在模型未知的情况下工作,像蒙特卡洛方法一样,但可以提供增量学习,而不是等待情节结束。
第七章:7. 时序差分学习
概览
在本章中,我们将介绍时序差分(TD)学习,并重点讨论它如何发展蒙特卡罗方法和动态规划的思想。时序差分学习是该领域的关键主题之一,研究它使我们能够深入理解强化学习及其在最基本层面上的工作原理。新的视角将使我们看到蒙特卡罗方法是时序差分方法的一个特例,从而统一了这种方法,并将其适用性扩展到非情节性问题。在本章结束时,你将能够实现TD(0)、SARSA、Q-learning和TD(λ)算法,并用它们来解决具有随机和确定性转移动态的环境。
时序差分学习简介
在前几章学习了动态规划和蒙特卡罗方法之后,本章我们将重点讨论时序差分学习,这是强化学习的主要基石之一。我们将从它们最简单的形式——单步方法开始,然后在此基础上构建出它们最先进的形式,基于资格迹(eligibility traces)概念。我们将看到这种新方法如何使我们能够将时序差分和蒙特卡罗方法框架在相同的推导思想下,从而能够对比这两者。在本章中,我们将实现多种不同的时序差分方法,并将它们应用于 FrozenLake-v0 环境,涵盖确定性和随机环境动态。最后,我们将通过一种名为 Q-learning 的离策略时序差分方法解决 FrozenLake-v0 的随机版本。
时序差分学习,其名称来源于它通过在后续时间步之间比较状态(或状态-动作对)值的差异来进行学习,可以被视为强化学习算法领域的一个核心思想。它与我们在前几章中学习的方法有一些重要相似之处——事实上,就像那些方法一样,它通过经验进行学习,无需模型(像蒙特卡罗方法那样),并且它是“自举”的,意味着它能够在达到情节结束之前,利用已经获得的信息进行学习(就像动态规划方法那样)。
这些差异与时序差分方法相对于蒙特卡洛(MC)和动态规划(DP)方法的优势紧密相关:它不需要环境模型,并且相对于 DP 方法,它可以更广泛地应用。另一方面,它的引导能力使得时序差分方法更适合处理非常长的任务回合,并且是非回合任务的唯一解决方案——蒙特卡洛方法无法应用于这种任务。以长期或非回合任务为例,想象一个算法,它用于授予用户访问服务器的权限,每次将排队中的第一个用户分配到资源时会获得奖励,如果没有授予用户访问权限,则没有奖励。这个队列通常永远不会结束,因此这是一个没有回合的持续任务。
正如前几章所见,探索与利用的权衡是一个非常重要的话题,在时序差分算法中同样如此。它们分为两大类:在政策方法和脱离政策方法。正如我们在前面章节中所看到的,在在政策方法中,所学习的政策用于探索环境,而在脱离政策方法中,二者可以不同:一个用于探索,另一个是目标政策,旨在学习。在接下来的部分中,我们将讨论为给定政策估计状态价值函数的通用问题。然后,我们将看到如何基于它构建一个完整的强化学习算法,训练在政策和脱离政策方法,以找到给定问题的最优策略。
让我们从时序差分方法的世界开始第一步。
TD(0) – SARSA 和 Q-Learning
时序差分方法是无模型的,这意味着它们不需要环境模型来学习状态值表示。对于给定的策略,
,它们累积与之相关的经验,并更新在相应经验中遇到的每个状态的值函数估计。在这个过程中,时序差分方法使用在接下来的时间步骤中遇到的状态(或状态)来更新给定状态值,状态是在时间t访问的,因此是t+1、t+2、...、t+n。一个抽象的例子如下:一个智能体在环境中初始化并开始通过遵循给定的策略与环境互动,而没有任何关于哪个动作会生成哪些结果的知识。经过一定数量的步骤,智能体最终会到达一个与奖励相关的状态。该奖励信号用于通过时序差分学习规则增加先前访问的状态(或动作-状态对)的值。实际上,这些状态帮助智能体达到了目标,因此应该与较高的值相关联。重复这个过程将使得智能体构建一个完整且有意义的所有状态(或状态-动作对)的价值图,以便它利用所获得的知识选择最佳动作,从而达到与奖励相关的状态。
这意味着,TD 方法不需要等到本回合结束才改进策略;相反,它们可以基于遇到的状态的值进行构建,学习过程可以在初始化之后立即开始。
在本节中,我们将重点讨论所谓的一步法,也称为 TD(0)。在这种方法中,唯一被考虑用于构建给定状态价值函数更新的数值是下一个时间步的数值,别无其他。因此,举例来说,时间t时刻状态的价值函数更新如下所示:

图 7.1:时间't'时刻状态的价值函数更新
这里,
是环境转移后的下一个状态,
是转移过程中获得的奖励,
是学习率,
是折扣因子。很明显,TD 方法是如何“自举”的:为了更新状态(t)的价值函数,它们使用下一个状态(t+1)的当前价值函数,而无需等待直到本回合结束。值得注意的是,前面方程中方括号内的量可以被解释为误差项。这个误差项衡量了状态 St 的估计值与新的、更好的估计值之间的差异,
。这个量被称为 TD 误差,我们将在强化学习理论中多次遇到它:

图 7.2:时间't'时刻的 TD 误差
该误差是针对计算时所使用的特定时间而言的,它依赖于下一个时间步的数值(即,时间 t 的误差依赖于时间t+1的数值)。
TD 方法的一个重要理论结果是它们的收敛性证明:事实上,已经证明,对于任何固定的策略,
,前面方程中描述的算法 TD(0)会收敛到状态(或状态-动作对)价值函数,
。当步长参数足够小且为常数时,收敛是可以达到的,并且如果步长参数根据一些特定的(但容易遵循的)随机逼近条件减小,则以概率1收敛。这些证明主要适用于算法的表格版本,表格版本是用于强化学习理论介绍和理解的版本。这些版本处理的问题是状态和动作空间维度有限,因此可以通过有限变量组合进行穷举表示。
然而,当状态和动作空间如此庞大以至于不能通过有限变量的有限组合来表示时(例如,当状态空间是 RGB 图像空间时),这些证明的大多数可以轻松地推广到依赖于近似的算法版本时,这些近似版本被用于算法版本。
到目前为止,我们一直在处理状态值函数。为了解决时序差异控制的问题,我们需要学习一个状态-动作值函数,而不是一个状态值函数。事实上,通过这种方式,我们将能够为状态-动作对关联一个值,从而构建一个值映射,然后可以用来定义我们的策略。我们如何具体实现这一点取决于方法类别。首先,让我们看看所谓的在线策略方法,由所谓的 SARSA 算法实现,然后看看所谓的 Q 学习算法,它实现了离线策略方法。
SARSA – 在线策略控制
对于一个在线策略方法,目标是估计
,即当前行为策略下的状态-动作值函数,
,适用于所有状态和所有动作。为此,我们只需将我们在状态值函数中看到的方程应用于状态-动作函数。由于这两种情况是相同的(都是马尔可夫链和奖励过程),关于状态值函数收敛到与最优策略相对应的值函数的定理(因此解决了找到最优策略的问题)在这种新设置中也是有效的,其中值函数涉及状态-动作对。更新方程如下所示:

图 7.3:时间‘t’时的状态-动作值函数
这种更新应该在每次从非终止状态
转移到另一个状态后执行。如果
是一个终止状态,则
的值设为0。正如我们所见,更新规则使用了五元组
的每个元素,这解释了与转换相关联的状态-动作对之间的转换,以及与转换相关联的奖励。正是因为这种形式的五元组,这个算法被称为SARSA。
使用这些元素,可以很容易地基于它们设计一个基于在线策略的控制算法。正如我们之前提到的,所有在线策略方法都估计行为策略
的
,同时基于
更新
。SARSA 控制算法的方案可以描述如下:
-
选择算法参数;即步长,
,该值必须在区间(0, 1]内,并且ε-贪心策略的ε参数必须小且大于 0,因为它表示选择非最优动作的概率,以便进行探索。这可以通过以下代码实现:alpha = 0.02 epsilon = 0.05 -
初始化
,对于所有的
,
,随意设置,唯一例外是 Q(terminal, ·) = 0,如以下代码片段所示,在一个有16个状态和4个动作的环境中:q = np.ones((16,4)) -
为每个回合创建一个循环。初始化
,并使用从 Q 导出的策略(例如,ε-贪心)从
中选择
。这可以通过以下代码片段实现,其中初始状态由环境的reset函数提供,动作通过专门的ε-贪心函数选择:for i in range(nb_episodes): s = env.reset() a = action_epsilon_greedy(q, s, epsilon=epsilon) -
为每个步骤创建一个循环。执行动作
并观察
。从
中选择
,使用从 Q 导出的策略(例如,ε-贪心)。使用 SARSA 规则更新选定状态-动作对的状态-动作值函数,该规则将新值定义为当前值与 TD 误差乘以步长的和,
,如以下表达式所示:![图 7.4:使用 SARSA 规则更新状态-动作值函数]](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_07_04.jpg)
图 7.4:使用 SARSA 规则更新状态-动作值函数
然后,使用
将新状态-动作对更新至旧状态-动作对,直到
是一个终止状态。所有这些都通过以下代码实现:
while not done:
new_s, reward, done, info = env.step(a)
new_a = action_epsilon_greedy(q, new_s, epsilon=epsilon)
q[s, a] = q[s, a] + alpha * (reward + gamma \
* q[new_s, new_a] - q[s, a])
s = new_s
a = new_a
注意
该算法的步骤和代码最初由Sutton, Richard S. 《强化学习导论》。剑桥,马萨诸塞州:麻省理工学院出版社,2015 年开发并概述。
在以下条件下,SARSA 算法可以以概率1收敛到最优策略和最优动作值函数:
-
所有的状态-动作对需要被访问无限多次。
-
在极限情况下,该策略会收敛为贪心策略,这可以通过ε-贪心策略实现,其中
ε随时间消失(这可以通过设置ε = 1/t来完成)。
本算法使用了 ε-greedy 算法。我们将在下一章详细解释这一点,因此这里只做简要回顾。当通过状态-动作值函数学习策略时,状态-动作对的值被用来决定采取哪个最佳动作。在收敛时,给定状态下会从可用的动作中选择最佳的那个,并选择具有最高值的动作:这就是贪婪策略。这意味着对于每个给定的状态,始终会选择相同的动作(如果没有动作具有相同的值)。这种策略对于探索来说并不是一个好选择,尤其是在训练的初期。因此,在这个阶段,优先采用 ε-greedy 策略:最佳动作的选择概率为 1-ε,而其他情况下则选择一个随机动作。随着 ε 渐变为 0,ε-greedy 策略最终会变成贪婪策略,且当步数趋近于无穷大时,ε-greedy 策略会趋近于贪婪策略。
为了巩固这些概念,让我们立即应用 SARSA 控制算法。以下练习将展示如何实现 TD(0) SARSA 来解决 FrozenLake-v0 环境,首先使用其确定性版本。
这里的目标是观察 SARSA 算法如何恢复最优策略,而我们人类可以提前估算出这一策略,针对给定的问题配置。在深入之前,我们先快速回顾一下冰湖问题是什么,以及我们希望代理人找到的最优策略。代理人看到的是一个 4 x 4 的网格世界。
该网格包含一个起始位置 S(左上角),冰冻的方块 F,洞 H,以及一个目标 G(右下角)。当代理人到达终极目标状态时,它会获得 +1 的奖励,而如果它到达由洞构成的终极状态,则该回合结束且没有奖励。下表表示了环境:

图 7.5:FrozenLake-v0 环境
如前图所示,S是起始位置,F表示冰冻的方块,H表示空洞,G是目标。在确定性环境中,最优策略是能够让智能体在最短时间内到达目标的策略。严格来说,在这个特定环境中,由于没有对中间步骤的惩罚,因此最优路径不一定是最短的。每一条最终能够到达目标的路径在累计期望奖励方面都是同样最优的。然而,我们将看到,通过适当使用折扣因子,我们将能够恢复最优策略,而该策略也考虑了最短路径。在这种情况下,最优策略在下图中有所表示,其中每个四个动作(向下、向右、向左、向上)都由其首字母表示。有两个方块,对于它们来说,两个动作将导致相同的最优路径:

图 7.6: 最优策略
在上面的图示中,D表示向下,R表示向右,U表示向上,L表示向左。!代表目标,而–表示环境中的空洞。
我们将使用一个递减的ε值来逐步减少探索的范围,从而使其在极限时变为贪婪的。
这种类型的练习在学习经典强化学习算法时非常有用。由于是表格化的(这是一个网格世界示例,意味着它可以用一个 4x4 的网格表示),我们可以跟踪领域中发生的所有事情,轻松跟随在算法迭代过程中状态-动作对的值的更新,查看根据选定策略的动作选择,并收敛到最优策略。在本章中,你将学习如何在强化学习的背景下编写一个参考算法,并深入实践所有这些基本方面。
现在让我们继续进行实现。
练习 7.01: 使用 TD(0) SARSA 解决 FrozenLake-v0 确定性过渡
在这个练习中,我们将实现 SARSA 算法,并用它来解决 FrozenLake-v0 环境,在该环境中仅允许确定性过渡。这意味着我们将寻找(并实际找到)一个最优策略,以便在这个环境中取回飞盘。
以下步骤将帮助你完成这个练习:
-
导入所需的模块:
import numpy as np import matplotlib.pyplot as plt %matplotlib inline import gym -
实例化一个名为
FrozenLake-v0的gym环境。将is_slippery标志设置为False以禁用其随机性:env = gym.make('FrozenLake-v0', is_slippery=False) -
看一下动作空间和观察空间:
print("Action space = ", env.action_space) print("Observation space = ", env.observation_space)这将打印出以下内容:
Action space = Discrete(4) Observation space = Discrete(16) -
创建两个字典,以便轻松将动作编号转换为动作:
actionsDict = {} actionsDict[0] = " L " actionsDict[1] = " D " actionsDict[2] = " R " actionsDict[3] = " U " actionsDictInv = {} actionsDictInv["L"] = 0 actionsDictInv["D"] = 1 actionsDictInv["R"] = 2 actionsDictInv["U"] = 3 -
重置环境并渲染它,以便能够查看网格问题:
env.reset() env.render()输出将如下所示:
![图 7.7: 环境的初始状态]()
图 7.7: 环境的初始状态
-
可视化该环境的最优策略:
optimalPolicy = ["R/D"," R "," D "," L ", \ " D "," - "," D "," - ", \ " R ","R/D"," D "," - ", \ " - "," R "," R "," ! ",] print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])输出将如下所示:
Optimal policy: R/D R D L D - D - R R/D D - - R R !这表示该环境的最优策略,显示在 4x4 网格中表示的每个环境状态下,在四个可用动作中选择的最优动作:向上移动,向下移动,向右移动,和向左移动。除了两个状态外,所有其他状态都有唯一的最优动作。实际上,如前所述,最优动作是那些通过最短路径将智能体带到目标的动作。两个不同的可能性为两个状态产生相同的路径长度,因此它们是同样的最优解。
-
定义函数来执行ε-贪心动作。第一个函数实现了一个具有
1 - ε概率的ε-贪心策略。选择的动作是与状态-动作对关联的最大值所对应的动作;否则,返回一个随机动作。第二个函数仅通过lambda函数在传递时调用第一个函数:def action_epsilon_greedy(q, s, epsilon=0.05): if np.random.rand() > epsilon: return np.argmax(q[s]) return np.random.randint(4) def get_action_epsilon_greedy(epsilon): return lambda q,s: action_epsilon_greedy\ (q, s, epsilon=epsilon) -
定义一个函数来执行贪婪动作:
def greedy_policy(q, s): return np.argmax(q[s]) -
现在,定义一个函数来计算智能体表现的平均值。首先,我们将定义用于计算平均表现的集数(在此例中为
500),然后在循环中执行所有这些集数。我们将重置环境并开始该集中的循环以进行此操作。接着,我们根据要衡量表现的策略选择一个动作,使用所选动作推进环境,最后将奖励添加到累积回报中。我们重复这些环境步骤,直到集数完成:def average_performance(policy_fct, q): acc_returns = 0. n = 500 for i in range(n): done = False s = env.reset() while not done: a = policy_fct(q, s) s, reward, done, info = env.step(a) acc_returns += reward return acc_returns/n -
设置总集数和步骤数,指定估算智能体的平均表现的频率,并设置
ε参数,该参数决定其衰减方式。使用初始值、最小值和衰减范围(以集数为单位):nb_episodes = 80000 STEPS = 2000 epsilon_param = [[0.2, 0.001, int(nb_episodes/2)]] -
将 SARSA 训练算法定义为一个函数。在此步骤中,Q 表被初始化。所有的值都等于
1,但终止状态的值被设置为0:def sarsa(alpha = 0.02, \ gamma = 1., \ epsilon_start = 0.1, \ epsilon_end = 0.001, \ epsilon_annealing_stop = int(nb_episodes/2), \ q = None, \ progress = None, \ env=env): if q is None: q = np.ones((16,4)) # Set q(terminal,*) equal to 0 q[5,:] = 0.0 q[7,:] = 0.0 q[11,:] = 0.0 q[12,:] = 0.0 q[15,:] = 0.0 -
在所有集数中开始一个
for循环:for i in range(nb_episodes): -
在循环内,首先根据当前集数定义 epsilon 值:
inew = min(i,epsilon_annealing_stop) epsilon = (epsilon_start \ *(epsilon_annealing_stop - inew)\ +epsilon_end * inew) / epsilon_annealing_stop -
接下来,重置环境,并使用ε-贪心策略选择第一个动作:
done = False s = env.reset() a = action_epsilon_greedy(q, s, epsilon=epsilon) -
然后,我们开始一个集内循环:
while not done: -
在循环内,环境通过所选动作和新状态以及奖励进行推进,并获取 done 条件:
new_s, reward, done, info = env.step(a) -
选择一个新的动作,使用ε-贪心策略,通过 SARSA TD(0)规则更新 Q 表,并更新状态和动作的值:
new_a = action_epsilon_greedy\ (q, new_s, epsilon=epsilon) q[s, a] = q[s, a] + alpha * (reward + gamma \ * q[new_s, new_a] - q[s, a]) s = new_s a = new_a -
最后,估算智能体的平均表现:
if progress is not None and i%STEPS == 0: progress[i//STEPS] = average_performance\ (get_action_epsilon_greedy\ (epsilon), q=q) return q, progress提供
ε参数减少的简要描述可能会有所帮助。这由三个参数决定:起始值、最小值和减少范围(称为epsilon_annealing_stop)。它们的使用方式如下:ε从起始值开始,然后在由参数“范围”定义的回合数中线性递减,直到达到最小值,之后保持不变。 -
定义一个数组,用于在训练过程中收集所有智能体的性能评估,以及 SARSA TD(0)训练的执行过程:
sarsa_performance = np.ndarray(nb_episodes//STEPS) q, sarsa_performance = sarsa(alpha = 0.02, gamma = 0.9, \ progress=sarsa_performance, \ epsilon_start=epsilon_param[0][0],\ epsilon_end=epsilon_param[0][1], \ epsilon_annealing_stop = \ epsilon_param[0][2]) -
绘制 SARSA 智能体在训练过程中平均奖励的历史记录:
plt.plot(STEPS*np.arange(nb_episodes//STEPS), sarsa_performance) plt.xlabel("Epochs") plt.title("Learning progress for SARSA") plt.ylabel("Average reward of an epoch")这会生成以下输出:
Text(0, 0.5, 'Average reward of an epoch')这可以通过以下方式进行可视化。它展示了 SARSA 算法的学习进展:
![图 7.8:训练过程中每个周期的平均奖励趋势]()
图 7.8:训练过程中每个周期的平均奖励趋势
如我们所见,随着
ε参数的退火,SARSA 的表现随着时间的推移不断提升,从而在极限时达到了0的值,从而获得了贪心策略。这也证明了该算法在学习后能够达到 100%的成功率。 -
评估经过训练的智能体(Q 表)在贪心策略下的表现:
greedyPolicyAvgPerf = average_performance(greedy_policy, q=q) print("Greedy policy SARSA performance =", greedyPolicyAvgPerf)输出如下所示:
Greedy policy SARSA performance = 1.0 -
显示 Q 表的值:
q = np.round(q,3) print("(A,S) Value function =", q.shape) print("First row") print(q[0:4,:]) print("Second row") print(q[4:8,:]) print("Third row") print(q[8:12,:]) print("Fourth row") print(q[12:16,:])输出如下所示:
(A,S) Value function = (16, 4) First row [[0.505 0.59 0.54 0.506] [0.447 0.002 0.619 0.494] [0.49 0.706 0.487 0.562] [0.57 0.379 0.53 0.532]] Second row [[0.564 0.656 0\. 0.503] [0\. 0\. 0\. 0\. ] [0.003 0.803 0.002 0.567] [0\. 0\. 0\. 0\. ]] Third row [[0.62 0\. 0.728 0.555] [0.63 0.809 0.787 0\. ] [0.707 0.899 0\. 0.699] [0\. 0\. 0\. 0\. ]] Fourth row [[0\. 0\. 0\. 0\. ] [0\. 0.791 0.9 0.696] [0.797 0.895 1\. 0.782] [0\. 0\. 0\. 0\. ]]该输出展示了我们问题的完整状态-动作值函数的值。这些值随后用于通过贪心选择规则生成最优策略。
-
打印出找到的贪心策略并与最优策略进行比较。在计算出状态-动作值函数后,我们可以从中提取出贪心策略。事实上,正如前面所解释的,贪心策略选择的是对于给定状态,Q 表中与之关联的最大值所对应的动作。为此,我们使用了
argmax函数。将其应用于 16 个状态(从 0 到 15)中的每一个时,它返回与该状态相关的四个动作(从 0 到 3)中具有最大值的动作索引。在这里,我们还直接使用预先构建的字典输出与动作索引相关的标签:policyFound = [actionsDict[np.argmax(q[0,:])],\ actionsDict[np.argmax(q[1,:])], \ actionsDict[np.argmax(q[2,:])], \ actionsDict[np.argmax(q[3,:])], \ actionsDict[np.argmax(q[4,:])], \ " - ",\ actionsDict[np.argmax(q[6,:])], \ " - ",\ actionsDict[np.argmax(q[8,:])], \ actionsDict[np.argmax(q[9,:])], \ actionsDict[np.argmax(q[10,:])], \ " - ",\ " - ",\ actionsDict[np.argmax(q[13,:])], \ actionsDict[np.argmax(q[14,:])], \ " ! "] print("Greedy policy found:") idxs = [0,4,8,12] for idx in idxs: print(policyFound[idx+0], policyFound[idx+1], \ policyFound[idx+2], policyFound[idx+3]) print(" ") print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])输出如下所示:
Greedy policy found: D R D L D - D - R D D - - R R ! Optimal policy: R/D R D L D - D - R R/D D - - R R !
正如前面的输出所示,我们实现的 TD(0) SARSA 算法仅通过与环境交互并通过回合收集经验,然后采用在SARSA – On-Policy Control部分中定义的 SARSA 状态-动作对值函数更新规则,成功地学到了该任务的最优策略。实际上,正如我们所看到的,对于环境中的每个状态,我们算法计算的 Q 表获得的贪心策略所指定的动作与为分析环境问题而定义的最优策略一致。如我们之前所见,在两个状态中有两个同样最优的动作,智能体能够正确地执行其中之一。
注意
要访问此特定部分的源代码,请参考packt.live/3fJBLBh。
你还可以在packt.live/30XeOXj在线运行这个示例。
随机性测试
现在,让我们来看一下如果在 FrozenLake-v0 环境中启用随机性会发生什么。为这个任务启用随机性意味着每个选定动作的转移不再是确定性的。具体来说,对于一个给定的动作,有三分之一的概率该动作会按预期执行,而两个相邻动作的概率各占三分之一和三分之一。反方向的动作则没有任何概率。因此,例如,如果设置了下(Down)动作,智能体会有三分之一的时间向下移动,三分之一的时间向右移动,剩下的三分之一时间向左移动,而绝不会向上移动,如下图所示:

图 7.9:如果从中心瓦片执行下(Down)动作时,各个结果状态的百分比
环境设置与我们之前看到的 FrozenLake-v0 确定性案例完全相同。同样,我们希望 SARSA 算法恢复最优策略。在这种情况下,这也可以事先进行估算。为了使推理更容易,这里有一个表示该环境的表格:

图 7.10:问题设置
在上面的图示中,S是起始位置,F表示冰冻瓦片,H表示坑洞,G是目标。对于随机环境,最优策略与对应的确定性情况有很大不同,甚至可能显得违背直觉。关键点是,为了保持获得奖励的可能性,我们唯一的机会就是避免掉入坑洞。由于中间步骤没有惩罚,我们可以继续绕行,只要我们需要。唯一确定的做法如下:
-
移动到我们下一个发现的坑洞的反方向,即使这意味着远离目标。
-
以各种可能的方式避免掉入那些有可能大于 0 的坑洞的瓦片:

图 7.11:环境设置(A),智能体执行的动作(B),以及在每个位置结束时接近起始状态的概率(C)
例如,考虑我们问题设置中左侧第二行的第一个瓦片,如前图表B所示。在确定性情况下,最优行动是向下移动,因为这样能使我们更接近目标。而在这个案例中,最佳选择是向左移动,即使向左意味着会碰到墙壁。这是因为向左是唯一不会让我们掉进坑里的行动。此外,有 33%的概率我们会最终到达第三行的瓦片,从而更接近目标。
注释
上述行为遵循了标准的边界实现。在该瓦片中,你执行“向左移动”这个完全合法的动作,环境会理解为“碰壁”。算法只会向环境发送一个“向左移动”的指令,环境会根据这个指令采取相应的行动。
类似的推理可以应用到其他所有瓦片,同时要记住前面提到的关键点。然而,值得讨论一个非常特殊的情况——这是我们无法实现 100%成功的唯一原因,即使使用最优策略:

图 7.12:环境设置(A)、代理执行“向左移动”动作(B)、以及每个位置结束时接近起始状态的概率(C)
现在,让我们来看一下我们问题设置中左侧第二行的第三个瓦片,如前图表 B 所示。这个瓦片位于两个坑之间,因此没有任何行动是 100%安全的。在这里,最佳行动实际上是向左或向右的坑移动!这是因为,向左或向右移动,我们有 66%的机会向上或向下移动,只有 33%的机会掉进坑里。向上或向下移动意味着我们有 66%的机会向右或向左移动,掉进坑里,只有 33%的机会真正向上或向下移动。由于这个瓦片是我们无法在 100%情况下实现最佳表现的原因,最好的办法是避免到达这个瓦片。为了做到这一点,除了起始瓦片外,最优策略的第一行所有的行动都指向上方,以避免落到这个有问题的瓦片上。
除了目标左侧的瓦片,所有其他值都受到坑的邻近影响:对于这个瓦片,最优行动选择是向下移动,因为这保持了到达目标的机会,同时避免落到上面的瓦片,在那里,代理会被迫向左移动以避免掉进坑里,从而冒着掉到两个坑之间的瓦片的风险。最优策略总结如下图所示:

图 7.13:最优策略
前面的图表显示了先前解释的环境的最优策略,其中 D 表示向下移动,R 表示向右移动,U 表示向上移动,L 表示向左移动。
在下面的例子中,我们将使用 SARSA 算法来解决这个新版本的 FrozenLake-v0 环境。为了获得我们刚才描述的最优策略,我们需要调整我们的超参数 —— 特别是折扣因子,
。事实上,我们希望给予代理人足够多的步骤自由度。为了做到这一点,我们必须向目标传播价值,以便所有目标中的轨迹都能从中受益,即使这些轨迹不是最短的。因此,我们将使用一个接近1的折扣因子。在代码中,这意味着我们将使用gamma = 1,而不是gamma = 0.9。
现在,让我们看看我们的 SARSA 算法在这个随机环境中的工作。
练习 7.02:使用 TD(0) SARSA 解决 FrozenLake-v0 随机转移问题
在本练习中,我们将使用 TD(0) SARSA 算法来解决 FrozenLake-v0 环境,并启用随机转移。正如我们刚才看到的,由于需要考虑随机性因素,最优策略看起来与之前的练习完全不同。这给 SARSA 算法带来了新的挑战,我们将看到它如何仍然能够解决这个任务。这个练习将展示给我们这些健壮的 TD 方法如何处理不同的挑战,展示出了显著的鲁棒性。
按照以下步骤完成此练习:
-
导入所需的模块:
import numpy as np import matplotlib.pyplot as plt %matplotlib inline import gym -
实例化
gym环境,称为FrozenLake-v0,使用is_slippery标志设置为True以启用随机性:env = gym.make('FrozenLake-v0', is_slippery=True) -
查看动作和观察空间:
print("Action space = ", env.action_space) print("Observation space = ", env.observation_space)输出如下:
Action space = Discrete(4) Observation space = Discrete(16) -
创建两个字典,以便轻松地将
actions索引(从0到3)映射到标签(左、下、右和上):actionsDict = {} actionsDict[0] = " L " actionsDict[1] = " D " actionsDict[2] = " R " actionsDict[3] = " U " actionsDictInv = {} actionsDictInv["L"] = 0 actionsDictInv["D"] = 1 actionsDictInv["R"] = 2 actionsDictInv["U"] = 3 -
重置环境并渲染以查看网格问题:
env.reset() env.render()输出如下:
![图 7.14:环境的初始状态
![img/B16182_07_14.jpg]()
图 7.14:环境的初始状态
-
可视化此环境的最优策略:
optimalPolicy = ["L/R/D"," U "," U "," U ",\ " L "," - "," L/R "," - ",\ " U "," D "," L "," - ",\ " - "," R "," D "," ! ",] print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])输出如下:
Optimal policy: L/R/D U U U L - L/R - U D L - - R D !这代表了此环境的最优策略。除了两个状态外,所有其他状态都有一个与之关联的单一最优动作。事实上,正如先前描述的,这里的最优动作是将代理人远离洞穴或具有导致代理人移动到靠近洞穴的几率大于零的瓦片的动作。两个状态有多个与之关联的同等最优动作,这正是此任务的意图。
-
定义函数来执行 ε-greedy 动作:
def action_epsilon_greedy(q, s, epsilon=0.05): if np.random.rand() > epsilon: return np.argmax(q[s]) return np.random.randint(4) def get_action_epsilon_greedy(epsilon): return lambda q,s: action_epsilon_greedy\ (q, s, epsilon=epsilon)第一个函数实现了ε-贪婪策略:以
1 - ε的概率,选择与状态-动作对关联的最高值的动作;否则,返回一个随机动作。第二个函数通过lambda函数传递时,简单地调用第一个函数。 -
定义一个函数,用于执行贪婪策略:
def greedy_policy(q, s): return np.argmax(q[s]) -
定义一个函数,用于计算代理的平均性能:
def average_performance(policy_fct, q): acc_returns = 0. n = 100 for i in range(n): done = False s = env.reset() while not done: a = policy_fct(q, s) s, reward, done, info = env.step(a) acc_returns += reward return acc_returns/n -
设置总回合数,表示评估代理平均性能的间隔步数的步数,以及控制
ε参数减少的参数,即起始值、最小值和范围(以回合数表示):nb_episodes = 80000 STEPS = 2000 epsilon_param = [[0.2, 0.001, int(nb_episodes/2)]] -
将 SARSA 训练算法定义为一个函数。初始化 Q 表时,所有值设置为
1,但终止状态的值设置为0:def sarsa(alpha = 0.02, \ gamma = 1., \ epsilon_start = 0.1,\ epsilon_end = 0.001,\ epsilon_annealing_stop = int(nb_episodes/2),\ q = None, \ progress = None, \ env=env): if q is None: q = np.ones((16,4)) # Set q(terminal,*) equal to 0 q[5,:] = 0.0 q[7,:] = 0.0 q[11,:] = 0.0 q[12,:] = 0.0 q[15,:] = 0.0 -
在所有回合之间开始一个循环:
for i in range(nb_episodes): -
在循环内,首先根据当前的回合数定义 epsilon 值。重置环境,并确保第一个动作是通过ε-贪婪策略选择的:
inew = min(i,epsilon_annealing_stop) epsilon = (epsilon_start \ * (epsilon_annealing_stop - inew)\ + epsilon_end * inew) \ / epsilon_annealing_stop done = False s = env.reset() a = action_epsilon_greedy(q, s, epsilon=epsilon) -
然后,开始一个回合内的循环:
while not done: -
在循环内,使用选定的动作在环境中执行步骤,并确保获取到新状态、奖励和完成条件:
new_s, reward, done, info = env.step(a) -
使用ε-贪婪策略选择一个新的动作,使用 SARSA TD(0)规则更新 Q 表,并确保状态和动作更新为其新值:
new_a = action_epsilon_greedy\ (q, new_s, epsilon=epsilon) q[s, a] = q[s, a] + alpha \ * (reward + gamma \ * q[new_s, new_a] - q[s, a]) s = new_s a = new_a -
最后,估算代理的平均性能:
if progress is not None and i%STEPS == 0: progress[i//STEPS] = average_performance\ (get_action_epsilon_greedy\ (epsilon), q=q) return q, progress提供关于
ε参数减小的简要描述可能会很有用。它受三个参数的控制:起始值、最小值和减小范围。它们的使用方式如下:ε从起始值开始,然后在由参数“范围”定义的集数内线性减小,直到达到最小值,并保持该值不变。 -
定义一个数组,用来在训练和执行 SARSA TD(0)训练期间收集所有代理的性能评估:
sarsa_performance = np.ndarray(nb_episodes//STEPS) q, sarsa_performance = sarsa(alpha = 0.02, gamma = 1,\ progress=sarsa_performance, \ epsilon_start=epsilon_param[0][0],\ epsilon_end=epsilon_param[0][1], \ epsilon_annealing_stop = \ epsilon_param[0][2]) -
绘制 SARSA 代理在训练期间的平均奖励历史:
plt.plot(STEPS*np.arange(nb_episodes//STEPS), sarsa_performance) plt.xlabel("Epochs") plt.title("Learning progress for SARSA") plt.ylabel("Average reward of an epoch")这将生成以下输出,展示了 SARSA 算法的学习进度:
Text(0, 0.5, 'Average reward of an epoch')图将如下所示:
![图 7.15:训练回合期间一个周期的平均奖励趋势]()
图 7.15:训练回合期间一个周期的平均奖励趋势
这个图清楚地展示了即使在考虑随机动态的情况下,SARSA 算法的性能是如何随回合数提升的。在约 60k 回合时性能的突然下降是完全正常的,尤其是在随机探索起主要作用,且随机过渡动态是环境的一部分时,正如在这个案例中所展示的那样。
-
评估贪婪策略在训练代理(Q 表)下的表现:
greedyPolicyAvgPerf = average_performance(greedy_policy, q=q) print("Greedy policy SARSA performance =", greedyPolicyAvgPerf)输出将如下所示:
Greedy policy SARSA performance = 0.75 -
显示 Q 表的值:
q = np.round(q,3) print("(A,S) Value function =", q.shape) print("First row") print(q[0:4,:]) print("Second row") print(q[4:8,:]) print("Third row") print(q[8:12,:]) print("Fourth row") print(q[12:16,:])将生成以下输出:
(A,S) Value function = (16, 4) First row [[0.829 0.781 0.785 0.785] [0.416 0.394 0.347 0.816] [0.522 0.521 0.511 0.813] [0.376 0.327 0.378 0.811]] Second row [[0.83 0.552 0.568 0.549] [0\. 0\. 0\. 0\. ] [0.32 0.195 0.535 0.142] [0\. 0\. 0\. 0\. ]] Third row [[0.55 0.59 0.546 0.831] [0.557 0.83 0.441 0.506] [0.776 0.56 0.397 0.342] [0\. 0\. 0\. 0\. ]] Fourth row [[0\. 0\. 0\. 0\. ] [0.528 0.619 0.886 0.506] [0.814 0.943 0.877 0.844] [0\. 0\. 0\. 0\. ]]该输出显示了我们问题的完整状态-动作值函数的值。这些值随后通过贪婪选择规则来生成最优策略。
-
打印出找到的贪婪策略,并与最优策略进行比较。计算出状态-动作值函数后,我们能够从中提取出贪婪策略。事实上,正如之前所解释的,贪婪策略会选择在给定状态下与 Q 表中最大值关联的动作。为此,我们使用了
argmax函数。当该函数应用于 16 个状态(从 0 到 15)时,它返回的是在四个可用动作(从 0 到 3)中,哪个动作与该状态的最大值关联的索引。在这里,我们还通过预先构建的字典直接输出与动作索引关联的标签:policyFound = [actionsDict[np.argmax(q[0,:])],\ actionsDict[np.argmax(q[1,:])],\ actionsDict[np.argmax(q[2,:])],\ actionsDict[np.argmax(q[3,:])],\ actionsDict[np.argmax(q[4,:])],\ " - ",\ actionsDict[np.argmax(q[6,:])],\ " - ",\ actionsDict[np.argmax(q[8,:])],\ actionsDict[np.argmax(q[9,:])],\ actionsDict[np.argmax(q[10,:])],\ " - ",\ " - ",\ actionsDict[np.argmax(q[13,:])],\ actionsDict[np.argmax(q[14,:])],\ " ! "] print("Greedy policy found:") idxs = [0,4,8,12] for idx in idxs: print(policyFound[idx+0], policyFound[idx+1], \ policyFound[idx+2], policyFound[idx+3]) print(" ") print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])输出将如下所示:
Greedy policy found: L U U U L - R - U D L - - R D ! Optimal policy: L/R/D U U U L - L/R - U D L - - R D !
如你所见,和之前的练习一样,我们的算法通过简单地探索环境,成功找到了最优策略,即使在环境转移是随机的情况下。正如预期的那样,在这种设置下,不可能 100% 的时间都达到最大奖励。事实上,正如我们所看到的,对于环境中的每个状态,通过我们的算法计算出的 Q 表所获得的贪婪策略都建议一个与通过分析环境问题定义的最优策略一致的动作。如我们之前所见,有两个状态中有许多不同的动作是同样最优的,代理正确地执行了其中之一。
注意
要访问该特定部分的源代码,请参考 packt.live/3eicsGr。
你也可以在 packt.live/2Z4L1JV 在线运行此示例。
现在我们已经熟悉了在策略控制,是时候转向离策略控制了,这是强化学习中的一次早期突破,追溯到 1989 年的 Q-learning。
注意
Q-learning 算法最早由 Watkins 在 Mach Learn 8, 279–292 (1992) 提出。在这里,我们仅提供一个直观理解,以及简要的数学描述。有关更详细的数学讨论,请参阅原始论文 link.springer.com/article/10.1007/BF00992698。
Q-learning – 离策略控制
Q-learning 是识别一类离策略控制时序差分算法的名称。从数学/实现的角度来看,与在策略算法相比,唯一的区别在于用于更新 Q 表(或近似方法的函数)的规则,具体定义如下:

图 7.16:近似方法的函数
关键点在于如何为下一个状态选择动作,
。实际上,选择具有最大状态-动作值的动作直接近似了找到最优 Q 值并遵循最优策略时发生的情况。此外,它与用于收集经验的策略无关,而是在与环境互动时得到的。探索策略可以与最优策略完全不同;例如,它可以是ε-greedy 策略以鼓励探索,并且在一些容易满足的假设下,已经证明 Q 会收敛到最优值。
在第九章,什么是深度 Q 学习?中,你将研究这种方法在非表格化方法中的扩展,我们使用深度神经网络作为函数近似器。这种方法叫做深度 Q 学习。Q-learning 控制算法的方案可以如下所示:
-
选择算法参数:步长,
,它必须位于区间(0, 1]内,以及ε-greedy 策略的ε参数,它必须较小且大于0,因为它表示选择非最优动作的概率,以促进探索:alpha = 0.02 epsilon_expl = 0.2 -
对所有
,
,
进行初始化,任意设置,除了 Q(terminal, *) = 0:q = np.ones((16, 4)) # Set q(terminal,*) equal to 0 q[5,:] = 0.0 q[7,:] = 0.0 q[11,:] = 0.0 q[12,:] = 0.0 q[15,:] = 0.0 -
在所有回合中创建一个循环。在该循环中,初始化
s:for i in range(nb_episodes): done = False s = env.reset() -
为每个回合创建一个循环。在该循环中,使用从 Q 派生的策略(例如,ε-greedy)从
中选择
:while not done: # behavior policy a = action_epsilon_greedy(q, s, epsilon=epsilon_expl) -
执行动作,
,观察
。使用 Q-learning 规则更新所选状态-动作对的状态-动作值函数,该规则将新值定义为当前值加上与步长
相乘的与离策略相关的 TD 误差。可以表示如下:![图 7.17:更新的状态-动作值函数的表达式]()
图 7.17:更新的状态-动作值函数的表达式
前面的解释可以通过代码实现如下:
new_s, reward, done, info = env.step(a)
a_max = np.argmax(q[new_s]) # estimation policy
q[s, a] = q[s, a] + alpha \
* (reward + gamma \
* q[new_s, a_max] -q[s, a])
s = new_s
如我们所见,我们只是将新状态下采取动作的随机选择替换为与最大 q 值相关的动作。这种(看似)微小的变化,可以通过适配 SARSA 算法轻松实现,但对方法的性质有着重要影响。我们将在接下来的练习中看到它的效果。
练习 7.03:使用 TD(0) Q-Learning 解决 FrozenLake-v0 的确定性转移问题
在本练习中,我们将实现 TD(0) Q 学习算法来解决 FrozenLake-v0 环境,其中只允许确定性转移。在本练习中,我们将考虑与练习 7.01,使用 TD(0) SARSA 解决 FrozenLake-v0 确定性转移中相同的任务,即取回飞盘的最优策略,但这次我们不使用 SARSA 算法(基于策略),而是实现 Q 学习(非基于策略)。我们将观察该算法的行为,并训练自己通过恢复智能体的最优策略来实现一种新的估算 q 值表的方法。
按照以下步骤完成此练习:
-
导入所需的模块,如下所示:
import numpy as np import matplotlib.pyplot as plt %matplotlib inline import gym -
实例化名为
FrozenLake-v0的gym环境,设置is_slippery标志为False,以禁用随机性:env = gym.make('FrozenLake-v0', is_slippery=False) -
查看动作空间和观察空间:
print("Action space = ", env.action_space) print("Observation space = ", env.observation_space)输出结果如下:
Action space = Discrete(4) Observation space = Discrete(16) -
创建两个字典,方便将
actions数字转换为动作:actionsDict = {} actionsDict[0] = " L " actionsDict[1] = " D " actionsDict[2] = " R " actionsDict[3] = " U " actionsDictInv = {} actionsDictInv["L"] = 0 actionsDictInv["D"] = 1 actionsDictInv["R"] = 2 actionsDictInv["U"] = 3 -
重置环境并渲染以查看网格问题:
env.reset() env.render()输出结果如下:
![图 7.18:环境的初始状态]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_07_18.jpg)
图 7.18:环境的初始状态
-
可视化该环境的最优策略:
optimalPolicy = ["R/D"," R "," D "," L ",\ " D "," - "," D "," - ",\ " R ","R/D"," D "," - ",\ " - "," R "," R "," ! ",] print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])输出结果如下:
Optimal policy: R/D R D L D - D - R R/D D - - R R !这表示该环境的最优策略,并显示在 4x4 网格中表示的每个环境状态,对于四个可用动作中最优的动作:上移、下移、右移和左移。除了两个状态外,所有其他状态都有与之关联的唯一最优动作。实际上,正如前面所述,最优动作是那些将智能体带到目标的最短路径。两个不同的可能性导致两个状态具有相同的路径长度,因此它们都同样最优。
-
接下来,定义将执行ε-贪婪动作的函数:
def action_epsilon_greedy(q, s, epsilon=0.05): if np.random.rand() > epsilon: return np.argmax(q[s]) return np.random.randint(4) -
定义一个函数来执行贪婪动作:
def greedy_policy(q, s): return np.argmax(q[s]) -
定义一个函数来计算智能体表现的平均值:
def average_performance(policy_fct, q): acc_returns = 0. n = 500 for i in range(n): done = False s = env.reset() while not done: a = policy_fct(q, s) s, reward, done, info = env.step(a) acc_returns += reward return acc_returns/n -
初始化 Q 表,使得所有值都等于
1,除了终止状态的值:q = np.ones((16, 4)) # Set q(terminal,*) equal to 0 q[5,:] = 0.0 q[7,:] = 0.0 q[11,:] = 0.0 q[12,:] = 0.0 q[15,:] = 0.0 -
设置总集数、表示我们评估智能体平均表现的间隔步数、学习率、折扣因子、
ε值(用于探索策略),并定义一个数组以收集训练过程中所有智能体的表现评估:nb_episodes = 40000 STEPS = 2000 alpha = 0.02 gamma = 0.9 epsilon_expl = 0.2 q_performance = np.ndarray(nb_episodes//STEPS) -
使用 Q-learning 算法训练代理:外部循环负责生成所需的回合数。然后,回合内循环完成以下步骤:首先,使用 ε-贪心策略选择一个探索动作,然后环境通过选择的探索动作进行一步,获取
new_s、reward和done条件。为新状态选择新动作,使用贪心策略更新 Q-table,使用 Q-learning TD(0) 规则更新状态的新值。每隔预定步骤数,就会评估代理的平均表现:for i in range(nb_episodes): done = False s = env.reset() while not done: # behavior policy a = action_epsilon_greedy(q, s, epsilon=epsilon_expl) new_s, reward, done, info = env.step(a) a_max = np.argmax(q[new_s]) # estimation policy q[s, a] = q[s, a] + alpha \ * (reward + gamma \ * q[new_s, a_max] - q[s, a]) s = new_s # for plotting the performance if i%STEPS == 0: q_performance[i//STEPS] = average_performance\ (greedy_policy, q) -
绘制 Q-learning 代理在训练过程中的平均奖励历史:
plt.plot(STEPS * np.arange(nb_episodes//STEPS), q_performance) plt.xlabel("Epochs") plt.ylabel("Average reward of an epoch") plt.title("Learning progress for Q-Learning")这将生成以下输出,展示 Q-learning 算法的学习进度:
Text(0.5, 1.0, 'Learning progress for Q-Learning')输出将如下所示:
![图 7.19:训练轮次中每个时期的平均奖励趋势]()
图 7.19:训练轮次中每个时期的平均奖励趋势
正如我们所看到的,图表展示了随着代理收集越来越多的经验,Q-learning 性能如何在多个周期中快速增长。它还表明,算法在学习后能够达到 100% 的成功率。还可以明显看出,与 SARSA 方法相比,在这种情况下,测量的算法性能稳步提高,且提高速度要快得多。
-
评估训练好的代理(Q-table)的贪心策略表现:
greedyPolicyAvgPerf = average_performance(greedy_policy, q=q) print("Greedy policy Q-learning performance =", \ greedyPolicyAvgPerf)输出将如下所示:
Greedy policy Q-learning performance = 1.0 -
显示 Q-table 的值:
q = np.round(q,3) print("(A,S) Value function =", q.shape) print("First row") print(q[0:4,:]) print("Second row") print(q[4:8,:]) print("Third row") print(q[8:12,:]) print("Fourth row") print(q[12:16,:])以下输出将被生成:
(A,S) Value function = (16, 4) First row [[0.531 0.59 0.59 0.531] [0.617 0.372 0.656 0.628] [0.672 0.729 0.694 0.697] [0.703 0.695 0.703 0.703]] Second row [[0.59 0.656 0\. 0.531] [0\. 0\. 0\. 0\. ] [0.455 0.81 0.474 0.754] [0\. 0\. 0\. 0\. ]] Third row [[0.656 0\. 0.729 0.59 ] [0.656 0.81 0.81 0\. ] [0.778 0.9 0.286 0.777] [0\. 0\. 0\. 0\. ]] Fourth row [[0\. 0\. 0\. 0\. ] [0\. 0.81 0.9 0.729] [0.81 0.9 1\. 0.81 ] [0\. 0\. 0\. 0\. ]]此输出显示了我们问题的完整状态-动作价值函数的值。这些值随后用于通过贪心选择规则生成最优策略。
-
打印出找到的贪心策略,并与最优策略进行比较:
policyFound = [actionsDict[np.argmax(q[0,:])],\ actionsDict[np.argmax(q[1,:])],\ actionsDict[np.argmax(q[2,:])],\ actionsDict[np.argmax(q[3,:])],\ actionsDict[np.argmax(q[4,:])],\ " - ",\ actionsDict[np.argmax(q[6,:])],\ " - ",\ actionsDict[np.argmax(q[8,:])],\ actionsDict[np.argmax(q[9,:])],\ actionsDict[np.argmax(q[10,:])],\ " - ",\ " - ",\ actionsDict[np.argmax(q[13,:])],\ actionsDict[np.argmax(q[14,:])],\ " ! "] print("Greedy policy found:") idxs = [0,4,8,12] for idx in idxs: print(policyFound[idx+0], policyFound[idx+1], \ policyFound[idx+2], policyFound[idx+3]) print(" ") print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])输出将如下所示:
Greedy policy found: D R D L D - D - R D D - - R R ! Optimal policy: R/D R D L D - D - R R/D D - - R R !
正如这些输出所示,Q-learning 算法能够像 SARSA 一样,通过经验和与环境的互动,成功地提取最优策略,正如在 练习 07.01,使用 TD(0) SARSA 解决 FrozenLake-v0 确定性转移 中所做的那样。
正如我们所看到的,对于网格世界中的每个状态,使用我们算法计算的 Q 表得到的贪心策略都能推荐一个与通过分析环境问题定义的最优策略一致的动作。正如我们已经看到的,有两个状态在其中存在多个不同的动作,它们同样是最优的,且代理正确地实现了其中的一个。
注意
要访问此特定部分的源代码,请参考 packt.live/2AUlzym。
您也可以在线运行此示例,网址为 packt.live/3fJCnH5。
对于 SARSA 来说,如果我们启用随机转移,看看 Q-learning 的表现将会是很有趣的。这将是本章末尾活动的目标。两个算法遵循的程序与我们在 SARSA 中采用的完全相同:用于确定性转移情况的 Q-learning 算法被应用,您需要调整超参数(特别是折扣因子和训练轮次),直到在随机转移动态下获得对最优策略的收敛。
为了完善 TD(0) 算法的全貌,我们将引入另一种特定的方法,该方法是通过对前面的方法进行非常简单的修改得到的:期望 SARSA。
期望 SARSA
现在,让我们考虑一个与 Q-learning 非常相似的学习算法,唯一的区别是将下一个状态-动作对的最大值替换为期望值。这是通过考虑当前策略下每个动作的概率来计算的。这个修改后的算法可以通过以下更新规则来表示:

图 7.20:状态-动作值函数更新规则
与 SARSA 相比,额外的计算复杂性提供了一个优势,即消除了由于随机选择 At+1 所带来的方差,这对于显著提高学习效果和鲁棒性是一个非常强大的技巧。它可以同时用于在策略和非策略的方式,因此成为了 SARSA 和 Q-learning 的一种抽象,通常其性能优于两者。以下片段提供了该更新规则的实现示例:
q[s, a] = q[s, a] + alpha * (reward + gamma *
(np.dot(pi[new_s, :],q[new_s, :]) - q[s, a])
在前面的代码中,pi 变量包含了每个状态下每个动作的所有概率。涉及 pi 和 q 的点积是计算新状态期望值所需的操作,考虑到该状态下所有动作及其各自的概率。
现在我们已经学习了 TD(0) 方法,让我们开始学习 N 步 TD 和 TD(λ) 算法。
N 步 TD 和 TD(λ) 算法
在上一章中,我们研究了蒙特卡罗方法,而在本章前面的部分,我们学习了 TD(0) 方法,正如我们很快会发现的,它们也被称为一步时序差分方法。在本节中,我们将它们统一起来:事实上,它们处在一系列算法的极端(TD(0) 在一端,MC 方法在另一端),而通常,性能最优的方法是处于这个范围的中间。
N 步时序差分算法是对一阶时序差分方法的扩展。更具体地说,它们将蒙特卡罗和时序差分方法进行了概括,使得两者之间的平滑过渡成为可能。正如我们已经看到的,蒙特卡罗方法必须等到整个回合结束后,才能将奖励反向传播到之前的状态。而一阶时序差分方法则直接利用第一个可用的未来步骤进行自举,并开始更新状态或状态-动作对的价值函数。这两种极端情况很少是最佳选择。最佳选择通常位于这一广泛范围的中间。使用 N 步方法可以让我们调整在更新价值函数时考虑的步数,从而将自举方法分散到多个步骤上。
在资格迹的背景下也可以回忆起类似的概念,但它们更为一般,允许我们在多个时间间隔内同时分配和扩展自举。这两个话题将单独处理,以便清晰起见,并且为了帮助你逐步建立知识,我们将首先从 N 步方法开始,然后再讲解资格迹。
N 步时序差分
正如我们已经看到的一阶时序差分方法一样,接近 N 步方法的第一步是专注于使用策略生成的样本回合来估计状态值函数,
。我们已经提到,蒙特卡罗算法必须等到回合结束后,才能通过使用给定状态的整个奖励序列来进行更新。而一阶方法只需要下一个奖励。N 步方法采用了一种中间规则:它们不仅依赖于下一个奖励,或者依赖于回合结束前的所有未来奖励,而是采用这两者之间的一个值。例如,三步更新将使用前三个奖励和三步后达到的估计状态值。这可以对任意步数进行形式化。
这种方法催生了一系列方法,它们仍然是时序差分方法,因为它们使用目标状态之后遇到的 N 步来更新其值。显然,我们在本章开始时遇到的方法是 N 步方法的特例。因此,它们被称为“一阶时序差分方法”。
为了更正式地定义它们,我们可以考虑状态的估计值,
,作为状态-奖励序列的结果,
,
,
,
,...,
,
(不包括动作)。在 MC 方法中,这个估计值只有在一集结束时才会更新,而在一步法中,它会在下一步之后立即更新。另一方面,在 N 步法中,状态值估计是在 N 步之后更新的,使用一种折扣n未来奖励以及未来 N 步后遇到的状态值的量。这个量被称为 N 步回报,可以通过以下表达式定义:

图 7.21:N 步回报方程(带状态值函数)
这里需要注意的一个关键点是,为了计算这个 N 步回报,我们必须等到达到时间t+1,以便方程中的所有项都可以使用。通过使用 N 步回报,可以直接将状态值函数更新规则形式化,如下所示:

图 7.22:使用 N 步回报的自然状态值学习算法的表达式
请注意,所有其他状态的值保持不变,如以下表达式所示:

图 7.23:指定所有其他值保持恒定的表达式
这是将 N 步 TD 算法形式化的方程。值得再次注意的是,在我们可以估计 N 步回报之前,在前n-1步期间不会进行任何更改。需要在一集结束时进行补偿,当剩余的n-1更新在到达终止状态后一次性执行。
与我们之前看到的 TD(0)方法类似,并且不深入讨论数据,N 步 TD 方法的状态值函数估计在适当的技术条件下会收敛到最优值。
N 步 SARSA
扩展我们在介绍一步法时看到的 SARSA 算法到其 N 步版本是非常简单的。就像我们之前做的那样,唯一需要做的就是将值函数的 N 步回报中的状态-动作对替换为状态,并在刚才看到的更新公式中结合ε-贪婪策略。N 步回报(更新目标)的定义可以通过以下方程描述:

图 7.24:N 步回报方程(带状态-动作值函数)
这里,
,如果
。状态-动作值函数的更新规则表达如下:

图 7.25:状态-动作值函数的更新规则
请注意,其他所有状态-动作对的值保持不变:
,对所有s的值都适用,因此
或
。N 步 SARSA 控制算法的方案可以如下表示:
-
选择算法的参数:步长
,它必须位于区间(0, 1]内,和ε-贪心策略的ε参数,它必须小且大于0,因为它表示选择非最优动作以偏向探索的概率。必须选择步数n的值。例如,可以使用以下代码来完成此选择:alpha = 0.02 n = 4 epsilon = 0.05 -
初始化
,对于所有
,
,任意选择,除了 Q(终止, ·) = 0:q = np.ones((16,4)) -
为每个回合创建一个循环。初始化并存储 S0 ≠ 终止状态。使用ε-贪心策略选择并存储动作,并将时间
T初始化为一个非常大的值:for i in range(nb_episodes): s = env.reset() a = action_epsilon_greedy(q, s, epsilon=epsilon) T = 1e6 -
为 t = 0, 1, 2,... 创建一个循环。如果 t < T,则执行动作
。观察并存储下一奖励为
,下一状态为
。如果
是终止状态,则将T设置为t+1:while True: new_s, reward, done, info = env.step(a) if done: T = t+1 -
如果
不是终止状态,则选择并存储新状态下的动作:new_a = action_epsilon_greedy(q, new_s, epsilon=epsilon) -
定义用于更新估计的时间
tau,等于t-n+1:tau = t-n+1 -
如果
tau大于 0,则通过对前 n 步的折扣回报求和,并加上下一步-下一动作对的折扣值来计算 N 步回报,并更新状态-动作值函数:G = sum_n(q, tau, T, t, gamma, R, new_s, new_a) q[s, a] = q[s, a] + alpha * (G- q[s, a])
通过一些小的改动,这个规则可以轻松扩展以适应预期的 SARSA。正如本章之前所见,它只需要我们用目标策略下第 N 步最后一个时间点的估计动作值来替代状态的预期近似值。当相关状态是终止状态时,它的预期近似值定义为 0。
N 步非策略学习
为了定义 N 步方法的离策略学习,我们将采取与一阶方法相似的步骤。关键点在于,像所有离策略方法一样,我们是在为策略
学习价值函数,同时遵循一个不同的探索策略,假设为 b。通常,
是当前状态-动作值函数估计的贪婪策略,而 b 具有更多的随机性,以便有效探索环境;例如,ε-贪婪策略。与我们之前看到的一阶离策略方法的主要区别在于,现在我们需要考虑到,我们正在使用不同于我们想要学习的策略来选择动作,并且我们是进行多步选择。因此,我们需要通过测量在两种策略下选择这些动作的相对概率来适当加权所选动作。
通过这个修正,我们可以定义一个简单的离策略 N 步 TD 版本的规则:在时间 t (实际上是在时间 t + n)进行的更新可以通过
进行加权:

图 7.26:时间 't' 时刻 N 步 TD 离策略更新规则
这里,V 是价值函数,
是步长,G 是 N 步回报,
称为重要性采样比率。重要性采样比率是指在两种策略下,从
到
执行 n 个动作的相对概率,其表达式如下:

图 7.27:采样比率方程
这里,
是智能体策略,
是探索策略,
是动作,
是状态。
根据这个定义,很明显,在我们想要学习的策略下永远不会选择的动作(即它们的概率为 0)会被忽略(权重为 0)。另一方面,如果我们正在学习的策略下某个动作相对于探索策略具有更高的概率,那么分配给它的权重应高于 1,因为它会更频繁地被遇到。显然,对于在策略情况下,采样比率始终等于 1,因为
和
是相同的策略。因此,N 步 SARSA 在策略更新可以视为离策略更新的一个特例。该更新的通用形式,可以从中推导出在策略和离策略方法,表达式如下:

图 7.28:离策略 N 步 TD 算法的状态-动作值函数
如你所见,
是状态-动作值函数,
是步长,
是 N 步回报,
是重要性抽样比率。完整算法的方案如下:
-
选择一个任意行为策略,
,使得每个状态的每个动作的概率对于所有状态和动作都大于 0。选择算法参数:步长,
,其必须在区间(0, 1]内,并为步数选择一个值n。这可以通过以下代码实现:alpha = 0.02 n = 4 -
初始化
,对于所有
,
可以任意选择,除非 Q(终止,·) = 0:q = np.ones((16,4)) -
初始化策略,
,使其对 Q 采取贪婪策略,或设定为一个固定的给定策略。为每个回合创建一个循环。初始化并存储 S0 ≠终止状态。使用 b 策略选择并存储一个动作,并将时间T初始化为一个非常大的值:for i in range(nb_episodes): s = env.reset() a = action_b_policy(q, s) T = 1e6 -
为 t = 0, 1, 2, ... 创建一个循环。如果 t < T,则执行动作
。观察并存储下一次奖励为
,并将下一个状态存储为
。如果
是终止状态,则将T设置为t+1:while True: new_s, reward, done, info = env.step(a) if done: T = t+1 -
如果
不是终止状态,则选择并存储新状态的动作:new_a = action_b_policy(q, new_s) -
定义估计更新的时间
tau,使其等于t-n+1:tau = t-n+1 -
如果
tau大于或等于0,则计算抽样比率。通过对前 n 步的折扣回报求和,并加上下一步-下一动作对的折扣值来计算 N 步回报,并更新状态-动作值函数:rho = product_n(q, tau, T, t, R, new_s, new_a) G = sum_n(q, tau, T, t, gamma, R, new_s, new_a) q[s, a] = q[s, a] + alpha * rho * (G- q[s, a])
现在我们已经研究了 N 步方法,是时候继续学习时序差分方法的最一般且最高效的变体——TD(λ)了。
TD(λ)
流行的 TD(λ)算法是一种时序差分算法,利用了资格迹概念。正如我们很快将看到的,这是一种通过任意步数适当加权状态(或状态-动作对)的价值函数贡献的过程。名称中引入的lambda项是一个参数,用于定义和参数化这一系列算法。正如我们很快会看到的,它是一个加权因子,可以让我们适当地加权涉及算法回报估计的不同贡献项。
任何时间差方法,例如我们已经看到的(Q-learning 和 SARSA),都可以与资格迹概念结合,我们将在接下来实现。这使我们能够获得一种更通用的方法,同时也更高效。正如我们之前预期的,这种方法实现了 TD 和蒙特卡罗方法的最终统一与推广。同样,关于我们在 N 步 TD 方法中看到的内容,在这里我们也有一个极端(λ = 0)的单步 TD 方法和另一个极端(λ = 1)的蒙特卡罗方法。这两个边界之间的空间包含了中间方法(就像 N 步方法中有限的 n > 1 一样)。此外,资格迹还允许我们使用扩展的蒙特卡罗方法进行所谓的在线实现,这意味着它们可以应用于非回合性问题。
相对于我们之前看到的 N 步 TD 方法,资格迹具有额外的优势,使我们能够显著提升这些方法的计算效率。如我们之前所提到的,选择 N 步方法中 n 的正确值往往不是一项简单的任务。而资格迹则允许我们将不同时间步对应的更新“融合”在一起。
为了实现这一目标,我们需要定义一种方法来加权 N 步返回值,
,使用一个随着时间呈指数衰减的权重。通过引入一个因子
,并用
对第 n 次返回进行加权。
目标是定义一个加权平均值,使得所有这些权重的总和为 1。标准化常数是收敛几何级数的极限值:
。有了这个,我们可以定义所谓的
-返回,如下所示:

图 7.29:lambda 返回的表达式
该方程定义了我们选择的
如何影响给定返回随着步数的增加呈指数下降的速度。
我们现在可以使用这个新的返回值作为状态(或状态-动作对)值函数的目标,从而创建一个新的值函数更新规则。此时看起来,为了考虑所有的贡献,我们应该等到本回合结束,收集所有未来的返回值。这个问题通过资格迹的第二个基本新颖性得到了解决:与其向前看,我们反转了视角,智能体根据资格迹规则,使用当前返回值和价值信息来更新过去访问过的所有状态(状态-动作对)。
资格迹初始化为每个状态(或状态-动作对)都为 0,在每一步时间更新时,访问的状态(或状态-动作对)的值加 1,从而使其在更新值函数时权重最大,并通过
因子逐渐衰退。这个因子是资格迹随着时间衰退的组合,如之前所解释的 (
),以及我们在本章中多次遇到的熟悉的奖励折扣因子
。有了这个新概念,我们现在可以构建新的值函数更新规则。首先,我们有一个方程式来调节资格迹的演化:

图 7.30:时间‘t’时状态的资格迹初始化和更新规则
然后,我们有了新的 TD 误差(或δ)的定义。状态值函数的更新规则如下:

图 7.31:使用资格迹的状态值函数更新规则
现在,让我们看看如何在 SARSA 算法中实现这个思想,以获得一个具有资格迹的策略控制算法。
SARSA(λ)
直接将状态值更新转换为状态-动作值更新,允许我们将资格迹特性添加到我们之前看到的 SARSA 算法中。资格迹方程可以如下修改:

图 7.32:时间‘t’时状态-动作对的资格迹初始化和更新规则
TD 误差和状态-动作值函数更新规则如下所示:

图 7.33:使用资格迹的状态-动作对值函数更新规则
一个完美总结所有这些步骤并展示完整算法的示意图如下:
-
选择算法的参数:步长
,该值必须位于区间(0, 1]内,和ε-贪心策略的ε参数,该参数必须小且大于 0,因为它代表选择非最优动作的概率,旨在促进探索。必须选择一个lambda参数的值。例如,可以通过以下代码来实现:alpha = 0.02 lambda = 0.3 epsilon = 0.05 -
初始化
,对于所有
,
,任选初始化,除了 Q(terminal, ·) = 0:q = np.ones((16,4)) -
为每个回合创建一个循环。将资格迹表初始化为
0:E = np.zeros((16, 4)) -
初始化状态为非终止状态,并使用ε-贪心策略选择一个动作。然后,开始回合内循环:
state = env.reset() action = action_epsilon_greedy(q, state, epsilon) while True: -
为每个回合的每一步创建一个循环,更新 eligibility traces,并将值为
1分配给最后访问的状态:E = eligibility_decay * gamma * E E[state, action] += 1 -
通过环境并使用 ε-贪婪策略选择下一个动作:
new_state, reward, done, info = env.step(action) new_action = action_epsilon_greedy\ (q, new_state, epsilon) -
计算
更新,并使用 SARSA TD(
) 规则更新 Q 表:delta = reward + gamma \ * q[new_state, new_action] - q[state, action] q = q + alpha * delta * E -
使用新状态和新动作值更新状态和动作:
state, action = new_state, new_action if done: break
我们现在准备好在已经通过单步 SARSA 和 Q-learning 解决的环境中测试这个新算法。
练习 7.04:使用 TD(λ) SARSA 解决 FrozenLake-v0 确定性转移问题
在这个练习中,我们将实现 SARSA(λ) 算法来解决 FrozenLake-v0 环境下的确定性环境动态。在这个练习中,我们将考虑与 练习 7.01,使用 TD(0) SARSA 解决 FrozenLake-v0 确定性转移问题 和 练习 7.03,使用 TD(0) Q-learning 解决 FrozenLake-v0 确定性转移问题 中相同的任务,但这一次,我们将不再使用像 SARSA(基于策略)和 Q-learning(非基于策略)这样的单步 TD 方法,而是实现 TD(λ),这是一种与 eligibility traces 功能结合的时序差分方法。我们将观察这个算法的行为,并训练自己实现一种新的方法,通过它来估算 Q 值表,从而恢复智能体的最优策略。
按照这些步骤完成此练习:
-
导入所需模块:
import numpy as np from numpy.random import random, choice import matplotlib.pyplot as plt %matplotlib inline import gym -
使用
is_slippery标志设置为False来实例化名为FrozenLake-v0的gym环境,以禁用随机性:env = gym.make('FrozenLake-v0', is_slippery=False) -
看一下动作和观察空间:
print("Action space = ", env.action_space) print("Observation space = ", env.observation_space)输出将如下所示:
Action space = Discrete(4) Observation space = Discrete(16) -
创建两个字典,以便轻松将
actions数字转换为动作:actionsDict = {} actionsDict[0] = " L " actionsDict[1] = " D " actionsDict[2] = " R " actionsDict[3] = " U " actionsDictInv = {} actionsDictInv["L"] = 0 actionsDictInv["D"] = 1 actionsDictInv["R"] = 2 actionsDictInv["U"] = 3 -
重置环境并渲染它,查看网格:
env.reset() env.render()输出将如下所示:
![图 7.34:环境的初始状态]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_07_34.jpg)
图 7.34:环境的初始状态
-
可视化该环境的最优策略:
optimalPolicy = ["R/D"," R "," D "," L ",\ " D "," - "," D "," - ",\ " R ","R/D"," D "," - ",\ " - "," R "," R "," ! ",] print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])输出将如下所示:
Optimal policy: R/D R D L D - D - R R/D D - - R R !这是我们在处理单步 TD 方法时已经遇到的确定性情况的最优策略。它展示了我们希望智能体在这个环境中学习的最优动作。
-
定义将采取 ε-贪婪动作的函数:
def action_epsilon_greedy(q, s, epsilon=0.05): if np.random.rand() > epsilon: return np.argmax(q[s]) return np.random.randint(4) def get_action_epsilon_greedy(epsilon): return lambda q,s: action_epsilon_greedy\ (q, s, epsilon=epsilon) -
定义一个将采取贪婪动作的函数:
def greedy_policy(q, s): return np.argmax(q[s]) -
定义一个将计算智能体平均表现的函数:
def average_performance(policy_fct, q): acc_returns = 0. n = 500 for i in range(n): done = False s = env.reset() while not done: a = policy_fct(q, s) s, reward, done, info = env.step(a) acc_returns += reward return acc_returns/n -
设置总回合数、表示我们评估智能体平均表现的间隔步数、折扣因子、学习率,以及控制其下降的
ε参数——起始值、最小值和范围(以回合数为单位)——以及适用的 eligibility trace 衰减参数:# parameters for sarsa(lambda) episodes = 30000 STEPS = 500 gamma = 0.9 alpha = 0.05 epsilon_start = 0.2 epsilon_end = 0.001 epsilon_annealing_stop = int(episodes/2) eligibility_decay = 0.3 -
初始化 Q 表,除了终止状态外,所有值都设置为
1,并设置一个数组来收集训练过程中智能体的所有表现评估:q = np.zeros((16, 4)) # Set q(terminal,*) equal to 0 q[5,:] = 0.0 q[7,:] = 0.0 q[11,:] = 0.0 q[12,:] = 0.0 q[15,:] = 0.0 performance = np.ndarray(episodes//STEPS) -
通过在所有回合中循环来启动 SARSA 训练循环:
for episode in range(episodes): -
根据当前回合的运行定义一个 epsilon 值:
inew = min(episode,epsilon_annealing_stop) epsilon = (epsilon_start * (epsilon_annealing_stop - inew) \ + epsilon_end * inew) / epsilon_annealing_stop -
将资格迹表初始化为
0:E = np.zeros((16, 4)) -
重置环境,使用ε-贪婪策略选择第一个动作,并开始回合内循环:
state = env.reset() action = action_epsilon_greedy(q, state, epsilon) while True: -
更新资格迹并为最后访问的状态分配一个
1的权重:E = eligibility_decay * gamma * E E[state, action] += 1 -
使用选定的动作在环境中执行一步,获取新的状态、奖励和结束条件:
new_state, reward, done, info = env.step(action) -
使用ε-贪婪策略选择新的动作:
new_action = action_epsilon_greedy\ (q, new_state, epsilon) -
计算
更新,并使用 SARSA TD(
)规则更新 Q 表:delta = reward + gamma \ * q[new_state, new_action] - q[state, action] q = q + alpha * delta * E -
使用新的状态和动作值更新状态和动作:
state, action = new_state, new_action if done: break -
评估智能体的平均表现:
if episode%STEPS == 0: performance[episode//STEPS] = average_performance\ (get_action_epsilon_greedy\ (epsilon), q=q) -
绘制 SARSA 智能体在训练过程中的平均奖励历史:
plt.plot(STEPS*np.arange(episodes//STEPS), performance) plt.xlabel("Epochs") plt.title("Learning progress for SARSA") plt.ylabel("Average reward of an epoch")这将生成以下输出:
Text(0, 0.5, 'Average reward of an epoch')这个过程的图表可以如下可视化:
![图 7.35:训练回合中一个 epoch 的平均奖励趋势]()
图 7.35:训练回合中一个 epoch 的平均奖励趋势
如我们所见,SARSA 的 TD(
)表现随着ε参数的退火而逐步提高,从而在极限情况下达到 0,并最终获得贪婪策略。这还表明该算法能够在学习后达到 100%的成功率。与单步 SARSA 模型相比,正如图 7.8所示,我们可以看到它更快地达到了最大性能,表现出显著的改进。 -
评估训练后的智能体(Q 表)在贪婪策略下的表现:
greedyPolicyAvgPerf = average_performance(greedy_policy, q=q) print("Greedy policy SARSA performance =", greedyPolicyAvgPerf)输出将如下所示:
Greedy policy SARSA performance = 1.0 -
显示 Q 表的值:
q = np.round(q,3) print("(A,S) Value function =", q.shape) print("First row") print(q[0:4,:]) print("Second row") print(q[4:8,:]) print("Third row") print(q[8:12,:]) print("Fourth row") print(q[12:16,:])这将生成以下输出:
(A,S) Value function = (16, 4) First row [[0.499 0.59 0.519 0.501] [0.474 0\. 0.615 0.518] [0.529 0.699 0.528 0.589] [0.608 0.397 0.519 0.517]] Second row [[0.553 0.656 0\. 0.489] [0\. 0\. 0\. 0\. ] [0\. 0.806 0\. 0.593] [0\. 0\. 0\. 0\. ]] Third row [[0.619 0\. 0.729 0.563] [0.613 0.77 0.81 0\. ] [0.712 0.9 0\. 0.678] [0\. 0\. 0\. 0\. ]] Fourth row [[0\. 0\. 0\. 0\. ] [0.003 0.8 0.9 0.683] [0.76 0.892 1\. 0.787] [0\. 0\. 0\. 0\. ]]该输出显示了我们问题的完整状态-动作价值函数的值。这些值随后用于通过贪婪选择规则生成最优策略。
-
打印出找到的贪婪策略,并与最优策略进行比较:
policyFound = [actionsDict[np.argmax(q[0,:])],\ actionsDict[np.argmax(q[1,:])],\ actionsDict[np.argmax(q[2,:])],\ actionsDict[np.argmax(q[3,:])],\ actionsDict[np.argmax(q[4,:])],\ " - ",\ actionsDict[np.argmax(q[6,:])],\ " - ",\ actionsDict[np.argmax(q[8,:])],\ actionsDict[np.argmax(q[9,:])],\ actionsDict[np.argmax(q[10,:])],\ " - ",\ " - ",\ actionsDict[np.argmax(q[13,:])],\ actionsDict[np.argmax(q[14,:])],\ " ! "] print("Greedy policy found:") idxs = [0,4,8,12] for idx in idxs: print(policyFound[idx+0], policyFound[idx+1], \ policyFound[idx+2], policyFound[idx+3]) print(" ") print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])这将产生以下输出:
Greedy policy found: R R D L D - D - R D D - - R R ! Optimal policy: R/D R D L D - D - R R/D D - - R R !
如您所见,我们的 SARSA 算法已经能够通过在确定性转移动态下学习最优策略来正确解决 FrozenLake-v0 环境。实际上,正如我们所见,对于网格世界中的每个状态,通过我们的算法计算出的 Q 表获得的贪婪策略都会给出与通过分析环境问题定义的最优策略一致的动作。正如我们之前看到的,有两个状态具有两个同等最优的动作,智能体正确地执行了其中之一。
注意
要访问该特定部分的源代码,请参阅packt.live/2YdePoa。
您也可以在packt.live/3ek4ZXa上在线运行此示例。
我们现在可以继续,测试它在暴露于随机动态下时的表现。我们将在下一个练习中进行测试。就像使用一步 SARSA 时一样,在这种情况下,我们希望给予智能体自由,以便利用中间步骤的零惩罚,减少掉入陷阱的风险,因此,在这种情况下,我们必须将折扣因子 gamma 设置为 1。 这意味着我们将使用 gamma = 1.0,而不是使用 gamma = 0.9。
练习 7.05:使用 TD(λ) SARSA 解决 FrozenLake-v0 随机过渡
在本练习中,我们将实现我们的 SARSA(λ) 算法来解决在确定性环境动态下的 FrozenLake-v0 环境。正如我们在本章前面看到的,当谈论一步 TD 方法时,最优策略与前一个练习完全不同,因为它需要考虑随机性因素。这对 SARSA(λ) 算法提出了新的挑战。我们将看到它如何仍然能够在本练习中解决这个任务。
按照以下步骤完成本练习:
-
导入所需的模块:
import numpy as np from numpy.random import random, choice import matplotlib.pyplot as plt %matplotlib inline import gym -
使用设置了
is_slippery=True标志的gym环境实例化FrozenLake-v0,以启用随机性:env = gym.make('FrozenLake-v0', is_slippery=True) -
看一下动作和观察空间:
print("Action space = ", env.action_space) print("Observation space = ", env.observation_space)这将打印出以下内容:
Action space = Discrete(4) Observation space = Discrete(16) -
创建两个字典,以便轻松地将
actions数字转换为移动:actionsDict = {} actionsDict[0] = " L " actionsDict[1] = " D " actionsDict[2] = " R " actionsDict[3] = " U " actionsDictInv = {} actionsDictInv["L"] = 0 actionsDictInv["D"] = 1 actionsDictInv["R"] = 2 actionsDictInv["U"] = 3 -
重置环境并渲染它,以查看网格问题:
env.reset() env.render()输出将如下所示:
![图 7.36:环境的初始状态]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_07_36.jpg)
图 7.36:环境的初始状态
-
可视化该环境的最优策略:
optimalPolicy = ["L/R/D"," U "," U "," U ",\ " L "," - "," L/R "," - ",\ " U "," D "," L "," - ",\ " - "," R "," D "," ! ",] print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])这将打印出以下输出:
Optimal policy: L/R/D U U U L - L/R - U D L - - R D !这代表了该环境的最优策略。除了两个状态,其他所有状态都与单一的最优行为相关联。事实上,正如本章前面描述的,最优行为是那些将智能体远离陷阱的行为,或者远离可能导致智能体掉入陷阱的格子。两个状态有多个等效的最优行为,这正是本任务的要求。
-
定义将采取 ε-贪心行为的函数:
def action_epsilon_greedy(q, s, epsilon=0.05): if np.random.rand() > epsilon: return np.argmax(q[s]) return np.random.randint(4) def get_action_epsilon_greedy(epsilon): return lambda q,s: action_epsilon_greedy\ (q, s, epsilon=epsilon) -
定义一个将采取贪心行为的函数:
def greedy_policy(q, s): return np.argmax(q[s]) -
定义一个函数,计算智能体的平均表现:
def average_performance(policy_fct, q): acc_returns = 0. n = 500 for i in range(n): done = False s = env.reset() while not done: a = policy_fct(q, s) s, reward, done, info = env.step(a) acc_returns += reward return acc_returns/n -
设置总回合数、表示我们将评估智能体平均表现的步数间隔、折扣因子、学习率以及控制
ε衰减的参数——起始值、最小值以及在一定回合数内衰减的范围,以及资格迹衰减参数:# parameters for sarsa(lambda) episodes = 80000 STEPS = 2000 gamma = 1 alpha = 0.02 epsilon_start = 0.2 epsilon_end = 0.001 epsilon_annealing_stop = int(episodes/2) eligibility_decay = 0.3 -
初始化 Q 表,将所有值设置为 1,除终止状态外,并设置一个数组来收集在训练过程中所有智能体的表现评估:
q = np.zeros((16, 4)) # Set q(terminal,*) equal to 0 q[5,:] = 0.0 q[7,:] = 0.0 q[11,:] = 0.0 q[12,:] = 0.0 q[15,:] = 0.0 performance = np.ndarray(episodes//STEPS) -
通过在所有回合中循环,开始 SARSA 训练循环:
for episode in range(episodes): -
根据当前剧集运行定义ε值:
inew = min(episode,epsilon_annealing_stop) epsilon = (epsilon_start * (epsilon_annealing_stop - inew) \ + epsilon_end * inew) / epsilon_annealing_stop -
初始化资格迹表为 0:
E = np.zeros((16, 4)) -
重置环境并根据ε-贪婪策略设置初始动作选择。然后,开始剧集内部循环:
state = env.reset() action = action_epsilon_greedy(q, state, epsilon) while True: -
通过应用衰减并使最后一个状态-动作对最重要来更新资格迹:
E = eligibility_decay * gamma * E E[state, action] += 1 -
定义环境步骤,选择动作并获取新的状态、奖励和完成条件:
new_state, reward, done, info = env.step(action) -
使用ε-贪婪策略选择新动作:
new_action = action_epsilon_greedy(q, new_state, epsilon) -
计算
更新并使用 SARSA TD(
)规则更新 Q 表:delta = reward + gamma \ * q[new_state, new_action] - q[state, action] q = q + alpha * delta * E -
使用新值更新状态和动作:
state, action = new_state, new_action if done: break -
评估代理的平均表现:
if episode%STEPS == 0: performance[episode//STEPS] = average_performance\ (get_action_epsilon_greedy\ (epsilon), q=q) -
绘制 SARSA 代理在训练期间的平均奖励历史:
plt.plot(STEPS*np.arange(episodes//STEPS), performance) plt.xlabel("Epochs") plt.title("Learning progress for SARSA") plt.ylabel("Average reward of an epoch")这会生成以下输出:
Text(0, 0.5, 'Average reward of an epoch')可以通过以下方式可视化该图:
![图 7.37:训练过程中每个时期的平均奖励趋势]()
图 7.37:训练过程中每个时期的平均奖励趋势
再次与图 7.15中看到的先前 TD(0) SARSA 情况进行比较,图形清楚地展示了即使在考虑随机动态时,算法的性能如何随着训练轮次的增加而改善。行为非常相似,也表明在随机动态的情况下,无法获得完美的表现,换句话说,无法 100%达到目标。
-
评估训练代理(Q 表)的贪婪策略表现:
greedyPolicyAvgPerf = average_performance(greedy_policy, q=q) print("Greedy policy SARSA performance =", greedyPolicyAvgPerf)这会打印出以下输出:
Greedy policy SARSA performance = 0.734 -
显示 Q 表的值:
q = np.round(q,3) print("(A,S) Value function =", q.shape) print("First row") print(q[0:4,:]) print("Second row") print(q[4:8,:]) print("Third row") print(q[8:12,:]) print("Fourth row") print(q[12:16,:])这会生成以下输出:
(A,S) Value function = (16, 4) First row [[0.795 0.781 0.79 0.786] [0.426 0.386 0.319 0.793] [0.511 0.535 0.541 0.795] [0.341 0.416 0.393 0.796]] Second row [[0.794 0.515 0.541 0.519] [0\. 0\. 0\. 0\. ] [0.321 0.211 0.469 0.125] [0\. 0\. 0\. 0\. ]] Third row [[0.5 0.514 0.595 0.788] [0.584 0.778 0.525 0.46 ] [0.703 0.54 0.462 0.365] [0\. 0\. 0\. 0\. ]] Fourth row [[0\. 0\. 0\. 0\. ] [0.563 0.557 0.862 0.508] [0.823 0.94 0.878 0.863] [0\. 0\. 0\. 0\. ]]此输出显示了我们问题的完整状态-动作值函数的值。然后,通过贪婪选择规则使用这些值来生成最优策略。
-
打印出找到的贪婪策略,并与最优策略进行比较:
policyFound = [actionsDict[np.argmax(q[0,:])],\ actionsDict[np.argmax(q[1,:])],\ actionsDict[np.argmax(q[2,:])],\ actionsDict[np.argmax(q[3,:])],\ actionsDict[np.argmax(q[4,:])],\ " - ",\ actionsDict[np.argmax(q[6,:])],\ " - ",\ actionsDict[np.argmax(q[8,:])],\ actionsDict[np.argmax(q[9,:])],\ actionsDict[np.argmax(q[10,:])],\ " - ",\ " - ",\ actionsDict[np.argmax(q[13,:])],\ actionsDict[np.argmax(q[14,:])],\ " ! "] print("Greedy policy found:") idxs = [0,4,8,12] for idx in idxs: print(policyFound[idx+0], policyFound[idx+1], \ policyFound[idx+2], policyFound[idx+3]) print(" ") print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])这会生成以下输出:
Greedy policy found: L U U U L - R - U D L - - R D ! Optimal policy: L/R/D U U U L - L/R - U D L - - R D !
同样,在随机环境动态的情况下,带资格迹的 SARSA 算法也能够正确学习到最优策略。
注意
要访问此特定部分的源代码,请参考packt.live/2CiyZVf。
您还可以在packt.live/2Np7zQ9上在线运行此示例。
通过本练习,我们完成了对时间差分方法的学习,涵盖了从最简单的一步式公式到最先进的方法。现在,我们能够在不受剧集结束后更新状态值(或状态-动作对)函数限制的情况下,结合多步方法。为了完成我们的学习旅程,我们将快速比较本章解释的方法与第五章 动态规划和第六章 蒙特卡洛方法中解释的方法。
动态规划(DP)、蒙特卡洛(Monte-Carlo)和时间差分(TD)学习之间的关系
从我们在本章中学到的内容来看,正如我们多次提到的,时间差分学习(TD)显然具有与蒙特卡罗方法和动态规划方法相似的特点。像前者一样,TD 直接从经验中学习,而不依赖于表示过渡动态的环境模型或任务中涉及的奖励函数的知识。像后者一样,TD 通过自举(bootstrapping)更新,即部分基于其他估计更新值函数,这样就避免了必须等待直到一轮结束的需求。这个特点尤为重要,因为在实际中,我们可能遇到非常长的回合(甚至是无限回合),使得蒙特卡罗方法变得不切实际且过于缓慢。这种严格的关系在强化学习理论中扮演着核心角色。
我们还学到了 N 步方法和资格迹,这两个不同但相关的主题让我们能够将 TD 方法的理论框架呈现为一个通用的图景,能够将蒙特卡罗和 TD 方法融合在一起。特别是资格迹的概念让我们能够正式地表示它们,具有额外的优势,即将视角从前向视角转变为更高效的增量反向视角,从而使我们能够将蒙特卡罗方法扩展到非周期性问题。
当把 TD 和蒙特卡罗方法纳入同一个理论框架时,资格迹(eligibility traces)展示了它们在使 TD 方法更稳健地应对非马尔科夫任务方面的价值,这是蒙特卡罗算法表现更好的典型问题。因此,资格迹即使通常伴随着计算开销的增加,通常也能提供更好的学习能力,因为它们既更快速又更稳健。
现在是时候处理本章的最后一个活动了,我们将把在理论和已覆盖的 TD 方法练习中学到的知识付诸实践。
活动 7.01:使用 TD(0) Q-Learning 解决 FrozenLake-v0 随机过渡问题
本活动的目标是让你将 TD(0) Q-learning 算法应用于解决 FrozenLake-v0 环境中的随机过渡动态。我们已经看到,最优策略如下所示:

图 7.38: 最优策略 – D = 向下移动,R = 向右移动,U = 向上移动,L = 向左移动
让 Q-learning 在这个环境中收敛并非易事,但这是可能的。为了让这个过程稍微简单一些,我们可以使用一个折扣因子 γ 值,设为 0.99。以下步骤将帮助你完成此练习:
-
导入所有必要的模块。
-
实例化健身环境并打印出观察空间和动作空间。
-
重置环境并呈现初始状态。
-
定义并打印出最优策略以供参考。
-
定义实现贪婪和 ε-贪婪策略的函数。
-
定义一个函数来评估智能体的平均表现,并初始化 Q 表。
-
定义学习方法的超参数(ε、折扣因子、总集数等)。
-
实现 Q 学习算法。
-
训练智能体并绘制平均性能随训练轮次变化的图表。
-
显示找到的 Q 值,并在将其与最优策略进行比较时,打印出贪婪策略。
本活动的最终输出与本章所有练习中遇到的非常相似。我们希望将使用指定方法训练的智能体找到的策略与最优策略进行比较,以确保我们成功地让其正确地学习到最优策略。
最优策略应如下所示:
Greedy policy found:
L U U U
L - R -
U D L -
- R D !
Optimal policy:
L/R/D U U U
L - L/R -
U D L -
- R D !
注意
本活动的解决方案可以在第 726 页找到。
完成此活动后,我们学会了如何通过适当调整超参数,正确地实现和设置单步 Q 学习算法,以解决具有随机过渡动态的环境问题。我们在训练过程中监控了智能体的表现,并面对了奖励折扣因子的作用。我们为其选择了一个值,使得我们的智能体能够学习到针对这一特定任务的最优策略,即便该环境的最大奖励是有限的,并且无法保证 100%完成任务。
总结
本章讨论了时序差分学习。我们首先研究了单步方法,包括其在策略内部和策略外部的实现,进而学习了 SARSA 和 Q 学习算法。我们在 FrozenLake-v0 问题上测试了这些算法,涉及了确定性和随机过渡动态。接着,我们进入了 N 步时序差分方法,这是 TD 和 MC 方法统一的第一步。我们看到,策略内和策略外方法在这种情况下是如何扩展的。最后,我们研究了带有资格迹的时序差分方法,它们构成了描述 TD 和 MC 算法的统一理论的最重要步骤。我们还将 SARSA 扩展到资格迹,并通过实现两个练习在 FrozenLake-v0 环境中应用这一方法,涵盖了确定性和随机过渡动态。通过这些,我们能够在所有情况下成功地学习到最优策略,从而证明这些方法是可靠且健壮的。
现在,是时候进入下一章了,在这一章中,我们将讨论多臂老丨虎丨机问题,这是一个经典的设置,在研究强化学习理论及其算法应用时常常会遇到。
第八章:8. 多臂老丨虎丨机问题
概述
本章将介绍流行的多臂老丨虎丨机问题及其一些常用的解决算法。我们将通过一个互动示例学习如何用 Python 实现这些算法,如 Epsilon 贪心算法、上置信界算法和汤普森采样。我们还将了解作为一般多臂老丨虎丨机问题扩展的上下文老丨虎丨机问题。通过本章的学习,你将深入理解一般的多臂老丨虎丨机问题,并具备应用一些常见方法来解决该问题的技能。
介绍
在上一章中,我们讨论了时间差分学习方法,这是一种流行的无模型强化学习算法,它通过信号的未来值来预测一个量。在本章中,我们将关注另一个常见话题,这不仅在强化学习中应用广泛,在人工智能和概率论中也具有重要地位——多臂老丨虎丨机(MAB)问题。
作为一个顺序决策问题,旨在通过在赌场老丨虎丨机上游戏来最大化奖励,多臂老丨虎丨机问题广泛适用于任何需要在不确定性下进行顺序学习的情况,如 A/B 测试或设计推荐系统。本章将介绍该问题的形式化方法,了解几种常见的解决算法(即 Epsilon 贪心算法、上置信界算法和汤普森采样),并最终在 Python 中实现它们。
总体来说,本章将让你深入理解多臂老丨虎丨机问题在不同的顺序决策场景中的应用,并为你提供将这一知识应用于解决一种变体问题——排队老丨虎丨机问题的机会。
首先,让我们从讨论问题的背景和理论表述开始。
多臂老丨虎丨机问题的表述
最简单形式的多臂老丨虎丨机(MAB)问题由多个老丨虎丨机(赌场赌博机)组成,每次玩家玩一个老丨虎丨机时(具体来说,当它的臂被拉动时),老丨虎丨机会随机地给玩家一个奖励。玩家希望在固定轮次结束时最大化自己的总奖励,但他们不知道每个老丨虎丨机的概率分布或平均奖励。因此,这个问题的核心就是设计一个学习策略,在这个策略中,玩家需要探索每个老丨虎丨机可能返回的奖励值,并从中快速识别出最有可能返回最大期望奖励的那个老丨虎丨机。
在本节中,我们将简要探讨问题的背景,并建立本章中将使用的符号和术语。
多臂老丨虎丨机问题的应用
我们之前提到的老丨虎丨机只是我们设定的一个简化版。在一般情况下的 MAB 问题中,我们在每一步面临一组可选择的多个决策,并且我们需要充分探索每个决策,以便更加了解我们所处的环境,同时确保尽早收敛到最优决策,以使得最终的总奖励最大化。这就是我们在常见的强化学习问题中面临的经典探索与利用的权衡。
MAB 问题的常见应用包括推荐系统、临床试验、网络路由,以及如我们将在本章末尾看到的排队理论。这些应用都包含了定义 MAB 问题的典型特征:在每一个顺序决策过程中,决策者需要从预定的可选项中进行选择,并且根据过去的观察,决策者需要在探索不同选择和利用认为最有利的选择之间找到平衡。
举个例子,推荐系统的目标之一是展示客户最可能考虑或购买的产品。当一个新客户登录像购物网站或在线流媒体服务这样的系统时,推荐系统可以观察客户的过去行为和选择,并根据这些信息决定应向客户展示什么样的产品广告。它这样做是为了最大化客户点击广告的概率。
另一个例子,稍后我们将更详细地讨论,是在一个由多个客户类别组成的排队系统中,每个类别都具有一个未知的服务速率。排队协调员需要弄清楚如何最好地安排这些客户,以优化某个目标,例如整个队列的累计等待时间。
总体而言,MAB 问题是人工智能领域,尤其是强化学习中的一个日益普遍的问题,它有许多有趣的应用。在接下来的章节中,我们将正式化该问题并介绍本章将使用的术语。
背景与术语
MAB 问题的特征如下:
-
一组可以选择的“K”个动作。每个动作称为“臂”,这是根据传统的老丨虎丨机术语来命名的。
-
一位中央决策者需要在每一步从这组动作中做出选择。我们将选择动作的行为称为“拉臂”,而决策者则称为“玩家”。
-
当拉动其中一个“K”个可用臂时,玩家会从该臂特定的概率分布中随机抽取一个随机奖励。重要的是,奖励是从各自的分布中随机选择的;如果奖励是固定的,玩家就能快速识别出能够提供最高回报的臂,这样问题就不再有趣。
-
玩家目标仍然是,在每个步骤中从“K”个臂中选择一个,以便在最后最大化奖励。过程中的步骤数称为视野,玩家可能知道也可能不知道。
-
在大多数情况下,每个臂可以被拉动无限次。当玩家确定某个特定臂是最优的时,他们可以继续选择该臂进行后续操作而不偏离。然而,在不同的环境下,某个臂被拉动的次数是有限的,从而增加了问题的复杂性。
下图展示了我们所使用环境中的一个迭代步骤,其中有四个臂,其成功率分别估计为 70%、30%、55%和 40%。

图 8.1:典型的 MAB 迭代
在每一步中,我们需要决定应该选择哪个臂来拉动:
-
与奖励的语言和相应的最大化目标相对,一个 MAB 问题也可以从成本最小化目标的角度来框定。排队的例子可以再次使用:整个队列的累计等待时间是一个负值,换句话说,这就是需要最小化的成本。
-
通常将一个策略的表现与最优策略或天才策略进行比较,天才策略事先知道哪个臂是最优的,并在每一步都拉动那个臂。当然,任何现实中的学习策略都不太可能模拟天才策略的表现,但它为我们提供了一个固定的度量标准来与我们的策略进行比较。给定策略与天才策略的表现差异称为遗憾,目标是最小化这个遗憾。
MAB 问题的核心问题是如何以最小的探索(拉动次优臂)识别出具有最大期望奖励(或最小期望成本)的臂。这是因为玩家的探索越多,选择最优臂的频率就越低,最终的奖励也会随之减少。然而,如果玩家没有充分探索所有的臂,他们可能会错误地识别最优臂,最终导致总奖励受到负面影响。
当真实最优臂的随机奖励在前几次实验中看起来低于其他臂的奖励(由于随机性)时,可能会导致玩家错误地识别最优臂。根据每个臂的实际奖励分布,这种情况发生的可能性较高。
所以,这就是我们在本章中要解决的总体问题。我们现在需要简要考虑多臂赌博机背景下的奖励概率分布的概念,以便充分理解我们正在尝试解决的问题。
多臂赌博机奖励分布
在传统的多臂赌博机问题中,每个臂的奖励都与伯努利分布相关联。每个伯努利分布又由一个非负数p来参数化,p的最大值为 1。当从伯努利分布中抽取一个数时,它可以取两个可能的值:1,其概率为p,以及 0,其概率为1 - p。因此,p的较高值对应玩家应当拉动的较好臂。这是因为玩家更有可能收到 1 作为奖励。当然,p的高值并不保证从特定臂获得的奖励始终为 1,事实上,即使是从p值最高的臂(也就是最优臂)拉取,某些奖励也可能为 0。
以下图是伯努利赌博机设置的一个示例:

图 8.2:样本伯努利多臂赌博机问题
每个臂都有自己独特的奖励分布:第一个臂返回 1 的概率为 75%,返回 0 的概率为 25%;第二个臂返回 1 的概率为 25%,返回 0 的概率为 75%,依此类推。请注意,我们经验上观察到的比例并不总是与真实比例匹配。
从这里,我们可以将多臂赌博机问题推广到奖励遵循任何概率分布的情况。虽然这些分布的内部机制不同,但多臂赌博机算法的目标保持不变:识别与期望值最高的分布相关联的臂,以最大化最终的累计奖励。
在本章中,我们将使用伯努利分布的奖励,因为它们是最自然和直观的奖励分布之一,并且为我们提供了一个可以研究各种多臂赌博机算法的背景。最后,在我们考虑本章将涉及的不同算法之前,先花点时间熟悉我们将要使用的编程接口。
Python 接口
帮助我们讨论多臂赌博机算法的 Python 环境包含在本章代码库的utils.py文件中,代码库地址为:https://packt.live/3cWiZ8j。
我们可以从这个文件中将Bandit类导入到一个独立的脚本或 Jupyter 脚本中。这个类是我们用来创建、互动和解决各种 MAB 问题的接口。如果我们正在使用的代码与该文件位于同一目录下,我们可以通过以下代码简单导入Bandit类:
from utils import Bandit
然后,我们可以将 MAB 问题声明为Bandit对象的实例:
my_bandit = Bandit()
由于我们没有向此声明传递任何参数,因此此Bandit实例采用其默认值:一个具有 0.7 和 0.3 概率的两个伯努利臂的 MAB 问题(尽管我们的算法在技术上并不知道这一点)。
我们需要注意的Bandit类中最核心的方法是pull()。该方法接受一个整数作为参数,表示我们希望在给定步骤拉取的臂的索引,并返回一个数字,表示从与该臂相关的分布中抽取的随机奖励。
例如,在以下代码片段中,我们调用pull()方法,并传递0参数来拉取第一个臂并记录返回的奖励,代码如下:
reward = my_bandit.pull(0)
reward
在这里,您可能会看到数字0或数字1被打印出来,这表示通过拉取臂 0 获得的奖励。假设我们想要拉取臂 1 一次,可以使用相同的 API:
reward = my_bandit.pull(1)
reward
同样,由于我们从伯努利分布中抽取,输出可能是0或1。
假设我们想要检查每个臂的奖励分布是什么样的,或者更具体地说,想知道这两个臂中哪个更有可能返回更多的奖励。为此,我们从每个臂拉取 10 次并记录每一步返回的奖励:
running_rewards = [[], []]
for _ in range(10):
running_rewards[0].append(my_bandit.pull(0))
running_rewards[1].append(my_bandit.pull(1))
running_rewards
这段代码会产生以下输出:
[[1, 1, 1, 0, 0, 1, 1, 1, 0, 0], [0, 0, 1, 0, 0, 1, 0, 1, 1, 1]]
由于随机性的原因,您可能会得到不同的输出。根据前述的输出,我们可以看到,臂 0 在 10 次拉取中返回了 6 次正奖励,而臂 1 返回了 5 次正奖励。
我们还希望绘制每个臂在 20 步过程中累计奖励的变化图。在这里,我们可以使用 NumPy 库中的np.cumsum()函数来计算这一量,并通过 Matplotlib 库进行绘制,代码如下:
rounds = [i for i in range(1, 11)]
plt.plot(rounds, np.cumsum(running_rewards[0]),\
label='Cumulative reward from arm 0')
plt.plot(rounds, np.cumsum(running_rewards[1]), \
label='Cumulative reward from arm 1')
plt.legend()
plt.show()
接着会生成如下图表:

图 8.3:累计奖励的示例图
该图表允许我们直观地检查从每个臂上获得的累计奖励在 10 次拉取过程中增长的速度。我们还可以看到,臂 0 的累计奖励始终大于臂 1 的累计奖励,这表明在这两个臂中,臂 0 是最优的。这与臂 0 被初始化为具有p = 0.7的伯努利奖励分布的事实一致,而臂 1 的奖励分布则是p = 0.3。
pull()方法是更底层的 API,用于在每一步中促进处理。然而,在设计各种 MAB 算法时,我们将允许这些算法自动与赌博机问题进行交互,而无需人工干预。这就引出了Bandit类的第二个方法,我们将用它来测试我们的算法:automate()。
正如我们将在下一节中看到的,这个方法接收一个算法对象的实现,并简化了我们的测试过程。具体来说,这个方法将调用算法对象,记录其决策,并以自动化的方式返回相应的奖励。除了算法对象外,它还接收其他两个优化参数:n_rounds,用于指定我们与赌博机互动的次数,以及visualize_regret,这是一个布尔标志,指示我们是否希望绘制所考虑算法与精灵算法之间的后悔值。
这个整个过程被称为实验,其中一个没有任何先验知识的算法会被测试用于解决多臂赌博机(MAB)问题。为了全面分析给定算法的性能,我们需要通过多个实验来验证该算法,并研究它在所有实验中的总体表现。这是因为 MAB 问题的特定初始化可能会使某个算法优于其他算法;通过在多个实验中比较不同算法的表现,我们对哪种算法更优的最终见解将更加稳健。
这时,Bandit类的repeat()方法派上了用场。该方法接收一个算法类的实现(与对象实现相对),并重复调用之前描述的automate()方法来操作算法类的实例。通过这样做,可以对我们考虑的算法进行多次实验,并且能给我们提供该算法表现的更全面视角。
为了与Bandit类的方法进行交互,我们将把 MAB 算法实现为 Python 类。pull()方法,因此包括automate()和repeat()方法,要求这些算法类实现有两个独立的方法:decide(),该方法应该返回算法认为应该在任意时刻拉动的臂的索引;以及update(),该方法接收一个臂的索引和刚从该臂获得的新奖励。在本章后续编写算法时,我们将牢记这两个方法。
关于 bandit API 的最后说明,由于随机性,在你自己的实现中,完全可能得到与本章中显示的结果不同的结果。为了更好的可复现性,我们已经将本章所有脚本的随机种子固定为 0,这样你就可以通过从本书的 GitHub 仓库获取任何 Jupyter Notebook,并使用以下截图中显示的选项运行代码,从而获得相同的结果:

图 8.4:使用 Jupyter Notebooks 重现结果
话虽如此,即使有随机性,我们也会看到一些算法在解决 MAB 问题时比其他算法表现得更好。这也是我们将通过许多重复实验分析算法性能的原因,确保任何性能上的优势在面对随机性时是稳健的。
这就是我们理解 MAB 问题所需的所有背景信息。现在,我们准备开始讨论解决该问题时常用的方法,首先从贪婪算法开始。
贪婪算法
回想一下我们在上一节中与Bandit实例的简短互动,我们拉取了第一个臂 10 次,第二个臂 10 次。这可能不是最大化累积奖励的最佳策略,因为我们在花费 10 轮拉取一个次优臂时,无论它是哪一个,都不是最优选择。因此,幼稚的做法是简单地将每个臂(或所有臂)拉一次,然后贪婪地选择返回正奖励的臂。
这个策略的一般化形式是贪婪算法,在该算法中,我们会保持一个奖励均值列表,包含所有可用臂的奖励均值,并在每一步选择拉取具有最高均值的臂。虽然直觉上很简单,但它遵循了一个概率推理:在经过大量样本后,经验均值(样本的平均值)是实际分布期望的一个良好近似。如果一个臂的奖励均值比其他任何臂都大,那么该臂实际上是最优臂的概率应该不低。
实现贪婪算法
现在,让我们尝试实现这个算法。如前一节所述,我们将把 MAB 算法写成 Python 类,以与本书提供的 bandit API 进行交互。在这里,我们要求该算法类具有两个属性:可拉取的臂的数量和算法从每个臂观察到的奖励列表:
class Greedy:
def __init__(self, n_arms=2):
self.n_arms = n_arms
self.reward_history = [[] for _ in range(n_arms)]
在这里,reward_history 是一个包含多个子列表的列表,每个子列表包含给定臂返回的历史奖励。这个属性中存储的数据将用于驱动我们的 MAB 算法的决策。
回想一下,算法类实现需要两个特定的方法来与老丨虎丨机 API 交互,分别是decide()和update(),后者较为简单,已在此实现:
class Greedy:
...
def update(self, arm_id, reward):
self.reward_history[arm_id].append(reward)
再次强调,update()方法需要接收两个参数:一个臂的索引(对应arm_id变量)和一个数字,表示通过拉动该臂所获得的最新奖励(reward变量)。在此方法中,我们只需要将此信息通过将数字附加到reward_history属性中对应的奖励子列表来存储。
对于decide()方法,我们需要实现之前描述的贪心算法逻辑:计算所有臂的奖励平均值,并返回平均值最高的臂。然而,在此之前,我们需要处理前几轮,算法尚未从任何臂中观察到奖励的情况。这里的惯例是强制算法至少拉动每个臂一次,这通过代码开头的条件来实现:
def decide(self):
for arm_id in range(self.n_arms):
if len(self.reward_history[arm_id]) == 0:
return arm_id
mean_rewards = [np.mean(history) for history in self.reward_history]
return int(np.random.choice\
(np.argwhere(mean_rewards == np.max(mean_rewards))\
.flatten()))
如你所见,我们首先检查是否有任何奖励子列表的长度为 0,这意味着算法未曾拉动过该臂。如果是这种情况,我们直接返回该臂的索引。
否则,我们使用mean_rewards变量来计算奖励的平均值:np.mean()方法计算存储在reward_history属性中的每个子列表的均值,我们通过列表推导式遍历它们。
最后,我们找到奖励平均值最高的臂索引,这是通过np.max(mean_rewards)计算的。关于我们在此实现的算法有一个微妙的要点:np.random.choice()函数:在某些情况下,多个臂可能有相同的最高奖励平均值,这时算法应随机选择其中的一个臂,而不会偏向任何一个。这里的期望是,如果选择了一个次优臂,未来的奖励将表明该臂确实不太可能获得正奖励,而我们最终仍然会收敛到最优臂。
就是这样。如前所述,贪心算法相当简单且符合直觉。现在,我们希望通过与我们的老丨虎丨机 API 互动来查看算法的实际效果。首先,我们需要创建一个新的 MAB 问题实例:
N_ARMS = 3
bandit = Bandit(optimal_arm_id=0,\
n_arms=3,\
reward_dists=[np.random.binomial \
for _ in range(N_ARMS)],\
reward_dists_params=[(1, 0.9), (1, 0.8), (1, 0.7)])
在这里,我们的 MAB 问题有三个臂,它们的奖励都遵循伯努利分布(由 NumPy 中的np.random.binomial随机函数实现)。第一个臂的奖励概率为p = 0.9,第二个臂为p = 0.8,第三个臂为p = 0.7;因此,第一个臂是最优臂,我们的算法需要识别出来。
(顺便提一下,要从伯努利分布中抽取参数为p的值,我们使用np.random.binomial(1, p),所以这就是为什么我们在前面的代码片段中将每个p的值与数字1配对的原因。)
现在,我们声明一个适当数量臂的贪心算法实例,并调用 bandit 问题的automate()方法,让算法与 bandit 进行 500 轮交互,具体如下:
greedy_policy = Greedy(n_arms=N_ARMS)
history, rewards, optimal_rewards = bandit.automate\
(greedy_policy, n_rounds=500,\
visualize_regret=True)
如我们所见,automate()方法返回一个包含三个对象的元组:history,即算法在整个过程中选择的臂的顺序列表;rewards,是通过拉动这些臂获得的对应奖励;以及optimal_rewards,是如果在每一步都选择最优臂时我们将获得的奖励列表(换句话说,这是精灵算法的奖励列表)。该元组通过以下图表可视化,这是前面代码的实际输出。
在automate()方法中,我们还有一个选项来可视化rewards和optimal_rewards这两个列表之间的累计和差异,这由visualize_regret参数指定。实质上,这个选项将绘制出我们算法的累计遗憾与轮次号之间的关系图。由于我们在调用中启用了此选项,将生成以下图表:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_05.jpg)
图 8.5:由 automate()绘制的样本累计遗憾
虽然我们没有其他算法进行比较,但从这张图中可以看到,我们的贪心算法表现得相当好,因为它能够在整个 500 轮中始终保持累计遗憾不超过 2。另一个评估我们算法表现的方法是查看history列表,该列表包含了算法选择拉动的臂:
print(*history)
这将以以下格式打印出列表:
0 1 2 0 1 2 2 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
如我们所见,在最初的三轮探索之后,当算法每个臂拉动一次时,它在臂之间稍微摇摆了一下,但很快就收敛到选择臂 0,即实际的最优臂,来进行剩余的所有轮次。这就是为什么算法最终的累计遗憾如此之低的原因。
话虽如此,这仅仅是一次实验。如前所述,为了充分评估我们算法的性能,我们需要多次重复这个实验,确保我们考虑的单个实验不是由于随机性导致算法表现特别好或特别差的异常情况。
为了便于反复实验,我们利用了 bandit API 的repeat()方法,具体如下:
regrets = bandit.repeat(Greedy, [N_ARMS], n_experiments=100, \
n_rounds=300, visualize_regret_dist=True)
记住,repeat()方法接受的是给定算法的类实现,而不是像automate()那样仅接受算法的实例。这就是为什么我们将整个Greedy类传递给该方法的原因。此外,通过该方法的第二个参数,我们可以指定算法类实现所需的任何参数。在这个例子中,它仅仅是可拉动的臂的数量,但在后续章节中,我们会使用不同算法时有不同的参数。
在这里,我们通过n_experiments参数对 Greedy 算法进行 100 次实验,每次实验使用我们之前声明的三个伯努利臂的相同赌博机问题。为了节省时间,我们只要求每个实验持续 300 轮,使用n_rounds参数。最后,我们将visualize_regret_dist设置为True,这将帮助我们绘制每次实验结束时算法的累积遗憾分布图。
确实,当这段代码运行完成时,以下图形将被生成:

图 8.6:Greedy 算法的累积遗憾分布
在这里,我们可以看到,在大多数情况下,Greedy 算法表现得足够好,保持累积遗憾低于10。然而,也有一些情况下,累积遗憾高达60。我们推测这些是算法错误估计每个臂的真实期望奖励并过早做出决策的情况。
作为衡量算法表现的最终方式,我们考虑这些实验的平均累积遗憾和最大累积遗憾,具体如下:
np.mean(regrets), np.max(regrets)
在我们当前的实验中,以下数字将被打印出来:
(8.66, 62)
这与我们这里看到的分布一致:大多数遗憾都足够低,使得平均值相对较低(8.66),但最大遗憾可能高达62。
这就是我们关于 Greedy 算法讨论的结束。接下来的部分,我们将讨论两种流行的算法变种,即探索后再决策(Explore-then-commit)和ε-Greedy 算法。
探索后再决策算法
我们提到过,Greedy 算法在某些情况下表现不佳的潜在原因是过早做出决策,而没有足够观察到每个臂的样本奖励。探索后再决策算法试图通过明确规定在过程开始时应花费多少轮来探索每个臂,从而解决这个问题。
具体而言,每个探索后决策(Explore-then-commit)算法由一个数字* T * 参数化。在每个多臂赌博机问题中,探索后决策算法将花费正好T轮来拉取每个可用的臂。只有在这些强制探索轮之后,算法才会开始选择奖励平均值最大的臂。贪婪算法是探索后决策算法的特例,其中T被设定为 1。因此,这个通用算法使我们能够根据情况定制此参数并进行适当设置。
这个算法的实现与贪婪算法非常相似,所以我们在这里不再讨论。简而言之,在确保贪婪算法每个臂至少拉取一次的条件下,我们可以在其decide()方法中修改条件,如下所示,前提是已经设置了T变量的值:
def decide(self):
for arm_id in range(self.n_arms):
if len(self.reward_history[arm_id]) < T:
return arm_id
mean_rewards = [np.mean(history) \
for history in self.reward_history]
return int(np.random.choice\
(np.argwhere(mean_rewards == np.max(mean_rewards))\
.flatten()))
尽管探索后决策算法是贪婪算法的更灵活版本,但它仍然留出了如何选择T值的问题。实际上,如果没有关于问题的先验知识,如何为特定的多臂赌博机问题设置T并不明显。通常,T会根据已知的时间范围进行设置;T的常见值可能是 3、5、10,甚至 20。
ε-贪婪算法
贪婪算法的另一个变种是ε-贪婪算法。对于探索后决策,强制探索的次数取决于可设置的参数T,这再次引出了如何最好地设置它的问题。对于ε-贪婪算法,我们不明确要求算法在每个臂上探索超过一轮。相反,我们将探索何时发生以及何时继续利用最优臂的决策交给随机性来决定。
正式地说,ε-贪婪算法由一个数字ε(介于 0 和 1 之间)参数化,表示算法的探索概率。在第一次探索轮之后,算法将以 1 - ε的概率选择拉取奖励平均值最大的臂。否则,它将以ε的概率均匀地选择一个可用的臂。与探索后决策不同,在后者中我们可以确定算法在前几轮会被强制探索,ε-贪婪算法可能在后续轮次中也会探索奖励平均值不佳的臂。然而,当探索发生时,这完全是由随机性决定的,参数ε的选择控制了这些探索轮次发生的频率。
例如,ε的常见选择是 0.01。在典型的强盗问题中,ε-贪婪算法将在过程开始时每个臂都拉一次,然后开始选择具有最佳奖励历史的臂。然而,在每一步中,以 0.01(1%)的概率,算法可能会选择进行探索,在这种情况下,它将随机选择一个臂而不带任何偏见。ε就像Explore-then-commit算法中的T一样,用于控制 MAB 算法应该进行多少探索。较高的ε值会导致算法更频繁地进行探索,尽管同样地,当它进行探索时,这完全是随机的。
ε-贪婪算法的直觉很明确:我们仍然希望保留贪婪算法的贪婪特性,但为了避免由于不具代表性的奖励样本而错误地选择一个次优臂,我们还希望在整个过程中不时进行探索。希望ε-贪婪能够一举两得,在贪婪地利用暂时表现较好的臂的同时,也留给其他看似次优的臂更好的机会。
从实现角度来看,算法的decide()方法应该增加一个条件判断,检查算法是否应该进行探索:
def decide(self):
...
if np.random.rand() < self.e:
return np.random.randint(0, self.n_arms)
...
那么,现在我们继续并完成本章的第一个练习,我们将在其中实现ε-贪婪算法。
练习 8.01 实现ε-贪婪算法
类似于实现贪婪算法时的做法,在本练习中,我们将学习如何实现ε-贪婪算法。这个练习将分为三个主要部分:实现ε-贪婪的逻辑,测试其在示例强盗问题中的表现,最后通过多次实验来评估其性能。
我们将按照以下步骤来实现:
-
创建一个新的 Jupyter Notebook,并导入 NumPy、Matplotlib 以及本章代码库中
utils.py文件中的Bandit类:import numpy as np np.random.seed(0) import matplotlib.pyplot as plt from utils import Bandit请注意,我们现在将 NumPy 的随机种子数固定,以确保代码的可重复性。
-
现在,开始实现ε-贪婪算法的逻辑。首先,它的初始化方法应该接受两个参数:要解决的强盗问题的臂数和ε,探索概率:
class eGreedy: def __init__(self, n_arms=2, e=0.01): self.n_arms = n_arms self.e = e self.reward_history = [[] for _ in range(n_arms)]与贪婪算法类似,在这里,我们也在跟踪奖励历史,这些历史记录存储在类对象的
reward_history属性中。 -
在同一个代码单元格中,实现
eGreedy类的decide()方法。该方法应该与
Greedy类中的对应方法大致相似。然而,在计算各臂的奖励平均值之前,它应该生成一个介于 0 和 1 之间的随机数,并检查它是否小于其参数ε。如果是这种情况,它应该随机返回一个臂的索引:def decide(self): for arm_id in range(self.n_arms): if len(self.reward_history[arm_id]) == 0: return arm_id if np.random.rand() < self.e: return np.random.randint(0, self.n_arms) mean_rewards = [np.mean(history) \ for history in self.reward_history] return int(np.random.choice(np.argwhere\ (mean_rewards == np.max(mean_rewards))\ .flatten())) -
在同一代码单元格中,为
eGreedy类实现update()方法,该方法应与Greedy类中的相应方法相同:def update(self, arm_id, reward): self.reward_history[arm_id].append(reward)再次说明,这种方法只需要将最近一次的奖励添加到该臂的奖励历史中。
这就是我们ε-Greedy 算法的完整实现。
-
在下一个代码单元格中,创建一个具有三个伯努利臂的赌博机问题实验,这些臂的相应概率分别为
0.9、0.8和0.7,并使用eGreedy类的实例(ε = 0.01,即默认值,不需要显式指定)通过automate()方法运行该实验。确保指定
visualize_regret=True参数,以便绘制算法在整个过程中累积后悔的图表:N_ARMS = 3 bandit = Bandit(optimal_arm_id=0, \ n_arms=3,\ reward_dists=[np.random.binomial \ for _ in range(N_ARMS)],\ reward_dists_params=[(1, 0.9), \ (1, 0.8), \ (1, 0.7)]) egreedy_policy = eGreedy(n_arms=N_ARMS) history, rewards, optimal_rewards = bandit.automate\ (egreedy_policy, \ n_rounds=500, \ visualize_regret=True)这应该会产生以下图表:
![图 8.7:ε-Greedy 算法的样本累积后悔]()
图 8.7:ε-Greedy 算法的样本累积后悔
与我们在 Greedy 算法中看到的相应图表相比,这里的累积后悔变化更大,有时会增长到
4,有时会降到-2。这正是算法探索增加的效果。 -
在下一个代码单元格中,我们打印出
history变量,看看它与 Greedy 算法的历史相比如何:print(*history)这将产生以下输出:
0 1 2 1 2 1 0 0 1 2 1 0 0 2 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0在这里,我们可以看到,在前几轮之后,算法做出的选择大多数都是臂 0。但偶尔也会选择臂 1 或臂 2,这大概是由于随机探索概率的原因。
-
在下一个代码单元格中,我们将进行相同的实验,不过这次我们将设置
ε = 0.1:egreedy_policy_v2 = eGreedy(n_arms=N_ARMS, e=0.1) history, rewards, optimal_rewards = bandit.automate\ (egreedy_policy_v2, \ n_rounds=500, \ visualize_regret=True)这将产生以下图表:
![图 8.8:增加探索概率后的样本累积后悔]()
图 8.8:增加探索概率后的样本累积后悔
在这里,我们的累积后悔比在步骤 5 中设置
ε = 0.01时要高得多。这大概是因为增加的探索概率过高所导致的。 -
为了进一步分析这个实验,我们可以再次打印出动作历史:
print(*history)这将产生以下输出:
0 1 2 2 0 1 0 1 2 2 0 2 2 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 1 2 0 1 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 0 0 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 2 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 2 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0将此与之前算法相同历史的数据进行比较,可以看到该算法确实在后期的轮次中进行了更多的探索。所有这些都表明,
ε = 0.1可能不是一个合适的探索概率。 -
作为我们对ε-Greedy 算法分析的最后一个部分,让我们利用重复实验选项。这次,我们将选择
ε = 0.03,如下所示:regrets = bandit.repeat(eGreedy, [N_ARMS, 0.03], \ n_experiments=100, n_rounds=300,\ visualize_regret_dist=True)接下来的图表将展示来自这些重复实验的累积后悔分布:
![图 8.9:ε-Greedy 算法的累积后悔分布]()
图 8.9:ε-Greedy 算法的累积后悔分布
这个分布与我们在贪婪算法中得到的结果非常相似。接下来,我们将进一步比较这两种算法。
-
使用以下代码计算这些累积遗憾值的均值和最大值:
np.mean(regrets), np.max(regrets)输出结果如下:
(9.95, 64)
将这一点与我们在贪婪算法中得到的结果(8.66 和 62)进行比较,结果表明在这个特定的强盗问题中,ε-贪婪算法可能会表现得较差。然而,它通过探索率成功地形式化了探索与利用之间的选择,而这是贪婪算法所缺乏的。这是一个多臂强盗(MAB)算法的宝贵特性,也是我们将在本章后面讨论的其他算法的重点。
注意
要访问这一特定部分的源代码,请参考 packt.live/3fiE3Y5。
你也可以在网上运行这个例子,链接是 packt.live/3cYT4fY。
在进入下一部分之前,让我们简要讨论一下另一种所谓的贪婪算法变种——Softmax 算法。
Softmax 算法
Softmax 算法试图通过选择每个可用臂的概率来量化探索与利用之间的权衡,这个概率与其平均奖励成正比。形式上,算法在每个时间步骤t选择臂i的概率如下:

图 8.10:表示算法在每个时间步骤选择该臂的概率的公式
指数中的每一项
是从臂i在前(t - 1)个时间步骤中观察到的平均奖励。根据定义概率的方式,平均奖励越大,相应的臂被选择的可能性就越大。在其最一般的形式下,这个平均项被一个可调参数
除以,后者控制算法的探索率。具体来说,当
趋向于无穷大时,最大臂的选择概率会趋近于 1,而其他臂的选择概率趋近于 0,使得算法完全贪婪(这也是我们认为它是贪婪算法的一种推广的原因)。当
趋近于 0 时,选择一个暂时次优的臂的可能性增大。当它趋向于 0 时,算法会无限期地均匀探索所有可用的臂。
类似于我们在设计ε-贪婪算法时遇到的问题,如何为每个特定的强盗问题设置该参数
的值并不完全明确,尽管算法的性能在很大程度上依赖于该参数。因此,Softmax 算法不像我们将在本章讨论的其他算法那样流行。
就这样,我们结束了对贪心算法的讨论,这是我们解决 MAB 问题的第一种方法,以及它的三种变体:先探索后承诺、ε-贪心和 Softmax。总体来说,这些算法专注于利用具有最大奖励均值的臂,同时有时偏离这个臂去探索其他看似次优的臂。
在接下来的章节中,我们将介绍另一种常见的 MAB 算法——上置信界限(UCB),其直觉与我们到目前为止所看到的稍有不同。
UCB 算法
上置信界限这个术语表示,算法不是像贪心算法那样考虑每个臂返回的过去奖励的平均值,而是计算每个臂预期奖励的估计值的上界。
置信界限这个概念在概率论和统计学中是非常常见的,其中我们关心的量(在这个例子中是每个臂的奖励)的分布不能仅通过过去观测值的平均值来良好表示。相反,置信界限是一个数值范围,旨在估计并缩小在该分布中大多数值所在的范围。例如,这一概念在贝叶斯分析和贝叶斯优化中被广泛应用。
在接下来的章节中,我们将讨论 UCB 如何建立其使用置信界限的方法。
不确定性面前的乐观主义
考虑一个只有两个臂的赌博机过程的中间部分。我们已经拉动过第一个臂 100 次,并观察到平均奖励为0.61;对于第二个臂,我们仅见过五个样本,其中三个样本的奖励为1,所以它的平均奖励是0.6。我们是否应该承诺在剩余的回合中探索第一个臂并忽视第二个臂?
很多人会说不;我们至少应该更多地探索第二个臂,以便更好地估计其期望奖励。这一观察的动机是,由于我们仅有很少的第二个臂的奖励样本,我们不应该确信第二个臂的平均奖励实际上低于第一个臂。那么,我们应该如何将我们的直觉形式化呢?UCB 算法,或者说其最常见的变种——UCB1 算法——指出,我们将不再使用平均奖励,而是使用以下平均奖励和置信界限之和:

图 8.11:UCB 算法的表达式
在这里,
表示当前我们与赌博机互动时的时间步长,或回合数,
表示我们已经拉动过的臂数,直到回合
。UCB 的其余部分与贪心算法的运作方式相同:在每一步中,我们选择拉动能够最大化前述总和的臂,观察返回的奖励,将其加到我们的奖励中,然后重复这个过程。
要实现这一逻辑,我们可以使用decide()方法,方法中包含如下代码:
def decide(self):
for arm_id in range(self.n_arms):
if len(self.reward_history[arm_id]) == 0:
return arm_id
conf_bounds = [np.mean(history) \
+ np.sqrt(2 * np.log(self.t) / len(history))\
for history in self.reward_history]
return int(np.random.choice\
(np.argwhere(conf_bounds == np.max(conf_bounds))\
.flatten()))
在这里,self.t应当等于当前的步骤时间。正如我们所见,该方法返回的是使conf_bounds中元素最大化的臂,这个列表存储了每只臂的乐观估算值。
你可能会想,为什么使用前述的量能捕捉我们想要应用于期望奖励估算的置信区间的概念。请记住我们之前所举的两臂赌博机的例子,我们希望有一个形式化的过程,能够鼓励探索那些很少被探索的臂(在我们的例子中是第二只臂)。正如你所见,在任何给定回合,这个量是
的递减函数。换句话说,当
很大时,这个量会变小;而当情况相反时,它会变大。因此,这个量由那些拉动次数较少的臂最大化——也就是那些探索较少的臂。在我们的例子中,第一只臂的估算如下:

图 8.12:第一只臂的估算
第二只臂的估算如下:

图 8.13:第二只臂的估算
使用 UCB 算法时,我们选择拉取第二只臂,这也是我们认为正确的选择。通过在平均奖励中加入所谓的探索项,我们从某种意义上来说,是在估算期望均值的最大可能值,而不仅仅是期望均值本身。这一直觉可以用“在不确定性面前保持乐观”这一术语来总结,它是 UCB 算法的核心特征。
UCB 的其他特性
UCB 并非毫无根据地乐观。当一只臂显著未被充分探索时,探索项会使得该量变大,从而增加被 UCB 选择的可能性,但并不能保证这只臂一定会被选择。具体来说,当某只臂的平均奖励非常低,以至于探索项无法弥补时,UCB 依然会选择利用那些表现良好的臂。
我们还应当讨论它在以成本为中心的 MAB 问题中的变化,这就是下置信区间(LCB)。对于奖励为中心的问题,我们将探索项加入到平均奖励中,以计算对真实均值的乐观估算。当 MAB 问题是最小化臂返回的成本时,我们的乐观估算变成了平均成本减去探索项,UCB 将选择最小化这一量的臂,或者在这种情况下,选择 LCB。
具体来说,我们在这里说的是,如果某个臂的探索次数较少,它的真实平均成本可能比我们目前观察到的要低,因此我们从探索项中减去平均成本,以估算某个臂的最低可能成本。除此之外,这种 UCB 变体的实现保持不变。
这就是关于 UCB 的全部理论内容。为了总结我们对该算法的讨论,我们将在下一个练习中实现它,以解决我们之前使用的伯努利三臂赌博机问题。
练习 8.02 实现 UCB 算法
在本次练习中,我们将实现 UCB 算法。本练习将引导我们通过熟悉的工作流程来分析 MAB 算法的表现:将其实现为一个 Python 类,进行一次实验并观察其行为,最后多次重复实验,考虑由此产生的后悔分布。
我们将按照以下步骤进行:
-
创建一个新的 Jupyter Notebook,导入
NumPy、Matplotlib,以及从代码库中包含的utils.py文件中的Bandit类:import numpy as np np.random.seed(0) import matplotlib.pyplot as plt from utils import Bandit -
声明一个名为
UCB的 Python 类,并定义以下初始化方法:class UCB: def __init__(self, n_arms=2): self.n_arms = n_arms self.reward_history = [[] for _ in range(n_arms)] self.t = 0与 Greedy 及其变体不同,我们对
UCB的实现需要在其属性t中跟踪一个额外的信息——当前轮次号。这个信息在计算上置信界限的探索项时使用。 -
实现类的
decide()方法,如下所示:def decide(self): for arm_id in range(self.n_arms): if len(self.reward_history[arm_id]) == 0: return arm_id conf_bounds = [np.mean(history) \ + np.sqrt(2 * np.log(self.t) \ / len(history))\ for history in self.reward_history] return int(np.random.choice\ (np.argwhere\ (conf_bounds == np.max(conf_bounds))\ .flatten()))上述代码不言自明:在每个臂至少拉一次之后,我们计算置信界限,作为经验均值奖励和探索项的总和。最后,我们返回具有最大总和的臂,必要时随机打破平局。
-
在同一个代码单元中,像这样实现类的
update()方法:def update(self, arm_id, reward): self.reward_history[arm_id].append(reward) self.t += 1我们已经对大部分逻辑比较熟悉,来自之前的算法。注意,在每次调用
update()时,我们还需要递增属性t。 -
声明我们一直在考虑的伯努利三臂赌博机问题,并在我们刚刚实现的 UCB 算法实例上运行它:
N_ARMS = 3 bandit = Bandit(optimal_arm_id=0,\ n_arms=3,\ reward_dists=[np.random.binomial \ for _ in range(N_ARMS)],\ reward_dists_params=[(1, 0.9), (1, 0.8), \ (1, 0.7)]) ucb_policy = UCB(n_arms=N_ARMS) history, rewards, optimal_rewards = bandit.automate\ (ucb_policy, n_rounds=500, \ visualize_regret=True)这段代码将生成以下图表:
![图 8.14:UCB 算法的样本累计后悔]()
图 8.14:UCB 算法的样本累计后悔
在这里,我们可以看到,这个累计后悔显著比我们在 Greedy 算法中看到的要糟糕,后者最多为 2。我们假设这种差异直接源自算法的乐观性质。
-
为了更好地理解这种行为,我们将检查算法的拉臂历史:
print(*history)这将产生以下输出:
0 1 2 1 0 2 0 1 1 0 1 0 2 0 2 0 0 1 0 1 0 1 0 1 2 0 1 0 1 0 0 1 0 1 0 1 0 0 0 2 2 1 1 0 1 0 1 0 1 0 1 1 1 2 2 2 2 0 2 0 2 0 1 1 1 1 1 0 0 0 0 0 2 2 0 0 1 0 1 0 0 0 0 0 1 0 2 2 2 0 0 0 0 0 0 0 0 1 0 1 0 1 1 0 1 0 1 0 1 0 0 0 0 2 2 2 0 0 0 0 0 0 0 0 0 1 1 0 1 0 0 0 0 0 0 2 2 2 2 2 0 1 0 1 1 0 1 0 0 0 0 0 0 2 2 2 2 0 0 1 0 1 1 0 1 0 0 0 0 0 0 0 0 0 1 0 1 1 1 0 2 1 1 0 1 0 1 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 0 0 2 2 2 0 0 0 1 1 1 0 0 0 0 0 0 2 2 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 2 2 2 2 2 2 2 2 0 0 0 0 0 0 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 2 2 2 2 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 2 2 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 1 1 1 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 2 2 2 2 2 2 2 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 2 2 2 2 2 0 0 1 1 0 0 0 0在这里,我们可以观察到,UCB 经常选择偏离真正的最优臂(臂 0)。这是由于它倾向于乐观地探索看似次优的臂的直接影响。
-
表面上,我们可能会得出结论:对于这个赌博机问题,UCB 算法实际上并不优于贪婪算法。但要真正确认这一点,我们需要检查该算法在多个实验中的表现。使用来自赌博机 API 的
repeat()方法来确认这一点:regrets = bandit.repeat(UCB, [N_ARMS], n_experiments=100, \ n_rounds=300, visualize_regret_dist=True)这段代码将生成以下图表:
![图 8.15:UCB 算法的遗憾分布]()
图 8.15:UCB 算法的遗憾分布
令我们惊讶的是,这个分布中的遗憾值明显低于由贪婪算法得出的结果。
-
除了可视化分布外,我们还需要考虑所有实验的平均遗憾和最大遗憾:
np.mean(regrets), np.max(regrets)输出结果如下:
(18.78, 29)
如你所见,数值明显低于我们在贪婪算法中看到的对应统计数据,后者为8.66和62。在这里,我们可以说有证据支持“UCB 算法在最小化赌博机问题的累计遗憾方面优于贪婪算法”这一说法。
注意
要访问此特定部分的源代码,请参考packt.live/3fhxSmX。
你还可以在网上运行这个示例,地址是:packt.live/2XXuJmK。
这个示例也说明了在分析 MAB 算法性能时重复实验的重要性。正如我们之前所见,仅使用一次实验,我们可能得出错误的结论,认为 UCB 算法在我们考虑的特定赌博机问题中劣于贪婪算法。然而,通过多次重复实验,我们可以看到事实恰恰相反。
在整个过程中,我们实现了 UCB 算法,并学习了在使用多臂老丨虎丨机算法(MAB)时进行全面分析的必要性。这也标志着 UCB 算法话题的结束。在接下来的章节中,我们将开始讨论本章的最后一种 MAB 算法:汤普森采样。
汤普森采样
到目前为止,我们所看到的算法组成了一套多元化的见解:贪婪算法及其变体主要关注利用,可能需要明确地强制执行探索;而 UCB 则倾向于对尚未充分探索的臂的真实期望回报持乐观态度,因此自然地,但也正当合理地,专注于探索。
汤普森采样也采用了完全不同的直觉。然而,在我们理解算法背后的思想之前,需要讨论其主要构建模块之一:贝叶斯概率的概念。
贝叶斯概率简介
一般来说,使用贝叶斯概率描述某个量的工作流程包括以下元素:
-
一个先验概率,表示我们对某个量的先验知识或信念。
-
一个似然概率,表示正如术语的名称所示,当前为止我们所观察到的数据的可能性。
-
最后,后验概率是前面两个元素的组合。
贝叶斯概率的一个基本组成部分是贝叶斯定理:

图 8.16:贝叶斯定理
在这里,P(X)表示给定事件X的概率,而P(X | Y)是给定事件Y已经发生的情况下,事件X发生的概率。后者是条件概率的一个例子,条件概率是机器学习中常见的对象,尤其是当不同事件/量彼此条件依赖时。
这个公式具体阐明了我们这里的贝叶斯概率的基本思想:假设我们给定了一个事件A的先验概率,并且我们也知道在事件A发生的情况下,事件B发生的概率。这里,给定事件B发生后,事件A的后验概率与上述两种概率的乘积成正比。事件 A 通常是我们关心的事件,而事件 B 则是我们已经观察到的数据。为了更好理解,让我们将这个公式应用于伯努利分布的背景下。
我们想估计未知参数p,该参数描述了一个伯努利分布,从中我们已经观察到了五个样本。由于伯努利分布的定义,这五个样本的和等于x,即一个介于 0 到 5 之间的整数的概率为
(如果你不熟悉这个表达式也不用担心)。
但是,如果样本是我们能观察到的,而我们不确定p的实际值是什么,该怎么办呢?我们如何“翻转”前面提到的概率量,以便从样本中得出关于p值的结论?这时贝叶斯定理就派上用场了。在伯努利例子中,给定任何p值的观察样本的似然性,我们可以计算p确实是该值的概率,前提是我们有了观察数据。
这与 MAB 问题直接相关。当然,我们总是从不知道实际值p开始,它是给定臂的奖励分布的参数,但我们可以通过拉动该臂来观察从中获得的奖励样本。因此,从若干样本中,我们可以计算p等于 0.5 的概率,并判断该概率是否大于p等于 0.6 的概率。
仍然有一个问题是如何选择p的先验分布。在我们的例子中,当我们没有关于p的任何先验信息时,我们可以说p在 0 到 1 之间是等概率的。因此,我们使用一个 0 到 1 之间的均匀分布来对p进行建模。Beta 分布是均匀分布的一种广义形式,其参数为α = 1 和β = 1,因此暂时假设p服从 Beta(1, 1)分布。
贝叶斯定理允许我们在看到一些观察后,更新这个 Beta 分布,得到一个具有不同参数的 Beta 分布。以我们正在进行的示例为例,假设在对这个伯努利分布进行五次独立观察后,我们得到三次 1 和两次 0。根据贝叶斯更新规则(具体的数学内容超出了本书的范围),一个具有α和β参数的 Beta 分布将更新为α + 3 和β + 2。
注意
一般来说,在n次观察中,其中x次是1,其余的是0,一个Beta(α, β)分布将更新为Beta(α + x, β + n - x)。粗略来说,在一次更新中,α应该根据观察到的样本数递增,而β应该根据零样本的数量递增。
从这个新的更新分布中,它反映了我们可以观察到的数据,新的p估计值,即分布的均值,可以计算为α / (α + β)。我们说过,通常我们从一个均匀分布,或者 Beta(1, 1),来建模p;在这种情况下,p的期望值是 1 / (1 + 1) = 0.5。当我们看到越来越多来自伯努利分布的样本,且真实的p值被确认后,我们将更新我们使用的 Beta 分布,从而更好地建模p,使其反映出根据这些样本,目前最可能的p值。
让我们考虑一个可视化的例子来把这一切联系起来。考虑一个伯努利分布,其中p = 0.9,我们假设这个值对我们是未知的。我们同样只能从这个分布中抽样,并且希望使用前面描述的贝叶斯更新规则来建模我们对p的信念。假设在每个时刻中,我们从这个分布中抽取一个样本,总共进行 1,000 次时刻。我们的观察结果如下:
-
在第 0 时刻,我们还没有任何观察。
-
在第 5 时刻,我们的所有观察都是 1,且没有零观察。
-
在第 10 时刻,我们有 9 个正向观察和 1 个零观察。
-
在第 20 时刻,我们有 18 个正向观察和 2 个零观察。
-
在第 100 时刻,我们有 91 个正向观察和 9 个零观察。
-
在第 1,000 时刻,我们有 892 个正向观察和 108 个零观察。
首先,我们可以看到正向观察的比例大致等于未知的真实值p = 0.9。此外,我们对p的值没有任何先验知识,因此我们选择使用 Beta(1, 1)来建模它。这对应于下图左上面板中的水平概率密度函数。
对于其余的面板,我们使用贝叶斯更新规则来计算一个新的 Beta 分布,以便更好地拟合我们观察到的数据。蓝色的线表示p的概率密度函数,显示了根据我们拥有的观察数据,p等于某个介于 0 和 1 之间特定值的可能性。
在第 5 时间步,我们的所有观察值都是 1,因此我们的信念会更新,反映出p接近 1 的概率非常大。这通过图表右侧概率质量的增加得以体现。到第 10 时间步时,出现了一个零值观察,因此p恰好为 1 的概率下降,将更多的概率质量分配给接近但小于 1 的值。在后续的时间步中,曲线越来越紧密,表明模型对p可能取的值越来越有信心。最终,在第 1000 时间步时,函数在 0.9 附近达到峰值,并且没有其他地方的峰值,表明它非常有信心p 大约为 0.9:

图 8.17:贝叶斯更新过程的视觉说明
在我们的示例中,Beta 分布用于对伯努利分布中的未知参数进行建模;使用 Beta 分布是非常重要的,因为当应用贝叶斯定理时,Beta 分布的先验概率与伯努利分布的似然概率结合起来,显著简化了数学计算,使后验分布成为一个不同的 Beta 分布,并更新了参数。如果使用 Beta 以外的其他分布,公式将不会以这种方式简化。因此,Beta 分布被称为伯努利分布的共轭先验。在贝叶斯概率中,当我们希望对给定分布的未知参数进行建模时,应使用该分布的共轭先验,这样数学推导才会顺利进行。
如果这个过程仍然让你感到困惑,不用担心,因为贝叶斯更新和共轭先验背后的大部分理论已经为常见概率分布得到了很好的推导。对于我们的目的,我们只需要记住我们刚才讨论过的伯努利/Beta 分布的更新规则。
注意
对于感兴趣的读者,欢迎查阅以下来自麻省理工学院的材料,进一步介绍各种概率分布的共轭先验:ocw.mit.edu/courses/mathematics/18-05-introduction-to-probability-and-statistics-spring-2014/readings/MIT18_05S14_Reading15a.pdf。
到目前为止,我们已经学会了如何在给定可观察数据的情况下,以贝叶斯方式对伯努利分布中的未知参数p进行建模。在下一节中,我们将最终将这个话题与我们最初的讨论点——汤普森采样算法——连接起来。
汤普森采样算法
考虑我们刚刚在 Bernoulli 奖励分布的 MAB 问题背景下学习的贝叶斯技术来建模p。我们现在有了一种方法,可以通过概率的方式量化我们对p值的信念,前提是我们已经观察到了来自相应臂的奖励样本。从这里,我们可以再次简单地采用贪婪策略,选择具有最大期望值的臂,即再次计算为α / (α + β),其中α和β是当前 Beta 分布的运行参数,用于建模p。
相反,为了实现 Thompson 采样,我们从每个 Beta 分布中抽取一个样本,这些 Beta 分布建模了每个 Bernoulli 分布的p参数,然后选择最大的样本。换句话说,带宽问题中的每个臂都有一个 Bernoulli 奖励分布,其参数p由某个 Beta 分布建模。我们从这些 Beta 分布中抽样,并选择样本值最高的臂。
假设在我们用来实现 MAB 算法的类对象语法中,我们将用于 Beta 分布的α和β的运行值存储在temp_beliefs属性中,以建模每个臂的p参数。Thompson 采样的逻辑可以如下应用:
def decide(self):
for arm_id in range(self.n_arms):
if len(self.reward_history[arm_id]) == 0:
return arm_id
draws = [np.random.beta(alpha, beta, size=1)\
for alpha, beta in self.temp_beliefs]
return int(np.random.choice\
(np.argwhere(draws == np.max(draws)).flatten()))
与贪婪算法或 UCB 不同,为了估计每个臂的真实值p,我们从相应的 Beta 分布中随机抽取一个样本,该分布的参数通过贝叶斯更新规则在整个过程中不断更新(如draws变量所示)。为了选择一个臂进行拉动,我们只需识别出具有最佳样本的臂。
有两个直接的问题:首先,为什么这个采样过程是估计每个臂奖励期望的好方法;其次,这个技术是如何解决探索与开发之间的权衡问题的?
当我们从每个 Beta 分布中抽样时,p值越可能等于某个给定值,那么这个值作为我们的样本被选中的可能性就越大——这就是概率分布的本质。所以,从某种意义上讲,分布的样本是对该分布所建模的量的近似。这就是为什么从 Beta 分布中抽取的样本可以合理地用作每个 Bernoulli 分布中p真实值的估计。
话虽如此,当当前表示我们对给定 Bernoulli 参数p的信念的分布是平坦的,且没有尖峰时(与前面可视化的最后一面图不同),这表明我们对p的具体值仍然有很多不确定性,这就是为什么在这种分布中,许多数值被赋予比单一尖峰分布更多的概率质量。当分布相对平坦时,从中抽取的样本很可能会分散在分布的范围内,而不是集中在某个单一区域,这再次表明我们对真实值的认识存在不确定性。所有这些都意味着,尽管样本可以作为给定量的近似值,但这些近似的准确性取决于建模分布的平坦程度(因此,最终取决于我们对真实值的信念有多确定)。
这一事实直接帮助我们解决了探索-开发困境。当从具有单一尖峰的分布中抽取p的样本时,它们更可能非常接近对应p的真实值,因此选择样本值最高的臂相当于选择具有最高p(或期望奖励)的臂。当分布仍然平坦时,从中抽取的样本值可能会波动,因此可能会取较大的值。如果因这个原因选择了某个臂,这意味着我们对该臂的p值还不够确定,因此值得进行探索。
Thompson 采样通过从建模分布中抽样,提供了一种平衡开发与探索的优雅方法:如果我们对每个臂的信念非常确定,选择最佳样本可能就等同于选择实际的最优臂;如果我们对某个臂的信念还不够确定,以至于其对应样本没有最佳值,进行探索将是有益的。
正如我们将在接下来的练习中看到的,Thompson 采样的实际实现非常直接,我们在实现中不需要包含我们之前讨论的太多理论贝叶斯概率。
练习 8.03:实现 Thompson 采样算法
在本练习中,我们将实现 Thompson 采样算法。像往常一样,我们将以 Python 类的形式实现该算法,并随后将其应用于 Bernoulli 三臂赌博机问题。具体来说,我们将按以下步骤进行操作:
-
创建一个新的 Jupyter Notebook,导入
NumPy、Matplotlib以及来自代码库中utils.py文件的Bandit类:import numpy as np np.random.seed(0) import matplotlib.pyplot as plt from utils import Bandit -
声明一个名为
BernoulliThompsonSampling的 Python 类(表示该类将实现 Bernoulli/Beta 分布的贝叶斯更新规则),并使用以下初始化方法:class BernoulliThompsonSampling: def __init__(self, n_arms=2): self.n_arms = n_arms self.reward_history = [[] for _ in range(n_arms)] self.temp_beliefs = [(1, 1) for _ in range(n_arms)]请记住,在汤普森采样中,我们通过 Beta 分布维持每个伯努利臂的p的运行信念,这两个参数会根据更新规则更新。因此,我们只需要追踪这些参数的运行值;
temp_beliefs属性包含了每个臂的这些信息,默认值为(1, 1)。 -
实现
decide()方法,使用 NumPy 中的np.random.beta函数从 Beta 分布中抽取样本,如下所示:def decide(self): for arm_id in range(self.n_arms): if len(self.reward_history[arm_id]) == 0: return arm_id draws = [np.random.beta(alpha, beta, size=1)\ for alpha, beta in self.temp_beliefs] return int(np.random.choice\ (np.argwhere(draws == np.max(draws)).flatten()))在这里,我们可以看到,我们并不是计算均值奖励或其上置信边界,而是从每个由
temp_beliefs属性存储的 Beta 分布中抽取样本。最后,我们选择与最大样本对应的臂。
-
在同一代码单元中,为类实现
update()方法。除了将最新的奖励附加到相应臂的历史记录中,我们还需要实现更新规则的逻辑:def update(self, arm_id, reward): self.reward_history[arm_id].append(int(reward)) # Update parameters according to Bayes rule alpha, beta = self.temp_beliefs[arm_id] alpha += reward beta += 1 - reward self.temp_beliefs[arm_id] = alpha, beta请记住,第一个参数α应该随着我们观察到的每个样本而递增,而β应该在样本为零时递增。前面的代码实现了这一逻辑。
-
接下来,设置熟悉的伯努利三臂赌博机问题,并将汤普森采样类的实例应用于该问题,以绘制单次实验中的累积遗憾:
N_ARMS = 3 bandit = Bandit(optimal_arm_id=0,\ n_arms=3,\ reward_dists=[np.random.binomial \ for _ in range(N_ARMS)],\ reward_dists_params=[(1, 0.9), (1, 0.8), \ (1, 0.7)]) ths_policy = BernoulliThompsonSampling(n_arms=N_ARMS) history, rewards, optimal_rewards = bandit.automate\ (ths_policy, n_rounds=500, \ visualize_regret=True)将生成以下图形:
![图 8.18:汤普森采样的样本累积遗憾]()
图 8.18:汤普森采样的样本累积遗憾
这个遗憾图比我们用 UCB 得到的要好,但比用贪心算法得到的要差。该图将在下一步中与拉取历史一起使用,用于进一步分析。我们来进一步分析拉取历史。
-
打印出拉取历史:
print(*history)输出将如下所示:
0 1 2 0 0 2 0 0 0 1 0 2 2 0 0 0 2 0 2 2 0 0 0 2 2 0 0 0 0 0 0 0 0 0 0 0 2 2 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 2 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 1 0 1 0 1 0 0 2 1 1 0 2 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 1 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 1 0 0 0 0 0 0 0 2 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 2 0 2 0 0 0 0 0 0 0 2 2 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 2 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0如你所见,算法能够识别出最佳臂,但偶尔会偏向臂 1 和臂 2。然而,随着时间的推移,探索的频率减少,这表明算法对其信念越来越确定(换句话说,每次运行的 Beta 分布都集中在一个峰值周围)。这是汤普森采样的典型行为。
-
正如我们所学,考虑单个实验可能不足以分析算法的表现。为了对汤普森采样的表现进行更全面的分析,我们来设置常规的重复实验:
regrets = bandit.repeat(BernoulliThompsonSampling, [N_ARMS], \ n_experiments=100, n_rounds=300,\ visualize_regret_dist=True)这将生成以下的遗憾分布:
![图 8.19:汤普森采样的累积遗憾分布]()
图 8.19:汤普森采样的累积遗憾分布
在这里,我们可以看到,汤普森采样能够将所有实验中的累积遗憾大幅度地最小化,相比其他算法(分布中的最大值仅为
10)。 -
为了量化这一说法,我们来打印出这些实验中的平均和最大后悔值:
np.mean(regrets), np.max(regrets)输出将如下所示:
(4.03, 10)
这显著优于其他算法的对比统计:Greedy 算法的值为8.66和62,而 UCB 的值为18.78和29。
注意
要访问此特定部分的源代码,请参考packt.live/2UCbZXw。
你也可以在 packt.live/37oQrTz 上在线运行这个示例。
Thompson Sampling 也是本书中将要讨论的最后一个常见的 MAB 算法。在本章的下一节也是最后一节中,我们将简要讨论经典 MAB 问题的一个常见变种——即上下文 bandit 问题。
上下文 bandit
在经典的 bandit 问题中,拉取某个 arm 所获得的奖励完全依赖于该 arm 的奖励分布,我们的目标是尽早识别出最优 arm,并在整个过程中一直拉取它。而上下文 bandit 问题则在此基础上增加了一个额外的元素:环境或上下文。类似于在强化学习中的定义,环境包含关于问题设置的所有信息、在任何给定时间的世界状态,以及可能与我们玩家在同一环境中参与的其他代理。
定义 bandit 问题的上下文
在传统的 MAB 问题中,我们只关心每次拉取某个 arm 时,它会返回什么潜在奖励。而在上下文 bandit 中,我们会提供有关我们所操作环境的上下文信息,并且根据设置的不同,某个 arm 的奖励分布可能会有所变化。换句话说,我们在每一步所做的拉取决策应该依赖于环境的状态,或者说是上下文。
这个设置使得我们正在使用的模型变得复杂,因为现在我们需要考虑我们感兴趣的量作为条件概率:给定我们看到的上下文,假设 arm 0 是最优 arm 的概率是多少?在上下文 bandit 问题中,上下文可能在我们算法的决策过程中扮演次要角色,也可能是驱动算法决策的主要因素。
一个现实世界的例子是推荐系统。我们在本章开头提到,推荐系统是 MAB 问题的常见应用,在每个刚刚访问网站的用户面前,系统需要决定哪种广告/推荐能够最大化用户对此感兴趣的概率。每个用户都有自己的偏好和喜好,而这些因素可能在帮助系统决定用户是否会对某个广告感兴趣时发挥重要作用。
例如,狗主人点击狗玩具广告的概率将明显高于普通用户,且可能点击猫粮广告的概率较低。这些关于用户的信息是 MAB 问题的一部分,MAB 是我们当前所考虑的推荐系统。其他因素可能包括他们的个人资料、购买/浏览历史等等。
总的来说,在上下文强盗问题中,我们需要考虑每个决策臂/决策的奖励期望,并在此过程中始终考虑当前的上下文。现在,让我们开始讨论即将解决的上下文强盗问题;这也是本书中多次提到的一个问题:排队强盗。
排队强盗
我们的强盗问题包含以下几个要素:
-
我们从一个客户队列开始,每个客户都属于预定的某个客户类别。例如,假设我们的队列总共有 300 个客户。在这些客户中,100 个客户属于类别 0,另外 100 个属于类别 1,其余 100 个属于类别 2。
-
我们还需要一个单一的服务器来按照特定顺序为所有这些客户提供服务。每次只能为一个客户提供服务,当一个客户正在接受服务时,队列中的其他客户必须等待,直到轮到他们为止。一旦一个客户被服务完毕,他们就会完全离开队列。
-
每个客户都有一个特定的工作时长,即服务器开始并结束客户服务所需的时间。属于类别 i(i 为 0、1 或 2)的客户的工作时长是从参数为λi 的指数分布中随机抽取的样本,称为速率参数。参数越大,从该分布中抽取到的小样本的概率就越大。换句话说,样本的期望值与速率参数成反比。(实际上,指数分布的均值是 1 除以其速率参数。)
注意
如果你有兴趣了解更多关于指数分布的信息,可以在这里找到更多内容:
mathworld.wolfram.com/ExponentialDistribution.html。就我们来说,我们只需要知道,指数分布随机变量的期望值与其速率参数成反比。 -
当一个顾客正在被服务时,队列中剩余的所有顾客将贡献到我们在整个过程结束时所产生的总累计等待时间。作为队列协调员,我们的目标是提出一种方法来安排这些顾客的顺序,以便在整个过程结束时最小化所有顾客的总累计等待时间。已知,最小化总累计等待时间的最佳顺序是“最短作业优先”,也就是说,在任何给定时间,剩余顾客中应该选择作业时间最短的顾客。
有了这个,我们可以看到队列问题与经典的多臂赌博(MAB)问题之间的相似性。如果我们不知道表征某个类别顾客作业时间分布的真实率参数,我们需要通过观察每个类别中几个样本顾客的作业时间来估计这个参数。一旦我们能够尽早识别并集中处理具有最高率参数的顾客,我们的总累计等待时间将尽可能低。在这里,拉取一个“臂”相当于选择一个给定类别的顾客来接下来服务,而我们需要最小化的负奖励(或成本)是整个队列的累计等待时间。
作为一个上下文赌博问题,队列问题在每个步骤中也包含一些额外的上下文,需要在决策过程中加以考虑。例如,我们提到,在每次实验中,我们从一个有限数量的顾客队列开始(每个三种不同类别的顾客各 100 个),一旦一个顾客被处理完毕,他们将永远离开队列。这意味着我们的赌博问题的三个“臂”每个都必须被拉取 100 次,而算法需要找到一种方法来优化这些拉取的顺序。
在下一节中,我们将讨论为您提供的队列赌博问题的 API。
使用队列 API
为了通过 API 定义该问题,请确保从本章的代码库中下载以下两个文件:utils.py,它包含我们一直使用的传统赌博问题和队列赌博问题的 API,以及data.csv,它包含将用于队列实验的输入数据。
现在,与我们一直在使用的 API 不同,我们需要执行以下操作来与队列赌博进行交互。首先,需要从utils.py文件中导入QueueBandit类。该类的实例声明如下:
queue_bandit = QueueBandit(filename='../data.csv')
filename 参数接受你代码和 data.csv 文件的相对位置,因此可能会根据你自己笔记本的位置发生变化。与 Bandit 类不同,由于 data.csv 文件包含来自多个实验的数据,这些实验使用不同的随机选择参数,因此我们不需要自己声明这些具体细节。事实上,我们之前提到的内容适用于我们将使用的所有实验:在每个实验中,我们都有一个由 300 个客户组成的队列,这些客户属于三个不同的客户类别,并具有不同的未知速率参数。
这个 API 还提供了 repeat() 方法,使我们能够使用某个算法与排队问题进行交互,该方法同样接受该算法的类实现和任何可能的参数作为其两个主要参数。该方法会通过许多不同的初始队列运行输入算法(这些队列再次是通过不同速率参数生成的三类客户队列),并返回每个队列的累计等待时间。该方法还有一个名为 visualize_cumulative_times 的参数,如果设置为 True,将会以直方图形式可视化累计等待时间的分布。
调用此方法应该如下所示:
cumulative_times = queue_bandit.repeat\
([ALG NAME], [ANY ALG ARGUMENTS], \
visualize_cumulative_times=True)
最后,我们需要牢记的最后一个区别是对算法实现的要求。算法的类实现应该具有一个 update() 方法,该方法的作用与我们已经熟悉的相同(它应该接受一个臂的索引(或一个客户类别的索引)和最相关的最新成本(或工作长度),并更新该算法所跟踪的任何适当信息)。
更重要的是,decide() 方法现在应该接受一个参数,该参数指示在任何给定时间队列中每个类别剩余的客户数量,存储在一个包含三个元素的 Python 列表中。记住,我们总是从一个包含每个类别 100 个客户的队列开始,所以开始时的列表将是 [100, 100, 100]。随着客户被我们的算法选择并服务,这个客户数量的列表将相应更新。这是我们的算法在做出决策时需要牢记的上下文;显然,如果队列中没有剩余的类别 1 客户,算法就不能选择下一个服务类别 1 的客户。最后,decide() 方法应该返回应该选择服务的类别的索引,类似于传统的多臂老丨虎丨机问题。
这就是我们需要了解的排队盗贼问题。在标志着本章内容结束的同时,本节也为我们即将进行的活动做好了准备:实现各种算法来解决排队盗贼问题。
活动 8.01:排队盗贼问题
如前所述,客户作业长度的真实速率参数未知的排队问题可以被框架化为一个 MAB 问题。在这个活动中,我们将重新实现本章中学习到的算法,并在排队问题的背景下比较它们的表现。因此,本活动将加深我们对本章所讨论概念的理解,并使我们有机会解决一个上下文强盗问题。
通过这些步骤,让我们开始活动:
-
创建一个新的 Jupyter Notebook,在其第一个代码单元格中,导入
NumPy和utils.py中的 QueueBandit 类。务必将 NumPy 的随机种子设置为0。 -
使用前文代码声明该类的实例。
-
在新的代码单元格中,实现该排队问题的贪心算法,并使用前文提供的代码将其应用于强盗对象。除了显示累计等待时间分布的直方图外,还需要打印出其中的平均值和最大值。
再次,贪心算法应该选择在每次队列迭代中具有较低平均作业长度的客户类别。
输出结果如下:
![图 8.20:贪心算法的累计等待时间分布]()
(1218887.7924350922, 45155.236786598274) -
在新的代码单元格中,实现该问题的 Explore-then-commit 算法。该算法的类实现应接受一个名为
T的参数,指定在实验开始时算法应进行多少次探索轮次。 -
类似于贪心算法,将
T=2的 Explore-then-commit 算法应用于强盗对象。比较该算法产生的累计等待时间分布以及其结果中的平均值和最大值与贪心算法的结果。这将生成以下图表:
![图 8.21:Explore-then-commit 的累计等待时间分布]()
(1238591.3208636027, 45909.77140562623) -
在新的代码单元格中,实现该问题的 Thompson Sampling 算法。
要对指数分布的未知速率参数进行建模,应使用伽马分布作为共轭先验。伽马分布也由两个参数α和β来参数化;它们关于样本作业长度 x 的更新规则是 α = α + 1 和 β = β + x。一开始,两个参数都应初始化为
0。要从伽马分布中抽取样本,可以使用
np.random.gamma()函数,该函数接受α和 1 / β。与我们的贪心算法和探索-再承诺算法的逻辑类似,应该在每次迭代中选择采样率最高的类别。 -
将算法应用于强盗对象,并通过累计等待时间分析其性能。与贪心算法和探索-再承诺算法进行比较。
将生成以下图形:
![图 8.22:Thompson Sampling 的累计等待时间分布]()
(1218887.7924350922, 45129.343871806814) -
在上下文老丨虎丨机问题中,通常会开发专门的算法。这些算法是常见 MAB 算法的变种,专门设计用于利用上下文信息。
在一个新的代码单元中,实现汤普森采样的一个利用型变种,其逻辑在每个实验开始时类似于汤普森采样,并且在至少一半客户被服务后,完全依靠贪心策略(像贪婪算法那样),选择具有最低平均作业时长的类别。
-
将该算法应用于老丨虎丨机对象,并将其性能与传统汤普森采样以及我们已实现的其他算法进行比较。
绘图结果如下:
![图 8.23:修改版汤普森采样的累计等待时间分布]()
图 8.23:修改版汤普森采样的累计等待时间分布
最大和平均累计等待时间如下:
(1218887.7924350922, 45093.244027644556)
注
本活动的解决方案可以在第 734 页找到。
总结
在本章中,介绍了多臂老丨虎丨机(MAB)问题及其作为强化学习和人工智能问题的动机。我们探讨了许多常用于解决 MAB 问题的算法,包括贪婪算法及其变种、UCB 和汤普森采样。通过这些算法,我们获得了如何平衡探索与利用(这是强化学习中最基本的组成部分之一)的独特见解和启发式方法,例如随机探索、不确定性下的乐观估计,或者从贝叶斯后验分布中采样。
这些知识得到了实践应用,我们学习了如何从零开始在 Python 中实现这些算法。在此过程中,我们还探讨了在多次重复实验中分析 MAB 算法的重要性,以获得稳健的结果。这个过程是任何涉及随机性的分析框架的核心。最后,在本章的活动中,我们将所学知识应用于排队老丨虎丨机问题,并学习了如何修改 MAB 算法,以使其适应给定的上下文老丨虎丨机。
本章也标志着马尔可夫决策问题这一主题的结束,该主题涵盖了过去四章的内容。从下一章开始,我们将开始探索作为强化学习框架的深度 Q 学习这一激动人心的领域。
第九章:9. 什么是深度 Q 学习?
概述
在本章中,我们将详细学习深度 Q 学习以及所有可能的变种。你将学习如何实现 Q 函数,并结合深度学习使用 Q 学习算法来解决复杂的强化学习(RL)问题。在本章结束时,你将能够描述并实现 PyTorch 中的深度 Q 学习算法,我们还将实践实现一些深度 Q 学习的高级变种,例如使用 PyTorch 实现的双重深度 Q 学习。
介绍
在上一章中,我们学习了多臂赌博机(MAB)问题——这是一种常见的序列决策问题,目的是在赌场的老丨虎丨机上最大化奖励。在本章中,我们将结合深度学习技术与一种流行的强化学习(RL)技术,叫做 Q 学习。简而言之,Q 学习是一种强化学习算法,决定代理采取的最佳行动,以获得最大奖励。在 Q 学习中,“Q”表示用于获得未来奖励的行动的质量。在许多 RL 环境中,我们可能没有状态转移动态(即从一个状态转移到另一个状态的概率),或者收集状态转移动态太复杂。在这些复杂的 RL 环境中,我们可以使用 Q 学习方法来实现 RL。
在本章中,我们将从理解深度学习的基础知识开始,了解什么是感知器、梯度下降以及构建深度学习模型需要遵循的步骤。接下来,我们将学习 PyTorch 以及如何使用 PyTorch 构建深度学习模型。了解了 Q 学习后,我们将学习并实现一个深度 Q 网络(DQN),并借助 PyTorch 实现。然后,我们将通过经验重放和目标网络来提高 DQN 的性能。最后,你将实现 DQN 的另一种变体——双重深度 Q 网络(DDQN)。
深度学习基础
我们已经在第三章《TensorFlow 2 实战深度学习》中实现了深度学习算法。在我们开始本章重点的深度 Q 学习之前,有必要快速回顾一下深度学习的基础知识。
在我们深入研究神经网络之前,首先了解一下什么是感知器。下图表示的是一个通用的感知器:

图 9.1:感知器
感知器是一种二元线性分类器,输入首先与权重相乘,然后我们将所有这些乘积的值加权求和。接着,我们将这个加权和通过激活函数或阶跃函数。激活函数用于将输入值转换为特定的输出值,例如(0,1),用于二元分类。这个过程可以在前面的图中可视化。
深度前馈网络,通常我们也称之为多层感知机(MLPs),在多个层次上有多个感知机,如图 9.2所示。MLP 的目标是逼近任何函数。例如,对于一个分类器,这个函数
,将输入映射,
,通过学习参数的值将其归类为 y(对于二分类问题,可能是 0 或 1)
描述自动生成](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_01c.png)或者权重。下图展示了一个通用的深度神经网络:

图 9.2:深度神经网络
MLP(多层感知机)的基本构建块由人工神经元(也称为节点)组成。它们自动学习最佳的权重系数,并将其与输入的特征相乘,从而决定神经元是否激活。网络由多个层组成,其中第一层称为输入层,最后一层称为输出层。中间的层被称为隐藏层。隐藏层的数量可以是“一个”或更多,具体取决于你希望网络有多深。
下图展示了训练深度学习模型所需的一般步骤:

图 9.3:深度学习模型训练流程
一个典型深度学习模型的训练过程可以解释如下:
-
决定网络架构:
首先,我们需要决定网络的架构,比如网络中有多少层,以及每一层将包含多少个节点。
-
初始化权重和偏置:
在网络中,每一层的每个神经元都将与上一层的所有神经元相连。这些神经元之间的连接都有相应的权重。在整个神经网络的训练过程中,我们首先初始化这些权重的值。每个神经元还会附带一个相应的偏置组件。这一初始化过程是一次性的。
-
sigmoid、relu或tanh)用于产生非线性输出。这些值会通过每一层的隐藏层传播,最终在输出层产生输出。 -
计算损失:
网络的输出与训练数据集的真实/实际值或标签进行比较,从而计算出网络的损失。网络的损失是衡量网络性能的指标。损失越低,网络性能越好。
-
更新权重(反向传播):
一旦我们计算出网络的损失,目标就是最小化网络的损失。这是通过使用梯度下降算法来调整与每个节点相关的权重来实现的。梯度下降是一种用于最小化各种机器学习算法中损失的优化算法:
![图 9.4:梯度下降]()
图 9.4:梯度下降
损失最小化反过来推动预测值在训练过程中更接近实际值。学习率在决定这些权重更新的速率中起着至关重要的作用。其他优化器的例子有 Adam、RMSProp 和 Momentum。
-
继续迭代:
前面的步骤(步骤 3 到 5)将继续进行,直到损失被最小化到某个阈值,或者我们完成一定次数的迭代来完成训练过程。
以下列出了在训练过程中应该调整的一些超参数:
-
网络中的层数
-
每一层中神经元或节点的数量
-
每层中激活函数的选择
-
学习率的选择
-
梯度算法变种的选择
-
如果我们使用迷你批梯度下降算法,则批次大小
-
用于权重优化的迭代次数
现在我们已经对深度学习的基本概念有了较好的回顾,接下来我们将开始了解 PyTorch。
PyTorch 基础
在本章中,我们将使用 PyTorch 来构建深度学习解决方案。一个显而易见的问题是,为什么选择 PyTorch?以下是一些我们应使用 PyTorch 来构建深度学习模型的原因:
-
Python 风格深度集成:
由于 PyTorch 的 Python 风格编程方式和面向对象方法的应用,PyTorch 的学习曲线非常平滑。一个例子是与 NumPy Python 库的深度集成,您可以轻松地将 NumPy 数组转换为 torch 张量,反之亦然。此外,Python 调试器与 PyTorch 的配合也非常流畅,使得在使用 PyTorch 时代码调试变得更加容易。
-
动态计算图:
许多其他深度学习框架采用静态计算图;然而,在 PyTorch 中,支持动态计算图,这使得开发人员能更深入地理解每个算法中的执行过程,并且可以在运行时编程改变网络的行为。
-
OpenAI 采用 PyTorch:
在强化学习(RL)领域,PyTorch 因其速度和易用性而获得了巨大的关注。如您所注意到的,现在 OpenAI Gym 常常是解决 RL 问题的默认环境。最近,OpenAI 宣布将 PyTorch 作为其研究和开发工作的主要框架。
以下是构建 PyTorch 深度神经网络时应该遵循的一些步骤:
-
导入所需的库,准备数据,并定义源数据和目标数据。请注意,当使用任何 PyTorch 模型时,您需要将数据转换为 torch 张量。
-
使用类构建模型架构。
-
定义要使用的损失函数和优化器。
-
训练模型。
-
使用模型进行预测。
让我们进行一个练习,构建一个简单的 PyTorch 深度学习模型。
练习 9.01:在 PyTorch 中构建一个简单的深度学习模型
本练习的目的是在 PyTorch 中构建一个工作的端到端深度学习模型。本练习将带您逐步了解如何在 PyTorch 中创建神经网络模型,并如何使用示例数据在 PyTorch 中训练同一模型。这将展示 PyTorch 中的基本过程,我们稍后将在深度 Q 学习部分中使用:
-
打开一个新的 Jupyter 笔记本。我们将导入所需的库:
# Importing the required libraries import numpy as np import torch from torch import nn, optim -
然后,使用 NumPy 数组,我们将源数据和目标数据转换为 torch 张量。请记住,为了使 PyTorch 模型正常工作,您应始终将源数据和目标数据转换为 torch 张量,如以下代码片段所示:
#input data and converting to torch tensors inputs = np.array([[73, 67, 43],\ [91, 88, 64],\ [87, 134, 58],\ [102, 43, 37],\ [69, 96, 70]], dtype = 'float32') inputs = torch.from_numpy(inputs) #target data and converting to torch tensors targets = np.array([[366], [486], [558],\ [219], [470]], dtype = 'float32') targets = torch.from_numpy(targets) #Checking the shapes inputs.shape , targets.shape输出将如下所示:
(torch.Size([5, 3]), torch.Size([5, 1]))非常重要的是要注意输入和目标数据集的形状。这是因为深度学习模型应与输入和目标数据的形状兼容,以进行矩阵乘法运算。
-
将网络架构定义如下:
class Model(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(3, 10) self.fc2 = nn.Linear(10, 1) def forward(self, x): x = torch.relu(self.fc1(x)) x = self.fc2(x) return x # Instantiating the model model = Model()一旦我们将源数据和目标数据转换为张量格式,我们应该为神经网络模型架构创建一个类。这个类使用
Module包从nn基类继承属性。这个新类叫做Model,将有一个正向函数和一个常规构造函数,叫做 (__init__)。__init__方法首先将调用super方法以访问基类。然后,在此构造方法内编写所有层的定义。forward 方法的作用是提供神经网络前向传播步骤所需的步骤。nn.Linear()的语法是(输入大小,输出大小),用于定义模型的线性层。我们可以在 forward 函数中与线性层结合使用非线性函数,如relu或tanh。神经网络架构表示输入层中的 3 个节点,隐藏层中的 10 个节点和输出层中的 1 个节点。在 forward 函数中,我们将在隐藏层中使用
relu激活函数。一旦定义了模型类,我们必须实例化模型。现在,您应该已经成功创建并启动了一个模型。
-
现在定义损失函数和优化器。我们正在处理的练习是一个回归问题;在回归问题中,我们通常使用均方误差作为损失函数。在 PyTorch 中,我们使用
MSELoss()函数用于回归问题。通常,将损失赋给criterion。Model参数和学习率必须作为必需的参数传递给优化器以进行反向传播。可以使用model.parameters()函数访问模型参数。现在使用 Adam 优化器定义损失函数和优化器。在创建 Adam 优化器时,将0.01作为学习率和模型参数一起传入:# Loss function and optimizer criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.01)此时,你应该已经成功定义了损失和优化函数。
注意
torch.optim包。 -
将模型训练 20 个周期并监控损失值。为了形成训练循环,创建一个名为
n_epochs的变量,并将其初始化为 20。创建一个for循环,循环n_epoch次。在循环内部,完成以下步骤:使用optimizer.zero_grad()将参数梯度清零。将输入传入模型,获取输出。使用criterion通过传入输出和目标来获得损失。使用loss.backward()和optimizer.step()执行反向传播步骤。每个周期后打印损失值:# Train the model n_epochs = 20 for it in range(n_epochs): # zero the parameter gradients optimizer.zero_grad() # Forward pass outputs = model(inputs) loss = criterion(outputs, targets) # Backward and optimize loss.backward() optimizer.step() print(f'Epoch {it+1}/{n_epochs}, Loss: {loss.item():.4f}')默认情况下,PyTorch 会在每一步计算时累积梯度。我们需要在训练过程中处理这一点,以确保权重根据正确的梯度更新。
optimizer.zero_grad()会将前一步训练的梯度清零,以停止梯度的累积。此步骤应在每个周期计算梯度之前完成。为了计算损失,我们应将预测值和实际值传入损失函数。criterion(outputs, targets)用于计算损失。loss.backward()用于计算权重梯度,我们将使用这些梯度来更新权重,从而获得最佳权重。权重更新是通过optimizer.step()函数完成的。输出将如下所示:
Epoch 1/20, Loss: 185159.9688 Epoch 2/20, Loss: 181442.8125 Epoch 3/20, Loss: 177829.2188 Epoch 4/20, Loss: 174210.5938 Epoch 5/20, Loss: 170534.4375 Epoch 6/20, Loss: 166843.9531 Epoch 7/20, Loss: 163183.2500 Epoch 8/20, Loss: 159532.0625 Epoch 9/20, Loss: 155861.8438 Epoch 10/20, Loss: 152173.0000 Epoch 11/20, Loss: 148414.5781 Epoch 12/20, Loss: 144569.6875 Epoch 13/20, Loss: 140625.1094 Epoch 14/20, Loss: 136583.0625 Epoch 15/20, Loss: 132446.6719 Epoch 16/20, Loss: 128219.9688 Epoch 17/20, Loss: 123907.7422 Epoch 18/20, Loss: 119515.7266 Epoch 19/20, Loss: 115050.4375 Epoch 20/20, Loss: 110519.2969如你所见,输出在每个周期后打印损失值。你应该密切监控训练损失。从前面的输出中我们可以看到,训练损失在逐步减少。
-
一旦模型训练完成,我们可以使用训练好的模型进行预测。将输入数据传入模型,获取预测结果并观察输出:
#Prediction using the trained model preds = model(inputs) print(preds)输出如下:
tensor([[ 85.6779], [115.3034], [146.7106], [ 69.4034], [120.5457]], grad_fn=<AddmmBackward>)
前面的输出展示了模型对相应输入数据的预测结果。
注意
若要访问此特定部分的源代码,请参考packt.live/3e2DscY。
你也可以在网上运行这个例子,访问packt.live/37q0J68。
我们现在已经了解了一个 PyTorch 模型是如何工作的。这个例子对于你训练深度 Q 神经网络时会非常有用。然而,除了这个,还有一些其他重要的 PyTorch 工具是你应该了解的,接下来你将学习这些工具。理解这些工具对于实现深度 Q 学习至关重要。
PyTorch 工具
为了使用这些工具,首先我们将创建一个大小为 10 的 torch 张量,包含从 1 到 9 的数字,使用 PyTorch 的 arange 函数。torch 张量本质上是一个元素矩阵,所有元素属于同一数据类型,可以具有多个维度。请注意,像 Python 一样,PyTorch 也会排除在 arange 函数中给定的数字:
import torch
t = torch.arange(10)
print(t)
print(t.shape)
输出将如下所示:
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
torch.Size([10])
现在我们开始逐个探索不同的函数。
view 函数
使用 view 函数重新调整张量的形状如下所示:
t.view(2,5) # reshape the tensor to of size - (2,5)
输出将如下所示:
tensor([[0, 1, 2, 3, 4],
[5, 6, 7, 8, 9]])
现在让我们尝试一个新的形状:
t.view(-1,5)
# -1 will by default infer the first dimension
# use when you are not sure about any dimension size
输出将如下所示:
tensor([[0, 1, 2, 3, 4],
[5, 6, 7, 8, 9]])
squeeze 函数
squeeze 函数用于移除任何值为 1 的维度。以下是一个形状为 (5,1) 的张量示例:
x = torch.zeros(5, 1)
print(x)
print(x.shape)
输出将如下所示:
tensor([[0.],
[0.],
[0.],
[0.],
[0.]])
torch.Size([5, 1])
对张量应用 squeeze 函数:
# squeeze will remove any dimension with a value of 1
y = x.squeeze(1)
# turns a tensor of shape [5, 1] to [5]
y.shape
输出将如下所示:
torch.Size([5])
如你所见,使用 squeeze 后,维度为 1 的维度已被移除。
unsqueeze 函数
正如其名所示,unsqueeze 函数执行与 squeeze 相反的操作。它向输入数据添加一个维度为 1 的维度。
考虑以下示例。首先,我们创建一个形状为 5 的张量:
x = torch.zeros(5)
print(x)
print(x.shape)
输出将如下所示:
tensor([0., 0., 0., 0., 0.])
torch.Size([5])
对张量应用 unsqueeze 函数:
y = x.unsqueeze(1) # unsqueeze will add a dimension of 1
print(y.shape) # turns a tensor of shape [5] to [5,1]
输出将如下所示:
torch.Size([5, 1])
如你所见,已向张量添加了一个维度为 1 的维度。
max 函数
如果将多维张量传递给 max 函数,函数将返回指定轴上的最大值及其相应的索引。更多细节请参考代码注释。
首先,我们创建一个形状为 (4, 4) 的张量:
a = torch.randn(4, 4)
a
输出将如下所示:
tensor([[-0.5462, 1.3808, 1.4759, 0.1665],
[-1.6576, -1.2805, 0.5480, -1.7803],
[ 0.0969, -1.7333, 1.0639, -0.4660],
[ 0.3135, -0.4781, 0.3603, -0.6883]])
现在,让我们对张量应用 max 函数:
"""
returns max values in the specified dimension along with index
specifying 1 as dimension means we want to do the operation row-wise
"""
torch.max(a , 1)
输出将如下所示:
torch.return_types.max(values=tensor([1.4759, 0.5480, \
1.0639, 0.3603]),\
indices=tensor([2, 2, 2, 2]))
现在让我们尝试从张量中找出最大值:
torch.max(a , 1)[0] # to fetch the max values
输出将如下所示:
tensor([1.4759, 0.5480, 1.0639, 0.3603])
要找到最大值的索引,可以使用以下代码:
# to fetch the index of the corresponding max values
torch.max(a , 1)[1]
输出将如下所示:
tensor([2, 2, 2, 2])
如你所见,最大值的索引已显示。
gather 函数
gather 函数通过沿指定的轴(由 dim 指定)收集值来工作。gather 函数的一般语法如下:
torch.gather(input, dim, index)
语法可以解释如下:
-
input(tensor): 在这里指定源张量。 -
dim(python:int): 指定索引的轴。 -
index(LongTensor): 指定要收集的元素的索引。
在下面的示例中,我们有一个形状为(4,4)的 q_values,它是一个 torch 张量,而 action 是一个 LongTensor,它包含我们想从 q_values 张量中提取的索引:
q_values = torch.randn(4, 4)
print(q_values)
输出将如下所示:
q_values = torch.randn(4, 4)
print(q_values)
tensor([[-0.2644, -0.2460, -1.7992, -1.8586],
[ 0.3272, -0.9674, -0.2881, 0.0738],
[ 0.0544, 0.5494, -1.7240, -0.8058],
[ 1.6687, 0.0767, 0.6696, -1.3802]])
接下来,我们将应用 LongTensor 来指定要收集的张量元素的索引:
# index must be defined as LongTensor
action =torch.LongTensor([0 , 1, 2, 3])
然后,找到 q_values 张量的形状:
q_values.shape , action.shape
# q_values -> 2-dimensional tensor
# action -> 1-dimension tensor
输出将如下所示:
(torch.Size([4, 4]), torch.Size([4]))
现在让我们应用 gather 函数:
"""
unsqueeze is used to take care of the error - Index tensor
must have same dimensions as input tensor
returns the values from q_values using the action as indexes
"""
torch.gather(q_values , 1, action.unsqueeze(1))
输出将如下所示:
tensor([[-0.2644],
[-0.9674],
[-1.7240],
[-1.3802]])
现在你已经对神经网络有了基本了解,并且知道如何在 PyTorch 中实现一个简单的神经网络。除了标准的神经网络(即线性层与非线性激活函数的组合)之外,还有两个变体,分别是卷积神经网络(CNNs)和递归神经网络(RNNs)。CNN 主要用于图像分类和图像分割任务,而 RNN 则用于具有顺序模式的数据,例如时间序列数据或语言翻译任务。
现在,在我们已经掌握了深度学习的基本知识以及如何在 PyTorch 中构建深度学习模型后,我们将重点转向 Q 学习,以及如何在强化学习(RL)中利用深度学习,借助 PyTorch 实现。首先,我们将从状态值函数和贝尔曼方程开始,然后再深入探讨 Q 学习。
状态值函数和贝尔曼方程
随着我们逐渐进入 Q 函数和 Q 学习过程的核心,让我们回顾一下贝尔曼方程,它是 Q 学习过程的支柱。在接下来的部分,我们将首先复习“期望值”的定义,并讨论它如何在贝尔曼方程中应用。
期望值
下图展示了状态空间中的期望值:

图 9.5:期望值
假设一个智能体处于状态 S,并且它有两条可以选择的路径。第一条路径的转移概率为 0.6,关联的奖励为 1;第二条路径的转移概率为 0.4,关联的奖励为 0。
现在,状态 S 的期望值或奖励如下所示:
(0.6 * 1) + (0.4 * 1) = 0.6
从数学上讲,它可以表示为:

图 9.6:期望值的表达式
值函数
当一个智能体处于某个环境中时,值函数提供了关于各个状态所需的信息。值函数为智能体提供了一种方法,通过这种方法,智能体可以知道某一给定状态对其有多好。所以,如果一个智能体可以从当前状态选择两个状态,它将总是选择值函数较大的那个状态。
值函数可以递归地通过未来状态的值函数来表示。当我们在一个随机环境中工作时,我们将使用期望值的概念,正如前一节所讨论的那样。
确定性环境的值函数
对于一个确定性世界,状态的值就是所有未来奖励的总和。
状态 1 的值函数可以表示如下:

图 9.7:状态 1 的值函数
状态 1 的值函数可以通过状态 2 来表示,如下所示:

图 9.8:状态 2 的值函数
使用状态 2 值函数的状态 1 简化值函数可以表示如下:

图 9.9:使用状态 2 值函数的状态 1 简化值函数
带折扣因子的状态 1 简化值函数可以表示如下:

图 9.10:带折扣因子的状态 1 简化值函数
通常,我们可以将值函数重新写为如下形式:

图 9.11:确定性环境下的值函数
随机环境下的值函数:
对于随机行为,由于环境中存在的随机性或不确定性,我们不直接使用原始的未来奖励,而是取从某个状态到达的期望总奖励来得出值函数。前述方程中新增加的是期望部分。方程如下:

图 9.12:随机环境下的值函数
这里,s 是当前状态,
是下一个状态,r 是从 s 到的奖励
。
动作值函数(Q 值函数)
在前面的章节中,我们学习了状态值函数,它告诉我们某个状态对智能体有多大的奖励。现在我们将学习另一个函数,在这个函数中,我们可以将状态与动作结合起来。动作值函数将告诉我们,对于智能体从某个给定状态采取任何特定动作的好坏。我们也称动作值为Q 值。方程可以写成如下形式:

图 9.13:Q 值函数表达式
上述方程可以按迭代方式写成如下:

图 9.14:带迭代的 Q 值函数表达式
这个方程也被称为贝尔曼方程。从这个方程,我们可以递归地表示
的 Q 值,表示为下一个状态的 Q 值
。贝尔曼方程可以描述如下:
“处于状态 s 并采取动作 a 的总期望奖励是两个部分的和:我们从状态‘s’采取动作 a 能够获得的奖励(即 r),加上我们从任何可能的下一状态-动作对(s′,a′)中能够获得的最大期望折扣回报**
。a′是下一最佳可能动作。”
实现 Q 学习以找到最佳动作
使用 Q 函数从任何状态中找到最佳动作的过程称为 Q 学习。Q 学习也是一种表格方法,其中状态和动作的组合以表格格式存储。在接下来的部分中,我们将学习如何通过 Q 学习方法逐步找到最佳动作。考虑以下表格:

图 9.15:Q 学习的示例表格
正如前面的表格所示,Q 值以表格的形式存储,其中行代表环境中的状态,列代表代理的所有可能动作。你可以看到,所有的状态都表示为行,而所有动作,如上、下、右和左,都存储为列。
任何一行和一列交叉点上的值即为该特定状态-动作对的 Q 值。
最初,所有状态-动作对的值都初始化为零。代理在某一状态下将选择具有最高 Q 值的动作。例如,如上图所示,当处于状态 001 时,代理将选择向右移动,因为它的 Q 值最高(0.98)。
在初期阶段,当大多数状态-动作对的值为零时,我们将利用之前讨论的ε-贪心策略来解决探索-利用的困境,具体如下:
-
设置ε的值(例如 0.90 这样的较高值)。
-
在 0 到 1 之间选择一个随机数:
if random_number > ε : choose the best action(exploitation) else: choose the random action (exploration) decay ε
状态中较高的ε值逐渐衰减。其思想是最初进行探索,然后进行利用。
使用上一章中描述的时序差分(TD)方法,我们以迭代方式更新 Q 值,如下所示:

图 9.16:通过迭代更新 Q 值
时间戳t为当前迭代,时间戳(t-1)为上一轮迭代。通过这种方式,我们用TD方法更新上一轮时间戳的 Q 值,并尽可能将 Q 值推近到最优 Q 值,这也叫做
。
我们可以将上面的方程重新写为:

图 9.17:更新 Q 值的表达式
通过简单的数学运算,我们可以进一步简化方程,如下所示:

图 9.18:Q 值更新方程
学习率决定了我们在更新 Q 值时应该采取多大的步伐。这个迭代和更新 Q 值的过程会持续进行,直到 Q 值趋近于
或者当我们达到某个预定义的迭代次数时。迭代过程可以如下可视化:

图 9.19:Q 学习过程
如你所见,经过多次迭代后,Q 表最终准备好了。
Q 学习的优点
以下是使用 Q 学习在强化学习领域中的一些优点:
-
我们不需要知道完整的转移动态;这意味着我们不必了解所有可能不存在的状态转移概率。
-
由于我们以表格格式存储状态-动作组合,通过从表格中提取细节,理解和实现 Q 学习算法变得更加简单。
-
我们不必等到整个回合结束才更新任何状态的 Q 值,因为学习过程是连续在线更新的,这与蒙特卡罗方法不同,在蒙特卡罗方法中我们必须等到回合结束才能更新动作值函数。
-
当状态和动作空间的组合较少时,这种方法效果较好。
由于我们现在已经了解了 Q 学习的基础知识,我们可以使用 OpenAI Gym 环境实现 Q 学习。因此,在进行练习之前,让我们回顾一下 OpenAI Gym 的概念。
OpenAI Gym 回顾
在我们实现 Q 学习表格方法之前,先快速回顾并重新审视 Gym 环境。OpenAI Gym 是一个用于开发强化学习(RL)算法的工具包。它支持教导代理从行走到玩像 CartPole 或 FrozenLake-v0 等游戏。Gym 提供了一个环境,开发者可以根据需要编写和实现任何强化学习算法,如表格方法或深度 Q 学习。我们也可以使用现有的深度学习框架,如 PyTorch 或 TensorFlow 来编写算法。以下是一个与现有 Gym 环境一起使用的示例代码:

图 9.20:Gym 环境
让我们理解代码的几个部分如下:
-
gym.make("CartPole-v1")这会创建一个现有的 Gym 环境(
CartPole-v1)。 -
env.reset()这会重置环境,因此环境将回到初始状态。
-
env.action_space.sample()这会从动作空间(可用动作的集合)中选择一个随机动作。
-
env.step(action)这会执行上一阶段选择的动作。一旦你采取了动作,环境将返回
new_state、reward和done标志(用于指示游戏是否结束),以及一些额外信息。 -
env.render()这会呈现出代理执行动作或进行游戏的过程。
我们现在已经对 Q 学习过程有了理论理解,并且我们也回顾了 Gym 环境。现在轮到你自己实现使用 Gym 环境的 Q 学习了。
练习 9.02:实现 Q 学习表格方法
在这个练习中,我们将使用 OpenAI Gym 环境实现表格 Q 学习方法。我们将使用FrozenLake-v0 Gym 环境来实现表格 Q 学习方法。目标是通过 Q 学习过程进行游戏并收集最大奖励。你应该已经熟悉来自第五章、动态规划中的 FrozenLake-v0 环境。以下步骤将帮助你完成练习:
-
打开一个新的 Jupyter notebook 文件。
-
导入所需的库:
# Importing the required libraries import gym import numpy as np import matplotlib.pyplot as plt -
创建
'FrozenLake-v0'Gym 环境,以实现一个随机环境:env = gym.make('FrozenLake-v0') -
获取状态和动作的数量:
number_of_states = env.observation_space.n number_of_actions = env.action_space.n # checking the total number of states and action print('Total number of States : {}'.format(number_of_states)) print('Total number of Actions : {}'.format(number_of_actions))输出将如下所示:
Total number of States : 16 Total number of Actions : 4 -
使用从上一步获取的详细信息创建 Q 表:
# Creation of Q table Q_TABLE = np.zeros([number_of_states, number_of_actions]) # Looking at the initial values Q table print(Q_TABLE) print('shape of Q table : {}'.format(Q_TABLE.shape)输出如下:
[[0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.] [0\. 0\. 0\. 0.]] shape of Q table : (16, 4)现在我们知道 Q 表的形状,并且每个状态-动作对的初始值都是零。
-
设置所有用于 Q 学习的必需超参数值:
# Setting the Hyper parameter Values for Q Learning NUMBER_OF_EPISODES = 10000 MAX_STEPS = 100 LEARNING_RATE = 0.1 DISCOUNT_FACTOR = 0.99 EGREEDY = 1 MAX_EGREEDY = 1 MIN_EGREEDY = 0.01 EGREEDY_DECAY_RATE = 0.001 -
创建空列表以存储奖励值和衰减的 egreedy 值用于可视化:
# Creating empty lists to store rewards of all episodes rewards_all_episodes = [] # Creating empty lists to store egreedy_values of all episodes egreedy_values = [] -
实现 Q 学习训练过程,通过固定次数的回合来进行游戏。使用之前学到的 Q 学习过程(来自实施 Q 学习以寻找最佳行动部分),以便从给定状态中找到最佳行动。
创建一个
for循环,迭代NUMBER_OF_EPISODES。重置环境并将done标志设置为False,current_episode_rewards设置为zero。创建另一个for循环,在MAX_STEPS内运行一个回合。在for循环内,使用 epsilon-greedy 策略选择最佳动作。执行该动作并使用图 9.18中展示的公式更新 Q 值。收集奖励并将new_state赋值为当前状态。如果回合结束,则跳出循环,否则继续执行步骤。衰减 epsilon 值,以便继续进行下一回合:# Training Process for episode in range(NUMBER_OF_EPISODES): state = env.reset() done = False current_episode_rewards = 0 for step in range(MAX_STEPS): random_for_egreedy = np.random.rand() if random_for_egreedy > EGREEDY: action = np.argmax(Q_TABLE[state,:]) else: action = env.action_space.sample() new_state, reward, done, info = env.step(action) Q_TABLE[state, action] = (1 - LEARNING_RATE) \ * Q_TABLE[state, action] \ + LEARNING_RATE \ * (reward + DISCOUNT_FACTOR \ * np.max(Q_TABLE[new_state,:])) state = new_state current_episode_rewards += reward if done: break egreedy_values.append(EGREEDY) EGREEDY = MIN_EGREEDY + (MAX_EGREEDY - MIN_EGREEDY) \ * np.exp(-EGREEDY_DECAY_RATE*episode) rewards_all_episodes.append(current_episode_rewards) -
实现一个名为
rewards_split的函数,该函数将 10,000 个奖励拆分为 1,000 个单独的奖励列表,并计算这些 1,000 个奖励列表的平均奖励:def rewards_split(rewards_all_episodes , total_episodes , split): """ Objective: To split and calculate average reward or percentage of completed rewards per splits inputs: rewards_all_episodes - all the per episode rewards total_episodes - total of episodes split - number of splits on which we will check the reward returns: average reward of percentage of completed rewards per splits """ splitted = np.split(np.array(rewards_all_episodes),\ total_episodes/split) avg_reward_per_splits = [] for rewards in splitted: avg_reward_per_splits.append(sum(rewards)/split) return avg_reward_per_splits avg_reward_per_splits = rewards_split\ (rewards_all_episodes , \ NUMBER_OF_EPISODES , 1000) -
可视化平均奖励或已完成回合的百分比:
plt.figure(figsize=(12,5)) plt.title("% of Episodes completed") plt.plot(np.arange(len(avg_reward_per_splits)), \ avg_reward_per_splits, 'o-') plt.show()输出如下:
![图 9.21:可视化已完成回合的百分比]()
图 9.21:可视化已完成回合的百分比
从前面的图中可以看出,回合已完成,并且百分比呈指数增长,直到达到一个点后变得恒定。
-
现在我们将可视化
Egreedy值的衰减:plt.figure(figsize=(12,5)) plt.title("Egreedy value") plt.bar(np.arange(len(egreedy_values)), egreedy_values, \ alpha=0.6, color='blue', width=5) plt.show()图形将如下所示:
![图 9.22:Egreedy 值衰减]()
图 9.22:Egreedy 值衰减
在图 9.22中,我们可以看到Egreedy值随着步骤数的增加逐渐衰减。这意味着,随着值接近零,算法变得越来越贪婪,选择具有最大奖励的动作,而不探索那些奖励较少的动作,这些动作通过足够的探索,可能会在长期内带来更多的奖励,但在初期我们对模型了解不够。
这突出了在学习初期阶段需要更高探索的需求。通过更高的 epsilon 值可以实现这一点。随着训练的进行,epsilon 值逐渐降低。这会导致较少的探索和更多利用过去运行中获得的知识。
因此,我们已经成功实现了表格 Q 学习方法。
注意
要访问此特定部分的源代码,请参考packt.live/2B3NziM。
您也可以在线运行此示例,网址为packt.live/2AjbACJ。
现在我们已经对所需的实体有了充分的了解,我们将学习强化学习中的另一个重要概念:深度 Q 学习。
深度 Q 学习
在深入讨论深度 Q 学习过程的细节之前,让我们先讨论传统表格 Q 学习方法的缺点,然后我们将看看将深度学习与 Q 学习结合如何帮助我们解决表格方法的这些缺点。
以下描述了表格 Q 学习方法的几个缺点:
-
性能问题:当状态空间非常大时,表格的迭代查找操作将变得更加缓慢和昂贵。
-
存储问题:除了性能问题,当涉及到存储大规模状态和动作空间的表格数据时,存储成本也很高。
-
表格方法仅在代理遇到 Q 表中已有的离散状态时表现良好。对于 Q 表中没有的未见过的状态,代理的表现可能是最优的。
-
对于之前提到的连续状态空间,表格 Q 学习方法无法以高效或恰当的方式近似 Q 值。
考虑到所有这些问题,我们可以考虑使用一个函数逼近器,将其作为状态与 Q 值之间的映射。在机器学习中,我们可以将这个问题看作是使用非线性函数逼近器来解决回归问题。既然我们在考虑使用一个函数逼近器,神经网络作为函数逼近器最为适合,通过它我们可以为每个状态-动作对近似 Q 值。将 Q 学习与神经网络结合的这个过程称为深度 Q 学习或 DQN。
让我们分解并解释这个难题的每个部分:
-
DQN 的输入:
神经网络接受环境的状态作为输入。例如,在 FrozenLake-v0 环境中,状态可以是任何给定时刻网格上的简单坐标。对于像 Atari 这样的复杂游戏,输入可以是几张连续的屏幕快照,作为状态表示的图像。输入层中的节点数将与环境中存在的状态数相同。
-
DQN 输出:
输出将是每个动作的 Q 值。例如,对于任何给定的环境,如果有四个可能的动作,那么输出将为每个动作提供四个 Q 值。为了选择最佳动作,我们将选择具有最大 Q 值的动作。
-
损失函数和学习过程:
DQN 将接受来自环境的状态,并且对于每个给定的输入或状态,网络将输出每个动作的估计 Q 值。其目标是逼近最优的 Q 值,这将满足贝尔曼方程右侧的要求,如下所示:
![图 9.23:贝尔曼方程]()
图 9.23:贝尔曼方程
为了计算损失,我们需要目标 Q 值和来自网络的 Q 值。从前面的贝尔曼方程中,目标 Q 值是在方程的右侧计算出来的。DQN 的损失是通过将 DQN 输出的 Q 值与目标 Q 值进行比较来计算的。一旦我们计算出损失,我们就通过反向传播更新 DQN 的权重,以最小化损失并使 DQN 输出的 Q 值更接近最优 Q 值。通过这种方式,在 DQN 的帮助下,我们将强化学习问题视为一个有源和目标的监督学习问题。
DQN 实现可以如下可视化:

图 9.24:DQN
我们可以按以下步骤编写深度 Q 学习过程:
-
初始化权重以获得
Q(s,a)的初始近似值:class DQN(nn.Module): def __init__(self , hidden_layer_size): super().__init__() self.hidden_layer_size = hidden_layer_size self.fc1 = nn.Linear\ (number_of_states,self.hidden_layer_size) self.fc2 = nn.Linear\ (self.hidden_layer_size,number_of_actions) def forward(self, x): output = torch.tanh(self.fc1(x)) output = self.fc2(output) return output如你所见,我们已经用权重初始化了 DQN 类。
__init__函数中的两行代码负责给网络连接赋予随机权重。我们也可以显式地初始化权重。现在常见的做法是让 PyTorch 或 TensorFlow 使用其内部的默认初始化逻辑来创建初始权重向量,如下所示的代码示例:self.fc1 = nn.Linear(number_of_states,self.hidden_layer_size) self.fc2 = nn.Linear(self.hidden_layer_size,number_of_actions) -
通过网络进行一次前向传播,获取标志(
state、action、reward和new_state)。通过对 Q 值取最大值的索引(选择最大 Q 值的索引)来选择动作,或者在探索阶段随机选择动作。我们可以使用以下代码示例来实现这一点:def select_action(self,state,EGREEDY): random_for_egreedy = torch.rand(1)[0] if random_for_egreedy > EGREEDY: with torch.no_grad(): state = torch.Tensor(state).to(device) q_values = self.dqn(state) action = torch.max(q_values,0)[1] action = action.item() else: action = env.action_space.sample() return action正如你在前面的代码片段中看到的,使用了 egreedy 算法来选择动作。
select_action函数通过 DQN 传递状态来获得 Q 值,并在利用过程中选择 Q 值最高的动作。if语句决定是否进行探索。 -
如果 episode 结束,则目标 Q 值将是获得的奖励;否则,使用 Bellman 方程来估计目标 Q 值。你可以在以下代码示例中实现:
def optimize(self, state, action, new_state, reward, done): state = torch.Tensor(state).to(device) new_state = torch.Tensor(new_state).to(device) reward = torch.Tensor([reward]).to(device) if done: target_value = reward else: new_state_values = self.dqn(new_state).detach() max_new_state_values = torch.max(new_state_values) target_value = reward + DISCOUNT_FACTOR \ * max_new_state_values -
获得的损失如下所示。
如果 episode 结束,则损失将是
。否则,损失将被称为
。以下是
loss的示例代码:loss = self.criterion(predicted_value, target_value) -
使用反向传播,我们更新网络权重(θ)。此迭代将针对每个状态运行,直到我们足够地最小化损失并得到一个近似最优的 Q 函数。以下是示例代码:
self.optimizer.zero_grad() loss.backward() self.optimizer.step()
现在我们对深度 Q 学习的实现有了较为清晰的理解,接下来让我们通过一个练习来测试我们的理解。
练习 9.03:在 CartPole-v0 环境中使用 PyTorch 实现一个有效的 DQN 网络
在本练习中,我们将使用 OpenAI Gym CartPole 环境实现深度 Q 学习算法。此练习的目的是构建一个基于 PyTorch 的 DQN 模型,学习在 CartPole 环境中平衡小车。请参考本章开始时解释的构建神经网络的 PyTorch 示例。
我们的主要目标是应用 Q 学习算法,在每一步保持杆子稳定,并在每个 episode 中收集最大奖励。当杆子保持直立时,每一步会获得+1 的奖励。当杆子偏离垂直位置超过 15 度,或小车在 CartPole 环境中偏离中心位置超过 2.4 单位时,episode 将结束:
-
打开一个新的 Jupyter 笔记本并导入所需的库:
import gym import matplotlib.pyplot as plt import torch import torch.nn as nn from torch import optim import numpy as np import math -
根据图形处理单元(GPU)环境的可用性创建设备:
# selecting the available device (cpu/gpu) use_cuda = torch.cuda.is_available() device = torch.device("cuda:0" if use_cuda else "cpu") print(device) -
使用
'CartPole-v0'环境创建一个 Gym 环境:env = gym.make('CartPole-v0') -
设置
seed以保证 torch 和环境的可复现结果:seed = 100 env.seed(seed) torch.manual_seed(seed) -
设置 DQN 过程所需的所有超参数值:
NUMBER_OF_EPISODES = 700 MAX_STEPS = 1000 LEARNING_RATE = 0.01 DISCOUNT_FACTOR = 0.99 HIDDEN_LAYER_SIZE = 64 EGREEDY = 0.9 EGREEDY_FINAL = 0.02 EGREEDY_DECAY = 500 -
实现一个在每一步后衰减 epsilon 值的函数。我们将使用指数方式衰减 epsilon 值。epsilon 值从
EGREEDY开始,并会衰减直到达到EGREEDY_FINAL。使用以下公式:EGREEDY_FINAL + (EGREEDY - EGREEDY_FINAL) \ * math.exp(-1\. * steps_done / EGREEDY_DECAY )代码将如下所示:
def calculate_epsilon(steps_done): """ Decays epsilon with increasing steps Parameter: steps_done (int) : number of steps completed Returns: int - decayed epsilon """ epsilon = EGREEDY_FINAL + (EGREEDY - EGREEDY_FINAL) \ * math.exp(-1\. * steps_done / EGREEDY_DECAY ) return epsilon -
从环境中获取状态和动作的数量:
number_of_states = env.observation_space.shape[0] number_of_actions = env.action_space.n print('Total number of States : {}'.format(number_of_states)) print('Total number of Actions : {}'.format(number_of_actions))输出将如下所示:
Total number of States : 4 Total number of Actions : 2 -
创建一个名为
DQN的类,该类接受状态数量作为输入,并输出环境中动作数量的 Q 值,并具有一个大小为64的隐藏层网络:class DQN(nn.Module): def __init__(self , hidden_layer_size): super().__init__() self.hidden_layer_size = hidden_layer_size self.fc1 = nn.Linear\ (number_of_states,self.hidden_layer_size) self.fc2 = nn.Linear\ (self.hidden_layer_size,number_of_actions) def forward(self, x): output = torch.tanh(self.fc1(x)) output = self.fc2(output) return output -
创建一个
DQN_Agent类,并实现构造函数_init_。该函数将在其中创建一个 DQN 类的实例,并传递隐藏层大小。它还将定义MSE作为损失标准。接下来,定义Adam作为优化器,并设置模型参数及预定义的学习率:class DQN_Agent(object): def __init__(self): self.dqn = DQN(HIDDEN_LAYER_SIZE).to(device) self.criterion = torch.nn.MSELoss() self.optimizer = optim.Adam\ (params=self.dqn.parameters() , \ lr=LEARNING_RATE) -
接下来,定义
select_action函数,该函数将接受state和 epsilon 值作为输入参数。使用egreedy算法选择动作。该函数将通过 DQN 传递state以获取 Q 值,然后在利用阶段使用torch.max操作选择具有最高 Q 值的动作。在此过程中,不需要梯度计算;因此我们使用torch.no_grad()函数来关闭梯度计算:def select_action(self,state,EGREEDY): random_for_egreedy = torch.rand(1)[0] if random_for_egreedy > EGREEDY: with torch.no_grad(): state = torch.Tensor(state).to(device) q_values = self.dqn(state) action = torch.max(q_values,0)[1] action = action.item() else: action = env.action_space.sample() return action -
定义
optimize函数,该函数将接受state、action、new_state、reward和done作为输入,并将它们转换为张量,同时保持它们与所用设备的兼容性。如果该回合已结束,则将奖励设为目标值;否则,将新状态通过 DQN(用于断开连接并关闭梯度计算)传递,以计算贝尔曼方程右侧的最大部分。利用获得的奖励和折扣因子,我们可以计算目标值:![图 9.25:目标值方程]()
def optimize(self, state, action, new_state, reward, done): state = torch.Tensor(state).to(device) new_state = torch.Tensor(new_state).to(device) reward = torch.Tensor([reward]).to(device) if done: target_value = reward else: new_state_values = self.dqn(new_state).detach() max_new_state_values = torch.max(new_state_values) target_value = reward + DISCOUNT_FACTOR \ * max_new_state_values predicted_value = self.dqn(state)[action].view(-1) loss = self.criterion(predicted_value, target_value) self.optimizer.zero_grad() loss.backward() self.optimizer.step() -
使用
for循环编写训练过程。首先,使用之前创建的类实例化 DQN 智能体。创建一个空的steps_total列表,用于收集每个回合的总步数。将steps_counter初始化为零,并用它来计算每个步骤的衰减 epsilon 值。在训练过程中使用两个循环。第一个循环是进行一定步数的游戏。第二个循环确保每个回合持续固定的步数。在第二个for循环中,第一步是计算当前步骤的 epsilon 值。使用当前状态和 epsilon 值,选择要执行的动作。接下来的步骤是执行该动作。一旦执行动作,环境将返回new_state、reward和done标志。利用optimize函数,执行一步梯度下降来优化 DQN。现在,将新状态作为下次迭代的当前状态。最后,检查该回合是否结束。如果回合结束,则可以收集并记录当前回合的奖励:# Instantiating the DQN Agent dqn_agent = DQN_Agent() steps_total = [] steps_counter = 0 for episode in range(NUMBER_OF_EPISODES): state = env.reset() done = False step = 0 for I in range(MAX_STEPS): step += 1 steps_counter += 1 EGREEDY = calculate_epsilon(steps_counter) action = dqn_agent.select_action(state, EGREEDY) new_state, reward, done, info = env.step(action) dqn_agent.optimize(state, action, new_state, reward, done) state = new_state if done: steps_total.append(step) break -
现在观察奖励,因为奖励是标量反馈,能够指示智能体的表现如何。你应该查看平均奖励以及过去 100 回合的平均奖励:
print("Average reward: %.2f" \ % (sum(steps_total)/NUMBER_OF_EPISODES)) print("Average reward (last 100 episodes): %.2f" \ % (sum(steps_total[-100:])/100))输出将如下所示:
Average reward: 158.83 Average reward (last 100 episodes): 176.28 -
执行奖励的图形表示。检查代理在更多回合中如何表现,并检查过去 100 回合的奖励平均值:
plt.figure(figsize=(12,5)) plt.title("Rewards Collected") plt.bar(np.arange(len(steps_total)), steps_total, \ alpha=0.5, color='green', width=6) plt.show()输出图应该如下所示:
![图 9.26:收集的奖励]()
图 9.26:收集的奖励
图 9.26显示了最初的步数和奖励值较低。然而,随着步数的增加,我们通过 DQN 算法收集到了稳定且更高的奖励值。
注意
要访问此特定部分的源代码,请参阅packt.live/3cUE8Q9。
您也可以在线运行此示例,网址是packt.live/37zeUpz。
因此,我们已经成功实现了在 CartPole 环境中使用 PyTorch 的 DQN。现在,让我们看看 DQN 中的一些挑战性问题。
DQN 中的挑战
前面章节中解释的内容看起来很好;然而,DQN 存在一些挑战。以下是 DQN 面临的几个挑战:
-
步数之间的相关性在训练过程中造成了收敛问题
-
非稳定目标的挑战。
这些挑战及其相应的解决方案将在接下来的章节中进行解释。
步数之间的相关性和收敛问题
从前面的练习中,我们已经看到,在 Q 学习中,我们将 RL 问题视为监督学习问题,其中有预测值和目标值,并通过梯度下降优化来减少损失,找到最优的 Q 函数。
梯度下降算法假设训练数据点是独立同分布的(即i.i.d),这一点在传统机器学习数据中通常成立。然而,在强化学习(RL)中,每个数据点是高度相关且依赖于其他数据点的。简而言之,下一状态取决于前一状态的动作。由于 RL 数据中的相关性,我们在梯度下降算法的情况下遇到了收敛问题。
为了解决收敛问题,我们将在接下来的章节中介绍一个可能的解决方案——经验回放。
经验回放
为了打破 RL 中数据点之间的相关性,我们可以使用一种名为经验回放(experience replay)的技术。在训练的每个时间步,我们将代理的经验存储在回放缓冲区(Replay Buffer)中(这只是一个 Python 列表)。
例如,在时间 t 的训练过程中,以下代理经验作为一个元组存储在回放缓冲区中
,其中:
-
- 当前状态 -
- 执行动作 -
- 新状态 -
- 奖励 -
- 表示该回合是否完成
我们为重放缓冲区设置了最大大小;随着新经验的出现,我们将继续添加新的经验元组。因此,当我们达到最大大小时,我们将丢弃最旧的值。在任何给定时刻,重放缓冲区始终会存储最新的经验,且大小不超过最大限制。
在训练过程中,为了打破相关性,我们将从重放缓冲区随机采样这些经验来训练 DQN。这个获取经验并从存储这些经验的重放缓冲区中进行采样的过程称为经验重放。
在 Python 实现中,我们将使用一个 push 函数来将经验存储在重放缓冲区中。将实现一个示例函数,从缓冲区采样经验,指针和长度方法将帮助我们跟踪重放缓冲区的大小。
以下是经验重放的详细代码实现示例。
我们将实现一个包含之前解释的所有功能的 ExperienceReplay 类。在该类中,构造函数将包含以下变量:capacity,表示重放缓冲区的最大大小;buffer,一个空的 Python 列表,充当内存缓冲区;以及 pointer,指向内存缓冲区当前的位置,在将内存推送到缓冲区时使用。
该类将包含 push 函数,该函数使用 pointer 变量检查缓冲区中是否有空闲空间。如果有空闲空间,push 将在缓冲区的末尾添加一个经验元组,否则该函数将替换缓冲区起始点的内存。它还包含 sample 函数,返回批量大小的经验元组,以及 __len__ 函数,返回当前缓冲区的长度,作为实现的一部分。
以下是指针、容量和模除在经验重放中的工作示例。
我们将指针初始化为零,并将容量设置为三。每次操作后,我们增加指针值,并通过模除运算得到指针的当前值。当指针超过最大容量时,值将重置为零:
![图 9.27:经验重放类中的指针、容量和模除]
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_27.jpg)
图 9.27:经验重放类中的指针、容量和模除
添加上述所有功能后,我们可以实现如下代码片段所示的 ExperienceReplay 类:
class ExperienceReplay(object):
def __init__(self , capacity):
self.capacity = capacity
self.buffer = []
self.pointer = 0
def push(self , state, action, new_state, reward, done):
experience = (state, action, new_state, reward, done)
if self.pointer >= len(self.buffer):
self.buffer.append(experience)
else:
self.buffer[self.pointer] = experience
self.pointer = (self.pointer + 1) % self.capacity
def sample(self , batch_size):
return zip(*random.sample(self.buffer , batch_size))
def __len__(self):
return len(self.buffer)
如你所见,经验类已经被初始化。
非平稳目标的挑战
请看下面的代码片段。如果仔细查看以下 optimize 函数,你会看到我们通过 DQN 网络进行了两次传递:一次计算目标 Q 值(使用贝尔曼方程),另一次计算预测的 Q 值。之后,我们计算了损失:
def optimize(self, state, action, new_state, reward, done):
state = torch.Tensor(state).to(device)
new_state = torch.Tensor(new_state).to(device)
reward = torch.Tensor([reward]).to(device)
if done:
target_value = reward
else:
# first pass
new_state_values = self.dqn(new_state).detach()
max_new_state_values = torch.max(new_state_values)
target_value = reward + DISCOUNT_FACTOR \
* max_new_state_values
# second pass
predicted_value = self.dqn(state)[action].view(-1)
loss = self.criterion(predicted_value, target_value)
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step() # weight optimization
第一次传递只是通过 Bellman 方程来近似最优 Q 值;然而,在计算目标 Q 值和预测 Q 值时,我们使用的是来自网络的相同权重。这个过程使整个深度 Q 学习过程变得不稳定。在损失计算过程中,考虑以下方程:

图 9.28:损失计算的表达式
损失计算完成后,我们执行一次梯度下降步骤,优化权重以最小化损失。一旦权重更新,预测的 Q 值将发生变化。然而,我们的目标 Q 值也会发生变化,因为在计算目标 Q 值时,我们使用的是相同的权重。由于固定目标 Q 值不可用,当前架构下整个过程是不稳定的。
解决这个问题的一个方法是,在整个训练过程中保持固定的目标 Q 值。
目标网络的概念
为了解决非平稳目标的问题,我们可以通过在流程中引入目标神经网络架构来解决这个问题。我们称这个网络为目标网络。目标网络的架构与基础神经网络相同。我们可以将这个基础神经网络称为预测 DQN。
如前所述,为了计算损失,我们必须通过 DQN 做两次传递:第一次是计算目标 Q 值,第二次是计算预测的 Q 值。
由于架构的变化,目标 Q 值将通过目标网络计算,而预测 Q 值的过程保持不变,如下图所示:

图 9.29:目标网络
从前面的图可以推断,损失函数可以写成如下形式:

图 9.30:损失函数的表达式
目标网络的整个目的就是使用新的状态-动作对来计算 Bellman 方程中的最大部分。
此时,您可能会问一个显而易见的问题,那就是,这个目标网络的权重或参数怎么办?我们如何从这个目标网络中以最优的方式获取目标值?为了在固定目标值和使用目标网络进行最优目标逼近之间保持平衡,我们将在每次固定的迭代后,从预测值更新目标网络的权重。但是,应该在多少次迭代后从预测网络更新目标网络的权重呢?这个问题的答案是一个超参数,在 DQN 的训练过程中需要进行调整。整个过程使得训练过程更加稳定,因为目标 Q 值会在一段时间内保持固定。
我们可以总结使用经验回放和目标网络训练 DQN 的步骤如下:
-
初始化回放缓冲区。
-
创建并初始化预测网络。
-
创建预测网络的副本作为目标网络。
-
运行固定次数的回合。
在每一回合中,执行以下步骤:
-
使用 egreedy 算法选择一个动作。
-
执行动作并收集奖励和新状态。
-
将整个经验存储在重放缓冲区中。
-
从重放缓冲区中随机选择一批经验。
-
将这一批状态通过预测网络传递,以获得预测的 Q 值。
-
使用一个新的状态,通过目标网络计算目标 Q 值。
-
执行梯度下降,以优化预测网络的权重。
-
在固定的迭代次数后,将预测网络的权重克隆到目标网络。
现在我们理解了 DQN 的概念、DQN 的不足之处以及如何通过经验重放和目标网络来克服这些不足;我们可以将这些结合起来,构建一个强健的 DQN 算法。让我们在接下来的练习中实现我们的学习。
练习 9.04:在 PyTorch 中实现带有经验重放和目标网络的有效 DQN 网络
在之前的练习中,你实现了一个有效的 DQN 来与 CartPole 环境一起工作。然后,我们看到了 DQN 的不足之处。现在,在本练习中,我们将使用 PyTorch 实现带有经验重放和目标网络的 DQN 网络,以构建一个更加稳定的 DQN 学习过程:
-
打开一个新的 Jupyter notebook,并导入所需的库:
import gym import matplotlib.pyplot as plt import torch import torch.nn as nn from torch import optim import numpy as np import random import math -
编写代码,根据 GPU 环境的可用性创建一个设备:
use_cuda = torch.cuda.is_available() device = torch.device("cuda:0" if use_cuda else "cpu") print(device) -
使用
'CartPole-v0'环境创建一个gym环境:env = gym.make('CartPole-v0') -
设置 torch 和环境的种子以保证可复现性:
seed = 100 env.seed(seed) torch.manual_seed(seed) random.seed(seed) -
从环境中获取状态和动作的数量:
number_of_states = env.observation_space.shape[0] number_of_actions = env.action_space.n print('Total number of States : {}'.format(number_of_states)) print('Total number of Actions : {}'.format(number_of_actions))输出如下:
Total number of States : 4 Total number of Actions : 2 -
设置 DQN 过程所需的所有超参数值。请添加几个新超参数,如这里所述,并与常规参数一起设置:
REPLAY_BUFFER_SIZE– 这设置了重放缓冲区的最大长度。BATCH_SIZE– 这表示有多少组经验!描述自动生成](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_30a.png) 用于训练 DQN。
UPDATE_TARGET_FREQUENCY– 这是目标网络权重从预测网络中刷新周期的频率:NUMBER_OF_EPISODES = 500 MAX_STEPS = 1000 LEARNING_RATE = 0.01 DISCOUNT_FACTOR = 0.99 HIDDEN_LAYER_SIZE = 64 EGREEDY = 0.9 EGREEDY_FINAL = 0.02 EGREEDY_DECAY = 500 REPLAY_BUFFER_SIZE = 6000 BATCH_SIZE = 32 UPDATE_TARGET_FREQUENCY = 200 -
使用先前实现的
calculate_epsilon函数,通过增加的步数值来衰减 epsilon 值:def calculate_epsilon(steps_done): """ Decays epsilon with increasing steps Parameter: steps_done (int) : number of steps completed Returns: int - decayed epsilon """ epsilon = EGREEDY_FINAL + (EGREEDY - EGREEDY_FINAL) \ * math.exp(-1\. * steps_done / EGREEDY_DECAY ) return epsilon -
创建一个名为
DQN的类,该类接受状态数作为输入,并输出环境中动作数的 Q 值,网络的隐藏层大小为64:class DQN(nn.Module): def __init__(self , hidden_layer_size): super().__init__() self.hidden_layer_size = hidden_layer_size self.fc1 = nn.Linear\ (number_of_states,self.hidden_layer_size) self.fc2 = nn.Linear\ (self.hidden_layer_size,number_of_actions) def forward(self, x): output = torch.tanh(self.fc1(x)) output = self.fc2(output) return output -
实现
ExperienceReplay类:class ExperienceReplay(object): def __init__(self , capacity): self.capacity = capacity self.buffer = [] self.pointer = 0 def push(self , state, action, new_state, reward, done): experience = (state, action, new_state, reward, done) if self.pointer >= len(self.buffer): self.buffer.append(experience) else: self.buffer[self.pointer] = experience self.pointer = (self.pointer + 1) % self.capacity def sample(self , batch_size): return zip(*random.sample(self.buffer , batch_size)) def __len__(self): return len(self.buffer) -
现在通过传入缓冲区大小作为输入,实例化
ExperienceReplay类:memory = ExperienceReplay(REPLAY_BUFFER_SIZE) -
实现
DQN_Agent类。请注意,以下是
DQN_Agent类中的一些更改(我们在练习 9.03中使用了该类,即在 CartPole-v0 环境中使用 PyTorch 实现一个有效的 DQN 网络),这些更改需要与之前实现的DQN_Agent类进行整合。创建一个普通 DQN 网络的副本,并将其命名为
target_dqn。使用target_dqn_update_counter周期性地从 DQN 网络更新目标 DQN 的权重。添加以下步骤:memory.sample(BATCH_SIZE)将从回放缓冲区随机抽取经验用于训练。将new_state传入目标网络,以从目标网络获取目标 Q 值。最后,在UPDATE_TARGET_FREQUENCY指定的某次迭代后,更新目标网络的权重。请注意,我们使用了
gather、squeeze和unsqueeze函数,这些是我们在专门的PyTorch 实用工具部分中学习过的:class DQN_Agent(object): def __init__(self): self.dqn = DQN(HIDDEN_LAYER_SIZE).to(device) self.target_dqn = DQN(HIDDEN_LAYER_SIZE).to(device) self.criterion = torch.nn.MSELoss() self.optimizer = optim.Adam(params=self.dqn.parameters(),\ lr=LEARNING_RATE) self.target_dqn_update_counter = 0 def select_action(self,state,EGREEDY): random_for_egreedy = torch.rand(1)[0] if random_for_egreedy > EGREEDY: with torch.no_grad(): state = torch.Tensor(state).to(device) q_values = self.dqn(state) action = torch.max(q_values,0)[1] action = action.item() else: action = env.action_space.sample() return action def optimize(self): if (BATCH_SIZE > len(memory)): return state, action, new_state, reward, done = memory.sample\ (BATCH_SIZE) state = torch.Tensor(state).to(device) new_state = torch.Tensor(new_state).to(device) reward = torch.Tensor(reward).to(device) # to be used as index action = torch.LongTensor(action).to(device) done = torch.Tensor(done).to(device) new_state_values = self.target_dqn(new_state).detach() max_new_state_values = torch.max(new_state_values , 1)[0] # when done = 1 then target = reward target_value = reward + (1 - done) * DISCOUNT_FACTOR \ * max_new_state_values predicted_value = self.dqn(state)\ .gather(1, action.unsqueeze(1))\ .squeeze(1) loss = self.criterion(predicted_value, target_value) self.optimizer.zero_grad() loss.backward() self.optimizer.step() if self.target_dqn_update_counter \ % UPDATE_TARGET_FREQUENCY == 0: self.target_dqn.load_state_dict(self.dqn.state_dict()) self.target_dqn_update_counter += 1 -
编写 DQN 网络的训练过程。使用经验回放和目标 DQN 的训练过程通过更少的代码简化了这个过程。
首先,使用之前创建的类实例化 DQN 代理。创建一个
steps_total空列表,用于收集每个回合的总步数。将steps_counter初始化为零,并用它计算每步的衰减 epsilon 值。在训练过程中使用两个循环:第一个循环用于进行一定数量的回合;第二个循环确保每个回合进行固定数量的步骤。在第二个
for循环内部,第一步是计算当前步骤的 epsilon 值。利用当前状态和 epsilon 值,选择要执行的动作。下一步是采取行动。一旦执行动作,环境会返回
new_state、reward和done标志。将new_state、reward、done和info推送到经验回放缓冲区。使用optimize函数,执行一次梯度下降步骤来优化 DQN。现在将新的状态设为下次迭代的当前状态。最后,检查回合是否结束。如果回合结束,则可以收集并记录当前回合的奖励:
dqn_agent = DQN_Agent() steps_total = [] steps_counter = 0 for episode in range(NUMBER_OF_EPISODES): state = env.reset() done = False step = 0 for i in range(MAX_STEPS): step += 1 steps_counter += 1 EGREEDY = calculate_epsilon(steps_counter) action = dqn_agent.select_action(state, EGREEDY) new_state, reward, done, info = env.step(action) memory.push(state, action, new_state, reward, done) dqn_agent.optimize() state = new_state if done: steps_total.append(step) break -
现在观察奖励。由于奖励是标量反馈,并能指示代理的表现情况,您应该查看平均奖励和最后 100 个回合的平均奖励。同时,进行奖励的图形表示。检查代理在进行更多回合时的表现,以及最后 100 个回合的奖励平均值:
print("Average reward: %.2f" \ % (sum(steps_total)/NUMBER_OF_EPISODES)) print("Average reward (last 100 episodes): %.2f" \ % (sum(steps_total[-100:])/100))输出将如下所示:
Average reward: 154.41 Average reward (last 100 episodes): 183.28现在我们可以看到,对于最后 100 个回合,使用经验回放的 DQN 的平均奖励更高,并且固定目标比之前练习中实现的普通 DQN 更高。这是因为我们在 DQN 训练过程中实现了稳定性,并且加入了经验回放和目标网络。
-
将奖励绘制在 y 轴上,并将步数绘制在 x 轴上,以查看随着步数增加,奖励的变化:
plt.figure(figsize=(12,5)) plt.title("Rewards Collected") plt.xlabel('Steps') plt.ylabel('Reward') plt.bar(np.arange(len(steps_total)), steps_total, alpha=0.5, \ color='green', width=6) plt.show()![图 9.31:收集的奖励]()
图 9.31:收集的奖励
正如你在前面的图表中看到的,使用带有目标网络的经验回放时,最初的奖励相较于之前的版本(请参见图 9.26)稍低;然而,经过若干回合后,奖励相对稳定,且最后 100 个回合的平均奖励较高。
注意
要访问该特定部分的源代码,请参考packt.live/2C1KikL。
您也可以在线运行此示例,访问packt.live/3dVwiqB。
在本次练习中,我们在原始 DQN 网络中添加了经验回放和目标网络(该网络在练习 9.03中进行了说明,在 CartPole-v0 环境中使用 PyTorch 实现工作 DQN 网络),以克服原始 DQN 的缺点。结果在奖励方面表现更好,因为我们看到了在过去 100 个回合中的平均奖励更加稳定。输出的比较如下所示:
原始 DQN 输出:
Average reward: 158.83
Average reward (last 100 episodes): 176.28
具有经验回放和目标网络输出的 DQN:
Average reward: 154.41
Average reward (last 100 episodes): 183.28
然而,DQN 过程仍然存在另一个问题,即 DQN 中的高估问题。我们将在下一节中了解更多关于这个问题以及如何解决它。
DQN 中的高估问题
在上一节中,我们引入了目标网络作为解决非平稳目标问题的方案。使用这个目标网络,我们计算了目标 Q 值并计算了损失。引入新的目标网络来计算固定目标值的整个过程在某种程度上使训练过程变得更加稳定。然而,2015 年,Hado van Hasselt 在他名为《深度强化学习与双重 Q 学习》的论文中,通过多个实验展示了这一过程高估了目标 Q 值,使整个训练过程变得不稳定:

图 9.32:DQN 和 DDQN 中的 Q 值估计
注意
上述图表来自Hasselt 等人,2015 年的论文《深度强化学习与双重 Q 学习》。欲了解 DDQN 的更深入阅读,请参阅以下链接:arxiv.org/pdf/1509.06461.pdf。
在对多个 Atari 游戏进行实验后,论文的作者展示了使用 DQN 网络可能导致 Q 值的高估(如图中橙色所示),这表示与真实 DQN 值的偏差很大。在论文中,作者提出了一种新的算法叫做双重 DQN。我们可以看到,通过使用双重 DQN,Q 值估计值更接近真实值,任何过估计都大大降低。现在,让我们讨论一下什么是双重 DQN 以及它与具有目标网络的 DQN 有什么不同。
双重深度 Q 网络(DDQN)
相比于具有目标网络的 DQN,DDQN 的细微差异如下:
-
DDQN 通过选择具有最高 Q 值的动作,使用我们的预测网络选择下一状态下要采取的最佳动作。
-
DDQN 使用来自预测网络的动作来计算下一个状态下目标 Q 值的对应估计(使用目标网络)。
如深度 Q 学习部分所述,DQN 的损失函数如下:

图 9.33:DQN 的损失函数
DDQN 的更新损失函数如下:

图 9.34:更新后的 DDQN 损失函数
以下图示展示了典型 DDQN 的工作原理:

图 9.35:DDQN
以下概述了 DDQN 实现中优化函数所需的更改:
-
使用预测网络选择一个动作。
我们将通过预测网络传递
new_state,以获取new_state的 Q 值,如下代码所示:new_state_indxs = self.dqn(new_state).detach()为了选择动作,我们将从输出的 Q 值中选择最大索引值,如下所示:
max_new_state_indxs = torch.max(new_state_indxs, 1)[1] -
使用目标网络选择最佳动作的 Q 值。
我们将通过目标网络传递
new_state,以获取new_state的 Q 值,如下代码所示:new_state_values = self.target_dqn(new_state).detach()对于与
new_state相关的最佳动作的 Q 值,我们使用目标网络,如下代码所示:max_new_state_values = new_state_values.gather\ (1, max_new_state_indxs\ .unsqueeze(1))\ .squeeze(1)gather函数用于通过从预测网络获取的索引来选择 Q 值。
以下是具有所需更改的完整 DDQN 实现:
def optimize(self):
if (BATCH_SIZE > len(memory)):
return
state, action, new_state, reward, done = memory.sample\
(BATCH_SIZE)
state = torch.Tensor(state).to(device)
new_state = torch.Tensor(new_state).to(device)
reward = torch.Tensor(reward).to(device)
action = torch.LongTensor(action).to(device)
done = torch.Tensor(done).to(device)
"""
select action : get the index associated with max q value
from prediction network
"""
new_state_indxs = self.dqn(new_state).detach()
# to get the max new state indexes
max_new_state_indxs = torch.max(new_state_indxs, 1)[1]
"""
Using the best action from the prediction nn get the max new state
value in target dqn
"""
new_state_values = self.target_dqn(new_state).detach()
max_new_state_values = new_state_values.gather\
(1, max_new_state_indxs\
.unsqueeze(1))\
.squeeze(1)
#when done = 1 then target = reward
target_value = reward + (1 - done) * DISCOUNT_FACTOR \
* max_new_state_values
predicted_value = self.dqn(state).gather\
(1, action.unsqueeze(1)).squeeze(1)
loss = self.criterion(predicted_value, target_value)
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
if self.target_dqn_update_counter \
% UPDATE_TARGET_FREQUENCY == 0:
self.target_dqn.load_state_dict(self.dqn.state_dict())
self.target_dqn_update_counter += 1
现在我们已经学习了 DQN 和 DDQN 的各种概念,让我们通过一个活动来具体化我们的理解。
活动 9.01:在 PyTorch 中为 CartPole 环境实现双重深度 Q 网络
在这个活动中,你的任务是在 PyTorch 中实现一个 DDQN,以解决 CartPole 环境中 DQN 的过估计问题。我们可以总结出使用经验重放和目标网络训练 DQN 的步骤。
以下步骤将帮助你完成该活动:
-
打开一个新的 Jupyter 笔记本并导入所需的库:
import gym import matplotlib.pyplot as plt import torch import torch.nn as nn from torch import optim import numpy as np import random import math -
编写代码,根据 GPU 环境的可用性创建设备。
-
使用
CartPole-v0环境创建一个gym环境。 -
设置 torch 和环境的种子以确保结果可复现。
-
从环境中获取状态和动作的数量。
-
设置 DQN 过程所需的所有超参数值。
-
实现
calculate_epsilon函数。 -
创建一个名为
DQN的类,该类接受状态数量作为输入,并输出环境中动作数量的 Q 值,网络具有 64 大小的隐藏层。 -
初始化回放缓冲区。
-
在
DQN_Agent类中创建并初始化预测网络,如练习 9.03中所示,在 PyTorch 中实现一个带有经验回放和目标网络的工作 DQN 网络。创建预测网络的副本作为目标网络。 -
根据双深度 Q 网络(DDQN)部分中展示的代码示例,修改
DQN_Agent类中的optimize函数。 -
运行固定数量的回合。在每个回合中,使用ε-greedy 算法选择一个动作。
-
执行动作并收集奖励和新状态。将整个经验存储在回放缓冲区中。
-
从回放缓冲区中选择一个随机的经验批次。将状态批次通过预测网络,以获得预测的 Q 值。
-
使用我们的预测网络选择下一状态要执行的最佳动作,通过选择具有最高 Q 值的动作。使用预测网络中的动作计算下一状态下目标 Q 值的对应估计。
-
执行梯度下降优化预测网络的权重。经过固定迭代后,将预测网络的权重克隆到目标网络中。
-
训练 DDQN 代理后,检查平均奖励以及最后 100 回合的平均奖励。
-
在 y 轴绘制收集的奖励,x 轴绘制回合数,以可视化随着回合数增加,奖励是如何被收集的。
平均奖励的输出应类似于以下内容:
Average reward: 174.09 Average reward (last 100 episodes): 186.06奖励的图表应与以下类似:
![图 9.36:奖励收集图]()
图 9.36:奖励收集图
注意
该活动的解决方案可以在第 743 页找到。
在本章结束之前,我们展示了不同 DQN 技术和 DDQN 的平均奖励对比:
普通 DQN 输出:
Average reward: 158.83
Average reward (last 100 episodes): 176.28
带有经验回放和目标网络的 DQN 输出:
Average reward: 154.41
Average reward (last 100 episodes): 183.28
DDQN 输出:
Average reward: 174.09
Average reward (last 100 episodes): 186.06
正如你从前面的图表中看到的,结合之前展示的结果对比,DDQN 相比其他 DQN 实现具有最高的平均奖励,且最后 100 个回合的平均奖励也较高。我们可以说,DDQN 相比其他两种 DQN 技术显著提高了性能。在完成整个活动后,我们学会了如何将 DDQN 网络与经验回放结合,克服普通 DQN 的问题,并实现更稳定的奖励。
总结
在本章中,我们首先介绍了深度学习,并探讨了深度学习过程中的不同组成部分。然后,我们学习了如何使用 PyTorch 构建深度学习模型。
接下来,我们慢慢将焦点转向了强化学习(RL),在这里我们学习了价值函数和 Q 学习。我们展示了 Q 学习如何帮助我们在不知道环境过渡动态的情况下构建 RL 解决方案。我们还研究了表格 Q 学习相关的问题,以及如何通过深度 Q 学习解决这些与性能和内存相关的问题。
然后,我们深入研究了与普通 DQN 实现相关的问题,以及如何使用目标网络和经验回放机制克服训练 DQN 时出现的相关数据和非平稳目标等问题。最后,我们学习了双重深度 Q 学习如何帮助我们克服 DQN 中的过度估计问题。在下一章,你将学习如何将卷积神经网络(CNN)和循环神经网络(RNN)与 DQN 结合使用来玩非常受欢迎的 Atari 游戏《Breakout》。
第十章:10. 使用深度递归 Q 网络玩 Atari 游戏
引言
在本章中,我们将介绍 深度递归 Q 网络(DRQNs)及其变种。你将使用 卷积神经网络(CNNs)和 递归神经网络(RNNs)训练 深度 Q 网络(DQN)模型。你将获得使用 OpenAI Gym 包训练强化学习代理玩 Atari 游戏的实践经验。你还将学习如何使用注意力机制分析长时间序列的输入和输出数据。在本章结束时,你将对 DRQNs 有一个清晰的理解,并能够使用 TensorFlow 实现它们。
引言
在上一章中,我们了解到 DQNs 相比传统强化学习技术取得了更高的性能。视频游戏是 DQN 模型表现优异的典型例子。训练一个代理来玩视频游戏对于传统的强化学习代理来说非常困难,因为在训练过程中需要处理和分析大量可能的状态、动作和 Q 值组合。
深度学习算法以处理高维张量而闻名。一些研究人员将 Q 学习技术与深度学习模型相结合,克服了这一局限性,并提出了 DQNs。DQN 模型包含一个深度学习模型,作为 Q 值的函数逼近。此技术在强化学习领域取得了重大突破,因为它有助于处理比传统模型更大的状态空间和动作空间。
从那时起,进一步的研究已展开,设计了不同类型的 DQN 模型,如 DRQNs 或 深度注意力递归 Q 网络(DARQNs)。在本章中,我们将看到 DQN 模型如何从 CNN 和 RNN 模型中受益,这些模型在计算机视觉和自然语言处理领域取得了惊人的成果。我们将在下一节中介绍如何训练这些模型来玩著名的 Atari 游戏《打砖块》。
理解《打砖块》环境
在本章中,我们将训练不同的深度强化学习代理来玩《打砖块》游戏。在深入之前,先了解一下这款游戏。
《打砖块》是一款由 Atari 在 1976 年设计并发布的街机游戏。苹果公司联合创始人 Steve Wozniak 是设计和开发团队的一员。这款游戏在当时非常受欢迎,随着时间的推移,多个版本被开发出来。
游戏的目标是用一个球打破位于屏幕顶部的所有砖块(由于该游戏于 1974 年开发,屏幕分辨率较低,因此球由像素表示,在以下截图中它的形状看起来像一个矩形),而不让球掉下来。玩家可以在屏幕底部水平移动一个挡板,在球掉下之前将其击打回来,并将球弹回砖块。同时,球在撞击侧墙或天花板后会反弹。游戏结束时,如果球掉落(此时玩家失败),或者当所有砖块都被打破,玩家获胜并可进入下一阶段:

图 10.1: 《打砖块》游戏截图
OpenAI 的gym包提供了一个模拟此游戏的环境,允许深度强化学习智能体在其上进行训练和游戏。我们将使用的环境名称是BreakoutDeterministic-v4。下面是该环境的一些基本代码实现。
在能够训练智能体玩这个游戏之前,你需要从gym包中加载《打砖块》环境。为此,我们将使用以下代码片段:
import gym
env = gym.make('BreakoutDeterministic-v4')
这是一个确定性游戏,智能体选择的动作每次都会按预期发生,并且具有4的帧跳跃率。帧跳跃指的是在执行新动作之前,一个动作会被重复多少帧。
该游戏包括四个确定性的动作,如以下代码所示:
env.action_space
以下是代码的输出结果:
Discrete(4)
观察空间是一个大小为210 x 160的彩色图像(包含3个通道):
env.observation_space
以下是代码的输出结果:
Box(210, 160, 3)
要初始化游戏并获取第一个初始状态,我们需要调用.reset()方法,代码如下所示:
state = env.reset()
从动作空间中采样一个动作(即从所有可能的动作中随机选择一个),我们可以使用.sample()方法:
action = env.action_space.sample()
最后,要执行一个动作并获取其从环境中返回的结果,我们需要调用.step()方法:
new_state, reward, is_done, info = env.step(action)
以下截图展示的是执行一个动作后的环境状态的new_state结果:

图 10.2: 执行动作后的新状态结果
.step()方法返回四个不同的对象:
-
由前一个动作产生的新环境状态。
-
与前一个动作相关的奖励。
-
一个标志,指示在前一个动作之后游戏是否已经结束(无论是胜利还是游戏结束)。
-
来自环境的其他信息。正如 OpenAI 的说明所述,这些信息不能用于训练智能体。
在完成了一些关于 OpenAI 中《打砖块》游戏的基本代码实现后,接下来我们将进行第一次练习,让我们的智能体来玩这个游戏。
练习 10.01: 使用随机智能体玩《打砖块》
在本练习中,我们将实现一些用于玩 Breakout 游戏的函数,这些函数将在本章剩余部分中非常有用。我们还将创建一个随机动作的智能体:
-
打开一个新的 Jupyter Notebook 文件并导入
gym库:import gym -
创建一个名为
RandomAgent的类,该类接收一个名为env的输入参数,即游戏环境。该类将拥有一个名为get_action()的方法,该方法将从环境中返回一个随机动作:class RandomAgent(): def __init__(self, env): self.env = env def get_action(self, state): return self.env.action_space.sample() -
创建一个名为
initialize_env()的函数,该函数将返回给定输入环境的初始状态,一个对应于完成标志初始值的False值,以及作为初始奖励的0:def initialize_env(env): initial_state = env.reset() initial_done_flag = False initial_rewards = 0 return initial_state, initial_done_flag, initial_rewards -
创建一个名为
play_game()的函数,该函数接收一个智能体、一个状态、一个完成标志和一个奖励列表作为输入。该函数将返回收到的总奖励。play_game()函数将在完成标志为True之前进行迭代。在每次迭代中,它将执行以下操作:从智能体获取一个动作,在环境中执行该动作,累计收到的奖励,并为下一状态做准备:def play_game(agent, state, done, rewards): while not done: action = agent.get_action(state) next_state, reward, done, _ = env.step(action) state = next_state rewards += reward return rewards -
创建一个名为
train_agent()的函数,该函数接收一个环境、一个游戏轮数和一个智能体作为输入。该函数将从collections包中创建一个deque对象,并根据提供的轮数进行迭代。在每次迭代中,它将执行以下操作:使用initialize_env()初始化环境,使用play_game()玩游戏,并将收到的奖励追加到deque对象中。最后,它将打印游戏的平均得分:def train_agent(env, episodes, agent): from collections import deque import numpy as np scores = deque(maxlen=100) for episode in range(episodes) state, done, rewards = initialize_env(env) rewards = play_game(agent, state, done, rewards) scores.append(rewards) print(f"Average Score: {np.mean(scores)}") -
使用
gym.make()函数实例化一个名为env的 Breakout 环境:env = gym.make('BreakoutDeterministic-v4') -
实例化一个名为
agent的RandomAgent对象:agent = RandomAgent(env) -
创建一个名为
episodes的变量,并将其值设置为10:episodes = 10 -
通过提供
env、轮次和智能体来调用train_agent函数:train_agent(env, episodes, agent)在训练完智能体后,你将期望达到以下分数(由于游戏的随机性,你的分数可能略有不同):
Average Score: 0.6
随机智能体在 10 轮游戏后取得了较低的分数,即 0.6。我们会认为当智能体的得分超过 10 时,它已经学会了玩这个游戏。然而,由于我们使用的游戏轮数较少,我们还没有达到得分超过 10 的阶段。然而,在这一阶段,我们已经创建了一些玩 Breakout 游戏的函数,接下来我们将重用并更新这些函数。
注意
要访问此部分的源代码,请参考packt.live/30CfVeH。
你也可以在网上运行此示例,网址为packt.live/3hi12nU。
在下一节中,我们将学习 CNN 模型以及如何在 TensorFlow 中构建它们。
TensorFlow 中的 CNN
CNN 是一种深度学习架构,在计算机视觉任务中取得了惊人的成果,如图像分类、目标检测和图像分割。自动驾驶汽车是这种技术的实际应用示例。
CNN 的主要元素是卷积操作,其中通过将滤波器应用于图像的不同部分来检测特定的模式,并生成特征图。特征图可以看作是一个突出显示检测到的模式的图像,如下例所示:

图 10.3:垂直边缘特征图示例
CNN 由多个卷积层组成,每个卷积层使用不同的滤波器进行卷积操作。CNN 的最后几层通常是一个或多个全连接层,负责为给定数据集做出正确的预测。例如,训练用于预测数字图像的 CNN 的最后一层将是一个包含 10 个神经元的全连接层。每个神经元将负责预测每个数字(0 到 9)的发生概率:

图 10.4:用于分类数字图像的 CNN 架构示例
使用 TensorFlow 构建 CNN 模型非常简单,这要归功于 Keras API。要定义一个卷积层,我们只需要使用Conv2D()类,如以下代码所示:
from tensorflow.keras.layers import Conv2D
Conv2D(128, kernel_size=(3, 3), activation="relu")
在前面的例子中,我们创建了一个具有128个3x3大小的滤波器(或内核)的卷积层,并使用relu作为激活函数。
注意
在本章中,我们将使用 ReLU 激活函数来构建 CNN 模型,因为它是最具性能的激活函数之一。
要定义一个全连接层,我们将使用Dense()类:
from tensorflow.keras.layers import Dense
Dense(units=10, activation='softmax')
在 Keras 中,我们可以使用Sequential()类来创建一个多层 CNN:
import tensorflow as tf
from tensorflow.keras.layers import Conv2D, Dense
model = tf.keras.Sequential()
model.add(Conv2D(128, kernel_size=(3, 3), activation="relu"), \
input_shape=(100, 100, 3))
model.add(Conv2D(128, kernel_size=(3, 3), activation="relu"))
model.add(Dense(units=100, activation="relu"))
model.add(Dense(units=10, activation="softmax"))
请注意,您只需要为第一个卷积层提供输入图像的维度。定义完模型的各层后,您还需要通过提供损失函数、优化器和要显示的度量标准来编译模型:
model.compile(loss='sparse_categorical_crossentropy', \
optimizer="adam", metrics=['accuracy'])
最后,最后一步是用训练集和指定数量的epochs来训练 CNN:
model.fit(features_train, label_train, epochs=5)
TensorFlow 中的另一个有用方法是tf.image.rgb_to_grayscale(),它用于将彩色图像转换为灰度图像:
img = tf.image.rgb_to_grayscale(img)
要调整输入图像的大小,我们将使用tf.image.resize()方法:
img = tf.image.resize(img, [50, 50])
现在我们知道如何构建 CNN 模型,接下来让我们在以下练习中将其付诸实践。
练习 10.02:使用 TensorFlow 设计 CNN 模型
在本次练习中,我们将使用 TensorFlow 设计一个 CNN 模型。该模型将用于我们在活动 10.01中使用的 DQN 代理,使用 CNN 训练 DQN 玩打砖块游戏,我们将在其中训练这个模型玩打砖块游戏。执行以下步骤来实现这个练习:
-
打开一个新的 Jupyter Notebook 文件并导入
tensorflow包:import tensorflow as tf -
从
tensorflow.keras.models导入Sequential类:from tensorflow.keras.models import Sequential -
实例化一个顺序模型并将其保存到变量
model中:model = Sequential() -
从
tensorflow.keras.layers导入Conv2D类:from tensorflow.keras.layers import Conv2D -
使用
Conv2D实例化一个卷积层,设置32个大小为8的滤波器,步幅为 4x4,激活函数为 relu,输入形状为(84,84,1)。这些维度与 Breakout 游戏屏幕的大小有关。将其保存到变量conv1中:conv1 = Conv2D(32, 8, (4,4), activation='relu', \ padding='valid', input_shape=(84, 84, 1)) -
使用
Conv2D实例化第二个卷积层,设置64个大小为4的滤波器,步幅为 2x2,激活函数为relu。将其保存到变量conv2中:conv2 = Conv2D(64, 4, (2,2), activation='relu', \ padding='valid') -
使用
Conv2D实例化第三个卷积层,设置64个大小为3的滤波器,步幅为 1x1,激活函数为relu。将其保存到变量conv3中:conv3 = Conv2D(64, 3, (1,1), activation='relu', padding='valid') -
通过
add()方法将三个卷积层添加到模型中:model.add(conv1) model.add(conv2) model.add(conv3) -
从
tensorflow.keras.layers导入Flatten类。这个类将调整卷积层输出的大小,转化为一维向量:from tensorflow.keras.layers import Flatten -
通过
add()方法将一个实例化的Flatten层添加到模型中:model.add(Flatten()) -
从
tensorflow.keras.layers导入Dense类:from tensorflow.keras.layers import Dense -
使用
256个单元实例化一个全连接层,并将激活函数设置为relu:fc1 = Dense(256, activation='relu') -
使用
4个单元实例化一个全连接层,这与 Breakout 游戏中可能的操作数量相对应:fc2 = Dense(4) -
通过
add()方法将两个全连接层添加到模型中:model.add(fc1) model.add(fc2) -
从
tensorflow.keras.optimizers导入RMSprop类:from tensorflow.keras.optimizers import RMSprop -
使用
0.00025作为学习率实例化一个RMSprop优化器:optimizer=RMSprop(lr=0.00025) -
通过在
compile方法中指定mse作为损失函数,RMSprop作为优化器,accuracy作为训练期间显示的指标,来编译模型:model.compile(loss='mse', optimizer=optimizer, \ metrics=['accuracy']) -
使用
summary方法打印模型的摘要:model.summary()以下是代码的输出:
![图 10.5:CNN 模型摘要]()
图 10.5:CNN 模型摘要
输出显示了我们刚刚构建的模型的架构,包括不同的层以及在模型训练过程中使用的参数数量。
注意
要访问此特定部分的源代码,请参考packt.live/2YrqiiZ。
你也可以在packt.live/3fiNMxE上在线运行这个示例。
我们已经设计了一个包含三个卷积层的 CNN 模型。在接下来的部分,我们将看到如何将这个模型与 DQN 代理结合使用。
将 DQN 与 CNN 结合
人类通过视觉玩视频游戏。他们观察屏幕,分析情况,并决定最合适的行动。在视频游戏中,屏幕上可能会发生许多事情,因此能够看到所有这些模式可以在游戏中提供显著的优势。将 DQN 与 CNN 结合,可以帮助强化学习智能体根据特定情况学习采取正确的行动。
不仅仅使用全连接层,DQN 模型还可以通过卷积层作为输入来扩展。模型将能够分析输入图像,找到相关模式,并将它们输入到负责预测 Q 值的全连接层,如下所示:

图 10.6:普通 DQN 与结合卷积层的 DQN 之间的区别
添加卷积层有助于智能体更好地理解环境。我们将在接下来的活动中构建的 DQN 智能体将使用练习 10.02中的 CNN 模型,使用 TensorFlow 设计 CNN 模型,以输出给定状态的 Q 值。但我们将使用两个模型,而不是单一模型。这两个模型将共享完全相同的架构。
第一个模型将负责预测玩游戏时的 Q 值,而第二个模型(称为目标模型)将负责学习应当是什么样的最优 Q 值。这种技术帮助目标模型更快地收敛到最优解。
活动 10.01:训练 DQN 与 CNN 一起玩 Breakout
在本活动中,我们将构建一个带有额外卷积层的 DQN,并训练它使用 CNN 玩 Breakout 游戏。我们将为智能体添加经验回放。我们需要预处理图像,以便为 Breakout 游戏创建四张图像的序列。
以下指令将帮助你完成此任务:
-
导入相关的包(
gym、tensorflow、numpy)。 -
对训练集和测试集进行重塑。
-
创建一个包含
build_model()方法的 DQN 类,该方法将实例化一个由get_action()方法组成的 CNN 模型,get_action()方法将应用 epsilon-greedy 算法选择要执行的动作,add_experience()方法将存储通过玩游戏获得的经验,replay()方法将执行经验回放,通过从记忆中抽样经验并训练 DQN 模型,update_epsilon()方法将逐渐减少 epsilon 值以适应 epsilon-greedy 算法。 -
使用
initialize_env()函数通过返回初始状态、False表示任务未完成标志、以及0作为初始奖励来初始化环境。 -
创建一个名为
preprocess_state()的函数,该函数将对图像执行以下预处理:裁剪图像以去除不必要的部分,将图像转换为灰度图像,并将图像调整为正方形。 -
创建一个名为
play_game()的函数,该函数将在游戏结束前持续进行游戏,然后存储经验和累积的奖励。 -
创建一个名为
train_agent()的函数,该函数将通过多个回合进行迭代,代理将在每回合中玩游戏并进行经验回放。 -
实例化一个 Breakout 环境并训练一个 DQN 代理进行
50个回合的游戏。请注意,由于我们正在训练较大的模型,这一步骤可能需要更长时间才能完成。预期输出将接近这里显示的结果。由于游戏的随机性以及 epsilon-greedy 算法选择执行动作时的随机性,您可能会看到略有不同的值:
[Episode 0] - Average Score: 3.0 Average Score: 0.59注意
本次活动的解答可以在第 752 页找到。
在下一节中,我们将看到如何通过另一种深度学习架构来扩展这个模型:RNN。
TensorFlow 中的 RNN
在上一节中,我们展示了如何将卷积神经网络(CNN)集成到深度 Q 网络(DQN)模型中,以提高强化学习代理的性能。我们添加了一些卷积层,作为 DQN 模型的全连接层的输入。这些卷积层帮助模型分析游戏环境中的视觉模式,并做出更好的决策。
然而,使用传统的 CNN 方法有一个局限性。CNN 只能分析单张图像。而在玩像 Breakout 这样的电子游戏时,分析图像序列要比分析单张图像更有力,因为它有助于理解球的运动轨迹。这就是 RNN 发挥作用的地方:

图 10.7:RNN 的序列化
RNN 是神经网络的一种特定架构,它处理一系列输入。它们在自然语言处理领域非常流行,用于处理语料库中的文本,例如语音识别、聊天机器人或文本翻译。文本可以被定义为一系列相互关联的单词。仅凭单个单词很难判断一个句子或段落的主题。你必须查看多个单词的序列,才能做出猜测。
有不同类型的 RNN 模型,其中最流行的是门控循环单元(GRU)和长短期记忆(LSTM)。这两种模型都有记忆功能,可以记录模型已经处理过的不同输入(例如,句子的前五个单词),并将它们与新的输入(如句子的第六个单词)结合起来。
在 TensorFlow 中,我们可以按照如下方式构建一个包含10个单元的LSTM层:
from tensorflow.keras.layers import LSTM
LSTM(10, activation='tanh', recurrent_activation='sigmoid')
Sigmoid 激活函数是 RNN 模型中最常用的激活函数。
定义GRU层的语法与此非常相似:
from tensorflow.keras.layers import GRU
GRU(10, activation='tanh', recurrent_activation='sigmoid')
在 Keras 中,我们可以使用 Sequential() 类来创建一个多层 LSTM:
import tensorflow as tf
from tensorflow.keras.layers import LSTM, Dense
model = tf.keras.Sequential()
model.add(LSTM(128, activation='tanh', \
recurrent_activation='sigmoid'))
model.add(Dense(units=100, activation="relu")))
model.add(Dense(units=10, activation="softmax"))
在拟合模型之前,你需要通过提供损失函数、优化器和要显示的度量标准来编译它:
model.compile(loss='sparse_categorical_crossentropy', \
optimizer="adam", metrics=['accuracy'])
我们之前已经看到如何定义 LSTM 层,但为了将其与 CNN 模型结合使用,我们需要在 TensorFlow 中使用一个名为 TimeDistributed() 的封装类。该类用于将相同的指定层应用到输入张量的每个时间步,如下所示:
TimeDistributed(Dense(10))
在前面的示例中,完全连接的层被应用到接收到的每个时间步。在我们的案例中,我们希望在将图像序列输入到 LSTM 模型之前,先对每个图像应用卷积层。为了构建这样的序列,我们需要将多个图像堆叠在一起,以便 RNN 模型可以将其作为输入。现在,让我们进行一个练习,设计一个 CNN 和 RNN 模型的组合。
练习 10.03:设计一个结合 CNN 和 RNN 模型的 TensorFlow 组合
在这个练习中,我们将设计一个结合了 CNN 和 RNN 的模型,该模型将被我们的 DRQN 代理用于 活动 10.02,训练 DRQN 玩 Breakout,以玩 Breakout 游戏:
-
打开一个新的 Jupyter Notebook 并导入
tensorflow包:import tensorflow as tf -
从
tensorflow.keras.models导入Sequential类:from tensorflow.keras.models import Sequential -
实例化一个
sequential模型,并将其保存到名为model的变量中:model = Sequential() -
从
tensorflow.keras.layers导入Conv2D类:from tensorflow.keras.layers import Conv2D -
使用
Conv2D实例化一个卷积层,该层具有32个大小为8的滤波器,步长为4x4,激活函数为relu。并将其保存到名为conv1的变量中:conv1 = Conv2D(32, 8, (4,4), activation='relu', \ padding='valid', input_shape=(84, 84, 1)) -
使用
Conv2D实例化第二个卷积层,该层具有64个大小为4的滤波器,步长为2x2,激活函数为relu。并将其保存到名为conv2的变量中:conv2 = Conv2D(64, 4, (2,2), activation='relu', \ padding='valid') -
使用
Conv2D实例化第三个卷积层,该层具有64个大小为3的滤波器,步长为1x1,激活函数为relu。并将其保存到名为conv3的变量中:conv3 = Conv2D(64, 3, (1,1), activation='relu', \ padding='valid') -
从
tensorflow.keras.layers导入TimeDistributed类:from tensorflow.keras.layers import TimeDistributed -
实例化一个时间分布层,该层将
conv1作为输入,输入形状为 (4,84,84,1)。并将其保存到一个名为time_conv1的变量中:time_conv1 = TimeDistributed(conv1, input_shape=(4, 84, 84, 1)) -
实例化第二个时间分布层,该层将
conv2作为输入,并将其保存到名为time_conv2的变量中:time_conv2 = TimeDistributed(conv2) -
实例化第三个时间分布层,该层将
conv3作为输入,并将其保存到名为time_conv3的变量中:time_conv3 = TimeDistributed(conv3) -
使用
add()方法将三个时间分布层添加到模型中:model.add(time_conv1) model.add(time_conv2) model.add(time_conv3) -
从
tensorflow.keras.layers导入Flatten类:from tensorflow.keras.layers import Flatten -
实例化一个时间分布层,该层将
Flatten()层作为输入,并将其保存到名为time_flatten的变量中:time_flatten = TimeDistributed(Flatten()) -
使用
add()方法将time_flatten层添加到模型中:model.add(time_flatten) -
从
tensorflow.keras.layers导入LSTM类:from tensorflow.keras.layers import LSTM -
实例化一个具有
512单元的 LSTM 层,并将其保存到名为lstm的变量中:lstm = LSTM(512) -
使用
add()方法将 LSTM 层添加到模型中:model.add(lstm) -
从
tensorflow.keras.layers导入Dense类:from tensorflow.keras.layers import Dense -
实例化一个包含
128个单元且激活函数为relu的全连接层:fc1 = Dense(128, activation='relu') -
使用
4个单元实例化一个全连接层:fc2 = Dense(4) -
使用
add()方法将两个全连接层添加到模型中:model.add(fc1) model.add(fc2) -
从
tensorflow.keras.optimizers导入RMSprop类:from tensorflow.keras.optimizers import RMSprop -
使用学习率为
0.00025的RMSprop实例:optimizer=RMSprop(lr=0.00025) -
通过在
compile方法中指定mse作为损失函数,RMSprop作为优化器,以及accuracy作为在训练期间显示的度量,来编译模型:model.compile(loss='mse', optimizer=optimizer, \ metrics=['accuracy']) -
使用
summary方法打印模型摘要:model.summary()以下是代码输出:
![图 10.8:CNN+RNN 模型摘要]()
图 10.8:CNN+RNN 模型摘要
我们已经成功地将 CNN 模型与 RNN 模型结合。前面的输出展示了我们刚刚构建的模型架构,其中包含不同的层和在训练过程中使用的参数数量。该模型以四张图像的序列作为输入,并将其传递给 RNN,RNN 会分析它们之间的关系,然后将结果传递给全连接层,全连接层将负责预测 Q 值。
注意
要查看该部分的源代码,请访问 packt.live/2UDB3h4。
你也可以在线运行这个示例,访问 packt.live/3dVrf9T。
现在我们知道如何构建一个 RNN,我们可以将这个技术与 DQN 模型结合。这样的模型被称为 DRQN,我们将在下一节中探讨这个模型。
构建 DRQN
DQN 可以从 RNN 模型中受益,RNN 可以帮助处理序列图像。这种架构被称为 深度递归 Q 网络 (DRQN)。将 GRU 或 LSTM 模型与 CNN 模型结合,将使强化学习代理能够理解球的运动。为了实现这一点,我们只需在卷积层和全连接层之间添加一个 LSTM(或 GRU)层,如下图所示:

图 10.9:DRQN 架构
为了将图像序列输入到 RNN 模型中,我们需要将多张图像堆叠在一起。对于 Breakout 游戏,在初始化环境之后,我们需要获取第一张图像并将其复制多次,以形成第一组初始图像序列。完成后,在每次动作后,我们可以将最新的图像附加到序列中,并移除最旧的图像,从而保持序列大小不变(例如,最大四张图像的序列)。
活动 10.02:训练 DRQN 玩 Breakout 游戏
在本活动中,我们将通过替换活动 10.01中的 DQN 模型来构建一个 DRQN 模型,使用 CNN 训练 DQN 玩 Breakout 游戏。然后,我们将训练 DRQN 模型来玩 Breakout 游戏,并分析智能体的性能。以下说明将帮助您完成本活动:
-
导入相关的包(
gym、tensorflow、numpy)。 -
重塑训练集和测试集。
-
创建
DRQN类,并包含以下方法:build_model()方法用于实例化一个结合 CNN 和 RNN 的模型,get_action()方法用于应用 epsilon-greedy 算法选择要执行的动作,add_experience()方法用于将游戏过程中获得的经验存储在记忆中,replay()方法通过从记忆中采样经验进行经验回放,并每两轮保存一次模型,update_epsilon()方法用于逐渐减少 epsilon-greedy 中的 epsilon 值。 -
使用
initialize_env()函数来训练智能体,该函数通过返回初始状态、False的 done 标志和0作为初始奖励来初始化环境。 -
创建一个名为
preprocess_state()的函数,对图像进行以下预处理:裁剪图像以去除不必要的部分,将图像转换为灰度图像,然后将图像调整为方形。 -
创建一个名为
combine_images()的函数,用于堆叠一系列图像。 -
创建一个名为
play_game()的函数,该函数将玩一局游戏直到结束,然后存储经验和累积奖励。 -
创建一个名为
train_agent()的函数,该函数将通过多轮训练让智能体玩游戏并执行经验回放。 -
实例化一个 Breakout 环境,并训练一个
DRQN智能体进行200轮游戏。注意
我们建议训练 200 轮(或 400 轮),以便正确训练模型并获得良好的性能,但这可能需要几个小时,具体取决于系统配置。或者,您可以减少训练轮数,这会减少训练时间,但会影响智能体的性能。
预期的输出结果将接近此处显示的内容。由于游戏的随机性和 epsilon-greedy 算法在选择动作时的随机性,您可能会得到稍微不同的值:
[Episode 0] - Average Score: 0.0
[Episode 50] - Average Score: 0.43137254901960786
[Episode 100] - Average Score: 0.4
[Episode 150] - Average: 0.54
Average Score: 0.53
注意
本活动的解决方案可以在第 756 页找到。
在接下来的章节中,我们将看到如何通过将注意力机制添加到 DRQN 中来提高模型的性能,并构建 DARQN 模型。
注意力机制和 DARQN 介绍
在前一部分,我们看到将 RNN 模型添加到 DQN 中有助于提高其性能。RNN 因处理序列数据(如时间信息)而闻名。在我们的案例中,我们使用了 CNN 和 RNN 的组合,帮助我们的强化学习智能体更好地理解来自游戏的图像序列。
然而,RNN 模型在分析长序列输入或输出数据时确实存在一些局限性。为了解决这一问题,研究人员提出了一种叫做注意力机制的技术,这也是深度注意力递归 Q 网络(DARQN)的核心技术。DARQN 模型与 DRQN 模型相同,只是增加了一个注意力机制。为了更好地理解这个概念,我们将通过一个应用实例:神经翻译。神经翻译是将文本从一种语言翻译成另一种语言的领域,例如将莎士比亚的戏剧(原文为英语)翻译成法语。
序列到序列模型最适合此类任务。它们包括两个组件:编码器和解码器。它们都是 RNN 模型,如 LSTM 或 GRU 模型。编码器负责处理输入数据中的一系列词语(在我们之前的例子中,这将是一个英语单词的句子),并生成一个被称为上下文向量的编码版本。解码器将这个上下文向量作为输入,并预测相关的输出序列(在我们的例子中是法语单词的句子):

图 10.10: 序列到序列模型
上下文向量的大小是固定的。它是输入序列的编码版本,只包含相关信息。你可以将它视为输入数据的总结。然而,这个向量的固定大小限制了模型从长序列中保留足够相关信息的能力。它往往会“遗忘”序列中的早期元素。但在翻译的情况下,句子的开头通常包含非常重要的信息,例如其主语。
注意力机制不仅为解码器提供上下文向量,还提供编码器的前一个状态。这使得解码器能够找到前一个状态、上下文向量和所需输出之间的相关关系。在我们的例子中,这有助于理解输入序列中两个远离彼此的元素之间的关系:

图 10.11: 带有注意力机制的序列到序列模型
TensorFlow 提供了一个Attention类。它的输入是一个形状为[output, states]的张量。最好通过使用函数式 API 来使用它,其中每个层作为一个函数接受输入并提供输出结果。在这种情况下,我们可以简单地从 GRU 层提取输出和状态,并将它们作为输入提供给注意力层:
from tensorflow.keras.layers import GRU, Attention
out, states = GRU(512, return_sequences=True, \
return_state=True)(input)
att = Attention()([out, states])
要构建 DARQN 模型,我们只需要将注意力机制添加到 DRQN 模型中。
让我们将这个注意力机制添加到我们之前的 DRQN 代理(在活动 10.02,训练 DRQN 玩 Breakout中),并在下一个活动中构建 DARQN 模型。
活动 10.03:训练 DARQN 玩 Breakout
在本次活动中,我们将通过向之前的 DRQN 中添加一个注意力机制来构建 DARQN 模型(来自活动 10.02,训练 DRQN 玩 Breakout)。然后,我们将训练该模型来玩 Breakout 游戏,并分析代理的表现。以下说明将帮助你完成此活动:
-
导入相关的包(
gym、tensorflow和numpy)。 -
重塑训练集和测试集。
-
创建一个
DARQN类,包含以下方法:build_model()方法,它将实例化一个结合了 CNN 和 RNN 的模型(类似于练习 10.03,使用 TensorFlow 设计 CNN 和 RNN 模型的组合);get_action()方法,它将应用 epsilon-greedy 算法选择要执行的动作;add_experience()方法,用于将游戏中获得的经验存储到内存中;replay()方法,它将通过从内存中采样经验并训练 DARQN 模型来执行经验重放,并在每两次回合后保存模型;以及update_epsilon()方法,用于逐渐减少 epsilon 值以进行 epsilon-greedy。 -
使用
initialize_env()函数初始化环境,返回初始状态,False作为 done 标志,以及0作为初始奖励。 -
使用
preprocess_state()函数对图像进行以下预处理:裁剪图像以去除不必要的部分,转换为灰度图像,并将图像调整为正方形。 -
创建一个名为
combine_images()的函数,用于堆叠一系列图像。 -
使用
play_game()函数进行游戏直到结束,然后存储经验和累积奖励。 -
通过若干回合进行迭代,代理将进行游戏并使用
train_agent()函数执行经验重放。 -
实例化一个 Breakout 环境并训练一个
DARQN代理玩这个游戏,共进行400个回合。注意
我们建议训练 400 个回合,以便正确训练模型并获得良好的性能,但这可能会根据系统配置花费几个小时。或者,你可以减少回合数,这将减少训练时间,但会影响代理的表现。
输出结果将接近你看到的这里。由于游戏的随机性以及 epsilon-greedy 算法在选择行动时的随机性,你可能会看到略有不同的值:
[Episode 0] - Average Score: 1.0
[Episode 50] - Average Score: 2.4901960784313726
[Episode 100] - Average Score: 3.92
[Episode 150] - Average Score: 7.37
[Episode 200] - Average Score: 7.76
[Episode 250] - Average Score: 7.91
[Episode 300] - Average Score: 10.33
[Episode 350] - Average Score: 10.94
Average Score: 10.83
注意
此活动的解答可以在第 761 页找到。
摘要
在本章中,我们学习了如何将深度学习技术与 DQN 模型相结合,并训练它来玩 Atari 游戏《Breakout》。我们首先探讨了如何为智能体添加卷积层,以处理来自游戏的截图。这帮助智能体更好地理解游戏环境。
我们进一步改进了模型,在 CNN 模型的输出上添加了一个 RNN。我们创建了一系列图像并将其输入到 LSTM 层。这种顺序模型使得 DQN 智能体能够“可视化”球的方向。这种模型被称为 DRQN。
最后,我们使用了注意力机制并训练了一个 DARQN 模型来玩《Breakout》游戏。该机制帮助模型更好地理解之前相关的状态,并显著提高了其表现。随着新的深度学习技术和模型的设计,该领域仍在不断发展,这些新技术在不断超越上一代模型的表现。
在下一章,你将接触到基于策略的方法和演员-评论员模型,该模型由多个子模型组成,负责根据状态计算行动并计算 Q 值。
第十一章:11. 基于策略的强化学习方法
概述
在本章中,我们将实现不同的基于策略的强化学习方法(RL),如策略梯度法、深度确定性策略梯度(DDPGs)、信任区域策略优化(TRPO)和近端策略优化(PPO)。你将了解一些算法背后的数学原理,还将学习如何在 OpenAI Gym 环境中为 RL 智能体编写策略代码。在本章结束时,你不仅将对基于策略的强化学习方法有一个基础的理解,而且还将能够使用前面提到的基于策略的 RL 方法创建完整的工作原型。
介绍
本章的重点是基于策略的强化学习方法(RL)。然而,在正式介绍基于策略的强化学习方法之前,我们先花些时间理解它们背后的动机。让我们回到几百年前,那时地球大部分地方还未被探索,地图也不完整。那个时候,勇敢的水手们凭借坚定的勇气和不屈的好奇心航行在广阔的海洋上。但是,他们在辽阔的海洋中并非完全盲目。他们仰望夜空寻找方向。夜空中的星星和行星引导着他们走向目的地。不同时间和地点看到的夜空是不同的。正是这些信息,加上精确的夜空地图,指引着这些勇敢的探险家们到达目的地,有时甚至是未知的、未标记的土地。
现在,你可能会问这个故事与强化学习有什么关系。那些水手们并非总是能够获得夜空的地图。这些地图是由环球旅行者、水手、天文爱好者和天文学家们经过数百年创造的。水手们实际上曾经一度在盲目中航行。他们在夜间观察星星,每次转弯时,他们都会标记自己相对于星星的位置。当到达目的地时,他们会评估每个转弯,并找出哪些转弯在航行过程中更为有效。每一艘驶向相同目的地的船只也可以做同样的事情。随着时间的推移,他们对哪些转弯在相对于船只在海上的位置,结合夜空中星星的位置,能更有效地到达目的地有了较为清晰的评估。你可以把它看作是在计算价值函数,通过这种方式,你能知道最优的即时动作。但一旦水手们拥有了完整的夜空地图,他们就可以简单地推导出一套策略,带领他们到达目的地。
你可以将大海和夜空视为环境,而将水手视为其中的智能体。在几百年的时间里,我们的智能体(水手)建立了对环境的模型,从而能够得出一个价值函数(计算船只相对位置),进而引导他们采取最佳的即时行动步骤(即时航行步骤),并帮助他们建立了最佳策略(完整的航行路线)。
在上一章中,你学习了深度递归 Q 网络(DRQN)及其相较于简单深度 Q 网络的优势。你还为非常流行的雅达利电子游戏Breakout建模了一个 DRQN 网络。在本章中,你将学习基于策略的强化学习方法。
我们还将学习策略梯度,它将帮助你实时学习模型。接着,我们将了解一种名为 DDPG 的策略梯度技术,以便理解连续动作空间。在这里,我们还将学习如何编写月球着陆模拟(Lunar Lander)代码,使用OUActionNoise类、ReplayBuffer类、ActorNetwork类和CriticNetwork类等类来理解 DDPG。我们将在本章后面详细了解这些类。最后,我们将学习如何通过使用 TRPO、PPO 和优势演员评论家(A2C)技术来改进策略梯度方法。这些技术将帮助我们减少训练模型的运行成本,从而改进策略梯度技术。
让我们从以下小节开始,学习一些基本概念,如基于值的强化学习(RL)、基于模型的强化学习(RL)、演员-评论家方法、动作空间等。
值基和模型基强化学习简介
虽然拥有一个良好的环境模型有助于预测某个特定动作相对于其他可能动作是否更好,但你仍然需要评估每一个可能状态下的所有可能动作,以便制定出最佳策略。这是一个非平凡的问题,如果我们的环境是一个仿真,且智能体是人工智能(AI),那么计算开销也非常大。将基于模型的学习方法应用到仿真中时,可以呈现如下情景。
以乒乓(图 11.1)为例。(乒乓——发布于 1972 年——是雅达利公司制造的第一批街机电子游戏之一。)现在,让我们看看基于模型的学习方法如何有助于为乒乓制定最佳策略,并探讨其可能的缺点。那么,假设我们的智能体通过观察游戏环境(即,观察每一帧的黑白像素)学会了如何玩乒乓。接下来,我们可以要求智能体根据游戏环境中的某一帧黑白像素来预测下一个可能的状态。但如果环境中有任何背景噪音(例如,背景中播放着一个随机的、无关的视频),我们的智能体也会将其考虑在内。
现在,在大多数情况下,这些背景噪声对我们的规划没有帮助——也就是说,确定最优策略——但仍然会消耗我们的计算资源。以下是Pong游戏的截图:

图 11.1:雅达利 Pong 游戏
基于值的方法优于基于模型的方法,因为在执行从一个状态到另一个状态的转换时,基于值的方法只关心每个动作的价值,基于我们为每个动作预测的累积奖励。它会认为任何背景噪声大多是无关紧要的。基于值的方法非常适合推导最优策略。假设你已经学会了一个动作-值函数——一个 Q 函数。那么,你可以简单地查看每个状态下的最高值,这样就能得出最优策略。然而,基于值的函数仍然可能效率低下。让我用一个例子来解释为什么。在从欧洲到北美,或者从南非到印度南部海岸的旅行中,我们的探索船的最优策略可能只是直接前进。然而,船可能会遇到冰山、小岛或洋流,这些可能会暂时偏离航道。它仍然可能是船只前进的最优策略,但值函数可能会任意变化。所以,在这种情况下,基于值的方法会尝试逼近所有这些任意值,而基于策略的方法可以是盲目的,因此在计算成本上可能更高效。因此,在很多情况下,基于值的函数计算最优策略可能效率较低。
演员-评论员模型简介
所以,我们简要解释了基于值方法和基于模型方法之间的权衡。现在,我们能否以某种方式结合这两者的优点,创建一个它们的混合模型呢?演员-评论员模型将帮助我们实现这一点。如果我们画出一个维恩图(图 11.2),我们会发现演员-评论员模型位于基于值和基于策略的强化学习方法的交集处。它们基本上可以同时学习值函数和策略。我们将在接下来的章节中进一步讨论演员-评论员模型。

图 11.2:不同强化学习方法之间的关系
在实践中,大多数时候,我们尝试基于价值函数所产生的值来学习策略,但实际上我们是同时学习策略和值的。为了结束这部分关于演员-评论家方法的介绍,我想分享一句 Bertrand Russell 的名言。Russell 在他的书《哲学问题》中说:“我们可以不通过实例推导一般命题,而仅凭一般命题的意义就能理解它,尽管一些实例通常是必需的,用以帮助我们弄清楚一般命题的含义。” 这句话值得我们思考。关于如何实现演员-评论家模型的代码将在本章后面介绍。接下来,我们将学习动作空间的内容,我们已经在第一章《强化学习导论》中涉及了它的基本概念。
在前面的章节中,我们已经介绍了动作空间的基本定义和类型。在这里,我们将快速回顾一下动作空间的概念。动作空间定义了游戏环境的特性。让我们看一下以下图示来理解这些类型:

图 11.3:动作空间
动作空间有两种类型——离散和连续。离散动作空间允许离散的输入——例如,游戏手柄上的按钮。这些离散动作可以向左或向右移动,向上或向下移动,向前或向后移动,等等。
另一方面,连续动作空间允许连续输入——例如,方向盘或摇杆的输入。在接下来的章节中,我们将学习如何将策略梯度应用于连续动作空间。
策略梯度
既然我们已经通过上一节中的导航示例阐明了偏好基于策略的方法而非基于价值的方法的动机,那么让我们正式介绍策略梯度。与使用存储缓冲区存储过去经验的 Q 学习不同,策略梯度方法是实时学习的(即它们从最新的经验或动作中学习)。策略梯度的学习是由智能体在环境中遇到的任何情况驱动的。每次梯度更新后,经验都会被丢弃,策略继续前进。让我们看一下我们刚才学到的内容的图示表示:

图 11.4:策略梯度方法的图示解释
一个立即引起我们注意的事情是,策略梯度方法通常比 Q 学习效率低,因为每次梯度更新后,经验都会被丢弃。策略梯度估计器的数学表示如下:

图 11.5:策略梯度估计器的数学表示
在这个公式中,
是随机策略,
是我们在时间点
上的优势估计函数——即所选动作的相对价值估计。期望值,
,表示我们算法中有限样本批次的平均值,在其中我们交替进行采样和优化。这里,
是梯度估计器。
和
变量定义了在时间间隔
时的动作和状态。
最后,策略梯度损失定义如下:

图 11.6:策略梯度损失定义
为了计算优势函数,
,我们需要 折扣奖励 和 基准估计。折扣奖励也称为 回报,它是我们智能体在当前回合中获得的所有奖励的加权和。之所以称为折扣奖励,是因为它关联了一个折扣因子,优先考虑即时奖励而非长期奖励。
本质上是折扣奖励和基准估计之间的差值。
请注意,如果你在理解这个概念时仍然有问题,那也不是大问题。只需尝试抓住整体概念,最终你会理解完整的概念。话虽如此,我还将向你介绍一个简化版的基础策略梯度算法。
我们首先初始化策略参数,
,以及基准值,
:
for iteration=1, 2, 3, … do
Execute the current policy and collect a set of trajectories
At each timestep in each trajectory, compute
the return Rt and the advantage estimate .
Refit the baseline minimizing ,
summed over all trajectories and timesteps.
Update the policy using the policy gradient estimate
end for
一个建议是反复浏览算法,并配合初步解释,来更好地理解策略梯度的概念。但再说一次,首先掌握整体的理解才是最重要的。
在实现实际元素之前,请通过 PyPI 安装 OpenAI Gym 和 Box2D 环境(包括如 Lunar Lander 等环境)。要进行安装,请在终端/命令提示符中输入以下命令:
pip install torch==0.4.1
pip install pillow
pip install gym "gym[box2d]"
现在,让我们使用策略梯度方法来实现一个练习。
练习 11.01:使用策略梯度和演员-评论员方法将航天器着陆在月球表面
在这个练习中,我们将处理一个玩具问题(OpenAI Lunar Lander),并使用基础策略梯度和演员-评论员方法帮助将月球着陆器着陆到 OpenAI Gym Lunar Lander 环境中。以下是实现此练习的步骤:
-
打开一个新的 Jupyter Notebook,导入所有必要的库(
gym,torch和numpy):import gym import torch as T import numpy as np -
定义
ActorCritic类:class ActorCritic(T.nn.Module): def __init__(self): super(ActorCritic, self).__init__() self.transform = T.nn.Linear(8, 128) self.act_layer = T.nn.Linear(128, 4) # Action layer self.val_layer = T.nn.Linear(128, 1) # Value layer self.log_probs = [] self.state_vals = [] self.rewards = []因此,在前面代码中初始化
ActorCritic类时,我们正在创建我们的动作和价值网络。我们还创建了空数组来存储对数概率、状态值和奖励。 -
接下来,创建一个函数,将我们的状态通过各个层并命名为
forward:def forward(self, state): state = T.from_numpy(state).float() state = T.nn.functional.relu(self.transform(state)) state_value = self.val_layer(state) act_probs = T.nn.functional.softmax\ (self.act_layer(state)) act_dist = T.distributions.Categorical(act_probs) action = act_dist.sample() self.log_probs.append(act_dist.log_prob(action)) self.state_vals.append(state_value) return action.item()在这里,我们将状态传递通过值层,经过 ReLU 转换后得到状态值。类似地,我们将状态通过动作层,然后使用 softmax 函数得到动作概率。接着,我们将概率转换为离散值以供采样。最后,我们将对数概率和状态值添加到各自的数组中并返回一个动作项。
-
创建
computeLoss函数,首先计算折扣奖励。这有助于优先考虑即时奖励。然后,我们将按照策略梯度损失方程来计算损失:def computeLoss(self, gamma=0.99): rewards = [] discounted_reward = 0 for reward in self.rewards[::-1]: discounted_reward = reward + gamma \ * discounted_reward rewards.insert(0, discounted_reward) rewards = T.tensor(rewards) rewards = (rewards – rewards.mean()) / (rewards.std()) loss = 0 for log_probability, value, reward in zip\ (self.log_probs, self.state_vals, rewards): advantage = reward – value.item() act_loss = -log_probability * advantage val_loss = T.nn.functional.smooth_l1_loss\ (value, reward) loss += (act_loss + val_loss) return loss -
接下来,创建一个
clear方法,用于在每回合后清除存储对数概率、状态值和奖励的数组:def clear(self): del self.log_probs[:] del self.state_vals[:] del self.rewards[:] -
现在,让我们开始编写主代码,这将帮助我们调用之前在练习中定义的类。我们首先分配一个随机种子:
np.random.seed(0) -
然后,我们需要设置我们的环境并初始化我们的策略:
env = gym.make(""LunarLander-v2"") policy = ActorCritic() optimizer = T.optim.Adam(policy.parameters(), \ lr=0.02, betas=(0.9, 0.999)) -
最后,我们迭代至少
10000次以确保适当收敛。在每次迭代中,我们采样一个动作并获取该动作的状态和奖励。然后,我们基于该动作更新我们的策略,并清除我们的观察数据:render = True np.random.seed(0) running_reward = 0 for i in np.arange(0, 10000): state = env.reset() for t in range(10000): action = policy(state) state, reward, done, _ = env.step(action) policy.rewards.append(reward) running_reward += reward if render and i > 1000: env.render() if done: break print("Episode {}\tReward: {}".format(i, running_reward)) # Updating the policy optimizer.zero_grad() loss = policy.computeLoss(0.99) loss.backward() optimizer.step() policy.clear() if i % 20 == 0: running_reward = running_reward / 20 running_reward = 0现在,当你运行代码时,你将看到每个回合的运行奖励。以下是前 20 个回合的奖励,总代码为 10,000 回合:
Episode 0 Reward: -320.65657506841114 Episode 1 Reward: -425.64874914703705 Episode 2 Reward: -671.2867424162646 Episode 3 Reward: -1032.281198268248 Episode 4 Reward: -1224.3354097571892 Episode 5 Reward: -1543.1792365484055 Episode 6 Reward: -1927.4910808775028 Episode 7 Reward: -2023.4599189797761 Episode 8 Reward: -2361.9002491621986 Episode 9 Reward: -2677.470775357419 Episode 10 Reward: -2932.068423127369 Episode 11 Reward: -3204.4024449864355 Episode 12 Reward: -3449.3136628102934 Episode 13 Reward: -3465.3763860613317 Episode 14 Reward: -3617.162199366013 Episode 15 Reward: -3736.83983321837 Episode 16 Reward: -3883.140249551331 Episode 17 Reward: -4100.137703945375 Episode 18 Reward: -4303.308164747067 Episode 19 Reward: -4569.71587308837 Episode 20 Reward: -4716.304224574078注意
为了方便展示,这里仅展示了前 20 个回合的输出。
要访问该特定部分的源代码,请参阅
packt.live/3hDibst。本部分目前没有在线互动示例,需要在本地运行。
这个输出表示我们的智能体——月球着陆器,已经开始采取行动。负奖励表明一开始,智能体不够聪明,无法采取正确的行动,因此它采取了随机行动,并因此受到了负面奖励。负奖励是惩罚。随着时间的推移,智能体将开始获得正面奖励,因为它开始学习。很快,你会看到游戏窗口弹出,展示月球着陆器的实时进展,如下图所示:

图 11.7:月球着陆器的实时进展
在下一部分,我们将研究 DDPG,它扩展了策略梯度的思想。
深度确定性策略梯度
在本节中,我们将应用 DDPG 技术以理解连续动作空间。此外,我们还将学习如何编写月球着陆模拟程序来理解 DDPG。
注意
我们建议你将本节中给出的所有代码输入到你的 Jupyter 笔记本中,因为我们将在后续的练习 11.02中使用它,创建学习智能体。
我们将在这里使用 OpenAI Gym 的 Lunar Lander 环境来处理连续动作空间。让我们首先导入必要的内容:
import os
import gym
import torch as T
import numpy as np
现在,我们将学习如何定义一些类,如OUActionNoise类、ReplayBuffer类、ActorNetwork类和CriticNetwork类,这些将帮助我们实现 DDPG 技术。在本节结束时,你将获得一个完整的代码库,可以在我们的 OpenAI Gym 游戏环境中应用 DDPG。
Ornstein-Uhlenbeck 噪声
首先,我们将定义一个类,提供一种被称为 Ornstein-Uhlenbeck 噪声的东西。物理学中的 Ornstein–Uhlenbeck 过程用于模拟在摩擦力作用下布朗运动粒子的速度。如你所知,布朗运动是指悬浮在液体或气体中的粒子,由于与同一流体中其他粒子的碰撞而产生的随机运动。Ornstein–Uhlenbeck 噪声提供的是一种具有时间相关性的噪声,并且其均值为 0。由于智能体对模型的了解为零,因此训练它变得很困难。在这种情况下,Ornstein–Uhlenbeck 噪声可以用作生成这种知识的样本。让我们来看一下这个类的代码实现:
class OUActionNoise(object):
def __init__(self, mu, sigma=0.15, theta=.2, dt=1e-2, x0=None):
self.theta = theta
self.mu = mu
self.sigma = sigma
self.dt = dt
self.x0 = x0
self.reset()
def __call__(self):
x = self.x_previous
dx = self.theta * (self.mu –- x) * self.dt + self.sigma \
* np.sqrt(self.dt) * np.random.normal\
(size=self.mu.shape)
self.x_previous = x + dx
return x
def reset(self):
self.x_previous = self.x0 if self.x0 is not None \
else np.zeros_like(self.mu)
在前面的代码中,我们定义了三个不同的函数——即_init_()、_call()_和reset()。在接下来的部分,我们将学习如何实现ReplayBuffer类来存储智能体的过去学习记录。
ReplayBuffer 类
Replay buffer 是我们从 Q-learning 中借用的一个概念。这个缓冲区本质上是一个存储所有智能体过去学习记录的空间,它将帮助我们更好地训练模型。我们将通过定义状态、动作和奖励的记忆大小来初始化该类。所以,初始化看起来会像这样:
class ReplayBuffer(object):
def __init__(self, max_size, inp_shape, nb_actions):
self.memory_size = max_size
self.memory_counter = 0
self.memory_state = np.zeros\
((self.memory_size, *inp_shape))
self.new_memory_state = np.zeros\
((self.memory_size, *inp_shape))
self.memory_action = np.zeros\
((self.memory_size, nb_actions))
self.memory_reward = np.zeros(self.memory_size)
self.memory_terminal = np.zeros(self.memory_size, \
dtype=np.float32)
接下来,我们需要定义store_transition方法。这个方法接受状态、动作、奖励和新状态作为参数,并存储从一个状态到另一个状态的过渡。这里还有一个done标志,用于指示智能体的终止状态。请注意,这里的索引只是一个计数器,我们之前已初始化,它从0开始,当其值等于最大内存大小时:
def store_transition(self, state, action, \
reward, state_new, done):
index = self.memory_counter % self.memory_size
self.memory_state[index] = state
self.new_memory_state[index] = state_new
self.memory_action[index] = action
self.memory_reward[index] = reward
self.memory_terminal[index] = 1 - done
self.memory_counter += 1
最后,我们需要sample_buffer方法,它将用于随机抽样缓冲区:
def sample_buffer(self, bs):
max_memory = min(self.memory_counter, self.memory_size)
batch = np.random.choice(max_memory, bs)
states = self.memory_state[batch]
actions = self.memory_action[batch]
rewards = self.memory_reward[batch]
states_ = self.new_memory_state[batch]
terminal = self.memory_terminal[batch]
return states, actions, rewards, states_, terminal
所以,整个类一目了然,应该是这样的:
DDPG_Example.ipynb
class ReplayBuffer(object):
def __init__(self, max_size, inp_shape, nb_actions):
self.memory_size = max_size
self.memory_counter = 0
self.memory_state = np.zeros((self.memory_size, *inp_shape))
self.new_memory_state = np.zeros\
((self.memory_size, *inp_shape))
self.memory_action = np.zeros\
((self.memory_size, nb_actions))
self.memory_reward = np.zeros(self.memory_size)
self.memory_terminal = np.zeros(self.memory_size, \
dtype=np.float32)
The complete code for this example can be found at https://packt.live/2YNL2BO.
在这一部分,我们学习了如何存储智能体的过去学习记录以更好地训练模型。接下来,我们将更详细地学习我们在本章简介中简要解释过的 actor-critic 模型。
Actor-Critic 模型
接下来,在 DDPG 技术中,我们将定义演员和评论家网络。现在,我们已经介绍了演员-评论家模型,但我们还没有详细讨论过它。可以将演员视为当前的策略,评论家则是价值函数。你可以将演员-评论家模型概念化为一个引导策略。我们将使用全连接神经网络来定义我们的演员-评论家模型。
CriticNetwork类从初始化开始。首先,我们将解释参数。Linear层,我们将使用输入和输出维度对其进行初始化。接下来是全连接层的权重和偏置的初始化。此初始化将权重和偏置的值限制在参数空间的一个非常窄的范围内,我们在-f1到f1之间采样,如下代码所示。这有助于我们的网络更好地收敛。我们的初始层后面跟着一个批量归一化层,这同样有助于更好地收敛网络。我们将对第二个全连接层重复相同的过程。CriticNetwork类还会获取一个动作值。最后,输出是一个标量值,我们接下来将其初始化为常数初始化。
我们将使用学习率为 beta 的Adam优化器来优化我们的CriticNetwork类:
class CriticNetwork(T.nn.Module):
def __init__(self, beta, inp_dimensions,\
fc1_dimensions, fc2_dimensions,\
nb_actions):
super(CriticNetwork, self).__init__()
self.inp_dimensions = inp_dimensions
self.fc1_dimensions = fc1_dimensions
self.fc2_dimensions = fc2_dimensions
self.nb_actions = nb_actions
self.fc1 = T.nn.Linear(*self.inp_dimensions, \
self.fc1_dimensions)
f1 = 1./np.sqrt(self.fc1.weight.data.size()[0])
T.nn.init.uniform_(self.fc1.weight.data, -f1, f1)
T.nn.init.uniform_(self.fc1.bias.data, -f1, f1)
self.bn1 = T.nn.LayerNorm(self.fc1_dimensions)
self.fc2 = T.nn.Linear(self.fc1_dimensions, \
self.fc2_dimensions)
f2 = 1./np.sqrt(self.fc2.weight.data.size()[0])
T.nn.init.uniform_(self.fc2.weight.data, -f2, f2)
T.nn.init.uniform_(self.fc2.bias.data, -f2, f2)
self.bn2 = T.nn.LayerNorm(self.fc2_dimensions)
self.action_value = T.nn.Linear(self.nb_actions, \
self.fc2_dimensions)
f3 = 0.003
self.q = T.nn.Linear(self.fc2_dimensions, 1)
T.nn.init.uniform_(self.q.weight.data, -f3, f3)
T.nn.init.uniform_(self.q.bias.data, -f3, f3)
self.optimizer = T.optim.Adam(self.parameters(), lr=beta)
self.device = T.device(""gpu"" if T.cuda.is_available() \
else ""cpu"")
self.to(self.device)
现在,我们必须为我们的网络编写forward函数。该函数接受一个状态和一个动作作为输入。我们通过这个方法获得状态-动作值。因此,我们的状态经过第一个全连接层,接着是批量归一化和 ReLU 激活函数。激活值通过第二个全连接层,然后是另一个批量归一化层,在最终激活之前,我们考虑动作值。请注意,我们将状态和动作值相加,形成状态-动作值。然后,状态-动作值通过最后一层,最终得到我们的输出:
def forward(self, state, action):
state_value = self.fc1(state)
state_value = self.bn1(state_value)
state_value = T.nn.functional.relu(state_value)
state_value = self.fc2(state_value)
state_value = self.bn2(state_value)
action_value = T.nn.functional.relu(self.action_value(action))
state_action_value = T.nn.functional.relu\
(T.add(state_value, action_value))
state_action_value = self.q(state_action_value)
return state_action_value
所以,最终,CriticNetwork类的结构如下所示:
DDPG_Example.ipynb
class CriticNetwork(T.nn.Module):
def __init__(self, beta, inp_dimensions,\
fc1_dimensions, fc2_dimensions,\
nb_actions):
super(CriticNetwork, self).__init__()
self.inp_dimensions = inp_dimensions
self.fc1_dimensions = fc1_dimensions
self.fc2_dimensions = fc2_dimensions
self.nb_actions = nb_actions
The complete code for this example can be found at https://packt.live/2YNL2BO.
接下来,我们将定义ActorNetwork。它与CriticNetwork类大致相同,但有一些细微而重要的变化。让我们先编写代码,然后再解释:
class ActorNetwork(T.nn.Module):
def __init__(self, alpha, inp_dimensions,\
fc1_dimensions, fc2_dimensions, nb_actions):
super(ActorNetwork, self).__init__()
self.inp_dimensions = inp_dimensions
self.fc1_dimensions = fc1_dimensions
self.fc2_dimensions = fc2_dimensions
self.nb_actions = nb_actions
self.fc1 = T.nn.Linear(*self.inp_dimensions, \
self.fc1_dimensions)
f1 = 1./np.sqrt(self.fc1.weight.data.size()[0])
T.nn.init.uniform_(self.fc1.weight.data, -f1, f1)
T.nn.init.uniform_(self.fc1.bias.data, -f1, f1)
self.bn1 = T.nn.LayerNorm(self.fc1_dimensions)
self.fc2 = T.nn.Linear(self.fc1_dimensions, \
self.fc2_dimensions)
f2 = 1./np.sqrt(self.fc2.weight.data.size()[0])
T.nn.init.uniform_(self.fc2.weight.data, -f2, f2)
T.nn.init.uniform_(self.fc2.bias.data, -f2, f2)
self.bn2 = T.nn.LayerNorm(self.fc2_dimensions)
f3 = 0.003
self.mu = T.nn.Linear(self.fc2_dimensions, \
self.nb_actions)
T.nn.init.uniform_(self.mu.weight.data, -f3, f3)
T.nn.init.uniform_(self.mu.bias.data, -f3, f3)
self.optimizer = T.optim.Adam(self.parameters(), lr=alpha)
self.device = T.device("gpu" if T.cuda.is_available() \
else "cpu")
self.to(self.device)
def forward(self, state):
x = self.fc1(state)
x = self.bn1(x)
x = T.nn.functional.relu(x)
x = self.fc2(x)
x = self.bn2(x)
x = T.nn.functional.relu(x)
x = T.tanh(self.mu(x))
return x
如你所见,这与我们的CriticNetwork类类似。这里的主要区别是,我们没有动作值,并且我们以稍微不同的方式编写了forward函数。请注意,forward函数的最终输出是一个tanh函数,它将我们的输出限制在0和1之间。这对于我们将要处理的环境是必要的。让我们实现一个练习,帮助我们创建一个学习代理。
练习 11.02:创建一个学习代理
在这个练习中,我们将编写我们的Agent类。我们已经熟悉学习智能体的概念,那么让我们看看如何实现一个。这个练习将完成我们一直在构建的 DDPG 示例。在开始练习之前,请确保已经运行了本节中的所有示例代码。
注意
我们假设你已经将前一节中呈现的代码输入到新的笔记本中。具体来说,我们假设你已经在笔记本中编写了导入必要库的代码,并创建了OUActionNoise、ReplayBuffer、CriticNetwork和ActorNetwork类。本练习开始时会创建Agent类。
为了方便起见,本练习的完整代码,包括示例中的代码,可以在packt.live/37Jwhnq找到。
以下是实现此练习的步骤:
-
首先使用
__init__方法,传入 alpha 和 beta,它们分别是我们演员和评论家网络的学习率。然后,传入输入维度和一个名为tau的参数,我们稍后会解释它。接着,我们希望传入环境,它是我们的连续动作空间,gamma 是智能体的折扣因子,之前我们已经讨论过。然后,传入动作数量、记忆的最大大小、两层的大小和批处理大小。接着,初始化我们的演员和评论家。最后,我们将引入噪声和update_params函数:class Agent(object): def __init__(self, alpha, beta, inp_dimensions, \ tau, env, gamma=0.99, nb_actions=2, \ max_size=1000000, l1_size=400, \ l2_size=300, bs=64): self.gamma = gamma self.tau = tau self.memory = ReplayBuffer(max_size, inp_dimensions, \ nb_actions) self.bs = bs self.actor = ActorNetwork(alpha, inp_dimensions, \ l1_size, l2_size, \ nb_actions=nb_actions) self.critic = CriticNetwork(beta, inp_dimensions, \ l1_size, l2_size, \ nb_actions=nb_actions) self.target_actor = ActorNetwork(alpha, inp_dimensions, \ l1_size, l2_size, \ nb_actions=nb_actions) self.target_critic = CriticNetwork(beta, inp_dimensions, \ l1_size, l2_size, \ nb_actions=nb_actions) self.noise = OUActionNoise(mu=np.zeros(nb_actions)) self.update_params(tau=1)update_params函数更新我们的参数,但有一个关键问题。我们基本上有一个动态目标。这意味着我们使用相同的网络来同时计算动作和动作的值,同时在每个回合中更新估计值。由于我们为两者使用相同的参数,这可能会导致发散。为了解决这个问题,我们使用目标网络,它学习值和动作的组合,另一个网络则用于学习策略。我们将定期用评估网络的参数更新目标网络的参数。 -
接下来,我们有
select_action方法。在这里,我们从演员(actor)获取观察结果,并将其通过前馈网络传递。这里的mu_prime基本上是我们添加到网络中的噪声,也称为探索噪声。最后,我们调用actor.train()并返回mu_prime的numpy值:def select_action(self, observation): self.actor.eval() observation = T.tensor(observation, dtype=T.float)\ .to(self.actor.device) mu = self.actor.forward(observation).to(self.actor.device) mu_prime = mu + T.tensor(self.noise(),\ dtype=T.float).to(self.actor.device) self.actor.train() return mu_prime.cpu().detach().numpy() -
接下来是我们的
remember函数,它不言自明。这个函数接收state、action、reward、new_state和done标志,以便将它们存储到记忆中:def remember(self, state, action, reward, new_state, done): self.memory.store_transition(state, action, reward, \ new_state, done) -
接下来,我们将定义
learn函数:def learn(self): if self.memory.memory_counter < self.bs: return state, action, reward, new_state, done = self.memory\ .sample_buffer\ (self.bs) reward = T.tensor(reward, dtype=T.float)\ .to(self.critic.device) done = T.tensor(done).to(self.critic.device) new_state = T.tensor(new_state, dtype=T.float)\ .to(self.critic.device) action = T.tensor(action, dtype=T.float).to(self.critic.device) state = T.tensor(state, dtype=T.float).to(self.critic.device) self.target_actor.eval() self.target_critic.eval() self.critic.eval() target_actions = self.target_actor.forward(new_state) critic_value_new = self.target_critic.forward\ (new_state, target_actions) critic_value = self.critic.forward(state, action) target = [] for j in range(self.bs): target.append(reward[j] + self.gamma\ *critic_value_new[j]*done[j]) target = T.tensor(target).to(self.critic.device) target = target.view(self.bs, 1) self.critic.train() self.critic.optimizer.zero_grad() critic_loss = T.nn.functional.mse_loss(target, critic_value) critic_loss.backward() self.critic.optimizer.step() self.critic.eval() self.actor.optimizer.zero_grad() mu = self.actor.forward(state) self.actor.train() actor_loss = -self.critic.forward(state, mu) actor_loss = T.mean(actor_loss) actor_loss.backward() self.actor.optimizer.step() self.update_params()在这里,我们首先检查我们是否在内存缓冲区中有足够的样本用于学习。因此,如果我们的内存计数器小于批次大小——意味着我们在内存缓冲区中没有足够的样本——我们将直接返回该值。否则,我们将从内存缓冲区中抽取
state(状态)、action(动作)、reward(奖励)、new_state(新状态)和done(结束)标志。一旦采样,我们必须将所有这些标志转换为张量以进行实现。接下来,我们需要计算目标动作,然后使用目标动作状态和新状态计算新的批评者值。然后,我们计算批评者值,这是我们在当前回放缓冲区中遇到的状态和动作的值。之后,我们计算目标。请注意,在我们将gamma与新的批评者值相乘时,如果done标志为0,结果会变为0。这基本上意味着当回合结束时,我们只考虑当前状态的奖励。然后,目标被转换为张量并重塑,以便实现目的。现在,我们可以计算并反向传播我们的批评者损失。接下来,我们对执行者网络做同样的事。最后,我们更新目标执行者和目标批评者网络的参数。 -
接下来,定义
update_params函数:def update_params(self, tau=None): if tau is None: tau = self.tau # tau is 1 actor_params = self.actor.named_parameters() critic_params = self.critic.named_parameters() target_actor_params = self.target_actor.named_parameters() target_critic_params = self.target_critic.named_parameters() critic_state_dict = dict(critic_params) actor_state_dict = dict(actor_params) target_critic_dict = dict(target_critic_params) target_actor_dict = dict(target_actor_params) for name in critic_state_dict: critic_state_dict[name] = tau*critic_state_dict[name]\ .clone() + (1-tau)\ *target_critic_dict[name]\ .clone() self.target_critic.load_state_dict(critic_state_dict) for name in actor_state_dict: actor_state_dict[name] = tau*actor_state_dict[name]\ .clone() + (1-tau)\ *target_actor_dict[name]\ .clone() self.target_actor.load_state_dict(actor_state_dict)在这里,
update_params函数接受一个tau值,它基本上允许我们以非常小的步骤更新目标网络。tau的值通常非常小,远小于1。需要注意的是,我们从tau等于1开始,但后来将其值减少到一个更小的数字。该函数的作用是首先获取批评者、执行者、目标批评者和目标执行者的所有参数名称。然后,它使用目标批评者和目标执行者更新这些参数。现在,我们可以创建 Python 代码的主要部分。 -
如果你已经正确创建了
Agent类,那么,结合前面的示例代码,你可以使用以下代码初始化我们的学习智能体:env = gym.make("LunarLanderContinuous-v2") agent = Agent(alpha=0.000025, beta=0.00025, \ inp_dimensions=[8], tau=0.001, \ env=env, bs=64, l1_size=400, \ l2_size=300, nb_actions=2) for i in np.arange(100): observation = env.reset() action = agent.select_action(observation) state_new, reward, _, _ = env.step(action) observation = state_new env.render() print("Episode {}\tReward: {}".format(i, reward))对于输出,你将看到每一回合的奖励。以下是前 10 回合的输出:
Episode 0 Reward: -0.2911892911560017 Episode 1 Reward: -0.4945150137594737 Episode 2 Reward: 0.5150667951556557 Episode 3 Reward: -1.33324749569461 Episode 4 Reward: -0.9969126433110092 Episode 5 Reward: -1.8466220765944854 Episode 6 Reward: -1.6207456680346013 Episode 7 Reward: -0.4027838988393455 Episode 8 Reward: 0.42631743995534066 Episode 9 Reward: -1.1961709218053898 Episode 10 Reward: -1.0679394471159185
你在前面的输出中看到的奖励在负数和正数之间波动。这是因为到目前为止,我们的智能体一直在从它可以采取的所有动作中随机选择。
注意
要访问这一部分的源代码,请参考packt.live/37Jwhnq。
本节目前没有在线互动示例,需要在本地运行。
在接下来的活动中,我们将让智能体记住它过去的学习内容,并从中学习。以下是游戏环境的样子:

图 11.8:显示 Lunar Lander 在游戏环境中悬停的输出窗口
然而,你会发现 Lander 并没有尝试着陆,而是悬停在我们的游戏环境中的月球表面。这是因为我们还没有让代理学习。我们将在接下来的活动中做到这一点。
在下一个活动中,我们将创建一个代理,帮助学习一个使用 DDPG 的模型。
活动 11.01:创建一个通过 DDPG 学习模型的代理
在这个活动中,我们将实现本节所学,并创建一个通过 DDPG 学习的代理。
注意
我们已经为实际的 DDPG 实现创建了一个 Python 文件,可以通过 from ddpg import * 作为模块导入。该模块和活动代码可以从 GitHub 下载,网址是 packt.live/2YksdXX。
以下是执行此活动的步骤:
-
导入必要的库(
os、gym和ddpg)。 -
首先,我们像之前一样创建我们的 Gym 环境(
LunarLanderContinuous-v2)。 -
使用一些合理的超参数初始化代理,参考 练习 11.02,创建学习代理。
-
设置随机种子,以便我们的实验可重复。
-
创建一个空数组来存储分数;你可以将其命名为
history。至少迭代1000次,每次迭代时,将运行分数变量设置为0,并将done标志设置为False,然后重置环境。然后,当done标志不为True时,执行以下步骤。 -
从观察中选择一个动作,并获取新的
state、reward和done标志。保存observation、action、reward、state_new和done标志。调用代理的learn函数,并将当前奖励添加到运行分数中。将新的状态设置为观察,最后,当done标志为True时,将score附加到history。注意
要观察奖励,我们只需添加一个
print语句。奖励值将类似于前一个练习中的奖励。以下是预期的仿真输出:
![图 11.9:训练 1000 轮后的环境截图]()
图 11.9:训练 1000 轮后的环境截图
注意
本活动的解决方案可以在第 766 页找到。
在下一节中,我们将看到如何改善刚刚实现的策略梯度方法。
改善策略梯度
在本节中,我们将学习一些有助于改善我们在前一节中学习的策略梯度方法的各种方法。我们将学习诸如 TRPO 和 PPO 等技术。
我们还将简要了解 A2C 技术。让我们在下一节中理解 TRPO 优化技术。
信任域策略优化
在大多数情况下,强化学习对权重初始化非常敏感。例如,学习率。如果我们的学习率太高,那么可能发生的情况是,我们的策略更新将我们的策略网络推向参数空间的一个区域,在该区域中,下一个批次收集到的数据会遇到一个非常差的策略。这可能导致我们的网络再也无法恢复。现在,我们将讨论一些新方法,这些方法试图消除这个问题。但在此之前,让我们快速回顾一下我们已经涵盖的内容。
在 策略梯度 部分,我们定义了优势函数的估计器,
,作为折扣奖励与基线估计之间的差异。从直观上讲,优势估计器量化了在某一状态下,代理所采取的行动相较于该状态下通常发生的情况有多好。优势函数的一个问题是,如果我们仅仅根据一批样本使用梯度下降不断更新权重,那么我们的参数更新可能会偏离数据采样的范围。这可能导致优势函数的估计不准确。简而言之,如果我们持续在一批经验上运行梯度下降,我们可能会破坏我们的策略。
确保这一问题不发生的一种方法是确保更新后的策略与旧策略差异不大。这基本上是 TRPO 的核心要点。
我们已经理解了普通策略梯度的梯度估计器是如何工作的:

图 11.10:普通策略梯度方法
下面是 TRPO 的效果:

图 11.11:TRPO 的数学表示
这里唯一的变化是,前面公式中的对数运算符被除以
代替。这就是所谓的 TRPO 目标,优化它将得到与普通策略梯度相同的结果。为了确保新更新的策略与旧策略差异不大,TRPO 引入了一个叫做 KL 约束的限制。
用简单的话来说,这个约束确保我们的新策略不会偏离旧策略太远。需要注意的是,实际的 TRPO 策略提出的是一个惩罚,而不是一个约束。
近端策略优化(PPO)
对于 TRPO,看起来一切都很好,但引入 KL 约束会为我们的策略增加额外的操作成本。为了解决这个问题,并基本上一次性解决普通策略梯度的问题,OpenAI 的研究人员引入了 PPO,我们现在来探讨这个方法。
PPO 背后的主要动机如下:
-
实现的便捷性
-
参数调优的便捷性
-
高效的采样
需要注意的一点是,PPO 方法不使用重放缓冲区来存储过去的经验,而是直接从代理在环境中遇到的情况中进行学习。这也被称为在线学习方法,而前者——使用重放缓冲区存储过去的经验——被称为离线学习方法。
PPO 的作者定义了一个概率比率,
,它基本上是新旧策略之间的概率比率。因此,我们得到以下内容:

图 11.12:旧策略和新策略之间的概率比率
当提供一批采样的动作和状态时,如果该动作在当前策略下比在旧策略下更有可能,那么这个比率将大于1。否则,它将保持在0和1之间。现在,PPO 的最终目标写下来是这样的:

图 11.13:PPO 的最终目标
让我们解释一下。像传统的策略梯度一样,PPO 试图优化期望,因此我们对一批轨迹计算期望算子。现在,这是我们在 TRPO 中看到的修改后的策略梯度目标的最小值,第二部分是它的截断版本。截断操作保持策略梯度目标在
和
之间。这里的
是一个超参数,通常等于0.2。
虽然这个函数乍一看很简单,但它的巧妙之处非常显著。
可以是负数也可以是正数,分别表示负优势估计和正优势估计。这种优势估计器的行为决定了min操作符的工作方式。以下是实际 PPO 论文中clip参数的插图:

图 11.14:clip 参数插图
左侧的图表示优势为正。这意味着我们的动作产生的结果好于预期。右侧的图表示我们的动作产生的结果低于预期回报。
请注意,在左侧的图中,当r过高时,损失趋于平坦。这可能发生在当前策略下的动作比旧策略下的动作更有可能时。在这种情况下,目标函数会在这里被截断,从而确保梯度更新不会超过某个限制。
另一方面,当目标函数为负时,当 r 接近 0 时,损失会趋于平缓。这与在当前策略下比旧策略更不可能执行的动作相关。现在你可能已经能理解截断操作如何将网络参数的更新保持在一个理想的范围内。通过实现 PPO 技术来学习它会是一个更好的方法。所以,让我们从一个练习开始,使用 PPO 来降低我们策略的操作成本。
练习 11.03:使用 PPO 改进月球着陆器示例
在本练习中,我们将使用 PPO 实现月球着陆器示例。我们将遵循几乎与之前相同的结构,如果你已经完成了之前的练习和示例,你将能够轻松跟进这个练习:
-
打开一个新的 Jupyter Notebook,并导入必要的库(
gym、torch和numpy):import gym import torch as T import numpy as np -
按照在 DDPG 示例中的做法设置我们的设备:
device = T.device("cuda:0" if T.cuda.is_available() else "cpu") -
接下来,我们将创建
ReplayBuffer类。在这里,我们将创建数组来存储动作、状态、对数概率、奖励和终止状态:class ReplayBuffer: def __init__(self): self.memory_actions = [] self.memory_states = [] self.memory_log_probs = [] self.memory_rewards = [] self.is_terminals = [] def clear_memory(self): del self.memory_actions[:] del self.memory_states[:] del self.memory_log_probs[:] del self.memory_rewards[:] del self.is_terminals[:] -
现在,我们将定义我们的
ActorCritic类。我们将首先定义action和value层:class ActorCritic(T.nn.Module): def __init__(self, state_dimension, action_dimension, \ nb_latent_variables): super(ActorCritic, self).__init__() self.action_layer = T.nn.Sequential\ (T.nn.Linear(state_dimension, \ nb_latent_variables),\ T.nn.Tanh(),\ T.nn.Linear(nb_latent_variables, \ nb_latent_variables),\ T.nn.Tanh(),\ T.nn.Linear(nb_latent_variables, \ action_dimension),\ T.nn.Softmax(dim=-1)) self.value_layer = T.nn.Sequential\ (T.nn.Linear(state_dimension, \ nb_latent_variables),\ T.nn.Tanh(), \ T.nn.Linear(nb_latent_variables, \ nb_latent_variables),\ T.nn.Tanh(),\ T.nn.Linear(nb_latent_variables, 1)) -
现在,我们将定义方法,从动作空间中采样并评估动作的对数概率、状态值和分布的熵:
# Sample from the action space def act(self, state, memory): state = T.from_numpy(state).float().to(device) action_probs = self.action_layer(state) dist = T.distributions.Categorical(action_probs) action = dist.sample() memory.memory_states.append(state) memory.memory_actions.append(action) memory.memory_log_probs.append(dist.log_prob(action)) return action.item() # Evaluate log probabilities def evaluate(self, state, action): action_probs = self.action_layer(state) dist = T.distributions.Categorical(action_probs) action_log_probs = dist.log_prob(action) dist_entropy = dist.entropy() state_value = self.value_layer(state) return action_log_probs, \ T.squeeze(state_value), dist_entropy最后,
ActorCritic类看起来像这样:Exercise11_03.ipynb class ActorCritic(T.nn.Module): def __init__(self, state_dimension, \ action_dimension, nb_latent_variables): super(ActorCritic, self).__init__() self.action_layer = T.nn.Sequential(T.nn.Linear\ (state_dimension, \ nb_latent_variables),\ T.nn.Tanh(), \ T.nn.Linear(nb_latent_variables, \ nb_latent_variables),\ T.nn.Tanh(),\ T.nn.Linear(nb_latent_variables, \ action_dimension),\ T.nn.Softmax(dim=-1)) The complete code for this example can be found at https://packt.live/2zM1Z6Z. -
现在,我们将使用
__init__()和update()函数定义我们的Agent类。首先让我们定义__init__()函数:class Agent: def __init__(self, state_dimension, action_dimension, \ nb_latent_variables, lr, betas, gamma, \ K_epochs, eps_clip): self.lr = lr self.betas = betas self.gamma = gamma self.eps_clip = eps_clip self.K_epochs = K_epochs self.policy = ActorCritic(state_dimension,\ action_dimension,\ nb_latent_variables)\ .to(device) self.optimizer = T.optim.Adam\ (self.policy.parameters(), \ lr=lr, betas=betas) self.policy_old = ActorCritic(state_dimension,\ action_dimension,\ nb_latent_variables)\ .to(device) self.policy_old.load_state_dict(self.policy.state_dict()) self.MseLoss = T.nn.MSELoss() -
现在让我们定义
update函数:def update(self, memory): # Monte Carlo estimate rewards = [] discounted_reward = 0 for reward, is_terminal in \ zip(reversed(memory.memory_rewards), \ reversed(memory.is_terminals)): if is_terminal: discounted_reward = 0 discounted_reward = reward + \ (self.gamma * discounted_reward) rewards.insert(0, discounted_reward) -
接下来,规范化奖励并将其转换为张量:
rewards = T.tensor(rewards).to(device) rewards = (rewards - rewards.mean()) \ / (rewards.std() + 1e-5) # Convert to Tensor old_states = T.stack(memory.memory_states)\ .to(device).detach() old_actions = T.stack(memory.memory_actions)\ .to(device).detach() old_log_probs = T.stack(memory.memory_log_probs)\ .to(device).detach() # Policy Optimization for _ in range(self.K_epochs): log_probs, state_values, dist_entropy = \ self.policy.evaluate(old_states, old_actions) -
接下来,找到概率比,找到损失并向后传播我们的损失:
# Finding ratio: pi_theta / pi_theta__old ratios = T.exp(log_probs - old_log_probs.detach()) # Surrogate Loss advantages = rewards - state_values.detach() surr1 = ratios * advantages surr2 = T.clamp(ratios, 1-self.eps_clip, \ 1+self.eps_clip) * advantages loss = -T.min(surr1, surr2) \ + 0.5*self.MseLoss(state_values, rewards) \ - 0.01*dist_entropy # Backpropagation self.optimizer.zero_grad() loss.mean().backward() self.optimizer.step() -
使用新权重更新旧策略:
# New weights to old policy self.policy_old.load_state_dict(self.policy.state_dict())所以,在本练习的步骤 6-10中,我们通过初始化我们的策略、优化器和旧策略来定义一个智能体。然后,在
update函数中,我们首先采用状态奖励的蒙特卡洛估计。奖励规范化后,我们将其转换为张量。然后,我们进行
K_epochs次策略优化。在这里,我们需要找到概率比,
,即新策略和旧策略之间的概率比,如前所述。之后,我们找到损失,
,并向后传播我们的损失。最后,我们使用新权重更新旧策略。 -
现在,我们可以像在上一个练习中一样运行模拟,并保存策略以供将来使用:
Exercise11_03.ipynb
env = gym.make("LunarLander-v2")
render = False
solved_reward = 230
logging_interval = 20
update_timestep = 2000
np.random.seed(0)
memory = ReplayBuffer()
agent = Agent(state_dimension=env.observation_space.shape[0],\
action_dimension=4, nb_latent_variables=64, \
lr=0.002, betas=(0.9, 0.999), gamma=0.99,\
K_epochs=4, eps_clip=0.2)
current_reward = 0
avg_length = 0
timestep = 0
for i_ep in range(50000):
state = env.reset()
for t in range(300):
timestep += 1
The complete code for this example can be found at https://packt.live/2zM1Z6Z.
以下是输出的前 10 行:
Episode 0, reward: -8
Episode 20, reward: -182
Episode 40, reward: -154
Episode 60, reward: -175
Episode 80, reward: -136
Episode 100, reward: -178
Episode 120, reward: -128
Episode 140, reward: -137
Episode 160, reward: -140
Episode 180, reward: -150
请注意,我们会在一定间隔保存我们的策略。如果你想稍后加载该策略并从那里运行模拟,这会非常有用。此练习的模拟输出将与图 11.9相同,唯一不同的是这里操作成本已被降低。
在这里,如果你观察奖励之间的差异,由于我们使用了 PPO 技术,每个连续回合中给予的积分要小得多。这意味着学习并没有像在练习 11.01、使用策略梯度和演员-评论员方法将航天器降落在月球表面中那样失控,因为在那个例子中,奖励之间的差异更大。
注
要访问本节的源代码,请参考packt.live/2zM1Z6Z。
本节目前没有在线互动示例,需要在本地运行。
我们几乎涵盖了与基于策略的强化学习相关的所有重要主题。所以,接下来我们将讨论最后一个话题——A2C 方法。
优势演员-评论员方法
我们已经在介绍中学习了演员-评论员方法及其使用原因,并且在我们的编码示例中也见过它的应用。简单回顾一下——演员-评论员方法位于基于价值和基于策略方法的交集处,我们同时更新我们的策略和我们的价值,后者充当着衡量我们策略实际效果的评判标准。
接下来,我们将学习 A2C 是如何工作的:
-
我们首先通过随机权重初始化策略参数,
。 -
接下来,我们使用当前策略进行N步的操作,
,并存储状态、动作、奖励和转移。 -
如果我们到达状态的最终回合,我们将奖励设置为
0;否则,我们将奖励设置为当前状态的值。 -
然后,我们通过从最后一个回合反向循环,计算折扣奖励、策略损失和价值损失。
-
最后,我们应用随机梯度下降(SGD),使用每个批次的平均策略和值损失。
-
从步骤 2开始,重复执行直到达到收敛。
注
我们在这里仅简要介绍了 A2C。该方法的详细描述和实现超出了本书的范围。
我们讨论的第一个编码示例(练习 11.01、使用策略梯度和演员-评论员方法将航天器降落在月球表面)遵循了基本的 A2C 方法。然而,还有另一种技术,叫做异步优势演员-评论员(A3C)方法。记住,我们的策略梯度方法是在线工作的。也就是说,我们只在使用当前策略获得的数据上进行训练,并且不跟踪过去的经验。然而,为了保持我们的数据是独立同分布的,我们需要一个大的转移缓冲区。A3C 提供的解决方案是并行运行多个训练环境,以获取大量训练数据。借助 Python 中的多进程,这实际上在实践中非常快速。
在下一个活动中,我们将编写代码来运行我们在 练习 11.03 中学习的月球着陆器仿真,使用 PPO 改进月球着陆器示例。我们还将渲染环境以查看月球着陆器。为此,我们需要导入 PIL 库。渲染图像的代码如下:
if render:
env.render()
img = env.render(mode = "rgb_array")
img = Image.fromarray(img)
image_dir = "./gif"
if not os.path.exists(image_dir):
os.makedirs(image_dir)
img.save(os.path.join(image_dir, "{}.jpg".format(t)))
让我们从最后一个活动的实现开始。
活动 11.02:加载已保存的策略以运行月球着陆器仿真
在这个活动中,我们将结合之前章节中解释的 RL 多个方面。我们将利用在 练习 11.03 中学到的知识,使用 PPO 改进月球着陆器示例,编写简单代码来加载已保存的策略。这个活动结合了构建工作 RL 原型的所有基本组件——在我们的案例中,就是月球着陆器仿真。
需要执行的步骤如下:
-
打开 Jupyter,并在新笔记本中导入必要的 Python 库,包括 PIL 库来保存图像。
-
使用
device参数设置你的设备。 -
定义
ReplayBuffer、ActorCritic和Agent类。我们已经在前面的练习中定义过这些类。 -
创建月球着陆器环境。初始化随机种子。
-
创建内存缓冲区并初始化代理与超参数,就像在前一个练习中一样。
-
将已保存的策略加载为旧策略。
-
最后,循环遍历你希望的回合数。在每次迭代开始时,将回合奖励初始化为
0。不要忘记重置状态。再执行一个循环,指定max时间戳。获取每个动作所采取的state、reward和done标志,并将奖励加入回合奖励中。 -
渲染环境以查看你的月球着陆器运行情况。
以下是预期的输出:
Episode: 0, Reward: 272 Episode: 1, Reward: 148 Episode: 2, Reward: 249 Episode: 3, Reward: 169 Episode: 4, Reward: 35以下屏幕截图展示了某些阶段的仿真输出:
![图 11.15:展示月球着陆器仿真环境]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_11_15.jpg)
图 11.15:展示月球着陆器仿真环境
完整的仿真输出可以在 packt.live/3ehPaAj 以图像形式找到。
注意
该活动的解决方案可以在第 769 页找到。
总结
在本章中,我们学习了基于策略的方法,主要是值方法(如 Q 学习)的缺点,这些缺点促使了策略梯度的使用。我们讨论了 RL 中基于策略的方法的目的,以及其他 RL 方法的权衡。
你已经了解了帮助模型在实时环境中学习的策略梯度。接下来,我们学习了如何使用演员-评论员模型、ReplayBuffer 类以及奥恩斯坦-乌伦贝克噪声实现 DDPG,以理解连续动作空间。我们还学习了如何通过使用 TRPO 和 PPO 等技术来改进策略梯度。最后,我们简要讨论了 A2C 方法,它是演员-评论员模型的一个高级版本。
此外,在本章中,我们还在 OpenAI Gym 中的月球着陆器环境中进行了一些实验——包括连续和离散动作空间——并编写了我们讨论过的多种基于策略的强化学习方法。
在下一章中,我们将学习一种无梯度的优化方法,用于优化神经网络和基于强化学习的算法。随后,我们将讨论基于梯度的方法的局限性。本章通过遗传算法提出了一种替代梯度方法的优化方案,因为它们能够确保全局最优收敛。我们还将学习使用遗传算法解决复杂问题的混合神经网络。
第十二章:12. 强化学习的进化策略
概述
本章中,我们将识别梯度方法的局限性以及进化策略的动机。我们将分解遗传算法的组成部分,并将其应用于强化学习(RL)。在本章结束时,你将能够将进化策略与传统机器学习方法结合,特别是在选择神经网络超参数时,同时识别这些进化方法的局限性。
引言
在上一章中,我们讨论了各种基于策略的方法及其优势。在本章中,我们将学习无梯度方法,即遗传算法;逐步开发这些算法,并利用它们优化神经网络和基于 RL 的算法。本章讨论了梯度方法的局限性,如在局部最优解处停滞,以及在处理噪声输入时收敛速度较慢。本章通过遗传算法提供了梯度方法的替代优化解决方案,因为遗传算法确保全局最优收敛。你将研究并实现遗传算法的结构,并通过神经网络的超参数选择和网络拓扑结构的演化来实现它们,同时将其与 RL 结合用于平衡车杆活动。使用遗传算法的混合神经网络用于解决复杂问题,如建模等离子体化学反应器、设计分形频率选择性表面或优化生产过程。在接下来的部分中,你将审视梯度方法带来的问题。
梯度方法的问题
在本节中,你将了解基于价值的方法与基于策略的方法的区别,以及在策略搜索算法中使用梯度方法。你将进一步分析在基于策略的方法中使用梯度方法的优缺点,并通过 TensorFlow 实现随机梯度下降,以解决带有两个未知数的立方函数。
强化学习有两种方法:基于价值的方法和基于策略的方法。这些方法用于解决与马尔可夫决策过程(MDPs)和部分可观察马尔可夫决策过程(POMDPs)相关的复杂决策问题。基于价值的方法依赖于通过识别最优价值函数来确定和推导最优策略。像 Q-learning 或 SARSA(λ)这样的算法属于这一类,对于涉及查找表的任务,它们的实现能导致全局最优的回报收敛。由于这些算法依赖于已知的环境模型,因此对于部分可观察或连续空间,使用这些价值搜索方法时无法保证收敛到最优解。
相反,基于策略的方法不是依赖于价值函数来最大化回报,而是使用梯度方法(随机优化)来探索策略空间。基于梯度的方法或策略梯度方法通过使用损失函数将参数化空间(环境)映射到策略空间,从而使 RL 代理能够直接探索整个策略空间或其一部分。其中最常用的方法(将在本节中实现)是梯度下降。
注意
有关梯度下降的进一步阅读,请参考Marbach, 2001的技术论文,链接如下:https://link.springer.com/article/10.1023/A:1022145020786。
梯度方法(随机梯度下降或上升法)的优点是它们适用于 POMDP 或非 MDP 问题,尤其是在解决具有多个约束的机器人问题时。然而,采用基于梯度的方法也有几个缺点。最显著的一个缺点是像REINFORCE和DPG这样的算法只能确定期望回报的局部最优解。当局部最优解被找到时,RL 代理不会进行全局搜索。例如,解决迷宫问题的机器人可能会被困在一个角落,并且会不断尝试在同一位置移动。此外,在处理高回报方差或噪声输入数据时,算法的性能会受到影响,因为它们的收敛速度较慢。例如,当一个机器人臂被编程为捡起并放置一个蓝色部件到托盘中,但桌面颜色带有蓝色调,导致传感器(如摄像头)无法正确识别部件时,算法的表现就会受到干扰。
注意
有关REINFORCE算法的进一步阅读,请参考Williams, 1992的技术论文,链接如下:https://link.springer.com/article/10.1007/BF00992696。
同样,请阅读Silvester, 2014的DPG算法,链接如下:http://proceedings.mlr.press/v32/silver14.pdf。
基于梯度的方法的替代方案是使用无梯度方法,这些方法依赖于进化算法来实现回报的全局最优解。
以下练习将帮助你理解梯度方法在收敛到最优解过程中的潜力以及在方法逐步寻找最优解时所需的漫长过程。你将面对一个数学函数(损失函数),它将输入值,
,映射到输出值,
。目标是确定输入的最优值,以使输出值达到最低;然而,这个过程依赖于每一步,并且存在停留在局部最优解的风险。我们将使用GradientTape()函数来计算梯度,这实际上就是求导的解决方案。这将帮助你理解这种优化策略的局限性。
练习 12.01:使用随机梯度下降法进行优化
本练习旨在使你能够应用梯度方法,最著名的 随机梯度下降法 (SGD),并通过遵循所需步骤收敛到最优解。
以下损失函数有两个未知数,
:

图 12.1:示例损失函数
在 100 步内,使用学习率 0.1 查找
的最优值。
执行以下步骤以完成练习:
-
创建一个新的 Jupyter Notebook。
-
将
tensorflow包导入为tf:import tensorflow as tf -
定义一个输出
的函数:def funct(x,y): return x**2-8*x+y**2+3*y -
定义一个初始化
x和y变量的函数,并将它们初始化为值5和10:def initialize(): x = tf.Variable(5.0) y = tf.Variable(10.0) return x, y x, y= initialize() -
在上述代码片段中,我们使用了十进制格式为
x和y分配初始值,以启动优化过程,因为Variable()构造函数需要具有float32类型的张量。 -
通过选择 TensorFlow 中
keras的SGD来实例化优化器,并输入学习率 0.1:optimizer = tf.keras.optimizers.SGD(learning_rate = 0.1) -
设置一个
100步的循环,其中你计算损失,使用GradientTape()函数进行自动微分,并处理梯度:for i in range(100): with tf.GradientTape() as tape: # Calculate loss function using x and y values loss= funct(x,y) # Get gradient values gradients = tape.gradient(loss, [x, y]) # Save gradients in array without altering them p_gradients = [grad for grad in gradients]在前面的代码中,我们使用了 TensorFlow 的
GradientTape()来计算梯度(本质上是微分解)。我们创建了一个损失参数,当调用该函数时,存储了
值。GradientTape()在调用gradient()方法时激活,后者用于在单次计算中计算多个梯度。梯度被存储在p_gradients数组中。 -
使用
zip()函数将梯度与值聚合:ag = zip(p_gradients, [x,y]) -
打印步骤和
的值:print('Step={:.1f} , z ={:.1f},x={:.1f},y={:.1f}'\ .format(i, loss.numpy(), x.numpy(), y.numpy())) -
使用已处理的梯度应用优化器:
optimizer.apply_gradients(ag) -
运行应用程序。
你将得到以下输出:
![图 12.2:使用 SGD 逐步优化]()
图 12.2:使用 SGD 逐步优化
你可以在输出中看到,从 Step=25 开始,
的值没有变化;因此,它们被认为是相应损失函数的最优值。
通过打印输入和输出的步骤和值,你可以观察到算法在 100 步之前就收敛到最优值
。然而,你可以观察到问题是与步骤相关的:如果优化在全局最优收敛之前停止,得到的解将是次优解。
注意
要访问此特定部分的源代码,请参考 packt.live/2C10rXD。
你也可以在 packt.live/2DIWSqc 在线运行此示例。
这项练习帮助你理解并应用 SGD 来求解损失函数,提升了你的分析能力以及使用 TensorFlow 编程的技能。这将有助于你选择优化算法,让你理解基于梯度方法的局限性。
在本节中,我们探讨了梯度方法在强化学习(RL)算法中的优缺点,识别了它们在决策过程中的适用问题类型。示例展示了梯度下降法的简单应用,通过使用 SGD 优化算法在 TensorFlow 中找到了两个未知数的最优解。在下一节中,我们将探讨一种不依赖梯度的优化方法:遗传算法。
遗传算法简介
由于梯度方法的一个问题是解决方案可能会卡在某个局部最优点,其他方法如不依赖梯度的算法可以作为替代方案。在本节中,你将学习关于不依赖梯度的方法,特别是进化算法(例如遗传算法)。本节概述了遗传算法实现的步骤,并通过练习教你如何实现进化算法来求解上一节给出的损失函数。
当存在多个局部最优解或需要进行函数优化时,推荐使用不依赖梯度的方法。这些方法包括进化算法和粒子群优化。此类方法的特点是依赖于一组优化解,这些解通常被称为种群。方法通过迭代搜索找到一个良好的解或分布,从而解决问题或数学函数。寻找最优解的模式基于达尔文的自然选择范式以及遗传进化的生物学现象。进化算法从生物进化模式中汲取灵感,如突变、繁殖、重组和选择。粒子群算法受到群体社会行为的启发,比如蜜蜂巢或蚁群,在这些群体中,单一的解被称为粒子,能够随着时间演化。
自然选择的前提是遗传物质(染色体)以某种方式编码了物种的生存。物种的进化依赖于它如何适应外部环境以及父母传递给子代的信息。在遗传物质中,不同代之间会发生变异(突变),这些变异可能导致物种成功或不成功地适应环境(尤其在恶劣环境下)。因此,遗传算法有三个步骤:选择、繁殖(交叉)和突变。
演化算法通过创建一个原始解的种群、选择一个子集,并使用重组或突变来获得不同的解来进行处理。这一新解集可以部分或完全替代原始解集。为了实现替代,这些解会经历一个基于适应度分析的选择过程。这增加了更适合用于开发新解集的解的机会。
除了解的开发外,演化算法还可以通过使用概率分布来进行参数适应。仍然会生成种群;然而,使用适应度方法来选择分布的参数,而不是实际的解。在确定新参数后,新的分布将用于生成新的解集。以下是一些参数选择的策略:
-
在估算出原始种群的参数梯度后,使用自然梯度上升,也称为自然进化策略(NESes)。
-
选择具有特定参数的解,并使用该子集的均值来寻找新的分布均值,这被称为交叉熵优化(CEO)。
-
根据每个解的适应度赋予权重,使用加权平均值作为新的分布均值 —— 协方差矩阵适应进化策略(CMAESes)。
演化策略中发现的一个主要问题是,实现解的适应度可能会在计算上昂贵且噪声较大。
遗传算法(GAs)保持解种群,并通过多个方向进行搜索(通过染色体),进一步促进这些方向上的信息交换。算法最常见的实现是字符串处理,字符串可以是二进制或基于字符的。主要的两个操作是突变和交叉。后代的选择基于解与目标(目标函数)的接近程度,这表示它们的适应度。
总体而言,遗传算法(GAs)有以下几个步骤:
-
种群创建。
-
适应度得分的创建并分配给种群中的每个解。
-
选择两个父代进行繁殖,依据适应度得分(可能是表现最好的解)。
-
通过结合和重新组织两个父代的代码,创建两个子代解。
-
应用随机突变。
-
孩子代的生成会重复进行,直到达到新的种群规模,并为种群分配权重(适应度得分)。
-
该过程将重复进行,直到达到最大代数或实现目标性能。
我们将在本章中进一步详细查看这些步骤。
在梯度算法和遗传算法之间有许多差异,其中一个差异是开发过程。基于梯度的算法依赖于微分,而遗传算法则使用选择、繁殖和变异等遗传过程。以下练习将使你能够实现遗传算法并评估其性能。你将使用一个简单的遗传算法在 TensorFlow 中进行优化,找到tensorflow_probability包的最佳解决方案。
练习 12.02:使用遗传算法实现固定值和均匀分布优化
在本练习中,你仍然需要像前一个练习那样求解以下函数:

图 12.3:样本损失函数
找到种群大小为 100 时的
的最优值,初始值为
,然后扩展到从类似于基于梯度方法的分布中随机抽样。
本练习的目标是让你分析应用遗传算法(GAs)和梯度下降方法的差异,从一对变量和多种潜在解决方案开始。该算法通过应用选择、交叉和变异来帮助优化问题,达到最优或接近最优的解决方案。此外,你将从一个均匀分布中抽样 100 个样本的值
。在本练习结束时,你将评估从固定变量开始和从分布中抽样之间的差异:
-
创建一个新的 Jupyter Notebook。
-
导入
tensorflow包,并下载和导入tensorflow_probability:import tensorflow as tf import tensorflow_probability as tfp -
定义一个函数,输出
:def funct(x,y): return x**2-8*x+y**2+3*y -
通过定义值为 5 和 10 的
变量来确定初始步长:initial_position = (tf.Variable(5.0), tf.Variable(10.0)) -
通过选择名为
differential_evolution_minimize的tensorflow_probability优化器来实例化优化器:optimizer1 = tfp.optimizer.differential_evolution_minimize\ (funct, initial_position = initial_position, \ population_size = 100, \ population_stddev = 1.5, seed = 879879) -
使用
objective_value和position函数打印
的最终值:print('Final solution: z={:.1f}, x={:.1f}, y={:.1f}'\ .format(optimizer1.objective_value.numpy(),\ optimizer1.position[0].numpy(), \ optimizer1.position[1].numpy())) -
运行应用程序。你将获得以下输出。你可以观察到最终的值与图 12.2中
Step=25.0的值是相同的:Final solution: z=-18.2, x=4.0, y=-1.5在本练习中,将显示最终的最优解。不需要额外的优化步骤来达到与基于梯度的方法相同的解决方案。你可以看到,你使用的代码行数更少,并且算法收敛所需的时间更短。
对于均匀优化,修改代码的步骤如下:
-
导入
random包:import random -
初始化种群大小,并通过从种群大小的随机均匀分布中抽样
变量来创建初始种群:size = 100 initial_population = (tf.random.uniform([size]), \ tf.random.uniform([size])) -
使用相同的优化器,将
initial_position参数更改为initial_population;使用相同的种子:optimizer2 = tfp.optimizer.differential_evolution_minimize\ (funct, initial_population= initial_population,\ seed=879879) -
使用
objective_value和position函数打印最终值!12:print('Final solution: z={:.1f}, x={:.1f}, y={:.1f}'\ .format(optimizer2.objective_value.numpy(),\ optimizer2.position[0].numpy(),\ optimizer2.position[1].numpy()))输出将如下所示:
Final solution: z=-18.2, x=4.0, y=-1.5
尽管值有所不同,你仍然会得到相同的结果。这意味着我们可以随机采样或选择一组特定的初始值,遗传算法仍然会更快地收敛到最优解,这意味着我们可以通过使用比梯度法更少的代码行来改进我们的代码。
注意
若要访问该特定部分的源代码,请参考packt.live/2MQmlPr。
你也可以在线运行这个例子,访问packt.live/2zpH6hJ。
该解将收敛到最优值,无论初始起点如何,无论是使用固定的输入值还是随机采样的染色体种群。
本节提供了进化算法的概述,解释了进化策略和遗传算法(GA)之间的区别。你有机会使用tensorflow_probabilities包实现差分进化,以优化损失函数的解法,分析了两种不同技术的实现:从固定输入值开始和使用输入值的随机采样。你还可以评估遗传算法与梯度下降方法的实施。遗传算法可以使用独立的起始值,并且其收敛到全局最优解的速度更快,不容易受到梯度下降方法的干扰,而梯度下降方法是逐步依赖的,并且对输入变量更敏感。
在接下来的部分中,我们将基于开发遗传算法的原则,首先从种群创建的角度开始。
组件:种群创建
在上一节中,你已经了解了用于函数优化的进化方法。在本节中,我们将重点讨论种群创建、适应度得分创建以及创建遗传算法的任务。
种群,
,被识别为一组个体或染色体:

图 12.4:种群表达式
这里,s 代表染色体的总数(种群大小),i 是迭代次数。每个染色体是以抽象形式表示的、对所提出问题的可能解决方案。对于二元问题,种群可以是一个随机生成的包含一和零的矩阵。
染色体是输入变量(基因)的组合:

图 12.5:染色体表达式
这里,m 是基因(或变量)的最大数量。
转换为代码后,种群创建可以如下所示:
population = np.zeros((no_chromosomes, no_genes))
for i in range(no_chromosomes):
ones = random.randint(0, no_genes)
population[i, 0:ones] = 1
np.random.shuffle(population[i])
然后,每个染色体将通过适应度函数进行比较:

图 12.6:适应度函数
适应度函数可以通过如下方式转化为代码:
identical_to_target = population == target
函数的输出是一个分数,表示染色体与目标(最优解)之间的接近程度。目标通过最大化适应度函数来表示。有些优化问题依赖于最小化一个成本函数。这个函数可以是数学函数、热力学模型,或者计算机游戏。这可以通过考虑权重较低(分数较低)的染色体,或者将成本函数转化为适应度函数来完成。
一旦适应度函数被识别并定义,进化过程就可以开始。初始种群被生成。初始种群的一个特点是多样性。为了提供这种多样性,元素可以是随机生成的。为了使种群进化,迭代过程从选择适应度最佳的父代开始,进而启动繁殖过程。
练习 12.03:种群创建
在这个练习中,我们将创建一个原始的二进制染色体种群,长度为 5。每个染色体应该包含八个基因。我们将定义一个目标解,并输出每个染色体与目标的相似度。这个练习的目的是让你设计并建立 GA 的第一组步骤,并找到适应目标的二进制解。这个练习类似于将一个控制系统的输出与目标匹配:
-
创建一个新的 Jupyter Notebook。导入
random和numpy库:import random import numpy as np -
创建一个生成随机种群的函数:
# create function for random population def original_population(chromosomes, genes): #initialize the population with zeroes population = np.zeros((chromosomes, genes)) #loop through each chromosome for i in range(chromosomes): #get random no. of ones to be created ones = random.randint(0, genes) #change zeroes to ones population[i, 0:ones] = 1 #shuffle rows np.random.shuffle(population[i]) return population -
定义一个创建目标解的函数:
def create_target_solution(gene): #assume that there is an equal number of ones and zeroes counting_ones = int(gene/2) # build array with equal no. of ones and zeros target = np.zeros(gene) target[0:counting_ones] = 1 # shuffle the array to mix zeroes and ones np.random.shuffle(target) return target -
定义一个函数来计算每个染色体的适应度权重:
def fitness_function(target,population): #create an array of true/false compared to the reference identical_to_target = population == target #sum no. of genes that are identical fitness_weights = identical_to_target.sum(axis = 1) return fitness_weights在前面的代码中,你正在将种群中的每个染色体与目标进行比较,并将相似度以布尔值的形式记录下来——如果相似则为
True,如果不同则为False,这些值保存在名为identical_to_target的矩阵中。统计所有为True的元素,并将它们作为权重输出。 -
初始化种群,包含
5个染色体和8个基因,并计算weights:#population of 5 chromosomes, each having 8 genes population = original_population(5,8) target = create_target_solution(8) weights = fitness_function(target,population)在前面的代码中,我们根据三个开发的函数计算
population、target和weights。 -
使用
for循环打印目标解、染色体的索引、染色体和权重:print('\n target:', target) for i in range(len(population)): print('Index:', i, '\n chromosome:', population[i],\ '\n similarity to target:', weights[i]) -
运行程序,你将得到类似如下的输出,因为种群元素是随机化的:
target: [0\. 0\. 1\. 1\. 1\. 0\. 0\. 1.] Index: 0 chromosome: [1\. 1\. 1\. 1\. 1\. 0\. 1\. 1.] similarity to target: 5 Index: 1 chromosome: [1\. 0\. 1\. 1\. 1\. 0\. 0\. 0.] similarity to target: 6 Index: 2 chromosome: [1\. 0\. 0\. 0\. 0\. 0\. 0\. 0.] similarity to target: 3 Index: 3 chromosome: [0\. 0\. 0\. 1\. 1\. 0\. 1\. 0.] similarity to target: 5 Index: 4 chromosome: [1\. 0\. 0\. 1\. 1\. 1\. 0\. 1.] similarity to target: 5
你会注意到,每个染色体都会与目标进行比较,并且相似度(基于适应度函数)会被打印出来。
注意
要访问此特定部分的源代码,请参阅packt.live/2zrjadT。
你也可以在线运行这个例子,网址是packt.live/2BSSeEG。
本节展示了遗传算法开发的第一步:生成随机种群、为种群中的每个元素(染色体)分配适应度分数,以及获得与目标最匹配的元素数量(在此情况下与最优解的相似度最高)。接下来的章节将扩展代码生成,直到达到最优解。为了实现这一点,在下一节中,你将探索用于繁殖过程的父代选择。
组成部分:父代选择
前一节展示了种群的概念;我们讨论了创建目标解并将其与种群中的元素(染色体)进行比较。这些概念已在一个练习中实现,接下来将在本节继续。在本节中,你将探索选择的概念,并实现两种选择策略。
对于繁殖过程(这是遗传算法的核心部分,因为它依赖于创建更强的后代染色体),有三个步骤:
-
父代选择
-
混合父代以创建新的子代(交叉)
-
用子代替代种群中的父代
选择本质上是选择两个或更多父代进行混合过程。一旦选择了适应度标准,就需要决定如何进行父代选择,以及将从父代中产生多少子代。选择是进行遗传进化的重要步骤,因为它涉及确定适应度最高的子代。选择最佳个体的最常见方法是通过“适者生存”。这意味着算法将逐步改进种群。遗传算法的收敛性依赖于选择更高适应度的染色体的程度。因此,收敛速度高度依赖于染色体的成功选择。如果优先选择适应度最高的染色体,可能会找到一个次优解;如果候选染色体的适应度始终较低,那么收敛将非常缓慢。
可用的选择方法如下:
-
自上而下配对:这是指创建一个染色体列表,并将其两两配对。奇数索引的染色体与偶数索引的染色体配对,从而生成母-父对。列表顶部的染色体会被选中。
-
随机选择:这涉及使用均匀分布的数字生成器来选择父代。
-
随机加权选择或轮盘赌法:这涉及到计算染色体相对于整个种群的适应度概率。父代的选择是随机进行的。概率(权重)可以通过排名或适应度来确定。第一种方法(见图 12.7)依赖于染色体的排名 (
),它可以作为染色体在种群列表中的索引,
表示所需的染色体数量(父代):![图 12.7:基于排名的概率]()
图 12.7:基于排名的概率
第二种方法(见图 12.8)依赖于染色体的适应度 (
) 与整个种群适应度之和的比较(
):

图 12.8:基于染色体适应度的概率
另外,概率(见图 12.9)也可以基于染色体的适应度 (
) 与种群中最高适应度的比较来计算 (
)。在所有这些情况下,概率将与随机选择的数字进行比较,以识别具有最佳权重的父代:

图 12.9:基于种群中最高适应度的概率
- 锦标赛选择法:该方法基于从染色体子集中随机选择染色体,其中适应度最高的染色体被选为父代。这个过程会重复,直到确定所需数量的父代。
轮盘赌和锦标赛技术是遗传算法中最常用的选择方法,因为它们受到生物过程的启发。轮盘赌方法的问题是它可能会有噪音,而且根据所使用的选择类型,收敛速度可能会受到影响。锦标赛方法的一个优点是它可以处理大规模种群,从而实现更平滑的收敛。轮盘赌方法用于在种群中加入随机元素,而当你希望识别与目标最相似的父代时,则使用锦标赛方法。以下练习将帮助你实现锦标赛和轮盘赌技术,并评估你对它们的理解。
练习 12.04:实现锦标赛和轮盘赌选择技术
在本练习中,你将实现锦标赛选择和轮盘选择方法,针对的是 Exercise 12.02, Implementing Fixed Value and Uniform Distribution Optimization Using GAs 中的二进制染色体种群。每个染色体应该包含八个基因。我们将定义一个目标解决方案,并打印出两组父母:一组基于锦标赛方法,另一组基于轮盘选择,从剩余种群中选出。每次选择父母后,适应度排名都将设为最小值:
-
创建一个新的 Jupyter Notebook,导入
random和numpy库:import random import numpy as np -
创建一个用于生成随机种群的函数:
# create function for random population def original_population(chromosomes, genes): #initialize the population with zeroes population = np.zeros((chromosomes, genes)) #loop through each chromosome for i in range(chromosomes): #get random no. of ones to be created ones = random.randint(0, genes) #change zeroes to ones population[i, 0:ones] = 1 #shuffle rows np.random.shuffle(population[i]) return population -
定义一个函数,用于创建目标解决方案:
def create_target_solution(gene): #assume that there is an equal number of ones and zeroes counting_ones = int(gene/2) # build array with equal no. of ones and zeros target = np.zeros(gene) target[0:counting_ones] = 1 # shuffle the array to mix zeroes and ones np.random.shuffle(target) return target -
定义一个函数,用于计算每个染色体的适应度权重:
def fitness_function(target,population): #create an array of true/false compared to the reference identical_to_target = population == target #sum no. of genes that are identical fitness_weights = identical_to_target.sum(axis = 1) return fitness_weights -
定义一个函数,用于选择权重最高(适应度评分最高)的父母对。由于种群规模缩小,染色体之间的竞争更加激烈。这个方法也被称为锦标赛选择:
# select the best parents def select_parents(population, weights): #identify the parent with the highest weight parent1 = population[np.argmax(weights)] #replace weight with the minimum number weights[np.argmax(weights)] = 0 #identify the parent with the second-highest weight parent2 = population[np.argmax(weights)] return parent1, parent2 -
创建一个轮盘函数,通过从均匀分布中选择一个随机数来实现:
def choice_by_roulette(sorted_population, fitness): normalised_fitness_sum = 0 #get a random draw probability draw = random.uniform(0,1) prob = [] -
在函数中,计算所有适应度评分的总和:
for i in range(len(fitness)): normalised_fitness_sum += fitness[i] -
计算染色体的适应度概率,与所有适应度评分的总和以及与适应度最高的染色体相比:
ma = 0 n = 0 # calculate the probability of the fitness selection for i in range(len(sorted_population)): probability = fitness[i]/normalised_fitness_sum #compare fitness to the maximum fitness and track it prob_max = fitness[i]/np.argmax(fitness) prob.append(probability) if ma < prob_max: ma = prob_max n = i -
遍历所有染色体,选择适应度概率更高的父母,条件是其适应度评分总和高于
draw,或者其适应度评分比最大适应度评分的父母概率更高:for i in range(len(sorted_population)): if draw <= prob[i]: fitness[i] = 0 return sorted_population[i], fitness else: fitness[n] = 0 return sorted_population[n], fitness -
初始化
population,计算target和适应度评分,并打印出评分和target:population = original_population(5,8) target = create_target_solution(8) weights = fitness_function(target,population) print(weights) print('\n target:', target)你将会得到类似于这样的输出:
[5 1 5 3 4] -
应用第一个选择方法,并打印出父母和新的评分:
print('\n target:', target) parents = select_parents(population,weights) print('Parent 1:', parents[0],'\nParent 2:', parents[1]) print(weights)你将会看到锦标赛选择过程的类似输出:
target: [0\. 1\. 1\. 1\. 1\. 0\. 0\. 0.] Parent 1: [1\. 1\. 1\. 1\. 1\. 0\. 1\. 1.] Parent 2: [1\. 1\. 1\. 1\. 1\. 1\. 1\. 0.] [0 1 5 3 4]你可以观察到,对于父母 1,分数已被替换为
0。对于父母 2,分数保持不变。 -
使用轮盘函数选择下一个父母对,并打印出父母和权重:
parent3, weights = choice_by_roulette(population, weights) print('Parent 3:', parent3, 'Weights:', weights) parent4, weights = choice_by_roulette(population, weights) print('Parent 4:', parent4,'Weights:', weights)你将会看到类似于这样的输出:
0.8568696148662779 [0.0, 0.07692307692307693, 0.38461538461538464, 0.23076923076923078, 0.3076923076923077] Parent 3: [1\. 1\. 1\. 1\. 1\. 1\. 1\. 0.] Weights: [0 1 0 3 4] 0.4710306341255527 [0.0, 0.125, 0.0, 0.375, 0.5] Parent 4: [0\. 0\. 1\. 0\. 1\. 1\. 1\. 0.] Weights: [0 1 0 3 0]
你可以看到父母 2 和父母 3 是相同的。这一次,父母的权重被修改为 0。此外,父母 4 被选中,并且它的权重也被改为 0。
注意
若要访问该部分的源代码,请参考 packt.live/2MTsKJO。
你也可以在线运行这个示例,网址是 packt.live/2YrwMhP。
通过这个练习,你实现了一种类似锦标赛的方法,通过选择得分最高的父代,以及轮盘选择技术。同时,你还开发了一种避免重复选择相同染色体的方法。第一组父代是使用第一种方法选择的,而第二种方法用于选择第二组父代。我们还发现需要一种替换索引的方法,以避免重复选择相同的染色体,这是选择过程中可能出现的陷阱之一。这帮助你理解了这两种方法之间的差异,并使你能够将与遗传算法相关的方法从种群生成到选择实际运用。
组件:交叉应用
本节扩展了通过交叉将父代的遗传代码重组到子代中的方法(即通过结合和重新组织两个父代的代码创建两个子代解决方案)。可以使用各种技术来创建新的解决方案,从而生成新的种群。机器学习中两个有效解的二进制信息可以通过一种称为交叉的过程重新组合,这类似于生物遗传交换,其中遗传信息从父代传递到子代。交叉确保了解决方案的遗传物质传递到下一代。
交叉是最常见的繁殖技术或交配方式。在父代(选定染色体)的第一个和最后一个基因之间,交叉点表示二进制代码的分裂点,这些代码将传递给子代(后代):第一个父代交叉点左侧的部分将由第一个子代继承,而第二个父代交叉点右侧的部分将成为第一个子代的一部分。第二个父代的左侧部分与第一个父代的右侧部分结合,形成第二个子代:
child1 = np.hstack((parent1[0:p],parent2[p:]))
child2 = np.hstack((parent2[0:p], parent1[p:]))
有多种交叉技术,如下所示:
-
单点交叉(你可以在前面的代码中看到)涉及在一个点上分割父代的遗传代码,并将第一部分传递给第一个子代,第二部分传递给第二个子代。传统遗传算法使用这种方法;交叉点对于两个染色体是相同的,并且是随机选择的。
-
两点交叉涉及两个交叉点,影响两个父代之间的基因交换。引入更多的交叉点可能会降低遗传算法的性能,因为遗传信息会丧失。然而,采用两点交叉可以更好地探索状态或参数空间。
-
多点交叉涉及多个分裂。如果分裂次数是偶数,那么分裂点是随机选择的,染色体中的各部分会交换。如果次数是奇数,则交换的部分是交替进行的。
-
均匀交叉涉及随机选择(如同抛硬币一样)一个父代,提供染色体(基因)中的某个元素。
-
三父代交叉涉及对比两个父代的每个基因。如果它们的值相同,子代继承该基因;如果不同,子代从第三个父代继承该基因。
请参考以下代码示例:
def crossover_reproduction(parents, population):
#define parents separately
parent1 = parents[0]
parent2 = parents[1]
#randomly assign a point for cross-over
p = random.randrange(0, len(population))
print("Crossover point:", p)
#create children by joining the parents at the cross-over point
child1 = np.hstack((parent1[0:p],parent2[p:]))
child2 = np.hstack((parent2[0:p], parent1[p:]))
return child1, child2
在前面的代码中,我们定义了两个父代之间的交叉函数。我们分别定义了父代,然后随机指定一个交叉点。接着,我们定义了通过在定义的交叉点将父代结合起来创建子代。
在接下来的练习中,你将继续实现遗传算法的组件,创建子代染色体。
练习 12.05:为新一代实施交叉
在这个练习中,我们将实现两个父代之间的交叉,以生成新的子代。按照练习 12.04:实现锦标赛和轮盘赌中的步骤,并使用权重最高的染色体,我们将应用单点交叉来创建第一组新的子代:
-
创建一个新的 Jupyter Notebook。导入
random和numpy库:import random import numpy as np -
创建一个随机种群的函数:
def original_population(chromosomes, genes): #initialize the population with zeroes population = np.zeros((chromosomes, genes)) #loop through each chromosome for i in range(chromosomes): #get random no. of ones to be created ones = random.randint(0, genes) #change zeroes to ones population[i, 0:ones] = 1 #shuffle rows np.random.shuffle(population[i]) return population如你在前面的代码中所见,我们已经创建了一个
population函数。 -
定义一个函数来创建目标解:
def create_target_solution(gene): #assume that there is an equal number of ones and zeroes counting_ones = int(gene/2) # build array with equal no. of ones and zeros target = np.zeros(gene) target[0:counting_ones] = 1 # shuffle the array to mix zeroes and ones np.random.shuffle(target) return target -
定义一个函数来计算每个染色体的适应度权重:
def fitness_function(target,population): #create an array of true/false compared to the reference identical_to_target = population == target #sum no. of genes that are identical fitness_weights = identical_to_target.sum(axis = 1) return fitness_weights -
定义一个函数来选择具有最高权重(最高适应度得分)的父代对。由于种群较小,染色体之间的竞争更为激烈。此方法也被称为锦标赛选择:
# select the best parents def select_parents(population, weights): #identify the parent with the highest weight parent1 = population[np.argmax(weights)] #replace weight with the minimum number weights[np.argmax(weights)] = 0 #identify the parent with the second-highest weight parent2 = population[np.argmax(weights)] return parent1, parent2 -
定义一个使用随机选择的交叉点的交叉函数:
def crossover_reproduction(parents, population): #define parents separately parent1 = parents[0] parent2 = parents[1] #randomly assign a point for cross-over p = random.randrange(0, len(population)) print("Crossover point:", p) #create children by joining the parents at the cross-over point child1 = np.hstack((parent1[0:p],parent2[p:])) child2 = np.hstack((parent2[0:p], parent1[p:])) return child1, child2 -
初始化种群,设置
5个染色体和8个基因,并计算权重:population = original_population(5,8) target = create_target_solution(8) weights = fitness_function(target,population) -
打印
目标解:print('\n target:', target)输出结果如下:
target: [1\. 0\. 0\. 1\. 1\. 0\. 1\. 0.] -
选择权重最高的父代并打印最终选择:
parents = select_parents(population,weights) print('Parent 1:', parents[0],'\nParent 2:', parents[1])输出结果如下:
Parent 1: [1\. 0\. 1\. 1\. 1\. 0\. 1\. 1.] Parent 2: [1\. 0\. 0\. 0\. 0\. 0\. 0\. 0.] -
应用
crossover函数并打印子代:children = crossover_reproduction(parents,population) print('Child 1:', children[0],'\nChild 2:', children[1])输出结果如下:
Crossover point: 4 Child 1: [1\. 0\. 1\. 1\. 0\. 0\. 0\. 0.] Child 2: [1\. 0\. 0\. 0\. 1\. 0\. 1\. 1.] -
运行应用程序。
你将获得与以下代码片段相似的输出。正如你所见,种群元素是随机化的。检查
Child 1和Child 2的元素是否与Parent 1和Parent 2的元素相同:target: [1\. 0\. 1\. 1\. 0\. 0\. 1\. 0.] . . . Parent 1: [1\. 0\. 1\. 1\. 1\. 0\. 1\. 1.] Parent 2: [0\. 0\. 1\. 1\. 0\. 1\. 0\. 0.] . . . Crossover point: 1 Child 1: [1\. 0\. 1\. 1\. 0\. 1\. 0\. 0.] Child 2: [0\. 0\. 1\. 1\. 1\. 0\. 1\. 1.]. . .
你可以检查Child 1的交叉点之后的元素与Parent 2的数组元素是否相同,且Child 2的元素与Parent 1的数组元素相同。
注意
要访问此特定部分的源代码,请参考packt.live/30zHbup。
你也可以在网上运行这个示例:packt.live/3fueZxx。
在本节中,我们识别了称为交叉的重新组合技术的各种策略。展示了随机生成交叉点的单点交叉的基本实现。在接下来的章节中,我们将讨论遗传算法设计的最后一个元素:群体变异。
组件:群体变异
在前面的章节中,你已经实现了群体生成、父代选择和交叉繁殖。本节将集中讨论随机变异的应用,以及重复生成子代直到达到新的群体大小,并为遗传算法群体分配权重(适应度评分)。本节将包括对变异技术的解释。接下来将介绍可用的变异技术,并讨论群体替换。最后,将提供一个实施变异技术的练习。
梯度方法的一个警告是算法可能会停留在局部最优解。为了防止这种情况发生,可以向解决方案群体引入变异。变异通常发生在交叉过程之后。变异依靠随机分配二进制信息,可以是在染色体集合中,也可以是在整个群体中。变异通过引入群体中的随机变化来提供问题空间的探索途径。这种技术防止了快速收敛,并鼓励探索新的解决方案。在最后几代(最后的世代)或者达到最优解时,变异不再被应用。
有各种变异技术,如下:
-
单点变异(翻转)涉及随机选择不同染色体的基因,并将它们的二进制值更改为它们的相反值(从 0 到 1,从 1 到 0)。
-
交换涉及选择一个父代染色体的两个部分并交换它们,从而生成一个新的子代。
-
你还可以在父代或染色体群体中随机选择一个段落进行反转,所有的二进制值都会变成它们的相反值。
变异的发生由其概率决定。概率定义了在群体内发生变异的频率。如果概率为 0%,那么在交叉后,子代不变;如果发生变异,染色体或整个群体的一部分将被改变。如果概率为 100%,则整个染色体都将被改变。
变异过程发生后,计算新子代的适应度,并将它们包含在群体中。这导致了群体的新一代。根据使用的策略,适应度最低的父代被丢弃,以给新生成的子代腾出位置。
练习 12.06:使用变异开发新的子代
在这个练习中,我们将专注于新一代的开发。我们将再次创建一个新种群,选择两个父代染色体,并使用交叉操作来生成两个子代。然后我们将这两个新染色体添加到种群中,并以 0.05 的概率对整个种群进行突变:
-
创建一个新的 Jupyter Notebook。导入
random和numpy库:import random import numpy as np -
创建一个用于生成随机种群的函数:
def original_population(chromosomes, genes): #initialize the population with zeroes population = np.zeros((chromosomes, genes)) #loop through each chromosome for i in range(chromosomes): #get random no. of ones to be created ones = random.randint(0, genes) #change zeroes to ones population[i, 0:ones] = 1 #shuffle rows np.random.shuffle(population[i]) return population -
定义一个创建目标解的函数:
def create_target_solution(gene): #assume that there is an equal number of ones and zeroes counting_ones = int(gene/2) # build array with equal no. of ones and zeros target = np.zeros(gene) target[0:counting_ones] = 1 # shuffle the array to mix zeroes and ones np.random.shuffle(target) return target -
定义一个函数来计算每个染色体的适应度权重:
def fitness_function(target,population): #create an array of true/false compared to the reference identical_to_target = population == target #sum no. of genes that are identical fitness_weights = identical_to_target.sum(axis = 1) return fitness_weights -
定义一个函数来选择具有最高权重(最高适应度得分)的父代对。由于种群较小,染色体之间的竞争更为激烈。这种方法也称为锦标赛选择:
# select the best parents def select_parents(population, weights): #identify the parent with the highest weight parent1 = population[np.argmax(weights)] #replace weight with the minimum number weights[np.argmax(weights)] = 0 #identify the parent with the second-highest weight parent2 = population[np.argmax(weights)] return parent1, parent2 -
定义一个使用随机选择交叉点的交叉函数:
def crossover_reproduction(parents, population): #define parents separately parent1 = parents[0] parent2 = parents[1] #randomly assign a point for cross-over p = random.randrange(0, len(population)) print("Crossover point:", p) #create children by joining the parents at the cross-over point child1 = np.hstack((parent1[0:p],parent2[p:])) child2 = np.hstack((parent2[0:p], parent1[p:])) return child1, child2 -
定义一个突变函数,使用概率和种群作为输入:
def mutate_population(population, mutation_probability): #create array of random mutations that uses the population mutation_array = np.random.random(size = (population.shape)) """ compare elements of the array with the probability and put the results into an array """ mutation_boolean = mutation_array \ >= mutation_probability """ convert boolean into binary and store to create a new array for the population """ population[mutation_boolean] = np.logical_not\ (population[mutation_boolean]) return population在前面的代码片段中,设置突变选择的条件是检查数组中的每个元素是否大于突变概率,该概率作为阈值。如果元素大于阈值,则应用突变。
-
将
children数组附加到原始种群中,创建一个新的交叉population,并使用print()函数显示它:population = original_population(5,8) target = create_target_solution(8) weights = fitness_function(target,population) parents = select_parents(population,weights) children = crossover_reproduction(parents,population)输出将如下所示:
Crossover point: 3 -
接下来,将
population与children合并:population_crossover = np.append(population, children, axis= 0) print('\nPopulation after the cross-over:\n', \ population_crossover)种群将如下所示:
Population after the cross-over: [[0\. 1\. 0\. 0\. 0\. 0\. 1\. 0.] [0\. 0\. 0\. 0\. 0\. 1\. 0\. 0.] [1\. 1\. 1\. 1\. 1\. 0\. 0\. 1.] [1\. 1\. 1\. 0\. 1\. 1\. 1\. 1.] [0\. 1\. 1\. 1\. 1\. 0\. 0\. 0.] [1\. 1\. 1\. 1\. 1\. 0\. 0\. 1.] [1\. 1\. 1\. 0\. 1\. 1\. 1\. 1.]] -
使用交叉种群和突变概率
0.05来创建一个新种群,并显示突变后的种群:mutation_probability = 0.05 new_population = mutate_population\ (population_crossover,mutation_probability) print('\nNext generation of the population:\n',\ new_population)如你所见,阈值(mutation_probability)是 0.05。因此,如果元素大于这个阈值,它们将发生突变(所以基因发生突变的几率是 95%)。
输出将如下所示:
Next generation of the population: [[1\. 0\. 1\. 1\. 1\. 1\. 0\. 1.] [1\. 0\. 1\. 1\. 1\. 0\. 1\. 1.] [1\. 0\. 0\. 0\. 0\. 1\. 1\. 0.] [0\. 0\. 0\. 1\. 0\. 0\. 0\. 0.] [1\. 0\. 0\. 0\. 1\. 1\. 1\. 1.] [0\. 0\. 0\. 0\. 0\. 1\. 1\. 0.] [0\. 0\. 0\. 1\. 0\. 1\. 0\. 1.]]
你将得到一个类似的输出,因为种群元素是随机化的。你可以看到交叉操作生成的染色体被添加到原始种群中,且在突变后,种群的染色体数量相同,但基因不同。交叉和突变步骤可以通过循环函数重复,直到达到目标解。这些循环也被称为代。
注意
要访问此特定部分的源代码,请参考 packt.live/3dXaBqi。
你还可以在网上运行这个示例,网址是 packt.live/2Ysc5Cl。
在本节中,描述了突变的概念。突变的好处在于它为染色体引入了随机变异,促进了探索,并帮助避免局部最优。介绍了不同的突变技术。我们使用的示例展示了通过在交叉过程完成后对种群实施反向突变来观察突变概率的影响。
应用于超参数选择
在本节中,我们将探讨遗传算法(GAs)在参数选择中的应用,尤其是在使用神经网络时。遗传算法广泛应用于生产调度和铁路管理中的优化问题。这类问题的解决方案依赖于将神经网络与遗传算法结合,作为函数优化器。
本节的练习提供了一个平台,用于调整神经网络的超参数,以预测风流模式。您将应用一个简单的遗传算法来优化用于训练神经网络的超参数值。
人工神经网络(ANNs)模拟了大脑中神经元的生物学过程和结构。人工神经网络中的神经元依赖于输入信息(参数)和权重的组合。该乘积(加上偏置)通过传递函数,这是一组并行排列的神经元,形成一个层。
对于权重和偏置优化,人工神经网络使用梯度下降法进行训练和反向传播过程。这影响了神经网络的发展,因为在训练开始之前,神经网络拓扑结构需要完全设计。由于设计是预设的,某些神经元在训练过程中可能没有被使用,但它们可能仍然处于活跃状态,因此变得冗余。此外,使用梯度方法的神经网络可能会陷入局部最优,因此需要依赖其他方法来帮助其继续处理,如正则化、岭回归或套索回归。人工神经网络广泛应用于语音识别、特征检测(无论是图像、拓扑还是信号处理)和疾病检测。
为了防止这些问题并增强神经网络的训练,遗传算法可以被实现。遗传算法用于函数优化,而交叉和变异技术则有助于问题空间的探索。最初,遗传算法被用于优化神经网络的权重和节点数。为此,遗传算法的染色体编码了可能的权重和节点变动。神经网络生成的适应度函数依赖于潜在值与参数的实际值之间的均方误差。
然而,研究已经扩展到递归神经网络(RNNs)的实现,并将其与强化学习(RL)结合,旨在提高多处理器性能。递归神经网络是一种人工神经网络(ANN),其输出不仅是输入加权过程的结果,还包含了一个包含先前输入和输出的向量。这使得神经网络能够保持对先前训练实例的知识。
遗传算法有助于扩展神经网络的拓扑结构,超越权重调整。一例是 EDEN,其中编码在染色体内进行,并且网络架构和学习率在多个 TensorFlow 数据集上实现了高精度。训练神经网络时,最具挑战性的问题之一是馈送给网络的特征(或输入超参数)的质量。如果参数不合适,输入和输出的映射将会错误。因此,遗传算法可以作为人工神经网络的替代方法,通过优化特征选择来发挥作用。
以下练习将教你如何应用简单的遗传算法来识别 RNN 的最佳参数(窗口大小和单元数量)。所实现的遗传算法使用 deap 包,通过 eaSimple() 函数,可以使用基于工具箱的代码创建一个简单的遗传算法,包括种群创建、通过 selRandom() 函数进行选择、通过 cxTwoPoint() 函数进行交叉和通过 mutFlipBit() 函数进行变异。为了进行比较和超参数选择,使用 selBest() 函数。
练习 12.07:为 RNN 训练实现遗传算法超参数优化
本次练习的目标是通过简单的遗传算法识别 RNN 使用的最佳超参数。在本次练习中,我们使用的是一个 2012 年天气预报挑战赛中的数据集。训练和验证参数时仅使用一个特征 wp2。使用的两个超参数是单元数量和窗口大小。这些超参数代表染色体的遗传物质:
注意
数据集可以在以下 GitHub 仓库中找到:https://packt.live/2Ajjz2F。
原始数据集可以在以下链接找到:https://www.kaggle.com/c/GEF2012-wind-forecasting/data。
-
创建一个新的 Jupyter Notebook。导入
pandas和numpy库及其函数:import numpy as np import pandas as pd from sklearn.metrics import mean_squared_error from sklearn.model_selection import train_test_split as split from tensorflow.keras.layers import SimpleRNN, Input, Dense from tensorflow.keras.models import Model from deap import base, creator, tools, algorithms from scipy.stats import bernoulli from bitstring import BitArray从
sklearn包中导入mean_squared_error和train_test_split。同时,从tensorflow和keras包中导入SimpleRNN、Input、Dense(来自layers文件夹)和模型(来自Model类)。为了创建遗传算法,必须从deap包中调用base、creator、tools和algorithms。对于统计学,我们使用的是伯努利方程;因此,我们将从scipy.stats包中调用bernoulli。从bitstrings中,我们将调用BitArray。 -
使用随机种子进行模型开发;
998是种子的初始化数字:np.random.seed(998) -
从
train.csv文件加载数据,使用np.reshape()将数据修改为只包含wp2列的数组,并选择前 1,501 个元素:#read data from csv data = pd.read_csv('../Dataset/train.csv') #use column wp2 data = np.reshape(np.array(data['wp2']), (len(data['wp2']), 1)) data = data[0:1500] -
定义一个函数,根据窗口大小划分数据集:
def format_dataset(data, w_size): #initialize as empty array X, Y = np.empty((0, w_size)), np.empty(0) """ depending on the window size the data is separated in 2 arrays containing each of the sizes """ for i in range(len(data)-w_size-1): X = np.vstack([X,data[i:(i+w_size),0]]) Y = np.append(Y, data[i+w_size,0]) X = np.reshape(X,(len(X),w_size,1)) Y = np.reshape(Y,(len(Y), 1)) return X, Y -
定义一个函数,通过简单的遗传算法训练 RNN,识别最佳超参数:
def training_hyperparameters(ga_optimization): """ decode GA solution to integer window size and number of units """ w_size_bit = BitArray(ga_optimization[0:6]) n_units_bit = BitArray(ga_optimization[6:]) w_size = w_size_bit.uint n_units = n_units_bit.uint print('\nWindow Size: ', w_size, \ '\nNumber of units: ',n_units) """ return fitness score of 100 if the size or the units are 0 """ if w_size == 0 or n_units == 0: return 100 """ segment train data on the window size splitting it into 90 train, 10 validation """ X,Y = format_dataset(data, w_size) X_train, X_validate, Y_train, Y_validate = \ split(X, Y, test_size= 0.10, random_state= 998)第一阶段是识别与窗口大小和单元数量相关的染色体部分。接下来,如果没有窗口大小或单元数量,则返回一个极高的适应度得分。将两个数组按 90:10 的比例分割为训练数组和验证数组。
-
初始化输入特征,并使用训练数据集训练
SimpleRNN模型。为了优化,使用 Adam 算法,并将均方误差作为损失函数。为了训练模型,使用fit函数,设置epochs为5,批次大小为4。要生成预测值,使用存储在X_validate中的输入值,在模型的predict函数中进行预测。计算RMSE:input_features = Input(shape=(w_size,1)) x = SimpleRNN(n_units,input_shape=(w_size,1))(input_features) output = Dense(1, activation='linear')(x) rnnmodel = Model(inputs=input_features, outputs = output) rnnmodel.compile(optimizer='adam', \ loss = 'mean_squared_error') rnnmodel.fit(X_train, Y_train, epochs=5, \ batch_size=4, shuffle = True) Y_predict = rnnmodel.predict(X_validate) # calculate RMSE score as fitness score for GA RMSE = np.sqrt(mean_squared_error(Y_validate, Y_predict)) print('Validation RMSE: ', RMSE, '\n') return RMSE, -
实例化种群大小、遗传算法使用的代数和基因长度,分别设置为
4、5和10:population_size = 4 generations = 5 gene = 10 -
使用
deap包中的工具箱实例化遗传算法,eaSimple()。为此,使用创建器工具将适应度函数实例化为RMSE:creator.create('FitnessMax', base.Fitness, weights= (-1.0,)) creator.create('Individual', list, fitness = creator.FitnessMax) toolbox = base.Toolbox() toolbox.register('bernoulli', bernoulli.rvs, 0.5) toolbox.register('chromosome', tools.initRepeat, \ creator.Individual, toolbox.bernoulli, n = gene) toolbox.register('population', tools.initRepeat, \ list, toolbox.chromosome) toolbox.register('mate', tools.cxTwoPoint) toolbox.register('mutate', tools.mutFlipBit, indpb = 0.6) toolbox.register('select', tools.selRandom) toolbox.register('evaluate', training_hyperparameters) population = toolbox.population(n = population_size) algo = algorithms.eaSimple(population,toolbox,cxpb=0.4, \ mutpb=0.1, ngen=generations, \ verbose=False)输出的最后几行如下所示:
Window Size: 48 Number of units: 15 Train on 1305 samples Epoch 1/5 1305/1305 [==============================] - 3s 2ms/sample - loss: 0.0106 Epoch 2/5 1305/1305 [==============================] - 3s 2ms/sample - loss: 0.0066 Epoch 3/5 1305/1305 [==============================] - 3s 2ms/sample - loss: 0.0057 Epoch 4/5 1305/1305 [==============================] - 3s 2ms/sample - loss: 0.0051 Epoch 5/5 1305/1305 [==============================] - 3s 2ms/sample - loss: 0.0049 Validation RMSE: 0.05564985152918074RMSE值越低,超参数越好。伯努利分布用于随机初始化染色体基因。基于染色体,初始化种群。在工具箱中,创建新种群有四个步骤:cxTwoPoint()表示父代在两个点交叉信息(交叉),mutFlipBit()会以0.6的概率仅突变染色体的一个元素,selRandom()函数,evaluate(这使用来自第 6 步和第 7 步的 RNN 训练函数)。 -
使用
selBest()函数选择单一最佳解,k=1,比较解的适应度函数,选择与最优解最相似的那个。为了获得最佳窗口大小和单元数量,遍历染色体,将比特值转换为无符号整数,并打印最优超参数:optimal_chromosome = tools.selBest(population, k = 1) optimal_w_size = None optimal_n_units = None for op in optimal_chromosome: w_size_bit = BitArray(op[0:6]) n_units_bit = BitArray(op[6:]) optimal_w_size = w_size_bit.uint optimal_n_units = n_units_bit.uint print('\nOptimal window size:', optimal_w_size, \ '\n Optimal number of units:', optimal_n_units)输出将如下所示:
Optimal window size: 48 Optimal number of units: 15 -
运行应用程序,你将得到类似的输出。窗口大小和单元数量的初始值将显示。遗传算法将使用 RNN 运行指定的代数。在每个代的结束时,
RMSE值会显示出来。一旦所有代数完成,最佳值将显示出来:![图 12.10:使用遗传算法优化窗口大小和单元数量]()
图 12.10:使用遗传算法优化窗口大小和单元数量
我们从初始窗口大小51和15个单元开始;最佳窗口大小减少为28,单元数量减少到4。根据RMSE计算,参数之间的差异减少至0.05。
注意
要访问该特定部分的源代码,请参考packt.live/37sgQA6。
你也可以在packt.live/30AOKRK在线运行此示例。
本节内容已经涵盖了将遗传算法与神经网络结合,作为替代梯度下降方法的方案。遗传算法主要用于优化神经网络的神经元数量和权重,但通过混合方法,其应用可以扩展到优化网络结构和超参数选择。本次练习测试了你应用遗传算法来寻找与天气预测问题相关的两个特征的最佳值的能力。这些特征被用来训练一个递归神经网络(RNN),利用 RMSE 值估计风流。在接下来的部分中,你将扩展对整个神经网络架构优化的混合技术的知识,使用 NEAT 方法。
NEAT 与其他形式
神经进化是指使用遗传算法(GA)进化神经网络的术语。这一机器学习分支在各种问题中已被证明优于强化学习(RL),并且可以与强化学习结合使用,因为它是一种无监督学习方法。如前一节所述,神经进化系统专注于改变人工神经网络(ANN)的权重、神经元数量(在隐藏层中)和拓扑结构。
增强拓扑的神经进化(NEAT)专注于人工神经网络(ANN)的拓扑进化。它涉及训练一个简单的 ANN 结构,该结构由输入和输出神经元以及表示偏置的单元组成,但没有隐藏层。每个 ANN 结构都在一个染色体中编码,其中包含节点基因和连接基因(即两个节点基因之间的映射或连接)。每个连接指定输入、输出、权重节点、连接的激活以及创新编号,这个编号作为基因交叉过程中的链接。
突变与连接的权重或整个系统的结构相关。结构突变可以通过在两个未连接的节点之间加入连接,或者通过在已有连接上增加一个新节点,从而产生两个新的连接(一个是在现有的节点对之间,另一个是包含新创建节点的连接)。
交叉过程涉及识别种群中不同染色体之间的共同基因。这依赖于关于基因派生的历史信息,使用全球创新编号。由突变产生的基因会从其突变的基因获得递增编号,而通过交叉产生的基因保持原来的编号。这项技术有助于解决因基因匹配问题而导致的神经网络拓扑结构问题。没有相同创新编号的基因从具有最高适应度的父本中选择。如果两个父本具有相同的适应度,基因将从每个父本中随机选择。
拥有相似拓扑的染色体根据它们之间的距离进行分组
;因此,个体根据与平均基因数
的差异,以及不同的基因
、附加基因
和权重差异
进行评估。每个系数
作为一个权重,强调每个参数的重要性:

图 12.11:拓扑距离计算
为了将染色体分类到不同物种中,比较距离
与阈值
。如果
,那么染色体属于满足此条件的第一个物种。为了防止物种主导,物种中的所有元素需要具有相同的适应度水平,该水平是根据物种中的成员数量计算的。物种的进化(包括多少新染色体被包含,
)取决于物种适应度与种群平均适应度之间的比较,
:

图 12.12:新染色体数量的计算
NEAT 的优势在于,与那些具有随机拓扑参数的神经进化算法不同,它从最简单的神经网络拓扑形式开始,并逐步进化以寻找最优解,从而显著减少了所使用的代数数量。
进化拓扑算法被分类为 权重进化人工神经网络 (TWEANNs),包括 EDEN、细胞编码 (CE)、强制子群体 (SE) —— 这是一种固定拓扑系统(其中 NEAT 在 CartPole 上优于后两者)—— 并行分布式遗传编程 (PDGP),和 广义递归链接获取 (GNARL).
现在我们将通过一个练习,展示如何应用 NEAT 来解决一个简单的 XNOR 门问题,XNOR 门是一种具有二进制输出的逻辑门。二进制输入和输出通过真值表进行量化,真值表是布尔逻辑表达式功能值集合的表示,展示了逻辑值的组合。
练习 12.08:使用 NEAT 实现 XNOR 门功能
在练习中,您将看到 NEAT 在解决简单布尔代数问题中的影响。该问题涉及实现 NEAT 算法以识别用于再现互斥非(XNOR)门的二进制输出的最佳神经网络拓扑结构。这是一种逻辑门,当两个输入信号相同时(即 0 或 1 - 分别等同于关闭和打开),逻辑门的输出将为 1(打开),而当一个输入为高(1)而另一个输入为低(0)时,输出将为 0(关闭)。
我们有以下 XNOR 逻辑门的真值表:

图 12.13:XNOR 门的真值表
使用 NEAT 算法创建一个前馈神经网络,可以模拟 XNOR 门的输出。
执行以下步骤完成练习:
-
在您的 Anaconda 环境中执行以下命令:
conda install neat -
创建一个新的 Jupyter Notebook。
-
从
__future__文件中导入print_function,并导入neat和os包:from __future__ import print_function import os import neat -
根据真值表初始化 XNOR 门的输入和输出:
xnor_inputs = [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] xnor_output = [(1.0,),(0.0,),(0.0,),(1.0,)] -
创建一个适应性函数,该函数使用实际输出和使用 NEAT 的前馈神经网络输出之间的平方差:
def fitness_function(chromosomes, configuration): for ch_id, chromosome in chromosomes: chromosome.fitness = 4.0 neural_net = neat.nn.FeedForwardNetwork.create\ (chromosome, configuration) for xnor_i,xnor_o in zip(xnor_inputs, xnor_output): output = neural_net.activate(xnor_i) squared_diff = (output[0] - xnor_o[0])**2 chromosome.fitness -= squared_diff -
创建一个名为
config-feedforward-xnor的新文本文件。在文件中包含以下 NEAT 算法的参数。对于适应性函数,选择最大值,阈值接近4,人口大小为200:[NEAT] fitness_criterion = max fitness_threshold = 3.9 pop_size = 200 reset_on_extinction = False -
在同一
config-feedforward-xnor文件中,包括使用0.01的变异率的节点激活的sigmoid函数。聚合选项主要是添加值,聚合的变异率为 0:[DefaultGenome] # activation options of the nodes activation_default = sigmoid activation_mutate_rate = 0.01 activation_options = sigmoid # aggregation options for the node aggregation_default = sum aggregation_mutate_rate = 0.0 aggregation_options = sum -
为算法设置
bias参数:# bias options for the node bias_init_mean = 0.0 bias_init_stdev = 0.05 bias_max_value = 30.0 bias_min_value = -30.0 bias_mutate_power = 0.5 bias_mutate_rate = 0.8 bias_replace_rate = 0.1对于偏置,最小值和最大值分别为
-30和30。将初始标准差设置为0.05,尽可能低,幂为0.5,变异率为0.8,替换率为0.1。这些值对实施遗传算法优化至关重要。 -
定义系数
,因为我们仅考虑基因之间的差异(它们的不一致性)和权重之间的差异:# compatibility options for the genes in the chromosome compatibility_disjoint_coefficient = 1.0 compatibility_weight_coefficient = 0.5 -
包括关于拓扑、连接以及与节点的包含或移除相关的参数信息:
# add/remove rates for connections between nodes conn_add_prob = 0.5 conn_delete_prob = 0.5 # connection enable options enabled_default = True enabled_mutate_rate = 0.01 feed_forward = True initial_connection = full # add/remove rates for nodes node_add_prob = 0.2 node_delete_prob = 0.2 -
从一个没有任何隐藏层的简单网络开始,并设置节点和连接的响应参数:
# network parameters num_hidden = 0 num_inputs = 2 num_outputs = 1 # node response options response_init_mean = 1.0 response_init_stdev = 0.0 response_max_value = 30.0 response_min_value = -30.0 response_mutate_power = 0.0 response_mutate_rate = 0.0 response_replace_rate = 0.0 # connection weight options weight_init_mean = 0.0 weight_init_stdev = 1.0 weight_max_value = 30 weight_min_value = -30 weight_mutate_power = 0.5 weight_mutate_rate = 0.9 weight_replace_rate = 0.15 -
选择距离阈值、物种适应性函数和父代选择的默认参数。这是要包含在
config-feedforward-xnor文件中的最终参数集:[DefaultSpeciesSet] compatibility_threshold = 3.0 [DefaultStagnation] species_fitness_func = max max_stagnation = 20 species_elitism = 2 [DefaultReproduction] Elitism = 2 survival_threshold = 0.2 -
现在,在主代码文件中,使用
config-feedforward-xnor文件配置神经网络的 NEAT 公式,并输出网络的每个配置在Exercise 12.08内:#load configuration configuration = neat.Config(neat.DefaultGenome, \ neat.DefaultReproduction, \ neat.DefaultSpeciesSet, \ neat.DefaultStagnation,\ "../Dataset/config-feedforward-xnor") print("Output of file configuration:", configuration)输出如下:
Output of file configuration: <neat.config.Config object at 0x0000017618944AC8> -
根据 NEAT 算法的配置获取种群,并将进度输出到终端,以监控统计差异:
#load the population size pop = neat.Population(configuration) #add output for progress in terminal pop.add_reporter(neat.StdOutReporter(True)) statistics = neat.StatisticsReporter() pop.add_reporter(statistics) pop.add_reporter(neat.Checkpointer(5)) -
运行算法
200代,并为神经网络拓扑选择最佳解决方案:#run for 200 generations using best = pop.run(fitness_function, 200) #display the best chromosome print('\n Best chromosome:\n{!s}'.format(best))输出将类似于以下内容:
****** Running generation 0 ****** Population's average fitness: 2.45675 stdev: 0.36807 Best fitness: 2.99412 - size: (1, 2) - species 1 - id 28 Average adjusted fitness: 0.585 Mean genetic distance 0.949, standard deviation 0.386 Population of 200 members in 1 species: ID age size fitness adj fit stag ==== === ==== ======= ======= ==== 1 0 200 3.0 0.585 0 Total extinctions: 0 Generation time: 0.030 sec ****** Running generation 1 ****** Population's average fitness: 2.42136 stdev: 0.28774 Best fitness: 2.99412 - size: (1, 2) - species 1 - id 28 Average adjusted fitness: 0.589 Mean genetic distance 1.074, standard deviation 0.462 Population of 200 members in 1 species: ID age size fitness adj fit stag ==== === ==== ======= ======= ==== 1 1 200 3.0 0.589 1 Total extinctions: 0 Generation time: 0.032 sec (0.031 average) -
使用函数将神经网络的输出与期望输出进行比较:
#show output of the most fit chromosome against the data print('\n Output:') best_network = neat.nn.FeedForwardNetwork.create\ (best, configuration) for xnor_i, xnor_o in zip(xnor_inputs, xnor_output): output = best_network.activate(xnor_i) print("input{!r}, expected output {!r}, got: {:.1f}"\ .format(xnor_i,xnor_o,output[0]))输出将如下所示:
Output: input(0.0, 0.0), expected output (1.0,), got: 0.9 input(0.0, 1.0), expected output (0.0,), got: 0.0 input(1.0, 0.0), expected output (0.0,), got: 0.2 input(1.0, 1.0), expected output (1.0,), got: 0.9 -
运行代码后,你将得到类似于此处所见的输出。由于染色体是随机生成的,算法将在不同的代数中收敛到一个接近最优的解:
****** Running generation 41 ****** Population's average fitness: 2.50036 stdev: 0.52561 Best fitness: 3.97351 - size: (8, 16) - species 2 - id 8095 Best individual in generation 41 meets fitness threshold \ - complexity: (8, 16) Best chromosome: Key: 8095 Fitness: 3.9735119749933214 Nodes: 0 DefaultNodeGene(key=0, bias=-0.02623087593563278, \ response=1.0, activation=sigmoid, \ aggregation=sum) 107 DefaultNodeGene(key=107, bias=-1.5209385195946818, \ response=1.0, activation=sigmoid, \ aggregation=sum)[…] Connections: DefaultConnectionGene(key=(-2, 107), \ weight=1.8280370376000628, \ enabled=True) DefaultConnectionGene(key=(-2, 128), \ weight=0.08641968818530771, \ enabled=True) DefaultConnectionGene(key=(-2, 321), \ weight=1.2366021868005421, \ enabled=True)[…]
通过运行这个实验,你可以看到转换到接近最优解的过程发生在小于最大代数(200)的情况下。前馈神经网络的输出几乎是最优的,因为它的值是整数。它们的值接近 1 和 0。你还可以观察到,从一个没有隐藏层的神经网络开始,ANN 进化成了具有 1149 个节点和各种连接的网络。
注意
若要访问此特定部分的源代码,请参考 packt.live/2XTBs0M。
本节目前没有在线互动示例,需在本地运行。
在本节中,介绍了 NEAT 算法,这是一种变异神经网络拓扑的神经进化算法。NEAT 算法与其他 TWEANNs(拓扑进化神经网络)的不同之处在于变异、交叉和选择的方式,这些操作优化神经网络的结构,从一个没有隐藏层的简单网络开始,并演化成一个更复杂的网络,节点和连接的数量增加。
这个练习涉及实现 NEAT 来重现 XNOR 逻辑门的输出,使你能够理解 NEAT 算法的结构,并分析将神经进化技术应用于简单电子问题的好处和意义。在下一节中,你将通过解决小车摆杆问题来测试你的编程能力和遗传算法(GA)的知识。
活动 12.01:小车摆杆活动
自动控制是一项挑战,尤其是在使用机械臂或小车运输车间设备时。这个问题通常被概括为小车摆杆问题。你将编写程序控制一个自动化小车以保持一根杆子平衡。目标是最大化杆子保持平衡的时间。为了解决这个问题,代理可以使用神经网络进行状态-动作映射。挑战在于确定神经网络的结构,并为神经网络每一层确定最优的权重、偏差和神经元数量的解决方案。我们将使用遗传算法(GA)来确定这些参数的最佳值。
该活动的目标是实现一个遗传算法,用于选择人工神经网络(ANN)的参数,经过 20 代之后,可以在 500 次试验中获得高的平均分数。你将输出每代和每集的平均分数,并通过调整神经网络的参数,使用遗传算法监控收敛到最优策略的过程,以图形方式呈现。此活动旨在通过实现前几章和本章的概念,测试你的编程能力。以下是实现此活动所需的步骤:
-
创建一个 Jupyter Notebook 文件并导入适当的包,如下所示:
import gym import numpy as np import math import tensorflow as tf from matplotlib import pyplot as plt from random import randint from statistics import median, mean -
初始化环境以及状态和动作空间的形状。
-
创建一个函数,用于生成随机选择的初始网络参数。
-
创建一个函数,使用一组参数生成神经网络。
-
创建一个函数,获取使用神经网络时 300 步的总奖励。
-
创建一个函数,在运行初始随机选择时,获取种群中每个元素的适应度分数。
-
创建一个突变函数。
-
创建一个单点交叉函数。
-
创建一个函数,通过选择奖励最高的对来生成下一代。
-
在函数中选择参数,构建神经网络并添加这些参数。
-
使用识别的参数构建神经网络,并根据构建的神经网络获得新的奖励。
-
创建一个函数,用于输出收敛图。
-
创建一个遗传算法函数,根据最高的平均奖励输出神经网络的参数。
-
创建一个函数,将参数数组解码为每个神经网络参数。
-
设置代数为 50,试验次数为 15,步骤数和试验数为 500。你将得到类似以下的输出(这里只显示前几行):
Generation:1, max reward:11.0 Generation:2, max reward:11.0 Generation:3, max reward:10.0 Generation:4, max reward:10.0 Generation:5, max reward:11.0 Generation:6, max reward:10.0 Generation:7, max reward:10.0 Generation:8, max reward:10.0 Generation:9, max reward:11.0 Generation:10, max reward:10.0 Generation:11, max reward:10.0 Generation:12, max reward:10.0 Generation:13, max reward:10.0 Generation:14, max reward:10.0 Generation:15, max reward:10.0 Generation:16, max reward:10.0 Generation:17, max reward:10.0 Generation:18, max reward:10.0 Generation:19, max reward:11.0 Generation:20, max reward:11.0奖励与代数的关系图将类似于以下内容:
![图 12.14:代数中获得的奖励]()
图 12.14:代数中获得的奖励
平均奖励的输出(这里只显示最后几行)将类似于以下内容:
Trial:486, total reward:8.0
Trial:487, total reward:9.0
Trial:488, total reward:10.0
Trial:489, total reward:10.0
Trial:490, total reward:8.0
Trial:491, total reward:9.0
Trial:492, total reward:9.0
Trial:493, total reward:10.0
Trial:494, total reward:10.0
Trial:495, total reward:9.0
Trial:496, total reward:10.0
Trial:497, total reward:9.0
Trial:498, total reward:10.0
Trial:499, total reward:9.0
Average reward: 9.384
注意
该活动的解决方案可以在第 774 页找到。
总结
本章中,你探讨了基于梯度和非基于梯度的算法优化方法,重点介绍了进化算法的潜力——特别是遗传算法——通过模仿自然的方式解决优化问题,比如亚优解。遗传算法由特定元素组成,如种群生成、父代选择、父代重组或交叉、以及最终突变发生,利用这些元素生成二进制最优解。
接着,探索了遗传算法(GAs)在神经网络的超参数调优和选择中的应用,帮助我们找到最合适的窗口大小和单元数。我们看到了结合深度神经网络和进化策略的最先进算法的实现,例如用于 XNOR 输出估计的 NEAT。最后,你有机会通过 OpenAI Gym 的平衡杆模拟来实现本章所学的内容,在这个模拟中,我们研究了使用深度神经网络进行动作选择时,遗传算法在参数调优中的应用。
强化学习(RL)系统中混合方法的发展是最近的优化发展之一。你已经开发并实现了适用于无模型 RL 系统的优化方法。在附加章节中(该章节可以通过互动版本的研讨会在 courses.packtpub.com 上访问),你将探索基于模型的 RL 方法以及深度 RL 在控制系统中的最新进展,这些进展可以应用于机器人技术、制造业和交通领域。
现在,你已经能够利用本书中学到的概念,使用各种编码技术和模型来进一步提升你的专业领域,并可能带来新的变化和进步。你的旅程才刚刚开始——你已经迈出了破解强化学习(RL)世界的第一步,并且你现在拥有了提升 Python 编程技能的工具,所有这些你都可以独立应用。
附录
1. 强化学习简介
活动 1.01:测量随机智能体的表现
-
导入所需的库—
abc、numpy和gym:import abc import numpy as np import gym -
定义表示智能体的抽象类:
""" Abstract class representing the agent Init with the action space and the function pi returning the action """ class Agent: def __init__(self, action_space: gym.spaces.Space): """ Constructor of the agent class. Args: action_space (gym.spaces.Space): environment action space """ raise NotImplementedError("This class cannot be instantiated.") @abc.abstractmethod def pi(self, state: np.ndarray) -> np.ndarray: """ Agent's policy. Args: state (np.ndarray): environment state Returns: The selected action """ pass智能体仅通过构造函数和一个抽象方法
pi表示。该方法是实际的策略;它以环境状态为输入,返回选定的动作。 -
定义连续智能体。连续智能体必须根据传递给构造函数的动作空间初始化概率分布:
class ContinuousAgent(Agent): def __init__(self, action_space: gym.spaces.Space, seed=46): # setup seed np.random.seed(seed) # check the action space type if not isinstance(action_space, gym.spaces.Box): raise ValueError\ ("This is a Continuous Agent pass as "\ "input a Box Space.") -
如果上下界是无限的,则概率分布只是一个以 0 为中心的正态分布,尺度等于 1:
""" initialize the distribution according to the action space type """ if (action_space.low == -np.inf) and \ (action_space.high == np.inf): # the distribution is a normal distribution self._pi = lambda: np.random.normal\ (loc=0, scale=1, \ size=action_space.shape) return -
如果上下界都是有限的,则分布是定义在该范围内的均匀分布:
if (action_space.low != -np.inf) and \ (action_space.high != np.inf): # the distribution is a uniform distribution self._pi = lambda: np.random.uniform\ (low=action_space.low, \ high=action_space.high, \ size=action_space.shape) return如果下界是
,则概率分布是一个平移的负指数分布:if action_space.low == -np.inf: # negative exponential distribution self._pi = (lambda: -np.random.exponential\ (size=action_space.shape) + action_space.high) return如果上界是
,则概率分布是一个平移的指数分布:if action_space.high == np.inf: # exponential distribution self._pi = (lambda: np.random.exponential\ (size=action_space.shape) + action_space.low) return -
定义
pi方法,这只是对构造函数中定义的分布的调用:def pi(self, observation: np.ndarray) -> np.ndarray: """ Policy: simply call the internal _pi(). This is a random agent, so the action is independent from the observation. For real agents the action depends on the observation. """ return self._pi() -
我们准备定义离散智能体。与之前一样,智能体必须根据传递的动作空间正确初始化动作分布:
class DiscreteAgent(Agent): def __init__(self, action_space: gym.spaces.Space, seed=46): # setup seed np.random.seed(seed) # check the action space type if not isinstance(action_space, gym.spaces.Discrete): raise ValueError("This is a Discrete Agent pass "\ "as input a Discrete Space.") """ initialize the distribution according to the action space n attribute """ # the distribution is a uniform distribution self._pi = lambda: np.random.randint\ (low=0, high=action_space.n) def pi(self, observation: np.ndarray) -> np.ndarray: """ Policy: simply call the internal _pi(). This is a random agent, so the action is independent from the observation. For real agents the action depends on the observation. """ return self._pi() -
现在,定义一个实用函数来根据动作空间创建正确的智能体类型是有用的:
def make_agent(action_space: gym.spaces.Space, seed=46): """ Returns the correct agent based on the action space type """ if isinstance(action_space, gym.spaces.Discrete): return DiscreteAgent(action_space, seed) if isinstance(action_space, gym.spaces.Box): return ContinuousAgent(action_space, seed) raise ValueError("Only Box spaces or Discrete Spaces "\ "are allowed, check the action space of "\ "the environment") -
最后一步是定义强化学习循环,其中智能体与环境互动并收集奖励。
定义参数,然后创建环境和智能体:
# Environment Name env_name = "CartPole-v0" # Number of episodes episodes = 10 # Number of Timesteps of each episode timesteps = 100 # Discount factor gamma = 1.0 # seed environment seed = 46 # Needed to show the environment in a notebook from gym import wrappers env = gym.make(env_name) env.seed(seed) # the last argument is needed to record all episodes # otherwise gym would record only some of them # The monitor saves the episodes inside the folder ./gym-results env = wrappers.Monitor(env, "./gym-results", force=True, \ video_callable=lambda episode_id: True) agent = make_agent(env.action_space, seed) -
我们需要跟踪每个回合的回报;为此,我们可以使用一个简单的列表:
# list of returns episode_returns = [] -
为每个回合开始一个循环:
# loop for the episodes for episode_number in range(episodes): # here we are inside an episode -
初始化计算累计折扣因子和当前回合回报的变量:
# reset cumulated gamma gamma_cum = 1 # return of the current episode episode_return = 0 -
重置环境并获取第一个观察:
# the reset function resets the environment and returns # the first environment observation observation = env.reset() -
循环执行若干时间步:
# loop for the given number of timesteps or # until the episode is terminated for timestep_number in range(timesteps): -
渲染环境,选择动作,然后应用它:
# if you want to render the environment # uncomment the following line # env.render() # select the action action = agent.pi(observation) # apply the selected action by calling env.step observation, reward, done, info = env.step(action) -
增加回报,并计算累计折扣因子:
# increment the return episode_return += reward * gamma_cum # update the value of cumulated discount factor gamma_cum = gamma_cum * gamma -
如果回合已结束,则跳出时间步循环:
""" if done the episode is terminated, we have to reset the environment """ if done: print(f"Episode Number: {episode_number}, \ Timesteps: {timestep_number}, Return: {episode_return}") # break from the timestep loop break -
在时间步循环结束后,我们必须通过将当前回报添加到每个回合的回报列表中来记录当前回报:
episode_returns.append(episode_return) -
在回合循环结束后,关闭环境并计算
statistics:# close the environment env.close() # Calculate return statistics avg_return = np.mean(episode_returns) std_return = np.std(episode_returns) var_return = std_return ** 2 # variance is std² print(f"Statistics on Return: Average: {avg_return}, \ Variance: {var_return}")你将获得以下结果:
Episode Number: 0, Timesteps: 27, Return: 28.0 Episode Number: 1, Timesteps: 9, Return: 10.0 Episode Number: 2, Timesteps: 13, Return: 14.0 Episode Number: 3, Timesteps: 16, Return: 17.0 Episode Number: 4, Timesteps: 31, Return: 32.0 Episode Number: 5, Timesteps: 10, Return: 11.0 Episode Number: 6, Timesteps: 14, Return: 15.0 Episode Number: 7, Timesteps: 11, Return: 12.0 Episode Number: 8, Timesteps: 10, Return: 11.0 Episode Number: 9, Timesteps: 30, Return: 31.0 Statistics on Return: Average: 18.1, Variance: 68.89000000000001
在本活动中,我们实现了两种不同类型的智能体:离散智能体,适用于离散环境,和连续智能体,适用于连续环境。
此外,你可以使用以下代码在笔记本中渲染回合:
# Render the episodes
import io
import base64
from IPython.display import HTML, display
episodes_to_watch = 1
for episode in range(episodes_to_watch):
video = io.open(f"./gym-results/openaigym.video\
.{env.file_infix}.video{episode:06d}.mp4", "r+b").read()
encoded = base64.b64encode(video)
display(
HTML(
data="""
<video width="360" height="auto" alt="test" controls>
<source src="img/mp4;base64,{0}" type="video/mp4" />
</video>""".format(
encoded.decode("ascii")
)
)
)
你可以看到回合持续时间不长。这是因为动作是随机执行的,因此在若干时间步后,杆子会倒下。
注意
要访问此特定部分的源代码,请参阅packt.live/3fbxR3Y。
本节当前没有在线交互式示例,需要在本地运行。
离散代理和连续代理是在面对新强化学习问题时的两种不同可能性。
我们设计的代理非常灵活,可以应用于几乎所有环境,而无需更改代码。
我们还实现了一个简单的强化学习循环,并衡量了代理在经典强化学习问题中的表现。
2. 马尔可夫决策过程与贝尔曼方程
活动 2.01:求解 Gridworld
-
导入所需的库:
from enum import Enum, auto import matplotlib.pyplot as plt import numpy as np from scipy import linalg from typing import Tuple -
定义
visualization函数:# helper function def vis_matrix(M, cmap=plt.cm.Blues): fig, ax = plt.subplots() ax.matshow(M, cmap=cmap) for i in range(M.shape[0]): for j in range(M.shape[1]): c = M[j, i] ax.text(i, j, "%.2f" % c, va="center", ha="center") -
定义可能的动作:
# Define the actions class Action(Enum): UP = auto() DOWN = auto() LEFT = auto() RIGHT = auto() -
定义
Policy类,表示随机策略:# Agent Policy, random class Policy: def __init__(self): self._possible_actions = [action for action in Action] self._action_probs = {a: 1 / len(self._possible_actions) \ for a in self._possible_actions} def __call__(self, state: Tuple[int, int], \ action: Action) -> float: """ Returns the action probability """ assert action in self._possible_actions # state is unused for this policy return self._action_probs[action] -
定义
Environment类和step函数:class Environment: def __init__(self): self.grid_width = 5 self.grid_height = 5 self._good_state1 = (0, 1) self._good_state2 = (0, 3) self._to_state1 = (4, 2) self._to_state2 = (2, 3) self._bad_state1 = (1, 1) self._bad_state2 = (4, 4) self._bad_states = [self._bad_state1, self._bad_state2] self._good_states = [self._good_state1, self._good_state2] self._to_states = [self._to_state1, self._to_state2] self._good_rewards = [10, 5] def step(self, state, action): i, j = state # search among good states for good_state, reward, \ to_state in zip(self._good_states, \ self._good_rewards, \ self._to_states): if (i, j) == good_state: return (to_state, reward) reward = 0 # if the state is a bad state, the reward is -1 if state in self._bad_states: reward = -1 # calculate next state based on the action if action == Action.LEFT: j_next = max(j - 1, 0) i_next = i if j - 1 < 0: reward = -1 elif action == Action.RIGHT: j_next = min(j + 1, self.grid_width - 1) i_next = i if j + 1 > self.grid_width - 1: reward = -1 elif action == Action.UP: j_next = j i_next = max(i - 1, 0) if i - 1 < 0: reward = -1 elif action == Action.DOWN: j_next = j i_next = min(i + 1, self.grid_height - 1) if i + 1 > self.grid_height - 1: reward = -1 else: raise ValueError("Invalid action") return ((i_next, j_next), reward) -
针对所有状态和动作进行循环,构建转移矩阵和奖励矩阵:
pi = Policy() env = Environment() # setup probability matrix and reward matrix P = np.zeros((env.grid_width*env.grid_height, \ env.grid_width*env.grid_height)) R = np.zeros_like(P) possible_actions = [action for action in Action] # Loop for all states and fill up P and R for i in range(env.grid_height): for j in range(env.grid_width): state = (i, j) # loop for all action and setup P and R for action in possible_actions: next_state, reward = env.step(state, action) (i_next, j_next) = next_state P[i*env.grid_width+j, \ i_next*env.grid_width \ + j_next] += pi(state, action) """ the reward depends only on the starting state and the final state """ R[i*env.grid_width+j, \ i_next*env.grid_width + j_next] = reward -
检查矩阵的正确性:
# check the correctness assert((np.sum(P, axis=1) == 1).all()) -
计算每个状态的预期奖励:
# expected reward for each state R_expected = np.sum(P * R, axis=1, keepdims=True) -
使用该函数可视化预期奖励:
# reshape the state values in a matrix R_square = R_expected.reshape((env.grid_height,env.grid_width)) # Visualize vis_matrix(R_square, cmap=plt.cm.Reds)该函数使用 Matplotlib 可视化矩阵。你应该看到类似于以下内容:
![图 2.62:每个状态的预期奖励]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_62.jpg)
图 2.62:每个状态的预期奖励
前面的图像是一个颜色表示,展示了在当前策略下与每个状态相关联的预期奖励。注意,坏状态的预期奖励恰好等于
-1。好状态的预期奖励分别恰好等于10和5。 -
现在设置贝尔曼期望方程的矩阵形式:
# define the discount factor gamma = 0.9 # Now it is possible to solve the Bellman Equation A = np.eye(env.grid_width*env.grid_height) - gamma * P B = R_expected -
求解贝尔曼方程:
# solve using scipy linalg V = linalg.solve(A, B) -
可视化结果:
# reshape the state values in a matrix V_square = V.reshape((env.grid_height,env.grid_width)) # visualize results vis_matrix(V_square, cmap=plt.cm.Reds)

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_02_63.jpg)
图 2.63:Gridworld 的状态值
注意,好状态的值小于这些状态的预期奖励。这是因为着陆状态的预期奖励为负,或者着陆状态接近奖励为负的状态。你可以看到,值较高的状态是状态
,接下来是状态
。另一个有趣的现象是位置 (0, 2) 上的状态值较高,且靠近好状态。
注意
要访问此部分的源代码,请参考 packt.live/2Al9xOB。
你也可以在线运行此示例,网址为 packt.live/2UChxBy。
在这个活动中,我们尝试了 Gridworld 环境,这是最常见的强化学习玩具环境之一。我们定义了一个随机策略,并使用 scipy.linalg.solve 求解贝尔曼期望方程,以找到该策略的状态值。
重要的是在可能的情况下可视化结果,以便更好地理解并发现任何错误。
3. 使用 TensorFlow 2 进行深度学习实践
活动 3.01:使用 TensorFlow 数据集和 TensorFlow 2 分类时尚服装
-
导入所有所需的模块:
from __future__ import absolute_import, division, \ print_function, unicode_literals import numpy as np import matplotlib.pyplot as plt # TensorFlow import tensorflow as tf import tensorflow_datasets as tfds -
使用 TensorFlow 数据集导入 Fashion MNIST 数据集,并将其拆分为训练集和测试集。然后,创建一个类别列表:
# Construct a tf.data.Dataset (train_images, train_labels), (test_images, test_labels) = \ tfds.as_numpy(tfds.load('fashion_mnist', \ split=['train', 'test'],\ batch_size=-1, as_supervised=True,)) train_images = np.squeeze(train_images) test_images = np.squeeze(test_images) classes = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', \ 'Coat','Sandal', 'Shirt', 'Sneaker', 'Bag', \ 'Ankle boot'] -
探索数据集,熟悉输入特征,即形状、标签和类别:
print("Training dataset shape =", train_images.shape) print("Training labels length =", len(train_labels)) print("Some training labels =", train_labels[:5]) print("Test dataset shape =", test_images.shape) print("Test labels length =", len(test_labels))输出将如下所示:
Training dataset shape = (60000, 28, 28) Training labels length = 60000 Some training labels = [2 1 8 4 1] Test dataset shape = (10000, 28, 28) Test labels length = 10000 -
可视化一些训练集实例。
观察图像的显示效果也很有用。以下代码片段展示了第一个训练集实例:
plt.figure() plt.imshow(train_images[0]) plt.colorbar() plt.grid(False) plt.show()输出图像将如下所示:
![图 3.30:第一个训练图像图]()
图 3.30:第一个训练图像图
-
执行特征归一化:
train_images = train_images / 255.0 test_images = test_images / 255.0 -
现在,让我们通过绘制
25个样本及其对应的标签来查看一些训练集实例:plt.figure(figsize=(10,10)) for i in range(25): plt.subplot(5,5,i+1) plt.xticks([]) plt.yticks([]) plt.grid(False) plt.imshow(train_images[i], cmap=plt.cm.binary) plt.xlabel(classes[train_labels[i]]) plt.show()输出图像将如下所示:
![图 3.31:一组 25 个训练样本及其对应的标签]()
图 3.31:一组 25 个训练样本及其对应的标签
-
构建分类模型。首先,使用一系列层创建一个模型:
model = tf.keras.Sequential\ ([tf.keras.layers.Flatten(input_shape=(28, 28)),\ tf.keras.layers.Dense(128, activation='relu'),\ tf.keras.layers.Dense(10)]) -
然后,将模型与
优化器、损失函数和指标关联起来:model.compile(optimizer='adam',\ loss=tf.keras.losses.SparseCategoricalCrossentropy\ (from_logits=True), metrics=['accuracy']) -
训练深度神经网络:
model.fit(train_images, train_labels, epochs=10)最后的输出行将如下所示:
Epoch 9/1060000/60000 [==============================] \ - 2s 40us/sample - loss: 0.2467 - accuracy: 0.9076 Epoch 10/1060000/60000 [==============================] \ - 2s 40us/sample - loss: 0.2389 - accuracy: 0.9103 -
测试模型的准确性。准确率应超过 88%。
-
在测试集上评估模型,并打印准确率得分:
test_loss, test_accuracy = model.evaluate\ (test_images, test_labels, verbose=2) print('\nTest accuracy:', test_accuracy)输出将如下所示:
10000/10000 - 0s - loss: 0.3221 - accuracy: 0.8878 Test accuracy: 0.8878注意
由于随机抽样和可变的随机种子,准确率可能会有所不同。
-
执行推理并检查预测结果与真实值的对比。
首先,向模型中添加一个
softmax层,使其输出概率而不是对数几率。然后,使用以下代码打印出第一个测试实例的概率:probability_model = tf.keras.Sequential\ ([model,tf.keras.layers.Softmax()]) predictions = probability_model.predict(test_images) print(predictions[0:3])输出将如下所示:
[[3.85897374e-06 2.33953915e-06 2.30801385e-02 4.74092474e-07 9.55752671e-01 1.56392260e-10 2.11589299e-02 8.57651870e-08 1.49855202e-06 1.05843508e-10] -
接下来,将一个模型的预测(即最高预测概率的类别)与第一个测试实例的真实标签进行对比:
print("Class ID, predicted | real =", \ np.argmax(predictions[0]), "|", test_labels[0])输出将如下所示:
Class ID, predicted | real = 4 | 4 -
为了进行更清晰的对比,创建以下两个函数。第一个函数绘制第
i个测试集实例图像,并显示最高预测概率的类别、其百分比概率,以及括号中的真实标签。对于正确预测,标题将显示为蓝色,对于错误预测,则显示为红色:def plot_image(i, predictions_array, true_label, img): predictions_array, true_label, img = predictions_array,\ true_label[i], img[i] plt.grid(False) plt.xticks([]) plt.yticks([]) plt.imshow(img, cmap=plt.cm.binary) predicted_label = np.argmax(predictions_array) if predicted_label == true_label: color = 'blue' else: color = 'red' plt.xlabel("{} {:2.0f}% ({})".format\ (classes[predicted_label], \ 100*np.max(predictions_array),\ classes[true_label]),\ color=color) -
第二个函数创建第二个图像,显示所有类别的预测概率的条形图。如果预测正确,它将以
蓝色标记概率最高的类别,若预测错误,则用红色标记。如果是后者,与正确标签对应的条形图将以蓝色标记:def plot_value_array(i, predictions_array, true_label): predictions_array, true_label = predictions_array,\ true_label[i] plt.grid(False) plt.xticks(range(10)) plt.yticks([]) thisplot = plt.bar(range(10), predictions_array,\ color="#777777") plt.ylim([0, 1]) predicted_label = np.argmax(predictions_array) thisplot[predicted_label].set_color('red') thisplot[true_label].set_color('blue') -
使用这两个函数,我们可以检查测试集中的每个实例。在下面的代码片段中,正在绘制第一个测试实例:
i = 0 plt.figure(figsize=(6,3)) plt.subplot(1,2,1) plot_image(i, predictions[i], test_labels, test_images) plt.subplot(1,2,2) plot_value_array(i, predictions[i], test_labels) plt.show()输出将如下所示:
![图 3.32:第一个测试实例,正确预测]()
图 3.32:第一个测试实例,正确预测
-
同样的方法也可以用来绘制用户自定义数量的测试实例,并将输出安排在子图中,如下所示:
""" Plot the first X test images, their predicted labels, and the true labels. Color correct predictions in blue and incorrect predictions in red. """ num_rows = 5 num_cols = 3 num_images = num_rows*num_cols plt.figure(figsize=(2*2*num_cols, 2*num_rows)) for i in range(num_images): plt.subplot(num_rows, 2*num_cols, 2*i+1) plot_image(i, predictions[i], test_labels, test_images) plt.subplot(num_rows, 2*num_cols, 2*i+2) plot_value_array(i, predictions[i], test_labels) plt.tight_layout() plt.show()输出将如下所示:
![图 3.33:前 25 个测试实例及其预测类别与真实标签的对比]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_04_14.jpg)
图 3.33:前 25 个测试实例及其预测类别与真实标签的对比
注意
要访问此特定部分的源代码,请参考 packt.live/3dXv3am。
你还可以在线运行这个示例,地址是 packt.live/2Ux5JR5。
在这个活动中,我们面临了一个与现实世界问题非常相似的挑战。我们必须处理复杂的高维输入——在我们的案例中是灰度图像——并且我们希望构建一个能够自动将它们分成 10 个不同类别的模型。得益于深度学习的强大能力和最先进的机器学习框架,我们成功构建了一个全连接神经网络,达到了超过 88% 的分类准确率。
4. 使用 OpenAI 和 TensorFlow 开始强化学习
活动 4.01:训练强化学习智能体玩经典视频游戏
-
导入所有必要的模块,包括 OpenAI Baselines 和 TensorFlow,以便使用
PPO算法:from baselines.ppo2.ppo2 import learn from baselines.ppo2 import defaults from baselines.common.vec_env import VecEnv, VecFrameStack from baselines.common.cmd_util import make_vec_env, make_env from baselines.common.models import register import tensorflow as tf -
为策略网络定义并注册一个自定义卷积神经网络:
@register("custom_cnn") def custom_cnn(): def network_fn(input_shape, **conv_kwargs): """ Custom CNN """ print('input shape is {}'.format(input_shape)) x_input = tf.keras.Input\ (shape=input_shape, dtype=tf.uint8) h = x_input h = tf.cast(h, tf.float32) / 255. h = tf.keras.layers.Conv2D\ (filters=32,kernel_size=8,strides=4, \ padding='valid', data_format='channels_last',\ activation='relu')(h) h2 = tf.keras.layers.Conv2D\ (filters=64, kernel_size=4,strides=2,\ padding='valid', data_format='channels_last',\ activation='relu')(h) h3 = tf.keras.layers.Conv2D\ (filters=64, kernel_size=3,strides=1,\ padding='valid', data_format='channels_last',\ activation='relu')(h2) h3 = tf.keras.layers.Flatten()(h3) h3 = tf.keras.layers.Dense\ (units=512, name='fc1', activation='relu')(h3) network = tf.keras.Model(inputs=[x_input], outputs=[h3]) network.summary() return network return network_fn -
创建一个函数,以 OpenAI Baselines 所需的格式构建环境:
def build_env(env_id, env_type): if env_type in {'atari', 'retro'}: env = make_vec_env(env_id, env_type, 1, None, \ gamestate=None, reward_scale=1.0) env = VecFrameStack(env, 4) else: env = make_vec_env(env_id, env_type, 1, None,\ reward_scale=1.0,\ flatten_dict_observations=True) return env -
构建
PongNoFrameskip-v4环境,选择所需的策略网络参数并进行训练:env_id = 'PongNoFrameskip-v0' env_type = 'atari' print("Env type = ", env_type) env = build_env(env_id, env_type) model = learn(network="custom_cnn", env=env, total_timesteps=1e4)在训练过程中,模型会输出类似以下内容(这里只报告了部分行):
Env type = atari Logging to /tmp/openai-2020-05-11-16-19-42-770612 input shape is (84, 84, 4) Model: "model" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= input_1 (InputLayer) [(None, 84, 84, 4)] 0 _________________________________________________________________ tf_op_layer_Cast (TensorFlow [(None, 84, 84, 4)] 0 _________________________________________________________________ tf_op_layer_truediv (TensorF [(None, 84, 84, 4)] 0 _________________________________________________________________ conv2d (Conv2D) (None, 20, 20, 32) 8224 _________________________________________________________________ conv2d_1 (Conv2D) (None, 9, 9, 64) 32832 _________________________________________________________________ conv2d_2 (Conv2D) (None, 7, 7, 64) 36928 _________________________________________________________________ flatten (Flatten) (None, 3136) 0 _________________________________________________________________ fc1 (Dense) (None, 512) 1606144 ================================================================= Total params: 1,684,128 Trainable params: 1,684,128 Non-trainable params: 0 _________________________________________________________________ -------------------------------------------- | eplenmean | 1e+03 | | eprewmean | -20 | | fps | 213 | | loss/approxkl | 0.00012817292 | | loss/clipfrac | 0.0 | | loss/policy_entropy | 1.7916294 | | loss/policy_loss | -0.00050599687 | | loss/value_loss | 0.06880974 | | misc/explained_variance | 0.000675 | | misc/nupdates | 1 | | misc/serial_timesteps | 2048 | | misc/time_elapsed | 9.6 | | misc/total_timesteps | 2048 | -------------------------------------------- -
在环境中运行训练好的智能体并打印累积奖励:
obs = env.reset() if not isinstance(env, VecEnv): obs = np.expand_dims(np.array(obs), axis=0) episode_rew = 0 while True: actions, _, state, _ = model.step(obs) obs, reward, done, info = env.step(actions.numpy()) if not isinstance(env, VecEnv): obs = np.expand_dims(np.array(obs), axis=0) env.render() print("Reward = ", reward) episode_rew += reward if done: print('Episode Reward = {}'.format(episode_rew)) break env.close()以下几行显示了输出的最后部分:
[...] Reward = [0.] Reward = [0.] Reward = [0.] Reward = [0.] Reward = [0.] Reward = [0.] Reward = [0.] Reward = [0.] Reward = [0.] Reward = [-1.] Episode Reward = [-17.]它还呈现了环境,实时显示环境中的变化:
![图 4.14:渲染后的实时环境中的一帧]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_04_14.jpg)
图 4.14:渲染后的实时环境中的一帧
-
使用内置的 OpenAI Baselines 运行脚本,在
PongNoFrameskip-v0环境中训练 PPO:!python -m baselines.run --alg=ppo2 --env=PongNoFrameskip-v0 --num_timesteps=2e7 --save_path=./models/Pong_20M_ppo2 --log_path=./logs/Pong/输出的最后几行将类似于以下内容:
Stepping environment... ------------------------------------------- | eplenmean | 867 | | eprewmean | -20.8 | | fps | 500 | | loss/approxkl | 4.795634e-05 | | loss/clipfrac | 0.0 | | loss/policy_entropy | 1.7456135 | | loss/policy_loss | -0.0005875508 | | loss/value_loss | 0.050125826 | | misc/explained_variance | 0.145 | | misc/nupdates | 19 | | misc/serial_timesteps | 2432 | | misc/time_elapsed | 22 | | misc/total_timesteps | 9728 | ------------------------------------------- -
使用内置的 OpenAI Baselines 运行脚本,在
PongNoFrameskip-v0环境中运行训练好的模型:!python -m baselines.run --alg=ppo2 --env=PongNoFrameskip-v0 --num_timesteps=0 --load_path=./models/Pong_20M_ppo2 --play输出将类似于以下内容:
episode_rew=-21.0 episode_rew=-20.0 episode_rew=-20.0 episode_rew=-19.0 -
使用提供的预训练权重查看训练好的智能体的表现:
!wget -O pong_20M_ppo2.tar.gz \ https://github.com/PacktWorkshops\ /The-Reinforcement-Learning-Workshop/blob/master\ /Chapter04/pong_20M_ppo2.tar.gz?raw=true输出将如下所示:
Saving to: 'pong_20M_ppo2.tar.gz' pong_20M_ppo2.tar.g 100%[===================>] 17,44M 15, 1MB/s in 1,2s 2020-05-11 16:19:11 (15,1 MB/s) - 'pong_20M_ppo2.tar.gz' saved [18284569/18284569]你可以使用以下命令读取
.tar文件:!tar xvzf pong_20M_ppo2.tar.gz输出将如下所示:
pong_20M_ppo2/ckpt-1.data-00000-of-00001 pong_20M_ppo2/ckpt-1.index pong_20M_ppo2/ pong_20M_ppo2/checkpoint -
使用内置的 OpenAI Baselines 运行脚本,在
PongNoFrameskip-v0上训练 PPO:!python -m baselines.run --alg=ppo2 --env=PongNoFrameskip-v0 --num_timesteps=0 --load_path=./pong_20M_ppo2 –play注意
要访问此特定部分的源代码,请参考
packt.live/30yFmOi。本部分目前没有在线互动示例,需在本地运行。
在本活动中,我们学习了如何训练一个最先进的强化学习智能体,该智能体通过仅查看屏幕像素,就能在玩经典的 Atari 视频游戏时实现超越人类的表现。我们利用卷积神经网络对环境观察进行编码,并利用最先进的 OpenAI 工具成功训练了 PPO 算法。
5. 动态规划
活动 5.01:在 FrozenLake-v0 环境中实现策略和价值迭代
-
导入所需的库:
import numpy as np import gym -
初始化环境并重置当前环境。在初始化器中将
is_slippery=False。显示动作空间的大小和可能的状态数量:def initialize_environment(): """initialize the OpenAI Gym environment""" env = gym.make("FrozenLake-v0", is_slippery=False) print("Initializing environment") # reset the current environment env.reset() # show the size of the action space action_size = env.action_space.n print(f"Action space: {action_size}") # Number of possible states state_size = env.observation_space.n print(f"State space: {state_size}") return env -
执行策略评估迭代,直到最小变化小于
smallest_change:def policy_evaluation(V, current_policy, env, \ gamma, small_change): """ Perform policy evaluation iterations until the smallest change is less than 'smallest_change' Args: V: the value function table current_policy: current policy env: the OpenAI FrozenLake-v0 environment gamma: future reward coefficient small_change: how small should the change be for the iterations to stop Returns: V: the value function after convergence of the evaluation """ state_size = env.observation_space.n while True: biggest_change = 0 # loop through every state present for state in range(state_size): old_V = V[state] -
根据当前策略执行动作:
action = current_policy[state] prob, new_state, reward, done = env.env.P[state]\ [action][0] -
使用贝尔曼最优方程更新
:V[state] = reward + gamma * V[new_state] # if the biggest change is small enough then it means # the policy has converged, so stop. biggest_change = max(biggest_change, abs(V[state] \ - old_V)) if biggest_change < small_change: break return V -
使用贝尔曼最优方程进行策略改进:
def policy_improvement(V, current_policy, env, gamma): """ Perform policy improvement using the Bellman Optimality Equation. Args: V: the value function table current_policy: current policy env: the OpenAI FrozenLake-v0 environment gamma: future reward coefficient Returns: current_policy: the updated policy policy_changed: True, if the policy was changed, else, False """ state_size = env.observation_space.n action_size = env.action_space.n policy_changed = False for state in range(state_size): best_val = -np.inf best_action = -1 # loop over all actions and select the best one for action in range(action_size): prob, new_state, reward, done = env.env.\ P[state][action][0] -
通过执行此动作计算未来的奖励。请注意,我们使用的是简化方程,因为我们没有非一的转移概率:
future_reward = reward + gamma * V[new_state] if future_reward > best_val: best_val = future_reward best_action = action -
使用
assert语句,我们可以避免进入不期望的情况:assert best_action != -1 if current_policy[state] != best_action: policy_changed = True -
更新当前状态下的最佳动作:
current_policy[state] = best_action # if the policy didn't change, it means we have converged return current_policy, policy_changed -
使用策略迭代为 FrozenLake-v0 环境找到最优策略:
def policy_iteration(env): """ Find the most optimal policy for the FrozenLake-v0 environment using Policy Iteration Args: env: FrozenLake-v0 environment Returns: policy: the most optimal policy """ V = dict() """ initially the value function for all states will be random values close to zero """ state_size = env.observation_space.n for i in range(state_size): V[i] = np.random.random() # when the change is smaller than this, stop small_change = 1e-20 # future reward coefficient gamma = 0.9 episodes = 0 # train for these many episodes max_episodes = 50000 # initially we will start with a random policy current_policy = dict() for s in range(state_size): current_policy[s] = env.action_space.sample() while episodes < max_episodes: episodes += 1 # policy evaluation V = policy_evaluation(V, current_policy,\ env, gamma, small_change) # policy improvement current_policy, policy_changed = policy_improvement\ (V, current_policy, \ env, gamma) # if the policy didn't change, it means we have converged if not policy_changed: break print(f"Number of episodes trained: {episodes}") return current_policy -
在 FrozenLake-v0 环境中执行测试:
def play(policy, render=False): """ Perform a test pass on the FrozenLake-v0 environment Args: policy: the policy to use render: if the result should be rendered at every step. False by default """ env = initialize_environment() rewards = [] -
定义智能体允许采取的最大步数。如果在此时间内未找到解决方案,则称之为一个回合,并继续:
max_steps = 25 test_episodes = 50 for episode in range(test_episodes): # reset the environment every new episode state = env.reset() total_rewards = 0 print("*" * 100) print("Episode {}".format(episode)) for step in range(max_steps): -
在当前状态下选择具有最高 Q 值的动作:
action = policy[state] new_state, reward, done, info = env.step(action) if render: env.render() total_rewards += reward if done: rewards.append(total_rewards) print("Score", total_rewards) break state = new_state env.close() print("Average Score", sum(rewards) / test_episodes) -
随机在
FrozenLake-v0环境中执行操作:def random_step(n_steps=5): """ Steps through the FrozenLake-v0 environment randomly Args: n_steps: Number of steps to step through """ # reset the environment env = initialize_environment() state = env.reset() for i in range(n_steps): # choose an action at random action = env.action_space.sample() env.render() new_state, reward, done, info = env.step(action) print(f"New State: {new_state}\n"\ f"reward: {reward}\n"\ f"done: {done}\n"\ f"info: {info}\n") print("*" * 20) -
执行价值迭代,找到 FrozenLake-v0 环境的最优策略:
def value_iteration(env): """ Performs Value Iteration to find the most optimal policy for the FrozenLake-v0 environment Args: env: FrozenLake-v0 Gym environment Returns: policy: the most optimum policy """ V = dict() gamma = 0.9 state_size = env.observation_space.n action_size = env.action_space.n policy = dict() -
随机初始化价值表,并随机初始化策略:
for x in range(state_size): V[x] = -1 policy[x] = env.action_space.sample() """ this loop repeats until the change in value function is less than delta """ while True: delta = 0 for state in reversed(range(state_size)): old_v_s = V[state] best_rewards = -np.inf best_action = None # for all the actions in current state for action in range(action_size): -
检查执行此动作时获得的奖励:
prob, new_state, reward, done = env.env.P[state]\ [action][0] potential_reward = reward + gamma * V[new_state] """ select the one that has the best reward and also save the action to the policy """ if potential_reward > best_rewards: best_rewards = potential_reward best_action = action policy[state] = best_action V[state] = best_rewards # terminate if the change is not high delta = max(delta, abs(V[state] - old_v_s)) if delta < 1e-30: break print(policy) print(V) return policy -
运行代码并确保输出与期望一致,可以在
main块中运行代码进行验证:if __name__ == '__main__': env = initialize_environment() # policy = policy_iteration(env) policy = value_iteration(env) play(policy, render=True)运行此代码后,你应该能看到如下输出:
![图 5.27: FrozenLake-v0 环境输出]()
图 5.27: FrozenLake-v0 环境输出
从输出中可以看出,我们成功达成了获取飞盘的目标。
注意
若要获取此部分的源代码,请参考packt.live/3fxtZuq。
你也可以在packt.live/2ChI1Ss 在线运行此示例。
6. 蒙特卡洛方法
活动 6.01:探索冰湖问题——奖励函数
-
导入必要的库:
import gym import numpy as np from collections import defaultdict -
选择环境为
FrozenLake。is_slippery设置为False。通过env.reset()重置环境,并通过env.render()渲染环境:env = gym.make("FrozenLake-v0", is_slippery=False) env.reset() env.render()你将看到如下输出:
![图 6.15: 冰湖状态渲染]()
图 6.15: 冰湖状态渲染
这是一个文本网格,字母
S、F、G和H用于表示FrozenLake的当前环境。突出显示的单元格S是代理的当前状态。 -
分别使用
print(env.observation_space)和print(env.action_space)函数打印观察空间中的可能值和动作值的数量:print(env.observation_space) print(env.action_space) name_action = {0:'Left',1:'Down',2:'Right',3:'Up'}你将获得以下输出:
Discrete(16) Discrete(4)16是网格中的单元格数,因此print(env.observation_space)打印出16。4是可能的动作数,因此print(env.action_space)打印出4。Discrete表示观察空间和动作空间仅取离散值,不取连续值。 -
下一步是定义一个函数来生成冻结湖泊 episode。我们初始化
episodes和环境:def generate_frozenlake_episode(): episode = [] state = env.reset() step = 0; -
逐步导航并存储
episode并返回reward:while (True): action = env.action_space.sample() next_state, reward, done, info = env.step(action) episode.append((next_state, action, reward)) if done: break state = next_state step += 1 return episode, reward动作通过
env.action_space.sample()获得。next_state、action和reward是通过调用env_step(action)函数获得的,然后它们被添加到一个 episode 中。现在,episode是一个由状态、动作和奖励组成的列表。关键在于计算成功率,即一批 episode 的成功概率。我们通过计算一批 episode 中的总尝试次数来实现这一点,接着计算其中有多少次成功到达目标。代理成功到达目标的比例与代理尝试次数之比就是成功率。
-
首先,我们初始化总奖励:
def frozen_lake_prediction(batch): for batch_number in range(batch+1): total_reward = 0 -
为每次迭代生成 episode 和 reward,并计算总奖励:
for i_episode in range(100): episode, reward = generate_frozenlake_episode() total_reward += reward -
通过将
total_reward除以100来计算成功率,并打印出来:success_percent = total_reward/100 print("Episode", batch_number*100, \ "Policy Win Rate=>", float(success_percent*100), \ "%") -
使用
frozen_lake_prediction函数计算冻结湖泊预测:frozen_lake_prediction(100)你将获得以下输出:
![图 6.16:没有学习的冻结湖泊输出]()
图 6.16:没有学习的冻结湖泊输出
输出打印了每 100 个 episode 的策略胜率。由于这是模拟代理遵循随机策略的情况,因此这些比率相当低。我们将在下一个练习中看到,通过结合贪心策略和 epsilon 软策略,如何将其提高到更高水平。
注意
若要访问此特定部分的源代码,请参考packt.live/2Akh8Nm。
你也可以在packt.live/2zruU07在线运行此示例。
活动 6.02 使用蒙特卡洛控制法每次访问解决冻结湖泊问题 epsilon 软策略
-
导入必要的库:
import gym import numpy as np -
选择环境为
FrozenLake,并将is_slippery设置为False:#Setting up the Frozen Lake environment env = gym.make("FrozenLake-v0", is_slippery=False) -
将
Q值和num_state_action初始化为零:#Initializing the Q and num_state_action Q = np.zeros([env.observation_space.n, env.action_space.n]) num_state_action = np.zeros([env.observation_space.n, \ env.action_space.n]) -
将
num_episodes的值设为100000,并创建rewardsList。我们将epsilon设置为0.30:num_episodes = 100000 epsilon = 0.30 rewardsList = []将 epsilon 设置为
0.30意味着我们以 0.30 的概率进行探索,以 1-0.30 或 0.70 的概率进行贪婪选择。 -
运行循环直到
num_episodes。我们将环境、results_List和result_sum初始化为零。同时,重置环境:for x in range(num_episodes): state = env.reset() done = False results_list = [] result_sum = 0.0 -
开始一个
while循环,检查是否需要以ε的概率选择一个随机动作,或以 1-ε的概率选择贪婪策略:while not done: #random action less than epsilon if np.random.rand() < epsilon: #we go with the random action action = env.action_space.sample() else: """ 1 - epsilon probability, we go with the greedy algorithm """ action = np.argmax(Q[state, :]) -
现在进行
action,并获取new_state和reward:#action is performed and assigned to new_state, reward new_state, reward, done, info = env.step(action) -
结果列表附加
state和action对。result_sum按结果的值递增:results_list.append((state, action)) result_sum += reward -
将
new_state赋值给state,并将result_sum附加到rewardsList:#new state is assigned as state state = new_state #appending the results sum to the rewards list rewardsList.append(result_sum) -
使用增量方法计算
Q[s,a],即Q[s,a] + (result_sum – Q[s,a]) / N(s,a):for (state, action) in results_list: num_state_action[state, action] += 1.0 sa_factor = 1.0 / num_state_action[state, action] Q[state, action] += sa_factor * \ (result_sum - Q[state, action]) -
每
1000次批量打印成功率的值:if x % 1000 == 0 and x is not 0: print('Frozen Lake Success rate=>', \ str(sum(rewardsList) * 100 / x ), '%') -
打印最终的成功率:
print("Frozen Lake Success rate=>", \ str(sum(rewardsList)/num_episodes * 100), "%")你最初将得到以下输出:
![图 6.17:冰湖成功率的初始输出]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_06_17.jpg)
图 6.17:冰湖成功率的初始输出
最终你将得到以下输出:

](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_06_18.jpg)
图 6.18:冰湖成功率的最终输出
成功率从接近 0%的非常低值开始,但通过强化学习,它逐渐学习,成功率逐步增加,最终达到 60%。
注意
要访问此特定部分的源代码,请参考packt.live/2Ync9Dq。
你也可以在网上运行这个示例:packt.live/3cUJLxQ。
7. 时间差分学习
活动 7.01:使用 TD(0) Q-Learning 解决 FrozenLake-v0 随机转移
-
导入所需的模块:
import numpy as np import matplotlib.pyplot as plt %matplotlib inline import gym -
使用
is_slippery标志设置为True来实例化名为FrozenLake-v0的gym环境,以启用随机性:env = gym.make('FrozenLake-v0', is_slippery=True) -
看一下动作和观察空间:
print("Action space = ", env.action_space) print("Observation space = ", env.observation_space)这将打印出以下内容:
Action space = Discrete(4) Observation space = Discrete(16) -
创建两个字典以轻松地将
actions的数字转换为动作:actionsDict = {} actionsDict[0] = " L " actionsDict[1] = " D " actionsDict[2] = " R " actionsDict[3] = " U " actionsDictInv = {} actionsDictInv["L"] = 0 actionsDictInv["D"] = 1 actionsDictInv["R"] = 2 actionsDictInv["U"] = 3 -
重置环境并渲染它,以查看网格问题:
env.reset() env.render()它的初始状态如下:
![图 7.39:环境的初始状态]()
](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_07_39.jpg)
图 7.39:环境的初始状态
-
可视化此环境的最佳策略:
optimalPolicy = [" * "," U ","L/R/D"," U ",\ " L "," - "," L/R "," - ",\ " U "," D "," L "," - ",\ " - "," R ","R/D/U"," ! ",] print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])这将打印出以下输出:
Optimal policy: L/R/D U U U L - L/R - U D L - - R D ! -
定义执行ε-贪婪动作的函数:
def action_epsilon_greedy(q, s, epsilon=0.05): if np.random.rand() > epsilon: return np.argmax(q[s]) return np.random.randint(4) -
定义一个执行贪婪策略的函数:
def greedy_policy(q, s): return np.argmax(q[s]) -
定义一个函数来计算代理的平均表现:
def average_performance(policy_fct, q): acc_returns = 0. n = 500 for i in range(n): done = False s = env.reset() while not done: a = policy_fct(q, s) s, reward, done, info = env.step(a) acc_returns += reward return acc_returns/n -
初始化 Q 表,使得所有值都等于
1,终止状态的值除外:q = np.ones((16, 4)) # Set q(terminal,*) equal to 0 q[5,:] = 0.0 q[7,:] = 0.0 q[11,:] = 0.0 q[12,:] = 0.0 q[15,:] = 0.0 -
设置总回合数、表示我们评估代理平均表现的步骤间隔数、学习率、折扣因子、探索策略的
ε值,以及一个数组来收集训练期间代理的所有表现评估:nb_episodes = 80000 STEPS = 2000 alpha = 0.01 gamma = 0.99 epsilon_expl = 0.2 q_performance = np.ndarray(nb_episodes//STEPS) -
训练 Q 学习算法。循环所有的回合:
for i in range(nb_episodes): -
重置环境并开始回合内循环:
done = False s = env.reset() while not done: -
使用 ε-贪婪策略选择探索动作:
# behavior policy a = action_epsilon_greedy(q, s, epsilon=epsilon_expl) -
步进环境,选择探索动作,并获取新的状态、奖励和完成条件:
new_s, reward, done, info = env.step(a) -
使用贪婪策略选择一个新的动作:
a_max = np.argmax(q[new_s]) # estimation policy -
使用 Q-learning TD(0) 规则更新 Q 表:
q[s, a] = q[s, a] + alpha * \ (reward + gamma * q[new_s, a_max] - q[s, a]) -
使用新值更新状态:
s = new_s -
评估代理在每一步的平均表现:
if i%STEPS == 0: q_performance[i//STEPS] = average_performance\ (greedy_policy, q) -
绘制 Q-learning 代理在训练过程中的平均奖励历史:
plt.plot(STEPS * np.arange(nb_episodes//STEPS), q_performance) plt.xlabel("Epochs") plt.ylabel("Average reward of an epoch") plt.title("Learning progress for Q-Learning")这将生成以下输出,展示 Q-learning 算法的学习进展:
Text(0.5, 1.0, 'Learning progress for Q-Learning')这个图表可以按如下方式可视化:
![图 7.40:训练周期中每个周期的平均奖励趋势]()
图 7.40:训练周期中每个周期的平均奖励趋势
在这种情况下,和应用于确定性环境的 Q-learning 一样,图表显示了随着代理收集越来越多的经验,Q-learning 性能在周期中增长的速度有多快。它还证明了,由于随机性的限制,该算法在学习后无法达到 100% 的成功率。与在随机环境中使用 SARSA 方法的表现相比,如 图 7.15 所示,该算法的性能增长速度更快且更稳步。
-
评估训练后的代理(Q 表)的贪婪策略表现:
greedyPolicyAvgPerf = average_performance(greedy_policy, q=q) print("Greedy policy Q-learning performance =", \ greedyPolicyAvgPerf)这将打印出以下内容:
Greedy policy Q-learning performance = 0.708 -
显示 Q 表的值:
q = np.round(q,3) print("(A,S) Value function =", q.shape) print("First row") print(q[0:4,:]) print("Second row") print(q[4:8,:]) print("Third row") print(q[8:12,:]) print("Fourth row") print(q[12:16,:])这将生成以下输出:
(A,S) Value function = (16, 4) First row [[0.543 0.521 0.516 0.515] [0.319 0.355 0.322 0.493] [0.432 0.431 0.425 0.461] [0.32 0.298 0.296 0.447]] Second row [[0.559 0.392 0.396 0.393] [0\. 0\. 0\. 0\. ] [0.296 0.224 0.327 0.145] [0\. 0\. 0\. 0\. ]] Third row [[0.337 0.366 0.42 0.595] [0.484 0.639 0.433 0.415] [0.599 0.511 0.342 0.336] [0\. 0\. 0\. 0\. ]] Fourth row [[0\. 0\. 0\. 0\. ] [0.46 0.53 0.749 0.525] [0.711 0.865 0.802 0.799] [0\. 0\. 0\. 0\. ]] -
打印出找到的贪婪策略,并与最优策略进行比较:
policyFound = [actionsDict[np.argmax(q[0,:])],\ actionsDict[np.argmax(q[1,:])],\ actionsDict[np.argmax(q[2,:])],\ actionsDict[np.argmax(q[3,:])],\ actionsDict[np.argmax(q[4,:])],\ " - ",\ actionsDict[np.argmax(q[6,:])],\ " - ",\ actionsDict[np.argmax(q[8,:])],\ actionsDict[np.argmax(q[9,:])],\ actionsDict[np.argmax(q[10,:])],\ " - ",\ " - ",\ actionsDict[np.argmax(q[13,:])],\ actionsDict[np.argmax(q[14,:])],\ " ! "] print("Greedy policy found:") idxs = [0,4,8,12] for idx in idxs: print(policyFound[idx+0], policyFound[idx+1], \ policyFound[idx+2], policyFound[idx+3]) print(" ") print("Optimal policy:") idxs = [0,4,8,12] for idx in idxs: print(optimalPolicy[idx+0], optimalPolicy[idx+1], \ optimalPolicy[idx+2], optimalPolicy[idx+3])这将生成以下输出:
Greedy policy found: L U U U L - R - U D L - - R D ! Optimal policy: L/R/D U U U L - L/R - U D L - - R D !
这个输出显示了,正如本章中所有练习的情况一样,离策略、一阶段的 Q-learning 算法能够通过简单地探索环境找到最优策略,即使在随机环境转移的背景下也是如此。如预期的那样,在这个设置下,不可能 100% 的时间内实现最大奖励。
正如我们所看到的,对于网格世界的每个状态,通过我们算法计算出的 Q 表所得到的贪婪策略都会指定一个符合最优策略的动作,而最优策略是通过分析环境问题定义的。正如我们之前所看到的,有两个状态,在这些状态下,许多不同的动作都是最优的,代理正确地执行了其中之一。
注意
要访问此特定部分的源代码,请参考 packt.live/3elMxxu。
你也可以在线运行这个示例,访问 packt.live/37HSDWx。
8. 多臂赌博机问题
活动 8.01:排队赌博机
-
导入必要的库和工具,如下所示:
import numpy as np from utils import QueueBandit -
声明 bandit 对象,如下所示:
N_CLASSES = 3 queue_bandit = QueueBandit(filename='data.csv')N_CLASSES变量将在我们后续的代码中使用。 -
实现贪婪算法,如下所示:
class GreedyQueue: def __init__(self, n_classes=3): self.n_classes = n_classes self.time_history = [[] for _ in range(n_classes)] def decide(self, queue_lengths): for class_ in range(self.n_classes): if queue_lengths[class_] > 0 and \ len(self.time_history[class_]) == 0: return class_ mean_times = [np.mean(self.time_history[class_])\ if queue_lengths[class_] > 0 else np.inf\ for class_ in range(self.n_classes)] return int(np.random.choice\ (np.argwhere\ (mean_times == np.min(mean_times)).flatten())) def update(self, class_, time): self.time_history[class_].append(time)请注意,我们通过检查
queue_lengths[class_]是否大于 0 来小心避免选择一个没有剩余客户的类别。剩余的代码与我们早先讨论的贪婪算法类似。随后,将算法应用于赌博机对象,如下所示:
cumulative_times = queue_bandit.repeat\ (GreedyQueue, [N_CLASSES], \ visualize_cumulative_times=True) np.max(cumulative_times), np.mean(cumulative_times)这将生成如下图表:
![图 8.24:来自贪婪算法(Greedy)的累积等待时间分布]()
(1218887.7924350922, 45155.236786598274)虽然这些值与我们之前的讨论相比看起来较大,但这是因为我们这里所使用的奖励/成本分布的数值较高。我们将使用贪婪算法的这些值作为参考框架,分析后续算法的表现。
-
使用以下代码实现探索后再承诺(Explore-then-commit)算法:
class ETCQueue: def __init__(self, n_classes=3, T=3): self.n_classes = n_classes self.T = T self.time_history = [[] for _ in range(n_classes)] def decide(self, queue_lengths): for class_ in range(self.n_classes): if queue_lengths[class_] > 0 and \ len(self.time_history[class_]) < self.T: return class_ mean_times = [np.mean(self.time_history[class_])\ if queue_lengths[class_] > 0 else np.inf\ for class_ in range(self.n_classes)] return int(np.random.choice\ (np.argwhere(mean_times == np.min(mean_times))\ .flatten())) def update(self, class_, time): self.time_history[class_].append(time) -
将算法应用于赌博机对象,如下所示:
cumulative_times = queue_bandit.repeat\ (ETCQueue, [N_CLASSES, 2],\ visualize_cumulative_times=True) np.max(cumulative_times), np.mean(cumulative_times)这将产生以下图表:
![图 8.25:来自探索后再承诺(Explore-then-commit)算法的累积等待时间分布]()
图 8.25:来自探索后再承诺(Explore-then-commit)算法的累积等待时间分布
这也将产生最大值和平均累积等待时间:(
(1238591.3208636027, 45909.77140562623))。与贪婪算法的结果((1218887.7924350922, 45155.236786598274))相比,探索后再承诺算法在这个排队赌博机问题上表现较差。 -
实现汤普森采样,如下所示:
class ExpThSQueue: def __init__(self, n_classes=3): self.n_classes = n_classes self.time_history = [[] for _ in range(n_classes)] self.temp_beliefs = [(0, 0) for _ in range(n_classes)] def decide(self, queue_lengths): for class_ in range(self.n_classes): if queue_lengths[class_] > 0 and \ len(self.time_history[class_]) == 0: return class_ rate_draws = [np.random.gamma\ (self.temp_beliefs[class_][0],1 \ / self.temp_beliefs[class_][1])\ if queue_lengths[class_] > 0 else -np.inf\ for class_ in range(self.n_classes)] return int(np.random.choice\ (np.argwhere(rate_draws == np.max(rate_draws))\ .flatten())) def update(self, class_, time): self.time_history[class_].append(time) # Update parameters according to Bayes rule alpha, beta = self.temp_beliefs[class_] alpha += 1 beta += time self.temp_beliefs[class_] = alpha, beta回顾我们之前讨论汤普森采样时,我们从每个臂的对应伽玛分布中抽取随机样本(这些分布用于建模服务率),以估算各个臂的奖励期望值。在这里,我们从相应的伽玛分布中抽取随机样本(这些伽玛分布用于建模服务率),以估算服务率(或工作时长的倒数),并选择最大值作为被抽取的样本。
-
这可以通过以下代码应用于解决赌博机问题:
cumulative_times = queue_bandit.repeat\ (ExpThSQueue, [N_CLASSES], \ visualize_cumulative_times=True) np.max(cumulative_times), np.mean(cumulative_times)将产生以下图表:
![图 8.26:来自汤普森采样(Thompson Sampling)的累积等待时间分布]()
图 8.26:来自汤普森采样(Thompson Sampling)的累积等待时间分布
从最大值和平均等待时间(
(1218887.7924350922, 45129.343871806814))来看,我们可以看到汤普森采样在贪婪算法上有所改进。 -
汤普森采样的修改版本可以实现如下:
class ExploitingThSQueue: def __init__(self, n_classes=3, r=1): self.n_classes = n_classes self.time_history = [[] for _ in range(n_classes)] self.temp_beliefs = [(0, 0) for _ in range(n_classes)] self.t = 0 self.r = r def decide(self, queue_lengths): for class_ in range(self.n_classes): if queue_lengths[class_] > 0 and \ len(self.time_history[class_]) == 0: return class_ if self.t > self.r * np.sum(queue_lengths): mean_times = [np.mean(self.time_history[class_])\ if queue_lengths[class_] > 0 \ else np.inf\ for class_ in range(self.n_classes)] return int(np.random.choice\ (np.argwhere\ (mean_times == np.min(mean_times))\ .flatten())) rate_draws = [np.random.gamma\ (self.temp_beliefs[class_][0],\ 1 / self.temp_beliefs[class_][1])\ if queue_lengths[class_] > 0 else -np.inf\ for class_ in range(self.n_classes)] return int(np.random.choice\ (np.argwhere\ (rate_draws == np.max(rate_draws)).flatten()))该类实现的初始化方法增加了一个额外的属性
r,我们将用它来实现利用逻辑。在
decide()方法中,在我们抽取样本来估算服务率之前,我们检查当前时间(t)是否大于当前队列长度(queue_lengths的总和)。该布尔值指示我们是否已经处理了超过一半的客户。如果是,我们就直接实现贪婪算法的逻辑,并返回具有最佳平均速率的臂。如果不是,我们就执行实际的汤普森采样逻辑。update()方法应与上一阶段实际的汤普森采样算法相同,如下所示:def update(self, class_, time): self.time_history[class_].append(time) self.t += 1 # Update parameters according to Bayes rule alpha, beta = self.temp_beliefs[class_] alpha += 1 beta += time self.temp_beliefs[class_] = alpha, beta -
最后,将算法应用于赌博机问题:
cumulative_times = queue_bandit.repeat\ (ExploitingThSQueue, [N_CLASSES, 1], \ visualize_cumulative_times=True) np.max(cumulative_times), np.mean(cumulative_times)我们将得到如下图表:
![图 8.27:修改版汤普森采样的累积等待时间分布]()
图 8.27:修改版汤普森采样的累积等待时间分布
结合最大和平均等待时间(1218887.7924350922, 45093.244027644556),我们可以看到,修改版的汤普森采样在减少实验中的累积等待时间方面比原版更有效。
这表明设计专门针对上下文赌博机问题的算法可能带来的潜在益处。
注意
要访问本节的源代码,请参考packt.live/2Yuw2IQ。
你也可以在packt.live/3hnK5Z5在线运行此示例。
在整个活动过程中,我们学习了如何将本章讨论的方法应用于排队赌博机问题,即探索一个潜在的上下文赌博机过程。最重要的是,我们考虑了一个修改版的汤普森采样,使其适应排队问题的上下文,从而成功地降低了我们的累积遗憾,相较于其他算法。本活动也标志着本章的结束。
9. 什么是深度 Q 学习?
活动 9.01:在 PyTorch 中实现双深度 Q 网络(DDQN)以应对 CartPole 环境
-
打开一个新的 Jupyter 笔记本,并导入所有必需的库:
import gym import matplotlib.pyplot as plt import torch import torch.nn as nn from torch import optim import numpy as np import random import math -
编写代码,基于 GPU 环境的可用性来创建设备:
use_cuda = torch.cuda.is_available() device = torch.device("cuda:0" if use_cuda else "cpu") print(device) -
使用
'CartPole-v0'环境创建一个gym环境:env = gym.make('CartPole-v0') -
设置
seed以确保 torch 和环境的可复现性:seed = 100 env.seed(seed) torch.manual_seed(seed) random.seed(seed) -
从环境中获取状态数和动作数:
number_of_states = env.observation_space.shape[0] number_of_actions = env.action_space.n print('Total number of States : {}'.format(number_of_states)) print('Total number of Actions : {}'.format(number_of_actions))输出如下:
Total number of States : 4 Total number of Actions : 2 -
设置 DDQN 过程所需的所有超参数值:
NUMBER_OF_EPISODES = 500 MAX_STEPS = 1000 LEARNING_RATE = 0.01 DISCOUNT_FACTOR = 0.99 HIDDEN_LAYER_SIZE = 64 EGREEDY = 0.9 EGREEDY_FINAL = 0.02 EGREEDY_DECAY = 500 REPLAY_BUFFER_SIZE = 6000 BATCH_SIZE = 32 UPDATE_TARGET_FREQUENCY = 200 -
实现
calculate_epsilon函数,如前面练习中所描述的:def calculate_epsilon(steps_done): """ Decays epsilon with increasing steps Parameter: steps_done (int) : number of steps completed Returns: int - decayed epsilon """ epsilon = EGREEDY_FINAL + (EGREEDY - EGREEDY_FINAL) \ * math.exp(-1\. * steps_done / EGREEDY_DECAY ) return epsilon -
创建一个名为
DQN的类,接受状态数作为输入,并输出环境中存在的动作数的 Q 值,网络的隐藏层大小为64:class DQN(nn.Module): def __init__(self , hidden_layer_size): super().__init__() self.hidden_layer_size = hidden_layer_size self.fc1 = nn.Linear(number_of_states,\ self.hidden_layer_size) self.fc2 = nn.Linear(self.hidden_layer_size,\ number_of_actions) def forward(self, x): output = torch.tanh(self.fc1(x)) output = self.fc2(output) return output -
实现
ExperienceReplay类,如前面练习中所描述的:class ExperienceReplay(object): def __init__(self , capacity): self.capacity = capacity self.buffer = [] self.pointer = 0 def push(self , state, action, new_state, reward, done): experience = (state, action, new_state, reward, done) if self.pointer >= len(self.buffer): self.buffer.append(experience) else: self.buffer[self.pointer] = experience self.pointer = (self.pointer + 1) % self.capacity def sample(self , batch_size): return zip(*random.sample(self.buffer , batch_size)) def __len__(self): return len(self.buffer) -
通过传递缓冲区大小作为输入来实例化
ExperienceReplay类:memory = ExperienceReplay(REPLAY_BUFFER_SIZE) -
实现 DQN 代理类,并修改
optimize函数(参考双深度 Q 网络(DDQN)部分给出的代码示例):class DQN_Agent(object): def __init__(self): self.dqn = DQN(HIDDEN_LAYER_SIZE).to(device) self.target_dqn = DQN(HIDDEN_LAYER_SIZE).to(device) self.criterion = torch.nn.MSELoss() self.optimizer = optim.Adam\ (params=self.dqn.parameters(), \ lr=LEARNING_RATE) self.target_dqn_update_counter = 0 def select_action(self,state,EGREEDY): random_for_egreedy = torch.rand(1)[0] if random_for_egreedy > EGREEDY: with torch.no_grad(): state = torch.Tensor(state).to(device) q_values = self.dqn(state) action = torch.max(q_values,0)[1] action = action.item() else: action = env.action_space.sample() return action def optimize(self): if (BATCH_SIZE > len(memory)): return state, action, new_state, reward, done = memory.sample\ (BATCH_SIZE) state = torch.Tensor(state).to(device) new_state = torch.Tensor(new_state).to(device) reward = torch.Tensor(reward).to(device) action = torch.LongTensor(action).to(device) done = torch.Tensor(done).to(device) """ select action : get the index associated with max q value from prediction network """ new_state_indxs = self.dqn(new_state).detach() # to get the max new state indexes max_new_state_indxs = torch.max(new_state_indxs, 1)[1] """ Using the best action from the prediction nn get the max new state value in target dqn """ new_state_values = self.target_dqn(new_state).detach() max_new_state_values = new_state_values.gather\ (1, max_new_state_indxs\ .unsqueeze(1))\ .squeeze(1) #when done = 1 then target = reward target_value = reward + (1 - done) * DISCOUNT_FACTOR \ * max_new_state_values predicted_value = self.dqn(state).gather\ (1, action.unsqueeze(1))\ .squeeze(1) loss = self.criterion(predicted_value, target_value) self.optimizer.zero_grad() loss.backward() self.optimizer.step() if self.target_dqn_update_counter \ % UPDATE_TARGET_FREQUENCY == 0: self.target_dqn.load_state_dict(self.dqn.state_dict()) self.target_dqn_update_counter += 1 -
按照以下步骤编写训练过程的循环。首先,使用之前创建的类实例化 DQN 代理。创建一个
steps_total空列表,用于收集每个回合的总步数。将steps_counter初始化为零,并用它来计算每个步骤的衰减 epsilon 值:dqn_agent = DQN_Agent() steps_total = [] steps_counter = 0在训练过程中使用两个循环;第一个循环用于执行游戏若干步,第二个循环确保每个回合持续固定的步数。在第二个
for循环中,第一步是计算当前步骤的 epsilon 值。使用当前状态和 epsilon 值,你可以选择要执行的动作。下一步是执行动作。一旦你执行了动作,环境将返回
new_state、reward和done标志。使用
optimize函数执行一次梯度下降步骤来优化 DQN。然后将新状态设置为下一次迭代的当前状态。最后,检查回合是否结束。如果回合结束,那么你可以收集并记录当前回合的奖励:for episode in range(NUMBER_OF_EPISODES): state = env.reset() done = False step = 0 for i in range(MAX_STEPS): step += 1 steps_counter += 1 EGREEDY = calculate_epsilon(steps_counter) action = dqn_agent.select_action(state, EGREEDY) new_state, reward, done, info = env.step(action) memory.push(state, action, new_state, reward, done) dqn_agent.optimize() state = new_state if done: steps_total.append(step) break -
现在观察奖励。由于奖励是标量反馈,能够指示代理的表现,你应查看平均奖励和最后 100 个回合的平均奖励。同时,绘制奖励的图形表示。检查代理在进行更多回合时的表现,以及最后 100 个回合的奖励平均值:
print("Average reward: %.2f" \ % (sum(steps_total)/NUMBER_OF_EPISODES)) print("Average reward (last 100 episodes): %.2f" \ % (sum(steps_total[-100:])/100))输出将如下所示:
Average reward: 174.09 Average reward (last 100 episodes): 186.06 -
在 y 轴绘制收集到的奖励,在 x 轴绘制游戏的回合数,以可视化奖励随回合数的变化:
Plt.figure(figsize=(12,5)) plt.title("Rewards Collected") plt.xlabel('Steps') plt.ylabel('Reward') plt.bar(np.arange(len(steps_total)), steps_total, \ alpha=0.5, color='green', width=6) plt.show()输出将如下所示:
![图 9.37:代理收集的奖励图]()
图 9.37:代理收集的奖励图
注意
要访问此特定部分的源代码,请参阅packt.live/3hnLDTd。
你也可以在线运行这个示例,访问 packt.live/37ol5MK。
以下是不同 DQN 技术与 DDQN 的比较:
基础 DQN 输出:
Average reward: 158.83
Average reward (last 100 episodes): 176.28
带有经验回放和目标网络的 DQN 输出:
Average reward: 154.41
Average reward (last 100 episodes): 183.28
DDQN 输出:
Average reward: 174.09
Average reward (last 100 episodes): 186.06
正如前图所示,通过前面的结果对比,DDQN 相较于其他 DQN 实现具有最高的平均奖励,并且最后 100 个回合的平均奖励也更高。我们可以说,DDQN 相较于其他两种 DQN 技术,显著提高了性能。完成整个活动后,我们学会了如何将 DDQN 网络与经验回放结合,克服基础 DQN 的问题,并实现更稳定的奖励。
10. 使用深度递归 Q 网络(DRQN)玩 Atari 游戏
活动 10.01:使用 CNN 训练 DQN 玩 Breakout
解决方案
-
打开一个新的 Jupyter Notebook,并导入相关的包:
gym、random、tensorflow、numpy和collections:import gym import random import numpy as np from collections import deque import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Conv2D, \ MaxPooling2D, Flatten from tensorflow.keras.optimizers import RMSprop import datetime -
为 NumPy 和 TensorFlow 设置种子为
168:np.random.seed(168) tf.random.set_seed(168) -
创建
DQN类,其中包含以下方法:build_model()方法,用于实例化一个 CNN;get_action()方法,应用 epsilon-greedy 算法来选择要执行的动作;add_experience()方法,将玩游戏过程中获得的经验存储到内存中;replay()方法,通过从内存中采样经验并训练 DQN 模型,每两回合保存一次模型;update_epsilon()方法,用于逐渐减少 epsilon-greedy 的 epsilon 值:Activity10_01.ipynb class DQN(): def __init__(self, env, batch_size=64, max_experiences=5000): self.env = env self.input_size = self.env.observation_space.shape[0] self.action_size = self.env.action_space.n self.max_experiences = max_experiences self.memory = deque(maxlen=self.max_experiences) self.batch_size = batch_size self.gamma = 1.0 self.epsilon = 1.0 self.epsilon_min = 0.01 self.epsilon_decay = 0.995 self.model = self.build_model() self.target_model = self.build_model() def build_model(self): model = Sequential() model.add(Conv2D(32, 8, (4,4), activation='relu', \ padding='valid',\ input_shape=(IMG_SIZE, IMG_SIZE, 1))) model.add(Conv2D(64, 4, (2,2), activation='relu', \ padding='valid')) model.add(Conv2D(64, 3, (1,1), activation='relu', \ padding='valid')) model.add(Flatten()) model.add(Dense(256, activation='relu')) model.add(Dense(self.action_size)) model.compile(loss='mse', \ optimizer=RMSprop(lr=0.00025, \ epsilon=self.epsilon_min), \ metrics=['accuracy']) return model The complete code for this step can be found at https://packt.live/3hoZXdV. -
创建
initialize_env()函数,它将初始化 Breakout 环境:def initialize_env(env): initial_state = env.reset() initial_done_flag = False initial_rewards = 0 return initial_state, initial_done_flag, initial_rewards -
创建
preprocess_state()函数来预处理输入图像:def preprocess_state(image, img_size): img_temp = image[31:195] img_temp = tf.image.rgb_to_grayscale(img_temp) img_temp = tf.image.resize\ (img_temp, [img_size, img_size],\ method=tf.image.ResizeMethod.NEAREST_NEIGHBOR) img_temp = tf.cast(img_temp, tf.float32) return img_temp -
创建
play_game()函数,它将玩一个完整的 Breakout 游戏:def play_game(agent, state, done, rewards): while not done: action = agent.get_action(state) next_state, reward, done, _ = env.step(action) next_state = preprocess_state(next_state, IMG_SIZE) agent.add_experience(state, action, reward, \ next_state, done) state = next_state rewards += reward return rewards -
创建
train_agent()函数,它将通过多个回合进行迭代,代理将在每一轮中玩游戏并执行经验重放:def train_agent(env, episodes, agent): from collections import deque import numpy as np scores = deque(maxlen=100) for episode in range(episodes): state, done, rewards = initialize_env(env) state = preprocess_state(state, IMG_SIZE) rewards = play_game(agent, state, done, rewards) scores.append(rewards) mean_score = np.mean(scores) if episode % 50 == 0: print(f'[Episode {episode}] \ - Average Score: {mean_score}') agent.target_model.set_weights(agent.model.get_weights()) agent.target_model.save_weights\ (f'dqn/dqn_model_weights_{episode}') agent.replay(episode) print(f"Average Score: {np.mean(scores)}") -
使用
gym.make()函数实例化一个名为env的 Breakout 环境:env = gym.make('BreakoutDeterministic-v4') -
创建两个变量,
IMG_SIZE和SEQUENCE,分别设置为84和4:IMG_SIZE = 84 SEQUENCE = 4 -
实例化一个名为
agent的DQN对象:agent = DQN(env) -
创建一个名为
episodes的变量,值设为50:episodes = 50 -
通过提供
env、episodes和agent来调用train_agent函数:train_agent(env, episodes, agent)以下是代码的输出:
[Episode 0] - Average Score: 3.0 Average Score: 0.59注意
要访问此特定部分的源代码,请参见
packt.live/3hoZXdV。你也可以在线运行此示例,网址为
packt.live/3dWLwfa。
你刚刚完成了本章的第一个活动。你成功地构建并训练了一个结合了 CNN 的 DQN 代理来玩 Breakout 游戏。该模型的表现与随机代理非常相似(平均得分为 0.6)。然而,如果你训练更长时间(通过增加训练集数目),它可能会取得更好的成绩。
活动 10.02:训练 DRQN 玩 Breakout
解决方案
-
打开一个新的 Jupyter Notebook 并导入相关的包:
gym、random、tensorflow、numpy和collections:import gym import random import numpy as np from collections import deque import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Conv2D, \ MaxPooling2D, TimeDistributed, Flatten, LSTM from tensorflow.keras.optimizers import RMSprop import datetime -
将 NumPy 和 TensorFlow 的随机种子设置为
168:np.random.seed(168) tf.random.set_seed(168) -
创建
DRQN类,其中包含以下方法:build_model()方法,用于实例化一个结合了 CNN 和 RNN 的模型;get_action()方法,应用 epsilon-greedy 算法来选择要执行的动作;add_experience()方法,用于将玩游戏过程中获得的经验存储到内存中;replay()方法,通过从内存中采样经验并训练 DRQN 模型,每两回合保存一次模型;update_epsilon()方法,用于逐渐减少 epsilon-greedy 的 epsilon 值:Activity10_02.ipynb class DRQN(): def __init__(self, env, batch_size=64, max_experiences=5000): self.env = env self.input_size = self.env.observation_space.shape[0] self.action_size = self.env.action_space.n self.max_experiences = max_experiences self.memory = deque(maxlen=self.max_experiences) self.batch_size = batch_size self.gamma = 1.0 self.epsilon = 1.0 self.epsilon_min = 0.01 self.epsilon_decay = 0.995 self.model = self.build_model() self.target_model = self.build_model() def build_model(self): model = Sequential() model.add(TimeDistributed(Conv2D(32, 8, (4,4), \ activation='relu', \ padding='valid'), \ input_shape=(SEQUENCE, IMG_SIZE, IMG_SIZE, 1))) model.add(TimeDistributed(Conv2D(64, 4, (2,2), \ activation='relu', \ padding='valid'))) model.add(TimeDistributed(Conv2D(64, 3, (1,1), \ activation='relu', \ padding='valid'))) model.add(TimeDistributed(Flatten())) model.add(LSTM(512)) model.add(Dense(128, activation='relu')) model.add(Dense(self.action_size)) model.compile(loss='mse', \ optimizer=RMSprop(lr=0.00025, \ epsilon=self.epsilon_min), \ metrics=['accuracy']) return model The complete code for this step can be found at https://packt.live/2AjdgMx . -
创建
initialize_env()函数,它将初始化 Breakout 环境:def initialize_env(env): initial_state = env.reset() initial_done_flag = False initial_rewards = 0 return initial_state, initial_done_flag, initial_rewards -
创建
preprocess_state()函数来预处理输入图像:def preprocess_state(image, img_size): img_temp = image[31:195] img_temp = tf.image.rgb_to_grayscale(img_temp) img_temp = tf.image.resize\ (img_temp, [img_size, img_size], \ method=tf.image.ResizeMethod.NEAREST_NEIGHBOR) img_temp = tf.cast(img_temp, tf.float32) return img_temp -
创建
combine_images()函数,用于将之前的四个截图堆叠在一起:def combine_images(new_img, prev_img, img_size, seq=4): if len(prev_img.shape) == 4 and prev_img.shape[0] == seq: im = np.concatenate\ ((prev_img[1:, :, :], \ tf.reshape(new_img, [1, img_size, img_size, 1])), \ axis=0) else: im = np.stack([new_img] * seq, axis=0) return im -
创建
play_game()函数,该函数将进行一整局 Breakout 游戏:def play_game(agent, state, done, rewards): while not done: action = agent.get_action(state) next_state, reward, done, _ = env.step(action) next_state = preprocess_state(next_state, IMG_SIZE) next_state = combine_images\ (new_img=next_state, prev_img=state, \ img_size=IMG_SIZE, seq=SEQUENCE) agent.add_experience(state, action, \ reward, next_state, done) state = next_state rewards += reward return rewards -
创建
train_agent()函数,该函数将在多个回合中反复进行,代理将玩游戏并进行经验回放:def train_agent(env, episodes, agent): from collections import deque import numpy as np scores = deque(maxlen=100) for episode in range(episodes): state, done, rewards = initialize_env(env) state = preprocess_state(state, IMG_SIZE) state = combine_images(new_img=state, prev_img=state, \ img_size=IMG_SIZE, seq=SEQUENCE) rewards = play_game(agent, state, done, rewards) scores.append(rewards) mean_score = np.mean(scores) if episode % 50 == 0: print(f'[Episode {episode}] - Average Score: {mean_score}') agent.target_model.set_weights\ (agent.model.get_weights()) agent.target_model.save_weights\ (f'drqn_model_weights_{episode}') agent.replay(episode) print(f"Average Score: {np.mean(scores)}") -
使用
gym.make()实例化一个名为env的 Breakout 环境:env = gym.make('BreakoutDeterministic-v4') -
创建两个变量,
IMG_SIZE和SEQUENCE,分别赋值为84和4:IMG_SIZE = 84 SEQUENCE = 4 -
实例化一个名为
agent的DRQN对象:agent = DRQN(env) -
创建一个名为
episodes的变量,赋值为200:episodes = 200 -
调用
train_agent函数,传入env,episodes和agent:train_agent(env, episodes, agent)以下是代码的输出:
[Episode 0] - Average Score: 0.0 [Episode 50] - Average Score: 0.43137254901960786 [Episode 100] - Average Score: 0.4 [Episode 150] - Average Score: 0.54 Average Score: 0.53注意:
要访问此特定部分的源代码,请参考
packt.live/2AjdgMx。你还可以在
packt.live/37mhlLM在线运行此示例。
在此活动中,我们添加了一个 LSTM 层并构建了一个 DRQN 代理。它学会了如何玩 Breakout 游戏,但即使经过 200 回合,也没有取得令人满意的结果。看来它仍处于探索阶段。你可以尝试训练更多回合。
活动 10.03:训练 DARQN 玩 Breakout
解决方案
-
打开一个新的 Jupyter Notebook 并导入相关的包:
gym,random,tensorflow,numpy和collections:import gym import random import numpy as np from collections import deque import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Conv2D, \ MaxPooling2D, TimeDistributed, Flatten, GRU, Attention from tensorflow.keras.optimizers import RMSprop import datetime -
将 NumPy 和 TensorFlow 的种子设置为
168:np.random.seed(168) tf.random.set_seed(168) -
创建
DARQN类并创建以下方法:build_model()方法,用于实例化一个结合了 CNN 和 RNN 模型的网络,get_action()方法,用于应用 epsilon-greedy 算法选择要执行的动作,add_experience()方法,用于将通过玩游戏获得的经验存储到内存中,replay()方法,将通过从内存中采样经验并进行经验回放来训练 DARQN 模型,并设置回调每两个回合保存一次模型,update_epsilon()方法,用于逐步减少 epsilon-greedy 中的 epsilon 值:Activity10_03.ipynb class DARQN(): def __init__(self, env, batch_size=64, max_experiences=5000): self.env = env self.input_size = self.env.observation_space.shape[0] self.action_size = self.env.action_space.n self.max_experiences = max_experiences self.memory = deque(maxlen=self.max_experiences) self.batch_size = batch_size self.gamma = 1.0 self.epsilon = 1.0 self.epsilon_min = 0.01 self.epsilon_decay = 0.995 self.model = self.build_model() self.target_model = self.build_model() def build_model(self): inputs = Input(shape=(SEQUENCE, IMG_SIZE, IMG_SIZE, 1)) conv1 = TimeDistributed(Conv2D(32, 8, (4,4), \ activation='relu', \ padding='valid'))(inputs) conv2 = TimeDistributed(Conv2D(64, 4, (2,2), \ activation='relu', \ padding='valid'))(conv1) conv3 = TimeDistributed(Conv2D(64, 3, (1,1), \ activation='relu', \ padding='valid'))(conv2) flatten = TimeDistributed(Flatten())(conv3) out, states = GRU(512, return_sequences=True, \ return_state=True)(flatten) att = Attention()([out, states]) output_1 = Dense(256, activation='relu')(att) predictions = Dense(self.action_size)(output_1) model = Model(inputs=inputs, outputs=predictions) model.compile(loss='mse', \ optimizer=RMSprop(lr=0.00025, \ epsilon=self.epsilon_min), \ metrics=['accuracy']) return model The complete code for this step can be found at https://packt.live/2XUDZrH. -
创建
initialize_env()函数,该函数将初始化 Breakout 环境:def initialize_env(env): initial_state = env.reset() initial_done_flag = False initial_rewards = 0 return initial_state, initial_done_flag, initial_rewards -
创建
preprocess_state()函数,用于预处理输入图像:def preprocess_state(image, img_size): img_temp = image[31:195] img_temp = tf.image.rgb_to_grayscale(img_temp) img_temp = tf.image.resize\ (img_temp, [img_size, img_size],\ method=tf.image.ResizeMethod.NEAREST_NEIGHBOR) img_temp = tf.cast(img_temp, tf.float32) return img_temp -
创建
combine_images()函数,用于将之前的四个截图堆叠在一起:def combine_images(new_img, prev_img, img_size, seq=4): if len(prev_img.shape) == 4 and prev_img.shape[0] == seq: im = np.concatenate((prev_img[1:, :, :], \ tf.reshape\ (new_img, [1, img_size, \ img_size, 1])), axis=0) else: im = np.stack([new_img] * seq, axis=0) return im -
创建
preprocess_state()函数,用于预处理输入图像:def play_game(agent, state, done, rewards): while not done: action = agent.get_action(state) next_state, reward, done, _ = env.step(action) next_state = preprocess_state(next_state, IMG_SIZE) next_state = combine_images\ (new_img=next_state, prev_img=state, \ img_size=IMG_SIZE, seq=SEQUENCE) agent.add_experience(state, action, reward, \ next_state, done) state = next_state rewards += reward return rewards -
创建
train_agent()函数,该函数将在多个回合中反复进行,代理将玩游戏并进行经验回放:def train_agent(env, episodes, agent): from collections import deque import numpy as np scores = deque(maxlen=100) for episode in range(episodes): state, done, rewards = initialize_env(env) state = preprocess_state(state, IMG_SIZE) state = combine_images\ (new_img=state, prev_img=state, \ img_size=IMG_SIZE, seq=SEQUENCE) rewards = play_game(agent, state, done, rewards) scores.append(rewards) mean_score = np.mean(scores) if episode % 50 == 0: print(f'[Episode {episode}] - Average Score: {mean_score}') agent.target_model.set_weights\ (agent.model.get_weights()) agent.target_model.save_weights\ (f'drqn_model_weights_{episode}') agent.replay(episode) print(f"Average Score: {np.mean(scores)}") -
使用
gym.make()实例化一个名为env的 Breakout 环境:env = gym.make('BreakoutDeterministic-v4') -
创建两个变量,
IMG_SIZE和SEQUENCE,分别赋值为84和4:IMG_SIZE = 84 SEQUENCE = 4 -
实例化一个名为
agent的DRQN对象:agent = DRQN(env) -
创建一个名为
episodes的变量,赋值为400:episodes = 400 -
调用
train_agent函数,传入env,episodes和agent:train_agent(env, episodes, agent)以下是代码的输出:
[Episode 0] - Average Score: 1.0 [Episode 50] - Average Score: 2.4901960784313726 [Episode 100] - Average Score: 3.92 [Episode 150] - Average Score: 7.37 [Episode 200] - Average Score: 7.76 [Episode 250] - Average Score: 7.91 [Episode 300] - Average Score: 10.33 [Episode 350] - Average Score: 10.94 Average Score: 10.83
在这个活动中,我们构建并训练了一个DARQN代理。它成功学会了如何玩 Breakout 游戏。它的初始分数是 1.0,经过 400 回合后,最终得分超过 10,如前面的结果所示。这是相当出色的表现。
注意
要访问此特定部分的源代码,请参考 packt.live/2XUDZrH。
你也可以在 packt.live/2UDCsUP 上在线运行此示例。
11. 强化学习的基于策略方法
活动 11.01:创建一个使用 DDPG 学习模型的代理
-
导入必要的库(
os、gym和ddpg):import os import gym from ddpg import * -
首先,我们创建我们的 Gym 环境(
LunarLanderContinuous-v2),就像之前一样:env = gym.make("LunarLanderContinuous-v2") -
使用一些合理的超参数初始化代理,如练习 11.02,创建学习代理:
agent = Agent(alpha=0.000025, beta=0.00025, \ inp_dimensions=[8], tau=0.001,\ env=env, bs=64, l1_size=400, l2_size=300, \ nb_actions=2) -
设置一个随机种子,以便我们的实验可以复现。
np.random.seed(0) -
创建一个空数组来存储分数,你可以将其命名为
history。循环至少进行1000个回合,在每个回合中,将运行分数变量设置为0,并将done标志设置为False,然后重置环境。然后,当done标志不为True时,执行以下步骤:history = [] for i in np.arange(1000): observation = env.reset() score = 0 done = False while not done: -
选择观察结果并获取新的
state、reward和done标志。保存observation、action、reward、state_new和done标志。调用代理的learn函数并将当前奖励添加到运行分数中。将新状态设置为观察,并最终,当done标志为True时,将score添加到history中:history = [] for i in np.arange(1000): observation = env.reset() score = 0 done = False while not done: action = agent.select_action(observation) state_new, reward, done, info = env.step(action) agent.remember(observation, action, reward, \ state_new, int(done)) agent.learn() score += reward observation = state_new # env.render() # Uncomment to see the game window history.append(score)你可以打印出
score和平均score_history结果,查看代理在一段时间内如何学习。注意
要观察奖励,我们可以简单地添加
print语句。奖励将与前面练习中的类似。运行代码至少 1,000 次,并观看你的着陆器尝试着陆在月球表面。
注意
要查看学习到策略后的月球着陆仿真,我们只需要取消注释前面代码块中的
env.render()代码。如前面的练习所示,这将打开另一个窗口,我们可以在其中看到游戏仿真。这是你创建的月球着陆器在学习策略后可能的行为表现:
![图 11.16:经过 1,000 轮训练后的环境截图]()
图 11.16:经过 1,000 轮训练后的环境截图
注意
要访问此特定部分的源代码,请参考 packt.live/30X03Ul。
本节目前没有在线互动示例,需要在本地运行。
活动 11.02:加载保存的策略以运行月球着陆仿真
-
导入必要的 Python 库:
import os import gym import torch as T import numpy as np from PIL import Image -
使用
device参数设置你的设备:device = T.device("cuda:0" if T.cuda.is_available() else "cpu") -
定义
ReplayBuffer类,如我们在上一练习中所做的:class ReplayBuffer: def __init__(self): self.memory_actions = [] self.memory_states = [] self.memory_log_probs = [] self.memory_rewards = [] self.is_terminals = [] def clear_memory(self): del self.memory_actions[:] del self.memory_states[:] del self.memory_log_probs[:] del self.memory_rewards[:] del self.is_terminals[:] -
定义
ActorCritic类,如我们在前一个练习中所做的:Activity11_02.ipynb class ActorCritic(T.nn.Module): def __init__(self, state_dimension, action_dimension, \ nb_latent_variables): super(ActorCritic, self).__init__() self.action_layer = T.nn.Sequential\ (T.nn.Linear(state_dimension, \ nb_latent_variables),\ T.nn.Tanh(),\ T.nn.Linear(nb_latent_variables, \ nb_latent_variables),\ T.nn.Tanh(),\ T.nn.Linear(nb_latent_variables, \ action_dimension),\ T.nn.Softmax(dim=-1)) The complete code for this step can be found at https://packt.live/2YhzrvD. -
定义
Agent类,如我们在前面的练习中所做的:Activity11_02.ipynb class Agent: def __init__(self, state_dimension, action_dimension, \ nb_latent_variables, lr, betas, gamma, K_epochs, eps_clip):\ self.lr = lr self.betas = betas self.gamma = gamma self.eps_clip = eps_clip self.K_epochs = K_epochs self.policy = ActorCritic(state_dimension,\ action_dimension,\ nb_latent_variables).to(device) self.optimizer = T.optim.Adam\ (self.policy.parameters(), \ lr=lr, betas=betas) self.policy_old = ActorCritic(state_dimension,\ action_dimension,\ nb_latent_variables)\ .to(device) self.policy_old.load_state_dict(self.policy.state_dict()) The complete code for this step can be found at https://packt.live/2YhzrvD. -
创建月球着陆器环境。初始化随机种子:
env = gym.make(„LunarLander-v2") np.random.seed(0) render = True -
创建内存缓冲区,并按照前面的练习初始化代理和超参数:
memory = ReplayBuffer() agent = Agent(state_dimension=env.observation_space.shape[0],\ action_dimension=4, nb_latent_variables=64,\ lr=0.002, betas=(0.9, 0.999), gamma=0.99,\ K_epochs=4,eps_clip=0.2) -
从
Exercise11.03文件夹加载保存的策略作为旧策略:agent.policy_old.load_state_dict\ (T.load("../Exercise11.03/PPO_LunarLander-v2.pth")) -
最后,循环遍历你期望的回合数。在每次迭代中,首先将回合奖励初始化为
0。不要忘记重置状态。再运行一个循环,指定max时间戳。获取每个动作的state、reward和done标志,并将奖励加到回合奖励中。渲染环境,查看你的月球着陆器的表现:for ep in range(5): ep_reward = 0 state = env.reset() for t in range(300): action = agent.policy_old.act(state, memory) state, reward, done, _ = env.step(action) ep_reward += reward if render: env.render() img = env.render(mode = „rgb_array") img = Image.fromarray(img) image_dir = "./gif" if not os.path.exists(image_dir): os.makedirs(image_dir) img.save(os.path.join(image_dir, "{}.jpg".format(t))) if done: break print("Episode: {}, Reward: {}".format(ep, int(ep_reward))) ep_reward = 0 env.close()以下是代码的输出:
Episode: 0, Reward: 272 Episode: 1, Reward: 148 Episode: 2, Reward: 249 Episode: 3, Reward: 169 Episode: 4, Reward: 35你会看到奖励在正区间波动,因为我们的月球着陆器现在对什么是好的策略有了一些了解。由于学习的空间较大,奖励可能会波动。你可能需要再迭代几千次,以便让代理学习出更好的策略。不要犹豫,随时调整代码中指定的参数。以下截图展示了某些阶段的仿真输出:
![图 11.17:显示月球着陆器仿真的环境]()
图 11.17:显示月球着陆器仿真的环境
在这项活动之前,我们单独解释了一些必要的概念,例如创建学习代理、训练策略、保存和加载已学习的策略等等。通过进行这项活动,你学会了如何通过结合本章所学的内容,自己构建一个完整的 RL 项目或工作原型。
注意
完整的仿真输出可以通过packt.live/3ehPaAj以图像形式查看。
若要访问此部分的源代码,请参考packt.live/2YhzrvD。
本节当前没有在线互动示例,需要在本地运行。
12. 强化学习的进化策略
活动 12.01:倒立摆活动
-
导入所需的包,代码如下:
import gym import numpy as np import math import tensorflow as tf from matplotlib import pyplot as plt from random import randint from statistics import median, mean -
初始化环境、状态和动作空间形状:
env = gym.make('CartPole-v0') no_states = env.observation_space.shape[0] no_actions = env.action_space.n -
创建一个函数,用于生成随机选择的初始网络参数:
def initial(run_test): #initialize arrays i_w = [] i_b = [] h_w = [] o_w = [] no_input_nodes = 8 no_hidden_nodes = 4 for r in range(run_test): input_weight = np.random.rand(no_states, no_input_nodes) input_bias = np.random.rand((no_input_nodes)) hidden_weight = np.random.rand(no_input_nodes,\ no_hidden_nodes) output_weight = np.random.rand(no_hidden_nodes, \ no_actions) i_w.append(input_weight) i_b.append(input_bias) h_w.append(hidden_weight) o_w.append(output_weight) chromosome =[i_w, i_b, h_w, o_w] return chromosome -
创建一个函数,使用参数集生成神经网络:
def nnmodel(observations, i_w, i_b, h_w, o_w): alpha = 0.199 observations = observations/max\ (np.max(np.linalg.norm(observations)),1) #apply relu on layers funct1 = np.dot(observations, i_w)+ i_b.T layer1= tf.nn.relu(funct1)-alpha*tf.nn.relu(-funct1) funct2 = np.dot(layer1,h_w) layer2 = tf.nn.relu(funct2) - alpha*tf.nn.relu(-funct2) funct3 = np.dot(layer2, o_w) layer3 = tf.nn.relu(funct3)-alpha*tf.nn.relu(-funct3) #apply softmax layer3 = np.exp(layer3)/np.sum(np.exp(layer3)) output = layer3.argsort().reshape(1,no_actions) action = output[0][0] return action -
创建一个函数,在使用神经网络时获取
300步的总奖励:def get_reward(env, i_w, i_b, h_w, o_w): current_state = env.reset() total_reward = 0 for step in range(300): action = nnmodel(current_state, i_w, i_b, h_w, o_w) next_state, reward, done, info = env.step(action) total_reward += reward current_state = next_state if done: break return total_reward -
创建一个函数,在执行初始随机选择时获取种群中每个元素的适应度评分:
def get_weights(env, run_test): rewards = [] chromosomes = initial(run_test) for trial in range(run_test): i_w = chromosomes[0][trial] i_b = chromosomes[1][trial] h_w = chromosomes[2][trial] o_w = chromosomes[3][trial] total_reward = get_reward(env, i_w, i_b, h_w, o_w) rewards = np.append(rewards, total_reward) chromosome_weight = [chromosomes, rewards] return chromosome_weight -
创建一个变异函数:
def mutate(parent): index = np.random.randint(0, len(parent)) if(0 < index < 10): for idx in range(index): n = np.random.randint(0, len(parent)) parent[n] = parent[n] + np.random.rand() mutation = parent return mutation -
创建一个单点交叉函数:
def crossover(list_chr): gen_list = [] gen_list.append(list_chr[0]) gen_list.append(list_chr[1]) for i in range(10): m = np.random.randint(0, len(list_chr[0])) parent = np.append(list_chr[0][:m], list_chr[1][m:]) child = mutate(parent) gen_list.append(child) return gen_list -
创建一个函数,通过选择奖励最高的一对来创建下一代:
def generate_new_population(rewards, chromosomes): #2 best reward indexes selected best_reward_idx = rewards.argsort()[-2:][::-1] list_chr = [] new_i_w =[] new_i_b = [] new_h_w = [] new_o_w = [] new_rewards = [] -
使用
for循环遍历索引,获取权重和偏置的当前参数:for ind in best_reward_idx: weight1 = chromosomes[0][ind] w1 = weight1.reshape(weight1.shape[1], -1) bias1 = chromosomes[1][ind] b1 = np.append(w1, bias1) weight2 = chromosomes[2][ind] w2 = np.append\ (b1, weight2.reshape(weight2.shape[1], -1)) weight3 = chromosomes[3][ind] chr = np.append(w2, weight3) #the 2 best parents are selected list_chr.append(chr) gen_list = crossover(list_chr) -
使用已识别的参数构建神经网络,并基于构建的神经网络获得新的奖励:
for l in gen_list: chromosome_w1 = np.array(l[:chromosomes[0][0].size]) new_input_weight = np.reshape(chromosome_w1,(-1,chromosomes[0][0].shape[1])) new_input_bias = np.array\ ([l[chromosome_w1.size:chromosome_w1\ .size+chromosomes[1][0].size]]).T hidden = chromosome_w1.size + new_input_bias.size chromosome_w2 = np.array\ ([l[hidden:hidden \ + chromosomes[2][0].size]]) new_hidden_weight = np.reshape\ (chromosome_w2, \ (-1, chromosomes[2][0].shape[1])) final = chromosome_w1.size+new_input_bias.size\ +chromosome_w2.size new_output_weight = np.array([l[final:]]).T new_output_weight = np.reshape\ (new_output_weight,\ (-1, chromosomes[3][0].shape[1])) new_i_w.append(new_input_weight) new_i_b.append(new_input_bias) new_h_w.append(new_hidden_weight) new_o_w.append(new_output_weight) new_reward = get_reward(env, new_input_weight, \ new_input_bias, new_hidden_weight, \ new_output_weight) new_rewards = np.append(new_rewards, new_reward) generation = [new_i_w, new_i_b, new_h_w, new_o_w] return generation, new_rewards -
创建一个函数来输出收敛图:
def graphics(act): plt.plot(act) plt.xlabel('No. of generations') plt.ylabel('Rewards') plt.grid() print('Mean rewards:', mean(act)) return plt.show() -
创建一个用于遗传算法的函数,根据最高平均奖励输出神经网络的参数:
def ga_algo(env, run_test, no_gen): weights = get_weights(env, run_test) chrom = weights[0] current_rewards = weights[1] act = [] for n in range(no_gen): gen, new_rewards = generate_new_population\ (current_rewards, chrom) average = np.average(current_rewards) new_average = np.average(new_rewards) if average > new_average: parameters = [chrom[0][0], chrom[1][0], \ chrom[2][0], chrom[3][0]] else: parameters = [gen[0][0], gen[1][0], \ gen[2][0], gen[3][0]] chrom = gen current_rewards = new_rewards max_arg = np.amax(current_rewards) print('Generation:{}, max reward:{}'.format(n+1, max_arg)) act = np.append(act, max_arg) graphics(act) return parameters -
创建一个函数,用于解码参数数组到每个神经网络参数:
def params(parameters): i_w = parameters[0] i_b = parameters[1] h_w = parameters[2] o_w = parameters[3] return i_w,i_b,h_w,o_w -
将代数设置为
50,试验次数设置为15,步数和试验数设置为500:generations = [] no_gen = 50 run_test = 15 trial_length = 500 no_trials = 500 rewards = [] final_reward = 0 parameters = ga_algo(env, run_test, no_gen) i_w, i_b, h_w, o_w = params(parameters) for trial in range(no_trials): current_state = env.reset() total_reward = 0 for step in range(trial_length): env.render() action = nnmodel(current_state, i_w,i_b, h_w, o_w) next_state,reward, done, info = env.step(action) total_reward += reward current_state = next_state if done: break print('Trial:{}, total reward:{}'.format(trial, total_reward)) final_reward +=total_reward print('Average reward:', final_reward/no_trials) env.close()输出(这里只显示了前几行)将类似于以下内容:
Generation:1, max reward:11.0 Generation:2, max reward:11.0 Generation:3, max reward:10.0 Generation:4, max reward:10.0 Generation:5, max reward:11.0 Generation:6, max reward:10.0 Generation:7, max reward:10.0 Generation:8, max reward:10.0 Generation:9, max reward:11.0 Generation:10, max reward:10.0 Generation:11, max reward:10.0 Generation:12, max reward:10.0 Generation:13, max reward:10.0 Generation:14, max reward:10.0 Generation:15, max reward:10.0 Generation:16, max reward:10.0 Generation:17, max reward:10.0 Generation:18, max reward:10.0 Generation:19, max reward:11.0 Generation:20, max reward:11.0输出可以通过如下图形进行可视化:
![图 12.15:在各代中获得的奖励]()
图 12.15:在各代中获得的奖励
输出的奖励平均值(这里只显示了最后几行)将类似于以下内容:
Trial:486, total reward:8.0
Trial:487, total reward:9.0
Trial:488, total reward:10.0
Trial:489, total reward:10.0
Trial:490, total reward:8.0
Trial:491, total reward:9.0
Trial:492, total reward:9.0
Trial:493, total reward:10.0
Trial:494, total reward:10.0
Trial:495, total reward:9.0
Trial:496, total reward:10.0
Trial:497, total reward:9.0
Trial:498, total reward:10.0
Trial:499, total reward:9.0
Average reward: 9.384
你会注意到,根据初始状态的不同,GA 算法收敛到最高分数的速度会有所不同;另外,神经网络模型并不总是能够达到最优解。这个活动的目的是让你实现本章学习的遗传算法技术,并观察如何结合神经网络参数调优的进化方法来进行动作选择。
注意
要访问该特定部分的源代码,请参考packt.live/2AmKR8m。
本节目前没有在线交互示例,需要在本地运行。


:如果任务具有有限的时间跨度,则
也可以是
。
是智能体在时间步 t 采取的动作。该动作属于由当前状态
定义的动作空间,
。
是智能体在时间 t 接收到的环境状态的表示。它属于由环境定义的状态空间,
。状态可以用图像、图像序列或假设有不同形状的简单向量表示。请注意,实际的环境状态可能与智能体感知到的状态不同,且更为复杂。
由一个实数表示,描述所采取的行动有多好。高奖励对应于一个好的行动。奖励对于代理理解如何实现目标至关重要。
;上限为 +3.1038,基本上是
。
和
:一个均匀分布
:一个平移的指数分布
:一个平移的负指数分布
:一个正态分布
是从状态
过渡到状态
的概率。
是从状态
过渡到状态
的奖励。
是与未来奖励相关的折扣因子,
:
。由于状态 1 没有自环,这是正确的。从状态 1 到状态 2 的转移概率是
,因为它是与边缘1->2相关联的概率。这可以应用于转移矩阵的所有元素。请注意,在这里,转移矩阵的元素按状态而不是矩阵中的位置索引。这意味着对于
,我们指的是矩阵的 0,0 元素。
。我们有
和
:
是状态集。
是动作集。
是奖励函数,
。
是在执行动作
和处于状态
时的期望奖励。
是转移概率函数,其中
是从当前状态
出发,执行动作
,并且到达状态
的概率。
是与未来奖励相关的折扣因子,
。

:这个项是给定代理策略定义的动作分布时即时奖励的期望值。每个状态-动作对的即时奖励都会根据给定状态下的动作概率加权,这个概率定义为
。
是给定转移函数定义的状态分布下,状态值函数的折扣期望值。请注意,这里的动作,
,都会根据从状态
到状态
的转移概率加权,给定动作
。这由
表示。
(列向量)是由策略引导的每个状态的即时奖励的期望值:
是每个状态的状态值列向量。
是基于动作分布的转移矩阵。它是一个
矩阵,其中
是 MDP 中状态的数量。给定两个状态
和
,我们得到以下内容:
的知识决定。
和
是好单元格。对于这些单元格,每个动作将代理引导到状态
和
,对应的回报分别是:从状态
外移的回报是 +10,从状态
外移的回报是 +5。
和
是坏单元格。对于这些单元格,所有动作的回报都是 -1。



















和
之间的递归关系,意味着
具有最优子结构。
将在某些时刻需要重新计算,这意味着它存在重叠子问题。动态规划(DP)的两个条件都符合,因此我们可以利用它来加速解决方案。






:
:






,该值必须在区间
,对于所有的
,
,随意设置,唯一例外是 Q(terminal, ·) = 0,如以下代码片段所示,在一个有
,并使用从 Q 导出的策略(例如,ε-贪心)从
中选择
。这可以通过以下代码片段实现,其中初始状态由环境的
并观察
。从
中选择
,使用从 Q 导出的策略(例如,ε-贪心)。使用 SARSA 规则更新选定状态-动作对的状态-动作值函数,该规则将新值定义为当前值与 TD 误差乘以步长的和,
,如以下表达式所示:![图 7.4:使用 SARSA 规则更新状态-动作值函数]



,它必须位于区间(0, 1]内,以及ε-greedy 策略的
,
,
进行初始化,任意设置,除了 Q(terminal, *) = 0:
中选择
:
,观察
。使用 Q-learning 规则更新所选状态-动作对的状态-动作值函数,该规则将新值定义为当前值加上与步长
相乘的与离策略相关的 TD 误差。可以表示如下:


,它必须位于区间(0, 1]内,和ε-贪心策略的
,对于所有
,
,任意选择,除了 Q(终止, ·) = 0:
。观察并存储下一奖励为
,下一状态为
。如果
是终止状态,则将
不是终止状态,则选择并存储新状态下的动作:
,使得每个状态的每个动作的概率对于所有状态和动作都大于 0。选择算法参数:步长,
,其必须在区间(0, 1]内,并为步数选择一个值
,对于所有
,
可以任意选择,除非 Q(终止,·) = 0:
,使其对 Q 采取贪婪策略,或设定为一个固定的给定策略。为每个回合创建一个循环。初始化并存储 S0 ≠终止状态。使用 b 策略选择并存储一个动作,并将时间
。观察并存储下一次奖励为
,并将下一个状态存储为
。如果
是终止状态,则将
不是终止状态,则选择并存储新状态的动作:
,该值必须位于区间(0, 1]内,和ε-贪心策略的
,对于所有
,
,任选初始化,除了 Q(terminal, ·) = 0:
更新,并使用 SARSA TD(
) 规则更新 Q 表:
)规则更新 Q 表:
)表现随着
更新并使用 SARSA TD(
)规则更新 Q 表:















。
。

- 当前状态
- 执行动作
- 新状态
- 奖励
- 表示该回合是否完成




,即新策略和旧策略之间的概率比,如前所述。
,并向后传播我们的损失。最后,我们使用新权重更新旧策略。
。
,并存储状态、动作、奖励和转移。
的函数:
值。
的值:
:
变量来确定初始步长:
的最终值:
变量来创建初始种群:
),它可以作为染色体在种群列表中的索引,
表示所需的染色体数量(父代):

,因为我们仅考虑基因之间的差异(它们的不一致性)和权重之间的差异:
,则概率分布是一个平移的负指数分布:
,则概率分布是一个平移的指数分布:




:












浙公网安备 33010602011771号