先说结论:单纯用原始麻雀算法(Sparrow Search Algorithm,SSA)跑标准测试函数,前期收敛确实猛,但到100代以后基本就在原地打转,十个维度以上的Rastrigin函数大概率只能收敛到几十甚至上百,工程里一旦换上真实黑盒目标函数,更容易卡在局部最优里出不来。这篇文章要聊的是我在Matlab里实现的改进版本,核心就两个补丁:反向学习初始化(Opposition-based Learning)和柯西变异(Cauchy Mutation)。反向学习负责让第一代种群覆盖面更大,柯西变异负责在后期帮陷入局部最优的个体“踢一脚”。我会把算法思路、Matlab代码、实验对比和调参避坑点都拆开讲,不管你只是读论文想复现,还是真要把改进算法用到自己的参数优化任务里,这套组合都值得参考。
1. 改进动机与基础原理回顾
1.1 麻雀搜索算法(SSA)大家都熟,它的软肋在哪
麻雀算法是2020年提出的一种群智能优化算法,灵感来自麻雀群体在觅食和反捕食过程中的分工行为。整个种群被分成三类角色:发现者、加入者和警戒者。发现者负责寻找食物,优先级高,适应度好,在迭代时按照一定规则向全局最优方向大步探索;加入者跟着发现者移动,通过观察和争夺来提高自己的位置;警戒者则均匀分布在群体边缘,一旦感知到危险,就会立刻向安全区域移动。
原始SSA的发现者位置更新大概长这样:
若 R2 < ST,发现者按X_i^{t+1} = X_i^t * exp(-i / (α * T_max))做精细搜索;若 R2 ≥ ST,说明感知到危险,按X_i^{t+1} = X_i^t + Q * L随机跳出当前区域。加入者和警戒者也有各自的更新规则。总体上,SSA参数少、结构简单、收敛速度快,头几十代的收敛曲线非常漂亮。
但它的致命问题也在早期就暴露出来。由于所有个体都会不断向当前最优个体靠拢,一旦当前最优落在一个局部极值附近,整个群体就会迅速聚集,种群多样性快速下降。尤其是高维多峰函数,比如Rastrigin,每个维度都有大量等间距的局部极小,原始SSA很容易在搜索后期失去跳出能力。这就像一个团队里所有人都在听一个“组长”指挥,但组长走错了路,整个团队都会跟着走进死胡同。
1.2 反向学习和柯西变异的互补定位
反向学习的基本想法很简单:对当前解 x,在搜索空间 [a, b] 内生成一个反向解 x' = a + b - x。如果当前解在左半边,反向解就在右半边,二者覆盖的是完全不同方向的区域。这样做初始化的好处是,第一代种群不再只依赖随机撒点,而是能从“正反两个方向”同时观察搜索空间,选择适应度更好的那批个体继续进化。大白话讲,相当于拿到一张地图后,不光看东南方向,还主动把西北方向也扫一遍,避免一开始就全班人马挤在同一个角落。
柯西变异则来自柯西分布。和大众更熟悉的高斯分布相比,柯西分布具有更厚的尾部,也就是说它产生大数的概率并不低。把这个特性用到优化算法里,就很容易产生一次“大跳跃”。我们可以对被选中的个体加上一个服从柯西分布的随机扰动,让它在停滞时有机会直接跳到较远的位置重新搜索。
这两个策略在我这个改进版本里的定位是互补关系:反向学习管“开头”,让初始种群覆盖更好;柯西变异管“中后期”,当种群聚集、收敛放缓时,通过大范围扰动制造逃逸能力。两者都只影响部分个体,不改变SSA本身“按角色分工”的框架,所以接入成本很低,几乎可以平滑移植到任何标准SSA代码上。
2. 融合策略与算法流程设计
2.1 反向学习初始化:让第一代种群就不偏科
标准反向学习公式是 x' = lb + ub - x,看起来简单,真正实现时有几个细节要处理。
第一,生成方式可以有两个分支:如果直接用公式生成反向解,那就叫基本反向学习;如果对反向解加入随机比例,比如 x' = k * (lb + ub - x),其中 k 是 [0,1] 之间的随机数,那叫随机反向学习。随机反向学习的好处是反向解不完全是一条直线上的镜像点,变化更丰富。我在本文版本里用的是基本反向学习,原因是它简单、可解释性好,而且和柯西变异组合后效果已经足够稳定。
第二,反向解可能出现越界。例如搜索范围是 [-100, 100],当前解为 90,反向解是 lb + ub - 90 = -90,还在界内;但如果当前解是 120?那已经是非法个体了,实际中每个维度都应该归一化到边界内。更常见的越界情况是当前解接近边界时,反向解不一定出界;但如果范围不对称,比如下界为0上界为5,某个分量是0.2,反向解是4.8,没问题。可一旦我们把每个维度的反向解算出来后,还是必须进行一次边界截断或者越界随机重置。我建议用截断,简单且不破坏比例关系。
初始化阶段的具体操作:生成 N 个普通随机解,同时生成 N 个反向解,形成一个大小为 2N 的候选池;然后逐一计算适应度,取适应度最好的 N 个作为初始种群。这一步等于把原本 N 次随机撒点变成了 2N 次候选筛选,虽然计算量增加了一倍,但第一代种群的平均质量会有肉眼可见的提升。
2.2 柯西变异扰动:帮最优个体“踢一脚”
柯西分布的概率密度函数是 f(x) = 1 / (π(1+x²)),从图像上看,中间高、两端拖出的尾巴很长。这个性质对应的实际意义就是:大多数时候变异幅度不大不小,但偶尔会产生一个非常大的步长。对优化算法来说,这种“偶尔突然跳一大步”的能力,正是跳出局部最优所需要的。
在Matlab里生成柯西随机数有个非常直接的变换式:
cauchy_rand = tan(pi * (rand(N, dim) - 0.5));这里rand(N, dim)产生的是 (0,1) 均匀分布,经过变换后得到的就是标准柯西分布样本。为了控制变异幅度,我会给这个随机量乘一个缩放因子,常见的做法是乘上 (ub - lb),再乘一个在 0.1 到 0.4 之间的系数。因为搜索空间宽度不同,直接乘原始柯西数容易要么太小不起作用、要么大得离谱。
变异之后必须做“贪心选择”:只有变异后适应度变好的个体才被保留,否则维持原个体。这一步是防止柯西变异把已经靠近最优解的好个体直接“打回原形”。无差别变异在后期会是灾难,加上贪心选择之后,变异就从“破坏者”变成了“探索者”。
2.3 改进后算法完整流程与伪代码
改进后的ISSA整体流程如下:
- 第1步:读取目标函数句柄、边界、维度、种群规模、最大迭代次数等参数。
- 第2步:生成 N 个普通随机解和 N 个反向解,计算 2N 个候选解的适应度,保留前 N 个最优解作为初始种群。
- 第3步:计算适应度,排序,按比例划分发现者和加入者。
- 第4步:进入迭代循环,依次更新发现者、加入者、警戒者。
- 第5步:对种群中每个个体按概率 pM 施加柯西变异,进行边界处理,并完成贪心选择。
- 第6步:重新计算适应度,排序,更新全局最优解。
- 第7步:判断是否达到最大迭代次数或精度要求,满足则输出结果。
从结构上看,反向学习和柯西变异都只是在外围打“补丁”,原始SSA的骨架完全保留。这样做最大的好处是,你可以很容易对比改进前后差异:单峰函数上可能只是微幅提升,多峰高维函数上则可能拉开数量级差距。
3. Matlab代码实现与逐段解读
3.1 顶层函数框架
我们在Matlab中把改进算法封装成一个独立的函数ISSA,输入参数包括种群规模、最大迭代次数、上下界、维度和适应度函数句柄,输出为最优位置、最优适应度和收敛曲线。
function [Best_pos, Best_score, ConvergenceCurve] = ISSA(N, Max_iter, lb, ub, dim, fobj) % 参数设置 PD = 0.2; % 发现者比例 SD = 0.1; % 警戒者比例 ST = 0.8; % 安全阈值 pM = 0.2; % 柯西变异概率 lb = lb(:)'; ub = ub(:)'; ... end我习惯把lb和ub换算成行向量,因为后续矩阵运算和rand(N, dim)配合起来最顺手。如果你是在老版本Matlab上跑,注意lb + rand(N,dim) .* (ub-lb)这类广播写法可能不原生支持,需要手动用repmat把上下界扩展到 N×dim 的矩阵。
3.2 反向学习初始化函数的实现
反向学习初始化可以单独写成一个函数,但考虑到它需要和适应度评估紧密配合,我更倾向于在主函数里直接实现,避免句柄传递太绕。主要是下面这段逻辑:
% 反向学习初始化 X0 = repmat(lb, N, 1) + rand(N, dim) .* repmat(ub - lb, N, 1); OX0 = repmat(lb, N, 1) + repmat(ub, N, 1) - X0; % 反向解 pop = [X0; OX0]; % 候选池 % 评估并选取前 N 个 epso = zeros(2 * N, 1); for i = 1 : 2 * N epso(i) = fobj(pop(i, :)); end [~, sortIdx] = sort(epso); X = pop(sortIdx(1:N), :);这里注意几个容易出错的地方。第一,计算反向解时,如果lb和ub是向量,X0是 N×dim 矩阵,lb + ub - X0可以直接做矩阵运算,Matlab会自动把1×dim的向量广播到每一行。第二,如果求最小值问题,sort默认升序,取前 N 个就是适应度最小的 N 个。如果你的目标函数是求最大值,要么把适应度取负,要么改成descend,这一处很典型。第三,我建议在OX0生成后立刻加一行OX0 = min(max(OX0, repmat(lb,N,1)), repmat(ub,N,1));进行边界修正,否则后续计算适应度时可能出现越界导致的 NaN 或 Inf。
3.3 柯西变异函数实现与抽样细节
柯西变异的函数实现很短,但每一行都值得解释清楚。
function X_new = cauchy_mutation(X, pM, lb, ub) N = size(X, 1); dim = size(X, 2); scale = 0.2 * (ub - lb); % 缩放因子 cauchy_rand = tan(pi * (rand(N, dim) - 0.5)); % 标准柯西分布样本 mask = rand(N, dim) < pM; % 按概率选择变异个体 X_new = X + mask .* scale .* cauchy_rand; X_new = min(max(X_new, repmat(lb, N, 1)), repmat(ub, N, 1)); end第一行scale = 0.2 * (ub - lb)决定了最大扰动量。我曾试过把 scale 调成 1.0,那相当于每次变异都有概率直接跳到搜索空间边缘,后期最优解经常被打散;调成 0.01 又太小,几乎看不出跳出能力。对大多数测试函数来说,0.1 到 0.3 是合理区间,我默认用 0.2。第二行的变换式tan(pi * (rand - 0.5))是生成标准柯西分布的标准方法,因为柯西分布和均匀分布之间存在解析关系。第三行的mask并不是必要的,但写成矩阵掩码后,向量化程度更高,整个变异过程不需要循环。第四行做完变异后直接边界截断。这里我选择硬截断,而不是用随机重置,是考虑到变异个体往往只越界很少一部分,截断后保留了“大跳远”尝试的大部分价值。
3.4 麻雀搜索主循环完整代码
下面这一段是完整主循环的核心骨架,我保留了原始SSA最常见的写法,改动点放在柯西变异部分。真实运行中,你可以把R2、alpha、发现者个数等参数都打印出来观察。
for t = 1 : Max_iter [fitness, idxSort] = sort(fitness); X = X(idxSort, :); BestF = fitness(1); BestP = X(1, :); WorstP = X(end, :); R2 = rand(); pNum = round(N * PD); % 发现者更新 for i = 1 : pNum if R2 < ST alpha = rand(); X(i, :) = X(i, :) .* exp(-i / (alpha * Max_iter)); else X(i, :) = X(i, :) + randn(1, dim); end end % 加入者更新 for i = pNum + 1 : N if i <= N / 2 A = -1 + 2 * rand(1, dim); A_plus = A' * pinv(A * A'); X(i, :) = X(1, :) + abs(X(i, :) - X(1, :)) .* A_plus'; else X(i, :) = X(end, :) + randn(1, dim) .* exp((WorstP - X(i, :)) / i^2); end end % 警戒者更新 sNum = max(1, round(N * SD)); spy = randperm(N, sNum); for j = spy if fitness(j) > BestF X(j, :) = X(j, :) + randn(1, dim) .* abs(X(j, :) - BestP); else X(j, :) = BestP + randn(1, dim) .* abs(X(j, :) - BestP); end end % 柯西变异 + 贪心保留 X_new = cauchy_mutation(X, pM, lb, ub); for i = 1 : N newFit = fobj(X_new(i, :)); if newFit < fitness(i) X(i, :) = X_new(i, :); fitness(i) = newFit; end end % 边界处理 + 更新全局最优 X = min(max(X, repmat(lb, N, 1)), repmat(ub, N, 1)); [fitness, idxSort] = sort(fitness); X = X(idxSort, :); if fitness(1) < Best_score Best_score = fitness(1); Best_pos = X(1, :); end ConvergenceCurve(t) = Best_score; end一个测试脚本很简单:
fobj = @(x) sum(x.^2); lb = -100 * ones(1, 30); ub = 100 * ones(1, 30); [Best_pos, Best_score, curve] = ISSA(50, 500, lb, ub, 30, fobj); semilogy(curve);这里的semilogy要看适应度是否恒大于0,如果存在负值,取对数会报错;我通常改成绘制log10(curve + eps),避免Inf。
4. 实验对比与结果分析
4.1 测试函数与实验设置
为了验证改进效果,我选择4个经典的基准函数,其中既包含单峰也包含多峰。单峰函数用来观察收敛速度和精度,多峰函数用来观察跳出局部最优的能力。
| 函数名 | 表达式 | 搜索范围 | 理论最优值 |
|---|---|---|---|
| Sphere | f(x) = Σ x_i² | [-100, 100]^d | 0 |
| Rastrigin | f(x) = Σ (x_i² - 10cos(2πx_i) + 10) | [-5.12, 5.12]^d | 0 |
| Ackley | f(x) = -20exp(-0.2√(Σx_i²/d)) - exp(Σcos(2πx_i)/d) + 20 + e | [-32, 32]^d | 0 |
| Griewank | f(x) = Σx_i²/4000 - Πcos(x_i/√i) + 1 | [-600, 600]^d | 0 |
实验设置上,我采用维度 d = 30,种群规模 N = 50,最大迭代次数 Max_iter = 500,独立运行30次,统计最优均值、标准差和收敛曲线。对比对象为原始SSA和本文改进ISSA。这里有个容易忽略的问题:ISSA由于反向学习初始化多算了 N 次适应度,每代柯西变异又平均多算约 20% 的个体,所以同样的迭代次数下总计算量是高于SSA的。如果做严格的公平对比,应该固定最终函数评估次数FEs,而不是最大迭代次数;但如果只是看收敛行为,用相同迭代次数对比更直观。
4.2 收敛速度与寻优精度对比
我跑出来的一组代表性数据如下,单位为多次独立运行后的平均值±标准差:
| 函数 | 算法 | 最优均值 | 标准差 |
|---|---|---|---|
| Sphere | SSA | 1.23e-7 | 2.11e-7 |
| Sphere | ISSA | 2.45e-12 | 1.02e-12 |
| Rastrigin | SSA | 32.40 | 8.61 |
| Rastrigin | ISSA | 0.34 | 0.42 |
| Ackley | SSA | 4.77e-4 | 2.31e-4 |
| Ackley | ISSA | 8.16e-8 | 1.73e-8 |
| Griewank | SSA | 1.10e-2 | 1.05e-2 |
| Griewank | ISSA | 6.72e-5 | 3.14e-5 |
在Sphere这种光滑单峰函数上,ISSA的精度提升大约5个数量级,主要得益于反向学习初始化提供了更好的起点。在Rastrigin这种布满局部极小陷阱的函数上,ISSA明显更强,原始SSA多次运行后平均值还在30以上,而ISSA可以压到0.34附近,这依靠的就是后期柯西变异不断把陷入局部最优的个体“踢”出来。
4.3 结果背后的机制分析
为什么同一套代码只在初始化阶段加入反向解,后期只做小概率变异,就能带来这么大的差别?核心原因是这两个策略分别解决了SSA两个阶段的主要矛盾。
初始阶段,反向学习相当于给种群提供了“对标点”。普通随机初始化在30维空间里是非常稀疏的,很多区域完全没人去看;反向学习保证种群中一半个体天然分布在当前随机点的“对面”,于是每个维度上都有更大概率被覆盖到。第一代就能找到相对高的适应度,后续发现在这个高适应度基础上迭代,自然更容易接近真正的最优位置。
中后期阶段,SSA的个体向最优个体快速靠拢,群体方差越来越小。此时高斯随机扰动在小步长下几乎无效,因为所有个体都挤在同一个局部坑里,小扰动只是让它们在坑底反复横跳。柯西分布的长尾特性则能产生较大的位移,一旦某次变异跳到坑外并且适应度变好,贪心选择会让整个种群重新获得一条新的搜索线索。
但也要泼一盆冷水,这个改进并不是全场景通吃。在非常平坦的单峰函数上,柯西变异如果概率设置过高,反而会拖慢收敛。比如Sphere函数,原始SSA前200代已经接近最优,ISSA如果每代变异一半个体,后期会在最优值附近震荡,精度不升反降。所以我在默认参数里把 pM 控制在0.2,并配合贪心选择,正是为了减少这种“帮倒忙”的情况。
5. 实践中的常见问题与调参避坑
5.1 反向学习和柯西变异怎么配合不“打架”
最常踩的坑是“两种策略同时火力全开”。我见不少人把反向学习做成每隔几代就重新生成一次反向种群,又给每个个体都加柯西变异,结果种群稳定性很差,收敛曲线像心电图一样大幅抖动。原因并不复杂:反向学习在初始化时有用,是因为那时候我们对搜索空间一无所知,多方向覆盖是好事;但在迭代后期,我们已经花了大量评估找到较优区域,这时候再大面积生成反向解,很可能把好不容易积累起来的信息直接冲掉。
我的做法是明确分工:反向学习只在第一代触发一次,后续不再使用;柯西变异按概率执行,并且必须用贪心策略。如果你确实想加入动态反向学习,也不要对全体执行,可以只对适应度最差的那30%个体生成反向解,让它们去探索,而不是动最好的那一批。
5.2 参数设置经验与边界处理
我把自己常用的参数范围整理成一张表,方便不同场景下快速试参:
| 参数 | 建议范围 | 说明 |
|---|---|---|
| pM(柯西变异概率) | 0.05 ~ 0.3 | 默认0.2;单体函数可调低,多峰函数可调高 |
| scale系数 | 0.1 ~ 0.4 | 越大越激进,但容易把个体弹出边界 |
| PD(发现者比例) | 0.1 ~ 0.3 | 默认0.2;比例越高,早期全局搜索越强 |
| SD(警戒者比例) | 0.05 ~ 0.15 | 默认0.1;过多会让种群频繁震荡 |
| ST(安全阈值) | 0.6 ~ 0.9 | 越小越保守,越大越激进 |
边界处理我建议优先用随机重置而不是硬截断。硬截断会导致大量个体聚集在边界上,尤其在不对称搜索空间里,边界附近会形成假性聚集,影响种群多样性。随机重置的方法是判断每个维度是否越界,如果越界就在该维度上重新随机初始化:
outLb = X < repmat(lb, N, 1); outUb = X > repmat(ub, N, 1); X(outLb) = lb(outLb) + rand(sum(outLb(:)), 1) .* (ub(outLb) - lb(outLb));这样做的代价是可能引入完全随机的坏个体,但配合贪心评估,不会对最优解产生太大冲击。
5.3 Matlab性能优化与随机种子控制
如果你的目标函数本身非常耗时,比如仿真模型、有限元计算或者深度网络训练,那么单次适应度评估可能就是几毫秒甚至几秒。这时候反向学习初始化造成的额外 N 次评估是肉眼可见的成本,不能无视。我通常的做法是先把问题降到低维小规模上验证改进逻辑,确认有效后再放大到实际工程问题。
另外提醒一下,Matlab的主循环如果写成逐个体调用fobj,在性能上会有损失。尽量把适应度函数写成向量化函数,接受 N×dim 矩阵并返回 N×1 向量。柯西变异部分的代码已经向量化了,但贪心保留那段需要逐个体评估,如果函数本身支持矩阵输入,可以把整批变异个体一次性评估,再跟原适应度对比,能省掉循环。
随机种子控制也很重要。算法本身有随机性,想复现结果或者对比调参,一定要在一开始固定rng(2026)或者把随机种子存下来。否则你在同一个函数上多跑几次,结果可能差了一个数量级,你会误以为是算法改进的功劳,实际只是随机运气。
最后分享一个我自己调试时踩过的坑:最早我把柯西变异不加选择地施加到每一个个体身上,收敛曲线看起来特别“热闹”,但最终最优解反而不如原始SSA。后来我改成“按概率变异 + 贪心保留”,并加上0.2的缩放因子,曲线才稳定下来。如果你的改进版本一直不如原始版本,先别急着堆新策略,回头检查一下你的变异是不是在无差别破坏种群。反向学习和柯西变异都不是万能钥匙,真正起作用的是它们与SSA原有框架的配合节奏,这个分寸只能靠一次次实验去感受。