从Q-Learning到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 的核心操作只有两件事:

  1. 初始化一张 Q 表,表中每个格子 Q(s,a) 代表“在状态 s 下执行动作 a 的长期回报期望”。
  2. 每采集一条经验 (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 函数。这样有三个好处:

  1. 不需要手动离散化状态,连续状态直接进网络。
  2. 网络参数是共享的,相似的状态会被分到相近的 Q 值,天然具备泛化能力。
  3. 训练变成了常见的梯度下降问题,可以复用深度学习生态里成熟的优化器、正则化手段。

但直接把 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.txt

3. 核心算法原理拆解

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-LearningDQN
Q 值存储方式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.py

4.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太小,ε 衰减太慢,智能体会一直随机乱走,后期仍然浪费大量探索;如果衰减太快,训练还没积累足够样本就进入纯贪婪模式,策略容易困在局部。

排查步骤:

  1. 打印每个 episode 的 ε 值,确认它是否在合理区间递减。
  2. 打印回放缓冲区中的样本数量,确认len(buffer)是否一直小于batch_size,导致网络根本没怎么训练。
  3. 观察前 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 应越接近 1
epsilon_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 把问题转化成了标准的“监督回归 + 经验复用”,让深度强化学习真正具备落地能力。

接下来建议按顺序做三件事:

  1. 把本文的train_dqn.py换个环境跑一遍,比如LunarLander-v2,体会动作空间更大时 DQN 的表现。
  2. 花半小时给 DQN 加 Double DQN 和 Dueling 结构,观察 Q 值高估现象是否缓解。
  3. 如果想继续深入,下一步就是策略梯度方向:从 REINFORCE 开始理解“直接优化策略”的思路,再过渡到 PPO、SAC 等现代主流算法。

DQN 是深度强化学习里承上启下的关键一环:它承接了表格方法的全部理论根基,又开启了深度网络在控制、博弈、调度等场景的大门。建议读者把本文代码亲自动手运行一遍,改几个超参数观察曲线变化,这比只看推导收获要大得多。

相关新闻

ORB-SLAM2跑自定义图片序列:从数据准备到轨迹评估

ORB-SLAM2跑自定义图片序列:从数据准备到轨迹评估

简介&#xff1a;面向视觉SLAM入门者与需要跑通自定义数据集的开发者&#xff0c;这套实操资源围绕ORB SLAM2&#xff0c;解决如何将自拍视频或图像序列整理成系统要求的输入格式&#xff0c;进而完成定位与建图&#xff0c;适用于机器人、无人机或AR场景。整个压缩包共4个文件…

2026/10/20 2:33:33 阅读更多 →
Qoder与Trae对比:AI编程工具的上下文、知识注入与错误修复深度解析

Qoder与Trae对比:AI编程工具的上下文、知识注入与错误修复深度解析

1. 这场“国产AI编程工具对决”根本不是选边站队&#xff0c;而是重新定义你的开发工作流最近在几个技术群和社区里&#xff0c;总能看到类似“Qoder和Trae到底哪个强”的讨论&#xff0c;标题里还带着“巅峰对决”“程序员必看”这种字眼。说实话&#xff0c;我第一次看到时心…

2026/10/20 2:33:33 阅读更多 →
DQN原理与PyTorch实现:从Q-Learning到经验回放+目标网络

DQN原理与PyTorch实现:从Q-Learning到经验回放+目标网络

很多学习强化学习的读者&#xff0c;在接触 DQN 之前&#xff0c;多半已经能用 Q-Learning 在一个小网格、迷宫或者简单的表格环境里跑通一个 Agent。那一刻你会觉得&#xff1a;强化学习好像也没那么复杂&#xff0c;更新公式背下来&#xff0c;迭代几百轮&#xff0c;Q 表收敛…

2026/10/20 2:33:33 阅读更多 →

最新新闻

STM32 bxCAN实战:从物理层到过滤器配置与调试排障

STM32 bxCAN实战:从物理层到过滤器配置与调试排障

做嵌入式这几年&#xff0c;UART、I2C、SPI都是老朋友&#xff0c;但真正让我觉得“像个通信工程师”的&#xff0c;是开始啃STM32的CAN外设之后。原因很简单&#xff1a;在汽车、机器人和工业现场&#xff0c;CAN总线是默认的“老大哥”&#xff0c;而它在微控制器上的硬件实现…

2026/10/20 6:26:33 阅读更多 →
ReST-MCTS精读:用蒙特卡洛树搜索与过程奖励实现LLM自我训练

ReST-MCTS精读:用蒙特卡洛树搜索与过程奖励实现LLM自我训练

这篇是论文精读系列的第九期。选题的时候我特意挑了一个既讲方法、又讲工程的文章&#xff1a;ReST-MCTS&#xff0c;标题直译过来就是“基于过程奖励引导的树搜索做LLM自我训练”。一句话概括它的核心思想&#xff1a;让大模型不要只想着“快答”&#xff0c;而是像下棋一样&a…

2026/10/20 6:26:33 阅读更多 →
实测,ToClaw为什么是最优秀的OpenClaw龙虾:从 ToDesk 远程到 TaoToken 统一 Key 的配置复盘

实测,ToClaw为什么是最优秀的OpenClaw龙虾:从 ToDesk 远程到 TaoToken 统一 Key 的配置复盘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 6:26:33 阅读更多 →
diagram-design:从图形类型到布局配色的技术图设计指南

diagram-design:从图形类型到布局配色的技术图设计指南

1. 从"diagram-design"这个标题说起&#xff1a;它到底在解决什么问题第一次看到"diagram-design"这个标题&#xff0c;我脑子里冒出来的第一个念头是&#xff1a;这大概率不是一个单纯的"画图工具"&#xff0c;而是一套关于"如何把复杂信息…

2026/10/20 6:26:33 阅读更多 →
CTF Misc入门:DOCX隐写与ZIP伪加密的底层原理及010 Editor实操

CTF Misc入门:DOCX隐写与ZIP伪加密的底层原理及010 Editor实操

CTF新手村常客——Misc题型里的DOCX隐写和伪加密&#xff0c;对很多刚入坑的朋友来说&#xff0c;看WriteUp觉得挺简单&#xff0c;真上手时又容易卡壳。我最初玩PolarCTF这类靶场时也是这样&#xff0c;面对一个打不开的docx文件&#xff0c;第一反应是直接改后缀名丢进解压工…

2026/10/20 6:26:33 阅读更多 →
HTTP POST表单提交全解析:Content-Type、编码格式与实战避坑指南

HTTP POST表单提交全解析:Content-Type、编码格式与实战避坑指南

1. 从一个被忽略的细节说起&#xff1a;为什么表单提交远不止填个action很多人第一次接触Web开发&#xff0c;接触到的第一个"能跑起来"的功能&#xff0c;大概率就是一个HTML表单。写一个<form>标签&#xff0c;配上action和method"post"&#xff0…

2026/10/20 6:25:32 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →