hsn-dl-r-merge-2

69 阅读25分钟

R 深度学习实用指南(三)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第三部分:强化学习

本节将展示强化学习和深度强化学习。读者将学习强化学习与监督学习和无监督学习算法的不同之处。读者将在多个场景中使用这种机器学习技术,解决那些离散动作导致正向结果概率增加或减少的问题。

本节包含以下章节:

  • 第十章,游戏中的强化学习

  • 第十一章,深度 Q 学习用于迷宫求解

第十章:用于游戏的强化学习

在本章中,我们将学习强化学习。顾名思义,通过这种方法,最佳策略是通过强化或奖励某些行为并惩罚其他行为来发现的。这种类型的机器学习的基本思想是使用一个代理,在环境中执行朝着目标的动作。我们将通过使用 R 中的ReinforcementLearning包来计算代理的策略,从而帮助它在井字游戏中获胜,来探索这种机器学习技术。

尽管这看起来像是一个简单的游戏,但它是一个非常适合研究强化学习的环境。我们将学习如何为强化学习构建输入数据结构,这对于井字游戏和更复杂的游戏来说是相同的格式。我们将学习如何利用输入数据计算策略,以为代理提供环境的最佳策略。我们还将了解这种类型的机器学习中可用的超参数,以及调整这些值的效果。

在本章中,我们将完成以下任务:

  • 理解强化学习的概念

  • 准备和预处理数据

  • 配置强化学习代理

  • 调整超参数

技术要求

你可以在 GitHub 链接 github.com/PacktPublishing/Hands-on-Deep-Learning-with-R 上找到本章的代码文件。

理解强化学习的概念

强化学习是机器学习三大类别中的最后一个。我们已经学习过监督学习和无监督学习。强化学习是第三大类别,并在许多方面与其他两种类型有所不同。强化学习既不依赖标记数据进行训练,也不会为数据添加标签。相反,它旨在找到一个最优解,使得代理能获得最高的奖励。

环境是代理完成任务的空间。在我们的案例中,环境将是用于玩井字游戏的 3 x 3 网格。代理在环境内执行任务。在这种情况下,代理在网格上放置 X 或 O。环境还包含奖励和惩罚——也就是说,代理需要因某些行为而获得奖励,而因其他行为而受到惩罚。在井字游戏中,如果一方将标记(X 或 O)放在三格连续的空间内,不论是水平、垂直还是对角线,那么该玩家获胜,反之,另一方玩家失败。这就是该游戏的简单奖励和惩罚结构。策略是决定代理在给定一系列先前行为的情况下应采取哪些行动,以最大概率成功的策略。

为了确定最优策略,我们将使用 Q-learning。Q-learning 中的 Q 代表质量。它涉及开发一个质量矩阵来确定最佳的行动路线。这包括使用贝尔曼方程。方程的内部计算奖励值,加上未来动作的折扣最大值,再减去当前的质量评分。这个计算值会乘以学习率并加到当前的质量评分上。稍后,我们将看到如何使用 R 编写这个方程。

在本章中,我们使用 Q-learning;然而,还有其他执行强化学习的方法。另一个流行的算法叫做演员-评论家,它与 Q-learning 在许多方面有显著的不同。以下段落对这两者进行比较,以更好地展示它们在追求同一类型的机器学习时采取的不同方法。

Q-learning 计算一个价值函数,因此它需要一个有限的动作集合,例如井字棋。演员-评论家则适用于连续环境,并力求优化策略,而不像 Q-learning 那样使用价值函数。相反,演员-评论家有两个模型,其中一个是演员,执行动作,而另一个是评论家,计算价值函数。这一过程会针对每个动作进行,并在多个迭代中,演员学习到最佳的动作集合。虽然 Q-learning 适用于解决像井字棋这样的游戏,这类游戏有有限的空间和动作集合,但演员-评论家适用于不受约束或动态变化的环境。

在本节中,我们简要回顾了执行强化学习的不同方法。接下来,我们将开始在我们的井字棋数据上实现 Q-learning。

数据准备和处理

对于我们的第一个任务,我们将使用 ReinforcementLearning 包中的井字棋数据集。在这种情况下,数据集已经为我们构建好了;然而,我们将调查它是如何构建的,以理解如何将数据转换为适合强化学习的正确格式:

  1. 首先,让我们加载井字棋数据。要加载数据集,我们首先加载 ReinforcementLearning 库,然后调用 data 函数,并将 "tictactoe" 作为参数传入。我们通过运行以下代码来加载数据:
library(ReinforcementLearning)

data("tictactoe")

运行这些代码后,你将在数据环境面板中看到数据对象。它当前的类型是 <Promise>;然而,我们将在下一步中将其更改,以查看此对象包含的内容。现在,你的环境面板将显示如下截图:

  1. 现在,让我们看一下前几行,评估数据集的内容。我们将使用 head 函数将前几行打印到控制台,这还会将我们环境面板中的对象从 <Promise> 转换为我们可以互动并探索的对象。我们使用以下代码将前五行打印到控制台:
head(tictactoe, 5)

运行代码后,您的控制台将显示如下内容:

此外,环境窗格中的对象现在将显示如下内容:

当我们查看这些图像时,我们可以看到数据是如何设置的。为了进行强化学习,我们需要将数据设置为一种格式,其中一列是当前状态,另一列是动作,然后是随后的状态,最后是奖励。让我们以第一行作为例子,详细解释这些值的含义。

State"........."。这些点表示 3x3 棋盘上的空格,所以这个字符串代表一个空的井字游戏棋盘。Action"c7",意味着扮演 X 的代理将在第七个位置放置一个 X,即左下角。NextState"......X.B",这意味着在这个场景中,对于这一行,对手已经在右下角放置了一个 O。Reward0,因为游戏尚未结束,0的奖励值表示游戏将继续的中立状态。像这样的行将存在于每个可能的StateActionNextStateReward的组合中**。

  1. 仅使用前五行,我们可以看到所有可能的动作都是非终结的,也就是说,游戏在执行这些动作后会继续。现在让我们看一下导致游戏结束的动作:
tictactoe %>%
  dplyr::filter(Reward == 1) %>%
  head()

tictactoe %>%
  dplyr::filter(Reward == -1) %>%
  head()

运行前面的代码后,我们将在控制台看到以下几行,表示导致胜利的动作:

我们还会看到这些行被打印到控制台,表示导致失败的动作:

让我们看一下子集中的第一行,该行导致了胜利。在这种情况下,代理已经在游戏板的右上角和中心放置了一个 X。这里,代理将在左下角放置一个 X,这样就形成了一个斜线连续的三个 X,这意味着代理赢得了游戏,我们可以在Reward列中看到这一点。

  1. 接下来,我们来看一个给定的状态,并查看所有可能的动作:
tictactoe %>%
 dplyr::filter(State == 'XB..X.XBB') %>%
  dplyr::distinct()

通过这种方式对数据进行子集选择,我们从一个给定的状态开始,并查看所有可能的选项。您的控制台输出将如下所示:

在这种情况下,游戏板上只剩下三个空位。我们可以看到两种动作会导致代理胜利。如果代理选择游戏板上的另一个空位,那么游戏板上还剩下两个空位,我们可以看到,无论对手选择哪个,游戏都会继续。

从这次调查中,我们可以看到如何为强化学习准备数据集。尽管这个数据集是由别人为我们准备的,但我们可以看到如何自己制作一个。如果我们想以不同的方式编码我们的井字棋棋盘,我们可以使用游戏《数字拼图》的值。《数字拼图》与井字棋同构,但它涉及选择数字而不是在网格上放置标记;然而,数字值与网格完美匹配,因此可以互换。游戏《数字拼图》涉及两位玩家在 1 到 15 之间选择数字,每个数字只能选择一次,获胜者是第一个选择出使数字之和为 15 的数字的人。考虑到这一点,我们可以像这样重写我们查看的第一行:

State <- '0,0'
Action <- '4'
NextState <- '4,8'
Reward <- 0

numberscramble <- tibble::tibble(
  State = State,
  Action = Action,
  NextState = NextState,
  Reward = Reward
)

numberscramble

运行后,我们将在控制台看到以下输出:

从中,我们可以看到StateActionNextState的值可以以我们喜欢的任何方式进行编码,只要使用一致的约定,以便强化学习过程可以从一个状态遍历到另一个状态,从而发现通向奖励的最优路径。

现在我们知道如何设置数据,接下来我们来看一下我们的智能体将如何找到最佳的奖励路径。

配置强化学习智能体

让我们详细了解如何使用 Q 学习配置一个强化学习智能体。Q 学习的目标是创建一个状态-动作矩阵,其中为所有状态-动作组合分配一个值——也就是说,如果我们的智能体处于某个状态,那么提供的值将决定智能体采取的行动,以获取最大值。我们将通过创建一个值矩阵来启用智能体最佳策略的计算,该矩阵为每一个可能的移动提供一个计算值:

  1. 首先,我们需要一组所有值为 0 的状态和动作对。作为最佳实践,我们将在这里使用哈希,这是一种比大型列表更高效的替代方案,可以扩展到更复杂的环境。首先,我们将加载哈希库,然后使用for循环填充哈希环境。for循环首先从数据中获取每个唯一状态,对于每个唯一状态,它将附加每个唯一动作,创建所有可能的状态-动作对,并为所有对分配一个值 0。通过运行以下代码,我们生成了这个哈希环境,它将在 Q 学习阶段保存计算出的值:
library(hash)

Q <- hash()

for (i in unique(tictactoe$State)[!unique(tictactoe$State) %in% names(Q)]) {
 Q[[i]] <- hash(unique(tictactoe$Action), rep(0, length(unique(tictactoe$Action))))
}

运行代码后,我们会看到环境面板现在看起来像下图所示:

我们有一个哈希环境,Q,它包含每个状态-动作对。

  1. 下一步是定义超参数。现在,我们将使用默认值;然而,我们很快就会调整这些值,以查看它们的影响。通过运行以下代码,我们将超参数设置为其默认值:
control = list(
 alpha = 0.1, 
 gamma = 0.1, 
 epsilon = 0.1
 )

运行代码后,我们现在可以看到环境面板中显示了我们的超参数值列表,面板现在看起来如下:

  1. 接下来,我们开始填充我们的 Q 矩阵。这同样是在一个 for 循环内进行的;不过,我们将仅查看其中的一次独立迭代。我们首先通过以下代码将一行数据的元素提取到离散的数据对象中:
  d <- tictactoe[1, ]
  state <- d$State
  action <- d$Action
  reward <- d$Reward
  nextState <- d$NextState

运行代码后,我们可以看到 环境 面板中的变化,其中现在包含了第一行中的离散元素。环境面板将如下所示:

  1. 接着,我们获取当前 Q 学习分数的值(如果有的话)。如果没有值,那么就将 0 存储为当前值。我们通过运行以下代码来设置这个初始的质量分数:
  currentQ <- Q[[state]][[action]]
  if (has.key(nextState,Q)) {
    maxNextQ <- max(values(Q[[nextState]]))
  } else {
    maxNextQ <- 0
  }

运行此代码后,我们现在得到 currentQ 的值,在这种情况下它是 0,因为状态 '......X.B' 的所有 Q 值都为 0,因为我们已将所有值设置为 0;然而,在下一步中,我们将开始更新 Q 值。

  1. 最后,我们通过使用 Bellman 方程更新 Q 值。这也叫做 时序差分 学习。我们通过以下代码写出这一计算 R 值的步骤:
  ## Bellman equation
  Q[[state]][[action]] <- currentQ + control$alpha *
    (reward + control$gamma * maxNextQ - currentQ)

q_value <- Q[[tictactoe$State[1]]][[tictactoe$Action[1]]]

运行以下代码后,我们可以提取这个状态-动作对的更新值;我们可以在标记为 q_value 的字段中看到它。您的 环境 面板将如下所示:

我们在这里注意到 q_value 仍然是 0。为什么会是这样呢?如果我们看一下我们的公式,我们会发现奖励是公式的一部分,而我们的奖励是 0,这使得整个计算值为 0。因此,直到我们的代码遇到具有非零奖励的行时,才会开始看到更新后的 Q 值。

  1. 我们现在可以将所有这些步骤结合起来,针对每一行运行它们,来创建我们的 Q 矩阵。通过运行以下代码,我们创建了一个包含所有值的矩阵,这些值将用于选择最佳策略的决策:
for (i in 1:nrow(tictactoe) {
  d <- tictactoe[i, ]
  state <- d$State
  action <- d$Action
  reward <- d$Reward
  nextState <- d$NextState

  currentQ <- Q[[state]][[action]]
  if (has.key(nextState,Q)) {
    maxNextQ <- max(values(Q[[nextState]]))
  } else {
    maxNextQ <- 0
  }
  ## Bellman equation
  Q[[state]][[action]] <- currentQ + control$alpha *
    (reward + control$gamma * maxNextQ - currentQ)
}

Q[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]

在循环遍历所有行后,我们看到某些状态-动作对现在已经有了 Q 矩阵中的值。运行以下代码时,我们将在控制台上看到以下输出:

到目前为止,我们已经为 Q-learning 创建了矩阵。在这种情况下,我们将值存储在一个哈希环境中,每个键值对都有对应的值;然而,这等同于将值存储在矩阵中——只不过这种方式在之后的扩展中更为高效。现在我们有了这些值,我们可以为智能体计算一个策略,以提供通向奖励的最佳路径;然而,在我们计算这个策略之前,我们将做最后一组修改,那就是将之前设置的超参数调整回默认值。

调整超参数

我们已经定义了环境,并遍历了从任何给定状态下可能采取的所有行动及其结果,以计算每一步的质量值,并将这些值存储在我们的 Q 对象中。到此为止,我们现在可以开始调整这个模型的选项,看看这些调整如何影响性能。

如果我们回顾一下,强化学习有三个参数,它们分别是 alpha、gamma 和 epsilon。下面的列表描述了每个参数的作用以及调整其值的影响:

  • Alpha:强化学习中的 alpha 值与许多其他机器学习模型的学习率相同。它是用于控制在基于智能体采取某些行动探索奖励时,更新概率的速度的常量值。

  • Gamma:调整 gamma 值可以改变模型对未来奖励的重视程度。当 gamma 设置为 1 时,当前和未来的所有奖励被赋予相同的权重。这意味着距离当前步骤几步之遥的奖励与下一步获得的奖励具有相同的价值。实际上,这几乎从来不是我们想要的效果,因为我们希望未来的奖励更有价值,因为获得它们需要更多的努力。相比之下,设置 gamma 为 0 意味着只有来自下一步行动的奖励才会有任何价值,未来的奖励完全没有价值。同样,除了特殊情况外,这通常不是我们想要的效果。在调整 gamma 时,你需要在未来奖励的加权平衡中找到一种平衡,使得智能体能够做出最优的行动选择。

  • Epsilon:epsilon 参数用于在选择未来行动时引入随机性。将 epsilon 设置为 0 被称为贪婪学习。在这种情况下,智能体将始终选择成功概率最高的路径;然而,正如其他机器学习方法一样,智能体很容易陷入某些局部最小值,永远无法发现最优策略。通过引入一些随机性,在不同的迭代中将会采取不同的行动。调整此值有助于优化探索与利用之间的平衡。我们希望模型能够利用已学到的知识,选择最佳的未来行动;但我们也希望模型继续探索并不断学习。

利用我们对这些超参数的理解,接下来我们来看一下在调整这些参数时,值是如何变化的:

  1. 首先,我们将调整alpha的值。如前所述,alpha值是学习率值,这是我们在学习其他机器学习主题时可能熟悉的内容。它只是一个常数值,用来控制算法调整的速度。目前,我们将alpha的值设置为0.1;然而,我们将alpha的值设为0.5。这个值高于我们通常实际希望的值,主要是为了探索更改这些值的影响。我们将需要将 Q 值重置为零并重新启动学习过程,以查看发生了什么。以下代码块将我们之前所做的一切再次执行,只不过对alpha进行了调整。我们调整alpha值并通过运行以下代码来查看其效果:
Q <- hash()

for (i in unique(tictactoe$State)[!unique(tictactoe$State) %in% names(Q)]) {
  Q[[i]] <- hash(unique(tictactoe$Action), rep(0, length(unique(tictactoe$Action))))
}

control = list(
  alpha = 0.5, 
  gamma = 0.1,  
  epsilon = 0.1
)

for (i in 1:nrow(tictactoe)) {
  d <- tictactoe[i, ]
  state <- d$State
  action <- d$Action
  reward <- d$Reward
  nextState <- d$NextState

  currentQ <- Q[[state]][[action]]
  if (has.key(nextState,Q)) {
    maxNextQ <- max(values(Q[[nextState]]))
  } else {
   maxNextQ <- 0
  }
  ## Bellman equation
  Q[[state]][[action]] <- currentQ + control$alpha *
    (reward + control$gamma * maxNextQ - currentQ)
}

Q[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]

从这个调整中,我们可以看到在234543处,Q 值发生了变化。你将在控制台上看到以下输出:

正如我们所预期的那样,由于我们提高了alpha的值,因此结果是,在我们之前查看的相同点上,Q 值变得更大。换句话说,我们的算法学习得更快,质量值获得了更大的权重。

  1. 接下来,我们调整gamma的值。如果我们记得的话,调整gamma的值会改变代理对未来奖励的重视程度。我们当前的值设置为0.1,这意味着未来的奖励是有价值的,但它们被重视的程度相对较小。让我们将其提高到0.9,看看会发生什么。我们进行与调整alpha时相同的操作。首先重置 Q 哈希环境,使所有状态–动作对的值为0,然后通过循环遍历所有选项,应用贝尔曼方程,并对gamma值进行更改来重新填充该哈希环境。我们通过运行以下代码来评估更改gamma值时会发生什么:
library(hash)

Q <- hash()

for (i in unique(tictactoe$State)[!unique(tictactoe$State) %in% names(Q)]) {
  Q[[i]] <- hash(unique(tictactoe$Action), rep(0, length(unique(tictactoe$Action))))
}

control = list(
  alpha = 0.1, 
  gamma = 0.9,  
  epsilon = 0.1
)

for (i in 1:nrow(tictactoe)) {
  d <- tictactoe[i, ]
  state <- d$State
  action <- d$Action
  reward <- d$Reward
  nextState <- d$NextState

  currentQ <- Q[[state]][[action]]
  if (has.key(nextState,Q)) {
    maxNextQ <- max(values(Q[[nextState]]))
  } else {
    maxNextQ <- 0
  }
  ## Bellman equation
  Q[[state]][[action]] <- currentQ + control$alpha *
    (reward + control$gamma * maxNextQ - currentQ)
}

Q[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]

运行完这段代码后,你将在控制台看到以下代码输出:

从这点出发,我们可以得出以下观察结论:

  • 我们可以看到我们的值显著增加了

  • 从这个状态并采取这个动作,会有一定的价值,但在考虑未来奖励时,价值会更大

  • 然而,对于像井字游戏这样的游戏,我们需要考虑到,从任何状态到奖励之间的步骤通常很少;然而,我们可以看到,从这个状态和这个动作出发,获得奖励的概率会很高

  1. 对于最后的调整,我们将调整epsilonepsilon的值取决于相对于探索知识的程度,我们使用多少以前的知识。为了这个调整,我们将回到使用ReinforcementLearning包中的函数,因为它不仅依赖于通过贝尔曼方程循环计算,还需要在多次迭代中存储这些值以供参考。要调整epsilon,我们使用以下代码:
# Define control object
control <- list(
alpha = 0.1, 
gamma = 0.1, 
epsilon = 0.9
)

# Perform reinforcement learning
model <- ReinforcementLearning(data = tictactoe, 
                               s = "State", 
                               a = "Action", 
                               r = "Reward", 
                               s_new = "NextState", 
                               iter = 5,
                               control = control)

model$Q_hash[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]

运行此代码后,我们会看到我们的 Q 值发生了变化。你将看到以下值打印到控制台:

从这里我们可以得出以下观察结论:

  • 我们的数值类似于使用默认参数值时的数值,但稍微大一些

  • 在这种情况下,我们引入了相对较大的随机性,以强迫我们的智能体继续探索;结果,我们可以看到,在这种随机性下,我们的价值损失并不大,而且即使它导致了不同的后续行动集,这个动作仍然保持相似的价值

  1. 在将参数调整到理想设置之后,我们现在可以查看给定状态的策略。首先,让我们看看Environment面板中的模型对象。你的Environment面板将如下所示:

让我们更深入地了解模型对象中的每个元素:

  • Q_hash:哈希环境,就像我们之前创建的那样,包含每个状态–动作对及其 Q 值

  • Q:一个命名矩阵,包含与哈希环境相同的数据,只是以命名矩阵的形式呈现

  • States:我们矩阵中的命名行

  • Actions:我们矩阵中的命名列

  • Policy:一个命名向量,包含智能体应该从任何状态采取的最优动作

  • RewardRewardSequence:这些是数据集中导致奖励的行数,少于导致惩罚的行数

我们可以使用这里的数值来查看在任何给定状态下所有动作的价值,并判断哪个是最好的行动。让我们从一个全新的游戏开始,看看哪个动作的价值最高。我们可以从这个状态看到每个动作的价值,并通过运行以下代码来标记哪个动作是最好的:

sort(model$Q['.........',1:9], decreasing = TRUE)

model$Policy['.........']

运行此代码后,我们将看到以下内容打印到控制台:

我们可以看到,第一行列出了所有可能的动作以及它们按降序排列的各自价值。我们可以看到,在这个命名向量中,动作"c5",即网格中心的标记,具有最高的价值。因此,当我们查看智能体处于该状态时的策略时,我们看到它是"c5"。通过这种方式,我们现在可以利用强化学习的结果,从任何给定状态中选择最优的行动:

  • 我们刚刚调整了所有参数,以便注意这些变量变化的影响

  • 然后,在最后一步中,我们看到如何根据网格处于任何状态来选择最佳策略

  • 通过尝试每种可能的行动组合,我们根据即时和未来的奖励计算了移动的价值

  • 我们决定通过调整参数来权衡 Q 值,并决定一种解决游戏的方法

概要

在本章中,我们使用 Q-learning 编码了一个强化学习系统。我们定义了我们的环境或游戏表面,然后查看了包含每种可能状态、动作和未来状态的数据集。使用数据集,我们计算了每个状态-动作对的价值,将其存储在哈希环境和矩阵中。然后,我们将这个值矩阵作为我们策略的基础,选择具有最大价值的移动。

在我们的下一章中,我们将通过将神经网络添加到 Q-learning 中来扩展深度 Q-learning 网络。

第十一章:深度 Q 学习在迷宫求解中的应用

在本章中,你将学习如何使用 R 来实现强化学习技术,并将其应用于迷宫环境。特别地,我们将创建一个代理,通过训练代理执行动作并从失败中学习,来解决迷宫。我们将学习如何定义迷宫环境,并配置代理使其能够穿越迷宫。我们还将把神经网络添加到 Q 学习中,这为我们提供了获取所有状态-动作对值的替代方法。我们将多次迭代我们的模型,以创建一个策略,帮助代理走出迷宫。

本章将涵盖以下主题:

  • 为强化学习创建环境

  • 定义一个代理来执行动作

  • 构建深度 Q 学习模型

  • 运行实验

  • 使用策略函数提高性能

技术要求

你可以在 github.com/PacktPublishing/Hands-on-Deep-Learning-with-R 上找到本章使用的代码文件。

为强化学习创建环境

在这一节中,我们将定义一个强化学习的环境。我们可以把它想象成一个典型的迷宫,其中代理需要在二维网格空间中导航到达终点。然而,在这种情况下,我们将使用一个基于物理的迷宫。我们将使用山地车问题来表示这一点。代理处在一个山谷中,需要到达山顶;但是,它不能直接爬坡。它必须利用动量到达山顶。为此,我们需要两个函数。一个函数将启动或重置代理,将其放置在表面上的一个随机点。另一个函数将描述代理在一步之后在表面上的位置。

我们将使用以下代码来定义 reset 函数,为代理提供一个起始位置:

reset = function(self) {
  position = runif(1, -0.6, -0.4)
  velocity = 0
  state = matrix(c(position, velocity), ncol = 2)
  state
}

我们可以看到,使用这个函数时,首先发生的事情是通过从 -0.6-0.4 之间的均匀分布中随机选择一个值来定义 position 变量。这是代理将被放置在表面上的点。接下来,velocity 变量被设置为 0,因为我们的代理尚未移动。reset 函数仅用于将代理放置在起始点。position 变量和 velocity 变量现在被加入到一个 1 x 2 的矩阵中,这个 matrix 变量就是我们代理的起始位置和起始速度。

下一个函数获取每个动作的值,并计算代理将采取的下一步。为了编写这个函数,我们使用以下代码:

  step = function(self, action) {
  position = self$state[1]
  velocity = self$state[2]
  velocity = (action - 1L) * 0.001 + cos(3 * position) * (-0.0025)
  velocity = min(max(velocity, -0.07), 0.07)
  position = position + velocity
  if (position < -1.2) {
    position = -1.2
    velocity = 0
  }
  state = matrix(c(position, velocity), ncol = 2)
  reward = -1
  if (position >= 0.5) {
    done = TRUE
    reward = 0
  } else {
    done = FALSE
  }
  list(state, reward, done)
}

在这个函数中,第一部分定义了位置和速度。在这个案例中,位置和速度是从self对象中获取的,接下来我们将介绍selfself变量包含有关代理的详细信息。这里,positionvelocity变量是从self中获取的,表示代理当前在表面上的位置以及当前的速度。然后,action参数用于计算速度。接下来的行将velocity限制在-0.70.7之间。之后,我们通过将速度加到当前位置来计算下一个位置。然后,还有一行约束代码。如果position超过-1.2,代理就会超出边界,并且会重置到-1.2位置,且速度为零。最后,会进行检查,看代理是否已达到目标。如果状态值大于0.5,代理就算获胜;否则,代理继续移动并尝试达到目标。

当我们完成这两个代码块时,我们将看到在环境面板中定义了两个函数。你的环境面板将如以下截图所示:

这两个函数的组合定义了表面的形状、代理在表面上的位置以及目标位置在表面上的设置。reset函数用于代理的初始位置,step函数定义了每次迭代时代理的步伐。通过这两个函数,我们定义了环境的形状和边界,并且为将代理放置和移动到环境中提供了机制。接下来,我们来定义我们的代理。

定义一个代理来执行动作

在本节中,我们将定义用于深度 Q 学习的代理。我们已经看到前面环境函数如何定义代理的移动方式。在这里,我们定义代理本身。在上一章中,我们使用了 Q 学习,并且能够将贝尔曼方程应用到由特定动作产生的新状态。在本章中,我们将通过神经网络增强 Q 学习的这一部分,这就是将标准 Q 学习转化为深度 Q 学习的关键。

为了将这个神经网络模型添加到过程中,我们需要定义一个类。这在面向对象编程中是常见的;然而,在像 R 这样的编程语言中则不太常见。为此,我们将使用R6包来创建类。我们将把R6类的创建分解为多个部分,以便更容易理解。类提供了实例化和操作数据对象的指令。在这种情况下,我们的类将使用声明的变量来实例化数据对象,并且一系列的函数(在类的上下文中称为方法)将用于操作数据对象。在接下来的步骤中,我们将逐一查看类的各个部分,以便更容易理解我们正在创建的类的构成部分。然而,运行代码的部分会导致错误。在详细讲解所有部分之后,我们将把所有内容包装在一个函数中来创建我们的类,并且这是你将要运行的最终、较长的R6代码。首先,我们将使用以下代码来设置初始值:

portable = FALSE,
lock_objects = FALSE,
public = list(
  state_size = NULL,
  action_size = NA, 
  initialize = function(state_size, action_size) {
      self$state_size = state_size
      self$action_size = action_size
      self$memory = deque()
      self$gamma = 0.95 
      self$epsilon = 1.0 
      self$epsilon_min = 0.01
      self$epsilon_decay = 0.995
      self$learning_rate = 0.001
      self$model = self$build_model()
  }
)

在使用 R 创建一个用于此目的的类时,我们首先设置两个选项。首先,我们将portable设置为FALSE,这意味着其他类不能继承此类的方法或函数。但这也意味着我们可以使用self关键字。其次,我们将lock_objects设置为FALSE,因为我们需要在此类中修改对象。

接下来,我们定义我们的初始值。我们在这里使用self,它是一个特殊的关键字,指代所创建的对象。记住,类不是对象——它是创建对象的构造器。在这里,我们将创建一个类的实例,这个对象将作为代理。该代理将使用以下值进行初始化。状态大小和动作大小将在创建环境时作为参数传入。下一个记忆是一个空的 deque。deque是一种特殊的对象类型,它是双端的,因此可以从两端添加或移除值。我们将使用它来存储代理在试图达成目标时所采取的步骤。Gamma 是折扣率。Epsilon 是探索率。正如我们所知道的,深度学习的目标是平衡探索与利用,因此我们从一个激进的探索率开始。然而,我们随后定义了一个 epsilon 衰减,这是探索率将被减少的程度,以及一个 epsilon 最小值,以确保探索率永远不会达到0。最后,学习率就是在调整权重时使用的常数值,而模型则是运行我们的神经网络模型的结果,我们将在接下来的部分介绍。

接下来,我们将通过添加一个函数来赋予类操作变量的能力。特别地,我们将添加build_model函数来运行神经网络:

build_model = function(...){
        model = keras_model_sequential() %>% 
        layer_dense(units = 24, activation = "relu", input_shape = self$state_size) %>%
        layer_dense(units = 24, activation = "relu") %>%
        layer_dense(units = self$action_size, activation = "linear")

        compile(model, loss = "mse", optimizer = optimizer_adam(lr = self$learning_rate), metrics = "accuracy")

        return(model)
    }

模型将当前状态作为输入,输出将是我们在预测模型时可用的动作之一。然而,这个函数只是返回模型,因为我们将在调用时根据我们在深度 Q 学习路径中的位置,传递一个不同的状态参数给模型。模型在两种不同的场景下被调用,我们稍后会详细讲解。

接下来,我们将加入一个用于记忆的函数。这个类的记忆部分将是一个存储状态、动作、奖励和下一状态的函数,随着智能体尝试解决迷宫,我们将这些值存储在智能体的记忆中,并通过以下代码将它们添加到双端队列(deque)中:

memorize = function(state, action, reward, next_state, done){
        pushback(self$memory,state)
        pushback(self$memory,action)
        pushback(self$memory,reward)
        pushback(self$memory, next_state)
        pushback(self$memory, done)
    }

我们使用pushback函数将给定的值添加到双端队列的第一个位置,并将所有现有元素向后移动一个位置。我们对状态、动作、奖励、下一状态以及显示迷宫是否已完成的标志进行此操作。这些序列存储在智能体的记忆中,因此当探索与利用公式选择利用选项时,它可以通过访问记忆中的这个序列来利用已知的内容,而不是继续进行探索。

接下来,我们将添加一些代码来选择下一个动作。为完成此任务,我们将检查衰减的 epsilon 值。根据衰减的 epsilon 是否大于从均匀分布中随机选择的值,将发生两种可能的动作之一。我们通过以下代码设置了一个决定下一个动作的函数:

act = function(state){
        if (runif(1) <= self$epsilon){
            return(sample(self$action_size, 1))
            } else {
        act_values <- predict(self$model, state)
        return(which(act_values==max(act_values)))
            }
    }

如前所述,这个动作函数有两种可能的结果。首先,如果从均匀分布中随机选择的值小于或等于 epsilon,那么将从活动动作的全部范围中选择一个值。否则,当前状态将用于使用我们先前定义的模型预测下一个动作,这会导致加权概率来判断这些动作中哪个是正确的。选择具有最高概率的动作。这是探索与利用之间的平衡,旨在寻找正确的下一步。

在之前讨论过探索步骤后,我们现在将编写我们的replay()函数,该函数将利用智能体已经知道并存储在记忆中的内容。我们使用以下代码来编写这个利用函数:

replay = function(batch_size){
        minibatch = sample(length(self$memory), batch_size) 
            state = minibatch[1]
            action = minibatch[2]
            target = minibatch[3]
            next_state = minibatch[4]
            done = minibatch[5]
            if (done == FALSE){
                target = (target + self$gamma *
                          max(predict(self$model, next_state)))
            target_f = predict(self$model, state)
            target_f[0][action] = target
            self$model(state, target_f, epochs=1, verbose=0)
            }
        if (self$epsilon > self$epsilon_min){
            self$epsilon = self$epsilon * self$epsilon_decay
            }
    }

让我们拆解这个函数,以利用智能体已经知道的内容来帮助解决难题。我们首先做的是从记忆双端队列(deque)中选择一个随机序列。接着,我们将从样本中提取的每个元素放入智能体序列中的特定部分:状态、动作、目标、下一状态和done标志,后者用来指示迷宫是否已解决。接下来,我们将添加一些代码来改变模型的预测方式。我们从使用序列结果中的状态来定义目标,利用我们从尝试此序列中学到的内容。

接下来,我们预测状态以获得模型可能预测的所有值。然后,我们将计算出的值插入到该向量中。当我们再次运行模型时,我们帮助模型基于经验进行训练。这也是更新 epsilon 的步骤,这将导致在未来的迭代中更多的开发(exploitation)和更少的探索(exploration)。

最后一步是添加一个保存和加载我们模型的方法或函数。我们通过以下代码为保存和加载模型提供手段:

load = function(name) {
    self$model %>% load_model_tf(name)
},
save = function(name) {
    self$model %>% save_model_tf(name)
}

通过这些方法,我们现在能够保存之前定义的模型,并加载训练好的模型。

接下来,我们需要将我们所覆盖的所有内容放入一个总体函数中,该函数将接收所有声明的变量和函数,并用它们来创建一个 R6 类。为了创建我们的 R6 类,我们将把刚才写的所有代码整合在一起。

运行完整代码后,我们将会在环境中得到一个 R6 类。它将有一个 Environment 的类。如果你点击它,你可以看到类的所有属性。你会注意到有许多与创建类相关的属性,我们并没有特别定义;然而,看看以下截图。我们可以看到这个类不可移植,我们可以看到将要赋值的公共字段,还能看到我们定义的所有函数,这些函数在作为类的一部分时被称为方法:

通过这一步,我们已经完全创建了一个 R6 类,作为我们的代理。我们为它提供了基于当前状态和一定随机性的行动手段,并且还为我们的代理提供了一种方式来探索这个迷宫的表面,以找到目标位置。我们还提供了一种方式,让代理回顾它从过去经验中学到的内容,并用这些来指导未来的决策。总的来说,我们拥有了一个完整的强化学习代理,它通过试错学习,最重要的是,从过去的错误中学习,并不断地通过随机行动来学习。

构建深度 Q 学习模型

在这一点上,我们已经定义了环境和我们的代理,这将使得运行我们的模型变得相当简单。记住,为了使用 R 进行强化学习,我们采用了面向对象编程中的一种技巧,而这在像 R 这样的编程语言中并不常用。我们创建了一个描述对象的类,但它本身并不是一个对象。为了从类中创建对象,我们必须实例化它。我们设置了初始值,并通过以下代码使用我们的 DQNAgent 类实例化一个对象:

state_size = 2
action_size = 20
agent = DQNAgent(state_size, action_size)

运行这段代码后,我们将在环境中看到一个代理对象。该代理的类为 Environment;然而,如果我们点击它,我们将看到类似下面的截图,其中包含与我们类的一些差异:

运行这一行代码后,我们现在拥有了一个继承了类中所有属性的对象。我们将 2 作为参数传入状态大小,因为对于这个环境,状态是二维的。这两个维度是位置和速度。我们可以看到我们传入的值在 state_size 字段旁边被反映出来。我们将 20 作为参数传入动作大小,因为对于这个游戏,我们将允许代理使用最多 20 单位的力量向前或向后推进。我们也能看到这个值。同样,我们可以看到所有的方法;不过它们不再嵌套在不同的方法下——它们现在都由 agent 对象继承。

为了创建我们的环境,我们使用 reinforcelearn 包中的 makeEnvironment 函数,该函数允许自定义环境创建。我们使用以下代码将 stepreset 函数作为参数传递,以创建代理导航的自定义环境:

env = makeEnvironment(step = step, reset = reset)

在运行前面的代码行后,你会在 Environment 面板中看到一个 env 对象。请注意,这个对象也具有 Environment 类。当我们点击这个对象时,我们会看到以下内容:

前面的代码行使用了我们之前创建的函数来定义一个环境。现在我们拥有了一个环境实例,它包含了初始化游戏的方式,而 step 函数定义了代理每次行动时可能的动作范围。请注意,这也是一个 R6 类,就像我们的代理类一样。

最后,我们加入了两个额外的初始值。通过运行以下代码,我们建立了剩余的初始值,以完成我们的模型设置:

done = FALSE
batch_size = 32

FALSE 作为 done 的第一个值表示目标尚未完成。32 的批量大小是代理在开始利用已知信息进行下一系列动作之前,将尝试进行的探索动作或系列动作的大小。

这是深度 Q 学习的完整模型设置。我们有一个代理实例,这是一个根据我们之前在类中定义的特征创建的对象。我们还有一个定义了我们在创建 stepreset 函数时设置的参数的环境。最后,我们定义了一些初始值,现在,一切都已经完成。下一步就是让代理开始行动,我们将在接下来完成这一过程。

运行实验

强化学习的最后一步是运行实验。为此,我们需要将代理放入环境中,然后允许代理采取步骤,直到达到目标。代理受到可用移动次数的限制,环境也施加了另一个约束——在我们的例子中,就是通过设置边界来实现。我们设置了一个for循环,循环通过代理尝试合法移动的回合,然后查看迷宫是否已成功完成。当代理到达目标时,循环停止。为了开始我们的实验,使用我们定义的代理和环境,我们编写了以下代码:

state = reset(env)
for (j in 1:5000) {
  action = agent$act(state)
  nrd = step(env,action)
  next_state = unlist(nrd[1])
  reward = as.integer(nrd[2])
  done = as.logical(nrd[3])
  next_state = matrix(c(next_state[1],next_state[2]), ncol = 2)
  reward = dplyr::if_else(done == TRUE, reward, as.integer(-10))
  agent$memorize(state, action, reward, next_state, done)
  state = next_state
  env$state = next_state
  if (done == TRUE) {
    cat(sprintf("score: %d, e: %.2f",j,agent$epsilon))
    break
  } 
  if (length(agent$memory) > batch_size) {
    agent$replay(batch_size)
  } 
  if (j %% 10 == 0) {
    cat(sprintf("try: %d, state: %f,%f ",j,state[1],state[2])) 
  }

}

前面的代码运行的是设置代理运动的实验。代理的行为受我们定义的类中的值和函数的控制,并且进一步由我们创建的环境推动。如我们所见,运行实验时会进行很多步骤。我们将在这里回顾每个步骤:

  1. 在运行前面的代码的第一行后,我们将看到代理的初始状态。如果查看state对象,它将类似于这样,其中位置值介于-0.4-0.6之间,速度为0

  1. 在运行剩余的代码块后,我们将看到类似以下内容的输出打印到控制台,其中显示了每十轮的状态:

  1. 当我们运行这段代码时,首先发生的事情是环境被重置,代理被放置在表面上。

  2. 然后,启动循环。每一轮中的活动顺序如下:

    1. 首先,使用agent类中的act函数来执行一个动作。记住,这个函数定义了代理允许的移动。

    2. 接下来,我们将代理采取的动作传递给step函数,以获取结果。

    3. 输出是下一个状态,这是代理在执行动作后所到达的位置,以及基于该动作是否带来了正面结果的奖励,最后是done标志,表示目标是否已经成功到达。

    4. 这三个元素作为list对象从函数中输出。

    5. 接下来的步骤是将它们分配到各自的对象中。对于rewarddone,我们仅从列表中提取它们,并将它们分别分配为整数和逻辑数据类型。至于下一个状态,这稍微复杂一些。我们首先使用unlist提取两个值,然后将它们放入一个 2 x 1 的矩阵中。

    6. 在将代理移动的所有元素转移到它们自己的对象后,奖励被计算出来。在我们的例子中,除非达到目标,否则没有中间成就会导致奖励,因此rewarddone的操作方式相似。这里,我们看到,如果done标志被设置为TRUE,则当rewardTRUE时,reward被设置为0,如step函数中所定义的那样。

    7. 接下来,所有从step函数输出的值都将添加到memory队列对象中。memorize函数将每个值推送到队列的第一个元素位置,并将现有值推回。

    8. 在此之后,state对象被赋值为下一个状态。这是因为下一个状态现在成为了新的当前状态,因为智能体采取了一个新的步骤。

    9. 然后会检查智能体是否已经到达迷宫的终点。如果是,则跳出循环并打印出 epsilon 值,以查看通过探索完成了多少任务,探索和利用各占多少。对于其他回合,会有一个二次检查,打印每十步的当前状态和速度。

    10. 另一个条件是replay函数的触发条件。在达到阈值后,智能体从记忆队列中提取值,过程从那里继续。

这是执行强化学习实验的整个过程。通过这个过程,我们现在拥有了一种比单纯使用 Q 学习更为强大的强化学习方法。虽然在环境有限且已知时,使用 Q 学习是一个不错的解决方案,但当环境扩展或动态变化时,则需要深度 Q 学习。通过让定义好的智能体在定义好的环境中采取行动并进行迭代,我们可以看到该智能体在解决环境中提出的问题时的表现如何。

使用策略函数提升性能

我们已经成功地编写了一个智能体,使用神经网络深度学习架构来解决问题。现在让我们看一下可以改进模型的几种方法。与其他机器学习不同,我们无法像通常那样通过性能指标进行评估,通常我们会尝试最小化某个选择的错误率。强化学习的成功略带主观性。你可能希望智能体尽可能快地完成任务,尽可能多地获取积分,或者尽可能少地犯错。此外,根据任务的不同,我们可能能够改变智能体本身,看看它对结果有何影响。

我们将讨论三种可能的提高性能的方法:

  • 动作大小:有时这是一个选项,有时则不是。如果你正在尝试解决一个智能体规则和环境规则在外部设置的问题,例如尝试在棋类游戏中优化性能,那么这将不是一个选项。然而,你可以想象一个问题,例如设置一个自动驾驶汽车,在这种情况下,如果在该环境下效果更好,你可以改变智能体。通过我们的实验,尝试将动作大小值从20更改为10,并且再更改为40,看看会发生什么。

  • 批量大小:我们还可以调整批量大小,以观察它对性能的影响。请记住,当代理的移动次数达到批次的阈值时,代理就会从记忆中选择值,开始利用已有的知识。通过提高或降低这个阈值,我们为代理提供了一种策略,即在使用已有知识之前,应该进行更多或更少的探索。将批量大小更改为1664128,观察哪个选项能让代理最快完成任务。

  • 神经网络:我们将讨论的最后一个需要修改的代理策略部分是神经网络。在很多方面,它是代理的“大脑”。通过修改,我们可以让代理做出更多有利于优化性能的选择。在AgentDQN类中,添加一些神经网络层,然后重新运行实验,看看会发生什么。接着,改变每一层的单元数量,并再次运行实验,查看结果。

除了这些变化外,我们还可以对起始的 epsilon 值、epsilon 衰减的速度以及神经网络模型的学习率进行调整。这些变化都会影响代理的策略函数。当我们更改一个值,这个值会改变动作或回放函数的输出时,我们就在修改代理用来解决问题的策略。我们可以为代理制定策略,让它探索更多或更少的动作,或者调整它在探索环境与利用当前知识之间所花费的时间,还可以调整代理从每一步中学习的速度,代理可能尝试多少次相似的动作以查看是否总是错误的,以及在采取导致失败的动作后,代理尝试调整的幅度。

与任何类型的机器学习一样,强化学习中有许多参数可以调节以优化性能。与其他问题不同,可能没有标准的度量来帮助调整这些参数,选择最合适的值可能更为主观,并且依赖于试验和错误的实验过程。

摘要

在这一章节中,我们编写了代码,使用深度 Q 学习进行强化学习。我们注意到,尽管 Q 学习是一种更简单的方法,但它需要一个有限且已知的环境。而应用深度 Q 学习则使我们能够在更大范围内解决问题。我们还定义了我们的智能体,这需要创建一个类。该类定义了我们的智能体,并通过实例化一个对象,将类中定义的属性应用于解决强化学习的挑战。接着,我们创建了一个自定义环境,使用函数定义了边界,以及智能体可以采取的移动范围和目标或目的。深度 Q 学习涉及在选择动作时加入神经网络,而不是像 Q 学习那样依赖 Q 矩阵。随后,我们将神经网络添加到我们的智能体类中。

最后,我们通过将智能体对象放入自定义环境,并让它采取各种行动,直到解决问题,从而将所有内容整合在一起。我们进一步讨论了一些可以采取的选择,以提高智能体的表现。有了这个框架,你已经准备好将强化学习应用于各种环境,并使用各种可能的智能体。这个过程基本上保持一致,变化将体现在智能体的编程方式以及环境中的规则。

这就完成了Hands-On Deep Learning with R。在本书中,你学习了多种深度学习方法。此外,我们还将这些方法应用于多种不同的任务。本书的编写偏向于实际操作。其目标是提供简洁的代码,解决实际项目中的问题。希望通过本书的学习,你已经准备好利用深度学习解决现实世界中的挑战。