简介使用MATLAB自主搭建深度Q网络算法解决CartPole小车倒立摆平衡问题是面向具备一定编程基础、希望深入理解强化学习核心机制的实用资源。资源完整覆盖了环境建模、神经网络近似Q值、经验回放、目标网络与ε-greedy探索等关键环节适合算法学习者对照理论逐步实现。压缩包共3个文件均为m脚本其中test.m作为整体运行入口CartPole.m定义小车与杆的动力学交互及奖励DQNLearn.m则实现网络训练与参数更新逻辑整体仅4KB结构清晰易于修改调试。该项目已有2178人学习下载通过阅读三段代码读者可掌握从状态空间定义、动作选择到损失计算与梯度下降的完整流程并能在此基础上调整网络层数、学习率、回放缓冲区大小等超参数扩展双DQN、优先回放等进阶方法是搭建自定义强化学习实验的良好起点。1. 用MATLAB手写DQN解决CartPole四维状态空间里最直观的强化学习实验接触强化学习第一周很多人会纠结要不要直接上Python和PyTorch但如果你手里只有MATLABCartPole这个环境其实是最合适的切入点。它状态空间只有4维位置、速度、角度、角速度动作只有左右两个环境动力学用几十行MATLAB就能写完。这篇文章把DQN的三大核心组件——估值网络、经验回放缓冲区、目标网络——逐个拆开给出能直接跑的代码也把训练中最容易翻车的参数调整记录成排查清单。适合课程设计、毕业设计或者想搞懂DQN内部原理而不想直接套RL Toolbox的从业者。完整脚本我整理成了可直接运行的工程下载后按第2章到第4章的代码顺序执行就能出训练曲线。2. DQN原理与CartPole环境建模为什么四维状态两个动作要选神经网络2.1 从Q-learning到DQN连续状态逼你放弃查表Q-learning的经典做法是维护一张Q(s,a)表格但CartPole状态中的x和theta都是连续实数。如果对每个维度切片10个区间状态总数是10^410000个乘以2个动作就是20000个表项这还没算维度升高后的组合爆炸。硬把连续状态离散化还会引入量化误差杆在角度临界点附近来回横跳时离散网格给不出精确的Q值策略会变得非常毛糙。DQN的核心思路是把查表换成函数逼近用一个神经网络f_θ(s)直接输出所有动作的Q值。CartPole动作空间只有2个所以输出层就是2个神经元一个对应向左推的Q值一个对应向右推的Q值。每次从环境拿到真实转移样本用梯度下降让网络输出对齐贝尔曼方程的目标值这样既绕开了离散化误差也让相近状态共享网络参数具备泛化能力。这里有两个稳定性技巧是DQN能work的关键。第一是经验回放训练时不直接用刚采集的连续样本而是从历史transition里随机抽样。连续样本之间存在强相关性会让梯度更新方向频繁抖动回放之后样本分布更接近独立同分布训练曲线明显更平滑。第二是目标网络计算TD target时用另一个网络每隔固定步数才同步一次权重避免“用当前参数更新目标、目标反过来影响当前更新”的自锁振荡。这两个组件在第3章会给出具体MATLAB实现。2.2 CartPole动力学与MATLAB步进仿真CartPole的动力学来自经典的倒立摆模型小车在水平轨道上运动摆杆通过无摩擦铰链连接在小车顶部。agent通过给小车施加向左或向右的推力来控制杆的平衡。状态向量是[x, x_dot, theta, theta_dot]即小车位置、速度、杆偏角和角速度。theta为0表示杆垂直向上允许的最大角度是±12度小车允许的位移范围是±2.4米超出就判定失败。下面是完整的步进仿真函数按OpenAI Gym的CartPole-v0/v1物理参数实现function [nextState, reward, done] cartpoleStep(state, action, params) % 输入: state [x, x_dot, theta, theta_dot] % action 0 向左推 / 1 向右推 % 输出: nextState, reward, done x state(1); x_dot state(2); theta state(3); theta_dot state(4); gravity params.gravity; % 重力加速度 9.8 masscart params.masscart; % 小车质量 1.0 masspole params.masspole; % 摆杆质量 0.1 total_mass masscart masspole; length params.length; % 摆杆半长 0.5 polemass_length masspole * length; force_mag params.force_mag; % 推力幅值 10.0 tau params.tau; % 仿真步长 0.02 force force_mag * (2 * action - 1); % action0 - -10, action1 - 10 cos_theta cos(theta); sin_theta sin(theta); temp (force polemass_length * theta_dot^2 * sin_theta) / total_mass; theta_acc (gravity * sin_theta - cos_theta * temp) / ... (length * (4/3 - masspole * cos_theta^2 / total_mass)); x_acc temp - polemass_length * theta_acc * cos_theta / total_mass; % 半隐式欧拉积分先更新速度再更新位置 x_dot x_dot tau * x_acc; theta_dot theta_dot tau * theta_acc; x x tau * x_dot; theta theta tau * theta_dot; nextState [x, x_dot, theta, theta_dot]; % 终止条件位移超过2.4或角度超过12度 done abs(x) 2.4 || abs(theta) 12 * pi / 180; reward 1; % CartPole每步存活得1分 end注意积分顺序半隐式欧拉先更新速度再用新速度更新位置这比完全显式欧拉更稳定在0.02秒步长下几乎不引入能量漂移。动作到推力的映射用2*action-1action取0时是-10N向左action取1时是10N向右。reward恒为1done标志用于TD target中截断未来奖励。物理参数集中放在params结构体里方便后面做蒙特卡洛实验时批量改。常见的做法是params struct(gravity, 9.8, masscart, 1.0, masspole, 0.1, ... length, 0.5, force_mag, 10.0, tau, 0.02);环境重置函数也很简单位置和角度在小范围内随机初始化避免每次从完全相同的起点出发导致策略过拟合function state envReset() % 位置和角度在正负0.05范围内随机初始化 state [unifrnd(-0.05, 0.05), 0, unifrnd(-0.05, 0.05), 0]; end2.3 网络输入输出维度与隐藏层选择状态是4维向量动作有2个所以网络输入层是4输出层是2。中间用两层全连接就够神经元数量24到32个。为什么不用CNN因为状态是低维稠密向量没有空间局部性卷积操作既浪费算力也难收敛。为什么不用LSTM因为CartPole是完整MDP状态已经包含了所有必要信息不需要用记忆去补观测缺失。我一般会先用24个神经元跑通整个训练流程确认环境、回放、梯度更新都没问题后再改成32个神经元看是否提升。实际测试下来24和32在最终episode reward上差别不大但32个神经元训练速度会慢10%左右。隐藏层激活函数用ReLU输出层不加激活函数因为Q值本身可以是任意实数线性输出才能让梯度顺畅回传。这里有一个容易忽略的细节如果用到featureInputLayer默认会对输入做Z-score归一化但CartPole四个维度的物理含义和量纲差别很大x在±2.4theta在±0.2弧度速度可能在±1数量级。归一化选项建议直接设成none让网络自己去学习尺度因为DQN训练过程中reward分布也在动态变化固定归一化反而会引入偏差。3. 估值网络、经验回放与目标网络DQN三个核心组件的MATLAB搭法3.1 用dlnetwork定义估值网络结构新版MATLAB的深度学习工具箱用dlnetwork管理可学习参数支持自动微分和自定义训练循环。定义网络结构非常直接layers [ featureInputLayer(4, Normalization, none, Name, state_input) fullyConnectedLayer(24, Name, fc1) reluLayer(Name, relu1) fullyConnectedLayer(24, Name, fc2) reluLayer(Name, relu2) fullyConnectedLayer(2, Name, q_output) ]; net dlnetwork(layers);featureInputLayer的4表示状态维度Normalization设为none是上一节说的关键点防止MATLAB默认对输入做归一化。fc1和fc2是两个隐层最后q_output输出2个值对应两个动作的Q值。dlnetwork对象创建后net.Learnables里存放所有权重和偏置。后面计算梯度、用adamupdate更新时都是围绕net.Learnables操作。需要特别注意的是在R2021a之前dlnetwork(layers)会直接初始化随机权重而部分老版本需要先调用initialize函数。如果你在创建net时报“网络未初始化”的名称错误检查一下MATLAB版本和深度学习工具箱版本必要时换成initialize(net)方式。3.2 经验回放缓冲区环形队列的MATLAB实现经验回放的本质是一个环形缓冲区存储(s, a, r, s, done)五元组。采样时均匀随机取出batchSize个transition。容量建议设50000CartPole的episode通常几百步50000能容纳最近近100个episode的经验足够覆盖策略变化过程。classdef ReplayBuffer handle properties capacity % 缓冲区最大容量 buffer % cell数组每个元素是{state, action, reward, nextState, done} head % 下一个写入位置 count % 当前已存储数量 end methods function obj ReplayBuffer(capacity) obj.capacity capacity; obj.buffer cell(capacity, 1); obj.head 1; obj.count 0; end function push(obj, s, a, r, s2, done) % 新transition写入头部覆盖最旧的样本 obj.buffer{obj.head} {s, a, r, s2, done}; obj.head mod(obj.head, obj.capacity) 1; obj.count min(obj.count 1, obj.capacity); end function [sBatch, aBatch, rBatch, s2Batch, doneBatch] sample(obj, batchSize) % 均匀随机采样打破连续样本相关性 sBatch zeros(4, batchSize); aBatch zeros(1, batchSize); rBatch zeros(1, batchSize); s2Batch zeros(4, batchSize); doneBatch zeros(1, batchSize); for i 1:batchSize idx randi(obj.count); % 只从已填充区域采样 t obj.buffer{idx}; sBatch(:, i) t{1}; aBatch(i) t{2}; rBatch(i) t{3}; s2Batch(:, i) t{4}; doneBatch(i) t{5}; end end end end这个类用handle继承引用传递后所有地方共享同一份缓冲区内存避免值拷贝开销。sample函数里randi(obj.count)只采样已填充区域缓冲区没满时不会被空cell拖垮。采样batch的组织方式sBatch是4×B矩阵每个batch样本占一列后续dlarray转换直接标CBChannel-Batch就能喂给网络。aBatch是1×B的值0或1在计算loss时会转成one-hot形式。doneBatch同样0或1用于TD target中乘(1-done)来对终止状态做折扣。3.3 目标网络与硬拷贝同步机制目标网络和目标网络初版可以用copy(net)创建权重完全相同参与前向计算但不参与梯度更新。每隔固定步数把估值网络的当前权重同步过去。targetNet copy(net); % 初始化为与估值网络相同权重 syncFreq 2000; % 每2000步同步一次 % 在训练循环内部 if mod(globalStep, syncFreq) 0 targetNet copy(net); % 硬拷贝 end硬拷贝是最简单的同步方式缺点是同步瞬间TD target会跳变一次导致loss曲线出现一个尖峰。如果这个尖峰导致训练震荡可以把硬拷贝改成软更新每次迭代都做targetNet权重 tau*targetNet权重 (1-tau)*net权重tau取0.005。软更新的效果很多时候更好但实现上要遍历两个网络的Learnables代码量多一些。第一次跑通时先用硬拷贝后续再改软更新。还有一个实战建议如果训练中期发现reward突然下跌优先怀疑是同步频率太高导致的。在目标网络权重还没充分收敛时就频繁覆盖等于把目标函数反复重设网络会一直追着移动的靶子跑。syncFreq从2000调到5000或8000通常能缓解。4. 训练循环与梯度更新epsilon-greedy、TD target与实时收敛曲线4.1 epsilon-greedy策略与衰减调度DQN必须平衡探索和利用。训练初期网络是随机权重Q值毫无意义必须靠随机动作去探索环境收集有效样本。随着训练推进逐渐降低随机概率让策略更多依赖网络输出。epsilon max(epsilonEnd, epsilonStart * (1 - globalStep / epsilonDecaySteps)); % 贪心选择动作 if rand() epsilon action randi([0, 1]); % 探索随机动作 else stateDL dlarray(state, CB); q extractdata(forward(net, stateDL)); % 2×1 Q值向量 [~, actionIdx] max(q); action actionIdx - 1; % 索引1/2转动作0/1 endepsilonStart取1.0epsilonEnd取0.01epsilonDecaySteps设为10000。也就是说前10000步从几乎全随机线性降到1%随机率之后保持1%探索。这个衰减速度对CartPole来说比较标准。如果训练曲线上升太慢把episodeDecaySteps缩小到5000加快收敛如果发现后期reward波动大说明1%的探索率太高改到0.002。注意状态state本身是1×4的行向量喂给dlnetwork时要转成4×1的列向量并标CB。max返回索引1或2对应动作需要减1因为MATLAB索引从1开始而我们定义动作是0或1。4.2 TD target与loss计算dlfeval中的梯度回传核心loss计算封装成独立的函数放在dlfeval里调用这样dlgradient才能正确追踪计算图。这一步是MATLAB实现DQN最容易写错的地方。function [loss, gradients] computeLoss(net, targetNet, sBatch, aBatch, ... rBatch, s2Batch, doneBatch, gamma) % 估值网络前向得到当前状态下每个动作的Q值 stateDL dlarray(sBatch, CB); % 4×B qValues forward(net, stateDL); % 2×B % 取出实际执行动作对应的Q值 q1 qValues(1, :); % 动作0的Q值 q2 qValues(2, :); % 动作1的Q值 actionIdx aBatch 1; % 0/1转为1/2 qSelected q1 .* (actionIdx 1) q2 .* (actionIdx 2); % 目标网络前向计算max_{a}Q_target(s, a) nextStateDL dlarray(s2Batch, CB); nextQValues forward(targetNet, nextStateDL); maxNextQ max(nextQValues, [], 1); % 对动作维度取max % TD target: r gamma * maxQ * (1 - done) tdTarget rBatch gamma * maxNextQ .* (1 - doneBatch); % MSE loss loss mean((qSelected - tdTarget).^2); gradients dlgradient(loss, net.Learnables); end函数参数中sBatch是4×B普通数组aBatch、rBatch、doneBatch是1×B普通数组。进入函数后先转dlarray给网络前向用。这里最关键的是qSelected的提取方式不要把qValues转成extractdata再索引那样会切断梯度。用逻辑乘法保留dlarray类型梯度才能从loss一路传回网络参数。TD target里的maxNextQ来自目标网络目标网络的Learnables不会接收梯度所以这里不需要做任何特殊处理。doneBatch乘上(1-done)之后终止状态的目标值就只剩纯奖励不再加未来回报。调用方式和梯度更新如下[loss, gradients] dlfeval(computeLoss, net, targetNet, ... sBatch, aBatch, rBatch, s2Batch, doneBatch, gamma); % adamupdate六参数版本兼容老版本MATLAB [net, avgGrad, avgSqGrad] adamupdate(net, gradients, ... avgGrad, avgSqGrad, globalStep, learningRate);avgGrad和avgSqGrad初始化为空数组[]由adamupdate内部维护。learningRate取1e-3比较稳妥如果Q值出现抖动就降到5e-4。globalStep是累计训练步数从1开始递增不能在每个episode内重置。4.3 训练主循环与实时曲线监控训练循环是整合以上组件的地方。每个episode重置环境内部按step推进每步执行动作、采集经验、必要时训练网络、周期性同步目标网络并在done后跳出。下面给出可运行的主循环骨架% 超参数设置 maxEpisodes 1000; % 最大episode数 maxSteps 500; % 单episode最大步数 batchSize 64; % 采样batch大小 replayCapacity 50000; % 经验回放容量 gamma 0.99; % 未来奖励折扣率 learningRate 1e-3; % Adam学习率 syncFreq 2000; % 目标网络同步步数 epsilonStart 1.0; epsilonEnd 0.01; epsilonDecaySteps 10000; buffer ReplayBuffer(replayCapacity); globalStep 0; avgGrad []; avgSqGrad []; episodeRewards zeros(1, maxEpisodes); hLine animatedline(Color, b); % 实时绘图 for episode 1:maxEpisodes state envReset(); episodeReward 0; for step 1:maxSteps epsilon max(epsilonEnd, epsilonStart * (1 - globalStep / epsilonDecaySteps)); if rand() epsilon action randi([0, 1]); else stateDL dlarray(state, CB); q extractdata(forward(net, stateDL)); [~, actionIdx] max(q); action actionIdx - 1; end [nextState, reward, done] cartpoleStep(state, action, params); buffer.push(state, action, reward, nextState, done); state nextState; episodeReward episodeReward reward; globalStep globalStep 1; if buffer.count batchSize [sBatch, aBatch, rBatch, s2Batch, doneBatch] buffer.sample(batchSize); [loss, gradients] dlfeval(computeLoss, net, targetNet, ... sBatch, aBatch, rBatch, s2Batch, doneBatch, gamma); [net, avgGrad, avgSqGrad] adamupdate(net, gradients, ... avgGrad, avgSqGrad, globalStep, learningRate); end if mod(globalStep, syncFreq) 0 targetNet copy(net); end if done break; end end episodeRewards(episode) episodeReward; addpoints(hLine, episode, episodeReward); drawnow limitrate; % 限制刷新频率避免阻塞训练 if mod(episode, 10) 0 fprintf(Episode %d: reward %.0f, epsilon %.3f, loss %.4f\n, ... episode, episodeReward, epsilon, loss); end end训练主循环中buffer.count在达到batchSize之前不触发训练通常几十步后经验就够了。drawnow limitrate每0.1秒最多刷新一次画面不会因为绘图拖慢训练。loss变量在第一个训练步之前不存在所以日志打印放在mod(episode,10)分支中且buffer足够大后才有意义。这个框架跑下来一般300到500个episode能见到平均reward超过400不同随机种子差异较大。如果到500个episode还卡在200以下去第5章排查清单里对号入座。5. 训练不收敛的排查清单Q值爆炸、loss不降与五个血泪踩坑记录5.1 loss在降但reward不涨epsilon衰减和目标网络同步在打架现象训练日志里loss从0.3缓慢降到0.02左右但episode reward始终在20到50徘徊看不到上涨趋势。原因loss降但reward不涨代表Q网络对固定目标拟合得很好但策略没有改进。常见原因是epsilon衰减过快训练到中期随机探索率已经降到1%而网络Q值还没有形成“左推比右推好”的区分度。此时argmax选择动作几乎等同于随机。另一个隐蔽原因是目标网络同步频率太高比如每200步就同步一次TD target一直跟着当前网络变网络学到一个处处平滑但对决策没帮助的Q函数。解决把epsilonDecaySteps从10000调到20000延长探索期并且把epsilonEnd从0.01降到0.001。同时把syncFreq从2000步提高到5000步给目标网络足够的时间稳定。5.2 Q值一路飙到上亿量级奖励范围与梯度爆炸现象训练中途打印出的Q值达到1e8loss出现NaN训练曲线直接断掉。原因CartPole的每步奖励恒为1加上gamma0.99在500步封顶的episode里Q值理论最大值也就500左右。Q值飙到上亿说明梯度爆炸把网络参数推到了极端数值。常见直接原因是你把reward改成了线性增长、累积设计了额外奖励或者学习率太高。当然也有可能是第2.3节提到的featureInputLayer默认归一化与物理量纲不匹配导致前向输出在训练早期被放大。解决先把learningRate降到5e-4然后在adamupdate之前做梯度裁剪。MATLAB里裁剪梯度的常见做法是遍历gradients结构体计算全局L2范数超过阈值就等比缩放。阈值取10或20就行。再做一次奖励归一化reward固定为1终止步返回0把最大累计奖励从500压到499避免数值边缘。5.3 训练曲线中期塌方经验回放容量与采样分布失衡现象训练到300个episode时reward已经到400附近但某个episode开始突然跌回100之后再也上不去。原因经验回放缓冲区只有5000容量时早期失败样本占据大头后期高质量的成功样本只占很小比例。随机采样时网络每次更新都在复习大量早期的失败状态相当于把学到的好策略慢慢冲淡。这是DQN里典型的样本分布偏移问题。解决把replayCapacity从10000至少提到50000最好100000。如果内存紧张可以在采样时对近期样本稍微加权比如最后10000步的样本以2倍概率参与采样。更彻底的做法是引入优先经验回放PER用TD误差作为采样权重但这个实现代码量会大不少建议先调容量看看效果。5.4 保存的模型加载后行为不一致dlarray和随机种子问题现象训练结束后用save保存net下次加载后前向预测结果与训练时的eval不一致策略表现变差。原因这不是网络坏了而是MATLAB的random seed在恢复模型时不会自动对齐。如果保存和加载之间执行过其他随机数操作环境中初始状态、epsilon随机数都会不同单次episode表现波动很大。另外如果你只保存了权重数组而没有保存net对象本身加载后忘记重新构建dlnetwork前向计算会出现维度不匹配。解决加载模型后先固定随机种子再跑验证rng(42)。保存时用save(dqn_cartpole.mat, net)保留整个dlnetwork对象加载时用S load(...); net S.net;。验证方法直接看第6章的冻结测试代码不要用单次episode判断模型好坏。5.5 小网络在GPU上反而更慢数据搬运开销盖过计算量现象把训练环境切到GPU后训练速度从每100episode 50秒变成110秒反而慢了一倍。原因一个只有24×24两层全连接的网络前向加反向的计算量极小。用GPU训练时dlarray要在GPU和CPU之间来回搬运cartpoleStep又在CPU上执行每一步都涉及一个GPU同步点。频繁的小矩阵GPU调用搬运开销远大于并行计算收益。解决对CartPole这种小网络直接在CPU上训练最快。在训练循环里不用gpuArraydlnetwork保持默认CPU状态。如果你的实验后续要换成图像输入或更大规模网络再考虑gpuArray(零)... 实际上MATLAB中把dlarray用gpuArray封装后cnn前向会自动走GPU。第一次跑通项目不要在这个环节浪费调试时间。6. 冻结测试与Double DQN改法验证Q值质量的两个实用技巧训练结束后第一步不是看训练曲线而是做一次冻结测试。把网络的探索彻底关掉固定随机种子连续跑100个episode统计平均奖励。这一步能过滤掉训练曲线里偶尔出现的运气成分判断策略是否真正收敛。简单实现如下rng(42); testRewards zeros(1, 100); for i 1:100 state envReset(); totalR 0; for step 1:500 stateDL dlarray(state, CB); q extractdata(forward(net, stateDL)); [~, actionIdx] max(q); action actionIdx - 1; [state, reward, done] cartpoleStep(state, action, params); totalR totalR reward; if done, break, end end testRewards(i) totalR; end fprintf(平均测试奖励: %.1f, 中位数: %.1f\n, ... mean(testRewards), median(testRewards));如果平均测试奖励稳定在450以上说明策略已经能持续平衡杆超过9秒。如果平均只有200但训练曲线末尾很高说明训练过程中出现了过拟合或运气成分需要检查是不是最后几个episode恰好踩到了简单的初始状态。中位数比平均值更稳健因为少数长跑episode会把均值拉高。第二个实用改法是Double DQN。CartPole动作空间只有2个过估计问题不算严重但如果你发现冻结测试时Q值普遍比实际回报高30%建议改成Double DQN。改动点只在computeLoss里的TD target部分先用当前网络选动作索引再用目标网络取对应Q值% 用当前网络选出最优动作 nextQCurrent forward(net, nextStateDL); [~, bestActionIdx] max(extractdata(nextQCurrent), [], 1); % 用目标网络计算该动作的Q值 nextQTarget forward(targetNet, nextStateDL); qTarget1 nextQTarget(1, :); qTarget2 nextQTarget(2, :); maxNextQ qTarget1 .* (bestActionIdx 1) qTarget2 .* (bestActionIdx 2);这段逻辑替换原computeLoss中max(nextQValues, [], 1)那一行即可。注意nextQCurrent必须用当前net不能复用targetNet的输出否则就退化成普通DQN了。实际跑下来Double DQN在CartPole上通常能把平均测试奖励再拉高10到30分而且Q值的绝对值更接近真实期望回报。从那以后我每做完一轮训练第一件事一定是冻结测试第二件事查Q值量级。这两个习惯帮我省下了无数次“模型看着挺好一测就崩”的尴尬时间。完整可运行脚本都在这个资源包里按章节顺序打开就能复现训练曲线希望你少走我踩过的这些弯路。本文还有配套的精品资源点击获取
