MATLAB从零实现DQN:CartPole环境下的深度强化学习实战
简介这份资源面向强化学习初学者与MATLAB使用者提供一套自主搭建DQN算法解决CartPole平衡杆问题的完整代码实现帮助读者把Q-learning与神经网络结合的理论落到可运行的程序中。压缩包共3个文件均为m脚本整体约4KB分别承担环境建模、DQN学习主流程与训练入口等职责结构精简便于逐行阅读与调试。内容覆盖状态与动作空间定义、Q网络架构设计、经验回放缓冲区、目标网络更新、ε-greedy探索策略以及训练评估等关键环节读者可据此理解价值函数近似、样本随机采样与训练稳定性之间的配合关系。目前已有2180人学习下载适合希望用MATLAB快速上手深度强化学习、并在此基础上尝试优先回放等改进技巧的读者参考。1. 用 MATLAB 从零搭一个 DQNCartPole 为什么是性价比最高的验证场很多人第一次接触强化学习都是在 Python 里跑 Stable-Baselines3两行代码就把 CartPole 跑到了 500 分。但如果你手头只有 MATLAB或者你的项目本身就跑在 MATLAB 生态里——比如做控制、做信号处理、做联合仿真——那把 DQN 用 MATLAB 自主搭一遍价值就完全不一样了。它逼你把经验回放、目标网络、ε-贪心、贝尔曼更新这些环节全部手写出来而不是调一个model.learn()就完事。CartPole 这个环境状态只有 4 维、动作只有 2 个是验证 DQN 实现是否正确的最小闭环跑通了说明你的梯度、采样、目标值计算逻辑没写反跑不通问题一定出在你自己的代码里而不是环境太复杂。这篇笔记面向的是有 MATLAB 基础、想真正搞懂 DQN 内部机制、并且愿意用 MATLAB 做深度学习与控制联合仿真的从业者。下面从网络结构、经验回放、训练循环到调参排查一步步把可复现的代码写出来。2. 先想清楚 DQN 在 MATLAB 里怎么落地网络、回放、目标网络三件套2.1 为什么用 Deep Learning Toolbox 的 dlnetwork 而不是 trainNetworkMATLAB 做深度学习有两条路一是trainNetwork适合监督学习输入输出成对、一次喂一批二是dlnetwork 自定义训练循环适合 DQN 这种目标值随训练动态变化、需要手动控制每次梯度更新的场景。DQN 的 TD 目标 $y r \gamma \max_{a} Q_{target}(s, a)$ 每一轮都在变trainNetwork那套固定标签的流程根本套不进去。所以必须用dlnetwork定义网络用dlfevaldlgradient手动求梯度用adamupdate手动更新参数。这是整个方案的地基选错了后面全是坑。网络结构上CartPole 状态 4 维、动作 2 个不需要卷积。常见做法是两层全连接每层 64 或 128 个神经元激活用 ReLU输出层 2 个节点对应两个动作的 Q 值。注意输出层不要加 softmax因为 Q 值是实数不是概率。下面是最小网络定义% 定义 Q 网络4 - 64 - 64 - 2 stateDim 4; actionDim 2; layers [ featureInputLayer(stateDim, Name, state) fullyConnectedLayer(64, Name, fc1) reluLayer(Name, relu1) fullyConnectedLayer(64, Name, fc2) reluLayer(Name, relu2) fullyConnectedLayer(actionDim, Name, qout) ]; qNet dlnetwork(layers);逻辑说明featureInputLayer接收 4 维状态向量两层fullyConnectedLayer做特征提取最后输出 2 维 Q 值。参数上64 是隐藏层宽度CartPole 用 64 足够用 128 收敛略稳但慢一点再大就过拟合且没必要。dlnetwork会自动初始化权重不用手动 assign。2.2 经验回放池用循环缓冲区而不是动态数组经验回放的作用是打破样本时间相关性让训练稳定。MATLAB 里如果每次用[buffer; newExp]拼接数据量一大内存拷贝会拖垮训练速度。正确做法是预分配一个固定容量的结构体数组用索引循环覆盖% 经验回放池预分配循环覆盖 bufferCap 10000; buffer.state zeros(stateDim, bufferCap, single); buffer.action zeros(1, bufferCap, single); buffer.reward zeros(1, bufferCap, single); buffer.nextState zeros(stateDim, bufferCap, single); buffer.done zeros(1, bufferCap, single); buffer.count 0; buffer.ptr 1; function buffer pushExperience(buffer, s, a, r, sNext, done) idx buffer.ptr; buffer.state(:, idx) s; buffer.action(idx) a; buffer.reward(idx) r; buffer.nextState(:, idx) sNext; buffer.done(idx) done; buffer.ptr mod(buffer.ptr, size(buffer.state,2)) 1; buffer.count min(buffer.count 1, size(buffer.state,2)); end逻辑说明ptr指向下一个写入位置写满后回到 1 覆盖最旧的经验。count记录当前有效样本数采样时只在1:count范围内取避免采到未初始化的零。参数上bufferCap取 10000 是 CartPole 的常用值太小如 1000会导致样本重复、训练震荡太大如 100000在 CartPole 这种短回合任务里浪费内存且早期样本长期滞留。2.3 目标网络硬更新还是软更新DQN 用目标网络计算 TD 目标避免自举导致的发散。目标网络参数不参与梯度更新每隔 C 步从在线网络硬拷贝一次或者每步做软更新 $\theta^- \leftarrow \tau\theta (1-\tau)\theta^-$。CartPole 上硬更新更常见C 取 100 到 500 都行。软更新 τ 取 0.005 左右收敛更平滑但慢。我一般先用硬更新 C200 跑通再考虑换软更新。% 目标网络初始化与硬更新 targetNet qNet; targetUpdateFreq 200; stepCount 0; % 训练循环内 stepCount stepCount 1; if mod(stepCount, targetUpdateFreq) 0 targetNet qNet; % 硬拷贝所有可学习参数 end逻辑说明targetNet qNet在 MATLAB 里会复制整个 dlnetwork 对象包括可学习参数。注意不要写成targetNet.Learnables qNet.Learnables那样只复制了引用后续更新会串。参数上targetUpdateFreq太小如 10目标网络追得太紧失去稳定作用太大如 2000目标值滞后严重学习变慢。3. 把训练循环写对采样、计算 TD 目标、梯度更新三步走3.1 ε-贪心采样与动作选择训练初期网络输出接近随机需要高探索后期要利用学到的 Q 值。ε 从 1.0 线性衰减到 0.05 是标准做法。动作选择时把状态转成dlarray前向传播取 Q 值最大的动作% ε-贪心动作选择 function [action, qValues] selectAction(qNet, state, epsilon, actionDim) if rand epsilon action randi(actionDim) - 1; % 0 或 1 qValues []; else dlState dlarray(single(state), CB); qValues predict(qNet, dlState); [~, idx] max(extractdata(qValues)); action idx - 1; end end逻辑说明dlarray的CB格式表示通道×批次单样本时批次为 1。predict只做前向不算梯度比forward快。extractdata把 dlarray 转回普通数组才能用max。参数上ε 衰减步数一般取总训练步数的 10% 到 20%CartPole 总步数约 5 万到 10 万所以 ε 在 5000 到 10000 步内从 1 降到 0.05。3.2 从回放池采样并计算 TD 目标这是 DQN 最容易写错的地方。TD 目标里max要作用在目标网络的输出上且终止状态的目标值只等于即时奖励不加折扣的未来值% 从回放池采样一个批次 batchSize 64; idx randperm(buffer.count, min(batchSize, buffer.count)); sBatch dlarray(buffer.state(:, idx), CB); aBatch buffer.action(idx); rBatch buffer.reward(idx); sNextBatch dlarray(buffer.nextState(:, idx), CB); doneBatch buffer.done(idx); % 目标网络前向取最大 Q qNext predict(targetNet, sNextBatch); maxQNext max(extractdata(qNext), [], 1); gamma 0.99; targetQ rBatch gamma * maxQNext .* (1 - doneBatch); % 在线网络前向取出实际动作对应的 Q qOnline forward(qNet, sBatch); qOnlineData extractdata(qOnline); qTaken qOnlineData(sub2ind(size(qOnlineData), aBatch 1, 1:numel(aBatch)));逻辑说明sub2ind把动作索引和批次索引转成线性索引取出每个样本实际执行动作的 Q 值。doneBatch为 1 时(1-done)为 0终止状态目标值只剩即时奖励。参数上gamma0.99是 CartPole 常用折扣太小如 0.9会让智能体短视太大如 0.999在 CartPole 这种回合制任务里没必要。3.3 用 dlfeval 和 adamupdate 完成一次梯度更新MATLAB 自定义训练循环的核心是dlfeval调用一个返回损失和梯度的函数再用adamupdate更新% 定义损失函数 function [loss, gradients] dqnLoss(qNet, sBatch, aBatch, targetQ) qOut forward(qNet, sBatch); qOutData extractdata(qOut); qTaken qOutData(sub2ind(size(qOutData), aBatch 1, 1:numel(aBatch))); loss mean((qTaken - targetQ).^2); gradients dlgradient(loss, qNet.Learnables); end % 训练循环内更新 [loss, gradients] dlfeval(dqnLoss, qNet, sBatch, aBatch, targetQ); [qNet, avgGrad, avgSqGrad] adamupdate(qNet, gradients, avgGrad, avgSqGrad, stepCount, learnRate);逻辑说明dlfeval在自动微分上下文中执行dqnLossdlgradient对qNet.Learnables求梯度。adamupdate维护一阶和二阶动量avgGrad和avgSqGrad初始化为空即可函数内部会自动初始化。参数上学习率learnRate取 1e-3 是起点CartPole 上 5e-4 到 1e-3 都常见太大如 1e-2会震荡不收敛太小如 1e-5学得极慢。4. 避坑与排查CartPole 跑不收敛时先查这 5 个地方4.1 现象奖励一直卡在 10 到 20完全不涨原因最常见的是 TD 目标里用了在线网络的 Q 值而不是目标网络或者max维度搞错。另一个高频原因是done标志没正确传入终止状态还在加折扣未来值导致 Q 值被高估。解决检查qNext predict(targetNet, sNextBatch)是否用的targetNet检查max(extractdata(qNext), [], 1)的维度是否为 1因为CB格式下第一维是通道即动作维检查doneBatch在回合结束时是否置 1。4.2 现象训练初期奖励上升几百轮后突然崩掉原因经验回放池里早期低质量样本被反复采样或者学习率没有衰减后期梯度太大把已学好的策略冲垮。解决把bufferCap适当调小让旧样本更快被覆盖或者在训练后期把学习率乘以 0.1。另一个办法是增大targetUpdateFreq让目标网络更稳定。4.3 现象dlgradient报错说找不到可微分参数原因qNet.Learnables在dlfeval外部被修改过或者网络里混入了不支持自动微分的层。MATLAB 的dlnetwork要求所有层都支持dlarray输入。解决确保网络只用featureInputLayer、fullyConnectedLayer、reluLayer这类内置层不要在dlfeval外面手动改qNet.Learnables的值如果自定义了层检查predict和forward是否都实现了。4.4 现象训练速度极慢一轮要好几秒原因每次采样都用randperm(buffer.count, batchSize)在几万样本里随机取或者predict被反复调用在单样本上。解决randperm本身不慢慢的是把dlarray反复创建。把批次数据一次性转成dlarray不要在循环里逐个转。另外predict用于目标网络时不需要梯度确认没有误用forward。如果还是慢检查 MATLAB 是否在用 CPU 而非 GPUgpuDevice确认可用后把网络和数据转到 GPU。4.5 现象换了随机种子结果差异巨大原因DQN 本身方差就大CartPole 虽然简单但不同初始化、不同采样顺序会导致收敛轮数差几倍。这不是 bug是算法特性。解决跑 3 到 5 个种子取平均不要用单次结果判断算法好坏。固定种子只用于调试复现评估时要用多个种子。如果多个种子都完全不收敛再回去查 4.1 到 4.4。5. 进阶技巧用奖励塑形和双网络结构把 CartPole 推到稳定 500 分CartPole 的原始奖励是每步 1回合上限 500 步。标准 DQN 在调好参数后能稳定到 475 以上但有时会在 300 到 400 徘徊。我一般会加两个小改动一是奖励塑形在杆子角度接近阈值时给一个小惩罚让智能体更早学会修正二是把目标值的max换成双网络结构用在线网络选动作、目标网络算 Q 值缓解高估。奖励塑形可以这样写在环境 step 之后加一层判断% 奖励塑形角度越大惩罚越重 angleThreshold 0.15; % 弧度 if abs(nextState(3)) angleThreshold shapedReward reward - 0.1 * abs(nextState(3)); else shapedReward reward; end逻辑说明nextState(3)是杆子角度超过 0.15 弧度时按比例扣分。参数上0.15 是经验值CartPole 终止阈值是 0.2095 弧度取 0.15 留出缓冲惩罚系数 0.1 不要太大否则智能体会过度保守。双网络目标值计算把原来的一行拆成两步% 双网络在线网络选动作目标网络算 Q qNextOnline predict(qNet, sNextBatch); [~, bestAction] max(extractdata(qNextOnline), [], 1); qNextTarget predict(targetNet, sNextBatch); qNextTargetData extractdata(qNextTarget); maxQNext qNextTargetData(sub2ind(size(qNextTargetData), bestAction, 1:numel(bestAction))); targetQ rBatch gamma * maxQNext .* (1 - doneBatch);逻辑说明bestAction由在线网络决定但 Q 值从目标网络取这样选动作和估价值解耦减少乐观偏差。参数上不需要额外调参只是多了一次前向传播训练速度略降但稳定性提升。验证方法上我习惯每 50 轮跑一次贪心策略ε0的评估记录最近 100 轮的平均奖励。如果平均奖励连续 3 次评估都超过 450就认为收敛。不要看单轮最高分那个没意义。最后说个血泪经验MATLAB 的dlnetwork在保存和加载时如果版本不同可能丢参数训练完立刻用save(dqnCartPole.mat, qNet)存下来别等。希望帮到你。本文还有配套的精品资源点击获取