DQN避障控制,这四个字拆开看,其实是三条技术线拧在一起的一次实战:用Pytorch搭建深度Q网络做决策,分别尝试基础DQN、优先级采样增强版,以及把传统人工势场算法揉进强化学习框架里做混合控制,最后用Matlab和Python两个平台把仿真跑通。这个方向很适合正在做机器人导航、无人车路径规划,或者想入门强化学习但不想只跑CartPole的读者——避障任务直观,奖励设计自由度高,环境也容易改造,是验证RL算法非常合适的试验田。
我一口气把这三个版本都写了,因为单纯跑通一个DQN其实意义不大,大家更关心的是“怎么让智能体学得更快、跑得更稳”。优先级采样解决样本利用效率,人工势场解决探索阶段的盲目性,这两个方向分别代表改进回放机制和引入经典控制知识跟深度强化学习的结合点。这篇文章会按“设计思路→算法原理→工具链→实操细节→实验对比→踩坑记录”的顺序写,代码片段和参数都尽量给出可复现版本。
1. 项目整体设计与思路拆解
1.1 避障控制任务到底是什么
移动机器人的避障问题可以概括成一句话:给定起点和目标点,在包含障碍物的环境中找到一条无碰撞路径,并且要能对动态变化做出实时响应。传统做法有Bug算法、人工势场、动态窗口法(DWA)、向量场直方图(VFH)这些,它们各有各的问题——要么容易陷入局部最小值,要么参数调起来非常依赖人工经验。
换成强化学习来做的理由很直接:把“感知状态—动作映射”交给网络去学习,不手写一堆规则。输入传感器测得的距离信息,输出转向和速度控制量,碰撞了给负奖励,到达目标给正奖励,多跑几轮它自己就能学会“贴着墙走会吃亏、直冲目标会撞车”这类因果。避障任务天然适合RL,因为状态空间和动作空间都能离散化,奖励信号虽然稀疏但有明确的成败边界。
1.2 三个版本的设计递进关系
这个项目里我做了三个版本,不是胡乱堆算法,而是有明确的递进意图。
第一个是最基础的DQN,用来建立整个训练管道的基线。它验证的是“深度Q网络能不能在二维避障仿真里学到有效策略”。这个版本跑通了,后面所有的改进才有意义。
第二个是优先级采样的DQN(PER-DQN)。基础DQN从经验回放池里随机抽样本,这个看似公平的做法其实很低效——那些“撞墙了”或者“到达终点了”的关键样本,在经验池里的占比并不高,随机采样很可能总抽到一些平淡无奇的转移。优先级采样改变的是抽样本的概率分布:TD误差大的样本说明网络预测偏差大,值得反复学习。这一步改进通常能把收敛速度提升一倍以上。
第三个是DQN加人工势场(APF)的混合控制。APF的思路是用引力把机器人拉向目标,用斥力把机器人推开障碍物,两者合力得出期望运动方向。我不用它替代DQN,而是把它跟DQN的输出融合:APF提供“当下最安全的局部动作”,DQN提供“全局最优策略下的动作”,两者按权重合成。这个组合能明显减少训练初期的碰撞次数,因为APF的斥力天然规避了“直愣愣撞墙”的愚蠢行为。
1.3 状态空间、动作空间与奖励设计
好的项目必须有清晰的任务建模。我用的环境是一个20m×20m的二维平面,机器人简化为圆形,半径0.2m,障碍物随机生成矩形区域,目标是地图上的一个固定点。
状态空间我选择两部分拼接:一部分是传感器读数,模拟激光雷达做7个扇形方向的障碍物距离检测,每个方向间隔45度,检测范围5m;另一部分是当前机器人相对目标点的距离和角度差。这样状态向量长度是9维,既保留局部避障信息,又保留全局导航信息。如果只给传感器不给目标信息,智能体根本不知道该往哪走;只给目标不给障碍信息,网络学不出避障能力。
动作空间离散成3个:左转30度、直行、右转30度,速度固定为0.5m/s。有人问为什么不用连续速度控制,这个后面会说,主要原因是离散版本稳定性高,实验对比起来也干净。
奖励函数的设计是整个项目最关键的决策之一。我采用的方案是:到达目标点半径0.5m范围内奖励+50,结束本回合;发生碰撞奖励-50,结束本回合;每一步施加-1的小惩罚,鼓励智能体尽快到达目标。在这个基础上,我还加入了距离变化量的奖励塑形:每步位置相对上一轮更接近目标就给+0.2的增量奖励,反之给-0.2。设置增量奖励的目的是缓解稀疏奖励问题,但要注意量化尺度不能压过终点奖励和碰撞惩罚的主导地位,否则智能体会学会原地绕圈刷距离奖励。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法原理:DQN、优先级采样与人工势场
2.1 DQN为什么需要经验回放和目标网络
基础DQN的核心公式大家应该不陌生:Q(s, a)表示在状态s下执行动作a的期望累积回报。更新时用Bellman方程逼近最优Q值,目标值由当前奖励加上折扣后的下一状态最优Q值构成。
但直接用神经网络拟合Q函数会出现两个麻烦。第一是样本间的强相关性,一个回合内的连续状态转移高度相似,按时间顺序训练会让网络在某段经验上反复震荡;第二是训练目标不稳定,如果拿同一个网络同时做预测和计算目标值,目标会随着网络参数变化不断漂移,训练数值容易发散。
经验回放解决第一个问题:把所有转移样本(s, a, r, s′, done)存进一个环形缓冲区,训练时随机抽一批,打乱时间顺序,破坏样本相关性。目标网络解决第二个问题:保留一份滞后同步的“旧网络”,用它计算目标Q值,主网络只负责预测和更新,每隔固定步数把主网络参数软拷贝过去。这两个机制是DQN能稳定训练的基础。
2.2 优先级采样到底改了什么
经验回放虽然随机,但随机不代表公平。有些样本对学习的价值远大于其他样本,比如机器人距离障碍物只剩0.3m时碰撞的那条转移,比它在空旷区域闲逛的转移信息量大多了。
优先级采样的思路就是:以TD误差绝对值作为样本重要性的衡量指标。每次更新网络后都会算出被采样样本的TD误差,这个误差反映了当前网络对这个样本预测的惊讶程度。误差大的样本放入缓冲区时保留高优先级,采样时就更可能被抽中。
但这里有个隐藏的坑,不能简单地“哪个重要就抽哪个”。频繁抽高优先级样本会导致回放池中某些样本被反复使用,网络对这些样本过拟合,最终整体性能反而下降。所以PER引入了重要性采样权重来修正偏差:样本被采到的概率越高,它在梯度更新时乘的权重就越小。这个权重由一个系数β控制,β在训练早期设成0.4,随训练进度线性升到1.0。β=1时修正完满,更新是无偏的。
具体实现上,采样概率的计算是P(i) = p_i^α / Σ p_j^α,其中p_i是该样本的优先级(一般取|TD误差|+一个小常数),α控制优先级参与程度,通常在0.4到0.7之间。存储结构用求和树(Sum Tree)实现。我现在项目的简化版本里用列表直接算L1距离也行,但样本多了之后求和树的O(log N)优势还是很明显的。
2.3 人工势场与DQN的融合位置
人工势场是一个经典到不能再经典的避障算法。目标点产生引力势场,障碍物产生斥力势场,机器人在势场中沿合力方向运动。公式上引力是U_att = 0.5 × k_att × d_target²,斥力是U_rep = 0.5 × k_rep × (1/ρ − 1/ρ0)²,ρ是机器人与障碍物的距离,ρ0是斥力影响范围,合力的方向就是势场梯度下降方向。
APF的最大问题是局部极小值:当引力斥力互相抵消,机器人可能停在某个位置出不来。但这个缺点在跟DQN融合时反而变成了优点——局部极小值的困境可以通过DQN的全局策略跳出。
我用的融合方式有两种,都在实验里跑过。
第一种是输出叠加:在网络的Q值输出层之后,把APF计算得到的期望转向角转换成一个优先动作向量,跟DQN的输出动作Q值做加权合成。具体公式是:Q_final = λ × Q_DQN + (1 − λ) × Q_APF,Q_APF是人工势场对三个离散动作的偏好打分。λ在训练前期设为0.6,后期逐渐提升到0.9,这样前期APF主导保证安全性,后期DQN学出来了主导精细导航。
第二种是奖励塑形:把APF的合力梯度方向跟当前动作的匹配程度作为一个附加奖励项加入奖励函数,让智能体学到“顺着势场方向走会多得奖励”。这个方法的优点是不动网络结构,只在奖励层面注入领域知识,缺点是需要调节势场奖励的尺度。
实际项目里我推荐先用第一种,因为它更直观,且能在训练早期就把碰撞次数压下来。第二种更适合作为奖励函数的补充手段,两者搭配使用效果最好。
3. 工具链选型与跨平台协同
3.1 为什么深度学习部分选Pytorch
这个问题没什么悬念。Pytorch的动态计算图特性让调试变得非常自然,你可以直接在断点处打印任意中间张量的形状和值,这在处理网络结构设计和PER采样逻辑联调时太重要了。另一个原因是Pytorch的生态足够成熟,torch.nn库里的常用模块基本都是现成的,不需要自己造轮子。
写强化学习代码还有个隐形的需求:你经常需要“边跑环境边改代码”。Pytorch的即时执行风格让这种做法效率很高。如果换成静态图框架,每改一次模型结构就得重新编译一次,调试体验会差很多。
3.2 Matlab在整个项目里的分工
很多人纠结“到底用Matlab还是Python”,其实完全没必要二选一。我的做法是Python负责训练,Matlab负责测试和可视化验证。
Python这边自建一个轻量级的二维环境类:写一个机器人运动学模型,模拟传感器读数,更新障碍物状态,记录碰撞和到达事件。这个环境类用面向对象的方式封装,接口参照Gym风格设计,reset返回初始状态,step接收动作、返回下一状态和奖励。训练效率高,改起来也快。
Matlab这边用矩阵运算构建同样的二维地图,重点是搭建一个独立于Python之外的运动学仿真测试床。训练好的模型参数导出成CSV文件,Matlab读取后用前向计算函数重建网络,让训练好的策略在另一个环境里跑避障测试。这里的关键是两边的坐标方向、角度单位、传感器模拟方式必须保持一致,否则会出现“Python里训练好、Matlab里跑不通”的尴尬情况。
3.3 两个平台的数据交换约定
我实际使用的交换格式是CSV文件。Python在每个训练阶段结束后导出两部分内容:一是全连接网络的权重和偏置,按层顺序平铺保存;二是训练日志,包含每个episode的累计奖励、碰撞次数、到达目标所用步数和成功率。
Matlab侧用readmatrix读取这些CSV,再写一个simple_predict函数,按照网络结构顺序做线性层计算和ReLU激活,最后输出三个动作的Q值,取最大对应的动作执行。整个过程绕开了深度网络的字段对齐问题,只关心数值正确性。这个方案其实有点“低技术含量”,但在跨语言工具链联调时是最稳的。如果你想要更轻量的传输格式,也可以保存为二进制npy文件后用Matlab的第三方工具包读取,但CSV的可检查性更高,出问题一眼就能看出来。
还有一点务必注意:Matlab环境里的障碍物配置和Python训练环境不能完全一样,否则测的是记忆不是泛化。要故意把训练环境里障碍物的位置、数量、形状做随机化,训练完再放到一组固定但从未见过的障碍分布里测试,这样才能反映策略的真实泛化能力。
4. 实践过程:从零搭建训练链路
4.1 网络结构与超参数选择
DQN网络的搭建采用三层全连接结构:输入层9维(传感器7维加目标相对距离1维加角度差1维),第一隐藏层128个神经元,第二隐藏层64个神经元,输出层3个神经元对应三个离散动作。
这里有两个细节值得展开。第一个是隐藏层的激活函数,ReLU是默认选择,没有太多悬念;问题是输出层不能用任何激活函数,直接输出Q值。如果输出层用了sigmoid把值压缩到[0,1]之间,Q值的范围表达就被限制死了,网络学不出大额奖励和巨额惩罚的区别。
第二个是优化器参数。我用Adam优化器,初始学习率0.001,权重衰减设为1e-5。学习率这一项很多人直接抄默认值,但实际调试中0.001配合批量大小64效果最稳。如果训练震荡明显,先别急着改网络结构,优先把学习率降到0.0005试试,往往立刻见效。
折扣因子γ取0.99,表示未来决策权重很高,智能体愿意为了最终目标忍受暂时的路径绕行。探索率ε采用线性衰减,从1.0开始,每回合衰减0.995,最低降到0.01。衰减速度很关键——衰减太快探索不足,智能体学到的是几个固定动作的机械重复;衰减太慢则训练后期还在乱试,收敛变慢。
经验回放容量设置成50000条,批量大小64。这个容量是对内存和训练质量的折中:太小了样本多样性不够,太大了旧样本滞留时间过长,学到的最新策略会被很久以前的无效经验拖累。
4.2 训练主循环的代码结构
整个训练主循环的核心骨架,我直接贴出关键代码:
python复制for episode in range(total_episodes):
state = env.reset()
episode_reward = 0
done = False
while not done:
# epsilon-greedy 动作选择
if random.random() < epsilon:
action = random.choice([0, 1, 2])
else:
with torch.no_grad():
q_values = online_net(state_tensor)
action = torch.argmax(q_values).item()
next_state, reward, done, info = env.step(action)
replay_buffer.push(state, action, reward, next_state, done)
# 每步更新一次网络
if replay_buffer.size() >= batch_size:
batch = replay_buffer.sample(batch_size)
states, actions, rewards, next_states, dones = batch
q_now = online_net(states).gather(1, actions)
q_next = target_net(next_states).max(1, keepdim=True)[0]
q_next[dones] = 0.0
q_target = rewards + gamma * q_next
loss = smooth_l1_loss(q_now, q_target.detach())
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 软更新目标网络
for target_param, online_param in zip(target_net.parameters(), online_net.parameters()):
target_param.data.copy_(tau * online_param.data + (1.0 - tau) * target_param.data)
state = next_state
episode_reward += reward
损失函数我用的不是普通的MSE,而是Smooth L1 Loss(即Huber损失)。它在误差较小的时候是平方损失,误差大的时候退化为线性损失,这样对TD误差中的离群点不那么敏感,训练过程更稳。很多入门代码直接用MSE,其实当某个样本的TD目标由于奖励设计原因特别大时,MSE的梯度会爆炸,Huber损失就完全没这个问题。
目标网络更新方式我选了软更新,τ取0.005。每一步都把目标网络参数往在线网络方向拉一点点,这样目标值的变化是连续平滑的,比“每500步硬复制一次”更稳。逛了一圈别人的项目,发现不少人还是习惯用硬更新,效果其实也可以,但软更新省去了硬拷贝时机选择这个麻烦参数。
4.3 PER采样的实现要点
优先级采样版本升级的点主要在经验回放部分。我在原缓冲区基础上维护一个priority数组,每次push新样本时把它的优先级初始化为最大,因为新到的样本从未被学习过,值得被优先看到。训练时按概率采样批量样本,更新网络后回写对应样本的新优先级。
核心采样函数长这样:
python复制def sample(self, batch_size, beta):
sampled_indices = []
sampled_probs = []
total_p = sum(p ** self.alpha for p in self.priorities)
for _ in range(batch_size):
rand = random.uniform(0, total_p)
cumsum = 0
for idx, p in enumerate(self.priorities):
cumsum += p ** self.alpha
if rand <= cumsum:
sampled_indices.append(idx)
sampled_probs.append((p ** self.alpha) / total_p)
break
# 重要性采样权重,用于修正偏差
weights = []
for idx, prob in zip(sampled_indices, sampled_probs):
w = (self.size() * prob) ** (-beta)
weights.append(w)
weights = torch.tensor(weights, dtype=torch.float32) / max(weights)
return np.array(sampled_indices), weights
这里有一个公式细节:importance weight = (N × P(i))^(-β),除以max(weights)是为了归一化,让权重最大值等于1,避免梯度更新幅度整体被缩小。β从0.4开始线性增加到1.0,这样训练初期对采样偏差的修正力度弱、学习信号大,训练后期偏差几乎被完全修正。
敏感的读者会发现一个问题:如果某条样本的TD误差一直很大,它会被反复采样,优先级也会反复更新为很大值,最终导致这条样本几乎垄断训练资源。解决办法有两个,一是给优先级加一个小常数ε做下限,确保每条样本都有一定的被采样机会;二是定期把所有样本的优先级都做衰减,让旧的“高价值”样本随训练推进逐渐降低权重。
4.4 DQN加APF的融合代码逻辑
APF模块不参与网络更新,它只是给网络输出加一个偏置。
python复制def apf_force(state, goal_pos, obstacles):
pos = state[:2] # 机器人当前坐标
# 引力项
att_force = k_att * (goal_pos - pos)
# 斥力项
rep_force = np.zeros(2)
for obs in obstacles:
dist = np.linalg.norm(pos - obs.pos)
if dist < rep_range:
rep_force += k_rep * (1/dist - 1/rep_range) * (1/dist**2) * (pos - obs.pos)
force = att_force + rep_force
angle = np.arctan2(force[1], force[0])
return angle
# 在动作选择时叠加
apf_angle = apf_force(state, env.goal, env.obstacles)
apf_action = torch.tensor([angle_to_action(apf_angle)], dtype=torch.long)
q_apf = F.one_hot(apf_action, num_classes=3).float()
q_final = alpha * q_dqn + (1 - alpha) * q_apf
action_to_angle的映射按动作索引定义:0对应左转30度,1对应直行,2对应右转30度。把APF输出的期望角度换算成最接近的离散动作,然后转成one-hot向量充当Q_APF。这一版的权重参数我推荐k_att=1.0,k_rep=0.5,rep_range=1.5m,λ从0.6逐步升到0.9。
有个很巧的用法是在训练初期把APF当成“安全网”:当传感器检测到最近障碍物距离小于0.8m时,强制把APF的动作参与度拉高,相当于引入一个安全保护措施。过了这个阈值就完全交给DQN主导。这个策略在实践中大幅减少了前500个episode里的碰撞次数。
5. 实验对比与效果分析
5.1 三条训练曲线的差异
我的实验设置是每个算法各跑2000个episode,每组重复5次取平均。基础DQN用来对比的收敛参考线,在训练曲线上的表现是典型的慢热:前600个episode几乎只有碰撞和到达的随机事件,episode reward长期在-50到-20区间徘徊;大约第1000个episode开始,策略才逐渐稳定,最终成功率稳定在65%左右。
PER-DQN的收敛速度明显优于基础版本。大约第500个episode就有了第一轮连续的成功到达记录,1500个episode之后成功率能爬到85%以上。这个差距完全来自样本利用效率的提升,同样的总样本数下,PER能把关键转移学得更充分。
DQN+APF的曲线是三版里最特别的:它的早期表现好得吓人,前200个episode就有几乎一半的回合能到达目标,因为它一开始就不怎么撞墙。但它的最终上限反而受APF牵制——当网络已经学到不错的策略,APF的固定规则反而可能给出次优的局部动作,导致路径偏移增加。把λ调到0.9之后,最终成功率能稳定在90%左右,但平均路径长度比PER-DQN长大约15%。这也说明APF更适合做协助者,不适合做主导者。
5.2 三套方案的适用场景
| 方案 | 收敛速度 | 最终成功率 | 平均路径长度 | 适用情况 |
|---|---|---|---|---|
| 基础DQN | 慢 | 约65% | 中等 | 验证新环境、新任务建模是否合理 |
| PER-DQN | 快 | 约85% | 最短 | 追求路径质量和整体效率的默认选择 |
| DQN+APF | 极快 | 约90% | 较长 | 对训练阶段安全性有要求、障碍物密集的静态环境 |
有意思的是,如果把APF的参与度λ限制在0.3以内,DQN+APF在训练后期的路径质量不会明显变差,成功率还会略有提升。这个结论提示了一个通用思路:先让APF带路,等策略成熟后手动调低λ甚至完全关闭APF,让它只作为训练期护栏存在。
5.3 这些指标怎么看才有效
看训练曲线不要只看最终reward数值,我建议把“碰撞次数”和“到达目标步数”每100个episode单独记录,拆开来画图。单纯的reward曲线会出现一种假象:智能体把“绕远路每步-1”的惩罚换来“缓慢接近目标+0.2增量奖励”,reward看起来稳定上升,但步数却越走越多。把三个指标放一起对比,能更真实地评估策略质量。
6. 常见问题与踩坑记录
6.1 训练不收敛与奖励失衡
最常遇到的问题是训练几千个episode之后,成功率还在30%以下打转。排查顺序是:先看奖励绝对值是否出现异常大或异常小的值,比如用MSE损失时碰撞惩罚给到-500,TD目标数值就会剧烈波动;再看ε衰减速度,如果训练到第2000个episode时ε还停在0.8,智能体就永远在随机尝试;最后看状态归一化有没有做。我在最初版本忘了把传感器距离归一化到[0,1]之间,5m量级的数值直接把网络激活函数都推到饱和区,梯度消失严重,后来统一除以最大检测距离才恢复正常。
6.2 优先级采样引入的方差放大
PER版本有个非常隐蔽的坑:训练前期TD误差本身就很大,高优先级样本可能陷入某个局部区域的过度记忆,导致前500个episode的成功率反而比基础DQN还差。排除方法很简单——检查采样权重是否随β递增更新了。很多人写完PER忘了在loss.backward()之前乘上重要性权重,等于只做了优先级采样没做偏差修正,训练方差必然爆炸。另外,要注意在buffer每次更新时把新样本的优先级设为当前全局最大优先级,否则新样本根本不会被采到。
6.3 Matlab与Python联调时最容易踩的坑
跨平台联调出现问题基本集中在数据格式和坐标约定上。最典型的是,Python训练时雷达传感器顺时针扫描角度递增,Matlab仿真里角度是逆时针递增,同样的状态数据训练出来的模型,在Matlab里直接就废了。所以第一步必须是统一坐标系和传感器扫描方向,写一份环境说明文档,两个平台的代码都按这个文档实现。其次,CSV里保存网络权重务必按层顺序保存,并且在Matlab里验证一次前向计算结果:用同一组输入数据,Python的prediction和Matlab的prediction误差要小于1e-5。
6.4 可复用的排查速查表
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
| Reward不上升 | 奖励塑形尺度失衡 | 降低增量奖励系数,确保终点/碰撞奖励占主导 |
| 前期损失值巨大 | 状态未归一化 | 传感器距离统一除以最大检测范围 |
| 训练后期震荡 | 学习率过高 | 降到0.0003以下,改用Huber损失 |
| PER训练方差大 | 缺少重要性采样权重 | 加上β修正并在优化前乘以权重 |
| Q值不断正向增长 | 目标网络更新太频繁 | 改用软更新τ=0.005或加大硬更新间隔 |
| Matlab加载模型后效果差 | 坐标约定不一致 | 统一扫描角方向并交叉验证前向输出 |
6.5 一些值得分享的调试心得
我先说一个我自己的习惯:训练时每100个episode保存一次模型权重,如果中途发现策略退化,可以回退到之前的版本调整参数,而不是从头再来。这个操作配合上文的CSV导出,在Matlab里也能反复测试不同阶段模型的避障表现,判断训练是否真的在进步。另外,给机器人加一个小范围的“碰撞缓冲”,也就是把实际碰撞距离从0.4m改成0.5m,训练出来的策略会明显更安全,代价只是路径略长,这个性价比很高。
最后再提一个小技巧:跑对比实验时给三个版本设完全相同的随机种子,否则环境初始化和网络初始化不一致会带来显著的随机波动,真实差异会被噪声掩盖。我后来用三组种子、每组取平均的方式报告指标,结果稳定了很多。这类细节对研究结论的可信度影响巨大,值得多花一点时间处理。
