尧图精选

DQN入门实践:从零实现MountainCar小车爬山

🕒 发布时间:2026/8/31 21:06:52 📁 来源:尧图网络
简介本资源是一份面向强化学习初学者与Python开发者的实战项目聚焦Deep Q-NetworkDQN算法在经典控制任务MountainCar中的完整实现解决智能体如何通过试错学习克服物理约束、自主达成目标的核心问题。压缩包共2个文件1个PyTorch/TensorFlow兼容的h5模型权重文件用于加载训练成果1个结构清晰的Python主程序文件含环境交互、经验回放、双网络更新及ε-greedy策略实现总大小仅6KB轻量但功能完备。已有880人下载学习适合高校课程实践、AI竞赛备赛或深度强化学习入门者快速复现并调试DQN核心机制。读者可直接运行脚本观察车辆从随机探索到稳定登顶的全过程深入理解状态编码、Q值估计、目标网络同步与损失函数设计等关键环节为拓展至更复杂游戏或机器人控制任务奠定扎实基础。 最近有个朋友问我想入门强化学习是不是得先拿Breakout或者Pong这类雅达利游戏练手。我的建议是反过来先把MountainCar跑通你对DQN的理解会比直接上雅达利游戏扎实得多。这个项目就是用Python从零实现DQN算法让小车学会自己爬上山坡。它不需要GPUCPU跑几分钟就能看到效果非常适合有Python基础、正在入门强化学习的人作为第一个完整项目。MountainCar这个环境很有意思状态只有位置和速度两个连续值动作只有三个离散选项看起来比雅达利游戏简单太多。但它的奖励极其稀疏——每一步只给-1小车到达山顶才算结束。这种设计让随机探索基本等于白给恰好把DQN最核心的痛点暴露出来如何在几乎没有正向反馈的情况下让智能体学到“先倒车、再冲刺”这种需要长期规划的策略。理解了MountainCar你再看DQN在雅达利游戏上的表现背后的机制是一样的。1. 项目背景与整体思路拆解1.1 为什么选MountainCar作为DQN的练手环境先把这个环境彻底说清楚。在OpenAI Gym现在叫Gymnasium里MountainCar的任务描述非常朴素一辆动力不足的小车被困在山谷里靠自身引擎爬不上右边的山坡必须通过来回摆动积蓄动能像人荡秋千一样借着惯性冲上山顶。环境的状态空间是二维的一个是小车的位置范围在[-1.2, 0.6]另一个是速度范围在[-0.07, 0.07]。动作空间是离散的三个动作向左推、不施加力、向右推。每一步的奖励都是-1直到小车到达目标位置位置不小于0.5或者超过回合步数上限本回合结束。严格来说MountainCar并不是Atari 2600主机上的那批游戏但它常和雅达利环境一起出现在强化学习基准套件里。DQN当年在雅达利游戏上打出一系列惊艳成绩核心机制就是价值函数近似加经验回放。而MountainCar把这种机制压缩到了一个极小的规模里可以随时看到训练曲线变化方便你做各种实验。这种可调试性是雅达利游戏给不了的后者动辄需要几十万帧才能看到明显进展。选择MountainCar还有一个实际好处训练速度快。雅达利环境通常要跑几万到几十万个stepMountainCar在你调好超参数之后大约几百个episode就能看到策略明显改善。我自己的机器没有独立显卡纯CPU训练大概几分钟就能从完全随机变成“知道先往左边倒车”的智能体。对新手来说反馈快就意味着试错成本低你可以在一个晚上尝试多组参数组合这种上手体感非常重要。1.2 DQN解决MountainCar的核心逻辑与设计取舍DQN全称是Deep Q-Network它解决的问题可以概括为在有限状态、有限动作的环境中学习一个从状态到动作价值的映射。这里的“价值”指的是未来累计折扣奖励的期望即Q值。MountainCar的状态虽然是连续的但动作空间很小只有三个动作正好符合DQN的使用场景。打个比方你可以把Q网络理解成一个“动作打分器”。给定当前位置和速度网络分别输出向左推、不推、向右推三个动作的分数。智能体每次决策时挑选分数最高的动作执行。训练的目标就是让这个打分器越来越准在某个状态哪个动作能带来更少的负奖励它的分就更高。为什么这个任务不选策略梯度方法不是说不行而是对于入门来说DQN的组件更直观更符合“在已知环境中做最优选择”的直觉。策略梯度直接建模策略分布处理连续动作空间有天然优势但对于MountainCar这种离散动作、低维状态的任务DQN的表达方式更简单也更容易定位问题出在哪里。整体架构包含四个核心部分主Q网络负责输出当前状态的Q值目标网络提供稳定的Q值估计用于计算损失经验回放缓冲区存储历史转移数据epsilon贪婪策略负责平衡探索与利用。这四个组件缺一不可后面我会逐个拆解它们的设计细节和踩坑经验。2. 核心细节解析DQN三大关键机制与参数选择2.1 经验回放为什么必须打破样本时间关联在我第一次写DQN的时候曾经天真地以为直接用最近采集的数据进行梯度更新就行了。结果训练曲线剧烈震荡甚至发散了。后来才意识到强化学习的训练数据不是独立同分布的——智能体在环境里采集到的相邻样本高度相关如果按时间顺序直接学习网络会对最近的经历过拟合导致灾难性遗忘。经验回放的解决方案用一个词概括就是“攒起来随机抽”。我实现的ReplayBuffer是一个固定容量的双端队列容量设为100000。每次环境交互产生的(state, action, reward, next_state, done)五元组都存入这个缓冲区训练时从中随机采样一个小批量batch size设为64进行梯度下降。随机采样的意义在于打破样本之间的时间相关性让每次更新面对的是一批来自不同时间段的经验相当于把数据分布“打散”了。缓冲区容量这个参数也值得琢磨。容量太大会让旧经验占据太多比例训练初期的低质量样本迟迟无法被淘汰拖慢学习速度容量太小则采样多样性不足网络容易震荡。100000对于MountainCar这种低维环境足够了因为一个episode最长也就几百步100000步相当于几百个完整episode的积累。如果是雅达利游戏那种高维图像输入缓冲区容量通常要设到1000000因为每帧图像的信息量远大于两个浮点数。还有一个容易被忽略的细节经验回放缓冲区里存的是转移元组而不是单纯的样本。这是因为Q-learning的更新依赖“当前状态动作的Q值”和“下一个状态的最大Q值”之间的时间差分关系。如果不把完整的转移过程存下来就无法计算这个时间差分目标。这也是DQN和普通监督学习在数据处理上最本质的区别。2.2 目标网络给训练一个稳定的靶子DQN另一个出道即巅峰的创新点是目标网络。为什么需要它因为Q-learning的更新本质上是自举的它用当前网络的估计值去更新当前网络自身。如果只有一个网络每一步更新都在移动靶子上瞄准误差会不断累积放大最终导致训练发散。这种自举带来的不稳定在MountainCar这种稀疏奖励环境下尤其致命因为奖励信号太稀疏误差几乎没有被修正的机会。目标网络的做法是维护一份主网络参数的延迟副本。计算Q值目标时用的是目标网络输出的下一代Q值而目标网络自身不参与梯度下降只在每隔固定步数后将主网络的参数同步过来。我在这个项目里把同步间隔设为1000步这里的步指的是环境交互step不是梯度更新次数。这样做的好处是在1000步的窗口内训练目标保持不变主网络可以稳定地朝这个目标优化等到目标网络更新之后再调整新的靶子。目标网络还有软更新和硬更新两种方式。硬更新就是上面说的每隔固定步数直接复制参数简单直接软更新则是每一步都把目标网络参数往主网络方向移动一小部分公式是theta_target tau * theta_main (1 - tau) * theta_targettau通常取0.005。对于MountainCar这个规模的问题硬更新已经完全够用。但如果你后续做更复杂的连续控制任务软更新的平滑性会明显更好值得提前了解。我实际调参时发现目标网络更新频率对MountainCar的训练稳定性影响很大。更新太频繁比如每隔100步就同步一次目标网络和主网络过于接近稳定性提升不明显更新太稀疏比如每隔10000步才同步一次目标网络长期不更新Q值估计会偏离真实价值。1000步是一个经过多次实验验证比较稳妥的取值你可以在此基础上根据训练曲线微调。2.3 探索策略与奖励信号的细节处理探索与利用的平衡是强化学习永恒的话题。在MountainCar里这个问题被稀疏奖励放大了如果智能体从一开始就只选当前Q值最高的动作那么它会一直困在“盲目向右推”的局部策略里永远学不到“先倒车”的长期规划。因此我采用了标准的epsilon贪婪策略以epsilon的概率随机选择动作以1-epsilon的概率选择Q值最大的动作。epsilon的初始值设为1.0意味着最开始完全随机探索。随着训练进行epsilon按0.995的比例在每个step衰减下限设为0.01。这个衰减速度是我反复试过的太快的话智能体还没积累足够的成功经验就进入纯利用阶段策略定型在次优解太慢的话后期大量随机动作会干扰已学到的优秀策略训练曲线会出现可见的震荡。0.995配合1000个episode的训练总量大约在训练到300个episode时epsilon降到0.2左右此时智能体基本已经掌握初级策略开始进入精细优化阶段。奖励信号的处理也有一个重要选择是否对稀疏奖励做塑形。最常见的做法是在到达山顶前每一步给-1到达后给0。这个设计简单且符合MountainCar的最小任务描述。有些人会在智能体接近山顶时给一个按距离缩放的奖励比如r -1 10 * |position - goal|之类的来引导智能体快速靠近目标区域。我建议入门阶段先不做奖励塑形因为塑形函数设计不当会引入新的偏置智能体可能学到“在原地不动”这种钻空子的行为。先把原始版本跑通再考虑扩展。3. 实操过程从环境安装到训练跑通3.1 环境准备与依赖安装先说环境版本。我用的Python是3.10深度学习框架是PyTorch 2.x。强化学习环境库需要特别注意老版本的gym库已经停止更新现在推荐使用gymnasium它是gym的继任者API基本兼容维护更活跃。如果你在网上下载老教程代码看到“import gym”和“env.unwrapped”这些写法建议直接替换成“import gymnasium as gym”。安装命令很简单在命令行执行pip install gymnasium torch numpy matplotlib如果你的机器有NVIDIA显卡并装好了CUDAPyTorch会自动使用GPU加速。但对这个项目来说CPU已经足够不用在环境配置上花太多时间。装完之后跑一个简单验证脚本确保环境能正常创建并交互import gymnasium as gym env gym.make(MountainCar-v0, max_episode_steps1000) state, info env.reset() print(状态空间:, env.observation_space) print(动作空间:, env.action_space) print(初始状态:, state) for _ in range(10): action env.action_space.sample() next_state, reward, terminated, truncated, info env.step(action) print(f动作: {action}, 奖励: {reward}, 是否结束: {terminated or truncated})这里有个很关键的参数max_episode_steps1000。MountainCar-v0默认的回合上限是200步这对DQN训练来说太短了。训练初期智能体完全是随机探索200步内到达山顶的概率极低几乎每个episode都是-200的固定奖励梯度信号非常微弱。把上限放宽到1000步相当于给了智能体更多时间去尝试“倒车-加速-冲坡”的完整流程学习效率会高很多。3.2 完整代码实现与逐段解析下面给出完整可运行的DQN训练代码。我按照模块拆开讲每个部分说明设计意图。首先是网络定义和超参数设置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 # 超参数 LEARNING_RATE 1e-3 GAMMA 0.99 EPSILON_START 1.0 EPSILON_END 0.01 EPSILON_DECAY 0.995 MEMORY_SIZE 100000 BATCH_SIZE 64 TARGET_UPDATE 1000 EPISODES 1000 MAX_STEPS 1000 class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): 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)网络结构是两层128维隐藏层输入是2维状态输出是3维Q值。这个规模对MountainCar来说绰绰有余。不建议一上来就用特别宽或特别深的网络因为这会显著增加训练时间而且在小规模问题上容易过拟合到已有经验上泛化能力反而变差。128个神经元的隐藏层在处理位置和速度两个连续输入时非线性表达能力已经足够。接下来是经验回放缓冲区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) states, actions, rewards, next_states, dones zip(*batch) return ( np.array(states, dtypenp.float32), np.array(actions, dtypenp.int64), np.array(rewards, dtypenp.float32), np.array(next_states, dtypenp.float32), np.array(dones, dtypenp.float32), ) def __len__(self): return len(self.buffer)这里的random.sample是不放回采样确保同一批次内不会出现重复样本。dones用np.float32存储方便后续在目标计算中和gamma直接相乘。一个常见错误是把done存成bool类型做张量运算时类型不匹配会报错。然后定义Agent类把主网络、目标网络、优化器、探索策略都封装在一起class DQNAgent: def __init__(self, state_dim, action_dim): self.action_dim action_dim self.q_net QNetwork(state_dim, action_dim) self.target_net QNetwork(state_dim, action_dim) self.target_net.load_state_dict(self.q_net.state_dict()) self.optimizer optim.Adam(self.q_net.parameters(), lrLEARNING_RATE) self.buffer ReplayBuffer(MEMORY_SIZE) self.epsilon EPSILON_START self.step_count 0 def choose_action(self, state): if np.random.rand() self.epsilon: return np.random.randint(self.action_dim) state_tensor torch.FloatTensor(state).unsqueeze(0) with torch.no_grad(): q_values self.q_net(state_tensor) return q_values.argmax().item() def update(self): if len(self.buffer) BATCH_SIZE: return states, actions, rewards, next_states, dones self.buffer.sample(BATCH_SIZE) states_tensor torch.FloatTensor(states) actions_tensor torch.LongTensor(actions).unsqueeze(1) rewards_tensor torch.FloatTensor(rewards).unsqueeze(1) next_states_tensor torch.FloatTensor(next_states) dones_tensor torch.FloatTensor(dones).unsqueeze(1) q_values self.q_net(states_tensor).gather(1, actions_tensor) with torch.no_grad(): max_next_q self.target_net(next_states_tensor).max(1, keepdimTrue)[0] targets rewards_tensor GAMMA * max_next_q * (1 - dones_tensor) loss nn.MSELoss()(q_values, targets) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.epsilon max(EPSILON_END, self.epsilon * EPSILON_DECAY) self.step_count 1 if self.step_count % TARGET_UPDATE 0: self.target_net.load_state_dict(self.q_net.state_dict())这里有几个值得掰开说的点。gather(1, actions_tensor)是从Q网络输出的三维张量里按动作索引取出对应Q值的标准写法比先转one-hot再逐元素相乘更高效。计算target时用torch.no_grad()包裹因为目标网络不参与反向传播不用计算梯度能节省显存和时间。(1 - dones_tensor)是为了在终止状态时不把未来的Q值计入目标因为终止状态之后没有下一步了。关于epsilon衰减的时机我选择在update函数里每个step衰减一次而不是每个episode衰减一次。原因是episode的长度差异很大训练初期一个episode可能跑满1000步后期可能一两百步就结束按episode衰减会导致epsilon下降速度与训练效率脱钩。按step衰减更稳定也更符合“交互次数决定探索量”的逻辑。最后是训练主循环env gym.make(MountainCar-v0, max_episode_stepsMAX_STEPS) agent DQNAgent(state_dim2, action_dim3) episode_rewards [] episode_lengths [] for episode in range(EPISODES): state, _ env.reset() total_reward 0 step_count 0 while True: 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) agent.update() state next_state total_reward reward step_count 1 if done: break episode_rewards.append(total_reward) episode_lengths.append(step_count) if (episode 1) % 50 0: avg_reward np.mean(episode_rewards[-50:]) print(fEpisode {episode 1}, 平均奖励: {avg_reward:.1f}, f平均步数: {np.mean(episode_lengths[-50:]):.1f}, fepsilon: {agent.epsilon:.3f}) torch.save(agent.q_net.state_dict(), dqn_mountaincar.pth) plt.plot(episode_rewards) plt.xlabel(Episode) plt.ylabel(Total Reward) plt.title(DQN on MountainCar) plt.show()一个实现细节Gymnasium的新API中env.step返回5个值其中terminated表示是否到达终止状态例如到达山顶truncated表示是否因为步数超限而提前结束。我把两者用or合并成done因为无论哪种情况这个episode都结束了都需要把done标志存入经验回放。如果你沿用老代码里的done单标志写法在新版gymnasium里会漏掉truncated信息。3.3 训练效果观测与收敛判断训练过程中最直观的观测指标是每回合总奖励曲线。我训练了1000个episode把每回合的奖励值画出来曲线形态大致是这样的前100到200个episode奖励值基本稳定在-1000左右因为智能体还在瞎逛几乎没有成功到达山顶的情况。200到400个episode之间开始出现零星的高奖励回合说明智能体偶然学会了“先左后右”的摆动策略。大约500个episode之后曲线明显抬升平均奖励稳定在-200到-300之间这对应着智能体可以稳定用两三百步以内完成任务。如果你看到曲线在某个episode突然从-1000跳到-150不用惊讶这不是bug而是探索过程中恰好触发了一次成功这次成功经验被存入缓冲区通过经验回放被反复学习带动了网络参数质的飞跃。这是DQN训练中非常典型的现象尤其在稀疏奖励任务中。判断模型是否真正学会可以跑一个纯利用epsilon设为0的测试循环看看智能体是否每次都能用70到100步完成任务。MountainCar的最优策略大概需要80多步到达山顶如果测试中智能体稳定做到这个水平说明它学会了最优的摆动策略。如果测试中智能体仍然需要300步以上大概率是探索不充分可以考虑降低epsilon衰减速率或者增加训练episode数。4. 常见问题与排查技巧实录4.1 训练不收敛奖励曲线纹丝不动最让人头大的情况就是训练了几百个episode奖励曲线一直平在-1000毫无起色。我遇到过这种问题排查思路从头到尾过一遍。首先检查经验回放缓冲区里有没有出现“成功样本”。可以在训练循环里加一行打印每次episode结束若total_reward -200打印成功episode的序号。如果训练了500个episode仍然零成功说明探索严重不足优先考虑三个调整把max_episode_steps调大比如从200改成1000把epsilon的衰减速率调慢比如从0.995改成0.998把目标网络的更新间隔调快比如从1000改成500。这三个改动都是在给智能体更多探索和试错的机会。其次是检查Q值的波动范围。在训练早期用debug模式打印一批状态对应的Q值如果Q值始终在零附近微小波动说明网络没有有效学习信号。这时候着重检查reward是否成功传到网络可以在计算loss后打印loss的值如果loss恒定不变或者极小大概率是target计算有误比如dones没乘到gamma上。4.2 训练中后期突然发散这种情况是最诡异的明明已经学会了一些有效策略奖励曲线也在稳步上升突然某几个episode奖励暴跌然后训练彻底崩坏。我定位到的原因是无脑使用Adam优化器加上经验回放中大量低质量样本的耦合效应。解决办法有两个。第一给梯度更新加一个裁剪在loss.backward()之后、optimizer.step()之前加一句torch.nn.utils.clip_grad_norm_(agent.q_net.parameters(), max_norm10)。第二个是检查是否需要降低学习率从1e-3降到5e-4通常能明显提升稳定性。MountainCar的奖励尺度本身就小都是-1Q值的绝对值不大梯度爆炸的风险不算高但一旦出现发散倾向裁剪是最快的止血方法。另外如果你发现发散总是出现在某次成功的episode之后还有一个可能这次成功经验被反复采样导致Q值对特定状态产生严重过拟合破坏了已有策略。这时候可以考虑提高TARGET_UPDATE频率让目标网络更快跟上主网络的变化减少长期不一致带来的漂移。不过这个调整要谨慎太频繁会重新引入不稳定。4.3 复现结果不稳定同一份代码同一台机器跑两次结果差别很大这在强化学习训练里是常态不是bug。随机性来源有三个环境初始状态的随机采样、epsilon贪婪策略的随机动作、经验回放时的随机采样。为了尽可能提升复现性你可以在代码开头固定随机种子np.random.seed(0) random.seed(0) torch.manual_seed(0) env gym.make(MountainCar-v0, max_episode_stepsMAX_STEPS) env.reset(seed0) env.action_space.seed(0)但需要说明的是即使固定了所有种子不同机器上的浮点运算差异和CPU线程调度仍然可能导致结果不完全一致。这恰恰是强化学习调参的常态。不要追求像素级复现重要的是评估模型最终性能的统计特征比如跑10次测试算平均奖励而不是单独看某一次的曲线。4.4 训练太慢怎么办MountainCar本身训练很快但如果你觉得还是慢先确认两件事第一是否在每次env.step时解压出所有返回值用不到的变量不要重复计算第二是否在choose_action里频繁做FloatTensor转换。把转换放在一次推理前的嵌套训练循环里可以小幅提升速度。想要更大的加速效果可以尝试向量化环境用gymnasium.vector模块同时运行多个环境实例每个step填充更多的经验。这样经验回放缓冲区能更快积累多样化的数据训练前期探索效率提升非常明显。不过对于MountainCar这个规模我建议先跑通单环境版本把概念理顺了再考虑性能优化。很多新手一上来就追求分布式、并行训练结果调试环境的时间比训练本身还长实在不划算。另外一个我常用的技巧是训练进行到一半时把当前Q网络画出来看看。用matplotlib画一张Q值分布的热力图横轴是位置纵轴是速度颜色是三个动作中的最大Q值。你会非常直观地看到网络学到了什么初始阶段颜色是杂乱的等到训练后期右边山坡对应区域的颜色会明显偏深表示Q值在那里变高。这种可视化的成就感比单纯的奖励曲线强得多也方便快速判断网络是否学到了合理的策略结构。5. 写在最后从MountainCar到更大的世界训练完这个项目之后我最大的感受是DQN的核心思想并不复杂但每个组件背后都对应着强化学习的本质困境。经验回放对应的是样本独立同分布假设的破与立目标网络对应的是自举更新的不稳定性epsilon贪婪对应的是探索与利用的永恒矛盾。MountainCar像是一个微缩模型把所有困境压缩到最小的可操作规模让你亲手调一遍、踩一遍坑比看十篇理论文章都有用。后续扩展的方向其实很多。你可以把网络结构换成Dueling DQN把Q值拆成状态价值和动作优势两部分这在动作空间更大的任务上往往见效也可以换成Double DQN用主网络选动作、用目标网络估价值减少Q值过估计还可以把目标网络更新方式改成软更新体会不同平滑策略的差异。这些改进在MountainCar上都只需要改动十几行代码却能让你的理论理解加深一个层次。如果你顺利跑通了这个小车爬山下一步挑战雅达利游戏就有了底气。把状态从二维数组换成屏幕图像把网络从全连接换成卷积网络再加上帧堆叠和奖励裁剪整个训练框架不需要大改。到时候再回头看MountainCar你可能会觉得它简单但正是这个简单的环境让你避开了很多弯路。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →