
简介面向金融时序预测与量化分析场景这个合集系统涵盖CNN、LSTM、随机森林与ARMA四类价格预测方法既有深度学习模型也有机器学习与经典统计方案并配套结果可视化和评判指标绘图适合入门者梳理技术路线也便于研究人员对照实验。相似度计算模块提供皮尔逊相关系数、动态时间规整、余弦相似度三种实现可支持序列匹配与聚类辅助工具包含特征方差计算、混淆矩阵绘制等脚本帮助完成特征筛选与模型评估。压缩包共68个文件以19个Python脚本为主配合8个CSV数据文件、30张PNG结果图、2个Notebook示例和6个Markdown说明文档整体容量约7.84MB目录按预测方法、相似度计算、其他工具三大模块组织便于按模块查阅。目前已有1752人学习使用内容覆盖从数据预处理到模型对比的完整链路是一份实用的时序预测参考资料。1. 金融时间序列预测这碗饭为什么模型越复杂越容易翻车把 CNN、LSTM、随机森林、ARMA 四类方法塞进同一个预测价格的任务里看起来是“全家桶”实际上一线做量化的人心里都有数金融时间序列预测的难点从来不在模型而在数据口径和评估方式。同一个序列前复权没做、停牌日没对齐、训练集里混进未来数据再好的 LSTM 模型代码也是白搭。标题把四类模型、相似度计算、评判指标绘图放在一起其实是在拼一套完整的时序预测工作流——从数据清洗开始到模型选型、相似样本检索、误差评估最后落到能说服自己的图表上。这篇文章打算按这条链路把每一步怎么落地、参数怎么调、坑在哪里讲透适合两类人看一是刚用 Python 做时序预测、想把这几个模型串起来的新手二是已经有单模型经验、想补全评估和相似度环节的从业者。2. 数据清洗与样本构建先让四套模型吃同一份数据2.1 金融时序数据的三道坎日期对齐、复权、缺失值所有模型跑之前先统一数据口径。常见做法是把日线数据存在 DataFrame 里索引是交易日列是 open、high、low、close、volume。第一道坎是日期对齐——不同来源的数据交易日历不一致直接 concat 会错位。我一般先拉交易所交易日历再对数据做 reindex缺失的交易日填 NaN。第二道坎是复权。预测“价格”这件事如果用未复权数据除权除息日会出现人为跳空模型会把这个假信号当真。用前复权数据做训练预测结果要映射回真实价格时再做一次逆运算。后复权更适合做长期回测因为前复权会随着新除权事件不断修正历史价格回测时容易引入未来信息。第三道坎是缺失值和停牌。停牌日的价格是“不存在”不是“不变”直接把上一交易日价格 forward fill 会让模型学出“价格永远不会变”的错误规律。我的处理方式是停牌超过 5 天的样本直接剔除该日停牌 12 天的用前收盘价填充但在样本权重上打一个折扣。代码里的权重列就是干这个用的。import pandas as pd import numpy as np def load_and_align(path, trade_calendar): df pd.read_csv(path, index_col0, parse_datesTrue) # 对齐交易日历缺失日期设为 NaN df df.reindex(trade_calendar) # 前复权处理复权因子在数据源里通常单独给出 df[close_adj] df[close] * df[factor] # 停牌识别成交量为 0 或 NaN 视为停牌 df[is_suspend] (df[volume].fillna(0) 0).astype(int) # 停牌不超过 5 天用前收盘价填充 df[close_adj] df[close_adj].ffill(limit5) # 超过 5 天置为 NaN后面统一剔除 df.loc[df[is_suspend].rolling(5).sum() 5, close_adj] np.nan return df.dropna(subset[close_adj])reindex保证了序列的等间隔factor是复权因子回测时用它把预测结果还原成当时看到的“真实价格”。这里有个关键细节训练时永远用复权后的数据不要掺原始价格否则除权日会被模型当成极端行情。2.2 用收益率代替价格平稳性是建模的前提ARMA 这类线性模型要求序列平稳直接用价格序列跑基本必炸——价格带趋势、带单位根ACF 衰减极慢。普通做法是对价格取对数差分也就是 log return一阶之后基本平稳。CNN 和 LSTM 对尺度不敏感但金融数据的信噪比太低用价格做输入时归一化因子会被极端值主导所以我习惯输入收益率、输出未来收益率最后再还原成价格。def make_return_features(df, price_colclose_adj, lags5): df[ret] np.log(df[price_col]).diff() # 用滚动统计做标准化避免用到未来数据 df[ret_mean] df[ret].rolling(20).mean().shift(1) df[ret_std] df[ret].rolling(20).std().shift(1) df[ret_norm] (df[ret] - df[ret_mean]) / df[ret_std] out pd.DataFrame(indexdf.index) for i in range(1, lags 1): out[flag_{i}] df[ret_norm].shift(i) out[target] df[ret].shift(-1) # 预测下一期收益率 return out.dropna()标准化参数ret_mean和ret_std都做了shift(1)意思是只用截至当前时刻的历史数据计算不偷看当前这根 K 线更不偷看未来。target是下一期收益率预测出来之后叠加上一期价格就能还原出预测价格。这套“收益率 滚动标准化”的预处理四个模型共用省得各搞一套特征工程对比结果时也更公平。2.3 滑窗切分与未来函数检查时序预测的样本切分和普通分类任务完全不同不能随机打乱。常见做法是 walk-forward 滑窗训练集 60%70%、验证集 15%20%、测试集放最后按时间顺序切。切完之后做一次未来函数检查——把训练集里每一行的特征和标签对照时间戳确认标签时间点晚于所有特征时间点。def walk_forward_split(df, train_ratio0.6, val_ratio0.15): n len(df) train_end int(n * train_ratio) val_end train_end int(n * val_ratio) train df.iloc[:train_end] val df.iloc[train_end:val_end] test df.iloc[val_end:] return train, val, testtrain里特征时间戳最大是t-1标签时间戳是t没有任何泄漏。一个容易忽略的检查feature engineering 阶段如果用了全样本统计量比如 min-max 缩放测试集信息就已经混进训练过程了。所以缩放器只能在训练集上fit验证集和测试集只用transform。3. 四个模型的分工与实现ARMA 做基线随机森林做非线性基线CNN 抓局部形态LSTM 抓长程依赖3.1 ARMA别看它老它是判断“模型有没有进步”的标尺ARMA 是 AR 和 MA 的组合捕捉的是序列自身的线性自相关结构。金融收益率序列普遍存在微弱的一阶自相关和波动率聚集ARMA 至少能把这个基线水平榨干净。用 statsmodels 跑的时候先看 ACF/PACF 图粗定阶数再按 AIC/BIC 筛选别一来就上大阶数。from statsmodels.tsa.arima.model import ARIMA import statsmodels.api as sm def fit_arma_best(train_ret, max_p5, max_q5): best_aic, best_order float(inf), None for p in range(0, max_p 1): for q in range(0, max_q 1): try: model ARIMA(train_ret, order(p, 0, q)).fit() if model.aic best_aic: best_aic, best_aic model.aic, (p, q) except ValueError: continue return best_order, best_aicARMA 的阶数组合是(p, q)这里的order(p, 0, q)第三个参数是差分阶数对收益率序列已经是平稳的差分阶数取 0。暴力搜索 36 个组合在日频数据上耗时很短但要注意max_p和max_q不要超过 10阶数过高容易过拟合历史噪声AIC 选出来的阶数在样本外往往不是最优的。我一般还会把 AIC 排名前 5 的组合都存下来在验证集上对比不全信 AIC。3.2 随机森林回归非线性基线也是特征重要性的照妖镜随机森林的优势是能吃原始特征、不用做尺度归一化还能输出特征重要性帮助判断哪些 lag 值和额外因子真正有用。在金融时序上随机森林的问题是外推能力差——训练集里没出现过的价格区间它预测时只会输出训练集范围内的值。所以涨跌停后的价格预测、极端行情下的预测随机森林几乎必然失效这一点不影响它做基线但别拿它当主力。from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_squared_error def train_rf(train_df, val_df, feature_cols, n_estimators300, max_depth8): X_train train_df[feature_cols].values y_train train_df[target].values X_val val_df[feature_cols].values y_val val_df[target].values model RandomForestRegressor( n_estimatorsn_estimators, max_depthmax_depth, min_samples_leaf10, random_state42, n_jobs-1 ) model.fit(X_train, y_train) y_pred model.predict(X_val) imp pd.Series(model.feature_importances_, indexfeature_cols).sort_values(ascendingFalse) return model, mean_squared_error(y_val, y_pred), impmin_samples_leaf10是金融时序里很重要的一挡——叶子节点样本太少模型会把个别极端行情当规律。max_depth8对 5 个 lag 特征绰绰有余树太深只会记住训练集里的噪声。n_estimators300不是越多越好300 棵之后 OOB 误差基本不再下降反而拖慢调参节奏。特征重要性这块我一般看 lag_1 和 lag_2 是否排在前列如果 lag_5 反而最高多半是数据里有星期效应没洗掉。3.3 CNN一维卷积在时序上的角色是“局部形态提取器”CNN 在时序预测里的定位不是替代 LSTM而是把最近 N 个时间步的局部形态比如 V 型反转、连续阴跌后的缩量十字星提取成特征。一维卷积核的尺寸对应时间窗口输出经过池化后接全连接层回归。金融序列信噪比低卷积核不要太多、不要太大否则第一层就在拟合噪声。import torch import torch.nn as nn class CNNRegressor(nn.Module): def __init__(self, input_size5, num_filters16, kernel_size3): super().__init__() self.conv1 nn.Conv1d(1, num_filters, kernel_size, padding1) self.conv2 nn.Conv1d(num_filters, num_filters, kernel_size, padding1) self.pool nn.MaxPool1d(2) self.fc nn.Linear(num_filters * (input_size // 2), 1) def forward(self, x): # 输入形状: (batch, 1, input_size) x torch.relu(self.conv1(x)) x torch.relu(self.conv2(x)) x self.pool(x) x x.view(x.size(0), -1) return self.fc(x)input_size是特征维度CNN 会把每个样本当成通道数为 1 的短信号处理。kernel_size3意味着每个卷积输出看的是最近 3 个 lag 的局部组合。padding1保证卷积前后长度不变。两层卷积加一层 MaxPool 是时序 CNN 的最小可用结构再加层数收益极其有限训练时间却翻倍。MaxPool1d(2)会把序列长度压缩一半所以全连接层输入维度是num_filters * (input_size // 2)这个整除关系要算清楚否则维度不匹配直接报错。3.4 LSTM长依赖的捕捉靠门控但金融序列的长依赖比你想象的短LSTM 的隐藏状态带遗忘门理论上能记住几十步之前的信息。但日频金融数据里超过 20 个交易日的“依赖”基本被市场有效性磨平了真正有用的窗口往往在 515 天。所以 LSTM 的 sequence length 不要设太长50 步以上的序列只会加大过拟合风险不会提升预测精度。import torch.nn as nn class LSTMRegressor(nn.Module): def __init__(self, input_size5, hidden_size32, num_layers1, dropout0.2): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.fc nn.Linear(hidden_size, 1) def forward(self, x): # 输入形状: (batch, seq_len, input_size) out, _ self.lstm(x) return self.fc(out[:, -1, :])hidden_size32对日频收益率序列通常够用取到 128 以上边际收益很低却让训练时间成倍增长。num_layers1是金融时序的默认选择——两层 LSTM 意味着第二层在加工第一层的抽象特征需要的数据量远比日频序列能给的多强行堆层数只会验证集崩坏。dropout0.2在单层 LSTM 上只作用于输出层防止全连接层过拟合。out[:, -1, :]取最后一步的隐藏状态作为整个序列的表示这是序列到点的标准做法。PyTorch 里 LSTM 源码默认参数是batch_firstFalse输入形状要求 (seq_len, batch, input_size)不设batch_firstTrue的话数据构造时非常容易搞反维度。这个细节盯不住前向传播的报错会耗掉半小时。3.5 四个模型怎么选先看任务周期再看数据量日频数据上四个模型的实战排序大概是数据量小少于 2000 根 K 线时 ARMA 和随机森林优先数据量中等200010000 根且特征里有额外因子时随机森林和 CNN 表现稳数据量大且任务偏长周期月度以上时 LSTM 才真正有发挥空间。盘中高频数据是另一种场景CNN 在局部形态提取上的优势更明显但那是另一个话题。4. 相似度计算金融序列的“像不像”到底怎么定义4.1 欧氏距离在金融序列上的失效相位偏移与幅度失真在相似度计算这个环节最常见的直觉是“两条序列数值越接近越相似”直接套欧氏距离。问题是金融序列有相位偏移——同样是“下跌后反弹”的形态A 股票用了 5 天B 股票用了 8 天欧氏距离会认为它们差异巨大。幅度失真同理两只股票形态一致但其中一只波动率是另一只的两倍直接算距离结果被波动率主导形态完全被淹没。所以做序列相似度之前先对序列做 z-score 标准化和趋势去除让两条序列在“形状”层面可比。这个步骤放在相似度计算前面比换任何高级距离函数都重要。4.2 DTW 动态时间弯曲处理错位与节奏差异DTW 允许两条序列在时间轴上伸缩对齐是处理相位偏移的主流方案。它的核心是构造一个距离矩阵然后找一条从矩阵左上到右下的最短路径。用 dtaidistance 库实现比手写动态规划快一个量级。from dtaidistance import dtw def dtw_similarity(seq1, seq2, window10): # 输入前先做 z-score消除幅度差异 def zscore(s): s np.asarray(s, dtypenp.float64) return (s - s.mean()) / (s.std() 1e-8) s1, s2 zscore(seq1), zscore(seq2) # window 限制 warping 路径的最大偏移 distance dtw.distance(s1, s2, windowwindow) return distancewindow10限制的是时间轴上的最大伸缩幅度对日频数据来说10 个交易日的偏移上限已经足够宽松。DTW 的返回值是距离数值越小越相似。使用 DTW 时要注意两点一是计算前必须 z-score否则幅度差异会主导距离二是 DTW 距离不满足三角不等式不能直接当度量放进 K-Means 这类依赖度量的算法里聚类要用 DTW 的变体或改用层次聚类。4.3 相似度在预测流程里的实际用途相似片段召回相似度计算不是为了算着玩在预测流程里有一个非常实用的落点从历史序列中召回与当前窗口最相似的片段统计这些片段之后 N 天的涨跌分布作为模型预测的交叉验证。比如 LSTM 预测明天涨 0.8%但相似片段之后 5 天的历史分布中位数是 -0.3%这时候要警惕模型可能抓错了形态。def recall_similar(df_ret, current_window, top_k20, horizon5): # current_window: 最近 N 天的收益率向量 dists [] for i in range(len(df_ret) - len(current_window) - horizon): hist_window df_ret[i:i len(current_window)] d dtw_distance(zscore(hist_window), zscore(current_window)) dists.append((i, d)) dists.sort(keylambda x: x[1]) top_k_idx [x[0] for x in dists[:top_k]] # 统计相似片段之后 horizon 天的累计收益 futures [] for idx in top_k_idx: seg df_ret[idx len(current_window): idx len(current_window) horizon] futures.append(np.prod(1 seg) - 1) return np.mean(futures), np.percentile(futures, [25, 75])这段代码输出的均值和中位数区间本质上是用历史相似日期的“后验分布”给模型预测做参照。注意df_ret必须用收益率序列不能用价格horizon是持有周期要和预测目标一致。实际使用时top_k 取 2050 之间比较稳定太少了统计量噪声大太多了相似度失去筛选意义。4.4 相似度坑标准化方式决定结果方向相似度计算的坑集中在标准化方式上。用全局均值和标准差做 z-score历史序列的每个点都被未来信息污染用扩展窗口的滚动均值做标准化早期样本标准化不稳定后期又追着最新值跑。我的做法是在相似度计算之前统一用训练集的均值和标准差做标准化而不是每一条待匹配序列单独标准化——这句话写出来很简单但实际调代码时很多人会对每段窗口重新算 mean/std导致相似度结果全偏。5. 常见问题排查现象、原因、解决5.1 LSTM 损失不降反升验证集像过山车现象LSTM 训练到第 30 个 epoch 时训练损失还能缓慢下降验证集损失曲线却剧烈震荡甚至持平在初始值附近。原因学习率太高LSTM 的梯度在时间步上累积参数更新幅度过大损失在最优点附近来回弹。另一个常见原因是序列长度太长梯度在反向传播中被截断长依赖信息根本传不回前面的时间步模型实际上在学“最近的 3 个交易日”而不是整个序列。解决先把学习率降到 1e-3 以下用 Adam 优化器然后把序列长度从 30 砍到 15看看验证集是否立刻变平稳最后检查数据标准化——LSTM 的输入如果用原始价格不同数量级的特征会让 LSTM 内部激活饱和梯度消失。5.2 随机森林的预测值全部落在训练集范围内极端行情预测失效现象测试集里出现训练期间从未见过的价格区间随机森林的预测结果却永远落在训练集 target 的 min 和 max 之间出现大涨大跌时预测值不变或反向。原因决策树是分段常数函数叶节点的输出是训练样本均值天然无法外推。这不是 bug是算法属性。解决随机森林只做中短期形态预测不做绝对价格预测输出改为“方向概率”——训练时把 target 转成二分类上涨/下跌用概率作为预测值避开回归外推问题。另一个补救方案是给极端样本加权让树在训练时至少“见过”类似量级的样本但本质问题没变。5.3 ARMA 的 AIC 选出来的阶数在样本外全面翻车现象用 AIC 在训练集上选出 ARMA(4,3)验证集上 MAPE 比 ARMA(1,1) 还差 20%。原因AIC 是在同一样本上的拟合优度加惩罚项样本量不够大时高阶模型的 AIC 优势可能是过拟合了训练集的特定噪声段。解决不再单独用 AIC 定阶改成 walk-forward 验证把训练集切成 5 段每一段分别拟合若干候选阶数取验证误差最小的组合。另外约束阶数上限不要超过 5金融收益率序列的高阶自相关在统计上多数不显著选了高阶基本是在刻噪声。5.4 相似度计算出来的 top 历史片段“长得一点也不像”现象DTW 距离最小的前几个历史片段画出来肉眼观察和当前窗口差异很大曲线错位严重。原因标准化方式错了。最常见的是每条窗口内部单独 z-score这样所有窗口的均值都变成 0、标准差都变成 1DTW 匹配的是“相对涨跌节奏”而不是形态本身另一个原因是窗口长度太短少于 10 个交易日随机噪声主导了距离。解决先用统一的训练集统计量做标准化再把窗口长度加到 2030 个交易日最后一步看 DTW 路径图如果对齐路径对角线周围大面积偏移说明 window 参数限制得太松把距离 tol 缩小到 5。5.5 指标算出来很好看但模拟交易完全不赚钱现象RMSE 和 MAE 在测试集上表现不错R² 也有 0.3 以上实盘模拟却发现预测方向和实际涨跌基本对不上。原因回归指标衡量的是数值逼近误差不是方向准确率。金融预测里连续三天预测涨 0.1% 而后天跌了 0.2%RMSE 可能不大但方向判断全错交易系统直接连续亏损。解决在评判指标里固定加入方向准确率和盈亏比配合残差分布图。如果 RMSE 小但方向准确率低于 52%这个模型不适合直接交易只能做波动率估计或风控信号。6. 评判指标与绘图用一张图判断模型是否真的能上实盘先给结论单看 RMSE 和 R² 做模型对比在金融时序预测上是新手行为。价格序列的 R² 天然虚高因为价格有趋势、有惯性预测“明天的价格接近今天的价格”就能拿到很高的 R²但这个预测毫无交易价值。改用收益率序列做评估之后R² 往往掉到 0.05 以下这才是真实水平。实际评估时我用四类指标RMSE/MAE 衡量数值误差MAPE 看相对误差方向准确率看交易价值MASE 对比朴素基线。MAPE 在价格接近 0 时会爆炸金融序列里如果做价差或高频数据MAPE 基本不可用。MASE 的定义是模型 MAE 除以朴素预测用上一期值直接预测下一期的 MAE小于 1 说明模型真的比“昨天等于明天”强这个指标在金融场景里最有说服力。import matplotlib.pyplot as plt from sklearn.metrics import mean_absolute_error, mean_squared_error def evaluate_and_plot(y_true, y_pred, model_name): mae mean_absolute_error(y_true, y_pred) rmse np.sqrt(mean_squared_error(y_true, y_pred)) mape np.mean(np.abs((y_true - y_pred) / (y_true 1e-8))) * 100 direction np.mean(np.sign(y_true) np.sign(y_pred)) * 100 fig, axes plt.subplots(2, 2, figsize(12, 8)) axes[0, 0].scatter(y_true, y_pred, alpha0.3, s10) axes[0, 0].plot([y_true.min(), y_true.max()], [y_true.min(), y_true.max()], r--) axes[0, 0].set_title(f{model_name}: pred vs true) residual y_true - y_pred axes[0, 1].hist(residual, bins50, densityTrue, alpha0.6) axes[0, 1].set_title(fResidual distribution) axes[0, 1].axvline(x0, colorr, linestyle--) axes[1, 0].plot(y_true.cumsum(), labeltrue, alpha0.7) axes[1, 0].plot(y_pred.cumsum(), labelpred, alpha0.7) axes[1, 0].set_title(Cumulative log return) axes[1, 0].legend() true_std y_true.rolling(20).std() pred_std y_pred.rolling(20).std() axes[1, 1].plot(true_std, labeltrue vol, alpha0.7) axes[1, 1].plot(pred_std, labelpred vol, alpha0.7) axes[1, 1].set_title(Return volatility comparison) axes[1, 1].legend() print(f{model_name}: MAE{mae:.5f}, RMSE{rmse:.5f}, fMAPE{mape:.2f}%, Direction{direction:.1f}%) return fig左上角散点图用来看预测值和真实值是否有系统性偏置——散点整体偏在红线一侧说明有偏差右上角残差直方图看残差峰度和偏度金融收益率残差通常厚尾如果残差近似正态说明序列中的非线性结构没被模型捕获左下角累计收益率曲线是最直观的对比——预测的累计曲线和真实曲线长期同向但幅度偏小是最常见的形态右下角波动率对比图能暴露模型的“回归到均值”毛病——预测波动率曲线明显比真实波动率平缓说明模型把极端波动全部平滑掉了这在金融场景里几乎致命。最后一件事所有模型的评估统一在收益率层面做不要换算回价格再计算误差。收益率层面的指标可以直接比较四个模型一旦换回价格MAE 和 RMSE 的量纲会随价格水平变化不同时间段、不同股票的指标数值完全失去可比性。我见过太多人在这上面栽跟头——训练时用收益率评估时却把预测价格画出来算 MAPE然后对着虚高的指标纠结模型哪里出了问题。先把评估口径统一了再去调模型。这些年做下来我自己的习惯是ARMA 和随机森林永远先跑一遍它们的误差就是这条数据的“地板”CNN 和 LSTM 的预测必须能在累计收益曲线上跑赢地板才留用相似度召回的结果挂在模型预测旁边做对照。一套流程固定下来换数据、换周期、换标的都走同一条链路指标和图表都标准化之后模型之间的差异才真正变得可解释。希望这些方法和踩坑记录能帮你在金融时间序列预测这条路上少走几步弯路。本文还有配套的精品资源点击获取