强化学习算法:DQN
paperreading
本文字数:5.8k 字 | 阅读时长 ≈ 23 min

强化学习算法:DQN

paperreading
本文字数:5.8k 字 | 阅读时长 ≈ 23 min

强化学习专项:1. 基本概念 · 2. DQN(本文) · 3. Policy Gradient · 4. PPO · 5. 如何理解强化学习

1. DQN 要解决什么问题?

智能体处于状态 \(s\) 时,需要从多个动作中选出一个动作。如果能够提前知道“每个动作最终大概能带来多少回报”,决策就很简单:选择价值最大的动作。从这里我们就可以看出,DQN(Deep Q-Network,深度 Q 网络) 属于基于价值函数的方法。它的基本思路就是给动作打分:使用神经网络输入状态 \(s\),输出所有离散动作的价值。

\[ Q(s,\cdot;\theta)= \left[ Q(s,a_1;\theta), Q(s,a_2;\theta), \ldots, Q(s,a_n;\theta) \right] \]

例如,CartPole 只有“向左”和“向右”两个动作,网络会输出两个 Q 值。

1.1 从动作价值到最优动作价值

对于固定策略 \(\pi\),动作价值 \(Q_\pi(s,a)\) 表示:

当前处于状态 \(s\),先执行动作 \(a\),之后按照策略 \(\pi\) 行动时,能够获得的期望折扣回报。

如果后续策略不同,同一个 \((s,a)\) 的长期结果也可能不同。我们真正关心的是:先执行动作 \(a\) 以后,如果后面始终尽可能做出最优决策,最多能获得多少回报。这就是最优动作价值:

\[ Q^*(s,a)= \max_\pi Q_\pi(s,a) \]

这里的星号表示:当前动作执行完以后,后续始终采用最优策略。它并不表示传入的动作 \(a\) 本身一定最优。

例如,计算 \(Q^*(s,\text{向右})\) 时,第一步已经固定为“向右”。即使这个动作并不好,也要先执行它,再从到达的下一状态开始做出最优选择。因此,\(Q^*\) 仍然需要分别评价每个候选动作:

\[ Q^*(s,\text{向左})=10,\qquad Q^*(s,\text{向右})=4 \]

比较两个值以后,才知道当前应该向左。

1.2 为什么要用神经网络?

如果状态和动作数量都很少,可以建立一张 Q-table,为每个 \((s,a)\) 保存一个 Q 值。表中的值并不是提前知道的真实答案:它们通常从 0 或随机值开始,再根据智能体与环境的交互不断更新。状态空间足够小时,这种表格型 Q-learning 不需要神经网络。

CartPole 的状态则由位置、速度、角度和角速度等连续变量组成。我们既无法列出所有可能的状态,也不可能逐一访问它们并更新对应的表格项。因此,需要让神经网络学习“状态到各动作 Q 值”的映射,并利用已经采样的数据估计没有见过的相似状态。

在 DQN 中,这个映射由带参数 \(\theta\) 的神经网络表示:

\[ Q(s,a;\theta) \approx Q^*(s,a) \]

这里的 \(Q(s,a;\theta)\) 只是网络当前的估计,并不是已知的真实答案。接下来的关键问题是:

没有真实的 \(Q^*(s,a)\) 作为标签,Q 网络应该怎样训练?

Bellman 最优方程提供了构造这个学习目标的方法。

2. 从 Bellman 方程到 DQN 的 loss

2.1 长期回报可以拆成两部分

从时间步 \(t\) 开始的折扣回报为:

\[ U_t= r_t+\gamma r_{t+1}+\gamma^2r_{t+2}+\cdots \]

把第一步奖励单独拿出来:

\[ U_t= r_t+\gamma U_{t+1} \]

它表达的只是:

当前的长期回报 = 当前奖励 + 折扣后的未来回报。

Bellman 方程把这个递推关系用在价值函数上。对于最优动作价值,执行 \(a_t\) 后会先获得奖励 \(r_t\),再进入下一状态 \(s_{t+1}\)。到了下一状态,最优策略会选择 Q 值最大的动作,因此:

核心公式:Bellman 最优方程

\[ Q^*(s_t,a_t)= \mathbb{E} \left[ r_t+\gamma(1-d_t) \max_{a'}Q^*(s_{t+1},a') \mid s_t,a_t \right] \]

公式中的各部分可以顺着一次交互来理解:

\(a'\) 表示下一状态中的候选动作,不是当前已经执行的 \(a_t\)。

为什么要取期望?

即使当前状态 \(s_t\) 和动作 \(a_t\) 完全相同,随机环境也可能返回不同的奖励和下一状态。假设执行同一个动作后有两种结果,并且都没有终止:

那么这个动作的价值不能只取其中一次结果,而要按照发生概率计算平均长期回报:

\[ \begin{aligned} Q^*(s_t,a_t) &=0.5\left[2+\gamma\max_{a'}Q^*(s_1,a')\right] \\ &\quad+0.5\left[0+\gamma\max_{a'}Q^*(s_2,a')\right] \end{aligned} \]

这个加权平均就是期望 \(\mathbb{E}\)。如果环境完全确定,相同的 \((s_t,a_t)\) 永远得到相同的奖励和下一状态,那么只有一种结果,可以暂时把期望理解成一次确定的计算。

2.2 用一个数字例子理解

假设环境是确定的。智能体执行当前动作后立即获得 2 分,并进入下一状态。在下一状态中,两个动作的最优 Q 值分别为 3 和 10。令 \(\gamma=0.9\),并且 episode 没有结束,那么:

\[ Q^*(s_t,a_t) =2+0.9\times\max(3,10) =11 \]

当前动作立即获得 2 分,未来最优价值为 10,折扣后为 9,所以当前动作的长期价值为 11。

如果执行动作后 episode 已经终止,即 \(d_t=1\),就没有未来回报:

\[ Q^*(s_t,a_t)=r_t=2 \]

2.3 理论等式怎样变成训练目标?

Bellman 最优方程描述的是真实 \(Q^*\) 应该满足的关系,但等式中的 \(Q^*\) 和环境期望都无法直接计算。DQN 用一次实际交互替代对环境结果的完整枚举,再用神经网络估计未知的 Q 值,最终把问题变成一次回归。

对于与环境交互得到的一条 transition:

\[ (s_t,a_t,r_t,s_{t+1},d_t) \]

需要构造两个数:

把 prediction 和 target 放进 loss,就能使用熟悉的反向传播更新网络。下面依次说明它们来自哪里。

1. prediction 从哪里来?

用于正常预测和梯度更新的 Q 网络称为在线网络。它输入当前状态 \(s_t\),输出所有动作的 Q 值:

\[ Q(s_t,\cdot;\theta)= \left[ Q(s_t,a_1;\theta), \ldots, Q(s_t,a_n;\theta) \right] \]

transition 中的 \(a_t\) 是智能体当时实际执行的动作,因此只取输出中与 \(a_t\) 对应的那一个值:

\[ \text{prediction}= Q(s_t,a_t;\theta) \]

例如,在线网络对 \(s_t\) 输出 \([7,5]\),而当时实际执行的是第一个动作,那么这条 transition 的 prediction 就是 7。第二个动作的 Q 值不会直接参与这条数据的 loss。

2. 一次采样怎样代替期望?

Bellman 方程中的 \(\mathbb{E}\) 表示:理论上要列出执行 \((s_t,a_t)\) 后的所有可能结果,再按照各自的发生概率计算平均值。前面 50% 到达 \(s_1\)、50% 到达 \(s_2\) 的例子,就是在显式计算这个期望。

实际训练时,我们通常不知道环境的转移概率,也无法提前列出所有结果。但每次与环境交互,都会真实观察到其中一种结果:

\[ (r_t,s_{t+1},d_t) \]

这条 transition 只是期望中的一次随机采样,并不等于完整期望。DQN 会将历史 transition 保存在经验回放池(replay buffer)中,再随机抽取 mini-batch 训练。随着采样数量增加,这些样本的平均效果会逐渐近似 Bellman 方程中的期望。

因此,\(r_t\)、\(s_{t+1}\) 和 \(d_t\) 都直接来自环境,不是网络预测出来的。

3. 下一状态的价值从哪里来?

观察到 \(s_{t+1}\) 后,我们仍然不知道真正的:

\[ \max_{a'}Q^*(s_{t+1},a') \]

只能继续用 Q 网络估计。标准 DQN 在实现中保留两份结构相同的网络:

这并不表示 DQN 要学习两个不同的 Q 函数。两个网络结构相同、目标相同,只是分工不同。训练开始时,将在线网络参数复制给目标网络;随后固定目标网络若干步,再重新复制:

\[ \theta^- \leftarrow \theta \]

所以“目标网络暂时固定”的意思是:两次参数同步之间,它不执行梯度更新。这样 prediction 在变化时,target 不会每一步都跟着变化。

现在可以构造 TD target:

\[ y_t= r_t+\gamma(1-d_t) \max_{a'}Q(s_{t+1},a';\theta^-) \]

\(y_t\) 是针对当前这条 transition 计算出的一个数,各部分来源如下:

组成部分 来源
\(r_t\)、\(s_{t+1}\)、\(d_t\) 与环境实际交互得到
\(\gamma\) 预先设置的折扣因子
\(\max_{a'}Q(s_{t+1},a';\theta^-)\) 目标网络对下一状态的估计

如果 \(d_t=1\),episode 已经终止,未来价值被置为 0,此时 \(y_t=r_t\)。

4. prediction 和 target 怎样组成 loss?

继续使用前面的数字。假设:

那么:

\[ \begin{aligned} \text{target}=y_t &=2+0.9\times\max(3,10) \\ &=11 \end{aligned} \]

这一条数据最终就是让在线网络的预测值 7 向 TD target 11 靠近:

\[ \underbrace{Q(s_t,a_t;\theta)}_{\text{prediction}=7} \longrightarrow \underbrace{y_t}_{\text{target}=11} \]

损失写成:

\[ L(\theta)= \mathbb{E} \left[ \ell\left( Q(s_t,a_t;\theta),y_t \right) \right] \]

这里的 \(\mathbb{E}\) 表示对 replay buffer 中抽取的 mini-batch 求平均。代码中的 smooth_l1_loss 会直接完成这一步。

反向传播只更新在线网络参数 \(\theta\)。计算 \(y_t\) 时会停止梯度,因此目标网络参数 \(\theta^-\) 不会被这次 loss 更新。

\(y_t\) 不是人工标注的 ground truth,而是由环境反馈和目标网络共同构造的学习目标。\(\ell\) 可以使用均方误差;后面的代码使用 Huber Loss。令预测误差为:

\[ e=Q(s_t,a_t;\theta)-y_t \]

当阈值为 1 时,Huber Loss 为:

\[ \ell(e)= \begin{cases} \frac{1}{2}e^2, & |e|\le 1,\\ |e|-\frac{1}{2}, & |e|>1. \end{cases} \]

误差较小时,它和均方误差一样进行细致调整;误差较大时,它近似绝对误差,避免异常大的 TD error 产生过大的梯度。PyTorch 中默认的 F.smooth_l1_loss 与阈值为 1 的 Huber Loss 等价。

3. 为什么需要经验回放和目标网络?

到这里已经可以训练 Q 网络了,但如果每获得一条 transition 就立刻用它更新同一个网络,训练通常不稳定,主要有两个原因:

  1. 连续 transition 来自同一条轨迹,彼此高度相关;
  2. 如果同一个网络同时计算 prediction 和 target,参数更新后,下一次重新计算时 target 也会跟着变化。

DQN 分别使用经验回放和目标网络缓解这两个问题。

3.1 经验回放

每次与环境交互后,DQN 都会把 transition 存入 replay buffer:

\[ (s_t,a_t,r_t,s_{t+1},d_t) \]

同一条轨迹中,相邻 transition 往往非常相似。例如 CartPole 连续几步的杆角度和小车速度只发生少量变化,动作和 reward 也可能连续相同。如果每得到一条数据就立即训练,连续多次梯度更新都会被同一小段经历主导,网络容易暂时过度适应最近的状态;轨迹进入另一个区域后,更新方向又可能突然改变。

replay buffer 会保存来自不同时间、不同 episode 的历史 transition。训练时不直接使用最新数据,而是随机抽取一个 mini-batch,将不同阶段的经历混合在一起。这样既能减弱相邻数据的时间相关性,使每次梯度更新更稳定,也能让一条 transition 被重复使用,提高数据利用率。

DQN 的交互数据通常由 \(\epsilon\)-greedy 策略产生,而 TD target 假设下一步选择 Q 值最大的动作。采集数据的策略和学习的目标策略可以不同,因此 DQN 属于 off-policy 算法。

3.2 目标网络

如果只使用一个网络,prediction 和 TD target 都依赖同一组参数 \(\theta\):

\[ \text{prediction}=Q(s_t,a_t;\theta) \]

\[ \text{target}= r_t+\gamma(1-d_t) \max_{a'}Q(s_{t+1},a';\theta) \]

当前这次反向传播中,target 会被当作常数,不会在求梯度的过程中变化。但是执行一次参数更新后,\(\theta\) 已经改变。下一次训练重新计算 target 时,用于估计下一状态价值的 \(Q(s_{t+1},a';\theta)\) 也可能跟着变化。

例如,假设更新前 prediction 为 4,并且:

\[ r_t=1,\qquad \gamma=0.9,\qquad \max_{a'}Q(s_{t+1},a';\theta)=10 \]

此时 target 为 \(1+0.9\times10=10\)。网络会尝试让 prediction 从 4 向 10 靠近。但网络的参数是共享的:调整 \(Q(s_t,a_t)\) 时,其他状态的 Q 值也可能受到影响。如果更新后下一状态的最大 Q 值变成 13,那么下一次计算出的 target 就变成:

\[ 1+0.9\times13=12.7 \]

网络还没追到原来的 target 10,目标已经移动到 12.7。实际中 target 也可能向反方向变化,这种相互影响容易让训练产生震荡。

因此,标准 DQN 额外保存一份在线网络的参数快照作为目标网络。在线网络正常更新,目标网络则保持不变,只负责计算 TD target。这样在连续若干次训练中,回归目标是相对稳定的。

每隔若干训练步,再将在线网络的参数复制给目标网络:

\[ \theta^- \leftarrow \theta \]

把经验回放和两个网络放在一起后,DQN 的训练信号就很清楚了:replay buffer 提供一批 transition;在线网络根据 \((s_t,a_t)\) 产生 prediction;奖励、下一状态和终止标记经过目标网络组成 TD target。两者计算 Huber Loss,反向传播只更新在线网络,目标网络则按一定间隔同步参数。

4. 把各部分组成完整训练流程

前面已经解决了 Q 网络怎样学习的问题,但智能体还需要决定怎样用当前 Q 值选择动作。训练初期的估计并不准确,如果始终选择 Q 值最大的动作,智能体可能很早就固定在一个并不好的选择上,没有机会尝试其他动作。

因此,DQN 通常使用 \(\epsilon\)-greedy 策略:

随着训练进行,\(\epsilon\) 逐渐减小,智能体会从更多探索转向更多利用。

完整流程如下:

  1. 初始化在线 Q 网络和目标 Q 网络,并复制一次参数;
  2. 根据 \(\epsilon\)-greedy 策略选择动作;
  3. 与环境交互,得到 \((s_t,a_t,r_t,s_{t+1},d_t)\);
  4. 将 transition 存入 replay buffer;
  5. 从 replay buffer 随机采样一个 mini-batch;
  6. 在线网络计算实际动作的预测值 \(Q(s_t,a_t;\theta)\);
  7. 目标网络计算 TD target \(y_t\);
  8. 最小化预测值和 TD target 之间的损失,只更新在线网络;
  9. 每隔固定步数,将在线网络参数同步给目标网络。

交互、存储数据和更新网络会不断交替进行。Q 网络的估计逐渐改善后,\(\epsilon\)-greedy 策略也会越来越倾向于选择高价值动作。

4.1 DQN 的特点

5. CartPole 代码实现

到了代码中,前面的公式分别对应下面这些张量计算:

含义 公式 代码
当前动作的预测值 \(Q(s_t,a_t;\theta)\) online_q_network(states).gather(1, actions)
下一状态的最优估计 \(\max_{a'}Q(s_{t+1},a';\theta^-)\) target_q_network(next_states).max(1).values
TD target \(r_t+\gamma(1-d_t)\max_{a'}Q(s_{t+1},a';\theta^-)\) td_targets
终止标记 \(d_t\) terminated_flags
回归损失 \(\ell(Q,y)\) F.smooth_l1_loss(...)

代码执行时可以分成三部分理解:

  1. 与环境交互:使用 \(\epsilon\)-greedy 选择动作,将 transition 保存到 replay buffer;
  2. 更新在线网络:随机采样一个 batch,计算 prediction、TD target 和 loss,再执行反向传播;
  3. 同步目标网络:每隔固定步数,将在线网络的参数复制给目标网络。

代码区分了 terminated 和 truncated:真正终止时,下一状态价值为 0;因时间上限截断时,只结束当前 episode,仍使用下一状态的 Q 值估计尚未观察到的未来回报,这种做法称为 bootstrap。

5.1 一次实际训练是怎样执行的?

下面固定随机种子运行一次代码。数值用于展示各个量怎样变化,不代表每次训练都会得到相同结果。

运行配置 数值
随机种子 7
折扣因子 \(\gamma\) 0.99
学习率 \(10^{-3}\)
batch_size 64
目标网络同步间隔 500 步
初始 \(\epsilon\) 1.0
Q 网络参数量 17,410

训练过程中,状态、动作、\(\epsilon\) 和 replay buffer 随交互变化;反向传播持续更新在线网络 \(\theta\),目标网络 \(\theta^-\) 只在同步时更新。下面用 Q 值观察网络参数带来的整体变化。

1. 初始化两个网络

初始化数据 数值
初始状态 \(s_0\) \([0.0125,\ 0.0397,\ 0.0276,\ -0.0275]\)
在线网络 \(Q(s_0,\cdot;\theta)\) \([0.0848,\ 0.0051]\)
目标网络 \(Q(s_0,\cdot;\theta^-)\) \([0.0848,\ 0.0051]\)

初始化时会把在线网络参数复制给目标网络,所以两者输出相同。

2. 与环境交互并保存 transition

同一个 episode 中,上一条 transition 的 \(s_{t+1}\) 会成为下一条的 \(s_t\)。每执行一次动作就保存一次;episode 结束时重置环境,但保留网络和 replay buffer:

执行动作 → 保存 transition → buffer 足够时采样并训练 → 继续执行

第 1 步时 \(\epsilon=1\),因此随机选择动作 1。环境产生的第一条数据为:

量 数值
\(s_0\) \([0.0125,\ 0.0397,\ 0.0276,\ -0.0275]\)
\(a_0,r_0,d_0\) \(1,\ 1,\ 0\)
\(s_1\) \([0.0133,\ 0.2344,\ 0.0270,\ -0.3113]\)
保存的数据 \((s_0,1,1,s_1,0)\)
环境步 1 2 3
\(\epsilon\) 1.0000 0.9981 0.9962
buffer 大小 1 2 3

这几步都来自随机探索。由于 batch_size=64,前 63 步只负责积累数据,不更新网络。

3. 第 64 步触发第一次网络更新

第 64 步后,buffer 中正好有 64 条 transition。此时 \(\epsilon=0.8875\),代码第一次采样一个 mini-batch。下面观察其中一条数据:

量 数值
\(s_t\) \([-0.0489,\ -0.2010,\ 0.0929,\ 0.3306]\)
\(a_t,r_t,d_t\) \(1,\ 1,\ 0\)
\(s_{t+1}\) \([-0.0529,\ -0.0073,\ 0.0995,\ 0.0686]\)
在线网络 \(Q(s_t,\cdot;\theta)\) \([0.0889,\ 0.0012]\)
prediction \(Q(s_t,a_t;\theta)\) \(0.0012\)
目标网络 \(Q(s_{t+1},\cdot;\theta^-)\) \([0.0856,\ 0.0002]\)

取其中的最大值并构造 TD target:

\[ \begin{aligned} y_t &=r_t+\gamma(1-d_t) \max_{a'}Q(s_{t+1},a';\theta^-) \\ &=1+0.99\times(1-0)\times0.0856 \\ &=1.0847 \end{aligned} \]

整个 mini-batch 的 Smooth L1 Loss 为 0.5458。一次更新前后的关键数值集中如下:

观察量 更新前 更新后
动作 1 的 prediction 0.0012 0.0355
TD target 1.0847 1.0847
在线网络 \(Q(s_t,\cdot;\theta)\) \([0.0889,\ 0.0012]\) \([0.1310,\ 0.0355]\)
目标网络 \(Q(s_{t+1},\cdot;\theta^-)\) \([0.0856,\ 0.0002]\) \([0.0856,\ 0.0002]\)

反向传播让 prediction 向 target 靠近,但不会一步到达;目标网络不参与这次更新,所以 TD target 保持不变。

4. 第 500 步同步目标网络

目标网络前 499 步保持不变,第 500 步才复制在线网络参数:

对 \(s_0\) 的输出 同步前 同步后
在线网络 \([1.0997,\ 1.0769]\) \([1.0997,\ 1.0769]\)
目标网络 \([0.0848,\ 0.0051]\) \([1.0997,\ 1.0769]\)

因此,一轮完整的数据变化可以压缩为:

交互并保存数据 → buffer 达到 64 → 采样并更新在线网络
→ 持续交互和训练 → 第 500 步同步目标网络

5.2 完整代码

import math
import random
from collections import deque, namedtuple

import gymnasium as gym
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F


Transition = namedtuple(
    "Transition", "state action reward next_state terminated"
)


class QNetwork(nn.Module):
    def __init__(self, state_dim, action_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, 128), nn.ReLU(),
            nn.Linear(128, 128), nn.ReLU(),
            nn.Linear(128, action_dim),
        )

    def forward(self, x):
        return self.net(x)


def main():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    env = gym.make("CartPole-v1")

    random_seed = 7
    random.seed(random_seed)
    np.random.seed(random_seed)
    torch.manual_seed(random_seed)
    env.action_space.seed(random_seed)

    state_dim = env.observation_space.shape[0]
    action_dim = env.action_space.n

    online_q_network = QNetwork(state_dim, action_dim).to(device)
    target_q_network = QNetwork(state_dim, action_dim).to(device)
    target_q_network.load_state_dict(online_q_network.state_dict())
    target_q_network.eval()

    num_episodes = 500
    batch_size = 64
    learning_rate = 1e-3
    gamma = 0.99
    replay_buffer_capacity = 10000
    epsilon_start = 1.0
    epsilon_end = 0.05
    epsilon_decay_steps = 500
    target_sync_interval = 500

    optimizer = torch.optim.Adam(
        online_q_network.parameters(), lr=learning_rate
    )
    replay_buffer = deque(maxlen=replay_buffer_capacity)
    environment_steps = 0

    def to_tensor(data, dtype):
        return torch.as_tensor(
            np.asarray(data), dtype=dtype, device=device
        )

    for episode in range(num_episodes):
        state, _ = env.reset(
            seed=random_seed if episode == 0 else None
        )
        episode_score = 0

        while True:
            # epsilon 随训练步数逐渐减小
            epsilon = epsilon_end + (epsilon_start - epsilon_end) * math.exp(
                -environment_steps / epsilon_decay_steps
            )

            # epsilon-greedy:随机探索或选择 Q 值最大的动作
            if random.random() < epsilon:
                action = env.action_space.sample()
            else:
                with torch.no_grad():
                    state_tensor = to_tensor(state, torch.float32).unsqueeze(0)
                    action = online_q_network(state_tensor).argmax(dim=1).item()

            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated
            episode_score += reward

            replay_buffer.append(
                Transition(state, action, reward, next_state, terminated)
            )
            state = next_state
            environment_steps += 1

            if len(replay_buffer) >= batch_size:
                batch = Transition(
                    *zip(*random.sample(replay_buffer, batch_size))
                )
                states = to_tensor(batch.state, torch.float32)
                actions = to_tensor(batch.action, torch.long).unsqueeze(1)
                rewards = to_tensor(batch.reward, torch.float32)
                next_states = to_tensor(batch.next_state, torch.float32)
                terminated_flags = to_tensor(batch.terminated, torch.float32)

                # 取出实际执行动作所对应的 Q(s_t, a_t)
                predicted_q_values = online_q_network(states).gather(
                    1, actions
                ).squeeze(1)

                # TD target 不参与梯度计算
                with torch.no_grad():
                    max_next_q_values = target_q_network(next_states).max(dim=1).values
                    td_targets = (
                        rewards
                        + gamma * (1.0 - terminated_flags) * max_next_q_values
                    )

                q_loss = F.smooth_l1_loss(predicted_q_values, td_targets)

                optimizer.zero_grad()
                q_loss.backward()
                torch.nn.utils.clip_grad_value_(
                    online_q_network.parameters(), 100
                )
                optimizer.step()

            # 定期用在线网络参数更新目标网络
            if environment_steps % target_sync_interval == 0:
                target_q_network.load_state_dict(online_q_network.state_dict())

            if done:
                break

        if episode % 10 == 0:
            print(f"Episode {episode:3d} | Score {episode_score:6.1f} | "
                  f"Epsilon {epsilon:.3f}")

    env.close()


if __name__ == "__main__":
    main()
Sep 06, 2026