做优化算法实验的朋友应该都有这种体验:灰狼优化算法看着简单,跑起来却很容易“早熟”。我第一次拿标准 GWO 跑 Ackley 函数时,前 20 次迭代看着收敛曲线漂亮得很,后面却卡在局部极值附近纹丝不动。后来我翻了不少改进论文,发现大家都喜欢在反向学习上做文章,尤其是基于透镜成像的反向学习策略,思路很有意思。于是我把透镜成像反向学习引入到 GWO 里,顺手把平时一直没想清楚的参数 C 也给重新设计了一遍,最后得到了一个基于透镜成像学习策略的灰狼优化算法(Lens Imaging Learning GWO,简称 LIL-GWO)。
本文就围绕这套算法的设计、Python 实现和实验验证展开。内容适合以下三类人:一是刚接触智能优化算法、想看懂 GWO 并尝试改进的在读学生;二是想在自己的项目里快速替换标准优化器、提升收敛精度的工程师;三是对反向学习、透镜成像这类“算子级改进”感兴趣、希望得到可复现代码的算法研究者。整体没有特别深的数学门槛,核心只需理解透镜成像高中物理那点比例关系,以及灰狼的位置更新公式。
标题里那句“首创参数 C 策略”值得先解释清楚。标准 GWO 中的参数 C 只是生成一个 [0, 2] 区间内的随机权重,用来给 α、β、δ 狼的引导加一点不确定性。我在这个算法里没有再让它纯随机,而是基于狼群当前透镜反向距离和迭代进度来动态调节,形成一种“自适应动态 C 策略”。这样一来,前期 C 的随机扰动更强,利于大范围探索;后期 C 逐渐收敛到小扰动区间,帮助狼群在最优解周围精细搜索。下面我会把完整逻辑和 Python 代码都交出来,并给出我在 Sphere、Rastrigin、Ackley 和 Griewank 四个标准函数上的对比结果。
1. 灰狼优化算法与透镜成像的设计思路
1.1 标准灰狼优化算法的框架与痛点
灰狼优化算法是 Mirjalili 在 2014 年提出的一种群智能优化方法,核心模仿自然界灰狼的等级制度与狩猎过程。狼群按照适应度从优到差被划分为四等:头狼 α、副手 β、下层 δ,剩下全是 ω。在算法里,α、β、δ 的角色相当于当前的三个最优解,ω 则承担群体搜索任务。每一轮迭代,其他狼都会向 α、β、δ 所在位置的方向靠近,同时引入随机扰动来避免搜索陷入单一区域。
标准 GWO 的位置更新可以拆成三个式子。先计算当前个体到三只引导狼的距离:
D_α = |C_1 · X_α(t) − X_i(t)| D_β = |C_2 · X_β(t) − X_i(t)| D_δ = |C_3 · X_δ(t) − X_i(t)|
然后得到三个引导分量:
X_1 = X_α − A_1 · D_α X_2 = X_β − A_2 · D_β X_3 = X_δ − A_3 · D_δ
最终新位置取三者的平均值,即 (X_1 + X_2 + X_3) / 3。A 和 C 是两个核心系数,其中 A 由参数 a 控制,a 从 2 线性衰减到 0,A = 2·a·r1 − a。A 的绝对值大小决定狼是“逼近猎物”还是“远离猎物”,本质上控制局部开发与全局探索的切换。而 C = 2·r2,只是区间 [0, 2] 上的均匀随机数,象征自然界中诸如风向、障碍物之类的随机干扰,它用来给引导位置添加不确定性,防止狼群过于集中。
很多初学者容易忽略 C 的作用,其实它很关键。如果 C 固定为 1,那么 D_α 就变成当前个体与 α 狼的绝对距离,搜索过程会迅速压缩到三个引导狼的连线附近。加上随机性之后,D 会在一定程度上被放大或缩小,相当于在引导方向上增加了一个随机的“正弦震荡”,这对避免过早收敛很重要。标准 GWO 的第二个痛点是,它没有显式的跳出局部最优机制。单个狼一旦被多峰函数的某个局部极值吸引,就很难靠自身更新挣脱出来,整群狼会在 α、β、δ 的带领下集体向错误区域收缩。
1.2 透镜成像学习为什么比普通反向学习更适合
反向学习(Opposition-Based Learning,OBL)是改善群智能算法多样性最常用的手段之一。它的基本思想很简单:假设当前解是 x,那么我们在解的取值范围内计算它的反向点 x' = a + b − x,然后比较 x 和 x' 的适应度,保留更好的。这个操作的意义在于,如果当前解落在搜索空间的边缘,那么它的反向点往往落在空间的另一侧,可以补充种群对未探测区域的覆盖。
但标准 OBL 有一个明显缺陷:反向点完全取决于边界端点,没有任何可调节的灵活性。碰到搜索空间不对称、或者最优解本身就在边界附近的问题时,盲目做反向反而可能把解推向错误方向。透镜成像学习策略正是从这个痛点出发做的改进。
透镜成像利用了光学里的物像关系。中学物理告诉我们,凸透镜成像时物距、像距和焦距满足 1/u + 1/v = 1/f,而像的高度 h' 与物体高度 h 之比等于像距与物距之比。放到优化算法里看,可以把当前解 X_j 看作物体位置,把搜索区间的中点 (a_j + b_j)/2 看作透镜光轴位置,把待生成的反向解 X_j* 看作像的位置。经过比例关系推导,可以得到下面的更新公式:
X_j* = (a_j + b_j) / 2 + (a_j + b_j) / (2·k) − X_j / k
其中 k 是控制“透镜缩放程度”的因子。当 k = 1 时,公式会退化成 X_j* = a_j + b_j − X_j,也就是标准 OBL;当 k 小于 1 时,反向解会落在比标准反向点更远的位置,能够越出原区间,搜索半径更大;当 k 大于 1 时,反向解会向区间中心收缩,更适合后期做局部精细搜索。
我个人的实现习惯是让 k 随迭代次数从 0.6 缓慢增加到 1.3。前期用较小的 k 产生大幅“跳出”的反向解,增强探索;后期用较大的 k 让反向解贴近原解附近,起到局部微调的效果。相比固定边界镜像的 OBL,这种连续可调的机制灵活得多,也更容易嵌入到 GWO 这类位置更新规则的算法中。
1.3 首创参数 C 策略到底改了什么
这里的参数 C 策略,是我在这个项目里尝试的一个改动。标准 GWO 中 C 只管随机,和迭代阶段、种群状态没有任何关系。我把它改成了一种受“透镜反向距离”和“迭代进度”共同控制的自适应策略,主要分为三步。
第一步,计算全局透镜反向距离比率 ρ。在第 t 次迭代,先对每个搜索维度的种群位置求透镜反向解,再统计所有狼当前解与其反向解之间的平均距离,然后除以搜索空间的尺度(ub − lb),得到归一化值 ρ。ρ 越大,说明狼群整体分散度还很高,反向解与当前解的跨度大,狼群仍处在探索期;ρ 越小,说明反向解已经基本贴近原解,种群逐步收敛到某一区域。
第二步,引入迭代阶段调制系数。设 w(t) = 1 − 0.5·t/T,它在算法前期接近 1,后期接近 0.5。这个系数的意义是,即使某个阶段 ρ 计算出来还比较大,随着迭代推进,整体随机扰动也要刻意削弱,保证算法在后期有足够的收敛性。
第三步,把两部分合并成动态 C 值:
C_i(t) = 2 · rand · (0.5 + 2·ρ_i(t) · w(t))
其中 ρ_i(t) 是个体 i 的当前解与其透镜反向解的归一化距离。当 ρ_i 处于高水平且迭代刚开始(w 接近 1)时,C 容易超过 1.5,狼群收到强随机扰动,探索力度大;当 ρ_i 变低且迭代接近尾声(w 接近 0.5)时,C 被压缩到 1.0 以下,扰动减小,狼群更专注于向引导狼位置收敛。
这套策略在设计上和别的改进不太一样:它不是额外引入一组参数来手动调大小,而是直接用当前种群的几何状态反过来调节 C。相当于让算法多了一层内部反馈:解越分散,扰动越强;解越集中,扰动越弱。从实验结果看,这套策略对多峰函数的收敛精度提升非常明显,后面实验部分会给出具体数据。
2. Python 完整实现与踩坑实录
2.1 基础 GWO 骨架搭建
为了让你能直接复制运行,我用 Python 3.9 + NumPy 写了一个完整实现。先搭标准 GWO 骨架。
import numpy as np def objective_sphere(x): return np.sum(x ** 2) class GWO: def __init__(self, obj_func, dim, lb, ub, n_wolves=30, max_iter=500, seed=None): self.obj_func = obj_func self.dim = dim self.lb = np.array(lb) if isinstance(lb, (list, tuple)) else np.full(dim, lb) self.ub = np.array(ub) if isinstance(ub, (list, tuple)) else np.full(dim, ub) self.n_wolves = n_wolves self.max_iter = max_iter self.rng = np.random.default_rng(seed) def init_population(self): return self.rng.uniform(self.lb, self.ub, size=(self.n_wolves, self.dim)) def update_position(self, X, alpha_pos, beta_pos, delta_pos, a): A1, C1 = 2 * a * self.rng.random(self.dim) - a, 2 * self.rng.random(self.dim) A2, C2 = 2 * a * self.rng.random(self.dim) - a, 2 * self.rng.random(self.dim) A3, C3 = 2 * a * self.rng.random(self.dim) - a, 2 * self.rng.random(self.dim) D_alpha = np.abs(C1 * alpha_pos - X) D_beta = np.abs(C2 * beta_pos - X) D_delta = np.abs(C3 * delta_pos - X) X1 = alpha_pos - A1 * D_alpha X2 = beta_pos - A2 * D_beta X3 = delta_pos - A3 * D_delta return (X1 + X2 + X3) / 3.0 def run(self): wolves = self.init_population() fitness = np.array([self.obj_func(w) for w in wolves]) order = np.argsort(fitness) alpha_pos = wolves[order[0]].copy() alpha_score = fitness[order[0]] beta_pos = wolves[order[1]].copy() beta_score = fitness[order[1]] delta_pos = wolves[order[2]].copy() delta_score = fitness[order[2]] for t in range(self.max_iter): a = 2 - 2 * t / self.max_iter for i in range(self.n_wolves): new_pos = self.update_position(wolves[i], alpha_pos, beta_pos, delta_pos, a) new_pos = np.clip(new_pos, self.lb, self.ub) new_fit = self.obj_func(new_pos) if new_fit < fitness[i]: wolves[i] = new_pos fitness[i] = new_fit order = np.argsort(fitness) if fitness[order[0]] < alpha_score: alpha_pos = wolves[order[0]].copy() alpha_score = fitness[order[0]] if fitness[order[1]] < beta_score: beta_pos = wolves[order[1]].copy() beta_score = fitness[order[1]] if fitness[order[2]] < delta_score: delta_pos = wolves[order[2]].copy() delta_score = fitness[order[2]] return alpha_pos, alpha_score这段代码有两个容易踩的坑。第一,很多人喜欢把三只引导狼的更新放在同一个 for 循环里,导致 α 位置在循环中途变了,后面的个体使用了“已经更新过”的 α。虽然结果不一定差,但从复现严谨性角度,建议把三个引导位置先保存下来,所有个体使用同一时刻的 α、β、δ。第二,边界裁剪必须放在适应度计算之前,否则会产生大量非法解,函数复杂度高的时候会把收敛曲线带歪。
2.2 透镜成像反向学习算子实现
透镜成像反向学习算子的核心就一个公式,但有几个细节要处理。首先是缩放因子 k 的调度。我采用随迭代次数变化的线性策略:
def lens_opposite(self, X, t, k_init=0.6, k_end=1.3): k = k_init + (k_end - k_init) * t / self.max_iter mid = (self.lb + self.ub) / 2.0 X_opp = mid + mid / k - X / k return np.clip(X_opp, self.lb, self.ub)这里要把 k 的计算放在循环外,避免每次个体更新都重新算一次。更关键的是,透镜反向解计算完之后需要做边界检查。由于当 k < 1 时反向解很容易越过搜索区间的边界,直接保留超界解会让算法在无效区域疯狂试探。我一般用 np.clip 把解拉回边界,同时配合下一小节的自适应 C 策略使用。
算子怎么嵌入迭代流程?我采用的是“竞争选择”策略:每个个体每轮先用灰狼位置更新公式得到一个候选位置,接着生成透镜反向解,然后让原始候选解和反向解同时计算适应度,留下更好的一方。这一步增加的计算量大约是每轮多算 n_wolves 次目标函数,我实测下来,在 Ackley 和 Griewank 这类比较复杂的函数上,增加的成本在 10% 到 20% 之间,但收敛精度提升显著,属于性价比很高的改进。
2.3 参数 C 策略的编码方式
参数 C 策略需要实时统计狼群反向距离,所以不能像标准 GWO 那样把 C 当成孤立随机数。我单独封装了一个方法:
def adaptive_c(self, wolves, t): mid = (self.lb + self.ub) / 2.0 k = 0.6 + 0.7 * t / self.max_iter dists = [] for w in wolves: opp = mid + mid / k - w / k dist = np.linalg.norm(opp - w) / np.linalg.norm(self.ub - self.lb) dists.append(dist) rho = np.mean(dists) w_t = 1 - 0.5 * t / self.max_iter base = 2 * self.rng.random(self.dim) C = base * (0.5 + 2.0 * rho * w_t) return np.clip(C, 0.1, 2.0)方法里先计算全局平均归一化反向距离 rho,再和迭代衰减系数 w_t 结合,最后乘以基础随机数得到维度独立的 C 向量。这里有三个参数我建议你不要随便改:0.5 是随机数的基础保底,2.0 是距离增益系数,0.1 是 C 的取值下界。C 取 0 会导致距离项 D 变成纯绝对距离,失去扰动意义;C 超过 2 则随机性过大,后期难以收敛。实测下来 [0.1, 2.0] 这个区间最稳。
2.4 完整算法主体与调用示例
把上面几个部分合并起来,就是完整的 LIL-GWO。子类直接复用父类的__init__,这样 seed、边界、种群规模这些参数都统一管理,不会出现两套随机源打架的问题。
class LensImagingGWO(GWO): def lens_opposite(self, X, t): k = 0.6 + 0.7 * t / self.max_iter mid = (self.lb + self.ub) / 2.0 return np.clip(mid + mid / k - X / k, self.lb, self.ub) def adaptive_c(self, wolves, t): mid = (self.lb + self.ub) / 2.0 k = 0.6 + 0.7 * t / self.max_iter dists = [] for w in wolves: opp = mid + mid / k - w / k dist = np.linalg.norm(opp - w) / np.linalg.norm(self.ub - self.lb) dists.append(dist) rho = np.mean(dists) w_t = 1 - 0.5 * t / self.max_iter base = 2 * self.rng.random(self.dim) return np.clip(base * (0.5 + 2.0 * rho * w_t), 0.1, 2.0) def run(self): wolves = self.init_population() fitness = np.array([self.obj_func(w) for w in wolves]) order = np.argsort(fitness) alpha_pos, alpha_score = wolves[order[0]].copy(), fitness[order[0]] beta_pos, beta_score = wolves[order[1]].copy(), fitness[order[1]] delta_pos, delta_score = wolves[order[2]].copy(), fitness[order[2]] for t in range(self.max_iter): a = 2 - 2 * t / self.max_iter C_adaptive = self.adaptive_c(wolves, t) for i in range(self.n_wolves): X = wolves[i] C1, C2, C3 = C_adaptive, C_adaptive, C_adaptive A1 = 2 * a * self.rng.random(self.dim) - a A2 = 2 * a * self.rng.random(self.dim) - a A3 = 2 * a * self.rng.random(self.dim) - a D_alpha = np.abs(C1 * alpha_pos - X) D_beta = np.abs(C2 * beta_pos - X) D_delta = np.abs(C3 * delta_pos - X) X1 = alpha_pos - A1 * D_alpha X2 = beta_pos - A2 * D_beta X3 = delta_pos - A3 * D_delta candidate = (X1 + X2 + X3) / 3.0 candidate = np.clip(candidate, self.lb, self.ub) opp = self.lens_opposite(X, t) fit_candidate = self.obj_func(candidate) fit_opp = self.obj_func(opp) if fit_candidate < fit_opp: if fit_candidate < fitness[i]: wolves[i] = candidate fitness[i] = fit_candidate else: if fit_opp < fitness[i]: wolves[i] = opp fitness[i] = fit_opp order = np.argsort(fitness) if fitness[order[0]] < alpha_score: alpha_pos = wolves[order[0]].copy() alpha_score = fitness[order[0]] if fitness[order[1]] < beta_score: beta_pos = wolves[order[1]].copy() beta_score = fitness[order[1]] if fitness[order[2]] < delta_score: delta_pos = wolves[order[2]].copy() delta_score = fitness[order[2]] return alpha_pos, alpha_score调用示例很简单:
def objective_rastrigin(x): d = len(x) return np.sum(x**2 - 10 * np.cos(2 * np.pi * x)) + 10 * d model = LensImagingGWO( obj_func=objective_rastrigin, dim=30, lb=-5.12, ub=5.12, n_wolves=30, max_iter=500, seed=42 ) best_pos, best_val = model.run() print(f"最优解: {best_val:.6e}")我把自适应 C 一次性算成维度相同的向量,再让三个引导方向共用同一组 C。这个取舍算是一个权衡:理论上应该分别生成三组 C,但因为 C 的目标是给距离项加扰动,维度内独立、维度间部分共享并不会明显影响寻优能力,反而减少了随机数开销和代码混乱度。
3. 在标准测试函数上的实验表现
3.1 实验设置与测试函数说明
实验我选了四个标准测试函数:Sphere(单峰、最容易)、Rastrigin(强多峰、经典陷阱函数)、Ackley(多峰且具有多个局部最优)、Griewank(多峰,维度间存在乘积耦合)。为了保证公平,所有算法都统一用 30 维,种群规模 30,最大迭代 500 次,随机种子一致,每个函数独立运行 20 次取平均最优值。
Sphere 函数公式是 f(x) = Σx_i²,全局最优在原点,值 0。Rastrigin 是 f(x) = Σ(x_i² − 10·cos(2πx_i)) + 10·d,全局最优也在原点,但周围布满大量局部极值。Ackley 和 Griewank 的公式也比较常用,这里不展开,重点是看算法在不同地形下的表现差异。
3.2 实验结果对比
我对比了标准 GWO、带标准 OBL 的 GWO(OBL-GWO)和本文的透镜成像学习 GWO(LIL-GWO),结果如下表:
| 测试函数 | 标准 GWO | OBL-GWO | LIL-GWO(本文) |
|---|---|---|---|
| Sphere | 2.31e-28 | 1.06e-31 | 1.74e-37 |
| Rastrigin | 9.62e-01 | 7.84e-02 | 3.23e-06 |
| Ackley | 1.48e-03 | 6.57e-04 | 1.03e-10 |
| Griewank | 1.96e-02 | 8.33e-03 | 1.57e-05 |
这些数值是 20 次运行的最优值均值。单峰函数 Sphere 上三者差距没有特别大,说明基础 GWO 本来就能很好处理这类简单地形;多峰函数上差距就非常明显了,尤其是 Rastrigin,LIL-GWO 比标准 GWO 提升了约五个数量级,比简单叠加 OBL 的版本也提升了三个多数量级。Ackley 同样从 10⁻³ 量级直接压到 10⁻¹⁰ 量级,说明透镜成像反向学习和动态 C 策略确实能帮助种群跳出局部最优。
3.3 收敛性分析与可复现性
从收敛曲线看,LIL-GWO 前期下降速度和标准 GWO 基本相当,中期(大约 100 到 250 次迭代)会把对方拉开,后期则凭借动态 C 策略在最优解附近保持精细搜索。标准 GWO 在 300 次迭代之后经常会陷入一个平台期,而 LIL-GWO 因为反向解的存在,种群多样性在后期依然能得到补充,平台期出现得更晚,突破能力也更强。
需要强调一点:这类群智能算法每次运行的结果都有随机性,单次结果并不可靠。我代码里保留了 seed 参数,就是为了方便你复现博客里的表格数据。如果你要对比算法,建议至少跑 20 次以上,记录均值、标准差和中位数,像我用 20 次均值这样的统计口径才是稳妥的。
4. 常见问题与调试经验速查
4.1 常见问题速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 收敛结果不升反降 | k 的初始值太大,反向解始终在原解附近 | k 初始值改为 0.5 以下,保证前期的跳出能力 |
| 迭代后期仍剧烈震荡 | C 没有随 t 衰减,随机扰动始终很强 | 检查 w_t 系数是否计算正确,确认迭代进度传入 |
| 适应度函数调用次数激增 | 候选解和反向解都计算了适应度 | 这是预期行为,如需减少次数可只对一半个体做透镜反向竞争 |
| 结果与本文不一致 | 随机种子、种群初始化方式或维度不同 | 确保 seed、n_wolves、max_iter 完全一致 |
| 反向解大量越界 | k < 1 时反向解天然会越过边界 | 用 np.clip 裁剪,并以裁剪后的解参与竞争选择 |
4.2 调试建议与独家技巧
调试这类算法,我最推荐的办法是把收敛曲线直接画出来,配合每轮种群的平均距离一起看。平均距离突然归零说明种群已经收敛到极小区间,这时候如果最优值还差得远,通常是参数 C 策略没起作用或者透镜反向解没有被真正竞争保留。还有一个小技巧:把 k 和 C 的实时数值打印到日志里,观察它们是否按预期随迭代变化。我曾经遇到过一次 C 始终等于 2 的 bug,查了半天才发现是自适应 C 函数里没有传入当前迭代次数 t,导致 w_t 永远停在初始值 1。
另外,选择测试函数时不要只看最终的数值,也要看运行时间。一维函数测试效果不明显,建议直接用 10 维或 30 维的多峰函数来调试。加深个体竞争选择时,要注意 numpy 的广播机制:lb 和 ub 如果是标量,np.linalg.norm(self.ub - self.lb) 没有问题;换成向量边界时,务必确认维度能广播成功,否则会引入非常隐蔽的 bug。
如果在嵌入式或者计算资源受限的环境里使用,可以把透镜反向解的计算改成批量矩阵形式,避免 for 循环逐个体操作。我后来用类似向量化的写法,单轮迭代耗时大约减少了三分之一。虽然测试函数本身不怎么耗时,但是换成工程里的仿真模型,目标函数一次评估可能就是几秒甚至几分钟,这种优化就很有价值。
5. 最后分享一点个人心得
如果只用一个词总结这个项目的收获,那就是“反馈”。我们太习惯把优化算法当成一组固定规则的叠加,却忽略了种群自身的状态信息其实可以反哺到控制参数上。参数 C 策略的本质就是把狼群的分散程度反馈回扰动强度,透镜成像则是在算子层面给这种反馈提供了更灵活的操作空间。近几年类似的自适应算子越来越流行,我觉得思路是一致的:真正有价值的改进,不是盲目堆参数、叠算子,而是让算法内部形成闭环,让每个设计都有明确的物理或几何动机,并能用实验数据验证。
我的建议是,拿到这套代码后先把标准 GWO 跑通,再逐步打开透镜反向和动态 C 两个模块,分别观察它们对结果的影响。这般拆解虽然多花一点时间,但远比直接跑完整算法更容易看清每个算子的真实贡献。后续你还可以在这个基础上尝试把 k 的调度改成非线性曲线,或者把自适应 C 的反馈源从全局均值换成个体局部邻居距离,都是很自然的扩展方向。真正动手做一遍,你才会发现改进一个经典算法的乐趣其实不在结果里,而在每一次调试中真正理解它的过程里。