参数化值函数近似与 DQN

参数化值函数近似

​ Q-learning 算法中,我们存储每个状态下所有动作的 QQ 值的表格。表格动作价值 Q(s,a)Q(s, a) 表示在状态 ss 下选择动作 aa 然后继续遵循某一策略预期得到的期望回报。然而,这种用表格存储动作价值的做法只在环境的状态和动作都是离散的,并且空间都比较小的情况下适用

当处理大规模 MDP 或者连续状态空间时,采用的方式往往是参数化值函数估计的方式:

Vθ(s)Vπ(s),Qθ(s,a)Qπ(s,a)V_\theta(s) \approx V^\pi(s), \quad Q_\theta(s,a) \approx Q^\pi(s,a)

其中这时候就采用了从函数的级别去近似,而不是对维护一张表的级别近似,这里提出两个核心的问题来加深理解

为什么使用参数化模型

​ 这里先要明确强化学习中收集的数据有两个特点:时变性与非稳定性

​ 我们考虑时变性:策略在迭代过程中,由于策略的改变,占用度量也会发生改变。使用梯度下降法的参数化模型,可以有利于模型适应新的数据(梯度下降法的灾难性遗忘的问题反而变成了它的优势了)。

​ 同时参数化还有一个好处在于可以将可见的状态泛化到没见过的状态上,而参数化模型比树模型的泛化能力更强更适合

对于 off-policy 算法,参数化值函数是对于什么策略的估计

​ 我们知道 off-policy 算法有两种策略,行为策略和目标策略,我们使用参数化值函数近似是为了在决策过程中对状态的估计。行为策略的目的是为了探索新的数据(explore),而目标策略才是真正需要去近似的东西。因此选择目标策略的估计而非行为策略

参数化模型的学习方式:

​ 根据我们的学习目标来确定损失函数,学习目标:找到参数向量 θ\theta 最小化值函数近似值与真实值之间的均方误差,,优化方式为:

J(θ)=Eπ[12(Vπ(s)Vθ(s))2]J(θ)θ=Eπ[(Vπ(s)Vθ(s))Vθ(s)θ]θθαJ(θ)θ=θ+α(Vπ(s)Vθ(s))Vθ(s)θJ(\theta) = \mathbb{E}_\pi \left[ \frac{1}{2} \left( V^{\pi}(s) - V_{\theta}(s) \right)^2 \right] \\ \Rightarrow -\frac{\partial J(\theta)}{\partial \theta} = \mathbb{E}_{\pi} \left[ \left( V^{\pi}(s) - V_{\theta}(s) \right) \frac{\partial V_{\theta}(s)}{\partial \theta} \right] \\ \theta \leftarrow \theta - \alpha \frac{\partial J(\theta)}{\partial \theta} = \theta + \alpha \left( V^{\pi}(s) - V_{\theta}(s) \right) \frac{\partial V_{\theta}(s)}{\partial \theta}

此事还需要估计真实的 Vπ(s)V^\pi(s),这时候又可以使用前面的两种估计方法:蒙特卡洛方法和时序差分方法:

θ{θ+α(1ni=1ngiVθ(s))Vθ(s)θθ+α(rt+γVθ(st+1)Vθ(s))Vθ(s)θ\theta \leftarrow \left\{ \begin{array}{c} \theta + \alpha \left( \frac{1}{n}\sum_{i=1}^n g_i - V_{\theta}(s) \right) \frac{\partial V_{\theta}(s)}{\partial \theta} \\ \theta + \alpha \left( r_t + \gamma V_\theta(s_{t+1}) - V_{\theta}(s) \right) \frac{\partial V_{\theta}(s)}{\partial \theta} \\ \end{array} \right.

应用:Deep Q-Network(DQN)

​ Q-learning

​ 由于 DQN 的环境是会实时改变的,在更新网络参数的同时目标也在不断地改变,这非常容易造成神经网络训练的不稳定性。为了解决这一问题,DQN 便使用了目标网络( target network)的思想: 既然在训练过程中 Q 网络的不断更新会导致目标不断发生改变,不如暂时先将 TD 误差目标中 的 Q 网络固定住。为了实现这一思想,我们需要利用两套 Q 网络

​ 同时我们考虑神经网络输入什么输出什么:

  • 一种方法是:神经网络的输入是状态 ss 和动作 aa,然后输出一个标量,表示在状态 ss 下 采取动作 aa 能获得的价值
  • 若动作是离散(有限)的,还可以只将状态 ss 输入到神经网络中,使其同时输出每一 个动作的 QQ 值。通常 DQN(以及 Q-learning)只能处理动作离散的情况,因为在函数 Q 的更新过 程中有 maxamax_a 这一操作

优化过程如下,第 ii 轮迭代中的损失函数:

Li(θi)=E(s,a,r,s)U(D)[(r+γmaxaQ(s,a;θi))目标Q值Q(s,a;θi)预测Q值]2L_i(\theta_i) = \mathbb{E}_{(s,a,r,s') \sim U(D)} \left[ \underbrace{\left(r + \gamma \max_{a'} Q(s', a'; \theta_i^-)\right)}_{\text{目标Q值}} - \underbrace{Q(s, a; \theta_i)}_{\text{预测Q值}} \right]^2

  • θi\theta_i 是第 ii 轮迭代中将要更新的网络参数,通过标准的反向传播算法进行更新
  • θi\theta_i^- 是目标网络参数,仅在 θi\theta_i 每更新 CC 步后进行更新
  • (s,a,r,s)U(D)(s,a,r,s') \sim U(D):样本从经验池 DD 中均匀抽样,这样做可以避免在近期经验上过拟合

有几点概念需要强调:

延迟更新:

目标网络参数 θi\theta_i^-CC 步才更新一次,其作用包括:

  • 避免目标值剧烈波动:若目标网络与当前网络参数同步更新(θi=θi\theta_i^- = \theta_i),目标 QQ 值会随每次参数调整剧烈变化,导致梯度方向频繁震荡。通过固定θi\theta_i^-的短期参数,目标值在CC步内保持稳定,为优化提供平稳目标
  • 缓解自举偏差:同步更新会导致目标网络与当前网络高度相关,可能放大Q值的高估误差(如“过乐观估计”)。延迟更新通过参数滞后性打破这种耦合,减少误差累积

经验回放:

​ 在一般的有监督学习中,假设训练数据是独立同分布的,每一个训练数 据会被使用多次在原来的 Q-learning 算法中,每一个数据只会用来更新一次Q 值。同样地 DQN 算法采用了经验回放(experience replay)方法,具体做法为维护一个回放缓冲区,将每次从环境中采样得到的四元组数据(状态、动作、奖励、 下一状态)存储到回放缓冲区中,训练 Q 网络的时候再从回放缓冲区中随机采样若干数据来进行训练

  • 使样本满足独立假设。在MDP 中交互采样得到的数据本身不满足独立假设,因为这一时刻的状态和上一时刻的状态有关。非独立同分布的数据对训练神经网络有很大的影响,会使神经网络拟合到最近训练的数据上。采用经验回放可以一定程度上打破样本之间的相关性
  • 提高样本效率。每一个样本可以被使用多次

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import random
import gymnasium as gym
import numpy as np
import collections
from tqdm import tqdm
import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt

class ReplayBuffer(object):
''' 经验回放池 '''
def __init__(self, capacity):
self.buffer = collections.deque(maxlen=capacity) # 队列,先进先出

def add(self, state, action, reward, next_state, done): # 将数据加入buffer
self.buffer.append((state, action, reward, next_state, done))

def sample(self, batch_size): # 从buffer中采样数据,数量为batch_size
# 用于从指定的序列(列表、元组、字符串等)中随机无放回地抽取指定数量的元素
transitions = random.sample(self.buffer, batch_size)
# zip(*):将列表中的每个元组拆开,然后将相同位置的元素配对在一起
state, action, reward, next_state, done = zip(*transitions)
return np.array(state), action, reward, np.array(next_state), done

def size(self): # 目前buffer中数据的数量
return len(self.buffer)

class Qnet(torch.nn.Module):
def __init__(self, state_dim, hidden_dim, action_dim):
super(Qnet, self).__init__()
self.fc1 = torch.nn.Linear(state_dim, hidden_dim)
self.fc2 = torch.nn.Linear(hidden_dim, action_dim)

def forward(self, x):
x = F.relu(self.fc1(x)) # 隐藏层使用ReLU激活函数
return self.fc2(x)

class DQN(object):
''' DQN算法 '''
def __init__(self, state_dim, hidden_dim, action_dim, learning_rate, gamma,
epsilon, target_update, device):
self.action_dim = action_dim

self.q_net = Qnet(state_dim, hidden_dim, self.action_dim).to(device) # Q网络

# 目标网络
self.target_q_net = Qnet(state_dim, hidden_dim, self.action_dim).to(device)
self.optimizer = torch.optim.Adam(self.q_net.parameters(), lr=learning_rate)
self.gamma = gamma # 折扣因子
self.epsilon = epsilon # epsilon-贪婪策略
self.target_update = target_update # 目标网络更新频率
self.count = 0 # 计数器,记录更新次数
self.device = device

def take_action(self, state): # epsilon-贪婪策略采取动作
if np.random.random() < self.epsilon:
action = np.random.randint(self.action_dim)
else:
state = torch.tensor([state], dtype=torch.float).to(self.device)
action = self.q_net(state).argmax().item()
return action

def update(self, transition_dict):
states = torch.tensor(transition_dict['states'], dtype=torch.float).to(self.device)
actions = torch.tensor(transition_dict['actions']).view(-1, 1).to(self.device)
rewards = torch.tensor(transition_dict['rewards'], dtype=torch.float).view(-1, 1).to(self.device)
next_states = torch.tensor(transition_dict['next_states'], dtype=torch.float).to(self.device)
dones = torch.tensor(transition_dict['dones'], dtype=torch.float).view(-1, 1).to(self.device)

q_values = self.q_net(states).gather(1, actions) # Q值
# 下个状态的最大Q值
max_next_q_values = self.target_q_net(next_states).max(1)[0].view(-1, 1)

# 计算目标Q值,当一个episode结束时(dones == True),未来的奖励应当被忽略,因此这部分乘以0。
# 相反,如果游戏还在继续(dones == False),则乘以1,保留对未来奖励的考虑
q_targets = rewards + self.gamma * max_next_q_values * (1 - dones) # TD误差目标
dqn_loss = torch.mean(F.mse_loss(q_values, q_targets)) # 均方误差损失函数

self.optimizer.zero_grad()
dqn_loss.backward()
self.optimizer.step()

if self.count % self.target_update == 0:
self.target_q_net.load_state_dict(self.q_net.state_dict()) # 更新目标网络
self.count += 1


lr = 2e-3
num_episodes = 500
hidden_dim = 128
gamma = 0.98
epsilon = 0.01
target_update = 10
buffer_size = 10000
minimal_size = 500
batch_size = 64
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")

env_name = 'CartPole-v1'
env = gym.make(env_name)
random.seed(0)
np.random.seed(0)
# env.seed(0)
torch.manual_seed(0)
replay_buffer = ReplayBuffer(buffer_size)
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
agent = DQN(state_dim, hidden_dim, action_dim, lr, gamma, epsilon,
target_update, device)

return_list = []
for i in range(10):
with tqdm(total=int(num_episodes / 10), desc='Iteration %d' % i) as pbar:
for i_episode in range(int(num_episodes / 10)):
episode_return = 0
state = env.reset()[0]
done = False
while not done:
action = agent.take_action(state)
next_state, reward, done, _, _ = env.step(action)
replay_buffer.add(state, action, reward, next_state, done)
state = next_state
episode_return += reward

# 当buffer数据的数量超过一定值后,才进行Q网络训练
if replay_buffer.size() > minimal_size:
b_s, b_a, b_r, b_ns, b_d = replay_buffer.sample(batch_size)
transition_dict = {
'states': b_s,
'actions': b_a,
'next_states': b_ns,
'rewards': b_r,
'dones': b_d
}
agent.update(transition_dict)

return_list.append(episode_return)
if (i_episode + 1) % 10 == 0:
pbar.set_postfix({
'episode':
'%d' % (num_episodes / 10 * i + i_episode + 1),
'return':
'%.3f' % np.mean(return_list[-10:])
})
pbar.update(1)

episodes_list = list(range(len(return_list)))
plt.plot(episodes_list, return_list)
plt.xlabel('Episodes')
plt.ylabel('Returns')
plt.title('DQN on {}'.format(env_name))
plt.show()

DQN的改进算法

Double DQN

​ 普通的 DQN 算法通常会导致对 Q 值的过高估计(overestimation)。原因在与 Q 网络的自举(bootstrapping):

​ 传统 DQN 优化的 TD 误差目标为:

r+γmaxaAQw(s,a)r+\gamma \max_{a'\in A}Q_{w^-}(s',a')

其中,maxaQw(s,a)\max_{a'}Q_{w-}(s',a') 由参数为 ww^- 的目标网络计算得出,其中 max\max 操作可以视为由两部分构成:

  • 选取状态 ss' 下的最优动作 a=argmaxaQa^*=\arg \max_{a'}Q
  • 再计算该动作对应的价值 Qw(s,a)Q_{w^-}(s', a')

​ 当这两部分共同采用同一套 Q 网络的时候,每次得到的都是神经网络中估算所有动作状态的最大值,考虑神经网络会不断累计正误差,DQN 的过高估计问题会非常严重,本质上的原因在于:

  • 探索不足:探索不足导致产生的样本无法反应出概率分布: π:p(s,rs,a)\pi:p\left( s',r {\mid} s,a \right)
  1. 值函数存在方差: Q(s,a)Q(s,a) 的更新存在方差,不可能一步就更新到目标值,它实际的形式应该是围绕目标值上下波动,但是波动的幅度越来越小

  2. 贪心思想将高估的值进行了传递:训练过程中,实际的 Q(s,a)Q\left( s',a' \right) 其实是一个估计值,它和最优的 Q(s,a)Q^{*}\left( s',a' \right) 存在误差,存在一些动作对应的值被高估,而贪心思想又在更新 Q(s,a)Q(s,a) 时使用了下一个状态 ss' 中最大的 maxaQ(s,a)\max_{a'}Q\left( s',a' \right) ,恰好会使得那些被高估的动作的再更新时不断反向传播到之前的状态

​ 为了解决这一问题,Double DQN 算法提出利用两套独立训练的神经网络来估算 maxa‘’Q(s,a)\max\limits_{ a‘’}Q^*\left(s', a' \right),具体做法是利用 一套神经网络 QωQ_{\omega} 的输出来选取价值最大的动作,但在使用该动作的价值时,用另一套神经网 络 QarQ_{ar} 来计算该动作的价值。这样即使其中一套神经网络的某个动作存在比较严重的过高估计问题,由于另一套神经网络的存在,这个动作最终使用的 QQ 值也不会存在很大的过高估计

优化目标:

​ 我们设选取动作网络参数为 ww(训练网络),预测 Q 值网络参数为 ww^-(目标网络),则通过之前的分析,我们的优化目标为:

r+γQw(s,argmaxaQw(s,a))r+\gamma Q_{w^-}(s', \arg\max_{a'}Q_{w}(s', a'))

Dueling DQN

​ Dueling DQN是 DQN的另一种改进算法,它在传统 DQN 的基础上只进行了微小的改动,但能大幅提升 DQN的表现

​ 它从另外一个方面考虑 DQN 的问题:每次更新 Q 网络的时候,我们只是对网络中的最大值进行反向传播,而不管其他的 Q 值,这导致不同动作的 Q 值可能相差过大:

普通DQN

Dueling-DQN

因此在 Dueling DQN 中,引入优势函数的概念:

​ 将动作价值函数 QQ 减去状态价值函数 VV 的结果定义为优势函数 AA,即 A(s,a)=Q(s,a)V(s)A(s,a)=Q(s,a)-V(s),则在同一状态下,所有动作的优势值之和为 0(因为所有动作价值的期望就是这个状态的状态价值)

​ 因此 Q 网络被建模为:

Qη,α,β(s,a)=Vη,α(s)+Aη,β(s,a)Q_{\eta, \alpha, \beta}(s,a)=V_{\eta, \alpha}(s)+A_{\eta, \beta}(s,a)

DQN与dueling-DQN网络结构差异

Dueling DQN 的问题:

​ 对于公式 Qη,α,β(s,a)=Vη,α(s)+Aη,β(s,a)Q_{\eta,\alpha,\beta}\left( s,a\right) = V_{\eta,\alpha}\left( s \right) + A_{\eta,\beta}\left( s,a\right),它存在对于 VV 值和 AA 值建模不唯一性的问题。例如,对于同样的 QQ 值,如果将 VV 值加上任意大小的常数 CC ,再将所有 AA 值减去 CC ,则得到的 QQ 值依然不变,这就导致了训练的不稳定性。为了解决这一问题,Dueling DQN 强制最优动作的优势函数的实际输出为 0 , 即:

Q_{\eta,\alpha,\beta}\left(s,a \right) = V_{\eta,\alpha}\left( {s} \right) + A_{\eta,\beta}\left( {s}, {a} \right) {-} {\max}\limits_{ {d}}A_{\eta,\beta}\left( {s}, a' \right)

此时 V(s)=maxaQ(s,a)V(s) = \max_aQ(s,a) ,可以确保 VV 值建模的唯一性。在实现过程中,我们还可以用平均操作代替最大化操作,即:

Qη,α,β(s,a)=Vη,α(s)+Aη,β(s,a)1AdAη,β(s,a)Q_{\eta,\alpha,\beta}\left(s,a\right) = V_{\eta,\alpha}\left( s\right) + A_{\eta,\beta}\left(s, a \right) - \frac{1}{|A|}\sum_dA_{\eta,\beta}\left(s,a' \right)

​ 此时 V(s)=1AdQ(s,a)V(s) = \frac{1}{|A|}\sum_d Q( s,a'),虽然它不再满足贝尔曼最优方程,但实际应用时更加稳定