☰
LSTM财务因子选股模型实战:从数据预处理到回测避坑指南
2026/9/28 15:27:48 网站建设 项目流程

简介:面向金融科技与量化选股方向的毕业设计/课程设计源码项目,基于Python实现LSTM财务因子预测与选股模型,并加入BP模型对照、MACD/RSI技术指标计算等模块,适合AI、电子信息、自动化等专业学生用于课题研究、项目演示或入门进阶。压缩包(zip)共14个文件,主要包括Python脚本(模型定义、训练过程、策略计算)、TensorFlow checkpoint权重文件(meta/data/index记录)以及配置JSON、项目介绍MD和运行说明TXT,整体大小仅1.19MB,目录结构清晰,便于快速定位核心代码。目前已有40人学习,可在完整代码与运行说明基础上复现模型流程,也可按需调整选股因子、模型参数或技术指标逻辑,扩展为更完整的量化策略系统。下载后结合项目介绍文档,可系统理解数据预处理、模型训练与评估选股的完整链路。

1. 这个毕设选题,为什么是“能拿高分”又“容易翻车”的典型

如果你正在找毕业设计题目,又刚好刷到了“毕业设计-LSTM财务因子预测选股模型源码(Python实现,高分项目).zip”这个压缩包,那说明你大概率已经在“深度学习 + 量化金融”这个方向上来回徘徊了。这个选题的定位很清晰:用 LSTM 神经网络读取上市公司的财务因子序列,预测未来一段时间的收益表现,然后按预测分数排序选股。它比单纯做“股票价格预测”要聪明,因为财务因子天然是横截面数据,送到 LSTM 里做时序建模,逻辑上说得通,答辩时也容易讲出故事来。它也比单纯做“策略回测”更吸引眼球,因为模型部分占了大头,导师一看就知道工作量足。

但这里必须先泼一盆冷水:这个方向听起来不难,真正落地的时候坑却极多。我见过太多人把财务数据一拼、LSTM 一跑、回测一画,看起来收益曲线很漂亮,结果一查是未来函数泄漏,或者训练集和测试集混在一起归一化,直接被答辩老师一句话问穿。所以这篇笔记不是为了夸这个项目多好,而是把“财务因子怎么构造、LSTM 怎么调、选股怎么做、坑在哪”这整条链路拆开来讲,让拿到源码的你既能跑通,也知道每一行代码在干什么,更知道怎么应付答辩时那些刁钻的问题。新手能顺着步骤复现,老手也能在这里看到边界和参数上的取舍。

2. 拿到 zip 之后:先分清“源码结构”和“数据流向”

2.1 解压之后先别急着跑,先画数据流

下载下来的 zip 解开之后,不管里面的目录怎么命名,核心模块跑不掉这几块:数据读取与因子计算、数据预处理与样本构造、LSTM 模型定义、训练与评估、选股与回测。我拿到任何一份量化源码的第一件事,不是看模型,而是先找数据入口,把“原始数据长什么样、进模型之前被改成了什么形状”搞清楚。这一步做扎实了,后面所有问题都能定位。

常见的数据入口有两种:一种是从本地 CSV 读取,字段类似date, symbol, pe, pb, roe, revenue_yoy, profit_yoy, close;另一种是直接用 akshare、tushare 这类库在线拉取。毕设项目一般用的是前一种,因为在线接口有频率限制,答辩现场断网就尴尬了。你先打开配置文件或者load_data函数,确认它到底读的是什么字段。注意,这里有一个很容易被忽略的问题:zip 包里很可能只带了示例数据,不会带全量历史数据,字段名也可能跟你自己下载的对不上。所以第一步不是跑通,而是把字段映射对齐。

2.2 训练集 / 验证集 / 测试集怎么切:时间顺序不能乱

LSTM 选股和图像分类有一个本质区别:图像分类可以把样本随机打乱,但时序数据一旦乱序,模型就“作弊”了。财务因子预测选股的正确切法是按时间先后切,比如前 60% 的时间段做训练,中间 20% 做验证,最后 20% 做测试。千万不要用train_test_split(random_state=42)这种随机切法,我见过不止一个毕设源码这么干,回测的时候收益高得离谱,其实就是因为模型“看到过”未来的数据分布。

代码里常见的写法是:

# 按时间顺序切片,而不是随机切分 train_end = int(len(df) * 0.6) val_end = int(len(df) * 0.8) df_train = df.iloc[:train_end] df_val = df.iloc[train_end:val_end] df_test = df.iloc[val_end:] print(f"训练区间: {df_train['date'].min()} ~ {df_train['date'].max()}") print(f"测试区间: {df_test['date'].min()} ~ {df_test['date'].max()}")

这段代码的逻辑很简单:先把len(df)按比例切成三段,然后按行索引切分,保证时间顺序不被破坏。最后一行把区间打印出来,是为了让你肉眼确认切分节点是合理的,比如不要出现在 2015 年牛市中途切开、训练集里根本没有熊市样本这种问题。切分比例 6:2:2 是量化里常用的默认值,但如果你数据只有五年,我建议改成 7:1.5:1.5,因为 LSTM 本身吃数据,训练集太小模型学不到时序规律。

2.3 因子数据和时间序列样本的区别:一张表里到底谁能进 LSTM

很多刚接触这个题目的同学会有一个误区:以为把财务因子表直接丢给 LSTM 就行。实际上 LSTM 的输入是一个三维张量,形状是(batch_size, sequence_length, feature_dim),也就是“一批样本、每个样本多少个时间步、每个时间步多少个特征”。一份原始的财务因子表格是二维的——每行是一个股票在某一天的因子值,这不能直接送进模型。

你需要先构造“滑动窗口样本”:假设sequence_length=12,含义是“用过去 12 个月的财务因子序列,预测下个月的收益”。那么每个样本的形状就是(12, feature_dim),标签是未来一个月的实际收益率。滑动窗口构造的代码长这样:

import numpy as np def make_samples(features, labels, seq_len=12): X, y = [], [] for i in range(len(features) - seq_len): X.append(features[i:i+seq_len]) y.append(labels[i+seq_len]) return np.array(X), np.array(y) # features: (n_samples, feature_dim), labels: (n_samples,) X, y = make_samples(feature_matrix, forward_return, seq_len=12) print(X.shape, y.shape) # 期望输出类似: (983, 12, 8) (983,)

这里的核心逻辑在循环里:每走一步,取长度为seq_len的因子序列作为一个样本,下一时刻的收益作为标签。seq_len=12是财务因子预测里比较常见的配置,因为 A 股财报按季度披露,一年刚好 4 份财报,12 个月能覆盖最近三个报告期,信息量比较充分。如果seq_len设太大,比如 36,样本数量会急剧减少,而且三年前的财务数据对当前股价的解释力已经很弱,反而引入噪声;设太小,比如 4,模型只有 4 个时间步,LSTM 根本学不到周期性规律。

3. LSTM 模型本体:从一行nn.LSTM到能训练出稳定 loss

3.1 为什么选 LSTM 而不是 RNN 或 Transformer:财务因子序列的特殊性

在毕设里选 LSTM 有一个很实际的理由:它比普通 RNN 能记住更长距离的信息,又比 Transformer 简单得多,容易在答辩时讲清楚门控机制。财务因子的序列天然带着“记忆”属性——一家公司这个季度的 ROE 突然从 8% 掉到 2%,和它连续三个季度从 8% 缓慢下滑到 2%,对未来的含义完全不同。LSTM 的遗忘门能学会“只记住持续性的恶化,而忽略单季波动”,这种能力在短序列财务数据上很契合。

常见做法是双层的 LSTM 加一层全连接回归头。第一层捕捉单因子自身的时序模式,第二层捕捉因子之间的交互模式。隐藏维度的设置有个经验值:hidden_size设为 32 或 64 就足够应付财务因子场景。你的特征维度一般只有 6 到 12 个,不像股票价格序列那样包含大量微观结构信息,隐藏单元太多只会让模型过拟合到训练集上。下面是一个可用的 PyTorch 模型定义:

import torch import torch.nn as nn class LSTMSelector(nn.Module): def __init__(self, feature_dim, hidden_size=64, num_layers=2, dropout=0.3): super().__init__() self.lstm = nn.LSTM( input_size=feature_dim, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout ) self.regressor = nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, 1) ) def forward(self, x): # x: (batch, seq_len, feature_dim) out, _ = self.lstm(x) # 取最后一个时间步的隐状态作为整段序列的表示 last_hidden = out[:, -1, :] return self.regressor(last_hidden).squeeze(-1)

模型结构不复杂,重点在forward里这行out[:, -1, :]。它的含义是:只取 LSTM 输出序列的最后一个时间步的隐藏向量,然后交给全连接层。为什么这么做?因为 LSTM 在每个时间步都有一个输出,但我们要的是“整个历史序列对应的未来预测”,最后一个时间步的隐状态已经编码了前面所有步的信息,这是最常用的做法。另外两点:batch_first=True让输入形状直接是(batch, seq_len, feature_dim),不用来回转置;dropout=0.3在两层 LSTM 之间和全连接层里都加了正则,毕设数据量小,不加 dropout 几乎必过拟合。

3.2 损失函数:不是回归就完了,要关注预测的“排序能力”

训练 LSTM 预测未来收益率,最直观的损失函数是 MSE,也就是预测值和真实收益率的均方误差。但选股任务和精准回归有一个关键差异:你真正需要的不是预测值多准,而是预测值的排序对不对。如果你预测所有股票的收益都比实际高 0.1,MSE 很大,但排序没乱,选股效果依然很好;反过来,如果预测值普遍偏小但排序乱了,选出来的股票就是错的。

所以代码里可以同时输出 MSE 和 Rank IC(排序相关系数)两个指标。MSE 用来优化网络参数,Rank IC 用来衡量选股能力。训练循环框架:

import torch.optim as optim from scipy.stats import spearmanr criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) for epoch in range(epochs): model.train() total_loss = 0.0 for batch_X, batch_y in train_loader: optimizer.zero_grad() pred = model(batch_X) loss = criterion(pred, batch_y) loss.backward() optimizer.step() total_loss += loss.item() if (epoch + 1) % 10 == 0: model.eval() with torch.no_grad(): val_pred = model(val_X) val_ic = spearmanr(val_pred.numpy(), val_y.numpy()).correlation print(f"Epoch {epoch+1}, Loss: {total_loss:.4f}, Val IC: {val_ic:.4f}") model.train()

optimizer.zero_grad()必须在每次反向传播前清空梯度,否则 PyTorch 会默认累加梯度,loss 会莫名其妙震荡。loss.backward()计算梯度,optimizer.step()用 Adam 优化器更新参数。每 10 个 epoch 在验证集上算一次 Rank IC,如果 IC 长期接近 0 或为负,说明模型没学到有效信号,这时候再跑更多 epoch 也没用,要去检查因子数据本身。学习率1e-3是 Adam 的常见默认值,如果 loss 在头几个 epoch 就爆炸到 nan,可以把学习率降到3e-4再试。

3.3 归一化的顺序:这是最容易埋雷的地方

财务因子的量纲差异极大,PE 可能是 5 到 100,ROE 是百分比 0 到 30,营收增速可能是 -50 到 200。不归一化直接进 LSTM,数值大的特征会主导梯度,模型很难学好。一般做法是用StandardScaler做 z-score 标准化。但这里有一个极其关键的顺序问题:只能用训练集的统计量去归一化验证集和测试集,不能把全部数据混在一起 fit。

正确写法:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() # 只对训练集拟合均值和标准差 feature_train = scaler.fit_transform(df_train_feature) # 验证集和测试集用训练集的统计量做转换 feature_val = scaler.transform(df_val_feature) feature_test = scaler.transform(df_test_feature)

fit_transform在训练集上计算每个因子的均值和标准差,然后原地标准化;验证集和测试集上只调用transform,用训练集的均值和标准差做相同变换。这样做的原因很朴素:在真实投资场景里,你站在“当前时刻”是看不到未来数据的均值和方差的。如果混在一起 fit,相当于把未来的统计信息泄漏到了训练过程里,验证集的 IC 会被系统性高估。这也是答辩时最容易被追问的点,代码里这么写,你就能讲清楚“为什么”。

4. 从“预测收益率”到“选股策略”:完成最后一公里

4.1 预测分数怎么变成持仓组合

模型输出的是一个连续值,代表预测的未来收益率。选股逻辑一般是每个调仓日(比如每个月末)对所有股票预测一个分数,然后取分数最高的 N 只股票等权买入。为什么用等权而不是按分数加权?因为 LSTM 的预测分数误差很大,用分数做权重会让组合过度集中到预测值极端高的股票上,而这些极端值往往就是过拟合的产物。等权是更稳健的做法。

def select_stocks(pred_scores, stock_pool, top_n=10): # pred_scores: 每只股票的预测分数 # stock_pool: 对应的股票代码列表 order = np.argsort(pred_scores)[::-1] # 从高到低排序 selected = [stock_pool[i] for i in order[:top_n]] return selected

np.argsort返回的是索引,[::-1]把顺序倒过来实现从高到低,取前top_n就是选出的股票。top_n是毕设里需要交代清楚的参数:选 5 只太集中,单票波动对组合收益影响巨大;选 20 只以上又摊薄了模型的选股能力,组合收益可能趋近于市场平均。我一般建议用 10 只,组合够集中、能看出模型效果,又不至于被一两只股票的极端行情带崩。回测里还要设定调仓频率:月度调仓和季度调仓会影响交易成本,月度调仓更贴近财务因子披露节奏,因为每个月都有新的因子数据可用。

4.2 回测不能只看收益率曲线,要看超额收益和最大回撤

回测代码通常会输出累计收益率曲线,但光看这一条线不够。你需要同时算出三个指标:年化收益率、年化波动率、最大回撤。最大回撤尤其重要,因为 LSTM 模型在牛市区间表现通常很好,在熊市和震荡市里可能连续跑输基准,最大回撤直接反映这种风险。一个完整的回测循环:

def backtest(dates, returns, benchmark_returns, initial_capital=1000000): capital = initial_capital wealth_curve = [] for i in range(len(dates)): # 模拟每日收益率的复利累积 capital *= (1 + returns[i]) wealth_curve.append(capital) wealth_curve = np.array(wealth_curve) total_return = wealth_curve[-1] / initial_capital - 1 # 计算最大回撤:当前净值除以历史最高净值的最大跌幅 peak = np.maximum.accumulate(wealth_curve) drawdown = (wealth_curve - peak) / peak max_drawdown = drawdown.min() print(f"总收益率: {total_return:.2%}") print(f"最大回撤: {max_drawdown:.2%}") return wealth_curve, max_drawdown

这里的核心思想是每日复利累积:把每日策略收益率依次乘到资金上。np.maximum.accumulate计算每天之前的历史最高净值,然后用当前净值减去最高净值再除以最高净值,得到回撤比例,取最小值就是整个回测期最惨的一天。注意returns是策略每日的收益序列,不是每只股票自己的收益。如果returns里有空值或者月度调仓产生的空档,需要先用前面的收益做前向填充,否则回测曲线会出现很多异常的平缓段。

4.3 比较基准:没有基准的论文是没有灵魂的

答辩老师一定问“你这个策略比沪深 300 好吗”,所以代码里必须把基准收益也拉出来对比。最简单的方式是同步打印策略累计收益和基准累计收益,并计算超额收益曲线。用 pandas 做这件事会比较顺手:

import pandas as pd result_df = pd.DataFrame({ 'date': dates, 'strategy': wealth_curve / initial_capital, 'benchmark': benchmark_curve / initial_capital }) result_df['excess'] = result_df['strategy'] - result_df['benchmark'] # 只看超额收益是否稳定向上 print(result_df['excess'].tail(10))

excess这列如果是稳步向上的,说明策略确实有持续跑赢基准的能力;如果是一段暴涨然后长期回落,说明策略只是靠某一波行情拉起来的,不具备稳健性。这个观察几乎可以直接写进论文的结论部分。另外提醒一句,基准不要只选沪深 300,建议同时对比中证 500 和万得全 A,因为你的股票池如果是全市场,不同基准的结论差异很大,选个对策略最有利的基准会被老师说“挑数据”。

5. 毕设项目避坑指南:五个让无数人翻车的经典场景

5.1 保存模型再加载后,预测结果全是 nan

这个坑几乎每个人都踩过。现象是训练时 loss 正常、预测正常,但把model.state_dict()保存下来、换一个会话加载之后,预测结果变成 nan 或全部相同值。原因有两个:一是保存时忘记保存标准化器的参数,加载后直接用新的StandardScaler去transform测试集,因子数值范围完全不对;二是 LSTM 的nn.LSTM层在加载时需要和保存时完全相同的hidden_size和num_layers,手滑改了一个参数,权重形状不匹配,PyTorch 不报错但会产生随机初始化。

解决方法只有一个:把scaler和模型参数一起保存。推荐做法:

# 保存时同时记录 scaler 参数 torch.save({ 'model_state': model.state_dict(), 'scaler_mean': scaler.mean_, 'scaler_scale': scaler.scale_, }, 'checkpoint.pt')

加载的时候先取出scaler_mean和scaler_scale手动赋给StandardScaler,再加载模型权重。这样无论什么环境,数据处理和模型都在同一套参数下工作。顺手把模型的feature_dim也存进去,加载时核对特征维度,如果数据集字段数变了,提前报错比跑到一半再 nan 好得多。

5.2 财务数据天然有“未来函数”——复权因子就是最大的泄漏源

股价数据如果不复权,遇到分红送股会有断崖式下跌,导致收益率计算完全失真;如果复权方式不对,比如在训练时用了后复权价格,在回测时又用了前复权价格,收益率序列会出现系统性偏差。更隐蔽的场景是:有些代码直接从数据库同时读取“最新复权因子”和“历史价格”,然后用最新的复权因子去调整所有历史价格——这是最典型的未来函数,因为复权因子在历史上是逐步更新的,你用今天的数据去调整五年前的价格,等于提前知道了后续的事件。

解决方案是:整个项目里只用一种复权方式,而且必须是在每个历史时点使用当时可得的复权因子数据。如果源码包里的数据没有提供历史复权因子,你就直接在因子列表里加入“未复权收盘价”的收益率,并且论文里明确写“仅使用未复权价格计算收益率”,这样至少逻辑自洽。答辩时被发现这一点,只要你能说清楚,不算硬伤;但如果你假装没看见,老师追问两轮就会穿帮。

5.3 样本量太小,LSTM 训出来一组固定输出

很多同学只有三到五年的财务数据,去掉滑动窗口之后样本量可能不到几百。这种规模下 LSTM 很容易把所有股票预测成同一个值,因为模型发现“输出均值”就是最小 loss 的方案,于是选股变成随机选。现象是你打印每个 epoch 的预测值分布,发现标准差在缩小,最后趋近于 0。

解决方向分两步:第一,把sequence_length从 12 缩短到 6,增加样本数量;第二,在 loss 函数上做文章,比如用分位数损失或者对预测值做排序后的 softmax 交叉熵,强迫模型输出不同的分数。排序损失的思路是:不直接回归未来收益,而是让模型预测“这十只股票里谁未来涨得最好”的概率。这个改法不复杂,但对小样本的稳定性提升很明显。

5.4 因子缺失值处理不当,模型把“缺失”学成了“利好”

财务因子天然有大量缺失值,因为部分公司上市时间短、或者报表披露不完整。最省事的df.dropna()会把所有存在缺失的样本删掉,如果你的面板数据是“月度 × 全股票”,删除后可能少掉一半样本。我见过更离谱的处理方式:用 0 填充缺失值,结果 LSTM 学到了“因子为 0 的股票后续收益偏低/偏高”这种虚假规律,而且无法解释。

正确做法是先分情况:对于 PE、PB 这类比值因子,缺失可能是“亏损企业财报没意义”,可以用全市场该因子的中位数填充;对于 ROE、营收增速这类有明确业务含义的因子,推荐用最近一期已披露的数值做前向填充。实在拿不准,就直接加一个is_missing的 0/1 特征列,让模型自己学缺失是不是信号。这是最诚实的做法。

5.5 训练时 loss 下降但回测稳定亏损:你大概率没做行业中性化

财务因子预测里有一个非常隐蔽的陷阱:模型学到的不是“选股能力”,而是“行业偏好”。比如过去三年医药行业整体涨得好,模型发现只要买入医药股就能提高预测准确率,于是把高分数都给了医药股;测试集恰好在医药回调期,策略立刻亏穿。LSTM 本身并不知道什么是行业,它只是拟合了数据里的统计规律。

解决方法是训练前对因子做行业中性化:每只股票的因子值减去所属行业的平均因子值,再除以行业内的标准差。做法很简单:

def neutralize_by_industry(df, factor_cols): df_neu = df.copy() for col in factor_cols: # groupby industry 再 transform,相当于行业内的 z-score df_neu[col] = df.groupby('industry')[col].transform( lambda x: (x - x.mean()) / x.std() ) return df_neu

groupby('industry')把股票按行业分组,transform对每个行业内做标准化,不改变行数和顺序。做完之后,因子值表达的是“公司在行业内相对强弱”,而不是“行业本身强不强”。这会丢掉一部分行业上的 beta 收益,但留下来的信号才是真正的 alpha。用中性化之后的数据重新跑训练和回测,你会发现策略收益可能下降一点,但回撤会明显缩小,这个交换是值得的。

6. 让毕设项目真正立得住:滚动训练、样本外验证和关键参数速查

走到这一步,你的模型已经能跑通,回测也画了出来,但这还不算完成。想要论文拿到高分,最后需要做两件事:滚动训练和分层回测。

滚动训练的核心是“不偷看未来”:基线方案里所有样本只训一次,然后测试;但现实中每隔一段时间会有新的财务数据披露,重新训练一次模型才是合理做法。你不用每次都从头训练,可以每季度用最近三年的数据重新训练,再预测下一个季度的选股权重。这样论文里就能画出“不同年份分别训练和预测”的表现,越是后几年,越接近真实场景,说服力越强。

分层回测的做法是把所有股票的预测分数从高到低平均分成五组或十组,分别计算每组未来一个月的平均收益。如果分数最高的组,真实收益也最高,分数的单调性趋势越明显,说明模型信号越有效。画出一张“分组收益柱状图”,答辩老师基本就不会再质疑你的模型没有预测能力了。这个图通常在论文里是最有价值的一张图。

最后整理一个参数速查表,方便你在训练前快速确定配置:

参数推荐值说明
sequence_length12(可降到 6)时间步数,对应过去 N 个月因子序列
hidden_size32 或 64LSTM 隐藏维度,因子维度小则用 32
num_layers2两层 LSTM,多了容易过拟合
dropout0.3全连接层和 LSTM 层间随机失活
learning_rate1e-3(可降 3e-4)Adam 默认值,loss 爆炸时降低
batch_size64 或 128样本量小时用 64
top_n10每次调仓选股数量
调仓频率月度与财报披露节奏匹配

这份参数表的适用前提是财务因子数据,不是股价高频数据。如果你把输入换成日线量价数据,sequence_length往往要拉长到 30 到 60,hidden_size也要提高到 128 左右,因为价格序列的信息密度和财务序列完全不同。毕设里不要盲目抄一个大模型的配置,跑不动是一回事,更麻烦的是你没法向导师解释清楚每个参数为什么这么设。

作为一个做过不少类似项目的人,我的习惯是每改一个参数,就同步记录一次验证集 IC 和测试集最大回撤,改完三五个参数之后回头对比,你会有一种“模型行为其实是可以预估”的感觉。也希望这篇笔记能帮你在这个毕设方向里少走几步弯路,少熬几个深夜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询