news 2026/9/18 12:20:06

基于PyTorch的DQN无人机避障实战:从仿真环境到智能体训练

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于PyTorch的DQN无人机避障实战:从仿真环境到智能体训练

做无人机避障项目时,我被一个问题困扰了很久:雷达数据明明能测到障碍物,程序也知道目标在哪,可真要让无人机自己飞过去,规则怎么写都不对劲。后来我把这个问题完全交给强化学习处理,用Python搭仿真环境、用PyTorch实现DQN算法,从随机乱撞开始训练,最终得到一个能主动躲开障碍、顺利到达目标点的智能体。整个过程从环境搭建到实战训练大概花了两周,中间踩了不少坑。这篇文章把完整思路和可运行代码都整理出来,适合刚接触强化学习、想用DQN解决实际控制问题的朋友。

1. 先把问题想清楚:避障任务为什么适合用DQN解决

1.1 无人机避障的本质是一个序列决策问题

无人机从起点飞到终点,每一时刻都要做一次选择:直行、左转还是右转。这个选择会影响后续所有状态——可能在两秒后撞上障碍物,也可能绕出一条更远但安全的路。这种“当前动作影响未来结果”的任务,本质上是马尔可夫决策过程,而强化学习处理的正是这一类问题。

和普通监督学习不同,避障任务里没有现成的“正确动作”标签。比如无人机离障碍物3米时该往左还是往右,没有一个标准答案,取决于目标方向、飞行速度、周围障碍物分布等多种因素。单纯靠人工标注数据来训练一个分类器,既难覆盖所有场景,也很难处理连续决策中的时序关联。强化学习的策略是不断试错,用奖励信号来告诉智能体“这次做得好不好”,然后逐步调整策略。

1.2 DQN相比规则法和传统Q-Learning的优势

手写规则的思路是:探测到障碍物距离小于某个阈值就转弯。听起来简单,实际做起来很痛苦。障碍物在左边还是右边?距离多近该转?转弯角度多大?密集成片的障碍物怎么处理?每条规则只能覆盖一种情况,规则之间还容易冲突。我在初期就写过一版规则避障,逻辑分支几十条,换一个场景就失灵,完全不可维护。

DQN走的是另一条路:把“当前状态”映射到“每个动作的价值”。Q-Learning本身就能做这件事,但传统Q表需要离散化状态。无人机的传感器数据是连续值,如果强行分箱,状态空间会爆炸,假设距离值分成20档、角度分成20档,组合起来就几百上千个状态,实际存不下也学不好。DQN的关键是用深度神经网络代替Q表,直接从连续状态中拟合Q值,效果和扩展性都好得多。

1.3 仿真先行:为什么先在二维环境验证

我强烈建议第一步不要直接上真机,甚至不要先上三维仿真,先在二维平面里把算法跑通。二维环境里状态表达直观、训练速度快,一个episode通常几秒就结束,方便反复调试奖励函数和网络结构。等DQN完全收敛、避障行为稳定后,再把逻辑迁移到Gazebo或AirSim这类三维仿真平台。

二维环境看起来简单,但它保留了强化学习的核心难点:状态连续性、时延决策、稀疏奖励。算法能在二维问题里学会避障,迁移到三维只是改状态输入和动作接口的问题。

2. 环境搭建:Anaconda虚拟环境与PyTorch安装的版本细节

2.1 创建独立的Python虚拟环境

我建议用Anaconda管理项目环境,不要直接装在系统Python里。一个项目一套环境是基本原则,避免后面装其他库时互相冲突版本。创建命令如下:

conda create -n uav_dqn python=3.9 conda activate uav_dqn

Python版本选3.9比较稳妥,兼容PyTorch全系列版本。如果你机器上已经装了Anaconda,直接用上面的命令创建环境即可。不想装Anaconda的话,用python -m venv uav_dqn建立虚拟环境也完全可行,只是后面管理CUDA相关依赖时conda会更省心。

2.2 PyTorch的CPU版与GPU版安装

安装PyTorch之前先确认机器有没有独立NVIDIA显卡,再用nvidia-smi查看CUDA版本。我的机器是RTX 3060,笔记本显存6GB,所以装了CUDA版本的PyTorch。

# CPU版本 pip install torch torchvision # GPU版本(以CUDA 11.8为例) pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118

这里有一个反复出现的坑:很多人安装了GPU版PyTorch后,torch.cuda.is_available()返回False。原因通常是显卡驱动版本太低,或者安装的PyTorch对应的CUDA版本与驱动不兼容。排查顺序很简单:先看驱动支不支持目标CUDA版本,再确认安装命令里指定的CUDA版本号是否正确。

注意:如果你只是想在本文环境里跑通DQN,CPU版本完全够用。DQN网络本身很小,CPU训练这个避障任务也就慢20%左右,没必要为了这点性能去折腾CUDA。

2.3 依赖库清单与安装验证

除了PyTorch,还需要几个基础库:numpy负责数值计算,matplotlib负责绘制训练曲线。安装命令:

pip install numpy matplotlib

安装完成后,用下面这段命令验证环境是否正常:

import torch import numpy as np print("PyTorch version:", torch.__version__) print("CUDA available:", torch.cuda.is_available()) print("Numpy version:", np.__version__)

确认能正常打印版本号,环境这步就算完成了。整个过程里唯一容易出问题的就是GPU版本,如果发现torch.cuda.is_available()为False,直接把PyTorch降级为CPU版本,不要在上面浪费太多时间。

3. 仿真环境设计:状态空间、动作空间与奖励函数

3.1 场景与坐标系定义

我设计的仿真场景是一个100×100的平面空间,无人机从左下角出发,目标是右上角,中间随机分布6个圆形障碍物。坐标轴定义很简单:x轴向右,y轴向上,无人机位置用二维坐标表示,朝向用与x轴正方向的夹角表示,单位是弧度。

环境类的核心职责是:维护无人机和障碍物的位置,执行动作后更新无人机位置,计算传感器读数,判断是否碰撞或到达目标。定义如下:

class UAVEnv: def __init__(self, width=100.0, height=100.0, sensor_num=8, sensor_range=25.0): self.width = width self.height = height self.sensor_num = sensor_num self.sensor_range = sensor_range self.obstacle_radius = 8.0 self.collision_dist = 2.0 self.arrive_dist = 3.0 self.max_steps = 200 self.start_pos = np.array([8.0, 8.0]) self.target_pos = np.array([width - 8.0, height - 8.0]) rng = np.random.default_rng(42) self.obstacles = [] for _ in range(6): while True: x = rng.uniform(20, self.width - 20) y = rng.uniform(20, self.height - 20) if (np.linalg.norm([x - self.start_pos[0], y - self.start_pos[1]]) > 25 and np.linalg.norm([x - self.target_pos[0], y - self.target_pos[1]]) > 25): self.obstacles.append((x, y, self.obstacle_radius)) break

障碍物生成采用拒绝采样:先随机选坐标,如果落在起点或终点附近25单位范围内就重新采样,保证每个episode的初始状态是合理的。固定随机种子42的好处是训练结果可复现,方便排查问题。后面如果想增加环境随机性,把default_rng(42)改成default_rng()即可。

3.2 激光雷达式的状态感知设计

状态空间是整个环境设计里最关键的环节,直接决定算法能不能学到有效策略。我参考了激光雷达的工作原理:从无人机当前位置向8个方向发射射线,每个方向间隔45度,返回该方向上最近障碍物的距离。

为什么选8个方向而不是4个或16个?我做过对比实验:4个方向感知太粗糙,无人机经常“看”不到侧后方的障碍物;16个方向信息量更足,但状态维度翻倍,训练收敛变慢,而避障效果提升不明显。8个方向在感知精度和训练效率之间比较平衡。

除了障碍物距离信息,状态里还需要包含目标相关信息:目标相对距离和目标相对角度。否则无人机只知道哪边有障碍,不知道往哪边飞才能到终点。完整状态向量如下:

状态分量维度说明归一化方式
传感器读数88个方向的障碍物最近距离除以最大探测距离25
目标相对距离1无人机到目标的欧氏距离除以场地尺寸200
目标相对角度1目标方向与当前朝向的夹角除以π

所有状态分量都归一化到大致[-1, 1]或[0, 1]区间,这个细节很重要。神经网络对输入数值范围敏感,如果原始距离值从0到100直接喂进去,数值大的维度会主导梯度更新,导致收敛极慢甚至不收敛。

状态计算的核心是射线与圆形障碍物的求交。我实现的_raycast方法逻辑如下:把无人机位置作为射线起点,从起点指向障碍物圆心得到向量,计算这个向量在射线方向上的投影。如果投影为负,说明障碍物在无人机身后,忽略。然后计算圆心到射线的垂直距离,如果垂直距离大于障碍物半径,说明射线不会穿过障碍物。否则用勾股定理求出交点距离。

def _get_state(self): sensor_readings = [] for i in range(self.sensor_num): angle = self.uav_heading + i * (2.0 * math.pi / self.sensor_num) sensor_readings.append(self._raycast(angle) / self.sensor_range) target_dx = self.target_pos[0] - self.uav_pos[0] target_dy = self.target_pos[1] - self.uav_pos[1] target_dist = math.hypot(target_dx, target_dy) / (self.width + self.height) target_angle = math.atan2(target_dy, target_dx) angle_diff = (target_angle - self.uav_heading + math.pi) % (2.0 * math.pi) - math.pi angle_diff /= math.pi return np.array(sensor_readings + [target_dist, angle_diff], dtype=np.float32) def _raycast(self, angle): direction = np.array([math.cos(angle), math.sin(angle)]) min_dist = self.sensor_range for ox, oy, r in self.obstacles: oc = np.array([ox, oy]) - self.uav_pos proj = np.dot(oc, direction) if proj < 0: continue perpendicular = np.linalg.norm(oc - proj * direction) if perpendicular <= r: dist = proj - math.sqrt(max(r * r - perpendicular * perpendicular, 0)) if 0 < dist < min_dist: min_dist = dist return min_dist

3.3 动作空间与转向模型

动作空间我选择了5个离散动作:直行、左转15度、右转15度、左转30度、右转30度。每次执行动作后,无人机先转向,再沿当前朝向直线前进2个单位长度。

这里有一个设计权衡:为什么不把转向角设为连续值?理论上DQN能输出连续动作,但连续动作空间的探索效率极低,算法需要大量样本才能找到合适的精确角度,对于二维避障来说没有必要。离散动作简单直接,5个动作足够覆盖“微调方向”和“快速转向”两种需求,训练难度也低很多。

为什么不直接用360度的转向?转向步长太大会导致无人机左右摇摆,轨迹呈锯齿状,而且容易“跳过”正确方向。15度和30度两个档位分别负责精细调整和快速避障,实际效果比单一档位好。

def _apply_action(self, action): if action == 0: delta = 0.0 elif action == 1: delta = math.radians(15) elif action == 2: delta = -math.radians(15) elif action == 3: delta = math.radians(30) elif action == 4: delta = -math.radians(30) else: raise ValueError(f"Invalid action: {action}") self.uav_heading += delta self.uav_pos[0] += 2.0 * math.cos(self.uav_heading) self.uav_pos[1] += 2.0 * math.sin(self.uav_heading) self.uav_pos[0] = min(max(self.uav_pos[0], 0.0), self.width) self.uav_pos[1] = min(max(self.uav_pos[1], 0.0), self.height)

3.4 奖励函数设计的细节与演进过程

奖励函数是强化学习里最需要用心的地方,它直接决定智能体学到的行为模式。我最初的版本很简单:碰撞给-100,到达给+100,其余步骤奖励为0。结果训练了几百轮,智能体完全学不会避障,因为绝大多数状态下反馈都是0,它根本不知道哪些动作是有意义的。

后来我加入了“距离变化引导”和“步数惩罚”,奖励函数变成:

事件奖励值设计原因
到达目标+100最终目标,大额正向奖励
碰撞障碍-100强烈惩罚危险行为
每步基础惩罚-0.01鼓励用最短路径到达
距离缩短奖励+0.5 × 距离减少量引导无人机朝目标前进

距离缩短奖励是关键。每执行一步动作,计算移动前后无人机到目标点距离的变化,距离缩短了就给予正奖励,变远了则给负奖励。相当于把稀疏的大目标奖励“铺”到了每一步,让智能体即使没有到达终点,也能感受到当前动作是好是坏。这个技术叫Reward Shaping(奖励塑形)。

但奖励权重需要调,不能太大。我曾经把距离奖励权重设为2.0,结果无人机确实一路冲向目标,但完全无视障碍物,因为高速接近目标带来的单步奖励(约2到3)远大于未来碰撞的远期惩罚(-100被折扣系数稀释成小值),学出来是个“莽夫”。把权重降到0.5后,避障行为明显改善。

完整的step方法如下:

def step(self, action): prev_dist = np.linalg.norm(self.uav_pos - self.target_pos) self._apply_action(action) self.step_count += 1 current_dist = np.linalg.norm(self.uav_pos - self.target_pos) reward = -0.01 terminated = False truncated = False if self._check_collision(): reward = -100.0 terminated = True elif current_dist < self.arrive_dist: reward = 100.0 terminated = True else: # 距离减少量归一化到步长2对应的尺度 reward += 0.5 * (prev_dist - current_dist) / 2.0 if not terminated and self.step_count >= self.max_steps: truncated = True return self._get_state(), reward, terminated, truncated

注意:step方法返回的terminated表示episode因碰撞或到达而结束,truncated表示因步数超限被强制截断。这两者的概念在DQN训练里要区分,计算目标Q值时只有terminated才需要把未来奖励置0,truncated情况下一步还会继续。

碰撞检测的实现也比较直观:

def _check_collision(self): for ox, oy, r in self.obstacles: if np.linalg.norm(self.uav_pos - np.array([ox, oy])) < self.collision_dist + r: return True return False

3.5 完整环境代码整合

把上述片段组合起来,就是完整的UAVEnv类。reset方法负责把无人机放回起点、重置朝向和步数计数,并返回初始状态:

import math import numpy as np class UAVEnv: def __init__(self, width=100.0, height=100.0, sensor_num=8, sensor_range=25.0): self.width = width self.height = height self.sensor_num = sensor_num self.sensor_range = sensor_range self.obstacle_radius = 8.0 self.collision_dist = 2.0 self.arrive_dist = 3.0 self.max_steps = 200 self.start_pos = np.array([8.0, 8.0]) self.target_pos = np.array([width - 8.0, height - 8.0]) rng = np.random.default_rng(42) self.obstacles = [] for _ in range(6): while True: x = rng.uniform(20, self.width - 20) y = rng.uniform(20, self.height - 20) if (np.linalg.norm([x - self.start_pos[0], y - self.start_pos[1]]) > 25 and np.linalg.norm([x - self.target_pos[0], y - self.target_pos[1]]) > 25): self.obstacles.append((x, y, self.obstacle_radius)) break def reset(self): self.uav_pos = self.start_pos.copy() self.uav_heading = 0.0 self.step_count = 0 return self._get_state() def step(self, action): prev_dist = np.linalg.norm(self.uav_pos - self.target_pos) self._apply_action(action) self.step_count += 1 current_dist = np.linalg.norm(self.uav_pos - self.target_pos) reward = -0.01 terminated = False truncated = False if self._check_collision(): reward = -100.0 terminated = True elif current_dist < self.arrive_dist: reward = 100.0 terminated = True else: reward += 0.5 * (prev_dist - current_dist) / 2.0 if not terminated and self.step_count >= self.max_steps: truncated = True return self._get_state(), reward, terminated, truncated def _apply_action(self, action): if action == 0: delta = 0.0 elif action == 1: delta = math.radians(15) elif action == 2: delta = -math.radians(15) elif action == 3: delta = math.radians(30) elif action == 4: delta = -math.radians(30) else: raise ValueError(f"Invalid action: {action}") self.uav_heading += delta self.uav_pos[0] += 2.0 * math.cos(self.uav_heading) self.uav_pos[1] += 2.0 * math.sin(self.uav_heading) self.uav_pos[0] = min(max(self.uav_pos[0], 0.0), self.width) self.uav_pos[1] = min(max(self.uav_pos[1], 0.0), self.height) def _get_state(self): sensor_readings = [] for i in range(self.sensor_num): angle = self.uav_heading + i * (2.0 * math.pi / self.sensor_num) sensor_readings.append(self._raycast(angle) / self.sensor_range) target_dx = self.target_pos[0] - self.uav_pos[0] target_dy = self.target_pos[1] - self.uav_pos[1] target_dist = math.hypot(target_dx, target_dy) / (self.width + self.height) target_angle = math.atan2(target_dy, target_dx) angle_diff = (target_angle - self.uav_heading + math.pi) % (2.0 * math.pi) - math.pi angle_diff /= math.pi return np.array(sensor_readings + [target_dist, angle_diff], dtype=np.float32) def _raycast(self, angle): direction = np.array([math.cos(angle), math.sin(angle)]) min_dist = self.sensor_range for ox, oy, r in self.obstacles: oc = np.array([ox, oy]) - self.uav_pos proj = np.dot(oc, direction) if proj < 0: continue perpendicular = np.linalg.norm(oc - proj * direction) if perpendicular <= r: dist = proj - math.sqrt(max(r * r - perpendicular * perpendicular, 0)) if 0 < dist < min_dist: min_dist = dist return min_dist def _check_collision(self): for ox, oy, r in self.obstacles: if np.linalg.norm(self.uav_pos - np.array([ox, oy])) < self.collision_dist + r: return True return False

4. DQN核心代码拆解:Q网络、经验回放与目标网络

4.1 Q网络结构设计

DQN的神经网络结构不用太复杂,三全连接层足够处理这个10维状态输入的任务。第一层128个神经元,第二层128个神经元,输出层5个神经元,对应5个动作的Q值。

import torch import torch.nn as nn import torch.optim as optim import numpy as np import random from collections import deque class DQNNetwork(nn.Module): def __init__(self, state_dim=10, action_dim=5, hidden_dim=128): super().__init__() self.net = 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.net(x)

网络结构这里有个经验之谈:不是网络越大越好。我试过把隐藏层加到256×256,训练速度明显变慢,但最终效果和128相当。对于这种低维输入的决策问题,一层网络很难拟合复杂非线性关系,两层是性价比较高的选择。层数继续增加到4层,在当前的任务规模下只会增加过拟合风险。

4.2 经验回放的设计与作用

经验回放是DQN相比传统Q-Learning的重要改进。在Q-Learning中,转移样本(state, action, reward, next_state, done)是逐个更新模型的,前后样本之间存在强相关性,网络参数更新时容易被连续相关的数据带偏,训练不稳定。经验回放的做法是把样本存进一个缓冲区,训练时随机抽取一批样本更新网络,打破数据相关性。

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): batch = random.sample(self.buffer, batch_size) states = np.array([x[0] for x in batch], dtype=np.float32) actions = np.array([x[1] for x in batch], dtype=np.int64) rewards = np.array([x[2] for x in batch], dtype=np.float32) next_states = np.array([x[3] for x in batch], dtype=np.float32) dones = np.array([x[4] for x in batch], dtype=np.float32) return ( torch.FloatTensor(states), torch.LongTensor(actions).unsqueeze(1), torch.FloatTensor(rewards), torch.FloatTensor(next_states), torch.FloatTensor(dones), ) def __len__(self): return len(self.buffer)

缓冲区容量我设为10万条。容量太小,早期样本很快被覆盖,智能体会“遗忘”刚开始的探索经验;容量过大则取样时容易抽到过时数据,导致策略更新滞后。10万条对应大约几百个episode的样本量,在这个项目里刚好合适。

4.3 目标网络:为什么需要以及怎么用

目标网络的引入是为了解决Q值“自举”的问题——训练时用目标Q值来更新当前Q值,如果目标Q值本身就是网络自己预测的,容易形成正反馈循环,导致Q值估值越来越大,训练发散。

解决办法是复制一份网络,参数不实时更新,每隔固定步数才同步一次。当前网络负责输出预测Q值,目标网络负责计算目标Q值,两者之间存在“时间差”,避免了自举带来的不稳定性。

class DQNAgent: def __init__(self, state_dim=10, action_dim=5, lr=1e-3, gamma=0.99, capacity=100000, batch_size=64, target_sync_interval=100): self.action_dim = action_dim self.gamma = gamma self.batch_size = batch_size self.target_sync_interval = target_sync_interval self.eval_net = DQNNetwork(state_dim, action_dim) self.target_net = DQNNetwork(state_dim, action_dim) self.target_net.load_state_dict(self.eval_net.state_dict()) self.target_net.eval() self.optimizer = optim.Adam(self.eval_net.parameters(), lr=lr) self.memory = ReplayBuffer(capacity) self.train_step = 0 def choose_action(self, state, epsilon=0.05): if np.random.rand() < epsilon: return np.random.randint(self.action_dim) state_tensor = torch.FloatTensor(state).unsqueeze(0) with torch.no_grad(): q_values = self.eval_net(state_tensor) return int(torch.argmax(q_values).item()) def update(self): if len(self.memory) < self.batch_size: return 0.0 states, actions, rewards, next_states, dones = self.memory.sample(self.batch_size) q_values = self.eval_net(states).gather(1, actions).squeeze(1) with torch.no_grad(): next_q_values = self.target_net(next_states).max(1)[0] targets = rewards + self.gamma * next_q_values * (1.0 - dones) loss = nn.MSELoss()(q_values, targets) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.train_step += 1 if self.train_step % self.target_sync_interval == 0: self.target_net.load_state_dict(self.eval_net.state_dict()) return loss.item()

target_sync_interval=100是我多次实验后的选择。同步太频繁(比如每步都同步),目标网络就失去了“时间差”意义,和单网络没区别;同步太慢(比如1000步),目标Q值会严重滞后,影响收敛速度。100步在这个项目中大约是1到2个episode,既能保持目标稳定,又能跟上策略变化。

4.4 训练主循环与环境交互流程

训练主循环的逻辑很清晰:每个episode先重置环境,拿到初始状态,然后反复执行“选择动作→执行动作→存入经验→采样更新”的流程,直到episode结束。这里需要引入epsilon-greedy探索策略——以一定概率随机选择动作,以保证初始阶段能尽可能多地探索环境。

def train(episodes=1000, batch_size=64): env = UAVEnv() agent = DQNAgent(state_dim=10, action_dim=5, batch_size=batch_size) epsilon = 1.0 epsilon_min = 0.02 epsilon_decay = 0.995 rewards_history = [] losses_history = [] for episode in range(episodes): state = env.reset() total_reward = 0.0 episode_loss = 0.0 update_count = 0 while True: action = agent.choose_action(state, epsilon) next_state, reward, terminated, truncated = env.step(action) done = terminated or truncated agent.memory.push(state, action, reward, next_state, done) loss = agent.update() if loss > 0: episode_loss += loss update_count += 1 state = next_state total_reward += reward if done: break epsilon = max(epsilon_min, epsilon * epsilon_decay) rewards_history.append(total_reward) losses_history.append(episode_loss / max(update_count, 1)) if (episode + 1) % 50 == 0: avg_reward = np.mean(rewards_history[-50:]) print(f"Episode {episode + 1}, Average Reward: {avg_reward:.2f}, Epsilon: {epsilon:.3f}") return agent, rewards_history, losses_history

训练时有一个容易忽略的问题:epsilon的衰减节奏。经典写法是每个episode乘一个衰减系数,但这样会造成一个偏差——如果某个episode特别长(比如200步才结束),这个episode里收集的经验非常多,epsilon变化却很慢;如果某个episode特别短(比如几步就撞了),epsilon又降得过快。更合理的方式是按总步数衰减。我在项目里最终选择了按episode衰减,因为当前环境每个episode的步数差异不大,但对更复杂的场景,建议改成按全局步数衰减,实现起来也简单,把衰减逻辑挪到训练循环内部即可。

epsilon的初始值设为1.0,即完全随机探索。衰减到0.02就停止,保留2%的随机性,防止策略陷入局部最优。

5. 训练实测与调参记录:从奖励不收敛到稳定避障

5.1 超参数配置与收敛过程

我的最终超参数配置如下:

超参数数值说明
Episode数1000训练轮数
批量大小64每次采样更新网络的样本数
学习率1e-3Adam优化器默认配置可跑通
折扣因子γ0.99重视长期收益
目标网络同步间隔100步每100步同步一次目标网络
经验池容量100000最大存储样本数
epsilon初始值1.0完全随机探索
epsilon最小值0.02保留一定随机性
epsilon衰减率0.995/episode每个episode乘一次

用这套参数训练,前100个episode的reward曲线几乎是平的,偶尔出现几个负的尖峰,这是因为epsilon很大,无人机还在大量随机探索,经常碰撞。200个episode之后,reward开始缓慢攀升,此时epsilon降到0.37左右,智能体开始学会利用已有经验。400到600个episode之间,reward出现明显增长,平均reward从负转正。700个episode以后基本稳定在正数区间,说明智能体已经能稳定避开障碍物。

训练完成后我单独跑了一轮评估,用epsilon=0(完全贪婪策略)连续测试10个episode,平均reward约75分,其中有8次成功到达目标,2次在密集障碍区域发生碰撞。对于二维仿真环境,这个表现已经算合格了。

5.2 三个我实际踩过的坑及排查过程

第一个坑是训练不收敛,reward始终在0以下徘徊。当时我怀疑是网络结构问题,试过加深加宽网络,没有效果。后来打印每个episode的平均Q值才发现,Q值的绝对值在持续变大,说明是目标网络同步频率过低导致Q值自举发散。把同步间隔从1000步改成100步后,训练逐渐恢复正常。这个问题的排查思路是:reward不涨不一定代表没在学习,需要看Q值是否稳定、loss是否异常。

第二个坑是无人机学会“绕远路”。具体表现是到达目标时间很长,reward能拿正数但偏低。原因分析:步数惩罚设置不合理,当时每步惩罚是-0.5,而距离缩短奖励只有0.5×(距离减少量/2)。操作下来,无人机宁可绕开所有障碍物也不走折线抄近路,因为每一次靠近障碍物的动作都会带来负的距离变化惩罚。解决办法是调大距离奖励权重,同时把步数惩罚降到-0.01,让无人机在“安全”和“快速”之间找到平衡。

第三个坑是训练后期reward震荡得很厉害。这其实是学习率和batch_size联合导致的,learning rate=3e-3时梯度步长太大,Q值更新容易幅度过猛,特别是在批量样本中存在少量碰撞样本时,loss会突然增大。把learning rate降回1e-3,震荡幅度明显减小。

提示:如果训练过程中出现了loss=nan,优先检查reward是否出现了极大值(比如超过1e6),以及网络里有没有做数值不稳定的操作。可以在update方法中加上torch.nn.utils.clip_grad_norm_(self.eval_net.parameters(), 10.0)做梯度裁剪,防患于未然。

5.3 评估模型:不只是看reward

很多人训练完只看奖励曲线就下了结论,我不建议这么做。奖励曲线只能反映整体趋势,无法体现避障行为的具体质量。我习惯做三类评估:

第一类是成功率评估。连续跑100个episode,统计成功到达目标的次数占比。这个指标最直接,成功率70%以上说明策略已经可用了。

第二类是轨迹可视化。把无人机每个episode的飞行轨迹画出来,观察是否有明显异常行为,比如原地打转、频繁急转弯、贴着障碍物飞等。轨迹图能暴露很多reward曲线反映不出的问题。

import matplotlib.pyplot as plt def evaluate_and_plot(agent, env, render=True): state = env.reset() positions = [env.uav_pos.copy()] total_reward = 0.0 while True: action = agent.choose_action(state, epsilon=0.0) state, reward, terminated, truncated = env.step(action) positions.append(env.uav_pos.copy()) total_reward += reward if terminated or truncated: break print(f"Test reward: {total_reward:.2f}") if render: positions = np.array(positions) fig, ax = plt.subplots(figsize=(6, 6)) for ox, oy, r in env.obstacles: circle = plt.Circle((ox, oy), r, fill=True, color='gray', alpha=0.5) ax.add_patch(circle) ax.plot(positions[:, 0], positions[:, 1], 'b-', linewidth=2) ax.scatter([env.start_pos[0]], [env.start_pos[1]], c='green', s=80, label='start') ax.scatter([env.target_pos[0]], [env.target_pos[1]], c='red', s=80, label='target') ax.set_xlim(0, env.width) ax.set_ylim(0, env.height) ax.legend() plt.show()

第三类是泛化测试。用固定随机种子42生成的障碍物只有一种布局,模型很可能只是“背下”了这个特定布局。我会重新生成新的障碍物布局测试模型,看它能否在没见过的场景里同样避障。这个测试非常关键——它能说明模型学到的是通用的“避障策略”而不是“记住地图”。

我在实验中把default_rng(42)改成default_rng()生成新布局,测试成功率从80%掉到65%,说明模型有一定泛化能力,但仍然受训练场景分布限制。解决办法是在训练时让每个episode随机生成障碍物布局,相当于变相增加训练样本多样性。修改后的环境类只需把reset方法里的障碍物生成逻辑移到每次reset时重新执行即可,这也是环境设计上最值得做的一项改进。

6. 从仿真到真机:差距在哪,下一步往哪走

6.1 仿真环境与真实环境的差异

二维仿真里跑通的策略,不能直接部署到真机上。真实无人机面临几个明显的差异:传感器有噪声,激光雷达读数不是精确的射线距离,而是带误差的测量值;状态反馈存在延迟,从传感器采集到动作执行有几十毫秒的时延;无人机本身有动力学约束,不能瞬间转向到指定角度,而是有一个加速、减速、转弯的过程。

应对办法是在仿真里加入这些“不理想因素”。比如给传感器读数加高斯噪声,给动作加随机扰动,把单步转向改成渐进转向。强化学习的一个优势是,只要环境建模得够真实,训练出的策略天然能适应这些扰动。我建议在二维仿真中加入一个小幅度噪声:sensor_readings += np.random.normal(0, 0.02, size=...),再观察模型表现,往往会有惊喜。

6.2 算法层面的改进方向

DQN本身有已知的缺点:Q值过估计导致策略过于激进。经典改进方案是Double DQN,核心改动只有几行代码——在计算目标Q值时,用当前网络选动作,用目标网络算Q值,而不是直接取目标网络的最大Q值。

# Double DQN的目标计算方式 with torch.no_grad(): next_actions = self.eval_net(next_states).argmax(dim=1, keepdim=True) next_q_values = self.target_net(next_states).gather(1, next_actions).squeeze(1) targets = rewards + self.gamma * next_q_values * (1.0 - dones)

这个改动能明显缓解训练中期的Q值虚高现象。另一个值得尝试的是Dueling DQN,把Q值拆成状态价值和动作优势两部分,在动作空间较大时能更快收敛。

如果你想让避障策略更接近真实无人机的感知方式,可以考虑用视觉图像作为状态输入。方法是在环境里渲染出一张俯视图,用CNN网络提取特征,再交给强化学习网络输出动作。这样状态空间就从10维变成了图像矩阵,训练难度会大幅增加,但更贴近真实场景中“用相机避障”的需求。AirSim这类仿真平台可以直接输出第一人称或俯视相机图像,搭配上面的环境逻辑改造即可。

6.3 从二维仿真到三维仿真的迁移路径

三维仿真推荐从Gazebo或AirSim入手。Gazebo配合PX4飞控,可以从激光雷达的/scan话题读取障碍物距离,这个数据格式和本文设计的8维传感器向量高度相似,只是维度更多、更新频率更高。控制层面,把离散转向动作翻译成cmd_vel话题的速度指令即可。

迁移时要做好两类适配工作。一是状态空间维度变了,传感器从8路变成360路,需要重新设计网络输入层;二是时间步长不同,仿真平台的决策周期是固定频率(比如10Hz),需要把每个动作在一个周期内匀速执行,而不是像二维仿真那样瞬间完成。核心的DQN训练逻辑、经验回放、目标网络机制都不需要改动,这也是在二维环境里把算法基础打扎实的价值所在。

我做这个项目最大的体会是:DQN难的不是搭网络,而是让智能体学会“会避障”和“会到达”这两件事的平衡。奖励函数权重调一次,行为模式就变一次;epsilon衰减节奏差一点,训练曲线就差一大截。如果你照着这套代码训练时发现模型不收敛,不要急着改网络结构,先检查奖励函数设计、epsilon衰减节奏和经验回放参数这三项,大部分问题都出在这三个环节。这套代码的所有核心逻辑已经完整给出,跑通一次之后,你就可以按自己的想法去改状态表达、换奖励函数,尝试Double DQN和Dueling DQN,一步步把避障智能体做得更实用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 12:20:06

国内AI模型API平台选型与实战指南

1. 项目概述&#xff1a;国内AI模型API平台现状与价值过去两年间&#xff0c;国内AI模型API服务市场呈现出爆发式增长态势。作为长期跟踪AI技术落地的从业者&#xff0c;我观察到头部科技企业、创业公司和科研机构都在积极构建自己的模型开放平台。这些平台将训练好的大语言模型…

作者头像 李华
网站建设 2026/9/18 12:18:16

PaddlePaddle与AI2环境搭建及模型迁移实战指南

1. 环境搭建背景与工具选型最近在尝试将AI2的研究成果迁移到PaddlePaddle框架时&#xff0c;发现官方文档中关于环境配置的说明比较分散。经过三天踩坑实践&#xff0c;整理出这套经过验证的安装方案&#xff0c;适用于Ubuntu 20.04/22.04系统&#xff0c;同时兼容NVIDIA和AMD显…

作者头像 李华
网站建设 2026/9/18 12:14:17

Python依赖注入框架a13x-depinj实战指南

1. 依赖注入框架的核心价值与a13x-depinj定位在软件开发中&#xff0c;依赖注入&#xff08;Dependency Injection&#xff09;早已不是新鲜概念&#xff0c;但真正能将其优雅落地到Python项目的框架却不多见。a13x-depinj这个命名看似随意&#xff08;作者承认是半夜敲代码时的…

作者头像 李华
网站建设 2026/9/18 12:13:16

Visual Studio 2019连接MySQL:环境、连接与乱码排查

数据库课程设计、上位机项目、实验室管理系统&#xff0c;只要业务数据落在 MySQL 上&#xff0c;Visual Studio 2019 就是绝大多数人打开的第一个工具。但真正动手的时候&#xff0c;麻烦往往不在代码本身&#xff1a;装完 MySQL 找不到连接器、NuGet 里挑错版本、连接字符串少…

作者头像 李华
网站建设 2026/9/18 12:12:27

基于Unity与C#的汉绣文化虚拟展馆3D交互漫游系统

1. 项目整体设计与技术选型思路汉绣这类非遗题材做数字化展示&#xff0c;最早我接触到的方案大多是网页端的360度全景图&#xff0c;点一下换个角度&#xff0c;看两眼就腻了。真正想做“展馆”那种沉浸感——推开大门、顺着回廊走、凑近看展柜里的绣品针脚——网页方案要么加…

作者头像 李华