简介:这是一份基于Python实现的GA-LSTM(遗传算法优化长短时记忆网络)时间序列预测模型代码包,面向机器学习开发者、科研人员及对预测算法感兴趣的进阶学习者。资源虽标注为matlab,但实际实现完全采用Python语言,压缩包共5个文件,包含2个py脚本、2个pyc编译文件和1个txt说明文档,整体大小仅9KB,结构简洁。代码围绕遗传算法与LSTM结合展开:利用GA全局搜索特性对LSTM权重进行迭代优化,帮助缓解传统模型过拟合或欠拟合问题,同时保留LSTM处理长期依赖的能力。具体内容包括网络结构定义、适应度函数构造、选择交叉变异等遗传操作,以及数据预处理、模型训练、预测和误差评估等完整流程。通过阅读和运行源码,读者可以直观理解GA如何辅助神经网络调参,并可将该方案迁移至股票预测、天气预测等场景。目前已有390人学习下载,适合需要提升时序预测精度、研究混合优化策略的开发者。
1. 拿到 GA-LSTM 的 zip 之后别急着解压:先搞清楚这套代码到底在帮你做哪件事
打开一个 GA-LSTM 预测代码的 zip,最容易劝退的不是 LSTM 本身,而是「遗传算法优化」这六个字。很多任务里,遗传算法其实不负责预测,它只做一件事:替你把 hidden_size、学习率、窗口长度这些超参当基因去搜索,而不是你自己盯着 loss 曲线一版一版试。GA-LSTM 的落地路径就是「GA 找参数,LSTM 做预测」,适合数据量不大、单序列居多、又不想在调参上耗整天的 Python 工程场景。我按自己实际落地的顺序来拆:数据怎么切、GA 怎么编码、LSTM 怎么接、坑在哪。这份思路可以直接还原成一个能跑的最小实现。
2. 先把样本切对:窗口构造、时间划分和归一化的正确顺序
2.1 窗口怎么切:可变 seq_len 的造样本方法
先说一个最常见的翻车点:构造样本时把 y 取成当前时刻,模型训练时等于看见答案。预测任务里 X 是过去 seq_len 个时刻,y 必须是未来一个时刻。下面这个函数接收归一化后的数组,切成 LSTM 能吃的三维输入。
import numpy as np def make_samples(x_scaled, y_scaled, seq_len, step=1): X, y = [], [] for i in range(seq_len, len(y_scaled) - 1, step): # 关键:输入窗口是 i 之前的 seq_len 行,标签取 i+1,而不是 i X.append(x_scaled[i - seq_len:i]) y.append(y_scaled[i + 1]) return np.array(X), np.array(y)逻辑说明:x_scaled 是形状 (n, n_feat) 的二维数组,经过归一化;y_scaled 是 (n,) 的一维目标序列。i 从 seq_len 开始,到 len(y_scaled)-1 结束,保证标签 i+1 存在。如果写成 y.append(y_scaled[i]),输入窗口最后一个时刻和标签是同一天,训练时 loss 正常下降,滚动预测第一天就露馅。
参数说明:seq_len 是 GA 要搜的核心超参之一,常见取值范围 6~36。日更数据里 7 和 30 是两个常用档位,小时级数据可以短一些。step=1 意味着窗口每次滑动 1 步,样本之间重叠,能提供更多训练样本;不建议为了减少样本量把 step 调大,连续预测的评估会被截断。x_scaled 支持多列特征,比如温度、是否为工作日、目标序列的滞后项;如果没有外生变量,就把目标列复制进 feature_cols 一起传入。
2.2 训练、验证、测试怎么切:时间顺序,不是随机切
时间序列预测的划分有个特殊要求:不能用 sklearn 的 train_test_split 随机切。随机切会把未来的数据点混进训练集,模型在验证阶段的表现会虚高。我一般会按时间顺序切成三段,训练段只用来拟合归一化和训练模型,验证段给 GA 计算适应度,测试段到最后评估时才碰一次。
def split_by_time(df, train_ratio=0.7, val_ratio=0.15): n = len(df) train_end = int(n * train_ratio) val_end = int(n * (train_ratio + val_ratio)) train_df = df.iloc[:train_end].copy() val_df = df.iloc[train_end:val_end].copy() test_df = df.iloc[val_end:].copy() return train_df, val_df, test_df参数说明:样本足够长就 70/15/15;样本只有几百条时可以改成 80/10/10,但 val 至少要留几十个样本,否则适应度噪声太大,GA 会选到运气好的超参而不是真正好的超参。如果序列有季节周期,切分点尽量选在周期边界附近,比如日数据按整周或整月切,避免训练段和验证段的统计特性完全错位。
2.3 归一化怎么放:训练段 fit,验证测试段 transform
归一化也是这类项目里最容易埋隐性 bug 的地方。常见错误是拿整个数据集一次性 fit MinMaxScaler,让测试段的 min/max 提前进入模型,导致「训练时看不出问题,上线后预测值系统性偏小」。
from sklearn.preprocessing import MinMaxScaler scaler_x = MinMaxScaler() scaler_y = MinMaxScaler() # 只用训练段拟合,验证和测试段只做变换 scaler_x.fit(train_df[feature_cols]) scaler_y.fit(train_df[[target_col]]) def transform_df(df): x_scaled = scaler_x.transform(df[feature_cols]) y_scaled = scaler_y.transform(df[[target_col]]).ravel() return x_scaled.astype(np.float32), y_scaled.astype(np.float32)逻辑说明:scaler 的 min/max 只来自训练段,验证和测试段用同一套变换。这样测试段里的新极值会被裁剪到 0~1 区间,这是 MinMaxScaler 的天然局限。如果你的数据有明显趋势漂移,最好换成 StandardScaler,或者观察 transform 后是否大量样本堆积在 0 和 1 边界上,堆积说明分布已经超出训练段范围。
参数说明:target 也要单独归一化,因为 LSTM 输出层是线性层,直接拟合归一化后的目标更容易收敛;最终预测结果用 scaler_y.inverse_transform 还原。GA 的适应度我习惯用归一化后的 RMSE,报告时再还原成原始量纲的 RMSE,两个口径分开记录。
提示:后面所有模型代码里的 x_train、y_train,默认都来自 transform_df 的输出,不要再自己重复做一次 fit。
3. 遗传算法优化 LSTM 的实现:基因编码、适应度与主循环
3.1 超参编码与种群初始化:从 0~1 的基因到真实超参
GA 的第一步是把候选超参编码成一条「基因」。我习惯用实数编码,所有基因值都在 [0,1] 之间,这样交叉和变异都不需要处理不同量纲。解码阶段把它映射到真实参数。
BOUNDS = { "hidden": (16, 128), "lr": (1e-4, 1e-2), "seq_len": (6, 36), "dropout": (0.0, 0.4), } def decode(genes): hidden = int(round(BOUNDS["hidden"][0] + genes[0] * (BOUNDS["hidden"][1] - BOUNDS["hidden"][0]))) lr = BOUNDS["lr"][0] * (BOUNDS["lr"][1] / BOUNDS["lr"][0]) ** genes[1] seq_len = int(round(BOUNDS["seq_len"][0] + genes[2] * (BOUNDS["seq_len"][1] - BOUNDS["seq_len"][0]))) dropout = BOUNDS["dropout"][0] + genes[3] * (BOUNDS["dropout"][1] - BOUNDS["dropout"][0]) return hidden, lr, seq_len, dropout def init_population(pop_size=12, seed=42): rng = np.random.default_rng(seed) return rng.random((pop_size, 4))逻辑说明:基因有 4 个维度,每个个体就是长度为 4 的数组。hidden 和 seq_len 解码后要转成整数;lr 用 log 均匀而不是线性均匀,因为学习率在 0.0001~0.01 之间,线性采样会把绝大多数个体落在 0.005~0.01 这一段,初始学习率普遍偏大,训练容易震荡。log 均匀可以让 0.0001、0.001、0.01 三个数量级都有差不多的采样机会。
参数说明:hidden 下限 16 是经验值,低于 16 的 LSTM 表达力太弱;上限 128 对单序列预测足够。seq_len 上限 36 意味着如果序列本身较短,构造后的样本数会明显减少,所以 GA 里要在 fitness 入口做一次合法性校验。dropout 只在 LSTM 层开启,搜索时给 0~0.4,最终模型大多落在 0.1~0.3。
3.2 适应度函数:训练一个小 LSTM,返回验证集 RMSE
GA 要评估每个个体的好坏,靠的是 fitness 函数。适应度值通常取验证集 RMSE,数值越小越好。这里的关键不是把 LSTM 训练到完全收敛,而是训练到能区分超参好坏的程度。
import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset class LSTMPredictor(nn.Module): def __init__(self, n_feat, hidden, dropout): super().__init__() self.lstm = nn.LSTM(n_feat, hidden, batch_first=True, dropout=dropout) self.fc = nn.Linear(hidden, 1) def forward(self, x): out, _ = self.lstm(x) return self.fc(out[:, -1, :]).squeeze(-1) def fitness(genes, train_df, val_df, feature_cols, target_col): hidden, lr, seq_len, dropout = decode(genes) if seq_len >= len(train_df) - 2: return 1e9 x_train, y_train = transform_df(train_df) x_val, y_val = transform_df(val_df) Xtr, ytr = make_samples(x_train, y_train, seq_len) # 验证段切掉前 seq_len 行,避免窗口引用训练段尾巴 Xva, yva = make_samples(x_val[seq_len:], y_val[seq_len:], seq_len) model = LSTMPredictor(len(feature_cols), hidden, dropout) opt = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.MSELoss() for epoch in range(15): model.train() for xb, yb in DataLoader(TensorDataset(torch.tensor(Xtr), torch.tensor(ytr)), batch_size=32, shuffle=True): opt.zero_grad() loss_fn(model(xb), yb).backward() opt.step() model.eval() with torch.no_grad(): pred = model(torch.tensor(Xva)) yva_t = torch.tensor(yva) rmse = float(torch.sqrt(torch.mean((pred - yva_t) ** 2))) return rmse逻辑说明:fitness 里做四件事:解码超参、按该 seq_len 重建样本、训练 15 轮、返回验证 RMSE。Xva 的构造刻意用了 x_val[seq_len:] 和 y_val[seq_len:],避免验证集最前面的样本引用训练段末尾的真实值,这种重叠会让适应度虚高,GA 选出「假最优」。GA 阶段训练轮数只给 15,是刻意为之:种群 12、代数 15,意味着最多要训练 180 个小型 LSTM,每个 15 轮已经是不小的算力消耗。
参数说明:batch_size 32,输入是 (样本数, seq_len, 特征数)。shuffle=True 在训练时可以打乱时序,因为每个样本自身已经包含窗口上下文。如果把它换到自己的数据上,优先改三个地方:seq_len 合法性判断、训练轮数、验证集重叠处理。数据噪声大时可以把 15 轮提高到 25 轮,代价是单次评估更慢。
3.3 GA 主循环:锦标赛选择、单点交叉和精英保留
有了种群和 fitness,主循环就比较固定了。我常用锦标赛选择加精英保留,代码量少且不容易早熟。
def tournament_select(pop, fitness_scores, k=3, rng=None): idx = rng.integers(0, len(pop), k) return pop[idx[np.argmin([fitness_scores[i] for i in idx])]] def crossover(a, b, prob=0.8, rng=None): if rng.random() > prob: return a.copy(), b.copy() cut = rng.integers(1, len(a)) return (np.concatenate([a[:cut], b[cut:]]), np.concatenate([b[:cut], a[cut:]])) def mutate(gene, prob=0.1, scale=0.1, rng=None): if rng.random() < prob: gene = gene + rng.normal(0, scale, size=gene.shape) return np.clip(gene, 0, 1) rng = np.random.default_rng(42) pop = init_population(12, seed=42) for gen in range(15): scores = np.array([fitness(g, train_df, val_df, feature_cols, target_col) for g in pop]) elite_idx = np.argsort(scores)[:2] elites = pop[elite_idx].copy() new_pop = [] while len(new_pop) < len(pop) - 2: p1 = tournament_select(pop, scores, k=3, rng=rng) p2 = tournament_select(pop, scores, k=3, rng=rng) c1, c2 = crossover(p1, p2, prob=0.8, rng=rng) new_pop.append(mutate(c1, prob=0.1, rng=rng)) new_pop.append(mutate(c2, prob=0.1, rng=rng)) pop = np.vstack([elites, np.array(new_pop[:len(pop) - 2])])逻辑说明:每一代先算全部个体的 fitness,把前 2 名直接复制到下一代,保证最优解不被交叉和变异破坏。剩下 10 个个体由锦标赛产生:每次从种群随机抽 3 个,选 fitness 最好的作为父本。交叉用单点交叉,切割位置在基因的第 1 到第 3 维之间;变异是加一个均值为 0 的高斯扰动,最后 clip 回 [0,1]。迭代 15 代后,pop 里适应度最好的是 GA 找到的最优超参。
| GA 参数 | 推荐值 | 为什么这么设 |
|---|---|---|
| 种群大小 | 12~20 | 每次评估都要训练模型,太大拖慢整体;太小容易早熟 |
| 迭代代数 | 15~25 | 多数单序列预测 15 代左右收敛,超过 25 代收益很低 |
| 交叉率 | 0.7~0.8 | 让优质基因片段充分组合 |
| 变异率 | 0.05~0.1 | 低于 0.05 早熟,高于 0.1 退化成随机搜索 |
| 精英数量 | 1~2 | 保留最优,超过 2 会让种群多样性下降 |
参数说明:上面代码写死了 12、15、0.8、0.1,实际建议先小规模跑一版看每代收敛曲线。如果第 10 代最优值还在明显下降,把代数加到 20;如果最优很早就稳住了但平均 fitness 和最优差很远,说明多样性不够,把变异率调到 0.15 或把种群加到 20。
4. 拿到最优超参之后:重训、多步预测和评估口径
4.1 用最优超参重训一次真正的最终模型
GA 阶段每个个体只训练 15 轮,属于「够用来比较、不够用来交付」。拿到最优基因之后,需要按同一组超参,把训练轮数拉满,配合验证集保存最优权重。
best_genes = pop[np.argmin(scores)] hidden, lr, seq_len, dropout = decode(best_genes) x_train, y_train = transform_df(train_df) x_val, y_val = transform_df(val_df) X_train, y_train_win = make_samples(x_train, y_train, seq_len) X_val, y_val_win = make_samples(x_val[seq_len:], y_val[seq_len:], seq_len) model = LSTMPredictor(len(feature_cols), hidden, dropout) opt = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.MSELoss() best_val_rmse = float("inf") for epoch in range(80): model.train() for xb, yb in DataLoader(TensorDataset(torch.tensor(X_train), torch.tensor(y_train_win)), batch_size=32, shuffle=True): opt.zero_grad() loss_fn(model(xb), yb).backward() opt.step() model.eval() with torch.no_grad(): val_rmse = float(torch.sqrt(torch.mean((model(torch.tensor(X_val)) - torch.tensor(y_val_win)) ** 2))) if val_rmse < best_val_rmse: best_val_rmse = val_rmse torch.save(model.state_dict(), "ga_lstm_best.pt")逻辑说明:这轮训练最多 80 轮,实际执行时 10~20 轮就能到稳定平台。重点在于「只在验证集 RMSE 变好时保存权重」,而不是训练完最后一轮直接收尾,这样保留了训练过程中的最优状态。
参数说明:seq_len 在这一步才真正参与最终数据集构造。保存权重时建议把 BOUNDS、seq_len、feature_cols、scaler_y 一起打包,否则预测脚本里还要手工对齐归一化参数。另一个别漏的细节:X_val 同样用 val 的 seq_len 之后构造,和 GA 阶段口径一致,这样比较才有意义。
4.2 单步预测与滚动多步预测:两者要分开评估
很多预测任务的真实需求不是下一天,而是未来 7 天或 30 天。LSTM 天然输出单步,想得到多步结果最常见的是滚动预测:把预测值拼回输入序列,再取下一个窗口,重复执行。
def roll_predict(model, recent_seq, scaler_y, horizon): model.eval() cur = recent_seq.copy() preds = [] with torch.no_grad(): for _ in range(horizon): y_norm = model(torch.tensor(cur[None, ...])).item() y_real = float(scaler_y.inverse_transform([[y_norm]])[0, 0]) preds.append(y_real) nxt = np.roll(cur, shift=-1, axis=0) nxt[-1, -1] = y_norm # 预测值补到目标特征列 cur = nxt return preds逻辑说明:cur 是长度为 seq_len、宽度为特征数的归一化窗口。预测出 y_norm 后,用 np.roll 把整行前移一步,再把新预测值放在最后一行的目标特征列,窗口继续滚。这种方法的缺点是误差累积:第 1 天偏了,第 2 天会把偏掉的预测值当输入继续滚。另一个方向是让模型直接输出多步,把最后的 Linear 层改成 horizon 个节点,训练标签变成未来 horizon 步序列;直接多输出不会「越滚越平」,但样本和 loss 都要重构,改动量比滚动大。
参数说明:这段代码假设目标列是特征矩阵的最后一列;如果特征列还有其他物理量,一定要把每一列分别滚动更新,否则窗口信息会错位。horizon 按业务需求设置,金融序列常用 5 或 7,负荷预测常用 30;别设成训练样本长度的三分之一以上,否则验证窗口过小。
4.3 评估指标怎么给:RMSE、MAE、MAPE 各算各的账
预测代码里最容易吵起来的地方是指标。RMSE 对大误差敏感,适合温度、负荷这类不希望出现单日大偏差的场景;MAE 更稳健,适合有少量极端点的数据;MAPE 业务上直观,但遇到真实值接近 0 的时刻会爆炸,所以要剔除近零样本后单独算。
def evaluate_metrics(y_true, y_pred): y_true = np.asarray(y_true, dtype=np.float64) y_pred = np.asarray(y_pred, dtype=np.float64) rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2))) mae = float(np.mean(np.abs(y_true - y_pred))) mask = np.abs(y_true) > 1e-6 mape = float(np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100) return {"rmse": rmse, "mae": mae, "mape": mape}逻辑说明:这段代码直接作用于还原后的真实量纲值,三个指标一次算齐。RMSE 和 MAE 不需要额外条件,MAPE 先过滤掉绝对值小于 1e-6 的样本,避免除零。最终报告里我习惯把单步评估和滚动多步评估分开写:单步 RMSE 反映模型对序列本身的学习能力,滚动多步 RMSE 反映业务上真实的 7 天预测表现。两者差别很大时,优先怀疑滚动窗口里的误差累积。
参数说明:mask 阈值 1e-6 不是固定标准,目标值普遍在万级以上可以改成 1;目标值本身很小就保持更小阈值。评估测试集时,测试段长度至少要是 horizon 的 5 倍,否则换一个起始点结果会完全不同。
5. 避坑清单:GA-LSTM 最容易翻车的 5 个地方
5.1 数据泄漏:归一化先 fit 全量、随机切分时间序列
现象:训练和验证的 RMSE 都很理想,一上真实预测就明显偏小或进入平台期,怎么看都不像同一套参数。
原因:最常见的是把整个数据集一次性交给 MinMaxScaler.fit,测试段的 min/max 提前参与归一化;或者用 sklearn 的 train_test_split 随机切分,把未来数据混进训练集。对滑动窗口样本还有一种更隐蔽的重叠:验证集最前面 seq_len 个样本引用训练段末尾的真实值。
解决:scaler 只 fit 训练段;切分按时间顺序;验证样本从 val 的 seq_len 之后开始构造。上线前再检查 transform 后的验证值是否大量堆积在 0 和 1 边界,如果是,说明测试段分布超出训练段范围,GA 选出的窗口和缩放参数都要重新考虑。
5.2 适应度噪声:为什么同样的超参跑几次结果不一样
现象:GA 一代比一代好,但把最优超参手动重跑一遍,验证 RMSE 比 GA 里记录的高出不少,有时翻一倍。
原因:fitness 函数没有固定随机种子,LSTM 初始化、DataLoader shuffle 顺序每次都不一致;只有 15 轮训练时,随机噪声在适应度里的占比很高,GA 很可能在替「运气好的一次训练」选超参。
解决:跑 GA 前把 torch.manual_seed、np.random.seed 全部固定;个别个体想多看几个随机结果,就在 fitness 里对同一个基因训练两次取平均。验证集样本少于 50 个时,适应度本身就不可信,先扩大验证集比例,或用时间序列交叉验证的均值做适应度。
5.3 计算开销失控:种群太大、每代全量重训、收敛后还在跑
现象:种群 30、代数 30,一跑就是几个小时,最后选出的结果和随机搜索差不多,白白烧掉机器。
原因:每个个体都从零开始训练 LSTM,计算量翻倍很快;GA 本身并不保证比网格搜索和随机搜索更好,只是相同算力下通常更高效。种群和代数设得太大,优势会被巨大的测试时间吞掉。
解决:先按 12 个种群、15 代跑一版;fitness 里训练轮数从 15 开始,不要一上来就 50。每代记录 best 和 average,连续 5 代没有明显变化就提前结束。想进一步提速,可以缓存同代里出现过的高分基因,同一组超参不要重复训练。
5.4 多步预测验证口径对不上:报告单步,业务要 7 天
现象:验证集上第 1 步 RMSE 很好,画未来 7 天曲线却从第 3 天开始明显偏平,和单步表现完全不匹配。
原因:训练和 GA 阶段都只计算单步 loss,验证指标也是单步,业务方要的却是滚动 7 天预测。单步好不代表多步好,滚动预测的误差累积在评估里被漏掉了。
解决:最终评估必须用和业务一致的 horizon,用同样的滚动预测流程在测试段上跑一遍,而不是用模型在验证集上的单步输出。如果改用直接多步输出,验证指标也要改成多步目标的平均 loss,两边口径对齐再谈调参。
5.5 超参搜索空间本身就是错的方向
现象:GA 跑完,最优超参比固定默认超参好一点点,换一个数据集甚至更差。
原因:搜索空间设得太窄或太宽。比如 hidden 范围 4~16,模型表达力不够,GA 再努力也找不到好结果;或者 seq_len 上限 100,但训练数据只有 300 条,大部分个体构造出的样本数太少,适应度噪声完全盖过超参差异。
解决:先用一组固定超参把纯 LSTM 跑通,确认数据和预处理没问题;再根据这个基准把 BOUNDS 设得宽但不极端。最优解如果总落在某个边界附近,比如 hidden 总是在 16 或 128,说明边界选得不对,该放宽或收窄。
6. 验证这套 GA-LSTM 有没有白调:基线和多次重训
如果要给这套方案做收尾,我的验证习惯是这样:先定一组不加优化的 LSTM 作为基线,hidden=32、lr=0.001、seq_len=12、dropout=0.2;再用随机搜索跑同样的预算,最后才是 GA-LSTM。每个方法在测试集上都用 5 个不同的随机种子重训,报告验证集 RMSE 的中位数和范围,而不是只报一次结果。LSTM 单次训练有随机性,只跑一次你分不清提升来自算法还是来自运气。
假设你得到的结果可能是:
| 方法 | 验证集 RMSE 中位数 | 说明 |
|---|---|---|
| 固定超参 LSTM | 12.6 | 基线,超参靠经验 |
| 随机搜索 | 11.9 | 同样预算随机试了 30 组 |
| GA-LSTM | 11.2 | 同样预算按进化方向搜索 |
如果 GA-LSTM 只比随机搜索好一点点,我不会太兴奋,说明收益可能来自超参搜索本身,而不是 GA 的收敛能力。这时应该先扩大搜索空间,而不是继续加种群或代数。真正该怀疑 GA 没起作用的情况是:GA 结果比随机搜索差,或者最优解总落在边界上。前者大多是 fitness 不稳定,后者大多是 BOUNDS 设置不合理。
还有一个细节容易被忽略:GA 是在验证集上选超参,它也会过拟合验证集。保险的做法是留出独立测试集,GA 全程只在 train/val 上做,测试集最后碰一次。我自己的习惯是最终重训时换不同随机种子跑 5 次,把中位模型保存下来,同时记下最优和最差的差距。这个差距如果超过验证集 RMSE 的 20%,说明单次训练噪声太大,优先解决稳定性而不是继续调 GA。这也是一直强调「先用纯 LSTM 跑通数据,再把 GA 接上去」的原因——当年我第一次直接套 GA-LSTM,跑了一整晚,后来才发现是归一化泄漏,白折腾一晚。希望这套流程能帮你少踩这个坑。
本文还有配套的精品资源,点击获取