
简介这是面向时间序列预测场景的GA-LSTM遗传算法优化LSTM预测代码适合具备Python与深度学习基础、希望借助遗传算法改善LSTM网络参数配置的开发者可迁移到股价走势、电力负荷等序列预测任务中。压缩包共4个文件以Ga.py与lstm.py两个Python脚本为主并辅以对应的pyc编译文件与测试数据整体约8KB代码量精简、结构明晰便于快速拆读和复用。已有6964人学习下载。资源清晰划分了遗传算法与LSTM两大模块Ga.py承载种群初始化、适应度评估、交叉及变异等迭代寻优逻辑lstm.py则负责数据序列化与模型结构定义test测试数据可用于检验优化后的泛化能力。对照代码可快速理解GA与LSTM结合的完整流程掌握一种通用的参数优化思路直接运用于其他序列预测任务。1. 把 LSTM 超参数交给遗传算法GA-LSTM 到底解决什么问题调 LSTM 超参数最怕手搓。时间步、神经元数、学习率、batch size、dropout每个变量都是连续区间彼此还耦合。你用网格搜索把这几个数扫一遍一台 8 核机器可能跑一个通宵靠经验拍脑袋又常常在过拟合和欠拟合之间来回翻车。GA-LSTM 的思路是把超参数选择当成黑箱优化问题底层仍然是 LSTM 做时间序列预测顶层用遗传算法去迭代超参数组合每一轮进化都用“验证集误差”回答哪个参数组合更好。这个组合最常见的落地场景是单变量预测比如电力负荷预测、股票收盘价预测、设备寿命预测里的回归任务。适合谁呢已经会用 LSTM 出预测结果、但调参没有确定方向的人。纯新手也能照抄代码跑通但至少得懂 Python 的循环、函数和 Keras 的 fit/predict。本文会从 LSTM 基座写到遗传算法算子最后给出一份可复现的 Python 实现以及我在实际调参中踩过的典型坑。2. 先搭 LSTM 预测基座窗口怎么建、验证集怎么切、误差怎么算任何遗传算法优化都需要一个“评估闭环”给一组超参数训练一次 LSTM返回一个分数。这个闭环的底层就是 LSTM 的时间序列预测子流程。我一般会先把固定超参数的 LSTM 跑通确认数据格式、切分方式、误差计算都正确再往上装遗传算法。否则 GA 跑完十几代回头发现训练集和验证集重叠所有优化成果都要推倒重来。对于单变量序列最常见做法是把序列改造成监督学习形式用前lookback个时间点预测后horizon个点。先用窗口从原始序列里切出二维输入和一维目标再按时间顺序拆分。这里有几个选择会直接决定 GA 优化空间先花一点篇幅说清楚。2.1 单步滚动预测为什么比多步输出更适合 GA 迭代预测任务分两种单步预测和多步预测。单步预测每次只预测下一个时间点模型简单训练开销小多步预测要一次性输出未来 M 个点或者用递归方式预测多步模型复杂度和误差传递问题都会放大。GA-LSTM 在搜索阶段要训练大量模型我强烈建议先锁定单步滚动预测。这样能显著缩短单次评估时间让同样预算下进化更多代。等到 GA 选出最优超参数你再决定是不是要换成多步模型。常见做法是在最终阶段把输出层改为M个神经元或保留单步模型做滚动预测。用单步评估只是为了让超参数选择更便宜不代表业务上线时必须用单步。这一点理解之后窗口构造就很好写了。import numpy as np def make_windows(data, lookback12, horizon1): X, y [], [] for i in range(len(data) - lookback - horizon 1): X.append(data[i:i lookback]) y.append(data[i lookback:i lookback horizon]) return np.array(X), np.array(y)make_windows的时间复杂度是 O(n×lookback)对几千条数据完全够用。X的 shape 是(样本数, lookback, 特征数)Keras LSTM 需要这个三维输入y是(样本数, horizon)。特征数这里取 1因为例子是单变量序列。如果是多变量输入把多个特征拼到最后一个维度就行后面 GA 解码时input_shape需要同步修改。2.2 数据清洗与滑动窗口构造缩放必须在切分之前想清楚时间序列预测里最常见的翻车点是 MinMaxScaler 到底什么时候 fit。很多人在整个数据上scaler.fit_transform然后再切训练集、验证集。这在 LSTM 超参数搜索里是有泄漏风险的验证集的数据分布已经被缩放器记住验证误差会被低估。正确的顺序应该是先切分再用训练集 fit 缩放器用同一套缩放器 transform 验证集和测试集。Python 实现上我一般会先把原始序列拆成 train 和 val 两块分别缩放后再各自构造窗口。下面这段是修正后的流程from sklearn.preprocessing import MinMaxScaler def scale_by_train(train_raw, val_raw): scaler MinMaxScaler(feature_range(0, 1)) train_scaled scaler.fit_transform(train_raw.reshape(-1, 1)) val_scaled scaler.transform(val_raw.reshape(-1, 1)) return train_scaled, val_scaled注意val_raw.reshape(-1, 1)是因为 scaler 要求二维输入。切分时还要小心如果数据带有季节周期建议在 train 和 val 之间留出一段 gap比如把最后几十个点丢弃避免验证集紧贴训练集尾部导致周期性被直接复制。GA 评估阶段我们关注的是相对排序不是绝对最优所以宁可多留 gap 也不要用信息重叠的数据。2.3 训练集、验证集、测试集三分法GA 选超参数时只用验证集GA 的适应度函数不能拿训练集误差当分数那只会选出记忆能力强的模型也不能拿最终测试集当分数否则遗传算法会在几十代迭代里逐渐把测试集“记住”。正确做法是保留一部分完全不参与遗传算法的测试集只有等到进化结束、确定最终超参数后才用它在最终模型上评估一次。我习惯把数据按 7:2:1 切70% 训练20% 验证10% 完全保留。GA 每一轮评估里模型在训练集上训练在验证集上算 RMSE这个 RMSE 就是 GA 的适应度。最终超参数确定后我会拿训练集加验证集重新训练最终模型再用保留测试集看真实误差。split_train int(len(scaled_data) * 0.7) split_val int(len(scaled_data) * 0.9) train_raw scaled_data[:split_train] val_raw scaled_data[split_train:split_val] test_raw scaled_data[split_val:] train_scaled, val_scaled scale_by_train(train_raw, val_raw) _, test_scaled scale_by_train(train_raw, test_raw)这段代码里有个细节scale_by_train(train_raw, test_raw)返回的第一个 train_scaled 会被扔掉但第二个是有效的缩放器。要注意如果数据量特别小10% 保留测试集可能只有几个点RMSE 波动会很大。这时可以改成按数值点切分比如保留最后 50 个点当测试集不足再补。GA 的验证集本身也要足够长否则单次验证误差根本不能反映泛化水平。3. 遗传算法的原理与代码个体编码、适应度、选择交叉和变异遗传算法原理用一句话概括就是模拟自然选择让一组候选解通过选择、交叉、变异不断演化。落到 LSTM 调参场景候选解就是一组超参数。很多刚接触 GA 的人会去找二进制编码的教材实现实际工程里我更推荐实数编码。因为 LSTM 超参数里有整数也有浮点数直接用 0 到 1 的染色体再用解码函数映射到真实超参数写起来直观也不容易越界。这一章会给出可直接复用的 GA 核心代码。为了让你看懂每一步我会把“个体表示”和“进化算子”分开讲。读者如果已经写过遗传算法 Python 代码可以直接跳到 3.2 看适应度函数怎么和 Keras 配合。3.1 个体编码的超参数表和解码函数GA 种群里的每个个体是一条染色体。这里染色体是长度为 5 的实数向量每个分量在 0 到 1 之间。五个基因分别对应lookback、units、learning_rate、batch_size、dropout。超参数范围如下表基因超参数搜索范围编码方式g0lookback530整数线性映射g1LSTM units16128整数线性映射g2learning_rate1e-41e-2对数缩放g3batch_size161282 的幂映射g4dropout00.4浮点线性映射学习率用对数缩放而不是线性缩放。因为从 1e-2 到 1e-4 跨越两个数量级线性映射会把 1e-3 和 1e-2 之间的差异压到很小GA 很难区分。对数映射让染色体在 0 到 1 区间里移动时学习率在数量级上均匀变化。batch_size 映射成 2 的幂是为了满足多数深度学习框架对 batch_size 的偏好。param_bounds { lookback: (5, 30), units: (16, 128), lr: (1e-4, 1e-2), batch_size: (16, 128), dropout: (0, 0.4), } def decode(individual): # 前两个基因是整数直接用 round 映射到边界 lookback int(round(individual[0] * (param_bounds[lookback][1] - param_bounds[lookback][0]) param_bounds[lookback][0])) units int(round(individual[1] * (param_bounds[units][1] - param_bounds[units][0]) param_bounds[units][0])) # 学习率用对数基因从 0 到 1 对应 1e-4 到 1e-2 lr 10 ** (-4 individual[2] * 2) # batch_size 用 2 的幂基因 0~1 对应 4~7也就是 16 到 128 batch_exp int(round(individual[3] * (7 - 4) 4)) batch_size 2 ** batch_exp dropout individual[4] * param_bounds[dropout][1] return { lookback: lookback, units: units, lr: lr, batch_size: batch_size, dropout: dropout, }decode是遗传算法和 LSTM 模型之间唯一的接口。你以后想加num_layers、activation、optimizer只需要改这段映射。要特别注意int(round())后的结果是否会越界chromosome 是 0 到 1 之间的浮点数round 之后可能因为边界浮点误差得到 16 或 128这没问题但 dropout 记得 clip 到合法范围。3.2 适应度函数把验证集 RMSE 翻译成 GA 能用的分数GA 里的适应度要数值化还要保证比较方向一致。LSTM 回归任务最简单的是用验证集 RMSE越小越好。为了和种群选择逻辑统一我这里直接让适应度函数返回 RMSE后面的锦标赛选择直接按小值选中。下面是最小可用的评估函数import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam def fitness_fn(individual, X_train, y_train, X_val, y_val): params decode(individual) try: tf.random.set_seed(42) model Sequential() model.add(LSTM(params[units], activationtanh, input_shape(params[lookback], X_train.shape[-1]))) model.add(Dropout(params[dropout])) model.add(Dense(1, activationlinear)) model.compile(optimizerAdam(learning_rateparams[lr]), lossmse) model.fit(X_train, y_train, epochs15, batch_sizeparams[batch_size], validation_data(X_val, y_val), verbose0) pred model.predict(X_val, verbose0) rmse float(np.sqrt(np.mean((y_val - pred) ** 2))) except Exception: rmse 1e10 return rmsefitness_fn里有三个关键点。第一每次建立独立模型不共享权重这是 GA 评估公平性的前提。第二tf.random.set_seed(42)尽量降低初始化差异但 GPU 上多个操作可能仍然有抖动这个放到第五章细说。第三except Exception是兜底一旦某个超参数组合导致训练过程崩溃比如 NaN、shape 不匹配直接给一个惩罚性分数避免 GA 选择到无效解。需要注意X_train和X_val的 lookback 维度和解码后的params[lookback]必须一致。如果 GA 在迭代中改变了 lookback那么每次训练前都要重新make_windows否则 shape 会报错。这里为了简化把窗口数据预先用最大 lookback 构造所有候选窗口解码时再按params[lookback]截取。下面代码演示这个技巧。3.3 选择、交叉、变异算子与种群初始化遗传算法的三个核心算子分别是选择、交叉、变异。选择负责把验证集 RMSE 较小的个体保留下来交叉在种群内交换基因片段变异负责在局部探索。我这里不搞复杂概率模型直接给出经过验证的最简实现。def init_population(pop_size, gene_len5): return np.random.rand(pop_size, gene_len) def tournament_select(pop, scores, k3): candidates np.random.choice(len(pop), k, replaceFalse) best min(candidates, keylambda i: scores[i]) return pop[best] def crossover(parent1, parent2, prob0.8): if np.random.rand() prob: point np.random.randint(1, len(parent1) - 1) child np.concatenate([parent1[:point], parent2[point:]]) return child return parent1.copy() def mutate(individual, prob0.15, step0.1): child individual.copy() mask np.random.rand(len(child)) prob child[mask] np.clip(child[mask] np.random.normal(0, step, mask.sum()), 0, 1) return childtournament_select的k3表示从种群中随机抽 3 个个体选 RMSE 最小的一个当作父代。这个操作不依赖全局排序方便并行。crossover是单点交叉交换点之前来自 parent1之后来自 parent2。之所以用np.random.randint(1, len(parent1)-1)是避免换到空片段。mutate用的是高斯扰动步长 0.1放在 0 到 1 区间里不会太猛。有了这些算子一个 GA 代际循环只有四步算适应度、选父代、交叉生成子代、变异。伪代码逻辑在第四章完整脚本里会出现。这里想强调GA 的有效性不在于算子有多复杂而在于适应度函数是否准确测量了 LSTM 的泛化误差。算子标准就够用。4. 端到端复现 GA-LSTM完整跑通并判断结果是否值得信前面零件已经拆完这一章把它们拧成一台完整机器。你会在一个合成正弦波序列上看到 GA-LSTM 的完整流程。用正弦波替代真实数据的理由是它能低成本复现不用额外下载数据集把重点放在 GA 和 LSTM 的衔接上。实际业务时把 sine 数据换成 pandas 读入的 csv 列即可。完整脚本长度大约一百多行我会按数据准备、解码评估、进化主循环三块给出。建议先整体过一遍再照着敲不要直接复制就跑否则改数据集时容易漏掉 shape。4.1 把代码串成主流程初始化、进化、解码、训练、记录先准备数据。这里用带噪声的正弦波构造一个回归序列然后按前文顺序切分。np.random.seed(1) t np.arange(0, 30, 0.02) raw (np.sin(t) 0.05 * np.random.randn(len(t))).reshape(-1, 1) split_train int(len(raw) * 0.7) split_val int(len(raw) * 0.9) scaler MinMaxScaler() train_raw raw[:split_train] val_raw raw[split_train:split_val] test_raw raw[split_val:] scaler.fit(train_raw) train_scaled scaler.transform(train_raw) val_scaled scaler.transform(val_raw) test_scaled scaler.transform(test_raw)这里注意scaler.fit(train_raw)只 fit 训练部分。接着用最大 lookback 构造窗口。GA 解码时每个个体需要的滑动窗口长度不同所以我们在这一步造一个“超集窗口”后续按个体截取。MAX_LOOKBACK 30 X_train_all, y_train make_windows(train_scaled, MAX_LOOKBACK) X_val_all, y_val make_windows(val_scaled, MAX_LOOKBACK) # 按个体提取指定 lookback def extract_windows(X_all, lookback): return X_all[:, -lookback:, :]extract_windows取每个样本的最后lookback个时间步。这里采用X_all[:, -lookback:, :]可以保证窗口的时间顺序不被打断。如果你从X_all[:, :lookback, :]截取等于每次预测都丢掉最新数据RMSE 会明显变差这是新手最容易犯的错。下面是进化主循环。种群大小设成 6遗传代数 5最少意味着 6×530 次 LSTM 训练。对合成数据大约几分钟内能跑完真实数据上请一定先减小 epochs。pop_size 6 n_generations 5 population init_population(pop_size) for gen in range(n_generations): scores [] for ind in population: params decode(ind) lookback params[lookback] Xt extract_windows(X_train_all, lookback) Xv extract_windows(X_val_all, lookback) score fitness_fn(ind, Xt, y_train, Xv, y_val) scores.append(score) best_idx np.argmin(scores) print(fgen {gen}: best rmse{scores[best_idx]:.5f}, fparams{decode(population[best_idx])}) new_population [population[best_idx]] while len(new_population) pop_size: p1 tournament_select(population, scores, k3) p2 tournament_select(population, scores, k3) child crossover(p1, p2) child mutate(child) new_population.append(child) population np.array(new_population)主循环里最容易被忽略的一行是new_population [population[best_idx]]。这是精英保留策略每一代最优个体直接进入下一代防止交叉变异破坏已经找到的好参数。没有它GA 可能在后期退化RMSE 甚至比上一代还差。tournament_select的 k 越大选择压力越强我这里用 k3 比较温和。4.2 进化日志与收敛判断这些现象说明 GA 在工作跑完上面的代码控制台会输出类似下面这样的日志gen 0: best rmse0.11738, params{lookback: 23, units: 66, lr: 0.0032, batch_size: 32, dropout: 0.22} gen 1: best rmse0.09821, params{lookback: 27, units: 88, lr: 0.0018, batch_size: 64, dropout: 0.15} gen 2: best rmse0.08776, params{lookback: 29, units: 76, lr: 0.0014, batch_size: 64, dropout: 0.17} gen 3: best rmse0.08653, params{lookback: 28, units: 80, lr: 0.0011, batch_size: 64, dropout: 0.18} gen 4: best rmse0.08594, params{lookback: 29, units: 79, lr: 0.0012, batch_size: 64, dropout: 0.16}看到前几代 RMSE 快速下降后几代下降变慢说明 GA 在有效收敛。最理想的是最后一代的最优个体相对前一代变化不大这代表搜索已经趋于稳定。如果第二十代比第十代还差多半是训练随机性太大或者种群多样性丢失记得先回去固定随机种子。这里有两点需要警惕。一是 RMSE 本身受序列分布影响不同的数据用同一个 RMSE 值没有对标意义只能看种群内相对排序。二是 GA 的最佳个体不一定在所有数据上都能复现因为单个验证集只有一段序列。更稳的做法是跑三次 GA把三次最优超参数取并集再逐个验证。4.3 先跑三次随机搜索再上 GA用基线判断 GA 值不值得GA 不是调参银弹。如果超参数空间很小或者你的验证集非常稳定也许随机搜索就能达到同样效果。我在项目中习惯的做法是先用随机搜索建立基线同样评估 30 次每次随机采一组超参数记录最优 RMSE然后再跑 GA。两条线对比能告诉你 GA 的相对收益有多少。下面这张表是我在不同项目里用的对比维度不是固定结果只是评估思路调参方式30 次评估后是否逼近局部最优实现复杂度是否容易改并行网格搜索通常不会维度多则稀疏低容易并行随机搜索会碰到较优点但无方向低容易并行GA-LSTM有方向能逼近更好组合中中等需要同步父代随机搜索代码非常简单for i in range(30): ind np.random.rand(5); score fitness_fn(...)。如果随机搜索 30 次的最优 RMSE 已经接近 GA 结果说明这个参数空间用不着 GA别为用而用。如果 GA 在同样预算下能稳定低 10% 到 20% 的 RMSE就值得把它固化到项目流程里。5. GA-LSTM 避坑与排查五个实测才懂的教训GA-LSTM 的坑一半在遗传算法一半在时间序列。下面是五个我实际遇到过的典型问题。每一条都按现象、原因、解决三步写你在复现时可以直接对照排查。5.1 随机种子没固定同一组超参数调出来的 RMSE 飘得离谱现象GA 里同一个个体在第二次评估时 RMSE 从 0.11 变成 0.09导致下一代选择结果变化剧烈。原因LSTM 的权重初始化是随机的验证集切分顺序不同也会改变 RMSE。虽然 Keras 有全局随机种子但 TensorFlow 在 GPU 上的一些算子仍存在不确定性的黑匣子。解决在fitness_fn开头固定tf.random.set_seed(42)GA 主循环开始处固定np.random.seed(42)两者不要混用。如果你的机器是 CPU 推理RMSE 基本能稳定复现在 GPU 上想完全复现需要设置环境变量TF_DETERMINISTIC_OPS1但会牺牲速度。折中方案是每个个体评估两次取均值预算不够时至少保证一次评估的代码路径完全一致。5.2 验证集被反复翻牌GA 也在“过拟合验证集”现象GA 选出的超参数在验证集上 RMSE 很低但在完全没见过的测试集上表现变差。原因遗传算法的每一代都基于同一段验证集做选择。优化进行到几十代之后它不只是泛化而是把验证集的噪声也拟合进去了。解决最终选出的超参数不要直接在验证集上做最终评价。先用训练集加验证集训练最终模型再用保留测试集测一次。如果测试集误差比验证误差高出一大截说明出现了选择偏差。实践中我会留出完全隔离的测试集测试集只允许用一次。5.3 数据缩放发生在切分之前偷偷泄漏了未来信息现象GA 得到的验证 RMSE 只有 0.03但特征可视化发现验证集和训练集分布完全一样。原因整个数据集先fit_transform再做切分scaler 用到了全量数据。验证集和测试集的均值和方差被训练集信息污染使验证误差偏小。解决遵循第 2 章的scale_by_train流程只对训练集fit验证集和测试集用同一个 scalertransform。另外如果数据有成段的缺失值最好用前一段数据做填充不能用整列均值填充那也是一种泄漏。5.4 训练轮数和种群大小没有配合预算烧光还看不到趋势现象pop_size30generations20每代还要训练 15 epochs整体需要 600 次 LSTM 训练。机器跑了一整夜只完成两代。原因在没有评估成本预估的情况下把 GA 参数设置得过大。GA 的复杂度不是 O(pop_size × generations) 这么简单每个个体还可能因为 lookback 不同而训练更慢。解决第一轮先跑pop_size4, generations3, epochs5摸清单次评估耗时。预算充足时再增加到pop_size12, generations10。训练轮数方面可以用早停控制单个个体耗时但要注意公平性见下一条。5.5 早停回调在 GA 评估中乱用带来不公平竞争现象有的个体早停在 12 epochs有的跑满 50 epochsGA 会偏向早停产生的低损失模型。原因早停是根据验证损失动态停止的。GA 的每个个体接受不同训练强度相当于在比“谁训练得更少更巧”而不是比“谁的参数结构更好”。解决如果你给每个个体都加了 EarlyStopping必须确保最大 epochs 一致并且至少让每个模型跑到最低阈值比如前 5 个 epochs 内不早停。更公平的做法是固定 epochs不做自适应早停。只有在最终训练阶段才对 GA 选出的最优超参数使用 EarlyStopping。6. 进阶玩法让每一次 GA-LSTM 评估都不白跑遗传算法评估成本高所以每一次训练都值得做点优化。第一个技巧是把适应度评估从 train from scratch 改成 warm start。种群内很多个体只差一两个基因如果每次从头初始化权重不仅慢还增大了随机性。常见做法是先训练一个基础超参数的模型保留权重GA 评估新个体时用 LSTM 层权重做初始值继续微调几步。注意这个技巧只在 GA 迭代到后半段、参数范围收窄时用前半程还是要从头训练以保证公平。第二个技巧是并行评估。每个个体互相独立天然可并行from concurrent.futures import ProcessPoolExecutor def evaluate_with_extract(ind): params decode(ind) Xt extract_windows(X_train_all, params[lookback]) Xv extract_windows(X_val_all, params[lookback]) return fitness_fn(ind, Xt, y_train, Xv, y_val) with ProcessPoolExecutor(max_workers4) as pool: scores list(pool.map(evaluate_with_extract, population))进程池需要注意ProcessPoolExecutor会复制内存里的X_train到子进程数据太大时用内存映射文件每个子进程都要重新设置随机种子。第三个技巧是保存评估日志。我最早跑 GA-LSTM 时只记住最后一行的最优参数后来想分析收敛情况却找不到历史。现在我至少会在每一代末写入一行 CSVgen, individual_id, lookback, units, lr, batch_size, dropout, rmse。这样即使进程崩溃也能断点续跑某一代出现异常 RMSE 时还能回溯到是哪组参数导致的。我个人的习惯是先跑三轮随机搜索作为基线再让 GA 跑固定预算。每次拿到 GA 最优结果都要先在保留测试集上验证。不少时候遗传算法看起来有效实际只是验证集过拟合有了保留测试集这道防线踩坑的概率就会低很多。希望这些经验能帮到你。本文还有配套的精品资源点击获取