ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

强化学习入门实战:GridWorld与Q-learning可替换实例

强化学习入门实战:GridWorld与Q-learning可替换实例

简介:这份强化学习实例面向零基础或初学强化学习的开发者,提供可直接运行的乒乓球对战模拟环境,帮助理解智能体如何通过与环境交互不断优化击球策略。压缩包共10个文件,以6个pth模型权重、3个py脚本和1个pyc缓存文件为主,整体约35.78MB;其中py脚本承担环境封装、DQN算法与训练测试逻辑,pth文件保存不同训练阶段的网络参数,替换后即可对比智能体各阶段表现。资源已有4371人学习下载,热度较高。读者可借此观察状态、动作、奖励与策略在具体游戏中的落地方式,理解Q-learning与DQN的差异,并基于现成代码尝试替换算法或调整参数,快速上手强化学习实验流程。

1. 强化学习实例:从“跑不起来”到“改一行就能换算法”

很多人第一次接触强化学习,卡住的地方不是数学,而是环境。装完框架,复制一段示例,结果要么版本对不上,要么训练半天奖励不涨,最后连问题出在哪都不知道。这个标题想解决的,就是这件事:给一个能直接运行、结果直观、方法可替换、对初学者代码友好的强化学习实例。它适合两类人——刚学完 Q-learning 公式、想看到智能体真的学会走路的同学;以及已经会调库、但想要一个干净骨架去替换算法做对比实验的工程师。核心诉求只有三个:跑得起来、看得见变化、换得动方法。我一般会把这类实例拆成“环境 + 算法 + 训练循环 + 可视化”四块,任何一块都能单独替换,这样才叫可替换,而不是把算法焊死在训练脚本里。

2. 先选对环境和算法:为什么用 GridWorld 配 Q-learning 起步

2.1 环境选型:为什么不用 Atari 或 MuJoCo 开局

初学者最容易踩的坑,是一上来就上图像输入或连续控制。Atari 需要卷积网络、经验回放、目标网络,训练几百万步才见效果;MuJoCo 涉及物理引擎和连续动作空间,调参成本极高。这些环境不是不好,而是不适合“直接运行、直观”这个目标。

常见做法是选一个状态空间离散、动作空间小、奖励结构清晰的环境。GridWorld(网格世界)就是典型:智能体在一个 N×N 的格子里,从起点走到终点,撞墙或走错扣分,到达终点给正奖励。它的好处是状态可以用整数编号,Q 表可以直接打印出来看,训练几十秒就能看到策略从乱走变成最短路径。这种“肉眼可见的收敛”对建立信心非常关键。

我一般会把环境写成独立类,只暴露reset()和step(action)两个接口,内部维护状态转移和奖励计算。这样后面换成 FrozenLake、CliffWalking 甚至自己定义的格子任务,只要接口一致,算法代码一行不用改。

2.2 算法选型:Q-learning 作为可替换骨架的基准

Q-learning 是离策略(off-policy)时序差分算法,更新公式是:

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

它只需要一张 Q 表,不需要神经网络,不需要经验回放,代码量小,调试直观。选它作为基准,不是因为它是“最好”的,而是因为它最容易验证正确性:你可以手动算几步,看 Q 值更新对不对。

更重要的是,它天然适合做“方法可替换”的骨架。把 Q 表换成神经网络,就是 DQN 的雏形;把 max 换成期望,就是 Expected SARSA;把离策略改成在策略,就是 SARSA。只要训练循环和接口设计得当,替换算法只需要改一个类。

下面是一个最小可运行的 Q-learning 实例,环境是 5×5 网格,起点左上角,终点右下角,撞墙或越界留在原地并给 -1 奖励,到达终点给 +10 并结束。

import numpy as np import random class GridWorld: def __init__(self, size=5): self.size = size self.start = (0, 0) self.goal = (size - 1, size - 1) self.state = self.start self.actions = [0, 1, 2, 3] # 上右下左 self.action_delta = [(-1, 0), (0, 1), (1, 0), (0, -1)] def reset(self): self.state = self.start return self._state_id(self.state) def step(self, action): dr, dc = self.action_delta[action] r, c = self.state nr, nc = r + dr, c + dc # 越界或撞墙:留在原地,给负奖励 if nr < 0 or nr >= self.size or nc < 0 or nc >= self.size: return self._state_id(self.state), -1.0, False self.state = (nr, nc) if self.state == self.goal: return self._state_id(self.state), 10.0, True return self._state_id(self.state), -0.1, False def _state_id(self, pos): return pos[0] * self.size + pos[1] def num_states(self): return self.size * self.size def num_actions(self): return len(self.actions)

这段环境代码的关键点:step返回三元组(next_state, reward, done),这是最通用的接口形式。奖励设计上,每走一步给 -0.1 是为了鼓励最短路径,撞墙给 -1 是为了让智能体学会避开边界。如果把每步奖励设成 0,智能体可能学会绕圈;如果撞墙惩罚太轻,它可能反复撞墙。这些数值不是玄学,是可以通过打印 Q 表验证的。

2.3 训练循环:把探索、更新、衰减拆清楚

训练循环是初学者最容易写乱的地方。常见错误是把探索率衰减写在回合内,或者更新 Q 值时用错下一状态。下面是一个标准写法:

class QLearning: def __init__(self, num_states, num_actions, alpha=0.1, gamma=0.95, epsilon=1.0): self.Q = np.zeros((num_states, num_actions)) self.alpha = alpha self.gamma = gamma self.epsilon = epsilon def choose_action(self, state): if random.random() < self.epsilon: return random.randint(0, self.Q.shape[1] - 1) return int(np.argmax(self.Q[state])) def update(self, s, a, r, s_next, done): best_next = 0.0 if done else np.max(self.Q[s_next]) td_target = r + self.gamma * best_next td_error = td_target - self.Q[s, a] self.Q[s, a] += self.alpha * td_error def train(env, agent, episodes=500, epsilon_decay=0.995, min_epsilon=0.01): rewards = [] for ep in range(episodes): s = env.reset() total_reward = 0.0 done = False while not done: a = agent.choose_action(s) s_next, r, done = env.step(a) agent.update(s, a, r, s_next, done) s = s_next total_reward += r agent.epsilon = max(min_epsilon, agent.epsilon * epsilon_decay) rewards.append(total_reward) return rewards

逻辑说明:choose_action用 ε-贪婪策略,训练初期大量随机探索,后期逐渐利用。update里done为真时下一状态价值为 0,这是终止状态的处理,漏掉会导致 Q 值虚高。epsilon_decay每回合乘一次,而不是每步乘,否则探索衰减太快,智能体来不及学到东西。参数上,alpha=0.1是学习率,太大震荡,太小收敛慢;gamma=0.95是折扣因子,越接近 1 越看重长期回报;epsilon从 1.0 降到 0.01,保证最终策略稳定。

跑完 500 回合,把rewards每 50 回合取平均,应该能看到从负几十涨到接近 10。如果没涨,先检查奖励设计和done处理,这两处出问题最多。

3. 让结果直观:可视化、Q 表打印和策略回放

3.1 训练曲线:用移动平均看趋势,别被单回合噪声骗了

单回合奖励波动很大,直接画原始曲线往往像心电图。常见做法是每 20 或 50 回合取移动平均,再画一条平滑线。下面这段代码不依赖 matplotlib 也能跑,先把数据算出来:

def moving_average(data, window=50): if len(data) < window: return [] return [np.mean(data[i:i+window]) for i in range(len(data) - window + 1)] rewards = train(env, agent, episodes=500) ma = moving_average(rewards, window=50) print("前50回合平均:", np.mean(rewards[:50])) print("后50回合平均:", np.mean(rewards[-50:]))

如果后 50 回合平均明显高于前 50,说明学习有效。如果两者差不多,可能是探索率没降下来,或者学习率太小。我一般会先看这两个数,再决定要不要调参,而不是一上来就画图。

3.2 Q 表打印:把“智能体学到了什么”变成可读的表格

Q 表是离散状态下的最大优势——你可以直接看。把每个状态的最优动作打印成箭头,就能看到策略长什么样:

def print_policy(agent, env): arrows = ['↑', '→', '↓', '←'] for r in range(env.size): row = [] for c in range(env.size): s = r * env.size + c if (r, c) == env.goal: row.append(' G ') else: a = int(np.argmax(agent.Q[s])) row.append(f' {arrows[a]} ') print(''.join(row)) print_policy(agent, env)

理想输出是从起点到终点的一条箭头路径,中间没有反复绕圈。如果看到箭头指向墙或原地打转,说明训练不够或奖励设计有问题。这个打印比任何曲线都直观,初学者能立刻判断“学没学会”。

3.3 策略回放:用确定性策略跑一遍,看步数和路径

训练时用 ε-贪婪,评估时要关掉探索。写一个evaluate函数,固定epsilon=0,跑 10 次取平均步数:

def evaluate(env, agent, episodes=10): steps_list = [] for _ in range(episodes): s = env.reset() done = False steps = 0 while not done: a = int(np.argmax(agent.Q[s])) s, r, done = env.step(a) steps += 1 steps_list.append(steps) return np.mean(steps_list) print("平均步数:", evaluate(env, agent))

5×5 网格最短路径是 8 步(从 (0,0) 到 (4,4) 需要 4 下 4 右)。如果平均步数在 8 到 12 之间,说明策略接近最优;如果超过 20,说明还有绕路。这个指标比奖励更直接,因为奖励受每步 -0.1 影响,步数才是真实效率。

4. 方法可替换:从 Q 表到 SARSA、Double Q 和 DQN 的改法

4.1 替换成 SARSA:只改一行更新目标

SARSA 是 on-policy 算法,更新时用实际选择的下一动作,而不是最大 Q 值。把update改成:

def update_sarsa(self, s, a, r, s_next, a_next, done): next_q = 0.0 if done else self.Q[s_next, a_next] td_target = r + self.gamma * next_q self.Q[s, a] += self.alpha * (td_target - self.Q[s, a])

训练循环里需要先选a_next,再更新。这个改动很小,但行为差异明显:SARSA 在悬崖行走任务里更保守,因为它把探索风险算进去了。想对比两种算法,只需要在训练脚本里换一个agent类,环境代码不动。

4.2 替换成 Double Q-learning:解决最大化偏差

Q-learning 的max操作会系统性高估 Q 值,尤其在噪声环境下。Double Q-learning 用两张 Q 表,一张选动作,一张评估:

class DoubleQLearning: def __init__(self, num_states, num_actions, alpha=0.1, gamma=0.95, epsilon=1.0): self.Q1 = np.zeros((num_states, num_actions)) self.Q2 = np.zeros((num_states, num_actions)) self.alpha = alpha self.gamma = gamma self.epsilon = epsilon def choose_action(self, state): if random.random() < self.epsilon: return random.randint(0, self.Q1.shape[1] - 1) return int(np.argmax(self.Q1[state] + self.Q2[state])) def update(self, s, a, r, s_next, done): if random.random() < 0.5: best_a = int(np.argmax(self.Q1[s_next])) target = r + (0 if done else self.gamma * self.Q2[s_next, best_a]) self.Q1[s, a] += self.alpha * (target - self.Q1[s, a]) else: best_a = int(np.argmax(self.Q2[s_next])) target = r + (0 if done else self.gamma * self.Q1[s_next, best_a]) self.Q2[s, a] += self.alpha * (target - self.Q2[s, a])

这个类接口和QLearning完全一致,训练循环不用改。这就是“方法可替换”的价值:算法之间可以横向对比,环境和其他代码保持稳定。

4.3 替换成 DQN 的思路:把 Q 表换成网络,接口不变

如果状态空间变大,Q 表存不下,就需要函数逼近。DQN 用神经网络输出每个动作的 Q 值,核心改动是:choose_action里用网络前向传播,update里用均方误差反向传播,再加经验回放和目标网络。但对外接口仍然是choose_action(state)和update(s,a,r,s_next,done)。这意味着你可以在同一个训练循环里,把QLearning换成DQN,环境代码一行不改。

我一般会先在小 GridWorld 上验证 DQN 实现是否正确,再迁移到更大环境。如果 DQN 在 5×5 网格上都学不会,大概率是网络结构或超参问题,而不是环境太难。

5. 避坑与排查:训练不收敛时先看这五处

5.1 奖励一直不涨,Q 值全是零

现象:跑了几百回合,rewards始终在初始值附近,打印 Q 表发现大部分是 0。原因通常是done处理错误,导致终止状态的价值被反复传播但目标值不对;或者学习率太小,更新量被淹没。解决:先检查update里done为真时best_next是否置 0,再打印几步的td_error,如果一直是 0,说明s和s_next传错了。

5.2 策略在终点附近来回走,不进入终止状态

现象:打印策略看到箭头在终点旁边绕圈。原因通常是到达终点的奖励不够大,或者每步负奖励累积起来让智能体觉得“多走几步也无所谓”。解决:把终点奖励提高到 +10 以上,每步惩罚降到 -0.01 到 -0.1 之间,确保最短路径的累计回报明显更高。

5.3 探索率衰减太快,后期完全不探索

现象:前 100 回合奖励涨得不错,后面突然卡住。原因:epsilon_decay设得太小,比如 0.9,几十回合就降到 0.01,智能体还没覆盖所有状态-动作对。解决:把衰减设为 0.995 或 0.999,或者用线性衰减,保证前 30% 回合有足够探索。我一般会打印每 50 回合的epsilon,确认它降得是否合理。

5.4 换算法后结果差异巨大,怀疑环境有问题

现象:Q-learning 能学会,换成 SARSA 后完全学不会。原因:SARSA 是在策略算法,如果choose_action的探索率很高,它会把探索带来的负奖励学进去,导致策略保守甚至不敢走。解决:对比算法时,确保评估阶段都用epsilon=0,训练阶段的探索率设置一致。如果 SARSA 在悬崖任务里表现更保守,那是正常特性,不是 bug。

5.5 随机种子不同,结果忽好忽坏

现象:同样的代码,有时收敛有时不收敛。原因:网格世界虽然简单,但随机初始化和探索路径会导致不同局部最优。解决:固定随机种子,跑 5 个种子取平均,再看趋势。如果 5 个种子里有 3 个以上收敛,说明算法没问题,只是方差大。我一般会在训练脚本开头加random.seed(42)和np.random.seed(42),方便复现。

6. 进阶技巧:用参数扫描和策略热力图把实例变成实验平台

当你已经能跑通基础版本,下一步不是换更炫的算法,而是把这个实例变成可做对比实验的平台。我常用的做法是参数扫描:固定环境,遍历alpha、gamma、epsilon_decay的组合,每个组合跑 5 个种子,记录后 50 回合平均奖励。下面是一个简化版扫描脚本:

def run_experiment(alpha, gamma, decay, seeds=5): scores = [] for seed in range(seeds): random.seed(seed) np.random.seed(seed) env = GridWorld(size=5) agent = QLearning(env.num_states(), env.num_actions(), alpha=alpha, gamma=gamma, epsilon=1.0) rewards = train(env, agent, episodes=300, epsilon_decay=decay) scores.append(np.mean(rewards[-50:])) return np.mean(scores), np.std(scores) for alpha in [0.05, 0.1, 0.3]: for gamma in [0.9, 0.95, 0.99]: mean, std = run_experiment(alpha, gamma, 0.995) print(f"alpha={alpha}, gamma={gamma}, mean={mean:.2f}, std={std:.2f}")

这个脚本能帮你快速看出哪些参数组合稳定。比如alpha=0.3往往方差大,gamma=0.99在 5×5 网格上可能过慢,因为折扣因子太高会让智能体过度看重远期奖励。扫描结果用表格整理,比单次跑更有说服力。

另一个技巧是画策略热力图:把每个状态的 Q 最大值当颜色深浅,箭头当动作方向。这样能一眼看出哪些区域价值高、哪些区域智能体还没学好。实现上只需要把print_policy里的箭头换成颜色块,或者用matplotlib.imshow画 Q 值矩阵。热力图配合移动平均曲线,基本能覆盖“学没学会、哪里没学会、为什么没学会”三个问题。

我自己的习惯是:每换一个算法或一组参数,先跑 3 个种子看趋势,再用参数扫描确认稳定性,最后用策略热力图检查边界情况。这套流程跑下来,一个强化学习实例就不只是“能运行”,而是能支撑小规模对比实验。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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