HOMEWORK 03

HW3 编程解析:DQN、Double-Q 与 Soft Actor-Critic

这是整门课代码量最大的一份作业:51 处 TODO、两条完全独立的算法线(离散动作的 DQN 与连续动作的 SAC)。真正难的不是行数,而是三处「写得出来但很容易写反」的地方——Double-DQN 的选/评解耦、tanh 挤压高斯的雅可比修正、以及熵温度的对偶梯度。本文逐处拆解数学、代码、写错的具体后果与对应断言,并附上真实跑出来的数字(含哪些实验因算力提前终止而未达标)。

UC Berkeley CS 185/285 Spring 2026 环境:CartPole-v1 / LunarLander-v2 / MsPacman / InvertedPendulum-v4 / Hopper-v4 / HalfCheetah-v4

0. 这份作业在考什么

HW3 的题面拆成两半:第 2 节实现 深度 Q 网络(Deep Q-Network, DQN),第 3 节实现 软性演员-评论家(Soft Actor-Critic, SAC)。表面上是两个不相干的算法,实际上它们共享同一条主线——如何在函数逼近下把「自举(bootstrapping)」这件事做稳。DQN 和 SAC 的每一处「奇怪的设计」都是在补同一个洞。

作业小节对应讲次与理论命题要动手验证的结论
§2.4 基础 DQN第 7 讲:值函数方法、拟合 Q 迭代目标网络(target network)把「移动靶」变成「半静止靶」,回归才能收敛
§2.5 Double-Q第 8 讲:$\max$ 算子的过估计偏差(overestimation bias)把「选动作」和「评动作」交给两个网络,$Q$ 的乐观程度可测量地下降
§2.5 train vs eval第 7 讲:行为策略 vs 目标策略(off-policy)$\epsilon$-greedy 采样回报与贪心评估回报在训练早期会系统性分离
§2.6 超参敏感性第 8 讲:目标网络更新周期的速度/稳定权衡周期短 → 传播快但目标抖;周期长 → 目标稳但值陈旧
§3.2 自举第 6/7 讲:Actor-Critic 的 TD 备份;Polyak 平均软更新 $\phi'\leftarrow\phi'+\tau(\phi-\phi')$ 是硬更新的连续极限
§3.3 熵项第 13 讲:最大熵强化学习(maximum entropy RL)熵不仅进 actor 目标,还要进备份,否则 $Q$ 学的不是软值函数
§3.4 重参数化第 5/13 讲:路径导数 vs 似然比估计量$\nabla_\theta\E[Q]$ 用 reparametrization 的方差远低于 REINFORCE
§3.5 自动调温第 13 讲:约束优化与对偶梯度下降(dual gradient descent)$\alpha$ 是拉格朗日乘子,它的运动方向由「当前熵 vs 目标熵」的符号决定
§3.6 clipped double-Q第 8 讲:过估计在连续动作下的表现两个 critic 取 $\min$ 是悲观估计,能压住 $Q$ 的爆炸性增长
核心结论(先看结论,再看推导)
  • Double-DQN 的证据不在回报上,在 $Q$ 值上。 LunarLander 跑到 35.5 万步时,vanilla 的平均预测 $Q$ 是 84.1,double 是 70.9(低 16%);而两者的评估回报(266.8 vs 256.0)差异纯属单种子噪声。看错指标就会得出「double-Q 没用」的错误结论。
  • tanh 挤压高斯的对数概率必须、且只能扣一次雅可比。 这份代码里 TanhTransform 已经帮你扣了;再手写一遍 $-\sum\log(1-a^2)$ 就是双重扣除,密度不再积分到 1。测试用数值积分 $\int p\,da=1.000$ 一击必中。
  • .sample() 换成 .rsample() 不会报错,只会让 actor 悄悄学错。 重参数化断掉后梯度路径消失,训练「看起来在跑」,但熵永远爬不到 $\log 2$。
  • Hopper 上 clipped double-Q 在共同的 0–66k 窗口里回报 602 vs 400,同时 $Q$ 低 9%。 single-Q 的 $Q$ 一路涨到 260 而回报卡在 600——这是过估计的教科书式指纹。
  • HalfCheetah 的 $\alpha$ 是下降的:$0.100\to0.024\to0.048$。因为 $H_{\text{target}}=-\dim(\mathcal A)=-6$ 是个很低的熵目标,初始策略熵(−1.14)远高于它,对偶步只能把探索的价格往下压。
关于本文实验数据的诚实声明 本文所有数字来自单种子(seed 1)真实运行,日志由 infrastructure/log_utils.py 的 CSV logger 产生。但算力被提前切断:MsPacman 只跑到 70k/1000k、Hopper clipped-Q 只跑到 67k/500k、HalfCheetah 只跑到 278k/1000k。这三个实验都没有达到作业要求的分数。文中每处都会标注「跑到第几步 / 目标是多少 / 趋势如何」,截断的结果一律按截断报告,不冒充达标。§10 专门讨论这对结论的影响。

1. 代码结构与张量形状链条

HW3 最容易在「形状」上翻车,因为 SAC 的 critic 多了一个「critic 集成」维度,而 DQN 的 critic 输出的是「每个动作一个值」。两条线的形状语义完全不同,混着写必然出事。这一节先把两条链条钉死。

1.1 目录结构与文件职责

hw3/
├── src/
│   ├── scripts/
│   │   ├── run_dqn.py            DQN 训练主循环(3 处 TODO)
│   │   └── run_sac.py            SAC 训练主循环(2 处 TODO)
│   ├── agents/
│   │   ├── dqn_agent.py          【核心】eps-greedy / TD 目标 / double-Q / 硬同步
│   │   └── sac_agent.py          【核心】熵估计 / 重参数化 actor / clipped double-Q
│   │                                    / 软硬目标更新 / alpha 自动调温
│   ├── networks/
│   │   ├── critics.py            DQNCritic: s -> (num_actions,)
│   │   │                         StateActionCritic: (s, a) -> scalar
│   │   └── policies.py           MLPPolicy.forward -> Distribution
│   ├── infrastructure/
│   │   ├── replay_buffer.py      ReplayBuffer / MemoryEfficientReplayBuffer
│   │   ├── distributions.py      make_tanh_transformed / make_multi_normal
│   │   └── pytorch_util.py       build_mlp / from_numpy / to_numpy
│   └── configs/                  dqn_config.py / sac_config.py
├── experiments/dqn/*.yaml        cartpole / lunarlander / mspacman ...
├── experiments/sac/*.yaml        sanity_invertedpendulum / hopper_* / halfcheetah_*
└── tests/test_hw3.py             18 项数值测试(本文每节都会引用)

一个容易被忽略的对照:DQNCritic.forward(obs) 返回 (batch, num_actions),StateActionCritic.forward(obs, acs) 返回 (batch,)。前者可以在最后一维直接 argmax 求 $\max_a Q$,后者做不到——这正是 §3 开头 PDF 讲的「连续动作空间里 $\max_a Q(s,a)$ 是个非线性优化问题」,也是必须引入显式 actor 的根本原因。

1.2 DQN 的形状链条

位置张量形状说明
get_action 入口observation(*ob_shape,) → (1, *ob_shape)[None] 补 batch 维;Atari 下 ob_shape=(4,84,84)
get_actionqa_values(1, num_actions)断言写死,形状错立刻炸
get_action 出口action(1,) → Python int.squeeze(0).item()
replay_buffer.sampleobservations(B, *ob_shape)MLP 配置下 (B, ob_dim)
同上actions(B,),int64离散动作是标量索引
同上rewards / dones(B,)dones 是 bool!见 §2.3
update_criticnext_qa_values(B, num_actions)目标网络对 $s'$ 的全动作值
update_criticnext_action(B,) → (B,1)unsqueeze(1) 才能喂 gather
update_criticnext_q_values(B,)gather(...).squeeze(1)
update_critictarget_values / q_values(B,)两者形状必须一致,否则 MSE 会广播成 (B,B)
常见误区:MSE 的静默广播 如果你把 q_values 留成 (B,1) 而 target_values 是 (B,),nn.MSELoss() 不会报错:PyTorch 会广播成 (B,B),然后对 $B^2$ 个「第 $i$ 个预测 vs 第 $j$ 个目标」求均值。损失曲线看起来还在下降(因为它确实在把所有预测拉向所有目标的均值),但 critic 学到的是一个近乎常数的函数,CartPole 的回报会永远卡在 9~20。这就是那三行 assert ... == (batch_size,) 存在的意义——不要删掉 starter code 里的断言。

1.3 SAC 的形状链条

SAC 多了一维 num_critic_networks(记作 $N$,single-Q 时 $N=1$,clipped double-Q 时 $N=2$),且它永远在最前面。

位置张量形状说明
actor(obs)分布对象batch_shape (B,)Independent(..., 1) 把动作维吸收进 event_shape
dist.rsample()action(B, ac_dim)tanh 后严格落在 $(-1,1)$
dist.log_prob(a)log_prob(B,)已对 ac_dim 求和
self.critic(obs, a)q_values(N, B)torch.stack([...], dim=0)
q_backup_strategy 输入next_qs(N, B)「来自」第 $i$ 个 target critic 的值
q_backup_strategy 内部min/mean 后(B,)沿 dim=0 归约
q_backup_strategy 输出next_qs(N, B)starter code 用 expand 重新撑开:「给」每个 critic 的目标
update_critictarget_values(N, B)reward[None] / done[None] 靠广播对齐
entropy(dist)H(B,)进 target 时要 [None] 广播到 (1,B)
update_alphalog_prob(B,),已 detach见 §8
直觉:q_backup_strategy 的两个「$N$」不是同一个意思 函数文档里那句话值得读三遍:输入的前导维是「来自(FROM)不同 critic 的值」,输出的前导维是「给(FOR)不同 critic 的目标」。clipped double-Q 的定义 $y_A=y_B=r+\gamma\min_i Q_{\phi'_i}$ 恰恰是说:两个 critic 收到同一个目标。所以先沿 dim=0 归约成 (B,),再 expand 回 (N,B),这个「先塌陷再撑开」看着多余,其实精确表达了语义。

2. DQN 主线:从 $\epsilon$-greedy 到硬同步

2.1 $\epsilon$-greedy 动作选择

要求。 PDF §2.4:「Implement $\epsilon$-greedy sampling in get_action. Recall that in DQN, the action space is discrete, so the greedy action is simply $a=\argmax_a Q_\phi(s,a)$ over the finite set of actions.」

数学。 行为策略是

$$ \pi_{\epsilon}(a\mid s)=\begin{cases} 1-\epsilon+\dfrac{\epsilon}{|\mathcal A|}, & a=\argmax_{a'}Q_\phi(s,a')\\[6pt] \dfrac{\epsilon}{|\mathcal A|}, & \text{否则} \end{cases} $$

注意贪心动作也可能被随机抽中,所以它的概率是 $1-\epsilon+\epsilon/|\mathcal A|$ 而不是 $1-\epsilon$。这个细节在写测试时很关键。

observation = ptu.from_numpy(np.asarray(observation))[None]

with torch.no_grad():
    qa_values: torch.Tensor = self.critic(observation)
    assert qa_values.shape == (1, self.num_actions), qa_values.shape

    if np.random.rand() < epsilon:
        action = torch.randint(low=0, high=self.num_actions,
                               size=(1,), device=qa_values.device)
    else:
        action = qa_values.argmax(dim=-1)

return ptu.to_numpy(action).squeeze(0).item()

易错点与后果。

  • 忘了 torch.no_grad():每一步环境交互都会建计算图并保留激活值。CartPole 上表现为显存/内存缓慢泄漏,Atari 上几千步就 OOM。
  • 用 np.random.rand() <= epsilon:在 $\epsilon$ 衰减到 0 之后仍有极小概率随机(rand() 可能返回 0.0),评估回报会莫名其妙掉几分。用严格小于。
  • 把 epsilon 用在评估上:run_dqn.py 里评估走的是 utils.sample_n_trajectories,它调用 agent.get_action(obs) 用默认 $\epsilon=0$。如果你把 $\epsilon$ 硬编码进 get_action,评估曲线会永远带噪声,LunarLander 上大概损失 30~50 分。

怎么验证。 test_dqn_epsilon_greedy_and_target_sync 用一个手工构造的两动作 Q 表做三档检查:$\epsilon=0$ 时必须严格贪心;$\epsilon=1$ 时 4000 次抽样应该接近 50/50;$\epsilon=0.5$ 时贪心动作出现的频率应该是 $1-0.5+0.5/2=75\%$。最后这一档正是用来抓「概率写成 $1-\epsilon$」的实现——如果你在随机分支里排除掉贪心动作,频率会变成 50%,测试立刻失败。

2.2 TD 目标:$y=r+\gamma(1-d)\max_{a'}Q_{\phi'}(s',a')$

要求。 PDF §2.4:「Implement a DQN critic update in update_critic」。

数学。 贝尔曼最优算子的样本版本。对一条转移 $(s,a,r,s',d)$:

$$ y=r+\gamma(1-d)\max_{a'}Q_{\phi'}(s',a'),\qquad \mathcal L(\phi)=\frac1B\sum_{i}\bigl(Q_\phi(s_i,a_i)-y_i\bigr)^2 $$

三个必须理解的点:(1)$y$ 里用的是 目标网络 $\phi'$ 而不是 $\phi$;(2)$y$ 整体在 torch.no_grad() 里,它是回归的「标签」,不参与梯度;(3)$(1-d)$ 把终止状态的自举掐断——终止之后没有未来,$y$ 必须精确等于 $r$。

done = done.float()          # ← 关键,见下
action = action.long()

with torch.no_grad():
    next_qa_values = self.target_critic(next_obs)      # (B, num_actions)
    ...
    next_q_values = torch.gather(next_qa_values, dim=1,
                                 index=next_action.unsqueeze(1)).squeeze(1)
    target_values = reward + self.discount * (1.0 - done) * next_q_values

qa_values = self.critic(obs)                            # (B, num_actions)
q_values = torch.gather(qa_values, dim=1,
                        index=action.unsqueeze(1)).squeeze(1)
loss = self.critic_loss(q_values, target_values)

2.3 1 - done 与 bool 张量:一个必踩的坑

常见误区:直接写 1 - done replay buffer 里 done 是从 Python bool 经 np.array(done) 存进去的,dtype 是 bool。PyTorch 明确拒绝对 bool 张量做减法:
RuntimeError: Subtraction, the `-` operator, with a bool tensor is not supported.
If you are trying to invert a mask, use the `~` or `logical_not()` operator instead.
所以 update_critic 的第一行必须是 done = done.float()。DQN 和 SAC 两个 agent 都要写——这是本作业里最高频的第一次运行报错。

顺带说明为什么不能用报错信息建议的 ~done:~done 仍是 bool,乘上 float 的 next_q_values 时 PyTorch 会隐式提升为 float,结果碰巧是对的。但一旦以后 done 改成 float 存储(有些版本的 starter code 会这么干),~ 就变成按位取反,$0.0$ 会变成一个巨大的负数,训练直接发散。写 done.float() 是唯一稳妥的写法。

还有一个语义坑:done 不等于 truncated。 看 run_dqn.py 里插入 buffer 的那行:

truncated = info.get("TimeLimit.truncated", False)
replay_buffer.insert(..., done=done and not truncated, ...)

CartPole 在 500 步时被 TimeLimit 截断,这不是 MDP 意义上的终止——杆子还立着,未来价值不为零。如果把 truncated 当作 done=True 存进去,$y$ 会在这些转移上被强行压成 $r=1$,critic 学到的 $Q$ 上界会被拉低到远小于 $\frac{1}{1-\gamma}=100$,CartPole 大概率停在 200 分左右上不去。这行 starter code 已经写好了,但值得知道它为什么在。

2.4 硬目标同步

要求。 「Call all of the required updates, and update the target critic if necessary, in update.」

critic_stats = self.update_critic(obs, action, reward, next_obs, done)

if step % self.target_update_period == 0:
    self.update_target_critic()          # target_critic.load_state_dict(critic.state_dict())

为什么需要目标网络。 如果直接用 $\phi$ 计算 $y$,损失变成 $\bigl(Q_\phi(s,a)-r-\gamma\max_{a'}Q_\phi(s',a')\bigr)^2$,这不是回归而是一个不动点迭代:每走一步梯度,标签也跟着动。第 7 讲里说的「这不是梯度下降,因为它不是任何函数的梯度」正是指这个。实践中的表现是 $Q$ 值发散——在 LunarLander 上通常几万步内 $Q$ 就冲到 $10^3$ 量级然后出 NaN。

易错点。 用 self.target_critic = copy.deepcopy(self.critic) 替代 load_state_dict。这会把 target_critic 换成一个新对象,而 self.critic_optimizer 里持有的参数引用还指向旧对象——如果你不小心把 target 的参数也放进了优化器,同步之后优化器会更新一组已被丢弃的张量。更隐蔽的是 nn.Module 的子模块注册会被打乱,agent.to(device) 之后 target 可能留在 CPU。老老实实用 load_state_dict。

怎么验证。 test_dqn_epsilon_greedy_and_target_sync 的后半段:人为把 online 网络的权重改掉,然后逐步调用 update,检查只在 step % period == 0 时 target 的输出才发生变化。这能同时抓住两类 bug——每步都同步(等于没有目标网络)和从不同步(target 永远是初始化值,$Q$ 学成常数)。

2.5 run_dqn.py 的三处 TODO

# 1) 用当前 epsilon 采样动作
action = agent.get_action(observation, epsilon)

# 2) 从 replay buffer 采一个 batch
batch = replay_buffer.sample(config["batch_size"])

# 3) 训练
update_info = agent.update(
    obs=batch["observations"], action=batch["actions"],
    reward=batch["rewards"], next_obs=batch["next_observations"],
    done=batch["dones"], step=step,
)

三行看着无脑,但有两个容易写反的地方。第一,epsilon 必须来自 exploration_schedule.value(step)(分段线性:$1.0\to0.1$ 用掉 30% 步数,再 $\to0.02$ 用掉 60%),而不是一个常数;第二,采样和训练必须包在 if step >= config["learning_starts"] 里。CartPole 的 learning_starts=1000,LunarLander 是 20000。如果 buffer 里只有几十条转移就开始训练,batch 会被同一条轨迹的高度相关样本填满,Q 网络在最初几百步就过拟合到一个错误的值,之后很难恢复——这就是经验回放要解决的「样本相关性」问题的反面教材。

推导:为什么 replay buffer 的 sample 是有放回的 ReplayBuffer.sample 用的是 np.random.randint(0, self.size, size=(batch_size,)),同一个 batch 里可以出现重复索引。这在数学上无害:我们要估计的是 $\E_{(s,a,r,s')\sim\mathcal D}[(Q_\phi-y)^2]$ 关于 buffer 经验分布 $\mathcal D$ 的期望,有放回抽样给出的是无偏 i.i.d. 估计,反倒比无放回更干净。测试 test_replay_buffer_shapes_and_ring_overwrite 还检查了环形覆盖:容量 5 的 buffer 插 8 条之后内容是 [5,6,7,3,4],而 len() 仍然返回 8(因为 self.size 只增不减,采样时用 % max_size 兜底)。这个「len 大于容量」的行为如果你自己写会觉得是 bug,其实是 starter code 的既定设计。

3. 重点一:Double DQN 与过估计偏差

3.1 $\max$ 为什么会系统性地高估

要求。 PDF §2.5:「The double-Q trick avoids overestimation bias in the critic update by using two different networks to select the next action $a'$ and to estimate its value... we'll keep using the target network $Q_{\phi'}$ to estimate the action's value, but we'll select the action using $Q_\phi$(the online Q network).」

数学。 设真值为 $Q^*(s',a)$,学到的估计带零均值噪声:$Q_{\phi'}(s',a)=Q^*(s',a)+\varepsilon_a$,$\E[\varepsilon_a]=0$。vanilla 目标里的关键量是 $\max_a Q_{\phi'}(s',a)$。由于 $\max$ 是凸函数,Jensen 不等式给出

$$ \E\Bigl[\max_a\bigl(Q^*(s',a)+\varepsilon_a\bigr)\Bigr]\;\ge\;\max_a\E\bigl[Q^*(s',a)+\varepsilon_a\bigr]=\max_a Q^*(s',a) $$

也就是说,哪怕估计本身是无偏的,取 $\max$ 之后也一定是向上有偏的。而且这个偏差随动作数 $|\mathcal A|$ 增大而增大(更多的候选 = 更容易抽到一个偏大的噪声),随噪声方差增大而增大。更糟的是它会被自举放大:偏大的 $y$ 训出偏大的 $Q$,下一轮 $\max$ 又在更偏大的值上取 $\max$。

直觉:$\max$ 是「选」和「评」的耦合 把 $\max_a Q_{\phi'}(s',a)$ 拆开看,它其实做了两件事:用 $Q_{\phi'}$ 挑出一个动作,再用 $Q_{\phi'}$ 给这个动作打分。挑的时候偏爱噪声大的动作,打分的时候又用了同一份噪声——于是「挑中的原因」和「打出的高分」是同一个错误,错误被记账了两次。Double-Q 的全部内容就是:让挑的人和打分的人不是同一个人。挑的人再怎么被噪声骗,打分的人手里那份噪声是独立的,期望上不会同向偏大。

Double-DQN 的目标为

$$ a^*=\argmax_{a'}Q_{\phi}(s',a'),\qquad y=r+\gamma(1-d)\,Q_{\phi'}(s',a^*) $$

注意 evaluation 始终用 $\phi'$;相对 vanilla 唯一改动的是 $\argmax$ 用哪个网络。

3.2 实现:一行 if 的位置很重要

with torch.no_grad():
    # 无论哪种模式,被“评估”的都是目标网络
    next_qa_values = self.target_critic(next_obs)          # (B, num_actions)
    assert next_qa_values.shape == (batch_size, self.num_actions)

    if self.use_double_q:
        # 选择:在线网络
        next_action = self.critic(next_obs).argmax(dim=-1)
    else:
        # vanilla:目标网络自己选自己评
        next_action = next_qa_values.argmax(dim=-1)

    next_q_values = torch.gather(next_qa_values, dim=1,
                                 index=next_action.unsqueeze(1)).squeeze(1)
    target_values = reward + self.discount * (1.0 - done) * next_q_values

结构上要记住的是:next_qa_values 这一行在 if 外面,而且永远是 target_critic。写成下面这样是最常见的错误:

# ✗ 错误写法:把评估也换成了在线网络
if self.use_double_q:
    next_qa_values = self.critic(next_obs)
    next_action = next_qa_values.argmax(dim=-1)
    next_q_values = next_qa_values.gather(...)     # 用 φ 评估 φ 选的动作

后果非常具体:这样写出来的东西数学上等价于「完全不用目标网络的 vanilla DQN」——$y=r+\gamma\max_a Q_\phi(s',a)$。它不但没有消除过估计,还把目标网络带来的稳定性一并丢掉了。在 LunarLander 上表现为 $Q$ 值在 10 万步左右开始超线性上涨、评估回报在冲到 100 分左右后崩回 $-200$。麻烦的是它不会报错,形状断言全部通过。

3.3 手算例子:4.6 → 2.8

这是本作业最值得亲手算一遍的例子(也是测试 test_double_dqn_target_differs_and_is_smaller 用的固定用例)。取 3 个状态、2 个动作、$\gamma=0.9$:

$a_0$$a_1$
$Q_\phi(s_0,\cdot)$1.002.00
$Q_\phi(s_1,\cdot)$3.00−1.00
$Q_\phi(s_2,\cdot)$0.500.25
$Q_{\phi'}(s_0,\cdot)$0.005.00
$Q_{\phi'}(s_1,\cdot)$2.004.00
$Q_{\phi'}(s_2,\cdot)$−1.007.00

batch 为 $s=[s_0,s_1]$,$a=[a_1,a_0]$,$r=[1.0,-2.0]$,$s'=[s_1,s_2]$,$d=[\text{False},\text{True}]$。

  • 取出的当前值:$Q_\phi(s_0,a_1)=2.0$,$Q_\phi(s_1,a_0)=3.0$,即 [2.0, 3.0]。
  • vanilla 第 0 条:$\max_a Q_{\phi'}(s_1,\cdot)=\max(2.0,4.0)=4.0$,于是 $y_0=1.0+0.9\times4.0=\mathbf{4.6}$。
  • double 第 0 条:先用在线网络选,$\argmax_a Q_\phi(s_1,\cdot)$:$3.0>-1.0$,选 $a_0$。再用目标网络评:$Q_{\phi'}(s_1,a_0)=2.0$。于是 $y_0=1.0+0.9\times2.0=\mathbf{2.8}$。
  • 第 1 条是终止转移:$d=\text{True}$,两种模式都给 $y_1=-2.0=r$,一模一样。
核心结论:$4.6\to2.8$ 就是被删掉的那部分乐观 在这个例子里,目标网络认为 $s_1$ 处 $a_1$ 值 4.0、$a_0$ 值 2.0;在线网络的看法完全相反($a_0$ 值 3.0、$a_1$ 值 −1.0)。两个网络意见相左,这正是「$Q_{\phi'}(s_1,a_1)=4.0$ 里有很大成分是噪声」的信号。vanilla 会不加辨别地采信这个 4.0;double 因为在线网络不认可 $a_1$,转而去问 $a_0$ 的值,只拿到 2.0。差出来的 $0.9\times2.0=1.8$ 就是被消掉的过估计。

一个可以直接当断言用的结构性质。 对任意一对网络,恒有

$$ Q_{\phi'}\bigl(s',\argmax_{a}Q_\phi(s',a)\bigr)\;\le\;\max_{a}Q_{\phi'}(s',a) $$

因为左边只是右边取 $\max$ 的候选之一。于是 double 目标永远 $\le$ vanilla 目标(在 $\gamma\ge0$、同一对网络下)。test_double_dqn_target_differs_and_is_smaller 就是用这条:先验证手算例子 $2.8<4.6$,再在 20 组随机 Q 表上验证 $y_{\text{double}}\le y_{\text{vanilla}}+10^{-6}$ 恒成立、且至少有一处严格小于。

常见误区:这条不等式不意味着 double 的 $Q$ 一定更低 上面的不等式是「给定同一对网络」的逐点性质。训练过程是闭环的:更小的目标 → 更小的 $Q$ → 下一轮的 $\argmax$ 也变了。所以不能推出「double 的学习曲线一定在 vanilla 下方」。经验上确实通常如此(本文 §9.2 的 LunarLander 低 16%),但那是实验结论不是定理。如果你的测试断言写成「训练 1 万步后 double 的 mean Q 必须更低」,会随机地失败。

3.4 gather 的两个索引维度

torch.gather(next_qa_values, dim=1, index=next_action.unsqueeze(1)) 这行值得单独说。gather 要求 index 与 input 维数相同,所以 (B,) 的动作索引必须 unsqueeze(1) 成 (B,1);输出也是 (B,1),再 squeeze(1) 回 (B,)。

常见的三种写错方式与后果:

写法发生什么
gather(qa, 1, action)(忘了 unsqueeze)立刻报 RuntimeError: Index tensor must have the same number of dimensions,好抓
gather(qa, 0, action[:,None])(dim 写成 0)不报错。它会沿 batch 维取,等于把第 $a_i$ 条样本的第 $i$ 个动作值拿来当第 $i$ 条的 $Q(s_i,a_i)$。梯度完全对不上样本,CartPole 回报永远在 9~30 之间随机游走
qa[:, action](花式索引)不报错,但形状变成 (B,B),MSE 广播后损失下降但学不到东西

更稳妥的等价写法是 qa_values[torch.arange(B), action],形状直接是 (B,),不容易搞混维度。两种写法数值完全一致。

怎么验证。 test_dqn_vanilla_td_target_matches_hand_computation 用上面那张手算表,把 agent.critic_loss 临时替换成一个「捕获 (q_values, target_values) 并返回零损失」的探针函数,然后直接比对 [2.0, 3.0] 与 [4.6, -2.0]。这个测试同时锁住了 gather 的维度、$\gamma$ 的位置和 $(1-d)$ 的作用;test_dqn_target_equals_reward_on_terminal_transitions 则专门用一个数值大 50 倍的目标网络来做终止检查——如果 $(1-d)$ 漏了,$y_1$ 会偏离 $r$ 几百,一眼可见。

4. SAC 主线之一:自举与目标网络更新

4.1 run_sac.py 的两处 TODO

PDF 提示得很直白:「This should look pretty similar to your DQN run script, as both are off-policy methods!」两处 TODO 是选动作和采 batch:

if step < config["random_steps"]:
    action = env.action_space.sample()      # 纯随机热身(starter code 已给)
else:
    action = agent.get_action(observation)  # ← TODO(3.1)

...
if step >= config["training_starts"]:
    batch = replay_buffer.sample(config["batch_size"])   # ← TODO(3.1)
    batch = ptu.from_numpy(batch)
    update_info = agent.update(observations=..., actions=..., rewards=...,
                               next_observations=..., dones=..., step=step)

注意 random_steps(InvertedPendulum 是 1000)和 training_starts(5000)是两个不同的阈值:前者控制「什么时候停止纯随机探索」,后者控制「什么时候开始梯度更新」。SAC 的探索靠策略自身的随机性(熵项),不像 DQN 有 $\epsilon$ 调度,所以开头那段纯随机采样承担了「把 buffer 填上多样数据」的职责。把 random_steps 设成 0,InvertedPendulum 上大概率会在前 1 万步陷入一个退化策略。

4.2 critic 更新:目标里的三层结构

要求。 PDF §3.2:$y\leftarrow r_t+\gamma(1-d_t)Q_\phi(s_{t+1},a_{t+1})$,$a_{t+1}\sim\pi(a_{t+1}\mid s_{t+1})$。§3.3 又在方括号里塞进熵项。合起来最终形式是

$$ y=r+\gamma(1-d)\Bigl[\underbrace{\textstyle\min_{i}Q_{\phi'_i}(s',a')}_{\text{§3.6 clipped double-Q}}+\underbrace{\beta\,\mathcal H\bigl(\pi(\cdot\mid s')\bigr)}_{\text{§3.3 熵奖励}}\Bigr],\quad a'\sim\pi(\cdot\mid s') $$
with torch.no_grad():
    next_action_distribution = self.actor(next_obs)
    next_action = next_action_distribution.sample()        # (B, ac_dim)
    next_qs = self.target_critic(next_obs, next_action)    # (N, B)

    if self.use_entropy_bonus and self.backup_entropy:
        next_action_entropy = self.entropy(next_action_distribution)   # (B,)
        next_qs = next_qs + self.get_temperature() * next_action_entropy[None]

    next_qs = self.q_backup_strategy(next_qs)              # (N, B) -> (N, B)
    target_values = reward[None] + self.discount * (1.0 - done[None]) * next_qs

q_values = self.critic(obs, action)                        # (N, B)
loss = self.critic_loss(q_values, target_values)
注意:熵项加在 min 之前还是之后? 本实现把熵加在 q_backup_strategy 之前,即 $\min_i\bigl(Q_{\phi'_i}+\beta H\bigr)$。因为 $H$ 与 $i$ 无关,$\min_i(Q_i+c)=\min_i Q_i+c$,两种顺序数值完全等价。但形状不等价:加在前面时 $H$ 需要 [None] 广播到 (1,B);加在后面时直接是 (B,) 与 (N,B) 广播。写在前面的好处是 q_backup_strategy 的输入语义保持纯粹(「来自各 critic 的软 Q 值」)。

三个易错点。

  • next_action 用了 rsample():整段在 no_grad() 里,用哪个都不影响数值。但 rsample() 会触发 TanhTransform(cache_size=1) 的缓存写入,而这个缓存随后又被 self.entropy(next_action_distribution) 里的第二次采样覆盖——不会出错,但会让你在调试时对「缓存里到底存的是哪次采样」产生困惑。这里用 .sample() 语义更干净。
  • 忘了 reward[None]:reward 是 (B,),next_qs 是 (N,B)。广播规则下 (B,) + (N,B) 恰好也是对的(右对齐),所以漏写 [None] 在 $N=1,2$ 时都不会出错。但显式写出来能让断言 target_values.shape == (N,B) 变成真正的校验而不是巧合。
  • 把 self.critic 写成 self.critics:self.critic 是一个方法(它 stack 所有 critic 的输出),self.critics 是 nn.ModuleList。直接调用后者会报 'ModuleList' object is not callable,好抓。

4.3 软更新 vs 硬更新

要求。 PDF §3.2 给了两种:硬更新每 $K$ 步 $\phi'\leftarrow\phi$;软更新(Polyak 平均)每步 $\phi'\leftarrow\phi'+\tau(\phi-\phi')$。sac_config.py 保证两者恰有一个不是 None。

if self.target_update_period is not None:
    if step % self.target_update_period == 0:
        self.update_target_critic()                       # 等价于 soft_update(tau=1.0)
elif self.soft_target_update_rate is not None:
    self.soft_update_target_critic(self.soft_target_update_rate)
else:
    raise ValueError("Must specify either target_update_period or soft_target_update_rate")
推导:Polyak 更新的闭式解,以及它和硬更新的关系 固定 $\phi$,从 $\phi'_0=0$ 出发迭代 $\phi'_{k+1}=(1-\tau)\phi'_k+\tau\phi$,这是一个一阶线性递推: $$ \phi'_k=\phi\bigl(1-(1-\tau)^k\bigr) $$ 代入 $\tau=0.005$,要让 $\phi'$ 走完 $\phi$ 的 63%(即 $1-e^{-1}$)需要 $k\approx1/\tau=200$ 步。所以「$\tau=0.005$ 的软更新」在时间常数上大致对应「周期 200 的硬更新」,但它是平滑的——硬更新在同步的那一瞬间目标值会跳变,回归标签整体平移,损失曲线上会看到周期性的尖峰;软更新把这个跳变摊到 200 步里。SAC 论文用软更新正是因为连续控制里 critic 对目标跳变更敏感。

test_sac_target_updates_hard_and_soft 就是拿这个闭式解做断言:把 target 参数清零、online 参数设成已知常数,跑 $k$ 步 Polyak,检查参数等于 $\phi(1-(1-\tau)^k)$;再单独验证 update_target_critic() 是精确硬拷贝(相当于 $\tau=1$)。这条断言能抓住把更新写成 $\phi'\leftarrow\tau\phi'+(1-\tau)\phi$($\tau$ 用反)的 bug——用反之后目标网络几乎瞬间等于在线网络,等于没有目标网络,Hopper 上 $Q$ 会在 2 万步内发散。

实现细节:soft_update_target_critic 用的是 target_param.data.copy_(...)。用 .data 而不是 with torch.no_grad() 只是风格问题,但绝不能写成 target_param = ...(那只是重绑局部变量,参数原地没变,target 网络永远停在初始化值)。

4.4 num_critic_updates:critic 走几步,actor 走一步

critic_infos = []
for _ in range(self.num_critic_updates):
    info = self.update_critic(observations, actions, rewards, next_observations, dones)
    critic_infos.append(info)

actor_info = self.update_actor(observations)

顺序是「critic 先走 num_critic_updates 步,然后 actor 走一步」。这个不对称有理论理由:actor 的目标 $\E_{a\sim\pi}[Q_\phi(s,a)]$ 只有在 $Q_\phi$ 近似正确时才有意义。如果 actor 更新得比 critic 快,它会去攻击 critic 尚未修正的估计误差——在连续动作空间里这表现为策略跑到动作边界(tanh 饱和),然后因为梯度消失再也回不来。反过来把 actor 放在 critic 之前更新也是常见错误,后果是 actor 用的是上一轮的 $Q$,等价于多了一步延迟,收敛变慢但通常不至于崩。

另外注意 update_critic 在循环里重复使用同一个 batch。这是 starter code 的设计(省一次采样),代价是同一批数据被拟合多次、稍微增加过拟合风险。InvertedPendulum / Hopper / HalfCheetah 的配置里 num_critic_updates 都是 1,所以这一点在本作业里不构成实际影响。

5. 重点二:tanh 挤压高斯的 log-prob 修正

5.1 为什么需要修正

连续控制环境的动作空间通常是有界的(MuJoCo 一律是 $[-1,1]^d$)。高斯策略的支撑集是整个 $\R^d$,直接用会产生越界动作,环境端会被 clip,于是 $\log\pi$ 与实际执行的动作对不上,梯度就错了。SAC 的做法是挤压(squash):

$$ u\sim\mathcal N\bigl(\mu_\theta(s),\,\sigma_\theta(s)^2\bigr),\qquad a=\tanh(u)\in(-1,1)^d $$

问题在于:$a$ 的密度不是 $\mathcal N(u;\mu,\sigma)$。必须做变量替换(change of variables)。

推导:雅可比行列式是怎么冒出来的 一维先做。设 $a=f(u)=\tanh u$,$f$ 严格单调因而可逆。概率必须守恒——落在区间 $[u,u+\mathrm{d}u]$ 的概率质量必须等于落在 $[a,a+\mathrm{d}a]$ 的: $$ p_A(a)\,|\mathrm{d}a| = p_U(u)\,|\mathrm{d}u| \quad\Longrightarrow\quad p_A(a)=p_U(u)\left|\frac{\mathrm{d}u}{\mathrm{d}a}\right|=\frac{p_U(u)}{|f'(u)|} $$ 多维时 $\mathrm{d}u$ 与 $\mathrm{d}a$ 的体积比就是雅可比矩阵行列式的绝对值:$p_A(a)=p_U(u)\bigl|\det\partial f/\partial u\bigr|^{-1}$。由于 tanh 是逐元素作用的,雅可比矩阵是对角阵 $\diag\bigl(f'(u_1),\dots,f'(u_d)\bigr)$,行列式就是对角元之积。取对数: $$ \log\pi(a\mid s)=\log\mathcal N(u;\mu,\sigma)-\sum_{i=1}^{d}\log\bigl|f'(u_i)\bigr| $$ 再代入 $\dfrac{\mathrm{d}}{\mathrm{d}u}\tanh u=1-\tanh^2 u=\operatorname{sech}^2u$(恒正,绝对值可去掉),得到最终形式: $$ \boxed{\;\log\pi(a\mid s)=\log\mathcal N(u;\mu,\sigma)-\sum_{i=1}^{d}\log\bigl(1-\tanh^2 u_i\bigr)\;} $$ 等价地,用 $a=\tanh u$ 可写成 $-\sum_i\log(1-a_i^2)$。
直觉:修正项的符号必须是「加」 因为 $0<1-\tanh^2u\le1$,所以 $\log(1-\tanh^2u)\le0$,减去一个负数 = 加。这符合直觉:tanh 把整条实轴压进了长度为 2 的区间,密度被压缩变高了,所以 $\log\pi$ 必须比 $\log\mathcal N$ 大。如果你算出来的修正让 $\log\pi$ 变小,符号一定写反了。

数量级也值得记:当 $u$ 接近 0 时 $1-\tanh^2u\approx1$,修正接近 0;当 $|u|=2$ 时 $1-\tanh^2 2\approx0.071$,修正约 $+2.65$ 每个维度。HalfCheetah 的 6 维动作在饱和区能贡献十几 nat 的差异——这不是可以忽略的小项。

5.2 这份代码里:修正是免费的

关键在 infrastructure/distributions.py:

def make_tanh_transformed(mean, std):
    return D.Independent(
        D.TransformedDistribution(
            base_distribution=D.Normal(mean, std),
            transforms=[D.TanhTransform(cache_size=1)],
        ),
        reinterpreted_batch_ndims=1,
    )

TransformedDistribution.log_prob 内部就是 base.log_prob(u) - transform.log_abs_det_jacobian(u, a),而 TanhTransform.log_abs_det_jacobian 的实现是

2 * (math.log(2.) - x - torch.nn.functional.softplus(-2. * x))

这正是 $\log(1-\tanh^2x)$ 的数值稳定写法(直接算 $\log(1-\tanh^2 x)$ 在 $|x|>9$ 时会得到 $\log 0=-\infty$,而 softplus 形式一路到 $|x|\sim80$ 都精确)。

常见误区(本作业排名第一):手动再扣一次雅可比 几乎所有 SAC 教程都会写这么一段:
# ✗ 在本代码库里这是双重扣除!
log_prob = normal.log_prob(u).sum(-1) - torch.log(1 - action.pow(2) + 1e-6).sum(-1)
那些教程用的是裸 Normal,所以必须手动扣。但这里已经包了 TanhTransform,再扣一次得到的是 $\log\mathcal N-2\sum_i\log(1-a_i^2)$,对应的「密度」$\tilde p(a)=p(a)\prod_i(1-a_i^2)^{-1}$ 积分不等于 1。

后果是什么? 这个多出来的 $-\sum\log(1-a^2)$ 项在 $|a|\to1$ 时趋于 $+\infty$,等价于给策略额外加了一个「远离边界」的巨大熵奖励。actor 会被推向 $\mu\approx0$、动作全挤在原点附近,HalfCheetah 上表现为回报长期卡在 0 附近(原地抽搐);同时 entropy 的日志值会明显超过理论上界 $\log 2=0.693$(1 维时),这是最容易观察到的信号。

所以 SoftActorCritic.entropy 只有一行:

def entropy(self, action_distribution):
    return -action_distribution.log_prob(action_distribution.rsample())

5.3 为什么「积分等于 1」这个测试能一击命中

test_tanh_gaussian_density_integrates_to_one 的做法是:在 $(-1,1)$ 上取 400001 个等距点,对 $\exp\bigl(\log\text{-prob}(a)\bigr)$ 做梯形积分,要求结果与 1.0 相差小于 $2\times10^{-3}$,并对 4 组不同的 $(\mu,\sigma)$ 都验一遍。

为什么这个断言比「和手写公式对拍」更强? 因为它检查的是一个不依赖于任何参考实现的性质。概率密度积分为 1 是定义级别的约束:

bug返回的量积分值
正确$\log\mathcal N(u)-\sum\log(1-a^2)$$1.000$ ✓
漏掉雅可比$\log\mathcal N(u)$$<1$($\mu=0,\sigma=1$ 时约 $0.62$)
双重扣除$\log\mathcal N(u)-2\sum\log(1-a^2)$发散(被积函数在 $\pm1$ 处不可积)
符号写反$\log\mathcal N(u)+\sum\log(1-a^2)$$\ll1$

三类错误全部被同一个断言覆盖,而且不需要你「知道正确答案长什么样」。测试套件还有另外三层加固:

  • test_tanh_gaussian_log_prob_change_of_variables:在 float64 下与手写公式逐点对拍到 $10^{-8}$,并额外断言「手写值必须比裸 Normal 值至少大 0.5」——保证测试用的 $(\mu,\sigma)$ 确实落在雅可比项非平凡的区域,避免测了个寂寞。
  • test_tanh_gaussian_monte_carlo_expectation_matches_density:用 40 万次采样估 $\E[a^2]$、$\E[|a|]$、$\E[e^a]$ 和微分熵,与数值积分对比,容差 5 倍标准误。这一步验证的是「rsample 产生的样本」与「log_prob 给出的密度」确实是同一个分布——如果你只改了 log_prob 没改采样(或反过来),前一个测试可能仍过,这个必挂。
  • test_tanh_actions_are_inside_open_interval:5 万个采样必须严格落在 $(-1,1)$ 开区间且全部有限;同时要求至少 20% 的样本 $|a|>0.5$,防止 $\sigma$ 太小让测试变成空转。

5.4 数值细节:cache_size=1 与 float32 饱和

TanhTransform(cache_size=1) 会缓存最近一次 rsample() 的 $(u,a)$ 对。调用 log_prob(a) 时,如果 $a$ 就是缓存里那个,它直接复用 $u$,不去算 $\operatorname{atanh}(a)$。

这为什么重要?因为 float32 下当 $|u|\gtrsim8.4$ 时 tanh(u) 会精确舍入到 $\pm1.0$,此时 $\operatorname{atanh}(\pm1)=\pm\infty$,log_prob 变成 NaN,整个训练在一步之内污染所有参数。float64 下这个阈值是 $|u|\gtrsim19$,所以在 CPU 上用 double 调试时不容易复现,一上 GPU float32 就炸——典型的「本地好好的,跑起来就 NaN」。

注意:缓存只对「刚采出来的那个动作」有效 entropy() 里写的是 -dist.log_prob(dist.rsample()),采样和求 log-prob 紧挨着,缓存必然命中。但 actor_loss_reparametrize 里先 action = dist.rsample(),中间过了一遍 critic,再 dist.log_prob(action)——中间没有别的采样,缓存仍然命中。如果你在这两句之间插了一次 dist.sample()(比如为了打日志),缓存会被覆盖,后面的 log_prob 就要走 atanh 路径,饱和风险回来了。这是个真实存在的陷阱。

测试 test_tanh_actions_are_inside_open_interval 特意在 $\sigma$ 很大(会饱和)时只检查闭区间性质,而对严格开区间的检查用现实的 $\sigma$——就是因为大 $\sigma$ 下 float32 的 tanh 一定会取到 $\pm1$,那不是实现 bug 而是浮点事实。

5.5 一维 tanh 高斯的最大熵:为什么是 0.678 而不是 0.693

PDF §3.3 的自测标准是:只开熵项、不开重参数化 actor 时,熵应该爬升到「接近一维 tanh 动作空间的最大熵 $\log2\approx0.69$」。$\log 2$ 来自 $(-1,1)$ 上的均匀分布:$H=-\int_{-1}^{1}\frac12\log\frac12\,\mathrm{d}a=\log2$。

但 tanh 高斯这个参数族永远取不到均匀分布,只能逼近。test_sac_entropy_estimator_and_max_entropy_of_1d_tanh_policy 扫了一遍 $\sigma$:

$\sigma$0.50.81.01.21.52.0
$H$(nat)0.5000.6780.6700.6010.4230.001

族内最优在 $\sigma\approx0.8$ 处取得 $H\approx0.678$,距离 $\log2=0.6931$ 差 0.015。$\sigma$ 再大反而下降:因为 tanh 把大 $|u|$ 全部压到 $\pm1$ 附近,密度在两端堆成两个尖峰,微分熵掉回接近 0。$\sigma=2.0$ 时熵只剩 0.001。

这张表的实用价值:如果你的 InvertedPendulum sanity 跑出来熵稳定在 0.65~0.68,实现基本正确;如果超过 0.6931,你多半漏扣或算错了雅可比(密度没归一化,熵可以任意大);如果停在 0.3 以下且不动,多半是 .sample() 断了梯度(见 §7)。

6. 熵奖励:进 actor,也要进备份

要求。 PDF §3.3 给了两条公式,很多人只实现了第一条:

$$ J_\pi=Q\bigl(s,\mu_\theta(s)+\sigma_\theta(s)\epsilon\bigr)+\beta\,\mathcal H\bigl(\pi(a\mid s)\bigr), \qquad y\leftarrow r+\gamma(1-d)\bigl[Q_\phi(s',a')+\beta\,\mathcal H\bigl(\pi(a'\mid s')\bigr)\bigr] $$

第二条(熵进备份)是最大熵 RL 的核心。没有它,critic 学的是普通的 $Q^\pi$,而 actor 却在最大化「$Q$ + 熵」——两者优化的不是同一个目标,得到的策略既不是最优确定性策略也不是最优最大熵策略。

推导:软 Q 函数满足的是软贝尔曼方程 最大熵 RL 的目标是 $\max_\pi\sum_t\E\bigl[r_t+\beta\mathcal H(\pi(\cdot\mid s_t))\bigr]$。把熵当成「奖励的一部分」,对应的软状态值函数是 $$ V^\pi(s)=\E_{a\sim\pi}\bigl[Q^\pi(s,a)\bigr]+\beta\,\mathcal H\bigl(\pi(\cdot\mid s)\bigr) =\E_{a\sim\pi}\bigl[Q^\pi(s,a)-\beta\log\pi(a\mid s)\bigr] $$ 于是软贝尔曼方程写作 $Q^\pi(s,a)=r+\gamma\E_{s'}\bigl[V^\pi(s')\bigr]$。把 $V^\pi$ 代进去、用单样本 $a'\sim\pi(\cdot\mid s')$ 估计期望,就得到代码里那一行。关键在于熵是 $s'$ 处的熵,不是 $s$ 处的——因为它属于「下一步开始的未来」。用错状态是隐蔽 bug:数值量级差不多,训练照跑,但 $Q$ 的语义错了。
# update_critic 里
if self.use_entropy_bonus and self.backup_entropy:
    next_action_entropy = self.entropy(next_action_distribution)   # π(·|s') 的熵
    assert next_action_entropy.shape == (batch_size,)
    next_qs = next_qs + self.get_temperature() * next_action_entropy[None]

# update_actor 里
if self.use_entropy_bonus:
    loss = loss - self.get_temperature() * entropy      # loss = -Q̄ - β·H

易错点:actor loss 里的符号。 目标是最大化 $J_\pi=\E[Q]+\beta H$,损失是 $-J_\pi=-\E[Q]-\beta H$。所以是 loss = loss - beta * entropy(在已有的 loss = -q.mean() 基础上再减)。写成 + 会变成最小化熵:策略会迅速塌成确定性的,$\sigma\to0$,动作全部饱和到 $\pm1$,InvertedPendulum 的熵日志会一路掉到 $-5$ 以下并且回报锁死在 100 上下。这个错误的好处是症状极其明显——熵单调下降就是信号。

怎么验证。 test_sac_entropy_bonus_enters_the_target 的设计很巧:把 backup_entropy 开、关各跑一次目标计算,断言两者之差恰好等于 $\gamma(1-d)\beta H$。这个「精确差值」断言能同时抓住:漏乘 $\gamma$、漏乘 $(1-d)$、漏乘 $\beta$、以及把熵加在括号外面($y=r+\gamma(1-d)Q+\beta H$)。而 test_alpha_used_by_actor_and_critic_updates 则验证 actor 损失精确等于 $-\overline{Q}-\alpha H$。

注意:use_entropy_bonus 与 backup_entropy 是两个开关 use_entropy_bonus=False 时熵项在 actor 和 backup 里都不生效;use_entropy_bonus=True, backup_entropy=False 则只在 actor 里加熵。PDF §3.3 的自测阶段(还没实现重参数化 actor)会出现一个特殊状态:actor 损失只有熵项,此时 actor 唯一的目标就是把熵推到最大,于是熵应该单调爬升并逼近 $\log2\approx0.69$。这是一个非常干净的分阶段自测,别跳过它直接实现 §3.4。

7. .sample() vs .rsample():断掉重参数化会让 actor 完全学错

要求。 PDF §3.4:「Parametrize $\pi_\theta$ as $\mu_\theta(s)+\sigma_\theta(s)\epsilon$... Hint: you can use .rsample() to get a reparametrized sample from a distribution in PyTorch.」代码注释里两处都写着「Think about whether to use .rsample() or .sample() here...」——这个提示出现两次不是偶然。

7.1 两种梯度估计量

推导:路径导数 vs 似然比 我们要算 $\nabla_\theta\E_{a\sim\pi_\theta(\cdot\mid s)}[Q(s,a)]$。有两条路:

(A)似然比 / REINFORCE(HW2 用的那条): $$ \nabla_\theta\E_{a\sim\pi_\theta}[Q]=\E_{a\sim\pi_\theta}\bigl[Q(s,a)\,\nabla_\theta\log\pi_\theta(a\mid s)\bigr] $$ 它把 $\theta$ 的影响全部塞进 $\log\pi$ 的梯度里,$Q$ 只当标量权重。不需要 $Q$ 可微,但方差随 $Q$ 的量级线性增长——Hopper 上 $Q$ 到 200 量级,方差会大到没法用。

(B)路径导数 / 重参数化(本作业用的):写 $a=g_\theta(s,\epsilon)=\tanh\bigl(\mu_\theta(s)+\sigma_\theta(s)\epsilon\bigr)$,$\epsilon\sim\mathcal N(0,I)$ 与 $\theta$ 无关,于是期望的分布不再依赖 $\theta$,可以直接把梯度搬进去: $$ \nabla_\theta\E_{\epsilon\sim\mathcal N}\bigl[Q\bigl(s,g_\theta(s,\epsilon)\bigr)\bigr] =\E_{\epsilon}\Bigl[\underbrace{\nabla_a Q(s,a)\big|_{a=g_\theta}}_{\text{critic 的输入梯度}}\cdot\;\nabla_\theta g_\theta(s,\epsilon)\Bigr] $$ 这里用到了 $Q$ 关于动作的梯度——这是路径导数比似然比强的根本原因:它利用了 critic 的一阶信息,而不是只把它当成一个数。PDF 那句「often gives a much lower variance, so it can be used with few samples(in practice, just using a single sample tends to work very well)」说的就是这件事。

7.2 写成 .sample() 会发生什么

action = action_distribution.rsample()    # ✓ 保留 θ → a 的计算图
q_values = self.critic(obs, action)       # (N, B)
loss = -q_values.mean()

如果换成 .sample():PyTorch 的 sample() 内部包了 with torch.no_grad(),返回的张量 requires_grad=False。于是 q_values 对 actor 的参数没有任何依赖,loss.backward() 之后 actor 所有参数的 .grad 是 None(或全零,取决于是否有其它路径)。

常见误区:它不会报错,只会安静地什么都不学 症状清单(按出现顺序):
  • 训练能跑完,损失是有限数,日志一切正常;
  • 如果同时开了熵项,actor 仍有一条梯度路径(熵项里的 rsample),于是 actor 会去纯粹地最大化熵——策略越来越随机;
  • InvertedPendulum 上熵爬到 0.67 附近就不动了,评估回报永远在 100 以下(随机策略摆几十步就倒);
  • 如果 entropy() 里也用了 .sample(),那 actor 就完全没有梯度,参数一动不动,回报锁死在初始水平。
反过来,critic 更新里的 next_action 用 .sample() 是对的(整段在 no_grad() 里,且目标值本来就不该有梯度)。所以两处「用哪个」的答案不同,这就是注释问了两遍的原因。

7.3 entropy() 里也必须是 rsample

熵的单样本估计是 $\hat{\mathcal H}=-\log\pi_\theta(a\mid s)$,其中 $a\sim\pi_\theta$。这里 $\theta$ 出现在两个地方:分布本身,以及被采出来的 $a$。用 rsample 时两条路径都保留,梯度是完整的;用 sample 时只剩「分布」那条。

有趣的是,理论上第二条路径的期望贡献为零(因为 $\E_{a\sim\pi}[\nabla_\theta\log\pi]=0$),所以用 sample 得到的仍是无偏估计的一部分——但它丢掉了低方差的那一半,并且在只有一个样本时表现很差。实践中的结论就是 NOTES 里那句:「训练仍然『能跑』,但 actor 拿不到熵奖励,InvertedPendulum 上熵停止向 $\log 2$ 爬升。」

怎么验证。 test_sac_entropy_estimator_and_max_entropy_of_1d_tanh_policy 的第一行断言就是 assert ent.requires_grad, "entropy must stay differentiable (use rsample)"——一句话锁死。test_sac_update_end_to_end_runs_and_learns 则在完整 update() 之后收集 actor 所有参数的梯度,断言它们全部有限且绝对值之和大于 0("no gradient reached the actor")。这条断言是本测试套件里最省事的「重参数化断了没有」检查器。

8. clipped double-Q:连续动作下的过估计对策

要求。 PDF §3.6:「learn two critics $Q_{\phi_A},Q_{\phi_B}$(and keep two target networks),then compute the target values by taking the minimum of both critics: $y_A=y_B=r+\gamma\min\bigl(Q_{\phi'_A}(s',a'),Q_{\phi'_B}(s',a')\bigr)$」。

8.1 为什么连续动作下 Double-DQN 那一招不好使

DQN 的 double 技巧依赖「用另一个网络做 $\argmax$」。连续动作里没有 $\argmax$——动作由 actor 给出,而 actor 恰恰是被训练去最大化 critic 的。也就是说 actor 会主动搜索 critic 高估最严重的那块区域,过估计不但没被稀释,反而被定向放大了。这是 TD3/SAC 论文的核心观察。

对策换成「悲观」:训两个独立初始化的 critic,备份时取逐元素最小值。设 $Q_i=Q^*+\varepsilon_i$,$\varepsilon_i$ 独立零均值,则 $\E[\min_i Q_i]\le\min_i\E[Q_i]=Q^*$——$\min$ 是凹函数,Jensen 方向与 $\max$ 相反,得到的是向下有偏的估计。用一个已知的低估去抵消一个未知的高估,代价是引入了偏差,但保守偏差在自举里不会滚雪球(低估的目标训出低估的 $Q$,下一轮 $\min$ 也不会更低到失控)。

if self.target_critic_backup_type == "mean":
    next_qs = next_qs.mean(dim=0)          # (N,B) -> (B,)   single-Q 基线
elif self.target_critic_backup_type == "min":
    next_qs = next_qs.min(dim=0).values    # (N,B) -> (B,)   clipped double-Q
...
if next_qs.shape == (batch_size,):
    next_qs = next_qs[None].expand((self.num_critic_networks, batch_size)).contiguous()
常见误区三连
  • torch.min(next_qs)(不带 dim):返回整个 batch 的全局标量最小值,形状 ()。后面的 expand 会把这一个数广播给所有样本,于是每条转移的目标都一样。critic 退化成常数函数,Hopper 回报永远是 15 左右(摔倒即止)。next_qs.min(dim=0) 返回的是 (values, indices) 具名元组,必须取 .values。
  • min(dim=1):沿 batch 维取最小,形状变成 (N,),后面的 expand 断言会挂(好抓)。
  • 忘了 .contiguous():expand 返回的是 stride 为 0 的视图。绝大多数情况能正常用,但一旦有 in-place 操作或需要 .view() 就会报错。starter code 写了,别删。

8.2 两个 critic 收到同一个目标

这一点值得强调:$y_A=y_B$。两个 critic 不是各学各的,它们回归的是同一组标签。它们之所以还能保持「独立」,靠的是不同的随机初始化和不同的 minibatch 噪声路径。这也解释了为什么必须用 nn.ModuleList 分别 make_critic(...) 两次,而不能 deepcopy 同一个——完全相同的初始化会让 $\min$ 退化成 identity,clipped double-Q 变回 single-Q。

怎么验证。 两个测试分工明确:

  • test_q_backup_strategy_min_is_elementwise_minimum:随机造一组 (2,17) 的 next_qs,断言输出精确等于 torch.minimum(qA,qB)、两行完全相同、逐元素 $\le$ 每个 critic、且严格小于 mean 备份的结果。「严格小于 mean」这一条排除了「$\min$ 写成了 $\text{mean}$」的复制粘贴错误。
  • test_sac_critic_target_hand_computation_min_vs_mean:把两个 target critic 换成常数函数 $Q'_A\equiv3$、$Q'_B\equiv-1$。min 应该自举 $-1$,mean 应该自举 $+1$;终止行则两者都等于 $r$。常数 critic 让期望值可以手算,是一个非常好用的测试技巧。

9. 重点三:熵温度的自动调节

9.1 从约束优化到对偶梯度

要求。 PDF §3.5 把选 $\beta$ 变成一个约束问题:

$$ \max_\theta\;\E\Bigl[\sum_{t}\gamma^t r_t\Bigr] \quad\text{s.t.}\quad \E_{(s_t,a_t)\sim\rho_\pi}\bigl[\mathcal H(\pi(\cdot\mid s_t))\bigr]\;\ge\;H_{\text{target}} $$

其中 $H_{\text{target}}=-\dim(\mathcal A)$。

推导:$\alpha$ 是拉格朗日乘子,它的梯度只有一项 引入乘子 $\alpha\ge0$,拉格朗日函数为 $$ \mathcal L(\theta,\alpha)=J_r(\theta)+\alpha\Bigl(\E\bigl[\mathcal H(\pi_\theta)\bigr]-H_{\text{target}}\Bigr) $$ 对偶梯度下降交替做两件事:固定 $\alpha$ 优化 $\theta$(这就是 actor/critic 更新,$\alpha$ 扮演温度 $\beta$ 的角色);固定 $\theta$ 优化 $\alpha$。第二步里 $J_r(\theta)$ 与 $\alpha$ 无关,可以丢掉,剩下 $$ \frac{\partial\mathcal L}{\partial\alpha}=\E\bigl[\mathcal H(\pi_\theta)\bigr]-H_{\text{target}} =\E_{a\sim\pi}\bigl[-\log\pi(a\mid s)-H_{\text{target}}\bigr] $$ 我们要最小化对偶(乘子往约束被违反的方向走),所以定义损失 $$ J(\alpha)=\E_{s\sim\mathcal D,\,a\sim\pi}\bigl[-\alpha\bigl(\log\pi(a\mid s)+H_{\text{target}}\bigr)\bigr] $$ 再用 $\alpha=e^{\log\alpha}$ 重参数化保证 $\alpha>0$(乘子必须非负,而无约束的 $\log\alpha$ 更适合喂给 Adam)。链式法则给出 $$ \frac{\partial J}{\partial(\log\alpha)}=\frac{\partial J}{\partial\alpha}\cdot\alpha =-\alpha\bigl(\log\pi+H_{\text{target}}\bigr) $$ 于是梯度下降把 $\log\alpha$ 移动 $+\eta\,\alpha\,(\log\pi+H_{\text{target}})$。

把符号掰开看,这是一个非常干净的比例控制器:

当前熵 $H=\E[-\log\pi]$$\log\pi+H_{\text{target}}$ 的符号$\alpha$ 的运动直觉
$H<H_{\text{target}}$(太确定)$>0$上升提高「变确定」的价格,逼策略保持随机
$H>H_{\text{target}}$(太随机)$<0$下降探索已经够多,把权重还给回报
$H=H_{\text{target}}$$=0$不动约束恰好激活,到达鞍点

9.2 为什么 $H_{\text{target}}=-\dim(\mathcal A)$

这是 SAC 第二篇论文的启发式。注意它是负数:微分熵可以为负,$-\dim(\mathcal A)$ 相当于要求「每个动作维度的熵不低于 $-1$ nat」。对 $(-1,1)$ 上的分布,单维熵上界是 $\log2\approx0.69$,所以 $-1$ 是一个相当低的目标——大致对应每维标准差 $\sigma\approx0.13$ 的窄高斯。它的作用不是「鼓励探索」,而是「设一个不许再确定下去的地板」。理解这一点对解释 §11.6 的 HalfCheetah 现象至关重要:$\dim(\mathcal A)=6$ 时目标是 $-6$,而策略初始熵只有 $-1.14$,远高于目标,于是 $\alpha$ 一路下降。

9.3 实现与三处 detach

# __init__
assert temperature > 0, "temperature must be > 0 to initialize log_alpha"
self.log_alpha = nn.Parameter(torch.tensor(float(np.log(temperature)),
                                           dtype=torch.float32, device=ptu.device))
self.alpha_optimizer = torch.optim.Adam([self.log_alpha], lr=alpha_learning_rate)
self.target_entropy = -float(action_dim)

# get_temperature
return self.log_alpha.exp().item()          # ← 返回 float,不是 tensor

# update_alpha
alpha = self.log_alpha.exp()
alpha_loss = -(alpha * (log_prob.detach() + self.target_entropy)).mean()
self.alpha_optimizer.zero_grad(); alpha_loss.backward(); self.alpha_optimizer.step()
核心:为什么 $\log\pi$ 必须 detach 对偶梯度的推导里,第二步是「固定 $\theta$ 优化 $\alpha$」。如果不 detach,alpha_loss.backward() 会把 $-\alpha\nabla_\theta\log\pi$ 这一项累加到 actor 的 .grad 上。后果分两种:
(1)如果 actor 的优化器随后 step()(比如你把 update_alpha 放在 update_actor 的 zero_grad 之前):actor 会额外收到一个「最小化 $-\alpha\log\pi$」即最大化熵的梯度,权重是 $\alpha$。这等于把熵奖励算了两遍,策略过度随机,HalfCheetah 回报大约腰斩。
(2)如果顺序正确(本代码里 update_actor 先 step 完,且返回的 log_prob 已经 .detach() 过):脏梯度会残留到下一次 zero_grad 被清掉,数值上无害——但你已经在依赖「下游一定会 zero_grad」这种脆弱假设了。

本实现做了双保险:update_actor 返回时写 "log_prob": log_prob.detach(),update_alpha 里又写一次 log_prob.detach()。重复但正确。

反方向的 detach:get_temperature() 返回 .item()。 这是一个 Python float,所以 actor 损失里的 self.get_temperature() * entropy 和 critic 目标里的乘法都不可能反传到 log_alpha。如果这里返回 self.log_alpha.exp()(张量),actor 的 loss.backward() 会给 log_alpha 塞进一个梯度:$\partial(-\alpha H)/\partial\log\alpha=-\alpha H$。由于 $H$ 通常为正,这个梯度是负的,Adam 会持续增大 $\alpha$——actor 发现「把温度调高能直接降低自己的损失」,于是 $\alpha$ 单调爆炸,策略变成纯噪声。这是一个非常隐蔽且致命的 bug:它不报错,只是让 $\alpha$ 在几万步内涨到 $10^2$ 量级。

9.4 数值验证

test_alpha_autotuning_direction_and_loss 直接喂人造的 log_prob,取 $\alpha_0=0.1$、$\dim(\mathcal A)=2$ 因而 $H_{\text{target}}=-2$:

喂进去的 $\log\pi$隐含熵 $H$期望的 alpha_loss实测$\alpha$ 的走向
$+5$$-5$(低于目标)$-0.1\times(5-2)=-0.30$$-0.30$上升 ✓
$-10$$+10$(高于目标)$-0.1\times(-10-2)=+1.20$$+1.20$下降 ✓
$+2$$-2$(正好等于目标)$0.0$$0.0$不动 ✓

测试还连跑 30 步检查 $\alpha=e^{\log\alpha}$ 始终严格为正、且在「熵持续偏低」的输入下至少涨了 50%。三档符号 + 一档零点 的组合能抓住所有把负号写反、把 $H_{\text{target}}$ 加成减、或者把 $\log\pi$ 当成 $-\log\pi$(即误用了 entropy 而不是 log_prob)的实现——最后这一类特别常见,因为 update_actor 同时返回了 entropy 和 log_prob,传错参数的代价是 $\alpha$ 朝完全相反的方向跑。

注意:日志里的 alpha 和 temperature 差一步 update_alpha 先算 alpha = log_alpha.exp() 再做优化器 step,返回的 info["alpha"] 是更新前的值;而 update() 末尾的 result["temperature"] = self.get_temperature() 是更新后的值。同一行日志里两者相差一个梯度步(lr 1e-4 下约 0.1%)。无害,但如果你写测试断言 alpha == temperature 会随机失败——测试里用的容差是 5%。

10. DQN 实验与结果

所有实验:单种子(seed 1),WANDB_MODE=disabled,RTX 5080,日志来自 exp/<run>/log.csv。超参数取自 yaml:$\gamma=0.99$,Adam,$\epsilon$ 分段线性 $1.0\to0.1$(前 30% 步)$\to0.02$(前 60% 步)。

10.1 三个环境的总览(含未达标声明)

run已跑 / 配置步数作业目标最佳 eval 回报@ step状态
CartPole vanilla100 000 / 100 000500500.017 500完成,达标
CartPole double-Q100 000 / 100 000—276.385 000完成(消融用)
LunarLander vanilla500 000 / 500 000—279.9450 000完成(消融用)
LunarLander double-Q355 265 / 500 000200256.0330 000截断,但在切断前已超过目标 200
MsPacman double-Q70 000 / 300 000(原 1 000 000)1500450.060 000截断 — 未达标
DQN 在 CartPole / LunarLander / MsPacman 上的训练与评估回报曲线
三个环境的学习曲线(灰=训练回报的滑动平均,蓝=评估回报,红虚线=作业目标)。左:CartPole 在约 1.75 万步冲到 500 后回落——这是 DQN 常见的「灾难性遗忘」,达标只要求「至少一次达到 500」。中:LunarLander 在 15 万步左右稳定越过 200 分线,之后在 200~260 之间震荡;标题里写明 355k/500k 截断。右:MsPacman 只跑到 70k/1000k,蓝色评估曲线从 60 单调上升到 450,趋势明确但离 1500 还差得远;灰色训练曲线开头那个 1050 的尖峰是随机策略头几个 episode 的运气,滑动平均很快落到 230。
必须如实说明 MsPacman 没有达到 1500 的目标。它跑到 70 000 步(配置里已经从原始的 1 000 000 缩短到 300 000,实际又在 70 000 处被杀掉),此时评估回报 450。四个评估点分别是 60 / 190 / 240 / 450(对应 0 / 20k / 40k / 60k),单调上升且斜率在最后一段变陡,看不出饱和迹象。但「趋势向上」不等于「会达标」——Atari 上 DQN 的典型学习曲线在几十万步处会有一个长平台期,仅凭 7 万步无法外推。本文不把它算作通过。

10.2 训练回报 vs 评估回报:为什么早期差别巨大

训练回报与评估回报画在同一坐标轴上
PDF §2.5 明确要求的交付物:把 train return 与 eval return 画在同一坐标系。该看的是两条曲线早期的分离方向——LunarLander 上评估回报一开始低于训练回报,之后反超。

机制。 训练回报由 $\epsilon$-greedy 行为策略产生,此时 $\epsilon$ 还很大(从 1.0 往 0.1 衰减),所以它测的是「一个大部分时间在乱动的策略」;评估回报由 贪心策略($\epsilon=0$)产生,测的是「完全相信当前 $Q$ 的策略」。两者的差距方向取决于环境:

  • LunarLander:早期 eval 更差。 一个还没训好的 $Q$ 函数会一致地选同一个错误动作(比如一直点主引擎),飞船直接摔死,回报 $-600$;而带随机噪声的行为策略反而经常「误打误撞」把着陆器稳住,训练回报只有 $-200$ 左右。等 $Q$ 变准之后,贪心的优势体现出来,eval 反超到 250+。
  • CartPole:早期 eval 略好。 因为 CartPole 里「随机动作」的破坏力更大——杆子倒了就结束,噪声几乎没有正面价值。
  • MsPacman:训练曲线开头虚高。 平滑窗口在最初几个 episode 上还没稳定,随机策略偶尔吃到一串豆子就冲到 1050,随后落回 230。这纯粹是统计假象,不是策略退化。
直觉:这两条曲线在测两件不同的事 train return 是「off-policy 算法的数据采集质量」的指标,eval return 才是「学到的策略有多好」。DQN 是 off-policy 的,所以它们本来就不需要一致。作业问这个问题,是想让你意识到:报告 RL 结果时必须说清楚回报是用哪个策略测的。很多论文的曲线不可比就是栽在这一点上。

10.3 Double-Q vs vanilla:该看哪个指标

Double DQN 与 vanilla DQN 在 CartPole 与 LunarLander 上的对比:上排评估回报,下排平均预测 Q 值
上排是评估回报,下排是平均预测 $Q(s,a)$。请重点看右下那张:在共同的 0–355k 窗口(黑虚线)内,红色(vanilla)的 $Q$ 曲线系统性地位于蓝色(double)上方,终点 84.1 vs 70.9。上排的回报差异则完全在种子噪声范围内。
指标vanilladouble-Q解读
LunarLander 平均 $Q$ @ 355k84.170.9低 16%——这是关键证据
LunarLander 0–355k 内最佳 eval266.8256.0差 10.8 分,单种子下不可区分
CartPole 平均 $Q$ @ 100k54.654.1几乎重合
CartPole 最佳 eval500.0276.3纯种子噪声,不要解读
核心结论:Double-Q 的效果要在 $Q$ 值上看,不在回报上看 Double-DQN 的理论承诺是「降低过估计偏差」,不是「提高回报」。降低偏差可能转化为更高回报(当过估计严重到破坏策略改进时),也可能不转化(当任务本身容易、$Q$ 的绝对值不影响 $\argmax$ 时)。所以:

验证一个方差削减/偏差消除技巧,要去看它直接作用的那个量。 这里就是 info["q_values"](每步 batch 内 $Q(s,a)$ 的均值),日志里现成有。LunarLander 上 16% 的乐观度下降是干净、单调、贯穿整条曲线的信号;而回报的 10 分差距在单种子下毫无统计意义。

CartPole 上 500 vs 276 的回报差看着惊人,但看下排的 $Q$ 曲线——两条几乎完全重叠。CartPole 的 $\gamma$-折扣回报有硬上界(每步奖励 1,$Q\le1/(1-\gamma)=100$),两动作的 $Q$ 函数又极其容易学,$\max$ 的过估计根本没有发挥空间。把 500 vs 276 解读成「double-Q 更差」是典型的单种子过度解读。

10.4 超参敏感性:target_update_period 扫描(部分完成)

PDF §2.6 要求选一个超参、跑至少四个取值。这里选的是目标网络更新周期,理由是它直接对应第 8 讲那个「移动靶」的理论讨论,能把「速度 vs 稳定」的权衡可视化。但只有两个设置跑够了步数,另外两个在约 2 万步被杀,已从图中剔除。

target_update_period已跑步数最佳 eval@ step
100208 228267.1130 000
1000(默认)355 265256.0330 000
50020 069(被杀)——
400023 537(被杀)——
LunarLander 上 target_update_period 取 100 与 1000 的对比曲线
只有两条曲线的「四点扫描」——这不是作业要求的完整交付物,如实标注。看 0–208k 的重叠窗口:周期 100(激进)在 13 万步时最佳 eval 已达 267.1,而周期 1000 在同一时刻还只有 27.6,直到 20.8 万步才爬到 239.8。代价是周期 100 的 $Q$ 值曲线明显更抖。

怎么解释这个结果。 短周期意味着目标网络紧跟在线网络:奖励信息沿着状态链传播得快(每 100 步就有一次「新学到的值」被写进目标),所以早期学习速度快得多。代价是回归目标一直在动,接近于「用自己预测自己」,方差大、容易发散。长周期给出稳定的回归目标,但目标里的值是陈旧的——LunarLander 前 13 万步几乎白学,就是因为目标网络还停留在很早的估计上。

这条权衡曲线不是单调的:周期太短(比如 1)会退化成没有目标网络,$Q$ 发散;周期太长(比如 4000)在 50 万步预算下大概会学得极慢。可惜这两端的实验都没跑够,无法给出实证。这是一个 2 点对比,不是作业要的 4 点扫描。

11. SAC 实验与结果

11.1 InvertedPendulum 双重 sanity check(两个都跑完了)

run步数最佳 eval最终 eval状态
sanity_invertedpendulum.yaml(固定 $\beta=0.1$)50 000 / 50 0001000.01000.0完成,solved
sanity_invertedpendulum_autotune.yaml50 000 / 50 0001000.01000.0完成,solved

配置:2×64 MLP,actor/critic lr 3e-4,batch 128,$\gamma=0.99$,软目标 $\tau=0.005$,单 critic,mean 备份,use_tanh,random_steps 1000,training_starts 5000。

InvertedPendulum 上固定温度与自动调温的四联图:评估回报、Q 值、策略熵、温度
四张子图对应四个必须分别检查的量。第 1 张:两条曲线都在 5 万步内打满 1000(红点线)。注意 autotune(绿)在约 2.2 万步就跳上去,fixed(蓝)拖到 4.2 万步——单种子,不必过度解读。第 2 张:$Q$ 稳定爬升到 62.7(fixed)/ 79.0(autotune),既没爆炸也没停在 0,这正是 PDF §3.2 的 sanity 判据。第 3 张:策略熵从 $\log2=0.693$(红点线,随机初始化的策略几乎均匀)快速下降并稳定在 $-0.97$ 附近,与 $H_{\text{target}}=-\dim(\mathcal A)=-1$ 吻合。第 4 张:学到的 $\alpha$ 从 0.100 掉到最低 0.051,再回升到 0.068,是典型的对偶控制器「过冲 + 回调」。
§3.2 的 sanity 判据怎么用 PDF 说:「the Q-values should stabilise at some reasonable value. If the Q-values go to infinity or stay at zero, you probably have a bug.」「reasonable」要有个尺子:InvertedPendulum 每步奖励恒为 1,若能永远站住,$Q$ 的理论上界是 $\sum_t\gamma^t=1/(1-\gamma)=100$。实测在 4.5 万步时是 62.7 / 79.0,低于 100 且在缓慢逼近——低的那部分一是策略还没完美,二是软 $Q$ 里减掉了熵惩罚。

如果你看到 $Q$ 超过 100,说明有过估计或者 $(1-d)$ 漏了;如果 $Q$ 一直贴着 0,多半是 target_values 的梯度没断(no_grad 漏了)导致 critic 学到了平凡解 $Q\equiv0$。

11.2 Hopper:single-Q vs clipped double-Q(两个都被截断)

run已跑 / 配置步数作业目标最佳 eval@ step
hopper_singleq_short.yaml(1 critic,mean)177 535 / 300 000(原 500 000)—664.0140 000
hopper_clipq_short.yaml(2 critics,min)66 841 / 300 000(原 500 000)1500602.450 000
必须如实说明 两个 run 都没有达到 1500 的目标,都在配置预算之前被杀。 clipped double-Q 只跑到 66 841 步(配置 300 000,原始作业配置 500 000),此时最佳回报 602.4。唯一公平的比较窗口是共同的 0–66k,图里用黑虚线标出。66k 之后只有 single-Q 一条线,不能拿来做对比。
Hopper 上 single-Q 与 clipped double-Q 的三联图:评估回报、平均预测 Q、critic 损失
左:评估回报,灰点线是没摸到的 1500 目标,黑虚线是 66k 公平比较线。中:平均预测 $Q(s,a)$——这才是过估计的直接证据。右:critic 损失(对数轴)。请重点看中间那张在 66k 之后的红线:single-Q 的 $Q$ 一路涨到 265,而左图的回报早在 600 附近就平了。
在 $\le$ 66k 窗口内single Q(mean)clipped double Q(min)
最佳 eval 回报399.6602.4(+51%)
60k 处 eval 回报399.6577.3
60k 处平均预测 $Q$205.9186.5(−9%)

「更好」且「更不乐观」同时成立,这正是过估计故事的完整形态:两个独立 critic 取 $\min$ 是一个悲观估计,actor 不会被「某个 critic 恰好在这块区域估高了」的假信号牵着跑。

过估计的教科书式指纹:$Q$ 涨而回报不涨 把 single-Q 的两条曲线叠起来看:17.7 万步时 $Q\approx265$,意味着 critic 相信「从当前状态出发能拿到约 265 的折扣回报」;而实测的无折扣回报只有 600 出头,考虑 $\gamma=0.99$ 的折扣,真实折扣回报大致在 100~150 量级。critic 高估了将近一倍,而且这个缺口还在扩大。

这是判断「我的 SAC 是不是被过估计害了」的最实用信号:不要只看回报曲线是不是平了,要看 $Q$ 是不是还在往上爬。回报平 + $Q$ 平 = 收敛;回报平 + $Q$ 涨 = 过估计正在发生。

但要克制:单种子,且 clipq 只跑到 66k。 这个趋势是提示性的(suggestive),不是结论性的(conclusive)。

11.3 HalfCheetah:自动调温 vs 固定温度(两个都被截断)

run已跑 / 配置步数作业目标最佳 eval@ step
halfcheetah_short.yaml(固定 $\beta=0.1$)278 000 / 300 000(原 1 000 000)60005551.3270 000
halfcheetah_autotune_short.yaml69 999 / 300 000(原 1 000 000)—3543.460 000
必须如实说明 两个都没到 6000。 固定 $\beta$ 的一跑在 27 万步时到 5551,被杀时曲线仍在单调上升,趋势指向 6000,但它没有达到,而且原始配置的预算是 100 万步。autotune 只跑到 7 万步。把「趋势指向」写成「达标」是学术不诚实。
HalfCheetah 上固定温度与自动调温的对比:评估回报、alpha 随训练的变化、熵与目标熵
PDF §3.5 要求的两子图交付物(这里画了三张)。左:两条评估回报曲线 + 66k 公平比较线。中:学到的 $\alpha$ 随训练变化——注意它是下降的。右:实测策略熵与 $H_{\text{target}}=-6$ 的对照,autotune 精确收敛到目标线上,固定 $\beta$ 则停在 $-4.06$。
在 $\le$ 66k 窗口内固定 $\beta=0.1$自动调温 $\alpha$
最佳 eval 回报2232.73543.4
60k 处 eval 回报2113.63543.4
60k 处实测熵−4.06−6.11(目标 −6.00)

回答 PDF §3.5 的三个问题(基于两者共有的 66k):

  1. 自动调温有帮助吗? 早期有:60k 处 3543 vs 2233。但这是单种子 + 短视野,只能说「至少可比」。PDF 自己也提示默认的 $\beta=0.1$ 对 HalfCheetah 已经调得不错,不该期待 100 万步时有大差距。自动调温的真正价值是省掉手工搜索,在没有已知好值的新环境上才最明显。
  2. $\alpha$ 怎么演化? 先下降,后部分回升:$0.100\to\mathbf{0.024}$(最低点)$\to\mathbf{0.048}$(60k 处)。
  3. 为什么是这个方向? 因为 $H_{\text{target}}=-\dim(\mathcal A)=-6$——HalfCheetah 的动作是 6 维,目标熵是个很低的值。而随机初始化的策略几乎是均匀的,实测熵 $-1.14$,远高于目标。代入 §9.1 的表:$H>H_{\text{target}}$ ⟹ $\log\pi+H_{\text{target}}<0$ ⟹ $\alpha$ 下降。也就是说在 HalfCheetah 上,对偶控制器一开始做的事情是「惩罚探索」而不是鼓励探索——它认为策略太随机了。等策略锐化到 $-6.11$(略微越过目标),梯度换号,$\alpha$ 回升并稳定。
直觉:$-\dim(\mathcal A)$ 是「地板」,不是「油门」 很多人第一次看到 $\alpha$ 下降会以为实现写反了。关键在于 $H_{\text{target}}$ 的绝对水平:动作维度越高,$-\dim(\mathcal A)$ 越负,对熵的要求越松。1 维的 InvertedPendulum 目标是 $-1$,而随机策略的熵是 $+0.69$,超出目标 1.7 nat;6 维的 HalfCheetah 目标是 $-6$,随机策略实测 $-1.14$,超出目标接近 5 nat。所以两个环境的 $\alpha$ 都会先降——只不过 HalfCheetah 降得更狠(0.100→0.024,跌掉 76%),InvertedPendulum 只降到 0.051。对比这两个数字,就能确认你的对偶步是按「熵与目标的差值」在工作,而不是在乱走。

12. 为什么这些任务需要这么多步?截断意味着什么

本作业有三个实验因算力被切断而未达标。与其糊弄过去,不如把「为什么它们本来就贵」讲清楚——这本身就是 RL 工程直觉的一部分。

12.1 三个昂贵任务的成本来源

任务配置预算贵在哪里
MsPacman1 000 000 步观测是 $4\times84\times84$ 像素,critic 是 Nature-CNN;奖励稀疏且延迟(吃到豆子才有分);$|\mathcal A|=9$,探索空间大;$\epsilon$ 从 1.0 衰减到 0.01 本身就要花掉几十万步
Hopper500 000 步奖励里有「存活奖励」,早期策略一摔就结束,每个 episode 只有十几步,采样效率极低;要先学会「站住」才谈得上「跳得远」,是典型的两阶段任务
HalfCheetah1 000 000 步17 维观测、6 维动作,$Q$ 函数的输入空间大;没有终止条件,每个 episode 固定 1000 步,回报量级到几千,critic 要拟合的动态范围极宽

共同的深层原因是 自举的传播速度。TD 学习每次梯度步只把价值信息沿状态链回传「一格」。要让终点的奖励信息传到起点,需要的更新次数与有效视野 $1/(1-\gamma)=100$ 成正比,再乘上「每个状态要被访问多次才能拟合准」的系数——几十万步是这个乘积的自然量级,不是实现效率问题。

12.2 提前终止对结论的影响:哪些能说,哪些不能

结论可信度理由
「Double-Q 的 $Q$ 值比 vanilla 低 16%」较可信这是贯穿整条曲线的单调差异,不依赖终点;且与理论预测方向一致
「clipped double-Q 在 Hopper 上早期更好」提示性共同窗口只有 66k,且单种子。66k 处的差距(602 vs 400)大于常见的种子波动,但不足以断言
「autotune 在 HalfCheetah 上更好」仅限早期只有 66k 共同窗口。PDF 自己说 $\beta=0.1$ 已调好,100 万步时很可能追平
「$\alpha$ 会下降到 0.024 再回升」可信这是机制性结论,由 $H_{\text{target}}=-6$ 与初始熵 $-1.14$ 的符号关系直接决定,与训练长度无关
「MsPacman 能达到 1500」不能说只有 7 万步、4 个评估点。Atari 曲线常有长平台,线性外推没有依据
「HalfCheetah 固定 $\beta$ 能到 6000」不能说27.8 万步到 5551 且仍在上升,只能说「趋势指向目标」
「target_update_period 的完整敏感性」不能说4 个设置只有 2 个跑够,作业要求的四点扫描未完成
一条通用原则 机制性结论($\alpha$ 的运动方向、$Q$ 的相对乐观度、目标网络周期的速度/稳定权衡)对训练长度不敏感,因为它们由公式的符号结构决定;性能性结论(能不能达标、谁的回报更高)对训练长度极度敏感。 当算力不够时,优先把机制性证据做扎实,并明确标注哪些性能数字是截断的。这比跑一堆半截的曲线然后声称达标要有价值得多。

13. 调参与踩坑记录

下面 9 条是实际实现过程中真实撞到的问题,按「第一次运行会遇到」的顺序排列。

13.1 WANDB_MODE=disabled 被无视

两个 run 脚本的 make_logger 原本写死 wandb.init(mode="online", ...)。wandb 的设置优先级是「显式 init 参数 > 环境变量」,所以 WANDB_MODE=disabled 完全不起作用,每次运行都死在 UsageError: No API key configured。改成

mode=os.environ.get("WANDB_MODE", "online"),

之后离线跑通。这是全作业唯一一处非 TODO 的改动。如果你不想改代码,也可以 wandb offline 或者直接注册一个账号——但离线跑对批量实验方便得多。

13.2 1 - done 在 bool 张量上抛异常

见 §2.3。DQN 和 SAC 两个 update_critic 都要写 done = done.float()。 报错信息是 RuntimeError: Subtraction, the '-' operator, with a bool tensor is not supported。

13.3 不要手动补 tanh 的雅可比

见 §5.2。make_tanh_transformed 已经包了 TanhTransform,其 log_abs_det_jacobian 就是 $2\bigl(\log2-x-\text{softplus}(-2x)\bigr)=\log(1-\tanh^2x)$。再手写一次 $-\sum\log(1-a^2)$ 是双重扣除,密度不再积分到 1,数值积分测试立刻抓住。

13.4 entropy() 里 .sample() 与 .rsample()

见 §7。.sample() 静默切断熵项的梯度,训练「照跑」但 actor 永远拿不到熵奖励,InvertedPendulum 上熵停止向 $\log2$ 爬升。必须 .rsample()。

13.5 $\alpha$ 除了 update_alpha 之外处处 detach

见 §9.3。get_temperature() 返回 .item()(float),保证 actor/critic 损失无法反传到 log_alpha;对称地,log_prob 在进对偶步之前 .detach()。两者耦合会让 $\alpha$ 与策略互相追逐——具体表现是 $\alpha$ 单调爆炸。

13.6 日志里的 alpha 比 temperature 早一步

update_alpha 先取 alpha = log_alpha.exp() 再做优化器 step,所以同一行日志里 info["alpha"] 是更新前的、info["temperature"] 是更新后的,差一个梯度步(lr 1e-4 下约 0.1%)。无害,但一开始把测试断言写成严格相等,随机失败了几次。

13.7 测试里 monkey-patch agent.critic_loss 会报错

self.critic_loss = nn.MSELoss() 是一个 子模块,直接 agent.critic_loss = my_fn 会触发

TypeError: cannot assign 'function' as child module 'critic_loss' (torch.nn.Module or None expected)

解决办法是先把它从 agent._modules 里 pop 出来,再赋普通属性。测试里的 spy_on_loss 辅助函数就是干这个的——这个技巧在需要「窥探中间张量」时非常好用(本文 §3.4 提到的手算目标对拍就依赖它)。

13.8 float32 下 tanh 会饱和

$|u|\gtrsim8.4$ 时 float32 的 tanh(u) 精确等于 $\pm1.0$(float64 下阈值约 19),此时 $\operatorname{atanh}$ 与 log_prob 变成非有限值。这就是 TanhTransform(cache_size=1) 在训练里至关重要的原因,也是「动作严格落在开区间」那个测试必须用现实的 $\sigma$ 而不是 $\sigma=20$ 的原因。

实用建议:在 update_actor 里加一行 NaN 探针
assert torch.isfinite(loss), f"actor loss is not finite: {loss}"
NaN 在 RL 里传播极快——一步 backward 就能污染所有参数,之后所有日志都是 nan,反查发生位置很痛苦。一行断言能把它钉在第一现场。

13.9 MemoryEfficientReplayBuffer._insert_frame 不对 framebuffer_idx 取模

它写 self.framebuffer[self.framebuffer_idx] 时没有 % max_framebuffer_size,而 sample() 里有。frame buffer 的容量是 $2\times$ capacity,所以 100 万步的 Atari 跑(100 万条转移 + 每次 reset 一帧)刚好卡在数组边界内不出事;再长一点就会 IndexError。这是 starter code 的既有问题,本作业的 Atari run 远短于此,未做修改——但如果你打算跑满 100 万步以上,值得先补上取模。

13.10 超参数速查

配置网络lrbatch目标更新其它
DQN CartPole2×64 MLP5e-4128period 1000learning_starts 1000
DQN LunarLander2×256 MLP1e-364period 1000learning_starts 20 000
DQN MsPacmanNature-CNN1e-4(Adam eps 1e-4)32period 2000grad clip 10.0,$\epsilon:1.0\to0.01$
SAC InvertedPendulum2×64 MLP3e-4128soft $\tau=0.005$1 critic,mean,$\beta=0.1$
SAC Hopper clipqMLP3e-4—soft2 critics,min
SAC HalfCheetahMLP3e-4—soft$\beta=0.1$ 或 autotune($H_{\text{target}}=-6$)

14. 自测清单

做完之后逐条对照。带 ★ 的是「写错了不报错」的静默 bug,优先检查。

14.1 DQN 代码层面

  • ☐ get_action 用 torch.no_grad() 包住,且随机分支用严格 < 比较。
  • ☐ ★ 随机动作是从全部 num_actions 里均匀抽(包括贪心动作),不是「贪心以外」。用 $\epsilon=0.5$ 时贪心动作应出现约 75% 而不是 50%。
  • ☐ update_critic 第一行有 done = done.float()。
  • ☐ ★ next_qa_values = self.target_critic(next_obs) 在 if use_double_q 的外面;只有 argmax 换网络。
  • ☐ ★ gather 的 dim=1(不是 0),index 是 (B,1),输出 squeeze(1) 回 (B,)。
  • ☐ 目标计算整体在 torch.no_grad() 内。
  • ☐ 目标同步用 load_state_dict,且只在 step % period == 0 时执行。
  • ☐ run_dqn.py 的 $\epsilon$ 来自 exploration_schedule.value(step);采样/训练在 learning_starts 之后。

14.2 SAC 代码层面

  • ☐ ★ actor_loss_reparametrize 里是 .rsample();entropy() 里也是 .rsample()。
  • ☐ ★ entropy() 只有 -dist.log_prob(dist.rsample()),没有手写的 $\log(1-a^2)$ 修正。
  • ☐ ★ actor 损失是 -q.mean() - beta * entropy(两个负号),不是加号。
  • ☐ ★ 熵进了 backup(update_critic 里),而且用的是 $\pi(\cdot\mid s')$ 的熵。
  • ☐ ★ q_backup_strategy 的 min 写成 next_qs.min(dim=0).values,不是 torch.min(next_qs)。
  • ☐ 软更新是 $\phi'\leftarrow(1-\tau)\phi'+\tau\phi$($\tau$ 乘在 online 参数上),用 .data.copy_() 原地写。
  • ☐ ★ get_temperature() 返回 .item()(float);update_alpha 里 log_prob 已 detach。
  • ☐ alpha_loss = -(alpha * (log_prob + target_entropy)).mean(),target_entropy = -action_dim(负数)。
  • ☐ 顺序是:critic 更新 $\times$ num_critic_updates → actor 更新 → alpha 更新 → 目标更新。

14.3 数值层面(跑测试)

  • ☐ uv run python tests/test_hw3.py → 18/18 通过(CPU 上约 2 分钟)。
  • ☐ 手算例子:$Q_\phi,Q_{\phi'}$ 如 §3.3 那张表时,vanilla 目标 $=4.6$、double 目标 $=2.8$、终止行 $=r=-2.0$。
  • ☐ 20 组随机 Q 表上 $y_{\text{double}}\le y_{\text{vanilla}}$ 恒成立。
  • ☐ tanh 高斯的密度在 $(-1,1)$ 上数值积分 $=1.000$(4 组 $(\mu,\sigma)$ 都要过)。
  • ☐ 一维 tanh 策略的熵 $\le\log2=0.6931$,族内最优约 $0.678$($\sigma\approx0.8$)。
  • ☐ Polyak 更新 $k$ 步后参数等于 $\phi\bigl(1-(1-\tau)^k\bigr)$。
  • ☐ $\alpha$ 的三档方向检查:$\log\pi=+5\Rightarrow\alpha\uparrow$,$-10\Rightarrow\alpha\downarrow$,$+2\Rightarrow$ 不动($\dim(\mathcal A)=2$)。
  • ☐ 完整 update() 后 actor 参数梯度全部有限且非零。

14.4 实验层面(看曲线判断)

  • ☐ CartPole 训练中至少有一次 eval 回报达到 500。达标后掉回 100 多是正常的(灾难性遗忘),不算失败。
  • ☐ LunarLander double-Q 至少有一次超过 200。若始终在 0 分附近,先查 $(1-d)$ 和 truncated 的处理。
  • ☐ ★ Double vs vanilla 的对比要画 q_values 而不只是回报。若两条 $Q$ 曲线完全重合(除 CartPole 外),八成 use_double_q 没生效——去 yaml 里确认。
  • ☐ InvertedPendulum 的 $Q$ 应稳定在几十的量级(上界 $1/(1-\gamma)=100$)。爆炸 → 查目标网络;恒为 0 → 查 no_grad。
  • ☐ 只开熵项、还没实现重参数化 actor 时,熵应单调上升并逼近 $0.69$。若超过 $0.6931$ → 雅可比算错;若不动 → rsample 没用上。
  • ☐ Hopper 上 clipped double-Q 的 $Q$ 曲线应低于 single-Q。若 single-Q 的 $Q$ 一路上涨而回报不涨,恭喜,你观察到了教科书里的过估计。
  • ☐ HalfCheetah autotune 的 $\alpha$ 应先降(因为初始熵远高于 $H_{\text{target}}=-6$),实测熵应收敛到 $-6$ 附近。若 $\alpha$ 单调上涨 → 检查 get_temperature() 是不是漏了 .item()。
最后一句 这份作业里最值得带走的不是代码,而是两个判断习惯。第一,验证一个技巧时要去看它直接作用的那个量——Double-Q 作用在 $Q$ 的乐观度上,就去看 q_values,不要只盯回报;clipped double-Q 也是同理。第二,算力不够时要区分机制性结论和性能性结论——「$\alpha$ 为什么下降」这种由公式符号决定的事情,7 万步就能说清楚;「能不能到 6000 分」这种事,27.8 万步说了不算。把这两件事分开写,是一份诚实报告和一份漂亮报告的区别。