CartPole平衡问题在强化学习圈子里被叫了很多年“Hello World”,我当年第一次上手跑这个环境时,心里想的是:不就一根杆子在轨道上立着嘛,动作只有左推和右推,奖励每步加一,这要是学不会也太说不过去了。结果用Q学习硬啃之后才发现,这个环境的观测是四维连续量,Q学习偏偏又是个依赖表格的算法,两者一碰上,“连续状态怎么塞进Q表”就成了第一道坎。这篇我会从Q学习的更新逻辑讲起,再结合我实际跑了4000个回合的训练记录,把状态离散化、训练循环、评估方法和调参陷阱一次说清楚,适合刚学完Q学习基础、正准备动手写第一个强化学习项目的读者,已经跑过DQN想回头补理论根基的同学也能用得上。
1. 为什么CartPole看起来简单,表格型Q学习却容易卡壳
1.1 任务目标与四个连续状态量
CartPole-v1的物理场景很简单:一个小车在一条无限长的水平轨道上,车上竖直插着一根杆子,杆子会受重力影响倒下。智能体每个时间步做一次决策,只有两个动作可选——向左推小车、向右推小车。环境返回的观测却是一个四元组,分别代表小车位置(单位米)、小车速度(米/秒)、杆子角度(弧度)、杆子角速度(弧度/秒),这四个量默认都是浮点数,也就是连续值。
任务目标是在尽量长的步数内保持杆子不倒。奖励设计得非常朴素:每存活一个时间步,奖励加1。环境本身设了上限,CartPole-v1默认每回合最多500步。一旦杆子与竖直方向的夹角超过约12度(0.2095弧度),或者小车位置偏离中心超过2.4米,当前回合立即终止。很多教程把“奖励越大越好”一句话带过,但实际操作中要意识到:这里的奖励是密集且恒定的,它不会告诉你怎么推更好,只告诉你“现在还活着”,真正有用的反馈其实藏在终止条件里——如果你做错了,回合结束,Q表才能知道之前的动作有问题。
1.2 连续状态空间与Q表的维度灾难
Q学习的基本思路是维护一个Q表,表中每个“状态-动作”对存一个数值,代表在这个状态执行这个动作能带来的期望累计奖励,然后通过反复试错更新这些数值。问题在于,CartPole返回的状态是四个连续浮点数,连续意味着潜在状态有无穷多个,你不可能为每个小数组合都开一行表格。
常见的折中办法是离散化,把每个维度切成若干段,每段叫一个“桶”。这里立刻出现一个矛盾:桶切得太粗,四个连续量被压缩成非常粗的粒度,环境里两个差别很大的状态会被丢进同一个桶,Q表无法区分“快倒了还是刚回正”,学出来的策略自然很糙;桶切得太细,表格规模会爆炸。四个维度每个切10份,组合就是10的4次方,一万个状态,乘以2个动作是两万个Q值,numpy数组存起来没问题,但这还只是勉强能用的粒度;每个维度切到20份,状态数直接跳到16万。真正让新手难受的不是内存,而是训练回合数——状态空间越大,每个状态被访问的次数就越少,Q值更新得就越慢,而CartPole每一步的终止条件又很苛刻,随机策略下绝大多数回合只能活二三十步,很多状态根本不会被探索到。
这也是后来深度强化学习能火起来的重要原因:神经网络天生就是个函数近似器,输入连续状态,输出动作Q值的估计值,不需要为每个离散组合单独开槽位。理解了Q表在这个环境上的局限,你才会明白DQN这类算法到底在解决什么问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Q学习的核心更新逻辑:为什么它最终能逼近最优策略
2.1 从Q值定义到贝尔曼方程
别急着写代码,先弄清楚Q学习在更新什么。Q值定义很简短:在状态s下执行动作a,之后一直按照最优策略行动,所能获得的期望累计折扣奖励,写作Q*(s,a)。这里的“折扣”是强化学习里非常重要的机制,用折扣因子γ表示未来的奖励在今天的价值。γ取0.99,意味着下一时刻的1点奖励现在值0.99,20步之后的1点奖励只值约0.817,越远越打折。
贝尔曼方程说的是:最优Q值可以写成当前奖励加上折扣后的未来最优Q值。公式形式是
Q*(s,a) = r + γ * max_{a'} Q*(s',a')
其中s'是执行a后到达的下一个状态。这个公式看起来像“自己定义自己”,但正是这种递归关系给了迭代算法突破口:如果初始Q值都设为0,然后用实际环境送来的样本不断逼近这个等式,Q值就会一步一步向真实值收敛。这也是为什么Q学习被称为时序差分方法的核心——它只比较“当前预测”和“一步真实奖励加下一状态预测”的差距,不需要等回合结束才知道对错。
2.2 更新公式逐项拆解
Q学习的单步更新公式是:
Q(s,a) ← Q(s,a) + α [ r + γ * max_{a'} Q(s',a') - Q(s,a) ]
方括号里的部分叫“时序差分误差”,它的含义是“实际走了一步之后,我的预测离理想预测差多少”。先用旧Q值对当前状态动作对做一个预测,走完一步环境,拿到真实奖励r和下一个状态s',再用s'下所有动作Q值的最大值作为“未来最优价值”的估计,于是目标值就是r + γ * max Q(s',a')。把旧预测向这个目标逼近一小步,步子大小由学习率α决定。
这里容易有个误解:为什么下一状态要取max,而不是取某个固定动作的Q值?因为Q学习的终极目标是找到最优策略,最优策略在状态s'下当然会选择能让Q值最大的那个动作。如果用平均值或者随机动作的Q值来更新,学出来的东西是某个平均策略的价值,而不是最优策略的价值。max算子正是“优化”意图在公式里的体现。
2.3 ε-greedy探索策略与超参数旋钮
光有更新公式还不能训练,因为智能体一开始全不知道哪个动作好,如果每次都选当前Q值最大的动作,就会永远踩在错误判断上,根本不会去尝试另一个方向。标准解法是ε-greedy:以概率ε随机选动作,以概率1-ε选当前Q值最大的动作。训练前期ε设成接近1,让智能体到处乱试,慢慢把Q表摸熟。训练后期要降低ε,逐渐转向“利用学到的经验”。
我这次训练用的探索率从1.0开始,每回合乘以0.995的衰减系数,最低压到0.02。算一下就知道,前300回合探索率会降到0.22左右,前800回合就接近0.02了,后面几千回合基本都在纯利用阶段。这个衰减速度偏快,如果你希望智能体更充分探索,可以把衰减系数改成0.998,这样到1500回合才降到0.05附近。探索策略没有绝对正确,它和状态离散化的粗细、学习率的选择都有关系,需要照着训练曲线一起调。
α和γ是另外两个关键旋钮。α太大,单步更新步子太猛,Q值会来回震荡;α太小,学得慢,几千回合可能都不够。γ的取值在CartPole这类短回合任务里,0.95到0.99都合理,但记住γ=0.99时Q值的理论上界是1除以(1-0.99),也就是100,如果你发现Q表里冒出几百上千的数,基本可以断定更新公式或者奖励写错了。
3. 状态离散化:如何把连续观测塞进Q表
3.1 分箱设计与每维的切分依据
离散化是整个Q学习落地CartPole最需要动脑的部分。我最终采用的分箱方案如下表所示:
| 状态维度 | 归一化范围 | 桶数 | 切分依据 |
|---|---|---|---|
| 小车位置x | [-2.4, 2.4] | 10 | 位置越界即终止,用环境判定边界 |
| 小车速度v | [-3.0, 3.0] | 10 | 速度无显式边界,实测极值约±2.5 |
| 杆子角度θ | [-0.5, 0.5] | 20 | 角度是平衡核心,需要更细粒度 |
| 角速度ω | [-3.0, 3.0] | 10 | 无显式边界,实测极值约±3 |
角度之所以要切成20个桶,是因为杆子倒不下来意味着策略必须对角度变化非常敏感。杆子角度超过约0.209弧度(12度)就终止,而范围取到±0.5弧度,相当于把有效区域附近分得很细,让Q表能感知到“角度稍有偏移”这个关键差异。位置、速度、角速度各切10份,是因为它们对维持平衡的影响相对间接,只需要知道一个大致区间就够。这个比例不是唯一答案,但你亲手调的时侯会体会到:角度桶数太少,训练曲线很难上去;角度桶数超过20,状态数量翻倍也会拖慢收敛。
相邻维度的组合效应值得展开说一下。四个维度独立切分,造成Q表在空间上的分布非常稀疏,比如小车位置在边缘、角度又很大的状态很少访问,这些格子长期保持0,即使训练几千回合也未必被照亮。一个直观感受是:如果把Q表所有非零项打印出来,会发现大量状态从未被触及。这不是训练失败,而是表格方法的固有浪费,只能靠足够多回合和合理的探索率来缓解。
3.2 边界溢出的坑:clip比你想的重要
CartPole的观测空间里,速度和角速度并没有真正的物理边界。虽然官方文档写了“无穷大”,但实际训练中速度很少超过±3,角速度也很少超过±3。可是如果你把离散化范围设成±3,就一定会遇到少数样本刚好落在边界外。很多新手在这里踩到IndexError,报错信息指向np.digitize的返回索引越界,原因就是没有做边界钳制。
正确的做法是对观测值做np.clip,强行把超过范围的样本压到边界上,然后转换成桶索引。我用的切割方式是np.linspace生成11个边界点,形成10个区间,再配合np.digitize和减1、clip,保证索引永远落在0到桶数减1之间。这个处理看似微不足道,实际上决定了训练会不会在中途崩掉。另一个容易忽略的点是:如果你发现训练过程中有些回合观测最小值一直卡在边界值上,说明你的范围定窄了,要适当调宽,否则大量状态都被错误地压到同一桶里,等于白白丢失信息量。
4. 从零开始的Q学习训练器:完整代码与拆解
4.1 环境搭建与离散化函数实现
正式进入代码环节。我用gymnasium库加载CartPole-v1环境,这是目前最主流也最干净的做法。先把依赖装好:
bash复制pip install gymnasium numpy
然后是离散化函数。这里我直接用一个5维numpy数组作为Q表,前4维对应四个状态分箱索引,最后一维对应两个动作:
python复制import gymnasium as gym
import numpy as np
env = gym.make("CartPole-v1")
BINS = [
np.linspace(-2.4, 2.4, 11), # 位置x 10个桶
np.linspace(-3.0, 3.0, 11), # 速度v 10个桶
np.linspace(-0.5, 0.5, 21), # 角度θ 20个桶
np.linspace(-3.0, 3.0, 11), # 角速度ω 10个桶
]
def discretize(obs):
idx = []
for i in range(4):
# 先把连续值压到边界内,防止越界
clipped = float(np.clip(obs[i], BINS[i][0], BINS[i][-1]))
# digitize返回区间右边界索引,减1后得到0开头的桶号
bucket = np.digitize(clipped, BINS[i]) - 1
bucket = int(np.clip(bucket, 0, len(BINS[i]) - 2))
idx.append(bucket)
return tuple(idx)
q_table = np.zeros((10, 10, 20, 10, 2))
这段代码里有几个细节值得解释。np.digitize返回的是“值落在第几个右边界之后”,比如落在第一个区间会返回1,所以减1变成0。最后再clip一次是双保险,确保即使边界条件出问题,索引也绝对合法。Q表用np.zeros初始化,表示初始时智能体认为所有动作的价值都是0,然后通过探索慢慢把真实价值写进去。
4.2 选择动作与训练主循环
选择动作的函数负责实现ε-greedy策略。注意利用阶段用np.argmax取出Q值最大的动作索引,这个索引天然对应0或1,正好是环境需要的动作:
python复制def select_action(state, epsilon):
if np.random.random() < epsilon:
return int(np.random.choice([0, 1]))
return int(np.argmax(q_table[state]))
训练主循环我放在下面。这里有一个非常重要的细节:环境返回的done被拆成了terminated和truncated两个标志。terminated表示智能体真正失败了,比如杆子倒了;truncated表示回合被截断,比如达到了500步上限。这两个标志必须分开处理,否则学习效果会受影响。
python复制ALPHA = 0.15
GAMMA = 0.99
EPSILON_START = 1.0
EPSILON_MIN = 0.02
EPSILON_DECAY = 0.995
EPISODES = 4000
epsilon = EPSILON_START
total_rewards = []
for episode in range(EPISODES):
obs, _ = env.reset()
state = discretize(obs)
ep_reward = 0
terminated, truncated = False, False
while not (terminated or truncated):
action = select_action(state, epsilon)
next_obs, reward, terminated, truncated, _ = env.step(action)
next_state = discretize(next_obs)
ep_reward += reward
current_q = q_table[state][action]
if terminated:
# 真正失败:没有未来,目标就是当前奖励
target = reward
else:
# 未失败(包括被截断):按贝尔曼方程计算完整目标
best_next_q = np.max(q_table[next_state])
target = reward + GAMMA * best_next_q
q_table[state][action] += ALPHA * (target - current_q)
state = next_state
total_rewards.append(ep_reward)
epsilon = max(EPSILON_MIN, epsilon * EPSILON_DECAY)
if (episode + 1) % 100 == 0:
avg = np.mean(total_rewards[-100:])
print(f"Episode {episode + 1}, avg(100) = {avg:.1f}, epsilon = {epsilon:.3f}")
这里我选择在truncated时也执行普通贝尔曼更新,而不是把它当成终止。原因是:达到500步上限说明智能体已经平衡得非常好,这个状态不是失败状态,如果此时把target直接设成reward,会低估“接近500步状态”的价值,让它在未来被策略绕开。对于CartPole这种截断状态很少到达的任务,这个差别影响不算大,但养成区分两个标志的习惯,对以后换到更复杂环境很重要。
4.3 评估函数与学习曲线的正确读法
训练循环里输出的滑动平均可以看个大概,但最终评估要用纯贪心策略跑固定回合数。注意评估时不探索,ε固定为0,这样测的是“智能体真正学会的策略”,而不是训练过程中带随机性的表现:
python复制def evaluate(episodes=30):
rewards = []
for _ in range(episodes):
obs, _ = env.reset()
state = discretize(obs)
ep_reward = 0
terminated, truncated = False, False
while not (terminated or truncated):
action = int(np.argmax(q_table[state])) # 纯贪心
obs, reward, terminated, truncated, _ = env.step(action)
state = discretize(obs)
ep_reward += reward
rewards.append(ep_reward)
avg = np.mean(rewards)
print(f"Evaluation over {episodes} episodes: avg reward = {avg:.1f}")
return avg
学习曲线只关注单回合reward会非常痛苦,因为CartPole的随机性很强,相邻两个回合可能一个是300分一个是30分,曲线锯齿大得让你误以为训练没效果。我建议只看100回合滑动平均。我这次训练的实际表现是:前几百回合平均分在二三十徘徊,大约500回合后开始有明显上升,1500回合左右平均突破100,到4000回合时平均维持在200上下,偶尔能跑到300以上。这个成绩谈不上惊艳,但对Q学习表格方法来说已经代表“基本学会了怎么保持平衡”,而且你会发现不同随机种子之间方差很大——同一套超参,换个seed可能1500回合就过200,也可能到5000回合还在150附近挣扎。
5. 我实际踩过的四个坑:排查思路与修复方案
5.1 状态越界导致的IndexError
第一次跑完整的训练循环,大概在几百回合时突然抛IndexError,我当时第一反应是“离散化写错了”。排查办法是先打印一个回合内每一步的观测值、桶索引和Q表形状,逐项对比。最后发现是角速度偶尔会冲到3.1左右,超出我设的±3范围,没有clip之前np.digitize返回了11,减1之后是10,显然越界了。修复就是我在discretize里加的那道clip双保险。
这里要分享一个排查的思路:遇到索引越界,不要首先怀疑Q表维度,而是先检查所有从环境读取的数值是否都在你预设的合法区间内。你可以临时在训练循环里print每次obs的min和max,观察有没有持续卡在边界上的维度。卡在边界说明范围设窄了,分布太偏向某一边说明坐标系理解有误。
5.2 terminated与truncated混用带来的隐性偏差
新手照着老教程写代码时,很容易把step返回的两个布尔值合并成一个done再用,这两种情况对于训练的意义完全不同。我专门做过对比实验:把truncated也当成终止更新,训练后期平均分会被压低,因为接近500步状态的价值被系统性低估。原因是:当回合由于步数上限被截断,智能体其实“活得好好的”,把目标直接设成reward等于告诉它“这个状态未来没有价值”,这明显与事实不符。
正确做法就是我在训练循环里写的分支处理。但在项目早期,很多人可能根本不会遇到500步截断,因为Q学习很难把杆子立满500步,这个坑会在你后期调参获得不错成绩后才突然冒出来。如果你在训练曲线后期发现成绩突然下降,或者长期卡在某个值上不去,可以考虑是不是这个原因。
5.3 探索率衰减策略与Q值异常的联动
探索率衰减太快是我遇到的最隐蔽问题之一。一开始我把衰减系数设成0.99,结果到第400回合ε已经降到0.018附近,而这时候智能体还没摸清状态分布,过早进入纯利用状态,Q表里大量高价值格子没被充分探索,后期怎么调α都补不回来。把衰减系数改到0.995之后,前800回合慢慢从探索过渡到利用,成绩才稳定下来。你可以这样诊断:训练结束前打印“最终ε值”和“训练中前期平均分”,如果前期平均分一直很低但后期上不去,大概率是探索窗口太短。
Q值异常则是另一个方向的问题。如果你打印Q表,发现某些动作的Q值超过100,先别急着调参,检查更新公式里是不是把max写成了带探索的随机选择,或者误用了mean。理论上γ=0.99、每步奖励最大为1,Q值上界就是100,超过就必然有bug。顺带说一句,观测这种异常时不要在训练中段下结论,Q表初期因为探索随机性出现个别高值是正常的,重点看几千回合后的整体分布。
5.4 学习率与分箱粒度不匹配
α=0.15这个值配上20桶的角度分箱,在我的环境里表现不错,但你如果改了离散化方案,α很可能需要跟着变。角度桶数增加,相当于Q表更细,每个状态的访问频次下降,这时候α太小会导致更新太慢,几个回合才反映一次,训练曲线看起来像心电图;α太大又会在细节状态上震荡,出现“学会一点立刻忘掉一点”的来回摆动。我的建议是先用默认的0.1到0.2跑1000回合,观察滑动平均曲线是平滑上升还是剧烈抖动,再决定往哪个方向调。如果抖动幅度超过30%,降低α比调其他任何参数都有效。
6. Q学习在CartPole上的能力边界与下一步扩展
6.1 表格方法在这类连续控制任务上能做到什么程度
训练了4000回合之后,我对Q学习在这类连续控制任务上的定位有了更清醒的判断。它能让你亲手理解强化学习最核心的“试错-反馈-更新”闭环,也能在CartPole上跑出不错的平衡效果,但这套方法天然有天花板:分箱粒度无论多细,状态之间的独立性都是割裂的,学不到“角度稍微偏右一点,速度也要对应改变”这种连续平滑的策略关系。想要稳定跑满500步,表格方法通常很吃力,偶尔能靠运气和超参拿到一次满分,但难以稳定复现。
所以我把评估预期放在一个更客观的位置:纯贪心策略平均达到200以上,已经可以认定这个实现是成功的。如果你追求500分,方向不是继续调Q表,而是换函数近似方法,比如DQN用神经网络把连续状态直接映射到Q值,或者在分箱基础上做状态聚合。目标不是证明Q学习天下第一,而是通过亲手跑通,把TD error、探索、折扣因子这些抽象概念变成肌肉记忆。
6.2 从CartPole出发可以走的几条扩展路线
如果读完这篇你想继续深入,我推荐按这个顺序试:先用相同思路把代码改到另外一个经典环境试试,比如LunarLander,那个环境的奖励更稀疏、终止条件更苛刻,能让你立刻体会到表格方法的局限;然后学习DQN,重点观察神经网络是如何替代Q表的,这会让你更珍惜在CartPole表格实现里建立的直觉;再往后可以看看Double DQN,它专门解决Q学习max算子带来的高估问题——你在第2章理解的公式,正是理解这个问题的第一块砖。
我个人的实操体会是:训练强化学习模型,大多数时间不是在写模型,而是在看曲线、调超参、排查边界。这篇里所有参数都基于我本地训练的实际结果,但你的随机种子、gymnasium版本、机器性能都会带来偏差。我的建议是,拿到代码之后先原样跑通1000回合,记录下第一版曲线,再参数逐个调。不要同时改三个以上的变量,否则出了效果你也不知道是哪个参数起了作用。第一版可能很丑,但那是你理解这个算法最好的起点。
