前言
最近学习了一下强化学习相关的知识,但是看了许多的文章,很少有一步到位直接入门再加上案例实践的,导致几天下来找文章都花费了不少的时间,学到中间阶段,很多文章知识点有重叠,又花费了许多不必要的时间。所以在这里打算梳理一下学习的内容,对那些看起来很复杂但是理解了就很简单的公式一个简单讲解,把刚入门的学习者从水深火热中解放出来(开个玩笑)。本篇文章主要给初入门的朋友一个较为清晰的方向,只讲解最基本的强化学习概念和原理,至于很多改进算法,大家可以自行阅读相关论文和博客。
强化学习概念
这里要讲一下强化学习的一个基本流程。实际上就是一个不断学习,改进方法的过程。可以类比于一个人打游戏,通过增加熟练度,探索新的技巧,从青铜上到王者的过程。如下图所示:
这里介绍一下强化学习的各种概念。 1. 智能体:也叫agent,就是需要学习东西的机器人,它会不断的和环境交互,做出动作,学习到新的方法来增强自己的能力,强化学习的强化也体现在这里 2. 环境:环境类比于游戏本身,如果玩超级玛丽,那就是游戏世界本身。它会根据玛丽的动作做出反馈,比如吃到金币会加分,碰到蘑菇会扣一条命 3. 动作:action,就是智能体可以采取的一系列操作。比如超级玛丽这样的简单游戏就是前进后退跳跃等。如果是像王者荣耀这样的游戏,就会包括攻击,技能,甚至发信号等操作。 4. 状态:state,当前所处的游戏情况的全部信息,有时候用observation(观察),这个和状态不一样,观察的意思是,有时候我们并不能获得环境全部的信息,比如玩moba游戏,你大部分时候并不会有对面的视野。在一些简单游戏中,状态和观察就是相同的,依情况而定。 5. 奖励:reward,奖励是智能体是否能提高自身水平的关键。我们需要根据环境来制定奖励和惩罚,智能体会不断的玩游戏,提高有奖励的动作的概率,降低有惩罚的动作的概率。举个例子,机器人不断的玩超级玛丽。我们设置,吃到金币加一分,碰到蘑菇或者掉坑里,扣100分,通关加1000分。这样在训练了很多次后,机器人就会尽量吃金币,并躲避陷阱,最终通关。 6. 回报:回报一般以G来表示,回报就是奖励的叠加,G_t就是值t时刻到最后时刻所有奖励的叠加,但是在强化学习中,越往后的奖励权重应该越低,我们要考虑长远问题,但眼前的东西应该更重要,所以后续的奖励在当前时刻应该要打折扣,类比于今天给你一百块,和下个月给你150,就算150更多,但是今天的100块也应该要优先与下个月的150.公式如下$G_t=r_{t+1}+γr_{t+2}+γ^2r_{t+3}+γ^3r_{t+4}+…+γ^{T−t−1}r_T$,这里的γ一般位于0到1之间,可以看到越往后的奖励权重越低。
强化学习的整体流程就是,机器人和环境交互,做出动作。环境给出奖励并更新状态。智能体在新的状态下,在此决策,给出动作。我们的目标是一次游戏流程中,得到的奖励最大化。一开始智能体的策略是随机的,它会在环境中乱动,环境会对它的行为给出奖励和惩罚,慢慢的智能体就会知道哪些事情可以做,哪些不能做。
这里最重要的就是,智能体要得到策略,选择动作,这个策略就是强化学习中需要通过不断训练改进的东西。最简单的策略就是根据状态和动作给出一个表格,我们通过查表得到下一步动作。举个例子,这里有一个小游戏,在一个4x4的格子里面,小人要避开水坑,成功走到礼物盒子那里。一共有16个格子,就是16个状态,而小人可以采取的动作有上下左右四种,所以我们可以构造一个16x4的矩阵,每一行代表状态,也就是所处是哪个格子,而四列分别是采取的动作,每次我们取四个动作里面价值最大的或者随机采样,这样就是一个策略。
马尔可夫过程
这里我们说一下马尔可夫性质,这个其实很简单,一句话总结就是未来的可能性只取决于当前时刻的动作,和过去做了什么没有关系。这就是马尔可夫过程的基础。举例说明,比如超级玛丽,吃到了一个金币,然后向前走了一步,再后来碰到了蘑菇,游戏结束,它碰到蘑菇,我们认为只和向前走这个动作有关系,和前面的吃了金币无关。
我们定义一组具有马尔可夫性质的状态序列s1,⋯,st,其中下一个时刻的状态st+1只取决于当前状态st。如此则有公式
p
(
X
t
+
1
=
x
t
+
1
∣
X
0
:
t
=
x
0
:
t
)
=
p
(
X
t
+
1
=
x
t
+
1
∣
X
t
=
x
t
)
p(X_{t+1}=x_{t+1}∣X_{0:t}=x_{0:t})=p(X_{t+1}=x_{t+1}∣X_t=x_t)
p(Xt+1=xt+1∣X0:t=x0:t)=p(Xt+1=xt+1∣Xt=xt)
这里的意思就是以X取前面所有时刻的x的为条件,下一个时刻状态变成x_t+1的概率和只以X取x_t的情况的概率一样。也就是与以前的状态无关。
而马尔可夫链条就是在符合马尔可夫性质的这样的一个状态转移关系。如图所示,其实就是一个简单的状态图,如果大家了解过有限自动机应该会比较熟悉。简单说明:这里的S1状态,它有0.1的概率维持自身状态,0.2的概率转变为s2状态0.7的概率转变为s4。
贝尔曼方程
在讲这个方程之前,我们先看看状态价值函数。前面我们说了回报G的概念。而状态价值函数就是回报的期望。因为回报需要考虑未来的奖励,而未来奖励还不知道,我们只有未来做出动作的可能性矩阵,例如上面提到的16x4的矩阵。所以我们需要利用期望这个数学工具.
这里简单介绍一下期望,期望顾名思义就是对未来可能性的一种评估。公式就是每种概率与值的乘积加和。举个例子。我们知道骰子有六个面,每个面的概率是六分之一。那么筛子的期望就是每一个面的点数乘以六分之一再相加。再比如,我们想要知道小明期末能考多少分,老师告诉我们,小明考60分的概率是0.8,考80的概率是0.2。那么小明期末成绩的期望就是60x0.8+80x0.2。
我们来看一下状态价值函数的定义,它其实就是回报G的期望
V
t
(
s
)
=
E
[
G
t
∣
s
t
=
s
]
=
E
[
r
t
+
1
+
γ
r
t
+
2
+
γ
2
r
t
+
3
+
γ
3
r
t
+
4
+
…
+
γ
T
−
t
−
1
r
T
∣
s
t
=
s
]
V^t(s) = E[G_t|s_t=s] =E[r_{t+1}+γr_{t+2}+γ^2r_{t+3}+γ^3r_{t+4}+…+γ^{T−t−1}r_T | s_t=s]
Vt(s)=E[Gt∣st=s]=E[rt+1+γrt+2+γ2rt+3+γ3rt+4+…+γT−t−1rT∣st=s]
我们在st的状态下,后续回报的期望,这样就消去了未来还没有得到的奖励,这是一个估计值,评估我们所在状态的一个价值。类比游戏就是估计现在这个局面是顺风局还是逆风局。
有了状态价值函数,我们就可以来看看贝尔曼方程,这个其实就是从其中推算出来的。我们直接看公式。
V
(
s
)
=
R
(
s
)
+
γ
∑
s
′
∈
S
i
p
(
s
′
∣
s
)
V
(
s
′
)
V(s) = R(s) + γ\sum_{s'\in S} i{p(s'|s)V(s')}
V(s)=R(s)+γ∑s′∈Sip(s′∣s)V(s′)
让我们来解释一下这个函数V(s)就是我估计在s状态下我得到的价值,注意这里的价值是后续到游戏结束我所得到的奖励的总和,这是一个估计值。R(s)是指,我到了这个状态,环境给出的奖励,这是实际值。S’可以看成未来所有的状态,p函数式只在马尔可夫链条上面的状态转移概率。这个加和函数的意思就是我对s状态以后的奖励进行估计。
时序差分
这里我们来讲强化学习的训练方法。看上面的贝尔曼方程,这个等式是一个理想状态。因为V(s)是在当前状态对结果进行预估,而等式右边有一个实际值R(s),以及后续的预估。它们不会完全相等。就好比我要从重庆去云南,中途经过四川。我在重庆预估,我到云南需要的时间是十个小时。等我到了四川,实际上从重庆到四川用了两个小时,我依据原先的估计方法继续估计四川到云南需要五个小时。这里就会有差别,根据我已经走过的一段路,实际上比最初我估计的时间要短很多,这里哪个更贴近真实值,很明显是后面的5+2=7个小时,虽然这个7小时依旧不是真实值,但是它有一部分是真实的,已经可以用于更新这个最初的估计。这个就是时序差分,它通过不断地更新当前状态的估计值,来逼近真实值函数,从而实现值函数的学习。
Q-learning
讲了这么多概念,其实挺没意思的。让我们来实际操作一下。Q-Learning是比较古老的强化学习模型了,它不包括深度学习的部分,适合一些简单的任务。以上面那个走格子的小游戏为例。这里需要下载gym这个包。
import random
import gym
from matplotlib import pyplot as plt
class MyWrapper(gym.Wrapper):
def __init__(self):
env = gym.make('FrozenLake-v1',
render_mode='rgb_array',
is_slippery=False)
super().__init__(env)
self.env = env
def reset(self):
state,_ = self.env.reset()
return state
def step(self, action):
#推荐一步,参数是要做的动作,返回状态,奖励和是否结束
state,reward,terminated,truncated,info = self.env.step(action)
over = terminated or truncated
if not over: # 走一步扣一分,让agent尽快找到终点
reward = -1
if over and reward == 0: # 如果掉进坑里,扣一百分
reward = -100
return state,reward,over
def show(self):
plt.figure(figsize=(3,3))
plt.imshow(self.env.render())
plt.show()
env = MyWrapper()
env.reset()
env.show()
定义策略表格,给出游玩方案,每一次我们取出所有动作中值最大的索引作为我们选择的动作,并把这个动作和做了之后得到的奖励等添加进data,以构建数据池,方便训练。
import numpy as np
from IPython import display
Q = np.zeros((16,4))
def play(show=False):
data = []
reward_sum = 0
state = env.reset()
over = False
while not over:
action = Q[state].argmax()
if random.random() < 0.1:
action = env.action_space.sample()
next_state,reward,over = env.step(action)
data.append((state,action,reward,next_state,over))
reward_sum += reward
state = next_state
if show:
display.clear_output(wait=True)
env.show()
return data,reward_sum
构建数据池
#数据池
class Pool:
def __init__(self):
self.pool = []
def __len__(self):
return len(self.pool)
def __getitem__(self, i):
return self.pool[i]
#更新动作池
def update(self):
#每次更新不少于N条新数据
old_len = len(self.pool)
while len(pool) - old_len < 200:
self.pool.extend(play()[0])
#只保留最新的N条数据
self.pool = self.pool[-1_0000:]
#获取一批数据样本
def sample(self):
return random.choice(self.pool)
pool = Pool()
pool.update()
这里提一句,off-policy和on-policy的区别。很简单,on-policy就是游戏运行一帧,模型就学一次。已经学过的数据直接丢弃。这样训练起来很慢,因为有时候要一个回合结束才能知道这些动作好不好,比如王者荣耀打完一局了才能知道这把行不行。off-policy就是可以让其它机器人去练,收集数据。然后我们真正的机器人只需要学习别人收集的数据就可以。我们上面构建数据池就是off-policy的方法。让很多机器人去探索,不管它们会不会玩游戏,无论是好的奖励和坏的惩罚,都能作为数据集给我们的机器人启发。
打个比方,小明在教室睡觉被老师批评,被批评后小明就知道错了,调整自己睡觉的频率。小明就是on-policy,班上其它同学看到了小明的遭遇,也可以引以为戒,调整自己睡觉的频率,它们就属于off-policy。虽然off-policy训练起来效率比较高,但是也有些问题需要解决,比如给到的数据集和自己的学习不能差别太大。比如说,小明睡觉被批评,但是你上课从来不睡觉,那么这件事给你的启发就比较小,甚至没有意义。
我们接着看下面的训练过程
def train():
for epoch in range(1000):
pool.update()
for i in range(200):
#随机抽一条数据
state, action, reward, next_state, over = pool.sample()
#Q矩阵当前估计的state下action的价值,Q里面是当前状态和行为下后续所有回报的和。所以当前的奖励加上下个状态的Q应该和当前的Q相等
value = Q[state, action]
#实际玩了之后得到的reward+下一个状态的价值*0.9
target = reward + Q[next_state].max() * 0.9
#value和target应该是相等的,说明Q矩阵的评估准确
#如果有误差,则应该以target为准更新Q表,修正它的偏差
#这就是TD误差,指评估值之间的偏差,以实际成分高的评估为准进行修正
update = (target - value) * 0.1
#更新Q表
Q[state, action] += update
if epoch % 100 == 0:
print(epoch, len(pool), play()[-1])
这里训练的方式就是时序差分,target是包含真实情况的未来估计,比纯预计值value要更准确,我们让value靠近target,这里的0.1属于学习率,可以自行设置。
训练完成后可以在运行一下
play(True)[-1]
应该能得到-4,也就是四步走到终点。
DQN
接下来,我们向现代进发,尝试一下深度强化学习,实际上也很简单,就是把这个决策表替换成神经网络,让神经网络去拟合它。这个网络不会很复杂,就算是一个普通的MLP也可以得到较好的效果。让我们以flappy bird这个小游戏为案例。
整个项目已经放到了github上面,大家可以自行查看
biluox/DQN_Flappy_Bird: Play the flappy bird by DQN (github.com)
让我们开始吧。 首先是环境部分,这个游戏不是gym上面的,是在github上从别人那里搞来的,用pygame写的一个环境。游戏实现比较简单,也不是我们的重点,这里就不再讲了。这里只说一下frame_step函数。它接收一个动作参数,返回三个字段,分别是图像像素矩阵,做动作后获得的奖励,以及游戏是否结束。需要注意的是动作参数是一个one-hot向量。用[0,1] 数组表示什么都不做,用[1,0]表示点击向上飞。让我们来看一下DQN的核心代码,就是一个很简单的卷积神经网络。为什么要使用卷积层,因为我们的输入是游戏的像素矩阵(游戏画面),我们用神经网络处理像素数据,通过多层卷积提取特征,然后经过两层Linear层得到一个两维的向量,分别是两种行为,注意,它的值是神经网络估计的该动作在当前状态下的回报,也就是前面提到的,对当前到游戏结束为止的全部奖励的估计。 ```python class DQNNetwork(nn.Module): def __init__(self): super(DQNNetwork, self).__init__() # 第一个维度是batch,一次输入四帧图像,故第二个维度数是4,图片为二维灰度图。故一共四个维度 self.conv1 = nn.Conv2d(in_channels=4, out_channels=32, kernel_size=8, stride=4, padding=0) self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=4, stride=2, padding=0) self.conv3 = nn.Conv2d(in_channels=64, out_channels=64, kernel_size=3, stride=1, padding=0) self.fc1 = nn.Linear(3136, 512) self.fc2 = nn.Linear(512, actions)
def forward(self, x):
x = F.relu(self.conv1(x))
x = F.relu(self.conv2(x))
x = F.relu(self.conv3(x))
# 接入全连接之前把数据压平
x = x.view(-1, 3136)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x
接下来我们对这个网络进行训练。一开始的时候,我们默认行为为什么也不做;
```python
game_state = game.GameState()
action = torch.tensor([1, 0], dtype=torch.float32)
image_data, reward, terminal = game_state.frame_step(action)
# 预处理图像 改编大小和彩色变黑白
image_data = resize_and_bgr2gray(image_data)
image_data = image_to_tensor(image_data)
这里要处理图像,去除地面图像,改变大小,并变为只有0和255的黑白图像
def resize_and_bgr2gray(image):
# 去除地面图像
image = image[:288, :404] # 512*0.79
image_data = cv2.cvtColor(cv2.resize(image, (84, 84)), cv2.COLOR_BGR2GRAY)
# 二值化
image_data[image_data > 0] = 255
image_data = np.reshape(image_data, (84, 84, 1))
return image_data
把我们的图像转变为tensor数据
def image_to_tensor(image):
# 原shape(84,84,1) torch默认通道数在最前面
image_tensor = image.transpose(2, 0, 1)
image_tensor = image_tensor.astype(np.float32)
image_tensor = torch.from_numpy(image_tensor)
return image_tensor
我们的输入数据为四帧图像,一开始没有这么多,我们直接复制
# state 被设计为4帧图片,但是一开始只有一帧,我们直接复制一样的,让模型跑起来
state = torch.cat([image_data for i in range(4)]).unsqueeze(0)
我们采用off-policy的方式,把每次的训练数据存储起来,批量训练。在正常游戏训练的情况下,我们应该给一点随机值,让智能体可以随机探索,但是这个游戏过于简单,有时候随机探索反而导致乱飞,我就注释掉了,大家也可以试试打开它
replay_memory = []
iter = 0
while iter < args.num_iters:
prediction = model(state)[0]
action = torch.zeros(2, dtype=torch.float32)
if torch.cuda.is_available():
action = action.cuda()
# epsilon = args.final_epsilon + (
# (args.num_iters - iter) * (args.initial_epsilon - args.final_epsilon) / args.num_iters)
# if random() <= epsilon:
# # 随机操作
# print('采取随机动作')
# action_index = randint(0, 1)
# else:
# print('采取Q动作')
取出值最大的索引作为动作序列,把动作输入环境,让游戏运行一帧,获得奖励和下一个状态,我们再次运行图像处理函数。把得到的新图像替换老图像序列中的一帧。
action_index = torch.argmax(prediction).item()
action[action_index] = 1
image_data, reward, terminal = game_state.frame_step(action)
image_data = resize_and_bgr2gray(image_data)
image_data = image_to_tensor(image_data)
if torch.cuda.is_available():
image_data = image_data.cuda()
# 之前4帧取3帧和最新的一帧拼接
next_state = torch.cat((state.squeeze(0)[1:, :, :], image_data)).unsqueeze(0)
# 有了一次状态转换,加入replay中
replay_memory.append([state, action, reward, next_state, terminal])
# 如果replay_memory满了,就清除最早的
if len(replay_memory) > args.replay_memory_size:
del replay_memory[0]
# 一开始数据并不多,所以要设置采样数为长度和batch中小的那个
batch = sample(replay_memory, min(len(replay_memory), args.batch_size))
把批量数据拼解起来,因为奖励是数值而不是向量,所以不用拼解直接取
state_batch, action_batch, reward_batch, next_state_batch, terminal_batch = zip(*batch)
state_batch = torch.cat(tuple(state for state in state_batch))
action_batch = torch.cat(tuple(state for state in action_batch))
reward_batch = torch.from_numpy(np.array(reward_batch, dtype=np.float32)[:, None])
next_state_batch = torch.cat(tuple(state for state in next_state_batch))
关键的来了,这里还是时序差分的方式,实际上大部分强化学习训练都是这个框架。我们使用当前状态,通过神经网络预测回报。然后用下一刻的状态再次预测回报。然后计算带有当前reward的半真实值。如果游戏结束了,那么直接返回reward,因为没有后续了,如果没有结束,则返回当前reward和消减后的未来预测。有了真实值,有了预测值,我们就可以反向传播优化神经网络了。
# Q(S,A) model预测的是这个状态动作的得分
current_prediction_batch = model(state_batch)
# Q(S',A)
next_prediction_batch = model(next_state_batch)
y_batch = torch.cat(tuple(reward if terminal else reward + args.gamma * torch.max(prediction) for
reward, terminal, prediction in
zip(reward_batch, terminal_batch, next_prediction_batch)))
# 模型的预测值 action_batch是one-hot编码,所以相乘定价等同于获取某个action对应模型预测的Q值
q_value = torch.sum(current_prediction_batch * action_batch.view(-1, 2), dim=1)
optimizer.zero_grad()
loss = criterion(q_value, y_batch)
loss.backward()
optimizer.step()
state = next_state
大部分代码就是这样,其它细节参考github仓库。总体而言还是很简单的。这就是最基本的深度强化学习的训练框架。这个还是要练很久的,用gpu,大概个小时才能正常绕过第一个柱子,三个小时左右智能体才能正常游戏。
总结
这里大概讲解了一下关于强化学习的一些基本知识,以及简单的实践,实际上强化学习还有很多东西,我自己也没有学的很明白,这里主要是做一个学习笔记,以及分享一些经过我理解过后的知识,希望可以给大家提供一点帮助,后面可能会写一篇关于策略梯度和ppo的文章,有空再看吧。