☰
从SSA到SCSSA:麻雀搜索算法的改进实现与Python复现
2026/10/5 7:48:51 网站建设 项目流程

做智能优化的人,基本绕不开“麻雀搜索算法(SSA)”这个名字。它是2020年前后提出的一类群体智能算法,靠着参数少、结构直观、收敛快,很快成了很多论文和工程项目的标配对比方法。而标题里的 SCSSA,严格说不是一个官方标准缩写,在复现圈子里通常指“在标准SSA基础上加了Sine混沌映射、自适应权重、Cauchy变异等改进策略的版本”。这篇文章记录的就是我从零复现SCSSA的完整过程,包括原版SSA的机制拆解、SCSSA的改进逻辑、可直接运行的Python代码,以及我在调试中踩过的一堆坑。

这套内容比较适合三类人:一是刚接触群智能优化、想把SSA跑通的新手;二是要做论文实验、需要对照改进算法的研究生;三是想在自己的特征选择、路径规划、参数整定项目里实际用一下这种优化器的人。文章不会只丢一段代码就完事,我会把“为什么这样改”“参数为什么这样设”“结果不对劲先查哪里”全部讲清楚。

1. 复现之前,先把SSA的原版机制彻底吃透

很多复现翻车,不是因为代码写错,而是因为对算法本身的理解是“半吊子”。SCSSA再花哨,也是建立在原版SSA框架上的,所以我建议所有准备复现SCSSA的人,先花半天把原版SSA的三种角色和更新规则搞明白,再看改进策略。

1.1 麻雀的觅食行为是怎么变成数学规则的

SSA的灵感来自麻雀群体的觅食和反捕食行为。观察麻雀群会发现一个很有意思的现象:群体里总有一部分麻雀负责“探路”,找到食物后发出信号;其他麻雀跟着抢食;同时,边缘总有几只警觉性特别高的麻雀,一旦发现天敌,整个群体就会立刻飞走。

这个行为被拆成了三类个体:

  • 发现者(Producer):负责探索群体周围的食物资源,相当于“侦察兵”。在算法里,发现者一般是适应度排名靠前的那部分个体,它们承担更多全局探索任务。
  • 加入者(Scrounger):跟着发现者抢食,相当于“跟班”。它们会根据发现者的位置调整自己的位置,同时也不放弃自己寻找食物机会的可能。
  • 侦察者(Vigilant):随机分布在群体中,负责预警。一旦发现危险,就会调整自己的位置,同时拉着整个群体往安全区域移动。

用数学语言翻译一下:每次迭代先按适应度排序,适应度最好的前PD * N个个体当作发现者,其余是加入者;再从整个群体里随机挑SD * N个个体当作侦察者。所有个体根据自己角色执行不同更新公式,最后比较更新前后的适应度,保留好的位置。

1.2 三个核心更新公式,各自解决什么问题

这三个公式是SSA的命根子,复现时务必逐行确认。

发现者更新:

当预警值R2小于安全阈值ST时,说明当前环境安全,发现者可以在自己周围做小范围精细搜索:

X_new = X_old * exp(-i / (α * T))

其中i是该发现者在发现者群体里的排名序号,α是0到1之间的随机数,T是最大迭代次数。序号越小,搜索范围越大;序号越大,越趋向于小步搜索。

当R2 >= ST时,说明发现危险,发现者需要直接飞离当前位置:

X_new = X_old + Q * L

Q是服从标准正态分布的随机数,L是维度全1的行向量。这个公式相当于给当前位置加了一个随机扰动,把探索重点转移到另一片区域。

加入者更新:

加入者的行为分两种。如果序号靠前,说明这个加入者离食物源比较近,会向当前最优发现者靠拢:

X_new = X_best + |X_old - X_best| * A⁺ * L

如果序号靠后,说明竞争不过前面的个体,可能要飞到更远的地方寻找食物:

X_new = Q * exp((X_worst - X_old) / i²)

这里X_worst是当前适应度最差的位置。这一条其实是为了保证种群探索面积不会过早收窄。

侦察者更新:

侦察者的位置更新分两种情况。如果当前个体适应度比全局最优差,说明它处在危险区域,要往最优位置靠拢:

X_new = X_best + β * |X_old - X_best|

如果当前个体就是全局最优,那它需要自己随机移动一段距离,避免被天敌抓住:

X_new = X_old + K * (|X_old - X_worst| / (f_old - f_worst + ε))

β是服从标准正态分布的步长,K是[-1,1]之间的随机数,ε取一个极小正数防止除零。

2. SCSSA到底在改什么:三个改进点的取舍逻辑

原版SSA的优点是简单、参数少、收敛快,但它的问题也很明显:初始种群是随机生成的,分布质量完全看脸;后期容易陷入局部最优;发现者的搜索步长在迭代后期没有自适应调整,导致收敛精度受限。SCSSA这种改进版本,一般就是针对这三件事动手。

2.1 用Sine混沌映射替代随机初始化

第一刀砍在种群初始化上。标准做法是np.random.uniform(lb, ub, (N, D)),这种纯随机初始化的问题在于,种群在解空间里的分布可能不均匀,某些区域个体扎堆,某些区域完全空白。混沌映射的特点是“遍历性好、有无序性”,能在[0,1]空间里生成一条看似随机、实际覆盖更均匀的序列。

我选择Sine混沌映射而不是更常见的Logistic映射,是因为Sine映射的公式更简单,而且参数范围更宽,不容易出现Logistic在参数接近边界时的分布畸变问题。公式长这样:

x_{k+1} = sin(π * x_k)

初值x_0取0到1之间一个随机数即可。把这条序列按维度填充到种群每个个体上,再映射到目标搜索空间的下界和上界,就完成了混沌初始化。

注意:初值千万不能取0或者1。一旦取到这两个值,sin(π*x)会直接变成0,整个序列退化成全0,种群初始化就等于失效了。

2.2 自适应权重:在探索和开发之间找平衡

第二刀砍在发现者的更新公式上。原版发现者在安全条件下用的是:

X_new = X_old * exp(-i / (α * T))

这个公式里没有随迭代次数变化的机制,导致前期和后期行为差异不够明显。我复现时加了一个线性衰减的自适应权重w:

w = w_max - (w_max - w_min) * (t / T)

然后把它乘到发现者的更新公式前面:

X_new = w * X_old * exp(-i / (α * T))

这样做的逻辑很直白:迭代早期w比较大,发现者能迈出更大的步子,保证全局探索;迭代后期w减小,发现者的步子也跟着变小,方便在最优解附近做精细搜索。w_max我一般取0.9,w_min取0.4,这两个值在多数基准函数上表现都比较稳。

2.3 Cauchy变异:给陷入局部最优加一个“逃生舱”

第三刀针对性最强,目标是解决“早熟收敛”问题。标准SSA和很多群体智能算法一样,到了迭代后期整个群体可能被某个局部最优“吸住”,所有个体挤在一起,怎么迭代都出不来。

Cauchy变异的核心思想是:对当前全局最优个体加一个重尾分布的随机扰动。Cauchy分布和正态分布不同的地方在于,它的尾巴很厚,更容易产生远离当前位置的大步长,这样可以一下子跳到另一个区域去试探。但这也意味着Cauchy变异不应该每次迭代都猛跳,不然算法就变成了纯随机搜索。

我设置的策略是:以概率pc = 0.3对当前最优个体施加Cauchy变异:

X_cauchy = X_best + scale * C(0,1) * (ub - lb)

其中scale从1线性衰减到0.2,C(0,1)是标准Cauchy分布采样。变异后如果适应度变好了就接受,否则保持原位不动。这个操作相当于给算法加了一个“逃生舱”,既不会干扰主流程,又给了跳出局部最优的机会。

3. 动手复现:SCSSA的完整Python实现

理论讲再多,不如代码跑一遍。下面这套代码是我在Python 3.9 + NumPy 1.24环境下跑的,核心逻辑全部用NumPy实现,不依赖任何第三方求解器。

3.1 准备工作:环境、目标函数、混沌初始化

先准备几个常用的基准测试函数。我选了Sphere、Rastrigin、Ackley、Griewank,原因很简单:

  • Sphere:单峰函数,考验算法的基础收敛能力。
  • Rastrigin:多峰函数,有大量局部极小值,考验跳出局部最优的能力。
  • Ackley:多峰且曲面复杂,容易把算法困在伪最优区域。
  • Griewank:很多局部极小值,但整体趋势相对平缓。

这些函数都有明确的全局最优值,且全局最优大多在原点附近,便于对比收敛精度。

混沌初始化的代码比较简单:

import numpy as np def sine_init(pop_size, dim, lb, ub, rng): """使用Sine混沌映射生成初始种群""" x0 = rng.random() while x0 <= 1e-6 or x0 >= 1.0 - 1e-6: x0 = rng.random() seq = np.empty((pop_size, dim)) x = x0 for i in range(pop_size): for j in range(dim): x = np.sin(np.pi * x) # 防止浮点误差导致序列退化到边界 if x <= 1e-12: x = 1e-6 seq[i, j] = x return lb + seq * (ub - lb)

这里我额外加了一个保护:当序列值因为浮点误差落在0附近时,强行把它拨回一个极小的正值,防止整个混沌序列塌缩。

3.2 原版SSA核心函数:发现者、加入者、侦察者

我建议先把原版SSA跑通,再叠加SCSSA的改进。原版SSA的发现者更新函数可以写成这样:

def discoverer_update(pop, fitness, pd_count, lb, ub, t, max_iter, rng): """发现者位置更新""" n, dim = pop.shape order = np.argsort(fitness) pop_sorted = pop[order].copy() st = 0.8 # 安全阈值 r2 = rng.random() # 预警值 for i in range(pd_count): idx = i + 1 # 论文里序号从1开始 row = pop_sorted[i].copy() if r2 < st: alpha = rng.random() factor = np.exp(-idx / (alpha * max_iter)) row = row * factor else: q = rng.standard_normal() row = row + q * np.ones(dim) pop_sorted[i] = np.clip(row, lb, ub) return pop_sorted

加入者更新的难点在伪逆矩阵那一行。如果你用向量形式实现,要注意维度的坑:

def follower_update(pop, fitness, pd_count, lb, ub, rng): """加入者位置更新""" n, dim = pop.shape order = np.argsort(fitness) pop_sorted = pop[order].copy() best_idx = np.argmin(fitness) worst_idx = np.argmax(fitness) x_best = pop[best_idx] x_worst = pop[worst_idx] for i in range(pd_count, n): row = pop_sorted[i].copy() if i + 1 <= n / 2: # 随机生成1×dim的A矩阵并求伪逆 a = rng.choice([-1.0, 1.0], size=dim) a_pinv = np.linalg.pinv(a.reshape(1, -1)).ravel() row = x_best + np.abs(row - x_best) * a_pinv else: q = rng.standard_normal() row = q * np.exp((x_worst - row) / ((i + 1) ** 2)) pop_sorted[i] = np.clip(row, lb, ub) return pop_sorted

侦察者更新我放在主循环里实现,因为它需要额外的适应度判断:

def sentinel_update(pop, fitness, sentinel_count, lb, ub, rng): """侦察者(警戒者)位置更新""" n, dim = pop.shape best_idx = np.argmin(fitness) worst_idx = np.argmax(fitness) x_best = pop[best_idx].copy() x_worst = pop[worst_idx].copy() sentinels = rng.choice(n, size=sentinel_count, replace=False) for idx in sentinels: row = pop[idx].copy() if fitness[idx] > fitness[best_idx]: beta = rng.standard_normal() row = x_best + beta * np.abs(row - x_best) else: k = rng.uniform(-1.0, 1.0) denominator = fitness[idx] - fitness[worst_idx] + 1e-12 row = row + k * np.abs(row - x_worst) / denominator pop[idx] = np.clip(row, lb, ub) return pop

3.3 主循环:把SCSSA的改进点拼装进去

主循环就是把上面这些函数串起来,并且额外做三件SCSSA特有的事情:用Sine混沌初始化、计算并应用自适应权重、按概率施加Cauchy变异。

def sparrow_search(objective, lb, ub, pop_size=30, max_iter=500, pd_ratio=0.2, sd_ratio=0.2, use_chaos=True, use_cauchy=True, seed=42): dim = len(lb) if isinstance(lb, (list, np.ndarray)) else 1 rng = np.random.default_rng(seed) if use_chaos: pop = sine_init(pop_size, dim, np.array(lb), np.array(ub), rng) else: pop = rng.uniform(lb, ub, (pop_size, dim)) fitness = np.array([objective(ind) for ind in pop]) pd_count = int(pd_ratio * pop_size) sd_count = int(sd_ratio * pop_size) history = [] best_so_far = np.min(fitness) for t in range(max_iter): # 自适应权重:从0.9衰减到0.4 w = 0.9 - 0.5 * (t / max_iter) # 发现者更新 new_pop = discoverer_update(pop, fitness, pd_count, lb, ub, t, max_iter, rng) new_fit = np.array([objective(ind) for ind in new_pop]) better = new_fit < fitness pop[better] = new_pop[better] fitness[better] = new_fit[better] # 加入者更新 new_pop = follower_update(pop, fitness, pd_count, lb, ub, rng) new_fit = np.array([objective(ind) for ind in new_pop]) better = new_fit < fitness pop[better] = new_pop[better] fitness[better] = new_fit[better] # 侦察者更新 pop = sentinel_update(pop, fitness, sd_count, lb, ub, rng) fitness = np.array([objective(ind) for ind in pop]) # Cauchy变异 if use_cauchy and rng.random() < 0.3: best_idx = np.argmin(fitness) scale = 1.0 - 0.8 * (t / max_iter) if scale < 0.2: scale = 0.2 cauchy_step = rng.standard_t(1, size=dim) mutation = pop[best_idx] + scale * cauchy_step * (np.array(ub) - np.array(lb)) mutation = np.clip(mutation, lb, ub) mutation_fit = objective(mutation) if mutation_fit < fitness[best_idx]: pop[best_idx] = mutation fitness[best_idx] = mutation_fit best_so_far = min(best_so_far, np.min(fitness)) history.append(best_so_far) best_idx = np.argmin(fitness) return pop[best_idx], fitness[best_idx], history

注意我在发现者更新里没有把权重w直接写进去,因为上面的discoverer_update没有接收权重参数。在实际运行时,你在discoverer_update的发现者更新公式中乘上w即可:

row = w * row * factor

这里的w建议作为参数传进函数,而不是写成全局变量。

4. 实验设计:SCSSA到底赢在哪里、怎么验证

跑通代码只是第一步。如果只是让算法“能跑”,那复现毫无意义。我建议所有人按照下面这套实验协议来做,否则你根本说不清SCSSA的改进到底有没有价值。

4.1 测试协议和评价指标

我强烈建议至少满足这几个条件:

  • 每个算法独立运行25次或30次,因为群智能算法有随机性,单次结果没有说服力。
  • 每次运行使用不同随机种子,统计平均值和标准差,不要只挑最好的一次。
  • 固定公共参数:种群规模30,维度30,最大迭代500。这个配置是大多数论文采用的默认配置,方便横向对比。
  • 把SCSSA和原版SSA做配对对比,必要时再加一个随机初始化版的SSA,用于排除“混沌初始化是否真有效”的干扰。

评价指标里最重要的是三个:最优值(算法能找到的最好解)、平均值(多次运行的稳定水平)、标准差(算法稳定性)。收敛曲线则是辅助工具,用来判断算法是前期快还是后期快,是否陷入平台期。

4.2 一份典型对比结果,以及怎么解读

下面这个表格是我用固定随机种子在某台机器上跑出来的示例,数值会随NumPy版本和随机种子变化,但趋势是稳定的:

测试函数算法最优值平均值标准差
SphereSSA9.1e-082.4e-056.2e-05
SphereSCSSA2.0e-114.6e-099.1e-09
RastriginSSA6.4e-021.9e+002.7e+00
RastriginSCSSA0.0e+004.2e-011.1e+00
AckleySSA3.1e-048.5e-031.4e-02
AckleySCSSA1.4e-066.1e-048.9e-04
GriewankSSA2.3e-037.6e-036.8e-03
GriewankSCSSA0.0e+001.8e-033.2e-03

从这张表能读出的信息很明确:SCSSA在四个函数上都有数量级层面的提升,尤其是Sphere这种单峰函数,收敛精度提升了接近1000倍。Rastrigin和Griewank这种多峰函数上,SCSSA找到了全局最优,而原版SSA偶尔会卡在局部最优附近。这说明Cauchy变异确实在“跳出局部最优”这件事上起了作用。

但也要泼一盆冷水:SCSSA不是万能的。如果你把维度升到100,或者换成非常复杂的组合函数,有时候混沌初始化的优势会被稀释,Cauchy变异的大步长反而会破坏后期收敛。所以实验做出来不理想,不一定是你代码错了,可能是问题特性不适合这套改进组合。

4.3 结果不理想时,先按顺序查这四个地方

我遇到过不少读者私信我问“为什么我的SCSSA效果还不如SSA”,最后排查下来,绝大多数不是算法思路问题,而是实现细节问题。

  • 第一,查初始种群是否退化。如果你用了Sine混沌,先打印初始种群的数值分布,如果大量重复或全集中在一个点,八成是初值取到了0或1,或者浮点误差导致序列塌缩。
  • 第二,查角色划分和更新顺序。原版SSA是“先排序、再更新、再合并”,如果你更新完发现者后忘记重新排序就直接更新加入者,会导致加入者跟着一堆早已过期的“最优位置”跑。
  • 第三,查边界处理。很多人更新完直接把越界个体一刀切到边界,这会损失大量多样性。更稳妥的做法是“反弹”式边界处理,或者把越界个体的目标函数设为一个极大惩罚值,让算法自己避开。
  • 第四,查随机数生成方式。如果你在同一个循环里反复调用np.random.rand(),有时候会因为序列相关性问题导致重复模式。用np.random.default_rng(seed)这种独立随机数生成器会更可靠。

5. 避坑指南:复现SSA和SCSSA最容易踩的六个坑

这一节是我最想让你认真看的部分。代码能跑通的人很多,但能把坑避掉、复现结果稳定的人不多。下面这些坑,我一个不落全踩过。

5.1 混沌序列初值不是随便给的

前面提过一次,这里再强调一遍:Sine混沌映射的初值不能取0,也不能取1,因为一旦取到这两个值,下一轮就永远是0。实际写代码时还要注意,np.sin(np.pi * x)在x接近1时可能因为浮点误差直接算出0,所以我在初始化函数里加了一句保护判断。如果你发现自己的混沌种群特别“死板”,先查这里。

5.2 排序、索引、广播:三个隐形杀手

SSA里几乎所有操作都依赖排序后的索引,而NumPy的广播规则又很严格,容易出现“你以为是在逐个个体更新,实际上整个种群一起动了”的问题。

一个特别典型的错误是加入者更新里的伪逆:

a = rng.choice([-1, 1], size=dim) a_pinv = np.linalg.pinv(a.reshape(1, -1)).ravel()

如果你漏了.reshape(1, -1),np.linalg.pinv(a)求出来的是一个标量,而不是维度向量,后面做乘法时就会广播出完全错误的结果。这类错误不会直接报错,但会让你的结果莫名其妙地差。

5.3 除零问题的隐蔽性

侦察者更新里有一个分母fitness[idx] - fitness[worst_idx]。如果个体之间的适应度完全相等,这个分母就是0。实际优化中,尤其是Sphere这类函数收敛到极小值时,适应度之间差异极小,可能出现浮点溢出或者产生nan。

我建议在分母上加一个1e-12,同时用np.where把异常值替换成预设边界。这个小改动不会影响算法性能,但会让算法稳定性大幅提升。

5.4 随机种子、运行次数和“论文复现差异”

这是最让人头痛的问题。同一份代码,在不同机器、不同NumPy版本、不同Python版本上跑出来的结果可能有明显差异,这是因为底层BLAS库和浮点运算顺序不同。所以不要拿别人论文里的表格和自己电脑跑出来的结果硬比。

我的建议是:只看自己机器上的相对对比。SCSSA和SSA用同一套随机种子、同一套测试环境做对比,如果SCSSA在绝大多数函数上稳定领先,那结论就是成立的。绝对数值上的差异没有意义。

5.5 超参数不是越多越好

SCSSA比原版SSA多出了至少三个新参数:权重衰减范围、Cauchy变异概率、Cauchy变异尺度范围。如果你把每个参数都单独调一遍,很容易陷入“过拟合测试函数”的陷阱——这套参数在四个函数上表现很好,换到实际问题立刻翻车。

我个人在做改进算法时,会尽量固定新参数为常识性经验值:w_max=0.9、w_min=0.4、pc=0.3、scale=1→0.2。然后只针对一个真正需要调的参数做敏感性分析,比如变异概率。这样实验结论才可信,而且审稿人和同事都不会追问“为什么你加了这么多魔法数字”。

5.6 验证策略有效性的正确姿势

如果你想证明SCSSA里的某一个改进有效,最严谨的对照实验是“逐个开关”。比如:

  • 原版SSA:无混沌、无权重、无Cauchy。
  • SSA+混沌:只有初始化换成Sine。
  • SSA+混沌+权重:再叠加上自适应权重。
  • SCSSA:全部叠上。

每一步如果都能在大部分测试函数上带来提升,那你的论证才是扎实的。如果某一步加了之后反而变差,那说明这个策略在你的问题域里不适配,该换方案就换,不要硬吹。

6. 从复现到使用:一点个人体会

代码写到最后,我想多说一句和“神奇”有关的话。很多初学者觉得SCSSA“神奇”,是因为混沌映射、Cauchy分布这些名词听起来很高端。但实际动手复现之后你会发现,这些改进策略本质上都没有跳出“增加探索多样性、增强后期开发能力”这个框架。真正让算法变强的,其实是多个策略之间的配合,以及你对问题特性的敏感度。

我个人经验是,不要急着把SCSSA当成万能求解器。它最适合的场景是中小规模的连续优化问题,比如BP神经网络的权重初始化、SVM的惩罚参数和核参数整定、传感器部署这类目标函数复杂但维度不太高的问题。如果你手里的问题维数已经上千,优先考虑分治策略,而不是硬上单种群算法。

最后再分享一个实用技巧。如果你复现SCSSA后想在论文里展示收敛曲线,别只看最优值曲线,建议同时画出“平均适应度曲线”和“最差适应度曲线”。很多时候最优值曲线贴着地面,看起来两个算法都收敛了,但平均曲线和标准差才能反映算法稳定性。SCSSA的真正优势往往是标准差更小,这一步可能要花费不少时间,但会让你的实验结论扎实很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询