简介:针对无人机辅助卡车配送中的协同调度难题,这份PDF论文给出了一种基于深度强化学习的可复现求解方案,面向物流优化、车辆路径规划与无人机应用方向的工程师和研究者。论文提出的混合模型(HM)结合注意力编码器与长短期记忆(LSTM)解码器,可显式记录多车动作序列,协调卡车与无人机路径规划,在随机数据集和城市物流案例上均优于传统启发式算法,并在大型实例上显著降低计算开销。资源共1个PDF文件,压缩包大小2.7MB,目前已有103人学习下载。内容包含完整模型架构、训练细节与多组实验对比,并给出与常用基线的性能差距,读者可按论文复现结果,并可将其扩展到带容量约束的车辆路径问题(mmCVRP)等衍生场景。实验结果显示该方法在简单与复杂条件下均表现稳定,尤其适合最后一公里交付方案设计和技术实施人员参考,也可作为深度强化学习在组合优化中应用的典型范例。
1. 无人机辅助旅行商问题:为什么这个方向值得投入
一个做无人机路径规划的工程师,十有八九会遇到这样一个场景:用户不只想让飞机在一条固定航线巡检,而是要求在一个有几十个目标点的区域里,自动算出“卡车怎么走、无人机从哪起飞、去哪几个点、在哪回收”最少时间的完整方案。这就是无人机辅助旅行商问题(TSP-D)的核心诉求——它把地面车辆和无人机当成一个协作系统,比单独规划一条无人机航线要复杂得多。旅行商问题本身就是 NP 难的,加上了无人机起降平台、续航、载荷约束后,精确解法只能应对几十个点,而深度强化学习算法把这类组合优化问题变成了一个可训练的网络,规模到 100、200 个点时仍然能给出接近最优的可行解,而且推理只需毫秒级。这篇笔记写给两类人:一是正在研究无人机路径规划算法的同学或工程师,想找一个能跑实验、能出指标、能写论文的方向;二是做物流巡检系统选型的人,需要判断深度强化学习求解方法到底能不能落地。我会把问题建模、网络结构、训练流程和那些不写论文就没人告诉你的坑,按可复现的顺序拆开讲。
2. 把无人机装进 TSP:问题定义与三类约束建模
2.1 从经典 TSP 到 TSP-D:多了什么变量
经典旅行商问题只问一件事:「给定 N 个城市,找一条经过每个城市一次且回到起点的最短闭合路径」。当无人机加入后,问题不再是一条路径,而是卡车路径和无人机路径的协同。常见表述是:一辆卡车从仓库出发,携带一架无人机,无人机可以随时从卡车当前位置起飞,访问一个(或少数几个)目标点后在另一个会合点降落到卡车上,卡车同时继续行驶。这里引入的变量包括:
- 决策变量一:无人机的起飞节点、访问节点、回收节点三元组 (i, j, k)。
- 决策变量二:卡车在哪些节点与无人机分离、在哪些节点重新汇合。
- 时间变量:无人机飞行耗时、卡车等待耗时、换电池或重新装载耗时。
从数学上讲,TSP-D 比 TSP 多了一组弧:无人机弧 (i, j, k),表示在 i 点起飞、访问 j、在 k 点回到卡车上。代价函数也从「总路程最小」变成了「总完成时间最小」。这个改变不是简单的目标函数换一下,因为它把并行执行引入了计算:卡车和无人机可以同时移动,路程最小和总耗时最小在很多拓扑下不是同一个解。
我在实际搭模型时,最常用的是把「卡车路径 + 无人机弧集合」作为完整解,解空间的大小是 TSP 的指数倍还叠加无人机弧的组合爆炸。这也是为什么转到深度强化学习方法——它在推断时直接对序贯决策建模,不需要显式枚举弧集合。
2.2 三类硬约束:续航、节点覆盖、起降逻辑
我把约束拆成三类,写进了状态转移和动作掩码里,稍有遗漏,模型训练出来的结果就是不可行的,浪费几个 GPU 天。
第一类,续航约束。无人机有最大飞行距离或最大飞行时间,从起飞点 i 到访问点 j 再到回收点 k 的总距离必须小于容量上限 C:d(i,j) + d(j,k) ≤ C。这个约束的写法决定了网络能不能学会规避不可行动作。常见做法是:把剩余续航计入状态向量,每个节点在编码时携带「无人机是否可达」的标志位,解码时不可达节点直接屏蔽。
第二类,节点覆盖约束。每个目标节点必须被服务,但服务方式有两种:卡车经过访问,或无人机经过访问。这一条容易和纯 TSP 混淆——不是所有节点都必须出现在一条序列里,而是要保证并集覆盖所有节点。我在代码里用一个 visited_mask 同时记录两种访问方式,而不是只记录卡车序列。
第三类,起降逻辑约束。无人机的起飞和回收必须发生在卡车的当前位置,且卡车只能在同一时间段内处于一个物理位置。这个约束在端到端的序列生成里很容易被打破,因为模型大概率会生成「卡车在 a → b → c,无人机在 b 起飞却在 c 回收」的非法组合——当卡车已经离开 c 时,无人机是不应该落下去的。
处理第三类约束的方式,我建议不要把它放进 loss 惩罚,而是直接做动作掩码(action mask),解码器每一步只能选择当前状态下可行的动作。掩码是深度强化学习求解组合优化问题最关键的部分,比网络结构还敏感。
2.3 建模粒度:点级决策还是子问题拆分
实现的时候还有一个上游问题:状态要表示到什么粒度。常见做法是三种。
第一种是点级(node-level)端到端:模型每次决定下一个动作是「卡车访问某节点」还是「放飞无人机去某节点再回收」。优点是简单,所有约束都集中在状态更新和掩码里;缺点是动作空间膨胀严重,训练收敛慢。
第二种是子问题拆分(route-first,assign-second):先用一个网络学习卡车主路径,再用匹配策略决定哪些节点交给无人机。这种思路更贴近数学规划习惯,但两个阶段的误差会叠加。
第三种是混合粒度:序列解码时把「放飞-访问-回收」当成一个超级动作,模型在每一步从所有超级动作里选一个。
我的经验是:你想要模型泛化能力强,点级端到端更符合深度强化学习范式,因为掩码让我们精确控制约束;你想快速得到可用的基线,子问题拆分用传统匹配算法更快。下面第三章的设计,就是对第一种做法的完整拆解。
3. 深度强化学习求解器的核心设计:从编码器到动作掩码
3.1 用 Attention 编码器把问题实例变成图表示
端到端求解的常用骨干是 Attention Model(AM)结构,处理的是节点集合和当前状态的交互。实例输入是一个二维矩阵:每一行的前几维是节点坐标,后几维是类型标志(是否仓库、是否待服务等)。这套结构经过位置编码和自注意力层后,把每个节点映射成嵌入向量。
下面这段代码定义了编码器的核心结构,我按可复现的标准写,PyTorch 加上它原生的 MultiheadAttention 就能跑:
import torch import torch.nn as nn class NodeEncoder(nn.Module): def __init__(self, input_dim=4, embed_dim=128, num_layers=3, num_heads=8): super().__init__() self.embed = nn.Linear(input_dim, embed_dim) self.layers = nn.ModuleList([ nn.TransformerEncoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=512, batch_first=True ) for _ in range(num_layers) ]) self.node_proj = nn.Linear(embed_dim, embed_dim) def forward(self, node_features): # node_features: [batch, num_nodes, input_dim] h = self.embed(node_features) for layer in self.layers: h = layer(h) return self.node_proj(h)这里有一个我在实验里踩过的参数点:input_dim 不是只放坐标就够了。我会在节点特征里额外拼接两类信息,第一类是节点类型编码(仓库节点/普通节点),第二类是当前训练 epoch 的复合标志(用于帮助模型感知训练进度)。这样做之后,训练阶段 loss 曲线下降更平滑,泛化到更大规模时 gap 也更稳。
编码器输出的节点嵌入不会变化,真正变化的是解码时用到的 context 向量。context 向量由三部分拼接:当前卡车所在节点嵌入、当前剩余续航归一化值、当前剩余待服务节点数。这个 context 在每个解码步都更新一次。
3.2 解码器中的动作掩码:把约束写进模型结构
解码器的输出是下一个动作的概率分布。动作可能是「卡车开到某个节点」,也可能是「从当前节点放飞无人机去访问某节点」。我用统一动作空间:长度为 2N 的向量,前 N 个动作表示卡车移动,后 N 个动作表示无人机访问。掩码规则如下:
- 已服务完成的节点,卡车移动动作和无人机动作同时屏蔽。
- 无人机动作中,目标节点距离当前卡车位置超过剩余续航,则屏蔽。
- 若剩余续航不足以飞到任何未服务节点,则后 N 个动作全部屏蔽。
- 仓库节点在非结束步骤不参与无人机访问动作。
掩码实现的关键是用布尔张量做乘法而不是用 padding 掩码混淆。看这段代码:
def compute_mask(state, dist_matrix, drone_range): batch_size, num_nodes = state.visited_mask.shape mask = torch.zeros(batch_size, 2 * num_nodes, dtype=torch.bool, device=state.device) # 卡车动作:不能去已经服务过的节点 mask[:, :num_nodes] = state.visited_mask # 从当前卡车位置出发的距离 cur_pos = state.truck_pos # [batch] truck_dist = dist_matrix[torch.arange(batch_size), cur_pos] # [batch, num_nodes] # 无人机动作:超续航距离的节点屏蔽 drone_dist = truck_dist + dist_matrix[torch.arange(batch_size).unsqueeze(1), torch.arange(num_nodes).unsqueeze(0)] mask[:, num_nodes:] = (drone_dist > drone_range) | state.visited_mask # 当前没带可用无人机时,屏蔽全部无人机动作 mask[:, num_nodes:] |= (~state.has_drone).unsqueeze(1) return mask这段代码里最容易出错的地方是 drone_dist 的计算维度。dist_matrix 是二维 [num_nodes, num_nodes],但 batch 存在时需要用 gather 或者广播索引。代码里用的是 arange 批次索引配合二维张量索引,需要确保 dist_matrix 不包含 batch 维度。在实际工程实现中,我会把 dist_matrix 扩展成 [batch, num_nodes, num_nodes]。
掩码是模型能不能输出可行解的第一道防线。如果这里写错,训练即便 loss 下降很快,采样出来的解也是不可行的——我们做实验时吃过一次大亏,训练了 48 小时才在验证阶段发现无人机回收点非法,根因就是掩码忘记排除已经出发离开的节点。
3.3 REINFORCE 训练目标与 baseline 设计
训练目标通常会选择策略梯度,用 REINFORCE 框架。奖励函数是总完成时间的负值。但因为完成时间是正值且量级不确定,我在实现里会做一件事——在实例内部用 max-min 归一化,让每个批次内的时间值被压缩到 0 到 1 之间,这在训练几百个 epoch 后能看到明显差异。
Baseline 设计我在实验中用了两种组合:
- 滚动平均 baseline:对每个实例最近 20 个批次的平均奖励做 EMA,简单且稳定。
- 贪心解码 baseline:每个 step 取概率最高的动作生成一条轨迹,作为当前 batch 的 baseline。
我建议把小批量内的共享 baseline 换成「单个实例多轨迹」方式,也就是同一个 instance 采样 k 条轨迹取均值作为 baseline。这个做法在稳定性和方差控制上比简单的共享 baseline 好不少。REINFORCE 的损失代码如下:
def reinforce_loss(log_probs, rewards, baseline): # log_probs: [batch, max_steps, action_space] # rewards: [batch] # baseline: [batch] advantages = rewards - baseline.detach() loss = -(log_probs * advantages.unsqueeze(1)).sum(dim=1).mean() return loss这里的一个训练细节:max_steps 是动态的,不同实例的解序列长度不一样。PyTorch 的 tensor 要求固定形状,所以我在生成解时会统一 padding 到 max_steps,同时维护一个 active_mask 来屏蔽 padding 位置的 log_prob。如果不屏蔽,padding 位置上的 logits 会引入噪声,导致梯度不稳定。
4. 复现路径:训练流程、超参数表与评估指标
4.1 数据生成:固定随机种子构建 TSP-D 实例集合
可复现的第一前提是数据生成可复现。我生成实例的方式是:仓库坐标固定在原点附近,普通节点在 [0, 1] × [0, 1] 或 [0, 100] × [0, 100] 的方形区域内均匀采样,然后用一个固定种子生成训练集和测试集,保证任何人在同样环境可以得到完全一致的实例。
import numpy as np def generate_tsdp_instance(num_nodes, seed, return_type='np'): rng = np.random.default_rng(seed) coords = rng.uniform(0, 100, size=(num_nodes, 2)) depot = np.array([[50.0, 50.0]]) # 节点 0 为仓库,其余为任务点 full_coords = np.vstack([depot, coords]) # 随机指定无人机的续航(按距离单位) drone_range = rng.uniform(20, 40) # 覆盖不同难度 # 无人机速度倍率,典型值取 1.5~3.0 倍卡车速度 drone_speed_ratio = rng.uniform(1.5, 3.0) if return_type == 'np': return full_coords, drone_range, drone_speed_ratio # 也可返回 dict 供网络直接使用 return { 'coords': full_coords, 'drone_range': drone_range, 'speed_ratio': drone_speed_ratio, }我在实验中固定了 100 个训练 epoch 内的实例生成种子范围:训练集用 0 到 9999 种子,测试集用 10000 到 10099 种子。这样生成的训练规模在 10 万级别。值得注意的一点是,不要把所有 epoch 都固定用同一个种子——虽然能复现,但网络会过拟合那一批具体坐标。每个 epoch 重新随机采样,只是设定全局种子。
4.2 超参数表:从网络维度到训练策略
我把训练中不起决定性作用但影响稳定性的参数放一起,按我用下来最稳的一组列出来做参考。
| 参数 | 值 | 说明 |
|---|---|---|
| 节点嵌入维度 | 128 | 过小(64)导致表示能力不足,过大(256)只增加显存不带来显著精度增益 |
| 编码器层数 | 3 | 深层对 100 节点以上实例有收益,但对 50 节点以下反而容易训练不稳定 |
| 注意力头数 | 8 | 与嵌入维度 128 匹配 |
| 前馈网络隐藏维度 | 512 | Transformer 层的标准 4 倍设计 |
| Batch size | 512 | 在单张 24 GB 显卡上可运行,过大对梯度噪声降低不明显 |
| 学习率 | 1e-4 | 高于 3e-4 时容易在 20 个 epoch 后发散 |
| 学习率衰减 | Cosine,50 epoch 降至 1e-5 | 衰减过快会让模型在泛化迁移时失去调节能力 |
| 优化器 | Adam, eps=1e-8 | 不建议换成 SGD,稀疏梯度下的稳定性很差 |
| 轨迹采样数 k | 8 | 每个实例采 8 条轨迹计算 baseline,效果比滚动平均好 |
这组参数对 N=50 和 N=100 的实例都能在 100 epoch 内把 gap 压到 5% 以下。如果你实验中发现召回率或 gap 振荡,先不要动网络结构,把 batch size 提上去或降低学习率,八成能稳住。
4.3 评估指标:gap、吸血时间与可行性校验
评估一个求解器不是说跑出一个总时间就完事。我习惯输出三个指标:
- 最优 gap:当前求解器解与精确解(小规模用 Gurobi,大规模用 LKH3 或 OR-Tools 的高质量解)差距的百分比。
- 可行率:在 1000 个测试实例中,满足所有约束的解所占比例。这个指标往往被忽略,但对落地特别关键。
- 求解时间:GPU 推理时延,包括从输入图到输出动作序列的全过程。
评估代码中,可行性校验要在解序列之外单独做一次,不要信任模型输出——因为我见过模型输出路程漂亮但未被掩码拦住的部分非法解。校验函数逐一检查续航约束、覆盖约束和起降约束,违反任何一条就标记为不可行。
def check_feasibility(seq, coords, drone_range): # seq 是模型输出的长度为 2N 的动作序列 # 用朴素模拟判断是否违反约束 truck_pos = 0 served = set() visited_truck = {0} has_drone = True for action in seq: if action < N: # 卡车动作 truck_pos = int(action) if truck_pos in visited_truck and truck_pos != 0: return False # 重复访问 visited_truck.add(truck_pos) served.add(truck_pos) else: # 无人机动作 target = int(action - N) if not has_drone: return False # 检查续航 dist_drone = euclid(coords[truck_pos], coords[target]) dist_back = euclid(coords[target], coords[truck_pos]) if dist_drone + dist_back > drone_range: return False has_drone = False served.add(target) return len(served) == num_nodes5. 避坑与排查:DRL 求解 TSP-D 最常见的 5 个失败模式
5.1 掩码错误引发非法解,训练 loss 却正常下降
这是一个非常隐蔽的坑。现象是训练过程中 loss 曲线很漂亮,从 0.8 一路降到 0.3,但验证时生成的解有一半左右在人工检查时会发现无人机回收点已经错过了卡车的到达时间。
原因是我之前提到的起降逻辑约束没有体现在掩码里——模型在某个 step 选择了无人机访问动作后,卡车位置没有变化,但实际物理过程里,无人机从某点起飞,卡车会继续前进,两者会合的位置并不是起飞点。如果简化成「在哪里起飞就在哪里回收」,模型生成的解在时间维度上是非法的。
解决方法是把时间 t 纳入状态。具体做法是维护一个 time_counter 张量,每个动作执行后更新卡车耗时与无人机耗时对比,若无人机飞回时间晚于卡车到达回收点时间,则在下一个 step 的掩码中把该回收动作屏蔽。这个修复需要改三处:状态定义、transition 函数、掩码逻辑。
5.2 续航距离单位对训练稳定性的干扰
现象:训练初期 loss 出现 NaN 或震荡,位置坐标是 0-100 量级,续航是 20-40,但网络在小数点后几位的精度上仍不稳定。
原因是网络对特征尺度非常敏感。坐标、距离、速度比在数值上跨越两个数量级时,注意力层的 logits 方差会变得很大,梯度计算随之不稳定。
解决:将所有距离除以坐标范围的最大值归一化到 0-1 区间,同时把续航、速度比同样做缩放。归一化后,模型对续航的感知灵敏度保持不变——因为约束判断是在原始坐标下完成的,归一化只影响网络输入层,不影响掩码的精确计算。这个改动让训练曲线立刻稳定下来,属于调参中的血泪经验。
5.3 学习率衰减过猛,模型只学会了「贪心」
现象是模型在训练集上 gap 越来越小,但换到更大规模的实例(从 50 个点到 200 个点)时,解的质量断崖式下跌,比随机启发式还差。
原因:余弦衰减设成 20 个 epoch 就降到 1e-6,模型在后期已经失去了对分布变化的适应能力。深度强化学习求解组合优化有「分布外泛化」问题——网络很容易记住当前训练分布的捷径,比如所有坐标都在方形区域均匀分布时,模型学会了先访问左下角节点。
解决:把衰减拉长到 80-100 epoch,而且最后一轮学习率不要低于 5e-5。同时我在训练时每 5 个 epoch 换一批不同的分布参数,比如坐标范围、续航范围都按大范围随机采样,让模型不能走捷径。
5.4 覆盖约束与重复访问的冲突引发的状态错乱
现象:模型在推理时反复访问同一个节点,导致虽然最终覆盖了所有节点,但步数超长,总耗时反而超过基线。可视化时发现卡车在同一片区绕圈。
原因是 RNN 或 Transformer解码器没有显式的「不重复访问」强约束时,会依赖注意力机制自己学会;但当节点数增大时,注意力图变得稀疏,模型会忘记已经访问过的远处节点。这种情况在小规模训练、大规模推理时尤其严重。
解决:把 visited_mask 不仅作为掩码,还拼接到 context 向量中,让每个解码步都能看到全局的覆盖进度。另一个技巧是在 loss 里加一个轻量的 staged 奖励——每多访问一个新节点,给一个小的正奖励,这能极大减少重复访问。
5.5 验证时用贪心解码,解的质量被低估
现象:测试集里的 gap 比同 epoch 训练集差很多,或者模型看起来始终没有学会构造复杂解。
原因:如果你在验证阶段只用贪心解码(每步取概率最高动作),会低估模型的真实能力。Transformer 解码器在推理时的自回归误差会累积:前几步的动作概率是高的,但一旦某一步选了一个次优动作,后面的概率分布就会被带偏。
解决:验证时至少做采样解码(sample 8 条轨迹取最优)或者 beam search。实验里发现采样 8 条比贪心解码平均能提高 4-6% 的 gap 表现。很多论文报告的数字都来自采样解码而不是贪心解码,所以复现时对齐这个细节很重要,否则你复现的结果看起来永远比原论文差一截。
6. 让模型跨规模泛化的三个验证技巧
如果你想让训练好的网络直接用于不同规模的实例——比如只在 50 节点上训练,测试到 100 或 200 节点——需要确认模型真的有构建式泛化能力,而不是只会按图分布规律走捷径。这里分享三个我反复用的验证技巧。
第一,实例规模插值验证。把测试实例分成三组:等于训练规模、2 倍训练规模、4 倍训练规模。对每一组分别记录 gap 和可行率。如果 2 倍时 gap 上升不超过 1 个百分点,说明编码器的自注意力是在学习「局部拓扑关系」而不是「固定序列长度」。如果 4 倍时 gap 大幅恶化,大概率是训练分布不够多样。
第二,分布偏移验证。训练时只用了均匀分布,测试时换成聚类分布(目标点集中几个簇)。TSP-D 中聚类分布对无人机的续航使用方式差异巨大——在聚簇内无人机可以很频繁起降,而均匀分布下一次起降可能跨越大半个地图。我建议训练数据中随机混入 30% 的聚类分布实例,模型的鲁棒性会好很多。
第三,约束边界压力测试。把续航参数在测试集中设置成贴近分布下界的极端值,例如正常训练时续航范围 20-40,测试时设置成 10。如果模型仍然能保持较高的可行率,说明网络学会了续航感知的开关行为——即「续航不足时自动收敛成纯卡车模式」。很多模型在这个压力测试下会崩溃,这是一个非常重要的稳定性指标。
这三个验证技巧不需要额外写复杂代码,只是在测试实例生成时多设置几组不同的分布参数,然后把评估函数跑三遍。它们帮你判断这个模型是「背下了训练集」还是「学会了求解逻辑」,也会告诉你值不值得继续投入算力加大训练数据。
在我自己的项目里,我把第三种压力测试当成模型上线的准入标准。做无人机路径规划算法也好,做物流调度的协同优化也好,真正到现场面对的环境中,不可预知的边界条件永远比训练集丰富。与其在测试集上刷 gap,不如先把边界约束扛住。这套方法的工程价值不在某一张 SOTA 表格里,而在于它给了你一个能快速调整约束、重新训练的闭环——换一套巡检环境、换一种无人机续航参数,重训 100 epoch 就能得到一个适配的求解策略。
最后提一句:训练完模型后,记得把所有超参数和种子记录在项目的 README 里。我吃过亏——半年后回头想复现当时某个结果,发现学习率衰减没记录,重试了三组参数才找回接近的效果。这算是做可复现研究最朴素的一条经验,希望帮到你。
本文还有配套的精品资源,点击获取