☰
从零手写Q-Learning:原理、实现与调参避坑指南
2026/10/14 11:42:25 网站建设 项目流程

1. 为什么我要从零手写一遍Q-Learning

很多人第一次接触强化学习,都是从Q-Learning开始的。它足够简单,简单到用一张Excel表格就能手工推演;但它又足够深刻,深刻到几乎包含了强化学习所有核心概念的雏形——状态、动作、奖励、策略、价值函数、探索与利用的权衡。我见过太多人上来就跑深度强化学习的代码,结果连贝尔曼方程在干什么都说不清楚,调参全靠玄学。所以这篇文章,我想把Q-Learning这件事从头到尾讲透,不是那种“贴一段代码然后说它能跑”的教程,而是把每一步为什么这么做、参数怎么定、坑在哪里,全部摊开来讲。

这篇文章适合谁看?如果你已经会Python基础语法,懂一点numpy,想真正搞明白强化学习到底在学什么,那这篇就是写给你的。如果你已经用过Q-Learning但总觉得哪里没吃透,比如epsilon到底该设多少、学习率大了小了会怎样、为什么有时候Q值会爆炸,那这篇也能帮你把那些模糊的地方补上。我会用一个经典的网格世界(GridWorld)作为贯穿案例,从环境搭建、算法实现、参数调优到问题排查,一步步走完。所有代码都是可以直接复制运行的,没有隐藏依赖,不需要GPU,一台普通笔记本就能跑。

先说清楚Q-Learning的定位。它属于无模型(Model-Free)、异策略(Off-Policy)、时序差分(Temporal Difference, TD)的强化学习算法。这三个标签每一个都有实际含义:无模型意味着我们不需要知道环境的转移概率和奖励函数,直接通过交互来学;异策略意味着行为策略(用来探索的策略)和目标策略(被评估和改进的策略)可以不同;时序差分意味着我们不需要等到回合结束才更新,而是每一步都可以用下一步的估计值来更新当前值。理解了这三个标签,你就理解了Q-Learning的设计哲学。

2. Q-Learning的核心原理拆解

2.1 从“值”的角度理解强化学习

强化学习的目标,说白了就是让智能体在某个环境里学会一套动作策略,使得长期累积奖励最大化。这里的关键词是“长期”。有时候为了长远利益,你得放弃眼前的小利,这正是强化学习比监督学习难的地方——奖励是延迟的,你做了一个动作,可能过几十步才知道这个动作到底好不好。

Q-Learning解决这个问题的思路是:维护一张表,记录在某个状态下做某个动作的“价值”。这个价值用Q值表示,Q(s, a)的含义是“在状态s下执行动作a,之后按照最优策略继续走下去,能拿到的累积奖励的期望”。注意这里的“按照最优策略继续走”,这是Q-Learning最精妙的地方——它在更新时用的是下一状态的最大Q值,而不是实际执行的那个动作的Q值。这就是异策略的体现。

用数学语言写出来就是:

Q(s, a) ← Q(s, a) + α [r + γ · max Q(s', a') - Q(s, a)]

这个公式是整个算法的灵魂。α是学习率,控制新信息覆盖旧信息的比例;γ是折扣因子,控制未来奖励的权重;r是即时奖励;max Q(s', a')是对下一状态所有可能动作中最大Q值的估计。中括号里的部分叫TD误差(TD Error),衡量的是“我原来的估计”和“我现在的估计”之间的差距。

2.2 为什么用max而不是实际动作的Q值

这个问题值得单独拿出来说,因为它是Q-Learning和SARSA的分水岭。SARSA的更新用的是实际执行的下一个动作的Q值,而Q-Learning用的是最大Q值。区别在哪?

假设智能体在一个悬崖边上,最优策略是沿着悬崖边走(因为路径最短),但偶尔探索时会掉下去。SARSA因为用的是实际动作的Q值,它会“知道”自己有时候会掉下去,所以学到的策略会离悬崖远一点,更保守。Q-Learning因为用的是max,它假设下一步一定选最优动作,所以学到的策略就是贴着悬崖走,更激进。

这不是谁好谁坏的问题,而是适用场景不同。如果你在训练一个真实机器人,掉下去就摔坏了,那SARSA的保守策略更安全;如果你在模拟环境里训练,摔了可以重来,那Q-Learning的激进策略能更快找到最优解。理解这一点,你在实际项目中选算法时就不会盲目。

2.3 探索与利用的平衡策略

Q-Learning有一个绕不开的问题:如果智能体一直选当前Q值最大的动作,那它可能永远发现不了更好的动作。这就是探索(Exploration)和利用(Exploitation)的矛盾。

最常用的解决方案是ε-贪婪策略:以ε的概率随机选一个动作(探索),以1-ε的概率选当前Q值最大的动作(利用)。ε通常从一个较大的值开始(比如1.0,完全随机),然后逐渐衰减到一个较小的值(比如0.01,几乎完全利用)。衰减的方式可以是线性的、指数的,或者按回合数分段衰减。

我个人的经验是,ε的衰减速度比初始值更重要。衰减太快,智能体还没探索够就开始利用,容易陷入局部最优;衰减太慢,训练时间会拉得很长。一个比较稳妥的做法是让ε在前60%的回合里从1.0线性降到0.1,然后在剩余回合里从0.1缓慢降到0.01。当然,具体数值要根据环境复杂度来调。

3. 用GridWorld把算法跑起来

3.1 环境设计:一个5x5的网格世界

为了让代码足够直观,我用一个5x5的网格世界作为环境。规则很简单:智能体从左上角出发,目标是到达右下角。每走一步奖励-1,到达目标奖励+10。网格里有一些障碍物,撞上去奖励-5并且原地不动。动作空间是上下左右四个方向。

为什么选这个环境?因为它足够小,小到你可以手工验证每一步的Q值更新是否正确;同时它又包含了强化学习的核心挑战——延迟奖励、障碍物惩罚、路径规划。你把这个环境跑通了,换成更复杂的环境(比如FrozenLake、Taxi)只是改一下状态转移逻辑的事。

环境的状态可以用一个整数表示,比如state = row * 5 + col。动作可以用0,1,2,3表示上下左右。奖励函数和转移函数都写死在环境类里。这里有一个细节:到达目标状态后,回合结束,不再有后续状态。这个终止条件在代码里要处理好,否则Q值更新会出错。

3.2 Q表的初始化与更新逻辑

Q表就是一个二维数组,行数是状态数(25),列数是动作数(4)。初始化通常全设为0,也可以设成一个小的随机值。我倾向于全设0,因为这样更容易复现结果,而且对于小规模问题,从0开始收敛速度已经够快了。

更新逻辑就是前面那个公式的直接翻译。每一步,智能体根据ε-贪婪策略选一个动作,执行后得到奖励和下一状态,然后计算TD误差并更新Q表。如果下一状态是终止状态,那么max Q(s', a')直接设为0,因为终止状态没有后续动作。

这里有一个容易踩的坑:Q表的索引顺序。如果你用numpy数组,Q[state, action]和Q[action, state]是完全不同的。我建议统一用Q[state, action],因为这样在取max的时候可以直接用np.max(Q[next_state, :]),代码更简洁。另外,状态和动作的映射关系要写清楚注释,否则过两天回来看代码自己都忘了。

3.3 完整代码实现与逐行注释

import numpy as np import random class GridWorld: def __init__(self, size=5): self.size = size self.n_states = size * size self.n_actions = 4 # 0:上, 1:下, 2:左, 3:右 self.start_state = 0 self.goal_state = size * size - 1 # 障碍物位置(用状态编号表示) self.obstacles = [6, 7, 12, 17, 18] self.max_steps = 100 def reset(self): self.state = self.start_state self.steps = 0 return self.state def step(self, action): self.steps += 1 row = self.state // self.size col = self.state % self.size if action == 0: row = max(0, row - 1) elif action == 1: row = min(self.size - 1, row + 1) elif action == 2: col = max(0, col - 1) elif action == 3: col = min(self.size - 1, col + 1) next_state = row * self.size + col if next_state in self.obstacles: next_state = self.state reward = -5 elif next_state == self.goal_state: reward = 10 else: reward = -1 done = (next_state == self.goal_state) or (self.steps >= self.max_steps) self.state = next_state return next_state, reward, done

环境类写好了,接下来是Q-Learning的主体。我把它写成一个函数,方便调参。

def q_learning(env, n_episodes=1000, alpha=0.1, gamma=0.99, epsilon_start=1.0, epsilon_end=0.01, decay_ratio=0.6): Q = np.zeros((env.n_states, env.n_actions)) rewards_history = [] decay_episodes = int(n_episodes * decay_ratio) for episode in range(n_episodes): state = env.reset() total_reward = 0 done = False # epsilon线性衰减 if episode < decay_episodes: epsilon = epsilon_start - (epsilon_start - epsilon_end) * (episode / decay_episodes) else: epsilon = epsilon_end while not done: # epsilon-贪婪策略选择动作 if random.random() < epsilon: action = random.randint(0, env.n_actions - 1) else: action = np.argmax(Q[state, :]) next_state, reward, done = env.step(action) total_reward += reward # Q值更新 if done: td_target = reward else: td_target = reward + gamma * np.max(Q[next_state, :]) Q[state, action] += alpha * (td_target - Q[state, action]) state = next_state rewards_history.append(total_reward) return Q, rewards_history

这段代码里有两个地方值得展开说。第一,epsilon的衰减我用了线性方式,并且把衰减过程限制在前60%的回合里,后40%保持最小epsilon。这样做的好处是前期探索充分,后期策略稳定。第二,终止状态的TD目标直接等于奖励,没有加gamma乘以max Q,因为终止状态没有后续动作。这个细节如果处理错了,Q值会在目标状态附近出现异常。

3.4 训练过程与结果验证

跑1000个回合,每100个回合打印一次平均奖励。你会看到平均奖励从最初的-100左右(因为随机走,经常撞障碍物)逐渐上升到-10以内(接近最优路径)。最优路径大概是8步左右,每步-1,加上终点+10,总奖励大约是+2。如果训练正常,最后几百个回合的平均奖励应该稳定在0到+3之间。

验证策略是否最优的方法很简单:把epsilon设为0,从起点开始跑一个回合,打印出路径。如果路径是沿着最短路线避开障碍物到达终点,说明Q表已经收敛。你也可以把Q表打印出来,看看每个状态下四个动作的Q值,最大的那个是否指向合理的方向。

4. 参数调优与常见问题排查

4.1 学习率α和折扣因子γ怎么选

学习率α控制每次更新的步长。α太大,Q值会震荡,甚至发散;α太小,收敛速度慢得让人抓狂。对于GridWorld这种确定性环境,α=0.1是一个比较稳妥的起点。如果你发现训练曲线波动很大,试着降到0.05;如果收敛太慢,可以提到0.2,但一般不建议超过0.3。

折扣因子γ控制未来奖励的权重。γ=0.99意味着智能体相当“有远见”,愿意为了10步后的奖励放弃眼前的利益。γ=0.9则更短视。对于GridWorld,γ=0.99是合适的,因为最优路径大概8步,折扣后的未来奖励仍然有足够的权重。如果γ太小(比如0.5),智能体可能学不会绕开障碍物,因为绕路的代价在折扣后变得微不足道。

有一个经验公式可以参考:γ的取值应该使得有效视野(大约1/(1-γ)步)覆盖到目标所需的最大步数。GridWorld最大步数100,但最优路径8步,所以1/(1-0.99)=100,足够覆盖。如果环境更大,比如20x20,最优路径可能40步,那γ=0.99仍然够用,但训练回合数要增加。

4.2 Q值爆炸与震荡的排查思路

Q值爆炸是Q-Learning最常见的问题之一。表现是Q值越来越大,最终变成inf或nan。原因通常有三个:学习率太大、奖励尺度太大、或者更新公式写错了。

排查步骤我一般是这样:先检查更新公式,确认td_target的计算没有多加或少加gamma;然后把学习率降到0.01试试,如果Q值不再爆炸,说明是学习率的问题;如果还不行,检查奖励函数,把奖励缩放到[-1, 1]区间再试。奖励缩放是一个很实用的技巧,尤其是当你的环境奖励有正有负且量级差异大时。

Q值震荡的表现是训练曲线上下波动,没有明显的收敛趋势。这通常是因为epsilon衰减太快,智能体在探索不足的情况下就锁定了某个次优策略。解决办法是放慢epsilon衰减,或者增加训练回合数。另外,如果环境本身有随机性(比如动作有概率失败),Q值震荡是正常的,这时候要看平均奖励的趋势而不是单个回合的奖励。

4.3 训练不收敛的五个典型原因

问题现象可能原因排查方法解决方案
平均奖励一直上不去epsilon太大或衰减太慢打印每回合的epsilon值加快衰减或降低epsilon_end
策略在几个状态间循环障碍物惩罚不够或奖励设计不合理检查奖励函数增加障碍物惩罚或调整目标奖励
Q值全为0或变化极小学习率太小或奖励太小打印Q表统计量提高学习率或放大奖励
到达目标后Q值异常终止状态处理错误检查done时的TD目标终止状态不加gamma*max
训练后期性能下降过拟合或epsilon太小对比不同epsilon下的表现保持最小epsilon在0.01以上

这张表是我在实际项目中总结出来的,基本上覆盖了90%的Q-Learning问题。遇到问题的时候按表排查,比盲目调参高效得多。

4.4 收敛速度优化的三个实用技巧

第一个技巧是乐观初始化。把Q表初始化为一个较小的正值(比如0.1),而不是0。这样智能体在早期会倾向于尝试那些还没访问过的状态-动作对,因为它们的Q值“看起来”比已经访问过的(被更新后可能变成负值)更高。这本质上是一种内置的探索机制,能显著加快早期探索速度。

第二个技巧是经验回放。虽然Q-Learning是异策略算法,理论上可以用经验回放,但在表格型Q-Learning里用得不多。不过如果你的环境状态数很多,可以考虑把最近的经验存下来,每次更新时随机抽一批来更新。这样做的好处是打破经验之间的相关性,让Q值更新更稳定。代价是需要更多内存和计算。

第三个技巧是动态调整学习率。训练初期用大学习率快速探索,训练后期用小学习率精细调整。实现方式可以是α = α_0 / (1 + episode / K),其中K是一个常数。这样α会随着回合数增加而逐渐减小,收敛更稳定。我在几个项目里试过,相比固定学习率,动态学习率能让最终策略的稳定性提升不少。

5. 从表格到神经网络:Q-Learning的扩展思路

5.1 什么时候表格不够用

表格型Q-Learning有一个硬性限制:状态数必须是有限的,而且不能太大。如果状态是连续的(比如机器人的关节角度),或者状态数是指数级的(比如围棋的棋盘状态),表格就存不下了。这时候就需要用函数逼近,最常用的就是神经网络,也就是DQN(Deep Q-Network)。

从表格到神经网络的过渡,核心变化是:不再维护一张Q表,而是训练一个神经网络,输入状态,输出每个动作的Q值。更新的时候,损失函数是TD误差的平方,用梯度下降来更新网络参数。听起来简单,但实际做起来有几个坑:样本相关性、目标网络、经验回放,这些在表格型Q-Learning里都不是问题,但在DQN里必须处理。

5.2 经验回放与目标网络的作用

经验回放解决的是样本相关性问题。在强化学习里,连续的经验是高度相关的(这一步和下一步的状态很相似),如果直接用这些样本来训练神经网络,会导致网络在局部样本上过拟合,训练不稳定。解决办法是把经验存到一个缓冲区里,每次训练时随机抽一批,这样样本之间就近似独立了。

目标网络解决的是目标值不稳定的问题。在表格型Q-Learning里,TD目标用的是当前的Q表,更新后Q表变了,目标也变了,但因为是表格,变化是局部的、可控的。在神经网络里,目标值依赖于网络参数,网络参数一变,目标值也跟着变,容易导致训练震荡。解决办法是维护两个网络:一个在线网络用来选动作和更新,一个目标网络用来计算TD目标,目标网络的参数定期从在线网络复制过来,这样目标值在一段时间内是稳定的。

这两个技巧在表格型Q-Learning里不是必须的,但在DQN里是标配。如果你打算从表格过渡到神经网络,建议先把这两个概念搞清楚,再动手写代码。

5.3 表格型Q-Learning的适用边界

虽然DQN很强大,但表格型Q-Learning并没有过时。在很多实际问题里,状态空间是离散且有限的,比如库存管理、推荐系统的粗排、简单的游戏AI,表格型Q-Learning完全够用,而且训练速度快、可解释性强、调参简单。我个人的建议是:能用表格就用表格,实在不行再上神经网络。不要为了用新技术而用新技术,解决问题才是目的。

表格型Q-Learning的适用边界大概是:状态数在10^6以内,动作数在100以内,环境是离散的或者可以合理离散化。超过这个范围,表格的内存和训练时间就会成为瓶颈。另外,如果状态之间的泛化很重要(比如相似状态应该有相似Q值),表格型Q-Learning需要额外的泛化机制(比如状态聚合),而神经网络天然具备泛化能力。

6. 几个我踩过的坑和实操心得

第一个坑是状态编码不一致。有一次我在环境里用(row, col)元组表示状态,在Q表里用整数索引,结果在更新的时候忘了转换,导致Q表更新到了错误的位置。训练了半天,策略完全不对。后来我养成了一个习惯:在环境类里统一用整数表示状态,所有对外接口都返回整数,这样就不会出现编码不一致的问题。

第二个坑是奖励设计不合理。我一开始把障碍物惩罚设成-1,和目标奖励+10相比太小了,智能体宁愿撞障碍物也不愿意绕路。后来把障碍物惩罚改成-5,智能体才学会避开。奖励设计是强化学习里最玄学的部分,没有固定公式,只能多试。我的经验是:惩罚要足够大,让智能体“疼”到记住;但也不能太大,否则智能体会变得过于保守,不敢探索。

第三个坑是epsilon衰减太快。有一次我设了decay_ratio=0.2,结果智能体在前200个回合就完成了探索,后面800个回合都在利用一个次优策略。训练曲线看起来收敛了,但实际策略不是最优的。后来我把decay_ratio调到0.6,问题解决。这个参数没有理论最优值,只能根据环境复杂度和训练预算来试。

第四个坑是忘了处理终止状态。在GridWorld里,到达目标后回合结束,但我的代码里没有正确处理done时的TD目标,导致目标状态的Q值被反复更新,越来越大。后来加上if done: td_target = reward的判断,问题解决。这个坑很隐蔽,因为训练曲线可能看起来正常,但策略在目标附近会表现异常。

最后分享一个调试技巧:把Q表打印出来看。对于小规模问题,直接把Q表打印成矩阵,每个状态一行,每个动作一列,看看最大值是否指向合理的方向。如果某个状态的Q值全是0或者全是负数,说明这个状态很少被访问到,可能需要调整探索策略。这个技巧虽然原始,但非常有效,比看训练曲线直观得多。

另外,如果你发现训练结果每次都不一样,不要慌,这是正常的。Q-Learning的探索过程有随机性,不同的随机种子会得到不同的训练轨迹。判断算法是否正常,要看多次运行的平均表现,而不是单次结果。我一般会跑5次,取平均奖励曲线,如果5次的趋势一致,说明算法是稳定的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询