先搞懂Q-Learning的核心逻辑:像学骑车一样“记教训”
我们可以把Q-Learning的智能体比作一个学骑车的小孩——
– 状态(State):比如“车把歪了”“要撞树了”;
– 动作(Action):比如“往左掰车把”“刹车”;
– 奖励(Reward):比如“没摔倒(+1)”“撞到树(-10)”;
– Q值(Q-Value):小孩脑子里的“经验笔记”——“在‘车把歪了’的状态下,‘往左掰车把’能得到多少好处?”

Q-Learning的本质,就是让智能体通过试错不断更新这份“经验笔记”,最终学会在每个状态下选最“划算”的动作。
举个更具体的例子:假设你训练一个智能体玩“走迷宫”——
– 状态是“迷宫中的位置(比如A/B/C点)”;
– 动作是“上下左右走”;
– 奖励是“走到出口(+100)”“撞到墙(-10)”“走一步(-1)”;
– Q值就是“在A点走右,能拿到多少总奖励?”
智能体一开始像个路痴,乱走乱撞,但每走一步都会更新自己的“Q笔记”——比如在A点走右撞到墙(-10),它就会把“A点→右”的Q值调低;如果走右能到B点,再走B点→上到出口(+100),它就会把“A点→右”的Q值调高(因为后续能拿到大奖励)。
Q表:智能体的“经验备忘录”
Q值不是零散的,而是被整理成一张Q表(Q-Table)——行是状态,列是动作,单元格里的数值就是对应状态-动作对的Q值。
比如“红绿灯路口”的简化Q表示例:
状态(s) | 动作:走(a1) | 动作:等(a2) |
---|---|---|
红灯 | -10(闯红灯扣分) | 5(遵守规则奖) |
绿灯 | 10(通行奖) | -5(等红灯浪费时间) |
智能体做决策时,只要看当前状态对应的行,选Q值最大的动作就行——比如红灯时,“等”的Q值(5)比“走”(-10)大,所以选“等”。
注意:Q表的大小由状态数×动作数决定。如果状态是连续的(比如CartPole的“小车位置”是-2.4到2.4之间的任意数),需要先把状态离散化(比如分成10个区间),否则Q表会无穷大(后面代码部分会详细讲)。
贝尔曼方程:Q值更新的“数学发动机”
Q表不是天生的,而是通过贝尔曼方程(Bellman Equation)不断迭代更新的。这个方程看起来有点抽象,但拆开讲其实很简单:
Q(s,a) = Q(s,a) + α × [ R + γ × maxQ(s’,a’) – Q(s,a) ]
我们逐个拆解每个符号的含义,再用“学骑车”的例子翻译一遍:
– Q(s,a):当前状态s下做动作a的旧Q值(比如“车把歪了→往左掰”的旧经验);
– α(学习率):“更新幅度”——比如α=0.1,意味着只把旧Q值的10%换成新经验;
– R:当前动作带来的即时奖励(比如“往左掰车把没摔倒,得+1”);
– γ(折扣因子):“未来奖励的权重”——比如γ=0.95,意味着“1步后的奖励”只值当前的95%,“2步后”是95%×95%=90.25%(毕竟未来的奖励不一定能拿到,比如骑车时下一步可能撞树);
– maxQ(s’,a’):下一个状态s’下,所有动作中最大的Q值(比如“往左掰车把后,车把正了,这时做什么动作能拿到最大奖励?”);
– [R + γ×maxQ(s’,a’) – Q(s,a)]:TD误差(时序差分误差)——旧经验和新经验的差距(比如旧经验认为“车把歪了→往左掰”能拿+0.5,但实际拿到了“即时+1 + 未来+0.95×1”=1.95,差距就是1.95-0.5=1.45)。
翻译成人话就是:“我之前以为做这个动作能拿这么多好处,现在实际试了下,好处比我想的多(或少),那我把经验更新一点,往实际情况靠。”
举个数值例子:
假设旧Q(s,a)=0.5,α=0.1,R=1,γ=0.95,maxQ(s’,a’)=1(下一个状态的最优Q值),那么:
Q(s,a) = 0.5 + 0.1×[1 + 0.95×1 – 0.5] = 0.5 + 0.1×(1.45) = 0.645
也就是说,“车把歪了→往左掰”的Q值从0.5涨到了0.645——智能体记住了“这个动作更划算”。
手把手写Q-Learning代码:用OpenAI Gym玩CartPole
理论讲再多,不如写一行代码。我们用OpenAI Gym的CartPole环境(经典入门环境:控制小车平衡一根杆子,不倒则得分)来实现Q-Learning。
1. 准备环境
先安装依赖:
pip install gymnasium numpy matplotlib # Gymnasium是新版Gym,兼容旧代码
2. 核心步骤拆解
CartPole的状态是连续的4维向量(小车位置、速度;杆子角度、角速度),动作是离散的2个(左推/右推)。我们需要先把连续状态离散化,再构建Q表。
3. 完整代码实现(带详细注释)
import gymnasium as gym
import numpy as np
import random
from matplotlib import pyplot as plt
# 1. 初始化环境
env = gym.make('CartPole-v1', render_mode="rgb_array") # 用rgb_array模式,后续可渲染视频
# 2. 状态离散化函数(把连续状态切成10个区间)
def discretize_state(state):
# 每个维度的范围(来自CartPole的官方文档)
state_bins = [
np.linspace(-2.4, 2.4, 10), # 小车位置
np.linspace(-3.0, 3.0, 10), # 小车速度
np.linspace(-0.209, 0.209, 10), # 杆子角度(弧度)
np.linspace(-3.0, 3.0, 10) # 杆子角速度
]
# 把每个维度的连续值转换成区间索引(比如位置-2.4→0,-1.2→1,…,2.4→9)
discretized = tuple(np.digitize(s, bin) for s, bin in zip(state, state_bins))
return discretized
# 3. 初始化Q表(用字典存储,键是离散状态,值是动作Q值的列表)
q_table = {}
# 超参数设置(我踩过无数坑后的“入门友好值”)
gamma = 0.95 # 未来奖励权重
alpha = 0.1 # 学习率
epsilon = 0.1 # 探索率(10%概率随机选动作,90%选最优)
episodes = 1000 # 训练轮数
# 4. 训练循环
reward_history = [] # 记录每轮的总奖励,方便画图
for episode in range(episodes):
state, _ = env.reset() # 重置环境,得到初始状态
discretized_state = discretize_state(state)
total_reward = 0
done = False
while not done:
# 4.1 ε-贪心策略:兼顾探索(试新动作)和利用(用已知最优)
if random.uniform(0, 1) < epsilon:
action = env.action_space.sample() # 随机选动作(探索)
else:
# 如果状态不在Q表中,初始化动作Q值为0
if discretized_state not in q_table:
q_table[discretized_state] = [0.0, 0.0]
action = np.argmax(q_table[discretized_state]) # 选Q值最大的动作(利用)
# 4.2 执行动作,得到下一个状态、奖励、是否结束
next_state, reward, done, _, _ = env.step(action)
discretized_next_state = discretize_state(next_state)
total_reward += reward
# 4.3 更新Q表
# 如果当前状态不在Q表,初始化
if discretized_state not in q_table:
q_table[discretized_state] = [0.0, 0.0]
# 如果下一个状态不在Q表,初始化(避免KeyError)
if discretized_next_state not in q_table:
q_table[discretized_next_state] = [0.0, 0.0]
# 计算TD误差,更新Q值
current_q = q_table[discretized_state][action]
max_next_q = np.max(q_table[discretized_next_state])
td_error = reward + gamma * max_next_q - current_q
new_q = current_q + alpha * td_error
q_table[discretized_state][action] = new_q
# 4.4 切换到下一个状态
discretized_state = discretized_next_state
# 记录每轮奖励
reward_history.append(total_reward)
# 每100轮打印进度
if (episode + 1) % 100 == 0:
print(f"Episode {episode+1}, Average Reward: {np.mean(reward_history[-100:]):.2f}")
# 5. 可视化训练过程
plt.plot(reward_history)
plt.xlabel("Episode")
plt.ylabel("Total Reward")
plt.title("Q-Learning Training Progress (CartPole)")
plt.show()
# 6. 测试训练好的智能体(不用探索,全选最优动作)
test_episodes = 10
test_rewards = []
for _ in range(test_episodes):
state, _ = env.reset()
discretized_state = discretize_state(state)
total_reward = 0
done = False
while not done:
if discretized_state not in q_table:
action = 0
else:
action = np.argmax(q_table[discretized_state])
next_state, reward, done, _, _ = env.step(action)
total_reward += reward
discretized_state = discretize_state(next_state)
test_rewards.append(total_reward)
print(f"Test Average Reward: {np.mean(test_rewards):.2f}")
env.close()
代码运行说明:
- 训练1000轮后,Average Reward应该能从初始的20左右涨到200(CartPole的上限是200,意味着杆子能一直立着);
- 测试时的平均奖励如果能稳定在180以上,说明Q表训好了。
踩过的坑:这些错误我帮你先避了
我刚开始学Q-Learning时,踩过无数“看起来没问题,结果跑不通”的坑,总结几个高频雷区:
坑1:ε不衰减,智能体一直“瞎试”
一开始我把ε固定为0.1,结果训练到2000轮,奖励还是波动很大——因为智能体永远有10%的概率乱选动作(比如明明该往左推,它偏往右推,直接把杆子摔了)。
解决办法:让ε随着训练轮数衰减,比如从0.1慢慢降到0.01(比如epsilon = max(0.01, epsilon * 0.995)
),后期减少探索,专注利用已知经验。
坑2:状态离散化太粗/太细
- 太粗:比如把小车位置分成2个区间(“左/右”),Q表不够精确,智能体学不会微调车把;
- 太细:比如分成100个区间,Q表大到无法训练(状态数=100^4=1亿,内存爆炸)。
解决办法:入门用10个区间,刚好平衡精度和计算量(像我代码里那样)。
坑3:超参数乱调
- γ太大(比如>0.99):智能体太看重未来奖励,比如为了“后面拿100奖励”,现在愿意承受-100的惩罚(结果直接摔杆子);
- α太大(比如>0.5):Q值波动太大,像个情绪不稳定的人,今天觉得“这个动作好”,明天又觉得“不好”;
- α太小(比如<0.01):学习速度太慢,1000轮都学不会。
解决办法:先按我代码里的“入门友好值”来,再慢慢调——比如把γ从0.95改成0.9,看奖励怎么变;把α从0.1改成0.2,看波动是不是变大。
坑4:Q表初始化错误
我一开始没处理“新状态”的情况,结果训练时经常报KeyError——因为智能体走到一个新状态(比如小车位置到了-2.0),Q表里没有这个状态的记录。
解决办法:每次访问状态前,先检查是否在Q表中,如果不在,初始化动作Q值为0(像代码里的if discretized_state not in q_table: q_table[discretized_state] = [0.0, 0.0]
)。
最后:Q-Learning的局限与进阶方向
Q-Learning是强化学习的“入门砖”,但它也有缺点:
– 无法处理高维连续状态:比如玩Atari游戏(状态是210×160的像素),Q表会大到无法存储(210×160×动作数,根本不可能);
– Q值估计不准:因为Q值是“经验平均”,如果某个状态-动作对试的次数少,Q值可能不准。
进阶方向:
– 用DQN(深度Q网络)代替Q表:用神经网络拟合Q值,处理高维状态;
– 用Double DQN解决“maxQ高估”问题(Q-Learning会高估未来奖励,Double DQN用两个网络互相验证);
– 用Dueling DQN分开估计“状态价值”和“动作优势”,提高Q值的准确性。
不过别急,先把Q-Learning吃透——这是所有强化学习算法的“地基”,搞懂它再学进阶算法,会轻松很多!
原创文章,作者:,如若转载,请注明出处:https://zube.cn/archives/225