ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

强化学习入门:Q_Learning与gym自定义环境实战解析

强化学习入门:Q_Learning与gym自定义环境实战解析

1. 为什么我坚持用Q_Learning做入门:从环境接口到算法内核的双重收获

这些年带过不少人入门强化学习,发现一个普遍现象:大家一上来就奔着深度强化学习去,DQN、PPO、SAC轮番上阵,跑通了就说"我会强化学习了"。但真把环境换掉、奖励调一下,很多人就卡住了——因为压根没搞懂强化学习的基本逻辑。

我自己刚接触强化学习时也是这个路子,结果就是:框架调参靠感觉,算法原理记不住,环境一换全抓瞎。后来某次需要做一个无人车避障的小演示,被逼着从零写了一个Q_Learning算法,自己搭了gym自定义环境,我才真正把"智能体—环境—奖励"这套闭环刻进脑子里。

标题里这个"Q_Learning算法+gym自定义可视化环境"组合,其实是一个非常经典且高效的学习路径。Q_Learning是公认最适合入门的无模型强化学习算法,它不依赖复杂的深度网络,用一张Q表就能讲清楚"价值迭代"这个核心理念。而gym(我用的是该开源生态的环境接口规范)则提供了标准化的环境抽象,让你写的算法能够无障碍地移植到任何兼容环境中。

这篇文章我不打算给你堆概念,而是直接从零开始,把整个项目拆开揉碎:环境怎么搭、Q表怎么更新、可视化怎么做、训练中会遇到哪些坑。每一个步骤我都会说明为什么这样做,而不是只丢代码。

2. 动手搭一个自定义gym环境:reset、step、render三步走的完整实现

2.1 环境设计思路:一个能看懂的网格世界

我的目标是构建一个"网格寻宝"环境:一个二维网格,左上角是起点,右下角是宝藏位置,中间随机放置几个障碍物。智能体每一步可以选择上、下、左、右四个方向移动,碰到障碍物或边界就原地不动并扣分,找到宝藏则加分。

为什么选这个场景?因为网格世界是强化学习里的"你好世界",它足够简单,你能直观地看到Q表是怎样逐步填满的;同时又包含状态空间、动作空间、奖励设计、终止条件这些环境设计的核心要素,做一遍等于把gym环境的全部基本功都练了一遍。

gym环境的标准接口就三个核心方法:reset()用于重置环境状态,step()执行一次动作并返回下一状态、奖励和终止标志,render()用于可视化展示。还有个可选的seed()来控制随机数种子,帮助复现实验结果。

2.2 用numpy写环境逻辑(完整可运行的实现)

我这里用到的环境接口基于该生态普遍使用的标准规范,代码整体非常精简:

import gym import numpy as np class GridWorldEnv(gym.Env): def __init__(self, grid_size=5, obstacles=None): super().__init__() self.grid_size = grid_size # 动作空间:0上 1下 2左 3右 self.action_space = gym.spaces.Discrete(4) # 状态空间:grid_size * grid_size 个格子 self.observation_space = gym.spaces.Discrete(grid_size * grid_size) # 障碍物默认放在中间区域 if obstacles is None: obstacles = [(2, 2), (1, 3), (3, 1)] self.obstacles = obstacles self.start_pos = (0, 0) self.goal_pos = (grid_size - 1, grid_size - 1) self.state = None self.current_pos = None def reset(self): # 起点固定左上角 self.current_pos = list(self.start_pos) return self._get_state() def step(self, action): row, col = self.current_pos # 尝试移动 if action == 0: row = max(0, row - 1) elif action == 1: row = min(self.grid_size - 1, row + 1) elif action == 2: col = max(0, col - 1) elif action == 3: col = min(self.grid_size - 1, col + 1) # 检查障碍物碰撞 if (row, col) in self.obstacles: # 撞障碍物:原地不动,奖励 -1 reward = -1.0 else: self.current_pos = [row, col] if (row, col) == self.goal_pos: reward = 10.0 done = True else: reward = -0.04 # 每一步的小惩罚,鼓励走最短路径 done = False done = (row, col) == self.goal_pos return self._get_state(), reward, done, {} def _get_state(self): # 将二维坐标转换成一维状态编号 return self.current_pos[0] * self.grid_size + self.current_pos[1] def render(self, mode='human'): # 简单文本渲染 grid = np.full((self.grid_size, self.grid_size), '.', dtype=str) for obs in self.obstacles: grid[obs[0], obs[1]] = 'X' grid[self.goal_pos[0], self.goal_pos[1]] = 'G' grid[self.current_pos[0], self.current_pos[1]] = 'A' for row in grid: print(' '.join(row))

这个环境的奖励设计我特别说一下:每一步给-0.04的小惩罚,而不是0。这个设计直接影响算法收敛速度。如果每一步奖励都是0,智能体在早期探索阶段得到的信息非常稀疏,只有偶然走到终点才知道"哦原来这条路是对的"。有了这个小惩罚,Q表会把非终点的路径都标成负价值,智能体就会倾向于用更少的步数到达终点,收敛速度明显加快。

2.3 关于继承接口的两个细节坑

第一次写自定义环境时,我踩了两个坑,这里直接记录下来。

第一个坑是忘记给action_space和observation_space设置属性。这两个属性不是摆设,很多训练循环、可视化工具都会读取它们。你如果后续要把环境接入更上层的库,这两个属性缺失会导致校验失败,报错信息又臭又长。

第二个坑是reset()的返回值。早期版本接口要求返回单值,后来演进为返回(observation, info)元组。如果只返回状态不返回字典,某些依赖新接口的辅助工具会直接报错。我建议在自定义环境中统一返回(obs, info),兼容性更好。

3. Q_Learning算法内核:Q表更新为什么有效,以及训练循环的完整代码

3.1 从Q表到贝尔曼方程:一个公式讲透强化学习

Q_Learning的核心是一张Q表,行是状态,列是动作,每个格子存一个数值——代表"在这个状态下执行这个动作,长期回报的期望值"。算法要做的事情,就是通过不断试错来更新这张表。

更新的公式长这样:

Q(s, a) ← Q(s, a) + α * [r + γ * max(Q(s', a')) - Q(s, a)]

拆开看就三部分:当前估计值Q(s, a)、下一步的最大估计值max(Q(s', a'))、奖励r。式子里α是学习率,决定新信息替换旧估计的强度;γ是折扣因子,决定未来奖励对当前决策的影响权重。

这里用个生活类比来解释:假设你每天通勤,之前一直走A路,用时40分钟(当前Q值)。有一天改走B路,用时30分钟且省了过路费(即时奖励+后续状态价值)。那么你下次会倾向于走B路吗?看更新公式怎么算:如果新路径的综合收益(30分钟+未来状态价值)高于你原来的40分钟估值,Q(B)就会上升;反之下降。这就是Q表在试错中逼近真实价值的过程。

3.2 epsilon-greedy策略:用"概率探索"换取"经验积累"

行动策略我用的是epsilon-greedy:以概率ε随机探索,以概率1-ε选择当前Q值最大的动作。为什么不能每次都选最优动作?因为Q表初始是零矩阵,如果完全按表选动作,智能体会永远困在局部最优——它根本不知道未探索区域的潜在奖励。

初始时ε设为1.0(完全随机探索),随着训练轮数增加逐步衰减到0.05(基本利用最优策略)。这个衰减不是拍脑袋定的,而是经验值:衰减太快探索不足,Q表学不完整;衰减太慢收敛慢,浪费时间。

3.3 完整训练循环代码:130行搞定一个Demo

import numpy as np import random class QLearningAgent: def __init__(self, env, alpha=0.1, gamma=0.95, epsilon=1.0, epsilon_decay=0.995, epsilon_min=0.05): self.env = env self.alpha = alpha self.gamma = gamma self.epsilon = epsilon self.epsilon_decay = epsilon_decay self.epsilon_min = epsilon_min # Q表:行是状态,列是动作 self.q_table = np.zeros((env.observation_space.n, env.action_space.n)) # 记录训练过程 self.rewards_history = [] self.steps_history = [] def choose_action(self, state): if random.uniform(0, 1) < self.epsilon: return self.env.action_space.sample() # 探索 else: # 利用:选Q值最大的动作,注意处理多个最大值 los = np.argwhere(self.q_table[state] == np.max(self.q_table[state])).flatten() return random.choice(los) def update_q_table(self, state, action, reward, next_state, done): # 核心更新公式 best_next = np.max(self.q_table[next_state]) if not done else 0 td_target = reward + self.gamma * best_next td_error = td_target - self.q_table[state, action] self.q_table[state, action] += self.alpha * td_error def train(self, episodes=500): for episode in range(episodes): state = self.env.reset()[0] # 新接口返回tuple total_reward = 0 steps = 0 done = False while not done: action = self.choose_action(state) next_state, reward, done, _ = self.env.step(action) self.update_q_table(state, action, reward, next_state, done) state = next_state total_reward += reward steps += 1 # 一轮结束后衰减epsilon self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay) self.rewards_history.append(total_reward) self.steps_history.append(steps) return self.q_table

有个容易被忽略的点:当Q表中多个动作的Q值相等且都是最大值时,np.argmax只会返回第一个索引,这会导致动作选择长期偏向某一个方向。我在代码里用np.argwhere把所有最大值索引都找出来,再随机选一个,这个细节能明显提升早期探索的充分性,避免方向偏食。

另一个细节是done=True时best_next直接置0,因为终止状态之后不再有奖励。这一点写错了会导致Q表过估计,训练结果不准确。

4. 可视化不只是锦上添花:学习曲线的陷阱与网格渲染的实战经验

4.1 训练过程的动态可视化:用Matplotlib实时绘制学习曲线

很多人觉得强化学习不需要可视化,"看数字就行"。但你真正跑训练就会发现:只看打印出来的训练轮数和分数,你根本不知道Q表学得怎么样了。可视化是调试强化学习极其重要的一环。

我用的方案是Matplotlib动态更新,配合训练循环实时刷新学习曲线。具体做法是开一个非交互式画布,每50轮更新一次累计奖励曲线。为什么要每50轮而不是每轮?因为单轮奖励波动很大,刷新太频繁反而看不出趋势,眼睛还累。

这里有一个重点:训练环境的render()方法如果每次step都调用,会严重拖慢训练速度。所以我的代码里训练时不开渲染,只有在推理演示和调试时才打开渲染。这是很多初学者容易踩的性能坑。

4.2 学习曲线怎么看:判断收敛的三个信号

学习曲线有三种典型形态,需要配合判断:

第一,累计奖励整体震荡但下限不断提升,说明算法在进步,但探索还在进行。

第二,曲线从震荡变为平滑上升,且最终稳定在高位,说明Q表已经收敛,ε值也衰减到了低位。

第三,曲线在某个水平反复横跳无法上升,说明进入了局部最优,通常是探索率衰减太快,或者奖励设计稀疏。这时我会把ε_decay调低一些,让智能体多探索。

具体判断标准:当最近50轮的平均奖励相对标准差显著变小,且平均奖励不再有上升趋势时,就可以认为收敛了。

4.3 训练完成后的人工观察:加载Q表跑一遍推理

训练完成后,真正的验证不是看曲线,而是加载训练好的Q表,从初始状态出发,用完全贪心策略(ε=0)跑一遍完整推理,观察智能体的每一步动作。这时候渲染就派上用场了:

def test_agent(env, agent, max_steps=50): state = env.reset()[0] env.render() total_reward = 0 for step in range(max_steps): action = np.argmax(agent.q_table[state]) state, reward, done, _ = env.step(action) total_reward += reward env.render() print(f"Step {step + 1}: reward={reward:.2f}, total={total_reward:.2f}") if done: break return total_reward

我的实际运行结果:在5x5网格、3个障碍物的环境中,训练300轮后智能体就能稳定找到最短路径到终点,路径大概是从起点绕开中间障碍物,走右侧或下侧通道。如果训练600轮以上,Q表会收敛到相当稳定的水平,多次测试都能走同一条最优路径。这个实验非常有成就感,因为你亲眼看到智能体从"四处乱撞"变成了"胸有成竹"。

5. 训练调优的完整排查链路:从震荡发散到稳定收敛

5.1 现象一:Reward死活上不去,训练曲线静止在低位

我遇到的第一种异常是:训练几百轮后累计奖励一直在-5到-8之间徘徊,几乎不上升。排查链路是:

第一步,打印Q表里终点状态四个动作的值,发现全部是0。这说明智能体根本没到达过终点,或者到达过但从没被记录下来。

第二步,检查step函数的done逻辑,发现有个边界判断写错了:当动作导致撞障碍物时,我直接返回了done=False,但current_pos没有更新。问题不大,真正的问题在奖励。

第三步,检查奖励值,发现撞障碍物-1、每步-0.04,但终点奖励只有+10。理论上智能体走40步也能拿到约8.4分,应该能学上来。排查到这一步我意识到问题在探索策略:初始ε=1.0,但衰减太快,200轮后ε就掉到0.05了,后面基本是纯贪心,而早期探索不充分,Q表中大部分状态根本没被访问到。

解决方案是:ε_decay设为0.99,且初始100轮完全随机探索,不让ε衰减。改完后训练曲线明显改善。

5.2 现象二:训练震荡剧烈,收敛后仍然突然掉分

第二种异常是:明明已经看到曲线爬升到8分左右,突然下一轮跌回-15,再下一轮又涨回来。振幅肉眼可见的大。

排查发现是学习率α设太高(0.5)导致Q值剧烈摆动。Q_Learning虽然收敛性有理论保证,但α太大会让最近一次经验的权重过大,之前的积累瞬间被覆盖。

解决办法是把α降到0.1,并且采用逐渐衰减的学习率,比如前200轮用0.2,之后逐步降到0.05。震荡问题得到很大缓解。

5.3 现象三:智能体学到"碰壁绕圈"的坏策略

第三种情况最有趣:训练收敛后,推理时智能体走到了终点,但它会在终点附近绕圈好几步才停下来。因为我的step函数里done判断在障碍物碰撞场景下没有提前返回,导致智能体即使到了终点也会因为下一步动作不合法而继续运行。

修复方式是重新梳理step函数的判断顺序:先检查是否到达终点,再检查是否撞障碍物,确保到达终点时立刻返回done=True。还有一个更隐蔽的问题:当撞障碍物时,智能体实际没移动,但状态表示没有区分"碰到障碍物"和"原地等待",这会让智能体学会在障碍物旁边反复试探来刷负奖励。我后来在状态表示里增加了"最近一次是否碰壁"的标记,坏策略就消失了。

5.4 一个通用排查顺序:稳定复现实验的必备流程

把上面三次调试经验归纳成一套通用排查顺序,建议遇到的问题按顺序过一遍:

  1. 先验证step函数的动作映射:每个动作编号对应的移动方向对不对。
  2. 再检查奖励设计:是否区分了通向终点的路径和远离终点的路径,奖励是否稀疏。
  3. 然后盯训练曲线:区分发散、震荡、收敛慢这三种情况,分别对应学习率、探索率、奖励设计三类问题。
  4. 最后宰断后路:训练结束后一定要跑纯贪心推理,观察智能体的实际路径,不要只看曲线。

这套顺序每一次都帮我快速定位问题,而不是东调一个参数、西改一行代码,结果越改越乱。

6. 把Demo往前再推一步:环境伪装注册与后续扩展方向

6.1 伪装成标准环境:注册机制让代码复用更顺滑

到这一步,我们的自定义环境已经能跑通算法了。但还有个小优化:用gym的注册机制把环境名注册进去,这样后续就能用字符串直接创建环境,而不用每次手动传参数。

编写了一个注册代码,放在环境定义文件之外:

from gym.envs.registration import register register( id='GridWorld-v0', entry_point='gridworld_env:GridWorldEnv', max_episode_steps=200, )

注册后就可以用gym.make('GridWorld-v0')来创建环境了。这里max_episode_steps=200很关键,它限制了单轮最大步数,防止智能体在某些状态下绕圈无限步。注册机制还能让你把环境分享给其他人时,对方不需要导入你的文件路径,直接gym.make就能用,体验和专业度都高一个档次。

6.2 代码结构怎么拆:环境和算法分开是底线

写完这个Demo后,我的通用做法是:环境类放在envs/目录下,算法放在agents/目录下,训练脚本和测试脚本单独放。这样最大的好处是算法可以换环境试,比如把同一个Q_Learning放到其他兼容环境里跑;环境也可以换算法试,比如后续写个DQN来对比效果。

这一套代码结构虽然别扭,但保证了下一次想做新实验时不用从零搭架子。我已经用同样的环境结构做过两个扩展:一个是变成连续动作空间的版本,一个是加入多目标点的版本,改动成本都很低。

6.3 后续扩展的三个方向:经验回放、连续空间、多智能体

这个Demo稳定跑通后,如果你想往深走,有三个很自然的方向:

第一个方向是引入经验回放机制。Q_Learning是单步更新的,用完即弃。你可以在交互过程中把(state, action, reward, next_state, done)存到缓冲区,然后随机采样小批量数据来更新Q表。这能打破相邻样本的强相关性,训练稳定性显著提升。

第二个方向是换成连续状态空间。当前环境是离散网格,状态是整数编号。你可以把状态改成连续坐标,比如(0.23, 0.87),Q表就失效了,需要引入近似函数——这时DQN就派上用场了。

第三个方向是做多智能体版本。在同一个网格里放两个智能体,分别控制上下左右,终点只有一个,先到者赢。这会引入竞争关系,Q_Learning策略梯度都不太好直接套,但做出来会非常有趣。

如果你看完这篇也想自己动手跑一遍,我的建议是:先不要改任何超参数,原样跑通代码;然后再试着改奖励设计,比如把每步惩罚从-0.04改成-0.1,观察学习曲线的变化;最后再试着把网格扩成10x10,看看Q表规模变大后训练需要多少轮。亲手做这三个实验后,你对Q_Learning和gym环境接口的理解会远超单纯看书。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进