刚开始接触强化学习那会儿,我的目标很简单:找一个现象足够直观、代码量不至于把人劝退、还能把“智能体怎么从环境里学会技能”完整走一遍的任务。CartPole平衡就是最好的练手对象。它只有4个连续状态、2个动作,规则很简单,但背后却藏着强化学习的核心骨架。我选择用Q学习而不是一上来就上DQN,是因为Q学习不需要神经网络、不需要GPU,一张Q表加几百行代码就能从头训练出一个能稳稳“立杆”的智能体,还能把贝尔曼更新、探索与利用、状态离散化这些概念一个一个亲手摸透。这篇文章就把我从理论推导到代码落地的完整过程写下来,写给想跨过“理论看得懂、代码写不出来”这道坎的人。
1. 项目概述与整体设计思路
1.1 为什么选择Q学习而不是直接上DQN
很多初学者看到CartPole的经典案例,第一反应是直接用深度Q网络(DQN)。但DQN里包含经验回放、目标网络、双Q更新等一系列复杂机制,这些机制当然有用,可它们也会把“强化学习最原始的更新逻辑”淹没掉。如果连Q表都没有亲手更新过一次,直接去调一个神经网络,遇到不收敛时根本分不清是哪个环节出了问题。
Q学习的优势在于极度直观:智能体维护一张表格,表格的每一个格子对应“某个离散状态下的某个动作”的价值估计,训练过程就是在不断修正这张表。它解决CartPole的思路可以浓缩成一句话:把连续世界切成小块,在每一块里试错,并把试错经验留在表格里。
我强烈建议每位准备入门强化学习的人都先从表格型Q学习走一遍完整流程。这种“过一遍”带来的收益不是学会某个算法,而是建立对“状态—动作—奖励—更新”这个闭环的肌肉记忆。之后再去学DQN、PPO、SAC,你会发现这些高级算法的核心组件都能在Q学习里找到影子。
1.2 整体设计思路:离散化加试错循环
CartPole的环境观测是连续的,但Q表要求离散的状态索引。所以设计的第一步是状态空间离散化。这一步很像把室内温度分成“冷、凉、舒适、热、滚烫”几个档位,虽然丢了精度,但换来了“可查找、可更新”的数据结构。
离散化之后,整个训练流程就是一个标准循环:
- 从环境拿到当前观测,离散化成状态索引。
- 根据ε-greedy策略选择动作(大概率选Q值最大的动作,小概率随机试)。
- 执行动作,环境返回下一个观测、奖励、是否结束。
- 把下一个观测离散化,用贝尔曼公式更新上一步状态下的Q值。
- 重复直到本轮结束,再开新一轮。
这个循环看起来简单,但每一个环节都有值得抠的细节。比如状态怎么切分、Q表维度怎么定义、探索率怎么衰减,这些参数直接影响最终能不能把杆子稳定住。后文我会把每个环节都展开说明,并给出可直接运行的代码。
1.3 整体代码结构总览
我最终的训练脚本可以拆成5个模块,每个模块职责单一:
create_bins():生成每个观测维度的离散化边界。discretize_state():把连续观测映射成离散索引。choose_action():基于ε-greedy策略返回动作。update_q_table():执行Q学习的核心更新公式。run_episode():跑完一个完整回合,返回累计奖励,并在过程中更新Q表。
这种结构让调试变得很舒服。比如想单独看某个状态下Q值怎么变化,直接在update_q_table()里打印对应状态索引就行,不必在几百行的训练循环里翻来翻去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境核心机制与状态动作空间拆解
2.1 CartPole的观测状态到底在说什么
CartPole的任务常被描述成“把杆子立起来”,但严格来说,它是通过左右移动底部小车来维持杆子不倒。环境返回的观测是一个4维向量,分别对应小车的水平位置、小车速度、杆与竖直方向的夹角、杆顶端的角速度。
这四个量的物理含义很直接:
- 小车位置:离轨道中心越远,就越接近掉落边界。
- 小车速度:速度太大时,即使马上反向加速,也可能刹不住冲出边界。
- 杆角度:角度超过正负12度左右就判定失败。
- 杆角速度:杆正在朝哪个方向倒、倒得多快,这是决定该往哪边推的关键信息。
下表是我整理的观测范围与终止边界,训练前最好先刻在脑子里:
| 观测维度 | 常用符号 | 观测范围 | 终止边界 | 主要影响 |
|---|---|---|---|---|
| 小车位置 | x | [-4.8, 4.8] | [-2.4, 2.4] | 位置越偏,越难回到中心 |
| 小车速度 | v | 无固定上限,通常取[-3, 3] | 本身不触发终止,但影响位置变化 | 速度过快会刹不住 |
| 杆角度 | θ | [-0.418, 0.418]弧度 | ±0.209弧度(约±12°) | 角度过大会直接失败 |
| 杆角速度 | ω | 无固定上限,通常取[-2, 2] | 本身不触发终止,但影响角度变化 | 角速度越大,越容易瞬间越界 |
这里有个关键点:观测范围并不等于终止边界。位置可以观察到±4.8,但一旦超过±2.4就会提前终止;角度可以观察到±0.418弧度,但超过±0.209弧度就判定失败。离散化的时候,边界不能完全照搬观测范围,必须根据“实际可能出现的稳定区间”来设计,否则很多分箱都用不上,白白浪费Q表容量。
2.2 动作、奖励与终止条件
CartPole的动作空间只有两个:向左推小车(动作0),向右推小车(动作1)。每一步只要杆子没倒、小车没出界,就获得奖励1。这个奖励设计得非常“吝啬”,因为它没有告诉智能体“怎么推更好”,只告诉它“你还活着”。恰恰是这种稀疏的正向反馈,迫使智能体通过长期试错找出能维持更久的动作序列。
终止条件有三个:
- 杆角度绝对值超过约12度
- 小车位置绝对值超过2.4
- 当前步数达到环境上限
环境上限在CartPole-v0是200步,在CartPole-v1是500步。这也带来了一个常见的认知误差:v1并不是“更难”,而是“上限更高”。因为智能体一旦学到优秀策略,v1能连续拿到500步的正奖励,而v0最多只能拿200步就会因为步数上限被截断。官方社区里常说的“solved条件”,v0是连续100回合平均奖励达到195以上,v1则是达到475以上。
需要注意,较新版本的Gym把“达到最大步数”从done拆成了truncated,只有杆倒了或出界才是terminated。训练时通常把两者取或,只要其中一个为真就结束本回合,因为继续在已经结束的轨迹上更新没有意义。
2.3 先让环境跑起来:基础交互代码
在我们写Q学习之前,先用几行代码确认环境接口能正常工作。这里要注意新老版本Gym的接口差异,老版本reset()直接返回观测,新版本返回(观测, 信息)二元组;老版本step()返回4元组,新版本返回5元组,多了一个truncated。
我在本机跑通环境的最小代码如下:
python复制import gym
import numpy as np
env = gym.make("CartPole-v1")
obs = env.reset()
if isinstance(obs, tuple):
obs = obs[0]
for step in range(20):
action = env.action_space.sample()
result = env.step(action)
# 新老版本兼容:统一解析为5个字段
if len(result) == 5:
obs, reward, terminated, truncated, info = result
done = terminated or truncated
else:
obs, reward, done, info = result
print(f"step={step}, obs={obs}, reward={reward}, done={done}")
if done:
break
env.close()
这段代码先随机动作跑20步,主要目的是确认环境能正常交互,顺便观察状态向量的大致数值范围。我第一次跑的时候发现角度和位置其实很少踩满观测上限,大多数情况下都集中在角度±0.3以内、位置±2以内,这为后面设计离散化边界提供了依据。
3. 从理论到代码:Q表更新与策略迭代
3.1 Q学习更新公式的直觉理解
Q学习的核心是想办法估计“在某个状态state采取某个动作action之后,未来能拿到的累计奖励期望”。这个值记为Q(s, a)。更新它的时候,我们不看完整的未来,只看下一步的即时奖励r,再加上对下一个状态最乐观动作的估计max_a' Q(s', a'),共同组成“时序差分目标”。
更新公式如下:
text复制Q(s, a) = Q(s, a) + alpha * (r + gamma * max_a' Q(s', a') - Q(s, a))
这里的alpha是学习率,决定新信息以多大幅度覆盖老信息;gamma是折扣因子,决定未来的奖励在当前看来值多少钱。括号里的r + gamma * max_a' Q(s', a')可以理解为“如果从这一步开始继续往前走,大约还能拿多少总奖励”,而这个总奖励与旧估计之间的差值,就是当前需要修正的误差。
生活化地理解:你原来估计某条路能拿到70分,走了一次之后发现实际拿到了80分,那么你的估计值就会往80方向靠近一点,至于靠近多少,由alpha决定。gamma则是在永无止境的任务里防止无限累加的工具,它让越远的奖励越不值钱,也让Q值保持有限。
3.2 ε-greedy探索策略与“探索利用”平衡
如果完全不随机,每次只选当前Q值最大的动作,那么一开始Q表全为0,智能体永远只会选索引靠前的动作,永远不会发现其他动作可能更好。这就是“利用过度、探索不足”。反过来,如果一直随机乱选,智能体就无法积累稳定的策略。
ε-greedy策略是个简单有效的折中:以概率ε随机选动作,以概率1-ε选择Q值最大的动作。训练前期ε可以很高,让智能体大胆试错;训练后期ε要降到很低,让智能体专心利用已经学到的经验。常见的做法是每回合结束后乘以一个衰减系数,同时设置一个下限,比如0.01。
我常用的探索率更新是:
python复制epsilon = max(epsilon_min, epsilon * epsilon_decay)
其中epsilon_decay通常取0.99到0.999之间。这个衰减速度要和总回合数匹配,衰减太慢会导致后期仍然频繁随机,衰减太快会导致前期还没探索充分就进入“只看眼前”的模式。
3.3 状态离散化的代码实现与Q表维度陷阱
离散化函数的核心是把每个连续值映射成一个整数索引。np.digitize是标准工具,它接收一个数值和一组边界点,返回这个数值落到哪个区间。因为CartPole有4个观测维度,所以要对每个维度分别做一次digitize。
这里有一个特别容易踩坑的地方:如果用np.linspace(-2.4, 2.4, num_bins)生成边界点,得到的是num_bins个分割点,np.digitize的输出范围是0到num_bins共num_bins+1个取值。如果Q表只分配num_bins个格子,就会出现索引越界。所以我推荐边界点直接用num_bins + 1个,让Q表维度与digitize输出范围严格一致。
下面这段代码是我最终采用的分箱方案:
python复制import numpy as np
def create_bins(num_bins):
return [
np.linspace(-2.4, 2.4, num_bins + 1), # 小车位置
np.linspace(-3.0, 3.0, num_bins + 1), # 小车速度
np.linspace(-0.5, 0.5, num_bins + 1), # 杆角度
np.linspace(-2.0, 2.0, num_bins + 1), # 杆角速度
]
def discretize_state(obs, bins):
idx = []
for value, boundary in zip(obs, bins):
idx.append(int(np.digitize(value, boundary)))
return tuple(idx)
边界范围的选择是有讲究的。位置固然可以到±4.8,但终止边界是±2.4,所以把位置边界设在±2.4已经足够;速度虽然理论上无上限,但根据我的采样观察,绝大多数情况速度都在±3以内;角度虽然观测能到±0.418弧度,但终止边界是±0.209弧度,为了给“快要倒但还没倒”的状态留出更多细分区域,我把角度边界扩到±0.5;角速度边界取了±2,能覆盖绝大多数场景。如果发现某维度频繁触发digitize返回0或最大值,说明边界偏小,需要扩大。
3.4 完整训练循环的实现
我把训练主体写成一个train函数,方便一键调参。代码如下:
python复制import gym
import numpy as np
from collections import deque
def choose_action(state, q_table, epsilon, env):
if np.random.random() < epsilon:
return env.action_space.sample()
return int(np.argmax(q_table[state]))
def update_q_table(q_table, state, action, reward, next_state, alpha, gamma):
best_next = np.max(q_table[next_state])
current = q_table[state][action]
q_table[state][action] = current + alpha * (reward + gamma * best_next - current)
def train_cartpole(env, q_table, bins, episodes=6000,
alpha=0.2, gamma=0.99,
epsilon_start=1.0, epsilon_min=0.01,
epsilon_decay=0.998):
scores = []
epsilon = epsilon_start
for episode in range(episodes):
obs = env.reset()
if isinstance(obs, tuple):
obs = obs[0]
state = discretize_state(obs, bins)
total_reward = 0
while True:
action = choose_action(state, q_table, epsilon, env)
result = env.step(action)
if len(result) == 5:
obs_next, reward, terminated, truncated, info = result
done = terminated or truncated
else:
obs_next, reward, done, info = result
next_state = discretize_state(obs_next, bins)
update_q_table(q_table, state, action, reward, next_state, alpha, gamma)
state = next_state
total_reward += reward
if done:
break
scores.append(total_reward)
epsilon = max(epsilon_min, epsilon * epsilon_decay)
if episode % 200 == 0:
recent = np.mean(scores[-100:]) if len(scores) >= 100 else np.mean(scores)
print(f"episode={episode}, avg_reward={recent:.2f}, epsilon={epsilon:.3f}")
return scores
if __name__ == "__main__":
env = gym.make("CartPole-v1")
num_bins = 20
bins = create_bins(num_bins)
q_shape = tuple([num_bins + 1] * 4 + [env.action_space.n])
q_table = np.zeros(q_shape, dtype=np.float32)
scores = train_cartpole(env, q_table, bins, episodes=6000)
env.close()
这段代码里有几个细节值得说明。q_shape用tuple拼出来,而不是直接写死维度,这样改分箱数时不用改Q表定义。epsilon_decay=0.998配合6000个回合,到后期探索率会降到非常低;如果想要更保守,可以用0.999。导航奖励我直接用环境的累计步数,因为在CartPole里每一步奖励都是1,total_reward就等于存活步数。
3.5 收敛判断与训练日志
训练过程中不能只看某个回合的分数,因为环境随机性很大,单回合成绩可能忽高忽低。我习惯每200个回合打印最近100个回合的平均分,同时打印当前探索率。当平均分稳定超过475,并且维持一段时间不回落,就说明Q表已经收敛到一个可用策略。
很多人的卡点在于“训练了2000回合分数还是100多”。这种时候不要急着加回合数,先检查Q表更新公式、探索率衰减和分箱边界三个地方,往往能找到问题。
4. 训练全程与关键参数调优
4.1 参数速查表与推荐初始值
Q学习虽然简单,但参数之间会互相影响。我把经验参数整理成了一张表,方便新手直接照抄,再根据自己训练曲线的表现去微调:
| 参数 | 推荐初始值 | 过低的影响 | 过高的影响 |
|---|---|---|---|
| num_bins | 20 | 状态分得太粗,学不到精细控制 | 状态分得太细,Q表大且更新稀疏 |
| alpha 学习率 | 0.2 | 收敛慢,需要更多回合 | Q值震荡,已学到的经验被覆盖 |
| gamma 折扣因子 | 0.99 | 智能体短视,只看眼前几步 | 在CartPole中影响不大,不建议超过0.99 |
| epsilon初始值 | 1.0 | 探索不足,容易陷入局部策略 | 随机动作太多,训练前期稳定但进展慢 |
| epsilon_min | 0.01 | 后期随机动作过多,策略不稳定 | 探索过早停止,可能错过更好策略 |
| epsilon_decay | 0.998 | 衰减太快,探索不充分 | 衰减太慢,后期仍然乱试 |
| episodes | 6000 | 训练不够,Q表未完全收敛 | 训练时间增加,收益有限 |
4.2 分箱数到底选多少
分箱数是表格型Q学习最重要的超参数。CartPole状态是4维,如果每一维分成10份,Q表就有10的4次方乘以2个动作,也就是20000个格子;如果每一维分成30份,就是30的4次方乘以2,约162万个格子。Q表越大并不意味着越好,因为每个格子都需要一定量的样本来更新,状态空间太稀疏会导致很多格子从来没有被访问过。
我在实际实验中发现,分箱数在15到25之间都能取得不错的效果。少于10时,杆子角度被切得太粗,智能体很难区分“正在慢慢倒”和“快要倒了”的区别,控制会很粗糙;多于30时,训练回合数需要大幅增加,否则大量格子始终是0,策略会和随机差不多。
一个比较稳妥的路径是:先用20箱跑通基线,降低到15箱看分数是否下降,如果下降说明状态细节很重要,再抬回20箱;如果15箱和20箱表现接近,说明这个任务不苛求太细的划分,可以少用一些格子来提升训练速度。
4.3 学习率与折扣因子的配合
学习率alpha决定Q值向目标靠近的步长。在CartPole这种奖励信号非常稳定的环境里,0.2到0.5都能正常工作。如果设成1.0,Q值会完全覆盖旧估计,训练过程会很“健忘”,明明上一轮学到的好策略,这一轮一个意外失败就能把Q值改掉;反过来如果设成0.01,更新太慢,训练5000回合可能还没把关键状态的值修正到位。
折扣因子gamma在CartPole里一般不需要做太多文章。因为每步奖励恒为1,且回合长度有限,gamma=0.99已经让未来300步内的奖励有较高权重。gamma=1.0在这个环境里也不会发散(因为有最大步数限制),但它会降低对“早一步失衡”的惩罚敏感度,所以我更推荐0.99。
4.4 探索率衰减的具体调整思路
探索率衰减是很多人忽略的细节。如果训练前期的探索不足,智能体可能一直重复同一个动作,甚至完全学不到“反向推杆”这种关键救命动作;如果探索率衰减过快,结果同样糟糕。我用一个简单比喻:前期要多到处逛街,搞清楚哪里有宝藏;后期要直奔最优路线,不再浪费时间到处乱逛。
线性衰减比指数衰减更容易控制节奏。比如epsilon_start=1.0,总回合数6000,想要到第5000回合左右降到0.01,那么每回合衰减量是(1.0 - 0.01) / 5000 ≈ 0.000198。每次更新用:
python复制epsilon = max(epsilon_min, epsilon - (epsilon_start - epsilon_min) / total_episodes)
这个方式的优点是不会像指数衰减那样前期降得太快、后期又拖沓。我自己的习惯是先用指数衰减跑一遍,如果发现训练中期分数迟迟上不去,就换成线性衰减,往往很快有改善。
4.5 训练过程中的典型分数轨迹
很多人第一次跑Q学习,期望看到“一条平滑上升的曲线”,但实际训练曲线其实更像阶梯状。我观察到的典型过程是:前500到1000回合分数在几十上下挣扎,偶尔能冲到200;1000回合以后某个关键状态被修正,分数突然跳到400多;之后又会反复掉下来,但掉下来的幅度逐渐减小,最终稳定在500附近。
这个阶梯式跳跃源自Q表的更新方式。当某个核心状态的Q值被修正后,策略会立刻变好,因为它直接影响后续一连串的决策;而其他次要状态还没来得及更新,所以又会偶尔犯错。理解了这一点,你看到曲线中间大涨大跌就不会慌张,只要滑动平均趋势在抬升,就说明学习在起作用。
5. 踩坑实录与常见问题速查
5.1 常见问题症状与排查方向
我把自己和其他人常遇到的问题整理成了一张速查表,训练不顺利时可以先对着表快速定位:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 运行时报索引越界 | Q表维度与离散化输出范围不一致 | 检查bins长度与Q表shape是否匹配 |
| 训练很久分数还在50以下 | 离散化边界太小,大量状态被压到同一格 | 打印状态分布,扩大边界 |
| 分数卡在200左右上不去 | 探索率衰减过快,后期不再尝试新动作 | 调慢epsilon_decay,改用线性衰减 |
| 分数时高时低、波动大 | 探索率下限太高,或alpha太大 | 降低epsilon_min,减小alpha |
| 几乎每次都在前几步失败 | 动作方向理解反了,或角度状态离散化过粗 | 打印每次动作前后角度变化 |
| 训练收敛后测试表现差 | 评估时没有关闭探索,随机动作干扰策略 | 测试时设置epsilon=0 |
| 无法渲染画面 | Gym版本接口差异或缺少图形环境 | 检查render参数,服务器上不做渲染 |
5.2 索引越界问题深挖
索引越界是我见过出现频率最高的运行错误。它通常来自对np.linspace和np.digitize配合关系的误解。np.digitize(x, bins)返回的是“x落在bins的哪个间隔”,如果bins有N个元素,返回值可能是0到N共N+1种。最规范的做法是让Q表的每个维度大小等于bins长度,而不是“分箱数”。
比如num_bins=20,我用np.linspace(-2.4, 2.4, num_bins + 1)生成21个边界点,这样digitize输出范围是0到20,Q表shape每一维设为21,再乘上动作数2。这个对应关系只要写一次,以后就不会再犯。如果你接手了别人的代码,他可能用num_bins个边界点,这时Q表要分配num_bins+1个格子,别看代码里写的num_bins就以为是全貌。
5.3 训练不收敛时的排查顺序
如果分数一直不涨,我建议按下面的顺序排查:
- 打印几个回合的状态序列,看状态值是否在合理范围内,有没有大量重复的离散化索引。
- 检查Q表更新公式里的
reward + gamma * np.max(q_table[next_state]),看目标值是否合理。 - 临时把
epsilon固定为0.05,排除探索过大的干扰。 - 把
alpha调大到0.5,看分数是否有变化,如果变化很明显,说明之前是学习率太低。 - 用测试模式(
epsilon=0)跑100回合,如果测试分数远高于训练分数,说明训练时的随机动作拖后腿,问题出在探索率衰减策略上。
我遇到过一种比较隐蔽的情况:分箱边界选得太宽,导致小车位置和角度都被压到少数几个索引里,Q表实际上退化成了一张很小的表,怎么调参数都学不好。解决方法是先用统计方式打印出每个离散化索引的出现频率,如果大量状态集中到某一个索引,就需要缩小边界或者增加分箱数。
5.4 版本差异与随机种子的坑
新老版本Gym的接口差异很容易让代码跑不通。老版本中step()返回四个值,而新版本返回五个值,其中第五个是truncated。如果直接写obs, reward, done, info = env.step(action),新版本就会因为解包数量不对而报错。我在代码里用了len(result)判断的方式做兼容,这样在两种环境下都能跑。
另一个关乎复现的问题是随机种子。CartPole的初始状态和动作随机性由环境内部的随机数生成器控制。想要复现实验结果,可以在env.reset(seed=某数字)时固定种子,并在choose_action里使用np.random.default_rng(seed)而不是全局np.random。我实际比较过,固定种子后同一组参数的结果基本稳定,这能让调参对比更公平。
5.5 一个真实的排坑经历
有次我用20箱训练到4000回合,平均分一直卡在180左右。从打印的状态看,小车位置已经能在中心附近维持很久,但角度偶尔突然大幅增大,随后直接失败。我一开始怀疑是gamma太小,后来把角度分箱边界从±0.5改到±0.6,同时把速度边界从±3改到±4,问题很快就消失了。原因是原始边界太小,样本中偶尔会出现超出边界的速度,digitize把过大的速度全部归到同一个索引,导致智能体无法区分“速度是3还是5”。
这次经历给我最大的教训是:离散化边界一定要根据真实采样数据来定,不要想当然地套用“观测范围”。观测范围只代表理论上不报错的范围,不代表智能体实际会经常遇到的范围。
6. 进阶方向与扩展思路
6.1 从表格型Q学习到DQN的思维跨越
表格型Q学习能在CartPole上取得成功,根本原因是状态被离散化后数量还算可控。如果把同样的思路套到更复杂的任务上,比如自动驾驶、机器人操作,状态连续且高维,Q表根本不可能存得下。于是需要用神经网络来替代Q表,输入状态,输出每个动作的Q值估计,这就是DQN的基本形态。
DQN不是不用Q学习的更新公式,而是把“查表更新”换成了“网络参数更新”。它面临的困难在于,相邻状态的特征很相似,直接用网络做Q值回传很容易导致训练不稳定,所以需要经验回放、目标网络这些配套机制。把Q学习代码跑通之后,你再去理解DQN时会发现,它的核心还是那句老话:用当前估计与目标之间的差值来修正策略。
6.2 Sarsa与Q学习的差别
和Q学习经常一起出现的还有Sarsa算法。两者最大的差异在于更新目标里的下一步动作:Q学习用的是max_a' Q(s', a'),也就是假设下一步会用当前最优策略;Sarsa用的是实际选择的动作a'对应的Q值。因此Q学习属于离策略方法,它学习的策略可以比当前行为策略更激进,Sarsa属于在策略方法,更新严格依赖当前行为策略。
在CartPole上,Q学习通常比Sarsa更高效,因为它在更新时已经“脑补”了最优后续动作。而Sarsa会更保守,在探索的过程中不太会高估Q值,所以它在某些有随机风险的环境里反而更稳。作为入门,建议两种算法都实现一遍,对比它们的分数曲线,这比单纯背概念更能理解离策略和在策略的本质差异。
6.3 把训练成果用起来
Q学习训练完成后,得到的Q表是一个NumPy数组,可以保存成文件,之后直接加载使用。测试的时候把探索率设为0,智能体会选择Q值最大的动作,这时它表现得像一个控制器。我通常会把最终策略跑100个回合,记录平均分和最大连续稳定步数,这个指标比训练过程的分数更有说服力。
如果想把智能体的行为可视化,可以在本地使用env.render(mode="human")观看实时动画,也可以把每一帧保存下来做成GIF。但要注意,在无图形界面的服务器上直接调用渲染会报错,需要切换到matplotlib手动绘制或使用环境自带的录像包装器。我个人的建议是先用命令行日志确认训练收敛,再本地渲染观察,能省下很多调试时间。
一些最后的小建议
我自己的习惯是先把Q学习完整跑通一遍,再回头去读理论推导,这时候再看马尔可夫决策过程、贝尔曼最优性方程都会觉得非常自然。CartPole最大的价值不在于解决一个具体的平衡问题,而在于用最少的工程复杂度把强化学习的整个心智模型建立起来。
如果你也想动手复现,最后分享一个小技巧:训练时除了看平均分,还可以额外记录“最近连续多少个回合达到500分”,这个指标比平均分更直接。平均分容易被极端低分拉低,而“连续满分回合数”能直观地告诉你策略的稳定性。等到连续几十个回合都能存活满500步,再保存Q表,这个模型就真正扎实了。
