☰
强化学习作业实战:Python+DQN在CartPole上的智能决策
2026/10/8 7:37:17 网站建设 项目流程

简介:基于Python实现的智能决策技术强化学习作业设计源码,是一份面向高校相关课程学习者的完整项目资料。资源围绕强化学习中的智能决策问题,包含状态值函数更新、策略迭代与动作选择等核心算法实现,并提供测试与解决方案文件,适合正在完成同类作业或希望系统掌握强化学习实践的读者。压缩包共111个文件,以32个Python源文件、24个测试文件、24个solution解决方案文件、14个lay布局文件为主,另含config配置、xml数据文件等,包体约861KB,结构清晰便于按模块学习。已有339人学习下载。通过这份源码可获取一套可运行的强化学习作业代码框架、模块化测试思路与问题处理策略,配套布局及配置文档则帮助理解工程组织方式,有助于从算法原理过渡到完整项目实现。

1. 当“智能决策”落到 Python 代码里:这份强化学习作业到底在做什么

大学里第一次交强化学习作业,十个人里有八个不是卡在公式推导上,而是卡在让训练稳定跑完不翻车。我今天想聊的这份“智能决策技术强化学习作业设计源码”,说白了就是一套用 Python 把强化学习闭环跑通的课程项目:给定一个决策环境,智能体通过试错学到策略,最后把源码、训练曲线和设计文档一起交上去。它适合正在做课程作业、毕设预研或者刚入门深度强化学习算法的人。你可能以为难点在算法的数学推导,实际做下来,真正的坑都在环境接口、训练稳定性、超参数调优这些“黑匣子”里。这篇笔记就按我做这类作业的顺序来写:先把环境搭起来,再让一个最基础的 DQN 算法在一个卡通小车上真正学会平衡,最后告诉你哪些地方值得画进作业报告、哪些地方会把你绊一跤。

2. 从环境到策略:智能决策作业的强化学习框架怎么搭

2.1 为什么作业选 Python + Gymnasium,而不是手推公式

强化学习作业最常见的形态,是让你实现一个能在“标准环境”里跑出结果的算法。十年前大家喜欢自己写格子世界、写迷宫,环境逻辑都堆在一个文件里;现在主流做法是直接用 Gymnasium(OpenAI Gym 的维护分支),它把“环境”和“智能体”的职责拆得很干净。你只需要调用env.reset()拿初始状态,env.step(action)让环境往前走一步,环境就会返回新的状态、奖励和“是否结束”的标记。这一层抽象的价值在于:作业里算法代码和环境代码可以分开提交,老师只需要跑你的智能体文件就行。

我自己做作业时一般不会一上来就手推贝尔曼方程然后直接写公式,而是先跑通最小闭环,再回头补推导。原因很简单:手推公式只能帮你算清楚“理想情况下的更新方向”,但强化学习真正难的是训练过程中的不稳定,比如奖励一直上不去、loss 突然变成 NaN。这些只有跟真实环境交互之后才会暴露。所以框架选型上,我建议直接接受 Gymnasium 的标准接口,把精力留给算法实现。

顺便提一句,安装 Python 环境时很多人会踩到 Gymnasium 和旧版 OpenAI Gym 的混用问题。如果你照着网上老教程的代码,写的是env.reset()返回两个值,在新版 gymnasium 里也是两个值,但语义从obs变成了(obs, info);而旧版 gym 0.21 的reset()只返回obs。最好的做法是新建一个 conda 环境,统一安装gymnasium和pytorch,不要让系统里的全局 Python 背锅。

2.2 最小可跑的决策环境:用 CartPole 把“状态-动作-奖励”闭环跑通

CartPole 是强化学习作业里最常见的入门环境,没有之一。它模拟的是小车顶上竖着一根杆子,智能体只能向左或向右推小车,目的让杆子尽量保持竖直。环境给你的状态是 4 个数:小车位置、速度、杆子角度、角速度,动作空间是 2 个离散动作(左/右)。这个环境的好处是单步推理极快、奖励定义清晰:每坚持一帧就给 1 分,倒下就结束。你的智能决策系统要学的,其实就是一套“看状态选动作”的映射。

先写一个最不智能的随机策略,验证环境本身能跑通:

import gymnasium as gym env = gym.make("CartPole-v1", render_mode="human") obs, info = env.reset() total_reward = 0 for step in range(200): action = env.action_space.sample() # 随机选动作 obs, reward, terminated, truncated, info = env.step(action) total_reward += reward if terminated or truncated: print(f"第 {step} 步倒下,累计奖励 {total_reward}") obs, info = env.reset() total_reward = 0 env.close()

逻辑说明:env.action_space.sample()是从动作空间里均匀随机采样,代码里只用来验证环境能不能正常步进。env.step()返回 5 个值,新版 gymnasium 把这个接口统一了。其中terminated表示环境因为“任务失败/成功”而结束,比如杆子倒了;truncated表示因为时间步数上限或外部条件截断,比如达到了单局最大步数。新手经常把这两个混在一起,条件写成if terminated:,结果 CartPole 在 500 步被截断时不会触发重置,训练循环就卡死了。

2.3 定义策略网络与价值网络:PyTorch 模型的三层结构

作业里真正体现“智能决策”的地方,是用神经网络来做决策。最常见的做法是用一个 Q 网络,输入是状态(4 个浮点数),输出是各个动作的 Q 值(2 个浮点数),表示“在这个状态下选左/右的长期回报期望”。网络本身不需要多深,三层全连接足够,因为 CartPole 的状态维度只有 4。

import torch.nn as nn class QNetwork(nn.Module): def __init__(self, state_dim=4, action_dim=2): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, action_dim) ) def forward(self, x): return self.net(x)

参数说明:中间层 64 是一个保守选择,太少了学不好,太多了在 CPU 上训练反而慢且容易过拟合。forward里的输入x形状是(batch_size, state_dim),输出形状是(batch_size, action_dim)。注意这里没有在最后一层加激活函数,因为 Q 值可以是任意实数,加了 sigmoid 或 tanh 反而把输出范围限制住,影响收敛。

为什么说“策略藏在网络里”?因为对于离散动作空间,策略就是从q_values里取最大值的那个动作,也就是argmax。作业报告里如果老师让你画“决策流程图”,通常也是画这个:状态向量输入网络 → 前向传播 → 得到各动作 Q 值 → 选最大值动作 → 执行。这也是后面 DQN 训练的最小单元。

3. 把“决策”变成“学习”:DQN 算法落地与训练脚本

3.1 DQN 的核心公式与代码对应关系

作业里要求实现的强化学习算法,最常见的是 DQN(Deep Q-Network)。它解决的问题是:当状态空间不是离散几个格子,而是像 CartPole 这样连续 4 维输入时,不能再用表格式 Q-learning 存一张大表,得用神经网络来逼近 Q 函数。这里有一个很多人觉得玄学的地方:DQN 并没有直接“学”一个策略网络,它学的是“价值网络”,策略只是从价值网络里argmax出来的副产品。

DQN 的更新目标来自贝尔曼方程:当前状态动作的 Q 值,等于即时奖励加上下一步最优动作的 Q 值乘以折扣因子。写成损失函数就是:

import torch.nn.functional as F # q_net 是当前要训练的网络,target_net 是固定的目标网络 # 从经验回放里采样的一个 batch:obs, actions, rewards, next_obs, dones current_q = q_net(obs).gather(1, actions) with torch.no_grad(): next_q = target_net(next_obs).max(dim=1, keepdim=True).values target_q = rewards + gamma * next_q * (1 - dones) loss = F.mse_loss(current_q, target_q)

逻辑说明:gather(1, actions)是从 Q 网络输出的所有动作 Q 值里,把当前 batch 里实际执行的那个动作的 Q 值挑出来。target_q的计算使用了target_net而不是q_net,并且包在torch.no_grad()里,防止梯度从这个分支反向传播,这是稳定训练的关键。dones是一个 0/1 张量,1 表示该条经验已经结束,结束状态下没有“下一步”,所以要把未来部分置为 0。

参数说明:gamma是折扣因子,作业里一般取 0.99,表示远期奖励的衰减程度。如果你把gamma设成 1.0,智能体就会把遥远的、不确定的奖励看得和眼前一样重,CartPole 这种没有稀疏奖励的环境容易震荡。

3.2 经验回放与目标网络:两个稳定训练的部件

DQN 比普通 Q-learning 多出来的两个关键设计,作业报告里必须写清楚。第一个是经验回放,把智能体每步的(状态, 动作, 奖励, 下一状态, 是否结束)五元组存进一个队列,训练时从中随机抽一批,打破样本之间的时间相关性。第二个是目标网络,让 TD 目标的计算网络参数在一段时间内保持冻结,避免“目标跟着网络一起变”导致训练发散。

经验回放队列可以直接用collections.deque实现,不用自己写链表:

from collections import deque import random class ReplayBuffer: 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, float(done))) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) states, actions, rewards, next_states, dones = zip(*batch) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)

deque(maxlen=capacity)的好处是容量满了会自动丢弃最旧的数据,非常适合回放池。float(done)把布尔值转成 0/1,方便后面与gamma相乘。采样时用random.sample做不放回抽样,这里不需要太复杂,但如果作业环境是连续动作空间,后面你可能要换成优先经验回放,那就得自己维护权重,先不用管。

目标网络的维护也很简单:每隔 N 步,把q_net的权重复制给target_net。注意不是每步都复制,否则目标网络就成了当前网络的克隆,稳定训练的意义就没了。常见做法是每 100 到 200 步同步一次。

3.3 训练循环怎么写:采样、更新、日志的完整代码

跑通 CartPole 训练的主循环,是这份作业源码的核心。它把环境交互、经验存储、网络更新、日志记录全部串起来。我一般会把训练循环写成一个函数,方便反复调整参数后重跑。

import torch import torch.nn as nn import torch.optim as optim import numpy as np from collections import deque def train_dqn(env_name="CartPole-v1", episodes=1000, lr=1e-3, gamma=0.99, batch_size=64, target_update_steps=100, epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=0.995): env = gym.make(env_name) state_dim = env.observation_space.shape[0] action_dim = env.action_space.n q_net = QNetwork(state_dim, action_dim) target_net = QNetwork(state_dim, action_dim) target_net.load_state_dict(q_net.state_dict()) optimizer = optim.Adam(q_net.parameters(), lr=lr) buffer = ReplayBuffer(capacity=20000) epsilon = epsilon_start step_count = 0 log = [] for episode in range(episodes): state, _ = env.reset() episode_reward = 0 while True: # epsilon 贪心:随机探索与利用结合 if np.random.rand() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): q_values = q_net(torch.FloatTensor(state).unsqueeze(0)) action = q_values.argmax(dim=1).item() next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated buffer.push(state, action, reward, next_state, done) episode_reward += reward state = next_state step_count += 1 # 每步都尝试更新,但只有 buffer 里有足够样本才开始 if len(buffer) >= batch_size: states, actions, rewards, next_states, dones = buffer.sample(batch_size) states = torch.FloatTensor(states) actions = torch.LongTensor(actions).unsqueeze(1) rewards = torch.FloatTensor(rewards).unsqueeze(1) next_states = torch.FloatTensor(next_states) dones = torch.FloatTensor(dones).unsqueeze(1) current_q = q_net(states).gather(1, actions) with torch.no_grad(): next_q = target_net(next_states).max(dim=1, keepdim=True).values target_q = rewards + gamma * next_q * (1 - dones) loss = nn.functional.mse_loss(current_q, target_q) optimizer.zero_grad() loss.backward() optimizer.step() if step_count % target_update_steps == 0: target_net.load_state_dict(q_net.state_dict()) if done: break epsilon = max(epsilon_end, epsilon * epsilon_decay) log.append(episode_reward) if (episode + 1) % 50 == 0: print(f"Episode {episode+1}, reward {episode_reward:.0f}, epsilon {epsilon:.3f}") return log rewards = train_dqn() print("训练完成,最近 10 局平均奖励:", np.mean(rewards[-10:]))

逻辑说明:主循环里最关键的是 epsilon 贪心策略——初始阶段完全随机探索,随着训练推进逐步收敛到利用最优动作。epsilon_decay每局结束后乘一次,所以epsilon是按“局”衰减的,不是按“步”衰减。你会发现这个脚本在 CartPole 上通常 200 局左右就能跑到单局 200 分以上,也就是杆子一直不倒。target_update_steps用全局步数作为计数,避免在局内同步造成目标网络变化太频繁。

参数说明:lr=1e-3是我调过的稳定点。如果调大到一个数量级,loss 曲线会剧烈抖动;调小到 1e-5,你可能会以为算法没效果。batch_size=64对 CartPole 来说足够,如果你的环境状态维度很大,建议跟着增大到 128 或 256。epsilon_decay=0.995对应大约 900 局后 epsilon 从 1.0 降到 0.01 附近,刚好能覆盖整个训练过程。这个脚本里我没加梯度裁剪,因为 CartPole 的 Q 值范围不大,后面换复杂环境时再补。

4. 作业里的“设计文档”部分:如何用图表和指标证明你的策略学会了

4.1 奖励曲线与滑动平均:判断收敛的指标

作业报告里老师最看重的不是你会不会背 DQN 论文,而是你能不能拿出训练曲线说明“策略真的在不断变好”。原始逐局奖励曲线非常毛糙,因为 epsilon 贪心带来的随机性会让单局分数忽高忽低。这时候就要做滑动平均,把最近 N 局奖励取平均,曲线立刻变得平滑,趋势一目了然。

def moving_average(data, window=20): return np.convolve(data, np.ones(window) / window, mode="valid")

用np.convolve实现滑动平均是最省事的写法,window=20表示看最近 20 局的平均。很多作业报告直接贴原始曲线然后说“震荡中上升”,这句话会暴露你根本没统计过。正确做法是同时画两条线:浅色的原始奖励曲线和深色的滑动平均曲线,并在图例里标注窗口大小。

4.2 超参数表:学习率、折扣因子、epsilon 怎么设

作业报告里如果只放一张训练曲线图,评委大概率会追问一句“你这些超参数怎么定的”。所以我会在报告里放一个超参数表,把每个参数的角色和最终取值说清楚。默认可以这样列:

参数取值作用影响
lr0.001Adam 优化器学习率太大会震荡,太小收敛慢
gamma0.99折扣因子越大越重视长期回报
epsilon_start1.0初始探索概率1.0 = 完全随机探索
epsilon_end0.01最小探索概率保证后期仍有少量探索
epsilon_decay0.995每局探索衰减速率衰减越快,利用越早
batch_size64每次更新采样的经验条数影响梯度稳定性
target_update_steps100目标网络同步间隔间隔太短易发散
buffer_capacity20000回放池容量太旧的经验不一定有益

这张表既是给你自己调参用的,也是给老师看的“设计依据”。注意不要只写“默认值”,最好在表后面加一句:我对比了lr=0.001和lr=0.01,前者训练曲线更平滑,所以选了前者。这种“对比测试”的表述在作业评分里特别加分。

4.3 把训练过程可视化:用 matplotlib 画置信区间曲线

单次训练跑出来的曲线有运气成分,我今天跑出 200 分,你换个随机种子可能就卡在 100 分。所以在作业里,我会用多个随机种子(比如 5 个)各训练一遍,把每一局的奖励按“种子”对齐,然后统计均值和标准差,画一个带置信区间的曲线。这一步被问到的概率极高,术语叫“多次实验的置信区间曲线”。

import matplotlib.pyplot as plt # runs: shape (num_seeds, num_episodes) 的奖励矩阵 def plot_confidence_curve(runs, window=20, label="DQN"): means = [] stds = [] for ep in range(runs.shape[1]): ep_rewards = runs[:, ep] means.append(ep_rewards.mean()) stds.append(ep_rewards.std()) smooth_means = moving_average(means, window) smooth_stds = moving_average(stds, window) x = np.arange(len(smooth_means)) plt.plot(x, smooth_means, label=label) plt.fill_between(x, smooth_means - smooth_stds, smooth_means + smooth_stds, alpha=0.3, label=f"{label} ±1std") plt.xlabel("Episode") plt.ylabel("Average Reward") plt.legend()

代码说明:fill_between的上界和下界分别是均值减/加标准差,alpha=0.3让阴影区域半透明,这样能看清曲线主体。为什么要画 std 而不是标准误?因为作业里展示“多次实验的稳定性”比“均值的置信度”更直观,评审也想看你算法在不同初始条件下的方差。如果阴影带特别宽,就说明你对超参数太敏感,需要回头找更强的稳定措施,比如梯度裁剪或延长目标网络同步间隔。

5. 避坑与常见问题:DQN 训练翻车时的 5 个排查点

5.1 现象:奖励一直不涨,训练曲线像心电图

原因:epsilon 没有按预期衰减。常见情况是epsilon的更新写在了step里而不是episode里,导致每个 step 都乘decay,几百步后探索率就降到 0.01,智能体变成纯利用早期学到的劣质策略,再也没法探索新动作。还有一种情况是epsilon_decay设得太小,比如 0.9,探索率下降过快,训练变成“盲目利用”。

解决:把 epsilon 衰减放在每个 episode 结束后执行,并打印出来确认它真的在下降。我自己的血泪经验是:至少每 50 局打印一次epsilon,如果训练 200 局后它已经小于 0.1,说明衰减速度合理;如果还是 0.9,说明衰减逻辑没生效。

5.2 现象:loss 一开始很小,训练过程中突然变成 NaN 或暴涨

原因:梯度爆炸,通常是因为目标 Q 值的计算方式有误,或者学习率太大。CartPole 的奖励范围很小,一般不应该是数值问题,但如果你把gamma设成 0.999,并且环境单局很长,Q 值积分会越来越大,梯度也跟着大。

解决:立刻在optimizer.step()前加nn.utils.clip_grad_norm_(q_net.parameters(), max_norm=10),这一步能兜住大多数梯度爆炸。同时检查target_q里有没有把dones漏乘(1 - dones),如果漏了,结束状态的 Q 值会被错误地叠加下一步奖励,训练中期就会出现奇异的 spike。

5.3 现象:训练曲线上升得很漂亮,但测试时智能体表现很随机

原因:你在测试时忘了关闭探索。q_net(states).argmax是在训练循环内部用的,如果测试代码也用了 epsilon 贪心,比如if random < epsilon: sample(),那必然会有随机动作。另一个原因是你测试的环境渲染模式render_mode="human"导致每一步都在等待窗口刷新,但不影响策略本身。

解决:写一个独立的测试函数,推理时强制with torch.no_grad(): action = q_net(state).argmax().item(),完全去掉随机采样。测试时不更新网络、不存回放、不衰减 epsilon。如果测试平均奖励大于训练曲线的滑动平均,那就说明你训练时确实被探索拖累了,这是正常的。

5.4 现象:gym.make报错 “Version v1 not found” 或者reset()报错

原因:环境版本与 gymnasium 库版本不匹配。CartPole-v1 是从 0.26 版 gym 开始被保留的,但如果你用的是旧版gym==0.21,reset()只返回一个值;如果你用的是gymnasium但把render_mode传给了旧版gym.make,也会报错。还有一个常见问题:你的环境里安装了gym和gymnasium两个包,import 的时候冲突。

解决:把所有依赖固定在一个文件里,先用 conda 新建环境,然后执行pip install gymnasium==0.29.1 torch==2.1.2这类明确版本号。不要用pip install gym,因为那可能装回旧版 OpenAI Gym。如果你的作业要求用旧版gym,那env.reset()返回的是obs而不是(obs, info),代码要按旧 API 写。这里没有万能解药,按你的实际安装版本选择。

5.5 现象:拿到老师的源码包,发现缺requirements.txt、跑起来报 ModuleNotFoundError

原因:这是作业源码最常见的“坑”——作者在自己机器上装了很多包,但打包时没固化依赖。你拿到手直接运行,缺少torch、gymnasium、numpy、matplotlib就会立刻停下来。

解决:拿别人的源码,永远先问三件事:Python 版本、依赖清单、目标环境。如果源码里没有requirements.txt,自己创建一个,里面至少写torch、gymnasium、numpy、matplotlib。安装不要污染全局环境,conda create -n rl python=3.10然后conda activate rl再pip install -r requirements.txt,这一整套流程本身就是作业源码复现能力的一部分。

6. 从作业到真实项目:把 DQN 换成 PPO 的迁移路线与验证技巧

作业交完后,如果你打算把这个项目往更深的方向延伸,最常见的路径是把 DQN 换成 PPO。DQN 只能做离散动作空间,而真实场景里机械臂、游戏、自动驾驶很多都是连续控制,这时候策略梯度家族更实用。做迁移不是从零写,而是先理解两者在接口层面的差异:DQN 输出的是每个离散动作的 Q 值,然后用argmax选动作;PPO 直接输出动作分布(比如高斯分布的均值和方差),再根据优势函数更新策略。你不需要自己实现全套 PPO,但可以用 stable-baselines3 的现成实现,让你自己的环境能跑起来,再用前面说的置信区间曲线方法对比两者。

我的习惯是:先保留 DQN 的train_dqn作为 baseline,然后写一个调用 PPO 的脚本,让它们在同一个环境、同一个随机种子下训练同样多的步数,最后把两条滑动平均曲线画在同一张图里。验证技巧有三条:第一,每个算法至少跑 5 个随机种子,否则分不出性能差异是算法还是运气;第二,测试时不带探索,用deterministic=True或者直接取策略均值;第三,比较“达到目标分数的最少训练步数”而不是只比较最终分数,因为很多时候 PPO 的最终分数更高,但 DQN 更快达到及格线。我自己当年做这个对比时,发现 DQN 在 CartPole 上其实比 PPO 更早达到 200 分,但 PPO 的阴影带更窄、方差更小,写进报告里就有说头了。

说到底,强化学习作业源码的价值不在代码量,而在你能不能把一个算法从“能跑”变成“可控”。保存一份带固定随机种子、固定依赖的版本,再留一份带对比实验的分析脚本,这份作业才算真的有复现价值。希望这些踩过的坑能帮到你以后不再被“环境报错”和“loss 起飞”逼到删代码重来。

提示:训练 DQN 前先备份一个“最小可运行版本”,每次改超参数都另存一份。强化学习训练失败的常客就是“改着改着跑不起来,却不知道是哪一次改动造成的”。有后悔药,才能大胆实验。

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

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

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

立即咨询