☰
从零实现DQN强化学习智能决策作业:Python源码与调参指南
2026/9/28 2:37:31 网站建设 项目流程

简介:这是一份基于Python实现的智能决策技术强化学习作业设计源码,面向机器学习初学者和高校学生,也适合需要完成强化学习课程设计的开发者;资源通过智能体与环境的交互示例,清晰地呈现了状态值函数更新、策略迭代优化与动作选择机制等关键环节的代码实现,是课程作业或入门实践的良好范本。资源共111个文件,压缩包仅861KB,以32个Python源文件为主线,构成实现强化学习算法的核心代码;24个测试文件用于模块化验证算法正确性与整体运行效率,24个解决方案文件提供算法调优、异常处理和性能优化思路,14个布局文件规整程序结构,8个配置文件与5个XML文件承担参数管理、数据存储等职责,另有版本信息、Git忽略规则等辅助文件,支持项目持久维护与版本控制,便于学习者快速定位所需模块。目前已有339人学习,可直接复用全套项目骨架、源码和测试用例,也可参考其清晰的目录结构来规划自己的实验项目;既有助于深入理解智能决策原理,又能快速掌握将强化学习算法转化为可运行工程的方法。

1. 智能决策强化学习作业源码:为什么我建议用 Python 从零搭一遍

基于Python实现的智能决策技术强化学习作业,核心不是「跑通一个 demo」,而是让智能体在一个连续决策问题里真的学会做选择。课程验收看的是源码结构、训练过程和决策效果,不是看你 clone 了哪个仓库。我见过不少同学把 GitHub 上的大工程拉下来改个参数就交,结果答辩换一个随机种子就翻车,被问「这行代码在干嘛」时答不上来。反直觉的结论是:从零写一套最小实现,反而比改别人的大工程省时间——因为你清楚每个变量的作用,调试才有方向。这篇笔记面向正在做强化学习课设、或者想把 RL 从理论推进到跑通的人,按「任务定义 → 算法实现 → 调参 → 排查」推进,给出一套能直接复现的 Python 源码方案。

2. 把智能决策任务翻译成 MDP:状态、动作、奖励这样定才不会返工

强化学习作业里七成以上的返工,不是算法写错,是问题定义没定清楚就写代码。智能决策问题的标准建模方式是马尔可夫决策过程(MDP),一套 MDP 由状态空间、动作空间、奖励函数、状态转移和折扣因子组成。作业题往往只给你一句「让智能体学会保持平衡」或「让智能体走到目标点」,怎么把它翻译成可计算的四元组,直接决定后面算法的收敛难度和演示效果。这一章先讲清楚任务定义阶段怎么少走弯路,再给出一段环境封装的参考代码。

2.1 一个适合做作业的决策任务长什么样

选任务时优先级应该是:状态维度低、动作是离散的、奖励信号频率适中、可视化直观。CartPole(倒立摆)是我最推荐的作业载体——状态只有 4 个数字(位置、速度、角度、角速度),动作只有 2 个(左推、右推),渲染出来一眼能看出智能体在干嘛。相比格子世界(Grid World),CartPole 的决策频率更高,能体现强化学习「根据连续状态做实时决策」的特点;相比机械臂或自动驾驶,它又不需要处理高维图像输入,不会让作业的难点跑偏到工程实现上。

如果你作业题目限定在某个业务场景,比如库存补货、路径规划、订单调度,思路也是一样的:先从业务里抽出「每一步看什么」「每一步能做什么」「做完得到什么反馈」,再映射到状态、动作和奖励。业务越复杂,越要砍掉跟决策无关的变量。常见错误是状态里堆了十几个业务字段,动作却只有一两个,算法需要大量样本才能学会忽略无关维度,在课设的训练时长里基本学不出来。

2.2 动作空间与奖励塑形的三个常见误用

第一个误用是拿 DQN 去处理连续动作。DQN 通过 argmax 在有限动作里选最优,动作必须是离散的。如果你的决策问题里动作是连续值(比如油门开度、转向角度),要么把动作离散化成几个档位,要么换用 DDPG、PPO 这类策略梯度算法。课设场景下我建议先离散化,训练开销小,曲线也好看。

第二个误用是奖励塑形过度。为了让智能体早点学会,有人会给「离目标越近奖励越大」的稠密奖励,结果智能体学会了原地打转——因为打转能稳定拿到「接近目标」的奖励但不承担「必须到达」的责任。奖励设计的核心是:让智能体最大化累积回报的行为,恰好就是你希望它做出的行为。稀疏奖励难学,但至少不会诱导出钻空子的策略。如果非要加中间奖励,加「首次达成某个里程碑」的一次性奖励,比加连续的距离奖励更安全。

第三个误用是忽略终止状态对 Q 值计算的影响。很多人把「游戏结束」当成普通转移存进经验池,计算 target 时仍然加上下一状态的预估 Q 值。但终止状态后面没有未来,target 应该直接等于当前奖励,否则 Q 值会被系统性高估。这个细节在代码里只差一行,却是训练崩盘的高频原因,第 5 章会专门展开。

2.3 从 MDP 到代码:环境接口与采样循环的对应关系

无论用什么库,环境接口都遵循同一套约定:reset 返回初始状态,step 接收动作、返回下一状态、奖励、是否终止以及额外信息。下面这段代码把环境包装成统一的 MDP 接口,同时打印每个时间步的转移数据,方便你确认任务定义是否符合预期。

import gymnasium as gym class MDPWrapper: """把 gym 环境包装成 MDP 四元组 (S, A, R, S') 的采样接口""" def __init__(self, env_name="CartPole-v1", max_steps=500): self.env = gym.make(env_name) self.max_steps = max_steps def reset(self): state, _ = self.env.reset() self.steps = 0 return state def step(self, action): # terminated 表示回合因失败/成功结束,truncated 表示因步数上限结束 state_next, reward, terminated, truncated, _ = self.env.step(action) self.steps += 1 # 步数上限导致的结束,对智能体来说也是“这局结束了” done = terminated or truncated or (self.steps >= self.max_steps) return state_next, float(reward), done def action_space_n(self): """返回离散动作总数,DQN 输出层维度要用""" return self.env.action_space.n def state_dim(self): """返回状态向量维度,DQN 输入层维度要用""" return self.env.observation_space.shape[0] if __name__ == "__main__": mdp = MDPWrapper() state = mdp.reset() for t in range(5): action = 0 # 先用固定动作验证接口 state, reward, done = mdp.step(action) print(f"t={t} state={state} reward={reward} done={done}")

这里的关键是把 terminated 和 truncated 合并成统一的 done。Gymnasium 从 0.26 起把回合结束拆成两个标志:terminated 代表任务本身结束(杆倒了),truncated 代表超过步数上限被人为掐断。对 DQN 的经验回放来说,这两者在「是否需要计算下一状态 Q 值」这件事上是一致的——都是回合结束,不需要 bootstrap。初学者只处理 terminated 而忽略 truncated,会导致智能体永远学不到「坚持到时间耗尽也是一种成功」。

此外要注意 reward 统一转成 float,很多环境返回 numpy 浮点数,转成 Python float 可以避免后面计算 target 时出现数据类型不一致的问题。max_steps=500 是 CartPole 的常见配置,它同时定义了「最优策略下单回合最大回报」,方便后面判断训练是否收敛。

3. 用 Python 从零搭 DQN 最小实现:两个类加一个训练循环就能跑

作业的核心算法选型,我建议 DQN(Deep Q-Network)。它对应经典的 Q-learning 算法思路,但用神经网络代替 Q 表,是深度强化学习算法里最容易自证收敛的一个:你只需要看 Q 值曲线和回合回报曲线就能讲清楚学习过程,不需要像 PPO 那样理解重要性采样和裁剪项。这一章给出一套最小可跑的 DQN 源码,两个类负责模型和记忆,一个循环负责训练。

3.1 为什么选 DQN:作业场景下它最容易自证收敛

DQN 的直觉一句话能说清:用一个神经网络去拟合 Q(s, a)——「在这个状态下做这个动作,未来累计回报的期望是多少」。每一步用当前网络选出最优动作,把「状态、动作、奖励、下一状态」存进回放池,再随机抽样更新网络。相比 Q-learning 的表格式实现,DQN 用神经网络做泛化,状态稍微变化也能输出合理的 Q 值,这正是智能决策需要的:见过的状态附近的新状态,也能给出可靠决策。

课设场景选 DQN 还有一个务实理由:它的失败模式已经被前人摸得很透,网上能查到的排查经验最多。你用 PPO 跑崩了,可能得同时检查广义优势估计、策略熵、裁剪系数三个环节;而 DQN 跑崩了,优先查的就那几样——回放池、target 网络更新、奖励数值范围。对需要按期交作业的人来说,失败模式简单、可排查,比算法先进更重要。

3.2 QNet 与 ReplayBuffer:两个类的职责与边界

下面定义两个核心类。QNet 负责把状态向量映射成每个动作的 Q 值;ReplayBuffer 负责存储历史转移并随机采样,打破相邻样本之间的相关性。

import random from collections import deque import numpy as np import torch import torch.nn as nn class QNet(nn.Module): """三层全连接网络:状态 -> Q值""" def __init__(self, state_dim, action_dim, hidden_size=128): super().__init__() self.fc = nn.Sequential( nn.Linear(state_dim, hidden_size), nn.ReLU(), nn.Linear(hidden_size, hidden_size), nn.ReLU(), nn.Linear(hidden_size, action_dim) ) def forward(self, x): return self.fc(x) class ReplayBuffer: """经验回放池:存 (s, a, r, s', done),随机采样小批量""" def __init__(self, capacity=10000): self.buffer = deque(maxlen=capacity) 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.array, 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)

QNet 的网络宽度 hidden_size=128 是课设场景的折中选择:两层 128 维隐藏单元足够拟合 CartPole 这种低维状态,训练速度也快。如果你换到更复杂的环境,先把 hidden_size 提到 256,而不是急着加深层数——深度在这里收益不大,宽度对拟合能力的提升更直接。

ReplayBuffer 用 deque(maxlen=capacity) 实现,容量满了会自动丢弃最旧的样本。capacity=10000 意味着智能体只从最近 1 万步的经验里学习。这个值不是越大越好:太大会混入大量旧策略时期的数据,导致当前策略学不到新经验;太小则样本多样性不足,训练方差变大。我一般把 capacity 设为「单回合最大步数 × 目标回合数」的两倍左右。

sample 方法里把数据统一转成 PyTorch 张量,action 用 LongTensor 是为了后续做 gather 索引,reward 和 done 用 FloatTensor 是为了直接参与算术运算。这些类型转换看起来繁琐,但能避免一大批运行时错误,属于「写一次省心一路」的做法。

3.3 训练循环里的 4 个关键设计:target 网络、epsilon、batch、衰减

训练循环是这套源码的心脏。下面这段代码包含 DQN 收敛必需的三个组件:经验回放、target 网络、epsilon 贪心探索。

def train_dqn(env, episodes=300, batch_size=64, gamma=0.99, lr=1e-3, epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=0.995, target_sync_interval=5): state_dim = env.state_dim() action_dim = env.action_space_n() q_net = QNet(state_dim, action_dim) target_net = QNet(state_dim, action_dim) target_net.load_state_dict(q_net.state_dict()) # 初始同步 optimizer = torch.optim.Adam(q_net.parameters(), lr=lr) buffer = ReplayBuffer(capacity=10000) loss_fn = nn.MSELoss() episode_rewards = [] for episode in range(episodes): state = env.reset() total_reward = 0.0 # epsilon 随回合指数衰减,从几乎全探索过渡到几乎全利用 epsilon = max(epsilon_end, epsilon_start * (epsilon_decay ** episode)) while True: if random.random() < epsilon: # 探索:随机选动作 action = random.randrange(action_dim) else: # 利用:取当前 Q 值最大的动作 with torch.no_grad(): q_values = q_net(torch.FloatTensor(state).unsqueeze(0)) action = int(q_values.argmax(dim=1).item()) next_state, reward, done = env.step(action) buffer.push(state, action, reward, next_state, done) state = next_state total_reward += reward if len(buffer) >= batch_size: s_b, a_b, r_b, s2_b, d_b = buffer.sample(batch_size) # 当前网络输出:选中的动作对应的 Q 值 q_pred = q_net(s_b).gather(1, a_b) # target 网络输出:下一状态的最大 Q 值,不反传梯度 with torch.no_grad(): q_next = target_net(s2_b).max(dim=1, keepdim=True)[0] # done=1 时下一状态不存在,target 直接等于当前奖励 q_target = r_b + gamma * q_next * (1 - d_b) loss = loss_fn(q_pred, q_target) optimizer.zero_grad() loss.backward() optimizer.step() if done: break # 每隔若干回合同步一次 target 网络 if episode % target_sync_interval == 0: target_net.load_state_dict(q_net.state_dict()) episode_rewards.append(total_reward) if episode % 10 == 0: print(f"episode={episode} reward={total_reward:.1f} " f"epsilon={epsilon:.3f} buffer_size={len(buffer)}") return q_net, episode_rewards

第一处关键是 target 网络。如果只用当前网络计算 Q 值和 target,会出现「自己追着自己跑」的问题:每一步更新都让 target 变一点,Q 值容易发散。这里用一个滞后更新的 target_net 计算目标值,每 5 个回合同步一次参数,让训练目标相对固定,Q 值才稳。target_sync_interval 太小等于没用 target 网络,太大会让学习变慢,5 到 20 之间是经验区间。

第二处关键是 epsilon 的衰减节奏。epsilon_start=1.0 让智能体一开始完全随机探索,epsilon_decay=0.995 意味着每个回合都乘以 0.995,到第 300 回合时 epsilon 约为 0.22。这个衰减速度保证前期充分探索、后期充分利用。如果任务更难,把 decay 调到 0.999,让探索持续时间更长;如果一开局随机动作就能拿到高分,decay 可以加快到 0.99。

第三处关键是 loss 的计算方式。q_pred 只取「实际执行的那个动作」的 Q 值,而不是该状态下所有动作的 Q 值均值——后者会让网络分不清哪个动作好。q_target 里 (1 - d_b) 把终止状态的未来 Q 值清零,这是第 2 章强调的终止处理在代码层面的落地。

第四处关键是梯度下降的稳定性。这里没有用梯度裁剪,是因为 lr=1e-3 在 CartPole 上通常不会导致 loss 爆炸;如果你换环境后发现 loss 冲到几千甚至 nan,优先调小 lr 到 3e-4 或 1e-4,而不是改网络结构。这个排查方向放到第 5 章细说。

4. 作业要能交也要能看:训练曲线、平均回报与 6 个必调超参数

源码能跑只是第一步,作业验收看重的是「你能不能证明算法有效」。一个画得清晰的训练曲线,比十页文字描述都管用。这一章讲怎么把训练过程可视化、哪些超参数值得优先调、作业报告里放哪些指标最有说服力。

4.1 一张能说明问题的训练曲线怎么画

训练曲线至少要包含两条信息:每回合原始回报、滑动平均回报。原始回报波动很大,直接画出来是一条上下乱跳的毛刺线,不体面;滑动平均能展示整体趋势,让人一眼看到「回报在上升、收敛到某个值」。下面这段代码基于第 3 章的 train_dqn 返回值画图。

import matplotlib.pyplot as plt def plot_training_curve(episode_rewards, window=20): """画原始回报 + 滑动平均回报,window 是平均窗口大小""" plt.figure(figsize=(10, 5)) # 原始曲线:浅色细线 plt.plot(episode_rewards, color="gray", linewidth=0.8, label="episode reward") # 滑动平均:深色粗线,反映整体趋势 if len(episode_rewards) >= window: kernel = np.ones(window) / window smoothed = np.convolve(episode_rewards, kernel, mode="valid") plt.plot(range(window - 1, len(episode_rewards)), smoothed, color="crimson", linewidth=2.0, label=f"moving avg (window={window})") plt.xlabel("episode") plt.ylabel("total reward") plt.legend() plt.grid(alpha=0.3) plt.savefig("training_curve.png", dpi=150) q_net, rewards = train_dqn(MDPWrapper(), episodes=300) plot_training_curve(rewards)

np.convolve 是计算滑动平均最简洁的办法,valid 模式保证输出长度比原始序列少 window-1,所以横坐标要从 window-1 开始对齐。window=20 是默认值:小于 20 会让曲线毛刺多,大于 50 会让收敛点看起来比实际晚,答辩时容易被追问。另存为 PNG 而不是直接 plt.show(),是为了把图片嵌进实验报告时分辨率可控,dpi=150 足够打印清晰。

画图前先确认训练真的在学:如果滑动平均线接近水平或贴地,画再好看的图也掩盖不了问题。一个判断技巧是看曲线中段有没有明显的爬坡段——DQN 在 CartPole 上的典型表现是前 50 回合回报在 10 到 30 之间挣扎,随后 50 到 150 回合快速爬升,最后收敛到 500(上限)。如果你的曲线没有爬坡段,直接进第 5 章排查。

4.2 6 个必调超参数的理解与经验区间

超参数是强化学习作业里最耗时间的部分,但不需要网格搜索,按下面的优先级调就能找到可用的组合。优先级排序:学习率 > epsilon 衰减 > 回放池容量 > target 同步间隔 > 折扣因子 > 网络宽度。

参数经验区间调大时的影响调小时的影响课设建议
learning_rate1e-4 ~ 3e-3学得快但容易震荡、loss 爆炸学得慢但曲线平滑先试 1e-3,不收敛再降到 3e-4
epsilon_decay0.99 ~ 0.999探索时间短,可能学不到最优动作探索时间长,前期回报低CartPole 用 0.995
replay capacity5000 ~ 50000样本多样,但混入旧策略数据只学最新经验,方差大10000 起步
target_sync_interval5 ~ 20target 网络更新慢,早期学习慢更新频繁,训练易震荡5 到 10
gamma0.9 ~ 0.99只看短期回报,策略短视看重长期回报,收敛变慢CartPole 用 0.99
hidden_size64 ~ 256拟合能力强但训练慢拟合能力弱,复杂任务学不动先用 128

如果你换了一个更复杂的环境,先调 learning_rate 和 epsilon_decay,这两个参数的敏感度最高;网络宽度反而可以照抄 128,因为大多数课设任务的状态维度不超过几十,128 已经够用。gamma 只在任务本身有长期回报结构时才需要仔细调,CartPole 这类每步都拿奖励的环境,0.99 就是安全的默认值。

4.3 作业报告里的指标怎么选:平均回报、成功率、收敛回合数

训练曲线的横轴是训练过程,只代表「学会了」;报告里还要有评估数据,代表「学得怎样」。我最常被问到的三个指标是:平均回报、成功率、收敛回合数。

平均回报要在确定性策略下评估:把 epsilon 设为 0,每回合都取 argmax 动作,连续跑 20 个回合取平均值。千万别用训练时的回报当评估指标,因为训练过程里 epsilon 还在探索,随机动作会把回报拉低,数据不好看也不真实。

成功率的定义取决于任务:CartPole 可以定义「回报达到 500 的回合比例」,也可以用「在 500 步内未倒下的回合数」。评估代码和训练代码分开写,跑 20 个回合,统计达到满分的比例,一张表写清楚「随机策略 vs DQN」的对比,这比任何文字都有说服力。收敛回合数则取滑动平均曲线首次超过 450 的回合编号,用来证明算法在计算资源有限的情况下也能学会。

def evaluate(env, q_net, episodes=20): """确定性策略评估:epsilon 固定为 0,跑多个回合取平均回报""" rewards = [] for _ in range(episodes): state = env.reset() total = 0.0 while True: with torch.no_grad(): q_values = q_net(torch.FloatTensor(state).unsqueeze(0)) action = int(q_values.argmax(dim=1).item()) state, reward, done = env.step(action) total += reward if done: break rewards.append(total) mean_reward = float(np.mean(rewards)) success_rate = float(np.mean([1 if r >= 450 else 0 for r in rewards])) print(f"eval: mean_reward={mean_reward:.1f}, success_rate={success_rate:.2f}") return mean_reward, success_rate

评估时同样要固定随机种子,否则每次运行结果波动很大,报告里的数字没有可复现性。在 env.reset() 之前调用 env.action_space.seed(0) 或给环境传入 seed 参数,能让每次评估走同一条随机轨迹,这对答辩现场复现尤其重要——不然评委让你现场再跑一遍,结果跟报告对不上,印象分会打折扣。

5. 强化学习作业常见问题排查:5 个翻车点从现象到解决

强化学习调试的难点在于,loss 下降不代表策略变好,策略变好不代表 loss 一定下降。基于这套 DQN 方案,我整理了五个出现频率最高的翻车场景,按「现象 → 原因 → 解决」的格式排开,你可以把第 4 章调参没调通的组合直接拿这一章对照。

5.1 训练半天,奖励曲线一动不动

现象:跑了 100 多个回合,每回合回报始终在 10 到 20 之间,滑动平均几乎水平。

原因通常有三个:epsilon 衰减太快,智能体还没怎么探索就进入利用阶段,随机策略在 CartPole 上本来就只能拿十几个回报;奖励信号没有差异,比如所有状态转移奖励都是 0,智能体无法区分动作好坏;再就是回放池还没填满就开始了训练,早期全是随机数据,网络学不到有效梯度。

解决:先把 epsilon_decay 改到 0.999 左右,让前 100 个回合保持高探索;检查环境 step 返回的 reward 是否真的是非零值,打印几个样本确认;最后在 len(buffer) >= batch_size 前不执行梯度更新(代码里已经做了),并且把 batch_size 之外的学习延迟打印出来确认回放池在持续填充。如果三条都检查过还没改善,把 lr 从 1e-3 提到 3e-3 试一次,排除「学得太慢」这个干扰项。

5.2 训练前期回报在涨,后期突然崩盘

现象:前 150 回合回报一路涨到 400 以上,150 回合之后突然掉到 100 以下,之后再也回不去。

原因:这是 Q 值高估导致的经典症状。DQN 用 max 操作选择动作,天然会高估真实 Q 值;如果 target 网络同步太频繁,误差会不断自我放大,最终把 Q 值推上天,策略跟着崩。另一个常见原因是回放池容量太小,后期智能体已经学会拿到高分,但池子里还堆着大量早期低分样本,抽样时新旧经验比例失衡。

解决:把 target_sync_interval 从 5 改到 20 或 30,让目标值变化更慢;把回放池容量从 10000 提高到 30000。如果还崩,给 loss 加梯度裁剪 torch.nn.utils.clip_grad_norm_(q_net.parameters(), 1.0),在 optimizer.step() 前执行,阻断梯度爆炸路径。这个现象在校验时最容易解释,答辩时就说「这是 Q 值高估,改用 Double DQN 能缓解」,顺便展示你理解了原理。

5.3 loss 变成 nan 或者上千

现象:训练到一半,打印的 loss 从个位数突然变成几千、几万,甚至输出 nan,之后所有 Q 值都是 nan,智能体表现完全随机。

原因:学习率太大导致参数更新幅度过大,Q 值溢出;奖励绝对值太大,比如环境返回奖励是 100 以上,q_target 里 gamma * next_q 加上大奖励直接超出网络表达能力;浮点误差在多层反向传播里积累,触发了数值溢出。

解决:先看奖励量级,如果单步奖励超过 10,把奖励除以一个缩放因子,或者用 reward / 10.0 归一化;然后调低 lr 到 1e-4 重新训练。如果已经出现 nan,没有后悔药,只能重跑,所以建议在训练循环里加一个 loss 检查:if not torch.isfinite(loss): print("loss non-finite at episode", episode) 并提前终止,省得白跑几百回合一张图都画不出来。

5.4 训练时表现好,演示或答辩时翻车

现象:训练曲线显示回报 500,成功率高;但现场演示时智能体动作犹豫、频繁失误,评分明显低于报告里的数字。

原因:训练时用的 epsilon 是衰减后的值,比如 0.01,这意味着每 100 步就有 1 步是随机动作,偶尔会破坏一次完美表现;更常见的是评估环境和训练环境随机性不一致——比如我没有固定随机种子,演示现场一跑就撞上了小概率的失败轨迹。

解决:演示前把 epsilon 显式设为 0,用确定性策略做评估;训练和评估都设置相同的全局随机种子,包括 torch.manual_seed、np.random.seed 和环境的 seed。这里最关键的是评估代码要跟训练代码解耦,我见过有人直接复用训练函数并把 epsilon 参数传 0,结果 env.reset() 每次的初始状态不同,数据照样不稳定。写一个独立的 evaluate 函数,就是第 4 章那个。

5.5 换一个环境后,同样的代码完全学不出来

现象:D2L 能跑通 CartPole,把 env_name 换成 MountainCar-v0 或其它环境后,训练好几百回合回报还是负数,完全学不动。

原因:不同环境的状态尺度差异很大。CartPole 的状态范围大致在 [-1, 1] 附近,而 MountainCar 的位置范围是 [-1.2, 0.6],速度范围是 [-0.07, 0.07],直接把原始状态送进网络,梯度会被大尺度特征主导。其次是奖励结构不同,MountainCar 每步奖励是 -1,是稀疏负奖励,跟 CartPole 每步 +1 的结构完全不同。

解决:第一步给状态做归一化,把原始状态减去均值除以标准差,可以用 running mean 的方式在训练中持续更新统计量,也可以用环境的真实取值范围手动归一化。第二步根据奖励符号调整策略——负奖励环境里要让智能体少做无用功,可能需要减小 gamma 让智能体更看重短期收益。换环境不是改一个字符串的事,状态、奖励、终止条件三样都要重新审视,这也是课程设计里区分「调包」和「理解」的好题目。

6. 把作业升级成可演示的智能决策 Demo:一个值得加练的进阶技巧

如果你想让这份作业从「交得上去」变成「拿得出手」,我建议加一个对比实验:同环境、同种子,跑 DQN 和 Double DQN 各一遍,画两条学习曲线放在同一张图里。这不是炫技,而是用实验结果说明你会诊断 DQN 的病灶——第 5 章提到的高估问题。Double DQN 的改动极小:计算目标时,用当前网络选动作,用 target 网络算 Q 值,两行代码的事。在训练循环里对应改成:

# Double DQN:用 q_net 选最优动作,用 target_net 评估该动作的 Q 值 with torch.no_grad(): best_action = q_net(s2_b).argmax(dim=1, keepdim=True) q_next = target_net(s2_b).gather(1, best_action) q_target = r_b + gamma * q_next * (1 - d_b)

对比实验的结果通常有两种走向:如果 DQN 已经收敛良好,两条曲线差别不大,说明这个任务对高估不敏感;如果 DQN 后期崩盘或评估回报低于 Double DQN,对比图直接解释了 Q 值高估对策略的影响。无论哪种结果,你都能在答辩里讲出一个「我试过、对比过、知道为什么」的完整故事。演示层面,再跑一个固定种子的渲染记录:用确定性策略控制 CartPole 跑 500 步,截取智能体从倾斜到回正的关键帧,配合评估表一起放在报告末尾。

我自己的教训是:当年做课设只跑了 DQN,没固定随机种子,答辩时隔天重跑,曲线形状完全不同,被评委质疑「结果可复现吗」。那次之后我所有实验都固定三重种子——PyTorch、NumPy、环境各设一个。作业能跑通只是及格线,能复现、能对比、能解释,才是智能决策方向上真正值钱的能力。希望这套源码思路和排查清单帮到你直接跑通,少走几晚弯路。

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

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

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

立即咨询