公司动态

DQN实战:用Python调通MountainCar稀疏奖励难题

📅 2026/8/27 3:41:21
DQN实战:用Python调通MountainCar稀疏奖励难题
简介强化学习是机器学习中极具挑战性的分支其核心在于智能体通过与环境交互学习最优策略。深度Q网络DQN作为价值函数的深度逼近方法用神经网络替代传统Q表解决了连续状态空间下的泛化问题并通过经验回放和目标网络两大机制稳定训练过程。在实际工程中奖励稀疏和环境反馈延迟常导致算法难以收敛MountainCar就是经典案例——每一步都是负奖励最优策略需要先反向蓄力对智能体的长期信用分配能力要求极高。针对这类问题奖励塑造Reward Shaping通过设计位置差分与里程碑奖励提供有效引导配合合理的探索率衰减与超参数调优能显著加快收敛速度。本文从DQN原理出发结合MountainCar环境完整展示了从环境搭建、网络设计到训练调参的工程实现路径为在稀疏奖励任务中应用深度强化学习提供可复用的实践参考。 我最早是在CartPole上跑通DQN的那时候觉得强化学习也不过如此——网络不大几分钟收敛奖励曲线一路向上。直到我把同一套代码原封不动搬到MountainCar上才知道什么叫“换个环境就是换个世界”。CartPole里每一步都有即时反馈角度偏了奖励还在MountainCar里每一步都是-1小车在谷底来回晃荡训练几千个episode可能还在原地打转。这篇文章就记录我如何用Python实现DQN把MountainCar这个看似简单但实际极其容易翻车的经典强化学习任务调通、训练收敛。我会从环境特性、算法原理、完整代码、踩坑记录到调参经验一步步拆开来讲。不管你是刚接触强化学习还是已经在其他环境上跑过DQN但被MountainCar卡住这篇文章都值得你花十分钟读完。1. MountainCar环境分析它到底难在哪1.1 环境规则与状态空间MountainCar是OpenAI Gym里的经典控制任务后来也被大量强化学习论文用作基准环境。场景很简单一辆动力不足的小车位于一个山谷底部目标是冲上右侧的山顶位置达到0.5。关键限制是——小车的引擎推力远小于重力没法直接爬上去必须先向左行驶利用左侧山坡的势能来回荡几次像荡秋千一样积蓄足够动能最终冲向右坡顶点。环境的状态空间是二维连续值position小车位置范围在[-1.2, 0.6]之间velocity小车速度范围在[-0.07, 0.07]之间动作空间是三个离散动作0向左加速1不施力2向右加速每一步的奖励是-1直到小车位置达到0.5时episode结束。也就是说如果让小车成功到达目标这一步会获得-1的奖励否则继续累积负奖励。智能体的目标是在尽可能少的步数内完成任务步数越少总奖励越高。1.2 稀疏奖励与反直觉策略MountainCar真正的难点不在这点维度而在于两个地方第一奖励信号极其稀疏。整个过程中除了到达终点外没有其他正奖励。每一步都在给-1相当于一个持续流血的负反馈。算法在早期完全无法判断“哪个动作是好的”因为无论如何都在扣分。这与CartPole完全不同——CartPole每保持一秒平衡就有一个正奖励算是即时反馈MountainCar则是典型的延迟奖励问题算法必须学会“牺牲短期不快继续朝着远离目标的方向开来换长期收益最终冲上山坡”。第二最优策略是反直觉的。从画面来看目标在右侧正常人会一直向右加速但这样做永远无法达到目标。只有先向左驶离目标利用左侧陡坡储蓄势能才能最终冲上右侧山顶。这给强化学习算法出了一个很大的难题它必须能够做长期的信用分配——即“向左走”这个动作虽然表面上没有靠近目标但却是成功的关键前置动作。1.3 为什么这个环境适合验证DQN很多人第一反应是只有两个连续状态维度用表格型Q-Learning也够了吧实际上不够。即使状态只有二维要将连续值离散化我们通常需要把位置和速度各自切分成很多段。如果位置分成20格、速度分成20格Q表就是20×20×31200个参数看起来不大。但为了让策略足够精细网格需要更密而且一旦状态维度增加到4维、8维Q表会指数爆炸。这正是DQN存在的意义——用神经网络拟合Q函数解决连续状态空间下的泛化问题。MountainCar是验证DQN的绝佳平台状态空间足够低维训练速度快方便反复调参但稀疏奖励和反直觉策略又让它不像CartPole那样“随便跑跑就能收敛”。换句话说在MountainCar上跑通DQN才算是真正理解了这个算法的核心机制。2. DQN原理拆解从表格Q到深度网络的三个关键工程2.1 神经网络如何替代Q表传统Q-Learning维护一张表格记录每个状态-动作对的Q值。更新公式是Q(s,a) ← Q(s,a) α [r γ max_a Q(s,a) - Q(s,a)]公式中r γ max_a Q(s,a)被称为时序差分目标TD Targetmax_a Q(s,a)表示在下一个状态下选择最大Q值的动作。表格型方法的问题在于它只能记录已经访问过的状态无法泛化到未见过的状态。DQN的做法是用一个神经网络Q(s,a;θ)来近似Q函数。输入是状态向量输出是每个动作的Q值。训练时我们最小化当前Q值和TD目标之间的均方误差L(θ) E[(r γ max_a Q(s,a;θ-) - Q(s,a;θ))²]这里θ-是目标网络的参数第2.3节会详细讲。回归问题的关键就是构造训练样本从经验回放缓冲区中采样一批(s,a,r,s,done)计算TD目标然后让网络输出逼近这个目标。2.2 经验回放打破样本之间的时间相关性强化学习的数据不是独立同分布的。一个episode内相邻时刻的状态高度相关如果直接用当前转移样本更新网络相当于拿一堆互相强相关的数据做梯度下降会导致网络振荡甚至发散。DQN的解决方案是经验回放Experience Replay。每次与环境交互后把(s,a,r,s,done)存入一个固定容量的回放缓冲区。训练时从缓冲区中均匀随机采样一个小批量数据来更新网络。这样做有两个好处打破样本相关性让训练数据尽量独立同分布提高数据利用率一条经验可以被多次采样使用打个比方这就像学生复习错题集不是考完试马上重做错题而是把所有错题收集起来过几天随机抽几道来练习。这样每次练习的知识点更多样复习效果更稳定。2.3 目标网络给Q值一个稳定的“锚点”如果直接用当前网络计算TD目标会出现“自己追自己”的问题——每次参数更新都会同时改变目标值网络被目标牵着鼻子走很难收敛。DQN的做法是维护另一个结构相同但参数滞后的网络称为目标网络Target Network。在每步更新中TD目标用目标网络计算y r γ max_a Q(s,a;θ-)当前网络向这个目标靠近。每隔固定步数将当前网络的参数整个复制给目标网络。这样在一段更新周期内目标是相对稳定的训练过程不易振荡。这个机制的原理有点像跑步时以远处的灯塔为参照物而不是盯着自己脚下的影子跑——目标固定才不容易跑偏。2.4 本次实验的超参数设置根据我的实测经验MountainCar上DQN的一组可靠超参数如下参数设置说明神经网络结构2层全连接每层128输入2维状态输出3个动作Q值激活函数ReLU隐藏层使用输出层不使用优化器Adam自带动量与自适应学习率收敛稳学习率0.001太大容易震荡太小收敛缓慢折扣因子γ0.99重视长期收益批量大小128采样更新所用的经验条数回放缓冲区大小50000存满后新经验覆盖旧经验ε初始值1.0前期完全随机探索ε最小值0.01后期保留少量探索ε衰减速率每步×0.995训练步数约1000步后降到0.01附近目标网络更新频率每500步复制一次参数频率过高导致训练不稳每个episode最大步数500比默认200更合理避免截断干扰有一说一这些参数不是一次试出来的我前前后后至少改了七八版。下面第4章会详细讲我踩过的坑和调参的完整链路。3. Python代码实现从搭建环境到训练闭环3.1 环境初始化与状态空间处理首先安装必要的库。推荐使用GymnasiumGym的维护分支API更规范区分了terminated和truncatedpip install gymnasium torch numpy matplotlib初始化环境时有一个容易被忽略的细节MountainCar默认通过TimeLimit包装器限制200步。200步后环境会返回截断信号这在训练中会造成严重误导。我的做法是自定义环境把步数限制放宽或者去掉import gymnasium as gym from gymnasium.wrappers import TimeLimit # 自定义环境去掉默认200步限制改为500步 env gym.make(MountainCar-v0) env TimeLimit(env, max_episode_steps500)3.2 定义神经网络模型网络的输入是2维状态位置和速度输出是3个动作的Q值。模型非常简单但训练时要注意浮点类型的匹配import torch import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): def __init__(self, state_dim, action_dim): super(DQN, self).__init__() self.fc1 nn.Linear(state_dim, 128) self.fc2 nn.Linear(128, 128) self.fc3 nn.Linear(128, action_dim) def forward(self, x): x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.fc3(x)特别提醒一点状态数据从Gym返回的是numpy.float32数组但PyTorch期望输入是torch.FloatTensor。转换时务必注意数据类型否则会报类型不匹配的错误。3.3 经验回放缓冲区实现回放缓冲区用deque实现最方便。容量设为50000存入最新经验满了自动弹出最旧的from collections import deque import random class ReplayBuffer: def __init__(self, capacity): 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) state, action, reward, next_state, done map(np.stack, zip(*batch)) return (torch.FloatTensor(state), torch.LongTensor(action).unsqueeze(1), torch.FloatTensor(reward).unsqueeze(1), torch.FloatTensor(next_state), torch.FloatTensor(done).unsqueeze(1)) def __len__(self): return len(self.buffer)action需要存成torch.LongTensor因为后续要用.gather()按索引取值。reward和done则用FloatTensor便于后续计算。3.4 ε-greedy探索策略的时间衰减训练前期需要大量探索后期应该更多利用学到的策略。ε-greedy是标准做法def select_action(state, policy_net, epsilon): if random.random() epsilon: return env.action_space.sample() # 随机探索 else: with torch.no_grad(): state_tensor torch.FloatTensor(state).unsqueeze(0) q_values policy_net(state_tensor) return q_values.argmax().item() # 贪婪利用注意计算Q值时要加torch.no_grad()。这里只是做推理选择动作不需要梯度不加反而会浪费内存甚至报错。ε的衰减策略我采用的是“按训练步数衰减每步乘以0.995”。1000步之后ε大约变为0.01差不多完成了从探索到利用的过渡。如果衰减太快agent还没来得及发现“先向左再向右”的策略就已经陷入纯利用状态后续基本不会再有大改进。3.5 训练主循环与损失计算下面是核心的训练循环。逻辑很清晰跑episode存经验采样更新定期复制目标网络def train_dqn(env, policy_net, target_net, optimizer, memory, batch_size128, gamma0.99, target_update500): epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.995 total_steps 0 episode_rewards [] episode_losses [] for episode in range(1000): state, _ env.reset() episode_reward 0 total_loss 0 loss_count 0 for step in range(500): # 每个episode最多500步 action select_action(state, policy_net, epsilon) next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated # 奖励塑造详见3.6节 shaped_reward reward shaping_reward(state, next_state) memory.push(state, action, shaped_reward, next_state, done) state next_state episode_reward reward total_steps 1 # 衰减探索率 epsilon max(epsilon_min, epsilon * epsilon_decay) # 当缓冲区足够大时开始训练 if len(memory) batch_size: batch memory.sample(batch_size) state_batch, action_batch, reward_batch, next_state_batch, done_batch batch # 当前网络评估的Q值 q_values policy_net(state_batch).gather(1, action_batch) # 目标网络计算TD目标 with torch.no_grad(): max_next_q target_net(next_state_batch).max(1, keepdimTrue)[0] target_q reward_batch gamma * max_next_q * (1 - done_batch) # Huber损失在异常值面前更稳定 loss F.smooth_l1_loss(q_values, target_q) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() loss_count 1 # 定期同步目标网络参数 if total_steps % target_update 0: target_net.load_state_dict(policy_net.state_dict()) if done: break episode_rewards.append(episode_reward) if loss_count 0: episode_losses.append(total_loss / loss_count) if episode % 20 0: print(fEpisode {episode}, Reward: {episode_reward}, Epsilon: {epsilon:.3f}) return episode_rewards, episode_losses这里有几个值得强调的点为什么要判断len(memory) batch_size才训练缓冲区里的经验太少时采样出来的批量样本重复率高梯度方向不稳定训练早期的loss会剧烈跳动。等缓冲区积累了一定量的“错题”再开始学习效果更好。为什么用smooth_l1_lossHuber Loss而不是MSEDQN早期训练时TD目标偶尔会出现较大的异常值。Huber Loss在误差小时接近MSE在误差大时退化为线性损失对离群点不敏感训练稳定得多。这是我多次实验对比后确定的。done的处理非常关键。如果一个episode因为到达终点而结束则不再有后续收益max_next_q要乘0。如果只是500步被截断从环境返回的truncatedTrue那么后面其实还有状态转移理论上不该直接置0。这里为了简单统一我将两种情况都视为doneTrue但在第4章会专门讲这样做的隐患。3.6 奖励塑造成分设计原始奖励全部是-1直接训练DQN在MountainCar上会非常缓慢。为了让算法快速获得有效引导我采用了两种奖励塑造手段# 全局变量记录当前episode小车到达的最高位置 best_position -0.4 # 初始位置附近 def shaping_reward(state, next_state): global best_position pos, _ state next_pos, _ next_state reward 0.0 # 1. 位置差分奖励鼓励小车向右移动 reward (next_pos - pos) * 10.0 # 2. 里程碑奖励每次达到新的最高点都给予额外奖励 if next_pos best_position: reward (next_pos - best_position) * 50.0 best_position next_pos return reward第一项“位置差分奖励”让小车每向右移动一点就有正反馈这相当于给了它一个“往右走是对的”的软性引导。第二项“里程碑奖励”解决稀疏性问题——小车每打破一次自己的最高位置记录就能获得一次明显的正奖励。这个设计模仿了人类学习时的“成就反馈”能显著加快探索速度。需要提醒的是奖励塑造不是随便加的不能改变最优策略的本质。我设计的两个分量都只与位置变化有关不会鼓励小车在原地振荡刷分。如果你设计一些不当的稀疏奖励例如在小车到达某个固定位置时给分小车可能学会反复刷该位置而不再尝试冲顶。4. 训练过程中最难跨越的几道坎4.1 训练早期梯度几乎为零奖励全为-1的困境我第一次在MountainCar上跑DQN用的是CartPole那套代码改了环境和网络输入维度就开始训练。结果3000个episode过去小车还是傻傻地在谷底左右横跳奖励曲线毫无起色。我一度怀疑是网络结构的问题把网络从64改成256加了一层Dropout都没有改善。后来我打印出每个episode的奖励和Q值分布发现问题出在奖励信号上。所有step的奖励都是-1即使小车某一次恰好向右坡度靠近它得到的奖励也只比原地不动时好一点点——而这个“好一点点”的信号被埋没在大量-1的噪声里。神经网络的梯度几乎是被负奖励淹没的很难从中提取出“向右移动是有价值的”这个信息。解决方式就是3.6节的奖励塑造。我加上了位置差分奖励和里程碑奖励之后第一个明显的现象是训练到500个episode左右小车已经学会了向左后退蓄力然后在右侧山坡上爬到一定高度。这是非常典型的“从零到一”的突破——奖励塑造帮算法跨越了冷启动阶段。4.2 Q值震荡与损失发散目标网络更新频率怎么看奖励塑造加进去以后训练速度明显加快但新麻烦出现了训练到大约1500个episode时loss开始剧烈震荡甚至时不时出现NaN。我检查了数据和代码没有发现明显的bug最后定位到是目标网络更新太频繁导致的。我最初设置的目标网络复制间隔是100步。在MountainCar这个环境里100步可能只够跑五六个episode的片段目标网络换得比模型还频繁导致TD目标一路漂移Q值越追越偏。把更新间隔从100步调整为500步之后loss曲线明显平滑了许多。如果你也遇到loss持续震荡试着把目标网络更新频率调慢通常会有立竿见影的效果。4.3 探索率衰减过快导致策略固化另一个非常隐蔽的坑是ε的衰减速率。我一开始设置的衰减率是每步0.99想着250步左右就能从1降到0.05前期探索够了。实际上MountainCar里一个episode往往要跑四五百步前几个episode里小车绝大部分时间都在谷底随机晃荡几乎没有任何有效探索。ε衰减过快的结果是车还没摸到过山顶策略就已经固定成了“原地小幅振荡”。正确的做法是拉长探索周期。我最终把衰减率降到0.995大约在训练前期保留足够的随机性。关于ε的设置一个比较实用的判断标准是**观察训练前500个episode里小车是否已经出现过多次向右侧山坡上冲的动作。**如果没有说明探索不足需要降低衰减速度。4.4 截断信号与终止信号混用带来的误导在第3.5节的代码中我把terminated和truncated都用done来标记。但在Gymnasium中这两个信号含义完全不同terminatedTrue小车到达目标位置≥0.5episode真正结束truncatedTrue因步数限制而被强行截断实际上小车还没完成任务如果统一当成done处理TD目标就默认“之后不再有奖励”但实际上被截断时小车还活着、还有继续前进的可能。这会让算法误以为所有被截断的episode都是“终点”学习信号混乱。最稳妥的做法是分开处理if terminated: # 真正到达目标TD目标不计算未来收益 target_q reward_batch elif truncated: # 被截断理论上应该继续往后推但这里为了简化可以视为终止 target_q reward_batch else: target_q reward_batch gamma * max_next_q如果你只想快速跑通把截断也视为终止问题不大但要知道这会导致策略稍微保守——小车倾向于在远离目标时主动停止前进。想要精细调校建议把时间限制放宽到500甚至1000步这样绝大部分episode都是因为terminated才结束的。4.5 种子固定与实验复现强化学习训练本身随机性很大。如果不固定随机种子同样的代码两次运行结果可能差异巨大。为了实验可复现我会在脚本开头统一设置import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) env.reset(seedseed) env.action_space.seed(seed)注意Gymnasium的环境种子设置是通过env.reset(seedseed)传入的而不是在环境创建时传入。另外PyTorch在GPU上的操作本身就不是完全确定的想要完全复现需要在训练中关闭cudnn的benchmark模式并设置torch.backends.cudnn.deterministic True。不过MountainCar这种低维环境用CPU训练就够快完全不必上GPU。5. 实验结果评估与收敛分析5.1 训练曲线怎么解读训练完成后我通常依次看三样东西episode reward曲线、每步平均loss曲线、以及最终策略的“回放视频”把训练好的模型放到环境里跑一遍看看小车是怎么行动的。以我的最终实验为例使用奖励塑造后大约500个episode时小车学会了往左后退再向右爬坡的初步策略1000个episode之后平均每局步数降到100步附近1500个episode时基本稳定在80到120步之间。作为对比随机策略通常要跑几百上千步且大概率永远无法到达目标。loss曲线的走势也有规律早期因为奖励塑造引入了较大的TD误差loss值偏高且波动明显中期随着策略趋于稳定loss逐渐下降后期即使loss不再大幅下降策略表现仍然在缓慢改善。这是因为TD误差的小幅波动可能只是Q值的微调不影响动作选择。5.2 不同配置的对比结果为了说明调参的重要性我把几个关键配置的收敛情况整理成了一张对比表配置是否收敛收敛episode数稳定后平均步数无奖励塑造否3000个episode仍在随机移动——位置差分奖励是约1200150步左右位置差分里程碑奖励是约700100步左右无经验回放否loss持续震荡——目标网络更新间隔100步是但loss震荡约1500180步左右目标网络更新间隔500步是收敛快且稳定约700100步左右这张表直观地反映了三个结论奖励塑造是MountainCar收敛的关键前提经验回放对于稳定训练不可或缺目标网络的更新频率对收敛质量影响很大。5.3 这些结果能推广到其他环境吗有人在MountainCar上调通的参数拿去跑Atari游戏却效果很差。MountainCar和Atari的区别在于前者状态只有2维Q值变化相对平缓网络结构不需要很深后者输入是84×84的RGB图像相当于一个高维视觉任务需要CNN和更大的回放缓冲区。但有一点是通用的低维环境非常适合做算法验证。我后来把MountainCar的代码切换到CartPole、Acrobot等经典控制任务只改了状态维度和动作维度几乎零成本完成验证。如果你是在学习或者调试算法本身先在小环境上跑通、跑稳比直接上复杂环境高效得多。代码编写时也建议把环境相关的参数提取为配置文件config { env_name: MountainCar-v0, state_dim: 2, action_dim: 3, hidden_layers: [128, 128], batch_size: 128, gamma: 0.99, lr: 0.001, buffer_size: 50000, target_update: 500, epsilon_min: 0.01, epsilon_decay: 0.995, }这样换环境时只需要改配置不需要动训练逻辑。6. DQN的改进方向换上新装备再跑一次6.1 Double DQN缓解Q值过高估计标准DQN在计算TD目标时用max_a Q(s,a)既选动作又算价值。这个max操作会系统性地高估Q值——因为估计本身有噪声max把这些噪声的最大值挑了出来。在MountainCar这种奖励全为负的环境里高估会让算法误以为某些动作“没那么差”导致策略不够激进。Double DQN的思路很简单用当前网络选出最优动作再用目标网络计算这个动作的Q值# 当前网络选动作 next_actions policy_net(next_state_batch).argmax(1, keepdimTrue) # 目标网络算价值 max_next_q target_net(next_state_batch).gather(1, next_actions)改动只有两行代码但能明显降低Q值的高估偏差。在MountainCar上实测收敛速度略微提升Q值的整体量级更合理。6.2 优先经验回放重新认识“错题集”普通经验回放是均匀采样但并非所有经验对学习都有相同的价值。TD误差大的样本说明当前网络对它的预测还很不准需要更频繁地被学习。优先经验回放就是按TD误差大小给经验分配采样概率误差越大越容易被抽到。实现上需要维护一个优先级队列或Sum Tree。不过在MountainCar这种简单环境中优先经验回放带来的提升有限主要吃灰了。如果你想在更复杂的环境中应用建议优先掌握这个技巧它是DQN从“能跑”走向“跑得好”的重要改进之一。6.3 从MountainCar到更复杂环境的迁移思路MountainCar上的经验迁移到复杂环境核心是三步第一确认状态表征是否合理是否需要归一化或图像预处理第二调整网络结构与状态维度匹配图像类任务用CNN向量类任务用MLP第三重新标定超参数特别是回放缓冲区大小、目标网络更新频率和衰减率。有一点要特别强调不要迷信别人给的超参数。超参数是一个环境一个样换环境就得重新调。比如MountainCar中50000的缓冲区大小、500步的目标网络更新间隔放到Atari里可能需要百万级缓冲区、每万步更新一次目标网络。关键是理解每个超参数在优化过程中扮演什么角色才能在不同环境下做出合理调整。回看我这次调通MountainCar的全过程最大的体会是强化学习调试第一步永远不是改算法而是确认奖励信号、确认环境交互接口、确认数据流向。把这三个地方排查清楚往往就能解决大部分看起来“像算法问题”的问题。如果你正准备在自己的项目里跑DQN建议先用MountainCar验证整条链路跑通了再上复杂环境这样你会少走很多弯路。本文还有配套的精品资源点击获取