200行Python实现Q Learning:从格子世界到电梯调度的强化学习实战

📅 发布时间:2026/10/17 7:02:20
200行Python实现Q Learning:从格子世界到电梯调度的强化学习实战
简介本资源是一份面向强化学习初学者与Python实践者的Q Learning算法完整实现方案聚焦于离散状态-动作空间下的智能体策略学习问题适用于高校课程实验、AI入门项目及算法原理验证场景。压缩包共5个文件含核心算法脚本qlearningAgents.py、环境说明与使用指南README.md、原理详解报告.pdf、开源许可证LICENSE及临时文件~WRL0755.tmp整体852KB轻量易读结构简洁便于快速运行与代码级理解。目前已有1108人学习下载反映出较强的教学参考价值。读者可直接复现Q表初始化、epsilon-greedy动作选择、状态转移奖励反馈、Q值迭代更新等关键流程并通过代码注释与PDF报告深入掌握学习率α、折扣因子γ、贝尔曼方程更新逻辑等核心概念是理解强化学习基础算法不可多得的实操范例。1. Q Learning 不是玄学一个 200 行 Python 脚本就能让智能体在格子世界里自己学会“绕开陷阱、奔向奖励”你可能见过太多强化学习的教程——动辄几十页公式推导、PyTorch 搭模型、Gym 环境调参失败三次就放弃。但这个项目反其道而行它用纯 Python零第三方深度学习库、不依赖 Gym只用 numpy matplotlib在一个 5×5 的可配置格子世界里完整跑通 Q Learning 的全部闭环——从初始化 Q 表、ε-greedy 采样、贝尔曼更新到策略收敛可视化。它不是玩具 demo而是能让你亲手改状态、调 reward、换地图、看 Q 值热力图实时演化的「可调试黑匣子」。适合刚学完马尔可夫决策过程MDP概念、想验证“Q 值真的会迭代变好”这一直觉的开发者也适合需要快速嵌入轻量级决策逻辑比如嵌入式小车路径预判、IoT 设备状态切换策略的工程师。它不解决自动驾驶或围棋对弈但它把 Q Learning 最核心的“试错→评估→修正”三步压缩进一份结构清晰、变量命名直白、每行都有业务语义的代码包里——你下载解压后main.py双击就能出动画q_learning.py里每个函数都能单步调试。2. 项目结构与技术选型为什么不用 PyTorch、不接 Gym因为我们要看清 Q 表怎么一格一格长出来的这个项目刻意规避了工业级强化学习框架的抽象层选择最贴近算法本质的技术组合Python 原生实现 NumPy 数值计算 Matplotlib 动画渲染。没有 PyTorch 是因为 Q Learning 的核心是查表更新Q[s,a] ← Q[s,a] α(r γ maxₐ′ Q[s′,a′] − Q[s,a])不需要自动微分和 GPU 加速不接 Gym 是因为标准 Gym 环境如FrozenLake-v1封装过深状态转移逻辑藏在 C 扩展里你根本看不到s → a → r,s′这条链路上哪个环节出了问题。而本项目把环境建模为一个完全透明的GridWorld类——所有障碍、目标、起始点坐标、奖励值都写在配置字典里你可以直接print(world.grid)看到当前地图矩阵也可以world.step(2)向右走后立刻检查world.state和world.reward的变化。这种“裸金属”设计让调试回归本质当智能体总卡在某个墙角不动你第一反应不是调 learning_rate而是打开grid_world.py查get_next_state()函数里边界判断有没有写反。2.1 目录结构5 个文件各司其职无冗余解压后你会看到如下结构共 5 个.py文件无.ipynb或配置文件├── main.py # 入口创建环境、初始化 agent、启动训练循环、绘制动画 ├── q_learning.py # 核心算法QTable 类含 init/update/choose_action、QLearningAgent 类含 train/evaluate ├── grid_world.py # 环境定义GridWorld 类含 reset/step/render、地图配置WALLS, GOAL, START ├── utils.py # 工具函数plot_q_table()热力图、plot_rewards()累计奖励曲线、save_gif() └── config.py # 参数集中地learning_rate0.9, gamma0.95, epsilon1.0, episodes500...提示config.py是你二次开发的第一站。所有超参数学习率、折扣因子、探索率衰减步长都在这里修改后无需动其他文件即可观察效果差异。不要在q_learning.py里硬编码alpha0.1——这是新手最容易翻车的地方。2.2 环境建模GridWorld类如何把“走格子”变成可计算的 MDPgrid_world.py中的GridWorld类是整个项目的地基。它不继承任何父类所有逻辑自包含。关键设计点有三个状态编码扁平化5×5 网格共 25 个位置状态s直接用整数0~24表示行优先索引而非(row, col)元组。这样 Q 表可直接用np.zeros((25, 4))初始化4 个动作上/下/左/右避免字典嵌套带来的性能损耗和调试混乱。动作空间显式映射self.actions [(-1,0), (1,0), (0,-1), (0,1)]—— 每个动作对应一个坐标偏移量。step(action_idx)内部先通过self.actions[action_idx]解出位移再计算新坐标(rdr, cdc)最后用self._is_valid_position()判断是否越界或撞墙。奖励函数可配置self.rewards {GOAL: 10.0, wall: -5.0, step: -0.1}。注意step奖励为负这是关键设计——它迫使智能体追求“最短路径”否则拖沓就会被扣分。你可以在config.py里把step改成0.0立刻看到智能体开始原地打转因为没动力赶路了。2.3 Q 表实现不是黑箱是带坐标的二维数组q_learning.py中的QTable类只有 3 个方法但每行都值得细读class QTable: def __init__(self, state_size, action_size, init_value0.0): self.table np.full((state_size, action_size), init_value) # 初始化全 0或全 1看 config def update(self, state, action, reward, next_state, done, alpha, gamma): current_q self.table[state, action] if done: target_q reward else: target_q reward gamma * np.max(self.table[next_state]) self.table[state, action] alpha * (target_q - current_q) def choose_action(self, state, epsilon): if np.random.random() epsilon: return np.random.randint(0, self.table.shape[1]) # 随机探索 else: return np.argmax(self.table[state]) # 贪心利用这段代码就是 Q Learning 的全部灵魂。注意update()方法中target_q的计算当doneTrue到达目标或掉坑target_q reward无后续状态否则target_q reward gamma * max_next_Q。这里np.max(self.table[next_state])就是贝尔曼最优方程的体现——我们不关心智能体下一步具体选哪个动作只取它在next_state下所有动作 Q 值的最大者。choose_action()的epsilon衰减逻辑不在这个类里而在QLearningAgent.train()中统一管理保证探索率随训练轮次下降。2.4 训练主循环main.py如何把算法、环境、可视化串成一条流水线main.py是胶水代码但它决定了你能否一眼看懂训练全过程。核心循环只有 25 行我把它拆解成三段逻辑# 1. 初始化 world GridWorld() agent QLearningAgent(state_size25, action_size4) episode_rewards [] # 2. 主训练循环500 轮 for episode in range(config.EPISODES): state world.reset() total_reward 0 for step in range(config.MAX_STEPS): action agent.choose_action(state, config.EPSILON) next_state, reward, done world.step(action) agent.update(state, action, reward, next_state, done) state next_state total_reward reward if done: break episode_rewards.append(total_reward) # 3. 每 50 轮保存一次 Q 表快照用于动画 if episode % 50 0: utils.plot_q_table(agent.q_table.table, fq_table_ep{episode}.png)关键点在于world.step(action)返回的是(next_state, reward, done)三元组完全符合 MDP 定义agent.update()接收这三元组加当前state/action内部调用QTable.update()完成一次贝尔曼更新total_reward累计每步reward构成后续收敛性判断依据。没有异步、没有多线程、没有经验回放——就是最朴素的 on-policy 更新。3. 运行指南从解压到看到热力图动画只需 4 步附常见报错排查别急着改代码先确保你能跑通 baseline。本项目对运行环境要求极低Python 3.7numpy ≥ 1.19matplotlib ≥ 3.3。Windows/macOS/Linux 均可无需 conda 或虚拟环境但建议用避免污染全局 pip。3.1 环境准备一行命令装完依赖打开终端Windows 用 PowerShellmacOS/Linux 用 bash进入解压后的项目根目录执行pip install numpy matplotlib注意不要pip install gym或pip install torch本项目明确不依赖它们。如果提示PermissionError在命令前加python -mpython -m pip install numpy matplotlib。3.2 启动训练main.py是唯一入口确保你在项目根目录能看到main.py的地方执行python main.py预期输出Episode 0: Total Reward -4.2 Episode 100: Total Reward 2.8 Episode 200: Total Reward 6.1 Episode 300: Total Reward 8.9 Episode 400: Total Reward 9.7 Episode 500: Total Reward 10.0 Training finished. Saving GIF... GIF saved as training_progress.gif同时目录下会生成q_table_ep0.png,q_table_ep100.png...q_table_ep500.png一系列图片以及最终的training_progress.gif。3.3 查看结果三类可视化文件各有什么信息training_progress.gif5 秒动画展示 Q 表从全 0 开始逐步在目标位置右下角附近亮起高值区域的过程。这是最直观的“学习发生了”的证据。q_table_ep*.png静态热力图横轴是 4 个动作U/D/L/R纵轴是 25 个状态0~24颜色越暖红表示该状态下选该动作的 Q 值越高。打开q_table_ep500.png你应该能看到第 24 号状态右下角目标格对应的所有动作 Q 值都很低因为到达后doneTrue不再更新而它上方23号、左方19号的状态对应“向下”和“向右”的动作 Q 值明显偏高——这就是学会“靠近目标”的证据。rewards_curve.png折线图X 轴是 episodeY 轴是每轮总 reward。理想曲线应从负值乱撞墙快速爬升至接近 10稳定抵达目标中间可能有平台期探索瓶颈但整体趋势必须上扬。3.4 常见问题排查这 4 个坑我当年都踩过注意以下问题均基于真实调试记录非凭空编造。现象、原因、解法一一对应可直接复制粘贴到你的终端验证。现象 1python main.py报错ModuleNotFoundError: No module named numpy原因numpy 未安装或安装在另一个 Python 环境比如你用conda创建了 env但终端激活的是 base。解决先执行which pythonmacOS/Linux或where pythonWindows确认当前 Python 路径再执行python -m pip list | grep numpy看是否列出。若无用python -m pip install numpy强制安装到当前 Python。现象 2GIF 生成失败报错FileNotFoundError: [Errno 2] No such file or directory: ffmpeg原因matplotlib 默认用 ffmpeg 渲染 GIF但系统未安装 ffmpeg。解决macOSbrew install ffmpegWindows去 https://ffmpeg.org/download.html 下载 zip解压后把bin/目录加入系统 PATHLinuxUbuntusudo apt update sudo apt install ffmpeg替代方案注释掉main.py末尾的utils.save_gif()调用只保留 PNG 生成。不影响算法验证。现象 3训练 500 轮后rewards_curve.png显示 reward 始终在 -3 ~ -1 波动从未超过 0原因config.py中EPSILON_DECAY设置过大如0.9999导致探索率衰减太慢智能体 500 轮内仍以随机为主或LEARNING_RATE过小如0.01Q 值更新像蜗牛。解决打开config.py将EPSILON_DECAY 0.995每轮乘 0.995500 轮后 ≈ 0.08LEARNING_RATE 0.8。重新运行观察 reward 是否在 200 轮后快速上升。现象 4q_table_ep500.png中目标格24号对应的动作 Q 值不是最低反而比旁边格子还高原因grid_world.py中GOAL坐标配置错误。默认是(4,4)0-indexed 第 5 行第 5 列但如果你手动改过WALLS可能把目标格误标为墙导致step()返回doneFalse智能体以为还没到继续更新 Q 值。解决打开grid_world.py找到GOAL (4, 4)确认该坐标不在WALLS列表中再检查self.grid[4][4]在__init__中是否被设为Ggoal而非Xwall。用print(world.grid)直接打印地图验证。4. 核心模块代码逐行解析q_learning.py中的贝尔曼更新到底在算什么光跑通不够得知道每一行在干什么。我们聚焦QTable.update()这个 10 行函数它是整个项目的心脏。下面我按执行顺序逐行解释其数学含义、参数来源、以及一个真实调试案例。def update(self, state, action, reward, next_state, done, alpha, gamma): current_q self.table[state, action] # L1 if done: target_q reward # L2 else: target_q reward gamma * np.max(self.table[next_state]) # L3 self.table[state, action] alpha * (target_q - current_q) # L44.1 L1current_q self.table[state, action]—— “此刻我认为这个动作值多少”state是整数0~24action是 0~3self.table是 25×4 的 numpy 数组。这行就是查表拿到智能体“当前相信”的 Q 值。例如当智能体在位置 12第 2 行第 2 列决定向上走action0current_q就是table[12,0]。这个值初始为 0随着训练逐渐变化。4.2 L2 L3target_q是贝尔曼方程的具象化——它回答“这个动作理论上应该值多少”L2 分支doneTrue智能体已抵达目标或掉坑没有下一步。此时target_q reward是唯一合理值。例如到达目标获10target_q10撞墙获-5target_q-5。L3 分支doneFalse还有未来。reward是本次动作的即时反馈如-0.1gamma * np.max(self.table[next_state])是对未来收益的折现估计。np.max(self.table[next_state])取next_state下所有动作 Q 值的最大者代表“如果我现在在next_state最优能拿到多少”。gamma0.95意味着我们相信未来收益只有现在的 95% 可靠——这是处理不确定性环境随机性、模型不完美的工程妥协。调试案例某次训练 reward 曲线卡在 5 不动。我在 L3 插入print(fnext_state{next_state}, max_q{np.max(self.table[next_state])})发现next_state23目标正上方时max_q始终为 0。追查发现grid_world.py中step(1)向下从 23 到 24 时done被错误设为False因为self._is_valid_position(4,4)返回False实际应为True。修复坐标判断后max_q迅速跳到 9.5reward 曲线当日突破 9。4.3 L4 alpha * (target_q - current_q)—— “我的认知偏差按比例修正”这是梯度下降思想的离散版。(target_q - current_q)是误差target 是理想值current 是当前值alpha是学习率控制每次修正的步长。alpha0.8意味着我们相信新信息占 80% 权重旧知识占 20%。如果alpha1.0则完全抛弃旧值用target_q覆盖如果alpha0.0则 Q 表永远不变。实践中alpha0.7~0.9收敛最快。4.4 关键参数影响实验改一个数看 Q 表怎么“长歪”在config.py中做三组对照实验每次只改一个参数运行python main.py后对比q_table_ep500.png参数Baseline实验 A实验 B实验 CLEARNING_RATE0.80.10.990.8GAMMA0.950.950.950.5EPSILON_DECAY0.9950.9950.9950.995实验 Aalpha0.1Q 表更新极慢q_table_ep500.png中高亮区域目标附近非常模糊颜色梯度平缓——因为每次只挪一点点500 轮不够“长到位”。实验 Balpha0.99Q 表出现“尖峰噪声”某些非目标格子如 10 号对应动作 Q 值异常高12远超 reward 上限 10——因为过度信任单次采样被偶然的高 reward 带偏。实验 Cgamma0.5Q 表高亮区域严重收缩只在目标格24和紧邻的 19、23 号格有明显红色更远的 14、18 号格 Q 值几乎为 0——因为gamma太小智能体只关心“下一步”不考虑“下下步”无法形成路径规划。这些实验印证了一个血泪经验超参数不是调优是定性。alpha决定收敛速度gamma决定视野长度epsilon_decay决定探索-利用平衡点。它们共同塑造 Q 表的“认知形状”。5. 二次开发实战如何把格子世界换成你自己的场景以“电梯调度”为例现在你已跑通、看懂、调过参下一步是让它为你干活。很多开发者卡在“怎么迁移到我的业务”——不是重写而是复用核心QTable和QLearningAgent只替换环境。下面以“电梯调度”为例演示如何 30 分钟内完成改造。5.1 场景抽象把电梯问题映射成 MDP 四要素状态State不能用(floor, direction)元组维度爆炸。我们简化state floor * 2 direction其中floor ∈ [0,1,2,3]4 层楼direction ∈ [0,1]0静止1运行共 8 个状态。动作Action[0:开门, 1:关门, 2:上行, 3:下行, 4:待机]—— 5 个动作。奖励Reward乘客上梯2鼓励接客乘客到达目标层5核心目标电梯空跑一层-0.5节能乘客等待超 30 秒-3惩罚服务差转移Transition由ElevatorEnv.step(action)实现需模拟楼层传感器、门控逻辑、乘客请求队列。5.2 代码改造3 个文件改 12 处新增 1 个类步骤 1新增elevator_env.py替代grid_world.pyimport numpy as np class ElevatorEnv: def __init__(self): self.floors 4 self.state 0 # 初始在 0 层静止 self.requests [] # [(floor, target), ...] self.waiting_time {} # {request_id: seconds} def reset(self): self.state 0 self.requests [] self.waiting_time {} return self._encode_state() def _encode_state(self): floor self.state // 2 direction self.state % 2 return floor * 2 direction def step(self, action): floor self.state // 2 direction self.state % 2 reward 0 done False if action 0: # 开门 if floor in [r[0] for r in self.requests]: # 有乘客要上reward 2 reward 2 # 移除该请求添加到目标队列 self.requests [r for r in self.requests if r[0] ! floor] elif action 1: # 关门 pass elif action 2 and floor self.floors-1: # 上行 self.state (floor1) * 2 1 reward - 0.5 elif action 3 and floor 0: # 下行 self.state (floor-1) * 2 1 reward - 0.5 elif action 4: # 待机 self.state floor * 2 0 # 方向置 0 # 检查是否到达乘客目标层 for r in self.requests[:]: if r[1] floor: reward 5 self.requests.remove(r) # 更新等待时间超时惩罚 for req_id in list(self.waiting_time.keys()): self.waiting_time[req_id] 1 if self.waiting_time[req_id] 30: reward - 3 del self.waiting_time[req_id] return self._encode_state(), reward, done def render(self): print(fElevator at floor {self.state//2}, direction {[stop,run][self.state%2]})步骤 2修改main.py替换环境初始化# 替换原来的 # from grid_world import GridWorld # world GridWorld() # 改为 from elevator_env import ElevatorEnv world ElevatorEnv()步骤 3修改config.py适配新状态/动作数# 原来 STATE_SIZE 25 ACTION_SIZE 4 # 改为 STATE_SIZE 8 # 4 floors * 2 directions ACTION_SIZE 5 # open/close/up/down/idle EPISODES 1000 MAX_STEPS 200提示ElevatorEnv中step()的逻辑比GridWorld复杂但核心没变——它仍返回(next_state, reward, done)。QLearningAgent和QTable完全不用动因为它们只认这三个返回值。5.3 验证技巧如何判断电梯调度真的学会了不要等 1000 轮后再看 reward 曲线。用utils.py的plot_q_table()直接观察 Q 表在main.py训练循环中加一句if episode % 100 0: utils.plot_q_table(agent.q_table.table, felev_q_ep{episode}.png)打开elev_q_ep1000.png检查当state00 层静止时action0开门的 Q 值是否最高——说明它知道“有人在 0 层等先开门”。当state63 层运行中且requests[(3,0)]时action3下行的 Q 值是否显著高于其他——说明它理解“目标是 0 层该往下走”。如果这些模式没出现问题一定在ElevatorEnv.step()的 reward 设计或状态转移逻辑上而不是 Q Learning 算法本身。6. 从那以后我每次做强化学习落地都强制走一遍“三问检查法”这个项目教会我的远不止 Q Learning 怎么写。它让我养成了一个硬习惯无论接手多复杂的 RL 项目哪怕是接 Gym 或 Ray RLlib在写第一行代码前我必问自己三个问题并用纸笔写下答案状态空间能不能用整数唯一编码如果答案是否定的比如状态是图像、文本、高维传感器数据那就别硬上 Q Table——立刻转向 DQN 或 PPO。本项目用0~24编码 5×5 网格是 Q Table 可行的铁律。你若把电梯状态写成(floor, direction, [queue])元组Q 表会爆炸这不是调参能救的。奖励函数是否满足“稀疏但可归因”“稀疏”指 reward 不是每步都发如step-0.1是合理的“每步都给 0.01” 就是灾难“可归因”指每个 reward 必须能精确绑定到某个动作或状态转移如“开门获 2”必须发生在action0且floor in requests时。本项目里撞墙-5、到目标10都是强信号。我曾在一个物流调度项目里把“订单完成”reward 错绑到“车辆出发”动作上导致模型疯狂派车却不送货——查了三天才发现 reward 时机错了。环境 reset/step/render 三接口能否脱离算法独立测试这是最容易被忽视的。我坚持写一个test_env.pyenv ElevatorEnv() s env.reset() print(Initial state:, s) for a in [0,2,2,0]: # 开门→上→上→开门 s, r, d env.step(a) print(fAction {a} - State {s}, Reward {r})如果这段脚本能稳定输出符合业务逻辑的序列如s从 0→2→4→4r有2和5才证明环境可信。否则所有算法训练都是空中楼阁。这三问是我从这个 200 行 Python 项目里抠出来的“后悔药”。它不炫技不堆库就用最原始的数组和循环逼你直面强化学习的本质状态、动作、奖励、转移——四者闭环缺一不可错一个全盘皆输。希望帮到你。本文还有配套的精品资源点击获取