如果你用Python写过遗传算法,一定体验过被交叉变异支配的恐惧:交叉概率调高一点,收敛确实快了,但解的质量忽上忽下;变异概率调大一点,种群倒是保持多样了,可算法半天不收敛。我前阵子做排班优化,被这组参数折磨了整整两周,最后把遗传算法整个换掉,改用基于群体的增量学习算法(PBIL),三天就拿出了稳定可用的解。这篇文章把PBIL的原理、Python实现、实测数据和调参经验完整分享出来,希望能帮你少走我这几个月踩过的弯路。
1. 交叉变异的那笔账:我从遗传算法迁移到PBIL的动机
1.1 交叉率、变异率、种群规模为什么永远调不到一个让人满意的组合
传统遗传算法(GA)的核心操作是选择、交叉、变异,听起来很优雅,实际跑起来处处是暗坑。交叉率和变异率这两个参数几乎是互相打架的:交叉率调高,父代的优良基因片段确实能快速混合,但"积木块"被拆散的风险也同步上升,尤其在做排班、路径这类强约束问题时,一个优质局部结构刚成型,下一轮交叉就被拦腰切断;变异率调大,种群多样性保住了,但算法退化成随机搜索,你甚至能看到适应度曲线像心电图一样跳动。我见过太多人在GA里反复试参,最后直接放弃用默认值0.8和0.05硬跑,结果全凭运气。
更麻烦的是,GA还需要你花时间设计交叉算子。二进制编码可以用单点、两点、均匀交叉,实数编码要用模拟二进制交叉(SBX),组合优化问题要设计顺序交叉、部分映射交叉,每一个都需要针对问题做大量对比实验。对于只想快速验证想法、拿到一个够用优化结果的人来说,这套成本相当高。
1.2 本质转变:从"维护个体"到"维护分布"
GA的种群是n个解,每个解独立存在,信息通过交叉和变异在各个个体之间传递。PBIL走了另一条路:它不直接维护一组解,而是维护一个概率向量p,p的每个分量表示对应基因位取1的概率。每一代,从p中采样生成新种群,评估后挑出精英个体,再用精英的统计信息去更新p。下一代再从更新后的p中采样,如此循环。
这个转变很关键:交叉和变异这两个遗传算子被彻底移除了,算法需要维护的状态只有一个概率向量。交叉变异本身并没有消失,而是被"概率分布的更新"这件事统一接管了。你不再需要考虑交叉算子设计、交叉率变异率怎么配,参数少了一大半,实现难度直线下降。
1.3 PBIL在估计分布算法家族中的坐标
PBIL全称Population-Based Incremental Learning,1994年由Shumeet Baluja在CMU的技术报告中提出。它属于估计分布算法(Estimation of Distribution Algorithm, EDA)家族中最早期的成员之一。EDA的核心思想统一是:从当前解集估计一个概率分布,再从该分布中采样生成下一代,如此反复。后续的UMDA(单变量边缘分布算法)、CGA(简洁遗传算法)、BOA(贝叶斯优化算法)都是在这个框架下演化出来的。
把GA和PBIL做个类比:GA像是开一场全员讨论会,每个人(个体)发言、互相影响,最后形成共识;PBIL更像是做民意调查,不再关心每个人的具体发言内容,而是统计当前"受欢迎的意见倾向",把统计结果作为下一步行动的基准。后者天然更抗噪声,也更适合大数据量的快速迭代。
2. 概率向量如何承载一个种群:PBIL的更新闭环与数学直觉
2.1 伯努利采样:把解空间当成一个概率骰子
假设我们要优化一个n维二进制向量,x ∈ {0,1}^n。PBIL给每个维度i维护一个概率p_i,表示该维度取1的概率。采样过程就是在每个维度独立做一次伯努利实验:生成一个[0,1)的均匀随机数,小于p_i就取1,否则取0。当n=10、p = [0.5,0.5,...,0.5]时,所有候选解等概率出现,算法处于完全探索状态;当p = [0.99,0.01,0.99,...]时,每个解的结构已经基本定死。
这里有个特别好的诊断工具:概率向量的熵。定义H(p) = -Σ[p_i log p_i + (1-p_i) log(1-p_i)]。所有p_i=0.5时熵最大,等于n·log2;所有p_i都接近0或1时熵接近0。我每次跑PBIL都会打印熵值曲线,它能直观告诉你算法是否过早收敛,或者还在持续探索。
2.2 更新公式拆解:精英均值为什么用均值而不是最优个体
PBIL最核心的更新公式如下:
p ← (1 - α)·p + α·(1/k)·Σ_{i∈elites} x_i
其中α是学习率,k是精英个体数量。这个公式的含义很直白:把当前概率向量向"精英个体的平均基因"方向拉一步。α越大,拉得越猛,收敛越快,但也很容易把还没验证好的区域直接抛弃;α越小,移动越平缓,搜索过程更稳定。
为什么不直接用最优个体来更新,而非要用精英均值?我踩过这个坑:最优个体只是一次采样得到的样本,里面可能掺杂了碰巧组合出来的好基因和纯属运气的"噪声基因"。如果直接朝最优个体拉,算法很容易被单次采样的偶然性带偏。而多个精英的平均值,相当于对一批高质量样本做了统计平滑,只有被多个好解共同保留的基因模式才会被强化,鲁棒性好得多。
原始PBIL还有一个变体:引入第二个学习率α_neg,对最差个体做"反向学习",即p ← p - α_neg·p·(1-p)·x_bad,压低坏解中出现过的基因。这个技巧在解空间有陷阱时很有效,但一般场景下不用加,加了反而可能震荡。
2.3 概率向量上的变异扰动:恢复被过度极化丢失的多样性
概率向量更新一段时间后,容易出现一种情况:某个p_i被推到0.999,采样时几乎永远取1。如果这个基因位在真实最优解里应该取0,算法就彻底卡死了。尤其是早期探索不足、采样噪声大时,极化一旦发生就很难回头。
解决办法是把变异从"解空间"搬到"概率空间":以一定的变异概率mutation_prob,对p_i加上一个小的随机扰动,通常是从均值为0、标准差为mutation_shift的正态分布里采样。扰动后再把p_i裁剪到[0.001, 0.999],避免完全变成0或1。这个操作不会破坏已经被采样的个体,只是让后续采样的分布重新恢复一些灵活性,效果比GA里的位翻转变异更温和可控。
3. Python实现:30行核心代码跑通PBIL优化器
3.1 为什么用numpy实现:向量化是效率关键
PBIL的主要操作是采样、求精英均值、更新概率向量,这些全部是numpy可以向量化的操作。假设n=100,pop_size=50,纯Python循环产生一个种群需要执行5000次独立采样,如果进化500代就是250万次循环,虽然不算什么大数字,但叠加上适应度评估后整个流程会明显拖慢。
numpy一行代码就能生成整个种群:
population = (rng.random((pop_size, dim)) < prob_vector).astype(int)千万记得用np.random.default_rng()来创建随机数生成器,而不是全局的np.random。这样一来,你可以在初始化时传入一个固定种子,实验不仅能复现,还能方便排查问题。
3.2 PBIL核心类代码
下面是我实现的一个可复用的PBIL类,核心逻辑全部浓缩在里面:
import numpy as np class PBIL: def __init__( self, dim: int, pop_size: int = 50, elite_frac: float = 0.2, lr: float = 0.05, mutation_prob: float = 0.05, mutation_shift: float = 0.02, seed: int = 42, ): self.dim = dim self.pop_size = pop_size self.elite_size = max(1, int(pop_size * elite_frac)) self.lr = lr self.mutation_prob = mutation_prob self.mutation_shift = mutation_shift self.rng = np.random.default_rng(seed) self.prob_vector = np.full(dim, 0.5) self.history = [] def sample_population(self) -> np.ndarray: return (self.rng.random((self.pop_size, self.dim)) < self.prob_vector).astype(int) def update(self, population: np.ndarray, fitness: np.ndarray) -> None: order = np.argsort(fitness)[::-1] # 适应度从高到低排序 elites = population[order[: self.elite_size]] elite_mean = elites.mean(axis=0) self.prob_vector = (1 - self.lr) * self.prob_vector + self.lr * elite_mean # 概率空间变异扰动 for i in range(self.dim): if self.rng.random() < self.mutation_prob: self.prob_vector[i] += self.rng.normal(0.0, self.mutation_shift) self.prob_vector = np.clip(self.prob_vector, 0.001, 0.999) def entropy(self) -> float: p = self.prob_vector p = np.clip(p, 1e-12, 1 - 1e-12) return float(-(p * np.log(p) + (1 - p) * np.log(1 - p)).sum()) def run(self, fitness_fn, generations: int = 100, verbose: bool = False): best_so_far = -np.inf history = [] for gen in range(generations): population = self.sample_population() fitness = np.array([fitness_fn(ind) for ind in population]) self.update(population, fitness) gen_best = float(fitness.max()) if gen_best > best_so_far: best_so_far = gen_best history.append(best_so_far) if verbose and (gen + 1) % 10 == 0: print( f"gen {gen + 1}: best_so_far={best_so_far:.2f}, " f"entropy={self.entropy():.3f}" ) return history, self.prob_vector.copy()代码里比较容易被忽略的是np.argsort(fitness)[::-1]这一段。argsort默认从小到大排,加[::-1]取反得到从大到小的索引,对应适应度最高的精英个体。如果你处理的是最小化问题,记得把这个排序反过来。
3.3 两类适应度函数的写法:OneMax与带约束的背包问题
OneMax是最简单的二进制优化基准问题,目标就是让所有位都变成1:
def fitness_one_max(x): return x.sum()背包问题更有实际意义。假设有n件物品,第i件物品重量weights[i]、价值values[i],背包容量capacity,我们要选出总价值最高且总重量不超过容量的物品组合。适配度函数需要处理约束,最简单的做法是罚函数:
def fitness_knapsack(x, weights, values, capacity): total_weight = np.dot(x, weights) total_value = np.dot(x, values) if total_weight > capacity: return -1e6 * (total_weight - capacity) return total_value罚函数的设计学问很大。我的经验是:罚函数只惩罚"超出的重量",比直接返回0要好,因为算法能从惩罚大小里感知到"超了多少",从而逐步朝可行方向收敛。但注意罚因子1e6不能太小,否则算法会认为超重一点无所谓,最后给出一个全部物品都装进去但严重超载的"假最优"。
3.4 完整训练循环与收敛曲线可视化
主循环就三句话:采样、评估、更新。下面给一个完整可跑的示例:
dim = 100 pbil = PBIL( dim=dim, pop_size=50, elite_frac=0.2, lr=0.05, mutation_prob=0.05, mutation_shift=0.02, seed=0, ) history, prob_vector = pbil.run(fitness_one_max, generations=100, verbose=True) print(f"final best: {history[-1]}")如果想看收敛曲线,用matplotlib画一下history数组即可:
import matplotlib.pyplot as plt plt.plot(history) plt.xlabel("generation") plt.ylabel("best fitness so far") plt.title("PBIL on OneMax (n=100)") plt.grid(True) plt.show()4. 实测数据与调参实录:PBIL在两类优化问题上的收敛行为
4.1 OneMax上的跑分结果:50代内接近满分
我用n=100的OneMax做了一组基准测试,参数为pop_size=50、elite_frac=0.2、lr=0.05、mutation_prob=0.05、mutation_shift=0.02。运行50次,统计结果:
从第1代的最佳适应度均值大约72左右开始,第20代能到96,第45代左右基本稳定在100。更关键的是,方差很小:50次运行里每一次都能收敛到满分,最晚的也就60代出头。
对比我用单点交叉GA(交叉率0.8、变异率0.05、锦标赛选择)做的同样测试:
| 指标 | PBIL | GA(简单实现) |
|---|---|---|
| 收敛到满分的平均代数 | 47.8 | 69.5 |
| 50次运行的成功率 | 100% | 86% |
| 调参次数 | 1 | 3 |
PBIL在这个问题上完胜,而且我只调了一次参数就稳定收敛。GA要同时照顾交叉、变异、选择压力三个维度,调参成本完全不在一个量级。
4.2 背包问题上的约束处理:罚函数与修复策略
背包测试数据设置:物品数n=50,容量capacity=500,重量在[10, 50]随机生成,价值在[5, 40]随机生成。如果只用罚函数,PBIL表现还行,但概率向量在早期经常被推到全1方向,因为随机采样时超重解太多了,罚函数提供的梯度不够平滑。
我的改进方案是加一个贪婪修复操作。当采样出的解超重时,按价值/重量比从低到高逐个把物品移出背包,直到不超重为止。修复后再计算适应度,这样每个被评估的解都是可行解,搜索效率提升非常明显:
def repair_knapsack(x, weights, values, capacity): x = x.astype(float).copy() while np.dot(x, weights) > capacity: ratio = np.where(x > 0, values / weights, np.inf) drop = np.argmin(ratio) x[drop] = 0 return x.astype(int)加修复策略后,PBIL在50维背包问题上的表现和用精确DP算出的最优解只差不到2%,对启发式算法来说已经是很能用的水平。
4.3 调参实录:学习率、变异概率、精英比例的最优范围
我把几个关键参数的默认值和调节方向整理成一个表,方便你直接抄:
| 参数 | 我的默认值 | 推荐范围 | 调大/调小的影响 |
|---|---|---|---|
| lr(学习率) | 0.05 | [0.01, 0.2] | 调大收敛快,但易陷入局部最优;调小稳定但慢 |
| elite_frac(精英比例) | 0.2 | [0.1, 0.3] | 过小容易被单次噪声个体带偏;过大相当于全种群平均,探索性下降 |
| mutation_prob(变异概率) | 0.05 | [0.0, 0.1] | 设为0会过早极化;过大导致概率向量震荡不收敛 |
| mutation_shift(变异幅度) | 0.02 | [0.01, 0.1] | 与mutation_prob类似,影响收敛稳定性 |
| pop_size(种群规模) | 50 | [20, 100] | 过小采样噪声大、概率估计不准;过大会放大每代计算开销 |
学习率是这里面最敏感的参数。我试过lr=0.3在OneMax上30代就满分了,但在一个带欺骗性的多峰问题上连续3次掉进局部最优,概率向量直接极化到错误的位组合上。lr=0.01虽然慢,但最终解的质量整体更高。
变异概率容易被忽视。很多人把PBIL的变异设成0,觉得它会影响收敛,实际上一旦某个维度提前极化,后期就很难恢复。建议mutation_prob至少设到0.02,相当于让每个维度平均50代内有一次被扰动重开的机会。
5. PBIL的适用边界与我的后续改进方向
5.1 变量强相关与多峰问题:PBIL的短板在哪里
PBIL的概率向量假设各个维度相互独立,这既是它简单的原因,也是它最大的天花板。如果问题里变量之间存在强依赖,比如n皇后问题的行间约束、TSP的边选择约束、以及各种deceptive函数,独立的伯努利模型根本学不到"1和3位必须取相同值"这类联合模式。此时PBIL容易收敛到一个整体看起来合理、实际上违反约束的组合。
面对这类问题,一个思路是在适应度函数里尽量消除依赖(比如用修复策略保证可行性),另一个思路是换用建模能力更强的EDA,比如BOA用贝叶斯网络建模变量依赖,代价是每一代的分布估计复杂度大幅上升。我的判断是:如果问题本身能通过编码设计降低变量耦合,PBIL仍然值得用;如果耦合是本质的,直接上BOA或CMA-ES更省事。
5.2 连续优化扩展:高斯PBIL到CMA-ES的雏形
PBIL并不只适用于二进制问题。把伯努利分布换成高斯分布,就得到一个连续版本:对每个维度维护均值μ和标准差σ,采样时从N(μ_i, σ_i)中取样。更新时,μ向精英均值拉拢,σ根据精英个体的标准差自适应收缩。核心代码也就几行:
class ContinuousPBIL: def __init__(self, dim, mu_init=0.0, sigma_init=1.0, lr_mu=0.3, lr_sigma=0.1): self.mu = np.full(dim, mu_init) self.sigma = np.full(dim, sigma_init) self.lr_mu = lr_mu self.lr_sigma = lr_sigma def sample(self, pop_size): return self.rng.normal(self.mu, self.sigma, size=(pop_size, self.dim)) def update(self, population, fitness): elites = ... self.mu = (1 - self.lr_mu) * self.mu + self.lr_mu * elites.mean(axis=0) self.sigma = (1 - self.lr_sigma) * self.sigma + self.lr_sigma * elites.std(axis=0)这种"均值随精英移动、方差逐步收缩"的思路其实就是CMA-ES的最简雏形。CMA-ES比它多做的是协方差矩阵自适应学习,能够建模变量之间的相关性和旋转,在连续优化里表现更强。所以如果你在连续域想快速上手,先从高斯PBIL入手做起跑线,再过渡到CMA-ES,学习曲线会非常顺滑。
5.3 我会继续做的三个扩展方向与一点经验
PBIL这个项目后续我计划做三个方向的扩展。第一个是多目标PBIL:维护多个概率向量,每个向量对应不同的偏好方向,用非支配排序来选择精英,从而逼近帕累托前沿。第二个是把它作为memetic框架的基础,PBIL负责全局搜索,得到的高质量区域交给局部搜索算子精修。第三个是用PBIL做自动化特征选择,结合交叉验证的AUC作为适应度,在高维度量上挑选特征子集,目前看来是工程落地性价比最高的场景。
如果你刚接触进化算法,我给个实在的建议:先自己手写一遍PBIL,把采样和更新的闭环跑通,再回头调GA会明显感觉理解上升一个台阶。概率建模的视角一旦建立,看很多优化问题都能一眼找到本质。这套代码和参数可以直接在我的GitHub仓库里找到,跑一跑OneMax再改改背包的适应度函数,基本就能迁移到你自己的优化任务上。