简介:面向强化学习入门者与Python开发者的Q Learning学习材料,围绕部分可观测环境中通过交互逐步优化策略的问题展开,帮助读者把Q表、奖励、折扣因子、学习率与探索-利用平衡等概念落到可运行代码上。压缩包共5个文件,含Python实现脚本、Markdown说明、PDF实验报告及许可证等,整体约852KB,结构轻量,适合按代码与实验报告两条线索对照阅读。已有1111人学习下载。读者可从中获得Q Learning的完整实现路径,包括Q表初始化、epsilon-greedy动作选择、Q值更新公式的代码表达、按回合训练与终止条件控制,也能借助报告理解参数调整对收敛效果的影响,在gym的倒立摆等环境中复现实验并观察学习率与折扣因子的作用。材料还提示离散状态可直接编码、连续状态需离散化后再建表,适合课程实验、自学练手与算法入门参考。
1. 别急着调参:先看清这份 QLearning 强化学习代码能替你解决什么
有个场景我印象很深:某开发者照着一篇博客写了三十行 QLearning 强化学习代码,训练到第 300 局,智能体还在迷宫左上角来回撞墙,奖励曲线像心电图。问题不在算法,而在他手里没有一份能跑通、能打印中间量、能对着改的完整实现。这份 python 实现 Q Learning 的完整代码就是干这个的:一个 5×5 格子迷宫,加 Q 表、ε-greedy 探索、时序差分更新和训练曲线,全部摊开在几个文件里,没有框架黑匣子。适合两类人:刚学完贝尔曼方程想找个能跑的载体;或者手上有离散状态的小决策问题,想拿表格法先做个基线。它不解决高维连续控制,但能让你把 Q 值一行行看明白。
2. 技术选型与目录结构:numpy + matplotlib 撑起的极简强化学习骨架
2.1 为什么这个场景不需要重型框架
Q Learning 是表格型算法,它全部的「模型」就是一张二维表。5×5 的地图展开成 25 个状态,上下左右 4 个动作,整张 Q 表是 25×4 个 float64,连内存占用都可以忽略。这种规模上神经网络或者通用强化学习框架,等于给自行车装飞机发动机:能跑,但你调试时看不到任何中间量,Q 值涨没涨、涨在哪一格,全靠猜。
我一般会坚持两条:依赖只留 numpy 和 matplotlib,环境、智能体、训练脚本拆成独立文件。第一条保证别人 clone 下来两条命令就能跑,不用为了看一眼 Q 表去装一堆依赖;第二条保证出问题时能定位——奖励不涨是环境写错了,还是更新公式写错了,文件边界就是责任边界。
超参数统一收进config.py。这个习惯是被坑出来的:alpha 写在 agent 里、episodes 写在训练循环里、epsilon 衰减硬编码在 while 里,做三组对照实验要改四个文件,改漏一个就得到一组毫无意义的曲线。用 dataclass 收口之后,跑实验只是换一个 Config 实例。
# config.py:所有可调项集中在一处,方便做对照实验 from dataclasses import dataclass @dataclass class Config: episodes: int = 500 # 训练总局数 max_steps: int = 200 # 单局最大步数,防止智能体无限兜圈 alpha: float = 0.1 # 学习率,新经验覆盖旧经验的幅度 gamma: float = 0.9 # 折扣因子,看多远 eps_start: float = 1.0 # 初始探索率,开局几乎纯随机 eps_end: float = 0.05 # 最终探索率,留一点随机性兜底 eps_decay_episodes: int = 300 # 探索率从 start 线性降到 end 所需局数 seed: int = 42 # 随机种子,保证可复现这里的每个字段都会在后面被直接引用。alpha调到 0.5 以上会出现明显的 Q 值震荡,因为单次经验占比过高,旧估计被冲掉;gamma取 0.9 意味着大约看 10 步以内的回报,5×5 迷宫最优路径 8 步,刚好够用;eps_end不给 0 是我自己的习惯,留 5% 随机能在环境有微小变化时不至于彻底卡死。
2.2 目录结构与文件职责
代码包解开后大致是这么一套结构,我按职责重新梳理了一遍,你拿到手可以对照着看:
qlearning_grid/ ├── config.py # 超参数与随机种子 ├── env.py # 5x5 格子环境,对外只暴露 reset / step ├── agent.py # Q 表、ε-greedy 选动作、TD 更新、模型存取 ├── train.py # 训练主循环、日志打印、Q 表落盘 ├── visualize.py # 奖励曲线、步数曲线、策略箭头图 ├── requirements.txt # numpy + matplotlib └── ckpt/ └── q_table.npy # 训练产物,可视化脚本直接读它| 文件 | 核心内容 | 二次开发时的改动频率 |
|---|---|---|
env.py | 地图、状态编码、奖励规则、终止判定 | 高,换任务基本都要改 |
agent.py | Q 表初始化、动作选择、更新公式 | 低,算法本身很少动 |
train.py | 主循环、epsilon 衰减、日志 | 中,加日志、加评估会改 |
config.py | 全部超参数 | 高,调参必改 |
visualize.py | 曲线与策略图 | 低 |
这个划分的关键点是env.py完全不认识 Q 表,agent.py完全不认识地图。后面你想把格子迷宫换成仓库拣货路径、换成简单博弈,只要新环境满足同样的接口,agent 那侧一个字都不用动。
2.3 环境接口约定:reset / step / done 三件套
很多人写 Q Learning 写到一半卡住,不是因为公式,是因为环境和算法糊在一个文件里,状态怎么表示、奖励什么时候给,全混在循环中。把接口固定成三个方法,问题立刻清晰:
# env.py 中对外的三个契约方法 class GridWorld: def reset(self): """回到起点,返回初始状态(一维整数索引)""" self.pos = self.start self.steps = 0 return self._encode(self.pos) def step(self, action): """执行一个动作,返回 (下一状态, 即时奖励, 是否终止)""" ... def render(self): """打印当前地图和智能体位置,调试用""" ...reset()每次返回的都是同一个整数(起点状态),这是有意的:迷宫起点固定,训练时每局从同一个地方开始,曲线才有可比性。step()的返回值顺序固定为「状态、奖励、终止标志」,训练循环里就永远是一行解包,不会出现把 reward 和 done 位置写反、结果训练几十局发现奖励一直是 1 和 0 的低级事故。
done用布尔值而不是 0/1 浮点,是为了让更新公式里写(1 - done)时语义明确。到了 agent 那侧再转 float,职责分开,读代码的人一眼知道这个标志从哪来、到哪去。
3. 核心模块拆解:一个 episode 从采样到 Q 表更新的完整链路
3.1 地图与状态编码:把二维坐标压成一维索引
地图直接写成字符数组,改起来最直观:S起点、G终点、#障碍、.通路。5×5 的地图手写五行字符串,谁都能在十秒内看懂哪里是墙。
# env.py:地图与状态编码 import numpy as np ACTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)] # 上 下 左 右 MAP = [ "S..#.", ".#.#.", ".#...", "...#.", "#...G", ] class GridWorld: def __init__(self, max_steps=200): self.map = MAP self.n_rows, self.n_cols = len(self.map), len(self.map[0]) self.start = (0, 0) self.goal = (self.n_rows - 1, self.n_cols - 1) self.n_states = self.n_rows * self.n_cols # 25 self.n_actions = len(ACTIONS) # 4 self.max_steps = max_steps self.pos, self.steps = self.start, 0 def _encode(self, pos): # 二维坐标 -> 一维索引,Q 表才能直接用整数下标访问 return pos[0] * self.n_cols + pos[1] def step(self, action): dr, dc = ACTIONS[action] r, c = self.pos[0] + dr, self.pos[1] + dc self.steps += 1 if not (0 <= r < self.n_rows and 0 <= c < self.n_cols) or self.map[r][c] == "#": r, c = self.pos # 撞墙或越界:原地不动 reward = -1.0 # 给个明显负奖励,让它长记性 elif (r, c) == self.goal: reward = 10.0 # 到终点给大正奖励 else: reward = -0.1 # 每走一步小惩罚,鼓励最短路径 self.pos = (r, c) done = self.pos == self.goal or self.steps >= self.max_steps return self._encode(self.pos), reward, done_encode是整个项目里最短但最关键的函数。状态从(row, col)元组变成row * n_cols + col的整数之后,Q 表可以用np.zeros((25, 4))直接建出来,可视化时又能reshape(n_rows, n_cols)画回格子图。如果偷懒用元组当索引,要么报错,要么得用字典存 Q 值,后面所有向量化操作全部失效。
奖励设计上,每一步 -0.1 是「时间成本」,撞墙 -1 是「违规成本」,终点 +10 是「目标收益」。三个数的大小关系决定了策略形状:撞墙惩罚必须显著大于普通步惩罚,否则智能体会发现贴着墙来回蹭比绕路便宜。max_steps是保险丝,防止某次策略退化后单局跑到天荒地老。
3.2 ε-greedy:探索与利用的那条分界线
Q 表刚初始化时全是 0,纯贪心会永远选 0 号动作(上),智能体连第一步都迈不出去。ε-greedy 就是给这个死局开的口子:以 ε 的概率随机选,以 1-ε 的概率选当前最优。
# agent.py:Q 表与动作选择 class QAgent: def __init__(self, n_states, n_actions, alpha, gamma): self.q_table = np.zeros((n_states, n_actions)) # 初始 Q 值全 0 self.n_actions = n_actions self.alpha, self.gamma = alpha, gamma def choose_action(self, state, epsilon): # 探索:随机试一个动作,保证每个动作都有被采样到的机会 if np.random.rand() < epsilon: return np.random.randint(self.n_actions) # 利用:取当前 Q 值最大的动作;平局随机打散,避免固定偏好 0 号 q = self.q_table[state] best_actions = np.flatnonzero(q == q.max()) return int(np.random.choice(best_actions))np.flatnonzero(q == q.max())这一行是很多人会忽略的细节。直接np.argmax在 Q 值全 0 时会永远返回下标 0,等于给「向上」这个动作一个隐形的初始偏好。5×5 迷宫里起点在左上角,向上是撞墙,智能体会先花几十局把这条路走废。打散平局之后,前期采样更均匀,收敛明显快一些。
epsilon不放在 agent 内部维护,而是每局由训练循环算好传进来,这样衰减策略想改成线性、指数还是分段,都只改训练脚本一处。
3.3 贝尔曼更新:四行代码里的时序差分
整个强化学习的核心就这一段。Q 值表示「在状态 s 做动作 a,之后一直按最优策略走能拿到的期望回报」,更新规则是用实际拿到的奖励加上下一状态的最优估值,去逼近当前估计。
# agent.py:时序差分更新 def learn(self, state, action, reward, next_state, done): # 终止状态没有"之后",bootstrap 项必须砍掉,否则 Q 值会无限膨胀 best_next = np.max(self.q_table[next_state]) target = reward + self.gamma * best_next * (1.0 - done) # 用学习率 alpha 把估计值往 target 挪一小步 td_error = target - self.q_table[state, action] self.q_table[state, action] += self.alpha * td_error参数逐个说:reward是这一步环境给的即时回报;gamma是折扣因子,0.9 表示 10 步之后的 1 分只值 0.35 分;best_next是下一状态所有动作里的最大 Q 值,体现「之后按最优走」;(1.0 - done)是终止掩码,到终点时 target 直接等于 reward,不再往后看。alpha控制这次经验能改写多少旧估计,取 0.1 意味着新经验占一成。
td_error单独提出来,不只是为了可读性。它是训练是否正常的体温计,我在train.py里会累计每局的 |td_error| 均值,正常情况应该是前期大、后期趋近于 0;如果一直不降,基本可以断定奖励设计或者终止判定有问题。
3.4 训练主循环:epsilon 衰减与收敛判定
主循环把前面三块拼起来,每局做三件事:重置环境、按 epsilon-greedy 采样、逐步更新 Q 表。
# train.py:训练主循环(节选) for episode in range(cfg.episodes): state = env.reset() total_reward, steps = 0.0, 0 # 线性衰减:从 eps_start 均匀降到 eps_end ratio = min(1.0, episode / cfg.eps_decay_episodes) epsilon = cfg.eps_start + (cfg.eps_end - cfg.eps_start) * ratio while True: action = agent.choose_action(state, epsilon) next_state, reward, done = env.step(action) agent.learn(state, action, reward, next_state, done) state = next_state total_reward += reward steps += 1 if done: break rewards.append(total_reward) lengths.append(steps) if (episode + 1) % 50 == 0: print(f"ep {episode+1:4d} | eps {epsilon:.3f} | " f"reward {np.mean(rewards[-50:]):7.2f} | steps {np.mean(lengths[-50:]):6.1f}")ratio用min(1.0, ...)截断,保证 300 局之后 epsilon 稳定停在eps_end,不会继续往下掉到负数。日志打的是最近 50 局的均值而不是单局值,因为 Q Learning 的单局回报方差极大,一次运气好撞到终点就能让曲线冒出个尖峰,看单局值容易误判收敛。
收敛判定我给的是经验阈值:最近 50 局平均步数稳定在 8 到 10 之间(最优路径 8 步),平均奖励稳定在 8 附近(终点 +10 减去路径上的步惩罚),就认为学到了。不必追求 Q 表完全不动,epsilon 还有 5% 的随机性,环境永远会有抖动。
4. 跑起来:从 pip install 到奖励曲线和策略热力图
4.1 依赖与入口:两条命令跑通训练
代码包的依赖极少,requirements.txt就两行。我习惯用虚拟环境隔离,避免和系统里的 numpy 版本打架:
numpy>=1.24 matplotlib>=3.7cd qlearning_grid python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install -r requirements.txt # 训练 500 局,指定种子便于复现 python train.py --episodes 500 --seed 42 # 训练完读 ckpt/q_table.npy,画曲线和策略图 python visualize.py --ckpt ckpt/q_table.npytrain.py支持三个常用参数:--episodes覆盖总训练局数,--seed固定随机种子,--save指定 Q 表保存路径。全部 500 局在普通笔记本上十几秒跑完,日志每 50 局打一行。训练结束后ckpt/q_table.npy是一个 25×4 的浮点数组,用np.load就能直接读,不需要反序列化任何自定义对象。
4.2 超参数取值表:alpha、gamma、epsilon 怎么定
新手最容易在这里翻车:抄了一份参数,换个地图就不收敛了。下面这张表是我在格子类任务上反复用过的取值区间,可以直接当起点。
| 参数 | 建议取值 | 调大的后果 | 调小的后果 |
|---|---|---|---|
alpha | 0.1(0.05~0.3) | 收敛快但 Q 值震荡,策略反复横跳 | 学得极慢,500 局不够 |
gamma | 0.9(0.8~0.99) | 看得远,但稀疏奖励下难收敛 | 短视,宁可原地磨蹭也不去远处终点 |
eps_start | 1.0 | — | 前期探索不足,容易困在偶然路径 |
eps_end | 0.05(0.01~0.1) | 后期一直乱走,曲线压不下去 | 环境稍有变化就彻底卡死 |
eps_decay_episodes | 总局数的 60% | 探索太久,浪费时间 | 衰减过快,Q 表还没铺开就停止探索 |
max_steps | 最优路径长度 × 20 | 训练变慢,兜圈子局拖时间 | 还没走到终点就被截断,学不到目标奖励 |
有一组关系值得单独记:gamma和max_steps要匹配。gamma 0.99 意味着看 100 步之后的回报,如果 max_steps 只有 50,那折扣后的远端奖励几乎为 0,智能体根本感受不到终点的存在,怎么训都不动。
4.3 结果怎么读:奖励曲线、步数曲线与策略箭头图
visualize.py输出三样东西。第一张是奖励曲线,我会把原始曲线画成浅色,再叠一条 50 局滑动平均的粗线:
# visualize.py:滑动平均,让趋势从噪声里浮出来 def moving_average(x, window=50): if len(x) < window: return np.array(x, dtype=float) kernel = np.ones(window) / window return np.convolve(x, kernel, mode="valid")np.convolve的mode="valid"表示只保留完全重叠的部分,输出长度是len(x) - window + 1,所以横轴要对应地切掉前window-1个点,否则曲线会整体左移,看着像提前收敛。
第二张是策略图,把 Q 表每一行取 argmax 还原成动作,再在格子上画箭头:
# visualize.py:Q 表 -> 策略箭头图 policy = np.argmax(q_table, axis=1).reshape(env.n_rows, env.n_cols) arrows = {0: (0, -1), 1: (0, 1), 2: (-1, 0), 3: (1, 0)} # 对应 上下左右 for r in range(env.n_rows): for c in range(env.n_cols): if env.map[r][c] == "#": continue dr, dc = arrows[policy[r, c]] ax.arrow(c, r, dc * 0.3, dr * 0.3, head_width=0.1, color="tab:blue")注意ax.arrow里的坐标顺序是(x, y),而地图是(row, col),所以横轴传c、纵轴传r,方向也反过来写。这个坑我踩过,画出来的图整张转了 90 度,箭头全部指向错误方向,一开始还以为是策略没学对。
正常收敛的结果长这样:前 50 局平均奖励在 -20 以下,智能体到处乱撞;150 局左右曲线抬头;300 局后稳定在 +8 上下,平均步数从 200 的截断值降到 8 到 9 步。策略图上是一条从左上角绕过障碍、贴着中间通道到右下角的连续箭头链,没有环。
5. 避坑与排查:Q Learning 跑不出结果的 5 个典型现场
5.1 奖励与终止信号类问题
现象一:训练越久,单局步数越长,总奖励反而更高,但智能体从来不到终点。原因:给每一步都设了正奖励,比如每步 +1。绕圈 200 步能拿 200 分,走到终点游戏结束只拿 10 分,理性智能体当然选择永远不结束。这是奖励设计里最经典的错误,跟算法实现无关。 解决:步奖励改成 0 或负数(-0.01 到 -1),终点给显著更大的正奖励,同时用max_steps硬截断。判断标准很简单——如果「最优策略」在数值上比「什么都不做」还差,奖励一定设计错了。
现象二:Q 值越训越大,终点附近数值涨到几百上千,策略却开始绕开终点。原因:更新时漏了(1 - done)。终止状态没有后继,但仍然拿max Q(next_state)去做 bootstrap,于是目标值里包含了自己,形成自激放大,训练越久越离谱。 解决:target 写成reward + gamma * max_q * (1.0 - done),并确保done是布尔或 0/1 浮点,别写成None或者字符串,那样1 - done直接抛类型错误。
现象三:智能体学会了走终点,但路径明显绕远,怎么训都不优化。原因:撞墙惩罚太轻,甚至没有。贴着障碍物来回蹭只损失普通步惩罚,绕远和走直线的代价一样,Q 表就没有动力去区分。 解决:撞墙给一个显著更重的负奖励(本例是 -1 对 -0.1,十倍关系),并且撞墙时位置不变。位置不变这点要确认,如果撞墙还能穿过去,地图上的#就形同虚设。
5.2 探索策略与数值类问题
现象四:前几十局就「学会」了一条能到终点的路,之后步数死活降不下去。原因:epsilon 衰减太快,或者干脆设成 0。Q 表初始全 0 时贪心永远选 0 号动作,智能体沿着一条偶然走通的路径不断强化,再也没机会发现更短的路线。 解决:eps_start设 1.0,衰减周期给到总训练局数的六成左右,eps_end留 0.05 不归零;同时把 argmax 的平局随机打散。判断是否探索不足有个土办法:看前 50 局的路径分布,如果所有局走的路线一模一样,探索肯定不够。
现象五:同样的代码跑两次,曲线差出一大截,怀疑实现有 bug。原因:随机种子没固定,np.random的全局状态被前一次运行影响;如果reset()里还随机了起点,差异会更大。 解决:在train.py入口统一np.random.seed(cfg.seed),环境内部如果要用随机,单独持有一个np.random.RandomState(seed)实例,不要和全局状态混用。这样同一份配置能稳定复现,做参数对比才有意义。
6. 二次开发:把 5×5 迷宫换成你自己的任务
想把这套代码用到自己的问题上,改动点其实只有三处,顺序别搞反。
第一处是环境契约。新环境只要提供reset()返回整数状态、step(action)返回(next_state, reward, done),agent.py和train.py一行都不用动。我一般先写一个假环境,故意让它在三步后必到终点,跑通整条链路再填真实逻辑,这样能把「环境写错」和「算法写错」彻底分开。
第二处是状态表示。表格法的硬约束是状态数必须可控,一般控制在几千到几万以内。如果你的状态是连续的(比如位置是浮点、速度是浮点),最省事的做法是分桶离散化:
# 连续状态离散化:把每个维度切成 n_bins 段,再拼成一维索引 def discretize(obs, low, high, n_bins=10): ratios = (obs - low) / (high - low) idx = np.clip((ratios * n_bins).astype(int), 0, n_bins - 1) state = 0 for i in idx: state = state * n_bins + i # 混合进制编码,保证唯一 return statelow和high是每个维度的物理范围,n_bins是分档数。维度一多状态数就指数爆炸,三维各 10 档就是 1000 个状态,四维就是 1 万,再往上就该考虑函数逼近了,那是另一个话题。
第三处是验证。改完之后别只看曲线好不好看,加一个确定性评估:关掉探索(epsilon=0),跑 100 局,统计成功率和平均步数。我在train.py末尾加了一段:
# 贪心评估:epsilon=0,跑 100 局,看真实策略水平 success, total_steps = 0, 0 for _ in range(100): s = env.reset() for _ in range(cfg.max_steps): a = int(np.argmax(agent.q_table[s])) s, r, done = env.step(a) total_steps += 1 if done and r > 0: success += 1 break print(f"成功率 {success}% | 平均步数 {total_steps / 100:.1f}")成功率不到 95% 就别急着调 alpha,先回去看奖励和终止条件。还有一个我很推荐的技巧:如果地图的最短路径长度是已知的(本例是 8 步),把它写成断言,训练完比对贪心步数,超了就报警。这比盯曲线靠谱得多。
从那以后我每次改完环境,都强制先跑一遍这个确定性评估再看训练曲线——曲线会被探索噪声骗,贪心评估不会。希望帮到你。
本文还有配套的精品资源,点击获取