1. 为什么深度学习能在决策问题上发力从Q-Learning到DQN的认知跃迁先纠正一个常见的误区DQN不是用神经网络替换Q表这么简单。如果你只把它理解成查表方式的升级版后面遇到的收敛困难、训练震荡、奖励炸掉这些问题你会完全摸不着头脑。在经典强化学习中Q-Learning用一张表格存储每个状态-动作对的价值估计。表格的横轴是状态纵轴是动作格子里面填的是Q值。这套机制在状态空间小、动作空间离散的游戏里很好用——比如十几年前大家拿Q-Learning玩2D走迷宫几百个状态一张表轻松搞定。但问题在于真实世界的状态空间是连续且高维的。拿Atari游戏举例一帧画面是210x160像素每个像素有128种颜色取值理论上可能的状态数是128的33600次方——这个数字比可观测宇宙的原子总数还要大好几个数量级。你不可能枚举所有状态更不可能在表格里给每个状态分配一行。就算你有100亿TB的内存也无法在一局游戏结束前把表填完。DQN的核心思想是用深度神经网络作为函数逼近器把状态作为输入输出每个动作的Q值估计。神经网络的参数是有限的比如几百万个权重但它能通过大量样本的学习在巨大的状态空间中泛化——相似的画面输入会得到相似的Q值输出。这就像人脑不是记住每一帧画面的具体像素而是学会看到类似情境时做出类似判断。但用神经网络做Q-Learning有一个致命问题不稳定。原因有三个层面。第一个层面是样本之间的强相关性。强化学习生成的数据不是独立同分布的——一局游戏里相邻几帧的画面高度相似如果按顺序训练网络会反复学习同一个小片段导致灾难性遗忘——学到新东西就忘掉旧东西。第二个层面是目标值本身在移动。Q-Learning的更新公式是Q(s,a) ← Q(s,a) α * [r γ * max(a) Q(s,a) - Q(s,a)]注意看右边目标值r γ * max(a) Q(s,a)里面也包含Q函数本身。当网络权重更新后Q值变了目标值也跟着变——你在追一个会跑的靶子训练过程自然像布朗运动一样混乱。第三个层面是样本分布随策略变化。策略一变采集到的转移样本分布就变了这会导致训练数据本身也在漂移。DQN的论文Mnih et al., 2015, Nature提出了两个关键机制来解决这些问题经验回放Experience Replay和目标网络Target Network。前者打破样本相关性后者让目标值在一个时间段内保持稳定。这两者缺一不可。理解了这一层你再看DQN的代码看到的就不再是一堆矩阵运算而是如何在非平稳目标下稳定地做函数逼近这一核心矛盾的三套解法。下面我会从零开始完整拆解实现过程。2. 从零搭建一个可运行的DQN环境、网络与训练主循环2.1 环境选型为什么用CartPole而不是Atari起步强烈建议第一次实现DQN不要直接上Atari。Atari需要帧堆叠、灰度化、裁剪、跳帧等十几步预处理像素输入动辄210x160x4的维度对显存和训练时间的要求很高。你很难判断训练不收敛到底是算法bug、超参问题还是预处理不对。CartPole是这个领域的Hello World状态是4维连续向量小车位置、速度、杆子角度、角速度动作空间是2个离散动作向左推、向右推。你不需要做任何视觉预处理环境直接给你状态向量网络输入层只要4个神经元就能开始训练。用OpenAI Gym创建环境import gym env gym.make(CartPole-v1) state_shape env.observation_space.shape[0] # 4 n_actions env.action_space.n # 2CartPole-v1的奖励设计是每存活一个时间步给1终止条件是杆子倾斜超过15度、小车滑出边界、或者达到500步上限。目标就是让杆子尽可能长时间保持直立——训练得好的DQN通常几百回合后就能稳定达到500分上限。注意CartPole-v1的终止分两种情况——杆子倒了失败终止和达到500步成功终止。实现时建议区分这两种状态因为达到500步时环境返回的terminated为True但这是一次成功的结束不应给予惩罚。2.2 网络结构到底该用什么激活函数输出层为什么不用softmaxDQN的网络结构设计有一个关键决策点输出层不用softmax。很多人第一次写的时候下意识会对输出加softmax因为分类问题做多了。但Q值的语义是每个动作的期望回报它们是独立的标量估计不是概率分布。你要的是每个动作的Q值高低排序而不是归一化成和为1的概率。网络结构我建议这样搭import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super(DQN, self).__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)两个隐含层每层128个神经元对CartPole来说绰绰有余。如果你想用更宽的网络比如256训练会更快收敛但会增加过拟合风险——CartPole这种简单环境用128已经够了。激活函数选ReLU而不是tanh或sigmoid。原因是深层网络中sigmoid的梯度在饱和区会趋近于0导致梯度消失ReLU在正区间梯度恒为1训练更稳定。一个容易忽略的细节是如果网络输出层的Q值经常出现很大的正数或负数建议检查ReLU是否导致某个神经元死亡——就是权重更新后输入一直为负ReLU输出恒为0该神经元再也不会被激活。这在实际训练中比想象中常见。初始化方式用PyTorch默认的Kaiming初始化即可不需要手动改。2.3 经验回放缓冲区为什么要随机采样容量怎么定经验回放的核心逻辑很简单把每一步的转移s, a, r, s, done存到一个固定大小的缓冲区里训练时从中随机采样一个小批量来更新网络。from collections import deque import random import numpy as np class ReplayBuffer: def __init__(self, capacity10000): self.buffer deque(maxlencapacity) 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, actions, rewards, next_states, dones zip(*batch) return (np.array(states), np.array(actions), np.array(rewards, dtypenp.float32), np.array(next_states), np.array(dones)) def __len__(self): return len(self.buffer)随机采样的意义在于切断相邻样本的时间相关性。如果按时间顺序训练网络会被连续相似的样本反复轰炸——比如小车一直在往左走状态和动作都高度相似每一批更新都在同一个方向推梯度网络很容易过拟合到当前状态附近忘记其他状态该怎么办。随机采样让每一批训练数据中的状态分布尽可能接近真实的状态空间分布相当于把一整局游戏的经验打散了重新组合。缓冲区容量在CartPole不用太大10000足够。如果设得太小比如1000缓冲区很快被新样本覆盖采样的多样性不够如果太大比如1e6老样本在目标网络更新后的过期问题会更严重——早些时候的经验对应的Q值估计已经不准了但还在被反复采样。提示CartPole的每局最多500步10000的缓冲区约等于能装20局完整经验足够覆盖多样状态了。2.4 目标网络两个网络交替更新的具体节奏目标网络的作用我在前面提过——让TD目标值在一段时间内保持固定。实现上就是维护两个结构完全相同的网络一个叫policy_net在线网络也叫行为网络一个叫target_net目标网络。policy_net DQN(state_shape, n_actions) target_net DQN(state_shape, n_actions) target_net.load_state_dict(policy_net.state_dict()) target_net.eval() # 目标网络不参与梯度更新训练过程中只有policy_net的权重会被优化器更新target_net每C步做一次硬拷贝——把policy_net的权重原样复制过去。这就是软更新与硬更新之分。经典DQNNature版本用的是硬更新每C步同步一次。C值的选择有个权衡C太小目标网络跟得太紧会跑的靶子问题没解决C太大目标值长期不更新又会导致策略学习滞后于实际Q值的变化。CartPole上C100比较合适也就是每100步环境交互同步一次。if step_count % target_update_freq 0: target_net.load_state_dict(policy_net.state_dict())补充说明后来很多实现改用软更新Polyak averaging——每次更新时让target_net的参数朝policy_net方向移动一小步tau 0.005 for target_param, policy_param in zip(target_net.parameters(), policy_net.parameters()): target_param.data.copy_(tau * policy_param.data (1.0 - tau) * target_param.data)软更新的好处是目标值变化是连续平滑的不会出现硬拷贝时跳变。在CartPole上两种方式都能收敛但如果你后面要移植到更复杂的任务软更新通常更稳。2.5 ε-greedy探索策略探索率怎么衰减、最小下限设多少DQN不是纯粹的利用策略——它需要平衡探索尝试新动作和利用选已知最优动作。标准做法是ε-greedy以概率ε随机选动作以概率1-ε选当前Q值最大的动作。def select_action(state, policy_net, epsilon, n_actions): if random.random() epsilon: return random.randrange(n_actions) else: with torch.no_grad(): state_tensor torch.FloatTensor(state).unsqueeze(0) q_values policy_net(state_tensor) return q_values.argmax().item()注意with torch.no_grad()——推理时不计算梯度节省显存和计算开销而且避免误把推理时的计算图累积到训练阶段。ε的衰减策略是关键超参。我见过很多人把ε设置为固定值0.1结果训练怎么也不收敛因为前期探索太少根本没见过足够的失败样本来学习。正确的做法是从高探索率开始随着训练推进逐步衰减epsilon_start 1.0 epsilon_end 0.01 epsilon_decay 0.995 # 每一步乘以这个系数 epsilon max(epsilon_end, epsilon_start * (epsilon_decay ** step_count))具体来说每一步而不是每回合乘以0.995到大约900步时ε降到0.01附近。前期高探索让agent充分试错后期低探索让策略更好地利用学到的知识。衰减速率要根据任务总步数调整如果训练总步数只有50000.995的衰减太慢了如果总步数几十万0.995刚好。CartPole上我试过的最优组合是ε从1.0开始衰减到0.01下限步数大概在几万步内完成衰减。你可以在训练曲线上看到明显的阶段性前几百回合探索为主分数波动大中后期探索率下降平均分数稳步上升。3. 训练主循环TD误差、损失函数与反向传播的完整链条3.1 一整个训练回合怎么串起来交互、存储、采样、更新训练主循环把前面所有组件串联起来。我把核心骨架写出来import torch.optim as optim batch_size 128 gamma 0.99 lr 1e-3 buffer_capacity 10000 target_update_freq 100 memory ReplayBuffer(buffer_capacity) optimizer optim.Adam(policy_net.parameters(), lrlr) state env.reset() total_rewards [] episode_reward 0 step_count 0 for episode in range(500): state env.reset() episode_reward 0 done False while not done: epsilon max(epsilon_end, epsilon_start * (epsilon_decay ** step_count)) action select_action(state, policy_net, epsilon, n_actions) next_state, reward, terminated, truncated, info env.step(action) done terminated or truncated # 存储经验 memory.push(state, action, reward, next_state, done) state next_state episode_reward reward step_count 1 # 经验足够时开始训练 if len(memory) batch_size: optimize_step() # 同步目标网络 if step_count % target_update_freq 0: target_net.load_state_dict(policy_net.state_dict()) total_rewards.append(episode_reward) if episode % 50 0: avg_reward np.mean(total_rewards[-50:]) print(fEpisode {episode}, Avg Reward: {avg_reward:.1f}, Epsilon: {epsilon:.3f})有几个细节值得注意。第一terminated和truncated要区分。Gym新API中env.step()返回5个值其中terminated表示回合因失败结束杆子倒了truncated表示因步数上限等环境限制结束。二者在实践中都要被视为done但如果你后续要加奖励塑造比如失败惩罚就要单独看terminated。第二before任何一次更新先保证缓冲区里有足够的样本。有个很容易犯的错误是刚开局缓冲区里只有几十条经验就开始训练随机采样出来的batch里大量重复样本梯度方向几乎无意义。我在上面的代码里用if len(memory) batch_size做了保护——同样可以改成if len(memory) 1000让前几百步纯收集经验不训练。3.2 损失函数不是普通MSE是带停止梯度的TD误差核心的optimize_step()函数是整篇文章的重头戏def optimize_step(): if len(memory) batch_size: return states, actions, rewards, next_states, dones memory.sample(batch_size) states torch.FloatTensor(states) actions torch.LongTensor(actions).unsqueeze(1) rewards torch.FloatTensor(rewards).unsqueeze(1) next_states torch.FloatTensor(next_states) dones torch.FloatTensor(dones).unsqueeze(1) # 当前状态的Q值选取实际执行动作对应的Q值 q_values policy_net(states).gather(1, actions) # 目标值r γ * max(a) Q_target(s, a)终止状态没有后续回报 with torch.no_grad(): next_q_values target_net(next_states).max(1, keepdimTrue)[0] target_q_values rewards (1 - dones) * gamma * next_q_values # 损失预测Q值与目标Q值的均方误差 loss nn.MSELoss()(q_values, target_q_values) optimizer.zero_grad() loss.backward() optimizer.step()这一小节的核心逻辑在三个地方。第一gather(1, actions)的用法。policy_net(states)输出的是[batch_size, n_actions]的Q值矩阵actions是[batch_size, 1]的索引矩阵gather的作用是取出每行中动作索引对应的Q值——也就是在当前状态下我实际执行的那个动作网络估计的Q值是多少。第二torch.no_grad()的位置极其重要。我们计算目标值时用了target_net但目标网络不需要梯度。如果忘了加no_grad()梯度会从两个网络同时反向传播torch会报错或者导致计算图异常训练直接崩溃。这是一个所有DQN新手都会踩的坑写成习惯所有计算目标值的操作一律放到no_grad()里面。第三(1 - dones)的作用。如果s是终止状态杆子已经倒了后面没有未来回报了TD目标就只是r本身如果不是终止状态则加上折扣后的未来回报。你可能会问reward本身就是1终止和非终止看起来一样但一旦你之后改了奖励函数比如失败惩罚-10dones遮蔽就变得关键。养成这个习惯代码才能迁移到更复杂的任务。损失函数用的是MSELoss()也就是TD误差的平方L E[(Q_target - Q_policy)²]这是一个标准的回归损失。有些实现会改用Huber Loss即smooth L1 loss它对离群值的敏感度更低训练会更稳定。CartPole上用MSE和Huber差别不大但我建议直接用Huberloss nn.SmoothL1Loss()(q_values, target_q_values)原因是Q值估计在训练初期会非常离谱模型彻底崩溃会导致某个样本的TD误差巨大MSE会把梯度放大到灾难性的程度Huber对于大误差的梯度是常数不会把模型炸掉。3.3 折扣因子γ为什么不是1为什么规模增大时要调高折扣因子γ决定了agent看得多远。γ0时agent只关心眼前一步的即时奖励γ1时agent对所有未来回报一视同仁完全不折扣。CartPole环境里每个时间步都是1没有终点奖励γ设成0.99意味着agent会把未来几十步的回报折进当前决策。如果γ设得太小比如0.5agent就会变得短视——它更在乎眼前能不能存活但问题在于CartPole的所有步骤奖励相同短视的策略会导致它没有动力去维持一个长期稳的状态。实际上你可以做一个实验把γ改成0.9你会发现CartPole也能收敛但收敛速度变慢改成0.99训练曲线更平滑。如果你后续迁移到奖励稀疏的任务比如只有终点才有1的奖励γ必须接近1比如0.99或0.999否则agent永远学不会为了远期目标放弃眼前小利。3.4 优化器与学习率Adam为什么是默认选项优化器选Adam而不是SGD核心原因是Adam为每个参数自适应地调整学习率对超参不那么敏感。DQN的状态-动作值函数本身是个非平稳的回归任务目标值一直在变SGD需要精细地调整学习率而Adam的默认学习率1e-3在大多数DQN任务上都够用。我实际测试下来CartPole上学习率在5e-4到3e-3之间都能收敛超出这个范围就会出问题——学习率太大1e-2训练震荡剧烈学习率太小1e-4收敛缓慢到几乎不可用。推荐初始值1e-3。一个小技巧可以给优化器加梯度裁剪grad clipping防止偶发的大梯度破坏已有权重# 在optimizer.step()之前 torch.nn.utils.clip_grad_norm_(policy_net.parameters(), max_norm10)这个操作把梯度的L2范数裁剪到10以内。早期实现里我发现不裁剪时偶尔会在某些episode丢分严重裁剪后曲线稳定性明显提升。4. 跑通之后必看的性能诊断损失曲线、奖励曲线与假收敛4.1 训练曲线到底应该长什么样训练完成后第一件事不是看最终分数而是画两条曲线每个episode的总奖励曲线和平均loss曲线。CartPole上正常的奖励曲线有这样的趋势第0~50个episode奖励值很低10~30分因为ε接近1agent基本在随机动作。曲线毛刺很大这是正常的。第50~200个episode奖励快速上升ε衰减到0.1左右agent开始利用学到的Q值有时候能冲到300分甚至500分。第200个episode奖励稳定在高位接近500说明策略已经收敛。loss曲线的走势要复杂一些。很多人误以为loss应该严格下降——其实不对。因为Q值在学习过程中不断更新target也在移动loss可能在前期快速上升因为Q值刚学会一些东西target就变了后期逐渐稳定在小范围内波动。真正需要警惕的是loss发散——每一轮都变得更大且没有回落趋势这通常意味着学习率太大、梯度爆炸或者目标网络同步频率太快/太慢。4.2 一个隐蔽的bug为什么看起来收敛实际却在振荡我在调试DQN时遇到过一种情况episode奖励曲线明明已经到了450但隔几十个episode突然掉回200以下然后又慢慢涨回450。反复振荡。排查后发现原因是目标网络同步频率和buffer容量的匹配问题。当target_net每100步同步一次而buffer容量只有5000时5000步内积累了约50次同步如果每步都做更新的话。这意味着相邻两次采样中的目标值可能已经发生了很大的变化——网络刚适应了上一个目标网络的打分标准下一个标准又来了导致策略在多个版本的标准之间震荡。解决方案有两个一是增大同步间隔从100增大到500或1000二是改用软更新τ0.005让目标值平滑变化。实测下来软更新的稳定性更好。4.3 验证模型的正确姿势关掉探索、连续跑多个回合求平均很多人用select_action里的ε-greedy策略直接跑测试这是错误的。ε-greedy在推理时引入随机性你没法判断差的结果到底是模型学得不好还是运气不好。正确姿势是def evaluate(policy_net, env, n_episodes10): total_rewards [] for _ in range(n_episodes): state env.reset() episode_reward 0 done False while not done: with torch.no_grad(): state_tensor torch.FloatTensor(state).unsqueeze(0) action policy_net(state_tensor).argmax().item() state, reward, terminated, truncated, _ env.step(action) done terminated or truncated episode_reward reward total_rewards.append(episode_reward) return np.mean(total_rewards)关键点测试时直接用argmax()取最大Q值对应的动作完全不用ε。跑10个回合取平均如果平均奖励在480以上说明模型真的学到了让杆子一直保持直立的策略。还有一个经验法则保存最佳模型而不是最后模型。训练过程中如果eval分数持续走高就保存一份best_model.pt如果发现训练后期出现振荡用最后的模型反而不如上个巅峰时期的模型。一段简单的保存逻辑if avg_reward best_reward: best_reward avg_reward torch.save(policy_net.state_dict(), dqn_best.pt)5. 深入DQN的变体Double DQN、Dueling DQN与未来扩展的思考路径5.1 Double DQN解决的是过度乐观估计问题经典DQN在计算目标值时用了max操作target r γ * max(a) Q_target(s, a)max天生会高估Q值——因为即使某个动作的真实Q值很低只要估计存在向上误差这在小批量随机采样中不可避免max就会把它当成最优动作。长期叠加后Q值估计会系统性偏高导致策略选择次优动作。Double DQN的想法很巧妙用在线网络选动作用目标网络计算该动作的Q值。# Double DQN的目标值计算 with torch.no_grad(): next_actions policy_net(next_states).argmax(1, keepdimTrue) # 在线网络选动作 next_q_values target_net(next_states).gather(1, next_actions) # 目标网络评估 target_q_values rewards (1 - dones) * gamma * next_q_values为什么这样能缓解高估因为Q_target毕竟是另一个网络的输出即便它对某个动作的估计偏高在线网络对哪个动作最好的判断不一定和它一致——两个网络的误差相互抵消了一部分高估程度显著降低。实现上只改了3行代码不需要修改网络结构非常划算。在CartPole这种简单任务上Double DQN的优势不明显经典DQN已经能打到500满分但在更复杂的任务上过度乐观估计会导致策略提前陷入次优——这一点千万记住。5.2 Dueling DQN把Q值拆成状态价值和动作优势Dueling DQN的网络结构将Q值分解为两部分Q(s,a) V(s) A(s,a)其中V(s)是状态价值函数在当前状态下平均能拿到多少回报A(s,a)是优势函数这个动作比平均水平好多少。网络先通过共享的卷积/全连接层提取特征然后分成两个分支一个输出V(s)一个输出A(s,a)最后合并得到Q值。这里有个工程细节如果直接让Q V A对于给定的(s,a)V和A的组合方式不是唯一的——你可以把V提高10、A降低10得到相同的Q。为了消除这种不可辨识性实际做法是让优势分支减去其均值class DuelingDQN(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super(DuelingDQN, self).__init__() self.feature nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU() ) self.value_stream nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) self.advantage_stream nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): features self.feature(x) value self.value_stream(features) advantage self.advantage_stream(features) q_values value advantage - advantage.mean(dim1, keepdimTrue) return q_values减去均值的好处是让V分支学会全局状态好坏让A分支学会动作的相对优劣两个分支分工明确训练更高效。在CartPole上Dueling结构的优势不明显但如果你做视觉输入的任务比如Atari它的优势就很突出了。5.3 从CartPole到复杂任务你真正需要改的4件事当你把CartPole跑通之后下一步通常是想处理更复杂的问题。从我的经验看有四件事要改状态输入的预处理如果是像素输入需要灰度化、缩放到84x84、堆叠最近4帧捕捉运动信息。OpenAI的Baselines已经把这一套封装得很好可以直接参考。如果状态是连续向量一般不需要太多预处理但建议做归一化——把每个维度的值缩放到[-1, 1]或[0, 1]范围。奖励缩放Reward Clipping很多DQN实现会直接把奖励裁剪到[-1, 1]防止奖励值域差异过大导致梯度爆炸。这相当于告诉网络所有奖励只有三档负面、中性、正面虽然丢失了一些精细信息但对稳定性帮助很大。批量大小和网络容量CartPole用128的隐含层和128的batch size到了Atari图像任务网络通常要加深到卷积层三个Conv层两个全连接层batch size提到32因为图像样本计算量更大batch太大容易显存溢出。更长的训练步数CartPole一两万步就能见效Atari游戏通常需要数百万甚至上千万步也就是几十小时的GPU训练。这也是为什么推荐先跑通CartPole再上复杂任务——调试周期从分钟级拉长到小时级之后错误百出的代码会让你痛不欲生。如果你要大规模接入强化学习任务推荐用现成框架如Stable-Baselines3——它封装了DQN、PPO、SAC等主流算法并且经过了大量社区验证。但强烈不建议一上来就调库因为框架里的超参和接口细节会掩盖算法的本质你自己从零手写一遍之后再用框架才会真正理解每个参数的物理意义。6. 踩过的坑合集超参不敏感的所有例外情况与处理心得6.1 奖励一直为0怎么办检查done标志和数据类型训练过程中如果发现模型完全没有学习迹象奖励始终在最低水平徘徊第一件事不是调超参而是检查dones标志。我在早期实现中犯过一个经典错误从Gym获取的terminated和truncated都是bool类型但存入buffer后取出来的是Python的bool我在构造tensor时用了torch.FloatTensor(dones)——如果是bool类型会自动转为0.0和1.0逻辑上没问题。但如果你不小心用了torch.LongTensor(dones)并且计算(1 - dones)这里一切正常但如果误用torch.BoolTensor(dones)直接乘gamma * next_q_values会保留为bool的张量参与运算那结果就全乱了。建议的做法在存储时就把done转换为float存进去避免后续类型混乱memory.push(state, action, reward, next_state, float(done))奖励为0还有一个常见原因环境返回的reward是float但你在累加reward时用了整数除法或截断——这看起来像小问题但在调试时很折磨人。6.2 训练曲线前期暴涨后期暴跌同步频率与衰减速率不匹配我在前面讲过目标网络同步频率和buffer容量匹配的问题。这里再补一个案例我把epsilon_decay设成0.99每episode衰减而不是每步衰减导致前100个episode内ε几乎没怎么降agent一直在随机探索学不到东西后面ε突然降下来agent还没来得及积累有效经验就开始利用奖励曲线像过山车一样暴涨暴跌。正确的思路是探索衰减的时间尺度要和经验积累匹配。CartPole每episode大约几十到几百步如果你按episode做衰减衰减速率要更激进比如0.95如果按step做衰减0.995是合理起点。两种方式都能跑通但不能混用epsilon_start * (epsilon_decay ** step_count)里step_count是全局步数如果你把衰减系数设为0.99但步数到了10000才衰减到0.00004那后期几乎不探索任务一旦变化比如环境随机性大agent就困在次优策略里。6.3 从CartPole迁移到其他环境时必改的三处胶水代码即便只改环境DQN跑崩的概率也极高。我列出三个最容易忽略的地方。第一动作空间不一定是离散从0开始。有些环境动作编号不连续比如{0, 2}有两个动作但中间没有1如果用argmax取出来的索引直接env.step(action)可能取到不存在的动作。稳妥做法是建立一个action_list list(range(env.action_space.n))把所有合法动作显式列出来。第二状态空间的维度范围差异巨大。CartPole的4维状态每个维度的量纲都不同位置在[-2.4, 2.4]角度在[-0.2, 0.2]神经网络的初始权重对每个维度做一样的处理会导致某些维度主导梯度。这在CartPole上问题不大ReLU的非线性帮了忙但换到连续控制环境比如MountainCar或Pendulum就很容易出问题。建议在每个episode开始前对状态做归一化obs_min np.array([-2.4, -inf, -0.2, -inf]) obs_max np.array([2.4, inf, 0.2, inf]) normalized_state (state - obs_min) / (obs_max - obs_min 1e-8)第三回合长度的上限。CartPole-v1是500步截断但其他环境的上限可能不同。如果你的while not done循环没有设置最大步数保护碰到MountainCar这种可以无限跑的环境一个episode能跑几千上万步导致训练进度统计严重失准。建议在训练主循环中加入步数上限max_steps_per_episode 500 steps 0 while not done and steps max_steps_per_episode: # ... 交互逻辑 ... steps 16.4 判断DQN是否学对了一个直观的行为白盒测试最后分享一个我常用的白盒验证方法——在模型训练好后手动设定一些特殊状态观察网络输出的Q值是否符合直觉。比如CartPole杆子向左倾斜角度为负时Q值中向右推的动作应该比向左推更大小车位置偏右时动作向左推的Q值应该更大。你可以在评估代码里加一段test_states [ np.array([0.0, 0.0, -0.2, 0.0]), # 杆子左倾 np.array([0.0, 0.0, 0.2, 0.0]), # 杆子右倾 np.array([2.0, 0.0, 0.0, 0.0]), # 小车偏右 ] for s in test_states: with torch.no_grad(): q policy_net(torch.FloatTensor(s).unsqueeze(0)) print(fState {s}: Q values {q.numpy()}, best action {q.argmax().item()})如果输出的最优动作和物理直觉一致左倾推荐右推、偏右推荐左推说明模型学到的是有意义的策略不是单纯靠随机运气在得分。如果行为和直觉完全相反那大概率是你的奖励函数或状态表示有问题而不是DQN本身的问题。这个测试虽然简单但在后续把DQN迁移到更复杂任务时非常有用——它能帮你把模型学到了什么变得可解释、可调试而不是一遇到新环境就两眼一抹黑地猛调超参。