1. 先聊聊我为什么在DDPG之后还要啃SAC
连续控制这个赛道,能用的算法其实就那么几个:DDPG、TD3、PPO、SAC。前两年我用DDPG做机械臂的位置跟踪,调了两周好不容易收敛了,换个种子重跑又崩了,那种感觉就像你熬夜写完的作业第二天发现打印机没墨了。后来换TD3,双Q网络确实压住了一部分过估计,鲁棒性上来了,但探索策略还是靠外部加的高斯噪声,随机性来自“外加”的噪声,而不是策略本身的内在性质,这一点让我一直觉得别扭。真正让我下决心把SAC啃透的,是它把“探索”这件事直接塞进了优化目标里。这不是技巧堆叠,而是目标函数层面的重新设计。SAC全名Soft Actor-Critic,核心卖点就三条:最大化熵的目标函数、双Q网络抑制过估计、温度系数自动调节。这套组合下来,我在几个不同尺度的连续控制任务上,基本能做到一次调参多个任务通用,换种子重跑也很少翻车。如果你已经懂DQN和DDPG,想找一个“调参不那么玄学”的连续控制baseline,SAC值得花时间。这篇笔记就把我理解SAC的完整思路、代码骨架、踩过的坑和排查方法一次讲清楚,代码用的是PyTorch。下面所有实现细节,凡是原论文没写死、我根据自己的实际运行经验补全的部分,我都会明确标出来是“常见实践补全”。
2. SAC的数学骨架到底在拆什么
2.1 从标准RL目标到最大熵目标的跳跃
标准RL的目标是最大化累计期望回报,写成式子就是 $\sum_t \mathbb{E}{(s_t,a_t)\sim\rho\pi}[r(s_t,a_t)]$。这个目标有个隐含假设:只要期望回报最大,策略多确定都无所谓。结果就是策略很快塌缩到一个确定性动作上,探索全靠外部噪声撑着。SAC改的地方很巧妙,它在每一步的回报里额外塞进一个熵项:
$J(\pi)=\sum_t \mathbb{E}{(s_t,a_t)\sim\rho\pi}\left[r(s_t,a_t)+\alpha,\mathcal{H}\big(\pi(\cdot|s_t)\big)\right]$
这里的 $\mathcal{H}$ 是策略分布的微分熵,$\alpha$ 叫温度系数,控制“回报”和“随机性”两件事谁更重要。熵越大,策略在动作上的分布越平,越愿意尝试不同动作。这个改动看着只是一项,但传播开来会让整套Bellman方程都变软。所谓“软”,指的就是价值函数的更新里也带上熵的贡献。我一开始不理解为什么要在回报里加熵,后来想明白了:它相当于给策略装了一个正则项,防止策略过早自信。就像你带孩子学走路,不逼他每步都走最优路径,而是允许他多试几个方向,试错空间大了,最终找到的路反而更稳。
2.2 软价值函数、软Q函数和软策略的关系链
定义软Q函数 $Q(s,a)$ 和软价值函数 $V(s)$:
$V(s)=\mathbb{E}{a\sim\pi}\big[Q(s,a)-\alpha\log\pi(a|s)\big]$ $Q(s,a)=r(s,a)+\gamma,\mathbb{E}{s'}[V(s')]$
把第一式代进第二式,就得到软Q的Bellman备份:$Q(s,a)=r+\gamma,\mathbb{E}_{s'}\big[Q(s',a')-\alpha\log\pi(a'|s')\big]$。注意,这个备份目标里多了一项 $-\alpha\log\pi(a'|s')$。这就是“软”的来源。它意味着Q函数学的不是纯回报,而是“回报减去策略的不确定性代价”。策略越确定(log π 的绝对值大),这一项惩罚越重。反过来,策略在某个状态下如果分布很平,惩罚就小。最终平衡出来的策略,既追求回报,又保持一定随机度。
至于策略本身怎么更新,SAC不去直接最大化 $V$,而是最小化策略和玻尔兹曼分布之间的KL散度。展开后策略loss就是 $\mathbb{E}_{s}\big[\alpha\log\pi(a|s)-Q(s,a)\big]$,其中动作 $a$ 是从当前策略里采样出来的。这一项的直觉是:让策略倾向高Q值的动作(第二项),同时别让分布塌得太窄(第一项)。我第一次看这个loss觉得是不是少了个负号,对着论文推了两遍才确认,是因为做的是最小化KL,展开后符号就长这样。
2.3 三个网络各自在优化什么
SAC实际跑起来会有五组参数:策略网络 $\pi_\phi$、两个Q网络 $Q_{\theta_1},Q_{\theta_2}$、以及两个Q网络各自的目标网络 $\bar Q_{\bar\theta_1},\bar Q_{\bar\theta_2}$。它们各自的loss是这样的:
- Q网络loss:$L_Q=\mathbb{E}\big[\big(Q_\theta(s,a)-(r+\gamma,(Q_{\bar\theta}(s',a')-\alpha\log\pi_\phi(a'|s')))\big)^2\big]$。两个Q网络各算一份,分别更新,防止同一个网络既当选手又当裁判。
- 策略loss:$L_\pi=\mathbb{E}{s}\big[\alpha\log\pi\phi(a|s)-Q_\theta(s,a)\big]$,其中 $a$ 由重参数化采样得到,$Q$ 取两个Q网络的最小值。
- 温度loss:$L_\alpha=\mathbb{E}\big[-\alpha(\log\pi_\phi(a|s)+\bar{\mathcal H})\big]$,$\bar{\mathcal H}$ 是目标熵,通常取动作维度的负数。
为什么要用两个Q网络取min?这是从TD3借来的过估计抑制技巧。单Q网络在off-policy训练里容易把某些动作的价值估高,策略就会一头扎进去,然后数据一变Q值又崩。取min相当于悲观估计,宁可低估也不高估,训练稳很多。目标网络用软更新,$\bar\theta\leftarrow\tau\theta+(1-\tau)\bar\theta$,$\tau$ 一般取0.005。
3. 参数里的门道:动作压缩与重参数化
3.1 高斯策略为什么要套一个tanh
连续动作空间里,SAC的策略输出是一个对角高斯分布,均值 $\mu$ 和标准差 $\sigma$ 由网络给出。但动作有边界(比如机械臂力矩限制在[-1,1]),直接用高斯分布会有无界问题。标准做法是采样一个无界值 $u\sim\mathcal N(\mu,\sigma^2)$,再过一层 $\tanh$ 压到(-1,1):$a=\tanh(u)$。代价是概率密度变了,因为 $\tanh$ 是双射,得按换元公式修正log概率:
$\log\pi(a|s)=\log\mathcal N(u;\mu,\sigma^2)-\sum_i\log\big(1-\tanh^2(u_i)\big)$
第二项就是雅可比行列式的对数。少写这一项,策略更新会偏,而且偏得不容易察觉,因为训练还能跑,就是收敛质量下降。我早期复现时忽略了这一项,任务奖励卡在一个不上不下的水平,排查了两天。数值上建议用 $\log(1-\tanh^2(u))$ 的等价稳定形式,直接算会在 $u$ 很大时溢出,我一般写成2*(np.log(2) - u - F.softplus(-2*u))。这是常见实践补全,原论文没强调数值细节,但工程上必须处理。
3.2 重参数化技巧带来的梯度通路
策略loss里的第二项 $Q(s,a)$ 需要动作 $a$ 对 $\phi$ 可导,否则梯度断在采样这一步。重参数化就是解决这个的:把随机性拆成独立的外生噪声 $\epsilon$,写成 $u=\mu_\phi(s)+\sigma_\phi(s)\odot\epsilon$。这样 $u$ 对 $\phi$ 有确定性的可导路径,梯度能穿过采样回传。$\epsilon$ 从标准正态里抽,本身不依赖参数。如果没有这一步,就只能用似然比(REINFORCE那套)估计梯度,方差会大得离谱,在连续控制里几乎不可用。所以SAC能同时用到Q函数的低方差梯度和策略的随机性,重参数化是中间那块拼图。
3.3 温度系数到底要不要手动调
$\alpha$ 手动设是能跑,但不同任务奖励尺度差很多,手动调特别费时间。SAC的自动温度调节把 $\alpha$ 也当成一个可学习参数,目标熵设为 $-\dim(\mathcal A)$。这个设定的直觉是:动作维度越高,想要的探索量越大,因为高维空间里需要更多样本才能覆盖。比如一个7自由度的机械臂,目标熵就是-7。$\alpha$ 的loss是 $L_\alpha=-\alpha(\log\pi+\bar{\mathcal H})$ 用梯度下降更新。注意更新 $\alpha$ 时要把策略的loss项detach掉,否则梯度会串。如果训练中发现 $\alpha$ 一直往上涨,多半是策略熵远低于目标,可能奖励尺度太大或者动作范围太窄;如果 $\alpha$ 一路跌到接近0,说明策略已经够随机,任务可能太简单。我通常把 $\alpha$ 的初始值设0.2、学习率跟Q网络一致(3e-4),然后观察它的走势判断训练健康度。
4. 从零搭一版能跑通的SAC
4.1 网络结构与环境接口
我用一个高斯策略网络和两个Q网络。策略网络输出均值和log标准差,Q网络输入状态和动作输出标量。代码基于PyTorch。环境接口沿用Gym风格,env.reset()返回状态,env.step(a)返回(s', r, done, info)。为了让读者能直接复现,我用一个通用的连续控制环境作为例子,具体环境名不写死,任何动作连续、维度固定的任务都能套。
import torch import torch.nn as nn import torch.nn.functional as F import numpy as np LOG_STD_MIN, LOG_STD_MAX = -20, 2 class GaussianPolicy(nn.Module): def __init__(self, obs_dim, act_dim, hidden=256): super().__init__() self.fc1 = nn.Linear(obs_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.mu_head = nn.Linear(hidden, act_dim) self.log_std_head = nn.Linear(hidden, act_dim) def forward(self, obs): h = F.relu(self.fc1(obs)) h = F.relu(self.fc2(h)) mu = self.mu_head(h) log_std = self.log_std_head(h) log_std = torch.clamp(log_std, LOG_STD_MIN, LOG_STD_MAX) return mu, log_std def sample(self, obs): mu, log_std = self.forward(obs) std = log_std.exp() dist = torch.distributions.Normal(mu, std) u = dist.rsample() # 重参数化采样 a = torch.tanh(u) log_prob = dist.log_prob(u) - torch.log(1 - a.pow(2) + 1e-6) log_prob = log_prob.sum(dim=-1, keepdim=True) return a, log_probQ网络就是一个普通的MLP,把状态和动作拼一起输入:
class QNet(nn.Module): def __init__(self, obs_dim, act_dim, hidden=256): super().__init__() self.fc1 = nn.Linear(obs_dim + act_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.fc3 = nn.Linear(hidden, 1) def forward(self, obs, act): x = torch.cat([obs, act], dim=-1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.fc3(x)4.2 关键超参数取值和我推荐的区间
下面这张表是我在多个连续控制任务上试出来的常用配置,不是唯一解,但作为起点很稳:
| 参数 | 常用取值 | 说明 |
|---|---|---|
| 学习率 | 3e-4 | 策略和Q都用Adam,温度也可以一样 |
| 批大小 | 256 | 内存够可以上512,太小梯度噪声大 |
| 折扣因子 | 0.99 | 长周期任务可用0.995 |
| 软更新系数 | 0.005 | 过大会抖,过小收敛慢 |
| 回放池 | 1e6 | 小任务1e5也够 |
| 随机预热步数 | 1e4 | 前1万步纯随机填充 |
| 每步更新次数 | 1 | 样本效率吃紧时可选1 |
| 目标熵 | -动作维度 | 自动调温必须设 |
| 隐藏层宽度 | 256 | 2层MLP,3层有时过拟合 |
隐藏层宽度不是越大越好。我在一个状态维度只有十几的任务上试过512×512,结果过拟合严重,评估奖励比训练奖励低一大截。256×2基本够用,除非状态是图像,那才需要卷积编码器。
4.3 训练循环里最容易写错的几行
训练主循环里,几个容易翻车的地方我单独拎出来说。第一处是Q目标的计算:
with torch.no_grad(): next_act, next_logp = policy.sample(next_obs) q1_next = q1_target(next_obs, next_act) q2_next = q2_target(next_obs, next_act) q_next = torch.min(q1_next, q2_next) - alpha * next_logp target_q = reward + (1 - done) * gamma * q_next注意alpha * next_logp这一项,别漏。done用来处理终止状态,终止时不算未来价值。第二处是策略更新的梯度通路:
act, logp = policy.sample(obs) q1_pi = q1(obs, act) q2_pi = q2(obs, act) q_pi = torch.min(q1_pi, q2_pi) policy_loss = (alpha.detach() * logp - q_pi).mean()这里alpha.detach()很关键,防止策略loss的梯度流进温度参数。第三处是温度更新:
_, logp = policy.sample(obs) alpha_loss = -(alpha * (logp + target_entropy).detach()).mean()logp要detach,否则策略网络也会被温度loss带着走,训练会乱。这三处我踩过至少两次,一次是漏了熵项导致Q值虚高,一次是忘了detach导致alpha爆炸。
5. 踩坑排查:SAC训练不收敛的典型症状
5.1 症状对照表与快速定位
训练出问题时,第一步别乱调参,先看曲线。下面这张表是我的排查顺序,从最常见的往上排:
| 症状 | 最可能原因 | 处理方式 |
|---|---|---|
| Q值持续攀升不下降 | 目标网络更新太慢或没同步 | 检查tau和target update调用 |
| 策略塌缩到确定性 | 熵项权重太小或漏写 | 检查logp是否正确计算 |
| alpha一路涨到很大 | 奖励尺度过大 | 奖励除以尺度因子或降低lr |
| 奖励震荡后崩盘 | 学习率过大或batch太小 | 降到1e-4,batch调大 |
| 早期奖励不涨 | 随机预热步数不够 | 预热加到2e4 |
| 评估远差于训练 | 过拟合或状态未归一化 | 加正则或做obs标准化 |
我遇到最多的是“策略塌缩到确定性”。表现是动作方差越来越小,最后策略几乎输出固定动作,reward卡住。根因通常是logp的雅可比修正项写错,导致熵项估计偏差。用前文那个稳定的等价写法能规避大部分数值问题。还有一种情况是目标熵设得不对,比如动作维度是6但目标熵写成-1,那策略会觉得随机度已经够高,不再探索。
5.2 状态归一化和奖励缩放的必要性
很多人复现SAC失败,问题不在算法,在数据预处理。状态各维度量纲差很多时,网络前几层会被大量纲维度主导。我的做法是跑几百步随机策略,统计状态均值和标准差,做在线归一化。注意评估时也要用同一套统计量,否则训练和评估分布不一致。奖励缩放同理,如果奖励在几百量级,Q值会很大,温度系数和梯度都会受影响。一个常用技巧是把奖励除以一个固定尺度,或者用运行均值做动态缩放。这些都是常见实践补全,论文里没细说,但不做的话复现成功率会低不少。
5.3 回放池采样与更新频率的讲究
SAC是off-policy,样本效率和回放池设计强相关。回放池太小,新旧数据相关性高,等效于on-policy,双Q抑制过估计的效果打折。我一般最少放到1e5,复杂任务1e6。每步更新多少次也有讲究,环境交互慢、计算资源多的时候,可以每步更新多次提高样本效率,但这会放大数据相关性带来的偏差,需要配合更大的batch。我实测每步更新1次、batch 256是个稳妥组合,追求样本效率可以试每步2次更新。另外,终止状态的处理要一致:如果环境用时间截断返回done,那这个done不该当作真正的终止,(1-done)会错误地把未来价值砍掉。稳妥做法是让环境区分终止和截断,或者对时间截断的情形仍然计算bootstrap。这一点在很多教程里被忽略,实际影响不小。
6. 和其他算法的取舍:什么场景我会选SAC
连续控制里SAC不是唯一选择,我把常用几个放在一起对比,方便你决定什么时候用它:
| 算法 | 是否off-policy | 探索方式 | 调参难度 | 适用场景 |
|---|---|---|---|---|
| DDPG | 是 | 外加噪声 | 高 | 简单连续任务,已不推荐新项目 |
| TD3 | 是 | 外加噪声 | 中 | 确定性策略够用的任务 |
| PPO | 否 | 策略分布采样 | 低 | 并行采样方便、奖励稳定的任务 |
| SAC | 是 | 内在最大熵 | 中低 | 需要样本效率且探索要求高的任务 |
我的实际选择逻辑是这样:如果仿真环境跑一次很便宜、能开大量并行采样,PPO省心,调参少;如果环境交互昂贵,比如真实机械臂或者复杂物理仿真,SAC的样本效率优势明显;如果任务奖励信号稀疏,SAC的最大熵探索比TD3的外加噪声更容易找到有效路径。机械臂抓取这类任务,我基本首选SAC。还有一个场景值得提,就是状态维度高、动作维度也高的任务,SAC自动调温给的适应性比手动设噪声好得多。
6.1 从SAC延伸到离线强化学习
后来越来越多的实战场景拿不到在线交互,只能吃现成数据集,这就牵扯到离线强化学习。SAC本身不能直接用于离线,因为它对分布外动作的Q值过估计严重,策略会跑到数据集没覆盖的动作上。有一类离线算法就是在SAC框架上加保守惩罚,把分布外动作的Q值压低。我理解这条线的思路时,一直把它当作“给SAC的Q目标加一个分布约束”,这样看比从零理解一堆新公式容易。如果你已经把在线SAC跑通,再去看这些离线变体,会顺很多。
6.2 我在调参顺序上的习惯
最后分享我调SAC的顺序,这个顺序帮我省了很多时间。第一步先把随机预热拉满,确认回放池有足够多样数据;第二步关掉自动调温,手动固定一个alpha,看能不能收敛,验证算法实现没问题;第三步打开自动调温,观察alpha是否稳定在合理区间;第四步再调网络宽度和学习率。很多人一上来就同时调一堆参数,出了问题根本不知道是哪一项造成的。分步验证听着慢,实际比反复试错快得多。另外提醒一句,换随机种子重跑至少三次再下结论,单次训练结果在RL里参考价值很低,我见过太多“调好了”其实是撞上了好种子。