简介:一份面向强化学习入门者的Python源码包,基于Deep Q-learning实现最短路径规划,适合学习DQN算法、路径搜索或完成课程设计的开发者。包内共有8个文件,包括6个Python脚本、1个Markdown说明文档和1个requirements依赖列表,压缩包仅7KB,轻量且易于运行。代码同时提供Q-learning与Deep Q-learning两种解法,内置工具函数、可视化模块与统一运行入口,可自由调整网格规模、起点终点及奖励参数,直观对比经典表格方法与深度网络的收敛表现。目前已有383人学习下载,适合作为从理论到实战的过渡参考。通过阅读与运行源码,可深入理解状态编码、奖励塑造、ε-greedy探索、经验回放和目标网络等强化学习关键环节,并结合可视化结果观察智能体逐步找到最短路径的过程。README文档和依赖清单可帮助快速搭建运行环境,节省配置时间。
1. 最短路径问题,为什么值得用强化学习再做一遍
如果你写过一次 Dijkstra,大概会觉得最短路径已经被前人解完了,没必要再用神经网络折腾一遍。但这个项目偏偏反着来:它把“找最短路径”重新定义成一个强化学习任务——智能体不知道地图长什么样,靠一次次试错、撞墙、绕路,最终学会从起点走到终点的最短路线。Deep Q-learning 在其中替代了传统的 Q 表,用神经网络去逼近状态-动作的价值,这正好是强化学习里最经典的实战入门场景。
项目代码量不大,主文件三个,外加一个可视化模块,对刚接触强化学习、想做点能跑能看的实验的人来说非常合适。你既能从 QLearning 版本理解表格型强化学习的完整流程,也能在 DeepQlearning 版本里看到神经网络如何接管决策。整份源码就是一份最小可复现的“深度强化学习最短路径”研究型代码,适合用来做算法对比、调参练习,或者作为把强化学习落地到路径规划问题的起点。
2. 项目骨架梳理:从目录结构认识每个模块的边界
拿到压缩包之后,建议先别急着跑Run.py,先把文件之间的调用关系确认一遍。这个项目结构不复杂,但每个模块的分工是清楚的:入口、算法、工具函数、可视化各自独立,改造时不用牵一发动全身。
2.1 目录结构与职责分配
解开压缩包后看到的核心文件是这样的:
RL-ShortestPath-master/ ├── README.md ├── requirements.txt ├── Run.py ├── ShortestPathQLearning.py ├── ShortestPathDeepQlearning.py ├── Visualizations.py └── utils/ ├── __init__.py └── Utilities.py从文件命名就能看出模块边界:Run.py是程序入口,负责编排整个训练流程;ShortestPathQLearning.py实现经典的表格型 Q-learning;ShortestPathDeepQlearning.py是深度版本,用神经网络近似 Q 值;utils/Utilities.py负责构造网格环境、定义动作、计算奖励这类公共逻辑;Visualizations.py把训练过程和路径画出来。README 和 requirements.txt 分别承担说明和依赖管理。
这种拆分方式很轻量,比把几百行代码全塞进一个文件里好维护得多。如果要做二次开发,核心工作基本集中在Utilities.py和两个算法文件里,入口和可视化基本不用动。
2.2 Run.py:全局训练流程的编排入口
Run.py是整个项目最先被执行的脚本。打开之后可以看到典型的“参数定义 → 环境初始化 → 训练 → 可视化”流程。粗略读下来的逻辑是这样:
from utils.Utilities import GridWorld from ShortestPathDeepQlearning import DeepQAgent from Visualizations import plot_training_rewards, plot_path def main(): # 地图与起点终点参数 grid = GridWorld(width=8, height=8, obstacles=[...]) agent = DeepQAgent( state_size=2, action_size=4, gamma=0.95, epsilon=1.0, epsilon_decay=0.995, learning_rate=0.001 ) rewards_history = [] for episode in range(500): state = grid.reset() done = False total_reward = 0 while not done: action = agent.choose_action(state) next_state, reward, done = grid.step(action) agent.remember(state, action, reward, next_state, done) agent.replay(batch_size=32) state = next_state total_reward += reward rewards_history.append(total_reward) plot_training_rewards(rewards_history) if __name__ == "__main__": main()这个入口层面对用户只暴露两个关键对象:GridWorld负责环境,DeepQAgent负责决策。训练主循环只做三件事:环境推进、经验收集、经验回放。参数集中在 main 开头,想换地图尺寸、调整邻居数量、改障碍位置,都只需要在这里动几行。
这里有个细节值得注意:state_size=2,说明状态不是网格坐标的一维展开,而是直接传入了(x, y)二维坐标。对小型网格这种表达够用,也符合直觉。后面如果要扩展到更大的地图,可以考虑加入目标点相对坐标、周围障碍信息等特征。
2.3 utils/Utilities.py:网格环境如何被抽象成强化学习接口
Utilities.py承担的是环境角色。强化学习环境的标准动作是reset和step,这个文件围绕这两个方法设计了网格世界的交互规则。
class GridWorld: def __init__(self, width=8, height=8, start=(0,0), goal=(7,7), obstacles=None): self.width = width self.height = height self.start = start self.goal = goal self.obstacles = obstacles or [] self.agent_pos = start def reset(self): self.agent_pos = list(self.start) return self.agent_pos def step(self, action): # 0: up, 1: down, 2: left, 3: right moves = [(-1,0), (1,0), (0,-1), (0,1)] dx, dy = moves[action] new_x = self.agent_pos[0] + dx new_y = self.agent_pos[1] + dy # 边界检测 if new_x < 0 or new_x >= self.width or new_y < 0 or new_y >= self.height: return self.agent_pos, -1, False # 障碍检测 if (new_x, new_y) in self.obstacles: return self.agent_pos, -1, False self.agent_pos = [new_x, new_y] # 到达终点 if tuple(self.agent_pos) == self.goal: return self.agent_pos, 10, True # 普通移动 return self.agent_pos, 0, Falsestep的返回值是一个四元组:新状态、即时奖励、是否结束。动作空间是离散的四方向移动,撞墙和撞障碍都返回 -1 作为惩罚,到达终点返回 +10 并终止回合,普通移动返回 0。这个奖励设计非常直觉,也让智能体必须学会“绕开障碍、直奔终点”。
需要留意的设计点是:碰撞时self.agent_pos没有更新,但返回值里带的是当前原地位置,也就是说智能体会明确收到“这个方向走不出去”的负反馈。这个设计比“原地不动但给正惩罚”更清晰,对于 Q 值学习也更友好。
3. 核心算法拆解:从 Q 表到深度网络的每一段关键逻辑
这一章是全文的核心。只有理解了 Q-learning 到 Deep Q-learning 的演进逻辑,才能知道改哪里、怎么调。项目把两个算法放在独立文件里,天然形成了一组对照实验。
3.1 QLearning 基线:表格型强化学习如何更新价值
ShortestPathQLearning.py里维护着一张 Q 表,表的每一行是状态(即网格坐标),每一列是四个动作。每次转移后,用贝尔曼方程更新 Q 值:
def update_q_table(self, state, action, reward, next_state, done): best_next_q = np.max(self.q_table[next_state[0], next_state[1], :]) td_target = reward + self.gamma * best_next_q * (1 - done) td_error = td_target - self.q_table[state[0], state[1], action] self.q_table[state[0], state[1], action] += self.learning_rate * td_error代码逻辑是典型的时间差分(TD)更新:先用下一状态的最大 Q 值计算目标值,再用目标值减去当前 Q 值得到 TD 误差,最后乘学习率做增量更新。done时best_next_q被置零,避免把终止状态的价值继续往后传播。
表格型 Q-learning 在这个小网格里跑起来效果很好,收敛也快。但它的问题直接暴露了两个:一是状态一旦变多(比如网格 100×100),Q 表规模就爆炸;二是表格无法泛化,没走过的状态永远没有值。这就是为什么需要深度网络版本。
3.2 Deep Q-learning 的模型结构:神经网络如何替代 Q 表
ShortestPathDeepQlearning.py的核心区别在于不再存表格,而是训练一个神经网络,输入状态、输出每个动作的 Q 值估计。模型结构并不复杂:
def build_model(self): model = Sequential() model.add(Dense(24, input_dim=2, activation='relu')) model.add(Dense(24, activation='relu')) model.add(Dense(4, activation='linear')) model.compile(optimizer=Adam(lr=self.learning_rate), loss='mse') return model状态是两个浮点数(x 坐标和 y 坐标),输入层维度 2;中间两层各 24 个神经元;输出层维度 4,对应四个动作的 Q 值。损失函数选均方误差,优化器用 Adam,这是深度强化学习里最常见的一套配置。
为什么中间层用 24 而不是更大的数字?对小网格环境,24 个神经元的表达能力已经足够拟合 Q 函数的分布;调大后反而容易过拟合到训练轨迹上。如果地图改到 20×20 以上,我会先把神经元加到 64 再观察收敛速度。需要强调的是,这里用linear激活而非relu,因为 Q 值需要输出负数和正数,最后一层做线性映射是标准做法。
3.3 经验回放:让训练数据不再是马尔可夫链
深度 Q-learning 里有个经典问题:直接从环境中逐条拿数据训练,样本之间高度相关,网络容易在局部震荡。项目里的解决办法是维护一个经验池,随机采样打破相关性:
def remember(self, state, action, reward, next_state, done): self.memory.append((state, action, reward, next_state, done)) if len(self.memory) > self.memory_size: self.memory.pop(0) def replay(self, batch_size=32): if len(self.memory) < batch_size: return batch = random.sample(self.memory, batch_size) for state, action, reward, next_state, done in batch: target = reward if not done: target += self.gamma * np.max(self.model.predict(next_state.reshape(1,2))[0]) target_f = self.model.predict(state.reshape(1,2)) target_f[0][action] = target self.model.fit(state.reshape(1,2), target_f, epochs=1, verbose=0)这里执行了predict和fit交替的操作:先预测当前状态下的四动作 Q 值,只把被选动作的 Q 值替换成 TD 目标,其余保持不变,然后做一次梯度下降。replay方法里的逻辑看起来是在循环里逐个样本训练,实际操作时也可以改成批量计算,项目里为了简单直观采用了逐样本更新。
经验池上限和批次大小直接决定训练效果。我一般会把memory_size设为 2000、batch_size设为 32;如果地图复杂化了,批次可以提到 64。pop(0)清掉最老经验的方式是环形队列的朴素实现,数据量大之后效率稍差,但小项目里完全够用。
这里有个陷阱要提醒:如果replay里不判断len(self.memory) < batch_size,前几十回合直接崩掉。这也是我最初看到代码时要确认的第一个点。项目里做了这个保护,但自己复写时很容易漏掉。
3.4 探索策略:epsilon 贪婪如何平衡“探路”和“走熟路”
强化学习必须解决探索与利用的矛盾。项目采用 epsilon-greedy 策略:大部分时候选择当前 Q 值最大的动作,但有 epsilon 概率随机走一步。epsilon 随训练过程逐步衰减:
def choose_action(self, state): if np.random.rand() < self.epsilon: return np.random.randint(self.action_size) state = state.reshape(1, 2) q_values = self.model.predict(state)[0] return np.argmax(q_values)epsilon_decay=0.995的含义是每个回合结束后乘以 0.995,500 回合后 epsilon 大约是1.0 * 0.995^500 ≈ 0.081,已经很低,说明智能体主要靠学到的策略走路径。衰减速率如果设置太慢(比如 0.999),后期仍然在随机探路,路径会不停抖动;太快(比如 0.9)则训练不够,网络学不到完整地图。
跑这个项目时,如果想加速看结果,可以把epsilon_decay调成 0.995 左右,训练 300 回合就能看到智能体稳定走通。如果调成 0.99,300 回合后 epsilon 只有 0.049,明显更“专一”。
3.5 Visualizations.py:训练曲线与路径复现
Visualizations.py负责把训练效果转成直观图像。项目里我拆出两个主要函数:一个画每回合累计奖励的曲线,另一个把最终学习到的路径标注在网格上。
def plot_path(grid, path, title="Learned Path"): fig, ax = plt.subplots() grid_matrix = np.zeros((grid.height, grid.width)) for (x, y) in grid.obstacles: grid_matrix[x, y] = -1 ax.imshow(grid_matrix, cmap="gray", origin="upper") path_x = [p[0] for p in path] path_y = [p[1] for p in path] ax.plot(path_y, path_x, marker="o", color="red", linewidth=2) ax.scatter([grid.goal[1]], [grid.goal[0]], c="green", s=200) ax.set_title(title) plt.show()坐标轴的处理稍微有点绕:imshow默认把数组第一维当 y 轴,所以画路径时要用path_y作为 x、path_x作为 y。如果直接按直觉传坐标,画出来的路径会是镜像的。这个坐标换位问题也在我的踩坑记录里占了一条。
4. 环境准备与运行:从 requirements 到第一张训练曲线
想把项目跑起来并不难,真正容易翻车的是依赖版本和 Python 环境。这个项目没有特别新的依赖,但有个别版本组合会踩坑。
4.1 环境安装与依赖检查
先看requirements.txt,里面列的通常是这几项:numpy、matplotlib、tensorflow 或 keras、scikit-learn(用于数据预处理)。我的建议是新建一个干净的 virtualenv 再装:
python -m venv rl_env source rl_env/bin/activate pip install -r requirements.txt如果之前机器上装了最新版 TensorFlow(2.10+),跑往期代码时偶尔会遇到 Keras API 兼容警告,但这个小项目用到的Sequential、Dense、Adam都是稳定 API,基本不会出问题。真正要留意的是 Python 版本。TensorFlow 对 Python 3.11 的支持本身就有限制,建议直接用 3.9 或 3.10。
4.2 标准运行流程
环境配好后,直接执行:
python Run.py正常情况下,脚本会边训练边输出每个回合的奖励值,训练结束后弹出两张图:一张是奖励随回合变化的曲线,另一张是智能体最终走出的路径。第一张图值得仔细看:如果曲线前期在负分附近徘徊、后期跳到正分,说明智能体在学习;如果全程基本没有波动,那大概率是探索率或者奖励设计的问题。
启动后可以盯一下前 50 回合的输出。初期智能体基本是乱撞的,累计奖励会持续为负;到了某个回合数,累计奖励突然变正并稳定,说明它找到了从起点到终点的可行路径。这个“顿悟”时刻的早晚,直接反映参数设置是否合理。
4.3 参数调整的优先级
如果发现训练效果不理想,我按影响程度排序会先调这几个参数:
| 参数 | 当前值 | 调整方向 | 影响 |
|---|---|---|---|
| learning_rate | 0.001 | 0.0001~0.01 | 过大不收敛,过小收敛慢 |
| gamma | 0.95 | 0.9~0.99 | 越小越只顾眼前,越大越看全局 |
| epsilon_decay | 0.995 | 0.99~0.999 | 决定探索和利用的切换速度 |
| batch_size | 32 | 16~64 | 影响梯度估计的稳定性 |
调参原则是每次只动一个变量。如果你同时调了 gamma 和 epsilon_decay,事后很难定位是哪个参数导致的失败。我会把每次实验的参数记录在文件名里,比如run_gamma099_decay0995.png,后面回溯时省力很多。
5. 避坑与排查:训练不收敛、路径异常时的小白自救指南
这个项目我自己跑第一遍时就出了一堆问题,下面这几条是最常遇到的。每条都是“现象、原因、解决”三段式,照着排查能少走不少弯路。
5.1 训练进度条跑完,但智能体永远到不了终点
现象是最直接的:训练了 500 回合,可视化路径还是一团乱麻,奖励曲线一直在负分区域徘徊,终点从未被激活。
原因是奖励稀疏加上惩罚过重。撞墙 -1、普通移动 0、到达终点 +10,这种设置下智能体随机走了几百步,累计惩罚就非常低。更关键的是到达终点的概率极低,在探索率较高时这个 +10 的奖励根本无法被捕获。
解决方法是引入“靠近目标的正向塑形奖励”:把上一步距离终点的曼哈顿距离和当前步的距离做差,距离缩短就给一个小正奖励,拉远就给负奖励:
distance = abs(x - goal_x) + abs(y - goal_y) reward = 0.1 * (prev_distance - distance) if reward < 0: reward = -0.1这样智能体能逐步建立“越走越近”的梯度信号,训练效率提升一个量级。
5.2 障碍物周围路径出现抖动
现象:智能体虽然能走到终点,但会贴着障碍物绕圈,或者明明有一条直路不走,偏要绕远。
原因是障碍物边界处 Q 值不平稳。撞到障碍返回 -1 的惩罚和正常路径的 0 奖励之间缺少过渡,网络在障碍边界附近会出现价值震荡。加上坐标型状态没有“前方是否有障碍”的信息,智能体只能靠试错记住边界。
我尝试过在状态里增加“前方三格是否有障碍”的标志位,状态从二维变成五维,抖动明显缓解。这个改动在Utilities.py里加一个返回障碍邻接信息的函数即可。
5.3 路径可视化出来是镜像的
现象:明明智能体从左上角走到了右下角,画出来却是反向的,或者路径里的坐标和网格对不上。
原因是imshow的坐标轴方向和网格数组的行列方向不一致。网格第 0 行在图像顶部,但很多人在画图时把 x 坐标直接透传给plot,导致上下翻转。
解决方法是统一坐标约定。我当时最简单的修法是在plot_path里做一次坐标转置,或者直接在GridWorld内部把坐标和矩阵索引的映射关系注释清楚,状态图上始终用plt.gca().invert_yaxis()保证数据显示和视觉一致。
5.4 epsilon 衰减到 0 后仍然随机乱走
现象:训练到后期,epsilon 已经降到 0.01,但每回合路径仍然不同,不收敛到一个稳定解。
原因是 epsilon 只控制随机动作概率,但choose_action里用的是单次最大 Q 值。如果两个动作的 Q 值非常接近,即使没有随机探索,argmax也可能在不同回合做出不同选择。说白了是 Q 函数在两条路径上的价值差异太小,网络没有完全分辨出“这条路更短”。
解决方法是拉大奖励差异,把最短路径奖励设为 +10,非最短路径设为 +5;或者提高 gamma,让折扣累积奖励更能反映路径长短差异。
5.5predict和fit的维度不匹配
现象:训练跑到几十个回合后,报维度错误,ValueError提示输入形状不对。
原因是state.reshape(1, 2)传入的是一维坐标,但如果某处state被当成四维状态传入,input_dim就匹配不上。这类问题多发生在改了状态表示但忘了同步修改build_model的input_dim的时候。
解决方法是给状态表示定义一个统一函数,比如state_to_feature(x, y),整个项目的所有模块只调这个函数。这样改状态设计时,只需要动这一个函数。
6. 二次开发扩展:把玩具网格改造成实用路径规划器
跑通只是第一步。这个项目的价值在于环境和算法分离,二次开发的空间很大,改起来也不算难。
6.1 替换环境:从网格到图结构
网格世界适合教学,但实际路径规划场景更多是图结构。你可以把GridWorld换成图搜索环境,节点是坐标点,边是连通路径:
class GraphWorld: def __init__(self, nodes, edges, start, goal): self.graph = {node: [] for node in nodes} for u, v in edges: self.graph[u].append(v) self.graph[v].append(u) def step(self, action): next_nodes = self.graph[tuple(self.agent_pos)] next_state = next_nodes[action] ...状态从二维坐标变成节点编号,动作从四方向变成邻居选择。DeepQAgent里的state_size也要相应调整,其他部分基本复用。这就是模块化设计带来的红利。
6.2 自定义奖励函数:让智能体学会“最短”
项目中到达终点给固定 +10 的奖励设计不够精细,因为无论走 15 步还是 25 步拿到的奖励一样。想让它学出最短路径,可以把奖励改为与步数相关:
if reached_goal: reward = 20 - step_count * 0.1 elif hit_obstacle: reward = -1 else: reward = -0.02每一步扣 0.02,到终点时步数越少奖励越高。这样即使两条路径都能到达终点,Q 值也会偏向更短那条。注意每步的小惩罚要设置得足够小,否则智能体为了少走步数会想办法反复尝试高风险动作。
6.3 训练过程监控:保存模型与回放轨迹
训练过程的监控能显著提升调参效率。我通常会在训练循环中加一个模型保存点,每 50 回合记录一次权重文件,方便对比不同阶段的决策变化:
if episode % 50 == 0: agent.model.save(f"model_ep{episode}.h5")再配合Visualizations.py,可以额外绘制一张“每 50 回合的路径对比图”,直接观察探索导致的路径变化率。从实践角度看,这个项目值得注意的一点是:如果你的地图比 8×8 更大,上面提到的 24 神经元隐藏层可能不够用,建议先扩到 64 再训练,并在训练初期观察前 100 回合的奖励曲线是否出现上升趋势。
最近我再跑这个项目时,习惯在choose_action里把 Q 值最大的动作和随机动作各自标记不同颜色,画在同一张图上。这样能直观看到探索和利用的切换时机,比只看奖励曲线有用得多。如果你打算在这个项目上做实验,强烈建议把这个“探索轨迹 vs 利用轨迹”的对比可视化加进去,那会让你对强化学习收敛过程的理解深一个台阶。
希望这些拆解和改造思路能帮到你。
本文还有配套的精品资源,点击获取