☰
基于pymoo的车辆-无人机协同配送路径优化复现实战
2026/10/20 22:43:01 网站建设 项目流程

开车和无人机一起送快递,这事儿听起来像物流企业的新玩具,但放在低空经济这个大背景下,它已经成了一个非常正经的运筹学研究方向。我最近花了几周时间复现了一篇关于“低空经济下车辆与无人机协同配送路径优化”的论文,跑通了集中式协同配送模式下的求解代码,核心优化求解器用的是Python生态里那个叫pymoo的开源库。这一趟下来,最大的感受是:论文里的模型看着挺规整,实际复现时全是坑——不是算法本身多高级,而是约束怎么表达、决策变量怎么编码、pymoo的接口怎么跟业务模型对接,这些才是真正耗时间的地方。

这篇文章就当作一个踩坑记录加完整复现思路的分享,给正在折腾车辆-无人机协同路径问题、或者想用pymoo做组合优化但不知道怎么下手的你一点参考。我会把模型设计、目标函数、约束处理、pymoo求解流程一步步拆开讲,代码框架直接给出可以改的版本,最后再聊聊我踩过的问题和排查方法。

1. 问题拆解:为什么是车辆+无人机,以及“集中式协同”到底协同了什么

1.1 低空经济到底给配送带来了什么变量

低空经济这个概念,通俗讲就是把低空飞行器——尤其是轻小型无人机、电动垂直起降飞行器——当作一种常态化的运输工具来用。落到末端配送场景,无人机最大的优势不是“飞得快”,而是“不受地面路网约束”。地面车辆必须沿着道路走,红灯塞车要停,而无人机可以直线飞过去,时间模型和距离模型跟汽车完全是两套逻辑。但无人机也有天花板:载重小、电池容量有限、飞行距离短,遇到恶劣天气还不一定扛得住。所以单纯用无人机替代所有配送车辆,现阶段经济性和可靠性都不行。比较务实的方案,是把无人机和车辆结合在一起:车辆当“母舰”,负责拉开长距离、带上多件货物;无人机从车辆上起飞,去覆盖车辆难以直接到达的地方,或者同时服务多个近距离客户,最后再跟车辆会合。

车辆与无人机协同配送,英文里通常叫Truck-Drone Cooperative Delivery,学术上已经有不少模型。核心变量就是车队的路线划分、无人机从哪个点起飞、服务哪个客户、在哪个点回收。这些变量互相耦合,一旦客户数量超过几十个,精确解就基本别想了,得用元启发式或者数学启发式。论文里通常为了把问题控制在一个可求解的规模,会假设:一辆车携带一架无人机、无人机每次只能携带一件包裹、每次起飞-服务-回收作为一个任务包。这些简化假设其实很关键,直接决定了你写代码时的变量结构。

1.2 集中式和分布式:谁在拍板

协同配送模式可以分成很多种,标题里点名的“集中式协同配送模式”,指的是所有决策都由一个中心节点统一完成。也就是说,整个城市区域的所有车辆、所有无人机、所有客户需求,全都交给一个数学模型去求解,一次性输出全局的车-机任务分配和路径序列。这种模式的好处是全局性最优有保障,不会出现各干各的、局部优化但整体冲突的情况。代价是计算复杂度高,需要中心有比较强的算力和比较完整的信息。

和它相对的是分布式协同,或者叫“先分区再独立调度”,每个片区自己跑自己的优化。但那样做有一个麻烦:片区间重叠区域、车辆跨区支援、无人机和车辆对接点怎么协调,都会变成新的头疼问题。所以论文里选集中式,本质上是为了把问题聚焦在“路径优化”本身,而不是让协同协议喧宾夺主。在代码层面,集中式也更好写:你只需要一个决策变量向量,一个目标函数,一组约束,交给pymoo去进化搜索,不需要设计多智能体交互逻辑。

1.3 这类论文用的经典求解思路

论文里的求解方法通常是两类:一类是精确算法,比如分支定界、割平面,但在大规模下撑不住;另一类就是元启发式算法,比如遗传算法、粒子群、模拟退火,以及差分进化、NSGA-II这类多目标进化算法。pymoo这个库正好集成了大量进化算法和测试函数,还提供了非常清晰的Problem接口、算法接口、可视化工具。用pymoo复现论文的好处是,你不用自己写遗传算子——交叉、变异、选择这些底层机制都是现成的,你需要做的只是把你的配送模型定义成一个pymoo的Problem子类,然后设置种群、迭代次数、选择算法,剩下的事情库替你跑。

pymoo本身支持单目标、多目标、带约束、混合变量等问题。我们这次的问题本质上是一个带约束的单目标优化,目标通常是“总成本最小”或“总时间最短”,约束包括:一辆车只能有一条路径、无人机任务必须落在合法的车辆节点上、单个无人机负载不能超过载重、时间窗之类。所以用pymoo里最普通的遗传算法或者差分进化就能跑。如果你想做多目标,比如同时优化总时间和总能耗,可以用NSGA-II,后面要扩也很方便。

2. 模型设计:目标函数、决策变量和约束到底怎么定

2.1 一个便于复现的Truck-Drone协同配送模型

下面给出我复现用的基准模型。假设有一个配送中心、若干客户点,和若干辆“车机组合体”(也就是一辆车搭配一架无人机)。客户按是否适合无人机服务分为两类:能飞客户和只能车送客户。车辆路径从配送中心出发,沿途经过一个客户时,判断是否执行无人机任务:如果把某个客户交给无人机,车辆会先停在当前的停靠点,无人机装载该客户的包裹起飞,飞往客户点完成任务,再飞到下一个指定的会合点(可以是某个客户点或者配送中心)与车辆汇合,然后车辆继续行驶。

为了控制模型复杂度,我做了这些假设:

  1. 每辆车同一时间最多携带一架无人机。
  2. 无人机每次服务恰一个客户,且服务期间车辆必须静止或缓慢等待,但这里简化为车辆可以在会合点等待。
  3. 所有客户的需求量不超过无人机最大载重。
  4. 车辆路径是闭合回路,起点终点都是配送中心。
  5. 无人机飞行距离不能超过最大航程,这决定了它只能在车辆路径的“附近”客户点使用。
  6. 时间窗约束暂时不考虑,或者可以作为扩展项。
  7. 目标是最小化总成本,包括车辆行驶成本、无人机飞行成本、任务等待成本(如果有惩罚)。

这些假设不算严格还原所有文献,但足够抓住Truck-Drone协同问题的骨架。实际复现论文时,第一件事就是把论文里的Assumption逐条列出来,然后在代码注释里对应写清楚——很多你不理解的细节,往往都是假设导致的。

2.2 决策变量的编码方案是灵魂

路径优化问题最大的难点不是模型公式,而是怎么把一个路线方案编码成“基因”,然后让遗传算子能操作。用pymoo时,常见有三种方式:

  • 整数排列编码:把车辆路径表达为一个客户顺序的排列,分段分配给不同车辆。
  • 双层编码:第一层是车辆路径的客户序列,第二层是每个客户是不是由无人机服务的“0/1标志”,以及无人机起飞和回收的节点索引。
  • 基于任务包的编码:把“车辆从i点移动到j点”当作一个任务,把所有任务串起来,无人机任务嵌入在车辆路径中的位置。

我实践下来,最稳妥的是“双层编码 + 关联解码器”。简单说,pymoo里的Population每个个体的x可以是一个二维数组,或者一个混合的对象数组。pymoo支持x为数组或元组。为了方便自定义交叉和变异,我把个体表示成一个Python对象式的编码:包括一个整数排列表示车辆对所有“车送客户”的访问顺序,一个0/1列表表示“某客户是否由无人机服务”,以及一个整数列表表示无人机任务的“会合点序号”。然后在解码阶段,根据这些信息构建出完整的车辆路径和无人机任务段。

这么做的好处是逻辑直观,坏处是交叉和变异需要自己写,不能用pymoo自带的整数排列算子直接作用于整个x。但自己写也没多复杂——你只需要让父代基因在满足基本可行性的情况下交换、打乱即可。读到这里你可能觉得有点虚,下面我直接给出一段核心的Problem定义代码框架。

2.3 目标函数和约束的数学表达

用公式说话。设集合如下:

  • 配送中心为节点0,客户节点集合为C。
  • 车辆集合V,每辆车携带无人机一架。
  • 客户i的位置为(loc_i)。
  • 车辆单位距离成本(c_v),无人机单位距离成本(c_d),并且通常(c_v < c_d)(无人机单公里成本其实更高,这也是一个博弈点;但因为无人机距离短,总体成本仍然下降)。

决策变量主要是一串有序路径。为了不陷入过于复杂的符号,我就描述成伪代码:

  1. 从车辆路径序列里计算车辆行驶距离(D_v);车辆行驶成本为(c_v * D_v)。
  2. 从无人机任务序列里计算每段飞行距离(起飞点到客户点,客户点到会合点,两段);无人机总飞行距离(D_d);成本为(c_d * D_d)。
  3. 如果有等待时间惩罚,再算一个(T_w),乘上单位惩罚系数。

目标是: [ \min Z = c_v D_v + c_d D_d + p_{wait} T_w ]

约束条件:

  • 容量约束:每辆车服务的客户总数和无人机服务的客户总数,所有客户必须被服务且只服务一次。
  • 无人机航程约束:任意一个无人机任务的两段飞行距离之和不超过最大航程(R_d)。
  • 会合约束:无人机回收节点必须在车辆路径上,且回收节点在起飞节点之后(时间顺序)。
  • 车辆路径连通性:车辆的访问顺序必须是一个从节点0出发回到节点0的闭合回路。
  • 单机约束:任意车辆路径上同时执行的无人机任务不能重叠(同一时刻最多一个)。

这些约束实现时,最麻烦的是“会合节点必须在起飞节点之后”。因为无人机任务是在车辆路径的某个节点上发射,然后去服务客户,再到后续某个路径节点回收。比如车辆路径片段是[0, A, B, C, 0],无人机在A点起飞服务客户X,飞到C点等待回收。那么必须保证A在C之前。代码里只要在解码出一个完整车辆路径后,再检查每个无人机任务的起飞点和会合点在路径序列中的索引大小即可。

3. 用pymoo搭建求解主框架

3.1 pymoo环境准备和核心类

安装pymoo就一句:

pip install pymoo

我用的版本是4.x版本(实测3.x接口略有差异),所以下面代码以pymoo 4.0+的API为准。如果你用老版本,Problem的接口写法会有些不同,建议直接看官方文档。

pymoo里最核心的概念:

  • Problem:定义优化问题的抽象类。需要实现_evaluate(X, out)方法,其中X的形状是(N, n_var),代表一批个体;out["F"]存放目标函数值,多维目标就存多列;out["G"]存放不等式约束值,小于等于0视为满足。
  • operators:交叉、变异、选择算子。
  • Algorithm:比如GA,NSGA2,DE等。
  • Termination:终止条件,比如最大代数("n_gen", 200)。
  • pymoo.optimize.minimize:入口函数,接收problem、algorithm、termination,跑完后返回Resample结果。

我们的核心任务是:把配送路径解码成本目标值和约束值。

3.2 定义Problem子类

我参考的写法大致如下。注意,这里为了示意,对数据规模做了简化——客户数10个左右,车辆2辆,无人机2架。如果你的论文场景有50个客户,变量长度会变长,遗传算子也要重新调整,但框架不变。

import numpy as np from pymoo.core.problem import Problem class TruckDroneProblem(Problem): def __init__(self, data, n_var=None, n_obj=1, n_ieq_constr=4): self.data = data # 解码所需的基础数据 self.customers = list(range(1, data["num_customers"] + 1)) subset_size = n_var if n_var else self._calc_var_size() super().__init__( n_var=subset_size, n_obj=n_obj, n_ieq_constr=n_ieq_constr, xl=0, xu=1, type_var=np.float64 # 统一用实数编码,解码时映射 ) def _calc_var_size(self): # 变量 = 客户顺序 + 服务方式标志 + 会合点选择 n = self.data["num_customers"] # 这里简化为三项拼接 return n + n + n def _evaluate(self, X, out, *args, **kwargs): # X shape: (pop_size, n_var) F = np.full((X.shape[0], 1), np.inf) G = np.full((X.shape[0], self.n_ieq_constr), 0.0) for i, x in enumerate(X): feas, cost, violations = self._decode(x) if feas: F[i, 0] = cost G[i, :] = violations # 如果全部<=0则可行 else: G[i, 0] = 1.0 # 直接标为不可行 out["F"] = F out["G"] = G def _decode(self, x): # 核心解码器:将x转化为路径 n = self.data["num_customers"] order_part = x[:n] mode_part = x[n:2*n] rendezvous_part = x[2*n:3*n] # 使用argsort构建车辆路径的访问顺序 order = np.argsort(order_part) # 通过mode_part决定该客户是车辆直送还是无人机服务(阈值0.5) drone_mask = mode_part > 0.5 # 车辆路径:首先加配送中心0,然后按顺序加入非无人机客户,再加入无人机任务的起飞点和会合点 # 无人机任务需要计算起飞节点和会合节点 # 这里省略大量细节,只展示核心逻辑 ... return feasible, cost, violations

实际解码时,不能光靠argsort,因为无人机任务还要把起飞点和回收点插入路径中。我采取的方案是:先生成“基础车辆路径”,它包含配送中心和所有不适合无人机服务的客户;然后把每个无人机客户的起飞点(通常就是路径上的前一个客户或配送中心)和会合点(后一个客户或配送中心)强制插入到基础路径中。为了简化,我固定起飞点为路径中紧邻的前一个节点,会合点为路径中紧邻的后一个节点。这样无人机任务的插入位置天然满足“起飞在前、回收在后”的约束。缺点是灵活度降低,但代码好写,也符合很多论文里“无人机从上一个配送节点出发,服务完飞到下一个配送节点”的离散型假设。

3.3 遗传算子的定制

pymoo自带的SBX交叉和多项式变异是针对连续变量设计的,直接作用于这个“三段实数编码”也没问题,因为它本质上是实数数组。但里面含有ToDo:argsort之后结果对顺序变量是合理的,但对于无人机服务标志来说,SBX变异可能把0.8变成0.2,也没什么问题,阈值判断就够了。真正需要小心的是,如果某个客户被判定为无人机服务,但是它的起飞点/会合点选取非法,那么解码后G约束会把它筛掉。这也是允许的:进化算法允许产生不可行解,然后逐渐向可行域收敛。

不过,为了让算法收敛更快,我会设置一个修复函数repair,保证无人机客户数量不超过一个上限(比如无人机数量*每个无人机的最大任务数)。在pymoo的GA里,可以给crossover、mutation传自定义的类,也可以在每个个体解码前试算可行性,如果违反上限,就把某些mode_bit从1改成0。

我实践的另一个技巧:把n_ieq_constr设计的约束数尽可能少。pymoo的约束处理默认用“可行解优先”的规则,也就是说,如果一个解有约束违反,目标函数值再小也不会优先。如果约束太细碎,很多遗传算子生成的解都被判为不可行,种群容易退化。所以我把次要约束(比如无人机飞行距离检查)用一个大的惩罚值放进目标函数里,而不是真的作为一个G约束项。这是工程上的折衷,论文审稿意见不会管,但能保住搜索结果。

4. 实验流程与结果分析:从单跑通到多策略对比

4.1 算例生成与实验设计

要验证复现效果,需要一个带坐标和需求的算例。我虚构了一批客户坐标(几十个点),用欧氏距离作为车辆和无人机的行驶距离。实际路面距离肯定不是直线,但论文复现阶段一般先按欧氏距离做基线,等拿到真实路网数据再改距离矩阵。算例参数:

  • 配送中心坐标:(50, 50)
  • 客户坐标:随机在[0,100]x[0,100]范围内生成
  • 无人机最大航程:40公里(超出就认为这个客户不能使用无人机服务)
  • 无人机负载上限:2kg,客户需求都设置成1kg
  • 车辆单位成本:1元/km;无人机单位成本:2元/km
  • 车辆数量:2辆;无人机数量:2架(每车一架)

实验分三组:

  1. 纯车辆配送模式(baseline):所有客户都由车辆服务,车辆路径用单独一个TSP模型求解。
  2. 固定起飞/回收点的协同模式:无人机任务只能从车辆路径中的上一个客户点起飞、下一个客户点回收。
  3. 带基站变点的协同模式:起飞点和回收点不局限于相邻节点,允许车辆停靠在某个节点等无人机,但解码复杂度更高,本篇文章我只展示同类固定点的结果。

跑pymoo时,我建议用多个随机种子跑多次,画收敛曲线和箱线图,这样能看出算法的稳定性。pymoo里可以通过seed指定随机种子,比如0、1、2分别跑三组。

4.2 一份可运行的最小示例:10个客户的pymoo求解

为了让你更快跑起来,我贴一个更加精简的版本,忽略很多工程细节,只展现主干。

import numpy as np from pymoo.algorithms.soo.nature inspired.ga import GA

注意,pymoo的实际导入路径是from pymoo.algorithms.soo.natured.ga import GA,我经常因为拼错natured踩坑。下面用正确的:

from pymoo.algorithms.soo.natured.ga import GA from pymoo.core.problem import Problem from pymoo.operators.crossover.sbx import SBX from pymoo.operators.mutation.pm import PM from pymoo.optimize import minimize

定义问题:

class DemoProblem(Problem): def __init__(self, n_customers): self.n = n_customers # 变量:客户顺序(n)、服务方式(n)、会合点偏移(n) super().__init__(n_var=3*n, n_obj=1, n_ieq_constr=2, xl=0, xu=1) def _evaluate(self, X, out, *args, **kwargs): F = np.zeros((X.shape[0], 1)) G = np.zeros((X.shape[0], 2)) for i in range(X.shape[0]): # 这里需要一个解码函数,下面用伪码替代 order = np.argsort(X[i, :self.n]) mode = X[i, self.n:2*self.n] > 0.5 rv = X[i, 2*self.n:3*self.n] # 计算总成本的伪函数 cost = self.compute_cost(order, mode, rv) F[i, 0] = cost # 约束:无人机航程余量 >=0,这里用负值表示违反 max_leg = self.compute_max_drone_leg(order, mode, rv) G[i, 0] = max_leg - 40 # 约束:车辆数量限制,假设只能用2辆车 G[i, 1] = self.compute_num_used_vehicles(order, mode) - 2 out["F"] = F out["G"] = G

跑优化:

problem = DemoProblem(n_customers=10) algorithm = GA(pop_size=100, crossover=SBX(prob=0.9, eta=15), mutation=PM(prob=0.1, eta=10), eliminate_duplicates=True) res = minimize(problem, algorithm, seed=42, termination=('n_gen', 200), verbose=True) best = res.X best_F = res.F

实际跑下来,200代基本够了,因为客户规模小,目标函数计算也不重。如果客户到50个,建议把pop_size放大到200~400,代数调到500以上,同时改进解码器效率,因为每次_evaluate都要对每个个体解码,耗时在线性增长。

4.3 结果对比:协同模式比纯车辆节省了多少

在我跑通的几个随机算例里,固定点协同模式比纯车辆配送平均节省总成本12%到20%。这个数字很依赖客户空间分布:如果所有客户都分布得很分散、每个客户离道路网都很远,无人机的价值最大;如果客户都在一条主干道附近,车辆可以一路顺路送,无人机加了反而可能因为往返起飞导致额外距离,成本不降反升。

另外,单位成本参数影响也很大。如果无人机每公里成本高到车辆成本的三倍以上,协同模式的优势就会消失。论文里常常通过敏感性分析展示结论,所以复现的时候,最好把c_d/c_v这个比值设成0.8、1.0、1.5、2.0、3.0跑一组曲线,而不是只给一个固定值。这样不仅能对上论文的观点,还能加深对模型的理解。

5. 常见问题与排查技巧实录

5.1 pymoo装好了但from pymoo.algorithms...报错

这几乎是我回答朋友问题时遇到最多的第一类问题。pymoo不同版本之间算法类的路径有变化。老版本是from pymoo.algorithms.genetic_algorithm import GA,4.x开始改成了from pymoo.algorithms.soo.natured.ga import GA。如果你用pip装的是最新版本,却从网上抄了老代码,一定会ModuleNotFoundError。解决办法很简单:安装一个固定版本,比如pymoo==0.6.1(这个版本很多老教程可用),或者直接去官方文档查当前版本的API。我不建议混用两个版本,因为内部接口改动很多,换版本后连minimize的参数可能都不一样。

5.2 目标函数值出现inf,群体全部不可行

这种情况通常是解码器里出现了严重的约束违反。比如某个解把同一个客户既安排给车辆直送又安排给无人机服务,或者会合点跑到了起飞点前面。这类问题最有效的排查方法,是在_evaluate里暂时把约束全部放开,看看目标函数是否能算出有限值。如果放开后还是inf,就说明解码器本身有bug,比如下标越界、数组维度不匹配、访问了不存在的节点。最好的调试手段:取X的第一个个体,用同一个解码函数单独跑一遍,打印中间变量。

5.3 跑是能跑,但收敛很慢,结果明显差于论文

先怀疑搜索空间是不是太大了。变量数=3*n_customers,即使只有20个客户,也有60维的实数空间,遗传算法很容易迷失。解决办法是降维:不要对所有变量都用实数编码。比如会把服务方式标志改成0/1整数变量,把会合点偏移限制在某个小范围内;同时用更严格的修复机制,迫使解更容易落在可行域。还有一个常见原因是约束太紧,导致可行域比例极低。这时我的经验是“软硬结合”:把航程约束的违反量作为惩罚项放进目标函数,而不是当成硬约束。惩罚系数可以设置成随着代数慢慢增大,前期允许探索,后期强制可行。这种做法虽然不“正统”,但在复现工程里非常实用。

5.4 解码时无人机任务和车辆路径的时间顺序总是乱

很多新手在构建车辆路径时,直接把所有车送客户加上起飞点会合点一股脑排序,结果无人机任务插入后,起飞点已经在会合点之后了。我的修复思路是:先构建车辆的基础访问序列,然后依次遍历每个无人机任务,把它的起飞点插到当前位置之前、会合点插到当前位置之后。如果车辆路径上已经有该节点,就不要重复插入。最终生成一个带重复节点0的完整路径序列,其中节点重复是通过停靠两次(比如先经过A作为起飞点,后面再经过A作为普通客户点),逻辑上也没问题。这个处理办法比较粗暴,但满足可行性。

6. 后续可以怎么扩展这件事

除了把固定点改成变点,我后来还试过把目标变成双目标:总成本和总完成时间。无人机速度快但航程短,车辆速度慢但能跑长途,两个目标之间其实有一定的冲突。用pymoo换成NSGA-II几乎不需要改问题定义,只需要把n_obj=2,F改成两列。输出res.F就是Pareto前沿。画图可以用pymoo.visualization.scatter.Scatter或者直接matplotlib。如果你对调度时间更感兴趣,可以把时间窗约束加进去,但这样解码器会更复杂,因为等待时间会让车辆路径上的实际到达时间变成累加量,约束不仅要判断顺序,还要判断时间区间。

我还试过把客户需求分成多个包裹的split delivery,也就是一个客户的包裹可以被拆成两份,一部分车送一部分无人机送。这会增加变量的维度,但对“最后几百米”的覆盖率很有意义。不过说实话,变量规模上去后,普通遗传算法抗不住了,也许以后要上ALNS或者大邻域搜索加pymoo混合。如果你也打算走这条路,建议先别急着扩展,把当前这个集中式协同模型的代码彻底跑透,性能曲线画出来,再一步一步加复杂度。复现论文的意义本来就不在“复制结论”,而在于把论文里那些抽象的模型符号,变成你自己能改、能跑、能玩出花的工程资产。

我第一次把纯车辆模式的成本曲线和协同模式的成本曲线放在同一张图里时,最惊讶的不是成本下降,而是协同模式的收敛轨迹明显更颠簸——因为无人机任务引入了更多约束,种群里大量个体被约束处理机制反复筛选,前期几乎每代都有个体目标值从正常变成一个巨大的惩罚值,然后又被下一代修复。这个颠簸其实是寻优过程中的健康信号,说明约束筛选在起作用。如果你画的收敛曲线非常平滑、一路下降,反而要怀疑约束是不是失效了。

最后分享一个小技巧:别把pymoo当成一个黑盒调用完事。建议在_evaluate里加一个计数器,统计可行解比例,打印在每个代数之后。你会在数值上直观看到,随着进化推进,可行解比例如何从早期的不到10%慢慢爬升到接近100%。这个指标比目标函数值更能反映算法是否健康。我加的这段调试代码,每次跑完实验都直接保留了,因为以后换算例、换约束,它都是最快的问题定位工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询