这段时间我把基金净值数据喂给了LSTM模型,断断续续调了好几个晚上的参数,最后在测试集上看到预测曲线贴着实净值波动的时候,确实觉得这玩意儿有点香。
先说清楚这个项目到底做了什么:用Python抓取某只公开指数基金的日净值数据,通过LSTM(长短期记忆网络)对净值序列做滚动预测,输入过去30个交易日的走势,输出来来5天的净值区间和趋势。整个过程走了一遍完整的数据清洗、特征工程、模型训练和回测评估,最终在测试集上,趋势方向的准确率大概在58%~61%之间晃。
这东西适合谁看?如果你刚开始接触时间序列预测,或者想用深度学习折腾一下量化分析,甚至只是好奇“基金净值到底能不能用AI猜一猜”,都可以读下去。我会把从数据到代码的每个环节都摊开来讲,包括我踩过的坑和调参时的观察。提前声明:这不是荐基,更不构成投资建议,纯粹是一次技术实验。
1. 为什么选LSTM——基金净值数据没那么简单
很多人一开始都会问同一个问题:预测净值为什么不直接用线性回归或者ARIMA?我当时也是从简单模型开始试的,但结果很快就说明了一个事实:基金净值数据里藏着非常明显的非线性特征,线性模型根本吃不住。
1.1 基金净值不是“按套路出牌”的随机游走
如果只看净值曲线,很多人会觉得“这不就是个时间序列嘛,昨天涨了今天大概接着涨”。但实际上基金净值每天的变化背后牵扯着一大堆因素:市场情绪、板块轮动、资金申赎、宏观数据发布、突发事件……这些因素的叠加让序列呈现出波动聚集效应——大涨大跌之后往往伴随着更大的波动,而平稳时期又相对安静。这种特性在时间序列分析里叫“波动率异方差”,简单模型很难刻画。
我做了一个很直观的对比:直接拿过去5天收益的平均值去预测下一天收益,结果在测试集上的方向准确率只有49%左右,也就是跟抛硬币差不多。换成线性回归稍微好点,勉强到52%,还是没什么参考价值。这说明光靠“均值回归”这种朴素假设,根本捕捉不到基金净值的真实规律。
我甚至把ADF单位根检验也跑了一遍,原始净值序列完全不平稳,但一阶差分(也就是每日收益率)就平稳了。这个现象透露了一个关键信息:模型应该去学“变化规律”,而不是直接记住净值绝对值的大小。
1.2 LSTM的门控机制到底在学什么
LSTM属于循环神经网络(RNN)家族,和普通前馈网络最大的区别在于它有一个“记忆单元”,可以跨时间步传递信息。普通的RNN在序列变长的时候会发生梯度消失,也就是早期的信息在反向传播时对参数更新的贡献会指数衰减,导致模型“记不住太久以前的事”。LSTM通过三个门控结构解决了这个问题。
- 遗忘门:决定过去的记忆哪些该丢掉。对基金来说,三个月前的一波大涨可能对明天的走势毫无帮助,遗忘门可以学会把这种干扰信息“擦掉”。
- 输入门:决定当前时刻的新信息哪些值得写入记忆。比如今天放量上涨,这个信号可能在未来几天都值得被记住。
- 输出门:决定当前时刻要把记忆中的哪些内容输出给下一层或者最终预测。
这三个门在我看来就像是一个有选择性的“情报筛选系统”:每一帧市场信息进来,网络自己学习是留下、更新还是丢弃。而金融时序最需要的恰恰就是这个能力——同样的市场新闻,在不同的宏观环境下含义完全不同。LSTM能给这种复杂性预留了极大的学习空间。
生活里的类比就是:你记一个老朋友的新电话号码,如果不小心记混了,你要判断“这个老号码是否还要保留”——遗忘门;新号码来了你要决定“这串数字值不值得记”——输入门;别人问起电话号码时你还要决定“说新号还是老号”——输出门。LSTM的时间记忆能力,本质上就是这个过程。
这里我用了两层LSTM堆叠的结构而不是单层,原因也很简单:单层LSTM对短期趋势的拟合力足够,但对稍微长一点的依赖关系建模能力有限;两层之后,第一层提取局部的时序特征,第二层在第一层的输出之上再抽象一层高维特征。但如果堆到三层,在我这个只有一千多条日净值样本的数据集上过拟合得非常快,训练损失一直降,验证损失直接起飞。
2. 数据准备与特征工程——九成精力都砸在这里
模型能不能出活,数据准备占了大头。这句话我原来也觉得是套话,真上手之后才发现,整个项目里最花时间的不是调参,而是把数据从“能跑”变成“跑得好”。
2.1 数据获取与清洗:净值数据也有“脏”的时候
首先要解决的是数据从哪来。我直接从一个公开的第三方数据平台导出了某只沪深300指数基金近5年的日净值,存成CSV文件,列名包括日期、单位净值、累计净值。一共大概1200多个交易日的记录。
拿到数据第一件事不是建模,而是检查有没有“坑”。我发现至少三个问题需要处理:
- 节假日停牌会导致日期不连续,如果用固定步长滑窗取数,得先用完整交易日历对齐。
- 基金分红除权会导致单位净值突然出现一个“跳水式下跌”,但这个下跌不代表真实亏损,所以最好直接用累计净值来建模。
- 偶发的数据缺失,比如某一天净值没更新,简单做法是向前填充(用前一天的净值顶上),但如果是连续几天缺失,就得考虑是否剔除这段区间。
我清洗完之后把原始净值转换成了每日收益率序列,这一步非常关键。净值本身是累积量,用收益率建模更符合金融直觉。我后面让LSTM预测的其实是收益率或者直接预测未来净值,但输入特征全部在收益率维度上做。
2.2 特征工程:除了净值,我到底喂了些什么
如果只拿“过去30天的净值”这一个特征丢给LSTM,模型也不是不能跑,但预测效果的提升空间会被严重限制。我加了几组衍生特征,最终的输入维度是7维,每只基金都能套用这套方案:
| 特征名称 | 计算方式 | 为什么要加 |
|---|---|---|
| 日收益率 | (当日净值 - 昨日净值)/ 昨日净值 | 核心变量,平稳且符合金融直觉 |
| 5日均线差 | 近5日均值 - 近20日均值 | 捕捉短期与中期趋势的“斜率差”,类似移动平均线的金叉死叉逻辑 |
| 20日波动率 | 近20日收益率标准差 | 刻画市场风险偏好,波动率高时往往意味着情绪化交易 |
| 20日最大回撤 | 近20日累计峰值的最大回撤幅度 | 让模型感知当前处于阶段高点还是回调期 |
| 5日交易量变化率 | 基金份额变化率(或用净值涨跌的5日均值代替) | 近似“资金进出热度”的代理指标 |
| 月度动量 | 近22个交易日的累计收益率 | 捕捉中期惯性效应 |
| 相对强弱指标RSI(14) | 标准RSI计算式 | 刻画超买超卖状态,经典技术指标 |
这些特征加进去之后有一个非常直观的变化:模型在测试集上的方向准确率从51%左右直接提到了56%以上,说明LSTM对多维特征的利用率远超单维特征。
2.3 数据分割与归一化:最容易翻车的两个细节
第一个细节是归一化。很多教程会直接对整个数据集做一个MinMaxScaler,再切分训练集和测试集,这样做的问题非常严重——测试集的信息被“偷看”了,因为归一化的最大最小值是由全量数据计算出来的。这会让模型的评估虚高,甚至出现正常场景下完全无法复现的表现。
正确的做法是:先切分数据,再用训练集上的最大最小值去归一化训练集和验证集、测试集。也就是说,归一化器一旦在训练集上确定下来,就必须原封不动地带到其他数据集上。
第二个细节是绝对不能洗牌。普通分类任务里常常用随机打乱数据来增加样本独立性,但时间序列完全不能这么干——一旦洗牌,前后时序关系就被破坏了,模型学到的是“穿越时空”的错乱模式。我按时间顺序切成了80%训练、10%验证、10%测试,保证任何一段预测都是基于它之前的全部数据训练出来的结果。
滑窗构造样本这一步也值得一提。我用look_back=30(用过去30个交易日)预测forecast_horizon=5(未来5个交易日),滑动步长为1。为什么用30?因为30个交易日大约等于6个星期的交易日,足够涵盖一个短周期的趋势变化,同时也兼顾了样本量——原本1200条序列,滑窗之后能生成大约1100条训练样本,不会因为look_back太大而损失太多边界数据。
3. 模型搭建与训练参数——好模型是“调”出来的
接下来进入模型部分。LSTM虽然听起来高大上,但网络结构本身并不复杂,真正的难点在于把超参数调到一种“既学得到规律、又不至于过拟合”的状态。
3.1 网络结构设计:两层LSTM加Dropout
我的最终结构长这样:
- 输入层:形状为(30, 7),分别是滑窗长度和特征数量
- 第一层LSTM:128个隐藏单元,返回完整序列
- Dropout层:0.2
- 第二层LSTM:64个隐藏单元,只返回最后一个时间步的输出
- Dropout层:0.2
- 全连接层:32个神经元,ReLU激活
- 输出层:5个神经元,对应未来5天的净值预测值
这里有一个很多人会搞混的点:LSTM层的return_sequences参数。第一层需要设为True,因为要输出每个时间步的隐藏状态给下一层LSTM;第二层只需要最后一个时间步的结果做预测,所以设为False。
为什么不加注意力机制?我试过,在这个小数据集上注意力引入的额外参数反而让模型更容易过拟合,收益不明显。当然,如果你手头有上万条分钟级数据,加注意力是值得考虑的方向。
3.2 超参数选择:学习率、损失函数和批次大小
优化器我直接选了Adam,学习率从0.001起步。用Adam是因为它对学习率不敏感、自带适应性调整过程,适合我这种初期没有大量算力去细调的场景。
损失函数我纠结了一下。一开始用MSE(均方误差),因为它对异常值敏感,能促使模型把数值预测得更准。后来我意识到,在基金净值的实际应用里,“预测方向和实际方向是否一致”往往比“预测精确到小数点后三位”更重要。但我回头看测试结果,MSE训练出来的模型,方向准确率反而比用其他损失函数更高,这可能是因为数值拟合得足够好之后方向自然就对了。
批次大小选了32。这个值很大程度取决于你的样本量,我测试过16和64,16会让训练过程震荡明显,64则收敛太慢。32是个不错的平衡点。最大训练轮次设了200轮,但实际训练里很少能跑满200轮,因为每次都在大约第50到第80轮之间就因为早停机制被掐掉了。
3.3 训练过程控制:早停与学习率衰减
早停机制是避免过拟合的核心工具。我设定patience=15,意思是连续15个epoch验证损失没有刷新最小值就终止训练,并回滚到验证损失最优的那个权重。为什么设15而不是5?因为LSTM训练过程的验证损失本身就有波动,太小的patience会误杀训练过程。
再配一个ReduceLROnPlateau:如果验证损失连续5个epoch不下降,学习率就乘以0.5。这种动态调整比一开始就用极小学习率有效得多——前期用较大学习率快速进入最佳区域,后期用小学习率精调。
我在训练过程中记录了一个很有意思的现象:在大概第30个epoch之前,训练损失下降得非常快,验证损失也同步下降;之后训练损失还在缓慢下降,但验证损失开始横向震荡,这个拐点几乎就是过拟合的起点。早停机制在这里的价值不是“锦上添花”,而是实实在在挽救了一个即将崩盘的模型。
4. 实操过程与代码实现——把每一步跑通
话不多说,直接上代码。下面的代码在我的环境里跑通过,Python 3.10、TensorFlow 2.13、NumPy、Pandas、Scikit-learn这几个库缺一不可。为了照顾可复现性,我把所有关键逻辑拆开解释。
4.1 数据准备与特征构造
import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler # 读取净值数据,假设CSV包含date, nav两列 df = pd.read_csv('fund_nav.csv', parse_dates=['date']) df = df.sort_values('date').reset_index(drop=True) # 计算日收益率 df['ret'] = df['nav'].pct_change() # 特征工程 df['ma5_diff'] = df['ret'].rolling(5).mean() - df['ret'].rolling(20).mean() df['vol20'] = df['ret'].rolling(20).std() df['max_drawdown_20'] = df['nav'].rolling(20).apply( lambda x: x.max() / x.min() - 1, raw=True ) df['momentum_22'] = df['ret'].rolling(22).sum() df['volume_trend'] = df['ret'].rolling(5).mean() / df['ret'].rolling(20).std() # RSI(14) delta = df['ret'].diff() gain = delta.clip(lower=0).rolling(14).mean() loss = (-delta.clip(upper=0)).rolling(14).mean() rs = gain / loss df['rsi_14'] = 100 - (100 / (1 + rs)) # 丢掉NaN行,后续滑窗所需 df = df.replace([np.inf, -np.inf], np.nan).dropna().reset_index(drop=True) feature_cols = ['ret', 'ma5_diff', 'vol20', 'max_drawdown_20', 'momentum_22', 'volume_trend', 'rsi_14'] data = df[feature_cols].values这里有个细节我刚开始没注意,后来排错才发现:maximum_drawdown原本我用的是从最高点到最低点的回撤,但滚动窗口内最高点在最低点之前才合理,如果直接算当前净值除以窗口最大值,会引入未来信息。所以上面用了x.max() / x.min() - 1,表示窗口内从高点到低点的最大跌幅,是一个正常的统计量。
4.2 构建滑窗数据集
def create_sequences(data, look_back=30, forecast_horizon=5): X, y = [], [] for i in range(len(data) - look_back - forecast_horizon + 1): X.append(data[i : i + look_back]) # 预测未来horizon天的收益率 y.append(data[i + look_back : i + look_back + forecast_horizon, 0]) return np.array(X), np.array(y) X, y = create_sequences(data)滑窗构造完成之后,X的形状是(样本数, 30, 7),y的形状是(样本数, 5)。这里不用把所有特征都预测,只预测收益率那一列(第一列)就够了。你要预测多天,输出就是多个值。
4.3 切分与归一化
train_size = int(len(X) * 0.8) val_size = int(len(X) * 0.1) X_train, y_train = X[:train_size], y[:train_size] X_val, y_val = X[train_size:train_size + val_size], y[train_size:train_size + val_size] X_test, y_test = X[train_size + val_size:], y[train_size + val_size:] # 关键:只在训练集上fit归一化器 scaler = StandardScaler() # 对每个特征维度做归一化 def scale_reshape(X_data): n_samples = X_data.shape[0] flattened = X_data.reshape(-1, X_data.shape[2]) transformed = scaler.transform(flattened) return transformed.reshape(n_samples, X_data.shape[1], X_data.shape[2]) # 先fit训练集 for i in range(X_train.shape[1]): scaler.partial_fit(X_train[:, i, :]) X_train = scale_reshape(X_train) X_val = scale_reshape(X_val) X_test = scale_reshape(X_test) # y也需要归一化 y_scaler = StandardScaler() y_train_scaled = y_scaler.fit_transform(y_train) y_val_scaled = y_scaler.transform(y_val) y_test_scaled = y_scaler.transform(y_test)这个切分方式看起来简单,实际上有个相当容易踩的坑:你在对X_train做形状变换归一化时,如果直接用scaler去处理整个X_train,之后又要用它处理X_test,那scale_reshape这个函数就必须记住训练集的统计量。我上面用partial_fit在训练集上逐时间步拟合,相当于把全部训练数据过了一遍,这样得到的均值和方差才真正属于训练集。
4.4 LSTM模型搭建
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dropout, Dense from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model = Sequential() model.add(LSTM(128, return_sequences=True, input_shape=(30, X_train.shape[2]))) model.add(Dropout(0.2)) model.add(LSTM(64, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(32, activation='relu')) model.add(Dense(5)) model.compile(optimizer='adam', loss='mse', metrics=['mae']) model.summary() callbacks = [ EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6) ] history = model.fit( X_train, y_train_scaled, validation_data=(X_val, y_val_scaled), epochs=200, batch_size=32, callbacks=callbacks, verbose=1 )LSTM的输入形状一定要确认好:第一个维度是样本数,第二个维度是时间步(30),第三个维度是特征数(7)。很多人报错说维度对不上,基本都是第三个维度写错了。
4.5 评估与反向预测
from sklearn.metrics import mean_absolute_error, mean_squared_error # 预测并逆归一化 pred_scaled = model.predict(X_test) pred = y_scaler.inverse_transform(pred_scaled) true = y_scaler.inverse_transform(y_test_scaled) mae = mean_absolute_error(true, pred) rmse = np.sqrt(mean_squared_error(true, pred)) # 方向准确率:预测收益率方向与真实方向一致的比例 direction_acc = np.mean(np.sign(pred[:, 0]) == np.sign(true[:, 0])) print(f'MAE: {mae:.5f}, RMSE: {rmse:.5f}, 方向准确率: {direction_acc:.2%}')最终我在测试集上的MAE大概是0.0086,方向准确率在59%左右。这是个什么概念?如果无脑预测“明天跟今天一样”,方向准确率大概也就50%~52%。能稳定超过55%,我个人的判断是模型确实捕捉到了一部分短期动量信息,虽然不多,但已经比随机强出一截。
回看训练时间,整个流程在一张普通GPU上大概跑了7分钟,CPU的话可能要20~30分钟。如果你不想等,可以先把第一层LSTM的单元数改成64,效果差距不会太大,但训练速度几乎快一倍。
5. 常见问题与排查技巧实录
真正花时间的从来不是正常流程,而是排查那些模型“看起来在训练,结果却离谱”的时刻。我把这段时间遇到最多的几个问题整理成了一份排查实录,希望你能少走点弯路。
5.1 预测曲线“根本没动”,像是水平线
这是我最早遇到的现象:测试集上预测出来的未来5天净值,几乎是一条水平线,真实净值在上下波动,预测值却纹丝不动。
排查思路:
- 先看训练损失:如果训练loss在下降但预测曲线是平的,大概率是模型只学到了输入的均值,没学到有效波动。这通常是因为滑窗长度太短,比如用look_back=3或5,模型根本看不出来任何趋势。
- 再看看归一化范围:如果你用MinMaxScaler直接归一化原始净值,而净值本身的绝对数值远大于波动幅度,模型只要预测一个接近均值的常数就能获得很小的loss,没必要去学波动细节。解决办法是像我一样改用收益率序列,或者把归一化后的数据做差分。
- 如果这些都没问题,就是学习率太小,模型在有限的epoch里还没充分收敛。把学习率从0.0001调回0.001,顺便把早停patience稍微放宽到20。
5.2 预测净值出现“数值漂移”,越预测越离谱
有时候单天的预测看起来合理,但连续预测多天后误差累积,预测净值越走越偏,甚至出现负值。
这里有两个关键点。第一是收益率序列累积成净值时,误差会被指数级放大,这是所有收益率预测模型的通病,LSTM也不能幸免。第二是特征里如果包含了滚动均值、波动率这些滞后统计量,预测时这些统计量本身也是用预测值算出来的,误差会二次传染。
我的解法是对输出的未来5天收益率做“截断”,限制在[-5%, 5%]的合理区间内,这虽然有点糙,但能有效抑制漂移;更严谨的做法是用蒙特卡洛模拟来估计净值区间,而不是直接用点预测去累加。
5.3 训练损失不降反升,或者剧烈震荡
典型的梯度爆炸现象。时间序列模型对梯度非常敏感,尤其是输入值范围没控制好时更容易出现。排查顺序是这样的:
- 确认有没有在y上做归一化,如果y的绝对值很大,比如直接预测净值100、200这样的量级,MSE损失会非常大,梯度必然爆炸。
- 如果已经在归一化还在震荡,试着加上梯度裁剪。TensorFlow里可以通过
optimizer=tf.keras.optimizers.Adam(clipnorm=1.0)来实现。 - 检查输入数据里有没有无穷值。我之前feature engineering时把分母算成0,结果出现了一个inf,模型直接罢工。
5.4 常见问题速查表
| 症状 | 可能原因 | 快速解法 |
|---|---|---|
| 预测值为常数 | 归一化方式不对或look_back太小 | 改用收益率序列,增大滑窗到30以上 |
| 训练loss震荡 | 学习率过大或数据有异常值 | 降学习率,添加gradient clipping |
| 验证loss不断上升 | 过拟合 | 增大Dropout,减少LSTM单元数或换小batch |
| 换新基金后效果暴跌 | 数据特性不同,模型不具备泛化性 | 重新做特征工程,至少重训模型 |
| 预测净值出现负值 | 收益率累积误差 | 对预测收益率做截断处理 |
结尾再聊几句
我个人在实际操作中的体会是:LSTM预测基金净值这件事,难点根本不在网络结构上,而在于你愿不愿意花时间去理解数据本身的脾气。数据里哪个特征值得喂给模型、哪些统计量会引入未来信息、归一化边界怎么设才合理,这些才是决定上限和下限的地方。代码在网上随便一抓一大把,但把数据处理好的人才能让同一个模型出现完全不同的表现。
如果你也想上手试试,我建议别急着追求复杂的模型,先拿一只你比较了解的基金,跑通这套基础流程,然后每天记录一下模型预测值和真实值之间的差异。过一段时间你会发现,你对这个模型的“脾性”越来越熟悉,这种手感比任何教程都值钱。
这个实验后续还能扩展的方向其实不少:把单只基金改成多只基金同时预测,利用它们之间的相关性做组合预测;换成GRU或者引入注意力机制,看对小样本数据集有没有帮助;把预测结果接上一层简单的均线策略回测,检验方向准确率能不能转化成实际收益。总之,先跑通,再谈优化,这个顺序别乱了。