做无人机避障项目时,我被一个问题困扰了很久:雷达数据明明能测到障碍物,程序也知道目标在哪,可真要让无人机自己飞过去,规则怎么写都不对劲。后来我把这个问题完全交给强化学习处理,用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_dqnPython版本选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个方向在感知精度和训练效率之间比较平衡。
除了障碍物距离信息,状态里还需要包含目标相关信息:目标相对距离和目标相对角度。否则无人机只知道哪边有障碍,不知道往哪边飞才能到终点。完整状态向量如下:
| 状态分量 | 维度 | 说明 | 归一化方式 |
|---|---|---|---|
| 传感器读数 | 8 | 8个方向的障碍物最近距离 | 除以最大探测距离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_dist3.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 False3.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 False4. 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-3 | Adam优化器默认配置可跑通 |
| 折扣因子γ | 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,一步步把避障智能体做得更实用。