简介:面向人工智能、机器人、无人机控制等领域研究人员与工程师的Python实战项目,基于多智能体强化学习(MARL)构建无人机三维路径规划系统。系统采用MAPPO算法,构建三维连续空间环境,通过局部观测与集中式价值网络实现协同策略训练,支持多无人机自主避障与高效路线自动筛选,可适配城市低空物流、森林巡检、应急救援等典型三维场景。资源包为单个docx文档,141KB,内含完整程序代码、GUI设计说明与代码详解,覆盖环境建模、障碍物判定、高斯策略网络、集中式价值网络、轨迹缓存与优势计算、MAPPO式训练更新及自动路线选择评估等核心模块。目前已有98人学习。借助文档中的GUI仿真系统,读者可深入理解奖励函数设计、安全约束融入与候选动作评估机制,掌握从理论到工程部署的全流程,为后续算法迭代与系统集成提供可复现的实验基础。
1. 多智能体强化学习做无人机三维路径规划:为什么单机规划器在编队里频繁失灵
多智能体强化学习(MARL)做无人机三维路径规划,很多人第一反应是问:A*、RRT、人工势场这些无人机路径规划算法都成熟了,为什么还要折腾强化学习?答案是:这些经典算法给的是“单机静态环境”的解法。把场景换成 3~5 架无人机同时在障碍稠密的三维空间里飞,它们要么重规划频率过高,要么互相把对方路径堵死。MARL 的价值不是“算得更快”,而是让每架无人机在训练中学会跟队友妥协——谁先过、谁避让、谁去占位,这是传统规划器给不了的能力。这篇内容从问题建模、Python 训练代码、PyQt5 可视化到实际踩坑,把一套可直接扩展的 MARL 三维路径规划系统完整讲清楚。适合在做编队飞行、协同巡检、物流配送仿真的工程师直接参考。
2. 把三维路径规划建模成 MARL 问题:状态空间、动作空间与奖励函数的落地设计
MARL 项目最容易在第一步就翻车:直接把单智能体强化学习的“状态-动作-奖励”套进多机场景,结果训练到一半发现每架无人机都在争同一个目标。这一章先把问题定义清楚,后面所有代码都建立在这套定义上。
2.1 为什么不用集中式规划器:动态编队与局部观测的现实约束
先回答一个必然要面对的问题:既然计算资源够,为什么不用一个集中式规划器统一给所有无人机算路径?集中式 A* 或带约束的优化求解器(例如混合整数规划)确实能算全局最优解,但代价是状态维度随无人机数量指数膨胀,而且它对环境动态变化非常敏感——障碍物或任务目标一改,整个联合规划要重跑一遍,无人机在飞行中根本等不起这个重规划周期。
另一个被忽略的问题是通信假设。集中式规划默认所有信息汇聚到一个中心节点,但实际编队飞行里每架无人机只能通过机间链路拿到邻居的相对位置和目标信息,局部观测是天然的约束。MARL 的 CTDE(集中训练、分布式执行)框架正好卡在这个点上:训练阶段用一个中心 Critic 看全局信息,帮助每个智能体学会协作;部署阶段每个智能体只用自己的 Actor 网络和局部观测做决策。这就是为什么多无人机路径规划场景里,MARL 比集中式求解器更贴合实际工程约束。
2.2 状态空间怎么定义:三维坐标归一化、相对目标与邻居信息
多智能体强化学习的状态空间设计不能直接堆叠绝对坐标,否则同一张地图换个起点,网络输出就乱了。我常用的做法是给每架无人机构造一个“自身视角”的观测向量,全部做归一化处理,让模型对地图尺寸和初始位置不敏感。
import numpy as np class Uav3DEnv: def __init__(self, num_uavs=4, bounds=(100, 100, 50)): self.num_uavs = num_uavs self.bounds = np.array(bounds, dtype=np.float32) self.uavs = [] def _get_state(self, uav_idx): my_pos = self.uavs[uav_idx] target = self.targets[uav_idx] state = [] # 自身位置归一化到 [0,1],避免数值尺度过大 state.extend(my_pos / self.bounds) # 到目标的相对向量,同样归一化 state.extend((target - my_pos) / self.bounds) # 最近障碍物距离,除以 50 做尺度压缩 state.append(self._nearest_obstacle_dist(uav_idx) / 50.0) # 当前无人机剩余油箱/电量,简单归一化 state.append(self.energy[uav_idx] / 100.0) # 邻居相对位置,按通信范围内的机间距离填充 for j in range(self.num_uavs): if j != uav_idx: rel = (self.uavs[j] - my_pos) / self.bounds state.extend(rel) return np.array(state, dtype=np.float32)这段代码的要点在状态组成:自身位置、目标相对位置、障碍物距离、电量、邻居相对位置。其中“目标相对位置”比绝对目标坐标泛化能力强得多,换目标点不需要重训练;邻居相对位置则是在训练中学会机间避碰和编队保持的关键字段。归一化不是可选项,真实三维地图里坐标动辄上百米,Q 值网络第一层就会吃进尺度差异巨大的输入,梯度更新很容易被大数值维度带偏。
如果你后续想接入无人机视觉感知,常规做法是在这个状态向量基础上拼接视觉特征向量,但要注意视觉特征维度高,DQN 这类离散动作算法会明显更难收敛,建议先保持低维状态跑通,再接感知模块。
2.3 动作空间:离散 6 方向还是连续速度控制
多智能体强化学习的动作空间选择直接影响算法选型,这个要在项目开始前就定死。做无人机三维路径规划,常见有两种动作设计:
离散动作:6 个标准方向(前、后、左、右、上、下)加一个悬停,动作维度低,DQN 和其变体可以直接用,训练稳定、算力要求低,适合 3~5 架无人机的编队避障验证。
连续动作:每个智能体输出三维速度向量 (vx, vy, vz) 或水平速度、垂直速度、偏航角,动作维度 3~4,必须用 MADDPG、SAC 这类连续控制算法,精度高,但训练时间翻倍,且调参难度明显上升。
我一般建议第一次做 MARL 路径规划先用离散 6 方向跑通整套流程,验证奖励函数和状态设计是否合理,再切换到连续动作升级精度。很多公开的无人机仿真项目里,离散动作配合共享 DQN 就能达到不错的编队避障效果,没必要一上来就上连续控制。
2.4 奖励函数是训练成败的胜负手:到达、避障、协作三项的权重建议
奖励函数是 MARL 里最像玄学的地方,但它其实是可拆解的。三维路径规划场景下,我通常把奖励分成四个独立部分,每一部分单独计算并打日志,训练时哪项出问题一眼就能看出来。
def _compute_reward(self, uav_idx, prev_pos, new_pos, hit_obstacle, out_of_bounds): target = self.targets[uav_idx] prev_dist = np.linalg.norm(prev_pos - target) new_dist = np.linalg.norm(new_pos - target) reward = 0.0 # 1) 距离塑形:靠近目标给正奖励,远离给负奖励 reward += 0.5 * (prev_dist - new_dist) # 2) 到达目标:一次性大奖励并终止该智能体 if new_dist < 3.0: reward += 10.0 self.done[uav_idx] = True # 3) 碰撞惩罚:撞障碍物或撞机,给强负奖励并终止 if hit_obstacle: reward -= 50.0 self.done[uav_idx] = True # 4) 越界惩罚:飞出地图边界 if out_of_bounds: reward -= 30.0 self.done[uav_idx] = True # 5) 能耗惩罚:鼓励走短路径,动作幅度越大代价越高 energy = np.linalg.norm(self.last_actions[uav_idx]) reward -= 0.05 * energy return reward几个权重需要特别说明。距离塑形系数 0.5 不能太小也不能太大,太小导致无人机原地打转,太大会让无人机贴着障碍物边缘“蹭”向目标,因为近距离跨越障碍物带来的距离收益超过碰撞惩罚。到达奖励 10.0 与碰撞惩罚 50.0 的比例也很关键:如果到达奖励远大于碰撞惩罚,智能体会学到“顶着碰撞风险莽向目标”;反过来则会过于保守,停在原地不动。我的经验是从 1:5 的比例起步调,先保证智能体敢动,再逐步增大碰撞惩罚。
更重要的是协作奖励。上面这段代码只有单机奖惩,编队场景一定要加机间距离约束。常见做法是两机距离小于安全阈值 1.5 米时,双方各扣一次惩罚;距离在 5 到 15 米之间时给一个小的正向奖励,鼓励编队保持。这个“安全距离带”的上下界要根据无人机尺寸和飞行速度定,不要照搬别人的数值。奖励各项权重记录在一张表里,方便复现对比:
| 奖励项 | 典型值 | 作用 |
|---|---|---|
| 距离塑形系数 | 0.3~0.8 | 引导持续接近目标 |
| 到达奖励 | 10~20 | 最终目标达成 |
| 碰撞惩罚 | 30~100 | 障碍物与机间避碰 |
| 越界惩罚 | 20~50 | 保持在地图边界内 |
| 能耗惩罚系数 | 0.02~0.1 | 抑制绕路和无效动作 |
3. 用 Python 从零跑通多智能体 DQN:环境搭建、共享经验回放与训练循环
这一章直接给出可运行的 Python 实现。为了让代码能在普通电脑上跑起来,先用多智能体共享参数的 DQN 作为基线,算法逻辑清楚,硬件要求低;跑通之后再讲升级到 MADDPG 的关键改动。
3.1 三维环境搭建:栅格地图、障碍物与无人机运动学
环境是无人机仿真的第一步,也是后面所有代码的地基。这里实现一个轻量三维环境,包含位置更新、碰撞检测和边界检查。
import numpy as np import random class Uav3DEnv: def __init__(self, num_uavs=4, bounds=(100, 100, 50), targets=None): self.num_uavs = num_uavs self.bounds = np.array(bounds, dtype=np.float32) if targets is None: self.targets = [np.array([80, 80, 40], dtype=np.float32) for _ in range(num_uavs)] else: self.targets = [np.array(t, dtype=np.float32) for t in targets] # 障碍物用球体中心坐标表示,半径为固定值 2.0 self.obstacles = [np.array([40, 40, 20], dtype=np.float32), np.array([60, 30, 30], dtype=np.float32), np.array([30, 60, 35], dtype=np.float32)] self.uavs = [] self.done = [] self.last_actions = [] self.energy = [] def reset(self): self.uavs = [] self.done = [False] * self.num_uavs self.energy = [100.0] * self.num_uavs self.last_actions = [np.zeros(1) for _ in range(self.num_uavs)] # 起始区域集中在左下角,目标在右上角,形成明显的路径规划压力 for _ in range(self.num_uavs): self.uavs.append(np.array([ random.uniform(0, 5), random.uniform(0, 5), random.uniform(0, 5)], dtype=np.float32)) return [self._get_state(i) for i in range(self.num_uavs)] def step(self, actions): step_size = 2.0 offset_map = { 0: [1, 0, 0], 1: [-1, 0, 0], 2: [0, 1, 0], 3: [0, -1, 0], 4: [0, 0, 1], 5: [0, 0, -1], 6: [0, 0, 0] # 悬停,通常只用于调试 } new_uavs = [] rewards = [] for i in range(self.num_uavs): if self.done[i]: rewards.append(0.0) new_uavs.append(self.uavs[i]) continue offset = np.array(offset_map[int(actions[i])], dtype=np.float32) new_pos = self.uavs[i] + offset * step_size hit_obs = self._hit_obstacle(new_pos) out = self._out_of_bounds(new_pos) hit_uav = self._hit_uav(i, new_pos) self.last_actions[i] = offset if not (hit_obs or out or hit_uav): new_uavs.append(new_pos) else: new_uavs.append(self.uavs[i]) # 撞了就不动 rewards.append(self._compute_reward(i, self.uavs[i], new_uavs[-1], hit_obs or hit_uav, out)) self.energy[i] -= 0.1 self.uavs = new_uavs return [self._get_state(i) for i in range(self.num_uavs)], rewards, self.done这个环境类把无人机运动学简化成了“固定步长移动”,每步 2 米,6 方向离散动作。如果你的项目对运动学精度有要求,可以改成连续速度 + 加速度限制,但训练复杂度会上去。障碍物用球体中心点表示、统一半径 2.0,是为了让碰撞检测用欧氏距离一行算完;真实地形可以用更复杂的网格模型替换 _hit_obstacle 内部实现。
边界检查_out_of_bounds要严格处理:任何一维坐标超出 [0, bounds[i]] 就判越界。曾经遇到一个案例是边界判断用了>没加=,无人机贴边滑行不出界,奖励函数在边界附近产生错误的局部最优,所以边界检查建议写成pos[i] < 0.0 or pos[i] > self.bounds[i]。
3.2 共享参数 DQN 与经验回放:每个智能体独立采样、共同学习
多智能体场景最朴素的做法是让所有无人机共享同一个 DQN 网络参数,每架无人机用自己的状态和动作去采样,经验统一放进同一个回放缓冲区。这个做法在无人机数量不多(3~5 架)时收敛效果好,训练速度快,代码也简单。它本质上把“每架无人机的经验”当作独立的样本来用,经验效率比单智能体更高。
import torch import torch.nn as nn import torch.optim as optim class DQN(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, action_dim) ) def forward(self, x): return self.net(x) class ReplayBuffer: def __init__(self, capacity=100000): self.capacity = capacity self.buffer = [] def push(self, s, a, r, s_, d): self.buffer.append((s, a, r, s_, d)) if len(self.buffer) > self.capacity: self.buffer.pop(0) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) s = torch.FloatTensor([b[0] for b in batch]) a = torch.LongTensor([b[1] for b in batch]).unsqueeze(1) r = torch.FloatTensor([b[2] for b in batch]) s_ = torch.FloatTensor([b[3] for b in batch]) d = torch.FloatTensor([b[4] for b in batch]) return s, a, r, s_, d共享参数为什么能成立?因为每架无人机的状态空间和动作空间是同构的,它们学习的目标本质上也是同一个策略模式——靠近目标、躲开障碍、保持编队。共享参数相当于让所有智能体把经验汇集在一起训练一个“公共大脑”,这在同构智能体场景里是一个合理且高效的简化。但要注意,如果无人机分工不同(比如一架负责侦察、一架负责打击),这个假设就不成立了,需要为不同角色分配独立网络。
3.3 训练循环:epsilon 衰减、目标网络同步与损失计算
训练循环是所有强化学习项目里最容易“看起来在跑、实际没在学”的部分。下面是完整的训练主循环,重点在于经验采样的频率和目标网络同步的时机。
def train(): state_dim = 4 + 4 + 1 + 1 + (env.num_uavs - 1) * 3 # 按 _get_state 计算 action_dim = 7 net = DQN(state_dim, action_dim) target_net = DQN(state_dim, action_dim) target_net.load_state_dict(net.state_dict()) optimizer = optim.Adam(net.parameters(), lr=1e-3) buffer = ReplayBuffer(100000) gamma = 0.99 batch_size = 128 target_update_freq = 200 for episode in range(2000): obs = env.reset() total_reward = 0.0 # epsilon 从 1.0 线性降到 0.05,前 500 局保持探索 eps = max(0.05, 1.0 - episode / 400) for step in range(300): actions = [] for i in range(env.num_uavs): if random.random() < eps: actions.append(random.randrange(action_dim)) else: with torch.no_grad(): q = net(torch.FloatTensor(obs[i]).unsqueeze(0)) actions.append(int(torch.argmax(q).item())) new_obs, rewards, dones = env.step(actions) for i in range(env.num_uavs): buffer.push(obs[i], actions[i], rewards[i], new_obs[i], dones[i]) obs = new_obs total_reward += sum(rewards) # 经验够了才开始训练网络 if len(buffer.buffer) >= batch_size: s, a, r, s_, d = buffer.sample(batch_size) q_pred = net(s).gather(1, a) with torch.no_grad(): q_next = target_net(s_).max(1, keepdim=True).values y = r.unsqueeze(1) + gamma * q_next * (1 - d.unsqueeze(1)) loss = nn.MSELoss()(q_pred, y) optimizer.zero_grad() loss.backward() optimizer.step() # 每 200 步同步一次目标网络 if step % target_update_freq == 0: target_net.load_state_dict(net.state_dict()) if all(dones): break if episode % 50 == 0: print(f"episode {episode}, reward {total_reward:.2f}, eps {eps:.2f}")几个参数值得解释。epsilon 衰减速度决定了探索和利用的平衡,衰减太快会陷入局部最优,衰减太慢则迟迟不收敛;上面从 1.0 到 0.05、约 400 局完成衰减,只是一个起点,如果你的地图更大、障碍更密,要适当拉长到 600~800 局。目标网络同步频率 200 步也是经验值——太频繁会让训练不稳定,太久则目标值滞后严重。损失用普通 MSELoss 就够了,不需要花哨的变体。
这里有个非常隐蔽的坑:env.step(actions)里面actions是 Pythonint列表,但DQN网络输出的是 logits,直接argmax拿的是动作索引。如果你在step()里拿这个索引查offset_map,一定要确认动作编号对齐;动作维度设计成 7(6 方向 + 悬停)时,索引 6 对应悬停,这个映射错位是训练期最常见也最难排查的 bug。
3.4 升级到 MADDPG:连续动作与集中式 Critic 的关键改动
离散 DQN 跑通之后,如果发现 6 方向步进太粗糙,或者需要输出连续速度向量,就该升级到 MADDPG。它的核心思想是:每个智能体有自己的 Actor 网络(策略),但训练时共享一个集中式 Critic 网络,Critic 输入所有智能体的观测和动作,从而部分解决多智能体环境的非平稳性问题。
class Actor(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, action_dim), nn.Tanh() ) def forward(self, x): return self.net(x) class Critic(nn.Module): def __init__(self, state_dims, action_dims): super().__init__() # 输入是所有智能体的状态和动作拼接 total_in = sum(state_dims) + sum(action_dims) self.net = nn.Sequential( nn.Linear(total_in, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, 1) ) def forward(self, all_states, all_actions): x = torch.cat(all_states + all_actions, dim=1) return self.net(x)Critic 拿全局信息,Actor 只用局部观测,这正好对应前面说的 CTDE 模式。MADDPG 训练时,每个智能体动作会加一个高斯噪声来保证探索,而不是 DQN 的 epsilon 贪心。常见的做法是噪声标准差从 1.0 逐步衰减到 0.1,这个衰减速度也属于“靠调”的超参数——噪声太大策略粗野,太小则探索不足。Critic 的学习率一般比 Actor 低一个数量级,例如 Actor 用 1e-4、Critic 用 1e-3,否则 Critic 更新过快会让整个训练崩掉。
4. PyQt5 做三维路径规划 GUI:训练曲线、实时轨迹回放与模型加载推理
代码和算法跑通之后,项目要能给别人看、能调参、能复现,就需要一个图形界面。很多人觉得 GUI 是加分项不是必选项,但做无人机路径规划系统,没有可视化就无法判断训练是否合理——你没法确认无人机是真的在避障还是绕了远路。这一章讲用 PyQt5 搭建一个二维面板 + 三维轨迹回放的可视化工具,兼顾训练监控和结果演示。
4.1 技术选型:为什么选 PyQt5 而不是 Tkinter
Python 做桌面 GUI,Tkinter 最省事但不能满足三维绘制需求,它没有原生 3D 组件,用 Canvas 自己画投影又浪费时间。PyQt5 配合 Matplotlib 的 FigureCanvas 是更成熟的组合。
import sys from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, \ QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QLabel, QTextEdit from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure import matplotlib matplotlib.use("Qt5Agg")这个组合的优点是:训练曲线和三维轨迹统一用 Matplotlib 渲染,样式可以完全复用之前的绘图代码;PyQt5 的信号槽机制天然适合训练线程和界面线程之间的数据分发。如果你有 Web 前端经验,也可以考虑用 Gradio 或 Flask 做浏览器端可视化,但桌面工具在离线环境和快速原型上仍然更顺手。
4.2 界面布局:控制面板、训练日志与三维绘图区
界面布局按“左侧控制、右上曲线、右下轨迹”的三分区来设计。控制面板放训练参数(学习率、回合数、epsilon 衰减系数)、开始/停止按钮、模型保存和加载按钮;右上区域用 Matplotlib 画奖励曲线和碰撞率曲线;右下区域用三维 Axes 画无人机轨迹和障碍物。
class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("MARL Uav Path Planning") self.resize(1280, 800) central = QWidget() self.setCentralWidget(central) layout = QHBoxLayout(central) # 左侧控制面板 left = QVBoxLayout() self.lr_input = QLineEdit("0.001") self.episode_input = QLineEdit("2000") left.addWidget(QLabel("Learning Rate")) left.addWidget(self.lr_input) left.addWidget(QLabel("Episodes")) left.addWidget(self.episode_input) self.btn_start = QPushButton("开始训练") self.btn_save = QPushButton("保存模型") self.btn_load = QPushButton("加载模型") left.addWidget(self.btn_start) left.addWidget(self.btn_save) left.addWidget(self.btn_load) self.log = QTextEdit() self.log.setReadOnly(True) left.addWidget(self.log) left.addStretch() # 右侧绘图区 right = QVBoxLayout() self.fig = Figure(figsize=(10, 6)) self.canvas = FigureCanvas(self.fig) self.ax_curve = self.fig.add_subplot(121) self.ax_3d = self.fig.add_subplot(122, projection="3d") right.addWidget(self.canvas) layout.addLayout(left, 1) layout.addLayout(right, 4)布局里最容易犯的错是让 QTextEdit 占用大量垂直空间,挤压绘图区。用addStretch()把日志组件顶上去,保证右侧画布是主视觉区域。另外,三维绘图的投影参数要提前设好:ax_3d.set_xlim(0, 100)、set_ylim(0, 100)、set_zlim(0, 50),并且固定视角,如果每次刷新都自动调整视角,轨迹动画会剧烈抖动,没法看。
4.3 训练线程与信号槽:为什么训练循环坚决不能放主线程
PyQt5 应用最容易翻车的地方就是训练卡死界面。强化学习训练循环是长时间阻塞操作,放进主线程的槽函数里,Qt 事件循环被堵住,界面的按钮、文本、绘图全部冻结。正确做法是用 QThread 跑训练,通过信号把训练数据发回主线程更新界面。
from PyQt5.QtCore import QThread, pyqtSignal class TrainWorker(QThread): update_curve = pyqtSignal(dict) update_log = pyqtSignal(str) def __init__(self, env, net, episodes, lr): super().__init__() self.env = env self.net = net self.episodes = episodes self.lr = lr self.running = True def run(self): # 训练循环里每 10 个 episode 发一次信号 for ep in range(self.episodes): if not self.running: break avg_reward, arrive_rate, collision_rate = self._one_episode() self.update_curve.emit({ "episode": ep, "avg_reward": avg_reward, "arrive_rate": arrive_rate, "collision_rate": collision_rate }) self.update_log.emit(f"ep {ep}: reward={avg_reward:.2f}") def _one_episode(self): # 训练单个回合的逻辑,返回统计指标 pass def stop(self): self.running = False信号槽的关键在于:update_curve信号带着字典参数,主线程对应的槽函数只负责更新 Matplotlib 画布,不执行任何训练逻辑。训练线程里要每隔若干 episode 检查self.running,否则你点了“停止训练”按钮,线程不会立刻停下来,还得等当前 episode 跑完。
4.4 三维轨迹绘制:动态更新技巧与坐标系设定
三维绘制的性能是 GUI 的一大瓶颈。最直接的做法是每帧ax_3d.clear()然后重新绘制所有轨迹,架子小、代码简单,但轨迹超过几百个点之后明显掉帧。我常用的优化技巧是轨迹降采样:每个回合只画最近 50 个位置点,历史轨迹用淡色线条,当前帧用亮色线条。
def draw_trajectory(self, all_positions_list, obstacles=None): # all_positions_list: 每架无人机的位置历史列表 self.ax_3d.clear() self.ax_3d.set_xlim(0, 100) self.ax_3d.set_ylim(0, 100) self.ax_3d.set_zlim(0, 50) # 先画障碍物,固定形状和颜色 if obstacles: for obs in obstacles: self.ax_3d.scatter(obs[0], obs[1], obs[2], c="r", s=40, marker="o") # 再画每条轨迹,用不同颜色区分无人机 colors = ["b", "g", "y", "m", "c"] for idx, pos_history in enumerate(all_positions_list): recent = pos_history[-50:] # 只取最近 50 帧 if len(recent) > 1: xs = [p[0] for p in recent] ys = [p[1] for p in recent] zs = [p[2] for p in recent] c = colors[idx % len(colors)] self.ax_3d.plot(xs, ys, zs, color=c, alpha=0.8) self.ax_3d.scatter(xs[-1], ys[-1], zs[-1], color=c, s=20) self.canvas.draw_idle()坐标系设定这里要提一句三维建模坐标系的选择:仿真环境里地图原点设在地面左下角,Z 轴向上,这和真实无人机的地固坐标系(如 ENU 东北天)存在差异。如果你后面要接真实飞控数据或倾斜摄影三维模型,需要做坐标转换;但纯仿真阶段,统一用地图自身的右手坐标系即可,不要在 GUI 里混用两种坐标系,否则轨迹显示旋转九十度会很难排查。
5. 多无人机 MARL 训练的 5 个翻车现场:现象、原因与排查手段
训练多智能体强化学习的路上一定会遇到几个反复出现的坑,这里整理 5 个我自己踩过也给别人排查过的问题,每一条都按“现象、原因、解决”展开,可以直接对着自己的训练日志逐条比对。
5.1 奖励尺度失衡:训练中 loss 变成 NaN,曲线瞬间消失
现象:训练跑到几百步之后,loss 突然变成 nan,奖励曲线断崖式下跌,之后网络输出全是常数。原因:奖励函数里到达奖励 +10、碰撞惩罚 -50,加上距离塑形,数值范围横跨 -50 到 +10,Q 值回归目标不稳定,梯度爆炸触发。解决:把所有奖励项按比例缩到同数量级。我的做法是碰撞惩罚从 50 降到 10,到达奖励从 10 降到 5,距离塑形系数从 0.5 降到 0.2,让各分项都在 ±10 以内;同时在优化器上加上梯度裁剪,nn.utils.clip_grad_norm_(net.parameters(), 10.0)。如果已经出现 NaN,先重置模型再用缩小后的奖励重新训练,不用在 NaN 的模型上继续调。
5.2 距离塑形盖过碰撞惩罚:无人机贴着障碍物边缘飞
现象:训练完成后看轨迹,无人机贴着障碍物表面绕行,最近距离只有 0.1 米,虽然没碰撞但极度危险。原因:距离塑形给的是“接近目标”的正奖励,障碍物边缘的路径虽然危险,但距离收益每步都在累积,最终超过了对碰撞风险的惩罚预期。解决:给障碍物设置安全缓冲距离,碰撞检测半径从 2.0 米扩大到 3.0 米,并把“障碍物距离太近”作为每步的持续惩罚。也就是说,不是撞上才惩罚,而是靠近就扣分。修改后无人机会主动保持安全距离,而不是贴边“蹭”过去。
5.3 epsilon 衰减过快:训练曲线平平的,到达率一直是 0
现象:训练几千 episode,总奖励一直在 -200 附近波动,没有上升趋势。原因:epsilon 从 1.0 到 0.05 衰减的窗口太短,前 200 局就把探索几乎关掉了,策略还没探索到到达目标的路径,就过早进入纯利用阶段,陷入“原地转圈”的行为。解决:把衰减窗口从 400 局拉长到 800 局,同时给每个 episode 的初始状态加入随机扰动,确保探索阶段能看到更多地图变化。判断探索是否足够的标准:前 20% 的训练里,无人机的平均步长应该明显大于随机策略的平均步长,如果一开始就收敛到很短的轨迹,大概率是探索不足。
5.4 共享经验回放导致编队行为失调:两架无人机反复互撞
现象:训练中单机避障表现不错,但两架无人机相遇时互不相让,反复碰撞,到达率一直上不去。原因:共享 DQN 的经验回放里,每架无人机只用自己的状态做决策,看不到其他智能体的动作。两个智能体同时冲向同一个目标时,没有一个机制让它们学会“礼让”。解决:在奖励函数里加入机间距离惩罚项,两机距离小于 3 米,双方各扣 2 分;同时把交互协商写入状态空间,确保一定程度上的协作信号。除了奖励,还要考虑把动作“共享给队友”作为状态输入,常见做法是把邻居无人机的最近动作向量拼进状态。
5.5 模型过拟合到固定地图:换障碍物布局后性能暴跌
现象:训练时固定使用同一张障碍物地图,训练结束时到达率 90% 以上;把障碍物位置随机改一下,到达率掉到 30%。原因:网络记住了特定障碍物坐标下的路径模式,而不是“避开障碍物并到达目标”的通用策略。解决:每个 episode 重置障碍物位置,让训练分布覆盖足够多的地图变化。障碍物坐标从训练初始的固定值改为uniform(20, 80)随机采样,地图边界保持固定。这个方法会增加训练难度,前几百局会明显不稳定,但最终策略的泛化能力远比固定地图强。
6. 进阶用法:从独立 DQN 迁到 MADDPG 参数表,以及仿真收敛后的验证套路
把基线 DQN 跑通之后,你可以沿着两个方向继续深入:一是升级算法到 MADDPG,二是建立一套严格的验证流程,确保策略不是在仿真里“蒙对”的。
MADDPG 迁移是我最常用的升级路径。它跟 DQN 的核心差异在训练方式,前面已经给了 Actor 和 Critic 的网络结构,关键参数可以按下面这张表起步:
| 参数 | DQN 基线值 | MADDPG 建议值 |
|---|---|---|
| Actor 学习率 | 无(共用 1e-3) | 1e-4 |
| Critic 学习率 | 1e-3 | 1e-3 |
| 探索噪声 σ | epsilon 线性衰减 | 0.5 起步,每 1000 步 ×0.99 |
| 目标网络软更新 τ | 硬同步,每 200 步 | 0.01 |
| 折扣因子 γ | 0.99 | 0.95(更长时域任务用 0.99) |
这个表格的出发点:Critic 负责价值评估,学得快一点;Actor 要稳,学得慢一点。软更新 τ=0.01 是标准起步值,目标网络平滑跟随,不会像硬同步那样出现周期性振荡。探索噪声用 Ornstein-Uhlenbeck 过程也可以,但简单的零均值高斯噪声已经够用。
训练收敛后,最容易被忽略的是验证环节。我习惯的做法是准备一张“从没出现在训练集里”的随机地图,跑 20 个测试 episode,统计三项指标:到达率、碰撞率、平均飞行路径长度。到达率低于 60% 说明策略泛化不足;碰撞率高于 5% 就要回去检查机间避碰的奖励权重;平均航迹长度如果大幅超过起点到终点的直线距离,说明能耗惩罚系数太小,无人机在绕路。这三项指标要固定下来,每次调参后都跑同一组测试地图,否则你没法判断这次改动到底是变好了还是变差了。
另一个很好用的进阶技巧是课程学习:先在地图小、障碍物少、无人机数量少的环境训练几万步,再逐步增加环境复杂度。多智能体强化学习对初始环境的敏感度极高,直接上复杂环境经常是训练不收敛的罪魁祸首。
我每次做这类项目都会把奖励函数拆成独立日志文件,奖励函数的分项贡献单独打印,哪一项异常一眼就能看到。养成这个习惯之后,很多看起来像“玄学”的不收敛问题,最后都能定位到具体某一项奖励的设计失误。希望帮到你。
本文还有配套的精品资源,点击获取