☰
机器学习股票预测项目拆解:从特征工程到LSTM避坑指南
2026/9/26 18:39:22 网站建设 项目流程

简介:面向量化投资爱好者、金融数据分析师及机器学习初学者,以Python工程形式整理了一份完整的股票价格分析与预测方案。内容覆盖历史行情数据预处理、技术指标特征构造、多模型训练验证与评估调优的实践链路,可直接用于搭建基线预测流程并扩展个人策略研究。压缩包共2个文件,包含一个Python主脚本与一个依赖库清单文件(txt+py),整体仅45KB,结构紧凑、便于阅读和二次开发。目前已有441人学习下载,适合想从实战角度理解线性回归、随机森林乃至LSTM等算法在时序预测中应用方式的读者。通过运行和调整脚本,可复现模型训练、验证与误差评估过程,结合参数设置与指标输出,获得从数据处理到模型比选的完整可执行参考。

1. 股票预测项目拆包:一个 app_fn.py 能让你少走多少弯路

把 zip 解开,里面安静躺着两个文件:app_fn.py 和 requirements.txt。这就是最近在资源站被反复下载的「基于机器学习算法的股票市场股票价格的分析及预测」工程。很多人以为股票预测项目都是动辄几十个文件的大仓库,实际上这份资源的核心就是一条能跑通的机器学习流水线:拉历史行情、清洗数据、构造特征、训练模型、评估误差。它适合两类人:一类是课程设计或毕业论文需要复现机器学习完整流程的学生,另一类是手里有数据但不知道怎么下手的量化入门者。先说结论:别指望它预测明天涨跌,但它把人工智能在金融数据上的标准套路完整串了一遍,值得照着跑一遍再改。

2. 先跑通环境和数据链路:从 requirements.txt 看懂项目边界

2.1 依赖清单一句话:装错版本比没有依赖更痛苦

打开 requirements.txt,看到的依赖数量比想象中少,基本集中在 pandas、numpy、matplotlib、scikit-learn 这几板斧。如果项目里还写了 LSTM 分支,那大概率会多出一行 tensorflow 或 keras,但这行不在默认清单里也很常见——作者默认你只跑随机森林那一套,深度学习部分靠你自己补。

我一般会先建一个虚拟环境再装,别直接往系统 Python 里灌,因为 pandas 和 numpy 的版本错配会让你在 running 到一半时突然看到AttributeError: 'DataFrame' object has no attribute 'append'这类翻车现场。

cd 基于机器学习算法的股票市场股票价格的分析及预测 python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install -r requirements.txt

逻辑说明:前三行是为了把依赖隔离在当前目录,第四行按清单装包。如果你要跑 LSTM 分支,再补一条pip install tensorflow-cpu,CPU 版对学习用途足够了,GPU 版反而会在驱动上浪费半天。

参数说明:requirements.txt里如果版本写的是pandas>=1.3.0这种形式,说明作者容忍新版本;如果写死==1.3.5,就按写死的装,不要手痒升级,我踩过 sklearn 1.2 之后随机森林部分参数行为变化的坑。

2.2 app_fn.py 的骨架:一条从行情到预测的流水线

这类工程里 app_fn.py 的结构大同小异,核心就是四个函数串起来:加载数据、清洗、构造特征、训练评估。完整跑一遍命令通常是这种形式:

python app_fn.py --symbol 600519.SS --start 2020-01-01 --end 2024-12-31

逻辑说明:--symbol传入股票代码,A 股带交易所后缀,美股直接传AAPL这种;--start和--end限定历史区间。命令跑完会打印出测试集上的误差指标,然后弹出一张真实价格和预测价格的对比图。

代码骨架大致长这样:

import pandas as pd import numpy as np def load_data(symbol, start, end): # 常见做法是接 yfinance 或 tushare,返回日线 OHLCV df = fetch_kline(symbol, start, end) return df[["open", "high", "low", "close", "volume"]] def clean_data(df): df = df.replace([np.inf, -np.inf], np.nan) df = df.sort_index() df["close"] = df["close"].ffill().bfill() return df.dropna()

逻辑说明:clean_data里先处理无穷值,再按时间排序,然后用前后值填充缺失的收盘价。注意顺序不能反,先排序再填充,否则时间序列是乱的,填充出来的也是错的。dropna把前面处理不掉的行直接丢掉,对价格数据来说丢几行不影响大局。

参数说明:ffill().bfill()是前向填充再后向填充,适合处理停牌导致的缺口;但如果连续缺失超过 5 个交易日,我建议直接丢弃这段,硬填出来的数据会让模型学到假 pattern。

2.3 数据切分是流水线的分水岭:别把测试集当训练集用

这份资源完整流程里,切分逻辑写在训练之前。很多新手在这里犯的第一个错,是直接用train_test_split默认参数乱切,默认的 shuffle 会把时间顺序打乱,导致模型用未来的数据去预测过去,训练分数虚高到怀疑人生。

def split_data(df, test_ratio=0.2): cut = int(len(df) * (1 - test_ratio)) train = df.iloc[:cut].copy() test = df.iloc[cut:].copy() return train, test

逻辑说明:按时间顺序硬切,前 80% 当训练集,后 20% 当测试集,不 shuffle。这是时间序列和普通分类问题的本质区别,shuffle 在这个场景里等于作弊。

参数说明:test_ratio=0.2是经验值,样本量少于 500 根 K 线时我建议改成 0.3,否则测试集只有 100 根,预测误差的统计意义很弱。切完之后可以打一眼train.index[-1]和test.index[0],确认两个集合在时间上是接壤的。

3. 特征工程决定模型上限:滞后值、技术指标与归一化顺序

3.1 基础特征不是越多越好:先用价格派生特征垫底

特征工程是这套工程里最值得动手改的地方。原始数据里只有开高低收和成交量五列,直接丢给模型,模型学到的只是「昨天的价格大概等于今天」,预测曲线会整体滞后一天,图形上看着拟合很好,实际毫无意义。

所以第一步是先造滞后特征和均线特征,让模型能看到趋势而不只是复制粘贴:

def add_basic_features(df): df["return_1"] = df["close"].pct_change() df["ma5"] = df["close"].rolling(5).mean() df["ma20"] = df["close"].rolling(20).mean() df["vol_ma20"] = df["volume"].rolling(20).mean() df["vol_ratio"] = df["volume"] / df["vol_ma20"] return df.dropna()

逻辑说明:return_1是当日收益率,ma5和ma20是短期和中期均线,vol_ratio是当日成交量相对 20 日均量的倍数,用来刻画放量缩量。这些特征是模型能真正用起来的「知识」,而不是原始价格的简单重复。

参数说明:pct_change()默认周期是 1,想算 5 日涨跌幅就传参pct_change(5)。窗口大小 5 和 20 对应一周和一个月,改成 10 和 60 也行,但窗口太小噪音大,太大反应迟钝,这是均线本身的物理限制。

3.2 MACD 和 RSI 的计算代码:注意 ewm 的默认行为

这份资源在摘要里提到了 MACD 和 RSI,实际代码里一般会有对应的两个函数。我拆包时最在意的是指数移动平均的写法,因为ewm函数有个默认参数坑:adjust=True会让计算方式偏向全序列平均,导致指标前半段数值偏高。正确写法是显式关掉它:

def add_macd(df, fast=12, slow=26, signal=9): ema_fast = df["close"].ewm(span=fast, adjust=False).mean() ema_slow = df["close"].ewm(span=slow, adjust=False).mean() df["macd"] = ema_fast - ema_slow df["macd_signal"] = df["macd"].ewm(span=signal, adjust=False).mean() df["macd_hist"] = df["macd"] - df["macd_signal"] return df def add_rsi(df, period=14): delta = df["close"].diff() gain = delta.clip(lower=0).rolling(period).mean() loss = (-delta.clip(upper=0)).rolling(period).mean() rs = gain / loss df["rsi"] = 100 - 100 / (1 + rs) return df

逻辑说明:adjust=False表示用递推方式计算 EMA,跟交易软件里的算法一致。RSI 的gain和loss分别统计上涨和下跌的平均幅度,算出来的值在 0 到 100 之间。这两组指标加上基础特征,特征矩阵就从 5 列膨胀到 15 列左右。

参数说明:MACD 的三组参数 12、26、9 是行业标准,不要随便改;RSI 的period=14也是默认值。如果你用分钟线数据,可以把 RSI 周期缩到 7,日线就保持 14。

3.3 归一化顺序是黑匣子:scaler 只能拿训练集来 fit

特征构造完,下一步是归一化。这里有一个整个项目里最容易被忽略的泄漏点:如果你把全部数据喂给MinMaxScaler再切分,测试集的信息就已经混进训练过程了,评估指标会虚高。正常做法是先切分,再在训练集上 fit,测试集只 transform:

from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

逻辑说明:fit_transform只调用一次,用在训练集上;测试集只用transform,意思是「用训练集学到的最大最小值来缩放测试集」。这样测试集的分布信息不会提前泄漏给模型。

参数说明:MinMaxScaler默认把数据压到 0 到 1。如果你后面要接 LSTM,这个范围刚好匹配 sigmoid 和 tanh 的敏感区间;如果只跑随机森林,归一化其实可有可无,但为了代码统一,我一般还是会做。

3.4 序列化窗口:给 LSTM 准备三维输入

如果项目里包含 LSTM 分支,特征矩阵还得再变形。LSTM 不吃二维表格,它要的是[样本数, 时间步, 特征数]这样的三维张量。常规做法是设定一个 lookback 窗口,比如用过去 10 天的数据预测第 11 天:

def make_windows(scaled, lookback=10, target_idx=0): X, y = [], [] for i in range(lookback, len(scaled)): X.append(scaled[i - lookback:i]) y.append(scaled[i, target_idx]) return np.array(X), np.array(y)

逻辑说明:scaled[i - lookback:i]取出连续 10 行作为输入,scaled[i, target_idx]取第 i 天的目标值。target_idx=0表示目标列是缩放后矩阵的第 0 列,也就是收盘价。这个循环看着简单,但数据量大时很慢,我一般会在前面加一步把 scaled 转成 float32。

参数说明:lookback=10表示用 10 个交易日预测下一个交易日,对应两周的日线数据。改大一点模型能看到更长的趋势,但训练样本会减少,样本量本来就小的 A 股个股要谨慎。

4. 模型选型与训练验证:从线性回归垫底到 LSTM 调参

4.1 基线模型先垫底:线性回归和随机森林的取舍

这份资源的摘要里列出了线性回归、决策树、随机森林、SVM、LSTM 一排模型。我拆完代码后的建议是:先跑随机森林当基线,别一上来就碰 LSTM。原因很简单,特征数量就十几列,样本量最多几千行,这个体量下树模型又快又稳,LSTM 的训练时间和收益完全不成正比。

from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_squared_error, r2_score model = RandomForestRegressor( n_estimators=200, max_depth=8, min_samples_leaf=5, random_state=42 ) model.fit(X_train_scaled, y_train) y_pred = model.predict(X_test_scaled) print("RMSE:", mean_squared_error(y_test, y_pred, squared=False)) print("R2:", r2_score(y_test, y_pred))

逻辑说明:max_depth=8和min_samples_leaf=5是刻意限制模型复杂度,防止它在噪音数据上过拟合。random_state=42固定随机种子,保证每次跑出来的结果一致,这在你调参时是刚需。

参数说明:n_estimators=200已经够用,再大收益很小但耗时线性增长。RMSE 的单位和价格一样,600519 的 RMSE 如果是 3.5,意思就是平均预测误差 3.5 元,这个数字比 R² 直观得多。

4.2 交叉验证的时间顺序陷阱:TimeSeriesSplit 才是对的

K 折交叉验证在普通分类里是标准操作,但在时间序列上是错的。默认KFold会随机打乱样本,训练集里混入测试集之后的数据,预测未来却偷看未来,这是典型的逻辑漏洞。这个场景里应该用TimeSeriesSplit:

from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5) for train_idx, val_idx in tscv.split(X_train_scaled): fold_model = RandomForestRegressor( n_estimators=100, max_depth=8, random_state=42 ) fold_model.fit(X_train_scaled[train_idx], y_train[train_idx]) val_pred = fold_model.predict(X_train_scaled[val_idx]) # 记录每一折的 RMSE,最后看均值

逻辑说明:TimeSeriesSplit保证每一折的训练集都严格早于验证集,模拟的是「用过去预测未来」的真实场景。5 折的意思是切 5 次,每次训练集变长,验证集后移。这个流程跑出来的误差才有参考价值。

参数说明:n_splits=5意味着训练集会切成 5 段,适合几百到几千行数据。如果你的数据量很少,比如只有 300 行,我建议降到 3 折,否则训练集被切得太碎,模型学不到完整趋势。

4.3 调参不要全网格:先锁定两三个关键参数

资源里提到的网格搜索,很多初学者会一上来就把所有参数的候选值铺满,结果 GridSearchCV 跑了一夜还没完。我一般会先固定n_estimators,只调max_depth和min_samples_leaf这两个影响过拟合的核心参数:

from sklearn.model_selection import GridSearchCV param_grid = { "max_depth": [6, 8, 10], "min_samples_leaf": [2, 5, 10], } grid = GridSearchCV( RandomForestRegressor(n_estimators=200, random_state=42), param_grid, scoring="neg_mean_squared_error", cv=tscv, n_jobs=-1 ) grid.fit(X_train_scaled, y_train) print(grid.best_params_, grid.best_score_)

逻辑说明:scoring="neg_mean_squared_error"因为 sklearn 里所有误差类指标都要取负数,越大越好,所以负的 MSE 越接近 0 越好。cv=tscv直接复用前面的时间序列交叉验证,保证调参过程和评估过程逻辑一致。

参数说明:n_jobs=-1表示用满所有 CPU 核心,三个候选值 × 三档深度 × 5 折,一共 45 次训练,并行以后大概几十秒。如果换成 LSTM,一套网格搜索可能要跑几个小时,这也是我劝你先树模型调参的原因。

4.4 LSTM 的学习率与 epoch:跑之前先设好随机种子

如果你执意要跑 LSTM 分支,代码里通常长这样:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense tf.random.set_seed(42) np.random.seed(42) model = Sequential([ LSTM(64, input_shape=(lookback, X_train.shape[2]), return_sequences=False), Dense(1) ]) model.compile(optimizer="adam", loss="mse", metrics=["mae"]) history = model.fit(X_train, y_train, epochs=50, batch_size=32, validation_data=(X_val, y_val), verbose=0)

逻辑说明:tf.random.set_seed(42)是必须的,否则 LSTM 的初始化权重每次都不一样,同一个项目两次跑出完全不同结果。return_sequences=False表示这一层只输出最后一个时间步的状态,再接一个 Dense 输出预测价格。

参数说明:学习率走 Adam 默认的0.001,不要自己乱调。epochs=50对日线数据够用,我见过有人在 200 个 epoch 上死磕,跑到后面验证集损失不降反升,那就是过拟合了,把 epochs 收回来更实际。

5. 避坑手册:股票预测里最容易翻车的五个点

5.1 未来函数泄漏:测试集 R² 高得离谱先别高兴

现象:测试集 R² 做到 0.98,RMSE 小到不敢相信,拿真实数据一跑,预测曲线几乎贴在实际曲线上,但滞后一天。 原因:最常见的是归一化时 scaler 用了全量数据 fit,或者特征里混进了未来信息,比如用当天的收盘价去预测当天的收盘价,再比如填充缺失值用了全序列均值。 解决:强制检查三步——scaler 只 fit 训练集;切分发生在特征构造之前;把所有涉及全序列统计的操作全部挪到切分之后。检查完再回看指标,通常 R² 会掉到 0.9 以下,这才是真实水平。

5.2 数据源不一致:同一只股票在两个平台拿到两个价

现象:用平台 A 的数据训练,换到平台 B 的数据测试,误差突然翻倍,画出来的价格曲线在某个日期有一个明显断崖。 原因:不同数据源的复权方式不一致,前复权、后复权、不复权三种口径下历史价格差异很大;另外除权除息导致的跳空如果不处理,模型会把这次的跳空当成一个可以重复的规律。 解决:统一使用前复权数据,并且确认训练集和测试集来自同一数据源口径。下载数据后先用df["close"].pct_change()看一眼有没有超过 20% 的异常跳变,有的话查一下当天是不是除权日。

5.3 LSTM 两次训练结果漂移:不是你的问题,是种子没固定

现象:同一份代码、同一份数据,上午跑 RMSE 是 2.8,下午跑变成 3.6,模型权重初始化的随机性把结果带飞了。 原因:深度学习框架默认不固定随机种子,GPU 上的并行计算还会引入额外随机性,导致结果不可复现。 解决:在代码最顶部固定三处种子——np.random.seed(42)、tf.random.set_seed(42)、random.seed(42),必要时还要设置os.environ["PYTHONHASHSEED"]="0"。固定之后依然有微小浮动,但不会出现数量级差异。

5.4 一步预测准,滚动预测全崩:误差是累积的

现象:用测试集预测下一个交易日,误差很小;改成预测未来 20 天,到第 5 天开始曲线就跑飞了。 原因:多步预测通常是把上一步的预测值当输入,误差一步步累积放大;而且市场不是平稳过程,20 天前的规律大概率已经失效。 解决:不要追求长期预测,把目标定死为「预测下一个交易日的收盘价」,到了第二天用真实数据重新训练再预测下一步,这就是 walk-forward 的思路。预测周期超过 5 天,任何模型都很难有统计优势。

5.5 幸存者偏差:回测赚钱不等于实盘赚钱

现象:在某个股票池上回测年化收益 30%,实盘跟进后发现走势完全对不上。 原因:筛选股票池时只保留了现在还活着的股票,退市的、暴跌的都被排除掉了,历史回测自然好看。 解决:回测时用全量股票池,包括已经退市的标的,或者至少明确记录股票池筛选条件。另外把交易成本、滑点计入回测,股票预测结果里如果收益只比成本高一点点,那这个策略基本没有实操价值。

6. 把预测结果用起来:walk-forward 验证与误差画图习惯

6.1 walk-forward 重训逻辑:每 5 天滚一次

模型跑通之后,最有价值的动作是把它从「一次性训练」改成「滚动重训」。常见做法是拿历史数据训练,预测未来 5 个交易日,等真实数据出来后再把新数据并入训练集重训。这段逻辑我习惯写成这样:

step = 5 preds = [] for start in range(len(train), len(all_data) - step, step): model.fit(all_data.iloc[:start], target.iloc[:start]) preds.extend(model.predict(all_data.iloc[start:start + step]))

逻辑说明:循环里每次用截止到start的历史数据训练,预测接下来 5 天,然后滚动到下一段。这个流程模拟了实盘操作节奏,也顺便验证模型在真实环境下的表现。跑完之后把preds和真实值对齐,计算误差分布。

参数说明:step=5对应一个交易周,如果你的数据是日线,这个节奏合理;换成step=1就是每天重训,效果更好但耗时成倍增加,先跑 5 天版本观察误差水平再决定要不要加密。

6.2 把误差画出来:比看 RMSE 数字更直观

RMSE 只是个汇总数字,真正能看出问题的是误差分布。我习惯把预测值和真实值画在同一张图上,再把残差单独画一张,看有没有系统性偏差。

import matplotlib.pyplot as plt residual = y_test - y_pred fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True) ax[0].plot(y_test.index, y_test, label="actual", alpha=0.8) ax[0].plot(y_test.index, y_pred, label="pred", alpha=0.8) ax[0].legend() ax[1].plot(y_test.index, residual, label="residual") ax[1].axhline(0, color="red", linewidth=0.8) plt.tight_layout() plt.savefig("pred_vs_actual.png", dpi=150)

逻辑说明:上图画真实与预测对比,如果预测线整体右移或滞后,说明模型只是把前一天的价格搬过来;下面画残差,如果残差有明显的趋势性而不是围绕 0 波动,说明还有未捕捉的因素。这两张图比任何指标都能暴露问题。

参数说明:dpi=150保证保存的图片在报告里足够清晰。保存路径建议直接写当前目录,别用绝对路径,换机器跑的时候少一个报错源。

另外,训练好的模型要用 joblib 存下来,不然每次预测都要重新训练:

import joblib joblib.dump(model, "stock_model.pkl")

从那以后,我每次拿到这类股票预测 zip,都强制自己先走一遍 walk-forward 和误差分布图,再谈优化。数据不说谎,只是经常被预处理和评估方式糊弄。希望帮到你。

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

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

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

立即咨询