ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Python的DQN在MountainCar上的实战:从实现到调优

基于Python的DQN在MountainCar上的实战:从实现到调优 简介本资源面向强化学习入门与进阶开发者提供基于Python实现DQN算法并应用于雅达利MountainCar环境的完整源码方案帮助读者理解深度Q网络在连续状态空间控制任务中的落地方式。压缩包共2个文件包含1个py源码脚本与1个h5模型文件整体约6KB其中脚本涵盖Q网络构建、经验回放缓冲区、目标网络更新与ε-greedy策略等核心模块h5文件为训练后保存的模型权重可直接加载复现。资源围绕MountainCar这一经典难题展开涉及状态编码、动作选取、均方误差损失、Adam优化器及超参数调优等关键知识点适合希望打通DQN从理论到代码实现链路的读者。目前已有881人学习下载可作为强化学习课程实验、毕业设计或算法自学的参考案例帮助读者掌握将DQN迁移至机器人控制、自动驾驶等场景的基本思路。1. 从 MountainCar 说起一份能跑通的 DQN 实战源码到底长什么样很多人第一次接触强化学习都是从 Gym 里的 CartPole 或者 MountainCar 开始的。这两个环境看起来简单但真动手写 DQN 的时候翻车点一个接一个奖励曲线不收敛、Q 值爆炸、经验回放写错、目标网络忘了同步。这份基于 Python 的 DQN 在雅达利游戏 MountainCar 上的应用与实现核心价值就在于它把「一个能跑通的 DQN 训练闭环」完整摊开了——环境封装、网络结构、经验回放、目标网络、ε-greedy 策略、训练与评估脚本全部落在可读的 Python 代码里。它适合两类人刚学完强化学习数学原理、想找一个能直接跑起来的小项目练手的入门者以及做过监督学习、想搞清楚 DQN 工程实现细节的开发者。MountainCar 这个环境状态维度低、动作空间离散训练几分钟就能看到效果是验证 DQN 实现是否正确的理想试验田。2. DQN 在 MountainCar 上的核心机制为什么它比 Q-learning 更适合这个场景2.1 MountainCar 的状态空间与奖励设计MountainCar 的环境设定很反直觉一辆小车停在两座山之间的谷底发动机动力不足以直接冲上右边的山顶必须来回摆动积累动量才能到达目标点。状态空间是二维连续值——位置 position 范围约 [-1.2, 0.6]速度 velocity 范围约 [-0.07, 0.07]。动作空间只有三个离散动作向左推、不推、向右推。奖励设计是每步 -1到达目标点position 0.5回合结束。这个环境对传统 Q-learning 的表格方法很不友好因为状态是连续的离散化粒度选粗了学不到精细策略选细了状态空间爆炸。DQN 用神经网络拟合 Q 函数天然适合处理连续状态输入。常见做法是把状态归一化后直接喂给网络输出三个动作对应的 Q 值。import gym import numpy as np env gym.make(MountainCar-v0) state env.reset() print(状态维度:, env.observation_space.shape) # (2,) print(动作空间:, env.action_space.n) # 3 print(位置范围:, env.observation_space.low, env.observation_space.high) # 位置范围: [-1.2 -0.07] 速度范围: [0.6 0.07]这段代码先确认环境的基本参数。observation_space.shape返回 (2,)说明状态是两个浮点数。action_space.n返回 3对应左推、不动、右推。low和high分别是位置和速度的下界与上界归一化时会用到。注意 Gym 不同版本 API 有差异新版返回(obs, info)元组老版只返回obs代码里要做兼容。2.2 DQN 的网络结构与经验回放DQN 相比原始 Q-learning 的两个关键改进一是用神经网络替代 Q 表二是引入经验回放Experience Replay打破样本间的时序相关性。网络结构不需要太深MountainCar 状态只有两维两层全连接比如 64-64足够。输出层节点数等于动作数 3。经验回放缓冲区存的是(state, action, reward, next_state, done)五元组。每次训练从缓冲区随机采样一个 batch计算目标 Q 值时用目标网络Target Network来算避免目标值频繁变动导致训练不稳定。import torch import torch.nn as nn import random from collections import deque class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden64): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim) ) def forward(self, x): return self.net(x) 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) state, action, reward, next_state, done zip(*batch) return (np.array(state), np.array(action), np.array(reward), np.array(next_state), np.array(done)) def __len__(self): return len(self.buffer)QNetwork用nn.Sequential堆两层隐藏层激活函数选 ReLU输出层不加激活因为 Q 值可以是任意实数。ReplayBuffer用deque实现设了maxlen自动淘汰旧样本。sample方法随机采样后把五元组拆成五个 numpy 数组方便后续转 tensor。这里有个细节done要转成 float 类型因为后面算 TD 目标时要乘(1 - done)。2.3 目标网络与 ε-greedy 策略的实现目标网络的作用是给 TD 目标提供一个相对稳定的参考。常见做法是每隔 C 步把在线网络的参数复制给目标网络或者用软更新Polyak averaging。MountainCar 这种小环境硬更新每 200 步同步一次就够了。ε-greedy 策略控制探索与利用的平衡。训练初期 ε 设大一点比如 1.0让智能体多探索随着训练推进逐渐衰减到 0.05 左右让智能体多利用学到的策略。class DQNAgent: def __init__(self, state_dim, action_dim, lr1e-3, gamma0.99, epsilon_start1.0, epsilon_end0.05, epsilon_decay0.995): self.action_dim action_dim self.gamma gamma self.epsilon epsilon_start self.epsilon_end epsilon_end self.epsilon_decay epsilon_decay self.online_net QNetwork(state_dim, action_dim) self.target_net QNetwork(state_dim, action_dim) self.target_net.load_state_dict(self.online_net.state_dict()) self.optimizer torch.optim.Adam(self.online_net.parameters(), lrlr) self.loss_fn nn.MSELoss() def select_action(self, state): if random.random() self.epsilon: return random.randint(0, self.action_dim - 1) with torch.no_grad(): state_t torch.FloatTensor(state).unsqueeze(0) q_values self.online_net(state_t) return q_values.argmax().item() def update(self, batch): state, action, reward, next_state, done batch state_t torch.FloatTensor(state) next_state_t torch.FloatTensor(next_state) action_t torch.LongTensor(action).unsqueeze(1) reward_t torch.FloatTensor(reward).unsqueeze(1) done_t torch.FloatTensor(done).unsqueeze(1) q_values self.online_net(state_t).gather(1, action_t) with torch.no_grad(): next_q self.target_net(next_state_t).max(1, keepdimTrue)[0] target_q reward_t self.gamma * next_q * (1 - done_t) loss self.loss_fn(q_values, target_q) self.optimizer.zero_grad() loss.backward() self.optimizer.step() return loss.item() def sync_target(self): self.target_net.load_state_dict(self.online_net.state_dict()) def decay_epsilon(self): self.epsilon max(self.epsilon_end, self.epsilon * self.epsilon_decay)select_action里用torch.no_grad()包住前向传播避免计算图膨胀。update里gather(1, action_t)按动作索引取出对应 Q 值这是 DQN 实现里最容易写错的地方之一——维度对不上会直接报错。target_q计算时乘(1 - done_t)终止状态没有后续奖励。sync_target做硬更新decay_epsilon每回合调用一次。3. 训练脚本与参数调优从零跑通一次完整训练3.1 训练主循环的搭建训练主循环的逻辑是每个 episode 重置环境然后循环执行「选动作 → 执行 → 存经验 → 采样更新 → 同步目标网络」直到 episode 结束。每回合结束后衰减 ε并记录累计奖励。def train(episodes500, batch_size64, sync_every200): env gym.make(MountainCar-v0) agent DQNAgent(state_dim2, action_dim3) buffer ReplayBuffer(capacity10000) total_steps 0 reward_history [] for ep in range(episodes): state env.reset() if isinstance(state, tuple): state state[0] ep_reward 0 done False while not done: action agent.select_action(state) result env.step(action) if len(result) 5: next_state, reward, terminated, truncated, _ result done terminated or truncated else: next_state, reward, done, _ result buffer.push(state, action, reward, next_state, float(done)) state next_state ep_reward reward total_steps 1 if len(buffer) batch_size: batch buffer.sample(batch_size) agent.update(batch) if total_steps % sync_every 0: agent.sync_target() agent.decay_epsilon() reward_history.append(ep_reward) if (ep 1) % 50 0: avg np.mean(reward_history[-50:]) print(fEpisode {ep1}, Avg Reward: {avg:.2f}, Epsilon: {agent.epsilon:.3f}) return agent, reward_history这段代码里env.step的返回值做了版本兼容处理新版 Gym 返回五元组老版返回四元组。buffer.push时把done转成 float因为后面要参与数值计算。total_steps % sync_every 0控制目标网络同步频率。每 50 回合打印一次最近 50 回合的平均奖励方便观察收敛趋势。3.2 关键参数怎么设学习率、折扣因子与缓冲区大小参数设置没有万能公式但 MountainCar 这个环境有一些经验值可以参考。下面这张表是我反复试出来的几组配置以及它们对应的表现。参数推荐值作用调大/调小的影响学习率 lr1e-3控制网络更新步长太大震荡不收敛太小收敛慢折扣因子 gamma0.99衡量未来奖励重要性接近 1 更看重长期回报缓冲区容量10000存历史经验太小样本相关性高太大占内存batch_size64每次更新采样数太小梯度噪声大太大更新慢epsilon_decay0.995探索衰减速度太快探索不足太慢收敛慢sync_every200目标网络同步间隔太频繁不稳定太慢目标滞后学习率 1e-3 配合 Adam 优化器在 MountainCar 上比较稳。gamma 设 0.99 是因为到达目标需要积累多步动量折扣因子太小会导致智能体只看眼前。缓冲区 10000 条经验大概占几十 MB 内存对现代机器毫无压力。batch_size 64 是经典配置显存和速度平衡得比较好。3.3 训练过程监控与收敛判断MountainCar 的奖励是每步 -1所以累计奖励越接近 0 越好。如果 200 步内到达目标累计奖励就是 -200 左右如果一直没到跑到最大步数 200 也是 -200。这里有个坑光看累计奖励可能分不清「快速到达」和「跑满步数」最好同时记录到达目标的步数。def evaluate(agent, episodes10): env gym.make(MountainCar-v0) rewards [] steps_list [] for _ in range(episodes): state env.reset() if isinstance(state, tuple): state state[0] done False ep_reward 0 steps 0 while not done: with torch.no_grad(): state_t torch.FloatTensor(state).unsqueeze(0) action agent.online_net(state_t).argmax().item() result env.step(action) if len(result) 5: state, reward, terminated, truncated, _ result done terminated or truncated else: state, reward, done, _ result ep_reward reward steps 1 rewards.append(ep_reward) steps_list.append(steps) print(f平均奖励: {np.mean(rewards):.2f}, 平均步数: {np.mean(steps_list):.1f}) return rewards, steps_list评估时把 ε 关掉直接用网络输出的 argmax 选动作这样测的是纯策略表现。steps_list记录每回合用了多少步如果平均步数从 200 降到 150 以下说明策略确实在进步。注意评估阶段也要做版本兼容处理不然换个 Gym 版本就报错。4. 避坑与排查DQN 训练不收敛的五个血泪经验4.1 奖励曲线一直是一条直线现象训练几百回合累计奖励始终在 -200 附近没有任何上升趋势。原因最常见的是经验回放缓冲区还没填满就开始训练或者 batch_size 设得比缓冲区容量还大。另一个可能是 ε 衰减太快智能体还没来得及探索就退化成纯利用而初始策略又很差。解决先让缓冲区积累至少 1000 条经验再开始更新代码里加个if len(buffer) 1000: continue。ε 衰减系数从 0.995 改成 0.998让探索持续更久。检查select_action里随机数生成范围是不是[0, action_dim-1]写成[0, action_dim]会越界。4.2 Q 值越来越大直到溢出现象训练过程中 loss 突然变成 nan或者 Q 值打印出来是几千几万。原因TD 目标计算时没有对done做处理终止状态的 bootstrap 项没被屏蔽导致 Q 值被无限放大。或者奖励没有做归一化MountainCar 每步 -1 虽然不大但累积起来配合高 gamma 也会让目标值偏大。解决确认target_q reward gamma * next_q * (1 - done)里done是 float 类型且终止时为 1。可以在 loss 计算前加torch.nn.utils.clip_grad_norm_(self.online_net.parameters(), max_norm10)做梯度裁剪。奖励如果量级大先除以一个常数缩放。4.3 目标网络忘了同步现象训练初期好像有进步但很快卡在一个次优策略上再也不动。原因sync_target没有在训练循环里被调用或者调用频率太低。目标网络一直用初始随机参数TD 目标就是错的网络学不到正确方向。解决在训练循环里加计数器每 200 步调用一次agent.sync_target()。可以在同步时打印一条日志确认执行了。如果用软更新检查tau是不是设得太小比如 0.001 以下导致目标网络跟得太慢。4.4 状态没有归一化导致网络学不动现象网络输出几乎不变不管输入什么状态选的动作都一样。原因MountainCar 的位置范围 [-1.2, 0.6] 和速度范围 [-0.07, 0.07] 量级差了一个数量级直接喂给网络会让梯度更新偏向位置维度速度维度几乎不起作用。解决在环境封装层做归一化把位置和速度都映射到 [-1, 1] 附近。常见做法是用(state - low) / (high - low) * 2 - 1。归一化后网络对两个维度的敏感度就均衡了。4.5 训练和评估用了不同的环境版本现象训练时看着奖励在涨评估时表现却很差。原因训练用的 Gym 版本和评估用的不一致或者评估时忘了把 ε 关掉导致评估时还在随机探索。解决训练和评估用同一个gym.make调用版本锁定在 requirements 里。评估函数里显式设置agent.epsilon 0或者直接用argmax选动作。如果环境有随机种子训练和评估分别设不同种子避免评估数据泄漏。5. 进阶技巧用 Double DQN 和优先经验回放再压榨一把基础 DQN 在 MountainCar 上跑通之后如果想进一步提升收敛速度和最终表现有两个改动成本低但收益明显的方向Double DQN 和优先经验回放Prioritized Experience Replay。Double DQN 解决的是原始 DQN 的 Q 值高估问题。原始 DQN 在算 TD 目标时用目标网络同时做动作选择和 Q 值评估容易选出被高估的动作。Double DQN 把这两步拆开用在线网络选动作用目标网络算该动作的 Q 值。改动只有一行# 原始 DQN next_q self.target_net(next_state_t).max(1, keepdimTrue)[0] # Double DQN with torch.no_grad(): best_action self.online_net(next_state_t).argmax(1, keepdimTrue) next_q self.target_net(next_state_t).gather(1, best_action)best_action由在线网络选出next_q由目标网络评估。这样即使在线网络对某个动作高估目标网络也会给出更保守的估值。在 MountainCar 上Double DQN 通常能让收敛所需的 episode 数减少 20% 到 30%。优先经验回放的核心思想是不是所有经验都同等重要TD 误差大的样本应该被更频繁地采样。实现上给每条经验算一个优先级p |TD error| ε采样概率正比于p^αα 控制优先级程度0 就是均匀采样。同时要用重要性采样权重修正偏差否则训练会不稳定。class PrioritizedBuffer: def __init__(self, capacity10000, alpha0.6, beta0.4): self.capacity capacity self.alpha alpha self.beta beta self.buffer [] self.priorities np.zeros(capacity, dtypenp.float32) self.pos 0 def push(self, transition, td_error1.0): if len(self.buffer) self.capacity: self.buffer.append(transition) else: self.buffer[self.pos] transition self.priorities[self.pos] (abs(td_error) 1e-5) ** self.alpha self.pos (self.pos 1) % self.capacity def sample(self, batch_size): probs self.priorities[:len(self.buffer)] probs probs / probs.sum() indices np.random.choice(len(self.buffer), batch_size, pprobs) weights (len(self.buffer) * probs[indices]) ** (-self.beta) weights weights / weights.max() batch [self.buffer[i] for i in indices] return batch, indices, weightspush时传入 TD 误差作为优先级依据初始值设 1.0 保证新样本至少被采到一次。sample里weights做归一化最大权重为 1避免更新步长过大。beta通常从 0.4 线性退火到 1.0训练后期偏差修正更充分。这两个技巧叠加使用MountainCar 上大概 300 到 400 回合就能稳定到达目标比基础 DQN 快一倍左右。不过优先经验回放实现复杂度高一些如果只是学习目的先把基础 DQN 跑通再改。从那以后我每次跑 DQN 类项目都强制先跑一遍随机策略看环境奖励基线再开训练不然连「模型到底有没有学到东西」都判断不了。希望帮到你。本文还有配套的精品资源点击获取
返回列表