强化学习(Reinforcement Learning)大概是2024年最让人“又爱又恨”的方向。爱的是它的想象力——游戏AI能打出超越人类极限的操作、机器人在仿真环境里自己学会行走、推荐系统可以根据实时反馈持续优化策略;恨的是它的学习曲线实在陡峭,数学推导一层套一层,代码实现看着简单跑起来全是问题,很多人在“看过无数教程”之后还是写不出一个真正能学习的demo。这篇文章就是这个系列的第一篇,不搞虚的,目标就一个:把强化学习从“听着很高深”拉回到“自己能亲手跑起来”。
如果你是Python基础还不错的开发者,或者学过一点机器学习,但对强化学习始终停留在“听说过Q-learning”这个层面,那这篇很适合你。我会从2024年的视角,重新梳理强化学习最核心的理论骨架——马尔可夫决策过程、回报、价值函数、Bellman方程,然后带大家用几十行代码,从零手写一个能收敛的Q-learning算法,去解决一个网格世界寻宝问题。先把地基打牢,后面再接触DQN、策略梯度那些大家伙,才不会一头雾水。
1. 内容怎么组织:这个系列到底要做什么
1.1 为什么偏偏从网格世界切入
很多人学强化学习喜欢一上来就挑战CartPole、Atari游戏甚至机器人控制,结果被环境接口、奖励设计、训练稳定性这些杂七杂八的问题淹没了,最后连核心算法都没看清楚。我强烈建议反过来:先用一个你能完全理解的环境把核心机制吃透,再去碰复杂系统。
网格世界(Gridworld)就是这个“能吃透的环境”。它只有十几个状态、四个动作,Q表展开来看就是一个小矩阵,每一步训练发生了什么,完全可视化。训练到一半你就能直观地看到哪些状态的值在涨、哪些动作被偏好,这种“可解释、可调试”的特性,让它成为学习RL时的最佳第一站,没有之一。
1.2 理论讲到什么程度才算“够用”
这个系列不追求把所有数学公式都推导一遍,也坚决避免把算法讲成只剩代码注释。我的原则是八个字:够用且讲明。
所有理论讲解都围绕一个问题:你写的每一行代码到底在解决什么?比如Bellman方程,我会告诉你它的直观含义、为什么Q-learning的更新公式长成那个样子、如果你把符号搞反了会发生什么。数学在这里是工具而不是门槛,理解它背后的直觉,比死记公式有用得多。
1.3 学习这个系列需要哪些基础
说实在的,门槛比你想象中低。需要你会Python基础语法,能看懂numpy的基本操作,理解简单的概率概念(比如期望值是什么)。如果你接触过一点机器学习的损失函数、梯度下降之类的概念,会有帮助但不是必须。线性代数和微积分?在入门阶段基本用不上,用到的时候我会现场解释。
所以,不要被“强化学习”这四个字吓退,它本质上就是一套教智能体“通过试错学会做决策”的框架,跟人学游泳、学骑车的逻辑是一样的。
2. 核心理论拆解:从MDP到Bellman方程
2.1 马尔可夫决策过程:强化学习的骨架
强化学习的几乎所有问题,都能抽象成马尔可夫决策过程(MDP)。MDP由五样东西组成:状态集合S、动作集合A、状态转移概率P、奖励函数R、折扣因子γ。是不是看着很抽象?我们用网格世界来对照一下就清楚了。
假设有一个4×4的网格,左上角是起点,右下角是终点宝藏。状态集合S就是我们可能站在的所有格子位置,动作集合A就是“上、下、左、右”四个方向。当你执行某个动作后,会从当前格子转移到下一个格子,这个转移规则就是状态转移概率P——在这个确定性环境里,P就是“往右走一定会到右边那个格子”。每到达一个位置,环境会给你一个即时奖励R,这里是到达终点给+1,其它情况给0。
为什么要这个抽象框架?因为它把所有决策问题都统一成了一个描述模型,不管你是做游戏AI、机器人控制还是推荐系统,都能映射成这几个要素。理解了MDP,你就抓住了强化学习的“语法”。
2.2 折扣因子γ:为什么未来的奖励要打折
现在说MDP里最需要一个“直觉解释”的参数——折扣因子γ,取值在0到1之间。
为什么要给未来的奖励打折?两个原因。第一,未来是不确定的,你今天承诺给我100块,和一年后承诺给我100块,价值显然不一样。第二,从数学上考虑,智能体在一个无限长的任务里,如果所有奖励都不打折,总回报可能无穷大,那比较策略好坏就无从谈起了。
γ的取值直接影响智能体的“眼光”:γ接近1,意味着智能体很有远见,愿意为长期收益放弃眼前利益;γ接近0,则意味着智能体只看重眼前的即时奖励,是个“短视鬼”。实际操作中,0.9到0.99是最常见的区间,具体取多少要看任务的时间跨度。网格世界这种短任务,0.95是一个稳妥的起点。
2.3 价值函数V与动作价值Q:拿什么衡量“好”
有了奖励,我们还需要一个衡量“一个状态值多少”的指标,这就是价值函数。状态价值函数V(s)表示:从状态s出发,按照某个策略行动,未来能获得的期望总回报。注意这里强调的是“期望”,也就是把所有随机性都考虑进去的平均水平。
但只看状态还不够,很多时候我们想知道“在这个状态下,我该选择哪个动作”。这时就需要另一个函数:动作价值函数Q(s, a),表示在状态s下执行动作a,然后继续按照某个策略走下去,能获得的期望总回报。
这两个值的关系可以这样理解:V是站在路口看整个前景,Q是走到其中一条路上去感受前途。强化学习的终极目标,就是找到最优策略——让每个状态下都能选出Q值最高的动作。记住这句话,后边的算法本质上都在做这一件事。
2.4 Bellman方程:全篇的含金量所在
Bellman方程是整个强化学习最重要的公式,它用一个简洁的递归关系揭示了价值函数的本质。话不多说,V(s)的Bellman方程长这样:
V(s) = E[R + γ * V(s')]用大白话翻译就是:当前状态的价值,等于立即能拿到的奖励R,加上未来状态价值的折扣期望γV(s')。这个递归结构非常优雅——它把“一个长期问题”拆成了“现在的问题 + 下一步的问题”,而下一步的问题和自我定义形式相同。
Q(s, a)的Bellman方程也同理,多考虑一个动作的影响。Q-learning的更新公式正是从这个方程里“抽”出来的:
Q(s, a) ← Q(s, a) + α * [r + γ * max_a' Q(s', a') - Q(s, a)]这个公式后面会手写实现,现在先说直觉:中括号里那一坨是“当前估计”和“真实目标”之间的差距——r + γ乘以下一状态最优Q值,是用新信息估计出来的目标值;减去当前Q值,就是误差项。α(学习率)控制我们迈多大步子去修正这个误差。整个Q-learning,就是不断沿着误差方向修正Q表的过程。
3. 探索与利用:强化学习独有的“两难”
3.1 如果只选最优,你永远学不会最优
强化学习和监督学习最大的不同,在于它没有现成的“正确答案”,只能靠试错得到反馈。而试错就引出了一个绕不开的矛盾:探索(exploration)与利用(exploitation)的取舍。
什么叫“利用”?就是你根据当前学到的经验,选那个Q值最高的动作,确保当下收益最大化。什么叫“探索”?就是你明明知道某个动作当前看起来不是最优,还是故意去试试别的,因为经验还不够,可能低估了一个真正的好动作。
这里有个很经典的陷阱:如果一开始就“贪心”地只选择Q值最大的动作,你会发现Q表永远不会收敛到最优。因为某些动作因为没有被探索,Q值一直停留在初始值,导致它们永远不会被选中,形成恶性循环。这就像一个人只吃自己吃过的菜,永远发现不了更好吃的菜。
3.2 ε-greedy策略与ε衰减怎么配
最经典的解决方案是ε-greedy:以ε的概率随机选择一个动作(探索),以1-ε的概率选择当前Q值最大的动作(利用)。
参数ε怎么定?我的习惯是分阶段来。训练初期ε可以设到0.3甚至0.5,让智能体大胆跑来跑去积累经验;随着训练推进,逐渐衰减到0.01、0.05,让智能体越来越“自信”,更多地利用已经学到的经验。
衰减方式也讲究,线性衰减和指数衰减都可以。指数衰减可以用一个分段函数实现:前几百个episode保持一个较高的探索概率,之后再快速衰减。不要一上来就用很小的ε,那等于没探索,Q表根本长不出好形状来。
3.3 比ε-greedy更“聪明”的几种探索方式
ε-greedy简单好用,但确实有点“笨”——它是盲目的随机探索,不考虑哪些动作可能更有信息量。这里顺带提一下更智能的方案,后面系列会详讲。
一种是置信上界(UCB)方法,它基于一个直觉:“被尝试次数越少的动作,不确定性越大,越值得优先探索”。另一种是汤普森采样(Thompson Sampling),它是从一个概率分布里采样动作,自动平衡探索与利用。这些方法在复杂环境里往往更高效,但入门阶段完全不需要,ε-greedy足够让你把所有核心概念跑通。
4. 2024年的Python环境到底怎么搭
4.1 工具链与库的选择说明
2024年做强化学习,Python生态已经相当成熟。如果只是在本地学习和调参,不需要上GPU。核心依赖其实就两个:numpy负责矩阵运算,matplotlib负责可视化。想用标准环境库的话,现在社区最主流的是Gymnasium——它是当年OpenAI Gym项目被接手后持续维护的版本,接口更稳定,文档也更清晰。
用哪个Python版本?我建议3.10或3.11。3.12也不是不行,但某些老库的兼容性偶尔会出问题,新手没必要在这种地方消耗时间。环境管理强烈建议用虚拟环境,别一股脑装进全局环境里,后面项目多起来会非常难受。
4.2 最小安装与快速验证
创建一个虚拟环境然后安装依赖,命令大概长这样:
python -m venv rl_env source rl_env/bin/activate # Windows下用 rl_env\Scripts\activate pip install numpy matplotlib gymnasium装完之后,可以用一段非常短的代码测试整条链路是否正常。以Gymnasium自带的经典控制环境为例:
import gymnasium as gym env = gym.make("CartPole-v1", render_mode="human") obs, info = env.reset() for _ in range(50): action = env.action_space.sample() # 随机动作 obs, reward, terminated, truncated, info = env.step(action) if terminated or truncated: obs, info = env.reset() env.close()能弹出环境窗口并正常渲染,说明你的环境没问题了。注意新版接口返回的是五元组,比老版本多了一个truncated标志,用来区分“任务终止”和“超时截断”,这个细节挺重要,老教程不更新会在这里踩坑。
4.3 写在环境搭建之后的小建议
不少人会花大量时间折腾复杂的深度学习框架和GPU环境,结果卡在安装环节就劝退了。我的建议是:入门阶段,越朴素越好。一个小网格世界,numpy就完全能搞定,根本不需要引入PyTorch。等后面真到了该上神经网络的时候,再顺势加框架,那一刻的安装配置压力也小得多。
5. 实操:从零手写Q-learning求解网格世界
接下来进入重头戏——我们用纯numpy实现一个完整的Q-learning算法,解决一个4×4网格寻宝问题。整个代码量不到100行,但麻雀虽小五脏俱全,所有强化学习核心要素都在里面。
5.1 第一步:定义GridWorld环境
虽然Gymnasium里有现成的网格世界环境(FrozenLake),但我更推荐教学场景下自己写一个环境类。只有亲手写过环境,你才会真正理解“智能体-环境交互”这个抽象概念到底指什么。
import numpy as np class GridWorld: def __init__(self, size=4): self.size = size self.start = (0, 0) self.goal = (size - 1, size - 1) # 动作:0=上, 1=下, 2=左, 3=右 self.actions = [0, 1, 2, 3] def reset(self): self.state = self.start return self.state def step(self, action): i, j = self.state if action == 0: i = max(0, i - 1) elif action == 1: i = min(self.size - 1, i + 1) elif action == 2: j = max(0, j - 1) elif action == 3: j = min(self.size - 1, j + 1) self.state = (i, j) if self.state == self.goal: reward = 1.0 done = True else: reward = 0.0 done = False return self.state, reward, done, {}解释几个设计细节:
- 状态用元组
(i, j)表示,代码直观。 - 碰到边界时
max和min操作让智能体停在原地,而不是“出界”。 - 奖励设计得很稀疏:只有到达终点才给+1,其它位置都是0。稀疏奖励任务更难学,但对理解Q-learning特别有效。
5.2 第二步:Q表的初始化与更新逻辑
Q表的形状应该是(状态数, 动作数),这里状态数就是16,动作数是4。初始值我建议设成全0。有人说初始值也可以设成偏大的数,能鼓励探索,那是另一个技巧,入门阶段全0即可。
class QLearningAgent: def __init__(self, env, alpha=0.1, gamma=0.95, epsilon=0.3): self.env = env self.alpha = alpha # 学习率 self.gamma = gamma # 折扣因子 self.epsilon = epsilon # 探索率 self.q_table = np.zeros((env.size * env.size, len(env.actions))) def state_index(self, state): return state[0] * self.env.size + state[1] def choose_action(self, state): idx = self.state_index(state) if np.random.random() < self.epsilon: return np.random.choice(self.env.actions) return int(np.argmax(self.q_table[idx])) def update(self, state, action, reward, next_state, done): idx = self.state_index(state) next_idx = self.state_index(next_state) if done: target = reward else: target = reward + self.gamma * np.max(self.q_table[next_idx]) self.q_table[idx, action] += self.alpha * ( target - self.q_table[idx, action] )这一段是整个算法的心脏。重点看update函数里的target计算:
- 如果到达终点(done为True),目标值就是实际奖励,不再加未来价值。
- 否则,目标值 = 即时奖励 + γ乘以下一状态Q值中的最大值。这就是Bellman方程的直接落码。
5.3 第三步:训练循环与ε衰减
训练循环就是不断重复“选动作→执行→看反馈→更新Q表”这个过程。为了更贴近实际使用,我加了一个简单的ε衰减逻辑:前80%的episode用较高的探索率,之后逐渐降下来。
def train(env, agent, episodes=800): rewards_history = [] for episode in range(episodes): state = env.reset() done = False total_reward = 0 # 简单分段衰减:前600轮探索率保持0.3,之后逐步衰减到0.01 if episode >= 600: agent.epsilon = max(0.01, agent.epsilon * 0.99) while not done: action = agent.choose_action(state) next_state, reward, done, _ = env.step(action) agent.update(state, action, reward, next_state, done) state = next_state total_reward += reward rewards_history.append(total_reward) return rewards_history env = GridWorld(size=4) agent = QLearningAgent(env, alpha=0.1, gamma=0.95, epsilon=0.3) history = train(env, agent)注意我把衰减条件设成episode >= 600,这是一种实践上常见的做法:先保证充分的探索,再切换到更“保守”的策略去巩固已经学到的知识。你也可以用线性衰减,效果差不多。
5.4 第四步:看结果、验证策略
训练结束后,打印Q表看看它到底学到了什么:
print(np.argmax(agent.q_table, axis=1).reshape(4, 4))这个输出是4×4的“最优策略表”,每个格子上写着数字0-3,对应“上下左右”。如果训练成功,你会在表里看到一条从起点蜿蜒到终点的方向链。另一个更直观的验证方式是把策略走一遍:
def show_policy(agent): state = env.reset() path = [state] done = False while not done: action = agent.choose_action(state) # 这里epsilon已经很小,基本是纯利用 state, reward, done, _ = env.step(action) path.append(state) print(" -> ".join(map(str, path))) show_policy(agent)如果路径是类似(0,0) -> (0,1) -> (0,2) -> (1,2) -> (2,2) -> (3,2) -> (3,3)这样的序列,恭喜,你的Q-learning算法已经完全跑通了。
6. 调试经验与常见问题排查
训练强化学习模型,代码能运行只是第一步,训练不收敛才是常态。这里把我踩过的一些坑和大家分享。
6.1 Q值为什么会“越学越乱”
Q值振荡是新手最常碰到的现象。表现为:明明训练了很久,Q表还是不稳定,或者某次训练效果好、某次效果很烂。
常见原因之一是学习率α设太大。Q-learning每步用的是增量更新,α太大意味着每次修正步子迈得过分,Q值会在目标附近来回震荡。解决办法是把α从0.1降到0.05甚至0.01试试。另一个原因是奖励设计不够合理,比如奖励非常稀疏且随机性大,导致误差信号不稳定。
我调试时有个习惯:把每一轮episode的总奖励画成曲线。如果曲线在大幅抖动,先降α;如果曲线整体有上升趋势但噪声很大,这是正常的,继续训练就好。
6.2 学习率α和折扣因子γ到底怎么配
参数配平是RL实践里的核心手感。我常用的起点组合是α=0.1, γ=0.95,然后在两个方向上试探。
如果你发现学习速度太慢(几百轮还没摸到终点),先检查是不是α太小。但也要警惕:α太大虽然学得快,但后期精度差、容易震荡。
γ的影响更微妙。γ设得太大(比如0.99),价值会传播得很远,需要更多轮次才能收敛;γ设得太小(比如0.5),智能体会变得很短视,只追求眼前奖励。针对网格世界这种短任务,0.9到0.95是最舒服的区间。如果是长线任务、奖励密度高,可以适当调高γ。
6.3 训练不收敛时的检查清单
我把常见的坑整理成一个速查表:
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 一直得不到正奖励 | 探索率太低 | 把ε提到0.3以上再看 |
| Q值整体偏大且发散 | 学习率过大 | 降低α到0.01 |
| 路径只往边界撞 | Q表初始化问题 | 检查维度是否填反 |
| 训练后期还在乱走 | ε没有衰减 | 检查衰减逻辑是否生效 |
| 每次结果差异巨大 | 随机种子没设 | 训练前设置固定随机种子 |
| 奖励一直为0 | 终点判定没触发 | 在step里手动打印状态 |
6.4 一个容易被忽略的“索引”坑
代码里state_index函数看着简单,但特别容易写错。我用的是i * size + j,这是行优先的编码方式。如果你另一处用了j * size + i,Q表行维度不变,但状态索引全乱了,训练出来的策略会完全错误。
这种错误很难通过报错发现,因为程序能正常运行,只是结果不对。我的经验是:写完索引函数后立刻做一个小测试,打印几个关键状态的索引,比如左上是0、右下是15,确保映射关系符合预期。
7. 后续进阶路线与个人体会
7.1 这个系列接下来要讲什么
这一篇搭好了“强化学习的地基”——MDP、价值函数、Bellman方程、Q-learning。下一篇会往前走一步,对比Q-learning和SARSA的异同,让你理解在线策略(on-policy)和离线策略(off-policy)到底差在哪。再往后,就会引入神经网络,讲DQN为什么能处理高维状态、如何解决经验回放和目标网络等等。路会越走越宽,但核心始终是这一篇里那些基本概念。
7.2 新手学RL的三点个人心得
第一,一定不要跳过“自己写环境”这一步。很多教程直接用现成环境库,虽然方便,但容易让人失去对“环境接口”的敏感度。亲手实现一个环境,你会深刻理解reset、step、奖励信号这些概念是怎么协同工作的。
第二,调参一定要有耐心。强化学习不是训练一次就完事的,参数不同、随机种子不同,结果可能天差地别。我建议每次只改一个参数,用固定的随机种子,把结果记录下来再对比,这样你才能逐步建立起“这个参数变化会带来什么效果”的直觉。
第三,遇到不收敛先别急着怀疑算法实现,先回头审视环境是否正常。很多时候问题不在Q-learning本身,而是奖励设计出了bug——比如终点永远到达不了,或者状态转移逻辑写错了。先做单元测试,再调算法。
说句实在话,第一次看到Q-learning在自己的代码里成功收敛,路径沿着网格世界一路走到终点那一刻,那种“它真的在学东西”的兴奋感,是其它编程任务很少能给的。希望这篇文章能让你也体会到这种感觉。下一篇我们继续往下走。