简介:面向无人机路径规划研究者与强化学习入门者的DQN三维避障航迹规划完整工程资料包,解决复杂三维环境中无人机如何通过深度强化学习自主避障并规划可行航迹的问题。内容覆盖DQN核心原理、状态-动作空间设计、经验回放机制以及环境建模与训练评估流程,可适用于静态或动态障碍物场景下的路径规划研究。包内共15个文件,以6个Python源码(网络模型、经验回放、环境模拟、无人机控制等)为主,辅以2个已训练模型权重pth文件、2个训练过程动图gif、2张系统框架及航迹效果图jpg、2份说明文档md和许可证文件,压缩包整体仅2.01MB。读者可结合说明文档快速理解代码结构,直接加载预训练权重观察避障效果,也可修改参数重新训练,进行算法对比或扩展到其他自主导航场景。该资源已有788人学习下载,对希望将理论学习转化为可运行程序的开发者和科研人员而言,是一份轻量、易读的实践参考,能显著缩短从DQN算法理解到三维环境部署的落地路径。
1. 基于DQN的三维无人机避障:为什么说这是最容易被低估的规划方案
DQN无人机避障,听起来像强化学习入门项目,但真正把三维空间和航迹规划结合起来后,维度、稀疏奖励和动作离散化这三个问题足以让不少开发者第一个晚上就翻车。我前后拆过多个版本,也亲手浪费了不少训练时间,最终留下这份能跑通全流程的资源:环境封装、状态提取、奖励函数、DQN训练、评估可视化,一个环节都不少。适合已经有点强化学习基础、想直接动手做三维避障航迹规划的人,也适合课程设计或毕业设计拿来做主线。这篇笔记会把状态空间怎么设计、奖励怎么塑形、训练参数怎么调,以及我踩过的坑全部摊开讲。
2. 从二维到三维:DQN避障的状态空间设计和奖励塑形
二维平面里无人机只需要感知前后左右四个方向,状态空间通常是一个极坐标或者简单的栅格。到了三维,多了上下两维,如果照搬二维的思路直接把坐标、速度、姿态角拼成一个大向量,维度轻松突破一百,同时数值范围还不在同一个量级,网络很容易被坐标绝对值带偏。我在第一个版本里就是直接堆了位置、速度、欧拉角,训练了两万步仍然看不到任何避障趋势,后来把状态改成激光雷达降采样加目标信息,局面立刻就对了。这一章讲的都是实际有效做法。
2.1 状态空间:用激光雷达降采样代替坐标堆叠
三维避障里最稳定的感知方式还是模拟激光雷达。常见的做法是把三维空间离散成水平方向 36 个方向、垂直方向 2 到 3 层的探测线,每条线返回最近的障碍物距离。比如水平按 10 度间隔扫一圈,得到 36 个距离值;垂直方向放上下两层,共 72 维,再拼上目标相对方向和距离,就组成了状态向量。
def get_state(self, pos, vel, target, obstacles): # pos: 无人机当前位置 [x, y, z] # vel: 速度向量 [vx, vy, vz] # target: 目标点位置 [tx, ty, tz] distances = [] # 垂直方向两层:-5度和+5度 for pitch in [-5.0, 5.0]: # 水平方向36个探测角度,间隔10度 for yaw in np.linspace(0, 2*np.pi, 36, endpoint=False): direction = np.array([ np.cos(np.radians(pitch)) * np.cos(yaw), np.cos(np.radians(pitch)) * np.sin(yaw), np.sin(np.radians(pitch)) ]) # raycast返回从当前位置沿方向到最近障碍物的距离,上限是20米 dist = self.raycast(pos, direction, obstacles, max_range=20.0) distances.append(dist) # 目标相对方向和归一化距离 rel_target = target - pos target_dist = np.linalg.norm(rel_target) target_dir = rel_target / (target_dist + 1e-6) # 归一化,防止除零 # 拼接成最终状态向量 state = np.concatenate([distances, target_dir, [target_dist]]) return state逻辑说明:这里的核心思想是把连续的三维感知空间离散成固定方向采样,而不是把点云或原始距离图全部丢进网络。72 个方向的距离读数已经能覆盖无人机周围的主要障碍信息,而且由于每个方向上的距离是标量,网络不需要自己学习特征提取。目标信息里只加了方向向量和距离标量,绝对坐标被完全剔除,这样无人机学的是相对导航策略,换一个起点位置也能生效。
参数说明:max_range=20 米很关键。如果设得太小,无人机看不到远处障碍,容易高速撞上去;设得太大,比如 50 米,大部分情况下探测距离全是 20 米的截断值,信息冗余且浪费维度。垂直层数我一般用 2 层,分别放在正负 5 度,既能感知上下方的障碍,又不会像 4 层那样大幅增加状态维度和计算量。这里用的是角度固定,如果你希望无人机通过俯仰获得更多信息,可以把 pitch 改成动态值,但状态维度的增大会让训练更难收敛,新手不建议一上来就加层数。
这里的距离值我做了除以 max_range 的归一化,让所有距离读数落在 0 到 1 之间。目标距离也做了同样处理,除以一个最大航程 40 米。这样网络输入所有特征的量级都在 0 到 1 之间,训练起来更稳定。如果不归一化,目标距离动辄几十米,激光雷达距离只有几米,信号量级不一致会让梯度被大数值特征主导。
2.2 奖励塑形:稀疏奖励和距离引导的平衡
三维避障如果只设置到达终点给 +1、碰撞给 -1,那奖励信号会稀疏到几乎无法训练。无人机在三维空间里大部分时间既没到目标也没碰撞,所有经验样本的奖励都是零,Q 值根本学不到东西。必须做奖励塑形,用连续的小反馈引导无人机走向目标。
def reward_function(self, state, action, next_state, done): # state和next_state中最后一位是目标距离 if self.at_target(next_state): return 10.0 if self.is_collision(next_state): return -10.0 d_prev = state[-1] d_new = next_state[-1] # 靠近目标获得正奖励,远离目标获得负奖励 reward = (d_prev - d_new) * 0.5 # 每一步的小时间惩罚,防止无人机原地悬停刷奖励 reward -= 0.01 # 从状态向量的前72维里找最小障碍距离 min_dist = min(next_state[:72]) if min_dist < 2.0: reward -= (2.0 - min_dist) * 0.2 return reward逻辑说明:奖励分了三部分。第一部分是距离变化奖励,步长的半米系数让无人机每靠近目标一步都能得到正向反馈,这是训练能快速收敛的主要动力。第二部分是时间惩罚,每步扣 0.01,一个 episode 最多几百步,这部分惩罚不会压垮正向奖励,但足够让无人机不敢在原地转圈。第三部分是对贴近障碍的额外惩罚,当无人机离某个障碍物小于 2 米时,惩罚随距离减小线性增加,这样无人机在决策时会尽量避免进入危险区域,但又不需要像势场法那样过度远离。
参数说明:距离变化系数 0.5 是我试出来的一个平衡点。如果调到 1.0,无人机会为了追求最小距离变化而横冲直撞,因为靠近目标的正收益远大于碰撞惩罚;如果调到 0.1,引导太弱,无人机漫无目的。时间惩罚 0.01 看起来小,但累积起来能有效压制原地打转。危险区域半径 2.0 和惩罚系数 0.2 是配套的,你可以根据障碍物平均大小调整,如果障碍物普遍半径较大,建议把危险半径调到 3.0。这里有一个容易犯的错误:不要把碰撞惩罚设得远大于到达奖励,否则无人机会过度保守,宁可绕很远也不愿靠近障碍,航迹会变得又长又怪。
另外,奖励函数里的每一项我都会在训练前打印一次,确保数量级没有断崖。比如距离变化系数是 0.5,时间惩罚是 0.01,离障碍惩罚最大不过 0.5,碰撞惩罚 -10。这样 Q 值的尺度大约在 -10 到 +10 之间,网络输出端不需要额外缩放。
2.3 动作空间:离散动作在三维中的取舍
DQN只能处理离散动作,因为网络输出的是每个动作的Q值。三维无人机比较自然的离散动作集合是六个方向加上悬停,共七个动作。我最初也想过加入后退,但后来发现后退动作在训练中很容易被Q网络学成“逃避碰撞”的万金油,导致无人机在障碍物前不断后退而不是绕行,所以最终保留的动作集合是前进、左移、右移、上升、下降、悬停,步长固定为 1.5 米。
# 动作定义,步长可配置 ACTIONS = { 0: (1.0, 0.0, 0.0), # 前进 1: (0.0, 1.0, 0.0), # 左移 2: (0.0, -1.0, 0.0), # 右移 3: (0.0, 0.0, 1.0), # 上升 4: (0.0, 0.0, -1.0), # 下降 5: (0.0, 0.0, 0.0) # 悬停 }逻辑说明:固定步长意味着每执行一个动作,无人机位置就向对应方向移动 1.5 米。这样做的优势是Q值对应的是一个明确的物理位移,网络在训练初期更容易通过距离变化奖励学会各动作的方向含义。如果换成连续速度控制,就需要回到连续动作算法如DDPG或TD3,复杂度会高不少,对于避障这个任务来说完全没必要。
参数说明:步长 1.5 米是一个折中值。设太长,比如 3 米,无人机一步就可能跳过障碍物,导致碰撞检测失效或航迹无法精细避障;设太短,比如 0.5 米,一个 episode 需要几百步才能到达目标,奖励累积路径太长,训练效率低。悬停动作虽然看起来没有意义,但能给无人机一个“原地观察”的选择,避免在两个动作之间来回震荡。实际训练中,悬停动作被选中的概率很低,但它存在时训练稳定性确实更好。如果你的三维环境里走廊很窄,建议把步长降为 1.0,并且去掉左移右移中与前进方向夹角过大的动作。
动作步长和速度模拟的配合也值得注意。如果你的模拟器 dt 是 0.1 秒,max_speed 是 5,那么一次 step 位移 0.5 米,而一个动作的步长是 1.5 米,意味着要连续执行三次才能真正移动完整步长。这会让状态转移看起来很奇怪:动作执行一次,位移只有 1/3。所以我在封装环境时,动作 step 会直接调用三次模拟器 step,保证一个动作对应完整的 1.5 米位移。这个小细节如果没处理,DQN会学出一个很难解释的Q值映射。
3. 环境搭建与数据生成:让模拟环境逼真到能直接迁移
训练环境的逼真度和采样多样性,直接决定训练出来的策略能不能用。这一章会讲我如何封装模拟器、生成随机障碍物,以及怎样避免过拟合到固定场景。
3.1 基于某开源模拟器的三维无人机环境配置
我用的模拟器本身提供了基本的物理模型和传感器接口,但原生接口比较复杂,所以我做了一层薄薄的封装,让强化学习环境只需要暴露 reset 和 step 两个方法。在这个封装里,无人机被当成一个有最大速度约束的质点移动,不考虑空气动力学细节,但对于避障航迹规划这个任务,这样的仿真级别已经足够。
class UAVSimulator: def __init__(self, config): self.dt = config.get('dt', 0.1) # 控制周期 0.1 秒 self.max_speed = config.get('max_speed', 5.0) # 最大速度 5 m/s self.pos = np.array(config['origin'], dtype=float) self.world_bounds = config.get('world_bounds', [-50, 50, -50, 50, 0, 30]) def step(self, action): # action是三维速度指令,先限幅 desired_vel = np.clip(action, -self.max_speed, self.max_speed) # 位置积分 self.pos = self.pos + desired_vel * self.dt # 边界截断,防止飞出世界 self.pos[0] = np.clip(self.pos[0], self.world_bounds[0], self.world_bounds[1]) self.pos[1] = np.clip(self.pos[1], self.world_bounds[2], self.world_bounds[3]) self.pos[2] = np.clip(self.pos[2], self.world_bounds[4], self.world_bounds[5]) return self.pos.copy()逻辑说明:这个封装放弃了复杂的姿态控制和动力学模型,直接把动作解释为速度指令并做位置积分。这样做的原因是DQN训练需要成千上万个episode,每一步仿真都必须足够快。如果引入真实的气动模型和PID控制器,单步耗时会增加几十倍,训练效率无法接受。对于避障航迹规划,核心是空间避障决策,而不是飞行控制精度,所以质点模型是够用的。
参数说明:dt=0.1 秒对应 10Hz 的控制频率,这与大多数开源飞控外部控制接口的默认频率一致。max_speed=5 米/秒限制了单步最大位移是 0.5 米,配合动作步长 1.5 米,无人机并不能一步完成一个动作的完整位移,而是需要 3 个控制周期左右,这样的模拟更接近真实运动。world_bounds 限制了飞行范围,超出边界需要在上层环境里判定为失败并给出惩罚,否则无人机容易飞出可用的空间。
这里有一个反直觉的选择:我没有用现成的 ROS 仿真器。原因是 DQN 训练需要快速重置和互动,ROS 的节点通信和物理步进会带来额外延迟。常见做法是先在一个纯 Python 模拟器里把策略学出来,再放到更逼真的仿真环境里做迁移测试。资源包也附带了一个把状态转移抽象成索引的接口,方便你后续接到其他模拟器。
3.2 障碍物随机生成与碰撞检测细节
障碍物的生成不能纯随机。如果障碍物重叠或者起点和终点之间不存在可行通道,训练就会产生大量无解 episode,浪费时间。我习惯生成球形障碍物,半径在 1 到 2.5 米之间,并且在生成时检查与已有障碍物的间距。
def generate_scene(seed=None, num_obs=10, min_gap=3.0): if seed: random.seed(seed) # 评估时固定种子,保证可复现 obstacles = [] for _ in range(num_obs): x = random.uniform(-20, 20) y = random.uniform(-20, 20) z = random.uniform(3, 15) r = random.uniform(1.0, 2.5) too_close = False for (ox, oy, oz, orad) in obstacles: # 计算两个球心的距离,并和半径之和加上最小间距比较 if np.linalg.norm([x-ox, y-oy, z-oz]) < r + orad + min_gap: too_close = True break if not too_close: obstacles.append((x, y, z, r)) return obstacles逻辑说明:生成障碍物时,除了检查球体之间不重叠,还加了 min_gap=3 米的间距,这样场景中会自然留出一些可行通道。如果没有任何间距约束,随机生成的障碍物可能形成一堵密不透风的墙,导致无人机无论如何都飞不到目标。球形障碍物的碰撞检测非常简单,只要无人机位置到球心的距离小于障碍半径加上无人机半径 0.5 米,就算碰撞。
参数说明:num_obs=10 是中密度场景。太多,单局训练成功率非常低,网络很难从失败中学到有效经验;太少,避障问题退化,学出来的策略遇到密集障碍就失效。min_gap=3 米是一个安全值,比无人机直径大,又不会让场景过度空旷。z 的范围我限制在 3 到 15 米,保证障碍物都分布在无人机可飞的高度区间,不做无意义的地面或高空障碍。
碰撞检测除了球体,还考虑了地面边界。飞行高度低于 0.5 米或者高于 30 米都算越界,越界后环境会提前终止并给 -2 的惩罚。这个惩罚比碰撞 -10 要小,但比时间惩罚大,目的是让无人机学会在可行空域内飞行。
3.3 训练数据收集:避免过拟合的几点做法
很多第一次做这个项目的人会把训练场景固定成一个,然后期望网络记住这唯一的环境,这样换一个障碍物布局就废了。我在资源包里用的是每个 episode 开始前重新生成障碍物、随机起点和终点,并且在每 500 个 episode 之后插入一轮固定场景评估。
def reset(self, mode='train'): if mode == 'train': self.obstacles = generate_scene(seed=None) else: self.obstacles = eval_scene # 固定评估场景 # 随机起点和终点,距离控制在20到40米 self.start = self.random_point(radius_range=(5, 10)) self.target = self.random_point(radius_range=(20, 40)) self.pos = self.start.copy() return self.get_state(...)逻辑说明:随机化布局和起终点,是为了让无人机学会通用的“感知-避障-导向”策略,而不是记忆某一条特定路径。固定评估场景则用来监测真实的学习进度,因为随机场景下成功率波动很大,看不清楚模型整体是在变好还是变坏。训练时每个 episode 只跑一次场景,场景之间的可能性几乎是无限的,这也是DQN经验回放能够发挥作用的前提。
参数说明:起点距离原点 5 到 10 米,终点距离原点 20 到 40 米,这种设置保证了两点之间有足够多的障碍物需要绕行,又不会因为目标太远导致步数用尽。如果你把终点放在 80 米外,单局要跑上百步,训练效率会明显下降。评估场景固定后,每 500 个 episode 跑一次 50 局,统计成功率,我习惯记录在日志里方便对比。
还有一点:训练集和评估集的场景生成参数要略有差异。我训练时障碍物数量范围是 8 到 12,评估时固定为 10 个但随机种子不同,这样能看出模型对密度的泛化。如果你训练和评估用的是完全一样的生成函数,模型可能会悄悄记住障碍物分布,评估成绩虚高。
4. DQN训练脚本解析:从经验回放到目标网络的参数细节
训练脚本是整个项目里最容易因为参数没调好而翻车的地方。这一章把网络结构、经验回放、目标网络更新和 epsilon 衰减都拆开讲,并给出我验证过能用的配置。
4.1 网络结构与输入输出维度
DQN网络在三维避障中不需要太深。输入是上一章构造的 76 维状态向量,输出是 7 个动作各自的 Q 值。中间用两层全连接加 ReLU 就够,每一层 128 个神经元。
class DQN(nn.Module): def __init__(self, state_dim=76, action_dim=7, hidden=128): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim) ) def forward(self, x): return self.net(x)逻辑说明:为什么不用 CNN 处理激光雷达数据?因为我的状态已经手工降采样成距离数组,不再有空间拓扑的平移不变性,全连接层反而能更直接地学习距离值和动作之间的映射关系。一些开源项目会用一维卷积对激光雷达读数做特征提取,但在实验里收敛速度和最终成功率都没有明显优势,网络还更复杂。
参数说明:hidden=128 是一个内存和精度都很均衡的选择。如果你把隐藏层改成 256,单个 step 的推理时间会翻倍,训练时间从几小时变成十几小时,而成功率的提升往往在 1% 以内。激活函数用 ReLU 没问题,但注意不要在最后一层用任何激活,Q 值应该是无约束的实数。
学习率我一般设 0.0001,用的是 Adam 优化器。Adam 对新任务通常比较稳,不需要手动调整太多。如果训练 loss 在初期就发散,先把学习率降到 3e-5。另外,网络权重初始化我用了默认的 Kaiming 均匀初始化,不需要额外改动。
4.2 经验回放缓冲区的大小怎么定
经验回放是DQN稳定训练的基础。缓冲区太小,样本相关性高,训练不稳定;太大,样本覆盖广,但抽到的新鲜经验比例更低。我的配置是 100000 条经验,batch size 是 64。
class ReplayBuffer: def __init__(self, capacity=100000): self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size=64): batch = random.sample(self.buffer, batch_size) states, actions, rewards, next_states, dones = zip(*batch) return (np.array(states), np.array(actions), np.array(rewards), np.array(next_states), np.array(dones))逻辑说明:deque(maxlen=capacity) 的好处是自动丢掉最老的经验,不用手动管理索引。随机采样打破了时间相关性,让网络更新时用的样本尽量独立同分布。100000 条经验按每个 episode 平均 100 步、10 万个步数来算,能覆盖大约 1000 个 episode 的经历,足够支撑前期的探索。
参数说明:batch_size 64 是我常用的起点。如果训练时 loss 抖动很厉害,可以试着加大到 128,梯度会更平滑,但每次更新计算量会上升。capacity 太低时,比如 10000,网络会很快遗忘掉早期的探索经验,导致后期动作选择单一。我的习惯是先跑 2000 个 episode,观察 buffer 是否已经填满,如果在填满之前 episode 数已经很多,说明 episode 平均步数很长,可以适当调小 capacity 或者加大 episode 终止步数上限。
缓冲区里的经验帧我做了轻量级处理:所有状态在存入前先归一化,action 存成整数,reward 存成浮点。这样做能减少内存占用,也让 sample 出来的张量直接是 float32。如果你的环境状态维度更大,可以用 numpy 的数组预分配替代 deque,但 100000 条以内 deque 完全够用。
4.3 目标网络更新频率和 epsilon 衰减
目标网络的存在是为了让Q值更新时有一个稳定的目标,避免自举导致的发散。目标网络参数是周期性从在线网络复制过来的,这个周期的长短会直接影响训练稳定性。epsilon 决定探索率,它同样需要精心调整。
# 每 C 步同步一次目标网络 if global_step % target_update_freq == 0: target_net.load_state_dict(net.state_dict()) # epsilon 衰减,使用指数衰减 epsilon = max(epsilon_min, epsilon * epsilon_decay) # 衰减率 0.995,最小值 0.05逻辑说明:target_update_freq 我设的是 500 步,也就是每 500 次环境交互后,把在线网络当前参数完全复制给目标网络。如果这个频率太快,比如 100 步,目标Q值变化过快,训练容易震荡;如果太慢,比如 5000 步,目标值过于陈旧,行动选择会偏向保守。epsilon 从 1.0 开始,前 1000 步基本是随机探索,之后按指数衰减到 0.05,后期几乎完全使用策略动作。
参数说明:epsilon_decay=0.995 意味着每步乘以 0.995,大约到第 600 步时 epsilon 掉到 0.05 附近,探索期其实很短。如果你发现无人机后期几乎没有探索行为,可以设置 epsilon_min=0.1 或把 decay 改为 0.998。这里有个小技巧:如果训练到一半发现模型陷入局部最优,可以手动把 epsilon 拉回 0.5,让它重新探索一段时间再慢慢衰减,我经常用这个土办法跳出局部最优。
训练循环里还有一个常用 trick:每隔一定步数保存一次模型,并保留最近三个版本。很多时候训练到后半程反而会稍微退步,回滚到之前的版本比重新训练要快得多。我把模型保存频率设为 1000 个 episode 一次,文件名带 episode 号和平均奖励,方便回溯。
5. 避坑:DQN三维无人机避障训练中常见的五个坑
这一章把我在训练中踩过的坑整理成五条,每条按“现象、原因、解决”的顺序来拆解,希望能帮你省下几个通宵。
5.1 训练时 loss 突然变成 NaN
现象:日志里 loss 在某一步直接变成 NaN,Q 值也变成异常大数,训练进程直接报废。
原因:奖励数值设计不当时,Q 值会持续增长,最后导致梯度爆炸。特别是碰撞惩罚和到达奖励设置跨度太大,网络为了拟合这些极端值会给出接近无穷的 Q 值估计。
解决:先给梯度做裁剪。在 optimizer.step() 前加torch.nn.utils.clip_grad_norm_(net.parameters(), max_norm=1.0)。同时把奖励函数里所有系数都缩小 10 倍重新测试,找到不爆炸的尺度。另外,检查经验回放里是否有 inf 或 NaN 状态,一旦出现就丢弃该条经验。
5.2 无人机在原地打转,奖励一直很小但不会撞
现象:训练曲线显示每个 episode 的累计奖励都差不多,无人机在原地转圈,既不靠近目标也不碰撞。
原因:时间惩罚每步扣 0.01,原地悬停虽然被扣分,但如果距离变化奖励小于时间惩罚的抵消,悬停就成了局部最优。常见原因是距离变化奖励系数太小,或者目标距离没有归一化,导致远距离时距离变化量很大,近距离时变化量很小,网络对近目标不敏感。
解决:把距离变化奖励改成相对变化,比如(d_prev - d_new) / d_prev,或者加大系数到 1.0。同时给每个 episode 加上最大步数限制,比如 200 步,超过就直接终止并给 -1 惩罚,这样原地打转不再是一个安全选择。
5.3 训练了很久成功率依然很低,无人机总是撞同一个方向的障碍
现象:训练两万步后,评估成功率不到 20%,观察轨迹,无人机几乎总是朝某个固定方向(比如正前方)撞过去。
原因:epsilon 衰减太快,无人机在早期还没有充分探索完所有动作方向时,就已经开始贪心地沿着某一方向冲。另外,状态里没有包含每个探测方向对应的角度索引,网络无法将“前方有障碍”和“左前方有障碍”区分开。
解决:把 epsilon_decay 从 0.995 改成 0.998,让探索期持续更长。同时在状态向量里加入一个最小距离层的索引,也就是哪个方向上的距离最近,这样网络能够定位危险方向。我加了这个索引之后,成功率提升了十几个百分点。
5.4 测试时无人机在障碍物表面反复横跳
现象:模型评估时,无人机靠近障碍物后不断在两个动作之间来回切换,航迹在障碍物边缘形成锯齿状,消耗大量步数。
原因:DQN输出的Q值在两个动作上接近相等,没有平滑机制。步长设得过大时,无人机每一步都跨过较大距离,好不容易绕到障碍物侧面又冲过头,所以只能在边缘徘徊。
解决:把动作步长从 1.5 米降到 1.0 米,同时加入一个“防抖”机制:如果当前动作与上一步动作相同,奖励加 0.02,鼓励连续执行同一动作。我实际用过这种动作平滑奖励,锯齿明显减少,飞行轨迹也更好看。
5.5 换成测试场景就失灵,训练场景里成功率很高
现象:训练时成功率 90%,换一个障碍物布局评估,立刻掉到 30% 以下。
原因:训练时没有随机化场景,或者随机化范围太小,网络记住了某些障碍物位置的映射。即使是随机生成障碍物,如果 radius 和位置范围固定,也可能让网络对特定分布过拟合。
解决:训练时每 1000 个 episode 就把障碍物生成参数调整一次:改变数量范围(8 到 12)、半径范围(1 到 3)、位置范围(z 到 20 米)。同时把起点和终点也做成随机范围,不要固定在几个常用点。我在评估时使用 5 个不同随机种子生成 100 个场景,稳定性才有参考意义。
5.6 自查训练日志的三个检查点
每次训练中断后,我会先看三个东西:loss 曲线是否收敛到一个稳定区间、epsilon 当前值是否还在合理范围、最近 100 局的平均奖励是否呈现上升趋势。如果 loss 已经不再下降,但平均奖励还在涨,说明网络仍在学习;如果两个都停了,就要考虑调大学习率或重置 epsilon。我通常在训练目录下用 tensorboard 记录这几个标量,看不到曲线时就直接 grep 日志里的平均奖励,这个习惯帮我避免了不少无效训练。
6. 验证与进阶:训练完成后如何证明你的避障策略真的能用
训练完不代表能出门,这一章用最少的验证手段告诉你模型到底行不行。
6.1 模型评估指标:成功率、航程代价、碰撞次数
我会在固定评估场景里跑 100 个 episode,统计三个指标:成功率、平均航程代价、碰撞次数。成功率是到达终点的比例;航程代价用实际飞行路径长度除以两点间直线距离,反映避障绕行的效率;碰撞次数就是撞上障碍物的总次数。一个合格的模型应该达到成功率 90% 以上、航程代价小于 1.5、碰撞次数为 0。
def evaluate(agent, env, episodes=100): success = 0 path_len = 0.0 collision = 0 for seed in range(episodes): state = env.reset(mode='eval') done = False while not done: action = agent.choose_action(state, epsilon=0.0) # 纯贪婪 next_state, reward, done = env.step(action) path_len += env.last_movement_length() if env.is_collision(): collision += 1 break state = next_state if env.at_target(): success += 1 return success, path_len / episodes, collision逻辑说明:评估时 epsilon 强制为 0,完全使用当前策略,不加入随机探索,否则成功率会有水分。path_len 只统计成功到达终点的 episode 的路径长度,失败或碰撞的路径不计入航程代价,避免拉低平均值。碰撞次数单独统计,它比成功率更敏感,有时候成功率差不多但碰撞次数差别很大。
6.2 可视化:把航迹画出来看拐弯是否平滑
指标之外,我还会把几条代表性航迹画成三维曲线。用 matplotlib 的 Axes3D 把起点、终点、障碍物和飞行路径画在一起,直接看路径是否平滑、是否为了避障过度绕远。如果路径出现大量折线,说明动作切换太频繁,需要加强动作平滑奖励或降低探索率。
6.3 从仿真到真机:一个简单的小技巧
从仿真迁移到真机时,最简单有效的一步不是调参,而是检查你的状态输入。仿真里激光雷达数据是理想距离,真机上激光雷达有噪声和最大测量距离限制。我的习惯是在训练时就给距离值加一个高斯噪声,标准差 0.1 米,并设置最大量程 15 米,这样模型对真机传感器的鲁棒性会好很多。另一个小技巧是把训练好的 Q 网络导出成 ONNX,在真机边缘设备上跑推理,动作输出频率 10Hz,配合飞控的速度控制接口就能用。
从那以后,我每次训练完都不会急着打包,而是强制自己先跑一遍评估脚本、看成功率、看三条航迹、看碰撞次数,全部合格才敢说模型能用。这套流程虽然啰嗦,但确实帮我避开了好几次“看起来收敛,实际上是个废模型”的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取