ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CliffWalking-v0:用48个格子跑通Q-Learning强化学习入门

CliffWalking-v0:用48个格子跑通Q-Learning强化学习入门

第一次接触强化学习,最头疼的往往不是公式本身,而是公式和代码对不上。Q值到底存在哪?ε-greedy怎么落地?环境返回的 observation 和 reward 又该怎么理解?如果你也有这些困惑,找一个足够小、又五脏俱全的环境踏踏实实跑一遍,远比继续啃理论有效。CliffWalking-v0正是这样一个入口级环境:一个4×12的小网格,智能体要从左下角走到右下角,中间横着一排“悬崖”。把这个案例跑通、读透,表格型强化学习里最核心的奖励设计、Q表、探索与利用、时序差分更新,你都会有非常直观的感受。

1. 场景拆解:先搞懂CliffWalking-V0在讲什么故事

1.1 一个极小但五脏俱全的强化学习沙盒

CliffWalking 在强化学习入门环境里和 FrozenLake 齐名。FrozenLake 考验的是“不确定性下做决策”,冰面会打滑,你选了方向也不一定按那个方向走;CliffWalking 则把“每一步都要付出代价”这件事摆到了台面上——规规矩矩走一步扣1分,掉下悬崖直接扣100分。这个设计特别适合用来理解“奖励函数如何塑造行为”。

环境本身是一个4行12列的网格,共48个小格子。起点在左下角,终点在右下角,最下面一行从第1列到第10列是悬崖区域。用坐标来说:x表示列,从0到11;y表示行,从0到3。起点是(x=0, y=3),终点是(x=11, y=3),悬崖是(x=1~10, y=3)。智能体每一步可以执行上、右、下、左四个动作中的一个。

这里有一个关键细节:环境返回的状态不是(x, y)坐标,而是一个0到47的整数。老版本gym和新版gymnasium都采用公式s = y * 12 + x做编码。也就是说,状态0代表左上角,状态36代表左下角起点,状态47代表右下角终点,状态37到46就是那一排悬崖。动手写代码之前先把这条编码规则记在心里,后面很多bug都出在这个换算上。

1.2 状态、动作、奖励:先建立坐标系

在CliffWalking-v0里,observation_space是Discrete(48),action_space是Discrete(4)。四个动作分别对应:0上、1右、2下、3左。环境每次step会返回四个关键信息:下一状态、即时奖励、是否终止、附加信息。

奖励规则是整个环境设计最值得品的地方:

情况奖励是否终止
正常走一步-1否
踩进悬崖-100是,回到起点
到达终点0是

每走一步扣1分,意味着浪费步数就是浪费长期收益。如果没有任何步数惩罚,智能体完全可以在安全区闲逛直到天荒地老,反正最后总分也不会更差。加上步数惩罚之后,“尽快到达终点”就变成了一个可优化的目标。这个“用奖励函数表达目标”的思路,在真实项目里太常用了,做推荐、做调度、做机器人控制,本质上都是同一件事:告诉智能体什么行为有价值,什么行为有成本。

为什么要扣100分而不是直接判负?因为如果一个行为带来的惩罚不够显著,智能体可能认为“掉下悬崖也没多大事”。-100和-1的差距足够大,让智能体在更新Q值时能明显感知到危险的边界,这也是强化学习奖励塑形中很常见的技巧:让风险行为的代价远大于正常行为的代价。

1.3 为什么它适合当第一个练手项目

我推荐新手先跑CliffWalking而不是直接上CartPole或Atari,有几个很现实的原因。

第一,状态空间小。表格型Q表只有48×4=192个值,一个二维数组就能装下,训练几秒钟就能收敛。你可以随时把Q表打出来,逐个格子观察智能体的判断。

第二,可解释性强。每个状态都有明确的物理意义,策略好不好一眼就能看出来。相比之下,深度强化学习动辄几十万参数,模型学得好不好你只能看回报曲线猜。

第三,调参反馈快。学习率、折扣因子、探索率改一改,重新训练也就几秒钟,非常适合做对比实验。

第四,经典结论明确。CliffWalking最经典的现象是Q-Learning和Sarsa学出两种风格完全不同的路径,一个贴崖走最短路,一个绕路求稳。这种“同环境不同算法产生不同行为”的对比,是理解on-policy和off-policy区别最好的入口。

2. 环境搭建与基础交互:先把“游戏”跑起来

2.1 安装环境并确认版本

环境安装本身很简单,直接装gymnasium就行:

pip install gymnasium

需要注意一个历史问题:老版本的gym是很多旧教程里的主角,但这个包已经不再积极维护,社区现在统一使用gymnasium作为继承者。接口上它们大部分兼容,但在reset和step的返回值上有明显差异。

装好之后先写一段验证代码:

import gymnasium as gym env = gym.make("CliffWalking-v0", render_mode="human") print("状态空间:", env.observation_space.n) print("动作空间:", env.action_space.n) state, info = env.reset() print("初始状态:", state) env.close()

如果看到状态空间输出48、动作空间输出4,说明环境正常。注意新版gymnasium的reset返回两个值:state和info;老版gym的reset只返回一个int。这个差异我在后面专门列一个对照表。

2.2 新旧接口的坑:reset、step返回值差异

很多新手拿着网上两三年前的代码直接跑,报错或者行为诡异,基本都是接口不匹配导致的。这里列一张对照表:

接口老版gym新版gymnasium
安装包gymgymnasium
reset返回值state(state, info)
step返回值(obs, reward, done, info)(obs, reward, terminated, truncated, info)
终止标志done=True表示episode结束terminated表示环境自然结束,truncated表示超时等截断
渲染env.render()make时传render_mode="human"

听上去只是多拆一个返回值,但我见过太多人在这上面卡半小时。老教程写的是:

obs = env.reset() obs, reward, done, info = env.step(action)

新版本必须改成:

obs, info = env.reset() obs, reward, terminated, truncated, info = env.step(action) done = terminated or truncated

我的建议是:新项目一律用gymnasium,代码里统一处理terminated or truncated。这样以后从这个小环境迁移到Atari、MuJoCo这些复杂环境,代码框架都不用换。

2.3 跑一个随机策略,感受环境动态

正式写Q-Learning之前,建议先跑一个完全随机的策略,看看环境长什么样。这段代码什么都不学,只是随机按方向键,感受一下“掉悬崖”是什么体验:

import gymnasium as gym env = gym.make("CliffWalking-v0", render_mode="human") state, info = env.reset() done = False step_count = 0 while not done: action = env.action_space.sample() next_state, reward, terminated, truncated, info = env.step(action) done = terminated or truncated print(f"step={step_count}, state={state}, action={action}, reward={reward}, next_state={next_state}") state = next_state step_count += 1 if done: print(f"Episode结束,共走了{step_count}步") break env.close()

跑几次你就能直观看到:随机策略大概率很快踩进悬崖,拿到-100的惨痛回报。这个“痛感”很重要——它帮你建立一种直觉:强化学习的目标,本质上就是让策略从“随机乱撞”变成“稳定拿到更多负分”,最终逼近理论上的最优-13。

3. Q-Learning算法拆解与代码实现

3.1 Q值是什么:一张记录状态-动作好坏的表格

Q-Learning的核心是一个叫Q表的二维数组。行是状态,列是动作,值代表“在这个状态下,先执行这个动作,之后继续按最优策略走,能拿到的期望总回报”。

用CliffWalking来说,Q[s][a]就是“当我在格子s朝某个方向走一步,之后每一步都尽量聪明地走,最后累计能拿多少分”。分数总体是负的,因为每步都要扣分,所以数值越大越好,越接近0越好。初始时所有Q值都是0,意味着智能体对任何格子、任何动作都没有偏好,所有尝试全靠随机探索。

Q表是这个环境最“透明”的产物。训练结束后你完全可以把它打印出来,看看哪个格子下哪个动作的Q值最高。这样做的好处是,学习过程不再是黑盒,出了任何问题都能直接看到模型内部出了什么偏差。

3.2 更新公式逐项拆开:TD error到底是什么

Q-Learning的更新公式长这样:

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

这里面的每个符号都值得掰开揉碎讲。

r是执行动作a后拿到的即时奖励。γ是折扣因子,代表未来的反馈相对于当前的折扣程度,越接近1说明越看重长期收益。max_a' Q(s', a') 是在新状态s'下,所有动作里Q值最大的那个,表示“如果到了新位置后采取最优行动,能期望到什么程度”。

方括号里的整体叫TD error,字面意思是“此时预测和事后看来的差距”。可以类比你在导航App里估计到车站要10分钟,结果走到一半发现前方拥堵,系统重新估算为15分钟——新增信息量与原估计的差值,就是类似TD error的东西。Q-Learning每次只把这个差距的一部分(由α控制)合并进原来的估计,所以叫增量学习,而不是一步到位。

特别强调一点:当episode结束时,更新公式里的未来项应该去掉。也就是说,如果执行动作后环境返回terminated=True,td_target就直接等于r,不再加γ·max Q(s', a')。原因很简单:都结束了,后面没有状态了,哪来的未来回报?

3.3 epsilon-greedy:如何平衡探索与利用

Q-Learning要选择动作,又不能每次都选Q值最大的。如果一开始就在Q表全零的基础上贪心,智能体会一直选动作0(上),永远走不出起点那一亩三分地。

所以工程上最常用的方法是epsilon-greedy策略:以ε的概率随机选一个动作,探索未知信息;以1-ε的概率选Q值最大的动作,利用已有经验。

def choose_action(state, epsilon): if np.random.random() < epsilon: return env.action_space.sample() return int(np.argmax(Q[state]))

ε设成0.1,意味着平均每10步会有1步是随机的。探索太多,学出来的策略不够稳;探索太少,可能学不到完整地图。对于CliffWalking,固定0.1就能跑通,如果想要更快的收敛,可以让ε随着训练进程逐渐衰减,从0.5掉到0.01。

3.4 完整训练代码:关键行逐条解读

下面是完整的Q-Learning训练代码。我把终止条件的处理单独拎了出来,避免很多教程里那个常见的bug。

import numpy as np import gymnasium as gym # 1. 创建环境 env = gym.make("CliffWalking-v0") # 2. 初始化Q表:48个状态,4个动作 Q = np.zeros((env.observation_space.n, env.action_space.n)) # 3. 超参数 alpha = 0.1 # 学习率:新信息覆盖旧估计的强度 gamma = 0.9 # 折扣因子:未来回报的折现 epsilon = 0.1 # 探索率:随机动作概率 num_episodes = 1000 def choose_action(state, epsilon): if np.random.random() < epsilon: return env.action_space.sample() return int(np.argmax(Q[state])) rewards_per_episode = [] # 4. 训练循环 for episode in range(num_episodes): state, _ = env.reset() done = False total_reward = 0 while not done: action = choose_action(state, epsilon) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated # 5. 计算TD目标 if done: td_target = reward else: td_target = reward + gamma * np.max(Q[next_state]) # 6. 更新Q值 Q[state, action] += alpha * (td_target - Q[state, action]) state = next_state total_reward += reward rewards_per_episode.append(total_reward) print("训练完成")

这段代码里最值得注意的就是第5步。走到悬崖时,环境返回的next_state其实是重置后的起点,reward是-100,done为True。如果还机械地加上gamma * np.max(Q[next_state]),就会把“从起点出发的最大价值”混入悬崖状态的更新里,导致悬崖位置的Q值被莫名抬高。很多教程代码为了简洁不区分这个,小环境里问题不明显,但一旦换到复杂环境,这种含糊写法会让算法行为变得难以捉摸。

alpha取0.1是表格型方法比较稳妥的起点。太大会导致Q值来回震荡,太小会拖慢收敛速度。gamma取0.9已经够用,因为CliffWalking的episode很短,未来回报不会因为折扣过大而丢失。如果想看更“目光长远”的智能体,可以把gamma改到0.95或0.99,对比一下路径变化。

4. 训练过程与细节优化:从震荡到收敛

4.1 观察学习曲线:怎么判断智能体学会了

训练过程中我把每个episode的总回报存在了rewards_per_episode里。plot出来大概长这样:前面几十个episode惨不忍睹,经常是-50、-80甚至更差,因为智能体在疯狂踩悬崖;慢慢曲线开始向上爬,最终稳定在一个区间震荡。

import matplotlib.pyplot as plt plt.plot(rewards_per_episode) plt.xlabel("Episode") plt.ylabel("Total Reward") plt.title("Q-Learning on CliffWalking-v0") plt.show()

单轮回报看起来很抖,因为即使策略已经不错,ε探索还是会让智能体偶尔踩崖。这时候用一个滑动平均看趋势更直观:

def moving_average(data, window=50): return np.convolve(data, np.ones(window) / window, mode="valid") smoothed = moving_average(rewards_per_episode, 50) plt.plot(smoothed) plt.show()

CliffWalking的理论最优总回报是-13,对应贴崖最短路径:起点向上1步,向右11步,再向下1步。如果你的滑动平均曲线最后能稳定在-15或更好的水平,说明策略已经相当不错了。注意这里不用纠结必须精确到-13,因为固定ε=0.1的训练过程里始终有探索噪声。

训练结束后,把ε设为0再跑100轮,记录平均回报,这才是“无噪声”的测试表现。通常测出来会比训练曲线更好,因为每一步都选Q值最大的动作,不会再乱踩悬崖。

4.2 Sarsa对比:为什么Q-Learning路径更激进

CliffWalking最有名的实验就是拿Q-Learning和Sarsa对比。两种算法在更新公式上只差一个字:

Q-Learning用max_a' Q(s', a')作为未来回报估计,Sarsa用的是Q(s', a'),其中a'是在s'下实际按当前ε策略选出的动作。

# Sarsa更新 Q[state, action] += alpha * (reward + gamma * Q[next_state, next_action] - Q[state, action])

看起来只是“选最大值”和“按策略选一个”的区别,但行为差异非常大:

对比维度Q-LearningSarsa
属于哪类方法off-policy,评估的是最优策略on-policy,评估的是当前含探索的策略
学到的训练路径贴悬崖边沿,追求最短路绕到上方安全区,求稳
测试时平均回报接近-13大约-17或更差一些
训练过程中踩崖频率较高,因为贴着危险区较低,路径天生保守

原因用一句话概括:Q-Learning的最大化操作“无视”了未来探索的风险,它假设后面每一步都会完美执行,于是敢贴崖走;Sarsa把当前策略里“还会有ε概率随机乱走”这个事实算了进去,因此评估出的价值里已经包含了“不小心踩崖”的代价,路径自然远离悬崖。

如果把两种算法训练完后的Q表打印出来,你会发现Sarsa在最上面一行格子的Q值更高,而Q-Learning在悬崖上方那一行格子的Q值更高。这种从数值到策略层面的差异,是理解off-policy与on-policy最直观的一课。

4.3 画一张策略表,看清每个格子的决策

数值总归抽象,我更推荐把最终策略画成一张“箭头地图”。每个非起点、非终点、非悬崖的格子,打印出Q值最高的动作:

arrows = ["↑", "→", "↓", "←"] for y in range(4): line = "" for x in range(12): s = y * 12 + x if (x, y) == (0, 3): line += "S " elif (x, y) == (11, 3): line += "G " elif y == 3 and 1 <= x <= 10: line += "X " else: line += arrows[np.argmax(Q[s])] + " " print(line)

训练完成后,这段代码输出的策略图会非常直观地展示Q-Learning学到的那条贴崖路径:上方两行基本都是“→”,到了最右侧往下拐;起点附近会先“↑”再“→”。看到这张图的时候,你对“Q表表达策略”这件事就有了肌肉记忆。以后无论换什么环境,只要状态空间还不算大,我都会先画策略表看看,这一步比看十行回报曲线的信息量都大。

5. 常见问题与排查技巧实录

5.1 问题速查表

这段时间帮不少同学看过CliffWalking相关代码,遇到的坑高度集中在下面几个:

问题现象常见原因解决方法
运行报错“unexpected return from reset”新版gymnasium返回两个值,按老版解包改成state, info = env.reset()
训练一直不收敛,总在悬崖附近徘徊epsilon太大或未衰减尝试epsilon=0.1固定值,或从0.5衰减到0.01
学出的路径绕远路gamma太小,忽视长期收益调到0.9以上再试
悬崖状态Q值莫名变高更新时done分支没有单独处理,误加了未来项确认done时td_target = reward
多次运行结果完全不同没有设置随机种子np.random.seed(0)并在make环境前固定seed
渲染窗口打开但不更新渲染模式或版本问题新版用render_mode="human",结束后env.close()

5.2 两个最容易忽视的隐性bug

第一个是np.max和Python内置max混用。Q表是二维数组,np.max(Q[next_state])是求一行里所有动作的Q值最大值,这是我们要的;如果随手写成max(Q[next_state]),在某些版本下会返回整行的最大元素序列,行为完全走样。写代码时宁可多打两个字符,也不要在这上面省事。

第二个是update时用错状态。我这里再强调一次:TD target里用的是next_state的Q值,不是当前state的Q值。很多初学代码写着写着就顺手抄成Q[state, action] += alpha * (reward + gamma * np.max(Q[state]) - Q[state, action]),等于用自己更新自己,训练曲线会非常离谱。出现这种情况,直接在纸上把一次转移的三元组(s, a, r, s')写出来,对着更新公式逐项标一遍,一眼就能看出哪里写串了。

5.3 我常用的一套排查流程

遇到学不好的情况,我不建议瞎调参,而是按顺序排查。

先看环境本身。随机策略跑几十轮,确认step返回、reward、终止逻辑都符合预期。接着看Q表是否在变化。训练50轮后把Q[0]那个状态的一行打出来,如果四个动作的Q值完全没变,说明更新根本没走到;如果变了,说明主循环没问题。然后看单episode回报。挑几个episode手动打印状态轨迹,看看智能体在哪个位置反复踩崖。最后才动超参数。epsilon、alpha、gamma逐个验证,一次只改一个,不然出了问题你根本不知道是谁引起的。

这套流程在CliffWalking上跑通后,迁移到其他表格型环境一样适用。调强化学习模型和做传统程序调试最大的区别是:信号是带噪声的。曲线抖不代表代码错,完全平滑反而要怀疑是不是策略已经僵化。你要学会区分“正常噪声”和“系统性偏差”,前者多看几条曲线就能判断,后者通常具有反复出现的规律,比如每到某一个特定状态附近就莫名变差。

结尾:一点个人体会

我至今觉得CliffWalking-v0是被低估的入门环境。它的奖励设定足够简单,但又足以支撑你认真思考“为什么这里的步数惩罚会让智能体选择绕路”、“为什么离悬崖越近反而Q值越高”、“为什么不同算法会走出风格迥异的路线”。我在第一次画出那张贴崖路径策略表的时候,突然明白了之前看论文时不太理解的“探索-利用困境”:一个在悬崖边来回试探的智能体,本质上就是在一个高不确定性区域不断做抽样,代价大,但收益也大。这个环境像一座小的训练场,练完之后你再看DQN、PPO那套复杂框架,至少理解它们想解决的原始问题是什么。如果你也在学强化学习的路上卡壳,我真心建议你先把这48个格子玩透,再往深了走。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进