做强化学习入门,绕不开的一个经典环境就是倒立摆平衡问题,业内通常叫它 CartPole。很多人第一次跑通 DQN 或者策略梯度,都是拿它练手。但如果你直接跳到深度强化学习,往往会忽略一个更基础、也更能帮你建立直觉的方法——蒙特卡洛方法。我见过不少同学,环境搭好了,模型也写了,训练半天不收敛,回头一查,连最基础的回合制采样和价值估计逻辑都没理清楚。所以这篇内容,我想把 CartPole 这个环境用蒙特卡洛方法从头到尾拆一遍,讲清楚每一步为什么这么做、参数怎么定、坑在哪里。不管你是刚接触强化学习的新手,还是想回头补一补基础的老手,应该都能从里面拿到点能直接复现的东西。
1. 为什么用蒙特卡洛方法啃 CartPole
1.1 先搞清楚 CartPole 到底在解决什么问题
CartPole 这个环境,说白了就是一根杆子立在一个能左右移动的小车上,你要通过给小车施加向左或向右的力,让杆子尽量不倒。状态空间是四个连续值:小车位置、小车速度、杆子角度、杆子角速度。动作空间只有两个:向左推、向右推。每坚持一个时间步给 1 分,杆子倾斜超过一定角度或者小车跑出边界,回合结束。
这个环境的特点是:状态连续、动作离散、回合有明确终止。它不像迷宫那种格子世界可以枚举状态,也不像有些任务奖励稀疏。CartPole 的奖励是每步都给,很密集,这其实对蒙特卡洛方法是有利的——因为蒙特卡洛靠的是完整回合的回报来估计价值,奖励密集意味着回报信号不会太稀疏,采样效率相对可控。
但这里有个关键矛盾:状态是连续的,蒙特卡洛方法传统上是基于表格的,也就是要维护一个状态到价值的映射表。连续状态没法直接建表,怎么办?这就引出了我们方案选型时第一个要做的决定。
1.2 表格法还是函数逼近,这个选择决定了后面所有代码结构
我一开始也纠结过,要不要直接上神经网络做函数逼近。后来实测下来,对于教学和建立直觉的目的,状态离散化加表格型蒙特卡洛是更稳妥的路线。原因有三点。
第一,表格法的逻辑是透明的。你能亲眼看到每个离散格子里的价值是怎么被一次次回报更新出来的,这对理解蒙特卡洛的核心——用实际回报的均值去逼近期望价值——非常有帮助。神经网络一上来就把这层逻辑包起来了,新手容易变成调包侠。
第二,CartPole 的状态范围是有限的。小车位置大概在正负 2.4 之间,角度在正负 0.2 弧度左右,速度和角速度也有经验范围。这意味着我们可以把连续空间切成有限个格子,虽然精度有损失,但完全够用。
第三,蒙特卡洛本身是回合制的,必须等一个回合结束才能更新。表格法配合离散化,更新逻辑简单直接,不会引入额外的近似误差来源。等你把这套跑通了,再换成函数逼近,心里就有底了。
所以整体思路定下来:把四个连续状态维度各自离散成若干区间,组合成一个离散状态编号,用字典或者数组维护每个状态动作对的价值,用完整回合的回报做蒙特卡洛更新,策略采用 epsilon 贪心。下面几节就围绕这个思路展开。
1.3 蒙特卡洛和时序差分的本质区别,别混着用
这里必须插一句,很多人会把蒙特卡洛和时序差分搞混。蒙特卡洛必须等回合结束,用从当前时刻到回合终止的累计回报来更新价值;时序差分则是走一步就更新,用下一步的估计值来 bootstrap。CartPole 是有终止的回合制任务,用蒙特卡洛是天然合适的。
但要注意,蒙特卡洛的更新目标是无偏的,因为用的是真实回报;代价是方差大,因为一个回合里随机性会累积。这就导致蒙特卡洛对采样数量要求比较高,学习曲线会比时序差分抖。你在调参的时候如果发现价值估计跳得厉害,别慌,这是方法本身的特性,不是代码写错了。理解这一点,后面看训练曲线就不会误判。
2. 状态离散化:连续空间怎么切成能用的格子
2.1 每个维度切多少格,这个数不是拍脑袋定的
离散化的核心参数是每个维度切几个区间。切太少,状态分辨率不够,策略学不细;切太多,状态空间爆炸,每个格子被访问的次数变少,蒙特卡洛的均值估计就不准了。这是个典型的偏差方差权衡。
我的经验是,CartPole 四个维度不要平均用力。小车位置和杆子角度对决策影响最大,可以多切几格;速度和角速度变化快,切太细反而采样跟不上。一个比较稳的起点是:
| 状态维度 | 经验范围 | 建议切分数量 | 说明 |
|---|---|---|---|
| 小车位置 | -2.4 到 2.4 | 6 | 超出即终止,边界明确 |
| 小车速度 | -3 到 3 | 6 | 实际很少跑满,留余量 |
| 杆子角度 | -0.2 到 0.2 | 12 | 角度最关键,切细一点 |
| 杆子角速度 | -3 到 3 | 6 | 变化剧烈,不宜过细 |
这样组合起来的状态数是 6×6×12×6,大概两千多个离散状态,每个状态两个动作,价值表规模在五千左右。这个量级对蒙特卡洛来说是可以接受的,一个几百回合的训练就能让大部分格子被访问到。
这里有个细节要注意:角度范围为什么是正负 0.2 而不是更大。因为环境本身在角度超过这个阈值时就判定回合结束了,所以超出这个范围的状态根本不会出现,切了也是浪费。同理,位置超过正负 2.4 也终止。速度和角速度没有硬边界,但实际运行中很少超过正负 3,超出部分可以截断到边界格子里。
2.2 离散化的代码实现和边界处理
离散化的本质就是一个分段函数:给定一个连续值,算出它落在第几个区间。用 numpy 的digitize或者自己写个线性映射都行。我习惯自己写,因为边界处理更可控。
import numpy as np def discretize_state(state, bins): # bins 是每个维度的切分数量列表 # 先把连续值裁剪到经验范围内,防止越界索引 upper = [2.4, 3.0, 0.2, 3.0] lower = [-2.4, -3.0, -0.2, -3.0] ratios = [(state[i] - lower[i]) / (upper[i] - lower[i]) for i in range(4)] # 映射到区间索引,注意边界要夹住 indices = [] for i in range(4): idx = int(ratios[i] * bins[i]) idx = min(bins[i] - 1, max(0, idx)) indices.append(idx) return tuple(indices)这段代码的关键在最后那个min和max。因为浮点误差或者极端情况,算出来的索引可能等于 bins 数量或者变成负数,不夹住的话后面查表直接报错。我踩过这个坑,训练到一半突然索引越界,排查半天才发现是边界值没处理。
注意:离散化的范围上下界一定要和环境终止条件对齐。角度阈值如果环境里是 0.2095 弧度,你离散化用 0.2,那 0.2 到 0.2095 之间的状态会被错误地夹到边界格子里,虽然影响不大,但逻辑上不严谨。建议直接用环境文档里给的终止阈值。
2.3 离散化带来的信息损失,心里要有数
必须承认,离散化是有代价的。两个连续状态可能被分到同一个格子里,但它们的真实价值其实不同。这种信息损失会导致策略在格子边界附近表现不稳定。实测下来,CartPole 用上面这套切分,训练好的策略能稳定撑到两三百步,但想再往上冲就比较难了,瓶颈就在离散精度。
如果你追求更高性能,可以增加切分数量,但要同步增加训练回合数,否则很多格子没被访问到,价值估计就是错的。另一个思路是用 tile coding 这种重叠式离散化,一个连续状态同时激活多个格子,能缓解边界问题,但实现复杂度上来了。作为入门,先把均匀离散这套吃透,比一上来就搞复杂方法强。
3. 蒙特卡洛控制的核心逻辑拆解
3.1 首次访问和每次访问,选哪个
蒙特卡洛估计价值有两种变体:首次访问只在一个回合里第一次遇到某状态动作对时更新,每次访问则是每次遇到都更新。理论上两者都收敛到真实价值,但方差和偏差特性略有不同。
在 CartPole 里,一个回合内同一个离散状态动作对重复出现的概率不低,尤其是杆子快倒的时候,状态会在几个格子间来回跳。我选择每次访问蒙特卡洛,原因是它能更充分地利用每个回合的样本,收敛更快。代价是同一个回合内的多次更新之间有相关性,方差会大一点,但配合后面要讲的增量式均值更新,实际影响可以接受。
如果你用首次访问,逻辑上要维护一个回合内已访问集合,代码稍微复杂一点,而且样本利用率低。对于 CartPole 这种回合不算长的任务,每次访问更划算。
3.2 增量式更新均值,别傻傻地存所有回报
蒙特卡洛的核心是求回报的均值。最朴素的做法是把每个状态动作对的所有回报存下来,最后求平均。但这样内存会随着训练不断增长,不现实。正确做法是用增量式公式:
新均值 = 旧均值 + (当前回报 - 旧均值) / 访问次数
这个公式的好处是只需要维护均值和计数两个量,内存恒定。而且它天然支持在线更新,来一个回报就更新一次。代码里就是一个字典存价值,一个字典存计数。
def update_value(value_table, count_table, state_action, return_value): count_table[state_action] = count_table.get(state_action, 0) + 1 n = count_table[state_action] old = value_table.get(state_action, 0.0) value_table[state_action] = old + (return_value - old) / n这里有个容易忽略的点:初始值设多少。价值表初始化为 0 是可以的,但如果你希望策略早期多探索,可以把初始值设得乐观一点,比如设成一个正数,这样没被访问过的状态动作对看起来价值很高,策略会倾向于尝试它们。这叫乐观初始值,是一种内置的探索机制。不过在配合 epsilon 贪心的情况下,初始值设 0 就够了,探索交给 epsilon 负责。
3.3 epsilon 贪心策略的衰减节奏
epsilon 贪心是平衡探索和利用的手段。epsilon 概率随机选动作,1 减 epsilon 概率选当前价值最高的动作。关键问题是 epsilon 怎么随训练变化。
我的做法是分段衰减,而不是线性连续衰减。前期 epsilon 设 1.0,纯随机探索,把状态空间尽量铺开;中期降到 0.5 左右,开始利用已有价值;后期降到 0.1,主要利用。每段持续固定的回合数。这样比连续衰减更好控制,也更容易复现。
为什么不用连续衰减?因为蒙特卡洛的价值估计在早期非常不准,如果 epsilon 降太快,策略会过早锁定到错误的价值上,陷入局部最优。分段衰减给了价值表足够的“预热”时间。实测下来,前 200 回合 epsilon 为 1.0,中间 300 回合 0.5,后面 500 回合 0.1,这个节奏在 CartPole 上比较稳。
提示:epsilon 为 1.0 时策略完全随机,这时候采到的回合回报普遍很低,价值表更新幅度大但方向乱。别看到这段曲线难看就以为出问题了,这是正常的预热阶段。
4. 完整实操流程与关键代码环节
4.1 环境交互和回合采样的写法
蒙特卡洛必须采完整回合,所以主循环结构是外层回合、内层时间步。每个回合开始重置环境,然后循环执行动作直到 done 为真,把每一步的状态、动作、奖励记下来。回合结束后,从后往前算累计回报,再逐个更新价值表。
这里有个实现细节:累计回报要从回合末尾往前算。因为回报是从当前时刻到终止的折扣奖励之和,从后往前累加最自然,避免重复计算。折扣因子 gamma 一般取 0.99,CartPole 回合通常几百步,0.99 的折扣能让远期奖励仍有影响,又不至于衰减太慢。
def run_episode(env, value_table, epsilon, bins, gamma=0.99): state = discretize_state(env.reset(), bins) episode = [] done = False while not done: if np.random.rand() < epsilon: action = np.random.randint(2) else: q_left = value_table.get((state, 0), 0.0) q_right = value_table.get((state, 1), 0.0) action = 0 if q_left >= q_right else 1 next_state_raw, reward, done, _ = env.step(action) next_state = discretize_state(next_state_raw, bins) episode.append((state, action, reward)) state = next_state # 从后往前算回报并更新 G = 0.0 for state, action, reward in reversed(episode): G = gamma * G + reward update_value(value_table, count_table, (state, action), G) return len(episode)注意env.reset()的返回值在不同版本里可能是单个状态或者元组,我这里按单状态写,你实际跑的时候如果报错,检查一下版本,取第一个元素就行。这种版本差异是新手常卡的地方,不是逻辑问题。
4.2 训练循环和监控指标
训练循环就是把上面的回合采样重复很多次,同时记录每个回合的步数作为性能指标。步数越多说明杆子撑得越久,策略越好。一般训练一千到两千回合,能看到明显上升趋势。
episode_lengths = [] for ep in range(2000): if ep < 200: eps = 1.0 elif ep < 500: eps = 0.5 else: eps = 0.1 length = run_episode(env, value_table, eps, bins) episode_lengths.append(length) if (ep + 1) % 100 == 0: avg = np.mean(episode_lengths[-100:]) print(f"回合 {ep+1}, 近100回合平均步数 {avg:.1f}, 当前epsilon {eps}")监控指标除了步数,我还建议记录价值表的规模,也就是有多少个状态动作对被访问过。这个数字能告诉你探索是否充分。如果训练到后期访问过的格子数还很少,说明离散化太细或者 epsilon 衰减太快,需要调整。
4.3 参数选择的计算依据
几个核心参数我列一下取值和理由,方便你直接抄:
| 参数 | 取值 | 选择理由 |
|---|---|---|
| 折扣因子 gamma | 0.99 | 回合几百步,0.99 保证远期奖励有约 0.99 的百次方衰减,仍有信号 |
| 学习回合数 | 2000 | 状态空间约两千格,每格平均被访问若干次,足够均值收敛 |
| epsilon 分段 | 1.0/0.5/0.1 | 预热、过渡、利用三阶段,避免过早锁定错误价值 |
| 离散切分 | 6/6/12/6 | 角度最关键切最细,其余适中,控制总状态数 |
gamma 为什么是 0.99 而不是 0.9?0.9 的话,一百步之后的奖励衰减到 0.9 的百次方,几乎为零,等于只看近处,CartPole 需要维持长期平衡,折扣太狠会让策略短视。0.99 在几百步的回合里能保留足够的远期信号,是这类任务的常用值。
5. 常见问题与排查实录
5.1 训练曲线不上升,先查这三个地方
第一个要查的是离散化索引是否越界或错位。前面提过边界夹取,如果没做,程序可能不报错但状态映射全乱,价值表学的是错误的状态。排查方法很简单,打印几个回合里离散化前后的状态,人工核对映射是否合理。
第二个要查的是回报计算方向。从前往后累加回报是错的,必须从后往前。如果写反了,价值表更新的是错误的目标,曲线会一直平。这个错误很隐蔽,因为代码不报错,只是学不会。
第三个要查的是epsilon 是否降太快。如果一上来就用 0.1,探索不足,很多状态没被访问,价值估计偏差大,策略卡在局部。把 epsilon 调回 1.0 重新跑,看曲线是否改善。
5.2 价值表更新了但策略还是乱选
这种情况通常是状态动作对的键没对齐。比如离散化返回的是元组,但查表时用了列表,或者动作索引和价值的存储顺序不一致。Python 里元组和列表作为字典键行为不同,元组可哈希,列表不可哈希,用列表当键直接报错。但如果动作索引写反了,比如训练时动作 0 存左边、评估时当成右边,就不会报错但策略全错。
排查技巧:固定一个简单状态,手动算一下两个动作的价值,再打印策略在该状态的选择,看是否一致。这种对齐问题在表格法里很常见,尤其是状态和动作组合成键的时候。
5.3 撑到一定步数就上不去了
这是离散化精度瓶颈的典型表现。前面说过,均匀离散在边界附近有信息损失,策略在临界状态判断不准。想突破的话,可以增加角度维度的切分数量,比如从 12 加到 20,同时把训练回合数提到 3000 以上,保证新格子被访问到。
另一个思路是调整离散化的范围,把角度范围收窄到实际更常出现的区间,让格子分布更集中。但这属于精细调优,入门阶段先把基础版本跑稳,知道瓶颈在哪就够了。
| 问题现象 | 可能原因 | 排查动作 |
|---|---|---|
| 曲线完全不涨 | 回报累加方向错 | 检查是否从后往前算 G |
| 曲线抖动剧烈 | epsilon 衰减太快 | 恢复分段衰减重跑 |
| 策略随机乱选 | 状态动作键错位 | 固定状态手动核对价值 |
| 步数卡在固定值 | 离散精度不足 | 增加关键维度切分数量 |
5.4 一个容易被忽略的实操心得
跑蒙特卡洛的时候,别用单次训练的曲线下结论。蒙特卡洛方差大,同样的参数跑两次,曲线可能差不少。我一般会跑三到五次,看平均趋势。如果三次里有两次能稳定上升,说明方案没问题,个别次的抖动是随机性导致的。这个习惯能帮你避免很多无效调参——有时候不是参数错了,只是这一次运气不好。
另外,价值表可以保存下来复用。训练好的价值表存成文件,下次评估直接加载,不用重新训练。这在做对比实验的时候特别省时间,比如你想比较不同离散化方案,价值表分开存,评估时切换加载就行。
6. 从表格蒙特卡洛往深度方法过渡的衔接点
把这套表格蒙特卡洛跑通之后,你其实已经掌握了强化学习最核心的几个概念:价值估计、策略改进、探索与利用的平衡、折扣回报。这些概念在深度强化学习里一个都没变,变的只是价值的表示方式——从表格换成神经网络。
具体来说,表格法里每个状态动作对一个独立的值,神经网络则是输入状态输出动作价值,用网络参数共享来泛化到没见过的状态。蒙特卡洛的更新目标,也就是实际回报,在深度方法里依然是目标,只是用梯度下降去拟合。你在这套代码里踩过的回报计算、epsilon 衰减、状态表示的坑,换成神经网络后依然会遇到,只是表现形式不同。
所以我的建议是,别急着跳过这一步。表格蒙特卡洛虽然简单,但它把强化学习的骨架暴露得最清楚。等你对每个环节的因果关系都有直觉了,再上深度方法,调参的时候才知道每个超参数在动什么。这套 CartPole 的代码量不大,但把该讲的逻辑都讲透了,你可以直接拿去改离散化方案、改 epsilon 节奏、改折扣因子,观察每个改动对训练曲线的影响,这种亲手实验得到的理解,比看多少篇教程都扎实。