简介:基于深度强化学习的最短路径求解Python源码,直观展示Deep Q-learning在导航与寻路场景中的建模与训练流程,适合学习强化学习基础、了解Q-learning与DQN过渡的开发者。压缩包内含8个文件,6个py脚本分别承担环境构建、Q-learning与Deep Q-learning训练、结果可视化和运行入口等角色,配1个Markdown说明文档讲解实现思路,1个requirements.txt管理依赖,整套代码仅7KB,结构紧凑便于精读。目前已有382人学习下载。通过对照代码中的QLearning与DeepQlearning实现,可深入理解状态与动作空间设计、奖励函数如何引导智能体靠近终点、epsilon-greedy探索策略,以及经验回放与目标网络对训练稳定性的作用;同时可随意改动地图布局或超参数复现实验。对正在从经典Q-learning迈向深度Q-network的读者来说,这份材料是很有价值的动手参考,无论是课程设计还是毕业设计都能直接引入。
1. 从最短路径到深度强化学习:为什么传统算法会被DRL替代
基于深度强化学习的最短路径问题,这几年在AGV调度、仓储机器人导航和自动驾驶决策里反复出现。我最早是在一个动态货架区的AGV项目里遇到这个需求:要用一套Python源代码,让小车从库位A走到库位B,路径要短,但货架会挪动,地图会变化。传统的最短路径算法每次都要重新建图、重新搜索,网格一多就很吃力;深度强化学习把走路径改成一个状态做一次决策,让智能体在试错中学会“走一步看全局”的策略。这篇文章从MDP建模、DQN代码实现、训练调参到避坑,完整给出一条可复现的落地路径。适合正在做路径规划但被动态环境折磨的工程师,也适合拿强化学习入门控制问题的读者。
2. 把最短路径写成MDP:状态、动作、奖励与Python环境搭建
2.1 最短路径问题的MDP建模:状态与动作怎么定义
传统最短路径算法(Dijkstra、A*)的前提是图结构已知且基本静态。深度强化学习的思路是反过来的:我并不知道“全局最优在哪”,但我可以在每个位置做动作,然后根据环境的反馈学会哪个动作更接近目标。这个思路对应到马尔可夫决策过程(MDP)里,五个要素是状态、动作、转移概率、奖励、折扣因子。对最短路径而言,转移概率通常由地图决定,比如你往上走一步,只要没撞墙就必然到达新位置;至于状态和动作,完全由问题定义决定。
我在网格环境里做的最短路问题,状态一般包含两部分:智能体当前位置和目标位置。只给当前位置不够,因为同样的位置,目标不同,走的策略也不同。常见做法是把当前位置和目标位置拼接成一个向量,或者用两个独立通道喂给网络。如果你处理的是图结构,那么状态就是一个节点ID加上目标节点ID,再配合一张用Python构建邻接矩阵生成的图。动作的定义有两种,一种是离散的四向或八向移动,另一种是直接选择邻居节点。四向移动适合网格,邻居选择适合抽象图。
转移概率这件事别忽略。如果你让智能体“撞墙后留在原地”,那么转移概率就带“自环”,这对训练不是坏事,反而能避免走出地图。但如果你想让探索更接近真实导航,可以把撞墙看作无效动作,让智能体原地不动并给一个小的负奖励。我一般用后一种,因为它在动态环境下更稳。
2.2 用Python构建网格世界环境:核心代码与边界处理
在Python里写一个最短路径环境,最直接的做法是继承OpenAI Gym(Gymnasium)的Env接口,这样后面接任何深度强化学习算法包都不需要改接口。下面的代码是一个10x10网格,起点在左上角,终点在右下角,中间随机放一些障碍物。
import numpy as np import gymnasium as gym from gymnasium import spaces class GridWorldShortestPath(gym.Env): def __init__(self, grid_size=10, obstacle_ratio=0.2, max_steps=100): super().__init__() self.grid_size = grid_size self.max_steps = max_steps # 动作: 0上, 1下, 2左, 3右 self.action_space = spaces.Discrete(4) # 状态: (agent_x, agent_y, target_x, target_y) 归一化到[0,1] self.observation_space = spaces.Box( low=0, high=1, shape=(4,), dtype=np.float32 ) # 随机生成障碍物 self.obstacles = set() while len(self.obstacles) < int(grid_size * grid_size * obstacle_ratio): x = np.random.randint(0, grid_size) y = np.random.randint(0, grid_size) if (x, y) == (0, 0) or (x, y) == (grid_size - 1, grid_size - 1): continue self.obstacles.add((x, y)) def reset(self, seed=None): super().reset(seed=seed) self.agent_pos = np.array([0, 0], dtype=np.int32) self.target_pos = np.array( [self.grid_size - 1, self.grid_size - 1], dtype=np.int32 ) self.steps = 0 return self._get_obs(), {} def step(self, action): # 根据动作计算新位置 delta = {0: (-1, 0), 1: (1, 0), 2: (0, -1), 3: (0, 1)}[action] new_pos = self.agent_pos + np.array(delta) # 边界与障碍物处理:撞墙/撞障碍物就留在原地 if (0 <= new_pos[0] < self.grid_size and 0 <= new_pos[1] < self.grid_size and tuple(new_pos) not in self.obstacles): self.agent_pos = new_pos self.steps += 1 # 奖励: 到达目标 +1, 撞墙 -0.1, 其他 -0.01(步数惩罚) reward = -0.01 terminated = False truncated = False if tuple(self.agent_pos) == tuple(self.target_pos): reward = 1.0 terminated = True elif (self.steps >= self.max_steps): truncated = True return self._get_obs(), reward, terminated, truncated, {} def _get_obs(self): # 把坐标归一化到[0,1],方便网络收敛 norm = self.grid_size - 1 return np.array([ self.agent_pos[0] / norm, self.agent_pos[1] / norm, self.target_pos[0] / norm, self.target_pos[1] / norm ], dtype=np.float32)代码的逻辑很直白:reset把智能体放回左上角,step根据动作更新位置,边界和障碍物都通过同一个if判断挡住。观察值归一化在0到1之间,这个细节对深度网络很重要,很多新手栽在状态量纲不统一上,导致训练震荡。奖励里我给的是稀疏奖励加步数惩罚,目的是让模型学会“少走弯路”,否则它可能会围着目标绕圈。
2.3 奖励设计的两种做法:稀疏奖励与势能塑形
上面代码用的奖励就是稀疏奖励加步数惩罚。实际项目中只靠终点+1的稀疏奖励,模型在小地图上能勉强学会,网格一大学习效率就很低。这时候常见做法是奖励塑形(reward shaping),核心思想是给中间过程一些“临时反馈”。我一般用基于距离的势能函数:如果这一步让智能体离目标更近了,就多给一点正奖励;离远了,就给负奖励。
def get_shaped_reward(self, prev_dist, curr_dist): # prev_dist/curr_dist 是到目标点的曼哈顿距离 if curr_dist < prev_dist: return 0.1 elif curr_dist > prev_dist: return -0.1 return -0.01需要说明的是,奖励塑形做不好会引入“抄近路”行为,比如模型故意绕远路去吃奖励。理论上有一种做法(势能函数塑形)可以保证不改变最优策略,前提是塑形函数必须是势能函数差,也就是 reward_shaping = gamma * phi(s') - phi(s),其中phi可以是目标距离的负值。实际工程项目里,我通常直接用距离差值作为塑形,简单管用,但你要知道这个近似是有理论风险的。如果要求严格,就按势能差的形式写,代码里只是把距离替换成phi而已。
这一章的代码可以从最简单的稀疏奖励跑起,先确认环境没问题,再切换成势能塑形。环境写好之后,下一步就是智能体本身了。
3. DQN算法落地:从神经网络到经验回放的完整Python实现
3.1 选DQN还是Double DQN:算法选型的现实考量
深度强化学习算法家族很大,但最短路径这种离散动作、状态维度不高的场景,DQN是最稳的起点。DDPG、PPO这些算法要么针对连续动作,要么实现复杂,训练时间更长。DQN的核心是用一个神经网络近似Q值,再用经验回放和固定目标网络来稳定训练。
我一般会直接用Double DQN,而不是普通DQN。原因是普通DQN会高估Q值,在路径规划里,高估会让模型过于自信,明明前面是墙它也敢撞。Double DQN用两个网络分别做“选动作”和“估计价值”,有效降低高估。实现成本只多了一个target_network和几行代码,不值得在不确定性上省。
3.2 网络结构与Python代码:一个可跑的基线模型
对于4维状态输入、4维动作输出,一个两层全连接网络就够。不需要一上来就堆LSTM或Transformer。网络中间的激活函数用ReLU,输出层不用激活,因为Q值可以是负数。
import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, state_dim=4, action_dim=4, hidden_dim=64): super().__init__() self.fc = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.fc(x)这个网络很小,但足够处理10x10网格,甚至50x50网格也能凑合。如果你的地图状态里有障碍物信息(比如用one-hot编码的网格图),就得改输入层,用卷积网络或直接把网格矩阵展平。参数上hidden_dim=64是最低配;状态复杂时我会调到128或256,同时把batch size调大。
3.3 训练循环与经验回放:每一行代码在做什么
经验回放是DQN的灵魂。如果不做回放,连续样本之间高度相关,网络更新会来回震荡。下面这段Python代码是一个可运行的DQN训练循环骨架。
import random from collections import deque import torch.optim as optim replay_buffer = deque(maxlen=20000) batch_size = 64 gamma = 0.99 epsilon = 1.0 epsilon_min = 0.01 epsilon_decay = 0.995 sync_freq = 500 q_net = QNetwork() target_net = QNetwork() target_net.load_state_dict(q_net.state_dict()) optimizer = optim.Adam(q_net.parameters(), lr=1e-3) def sync_target(): target_net.load_state_dict(q_net.state_dict()) env = GridWorldShortestPath(grid_size=10) for episode in range(2000): obs, _ = env.reset() done = False total_reward = 0 while not done: # epsilon-greedy探索 if random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): q_values = q_net(torch.as_tensor(obs, dtype=torch.float32)) action = q_values.argmax().item() next_obs, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated total_reward += reward # 存经验 replay_buffer.append((obs, action, reward, next_obs, done)) obs = next_obs # 采样更新 if len(replay_buffer) >= batch_size: batch = random.sample(replay_buffer, batch_size) s = torch.tensor([b[0] for b in batch], dtype=torch.float32) a = torch.tensor([b[1] for b in batch], dtype=torch.long) r = torch.tensor([b[2] for b in batch], dtype=torch.float32) s_next = torch.tensor([b[3] for b in batch], dtype=torch.float32) d = torch.tensor([b[4] for b in batch], dtype=torch.float32) # Double DQN: 用当前网络选动作,目标网络算Q值 next_actions = q_net(s_next).argmax(dim=1) q_next = target_net(s_next).gather(1, next_actions.unsqueeze(1)).squeeze(1) q_target = r + gamma * q_next * (1 - d) q_pred = q_net(s).gather(1, a.unsqueeze(1)).squeeze(1) loss = nn.MSELoss()(q_pred, q_target) optimizer.zero_grad() loss.backward() optimizer.step() if episode % sync_freq == 0: sync_target() epsilon = max(epsilon_min, epsilon * epsilon_decay)这段代码里最关键的两个参数是gamma和sync_freq。gamma越接近1,智能体越看重远期收益,路径规划里一般设0.99;如果设太小,智能体会为了眼前的负奖励而不敢绕远路。sync_freq控制目标网络多久同步一次,太频繁等于没有目标网络,太稀疏又会让目标网络和当前网络差距过大。经验回放队列maxlen=20000是对10x10地图合适的容量,地图再大需要加容量,否则旧经验覆盖太快。
3.4 从DQN到Double DQN:避免Q值高估的实际改动
普通DQN的target直接取target_net(s_next)的最大值,这个max操作会带来系统性高估。改成Double DQN只需要几行代码,上面训练循环里已经写了,思路是:先用q_net对下一个状态选出最优动作,再用target_net计算这个动作的Q值。这样把“选动作”和“评估动作”解耦,在路径规划里能明显减少“因为高估而撞墙”的情况。
我实际使用时不希望硬同步太突然,所以常用soft update:每次更新时让target_net参数向q_net滑动一点,比例tau=0.005。代码是把sync_target换成:
def soft_sync_target(tau=0.005): for t, q in zip(target_net.parameters(), q_net.parameters()): t.data.copy_(tau * q.data + (1 - tau) * t.data)注意soft update后就不需要sync_freq了,每个训练步都会做一次。这个做法让目标网络始终“慢半拍”,训练更平滑,但也要付一点计算代价。对于网格最短路这种规模,一般感受不到差别,大图上建议用soft update。
3.5 探索策略与经验质量:让模型不靠运气找到最优路径
ε-greedy是最原始的探索方式,但epsilon衰减速度影响很大。路径规划里最优路径往往只有一条,太早关闭探索会陷在次优策略。我习惯把epsilon_decay设成0.995,并且保证至少训练1000个episode再看到明显成功率。
另一个容易忽略的点是replay buffer里的经验质量。训练早期,buffer里全是随机动作产生的废样本,网络要从里面学出“走到终点”的信号很难。我自己常用的一个技巧是先用Dijkstra生成一批专家轨迹,把“动作-状态-奖励”样本预填充进replay_buffer,让网络一开始就知道“朝目标走是好的”,然后接着跑正常探索。这个预热样本池一般占buffer的20%就够,千万别让专家经验比重过大,否则模型会失去探索能力。
4. 训练稳定下来的关键:参数调节与评估指标
4.1 超参数表:学习率、批大小、目标网络更新频率怎么配
深度强化学习训练不收敛,九成是超参数问题而不是代码问题。下面是我在网格最短路径上常用的一组参数,可以直接作为起点。
| 参数 | 推荐值 | 调节方向 |
|---|---|---|
| 学习率 lr | 1e-3 | 训练震荡就降到3e-4,收敛慢就调到3e-3 |
| batch_size | 64 | 状态维度高或地图大时调到128/256 |
| replay_buffer容量 | 20000 | 动态障碍物多时加大到50000 |
| gamma | 0.99 | 路径长度要求更高时用0.995 |
| sync_freq | 500 | Q值震荡时降到200或300 |
| epsilon_decay | 0.995 | 想更快收敛就设0.99,但可能过早收敛到次优 |
| 网络hidden_dim | 64 | 网格大于20x20时调到128或256 |
学习率是最容易出事的。我见过很多项目把lr设成1e-2,结果loss直接爆表;也别一上来就用Adam默认1e-3,有时候3e-4更稳。batch_size太小会让梯度估计噪声大,太大又拖慢更新。sync_freq也是一个玄学参数,不同任务差异很大,建议做成可配置项,调参时先固定其他变量再动它。
4.2 评估模型:用成功率与路径长度偏差说话
训练日志里loss下降不代表策略好,因为Q值逼近是一个间接目标。我评估最短路径模型时只看两个指标:成功率和路径长度偏差。成功率就是智能体从起点出发,在最大步数内到达终点的回合比例。路径长度偏差是实际路径长度与最短路径长度(用Dijkstra算出来的)的差值,这个指标才真正体现“最优性”。
比如10x10网格,无障碍时最短路径是18步,模型走了20步,偏差就是2。无障碍时偏差应该稳定为0;有障碍时,只要模型能绕过障碍,偏差小于3步就算可用。评估时要把epsilon设成0,关闭探索,跑100个随机生成的障碍地图取平均,否则模型表现会被随机动作污染。
4.3 训练过程可视化与监控:盯住eval曲线而不是reward曲线
我习惯用TensorBoard记录每个episode的total_reward、success rate和平均路径长度。不要只看reward曲线,因为reward受奖励塑形影响,上升了不一定路径就短。更靠谱的是每50个episode跑一次评估,把success rate和Dijkstra偏差记录下来。
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter(log_dir='runs/dqn_shortest_path') # 在每个episode结束时记录 writer.add_scalar('episode/reward', total_reward, episode) if episode % 50 == 0: succ, gap = evaluate(q_net, env, dijkstra_paths) writer.add_scalar('eval/success_rate', succ, episode) writer.add_scalar('eval/path_gap', gap, episode)看曲线时有个常见误区:如果eval/success_rate一直为0,但episode/reward在涨,说明模型学会了减少步数惩罚,但没找到终点。这时候要检查奖励是不是被塑形带偏了,比如它发现原地不动可以避免负奖励,就会“躺平”。我遇到这种情况会把步数惩罚从-0.01改成-0.05,或者把到达目标的奖励从+1提到+5,增强目标信号。
4.4 用Dijkstra算基线:评估最优性的唯一标尺
评估的时候没有标尺,你就不知道自己模型的路径到底好不好。我一般会在环境里跑一次Dijkstra,把理论最短路径长度算出来,作为对比基线。下面的代码是一个网格上的Dijkstra实现。
import heapq def dijkstra_shortest_path(grid, start, target): # grid: 二维numpy数组, 0可走, 1障碍 rows, cols = grid.shape dist = np.full((rows, cols), np.inf) dist[start] = 0 pq = [(0, start)] while pq: d, (x, y) = heapq.heappop(pq) if d > dist[x, y]: continue for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1)]: nx, ny = x + dx, y + dy if 0 <= nx < rows and 0 <= ny < cols and grid[nx, ny] == 0: nd = d + 1 if nd < dist[nx, ny]: dist[nx, ny] = nd heapq.heappush(pq, (nd, (nx, ny))) return dist[target]这个函数是评估阶段的“标尺”,每个测试地图都跑一次,得到理论最短路径,然后对比DRL路径。如果DRL路径长度大于理论值,说明策略不是最优的。注意Dijkstra是静态算法,DRL的卖点是动态环境下的实时决策,所以评估时要区分静态场景和动态场景:动态场景下没有固定最短路径,只能跟“重规划Dijkstra”对比,但那样比较的是决策速度而不是路径长度。
5. DRL最短路径实战避坑:五个让模型翻车的典型案例
5.1 障碍物随机、奖励稀疏,训练完全不收敛
现象:训练了3000个episode,success rate还是0,reward曲线在-20附近不动。
原因:网格10x10从起点到终点最短也要18步,如果奖励只在终点给+1,那么随机探索到终点的概率极低,属于典型稀疏奖励问题。与此同时,障碍物每次reset随机生成,模型刚记住一条路,下一局地图又变了,经验回放里的样本来自不同地图,互相干扰。
解决:先把障碍物固定,让模型先学会无障碍地图上的最短路径;同时加入距离势能塑形,让每一堵墙的碰撞都有反馈。固定障碍物训练稳定后,再逐步放开随机障碍,但每次reset只改20%的障碍物,而不是完全重新生成。这样模型是从“会走”慢慢转成“会绕”。
5.2 目标网络更新太勤,Q值震荡发散
现象:loss从0.1降到0.01后突然跳到100以上,然后一直在10附近震荡;路径评估结果忽好忽坏。
原因:sync_freq设成了50,目标网络几乎每个episode都同步,导致“目标”始终跟着当前网络跑,Q值失去稳定锚点,就像追自己的影子。另一个可能原因是经验回放里没有存下足够多的终止状态,导致bootstrap误差反复累积。
解决:把sync_freq调到500或1000,同时把replay_buffer里终止样本的比例拉高。我常用的一个技巧是在buffer满之后,采样时强行掺入10%的终止样本,确保模型知道“到终点就结束”。这样Q值收敛速度慢一点,但不会发癫。
5.3 奖励塑形用曼哈顿距离,模型学会绕圈
现象:成功率和路径长度都正常,但渲染出来的轨迹有一个小圈,比如明明直走就能到,它非要先左转再右转。
原因:奖励塑形用的是“距离差值”,而距离差是曼哈顿距离。曼哈顿距离在网格里有多个方向同时变小的情况,模型利用了这个漏洞,通过绕路来制造“每一步都变近”的假象。或者是撞墙惩罚-1太大,模型害怕撞墙,所以选择在不撞墙的路径里故意绕远。
解决:把距离差值的塑形改成势能差形式,势能函数直接用欧几里得距离而不是曼哈顿距离;同时把撞墙惩罚从-1降到-0.1。如果模型还在绕圈,可以在每个episode结束时,把路径长度超过Dijkstra最短路径1.5倍的回合直接截断并给一个大负奖励,这个“后悔药”机制能有效压制绕路行为。
5.4 训练环境与评估环境不一致,部署时成功率腰斩
现象:训练时成功率98%,部署到新地图只有50%;部分障碍物布置稍有改变,模型就像不认识路一样。
原因:训练时障碍物每次reset都重新生成,但生成比例和分布与部署场景不一致。比如训练地图全是10x10,部署地图变成15x15,模型的输入归一化虽然不变,但地图尺寸和障碍密度超出训练范围,网络无法泛化。
解决:让训练环境尽量贴近部署环境。我在做动态货架项目时,专门做了一组“域随机化”参数:障碍物比例在0.1到0.3之间随机浮动,地图尺寸偶尔变化一格。每隔100个episode换成一套新的随机障碍,但保持与部署一致的生成规则。评估时单独留出100张从没见过的障碍图,确保测的是泛化能力而不是记忆能力。
5.5 随机种子不固定,复现结果飘忽不定
现象:同一份代码跑五次,五次结果不一样,有时训练到500个episode就成功,有时跑满2000个episode还是0%。
原因:深度强化学习训练本身噪声大,如果Python、NumPy、PyTorch的随机种子都没固定,模型初始化和环境初始化的不确定性会被放大。最短路径又是非常“脆”的问题,初期探索差一点点,后面策略就完全不同。
解决:固定三套随机种子:random.seed(seed)、np.random.seed(seed)、torch.manual_seed(seed),同时在环境reset时传入seed。我在训练脚本开头强制做这件事,并把seed写进模型文件名。评估阶段用10个种子分别训练,取成功率最高的那个模型部署,而不是只跑一次就下结论。
6. 从网格到真实图结构的进阶:验证与部署技巧
6.1 用测试集和可视化验证模型泛化性
训练完成后,别急着拿去上线。我会准备一个测试集,里面随机生成100张没有出现在训练里的地图,分别用Dijkstra和训练好的DQN跑一遍,比较成功率、平均路径长度和决策耗时。如果成功率低于95%,说明模型没有真正学到最短路径,只是背下了训练地图。可视化时把路径画在网格上,重点看是否出现重复访问同一个节点的情况,那是模型在抖动的典型特征。
6.2 从网格到图:GNN或Dijkstra混合策略
真实地图不一定是规整网格,可能是任意拓扑的图结构。这时候动作空间从“上下左右”变成“从当前节点选择邻居”,状态里需要用邻接矩阵或节点的特征向量表示。图很小可以用邻接矩阵直接拼进全连接网络,图很大就上GNN。我的经验是,工程上更稳的做法不是让DRL完全替代传统算法,而是用DRL输出一个偏好权重,叠加到Dijkstra的一部分逻辑上,形成混合策略。这种方法在高动态场景下既保留Dijkstra的最优性边界,又获得DRL的实时性。
6.3 最后的技术习惯:随机种子与复现性
DRL训练结果对随机种子非常敏感,这个前面已经说过,再强调一次:我现在固定Python、NumPy、PyTorch三套随机种子,并且每次环境reset都传入seed。这样同事复现时至少不会因为“运气”而翻车。做评估时用10个种子分别训练,取成功率最高的模型部署,而不是只跑一次就下结论。
这个路线我前后调了大概三周,最后稳定到“固定障碍物预训练 + 动态障碍物微调 + Dijkstra校验”的流程,才敢把它放到仓储仿真里跑。希望你在这个方向上少走我走过的弯路,直接照着一套实践走到底,希望帮到你。
本文还有配套的精品资源,点击获取