简介:使用MATLAB自主搭建深度Q网络算法解决CartPole小车倒立摆平衡问题,是面向具备一定编程基础、希望深入理解强化学习核心机制的实用资源。资源完整覆盖了环境建模、神经网络近似Q值、经验回放、目标网络与ε-greedy探索等关键环节,适合算法学习者对照理论逐步实现。压缩包共3个文件,均为m脚本,其中test.m作为整体运行入口,CartPole.m定义小车与杆的动力学交互及奖励,DQNLearn.m则实现网络训练与参数更新逻辑,整体仅4KB,结构清晰易于修改调试。该项目已有2178人学习下载,通过阅读三段代码,读者可掌握从状态空间定义、动作选择到损失计算与梯度下降的完整流程,并能在此基础上调整网络层数、学习率、回放缓冲区大小等超参数,扩展双DQN、优先回放等进阶方法,是搭建自定义强化学习实验的良好起点。
1. 用MATLAB手写DQN解决CartPole:四维状态空间里最直观的强化学习实验
接触强化学习第一周,很多人会纠结要不要直接上Python和PyTorch,但如果你手里只有MATLAB,CartPole这个环境其实是最合适的切入点。它状态空间只有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^4=10000个,乘以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); % action=0 -> -10, action=1 -> +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-1,action取0时是-10N向左,action取1时是+10N向右。reward恒为1,done标志用于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.4,theta在±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。容量建议设50000,CartPole的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转换直接标'CB'(Channel-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.0,epsilonEnd取0.01,epsilonDecaySteps设为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值达到1e+8,loss出现NaN,训练曲线直接断掉。
原因:CartPole的每步奖励恒为1,加上gamma=0.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上训练最快。在训练循环里不用gpuArray,dlnetwork保持默认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值量级。这两个习惯帮我省下了无数次“模型看着挺好一测就崩”的尴尬时间。完整可运行脚本都在这个资源包里,按章节顺序打开就能复现训练曲线,希望你少走我踩过的这些弯路。
本文还有配套的精品资源,点击获取