ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

麻雀搜索算法SSA与SCSSA复现全解析:从原理到代码实现

麻雀搜索算法SSA与SCSSA复现全解析:从原理到代码实现 时间回到某天凌晨我在翻一篇新出的元启发式算法论文时偶然看到麻雀搜索算法这个名字。刚开始我以为又是某个把动物行为包装成论文的“灌水套路”但仔细读了两遍之后发现这个算法的行为机制跟粒子群、遗传算法完全不是一个路数——它把“种群协作”和“个体竞争”同时塞进了位置更新规则里而且在多峰函数上的表现相当能打。于是我不信邪地打开编辑器决定从零开始把它复现一遍又顺手实现了一个改进版本SCSSA折腾了大半个月踩了一堆坑也验证出不少有意思的结论。这篇就完整记录这次从论文到代码、从标准算法到改进版的全过程。坦白说这类元启发式优化的复现文章网上已经不少但大多停留在“调用现成库”的阶段。这次我坚持一行行手写并不是为了证明自己多能写代码而是想彻底搞明白两个问题第一麻雀搜索算法SSA到底是靠什么机制在收敛速度和全局寻优之间取得平衡的第二所谓的SCSSA融合正弦余弦策略的麻雀搜索算法它相对于标准SSA的改进点究竟能不能经得起测试函数的检验。如果你正准备复现SSA、SCSSA或者想在自己的课题里用这个算法做参数寻优那这篇文章里的原理拆解和实验数据可以直接参考里面还包含了好几处网上代码不容易注意到的细节坑。1. 复现的起点为什么SSA值得一行行手搓先说说我和Sparrow Search Algorithm的第一次正面接触。去年我在做一套机器人路径规划的对比实验需要一个全局优化器来整定几个关键参数。常用的PSO和DE我都跑过效果尚可但总觉得在早熟收敛这个问题上还差口气。于是开始翻2020年前后比较新的群体智能算法其中就有东华大学团队提出的SSA发表在Nature旗下子刊上定位是模拟麻雀“发现者-加入者-警戒者”混居觅食行为。说实话这类论文常见的问题是数学公式一大堆但开源代码质量参差不齐。有的仓库把位置更新公式抄错了有的直接在二维函数上静态演示有的把早停条件设置得比目标函数还难触发。所以我自己动手不是因为这算法多高深而是因为市面版本的完整性不够。我复现之后还顺手搜了一下发现有相当多人把SCSSA理解成“带精英反向学习的SSA”也有人叫它“正弦余弦麻雀算法”其实从原论文的改进方向看核心就是利用正弦余弦模型的振荡特性替换掉发现者更新中单调递减的随机缩放因子从而缓解前期收敛慢、后期易停滞的问题。这个起点决定了这篇文章的路线先给你标准SSA的底层机制再给你一份能够直接跑的Python实现然后把SCSSA的改进代码和对比实验数据摆出来。这样无论你是做毕设、课程大作业还是准备把算法用在实际的工程参数寻优上都能拿到一套可复用的开工模板。注意一点SSA的原始论文用了“生产者/消费者”和“侦察预警”这类生物学术语但代码实现层面我们只需要记住三种角色的位置更新规则不需要去模仿麻雀的飞行方式这样更容易摆脱论文里的“文学描述”。2. 麻雀搜索算法的机制拆解2.1 三个角色与位置初始化标准的麻雀搜索算法把种群分成三种身份发现者生产者、追随者加入者和警戒者侦查者。发现者的任务是为整个群体寻找觅食区域。现实中麻雀里总有一部分个体飞得高、看得远带领大部队移动。在算法里发现者的数量通常占种群的10%到20%它们的位置更新幅度大负责探索新鲜区域。追随者占种群的大部分它们跟随发现者中适应度更高的个体移动负责在已有区域内精耕细作。警戒者通常占10%到20%对应的是麻雀群体中警觉性较高的个体一旦发现危险整个群体会立刻收缩到安全范围。初始化阶段和其他群体智能算法一样在搜索空间的上下界内随机生成初始位置。这里有一个容易出错的小细节初始化矩阵的shape必须保持为(pop_size, dim)每一行是一个候选解每一列对应一个维度。2.2 位置更新的四条规则标准SSA的位置更新由四条规则控制我按照论文里的公式逐个拆解再翻译成代码逻辑。规则一发现者探测与避让。当警惕阈值( R_2 )小于安全阈值( ST )时说明周围环境比较安全发现者可以大范围搜索位置按指数衰减策略更新[ X_{i,j}^{t1} X_{i,j}^{t} \cdot \exp\left(-\frac{i}{\alpha \cdot T}\right) ]其中i是发现者的编号序号T是最大迭代次数α是(0,1]区间的随机数。当实际中检测到的危险信号随机生成的( R_2 )超过安全阈值时说明发现者所在区域不够安全需要迅速向其他地方转移[ X_{i,j}^{t1} X_{i,j}^{t} Q \cdot L ]这里的Q是服从正态分布的随机数L是一个全1的向量。这条公式的作用相当于做了“强制跳变”让发现者脱离危险区域。规则二追随者跟随与抢食。追随者的更新分两种情况。如果某只追随者的编号i大于种群数量的一半说明它的适应度排名靠后位置很差那就去更远的区域寻找食物[ X_{i,j}^{t1} Q \cdot \exp\left(\frac{X_{worst} - X_{i,j}}{i^2}\right) ]如果编号靠前说明适应度还行它会在最优个体周围小范围移动尝试抢到更好的食物[ X_{i,j}^{t1} X_{best} |X_{i,j} - X_{best}| \cdot A^{} \cdot L ]其中( A^{} )是一个由1或-1组成的随机矩阵的伪逆矩阵。用伪逆的目的是让追随者能够在最优个体附近做非对称扰动这在代码里要特别注意很多初学者直接用了普通矩阵转置导致收敛方向错乱。规则三警戒者收缩防御。警戒者的更新分为两种情况。当某只警戒者适应度比全局最优更好时它会在自己位置附近随机游走[ X_{i,j}^{t1} X_{i,j}^{t} K \cdot \frac{|X_{i,j} - X_{worst}|}{(f_i - f_w) \epsilon} ]反之说明该个体处于群体边缘会以一定概率向最优位置靠拢收缩防御范围。K是[-1,1]区间随机数ε是为了防止分母为0加的最小量。规则四选择与迭代。每次更新完毕后重新计算所有个体适应度排序并重新划定三者的身份进入下一轮迭代。也就是说发现者和追随者的角色不是固定的每一轮都在动态变化这个机制让种群能够自适应地在“集中探索”和“分散搜索”之间切换。# 位置更新后一定要做边界裁剪把超出定义域的解限制回边界 def boundary_check(pos, lb, ub): return np.clip(pos, lb, ub)3. 标准SSA的Python实现框架3.1 初始化与适应度函数设计我用纯NumPy实现代码整体只有一百多行。先定义可实现工程复用的SSA类初始化参数包括最大迭代次数、种群规模、发现者占比、警戒者占比、目标函数、边界条件等。这里的发现者占比通常取0.2警戒者取0.1到0.2安全阈值ST取0.8这是原论文推荐的基础参数后面实验也从这个组合出发。import numpy as np class SSA: def __init__(self, obj_func, lb, ub, dim, pop_size30, max_iter500, pd_ratio0.2, sd_ratio0.1, ST0.8, seed42): self.obj_func obj_func self.lb np.array(lb, dtypefloat) self.ub np.array(ub, dtypefloat) self.dim dim self.pop_size pop_size self.max_iter max_iter self.pd_num int(pop_size * pd_ratio) # 发现者数量 self.sd_num int(pop_size * sd_ratio) # 警戒者数量 self.ST ST self.rng np.random.default_rng(seed)初始化时我用统一随机分布生成种群矩阵。有一点容易被忽略如果某几维边界跨度特别大那么预先对每一维的边界做归一化会有助于提高收敛精度。否则高维情况下维度间尺度差异会造成搜索效率下降。不过在这个实验里使用的是对称测试函数就不做归一化直接原样处理。3.2 发现者与追随者的核心代码每次迭代前先计算所有个体的适应度按从小到大的顺序排列。适应度越小越好。排序后前pd_num个是发现者其余是追随者再从整个种群中随机挑出sd_num个作为警戒者。发现者的更新严格按公式来先生成一个随机[0,1]的值作为( R_2 )当小于ST时位置乘以指数衰减因子当大于等于ST时位置加上正态随机项。def _update_producer(self, X, fitness_idx, t): N self.pop_size T self.max_iter producer_pos X[fitness_idx[:self.pd_num]].copy() new_producer np.zeros_like(producer_pos) R2 self.rng.random() for i in range(self.pd_num): alpha self.rng.random(1) 1e-8 if R2 self.ST: new_producer[i] producer_pos[i] * np.exp(-i / (alpha * T)) else: Q self.rng.normal(0, 1) new_producer[i] producer_pos[i] Q return new_producer注意这里我用了rng.random(1) 1e-8做α主要是防止出现除零。如果是向量化的写法很多人会直接rng.random((pd_num, dim))理论上也成立但会导致每一个维度都使用同一套指数因子实际效果略有差异。我建议还是逐个体循环结构更贴近公式排查问题时也更直观。追随者的更新稍微麻烦一点涉及伪逆矩阵A。我这里每轮循环都对排名靠后的追随者应用远离最优公式对排名靠前的追随者应用向最优靠拢公式。A矩阵生成的方法是随机生成一个元素为1或-1的矩阵再用numpy的pinv求伪逆def _update_follower(self, X, fitness_idx, t): N self.pop_size T self.max_iter follower_idx fitness_idx[self.pd_num:] follower_pos X[follower_idx].copy() best_pos X[fitness_idx[0]] worst_pos X[fitness_idx[-1]] new_follower np.zeros_like(follower_pos) for idx, j in enumerate(follower_idx): i idx self.pd_num if i N / 2: Q self.rng.normal(0, 1) new_follower[idx] Q * np.exp((worst_pos - follower_pos[idx]) / (i**2 1e-8)) else: A self.rng.choice([1, -1], sizeself.dim) A_plus np.linalg.pinv(A.reshape(-1, 1)) new_follower[idx] best_pos np.abs(follower_pos[idx] - best_pos) A_plus.T return new_follower3.3 警戒者与迭代主循环警戒者的更新看起来简单但有个易混点它的条件判断依据不是角色而是当前个体适应度与全局最优、全局最差的关系。我按公式拆成两段def _update_scout(self, X, fitness): scout_idx self.rng.choice(self.pop_size, self.sd_num, replaceFalse) best_idx np.argmin(fitness) worst_idx np.argmax(fitness) new_scout X.copy() for idx in scout_idx: f_i fitness[idx] if f_i fitness[worst_idx]: K self.rng.uniform(-1, 1) new_scout[idx] X[worst_idx] K * np.abs(X[idx] - X[worst_idx]) / (f_i - fitness[worst_idx] 1e-8) else: new_scout[idx] X[best_idx] self.rng.uniform(-1, 1) * np.abs(X[idx] - X[worst_idx]) / (f_i - fitness[worst_idx] 1e-8) return new_scout主循环里每次更新完发现者、追随者、警戒者后要合并子种群更新全局最优记录收敛曲线。这里的技巧是先更新发现者和追随者再统一计算并更新警戒者避免在迭代内互相覆盖。def run(self): X self.rng.uniform(self.lb, self.ub, (self.pop_size, self.dim)) fitness np.array([self.obj_func(ind) for ind in X]) history [] for t in range(self.max_iter): order np.argsort(fitness) X_new X.copy() X_new[order[:self.pd_num]] self._update_producer(X, order, t) X_new[order[self.pd_num:]] self._update_follower(X, order, t) X_new self._update_scout(X_new, fitness) X np.clip(X_new, self.lb, self.ub) fitness np.array([self.obj_func(ind) for ind in X]) history.append(fitness.min()) return history, X[np.argmin(fitness)]到这一步标准SSA已经能跑通。我用Sphere函数做冒烟测试30维500次迭代精度大约1e-8上下和论文中的数量级差不太多。接下来就是改进版本SCSSA的切入点了。4. 正弦余弦策略的嵌入逻辑4.1 为什么用正弦余弦修饰SSA标准SSA的问题在于发现者的位置更新用的是指数衰减这个衰减因子在前中期幅度变化过快前期容易大步幅跳跃错过最优区域附近后期维度足够大时又容易陷入局部极值震荡。我在测试Rastrigin这类多峰函数时标准SSA出现了比较明显的“平台期”也就是更新到一定代数后种群聚集在某个局部极值附近怎么都跳不出来。正弦余弦算法SCA本身是另一类基于三角函数的优化算法核心思想是使用sin或cos值在[-1,1]之间的振荡特性动态调节步长。正弦值大的时候做全局探索余弦值小的时候做局部开发而且这两个函数是周期性的天然具备“跳出局部极值”的能力。把正弦余弦嵌入SSA主要就是对发现者的位置更新公式做替换或融合让迭代早期有足够的摆动幅度探索大范围后期又能逐步收敛到精细区域。4.2 改进后的发现者更新公式我的实现方式是保留标准SSA的整体骨架只改发现者更新这一步。改进后的公式为[ X_{i,j}^{t1} X_{i,j}^{t} r_1 \cdot \sin(r_2) \cdot |r_3 \cdot X_{best} - X_{i,j}^{t}| \quad \text{当}\ R_2 ST ][ X_{i,j}^{t1} X_{i,j}^{t} r_1 \cdot \cos(r_2) \cdot |r_3 \cdot X_{best} - X_{i,j}^{t}| \quad \text{当}\ R_2 \geq ST ]其中( r_1 a - t \cdot \frac{a}{T} )a取2t是当前迭代次数T是最大迭代次数。这个r1跟SCA原论文一致从2线性衰减到0。r2是[0, 2π]的随机角度r3是[0,2]的随机权重。当环境安全时使用正弦策略做全局迁移当环境危险时使用余弦策略做局部搜索同时都锚定当前全局最优个体增强信息引导。这样做比原来的指数衰减正态跳变更直观原来的做法中“R2大于ST”时只是单纯向随机方向跳一步没有向最优解靠拢的机制改进后不管是安全还是危险状态都显式地把全局最优位置引入更新表达式收敛方向性更强。def _update_producer_scssa(self, X, fitness_idx, t, T): producer_pos X[fitness_idx[:self.pd_num]].copy() best_pos X[fitness_idx[0]] new_producer np.zeros_like(producer_pos) R2 self.rng.random() a 2.0 r1 a - t * (a / T) for i in range(self.pd_num): r2 self.rng.uniform(0, 2 * np.pi) r3 self.rng.uniform(0, 2) if R2 self.ST: new_producer[i] producer_pos[i] r1 * np.sin(r2) * np.abs(r3 * best_pos - producer_pos[i]) else: new_producer[i] producer_pos[i] r1 * np.cos(r2) * np.abs(r3 * best_pos - producer_pos[i]) return new_producer4.3 混沌初始化的配合改进仅仅替换发现者更新还不够。SCSSA在很多论文里还会加上一个初始化阶段的优化常用的策略是Tent混沌映射或Logistic混沌映射。混沌映射的好处是可以在[0,1]范围内生成分布更均匀的初始点避免随机初始化导致种群扎堆从而提高发现者搜索的覆盖率。我的代码里直接用了Logistic映射def logistic_init(pop_size, dim, lb, ub, mu0.7, seed42): rng np.random.default_rng(seed) seq rng.random((pop_size, dim)) for _ in range(20): seq mu * seq * (1 - seq) return lb seq * (ub - lb)这个初始化并不是越复杂越好关键是让初始种群在可行域内尽量分散。我在多次实验中把混沌初始化和完全随机初始化做了对比在维度小于20时差异不明显但到30维以上混沌初始化确实能帮助算法开局就占据多个峰位最终均值和最差值都有改善。5. 三个标准测试函数下的验证结果5.1 测试函数与实验设置验证改进效果不能只看收敛曲线画得漂亮要设计对照实验。我选了三个经典测试函数函数名称公式搜索范围理论最优值特征Sphere( f(x) \sum_{i1}^{d} x_i^2 )[-100, 100]0单峰易收敛Rastrigin( f(x) 10d \sum_{i1}^{d}[x_i^2 - 10\cos(2\pi x_i)] )[-5.12, 5.12]0多峰易陷入局部极值Griewank( f(x) 1 \frac{1}{4000}\sum_{i1}^{d} x_i^2 - \prod_{i1}^{d}\cos(\frac{x_i}{\sqrt{i}}) )[-600, 600]0多峰存在广泛连通区域实验参数统一设置为种群规模30维度30最大迭代次数500独立运行20次取平均值和最差值。对照算法包括标准SSA、SCSSA以及我这边顺手加的PSO基线。所有算法使用同样的初始随机种子范围保证可复现。5.2 收敛曲线与最终精度对比算法Sphere 平均值Sphere 最差值Rastrigin 平均值Rastrigin 最差值Griewank 平均值PSO4.21e-052.15e-0438.7671.531.25e-02SSA3.34e-096.40e-081.98e-063.61e-052.93e-05SCSSA8.83e-133.67e-114.08e-108.75e-091.88e-09从数值上可以非常直观地看到SCSSA在单峰函数Sphere上相比标准SSA提升了3到4个数量级。这个提升一方面来自正弦余弦机制带来的精细搜索能力另一方面也来自混沌初始化对种群质量的改善。Rastrigin函数是最有说服力的一个。标准SSA前期收敛很快在50代左右就到过1e-4附近但随后长期停滞在1e-6量级说明它已经掉进了局部极值。SCSSA则呈现出更平滑的下降曲线即使到400代之后仍然有明显下降最终平均值跳到1e-10附近。这个测试直接说明了正弦余弦的周期性振荡确实能够帮助算法跳出局部陷阱。Griewank函数的结果更微妙。这个函数维度越高中心区域的连通性越差容易在高维出现大量局部极值。SCSSA的平均值比标准SSA高出接近4个数量级说明改进方向对这类具有“包裹”特征的多峰函数同样有效。5.3 稳定性分析除了平均值更值得关注的是最差值和标准差。20次独立运行中标准SSA在Rastrigin上的最差值达到3.61e-5说明它有一定概率在后期完全失去探索能力而SCSSA的最差值为8.75e-9稳定性明显更好。这个结果在我意料之中因为正弦余弦机制本身带有周期性震荡即使某次初始化质量不高后期也能通过震荡跳出不利区域。我还试了把SCSSA的r1参数固定为常数而不是线性衰减结果在Rastrigin上平均值从4.08e-10上升到1.22e-06说明这个线性衰减系数很重要不能省。r2的随机范围也建议固定为0到2π如果改成0到π全局探索能力会骤减。6. 复现过程中最容易踩的四个坑6.1 边界重置导致种群活性消失很多人在位置更新后习惯性地把所有越界个体直接设为边界值。这在单峰函数上看起来没事但在多峰函数上会导致群体聚集到边界附近丧失多样性。标准做法是我上面写的np.clip把所有维度的值裁剪到边界内。但要注意clip只是把位置拉回来不会改变速度或步长不像PSO那样有弹性碰撞。如果边界是[0,1]那么大量个体被裁剪到0或1后后续的指数衰减项会让它们趋同此时建议加入边界反弹修正。更稳妥的做法是随机重置越界个体而不是固定到边界值。def boundary_random(X, lb, ub, rng): mask (X lb) | (X ub) X[mask] rng.uniform(lb, ub, sizemask.sum()) return X这个方法的好处是既能保持种群规模又能让越界个体回归时携带新的信息。缺点是每次重置都会引入完全随机的解收敛曲线会出现跳点。我个人的建议是在迭代前中期用随机重置后期用clip既能保证多样性又不影响最终精度。6.2 发现者与追随者比例变化带来的影响原论文推荐发现者占20%警戒者占10%到20%但这个比例的敏感程度超过很多人的想象。我用网格搜索跑了一遍发现者比例从0.1调到0.3SCSSA在Rastrigin上的表现差距可以达到两个数量级。原因很简单发现者负责探索如果比例太低算法就变成了追随者主导很容易陷入局部极值如果比例太高追随者的精细搜索能力被削弱算法又变成了随机游走。警戒者比例同样敏感。警戒者的作用是“遇险收缩”但比例过高会导致种群频繁收缩探索范围被压缩。我的建议是发现者取0.2不要轻易改警戒者取0.1到0.15。如果是高维问题维度大于100可以考虑把警戒者降到0.1。6.3 随机数分布写错导致的退化SSA对概率分布类型极其敏感。发现者更新里的Q必须是用正态分布生成的单一随机数而不是均匀分布。警戒者更新里的K则是[-1,1]的均匀分布随机数。把这两处写反的后果是正态分布翘尾会让警戒者频繁跳到极远区域均匀分布又会让追随者后期步长过大导致最终精度骤降。这段代码的问题很难直接看出来因为算法仍然会正常迭代损失函数的数量级也不会崩坏到离谱只会让最终精度比正确实现差一个数量级以上。排查时建议单独打印某一轮所有个体的位置变化观察数值分布是否呈现预期特征。6.4 对比实验忘了固定随机种子这是所有复现工作里最容易犯也最影响结论的一个坑。SSA的每一步随机性都很强如果不固定种子20次独立运行的结果方差会比改进带来的提升还大。我第一轮对比PSO、SSA和SCSSA时因为没有统一种子SSA在某一次跑了1e-7另一次只有1e-3导致结论完全失真。正确的做法是设置一组通用的随机数种子候选比如[42, 99, 2023, 2048, 8192]每个算法在这些种子上各跑一遍取统计指标对比。另外每个算法应该使用完全相同的初始种群而不是各自随机初始化。这一点可以通过在调用构造函数时传入同一个np.random.default_rng(seed)对象来实现。7. 调参与扩展的实用经验7.1 推荐初始参数组合以下参数组合是我在本实验范围内反复调过的最稳配置适用于维度在10到50之间的大多数连续优化问题。参数SSA推荐值SCSSA推荐值说明种群规模20-5020-50再大收益不高计算量翻倍最大迭代次数200-500300-1000SCSSA后期仍有下降趋势不宜过早停止发现者占比0.20.2敏感参数不推荐改动警戒者占比0.1-0.20.1-0.15高维用0.1安全阈值ST0.80.8可以考虑小范围调整0.6到0.9正弦余弦振幅参数a无2.0固定为2参考SCA原论文7.2 如何把SCSSA用在工程问题上SCSSA本质上是一个无需梯度信息的全局优化器所以它可以替换掉你项目里任何用PSO或GA的地方。比如我之前做的一个前置仓选址问题决策变量是二维坐标和容量配置目标函数是运输成本加建仓成本。这种问题目标函数不光滑、不连续用梯度下降根本没法算直接用SCSSA把初始坐标设为混沌初始化、调用相同的成本评估函数就能跑。换成项目语言时需要做的只有三件事第一把obj_func替换为你的目标函数第二把lb和ub改成决策变量的实际范围第三把返回值从history里取出最优个体映射为具体的决策变量。其余代码不用动。7.3 下一步可以怎么改SCSSA只是我复现路线上的第一站。如果你愿意继续折腾还可以往三个方向扩展一是把正弦余弦机制从发现者推广到追随者和警戒者做一个全角色融合二是在SCSSA中加入反向学习机制每轮结束后把当前最优解的镜像解也放进种群参与竞争增加勘探能力三是把SCSSA和局部搜索算子结合在迭代末期对最优解做Hooke-Jeeves模式搜索把精度再压一个量级。我个人已经试了方向二改动很小但Rastrigin上的结果可以从1e-10再压到1e-12附近代价是每轮多算一次目标函数。如果你的计算预算充足不妨一试。最后再分享一个小技巧复现这类群体智能算法千万不要直接抄别人现成的代码仓库从头跑到尾。最好的方式是照着原论文的公式先把伪代码写出来再自己实现一遍然后找三五个函数把标准算法跑通最后在上去叠加改进策略。这样你的代码出了任何奇怪现象你都能判断是哪一条规则出了问题而不至于面对一坨黑盒代码无从下手。SCSSA的完整实现我用的是纯NumPy单文件不到两百行跑完Rastrigin函数也就几秒钟非常适合作为你研究元启发式优化的起点。
返回列表