主题
TD3:双延迟深度确定性策略梯度
TD3(Twin Delayed DDPG,Fujimoto et al., 2018)针对 DDPG 中函数近似误差导致的 Q 值高估和训练不稳定问题,提出了三项简单而有效的改进:使用两个 Critic 取较小值(Clipped Double Q-learning)、延迟更新 Actor,以及目标策略平滑。这些改动只需修改几行代码,却让 TD3 在 MuJoCo 连续控制基准上大幅超过 DDPG,成为连续控制中强有力的基线之一。
动机:Actor-Critic 中的高估问题
在 Double DQN 中我们看到,对带噪声的估计取最大值会导致高估。在 Actor-Critic 中,虽然没有显式的
他们还发现 Double DQN 的做法(在线网络选动作、目标网络评估)在 Actor-Critic 中效果有限,因为策略变化缓慢,在线网络和目标网络太相似,无法提供独立的估计。
三项关键改进
Clipped Double Q-learning
训练两个独立的 Critic
两个 Critic 都回归到同一个目标
延迟策略更新
如果 Critic 的估计还不准,Actor 跟着不准的梯度更新只会让情况更糟(策略变差 → 价值估计更差)。TD3 让 Critic 每更新
目标策略平滑
确定性策略容易对 Q 函数中尖锐的峰值过拟合。TD3 在计算目标时,给目标动作加上裁剪过的噪声:
这相当于让 Q 值在动作的小邻域内取平均,起到正则化的作用:相似的动作应该有相似的价值。常用
算法流程
text
初始化 Actor μ_θ、两个 Critic Q_φ1, Q_φ2 及其目标网络,回放缓冲区 D
对每一步 t:
a = clip(μ_θ(s) + N(0, σ_explore)),执行并存入 D
从 D 采样小批量 (s, a, r, s', d)
ã' = clip(μ_θ⁻(s') + clip(N(0, σ), −c, c)) # 目标策略平滑
y = r + γ(1−d) · min(Q_φ1⁻(s', ã'), Q_φ2⁻(s', ã')) # 取较小值
更新两个 Critic:最小化 Σ_i (Q_φi(s,a) − y)²
若 t mod d == 0: # 延迟更新
更新 Actor:最大化 Q_φ1(s, μ_θ(s))
软更新所有目标网络1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
代码实现
TD3 的代码在 DDPG 的基础上只改了三处(双 Critic、目标平滑、延迟更新),其余设置完全相同,便于对比。
py
"""TD3:Pendulum-v1(双 Critic 取小 + 延迟策略更新 + 目标策略平滑)。"""
import copy
import random
import gymnasium as gym
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
GAMMA, TAU, LR_A, LR_C, BATCH = 0.99, 0.005, 1e-3, 1e-3, 256
TOTAL_STEPS, START_STEPS, EXPL_NOISE = 20_000, 1_000, 0.1
POLICY_NOISE, NOISE_CLIP, POLICY_DELAY = 0.2, 0.5, 2
random.seed(0); np.random.seed(0); torch.manual_seed(0)
env = gym.make("Pendulum-v1")
env.action_space.seed(0) # 预热阶段的随机动作也要可复现
obs_dim, act_dim = env.observation_space.shape[0], env.action_space.shape[0]
max_action = float(env.action_space.high[0]) # Pendulum 的力矩范围 [-2, 2]
def mlp(i, o, out_act=None):
layers = [nn.Linear(i, 256), nn.ReLU(), nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, o)]
return nn.Sequential(*layers, *( [out_act] if out_act else [] ))
actor = mlp(obs_dim, act_dim, nn.Tanh()) # μ(s) ∈ [-1, 1],再乘 max_action
critic1, critic2 = mlp(obs_dim + act_dim, 1), mlp(obs_dim + act_dim, 1) # 两个独立的 Q 网络
actor_t, critic1_t, critic2_t = copy.deepcopy(actor), copy.deepcopy(critic1), copy.deepcopy(critic2)
opt_a = torch.optim.Adam(actor.parameters(), lr=LR_A)
opt_c = torch.optim.Adam(list(critic1.parameters()) + list(critic2.parameters()), lr=LR_C)
class Replay:
def __init__(self, size=100_000):
self.s = np.zeros((size, obs_dim), np.float32); self.a = np.zeros((size, act_dim), np.float32)
self.r = np.zeros((size, 1), np.float32); self.s2 = np.zeros((size, obs_dim), np.float32)
self.d = np.zeros((size, 1), np.float32); self.ptr, self.n, self.size = 0, 0, size
def add(self, s, a, r, s2, d):
self.s[self.ptr], self.a[self.ptr], self.r[self.ptr], self.s2[self.ptr], self.d[self.ptr] = s, a, r, s2, d
self.ptr = (self.ptr + 1) % self.size; self.n = min(self.n + 1, self.size)
def sample(self, k):
i = np.random.randint(0, self.n, k)
return [torch.as_tensor(x[i]) for x in (self.s, self.a, self.r, self.s2, self.d)]
def soft_update(net, target):
with torch.no_grad():
for p, tp in zip(net.parameters(), target.parameters()):
tp.mul_(1 - TAU).add_(TAU * p)
buf = Replay()
obs, _ = env.reset(seed=0)
ep_ret, returns = 0.0, []
for step in range(TOTAL_STEPS):
if step < START_STEPS:
a = env.action_space.sample() # 预热:均匀随机动作
else:
with torch.no_grad():
a = max_action * actor(torch.as_tensor(obs, dtype=torch.float32)).numpy()
a = np.clip(a + np.random.normal(0, EXPL_NOISE * max_action, act_dim), -max_action, max_action)
obs2, r, term, trunc, _ = env.step(a)
buf.add(obs, a / max_action, r, obs2, float(term)) # 存归一化后的动作
obs, ep_ret = obs2, ep_ret + r
if term or trunc:
returns.append(ep_ret); obs, _ = env.reset(); ep_ret = 0.0
if len(returns) % 10 == 0:
print(f"step {step + 1:6d} | 最近10回合平均回报 {np.mean(returns[-10:]):8.1f}")
if step >= START_STEPS:
s, a_b, r_b, s2, d = buf.sample(BATCH)
with torch.no_grad():
# 目标策略平滑:给目标动作加裁剪过的高斯噪声(动作已归一化到 [-1, 1])
noise = (torch.randn_like(a_b) * POLICY_NOISE).clamp(-NOISE_CLIP, NOISE_CLIP)
a2 = (actor_t(s2) + noise).clamp(-1, 1)
sa2 = torch.cat([s2, a2], 1)
y = r_b + GAMMA * (1 - d) * torch.min(critic1_t(sa2), critic2_t(sa2)) # 取两个 Q 的较小值
sa = torch.cat([s, a_b], 1)
c_loss = F.mse_loss(critic1(sa), y) + F.mse_loss(critic2(sa), y)
opt_c.zero_grad(); c_loss.backward(); opt_c.step()
if step % POLICY_DELAY == 0: # 延迟更新 Actor 与目标网络
a_loss = -critic1(torch.cat([s, actor(s)], 1)).mean()
opt_a.zero_grad(); a_loss.backward(); opt_a.step()
soft_update(actor, actor_t); soft_update(critic1, critic1_t); soft_update(critic2, critic2_t)
# 确定性策略评估
scores = []
for i in range(10):
o, _ = env.reset(seed=1000 + i); done, total = False, 0.0
while not done:
with torch.no_grad():
o, r, te, tr, _ = env.step(max_action * actor(torch.as_tensor(o, dtype=torch.float32)).numpy())
total += r; done = te or tr
scores.append(total)
print(f"确定性策略评估(10 回合)平均回报: {np.mean(scores):.1f}")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
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
实际运行输出(Pendulum-v1,2 万步):
text
step 2000 | 最近10回合平均回报 -1348.5
step 4000 | 最近10回合平均回报 -1309.6
step 6000 | 最近10回合平均回报 -582.4
step 8000 | 最近10回合平均回报 -133.3
step 10000 | 最近10回合平均回报 -190.0
step 12000 | 最近10回合平均回报 -147.8
step 14000 | 最近10回合平均回报 -177.6
step 16000 | 最近10回合平均回报 -187.7
step 18000 | 最近10回合平均回报 -144.4
step 20000 | 最近10回合平均回报 -202.4
确定性策略评估(10 回合)平均回报: -169.11
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
TD3 在约 8000 步时学会了任务,确定性策略评估平均为 −169.1,与 DDPG(−168.0)相当。由于延迟更新,TD3 的 Actor 更新次数只有 DDPG 的一半,早期学得稍慢一些。在 Pendulum 这样简单的任务上,DDPG 本身就能工作,TD3 的优势不明显;论文中 TD3 的显著优势来自 HalfCheetah、Hopper、Walker2d、Ant 等更难的 MuJoCo 任务(单个种子的小实验不足以比较两者的优劣)。
与 DDPG、SAC 的比较
| DDPG | TD3 | SAC | |
|---|---|---|---|
| 策略 | 确定性 | 确定性 | 随机(高斯 + tanh) |
| Critic 数量 | 1 | 2(取小) | 2(取小) |
| 探索 | 外加噪声 | 外加噪声 | 最大熵,内在随机性 |
| Actor 更新频率 | 每步 | 每 2 步 | 每步 |
| 目标策略平滑 | 无 | 有 | 随机策略自带 |
| 超参数敏感性 | 高 | 中 | 低(自动调温) |
实践技巧
- 作者官方代码仓库(sfujim/TD3)当前的默认超参数:学习率
、批量 256、 、探索噪声 0.1、目标噪声 0.2、噪声裁剪 0.5、延迟 、预热 25000 步(MuJoCo)。论文原版的部分数值(如学习率 、批量 100)与此略有不同。 - 官方实现:github.com/sfujim/TD3,代码简洁,值得阅读。
- 目标策略平滑的噪声大小要与动作范围匹配:本例中动作已归一化到
。
常见误区
- 两个 Critic 用了不同的目标:两者都应回归到同一个
(取最小值后的结果)。 - Actor 用两个 Critic 的最小值更新:原论文只用
,用最小值也可以但不是标准做法。 - 延迟更新时忘记同时延迟目标网络的软更新。
- 目标平滑的噪声不裁剪:可能产生过大的扰动,偏离真实动作。
小结
- TD3 用 Clipped Double Q-learning 抑制高估,用延迟更新减少误差累积,用目标策略平滑正则化 Q 函数。
- 三项改动都很简单,但对稳定性提升显著。
- 在简单任务上与 DDPG 差别不大,在复杂连续控制任务上优势明显。
练习题
- 分别去掉三项改进中的一项(消融实验),在 Pendulum 上观察影响。
- 记录训练过程中两个 Critic 的 Q 估计差异。
- 把延迟
改为 1、2、5,比较结果。 - 为什么取两个 Critic 的最小值会导致低估?低估为什么比高估更“安全”?
- 在
HalfCheetah-v5(需安装 MuJoCo)上比较 DDPG 和 TD3(需要较长训练时间)。
参考文献
- Fujimoto, S., van Hoof, H., & Meger, D. (2018). Addressing Function Approximation Error in Actor-Critic Methods. ICML. arXiv:1802.09477
- van Hasselt, H. (2010). Double Q-learning. NeurIPS.
- Lillicrap, T. P. et al. (2016). Continuous control with deep reinforcement learning. ICLR. arXiv:1509.02971