DQN理论介绍
相关代码已开源至github:github.com/sdycodes/RL…
回顾Q函数的基于Q函数的策略改进
首先回顾第二章中函数的定义以及策略改进的思路。
Q函数的定义: 在状态下执行,之后按照执行,得到的期望累计收益为
在已知的情况下,一种直观的策略改进方法为.
它能够改进策略的原因为
从策略迭代到tabular Q-Learning
策略迭代是model-based方法,我们已知了模型。但是如果没有model,就没法先估计,然后再去求了,所以我们考虑直接估计。
那么怎么估计呢,说到底的值是一个期望,所以一种方法是蒙塔卡罗。但这个问题是必须要把一条完整的episode采样完才能求和计算,而且每个reward都是随机变量的值,过多随机变量求和的方差很大,所以估计可能会很不准(无偏但是方差大)。
另一种方法是时序差分,这是一个很伟大的方法,他首先计算时许差分误差temporal defference error(TD error),计算方法如下
其中,是后序的期望收益,估计方法有很多种,但是有一点可以确定,那就是现在因为没有模型了,也没有,所以只能自举地通过的期望去估计。所以是一个关于的函数
这个式子很容易理解,第一项是函数对当前状态的估计(完全靠猜出来的),减号后面这个整体看作是当前收益加上后面的收益(第一步是真实数据,后面是靠猜的,稍微靠谱了一点)。两者的差值可以看作是考虑了一步真实数据下的误差,所以叫时序差分误差。把这个误差作为更新的目标,然后可以
去更新。
现在的关键是是怎么算的。从策略迭代那里的知识可以知道,可以看作是对这一步期望收益的一个估计(),那则是对接下来的局面按照和环境交互的期望累积收益的估计。
所以一个直接的想法是直接用来估计,即。这个方法叫做SARSA。他还有一个改进版叫期望SARSA,即用代替
上面的方法估计和交互使用了同一个策略,是on-policy的,Q-learning则直接用这个新策略估计,即。和交互策略不一样,所以是一个off-policy的方法。
off-policy的一大优势就是即使更新了学习的策略,采用之前策略产生的样本还是有效的,这一点可以考虑策略迭代中PPO和REINFORCE的差异,PPO是off-policy的,所以凭借importance ratio可以继续使用过去策略采样的样本去训练。REINFORCE则一旦更新,之前策略采样的数据都没法用了。
而在Q-learning中,off-policy的特点被发挥得更好。因为策略始终通过argmaxQ导出,不依赖之前的策略,所以交互的策略是啥其实都不重要,不管咋更新策略,采样的数据都是可以用的,各种历史数据可以看作是一个混合策略交互的结果,而只要argmax就一定可以比之前策略更好。这就比PPO又强大了一些,因为PPO的importance ratio如果太大估计的方差很大会影响性能,而DQN则不存在这个问题,它可以随意选取历史样本看作是一个过去策略的混合,然后argmax来改进策略。这使得经验重放技术、epsilon-greedy的使用在理论上可行,这进一步提升样本的利用率、样本探索的多样性,对于神经网络的训练很重要。所以DQN得广泛使用。
from Q-Learning to DQN
上面可以看到,Q-Learning的在时序差分思想中尝试引入off-policy而产生的新算法。并且因为它对数据应用的强大潜力,让函数神经网络化看起来非常promising。所以DQN其实就是用DNN表示函数。
在Q函数更新上,因为现在是神经网络,所以使用均方误差+梯度下降来训练。非常简单
现在需要考虑这个问题:DQN似乎天然不擅长连续动作的任务,毕竟argmax,离散动作很容易,连续的不好搞啊。
确实是这样,vanila的DQN是离散动作的。但是DQN也有很多变种可以支持连续动作。比如NAF和DDPG架构。也可以固定参数反向传播回去找action,也可以采样找。而对于离散动作,我们一般也不是把a输入网络,而是输出一个长度为的向量,每个位置表示一个,这样取argmax更容易。
vanila DQN
最原始的DQN非常简单,我们用神经网络表示,然后考虑自举的价值估计(如果是episodic的terminal state那么),我们设计的loss就是和之间的均方误差(注意里面的Q是个数值,不参与梯度下降)。引入经验重放技术,可以把所有探索的放在一起(活着保留一部分),然后sample一些mini batch进行曲梯度下降。
动作选择很简单,探索过程是-greedy,训练好了输出动作就是。
def update(self, states: List[np.array], actions: List[np.array], rewards: List[np.array],
next_states: List[np.array], dones: List[bool], infos: List[Any], update_time: int = -1) -> Any:
# calculate td errors
states, rewards, next_states, dones = self.to_tensor(states, rewards, next_states, dones,
self.device, self.state_dim, self.finite)
actions = Tensor(np.array(actions)).to(self.device, torch.long).view(-1, 1)
# 拟合目标y不参与梯度下降
with torch.no_grad():
q_next = self.q_target(next_states).view(-1, self.n_action).max(1, keepdim=False)[0]
q_eval = self.q_learn(states).view(-1, self.n_action).gather(dim=1, index=actions).view(-1)
td_errors = torch.square(q_eval - (rewards.view(-1) + self.gamma * (1. - dones.view(-1)) * q_next)).mean()
self.optimizer.zero_grad()
td_errors.backward()
self.optimizer.step()
self.update_counter += 1
if self.update_target_network_hard(self.q_target, self.q_learn, self.update_counter, self.target_update_freq):
self.update_counter = 0
Tricks of DQN
DQN常见的有下面6个技巧,6个技巧优化彼此不冲突,可以合在一起,称为rainbow方法。
-
1 Double DQN: 传统DQN有两个问题:1是训练不稳定,这很好理解,因为拟合目标里用到了,梯度下降之后发生改变,其实这个估计值本身也不准确了,所以不稳定。2是估计值偏高,这个更严重。回顾Q-learning中选择action的公式:,因为本身可能不准,然后的计算又取了argmax作为估计所以左脚踩右脚就估计过大了。 解决上述问题的方式是Double DQN,就是让估计值,实现方式很简单,就是每隔一段时间,就把从复制一份,平时就定死不动。
-
2 Dueling DQN: 这个方法利用advantage function, ,就是把分成了两个部分,是状态带来的价值,而才是动作带来的价值。在网络设计上,一般前面几层是共享的,就是最后分别输出和的网络有所不同。这样直接把的作为拟合目标会出问题,因为有可能出现undefinedable问题,即直接是0,然后训练成了。所以为了避免这个问题,把换成了的形式,从而输出的的形式。原论文还设计了一种用代替avg的方法,这个在理论上更合理,但一般avg效果更好。
-
3 Noisy Net: 也是为了探索-利用,-greedy是为了鼓励探索,那么我们可以在网络参数上增加高斯噪声,实现输出的动作有更多可能增加探索。在选动作的时候每隔一段时间reset,是update之前reset一下noise。
-
4 Distributional DQN: 他的思路是把函数的输出从单一的值变成了一个value的分布。这个方法很复杂,实现起来也巨复杂。他的网络输出是一个n_action * n_atom的矩阵,表示每个动作a下的分布列,所以选动作的时候需要根据分布列算期望选最大的。然后拟合目标,这个咋加?因为Q输出是概率分布,但r是个值。答案是把横坐标移动,然后clamp保持取值范围。loss function怎么算?这些事详见下面的代码吧。
def update(self, states: List[np.array], actions: List[np.array], rewards: List[np.array],
next_states: List[np.array], dones: List[bool], infos: List[Any], update_time: int = -1) -> None:
batch_size = len(states)
states, rewards, next_states, dones = \
self.to_tensor(states, rewards, next_states, dones, self.device, self.state_dim, self.finite)
actions = Tensor(actions).to(self.device, dtype=torch.long).view(-1, 1)
indices, memory, _ = infos
self.q_target.reset_noise()
if self.n_atom == 1:
q_next_actions = self.q_learned(next_states).detach().argmax(dim=1, keepdim=True)
q_targets = rewards + (1. - dones) * self.gamma * self.q_target(next_states).gather(dim=1, index=q_next_actions)
td_errors = q_targets - self.q_learned(states).gather(dim=1, index=actions)
loss = torch.square(td_errors)
else:
# double DQN的思路找Q(s',a)
probs = self.q_target(next_states).detach()
q_next_actions = (self.q_learned(next_states).detach() * self.quantile).sum(dim=2).argmax(dim=1)
q_s_a_probs = probs[np.arange(batch_size), q_next_actions]
# quantiles是v_min, v_max的均匀分为点,比如[1,2,3,4,...,10]
# new_quantiels就是把所有分为点都按照r + gamma * quantile的方式计算,比如[3,4,5,6,...,14]
new_quantiles = rewards + (1. - dones) * np.power(self.gamma, self.n) * self.quantile.view(-1, self.n_atom)
# clamp之后,数值重新限制在v_min和v_max之间,比如[3,4,5,6...,10]
new_quantiles.clamp_(self.v_min, self.v_max)
# self.delta表示v_max-v_main/(分位点数-1)是平均间隔 单位化,比如[2,3,4, ...,9]
new_quantiles = (new_quantiles - self.v_min) / self.delta
# 对分位点分别上下取整,比如quantile_l = [2,3,4,..5,6,7,9], quantile_u = [2,3,4,5,6...9]
quantile_l = new_quantiles.floor().to(torch.int64)
quantile_u = new_quantiles.ceil().to(torch.int64)
# 这两步最骚 quantile_l > 0说明下取整还不是左端点,quantile_l == quantile_u说明严格落在了某个分为点上
# 对于这种恰好落在分位点的情况,为了避免都计算不上,为啥能避免后面会说
# 对于上面的例子 quantile_l = [1,2,3,...,8], quantile_u = [3,4,5,...,10]
# 相当于落在分位点上强行拆界到相邻的分位点
quantile_l[(quantile_l > 0) * (quantile_l == quantile_u)] -= 1
quantile_u[(quantile_u < (self.n_atom - 1)) * (quantile_l == quantile_u)] += 1
# 这里也是个很妙的技巧,下面用A表示atom即分位点数,B表示batchsize,
# torch.linspace生成的是[0,A,2A,...(B-1)A],view之后expand变成了
# [[0,0,0,0,0...0], [A,A,A,,,A], ..., [(B-1)A,...,(B-1)A]]
offset = torch.linspace(0, (batch_size - 1) * self.n_atom, batch_size).view(
-1, 1).expand(batch_size, self.n_atom).to(actions)
# m的形状和offset一样,B行A列。全是0
m = torch.zeros(batch_size, self.n_atom, dtype=torch.double, device=self.device)
# 这里就要配合前面的quantile了,offset只是起到了找到batch里对应一行的作用。后面加的quantile表示具体加到那个分为点上。加的对象就是q_s_a,相当于把输出值经过差分计算再重新按照分为点分配。
# quantile_u - new_quantiles和new_quantiles - quantile_l是为了实际值按照相邻分为点距离反比分配
# 呼应一下前面如果刚好落在分位点上,那么这两个减法都是0. 反而没加上,所以拆解成相邻两个分为点作为上下界
# 这个办法其实也有问题,这会导致恰好落在分位点上的分布计算不准确
# 不过这种情况发生的概率很低,我这里举的例子为了方便理解,其实是很难发生的。
m.view(-1).index_add_(0, (offset + quantile_l).view(-1), (q_s_a_probs * (quantile_u.double() - new_quantiles)).view(-1))
m.view(-1).index_add_(0, (offset + quantile_u).view(-1), (q_s_a_probs * (new_quantiles - quantile_l.double())).view(-1))
# loss其实是熵,就是Q预测出来的分布和差分出来的分布的差异
td_errors = torch.sum(-m * ((self.q_learned(states).log())[np.arange(batch_size), actions.view(-1)]), dim=1)
loss = td_errors
weights = memory.get_weights(indices)
if weights is not None:
loss = (torch.Tensor(weights).to(self.device) * loss).mean()
else:
loss = loss.mean()
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
self._update_target()
return td_errors.detach().cpu().numpy()
看完代码再重新总结一下思想,预测了下一个状态,从v_min-v_max的分布列,然后现在当前的reward确定了,所以我们希望知道的分布,于是我们计算了一下v_min-v_max经过的操作后变到了哪个范围,然后原来再v这个位置的概率相当于是现在的位置的概率,又因为分位点是整数,所以需要把它分摊到相邻的分位点上。
- 5 multi-step: 这个很简单,其实就是第三章model-free基础技术里的n-step思想。
- 6 重要性经验重放技术 经验重放技术在sample minibatch的时候是均匀的,有没有可能在采样概率的权重上做做文章?有,思路很简单,就是如果一条样本的差分误差大,说明这个样本学的不好,那应该有更高的概率被采样,所以这个概率就用来表示其中是样本的差分误差,超参数调节加权程度。代码先不放了。所有优化全加起来的rainbow有一个实现,等整个专栏完成放出来。
Continuous Action DQN
DQN天然不适合连续问题,因为有一个argmax的过程。而且虽然看起来Q-Net应该把action-state都作为输入,但实际上我们输入的state,输出是每个action的value(对于distributional甚至输出的是每个action的value分布)。这样很方便取argmax。如果是连续的怎么搞,肯定不能直接输出action,这样就是policy gradient思路了,不是DQN。 那怎么搞呢?简单思路有三个
- 1 把连续决策空间离散化,保持输出是每个action(区间)的value(多维的不好弄)
- 2 Q的输入确实是action space + state space,输出只是一个value,然后sample一些action,然后输入到网络。(代价太高,为了取argmax需要多次前向传播)
- 3 反向推导,固定参数和state输入,反向求导找到使输出最大的action(这也太复杂了。。。)
这些简单方法都不是很好,所以提出了一些思路,经典算法有如下两个。
NAF
这个我当时看的时候印象很深刻。他的思路是让,然后把看成一个二次型,网络拟合和,如果X是正定的,那么这个二次型恒小于0,那最优action就是,那问题来了,怎么让是正定的呢?可以分解成,是一个主对角线元素都为正数的下三角阵。所以只要拟合,就可以计算出一个正定矩阵。怎么保证主对角线都是正数?取指数即可。
DDPG Deep Deterministic Policy Gradient
我们前面刚说输出不能是action否则就是policy gradient了,结果DDPG就这么干了。但我们还是认为它属于DQN,原因在于他用一个神经网络拟合并且经验重放、loss和DQN是一样的,Deterministic表明他还有一个actor但是输出是确定动作(只有一个动作)。对于Q网络只要和DQN一样训练就好。对于actor,他的目标就是最大化,所以loss=,这样求梯度就是把Q固定带入然后对求导。他也不是传统的AC算法,因为actor输出的是确定的而不是分布。另外因为输出是确定的action,所以探索的时候加一点正态分布的noise鼓励探索。