
简介基于LSTM的财务因子预测选股模型Python项目源自答辩98分的个人毕设面向计算机、金融科技、自动化等专业学生、教师或从业者作为课程设计、大作业或毕设参考也适合量化选股入门者。压缩包共15个文件、约1.19MB含7个Python脚本覆盖长短期记忆网络与BP网络模型、MindGo平台交互、MACD与RSI指标计算还附带训练好的模型参数文件和配置文件便于直接加载或二次训练。已有184人学习下载代码经调试测试可稳定运行。项目完整呈现财务因子处理、特征工程到模型训练与选股预测流程新手可借此理解深度学习在因子建模中的应用进阶者也能据此调整网络结构、扩展选股策略整体参考与实用价值较高。1. 这个项目到底在做什么把财务因子序列交给LSTM而不是拍脑袋基于LSTM的财务因子预测选股模型核心思路是把ROE、营收同比增速、资产负债率、毛利率这些财务指标按报告期排成时间序列用LSTM去学习时序规律预测下一期因子值最后按预测结果对股票池排序选股。这份python源码值得读的地方不在“用LSTM”本身而在于它把财务数据的横截面信息、时序信息、停牌与口径问题跟模型输入结构做了完整对接。适合正在做量化课设、需要交一份能讲清楚逻辑的高分项目代码的同学也适合想验证“因子预测到底靠不靠谱”的初级量化工程师。这类项目最大的诱惑是“拿到代码直接跑出收益率”但真正的工程点在数据准备和避免未来函数。下面我会从建模思路开始逐步拆到可复现的代码和参数设置最后重点说那些让模型翻车的坑。2. 财务因子预测的建模思路为什么选LSTM数据怎么准备2.1 因子序列是典型的时序数据LSTM比MLP更适合财务因子预测本质上是一个时序回归问题用历史若干期的因子值去预测未来一期的值。常见的手写因子打分法用的是最新截面值比如“ROE 15% 就入选”这忽略了因子本身的动态趋势。一家公司的ROE连续五个季度从8%爬到20%跟一直稳定在20%对未来一期的意义显然是不同的。LSTM能捕捉这种趋势变化这是它相对于多层感知机MLP的核心优势。LSTM通过输入门、遗忘门、输出门控制信息流动。门控机制让模型能记住几十个时间步之前的因子水平同时跳过噪声波动。财务数据是季度频率一个窗口8期对应两年历史LSTM的记忆长度足够覆盖一个完整的财务周期。相比之下MLP把所有时间步的输入拉平成一个大向量隐式假设各期之间独立等于主动丢掉了顺序关系。还有一个实际理由财务因子数据量不大。A股几千家公司、每季度一个截面十几年下来也就十万级样本这个规模下LSTM的训练成本可接受也不需要上Transformer这类大模型。如果你的目标是“高分项目代码”LSTM的复杂度和可解释性刚好卡在能演示、能答辩、能跑通的位置。2.2 数据清洗与标准化财务口径不一致是第一个坑财务因子原始数据不能直接喂给LSTM。我见过好几个人在这步翻车从数据接口拉出来的报表里同一家公司不同年度的报告期口径可能因合并报表范围变化而不可比有的因子缺了某个报告期直接把NaN填成0导致LSTM学到“0代表基本面恶化”的错误规律。更麻烦的是分红送转带来的股本变化会直接影响每股收益类因子的连续性。清洗流程我一般分四步。第一步按股票代码和报告期去重只保留最新披露的财报避免重复样本。第二步剔除缺失率超过30%的因子列对缺失较少的因子用同行业、同报告期的中位数填充不能用全市场均值否则会抹掉行业差异。第三步用MAD绝对中位差缩尾处理异常值比如某个因子出现10倍于历史的极端值缩尾到中位数±5倍MAD。第四步剔除上市不满一年的股票因为次新股没有足够历史序列。标准化需要同时考虑横截面和时序两个维度。常见做法是先对每个因子做时序上的Z-Score标准化——用每只股票自身历史均值和标准差消除不同股票的量纲差异再在预测时对输入窗口做一次缩放。这里有个要点标准化参数必须只用训练集计算验证集和测试集在预测时用训练集的参数去变换否则会造成数据泄漏。我们会在第5章专门讲这个问题但代码里必须提前预留这个设计。2.3 滑动窗口构造样本窗口长度、步长和标签设计LSTM的输入是三维张量(样本数, 时间步数, 特征维度)。时间步对应过去的报告期特征维度对应经过清洗和标准化的财务因子。窗口长度我一般取4到12期季度数据取8期比较平衡太短抓不住趋势太长会把几年前的旧信息混入反而增加噪声。步长默认1也就是每个报告期往后滑动一次但要注意样本之间高度重叠后面第5章会讲怎么缓解。标签设计有两条路线。路线A是回归预测下一期因子值比如预测下季度ROE路线B是直接把未来20个交易日的收益率作为标签让模型端到端学习“因子序列 - 收益”。从答辩角度路线A更干净因为你在做“财务因子预测”选股是预测之后的应用从实盘角度路线B更直接但引入的噪声更大。我建议项目里保留两个标签选项默认用路线A选股时再用预测因子值排序。下面这段代码展示了从长表因子数据构造滑动窗口样本的过程这是整个项目的地基import numpy as np import pandas as pd from sklearn.preprocessing import StandardScaler def make_windows(df, feature_cols, window8, step1): df: 每只股票按报告期升序排列的因子长表 必须包含列: stock_code, report_date 返回 X: (样本数, window, len(feature_cols)), y: (样本数, 1) X, y, meta [], [], [] for code, g in df.groupby(stock_code): g g.sort_values(report_date).reset_index(dropTrue) values g[feature_cols].values # 对每只股票做滚动窗口 for i in range(0, len(g) - window, step): x values[i:i window] # 预测下一期的第一个因子或指定标签列 target values[i window, 0] # 这里假设标签是第一个因子 X.append(x) y.append(target) meta.append((code, g[report_date].iloc[i window])) return np.array(X), np.array(y), meta这段代码有两点值得注意。第一range(0, len(g) - window, step)里的step默认1会让相邻样本共享window-1期数据样本量大了之后模型容易过拟合到重复模式训练时要配合早停。第二target取的是values[i window, 0]如果你的标签列不是第一列需要单独指定。我是习惯把标签因子放在特征列表的第一位这样代码不用额外传参。标准化要在构造窗口之后做吗不是。正确顺序是先对原始因子做时序标准化再构造窗口。如果先构窗口再跨样本标准化每个窗口内部会出现“过去被未来缩放”的情况数据泄漏从第一步就埋下了。所以上面的代码里feature_cols必须是已经标准化完的列。3. 用Python落地LSTM选股核心代码与逐段说明3.1 环境准备与依赖项目基于Python 3.8核心依赖是torch、pandas、numpy、scikit-learn。不建议用Keras因为LSTM的定制化需求比如取最后一步输出、灵活的初始化方式在PyTorch里更顺手而且调试梯度时能看到中间状态。安装命令很简单pip install torch pandas numpy scikit-learn建议在虚拟环境里装避免把系统全局Python弄乱。如果你的机器没有GPU纯CPU训练这个规模的数据也够用只是epoch时间会多几倍。在代码里加一句torch.set_default_dtype(torch.float32)防止默认float64拖慢速度。3.2 数据加载与因子计算因子数据一般从tushare、baostock或akshare拉取但教学项目用本地CSV最稳。我们需要一张包含stock_code, report_date, roe, revenue_yoy, debt_to_asset, gross_margin等列的因子表。下面是一个读取和预处理的骨架import pandas as pd import numpy as np df pd.read_csv(financial_factors.csv, parse_dates[report_date]) df df.sort_values([stock_code, report_date]) # 去重同一股票同一报告期只保留一行 df df.drop_duplicates(subset[stock_code, report_date], keeplast) # 剔除缺失过多的因子列 thresh 0.3 valid_cols df.columns[df.isnull().mean() thresh] df df[valid_cols] # 行业同行填充假设有industry列 df[roe] df.groupby([industry, report_date])[roe].transform(lambda x: x.fillna(x.median()))注意填充时用了行业和日期分组这比全市场均值填充更符合财务逻辑。如果你的数据里没有行业列退而求其次用全市场日期中位数。数据加载完检查一下报告期是否连续有些股票会缺中间季度窗口构造时会把空隙当成连续时间这是很大的隐患。我一般会生成完整的季度序列然后重采样对齐。3.3 构建LSTM模型PyTorch版本模型结构不复杂LSTM层 - 取最后一个时间步的隐藏状态 - 全连接层 - 回归输出。关键是取哪个输出。nn.LSTM返回全序列输出和最后隐藏状态我们只需要最后时间步的隐藏状态h_n再经过一个Linear层映射到1维预测值。代码如下import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_dim5, hidden_dim32, num_layers2, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0.0 ) self.fc nn.Linear(hidden_dim, 1) def forward(self, x): # x: (batch, window, input_dim) _, (h_n, _) self.lstm(x) # h_n: (num_layers, batch, hidden_dim) # 取最后一层的隐藏状态 out h_n[-1] # (batch, hidden_dim) return self.fc(out).squeeze(-1) # (batch,)几个关键点。batch_firstTrue让输入维度更直观第一维是batch第二维是时间步第三维是特征。num_layers2意味着有两层堆叠LSTM每层的输出会成为下一层的输入。dropout只在层数大于1时生效这是PyTorch的规则——单层LSTM用dropout没有意义。隐藏层hidden_dim32是一个起点值过小欠拟合过大在季度数据上很容易过拟合后面调参章节会展开。有人会问为什么不用return_sequencesTrue然后对每个时间步输出做平均池化。对于财务因子预测最后的隐藏状态已经包含了整个窗口的压缩信息平均池化反而会稀释近期信息。我试过两种取最后时间步的预测误差更小。3.4 训练循环与早停训练流程是标准的PyTorch范式定义损失函数MSE、Adam优化器、循环epoch。财务因子预测对过拟合极其敏感所以早停必须写进代码。我这里提供一个带早停的训练函数def train_model(model, X_train, y_train, X_val, y_val, epochs100, lr1e-3, patience10): optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.MSELoss() train_losses, val_losses [], [] best_val float(inf) best_state None bad_epochs 0 for epoch in range(epochs): model.train() perm torch.randperm(len(X_train)) epoch_loss 0.0 # 简单全batch训练样本量不大时可以这样 for idx in perm.split(64): xb torch.tensor(X_train[idx], dtypetorch.float32) yb torch.tensor(y_train[idx], dtypetorch.float32) optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() optimizer.step() epoch_loss loss.item() * len(idx) train_losses.append(epoch_loss / len(X_train)) # 验证 model.eval() with torch.no_grad(): xv torch.tensor(X_val, dtypetorch.float32) yv torch.tensor(y_val, dtypetorch.float32) val_loss criterion(model(xv), yv).item() val_losses.append(val_loss) if val_loss best_val: best_val val_loss best_state {k: v.clone() for k, v in model.state_dict().items()} bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: print(fEarly stop at epoch {epoch}) break model.load_state_dict(best_state) return model, train_losses, val_losses这个函数有几个细节。perm.split(64)是手动分batch比DataLoader轻量适合教学代码。早停的耐心值patience10意味着验证损失连续10个epoch不降就停能省下大量时间。注意验证集必须按时间切不能用随机切分否则未来的数据会混进验证集这一点在第5章会细说。训练结束后加载最优权重。3.5 预测与选股打分训练完成后对股票池里每一只股票的最后一个窗口做预测得到下一期因子预测值然后按预测值从高到低排序取前N只。但这里有一个细节预测的是因子值而因子值的高低与预期收益的关系并不总是单调的。比如资产负债率这个因子并非越高越好。所以选股前我们要对预测结果做方向判断def select_stocks(pred_df, direction_map, top_n20): pred_df: 包含 stock_code, predicted_factor, factor_name direction_map: 因子方向如 {roe: 1, debt_to_asset: -1} pred_df[direction] pred_df[factor_name].map(direction_map) pred_df[score] pred_df[predicted_factor] * pred_df[direction] ranked pred_df.sort_values(score, ascendingFalse) return ranked.head(top_n)财务里有些因子是正向的ROE越高越好有些是负向的负债率在合理区间内越低越好。方向映射表来自金融常识不要丢给模型自己学。另外选股结果最好做一个行业中性化处理同一行业内选出分数最高的股票避免预测值被某个行业的整体水平带偏。比如银行股ROE普遍高于制造业直接全局排序会让模型全部选中银行股这在回测里看起来很爽但实盘就是踩中行业轮动陷阱。选股后可以计算组合的未来收益用于回测评估。预测和回测分开写不要揉在同一个函数里否则将来调试时你会疯掉。4. 参数怎么调那些必须手动试的关键旋钮4.1 输入窗口、隐藏层大小与层数窗口长度是最重要的超参数。我习惯先在4、8、12、16四个数字上做小规模验证用验证集MSE选择。季度财务数据取8期是个合理默认因为两年时间基本覆盖了一个完整库存周期。窗口超过16期会让样本数量直接减半而且十年前的基本面数据与未来相关性已经很弱。隐藏层大小hidden_dim从16到64试。32是甜区再大在几万样本上几乎没有提升还可能让训练波动变大。你可以用下面这个简单的搜索代码快速筛选import itertools def grid_search(X_train, y_train, X_val, y_val): best None for window, hidden, layers in itertools.product([4, 8, 12], [16, 32, 64], [1, 2]): model LSTMPredictor(input_dimX_train.shape[2], hidden_dimhidden, num_layerslayers) model, _, _ train_model(model, X_train, y_train, X_val, y_val, epochs50, patience5) # 记录验证loss model.eval() with torch.no_grad(): loss nn.MSELoss()(torch.tensor(X_val, dtypetorch.float32), torch.tensor(y_val, dtypetorch.float32)).item() print(fwindow{window}, hidden{hidden}, layers{layers}, val_loss{loss:.4f}) best ...层数从1层开始试比较稳妥。2层模型表达能力更强但财务数据信噪比很低深网络容易记住噪声。我见过有人用4层LSTM在训练集上Loss降到0.001验证集却完全发散——这是典型的过拟合。除非你有大量数据否则层数不宜超过3。4.2 学习率、batch size与epoch学习率用1e-3起步Adam优化器不需要太多手动调整。但如果训练Loss出现锯齿状震荡把学习率降到3e-4或1e-4。季度数据样本量小batch size我常用64。batch太大模型收敛慢batch太小随机性太强。在train_model函数里我用perm.split(64)这就是batch size为64的实现。epoch上限100但早停一般会在40以内触发。一个经验值当训练Loss逐步下降而验证Loss开始抬头时继续训练已无意义肉眼看到这个交叉点就该停下。早停参数patience设10比较宽松设5更激进但可能错过最佳点。我倾向10因为LSTM训练本身有随机性验证Loss会有一些波动耐心太短会被假反弹骗。4.3 正则化手段Dropout和早停Dropout是LSTM过拟合的头号解药。在LSTMPredictor里我设置了dropout0.2这个值在财务因子数据上比较合适。如果验证集MSE比训练集高30%以上把dropout提到0.3或0.4。注意PyTorch的LSTM dropout只作用于层间输入不会作用于隐藏状态的时间步这跟TensorFlow的recurrent_dropout不同所以效果会弱一点但够用。早停是比Dropout更重要的正则化。在财务数据这种强噪声环境LSTM经常在某个epoch把所有细节记住包括噪声。没有早停你会得到一份“训练完美、验证崩溃”的模型。我还会在训练时把训练集随机打乱每次epoch后重新torch.randperm这能有效避免模型学到样本顺序。4.4 评估指标不只盯着Loss要看预测方向准确率回归任务用MSE评估没问题但选股决策更关心预测值相对高低是否靠谱而不是数值精确性。所以除了val_loss我还会计算方向准确率把验证集样本按真实标签排序取前20%作为“高”组后20%作为“低”组然后看模型预测的高组里有多大比例的真实值确实属于高组。这个比例超过60%就说明模型学到了投资相关的信息。方向准确率的代码可以这样写def direction_accuracy(y_true, y_pred, top_ratio0.2): n len(y_true) k int(n * top_ratio) true_high set(np.argsort(y_true)[-k:]) pred_high set(np.argsort(y_pred)[-k:]) hit len(true_high pred_high) / k return hit这个指标比MSE跟选股逻辑更贴。我在项目里会同时输出两个指标答辩时解释为什么方向准确率比Loss重要是一个加分项。另外注意财务因子预测的MSE天然很小因为因子值是0.06这种量级的ROEMSE是0.001并不代表模型好必须结合方向准确率和回测收益一起看。5. 避坑记录5个让模型翻车的真实原因5.1 未来函数因子计算用了未来财报数据现象模型在回测里收益率高得离谱年化超过80%样本外实盘却持续亏损。原因很多公开财务数据接口在计算“滚动12个月ROE”时会把当前报告期之后发布的财报数据也混进去或者你在做因子对齐时把“报告期”和“发布日期”搞混了。解决构造因子时只使用报告期截止日之前已经披露的数据。具体到代码需要保留ann_date公告日期并确保ann_date 预测日。只用report_date做过滤会引入未来数据因为年报一般次年4月底才披露。一个简单的检查方法在验证集里随机挑一个预测日期看模型输入窗口里是否出现超过该日期披露的财报如果有说明泄漏了。5.2 标准化时用全样本统计量造成数据泄漏现象验证集Loss正常但方向准确率在训练和验证间差距极大。原因如果先对全量数据做Z-Score标准化均值方差是“看完整段历史”算出来的验证集的数据分布已经被训练集信息污染了。解决把标准化器放在训练数据上fit然后用训练集的均值和标准差去transform验证集和测试集。我在2.2节特意强调过这里再说一遍任何涉及全局统计量的预处理都要在分割数据集之后做。正确顺序是划分训练/验证/测试按时间→ 在训练集上计算均值方差 → 用该均值方差变换所有集合。5.3 LSTM对输入尺度极其敏感忘记逐因子缩放现象加入新因子之后模型训练Loss变成nan。原因财务因子量纲差异巨大比如总资产可能是千亿级别1e11而ROE只有0.1。如果标准化只做了横截面归一但没有消除时序上的极端值LSTM的内部激活函数很容易饱和梯度也跟着爆炸。解决对所有因子统一做MAD缩尾然后做时序Z-Score标准化。另外在LSTMPredictor里可以给LSTM层设置bias_initializer或对LSTM内部权重做小方差初始化。PyTorch默认初始化一般够用但如果出现nan检查是否是梯度裁剪。加一行torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)能防住大多数梯度爆炸。5.4 训练集和测试集按时间乱切产生样本重叠现象模型评估时效果不错但放到滚动实盘回测里每期换仓都亏。原因财务数据是强序列相关的如果随机把样本划分成训练集和验证集前几年的样本会出现在验证集里而模型已经见过同一家公司的历史模式验证Loss虚低。解决必须按时间顺序切分。比如用2015-2019年做训练2020年做验证2021年做测试。同时滑动窗口步长为1带来的相邻样本重叠也需要控制常见做法是把验证集从训练集末尾隔开一个季度或者每3期取一个样本。5.5 输出了连续预测值但直接用没做行业中性化现象Top 20选股结果里80%都是银行股导致组合和银行指数高度相关。原因财务因子天然有行业属性。银行业ROE不高不低但负债率普遍偏高如果直接按预测因子值排序某些因子方向上银行业会集体靠前。解决在选股前对因子做行业中性化——将预测值对行业虚拟变量回归取残差作为纯净的选股分数。PyTorch里可以直接用statsmodels的OLS或者在pandas里用groupby做组内排名pred_df[score] pred_df.groupby(industry)[predicted_factor].rank(ascendingFalse)组内排名比残差法更直观也更容易向答辩老师解释。每只股票在它所在的行业里被排名然后从每个行业排名靠前的股票里等量抽取这样组合行业权重就均衡了。6. 让模型真正可用的三个进阶技巧6.1 滚动再训练财务因子的统计特性会随经济周期变化一本道训练一次然后一直用几个月后就会失效。常见的做法是每季度新财报披露后把最新数据拼进训练集重新训练一次模型。实现上只需要在训练脚本外套一个循环按季度移动重训然后把训练好的模型对最新窗口做预测。我在自己项目中会用joblib缓存模型文件每次重训前先比较新旧验证集Loss如果新Loss明显变差就回退到上一版模型继续用几天。这算是给模型一个“后悔药”。6.2 集成多个随机种子LSTM初始化权重带随机性同一份数据跑两次选股结果可能差20%。这不是bug是模型对初始化敏感。进阶做法是训练5个不同随机种子的模型对同一预测样本取预测值中位数或对选股排名做平均。代码上很简单models [] for seed in [0, 1, 2, 3, 4]: torch.manual_seed(seed) model LSTMPredictor(...) model, _, _ train_model(...) models.append(model) preds np.mean([m(X_test).numpy() for m in models], axis0)集成之后方向准确率一般能提升3-5个百分点而且是免费的。6.3 用换手率和最大回撤验证实盘可行性预测准确不代表能赚钱还要看组合在换仓时的交易成本。一个简单验证方法是记录每次换仓时调出的股票和调入的股票计算单边换手率。换手率超过50%的组合每年光手续费就可能吃掉几个点的超额收益。我在项目里会额外输出一个模拟回测资金曲线用matplotlib画净值曲线和最大回撤。最大回撤超过20%就要重新审视预测的稳定性而不是盲目加大仓位。这三件事做完你的项目就从“跑通代码”升级成“有逻辑、有验证、有边界”的完整方案。我自己每次做因子预测类项目最后都会回到一个习惯先花两天把数据清洗和防泄漏做对再花半天调模型参数顺序不能反。如果你正打算复现这个方向希望这些经验能帮你少踩几个坑祝顺利。本文还有配套的精品资源点击获取