MATLAB从零实现DQN:CartPole环境下的深度强化学习实战
简介:这份资源面向强化学习初学者与MATLAB使用者,提供一套自主搭建DQN算法解决CartPole平衡杆问题的完整代码实现,帮助读者把Q-learning与神经网络结合的理论落到可运行的程序中。压缩包共3个文件,均为m脚本,整体约4KB,分别承担环境建模、DQN学习主流程与训练入口等职责,结构精简,便于逐行阅读与调试。内容覆盖状态与动作空间定义、Q网络架构设计、经验回放缓冲区、目标网络更新、ε-greedy探索策略以及训练评估等关键环节,读者可据此理解价值函数近似、样本随机采样与训练稳定性之间的配合关系。目前已有2180人学习下载,适合希望用MATLAB快速上手深度强化学习、并在此基础上尝试优先回放等改进技巧的读者参考。
1. 用 MATLAB 从零搭一个 DQN:CartPole 为什么是性价比最高的验证场
很多人第一次接触强化学习,都是在 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 而不是 trainNetwork
MATLAB 做深度学习有两条路:一是trainNetwork,适合监督学习,输入输出成对、一次喂一批;二是dlnetwork+ 自定义训练循环,适合 DQN 这种目标值随训练动态变化、需要手动控制每次梯度更新的场景。DQN 的 TD 目标 $y = r + \gamma \max_{a'} Q_{target}(s', a')$ 每一轮都在变,trainNetwork那套固定标签的流程根本套不进去。所以必须用dlnetwork定义网络,用dlfeval+dlgradient手动求梯度,用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 左右,收敛更平滑但慢。我一般先用硬更新 C=200 跑通,再考虑换软更新。
% 目标网络初始化与硬更新 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,终止状态目标值只剩即时奖励。参数上,gamma=0.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在自动微分上下文中执行dqnLoss,dlgradient对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 而非 GPU,gpuDevice确认可用后把网络和数据转到 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')存下来,别等。希望帮到你。
本文还有配套的精品资源,点击获取