简介:这份PDF是Eyal Wirsansky所著《Hands-On Genetic Algorithms with Python》第二版(2024),面向希望用遗传算法解决AI与机器学习实际问题的Python开发者、数据科学家及算法学习者。全书围绕遗传算法的原理展开,逐一讲解编码、选择、交叉、变异等核心操作,并结合多个实战案例,演示如何用Python从零实现、调优遗传算法,以及如何将其与神经网络、强化学习等技术相结合,覆盖优化、分类、路径规划等典型场景,适合从入门到进阶的系统学习。压缩包共1个文件,类型为PDF,大小约5.99MB,内容完整,便于按章节阅读或作为常备技术参考。目前已有54人学习。通过阅读这份PDF,读者可以掌握遗传算法的主要流程与代码实现,理解复杂搜索空间问题的求解策略,并获得可迁移到自身项目中的启发式算法设计思路。
1. 遗传算法实战:从OneMax到云端部署,一本书打通进化的完整链路
调参调了三天,网格搜索把时间吃光了,模型精度纹丝不动;换成遗传算法跑半小时,反而翻出一组更好的超参数——这种场景,在《Hands-On Genetic Algorithms with Python》第二版里被反复验证。它不讲进化论科普,而是把遗传算法当解决AI和机器学习问题的工具箱:从OneMax教学案例起步,延伸到组合优化、特征选择、超参数调优、神经网络架构搜索、强化学习,再到云端并发部署。
适合两类人:刚接触进化计算的Python开发者,想找一条能照着敲的代码路径;被搜索空间折磨的机器学习工程师。这版2024年发布,强化学习、NLP、可解释AI和云部署内容都比第一版更实。
2. 遗传算法核心组件与DEAP:跑通第一个进化循环
2.1 编码、适应度与算子:遗传算法为什么和网格搜索不同
书的第一部分把遗传算法和传统优化算法的差异讲得很清楚:传统算法从单个解开始迭代,遗传算法从一组解(种群)出发;传统算法依赖梯度或解析形式,遗传算法只需要一个能打分的目标函数;传统算法每次迭代是确定的,遗传算法用带概率的选择、交叉、变异产生下一代。这意味着你不需要目标函数连续、可导,甚至不需要有明确的数学表达式——只要能把候选解映射成一个分数,就能用GA去搜。
书中特别强调的核心组件有四个:种群(population)、适应度函数(fitness function)、选择算子(selection)、交叉与变异算子(crossover 和 mutation)。前两个解决“怎么评价一个解”,后两个解决“怎么从已有解产生新解”。编码方式决定了搜索空间的形状:二进制编码适合特征选择、背包这类开关型问题,排列编码适合TSP这种顺序敏感问题,实数编码适合连续函数优化。书里提到的一个结论我印象很深:更重要的是适应度函数的区分度,如果所有个体分数都挤在一起,选择压力不够,进化就变成随机游走。
2.2 用DEAP创建个体、种群与遗传算子
书里正式动手的部分用的是DEAP框架。DEAP是Python生态里最常用的进化计算框架,它不内置求解器,只提供零件:Fitness类、Individual、Toolbox,以及一批现成的选择和交叉变异算子。下面这个OneMax问题是书里的第一个完整示例,目标是让一个二进制串中1的个数尽可能多。
import random from deap import base, creator, tools # 定义适应度类:单目标、最大化 creator.create("FitnessMax", base.Fitness, weights=(1.0,)) # 定义个体类型:继承list,挂上适应度属性 creator.create("Individual", list, fitness=creator.FitnessMax) toolbox = base.Toolbox() # 每个基因位等概率生成0或1 toolbox.register("attr_bool", random.randint, 0, 1) # 用initRepeat把100个基因位拼成一个个体 toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_bool, n=100) # 种群就是多个个体组成的list toolbox.register("population", tools.initRepeat, list, toolbox.individual) def eval_one_max(individual): # 个体是list,直接求和就是1的个数 return (sum(individual),) toolbox.register("evaluate", eval_one_max) # 两点交叉 toolbox.register("mate", tools.cxTwoPoint) # 按位翻转,每一位翻转概率5% toolbox.register("mutate", tools.mutFlipBit, indpb=0.05) # 锦标赛选择,每次随机挑3个取最优 toolbox.register("select", tools.selTournament, tournsize=3)creator.create是DEAP里比较有特色的一步:它不是造实例,而是在动态创建新类。FitnessMax继承base.Fitness,weights=(1.0,)表示单目标最大化;如果改成(-1.0,)就是最小化,多目标则写成(1.0, -1.0)这种元组。Individual继承list,这句话之后,每个个体就是自带fitness属性的Python列表,方便后续访问基因位和适应度。
toolbox是一个注册表,register的作用是把函数名和参数绑在一起。比如attr_bool绑定了random.randint(0, 1),后面initRepeat创建个体时就会重复执行n次attr_bool。mate和mutate直接复用DEAP内置算子:cxTwoPoint是两点交叉,mutFlipBit按概率翻转二进制位,indpb=0.05代表每个基因位有5%的概率被翻转,这个值通常不调太大。select用锦标赛选择,tournsize=3表示每次随机抽3个个体、把适应度最高的那个选进下一代。
注意:creator.create在解释器里重复执行会报“already exists”错误,调试时建议把创建个体的代码整体放在一个函数里,或者直接重启内核。
2.3 内置算法、统计对象与精英保留
DEAP不强制手写进化主循环,它提供了现成的算法。书里用eaSimple跑通了整个OneMax流程:
from deap import base, tools, algorithms # 创建初始种群 pop = toolbox.population(n=50) # 注册统计指标:记录每代最大/平均适应度 stats = tools.Statistics(key=lambda ind: ind.fitness.values[0]) stats.register("max", max) stats.register("avg", lambda x: sum(x) / len(x)) # HOF保存历史最优个体 hof = tools.HallOfFame(1) pop, logbook = algorithms.eaSimple( pop, toolbox, cxpb=0.8, mutpb=0.2, ngen=50, stats=stats, halloffame=hof, verbose=True )eaSimple是DEAP自带的最基础迭代器:每一代先按适应度做选择,再以cxpb概率对选中的个体做交叉,以mutpb概率做变异,最后用新一代替换旧种群。cxpb=0.8表示80%的个体参与交叉,mutpb=0.2表示20%的个体发生变异,这两个概率独立作用、不需要加起来等于1。ngen=50是迭代50代,也就是一共评估50×50=2500个个体,OneMax这种简单问题一般几十代内就能收敛。
stats.register把统计函数绑定到指标名上,输出的logbook是一个类似表格的容器,能看到每一代的最大、平均适应度是不是在稳步上升。HallOfFame则是很多人一开始会忽略的组件:它把进化历史上出现过的精英个体单独保存下来,避免最优解在交叉变异中丢失。verbose=True会在终端打印进度,第一次跑建议开着,能直观看到收敛过程。
3. 组合优化与约束求解:背包、TSP、N皇后三类典型问题的实现套路
3.1 背包问题:二进制编码配合惩罚函数
书里的第一个组合优化案例是0-1背包。给定一组物品的重量和价值,以及一个容量上限,目标是选出总重量不超过容量、总价值尽可能高的子集。用遗传算法解决这个问题非常自然:每个个体是一个0/1列表,1代表选中该物品,适应度函数直接算总价值,超重则给一个很低的分数。
def eval_knapsack(individual): total_weight = sum(w * bit for w, bit in zip(weights, individual)) total_value = sum(v * bit for v, bit in zip(values, individual)) if total_weight > capacity: # 超出容量直接判0分,让选择压力淘汰它 return (0.0,) return (float(total_value),)这里没有做复杂的修复逻辑,而是用惩罚函数。常见做法是:不满足约束的个体适应度置为一个很低的值,让它自然被选择算子淘汰。优点是简单,缺点是惩罚太弱会有很多超重个体浑水摸鱼,太强则可能丢失边界附近的优良基因。书里的例子演示下来,我的体会是:对于背包这类问题,惩罚通常比修复省事,因为修复一个非法个体的成本往往比进化本身还要高。
调用时只需要复用第2章的toolbox,把evaluate替换成eval_knapsack,再把基因位长度改成物品数量。配合stats和HOF看收敛曲线,你会注意到背包问题比OneMax慢不少,因为它的适应度景观有大量平台区,很多个体分数一样,选择压力不足。书里在背包案例最后还专门演示了增强探索和精英保留的改进,基本思路就是加大种群规模、提高交叉比例,同时用HOF锁住到目前为止最好的解。
3.2 TSP和VRP:排列编码与有序交叉
TSP和VRP是另一类组合优化问题。关键区别在于解不再是0/1向量,而是一个城市的排列,所以二进制交叉算子不能用——把两个父排列做单点交叉会产生重复城市。书里为这类问题准备的是有序交叉和部分匹配交叉这类针对排列设计的算子,DEAP的tools里也直接内置了cxOrdered和cxPartialyMatched。
def eval_tsp(route): # route是城市索引的排列,例如[0, 3, 1, 2] total = 0.0 for i in range(len(route) - 1): total += distance_matrix[route[i]][route[i + 1]] # 回到起点,构成闭合回路 total += distance_matrix[route[-1]][route[0]] # DEAP默认最大化,这里取倒数 return (1.0 / total,)适应度函数不复杂:把路径上所有相邻距离累加,最后封口回到起点。为了让eaSimple能直接跑,把距离和换算成“分数”时取倒数,总距离越小分数越大。书里还提到了TSPLIB基准文件,标准测试实例可以直接拿来验证算法质量,而不是自己拍脑袋生成随机城市。
VRP在TSP基础上增加了容量约束和车辆数,书里的做法是把路径切分成多个子路径,每个子路径对应一辆车,适应度里加上总行驶距离和超容量惩罚。这种“切分路径”的思路是VRP编码里最常见的做法,比直接用变长染色体更稳定,交叉变异算子也不需要额外定制。
3.3 约束满足:N皇后、图着色与护士排班
第三类问题书里归到约束满足。N皇后要求在一个N×N棋盘上放置N个皇后,任意两个不能互相攻击。比较自然的编码是把每个皇后放在不同行,用排列表示列位置,这样“同一行/列不重复”这个硬约束天然满足,剩下的就是斜线冲突,适应度函数统计冲突对数,冲突越少分数越高。
def eval_nqueens(individual): # individual是长度为N的排列,index=行,value=列 diagonal_conflicts = 0 n = len(individual) for i in range(n): for j in range(i + 1, n): if abs(individual[i] - individual[j]) == abs(i - j): diagonal_conflicts += 1 return (1.0 / (1.0 + diagonal_conflicts),)这段是典型的“编码消解约束”思路:把最容易破坏解合法性的约束直接编码掉,剩下的用适应度函数去惩罚。书里进一步把约束分成硬约束和软约束:硬约束不满足就是非法解,软约束不满足只是分数低。护士排班问题就是典型的混合场景——每天每个班次必须有人(硬约束),但每个人最好不超过一定工时、尽量保证周末休息(软约束),遗传算法在这里的用法是把两类约束都折算进适应度,软约束设权重,硬约束设大惩罚。
图着色问题的处理方式类似,书里演示了同时存在硬约束和软约束时怎么调权重。这类问题的规律是:不要试图让一个解“完全合法”才开始进化,而是让非法解带着惩罚参与进化,随着选择压力增大,种群会自己逐渐收敛到合法区域。
4. AI和机器学习实战:特征选择、超参数、MLP与强化学习
4.1 特征选择:遗传算法当降维工具
特征选择是遗传算法进机器学习的经典入口。一个数据集往往有几十个特征,模型里塞进太多冗余特征不仅训练慢,还可能过拟合。把“选哪些特征”编码成一个二进制向量,1表示启用,0表示丢弃,适应度函数就是“用这些特征训练模型,交叉验证得到的分数”。
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import cross_val_score def eval_feature_selection(individual): selected = [i for i, bit in enumerate(individual) if bit == 1] if len(selected) == 0: return (0.0,) X_sub = X_train[:, selected] scores = cross_val_score(RandomForestClassifier(n_estimators=20), X_sub, y_train, cv=3) return (scores.mean(),)书里用的是Friedman-1回归问题和一个Zoo数据集做演示。这个适应度函数的细节在于:每个个体的评估都是一次完整的模型训练,成本比OneMax高一个量级。所以种群和代数都不宜大,常见做法是种群50、50代以内,并且配合HOF把历史最好的特征子集保存下来。如果特征维度很高,还可以在适应度里加一个“特征数量越少越好”的小权重,把降维目标也嵌进去,这就是多目标优化的雏形。
4.2 超参数调优:遗传搜索与网格搜索的time budget之争
书里用Wine数据集和AdaBoost分类器做超参数调优,先跑了一遍默认参数准确率、再跑一遍网格搜索,最后换成遗传算法。结论很直白:当搜索空间从几个参数变成几十个参数组合时,网格搜索的评估次数随维度指数膨胀,遗传算法却可以固定在几百次评估内找到足够好的组合。
from sklearn.ensemble import AdaBoostClassifier def eval_adaboost(individual): n_estimators = int(individual[0]) learning_rate = max(individual[1], 1e-3) model = AdaBoostClassifier(n_estimators=n_estimators, learning_rate=learning_rate) scores = cross_val_score(model, X_train, y_train, cv=5) return (scores.mean(),)个体可以是混合类型:整数参数如n_estimators取整,连续参数如learning_rate直接保留。书里的建议是超参数范围别给得太宽,先根据经验框定一个合理区间,GA在这个区间里搜索效率才高;范围过宽时惩罚机制也救不回来,因为绝大多数区域可能全是低分。用遗传算法调超参不等于无脑随机搜索,它靠的是交叉把两套还不错的参数组合拼在一起,比独立随机采样更容易继承优良片段。网格搜索在维度爆炸时还有一个折中方案是随机搜索,但随机搜索没有“继承”的概念,同等评估次数下,GA在多数表格型数据集上能更快逼近好区域。
4.3 架构优化:MLP网络的层数和神经元搜索
第9章把问题从调参升级到调架构:用Iris数据集训练一个MLP分类器,染色体表示隐藏层结构,比如三段分别编码“有多少个隐藏层、每层多少神经元、用什么激活函数”。网络规模是离散值,遗传算法天然适合这种架构搜索,而梯度下降做不了离散的层数搜索。
from sklearn.neural_network import MLPClassifier def eval_mlp_arch(individual): hidden_layers = tuple(individual) model = MLPClassifier(hidden_layer_sizes=hidden_layers, max_iter=500, random_state=0) scores = cross_val_score(model, X_train, y_train, cv=5) return (scores.mean(),)这里的关键是保持评价成本可控:Iris数据集很小,跑一次MLP训练也就是几百毫秒,50代×50个个体总共2500次训练还可以接受。书里还演示了把架构搜索和超参数调优合并成一个染色体——隐藏层结构、学习率、批大小全都编码进同一个个体,一起进化。这种做法能同时优化多个维度,但搜索空间变大之后收敛速度会下降,需要配合并行评估把成本压下来。
4.4 强化学习与NLP/XAI:GA不只是优化器
这一部分对很多人来说可能是最破圈的内容。强化学习里,书用Gymnasium的CartPole和MountainCar两个环境,让遗传算法直接进化一个神经网络控制器的权重,而不是用反向传播。染色体就是一个固定长度的权重向量,适应度函数是让这个控制器在环境里跑一局获得的累计回报。
import gymnasium as gym def eval_cartpole(individual): # 把权重向量解析成前向计算函数 model = build_simple_mlp(individual, input_dim=4, hidden=8, output=2) total_reward = 0.0 obs, _ = env.reset() for _ in range(500): action = 0 if model(obs) < 0.5 else 1 obs, reward, done, truncated, _ = env.step(action) total_reward += reward if done or truncated: break return (total_reward,)这种完全不反向传播、仅靠遗传搜索的路线在小型控制任务上效果相当能打,书里把它作为遗传算法和强化学习结合的代表案例。NLP章节的mystery word和n-gram子集选择,以及可解释AI章节里用反事实搜索解释信用风险评估的案例,本质上都是同一个套路:把候选解编码成染色体,用适应度逼近我们关心的目标。
到这里能看出贯穿全书的暗线:遗传算法在AI里的角色是“不可导目标的黑匣子优化器”——参数搜索、架构搜索、策略搜索、反事实搜索本质是同一件事。书里也坦白讲了局限性:评估成本完全由适应度函数决定,如果你把一次评估从毫秒级放大到秒级,就得配合第13章的并行手段,否则就是拿时间换精度。
5. 性能提升与云端部署避坑:并行评估、SCOOP、云函数的常见问题
5.1 多进程并行:先用Pool把单核评估变成多核
遗传算法最让人头疼的是评价函数太贵。OneMax一个个体一微秒,跑几千代也无所谓;但特征选择那种“每个个体训练一次模型”的场景,50代×50个个体的2500次训练可能就要几十分钟。书里第13章的做法是先引入multiprocessing的Pool,把逐个体评估改成并行评估。
from multiprocessing import Pool def evaluate_worker(individual): return eval_feature_selection(individual) with Pool(processes=4) as pool: fits = pool.map(evaluate_worker, population) for ind, fit in zip(population, fits): ind.fitness.values = fit思路是把原来的map调用替换成Pool.map。注意pool.map要求评估函数可以被pickle序列化,如果你的评估函数是某个类的成员方法、且这个类没做特殊处理,多进程一跑就报错。常见解法是拆成模块级函数,或者用支持多进程pickle的库。进程数不是越大越好,受数据集拷贝、内存带宽限制,机器上一般4~8进程收益最高,再往上进入玄学区,收益不增反降。
5.2 从本地到Web服务:把GA包成服务器的思路
书在后面章节又进了一步:把适应度评估放到服务器端,客户端只负责进化,服务端负责跑模型打分数,用Gunicorn或Waitress起一个轻量HTTP服务。这样做的意义是突破单机限制,多台机器可以各自跑一个客户端共享同一个评估服务,进化过程本身可以分布式。
实现上大致是:客户端把候选解逐个POST到服务端的/evaluate接口,服务端用预加载的模型计算适应度并返回JSON,客户端拿到分数后继续选择、交叉、变异。书里用的生产级方案是Waitress或Gunicorn作为WSGI服务器,再往上走就是无服务器函数计算平台——把评估模块部署成云函数(Lambda)加API Gateway,用Zappa做Python代码的打包发布。这时候成本模型会变化:每个请求计费,适应度评估变成按量付费,但因为GA的评估请求天然是并发的,云函数的自动扩缩容和GA的种群评估节奏配合得相当好。
本地调试时建议先绕过云函数,直接用本地HTTP服务验证评估逻辑,避免把代码bug和部署配置问题混在一起查。
5.3 高频踩坑记录
踩坑1:适应度评估用到了共享可变状态,多进程下半数个体分数错乱现象:单进程跑得好好的,换成Pool后每代最优解忽上忽下,跟随机差不多。原因:评估函数内部引用了外部的可变全局,多进程下每个子进程各自拷贝一份,更新不互通。解决:把只读数据声明为模块级全局,把可变状态在worker内部重新构建,确保评估是纯函数。
踩坑2:变异概率照着别人的配置抄,结果OneMax都收敛不了现象:把mutpb和indpb调大之后,代数结束时最大适应度还在原地徘徊。原因:indpb是每个基因位的翻转概率,0.5意味着每代有大量个体被随机打乱,进化变成了随机搜索。解决:先从indpb=0.05开始,观察平均适应度是否稳步上升,再按需微调,不要一上来就大改。
踩坑3:eaSimple不保留精英,最优解在下一代被交叉掉了现象:logbook显示上一代最大适应度100,下一代只剩下90,往后再也没摸到100。原因:eaSimple默认不使用精英保留策略,最优个体可能被交叉或变异破坏。解决:用HallOfFame保存历史最优;需要严格单调收敛的话,自己把精英个体手动插回下一代种群。
踩坑4:云函数评估接口超时现象:部署好之后客户端发请求,返回503或超时。原因:云函数对单次调用有执行时间限制,而适应度评估可能要训练一个完整模型,超过默认限制。解决:把评估拆细,换成更轻量的模型;或者把种群评估拆成多次调用,配合异步客户端。书里专门写了异步调用的变体,就是为了应对这种瓶颈。
6. 二次开发:把书里的示例改造成自己的GA引擎
6.1 解耦问题与算法:不建议把评估函数焊死在toolbox里
很多人在跑完背包和TSP的例子后,会直接把toolbox和相关代码复制进自己的项目。我的建议是做一个轻量拆解:把染色体解码、评估函数、问题参数抽成一个Problem类,把算法流程留在GAEngine里,这样换问题只需要替换Problem实现。
| 组件 | 书里的形式 | 改造建议 |
|---|---|---|
| 染色体定义 | creator.create | 按问题类型选二进制/排列/实数 |
| 评估函数 | toolbox.register("evaluate") | 抽成独立模块,保证纯函数 |
| 问题参数 | 写在全局变量里 | 收敛到配置类或dataclass |
| 遗传算子 | 直接复用DEAP内置 | 优先用内置,确有问题再自定义 |
6.2 验证遗传算法效果的三个习惯
第一个习惯:任何新问题先跑一个小规模基准,用暴力搜索或已知最优值校准适应度上限。第二个习惯:同一参数重复跑5次,看最好值和平均值的方差,GA带随机性,一次跑通不代表稳定。第三个习惯:和随机搜索对比,如果GA跑50代不如随机采样5000次,说明编码或选择策略有问题。
我最早照着OneMax的套路去跑一个稍微复杂的调度问题,种群和变异参数直接抄,跑了十分钟没收敛,那时候才意识到这本书里每个例子能收敛,除了算法本身,更重要的是“评估函数设计”和“问题编码”贴合问题结构。从那以后我每接手一个GA项目,都强制先写一个最小验证脚本,用3分钟跑通,再往上加数据量。希望帮到你。
本文还有配套的精品资源,点击获取