机器学习股价预测实战:决策树与随机森林回测全解析
简介一份面向数据分析师与量化爱好者的机器学习实战资源聚焦决策树、随机森林与时间序列预测在股价场景中的应用系统展示从数据清洗、特征工程到建模评估的完整流程。压缩包共7个文件包括两个Jupyter Notebook及对应的HTML预览、股票价格Excel表、CSV数据集和项目说明文档整体约2.02MB结构紧凑便于快速上手。已有3481人学习使用适合作为金融数据挖掘入门或课程设计的参考资料。内容既有分类场景下的决策树与随机森林实现也有基于历史行情的时间序列预测代码并配有结果可视化与指标解读可帮助理解特征重要性、超参数调整和误差评估等关键环节。通过动手复现读者能快速迁移这些方法到其他金融或业务预测任务中。1. 机器学习股价预测项目拆解这个 zip 装的是「把历史行情喂给树模型」当你在网上下载到一个叫“机器学习实战项目——决策树随机森林时间序列预测股价.zip”的压缩包时先别急着解压。看名字就知道这类项目是把机器学习入门阶段最常碰的两类模型——决策树和随机森林硬生生拍在股价预测这个场景上。它不教你用 ARIMA 或 LSTM 做传统时间序列预测而是把“明天的涨跌”改写成“机器学习的分类标签”用一套完整的特征工程、模型训练、回测评估流程跑出一个能看效果的实战样例。这个方向对正在学机器学习入门的人特别合适你已经会调 sklearn 的分类器但还没把 pandas 和 sklearn 串成一个项目你想要一个比鸢尾花分类更有现实感的练习目标又不想一上来就碰深度学习。照这篇文章的流程走你会得到一版可跑的涨跌分类器、一条回测净值曲线以及一连串模型翻车时的后悔药。当然股价预测本身一半是数学一半是玄学项目真正值钱的地方不在预测准确率而在你怎么处理时间序列数据、怎么做不骗人的回测。2. 把股价日线改造成「能喂给 sklearn」的数据集特征窗口、标签设计、最小实现2.1 为什么树模型不适合直接吃原始股价序列很多第一次做时间序列预测的人会犯同一个错把 date 和 close 两列直接扔进 DecisionTreeClassifier让模型学“价格 100 的时候明天涨价格 150 的时候明天跌”。结果测试集上准确率接近五五开偶尔还低于五成。这不是模型笨而是喂法错了。决策树和随机森林本质上是“按特征值做区间切分”的模型它不关心样本之间的先后关系。你把 7 月 1 日和 7 月 2 日的行情拆成两行独立样本模型就默认这两行之间没有因果联系。可股价恰恰是强自相关的今天的收盘价和明天、后天的走势都绑在一起。所以第一步是把时间序列“摊平”成监督学习能吃的横截面表格——每一行代表“今天”这一行的特征是从过去 N 天行情里算出来的统计量这一行的标签是“明天涨不涨”。这也是整个机器学习项目里最容易被低估的一步。你后面模型调得再好特征里的未来函数不除干净回测照样是自欺欺人。下面直接给一段能跑通的最简实现。2.2 最小可跑通的数据构造代码没有真实行情也不影响理解流程我用一段模拟数据把流程串起来你有 CSV 文件的话把读数据那一行替换成pd.read_csv(自己的文件.csv)即可字段保持 date、close 两列就够起步。import pandas as pd import numpy as np # 1) 没有真实行情时生成 800 天带趋势和噪声的模拟走势 np.random.seed(42) n 800 returns np.random.normal(loc0.0003, scale0.02, sizen) # 每日收益率 price 100 * np.exp(np.cumsum(returns)) df pd.DataFrame({date: pd.date_range(2021-01-01, periodsn), close: price}) # 2) 特征工程把时间序列变成横向特征 df[return_1] df[close].pct_change(1) # 当日较昨日的收益率 df[return_2] df[close].pct_change(2) # 两日收益率 df[return_5] df[close].pct_change(5) # 五日收益率 df[ma5] df[close].rolling(5).mean() # 5 日均线 df[ma20] df[close].rolling(20).mean() # 20 日均线 df[high_low_diff] (df[close].rolling(5).max() - df[close].rolling(5).min()) / df[close] # 5日振幅 # 3) 标签明天收盘价高于今天记为 1否则 0 df[y] (df[close].shift(-1) df[close]).astype(int) # 4) 删除有 NaN 的行前 20 天算不出 ma20 df df.dropna().reset_index(dropTrue) features [return_1, return_2, return_5, ma5, ma20, high_low_diff] X df[features] y df[y] print(样本数:, len(df), 正例占比: %.3f % y.mean())代码逻辑按三步走第一步生成或读取行情第二步用pct_change和rolling构造横截面特征第三步用shift(-1)把明天的涨跌搬回今天做标签。参数说明里最值得留意的是shift(-1)的方向。df[close].shift(-1)是把“下一天的收盘价”放到今天这一行所以y表示今天看明天的涨跌方向。一旦你写成shift(1)标签就滞后了一天模型学到的是昨天的规律去预测昨天回测必然亏钱。return_5、ma20这类带窗口的参数也不是拍脑袋5 日大致对应一周交易节奏20 日对应一个月这两个窗口在技术分析里最常用。窗口再大在 800 天样本上会吃掉太多开头数据得不偿失。2.3 标签的两种设计回归连续值与分类涨跌怎么选同样是预测明天的行情你可以让模型输出“明天收盘价是多少”也可以让它输出“明天是涨还是跌”。这两条路对应机器学习的回归和分类两类问题评估方式完全不同。设计方式目标值常见评估指标落地场景回归明天的收盘价或收益率MAE、MAPE、RMSE目标价估算、止损位参考分类明天的涨跌1/0准确率、精确率、召回率、F1多空信号、仓位控制我一般建议初学阶段优先做分类。原因很现实股价里的连续价格噪声极大让回归模型预测具体价格它学到的基本是“预测最近几天的均值”MAPE 看着不高实际没有交易价值。而分类只要判断一个方向信号更稳而且后面想升级时分类器自带的predict_proba可以直接拿来当仓位权重用这是回归做不到的。还有一个理由和机器学习入门体验有关树模型分类器predict_proba输出的概率天然适合后面做 0.55 以上才开仓这类规则。你要是真的关心价格预测本身我在最后一章会单独讲什么时候该回头用回归。当前我们先沿着分类这条线往后走。3. 决策树与随机森林建模先跑基线再按参数边界收住过拟合3.1 决策树用 max_depth 和 min_samples_leaf 把过拟合摁住决策树算法是机器学习算法里最直观的一类它不停地把样本按特征值切分成两块直到子节点的样本足够纯。决策树如何逼近真实曲线靠的就是分段常数——深度越深分段越细曲线拟合得越弯。但股价数据信噪比很低你用深度 10 的树去拟合训练集可以把每个样本的标签都背下来测试集上却一塌糊涂。所以入门第一个要建立的手感就是树模型必须限制生长。from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score, classification_report # 按时间顺序切分训练集用前 70%测试集用后 30% split_idx int(len(df) * 0.7) X_train, X_test X[:split_idx], X[split_idx:] y_train, y_test y[:split_idx], y[split_idx:] # 单棵决策树基线 dt DecisionTreeClassifier(max_depth4, min_samples_leaf5, random_state42) dt.fit(X_train, y_train) y_pred_dt dt.predict(X_test) print(决策树 train acc: %.3f % accuracy_score(y_train, dt.predict(X_train))) print(决策树 test acc: %.3f % accuracy_score(y_test, y_pred_dt)) print(classification_report(y_test, y_pred_dt))这段代码刻意先不调参只设两个保护参数。max_depth4限制树最多分裂 4 层每层分裂最多能把样本空间切出 16 个区域股价数据里已经够用了。min_samples_leaf5要求每个叶子节点至少 5 个样本避免模型为了一个异常样本单独长出一条分支。这两个参数本质上都在回答同一个问题你允许多大程度地“记住”训练集。跑完你会看到 train acc 大概在 0.60 左右test acc 也差不多别嫌弃它低。在完全随机的数据上准确率正常就是 0.50 附近你只要稳定高过 0.55 一点就说明特征里确实有微弱的信息量。继续往下做随机森林就是看集成方法能不能把这个微弱信号吃得更干净。3.2 随机森林为什么高噪声数据上集成比单棵树稳随机森林算法原理说穿了不复杂训练几百棵相互独立的决策树每棵树只用一部分样本和一部分特征最后投票决定结果。单棵树会过度拟合局部的噪声几百棵树同时在一个噪声点上跑偏的概率很低投票函数把这种随机波动平均掉了。在机器学习实战项目案例里随机森林也是处理表格型数据时第一个该试的模型。from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier(n_estimators300, max_depth5, min_samples_leaf5, max_featuressqrt, n_jobs-1, random_state42) rf.fit(X_train, y_train) y_pred_rf rf.predict(X_test) print(随机森林 train acc: %.3f % accuracy_score(y_train, rf.predict(X_train))) print(随机森林 test acc: %.3f % accuracy_score(y_test, y_pred_rf)) print(classification_report(y_test, y_pred_rf))对比决策树随机森林的训练集准确率通常更低测试集准确率反而更高这是正常的说明方差被压下来了。参数上有几个点值得展开n_estimators设 300 是性能和效果的平衡点。你的样本只有几百行时50 棵树和 300 棵树差别不大样本上万后树太少会让投票结果的方差变大。我一般先设 300效果不够再说 500超过 500 边际收益很低。max_depth比单棵树多放了一格因为单棵树的错误会被集成稀释稍微深一点能保留更多信息。min_samples_leaf保持 5逻辑和决策树一致。max_featuressqrt让每棵树分裂时只看部分特征这是随机森林比“全特征 bagging”更稳的关键。但要注意本项目只有 6 个特征开方后每次只看 2 个可能会丢掉强特征跑完可以用所有特征对比一次选测试结果更好的。训练速度方面可以做一次对比决策树秒出结果随机森林 300 棵树也就几秒钟n_jobs-1让所有 CPU 核都被用上。如果你发现训练特别慢问题多半出在特征数太多或n_estimators被拉到 1000而不是模型本身。3.3 决策树和随机森林的区别一句话给新手讲清网上搜“随机森林和决策树区别”会得到一大篇理论。落到这个项目里你只需要记住一点决策树是单打独斗随机森林是拉了几百个小弟投票。代价是你丢了“这一棵树到底为什么这么切”的可解释性换来了更稳的预测。所以在实战里的分工是用随机森林做最终模型因为它抗噪声、不容易让测试集准确率上蹿下跳用单棵决策树做可解释性分析比如tree.plot_tree画出一棵深度 3 的树看它第一个分裂特征是什么、怎么切分的。这比对着feature_importances_猜要直观得多。随机森林调参你只需要盯紧测试集准确率的变化决策树调参还要额外观察它有没有长出明显不合理的分支。两者结合才是机器学习项目交付时拿得出手的完整姿势。4. 时间序列切分避坑五个真实翻车现场和后悔药4.1 随机切分训练集导致偷看未来现象用 sklearn 的train_test_split默认随机切分测试集准确率冲到 0.72你兴冲冲地去跑历史回测结果策略净值一路阴跌。原因股价有强自相关性随机抽样会把 7 月 5 日分到训练集、7 月 6 日分到测试集。模型等于看到昨天的行情预测今天测试时当然“准”。这种泄露在机器学习入门项目里最隐蔽因为代码不报错、指标还特别好看。解决任何时序数据都必须按时间顺序切分。df df.sort_values(date).reset_index(dropTrue) split_idx int(len(df) * 0.7) train df.iloc[:split_idx] test df.iloc[split_idx:] print(训练集结束索引:, train.index[-1], 测试集开始索引:, test.index[0])在每次切分前加一行sort_values(date)再打印边界索引确认没有交错。这条习惯每个时间序列预测模型项目里都应该刻进肌肉记忆。4.2 归一化在全体数据上 fit测试集信息提前泄露现象做特征缩放后训练集和测试集准确率都比不缩放时高但其实测试集已经被“剧透”了。更多情况是缩放后树模型测试准确率没变你忽略了这步侥幸躲过一劫。原因scaler.fit(X_all)会把全体数据的均值和标准差算进变换。测试集的特征分布早就被训练过程看见了一旦后续模型用到距离计算泄露就会扩散。解决树模型不需要归一化但如果你坚持要缩放或者还打算对比逻辑回归、SVM就必须只在训练集上 fit。from sklearn.preprocessing import StandardScaler scaler StandardScaler().fit(X_train) # 只在训练集上学习参数 X_train_s scaler.transform(X_train) X_test_s scaler.transform(X_test)注意看fit只出现在X_train上X_test只用transform。这条和上一条本质上同源任何跨训练集和测试集的统计计算都要警惕信息泄露。4.3 shift 错位标签把当天的收盘价当成了未来现象回测曲线一开始很好看仔细检查发现每次预测的“未来一天”其实是同一天等于开盘就知道收盘价稳赚不赔。原因y (df[close].shift(-1) df[close])写成了shift(0)或shift(1)还有一个隐蔽变体构造特征时不小心在return_1里用了包含当天收盘价的公式而标签也用当天收盘价比对造成特征和标签重叠。解决训练前打印最后五行用肉眼确认标签与价格的对齐关系。print(df[[close, ma20, y]].tail())如果最后一行close后面跟着yNaN说明标签用的是shift(-1)对齐正确如果最后一行y已经有 0 或 1就说明标签里混进了当天信息。这是对抗未来函数最实用的土办法。4.4 涨跌分类里的“永远看多”陷阱现象测试集准确率 0.65你觉得模型厉害结果拿收益率一看什么都不买还能有接近 0.60 的“准确率”。原因样本不平衡。行情如果处在上涨区间正例占比会超过 60%模型只要全部预测“涨”就能刷出高准确率但它其实啥也没学会。解决用混淆矩阵看精确率和召回率还要和一个“永远看多”的基线做对比。from sklearn.metrics import confusion_matrix print(confusion_matrix(y_test, y_pred_rf)) # 输出格式[[TN, FP], [FN, TP]]看右下角 TP 和右上角 FP 的比例确认模型在“预测涨”这件事上不是瞎蒙。更直接的办法是在回测里对比买入持有策略下一章会写。准确率这个指标在股价预测这类类别不平衡的数据上真的不够用。4.5 反复调同一个测试集导致测试集过拟合现象你在测试集上反复调参调一次涨一点最后测试准确率 0.68你把它当成真实水平写进报告结果换一段行情直接掉回 0.53。原因测试集被用了太多次模型的参数选择已经隐含着测试集的信息。这比偷看未来更隐蔽——代码没问题切分没问题但实验流程本身有问题。解决把数据切成三段训练集、验证集、测试集。验证集用来调参测试集只在全部调参结束后验证一次。没有太多数据时可以用第 5 章的滚动验证代替一次性验证集那更接近真实交易环境。记住一个原则测试集是你最后一次验证模型时才该碰的“神圣”数据。5. 滚动回测与绩效验证模型是不是真能拿出去说5.1 一次性切分只是第一步前面的 70/30 切分适合看模型泛化能力但它和真实交易差得远。真实场景里你会隔一段时间重新训练一次模型用最近的数据去预测未来一段时间。更麻烦的是市场规律会变三年前的规律今天可能完全失效。所以做完模型训练下一步必须是滚动回测。滚动回测的本质是反复重复“训练—预测—推进时间”这个循环。它能暴露一次性切分看不到的问题你的模型是只能在某段行情里灵还是真的学到了可复用的规律。常见两种窗口expanding window每次都从最早样本开始训练和 rolling window只取最近的一批样本。股价数据里我倾向 rolling window因为旧数据往往是噪声而非信息取最近 300 个交易日能减轻行情风格切换的干扰。5.2 滚动窗口回测最小实现这里直接给一个能跑的回测框架沿用前面的df和features。lookback 300 # 每次只用最近 300 个交易日训练 step 20 # 每 20 天重新训练一次 start 400 # 留出足够历史数据给第一次训练 net_value 1.0 net_values [] trades 0 correct 0 for i in range(start, len(df) - 1, step): hist df.iloc[i - lookback:i] model RandomForestClassifier(n_estimators200, max_depth4, min_samples_leaf5, max_featuressqrt, n_jobs-1, random_state42) model.fit(hist[features], hist[y]) for j in range(i, min(i step, len(df) - 1)): prob_up model.predict_proba(df.iloc[[j]][features])[0, 1] position 1 if prob_up 0.55 else 0 # 只有概率超过 0.55 才做多 daily_ret df.iloc[j 1][close] / df.iloc[j][close] - 1 net_value * (1 position * daily_ret) trades 1 if (prob_up 0.55) bool(df.iloc[j 1][y]): correct 1 net_values.append(net_value) print(交易天数:, trades, 胜率: %.3f % (correct / trades)) print(策略累计净值:, round(net_value, 3))这段有几处容易踩坑的参数要说明lookback300是取最近 300 天数据重新训练。设太长模型学的是很多年前的旧规律设太短样本不够学出稳定模式。我一般让 lookback 至少覆盖 200 到 300 个交易日对应差不多一年。step20表示每 20 个交易日重训一次太频繁会浪费时间且模型变化太快20 天约等于一个月是收益和成本的平衡点。prob_up 0.55是置信度过滤。模型说“明天有 51% 概率涨”时你进场长期会被手续费磨死过滤到 55% 以上再动作进场次数变少但单次胜率更高。阈值怎么定可以直接跑 0.5、0.55、0.6 三档对比。这段代码还隐含一个交易成本问题每次position1都假设你满仓进出没有算手续费和滑点。股票交易按万分级别的费率计算回测周期短时可以忽略但如果你的策略一天进出好几次手续费会吃掉大部分收益建议留一个cost0.0005的变量乘进去。5.3 回测指标怎么算累计净值、胜率、最大回撤、超额收益只看累计净值容易兴奋过头还要补三个指标才敢下结论。指标计算公式说明累计净值逐日复利相乘大于 1 代表赚钱低于买入持有净值就说明模型白做胜率预测正确次数 / 总交易天数配合盈亏比看胜率低但单次赚得多也可以最大回撤净值从高点回落的最大幅度超过 30% 的策略再赚钱也拿不住最大回撤计算很简单核心是记录历史最高净值peak net_values[0] max_drawdown 0 for v in net_values: if v peak: peak v drawdown (peak - v) / peak max_drawdown max(max_drawdown, drawdown) print(最大回撤: %.3f % max_drawdown)最后拿策略净值曲线和“买入持有”对比。如果买入持有收益是 30%你的策略收益是 40%那超额收益 10% 才说明模型有边际价值如果策略 40%买入持有 45%你的模型不是在赚钱是在给手续费打工。6. 进阶玩法特征重要性、概率仓位和 alpha 对比这三个小改动能让项目从“课程作业”变成“可以写进简历的实战案例”修改量都不大但评估维度完全不同。第一把特征重要性画出来用数据说话哪个技术指标最有用。随机森林训练完之后一行代码就能拿到每个特征的平均贡献度import matplotlib.pyplot as plt imp pd.Series(rf.feature_importances_, indexfeatures).sort_values() imp.plot.barh() plt.title(Feature Importance in Random Forest) plt.show()这一步很值得做。你可能发现return_1的重要性远高于ma20也可能发现high_low_diff基本没有贡献。然后把低贡献特征从features里删掉再训练测试集准确率不降反升的情况很常见。这比盲目堆特征更像一个专业机器学习项目的收尾。第二把predict_proba输出的概率用起来做概率仓位控制。前面回测里 0.55 以上满仓做多但概率 0.55 和 0.85 的置信度完全不同。简单一点的做法是分段概率 0.65 以上用 0.8 仓位0.5 到 0.65 用 0.3 仓位0.5 以下空仓。这样既保留分类模型的方向判断又避免在高不确定区间的频繁亏损。概率仓位是分类模型比回归模型在实盘里更实用的核心原因。第三alpha 对比要具体算出来。不要只写“回测收益率 25%”要写“同期买入持有收益 18%策略超额收益 7%最大回撤 15%”。alpha 为正说明模型捕捉到了超越市场风格的信息alpha 为负说明你把自己绕晕了。把这三件套放进项目说明里这个机器学习实战项目的完成度会完全不一样。我做这类项目最大的教训是把太多时间花在调参上花太少时间在验证流程上。后来每次拿到新数据我都先回测再训练先定基线再调参。股价预测的本质决定了模型很难持续稳定赚钱但这个流程本身——特征构造、时序切分、滚动回测、风险指标——放在任何数据集上复用都是值钱的。希望帮到你。本文还有配套的精品资源点击获取