强化学习研讨会(四)

原文:annas-archive.org/md5/e0caa69bfbd246ee6119f0157bdca923

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:8. 多臂老虎机问题

概述

本章将介绍流行的多臂老虎机问题及其一些常用的解决算法。我们将通过一个互动示例学习如何用 Python 实现这些算法,如 Epsilon 贪心算法、上置信界算法和汤普森采样。我们还将了解作为一般多臂老虎机问题扩展的上下文老虎机问题。通过本章的学习,你将深入理解一般的多臂老虎机问题,并具备应用一些常见方法来解决该问题的技能。

介绍

在上一章中,我们讨论了时间差分学习方法,这是一种流行的无模型强化学习算法,它通过信号的未来值来预测一个量。在本章中,我们将关注另一个常见话题,这不仅在强化学习中应用广泛,在人工智能和概率论中也具有重要地位——多臂老虎机MAB)问题。

作为一个顺序决策问题,旨在通过在赌场老虎机上游戏来最大化奖励,多臂老虎机问题广泛适用于任何需要在不确定性下进行顺序学习的情况,如 A/B 测试或设计推荐系统。本章将介绍该问题的形式化方法,了解几种常见的解决算法(即 Epsilon 贪心算法、上置信界算法和汤普森采样),并最终在 Python 中实现它们。

总体来说,本章将让你深入理解多臂老虎机问题在不同的顺序决策场景中的应用,并为你提供将这一知识应用于解决一种变体问题——排队老虎机问题的机会。

首先,让我们从讨论问题的背景和理论表述开始。

多臂老虎机问题的表述

最简单形式的多臂老虎机(MAB)问题由多个老虎机(赌场赌博机)组成,每次玩家玩一个老虎机时(具体来说,当它的臂被拉动时),老虎机会随机地给玩家一个奖励。玩家希望在固定轮次结束时最大化自己的总奖励,但他们不知道每个老虎机的概率分布或平均奖励。因此,这个问题的核心就是设计一个学习策略,在这个策略中,玩家需要探索每个老虎机可能返回的奖励值,并从中快速识别出最有可能返回最大期望奖励的那个老虎机。

在本节中,我们将简要探讨问题的背景,并建立本章中将使用的符号和术语。

多臂老虎机问题的应用

我们之前提到的老虎机只是我们设定的一个简化版。在一般情况下的 MAB 问题中,我们在每一步面临一组可选择的多个决策,并且我们需要充分探索每个决策,以便更加了解我们所处的环境,同时确保尽早收敛到最优决策,以使得最终的总奖励最大化。这就是我们在常见的强化学习问题中面临的经典探索与利用的权衡。

MAB 问题的常见应用包括推荐系统、临床试验、网络路由,以及如我们将在本章末尾看到的排队理论。这些应用都包含了定义 MAB 问题的典型特征:在每一个顺序决策过程中,决策者需要从预定的可选项中进行选择,并且根据过去的观察,决策者需要在探索不同选择和利用认为最有利的选择之间找到平衡。

举个例子,推荐系统的目标之一是展示客户最可能考虑或购买的产品。当一个新客户登录像购物网站或在线流媒体服务这样的系统时,推荐系统可以观察客户的过去行为和选择,并根据这些信息决定应向客户展示什么样的产品广告。它这样做是为了最大化客户点击广告的概率。

另一个例子,稍后我们将更详细地讨论,是在一个由多个客户类别组成的排队系统中,每个类别都具有一个未知的服务速率。排队协调员需要弄清楚如何最好地安排这些客户,以优化某个目标,例如整个队列的累计等待时间。

总体而言,MAB 问题是人工智能领域,尤其是强化学习中的一个日益普遍的问题,它有许多有趣的应用。在接下来的章节中,我们将正式化该问题并介绍本章将使用的术语。

背景与术语

MAB 问题的特征如下:

  • 一组可以选择的“K”个动作。每个动作称为“臂”,这是根据传统的老虎机术语来命名的。

  • 一位中央决策者需要在每一步从这组动作中做出选择。我们将选择动作的行为称为“拉臂”,而决策者则称为“玩家”。

  • 当拉动其中一个“K”个可用臂时,玩家会从该臂特定的概率分布中随机抽取一个随机奖励。重要的是,奖励是从各自的分布中随机选择的;如果奖励是固定的,玩家就能快速识别出能够提供最高回报的臂,这样问题就不再有趣。

  • 玩家目标仍然是,在每个步骤中从“K”个臂中选择一个,以便在最后最大化奖励。过程中的步骤数称为视野,玩家可能知道也可能不知道。

  • 在大多数情况下,每个臂可以被拉动无限次。当玩家确定某个特定臂是最优的时,他们可以继续选择该臂进行后续操作而不偏离。然而,在不同的环境下,某个臂被拉动的次数是有限的,从而增加了问题的复杂性。

下图展示了我们所使用环境中的一个迭代步骤,其中有四个臂,其成功率分别估计为 70%、30%、55%和 40%。

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_01.jpg

图 8.1:典型的 MAB 迭代

在每一步中,我们需要决定应该选择哪个臂来拉动:

  • 与奖励的语言和相应的最大化目标相对,一个 MAB 问题也可以从成本最小化目标的角度来框定。排队的例子可以再次使用:整个队列的累计等待时间是一个负值,换句话说,这就是需要最小化的成本。

  • 通常将一个策略的表现与最优策略或天才策略进行比较,天才策略事先知道哪个臂是最优的,并在每一步都拉动那个臂。当然,任何现实中的学习策略都不太可能模拟天才策略的表现,但它为我们提供了一个固定的度量标准来与我们的策略进行比较。给定策略与天才策略的表现差异称为遗憾,目标是最小化这个遗憾。

MAB 问题的核心问题是如何以最小的探索(拉动次优臂)识别出具有最大期望奖励(或最小期望成本)的臂。这是因为玩家的探索越多,选择最优臂的频率就越低,最终的奖励也会随之减少。然而,如果玩家没有充分探索所有的臂,他们可能会错误地识别最优臂,最终导致总奖励受到负面影响。

当真实最优臂的随机奖励在前几次实验中看起来低于其他臂的奖励(由于随机性)时,可能会导致玩家错误地识别最优臂。根据每个臂的实际奖励分布,这种情况发生的可能性较高。

所以,这就是我们在本章中要解决的总体问题。我们现在需要简要考虑多臂赌博机背景下的奖励概率分布的概念,以便充分理解我们正在尝试解决的问题。

多臂赌博机奖励分布

在传统的多臂赌博机问题中,每个臂的奖励都与伯努利分布相关联。每个伯努利分布又由一个非负数p来参数化,p的最大值为 1。当从伯努利分布中抽取一个数时,它可以取两个可能的值:1,其概率为p,以及 0,其概率为1 - p。因此,p的较高值对应玩家应当拉动的较好臂。这是因为玩家更有可能收到 1 作为奖励。当然,p的高值并不保证从特定臂获得的奖励始终为 1,事实上,即使是从p值最高的臂(也就是最优臂)拉取,某些奖励也可能为 0。

以下图是伯努利赌博机设置的一个示例:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_02.jpg

图 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

同样,由于我们从伯努利分布中抽取,输出可能是01

假设我们想要检查每个臂的奖励分布是什么样的,或者更具体地说,想知道这两个臂中哪个更有可能返回更多的奖励。为此,我们从每个臂拉取 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()

接着会生成如下图表:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_03.jpg

图 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,并使用以下截图中显示的选项运行代码,从而获得相同的结果:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_04.jpg

图 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()方法中,我们还有一个选项来可视化rewardsoptimal_rewards这两个列表之间的累计和差异,这由visualize_regret参数指定。实质上,这个选项将绘制出我们算法的累计遗憾与轮次号之间的关系图。由于我们在调用中启用了此选项,将生成以下图表:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_05.jpg

](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,这将帮助我们绘制每次实验结束时算法的累积遗憾分布图。

确实,当这段代码运行完成时,以下图形将被生成:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_06.jpg

图 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 实现ε-贪婪算法

类似于实现贪婪算法时的做法,在本练习中,我们将学习如何实现ε-贪婪算法。这个练习将分为三个主要部分:实现ε-贪婪的逻辑,测试其在示例强盗问题中的表现,最后通过多次实验来评估其性能。

我们将按照以下步骤来实现:

  1. 创建一个新的 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 的随机种子数固定,以确保代码的可重复性。

  2. 现在,开始实现ε-贪婪算法的逻辑。首先,它的初始化方法应该接受两个参数:要解决的强盗问题的臂数和ε,探索概率:

    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属性中。

  3. 在同一个代码单元格中,实现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()))
    
  4. 在同一代码单元格中,为eGreedy类实现update()方法,该方法应与Greedy类中的相应方法相同:

        def update(self, arm_id, reward):
            self.reward_history[arm_id].append(reward)
    

    再次说明,这种方法只需要将最近一次的奖励添加到该臂的奖励历史中。

    这就是我们ε-Greedy 算法的完整实现。

  5. 在下一个代码单元格中,创建一个具有三个伯努利臂的赌博机问题实验,这些臂的相应概率分别为0.90.80.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)
    

    这应该会产生以下图表:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_07.jpg

    图 8.7:ε-Greedy 算法的样本累积后悔

    与我们在 Greedy 算法中看到的相应图表相比,这里的累积后悔变化更大,有时会增长到4,有时会降到-2。这正是算法探索增加的效果。

  6. 在下一个代码单元格中,我们打印出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,这大概是由于随机探索概率的原因。

  7. 在下一个代码单元格中,我们将进行相同的实验,不过这次我们将设置ε = 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)
    

    这将产生以下图表:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_08.jpg

    图 8.8:增加探索概率后的样本累积后悔

    在这里,我们的累积后悔比在步骤 5 中设置ε = 0.01时要高得多。这大概是因为增加的探索概率过高所导致的。

  8. 为了进一步分析这个实验,我们可以再次打印出动作历史:

    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 可能不是一个合适的探索概率。

  9. 作为我们对ε-Greedy 算法分析的最后一个部分,让我们利用重复实验选项。这次,我们将选择ε = 0.03,如下所示:

    regrets = bandit.repeat(eGreedy, [N_ARMS, 0.03], \
                            n_experiments=100, n_rounds=300,\
                            visualize_regret_dist=True)
    

    接下来的图表将展示来自这些重复实验的累积后悔分布:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_09.jpg

    图 8.9:ε-Greedy 算法的累积后悔分布

    这个分布与我们在贪婪算法中得到的结果非常相似。接下来,我们将进一步比较这两种算法。

  10. 使用以下代码计算这些累积遗憾值的均值和最大值:

    np.mean(regrets), np.max(regrets)
    

    输出结果如下:

    (9.95, 64)
    

将这一点与我们在贪婪算法中得到的结果(8.6662)进行比较,结果表明在这个特定的强盗问题中,ε-贪婪算法可能会表现得较差。然而,它通过探索率成功地形式化了探索与利用之间的选择,而这是贪婪算法所缺乏的。这是一个多臂强盗(MAB)算法的宝贵特性,也是我们将在本章后面讨论的其他算法的重点。

注意

要访问这一特定部分的源代码,请参考 packt.live/3fiE3Y5

你也可以在网上运行这个例子,链接是 packt.live/3cYT4fY

在进入下一部分之前,让我们简要讨论一下另一种所谓的贪婪算法变种——Softmax 算法。

Softmax 算法

Softmax 算法试图通过选择每个可用臂的概率来量化探索与利用之间的权衡,这个概率与其平均奖励成正比。形式上,算法在每个时间步骤t选择臂i的概率如下:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_10.jpg

图 8.10:表示算法在每个时间步骤选择该臂的概率的公式

指数中的每一项 https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_10a.png 是从臂i在前*(t - 1)*个时间步骤中观察到的平均奖励。根据定义概率的方式,平均奖励越大,相应的臂被选择的可能性就越大。在其最一般的形式下,这个平均项被一个可调参数 https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_10b.png 除以,后者控制算法的探索率。具体来说,当https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_10c.png趋向于无穷大时,最大臂的选择概率会趋近于 1,而其他臂的选择概率趋近于 0,使得算法完全贪婪(这也是我们认为它是贪婪算法的一种推广的原因)。当https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_10d.png 趋近于 0 时,选择一个暂时次优的臂的可能性增大。当它趋向于 0 时,算法会无限期地均匀探索所有可用的臂。

类似于我们在设计ε-贪婪算法时遇到的问题,如何为每个特定的强盗问题设置该参数https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_10e.png的值并不完全明确,尽管算法的性能在很大程度上依赖于该参数。因此,Softmax 算法不像我们将在本章讨论的其他算法那样流行。

就这样,我们结束了对贪心算法的讨论,这是我们解决 MAB 问题的第一种方法,以及它的三种变体:先探索后承诺、ε-贪心和 Softmax。总体来说,这些算法专注于利用具有最大奖励均值的臂,同时有时偏离这个臂去探索其他看似次优的臂。

在接下来的章节中,我们将介绍另一种常见的 MAB 算法——上置信界限UCB),其直觉与我们到目前为止所看到的稍有不同。

UCB 算法

上置信界限这个术语表示,算法不是像贪心算法那样考虑每个臂返回的过去奖励的平均值,而是计算每个臂预期奖励的估计值的上界。

置信界限这个概念在概率论和统计学中是非常常见的,其中我们关心的量(在这个例子中是每个臂的奖励)的分布不能仅通过过去观测值的平均值来良好表示。相反,置信界限是一个数值范围,旨在估计并缩小在该分布中大多数值所在的范围。例如,这一概念在贝叶斯分析和贝叶斯优化中被广泛应用。

在接下来的章节中,我们将讨论 UCB 如何建立其使用置信界限的方法。

不确定性面前的乐观主义

考虑一个只有两个臂的赌博机过程的中间部分。我们已经拉动过第一个臂 100 次,并观察到平均奖励为0.61;对于第二个臂,我们仅见过五个样本,其中三个样本的奖励为1,所以它的平均奖励是0.6。我们是否应该承诺在剩余的回合中探索第一个臂并忽视第二个臂?

很多人会说不;我们至少应该更多地探索第二个臂,以便更好地估计其期望奖励。这一观察的动机是,由于我们仅有很少的第二个臂的奖励样本,我们不应该确信第二个臂的平均奖励实际上低于第一个臂。那么,我们应该如何将我们的直觉形式化呢?UCB 算法,或者说其最常见的变种——UCB1 算法——指出,我们将不再使用平均奖励,而是使用以下平均奖励和置信界限之和:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_11.jpg

图 8.11:UCB 算法的表达式

在这里,https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_11a.png表示当前我们与赌博机互动时的时间步长,或回合数,https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_11b.png表示我们已经拉动过的臂数,直到回合https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_11e.png。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中元素最大化的臂,这个列表存储了每只臂的乐观估算值。

你可能会想,为什么使用前述的量能捕捉我们想要应用于期望奖励估算的置信区间的概念。请记住我们之前所举的两臂赌博机的例子,我们希望有一个形式化的过程,能够鼓励探索那些很少被探索的臂(在我们的例子中是第二只臂)。正如你所见,在任何给定回合,这个量是https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_11d.png的递减函数。换句话说,当https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_11f.png很大时,这个量会变小;而当情况相反时,它会变大。因此,这个量由那些拉动次数较少的臂最大化——也就是那些探索较少的臂。在我们的例子中,第一只臂的估算如下:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_12.jpg

图 8.12:第一只臂的估算

第二只臂的估算如下:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_13.jpg

图 8.13:第二只臂的估算

使用 UCB 算法时,我们选择拉取第二只臂,这也是我们认为正确的选择。通过在平均奖励中加入所谓的探索项,我们从某种意义上来说,是在估算期望均值的最大可能值,而不仅仅是期望均值本身。这一直觉可以用“在不确定性面前保持乐观”这一术语来总结,它是 UCB 算法的核心特征。

UCB 的其他特性

UCB 并非毫无根据地乐观。当一只臂显著未被充分探索时,探索项会使得该量变大,从而增加被 UCB 选择的可能性,但并不能保证这只臂一定会被选择。具体来说,当某只臂的平均奖励非常低,以至于探索项无法弥补时,UCB 依然会选择利用那些表现良好的臂。

我们还应当讨论它在以成本为中心的 MAB 问题中的变化,这就是下置信区间LCB)。对于奖励为中心的问题,我们将探索项加入到平均奖励中,以计算对真实均值的乐观估算。当 MAB 问题是最小化臂返回的成本时,我们的乐观估算变成了平均成本减去探索项,UCB 将选择最小化这一量的臂,或者在这种情况下,选择 LCB。

具体来说,我们在这里说的是,如果某个臂的探索次数较少,它的真实平均成本可能比我们目前观察到的要低,因此我们从探索项中减去平均成本,以估算某个臂的最低可能成本。除此之外,这种 UCB 变体的实现保持不变。

这就是关于 UCB 的全部理论内容。为了总结我们对该算法的讨论,我们将在下一个练习中实现它,以解决我们之前使用的伯努利三臂赌博机问题。

练习 8.02 实现 UCB 算法

在本次练习中,我们将实现 UCB 算法。本练习将引导我们通过熟悉的工作流程来分析 MAB 算法的表现:将其实现为一个 Python 类,进行一次实验并观察其行为,最后多次重复实验,考虑由此产生的后悔分布。

我们将按照以下步骤进行:

  1. 创建一个新的 Jupyter Notebook,导入 NumPyMatplotlib,以及从代码库中包含的 utils.py 文件中的 Bandit 类:

    import numpy as np
    np.random.seed(0)
    import matplotlib.pyplot as plt
    from utils import Bandit
    
  2. 声明一个名为 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 中跟踪一个额外的信息——当前轮次号。这个信息在计算上置信界限的探索项时使用。

  3. 实现类的 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()))
    

    上述代码不言自明:在每个臂至少拉一次之后,我们计算置信界限,作为经验均值奖励和探索项的总和。最后,我们返回具有最大总和的臂,必要时随机打破平局。

  4. 在同一个代码单元中,像这样实现类的 update() 方法:

        def update(self, arm_id, reward):
            self.reward_history[arm_id].append(reward)
            self.t += 1
    

    我们已经对大部分逻辑比较熟悉,来自之前的算法。注意,在每次调用 update() 时,我们还需要递增属性 t

  5. 声明我们一直在考虑的伯努利三臂赌博机问题,并在我们刚刚实现的 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)
    

    这段代码将生成以下图表:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_14.jpg

    图 8.14:UCB 算法的样本累计后悔

    在这里,我们可以看到,这个累计后悔显著比我们在 Greedy 算法中看到的要糟糕,后者最多为 2。我们假设这种差异直接源自算法的乐观性质。

  6. 为了更好地理解这种行为,我们将检查算法的拉臂历史:

    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)。这是由于它倾向于乐观地探索看似次优的臂的直接影响。

  7. 表面上,我们可能会得出结论:对于这个赌博机问题,UCB 算法实际上并不优于贪婪算法。但要真正确认这一点,我们需要检查该算法在多个实验中的表现。使用来自赌博机 API 的repeat()方法来确认这一点:

    regrets = bandit.repeat(UCB, [N_ARMS], n_experiments=100, \
                            n_rounds=300, visualize_regret_dist=True)
    

    这段代码将生成以下图表:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_15.jpg

    图 8.15:UCB 算法的遗憾分布

    令我们惊讶的是,这个分布中的遗憾值明显低于由贪婪算法得出的结果。

  8. 除了可视化分布外,我们还需要考虑所有实验的平均遗憾和最大遗憾:

    np.mean(regrets), np.max(regrets)
    

    输出结果如下:

    (18.78, 29)
    

如你所见,数值明显低于我们在贪婪算法中看到的对应统计数据,后者为8.6662。在这里,我们可以说有证据支持“UCB 算法在最小化赌博机问题的累计遗憾方面优于贪婪算法”这一说法。

注意

要访问此特定部分的源代码,请参考packt.live/3fhxSmX

你还可以在网上运行这个示例,地址是:packt.live/2XXuJmK

这个示例也说明了在分析 MAB 算法性能时重复实验的重要性。正如我们之前所见,仅使用一次实验,我们可能得出错误的结论,认为 UCB 算法在我们考虑的特定赌博机问题中劣于贪婪算法。然而,通过多次重复实验,我们可以看到事实恰恰相反。

在整个过程中,我们实现了 UCB 算法,并学习了在使用多臂老虎机算法(MAB)时进行全面分析的必要性。这也标志着 UCB 算法话题的结束。在接下来的章节中,我们将开始讨论本章的最后一种 MAB 算法:汤普森采样。

汤普森采样

到目前为止,我们所看到的算法组成了一套多元化的见解:贪婪算法及其变体主要关注利用,可能需要明确地强制执行探索;而 UCB 则倾向于对尚未充分探索的臂的真实期望回报持乐观态度,因此自然地,但也正当合理地,专注于探索。

汤普森采样也采用了完全不同的直觉。然而,在我们理解算法背后的思想之前,需要讨论其主要构建模块之一:贝叶斯概率的概念。

贝叶斯概率简介

一般来说,使用贝叶斯概率描述某个量的工作流程包括以下元素:

  • 一个先验概率,表示我们对某个量的先验知识或信念。

  • 一个似然概率,表示正如术语的名称所示,当前为止我们所观察到的数据的可能性。

  • 最后,后验概率是前面两个元素的组合。

贝叶斯概率的一个基本组成部分是贝叶斯定理:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_16.jpg

图 8.16:贝叶斯定理

在这里,P(X)表示给定事件X的概率,而P(X | Y)是给定事件Y已经发生的情况下,事件X发生的概率。后者是条件概率的一个例子,条件概率是机器学习中常见的对象,尤其是当不同事件/量彼此条件依赖时。

这个公式具体阐明了我们这里的贝叶斯概率的基本思想:假设我们给定了一个事件A的先验概率,并且我们也知道在事件A发生的情况下,事件B发生的概率。这里,给定事件B发生后,事件A的后验概率与上述两种概率的乘积成正比。事件 A 通常是我们关心的事件,而事件 B 则是我们已经观察到的数据。为了更好理解,让我们将这个公式应用于伯努利分布的背景下。

我们想估计未知参数p,该参数描述了一个伯努利分布,从中我们已经观察到了五个样本。由于伯努利分布的定义,这五个样本的和等于x,即一个介于 0 到 5 之间的整数的概率为https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_16a.png(如果你不熟悉这个表达式也不用担心)。

但是,如果样本是我们能观察到的,而我们不确定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

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_17.jpg

图 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 三臂赌博机问题。具体来说,我们将按以下步骤进行操作:

  1. 创建一个新的 Jupyter Notebook,导入NumPyMatplotlib以及来自代码库中utils.py文件的Bandit类:

    import numpy as np
    np.random.seed(0)
    import matplotlib.pyplot as plt
    from utils import Bandit
    
  2. 声明一个名为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)。

  3. 实现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 分布中抽取样本。

    最后,我们选择与最大样本对应的臂。

  4. 在同一代码单元中,为类实现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
    

    请记住,第一个参数α应该随着我们观察到的每个样本而递增,而β应该在样本为零时递增。前面的代码实现了这一逻辑。

  5. 接下来,设置熟悉的伯努利三臂赌博机问题,并将汤普森采样类的实例应用于该问题,以绘制单次实验中的累积遗憾:

    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)
    

    将生成以下图形:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_18.jpg

    图 8.18:汤普森采样的样本累积遗憾

    这个遗憾图比我们用 UCB 得到的要好,但比用贪心算法得到的要差。该图将在下一步中与拉取历史一起使用,用于进一步分析。我们来进一步分析拉取历史。

  6. 打印出拉取历史:

    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 分布都集中在一个峰值周围)。这是汤普森采样的典型行为。

  7. 正如我们所学,考虑单个实验可能不足以分析算法的表现。为了对汤普森采样的表现进行更全面的分析,我们来设置常规的重复实验:

    regrets = bandit.repeat(BernoulliThompsonSampling, [N_ARMS], \
                            n_experiments=100, n_rounds=300,\
                            visualize_regret_dist=True)
    

    这将生成以下的遗憾分布:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_19.jpg

    图 8.19:汤普森采样的累积遗憾分布

    在这里,我们可以看到,汤普森采样能够将所有实验中的累积遗憾大幅度地最小化,相比其他算法(分布中的最大值仅为10)。

  8. 为了量化这一说法,我们来打印出这些实验中的平均和最大后悔值:

    np.mean(regrets), np.max(regrets)
    

    输出将如下所示:

    (4.03, 10)
    

这显著优于其他算法的对比统计:Greedy 算法的值为8.6662,而 UCB 的值为18.7829

注意

要访问此特定部分的源代码,请参考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 问题。在这个活动中,我们将重新实现本章中学习到的算法,并在排队问题的背景下比较它们的表现。因此,本活动将加深我们对本章所讨论概念的理解,并使我们有机会解决一个上下文强盗问题。

通过这些步骤,让我们开始活动:

  1. 创建一个新的 Jupyter Notebook,在其第一个代码单元格中,导入NumPyutils.py中的 QueueBandit 类。务必将 NumPy 的随机种子设置为0

  2. 使用前文代码声明该类的实例。

  3. 在新的代码单元格中,实现该排队问题的贪心算法,并使用前文提供的代码将其应用于强盗对象。除了显示累计等待时间分布的直方图外,还需要打印出其中的平均值和最大值。

    再次,贪心算法应该选择在每次队列迭代中具有较低平均作业长度的客户类别。

    输出结果如下:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_20.jpg

    (1218887.7924350922, 45155.236786598274)
    
  4. 在新的代码单元格中,实现该问题的 Explore-then-commit 算法。该算法的类实现应接受一个名为T的参数,指定在实验开始时算法应进行多少次探索轮次。

  5. 类似于贪心算法,将T=2的 Explore-then-commit 算法应用于强盗对象。比较该算法产生的累计等待时间分布以及其结果中的平均值和最大值与贪心算法的结果。

    这将生成以下图表:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_21.jpg

    (1238591.3208636027, 45909.77140562623)
    
  6. 在新的代码单元格中,实现该问题的 Thompson Sampling 算法。

    要对指数分布的未知速率参数进行建模,应使用伽马分布作为共轭先验。伽马分布也由两个参数α和β来参数化;它们关于样本作业长度 x 的更新规则是 α = α + 1β = β + x。一开始,两个参数都应初始化为0

    要从伽马分布中抽取样本,可以使用np.random.gamma()函数,该函数接受α和 1 / β。与我们的贪心算法和探索-再承诺算法的逻辑类似,应该在每次迭代中选择采样率最高的类别。

  7. 将算法应用于强盗对象,并通过累计等待时间分析其性能。与贪心算法和探索-再承诺算法进行比较。

    将生成以下图形:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_22.jpg

    (1218887.7924350922, 45129.343871806814)
    
  8. 在上下文老虎机问题中,通常会开发专门的算法。这些算法是常见 MAB 算法的变种,专门设计用于利用上下文信息。

    在一个新的代码单元中,实现汤普森采样的一个利用型变种,其逻辑在每个实验开始时类似于汤普森采样,并且在至少一半客户被服务后,完全依靠贪心策略(像贪婪算法那样),选择具有最低平均作业时长的类别。

  9. 将该算法应用于老虎机对象,并将其性能与传统汤普森采样以及我们已实现的其他算法进行比较。

    绘图结果如下:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_08_23.jpg

图 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 学习之前,有必要快速回顾一下深度学习的基础知识。

在我们深入研究神经网络之前,首先了解一下什么是感知器。下图表示的是一个通用的感知器:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_01.jpg

图 9.1:感知器

感知器是一种二元线性分类器,输入首先与权重相乘,然后我们将所有这些乘积的值加权求和。接着,我们将这个加权和通过激活函数或阶跃函数。激活函数用于将输入值转换为特定的输出值,例如(0,1),用于二元分类。这个过程可以在前面的图中可视化。

深度前馈网络,通常我们也称之为多层感知机MLPs),在多个层次上有多个感知机,如图 9.2所示。MLP 的目标是逼近任何函数。例如,对于一个分类器,这个函数https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_01a.png,将输入映射,https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_01b.png,通过学习参数的值将其归类为 y(对于二分类问题,可能是 0 或 1)

描述自动生成](https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_01c.png)或者权重。下图展示了一个通用的深度神经网络:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_02.jpg

图 9.2:深度神经网络

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

下图展示了训练深度学习模型所需的一般步骤:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_03.jpg

图 9.3:深度学习模型训练流程

一个典型深度学习模型的训练过程可以解释如下:

  1. 决定网络架构

    首先,我们需要决定网络的架构,比如网络中有多少层,以及每一层将包含多少个节点。

  2. 初始化权重和偏置

    在网络中,每一层的每个神经元都将与上一层的所有神经元相连。这些神经元之间的连接都有相应的权重。在整个神经网络的训练过程中,我们首先初始化这些权重的值。每个神经元还会附带一个相应的偏置组件。这一初始化过程是一次性的。

  3. sigmoidrelutanh)用于产生非线性输出。这些值会通过每一层的隐藏层传播,最终在输出层产生输出。

  4. 计算损失

    网络的输出与训练数据集的真实/实际值或标签进行比较,从而计算出网络的损失。网络的损失是衡量网络性能的指标。损失越低,网络性能越好。

  5. 更新权重(反向传播)

    一旦我们计算出网络的损失,目标就是最小化网络的损失。这是通过使用梯度下降算法来调整与每个节点相关的权重来实现的。梯度下降是一种用于最小化各种机器学习算法中损失的优化算法:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_04.jpg

    图 9.4:梯度下降

    损失最小化反过来推动预测值在训练过程中更接近实际值。学习率在决定这些权重更新的速率中起着至关重要的作用。其他优化器的例子有 Adam、RMSProp 和 Momentum。

  6. 继续迭代:

    前面的步骤(步骤 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 深度神经网络时应该遵循的一些步骤:

  1. 导入所需的库,准备数据,并定义源数据和目标数据。请注意,当使用任何 PyTorch 模型时,您需要将数据转换为 torch 张量。

  2. 使用类构建模型架构。

  3. 定义要使用的损失函数和优化器。

  4. 训练模型。

  5. 使用模型进行预测。

让我们进行一个练习,构建一个简单的 PyTorch 深度学习模型。

练习 9.01:在 PyTorch 中构建一个简单的深度学习模型

本练习的目的是在 PyTorch 中构建一个工作的端到端深度学习模型。本练习将带您逐步了解如何在 PyTorch 中创建神经网络模型,并如何使用示例数据在 PyTorch 中训练同一模型。这将展示 PyTorch 中的基本过程,我们稍后将在深度 Q 学习部分中使用:

  1. 打开一个新的 Jupyter 笔记本。我们将导入所需的库:

    # Importing the required libraries
    import numpy as np
    import torch
    from torch import nn, optim
    
  2. 然后,使用 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]))
    

    非常重要的是要注意输入和目标数据集的形状。这是因为深度学习模型应与输入和目标数据的形状兼容,以进行矩阵乘法运算。

  3. 将网络架构定义如下:

    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 函数中与线性层结合使用非线性函数,如 relutanh

    神经网络架构表示输入层中的 3 个节点,隐藏层中的 10 个节点和输出层中的 1 个节点。在 forward 函数中,我们将在隐藏层中使用 relu 激活函数。一旦定义了模型类,我们必须实例化模型。

    现在,您应该已经成功创建并启动了一个模型。

  4. 现在定义损失函数和优化器。我们正在处理的练习是一个回归问题;在回归问题中,我们通常使用均方误差作为损失函数。在 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包。

  5. 将模型训练 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
    

    如你所见,输出在每个周期后打印损失值。你应该密切监控训练损失。从前面的输出中我们可以看到,训练损失在逐步减少。

  6. 一旦模型训练完成,我们可以使用训练好的模型进行预测。将输入数据传入模型,获取预测结果并观察输出:

    #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 学习过程的支柱。在接下来的部分,我们将首先复习“期望值”的定义,并讨论它如何在贝尔曼方程中应用。

期望值

下图展示了状态空间中的期望值:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_05.jpg

图 9.5:期望值

假设一个智能体处于状态 S,并且它有两条可以选择的路径。第一条路径的转移概率为 0.6,关联的奖励为 1;第二条路径的转移概率为 0.4,关联的奖励为 0。

现在,状态 S 的期望值或奖励如下所示:

(0.6 * 1) + (0.4 * 1) = 0.6 

从数学上讲,它可以表示为:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_06.jpg

图 9.6:期望值的表达式

值函数

当一个智能体处于某个环境中时,值函数提供了关于各个状态所需的信息。值函数为智能体提供了一种方法,通过这种方法,智能体可以知道某一给定状态对其有多好。所以,如果一个智能体可以从当前状态选择两个状态,它将总是选择值函数较大的那个状态。

值函数可以递归地通过未来状态的值函数来表示。当我们在一个随机环境中工作时,我们将使用期望值的概念,正如前一节所讨论的那样。

确定性环境的值函数

对于一个确定性世界,状态的值就是所有未来奖励的总和。

状态 1 的值函数可以表示如下:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_07.jpg

图 9.7:状态 1 的值函数

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

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_08.jpg

图 9.8:状态 2 的值函数

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

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_09.jpg

图 9.9:使用状态 2 值函数的状态 1 简化值函数

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

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_10.jpg

图 9.10:带折扣因子的状态 1 简化值函数

通常,我们可以将值函数重新写为如下形式:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_11.jpg

图 9.11:确定性环境下的值函数

随机环境下的值函数:

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

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_12.jpg

图 9.12:随机环境下的值函数

这里,s 是当前状态,https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_12a.png是下一个状态,r 是从 s 到的奖励https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_12b.png

动作值函数(Q 值函数)

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

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_13.jpg

图 9.13:Q 值函数表达式

上述方程可以按迭代方式写成如下:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_14.jpg

图 9.14:带迭代的 Q 值函数表达式

这个方程也被称为贝尔曼方程。从这个方程,我们可以递归地表示https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_14a.png的 Q 值,表示为下一个状态的 Q 值https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_14b.png。贝尔曼方程可以描述如下:

“处于状态 s 并采取动作 a 的总期望奖励是两个部分的和:我们从状态‘s’采取动作 a 能够获得的奖励(即 r),加上我们从任何可能的下一状态-动作对(s′,a′)中能够获得的最大期望折扣回报*https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_14c.png。a′是下一最佳可能动作。”*

实现 Q 学习以找到最佳动作

使用 Q 函数从任何状态中找到最佳动作的过程称为 Q 学习。Q 学习也是一种表格方法,其中状态和动作的组合以表格格式存储。在接下来的部分中,我们将学习如何通过 Q 学习方法逐步找到最佳动作。考虑以下表格:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_15.jpg

图 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 值,如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_16.jpg

图 9.16:通过迭代更新 Q 值

时间戳t为当前迭代,时间戳(t-1)为上一轮迭代。通过这种方式,我们用TD方法更新上一轮时间戳的 Q 值,并尽可能将 Q 值推近到最优 Q 值,这也叫做https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_16a.png

我们可以将上面的方程重新写为:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_17.jpg

图 9.17:更新 Q 值的表达式

通过简单的数学运算,我们可以进一步简化方程,如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_18.jpg

图 9.18:Q 值更新方程

学习率决定了我们在更新 Q 值时应该采取多大的步伐。这个迭代和更新 Q 值的过程会持续进行,直到 Q 值趋近于https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_18a.png 或者当我们达到某个预定义的迭代次数时。迭代过程可以如下可视化:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_19.jpg

图 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 环境一起使用的示例代码:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_20.jpg

图 9.20:Gym 环境

让我们理解代码的几个部分如下:

  • gym.make("CartPole-v1")

    这会创建一个现有的 Gym 环境(CartPole-v1)。

  • env.reset()

    这会重置环境,因此环境将回到初始状态。

  • env.action_space.sample()

    这会从动作空间(可用动作的集合)中选择一个随机动作。

  • env.step(action)

    这会执行上一阶段选择的动作。一旦你采取了动作,环境将返回new_staterewarddone标志(用于指示游戏是否结束),以及一些额外信息。

  • env.render()

    这会呈现出代理执行动作或进行游戏的过程。

我们现在已经对 Q 学习过程有了理论理解,并且我们也回顾了 Gym 环境。现在轮到你自己实现使用 Gym 环境的 Q 学习了。

练习 9.02:实现 Q 学习表格方法

在这个练习中,我们将使用 OpenAI Gym 环境实现表格 Q 学习方法。我们将使用FrozenLake-v0 Gym 环境来实现表格 Q 学习方法。目标是通过 Q 学习过程进行游戏并收集最大奖励。你应该已经熟悉来自第五章动态规划中的 FrozenLake-v0 环境。以下步骤将帮助你完成练习:

  1. 打开一个新的 Jupyter notebook 文件。

  2. 导入所需的库:

    # Importing the required libraries
    import gym
    import numpy as np
    import matplotlib.pyplot as plt
    
  3. 创建 'FrozenLake-v0' Gym 环境,以实现一个随机环境:

    env = gym.make('FrozenLake-v0')
    
  4. 获取状态和动作的数量:

    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
    
  5. 使用从上一步获取的详细信息创建 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 表的形状,并且每个状态-动作对的初始值都是零。

  6. 设置所有用于 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
    
  7. 创建空列表以存储奖励值和衰减的 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 = []
    
  8. 实现 Q 学习训练过程,通过固定次数的回合来进行游戏。使用之前学到的 Q 学习过程(来自实施 Q 学习以寻找最佳行动部分),以便从给定状态中找到最佳行动。

    创建一个 for 循环,迭代 NUMBER_OF_EPISODES。重置环境并将 done 标志设置为 Falsecurrent_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)
    
  9. 实现一个名为 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)
    
  10. 可视化平均奖励或已完成回合的百分比:

    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()
    

    输出如下:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_21.jpg

    图 9.21:可视化已完成回合的百分比

    从前面的图中可以看出,回合已完成,并且百分比呈指数增长,直到达到一个点后变得恒定。

  11. 现在我们将可视化 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()
    

    图形将如下所示:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_22.jpg

图 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 值,这将满足贝尔曼方程右侧的要求,如下所示:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_23.jpg

图 9.23:贝尔曼方程

为了计算损失,我们需要目标 Q 值和来自网络的 Q 值。从前面的贝尔曼方程中,目标 Q 值是在方程的右侧计算出来的。DQN 的损失是通过将 DQN 输出的 Q 值与目标 Q 值进行比较来计算的。一旦我们计算出损失,我们就通过反向传播更新 DQN 的权重,以最小化损失并使 DQN 输出的 Q 值更接近最优 Q 值。通过这种方式,在 DQN 的帮助下,我们将强化学习问题视为一个有源和目标的监督学习问题。

DQN 实现可以如下可视化:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_24.jpg

图 9.24:DQN

我们可以按以下步骤编写深度 Q 学习过程:

  1. 初始化权重以获得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)
    
  2. 通过网络进行一次前向传播,获取标志(stateactionrewardnew_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语句决定是否进行探索。

  3. 如果 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
    
  4. 获得的损失如下所示。

    如果 episode 结束,则损失将是https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_24a.png

    否则,损失将被称为https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_24b.png

    以下是loss的示例代码:

            loss = self.criterion(predicted_value, target_value)
    
  5. 使用反向传播,我们更新网络权重(θ)。此迭代将针对每个状态运行,直到我们足够地最小化损失并得到一个近似最优的 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 将结束:

  1. 打开一个新的 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
    
  2. 根据图形处理单元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)
    
  3. 使用'CartPole-v0'环境创建一个 Gym 环境:

    env = gym.make('CartPole-v0')
    
  4. 设置seed以保证 torch 和环境的可复现结果:

    seed = 100
    env.seed(seed)
    torch.manual_seed(seed) 
    
  5. 设置 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
    
  6. 实现一个在每一步后衰减 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
    
  7. 从环境中获取状态和动作的数量:

    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
    
  8. 创建一个名为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
    
  9. 创建一个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)
    
  10. 接下来,定义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
    
  11. 定义optimize函数,该函数将接受stateactionnew_staterewarddone作为输入,并将它们转换为张量,同时保持它们与所用设备的兼容性。如果该回合已结束,则将奖励设为目标值;否则,将新状态通过 DQN(用于断开连接并关闭梯度计算)传递,以计算贝尔曼方程右侧的最大部分。利用获得的奖励和折扣因子,我们可以计算目标值:https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_25.jpg

        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()
    
  12. 使用for循环编写训练过程。首先,使用之前创建的类实例化 DQN 智能体。创建一个空的steps_total列表,用于收集每个回合的总步数。将steps_counter初始化为零,并用它来计算每个步骤的衰减 epsilon 值。在训练过程中使用两个循环。第一个循环是进行一定步数的游戏。第二个循环确保每个回合持续固定的步数。在第二个for循环中,第一步是计算当前步骤的 epsilon 值。使用当前状态和 epsilon 值,选择要执行的动作。接下来的步骤是执行该动作。一旦执行动作,环境将返回new_staterewarddone标志。利用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
    
  13. 现在观察奖励,因为奖励是标量反馈,能够指示智能体的表现如何。你应该查看平均奖励以及过去 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
    
  14. 执行奖励的图形表示。检查代理在更多回合中如何表现,并检查过去 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()
    

    输出图应该如下所示:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_26.jpg

图 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 的训练过程中,以下代理经验作为一个元组存储在回放缓冲区中 https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_26a.png,其中:

我们为重放缓冲区设置了最大大小;随着新经验的出现,我们将继续添加新的经验元组。因此,当我们达到最大大小时,我们将丢弃最旧的值。在任何给定时刻,重放缓冲区始终会存储最新的经验,且大小不超过最大限制。

在训练过程中,为了打破相关性,我们将从重放缓冲区随机采样这些经验来训练 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 学习过程变得不稳定。在损失计算过程中,考虑以下方程:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_28.jpg

图 9.28:损失计算的表达式

损失计算完成后,我们执行一次梯度下降步骤,优化权重以最小化损失。一旦权重更新,预测的 Q 值将发生变化。然而,我们的目标 Q 值也会发生变化,因为在计算目标 Q 值时,我们使用的是相同的权重。由于固定目标 Q 值不可用,当前架构下整个过程是不稳定的。

解决这个问题的一个方法是,在整个训练过程中保持固定的目标 Q 值。

目标网络的概念

为了解决非平稳目标的问题,我们可以通过在流程中引入目标神经网络架构来解决这个问题。我们称这个网络为目标网络。目标网络的架构与基础神经网络相同。我们可以将这个基础神经网络称为预测 DQN。

如前所述,为了计算损失,我们必须通过 DQN 做两次传递:第一次是计算目标 Q 值,第二次是计算预测的 Q 值。

由于架构的变化,目标 Q 值将通过目标网络计算,而预测 Q 值的过程保持不变,如下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_29.jpg

图 9.29:目标网络

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

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_30.jpg

图 9.30:损失函数的表达式

目标网络的整个目的就是使用新的状态-动作对来计算 Bellman 方程中的最大部分。

此时,您可能会问一个显而易见的问题,那就是,这个目标网络的权重或参数怎么办?我们如何从这个目标网络中以最优的方式获取目标值?为了在固定目标值和使用目标网络进行最优目标逼近之间保持平衡,我们将在每次固定的迭代后,从预测值更新目标网络的权重。但是,应该在多少次迭代后从预测网络更新目标网络的权重呢?这个问题的答案是一个超参数,在 DQN 的训练过程中需要进行调整。整个过程使得训练过程更加稳定,因为目标 Q 值会在一段时间内保持固定。

我们可以总结使用经验回放和目标网络训练 DQN 的步骤如下:

  1. 初始化回放缓冲区。

  2. 创建并初始化预测网络。

  3. 创建预测网络的副本作为目标网络。

  4. 运行固定次数的回合。

在每一回合中,执行以下步骤:

  1. 使用 egreedy 算法选择一个动作。

  2. 执行动作并收集奖励和新状态。

  3. 将整个经验存储在重放缓冲区中。

  4. 从重放缓冲区中随机选择一批经验。

  5. 将这一批状态通过预测网络传递,以获得预测的 Q 值。

  6. 使用一个新的状态,通过目标网络计算目标 Q 值。

  7. 执行梯度下降,以优化预测网络的权重。

  8. 在固定的迭代次数后,将预测网络的权重克隆到目标网络。

现在我们理解了 DQN 的概念、DQN 的不足之处以及如何通过经验重放和目标网络来克服这些不足;我们可以将这些结合起来,构建一个强健的 DQN 算法。让我们在接下来的练习中实现我们的学习。

练习 9.04:在 PyTorch 中实现带有经验重放和目标网络的有效 DQN 网络

在之前的练习中,你实现了一个有效的 DQN 来与 CartPole 环境一起工作。然后,我们看到了 DQN 的不足之处。现在,在本练习中,我们将使用 PyTorch 实现带有经验重放和目标网络的 DQN 网络,以构建一个更加稳定的 DQN 学习过程:

  1. 打开一个新的 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
    
  2. 编写代码,根据 GPU 环境的可用性创建一个设备:

    use_cuda = torch.cuda.is_available()
    device = torch.device("cuda:0" if use_cuda else "cpu")
    print(device)
    
  3. 使用 'CartPole-v0' 环境创建一个 gym 环境:

    env = gym.make('CartPole-v0')
    
  4. 设置 torch 和环境的种子以保证可复现性:

    seed = 100
    env.seed(seed)
    torch.manual_seed(seed)
    random.seed(seed)
    
  5. 从环境中获取状态和动作的数量:

    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
    
  6. 设置 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
    
  7. 使用先前实现的 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
    
  8. 创建一个名为 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
    
  9. 实现 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)
    
  10. 现在通过传入缓冲区大小作为输入,实例化 ExperienceReplay 类:

    memory = ExperienceReplay(REPLAY_BUFFER_SIZE)
    
  11. 实现 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指定的某次迭代后,更新目标网络的权重。

    请注意,我们使用了gathersqueezeunsqueeze函数,这些是我们在专门的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
    
  12. 编写 DQN 网络的训练过程。使用经验回放和目标 DQN 的训练过程通过更少的代码简化了这个过程。

    首先,使用之前创建的类实例化 DQN 代理。创建一个steps_total空列表,用于收集每个回合的总步数。将steps_counter初始化为零,并用它计算每步的衰减 epsilon 值。在训练过程中使用两个循环:第一个循环用于进行一定数量的回合;第二个循环确保每个回合进行固定数量的步骤。

    在第二个for循环内部,第一步是计算当前步骤的 epsilon 值。利用当前状态和 epsilon 值,选择要执行的动作。

    下一步是采取行动。一旦执行动作,环境会返回new_staterewarddone标志。将new_staterewarddoneinfo推送到经验回放缓冲区。使用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
    
  13. 现在观察奖励。由于奖励是标量反馈,并能指示代理的表现情况,您应该查看平均奖励和最后 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 训练过程中实现了稳定性,并且加入了经验回放和目标网络。

  14. 将奖励绘制在 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()
    

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_31.jpg

图 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 值,使整个训练过程变得不稳定:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_32.jpg

图 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 的损失函数如下:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_33.jpg

图 9.33:DQN 的损失函数

DDQN 的更新损失函数如下:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_34.jpg

图 9.34:更新后的 DDQN 损失函数

以下图示展示了典型 DDQN 的工作原理:

https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_35.jpg

图 9.35:DDQN

以下概述了 DDQN 实现中优化函数所需的更改:

  1. 使用预测网络选择一个动作。

    我们将通过预测网络传递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]
    
  2. 使用目标网络选择最佳动作的 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 的步骤。

以下步骤将帮助你完成该活动:

  1. 打开一个新的 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
    
  2. 编写代码,根据 GPU 环境的可用性创建设备。

  3. 使用CartPole-v0环境创建一个gym环境。

  4. 设置 torch 和环境的种子以确保结果可复现。

  5. 从环境中获取状态和动作的数量。

  6. 设置 DQN 过程所需的所有超参数值。

  7. 实现calculate_epsilon函数。

  8. 创建一个名为DQN的类,该类接受状态数量作为输入,并输出环境中动作数量的 Q 值,网络具有 64 大小的隐藏层。

  9. 初始化回放缓冲区。

  10. DQN_Agent类中创建并初始化预测网络,如练习 9.03中所示,在 PyTorch 中实现一个带有经验回放和目标网络的工作 DQN 网络。创建预测网络的副本作为目标网络。

  11. 根据*双深度 Q 网络(DDQN)*部分中展示的代码示例,修改DQN_Agent类中的optimize函数。

  12. 运行固定数量的回合。在每个回合中,使用ε-greedy 算法选择一个动作。

  13. 执行动作并收集奖励和新状态。将整个经验存储在回放缓冲区中。

  14. 从回放缓冲区中选择一个随机的经验批次。将状态批次通过预测网络,以获得预测的 Q 值。

  15. 使用我们的预测网络选择下一状态要执行的最佳动作,通过选择具有最高 Q 值的动作。使用预测网络中的动作计算下一状态下目标 Q 值的对应估计。

  16. 执行梯度下降优化预测网络的权重。经过固定迭代后,将预测网络的权重克隆到目标网络中。

  17. 训练 DDQN 代理后,检查平均奖励以及最后 100 回合的平均奖励。

  18. 在 y 轴绘制收集的奖励,x 轴绘制回合数,以可视化随着回合数增加,奖励是如何被收集的。

    平均奖励的输出应类似于以下内容:

    Average reward: 174.09
    Average reward (last 100 episodes): 186.06
    

    奖励的图表应与以下类似:

    https://github.com/OpenDocCN/freelearn-dl-pt7-zh/raw/master/docs/rl-ws/img/B16182_09_36.jpg

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

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值