最近总有人拿着低空经济方向的新论文来问车辆与无人机协同配送的优化模型到底怎么用代码实现尤其论文里常见的集中式协同配送模式听起来就是车辆带着无人机送货真到复现的时候却要同时处理车辆路径、无人机分配、起降点同步一大堆问题。这篇文章把我从零复现某篇论文的过程完整写出来包括建模、pymoo求解、算例验证和踩坑记录。内容主要给正在复现同类论文的研究生参考也适合物流算法工程师想用Python先验证无人机方案时快速上手。我会把可以跑的代码骨架贴出来也会把论文通常不会写的坑讲清楚。下面默认你了解基本VRP概念和常用算法库如果你刚入门也没关系关键步骤我都会解释为什么这样做。1. 把集中式协同配送翻译成数学问题建模是复现的第一道坎1.1 场景假设与符号定义复现的第一步不是打开pymoo而是先把论文里的模型翻译成自己能写代码的形式。我复现的那个原型场景是这样的一个配送中心一辆卡车车上搭载两架无人机。卡车沿一条主干路线配送无人机在途中被释放飞到周边客户点送货之后回到卡车上继续跟车。这就是集中式协同配送的核心特征——所有车辆的路径与无人机的起飞、回收动作都由同一个决策模型统一调度而不是车辆和无人机各自做局部决策。集中式和分布式的差异很关键。分布式模型里每辆车可以独立优化自己那条路线无人机再按局部规则插入集中式模型则把车辆去哪、无人机去哪、什么时候起飞、什么时候汇合全部作为决策变量搜索空间大了很多但解的质量上限更高。论文里选择集中式通常就是为了论证这个协同系统能取得全局更优的配送方案。在建模前我把符号表整理如下这是后面写代码的基础符号含义D配送中心车辆和无人机的出发点与最终返回点C {1, ..., N}客户点集合每个客户有二维坐标、服务时间Q_v车辆最大载重Q_u无人机最大载重E_max无人机最大续航里程v_v、v_u车辆速度、无人机速度c_v、c_u车辆单位距离运输成本、无人机单位距离飞行成本这个表看起来简单但每个符号在写代码时都对应一个数组或者一个常量。我建议一开始就把数据类写好用字典或者dataclass存客户坐标、车辆参数、无人机参数避免求解答散落着一堆魔法数字。真到调试的时候你会发现把数据结构和模型结构分开比什么都省时间。1.2 目标函数与约束条件的取舍论文原模型里往往同时考虑行驶成本、时间成本和客户满意度是一个多目标模型。但复现初期我强烈建议先把多目标压成单目标跑通之后再拆回多目标。原因很简单单目标下你只需要看一个F值的变化判断遗传算法是否收敛、模型是否符合直觉都要容易得多。我用的目标函数是min Z c_v * L_v c_u * L_u λ * T_total其中L_v是车辆总行驶距离L_u是无人机总飞行距离T_total是总完工时间λ是时间惩罚系数。把时间和成本量纲统一需要注意后面我会专门讲这个坑如果你直接把分钟和千米相加遗传算法很可能发现一个退化解——永远不让无人机干活成本反而更低。约束方面与论文一致的主要有几条每个客户恰好被服务一次车辆和无人机都不能超载无人机单次任务距离不能超过E_max无人机释放后车辆需要移动到预先设定的汇合点等待回收存在严格的时空同步约束。时空同步约束是集中式模型里最麻烦的一条。车辆和无人机从同一个点出发无人机以更高的速度飞行如果汇合点选得不好车辆就要原地空等浪费移动时间。所以我在解码时会把汇合点一起优化出来而不是预先固定。后面2.2节的解码逻辑还会详细展开这一点。1.3 为什么选随机键编码而不是二进制或置换编码这是很多第一次复现VRP类论文的人最容易纠结的地方。论文里写的是采用遗传算法求解但通常不会告诉你基因到底怎么编码。你翻代码仓库可能看到二进制编码、置换编码、实数编码五花八门。我的建议是优先用随机键编码Random Key Encoding它和pymoo现成的遗传算子配合最顺。随机键编码的思想很直观每个决策变量是一个[0,1]之间的实数解码时把这组实数按大小排序排序结果就对应一条访问顺序。比如五个客户随机键是[0.2, 0.9, 0.4, 0.1, 0.7]排序后是客户4、客户1、客户3、客户5、客户2这就是车辆访问客户的顺序。那谁用无人机配送这个信息怎么编码我用了第二段随机键每个客户对应一个[0,1]实数大于0.5表示该客户由无人机服务否则由车辆服务。这样整个染色体长度就是2N前半段决定顺序后半段决定分配天然适合用SBX交叉和PM变异算子来处理不需要自己重写算子。如果坚持用置换编码交叉之后你还要处理大量重复客户基因的修复问题代码复杂度会高一个数量级。实测下来随机键编码加现成算子在5到20个客户的中小规模算例里收敛速度和稳定性都很够用。2. pymoo求解框架搭建Problem类、解码器与遗传算子配置2.1 自定义Problem类把模型挂到pymoo的接口上pymoo的用法核心就是自定义Problem类重写_evaluate方法。你的模型越复杂越要把解码逻辑拆到独立函数里否则每次评估都重复解码速度会很难看。下面是我复现时使用的Problem类骨架import numpy as np from pymoo.core.problem import Problem class TruckUAVCooperative(Problem): def __init__(self, depot, customers, truck_param, uav_params): self.depot depot self.customers customers self.truck_param truck_param self.uav_params uav_params self.n_customers len(customers) # 前半段是车辆路径随机键后半段是无人机分配随机键 super().__init__( n_var2 * self.n_customers, n_obj1, n_ieq_constr0, xl0.0, xu1.0, ) def _evaluate(self, X, out, *args, **kwargs): costs np.zeros(len(X)) for i, x in enumerate(X): decoded self._decode(x) repaired self._repair(decoded) costs[i] self._calc_cost(repaired) out[F] costs这里我把n_ieq_constr设成了0也就是说完全没有用罚函数。为什么不设约束因为所有的可行性问题都在解码和repair阶段处理掉了。这是复现组合优化问题时非常实用的思路与其让遗传算法在不可行解空间里瞎跑不如让每一个个体解码出来就是一个可行的配送方案。后面踩坑部分我会专门讲为什么我最早用罚函数时把整个复现带进了死胡同。2.2 解码器设计让每个个体天然可行解码器内部做的事情可以分三步看。第一步取出前半段随机键按大小排序得到车辆访问客户的顺序。第二步取出后半段随机键按0.5阈值决定哪些客户划给无人机得到车辆客户子集和无人机客户子集。第三步也是关键的一步把无人机任务插入到车辆路线上。我用的策略是在车辆访问顺序里找到无人机任务相邻的两个车辆客户点一个是释放点一个是回收点如果释放点和回收点之间车辆行驶距离太长或者无人机单趟飞行距离超过E_max就把这个无人机任务降级回车辆配送这就是repair逻辑。举一个具体的例子。假设N5随机键x[0.35, 0.71, 0.12, 0.88, 0.45, 0.62, 0.18, 0.39, 0.76, 0.03]。先看前半段排序后客户顺序为[3, 1, 5, 2, 4]。再看后半段客户30.62、客户20.76都大于0.5由无人机服务客户10.18、客户40.03、客户50.39由车辆服务。于是车辆路径变成[1, 5, 4]无人机任务为[3, 2]。接下来把无人机任务尝试插入车辆路径车辆可能在客户1处放无人机去客户3在客户5处回收也可能在客户5处放无人机去客户2在客户4处回收。插入位置不同总成本完全不同所以这部分本质上是一个子问题。def _decode(self, x): order np.argsort(x[:self.n_customers]) assign x[self.n_customers:] 0.5 truck_customers [c for c in order if not assign[c]] uav_customers [c for c in order if assign[c]] return truck_customers, uav_customers def _repair(self, decoded): truck_customers, uav_customers decoded valid_uav [] for c in uav_customers: # 找到车辆路径上合适起降点并检查无人机续航 if self._uav_feasible(c, truck_customers): valid_uav.append(c) else: truck_customers.append(c) # 降级成车辆配送 return truck_customers, valid_uav实际实现时释放点和回收点还需要在truck_customers里按距离重新选择要维护一套无人机任务释放点客户回收点的数据结构。这段逻辑是整个代码量的大头也是最容易出错的地方强烈建议单独写成函数先用几个手算小案例测过再接入pymoo。我当时就是在这块没有认真单测结果整个模型跑了半天才发现解码有bug。2.3 遗传算子选择与超参数配置pymoo 0.6.x版本下单目标遗传算法可以直接用GA类配合现成的采样、交叉、变异算子from pymoo.algorithms.soo.genetic_algorithm import GA from pymoo.operators.sampling.rng import FloatRandomSampling from pymoo.operators.crossover.sbx import SBX from pymoo.operators.mutation.pm import PM from pymoo.optimize import minimize algorithm GA( pop_size100, samplingFloatRandomSampling(), crossoverSBX(prob0.9, eta15), mutationPM(prob1 / (2 * self.n_customers), eta20), eliminate_duplicatesTrue, ) res minimize(problem, algorithm, (n_gen, 300), seed42, verboseTrue)注意pymoo的导入路径在不同版本之间差异很大我这个写法对应0.6.x版本。如果你用的是0.5.x或者更新的1.x请以官方文档的API说明为准。这个坑几乎每个第一次用pymoo的人都会遇到代码抄过来却因为版本对不上而报错非常影响心态。说几个实际体会。SBX的eta默认值在15到20之间通常问题不大。eta太大的话子代基因会非常接近父代收敛很慢eta太小子代容易跑太远破坏优秀的编码片段。变异概率一般设在1/n_var附近随机键编码下可以把变异概率稍微调大增加种群多样性代价是后期收敛会慢一些。还有eliminate_duplicatesTrue一定记得打开开了之后重复个体在每一代开始前会被清除并重新随机生成对防止早熟帮助很大。我在没开这个开关时种群经常在150代左右就彻底僵住后面踩坑部分有具体描述。3. 算例构造与结果分析如何判断复现成功3.1 小规模算例先跑通逻辑再谈性能复现论文代码的一个大忌是上来就上几十上百个客户的大算例。我的做法是先构造一个5个客户的微型算例目的是验证模型能不能给出符合直觉的协同方案。算例设置如下仓库在坐标(50, 50)5个客户随机分布在100×100的平面上其中两个客户离车辆主干较远但离可能经过的直线又很近这样用无人机绕一小段就能覆盖。车辆载重设得足够大暂不考虑容量约束无人机续航先设30分钟速度假设60 km/h也就是说单趟任务最大飞行距离大约30公里。具体的客户坐标可以这样造客户X坐标Y坐标需求量1203082601553357010480756590407这个规模跑一次GApop_size50迭代100代十几秒就能出结果。如果这个都不能得到合理答案问题一定出在解码器或者目标函数上不要去怪算例规模。我一直觉得小算例的价值不只是验证代码能跑更重要的是让你人工手算一条可行解和算法给出的解对比迅速暴露模型逻辑上的错误。3.2 结果收敛性与合理性用上面这个配置跑下来我观察到的收敛曲线大致分三个阶段前30代目标值快速下降中间30到70代缓慢改善之后基本平稳。最终解里车辆走了一条穿过平面中部的路线两架无人机分别从车辆路线上的两个节点起飞各自服务一两个客户后回到后续节点汇合。没有出现无人机飞出去几百公里再回来这种违反物理常识的规划。这里有一个非常有效的验证方法人工构造一个显然应该用无人机的场景。比如让客户3离车辆主干路线只有5公里但车辆如果专门绕过去额外要走40多公里。这种情况下任何合理的模型都应该选择让无人机飞一趟航程只有10公里左右。如果模型给出的解反而全用车辆配送那就说明目标函数权重或量纲有问题而不是算法没调好。我每次改完模型都会先跑这种常识测试用例比看收敛曲线快多了。另外我还习惯把最终方案的路径画出来用matplotlib把车辆路线和无人机起降弧线用不同颜色标注。看到车辆和无人机确实形成主干支线的结构才算真正放心了。枯燥的数值表格容易掩盖不合理之处一图胜千言。3.3 关键参数敏感性续航里程对协同效果的影响我把无人机续航从20分钟调到40分钟分别跑十次取平均结果如下无人机续航平均无人机服务客户数相对基准总成本20分钟0.499.8%30分钟2.294.5%40分钟3.888.2%这个趋势很符合直觉续航很短的无人机几乎没有存在价值只是多了一份机队成本一旦续航超过某个临界值无人机开始批量承担绕路落差大的客户总成本显著下降。这说明复现论文时如果论文里没有给明确的续航参数你不能随便拍脑袋填一个一定要先做一遍敏感性分析确定临界区间在哪里。我见过不少复现代码的人参数都是默认值跑出来的结果和论文趋势完全对不上又找不到原因其实很可能就是关键参数没校准。4. 复现过程里踩过的三个坑完整排查链路4.1 初始解全部不可行约束处理方式选错了第一次实现时我把无人机续航约束写成了pymoo的约束函数也就是在_evaluate里额外返回g(x)约束违反量想着通过罚函数让种群慢慢进化到可行区域。结果跑了200代最好的个体依然不可行。原因在于初始种群绝大部分个体的无人机任务都超续航罚函数提供的引导信息太弱遗传算法根本找不到可进化的方向。排查过程是这样的我先不看遗传算法直接把初始采样出的50个个体全部解码统计每个个体的无人机任务超续航次数发现100%都有超续航问题。这说明问题不是算法参数而是约束表达方式。随后我把超续航客户直接修复为车辆配送也就是把约束移进解码器初始种群的可行率立刻变成100%目标函数值才开始有意义地下降。这件事让我养成了一个习惯凡是能用解码保证的约束绝不用罚函数。对VRP这类结构化很强的组合问题罚函数往往让搜索一直浪费在不可行区域里。4.2 目标函数量纲混乱无人机被优化没了另一个很难发现的坑是目标函数量纲。最开始我给车辆成本用的是每公里2元给无人机成本用的是每分钟2元完工时间的权重按1元每分钟加。表面上看都是2元但车辆一公里要跑大约0.9分钟无人机一分钟能飞约1公里两个量纲交叉之后无人机在目标函数里的单位贡献被明显低估最终解干脆完全不用无人机。为了确认我手动构造一个场景一个客户距离车辆路线很近但离仓库很远无人机航程完全覆盖。让模型跑100次统计无人机使用频率结果100次里只有3次选择了无人机。我把目标函数改成统一按分钟计算车辆和无人机都换算成单位任务时间成本之后同样的场景下无人机使用率提升到了90%以上。所以设计目标函数时先把所有成本统一到同一个量纲再谈优化否则会得到一个逻辑自洽但实际愚蠢的解。注意这里的统一不是简单乘系数而是把车辆跑1公里对应的时间和无人机飞1公里对应的时间分别计算后再加权。4.3 种群早熟多样性丢失的排查第三个坑是跑了多代之后结果停滞不前最后一次跑出的解甚至不如我手算的可行解。我一开始怀疑是SBX交叉太聚集调小了eta没有明显改善。后来发现是种群个体重复率过高GA跑到大概140代的时候我在中途查看输出发现前20个个体几乎一模一样整个种群的有效多样性已经崩塌了。修复方式就是两个打开eliminate_duplicatesTrue并且把PM变异概率从1/(2N)调大到约1/N。这样处理后种群在全部300代里都保持了足够的多样性目标函数值也继续下降了约6%。这个例子说明组合优化里遗传算法的核心不是交叉而是维持多样性。交叉负责找解变异和去重负责兜底三者缺一不可。5. 后续扩展与个人建议5.1 从集中式到更大规模的近似策略集中式协同模型有个先天问题决策变量包含完整的车辆路线和每个客户的无人机分配一旦客户数超过50pymoo的GA直接跑会很吃力单跑一次可能就要一两个小时。实践中可以考虑两阶段法第一阶段用聚类算法把客户分成若干个簇每簇由一辆车负责簇内部再做集中式车机协同第二阶段再对簇之间的衔接做优化。这样模型从全局集中变成组内集中、组间协调可扩展性好很多。如果你希望保持全局集中式那就得换思路比如把模型线性化后交给商业MIP求解器求解小规模精确解用pymoo构造初始上界。我在小规模对比中发现pymoo得到的解和精确解的差距能控制在5%以内这说明随机键加GA的方案作为快速原型完全够用。真到大规模落地再考虑更精细的数学规划或者启发式混合算法。5.2 动态场景下的滚动时域扩展论文复现通常只做静态规划但实际配送场景里新订单随时会来。我试过一个扩展做法把时间轴切成多个时段每个时段开始时把当前未配送客户和车辆位置作为初始状态再用pymoo重新求解未来一段时间的方案这就是滚动时域。在这个框架下pymoo仍然是每个时段的求解内核只需要在Problem类里增加当前位置和已用时间两个状态字段模型改动不大。推荐动手扩展前先把静态版本整理成输入数据、模型、解码器、求解器、输出方案五个独立模块动态化时只需要替换输入数据部分核心模型不动。如果能做成这样后续换算子、换算法、加约束都只是局部改动不会牵一发动全身。5.3 给准备复现类似论文的读者几点建议根据我这次复现的经验有几条比较通用。第一先写解码器再写目标函数最后才接pymoo。解码器测不透上层跑了也白跑。第二一定要保留所有随机种子和参数记录否则跑完一发结果回头想复现昨天那张图你会非常被动。第三不要一上来就加时间窗、多目标、多车辆功能每加一个就要重新做一次小算例回归。我的经验是每增加一个功能先用5客户算例重跑一遍确认旧的合理解没有被破坏。最后再说个体会。刚开始复现那会儿我总想着把论文作者源码找出来直接跑后来发现自己从头搭一遍虽然慢但模型里的每个假设、每个参数的含义都会理解得特别透彻。这种理解才是复现论文真正值钱的地方。后面我再碰到类似的车机协同选题第一件事依然是先画解码器再谈优化算法。