R 强化学习实用指南(二)
原文:
annas-archive.org/md5/25a973e468806ffc3c064b967b5af8dd译者:飞龙
第五章:动态规划与最优策略
动态规划(DP)代表了一组可以用来计算最优策略的算法,前提是有一个完美的环境模型,以马尔可夫决策过程(MDP)的形式给出。DP 方法根据之前步骤中的估计值来更新状态值的估计。在 DP 中,优化问题被分解为更简单的子问题,每个子问题的解被存储,以便每个子问题只被解决一次。在本章中,我们将学习如何通过 R 代码实现 DP 来选择最优的投资组合。
本章涵盖以下主题:
-
理解 DP
-
学习自顶向下的 DP 方法
-
分析递归与备忘录化的区别
-
学习优化技术
-
在强化学习应用中实现 DP
-
解决背包问题
-
机器人导航系统的优化
技术要求
查看以下视频,了解代码如何运行:
理解 DP
DP(动态规划)是理查德·贝尔曼(Richard Bellman)在 1950 年代开发的数学方法论。它用于解决需要按顺序处理一系列相互依赖的决策问题。该方法论背后的基本原则是贝尔曼的最优性——无论初始状态和初始决策如何,后续的决策必须相对于前一个决策所导致的状态提供最优的策略。这是最优策略的本质特征。
设想一个寻找连接两个位置的最佳路径的例子。最优性原则指出,路径中每一个子路径,无论是从中间位置到最终位置,都必须是最优的。基于这一原则,DP 通过一次作出一个决策来解决问题。在每一步,都会确定未来的最佳策略,而不考虑过去的选择(它是一个马尔可夫过程),前提是这些选择也是最优的。
因此,DP 在原问题可以分解为一系列较小的子问题,或当支付的成本或获得的利润可以表示为与每个单独决策相关的基本成本之和时,DP 是有效的。更一般地,成本必须通过某些运算符表示为基本成本的组合,这些基本成本各自依赖于单个决策。
以下图示展示了网络中两个节点之间的最佳路径(红色路径),在所有可用路径中选择的“最佳”路径是“最短”的:
有许多路径可以到达相同的目的地:只有一条是最短的。
现在让我们来分析这项技术的基本概念。我们将通过比较两种非常流行的技术开始。
学习自顶向下的 DP 方法
为了理解动态规划背后的机制,我们可以将其与另一种非常常见的解决问题的机制——分治法进行比较。通过分治法,一个问题被分解成两个或更多的子问题,原问题的解是从子问题的解开始构建的。这种方法叫做自顶向下的技术,按以下步骤进行:
-
将问题实例分解成两个或更多的子实例。
-
对每个子实例递归地解决问题。
-
重新组合子问题的解,以获得全局解。
这种机制广泛应用于解决多个问题。最常见的应用是两种最常用的排序算法——快速排序和归并排序。
例如,在快速排序算法中,待排序列表的元素被分为两个块,一块是小于主元的元素,另一块是大于主元的元素,然后算法会递归地对这两个块进行调用。归并排序中,算法找到中间位置的索引,并将列表分成两个各包含 n/2 元素的块。然后,算法会递归地对这两个块进行调用。
有些情况下,分治法无法应用,因为我们不知道如何获得子问题——问题本身并不包含足够的信息来让我们决定如何将其分解为多个部分。
在这种情况下,动态规划发挥作用:我们继续计算所有可能的子问题的解,并从子解开始,逐步得到新的子解,直到解决原问题。与分治法不同,待解决的子问题不一定是互不相交的,这意味着一个子问题可以是多个其他子问题的共同部分。为了避免对子问题进行重复计算,子问题通过自底向上的策略解决——从最小的子问题开始解决,逐步向大的子问题推进,并将这些子问题的解存储在适当的表格中,以便它们可以在需要时(如有必要)为解决其他子问题提供帮助。
在递归算法中,我们常常遇到一些从计算角度来看不必要的繁重过程。让我们看看如何解决这个问题。
分析递归和记忆化之间的区别
在这里所说的基础上,我们可以推断出动态规划(DP)用于那些具有递归定义的问题,但将该定义直接转化为算法会由于不同递归调用对相同数据子集的重复计算,导致程序的时间复杂度呈指数增长。一个例子是斐波那契数的计算,我们将在后续详细分析。
我们已经看到,动态规划(DP)是一种更高效地解决递归问题的技术。这是为什么呢?在递归过程中,我们很多时候会重复地解决子问题。在动态规划中,情况并非如此——我们会记住这些子问题的解,从而避免再次求解。这种做法叫做记忆化。
如果一个变量在某一给定步骤的值依赖于之前计算的结果,并且这些计算会被重复多次,那么存储中间结果就变得非常有用,从而避免重复计算那些计算成本高昂的部分。
为了更好地理解递归和记忆化之间的区别,我们来分析一个简单的例子:计算一个数字的阶乘。一个自然数 n 的阶乘,记作n!,是小于或等于该数字的所有正整数的乘积。n 的阶乘计算公式如下:
一个数字的阶乘也可以通过递归来定义:
如果一个函数调用自身,则称该函数为递归函数。递归函数只能直接解决问题的某些特定情况,这些特定情况称为基准情况(例如之前公式中的那些情况):如果传入的数据属于某个基准情况,它就返回一个结果。在每次调用时,数据都会减少,直到某个时刻,我们到达了基准情况。当函数调用自身时,它会暂停当前执行,去执行新的调用。内部调用结束后,执行会恢复。递归调用的序列在最内层的调用遇到基准情况时终止。现在让我们看看如何优化这种技术。
学习优化技术
优化问题是一个可以通过成本函数(也叫做目标函数)来衡量其解的问题。要寻找的值通常是该函数的最小值或最大值。优化问题可以被简化为一系列的决策问题。
要解决优化问题,必须使用迭代算法。即一种计算程序,它在给定当前解的近似值时,通过适当的操作序列来确定一个新的近似值。从一个初始近似值开始,问题的可能解就以一种连续的方式被确定出来。
最优解的搜索算法可以分为以下三类:
-
列举技术:列举技术通过遍历函数定义域内的所有点来寻找最优解。通过将问题简化为更简单的子问题,可以减少问题的复杂性。动态规划(DP)就是其中的一种技术。
-
数值技巧:这些技巧通过利用一组必要且充分的条件来优化问题。它们可以分为直接方法和间接方法。间接方法通过求解一组非线性方程并迭代地寻找解,直到代价函数的梯度为零,来寻找最小值。直接方法则通过梯度指导搜索解的过程。
-
概率技巧:概率技巧基于枚举技术,但它们使用额外的信息来进行研究,可以看作是进化过程。这一类包括模拟退火算法,它使用热力学进化过程,以及基因算法类,它们利用生物进化技术。
在下一部分,我们将讨论基于动态规划(DP)的优化技术。
计算费波那契数列
列奥纳多·皮萨诺,也叫费波那契,是一位著名的意大利数学家(皮萨,1175 - 1240)。他的名字与费波那契数列相关,该数列源自斯瓦比亚皇帝腓特烈二世提出的一个问题。1223 年,在比萨的数学家比赛中,他提出了如下问题:在不考虑死亡的情况下,每对兔子每月生育一对兔子,且最年轻的兔子在生命的第二个月就能繁殖,一年内能得到多少对兔子?
费波那契对测试给出了如此快速的回答,以至于有人认为比赛是作弊的:
看看这里给出的数列:
-
前两个元素是 1, 1。
-
每个元素是由前两个元素之和给出的。
以 F(n) 表示第 n 个月的对数,费波那契数列变为以下形式:
-
F(1) = 1
-
F(2) = 1
-
F(n) = F (n-1) + F (n-2) 在第 n 个月,其中 n> 2
基于这个定义,我们通常假设 F(0) = 0,以便递归关系 F(n) = F(n-1) + F(n-2) 在 n = 2 时也成立。
费波那契数列促使我们研究了数学和自然科学的许多领域。然而,尽管发现了这一重要数列,费波那契并没有掌握它的许多方面。四个世纪后,开普勒观察到,两个连续项之间的关系趋向于黄金比例。
那么,来看看一个简单的 R 函数,它通过递归过程计算费波那契数:
FibRec <- function(n) {
if (n<=2)
return(1)
return (FibRec(n-1)+FibRec(n-2))
}
StartTime <- Sys.time()
paste("20th Fibonacci number is: ",FibRec(20))
EndTime <- Sys.time()
paste("Computational time using Recursion is: ",EndTime - StartTime)
在函数内部,有一个 if 结构包含两个选项:如果 n> 2,函数会调用自身;当 n<=2 时返回 1。对 FibRec(n-1) 的调用要求函数解决比最初问题更简单的问题(数值较小),但问题始终是相同的。函数会一直调用自身,直到达到它可以立即解决的基本情况。为了比较两种解决技术,使用 Sys.time() 函数来计算计算成本。结果如下所示:
"20th Fibonacci number is: 6765"
Computational time using Recursion is: 0.0400021076202393
由于使用的递归算法的性质,该程序需要进行 n + 1 次阶乘函数调用才能得到结果,并且每次调用都伴随着与函数返回计算值所需时间相关的成本。
通过记忆化,可以按如下方式改进该程序:
-
创建一个变量来存储临时结果(
RecTable)。 -
在进行计算之前,先检查该计算是否已经完成。如果完成了,则使用存储的结果。
-
如果这是第一次计算,存储结果以备将来使用。
以下代码展示了程序的记忆化版本:
RecTable <- c(1, 1, rep(NA, 100))
FibMem <- function(x) {
if(!is.na(RecTable[x])) return(RecTable[x])
ans <- FibMem(x-2) + FibMem(x-1)
RecTable[x] <<- ans
ans
}
StartTime <- Sys.time()
paste("20th Fibonacci number is: ",FibMem(20))
EndTime <- Sys.time()
paste("Computational time using Memoization is: ",EndTime - StartTime)
在这种情况下,我们将斐波那契数存储在一个表中,之后可以在下一次计算时检索。这样,避免了每次都进行整个计算。记忆化提高了函数的时间效率。每次该函数被调用时,都会突出改进,从而加速了算法。以下是结果:
20th Fibonacci number is: 6765
Computational time using Memoization is: 0.0310020446777344
通过比较两种计算成本,可以发现带有记忆化的版本更快。现在让我们来看一下如何在强化学习的背景下利用 DP 提供的潜力。
在强化学习应用中实现 DP
DP 代表一组算法,可用于在环境的完美模型(以 MDP 形式)下计算最优策略。DP 的基本思想,以及强化学习的一般思想,是利用状态值和动作来寻找良好的策略。
DP 方法通过迭代两个过程来解决马尔可夫决策过程,分别是 策略评估 和 策略改进:
-
策略评估算法通过应用迭代方法来求解贝尔曼方程。由于只有当 k → ∞ 时收敛才有保障,我们必须满足于通过设置停止条件得到良好的近似。
-
策略改进算法基于当前值来改进策略。
策略迭代算法的一个缺点是每一步都需要评估一个策略。这涉及到一个迭代过程,我们事先并不知道它的收敛时间,这将取决于起始策略是如何选择的。
克服这一缺点的一种方法是,在特定步骤中切断策略的评估。这一操作不会改变收敛到最优值的保证。在策略评估被逐步阻断(也叫做遍历)的特殊情况下,定义了值迭代算法。在值迭代算法中,每次政策改进步骤之间都会执行一次值计算的迭代。
因此,DP 算法本质上是基于策略评估和策略改进,这两个过程并行进行。反复执行这两个过程使得整个过程趋向于最优解。在策略迭代算法中,这两个阶段交替进行,且每个阶段结束后才开始下一个阶段。
DP 方法通过环境中所有可能的状态集进行操作,在每次迭代时对每个状态执行完整的备份操作。每次备份操作都根据所有可能的后继状态的值更新一个状态的值。这些状态会根据它们发生的概率进行加权,这个概率由策略选择和环境的动态性共同决定。完整备份与贝尔曼方程密切相关,它们不过是将贝尔曼方程转化为赋值指令的过程。
当一次完整的备份迭代没有对状态值产生任何变化时,收敛就达成,因此最终的状态值完全满足贝尔曼方程。DP 方法仅在存在完美的交替器模型时适用,该模型必须等同于马尔可夫决策过程(MDP)。
正是由于这个原因,DP 算法在强化学习中的应用有限,既因为它假设环境有完美模型,又因为计算量高且昂贵。但仍然有必要提及它们,因为它们代表了强化学习的理论基础。事实上,所有强化学习方法都试图达到 DP 方法的同样目标,只是计算成本较低,并且不假设环境有完美的模型。
DP 方法通过与状态数 𝑛 和动作数 𝑚 相比的多项式操作次数,收敛到最优解,而与之相对的是基于直接搜索的方法需要 𝑚*𝑛 的指数操作次数。
DP 方法基于先前步骤中做出的估计,更新状态值的估算。这代表了一种特殊的属性,称为自举(bootstrapping)。多种强化学习方法执行自举,即便是那些不要求环境的完美模型(如 DP 方法所需的),也同样执行自举。我们来看一个使用 DP 的实际案例。
求解背包问题
在这一节中,我们将分析一个经过超过一个世纪研究的经典问题,自 1897 年以来—背包问题。首位处理背包问题的是数学家托比亚斯·丹齐格,他将其命名为源自普通的装载最有用物品而不过载背包的问题。
这种类型的问题可以与现实生活中不同情况联系起来。为了更好地描述这个问题,我们将提出一个非常独特的情景:一个小偷进入房子并想偷走贵重物品。他把它们放在他的背包里,但受到重量的限制。每个物体都有自己的价值和重量,所以他必须选择价值高但重量不大的物品。不能超过背包的重量限制,但同时又要优化价值。
现在,我们将从数学的角度解决这个问题。假设我们有一个由整数标记为 1 到n的 n 个对象组成的集合 X:{1, 2, ..., n}。这些对象满足以下条件:
-
第 i 个物品具有重量 p[i]和价值 v[i]。
-
每个物体只有一个实例。
我们有一个容器,最多可以携带重量为 P 的物品。我们想确定对象的子集 Y ⊆ X:
-
Y 中物品的总重量≤ P。
-
Y 中物品的总价值是可能的最大值。
这两个数学形式中的条件如下:
- 我们希望确定一个对象的子集 Y ⊆ X,以便:
- 为了最大化以下总价值:
如所放置的那样,这是一个优化问题。一般来说,优化问题有两个部分:
-
一组必须遵守的约束(可能为空)。
-
必须最大化或最小化的目标函数。
我们已经采用的数学形式定义问题,明确澄清了我们刚才提到的两部分。许多实际问题可以相对简单地被表述为可以使用计算器解决的优化问题。将新问题简化为已知问题允许使用现有的解决方案。
与大多数问题一样,即使对于优化问题,解决问题的不同方法也允许我们达到解决方案。它们在时间和内存要求的复杂性以及所需的编程工作方面自然有所不同。
有两个问题的版本:
-
0-1 背包问题:每个物品要么全部接受,要么全部拒绝。
-
分数背包问题:我们可以取物品的分数部分。
这两个问题之间的实质差异在于物品是否可以分割。在 0-1 背包问题中,我们不能分割物品。相反,在分数背包中,我们可以分割物体以最大化背包的总价值。
我们介绍的背包问题可以很容易地应用于金融投资组合优化问题。实际上,只需将物体的重量与考虑中的金融产品的风险权重相关联,将物体的价值与金融产品的预期价值相关联即可。基于这些假设,可以选择使预期价值最大化,并将风险保持在特定值以下的金融产品。
在接下来的部分,我们将通过三种不同的方法解决背包问题:
-
暴力算法
-
贪婪算法
-
DP
我们将深入探讨每个解决方案,突出它们的优缺点。
暴力算法
暴力算法列出了可能表示解决方案的所有可能值,并检查每个值是否满足问题所施加的条件。此算法易于实现,如果存在解决方案,则总是返回解决方案,但其成本与可能解的数量成正比。因此,通常在问题规模有限或存在可以减少可能解集的假设时使用暴力搜索。该方法也用于在实现简单性比速度更重要时。
要解决背包问题,暴力算法是最直接的解决方案:检查填充背包的所有可能方法,这些方法有2n种,并打印出一个最优解(可能不止一个)。对于n > 15,这种方法变得非常慢。这种算法通常直接基于问题的定义和相关概念的理解。
这里是这个简单算法的要点:
-
枚举每种可能的组合。
-
选择最佳解决方案(检查所有组合,返回最大值且总重量小于或等于 P 的组合)。
-
优化性得到保证。
-
对于较大的n,时间成本极高。运行时间将为O(2n)。
在下面的代码块中是解决 0-1 背包问题的示例代码:
W = 10
WeightArray = c(5,2,4,6)
ValueArray = c(18,9,12,25)
DataKnap<-data.frame(WeightArray,ValueArray)
BestValue = 0
ItemsSelected = c()
TempWeights<-c()
TempValues<-c()
for(i in 1:4){
CombWeights<-as.data.frame(combn(DataKnap[,1], i))
CombValues<-as.data.frame(combn(DataKnap[,2], i))
SumWeights<-colSums(CombWeights)
SumValue<-colSums(CombValues)
TempWeights<-which(SumWeights<=W)
if(length(TempWeights) != 0){
TempValues<-SumValue[TempWeights]
BestValue<-max(TempValues)
Index<-which((TempValues)==BestValue)
MaxIndex<-TempWeights[Index]
MaxVW<-CombWeights[, MaxIndex]
j=1
while (j<=i){
ItemsSelected[j]<-which(DataKnap[,1]==MaxVW[j])
j=j+1
}
}
}
list(value=round(BestValue),elements=ItemsSelected)
我们将逐行分析这段代码。前几行设置了数据:
W = 10
WeightArray = c(5,2,4,6)
ValueArray = c(18,9,12,25)
DataKnap<-data.frame(WeightArray,ValueArray)
让我们逐个元素看一下:
-
W是最大重量容量。 -
WeightArray是重量数组。 -
ValueArray是值数组。 -
DataKnap是包含重量和值的数据框。
现在我们将初始化算法中使用的变量:
BestValue = 0
ItemsSelected = c()
TempWeights<-c()
TempValues<-c()
如前所述,暴力算法系统地列出了可能表示解决方案的所有可能值,并检查每个值是否满足问题所施加的条件。由于系统传递了四个对象,将设置一个四步循环如下:
for(i in 1:4){
对于for循环的每一步,我们将计算所有取出的物体的组合,i次不重复。为此,使用了combn()函数:
CombWeights<-as.data.frame(combn(DataKnap[,1], i))
CombValues<-as.data.frame(combn(DataKnap[,2], i))
这个函数生成所有DataKnap列中元素的组合,取i个元素。接下来,我们将对返回的数组进行求和:
SumWeights<-colSums(CombWeights)
SumValue<-colSums(CombValues)
现在需要选择只返回重量总和<=W的组合:
TempWeights<-which(SumWeights<=W)
如果该操作返回至少一个组合,我们将计算最佳解:
if(length(TempWeights) != 0){
TempValues<-SumValue[TempWeights]
BestValue<-max(TempValues)
Index<-which((TempValues)==BestValue)
MaxIndex<-TempWeights[Index]
MaxVW<-CombWeights[, MaxIndex]
j=1
while (j<=i){
ItemsSelected[j]<-which(DataKnap[,1]==MaxVW[j])
j=j+1
}
}
}
最后,最佳组合的列表将被打印出来:
list(value=round(BestValue),elements=ItemsSelected)
以下是 0-1 背包问题解返回的结果:
$value
[1] 37
$elements
[1] 3 4
结果表明,最佳解返回值为 37,所选物体位于第 3 和第 4 位置。正如预期的那样,我们刚才处理的背包问题的最优解是最直接的,但从计算的角度来看也是最昂贵的。在接下来的部分中,我们将尝试获得其他解,力求在计算上节省开销。
贪心算法
在引入贪心算法来寻找背包问题的最优解之前,回顾一下任何贪心技术的主要特点是很有必要的。任何贪心技术都是迭代进行的。从一个空的解开始,在每次迭代中,元素 A 会被添加到正在构建的部分解中。在所有可以添加的候选元素中,元素 A 是最有前途的,即如果选择它,它将导致目标函数的最大改进。显然,并不是所有问题都能通过这种策略解决;只有那些可以证明当前做出最佳选择能导致全局最优解的问题,才能使用这种方法。
让我们首先看一个简单的算法,执行以下操作:
-
丢弃所有重量超过最大容量的物体(预处理)。
-
根据给定的标准对物体进行排序。
-
一次选择一个物体,直到满足重量限制。
-
返回解的值和所选物体的集合。
在以下代码块中,我们可以看到执行该算法的代码:
K = 10
w = c(5,2,4,6)
v = c(18,9,12,25)
DataKnap<-data.frame(w,v)
DataKnap$rows_idx <- row(DataKnap)
DataKnap <- DataKnap[DataKnap$w < K,]
DataKnap$VWRatio <- DataKnap$v/DataKnap$w
DescOrder <- order(DataKnap$VWRatio, decreasing = TRUE)
DataKnap <- DataKnap[DescOrder,]
KnapSol <- list(value = 0)
SumWeights <- 0
i <- 1
while (i<=nrow(DataKnap) & SumWeights + DataKnap$w[i]<=K){
SumWeights <- SumWeights + DataKnap$w[i]
KnapSol$value <- KnapSol$value + DataKnap$v[i]
KnapSol$elements[i] <- DataKnap$row[i]
i <- i + 1
}
print(KnapSol)
我们将逐行分析这段代码。初始的几行设置了初始数据:
K = 10
w = c(5,2,4,6)
v = c(18,9,12,25)
DataKnap<-data.frame(w,v)
为了找到最佳解,我们首先对物体按价值密度进行降序排列,计算方法如下:
这种技术在以下代码中实现:
DataKnap$rows_idx <- row(DataKnap)
DataKnap <- DataKnap[DataKnap$w < K,]
DataKnap$VWRatio <- DataKnap$v/DataKnap$w
DescOrder <- order(DataKnap$VWRatio, decreasing = TRUE)
DataKnap <- DataKnap[DescOrder,]
以下几行用于初始化变量:
KnapSol <- list(value = 0)
SumWeights <- 0
i <- 1
现在,将使用while循环来迭代该过程:
while (i<=nrow(DataKnap) & SumWeights + DataKnap$w[i]<=K){
SumWeights <- SumWeights + DataKnap$w[i]
KnapSol$value <- KnapSol$value + DataKnap$v[i]
KnapSol$elements[i] <- DataKnap$row[i]
i <- i + 1
}
循环会一直重复,直到两个条件都为真。一旦其中一个条件为假,循环就会停止。第一次检查是在数据矩阵的行数上进行的,最多会有与行数相等的迭代次数。第二次检查是在设定的最大容量上进行的。一旦超过这个容量,循环会停止。
最后,结果会被打印出来:
print(KnapSol)
结果显示如下:
$value
[1] 34
$elements
[1] 2 4
从对前面数据的分析中,我们可以注意到,我们没有像暴力算法那样获得最优解,但这个过程非常快速。
使用动态规划实现解法
在前面的章节中,我们已经看到了如何通过不同的方式解决背包问题。特别是,我们学会了用一种叫做暴力求解的算法来处理这个问题。在这种情况下,我们通过极大的计算代价获得了最优解。相反,后面看到的贪心算法从计算的角度给我们提供了一个更轻量的算法,但它无法获得最优解。通过动态规划(DP),可以提供一个同时满足最优解和快速算法这两个需求的解法。
在动态规划中,我们将一个优化问题分解为更简单的子问题,并存储每个子问题的解,以便每个子问题仅解决一次。该方法的核心思想是,首先计算子问题的解并将其存储在表格中,以便稍后可以重复使用这些解(重复使用)。
在下面的代码块中,使用动态规划实现了一个背包问题的解决方案:
v <- c(18,9,12,25)
w <- c(5,2,4,6)
W <- 10
Tabweights<-c(0,w)
TabValues<-c(0,v)
n<-length(w)
TabMatrix<-matrix(NA,nrow =n+1,ncol = W+1)
TabMatrix[,]<-0
for (j in 2:W+1){
for (i in 2:n+1){
if (Tabweights[i] > j) {
TabMatrix[i,j] = TabMatrix[i-1,j]
}
else
{
TabMatrix[i,j]<-max(TabMatrix[i-1,j], TabValues[i] + TabMatrix[i-1,j-Tabweights[i]])
}
}
}
cat("The best value is",TabMatrix[i,j])
i = n+1
w = W+1
ItemSelected = c()
while(i>1 & w>0)
{
if(TabMatrix[i,w]!=TabMatrix[i-1,w])
{
ItemSelected<-c(ItemSelected,(i)-1)
w = w - Tabweights[i]
i = i - 1
}
else
{
i = i - 1
}
}
cat("The items selected are",ItemSelected)
我们将逐行分析这段代码。这个算法从定义将在过程中使用的数据开始:
v <- c(18,9,12,25)
w <- c(5,2,4,6)
W <- 10
然后定义其他变量:
Tabweights<-c(0,w)
TabValues<-c(0,v)
n<-length(w)
TabMatrix<-matrix(NA,nrow =n+1,ncol = W+1)
TabMatrix[,]<-0
我们来看一下这段代码的元素:
-
Tabweights是一个包含重量和 0 作为第一个元素的向量。 -
TabValues是一个包含值和 0 作为第一个元素的向量。 -
n是物品的数量。 -
TabMatrix是一个表格矩阵。
我们首先定义并初始化一个将包含值的表格。该表格是从上到下按列构建的,如下图所示:
然后我们设置一个对所有物品和所有重量值的迭代循环:
for (j in 2:W+1){
for (i in 2:n+1){
if (Tabweights[i] > j) {
TabMatrix[i,j] = TabMatrix[i-1,j]
}
else
{
TabMatrix[i,j]<-max(TabMatrix[i-1,j], TabValues[i] + TabMatrix[i-1,j-Tabweights[i]])
}
}
}
首先,我们用 0 填充第一行 i=1。这意味着当没有物品时,重量为 0,因此我们将第一列 w = 1 填充为 0。这意味着当重量为 0 时,所考虑的物品为 0。实际上,我们将第一行初始化为 0,这对应于对于不同的可运输重量,我们没有任何物品的情况(T[1, w] = 0)。将第一列初始化为 0,这对应于对于多个可能的物品,我有一个零容量的背包的情况(T[i, 1] = 0)。
填充表格的规则由以下算法提供:
if (Tabweights[i] > j) {
TabMatrix[i,j] = TabMatrix[i-1,j]
}
else
{
TabMatrix[i,j]<-max(TabMatrix[i-1,j], TabValues[i] + TabMatrix[i-1,j-Tabweights[i]])
如果第 i^(th) 个元素的重量大于列的重量,则第 i^(th) 个元素将等于前一个元素,海拔将通过以下公式计算:
一旦到达表格最后一行的最后一个单元格,我们可以记住得到的结果,这代表背包中可以携带物品的最大值:
cat("The best value is",TabMatrix[i,j])
返回以下结果:
The best value is 37
到目前为止的过程没有指出哪个子集提供最优解。我们必须通过分析表格的最后一列(w = P)来提取此信息;我们将从最后一个值开始,逐步向上运行。
i = n+1
w = W+1
ItemSelected = c()
while(i>1 & w>0)
{
if(TabMatrix[i,w]!=TabMatrix[i-1,w])
{
ItemSelected<-c(ItemSelected,(i)-1)
w = w - Tabweights[i]
i = i - 1
}
else
{
i = i - 1
}
}
如果当前元素与前一个元素相同,我们就跳到下一个;否则,当前物体将被包含在背包中:
if(TabMatrix[i,w]!=TabMatrix[i-1,w])
{
ItemSelected<-c(ItemSelected,(i)-1)
如果元素被插入到背包中,通过从选定物体的重量中减去当前w值来获得列值:
w = w - Tabweights[i]
最后,所选的物品将被打印出来:
cat("The best value is",TabMatrix[i,j])
结果如下所示:
The items selected are 4 3
动态规划(DP)算法使我们能够获得最优解,从而节省计算成本。
在下一节中,我们将分析一个实际案例;我们将优化机器人的导航系统。
机器人导航系统的优化
机器人是一种根据提供的指令执行特定动作的机器,这些指令可以是基于直接人工监督,或基于使用人工智能过程的通用指导来独立执行的。机器人应该能够替代或协助人类完成诸如制造、建筑、在不适合人类的条件下处理重型和危险物料,或者仅仅是解放一个人免于承担某些责任的工作。
机器人应通过感知与行动之间的反馈来装备引导连接,而不是通过直接的人工控制。动作可以通过电磁马达或执行器的形式来进行,这些执行器可以移动四肢、开关夹爪或移动机器人。逐步控制和反馈由一个外部或内部机器人计算机或微控制器运行的程序提供。根据这个定义,机器人概念几乎可以包括所有自动化设备。
训练一个代理在环境中移动
为了理解如何解决与机器人自主导航相关的问题,我们将从一个广泛的问题开始——gridworld问题。在这些问题中,环境被定义为一个简单的二维矩形网格,尺寸为(N, M),代理从一个网格格子出发,试图移动到另一个位于其他位置的网格格子。这个环境非常适合应用强化学习算法,帮助代理在网格上发现到达目标网格格子的最优路径和策略,以最少的移动次数达到目标。
以下图示展示了一个 5 x 5 的网格:
代理在探索状态的过程中不断演化。该环境没有终止状态。代理可以执行{右移、左移、上移、下移}的动作。如果动作使代理移出网格,代理将保持在当前状态,但会应用一个负奖励。对于所有其他状态(和动作),奖励为 R = -2,除了将代理移至终点的动作。在这种情况下,四个动作的奖励为 R = +20,并将代理带到最终状态。
为了更好地理解上下文,我们将只处理一个 2 x 2 网格的问题,该网格中有一堵墙,禁止从第 1 格到第 4 格的通过,具体如以下图所示:
我们的目标是制定出最优策略,从 C1(起点)出发,最终到达 C4(终点)。以下代码是解决网格世界问题的一个示例:
library(MDPtoolbox)
UpAct=matrix(c(0.3, 0.7, 0, 0,
0, 0.9, 0.1, 0,
0, 0.1, 0.9, 0,
0, 0, 0.7, 0.3),
nrow=4,ncol=4,byrow=TRUE)
DownAct=matrix(c( 1, 0, 0, 0,
0.7, 0.2, 0.1, 0,
0, 0.1, 0.2, 0.7,
0, 0, 0, 1),
nrow=4,ncol=4,byrow=TRUE)
LeftAct=matrix(c( 0.9, 0.1, 0, 0,
0.1, 0.9, 0, 0,
0, 0.7, 0.2, 0.1,
0, 0, 0.1, 0.9),
nrow=4,ncol=4,byrow=TRUE)
RightAct=matrix(c( 0.9, 0.1, 0, 0,
0.1, 0.2, 0.7, 0,
0, 0, 0.9, 0.1,
0, 0, 0.1, 0.9),
nrow=4,ncol=4,byrow=TRUE)
AllActions=list(up=UpAct, down=DownAct, left=LeftAct, right=RightAct)
AllRewards=matrix(c( -2, -2, -2, -2,
-2, -2, -2, -2,
-2, -2, -2, -2,
20, 20, 20, 20),
nrow=4,ncol=4,byrow=TRUE)
mdp_check(AllActions, AllRewards)
GridModel=mdp_policy_iteration(P=AllActions, R=AllRewards, discount = 0.1)
GridModel$policy
names(AllActions)[GridModel$policy]
GridModel$V
GridModel$iter
GridModel$time
我们将逐行分析这段代码。首先,我们加载了库:
library(MDPtoolbox)
接下来,我们设置所有可能的动作:
UpAct=matrix(c(0.3, 0.7, 0, 0,
0, 0.9, 0.1, 0,
0, 0.1, 0.9, 0,
0, 0, 0.7, 0.3),
nrow=4,ncol=4,byrow=TRUE)
DownAct=matrix(c( 1, 0, 0, 0,
0.7, 0.2, 0.1, 0,
0, 0.1, 0.2, 0.7,
0, 0, 0, 1),
nrow=4,ncol=4,byrow=TRUE)
LeftAct=matrix(c( 0.9, 0.1, 0, 0,
0.1, 0.9, 0, 0,
0, 0.7, 0.2, 0.1,
0, 0, 0.1, 0.9),
nrow=4,ncol=4,byrow=TRUE)
RightAct=matrix(c( 0.9, 0.1, 0, 0,
0.1, 0.2, 0.7, 0,
0, 0, 0.9, 0.1,
0, 0, 0.1, 0.9),
nrow=4,ncol=4,byrow=TRUE)
每个动作矩阵都是 4 x 4 类型。事实上,它包含了从每个状态(由行表示)出发,在可能转移到其他状态(由列表示)时的所有概率。例如,MoveUp 矩阵的第一行包含了从 C1 状态出发,进行向上的动作时能够转移到其他状态的所有概率。显然,在这个状态下,执行此动作时,我可以转移到状态 C2。事实上,相关的概率是 0.7。相同矩阵的第二行包含了从 C2 状态出发,执行向上的动作时能够转移到其他状态的概率,在这种情况下,最大概率是它保持在此状态。
在下一步,我们将把所有定义的动作合并成一个列表:
AllActions=list(up=UpAct, down=DownAct, left=LeftAct, right=RightAct)
根据假设,我们来定义问题允许的奖励和惩罚:
AllRewards=matrix(c( -2, -2, -2, -2,
-2, -2, -2, -2,
-2, -2, -2, -2,
20, 20, 20, 20),
nrow=4,ncol=4,byrow=TRUE)
在继续之前,我们必须检查我们定义的问题格式。我们将使用mdp_check()函数:
mdp_check(AllActions, AllRewards)
这个函数检查由转移概率数组(AllActions)和奖励矩阵(AllRewards)定义的 MDP 是否有效。如果AllActions和AllRewards是正确的,函数将返回一个空的错误消息。反之,函数将返回一个描述问题的错误消息。让我们搜索从 C1 到 C4 的最优策略:
GridModel=mdp_policy_iteration(P=AllActions, R=AllRewards, discount = 0.1)
使用了mdp_policy_iteration()函数。这个函数应用策略迭代算法来解决折扣 MDP。该算法的基本思路是通过评估当前策略,迭代地改善策略。当两次连续的策略相同或已达到指定的迭代次数时,迭代过程停止。传入了三个参数:
-
AllActions:转移概率数组。这个数组可以是一个三维数组,也可以是一个列表,每个元素包含一个稀疏矩阵。 -
AllRewards:奖励数组。这个数组可以是一个三维数组或一个列表,每个元素包含一个稀疏矩阵或一个可能是稀疏的二维矩阵。 -
discount:折扣因子。折扣因子是一个介于[0; 1]之间的实数。
到此为止,我们可以恢复策略:
GridModel$policy
返回以下结果:
1 4 2 2
为了更好地理解策略,我们可以提取由策略定义的动作名称:
names(AllActions)[GridModel$policy]
返回以下结果:
"up" "right" "down" "down"
现在我们可以提取每个步骤的最优值。这些值在每次运行时可能不同:
GridModel$V
返回以下结果:
-2.213209 -2.097323 -0.474916 22.222222
我们讨论了迭代。实际上,我们可以看到算法在多少次迭代后达到收敛:
GridModel$iter
返回以下结果:
3
最后,我们打印了执行时间:
GridModel$time
返回以下结果:
Time difference of 0.377022 secs
面临的问题看似微不足道,但它让我们理解了必须如何处理。除非定义了最大行动和奖励的矩阵,否则必须以相同的方式处理更大的网格。
总结
在本章中,我们讨论了优化技术的基本概念。首先,我们学习了动态规划(DP)背后的基本元素。在动态规划中,我们将一个优化问题细分为更简单的子问题:我们计算所有可能子问题的解,从这些子解中获取新的子解,然后解决原始问题。
然后我们学习了递归与备忘录化的区别。随后,我们学习了背包问题的基础。这个问题通过三种不同的方法进行了解决:暴力法、贪心算法和动态规划。对于每种方法,提供了一个解法算法并进行了结果比较。
最后,我们讨论了导航路径的优化问题。为了处理机器人自主导航,我们学习了如何解决在网格世界中寻找路径的问题。通过这种方式,我们看到了如何解决找到最佳策略以确定路径的问题。
在下一章,我们将学习预测技术的基本概念。
第六章:蒙特卡洛方法在预测中的应用
蒙特卡洛方法用于估计价值函数并发现优秀策略时,并不需要环境的模型。你可以仅通过智能体的经验,或通过从智能体与环境交互中获得的状态序列、动作和奖励样本来进行学习。经验可以通过智能体的学习过程获得,或者通过先前填充的数据集进行模拟。在本章中,我们将学习如何使用蒙特卡洛方法来预测最优策略。
到本章结束时,你应该熟悉预测技术的基本概念,并学会如何应用蒙特卡洛方法来预测环境行为。我们还将学习无模型方法来处理强化学习问题,如何估计动作值,以及如何通过学习最优策略的价值来构成一个脱离策略的算法,而无需考虑智能体的动作。
本章将覆盖以下主题:
-
预测概述
-
理解蒙特卡洛方法
-
接近无模型算法
-
动作值估计
-
使用蒙特卡洛方法预测黑杰克策略
技术要求
请观看以下视频,查看代码的实际应用:
预测概述
试图预测未来有着悠久的历史,贯穿了整个人类历史,适应了不同文明的典型方式和宗教背景。预测未来事件的需求似乎不仅仅是为了纯粹的推测和认知目的,还具有操作性目的。其目标是选择最合适的行为来应对将要出现的问题,并尽力充分利用未来的情况。
预测和预言常常被作为同义词使用,但区分这两个术语的含义通常是个好主意。预测允许你将未来事件的发生概率与之关联,或者指定置信区间来估计将来可观察和可测量的大小。另一方面,预言涉及识别某一可测量量在未来将呈现的具体值。因此,使用推断统计的经典工具,可以轻松地将相应的预测与所制定的预言关联起来,从而得出相关的置信区间。
在下图中,我们可以看到两个例子,用来理解预测与预言之间的区别:
预测中遇到的困难源于未来的不确定性,而在预期的时刻,未来尚未确定。预测中不确定性元素的强大相关性意味着概率计算工具在制定良好预测时至关重要。
在制定预测时,需要观察一些方面,如下所示:
-
预测的性质可以是定性的或定量的,但通常在复杂现象中,两者的方面都会出现。
-
预测对象可以是以连续性表现出来的现象的未来值,或者是现象发生的时间,或者是未来将发生事件的方式和特征。
-
预测的时间范围通常被划分为短期、中期和长期。这个区分并不清晰和精确。通常,我们谈论短期预测时,结构条件保持不变。这是因为要预测的事件将在很大程度上由在预测时已经实施的行为和行动决定。而如果决定要预测事件的基本条件仍然基本不确定时,我们则称之为长期预测。
-
最后,关于维度,预测可以仅涉及一个现象(单变量预测),或者同时涉及更多相关现象(多变量预测)。在这种情况下,它可以基于因果关系,通过这些关系,现象的行为可能会在一定的时间滞后后,决定其他现象的趋势(因果预测)。
风险和不确定性在预测制定中至关重要。事实上,最好标明与预测相关的不确定性程度。无论如何,数据必须更新,以便预测尽可能准确。
与统计预测概念相关的三个术语是:对象、目的和方法。让我们尝试理解统计预测的含义。统计预测适用于概念上定义的现象,以便进行客观测量。因此,现象和测量方法必须明确和定义,并且在整个调查过程中保持不变。预测的目的是研究现象的未来表现,这些表现由过去所发生的结构性稳定性决定。最后,预测方法代表了使用与随机过程相关的概率计算发展的数学模型,一方面,另一方面是统计推理的范式和在不确定条件下的决策理论原则。现在,让我们通过分析实际例子来理解如何使用不同的方法。
预测方法
预测方法的差异主要基于所使用决策的特征和目标。时间范围的长度、广泛历史数据库的可用性与均质性,以及预测所涉及产品的特征(如生命周期阶段)是影响方法选择的一些因素。
从本质上讲,预测方法分为两大类——定性和定量。在下图中,我们可以看到这两类方法的示例:
在接下来的章节中,我们将讨论这两种方法。我们将分析具体案例,理解这种分类的依据。
定性方法
定性预测方法用于根据过去的数据预测未来的数据。当过去的数值数据可用,并且合理假设数据中的某些模式应当延续时,便采用这些方法。这些方法通常应用于短期或中期决策。因此,定性方法主要依赖于判断,因此依赖于消费者和专家的意见与判断。当定量信息有限或不存在时,但有足够的定性信息时,便采用这些方法。
以下项目列出了一些定性方法的应用示例:
-
销售部门评估:每位销售代表估计其所在区域下一个时期的未来需求。该方法的假设是,最接近客户的人比其他任何人更了解客户的未来需求。然后,这些信息会被汇总,得出每个地理区域或产品系列的全球预测。
-
市场调查:公司通常会寻求专门从事市场调查的公司来进行此类预测。信息通常直接来自客户,或者更常见的是来自他们的代表性样本。然而,这种调查主要用于寻找新想法、了解客户是否喜欢或不喜欢现有产品、查找某个产品的最受欢迎品牌等等。
定量方法
当定量信息充分可用时,我们谈论定量方法。它们也用于根据过去的数据预测未来的数据。这些方法通常应用于短期或中期决策。
定量预测方法的示例包括以下内容:
-
时间序列:待预测的现象被视为一个黑箱,因为它并不试图识别可能影响它的现象。该方法的目标是识别现象的过去演变,并通过外推过去的数据来做出预测。换句话说,待预测的现象是相对于时间进行建模的,而不是相对于某个解释性变量(考虑销售趋势、国内生产总值(GDP)趋势等)。
-
解释性方法:假设待预测的变量可以与一个或多个独立变量或解释性变量相关联。例如,一个家庭的消费品需求取决于其收入和商品的年龄。
这样的预测技术采用回归方法,因此,分析的主要阶段是指定并估计一个模型,该模型将待预测的变量(响应变量)与解释变量(例如,广告和/或价格促销对销售的影响)关联起来。
这些方法可以用于以下假设:
-
关于现象过去演变的足够信息是可用的。
-
这些信息是可以量化的。
-
可以假设过去演变的特征在未来仍然存在,以便做出预测。
最终,定量方法在有足够的定量信息可用时被使用。
在接下来的部分,我们将介绍蒙特卡罗方法,特别是我们将重点介绍这些方法在解决强化学习问题中的应用。我们还将探讨使用这些方法进行预测和控制意味着什么。
理解蒙特卡罗方法
蒙特卡罗方法用于估计价值函数并发现优秀策略,不需要环境模型的存在。这些方法可以仅通过代理的经验来进行学习,或通过从代理与环境交互中获得的状态序列、动作和奖励样本来进行学习。经验可以通过代理在学习过程中的获取,也可以通过先前填充的数据集来模拟。在线学习中获得经验的可能性非常有趣,因为它使得即使在没有事先了解环境动态的情况下,也能获得优秀的行为。即使是通过已经填充的经验数据集进行学习,也可以很有趣,因为如果与在线学习相结合,它使得由他人经验所引发的自动策略改进成为可能。
为了解决强化学习问题,蒙特卡洛方法通过基于过去回合中获得的奖励总和(平均值)来估计价值函数。这假设经验被分为回合,并且每个回合由有限数量的过渡组成。这是因为在蒙特卡洛方法中,策略更新和价值函数估计发生在回合完成之后。事实上,蒙特卡洛方法是通过迭代估计策略和价值函数的。然而,在这种情况下,每次迭代周期相当于完成一个回合。因此,策略更新和价值函数估计是按回合进行的,正如我们刚才所说的。
蒙特卡洛方法通过使用示例回报来获取最佳策略。因此,一个能够生成这些示例过渡的环境模型就足够了。与动态规划不同,蒙特卡洛方法不需要知道所有可能过渡的概率。在许多情况下,实际上很容易生成满足期望概率分布的样本,而显式表达所有概率分布是不可行的。这些算法模拟一个称为“回合”的示例序列,并基于观察值、更新值和策略估计进行运算。在经过足够多的回合迭代后,所得结果显示出令人满意的准确性。
与基于动态规划的算法相比,蒙特卡洛算法不需要完整的系统模型。然而,它们提供了在每次仿真结束时才更新价值和策略的可能性,而不像动态规划算法那样在每一步都更新估计。
现在是时候理解使用蒙特卡洛方法进行预测和控制的意义了。
蒙特卡洛预测方法
蒙特卡洛预测用于估计价值函数。在这种情况下,给定策略下,从任何给定状态开始的预期总奖励将被预测。该过程遵循以下流程:
-
给出策略
-
计算价值函数
你会回忆到,策略定义了代理在当前状态下的行为方式,从而以特定方式表示在特定状态下采取某一动作的概率。
预测任务要求提供策略,目的是衡量其表现,即预测在给定状态下由策略提供的总奖励,假设策略是预先设定的。
蒙特卡洛控制方法
蒙特卡洛控制用于优化价值函数,以使价值函数比估计更准确。在控制中,策略并非固定,目标是找到最优策略。在这种情况下,我们的目标是找到能够最大化每个给定状态下总奖励的策略。
控制算法也适用于预测,以不同的方式预测动作的值,并调整策略以在每个阶段选择最佳动作。因此,这些算法的输出提供了一个近似最优策略和遵循该策略的未来预期奖励。
在接下来的部分中,我们将了解如何区分模型无关和模型基础的算法。
接近模型无关算法
在上一节中,理解蒙特卡罗方法,我们说过蒙特卡罗方法不需要环境模型来估计值函数或发现优秀的策略。这意味着蒙特卡罗是模型无关的:不需要马尔可夫决策过程(MDP)转移或奖励的知识。因此,我们之前不需要对环境进行建模,但在与环境的交互中会收集必要的信息(在线学习)。蒙特卡罗方法直接从经验中学习,其中一段经验是一系列元组(状态、动作、奖励和下一个状态)。
在下面的截图中,我们可以看到模型基础和模型无关方法的比较:
模型无关方法可以应用于许多不需要任何环境模型的强化学习问题。许多模型无关方法尝试学习值函数并从中推断出最优策略,或者直接在策略参数空间中搜索最优策略。这些方法也可以分类为在策略方法或离策略方法。在策略方法使用当前策略生成动作,并更新策略本身,而离策略方法则使用不同的探索策略生成动作,并相对于更新的策略。两种方法——模型无关和模型基础的差异是什么?在接下来的部分中,我们将尝试突出这些差异,以便选择解决问题的正确方法。
模型无关与模型基础
在这一部分中,我们将尝试通过强化学习来澄清这两种方法的差异,以解决问题。在这些问题中,代理人不知道系统的所有元素,这使他无法计划解决方案。特别是,代理人不知道环境将如何响应他的行动而发生变化。这是因为转移函数T是未知的。此外,他甚至不知道他的行动将获得什么即时奖励。这是因为他还没有注意到奖励函数。代理人将不得不通过尝试行动、观察回答,并在某种程度上找到一个好的策略,以获得可能的最佳最终奖励。
接下来出现一个问题:如果代理既不知道转移函数,也不知道奖励函数,那么他如何推导出好的策略呢?为此,可以采取两种方法:基于模型的方法和无模型的方法。
在第一种方法(基于模型的方法)中,代理从其观察到的环境功能中学习一个模型,然后利用该模型推导出解决方案。例如,如果代理处于状态 s1,并执行一个动作 a1,他可以观察到环境转移将其带到状态 s2,从而获得奖励 r2。此信息可以用于更新转移矩阵 T(s2 | s1, a1)和 R(s1, a1)的评估。可以使用监督学习范式执行此更新。一旦代理充分建模了环境,他就可以使用该模型来找到一个策略。采用这种方法的算法被称为基于模型的方法。
第二种方法不涉及学习环境模型来找到一个好的策略。最经典的例子之一是 Q 学习,我们将在第七章中详细分析,时间差学习。该算法直接估计每个状态下每个动作的最优值,从中可以通过选择当前状态下值最高的动作来推导出策略。由于这些方法不学习环境模型,它们被称为无模型方法。
最终,如果在学习之后,代理能够在采取任何行动之前预测下一个状态和奖励,那么我们的算法就是基于模型的。否则,它就是一个无模型算法。
现在,让我们看看蒙特卡洛方法中的动作值是如何更新的。
动作值的估计
一般来说,蒙特卡洛方法依赖于重复的随机采样来获得数值结果。为此,它们使用随机性来解决确定性问题。在我们的例子中,我们将使用状态和动作-状态对的随机采样,查看奖励,然后以迭代的方式回顾策略。随着我们探索每个可能的动作-状态对,过程的迭代将收敛到最优策略。
例如,我们可以使用以下程序:
-
我们将正确的动作赋予奖励+1,错误的动作赋予奖励-1,平局赋予奖励 0。
-
我们建立一个表格,其中每个键对应于一个特定的状态-动作对,每个值是该对的值。这代表了在该状态下执行该动作所获得的平均奖励。
为了解决强化学习问题,蒙特卡罗方法通过估计基于过去回合中平均获得的总奖励的值函数。这个假设是经验被分为若干回合,并且每个回合包含有限数量的转换。因为在蒙特卡罗方法中,新的值估计和策略修改发生在每个回合结束后。蒙特卡罗方法通过迭代方式估计策略和值函数。然而,在这种情况下,每个迭代周期相当于完成一次回合——新的策略和值函数估计是在每个回合后逐步进行的,如下图所示:
工作流包括对经验回合的采样以及在每个回合结束时更新估计值。由于每个回合中有许多随机决策,这些方法的方差很高,尽管它们是无偏的。
你可能会回忆起两个过程,分别叫做策略评估和策略改进:
-
策略评估算法通过应用迭代方法解决贝尔曼方程。由于我们只能在 k → ∞时保证收敛性,因此我们必须通过设置停止条件来获得良好的近似值。
-
策略改进算法基于当前的值来改进策略。
正如我们所说,新的策略和值函数估计是在每个回合后逐步进行的;因此,策略仅在回合结束时更新。
以下代码块显示了蒙特卡罗策略评估的伪代码:
Initialize
arbitrary policy π
arbitrary state-value function
Repeat
generate episode using π
for each state s in episode
the received reward R is added to the set of
reinforcers obtained so far
estimate the value function on the basis on the average
of the total sum of rewards obtained
通常,蒙特卡罗一词用于描述涉及随机组件的估计方法。在这里,蒙特卡罗指的是基于总奖励平均值的强化学习方法。与动态规划方法通过计算每个状态的值不同,蒙特卡罗方法计算每个状态-动作对的值,因为在没有模型的情况下,只有状态值不足以决定在某个状态下执行哪个动作最优。
在详细分析了蒙特卡罗方法如何基于强化学习解决问题之后,接下来我们将查看一个实际案例。为此,我们将使用一个非常流行的游戏——二十一点。我们将看到如何使用蒙特卡罗方法预测最佳游戏策略。
使用蒙特卡罗方法进行二十一点策略预测
二十一点是一种在庄家和玩家之间进行的纸牌游戏。玩家如果得分超过庄家且不超过 21 点,则获胜;而得分超过 21 点的玩家爆牌并输掉游戏。二十一点通常使用由两副法式扑克牌组成的牌组(104 张牌)。在游戏中,A 牌可以算作 11 点或 1 点,图牌算作 10 点,其它牌按照面值计算。种子牌不具备影响或价值。点数的计算通过简单的算术运算来完成。
一旦玩家下注,庄家从左到右依次为每位玩家发放一张未盖面的牌,在每个位置上发一张,最后一张发给自己。然后庄家进行第二轮发牌,仍然不发给自己。一旦发牌结束,庄家按顺序读取每位玩家的得分,并邀请他们展示自己的牌:他们可以选择要牌(hit)或停牌(stick),完全由他们决定。如果某个玩家的点数超过 21 点,他就会输,庄家将赢得该玩家的赌注。一旦玩家们确定了自己的得分,庄家根据一个简单的规则进行游戏;也就是说,如果庄家的点数低于 17 点,他必须继续要牌,一旦得分达到或超过 17 点,他就必须停牌。如果庄家的点数超过 21 点,庄家爆牌,并且必须支付桌面上所有剩余的赌注。一旦所有得分都确定,庄家会将自己的得分与其他玩家进行比较,支付比自己高的组合,收取低于自己的赌注,并对平局的赌注不做处理。赢钱的赌注按面值支付。
二十一点游戏作为一个 MDP
二十一点可以视为一个 MDP(马尔可夫决策过程),因为玩家的状态可以通过他们手中的牌的点数来定义,而与玩家自己手中的牌无关。庄家的状态完全由他面前显示的单张牌的点数决定,因此整个游戏的最终状态由玩家和庄家的状态共同决定。最后,游戏的下一状态完全由当前状态和玩家的行动以随机方式定义。
我们回顾一下,问题可以被定义为 MDP,如果下一状态仅仅是当前状态和执行的动作的随机函数。此外,MDP 适用于那些决策空间有限且离散,结果不确定,且终止状态和相对奖赏明确的情况。MDP 的解决方案为我们提供了基于一个旨在最大化每个可能状态的奖励的过程,来执行的最优行动。
在接下来的章节中,我们将逐行解释代码:
- 我们将开始定义可用的操作:
HIT <- 1
STICK <- 2
让我们来看一下要牌和停牌的含义:
-
HIT:从庄家那里再拿一张牌。 -
STICK:不再拿牌。
- 现在,我们需要模拟从牌堆中发牌的过程,这个操作由庄家执行,庄家会给每个玩家发两张牌:
BJCard <- function()
return(sample(10,1))
sample()函数从传递的元素中提取指定大小的样本,可以选择有放回(1)或无放回(0)抽样。
- 现在,让我们开始随机生成一个初始状态:
StateInput <- function () {
return ( c(sample(10, 1), sample(10, 1), 0))
}
因此,初始状态以及一般的所有可能状态,由具有这里指定的三个元素的向量表示:
-
庄家牌(sample(10, 1)):一个介于 1 到 10 之间的值
-
玩家手牌值(sample(10, 1)):玩家卡牌值的总和
-
终止状态(0):一个二进制值(0-1),告诉我们手牌是否结束
状态和奖励更新
在接下来的代码块中,我们将分析并更新状态以及从环境中返回的奖励。
- 现在,我们将创建一个函数来执行过程的单步操作(
StepFunc),根据传递的状态(s)和动作(a),返回新的状态和奖励:
StepFunc <- function (s, a) {
if(s[3]==1)
return(list(s, 0))
NewState <- s
BJReward <- 0
第一个检查项是验证我们是否处于终止状态(手牌结束),如果是,则退出循环,返回当前状态和奖励为零。
- 让我们检查传递的动作:
if(a==1) {
NewState[2] <- s[2] + BJCard()
if (NewState[2]>21 || NewState[2]<1) {
NewState[3] <- 1
BJReward <- -1
}
}
如果要执行的动作是HIT,则会发现一张新卡,并更新状态和奖励。
- 让我们看看如果过去的动作是
STICK会发生什么:
else {
NewState[3] <- 1
DealerWork <- FALSE
DealerSum <- s[1]
while(!DealerWork) {
DealerSum <- DealerSum + BJCard()
if (DealerSum>21) {
DealerWork <- TRUE
BJReward <- 1
} else if (DealerSum >= 17) {
DealerWork <- TRUE
if(DealerSum==s[2])
BJReward <- 0
else
BJReward <- 2*as.integer(DealerSum<s[2])-1
}
}
}
return(list(NewState, BJReward))
}
然后,手牌传给庄家,庄家执行他的游戏。你开始通过将玩家的终止状态更新为 1 来开始。然后,庄家状态(DealerWork <- FALSE)及其当前分数被更新(DealerSum <- s [1])。从这一点开始,使用while循环运行庄家的游戏。首先,发一张新卡。这时,使用第一个IF值检查庄家是否爆掉(DealerSum > 21)。如果爆掉了,游戏以玩家胜利结束。庄家游戏的状态更新(DealerWork <- TRUE),并且总奖励BJReward <- 1。如果不是,若DealerSum >= 17,则庄家停止游戏并检查玩家状态。如果分数相等(DealerSum == s [2]),则游戏以平局结束(BJReward <- 0);否则,如果庄家的分数大于玩家的分数,玩家的BJReward = -1,玩家失败。如果庄家的分数小于玩家的分数,则玩家的BJReward = 1,玩家胜利。最后,如前所述,函数返回更新后的状态和步骤的最终奖励。
策略预测
现在是预测成功游戏最佳策略的时候了:
- 在定义了更新状态和奖励的函数后,现在是定义策略的时候了:
ActionsEpsValGreedy <- function(s, QFunc, EpsVal) {
if(runif(1)<EpsVal)
return(sample(1:2, 1))
else
return(which.max(QFunc[s[1],s[2],]))
}
为了定义策略,创建了一个ActionsEpsValGreedy()函数。该函数接受以下输入:
-
s: 状态 -
QFunc: 动作值函数 -
EpsVAl: epsilon 的数值
这返回要遵循的动作。
正如我们在第四章中所说的,多臂老虎机模型,在ε-贪婪方法中,我们假设以ε的概率选择不同的行动。这个行动是在 n 个可能的行动中均匀选择的。通过这种方式,我们引入了一种探索的元素,从而提高了性能。然而,如果两个行动之间的 Q 值差异非常小,那么该算法也会选择 Q 值更高的那个行动。
- 现在,让我们加载
foreach库:
library("foreach")
该包处理foreach循环结构。foreach命令允许你在不使用显式计数器的情况下遍历集合中的项。我们建议使用该包的返回值,而不是它的副作用。以这种方式使用时,它类似于标准的lapply函数,但不需要评估函数。因此,使用foreach可以方便地并行执行循环。
- 最后,我们可以定义
MontecarloFunc函数,它将指导我们解决问题。此函数接受以下变量作为输入:
NumEpisode:要播放的回合数
MontecarloFunc()函数返回以下值:
-
QFunc:更新的行动值函数 -
N:更新后的状态-行动访问次数
- 让我们详细分析一下:
MontecarloFunc <- function(NumEpisode){
QFunc <- array(0, dim=c(10, 21, 2))
N <- array(0, c(10,21,2))
N0=100
一旦传入输入,以下变量将被初始化:
-
QFunc:作为数组的行动值函数,包含以下变量:所有可能的卡牌值(dim=10)、所有可能的和值(dim=21)以及所有可能的行动(dim=2) -
N:状态-行动访问次数 -
N0:N 的偏移量
- 然后,我们将定义一个策略:
policy <- function(s) {
ActionsEpsValGreedy(s, QFunc, N0/(sum(N[s[1], s[2],])+N0))
}
- 我们现在将使用一个循环执行所有必要的回合,以计算最佳策略:
foreach(i=1:NumEpisode) %do% {
s <- StateInput()
SumReturns <- 0
N.episode <- array(0, c(10,21,2))
- 现在,让我们为每个回合玩一场游戏:
while(s[3]==0) {
a <- policy(s)
N.episode[s[1], s[2], a] <- N.episode[s[1], s[2], a] + 1
StateReward <- StepFunc(s, a)
s <- StateReward[[1]]
SumReturns <- SumReturns + StateReward[[2]]
}
在游戏的终止状态为 0(游戏进行中)之前,它执行以下操作:
-
根据定义的策略选择一个行动来执行
-
增加访问计数器
-
通过调用
StepFunc()函数执行一个步骤 -
更新你的奖励
- 做完这些后,我们继续更新 Q 和 N:
IndexValue <- which(N.episode!=0)
N[IndexValue] <- (N[IndexValue]+N.episode[IndexValue])
QFunc[IndexValue] <- QFunc[IndexValue] + (SumReturns-QFunc[IndexValue]) / N[IndexValue]
}
上述代码块包括了整个过程的关键元素——Q 函数的更新模式。在这种情况下,采用了增量方法。
- 事实上,函数q是使用以下函数更新的:
这里:
-
G:这是奖励的总和。
-
Q:这是行动值函数。
-
N:这是状态-行动访问次数。
- 最后,返回以下结果:
return(list(QFunc=QFunc, N=N))
}
- 定义了所有必要的函数后,到了运行模拟的时候:
MCModel <- MontecarloFunc(NumEpisode=100000)
我们只需传递获得良好预期的最佳策略所需的回合数。
- 此时,为了分析结果,我们可以绘制图形。然而,需要适当格式化行动值函数:
StateValueFunc <- apply(MCModel$QFunc, MARGIN=c(1,2), FUN=max)
为此,我们使用了apply()函数,该函数返回一个向量或数组,或者是通过将函数应用于数组或矩阵的边界所得到的值列表。
- 现在,我们可以绘制一张图表:
persp(StateValueFunc, x=1:10, y=1:21, theta=50, phi=35, d=1.9, expand=0.3, border=NULL, ticktype="detailed",
shade=0.6, xlab="Dealer exposed card", ylab="Player sum", zlab="Value", nticks=10)
使用了persp()函数。此函数绘制了一个在 x-y 平面上表面的透视图。
绘制了以下图表:
通过这种方式,我们对价值函数有了良好的估计。
总结
在本章中,我们探讨了蒙特卡罗方法的基本概念。蒙特卡罗方法通过将问题的解决方案表示为假设总体的一个参数,并通过随机数序列获取的总体样本来估算这个参数。之后,我们强调了这种技术为我们提供的不同方法之间的差异。蒙特卡罗预测用于估算价值函数,而蒙特卡罗控制用于优化价值函数,使价值函数比估算值更准确。
然后我们继续分析基于无模型方法的算法与基于有模型方法的算法之间的区别。此外,我们还逐步分析了进行蒙特卡罗策略评估的过程。最后,作为学习到的概念的实际案例,进行了涉及蒙特卡罗方法的二十一点策略预测。
在下一章中,我们将学习不同类型的时序差分(TD)学习算法。你将了解如何使用 TD 算法预测系统的未来行为,并学习 Q 学习算法的基本概念。你还将学习如何使用当前最佳策略估计通过 Q 学习算法生成系统行为。
第七章:时序差分学习
时序差分 (TD) 学习算法基于减少代理在不同时间做出的估计之间的差异。它是 蒙特卡洛 (MC) 方法和 动态规划 (DP) 思想的结合。该算法可以直接从原始数据中学习,而无需环境动态模型(就像 MC)。更新估计部分依赖于其他已学得的估计,而无需等待结果(自举,就像 DP)。在本章中,我们将学习如何使用 TD 学习算法来解决车辆路径规划问题。
本章将涵盖以下主题:
-
理解 TD 方法
-
介绍图论及其在 R 中的实现
-
将 TD 方法应用于车辆路径规划问题
本章结束时,你将学习到不同类型的 TD 学习算法,并了解如何使用它们来预测系统的未来行为。我们将学习 Q 学习算法的基本概念,并使用它们通过当前最优策略估计生成系统行为。最后,我们将区分 SARSA 和 Q 学习方法。
查看以下视频,看看代码的实际操作:
理解 TD 方法
TD 方法基于减少代理在不同时间做出的估计之间的差异。Q 学习是一个 TD 算法,我们将在接下来的部分学习,它基于相邻时刻状态之间的差异。TD 方法更加通用,可能会考虑更远的时刻和状态。
TD 方法结合了 MC 方法和动态规划(DP)的思想,正如你可能记得的那样,可以总结如下:
-
MC 方法使我们能够根据获得结果的平均值来解决强化学习问题。
-
DP 代表一组算法,这些算法可以在给定环境的完美模型(MDP)下,用于计算最优策略。
一方面,TD 方法继承了从与系统交互中积累的经验中直接学习的思想,这与蒙特卡洛(MC)方法类似,而不需要系统本身的动态信息。另一方面,它们继承了动态规划(DP)方法的思想,即基于其他状态的估计来更新某一状态下的函数估计(自举)。TD 方法适合在没有动态环境模型的情况下进行学习。如果时间步长足够小,或者随着时间的推移减少,你需要通过一个固定策略来收敛。
这些方法与其他技术的不同之处在于,它们试图最小化连续时间预测的误差。为了实现这一目标,这些方法将价值函数的更新重写为贝尔曼方程的形式,从而通过自举法提高预测精度。在这里,每次更新步骤都会减少预测的方差。为了实现更新的反向传播并节省内存,采用了资格向量。示例轨迹的使用效率更高,从而获得了良好的学习速率。
基于时间差异的方法使我们能够通过根据向下一个状态过渡的结果来更新价值函数,从而管理控制问题(即寻找最优策略)。在每一步中,函数Q(行动-价值函数)基于它为下一个状态-动作对所假定的值以及通过以下方程获得的奖励来更新:
通过采用一步前瞻,很明显,也可以使用两步公式,如下所示:
术语“前瞻”指的是一种试图预测在评估某个值时选择一个分支变量的效果的过程。该过程有以下目的:选择一个变量稍后进行评估,并评估分配给它的值的顺序。
更一般地说,通过n步前瞻,我们得到以下公式:
基于时间差异的不同类型算法的一个特征是选择行动的方法。有“在策略”方法,其中更新基于由所选策略确定的行动的结果;还有“离策略”方法,在这种方法中,可以通过假设的行动评估不同的策略,这些假设的行动实际上并未执行。与“在策略”方法不同,后者可以将探索问题与控制问题分离,且学习策略在学习阶段并不一定被应用。
在接下来的章节中,我们将通过两种方法学习如何实现时间差异方法:SARSA 和 Q 学习。
SARSA
正如我们在第一章《使用 R 进行强化学习概述》中所预期的,SARSA 算法实现了一种在策略的时间差异方法,其中,行动-价值函数(Q)的更新是基于从状态s = s (t)过渡到状态s' = s (t + 1)的结果,并且该过渡是基于所选策略π (s, a)采取的行动a (t)。
一些策略总是选择提供最大奖励的行动,而非确定性策略(如ε-贪心、ε-软策略或软最大策略)则确保在学习阶段有一定的探索成分。
在 SARSA 中,必须估算动作价值函数 𝑞 (𝑠, 𝑎),因为在没有环境模型的情况下,状态 𝑣 (𝑠)(价值函数)的总值不足以让策略根据给定的状态判断执行哪个动作是最好的。然而,在这种情况下,值是通过遵循贝尔曼方程,并考虑状态-动作对代替状态,逐步估算的。
由于其在策略中的特性,SARSA 根据π策略的行为估算动作价值函数,同时根据从动作价值函数中更新的估算值,修改策略的贪婪行为。SARSA 的收敛性,和所有 TD 方法一样,依赖于策略的性质。
以下代码块展示了 SARSA 算法的伪代码:
Initialize
arbitrary action-value function
Repeat (for each episode)
Initialize s
choose a from s using policy from action-value function
Repeat (for each step in episode)
take action a
observe r, s'
choose a' from s' using policy from action-value function
update action-value function
update s,a
动作价值函数的update规则使用所有五个元素(s[t],a[t],r[t + 1],s[t + 1],以及 a[t + 1]),因此被称为状态-动作-奖励-状态-动作 (SARSA)。
Q-learning
Q-learning 是最常用的强化学习算法之一。其原因在于它能够比较可用动作的期望效用,而不需要环境模型。得益于这项技术,可以在完成的 MDP 中为每个给定的状态找到最优动作。
强化学习问题的一个通用解决方案是在学习过程中估算评估函数。这个函数必须能够通过奖励的总和评估特定策略的便利性或其他方面。事实上,Q-learning 试图最大化 Q 函数(动作价值函数)的值,Q 函数表示我们在状态 s 下执行动作 a 时的最大折扣未来奖励。
Q-learning 和 SARSA 一样,逐步估算函数值 𝑞 (𝑠, 𝑎),在环境的每个步骤中更新状态-动作对的值,遵循更新 TD 方法估算值的通用公式逻辑。与 SARSA 不同,Q-learning 具有离策略特性。也就是说,虽然策略是根据 𝑞 (𝑠, 𝑎) 估算的值进行改进的,但价值函数更新估算值时遵循严格的贪婪次级策略:给定一个状态,选择的动作总是那个能够最大化值 max𝑞 (𝑠, 𝑎) 的动作。然而,π策略在估算值方面起着重要作用,因为要访问和更新的状态-动作对是通过它来决定的。
以下代码块展示了 Q-learning 算法的伪代码:
Initialize
arbitrary action-value function
Repeat (for each episode)
Initialize s
choose a from s using policy from action-value function
Repeat (for each step in episode)
take action a
observe r, s'
update action-value function
update s
Q-learning 使用一个表格来存储每个状态-动作对。在每个步骤中,智能体观察当前环境的状态,并使用π策略选择并执行动作。通过执行该动作,智能体获得奖励 𝑅[𝑡+1],以及新的状态 𝑆[𝑡+1]。此时,智能体可以计算 𝑄 (s[𝑡], a[𝑡]),并更新估算值。
在接下来的部分中,将给出图论的基础,并说明如何在 R 中处理这项技术。
引入图论并在 R 中实现
图是广泛应用于优化问题的数据结构。图由顶点和边的结构表示。顶点可以是从中出发的不同选择(即边)。通常,图用于清晰地表示网络:顶点代表独立的计算机、路口或公交车站,边则是电气连接或道路。边可以以任何可能的方式连接顶点。
图论是数学的一个分支,它允许你描述对象集合及其关系;由莱昂哈德·欧拉在 1700 年发明。
图通常用G = (V, E)的紧凑形式表示,其中V表示顶点集合,E表示构成图的边集合。顶点的数量是*|V|,边的数量是|E|*。图的顶点数,或其子部分的顶点数,显然是定义其维度的基本量;边的数量和分布描述了它们的连接性。
有不同类型的边:我们讨论的是无向边,其边没有方向,而与有向边相比。有向边称为弧,相关的图称为有向图。例如,无向边用于表示具有同步链路的数据传输计算机网络(如下图所示),而有向图则可以表示道路网络,允许表示双向和单向道路。
下图表示一个简单的图:
如果我们能够从任何给定的顶点到达图中的所有其他顶点,我们就说这个图是连通的。如果每条边都关联一个权重,并且通常由权重函数(w)定义,则图是加权图。权重可以视为两个节点之间的成本或距离。成本可能取决于流量通过边的规律。在这个意义上,权重函数w可以是线性的,也可以不是,并且取决于通过边的流量(非拥塞网络)或周围边的流量(拥塞网络)。
顶点的特征是其度数,度数等于以该顶点为终点的边的数量。根据度数,顶点如下所示:
-
度数为 0 的顶点称为孤立顶点。
-
度数为 1 的顶点称为叶子顶点。
下图展示了一个按度数标记的图:
在有向图中,我们可以区分出度(即出发边的数量)和入度(即进入边的数量)。基于这一假设,入度为零的顶点称为源顶点,出度为零的顶点称为汇顶点。
最后,简约顶点是其邻居形成团体的顶点:每两个邻居都是相邻的。通用顶点是与图中所有其他顶点相邻的顶点。
表示图的方法有多种,例如以下几种:
-
图形表示(如前图所示)
-
邻接矩阵
-
顶点 V 和弧 E 的列表
表示图的第一种方法通过实际示例进行了清晰的介绍(见前面的图示)。在图形表示中,圆圈表示顶点,线条表示两个顶点之间的连接,如果它们相连。若该连接具有方向性,则通过添加箭头来表示。在接下来的部分,我们将分析表示图的其他两种方法。
邻接矩阵
到目前为止,我们已经通过顶点和边来表示图。当顶点数量较少时,这种表示方法是最好的,因为它使我们能够直观地分析图的结构。当顶点数量变大时,图形表示变得混乱。在这种情况下,通过邻接矩阵表示图会更好。邻接矩阵或连接矩阵是图表示中常用的数据结构,广泛应用于图操作算法的设计以及图的计算机表示中。如果它是稀疏矩阵,使用邻接表优于使用矩阵。
给定任何图,其邻接矩阵由一个方形二进制矩阵组成,矩阵的行和列是图中顶点的名称。在矩阵的 (i, j) 位置上,如果图中存在一条从顶点 i 到顶点 j 的边,则该位置为 1;否则为 0。在无向图的表示中,矩阵相对于主对角线是对称的。例如,查看以下图示所表示的图:
前面的图可以通过以下邻接矩阵来表示:
如预期所示,矩阵相对于主对角线是对称的,表示图是无向的。如果矩阵中不是 1 而是其他数字,那么这些数字表示分配给每个连接(边)的权重。在这种情况下,矩阵被称为马尔可夫矩阵,因为它适用于马尔可夫过程。例如,如果图的顶点集表示地图上的一系列点,那么边的权重可以解释为它们连接的点之间的距离。
这个矩阵的一个基本特点是,它可以计算从节点i到节点j的路径数,这些路径必须经过n个顶点。为了得到这些信息,只需将矩阵的n次方计算出来,并查看在i, j位置上的数字即可。另一种表示图的方式是使用邻接列表。我们来看一下。
邻接列表
邻接列表是图在内存中的一种表示方式。这可能是最简单的实现方式,尽管通常来说,它在占用空间方面不是最有效的。
让我们分析一个简单的图;每个顶点旁边列出的是其相邻顶点的列表。表示方法的基本思想是,每个顶点Vi都与一个包含所有与其相连的顶点Vj的列表相关联,即存在一条从Vi到Vj的边。
假设你记住了所有类型为(Vi, L)的顶点对,其中L是顶点Vi的邻接列表,那么我们就能得到图的唯一描述。或者,如果你决定对邻接列表进行排序,那么就不需要显式地存储顶点了。
让我们举个例子——我们将使用上一节中采用的相同图形,图示如下:
基于前面提到的内容,我们将构建邻接列表。上图中的图可以表示如下:
| 1 | 相邻于 | 2,3 |
|---|---|---|
| 2 | 相邻于 | 1,3 |
| 3 | 相邻于 | 1,2,4 |
| 4 | 相邻于 | 3 |
邻接列表由对组成。每个图中的顶点都有一对。对的第一个元素是正在分析的顶点,第二个元素是由所有与它相邻的顶点组成的集合,这些顶点通过一条边与之相连。
假设我们有一个包含n个顶点和m条边(有向)的图,且假设邻接列表已按顺序记忆(为了避免显式记忆索引),那么每条边会出现在一个且仅一个邻接列表中,并且它会以指向的顶点编号的形式出现。因此,需要记住总共m个小于等于n的数字,总的成本为mlog2n。
对于无向图来说,没有明显的方法来优化这种表示法;每条弧必须在连接的两个顶点的邻接列表中都进行记忆,从而降低了效率。如果图是有向图,我们仍然需要一种有效的方法来知道指向某个顶点的弧。在这种情况下,将每个顶点关联两个列表是比较方便的:一个是进入弧的列表,另一个是出去弧的列表。
在时间效率方面,邻接列表的表示方式在访问和插入操作中表现得相当不错,主要操作在*O(n)*时间内完成。到目前为止,我们已经分析了图形表示的技术。接下来,让我们学习如何在 R 环境中使用这些技术。
在 R 中处理图形
在 R 中,节点集(V)和弧集(E)是不同类型的数据结构。对于V,一旦我们为每个节点分配唯一标识符,就可以无歧义地访问每个节点。因此,它就像是在说,托管节点属性的数据结构是一维的,因此是一个向量。
相反,弧集(节点之间的链接)E的数据结构不能是向量,它不表示单一对象的特征,而是表示对象对之间的关系(在这种情况下是节点对之间的关系)。因此,如果例如在V(节点集)中有 10 个节点,那么E的维度将是 10 × 10,即所有可能节点对之间的关系。最终,E有两个维度,因此它不是向量,而是矩阵。
在矩阵E中,我们有多行等于V中节点的数量,多列等于V中节点的数量。这表示在邻接矩阵部分中详细分析的邻接矩阵。
要在 R 中处理图形,我们可以使用igraph包——该包包含用于简单图形和网络分析的函数。它能够很好地处理大型图形,并提供生成随机图和规则图、图形可视化、中心性方法等功能。
以下表格提供了有关该包的一些信息:
| 包 | igraph |
|---|---|
| 日期 | 2019-22-04 |
| 版本 | 1.2.4.1 |
| 标题 | 网络分析与可视化 |
| 维护者 | Gábor Csárdi |
为了开始使用可用工具,我们将分析一个简单的示例。假设我们有一个由四个节点和四条边组成的图。首先要做的是定义这四个节点之间的链接;为此,我们将使用graph函数(记得在安装后加载igraph库):
library(igraph)
Graph1 <- graph(edges=c(1,2, 2,3, 3, 1, 3,4), n=4, directed=F)
graph函数是graph.constructors方法的一部分,提供了创建图形的各种方法:空图、有给定边的图、从邻接矩阵构建的图、星形图、格状图、环形图和树形图。我们使用的方法是通过使用数值向量来定义边来定义图形,向量中的第一个元素到第二个元素为第一条边,第三个元素到第四个元素为第二条边,以此类推。
实际上,我们可以看到传入了四对值:第一对定义了节点 1 和节点 2 之间的连接,第二对定义了节点 2 和节点 3 之间的连接,第三对定义了节点 3 和节点 1 之间的连接,最后,第四对定义了节点 4 和节点 2 之间的连接。为了更好地理解图中节点之间的连接,我们将绘制该图:
plot(Graph1)
绘制了以下图形:
我们创建的图是一个具有特征的对象,可以按如下方式进行分析:
Graph1
返回的结果如下:
IGRAPH 143ffd1 U--- 4 4 --
+ edges from 143ffd1:
[1] 1--2 2--3 1--3 3--4
节点之间的边已指示。然后,我们计算节点 1 和节点 4 之间的最短路径:
get.shortest.paths(Graph1, 1, 4)
get.shortest.paths() 计算从源顶点到目标顶点的单一最短路径。该函数对于无权图使用广度优先搜索,对于有权图使用 Dijkstra 算法。在我们的例子中,由于添加了权重属性,因此使用了 Dijkstra 算法。
返回以下结果:
$vpath
$vpath[[1]]
+ 3/4 vertices, from 1b5d9f3:
[1] 1 3 4
现在,计算此路径上两点之间的距离:
distances(Graph1, 1, 4)
返回以下结果:
[1,] 2
目前我们所表示的图在识别两地之间最短路径方面的用途有限,这正是我们的目标。为了计算最佳路径,需要引入边权重的概念。在我们的例子中,我们可以将此属性视为两个节点之间路径长度的度量;通过这种方式,我们可以通过路径来评估两个节点之间的距离。为此,我们将使用以下方式来定义属性权重:
WeightsGraph1<- c(1,1,4,1)
E(Graph1)$weight <- WeightsGraph1
首先,我们通过一个向量定义了权重,确认了在图创建时定义的边的顺序。然后,我们将权重属性添加到先前创建的图中。现在,每条边都有了自己的长度。如果没有定义权重会怎样呢?简单地说,它们都会被设为 1;在这种情况下,最短路径将是节点数最少的路径。
现在,让我们重新计算节点 1 和节点 4 之间的最短路径:
get.shortest.paths(Graph1, 1, 4)
返回以下结果:
$vpath
$vpath[[1]]
+ 4/4 vertices, from 1b5d9f3:
[1] 1 2 3 4
我们可以看到,现在的路径涉及多个节点。我们将验证这两个节点之间的距离:
distances(Graph1, 1, 4)
返回以下结果:
[1,] 3
通过这种方式,我们验证了所指示的路径是最短路径,因为最长的连接已被避免。在下一节中,我们将看到如何使用 Dijkstra 算法找到最佳路径。
Dijkstra 算法
Dijkstra 算法用于解决从源节点 s 到所有节点的最短路径问题。该算法为节点维护一个标签 d(i),表示节点 i 最短路径长度的上限。
在每一步中,算法将 V 中的节点分成两组:一组是永久标记的节点,另一组是仍然是临时标记的节点。永久标记节点的距离表示从源节点到这些节点的最短路径距离,而临时标记的节点则包含一个值,该值可以大于或等于最短路径长度。
该算法的基本思想是从源节点开始,尝试永久标记后继节点。开始时,算法将源节点的距离值设为零,并将其他节点的距离初始化为一个任意高的值(按照惯例,我们将距离的初始值设为 d[i] = + ∞, ∀i ∈ V)。在每次迭代中,节点标签 i 是从源节点出发的路径中最小距离的值,该路径除了 i 之外只有永久标记的节点。算法选择那些临时标记的节点中标签值最低的节点,将其永久标记,并更新所有与之相邻节点的标签。当所有节点都被永久标记时,算法终止。
通过执行该算法,针对每个目标节点 v(属于 V),我们可以获得一个最短路径 p(从 s 到 v),并计算以下内容:
-
d [v]:节点 v 到源节点 s 的距离 p
-
π [v]: 节点 v 的前驱节点为 p
对于每个节点 v(属于 V)的初始化,我们将使用以下过程:
-
d [v] = ∞ 如果 v ≠ s,否则 d [s] = 0
-
π [v] = Ø
在执行过程中,我们使用泛化边 (u, v)(属于 E)的松弛技术来改善 d 的估算值。
边 (u, v) 的松弛操作,旨在评估是否可以通过将 u 作为 v 的前驱节点来改善当前的距离值 d [v],如果可以改善,则更新 d [v] 和 π [v]。该过程如下:
-
如果 d[v]> d[u] + w (u, v) 则
-
d[v] = d[u] + w (u, v)
-
π [v] = u
该算法基本上执行两个操作:节点选择操作和更新距离的操作。第一个操作在每一步选择标签值最低的节点;另一个操作验证条件 d[v]> d[u] + w(u, v),如果满足条件,则更新标签值,令 d[v] = d[u] + w (u, v)。
在接下来的部分中,我们将实现一种 TD 方法来解决一个实际应用问题。
将 TD 方法应用于车辆路径问题
给定一个加权图和一个指定的顶点 V,通常需要找出从一个节点到图中每个其他顶点的路径。识别连接两个或多个节点的路径是许多离散优化问题的子问题,并且在现实世界中有广泛的应用。
例如,考虑一个问题:在一张道路地图上标识两地之间的路线,其中顶点表示地点,边表示连接它们的道路。在这种情况下,每个代价都与道路的公里长度或覆盖该段道路的平均时间相关。如果我们想要识别的是最小总代价的路径,而非任意路径,那么所得到的问题被称为图中的最短路径问题。换句话说,图中两个顶点之间的最短路径是连接这两个顶点并最小化穿越每条边的代价之和的路径。
所以,让我们通过一个实际的例子来考虑——假设一位游客开车从罗马前往威尼斯。假设他手中有一张意大利地图,上面标出了各个城市之间的直连路径及其长度,游客如何找到最短的路径?
该系统可以通过一个图来示意,其中每个城市对应一个顶点,道路对应顶点之间的连接弧。你需要确定图中源顶点和目标顶点之间的最短路径。
该问题的解决方案是为从罗马到威尼斯的所有可能路线编号。对于每条路线,计算总长度,然后选择最短的一条。这个解决方案不是最有效的,因为需要分析的路径有数百万条。
实际上,我们将意大利地图建模为一个加权有向图 G = (V, E),其中每个顶点表示一个城市,每条边 (u, v) 表示从 u 到 v 的直接路径,而每个权重 w(u, v) 对应于边 (u, v),表示 u 和 v 之间的距离。因此,要解决的问题是找到从表示罗马的顶点到表示威尼斯的顶点的最短路径。
给定一个加权有向图 G = (V, E),路径 p = (v0, v1, ..., vk) 的权重由其组成的边的权重之和给出,如下公式所示:
从节点 u 到节点 v 的最短路径是一个路径 p = (u, v1, v2, ..., v),使得 w(p) 最小,如下所示:
从 u 到 v 的最短路径的代价用 δ(u, v) 表示。如果从 u 到 v 没有路径,则 δ(u, v) = ∞。
给定一个连通的加权图 G = (V, E) 和一个源节点 s,有多种算法可以找到从 s 到 V 中其他节点的最短路径。在上一节中,我们分析了 Dijkstra 算法,现在是时候使用基于强化学习的算法来解决这个问题了。
正如本章开始时预期的那样,车辆路径问题(VRP)是一个典型的配送和运输问题,旨在优化使用一组有限容量的车辆来接送货物或人员,并将其运送到地理上分布的站点。以最佳方式管理这些操作可以显著降低成本。在用 Python 代码解决问题之前,让我们分析一下这一主题的基本特征,以便理解可能的解决方案。
基于迄今为止所述,很明显,这类问题可以被视为路径优化过程,可以通过图论有效地解决。
假设我们有以下图,其中边上的数字表示顶点之间的距离:
很容易看出,从 1 到 6 的最短路径是 1 – 2 – 5 – 4 - 6。
在理解 TD 方法部分中,我们已经看到,选择动作的方法根据 TD 的不同,算法的类型也会有所不同。在基于策略的方法(SARSA)中,更新是根据由选定策略决定的动作结果进行的,而在离策略方法(Q-learning)中,策略是通过假设的动作来评估的,而这些动作并未真正执行。我们将通过这两种方法来解决刚才提到的问题,突显解决方案的优点和缺点。那么,让我们看看如何使用 Q-learning 来处理车辆路径问题。
Q-learning 方法
正如我们在Q-learning部分所说,Q-learning 试图最大化 Q 函数(动作-价值函数)的值,该函数表示当我们在状态s中执行动作a时,能够获得的最大折扣未来奖励。
以下代码块是一个 R 代码的实现,通过 Q 学习技术让我们研究这一路径:
N <- 1000
gamma <- 0.9
alpha <- 1
FinalState <- 6
RMatrix <- matrix(c(-1,50,1,-1,-1,-1,
-1,-1,-1,1,50,-1,
-1,-1,-1,1,-1,-1,
-1,-1,-1,-1,-1,100,
-1,-1,-1,50,-1,-1,
-1,-1,-1,-1,-1,100),nrow=6,byrow = TRUE)
print(RMatrix)
QMatrix <- matrix(rep(0,length(RMatrix)), nrow=nrow(RMatrix))
for (i in 1:N) {
CurrentState <- sample(1:nrow(RMatrix), 1)
repeat {
AllNS <- which(RMatrix[CurrentState,] > -1)
if (length(AllNS)==1)
NextState <- AllNS
else
NextState <- sample(AllNS,1)
QMatrix[CurrentState,NextState] <- QMatrix[CurrentState,NextState] + alpha*(RMatrix[CurrentState,NextState] + gamma*max(QMatrix[NextState, which(RMatrix[NextState,] > -1)]) - QMatrix[CurrentState,NextState])
if (NextState == FinalState) break
CurrentState <- NextState
}
}
print(QMatrix)
我们将逐行分析代码,从以下参数的设置开始:
N <- 1000
gamma <- 0.9
alpha <- 1
FinalState <- 6
这里,我们有以下内容:
-
N: 迭代的回合数 -
gamma: 折扣因子 -
alpha: 学习率 -
FinalState: 目标节点
让我们继续设置奖励矩阵:
RMatrix <- matrix(c(-1,50,1,-1,-1,-1,
-1,-1,-1,1,50,-1,
-1,-1,-1,1,-1,-1,
-1,-1,-1,-1,-1,100,
-1,-1,-1,50,-1,-1,
-1,-1,-1,-1,-1,100),nrow=6,byrow = TRUE)
我们看到矩阵的呈现方式如下:
print(RMatrix)
以下矩阵被打印出来:
[,1] [,2] [,3] [,4] [,5] [,6]
[1,] -1 50 1 -1 -1 -1
[2,] -1 -1 -1 1 50 -1
[3,] -1 -1 -1 1 -1 -1
[4,] -1 -1 -1 -1 -1 100
[5,] -1 -1 -1 50 -1 -1
[6,] -1 -1 -1 -1 -1 100
让我们尝试理解如何设置这个矩阵。一切都非常简单:我们在最方便的边上(那些权重较低的边,即较短的路径)关联了高奖励。然后,我们将最高的奖励(100)赋给了通向目标的边。最后,我们将负奖励分配给不存在的连接。下图显示了我们如何设置奖励:
我们只是将边的权重替换成与长度值相对应的奖励。较长的边返回较低的奖励,而较短的边返回较高的奖励。最终,当目标到达时,将获得最大的奖励。
正如我们在Q-learning部分所说,我们的目标是估计一个评估函数,该函数根据奖励的总和来评估策略的便利性。Q-learning 算法试图最大化 Q 函数(行动值函数)的值,Q 函数表示我们在状态s中执行动作a时的最大折现未来奖励。
让我们再次分析我们需要使用 R 实现的程序:
Initialize
arbitrary action-value function
Repeat (for each episode)
Initialize s
choose a from s using policy from action-value function
Repeat (for each step in episode)
take action a
observe r, s'
update action-value function
update s
在每一步,代理观察环境的当前状态,并使用π策略选择并执行动作。通过执行该动作,代理获得奖励𝑅𝑡 + 1和新状态𝑆𝑡 + 1。此时,代理可以通过更新估计来计算𝑄 (s𝑡, a𝑡)。
因此,Q 函数代表了程序的核心元素;它是一个与奖励矩阵维度相同的矩阵。首先,我们将其初始化为全零矩阵:
QMatrix <- matrix(rep(0,length(RMatrix)), nrow=nrow(RMatrix))
此时,我们必须设置一个循环,对每个回合重复操作:
for (i in 1:N) {
循环的初始部分用于设置初始状态和初始策略;在我们的案例中,我们将随机选择一个初始状态:
CurrentState <- sample(1:nrow(RMatrix), 1)
设置初始状态后,我们必须插入一个循环,直到达到最终状态,即我们的目标:
repeat {
现在我们必须根据当前状态中可用的可能动作选择下一个状态。为了移动到下一个节点,我们可以采取哪些行动?如果只有一个可能的动作可用,我们将选择那个动作。否则,我们将随机选择一个动作,然后再分析其他动作:
AllNS <- which(RMatrix[CurrentState,] > -1)
if (length(AllNS)==1)
NextState <- AllNS
else
NextState <- sample(AllNS,1)
根据获得的结果,我们可以更新行动值函数(QMatrix):
QMatrix[CurrentState,NextState] <- QMatrix[CurrentState,NextState] + alpha*(RMatrix[CurrentState,NextState] + gamma*max(QMatrix[NextState, which(RMatrix[NextState,] > -1)]) - QMatrix[CurrentState,NextState])
用于更新 Q 函数的公式如下:
现在,我们将检查已达成的状态:如果我们已经达到了目标,则使用 break 命令退出循环;否则,我们将把下一个状态设为当前状态(NextState):
if (NextState == FinalState) break
CurrentState <- NextState
}
}
一旦程序完成,我们将打印 Q 矩阵:
print(QMatrix)
返回以下结果:
[,1] [,2] [,3] [,4] [,5] [,6]
[1,] 0 864.5 811.9 0 0 0
[2,] 0 0.0 0.0 901 905 0
[3,] 0 0.0 0.0 901 0 0
[4,] 0 0.0 0.0 0 0 1000
[5,] 0 0.0 0.0 950 0 0
[6,] 0 0.0 0.0 0 0 1000
让我们尝试理解这个矩阵告诉了我们什么。首先,我们可以说这个矩阵允许我们计算从任何状态开始的最短路径,因此,不一定是从节点 1 开始。在我们的案例中,我们将从节点 1 开始,以确认视觉上获得的结果。回想一下,矩阵的每一行代表一个状态,每一列中的值告诉我们转移到列索引标记的状态时的奖励。
在接下来的流程路径中,我们有如下内容:
-
从第一行开始,我们看到最大值位于第二列,因此,最佳路径将我们从状态 1 带到状态 2。
-
然后,我们进入由第二行标识的状态 2;在这里,我们看到最大奖励值位于第五列,因此,最佳路径将我们从状态 2 带到状态 5。
-
然后我们继续讨论由第五行标识的状态 5。在这里,我们看到奖励的最大值与第四列相对应,因此,最佳路径将我们从状态 5 带到状态 4。
-
最后,我们转到由第四行标识的状态 4。在这里,我们看到奖励的最大值与第六列相对应,因此,最佳路径将我们从状态 4 带到状态 6。
我们已经到达目标,并且通过这样做,我们从节点 1 到节点 6 绘制了更短的路径,路径如下:
1 – 2 - 5 – 4 – 6
这条路径与本节开始时视觉上得到的路径一致。提取QMatrix矩阵的最短路径的过程可以如下轻松地自动化:
RowMaxPos<-apply(QMatrix, 1, which.max)
ShPath <- list(1)
i=1
while (i!=6) {
IndRow<- RowMaxPos[i]
ShPath<-append(ShPath,IndRow)
i= RowMaxPos[i]
}
print(ShPath)
返回以下结果:
[[1]]
[1] 1
[[2]]
[1] 2
[[3]]
[1] 5
[[4]]
[1] 4
[[5]]
[1] 6
如我们所见,返回的结果是相同的。现在,让我们看看如果我们尝试用不同的方法解决同样的问题会发生什么。
SARSA 方法
正如我们在 SARSA 中所预见的,从当前状态St出发,采取一个动作At并且代理获得奖励 R。这样,代理就被转移到下一个状态St + 1,并在St + 1中采取动作At + 1。实际上,SARSA 是元组(S, A, R, St + 1, At + 1)的缩写。
以下是 SARSA 方法的完整代码:
N <- 1000
gamma <- 0.9
alpha <- 1
FinalState <- 6
RMatrix <- matrix(c(-1,50,1,-1,-1,-1,
-1,-1,-1,1,50,-1,
-1,-1,-1,1,-1,-1,
-1,-1,-1,-1,-1,100,
-1,-1,-1,50,-1,-1,
-1,-1,-1,-1,-1,100),nrow=6,byrow = TRUE)
print(RMatrix)
QMatrix <- matrix(rep(0,length(RMatrix)), nrow=nrow(RMatrix))
for (i in 1:N) {
CurrentState <- sample(1:nrow(RMatrix), 1)
repeat {
AllNS <- which(RMatrix[CurrentState,] > -1)
if (length(AllNS)==1)
NextState <- AllNS
else
NextState <- sample(AllNS,1)
AllNA <- which(RMatrix[NextState,] > -1)
if (length(AllNA)==1)
NextAction <- AllNA
else
NextAction <- sample(AllNA,1)
QMatrix[CurrentState,NextState] <- QMatrix[CurrentState,NextState] + alpha*(RMatrix[CurrentState,NextState] + gamma*QMatrix[NextState,NextAction] - QMatrix[CurrentState,NextState])
if (NextState == FinalState) break
CurrentState <- NextState
}
}
print(QMatrix)
RowMaxPos<-apply(QMatrix, 1, which.max)
ShPath <- list(1)
i=1
while (i!=6) {
IndRow<- RowMaxPos[i]
ShPath<-append(ShPath,IndRow)
i= RowMaxPos[i]
}
print(ShPath)
如你所见,大部分代码与前一个案例(Q-learning)相似,因为这两种方法之间有许多相似之处。我们只会分析两者之间的差异。在代码的第一部分,设置了初始参数并定义了奖励矩阵:
N <- 1000
gamma <- 0.9
alpha <- 1
FinalState <- 6
RMatrix <- matrix(c(-1,50,1,-1,-1,-1,
-1,-1,-1,1,50,-1,
-1,-1,-1,1,-1,-1,
-1,-1,-1,-1,-1,100,
-1,-1,-1,50,-1,-1,
-1,-1,-1,-1,-1,100),nrow=6,byrow = TRUE)
print(RMatrix)
现在,让我们继续初始化 Q 矩阵并设置将允许我们更新动作价值函数的循环:
QMatrix <- matrix(rep(0,length(RMatrix)), nrow=nrow(RMatrix))
for (i in 1:N) {
CurrentState <- sample(1:nrow(RMatrix), 1)
repeat {
AllNS <- which(RMatrix[CurrentState,] > -1)
if (length(AllNS)==1)
NextState <- AllNS
else
NextState <- sample(AllNS,1)
到目前为止,与前一个示例中分析的公式相比,没有任何变化。但现在有了一些重要的变化。在SARSA部分,我们看到了算法的伪代码;为了方便起见,我们在此重复一下:
Initialize
arbitrary action-value function
Repeat (for each episode)
Initialize s
choose a from s using policy from action-value function
Repeat (for each step in episode)
take action a
observe r, s'
choose a' from s' using policy from action-value function
update action-value function
update s,a
与前一部分(Q-learning 方法)中提出的方法相比,我们可以看到这两种方法的实质性区别在于更新动作价值函数所使用的公式以及计算下一状态要采取的动作。我们将在下一个状态中执行的动作计算公式如下:
AllNA <- which(RMatrix[NextState,] > -1)
if (length(AllNA)==1)
NextAction <- AllNA
else
NextAction <- sample(AllNA,1)
该方法用于评估下一个状态。我们将使用的公式如下:
这个公式在 R 代码中变为:
QMatrix[CurrentState,NextState] <- QMatrix[CurrentState,NextState] + alpha*(RMatrix[CurrentState,NextState] + gamma*QMatrix[NextState,NextAction] - QMatrix[CurrentState,NextState])
其余的代码与前一部分类似:
if (NextState == FinalState) break
CurrentState <- NextState
}
}
print(QMatrix)
RowMaxPos<-apply(QMatrix, 1, which.max)
ShPath <- list(1)
i=1
while (i!=6) {
IndRow<- RowMaxPos[i]
ShPath<-append(ShPath,IndRow)
i= RowMaxPos[i]
}
print(ShPath)
然后我们可以分析结果:
[,1] [,2] [,3] [,4] [,5] [,6]
[1,] 0 50 1 0 0 0.0000
[2,] 0 0 0 1 50 0.0000
[3,] 0 0 0 1 0 0.0000
[4,] 0 0 0 0 0 999.9999
[5,] 0 0 0 50 0 0.0000
[6,] 0 0 0 0 0 999.9999
最短路径如下:
[[1]]
[1] 1
[[2]]
[1] 2
[[3]]
[1] 5
[[4]]
[1] 4
[[5]]
[1] 6
结果与前一个示例中得到的结果相同。让我们来理解这两种方法有何不同。
区分 SARSA 和 Q-learning
从算法的角度来看,我们在前面章节分析的两种方法之间的实质性差异在于我们用来更新动作价值函数的两个方程。我们来对比一下它们,以便更好地理解:
Q 学习计算*Q (s, a)和动作的最大值之间的差异,而 SARSA 计算Q (s, a)*与下一步动作的值之间的差异。在这样做时,您可以突出以下几点:
-
SARSA 使用智能体在环境中生成经验时使用的策略(如 epsilon-贪心),以选择额外的动作A t + 1。然后,它使用*Q (S t + 1, A t + 1)*来折扣 gamma 因子,并将其作为预期的未来回报,计算更新目标。
-
Q 学习不使用此策略来选择额外的动作A t + 1。相反,它在更新规则中估计期望的未来回报,将其表示为max Q (S t + 1, A),并对所有动作进行计算。
这两种方法收敛到不同的解:
-
SARSA 通过遵循与生成经验时相同的策略收敛到最优解。这将包含一些随机性,以确保收敛。
-
Q 学习通过遵循贪心策略生成经验并进行训练,最终收敛到一个最优解。
当我们需要确保智能体在学习过程中的表现时,建议使用 SARSA。这是因为在学习过程中,我们必须确保错误的数量较少,而这些错误对于我们使用的设备来说是昂贵的。因此,我们关心其在学习过程中的表现。
像 Q 学习这样的算法在我们不关心学习过程中智能体的表现,仅仅希望智能体学会一个最优的贪心策略(我们将在过程结束时采用)时是值得推荐的。
总结
在这一章中,介绍了 TD 学习算法。这些算法基于减少智能体在不同时间做出的估计之间的差异。SARSA 算法实现了一个在策略的 TD 方法,而 Q 学习具有脱离策略的特点。
然后,介绍了图论的基础——包括邻接矩阵和邻接列表的内容。我们已经看到如何使用igraph包在 R 中表示图。通过这样做,我们解决了最短路径问题,并且分析了 R 中的 Dijkstra 算法。
最后,使用 Q 学习和 SARSA 算法解决了车辆路径规划问题。详细分析了这两种方法解决该问题的差异。
在下一章,我们将学习博弈论的基本概念。我们将学习如何安装和配置 OpenAI Gym 库,并理解它是如何工作的。我们将了解 Q-learning 和 SARSA 算法之间的区别,并理解如何进行学习阶段和测试阶段。最后,我们将学习如何使用 R 开发 OpenAI Gym 应用。
第三部分 - 现实世界的应用
在本节结束时,你将熟练掌握强化学习的现实世界应用。
本节包含以下章节:
-
第八章,游戏应用中的强化学习
-
第九章,金融工程中的 MAB
-
第十章,健康护理中的 TD 学习
第八章:博弈应用中的强化学习
游戏一直是人类文化中的一种现象,人们通过它展现智慧、互动和竞争。但游戏也是逻辑学、人工智能(AI)、计算机科学、语言学、生物学以及最近越来越多地出现在社会科学和心理学中的一个重要理论范式。游戏,尤其是战略游戏,为强化学习算法提供了理想的测试环境,因为它们可以作为实际问题的模型。
在本章中,我们将学习如何使用强化学习算法解决博弈论中的问题。到本章结束时,我们将掌握博弈论的基本概念。我们还将学习如何安装和配置 OpenAI Gym 库,了解 OpenAI Gym 库的工作原理,并学习如何使用 Q 学习解决游戏问题。除此之外,我们还将了解如何进行学习和测试阶段,并学习如何使用 R 开发 OpenAI Gym 应用。
在本章中,我们将涵盖以下主题:
-
理解博弈论的基础
-
探索博弈论的应用
-
玩井字棋游戏
-
介绍 OpenAI Gym 库
-
使用 FrozenLake 环境的机器人控制系统
技术要求
查看以下视频,了解代码在实际应用中的表现:
理解博弈论的基础
博弈论是一门数学科学,研究和分析主体在冲突或战略互动情况下的个体决策,这些决策涉及与其他竞争主体的互动,目的是最大化每个主体的利润。在这种情况下,一个主体的决策可能会影响另一个(或多个)主体的结果,反之亦然,依据反馈机制,通过模型寻求竞争性和/或合作性解决方案。
博弈论的理论起源可以追溯到 1654 年,那时布莱兹·帕斯卡(Blaise Pascal)与皮埃尔·德·费马(Pierre de Fermat)就赌博概率的计算进行书信往来。博弈论一词最早由埃米尔·博雷尔(Emil Borel)在 1920 年代使用。博雷尔发展了博弈理论,提出了一个包含两名玩家的零和博弈,并试图寻找一种解决方案,即冯·诺依曼的零和博弈解决概念。普遍认为,约翰·冯·诺依曼(John von Neumann)和奥斯卡·摩根斯坦(Oskar Morgenstern)于 1944 年发布的《博弈论与经济行为》一书标志着现代博弈论的诞生,尽管其他作者(如恩斯特·泽梅洛(Ernst Zermelo)和阿尔芒·博雷尔(Armand Borel))也曾讨论过博弈论。
这两位学者的思想可以非正式地描述为试图在涉及资源的赢得或分配的情境下,用数学描述人类行为。后来的著名学者,特别是在非合作博弈方面有深入研究的,是数学家约翰·福布斯·纳什(John Forbes Nash jr.),他的事迹被荣·霍华德的电影《美丽心灵》所呈现。
曾有八位诺贝尔经济学奖获得者处理过博弈论问题。约翰·梅纳德·史密斯(John Maynard Smith),一位长期卓越的生物学家、遗传学家以及萨塞克斯大学教授,也因其在这一领域的贡献获得了克拉福德奖。
在接下来的章节中,我们将介绍博弈论的基本概念,然后分析研究人员所面临的主要博弈类型。
博弈论的基本概念
博弈论的主要目标是胜利。每个人必须了解游戏规则并意识到每一步的后果。个人打算做出的所有行动构成了一种策略。根据所有玩家采取的策略,每个玩家根据适当的衡量单位获得报酬。报酬可以是正面、负面或无。若每个玩家的支付与其他玩家的损失相对应,则该游戏称为常数和游戏。两名玩家之间的零和游戏代表了一个奖励从一方支付给另一方的情况。若策略对所有玩家都是满意的,则称为均衡策略;否则,就需要计算并最大化玩家的数学期望或预期值,即可能奖励的加权平均值,每个奖励根据该事件的概率进行加权。
在一个游戏中,有一个或多个竞争者试图赢得比赛,也就是最大化他们的收益。收益由一个规则定义,量化地规定了竞争者根据他们的行为获得的奖励。这种函数叫做支付函数。每个玩家可以采取有限或无限的行动或决策,从而确定一种策略。每种策略都有一个结果,表现为采纳该策略的玩家所获得的后果,可能是奖励(正面/负面)。游戏的结果完全由他们策略的顺序以及其他玩家所采取的策略决定。
如何为每个玩家表征游戏的结果呢?如果你以奖励来衡量策略的后果,那么每个策略都可以匹配一个数值:负值表示支付给对手,如罚款;而正值则表示收益,即获得奖励。与玩家所采取的策略以及其他所有玩家在某一时刻所采取的策略相关的增益或损失,通常通过支付函数所指示的货币值来表示。
玩家所做的决策自然会相互碰撞,或者与其他玩家所做的决策相一致,从而衍生出各种类型的博弈。
一个有用的工具来表示两个玩家、两家公司或两个个人之间的互动是一个双重决策矩阵或表格。这个决策表显示了由两个玩家进行的博弈的策略和获胜情况。因此,决策矩阵是一个表示工具,通过它我们可以列出玩家之间互动的所有可能结果,并为每种情形分配奖励值,该奖励值在每种情况下竞争给予每个玩家。另一种表示形式涉及到每个决策的采取顺序,或者行动的执行方式。游戏的这一特征可以通过树形图来描述,树形图表示从初始状态到最终状态之间,竞争者的每一个可能组合,最终在这些状态中分配奖励。
描述一个战略情境需要四个基本要素:
-
玩家: 游戏中的决策者(谁参与?)
-
行动: 玩家可以选择的可能行动或动作(他们能做什么?)
-
策略: 玩家们的行动计划(他们打算做什么?)
-
收益: 玩家获得的可能收益(他们得到了什么?)
因此,策略是一个完整且有条件的计划,或决策决策,明确规定了玩家在任何可能需要做出决策的情况下必须采取的行动。作为一个完整的有条件计划,策略通常定义了玩家在游戏中可能无法实现的情境下应该选择哪种行动。在接下来的部分中,游戏将被分类,并且每个主题会有简短的描述。
游戏类型
游戏可以根据不同的范式进行分类,以下是其中几种:
-
合作
-
对称性
-
总和
-
排序
在接下来的部分中,我们将简要描述这些主题。
合作博弈
当玩家的利益不直接对立,而是具有共同利益时,便呈现出合作博弈。玩家们追求一个共同的目标,至少在游戏进行期间如此;其中一些玩家可能会倾向于联合起来以提高他们的回报。保证由绑定协议提供。那么,如何用数学来表示共同利益呢?个人利益在联盟或联合中的结合体的概念通过定义本质性博弈来表达;而一个普通联盟的价值则通过一个称为特征函数的函数来衡量。
相反,在非合作博弈中,也称为竞争性博弈,玩家不能达成具有约束力的协议(即使是通过规定)。约翰·纳什提出的纳什均衡适用于这一类博弈,它可能是整个理论中最著名的概念,因为它有广泛的应用领域。在非合作博弈中采用的理性行为标准是个体化的,称为最大策略。
对称博弈
在对称博弈中,采用某一策略所获得的利润仅取决于其他玩家所采用的策略,而与采用该策略的玩家无关。如果玩家的身份可以改变而不改变支付结果,则该博弈是对称的。
相比之下,在不对称博弈中,两名玩家没有相同的策略系列。然而,也有可能一个博弈对于两名玩家来说具有相同的策略,但它依然是不对称的。
零和博弈
零和博弈是常数和博弈的一个特例,其中常数为零。零和博弈建模了所有对立的情境,其中两名玩家的对抗是完全的:一名玩家的胜利恰好与另一名玩家的失败相对应。换句话说,两个竞争者根据所使用策略所获得的总利润始终为零。例如,在象棋中,这意味着唯一的三种可能结果是:胜利、失败和平局(奖励:+1,-1 和 0)。
顺序博弈
在顺序博弈中,后续的玩家保留了一些关于前一玩家行为的知识。这并不意味着他们知道前一玩家的所有行动。例如,一名玩家可能知道前一名玩家没有进行某个动作,但不知道第一名玩家做了哪些其他可选动作。
现在我们已经学会了根据一些范式来分类游戏。为什么分析博弈如此重要?这是因为许多现实生活中的问题可以通过博弈论得出的解决方案来应对。在下一节中,我们将看到一些例子。
探索博弈论的应用
博弈论一直吸引着许多研究者,因为它在实际领域和人类各个工作领域中的应用非常有价值,例如以下几个方面:
-
哲学:哲学一直在分析博弈论,因为它提供了一种方法来澄清一些哲学家的逻辑难题,例如康德、卢梭、霍布斯以及其他社会政治理论家的难题。
-
经济学:商业世界中的许多投机行为可以通过博弈论的方法来建模。一个著名的例子是寡头定价与囚徒困境之间的相似性。
-
生物学:尽管大自然常被认为是残酷的,但许多不同物种之间存在合作。这种共存的原因可以通过博弈论来建模。
-
AI:人类可以根据其所接收的环境刺激做出决策。而机器则只能在编程时根据多种条件的决策列表进行规划。这个限制可以通过人工智能来克服,它赋予机器从创造者那里获取新决策的能力,而不是仅依赖预先计划的规则。为此,程序必须根据观察到的刺激和经验生成新的回报矩阵。
在接下来的章节中,我们将分析一个广泛流行的游戏,并学习如何使用强化学习进行处理。
玩井字游戏
井字游戏是用强化学习解决的第一个游戏的完美例子:实际上,与其他策略游戏相比,它有一些简单的规则。此外,它相对容易编程,而且因为游戏最多只进行九轮,评估函数的训练非常快速。井字游戏是一个完美信息的双人游戏,每个玩家被分配一个符号来进行游戏。通常使用的符号是叉和圈。游戏由使用叉号的玩家开始。
游戏网格具有 3x3 的结构,初始时呈现九个空单元格,如下所示:
玩家轮流选择一个空格,并绘制自己的符号。成功将三个符号放置在水平、垂直或对角线上的玩家获胜。如果游戏网格填满且没有任何玩家成功完成三符号的直线,游戏以平局结束。因此,如果正确玩法,井字游戏最终会以平局告终,使得游戏毫无意义。
在接下来的章节中,我们将介绍使用 Q-learning 算法玩游戏的tictactoe包。
tictactoe 包
为了处理井字游戏,我们将使用 CRAN 网站上可用的tictactoe包。该包实现了一个控制台上的井字游戏,可以与人类或 AI 玩家对战。各种等级的 AI 玩家通过 Q-learning 算法进行训练。
以下表格提供了有关该包的一些信息:
| 包 | tictactoe |
|---|---|
| 日期 | 2017-05-26 |
| 版本 | 0.2.2 |
| 标题 | 井字游戏 |
| 作者 | 森本光太 |
在该包的帮助下,我们将与计算机进行第一次对局,以突出游戏的特性,然后我们将训练一个人工代理,按照最佳策略进行游戏,以获得最多的胜利。
玩井字游戏
首先,我们将看到如何设置游戏环境,并开始第一次游戏:
- 首先,我们需要导入库:
library(tictactoe)
- 然后,我们可以在 R 控制台上使用
ttt()函数启动井字游戏,如下所示:
ttt(ttt_human(name = "GIUSEPPE"), ttt_random())
请注意以下事项:
-
ttt_human()创建一个人类井字游戏玩家;如果我们愿意,还可以通过 name 属性设置名字(例如name = "GIUSEPPE")。 -
ttt_random()设置一个随机玩家,该玩家仅随机地将对方的符号(圆圈)放置在一个空位上。
控制台返回以下网格:
A B C
------
1| . . .
2| . . .
3| . . .
Player 1 (GIUSEPPE) to play
choose move (e.g. A1) >
正如预期的那样,游戏基于一个简单的 3x3 网格;为了方便识别单元格,列用字母 A、B、C 命名,而行用数字 1、2、3 表示。这意味着左上角的第一个单元格将被标识为 A1。控制台打印的最后一行邀请玩家 1(GIUSEPPE)进行下一步操作。
- 我们首先在单元格 A1 中放置 X,随后返回以下结果:
action = A1
A B C
------
1| X . .
2| . . .
3| . . .
Player 2 (random AI) to play
action = B1
A B C
------
1| X O .
2| . . .
3| . . .
Player 1 (GIUSEPPE) to play
choose move (e.g. A1) >
如我们在前面的代码块中看到的,X 已正确放置在左上角的单元格中,然后玩家 2(计算机)随机地将符号(O)放置在一个空位上。最后一行再次邀请玩家 1 进行下一步操作。我们可以这样进行,直到游戏结束。
有三种结果可供选择——0、1 和 2,分别表示平局、玩家 1 获胜和玩家 2 获胜。例如:
action = B2
game over
A B C
------
1| X . X
2| O X O
3| X . O
won by Player 1 (GIUSEPPE)!
在这种情况下,我赢了,但仅仅是因为计算机随机地放置了它的符号,并没有遵循任何策略。
使用 Q-learning 训练代理
我们可以训练代理遵循一种策略。我们来看看怎么做:
- 为了开始,我们可以模拟游戏,检查两个人工智能代理互相对弈时得到的结果:
player1<- ttt_ai()
player2<- ttt_ai()
SimulatedGame <- ttt_simulate(player1, player2, N = 100)
在上述代码中,使用了以下函数:
-
ttt_ai() -
ttt_simulate
ttt_ai()函数创建了一个人工智能的井字棋玩家。我们没有使用任何参数,实际上可以使用以下参数:
-
name:玩家名称。 -
level:AI 强度必须是从 0(最弱)到 5(最强)之间的整数。
level参数定义了我们创建的代理的有效性;从 0 到 5,代理的游戏管理技能逐渐提高,使得对手的游戏变得更加困难。在之前的指令中,我们已经使用ttt_random()函数创建了一个人工智能代理,它是ttt_ai()函数的别名,其中代理的级别默认设置为 0。
ttt_ai()函数返回一个对象,该对象与getmove()函数关联;此函数接受一个ttt_game类型的对象,并使用策略函数返回一个最优的动作。
一个ttt_ai对象包含值函数和策略函数。值函数将一个游戏状态与从第一个玩家视角的评估相关联。策略函数将一个游戏状态与通过评估值函数获得的一组最优动作相关联。这些函数通过基于 Q-learning 的算法进行训练,我们在第七章中详细讨论了时序差分学习。
使用的第二个函数是ttt_simulate(),它模拟了两个人工智能代理之间的井字棋游戏。传入的参数如下:
-
player1,player2:用于模拟的人工玩家。 -
N:模拟游戏的次数。
除这些外,还可以使用以下附加参数:
-
verbose:如果为真,则显示进度报告。 -
showboard:如果为真,则显示游戏过渡。 -
pauseif:当出现指定结果时暂停模拟。这对探索性目的很有用。
该函数返回一个整数向量,表示所执行模拟的结果。实际上,每次模拟都会返回一个值,介于 0、1 和 2 之间,分别代表平局、玩家 1 胜利或玩家 2 胜利,正如我们已经提到的那样。
- 我们可以使用
str()函数验证我们所说的内容,该函数返回一个 R 对象内部结构的紧凑视图:
str(SimulatedGame)
返回以下结果:
int [1:100] 1 2 2 0 1 1 2 1 2 2 ...
- 我们可以做更多的事;例如,我们可以使用
prop.table()函数验证整个模拟中的三种游戏结果的发生情况,方法如下:
prop.table(table(SimulatedGame))
- 此函数接受一个表格作为参数,并计算其包含的数据的比例。返回以下结果:
SimulatedGame
0 1 2
0.12 0.51 0.37
通过这种方式,我们可以看到,在进行的模拟中,玩家 1 获胜的次数更多(51%),而玩家 2 获胜的次数较少(37%),平局的次数明显较低(12%)。
- 现在,我们重复实验,但这次我们将通过基于 Q 学习算法的训练来提高两个玩家之一的表现。如同之前的做法,我们首先创建两个代理:
player3<- ttt_ai()
player4<- ttt_ai()
- 完成此操作后,我们将重点放在玩家 4 上,尝试通过训练阶段提高他的表现,在这个阶段他将学习遵循最佳策略:
TrainPlayer4 <- ttt_qlearn(player4, N = 500, verbose = FALSE)
ttt_qlearn() 函数通过 Q-learning 训练井字棋代理。以下参数可用:
-
player:待训练的人工玩家。 -
N:回合数。 -
epsilon:随机探索动作的比例。 -
alpha:学习率。 -
gamma:折扣因子。 -
simulate:如果为真,则在训练期间进行模拟。 -
sim_every:在进行此多次训练后进行模拟。 -
N_sim:模拟游戏的次数。 -
verbose:如果为真,则显示进度报告。
在基于 Q 学习的训练过程中,代理与自己对弈以更新值函数和策略。所使用的算法是带有 epsilon 贪心策略的 Q 学习。
对于每个状态 s,玩家根据以下公式更新值函数:
这发生在第一个玩家的回合。当第二个玩家的回合到来时,使用的公式将始终相同,只要你将最大值替换为最小值。以类似的方式,我们继续更新策略;也就是说,我们寻找一组行动,使我们能够到达下一个状态,从而最大化值函数。
控制过程的参数如下:
-
学习率决定了新信息被获取的频率,并将替代旧信息。折扣因子为 0 时,会阻止智能体学习;然而,折扣因子为 1 时,智能体只对最近的信息感兴趣。
-
折扣因子决定未来奖励的重要性。折扣因子为 0 时,智能体只会使用当前奖励,而折扣因子接近 1 时,智能体也会关注他在长期未来将获得的奖励。
算法中使用的策略使得玩家采用 e-greedy 方法选择下一个动作。这意味着智能体将在概率 1-e 下遵循其策略,而在概率 e 下选择随机动作。
游戏结束时,玩家将最终状态设置如下:
-
如果玩家 1 胜利,结果为 100
-
如果玩家 2 获胜,则结果为 -100
-
如果是平局,结果为 0
学习过程重复 N 次,N 是用户设定的值:
- 在训练好玩家 4 后,我们可以模拟游戏:
SimulatedGameQLearn <- ttt_simulate(player3, player4, N = 100)
- 我们再次验证结果的出现次数:
prop.table(table(SimulatedGameQLearn))
返回以下结果:
SimulatedGameQLearn
0 1 2
0.31 0.21 0.48
在这种情况下,训练过的智能体(玩家 4)赢得了最多的游戏(48%),其次是平局(31%),最后是玩家 3 赢得的游戏(21%)。因此,玩家的训练通过创建一个能够识别出让他获得更多胜利的策略的智能体,逆转了游戏的结果。
在接下来的部分中,将介绍 OpenAI Gym 库;这个库包含许多环境,可以让我们使用强化学习来训练智能体。
介绍 OpenAI Gym 库
OpenAI Gym 是一个帮助我们实现基于强化学习算法的库。它专注于强化学习中的阶段性设置。换句话说,智能体的经历被分为一系列的阶段。智能体的初始状态由一个分布随机采样,并且交互直到环境达到终止状态。这一过程对每个阶段重复,目的是最大化每个阶段的总奖励期望,并在尽可能少的阶段内实现高性能。
Gym 是一个用于开发和比较强化学习算法的工具包。它支持训练智能体完成从走路到玩 Pong 或弹球等游戏的各种任务。该库可以通过以下链接访问:gym.openai.com/。
OpenAI Gym 是一个更加宏大的项目的一部分,该项目被称为 OpenAI 项目。OpenAI 是一家由埃隆·马斯克和山姆·奥特曼创办的人工智能(AI)研究公司。它是一个非营利项目,旨在促进和发展友好的 AI,以便造福整个人类。该组织的目标是通过将专利和研究公开,来与其他机构和研究人员自由合作。创始人之所以启动这一项目,是因为他们对 AI 的不加限制使用可能带来的生存风险感到担忧。
OpenAI Gym 是一个程序库,允许你开发人工智能、衡量智能能力,并增强学习能力。简而言之,它是一个以算法形式呈现的健身房,用来训练当前的数字大脑,并将其投射到未来。除此之外,还有另一个目标。OpenAI 希望通过资助那些能够促进人类进步的项目来激励 AI 领域的研究,即便这些项目在经济上没有直接回报。而通过 Gym,它旨在标准化 AI 的衡量方法,以便研究人员能够在平等的条件下竞争,并了解他们的同事取得了哪些进展,最重要的是,它专注于那些对所有人都真正有益的成果。
可用的工具非常多。从玩像 Pong 这样的老式视频游戏,到在围棋中对战,再到控制机器人,我们只需将算法输入到这个数字化空间,便可看到它如何工作。第二步是将获得的基准数据与其他人进行比较,看看我们在同行中处于什么位置,也许我们还可以与他们合作,共享互利成果。
以下列表展示了库中可用的一些环境;这些环境按类别分组,便于搜索:
-
算法:执行计算任务,如加法多位数和反转序列。你可能会认为这些任务对计算机来说很简单,但挑战在于如何仅通过示例来学习这些算法。这些任务的一个优点是,可以通过变化序列长度轻松调节难度。
-
Atari:玩经典的 Atari 游戏。我们已将街机学习环境(对强化学习研究产生了巨大影响)集成到一个易于安装的形式中。
-
Box2D:在 Box2D 模拟器中进行连续控制任务。
-
经典控制:完成小规模任务,主要来自强化学习文献。它们的目的是帮助你入门。
-
MuJoCo:连续控制任务,在快速物理模拟器中运行。此任务使用 MuJoCo 物理引擎,该引擎专为快速且精确的机器人模拟设计。
-
机器人技术:为 Fetch 和 ShadowHand 机器人提供基于目标的模拟任务。
-
Toy text:简单的文本环境,帮助你入门。
特别是,经典控制类别提供了非常有用的环境,能够重现重要物理实验的场景。
OpenAI Gym 对我们代理的结构没有假设,并且与任何数值计算库兼容。Gym 库是一组测试问题——环境——我们可以用来处理强化学习算法。这些环境具有共享的接口,允许你编写通用的算法。首先,让我们看看如何安装这个库。
OpenAI Gym 安装
如前所述,OpenAI Gym 是一个在 Python 环境中编写的库。为了能够将其集成到 R 环境中,必须在计算机上安装 Python 版本。首先,我们需要安装 OpenAI 的gym-http-api API;这些是允许访问越来越多环境的 API。
API,即应用程序编程接口,是创建和集成应用软件的一组定义和协议。它们允许你的产品或服务与其他产品或服务进行通信,而无需知道它们是如何实现的,从而简化了应用开发,并节省了时间和成本。在创建新工具和产品或管理现有工具时,API 提供了灵活性,保证了创新机会,并简化了设计、管理和使用。
- 要下载并安装 OpenAI
gym-http-apiAPI,你可以运行以下 shell 命令。执行这些命令时,必须使用命令窗口:
git clone https://github.com/openai/gym-http-api
cd gym-http-api
pip install -r requirements.txt
上面代码块中的第一行代码使用 Git 软件从 github 仓库下载蜜蜂并进行安装。Git 软件允许你在管理项目的同时保持对源代码及其历史的控制,并且允许更多开发者在项目中协作;它本质上是一个版本控制系统。Git 是开源社区的事实标准,由 Linus Torvald 在 2005 年为 Linux 内核开发而创建,且在创建两个月后由 Google 开发者 Junio Hamano 维护;Git 不断发展,并且完全免费且开源。第二行代码将命令行移动到我们复制仓库的文件夹中。最后,第三行使用 pip 软件安装 API。
上述代码旨在由单个用户在本地运行。包括一个 Python 客户端,用于演示如何与服务器进行交互。
- 要从命令行启动服务器,进入我们安装 API 的文件夹,然后运行以下命令:
python gym_http_server.py
返回以下结果:
Server starting at: http://127.0.0.1:5000
- 现在服务器已准备好与我们的脚本交互。我们可以安装
gym包:
install.packages("gym")
以下表格提供了一些关于gym包的信息:
| 包 | gym |
|---|---|
| 日期 | 2016-10-25 |
| 版本 | 0.1.0 |
| 标题 | 提供对 OpenAI Gym API 的访问 |
| 作者 | Paul Hendricks |
为了验证包的功能,我们可以执行文档中提供的示例脚本。
- 让我们开始导入这个库:
library(gym)
- 首先,我们将与客户端建立连接,以便与服务器进行交互:
RemoteBase <- "http://127.0.0.1:5000"
Client <- create_GymClient(RemoteBase)
print(Client)
create_GymClient()函数实例化一个GymClient实例,以便与 OpenAI Gym 服务器集成。以下结果已打印:
<GymClient: http://127.0.0.1:5000>
- 连接已建立,现在我们可以创建环境:
EnvId <- "CartPole-v0"
InstanceId <- env_create(Client, EnvId)
print(InstanceId)
以下结果已打印:
[1] "376e0df2"
- 现在,我们可以列出当前工作会话中创建的所有环境,并列出可用的蜜蜂:
AllEnvsAvailable <- env_list_all(Client)
print(AllEnvsAvailable)
以下结果已打印:
$`376e0df2`
[1] "CartPole-v0"
- 通过这种方式,我们可以确认模拟环境已正确创建,现在我们可以请求与其交互所需的信息:
ActionSpaceInfo <- env_action_space_info(Client, InstanceId)
print(ActionSpaceInfo)
env_action_space_info()函数评估一个动作是否属于环境的动作空间。以下结果已打印:
$n
[1] 2
$name
[1] "Discrete"
- 在此环境中,只有两个动作可用。现在让我们创建代理:
agent <- random_discrete_agent(ActionSpaceInfo[["n"]])
random_discrete_agent()函数仅创建一个示例随机离散代理。
- 现在,我们将设置一个文件夹来保存结果,并打开一个窗口以监控环境变化:
OutDir <- "/TempFolder/results"
env_monitor_start(Client, InstanceId, OutDir, force = TRUE, resume = FALSE)
- 让我们初始化一些在模拟中需要的变量:
EpisodeCount <- 100
MaxSteps <- 200
Reward <- 0
done <- FALSE
- 现在,我们将实现两个循环来使用随机动作与环境进行交互:
for (i in 1: EpisodeCount) {
Object <- env_reset(Client, InstanceId)
for (i in 1: MaxSteps) {
action <- env_action_space_sample(Client, InstanceId)
results <- env_step(Client, InstanceId, action, render = TRUE)
if (results[["done"]]) break
}
}
返回以下截图:
在截图中,你可以看到小车-杠杆(cart-pole),它将根据算法提供的指示进行移动。
- 最后,我们将使用以下命令关闭窗口:
env_monitor_close(Client, InstanceId)
这个例子帮助我们开始与 OpenAI Gym 中可用的环境进行交互。如果你没有理解某些部分,不用担心,接下来的部分我们可以深入学习它们。
OpenAI Gym 方法
OpenAI Gym 提供了env类,该类封装了环境及其可能的内部动态。该类具有不同的方法和属性,用于实现创建新环境。最重要的方法分别是 reset、step 和 render,我们来简要了解一下它们:
-
reset 方法的任务是重置环境,将其初始化为初始状态。在 reset 方法中,必须包含构成环境的元素的定义,在本例中是机械臂、待抓取物体及其支撑物的定义。
-
step 方法的任务是将环境向前推进一步。它需要输入要执行的动作,并返回代理的新观察结果。在该方法中,必须定义运动动态的管理、状态和奖励的计算以及完成回合的控制。
-
第三种也是最后一种方法是我们必须定义要渲染到哪个内部,因为每个步骤的元素都必须被表示出来。该方法涉及不同类型的渲染,例如人类、
rgb_array或ansi。使用人类类型时,渲染在屏幕或命令行界面上进行,且该方法不返回任何内容;使用rgb_array类型时,调用该方法会返回一个表示屏幕 RGB 像素的 n 维数组;选择第三种类型时,返回方法会返回一个包含文本表示的字符串。为了渲染,OpenAI Gym 提供了一个 viewer 类,通过它可以将环境的元素绘制为一组多边形和圆形。
关于环境的属性,env类提供了动作空间、观察空间和奖励范围的定义。动作空间属性表示动作空间,即智能体在环境中可以执行的可能动作的集合。通过观察空间属性,定义组成状态的参数的数量,并且为每个参数定义可以假设的值范围。奖励范围属性包含在环境中可以获得的最小和最大奖励,默认设置为(-∞,+∞)。
使用框架所提出的env类作为新环境的基础,采用工具包提供的通用接口。通过这种方式,创建的环境可以被集成到工具包库中,并且它们的动态可以通过 OpenAI Gym 社区用户已实现的算法进行学习。
在接下来的部分中,我们将使用 OpenAI Gym 环境实现一个机器人控制系统。
使用 FrozenLake 环境的机器人控制系统
从技术角度讲,机器人可以被看作是一种特殊类型的自动控制,即一种物理上位于环境中的自动化装置,它能够通过传感器感知环境的某些特征,并且可以执行动作以改变环境。这些动作是通过所谓的执行器来完成的。
传感器所做的测量与执行器所给出的命令之间的所有内容可以被定义为控制程序或机器人的控制器。这是机器人智能编码的组件,在某种意义上,它构成了必须引导其行动以获得期望行为的大脑。控制器可以通过多种方式实现:通常它是运行在一个或多个微控制器上的软件,这些微控制器物理集成在系统中(车载),但它也可以通过电子电路(模拟或数字)直接连接到机器人的硬件中。让我们从观察环境开始。
FrozenLake 环境
FrozenLake 环境(gym.openai.com/envs/FrozenLake-v0/)是一个 4 x 4 的网格,包含四个可能的区域:安全(S)、冰面(F)、洞(H)和目标(G)。智能体在网格世界中控制一个角色的移动,并在网格中移动,直到到达目标或掉入洞中。网格中的一些瓷砖是可行走的,而其他瓷砖则会导致智能体掉进水里。如果掉进洞里,智能体必须从头开始,并且奖励为 0。智能体沿着一条不确定的路径移动,该路径仅部分依赖于所选择的方向。智能体在找到通向设定目标的可能路径时会获得奖励。智能体有四个可用的移动方向:上、下、左和右。该过程将持续,直到智能体从每次失败中学习并最终到达目标。
该表面使用如下网格进行描述:
-
SFFF (S: 起点,安全)
-
FHFH (F: 冰面,安全)
-
FFFH (H: 洞,掉进深渊)
-
HFFG (G: 目标,飞盘所在位置)
在下图中,我们可以看到 FrozenLake 网格(4 x 4):
当你到达目标或掉进洞里时,剧集结束。如果你到达目标,你将获得奖励 1,否则奖励为 0。
Q 学习解决方案
正如我们在第七章中所说的,时序差分学习,Q 学习是最常用的强化学习算法之一。这是因为它能够在不需要环境模型的情况下比较可用动作的期望效用。得益于这一技术,可以为每个给定状态找到一个最优动作,这适用于完备的 马尔可夫决策过程(MDP)。
在以下代码中,我们将使用 Q 学习方法,从起点单元格到目标单元格在 4 x 4 网格环境中找到正确的路径:
- 一如既往,我们将逐行分析代码。让我们从导入库开始:
library(gym)
- 首先,我们将建立与客户端的连接,以便与服务器进行交互:
remote_base <- "http://127.0.0.1:5000"
client <- create_GymClient(remote_base)
print(client)
create_GymClient() 函数实例化一个 GymClient 实例,以便与 OpenAI Gym 服务器进行集成。以下结果被打印出来:
<GymClient: http://127.0.0.1:5000>
- 连接已经建立;现在我们可以创建环境:
env_id <- "FrozenLake-v0"
instance_id <- env_create(client, env_id)
print(instance_id)
以下结果被打印出来:
[1] "af775b0a"
- 现在我们可以列出当前工作会话中通过蜜蜂创建的所有环境:
AllEnvsAvailable <- env_list_all(Client)
print(AllEnvsAvailable)
以下结果被打印出来:
$af775b0a
[1] "FrozenLake-v0"
通过这种方式,我们确认模拟环境已被正确创建。
- 现在,我们将从环境中获取一些信息:
ActionSpaceInfo <- env_action_space_info(client, instance_id)
print(ActionSpaceInfo)
env_action_space_info() 函数评估某个动作是否为环境动作空间的成员。以下结果被打印出来:
$n
[1] 4
$name
[1] "Discrete"
在这个环境中,有以下四个可用的动作:
-
0: 向左移动
-
1: 向下移动
-
2: 向右移动
-
3: 向上移动
- 现在,让我们来看一下一个在此环境中移动的智能体可以采取的状态:
ObservationSpaceInfo <- env_observation_space_info(client, instance_id)
print(ObservationSpaceInfo)
env_observation_space_info() 函数获取环境观察空间的信息(名称和维度/边界)。以下结果被打印出来:
$n
[1] 16
$name
[1] "Discrete"
从以下图示中,我们可以看到这个环境中有十六个状态(0 – 15),覆盖一个 4x4 的网格,从左到右、从上到下依次编号,如下所示:
- 然后我们将提取这些值以便重新使用:
ActionSize = action_space_info$n
StateSize = observation_space_info$n
- 现在,初始化参数,从
QTable开始:
Qtable <- matrix(data = 0, nrow = StateSize, ncol = ActionSize)
QTable 的行数等于观察空间的大小 (observation_space_info$n),而列数等于动作空间的大小 (action_space_info$n)。正如我们所说,FrozenLake 环境为 4x4 网格中的每个单元提供一个状态,并且提供四个动作,从而返回一个 16 x 4 的表格。
这个表格通过 matrix() 函数初始化为全零,初始化方式如下:
print(Qtable)
接下来,打印出以下表格:
[,1] [,2] [,3] [,4]
[1,] 0 0 0 0
[2,] 0 0 0 0
[3,] 0 0 0 0
[4,] 0 0 0 0
[5,] 0 0 0 0
[6,] 0 0 0 0
[7,] 0 0 0 0
[8,] 0 0 0 0
[9,] 0 0 0 0
[10,] 0 0 0 0
[11,] 0 0 0 0
[12,] 0 0 0 0
[13,] 0 0 0 0
[14,] 0 0 0 0
[15,] 0 0 0 0
[16,] 0 0 0 0
现在,我们定义一些参数:
alpha = 0.80
gamma = 0.95
基本上,alpha 是学习率,gamma 是折扣因子。学习率处理已获取信息的更新,它确定何时该用新的获取信息替代旧的。将学习率设置为 0 表示智能体不会学习任何东西,只会利用以前的知识。将学习率设置为 1 时,智能体只考虑最新的信息,并忽略以前的知识。这就是探索与利用的困境。理想情况下,智能体必须探索每个状态下的所有可能动作,找到那个实际上最能通过利用它来达成目标的动作。折扣因子决定了未来奖励的重要性。因子为 0 时,表示只考虑当前奖励,而接近 1 时,智能体将更加努力地追求长期的高奖励。
- 现在,我们设置 episode 的数量:
NumEpisodes = 200
NumEpisodes 参数的含义如下。智能体通过经验进行学习,没有导师来指导它;这种方式表示没有监督的学习。智能体将持续探索,直到达到目标,从一个状态移动到下一个状态。每次探索称为一次episode。每个 episode 包括智能体从初始状态到目标状态的移动。每当智能体到达目标状态时,我们就开始下一个 episode。
- 现在,我们将创建一个列表来保存总奖励:
RewardsList = list()
然后初始化另外两个参数;我们将需要它们来恢复结果:
IndxList = 1
NumGoal=0
- 在这一点上,设置好参数后,就可以开始 Q-learning 循环了:
for (i in 1:NumEpisodes) {
cat("######Episode ", i, " ######", "\n")
- 所以,我们通过
env_reset()方法初始化系统:
state = env_reset(client, instance_id)
然后,初始化一个奖励计数器和周期计数器:
SumReward = 0
j = 0
从这一点出发,Q-learning 表格算法被实现:
while (j < 99){
- 每当进入新的一步时,我们就增加周期计数器:
j=j+1
- 现在,我们需要选择一个动作:
action = which.max(Qtable[state+1,] + runif(4,0, 1)*(1./(i+1)))
action = action - 1
通过贪心方法从Qtable中选择动作。由于环境是未知的,因此必须以某种方式探索,代理将利用随机性的力量来进行探索。使用了两个函数,which.max()和runif()。which.max()函数返回沿某一轴的最大值的索引。runif()函数返回一个标准正态分布的样本(或多个样本)。在刚分析的代码块的第二行中,我们将动作索引减少了一单位。这是必要的,因为如前所述,环境中的可用动作范围是 0 到 3,而在 r 中表格的索引从 1 开始(与 Python 不同,后者从 0 开始)。因此,为了使结果与 r 环境兼容,必须进行这一修正。
- 现在,我们将使用
env_step()方法来返回根据我们传给它的动作作出的新状态。显然,我们传给方法的动作是我们刚刚决定的:
Results<- env_step(client, instance_id, action, render = TRUE)
env_step()方法返回一个包含以下内容的列表:
-
action: 在环境中采取的动作 -
observation: 代理对当前环境的观察 -
reward: 上一个动作后返回的奖励数量 -
done: 回合是否已结束 -
info: 包含辅助诊断信息的列表
- 目前,我们只对以下代码感兴趣:
NewState<-Results$observation
ActualReward<-Results$reward
- 然后,我们可以用新知识更新
Qtable字段:
Qtable[state+1, action+1] = (1 - alpha) * Qtable[state+1, action+1] + alpha * (ActualReward + gamma * max(Qtable[NewState+1,]))
用于 Q 函数更新的公式如下:
同时,在这种情况下,我们必须为状态和动作引入修正(状态 + 1,动作 + 1),因为 FrozenLake 环境期望状态值为 0-15,动作值为 0-3,但我们知道在 r 中行和列索引是从 1 开始的。
- 现在,我们将用刚刚获得的奖励和环境状态更新奖励总和:
SumReward = SumReward + ActualReward
state = NewState
- 最后,我们插入一个检查,查看是否已经到达回合结束的条件。关于这一点,我们记得当到达目标或掉入陷阱时,回合被视为结束:
if (Results[["done"]]) {
print("#####STEP######")
print(j)
cat("State achieved", NewState+1, "\n")
cat("Reward", ActualReward, "\n")
if (state==15) {
NumGoal=NumGoal+1
}
break
}
}
用于回合结束检查的if循环包含一系列指令,帮助我们找到解决方案。实际上,我插入了一个控制,用来区分是否到达目标或掉入陷阱,然后是一系列打印输出,让我们能够验证算法的成功。
- 在最终离开贯穿各个回合的循环之前,我们将尝试更新奖励总和和奖励列表的索引:
RewardsList[IndxList]<-SumReward
IndxList = IndxList +1
}
- 最后,我们打印结果,首先是分数:
cat("Numbers of goal achieved ", NumGoal, "\n")
print ("Score: ")
print(do.call(sum,RewardsList)/NumEpisodes)
然后,我们打印Qtable的值:
print ("Final Q-Table Values")
print (Qtable)
返回以下表格:
[,1] [,2] [,3] [,4]
[1,] 8.269208e-02 2.915901e-03 0.0026752394 2.927068e-03
[2,] 3.346197e-05 4.689209e-05 0.0001546271 1.524209e-01
[3,] 6.866991e-04 9.183121e-04 0.0032547141 2.434100e-01
[4,] 1.191637e-03 5.130816e-04 0.0005775460 7.667945e-02
[5,] 8.717573e-02 2.206559e-04 0.0002355794 3.511920e-04
[6,] 0.000000e+00 0.000000e+00 0.0000000000 0.000000e+00
[7,] 6.317772e-05 4.168548e-05 0.1003525858 6.572051e-05
[8,] 0.000000e+00 0.000000e+00 0.0000000000 0.000000e+00
[9,] 2.501462e-03 9.431424e-04 0.0022643051 7.393004e-02
[10,] 4.658178e-05 5.656864e-01 0.0000000000 1.469386e-04
[11,] 1.261091e-02 4.726838e-04 0.0005489759 2.321079e-03
[12,] 0.000000e+00 0.000000e+00 0.0000000000 0.000000e+00
[13,] 0.000000e+00 0.000000e+00 0.0000000000 0.000000e+00
[14,] 8.009153e-04 0.000000e+00 0.8782760471 2.432799e-04
[15,] 0.000000e+00 9.929596e-01 0.0000000000 0.000000e+00
[16,] 0.000000e+00 0.000000e+00 0.0000000000 0.000000e+00
包含价值函数的表格已经准备好,我们可以使用它来提取从起始单元到目标单元的路径,而不掉入陷阱。
- 首先,我们将搜索限制在五条可能的路径上:
for (episode in 1:5) {
print("****************EPISODE**********************")
print(episode)
- 为了做到这一点,我们使用了一个循环,并包括了每个回合的打印输出以进行检查。现在,正如我们在训练阶段所做的那样,开始吧:使用
env_reset()函数重置环境:
state = env_reset(client, instance_id)
在这方面,我们回顾一下,env_reset()函数将环境恢复到初始状态,即状态 0,对应于 Qtable 的第一行。正如我们之前所强调的,为了使 OpenAI Gym 环境与该环境兼容,需要通过添加一个单位进行修正。这样,状态 0 就会对应于 Qtable 的第一行。
- 接下来的
for循环将允许我们在网格中移动,以按照Qtable提供的方向到达目标:
for (step in 1:100) {
- 现在,让我们初始化一个奖励计数器:
TotRew = 0
- 现在,我们将提取在给定实际状态下具有最大预期未来回报的动作,即与当前步骤相关的动作。Qtable 返回此值作为当前状态对应行中最大值的索引:
action = which.max(Qtable[state+1,])
action = action - 1
我们再次将动作索引减去一个单位。
- 现在,我们将使用
env_step()方法返回响应提取的动作后的新状态:
Results<- env_step(client, instance_id, action, render = TRUE)
对于env_step()函数返回的值,我们目前只关心状态和奖励:
Newstate<-Results$observation
reward<-Results$reward
- 接下来,我们将更新奖励计数器:
TotRew <- TotRew + reward
- 现在,我们将检查是否已到达回合的结束:
if (Results[["done"]]) {
cat("Number of steps", step, "\n")
print(TotRew)
cat("STATO", Newstate, "\n")
break
}
state = Newstate
}
}
通过这种方式,我们得到了五条最佳路线,可以在不掉入陷阱的情况下到达目标。
总结
在这一章节中,我们学习了博弈论的基本概念。我们了解了游戏的基本特征,以及所采用的解决方案如何帮助我们解决现实问题。我们分析了一系列实际应用,在这些应用中使用了这些理论来获得解决方案。接着,我们详细分析了 OpenAI Gym 库,以及如何与可用的环境进行交互以模拟现实问题。
接着,我们使用强化学习解决了井字游戏问题。我们使用tictactoe包来设置环境,并通过 Q 学习训练代理玩游戏。最后,我们查看了 FrozenLake 环境。这个环境是一个 4 × 4 的网格,包含四个可能的区域:安全区(S)、冰面(F)、陷阱(H)和目标(G)。代理控制一个角色在网格世界中的移动,直到它到达目标或掉进陷阱。这个环境特别适合模拟与机器人在充满障碍物的环境中的移动问题。定义环境之后,我们创建了一个能够在环境中移动并使用基于 Q 学习的算法找到目标的代理。
在接下来的章节中,我们将学习金融问题的基本概念;如何预测股市价格,以及如何优化股票投资组合。然后,我们将了解如何实现欺诈检测技术。