遗传算法与 Geatpy:从第一个例子开始
这篇笔记面向第一次接触优化算法的读者。先不用背算法缩写,抓住一个问题:有很多可选方案,怎样找到比较好的那个?
例如无人机沿一条已经确定的路线巡检,我们需要决定速度和作业距离;或者有多个杆塔,需要决定先检查哪个、后检查哪个。前者是一组数值,后者是一个排列,但都属于“优化问题”。
本文先讲基本遗传算法,再讲 Geatpy 的三个步骤,随后给 SGA、DE、PSO、NSGA-II 和 MOEA/D 配上案例。末尾保留无人机巡检参数的完整示例。接口以 Geatpy 2.7.0 官方源码为依据,示例模型用于学习,不是经过实飞标定的机型参数。
一、基本遗传算法到底做了什么
1.1 先用一个能手算的例子
假设要在 中寻找让下面函数最小的 x:
我们知道答案是 x=3,目标值为 0。但先假装不知道答案,随便试几个方案:
| 候选解 x | 目标值 f(x) | 好坏 |
|---|---|---|
| 1 | 4 | 还不错 |
| 5 | 4 | 还不错 |
| 8 | 25 | 比前两个差 |
| 3.5 | 0.25 | 当前最好 |
遗传算法同时保留一批这样的方案,让较好的方案更有机会产生后代,再加入一些随机变化,反复寻找更好的答案。这里的“遗传”只是算法比喻,不涉及真实生物。
1.2 六个词一次讲清
| 名词 | 通俗理解 | 巡检任务中的例子 |
|---|---|---|
| 决策变量 | 我们可以调整的量 | 速度、作业距离、访问顺序 |
| 个体 | 一份完整候选方案 | 速度 8 m/s、距离 10 m |
| 染色体 | 把方案写成算法可操作的数据 | [8.0, 10.0] |
| 种群 | 同时参与搜索的全部方案 | 80 组不同参数 |
| 目标值 | 方案真正要优化的指标 | 任务能耗或航程 |
| 适应度 | 选择操作使用的优劣尺度 | 结合目标方向和违规程度评定 |
目标值与适应度不一定是同一个数。最小化能耗时,能耗越低越好;有些选择算子却要求适应度越高越好,需要做转换。使用 Geatpy 时,正确设置目标方向即可,不必为了“越大越好”自己把最小化目标取负。
1.3 一代进化的流程

图 1:选择、交叉、变异与重新评价。数字仅用来说明基因怎样组合,不对应第八节的巡检模型;精英保留是可选增强策略,新子代不一定比父代好。
选择:让好方案有更大机会当父母,通常保留一些随机性。
交叉:组合两个方案的信息。例如 [8, 10] 和 [6, 12] 可以产生 [8, 12]。但这个新方案必须重新评价,不保证比父母好。
变异:例如把速度 8 改为 8.3,或交换巡检路线中的两个点。它帮助探索目前种群还没覆盖的位置。
精英保留:直接复制当前最好方案,避免被随机操作破坏。它是一种常见增强策略,并非所有基本 GA 都默认包含。固定、确定的评价下,保留精英可以保护历史最好结果;不能据此保证找到全局最优。
初始化只发生在开始时;以后每代是在已有种群上进化,不是每次完全重新随机生成。
1.4 哪些问题值得用遗传算法
当方案是离散选择、顺序排列,或者评价要运行仿真、无法方便求导时,GA 是可以比较的方法。例如巡检点访问顺序、传感器布点和带复杂约束的任务分配。
上面的平方函数更适合直接计算答案。能写成线性规划或整数规划的任务也应与相应求解器比较。遗传算法的优势是灵活,代价是反复评价和随机性,不是“所有问题都更快”。
二、使用 Geatpy 前:先把问题说清楚
2.1 四种分类,不能混在一起
“单目标还是多目标”看你保留几个独立指标;“有约束还是无约束”看变量边界之外是否还有必须满足的条件。
| 类型 | 例子 | 目标输出 | 约束输出 |
|---|---|---|---|
| 单目标、无额外约束 | 最小化 (x-3)^2,x 在指定范围内 | 一列 ObjV | 不返回 CV |
| 单目标、有约束 | 最小化能耗,任务时间必须不超过 15 分钟 | 一列 ObjV | 返回时间约束列 |
| 多目标、无额外约束 | 同时最小化能耗和时间,不预先合成总分 | 两列 ObjV | 不返回 CV |
| 多目标、有约束 | 同时最小化能耗和时间,作业距离不得低于下限 | 两列 ObjV | 返回距离约束列 |
严格说变量上下界也是约束;本文“无约束案例”指只有上下界,没有额外的函数约束,这也是优化示例中常见的用法。
把能耗和时间加权成 J=能耗+权重×时间 后,只优化一个 J,这仍然是单目标。多目标则保留两个指标,让算法找到不同折中方案。
2.2 Geatpy 的三个对象
| 步骤 | 对象 | 你需要回答的问题 |
|---|---|---|
| 定义问题 | ea.Problem | 变量有哪些?怎样评价?有什么限制? |
| 创建种群 | ea.Population 等 | 一份方案怎样编码?同时搜索多少份方案? |
| 运行算法 | 算法模板 | 候选方案如何变化?搜索多久? |
可以把它们理解为“题目、考生名单、解题方式”。换算法时题目可以不变;换问题时也不必重写整个优化循环。
下面展示的是对应步骤的代码片段,完整脚本在后续案例中:
problem = MyProblem() # 写清题目。population = ea.Population(Encoding="RI", NIND=60) # 60 个候选方案。algorithm = ea.soea_SGA_templet(problem, population, MAXGEN=100)result = ea.optimize(algorithm, seed=42, drawing=0, saveFlag=False)三、定义问题:Problem 要填什么
3.1 问题参数逐项解释
假设变量是速度 v 与作业距离 d,同时希望能耗最小、图像质量最大:
| 参数 | 含义 | 示例 |
|---|---|---|
name | 问题名称,便于识别日志 | "DroneInspection" |
M | 目标数量 | 2:能耗与质量 |
maxormins | 每个目标的方向 | [1, -1]:第一个最小化,第二个最大化 |
Dim | 决策变量数量 | 2:速度与距离 |
varTypes | 每个变量连续还是整数 | [0, 0]:两个都是连续变量 |
lb、ub | 每个变量的上下界 | [4, 5]、[12, 15] |
lbin、ubin | 是否包含边界值 | [1, 1]:边界可以取到 |
varTypes 中 0 表示连续变量,1 表示整数变量。若是是否执行任务,用整数变量并将上下界设为 0 和 1;varTypes=1 本身并不意味着二进制。
两个常见混淆:M=2 表示两个目标,Dim=2 表示两个变量,二者没有必然联系;maxormins 长度应为 M,而 varTypes 和上下界长度应为 Dim。
3.2 每个方案怎样得到一个分数
evalVars(Vars) 接收一批候选方案。若有 60 个个体、每个有两个变量,Vars.shape 就是 (60, 2)。
# 这是评价函数内部的片段:每行一个方案,每列一个变量。v = Vars[:, 0]d = Vars[:, 1]
# 单目标必须返回二维矩阵 (N, 1),而不是一维数组 (N,)。ObjV = ((v - 8) ** 2 + (d - 10) ** 2).reshape(-1, 1)
# 若保留两个独立目标,则每个目标占一列,形状为 (N, 2)。ObjV_multi = np.column_stack([(v - 8) ** 2, (d - 10) ** 2])这里的 N 是种群人数,不是变量数量。NumPy 的按列计算让同一函数一次评价所有方案;若评价必须调用外部仿真,也可以逐个评价,再整理为同样形状的矩阵。
3.3 有约束时如何返回 CV
Geatpy 使用“约束左边不大于 0”为满足条件的约定。例如:
| 原条件 | 对应的 CV 表达式 | 满足时 |
|---|---|---|
| 能耗 E ≤ 50 | E - 50 | ≤ 0 |
| 距离 d ≥ 6 | 6 - d | ≤ 0 |
| x+y = 5,允许误差 0.001 | abs(x+y-5)-0.001 | ≤ 0 |
负值表示满足约束,不是负的“违规量”;正值表示违反程度。一个方案必须在所有约束列都满足条件,才算可行。
CV = np.column_stack([energy - 50.0, 6.0 - stand_off])# 在 evalVars 内返回两个矩阵:目标值和约束值。# return ObjV, CV约束的正负方向很容易写反,可以先代入一组手工数据检查。例如能耗 45 时,45-50=-5,可行;55 时为 +5,不可行。
“有约束”不需要另选一种种群类。约束属于 Problem,种群编码只负责方案表示。
3.4 evalVars 与 aimFunc 如何选择
两种接口都能用于单目标、多目标和带约束问题,不是按目标数量划分的。
evalVars(Vars):直接返回ObjV,有约束则返回(ObjV, CV),初学者可以先用它。aimFunc(pop):读取pop.Phen,再把结果写入pop.ObjV和pop.CV。
自定义问题类一般选一种接口实现,不要同时重写两者。Phen 是解码后的变量,评价应针对它;二进制染色体 Chrom 不一定已经是实际速度或距离。Geatpy Problem 接口源码。
四、创建种群:编码与种群类型怎样选
4.1 三种常用编码
| 编码 | 存什么 | 例子 | 合适的任务 |
|---|---|---|---|
RI | 实数或整数 | [8.5, 7.0, 2] | 速度、距离、无人机数量 |
BG | 二进制/格雷码位串 | 101010... | 二进制编码,按 Field 解码为实际变量 |
P | 不重复的排列 | [3, 1, 4, 2] | 杆塔访问顺序、作业排序 |
RI 里的变量最终是连续还是整数,仍由 varTypes 决定。多个 0/1 选择也可以用 RI 配合整数类型和 [0,1] 上下界表示。
population = ea.Population(Encoding="RI", NIND=60)NIND=60 表示 60 个方案,不表示 60 个变量,也不表示迭代 60 次。构造 Population 后,通常由算法按 Problem 的范围初始化染色体,不必手动填 60 行数据。
对于 P 编码,整个排列应有一致的整数集合。若路线包括固定机巢,可让染色体仅包含其余节点,评价时再补上机巢;不能只把现有速度数组的编码改成 P,就变成路径问题。Geatpy Population 编码说明。
4.2 Population、PsyPopulation 和多种群
| 类型 | 通俗理解 | 什么时候使用 |
|---|---|---|
Population | 每个方案一条染色体 | 同一条染色体表达连续参数、整数选择或排列 |
PsyPopulation | 每个方案包含多条染色体 | 同时含路线排列与连续速度,分别编码 |
| 多种群算法 | 几群方案分别搜索并交换信息 | 使用明确支持多种群的模板进行进一步实验 |
例如“访问顺序 + 每段速度”可考虑多染色体,路线用 P、速度用 RI,并明确各染色体对应的变量列。需要匹配 psy 模板、Field 和索引配置,不能把普通 Population 随意传给多染色体算法。
多目标是评价指标多;多染色体是一个方案有多种编码;多种群是有多个群体。 三个概念彼此独立。后面的双目标例子仍用普通 Population。PsyPopulation 源码。
4.3 种群规模不是越大越好
入门小问题可从几十个个体开始,观察效果再调整。每个方案若需要 10 秒仿真,100 个方案的一代就可能非常昂贵。
种群过小容易缺乏多样性;过大增加计算开销。比较算法时,应记录实际目标评价次数,而不是仅比较代数。
五、运行算法:五种方法的原理与横向比较
5.1 SGA:通过选择、交叉和变异产生新方案
SGA 是基本遗传算法。Geatpy 的 soea_SGA_templet 使用轮盘赌选择,交叉和变异算子根据编码选择。它不是种马算法,种马对应 soea_studGA_templet;增强精英保留对应 soea_SEGA_templet。
适合学习基本进化流程,以及离散选择、排列路线等任务。它对编码和算子依赖较强,种群过早相似会导致搜索停滞。SGA 官方实现。
5.2 DE:用方案之间的差,决定往哪里试
差分进化先取三个不同方案,构造:
若三个方案分别为 [6,8]、[10,12]、[8,10],F=0.5,就得到候选 [7,9]。再将它与当前方案交叉,评价后进行一对一比较,较好的留下;有约束时也要考虑可行性。
F 控制差分步长,CR 控制候选向量有多少分量进入试验方案。rand/1/bin 分别指随机基向量、一组差分、二项式交叉。“bin”不是把决策变量改成二进制。

图 2:差分变异产生候选向量,交叉后才得到试验方案。图中的 A 是基向量,x 是当前待比较方案;经典 DE/rand/1 采样时需避免把目标个体与差分来源索引混用。
它通常是连续黑箱参数优化的实用基线,例如仿真模型标定、控制器参数、巡检速度与拍摄距离;经典 DE 不直接适用于路线排列。DE 官方实现。
5.3 PSO:每个方案记住自己和群体的最好位置
粒子群把一组参数看成粒子的“位置”。粒子同时受三个因素影响:原来的移动趋势、自己曾经找到的最好位置、邻域或全群找到的最好位置。
这里用 w 表示搜索速度,避免与无人机物理速度 v 混淆。粒子不是无人机,粒子的搜索速度也不是飞行速度。

图 3:个人记忆与群体记忆共同影响下一步搜索,粒子不会直接跳到 gbest。箭头仅表达影响方向;实际步长还取决于惯性权重、学习系数与随机数。
PSO 不使用 GA 式交叉变异,常用于连续参数调节;如果大家过早跟随同一个好解,也会失去多样性。Geatpy 2.7.0 官方导出列表未提供通用 PSO 模板,本文用 NumPy 实现,不调用虚构的 ea.soea_PSO_templet。Geatpy 2.7.0 模板导出列表。
5.4 NSGA-II:同时留下不同的折中方案
假设两个指标都要最小化,A 的能耗更低但时间更长,B 的能耗更高但时间更短,此时没有谁全面胜出。
如果 A 所有指标都不比 B 差,且至少一个更好,称 A 支配 B。不会被其他可行方案支配的方案,构成我们想寻找的 Pareto 解集。
NSGA-II 先按支配关系分层,优先保留层级更好的方案;同层里再用拥挤距离,倾向保留在目标空间中不那么挤的方案。父子代合并后再筛选,兼顾质量与分布。

图 4:给出的五份可行方案中,A、B、C 互不支配,B 支配 D,D 支配 E。右侧用疏密示意拥挤距离;它在目标空间中计算,不是无人机之间的物理距离。
如果下一代容量不足以装下同一层的全部方案,就在这一层里做拥挤距离比较;不是先把低层级方案与高层级方案混在一起,只按疏密排序。
常用于能耗与时间、成本与精度等双目标或少量目标问题。结果是一组近似折中解,不是一条自动确定的最终方案;还需要任务偏好做选择。NSGA-II 官方实现。
5.5 MOEA/D:把多目标拆成许多不同偏好的小任务
MOEA/D 的 D 指 Decomposition,分解。可以想象同时安排多组搜索:有的更重视能耗,有的更重视时间,有的追求平衡;相近偏好的子问题互相分享新方案。
但它不只是做很多次简单加权求和。Geatpy 的基本模板使用切比雪夫或 PBI 分解方式,把目标向量转成每个子问题的标量评价,结合权重方向、理想点和邻域更新。理想点表示各目标目前观察到的最好分量,不一定对应某个真实方案。

图 5:不同子问题保留各自的评价方向,相近权重的邻居分享候选。一个新方案可能改善邻居 A,却不改善邻居 B,因此不必替换所有邻居的解。图中权重与箭头仅作概念示意。
它适合希望通过权重方向组织多目标搜索的任务。目标尺度、权重分布、邻域大小和前沿形状都会影响表现;不是目标多就一定比 NSGA-II 好。MOEA/D 官方实现。
5.6 放在一起比较
| 方法 | 本文版本的目标类型 | 常见变量 | 搜索依据 | 主要注意点 | 巡检场景 |
|---|---|---|---|---|---|
| SGA | 单目标 | 连续、整数、排列 | 选择、交叉、变异 | 编码与算子要匹配 | 点位访问顺序、任务选择 |
| DE | 单目标 | 主要是连续参数 | 方案之间的差分 | F、CR、边界与种群多样性 | 速度、拍摄参数标定 |
| PSO | 本文为单目标 | 连续参数 | 自己和群体的历史最好 | 容易过早聚集、尺度影响 | 控制器和巡检参数调节 |
| NSGA-II | 多目标 | 取决于编码 | 支配分层、拥挤距离 | 要检查前沿质量与可行性 | 能耗与任务时间折中 |
| MOEA/D | 多目标 | 取决于模板和算子 | 分解、权重方向、邻域 | 目标尺度、权重和邻域 | 多项任务指标的折中设计 |
这张表描述的是具体示例和常见使用方式,不代表整个算法家族的全部变体。NSGA-II、MOEA/D 可以采用不同的繁殖算子,不能把“多目标算法”和“变量是连续还是离散”绑定在一起。
初学时:先用 SGA 学流程,连续黑箱任务比较 DE 与 PSO,双目标先理解 NSGA-II,再看 MOEA/D 的分解思想。没有一种方法在所有问题上都更好。
六、五种算法分别做一个案例
6.1 SGA:整数变量的单目标无约束问题
任务是在整数 x=0 到 10 中最小化 (x-3)^2。已知最优 x=3、f=0,适合检查环境和理解代码。这是完整脚本,可单独运行:
import geatpy as eaimport numpy as np
class IntegerProblem(ea.Problem): def __init__(self): super().__init__(name="IntegerDemo", M=1, maxormins=[1], Dim=1, varTypes=[1], lb=[0], ub=[10], lbin=[1], ubin=[1])
def evalVars(self, Vars): x = Vars[:, [0]] # 双层索引保留 (N,1) 形状。 return (x - 3) ** 2 # 无额外约束,只返回目标矩阵。
problem = IntegerProblem()population = ea.Population(Encoding="RI", NIND=40)algorithm = ea.soea_SGA_templet(problem, population, MAXGEN=80, logTras=10)result = ea.optimize(algorithm, seed=42, drawing=0, verbose=False, outputMsg=False, drawLog=False, saveFlag=False)if result["Vars"] is None or len(result["Vars"]) == 0: raise RuntimeError("未找到可行结果,请检查问题定义和搜索预算")print("找到的 x:", result["Vars"][0])print("目标值:", result["ObjV"][0])这里整数性质由 varTypes=[1] 指定。想学习二进制编码,可以在这个问题上尝试 Encoding="BG";解码后评价的仍然是实际 x。
6.2 DE:连续变量的单目标有约束问题
任务是最小化到点 (1,2) 的距离平方,但要求 x+y≥5:
无约束时答案是 (1,2),但它违反条件。有约束的解析最优点为 (2,3),目标值为 2。可以把这个模型理解为:希望参数接近默认值,但总投入必须达到下限。
import geatpy as eaimport numpy as np
class ConstrainedProblem(ea.Problem): def __init__(self): super().__init__(name="ConstrainedDemo", M=1, maxormins=[1], Dim=2, varTypes=[0, 0], lb=[0, 0], ub=[5, 5], lbin=[1, 1], ubin=[1, 1])
def evalVars(self, Vars): x, y = Vars[:, [0]], Vars[:, [1]] ObjV = (x - 1) ** 2 + (y - 2) ** 2 CV = 5 - x - y # x+y>=5 等价于 5-x-y<=0。 return ObjV, CV
problem = ConstrainedProblem()population = ea.Population(Encoding="RI", NIND=60)algorithm = ea.soea_DE_rand_1_bin_templet(problem, population, MAXGEN=200, logTras=20)algorithm.mutOper.F = 0.5 # 差分步长系数。algorithm.recOper.XOVR = 0.7 # DE 的交叉概率 CR。result = ea.optimize(algorithm, seed=42, drawing=0, verbose=False, outputMsg=False, drawLog=False, saveFlag=False)if result["Vars"] is None or len(result["Vars"]) == 0: raise RuntimeError("本次没有找到可行解")best = result["Vars"][0]ObjV, CV = problem.evalVars(best.reshape(1, -1))print("x,y:", best)print("目标值:", ObjV[0, 0])print("约束左边(<=0 为可行):", CV[0, 0])assert CV[0, 0] <= 1e-8结果应接近 (2,3),不是无约束最优点 (1,2)。打印 CV 可以检查方向有没有写反。DE 是随机搜索,不能把“应接近”当作每次必然达到某个精度的承诺。
6.3 PSO:用历史经验寻找连续参数
为比较搜索方式,先用与 DE 相同的距离平方目标,但暂时去掉 x+y≥5 约束。这样已知最优点为 (1,2)、目标值为 0。下面只依赖 NumPy:
import numpy as np
SEED = 42N_PARTICLES = 40ITERATIONS = 150rng = np.random.default_rng(SEED)lower = np.array([0.0, 0.0])upper = np.array([5.0, 5.0])
def objective(positions): # 每行是一组 [x,y],每个粒子得到一个需要最小化的目标值。 return (positions[:, 0] - 1) ** 2 + (positions[:, 1] - 2) ** 2
positions = rng.uniform(lower, upper, size=(N_PARTICLES, 2))velocities = rng.uniform(-0.5, 0.5, size=positions.shape)personal_best = positions.copy()personal_scores = objective(personal_best)global_best = personal_best[np.argmin(personal_scores)].copy()
for _ in range(ITERATIONS): r1 = rng.random(positions.shape) r2 = rng.random(positions.shape) velocities = (0.7 * velocities + 1.5 * r1 * (personal_best - positions) + 1.5 * r2 * (global_best - positions)) # 限制搜索步长;这不是对无人机的物理限速。 velocities = np.clip(velocities, -1.0, 1.0) proposed = positions + velocities # 截回边界,同时清零撞到边界的速度分量。 hit_boundary = (proposed < lower) | (proposed > upper) positions = np.clip(proposed, lower, upper) velocities[hit_boundary] = 0.0
scores = objective(positions) improved = scores < personal_scores personal_best[improved] = positions[improved] personal_scores[improved] = scores[improved] global_best = personal_best[np.argmin(personal_scores)].copy()
print("PSO 找到的 x,y:", global_best)print("目标值:", objective(global_best.reshape(1, -1))[0])要使用 PSO 解决有约束问题,需要额外实现罚函数、可行性比较或修复操作,不能把添加一列 CV 当作这个手写 PSO 已经会处理约束。末尾的无人机代理模型可作为下一步练习,但应同时检查原始约束。
6.4 NSGA-II:一个变量,两个互相冲突的目标
令 x 表示设备设置在一条长 4 的直线上的位置,希望它同时靠近左端和右端:
靠左有利于 f1,靠右有利于 f2。x=0 的目标是 (0,16),x=2 为 (4,4),x=4 为 (16,0);三者没有谁同时更好。它可以类比传感器位置的不同服务偏好,但公式本身只是教学模型。
下面脚本用开关展示“多目标无约束”和“多目标有约束”:加约束时要求 1≤x≤3,变量原始边界仍设为 [0,4],通过 CV 表达额外限制。
import geatpy as eaimport numpy as np
class TradeoffProblem(ea.Problem): def __init__(self, constrained=False): self.constrained = constrained super().__init__(name="TradeoffDemo", M=2, maxormins=[1, 1], Dim=1, varTypes=[0], lb=[0], ub=[4], lbin=[1], ubin=[1])
def evalVars(self, Vars): x = Vars[:, [0]] ObjV = np.hstack([x ** 2, (x - 4) ** 2]) # 两个目标占两列。 if self.constrained: CV = np.hstack([1 - x, x - 3]) # 1<=x<=3。 return ObjV, CV return ObjV
problem = TradeoffProblem(constrained=True) # 改为 False 即无额外约束。population = ea.Population(Encoding="RI", NIND=80)algorithm = ea.moea_NSGA2_templet(problem, population, MAXGEN=150, logTras=20)result = ea.optimize(algorithm, seed=42, drawing=0, verbose=False, outputMsg=False, drawLog=False, saveFlag=False)if result["Vars"] is None or len(result["Vars"]) == 0: raise RuntimeError("本次没有得到可行的非支配解")order = np.argsort(result["Vars"][:, 0])print("展示按 x 排序的部分折中解(x, f1, f2):")for index in order[::max(1, len(order) // 8)]: print(result["Vars"][index, 0], *result["ObjV"][index])无额外约束时,整个 [0,4] 都是 Pareto 决策区间;加约束后变成 [1,3]。算法只用有限个点近似它,不会列出无穷多个解。
6.5 MOEA/D:在同一问题上换一种组织方式
为了看出算法差别,沿用上一节的双目标问题。复制上一节完整代码,保留 TradeoffProblem,把其后的运行部分替换为下面代码。这是依赖前一节问题类的片段:
problem = TradeoffProblem(constrained=False)population = ea.Population(Encoding="RI", NIND=80)algorithm = ea.moea_MOEAD_templet(problem, population, MAXGEN=150, logTras=20)result = ea.optimize(algorithm, seed=42, drawing=0, verbose=False, outputMsg=False, drawLog=False, saveFlag=False)if result["Vars"] is None or len(result["Vars"]) == 0: raise RuntimeError("本次没有得到可行的非支配解")order = np.argsort(result["Vars"][:, 0])for index in order[::max(1, len(order) // 8)]: print(result["Vars"][index, 0], *result["ObjV"][index])两种算法都应返回一组折中方案。比较时,看解是否覆盖左右两端、中间是否稀疏、是否满足约束,而不是只挑一个目标最小值比较。
这里 x 只有一个,能手算前沿,因此便于判断实现是否合理。实际项目可换成能耗、任务时间、识别误差等目标,但需要先核对目标单位和尺度。
七、把一次优化跑明白
7.1 算法参数和问题参数分开管理
| 参数 | 属于哪里 | 含义 |
|---|---|---|
M、Dim、上下界 | Problem | 题目结构 |
NIND、Encoding | Population | 方案数量与表示方式 |
MAXGEN | Algorithm | 代数上限 |
MAXEVALS | Algorithm | 目标评价次数的停止阈值 |
MAXTIME | Algorithm | 运行时间阈值 |
logTras | Algorithm | 多少代记一次日志,0 通常表示不记 |
seed | optimize | 随机种子,便于复现 |
drawing、saveFlag | optimize | 是否绘图、是否保存文件 |
停止阈值通常在算法检查点判断,不一定精确截断到某一次评价。不要同时设置很多停止条件却不知道到底是哪一个触发结束。
result["Vars"] 是候选变量,result["ObjV"] 是对应目标。单目标通常读取最好方案,多目标读取近似非支配集合。无可行结果时可能没有可用的 Vars,先检查再索引,避免直接 [0] 报错。Geatpy 优化入口。
7.2 建议按这个顺序检查
- 用手工方案调用评价函数,检查目标方向和 CV 符号。
- 检查矩阵形状:Vars 是 N×Dim,ObjV 是 N×M,CV 是 N×约束数。
- 运行已知答案的小问题,先确认模型写对。
- 换成真实问题,重新检查变量边界和物理单位。
- 多个种子重复运行,再比较目标值、可行率、运行时间和评价次数。
“不报错”只说明程序能执行;“最后输出一个很小的值”也不证明约束满足。尤其是含罚函数的模型,应单独报告原始能耗、时间及每条约束。
7.3 源码建议从哪里读
先读自己的 evalVars,它决定算法到底在优化什么;再读 Population.Phen 如何进入评价;最后读模板的 run()。
SGA 看选择、交叉、变异;DE 看差分个体与一对一选择;NSGA-II 看父子合并与非支配排序;MOEA/D 看权重、邻域与子问题替换。暂时不必深入编译内核,就能看懂主要的数据流。
7.4 本文示例的验证范围
Geatpy 示例按 2.7.0 官方接口编写。当前用于检查文档的 Python 环境没有安装 Geatpy,因此不能把其随机优化结果当作已经实跑成功;问题函数可独立用 NumPy 检查,解析答案也可作为运行后的验收基准。手写 PSO 和下方手写 GA 可以直接用 NumPy 运行。
本次检查结果:12 个 Python 代码块通过语法解析;整数目标、连续约束目标、双目标问题的评价函数通过手工基准和边界检查。固定种子 42 时,手写 PSO 得到 (1,2) 附近的解,目标值约为 4.61×10^-22;手写巡检 GA 得到速度 10.52 m/s、作业距离 8.00 m、能耗 39.90 Wh。这些是教学代码的运行记录,不能作为真实飞行性能结论。
八、补充实践:无人机巡检参数优化
下面保留已有的巡检示例,用它把前面的概念连起来:两个连续变量、一个综合目标、两条额外约束,先手写 GA,再交给 Geatpy。原本的能耗与成像系数都是教学假设。
8.1 六个核心概念
- 个体:一个候选巡检方案,例如
[速度, 作业距离]。 - 染色体:候选解的编码。连续飞行参数用实数编码,巡检顺序常用排列编码。
- 种群:同一代中的全部候选解。
- 目标值:问题真正想最小化或最大化的量。
- 适应度:算法选择个体时使用的优劣尺度;最小化问题需要注意方向转换。
- 遗传算子:选择、交叉和变异。
每一代的流程是:随机生成初始种群,计算目标值,保留优秀个体,从父代交叉产生子代,再用小概率变异增加多样性,直到达到最大代数或长期没有改进。
“精英保留”很重要:把当前最优个体直接复制到下一代,可以防止好解被交叉和变异破坏。但精英比例过高会让种群迅速变得相似,反而更容易早熟收敛。
8.2 巡检案例:优化飞行速度与作业距离
假设 Gurobi 已经给出一条长 6 km 的巡检路线,共有 12 个需要悬停拍摄的点。现在要决定两个连续参数:
- 巡航速度 m/s;
- 无人机与被检设备的作业距离 m。
速度太低会延长任务时间,速度太高会增加推进功耗;离设备太远会降低成像质量,太近又不安全。真实能耗需要飞行试验或高保真仿真,这里使用一个简化代理模型教学:
其中 是预计能耗, 是飞行加悬停总时间。电池安全预算设为 50 Wh,作业距离不得小于 6 m。代码中的系数不代表任何真实机型。
import numpy as np
# 固定随机种子,保证重复运行时得到相同结果,便于比较和排错。SEED = 42POPULATION_SIZE = 80 # 每一代同时保留的候选方案数量GENERATIONS = 150 # 进化轮数MUTATION_RATE = 0.10 # 每个决策变量独立发生变异的概率
# 以下参数只用于说明算法,不是真实机型的飞行或安全参数。MISSION_DISTANCE = 6_000.0 # 总航程,单位 mINSPECTION_POINTS = 12 # 需要悬停拍摄的杆塔数量BATTERY_LIMIT = 50.0 # 任务可用能量,单位 Wh
# 染色体含两个连续基因:[巡航速度(m/s), 与目标的作业距离(m)]。LOWER = np.array([4.0, 5.0])UPPER = np.array([12.0, 15.0])
rng = np.random.default_rng(SEED)
def mission_metrics(population: np.ndarray): """批量计算种群中每个方案的能耗、任务时间和成像惩罚。
population 的形状为 (方案数量, 2),因此下面的计算无需 Python 循环。 这些公式是教学用代理模型;实际项目应由飞行试验或高保真仿真标定。 """ speed = population[:, 0] stand_off = population[:, 1]
# 总时间 = 航程/速度 + 每个巡检点固定悬停 20 秒。 mission_time = MISSION_DISTANCE / speed + INSPECTION_POINTS * 20
# 设 7 m/s、8 m 附近功率较低;偏离越大,附加功率越高。 power = 150 + 2.2 * (speed - 7) ** 2 + 0.8 * (stand_off - 8) ** 2
# W×s 除以 3600 得到 Wh。 energy = power * mission_time / 3_600
# 8 m 被设为理想成像距离,距离偏差以二次函数惩罚。 image_penalty = 1.5 * (stand_off - 8) ** 2 return energy, mission_time, image_penalty
def objective(population: np.ndarray) -> np.ndarray: """返回需要最小化的综合代价,数值越小代表方案越好。""" energy, mission_time, image_penalty = mission_metrics(population) # 0.015 是时间项权重,用于协调 Wh、秒和无量纲图像惩罚的尺度。 score = energy + 0.015 * mission_time + image_penalty
# 罚函数把约束违反量加入目标。平方可让严重违规受到更大惩罚。 # maximum(..., 0) 保证满足约束时罚项恰好为 0。 score += 100 * np.maximum(energy - BATTERY_LIMIT, 0) ** 2 score += 100 * np.maximum(6 - population[:, 1], 0) ** 2 return score
def tournament_select( population: np.ndarray, scores: np.ndarray, tournament_size: int = 3,) -> np.ndarray: """从随机抽取的 tournament_size 个个体中复制得分最低者。""" # 这是最小化问题,所以 np.argmin 对应锦标赛胜者。 candidates = rng.integers(0, len(population), size=tournament_size) winner = candidates[np.argmin(scores[candidates])] return population[winner].copy()
# 在每个变量的上下界之间均匀采样,形成第 0 代种群。population = rng.uniform(LOWER, UPPER, size=(POPULATION_SIZE, 2))
for _ in range(GENERATIONS): scores = objective(population)
# 精英保留:直接复制当前最优个体,避免好解被交叉或变异破坏。 elite = population[np.argmin(scores)].copy() next_population = [elite]
while len(next_population) < POPULATION_SIZE: parent1 = tournament_select(population, scores) parent2 = tournament_select(population, scores)
# 对两个基因分别做算术交叉;weight 越接近 1,子代越像 parent1。 weight = rng.random(2) child = weight * parent1 + (1 - weight) * parent2
# 每个基因独立决定是否变异,并加入均值为 0 的高斯扰动。 # 两个变量量纲不同,所以分别设置 0.4 和 0.6 的扰动尺度。 mutation_mask = rng.random(2) < MUTATION_RATE mutation_scale = np.array([0.4, 0.6]) child[mutation_mask] += rng.normal( loc=0.0, scale=mutation_scale[mutation_mask], ) # 变异可能越界,clip 将方案截回允许的速度和距离范围。 child = np.clip(child, LOWER, UPPER) next_population.append(child)
population = np.asarray(next_population)
scores = objective(population)best = population[np.argmin(scores)]
# reshape 把单个方案从形状 (2,) 转为 mission_metrics 所需的 (1, 2)。energy, mission_time, _ = mission_metrics(best.reshape(1, -1))
print(f"speed: {best[0]:.2f} m/s")print(f"stand-off distance: {best[1]:.2f} m")print(f"energy: {energy[0]:.2f} Wh")print(f"mission time: {mission_time[0]:.1f} s")print(f"score: {scores.min():.2f}")这个例子中,一个染色体就是 [速度, 作业距离]。锦标赛选择保留较好的参数组合,算术交叉在父代之间插值,高斯变异尝试附近的新参数,精英策略保证当前最好方案不会丢失。
惩罚项是教学中常见的做法,但惩罚系数过小会让不可行方案混进结果,过大又可能让搜索困难。更稳妥的框架通常会单独维护约束违反量,Geatpy 的 CV 就是为此设计的。
8.3 Geatpy 的“三对象”结构
Geatpy 提供问题、种群与进化算法模板,下面再次用这三个对象求解同一个巡检问题。 教材把 Geatpy 概括成三个对象:
Problem:变量范围、变量类型、目标函数和约束;Population:编码方式与种群规模;Algorithm:选择算法模板,控制迭代和停止条件。
这种拆分的好处是,问题定义和搜索算法可以分别替换。换目标函数时不用重写遗传循环,换算法时也不必重写问题。
Geatpy 2.7.0 发布较早,PyPI 提供的预编译包最高对应 CPython 3.10。为了复现教材代码,建议单独创建旧环境,不要降低现有项目的 Python 版本:
conda create -n geatpy27 python=3.10conda activate geatpy27python -m pip install "numpy<2" geatpy==2.7.0版本兼容情况应以 Geatpy 的 PyPI 页面 为准。
8.4 用 Geatpy 求解同一个巡检参数问题
import geatpy as eaimport numpy as np
MISSION_DISTANCE = 6_000.0INSPECTION_POINTS = 12BATTERY_LIMIT = 50.0
def mission_metrics(variables: np.ndarray): """计算 Geatpy 传入的一批候选方案的任务指标。""" # variables 每一行依次是 [速度(m/s), 作业距离(m)]。 speed = variables[:, 0] stand_off = variables[:, 1] mission_time = MISSION_DISTANCE / speed + INSPECTION_POINTS * 20 power = 150 + 2.2 * (speed - 7) ** 2 + 0.8 * (stand_off - 8) ** 2 energy = power * mission_time / 3_600 image_penalty = 1.5 * (stand_off - 8) ** 2 return energy, mission_time, image_penalty
class DroneInspectionProblem(ea.Problem): """把无人机参数优化问题封装成 Geatpy 可识别的 Problem。"""
def __init__(self) -> None: # ea.Problem 保存目标数量、变量类型、上下界及端点是否可取。 super().__init__( name="DroneInspection", M=1, # 目标函数数量 maxormins=[1], # 1 表示最小化,-1 表示最大化 Dim=2, # 决策变量数量 varTypes=[0, 0], # 0 为连续变量,1 为离散变量 lb=[4.0, 5.0], # 速度、作业距离的下界 ub=[12.0, 15.0], lbin=[1, 1], # 包含下界 ubin=[1, 1], # 包含上界 )
def evalVars(self, variables: np.ndarray): """同时评价整个种群,并返回目标矩阵 ObjV 与约束矩阵 CV。""" energy, mission_time, image_penalty = mission_metrics(variables) objective = energy + 0.015 * mission_time + image_penalty
# Geatpy 约定 CV 的每一列对应一条约束,CV <= 0 表示满足约束。 # 例如 energy - 50 <= 0 等价于 energy <= 50。 constraint_violation = np.column_stack( [ energy - BATTERY_LIMIT, # 能耗不得超过 50 Wh 6 - variables[:, 1], # 作业距离不得小于 6 m ] ) return objective.reshape(-1, 1), constraint_violation
problem = DroneInspectionProblem()
# RI 表示实数/整数混合编码;本例两个变量的 varTypes 都为 0,因此均按实数处理。population = ea.Population(Encoding="RI", NIND=80)
# SEGA 是增强精英保留遗传算法模板,适合单目标优化入门。algorithm = ea.soea_SEGA_templet(problem, population)algorithm.MAXGEN = 150 # 最大进化代数algorithm.logTras = 10 # 每 10 代记录一次日志algorithm.trappedValue = 1e-8 # 小于该改进量视为目标没有明显改善algorithm.maxTrappedCount = 20 # 连续 20 次无明显改善时允许提前停止
# seed 用于复现实验;关闭绘图和落盘,方便在脚本或终端中直接运行。result = ea.optimize( algorithm, seed=42, verbose=True, drawing=0, outputMsg=True, drawLog=False, saveFlag=False,)
# 先判断是否找到可行结果,再读取变量,避免空结果直接索引。if result["Vars"] is None or len(result["Vars"]) == 0: raise RuntimeError("本次没有找到可行巡检方案,请检查约束和搜索预算")best = result["Vars"][0]energy, mission_time, _ = mission_metrics(best.reshape(1, -1))print(f"speed: {best[0]:.2f} m/s")print(f"stand-off distance: {best[1]:.2f} m")print(f"energy: {energy[0]:.2f} Wh")print(f"mission time: {mission_time[0]:.1f} s")print(f"objective: {result['ObjV'][0, 0]:.2f}")Encoding="RI" 表示实数与整数编码,适合速度、距离等连续参数。若把染色体改成杆塔访问顺序,可以使用 P 排列编码;若希望同时保留“低能耗”和“高清晰度”两类方案,而不是先人为合成一个分数,可以使用 NSGA-II 得到 Pareto 解集。
8.5 约束怎么写
Geatpy 通常使用约束违反矩阵 CV。约定上,满足 时没有违反约束;大于 0 的部分表示违反程度。上面的巡检案例有两列约束违反量:
constraint_violation = np.column_stack( [ # Geatpy 以“值不大于 0”为可行,因此先统一移到不等式左边。 energy - 50.0, # energy <= 50 → energy - 50 <= 0 6.0 - stand_off, # stand_off >= 6 → 6 - stand_off <= 0 ])一定要检查不等号方向。energy - 50 <= 0 表示不超电量;6 - stand_off <= 0 表示距离至少为 6 m。方向写反,会让算法惩罚原本安全的方案。
8.6 GA、PSO 和多目标算法怎么选
- 连续变量、参数较少时,先试 SciPy 的局部或全局优化方法;
- 连续黑箱问题可以比较差分进化(DE)与粒子群(PSO);
- 含离散编码、排列或自定义遗传操作时,GA 更自然;
- 同时优化成本、时间和可靠性时,可用 NSGA-II 得到一组 Pareto 解;
- 能写成线性或整数规划时,应先和 Gurobi 结果比较,不要默认元启发式更好。
粒子群算法不做交叉和变异。每个粒子根据当前速度、自己的历史最好位置 pbest 和全体最好位置 gbest 更新下一步位置。它参数较少、连续优化中上手快,但同样可能早熟收敛。
若新环境无法安装 Geatpy,也可以了解 pymoo 等框架;实际接口和依赖需按该框架文档重新核对,不能直接复制 Geatpy 的类名。
8.7 怎样做可信的进化算法实验
一次运行的最好值没有太大说服力。至少应记录:
- 随机种子;
- 种群规模与迭代代数;
- 交叉率、变异率和具体算子;
- 总目标函数评估次数;
- 停止条件;
- 多个随机种子下的均值、标准差和最好值;
- 与随机搜索、简单局部搜索或精确方法的对照。
比较不同算法时,最好限制相同的目标函数评估次数,而不只是相同的迭代次数。不同算法每代评估的个体数可能不同,只比代数并不公平。
8.8 本章练习
- 删除手写 GA 中的精英保留,比较巡检目标值是否会反弹。
- 把变异率改成 0、0.01、0.5,观察收敛速度与参数种群多样性。
- 把电量预算从 50 Wh 改为 40 Wh,检查返回方案是否仍然可行。
- 将“速度、作业距离”扩展为“速度、距离、单点悬停时间”,并修改能耗模型。
- 用相同仿真评估次数比较随机搜索、GA 和 PSO,而不是只比较最好的一次。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
