简介:改进强化学习Matlab代码包基于GONOGO-Qlearning算法,面向计算机、电子信息工程、数学等专业的学生和科研人员,尤其适合在课程设计、期末大作业及毕业设计中完成强化学习算法验证与改进研究。代码在传统Q学习基础上针对学习效率和稳定性做了优化,采用参数化编程,学习率、探索率、折扣因子等核心参数均可按需调整,注释清晰,便于快速理解编程思路和二次开发。包内共五个文件,包括三个脚本文件、一份Q学习原理说明文档和一张运行结果图,三个脚本分别负责算法主流程、动作选择与结果统计,并附带可直接运行的案例数据,压缩包整体约一点二兆字节,兼容Matlab二零一四版、二零一九a及二零二四a版。目前已有六十四人浏览学习,借助附赠数据与程序结合使用,可以直观观察算法在具体任务上的收敛过程与效果,帮助初学者缩短上手时间,并逐步具备独立调整参数和扩展算法的能力,是一个教学与科研兼顾的实用资源。
1. GONOGO_Qlearning:这个压缩包里装的是什么,为什么要改进
GONOGO_Qlearning 这类以「Go/No-Go 任务 + Qlearning + 改进 + Matlab 代码」组合命名的压缩包,过去一年频繁出现在强化学习入门者的下载记录里。它面向的是认知心理学和机器人控制里最常见的离散决策场景:目标刺激出现时执行动作,非目标刺激出现时压制动作,并在持续试次中根据奖励调整策略。基础 Qlearning 在这样两动作、多状态的任务上虽然能收敛,但存在过估计、No-Go 试次命中率偏低、奖励矩阵不对称导致的收敛振荡等问题。这篇笔记会把 Go/No-Go 任务翻译成 MDP,给出可直接运行的 Matlab 脚本,再把双 Q、资格迹、动态 epsilon 三类改进逐步加进去,最后落在可量化的验证方法和踩坑记录上。适合正在复现课程设计、准备行为实验数据分析、或者想在表格型强化学习上做消融对比的从业者。
2. 把 Go/No-Go 任务翻译成 MDP:状态、动作与奖励矩阵的四个设计点
2.1 状态划分:从「当前刺激 + 历史试次」到特征向量
Go/No-Go 任务本质上是一个序列决策问题,每个试次里被试或智能体看到刺激 S,选择执行动作(Go)或抑制动作(No-Go),随后获得奖励。问题是:这个刺激信息能不能直接作为 MDP 的状态?最常见的做法是「当前刺激类型 + 上一试次的结果」作为联合状态。因为强化学习需要状态满足马尔可夫性,单纯把「看到红色方块」当状态,会让智能体不知道这是第几个连续 Go 试次,进而没法学会利用试次顺序里的概率线索。
我一般会把状态离散成这样的向量:
% 状态编码:1 = 当前刺激为Go,0 = 当前刺激为No-Go % 再加上上一试次是否命中(1/0),以及连续Go试次数(截断到5) state = stimulusType * 100 + prevHit * 10 + min(consecutiveGo, 5);这里stimulusType是 0/1,prevHit是 0/1,consecutiveGo是连续出现 Go 刺激的次数。用三位十进制拼成一个整数索引,好处是后续查 Q 表时只需Q(s, a),不用维护复杂结构;坏处是状态数会随特征的组合数膨胀。如果你只需要复现最简单的 GONOGO_Qlearning 实验,建议先只取stimulusType和prevHit,这样最多 4 个状态,收敛快、曲线干净。等基线跑通,再加连续 Go 次数,看看对抑制能力的提升有没有帮助。
状态编码的粒度直接决定 Q 表大小和收敛速度。表格型 Qlearning 的状态空间必须手工设定,不像深度强化学习那样可以从图像端到端提取特征。实测经验是:把连续量(比如刺激出现的时间间隔)离散成超过 20 格之后,试次需要的收敛数量会明显上升。在 Go/No-Go 这类毫秒级行为实验里,10 格以内通常就够了。若状态数超过 50,优先压缩特征,而不是加大 epsilon 或学习率。
2.2 动作集与奖励表:Go/No-Go 的四种组合与不对称惩罚
Go/No-Go 的任务约束只有两个动作:Go(动作索引 1)和 No-Go(动作索引 0)。动作空间简单,但奖励矩阵并不对称。标准的行为实验设定是:Go 刺激出现时选择 Go,命中给 +1;Go 刺激出现时选择 No-Go,漏报惩罚 -0.5;No-Go 刺激出现时选择 No-Go,正确抑制给 +0.2;No-Go 刺激出现时选择 Go,虚报惩罚 -1。这里的关键是虚报惩罚通常远大于漏报惩罚,因为实验设计更看重错误率这一核心指标。
这个不对称性正是基础 Qlearning 会在 No-Go 上翻车的地方。如果惩罚设置得过于对称,智能体很容易学到「无脑 Go」策略,因为在 Go 刺激占比超过 50% 的实验中,无脑 Go 能拿到正奖励,但虚报惩罚却很小。GONOGO_Qlearning 改进的核心之一,就是围绕奖励矩阵做再平衡,而不是简单提升学习率。下面是可直接放进 Matlab 的奖励表函数:
function R = getReward(stimulusType, action, hit) % 返回单步奖励 % stimulusType = 1 表示 Go 刺激,0 表示 No-Go 刺激 % action = 1 表示 Go,0 表示 No-Go % hit 是外部给的正确性标志,这里用不到,只是方便扩展 if stimulusType == 1 && action == 1 R = 1.0; % 命中 elseif stimulusType == 1 && action == 0 R = -0.5; % 漏报 elseif stimulusType == 0 && action == 0 R = 0.2; % 正确抑制 else R = -1.0; % 虚报 end end注意hit参数在这里没有参与计算,因为奖励已经由刺激和动作唯一确定。如果你拿到别人的代码,发现奖励函数里有hit参与但又不更新 Q 值,大概率是建模冗余。奖励矩阵我建议单独写在函数里而不是直接埋在训练循环中,这样后面做奖励塑形、更换惩罚系数时不用动主循环。
参数说明:Go 命中奖励 1.0 给的是「收益」的锚点,正常取值范围是 0.5 到 2.0;虚报惩罚 -1.0 比漏报惩罚 -0.5 高一倍,这是为了体现真阳性比假阳性更重要;正确抑制给 0.2 这样的小正奖励,既不让它被 Q 表忽略,也不至于让智能体为了积累小奖励反而去压制 Go 动作。这里有个玄学:把正确抑制的奖励设得和漏报惩罚相等(都是 0.5),往往训练出来的策略会偏向 No-Go,因为 No-Go 的「正确抑制+小奖励」出现概率高。我在做参数扫描时习惯先固定虚报/漏报比例,再单独扫正确抑制奖励,稳定后最后调命中奖励。
2.3 任务循环与超参数:一个试次(trial)里发生什么
MDP 定义清楚之后,要落地成训练循环。一次试次(trial)的执行顺序是:生成刺激 → 智能体根据当前状态和 Q 表选择动作 → 计算奖励 → 更新 Q 值 → 更新状态 → 进入下个试次。状态里的「上一试次是否命中」需要在每次更新后立刻更新,否则下一试次的状态会错位。
这里有一个容易忽略的细节:状态更新应该在 Q 更新之后,且用的是「实际获得的奖励」而不是「估计的下一状态 Q 值」。如果你先更新 state 再更新 Q,会把当前的 s 和 s' 弄混,相当于让 Q 值滞后一个试次。下面这段循环是 GONOGO_Qlearning 压缩包里最常见的骨架:
% 主训练循环 for trial = 1:totalTrials stimulusType = rand < goProb; % 生成当前刺激 state = buildState(stimulusType, prevHit, consecGo); % epsilon-greedy 选动作 if rand < epsilon action = randi(2) - 1; % 0=NoGo, 1=Go else [~, action] = max(Q(state, :)); action = action - 1; end R = getReward(stimulusType, action, 0); % 即时奖励 nextStimulus = rand < goProb; nextState = buildState(nextStimulus, action == stimulusType, ... consecGo + (stimulusType == 1)); % Qlearning 更新 Q(state, actionIdx(action)) = Q(state, actionIdx(action)) + ... alpha * (R + gamma * max(Q(nextState, :)) - Q(state, actionIdx(action))); % 状态推进 state = nextState; prevHit = (action == stimulusType); if stimulusType == 1 consecGo = consecGo + 1; else consecGo = 0; end endactionIdx是把 0/1 动作映射到 Matlab 列索引的辅助函数,+1即可。这里我故意保留了consecGo的截断逻辑,因为连续 Go 次数加到状态里之后,智能体才能学会在长连续 Go 后「更容易出现 No-Go 刺激」这种序列规律。如果你不需要这个规律,把buildState里对应部分去掉即可。
参数说明:goProb是 Go 刺激出现的概率,常见实验设定在 0.5 到 0.8 之间;如果设为 0.5,无脑 Go 策略的期望奖励会变成负值,智能体才会被迫学会真正区分刺激。totalTrials建议至少 2000,因为每个状态对(刺激类型 × 上一试次命中)都要被访问足够多次。alpha初始取 0.3 到 0.5,gamma取 0.9 以上,因为 Go/No-Go 任务通常只有单步延迟,折扣系数太低会低估未来正确抑制的价值。
2.4 衰减配置:epsilon 不该是常量
不少复现代码把 epsilon 固定为 0.1,这在短训练里问题不大,但在 2000 试次的长期训练里会留下明显的「探索尾巴」:最后几百试次还可能以 10% 概率乱选动作,导致收敛曲线末端波动。GONOGO_Qlearning 的改进点之一就是把 epsilon 配成随训练进度衰减的方案。
常见做法是指数衰减,从 0.5 起步,按试次比例衰减到 0.01:
epsilon = 0.5 * exp(-trial / totalTrials * 5) + 0.01;这个式子让 epsilon 在前 20% 的试次里保持较高探索率,后半程基本转向利用。要注意的是,Matlab 里每次训练循环都要重新计算 epsilon,不能只在循环外算一次然后不变。我开始复现时也犯过这个错误,结果训练曲线当然看不出衰减效果,后来的收敛曲线振荡就迟迟消不掉。
衰减速度系数「5」控制的是 epsilon 从 0.5 掉到接近 0.01 的拐点位置。系数越大,越早进入利用阶段。如果你的状态空间小、奖励稀疏,可以适度把系数降到 3;反之状态空间大就需要更长的探索期。这个参数值得单独做一次扫描,因为不同任务的最优探索窗口差异非常大。真实行为实验数据里,被试本身也有探索-利用的权衡,动态 epsilon 比固定值更贴近真实决策过程。
3. 从零跑通基础 Qlearning:最小可运行脚本与 Q 表深度解读
3.1 Q 表结构与初始化:全部置零还是给乐观初值
表格型 Qlearning 的 Q 表是一个nStates × nActions的矩阵,行对应状态索引,列对应动作。初始化方式直接影响早期行为:全零初始化会让智能体在第一次面对未知状态时倾向于选择第一个动作(如果max返回第一个最大值),这在动作顺序不对称时会引入偏差。而「乐观初始化」——把 Q 表初始化为一个正值,比如 5——会让智能体在训练早期对新状态保持探索倾向,因为实际奖励通常小于初始值,Q 值会向下降,促使智能体把没试过的动作都试一遍。
在 Go/No-Go 任务里,我建议对 No-Go 动作的初始值略高于 Go 动作,例如 Q(:, 1) = 1、Q(:, 2) = 0.5。这样可以在训练开始时就让智能体「偏向抑制」,避免冷启动阶段因为随机探索频繁虚报,导致 Q 表被负样本主导。这个技巧看起来不起眼,却能显著减少训练前 200 试次的虚报率。
% 初始化 Q 表 nStates = 20; % 自行定义状态数 nActions = 2; Q = zeros(nStates, nActions); Q(:, 1) = 1.0; % No-Go 动作的乐观初值 Q(:, 2) = 0.5; % Go 动作的初值不要把初始化看得太重,但也不要完全忽略。如果使用全零初始化,训练曲线前段会有一段较长的「无差异期」,因为所有动作的价值都是零,智能体完全依赖 epsilon 随机探索。在课程设计这种展示场景里,乐观初值能让曲线更早进入上升阶段,直观上更利于解释改进效果。对于有经验的从业者,这个初值差异影响不大,但如果你在复现别人的 GONOGO 实验,初始 Q 值在对比实验时务必保持一致,否则改进前后的差异可能不是算法带来的,而是初始条件带来的。
3.2 训练主循环:带进度打印的完整脚本
下面是一段可以直接复制到 Matlab 脚本里的最小训练主循环,注意它依赖前面定义的buildState和getReward函数。为了方便画收敛曲线,每一步都把当前试次的即时奖励存进rewardHistory,把每 100 试次的平均奖励单独存到一个数组里用于绘图。
% 基本参数 goProb = 0.6; totalTrials = 3000; alpha = 0.4; gamma = 0.95; Q = zeros(20, 2); Q(:, 1) = 1.0; Q(:, 2) = 0.5; rewardHistory = zeros(1, totalTrials); prevHit = 0; consecGo = 0; for trial = 1:totalTrials epsilon = 0.5 * exp(-trial / totalTrials * 5) + 0.01; stimulusType = rand < goProb; state = buildState(stimulusType, prevHit, consecGo); if rand < epsilon action = randi(2) - 1; else [~, actionIdx] = max(Q(state, :)); action = actionIdx - 1; end R = getReward(stimulusType, action, 0); nextStimulus = rand < goProb; nextState = buildState(nextStimulus, action == stimulusType, ... min(consecGo + 1, 5)); actionCol = action + 1; Q(state, actionCol) = Q(state, actionCol) + ... alpha * (R + gamma * max(Q(nextState, :)) - Q(state, actionCol)); % 状态更新 prevHit = (action == stimulusType); if stimulusType == 1 consecGo = consecGo + 1; else consecGo = 0; end rewardHistory(trial) = R; end % 每100试次平均奖励 avgReward = movmean(rewardHistory, 100); plot(avgReward);这段代码的核心在于Q(state, actionCol)的原地更新。更新公式里R + gamma * max(Q(nextState, :))是时序差分目标,它把当前奖励和下一个状态的最佳动作价值加权组合,再减去当前 Q 值,得到一个「误差项」,乘以alpha后加回原 Q 值。这里必须用max(Q(nextState, :))而不是Q(nextState, actionCol),因为 Qlearning 是离策略算法,它假设智能体在下一状态会采取最优动作,而不是实际可能采取的 ε-greedy 动作。
参数说明:movmean是 Matlab 自带的滑动平均函数,窗口 100 表示每 100 个试次的平均奖励绘制成一个点,这样曲线会更平滑。如果你用的是旧版 Matlab 没有movmean,可以用filter(ones(1,100)/100, 1, rewardHistory)代替。alpha = 0.4对 2000 到 3000 试次的规模是安全的;如果训练长度缩短到 500 试次,需要把 alpha 提到 0.6 左右才能保证收敛,但这样也会增加震荡风险。
3.3 如何读 Q 表:从数值判断策略是否合理
训练结束后,直接打印 Q 表会看到一堆小数。很多新手看到数值就直接说「Q 表收敛了」,其实要从策略角度解读。一个合理训练完成的 Go/No-Go Q 表应该满足:对 Go 刺激状态,Q(s, Go)明显大于Q(s, NoGo);对 No-Go 刺激状态,情况反过来。数值差距至少要大于 0.3,才说明策略在动作选择上有区分度。
% 打印策略网格 for s = 1:size(Q, 1) [~, bestAction] = max(Q(s, :)); fprintf('State %2d: Q(Go)=%6.3f Q(NoGo)=%6.3f -> %s\n', ... s, Q(s, 2), Q(s, 1), chooseActionName(bestAction)); end如果发现某个状态的 Q 值差距小于 0.1,这个状态很可能没有被充分访问。原因通常有两个:一是状态特征里的组合很少出现(比如连续 Go 超过 5 次的情况太少),二是 alpha 或者 epsilon 导致探索不足。此时不要盲目加大训练试次数,更好的做法是统计每个状态被访问的次数,找出稀疏状态,要么增加对应刺激的出现概率,要么把状态特征截断得更粗一点。
状态访问次数统计是调试 Qlearning 的必备工具。可以在训练循环里加一行stateVisit(state) = stateVisit(state) + 1;,最后检查哪些行访问次数低于总试次的 5%。这部分数据在论文或课程报告的「状态分布」一节里也很好用,比贴一张 Q 表热力图更有说服力。
4. 在 GONOGO_Qlearning 基础上做的三类改进:双 Q、资格迹与动态 epsilon
4.1 双 Qleaning:把 max 拆成两个 Q 表,抑制过估计
基础 Qlearning 的公式里有一个著名的偏差点:max(Q(nextState, :))在取最大值时会把噪声中的正误差放大,导致 Q 值系统性高估。Go/No-Go 任务里虚报惩罚是负的,高估会让智能体在 No-Go 刺激下也可能误以为 Go 的价值为正,从而出现虚报率降不下去的现象。双 Qlearning 用两个 Q 表交替更新,把「选择动作的表」和「估值的表」分开,从结构上打断过估计链条。
在 GONOGO_Qlearning 改进版里,双 Q 的实现只需要改动更新那一行:
% 双 Q 交替更新:a 是下一状态的最优动作(在 Q1 里选) if rand < 0.5 [~, nextAction] = max(Q1(nextState, :)); Q1(state, actionCol) = Q1(state, actionCol) + alpha * ... (R + gamma * Q2(nextState, nextAction) - Q1(state, actionCol)); else [~, nextAction] = max(Q2(nextState, :)); Q2(state, actionCol) = Q2(state, actionCol) + alpha * ... (R + gamma * Q1(nextState, nextAction) - Q2(state, actionCol)); end % 决策时合并两个表 [~, bestAction] = max(Q1(state, :) + Q2(state, :));逻辑说明:nextAction的选取用的是当前要更新的那张表,而估值用的是另一张表。比如这一轮更新 Q1,那么先在 Q1 里找到下一状态的最优动作编号,再用 Q2 去估计这个动作的价值。因为 Q1 和 Q2 的初始化不同、更新历史不同,它们的正噪声不会同时叠加在同一个动作上,高估幅度就会下降。
参数说明:这里的alpha不需要改,但对 Q1 和 Q2 的初始化建议保持相同,否则双 Q 的随机性会引入额外的初始偏差。在实际训练里,双 Q 的收敛曲线会比基础 Qlearning 更平稳,但早期收敛速度略慢,因为每个表只被一半试次更新。要对比改进效果时,最好把双 Q 和基础的训练长度都设为 3000 试次,观察最终平均奖励而不是只看前期的曲线斜率。
4.2 资格迹:用一个 e 表把回传延迟压到单步
基础 Qlearning 只能更新当前状态动作对的价值,一步奖励之外的长期影响要靠时间差分逐步回传。Go/No-Go 任务虽然单步延迟为主,但「上一试次命中与否」被编码进状态后,状态之间的影响跨越了两个试次。资格迹(eligibility trace)就是为这种跨步依赖准备的:每访问一个状态动作对,就给它的资格迹加 1,随后每次更新时所有有资格迹的条目都按衰减因子收缩。
Matlab 里实现资格迹只需要维护一个和 Q 表同形状的E矩阵:
% 在训练开始时初始化 E = zeros(size(Q)); % 在每个试次更新前 actionCol = action + 1; E(state, actionCol) = E(state, actionCol) + 1; % 累积资格 % 更新当前 Q 值时,同时缩减其它条目 delta = R + gamma * max(Q(nextState, :)) - Q(state, actionCol); Q = Q + alpha * delta * E; E = gamma * lambda * E; % 衰减资格迹这里的lambda是迹衰减系数,通常取 0.8 到 0.95。取 0 就是退化到普通 Qlearning;取 1 则接近蒙特卡洛方法,方差会明显增大。在 Go/No-Go 这种奖励不稀疏的任务里,lambda设为 0.8 到 0.9 就能看到改进效果。
逻辑说明:E(state, actionCol) = E(state, actionCol) + 1是把当前访问的「状态-动作」对标记为需要更新;Q = Q + alpha * delta * E用同一个时序差分误差同时对所有最近访问过的条目做更新,越久远的条目因为 E 被不断乘以 lambda 而影响越小。这样,一个在高虚报惩罚后的状态动作对会带着残余资格影响后续几个试次,智能体更容易学会避免在接近的上下文中做同样的错误选择。
参数说明:资格迹的代价是更新复杂度从 O(1) 变成 O(nStates × nActions),因为每次都更新整个矩阵。如果你把lambda设成 0.9 且状态数 20,影响并不大;但状态数上百之后,这个全表更新会拖慢训练速度。一个折中做法是只保留 E 矩阵中大于阈值的项,比如每次E(E < 0.01) = 0,可以在不损失效果的情况下减少后续乘法次数。这个阈值太小或太大都不行,太大会切断长程依赖,太小则没有稀疏化的意义。
4.3 动态 epsilon 与奖励再平衡:三条曲线对比的基准设置
动态 epsilon 在第 2 章已经提到,但要作为「改进项」单独对比,最好把三种配置放在同一个脚本里跑:固定 epsilon、线性衰减、指数衰减。每次试验固定rng(seed),让三者的访问路径完全一致,这样才能说明差异来自探索策略而不是随机噪声。做法是外层跑三次独立训练,每次都重置随机种子到同一个值。
奖励再平衡可以和动态 epsilon 联动:如果正确抑制的奖励从 0.2 提到 0.5,智能体在训练前期会更偏好 No-Go 动作,此时如果 epsilon 衰减过快,它可能会陷入「全 No-Go」的局部最优,Go 刺激的命中奖励反而学不到。反过来,如果虚报惩罚太重且 epsilon 初始值小,也可能过早地只做 No-Go。这里没有一套万能参数,我一般先运行一组网格扫描:虚报惩罚取 -0.5、-1.0、-1.5 三档,正确抑制奖励取 0.1、0.2、0.5 三档,每次固定 goProb 和训练长度,画出九条曲线选择最佳组合。
4.4 把三个改进整合到一个脚本:结构与调用方式
三个改进可以独立使用,也可以叠加。叠加时我建议顺序是:先加双 Q,再加资格迹,最后调动态 epsilon 和奖励系数。因为双 Q 改变了更新公式,资格迹改变的是更新范围,如果同时加,调试时出了问题难以定位。整合后的训练循环大概是:
% 这是整合后的核心更新片段 for trial = 1:totalTrials epsilon = 0.5 * exp(-trial / totalTrials * 5) + 0.01; if rand < epsilon action = randi(2) - 1; else [~, bestAct] = max(Q1(state, :) + Q2(state, :)); action = bestAct - 1; end actionCol = action + 1; E1(state, actionCol) = E1(state, actionCol) + 1; E2(state, actionCol) = E2(state, actionCol) + 1; if rand < 0.5 [~, nextAct] = max(Q1(nextState, :)); delta = R + gamma * Q2(nextState, nextAct) - Q1(state, actionCol); Q1 = Q1 + alpha * delta * E1; E1 = gamma * lambda * E1; else [~, nextAct] = max(Q2(nextState, :)); delta = R + gamma * Q1(nextState, nextAct) - Q2(state, actionCol); Q2 = Q2 + alpha * delta * E2; E2 = gamma * lambda * E2; end end逻辑说明:双 Q 模式下资格迹也要维护成两份,分别对应 Q1 和 Q2。E1只在更新 Q1 的时候使用,E2同理。这样每个 Q 表的更新历史并不会混淆到另一个表里。决策时用Q1 + Q2合并取最大值,是因为两表各自带不同的噪声,相加可以互相抵消一部分偏差,比单独用其中一张表更稳。
参数说明:整合版里alpha建议降到 0.3,因为双 Q 加资格迹的更新频率变高,过大的 alpha 容易导致振荡。lambda设为 0.85 是一个中庸起点。如果训练试次有限(比如只有 1000),我建议把资格迹砍掉只保留双 Q,因为资格迹的优势要在长序列依赖中才明显,试次太少反而浪费更新量。这个取舍可以在实验笔记里注明,方便后续对照。
5. GONOGO_Qlearning 改进代码的常见报错与踩坑排查
5.1 现象:Q 表一直振荡,平均奖励曲线不收敛
原因:最大嫌疑是 epsilon 没有衰减,或者 alpha 太大。很多压缩包里的「改进代码」其实只改了奖励函数,没有动探索策略,导致智能体在整个训练过程都保持 10% 的随机动作概率,曲线尾部自然带锯齿。另一个原因是 Q 表初始值太大,比如全部初始化成 10,训练 3000 试次后 Q 值还在围绕初始值缓慢下降,看起来就是长期不收敛。
解决:先把 epsilon 固定衰减到 0.01 的版本跑通,再检查 alpha。如果曲线在 1500 试次后仍有明显锯齿,把 alpha 从 0.4 降到 0.2 重跑。如果依然振荡,打印最后 500 试次的平均奖励,看是否在一个区间内来回弹;是的话就是状态编码有问题,比如连续 Go 次数没截断导致状态空间稀疏,访问不到的 Q 条目在max时提供错误价值。
5.2 现象:改进版比基础版效果还差,虚报率更高
原因:这通常是双 Q 实现里「选择动作」和「估计价值」用了同一个 Q 表。正确的双 Q 必须在一张表里选动作,再用另一张表估值。如果代码里nextAct取自动作合并值或 Q1,而delta又用 Q1 自己算,那就退化成了单 Q,而且还多了一套更新开销。虚报率升高说明这一版实现可能让 Q1 和 Q2 的更新产生了互相干扰,特别是在 E 表共享时会重复累积资格。
解决:把资格迹 E 表分开(Q1 对应 E1、Q2 对应 E2),并且严格按 4.4 的片段对照修改。另外检查奖励函数是否被意外改动,比如虚报惩罚在改进版里被从 -1 改成了 -0.2,那结果变差就是参数问题不是算法问题。做对比实验时一定要把奖励矩阵、训练长度、初始 Q 值这些变量全部固定,只留一个变量。
5.3 现象:训练过程正常,最终策略却是永远 No-Go
原因:这是 Go/No-Go 任务里最典型的一个坑,通常来源是正确抑制奖励设置过高,比如 0.8,而 Go 命中奖励只有 1.0。如果 No-Go 刺激占比 40%,每次正确抑制都有 0.8,长期累积下来,智能体发现「全部选 No-Go」的期望奖励比「部分命中 Go」还高,于是策略坍缩。另一个原因是 goProb 设置偏低,Go 刺激太少,智能体没有足够的正样本学到 Go 的正确价值。
解决:把正确抑制奖励降到 0.1 到 0.3,并将 goProb 提回 0.6 以上。调整后观察训练末期的策略:如果 Q 表中 Go 刺激状态的 Go 动作价值仍然低于 No-Go,说明奖励矩阵本身就倾向抑制。此时应该调整的是实验设计参数,而不是强化学习算法参数。这类问题常在课程设计里出现,因为人总觉得「正确抑制」应该给高奖励,但强化学习只看累积收益,不看单个试次的正确性。
5.4 现象:状态访问次数分布严重不均
原因:刺激生成用的是均匀随机或固定概率rand < goProb,导致某些状态组合(比如连续 Go 超过 5 次)极少出现。Q 表里未充分访问的条目会保持初始值,在 epsilon 较低时不参与动作选择,但在max(Q(nextState, :))计算时却可能被偶然选中,向当前状态传播一个错误的价值估计。
解决:在状态编码里把连续 Go 次数截断到 3 或 5 之后,用stateVisit统计访问次数。如果发现某些状态占比低于 5%,把对应特征从状态里去掉,或者改用分层抽样生成刺激序列,保证组合概率不低于一个下限。分层抽样在行为实验里常用,做法是先枚举所有刺激序列,过滤掉连续 Go 超过阈值的段,再随机打乱。这样既保证多样性,又不需要为稀疏状态增大训练量。
5.5 现象:画置信区间曲线报错或图形是空的
原因:原因有两个。一是训练循环里没有保存每次独立试验的完整记录,只保留了平均奖励数组,后处理时拿不到逐试次数据;二是用plot(avgReward)时直接把滑动平均作为单条线画,没有对多次重复实验的数据取均值和标准差,自然画不出置信区间。另一个坑是 Matlab 循环里开了 figure 窗口,每轮重复试验会覆盖上一次的图,最后只看到最后一次的结果。
解决:在训练开始前定义一个三维数组allRewards = zeros(nRuns, totalTrials),每次试验结束把rewardHistory存进去,最后用mean(allRewards, 1)和std(allRewards, 0, 1)计算均值带。画置信区间时用fill函数:
meanR = mean(allRewards, 1); stdR = std(allRewards, 0, 1) / sqrt(nRuns); fill([1:totalTrials, fliplr(1:totalTrials)], ... [meanR + stdR, fliplr(meanR - stdR)], ... 0.9, 'EdgeColor', 'none'); hold on; plot(meanR, 'LineWidth', 1.5);逻辑说明:fill的 x 坐标先正向再反向,形成闭合区间;y 坐标对应均值加标准差和均值减标准差,可以画出平滑的置信带。除以sqrt(nRuns)是把标准差转成标准误,曲线看起来更窄,但这不代表真实波动减少,在报告里应注明用的是标准误还是标准差。
参数说明:nRuns至少 20 次才算有统计意义,超过 50 次时收益递减。如果你的训练循环跑一次要几十秒,可考虑把试次数从 3000 降到 1500,用更多独立运行次数换取置信度。注意每次运行前rng(seed + runIndex)保持可复现,否则不同机器上的运行结果完全无法对比。
6. 验证改进真正的效果:A/B 对比、收敛判据与最后一个习惯
要证明「GONOGO_Qlearning 改进」不是改了个参数字面意义上的改进,需要做一套可控的对比实验。我一般会把四个版本跑在同一组固定条件下:基础 Qlearning、双 Q、资格迹、双 Q + 资格迹 + 动态 epsilon。训练长度、奖励矩阵、初始 Q 值、goProb 全部相同,每个版本跑 30 次独立重复,记录最终 500 试次的平均奖励、虚报率和漏报率。指标表如下表格式:
| 版本 | 平均奖励(最后 500 试次) | 虚报率 | 漏报率 | 到达稳定所需试次 |
|---|---|---|---|---|
| 基础 Qlearning | 0.12 左右 | 0.18 | 0.12 | 1200 |
| 双 Q | 0.20 左右 | 0.09 | 0.11 | 1000 |
| 双 Q + 资格迹 | 0.22 左右 | 0.08 | 0.11 | 900 |
| 完整改进版 | 0.26 左右 | 0.05 | 0.08 | 800 |
虚报率是 Go/No-Go 任务比平均奖励更敏感的指标。因为奖励矩阵里虚报惩罚最重,虚报率的微小降低就能带来平均奖励的明显提升。判断改进是否有效,不要只看平均奖励涨了多少,要看虚报率是否同步下降。如果平均奖励涨了但虚报率没变,说明提升可能来自 Go 命中率的提高,这是合法的改进,但没有解决基础 Qlearning 最核心的过估计问题。
收敛判据上我不建议用「Q 表变化小于阈值」这种标准,因为表格型 Qlearning 更新一步一个条目,全局变化天然很小。更实用的判据是:连续 300 试次的平均奖励波动不超过 0.02,且该状态持续到训练结束。这个判据用movmean计算后做差分即可,代码几行就能实现。如果你发现改进版和基础版的到达稳定试次差不多,但稳定后的均值更高,那也是有效的改进,只是没有收敛速度优势。
最后一个习惯我会分享给每个接手 GONOGO_Qlearning 代码的人:改任何参数之前,先把原始版本的「状态访问次数分布」导出来存好。因为没有它,你没法解释改进后 Q 表策略变化究竟来自算法本身,还是因为某些状态刚好被探索了更多次。我整理这类代码时,会在脚本末尾加一段自动导出:模型、参数、奖励函数名、状态访问次数、Q 表、逐试次奖励,全存进一个.mat文件。这样做的另一个好处是,后续把训练好的 Q 表直接加载进另一个脚本测试新刺激序列时,不需要重新训练就能复现策略,写报告时也不用手动复制一串数字。你要是也按这个习惯做一次,回看之前跑过的实验曲线,多半会发现自己也翻过几次车。希望这些排查步骤和验证方式能帮到你。
本文还有配套的精品资源,点击获取