做控制相关项目的时候,很多同学第一次接触强化学习,都会拿小车倒立摆来试手。这个对象看起来结构简单,但它是典型的不稳定非线性系统,PID想调稳都得花不少功夫,而Q-Learning这种不依赖精确模型的算法反而能在这个场景里体现出价值。我这段时间正好在MATLAB里把一整套基于Q-Learning的小车倒立摆平衡控制系统性能仿真完整跑通了一遍,从建模、状态离散化、奖励设计,到训练循环、参数调整、性能评估都踩了一遍,所以想把这些过程整理出来分享给正在做控制类课题设计、算法预研,或者刚入门强化学习想找一个能跑起来的例子的朋友。
先说结论:只要状态空间划分合理、奖励函数不过分稀疏,纯Q-Learning在MATLAB里训练两万回合以内,小车倒立摆就能做到稳定直立,平均保持时间可以从最初的零点几秒提升到十秒以上。这里不打算堆一堆数学推导,而是按我实操时的顺序来讲,怎么搭仿真环境、怎么设参数、怎么判断训练是否收敛、怎么排查发散。文章后面还会给出性能仿真阶段的测试思路,以及一次从完全发散到恢复稳定的完整调参记录。
1. 项目整体设计与方案选型:先想清楚为什么用强化学习
1.1 为什么选Q-Learning而不是PID/LQR
如果只是想让倒立摆立起来,经典控制理论确实更成熟。在平衡点附近把系统线性化之后,LQR可以给出很好的反馈增益,PID经过仔细整定也能维持直立。但这类方法有一个隐含前提:模型工作在平衡点附近。一旦初始角度偏大,或者外界干扰让角度超出线性化区间,线性控制器的性能会明显下降,甚至直接发散。
Q-Learning的做法完全不一样。它不需要我们显式推导控制律,智能体通过不断试错,在离散状态空间里学习“当前状态下做哪个动作能让累计奖励最大”。这样控制系统不再被限制在平衡点附近,对非线性状态转移也有天然的适应能力。换句话说,传统控制器是在“模型已知的前提下设计最优策略”,而Q-Learning是在“模型基本不可知的前提下通过探索找策略”。
从项目实现角度讲,Q-Learning在MATLAB里落地也足够轻量。它不需要像深度强化学习那样引入神经网络框架,一个Q表加一个环境函数就能跑起来,训练时间通常只需要几分钟到十几分钟。对于课程设计或个人预研来说,这种复杂度是刚刚好的。
| 维度 | 传统PID/LQR | Q-Learning |
|---|---|---|
| 模型依赖 | 通常需要线性化模型 | 只需与环境交互 |
| 参数设计 | 整定KP/KI/KD或Q/R矩阵 | 设计离散状态空间与奖励函数 |
| 适用范围 | 平衡点附近效果好 | 非线性、大范围状态均可学习 |
| 代码复杂度 | 低 | 中等 |
| 训练时间 | 无需训练 | 需要一定回合数与探索 |
| 可解释性 | 控制律直观 | 策略藏在Q表里,需结合实验分析 |
1.2 倒立摆数学模型与仿真环境设计
小车倒立摆的物理模型本身并不复杂,常见的一阶倒立摆可以这样描述:小车质量为M=1.0kg,摆杆质量为m=0.1kg,摆杆长度为l=0.5m,重力加速度取g=9.8m/s²。状态变量一共四个:小车位置x、摆杆角度θ(垂直向上为0)、小车速度dx、摆杆角速度dtheta。控制输入是作用在小车上的水平力F。
完整推导拉格朗日方程比较啰嗦,这里直接给结论形式的状态更新关系。摆杆角加速度可以写成与F、θ、dtheta相关的表达式,小车加速度则同时受F和摆杆运动的影响。这样的系统有非线性项,比如θ²的角速度耦合、cosθ耦合,所以不能用简单的线性状态方程来实现仿真。
我的做法是在MATLAB里用固定步长的欧拉积分来推进状态,步长取0.02秒。为什么不用RK4?因为训练过程需要反复进行环境交互,欧拉积分在0.02秒步长下已经能保留足够准确的动态特征,而且计算量小很多。如果你觉得不放心,可以改成0.01秒步长,但训练回合里单回合步数会翻倍,整体时间变长,收敛趋势不会有多大区别。
1.3 控制目标、初始条件与任务边界
这个项目的控制目标很明确:让摆杆保持竖直,同时小车不要冲出允许范围。为了增加一点难度,每次训练开始时小车位置固定为0,但摆杆角度会在[-0.1, 0.1]弧度之间随机初始化,速度都设为0。这样学习到的策略不会只对单一初始条件有效,对小的随机扰动也有一定鲁棒性。
回合结束条件有两个:摆杆角度绝对值超过0.6弧度(大约34度),说明已经倒伏;或者小车位置绝对值超过2.4米,说明小车撞到了行程限位。单回合最大步数设置为500步,对应10秒仿真时间。如果智能体能让摆杆坚持到500步结束,就算这一回合成功。训练停止条件我设为连续20回合都成功,或者达到最大训练回合数20000回合。
2. Q-Learning关键设计:状态、动作、奖励与参数怎么定
2.1 状态离散化:粒度怎么选才不丢关键信息
Q-Learning处理连续状态有一个绕不开的前提:必须把连续状态划分成离散格子。状态有四个维度,我在项目里每维都划分成12格,以下是具体范围:
- 小车位置:[-2.4, 2.4],12格
- 摆杆角度:[-0.6, 0.6],12格
- 小车速度:[-3.0, 3.0],12格
- 摆杆角速度:[-5.0, 5.0],12格
Q表的规模就是12×12×12×12,每个状态组合对应3个动作,总计大约6.2万条Q值。这个规模在MATLAB里只是一块很小的数组,训练速度不会成为瓶颈,但已经足够表达倒立摆平衡所需的控制策略。
这里有一个很容易踩的坑:网格太粗,智能体区分不了细小的状态差异,控制动作会显得僵直,表现为“明明摆杆已经开始往右倒,它还在往右推”;网格太细,Q表虽然表达能力更强,但探索难度成倍增加,每个格子里的状态都需要多次访问才能学准确,训练回合数必须跟着翻倍。我实际测试下来,每维10到15格是比较舒服的范围,既能学到平滑策略,又不会拖慢收敛。
2.2 动作集设计:离散力三选一
动作集我用了三个离散力:-10N、0N、+10N。为什么不把F设计成连续取值?因为Q-Learning本身是离散动作算法,每回合只能从有限集合里选一个动作,强行连续化会导致无法直接使用max操作。三选一已经能表达“左推、右推、不推”三种必要行为,比二选一多了一个“保持不动”的选项,这对于倒立摆平衡很重要——当摆杆很接近竖直时,最优策略可能就是不施加任何控制力,让系统自然稳定。
力的大小需要匹配小车质量。小车质量是1kg,如果力太小,比如±2N,在摆杆角度偏大时根本无法把倾倒趋势扳回来,训练会一直失败;如果力太大,比如±100N,小车会在几步之内冲到边界,位置发散比角度发散还快。我按工程经验取±10N,在1kg小车质量下足够快速纠偏,又不会让小车行程在短时间内撞到限位。
2.3 奖励函数:要同时抑制“原地抖”和“往一边跑”
奖励函数是强化学习项目里最值得花时间的部分。我在这个项目里用的奖励函数是:
r = cos(θ) - 0.1 × |x| - 0.01 × |dtheta|
如果发生终止条件,再额外减1。
这个设计是逐项考虑的。cos(θ)这一项最核心,θ越接近0,奖励越接近1,否则迅速下降,相当于把“摆杆竖直”这一目标放在最高优先级;0.1×|x|是位置惩罚,防止小车只顾平衡摆杆、不管自己已经冲到边界附近;0.01×|dtheta|是角速度惩罚,让智能体尽量避免高频抖动,如果完全去掉这一项,学到的策略往往会看到小车在平衡点附近快速来回震荡,摆杆角度虽然在可控范围,但控制力曲线非常难看。
还有一个很容易犯的错:用稀疏奖励,也就是“保持直立到最后给+1,倒下给-1”。这种设计思路听起来合理,但实际训练时智能体绝大多数回合都很快倒下,奖励几乎全是-1,反馈信息太少,探索效率非常差。改成密集奖励后,智能体每一步都能感受到“角度更接近竖直会让奖励变大”,学习速度提升非常明显。
如果你的训练对象和这个项目类似,可以通过现象反推怎么调权重:摆杆能立住但小车一直在朝一个方向跑,增大|x|前面的系数;摆杆角度稳定但控制力来回切换,增大|dtheta|前面的系数;摆杆总有一个固定偏差,减小cosθ对微小角度的敏感度或者增加一点角度二次惩罚项。
2.4 学习率、折扣因子、探索率:参数背后的实际影响
这几个参数对收敛速度影响很大,我直接说最终采用的参数组合:
| 参数 | 推荐值 | 影响 |
|---|---|---|
| 学习率α | 0.1,后期降到0.02 | 值太大导致Q值震荡,太小导致学习过慢 |
| 折扣因子γ | 0.95 | 值太接近1会让策略过于看重远期,太小会只顾眼前 |
| 初始探索率ε | 1.0 | 保证前期充分探索 |
| 最低探索率ε_min | 0.05 | 保留少量探索避免Q表固化 |
| 探索衰减方式 | 线性衰减,20000回合内从1降到0.05 | 探索和利用之间逐步平衡 |
学习率α控制的是每一次Q值更新对新信息的采纳程度。0.1起步,随着训练推进慢慢降到0.02,是因为前期需要快速吸收经验,后期Q值已经接近真实值,更新幅度太大反而会破坏已经收敛的部分。
折扣因子γ=0.95表示未来奖励的权重。倒立摆这个任务每步奖励量级在-1到1之间,500步回合,如果γ太接近1,累计奖励在数学上会膨胀,Q值更新容易不稳定;γ=0.95时,智能体既要考虑未来几步能不能保持平衡,又不会因为远期目标太模糊而忽视眼前状态。
探索率ε的选择很关键。我用的是ε-greedy策略:初期ε=1,也就是完全随机动作,让智能体尽可能多地见识不同状态;随训练回合数线性下降到0.05,后期绝大多数动作都选择当前Q值最大的动作,只保留少量探索测试。如果ε一直固定为0.1,你会发现Q表后期的噪声很大,因为已经稳定的策略偶尔会被随机动作打断,回报曲线会一直上下波动。
3. MATLAB仿真实现与训练流程:从环境到Q表收敛
3.1 仿真文件组织与数据流
MATLAB写强化学习项目,我建议把环境、智能体和训练主流程分开,不要全塞在一个脚本里。我的文件结构大致是:
- envStep.m:环境更新函数,输入当前状态和动作,输出下一个状态、奖励、是否终止
- stateToIndex.m:连续状态到离散格索引的映射函数
- trainQLearning.m:主训练脚本,负责Q表初始化、回合循环、参数更新和日志记录
- plotResult.m:训练后绘图和性能评估,包括回报曲线、状态轨迹、控制力曲线
这样做的最大好处是以后切换算法非常方便。比如想把Q-Learning换成SARSA或DQN,只需要改智能体的学习模块,环境部分完全不用动。我调试的时候也方便,环境函数有bug时可以先单独测试envStep,不需要每次都跑完整训练。
3.2 环境函数与状态映射代码
envStep是仿真的核心,输入当前状态向量s和控制力F,通过动力学方程更新状态。核心代码大致是:
function [sNext, reward, done] = envStep(s, F) g = 9.8; M = 1.0; m = 0.1; L = 0.5; dt = 0.02; x = s(1); theta = s(2); dx = s(3); dtheta = s(4); % 摆杆角加速度 thetaAcc = (g*sin(theta) - cos(theta)*(F + m*L*dtheta^2*sin(theta))/(M+m)) / ... (L*(4/3 - m*cos(theta)^2/(M+m))); % 小车加速度 xAcc = (F + m*L*(dtheta^2*sin(theta) - thetaAcc*cos(theta))) / (M+m); sNext = s + dt * [dx; dtheta; xAcc; thetaAcc]; % 终止判断 done = abs(sNext(2)) > 0.6 || abs(sNext(1)) > 2.4; % 奖励函数 reward = cos(sNext(2)) - 0.1*abs(sNext(1)) - 0.01*abs(sNext(4)); if done reward = reward - 1.0; end end状态映射函数的作用是把连续状态变成Q表索引。注意边界处理,如果状态正好等于上限,索引必须封顶到最后一格,否则会数组越界:
function idx = stateToIndex(s, sMin, sMax, gridN) idx = zeros(1,4); for i = 1:4 binWidth = (sMax(i) - sMin(i)) / gridN(i); idx(i) = min(gridN(i), max(1, floor((s(i) - sMin(i)) / binWidth) + 1)); end end3.3 Q-Learning训练循环与更新代码
训练主循环是整个项目的核心逻辑。每一回合先随机初始化状态,然后循环执行“选动作→走一步→更新Q值→进入下一状态”,直到回合结束。Q值更新严格遵循标准公式:
Q(s,a) = Q(s,a) + α × (r + γ × max(Q(s',a')) - Q(s,a))
终止状态时,max(Q(s',a'))这一项要置为0,因为结束后没有未来奖励了。训练循环代码大致如下:
MAX_EPISODES = 20000; MAX_STEPS = 500; alpha = 0.1; gamma = 0.95; epsilon = 1.0; actions = [-10; 0; 10]; gridN = [12 12 12 12]; sMin = [-2.4, -0.6, -3.0, -5.0]; sMax = [2.4, 0.6, 3.0, 5.0]; Q = zeros(gridN(1), gridN(2), gridN(3), gridN(4), length(actions)); returns = zeros(MAX_EPISODES, 1); for episode = 1:MAX_EPISODES theta0 = (rand*2 - 1) * 0.1; s = [0; theta0; 0; 0]; done = false; stepCount = 0; episodeReward = 0; while ~done && stepCount < MAX_STEPS idx = stateToIndex(s, sMin, sMax, gridN); if rand < epsilon aIdx = randi(length(actions)); else [~, aIdx] = max(Q(idx(1), idx(2), idx(3), idx(4), :)); end F = actions(aIdx); [sNext, reward, done] = envStep(s, F); idxNext = stateToIndex(sNext, sMin, sMax, gridN); qPredict = Q(idx(1), idx(2), idx(3), idx(4), aIdx); if done qTarget = reward; else qTarget = reward + gamma * max(Q(idxNext(1), idxNext(2), idxNext(3), idxNext(4), :)); end Q(idx(1), idx(2), idx(3), idx(4), aIdx) = qPredict + alpha * (qTarget - qPredict); s = sNext; stepCount = stepCount + 1; episodeReward = episodeReward + reward; end epsilon = max(0.05, 1 - 0.95 * episode / MAX_EPISODES); alpha = max(0.02, 0.1 - 0.08 * episode / MAX_EPISODES); returns(episode) = episodeReward; end这段代码可以直接跑,但两个细节需要留意。一是Q表初始化成全零,我开始也这么用,实际效果OK;不过有些人喜欢先给一个小正值初始化,鼓励智能体探索更多状态,我的经验是只要探索率设计合理,全零初始化完全够用。二是回合结束条件中要用“更新后的状态”来判断是否终止,不要用旧状态,否则智能体会在多走一步之后才收到失败的反馈,时序上差了一步,训练效率会降低。
3.4 训练过程怎么看:回报曲线与成功标准
训练不是放着等结果就行,我习惯每50回合计一次滑动平均回报画出来。前期回报通常是负的,因为智能体不知道怎样控制力才能让摆杆保持平衡,大部分回合很快倒下;中期回报开始上升,说明智能体逐渐学到了一部分状态下的正确动作;后期回报稳定在高位,并且曲线波动很小,说明策略基本收敛。
单看回报还不够,我还会记录每个回合成功与否。如果连续20个回合都成功保持500步,就提前终止训练并保存Q表。我实际跑出来的结果是,在大约8000回合时回报曲线开始明显抬升,到15000到18000回合达到稳定。这是正常速度,不要太早放弃。如果你发现训练了5000回合回报还是完全平的、一点上升趋势都没有,那大概率不是算法问题,而是环境、状态映射或奖励函数设计出了问题,下面一节专门讲这类问题。
4. 训练调试实战:常见发散问题与排查技巧
4.1 长时间不收敛、震荡与“原地乱动”的根因
很多人在训练强化学习时第一反应是怀疑算法,但根据我的经验,Q-Learning本身公式很成熟,绝大多数“不收敛”都是环境或参数没配对。
状态离散化太粗是最隐蔽的原因。出现这种现象时,训练初期回报还有一点上升,但很快卡死在一个较低水平,因为智能体无法区分“摆杆正在以高速远离垂直位置”和“摆杆正在以低速接近垂直位置”,这两个状态如果被分到了同一个格子,它就只能用过拟合的方式选择动作,结果就是控制力反复横跳。
奖励函数量纲失衡也很常见。比如位置惩罚系数设得很大,智能体就只关心让小车留在中间,即使摆杆已经倒到一边也不愿意大力纠正,因为冲出边界扣的分更多;反之,如果角度惩罚太小,智能体虽然让摆杆保持竖直,但小车一直在轨道上疯狂左右冲,直到某个时刻位置超限才结束,这本质上是探索不足。
还有一种情况叫做“动作盲区”:动作力太小,无法抵抗重力矩,智能体无论选哪个动作结果都是倒下。这时候问题不在强化学习,而在物理参数。把力从±5N改成±10N之后,训练效果立刻好转。所以遇到发散的回合,先检查这个基本项。
4.2 从完全发散到稳定平衡的调参记录
我自己的调试过程挺有代表意义。第一次跑的时候,我用的奖励函数是r = cos(θ) - 0.5×|x|,想着位置惩罚大一点,小车不会乱跑。结果训练到3000回合,回报一直稳定在-3左右,摆杆基本撑不过30步。后来我检查每个回合结束时的状态,发现小车确实很少冲边界,但摆杆角度经常在0.3到0.5之间缓慢偏移,角度偏离到0.6才触发终止。这说明智能体学会了“只要位置不超限就行”的偷懒策略,把奖励函数的逻辑带偏了。
第二次我把位置惩罚降到0.1,角度项变成主导,训练明显好转,但又出现了新的问题:小车开始反复左右冲,从一条轨迹上看,位置一直在边界附近反弹。原因是我给的位置惩罚集中在绝对值项上,而角度控制变强之后,小车需要比较大的位移来维持平衡。后来我在奖励里加入角速度惩罚项,才把这种高频抖动压住。整个调参过程下来,我的体会是:奖励函数的每一项都需要一个明确的行为动机,如果发现智能体做出了你不希望看到的“钻空子”行为,优先调整奖励权重,而不是急着改Q-Learning参数。
4.3 常见问题速查表
| 现象 | 可能原因 | 建议处理方式 |
|---|---|---|
| 回报长期不上升 | 状态离散化太粗 | 增加网格数,或缩小状态边界 |
| 奖励出现大幅震荡 | 学习率α偏大 | 将α降到0.05以下 |
| 摆杆能立住但小车乱跑 | 位置惩罚权重低 | 增加 |
| 控制力高频来回切换 | 缺少角速度惩罚 | 加入0.01× |
| 摆杆一直停在某个偏角 | 角度项敏感度不足 | 增大角度误差惩罚,比如用θ² |
| 每次都在固定状态倒下 | 探索率衰减太快 | 降低衰减速度,保证后期仍有随机探索 |
| 训练后期Q值被随机动作污染 | ε降到0.05以下后仍有明显波动 | 关闭探索,直接贪婪策略测试 |
5. 性能仿真评估与后续扩展:学会看指标,也留好算法接口
5.1 训练完成后的性能仿真怎么测
训练收敛之后,很多人就停了,但一个完整的性能仿真项目不应该终止于训练完成。我用下面这几个方法验证策略的真实表现。
先把探索彻底关掉,ε直接设为0,用纯贪婪策略测试50个随机初始状态,统计三个指标:成功回合率、平均保持时间、最大可恢复初始角度。我跑出来的常见结果是:成功回合率在90%以上,平均保持时间超过9秒,最大可恢复初始角度大约在0.35弧度左右。这些指标比你单看“训练时最后几回合成功”更有说服力,因为随机初始状态不会和训练时的初始分布完全一样。
再做一次干扰测试,在某个时间点突然给摆杆注入一个角速度扰动,相当于模拟外界冲击,观察系统能不能在几秒内恢复平衡。恢复时间越短,说明策略越鲁棒。这种测试在课程设计评审或项目答辩时很加分,因为别人看到的是“不仅训练的时候能立住,遇到扰动也能自动恢复”。
最后绘图分析。把测试阶段的状态轨迹和控制力曲线画出来,重点看两个地方:稳态时控制力是否有明显的高频抖动,以及摆杆角度是否在零点附近对称波动。如果控制力曲线看起来像锯齿一样,即使摆杆没倒,也说明策略还有改进空间,可以尝试在奖励函数里继续加大角速度惩罚。
5.2 从Q-Learning走向DQN:这套环境还能怎么复用
这个项目最大的复用价值在于:MATLAB里这套环境函数完全不需要改动,我可以直接把它接给其他强化学习算法。比如把Q表换成一个小型神经网络,输入连续状态,输出每个动作的Q值,就变成DQN。这时候状态离散化函数可以扔掉,动作集可以保留三值动作,训练框架从查表更新变成神经网络的反向传播。
还可以往双倒立摆、倒立摆加随机风干扰、或者小车轨道带斜坡的方向扩展。每次只改一个环节,比如改环境方程、改奖励函数、改动作集,就能观察算法在不同难度下的表现。这种扩展思路很适合用来作为一个系列实验,从简单到复杂一层层验证,比单独跑一个固定任务更能说明算法的能力边界。
最后说一点体感。这个项目里,Q值更新本身只占一小部分代码,真正决定成败的是环境建模和奖励设计。你在MATLAB里跑的时候,如果发现训练初期一片混乱,先别急着怀疑强化学习,回过头检查状态映射有没有写错、奖励量纲合不合理、动作力能不能抵抗重力矩。把环境写对了,Q-Learning的收敛速度往往比你预期的更快。把这个流程走通之后,再去做DQN或者其他连续控制算法,你会对每一行代码和数据流都有更清楚的感觉。