概述
实际上将之前 policy iteration 当中基于模型的部分只要替换掉不需要模型的部分,就得到了本节课的蒙特卡洛的算法;另外在本门课当中将 value iteration 和 policy iteration 统称为 model-based reinforcement learning,更准确的说应该称为动态规划的方法。
这种方法研究的是比如用数据估计出来一个模型,然后再基于这个模型来进行强化学习。
而这节课讲的将是 model-free 的方法。
课程大纲:
会介绍基于 蒙特卡洛 思想的三个算法,三者环环相扣,另外第一个是最简单的,以至于简单到没办法用,但其是后面两个算法的基础。
Motivating example
从 model-based 的 RL 到 model-free 的 RL ,最让人难以理解的应该是怎么样在没有模型的情况下去估计一些量,而这里有一种重要的方法或者思想就是 蒙塔卡洛估计:
对于上面的抛硬币例子,使用 model-based 方法非常简单:
但有一个问题是,在实际问题当中很有可能我们是没办法去知道这么详细准确的概率分布的,那么 model-free 的方式怎么做:
如上图所示,其基本的思想就是投掷硬币很多次,然后这 n 次实验的结果分别是 x1 、x2 一直到 xn,然后把这些结果先相加再除以这个 n,就得到了平均值,用 xbar 来表示,这个 expectation 就用这个 xbar 来做一个近似,那么就认为 xbar 就是 E[x] 。
这个就是 蒙特卡洛估计 的一个基本思想。
一个问题,使用这种方式得到的期望值是否精确:
这种直观的解释事实上是有很好的这个数学来做支撑的,也就是大数定理:
可以总结出如下几点:
蒙特卡洛不仅仅可以用到这种投掷硬币这样简单的任务当中,凡是需要做大量的采样然后实验然后最后用实验的结果来进行近似的这样的一种方法,都可以称为 蒙特卡洛 的估计的方法。
The simplest MC-based RL algorithm
Algorithm:MC Basic
重点:
如何使得 policy iteration 这个算法变成 model-free 的:
怎么样求解这个优化问题得到新的策略 Π 呢?之前说过,直接选择最大的 qΠk,所以这里非常核心的一个点就是这个 qΠk 。
想要计算 qΠk 的话,有下面两种方法:
如上图所示,第一种方法是依赖于模型的,也就是 value iteration 这个算法所使用的,第一步得到 vΠk,第二步通过已知的模型就可以计算出 qΠk 的量。
而第二种方法不依赖于模型,它依赖于 qΠk 也就是 action value 最最原始的定义,就是从 s 出发,take 一个 action 然后得到一个 return G(t),然后这个 return 是一个 random variable ,我对其求平均 也就是 expectation 即可。
所以 model-free 的强化学习算法和基于蒙特卡洛的方法的核心的思想是什么呢?就是用 action value 的这个本质定义。
如何求解?
首先从任意的一个 s 和 a 的一个组合出发,然后根据当前的策略我们可以完成一个 episode。我们计算出来这个 episode 的所对应的 discounted return 用小写的 g( s, a ) 来表示,这个 g( s, a ) 就是 action value 定义式中的 G(t) 随机变量集合中的一个采样,如果我们有很多这样的采样也就是一个采样集合,我们就可以使用这些采样求一个平均值来估计这个 G(t) 的这个平均值,也就是这个 G(t) 的期望:
总的来说就一句话:当我们没有模型的时候,我们就得有数据,没有数据的时候就得有模型。
这里的数据在统计或者概率里面称为 sample,而在强化学习当中被称为 experience 。
现在给出详细定义:
实际上不难看出,该算法和 policy iteration 实际上是差不多的,第二步是一模一样的,唯一的区别在第一步:policy iteration 第一步求解 state value 然后再得到 action value,而 MC Basic 算法则是直接通过数据得到这个 state value 。
算法的伪代码描述:
这个算法实际上在其他地方是看不到的,这是赵世钰老师自己为了方便教学给单独命名的一个算法,因为老师认为在学习的时候应该把最最核心的想法和其他让它看起来更加复杂的这样一些东西给剥离开来。而对于蒙特卡洛算法来说,其最核心的想法就是把 policy iteration 当中的基于 model 的部分替换掉。但是怎么样去更高效的利用这些数据则是后面两个算法需要做到并且提高的事情。
关于这一小节需要注意的事情:
应用例子:
然后是第二个例子,重点是分析一些有趣的性质,如 episode 的长度。
什么意思呢?我们在用 MC Basic 的时候我们需要数据,这个数据基本上就是从任何一个状态和action出发然后我有很多的 episode,我们要计算每一个 episode 的 return 。
这个 episode 的长度理论上说是越长越好,最好是无穷长,这样的话其所计算出来的 return 是最精确的。
但是在现实当中,这个 episode 显然不可能无穷长,特别是在这个网格世界的例子当中,我们是没有终止条件的,如果不人为设置一个 episode 的长度的话它就会一直这样持续下去。
所以 episode 的长度的设置是一个让人关心的话题:
总结一下 episode 相关的性质:
首先就是当 episode length 比较短的时候,只有离目标比较近的那些状态它们才能在这么短的步骤内找到目标,因此它们能找到正确的策略。
随着 episode length 的逐渐变长,那么离目标越来越远的那些状态也能慢慢的到达目标,从而找到最优的策略。
总而言之,这个 episode length 必须要足够长,让它所有的状态都能够有机会到达目标,同时它也不需要无限长,只要是足够充分的长就可以了。
MC Basic Implementation
import numpy as np
import time
import os
def get_reward(location, action, graph):
# r, c 表示地图的行数和列数
r, c = len(graph), len(graph[0])
reward = -1 # 默认奖励为 -1,因为要求走最短路径
# row, col 表示当前所在行列位置
row, col = location
# 采取行动为0,表示往上,那么当前位置的行数+1
if action == 0:
row = row - 1
# 采取行动为1,表示往下,那么当前位置的行数-1
elif action == 1:
row = row + 1
# 采取行动为2,表示往左,那么当前位置的列数-1
elif action == 2:
col = col - 1
# 采取行动为3,表示往右,那么当前位置的列数+1
elif action == 3:
col = col + 1
# 如果采取了action后的所在行列位置越界了,reward-1
if row < 0 or row > r - 1 or col < 0 or col > c - 1:
reward = -1
# 如果采取了action后的所在行列位置在forbidden area,reward-100
# 这表示我们并不想让 agent 走进 forbidden area
elif graph[row][col] == '×':
reward = -100
# 如果采取了action后的所在行列位置在目标位置了,reward+20
elif graph[row][col] == '●':
reward = 20
# 控制边界约束, 防止越界异常
row = max(0, row)
row = min(r - 1, row)
col = max(0, col)
col = min(c - 1, col)
# 返回下一个状态以及奖励
return row, col, reward
# 在Python 3中,几乎所有的类都默认继承自object类,即使你不显式地写出来
class Solver(object):
def __init__(self, r: int, c: int):
"""
:param r: 代表当前地图行数
:param c: 代表当前地图列数
"""
# 初始化动作空间
# 在Python中,大括号 {} 通常用来表示一个字典(dictionary)。
# 字典是Python中一种内置的数据结构,用于存储键值对(key-value pairs)。
# 每个键(key)都是唯一的,并且与一个值(value)相关联。
self.idx_to_action = {0: '↑', 1: '↓', 2: '←', 3: '→', 4: 'O'}
# 初始化地图行数、列数、动作个数
self.r, self.c, self.action_nums = r, c, len(self.idx_to_action)
# 随机初始化状态价值矩阵
self.state_value_matrix = np.random.randn(r, c)
# 随机初始化动作价值矩阵,这是一个三维矩阵
# 这个矩阵用于表示在某个状态(由r和c指定)下,执行不同动作(由len(self.idx_to_action)确定)的“价值”或“评分”。
self.action_value_matrix = np.random.randn(r, c, len(self.idx_to_action))
# 随机初始化当前最优策略
# self.cur_best_policy 被赋予了这个二维数组,它用于表示在当前学习或评估过程中,
# 对于每个状态(由 r 行和 c 列定义的状态空间中的每个点),算法认为的最佳动作(或动作索引)。
# 然而,由于这些值是随机抽取的,所以它们并不代表真正的最优策略,而只是作为初始值或某种随机策略的一部分。
# np.random.choice 是 NumPy 库中的一个函数,用于从给定的一维数组中随机抽取元素,或者从指定的范围中随机生成整数
# size=(r, c) 指定了输出数组的形状。
# 因此,np.random.choice 会生成一个形状为 (r, c) 的二维数组,其中每个元素都是从上述范围内随机抽取的一个整数
self.cur_best_policy = np.random.choice(len(self.idx_to_action), size=(r, c))
self.cnt = 0
# 打印当前的最优策略
def show_policy(self):
# [self.idx_to_action[idx] for idx in i] 是一个列表推导式,
# 它遍历 i 中的每个元素(假设 i 是一个可迭代对象,比如列表或元组,且其元素是索引),
# 并使用这些索引从 self.idx_to_action(假设这是一个字典或列表,将索引映射到动作名称或动作本身)中检索对应的动作。
# 然后,print 函数的星号操作符 * 用于解包这个列表,使得列表中的每个元素都作为 print 函数的一个单独的位置参数,
# 从而它们会被打印出来,并且默认会在它们之间添加空格作为分隔符。
# 更具体的解释可以看本文代码后面的相关语法解析
for i in self.cur_best_policy.tolist():
print(*[self.idx_to_action[idx] for idx in i], sep=' ')
# 显示地图
def show_graph(self, graph):
for i in graph:
print(*i, sep=' ')
# 清空控制台
def clear_console(self):
"""
通过os.name属性,可以获取一个字符串,该字符串表示Python正在运行的操作系统。
对于Windows系统,os.name的值是'nt'(代表“New Technology”,是Windows NT及其后续版本的缩写)。
对于大多数Unix-like系统(包括Linux和macOS),os.name的值是'posix'。
对于代码 _ = os.system(...):
其使用了_(通常用作Python中的“don't care”变量,即一个用于接收不需要使用的值的变量名)来接收os.system(...)的返回值。
如果不关心返回值的话,不写 _ 也是可以的,但这是一种良好的编程习惯
"""
if os.name == 'nt': # 对于 windows 系统
_ = os.system('cls')
else: # 对于 Linux 和 mac
_ = os.system('clear')
# 打印点到点的动态运行过程
def show_point_to_point(self, start_point, end_point, graph):
# 越界检测
assert (0 <= start_point[0] < self.r) and (
0 <= start_point[1] < self.c), f'The start_point is {start_point}, is out of range.'
assert (0 <= end_point[0] < self.r) and (
0 <= end_point[1] < self.c), f'The end_point is {end_point}, is out of range.'
# 记录起始点
row, col = start_point
i = 0
# 开始展示动态运行过程
while True:
# 在起始点根据当前的最优策略选择采取的行动
graph[row][col] = self.idx_to_action[self.cur_best_policy[row][col]]
# 选择行动之后,清空控制台
self.clear_console()
# 显示地图
self.show_graph(graph)
# 为了方便观察,沉睡 0.5 s
time.sleep(0.5)
# 根据最优策略选择action后所进入的下一个状态[row][col]以及对应得到的 reward 值
# 对于打印动态运行过程来说 reward 并没有用,因此这里没有接收 reward 值
row, col, _ = get_reward((row, col), self.cur_best_policy[row][col], graph)
# 循环退出条件为:要么已经到达最终状态,要么i已经大于了 r*c 大小
# 因为如果进行轮次数 i 已经比网格世界的格子数还大了,那么说明无解,当然可以结束
if (row, col) == end_point or i > self.r * self.c:
break
# 轮次数量+1
i += 1
class MCBasicSolver(Solver):
"""蒙特卡洛基本算法,使用蒙特卡洛估计动作价值"""
def __init__(self, r, c):
super().__init__(r, c)
def update(self, episode_nums=1, episode_length=20, gamma=0.9):
last_state_value_matrix = np.ones(shape=(r, c), dtype=float)
# 收敛精度为循环条件
while np.sum(np.abs(last_state_value_matrix - self.state_value_matrix)) > 1e-4:
# 遍历所有状态
for i in range(r * c):
# 遍历所有动作
for k in range(len(self.idx_to_action)):
cache1 = []
# 收集 episode_nums 个 episode
# 也就是定义要跑几个回合,默认为1个回合
"""
这里为一个回合或者为几个回合结果都是一样的,因为网格世界环境的运行是一个确定性的过程
而且策略也都是给定的是确定的,那就意味着比如说从 (s1, a1) 出发不管采样多少次
最后所得到的轨迹都是相同的,所以只需要采样一次即可,所里这里的回合数为1或者为多少都是一样
但如果是别的随机环境或者随机策略那么这里的回合数也就是采样数设置为1就不合适了哟
因为对于随机事件只采样一次显然是不合理的
"""
for n in range(episode_nums):
cache2 = []
# 每一个 episode 回合的长度
while len(cache2) < episode_length:
if len(cache2) == 0:
# 如果 cache2 == 0,那么当前回合是第一次出发,也就是说当前状态是起点
# 那就先求得当前行当前列的状态所采取 action k 之后的下一个state和立即奖励
# // 是整数除法运算符,用于计算两个数相除后的整数部分结果,即向下取整。
# *state的语法含义是:表示将多个参数打包成一个可迭代对象或者从一个可迭代对象解包成多个参数。
# 默认打包成的可迭代对象为一个元组
*state, reward = get_reward([i // c, i % c], k, graph)
else:
# 否则的话就不是起点,而是要求到下一个位置的状态和立即奖励
# 其所采取的策略是随机给定的
*state, reward = get_reward(state, self.cur_best_policy[state[0], state[1]], graph)
# 存一下每次状态转移时所获得的 reward 值
cache2.append(reward)
# 当上面的while遍历结束,说明已经拿到了某一个状态下的某一个动作所得到的一条 trajectory 的 return 值
# 我们将其存进 cache1 中
cache1.append(cache2)
# 退出 for n 循环说明回合数已经跑完,实际上这里跑几个回合结果都是一样的
# 因为在这个循环回合中,给定的策略是不会变化的
# 转换成 numpy 数组
cache1 = np.array(cache1, dtype=float)
# 这里我们不关心行,只关心列的数量
_, col = cache1.shape
# 遍历这个列数的列表,每一个元素都是某一状态下采取某一动作后所能取得的 return 值
for j in range(col):
# numpy 数组的切片操作,:表示选取所有行,j 表示选取第 j 列然后进行切片
# 但要注意这样形式切出来的是个向量嗷
# 因此这里就是按列更新一下乘上折扣率的结果,这样更新更快捷,虽然理解难度上增加了一些
cache1[:, j] = cache1[:, j] * (gamma ** j) # 乘以折扣率
# axis=1 参数指定沿着第二维(即列)的方向进行求和操作。这意味着对于每一行,将该行的所有列元素相加。
# 然后将结果赋给了action_value_sum,这样 action_value_sum 就成为了一个包含了每行元素和的一维 NumPy 数组。
action_value_sum = np.sum(cache1, axis=1)
# 更新 action_value_matrix 中在i状态下采取 k action 后在trajectory长为episode_length 的 return 值
# 并且是求跑了多个回合后的平均 return 值
self.action_value_matrix[i // c, i % c, k] = action_value_sum.sum() / episode_nums # 求平均
# 退出上面的循环后,说明在状态 i 下的五个 action 的 trajectory 的 return 值都已经求完
# 那么此时就可以根据 action_value_matrix 来更新我们的策略了
self.cur_best_policy[i // c, i % c] = np.argmax(self.action_value_matrix[i // c, i % c]) # 更新策略
# 退出了上面的循环后,意味着每一个状态的每一个动作都已经计算过,且每个状态下的策略也都已经更新
last_state_value_matrix = self.state_value_matrix # 先记录保存上一次的状态价值矩阵
self.state_value_matrix = np.max(self.action_value_matrix, axis=2) # 然后获取当前状态下的状态价值矩阵
if __name__ == "__main__":
# 定义地图,□ 表示可以正常走的,× 表示 forbidden area,● 表示终点
graph = [['□', '□', '□', '□', '□'],
['□', '×', '×', '□', '□'],
['□', '□', '×', '□', '□'],
['□', '×', '●', '×', '□'],
['□', '×', '□', '□', '□']]
r = len(graph)
c = len(graph[0])
"""MCBasic"""
mc_basic_solver = MCBasicSolver(r, c)
mc_basic_solver.update(episode_nums=1, episode_length=20, gamma=0.9)
mc_basic_solver.show_policy()
print(mc_basic_solver.state_value_matrix)
mc_basic_solver.show_point_to_point((1, 2), (3, 2), graph)
细节在代码中的注释里写的很详细,另外运行结果是动图,这里也就不再赘述。
Use data more efficiently
Algorithm:MC Exploring Starts
本节所介绍的 MC Exploring Starts 是对 MC Basic 算法的推广,让其变得更加实用。
考虑下面这样一个例子,在一个网格世界当中,我们遵循一个策略 Π 然后得到了下图中的这样一个 episode:
然后引入一个简单的概念叫 Visit,也就是在上图这个 episode 当中每出现一次这个 state-action pair 它都称为对这个 state-action pair 有了一个 visit,如下图红圈中的 pair:
在 MC Basic 当中我们用的方法是什么呢?它叫 inital-visit,就是对于这个 episode 我只考虑 (s1, a2) 然后我用剩下的这一条链上所得到的这个 return 来估计 (s1, a2) 的 action value 。这样就可以估计 qΠ(s1, a2) 。它的问题就是没有充分利用这个 episode,它里面还有很多数据被浪费了。
为什么这么说?
因为在 MC Basic 中对于那一大串只用了最终的 (s1, a2),而后面的一大串数据都直接丢掉了,所以说浪费了。
如下图所示:
如果我不考虑 (s1, a2) 的话,我从 (s2, a4) 开始考虑那么这也是一条新的 episode 呀。
那这条新的 episode 就可以用来估计 (s2, a4) 的 action value,以此类推,如下图:
像上图这么利用的话,那么数据就会利用地非常充分了。
而这里面还分为两种方法,一种是 first-visit ,还有一种是 every-visit 。
下面通过一个例子来看一下二者的区别:
可以看见在上图中的这个 episode 中,有两次访问了 (s1, a2) 这个对儿。
如果是 every visit,那么就是我所有的都不管,我只要访问了一次那么它后面的这些(包括重复的 (s1, a2) 的 episode)我都可以用它的 return 来估计 (s1, a2) 的 action value,所以后面出现的第二次 (s1, a2) 也会再一次被用来估计。
但是对于 first-visit 的这种策略就是我只是用第一次出现,我第二次出现的时候我就不再用它后边的来进行估计了。
除了怎么样让数据的利用更加高效之外其实我们还可以怎么样去更加高效的更新策略,这里也有两种方法:
第一种方法也是在 MC Basic 当中所使用的,它就是说我把所有的这个 episode 的从一个 state-action pair 出发的我全部给收集到,收集到了之后就做一个 average return,这样来估计 action value。但是这样的方法有一个问题,就是它需要等,它要等所有的 episode 全都得到了才行。
而第二种方法就是我得到了一个 episode,我就用这个 episode 的 return 来立刻估计 action value。然后不要停不要等,下一个就直接开始改进策略。这样的话就是得到一个 episode 就改进一个策略,这样它的效率会更高。
上述这些方法实际上有一个名字,叫 Generalized policy iteration,简称 GPI。
其不是一个具体的算法,而是一大类算法,或者说它是一种思想或者架构,就是在 policy evaluation 和 policy improvement 中间不断地进行切换,而且这个 policy evaluation 不需要去非常精确地把 action value 或者 state value 给估计出来。
之前介绍的算法和本节课学习的算法实际上都能够落到这个 GPI 的框架当中。
基于上面的思想,我们就可以得到一个新的算法,称为 MC Exploring Starts ,算法伪代码:
然而这个算法的理论和实际是割裂的,因为在实际当中非常难实现,比如在一个网格世界当中要有一个机器人在那里,它要从不同的 (s, a) 出发的时候,我们必须得给它搬运过去设置好程序等等会比较麻烦。因此我们是需要将 exploring starts 这个条件给去掉或者说叫转化掉的,而在下一小节中所介绍的算法中我们可以做到这个事情。
MC Exploring Starts Implementation
class MCExploringStartsSolve(Solver):
"""MC Exploring Starts 算法"""
def __init__(self, r, c):
super().__init__(r, c)
def update(self, graph, episode_nums=5000, episode_length=100, gamma=0.9):
# 创建一个大小为 r * c 的二维列表 returns,
# 其中每个位置都是一个列表,用于存储各种动作的 return
returns = [[[] for _ in range(self.action_nums)] for _ in range(r * c)] # 追踪每个(state, action)对儿的return
# 这里做五千次随机采样,这样更接近真实的 expectation 值
# 因为我们的状态和动作都是随机的,所以这个本算法代码的实现要比上面基础版的更贴近实际一些
for i in range(episode_nums):
# 随机选择一个开始的状态,并随机选择一个开始的动作
row, col = random.randint(0, r - 1), random.randint(0, c - 1)
a = random.choice(list(self.idx_to_action.keys())) # 随机选择一个开始的动作
cache = [] # 用于存储每一个 episode 的信息
# 跑一个长度为 episode_length 的 trajectory
for j in range(episode_length):
if j == 0:
# 说明是一段 episode 的起始状态,起始状态的状态位置和action是给定的
ret = get_reward((row, col), a, graph)
cache.append((row, col, a, ret[2]))
else:
# 不是起始状态那就是中间状态,那么就要根据当前策略得到下一个状态和立即奖励
ret = get_reward((row, col), self.cur_best_policy[row, col], graph)
cache.append((row, col, self.cur_best_policy[row, col], ret[2]))
row, col = ret[0], ret[1]
# 从后面往前递推进行计算
cache = list(reversed(cache))
G = 0
for t, (row, col, a, reward) in enumerate(cache):
G = reward + gamma * G
""""加这个if语句就是First-visit MC,不加就是Every-visit MC"""
# if not(row,col,a) in [(row_t,col_t,a_t) for row_t,col_t,a_t,_ in cache[:t]]:
idx = row * c + col
returns[idx][a].append(G)
self.action_value_matrix[row, col, a] = np.mean(returns[idx][a])
self.cur_best_policy[row, col] = np.argmax(self.action_value_matrix[row, col])
self.state_value_matrix = np.max(self.action_value_matrix, axis=2) # 获取当前状态下的状态价值矩阵
测试代码基本和上面的基础版本差不多,CV 进去就可以了:
if __name__ == "__main__":
# 定义地图,□ 表示可以正常走的,× 表示 forbidden area,● 表示终点
graph = [['□', '□', '□', '□', '□'],
['□', '×', '×', '□', '□'],
['□', '□', '×', '□', '□'],
['□', '×', '●', '×', '□'],
['□', '×', '□', '□', '□']]
r = len(graph)
c = len(graph[0])
"""MCBasic"""
# mc_basic_solver = MCBasicSolver(r, c)
# mc_basic_solver.update(episode_nums=1, episode_length=20, gamma=0.9)
# mc_basic_solver.show_policy()
# print(mc_basic_solver.state_value_matrix)
# mc_basic_solver.show_point_to_point((1, 2), (3, 2), graph)
"""MCExploringStarts"""
mc_exploring_starts_solver = MCExploringStartsSolve(r, c)
mc_exploring_starts_solver.update(graph, episode_nums=1000, episode_length=20, gamma=0.9)
mc_exploring_starts_solver.show_policy()
print(mc_exploring_starts_solver.state_value_matrix)
mc_exploring_starts_solver.show_point_to_point((0, 0), (3, 2), graph)
MC without exploring starts
Algorithm:MC ε-Greedy
去掉 exploring starts 的方法就是引入 soft policy:
什么是 soft-policy ?其实就是它对每一个 action 都有可能去做选择。
之前说过,policy 其实分为两种,一种是确定性的 deterministic 的 policy,我们之前常说的 greedy policy 就是 deterministic 的。还有一种就是 stochastic ,然后 soft policy 包括后面我们马上要介绍的 ε-greedy 都是 stochastic policy 的。
为什么要引入 soft policy 呢?就是因为如果我从一个 state-action pair 比如说 s 和 a 出发,如果后边的这个 episode 特别特别长,因为它是探索性的,实际上我能够确保任何一个 s 和 a 都能够被这个 episode 访问到,那这样的话我就可以去掉 exploring starts 这样一个条件。我不需要从每一个 s 和 a 都出发了,我只要从一个或者几个出发,我就能够覆盖到其它的。
这里面的 soft policy 我们用什么呢?用的就是 ε-greedy 的policy:
什么是 ε-greedy 呢?比如说我们在一个状态 s,然后它有一个 greedy action,它所对应的这个 qΠ(s, a*) 是最大的,这时候的 ε-greedy 就会给这个 greedy action 这样的选择的概率:
而其他的不是 greedy action 的 action 则是这样的概率:
其中,ε 属于范围 0到1,而 |A(s)| 对应的则是状态 s 所对应的 action 的个数。
以本课程介绍的网格世界为例子,每一个状态所对应的 action 为 5,如果我们设计 ε 为 0.2 ,那这个时候下面这个式子就能计算出来为 0.04 :
也就是有四个 action 它们的概率都分别是 0.04,所以剩下的那个 action 它的概率可由下面的式子计算得出是 1 减去 0.16 得 0.84:
说明 greedy action 是有最大的概率做选择,但是其它的 action 也有一些比较小的概率去可以选择到。
这里面有一个性质就是我选择这个 greedy action 的概率始终是比其他的任何一个 action 的概率都要大。
对于 ε 是从 0 到 1 的话这个是什么意思呢?
意思就是说我虽然给了其它 action 一些正数的概率,我可能会去选择它们,但是我把最大的概率还是留给了 greedy action。
为什么要使用 ε-greedy 呢?是因为它能够去平衡 exploitation (利用)和 exploration(探索)。
exploitation 字面意义上是“剥削”,但是在强化学习中应该被理解为 “充分利用” 的意思。
比如说我在一个状态,我有很多个 action,然后里边有的 action 我知道它的 action value 是比较大的,我此时此刻是知道的。那我知道这些信息我就应该在下一个时刻我就去采取那个 action,那么未来就有理由相信我能够得到更多的 reward,这个就称为充分的利用。
而 exploration 是什么?
就是我现在虽然知道那个 action 能够给我带来很多的 reward,但是说不定现在的信息是不完备的,说不定应该去探索一下其他的 action。这样执行完之后可能发现那个其它的 action 它们的 action value 可能也是非常好的,那这个就叫 exploration 探索。
如果 ε = 0 那很显然 greedy action 被选的概率值为 1,而其他的 action 被选的概率为 0 。这时候 ε-greedy 就变成了 greedy,这个时候的探索就会弱一些,但是它的充分利用就会强一些。
如果 ε = 1 那很显然所有的 action 被选的概率都是相同的 。这个时候就变成了一个均匀分布,这时候的探索性会更强,然后这时候的充分利用性会更弱一些。
那么如何将这种策略和我们之前说的蒙特卡洛的强化学习问题结合在一起呢?
先看我们之前的方式:
之前我们的 MC Basic 和 MC Exploring Starts 这两个算法当中的 policy improvement 这个步骤就是:
在首先求得一个 QΠk 之后通过求解一个优化问题得到一个新的策略,之前其实没有强调但实际上我们在这样做的时候,也就是在求解这个优化问题的时候实际上这个 Π 应该是在所有可能的策略当中去做选择,而求解出来的最优策略就是一个 greedy policy:
而现在的做法也很简单:
我们现在要做的就是把这个 ε-greedy 嵌入到这个算法当中,怎么嵌?
就是在求解这个问题的时候:
我们不去在所有的策略里面去找,而只是在所有的 ε-greedy 的策略里面找,这里的 ε 是事先给定的。
因此这个时候所得到的最优的策略是:
通过上述方式,我们最终就得到了 MC ε-greedy 这样一个算法。
算法伪代码如下:
一些例子,用来更好的理解这个算法:
一个技巧就是,最开始的时候 ε 可以设置大一点,这个时候探索性比较强,然后 ε 就可以逐渐减小到 0,最后就可以得到一个最优的策略。
MC ε-Greedy Implementation
import random
import numpy as np
import time
import os
def get_reward(location, action, graph):
# r, c 表示地图的行数和列数
r, c = len(graph), len(graph[0])
reward = -1 # 默认奖励为 -1,因为要求走最短路径
# row, col 表示当前所在行列位置
row, col = location
# 采取行动为0,表示往上,那么当前位置的行数+1
if action == 0:
row = row - 1
# 采取行动为1,表示往下,那么当前位置的行数-1
elif action == 1:
row = row + 1
# 采取行动为2,表示往左,那么当前位置的列数-1
elif action == 2:
col = col - 1
# 采取行动为3,表示往右,那么当前位置的列数+1
elif action == 3:
col = col + 1
# 如果采取了action后的所在行列位置越界了,reward-1
if row < 0 or row > r - 1 or col < 0 or col > c - 1:
reward = -1
# 如果采取了action后的所在行列位置在forbidden area,reward-100
# 这表示我们并不想让 agent 走进 forbidden area
elif graph[row][col] == '×':
reward = -100
# 如果采取了action后的所在行列位置在目标位置了,reward+20
elif graph[row][col] == '●':
reward = 20
# 控制边界约束, 防止越界异常
row = max(0, row)
row = min(r - 1, row)
col = max(0, col)
col = min(c - 1, col)
# 返回下一个状态以及奖励
return row, col, reward
# 在Python 3中,几乎所有的类都默认继承自object类,即使你不显式地写出来
class Solver(object):
def __init__(self, r: int, c: int):
"""
:param r: 代表当前地图行数
:param c: 代表当前地图列数
"""
# 初始化动作空间
# 在Python中,大括号 {} 通常用来表示一个字典(dictionary)。
# 字典是Python中一种内置的数据结构,用于存储键值对(key-value pairs)。
# 每个键(key)都是唯一的,并且与一个值(value)相关联。
self.idx_to_action = {0: '↑', 1: '↓', 2: '←', 3: '→', 4: 'O'}
# 初始化地图行数、列数、动作个数
self.r, self.c, self.action_nums = r, c, len(self.idx_to_action)
# 随机初始化状态价值矩阵
self.state_value_matrix = np.random.randn(r, c)
# 随机初始化动作价值矩阵,这是一个三维矩阵
# 这个矩阵用于表示在某个状态(由r和c指定)下,执行不同动作(由len(self.idx_to_action)确定)的“价值”或“评分”。
self.action_value_matrix = np.random.randn(r, c, len(self.idx_to_action))
# 随机初始化当前最优策略
# self.cur_best_policy 被赋予了这个二维数组,它用于表示在当前学习或评估过程中,
# 对于每个状态(由 r 行和 c 列定义的状态空间中的每个点),算法认为的最佳动作(或动作索引)。
# 然而,由于这些值是随机抽取的,所以它们并不代表真正的最优策略,而只是作为初始值或某种随机策略的一部分。
# np.random.choice 是 NumPy 库中的一个函数,用于从给定的一维数组中随机抽取元素,或者从指定的范围中随机生成整数
# size=(r, c) 指定了输出数组的形状。
# 因此,np.random.choice 会生成一个形状为 (r, c) 的二维数组,其中每个元素都是从上述范围内随机抽取的一个整数
self.cur_best_policy = np.random.choice(len(self.idx_to_action), size=(r, c))
self.cnt = 0
# 打印当前的最优策略
def show_policy(self):
# [self.idx_to_action[idx] for idx in i] 是一个列表推导式,
# 它遍历 i 中的每个元素(假设 i 是一个可迭代对象,比如列表或元组,且其元素是索引),
# 并使用这些索引从 self.idx_to_action(假设这是一个字典或列表,将索引映射到动作名称或动作本身)中检索对应的动作。
# 然后,print 函数的星号操作符 * 用于解包这个列表,使得列表中的每个元素都作为 print 函数的一个单独的位置参数,
# 从而它们会被打印出来,并且默认会在它们之间添加空格作为分隔符。
# 更具体的解释可以看本文代码后面的相关语法解析
for i in self.cur_best_policy.tolist():
print(*[self.idx_to_action[idx] for idx in i], sep=' ')
# 显示地图
def show_graph(self, graph):
for i in graph:
print(*i, sep=' ')
# 清空控制台
def clear_console(self):
"""
通过os.name属性,可以获取一个字符串,该字符串表示Python正在运行的操作系统。
对于Windows系统,os.name的值是'nt'(代表“New Technology”,是Windows NT及其后续版本的缩写)。
对于大多数Unix-like系统(包括Linux和macOS),os.name的值是'posix'。
对于代码 _ = os.system(...):
其使用了_(通常用作Python中的“don't care”变量,即一个用于接收不需要使用的值的变量名)来接收os.system(...)的返回值。
如果不关心返回值的话,不写 _ 也是可以的,但这是一种良好的编程习惯
"""
if os.name == 'nt': # 对于 windows 系统
_ = os.system('cls')
else: # 对于 Linux 和 mac
_ = os.system('clear')
# 打印点到点的动态运行过程
def show_point_to_point(self, start_point, end_point, graph):
# 越界检测
assert (0 <= start_point[0] < self.r) and (
0 <= start_point[1] < self.c), f'The start_point is {start_point}, is out of range.'
assert (0 <= end_point[0] < self.r) and (
0 <= end_point[1] < self.c), f'The end_point is {end_point}, is out of range.'
# 记录起始点
row, col = start_point
i = 0
# 开始展示动态运行过程
while True:
# 在起始点根据当前的最优策略选择采取的行动
graph[row][col] = self.idx_to_action[self.cur_best_policy[row][col]]
# 选择行动之后,清空控制台
self.clear_console()
# 显示地图
self.show_graph(graph)
# 为了方便观察,沉睡 0.5 s
time.sleep(0.5)
# 根据最优策略选择action后所进入的下一个状态[row][col]以及对应得到的 reward 值
# 对于打印动态运行过程来说 reward 并没有用,因此这里没有接收 reward 值
row, col, _ = get_reward((row, col), self.cur_best_policy[row][col], graph)
# 循环退出条件为:要么已经到达最终状态,要么i已经大于了 r*c 大小
# 因为如果进行轮次数 i 已经比网格世界的格子数还大了,那么说明无解,当然可以结束
if (row, col) == end_point or i > self.r * self.c:
break
# 轮次数量+1
i += 1
# epsilon 贪婪法,当 epsilon = 0,完全贪婪法
def get_epsilon_greedy_action(self, state, epsilon=0.1):
row, col = state
# 找最优动作
best_action = np.argmax(self.action_value_matrix[row][col]).item()
# epsilon贪婪法,当 epsilon != 0时,才有可能进入该 if 语句,否则直接返回最优动作
"""
< 右侧的式子表示的是 greedy action 被选择的概率
< 左侧的式子表示的是其它的 action 被选择的概率
使用 random.random 随机数与 greedy action 的概率值进行随机比较这样才不会总是返回最优动作
同时这样的做法也会更倾向于选择概率值较大的 greedy action
"""
if random.random() < epsilon * (self.action_nums - 1) / self.action_nums:
# 随机选择除了当前最佳 action 之外的其它 action
# 因为其它的 action 被选择的概率都是一样的,所以随机选就可以
actions = list(self.idx_to_action.keys())
actions.remove(best_action)
return random.choice(actions)
return best_action
class MCEpsilonGreedySolve(Solver):
"""MC Epsilon Greedy 算法"""
def __init__(self, r, c):
super().__init__(r, c)
def update(self, episode_nums=5000, episode_length=100, gamma=0.9):
# 创建一个大小为 r * c 的二维列表 returns,
# 其中每个位置都是一个列表,用于存储各种动作的 return
returns = [[[] for _ in range(self.action_nums)] for _ in range(r * c)] # 追踪每个(state, action)对儿的return
# 这里做五千次随机采样,这样更接近真实的 expectation 值
# 因为我们的状态和动作都是随机的,所以这个本算法代码的实现要比上面基础版的更贴近实际一些
for i in range(episode_nums):
# 随机选择一个开始的状态,并随机选择一个开始的动作
row, col = random.randint(0, r - 1), random.randint(0, c - 1)
a = random.choice(list(self.idx_to_action.keys())) # 随机选择一个开始的动作
cache = [] # 用于存储每一个 episode 的信息
# 跑一个长度为 episode_length 的 trajectory
for j in range(episode_length):
if j == 0:
# 说明是一段 episode 的起始状态,起始状态的状态位置和action是给定的
ret = get_reward((row, col), a, graph)
cache.append((row, col, a, ret[2]))
else:
# 不是起始状态那就是中间状态,那么就要根据当前策略得到下一个状态和立即奖励
ret = get_reward((row, col), self.cur_best_policy[row, col], graph)
# 相较于MC Exploring Starts,这里的动作选择不是随机的,而是epsilon-greedy,只改了这一行
cache.append((row, col, self.get_epsilon_greedy_action((row, col)), ret[2]))
row, col = ret[0], ret[1]
# 从后面往前递推进行计算
cache = list(reversed(cache))
G = 0
for t, (row, col, a, reward) in enumerate(cache):
G = reward + gamma * G
""""加这个if语句就是First-visit MC,不加就是Every-visit MC"""
# if not(row,col,a) in [(row_t,col_t,a_t) for row_t,col_t,a_t,_ in cache[:t]]:
idx = row * c + col
returns[idx][a].append(G)
self.action_value_matrix[row, col, a] = np.mean(returns[idx][a])
self.cur_best_policy[row, col] = np.argmax(self.action_value_matrix[row, col])
self.state_value_matrix = np.max(self.action_value_matrix, axis=2) # 获取当前状态下的状态价值矩阵
if __name__ == "__main__":
# 定义地图,□ 表示可以正常走的,× 表示 forbidden area,● 表示终点
graph = [['□', '□', '□', '□', '□'],
['□', '×', '×', '□', '□'],
['□', '□', '×', '□', '□'],
['□', '×', '●', '×', '□'],
['□', '×', '□', '□', '□']]
r = len(graph)
c = len(graph[0])
"""MCEpsilonGreedy"""
mc_epsilon_greedy_solver = MCEpsilonGreedySolve(r, c)
mc_epsilon_greedy_solver.update(episode_nums=1000, episode_length=20, gamma=0.9)
mc_epsilon_greedy_solver.show_policy()
mc_epsilon_greedy_solver.show_point_to_point((0, 0), (3, 2), graph)