我最早接触 CartPole 是在某实验室的分享会上,当时大家聊到强化学习入门环境,都觉得“一个倒立摆能有多难”。结果我回去用 Q-learning 写第一版时,杆子像喝醉一样左右乱甩,Q 表更新了几千步,实际表现却和随机推车没什么区别。后来把状态离散化、回报设计、epsilon 衰减顺序重新梳理了一遍,才终于在 CartPole 上稳定撑到 500 步。
这篇文章就围绕这个完整的落地过程展开:先讲 CartPole 为什么难,再讲 Q-learning 的核心更新逻辑,接着给出可以直接照跑的 Python 代码,最后聊我踩过的坑和调参心得。适合刚看完 Q-learning 公式但不知道怎么写代码的人,也适合已经跑起来但发现训练曲线始终不涨的人。
1. 先搞清楚 CartPole 到底难在哪
CartPole 这个名字看起来人畜无害,但真要控制好,它背后涉及的是经典的非线性倒立摆模型。杆子通过铰链连接在一辆小车上,智能体每一时刻只能做一件事:向左或向右推小车。目标很单纯:让杆子保持竖直,同时小车不要跑出边界。
1.1 状态、动作和回报是唯一需要关心的三件事
用强化学习的术语说,CartPole 是一个离散动作、连续状态的马尔可夫决策过程。每一步环境会返回一个 4 维观测向量,分别表示小车位置、小车速度、杆子角度、杆子角速度。
| 状态维度 | 含义 | 我常用的离散边界 |
|---|---|---|
| cart position | 小车相对中心的水平位置 | -2.4 ~ 2.4 |
| cart velocity | 小车的水平速度 | -2.0 ~ 2.0 |
| pole angle | 杆子相对竖直方向的角度 | -0.5 ~ 0.5 |
| pole angular velocity | 杆子的角速度 | -2.0 ~ 2.0 |
动作空间更简单,只有两个离散动作:0 表示向左推,1 表示向右推。回报同样简单:只要这个 step 没有触发终止条件,就回报 +1。所以一个 episode 的累计回报,实际上就是杆子坚持的步数。CartPole-v1 的默认时间上限是 500 步,也就是说,训练得当的智能体应该尽量逼近 500 这个分数。
这里的难点在于,状态空间是连续的。如果你直接用一个二维数组,用原始浮点数去索引,那是做不到的,因为浮点数有无数种取值。这也直接引出 Q-learning 在 CartPole 上的第一个关键决策:状态离散化。
1.2 连续状态必须离散化,不能硬套公式表
Q-learning 最朴素的形式是查表法:有一张 Q 表,表里记录“在某个状态下选择某个动作”的长期回报估计值。状态如果是离散的,比如迷宫里的几个格子,那 Q 表很容易建。但 CartPole 的状态是连续浮点数,而且四个维度之间还会相互影响,直接拿原始观测值存表根本不可能。
所以常见做法是把每个连续维度切成若干区间,把连续观测值映射成离散整数编号。比如杆子角度在 -0.5 到 0.5 之间,我把它切成 8 个区间,那么某个具体角度就会落到其中一个区间,这个区间编号就是该维度的状态索引。四个维度都这样处理,最后组合成一个四元组,那就是 Q 表的一个状态索引。
我用的桶数量是 (4, 2, 8, 4)。意思是:小车位置切 4 桶,速度只切 2 桶(大概分成“向左走”和“向右走”),杆子角度切 8 桶,杆子角速度切 4 桶。这样总状态数是 4×2×8×4 = 256 个,再乘上两个动作,Q 表里只需要维护 512 个数值。这个规模小到任何电脑都能秒跑。
注意:速度维度的真实范围是无穷大,观察空间里给的是
-inf和inf,没法直接拿来切桶。我自己动手设了 -2.0 到 2.0,超出边界的值就归到边界桶。这个属于常见实践里的工程补充,因为在实际训练中速度很少会超过这个范围,而且就算偶尔超过,丢到边界桶也不会破坏整体训练。
1.3 终止条件才是真正的训练信号
很多初学者会忽略一件事:CartPole 的回报虽然每一步都是 +1,但真正让智能体学到东西的,是终止条件。当杆子倾斜角度超过 12 度,或者小车跑出边界,环境就会终止当前 episode。如果达到了 500 步的上限,环境会返回 truncated=True,表示这一轮完美完成。
换句话说,Q-learning 在更新时不仅要看当前回报,还要看“这个状态之后还能不能继续活下去”。如果某一步之后很快终止,Q 值就会被拉低;如果某一步之后还能继续存活很长时间,Q 值就会逐步累积变高。这也是为什么不能只盯着“每一步 +1”傻乐,要理解终止状态在 Q 值传播中的作用。
在 Gymnasium 接口里,env.step(action) 会返回 5 个值:observation、reward、terminated、truncated、info。其中 terminated 表示环境因为杆子倒了或小车出界而结束,truncated 表示因为达到时间上限而结束。写代码时要注意把两者都当成结束信号,否则 loop 容易卡在环境内部。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Q-Learning 的核心逻辑和工程选型
Q-learning 是强化学习里最经典的离策略时间差分算法。说人话就是:它用“下一步的最大 Q 值”来估计当前这步做这个动作到底值不值,然后不断修正这个估计。不需要环境模型,不需要完整的状态转移概率,只需要不断采样交互数据就行。
2.1 从 Q 值到策略:怎么判断动作好不好
Q 值的全称是 action-value,表示“在状态 s 下选择动作 a,之后一直按照最优策略走,预期能拿到的总回报”。数学上写成期望形式,但实际更新时我们用单步采样近似:
Q(s,a) ← Q(s,a) + α × [r + γ × max_a' Q(s',a') − Q(s,a)]
这个式子里的 r + γ × max_a' Q(s',a') 叫 TD 目标,max_a' Q(s',a') 表示从下一个状态开始,假设你选择最优动作,能得到多大的价值。之所以取 max,是因为 Q-learning 是 off-policy 算法,它评估的是“最优策略”,不是当前正在执行的策略。
整个 Q 表本质上是 agent 对环境的内部价值地图。训练时每走一步,就用真实观察到的回报去刷新这个地图。动作选择也一样简单:某个状态下,Q 值最大的动作就是当前策略认为最好的动作。
2.2 三个关键超参数:alpha、gamma、epsilon
任何 Q-learning 代码里,最核心的三个超参数分别是学习率 α、折扣因子 γ、探索概率 ε。我一开始喜欢照搬网上推荐值,后来自己逐个调了一遍,才真正知道每个参数在训练过程中扮演什么角色。
- α(学习率):控制新信息对旧 Q 值的影响程度。α 太大,Q 值容易震荡;α 太小,学习过程慢到让人怀疑人生。对 CartPole 这种每步都有回报的问题,我常用的范围是 0.3 到 0.6。我的代码里用 0.5,属于比较激进但又不至于发疯的取值。
- γ(折扣因子):控制智能体有多看重未来回报。γ=0 时只看眼前这一步,γ=1 时所有未来回报看得一样重。CartPole 每个 step 都 +1,我习惯用 0.99,这样 agent 会尽量追求更长的存活时间,而不是只顾眼前。
- ε(探索率):控制 agent 随机探索与利用已有 Q 值的比例。训练初期 ε 接近 1,让 agent 多尝试各种动作;训练后期 ε 逐渐衰减,让 agent 更多选择 Q 值最大的动作。
这三个参数不是孤立的。α 大会加速收敛,但也需要配合一个合理的 γ,否则只学眼前回报。ε 衰减得太快,agent 会错过后面的好策略;衰减得太慢,训练半天还像个醉汉乱逛。
2.3 为什么动作选择要用 epsilon-greedy,而不是纯贪心
如果训练过程中每一步都选当前 Q 值最大的动作,agent 会早早锁定一个看似不错的局部行为,但可能永远不会发现更好的动作。比如它前期偶然发现一直向右推能撑几十步,于是再也不向左推了,但 Q 表里很多状态根本没被充分探索。
epsilon-greedy 的做法是:每次以 ε 的概率随机选动作,以 1-ε 的概率选 Q 值最大的动作。这样既能利用已经学到的知识,又能保留探索机会。比较关键的一点是,动作选择用 ε-greedy,但 Q 值更新里的 max 依旧针对最优动作,这正是 Q-learning 的离策略特性。
提醒一点:训练完做评估时,必须把 ε 设为 0,直接用贪心策略。否则测试结果里混入随机动作,没法反映真实水平。我见过很多人在评估时忘了关闭探索,最后曲线一上一下,还以为代码写错了。
3. 代码落地:一步步把 Q-learning 跑起来
理论讲完了,下面进入代码部分。我用的是 Gymnasium 库,它是目前比较主流的强化学习环境接口。先装环境:
bash复制pip install gymnasium
CartPole-v1 在 Gymnasium 里可以直接通过 gym.make("CartPole-v1") 创建。
3.1 环境初始化与状态离散化映射
首先要定义离散化的边界和桶数量。CartPole 的原始观测是 4 维浮点向量,我按照上面的表格手动指定边界,然后把每个维度映射到对应的整数桶。
python复制import random
import numpy as np
import gymnasium as gym
env = gym.make("CartPole-v1")
N_BINS = (4, 2, 8, 4)
BOUNDS = [
(-2.4, 2.4), # 小车位置
(-2.0, 2.0), # 小车速度
(-0.5, 0.5), # 杆子角度
(-2.0, 2.0), # 杆子角速度
]
def discretize(obs):
idx = []
for value, (low, high), bins in zip(obs, BOUNDS, N_BINS):
if value <= low:
bucket = 0
elif value >= high:
bucket = bins - 1
else:
bucket = int((value - low) / (high - low) * bins)
bucket = min(bins - 1, bucket)
idx.append(bucket)
return tuple(idx)
这里有一个容易被忽视的细节:边界值要记得做 clamp。比如小车位置偶尔冲到 -2.41,如果不对负数边界做处理,计算出来的索引可能是 0 或负数,导致 Q 表下标越界。我的代码里用 if value <= low 直接归到 0,用 elif value >= high 归到最后一个桶,保证索引永远不会超出范围。
对角度边界,我用了 -0.5 到 0.5,比实际终止阈值 12 度稍大。因为 CartPole-v1 在角度超过 12 度时就已经终止了,所以训练中实际看到的观测值不会超过这个范围。如果角度桶切得太细,比如切 20 个,虽然状态更精确,但样本量不足时会更容易出现“某个状态没被访问过”的冷门桶;切得太粗,又会让 agent 丢失关键角度信息。(4,2,8,4) 这个组合是我调试下来性价比比较高的方案。
3.2 训练主循环:Q 表更新与探索衰减
Q 表直接用 NumPy 数组初始化。数组形状是 (4, 2, 8, 4, 2),前 4 维是状态索引,最后一维是动作。然后开始跑 episode 循环,每个 episode 内不断和环境交互,交互完立刻更新 Q 表。
python复制q_table = np.zeros(N_BINS + (env.action_space.n,))
ALPHA = 0.5
GAMMA = 0.99
EPSILON = 1.0
EPSILON_MIN = 0.01
EPSILON_DECAY = 0.995
EPISODES = 600
reward_history = []
for episode in range(EPISODES):
obs, _ = env.reset()
state = discretize(obs)
total_reward = 0
terminated = False
truncated = False
while not terminated and not truncated:
# epsilon-greedy 动作选择
if random.random() < EPSILON:
action = env.action_space.sample()
else:
action = np.argmax(q_table[state])
next_obs, reward, terminated, truncated, _ = env.step(action)
next_state = discretize(next_obs)
# Q-learning 更新
best_next = np.max(q_table[next_state])
td_target = reward + GAMMA * best_next
q_table[state][action] += ALPHA * (td_target - q_table[state][action])
state = next_state
total_reward += reward
reward_history.append(total_reward)
# 每个 episode 结束之后再衰减 epsilon
EPSILON = max(EPSILON_MIN, EPSILON * EPSILON_DECAY)
# 滚动平均,判断是否提前停止
if len(reward_history) >= 50:
avg = np.mean(reward_history[-50:])
if avg >= 450:
print(f"early stop at episode {episode}, avg={avg:.1f}")
break
代码里的 total_reward 就是当前 episode 存活步数。如果能够稳定坚持 500 步,最后 50 个 episode 的平均值会接近 500。提前停止有助于节省时间,但要注意别只看一两个高分 episode 就判定成功,必须用至少 50 个 episode 的平均分来判断。
一个常见问题是,有些文章会把 EPSILON 更新放在每个 step 里。我在实践里的体会是:按 episode 衰减更稳定。因为如果按 step 衰减,前面很多随机动作导致提前终止,同一个 episode 内的步数少,epsilon 下降不均匀;按 episode 衰减则每个 episode 的探索程度差不多。
3.3 测试评估:让 agent 退出探索模式
训练完后,用一个独立的评估函数跑若干回合。测试阶段必须关闭 epsilon,直接每次选 Q 值最大的动作。这里我会统计平均回报,如果平均回报达到 450 以上,基本可以认为智能体学会了这个任务。
python复制def evaluate(q_table, env, n_episodes=20):
total = 0.0
for _ in range(n_episodes):
obs, _ = env.reset()
state = discretize(obs)
terminated = False
truncated = False
episode_reward = 0
while not terminated and not truncated:
action = np.argmax(q_table[state])
obs, reward, terminated, truncated, _ = env.step(action)
episode_reward += reward
if not terminated and not truncated:
state = discretize(obs)
total += episode_reward
return total / n_episodes
avg_reward = evaluate(q_table, env, n_episodes=20)
print(f"average test reward: {avg_reward:.1f}")
注意循环里我是在确认环境没结束后才更新 state。这样做的原因是,如果这一步触发了终止条件,下一步根本不会执行,再去更新状态没有意义。虽然多数情况下不影响结果,但养成这个习惯可以避免后续迁移到其他环境时踩坑。
如果想看可视化,可以单独创建一个 render_mode="human" 的环境来测试:
python复制eval_env = gym.make("CartPole-v1", render_mode="human")
evaluate(q_table, eval_env, n_episodes=5)
eval_env.close()
训练环境不需要渲染,因为渲染会拖慢交互速度;测试时再打开渲染比较合理。
4. 训练效果和超参数调优
很多文章写到这里就停了,但实际跑代码的人最关心的是:训练曲线到底长什么样,参数变了会有什么影响。我用相同的随机种子和数据,跑了不同的参数组合,下面直接分享观察结果。
4.1 我跑出来的 reward 曲线长什么样
使用 alpha=0.5、gamma=0.99、epsilon_decay=0.995 这套参数时,前 100 个 episode 的平均回报通常在 20 到 60 之间,偶尔能冲到 100,但很快又会掉下来。原因是早期 epsilon 很高,智能体大部分时间在随机探索,Q 表还没有形成稳定的价值判断。
100 到 300 个 episode 之间,曲线开始出现明显上升。比如原本平均 40 分,一下子跳到 120、180,甚至有几次冲到 400。但这个过程很不稳定,经常会出现“上一个 episode 拿了 400,下一个 episode 只拿了 20”的情况。这很正常,因为随机探索动作仍然存在,而且早期 Q 值更新很敏感,一个坏样本就可能让 Q 值产生波动。
300 个 episode 之后,如果参数设置合理,最后 50 个 episode 的平均分很容易突破 450。这时候智能体已经学会了:杆子往左倒就往左推,杆子往右倒就往右推,而且不会让小车跑出边界。
千万不要只看单次 episode 的回报来判断模型好坏。单次 500 可能只是运气好,必须看滚动平均。我习惯每 50 个 episode 打印一次平均分,这样能过滤掉大部分噪声。
4.2 超参数组合对比
我专门跑过几组对比,下面这张表总结了个大概规律。实际效果会受随机种子影响,但趋势是稳定的。
| 参数调整 | 现象 | 我的结论 |
|---|---|---|
| alpha 从 0.5 调到 0.1 | 学习明显变慢,300 个 episode 后才开始有起色 | CartPole 这种小状态空间可以用稍大的学习率 |
| alpha 从 0.5 调到 0.9 | Q 值震荡严重,曲线撕裂感很强 | 学习率太高,不建议 |
| gamma 从 0.99 调到 0.9 | agent 更短视,最后平均分经常卡在 300 左右 | 0.99 更适合这种长时程任务 |
| epsilon_decay 从 0.995 调到 0.98 | 探索衰减太快,agent 还没探索完就进入利用阶段,Q 表很多状态从未被访问 | 衰减要柔和一点 |
| epsilon_decay 从 0.995 调到 0.999 | 探索时间过长,最后分数也能高,但要跑更多 episode 才收敛 | 如果时间充足可以接受,否则默认 0.995 更均衡 |
我个人的默认参数组合是 alpha=0.5, gamma=0.99, epsilon_decay=0.995,在 600 个 episode 内大多数情况下可以稳定到 450 分以上。如果你发现训练很久都不收敛,优先检查 epsilon 的衰减速度,而不是盲目提高学习率。
4.3 从“能跑”到“跑得稳”:还要关注泛化性能
当我第一次看到测试平均分超过 450 时,以为任务已经彻底搞定了。后来换了个评估方式,用 100 个测试 episode 去统计“达到 500 步的比例”,结果发现大约有 15% 的 episode 依然只跑了 100 多步。这说明 Q-learning 虽然基本学会了策略,但离散化带来的信息损失还是会让它在某些边缘状态下犯错。
如果想提升稳定性,有两条路:
- 增加关键维度的桶数。比如把杆子角度从 8 桶增加到 16 桶,角速度从 4 桶增加到 6 桶。代价是 Q 表变大,训练需要更多样本。
- 适当调低 epsilon 下限。默认
EPSILON_MIN=0.01意味着训练后期仍有 1% 的随机动作,虽然影响不大,但对追求稳定的场景可以降到 0.001 或直接设 0。 - 用滑动平均保存最佳 Q 表。每训练 20 个 episode,就评估一次,如果测试分数创新高,就把当前 Q 表备份下来。最后使用历史最佳 Q 表,而不是最后一版。
我后来在代码里加了“最佳 Q 表备份”逻辑:用 best_q_table 保存测试平均分最高时的表格,训练结束后加载备份再做最终测试。这样能在一定程度上避开“尾部训练曲线抖动导致最终 Q 表不是最优”的问题,也是工程实践里很实用的小技巧。
5. 常见问题与排查技巧实录
最后这部分,我把自己踩过的问题整理成一张排查清单。如果你照着上面的代码跑,大概率不会遇到严重问题,但一旦改了离散化参数或者环境迁移,这些经验能帮你省下大量时间。
5.1 Q 表更新了很久还是全零,或全是一个值
这个问题最常见的来源是 discretize 函数写错了,导致所有观测值都被映射到同一个 state。比如边界设置太小,绝大多数样本都落到了 0 桶,Q 表就只有个别条目被更新。排查时可以在训练前打印几组原始观测对应的离散状态索引,确认它们是否足够分散。
另一个原因是更新位置写错了。有些初学者把 q_table[state][action] 的更新写进了 if done 分支里,导致只有终止前最后一步才更新。正确做法是每个 step 都更新,而且更新完后要把 next_state 赋给 state。
5.2 epsilon 衰减和 alpha 之间的配合问题
如果把 epsilon_decay 设得特别快,比如 0.98,那么训练初期探索时间太短,Q 表里后面很多状态完全没被访问过,训练中后期 agent 只会机械地选择少数几个熟悉状态下的动作,分数卡死不涨。反之,epsilon_decay=0.9999 时探索时间很长,但收敛也慢,需要把 EPISODES 调高到 2000 以上。
我建议的经验值是:600 个 episode 内收敛,decay=0.995 比较合适;1000 个 episode 的预算,可以用 decay=0.997。同时也要检查 alpha,如果 alpha 偏小,Q 值修正太慢,即便探索充足也可能在 600 个 episode 内学不到稳定策略。
5.3 测试平均分很高,但单次 episode 波动极大
这种情况多半是离散化粒度太粗。比如把角度只切成 4 个桶,agent 在边界附近无法区分“马上要倒了”和“还可以稳住”的细微差别,于是同一个状态偶尔会走出完全不同的结果。解决方法是把角度和角速度的桶数提高,或者把边界范围收窄,让主要观测区域分布得更密集。
另一种可能是训练时 epsilon 没有完全关闭。测试阶段要确认选择动作时用的是 np.argmax(q_table[state]),而不是沿用训练里的 random.random() < EPSILON 判断。这个低级错误我犯过不止一次。
5.4 训练时间太长,如何做性能优化
256 个状态、2 个动作的 Q-learning 几乎不存在性能问题。如果状态被细化到几万个,比如 (10, 10, 20, 10),Q 表就是几万行乘以 2,内存不是问题,但收敛速度会明显下降。这时候可以考虑:
- 减少不必要的维度。CartPole 里小车速度对平衡贡献有限,可以先把它只切成 2 桶,甚至临时去掉,看性能损失多少。
- 使用双 Q-learning 或期望 Sarsa,但这是后话。先让基础版本跑通,再谈改进。
- 如果状态空间继续扩大,就该换用 DQN 或 tile coding,而不是硬啃 Q 表。
我现在的做法是:在基础 Q-learning 通过之后,再去修改离散化桶数和超参数,让它逐渐逼近 500 分;然后再尝试把算法换成 DQN 或者加入经验回放,对比收敛速度和稳定性。这样一步一步递进,比一上来就追求“最强算法”要容易理解得多。
CartPole 的 Q-learning 版本虽然简单,但它把强化学习里最核心的几个概念全部串起来了:状态离散化、Q 值更新、探索与利用、终止条件处理。后面做 DQN、PPO 时,很多直觉仍然是从这个小实验里来的。如果你刚起步,别急着折腾高深算法,先把这个 500 分的平衡杆跑通,再往下走会顺畅很多。
