ARTICLE DETAIL

资讯详情

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

DQN强化学习控制倒立摆:从CartPole仿真到策略迁移全指南

DQN强化学习控制倒立摆:从CartPole仿真到策略迁移全指南 简介基于深度Q网络的倒立摆强化学习控制系统实现包面向强化学习初学者和控制工程研究人员重点解决连续状态空间中的平衡控制策略学习问题。资源包共11个文件主要为3个Python脚本Q网络定义、智能体交互与主训练流程、2个Markdown说明文档、依赖清单txt及若干备份文件整体压缩后仅5KB便于快速下载和阅读。实现依托PyTorch框架与Gym标准环境完整展示了深度Q网络的核心机制经验回放打破样本时间相关性、目标网络抑制价值估计波动、ε-贪婪策略平衡探索与利用并涉及学习率、折扣因子等超参数调优。通过随包的训练日志与网络权重可复现倒立摆稳定平衡的训练过程并观察收敛曲线变化。该方案可作为无人机姿态调节、机器人步态控制等非线性控制任务的算法迁移参考。已有88人学习下载适合希望从代码层面理解深度强化学习落地细节的读者。1. 一个DQN控制倒立摆的系统到底解决的是什么问题摆杆从竖直位置偏了15度传统PID也能拉回来但换一个初始角度、换一根质量不同的杆参数又得重新调。基于DQN的倒立摆强化学习控制系统实现解决的是“不依赖被控对象数学模型、只靠奖励信号让智能体自己学会稳定策略”这件事。它把倒立摆建模成马尔可夫决策过程用深度Q网络拟合Q函数在仿真环境里反复试错最终输出一个端到端的控制策略网络。适合三类人刚入门强化学习、想用一个能跑通的项目练手的工程师做课程设计或本科毕设的学生以及准备把策略从仿真迁移到实物摆杆、先验证算法可行性的硬件玩家。2. 倒立摆控制在强化学习里怎么建模状态空间、动作空间与奖励设计2.1 为什么把倒立摆当成马尔可夫决策过程倒立摆系统通常由一辆在有限轨道上运动的小车和一根通过铰链安装在小车上的杆组成。控制目标是让杆保持竖直同时小车不要滑出轨道边界。从强化学习的角度看这就是一个标准的马尔可夫决策过程智能体观测当前状态施加一个动作环境根据物理规律转移到下一个状态并返回一个奖励信号。做工程的人常纠结于仿真工具——有人用Python加Simulink做强化学习联调有人完全基于gymnasium训练也有人直接用自写的动力学方程自己推演。原理都一样倒立摆的运动方程由拉格朗日方程就可以推出来但落地路径差别很大Simulink的好处是之后容易对接实物模型的电气部分坏处是每次训练要多绕一层接口gymnasium的CartPole-v1环境则是零配置、最省事的选择。我一般建议先用它把DQN算法本身验证通再决定要不要换成自己的物理模型这个顺序可以避免把“算法问题”和“仿真环境问题”混在一起调试。MDP五元组里状态集合S是连续的4维观测动作集合A是离散的2个动作转移概率P由小车质量、杆长、摩擦力等物理参数决定奖励函数R需要人工设计折扣因子γ在DQN里通常取0.99。倒立摆的物理参数在gymnasium里已经被设成一组默认值训练时不需要关心但后面如果要迁移到实物这些参数恰恰是误差的主要来源到第6章我会专门展开。2.2 状态空间和动作空间DQN的前提是离散动作CartPole-v1的观测空间是4维连续向量具体含义和大致范围如下表。速度两个维度没有硬性边界这里给的是工程经验值用来做状态归一化参考。索引物理量含义CartPole-v1 范围0x小车在轨道上的位置-4.8 ~ 4.81v小车速度无硬边界通常在 -5 ~ 52θ杆与竖直方向的夹角-0.418 ~ 0.418 rad约 ±24°3ω杆的角速度无硬边界通常在 -5 ~ 5动作空间是2个离散动作0表示向左推小车1表示向右推小车。为什么DQN要求离散动作因为它的核心输出是Q(s,a)的估计值每次更新都要对下一个状态的所有动作计算max_a Q(s,a)动作空间一旦连续这个最大值就没法直接算了。要处理连续动作就得换DDPG、TD3、SAC那一套演员-评论家架构。所以真实倒立摆如果用电枢电压连续控制常见的做法是先离散成几档比如-10V、0V、10V或者-10、-5、0、5、10五档。档位越多控制越细腻但探索空间按指数增大我建议从2到3档起步跑通后再加密。2.3 奖励函数怎么设计才不容易把网络带偏gymnasium的CartPole-v1默认奖励是“每一步存活1直到倒下”。这个奖励函数最简单也最容易跑通。但要注意它实际奖励的是“延续”不是“竖直”。模型完全可能学出一套左右来回快速摆动、让杆在临界角度附近晃来晃去就是不倒的策略这也是很多人看到reward曲线满分500后部署到实物却一碰就倒的原因。奖励方案公式/规则特点默认存活奖励r 1未终止实现零成本收敛快但策略偏向“磨时间”角度惩罚r -|θ| - 0.01·|x| - 0.01·|v|让策略主动把杆收向竖直位置适合后续迁移分段奖励|θ| 0.1 rad 时 r1否则 r-1信号更强但奖励跳变容易引起训练震荡我一般先用默认奖励把DQN训练到500分证明实现没问题再换成角度惩罚做实体部署前的验证。奖励scale对DQN非常敏感reward整体多乘10倍等效于学习率放大了10倍训练曲线会立刻开始震荡。如果发现reward曲线很毛糙第一反应不是改网络结构而是把奖励值域压到[-1,1]附近。2.4 状态归一化让四个量纲别打架倒立摆状态里x范围是±4.8角度只有±0.418速度和角速度大致在±5。这些数值直接拼成输入向量喂给网络大数值维度会主导梯度更新角度维度的微弱变化被稀释网络对杆偏了几度的敏感度反而变低。一个固定的经验缩放就能解决import numpy as np def normalize_state(s): # CartPole-v1 经验缩放输入原始4维观测输出归一化向量 scale np.array([4.8, 5.0, 0.418, 5.0]) return np.clip(s / scale, -1.0, 1.0)这个函数把每个维度除以各自典型边界再截断到[-1,1]保证四个维度量级一致。前两个除数来自观测空间标注范围速度和角速度的5.0是经验值不需要太精确只要把量级拉齐就行。状态维度越多归一化收益越明显机械臂强化学习这类关节角加角速度的高维状态任务里归一化几乎是标配。如果后面自定义了更高维度的状态比如加了杆的角速度滤波值、小车加速度记得把scale列表同步扩展。3. 从Q-Learning到DQN经验回放和目标网络为什么是倒立摆的保命设计3.1 表格式Q-Learning在连续状态上的必然失败Q-Learning的更新核心是Q(s,a) ← Q(s,a) α[r γ·max_a Q(s,a) - Q(s,a)]这个公式本身没问题但它假设Q函数可以用一张表来存。CartPole状态是4维连续向量如果把每个维度粗暴地分成30格总格子数就是30^4810000个每个格子还要为2个动作各存一个Q值表格规模奔着160万项去。更麻烦的是速度的边界在哪根本说不清楚离散化精度也没法保证。分割到20格、每格0.1弧度够不够问题在于状态空间是稀疏的。倒立摆的动态是连续的一条完整轨迹访问过的格子只占整个状态空间极少一部分表格里全是空洞。有人可能会说那只存访问过的格子不行吗不行Q-Learning更新需要查询相邻状态的Q值没访问过的状态没有可用数据。这不只是参数量的问题而是查表方式本身就无法泛化。DQN换了个思路用一个神经网络Q(s,a;θ)来拟合整个Q函数输入连续状态输出各动作的Q值。神经网络天然具有泛化能力相近状态会输出相近的Q值于是不需要遍历所有格子也能对未知状态给出合理估值。这是强化学习数学原理里最核心的跳跃之一从查表到参数化函数逼近。3.2 经验回放把时间相关性打散倒立摆这类单步动态简单的环境很多人觉得经验回放和目标网络可有可无其实这两样才是DQN训练不翻车的保命设计尤其当训练集规模变大后。先讲经验回放。强化学习样本来自一条时间轨迹相邻两步的状态、动作、奖励高度相关小车位置才移动零点几毫米杆角度才偏零点几度。如果样本一进来就立刻拿去梯度更新网络会在一个极小的局部区域反复踩油门对全局状态空间的拟合进度被严重拖慢甚至出现“学一点、忘一点”的灾难性遗忘。经验回放的做法是把最近的transition存进一个固定容量的环形缓冲区每次训练从缓冲区里随机抽一批样本算梯度。随机抽样把近乎复制粘贴的连续样本打散让一批数据尽量来自不同时间片段和不同状态区域梯度方向更接近真实方向。缓冲区容量的选择有讲究。倒立摆这种低维环境50000到100000比较合适太小样本多样性不够太大缓冲区里堆太多早期垃圾经验网络每步都在跟过期数据较劲。用双端队列实现最省事from collections import deque # 核心逻辑超容量自动淘汰最老样本 self.memory deque(maxlen50000)缓冲区本质上是一个时间维度上的权衡容量一旦定下来整个训练过程中不需要再调整。如果发现训练后期reward曲线开始倒退先把容量减小一半试试往往比调学习率更直接。3.3 目标网络给更新目标一个“镇定器”Q-Learning的更新目标里有max_a Q(s,a)如果这个Q值来自正在被训练的网络自己就形成了一个会跑的靶子网络参数一变目标也跟着变梯度下降的终点一直在移动训练自然震荡甚至发散。目标网络是一个冻结的副本不参与梯度更新只负责计算target让目标值在一段时间内保持稳定。实现上两种常见做法。硬更新是每隔C步把当前网络参数整体复制给目标网络简单直接但每次复制都会让目标值跳变一次软更新是每一步都按比例混合θ_target ← τ·θ_current (1-τ)·θ_targetτ取0.005或0.001目标网络平滑地追赶当前网络。倒立摆这种训练步数不长的小任务我推荐软更新原因就一个省去调C步的麻烦训练曲线更平滑。τ太大会让目标跟着当前网络快速跑相当于没镇定τ太小则目标网络追得太慢早期训练进度被拖累。0.005是个很稳的起点。一次梯度更新的完整计算流程是从缓冲区采样一批经验用当前网络对每个状态取动作对应的Q值用目标网络对下一状态算最大Q值然后拼出目标# 目标值真正终止时(1-done)才会清零未来回报 q_target rewards gamma * max_q_next * (1.0 - terminated)最后一个(1-terminated)乘数很关键。如果是真正的物理终止比如杆倒了未来回报是0target就只剩当前reward如果只是步数截断未来回报必须折现加进来。忽略这个细节网络会在每个终止点都去预测一个虚高的未来回报训练出来的策略会变得畏手畏脚。提示gymnasium的step返回五元组terminated表示物理终止truncated表示步数截断。这两者一定要分开处理具体坑在第5章展开。4. 用Python落地一套DQN倒立摆控制器Gymnasium环境与训练代码4.1 先把环境跑起来CartPole-v1与接口检查依赖只需要三个torch负责网络与优化器gymnasium提供仿真环境numpy处理数值计算。安装后先验证环境接口pip install numpy torch gymnasium python -c import gymnasium as gym; egym.make(CartPole-v1); print(e.observation_space); print(e.action_space); print(e.spec.max_episode_steps)这段命令会输出观测空间Box(4,)、动作空间Discrete(2)、最大步数500。如果你在参考代码里看到env.step(action)返回4个值那是老版本gym的接口建议统一迁移到gymnasium终止和截断的逻辑会清楚很多。环境对象用完后记得env.close()释放资源训练脚本长跑时尤其重要不然后台会积累渲染进程。训练模式千万别加render_modehuman否则每步做一次图形渲染训练速度会掉一个数量级。4.2 定义DQN网络状态进Q值出网络输入是4维状态输出是2个动作的Q值。隐层宽度选128两层ReLU。倒立摆状态维度低这个容量完全够换到真实倒立摆、状态维度加了编码器读数和历史帧可以扩到256或加第三层。import torch import torch.nn as nn class DQN(nn.Module): # 输入state输出每个action的Q值 def __init__(self, state_dim4, action_dim2, hidden128): 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)输出层没有加激活函数因为Q值可以是任意实数不需要压缩到(0,1)。两个输出节点分别对应“向左推”和“向右推”训练时把动作对应节点的预测值取出来就是Q(s,a)计算下一状态的目标值时对两个节点取最大值就是max_a Q(s,a)。4.3 Agent类动作选择、经验存储、单步更新import random import numpy as np import torch import torch.nn as nn from collections import deque class DQNAgent: def __init__(self, state_dim4, action_dim2, lr1e-3, gamma0.99, tau0.005, buffer_size50000): self.action_dim action_dim self.gamma gamma self.tau tau self.device torch.device(cuda if torch.cuda.is_available() else cpu) self.q_net DQN(state_dim, action_dim).to(self.device) self.target_net DQN(state_dim, action_dim).to(self.device) self.target_net.load_state_dict(self.q_net.state_dict()) self.optimizer torch.optim.Adam(self.q_net.parameters(), lrlr) self.memory deque(maxlenbuffer_size) def choose_action(self, state, epsilon): # epsilon-greedy随机数与阈值比较决定探索还是利用 if random.random() epsilon: return random.randint(0, self.action_dim - 1) with torch.no_grad(): q self.q_net(torch.FloatTensor(state).unsqueeze(0).to(self.device)) return int(torch.argmax(q).item()) def store(self, state, action, reward, next_state, terminated): self.memory.append((state, action, reward, next_state, terminated)) def update(self, batch_size64): if len(self.memory) batch_size: return batch random.sample(self.memory, batch_size) states torch.FloatTensor(np.array([t[0] for t in batch])).to(self.device) actions torch.LongTensor(np.array([t[1] for t in batch])).unsqueeze(1).to(self.device) rewards torch.FloatTensor(np.array([t[2] for t in batch])).unsqueeze(1).to(self.device) next_states torch.FloatTensor(np.array([t[3] for t in batch])).to(self.device) term torch.FloatTensor(np.array([t[4] for t in batch])).unsqueeze(1).to(self.device) # 当前网络输出预测Q值只取实际执行动作那一列 q_pred self.q_net(states).gather(1, actions) # 目标网络输出targetdetach后不参与梯度传播 with torch.no_grad(): q_next self.target_net(next_states).max(dim1, keepdimTrue)[0] q_target rewards self.gamma * q_next * (1.0 - term) loss nn.MSELoss()(q_pred, q_target) self.optimizer.zero_grad() loss.backward() # 梯度裁剪防止单样本异常值把参数推出稳定区 for p in self.q_net.parameters(): p.grad.data.clamp_(-1.0, 1.0) self.optimizer.step() # 软更新目标网络 for p, p_t in zip(self.q_net.parameters(), self.target_net.parameters()): p_t.data.copy_(self.tau * p.data (1.0 - self.tau) * p_t.data) return loss.item()几个参数和设计要解释清楚。学习率lr1e-3是Adam在CartPole这类小任务上的稳妥起点如果训练曲线高频震荡先降到3e-4而不是改网络结构。gamma0.99意味着未来10步的累计回报权重约0.9足够覆盖倒立摆的调节过程。动作选择用了epsilon-greedy训练初期epsilon接近1.0近乎纯随机中后期逐步衰减到0.01。target_net不参与梯度计算所以算target时包了torch.no_grad()。梯度裁剪放在backward之后把每个参数的梯度限制在[-1,1]防止个别离群样本把Q值推向无穷。4.4 训练主循环探索退火与进度输出env gym.make(CartPole-v1) agent DQNAgent() epsilon, eps_min, eps_decay 1.0, 0.01, 0.995 for ep in range(400): state, _ env.reset() ep_reward 0.0 terminated truncated False while not (terminated or truncated): action agent.choose_action(state, epsilon) next_state, reward, terminated, truncated, _ env.step(action) # 注意经验里存的是terminated不是(terminated or truncated) agent.store(state, action, reward, next_state, terminated) agent.update(batch_size64) state next_state ep_reward reward epsilon max(eps_min, epsilon * eps_decay) if ep % 20 0: print(fep {ep}: train_reward {ep_reward:.0f}, eps {epsilon:.3f})这个循环里有两个容易误用的小地方。第一episode结束条件是(terminated or truncated)终止可能是因为杆倒了也可能是因为到达500步上限。后者在训练后期占绝大多数遇到截断直接重置环境即可。第二存入经验时只用terminated因为truncated只表示“这局到长度了”不代表物理失败目标值计算里不应该把未来回报清零。这一步改错模型会在接近500步时学出莫名其妙的防御动作训练曲线看似正常离线评估却忽好忽坏。探索率用每集乘0.995的指数退火400集后大约0.13还保留一定随机性。想更早看到收敛效果可以改成线性退火epsilon max(0.01, 1.0 - ep * 0.003)。要不要在训练中间插评估建议每20集跑一次离线评估只看train_reward会被探索噪声误导训练曲线好看不代表策略真的稳定。4.5 模型保存与离线评估用固定流程保证可比性训练结束后保存权重而不是整个agent对象torch.save(agent.q_net.state_dict(), cartpole_dqn.pth) def evaluate(agent, n_episodes5, max_steps500): rewards [] for _ in range(n_episodes): state, _ env.reset() ep_r 0.0 for _ in range(max_steps): # 评估时仍留1%探索避免贪心策略卡在边界状态 action agent.choose_action(state, epsilon0.01) state, reward, terminated, truncated, _ env.step(action) ep_r reward if terminated or truncated: break rewards.append(ep_r) return float(np.mean(rewards))评估时epsilon取0.01而不是0这是DQN的玄学之一纯贪心策略在边界状态上容易被死角卡死留1%的随机动作反而能摆脱困境。evaluate函数固定n_episodes和max_steps否则两次评估结果不具备可比性。用这个函数每20集测一次会看到reward从30分一路跳到500分那个跳变点就是策略真正成型的位置。5. DQN控制倒立摆的5个踩坑记录现象、原因与解法5.1 训练中期突然发散reward曲线断崖下跌现象前100集reward稳步涨到150之后突然掉回20以下loss同步飙升到1e4以上。原因最典型的有三个。一是学习率偏大Adam修正后的更新步长依然过大参数被一步步推出稳定区域二是软更新参数tau设得太大比如0.1目标网络几乎跟着当前网络同步变化target不断漂移三是把truncated当terminated存进了经验目标值在500步边界被错误清零网络学到一个“第500步假装世界末日”的假规律。解决先固定随机种子把lr降到3e-4、tau降到0.005再加梯度裁剪。如果问题依旧检查store里存的是terminated还是合并后的done。调试顺序上先看loss曲线loss大于1e2基本就能断定目标计算有问题而不是网络容量不够。5.2 训练很久reward就是不涨现象300集跑完reward始终在20到40之间波动像一条平线。原因探索率退火太慢、经验回放缓冲区太大、或者状态没归一化。CartPole只有2个动作随机策略的采样效率其实很低如果100集时epsilon还在0.8以上大量样本来自随机动作有效经验很少。另一个隐蔽原因是reward的取值范围默认存活奖励每步1但如果你改成距离惩罚把单步reward压到-0.001Q值的梯度信号会非常弱。解决epsilon退火节奏改成前200集线性从1.0降到0.05状态归一化必须加上reward先控制在[-1,1]区间做验证。还有buffer_size的问题小任务里50000足够了如果设成100万而总训练步数才几万缓冲区里90%都是早期垃圾经验网络每步都在从过期样本里学自然学不动。5.3 训练曲线好看测试一推就倒现象训练evaluate均值500把初始角度固定成0.2弧度立刻连50分都拿不到。原因策略过拟合到了仿真环境的初始状态分布。CartPole每次reset时杆的角度只在±0.05弧度范围内随机DQN只要学会这一小块领域就能拿满分并没有学会大范围平衡。这是把仿真策略搬到实物前最容易翻车的点实物初始装杆误差可能就有几度还不算手动推杆带来的角速度扰动。解决训练时不要用环境默认的reset包一层Wrapper把初始角度和角速度的随机范围扩大import gymnasium as gym import numpy as np class AngleResetWrapper(gym.Wrapper): # 扩大初始角度与角速度强制策略学会大范围收敛 def reset(self, **kwargs): obs, info self.env.reset(**kwargs) # 修改环境内部的真实状态而不是只改观测 self.env.unwrapped.state[2] np.random.uniform(-0.2, 0.2) # 杆角度 self.env.unwrapped.state[3] np.random.uniform(-0.5, 0.5) # 角速度 obs np.array(self.env.unwrapped.state, dtypenp.float32) return obs, info注意只改obs不改环境内部state智能体看到的角度和物理状态不一致训练出来的是幻觉策略。这里直接操作env.unwrapped.state绕过gymnasium的读取接口在CartPole这种简单环境里是安全的。训练时还可以给动作加10%高斯噪声或者每10步以10%概率重复上一次动作模拟真实执行机构的滞后。凡是加了扰动后分数掉一半以上的策略都说明余量不足。5.4 loss变成nan训练直接崩掉现象跑着跑着loss变成nan后续所有reward也变成nan整个流程废掉。原因绝大多数情况下是状态或奖励里混入了NaN。CartPole默认环境不太可能出现NaN但一旦开始自定义奖励、做状态滤波、或从真实传感器读取数据除零、开方负数、未初始化数组都会引入。另一个少见原因是Q值训练中爆炸到inf然后在反向传播里变成NaN本质还是目标值震荡或学习率过大。解决给数据进网络前加一道断言是最快的拦截手段。在store之前检查assert np.isfinite(state).all() and np.isfinite(next_state).all()update里把梯度裁剪保留再把网络输出做一次性clip兜底q_pred self.q_net(states).clamp(-1e3, 1e3)正常训练完全不受影响。如果加了断言之后才报错说明问题在数据处理上游而不是网络结构。这些“后悔药”平时不显眼但真出问题时能省一晚上排查时间。5.5 同一份代码两次训练结果差一大截现象第一次跑500分第二次同一个脚本只考了80分代码一行没改。原因随机种子没固定。gymnasium的reset、动作探索的random、PyTorch的网络初始化三者各有各的随机源不一起固定就谈不上可复现。换一台机器、换一个torch版本结果也会变但同环境下至少要能复现出趋势。解决在脚本入口统一固定种子import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42) env.reset(seed42)gymnasium环境必须在reset前设置种子才有效最好在make之后立刻调用一次env.reset(seed42)。注意换PyTorch小版本后严格复现仍可能失败这是浮点运算和底层库实现的差异不要纠结到bit级复现。只要能固定参数跑出稳定趋势就足够定位问题了。6. 把DQN策略从仿真搬到真实倒立摆差距与验证技巧仿真里reward拿满500分不代表能直接上实物。真实系统有传感器噪声、电机惯性、电压死区摩擦系数随温度漂移控制频率和环境默认的50Hz也可能不一样。最常见的三个差距是状态量测有延迟、动作执行有滞后、物理参数与仿真不一致。DQN默认假设环境是MDP当前状态足够描述未来、动作立即生效这一点在实物上几乎必然被打破。我的验证习惯是先不急着换实物先给仿真加三样东西训练时随机化初始角度与角速度用上一章的Wrapper给动作加10%随机扰动把环境控制频率调低模拟执行机构跟不上指令的情况。三者都扛住以后再考虑实物。一个非常实用的评估指标是“可容忍扰动范围”固定策略把初始角度从0.05弧度逐步加到0.3弧度看最大能稳定几秒。如果加到0.15弧度就倒说明策略margin很薄上实物基本一推就翻。风力摆这类摆系统的控制也有同样的验证逻辑先测小扰动再测阶跃扰动不要一上来就给大摆角。最后说一个我的个人习惯先把默认CartPole环境跑满500分再花一倍时间做扰动测试和参数域随机化最后才动实物。直接跳过稳健性验证会把大量时间耗在花式返工上。希望帮到你。本文还有配套的精品资源点击获取
返回列表