资讯动态

7种启发式算法统一封装为Python库:设计与避坑指南

发布时间:2026/10/3 9:14:13 来源:尧图企业网站定制
简介面向算法优化与学习用途的一份 Python 多启发式算法封装库覆盖差分进化、遗传、粒子群、模拟退火、蚁群、免疫与人工鱼群等常用算法并可直接用于旅行商等组合优化问题。资源共 83 个文件约 96KB以 45 个 py 源码文件实现算法主体与实用工具25 个 md 文档说明原理和使用方式其余为环境配置、测试脚本、示例数据与授权信息等目录结构清晰适合初中级开发者阅读和二次开发已有 207 人学习。打包内容不仅包含算法模块还提供覆盖路径规划与典型函数优化的大量可运行示例以及数据文件、配置模板和运行辅助脚本能帮助读者在几分钟内跑通遗传、粒子群、模拟退火、蚁群、鱼群等算法的调用流程同时理解各算法参数设置与迁移到自身项目的改造方法。1. 7种启发式算法封装成Python代码库为什么这件事值得做把差分进化算法、遗传算法、粒子群算法、模拟退火算法、蚁群算法、鱼群算法收进同一个Python代码库我第一次听到这个需求时第一反应是“网上现成实现一大把封装它们有什么意义”。直到我自己做算法横向对比时连续踩坑每个算法来自不同的项目接口风格完全不同有的内部求最小值有的求最大值最后对比结果时才发现测试脚本改了五遍。这个库真正解决的不是“发明算法”而是让6种算法用同一套输入输出说话需要换算法时只改一行名字。市面上这类封装通常会再补一个人工蜂群算法进来凑齐7种反正多加一个算法只是多一个注册项的事。适合正在做论文实验、课程设计和生产环境算法选型的人。2. 统一接口设计让6种算法用同一套输入输出说话先承认一个事实DE、GA、PSO、SA、ACO、鱼群算法背后的数学模型差别很大但骨架是同一个——维护一组候选解在边界范围里反复评估然后挑出分数最好的那个。要让这些算法老老实实跑同一个问题第一步不是写算法而是把“问题”和“解”抽象成统一的数据结构。这步不做后面越改越乱。2.1 最小公共接口先定义问题的维度、边界和目标函数我一般先把问题描述成三个东西维度、边界、目标函数。维度告诉每个算法解向量有多长边界告诉算法每个维度的合法范围目标函数接收一个浮点数组返回一个浮点分数。用dataclass定义from dataclasses import dataclass from typing import Callable, List import numpy as np dataclass class Problem: dim: int lower: List[float] upper: List[float] fitness_fn: Callable[[List[float]], float] def __post_init__(self): self.lower np.asarray(self.lower, dtypenp.float64) self.upper np.asarray(self.upper, dtypenp.float64) if self.lower.shape[0] 1: self.lower np.full(self.dim, self.lower[0]) if self.upper.shape[0] 1: self.upper np.full(self.dim, self.upper[0])这段代码的逻辑是lower和upper允许传单个数值或数组内部统一转成float64数组如果只传了标量就自动广播到每个维度。这不算什么高深技巧但能省掉调用方一大半的重复代码。参数说明dim是优化问题的维度比如找两个变量的最小值dim2lower和upper是每个维度的边界fitness_fn必须接收一个一维数组。这里我刻意做了一个约定所有算法内部一律按“分数越小越好”处理。如果你的目标是最大化不要在算法内部改符号而是在传入fitness_fn之前取负号。这样DE的比较逻辑、SA的接受逻辑、ACO的信息素增量都只用写一种。这个约定会在第5章展开讲但它直接影响接口设计所以从第一步就要定死。还要做一个统一的“解”结构。算法在迭代中产生的候选解用position和score两个字段表示from dataclasses import dataclass import numpy as np dataclass class Solution: position: np.ndarray score: floatposition是当前算法在解空间里的坐标score是对应目标函数值。所有算法内部只要维护这个二元组后面的可视化、日志、早停判断都能复用。2.2 统一结果对象带回最好解和收敛历史只返回一个最好解是不够的。做实验需要画收敛曲线看算法在第几代稳定下来进了局部最优的表现。所以我加了OptimizeResult把每次迭代的历史分数都存住dataclass class OptimizeResult: best_position: np.ndarray best_score: float history: List[float] iterations_used: int algorithm_name: strhistory列表里第i项是第i轮结束时的历史最佳分数。注意它不是当前代种群均值而是始终只记最优避免画图时被均值波动搞糊涂。iterations_used用于判断提前终止如果算法在第80轮就触发早停但max_iter是200这个字段就能告诉你实际用了多少轮。写可视化代码时把history直接扔给matplotlib画折线图6种算法就能放在同一张图上对比。2.3 注册机制用装饰器把算法名挂到分发字典前面的数据结构解决的是输入和输出接下来解决调度问题。我希望调用方写get_solver(pso)就能拿到粒子群算法对象写get_solver(aco)就能拿到蚁群算法对象。常见做法是维护一个全局注册表用装饰器注册ALGORITHMS {} def register(name: str): def decorator(cls): ALGORITHMS[name] cls return cls return decorator register(de) class DifferentialEvolutionSolver: def solve(self, problem: Problem) - OptimizeResult: ... register(pso) class PSOSolver: def solve(self, problem: Problem) - OptimizeResult: ... def get_solver(name: str): if name not in ALGORITHMS: raise ValueError(funknown algorithm: {name}) return ALGORITHMS[name]()这段代码的关键约束是每个算法类只需要实现solve(problem)这一个公共方法加上register后业务代码完全不用改。新增算法的时候在类定义上一行加装饰器就行。这层抽象让命令行入口变得非常简单import argparse parser argparse.ArgumentParser() parser.add_argument(--algorithm, requiredTrue) args parser.parse_args() problem Problem(dim2, lower[-5, -5], upper[5, 5], fitness_fnlambda x: x[0]**2 x[1]**2) solver get_solver(args.algorithm) result solver.solve(problem) print(result.best_score, result.best_position)2.4 算法参数单独传种群和迭代次数从问题里拆出去回到一个问题种群大小、迭代次数、变异率这些参数到底放哪我的选择是单独定义算法参数dataclass不放Problem里。原因是参数扫描时你只想换算法自身的参数不想反复重建Problem对象。from dataclasses import dataclass dataclass class PSOParams: w: float 0.7 c1: float 1.5 c2: float 1.5 n_particles: int 30 max_iter: int 200 early_stop: int 30PSOSolver构造函数接收这个参数对象solve方法内部只读取参数值。这样跑参数扫描时外层循环只需要改PSOParams的字段问题定义保持不变。6种算法各有各的参数dataclass但所有参数都会有默认值保证一个新手拿到代码后可以直接调用不需要先精通参数调优。到这里整个库的骨架已经立住了Problem是输入OptimizeResult是输出ALGORITHMS是路由器Params是每种算法的可调旋钮。剩下的工作是把6种算法一个个填进这个骨架里。3. 逐个拆算法核心DE/GA的变异、PSO/鱼群的速度、SA/ACO的概率接口统一之后重头戏是每个算法算子的实现。这6种算法的搜索逻辑可以分三组DE和GA是种群变异派PSO和鱼群算法是速度-位置派SA是单点搜索、ACO是路径积累。分组之后你会发现真正需要手写的核心代码并不多但每个算子都有几个参数会影响结果写错一个就全盘翻车。3.1 DE和GA差分扰动与基因重组是两回事DE的核心算子叫差分变异。它从当前种群里随机挑三个互不相同的个体用其中两个位置的差去扰动第三个def _mutate_de(population, idx, F, rng): n len(population) candidates [i for i in range(n) if i ! idx] r1, r2, r3 rng.choice(candidates, 3, replaceFalse) return population[r1] F * (population[r2] - population[r3])逻辑说明r1、r2、r3是三个不同于当前个体idx的随机索引F是缩放因子。这个算子的巧妙之处在于它不依赖任何梯度信息差分向量天然编码了种群的分布信息——种群分布越散扰动步长越大前期探索强后期种群聚拢扰动变小搜索自然变细。参数F一般取0.4到0.9小于0.4容易早熟大于0.9容易震荡不收敛。GA的交叉变异则完全是另一套思路。常见做法是对每个维度以交叉概率cr交换两个父本的基因然后再以变异概率pm把某个基因随机重置def _crossover_ga(parent1, parent2, cr, rng): mask rng.rand(len(parent1)) cr child np.where(mask, parent1, parent2) return child def _mutate_ga(child, low, high, pm, rng): for i in range(len(child)): if rng.rand() pm: child[i] low[i] rng.rand() * (high[i] - low[i]) return childGA的cr是交叉率通常0.6到0.9pm是变异率通常0.01到0.1。DE靠差分向量找方向GA靠基因组合找方向。二者在中文博客里经常被混为一谈但实现差异非常大。在统一接口里它们唯一的共同点是都维护一个种群每轮都要评估整个种群的目标函数值。3.2 PSO和鱼群算法邻居交互的两种实现粒子群算法每个粒子记录自己的历史最优pbest和全局最优gbest。速度更新公式是def _update_pso(vel, pos, pbest, gbest, w, c1, c2, rng): r1, r2 rng.rand(len(pos)), rng.rand(len(pos)) vel w * vel c1 * r1 * (pbest - pos) c2 * r2 * (gbest - pos) return velw控制粒子保留上一时刻速度的惯性c1是飞向自身历史最优的权重c2是飞向群体最优的权重。常见取w0.7c1c21.5。这三个参数是最容易靠调参经验乱试的w太大粒子在解空间里横冲直撞w太小粒子早早聚成一团陷入局部最优。鱼群算法和PSO的相同点是都在解空间里移动区别是鱼群算法更强调视野visual。每条鱼只感知视野范围内的其他鱼def _move_afsa(fish, target, visual, step): direction target - fish dist np.linalg.norm(direction) if dist 1e-12: return fish return fish step * direction / dist每次移动不超过步长steptarget可能来自聚群中心也可能来自附近分数更好的鱼。鱼群算法的参数比PSO更多visual和step一般取搜索区间跨度的10%到20%调参稍多一点但好处是跳出局部最优的动作更自然鱼群会散开重聚。3.3 SA和ACO单点随机游走和多智能体路径积累模拟退火算法的核心不是怎么产生新解而是怎么决定要不要接受一个更差的解。每轮在当前解附近扰动产生一个新解计算分数差delta然后用Metropolis准则def _accept_sa(delta, temp, rng): if delta 0: return True return rng.random() np.exp(-delta / temp)delta小于0表示新解更优无条件接受delta大于0时以e的负delta除以temp次方的概率接受。temp是当前温度。温度高时接受差解的概率大允许算法在山谷之间跳跃温度接近0时算法退化成贪心局部搜索。实际实现里hammersley扰动、随机游走扰动都比纯高斯扰动更稳这个可以放到第4章温度衰减里一起说。蚁群算法完全不同它不直接维护解向量而是维护一张信息素图。每只蚂蚁走完一条路径后按路径质量在走过的路径上留下信息素增量然后整张图按比例挥发def _update_pheromone(tau, delta_tau, rho): return (1 - rho) * tau delta_taurho是挥发系数常用0.3到0.6。rho太小信息素积累过慢收敛慢rho太大之前的路径痕迹很快消失算法容易从已经找到的好路径上丢开。写代码的时候要特别注意delta_tau只在本次迭代走过的路径上加不能把历史delta累加进去。ACO解决连续优化问题时一般需要先把连续空间离散成网格这个转换本身就会带来误差后面避坑章会专门讲。4. 参数最难的四个点种群规模、缩放因子、温度衰减与信息素挥发这6种算法放到同一套接口后参数调优成了绕不开的环节。没有一套万能参数能覆盖所有问题但有一批基线参数外加几条调参顺序的规律。按经验先定种群大小和迭代次数再定算法特有参数最后固定随机种子做对比这个顺序能少跑很多冤枉路。4.1 种群大小与迭代次数的互相制约先看全局参数。在我的代码库里Problem只描述问题本身n_agents和max_iter放在算法参数对象里。不同算法的n_agents含义略不同DE和GA是种群大小PSO是粒子数ACO是蚂蚁数鱼群算法是鱼的数量。各算法参数范围先列成一张表方便做基线算法关键参数常见范围说明DEF / CRF0.4~0.9CR0.6~0.9F是差分缩放因子CR是交叉率GAcr / pmcr0.6~0.9pm0.01~0.1cr是交叉率pm是变异率PSOw / c1 / c2w0.6~0.9c1c21.5w是惯性权重c1/c2是加速度系数SAalpha / temp0alpha0.85~0.95temp0100~1000alpha是降温系数ACOrho / alpha / betarho0.3~0.6alpha1beta2rho是信息素挥发alpha/beta是权重AFSAvisual / step区间跨度的10%~20%visual是视野step是步长种群大小和迭代次数互相制约而且这个制约关系经常被低估。max_iter固定时把种群扩大一倍等于把总评估次数扩大一倍计算成本直接翻倍。如果fitness_fn是跑仿真或者算复杂模型评估一次要几百毫秒无脑加大种群会跑得让人怀疑人生。我的习惯是先定max_iter再用max_iter的1/5到1/10作为种群规模跑通后再按结果微调。4.2 缩放因子和变异率前期探索、后期收敛DE的F是差分向量缩放因子。固定F0.8时简单问题没问题但遇到多峰函数后期种群聚拢后扰动幅度太小很难跳出局部最优。常见做法是让F随迭代递减def _scale_factor(iteration, max_iter, f_min0.4, f_max0.9): return f_max - (f_max - f_min) * (iteration / max_iter)前期的F接近0.9差分扰动大种群能覆盖更大空间后期的F接近0.4扰动变小利于在最优解附近精细搜索。GA的变异率我也用类似逻辑前期0.1后期0.01保持搜索侧重从探索转向开发。这里要注意这个递减式参数调度并非对每个算法都有效。SA的温度衰减本身就是调度再配一个递减的扰动步长反而会让算法太快变贪心。参数调度要用在正确的地方不要一窝蜂全加上。4.3 SA温度衰减降太快就变成局部搜索SA的降温速度直接决定它的随机性强不强。我见过最典型的翻车是alpha取0.99迭代500轮温度几乎没降也见过alpha取0.5前20轮就变成纯粹的贪心搜索之后一直卡在局部。SA更像是取舍艺术没有绝对正确的alpha。一个实用的经验是让温度递减到100轮左右降到初始温度的1%def _temperature(initial_temp, iteration, alpha): return initial_temp * (alpha ** iteration)取alpha0.9050轮后温度约为初温的0.5%效果符合预期。initial_temp也不能拍脑袋先对目标函数做几十次随机采样取分数差的范围作为initial_temp的量级。如果initial_temp太小差解接受概率过低SA直接退化成爬山太大前百轮都在乱跳白费算力。4.4 ACO的信息素挥发和鱼群的视野步长ACO的关键参数是信息素挥发系数rho、信息素权重alpha、启发式权重beta。rho取0.3到0.6之间算是比较稳的经验区间。rho偏大时挥发快路径差距被快速放大容易朝初始蚂蚁偶然发现的好路径集中rho偏小时挥发慢各条路径的信息素会维持很久收敛慢但不容易被初期假象带偏。beta是两点距离或代价的权重组合优化问题里一般beta大于alpha因为路径代价对好坏的影响比信息素更直接。鱼群算法最特别的地方在于visual和step需要配合问题边界。如果visual设成区间跨度的5%鱼群感知范围太小聚群行为几乎失效visual设成80%每条鱼能看到所有鱼退化成PSO。step设太大鱼会在最优解附近来回震荡step设太小收敛慢得离谱。我一般取visual为区间跨度的10%到20%step为visual的一半基本不会出大错。最后提一条调参顺序先把所有算法的公共参数也就是n_agents和max_iter用同一组随机种子统一跑一遍记录基线结果固定之后再单独动F、w、rho这类专属参数。调专属参数时一次只改一个记录历史分数变化。这个习惯让我少走很多弯路也能更早暴露哪个算法的实现有bug。5. 避坑指南启发式算法结果差一个数量级的5个原因代码库能跑通只是第一步真正让结果差一个数量级的问题往往不在算法本身而在调用层。这一章是我在实际封装和测试中踩过的坑每一条都有具体现象、原因和解决办法。5.1 越界处理直接裁剪会让种群失去多样性现象同一个问题用GA跑最优解总是贴着边界比如真实最优在某个中间位置GA却报出边界值。原因是无脑越界裁剪。种群里有大量个体跑出边界后直接np.clip把它们拉回边界上边界位置就堆了一大堆重复个体种群多样性迅速归零搜索能力退化。解决对越界维度做随机重置而不是简单裁剪。def _repair_bounds(position, lower, upper, rng): pos np.copy(position) for i in range(len(pos)): if pos[i] lower[i]: pos[i] lower[i] rng.rand() * (upper[i] - lower[i]) elif pos[i] upper[i]: pos[i] upper[i] - rng.rand() * (upper[i] - lower[i]) return pos这个修复函数让越界解回到边界内部但不重复堆在边界线上保留了多样性。视觉效果不强但对GA和DE的影响非常明显尤其是高维问题上越界个体比例常常超过30%。5.2 随机种子不固定横向对比等于白做现象用同一份代码跑DE和GADE每次结果都有明显波动和GA的对比结论变来变去。原因所有算法都共用numpy的全局随机状态不同算法申请随机数的次数不同全局状态互相污染。更隐蔽的是先跑DE再跑GA和先跑GA再跑DE结果不一样。解决每个算法实例持有独立的随机数发生器solve开始时固定种子。import numpy as np class PSOSolver: def __init__(self, params, seed42): self.params params self.rng np.random.RandomState(seed) def solve(self, problem): rng self.rng # 所有涉及随机的操作全部用 rng不要用 np.random ...这样做以后同一个算法的多次运行完全可复现不同算法也可以用同一个seed初始化保证比较的初始条件一致。这是我的血泪经验没有固定种子的对比实验基本不具备参考价值。5.3 目标函数方向不统一排名结果全错现象调参时发现GA在同一个问题上“表现特别好”PSO“完全不收敛”仔细看才发现GA内部按越大越好、PSO按越小越好。原因没有在接口层统一约定目标函数方向每个算法在写比较逻辑时各有各的假设。解决在Problem入口强制统一最小化。最大化的目标函数在传入时取负号problem Problem( dim2, lower[-5, -5], upper[5, 5], fitness_fnlambda x: -original_max_fitness(x), )这样所有算法内部只需要写小于号不需要为某个算法做特判。这个约定要写进代码库的README否则后加入的人很容易踩进去。5.4 缺少停滞判断迭代没结束就翻车现象max_iter设了500算法跑到80轮就已经卡在原地接下来420轮结果一模一样画出来的收敛曲线后半段是一条水平线纯属浪费算力。原因只用一个固定迭代次数当终止条件没有检查“连续多轮没有改进”这个状态。解决增加早停判断。记录history里最近几十轮的分数差小于阈值就提前结束。def _should_early_stop(history, stall_limit, tol1e-6): if len(history) stall_limit: return False return abs(history[-1] - history[-stall_limit]) tolstall_limit取30到50轮tol取1e-6到1e-8。这能省掉大量无意义的评估次数尤其当目标函数评估很贵时早停的价值甚至大于算法本身的优化能力。5.5 用ACO解连续变量网格分辨率成了隐形陷阱现象把ACO用于连续优化时结果比DE差一截信息素权重怎么调都救不回来。原因ACO天生解决的是路径或排列类离散问题信息素需要定义在离散的元素或边上用来解连续变量需要做离散网格而网格分辨率就是先天的限制。网格切得越细搜索空间越大蚂蚁数量不够时每轮探索的信息素稀疏得可怜。解决连续优化优先选DE、PSO、鱼群这类直接在连续空间移动的算法组合优化选ACO。同一个代码库里保留全部算法的意义在于按问题选型不要指望一个算法通吃所有场景。6. 用Sphere和Rastrigin验证新算法接入代码库前的标准动作代码库新增一个算法后我习惯先在两个标准测试函数上做过检Sphere检验它能不能收敛到全局最优Rastrigin检验它在多峰地形下会不会被困住。这两个函数一个简单一个难组合起来能把大部分实现问题暴露出来。Sphere函数是最简单的连续凸函数最优解是全零向量。Rastrigin在Sphere基础上叠加了余弦峰局部最优极多是最常见的多峰测试函数import numpy as np def sphere(x): x np.asarray(x) return np.sum(x ** 2) def rastrigin(x): x np.asarray(x) return 10 * len(x) np.sum(x ** 2 - 10 * np.cos(2 * np.pi * x))接入自测时的判定标准我定为两条dim5、边界[-5,5]时DE和PSO在500轮内能把Sphere降到1e-4以下Rastrigin至少要跑到50以下说明算法没有完全困在最近的局部最优。测试脚本不需要任何第三方依赖只有numpy如果环境里还没装numpy先装好numpy再往下跑。我自己的惯例是每次新增算法或改完算子先把这两个函数各跑十次每次都固定同一套seed记录最好成绩和收敛曲线。没通过就不合并到主分支。坚持这个习惯以后几乎再没有出现过“换了个问题性能骤降”的意外。测试代码也保持最小化for name in [de, pso, sa]: solver get_solver(name) result solver.solve(problem) print(name, result.best_score, result.iterations_used)把问题边界和数据记录统一后算法对比就只是打印一行结果的事。这也正是封装这7种算法的最终目的不止能跑更能比比完还能快速换方向。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑