之前我们聊 Q-Learning 时,大部分代码还停留在“查表”阶段:把状态离散成一个个格子,然后用一张二维表记录每个状态-动作的价值。这个思路在小型迷宫、FrozenLake 这类环境里足够用,但一旦遇到 CartPole、Atari 游戏甚至连续控制任务,表格就会瞬间失效。第 49 篇我们来解决这个核心瓶颈:把 Q-Learning 中的 Q 表换成神经网络,这就是 Deep Q-Network(DQN)。本文会从 Q-Learning 的更新公式重新推导 DQN 的损失函数与训练流程,并给出 Q-Learning 基线、DQN 完整可运行的 CartPole 实战代码,最后整理训练不收敛、Q 值爆炸等高频问题的排查清单。适合已经理解 MDP、价值迭代和时序差分,但还没有系统跑通过 DQN 的读者。1. 从 Q-Learning 到 DQN:为什么需要深度网络1.1 Q-Learning 解决的是什么问题Q-Learning 是强化学习中经典的无模型(Model-Free)、**异策略(Off-Policy)**算法。所谓无模型,是指它不需要知道环境的转移概率 P(s'|s,a) 和奖励函数 R(s,a),直接通过与环境的交互样本来学习;所谓异策略,是指它用 ε-greedy 等探索策略采集数据,却在更新时使用 max 算子去逼近最优策略的价值,行为策略和目标策略可以是不同的。表格版 Q-Learning 的核心操作只有两件事:初始化一张 Q 表,表中每个格子 Q(s,a) 代表“在状态 s 下执行动作 a 的长期回报期望”。每采集一条经验 (s,a,r,s'),用下面的公式更新 Q 表:Q(s,a) ← Q(s,a) + α [ r + γ * max_{a'} Q(s',a') - Q(s,a) ]其中 r + γ * max_{a'} Q(s',a') 叫做TD Target,它和当前 Q(s,a) 的差值叫做TD Error。Q-Learning 的收敛性建立在 Bellman 最优方程之上:Q*(s,a) = r + γ * max_{a'} Q*(s',a')只要每个状态-动作对都持续被访问,并且学习率 α 满足随机近似条件,Q 表最终会收敛到最优 Q 值。这个数学结论很漂亮,但它有一个致命的隐性前提——状态的个数必须是有限的、可枚举的。1.2 表格方法的瓶颈拿 CartPole 举例。环境返回的观测 state 是一个 4 维连续向量:[小车位置, 小车速度, 杆角度, 杆角速度]这四个值都是浮点数。要用表格法,我们必须手动把它们切成若干区间。比如每个维度切成 12 个区间,那么表格规模就是:12^4 × 2 = 41472 个格子听上去还能接受,但我们立刻会遇到两个问题:离散化粒度不好把握。切得太粗,相邻的不同状态被当成同一个状态,策略粗糙;切得太细,格子数量指数爆炸,每个格子被访问到的次数急剧减少,训练数据稀疏,收敛变慢。泛化能力为零。Q 表只能记录“见过”的状态,一个从未访问过的状态没有任何价值信息。状态一多,这条路径就走死了。再往后推一步,如果观测是图像,比如 Atari 游戏的一帧画面是 210×160×3 的像素矩阵,把所有像素组合枚举出来是一个天文数字。连续控制任务(机械臂、无人机)的状态本质上是连续向量,理论上表格根本不可行。所以,用函数逼近代替查表是自然的发展方向。1.3 DQN 的核心思想DQN 的思路一句话就能说清楚:用一个神经网络 Q(s,a;θ) 去近似真实的最优 Q 函数 Q(s,a)*。网络输入是状态 s,输出是每个候选动作的 Q 值。以 CartPole 为例,输入维度是 4,输出维度是 2,网络中间用若干隐藏层拟合 Q 函数。这样有三个好处:不需要手动离散化状态,连续状态直接进网络。网络参数是共享的,相似的状态会被分到相近的 Q 值,天然具备泛化能力。训练变成了常见的梯度下降问题,可以复用深度学习生态里成熟的优化器、正则化手段。但直接把 Q-Learning 的更新公式套到神经网络上会面临一个麻烦:TD Target 中也包含同一个网络自身的输出,优化目标一直在移动,训练极不稳定。2013 年 Mnih 等人提出的 DQN 在 Nature 上发表时,最重要的工程贡献就是两个稳定化技巧——经验回放(Experience Replay)和目标网络(Target Network)。接下来的章节,我们先把原理逐层剥开,再进入代码实战。2. 环境准备与版本说明本文的实战代码依赖以下环境:操作系统:Windows / Linux / macOS 均可。Python 版本:3.10 或更高。PyTorch:2.x 稳定版。Gymnasium:建议使用 0.29 或更新的稳定版(gymnasium是旧版gym的社区继承版本,API 有差异,下面代码均基于新 API)。NumPy:用于 Q-Learning 基线的数组操作。安装命令:pip install torch gymnasium numpy版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。如果网络环境安装较慢,可以自行配置 PyTorch 的国内镜像源。项目结构如下:rl-dqn-tutorial/ ├── src/ │ ├── q_learning_baseline.py # 表格 Q-Learning 基线 │ ├── dqn_agent.py # DQN 网络、回放缓冲区和 Agent │ └── train_dqn.py # DQN 训练主循环 └── requirements.txt3. 核心算法原理拆解3.1 再看一遍 Q-Learning 更新公式为了让后面 DQN 的损失函数看起来不突兀,我们先把 Q-Learning 公式拆成“预测”和“目标”两部分:预测 = Q(s, a) 目标 = r + γ * max_{a'} Q(s', a') 更新 = Q(s, a) + α * (目标 - 预测)Q-Learning 拿当前 Q 值做预测,拿“即时奖励 + 下一状态最优动作的折扣 Q 值”做目标,两者做差得到 TD Error,然后用学习率 α 把 Q 值往目标方向推一小步。这个过程中有两点值得注意:max 算子导致了异策略特性。更新时选择下一个动作用的是贪婪策略,而不是采集数据时使用的 ε-greedy 策略。这样一来,即使行为策略一直在探索,更新过程仍然在向最优策略的价值逼近。自举(Bootstrapping)特性。目标是基于 Q(s',a') 估计出来的,不是真实回报的累计。自举让算法效率高、方差大,也是后续稳定性问题的根源。3.2 从 Q 表到 Q 网络Q 表本质上是把状态离散化后存储的一张查找表。换成神经网络后,我们不再显式存储每个状态的 Q 值,而是用一组参数 θ 来表示 Q 函数:Q(s, a; θ) ≈ Q*(s, a)网络输出层的维度等于动作数。CartPole 动作空间只有“向左”、“向右”两个动作,所以输出层是 2 维;如果动作空间有 18 个离散动作,输出层就是 18 维。用神经网络逼近 Q 函数后,Q-Learning 的“查表并直接修改格子数值”就变成了“前向传播得到预测、计算梯度、反向传播更新参数”。公式对应关系如下:对比维度表格 Q-LearningDQNQ 值存储方式Q 表,直接查表神经网络,前向传播计算适用状态空间低维离散状态高维连续状态、图像更新方式查表后直接赋值梯度下降最小化损失样本利用率每条经验用一次即丢弃经验回放反复采样使用泛化能力几乎为零参数共享,具备泛化能力3.3 经验回放(Experience Replay)如果按照原始 Q-Learning 的模式,每走一步就立刻用这条经验更新网络,会遇到两个问题:样本高度相关。强化学习的相邻状态之间存在强相关性,连续几条经验往往指向同一个方向,梯度更新会产生较大振荡。样本利用率低。一条经验用过一次就扔掉了,而深度网络训练偏偏非常“吃数据”。经验回放的做法是维护一个固定容量(比如 20000)的缓冲区,把每条经验 (s, a, r, s', done) 存进去。训练时从缓冲区中随机均匀采样一个小批量(batch)来计算损失。随机采样打破了样本之间的时间相关性,让梯度估计更稳定;同时同一条经验可以被多次复用,相当于提高了数据效率。这里还要强调一个前提:DQN 是异策略算法,经验回放才能成立。因为更新时使用的是 max 算子计算出的目标值,和采集这条经验时的行为策略无关。假如是 SARSA 这类同策略算法,旧策略采集的经验就不能随便回放了。3.4 目标网络(Target Network)光有经验回放还不够,DQN 的损失函数定义如下:L(θ) = E[ ( Q(s, a; θ) - (r + γ * max_{a'} Q(s', a'; θ)) )² ]注意等式右侧的 TD Target 中,下一个状态的 Q 值也使用了当前要更新的参数 θ。这意味着优化目标每更新一次参数就跟着变一次,训练目标始终在移动。在监督学习中,标签是固定的;在这里,标签由模型自己生成,梯度的方向会变得不可控,很容易发散。DQN 的解决办法是额外维护一个参数为 θ⁻ 的目标网络,专门用来计算 TD Target:目标 = r + γ * max_{a'} Q(s', a'; θ⁻)训练过程中,目标网络的参数不参与梯度更新,而是每隔固定步数(比如每 100 步或 1000 步)从策略网络复制一次:θ⁻ ← θ这样在一个同步周期内,TD Target 是相对固定的,训练就从“追一个移动靶”变成了“追一个每隔一段时间才移动一次的靶”,稳定性大幅提升。后续实践中还常使用软更新,每一步都把目标网络向策略网络靠近一点:θ⁻ ← τ * θ + (1 - τ) * θ⁻,其中 τ 通常取 0.005软更新比硬更新更平滑,在 DDPG、SAC 等算法中也是标准做法。3.5 DQN 完整算法流程把以上模块串起来,DQN 的训练流程如下:1. 初始化策略网络 Q(s,a;θ),初始化目标网络 Q(s,a;θ⁻),令 θ⁻ = θ 2. 初始化空的经验回放缓冲区 D,容量为 C 3. 循环执行每个 episode: a. 获取初始状态 s b. 循环执行每一步: - 用 ε-greedy 策略选择动作 a - 执行 a,得到 r 和 s' - 将 (s, a, r, s', done) 存入 D - 若 D 中样本数量足够,随机采样一个 batch - 计算损失 L(θ),用梯度下降更新 θ - 每隔 C 步,同步 θ⁻ ← θ - s ← s',如果 done 则结束当前 episode c. 衰减 ε下一节就按照这个流程,分别实现表格 Q-Learning 基线和 DQN,并放到 CartPole-v1 环境中对比。4. 完整实战:Q-Learning 与 DQN 对比实验4.1 实验环境与项目结构CartPole-v1 是一个经典的平衡控制任务:小车在轨道上左右移动,杆子初始竖立,目标是在 500 步内不让杆子倒下。观测是 4 维连续状态,动作是 2 个离散动作,每坚持一步奖励 +1,当杆子倾斜角度超过阈值或小车越界时 episode 提前终止。我们先用表格 Q-Learning 配合状态离散化跑一个基线,再用 DQN 跑同一个任务,观察两者在训练曲线和最终表现上的差异。4.2 表格 Q-Learning 基线文件路径:src/q_learning_baseline.py# 文件路径:src/q_learning_baseline.py import gymnasium as gym import numpy as np def build_bins(n_bins=12): """根据 CartPole 状态的物理范围构造离散化区间""" return [ np.linspace(-2.4, 2.4, n_bins), # 小车位置 x np.linspace(-3.0, 3.0, n_bins), # 小车速度 x_dot np.linspace(-0.5, 0.5, n_bins), # 杆角度 theta np.linspace(-3.0, 3.0, n_bins) # 杆角速度 theta_dot ] def discretize_state(state, bins): """将连续的 4 维状态映射为离散索引元组""" return tuple(np.digitize(state[i], bins[i]) for i in range(len(state))) def train_q_learning(episodes=1500, alpha=0.1, gamma=0.99, epsilon=1.0, epsilon_min=0.01, epsilon_decay=0.995, n_bins=12): env = gym.make('CartPole-v1') bins = build_bins(n_bins) # 每个维度离散化后索引范围是 0 ~ n_bins,所以每个维度长度是 n_bins + 1 q_table_shape = [n_bins + 1] * 4 + [env.action_space.n] q_table = np.zeros(q_table_shape) rewards_history = [] for episode in range(episodes): state, _ = env.reset() state_idx = discretize_state(state, bins) total_reward = 0 done = False while not done: # epsilon-greedy 动作选择 if np.random.random() < epsilon: action = env.action_space.sample() else: action = np.argmax(q_table[state_idx]) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated next_idx = discretize_state(next_state, bins) # Q-Learning 核心更新 if terminated: # 真正终止:后续没有回报,不继续自举 td_target = reward else: td_target = reward + gamma * np.max(q_table[next_idx]) td_error = td_target - q_table[state_idx + (action,)] q_table[state_idx + (action,)] += alpha * td_error state_idx = next_idx total_reward += reward epsilon = max(epsilon_min, epsilon * epsilon_decay) rewards_history.append(total_reward) if (episode + 1) % 100 == 0: avg = np.mean(rewards_history[-100:]) print(f"[Q-Learning] Episode {episode + 1}, " f"Average Reward(100): {avg:.2f}, Epsilon: {epsilon:.3f}") env.close() return q_table, rewards_history if __name__ == '__main__': train_q_learning()运行命令:python src/q_learning_baseline.py这段代码里有两个容易踩坑的细节:np.digitize返回的索引范围是 0 到 n_bins,因此表格每个维度的长度必须是 n_bins + 1,否则索引会越界。terminated和truncated要分开理解。CartPole 里terminated=True意味着杆子倒了,状态之后没有未来回报;而truncated=True只是达到了 500 步上限,此时环境本身没有“死亡”,理论上还可以继续自举。代码里对terminated单独处理,可以避免把“真正结束”和“超时截断”混为一谈后造成的价值低估。4.3 DQN 网络与经验回放实现文件路径:src/dqn_agent.py# 文件路径:src/dqn_agent.py import random from collections import deque import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim class DQNNetwork(nn.Module): """三层全连接网络:输入状态,输出每个动作的 Q 值""" def __init__(self, state_dim, action_dim, hidden_dim=128): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.net(x) class ReplayBuffer: """经验回放缓冲区,容量满时自动丢弃最旧样本""" def __init__(self, capacity=20000): self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) states = torch.FloatTensor([b[0] for b in batch]) actions = torch.LongTensor([b[1] for b in batch]) rewards = torch.FloatTensor([b[2] for b in batch]) next_states = torch.FloatTensor([b[3] for b in batch]) dones = torch.FloatTensor([b[4] for b in batch]) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)ReplayBuffer使用deque(maxlen=capacity)实现,容量满时自动淘汰最老的样本,保证缓冲区里始终是“最新的一批经验”。4.4 DQN Agent 与训练主循环继续在src/dqn_agent.py中补充DQNAgent:# 文件路径:src/dqn_agent.py(追加以下代码) class DQNAgent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99, epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=0.995, capacity=20000, batch_size=64, target_update=100): self.action_dim = action_dim self.gamma = gamma self.batch_size = batch_size self.target_update = target_update self.policy_net = DQNNetwork(state_dim, action_dim) self.target_net = DQNNetwork(state_dim, action_dim) self.target_net.load_state_dict(self.policy_net.state_dict()) self.target_net.eval() self.optimizer = optim.Adam(self.policy_net.parameters(), lr=lr) self.buffer = ReplayBuffer(capacity) self.epsilon = epsilon_start self.epsilon_end = epsilon_end self.epsilon_decay = epsilon_decay self.learn_step = 0 def choose_action(self, state): """epsilon-greedy 动作选择""" if random.random() < self.epsilon: return random.randrange(self.action_dim) with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_values = self.policy_net(state_tensor) return q_values.argmax().item() def update(self): """从经验回放中采样并更新一次网络参数""" if len(self.buffer) < self.batch_size: return None states, actions, rewards, next_states, dones = self.buffer.sample( self.batch_size ) # 策略网络预测:取出 batch 中每个样本对应动作的 Q 值 current_q = self.policy_net(states).gather( 1, actions.unsqueeze(1) ).squeeze(1) # 目标网络计算:r + gamma * max_a' Q_target(s', a') # no_grad 保证梯度不会流向目标网络 with torch.no_grad(): next_q = self.target_net(next_states).max(1)[0] target_q = rewards + self.gamma * next_q * (1 - dones) loss = F.mse_loss(current_q, target_q) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.learn_step += 1 # 硬更新:每隔 target_update 步同步一次目标网络 if self.learn_step % self.target_update == 0: self.target_net.load_state_dict(self.policy_net.state_dict()) return loss.item() def decay_epsilon(self): self.epsilon = max(self.epsilon_end, self.epsilon * self.epsilon_decay)有几处实现细节需要重点说明:gather(1, actions.unsqueeze(1))的作用是从网络输出中取出每个样本实际执行动作对应的 Q 值。actions形状是(batch_size,),unsqueeze(1)后变成(batch_size, 1),正好匹配 Q 值矩阵索引。计算 target 时包裹了torch.no_grad()。如果这里忘记隔离梯度,目标网络的参数也会被反向传播更新,目标网络就失去了意义。dones参与 target 计算时乘了(1 - dones)。当done为 1 时,下一状态没有未来回报,目标退化为r。训练主循环放在src/train_dqn.py:# 文件路径:src/train_dqn.py import gymnasium as gym from dqn_agent import DQNAgent def train(episodes=600, seed=42): env = gym.make('CartPole-v1') state_dim = env.observation_space.shape[0] action_dim = env.action_space.n agent = DQNAgent(state_dim, action_dim, lr=1e-3, gamma=0.99, capacity=20000, batch_size=64, target_update=100) rewards_history = [] for episode in range(episodes): state, _ = env.reset(seed=seed + episode) total_reward = 0 done = False while not done: action = agent.choose_action(state) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated # 保存经验 agent.buffer.push(state, action, reward, next_state, done) loss = agent.update() state = next_state total_reward += reward agent.decay_epsilon() rewards_history.append(total_reward) if (episode + 1) % 50 == 0: avg = sum(rewards_history[-50:]) / 50 print(f"[DQN] Episode {episode + 1}, " f"Average Reward(50): {avg:.2f}, " f"Epsilon: {agent.epsilon:.4f}, Loss: {loss:.4f}") env.close() return rewards_history if __name__ == '__main__': train()运行命令:python src/train_dqn.py4.5 结果与对比说明我使用这套超参数运行时,典型表现如下:表格 Q-Learning:前 300 个 episode 基本在随机探索,平均奖励只有 20~40;随着 ε 衰减,逐渐能学到一些有效策略,最终在 100~250 之间波动。对离散化 bin 数量非常敏感,改成 6 个 bin 可能学得很差,改成 30 个 bin 又会因为样本稀疏而变慢。DQN:早期同样因为探索不足表现一般,但通常在 100~300 个 episode 之间出现明显的“学习跳跃”,之后平均奖励很快接近 500。损失值在训练初期会比较高,后期逐渐下降并伴随波动,这是正常的。需要强调,具体数值会受随机种子、网络初始化、超参数影响,不能把上面的数字当成固定结论。**真正重要的不是“DQN 一定比 Q-Learning 好”这个结论,而是表格方法在状态维度增长后会指数级失效,而 DQN 通过神经网络逼近和两个稳定性技巧,把 Q-Learning 从低维离散场景扩展到了高维连续场景。5. 常见问题与排查思路下面整理 DQN 训练中最常遇到的几类问题,按“现象 → 原因 → 排查方向”给出解决思路。问题现象常见原因解决思路训练很久 reward 一直很低ε 衰减过快或过慢、探索不足调整epsilon_decay,让探索期足够长损失值震荡剧烈或快速上升学习率过大、奖励尺度太大降低lr,对 reward 做 clip 或归一化网络输出 Q 值虚高max 算子对 Q 值存在高估改用 Double DQN,或增大目标网络更新频率性能在学习后突然崩溃回放缓冲区里旧策略样本污染减小 buffer 容量,或尽早调整 ε 策略评估时表现远差于训练时评估时没有关闭 ε 探索评估阶段将epsilon设为 0训练曲线锯齿感极强batch 大小太小、样本相关性强增大batch_size,检查是否打乱样本顺序5.1 训练不收敛、reward 长期低位徘徊这种问题多数出在探索策略上。如果epsilon_decay太小,ε 衰减太慢,智能体会一直随机乱走,后期仍然浪费大量探索;如果衰减太快,训练还没积累足够样本就进入纯贪婪模式,策略容易困在局部。排查步骤:打印每个 episode 的 ε 值,确认它是否在合理区间递减。打印回放缓冲区中的样本数量,确认len(buffer)是否一直小于batch_size,导致网络根本没怎么训练。观察前 100 个 episode 的平均奖励是否比随机策略高。CartPole 随机策略约 20~30,如果连这个基准都不到,大概率是环境 reset 或 reward 处理错了。5.2 损失值不降反升、Q 值爆炸DQN 的损失值本身并不是单调递减的,因为 TD Target 由目标网络产生,目标网络会周期性变化。但如果损失值出现明显发散,通常有两个原因:学习率太大,参数更新步长过头,网络在最优解附近振荡甚至漂移。奖励值域过大。如果 reward 动辄上千,TD Target 就会很大,梯度也同步变大。对应的处理方式:# 在 update 中限制梯度范数,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm=10.0)同时对输入状态做归一化也很重要。CartPole 状态范围不算离谱,但如果是像素输入,一定要先除以 255 或做标准化。5.3 terminated 与 truncated 混淆导致价值低估现在的 Gymnasium API 把 episode 结束分成两类:terminated=True:环境真正进入终止状态,比如杆子倒了,后面没有 reward。truncated=True:只是到达步数上限被截断,环境本身还能继续。如果代码里把两者合并成done = terminated or truncated,同时在计算 target 时统一使用(1 - done),那么当截断发生时,下一状态本应继续自举却被强制当成终止,会系统性低估状态价值。CartPole 中截断只发生在 500 步上限,影响较小;但在很多长程任务里这个问题会被放大。更严谨的做法是分开存储:# 存储时只记录 terminated,truncated 不切断自举 agent.buffer.push(state, action, reward, next_state, float(terminated))同时循环结束条件仍是terminated or truncated,这属于容易被人忽视但值得注意的细节。6. 最佳实践与工程建议6.1 超参数配置经验以下是一组在 CartPole 这类中小型离散动作任务上通用的起点配置,实际使用时按项目调整:超参数建议范围调整方向learning rate1e-4 ~ 1e-3不收敛时调小,训练太慢可适当调大gamma0.95 ~ 0.99任务越长,gamma 应越接近 1epsilon_decay0.99 ~ 0.999任务越复杂,探索期应该越长buffer capacity10000 ~ 100000样本多样性不足时增大batch size32 ~ 256梯度不稳时增大target_update100 ~ 1000 步更新太频繁会失去稳定作用6.2 训练稳定性与可复现工程实战中,除了算法本身,训练过程的稳定性往往决定项目能否交付:固定随机种子。在代码开头设置random.seed(0)、np.random.seed(0)、torch.manual_seed(0),并在env.reset(seed=...)中传种子。虽然不能完全消除随机性,但能让结果可控。使用滑动平均评估曲线。单次 episode 的 reward 波动极大,建议每 N 个 episode 计算一次滑动平均,再决定是否继续训练。定期保存模型 checkpoint。深度强化学习经常出现“学会了又崩掉”的现象,保存历史最好模型比保存最后一个模型更稳妥。评估与训练分离。评估时关闭 ε 探索,多次随机种子运行取平均值,避免单次运行结果误导判断。6.3 DQN 的经典改进方向如果你的任务里 DQN 已经能跑通,下一步可以按顺序尝试以下改进:Double DQN:把 target 计算中的 max 改成a' = argmax Q(s'; θ),再用Q(s'; θ⁻)取值,缓解 Q 值高估问题,代码改动量很小。Dueling DQN:把网络输出拆成状态价值 V(s) 和动作优势 A(s,a),在动作差距较小的情况下更容易区分好动作。Prioritized Experience Replay:不再均匀采样经验,而是按 TD Error 大小给样本加权,让“更值得学”的样本获得更高采样概率。Noisy DQN:用噪声层代替 ε-greedy 探索,让探索强度随训练自动调整。Rainbow:把上述改进与多步 TD、分布 RL 等整合到一起,是 DQN 方向的集大成者。7. 总结与下一步学习路线本文从 Q-Learning 的表格更新公式重新出发,推导了 DQN 的损失函数,解释了为什么朴素地把 Q 表换成神经网络会导致训练不稳定,以及经验回放和目标网络分别解决了什么问题。实战部分给出了两个可运行的 CartPole 实验:表格 Q-Learning 使用状态离散化建立 Q 表,DQN 用三层全连接网络配合 20000 容量经验回放和 100 步目标网络更新完成训练。通过对比可以直观感受到,状态空间一旦连续化、高维化,表格方法就会迅速走到极限,而 DQN 把问题转化成了标准的“监督回归 + 经验复用”,让深度强化学习真正具备落地能力。接下来建议按顺序做三件事:把本文的train_dqn.py换个环境跑一遍,比如LunarLander-v2,体会动作空间更大时 DQN 的表现。花半小时给 DQN 加 Double DQN 和 Dueling 结构,观察 Q 值高估现象是否缓解。如果想继续深入,下一步就是策略梯度方向:从 REINFORCE 开始理解“直接优化策略”的思路,再过渡到 PPO、SAC 等现代主流算法。DQN 是深度强化学习里承上启下的关键一环:它承接了表格方法的全部理论根基,又开启了深度网络在控制、博弈、调度等场景的大门。建议读者把本文代码亲自动手运行一遍,改几个超参数观察曲线变化,这比只看推导收获要大得多。
之前我们聊 Q-Learning 时,大部分代码还停留在“查表”阶段:把状态离散成一个个格子,然后用一张二维表记录每个状态-动作的价值。这个思路在小型迷宫、FrozenLake 这类环境里足够用,但一旦遇到 CartPole、Atari 游戏甚至连续控制任务,表格就会瞬间失效。第 49 篇我们来解决这个核心瓶颈:把 Q-Learning 中的 Q 表换成神经网络,这就是 Deep Q-Network(DQN)。本文会从 Q-Learning 的更新公式重新推导 DQN 的损失函数与训练流程,并给出 Q-Learning 基线、DQN 完整可运行的 CartPole 实战代码,最后整理训练不收敛、Q 值爆炸等高频问题的排查清单。适合已经理解 MDP、价值迭代和时序差分,但还没有系统跑通过 DQN 的读者。1. 从 Q-Learning 到 DQN:为什么需要深度网络1.1 Q-Learning 解决的是什么问题Q-Learning 是强化学习中经典的无模型(Model-Free)、**异策略(Off-Policy)**算法。所谓无模型,是指它不需要知道环境的转移概率 P(s'|s,a) 和奖励函数 R(s,a),直接通过与环境的交互样本来学习;所谓异策略,是指它用 ε-greedy 等探索策略采集数据,却在更新时使用 max 算子去逼近最优策略的价值,行为策略和目标策略可以是不同的。表格版 Q-Learning 的核心操作只有两件事:初始化一张 Q 表,表中每个格子 Q(s,a) 代表“在状态 s 下执行动作 a 的长期回报期望”。每采集一条经验 (s,a,r,s'),用下面的公式更新 Q 表:Q(s,a) ← Q(s,a) + α [ r + γ * max_{a'} Q(s',a') - Q(s,a) ]其中 r + γ * max_{a'} Q(s',a') 叫做TD Target,它和当前 Q(s,a) 的差值叫做TD Error。Q-Learning 的收敛性建立在 Bellman 最优方程之上:Q*(s,a) = r + γ * max_{a'} Q*(s',a')只要每个状态-动作对都持续被访问,并且学习率 α 满足随机近似条件,Q 表最终会收敛到最优 Q 值。这个数学结论很漂亮,但它有一个致命的隐性前提——状态的个数必须是有限的、可枚举的。1.2 表格方法的瓶颈拿 CartPole 举例。环境返回的观测 state 是一个 4 维连续向量:[小车位置, 小车速度, 杆角度, 杆角速度]这四个值都是浮点数。要用表格法,我们必须手动把它们切成若干区间。比如每个维度切成 12 个区间,那么表格规模就是:12^4 × 2 = 41472 个格子听上去还能接受,但我们立刻会遇到两个问题:离散化粒度不好把握。切得太粗,相邻的不同状态被当成同一个状态,策略粗糙;切得太细,格子数量指数爆炸,每个格子被访问到的次数急剧减少,训练数据稀疏,收敛变慢。泛化能力为零。Q 表只能记录“见过”的状态,一个从未访问过的状态没有任何价值信息。状态一多,这条路径就走死了。再往后推一步,如果观测是图像,比如 Atari 游戏的一帧画面是 210×160×3 的像素矩阵,把所有像素组合枚举出来是一个天文数字。连续控制任务(机械臂、无人机)的状态本质上是连续向量,理论上表格根本不可行。所以,用函数逼近代替查表是自然的发展方向。1.3 DQN 的核心思想DQN 的思路一句话就能说清楚:用一个神经网络 Q(s,a;θ) 去近似真实的最优 Q 函数 Q(s,a)*。网络输入是状态 s,输出是每个候选动作的 Q 值。以 CartPole 为例,输入维度是 4,输出维度是 2,网络中间用若干隐藏层拟合 Q 函数。这样有三个好处:不需要手动离散化状态,连续状态直接进网络。网络参数是共享的,相似的状态会被分到相近的 Q 值,天然具备泛化能力。训练变成了常见的梯度下降问题,可以复用深度学习生态里成熟的优化器、正则化手段。但直接把 Q-Learning 的更新公式套到神经网络上会面临一个麻烦:TD Target 中也包含同一个网络自身的输出,优化目标一直在移动,训练极不稳定。2013 年 Mnih 等人提出的 DQN 在 Nature 上发表时,最重要的工程贡献就是两个稳定化技巧——经验回放(Experience Replay)和目标网络(Target Network)。接下来的章节,我们先把原理逐层剥开,再进入代码实战。2. 环境准备与版本说明本文的实战代码依赖以下环境:操作系统:Windows / Linux / macOS 均可。Python 版本:3.10 或更高。PyTorch:2.x 稳定版。Gymnasium:建议使用 0.29 或更新的稳定版(gymnasium是旧版gym的社区继承版本,API 有差异,下面代码均基于新 API)。NumPy:用于 Q-Learning 基线的数组操作。安装命令:pip install torch gymnasium numpy版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。如果网络环境安装较慢,可以自行配置 PyTorch 的国内镜像源。项目结构如下:rl-dqn-tutorial/ ├── src/ │ ├── q_learning_baseline.py # 表格 Q-Learning 基线 │ ├── dqn_agent.py # DQN 网络、回放缓冲区和 Agent │ └── train_dqn.py # DQN 训练主循环 └── requirements.txt3. 核心算法原理拆解3.1 再看一遍 Q-Learning 更新公式为了让后面 DQN 的损失函数看起来不突兀,我们先把 Q-Learning 公式拆成“预测”和“目标”两部分:预测 = Q(s, a) 目标 = r + γ * max_{a'} Q(s', a') 更新 = Q(s, a) + α * (目标 - 预测)Q-Learning 拿当前 Q 值做预测,拿“即时奖励 + 下一状态最优动作的折扣 Q 值”做目标,两者做差得到 TD Error,然后用学习率 α 把 Q 值往目标方向推一小步。这个过程中有两点值得注意:max 算子导致了异策略特性。更新时选择下一个动作用的是贪婪策略,而不是采集数据时使用的 ε-greedy 策略。这样一来,即使行为策略一直在探索,更新过程仍然在向最优策略的价值逼近。自举(Bootstrapping)特性。目标是基于 Q(s',a') 估计出来的,不是真实回报的累计。自举让算法效率高、方差大,也是后续稳定性问题的根源。3.2 从 Q 表到 Q 网络Q 表本质上是把状态离散化后存储的一张查找表。换成神经网络后,我们不再显式存储每个状态的 Q 值,而是用一组参数 θ 来表示 Q 函数:Q(s, a; θ) ≈ Q*(s, a)网络输出层的维度等于动作数。CartPole 动作空间只有“向左”、“向右”两个动作,所以输出层是 2 维;如果动作空间有 18 个离散动作,输出层就是 18 维。用神经网络逼近 Q 函数后,Q-Learning 的“查表并直接修改格子数值”就变成了“前向传播得到预测、计算梯度、反向传播更新参数”。公式对应关系如下:对比维度表格 Q-LearningDQNQ 值存储方式Q 表,直接查表神经网络,前向传播计算适用状态空间低维离散状态高维连续状态、图像更新方式查表后直接赋值梯度下降最小化损失样本利用率每条经验用一次即丢弃经验回放反复采样使用泛化能力几乎为零参数共享,具备泛化能力3.3 经验回放(Experience Replay)如果按照原始 Q-Learning 的模式,每走一步就立刻用这条经验更新网络,会遇到两个问题:样本高度相关。强化学习的相邻状态之间存在强相关性,连续几条经验往往指向同一个方向,梯度更新会产生较大振荡。样本利用率低。一条经验用过一次就扔掉了,而深度网络训练偏偏非常“吃数据”。经验回放的做法是维护一个固定容量(比如 20000)的缓冲区,把每条经验 (s, a, r, s', done) 存进去。训练时从缓冲区中随机均匀采样一个小批量(batch)来计算损失。随机采样打破了样本之间的时间相关性,让梯度估计更稳定;同时同一条经验可以被多次复用,相当于提高了数据效率。这里还要强调一个前提:DQN 是异策略算法,经验回放才能成立。因为更新时使用的是 max 算子计算出的目标值,和采集这条经验时的行为策略无关。假如是 SARSA 这类同策略算法,旧策略采集的经验就不能随便回放了。3.4 目标网络(Target Network)光有经验回放还不够,DQN 的损失函数定义如下:L(θ) = E[ ( Q(s, a; θ) - (r + γ * max_{a'} Q(s', a'; θ)) )² ]注意等式右侧的 TD Target 中,下一个状态的 Q 值也使用了当前要更新的参数 θ。这意味着优化目标每更新一次参数就跟着变一次,训练目标始终在移动。在监督学习中,标签是固定的;在这里,标签由模型自己生成,梯度的方向会变得不可控,很容易发散。DQN 的解决办法是额外维护一个参数为 θ⁻ 的目标网络,专门用来计算 TD Target:目标 = r + γ * max_{a'} Q(s', a'; θ⁻)训练过程中,目标网络的参数不参与梯度更新,而是每隔固定步数(比如每 100 步或 1000 步)从策略网络复制一次:θ⁻ ← θ这样在一个同步周期内,TD Target 是相对固定的,训练就从“追一个移动靶”变成了“追一个每隔一段时间才移动一次的靶”,稳定性大幅提升。后续实践中还常使用软更新,每一步都把目标网络向策略网络靠近一点:θ⁻ ← τ * θ + (1 - τ) * θ⁻,其中 τ 通常取 0.005软更新比硬更新更平滑,在 DDPG、SAC 等算法中也是标准做法。3.5 DQN 完整算法流程把以上模块串起来,DQN 的训练流程如下:1. 初始化策略网络 Q(s,a;θ),初始化目标网络 Q(s,a;θ⁻),令 θ⁻ = θ 2. 初始化空的经验回放缓冲区 D,容量为 C 3. 循环执行每个 episode: a. 获取初始状态 s b. 循环执行每一步: - 用 ε-greedy 策略选择动作 a - 执行 a,得到 r 和 s' - 将 (s, a, r, s', done) 存入 D - 若 D 中样本数量足够,随机采样一个 batch - 计算损失 L(θ),用梯度下降更新 θ - 每隔 C 步,同步 θ⁻ ← θ - s ← s',如果 done 则结束当前 episode c. 衰减 ε下一节就按照这个流程,分别实现表格 Q-Learning 基线和 DQN,并放到 CartPole-v1 环境中对比。4. 完整实战:Q-Learning 与 DQN 对比实验4.1 实验环境与项目结构CartPole-v1 是一个经典的平衡控制任务:小车在轨道上左右移动,杆子初始竖立,目标是在 500 步内不让杆子倒下。观测是 4 维连续状态,动作是 2 个离散动作,每坚持一步奖励 +1,当杆子倾斜角度超过阈值或小车越界时 episode 提前终止。我们先用表格 Q-Learning 配合状态离散化跑一个基线,再用 DQN 跑同一个任务,观察两者在训练曲线和最终表现上的差异。4.2 表格 Q-Learning 基线文件路径:src/q_learning_baseline.py# 文件路径:src/q_learning_baseline.py import gymnasium as gym import numpy as np def build_bins(n_bins=12): """根据 CartPole 状态的物理范围构造离散化区间""" return [ np.linspace(-2.4, 2.4, n_bins), # 小车位置 x np.linspace(-3.0, 3.0, n_bins), # 小车速度 x_dot np.linspace(-0.5, 0.5, n_bins), # 杆角度 theta np.linspace(-3.0, 3.0, n_bins) # 杆角速度 theta_dot ] def discretize_state(state, bins): """将连续的 4 维状态映射为离散索引元组""" return tuple(np.digitize(state[i], bins[i]) for i in range(len(state))) def train_q_learning(episodes=1500, alpha=0.1, gamma=0.99, epsilon=1.0, epsilon_min=0.01, epsilon_decay=0.995, n_bins=12): env = gym.make('CartPole-v1') bins = build_bins(n_bins) # 每个维度离散化后索引范围是 0 ~ n_bins,所以每个维度长度是 n_bins + 1 q_table_shape = [n_bins + 1] * 4 + [env.action_space.n] q_table = np.zeros(q_table_shape) rewards_history = [] for episode in range(episodes): state, _ = env.reset() state_idx = discretize_state(state, bins) total_reward = 0 done = False while not done: # epsilon-greedy 动作选择 if np.random.random() < epsilon: action = env.action_space.sample() else: action = np.argmax(q_table[state_idx]) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated next_idx = discretize_state(next_state, bins) # Q-Learning 核心更新 if terminated: # 真正终止:后续没有回报,不继续自举 td_target = reward else: td_target = reward + gamma * np.max(q_table[next_idx]) td_error = td_target - q_table[state_idx + (action,)] q_table[state_idx + (action,)] += alpha * td_error state_idx = next_idx total_reward += reward epsilon = max(epsilon_min, epsilon * epsilon_decay) rewards_history.append(total_reward) if (episode + 1) % 100 == 0: avg = np.mean(rewards_history[-100:]) print(f"[Q-Learning] Episode {episode + 1}, " f"Average Reward(100): {avg:.2f}, Epsilon: {epsilon:.3f}") env.close() return q_table, rewards_history if __name__ == '__main__': train_q_learning()运行命令:python src/q_learning_baseline.py这段代码里有两个容易踩坑的细节:np.digitize返回的索引范围是 0 到 n_bins,因此表格每个维度的长度必须是 n_bins + 1,否则索引会越界。terminated和truncated要分开理解。CartPole 里terminated=True意味着杆子倒了,状态之后没有未来回报;而truncated=True只是达到了 500 步上限,此时环境本身没有“死亡”,理论上还可以继续自举。代码里对terminated单独处理,可以避免把“真正结束”和“超时截断”混为一谈后造成的价值低估。4.3 DQN 网络与经验回放实现文件路径:src/dqn_agent.py# 文件路径:src/dqn_agent.py import random from collections import deque import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim class DQNNetwork(nn.Module): """三层全连接网络:输入状态,输出每个动作的 Q 值""" def __init__(self, state_dim, action_dim, hidden_dim=128): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.net(x) class ReplayBuffer: """经验回放缓冲区,容量满时自动丢弃最旧样本""" def __init__(self, capacity=20000): self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) states = torch.FloatTensor([b[0] for b in batch]) actions = torch.LongTensor([b[1] for b in batch]) rewards = torch.FloatTensor([b[2] for b in batch]) next_states = torch.FloatTensor([b[3] for b in batch]) dones = torch.FloatTensor([b[4] for b in batch]) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)ReplayBuffer使用deque(maxlen=capacity)实现,容量满时自动淘汰最老的样本,保证缓冲区里始终是“最新的一批经验”。4.4 DQN Agent 与训练主循环继续在src/dqn_agent.py中补充DQNAgent:# 文件路径:src/dqn_agent.py(追加以下代码) class DQNAgent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99, epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=0.995, capacity=20000, batch_size=64, target_update=100): self.action_dim = action_dim self.gamma = gamma self.batch_size = batch_size self.target_update = target_update self.policy_net = DQNNetwork(state_dim, action_dim) self.target_net = DQNNetwork(state_dim, action_dim) self.target_net.load_state_dict(self.policy_net.state_dict()) self.target_net.eval() self.optimizer = optim.Adam(self.policy_net.parameters(), lr=lr) self.buffer = ReplayBuffer(capacity) self.epsilon = epsilon_start self.epsilon_end = epsilon_end self.epsilon_decay = epsilon_decay self.learn_step = 0 def choose_action(self, state): """epsilon-greedy 动作选择""" if random.random() < self.epsilon: return random.randrange(self.action_dim) with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_values = self.policy_net(state_tensor) return q_values.argmax().item() def update(self): """从经验回放中采样并更新一次网络参数""" if len(self.buffer) < self.batch_size: return None states, actions, rewards, next_states, dones = self.buffer.sample( self.batch_size ) # 策略网络预测:取出 batch 中每个样本对应动作的 Q 值 current_q = self.policy_net(states).gather( 1, actions.unsqueeze(1) ).squeeze(1) # 目标网络计算:r + gamma * max_a' Q_target(s', a') # no_grad 保证梯度不会流向目标网络 with torch.no_grad(): next_q = self.target_net(next_states).max(1)[0] target_q = rewards + self.gamma * next_q * (1 - dones) loss = F.mse_loss(current_q, target_q) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.learn_step += 1 # 硬更新:每隔 target_update 步同步一次目标网络 if self.learn_step % self.target_update == 0: self.target_net.load_state_dict(self.policy_net.state_dict()) return loss.item() def decay_epsilon(self): self.epsilon = max(self.epsilon_end, self.epsilon * self.epsilon_decay)有几处实现细节需要重点说明:gather(1, actions.unsqueeze(1))的作用是从网络输出中取出每个样本实际执行动作对应的 Q 值。actions形状是(batch_size,),unsqueeze(1)后变成(batch_size, 1),正好匹配 Q 值矩阵索引。计算 target 时包裹了torch.no_grad()。如果这里忘记隔离梯度,目标网络的参数也会被反向传播更新,目标网络就失去了意义。dones参与 target 计算时乘了(1 - dones)。当done为 1 时,下一状态没有未来回报,目标退化为r。训练主循环放在src/train_dqn.py:# 文件路径:src/train_dqn.py import gymnasium as gym from dqn_agent import DQNAgent def train(episodes=600, seed=42): env = gym.make('CartPole-v1') state_dim = env.observation_space.shape[0] action_dim = env.action_space.n agent = DQNAgent(state_dim, action_dim, lr=1e-3, gamma=0.99, capacity=20000, batch_size=64, target_update=100) rewards_history = [] for episode in range(episodes): state, _ = env.reset(seed=seed + episode) total_reward = 0 done = False while not done: action = agent.choose_action(state) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated # 保存经验 agent.buffer.push(state, action, reward, next_state, done) loss = agent.update() state = next_state total_reward += reward agent.decay_epsilon() rewards_history.append(total_reward) if (episode + 1) % 50 == 0: avg = sum(rewards_history[-50:]) / 50 print(f"[DQN] Episode {episode + 1}, " f"Average Reward(50): {avg:.2f}, " f"Epsilon: {agent.epsilon:.4f}, Loss: {loss:.4f}") env.close() return rewards_history if __name__ == '__main__': train()运行命令:python src/train_dqn.py4.5 结果与对比说明我使用这套超参数运行时,典型表现如下:表格 Q-Learning:前 300 个 episode 基本在随机探索,平均奖励只有 20~40;随着 ε 衰减,逐渐能学到一些有效策略,最终在 100~250 之间波动。对离散化 bin 数量非常敏感,改成 6 个 bin 可能学得很差,改成 30 个 bin 又会因为样本稀疏而变慢。DQN:早期同样因为探索不足表现一般,但通常在 100~300 个 episode 之间出现明显的“学习跳跃”,之后平均奖励很快接近 500。损失值在训练初期会比较高,后期逐渐下降并伴随波动,这是正常的。需要强调,具体数值会受随机种子、网络初始化、超参数影响,不能把上面的数字当成固定结论。**真正重要的不是“DQN 一定比 Q-Learning 好”这个结论,而是表格方法在状态维度增长后会指数级失效,而 DQN 通过神经网络逼近和两个稳定性技巧,把 Q-Learning 从低维离散场景扩展到了高维连续场景。5. 常见问题与排查思路下面整理 DQN 训练中最常遇到的几类问题,按“现象 → 原因 → 排查方向”给出解决思路。问题现象常见原因解决思路训练很久 reward 一直很低ε 衰减过快或过慢、探索不足调整epsilon_decay,让探索期足够长损失值震荡剧烈或快速上升学习率过大、奖励尺度太大降低lr,对 reward 做 clip 或归一化网络输出 Q 值虚高max 算子对 Q 值存在高估改用 Double DQN,或增大目标网络更新频率性能在学习后突然崩溃回放缓冲区里旧策略样本污染减小 buffer 容量,或尽早调整 ε 策略评估时表现远差于训练时评估时没有关闭 ε 探索评估阶段将epsilon设为 0训练曲线锯齿感极强batch 大小太小、样本相关性强增大batch_size,检查是否打乱样本顺序5.1 训练不收敛、reward 长期低位徘徊这种问题多数出在探索策略上。如果epsilon_decay太小,ε 衰减太慢,智能体会一直随机乱走,后期仍然浪费大量探索;如果衰减太快,训练还没积累足够样本就进入纯贪婪模式,策略容易困在局部。排查步骤:打印每个 episode 的 ε 值,确认它是否在合理区间递减。打印回放缓冲区中的样本数量,确认len(buffer)是否一直小于batch_size,导致网络根本没怎么训练。观察前 100 个 episode 的平均奖励是否比随机策略高。CartPole 随机策略约 20~30,如果连这个基准都不到,大概率是环境 reset 或 reward 处理错了。5.2 损失值不降反升、Q 值爆炸DQN 的损失值本身并不是单调递减的,因为 TD Target 由目标网络产生,目标网络会周期性变化。但如果损失值出现明显发散,通常有两个原因:学习率太大,参数更新步长过头,网络在最优解附近振荡甚至漂移。奖励值域过大。如果 reward 动辄上千,TD Target 就会很大,梯度也同步变大。对应的处理方式:# 在 update 中限制梯度范数,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm=10.0)同时对输入状态做归一化也很重要。CartPole 状态范围不算离谱,但如果是像素输入,一定要先除以 255 或做标准化。5.3 terminated 与 truncated 混淆导致价值低估现在的 Gymnasium API 把 episode 结束分成两类:terminated=True:环境真正进入终止状态,比如杆子倒了,后面没有 reward。truncated=True:只是到达步数上限被截断,环境本身还能继续。如果代码里把两者合并成done = terminated or truncated,同时在计算 target 时统一使用(1 - done),那么当截断发生时,下一状态本应继续自举却被强制当成终止,会系统性低估状态价值。CartPole 中截断只发生在 500 步上限,影响较小;但在很多长程任务里这个问题会被放大。更严谨的做法是分开存储:# 存储时只记录 terminated,truncated 不切断自举 agent.buffer.push(state, action, reward, next_state, float(terminated))同时循环结束条件仍是terminated or truncated,这属于容易被人忽视但值得注意的细节。6. 最佳实践与工程建议6.1 超参数配置经验以下是一组在 CartPole 这类中小型离散动作任务上通用的起点配置,实际使用时按项目调整:超参数建议范围调整方向learning rate1e-4 ~ 1e-3不收敛时调小,训练太慢可适当调大gamma0.95 ~ 0.99任务越长,gamma 应越接近 1epsilon_decay0.99 ~ 0.999任务越复杂,探索期应该越长buffer capacity10000 ~ 100000样本多样性不足时增大batch size32 ~ 256梯度不稳时增大target_update100 ~ 1000 步更新太频繁会失去稳定作用6.2 训练稳定性与可复现工程实战中,除了算法本身,训练过程的稳定性往往决定项目能否交付:固定随机种子。在代码开头设置random.seed(0)、np.random.seed(0)、torch.manual_seed(0),并在env.reset(seed=...)中传种子。虽然不能完全消除随机性,但能让结果可控。使用滑动平均评估曲线。单次 episode 的 reward 波动极大,建议每 N 个 episode 计算一次滑动平均,再决定是否继续训练。定期保存模型 checkpoint。深度强化学习经常出现“学会了又崩掉”的现象,保存历史最好模型比保存最后一个模型更稳妥。评估与训练分离。评估时关闭 ε 探索,多次随机种子运行取平均值,避免单次运行结果误导判断。6.3 DQN 的经典改进方向如果你的任务里 DQN 已经能跑通,下一步可以按顺序尝试以下改进:Double DQN:把 target 计算中的 max 改成a' = argmax Q(s'; θ),再用Q(s'; θ⁻)取值,缓解 Q 值高估问题,代码改动量很小。Dueling DQN:把网络输出拆成状态价值 V(s) 和动作优势 A(s,a),在动作差距较小的情况下更容易区分好动作。Prioritized Experience Replay:不再均匀采样经验,而是按 TD Error 大小给样本加权,让“更值得学”的样本获得更高采样概率。Noisy DQN:用噪声层代替 ε-greedy 探索,让探索强度随训练自动调整。Rainbow:把上述改进与多步 TD、分布 RL 等整合到一起,是 DQN 方向的集大成者。7. 总结与下一步学习路线本文从 Q-Learning 的表格更新公式重新出发,推导了 DQN 的损失函数,解释了为什么朴素地把 Q 表换成神经网络会导致训练不稳定,以及经验回放和目标网络分别解决了什么问题。实战部分给出了两个可运行的 CartPole 实验:表格 Q-Learning 使用状态离散化建立 Q 表,DQN 用三层全连接网络配合 20000 容量经验回放和 100 步目标网络更新完成训练。通过对比可以直观感受到,状态空间一旦连续化、高维化,表格方法就会迅速走到极限,而 DQN 把问题转化成了标准的“监督回归 + 经验复用”,让深度强化学习真正具备落地能力。接下来建议按顺序做三件事:把本文的train_dqn.py换个环境跑一遍,比如LunarLander-v2,体会动作空间更大时 DQN 的表现。花半小时给 DQN 加 Double DQN 和 Dueling 结构,观察 Q 值高估现象是否缓解。如果想继续深入,下一步就是策略梯度方向:从 REINFORCE 开始理解“直接优化策略”的思路,再过渡到 PPO、SAC 等现代主流算法。DQN 是深度强化学习里承上启下的关键一环:它承接了表格方法的全部理论根基,又开启了深度网络在控制、博弈、调度等场景的大门。建议读者把本文代码亲自动手运行一遍,改几个超参数观察曲线变化,这比只看推导收获要大得多。