简介:基于PSO-LSTM神经网络的股票调整收盘价预测Python源码,是一份面向高校期末大作业与课程设计的可直接运行项目。代码含完整注释,并通过粒子群算法自动寻优学习率、隐含层节点等关键超参数,结合LSTM完成对调整收盘价的单步预测,逻辑清晰,新手也能快速读懂全流程。压缩包共10个文件,核心为1个Python预测脚本,另配有7个CSV格式的股票历史数据集(涵盖美股、A股、黄金等不同标的)、1份项目说明与1个备注文本,总计仅490KB,轻量易部署。目前已有171人学习,适合金融时序预测、深度学习相关课程的高分参考。下载后即可直接运行,按注释修改数据路径与模型参数,再利用多组行情数据完成对比验证,为期末答辩或实验报告提供扎实的代码与数据支撑。
1. 为什么是 PSO-LSTM:调整收盘价预测这个场景,传统模型先输一半
拿到「基于 PSO-LSTM 神经网络的股票调整收盘价预测 Python 源码.zip」这类项目,第一反应通常是两个疑问:LSTM 做股价预测不早就被讲烂了吗?PSO 又是来干嘛的?实际上,当目标从「收盘价」换成「调整收盘价」之后,问题性质变了:序列里藏着除权除息造成的价格跳空,普通模型会把跳空当趋势学,预测曲线一出测试集就“飞”了。LSTM 能记住较长周期内的涨跌结构,但对超参数极其敏感,hidden units 设成 32 还是 128,预测滞后能从 1 个交易日拉长到 5 个交易日。PSO 在这里解决的就是“LSTM 参数靠手感”的问题,用粒子群把学习率、隐层节点数、时间步长这批超参自动搜出来。
这条技术路线适合两类人:一类是刚接触量化但不想只停留在调用 sklearn 回归的初学者,想看看神经网络怎么端到端地吃行情数据;另一类是已经跑过 LSTM 但被调参折磨过的熟手,想找一个可复现的超参数搜索方案。源码包的落地价值不在“预测准确率有多高”,而在于它把「数据预处理 → PSO 寻参 → LSTM 训练 → 反归一化回测」这条链路完整走通了。下面按这条链路拆开讲。
2. PSO 和 LSTM 为什么要组合:从“黑匣子”到参数可解释
2.1 LSTM 被选中的理由:时序记忆与调整收盘价的连续性
在股票序列建模里,常见神经网络候选有四类:前馈神经网络(BP 神经网络)、卷积神经网络、LSTM 神经网络、Transformer 神经网络。BP 神经网络和卷积神经网络有一个共同短板——它们默认样本独立,要把时间上下文硬塞进特征工程里,比如用过去 5 天的涨跌幅拼成一个 5 维向量。这种做法在行情波动平缓时勉强够用,一旦遇到连续跳空或长时间的横盘震荡,手工特征很难把“这个走势和三个月前某段走势相似”这种长程依赖表达出来。
LSTM 神经网络的核心机制是门控单元:输入门决定新信息写入多少,遗忘门决定记忆单元丢弃多少,输出门决定当前时刻放出多少。三个门配合,让梯度在时间维度上能传得更远,这就是它比 BP 神经网络更适配金融时序的原因。调整收盘价序列有一个特点——经过复权处理后,价格是连续的,但连续性里嵌入了“除权除息日附近波动率骤增”的局部异常,LSTM 的遗忘门可以学会在这些局部异常之后把旧状态重置,这是普通前馈网络做不到的。
2.2 PSO 到底在优化什么:超参数搜索的三种常见姿势
LSTM 的超参数组合空间极大:时间步长(seq_len)、隐层节点数(hidden_units)、网络层数(num_layers)、学习率、batch size、dropout、优化器类型。每个参数取 5 个候选值,组合数就是 5 的 6 次方,接近 1.6 万种,逐个跑网格搜索,在 CPU 上够跑一个通宵。
常见做法有三种替代方案。第一种是随机搜索,从参数空间里随机采样 N 组,跑完取最优;第二种是贝叶斯优化,用高斯过程拟合“参数 → 验证集误差”的响应面,每次迭代选最有潜力的点;第三种就是粒子群优化(PSO)。PSO 的思路很直白:初始化一群粒子,每个粒子代表一组超参数,粒子在参数空间里飞,飞行的方向由两个因素决定——粒子自己历史上找到过的最优位置(pbest)和整个群体找到过的最优位置(gbest)。
PSO 相对网格搜索的核心优势是“用梯度无关的方式做智能搜索”,它不假设参数和目标函数之间有什么光滑关系,在离散参数(如层数)和连续参数(如学习率)混合的场景里可以直接编码。相对贝叶斯优化,PSO 的实现更简单,不需要维护代理模型,一个速度更新公式加一个位置更新公式就能迭代。它的代价是需要多次评估适应度,每一轮都要完整训练一次 LSTM,所以实际工程里通常把粒子数控制在 8~15,迭代次数控制在 5~10 轮。
2.3 PSO-LSTM 的最小数据流:从行情列表到预测张量
把整条链路的张量形状理清楚,后面读源码会顺畅很多。假设拿到的历史行情是 DataFrame,包含 date、open、close、high、low、volume、adj_close 七列,按日期升序排列。
第一步是构造滑窗样本。设定 seq_len=20,意思是“用过去 20 个交易日的调整收盘价预测下一个交易日的调整收盘价”。滑动窗口产生 N-20 个样本,每个样本的形状是 (20, feature_dim),其中 feature_dim 是特征列数。如果只用调整收盘价一个特征,feature_dim=1;如果加入成交量、涨跌幅、最高最低价差等特征,feature_dim 会变大。
第二步是划分数据集。注意这里是时间序列,不能随机打乱。常见切法是前 70% 做训练集,中间 15% 做验证集,最后 15% 做测试集。验证集在 PSO 流程里承担“适应度评估”的角色,测试集从头到尾不参与任何参数选择,只在最后评估一次。
第三步是归一化。调整收盘价序列的量纲通常在几元到几百元之间,而 LSTM 内部激活函数对输入尺度敏感,所以要用 MinMaxScaler 把数据压到 [0,1] 区间。这里有三个细节容易被忽略:一是 scaler 只能 fit 训练集,验证集和测试集用同一个 scaler 做 transform,防止未来数据信息泄漏;二是如果序列里有极端值(比如某天因为异常交易出现 50% 的巨幅波动),MinMax 会被拉偏,可以考虑改用 RobustScaler;三是预测出的结果必须做反归一化才能和真实价格对比,否则 RMSE 数值小得漂亮但毫无意义。
3. 调好“调整收盘价”这个输入:前后复权、缺失值与特征工程
3.1 调整收盘价 vs 不复权收盘价:为什么必须用复权序列
很多初学者拿到行情数据后直接取 close 列开始训练,这是第一个翻车点。股票在除权除息日会有一个价格跳变:10 送 10 的股票,除权日开盘价直接腰斩。如果把这个跳变喂给 LSTM,模型会学出一个“莫名其妙跌了 50%”的模式,后续预测全部被带偏。
调整收盘价(adjusted close)解决的就是这个问题。它的计算方式是把历史价格按分红送股比例统一折算到当前口径,常见有两种:前复权和后复权。前复权保持最新价格不变,往前调整历史价格;后复权保持最早价格不变,往后调整。对训练 LSTM 来说,后复权序列更合理——最新价格是动态变化的,前复权会随着每次新除权事件把整段历史价格重新折算一遍,导致训练数据的数值每天都在变,模型不可复现。
实际工程坑在于:不同数据源的调整收盘价算法不一样。有的数据源只考虑现金分红,不考虑送转股;有的把两者都算进去;还有的会把配股也折算进去。同一只股票,两个数据源给出的 adjusted close 可能差 3%~5%,训练出来的模型在测试集上的表现会完全不同。所以项目里必须在数据加载阶段打印数据源名称和复权方式,否则预测结果“漂移”了都不知道是模型问题还是数据问题。
3.2 数据清洗与滑窗构造:训练集/验证集/测试集的时间顺序
股票数据不像 MNIST 那样拿过来就能训练,清洗步骤缺一不可。第一步是去重:同一交易日出现多行记录,通常是因为盘中多次采集,保留最后一行的收盘数据即可。第二步是去停牌日:停牌期间没有交易,价格是空值或前值填充,这些行要直接删掉,否则滑窗里混入大量重复价格,等于给模型灌了噪声。第三步是跳空检测:交易日之间如果出现超过 20% 的涨跌幅,要检查是否为数据错误,比如单位搞错、小数点移位。
滑窗构造里还有个容易忽略的细节:窗口之间是否重叠。如果 stride=1,相邻两个样本有 19 天的数据重叠,这会让验证集和训练集之间存在大量信息重复,模型评估指标虚高。更稳妥的做法是训练集内允许重叠来增加样本量,但验证集和测试集的起始位置要在时间上严格后移,保证验证集完全不包含训练集窗口里的数据。
代码层面,滑窗构造通常写成下面这样:
import numpy as np import pandas as pd def create_sequences(data, seq_len=20, stride=1): xs, ys = [], [] for i in range(0, len(data) - seq_len, stride): x = data[i : i + seq_len] y = data[i + seq_len] xs.append(x) ys.append(y) return np.array(xs), np.array(ys) # 假设 df 已按日期升序排列,且只取 adj_close 列 values = df["adj_close"].values.reshape(-1, 1) train_val_cut = int(len(values) * 0.7) val_test_cut = int(len(values) * 0.85) train_raw = values[:train_val_cut] val_raw = values[train_val_cut:val_test_cut] test_raw = values[val_test_cut:] X_train, y_train = create_sequences(train_raw, seq_len=20) X_val, y_val = create_sequences(val_raw, seq_len=20) X_test, y_test = create_sequences(test_raw, seq_len=20)这段代码里有个细节:create_sequences 对训练集和验证集分别调用,而不是先拼起来再切,就是为了防止窗口跨段导致数据泄漏。如果你发现验证集效果比训练集还好,先怀疑是不是这里写错了。
3.3 特征归一化:MinMaxScaler 的拟合时机与回测陷阱
归一化是整个流程里看起来最简单、实际最容易埋雷的一步。错误示范是先对全量数据做 MinMaxScaler,再切训练集/验证集/测试集——测试集的 min 和 max 已经参与了缩放,等于让模型在训练时就“瞥见”了未来的价格区间,回测指标会好得离谱,实盘直接打回原形。
正确做法是先切分、再归一化。训练集上 fit 出 scaler,然后对验证集和测试集分别 transform。写成代码是:
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) train_scaled = scaler.fit_transform(train_raw) val_scaled = scaler.transform(val_raw) test_scaled = scaler.transform(test_raw)注意 predict 之后的反归一化,必须使用同一个 scaler 对象,而不是重新 fit。很多源码包在这里写错,最后画出来的预测曲线和真实曲线数值对不上。反归一化这行代码在预测阶段要反复用到,建议封装成一个函数,避免在多个 notebook 单元格里复制粘贴出错。
4. 把源码跑通:PSO-LSTM 核心代码与关键参数说明
4.1 项目文件结构与运行顺序
这类源码包的结构通常遵循“数据 → 模型 → 优化 → 回测”的四层约定。拿到 zip 解压后,先看有没有 README 或 requirements.txt,前者告诉你文件用途,后者告诉你依赖库清单。一个典型结构大致是这样:
- data/ 目录:存放行情数据抓取脚本或 CSV 文件
- pso.py:粒子群优化算法实现
- lstm_model.py:LSTM 网络结构定义
- train_pso.py:主程序,串联数据加载、PSO 迭代、模型训练
- evaluate.py:测试集评估与可视化
运行顺序是先跑数据准备脚本生成 CSV,再跑 train_pso.py,最后跑 evaluate.py。如果跳过数据准备直接训练,大概率报文件不存在错误。这里不建议在 Windows 命令行里直接python train_pso.py一把梭,建议用 vscode 打开项目根目录,配置好 Python 环境后逐文件运行,遇到 import 报错能在编辑器里直接追溯。
4.2 PSO 粒子与适应度函数
PSO 的 Python 实现核心是粒子类。每个粒子的位置是一个一维向量,每一位对应一个超参数。常见的编码方式是[learning_rate, hidden_units, num_layers, seq_len, batch_size],其中 learning_rate 是连续变量,后四个是整数变量。位置更新公式需要处理整数取整问题,通常的做法是每次更新后对相应维度做round()并限制在搜索边界内。
import numpy as np class Particle: def __init__(self, bounds): self.position = np.array([ np.random.uniform(low, high) for low, high in bounds ]) self.velocity = np.zeros(len(bounds)) self.pbest_position = self.position.copy() self.pbest_value = float("inf") def clamp(self, bounds): for i in range(len(bounds)): low, high = bounds[i] self.position[i] = np.clip(self.position[i], low, high) if i > 0: # 整数参数取整 self.position[i] = round(self.position[i])这里对第 0 维(learning_rate)不做取整,因为它需要连续值,其他维度取整后送入 LSTM 模型才能作为 hidden_units、num_layers 这类整数参数使用。
适应度函数的写法决定了 PSO 能否收敛。它接收一组超参数,构建 LSTM,在训练集上训练若干个 epoch,返回验证集上的 RMSE。这个函数会被反复调用,所以一定要控制训练时间和 epoch 数。
def fitness_function(params, X_train, y_train, X_val, y_val): lr, hidden_units, num_layers, seq_len, batch_size = params hidden_units = int(hidden_units) num_layers = int(num_layers) seq_len = int(seq_len) batch_size = int(batch_size) model = LSTMPredictor( input_size=1, hidden_units=hidden_units, num_layers=num_layers ) history = train_model( model, X_train, y_train, lr=lr, epochs=15, batch_size=batch_size ) val_pred = predict(model, X_val) rmse = np.sqrt(np.mean((y_val - val_pred) ** 2)) return rmse关键细节是 epochs 不能设太大,否则一个粒子就要跑几分钟,整个 PSO 流程在地铁上都跑不完。15~20 个 epoch 足够分出参数好坏,最后拿到最优参数后再用更多 epoch 做正式训练,这是 PSO-LSTM 项目里最常见的省时间技巧。
4.3 LSTM 模型构建与训练
LSTM 网络结构在 PyTorch 下的写法比较固定。单层或多层 LSTM 后接一个全连接层输出预测值。注意输入张量的维度顺序是(batch, seq_len, input_size),很多人在这里翻车,把维度传成(seq_len, batch, input_size)导致训练时形状不匹配。
import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size=1, hidden_units=64, num_layers=2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_units, num_layers=num_layers, batch_first=True, dropout=0.2 if num_layers > 1 else 0.0 ) self.fc = nn.Linear(hidden_units, 1) def forward(self, x): # x shape: (batch, seq_len, input_size) out, _ = self.lstm(x) # 取最后一个时间步的输出 out = out[:, -1, :] return self.fc(out)batch_first=True 这个参数值得专门提一句:PyTorch 的 LSTM 默认把 batch 放在第 1 维(第 0 维是时间步),而 PyTorch 的 DataLoader 产出的张量是 batch 在第 0 维,加了 batch_first=True 之后两者才对得上,省去转置的麻烦。
训练循环里,损失函数用 MSELoss,优化器用 Adam。Adam 对学习率的敏感度比 SGD 低,配合 PSO 搜出来的 lr 值更稳健。代码里有一个工程细节:训练过程中记录每个 epoch 在验证集上的 loss,如果连续 5 个 epoch 没有改善就 early stop,避免粒子评估浪费时间。
4.4 预测、反归一化与结果输出
训练完成后进入预测阶段。预测的核心逻辑是用最后 seq_len 个训练数据作为初始窗口,逐日滚动预测。每预测出一天的值,就把它加入窗口末尾、丢掉窗口最前面的值,保持窗口长度不变。这是时间序列预测的标准做法,但实现时有个坑:预测出的值要反归一化后再塞回窗口,下一次预测时再重新归一化,否则误差会累积。
def rolling_predict(model, scaler, last_window, predict_days=30): model.eval() window = last_window.copy() predictions = [] for _ in range(predict_days): with torch.no_grad(): x = torch.FloatTensor(window).unsqueeze(0) # (1, seq_len, 1) pred_scaled = model(x).item() pred_price = scaler.inverse_transform( np.array([[pred_scaled]]) )[0][0] predictions.append(pred_price) # 更新窗口:用反归一化后的真实价格尺度 new_val = pred_scaled window = np.roll(window, -1, axis=0) window[-1] = new_val return np.array(predictions)这段代码里 window 是 shape 为(seq_len, 1)的二维数组,np.roll 把整体前移,最后一个位置填入新预测值。注意 new_val 用的是归一化后的值,因为 window 本身是归一化尺度,混入反归一化价格会破坏输入分布。
评估阶段输出三样东西:测试集预测曲线图、RMSE 数值、方向准确率(预测涨跌与真实涨跌一致的占比)。方向准确率比 RMSE 更能反映模型的实际交易价值,源码包里通常用一行np.mean((np.diff(y_true) * np.diff(y_pred)) > 0)计算,这个指标建议保留。
5. PSO-LSTM 调参与踩坑:5 个高频问题的现象、原因与解法
5.1 适应度曲线不下降,每个粒子都在原地打转
现象:PSO 迭代 5 轮,gbest 的 RMSE 几乎没有变化,打印每个粒子的位置发现大家集中在初始位置附近。原因多半是适应度函数写错了,粒子位置变化后没有正确映射到模型超参数,典型错误是把整数参数直接浮点传入了 nn.LSTM 的 hidden_size,PyTorch 里 hidden_size 是浮点时不会立刻报错,但会被截断成同一个值,导致所有粒子的模型结构完全相同。另一个常见原因是归一化写在了滑窗之前,导致每个粒子的训练数据都一样,模型毫无区分度。
解决:在 fitness_function 开头打印当前粒子的位置和训练数据 shape,人工验证两三轮;把 hidden_units、num_layers、seq_len 全部强制 int() 并断言值大于 0;确认归一化是 fit 在训练集上。
5.2 训练集拟合得很好,测试集预测曲线是一条平移的滞后线
现象:测试集上预测曲线和真实曲线形状几乎一致,但整体向右平移了 1~2 个交易日,方向准确率只有 50% 上下。这是 LSTM 单步预测的经典翻车现场。原因是模型学习到“明天的价格约等于今天的价格”这种朴素规律,在平稳行情里这已经是最小化 MSE 的最优解,而 PSO 的适应度函数恰恰是 RMSE,于是它会把参数往“最懒预测”的方向引导。
解决:换适应度函数。把 RMSE 改成混合指标,例如0.5 * RMSE + 0.5 * (1 - direction_accuracy),让粒子不仅要预测准价格,还要预测准方向;或者把预测目标从“明日收盘价”改成“未来 5 日收益率”,迫使模型学习中期趋势而不是拟合一阶自相关。
5.3 PSO 过早收敛到局部最优,搜索到的参数和随机生成的差不多
现象:gbest 在第二轮就不再更新,最终参数接近初始化值的中心点。原因通常是粒子数量和迭代次数不匹配——粒子数太少(比如 4 个)、迭代次数太少(比如 3 轮),粒子还没飞开就停了;也可能是速度更新式的惯性权重 w 设得太小(小于 0.3),粒子缺乏全局探索能力。
解决:惯性权重 w 从 0.9 线性衰减到 0.4,这是 PSO 论文里最经典的设置。c1 和 c2 分别设成 2.0 和 2.0,如果发现收敛太快就调大 w 上限到 1.2。粒子数建议 10~12 个,迭代次数至少 8 轮,一轮跑不完就缩短单粒子的训练 epoch。
5.4 调整收盘价数据源不一致导致预测结果漂移
现象:同一时间段、同一只股票,换了一个数据源之后,RMSE 从 1.2 变成了 2.8,但你根本没改模型代码。原因就是数据源复权算法不一致。有的数据源对分红送股做的是“等比复权”,有的是“等差复权”,两者的历史序列在前 5 年会差出一大截。还有的数据源把未复权价格和复权因子分开给,源码里如果只读了 close 而没乘上复权因子,就回到了第一节说的“跳空陷阱”。
解决:在数据加载后加一个断言校验——检查最近一个交易日的 adj_close 是否等于 close,正常情况下它们应该完全相等(因为最新价格不需要复权);再往历史方向抽查 3 个除权日附近的价格,确认没有 50% 左右的跳变。两处都通过再进入训练流程。
5.5 CPU 环境下的训练时间失控,一个粒子要跑 10 分钟
现象:PSO 共 10 个粒子、迭代 8 轮,每轮每个粒子训练 30 个 epoch,总计 2400 次模型训练,CPU 上跑完要十几个小时。原因不是代码慢,而是评估次数太多。解决思路有三个方向:第一个方向是缩小搜索空间,把 seq_len 固定在 20,只搜 learning_rate、hidden_units、num_layers 三个维度;第二个方向是提前终止,验证集 loss 连续 3 个 epoch 不降就跳出训练;第三个方向是"群体记忆",把每个粒子历史训练过的超参数组合缓存起来,遇到相同组合直接复用历史 RMSE,不重复训练。第三种方式在粒子数较多时能省掉 30% 以上的训练时间,值得写进源码。
6. 从“跑通”到“可信”:残差检验与滚动预测的进阶验证
源码包跑通只是起点,评测 PSO-LSTM 值不值得用于实盘,必须做两件事:残差检验和滚动预测。
残差检验关注的是预测误差是否还有可利用的模式。把测试集预测值和真实值做差,得到残差序列,然后看三样东西:残差均值是否接近 0,残差是否存在显著自相关(用 Ljung-Box 检验,p 值小于 0.05 说明残差里还有信息没被模型学到),残差方差是否随时间变化。如果残差表现出明显的“波动聚集”——大涨大跌后残差也大、平稳期残差也小,说明模型没捕捉到波动率变化,此时再调 LSTM 结构意义不大,应该考虑把 GARCH 类模型或波动率特征加进去。
滚动预测比一次性切分更接近实盘场景。按顺序做 20 次实验:每次用前 80% 的数据训练,预测接下来 5 个交易日,然后整个窗口往后推 5 天。20 次预测串起来形成一个连续的预测序列,在这条序列上计算 RMSE 和方向准确率。滚动预测的意义在于它暴露了模型在不同市场状态下的稳定性——单次测试集只代表一段行情,滚动的结果才能看出模型在上涨、下跌、横盘三种状态下分别什么表现。
如果滚动预测结果表现不错,再考虑能不能把预测结果落成一个简单的交易信号,比如“预测未来 5 日收益为正且超过阈值时持有”。但这里有一条必须守住的红线:任何基于历史价格的预测模型,在真实市场里都要面对无法消除的不确定性,回测指标永远只是下限而非上限。我自己的习惯是,跑完 PSO-LSTM 之后先把测试集预测图保存下来,和真实 K 线叠加,肉眼确认预测曲线在关键拐点处的滞后程度,这个步骤比任何指标都更能让人清醒——模型学到的是规律还是幻觉,一眼就能看出来。记住,神经网络是黑匣子,但你的验收流程不能是黑匣子。希望这篇拆解能帮你把这个源码包从“跑通”推进到“看懂”,少走几步我曾经踩过的弯路。
本文还有配套的精品资源,点击获取