ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

机器学习+量化投资:从特征工程到多算法集成的完整实践

机器学习+量化投资:从特征工程到多算法集成的完整实践 简介本资源为Python机器学习驱动的基本面量化投资研究项目包含完整源码、配套数据集与项目说明文档面向具备一定Python基础、希望将机器学习算法应用于金融量化场景的学习者和研究者。包内共219个文件主要由167个csv数据文件、36个abak中间结果文件、11个py源码脚本及pdf、md说明文档组成涵盖数据预处理、模型训练、策略结果分析等环节压缩包整体约145.79MB可直接运行便于复现和扩展实验。目前已有49人学习下载。项目集成线性回归、随机森林、XGBoost、LSTM、GBDT、Ridge、Lasso、ElasticNet等常用算法通过实际案例演示模型选择、调参对比和结果评估流程并附有算法对比结果文件便于直观理解不同模型在量化投资中的适用性。对入门者可作为建立量化投资基本流程的起点对有基础用户则可用于深入理解机器学习算法在基本面分析、组合评估及策略构建中的实际应用与优化思路。1. 机器学习量化投资研究项目先确认你要解决的是预测还是流程很多人在接触Python之后会走到同一个岔路口手上有行情数据想用机器学习找规律却不知道从哪一步开始。这个标题指向的项目“机器学习驱动的量化投资研究集成多种算法”不是一个给你“稳赚信号”的代码包而是一条标准研究链路把数据集整理成监督学习格式、用多种算法做预测、用回测检验结果。适合有Python和pandas基础、想系统做量化研究的从业者也适合想把自己手工指标升级成“可验证策略”的交易者。它的真正价值在于让研究假设变成可复现、可对比、可反驳的实验流程而不是一个只存在于截图里的漂亮曲线。2. 数据集与特征工程把行情表改造成监督学习训练集的落地步骤如果你打开这份研究项目源码最先看到的往往不是模型而是一堆CSV或Parquet文件。这个顺序是对的量化研究里数据决定上限模型只是逼近这个上限。所以我一般会把“数据集与特征工程”放在第一章来讲先把数据管道的几个坑填平后面跑模型才不闹鬼。2.1 数据集字段标准化先解决对齐再看模型市面上能拿到的行情数据无论是免费接口还是商业数据源字段名都五花八门但核心始终是这几列交易时间、开盘价、最高价、最低价、收盘价、成交量、成交额。这份项目源码里的数据集大概率已经做了字段重命名但你自己从别处下载新数据时一定会遇到列名不一致的问题。常见做法是把字段统一成下面这套约定再进特征工程约定字段含义常见坑ts_code股票/合约代码注意是字符串不是数字trade_date交易日期要parse成datetime不要当字符串排序open / high / low / closeOHLC价格必须统一复权口径混用会算错收益vol / amount成交量 / 成交额vol单位可能是手也可能是股行情数据的第一个坑就是复权。前复权、后复权、不复权三种口径算出来的收益率完全不一样。我的习惯是用后复权价格计算动量类因子和标签因为后复权不会因为除权除息造成收益率跳变回测引擎里下单时再切回前复权价格做撮合。记住一句话全项目统一用一种复权口径中途不要混。数据预处理还要做三件脏活。第一剔除上市不满60个交易日的次新股次新股的涨跌幅和流动性都不正常。第二识别并处理停牌日停牌期间成交量为0收益率是NaN直接drop掉或前向填充要看场景。第三过滤ST和涨跌停样本这个后面避坑章节会单独讲。2.2 预测目标与特征标签怎么定特征怎么来数据清洗完下一步是把“预测涨跌”翻译成监督学习的输入输出。很多新手拿到数据直接丢给LightGBM结果模型把公司代码当成特征学进去了这就是没有理解“标签”的含义。标签定义是这个项目最关键也最容易返工的地方。常见做法是用未来N个交易日的收益率作为预测目标然后转成二分类或者回归。比如预测未来5日收益率是否大于等于5%大于等于就标1否则标0。这个阈值直接影响正负样本比例阈值设太低正例太多模型学不到区分度设太高正例太少模型会偏向全部预测0。我一般这样构造特征动量类过去5日、10日、20日收益率刻画短中期趋势波动类过去20日日收益率的标准差刻画风险水平量能类当日成交量与过去5日平均成交量的比值刻画量能异动价量背离类价格创新高但成交量没跟上这类结构特征有时比纯价格特征有效特征构造的原则只有一条任何特征在时间t只能使用t时刻及之前的信息。这是量化机器学习里最根本的约束违反它就是未来函数后面章节细说。2.3 可复现的数据预处理脚本在动手写模型之前先把数据管道跑通。下面这段代码是我处理日线数据的基础脚本能在pandas里完成排序、特征计算和标签生成。import pandas as pd import numpy as np # 读入日线数据约定字段名见 2.1 节的表 df pd.read_csv(daily_data.csv, parse_dates[trade_date]) df df.sort_values([ts_code, trade_date]).reset_index(dropTrue) def build_dataset(df: pd.DataFrame, label_days: int 5, ret_threshold: float 0.05, min_periods: int 20): # 日收益率注意 groupby 后算不要跨股票算 df[ret_1d] df.groupby(ts_code)[close].pct_change() # 动量因子过去 5 / 10 / 20 日累计收益 for w in [5, 10, 20]: df[fmomentum_{w}] ( df.groupby(ts_code)[close].transform(lambda x: x.pct_change(w)) ) # 波动因子过去 20 日收益率标准差 df[volatility_20] ( df.groupby(ts_code)[ret_1d] .transform(lambda x: x.rolling(min_periods, min_periods).std()) ) # 量能因子当日成交量 / 过去 5 日均量 df[vol_ma5] ( df.groupby(ts_code)[vol].transform( lambda x: x.rolling(5, 5).mean() ) ) df[volume_ratio] df[vol] / df[vol_ma5] # 标签未来 label_days 日收益shift 到当前行 df[label_ret] ( df.groupby(ts_code)[close] .transform(lambda x: x.shift(-label_days) / x - 1.0) ) df[label] (df[label_ret] ret_threshold).astype(int) # 清理无穷值和没有标签的尾部样本 df df.replace([np.inf, -np.inf], np.nan) df df.dropna(subset[label, volatility_20]).reset_index(dropTrue) return df sample build_dataset(df, label_days5, ret_threshold0.05) print(sample[[ts_code, trade_date, momentum_5, volume_ratio, label]].head())label_days是预测周期代表你持有多久再评估收益一般取1、5、10、20和交易频率强相关。ret_threshold是正例阈值建议先看label_ret的分位数再定而不是拍脑袋设0.05。min_periods控制滚动窗口最少样本数上市时间短的股票滚动20日标准差会算出NaN这里设为20意味着不足20个样本直接置空。这段代码里有两点要注意pct_change自带组内结果但rolling需要和groupby搭配且rolling结束后要用reset_index或transform对齐索引否则会出现错位。另一个细节是dropna(subset[volatility_20])会把上市不满20天的新股全部剔除这是有意的。2.4 特征泄漏那个看起来完美的回测为何一上实盘就翻车特征泄漏是量化机器学习里最常见的回测翻车原因而且它藏得极深。最典型的场景是你想填充缺失值直接对整个数据集做了fillna(df.mean())这个均值是全样本的包含了未来信息模型在训练时“偷看”了未来分布。同样的道理如果你用标准化StandardScaler().fit(X)时先拟合了整个X再切训练测试集也会泄漏。解决方法一是把fit限制在训练集上测试集只用transform二是所有因子计算都按 2.3 节的写法在groupby内部完成不让未来数据跨行流动。另一个高频泄漏源是shift方向搞反。计算未来收益作为标签要用shift(-label_days)把未来收益挪到当前行而计算特征时如果用了未来N日的数据做平均就是往过去“偷”数据。一个直观的检查方法把特征和标签画在同一张图上看特征在时间轴上是否超前于标签变化。超前就是泄漏趁早删。3. 多算法集成用组合模型代替单一模型参数与切分方式怎么定数据管道跑通后进入标题里最核心的部分——集成多种算法。为什么研究项目要强调“多种算法”而不是“调出一个最强模型”因为金融数据信噪比极低任何单一模型都容易过拟合到某段行情的噪声里集成至少能把“只看一个方向”的风险摊薄。3.1 算法对比为什么多算法集成是量化研究的默认做法先看一张在日频A股数据上常见的模型表现对比结论是“没有绝对最强只有更稳”模型优势劣势适合场景逻辑回归稳定、可解释、不容易过拟合只能学线性关系基线模型必跑随机森林能抓非线性对异常值钝感对噪声敏感容易过拟合中等维度特征GBDT/LightGBM表格数据王者特征交互强参数多调不好就是过拟合王特征工程到位后的主力SVM小样本高维表现好核函数选择玄学特征维度高但样本不多的阶段MLP能学复杂映射对数据量和调参要求高特征已经非常干净的阶段我在实际项目里的做法是逻辑回归永远作为基线随机森林和GBDT作为主力SVM看情况上。跑完一轮后把预测结果做等权平均AUC通常比任何单一模型都稳。这里有个反直觉的经验集成后AUC不会暴涨但回测曲线的回撤会变小这才是集成在量化里的价值。3.2 时序交叉验证K折在这里不适用这是机器学习在量化里最容易被忽略的一步。KFold默认会打乱样本顺序随机切分这在图像分类里没问题但在时序数据里就是灾难模型用2023年的数据训练却在2020年的数据上验证相当于让模型“预知未来”。正确做法是用TimeSeriesSplit它严格按时间顺序切分训练集永远在验证集之前。下面这个时序交叉验证的拆法能保证每一折都不含未来信息from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) for fold, (train_idx, val_idx) in enumerate(tscv.split(X)): X_train, X_val X[train_idx], X[val_idx] y_train, y_val y[train_idx], y[val_idx] print(ffold {fold 1}: train {len(train_idx)} samples, val {len(val_idx)} samples)注意TimeSeriesSplit的切分是等比扩窗训练集不断变大验证集固定为一段。第一次切分时训练集可能只有1/5的样本模型会欠拟合所以一般我只看最后一个fold的结果做决策前面的fold用来观察模型是否随数据量增加而进步。还有一个细节金融数据相邻交易日高度自相关紧挨着切分会造成“训练集最后一天和验证集第一天”之间存在信息重叠。我习惯在训练集尾部留出5到10个交易日的gap再开始验证集这个gap窗口完全不参与训练能显著降低时序泄漏。3.3 集成训练最小脚本特征和标签准备好之后下面是一份可以直接跑的集成训练脚本它把逻辑回归、随机森林、GBDT三个模型的结果做了等权平均再用AUC评估。from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.metrics import roc_auc_score import numpy as np # 取特征列只保留以 momentum_ / volatility_ / volume_ 开头的列 feature_cols [ c for c in sample.columns if c.startswith((momentum_, volatility_, volume_)) ] X sample[feature_cols].fillna(0.0).values y sample[label].values # 用最后一个 fold 做验证 split_point int(len(X) * 0.8) X_train, X_val X[:split_point], X[split_point:] y_train, y_val y[:split_point], y[split_point:] models { lr: LogisticRegression(max_iter1000, C1.0), rf: RandomForestClassifier( n_estimators200, max_depth5, min_samples_leaf50, random_state42 ), gbdt: GradientBoostingClassifier( n_estimators200, max_depth3, learning_rate0.05, random_state42 ), } proba_list [] for name, model in models.items(): model.fit(X_train, y_train) proba model.predict_proba(X_val)[:, 1] proba_list.append(proba) auc roc_auc_score(y_val, proba) print(f{name} AUC: {auc:.4f}) # 等权平均集成 ensemble_proba np.mean(proba_list, axis0) ensemble_auc roc_auc_score(y_val, ensemble_proba) print(fensemble AUC: {ensemble_auc:.4f})为什么max_depth要设这么小因为金融数据噪声大树一旦长得深就疯狂拟合噪声样本外性能直线下降。min_samples_leaf50强制每个叶子至少50个样本相当于给树加了一根“稳健性保险丝”。GBDT的learning_rate0.05配合浅树比大学习率加深度更容易收敛到稳定区域。等权平均是默认选项逻辑上比投票法更平滑因为predict_proba输出的概率保留了置信度信息。投票法只取硬标签一旦某两个模型同时出错结果就会错得更离谱。等权平均能部分对冲这种“共识性错误”。3.4 调权重的边界别把验证集变成测试集等权平均是没问题的基线但你一定想把权重调一调比如逻辑回归0.2、随机森林0.3、GBDT0.5。可以这么做在验证集上做一次网格搜索选出让AUC最大的权重组合。但这里有一条血泪经验权重只能在验证集上调绝对不能看到测试集后再回头调权重。否则权重会过拟合到测试集的某一段行情里样本外表现立刻打回原形。我见过有人拿同一份数据反复调权重调出0.7的AUC换一段新数据直接掉到0.52这就是把验证集当训练集反复薅的后果。更稳妥的做法是只调“算法层”的权重不调“参数层”的权重。换句话说每个模型的内部参数保持默认或按经验固定权重搜索范围放在0.2到0.6之间步长0.1。搜索空间越小过拟合风险越低项目的可维护性越高。4. 回测避坑实录收益曲线好看背后的五个常见翻车点模型训练完毕AUC再高也只是预测能力离“能赚钱的策略”之间还隔着一道回测。这一章直接把研究项目里最常踩的坑列出来每一条都是真实项目里反复出现的现象、原因和解决方式。4.1 前视偏差回测中最常见的未来函数现象回测曲线完美年化收益高得吓人但一上实盘或者换一段新数据就崩。原因特征计算或信号生成时无意中用到了未来数据。最典型的就是没有对因子做shift(1)导致“当天收盘后计算的因子”被当成“当天开盘就能交易的信号”去撮合。实际上一个用收盘价计算的因子最早只能在第二天开盘执行当天价格已经收盘根本交易不进去。解决所有因子列在进入模型前统一执行df[factor] df.groupby(ts_code)[factor].shift(1)把整体信号往后推一个交易日。这是量化回测里最便宜也最有效的防未来函数手段。每次写完因子检查一下如果因子在t日用到close那它在t日收盘前是否可知不可知就shift。4.2 停牌与涨跌停价格看得见但交易不进去现象策略回测中重仓的股票连续涨停净值曲线直线拉升实盘根本买不进。原因数据集里的涨跌停价格是真实存在的但涨停时买单排队轮不到你的成交价跌停时卖单排队你的卖单也出不去。回测引擎用当天收盘价撮合默认你总能成交这就产生了巨大的幸存者偏差。解决在数据清洗阶段过滤涨跌停样本或者在回测撮合时把涨停日的买入信号改为“无法成交”。A股常见做法是当日收盘价触及涨停价的股票次日信号直接置空。我在2.1节说的“过滤涨跌停样本”指的就是这里。成交价也不能用收盘价一般按次日开盘价加滑点撮合这样更接近真实冲击成本。4.3 样本外污染同一个数据集反复调参等于把验证集当训练集现象验证集AUC 0.68觉得不错于是换个参数又跑一次0.71再换个参数0.73最终选定0.73那组信心满满上实盘结果AUC只有0.53。原因每一次调参都看到验证集的结果人脑已经在隐式地做优化验证集实际上变成了训练集的一部分。这个逻辑和机器学习模型过拟合验证集在原理上完全一样只是“过拟合者”变成了你。解决把数据集切成三段——训练集、验证集、测试集。训练集用来拟合模型验证集用来调参测试集只允许跑一次跑完就永久归档不再回头。如果测试集结果不理想就回去调参然后换一个更新的时间段重新构造测试集。这个纪律比任何模型技巧都重要。4.4 费用模型过于乐观现象策略每天换仓回测年化30%扣除佣金和滑点后只剩5%换了实盘更低。原因量化研究项目源码里如果只写了佣金万2.5那就忽略了三个隐藏成本印花税卖出单边收取、最低佣金5元、滑点。高频换仓策略对费用极度敏感换手率高达日均50%时哪怕万2.5的佣金也会把利润吃掉大半。解决回测脚本里至少加上这两行成本commission_rate 0.00025 # 单边佣金万2.5 min_commission 5.0 # 每笔最低5元 stamp_tax 0.001 # 卖出单边印花税千1 slippage 0.002 # 滑点按 0.2% 计算 def calc_trade_cost(trade_value, is_sellFalse): commission max(trade_value * commission_rate, min_commission) tax trade_value * stamp_tax if is_sell else 0.0 slippage_cost trade_value * slippage return commission tax slippage_costtrade_value是单笔成交金额is_sell表示卖出方向。滑点设在0.2%已经是比较保守的估计如果策略交易的是流动性差的小盘股建议提到0.5%。达不到这个成本门槛的策略不管AUC多高都先放一放。4.5 参数敏感性换参数就翻车的策略不值得上实盘现象n_estimators100时回测年化25%改成150变成8%改回100又变成25%这个策略在100这个点上表现极好。原因模型在某些参数位置上“偶然”契合了样本内的噪声。金融数据的信噪比太低参数微小变化就可能导致结果大幅波动说明模型没有抓到稳定的结构而是在拟合特定时段的运气。解决做参数敏感性矩阵把关键参数在小范围内做网格测试n_estimators取80、100、120、140max_depth取3、5、7记录每个组合的年化收益、回撤、换手率选的是“邻域内结果稳定”的参数而不是“单点最优”的参数。我见过太多人在参数精确到个位数的地方发现了“最优参数”实盘两周就没了这周一就是量化研究里最常见的玄学之一。5. 滚动样本外验证把研究项目变成能持续迭代的最小闭环最后这一步是让这份研究项目源码真正脱离“跑一次就扔”的关键。普通回测做完就结束了但量化研究是一个持续迭代的过程市场风格一直在变模型的生命周期往往只有几个月。滚动样本外验证的做法是把数据按时间切成多段每一轮用前面的数据训练预测下一段然后扩大训练集再训练再预测再下一段。这样模拟出来的表现比一次性切分更接近真实上线过程。n_splits 5 score_list [] for i in range(1, n_splits 1): split_point int(len(X) * i / (n_splits 1)) X_train, X_val X[:split_point], X[split_point:] y_train, y_val y[:split_point], y[split_point:] model GradientBoostingClassifier( n_estimators200, max_depth3, learning_rate0.05, random_state42 ) model.fit(X_train, y_train) auc roc_auc_score(y_val, model.predict_proba(X_val)[:, 1]) score_list.append(auc) print(fround {i}: train {len(X_train)}, val {len(X_val)}, AUC {auc:.4f}) print(fmean rolling AUC: {np.mean(score_list):.4f})这段代码输出5轮的AUC序列比单一AUC更有信息量如果AUC逐渐向上说明模型正在从数据增长中获益如果忽高忽低说明模型不稳定问题多半出在特征或者参数上如果一路向下说明特征失效市场风格已经切换。我还有两个习惯。第一个是每次跑实验都把参数和结果记录成一行日志日期、特征版本、模型参数、AUC、换手率、最大回撤。没有日志的调参过程就是黑匣子三个月后你再看到一份0.68 AUC的结果根本想不起来它用了哪套特征。第二是每次迭代只改一个变量要么加特征要么改参数同一次实验改两处以上出了问题你都不知道该怪谁。我早期做这个方向时吃过没做样本外切割的亏连续两周对着验证集调参调出来的漂亮曲线一上实盘就哑火。后来养成滚动验证和实验日志两个习惯项目终于可以安心迭代不再靠运气说话。这个方向值不值得做答案是可以做但前提是先把数据管道、时序切分、成本模型这三件事做扎实。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进