从零实现DQN训练MountainCar:稀疏奖励与惯性环境的强化学习实战
2026/8/31 17:57:12 网站建设 项目流程

简介:本资源是一份面向强化学习初学者与Python开发者的实战项目,聚焦Deep Q-Network(DQN)算法在经典控制任务MountainCar中的完整实现,解决智能体如何通过试错学习克服物理限制、成功登顶的核心问题。压缩包共2个文件(1个PyTorch/TensorFlow训练脚本.py + 1个已训练模型.h5),总大小仅6KB,轻量但结构完整:Python脚本封装了环境交互、经验回放、双网络更新、ε-greedy策略及Q值损失计算等DQN核心逻辑;H5模型文件支持直接加载推理,便于快速验证与二次微调。已有880人学习下载,内容覆盖状态编码(位置/速度归一化)、动作空间映射、目标网络软更新机制及超参调优关键点,配套代码注释清晰、无冗余依赖,可直接运行复现训练过程,是理解DQN原理与落地游戏控制场景的高性价比入门范例。 做强化学习实验的时候,很多人入门第一个想跑通的就是 DQN,而大多数人会优先选 CartPole 或者 Breakout 这种“一眼能看到效果”的环境。我今天想聊的是 MountainCar,一个看起来很简单、却特别容易让人怀疑人生的环境。拿它跑 DQN,你会把奖励设计、网络结构、超参数这些底层逻辑全部踩一遍,收获比直接跑 Atari 还要多。

先提醒一句:MountainCar 其实不算雅达利游戏,它是 OpenAI Gym / Gymnasium 里的经典控制任务,源自 Richard Sutton 早期的强化学习教材示例。之所以经常和雅达利放在一起讲,是因为 DQN 这个算法最早正是在 Atari 2600 系列游戏上打出名气来的,而 MountainCar 又恰好是验证 DQN 这类离散动作算法的绝佳标尺:状态低维、动作只有 3 个、但任务本身是个“稀疏奖励 + 强惯性”双坑环境。这篇博文我就用 Python 从零实现一遍 DQN,把 MountainCar 从“小车一直原地晃”训到“能借惯性冲上山顶”,过程中所有选型、参数、改法都会交代清楚。


1. 项目背景与整体设计思路

1.1 MountainCar 和雅达利:为什么总是被放在一起聊

先把这个环境说透。MountainCar 的目标非常朴素:一辆动力孱弱的小车被困在两座山之间的谷底,引擎只能勉强推动车子,想直接靠发动机力量爬到右侧山顶是永远不可能的。你必须让小车先向左爬上左侧山坡,再利用重力加速度冲下来,借助惯性冲到右侧山顶。环境每一步的奖励是 -1,直到位置超过 0.5 这条线才算到达终点。

这个机制天然就包含两个经典问题:第一是稀疏奖励,如果只看“是否到达山顶”,前几百个 episode 里智能体拿到的反馈几乎全是 -1,完全不知道自己在靠近目标;第二是大惯性,动作和最终效果之间存在明显的延迟,小车往左推完之后,过好几步速度才体现出来。这两个特性和雅达利游戏里的很多关卡是共通的,比如《山路赛车》里的连续动作决策、《蒙特祖玛的复仇》里的稀疏回报,所以用 MountainCar 理解 DQN 的改进点,比直接面对几百万像素的 RGB 图像要轻松得多。

DQN 在雅达利上能成功,关键不在于环境本身多复杂,而在于它用神经网络拟合 Q 函数,把“状态到每个动作的价值评估”这件事变成了一个端到端优化问题。MountainCar 的状态只有 2 个连续浮点数——位置和速度,动作空间是 3 个离散动作(向左、不动、向右),非常适合先用一个简单 MLP 网络跑通完整流程。反过来看,如果连 MountainCar 都训不收敛,那直接上 Atari 大概率更是个灾难。

1.2 为什么用 DQN 而不是 Policy Gradient

我在不少项目里同时试过 DQN、PPO、A2C 这些方法,这个任务我坚持用 DQN 是有原因的。MountainCar 的动作空间是离散的,天然匹配 Q-learning 的max over actions策略;而 Policy Gradient 方法在离散动作上也能做,但它更依赖对动作概率分布的采样,在小规模环境里需要更多调参才能稳定。DQN 的核心思想是学一个Q(s, a)表或函数,每个决策点直接取 Q 值最大的动作,逻辑上更直观,排查问题也更方便。

另外,DQN 的三个经典组件——经验回放、目标网络、以及后来延伸出的 Double DQN、Dueling DQN——在 MountainCar 里都能被单点验证。比如你只把“是否更新目标网络的间隔”这个参数调大,就能看到 reward 曲线剧烈震荡;只去掉经验回放,训练几乎直接发散。这些现象在 Atari 实验里也会出现,但 Atari 一次训练要跑几千万帧,复现成本太高,MountainCar 几分钟就能给你答案。

所以整体设计思路就是:用 MountainCar 作为 DQN 的“诊断平台”,先搭一个最小可用版本,再把奖励设计、网络结构、超参数逐个优化,最后把同一套代码结构映射到 Atari 的图像输入上。这样内容的可移植性最强,也最贴近实际工作流。


2. 环境准备与 DQN 核心原理

2.1 搭建 Python 运行环境

我的实验环境用的是 Python 3.10,强化学习环境库选择 Gymnasium,而不是老版的 Gym。原因很简单:Gym 已经停止维护了,新项目再用它会碰到很多 API 兼容问题。gym.make("MountainCar-v0")在 Gymnasium 里依然可用,但返回的 reset 和 step 接口是新版的五元组格式,代码里必须按新格式处理。

依赖安装只需要四个库:

pip install gymnasium torch numpy matplotlib

如果你机器上有显卡,PyTorch 可以装 CUDA 版,没有显卡 CPU 也能跑这个任务,因为神经网络很小,一个 episode 最多 200 步,几千个 episode 的运算量对 CPU 来说完全可接受。我自己实际测试过,Apple Silicon 的 MacBook 上用 CPU 跑 2000 个 episode 大概耗时不到 3 分钟,训练过程中的瓶颈根本不在模型计算,而在经验回放采样和频繁的环境 step。

安装完成后,先写几行代码确认环境是不是正常的:

import gymnasium as gym env = gym.make("MountainCar-v0") state, info = env.reset() print(env.observation_space) # Box([-1.2, -0.07], [0.6, 0.07], (2,), float32) print(env.action_space) # Discrete(3) print(state) # 比如 [-0.5, 0.0]

2.2 经验回放和目标网络到底在解决什么问题

很多人第一次看完 DQN 理论,代码还是写不出来,本质是因为没搞懂那两个“反直觉”的组件。

经验回放的思路是:把智能体每一步探索得到的(state, action, reward, next_state, done)存进一个缓冲区,训练时再从缓冲区随机抽一批数据来更新网络。为什么不能像普通监督学习那样“来一条学一条”?因为相邻 transition 之间高度相关,你前一秒在学左侧山坡的经验,下一秒又一条左侧山坡的数据进来,网络会反复被同一片区域带偏,学到的模型方差很大。随机打乱采样相当于在时间维度上做了“去相关”,让每一步更新都能看到不同时期的经验,这比单纯堆大数据量更重要。

目标网络解决的是另一个问题:Q-learning 的更新公式里,target = r + γ * max_a' Q(s', a'),其中待更新的 Q 网络同时出现在等号两侧。如果每一轮都用同一个网络计算 target 再去更新自己,这就好比“拿自己的答案考试,批完再改分”,会产生震荡和发散。目标网络的思路很简单:复制一份网络参数,专门负责算 target,它不参与实时梯度更新,而是每隔固定步数或软更新地同步一次在线网络的参数。这样 target 在一段时间内是相对稳定的,学习过程就平稳了。

至于 Q 函数本身,你可以把它理解成一张“如果我在状态 s 做了动作 a,以后还能拿多少收益”的估值表。传统 Q-learning 用表格存储,状态一多就爆;DQN 用神经网络来逼近这张表,输入是状态,输出是每个动作的 Q 值估值。MountainCar 的状态是 2 维 float,神经网络只需 2 个输入神经元、3 个输出神经元,结构非常直观。


3. 从零实现 DQN 训练 MountainCar

3.1 状态、动作与奖励的细节处理

正式开始写代码前,先把环境细节吃透,因为这些细节直接决定收敛难度。

MountainCar 的观测空间是 2 维的 Box,取值范围分别是位置[-1.2, 0.6]和速度[-0.07, 0.07]。数值范围不大,所以不需要像 Atari 那样做像素归一化,直接喂给网络即可。动作空间是 3 个离散动作:0 表示向左推,1 表示不施加动力,2 表示向右推。每一步默认奖励是 -1,直到位置达到>= 0.5才算成功,并结束当前 episode。同时环境默认设置了max_episode_steps=200,也就是 200 步没成功也会被强制截断。

这里有一个非常关键的坑:在新版 Gymnasium 的step返回值中,结束标志被拆成了terminatedtruncated两个布尔值。terminated表示“真正达到终点或进入死局”,truncated表示“到达最大步数而被截断”。在任何需要计算 Q target 的代码里,只有terminated才应该把目标值里的未来收益置零,truncated不能这样处理,否则智能体在靠近终点但还没到的时候,会因为步数耗尽被错误地当成“游戏结束”,学到一个错误的“该处无未来收益”信号。MountainCar 里,truncated几乎总会触发,所以如果你直接把done = terminated or truncated再用这个 done 去算 target,训练质量会受到很大影响。我的处理方式是单独保存terminated作为 Q target 里的 done 信号。

奖励设计上,原始环境每步 -1,这会让智能体在很长一段时间里只能看到负回报。为了加速收敛,我给“成功到达山顶”的动作序列额外加了+100的终止奖励。这不是改变环境语义,只是在训练 reward 里做一个 shape 调整,让稀疏的正反馈来得更明显。你也可以继续用纯 -1 奖励,DQN 理论上也能学到,但需要跑更多 episode,而且非常容易让新手误以为“算法不收敛”。

3.2 神经网络、记忆池和超参数

我用的网络结构很简单,一个三层 MLP:输入层 2 个神经元,两个隐藏层各 64 或 128 个神经元,激活函数用 ReLU,输出层是 3 个神经元,对应三个动作的 Q 值。在 MountainCar 这种低维任务上,隐藏层 64 已经完全够用,128 会更稳但差距不明显。更大的网络在这个任务上反而可能过拟合或者训练变慢,没必要单方面追求容量。

记忆池我用deque(maxlen=20000)实现,容量 20000 条 transition。这个数字不是随便定的:MountainCar 一个 episode 最多 200 步,20000 条大约对应 100 个完整 episode 的数据量,足够覆盖一个阶段内的大部分探索轨迹。如果容量太小,采样时很容易抽到过于近期的数据,去相关效果会打折;如果容量太大,老经验淘汰得太慢,环境虽然静态但也会拖慢对新策略的学习。

几个关键超参数我分享出来给你抄作业:

参数取值说明
学习率3e-4用 Adam 优化器,比常规 1e-3 更稳定
折扣因子 γ0.99未来收益折现,值越大越看重长期回报
批大小128经验回放每次抽样的样本数
训练频率4每 4 步更新一次网络
目标网络同步0.005软更新系数,每步小幅同步
ε 初始值1.0一开始完全随机探索
ε 最小值0.01保留少量探索
ε 衰减0.995 / episode每个 episode 结束衰减一次
记忆池容量20000transition 数量

代码实现如下:

import random from collections import deque, namedtuple import numpy as np import torch import torch.nn as nn import torch.optim as optim import gymnasium as gym Transition = namedtuple('Transition', ['state', 'action', 'reward', 'next_state', 'done']) class ReplayBuffer: def __init__(self, capacity): self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append(Transition(state, action, reward, next_state, done)) def sample(self, batch_size): transitions = random.sample(self.buffer, batch_size) batch = Transition(*zip(*transitions)) states = torch.FloatTensor(np.array(batch.state)) actions = torch.LongTensor(batch.action).unsqueeze(1) rewards = torch.FloatTensor(batch.reward).unsqueeze(1) next_states = torch.FloatTensor(np.array(batch.next_state)) dones = torch.FloatTensor(batch.done).unsqueeze(1) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer) class DQN(nn.Module): 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)

3.3 训练主循环:从随机探索到收敛

训练主循环我把它拆成三个部分:探索策略、网络更新、目标网络同步。探索策略用最简单的 ε-greedy,以概率 ε 随机选动作,其余时候选当前网络 Q 值最大的动作。ε 在开始的 1.0 慢慢衰减到 0.01,确保前期有足够多的随机动作去发现“借惯性上山”这个关键规律。

网络更新部分严格按 DQN 论文来做:采样一批经验,用在线网络算出当前状态下实际执行动作的 Q 值,再用目标网络算出next_state下所有动作 Q 值的最大值,构造 target。这里要注意 PyTorch 的梯度传播控制:target的计算必须放在torch.no_grad()内,否则反向传播时会通过目标网络也去更新参数,目标网络就失去意义了。

def train_step(policy_net, target_net, optimizer, memory, gamma=0.99, batch_size=128): if len(memory) < batch_size: return None states, actions, rewards, next_states, dones = memory.sample(batch_size) current_q = policy_net(states).gather(1, actions) with torch.no_grad(): next_q = target_net(next_states).max(1, keepdim=True)[0] target_q = rewards + gamma * next_q * (1 - dones) loss = nn.MSELoss()(current_q, target_q) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()

主循环中每个 episode 开始先env.reset(),然后循环 step 直到terminatedtruncated。每一步把transition存入记忆池,每 4 步调用一次train_step。同时每个 episode 结束后,用软更新把目标网络参数往在线网络方向拉一点:

def soft_update(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 )

为什么用软更新而不是每 N 步硬拷贝一次?硬拷贝实现简单,我在早期版本里也用过target_net.load_state_dict(policy_net.state_dict()),但它有一个间歇性问题:刚拷贝完的 100 步里 target 完全跟着在线网络走,容易出现短暂震荡;软更新每一步都让 target 轻微靠近在线网络,训练过程平滑很多。MountainCar 这个问题不明显,但如果以后迁移到 Atari,软更新会省去很多调同步间隔的心力。

完整训练循环的骨架大致是这样:

env = gym.make("MountainCar-v0") state_dim = env.observation_space.shape[0] action_dim = env.action_space.n policy_net = DQN(state_dim, action_dim) target_net = DQN(state_dim, action_dim) target_net.load_state_dict(policy_net.state_dict()) optimizer = optim.Adam(policy_net.parameters(), lr=3e-4) memory = ReplayBuffer(capacity=20000) epsilon = 1.0 epsilon_min = 0.01 epsilon_decay = 0.995 gamma = 0.99 batch_size = 128 train_freq = 4 success_reward = 100.0 total_steps = 0 all_rewards = [] for episode in range(2000): state, _ = env.reset() episode_reward = 0 terminated = False truncated = False step_count = 0 while not (terminated or truncated): if random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): q_values = policy_net(torch.FloatTensor(state).unsqueeze(0)) action = q_values.argmax(dim=1).item() next_state, reward, terminated, truncated, _ = env.step(action) if terminated: reward += success_reward memory.push(state, action, reward, next_state, terminated) state = next_state episode_reward += reward step_count += 1 total_steps += 1 if total_steps % train_freq == 0: loss = train_step( policy_net, target_net, optimizer, memory, gamma, batch_size ) soft_update(target_net, policy_net) epsilon = max(epsilon_min, epsilon * epsilon_decay) all_rewards.append(episode_reward) if (episode + 1) % 50 == 0: avg_reward = np.mean(all_rewards[-50:]) avg_steps = 200.0 if not terminated else step_count print(f"Episode {episode+1}, avg_reward_last50: {avg_reward:.2f}, finished: {terminated}") env.close()

3.4 为什么加成功奖励是对的选择

我想重点解释一下reward += 100这一步背后的逻辑,因为它是整个项目最容易被忽略但影响最大的细节。

原始 MountainCar 每一步给 -1,成功时并不额外给正奖励,所以一个成功 episode 的总奖励也就是“负的步数”,比如 137 步就是 -137。如果你完全不加成功奖励,DQN 在早期的 Q 值目标基本全是负数,没有明显的正样本引导,网络只能通过“比较哪个动作带来的负值更小”来学习,收敛非常慢。我在第一次实验里用原始奖励跑了 2000 个 episode,成功率一直不超过 20%,后来加上terminated+100,400 个 episode 之后成功率就回到了 90% 以上。

有人会问:这样改不是改变了 MDP 吗?严格来说确实改变了奖励函数,但在 MountainCar 这种稀疏奖励环境里,reward shaping 是实践中的常用手段。关键是 shaping 不要违背任务目标,成功给正奖励并没有引入新的误导性信息,只是把“到达终点”这个信号放大了。如果你担心影响真实性,可以只加比较小的+10,也能看到明显改善。把这个奖励调整写进实验记录里,是正规项目该有的习惯。


4. 训练结果、调优心得与常见问题

4.1 我的实验观察:三个阶段

我按上面的参数跑了完整实验,记录了几个标志性阶段。前 150 个 episode 基本是完全的随机探索,小车大多在谷底来回晃动,偶尔能冲上左侧山坡,但每次都差一点到达山顶。这个时候的 reward 曲线几乎是一条水平线,看不出任何学习迹象;很多人到这里就以为代码写错了,其实不是,DQN 的“顿悟”通常要等经验池积累到一定量、目标网络开始稳定后才出现。

从第 250 到 450 个 episode,我注意到小车开始学会利用左侧山坡的“摆动”蓄力:它会在谷底左边推几下,让速度正起来,再冲上右侧半山腰,虽然还不能稳定到达终点,但轨迹已经有意识了。这段期间 reward 曲线开始出现周期性的大负值变化,因为有些 episode 能撑满 200 步,有些更早失败,波动变大是正常的。

到了第 600 个 episode 左右,成功概率稳定提升,平均每个 episode 的步数从 190 快速下降,最终大多数 episode 在 120 到 160 步内完成。如果你把每一步的画面临时组一个视频,会看到小车非常流畅地“左-右-右”蓄力冲顶,基本没有多余动作。

4.2 训练中的经典问题排查表

在跑这个项目的过程中,我记录了一批高频问题,这里整理成一个速查表,你可以直接对照排查。

现象根因解决办法
训练几百个 episode 一直不收敛,小车原地晃奖励过于稀疏,正反馈信号太弱到达终点时加成功奖励,比如 +100
损失值爆炸,出现 NaN学习率过大或 Q 值估计过大调低学习率到 1e-4~3e-4,检查 reward 归一化
前期收敛,后期突然崩溃目标网络同步太频繁,target 不稳定改用软更新,τ 取 0.001~0.01
epsilon 衰减太快,探索不足还没找到有效策略就过早进入 exploitation把衰减周期拉长,衰减系数调到 0.995 或更慢
状态值没有归一化导致训练慢position/velocity 量级差异不大,但有时仍会波动可尝试标准化为均值 0 方差的输入
环境 reset / step 报错,返回 5 个值新老 Gym API 混用统一用 Gymnasium,reset接 2 个返回值,step接 5 个返回值
成功概率低但不发散网络容量太小,Q 值逼近能力不足隐藏层从 64 扩大到 128
表现有周期性大波动随机种子未固定,训练过程有随机性设置np.random.seedtorch.manual_seed,做多次实验取均值

这里重点提一个容易踩的坑:在 Gymnasium 新版接口里,truncated经常被忽略,导致新手把“200 步未完成”当成真正的终止状态写入经验池。如果这个信号混进了 Q target 计算,等于在告诉网络“到第 200 步就游戏结束了,未来收益为 0”,但实际不是这样,网络会学到错误的边界效应。我建议在 push 经验时只存terminated,把truncated视为正常的中间状态,这样更符合 DQN 的语义。

4.3 从 MountainCar 到雅达利:这套代码怎么改

MountainCar 跑通之后,你已经有了一套可迁移的 DQN 代码骨架。往 Atari 方向扩展,需要替换的是环境感知部分,而不是强化学习核心。Atari 的原始输入是 210x160 的 RGB 图像,直接喂给 MLP 不现实,通常要做三步预处理:灰度化、降采样到 84x84、再取最近 4 帧堆叠成一个 84x84x4 的张量输入。这样做的目的是给网络提供“运动信息”,单帧图像看不出速度和方向,4 帧连起来才能判断游戏状态的变化。

网络结构也要从 MLP 换成卷积网络,典型的架构是三层卷积:第一层 32 个 8x8 卷积核、步长 4,第二层 64 个 4x4 卷积核、步长 2,第三层 64 个 3x3 卷积核、步长 1,最后接两个全连接层输出动作 Q 值。这个结构几乎成了 Atari DQN 的标准配置,我建议直接沿用。

此外,Atari 的奖励范围通常比较大,探索也更困难,实践中几乎都会用 Double DQN 来缓解 Q 值过估计:选择动作时用在线网络,评估这个动作的价值时用目标网络,两个网络分工避免“自己给自己虚高评分”。在 MountainCar 里我也测试过 Double DQN,它把成功率的波动幅度减小了一些,说明这个改进思路在任何离散动作环境都有效,可以当成下一步优化方向。

还有一个值得提的变体是 Dueling DQN,它把网络输出拆成状态价值 V(s) 和动作优势 A(s, a) 两部分,最后组合成 Q(s, a)。MountainCar 这种“不同动作差异其实不大”的环境里,Dueling 结构能更快学会状态本身的价值,虽然这个任务里提升不如 Atari 明显,但思路值得理解。


从动手写第一个环境的reset()到现在,我最大的体会是:强化学习项目里,最耗时间的往往不是算法本身,而是对环境和代码细节的理解。MountainCar 这个例子看着不起眼,但它把 DQN 的每个关键组件都逼着你在实践中重新学了一遍。如果你在跑其他环境时遇到瓶颈,我建议回来重新审视自己的奖励设计和 done 信号处理,这两处往往比网络结构更致命。最后再分享一个小技巧:训练时顺手把每个 episode 的步数、收集到的奖励、 Q 值均值都记录成 CSV,后面复盘调参时,这些数据比训练曲线本身更值钱。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询