很多朋友一听到“强化学习编程实战”这几个字,第一反应往往是:这玩意是不是得上千行代码,还得先背完半本数学书?我当年也这么想,直到第一次亲手让一个智能体自己学会走完一张网格地图,才发现核心思路其实很朴素:让程序不断试错、记住哪些选择能带来更多长期收益。这篇文章是这个系列的第一篇,我会把强化学习最核心的几个概念讲透,再带你用不到一百行Python代码实现一个能跑通的Q-Learning算法。
这篇内容适合两类人:一类是刚看完理论但写不出代码的初学者,另一类是已经在用深度学习模型做项目、想回头补一补强化学习基础概念的开发者。我不打算从贝叶斯定理讲起,也不会一上来就甩出神经网络,而是用一个大家都能上手的游戏环境,把“状态、动作、奖励、策略、价值函数”这些东西全部落到代码里。跟着我一步步敲完,你会发现自己已经拥有一个能“学习”的智能体,并且对强化学习的工作方式有了真正的体感。
1. 先搞懂强化学习的“四件套”,再写第一行代码
很多教程喜欢上来就给你一堆公式,结果代码还没跑起来,人已经晕了。我更倾向于先用一个生活场景把四个核心概念串起来,这样后面看代码时,每一步你都知道它在干什么。
1.1 状态、动作、奖励、环境:一个学骑车的生活案例
假设你教一个从来没骑过自行车的人,怎么让他学会?你不会给他一本《自行车动力学原理》,你会让他跨上车,告诉他“先试着往前蹬”,然后看他歪歪扭扭地骑。他每蹬一脚,身体往哪边倾斜,该怎么调整车把,这些都是他做决策时看到的信息。如果他摔倒了,你会说“没事,再来一次”;如果他稳稳骑了几米,你会夸一句“很好,就这样”。
在这个例子里,骑车的人就是智能体,路面和车况就是环境,他看到的眼前道路、车身倾斜程度就是状态,他左右转动车把、蹬踏板的动作就是动作,他摔倒或前进几米带来的感受就是奖励。整个过程没有人在旁边一步步告诉他“这个瞬间你的手该放几度”,他完全是靠一次次试错,把“在什么情况下做什么动作能避免摔倒”这件事记下来的。
强化学习解决的问题,就是让一个智能体通过不断和环境交互,学会一套动作策略,使得它在长期内获得的累积奖励最大。注意“长期”这两个字,它不是只看眼前这一下爽不爽。比如骑自行车的时候,你可能会先松开刹车往前冲,这很危险,但为了学会平衡,你必须接受眼前的不舒服。对应到算法里,就需要一个能够衡量“未来收益”的机制,这就是后面要讲的折扣因子和值函数。
1.2 策略和价值函数:智能体凭什么做决策
有了四件套,智能体还缺两样东西:怎么决定动作,以及怎么判断当前状态好不好。前者叫策略,后者叫价值函数。
策略听起来高深,其实就是“输入状态,输出动作”的规则。有些策略是随机策略,意思是“在这个状态下,有70%的概率向左转,30%的概率向右转”;有些是确定性策略,意思是“这个状态下,永远向左转”。强化学习训练的目标,就是找到一个足够好的策略,好到能让你在长期内获得尽量多的奖励。
价值函数就是对“赚得多不多”的量化估计。这里有一个关键点:价值函数不是叫你预测下一瞬间爽不爽,而是预测“从当前状态出发,如果按照某种策略继续走下去,未来累积奖励大概是多少”。更常用的是动作价值函数Q(s, a),它表示“我在状态s时先做出动作a,之后继续按策略走,能拿到的期望累积奖励”。Q这个字母总是出现在强化学习的各种算法里,你现在记住一件事就行:Q值越高,代表在这个状态下做这个动作越有前途。我后面要写的Q-Learning,本质上就是在不停修正这张“状态-动作”的打分表。
很多人写代码时容易把策略和价值函数混在一起,其实没必要。你可以把策略理解为“决策模块”,价值函数理解为“评价模块”。决策模块负责挑动作,评价模块负责反馈“刚才那步到底走得好不好”。Q-Learning里最有趣的地方在于,它只用Q表来决策,也只用Q表来评估,一套数据干了两份活。
1.3 为什么第一篇文章要选表格型Q-Learning
现在业界谈到强化学习,很多人张口就是深度强化学习,也就是用神经网络来当决策模块的方法。但作为第一篇实战文章,我更推荐你先从表格型Q-Learning开始。
原因很实在:第一,表格型方法天然直观,整张Q表就是一张Excel表格,行是状态,列是动作,交叉格填着Q值。训练过程就是一行一行地更新表格,你能非常清楚地看到它在学什么。第二,它的代码量特别小,核心更新逻辑只有一行代码,非常适合做第一个能跑起来的项目。第三,它能帮你建立最关键的直觉:什么叫“根据反馈更新决策依据”。这个直觉没打通,后面看深度强化学习的代码会非常痛苦。
当然,表格型方法有个硬伤:状态一多,表格就爆炸。一张围棋棋盘的状态数是天文数字,你不可能用表格去存。但你现在不是要解决围棋,你要解决的是先让自己的代码跑起来、看到智能体从傻到聪明的全过程。这个过程的认知价值,远远超过直接去跑一个大模型。所以这一篇的路线是:先用小环境把原理跑通,下一篇我们再讨论把Q表换成神经网络会发生什么。
2. 搭一个能练手的试验场:gymnasium冰湖环境
学强化学习最怕什么?最怕你自己搭实验环境。你想写个迷宫,还得先画迷宫;想学会走路,总不能让智能体去跑真实机器人。所以行业里早有开源工具把我们常用的实验环境封装好了,这就是gymnasium。
2.1 环境准备与安装
gymnasium的名字你可能听过,它和几年前流行的OpenAI Gym其实是一脉相承的。我建议你直接用gymnasium,因为它维护得更积极,接口也更现代。安装非常简单,如果你用的是pip,直接在终端里执行下面的命令就行:
pip install gymnasium我还建议顺手把numpy装上,等会写Q表要用的。如果你在用conda,也可以这样:
conda install -c conda-forge gymnasium numpy装完之后可以用一行代码验证环境是否正常:
import gymnasium as gym env = gym.make("FrozenLake-v1", map_name="4x4", is_slippery=True) print(env.observation_space, env.action_space)我这里直接选了FrozenLake-v1这个环境。如果这段代码没报错,说明你的实验场地已经铺好了。
有一点要提前说:很多老教程还在用gym.Env这种旧接口,比如gym.make("FrozenLake-v0"),现在已经移除了。你看到“v0”的写法,尽量替换成gymnasium的“FrozenLake-v1”。新版接口在reset和step返回值上也有变化,跟旧版不一样,最好以我下面写的为准。
2.2 看代码前先理解FrozenLake的规则
FrozenLake是个经典环境,模拟的是一个人在有冰窟窿的湖面上行走。地图是4x4共16个格子,有些格子是冰面,标为F,有些格子是已经裂开的洞,标为H,还有起点S和终点G。智能体从左上角出发,要走到右下角。它每走一步,如果没有掉进洞里,就还在流程中,不掉洞走到终点就能得到1分,中途掉洞或者走到超过步数上限就算结束,得0分。
你可能已经发现一个问题:这个环境的奖励非常稀疏。它不会因为你靠近终点就给你“小甜甜的鼓励”,也不会因为你走错方向就给你负分,只有最后到了终点才是1分,其他情况全是0。这就是我前面说的“长期收益”问题:在终点前,每一个状态看起来都像零回报,那智能体凭什么知道“什么样的状态更接近终点”?这个问题后面我会专门讲,你可以先在脑子里留个问号。
另一个很关键的设计是is_slippery。当is_slippery=False时,你选择的动作是确定性的:你按“右”,就一定往右走一格。但当is_slippery=True时,冰面是滑的,你的“右”动作只有大约三分之一概率真的往右,另外三分之二概率会滑到其他方向。我建议你第一次先跑False版本,因为确定性环境更容易验证代码逻辑,等代码跑通了,再切换成True感受一下随机环境的难度。
2.3 第一次与环境的交互:reset、step和render
在gymnasium里,智能体和环境的交互遵循一套固定的协议。训练开始前,需要调用一下reset来初始化环境状态,它会返回第一个状态和额外的环境信息。智能体每走一步,就要调用step,传入一个动作,环境会返回四个东西:新状态、奖励、两个结束标志、额外信息。
写一小段交互代码:
import gymnasium as gym import random env = gym.make("FrozenLake-v1", map_name="4x4", is_slippery=False) state, info = env.reset() done = False while not done: action = env.action_space.sample() # 随机动作 next_state, reward, terminated, truncated, info = env.step(action) state = next_state done = terminated or truncated print("最终奖励:", reward)这里有个新同学经常忽略的点:step返回的奖励是“这一步”的奖励,不是整个游戏的累积结果。很多人写循环时习惯在结束之后再回头算总分,这没错,但要记住在强化学习里,每一步反馈都是一种即时信号,价值函数做的则是把这些即时信号“打包”成长期预测。另外,terminated表示是不是真的终结了,比如掉进洞里;truncated表示是不是因为步数超过限制被强制掐断。这两者看起来都像“结束”,但对算法来说含义不同,我在第4章会单独展开。
3. 用Q-Learning训练一个能学会走路的小智能体
环境准备好之后,就可以开始写真正的核心代码了。这一章是整个项目的主力部分,我会从头到尾把Q-Learning的训练循环实现出来,并且解释每个关键步骤为什么非要这么写。
3.1 初始化Q表与超参数
首先创建Q表。FrozenLake用到的观察空间有16个状态,动作空间有4个方向:上下左右。所以Q表就是一个16行4列的矩阵。我习惯用numpy来建:
import numpy as np n_states = env.observation_space.n n_actions = env.action_space.n q_table = np.zeros((n_states, n_actions))为什么把Q表全部初始化为0,而不是随机初始化?因为在一开始,智能体对所有动作都一无所知,给它们同样的起点,让数据来说话。实际上,初始化成0可以避免一开始就往某个动作方向偏,这在表格型方法里足够用。
接下来是三个超参数,这里我直接给你一组能用的默认值,下一章再讲怎么调:
alpha = 0.1 # 学习率 gamma = 0.99 # 折扣因子 epsilon = 1.0 # 初始探索率 min_epsilon = 0.01 # 最低探索率 decay = 0.995 # 探索率衰减系数 episodes = 10000 # 训练回合数学习率alpha控制每次更新时,新信息覆盖旧信息的比例。它有点像骑自行车时,你会不会快速纠正车把:纠正慢了,容易一直歪着走;纠正太快,又可能因为路面一点坑洼就猛打方向。0.1这个值在Q-Learning里属于比较稳妥的选择。
折扣因子gamma是“对未来收益的重视程度”,范围在0到1之间。gamma越接近1,智能体越愿意为了远期的1分奖励忍受眼前很多步零奖励;gamma越小,它就越近视,只看眼前几步。FrozenLake这种奖励在终点的任务,我建议把gamma放在0.95以上,否则它会学得很慢。
3.2 epsilon-greedy:如何平衡探索与利用
这是强化学习里最迷人的一个问题:智能体到底应该选当前已知最好的动作,还是应该冒着风险去试试其他动作?
如果你总是选Q表里分数最高的动作,也就是“利用”,那就会陷入局部最优。比如它早期碰巧发现走某条路能到达终点,就会一直走那条路,可能这条路不是最优的。如果你总是随机试探,也就是“探索”,那执行效率又太低,大部分时间在瞎撞。
epsilon-greedy是这个问题的一个简单而有效的解法:用epsilon的概率随机选动作探索,用1-epsilon的概率选Q值最高的动作利用。训练开始时epsilon设为1.0,智能体几乎完全在随机逛地图;随着训练推进,epsilon不断缩小,它越来越依赖已经学到的经验。
下面这段代码就是完整的动作选择逻辑:
if np.random.random() < epsilon: action = env.action_space.sample() # 探索:随机选 else: action = np.argmax(q_table[state]) # 利用:选Q值最大的这种“先多探索,后多利用”的策略,很像人刚到一个新城市时先瞎逛,熟悉之后就开始走自己的固定路线。训练代码里,每一回合结束后,我还会让epsilon乘以衰减系数:
epsilon = max(min_epsilon, epsilon * decay)这样探索率是平滑下降的,不会在某个回合突然骤变。
3.3 训练循环的核心更新公式
现在到了整个Q-Learning最核心的地方。每一次智能体从状态s选择动作a,环境会返回新状态s'、即时奖励r。Q-Learning的关键是更新Q表:
best_next = np.max(q_table[next_state]) q_table[state, action] = ( q_table[state, action] + alpha * (reward + gamma * best_next - q_table[state, action]) )这个公式里,reward + gamma * best_next被称为“目标值”。它想表达的意思很直接:如果我在状态s做了动作a,立刻拿到reward,并且到了新状态s'之后,未来还能继续拿最佳Q值,那么我把这两部分加起来,就是我这次决策应有的长期回报估计。q_table[state, action]是旧估计。两者的差叫“时间差分误差”,表示“我的估计到底偏了多少”。然后用alpha缩放一下,一点一点往正确方向修正。
我把完整的训练循环列出来,这里包含了动作选择、环境交互和Q表更新:
for episode in range(episodes): state, _ = env.reset() done = False while not done: if np.random.random() < epsilon: action = env.action_space.sample() else: action = np.argmax(q_table[state]) next_state, reward, terminated, truncated, _ = env.step(action) best_next = np.max(q_table[next_state]) q_table[state, action] += alpha * ( reward + gamma * best_next - q_table[state, action] ) state = next_state done = terminated or truncated epsilon = max(min_epsilon, epsilon * decay)你可能会想:为什么计算best_next时,取新状态所有动作里最大的Q值,而不看我实际采用策略会选哪个动作?这正是Q-Learning被称为off-policy的原因。它更新Q表时,假设自己从新状态起会一直走最优路线,而实际行为策略是epsilon-greedy,两者可以不一样。这种“用最优目标来更新”的思路,让算法即使偶尔随机探索,也能朝着最优策略的方向收敛。理解这个点,对后面学DQN非常有帮助。
3.4 评估结果而不只是“看训练曲线”
训练完10000个回合之后,很多人会兴奋地直接把Q表拿去做测试。这里我要泼一盆冷水:训练过程中看到的reward波动很大,因为epsilon高的时候在乱走,直接看训练日志判断模型好坏,很容易被误导。我建议你单独写一个评估函数,把epsilon固定成0,只用纯贪心策略跑若干回合,统计成功率:
def evaluate_policy(env, q_table, n_test=20): successes = 0 for _ in range(n_test): state, _ = env.reset() done = False episode_reward = 0 while not done: action = np.argmax(q_table[state]) state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated episode_reward += reward if episode_reward > 0: successes += 1 return successes / n_test print("贪心策略成功率:", evaluate_policy(env, q_table))我实测下来,在is_slippery=False的FrozenLake上,默认参数下跑完10000回合,成功率一般能达到0.8以上,有时候能到1.0。is_slippery=True之后难度明显加大,成功率掉到0.5左右也很正常。这个评估数字才是项目真正看得见的产出。后面你调参,也要时刻盯着这个数字,而不是盯着训练过程中那条起伏很大的reward曲线。
4. 训练中的坑:收敛慢、不稳定、成功率上不去怎么办
理论上照着上面的代码抄一遍,应该能跑出结果。但实际调试的时候,你大概率会遇到几个典型的坑。这一章全部是我自己试错试出来的经验,希望能帮你少走点弯路。
4.1 为什么奖励稀疏是最容易踩的坑
FrozenLake的奖励设置是:走到终点给1,其他所有情况给0。这个设定对新手来说,最大的问题不是“学不会”,而是“前中期完全没有反馈信号”。
想象一下,当epsilon比较大时,智能体在冰面上乱走,偶尔走到终点,得到一次1分奖励。如果gamma太小,比如0.5,那么这一次奖励对很久之前的决策几乎没有影响,因为gamma连乘很多次后接近0了。所以智能体会很长时间都学不会“哪些早期状态是好状态”。
解决办法有三个方向。第一,把gamma调高,让终点奖励能“辐射”回更早的状态。第二,把训练的回合数拉长,给算法更多时间去撞到终点。第三,也是最容易被忽略的,不要在每个回合中反复重置环境后完全不看过程,你可以在一轮结束后,打印一下这一回合的路径长度,作为辅助观察。路径长度越短,说明它越来越目标明确,即使还没拿到终点的1分,也能看出它在进步。
这里额外提一下reward shaping,也就是给中间过程设计奖励,比如“离终点更近一步就加0.1”。很多新手喜欢这么搞,希望加快训练速度。但要小心:随便设计的中间奖励会改变原问题的目标,可能把智能体教成“只是在原地打转刷中间奖励”的滑头。如果你真想改奖励,一定要先想清楚“这个中间奖励会不会引入原问题没有的偏好”。
4.2 epsilon衰减和学习率怎么调才比较稳
epsilon衰减系数是很多人第一个乱调的东西。衰减太慢,智能体到后期还在乱走,浪费大量回合;衰减太快,它过早变得保守,可能永远发现不了新的好路线。我给你一个基本思路:先让它在训练前半段保持较高探索率,后半段逐步收紧。比如10000回合,初始epsilon=1.0,decay=0.995,训练结束时epsilon会降到0.01左右,这个节奏在FrozenLake上比较够用。
学习率alpha也一样,别一上来就调大。alpha=0.5虽然更新更快,但在随机性比较大的环境里容易震荡;alpha=0.1是比较稳的起点。你要是想进阶一点,可以用动态学习率,训练早期大一点,后期小一点,让最后的Q值更稳定。这一篇不做这么复杂,但要记得:一次只调一个参数,否则你根本不知道是谁导致的改善。
还有一个细节:Q值更新时,如果reward=0且best_next=0,那么q_table[state, action]会朝着0被压缩,也就是说只有成功路径上的状态Q值会被抬高。所以当奖励稀疏时,Q表大部分格子长期保持0,这是正常的,别急着怀疑代码错了。你可以打印Q表看看,成功路径附近的状态有非零值,说明学习在生效。
4.3 常见错误排查速查表
我把新手最常问的问题整理成一张表,方便你对照检查。这张表是基于我在指导别人跑代码时看到的高频问题,比你一个个翻报错更实用。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 训练后成功率接近0 | epsilon衰减太快 | 调大episodes或降低decay,让探索更充分 |
| 成功率一直上不去,且reward曲线乱跳 | alpha太大 | 把alpha降到0.1以下 |
| Q表全为零 | 奖励太稀疏,且步数上限导致很难到终点 | 拉高gamma,增加训练轮数,检查更新公式是否漏了max |
| 一运行就报错,说tuple索引出错 | 接口用成了旧版 | 改为gymnasium新版接口,重视reset和step返回值的数量 |
| 训练时好时坏,成功率波动大 | 没有做纯贪心评估 | 把epsilon固定为0再测试,不要拿训练过程当评估结果 |
| 明明到终点了,却显示未成功 | terminated和truncated处理错误 | 循环跳出条件用terminated or truncated,但奖励只看terminated那一步 |
关于最后一条,我再多说一句。truncated代表的是步数截断,比如环境说明书里说了200步没到终点就算超时,这并不等于任务失败。在FrozenLake里,如果卡在边缘来回横跳,步数上限会截断,但这一步的reward可能仍然是0,不代表走到了目标。写代码时不要把truncated当成奖励信号来学习,否则会学到错误的东西。
5. 从这张小地图走向更大世界
当你把FrozenLake跑通,你获得的不仅仅是一个能通过网格的小模型,更重要的是你理解了强化学习的一条完整链路:环境、交互、表格、更新、评估。接下来你完全可以把这套经验迁移到更复杂的问题上。
5.1 当Q表不够用时:神经网络登场
表格型Q-Learning的局限是非常明显的。状态一旦变多,表格存储不可接受。比如一个100x100的网格地图,状态就有10000个,勉强可以接受;但如果状态里有连续变量,比如机器人的关节角度,那状态数就变成无穷多了,表格直接失去意义。
这个时候就要用神经网络来拟合Q函数。思路其实和你现在掌握的Q-Learning一脉相承:表格变成了神经网络,输入是状态特征,输出是每个动作的Q值预测;目标还是reward + gamma * best_next,只是更新方式变成了梯度下降,这就是深度Q网络,也就是常说的DQN的大致轮廓。有了这一篇打底,你再去看DQN的代码,会发现核心更新思想和Q-Learning非常像,只是把查表换成了神经网络前向传播。
所以你现在写的这张Q表,不是玩具,而是一个帮助你建立直觉的基石。以后你哪怕天天用神经网络做强化学习,脑子里也要能随时映射出“我正在更新哪张表的哪个格子”。
5.2 从项目角度看,如何判断一个强化学习实验做得好不好
一个完整的强化学习项目,至少包含四个部分:环境理解、算法实现、调参实验、结果评估。很多人只盯住算法实现,写完代码就以为结束了,其实后面两步才是真正拉开差距的地方。
我给自己定了一套评估流程,分享给你参考:首先,固定一个随机种子,保证每次实验结果可复现。其次,画出评估成功率的曲线而不是训练reward曲线,每训练100回合评估一次,记录20回合的贪心成功率。最后,做一个简单的消融实验。比如把epsilon固定为0,看成功率掉多少;把gamma调成0.9,看会不会变差。这些实验能帮你理解每个参数到底在起什么作用。
我在做完FrozenLake之后,顺手把Q表可视化成了热力图。你看哪几个状态附近Q值高,那基本就是智能体认为的“安全路线”。这种可视化的方式,能让实验结果变得非常直观,也方便你向别人讲解你的项目。你要是感兴趣,完全可以把这套流程扩展到更复杂的环境里。
最后再分享一个小技巧:调参的时候,准备一个小本子或者一个配置文件,每次记录当前参数、评估成功率、问题现象。不要凭感觉去改参数,你觉得“这次好像好了”,但如果你没记录,过两天就忘了。我吃过这个亏,同一个问题反复调了三个晚上,最后发现最好的参数组合竟然是自己第一天就试过的那个组合。先把这套朴素的实验纪律养好,后面学什么强化学习算法都事半功倍。
这篇的内容就到这里,下一篇我会拿一个效果更直观、状态更连续的环境来做实战,到时候我们会和神经网络正式打交道。你可以先把这章的代码跑通,把Q表打印出来看看,感受一下“一个程序在慢慢学会走一条路”到底是什么感觉。