LSTM神经网络在股指预测与量化选股中的完整实践路径
简介:一份聚焦神经网络在金融时序分析中应用的科研论文PDF,面向量化研究、金融建模及机器学习实践者。资料系统梳理了数据预处理环节中的异常值检测与缺失值填补流程,重点讲解了RBP神经网络补全缺损数据、Markowitz均值-方差组合模型优化收益风险,以及NARX非线性多步预测网络构建股指预测模型的方法;同时结合Python绘制时间序列调和曲线,直观展示数据缺失情况。NARX模型采用三层结构,隐含层使用高斯激活函数,依据链式偏微分法则调整网络参数,最终给出平均误差0.092以内的实证结果。内容还覆盖了投资组合相关矩阵与协方差矩阵的评价维度,以及过拟合与交叉验证等实操问题,从数据清洗、组合评估到模型优化形成完整链路。资源为单份PDF文件,共1个文件,压缩包大小1.83MB,已有119人学习浏览。对于希望快速理解神经网络选股思路、NARX动态建模技巧以及风险收益平衡机制的读者,这份材料能提供较清晰的算法推导与实验参考价值,适合作为金融科技、数据建模方向的入门研读或项目参考资料。
1. 股指预测不是玄学,但网络选错、数据切错都会翻车
量化圈每隔一段时间就有人晒出“基于神经网络的股指预测与选股”的优美曲线,点开一看多半是验证集在涨。做过的人心里都有数:这个方向真正的难点从来不是把模型跑起来,而是让预测严格落在未来信息之外,让选股信号在回测里真实可复现。它要解决两件事——对指数未来涨跌给出概率判断,再把同一套方法平移到全市场个股上,排序选出值得持仓的组合。适合已有Python和基础机器学习底子、想用深度神经网络替代经验规则的从业者。看懂本文,你拿到的是一整套从数据清洗、特征构造、LSTM训练到选股验证的落地路径,而不是一张画饼的收益曲线。
2. 把涨跌装进监督学习:问题建模与网络选型
2.1 先定义问题:为什么预测方向比预测价格靠谱
我最初的做法很幼稚:把每天收盘价丢给LSTM,让它输出明天收盘价。训练损失降到接近0,验证集拟合得像印上去一样,可实盘一跑就钝。后来才想明白:价格序列是非平稳的,十年前3000点和现在3000点的含义完全不同。让网络去拟合绝对价格,等于逼它记下一条长期漂移曲线,而对真正要抓的短期相对变化几乎没有表达力。
常见做法是把它重构成分类问题:给定过去N个交易日的特征序列,预测未来H个交易日收益率是否大于某个阈值。数学化一点:x_t 是过去N天的特征向量序列,y 是未来H日收益率的正负号。为什么是分类而不是回归?因为分类对噪声更鲁棒,涨跌方向的信噪比远高于具体收益数值;同时分类输出天然能转成概率,为后面选股排序提供同一把尺子。阈值通常设在0.5%到1%之间,太大会让正样本稀少,太小则几乎全是噪声标签。
另一个必须想清楚的点是滑窗长度N。对日线数据,我一般取20个交易日(约一个月)作为基础窗口。20这个数字不是玄学,它对应A股月级别趋势的记忆长度;太长会把远古行情塞进来,太短则抓不住均线支撑。窗口定了之后,整个数据集的样本量由总交易日减去窗口和预测跨度决定,一般能拿到两千到三千条样本,够一个中小规模网络吃饱。
2.2 网络选型:前馈神经网络、LSTM、一维卷积与GNN怎么挑
模型选型是新手最容易翻车的地方。一上来就上几十层Transformer,结果验证集还不如“昨天涨今天就买”的朴素基线。先看前馈神经网络,也就是常说的BP神经网络:它结构最简单,输入是一个打平的特征向量,经过若干隐藏层后接输出层,靠反向传播更新权重。它的问题在于完全没有时间顺序概念——把第1天的数据和第19天的数据交换位置,模型输出不变,这对行情序列是致命的。用它做序列预测,调参再狠准确率也基本在52%以下,定位只能是基线对照。
循环神经网络(RNN)在设计上天生为序列建模,但经典RNN存在梯度消失,长序列记忆很差。LSTM神经网络用输入门、遗忘门和输出门把长期信息保存在单元状态里,是目前RNN最常用的替代。对股指日线这种几百到几千个样本的序列,两层LSTM把小周期波动和大周期趋势分开建模,是最稳的起点方案。卷积神经网络在序列预测里的用法比较特别:一维卷积神经网络用一维卷积核扫描时间轴,提取K线形态的局部模式,比如“连续三根阳线”“长下影线放量”。它训练快、不挑数据规模,但单用对全局趋势捕捉弱,常见做法是把1D CNN输出再接LSTM,让CNN提特征、LSTM记时序。
图神经网络(GNN)是这几年的热点,把个股之间的关联、行业联动建模成图结构,理论上对选股很有吸引力。但图的构造方式、邻接矩阵的时效性都很难调,数据量也不够支撑,入门阶段不建议动它,等LSTM流水线跑通再研究不迟。我给的选型结论是三步走:BP做基线;LSTM作为主力模型先把预测做稳;之后用1D CNN+LSTM的复合结构去提特征,换取边际收益。架构对比见表。
| 模型 | 时间顺序 | 训练成本 | 过拟合风险 | 在股指预测中的角色 |
|---|---|---|---|---|
| 前馈/BP神经网络 | 不支持 | 低 | 中 | 基线对照 |
| RNN/LSTM | 支持 | 中 | 高 | 主力方案 |
| 一维卷积神经网络 | 局部支持 | 低 | 中 | 特征提取器 |
| CNN+LSTM | 支持 | 中高 | 高 | 进阶方案 |
| 图神经网络GNN | 支持 | 高 | 高 | 选股扩展 |
2.3 输入设计:只喂收盘价等于让模型闭眼猜
我见过很多人复现代码,训练数据就是一个收盘价序列,网络怎么调都上不去。原因很简单:单序列信息量太低,成交量、换手率、振幅、量比、均线偏离这些特征直接关系到参与者情绪和资金行为,不加等于少了一整维信息。我常用的特征集按四维组织。第一维度是价格:开高低收,以及它们衍生出的涨跌幅和振幅。第二维度是量能:成交量、成交额、换手率、量比(当日成交量与过去5日均量的比值)。第三维度是资金:指数层面可以看北向资金净流入,个股层面如果拿不到就用成交额替代。第四维度是技术形态:5日和20日均线偏离度、RSI、布林带位置。
特征不是越多越好,这是血泪教训。原始序列堆到三四十维,网络很快开始背特征,验证集幻觉式上涨。经验上限是20维左右,超过的部分该筛就筛。另外,所有特征在进网络前要做标准化,量纲不统一会让LSTM的门控机制失效——比如成交额以亿为单位、RSI在0到100之间,直接把原始值拼进特征向量,梯度会被大数值特征带走,小数值特征等于白放。
3. 从行情数据到训练样本:特征构造与数据集切分
3.1 数据获取与清洗:停牌、复权和跳空三件事
以沪深300指数日线为例,常见做法是用akshare这类免费数据接口直接拿数据,代码很短:
import akshare as ak import pandas as pd # 拉取沪深300指数日线,接口返回的列名在不同版本略有差异 raw = ak.index_zh_a_hist(symbol="000300", period="daily", start_date="20100101", end_date="20241231") raw.columns = ["date", "code", "open", "close", "high", "low", "volume", "amount", "amplitude", "pct_change", "change", "turnover"] # 只保留建模要用的列,并按时间升序排列 df = raw[["date", "open", "close", "high", "low", "volume", "amount", "turnover"]].copy() df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date").reset_index(drop=True) # 有缺失的交易日直接剔除,不做填充 df = df.dropna().reset_index(drop=True) print(df.tail())这段代码的逻辑是先拿全历史日线,再统一列名,然后按时间排序,最后剔除缺失值。需要说明的是,指数日线本身不存在复权问题,但如果把模型平移到个股上,一定要用前复权价格,否则除权日会出现一个与真实交易无关的假跳空,模型会当成重要信号学进去。停牌日同样要处理:常见做法是删除该日记录,而不是用前后填充值硬补,因为补出来的价格不真实。
参数上,start_date选2010年而不是更早,一是A股早期流动性结构和现在差异大,二是数据区间越长行情风格漂移越明显。对日线级预测,10到15年数据已经能覆盖两三轮牛熊,再多反而让模型去拟合早已失效的旧规则。
3.2 特征构造与标签生成:未来5日方向怎么写才不泄漏
特征构造我习惯用向量化操作一次性算完,写在一个函数里,清洗和调试都方便:
def build_features(df): # 基础涨跌幅和振幅 df["ret_1d"] = df["close"].pct_change() df["amp"] = (df["high"] - df["low"]) / df["close"] # 均线偏离度:收盘价相对均线的位置 for win in [5, 10, 20]: ma = df["close"].rolling(win).mean() df[f"ma_dev_{win}"] = df["close"] / ma - 1 # 量比:当日成交量除以5日均量,反映放量缩量 df["vol_ratio"] = df["volume"] / df["volume"].rolling(5).mean() # RSI(14),常用动量指标 diff = df["close"].diff() gain = diff.clip(lower=0).rolling(14).mean() loss = (-diff.clip(upper=0)).rolling(14).mean() rs = gain / (loss + 1e-9) df["rsi_14"] = 100 - 100 / (1 + rs) # 标签:未来5日收益率是否超过0.5%,作为上涨正样本 df["future_ret"] = df["close"].shift(-5) / df["close"] - 1 df["label"] = (df["future_ret"] > 0.005).astype(int) # 删除因滚动窗口和shift产生的前后缺失行 df = df.dropna().reset_index(drop=True) return df这里有一个关键细节:标签的future_ret用的是close.shift(-5),意味着第t行样本的标签只有到t+5日收盘后才能确定。真实部署时要格外注意,当天收盘后只能做出当天的特征和预测,不能用未来数据验证,这是很多回测失效的根源。
量比、均线偏离、RSI都是无量纲或有界量,进入LSTM前不需要再做标准化;成交量、成交额这类绝对量则要取对数或交给StandardScaler。不要把原始成交量直接喂进去,几百亿和几百万的数值差距会把梯度推向一个方向,小特征直接失效。
3.3 按时间切分与标准化:训练集、验证集、测试集顺序不能乱
切分股票数据不能像图像分类那样随机打乱,必须按时间顺序硬切:
import numpy as np def split_sequences(df, feature_cols, seq_len=20): data = df[feature_cols].values labels = df["label"].values X, y = [], [] for i in range(len(data) - seq_len): win_x = data[i:i + seq_len] win_y = labels[i + seq_len - 1] # 窗口内最后一天的标签 X.append(win_x) y.append(win_y) X = np.array(X, dtype=np.float32) y = np.array(y, dtype=np.int64) # 按时间顺序:前70%训练,中间15%验证,最后15%测试 n = len(X) idx_train = int(n * 0.70) idx_val = int(n * 0.85) X_train, y_train = X[:idx_train], y[:idx_train] X_val, y_val = X[idx_train:idx_val], y[idx_train:idx_val] X_test, y_test = X[idx_val:], y[idx_val:] return (X_train, y_train), (X_val, y_val), (X_test, y_test)这里seq_len=20代表用最近20个交易日的特征序列预测未来5日方向。特征窗口和标签窗口前后相接,不存在重叠,所以每个滑窗样本的信息只来自窗口内的过去20天。标准化必须在切分之后进行,且只能fit训练集,再拿同一组参数transform验证集和测试集:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() n_features = X_train.shape[2] X_train_flat = X_train.reshape(-1, n_features) scaler.fit(X_train_flat) # 只学训练集的均值和方差 X_train = scaler.transform(X_train_flat).reshape(X_train.shape) X_val = scaler.transform(X_val.reshape(-1, n_features)).reshape(X_val.shape) X_test = scaler.transform(X_test.reshape(-1, n_features)).reshape(X_test.shape)如果把scaler.fit放在切分前,或对整个数据集做,验证集和测试集的信息就会泄漏进训练过程,这是时序预测里最隐蔽的坑,也是很多模型实验集指标高得离谱、实盘一塌糊涂的原因。
注意:标准化一定要在切分之后做。先切分、再fit、再transform,这个顺序错了,后面的所有评估都失去意义。
4. LSTM模型训练与调参:先让预测稳定,再谈收益
4.1 搭建一个可复现的两层LSTM预测器
模型结构我固定成“LSTM层+全连接层”,代码短、调试容易,也方便后续把数据规模扩大后换更复杂的结构:
import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size, hidden_size, num_layers, dropout=0.3): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, ) # 二分类:输出1个logit,配合BCEWithLogitsLoss使用 self.fc = nn.Linear(hidden_size, 1) def forward(self, x): # x shape: (batch, seq_len, input_size) out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的隐状态作为序列表示 last = out[:, -1, :] logits = self.fc(last).squeeze(-1) return logits参数含义逐一说清楚:input_size是特征维度,也就是第3章feature_cols的长度;hidden_size是LSTM隐状态维度,64是起步值,再小表达能力不足,再大容易过拟合;num_layers=2分成两个堆叠层,让网络先学短周期再学长周期;batch_first=True表示输入张量维度顺序是(批次, 序列长度, 特征数),PyTorch默认是(序列长度, 批次, 特征数),写错就会出现维度报错。
forward里out[:, -1, :]取的是序列最后一个时间步的输出。有人会把所有时间步的输出做mean pooling,也可以,但最后一个时间步的信息最接近预测点,实操里效果通常更好。全连接层把隐状态压到1个logit,对应二分类;如果要换成三分类(涨/平/跌),把fc输出维度改成3并把损失函数换成CrossEntropyLoss即可。
4.2 训练配置:学习率、早停与学习率衰减
训练循环用PyTorch标配写法,加上梯度裁剪、早停和学习率衰减三个机制:
def train_model(model, train_loader, val_loader, epochs=60, lr=1e-3, patience=10): optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=5) criterion = nn.BCEWithLogitsLoss() best_val_loss = float("inf") best_state = None wait = 0 for epoch in range(epochs): # 训练阶段 model.train() total_loss = 0.0 for xb, yb in train_loader: optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb.float()) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * xb.size(0) # 验证阶段 model.eval() val_loss = 0.0 with torch.no_grad(): for xb, yb in val_loader: logits = model(xb) loss = criterion(logits, yb.float()) val_loss += loss.item() * xb.size(0) scheduler.step(val_loss) # 早停:验证损失连续patience轮不下降就恢复最佳权重 if val_loss < best_val_loss: best_val_loss = val_loss best_state = {k: v.clone() for k, v in model.state_dict().items()} wait = 0 else: wait += 1 if wait >= patience: model.load_state_dict(best_state) print(f"早停于 epoch {epoch}") break else: model.load_state_dict(best_state) return model三个关键点。第一,梯度裁剪clip_grad_norm_是LSTM训练必须的,序列建模的梯度范数容易爆炸,不裁剪loss会突然变成nan,max_norm=1.0是保守值,想放开可以试2.0。第二,weight_decay=1e-5做小的L2正则,对抗过拟合。第三,ReduceLROnPlateau在验证损失平稳时把学习率减半,比固定学习率更容易收敛到好的局部极小值。
EarlyStopping的patience(10轮)和scheduler的patience(5轮)要区分开:scheduler先一步开始降学习率,给模型机会跳出平台期;如果降了学习率还改善不了,再触发早停,这比直接早停更稳。epochs=60只是上限,实战里数据量小时往往20到40轮就早停了。
4.3 评估预测:方向准确率、F1和朴素基线对比
训练完不要只看loss,要看预测方向的命中情况。我固定用下面这组指标卡模型:
from sklearn.metrics import accuracy_score, f1_score, confusion_matrix def evaluate(model, X, y): model.eval() with torch.no_grad(): logits = model(torch.tensor(X)) prob = torch.sigmoid(logits) pred = (prob >= 0.5).int().numpy() acc = accuracy_score(y, pred) f1 = f1_score(y, pred) cm = confusion_matrix(y, pred) return {"accuracy": acc, "f1": f1, "confusion": cm}除了accuracy,我还额外看两个东西。一是F1分数,因为涨跌样本经常不平衡,单边行情里会严重倾斜,准确率会被多数类放大。二是看涨类别的召回率——选股任务里更关心“该涨的股票有没有被选出来”,召回率低说明模型把大量真上涨漏掉了,排序选股的意义就大打折扣。
最重要的对照实验是朴素基线:直接用“今日收盘涨,则预测明天也涨”做规则,准确率大约51%到53%。神经网络如果连这个都超不过,别谈后面的选股,先去查特征和泄漏。当模型在测试集上准确率稳定大于55%时,方向统计意义上可用;想冲到60%以上,日线级别的指数预测很难复现,不如转向选股场景——样本量从1个指数变成几千只股票,模型能学到的东西才够多。
5. 时序预测的五个经典坑:数据泄漏、过拟合与失效特征
5.1 标准化和标签里的未来函数:两个最隐蔽的数据泄漏
现象:训练完的模型在验证集上准确率高达80%,曲线完美得像资产翻倍,可实盘交易几天就连续亏损。这是我在这个方向踩过的最大一个坑,排查到最后发现泄漏根本不在模型里,而在数据处理。
原因一:有的人在切分数据前对整份DataFrame做StandardScaler,把验证集、测试集的均值和方差也学进了转换参数。模型等于开卷考试,测试数据的信息已经被编码进特征分布里,验证集当然好看。原因二:标签构造时误用未来信息,比如用当天的最高最低价来决定当天是否持仓,或者用未来复权数据反推现在的价格,都属于未来函数。
解决:严格遵循“先按时间切分,再只fit训练集”两步法;标签必须由t+1日及之后的纯未来数据计算,且特征窗口和标签窗口之间不能有重叠。我现在的代码里专门写了一条断言,测试集样本的时间戳必须全部晚于训练集,跑数前先校验,宁可报错也不要带病训练。
5.2 过拟合与随机种子:为什么两次训练结果差很多
现象:训练集loss一路降到0.1以下,验证集loss在某个epoch后突然反弹;或者模型输出基本只有“涨”一类。另一个现象是同一份数据、同一个脚本,两次训练的准确率能差3到5个百分点,这让回测和调参都不可信,神经网络在这里表现得像个黑匣子。
原因:hidden_size设到256甚至512,LSTM层数加到3层以上,再加上全连接层后参数量轻松过千万,而日线样本量只有两三千条,模型直接背题。随机种子不固定是另一个隐形原因:PyTorch的DataLoader默认shuffle=True,加上没有固定torch.manual_seed,每个epoch的样本顺序都在变,训练结果自然漂移。
解决:从LSTM两层、hidden_size=64起步,dropout=0.3,加早停和梯度裁剪;在脚本开头固定torch.manual_seed(42)、np.random.seed(42),DataLoader里设置generator。如果调大参数量,要同步增加正则强度并把训练集样本扩到1万条以上,否则就是拿参数量换幻觉。
5.3 类别不平衡与回测滑点:选股信号失效的两个放大器
现象:把模型从指数平移到全市场个股后,Top20组合的回测年化收益很高,但实盘跟单跑不赢,有的月份还跑输指数。
原因一:全市场个股的涨跌分布并非均衡。指数成分股在震荡市大约五成交易日上涨,但个股端牛市里上涨占比可到60%以上。模型用默认阈值0.5做分类时,会把大量边缘上涨判成下跌,选股排名的区分度被压缩。原因二:回测只按收盘价成交,没扣手续费和滑点。A股双边费率加滑点一般在万三到万五,5日换仓一次一年约50次换手,成本累计吃掉3到5个点;预测本身只有55%左右的准确率,这点优势很容易被交易成本抹平。
解决:对类别做加权或降低判断阈值;回测里每笔成交强制扣除双边费用,至少扣万五再算净收益;选股周期不要短于5个交易日,换手越频繁成本越敏感。我现在的做法是:预测概率只用于排序,排名稳定后再结合基本面做人工过滤,不盲目相信分数本身。
5.4 特征失效与行情漂移:模型不是训一次用一年
现象:模型在测试集上准确率56%,照此跑了一个月,第二个月开始连续低于50%,Top20组合跑不赢指数。很多人第一反应是“模型坏了”,其实只是市场风格变了。
原因:日线特征(均线、RSI、量比)在趋势市有效,进入震荡市后特征分布整体偏移,模型学到的是旧风格下的规律。行情风格漂移不是异常,而是常态;用固定模型去对抗漂移,等于拿去年的地图找今年的路。
解决:滚动重训,每月底用最近12个月数据重新训练一次;同时对特征分布做监控,比如计算特征均值、方差的变化幅度,一旦超过阈值就触发重训。这是我做这个方向最持续的维护成本,也是想长期投入必须接受的现实:模型是一个需要定期保养的工具,不是一劳永逸的印钞机。
6. 从预测分数到选股组合:用RankIC评估选股能力
6.1 用预测概率排序,构建等权组合
训练好模型后,等收盘拿到当日全市场股票特征,逐只推理出上涨概率,按概率从高到低排序,取前20只等权持有5个交易日:
scores = [] for stock_df in all_stocks: # 每只股票的特征序列 prob = predict(model, stock_df) # 取sigmoid后的概率 scores.append(prob) # 按概率降序取前20只,等权分配资金 picks = sorted(range(len(scores)), key=lambda i: -scores[i])[:20] weight = 1 / 20等权是为了不把赌注押在单只股票上。排序而不是设阈值选股,是因为模型校准不一定完美——概率绝对值可能有偏,但相对排名在同类股票之间更稳定。
6.2 RankIC与分组回测:看排序能力,不看准确率
选股模型的评价指标和预测模型不一样:准确率只说明“涨跌命中”,选股还看重“概率高的是不是真的涨更多”。我固定用下一期收益和预测概率的Spearman相关系数,也就是RankIC来判断:
| RankIC区间 | 判断 |
|---|---|
| 大于0.05 | 较好的选股能力,值得继续投入 |
| 0.03到0.05 | 有一定区分度,可小仓位验证 |
| 0到0.03 | 统计不显著,先回去查特征泄漏 |
| 小于0 | 模型在反向选择,立即停用 |
我自己的习惯是每天盘中跑完选股后,把Top20组合收益和全市场平均收益对比,连续跟踪4到8周,看超额收益是否稳定为正。这个频率能过滤掉单个月的行情运气,也比训练一次看一个测试集更接近实盘真相。这个方向值得做,但它不是一夜暴富的捷径,靠的是把数据、特征、回测纪律一件件磨到位。我自己也栽过“验证集75%、实盘亏损”的跟头,后来才明白,时序预测里最大的敌人不是模型容量,而是无处不在的未来信息。希望你在这个方向上少走几步弯路。
本文还有配套的精品资源,点击获取