很多学习强化学习的读者,在接触 DQN 之前,多半已经能用 Q-Learning 在一个小网格、迷宫或者简单的表格环境里跑通一个 Agent。那一刻你会觉得:强化学习好像也没那么复杂,更新公式背下来,迭代几百轮,Q 表收敛,策略就出来了。但一旦你把问题换成稍微真实一点的场景——比如输入是图像,或者状态是连续值,Q 表就立刻崩掉了。为什么?因为 Q 表的规模随着状态空间呈指数级增长。国际象棋的状态数已经超过 10 的 40 次方,围棋更是高到无法用表格索引。表格型 Q-Learning 能解决的问题,本质上都停留在“玩具环境”层面。DQN 解决的就是这个问题。它的核心改动是用神经网络代替 Q 表,但如果你以为 DQN 只是“Q-Learning + 一个深度学习网络”,那就漏掉了最关键的判断:直接从表格更新切到神经网络训练,算法根本不收敛。DQN 真正的贡献,是围绕神经网络训练重新设计了一套稳定机制,包括经验回放(Experience Replay)和目标网络(Target Network)。这篇文章会从 Q-Learning 的更新公式出发,讲清楚 DQN 到底改了什么、为什么这样改,然后给出一个可复制的 PyTorch 最小实现,在 Gymnasium 的 CartPole 环境上完整跑通训练和验证。看完之后,你就能回答三个问题:DQN 的三个关键机制各自解决什么痛点?训练不收敛时应该先检查哪里?从 DQN 往后,强化学习应该往哪些方向继续深入。1. 从 Q-Learning 到 DQN:到底改了什么先回顾表格 Q-Learning 的更新公式:Q(s, a) <- Q(s, a) + alpha * (r + gamma * max(Q(s', a')) - Q(s, a))这个公式的意思是:Agent 在状态 s 执行动作 a,环境返回奖励 r 并转移到新状态 s',然后用r + gamma * max(Q(s', a'))作为“真实价值”的估计,去修正原来的 Q(s, a)。这个估计叫时序差分目标(TD Target),更新的幅度由学习率 alpha 控制。表格方法的逻辑非常清晰:状态 s 和动作 a 作为下标,Q 值直接存在表里。但表格方法有三个硬伤:连续状态无法索引。如果状态是温度、位置、速度这种连续值,不可能为每一个小数建一行。维度灾难。即使状态离散,只要维度增加,表的大小就会指数膨胀。无法泛化。表格里的每个状态都是独立的,学过的状态对没见过的状态没有帮助。DQN 的思路是把查找表变成函数逼近:用一个带参数 theta 的神经网络表示Q(s, a; theta)。网络输入状态 s,输出所有动作的 Q 值。这样相近的状态会得到相近的输出,天然具备泛化能力。但问题也随之而来。表格更新一次只影响一个格子;神经网络参数更新一次,会改变所有状态下的输出。这意味着训练信号之间会产生强烈的干扰。如果只是简单套用 Q-Learning 的更新公式,把网络当表用,训练过程会剧烈震荡甚至发散。DQN 能稳定工作的前提,是另外两个设计——经验回放和目标网络——真正解决了样本相关性和目标漂移问题。2. Q-Learning 复习:先明确一个学习闭环在进入 DQN 之前,有必要把 Q-Learning 的完整逻辑再过一遍。因为后面所有 DQN 的改进,都是围绕它的损失函数和采样过程展开的。强化学习问题通常建模为马尔可夫决策过程(MDP),由五元组(S, A, P, R, gamma)描述:符号含义通俗解释S状态集合Agent 当前所处的局面A动作集合Agent 可以选择的行动P状态转移概率执行动作后环境如何变化R奖励函数每个状态动作组合获得的即时反馈gamma折扣因子未来奖励相对当前的价值衰减程度Agent 的目标是最大化长期累积回报。考虑折扣因子后,从状态 s 出发的累积回报为:G_t = r_t + gamma * r_{t+1} + gamma^2 * r_{t+2} + ...动作价值函数 Q(s, a) 表示在状态 s 执行动作 a 后,按照某个策略继续行动所能获得的期望回报。最优动作价值函数 Q* 满足贝尔曼最优方程:Q*(s, a) = r + gamma * max(Q*(s', a'))这个方程的本质是:当前状态动作的价值,等于即时奖励加上下一个状态的最优价值。Q-Learning 的更新公式正是对这个方程的迭代逼近。Q-Learning 是离线策略(Off-Policy)算法,它用行为策略采集数据,却用贪心策略max Q(s', a')更新价值。这样做的优势是可以在探索过程中逐步逼近最优 Q 值,而不需要当前策略已经是最优策略。整个过程可以用下面这段伪代码概括:初始化 Q(s, a) 为任意值 重复每个 episode: 初始化状态 s 重复每个 step: 用 epsilon 贪心策略根据当前 Q 选择动作 a 执行 a,获得奖励 r 和新状态 s' 更新 Q(s, a) <- Q(s, a) + alpha * (r + gamma * max Q(s', a') - Q(s, a)) s <- s' 直到 s 为终止状态看到这里你应该明白,Q-Learning 的更新本身并不复杂。它的局限不在公式,而在“Q 表”这个存储载体。DQN 恰恰是从载体上下手,把表的存储和查找替换成神经网络的参数化和前向计算。3. DQN 的三个关键机制DQN 论文(Nature 版本)里最重要的改进有三个:神经网络函数逼近、经验回放、目标网络。下面逐一分析每个机制解决的问题。3.1 神经网络函数逼近DQN 的网络结构很直接:输入是状态的特征向量,输出是每个动作的 Q 值。对于 CartPole 这种输入只有 4 个连续值的小环境,两到三层全连接网络就足够;对于 Atari 游戏,输入是 84x84 的灰度图像,则需要卷积神经网络。用神经网络做 Q 值逼近,最大的好处是泛化。表格方法里,状态 (0.1, 0.2) 和状态 (0.1, 0.3) 是完全没有关联的两个格子;但在神经网络里,它们会映射到相近的特征空间,输出自然接近。这是 DQN 能应对连续和高维状态的根本原因。代价是训练不稳定。每次梯度下降都在改动网络的整体参数,可能出现“学会一个状态、忘了另一个状态”的灾难性遗忘。所以单靠网络替代 Q 表远远不够。3.2 经验回放(Experience Replay)Q-Learning 从环境中按时间顺序采样,连续样本之间存在强烈的相关性。例如在 CartPole 中,第 1 帧和第 2 帧的画面非常相似,如果直接用这些连续样本做梯度更新,梯度方向会偏向最近的经历,导致训练震荡。经验回放的做法是:把每次交互产生的转移样本(s, a, r, s', done)存进一个固定大小的缓冲区,训练时从中随机采样一个小批量。这带来两个好处:打破样本相关性。随机采样让每个 batch 里的样本尽量来自不同的时间点,梯度估计更接近独立同分布。提高样本利用率。一条样本可以被多次使用,尤其适合样本采集成本高的真实环境。经验回放并不复杂,代码上就是一个带有最大容量的队列,满员后自动丢弃旧样本。但它是 DQN 稳定训练的基础。3.3 目标网络(Target Network)如果网络同时扮演两个角色——“预测当前 Q 值”和“计算 TD 目标”,就会出现一个问题:每次更新参数后,TD 目标也随之变化。这相当于一个移动靶,模型追着不断漂移的目标训练,很难收敛。DQN 的解决办法是维护两份网络参数:策略网络policy_net:负责输出当前动作的 Q 值,也承担梯度更新。目标网络target_net:负责计算 TD 目标中的max Q(s', a'),参数每隔固定步数才从策略网络复制一次。训练过程中,策略网络只更新自身参数,目标网络保持相对稳定。这样 TD 目标在一段时间内是固定的,训练变成了一个稳定的回归问题。目标网络不是只要更新越频繁越好,太频繁会重新引入目标漂移,太少则会让目标过时,一般每 50 到 200 步同步一次是比较常用的选择。把三个机制合起来看,DQN 的每一步更新都遵循这个流程:从经验缓冲区随机采样一个 batch 用策略网络计算当前状态动作的 Q 值 用目标网络计算下一状态的最大 Q 值,构造 TD 目标 计算两者之间的均方误差或 Huber 损失 反向传播更新策略网络 每隔 N 步,将策略网络参数复制给目标网络4. 环境准备与依赖安装动手实现之前,先把运行环境准备好。本文的示例代码基于 Python 和 PyTorch,环境使用 Gymnasium。这里特别提醒一点:如果你过去习惯使用gym,请注意新版项目已经改名为gymnasium,API 也有一些变化。新版env.step()返回五个值:observation、reward、terminated、truncated、info,其中terminated表示智能体是否因达到终止条件而结束,truncated表示是否因超过步数上限或环境超时而被截断。判断 episode 是否结束,需要把两者取或。安装依赖只需两条命令:# 创建虚拟环境(推荐) python -m venv rl_env source rl_env/bin/activate # Windows 下使用 rl_env\Scripts\activate pip install torch gymnasium matplotlib numpy安装完成后,可以用下面的代码验证环境是否可用:import gymnasium as gym env = gym.make("CartPole-v1") print("观测空间:", env.observation_space) print("动作空间:", env.action_space)CartPole-v1 的观测空间是 4 个连续值:小车位置、速度、杆子角度、角速度;动作空间只有两个:向左和向右。目标是通过左右移动小车,让杆子尽量保持垂直。每个 step 存活奖励为 1,episode 默认在 500 步时截断。对于 DQN 入门,这个环境是最合适的试验场。5. DQN 最小实现:从零跑通 CartPole下面给出一个完整的 DQN 实现,可以保存为dqn_cartpole.py后直接运行。代码刻意保持结构清晰,没有做过度封装,方便你逐行理解。5.1 定义 Q 网络import random from collections import deque import gymnasium as gym import matplotlib.pyplot as plt import numpy as np import torch import torch.nn as nn import torch.optim as optim class DQNNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_size=128): super(DQNNetwork, self).__init__() self.fc1 = nn.Linear(state_dim, hidden_size) self.fc2 = nn.Linear(hidden_size, hidden_size) self.fc3 = nn.Linear(hidden_size, action_dim) def forward(self, x): x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) return self.fc3(x)这个网络的结构非常简单:输入 4 维状态,经过两个 128 维的隐藏层,输出 2 个动作的 Q 值。隐藏层的激活函数用 ReLU,最后一层不接激活,因为 Q 值理论上可以是任意实数。5.2 经验回放缓冲区class ReplayBuffer: def __init__(self, capacity): 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 = np.array([b[0] for b in batch], dtype=np.float32) actions = np.array([b[1] for b in batch], dtype=np.int64) rewards = np.array([b[2] for b in batch], dtype=np.float32) next_states = np.array([b[3] for b in batch], dtype=np.float32) dones = np.array([b[4] for b in batch], dtype=np.float32) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)这里用 Python 标准库的deque实现固定容量队列,容量满了之后最早的经验会自然丢弃。采样时通过random.sample随机选取一小批样本,这正是“打破相关性”的关键。5.3 DQN Agentclass DQNAgent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99, batch_size=32, buffer_size=10000, target_update_freq=100): self.state_dim = state_dim self.action_dim = action_dim self.gamma = gamma self.batch_size = batch_size self.target_update_freq = target_update_freq 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.loss_fn = nn.SmoothL1Loss() # Huber 损失,比 MSE 对异常值更稳健 self.memory = ReplayBuffer(buffer_size) self.total_steps = 0 def choose_action(self, state, epsilon): """使用 epsilon 贪心策略选择动作""" if random.random() < epsilon: return random.randint(0, self.action_dim - 1) with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_values = self.policy_net(state_tensor) return int(q_values.argmax(dim=1).item()) def store_transition(self, state, action, reward, next_state, done): self.memory.push(state, action, reward, next_state, done) def update(self): """从经验池采样一个 batch,执行一次梯度更新""" if len(self.memory) < self.batch_size: return 0.0 states, actions, rewards, next_states, dones = self.memory.sample(self.batch_size) states_t = torch.FloatTensor(states) actions_t = torch.LongTensor(actions).unsqueeze(-1) rewards_t = torch.FloatTensor(rewards).unsqueeze(-1) next_states_t = torch.FloatTensor(next_states) dones_t = torch.FloatTensor(dones).unsqueeze(-1) # 策略网络输出当前状态动作的 Q 值 q_pred = self.policy_net(states_t).gather(1, actions_t) # 用目标网络计算下一状态的最大 Q 值,构造 TD 目标 with torch.no_grad(): q_next = self.target_net(next_states_t).max(dim=1, keepdim=True).values q_target = rewards_t + self.gamma * q_next * (1 - dones_t) # 计算损失并反向传播 loss = self.loss_fn(q_pred, q_target) self.optimizer.zero_grad() loss.backward() # 梯度的值不宜过大,可以视情况引入梯度裁剪 torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm=1.0) self.optimizer.step() self.total_steps += 1 if self.total_steps % self.target_update_freq == 0: self.target_net.load_state_dict(self.policy_net.state_dict()) return loss.item()这里有一个非常值得注意的细节:q_target的计算被放进了torch.no_grad()里。因为目标网络的更新频率较低,我们只需要用它计算目标值,不希望梯度经过它传播。否则梯度会同时更新两个网络,目标漂移问题会再次出现。损失函数选择了SmoothL1Loss,也就是 Huber 损失。它结合了 MSE 在零点附近的平滑性和 MAE 在大误差时的鲁棒性,当 TD 误差很大时不会产生过大的梯度。5.4 训练主循环def train(episodes=500, seed=42): env = gym.make("CartPole-v1") agent = DQNAgent(state_dim=4, action_dim=2) epsilon = 1.0 epsilon_min = 0.01 epsilon_decay = 0.995 reward_history = [] avg_reward_history = [] for episode in range(1, episodes + 1): state, _ = env.reset(seed=seed + episode) total_reward = 0 while True: action = agent.choose_action(state, epsilon) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated agent.store_transition(state, action, reward, next_state, done) agent.update() state = next_state total_reward += reward if done: break epsilon = max(epsilon_min, epsilon * epsilon_decay) reward_history.append(total_reward) avg_reward = np.mean(reward_history[-50:]) avg_reward_history.append(avg_reward) if episode % 20 == 0: print(f"Episode {episode}, Reward: {total_reward}, " f"Avg(last 50): {avg_reward:.2f}, Epsilon: {epsilon:.3f}") if avg_reward >= 450: print(f"收敛条件达成于第 {episode} 个 episode,平均奖励 {avg_reward:.2f}") break env.close() return reward_history, avg_reward_history训练过程中使用了 epsilon 贪心策略。刚开始 epsilon 设为 1.0,Agent 几乎完全随机探索;随着训练推进,epsilon 按 0.995 的速率指数衰减,逐渐减少随机动作的比例,让 Agent 更多依赖已经学到的策略。这种方式保证了前期有足够多的探索样本,后期有足够高的利用率。5.5 运行与绘图if __name__ == "__main__": rewards, avgs = train(episodes=500) plt.figure(figsize=(10, 5)) plt.plot(rewards, alpha=0.4, label="Episode Reward") plt.plot(avgs, label="Avg Reward (last 50)") plt.xlabel("Episode") plt.ylabel("Reward") plt.legend() plt.title("DQN Training on CartPole-v1") plt.savefig("dqn_cartpole.png", dpi=150) print("训练曲线已保存到 dqn_cartpole.png")运行方式:python dqn_cartpole.pyCartPole 的状态向量只有 4 维,网络非常小,CPU 就能完成训练,不需要 GPU。这个示例非常适合用来验证你对 DQN 的理解是否正确。6. 运行结果与收敛判断训练开始后,你会在终端看到类似这样的输出(具体数值会因随机种子和采样过程略有不同):Episode 20, Reward: 22, Avg(last 50): 19.50, Epsilon: 0.905 Episode 40, Reward: 41, Avg(last 50): 24.75, Epsilon: 0.820 Episode 60, Reward: 45, Avg(last 50): 31.20, Epsilon: 0.742 Episode 80, Reward: 67, Avg(last 50): 44.35, Epsilon: 0.671从训练行为上看,前几十轮 reward 会很低,通常只有 20 到 50,说明 Agent 还在大量随机探索。随着 epsilon 衰减和经验积累,reward 会出现阶段性跳升。一个比较典型的现象是:在第 100 到 200 轮之间,Agent 突然学会稳定控制杆子一段时间,reward 迅速升高到 200 以上,之后可能在 200 到 500 之间波动,最终稳定在 450 以上。收敛判断标准建议看最近 50 个 episode 的平均奖励。CartPole-v1 的 episode 上限是 500,如果你看到平均奖励持续高于 450,说明 Agent 已经非常稳定,训练可以提前结束。代码中也设置了avg_reward >= 450的提前停止条件。如果训练结束(跑完 500 个 episode)后平均奖励仍然只有几十,不要急着调神经网络结构,优先检查下面几个环节:epsilon 衰减是否太快,导致后期探索不足;经验回放缓冲区容量是否太小;目标网络同步频率是否太慢或太快;学习率是否设置过大。为了更严谨地评估最终策略,建议单独写一个评估函数,只用纯贪心策略去玩游戏,不加入任何随机探索:def evaluate(agent, episodes=20, seed=0): env = gym.make("CartPole-v1") total = 0.0 for i in range(episodes): state, _ = env.reset(seed=seed + i) done = False while not done: action = agent.choose_action(state, epsilon=0.0) state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated total += reward env.close() return total / episodes评估时把epsilon固定为 0,Agent 始终选择最大 Q 值对应的动作。这样得到的平均奖励比训练日志中的数值更真实地反映策略水平。7. DQN 常见问题与排查思路DQN 在入门阶段最常见的挫折不是看不懂代码,而是“代码能跑,但奖励曲线死活不上涨”。下面整理了一份排查表,建议收藏备用。问题现象可能原因排查方式解决方案训练曲线一直贴地,reward 极低epsilon 衰减太快,探索不足打印 epsilon 变化,观察后期是否已小于 0.05调大 epsilon_decay,如 0.998reward 上不去,且震荡剧烈学习率过大或目标网络同步过快观察 loss 是否忽大忽小降低学习率到 1e-4,调大 target_update_freqLoss 变成 NaN输入或者梯度爆炸检查 reward 和观测是否有异常值加入梯度裁剪,对观测做归一化训练前期 reward 能到 500,后期反而下降过拟合或 epsilon 过小导致探索消失绘制最近 50 轮平均曲线引入更大的经验池,或调小学习率目标网络同步后性能明显变差目标网络同步不频繁检查 total_steps 是否正常累加适当调小 target_update_freq训练非常慢每个 step 都执行 update,但缓冲区还没满打印 len(memory)正常现象,缓冲区满之前不更新即可在这些问题里,最常见的原因是“经验池里还没有足够多样本就开始更新”,以及“epsilon 衰减太快导致后期一直在利用”。需要记住一个基本原则:DQN 是离线策略算法,它的样本来自过去任意时刻的策略。经验池里的样本多样性才是训练稳定的基础。另外,如果你发现代码能跑但是所有动作都选同一个,基本可以确定 Q 网络没有学到有效信息。这时候先不要调网络,把奖励曲线和 epsilon 曲线画出来,看看到底是探索不够还是 TD 目标有问题。8. DQN 最佳实践与工程建议跑通一个 CartPole 示例只是第一步。如果把 DQN 用在实际项目中,下面这些工程经验值得提前了解。8.1 超参数设置的基本盘DQN 对超参数比较敏感,下面是一组在 CartPole 上常用的默认值,可作为起点:参数推荐值说明学习率 lr1e-3 到 1e-4过大容易震荡,过小收敛慢折扣因子 gamma0.99任务越长,gamma 越接近 1batch_size32 到 256影响梯度估计的稳定性经验池容量10000 到 100000与环境复杂度和采样成本成正比target_update_freq50 到 200更新越快,目标变化越快epsilon_decay0.99 到 0.998需要保证中期还有一定探索隐藏层宽度64 到 256CartPole 128 足够,复杂任务需要更大不要同时调多个超参数。每次只改一个,对比训练曲线,才能判断哪个因素真正影响效果。8.2 输入归一化CartPole 的四个观测维度数值范围不同,小车位置在-4.8到4.8之间,角度在约-0.42到0.42之间,角速度范围更大。如果不归一化,网络训练初期容易被数值范围大的维度主导。使用环境的observation_space.high和observation_space.low做归一化是一个稳健的工程习惯。8.3 梯度裁剪即使有经验回放和目标网络,遇到极端奖励或离群样本时,梯度依然可能爆炸。在每次loss.backward()之后加一行梯度裁剪是成本最低的稳定手段:torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm=1.0)这一行代码对于复杂环境尤其重要,建议在写 DQN 时直接加上。8.4 模型保存与加载训练完成后,需要把策略网络保存下来,方便后续评估或在真实环境中部署:torch.save(agent.policy_net.state_dict(), "dqn_cartpole.pth")需要继续训练时,可以直接加载权重再跑:agent.policy_net.load_state_dict(torch.load("dqn_cartpole.pth")) agent.target_net.load_state_dict(agent.policy_net.state_dict())注意加载模型后,目标网络也要同步一次,否则目标网络和策略网络的初始参数不一致,训练一开始就会不稳定。8.5 固定随机种子强化学习训练本身有大量随机性,环境、经验采样、网络初始化都会影响结果。为了方便对比实验,固定随机种子几乎是必须的:import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42)在gym.make时,也可以给环境指定 seed。这样出现问题时,可以精确复现。8.6 评估优先于训练建议在训练循环之外单独维护一个评估函数,每隔固定轮数执行一次纯贪心评估。训练时的高 reward 可能是因为 epsilon 较高导致的偶然结果,评估曲线才是真实策略水平的体现。把评估曲线和训练曲线画在一张图上,能帮你更早发现问题。9. DQN 的局限与进一步学习方向到此为止,你已经理解了 DQN 的核心机制,也亲手实现并运行了一个完整示例。但入门的终点,恰恰是深入理解强化学习的起点。DQN 本身有几个非常明显的局限,知道这些局限,也就知道下一步该学什么。第一个局限是过估计问题。DQN 在计算 TD 目标时使用max Q(s', a'),而最大值操作会引入正向偏差。即使真实的 Q 值相同,噪声也会让最大值偏高。这会导致 Agent 对某些动作产生过高的估计。解决方案是 Double DQN:用策略网络选择最优动作,再用目标网络计算该动作的 Q 值,从而降低过估计。第二个局限是样本效率低。虽然经验回放提升了样本利用率,但 DQN 本质上还是需要大量交互。在真实机器人、自动驾驶这类采样成本高的场景里,样本效率直接决定了项目可行性。后续的 Prioritized Experience Replay 通过按 TD 误差大小加权采样,让更“意外”的样本得到更多训练机会,这是提升样本效率的有效方向。第三个局限是只能处理离散动作。DQN 输出的是一组动作的 Q 值,天然适合离散动作空间。但很多真实问题,如机器人控制、无人机飞行,动作本身是连续值。解决连续控制问题的主流算法是 Actor-Critic 家族的 DDPG、TD3、SAC,以及基于策略梯度的 PPO。学习路径上,建议先吃透 DQN 的价值函数逼近思路,再过渡到 Actor-Critic 架构。如果按学习进度规划,你的下一步可以是:实现 Double DQN,在 CartPole 上对比它与标准 DQN 的差异;加入 Prioritized Experience Replay,观察训练速度的变化;换一个更复杂的环境,比如 LunarLander-v2,测试代码的通用性;学习策略梯度方法,理解价值学习和策略学习两条路线的差别。真正理解 DQN,不在于背下三个机制的名字,而在于当你面对一条不怎么上涨的训练曲线时,能快速判断出问题出在经验采样、目标计算、还是探索策略。这也是从“会跑代码”走向“会调试算法”的关键一步。建议你先把今天的 CartPole 示例跑熟,再动手修改一个超参数,观察它对收敛速度的影响。有了这个基础,后续的强化学习进阶之路会顺畅很多。
很多学习强化学习的读者,在接触 DQN 之前,多半已经能用 Q-Learning 在一个小网格、迷宫或者简单的表格环境里跑通一个 Agent。那一刻你会觉得:强化学习好像也没那么复杂,更新公式背下来,迭代几百轮,Q 表收敛,策略就出来了。但一旦你把问题换成稍微真实一点的场景——比如输入是图像,或者状态是连续值,Q 表就立刻崩掉了。为什么?因为 Q 表的规模随着状态空间呈指数级增长。国际象棋的状态数已经超过 10 的 40 次方,围棋更是高到无法用表格索引。表格型 Q-Learning 能解决的问题,本质上都停留在“玩具环境”层面。DQN 解决的就是这个问题。它的核心改动是用神经网络代替 Q 表,但如果你以为 DQN 只是“Q-Learning + 一个深度学习网络”,那就漏掉了最关键的判断:直接从表格更新切到神经网络训练,算法根本不收敛。DQN 真正的贡献,是围绕神经网络训练重新设计了一套稳定机制,包括经验回放(Experience Replay)和目标网络(Target Network)。这篇文章会从 Q-Learning 的更新公式出发,讲清楚 DQN 到底改了什么、为什么这样改,然后给出一个可复制的 PyTorch 最小实现,在 Gymnasium 的 CartPole 环境上完整跑通训练和验证。看完之后,你就能回答三个问题:DQN 的三个关键机制各自解决什么痛点?训练不收敛时应该先检查哪里?从 DQN 往后,强化学习应该往哪些方向继续深入。1. 从 Q-Learning 到 DQN:到底改了什么先回顾表格 Q-Learning 的更新公式:Q(s, a) <- Q(s, a) + alpha * (r + gamma * max(Q(s', a')) - Q(s, a))这个公式的意思是:Agent 在状态 s 执行动作 a,环境返回奖励 r 并转移到新状态 s',然后用r + gamma * max(Q(s', a'))作为“真实价值”的估计,去修正原来的 Q(s, a)。这个估计叫时序差分目标(TD Target),更新的幅度由学习率 alpha 控制。表格方法的逻辑非常清晰:状态 s 和动作 a 作为下标,Q 值直接存在表里。但表格方法有三个硬伤:连续状态无法索引。如果状态是温度、位置、速度这种连续值,不可能为每一个小数建一行。维度灾难。即使状态离散,只要维度增加,表的大小就会指数膨胀。无法泛化。表格里的每个状态都是独立的,学过的状态对没见过的状态没有帮助。DQN 的思路是把查找表变成函数逼近:用一个带参数 theta 的神经网络表示Q(s, a; theta)。网络输入状态 s,输出所有动作的 Q 值。这样相近的状态会得到相近的输出,天然具备泛化能力。但问题也随之而来。表格更新一次只影响一个格子;神经网络参数更新一次,会改变所有状态下的输出。这意味着训练信号之间会产生强烈的干扰。如果只是简单套用 Q-Learning 的更新公式,把网络当表用,训练过程会剧烈震荡甚至发散。DQN 能稳定工作的前提,是另外两个设计——经验回放和目标网络——真正解决了样本相关性和目标漂移问题。2. Q-Learning 复习:先明确一个学习闭环在进入 DQN 之前,有必要把 Q-Learning 的完整逻辑再过一遍。因为后面所有 DQN 的改进,都是围绕它的损失函数和采样过程展开的。强化学习问题通常建模为马尔可夫决策过程(MDP),由五元组(S, A, P, R, gamma)描述:符号含义通俗解释S状态集合Agent 当前所处的局面A动作集合Agent 可以选择的行动P状态转移概率执行动作后环境如何变化R奖励函数每个状态动作组合获得的即时反馈gamma折扣因子未来奖励相对当前的价值衰减程度Agent 的目标是最大化长期累积回报。考虑折扣因子后,从状态 s 出发的累积回报为:G_t = r_t + gamma * r_{t+1} + gamma^2 * r_{t+2} + ...动作价值函数 Q(s, a) 表示在状态 s 执行动作 a 后,按照某个策略继续行动所能获得的期望回报。最优动作价值函数 Q* 满足贝尔曼最优方程:Q*(s, a) = r + gamma * max(Q*(s', a'))这个方程的本质是:当前状态动作的价值,等于即时奖励加上下一个状态的最优价值。Q-Learning 的更新公式正是对这个方程的迭代逼近。Q-Learning 是离线策略(Off-Policy)算法,它用行为策略采集数据,却用贪心策略max Q(s', a')更新价值。这样做的优势是可以在探索过程中逐步逼近最优 Q 值,而不需要当前策略已经是最优策略。整个过程可以用下面这段伪代码概括:初始化 Q(s, a) 为任意值 重复每个 episode: 初始化状态 s 重复每个 step: 用 epsilon 贪心策略根据当前 Q 选择动作 a 执行 a,获得奖励 r 和新状态 s' 更新 Q(s, a) <- Q(s, a) + alpha * (r + gamma * max Q(s', a') - Q(s, a)) s <- s' 直到 s 为终止状态看到这里你应该明白,Q-Learning 的更新本身并不复杂。它的局限不在公式,而在“Q 表”这个存储载体。DQN 恰恰是从载体上下手,把表的存储和查找替换成神经网络的参数化和前向计算。3. DQN 的三个关键机制DQN 论文(Nature 版本)里最重要的改进有三个:神经网络函数逼近、经验回放、目标网络。下面逐一分析每个机制解决的问题。3.1 神经网络函数逼近DQN 的网络结构很直接:输入是状态的特征向量,输出是每个动作的 Q 值。对于 CartPole 这种输入只有 4 个连续值的小环境,两到三层全连接网络就足够;对于 Atari 游戏,输入是 84x84 的灰度图像,则需要卷积神经网络。用神经网络做 Q 值逼近,最大的好处是泛化。表格方法里,状态 (0.1, 0.2) 和状态 (0.1, 0.3) 是完全没有关联的两个格子;但在神经网络里,它们会映射到相近的特征空间,输出自然接近。这是 DQN 能应对连续和高维状态的根本原因。代价是训练不稳定。每次梯度下降都在改动网络的整体参数,可能出现“学会一个状态、忘了另一个状态”的灾难性遗忘。所以单靠网络替代 Q 表远远不够。3.2 经验回放(Experience Replay)Q-Learning 从环境中按时间顺序采样,连续样本之间存在强烈的相关性。例如在 CartPole 中,第 1 帧和第 2 帧的画面非常相似,如果直接用这些连续样本做梯度更新,梯度方向会偏向最近的经历,导致训练震荡。经验回放的做法是:把每次交互产生的转移样本(s, a, r, s', done)存进一个固定大小的缓冲区,训练时从中随机采样一个小批量。这带来两个好处:打破样本相关性。随机采样让每个 batch 里的样本尽量来自不同的时间点,梯度估计更接近独立同分布。提高样本利用率。一条样本可以被多次使用,尤其适合样本采集成本高的真实环境。经验回放并不复杂,代码上就是一个带有最大容量的队列,满员后自动丢弃旧样本。但它是 DQN 稳定训练的基础。3.3 目标网络(Target Network)如果网络同时扮演两个角色——“预测当前 Q 值”和“计算 TD 目标”,就会出现一个问题:每次更新参数后,TD 目标也随之变化。这相当于一个移动靶,模型追着不断漂移的目标训练,很难收敛。DQN 的解决办法是维护两份网络参数:策略网络policy_net:负责输出当前动作的 Q 值,也承担梯度更新。目标网络target_net:负责计算 TD 目标中的max Q(s', a'),参数每隔固定步数才从策略网络复制一次。训练过程中,策略网络只更新自身参数,目标网络保持相对稳定。这样 TD 目标在一段时间内是固定的,训练变成了一个稳定的回归问题。目标网络不是只要更新越频繁越好,太频繁会重新引入目标漂移,太少则会让目标过时,一般每 50 到 200 步同步一次是比较常用的选择。把三个机制合起来看,DQN 的每一步更新都遵循这个流程:从经验缓冲区随机采样一个 batch 用策略网络计算当前状态动作的 Q 值 用目标网络计算下一状态的最大 Q 值,构造 TD 目标 计算两者之间的均方误差或 Huber 损失 反向传播更新策略网络 每隔 N 步,将策略网络参数复制给目标网络4. 环境准备与依赖安装动手实现之前,先把运行环境准备好。本文的示例代码基于 Python 和 PyTorch,环境使用 Gymnasium。这里特别提醒一点:如果你过去习惯使用gym,请注意新版项目已经改名为gymnasium,API 也有一些变化。新版env.step()返回五个值:observation、reward、terminated、truncated、info,其中terminated表示智能体是否因达到终止条件而结束,truncated表示是否因超过步数上限或环境超时而被截断。判断 episode 是否结束,需要把两者取或。安装依赖只需两条命令:# 创建虚拟环境(推荐) python -m venv rl_env source rl_env/bin/activate # Windows 下使用 rl_env\Scripts\activate pip install torch gymnasium matplotlib numpy安装完成后,可以用下面的代码验证环境是否可用:import gymnasium as gym env = gym.make("CartPole-v1") print("观测空间:", env.observation_space) print("动作空间:", env.action_space)CartPole-v1 的观测空间是 4 个连续值:小车位置、速度、杆子角度、角速度;动作空间只有两个:向左和向右。目标是通过左右移动小车,让杆子尽量保持垂直。每个 step 存活奖励为 1,episode 默认在 500 步时截断。对于 DQN 入门,这个环境是最合适的试验场。5. DQN 最小实现:从零跑通 CartPole下面给出一个完整的 DQN 实现,可以保存为dqn_cartpole.py后直接运行。代码刻意保持结构清晰,没有做过度封装,方便你逐行理解。5.1 定义 Q 网络import random from collections import deque import gymnasium as gym import matplotlib.pyplot as plt import numpy as np import torch import torch.nn as nn import torch.optim as optim class DQNNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_size=128): super(DQNNetwork, self).__init__() self.fc1 = nn.Linear(state_dim, hidden_size) self.fc2 = nn.Linear(hidden_size, hidden_size) self.fc3 = nn.Linear(hidden_size, action_dim) def forward(self, x): x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) return self.fc3(x)这个网络的结构非常简单:输入 4 维状态,经过两个 128 维的隐藏层,输出 2 个动作的 Q 值。隐藏层的激活函数用 ReLU,最后一层不接激活,因为 Q 值理论上可以是任意实数。5.2 经验回放缓冲区class ReplayBuffer: def __init__(self, capacity): 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 = np.array([b[0] for b in batch], dtype=np.float32) actions = np.array([b[1] for b in batch], dtype=np.int64) rewards = np.array([b[2] for b in batch], dtype=np.float32) next_states = np.array([b[3] for b in batch], dtype=np.float32) dones = np.array([b[4] for b in batch], dtype=np.float32) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)这里用 Python 标准库的deque实现固定容量队列,容量满了之后最早的经验会自然丢弃。采样时通过random.sample随机选取一小批样本,这正是“打破相关性”的关键。5.3 DQN Agentclass DQNAgent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99, batch_size=32, buffer_size=10000, target_update_freq=100): self.state_dim = state_dim self.action_dim = action_dim self.gamma = gamma self.batch_size = batch_size self.target_update_freq = target_update_freq 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.loss_fn = nn.SmoothL1Loss() # Huber 损失,比 MSE 对异常值更稳健 self.memory = ReplayBuffer(buffer_size) self.total_steps = 0 def choose_action(self, state, epsilon): """使用 epsilon 贪心策略选择动作""" if random.random() < epsilon: return random.randint(0, self.action_dim - 1) with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_values = self.policy_net(state_tensor) return int(q_values.argmax(dim=1).item()) def store_transition(self, state, action, reward, next_state, done): self.memory.push(state, action, reward, next_state, done) def update(self): """从经验池采样一个 batch,执行一次梯度更新""" if len(self.memory) < self.batch_size: return 0.0 states, actions, rewards, next_states, dones = self.memory.sample(self.batch_size) states_t = torch.FloatTensor(states) actions_t = torch.LongTensor(actions).unsqueeze(-1) rewards_t = torch.FloatTensor(rewards).unsqueeze(-1) next_states_t = torch.FloatTensor(next_states) dones_t = torch.FloatTensor(dones).unsqueeze(-1) # 策略网络输出当前状态动作的 Q 值 q_pred = self.policy_net(states_t).gather(1, actions_t) # 用目标网络计算下一状态的最大 Q 值,构造 TD 目标 with torch.no_grad(): q_next = self.target_net(next_states_t).max(dim=1, keepdim=True).values q_target = rewards_t + self.gamma * q_next * (1 - dones_t) # 计算损失并反向传播 loss = self.loss_fn(q_pred, q_target) self.optimizer.zero_grad() loss.backward() # 梯度的值不宜过大,可以视情况引入梯度裁剪 torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm=1.0) self.optimizer.step() self.total_steps += 1 if self.total_steps % self.target_update_freq == 0: self.target_net.load_state_dict(self.policy_net.state_dict()) return loss.item()这里有一个非常值得注意的细节:q_target的计算被放进了torch.no_grad()里。因为目标网络的更新频率较低,我们只需要用它计算目标值,不希望梯度经过它传播。否则梯度会同时更新两个网络,目标漂移问题会再次出现。损失函数选择了SmoothL1Loss,也就是 Huber 损失。它结合了 MSE 在零点附近的平滑性和 MAE 在大误差时的鲁棒性,当 TD 误差很大时不会产生过大的梯度。5.4 训练主循环def train(episodes=500, seed=42): env = gym.make("CartPole-v1") agent = DQNAgent(state_dim=4, action_dim=2) epsilon = 1.0 epsilon_min = 0.01 epsilon_decay = 0.995 reward_history = [] avg_reward_history = [] for episode in range(1, episodes + 1): state, _ = env.reset(seed=seed + episode) total_reward = 0 while True: action = agent.choose_action(state, epsilon) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated agent.store_transition(state, action, reward, next_state, done) agent.update() state = next_state total_reward += reward if done: break epsilon = max(epsilon_min, epsilon * epsilon_decay) reward_history.append(total_reward) avg_reward = np.mean(reward_history[-50:]) avg_reward_history.append(avg_reward) if episode % 20 == 0: print(f"Episode {episode}, Reward: {total_reward}, " f"Avg(last 50): {avg_reward:.2f}, Epsilon: {epsilon:.3f}") if avg_reward >= 450: print(f"收敛条件达成于第 {episode} 个 episode,平均奖励 {avg_reward:.2f}") break env.close() return reward_history, avg_reward_history训练过程中使用了 epsilon 贪心策略。刚开始 epsilon 设为 1.0,Agent 几乎完全随机探索;随着训练推进,epsilon 按 0.995 的速率指数衰减,逐渐减少随机动作的比例,让 Agent 更多依赖已经学到的策略。这种方式保证了前期有足够多的探索样本,后期有足够高的利用率。5.5 运行与绘图if __name__ == "__main__": rewards, avgs = train(episodes=500) plt.figure(figsize=(10, 5)) plt.plot(rewards, alpha=0.4, label="Episode Reward") plt.plot(avgs, label="Avg Reward (last 50)") plt.xlabel("Episode") plt.ylabel("Reward") plt.legend() plt.title("DQN Training on CartPole-v1") plt.savefig("dqn_cartpole.png", dpi=150) print("训练曲线已保存到 dqn_cartpole.png")运行方式:python dqn_cartpole.pyCartPole 的状态向量只有 4 维,网络非常小,CPU 就能完成训练,不需要 GPU。这个示例非常适合用来验证你对 DQN 的理解是否正确。6. 运行结果与收敛判断训练开始后,你会在终端看到类似这样的输出(具体数值会因随机种子和采样过程略有不同):Episode 20, Reward: 22, Avg(last 50): 19.50, Epsilon: 0.905 Episode 40, Reward: 41, Avg(last 50): 24.75, Epsilon: 0.820 Episode 60, Reward: 45, Avg(last 50): 31.20, Epsilon: 0.742 Episode 80, Reward: 67, Avg(last 50): 44.35, Epsilon: 0.671从训练行为上看,前几十轮 reward 会很低,通常只有 20 到 50,说明 Agent 还在大量随机探索。随着 epsilon 衰减和经验积累,reward 会出现阶段性跳升。一个比较典型的现象是:在第 100 到 200 轮之间,Agent 突然学会稳定控制杆子一段时间,reward 迅速升高到 200 以上,之后可能在 200 到 500 之间波动,最终稳定在 450 以上。收敛判断标准建议看最近 50 个 episode 的平均奖励。CartPole-v1 的 episode 上限是 500,如果你看到平均奖励持续高于 450,说明 Agent 已经非常稳定,训练可以提前结束。代码中也设置了avg_reward >= 450的提前停止条件。如果训练结束(跑完 500 个 episode)后平均奖励仍然只有几十,不要急着调神经网络结构,优先检查下面几个环节:epsilon 衰减是否太快,导致后期探索不足;经验回放缓冲区容量是否太小;目标网络同步频率是否太慢或太快;学习率是否设置过大。为了更严谨地评估最终策略,建议单独写一个评估函数,只用纯贪心策略去玩游戏,不加入任何随机探索:def evaluate(agent, episodes=20, seed=0): env = gym.make("CartPole-v1") total = 0.0 for i in range(episodes): state, _ = env.reset(seed=seed + i) done = False while not done: action = agent.choose_action(state, epsilon=0.0) state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated total += reward env.close() return total / episodes评估时把epsilon固定为 0,Agent 始终选择最大 Q 值对应的动作。这样得到的平均奖励比训练日志中的数值更真实地反映策略水平。7. DQN 常见问题与排查思路DQN 在入门阶段最常见的挫折不是看不懂代码,而是“代码能跑,但奖励曲线死活不上涨”。下面整理了一份排查表,建议收藏备用。问题现象可能原因排查方式解决方案训练曲线一直贴地,reward 极低epsilon 衰减太快,探索不足打印 epsilon 变化,观察后期是否已小于 0.05调大 epsilon_decay,如 0.998reward 上不去,且震荡剧烈学习率过大或目标网络同步过快观察 loss 是否忽大忽小降低学习率到 1e-4,调大 target_update_freqLoss 变成 NaN输入或者梯度爆炸检查 reward 和观测是否有异常值加入梯度裁剪,对观测做归一化训练前期 reward 能到 500,后期反而下降过拟合或 epsilon 过小导致探索消失绘制最近 50 轮平均曲线引入更大的经验池,或调小学习率目标网络同步后性能明显变差目标网络同步不频繁检查 total_steps 是否正常累加适当调小 target_update_freq训练非常慢每个 step 都执行 update,但缓冲区还没满打印 len(memory)正常现象,缓冲区满之前不更新即可在这些问题里,最常见的原因是“经验池里还没有足够多样本就开始更新”,以及“epsilon 衰减太快导致后期一直在利用”。需要记住一个基本原则:DQN 是离线策略算法,它的样本来自过去任意时刻的策略。经验池里的样本多样性才是训练稳定的基础。另外,如果你发现代码能跑但是所有动作都选同一个,基本可以确定 Q 网络没有学到有效信息。这时候先不要调网络,把奖励曲线和 epsilon 曲线画出来,看看到底是探索不够还是 TD 目标有问题。8. DQN 最佳实践与工程建议跑通一个 CartPole 示例只是第一步。如果把 DQN 用在实际项目中,下面这些工程经验值得提前了解。8.1 超参数设置的基本盘DQN 对超参数比较敏感,下面是一组在 CartPole 上常用的默认值,可作为起点:参数推荐值说明学习率 lr1e-3 到 1e-4过大容易震荡,过小收敛慢折扣因子 gamma0.99任务越长,gamma 越接近 1batch_size32 到 256影响梯度估计的稳定性经验池容量10000 到 100000与环境复杂度和采样成本成正比target_update_freq50 到 200更新越快,目标变化越快epsilon_decay0.99 到 0.998需要保证中期还有一定探索隐藏层宽度64 到 256CartPole 128 足够,复杂任务需要更大不要同时调多个超参数。每次只改一个,对比训练曲线,才能判断哪个因素真正影响效果。8.2 输入归一化CartPole 的四个观测维度数值范围不同,小车位置在-4.8到4.8之间,角度在约-0.42到0.42之间,角速度范围更大。如果不归一化,网络训练初期容易被数值范围大的维度主导。使用环境的observation_space.high和observation_space.low做归一化是一个稳健的工程习惯。8.3 梯度裁剪即使有经验回放和目标网络,遇到极端奖励或离群样本时,梯度依然可能爆炸。在每次loss.backward()之后加一行梯度裁剪是成本最低的稳定手段:torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm=1.0)这一行代码对于复杂环境尤其重要,建议在写 DQN 时直接加上。8.4 模型保存与加载训练完成后,需要把策略网络保存下来,方便后续评估或在真实环境中部署:torch.save(agent.policy_net.state_dict(), "dqn_cartpole.pth")需要继续训练时,可以直接加载权重再跑:agent.policy_net.load_state_dict(torch.load("dqn_cartpole.pth")) agent.target_net.load_state_dict(agent.policy_net.state_dict())注意加载模型后,目标网络也要同步一次,否则目标网络和策略网络的初始参数不一致,训练一开始就会不稳定。8.5 固定随机种子强化学习训练本身有大量随机性,环境、经验采样、网络初始化都会影响结果。为了方便对比实验,固定随机种子几乎是必须的:import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42)在gym.make时,也可以给环境指定 seed。这样出现问题时,可以精确复现。8.6 评估优先于训练建议在训练循环之外单独维护一个评估函数,每隔固定轮数执行一次纯贪心评估。训练时的高 reward 可能是因为 epsilon 较高导致的偶然结果,评估曲线才是真实策略水平的体现。把评估曲线和训练曲线画在一张图上,能帮你更早发现问题。9. DQN 的局限与进一步学习方向到此为止,你已经理解了 DQN 的核心机制,也亲手实现并运行了一个完整示例。但入门的终点,恰恰是深入理解强化学习的起点。DQN 本身有几个非常明显的局限,知道这些局限,也就知道下一步该学什么。第一个局限是过估计问题。DQN 在计算 TD 目标时使用max Q(s', a'),而最大值操作会引入正向偏差。即使真实的 Q 值相同,噪声也会让最大值偏高。这会导致 Agent 对某些动作产生过高的估计。解决方案是 Double DQN:用策略网络选择最优动作,再用目标网络计算该动作的 Q 值,从而降低过估计。第二个局限是样本效率低。虽然经验回放提升了样本利用率,但 DQN 本质上还是需要大量交互。在真实机器人、自动驾驶这类采样成本高的场景里,样本效率直接决定了项目可行性。后续的 Prioritized Experience Replay 通过按 TD 误差大小加权采样,让更“意外”的样本得到更多训练机会,这是提升样本效率的有效方向。第三个局限是只能处理离散动作。DQN 输出的是一组动作的 Q 值,天然适合离散动作空间。但很多真实问题,如机器人控制、无人机飞行,动作本身是连续值。解决连续控制问题的主流算法是 Actor-Critic 家族的 DDPG、TD3、SAC,以及基于策略梯度的 PPO。学习路径上,建议先吃透 DQN 的价值函数逼近思路,再过渡到 Actor-Critic 架构。如果按学习进度规划,你的下一步可以是:实现 Double DQN,在 CartPole 上对比它与标准 DQN 的差异;加入 Prioritized Experience Replay,观察训练速度的变化;换一个更复杂的环境,比如 LunarLander-v2,测试代码的通用性;学习策略梯度方法,理解价值学习和策略学习两条路线的差别。真正理解 DQN,不在于背下三个机制的名字,而在于当你面对一条不怎么上涨的训练曲线时,能快速判断出问题出在经验采样、目标计算、还是探索策略。这也是从“会跑代码”走向“会调试算法”的关键一步。建议你先把今天的 CartPole 示例跑熟,再动手修改一个超参数,观察它对收敛速度的影响。有了这个基础,后续的强化学习进阶之路会顺畅很多。