
别人做无人机避障都是直接上PX4和Gazebo那套重装备我这次反着来——只用一个MacBook装好Python和PyTorch在纯仿真环境里用DQN把避障策略跑通。这篇文章就把整个项目的思路、代码、调试过程全部摊开讲适合刚接触强化学习、想用无人机做点实际东西的读者参考。先说清楚这个项目到底做了什么用PyTorch搭建一个Double DQN网络训练四旋翼无人机在一张随机生成的障碍物地图里自主飞行从起点安全到达目标点。障碍物是随机圆形无人机只需要避开它们走到终点就算成功。整个过程没有用任何机器人操作系统ROS和复杂物理引擎全部自己写代码能跑、能看、能改。1. 项目整体设计与环境搭建1.1 无人机避障这件事为什么用DQN来做经典路径规划算法比如A*、RRT在已知静态地图里表现很好但一旦地图变化或者无人机面对的是未知环境传统规划算法就要依赖反复重规划实时性很难保证。DQN这类强化学习算法适合这个场景的核心原因在于它不依赖精确的先验地图而是通过大量探索试错让智能体学习“看到什么样的局部信息就该采取什么动作”这一映射关系。另一个原因是DQN对硬件和算力的要求相对友好。完整跑一次PPO或者SAC当然也行但对环境步数、采样效率的要求更高调参也更折磨人。DQN加上Double DQN的小改进只需要一张普通显卡甚至CPU都能跑出效果来。我这次就是纯CPU训练整个收敛过程大概花了一个多小时完全在可接受范围内。其实很多初学者容易陷入一个误区以为避障一定需要高精度传感器、复杂的位姿估计、SLAM建图其实在仿真阶段我们完全可以简化掉这些。先让算法学会基于局部观测做决策再往里面加传感器噪声、动力学约束这就是一个渐进的可控过程。我一直觉得做强化学习项目最重要的一点就是“能拆多简单就拆多简单”复杂度要逐步累加而不是一步到位。1.2 环境搭建的主要步骤我使用的是Anaconda来管理Python环境这套流程我在多台机器上试过最稳。如果你已经有Python环境也可以直接用venv但Anaconda对后续装PyTorch、opencv这类库的处理更省心。安装过程我直接给了完整命令跟着执行就行。# 创建python3.9环境建议用3.8到3.10之间版本太新容易遇到依赖兼容问题 conda create -n drone_dqn python3.9 conda activate drone_dqn # 安装PyTorch CPU版本macOS用户和Windows用户都可以用这一条 # 如果你有NVIDIA显卡去掉--index-url参数直接安装默认CUDA版本即可 pip install torch2.0.1 --index-url https://download.pytorch.org/whl/cpu # 安装数据处理和可视化的库 pip install numpy opencv-python matplotlib我这里测试用的环境是Python 3.9.18、PyTorch 2.0.1CPU版、NumPy 1.24.3。这里有个细节值得说PyTorch 2.0之后对Apple Silicon做了优化MPS后端在MacBook上训练效率比CPU快不少代码里只需要把设备从“cpu”改成“mps”就行。如果你是Windows用户无CUDA环境就直接用CPU这个项目的网络规模不大CPU完全能吃得消。注意如果你用Windows系统并且打算用GPU训练请务必先确认CUDA版本和PyTorch版本匹配。常见问题就是装了CUDA 11.8的驱动却装了要求CUDA 12.1的PyTorch导致GPU不可用最后只能退回CPU训练白白浪费时间。1.3 基础环境验证环境装完之后第一时间跑一段简单代码验证能不能正常用。import torch import numpy as np print(PyTorch版本:, torch.__version__) print(NumPy版本:, np.__version__) # 测试一个简单的张量运算 x torch.tensor([[1.0, 2.0], [3.0, 4.0]]) y torch.matmul(x, x.T) print(矩阵运算测试:\n, y) print(CPU线程数:, torch.get_num_threads())如果输出正常说明环境没问题。我遇到过的坑是装了OpenCV之后numpy版本被自动升级到2.x导致部分依赖numpy 1.x的库报错处理方式是装完所有库之后统一固定numpy版本pip install numpy1.24.32. 仿真环境建模从物理模型到状态空间2.1 无人机运动模型的简化我不想引入复杂的空气动力学模型那会瞬间拉高整个项目的复杂度所以无人机运动模型直接采用二维平面内的运动学模型。把无人机当作一个质点在二维平面中移动能前进、转向、左右平移。对避障任务来说二维模型已经能完成核心验证三维扩展就是在二维基础上增加高度维度和对应动作算法逻辑不变。设定一个离散时间步长每个step为0.2秒也就是每0.2秒做一次决策。无人机的运动方程简化后如下x_new x v * cos(yaw) * dt y_new y v * sin(yaw) * dt yaw_new yaw yaw_rate * dt其中v是线速度常量yaw_rate是角速度根据动作不同取不同值。这样设计的好处是代码简洁同时又能保留无人机“转弯需要时间”的特性不是瞬间改变方向让避障决策更有挑战性。还有个关键点无人机有“传感器”用来感知障碍物。我用射线检测ray casting的方式模拟激光雷达从无人机当前位置向多个方向发射射线检测射线与障碍物的交点得到障碍物距离。这种方式比栅格地图更接近真实传感器的观测方式也为以后接入真实激光雷达数据铺好了路。2.2 状态空间设计无人机“看到”什么状态空间是整个环境设计的灵魂。状态设计得好算法训练效率高状态设计得差网络怎么调都不收敛。我最终采用的是8维状态向量状态维度含义备注x无人机当前x坐标归一化到[0,1]y无人机当前y坐标归一化到[0,1]yaw当前朝向角tanh归一化到[-1,1]r1正前方障碍物距离归一化最大1.0r2左前方障碍物距离归一化最大1.0r3右前方障碍物距离归一化最大1.0gx目标点相对x方向归一化到[-1,1]gy目标点相对y方向归一化到[-1,1]前三项描述无人机自身状态中间三项是传感器输入最后两项是目标信息。为了把目标点信息编码进状态并没有直接把目标点的全局坐标放进去而是使用“目标点在无人机坐标系下的相对方向”。比如目标在无人机的右前方那gx、gy就会编码出一个偏右的方向向量。这种处理方式的好处是网络能学习到相对关系而不是死记硬背绝对坐标。我的经验是状态空间的归一化是所有环节里最不能偷懒的一步。如果输入值域在0到100和0到1之间混着传进网络梯度更新很容易不稳定收敛非常慢甚至会发散。我用了一个简单的辅助函数来处理归一化def normalize(value, max_value): return max(0.0, min(1.0, value / max_value))三个射线距离的最大值都设为5米超过5米就按5米算归一化成1.0。这一步虽然是细节但对训练稳定性的贡献很大。强化学习对输入数据的尺度极其敏感很多人训练不收敛最后查来查去发现就是输入没归一化。2.3 动作空间设计无人机如何行动动作空间采用了离散动作设计这是DQN最适配的设置。因为DQN本质上是计算每个离散动作的Q值然后选最大的那个如果动作连续比如控制油门量连续值DQN就不方便直接用了。我的动作空间包含4个动作动作编号动作含义运动学参数0直行v1.0, yaw_rate01左转v0.8, yaw_rate0.52右转v0.8, yaw_rate-0.53悬停v0.0, yaw_rate0直行速度最快转弯时速度稍微降低悬停则完全不动。悬停这个动作很关键——很多初学者在设计避障任务时只设计“前进、左转、右转”三个动作结果无人机遇到障碍物正面堵路时只能不断左右摇摆很容易陷入震荡。加一个悬停动作让无人机有了“等待”的能力在紧急情况下可以原地调整朝向然后重新规划方向。动作空间不是越大越好。一个原则是动作之间的区分度要大不要设计两个很相似的动作。比如设计“左转15度”和“左转30度”网络很难精确区分会造成策略不稳定。我早期尝试过6个动作结果训练时间明显变长收敛效果反而不如4个动作。2.4 奖励函数训练好坏的指挥棒奖励函数是整个项目里最需要反复打磨的部分它直接决定了网络学到什么样的策略。我最终的奖励函数是这么设计的def get_reward(self, state, done, collision, reached, step): reward 0.0 # 到达目标点大额正向奖励 if reached: reward 100.0 # 碰撞大额负向奖励 if collision: reward - 100.0 # 朝向目标移动小步正向奖励 # 每步最多给2.0的奖励越接近目标方向奖励越大 reward 2.0 * direction_reward # 为了保证训练稳定每步扣除微小时间惩罚 reward - 0.01 * step # 近距离障碍物惩罚 # 距离在0.5米内开始触发越近惩罚越大 min_dist min(state[3], state[4], state[5]) * 5.0 if min_dist 0.5: reward - 5.0 * (0.5 - min_dist) return reward这里最值得解释的是“朝向目标移动”这个奖励。我计算无人机当前位置到目标点的距离同时跟踪上一时刻的距离。如果当前距离变短了给正向奖励变长了给负向奖励。这看起来简单但有个大坑如果奖励大小和距离变化量直接相等那么网络可能学会“原地转圈刷奖励”的坏策略因为只要方向对了就能拿到奖励不用真的前进。我的解决办法是把方向奖励和速度动作绑定。只有选择了直行动作且方向正确时才能拿到满额的方向奖励转弯动作的方向奖励减半悬停动作不给方向奖励。这样网络必须学会“朝目标方向直行”才能拿到最高奖励而不是原地转圈。关于奖励函数我强烈建议你多跑几个版本对比。这个项目我一共迭代了三版奖励函数第一版没有距离惩罚项无人机频繁擦着障碍物边缘飞过第二版距离惩罚太大无人机变得过于保守离障碍物还很远就停下来不敢动第三版才是上面这个平衡的版本。**奖励函数没有完美的只能不断试出来的。**这是强化学习项目里最大的实话。2.5 场景生成与重置机制为了让训练出的策略有泛化性每一轮训练都要随机生成新的障碍物布局。我使用的场景逻辑是在一个10m×10m的正方形区域内随机生成6-10个圆形障碍物每个障碍物的半径在0.3到0.8米之间随机。每次重置场景时需要检查几个条件起点和终点不能被障碍物覆盖需要保证距离任何障碍物的最小距离大于0.5米起点和终点的距离必须大于4米确保任务有足够的飞行距离障碍物之间的间距不能太小避免形成“死胡同”这些检查不是可选项。如果起点直接生成在障碍物正中间无人机无论怎么走都立刻碰撞奖励函数再合理也学不出有效策略。用代码写出来是def reset(self): self.generate_scene() # 重试多次直到找到合法场景 while not self._is_scene_valid(): self.generate_scene() # 初始化无人机状态 self.drone_x, self.drone_y self.start_pos self.yaw np.random.uniform(-np.pi, np.pi) return self._get_state()每次训练回合开始都会随机化无人机的初始朝向角度这能防止网络过拟合一个固定方向增加策略的鲁棒性。就像真实无人机在空中不可能每次起飞都面朝同一个方向。3. DQN算法原理与PyTorch网络实现3.1 从Q-Learning到DQNQ-Learning是经典的值迭代方法通过维护一个Q表来记录每个状态下每个动作的期望回报。如果状态是离散且有限的Q表可以正常工作但如果状态连续比如上面提到的8维连续状态Q表就完全不可行——状态空间是无限的没法穷举存储。DQN的核心思想是用神经网络替代Q表输入是状态向量输出是每个动作的Q值估计。网络通过不断更新参数来逼近真实的Q函数。Q值更新的核心公式如下Q_new(s, a) r gamma * max_a Q_target(s, a)其中gamma是折扣因子我设为0.99。这个公式的含义是当前状态动作对的价值等于立即奖励加上未来最大价值的折扣和。直接用一个网络去做更新很容易出现“追逐自己尾巴”的问题——因为更新目标也来自同一个网络的估计参数一更新目标也跟着变了训练会不稳定。DQN的两个关键改进就是为了解决这个问题。3.2 Double DQN为什么它能显著提升避障效果原始DQN存在严重的过估计问题max_a Q(s, a)这一步天然偏向于高估Q值。在无人机避障任务里过估计的后果就是无人机对“安全”的判断过于乐观明明障碍物很近Q值还认为继续前进能拿到高回报导致无视障碍物直接撞上去。Double DQN的核心改进思路是动作选择和价值评估解耦。# 原始DQN target_q_value r gamma * max(target_net(next_state)) # Double DQN best_action argmax(eval_net(next_state)) target_q_value r gamma * target_net(next_state)[best_action]关键在于用当前网络eval_net选择最佳动作再用目标网络target_net计算这个动作的Q值。两个网络同时高估同一个动作的概率远低于一个网络高估的概率因此能有效缓解过估计。实际训练中我的失败实验也证实了这一点。用原始DQN训练时无人机在简单场景下能勉强到达终点但在障碍物密集的场景中频繁碰撞成功率不到30%换成Double DQN后相同训练轮次内成功率提升到了65%以上。如果你做DQN项目我建议直接上Double DQN代码复杂度增加很小效果提升很明显。3.3 网络结构设计网络结构参考了经验回放DQN的经典设计针对8维状态输入和4个动作输出做了适配。import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): def __init__(self, state_dim8, action_dim4, hidden_dim128): super(DQN, self).__init__() # 输入层8维状态 self.fc1 nn.Linear(state_dim, hidden_dim) # 隐藏层 self.fc2 nn.Linear(hidden_dim, hidden_dim) # 输出层4个动作的Q值 self.fc3 nn.Linear(hidden_dim, action_dim) # 权重初始化这里很讲究 nn.init.xavier_uniform_(self.fc1.weight) nn.init.xavier_uniform_(self.fc2.weight) nn.init.xavier_uniform_(self.fc3.weight) def forward(self, x): x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.fc3(x)这里有两个细节经验值得分享第一个是隐藏层的选择。我用的是128×128的两层结构没有使用更深的网络。对于这个任务输入维度低、任务复杂度有限太深的网络反而需要更多训练样本才能收敛。我试过256×128和128×64的网络效果都不如128×128稳定。不是说越深越好而是要根据任务复杂度匹配网络容量。第二个是激活函数。隐藏层用ReLU是常规选择但输出层不能用ReLU或者Sigmoid必须保持线性输出。因为Q值需要回归任意大小的实数加了激活函数反而限制了表达范围。很多初学者在这里犯错误在输出层加了ReLU结果Q值非负导致网络无法表达负向奖励训练直接失败。3.4 核心训练逻辑代码上面说完了网络定义现在给出完整训练逻辑。这里是整个项目最核心的代码我加上了详细注释class ReplayBuffer: 经验回放缓冲区 def __init__(self, capacity10000): self.buffer deque(maxlencapacity) 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) state, action, reward, next_state, done map(np.array, zip(*batch)) return state, action, reward, next_state, done def __len__(self): return len(self.buffer) class DQNAgent: def __init__(self, state_dim8, action_dim4, lr1e-4, gamma0.99, epsilon1.0, epsilon_min0.01, epsilon_decay0.995, buffer_capacity20000, batch_size128, target_update200): # Double DQN的两个网络 self.eval_net DQN(state_dim, action_dim) self.target_net DQN(state_dim, action_dim) self.target_net.load_state_dict(self.eval_net.state_dict()) self.target_net.eval() # 目标网络不参与训练 self.optimizer torch.optim.Adam(self.eval_net.parameters(), lrlr) self.loss_fn nn.SmoothL1Loss() # Huber Loss比MSE更能容忍异常值 self.action_dim action_dim self.gamma gamma self.epsilon epsilon self.epsilon_min epsilon_min self.epsilon_decay epsilon_decay self.buffer ReplayBuffer(buffer_capacity) self.batch_size batch_size self.target_update target_update self.learn_step_counter 0 def choose_action(self, state): Epsilon-greedy策略选择动作 if np.random.random() self.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 torch.argmax(q_values).item() def update(self): 经验回放更新网络 if len(self.buffer) self.batch_size: return # 从经验池中采样 states, actions, rewards, next_states, dones self.buffer.sample(self.batch_size) states torch.FloatTensor(states) actions torch.LongTensor(actions).unsqueeze(1) rewards torch.FloatTensor(rewards).unsqueeze(1) next_states torch.FloatTensor(next_states) dones torch.FloatTensor(dones).unsqueeze(1) # 当前Q值 q_values self.eval_net(states).gather(1, actions) # Double DQN计算目标Q值 with torch.no_grad(): # 用评估网络选动作 next_actions self.eval_net(next_states).argmax(1, keepdimTrue) # 用目标网络算Q值 next_q_values self.target_net(next_states).gather(1, next_actions) target_q_values rewards self.gamma * next_q_values * (1 - dones) # 计算Loss并反向传播 loss self.loss_fn(q_values, target_q_values) self.optimizer.zero_grad() loss.backward() # 梯度裁剪很重要防止梯度爆炸导致训练崩溃 torch.nn.utils.clip_grad_norm_(self.eval_net.parameters(), 1.0) self.optimizer.step() # 定期软更新目标网络 self.learn_step_counter 1 if self.learn_step_counter % self.target_update 0: self.target_net.load_state_dict(self.eval_net.state_dict()) def decay_epsilon(self): 探索率衰减 self.epsilon max(self.epsilon_min, self.epsilon * self.epsilon_decay)这段代码里有几个值得注意的经验首先是SmoothL1LossHuber Loss而不是传统的MSE。这个Loss对于误差大的样本梯度是常数而非线性增长对异常值不敏感。在训练初期Q值估计误差可能很大MSE会导致梯度过大网络参数剧烈震荡。我实测下来换用Huber Loss后训练过程明显稳定了很多。其次是梯度裁剪。这里设置最大梯度范数为1.0效果是可以防止因某个异常数据的梯度爆炸导致训练崩溃。这一步虽小但在DQN中非常关键很多DQN训练中期突然发散的问题根源就在这里。目标网络更新间隔这里设200步更新一次。我试过100、200、500三组参数200步的效果最好既不会因更新太频繁导致训练不稳定也不会因更新太慢导致目标值滞后太厉害。我在调试过程中发现还有一个更新策略是软更新Polyak averagingtarget_params tau * target_params (1 - tau) * eval_params比如tau0.005每一步都让目标网络向当前网络缓慢靠近比间隔硬拷贝更平滑。这个思路更稳但会增加约20%的计算量。我这次用硬更新就满足需求了追求更稳定的话可以试试软更新。4. 训练主循环与避障策略收敛分析4.1 训练主循环代码训练主循环是连接环境、Agent和场景的桥梁。核心逻辑是每回合重置环境每个step中选择动作、执行动作、获得奖励、存储经验、更新网络、检查回合结束条件。单回合内最多执行200步超过200步还未到达终点则判定超时提前结束回合。def train(): env DroneEnv() agent DQNAgent() episodes 1000 max_steps 200 for episode in range(episodes): state env.reset() total_reward 0 done False for step in range(max_steps): action agent.choose_action(state) next_state, reward, done, info env.step(action) # 关键经验入库 agent.buffer.push(state, action, reward, next_state, done) # 每步更新一次网络 agent.update() state next_state total_reward reward if done: break # 每个回合结束衰减探索率 agent.decay_epsilon() # 每50个回合打印一次训练状态 if episode % 50 0: success_flag info.get(reached, False) print(fEpisode: {episode}, Total Reward: {total_reward:.2f}, fEpsilon: {agent.epsilon:.3f}, Success: {success_flag}) # 保存模型 torch.save(agent.eval_net.state_dict(), dqn_drone.pt)这里有个容易被忽略的细节agent.buffer.push这行代码初学者经常忘了执行导致经验池永远为空网络根本不更新无人机完全靠随机探索乱飞训练半天没有任何效果。每次调试这个项目我都会先确认经验池长度是否在增长。4.2 超参数选择和实战调优记录超参数的选择不是我一开始就定下来的中间踩了不少坑。我把最终的配置和试过的改动整理成了表格方便对照。超参数最终选择试过的其他值经验总结学习率 lr1e-41e-3, 3e-41e-3会震荡发散1e-4最稳折扣因子 gamma0.990.9, 0.95越小越短视避障中容易只顾眼前探索率初始值1.00.5初期必须充分探索1.0正确探索率衰减系数0.9950.99, 0.9990.99衰减太快后半程探索不足经验池容量200005000, 1000005000太小容易遗忘100000训练太慢批量大小12832, 6432更新噪声太大128较平滑目标网络更新间隔200100, 500200步与训练节奏匹配较好学习率这块我特别想强调一下DQN对学习率非常敏感。最初尝试1e-3时前100个回合的奖励一直在大幅波动有一种“好了又坏、坏了又好”的感觉这就是梯度过大导致网络参数在两个状态之间来回振荡。降到1e-4之后波动明显减小收敛速度反而更快看起来“慢”实际是“稳中快进”。探索率衰减系数0.995意味着从初始值1.0降到0.01大约需要约900个回合因为0.995^900≈0.01。这给我的总训练回合数定了个基调少于1000回合探索率还没降到位策略还没收敛。所以训练回合数最后定为1000。4.3 训练过程曲线解读训练过程中的一个典型回合表现是前200个回合总奖励普遍是负值在-50到-20之间徘徊成功率基本是0%无人机在障碍物间乱撞。这是正常现象因为此时epsilon还很高无人机大多数时间在随机探索。第300回合左右开始出现转折。无人机偶尔能避开第一个障碍物但在复杂场景中仍然容易碰撞。总奖励的波动变小从负值逐渐向0靠近说明网络开始学到点有用的东西了。到第500回合总奖励变成正数出现一些零星的“到达目标点”的成功案例。这个阶段特别关键因为一旦出现一次成功网络会获得100的奖励这个信号会让策略倾向于重复能走向终点的路径。第800回合后成功率稳定在60%-80%之间总奖励稳定在50左右。个别复杂场景还是会失败比如障碍物挤成一堆、通道过于狭窄的场景但这些失败本身也说明了仿真环境的多样性足够泛化性还在继续增强。训练结束后我保存了模型权重然后单独写了一个测试脚本把epsilon直接设为0让无人机完全按照学到的策略做贪心决策。测试了100个随机场景成功率约在70%。这个数字不算优秀但对一个简单的仿真项目来说已经能证明DQN确实学到了避障能力而不是靠运气蒙过去的。4.4 策略可视化让无人机“自己飞”训练过程中我还用OpenCV做了一个简单的可视化窗口实时绘制无人机位置、射线、障碍物和目标点。可视化对调试的帮助非常大因为单独看loss曲线很难判断策略的好坏但用眼睛直接观察无人机是怎么飞的问题一目了然。import cv2 import numpy as np def render(self): # 创建一个800x800像素的画布对应10m×10m区域比例80像素/米 canvas np.ones((800, 800, 3), dtypenp.uint8) * 255 # 绘制障碍物 for obs in self.obstacles: ox, oy, radius obs center (int(ox * 80), int(oy * 80)) cv2.circle(canvas, center, int(radius * 80), (100, 100, 100), -1) # 绘制射线 for angle_offset, dist in [(-np.pi/4, self.r_left), (0, self.r_front), (np.pi/4, self.r_right)]: angle self.yaw angle_offset end_x (self.drone_x np.cos(angle) * dist) * 80 end_y (self.drone_y np.sin(angle) * dist) * 80 cv2.line(canvas, (int(self.drone_x * 80), int(self.drone_y * 80)), (int(end_x), int(end_y)), (0, 0, 255), 2) # 绘制无人机和起点终点 drone_pos (int(self.drone_x * 80), int(self.drone_y * 80)) cv2.circle(canvas, drone_pos, 8, (255, 0, 0), -1) cv2.circle(canvas, (int(self.start[0] * 80), int(self.start[1] * 80)), 6, (0, 255, 0), -1) cv2.circle(canvas, (int(self.goal[0] * 80), int(self.goal[1] * 80)), 6, (0, 200, 0), -1) return canvas可视化有一个重要的调试技巧观察射线长度和无人机转向之间的逻辑关系。如果无人机右前方射线很短检测到障碍物但依然右转说明策略有问题如果前方射线短、左右射线长无人机选择左右转弯说明策略学到的是合理的避障逻辑。这种“看行为”的方式比盯着loss曲线更直观、更有效。5. 常见问题与排查技巧实录5.1 训练不收敛奖励曲线一路狂跌这是强化学习项目最经典的崩溃现场。我排查这个问题时按照以下顺序逐一检查第一步检查训练数据是否存在异常波动比如奖励是否有“尖刺”Q值是否太大或太小。第二步检查经验回放逻辑确认经验池在持续读取数据而且缓冲区里的数据是新的不是旧数据反复训练导致网络只记住了过去的经验对新场景完全失效。第三步检查探索率如果探索率衰减过快网络会过早进入“利用”阶段在一个不够好的策略里出不来。优先级最高的检查项是梯度的数值。如果在训练中途打印loss.item()发现数值在几十到几百之间来回跳说明Loss过大。我遇到这个情况时把MSE换成了Huber Loss同时加上了梯度裁剪两个改动一上去Loss从剧烈震荡变为稳步下降。还有一个容易被忽略的点是经验池里的数据分布。如果缓冲区容量太小比如我最初用的5000早期探索阶段的“坏数据”会一直留在池子里反复被采样导致后续训练过程被高比例的失败经验干扰。5.2 无人机明明检测到障碍物就是不知道绕开如果无人机在遇到障碍物时只会急停或者原地乱转说明避障策略学偏了。我发现原因出在奖励函数上。当无人机靠近障碍物时我会给一个强烈的负向奖励这让网络学到了“靠近障碍物很危险”的结论但因为奖励大小设置问题它宁可停下来也不愿意绕行。绕行这个行为需要“连续转向并前进”才能完成。如果单步奖励设计不合理网络很容易选择最保守的策略悬停。解决办法分两步第一把临近障碍物的惩罚从“连续惩罚”改成“只惩罚碰撞”给无人机留出绕行的空间第二把“离目标点更近”这一信号加入奖励让无人机有动力在不碰撞的前提下持续推进。另一个经常被忽略的原因是动作空间过小。如果动作里没有“左转前进”的组合动作网络只能通过“先原地转方向再直行”来完成绕行这使得行为序列很长奖励稀疏难以学习。我后来把悬停加进动作空间也是为了给无人机更多行为组合的选择。5.3 训练集上表现好换个场景就翻车训练时的场景是随机生成的但“随机”的范围不够大容易让网络过拟合到特定的障碍物模式上。我遇到过的情况是训练时障碍物基本都是圆形且分布均匀但测试时出现狭长通道无人机就不知道怎么飞了。解决思路有两种。第一种是增加训练场景的多样性障碍物不仅可以是圆形还可以引入矩形、多边形障碍物的数量可以从8个变化到6-10个随机取值。第二种是引入课程学习先从单障碍物、无障碍物的简单场景开始训练等策略稳定后再逐步增加障碍物数量和复杂度让网络从简单到复杂地学习。课程学习的效果比我预期的更好最终模型的泛化能力明显提升成功率从70%提高到78%左右。5.4 代码运行报错的常见解决方案AttributeError: module numpy has no attribute intnumpy 1.24移除了别名需要把代码里的np.int改成intnp.float改成float。这个错误在运行老代码时真的很常见。RuntimeError: Found no NVIDIA driver on your system没有安装CUDA的机器用torch.device(cpu)即可或者在安装时明确使用CPU版本的PyTorch。core dumped / 内存溢出经验池容量设置过大加上状态数据是numpy数组内存消耗会陡增。一种解决方法是适当缩小capacity另一种是经验池中用np.float32存储而非np.float64内存占用直接减半。6. 后续扩展方向从仿真到实机还有多远仿真里跑通DQN只是第一步。如果想往实机部署走有几个方向值得继续探索状态空间加噪声仿真环境是完美感知的真实传感器会有噪声。可以给状态加入高斯噪声模拟传感器误差让策略更鲁棒。连续动作控制因为DQN输出的是离散动作飞行轨迹会有“折线感”不够平滑。可以用DDPG或TD3替换DQN让动作输出变成连续量飞行更自然。三维扩展把当前二维平面场景扩展到三维增加高度控制和Z方向的障碍物检测这也是从仿真走向实机的必经之路。跟现代硬件结合验证过的模型结构可以部署到边缘设备比如Jetson系列这时需要注意网络结构要尽量轻量化量化、剪枝等部署技术都值得学习。对于想进一步深入的朋友我个人的建议是先把Double DQN玩透再去看PPO和SAC。优先搞懂它们处理经验的方式、更新策略的差异这对培养强化学习直觉非常有帮助。而且这些算法都不缺实现代码关键是自己动手跑一遍把每个环节的细节弄清楚。最后再分享一个实操中的体会强化学习项目的调试本质上就是在“奖励设计”和“超参数”之间反复腾挪耐心比技巧更重要。这个项目从开始写代码到跑出稳定结果我前后迭代了将近两周大部分时间都花在观察无人机飞行行为、调整奖励、重新训练这三件事上。但正是这些反复调试的过程让我真正理解了DQN为什么会收敛、为什么会发散、哪些参数影响最大。如果你照着代码跑通一遍也去改一改奖励函数、调一调学习率亲眼看看策略的变化相信你也会有同样的感觉。