前言
之前好一阵没有更新博客是因为在参加考研,现在考研结束,笔者本人也将迎来新的学习机会,于是开始了Reinforcement Learning的学习,本人在这之前学习过传统的模式识别以及粗略地阅读过一些深度学习相关的综述文献,同时还有补充一点控制理论的知识,因此学习起来难度适中,接下来我会从理论学习、代码实战、仿真训练几个部分展开总结,一些预备知识的学习链接如下:
模式识别:晚些时候我会上传我的模式识别学习笔记,不过写的一般般就是了
深度学习:mli/paper-reading: 深度学习经典、新论文逐段精读 (github.com)
控制理论:DR_CAN的个人空间-DR_CAN个人主页-哔哩哔哩视频 (bilibili.com)
RL理论
RL的理论非常多,但目前我所实操使用到的理论仅有PPO和DDPG,因此这里我只会介绍这两种理论知识,具体的学习教程可以参考:
【强化学习的数学原理】课程:从零开始到透彻理解(完结)_哔哩哔哩_bilibili
datawhalechina/easy-rl: 强化学习中文教程(蘑菇书🍄),在线阅读地址:https://datawhalechina.github.io/easy-rl/
首先交代一些前提知识,在强化学习中,其实简单来说就只有三大件:环境、智能体以及执行算法,其中,智能体和环境进行交互,而智能体做交互的动作决策就由执行算法来定义;环境中包含了具体的物理环境以及’奖励‘,而智能体就会利用各种方法来不断获得更多的’奖励‘。
让我们举一个具体的例子,比如说平衡车作为一个智能体,人会通过遥控来控制平衡车的动作,这里借用一点控制理论的概念,平衡车的状态空间就是当前的位姿、电机转速、电流扭矩等,而动作空间就是下一时刻的电机转角以及电流等,很自然的一个‘奖励’就是保持平衡;于是当我们利用遥控来控制机器人时,机器人会利用传感器(陀螺仪、磁力计等)采集当前数据,当前数据会被智能体的算法利用,输出一个具体的动作,这个动作再和遥控器要求的数据进行数据融合等操作,最终得到我们期望的平衡车动作。
工作示例见下图
最后,再介绍一下什么是同策略和异策略,前者是指我们要更新的智能体和当前与环境交互的智能体是同一个,而异策略是指要更新的智能体和当前与环境交互的智能体不是同一个,这种方法可以节省数据采样的时间,因为在RL中,最耗时的操作往往不是神经网络的计算,而是数据采样。
PPO(近端策略优化)
由于数学证明部分可以在书中找到,这里我就不详细展开,采用一下‘奥卡姆剃刀’(bushi),简单介绍一下其核心思想。
在RL中,核心的两个角色分别为Actor和Critic,前者做出决策,后者来评估决策的好坏。前面已经说过,由于智能体与环境交互会产生大量的时耗,而且每次更新完这个智能体以后其先前采样得到的数据就不适用了(因为智能体对环境的理解变化了),为此,我们可以通过定义两个智能体,一个智能体A进行交互并获得数据,另一个智能体B可以用智能体A所获得的数据不断训练,等到了一个epoch时间后A再进行更新,此时可以把B的模型参数复制给A,然后如此往复,得到最终的模型。
当然,这只是一个简单的介绍,在实际应用中会出现各种各样的问题,比如:“如果A和B的模型参数相差太远,那么A得到的数据还能给B使用吗”,为了解决这个问题,研究者们利用了重要性采样原则、优势函数、加入包含了KL散度的惩罚项等 来解决这个问题,对这部分感兴趣的朋友可以查阅我上面提供的链接自行学习,接下来展示一下简单的PPO代码,具体的代码网址如下:
easy-rl/notebooks/PPO.ipynb at master · datawhalechina/easy-rl (github.com)
网络结构
根据具体情况的不同,我们可以定义不同的网络来提取演员和评论员的状态、动作特征以适配具体的工作,例如在游戏中可以利用CNN或者ViT来对游戏帧提取特征,具有连续性的任务可以用到LSTM来保存时序信息,我还有看到部分研究者利用Transformer架构将RL和多模态相结合,不过代码示例就是简单的多层感知机架构,具体代码如下
import torch.nn as nn
import torch.nn.functional as F
class ActorSoftmax(nn.Module):
def __init__(self, input_dim, output_dim, hidden_dim=256):
super(ActorSoftmax, self).__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, output_dim)
def forward(self,x):
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
probs = F.softmax(self.fc3(x),dim=1)
return probs
class Critic(nn.Module):
def __init__(self,input_dim,output_dim,hidden_dim=256):
super(Critic,self).__init__()
assert output_dim == 1 # critic must output a single value
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, output_dim)
def forward(self,x):
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
value = self.fc3(x)
return value
模型环境
这部分一般由人为定义,具体包括了状态空间、动作空间以及奖励机制等,具体代码见网址,这里不水字数了。
训练部分
训练部分的代码就是将刚才简要介绍的核心思想,代码如下:
print("开始训练!")
rewards = [] # 记录所有回合的奖励
steps = []
best_ep_reward = 0 # 记录最大回合奖励
output_agent = None
for i_ep in range(cfg.train_eps):
ep_reward = 0 # 记录一回合内的奖励
ep_step = 0
state = env.reset() # 重置环境,返回初始状态
for _ in range(cfg.max_steps):
ep_step += 1
action = agent.sample_action(state) # 选择动作
next_state, reward, done, _ = env.step(action) # 更新环境,返回transition
agent.memory.push((state, action,agent.log_probs,reward,done)) # 保存transition
state = next_state # 更新下一个状态
agent.update() # 更新智能体
ep_reward += reward # 累加奖励
if done:
break
if (i_ep+1)%cfg.eval_per_episode == 0:
sum_eval_reward = 0
for _ in range(cfg.eval_eps):
eval_ep_reward = 0
state = env.reset()
for _ in range(cfg.max_steps):
action = agent.predict_action(state) # 选择动作
next_state, reward, done, _ = env.step(action) # 更新环境,返回transition
state = next_state # 更新下一个状态
eval_ep_reward += reward # 累加奖励
if done:
break
sum_eval_reward += eval_ep_reward
mean_eval_reward = sum_eval_reward/cfg.eval_eps
if mean_eval_reward >= best_ep_reward:
best_ep_reward = mean_eval_reward
output_agent = copy.deepcopy(agent)
print(f"回合:{i_ep+1}/{cfg.train_eps},奖励:{ep_reward:.2f},评估奖励:{mean_eval_reward:.2f},最佳评估奖励:{best_ep_reward:.2f},更新模型!")
else:
print(f"回合:{i_ep+1}/{cfg.train_eps},奖励:{ep_reward:.2f},评估奖励:{mean_eval_reward:.2f},最佳评估奖励:{best_ep_reward:.2f}")
steps.append(ep_step)
rewards.append(ep_reward)
print("完成训练!")
env.close()
return output_agent,{'rewards':rewards}
DDPG(深度确定性策略梯度)
前面的PPO是基于策略的方法,而DDPG是一种基于价值的方法,它是Q网络的变体,深度是因为用了神经网络;确定性表示 DDPG 输出的是一个确定性的动作,可以用于有连续动作的环境;策略梯度代表的是它用到的是策略网络。其主要的核心内容就是利用确定性的策略来优化行动值函数,找到最优价值的动作。
这里简单介绍一下Q网络:
当我们要评估一个动作好坏的时候,我们就会利用其当前的状态、动作以及未来的状态、动作来计算一些与奖励相关的评估指标,而Q网络就是一种基于神经网络架构的指标生成器,训练完成后,智能体就会选择Q网络输出值最大的值所对应的动作来执行未来的操作。
而DDPG相比Q网络不同之处在于其引入了演员机制
最开始训练的时候,这两个神经网络的参数是随机的。所以评论员最开始是随机打分的,演员也随机输出动作。但是由于有环境反馈的奖励存在,因此评论员的评分会越来越准确,所评判的演员的表现也会越来越好。既然演员是一个神经网络,是我们希望训练好的策略网络,我们就需要计算梯度来更新优化它里面的参数 θ 。简单来说,我们希望调整演员的网络参数,使得评委打分尽可能高。注意,这里的演员是不关注观众的,它只关注评委,它只迎合评委的打分 Q_w(s, a)。
深度 Q 网络的最佳策略是想要学出一个很好的 Q 网 络,学出这个网络之后,我们希望选取的那个动作使 Q 值最大。DDPG 的目的也是求解让 Q 值最大的那 个动作。演员只是为了迎合评委的打分而已,因此我们才说这是策略确定性的,下面来介绍一个小DEMO。
代码实战
这部分内容就涉及到了实战,我是看着莫烦的教程来学习的,具体的链接如下:
【莫烦Python】强化学习 Reinforcement Learning_哔哩哔哩_bilibili
MorvanZhou/RLarm (github.com)
实战的项目是一个简单的机械臂触碰物体的实验
环境
这个环境是基于pyglet绘制的简单的2D环境,其状态空间包含了端点到物体的距离,关节到物体的距离,关节之间的角度共计9个状态,而动作空间则为两个臂的转角即2个动作,奖励自然就是手臂末端与物体之间的距离,同时加入了部分引导奖励比如关节点距离物体的距离等,具体的环境代码如下:
import numpy as np
import pyglet
class ArmEnv(object):
viewer = None
dt = .1 # refresh rate
action_bound = [-1, 1]
goal = {'x': 100., 'y': 100., 'l': 40}
state_dim = 9
action_dim = 2
def __init__(self):
self.arm_info = np.zeros(
2, dtype=[('l', np.float32), ('r', np.float32)])
self.arm_info['l'] = 100 # 2 arms length
self.arm_info['r'] = np.pi/6 # 2 angles information
self.on_goal = 0
def step(self, action):
done = False
action = np.clip(action, *self.action_bound)
self.arm_info['r'] += action * self.dt
self.arm_info['r'] %= np.pi * 2 # normalize
(a1l, a2l) = self.arm_info['l'] # radius, arm length
(a1r, a2r) = self.arm_info['r'] # radian, angle
a1xy = np.array([200., 200.]) # a1 start (x0, y0)
a1xy_ = np.array([np.cos(a1r), np.sin(a1r)]) * a1l + a1xy # a1 end and a2 start (x1, y1)
finger = np.array([np.cos(a1r + a2r), np.sin(a1r + a2r)]) * a2l + a1xy_ # a2 end (x2, y2)
# normalize features dist1 = [(self.goal['x'] - a1xy_[0]) / 400, (self.goal['y'] - a1xy_[1]) / 400]
dist2 = [(self.goal['x'] - finger[0]) / 400, (self.goal['y'] - finger[1]) / 400]
r = -np.sqrt(dist2[0]**2+dist2[1]**2)
# done and reward
if (self.goal['x'] - self.goal['l']/2 < finger[0] < self.goal['x'] + self.goal['l']/2
) and (self.goal['y'] - self.goal['l']/2 < finger[1] < self.goal['y'] + self.goal['l']/2):
r += 1.
self.on_goal += 1
if self.on_goal > 50:
done = True
else:
self.on_goal = 0
# state
s = np.concatenate((a1xy_/200, finger/200, dist1 + dist2, [1. if self.on_goal else 0.]))
return s, r, done
def reset(self):
self.arm_info['r'] = 2 * np.pi * np.random.rand(2)
self.on_goal = 0
(a1l, a2l) = self.arm_info['l'] # radius, arm length
(a1r, a2r) = self.arm_info['r'] # radian, angle
a1xy = np.array([200., 200.]) # a1 start (x0, y0)
a1xy_ = np.array([np.cos(a1r), np.sin(a1r)]) * a1l + a1xy # a1 end and a2 start (x1, y1)
finger = np.array([np.cos(a1r + a2r), np.sin(a1r + a2r)]) * a2l + a1xy_ # a2 end (x2, y2)
# normalize features dist1 = [(self.goal['x'] - a1xy_[0])/400, (self.goal['y'] - a1xy_[1])/400]
dist2 = [(self.goal['x'] - finger[0])/400, (self.goal['y'] - finger[1])/400]
# state
s = np.concatenate((a1xy_/200, finger/200, dist1 + dist2, [1. if self.on_goal else 0.]))
return s
def render(self):
if self.viewer is None:
self.viewer = Viewer(self.arm_info, self.goal)
self.viewer.render()
def sample_action(self):
return np.random.rand(2)-0.5 # two radians
class Viewer(pyglet.window.Window):
bar_thc = 5
def __init__(self, arm_info, goal):
# vsync=False to not use the monitor FPS, we can speed up training
super(Viewer, self).__init__(width=400, height=400, resizable=False, caption='Arm', vsync=False)
pyglet.gl.glClearColor(1, 1, 1, 1)
self.arm_info = arm_info
self.center_coord = np.array([200, 200])
self.batch = pyglet.graphics.Batch() # display whole batch at once
self.goal = self.batch.add(
4, pyglet.gl.GL_QUADS, None, # 4 corners
('v2f', [goal['x'] - goal['l'] / 2, goal['y'] - goal['l'] / 2, # location
goal['x'] - goal['l'] / 2, goal['y'] + goal['l'] / 2,
goal['x'] + goal['l'] / 2, goal['y'] + goal['l'] / 2,
goal['x'] + goal['l'] / 2, goal['y'] - goal['l'] / 2]),
('c3B', (86, 109, 249) * 4)) # color
self.arm1 = self.batch.add(
4, pyglet.gl.GL_QUADS, None,
('v2f', [250, 250, # location
250, 300,
260, 300,
260, 250]),
('c3B', (249, 86, 86) * 4,)) # color
self.arm2 = self.batch.add(
4, pyglet.gl.GL_QUADS, None,
('v2f', [100, 150, # location
100, 160,
200, 160,
200, 150]), ('c3B', (249, 86, 86) * 4,))
def render(self):
self._update_arm()
self.switch_to()
self.dispatch_events()
self.dispatch_event('on_draw')
self.flip()
def on_draw(self):
self.clear()
self.batch.draw()
def _update_arm(self):
(a1l, a2l) = self.arm_info['l'] # radius, arm length
(a1r, a2r) = self.arm_info['r'] # radian, angle
a1xy = self.center_coord # a1 start (x0, y0)
a1xy_ = np.array([np.cos(a1r), np.sin(a1r)]) * a1l + a1xy # a1 end and a2 start (x1, y1)
a2xy_ = np.array([np.cos(a1r+a2r), np.sin(a1r+a2r)]) * a2l + a1xy_ # a2 end (x2, y2)
a1tr, a2tr = np.pi / 2 - self.arm_info['r'][0], np.pi / 2 - self.arm_info['r'].sum()
xy01 = a1xy + np.array([-np.cos(a1tr), np.sin(a1tr)]) * self.bar_thc
xy02 = a1xy + np.array([np.cos(a1tr), -np.sin(a1tr)]) * self.bar_thc
xy11 = a1xy_ + np.array([np.cos(a1tr), -np.sin(a1tr)]) * self.bar_thc
xy12 = a1xy_ + np.array([-np.cos(a1tr), np.sin(a1tr)]) * self.bar_thc
xy11_ = a1xy_ + np.array([np.cos(a2tr), -np.sin(a2tr)]) * self.bar_thc
xy12_ = a1xy_ + np.array([-np.cos(a2tr), np.sin(a2tr)]) * self.bar_thc
xy21 = a2xy_ + np.array([-np.cos(a2tr), np.sin(a2tr)]) * self.bar_thc
xy22 = a2xy_ + np.array([np.cos(a2tr), -np.sin(a2tr)]) * self.bar_thc
self.arm1.vertices = np.concatenate((xy01, xy02, xy11, xy12))
self.arm2.vertices = np.concatenate((xy11_, xy12_, xy21, xy22))
if __name__ == '__main__':
env = ArmEnv()
while True:
env.render()
env.step(env.sample_action())
DDPG算法
关于算法部分,损失函数就是当前Q值和下一Q值之间的MSE,关于存储池,参数替换方法(分别为平滑插值和直接替换两种)见如下代码
import tensorflow as tf
from tensorflow import keras
import numpy as np
class DDPG(object):
def __init__(self, a_dim, s_dim, a_bound, batch_size=32, tau=0.002, gamma=0.8,
lr=0.0001, memory_capacity=9000, soft_replace=True):
super().__init__()
self.batch_size = batch_size
self.tau = tau # soft replacement
self.gamma = gamma # reward discount
self.lr = lr
self.memory_capacity = memory_capacity
# 初始化指针 数据池容量
self.memory = np.zeros((memory_capacity, s_dim * 2 + a_dim + 1), dtype=np.float32)
self.pointer = 0
self.memory_full = False
self._soft_replace = soft_replace
self.a_replace_counter = 0
self.c_replace_counter = 0
# 定义变量
self.a_dim, self.s_dim, self.a_bound = a_dim, s_dim, a_bound[1]
s = keras.Input(shape=(s_dim,)) # 当前状态
s_ = keras.Input(shape=(s_dim,)) # 下一状态
# 创建网络配置变量
self.actor = self._build_actor(s, trainable=True, name='a/eval')
self.actor_ = self._build_actor(s_, trainable=False, name='a/target')
self.critic = self._build_critic(s, trainable=True, name='d/eval')
self.critic_ = self._build_critic(s_, trainable=False, name='d/target')
# 定义优化器和损失函数
self.opt = keras.optimizers.Adam(self.lr, 0.5, 0.9)
self.mse = keras.losses.MeanSquaredError()
def sample_memory(self):
if self.memory_full:
# 如果缓冲区已满 则随机选取数据
indices = np.random.randint(0, self.memory_capacity, size=self.batch_size)
else:
# 否则只能选取一部分数据
indices = np.random.randint(0, self.pointer, size=self.batch_size)
bt = self.memory[indices, :]
bs = bt[:, :self.s_dim]
ba = bt[:, self.s_dim: self.s_dim + self.a_dim]
br = bt[:, -self.s_dim - 1: -self.s_dim]
bs_ = bt[:, -self.s_dim:]
return bs, ba, br, bs_
def learn(self):
self.param_replace()
# 加载训练数据
bs, ba, br, bs_ = self.sample_memory()
with tf.GradientTape() as tape:
a = self.actor(bs) # 由状态获取动作
q = self.critic((bs, a)) # 评论员计算q值
actor_loss = tf.reduce_mean(-q) # 评估网络性能
grads = tape.gradient(actor_loss, self.actor.trainable_variables)
self.opt.apply_gradients(zip(grads, self.actor.trainable_variables))
with tf.GradientTape() as tape:
a_ = self.actor_(bs_) # 由状态获取动作
q_ = br + self.gamma * self.critic_((bs_, a_)) # 计算价值函数
q = self.critic((bs, ba))
critic_loss = self.mse(q_, q) # 损失函数为当前q值与下一q值的mse
grads = tape.gradient(critic_loss, self.critic.trainable_variables)
self.opt.apply_gradients(zip(grads, self.critic.trainable_variables))
return actor_loss.numpy(), critic_loss.numpy()
def store_transition(self, s, a, r, s_):
if s.ndim == 1:
s = np.expand_dims(s, axis=0)
if s_.ndim == 1:
s_ = np.expand_dims(s_, axis=0)
# 数据整合
transition = np.concatenate((s, a, np.array([[r]], ), s_), axis=1)
index = self.pointer % self.memory_capacity
self.memory[index, :] = transition
self.pointer += 1
if self.pointer > self.memory_capacity:
self.memory_full = True
# 定义演员网络
def _build_actor(self, s, trainable, name):
x = keras.layers.Dense(self.s_dim * 50, trainable=trainable)(s)
x = keras.layers.LeakyReLU()(x)
x = keras.layers.Dense(self.s_dim * 50, trainable=trainable)(x)
x = keras.layers.LeakyReLU()(x)
x = keras.layers.Dense(self.a_dim, trainable=trainable)(x)
a = self.a_bound * tf.math.tanh(x)
model = keras.Model(s, a, name=name)
return model
# 定义评论员网络
def _build_critic(self, s, trainable, name):
a = keras.Input(shape=(self.a_dim,))
x = tf.concat([
keras.layers.Dense(self.s_dim * 50, trainable=trainable, activation="relu", use_bias=False)(s),
keras.layers.Dense(self.a_dim * 50, trainable=trainable, activation="relu", use_bias=False)(a)], axis=1)
x = keras.layers.Dense(self.s_dim * 50, trainable=trainable)(x)
x = keras.layers.LeakyReLU()(x)
q = keras.layers.Dense(1, trainable=trainable)(x)
model = keras.Model([s, a], q, name=name)
# model.summary()
return model
# 参数替换方法,软替换:平滑插值 硬替换:直接换
def param_replace(self):
if self._soft_replace:
# 遍历整个网络的所有参数
for la, la_ in zip(self.actor.layers, self.actor_.layers):
for i in range(len(la.weights)):
la_.weights[i] = (1 - self.tau) * la_.weights[i] + self.tau * la.weights[i]
for lc, lc_ in zip(self.critic.layers, self.critic_.layers):
for i in range(len(lc.weights)):
lc_.weights[i] = (1 - self.tau) * lc_.weights[i] + self.tau * lc.weights[i]
else:
self.a_replace_counter += 1
self.c_replace_counter += 1
if self.a_replace_counter % 1000 == 0:
for la, la_ in zip(self.actor.layers, self.actor_.layers):
for i in range(len(la.weights)):
la_.weights[i] = la.weights[i]
self.a_replace_counter = 0
if self.c_replace_counter % 1100 == 0:
for lc, lc_ in zip(self.critic.layers, self.critic_.layers):
for i in range(len(lc.weights)):
lc_.weights[i] = lc.weights[i]
self.c_replace_counter = 0
def act(self, s):
if s.ndim < 2:
s = np.expand_dims(s, axis=0)
a = self.actor.predict(s)
return a
def save(self):
self.actor.save_weights('./actor_weights')
self.critic.save_weights('./critic_weights')
def restore(self):
self.actor.load_weights('./actor_weights')
self.critic.load_weights('./critic_weights')