把股市历史数据丢进机器学习模型,让它预测明天的价格,这活儿到底能不能干?我在这个系列第七个项目里完整走了一遍“数据获取→特征工程→模型训练→回测评估”的流程。这篇不是教你“照着做就能从股市赚到钱”,而是把这条技术链路拆开讲透:股价预测任务的本质是什么,标签怎么设计,特征怎么做,模型选什么,评估指标怎么看,以及最容易踩的坑在哪里。适合已经学完机器学习基础算法、想拿真实数据练手的人,也适合想系统梳理时间序列预测完整流程的人。
先说一句大实话:股价数据信噪比极低,影响价格的因素多且杂,机器学习模型很难稳定跑赢市场。但正是因为问题难,这个项目才特别适合用来检验你对特征工程、数据划分、模型调参和评估方法的掌握程度。做完它,你对“真实数据项目”的理解会往前走一大截。
1. 整体设计思路:先想清楚预测目标再动手
1.1 股价预测的本质:回归目标还是分类目标
入手的第一个问题,不是“用什么模型”,而是“要预测什么”。股价预测通常有两条技术路线,对应着两种完全不同的标签设计。
第一条路线是把股价预测当作回归问题:用过去N天的行情数据,预测明天的收盘价,或者预测未来5日的收益率。回归目标的优点是直观,预测结果就是具体数值,可以和真实价格直接对比。缺点是金融价格序列高度非平稳,今天股价100元,明天可能因为除权、拆分变成50元,模型学的绝对值规律很容易失效。实际训练里你还经常看到一个现象:模型预测的曲线比真实曲线平滑很多,涨跌方向滞后一拍。
第二条路线是把股价预测当作分类问题:预测明天相对于今天的涨跌方向,标签是0或1,也就是二分类。分类目标的好处是没那么容易被价格尺度变化干扰,也更贴近实际交易决策——大多数短线策略本质上只需要判断方向。坏处是丢掉了很多幅度信息,预测“会涨”和预测“会大涨”是两回事。
我在这个项目里选择了一个折中方案:主标签用“未来5日收益率的方向”,同时保留一个辅助回归目标“未来5日收益率数值”用于回测模拟。这样模型训练时分类目标给出方向信号,评估时回归目标给出幅度参考,两个角度互相补充。
这里有个容易被忽略的细节:标签的时间跨度会直接影响模型的行为。预测第二天的涨跌,数据噪音极大,模型很难学到稳定规律;预测未来5日或10日的趋势,噪音被平均掉一部分,规律性更强,但样本之间的重叠度也更高,评估时要注意独立性。我在实践里一般从3日到10日之间做实验,盲目追求更长的预测周期不一定更好。
1.2 从数据到预测的完整流程与工具链
整个项目按下面这条链路走:数据获取→清洗对齐→特征工程→样本构造→模型训练→评估回测。每一个环节都有独立的坑,如果链路里任何一步做得不严谨,后面的复杂模型都救不回来。
工具链上我用的是最成熟的组合:Python处理数据,pandas做表结构操作,numpy做数值计算,scikit-learn提供普通机器学习基线模型,TensorFlow/Keras负责LSTM这类序列模型,matplotlib绘制损失曲线和预测对比图。这套组合的好处是生态成熟、资料多、出问题容易排查,对个人项目来说完全够用。
有一个经验值得单独说:拿到项目后,先不管效果,用最简单的模型把整条链路跑通,再去上复杂模型。很多人一上来就调LSTM,结果数据泄漏、标签错位这类问题被复杂模型掩盖,最后效果差也不知道是哪一步错了。我通常是先写一个线性回归基线,把特征、标签、切分逻辑全部验证一遍,确认没有明显bug,再切换到LSTM。这能帮你省掉大量调试时间。
2. 数据划分与特征工程:这两步不出错,模型就成功了一半
2.1 时间序列切分:防止未来数据泄漏
处理普通分类任务时,我们可以用train_test_split随机打乱数据。但处理时间序列时,随机打乱是灾难性的:模型会在训练阶段看到“未来”的数据,测试时表现虚高,一上真实场景立刻拉胯。
正确做法是按时间顺序切分。我这次把数据切成三段,训练集从2020年初到2023年6月,验证集是2023年下半年,测试集是2024年上半年。这样的切分意图是:训练集训练模型,验证集用来调参和触发早停,测试集模拟“模型从未见过的未来数据”,用来做最终效果评估。
import pandas as pd # df 为原始行情数据,按日期升序排列,date 为索引 train_data = df.loc['2020-01-01':'2023-06-30'] val_data = df.loc['2023-07-01':'2023-12-31'] test_data = df.loc['2024-01-01':'2024-06-30'] print(f'train: {train_data.shape}, val: {val_data.shape}, test: {test_data.shape}')切分之后还有一个隐蔽的泄漏点:特征工程也要“按时间递归”去做。比如计算移动平均MA20,如果拿整个数据集直接计算再切分,训练集里某些滑窗实际包含了未来数据点。这在多数情况下影响不大,但在严格的项目里属于数据泄漏。严谨做法是对三个数据集分别计算特征,或者先切分再对每段单独做滑窗统计,这一点值得养成习惯。
2.2 技术指标特征构建:把K线转成模型能懂的输入
原始行情数据通常包含开盘价、最高价、最低价、收盘价、成交量五列。模型如果直接吃这五列,能学到的东西非常有限,因为价格本身的绝对数值受市场环境影响太大。需要手工构造一批衍生特征,把价格趋势、波动幅度、动量等信息显式暴露给模型。
我用的特征分四类。第一类是移动平均线,包括MA5、MA10、MA20、MA60,用来捕捉不同周期的趋势;第二类是价格相对位置,比如收盘价与MA20的偏离度,衡量当前价格处于近期区间的什么位置;第三类是波动相关特征,包括日内振幅、过去N日收益率标准差;第四类是量价配合特征,比如成交量相对5日均量的变化率。全部特征加起来18个,最后做标准化。
| 特征类别 | 具体特征 | 含义与用途 |
|---|---|---|
| 趋势类 | MA5、MA10、MA20、MA60 | 捕捉不同周期的价格趋势 |
| 位置类 | 收盘价/MA20 - 1 | 衡量当前价相对均线的偏离 |
| 波动类 | 日内振幅、20日收益率标准差 | 刻画价格活跃程度 |
| 量价类 | 成交量/MA5成交量 - 1 | 判断量能是否配合价格变化 |
计算这些特征时,最需要注意的就是“当前特征只能用当前及之前的数据”。以MA20为例,时间点t的MA20,必须是t时刻往前数20根K线的平均价,不能把t+1的K线也算进去。pandas的rolling函数默认就是向后窗口,方向天然正确,但如果你自己写循环或做数据拼接,这里极容易出错。
归一化也是一个关键细节。LSTM对输入尺度敏感,特征不归一化直接训练,损失很容易震荡甚至发散。我用的方法是MinMaxScaler,将特征缩放到0到1区间。有一点必须提醒:scaler只能fit在训练集上,然后用同一个scaler去transform验证集和测试集。如果对全部数据一起fit,测试集的信息就已经泄漏进特征了,这会高估模型效果。
2.3 标签设计:预测未来多少天的价格才合理
标签的构造原理是这样的:在时间点t,我们想知道未来5日的收益率,就用t+5的收盘价除以t的收盘价再减去1。pandas里用shift方法可以实现这种跨时间取数。
# 未来5日收益率作为回归标签 df['future_return_5d'] = df['close'].shift(-5) / df['close'] - 1 # 分类标签:上涨为1,下跌为0 df['label_5d'] = (df['future_return_5d'] > 0).astype(int)这里有一个需要注意的关键点:shift(-5)意味着标签使用了未来5日的数据,在构造特征时绝不能把这部分信息混进特征列。也正是因为这个原因,样本的最后5行无法构造完整标签,直接丢弃即可。
至于为什么选5日,我是在实验中比较出来的。1日标签的预测准确率看着还行,但仔细分析后发现模型几乎总是预测“上涨”,因为单日上涨天数在样本里占比略高,模型学到的只是频率统计,没有任何有效信号。10日标签的稳定一些,但训练样本之间的重叠度太高,导致测试集评估过于乐观。5日算是一个折中,既能平滑单日噪音,又不至于让样本重叠太严重。
3. 模型训练:线性回归打底,LSTM进阶
3.1 第一步:用线性回归跑通全流程基线
我一直强调基线模型的重要性。线性回归在股价预测上效果不会好,但它足够简单,可以验证数据管道是否通畅、标签是否对齐、评估代码是否合理。
from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_error, accuracy_score # X_train 为特征矩阵,y_train_5d 为5日收益分类标签 model = LinearRegression() model.fit(X_train, y_train_5d) # 分类视角:预测值>0判为涨 pred_train = (model.predict(X_train) > 0).astype(int) pred_test = (model.predict(X_test) > 0).astype(int) print(f'train acc: {accuracy_score(y_train_5d, pred_train):.4f}') print(f'test acc: {accuracy_score(y_test_5d, pred_test):.4f}')跑完的典型结果是:训练集准确率50%到55%,测试集也在50%上下浮动,基本等于抛硬币。这个结果很正常,金融价格序列的预测难度本来就高,线性模型只能捕捉线性关系,对非线性模式无能为力。但它的价值在于:给你一个“地板成绩”,后续任何模型的准确率如果明显低于这个值,大概率是代码写错了,而不是模型不行。
我建议把基线模型的结果存档,包含训练时间、特征数量、数据区间、准确率这些信息。之后换LSTM、换特征子集,都拿这个基线做基准。没有基线做对照,你会很难判断模型改进到底来自哪里。
3.2 第二步:构造滑动窗口样本,让LSTM上手
LSTM能读的是序列数据。对每一条样本来说,输入不是一个时刻的特征向量,而是连续多个时刻组成的时间窗口。我这次选的窗口长度是60天,也就是用过去60个交易日的特征预测未来5日的涨跌方向。
滑动窗口的构造方式不复杂,但要特别注意样本量变化。假设原始数据有800个交易日,窗口长度60,构造出的序列样本是800-60=740条,而不是800条。特征矩阵的形状从二维变成三维,维度是(样本数,窗口长度,特征数)。
import numpy as np def make_sequences(data, feature_cols, label_col, window=60): X, y = [], [] for i in range(window, len(data)): X.append(data[feature_cols].iloc[i-window:i].values) y.append(data[label_col].iloc[i]) return np.array(X), np.array(y) # 假设 X_raw 是特征 DataFrame,label 是标签列 X_seq, y_seq = make_sequences(df_processed, feature_cols, 'label_5d', window=60) print(X_seq.shape, y_seq.shape) # 例如 (740, 60, 18)窗口长度60天是一个经验值。太短,模型看不到足够长的趋势背景;太长,数据量变少,计算开销变大,而且远古的价格信息对当前预测的干扰反而增加。实际项目中可以把窗口长度当作超参数一起调,40、60、90都试一遍,对比验证集表现再定。
网络结构上,我用的方案是两层LSTM加一个全连接输出层。第一层LSTM返回序列,第二层只返回最后一步的输出,中间加了一个Dropout层防止过拟合,最后接一个sigmoid输出层做二分类。单元数设成64,整体参数规模不大,在普通CPU机器上训练也能接受。
3.3 训练控制:早停、随机种子与归一化细节
LSTM训练有几个细节点,每一点都直接影响最终效果。
第一是随机种子。LSTM的权重初始化和训练过程中的随机性很强,同一个模型同一天跑两次,准确率可能差3到5个百分点。我在所有涉及随机性的环节都设置了固定随机种子,确保实验可复现。这一点在调参时尤其重要,不然你无法判断准确率提升是来自参数调整还是纯随机波动。
第二是早停机制。我自己做实验时经常踩到过拟合的坑:训练集损失一路下降,验证集损失先是下降后突然反弹。应对办法是在验证集损失连续10轮不下降时停止训练,并恢复最优权重。Keras的EarlyStopping回调可以很方便地实现这点。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping model = Sequential([ LSTM(64, return_sequences=True, input_shape=(60, 18)), Dropout(0.2), LSTM(64), Dropout(0.2), Dense(1, activation='sigmoid') ]) model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy']) early_stop = EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True) history = model.fit(X_train_seq, y_train_seq, validation_data=(X_val_seq, y_val_seq), epochs=50, batch_size=64, callbacks=[early_stop], verbose=0)第三是预测结果的取整问题。sigmoid输出的是一个0到1之间的概率值,通常以0.5为界判涨跌。但实际数据中上涨样本和下跌样本往往不平衡,模型为了降低损失会偏向多数类。我验过几种阈值,发现这个项目里阈值设成0.45比0.5略好。如果你想追求更精细的控制,可以用验证集遍历不同阈值,选一个让验证集准确率最高的值,再用到测试集上。
4. 模型评估与踩坑实录:指标、回测与排查清单
4.1 评估指标不能只看误差:方向准确率同样重要
回归类模型常用MAE和RMSE评估预测误差,股价预测里这两个指标也能看,但有一个陷阱:股价的绝对值波动大,RMSE容易受少数极端行情影响,导致你很难客观判断模型好坏。
我建议把“方向准确率”作为主指标。方向准确率的含义很直接:模型说涨,真实市场也确实涨了,就算预测正确。它不关心涨了多少,只关心方向对不对,这更贴近交易决策的实际需求。
from sklearn.metrics import accuracy_score, confusion_matrix # y_prob 为 sigmoid 输出的概率 y_pred_class = (y_prob > 0.45).astype(int) acc = accuracy_score(y_test_label, y_pred_class) tn, fp, fn, tp = confusion_matrix(y_test_label, y_pred_class).ravel() print(f'test acc: {acc:.4f}') print(f'confusion matrix: TN={tn}, FP={fp}, FN={fn}, TP={tp}')还需要一个所有模型评估都应做的对照:把“昨天收盘价作为明天的预测值”这类朴素基线列出来。如果模型的方向准确率只比“无脑全预测上涨”高两三个百分点,并且这种优势在测试集上不稳定,说明模型几乎没有学到有效信息。我最开始跑出的模型就是这种情况,后来靠改进特征和标签才把测试集准确率稳定在了53%附近,虽然绝对值仍然不高,但至少是真实信号而非统计假象。
4.2 回测:从预测数值到模拟交易
准确率只是一个预览指标,要真正判断模型有没有价值,需要做回测。回测的思路很简单:从测试集第一天开始,按模型的预测结果模拟买入卖出,计算组合净值变化。
需要注意回测不是把预测准确率乘一下那么简单,交易成本是必须考虑的部分。比如每次交易按万分之一手续费估算,测试集半年内如果频繁交易,手续费累计起来会明显吃掉利润。我简单模拟了两种策略,一种是每天根据模型预测持仓或空仓,另一种是只在大波动日附近交易,结果第一个策略在扣除手续费后几乎没有超额收益。这个结果很说明问题:模型有微弱预测能力,但不足以覆盖摩擦成本。
回测代码本身不复杂:
capital = 1.0 position = False # False表示空仓 for date, pred in zip(test_dates, y_prob): if pred > 0.55 and not position: position = True elif pred < 0.45 and position: capital *= 1 - 0.0001 position = False # 最终净值再乘一个粗略的无风险收益,和指数收益对比这种回测属于极简版本,没有考虑涨跌停限制、最小交易单位、滑点等真实约束。做研究够用,但要真拿去实盘操作,还差很远。我一直提醒自己:回测结果只能说明模型在历史数据上有没有信号,不能证明未来能赚钱。
4.3 高频踩坑清单与排查思路
做完整个项目,我整理了遇到过的典型问题,按症状、原因、办法三列整理成了一张排查表。
| 症状 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 测试集准确率虚高,实盘下降 | 数据泄漏 | 检查标签是否被混入特征,scaler是否只fit在训练集 |
| 损失曲线震荡不收敛 | 特征未归一化或学习率过大 | 用MinMaxScaler,降低学习率 |
| 训练集准确率高,测试集很低 | 过拟合 | 增加Dropout,减少LSTM层数,使用早停 |
| 预测曲线总是滞后真实价格 | 模型学到的是惯性而非真实规律 | 换用更长时间跨度标签,加入波动特征 |
| 结果每次跑都不一样 | 随机种子未固定 | 全局设置固定随机种子 |
| 验证集好,测试集崩 | 验证集参与过多调参 | 保留全新测试集,只在最后评估一次 |
还有一个很容易忽略的问题:数据区间不同,模型表现差异巨大。比如某段时间市场单边上涨,模型只要学会“跟着买”就能表现不错,但换成震荡行情,立刻失效。我的做法是刻意选择包含不同行情类型的数据区间,并且每次评估都分开统计上涨段和下跌段的准确率,这样能更真实地看到模型的适用边界。
最后分享一个我个人体验很深的点:做这个项目最大的收获,不是模型预测准确率有多高,而是掌握了一套严谨处理时间序列数据的工程方法。数据切分、滑窗构造、归一化泄漏、标签对齐,这些能力在任何一个时间序列相关任务里都用得上。如果你也想把这个项目继续往下做,一个很自然的扩展方向是引入更多维度的数据源,把技术指标和基本面特征放在一起做多因子实验,或者对比Transformer类模型在金融序列上的表现。但不管往哪个方向走,请先把今天讲的这些数据细节处理好,这比换复杂模型重要得多。