简介:这份资源面向需要掌握智能优化算法的学生、科研人员与工程实践者,聚焦神经网络与遗传算法协同求解非线性函数极值这一典型优化问题。非线性函数往往结构复杂、多模态,传统梯度法易陷入局部最优,而将神经网络的非线性拟合能力与遗传算法的全局搜索能力结合,可有效提升寻优质量。压缩包共12个文件,约100KB,以9个m脚本文件为主,另含2个mat数据文件和1个asv备份文件,涵盖神经网络建模、遗传算法框架、训练优化与结果评估等模块,脚本可直接运行调试。资源已有343人学习,适合作为课程设计、算法入门或工程优化的参考范例,帮助读者理解种群初始化、选择、交叉、变异与网络权重更新的完整流程,并在此基础上迁移到更复杂的非线性优化场景。
1. 神经网络遗传算法做函数极值寻优:为什么你手算的梯度总掉进局部最优
如果你曾经用梯度下降去求一个非线性函数的极值,大概率遇到过这种情况:换个初始点,结果就完全不一样,有时候收敛到一个明显不是全局最优的解,调学习率也没用。这不是代码写错了,而是梯度类方法天生只能做局部搜索。神经网络遗传算法函数极值寻优,本质上是用遗传算法的全局搜索能力去弥补梯度法的视野局限,再借助神经网络做函数曲面的拟合代理,把“求极值”这件事从解析推导变成可迭代的数值搜索。它适合两类人:一类是在做参数标定、控制器调优、实验设计时反复遇到多峰函数的工程师;另一类是刚接触智能优化、想找一个能跑通、能改参数、能看到中间过程的最小案例的开发者。非线性函数极值这个场景之所以经典,是因为它足够简单,你能手动画出曲面验证结果;又足够复杂,多峰、非凸、不可导的情况都能构造出来。下面我按“先跑通再调优”的顺序,把这个方案拆成可复现的步骤。
2. 神经网络遗传算法函数极值寻优的整体链路与选型理由
2.1 为什么不是纯遗传算法,也不是纯神经网络
纯遗传算法直接在函数上评估适应度,对非线性函数极值寻优是可行的,但有个硬伤:每一代都要对种群中每个个体调用一次目标函数。如果目标函数本身是仿真模型、查表插值或者一段耗时计算,几千次评估的代价就不可接受了。神经网络在这里的角色是代理模型,先用少量采样点训练一个网络去拟合函数曲面,之后遗传算法的适应度评估全部走网络前向推理,单次评估从毫秒级降到微秒级。纯神经网络做极值寻优的问题在于,它本质是拟合,不是搜索,训练完你只能拿到一个近似曲面,还得再在曲面上找极值,而且网络外推区域不可信。所以常见做法是:神经网络负责“快速评估”,遗传算法负责“全局搜索”,两者串起来。
2.2 代理模型精度和搜索效率的权衡
代理模型的精度直接决定遗传算法最终找到的极值是否可信。采样点太少,网络欠拟合,曲面太平,遗传算法会收敛到一个虚假的平滑极值;采样点太多,训练成本上升,而且遗传算法前期探索阶段其实不需要那么高的精度。我一般会分两阶段:第一阶段用较少的采样点(比如 200 到 500 个)训练一个粗代理,让遗传算法快速定位到极值所在的大致区域;第二阶段在那个区域附近加密采样,重新训练代理,再做一次局部精细搜索。这个思路和响应面优化里的“自适应采样”是一致的,只是实现上更轻量。
2.3 最小可跑通的目录结构和依赖
这个案例不需要复杂框架,Python 加 NumPy 就够。目录建议这样组织:
nn_ga_extremum/ ├── config.py # 函数定义、采样范围、网络和GA参数 ├── sample.py # 拉丁超立方采样生成训练数据 ├── surrogate.py # 神经网络代理模型训练与保存 ├── ga_search.py # 遗传算法在代理模型上寻优 └── run.py # 串联流程入口依赖只有 numpy 和 matplotlib,如果要画曲面验证结果就加上 matplotlib。不引入深度学习框架的原因是:这个规模的网络(两到三层全连接)用 NumPy 手写前向和反向传播完全够用,而且你能看清每一步在算什么,调参时不会变成黑匣子。
3. 用 NumPy 手写代理网络并跑通非线性函数拟合
3.1 目标函数定义与采样范围
先选一个经典的非线性多峰函数作为寻优对象。这里用二维的 Rastrigin 函数变体,它在定义域内有大量局部极小值,全局极小值在原点附近,非常适合验证算法是否跳出局部最优。
# config.py import numpy as np # 目标函数:二维Rastrigin变体,全局极小值在(0,0)附近 def target_func(x, y): A = 10 return (A * 2 + (x**2 - A * np.cos(2 * np.pi * x)) + (y**2 - A * np.cos(2 * np.pi * y))) # 搜索范围 X_RANGE = (-5.12, 5.12) Y_RANGE = (-5.12, 5.12) # 采样点数量 N_SAMPLE = 400 # 神经网络结构 HIDDEN_DIM = 64 EPOCHS = 3000 LR = 0.01这里target_func返回的是标量值,输入是两个独立变量。X_RANGE和Y_RANGE决定了采样和搜索的边界,遗传算法的初始种群也必须落在这个范围内。N_SAMPLE设 400 是一个经验值:二维输入下,400 个拉丁超立方采样点能覆盖得比较均匀,再少容易出现大片空白区域导致代理模型失真。
3.2 拉丁超立方采样生成训练集
随机均匀采样在二维下容易出现局部聚集,拉丁超立方采样能保证每个维度上的分层均匀性,对代理模型训练更友好。
# sample.py import numpy as np from config import X_RANGE, Y_RANGE, N_SAMPLE, target_func def latin_hypercube(n, dim): # 每个维度分成n个区间,每个区间内随机取一点,再打乱组合 intervals = np.linspace(0, 1, n + 1) points = np.random.uniform(intervals[:-1], intervals[1:], size=(n, dim)) for d in range(dim): np.random.shuffle(points[:, d]) return points def generate_data(): np.random.seed(42) unit_points = latin_hypercube(N_SAMPLE, 2) x = X_RANGE[0] + unit_points[:, 0] * (X_RANGE[1] - X_RANGE[0]) y = Y_RANGE[0] + unit_points[:, 1] * (Y_RANGE[1] - Y_RANGE[0]) z = target_func(x, y) # 输入归一化到[-1,1],输出也做标准化,加速训练 X = np.stack([x, y], axis=1) X_norm = 2 * (X - X_RANGE[0]) / (X_RANGE[1] - X_RANGE[0]) - 1 z_mean, z_std = z.mean(), z.std() Z_norm = (z - z_mean) / z_std return X_norm, Z_norm.reshape(-1, 1), z_mean, z_std if __name__ == "__main__": X, Z, zm, zs = generate_data() print("X shape:", X.shape, "Z shape:", Z.shape) print("z mean/std:", zm, zs)latin_hypercube的核心是先把每个维度分成 n 个等宽区间,在每个区间内随机取一个点,然后对每个维度的点做独立打乱,这样既保证分层均匀,又避免不同维度之间的相关性。输入归一化到 [-1,1] 是因为后面网络用 tanh 激活,输出标准化是为了让损失函数尺度一致。z_mean和z_std要保存下来,遗传算法找到归一化极值后需要反变换回真实函数值。
3.3 两层全连接网络的前向与反向实现
网络结构定为 2-64-64-1,激活函数用 tanh,输出层线性。手写反向传播的关键是把每一层的梯度推导清楚,尤其是 tanh 的导数。
# surrogate.py import numpy as np from config import HIDDEN_DIM, EPOCHS, LR class SurrogateNet: def __init__(self, input_dim=2): # 初始化权重,用He初始化适配tanh self.W1 = np.random.randn(input_dim, HIDDEN_DIM) * np.sqrt(2.0 / input_dim) self.b1 = np.zeros((1, HIDDEN_DIM)) self.W2 = np.random.randn(HIDDEN_DIM, HIDDEN_DIM) * np.sqrt(2.0 / HIDDEN_DIM) self.b2 = np.zeros((1, HIDDEN_DIM)) self.W3 = np.random.randn(HIDDEN_DIM, 1) * np.sqrt(2.0 / HIDDEN_DIM) self.b3 = np.zeros((1, 1)) def forward(self, X): self.z1 = X @ self.W1 + self.b1 self.a1 = np.tanh(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 self.a2 = np.tanh(self.z2) self.z3 = self.a2 @ self.W3 + self.b3 return self.z3 def backward(self, X, y_true, y_pred): m = X.shape[0] # 输出层梯度(MSE损失) dz3 = (y_pred - y_true) / m dW3 = self.a2.T @ dz3 db3 = np.sum(dz3, axis=0, keepdims=True) # 第二隐藏层 da2 = dz3 @ self.W3.T dz2 = da2 * (1 - self.a2 ** 2) dW2 = self.a1.T @ dz2 db2 = np.sum(dz2, axis=0, keepdims=True) # 第一隐藏层 da1 = dz2 @ self.W2.T dz1 = da1 * (1 - self.a1 ** 2) dW1 = X.T @ dz1 db1 = np.sum(dz1, axis=0, keepdims=True) return dW1, db1, dW2, db2, dW3, db3 def update(self, grads): dW1, db1, dW2, db2, dW3, db3 = grads self.W1 -= LR * dW1 self.b1 -= LR * db1 self.W2 -= LR * dW2 self.b2 -= LR * db2 self.W3 -= LR * dW3 self.b3 -= LR * db3 def train(X, Z, epochs=EPOCHS): net = SurrogateNet() losses = [] for epoch in range(epochs): y_pred = net.forward(X) loss = np.mean((y_pred - Z) ** 2) grads = net.backward(X, Z, y_pred) net.update(grads) losses.append(loss) if epoch % 500 == 0: print(f"Epoch {epoch}, loss={loss:.6f}") return net, lossesforward里把中间激活值保存下来供反向使用,这是手写反向传播的标准做法。backward中dz3除以 m 是因为损失对 batch 取了平均,梯度也要对应缩放。tanh 的导数是1 - a^2,这里直接用激活后的值算,比重新算1 - tanh(z)^2更省。学习率 0.01 配合 3000 轮,在这个规模的数据上通常能降到 1e-3 以下的 MSE。如果 loss 震荡,先把学习率降到 0.005 试试,不要急着加层。
3.4 训练过程监控与拟合效果验证
训练完不能只看 loss,要画一张预测曲面和真实曲面的对比图,肉眼确认代理模型有没有把多峰结构学出来。
# run.py 中的验证片段 import numpy as np import matplotlib.pyplot as plt from sample import generate_data from surrogate import train from config import X_RANGE, Y_RANGE, target_func X, Z, z_mean, z_std = generate_data() net, losses = train(X, Z) # 生成网格验证 grid = np.linspace(X_RANGE[0], X_RANGE[1], 80) gx, gy = np.meshgrid(grid, grid) grid_input = np.stack([gx.ravel(), gy.ravel()], axis=1) grid_norm = 2 * (grid_input - X_RANGE[0]) / (X_RANGE[1] - X_RANGE[0]) - 1 pred_norm = net.forward(grid_norm) pred = pred_norm * z_std + z_mean true = target_func(gx.ravel(), gy.ravel()).reshape(-1, 1) mse_grid = np.mean((pred - true) ** 2) print("Grid MSE:", mse_grid) fig, axes = plt.subplots(1, 2, figsize=(12, 5)) axes[0].contourf(gx, gy, true.reshape(gx.shape), levels=30, cmap="viridis") axes[0].set_title("True Surface") axes[1].contourf(gx, gy, pred.reshape(gx.shape), levels=30, cmap="viridis") axes[1].set_title("Surrogate Surface") plt.tight_layout() plt.savefig("surface_compare.png", dpi=120)网格 MSE 比训练 loss 更有参考价值,因为它是在未参与训练的均匀网格上评估的。如果网格 MSE 比训练 loss 大一个数量级以上,说明过拟合了,需要减少隐藏层维度或者增加采样点。等高线图能直观看出代理曲面是否保留了真实曲面的峰谷结构,如果代理曲面明显更平滑,遗传算法找到的极值位置就会偏移。
4. 遗传算法在代理模型上的寻优实现与参数设置
4.1 编码方式与种群初始化
遗传算法用实数编码,每个个体是一个二维向量,直接对应函数的两个自变量。实数编码比二进制编码更适合连续函数极值寻优,不需要解码,精度也不受编码长度限制。
# ga_search.py import numpy as np from config import X_RANGE, Y_RANGE POP_SIZE = 80 N_GEN = 150 CROSS_RATE = 0.8 MUT_RATE = 0.1 ELITE_NUM = 4 def init_population(pop_size): pop = np.random.uniform( [X_RANGE[0], Y_RANGE[0]], [X_RANGE[1], Y_RANGE[1]], size=(pop_size, 2) ) return pop种群大小 80 是在二维搜索空间下的经验值:太小容易早熟收敛,太大每代计算量上升但收益递减。精英保留数 4 表示每代直接复制最优的 4 个个体到下一代,防止交叉变异把已经找到的好解破坏掉。变异率 0.1 对实数编码来说偏高,但这里搜索空间只有二维,稍高的变异率有助于跳出局部最优。
4.2 适应度函数与代理模型推理
适应度直接取代理模型预测值的负数,因为遗传算法默认找适应度最大的个体,而我们要找函数极小值。
def fitness(pop, net, z_mean, z_std): # 归一化输入 pop_norm = 2 * (pop - X_RANGE[0]) / (X_RANGE[1] - X_RANGE[0]) - 1 pred_norm = net.forward(pop_norm) pred = pred_norm * z_std + z_mean return -pred.flatten() # 取负,把极小值问题转成极大值问题这里每次调用net.forward都是一次矩阵乘法,80 个个体一次前向在 NumPy 下不到一毫秒,所以整个遗传算法跑 150 代的总耗时主要花在训练网络上,搜索阶段几乎可以忽略。这也是代理模型方案的价值所在:如果直接调用target_func,虽然这个函数很快,但换成仿真模型后差距就是几个数量级。
4.3 选择、交叉与变异算子
选择用锦标赛选择,交叉用模拟二进制交叉,变异用多项式变异。这两个算子对实数编码的连续优化问题比较稳,不会像简单算术交叉那样快速丧失多样性。
def tournament_select(pop, fit, k=3): idx = np.random.choice(len(pop), k, replace=False) best = idx[np.argmax(fit[idx])] return pop[best].copy() def sbx_crossover(p1, p2, eta=15): # 模拟二进制交叉 u = np.random.rand(2) beta = np.where(u <= 0.5, (2 * u) ** (1 / (eta + 1)), (1 / (2 * (1 - u))) ** (1 / (eta + 1))) c1 = 0.5 * ((1 + beta) * p1 + (1 - beta) * p2) c2 = 0.5 * ((1 - beta) * p1 + (1 + beta) * p2) return c1, c2 def poly_mutation(ind, eta=20): for i in range(len(ind)): if np.random.rand() < MUT_RATE: u = np.random.rand() delta = np.where(u < 0.5, (2 * u) ** (1 / (eta + 1)) - 1, 1 - (2 * (1 - u)) ** (1 / (eta + 1))) ind[i] += delta * (X_RANGE[1] - X_RANGE[0]) * 0.1 return np.clip(ind, [X_RANGE[0], Y_RANGE[0]], [X_RANGE[1], Y_RANGE[1]])SBX 的分布指数 eta 越大,产生的子代越接近父代,探索能力越弱。eta=15 是一个偏探索的设置,适合前期。多项式变异的 eta=20 配合 0.1 的变异率,单次扰动幅度控制在搜索范围的 10% 以内,避免把个体直接甩出有希望的区域。np.clip保证变异后个体不越界,这比越界后重新初始化更温和。
4.4 完整迭代流程与收敛曲线
把上面的算子串起来,每代记录最优适应度和种群均值,用来判断是否收敛。
def run_ga(net, z_mean, z_std): pop = init_population(POP_SIZE) best_history = [] for gen in range(N_GEN): fit = fitness(pop, net, z_mean, z_std) best_idx = np.argmax(fit) best_history.append(-fit[best_idx]) # 精英保留 elite = pop[np.argsort(fit)[-ELITE_NUM:]].copy() new_pop = list(elite) while len(new_pop) < POP_SIZE: p1 = tournament_select(pop, fit) p2 = tournament_select(pop, fit) if np.random.rand() < CROSS_RATE: c1, c2 = sbx_crossover(p1, p2) else: c1, c2 = p1.copy(), p2.copy() c1 = poly_mutation(c1) c2 = poly_mutation(c2) new_pop.append(c1) if len(new_pop) < POP_SIZE: new_pop.append(c2) pop = np.array(new_pop[:POP_SIZE]) if gen % 30 == 0: print(f"Gen {gen}, best={-fit[best_idx]:.6f}") return pop, best_history精英保留用np.argsort(fit)[-ELITE_NUM:]取适应度最高的几个,注意这里 fit 已经是负的极小值,所以适应度越大代表函数值越小。收敛曲线如果在前 50 代就压平,说明种群多样性丢失太快,可以把变异率提到 0.15 或者把锦标赛规模从 3 降到 2。如果曲线一直缓慢下降不收敛,检查代理模型在边界区域的预测是否合理,遗传算法可能一直在边界附近试探。
5. 避坑与排查:代理模型和遗传算法联调时最容易翻车的五个点
5.1 代理模型在搜索边界外推导致极值位置偏移
现象:遗传算法找到的极值点落在搜索范围边界上,但真实函数在边界处并不是极小值。原因:采样点没有覆盖边界,代理模型在边界附近只能外推,预测值不可信,遗传算法却把外推的低值当成了真实极小值。解决:在采样时对边界附近加密,或者把搜索范围比采样范围缩小 5% 到 10%,让遗传算法在代理模型有数据支撑的区域里搜索。
5.2 输出标准化参数丢失导致反变换错误
现象:遗传算法输出的极值对应的函数值和真实函数值差了一个数量级。原因:训练时对输出做了标准化,搜索结束后忘记用z_mean和z_std反变换,或者反变换时用错了维度。解决:把z_mean和z_std和网络权重一起保存,反变换写成独立函数,每次调用只走这一个入口,不要在多处手写反变换公式。
5.3 种群初始化范围与采样范围不一致
现象:遗传算法前期收敛很快,但找到的解明显不是全局最优。原因:种群初始化用了默认的 [0,1] 范围,而函数定义域是 [-5.12, 5.12],大部分个体落在函数变化平缓的区域,选择压力把种群快速拉向局部。解决:初始化时显式用X_RANGE和Y_RANGE作为上下界,并在第一代打印种群坐标范围确认。
5.4 学习率过大导致代理模型欠拟合
现象:训练 loss 降到某个值后不再下降,网格 MSE 远大于训练 loss。原因:学习率 0.01 对这个网络规模偏大,权重在损失曲面上来回跳,无法进入较窄的极小值区域。解决:把学习率降到 0.005 或 0.001,训练轮数相应增加。判断依据是看 loss 曲线是否在后期还有明显震荡,如果有,先降学习率再考虑加层。
5.5 精英保留数量过多导致早熟收敛
现象:收敛曲线在前 20 代就压平,之后完全不变。原因:精英保留数设成了 10 以上,每代大部分个体直接复制上一代最优,交叉变异产生的新个体被挤出种群,多样性迅速丧失。解决:精英保留数控制在种群大小的 5% 到 10%,80 个种群保留 4 到 8 个比较合适。如果还是早熟,把锦标赛选择的 k 从 3 降到 2,降低选择压力。
6. 两阶段自适应采样:把极值精度再压一个数量级
第一轮跑通之后,你大概率会发现遗传算法找到的极值点和真实全局极小值点还有一点距离,这个距离主要来自代理模型的拟合误差。我一般会做第二轮:把第一轮遗传算法找到的最优个体附近区域作为重点,重新采样并训练一个局部代理模型,再在这个局部代理上做一次小范围搜索。具体做法是,以第一轮最优解为中心,取一个边长为原搜索范围 20% 的方形区域,在这个区域内用拉丁超立方采 200 个点,和第一轮的 400 个点合并,重新训练网络。第二轮训练时把学习率降到 0.002,训练轮数加到 5000,让网络在局部区域拟合得更细。第二轮遗传算法的种群初始化直接在第一轮最优解附近高斯扰动生成,种群大小可以降到 40,代数降到 80,因为搜索空间已经小了很多。两轮下来,极值位置的误差通常能从第一轮的 0.1 左右降到 0.01 以内。验证方法很简单:把两轮找到的极值点代入真实target_func,和理论全局极小值比较,同时画一张局部区域的真实曲面和代理曲面对比图,确认代理曲面在极值附近没有出现虚假的平坦区域。我自己的习惯是,每次改完采样策略或网络结构,先把第一轮结果和真实值对比一次,确认代理模型没有系统性偏移,再跑第二轮。这个顺序不能反,否则第二轮的局部搜索会建立在一个有偏差的代理上,越搜越偏。希望帮到你。
本文还有配套的精品资源,点击获取