最近总有人拿着低空经济方向的新论文来问:车辆与无人机协同配送的优化模型,到底怎么用代码实现?尤其论文里常见的"集中式协同配送模式",听起来就是车辆带着无人机送货,真到复现的时候却要同时处理车辆路径、无人机分配、起降点同步一大堆问题。这篇文章把我从零复现某篇论文的过程完整写出来,包括建模、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_var=2 * self.n_customers, n_obj=1, n_ieq_constr=0, xl=0.0, xu=1.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逻辑。
举一个具体的例子。假设N=5,随机键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]。再看后半段,客户3=0.62、客户2=0.76都大于0.5,由无人机服务;客户1=0.18、客户4=0.03、客户5=0.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_size=100, sampling=FloatRandomSampling(), crossover=SBX(prob=0.9, eta=15), mutation=PM(prob=1 / (2 * self.n_customers), eta=20), eliminate_duplicates=True, ) res = minimize(problem, algorithm, ("n_gen", 300), seed=42, verbose=True)注意:pymoo的导入路径在不同版本之间差异很大,我这个写法对应0.6.x版本。如果你用的是0.5.x或者更新的1.x,请以官方文档的API说明为准。这个坑几乎每个第一次用pymoo的人都会遇到,代码抄过来却因为版本对不上而报错,非常影响心态。
说几个实际体会。SBX的eta默认值在15到20之间,通常问题不大。eta太大的话,子代基因会非常接近父代,收敛很慢;eta太小,子代容易跑太远,破坏优秀的编码片段。变异概率一般设在1/n_var附近,随机键编码下可以把变异概率稍微调大,增加种群多样性,代价是后期收敛会慢一些。还有,eliminate_duplicates=True一定记得打开,开了之后重复个体在每一代开始前会被清除并重新随机生成,对防止早熟帮助很大。我在没开这个开关时,种群经常在150代左右就彻底僵住,后面踩坑部分有具体描述。
3. 算例构造与结果分析:如何判断复现成功
3.1 小规模算例:先跑通逻辑,再谈性能
复现论文代码的一个大忌是上来就上几十上百个客户的大算例。我的做法是先构造一个5个客户的微型算例,目的是验证模型能不能给出符合直觉的协同方案。算例设置如下:仓库在坐标(50, 50),5个客户随机分布在100×100的平面上,其中两个客户离"车辆主干"较远但离可能经过的直线又很近,这样用无人机绕一小段就能覆盖。车辆载重设得足够大,暂不考虑容量约束;无人机续航先设30分钟,速度假设60 km/h,也就是说单趟任务最大飞行距离大约30公里。
具体的客户坐标可以这样造:
| 客户 | X坐标 | Y坐标 | 需求量 |
|---|---|---|---|
| 1 | 20 | 30 | 8 |
| 2 | 60 | 15 | 5 |
| 3 | 35 | 70 | 10 |
| 4 | 80 | 75 | 6 |
| 5 | 90 | 40 | 7 |
这个规模跑一次GA,pop_size=50,迭代100代,十几秒就能出结果。如果这个都不能得到合理答案,问题一定出在解码器或者目标函数上,不要去怪算例规模。我一直觉得,小算例的价值不只是"验证代码能跑",更重要的是让你人工手算一条可行解,和算法给出的解对比,迅速暴露模型逻辑上的错误。
3.2 结果收敛性与合理性
用上面这个配置跑下来,我观察到的收敛曲线大致分三个阶段:前30代目标值快速下降,中间30到70代缓慢改善,之后基本平稳。最终解里,车辆走了一条穿过平面中部的路线,两架无人机分别从车辆路线上的两个节点起飞,各自服务一两个客户后回到后续节点汇合。没有出现无人机飞出去几百公里再回来这种违反物理常识的规划。
这里有一个非常有效的验证方法:人工构造一个"显然应该用无人机"的场景。比如让客户3离车辆主干路线只有5公里,但车辆如果专门绕过去,额外要走40多公里。这种情况下,任何合理的模型都应该选择让无人机飞一趟,航程只有10公里左右。如果模型给出的解反而全用车辆配送,那就说明目标函数权重或量纲有问题,而不是算法没调好。我每次改完模型都会先跑这种"常识测试用例",比看收敛曲线快多了。
另外,我还习惯把最终方案的路径画出来,用matplotlib把车辆路线和无人机起降弧线用不同颜色标注。看到车辆和无人机确实形成"主干+支线"的结构,才算真正放心了。枯燥的数值表格容易掩盖不合理之处,一图胜千言。
3.3 关键参数敏感性:续航里程对协同效果的影响
我把无人机续航从20分钟调到40分钟,分别跑十次取平均,结果如下:
| 无人机续航 | 平均无人机服务客户数 | 相对基准总成本 |
|---|---|---|
| 20分钟 | 0.4 | 99.8% |
| 30分钟 | 2.2 | 94.5% |
| 40分钟 | 3.8 | 88.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_duplicates=True,并且把PM变异概率从1/(2N)调大到约1/N。这样处理后,种群在全部300代里都保持了足够的多样性,目标函数值也继续下降了约6%。这个例子说明,组合优化里遗传算法的核心不是交叉,而是维持多样性。交叉负责找解,变异和去重负责兜底,三者缺一不可。
5. 后续扩展与个人建议
5.1 从集中式到更大规模的近似策略
集中式协同模型有个先天问题:决策变量包含完整的车辆路线和每个客户的无人机分配,一旦客户数超过50,pymoo的GA直接跑会很吃力,单跑一次可能就要一两个小时。实践中可以考虑两阶段法:第一阶段用聚类算法把客户分成若干个簇,每簇由一辆车负责,簇内部再做集中式车机协同;第二阶段再对簇之间的衔接做优化。这样模型从全局集中变成"组内集中、组间协调",可扩展性好很多。
如果你希望保持全局集中式,那就得换思路,比如把模型线性化后交给商业MIP求解器求解小规模精确解,用pymoo构造初始上界。我在小规模对比中发现,pymoo得到的解和精确解的差距能控制在5%以内,这说明随机键加GA的方案作为快速原型完全够用。真到大规模落地,再考虑更精细的数学规划或者启发式混合算法。
5.2 动态场景下的滚动时域扩展
论文复现通常只做静态规划,但实际配送场景里新订单随时会来。我试过一个扩展做法:把时间轴切成多个时段,每个时段开始时把当前未配送客户和车辆位置作为初始状态,再用pymoo重新求解未来一段时间的方案,这就是滚动时域。在这个框架下,pymoo仍然是每个时段的求解内核,只需要在Problem类里增加"当前位置"和"已用时间"两个状态字段,模型改动不大。
推荐动手扩展前,先把静态版本整理成"输入数据、模型、解码器、求解器、输出方案"五个独立模块,动态化时只需要替换输入数据部分,核心模型不动。如果能做成这样,后续换算子、换算法、加约束,都只是局部改动,不会牵一发动全身。
5.3 给准备复现类似论文的读者几点建议
根据我这次复现的经验,有几条比较通用。第一,先写解码器,再写目标函数,最后才接pymoo。解码器测不透,上层跑了也白跑。第二,一定要保留所有随机种子和参数记录,否则跑完一发结果,回头想复现昨天那张图,你会非常被动。第三,不要一上来就加时间窗、多目标、多车辆,功能每加一个就要重新做一次小算例回归。我的经验是,每增加一个功能,先用5客户算例重跑一遍,确认旧的合理解没有被破坏。
最后再说个体会。刚开始复现那会儿,我总想着把论文作者源码找出来直接跑,后来发现,自己从头搭一遍虽然慢,但模型里的每个假设、每个参数的含义都会理解得特别透彻。这种理解才是复现论文真正值钱的地方。后面我再碰到类似的车机协同选题,第一件事依然是先画解码器,再谈优化算法。