GA-LSTM实战:用遗传算法自动搜索LSTM最优超参数
简介这是一份基于遗传算法与长短时记忆网络相结合的时间序列预测代码面向有一定深度学习基础、希望自动寻找最优参数的开发者。项目利用遗传算法的全局搜索能力自动优化长短时记忆网络的权重与超参数取代传统的人工试错适合销量预测、股票走势预测等序列回归任务。压缩包共含四个文件包括两个源码文件和两个编译后的字节码文件整体仅八千字节轻量便携源码中分别实现了数据加载、网络构建和遗传算法进化流程用户只需修改数据读取部分即可适配自己的输入数据。目前已有六千九百六十四人学习使用代码预设了种群初始化、交叉变异、多代迭代与性能评估等完整环节运行后能直观观察损失下降趋势理解两种算法如何协同工作。通过这份资源读者可以掌握一套可复用的进化优化网络框架为后续研究或工程实践提供高效参数调优参考。1. GA-LSTM 是什么把超参数调优交给遗传算法GA-LSTM 这个项目核心一句话用遗传算法去搜 LSTM 的超参数搜到最优组合后再拿这组参数训练模型做预测。它解决的不是“要不要用 LSTM”的问题而是“LSTM 该用多少层、多大 hidden size、多长时间窗口、什么学习率”这类靠手感和试错很难一次答对的参数问题。适合正在做时间序列预测、又不想一组一组手测参数的人销量预测、交通流预测、能耗预测、气象序列都适用。拆这套代码时我最大的感受是遗传算法本身不复杂真正的成本在“每次评估都要训练一个 LSTM”。种群稍大一点一跑就是几小时所以后面谈到的种群规模、变异率、评估轮数都要配套调整。这篇文章从调参难点讲起再拆遗传算法主循环和模型代码最后给可直接照做的数据准备、训练、预测步骤以及四类高频坑不收敛、滞后预测、内存不足、结果不稳定。2. LSTM 为什么难调参预测任务里的超参数黑洞2.1 预测问题里的 LSTM它到底在学什么LSTM 不是普通回归模型。RNN 系列网络处理序列数据时会按时间步把信息和状态一起传递而 LSTM 在 RNN 基础上加了输入门、遗忘门、输出门让模型能“记住”前几步的模式也不至于在长序列上把梯度一路传回时衰减到零。RNN 之所以难训练是因为沿时间步反向传播时连续矩阵乘法会让梯度指数级放大或缩小LSTM 的遗忘门相当于给梯度保留了一条相对稳定的通道。短序列上 RNN 和 LSTM 差距不明显序列长度超过几十步时LSTM 的优势才真正体现出来。做预测时我们一般构造出“用过去 look_back 步作为输入预测未来 1 步”的监督学习样本。比如有 1000 天的销售数据取 10 天窗口那么第 1 条样本是第 110 天预测第 11 天第 2 条样本是第 211 天预测第 12 天。这个构造方式决定了 LSTM 的建模能力和代价窗口长度小了信息不够预测结果像随机抓取窗口长度大了样本高度重叠训练时间涨得厉害而且模型未必真能用上那么长的历史。代码角度LSTM 层接收的形状是(batch_size, look_back, input_dim)所以在数据预处理阶段就要把一维序列切片成三维输入。初学者最容易在这里把数组维度搞混后面 4.2 会专门处理。另外注意预测任务里的 LSTM 本质是回归模型最后一层接Dense(1)损失函数用 mse 或 mae不是分类任务里的交叉熵。这一点在写适应度函数时很重要。2.2 哪些超参数值得用遗传算法去搜从学习率到时间步手动调参的顺序一般是先固定 batch_size 和 epochs然后调学习率、units、层数最后再回头调 look_back。这个顺序有效但参数之间会互相影响units 变大后最优学习率往往要变小look_back 变长后dropout 要跟着调否则很容易过拟合。参数多起来网格搜索是指数爆炸随机搜索又碰运气遗传算法在这里有天然优势——它维护一个种群每一代都把“好的参数组合”留下来交叉变异相当于在搜索空间里沿着趋势走。按常见实践下面这些参数值得放进搜索范围参数常见范围说明learning_rate1e-4 ~ 1e-2Adam 优化器下太小训练慢太大会震荡units16 ~ 128第一层 LSTM 神经元数num_layers1 ~ 2层数太多时显存压力大小数据 1 层往往更好look_back5 ~ 30历史窗口按数据周期决定范围dropout0 ~ 0.4防止过拟合但太大会欠拟合batch_size16 ~ 64影响梯度稳定性和训练速度learning_rate 从连续范围里取我一般用对数均匀分布生成也就是先随机一个指数再取 10 的幂这样小学习率也能有机会被选中。units、look_back、batch_size 用整数随机。注意 epochs 不建议放进遗传搜索里因为它在同一个搜索循环里直接决定评估耗时更常见的是搜索阶段固定一个偏小的 epochs比如 20等找到最优超参数后再用完整 epochs 重训。这些参数不是独立存在的。units 增大后模型的容量变大需要更小的学习率或者更高的 dropout 来抑制过拟合look_back 增大后每个样本包含的信息量变多模型容量反而可以适度减小。遗传算法处理这种耦合关系比手工调参有优势因为好的参数组合在交叉过程中有机会作为一个整体被保留进下一代。2.3 遗传算法优化 LSTM 的基本流程编码、适应度、选择遗传算法解决这个问题的套路是每个个体编码成一组超参数个体之间通过适应度排序适应度越高越有机会留下后代。整个过程分六步初始化种群、评估适应度、选择父本、交叉、变异、生成下一代。伪代码如下# GA-LSTM 基本流程伪代码 def fitness(individual): params decode(individual) # 把个体解码成超参数字典 model build_lstm(params) # 按参数构建 LSTM score train_and_evaluate(model, X_train, y_train, X_val, y_val) # 训练并返回验证误差 return 1.0 / (score 1e-6) # 误差越小适应度越高 for generation in range(GENERATIONS): scores [fitness(ind) for ind in population] parents tournament_select(population, scores) # 选择较优个体 children crossover(parents) # 交叉产生子代 children mutate(children) # 变异引入随机性 population top_elite(population, parents, children) # 精英保留这个流程里最贵的是 fitness 函数。普通遗传算法评估一个个体就是算一次函数几毫秒在这里评估一个个体要完整训练一个 LSTM单次几秒甚至几分钟。所以种群和迭代次数要控制在可接受范围内我建议种群 815 个、迭代 1530 代总量大概 200400 次训练。如果数据量大每个个体只训 20 轮来打分不要追求充分训练。编码方式有两种二进制编码把学习率缩放到二进制串交叉变异按位做实数或整数编码则直接把参数存成 dict 或数组。在超参数调优场景里二进制编码没有直观优势解空间还变大了所以我更倾向于直接维护一个超参数字典调试时一眼能看清当前个体是什么。选择算子我常用锦标赛随机抽 k 个个体取其中适应度最高的。它的好处是排序压力和群体规模解耦不会出现某个特别好的个体把整个种群吞掉的情况。再加上精英保留每代最优个体直接进入下一代搜索基本不会崩。3. 核心实现GA-LSTM 的 Python 代码结构与关键参数3.1 代码包结构训练、预测、适应度评估各司其职拿到这份代码先别急着跑 main.py先认清结构。合理的 GA-LSTM 代码包应该是分层拆开的不要把所有逻辑塞在一个文件里。ga_lstm/ ├── main.py # 入口解析参数、串起搜索和预测 ├── ga.py # 遗传算法主循环选择/交叉/变异 ├── lstm_model.py # 构建 LSTM 模型 ├── data_loader.py # 读取 CSV、构造滑动窗口、归一化 ├── evaluate.py # 计算 MAE/RMSE/MAPE └── params.py # 参数范围、种群、迭代次数配置main.py 是整个流程的调度者。它先读数据按时间顺序切分训练集和测试集再调用 ga.py 启动搜索搜索结束后取出最优个体重新构建模型训练最后在测试集上预测并输出指标。这样拆分的好处是你单独调试某个文件时不用把整个搜索跑完比如只看数据切片是否正确直接运行 data_loader.py 里的函数即可。我最常用到的是 params.py 和 ga.py 这两个文件。params.py 里改了参数范围、种群大小、遗传代数后ga.py 会自动按新的范围生成初始种群完全不用改其他文件。给新数据集做适配时优先改这两个文件不要一上来就动 lstm_model.py。3.2 遗传算法主循环代码逐行拆解初始化、交叉、变异个体的表示方式直接用一个 dict 保存超参数比用二进制编码直观。代码里常见做法是下面这种# ga.py初始化种群 import random from math import log10 def init_population(pop_size, param_bounds): population [] for _ in range(pop_size): ind { learning_rate: round(10 ** random.uniform( log10(param_bounds[learning_rate][0]), log10(param_bounds[learning_rate][1])), 6), units: random.randint(*param_bounds[units]), look_back: random.randint(*param_bounds[look_back]), dropout: round(random.uniform(*param_bounds[dropout]), 2), batch_size: random.choice(param_bounds[batch_size]), } population.append(ind) return populationlearning_rate 用10 ** random.uniform生成这是前面说的对数均匀采样。均匀分布在 1e-4 到 1e-2 之间采样时大部分随机点会集中在 1e-3 到 1e-2 区域1e-4 附近被抽中的概率很低对数均匀才能让小学习率也有机会出现。round 到 6 位是防止浮点数过长日志和结果比较时更干净。units 和 look_back 必须为整数batch_size 从 16、32、64 里随机选一个而不是连续取值。交叉和变异按参数键逐一处理# ga.py单点交叉 随机变异 def crossover(parent_a, parent_b): child {} for key in parent_a: child[key] parent_a[key] if random.random() 0.5 else parent_b[key] return child def mutate(individual, param_bounds, mutation_rate0.2): if random.random() mutation_rate: return individual key random.choice(list(individual.keys())) if key learning_rate: individual[key] round(10 ** random.uniform( log10(param_bounds[key][0]), log10(param_bounds[key][1])), 6) elif key in (units, look_back): individual[key] random.randint(*param_bounds[key]) elif key batch_size: individual[key] random.choice(param_bounds[key]) else: individual[key] round(random.uniform(*param_bounds[key]), 2) return individual单点交叉对参数组合的破坏较小适合参数数量少的场景。变异率不要设太高0.2 是我用得比较舒服的值超过 0.4 时遗传算法容易退化成随机搜索适应度曲线会明显震荡。还有一个细节crossover 没有处理两个父本完全一样的情况如果种群多样性耗尽子代会全是同一个体这时候只能靠变异重新引入随机性所以 mutation_rate 不能设成 0。3.3 LSTM 模型构建与适应度函数写法LSTM 模型构建用 Keras 顺序式写法。注意输入形状和 return_sequences 的设置# lstm_model.py构建 LSTM from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout def build_lstm(units64, dropout0.2, look_back10, input_dim1, num_layers1): model Sequential() model.add(LSTM(units, input_shape(look_back, input_dim), return_sequences(num_layers 1))) if num_layers 1: model.add(LSTM(units, return_sequencesFalse)) model.add(Dropout(dropout)) model.add(Dense(1)) model.compile(optimizeradam, lossmse) return modelreturn_sequences 这里很容易写错只有最后一层 LSTM 才设置 return_sequencesFalse中间层必须为 True否则第二层 LSTM 接收不到三维序列。如果你只搭一层 LSTM那这一层就是最后一层return_sequences 必须为 False。input_dim 是每个时间步的特征数做单变量预测就是 1加入节假日、价格等其他特征后变成 2 或 3模型不用改只要构造 X 时把维度拼对。适应度函数负责训练和打分# ga.py适应度函数 def evaluate_individual(individual, X_train, y_train, X_val, y_val): model build_lstm(**individual) model.fit( X_train, y_train, epochsSEARCH_EPOCHS, # 搜索阶段用小轮数省时间 batch_sizeindividual[batch_size], validation_data(X_val, y_val), verbose0 ) val_loss model.evaluate(X_val, y_val, verbose0) return val_lossfit 里传 validation_data 的好处是训练过程中能看到 val_loss 的变化如果某个个体的 val_loss 持续不降可以提前淘汰。这里的 fit 不要开 shuffle时序预测的验证集必须保持时间顺序。model.evaluate 返回的就是验证集上的 loss数值越小代表这组超参数越好因为遗传算法的适应度设计成“误差越小排名越靠前”。4. 跑通 GA-LSTM 的手把手步骤数据准备、训练与预测4.1 环境准备依赖库与数据格式Python 环境这一步先解决依赖。如果你刚开始接触 Python先装好 Python 本体然后在终端执行安装命令pip install numpy pandas scikit-learn tensorflow keras matplotlibnumpy 负责数组运算pandas 读 CSVscikit-learn 提供 MinMaxScaler 和误差指标函数keras tensorflow 是 LSTM 的运行环境。安装 numpy 库踩得比较多的坑是官方源下载慢常见做法是在 pip 命令里加国内 PyPI 镜像比如清华或阿里云的源装 TensorFlow 时如果机器没有 GPU装 CPU 版本就够几千条数据完全能跑只是训练速度慢一些。数据格式最简单的是两列 CSV第一列日期时间第二列数值列名用 date 和 value。pandas 默认会把日期读成 object 类型需要转成 datetime。下面是一个基础的数据接入写法# data_loader.py读取与归一化 import pandas as pd from sklearn.preprocessing import MinMaxScaler def load_data(path): df pd.read_csv(path) df[date] pd.to_datetime(df[date]) df df.sort_values(date).reset_index(dropTrue) # 必须按时间升序 scaler MinMaxScaler() df[value] scaler.fit_transform(df[[value]]) return df, scaler排序这步必须放在读取后第一时间做。很多 CSV 并不是严格按日期升序排列如果数据本身乱序LSTM 学到的“时序依赖”全是假的后面再花力气调参也没有意义。归一化用 MinMax 把数值压到 01LSTM 的激活函数对输入范围敏感不做这一步训练很难收敛。注意这里的 scaler 保存下来预测完之后要用它把结果还原到原始量纲。4.2 用 GA 搜索最优超参数命令行与参数设置数据准备好了搜索流程一般封装成一行命令python main.py --data data.csv --target value --pop_size 10 --generations 20main.py 解析这些参数后会去 data_loader.py 取数据再切成训练集和测试集。时间序列切分不能打乱顺序前 80% 训练、后 20% 测试。如果数据有季节性比如按周循环look_back 至少要比一个完整周期长否则模型永远看不到完整周期。看数据时留意一下最高频的周期性这是设置 look_back 范围的主要依据。默认参数集中在 params.py 里常见的配置如下# params.py搜索参数配置 SEARCH_EPOCHS 20 # 搜索阶段每个个体训练轮数 FINAL_EPOCHS 150 # 找到最优参数后重训轮数 PARAM_BOUNDS { learning_rate: (1e-4, 1e-2), units: (16, 128), look_back: (5, 30), dropout: (0.0, 0.4), batch_size: [16, 32, 64], }搜索阶段设 20 轮是在“评得准”和“评得快”之间折中20 轮足够判断一组参数好坏又不至于让每次评估都变成一次完整训练。最后一代跑完把最优个体取出来用 FINAL_EPOCHS 重训。这一步很多人会忘掉直接拿搜索阶段的小轮数模型去预测效果会明显变差。重训和预测的代码大致如下# main.py最优参数重训与预测 best_params ga_search.best_individual final_model build_lstm(**best_params) final_model.fit(X_train, y_train, epochsFINAL_EPOCHS, batch_sizebest_params[batch_size], verbose1) predictions final_model.predict(X_test) predictions scaler.inverse_transform(predictions) # 还原到原始量纲不要在归一化后的数值上直接跟原始数值比误差。predict 出来的结果是 01必须先 inverse_transform 再算指标否则 MAE、RMSE 都是缩放后的值真实误差根本看不出来。变量名在不同实现里可能不一样我这里的ga_search.best_individual是示意重点是你一定要在搜索完成后保留最优个体的完整参数。4.3 评估结果误差指标与预测曲线看模型好坏不能只看 loss。LSTM 时间序列预测里我固定看三个指标# evaluate.py误差指标 import numpy as np from sklearn.metrics import mean_absolute_error, mean_squared_error def calc_metrics(y_true, y_pred): y_true np.asarray(y_true).flatten() y_pred np.asarray(y_pred).flatten() mae mean_absolute_error(y_true, y_pred) rmse np.sqrt(mean_squared_error(y_true, y_pred)) mape np.mean(np.abs((y_true - y_pred) / (y_true 1e-8))) * 100 return {MAE: mae, RMSE: rmse, MAPE: mape}MAPE 分母加 1e-8 是为了防止真实值为 0 时除零。如果数据本身有 0 值MAPE 会失去参考价值这时优先看 MAE。RMSE 对大误差敏感适合判断是否存在极端坏预测。画预测曲线用 matplotlib注意横坐标日期太密时旋转标签import matplotlib.pyplot as plt plt.figure(figsize(12, 4)) plt.plot(y_test, labeltrue, colorblack) plt.plot(predicted, labelpred, colorred, linestyle--) plt.legend() plt.xticks(rotation45) # 横坐标日期过多时旋转防重叠 plt.tight_layout() plt.show()画图时横坐标太密集是高频问题rotation45或每隔 N 个标签显示一个都行。如果预测曲线比真实曲线整体“平移了一个时间点”直接去下一章看滞后拍的问题这是时间序列预测最容易踩的坑。5. GA-LSTM 避坑指南常见问题与排查记录5.1 遗传算法一直不收敛适应度震荡的三种原因现象跑了几代甚至十几代每代最优适应度上下乱跳最优参数组合没有明显变好。原因一种群太小。个体数少于 8 时选择压力不够随机性主导了搜索过程。原因二变异率过高遗传算法变成了随机搜索好不容易找到的好参数组合被频繁变异打散。原因三评估噪声太大每个个体只训练几个 epoch验证损失的高低很大程度上是训练不充分的随机结果不是超参数的真实优劣。解决先把种群提到 1215变异率压到 0.2再看 SEARCH_EPOCHS 是否低于 15如果低于 15 就往上调。最后把每代的最优个体打印出来对比如果连续三代最优个体的 units 都在 80 左右说明搜索方向是有效的如果最优个体每次都不相同优先按上面三步排查。5.2 预测曲线滞后一拍序列构造与标准化的坑现象预测曲线和真实曲线形状一致但整体向后平移了一个时间点误差看起来非常大。原因有两个。第一构造监督学习样本时X 的最后一步是 t-1 时刻预测目标是 t 时刻如果预测完成后再把预测值塞回输入继续预测下一步预测误差会被不断累积和放大产生递归式滞后。第二对测试集做 MinMaxScaler 时用了全量数据 fit相当于把未来信息泄露给训练过程预测结果会整体偏移。解决测试集的归一化只用训练集的 scaler.transform不要在测试集上重新 fit。做多步预测时第一帧用真实值做输入后面每一步如果没有真实值就用上一步的预测值代入同时要接受误差累积的现实。还有一种滞后来自评价方式如果你把第 t 步的预测值拿去和第 t 步的真实值对齐但模型内部构造样本时其实是用 t-1 之前的信息预测 t那对齐关系本身就是错的。5.3 内存溢出与训练过慢把搜索成本降下来现象设置 20 代、每代 15 个个体的搜索任务跑第一代就要十几分钟偶尔还直接 OOM。原因Keras 每构建一个模型就占用一份资源多个模型默认会抢占全部 GPU 显存同时 pop_size 大、输入序列长会让训练耗时成倍上涨。CPU 环境下所有个体串行训练时间主要花在 LSTM 的矩阵运算上。解决给 TensorFlow 配置显存按需增长这段代码放在 import 后马上执行import tensorflow as tf gpus tf.config.experimental.list_physical_devices(GPU) if gpus: tf.config.experimental.set_memory_growth(gpus[0], True)如果显存还是吃紧先减 pop_size再减 look_back最后才减 SEARCH_EPOCHS。pop_size 从 15 减到 10 通常只损失一点搜索质量训练时间直接降三分之一。训练过慢时还可以把 batch_size 下限提到 32减少每个 epoch 的迭代次数。5.4 结果复现不了随机种子和数据划分不一致现象同一个数据集、同一套参数第二次运行结果和第一次差很多最优超参数也变了。原因Python 的随机数、numpy 的随机数、TensorFlow/Keras 的随机数各自独立只设置一个没有用另外数据切分如果每次重新读取时对索引顺序敏感训练集内容不一样结果自然不一样。解决在 main.py 顶部统一固定随机种子import random import numpy as np import tensorflow as tf random.seed(42) np.random.seed(42) tf.random.set_seed(42)固定随机种子不是玄学是让搜索过程可复现的最低成本操作。注意 GPU 上部分算子仍可能有非确定性行为完全复现比较困难至少在 CPU 环境下固定种子后同一份数据应该跑出一致的结果。数据划分部分建议手动保存 train 和 test 的索引列表不要每次重新采样。6. 进阶技巧把 GA-LSTM 从“能跑”调到“可靠”6.1 把适应度从 MSE 换成 MAPE让优化目标贴近业务如果数据是销量或流量这类业务指标用 MSE 做适应度会把大数值样本的误差放大搜索出来的参数可能偏向“拟合大值”小值预测偏差很大。我给适应度函数加一个开关比如metricmape时验证评估返回 MAPE其他情况返回 MSE。遗传算法只认适应度高低分数定义越贴近业务目标搜索出来的参数就越实用。6.2 早停机制与最优个体存档GA 跑到后期很容易出现最优个体连续几代不更新。我的习惯是每代结束后存档三样东西最优个体、该个体的验证误差、当前代序号。如果连续 5 代最优验证误差不再下降直接中断搜索不再硬跑完所有代数。这个检查放在 ga.py 的主循环末尾比较历史记录即可。存档的价值在于如果后面调试中改了数据或代码能回看是哪一代开始稳定的。6.3 时序数据不能用普通 K-Fold用 TimeSeriesSplit普通 K-Fold 随机打乱会破坏时间顺序训练集里混进未来信息验证指标虚高。时间序列正确做法是用 sklearn 的 TimeSeriesSplit前段训练、后段验证切分点逐步向后推进from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits3) for train_idx, val_idx in tscv.split(X): X_train_seq, X_val_seq X[train_idx], X[val_idx] # 每个个体在每个折上评估一次取平均作为适应度用 TimeSeriesSplit 后每个个体的评估次数从 1 次变成 3 次训练成本翻三倍。所以我一般只在最后验证最优参数时用三折搜索阶段还是保持单次验证集切分否则总时长会失控。从那以后我每次跑 GA-LSTM都会强制走一遍固定随机种子、按时间顺序切分数据、搜索阶段小轮数评估、拿到最优参数后重训一轮最后再看误差曲线而不是单看 loss。这套检查顺序看起来烦琐但能挡掉我前面说的大部分翻车现场。希望帮到你。本文还有配套的精品资源点击获取