Xiaohei's Blog
headpicBlur image

前言#

到了连续控制(continuous action space),你会发现 DQN 这套“输出离散动作 Q 值”的方式没那么顺手了。

我第一次做连续控制任务时最大的感受是:动作不再是“选 A 还是选 B”,而是“输出一段连续向量”。这时再用 DQN 去离散化动作,往往既不优雅也不高效。更顺的路线是 Actor-Critic:Actor 直接产出连续动作,Critic 负责评价这个动作在当前状态下到底值不值。

我自己当时的“翻车现场”也很典型:把动作离散成 11 档、21 档,看起来很细了,但策略依然像在抖腿——要么动作不够细导致控制很粗糙,要么离散太细导致动作维度膨胀,学习变得又慢又不稳定。更要命的是,reward 曲线的抖动和环境随机性混在一起,你根本不知道是探索问题、还是值估计的问题。于是我就彻底转到 Actor-Critic:动作直接从网络里出来,探索要么靠噪声(DDPG/TD3),要么靠熵正则(SAC),思路一下子清爽很多。

这一篇我就沿着我自己的实践顺序,把三套最常用的 off-policy 连续控制算法串起来:DDPG 是最基础的 deterministic Actor-Critic;TD3 主要是修 DDPG 容易高估、容易抖的问题;SAC 则在目标函数里把“熵”引入进来,让探索更自然、训练更稳。

这篇我按“你写代码时要实现哪些模块”来整理。

DDPG:deterministic Actor-Critic 的最小工程结构#

DDPG 的结构非常“工科”:Actor 输出动作 aa,Critic 评估 (s,a)(s,a) 的 Q 值。这里有一个我认为必须牢牢记住的关键点:Critic 的输入是 state + action 的拼接,不是只喂 state。

网络结构#

  • Actor(策略网络):输入 state,输出连续动作;输出层常用 tanh 把动作限制到 [1,1][-1,1](再映射到环境动作范围)
  • Critic(价值网络):输入 [state, action] 拼接向量,输出 Q(s,a)Q(s,a)

实现时我会额外注意一个特别容易被忽略的小细节:Actor/Critic 的输出层初始化不要太“放飞”。如果初始动作幅度很大,或者 Critic 初始 Q 值异常夸张,训练经常会在前几千步就开始发散。很多实现会对最后一层的权重做更小范围的初始化,就是为了解决这个。

Replay Buffer#

和 DQN 类似(push+sample),但 transition 里的 action 是连续向量。

探索噪声#

DDPG 是确定性策略(deterministic policy),所以探索通常靠给动作加噪声

  • action = actor(state) + noise

我的习惯是:训练阶段加噪声促进探索;测试阶段完全关掉噪声,只看学到的确定性策略到底能拿多少回报。否则你会出现一个很“玄学”的现象:测试时看起来也很随机,根本不知道模型到底学没学会。

update:先 critic 再 actor,再软更新 target#

DDPG 的 update() 我通常按固定顺序写死:

  1. 从 replay 采样一批 transition
  2. 用贝尔曼方程算 critic target
  3. 更新 critic(最小化 TD error)
  4. 更新 actor(最大化 critic 对当前 actor 动作的 Q 值)
  5. 更新各自目标网络参数(soft update)

TD3:Twin Delayed DDPG(解决高估与不稳定)#

当我用 DDPG 在更复杂的连续控制任务上跑起来以后,很常见的痛点是:回报曲线一会儿很好看,一会儿又突然崩掉;或者 Critic 的 Q 值虚高,导致 Actor 被带偏。TD3 的出现基本就是为了解决这些问题,它把 Double Q-learning 的“取较小估计更保守”的思想带进了 DDPG。

工程上 TD3 的“显眼差别”主要体现在三件事:

  1. 双 Critic:两个 Q1,Q2Q_1, Q_2,算 target 时取较小者缓解过估计
  2. 延迟更新 Actor:critic 更新更频繁,actor 慢一点(delayed policy update) 3.(常见实现里还有 target policy smoothing:对 target 动作加一点小噪声)

实际写代码时,你只要把“双 critic”写对、把“actor 延迟更新”写对,就能看到稳定性明显提升;其它技巧(比如 target policy smoothing)可以作为锦上添花后面再加。

SAC:Soft Actor-Critic(熵正则化让探索更“软”)#

SAC 是我在连续控制里最喜欢的一套,因为它对“探索”这件事的处理更自然:不是靠外部噪声硬塞随机性,而是在优化目标里直接鼓励策略保持一定随机性(最大化熵)。很多时候这会让训练更稳,也更不容易陷入坏的局部最优。

从工程上看,SAC 依然离不开三块:网络、replay、update。常见的网络拆分是 ValueNet / QNet / PolicyNet(也有实现会省略 ValueNet,用 twin Q + target V 替代,但主旨不变)。

你选哪种网络拆法都可以,关键是 update 的目标里要正确引入熵正则项,以及 Policy 更新时要能拿到 action 的 log_prob

SAC 的直觉#

如果你把 PPO 理解成“动作概率分布 + on-policy 约束”,那 SAC 更像:

在 off-policy 的 Q 学习里,鼓励策略保持一定随机性(最大化熵),让探索更稳定。

工程差异点:PolicyNet 的动作采样#

我在实现 SAC 时,会把策略网络的动作相关函数明确拆成两种:

evaluate() 用于训练:返回采样动作以及它的 log_prob(因为熵项要用到它);get_action() 用于交互/测试:给定 state 输出动作(测试时通常用更确定的方式,比如取均值,或者关掉随机性)。

你可以把它们理解为我们前面文章中的 sample/predict 的连续动作版本:训练期需要概率信息,测试期只需要行为本身。

小结#

把连续控制这三套算法压缩成一句工程结论:

DDPG/TD3/SAC 都是“Actor 产动作、Critic 评估 (s,a)(s,a)、Replay 做 off-policy”,差异主要体现在 update() 的 target 计算方式、是否用双 critic、是否延迟更新 actor、以及是否引入熵正则。

如果你后面要把这些算法写成统一框架,我建议保留同样的接口:

  • sample(state):训练交互(DDPG/TD3 加噪声,SAC 采样分布)
  • predict(state):测试(不加噪声/取均值动作)
  • update():从 replay 采样并更新网络

后续我会看情况把这个系列继续补成“通用工程模板仓库”:统一日志、统一评估、统一保存/加载与配置管理。

连续控制里我最常踩的坑 & 排障顺序#

连续控制的训练“崩”起来比离散动作更快,尤其是 DDPG/TD3 这种 deterministic 方法,稍微一个尺度不对就会直接发散。我一般按下面顺序排查:

  1. 动作缩放是否正确:Actor 输出常是 tanh[1,1][-1,1],环境动作空间可能是别的范围。这个映射一旦错了,训练会表现得非常诡异(像是永远学不会某个动作方向)。
  2. 噪声别太大/别太小:噪声太大动作乱飞,buffer 里全是坏数据;噪声太小又不探索。建议先把噪声单独打印出来,看它的量级相对动作范围是否合理。
  3. 先盯 Q 值是否爆炸:每隔一段时间打印 Q1/Q2 的均值、最大值;如果 Q 值飙升,优先怀疑学习率、reward 尺度、done 处理、target 计算。
  4. target 的软更新系数(tau)要保守:tau 太大,target 跟着在线网络跑,稳定性下降;tau 太小,又会学得很慢。建议先用保守设置跑通,再微调。
  5. TD3 的双 critic/取 min 要写对:这是它解决过估计的核心。如果你写成了 max,或者 target 用错了 critic,会直接退化/发散。
  6. SAC 重点看 entropy / alpha:熵项权重不合适,会出现“动作太随机”或“过早确定”。如果实现支持自动调 alpha,一般会更省心。
  7. 先用短 horizon 验证闭环:把 episode length 缩短、reward 简化、甚至先关掉部分随机性,确认 update 不会爆,再逐步加回真实设置。
强化学习算法程序实践(4):连续控制(DDPG / TD3 / SAC)
https://xiaohei94.github.io/blog/rl-algorithm-4
Author 红鼻子小黑
Published at May 1, 2025
Comment seems to stuck. Try to refresh?✨