简介:围绕 Marcos López de Prado《Advances in Financial Machine Learning》一书的精选练习,这份代码包给出 Jupyter Notebook 形式的实验性解答,面向量化金融研究者与有一定编程基础的数据科学读者,便于对照原书理论验证思路、复现金融建模流程。包内共 43 个文件、约 2.8MB:18 个 Python 脚本构成 src 工具层,提供 bars.py、snippets.py 等基础函数;6 个 Notebook 分主题演示监督分类中的标签与元标签、金融交叉验证、tick/volume/dollar volume K线构造、样本权重、分数阶差分特征等核心方法;另附 RST 文档、Makefile、requirements、配置与许可证文件,配合 setup.py 可快速完成本地安装。另有 CSV 与图片等补充材料,与模型、参考目录组成较完整的实验工程结构。目前已有 335 人浏览学习。除可运行示例外,资源还整理了一批受该书启发的 GitHub 项目与文章链接,方便在统一入口下对比不同实现,拓展从理论模型到真实金融数据的落地思路。
1. Adv_Fin_ML_Exercises:这不是书抄,是能跑的实验笔记
做量化策略或者金融风控模型的人,大概率都翻过那本讲金融机器学习的进阶书,里面的概念人人叫好,但代码不全、练习也没有标准答案。Adv_Fin_ML_Exercises 这个仓库就是针对这种情况出现的:它把书里挑选出来的练习做成了实验性解决方案,用 Jupyter Notebook 组织,拿到就能跑。它解决的核心问题不是「这本书讲了什么」,而是「书里的方法在真实代码里长什么样、参数到底怎么设、跑出来为什么和书上不一样」。适合正在学特征工程、样本标注、组合模型和交叉验证的从业者,也适合被论文公式劝退、想直接从代码入手的同学。看完这篇拆解,你不仅能判断这套练习值不值得下,还能照着思路在自己的数据上复现一遍。
2. 环境与数据准备:把 Jupyter Notebook 变成金融实验台
2.1 依赖安装与版本对齐
这套练习不是单文件脚本,而是按章节拆分的 Notebook 集合,依赖集中在 requirements 里。常见做法是先建一个干净的虚拟环境,再装依赖。我一般会这样处理:
cd Adv_Fin_ML_Exercises python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install --upgrade pip pip install -r requirements.txt逻辑说明:venv 隔离环境避免污染系统 Python;-r requirements.txt会一次性装上 numpy、pandas、scikit-learn、statsmodels、matplotlib、jupyter 这些核心库。参数说明里最需要注意的是版本对齐:pandas 的DataFrame.append在新版本被移除,如果 Notebook 还是老写法,装最新版 pandas 会直接报 AttributeError。
pip install "pandas>=1.3,<2.0" "numpy>=1.21,<1.24" "scikit-learn>=1.0,<1.2"这里把 pandas 压到 2.0 以下,是因为练习里大量用到fillna(method='ffill')这类旧 API,新版本已经改成fillna(method=None, axis=None)而method参数只保留 'ffill'/'bfill' 的字符串形式,部分老代码还是会翻车。装完依赖后启动 Jupyter:
jupyter notebook打开后建议先跑一遍每个 notebook 开头的「环境自检」单元格,确认版本号,避免后面对照着抄代码时被 API 差异卡住。
2.2 行情数据预处理:从 OHLC 到可建模的样本
金融机器学习的第一步不是建模,而是把行情数据整理成「一个样本一行」的表格。练习里用到的是 OHLCV 日线数据,典型的结构是 date、open、high、low、close、volume。原始数据通常来自本地 CSV 或者数据库查询,第一步是统一时间索引:
import pandas as pd df = pd.read_csv('ohlcv.csv', parse_dates=['date']) df.set_index('date', inplace=True) df.sort_index(inplace=True) df = df[~df.index.duplicated(keep='first')] print(df.head())逻辑说明:set_index把日期列变成索引,后续所有重采样和滚动计算都依赖时间索引;sort_index保证时间升序;duplicated去重是为了处理行情源偶尔推送重复 K 线的问题。参数说明:keep='first'保留第一条,通常意味着保留交易所原始推送,不做二次修正;如果发现某天数据缺失,可以用前向填充补上缺口,但只能用于特征,不能用于标签。
接下来是把价格序列转成练习里需要的收益率和波动率特征:
df['ret'] = df['close'].pct_change() df['volatility'] = df['ret'].rolling(20).std() * np.sqrt(252) df['log_ret'] = np.log(df['close'] / df['close'].shift(1)) df.dropna(inplace=True)这里pct_change()计算简单收益率,rolling(20).std()是 20 日滚动标准差,乘以sqrt(252)折算成年化波动率。log_ret用对数收益率,好处是时间可加,练习里计算累计收益或者做标签阈值判断时更稳。dropna去掉前 20 行没有滚动统计量的数据,这一步不做的话,后面所有模型都会把 NaN 当特征传进去,报错或者学出奇怪的东西。
2.3 标签与样本划分:别把未来的数据泄漏进来
金融机器学习最容易翻车的不是模型选型,而是标签和特征的时间对齐。练习里反复强调一个原则:标签只能包含 t 时刻之后的信息,特征只能包含 t 时刻之前的信息。写代码时最容易犯的一个错是:
# 反例:特征里用了未来数据 df['feature_future'] = df['close'].shift(-5)这种写法在构建训练集时不会报错,但验证集上表现会虚高,实盘直接打回原形。练习里的做法是把特征构造和标签构造放在两个独立函数里,特征统一用shift(n)滞后,标签统一用未来窗口计算:
def build_features(df, lags=[1, 5, 10]): feat = pd.DataFrame(index=df.index) for lag in lags: feat[f'ret_{lag}d'] = df['close'].pct_change(lag) feat[f'vol_{lag}d'] = df['volatility'].shift(lag) return feat.dropna() def build_labels(df, horizon=5, upper=0.02, lower=-0.02): future_ret = df['close'].shift(-horizon) / df['close'] - 1 label = pd.Series(0, index=df.index, dtype=int) label[future_ret > upper] = 1 label[future_ret < lower] = -1 return label逻辑说明:build_features里volatility.shift(lag)把波动率特征整体向后平移,保证 t 时刻的样本只看到 t-lag 时刻之前的信息;build_labels里shift(-horizon)取未来 horizon 天的收益,和特征错开。这样的设计让样本矩阵在构造时就天然避免了泄漏。
参数说明:horizon=5表示预测未来 5 个交易日;upper=0.02和lower=-0.02是分类阈值,收益超过 2% 标为 1,低于 -2% 标为 -1,中间标 0。阈值怎么选直接影响样本分布,如果标的波动大,2% 阈值会导致绝大多数样本为 0 类,训练出来的模型几乎没有区分度;波动小的标的则相反。我一般会先看df['ret'].describe()的分位数,把阈值设在 70% 分位附近,而不是拍脑袋定 0.02。
3. 事件驱动采样与三重栅栏:把损失函数装进时间轴
3.1 事件定义:波动率阈值与指数衰减
书里最核心的思想之一是「不是每个时刻都值得建模」。普通机器学习在固定频率上采样,但金融市场里价格行为不是均匀分布的,盘整期的样本对模型训练几乎没有贡献。事件驱动采样就是只在满足条件的时刻生成样本。
练习里用波动率阈值来定义事件,核心代码如下:
def get_events(df, vol_window=20, threshold=1.0): df['vol'] = df['ret'].rolling(vol_window).std() * np.sqrt(252) df['vol_ma'] = df['vol'].rolling(vol_window).mean() df['event'] = (df['vol'] > df['vol_ma'] * threshold).astype(int) events = df.index[df['event'].diff().fillna(0) == 1].tolist() return events逻辑说明:先计算滚动波动率和它的均线,当当前波动率超过均线的 threshold 倍时,标记为事件候选;diff()找从 0 变 1 的拐点,避免连续多个高波动日重复取样。参数说明:threshold=1.0表示波动率超过自身 20 日均值就触发事件,数值调大事件变少、样本更精;vol_window=20是滚动窗口,分钟级数据可以缩到 5~10。
除了这种阈值事件,练习里还有指数衰减加权的方式。对事件附近样本赋予不同权重,离事件越近权重越高,衰减公式是w = exp(-k * dt),k控制衰减速度,dt是距离事件的时间间隔。这个权重不是给模型的 sample_weight 用,而是用于后续计算样本唯一性,防止重叠样本被重复计数。
3.2 三重栅栏标记:上下碰触与到期
三重栅栏是标签生成的核心方法,思路很简单:在事件发生后,设置上边界、下边界和最长持有期三个条件,哪个先触发就按哪个打标签。相比固定 horizon 的收益分类,三重栅栏更贴近实际止损止盈逻辑。
def triple_barrier(close, events, horizon=5, upper=0.03, lower=-0.03): labels = [] for t in events: future = close.loc[t:].iloc[1:horizon+1] if len(future) == 0: labels.append(0) continue path = future / close.loc[t] - 1 if (path > upper).any(): labels.append(1) elif (path < lower).any(): labels.append(-1) else: labels.append(0) return pd.Series(labels, index=events)逻辑说明:对每个事件时刻 t,取出之后最多 horizon 根 K 线,逐日计算相对 t 时刻的收益率路径;路径上的任意一点突破上界就打 1,突破下界就打 -1,全都没碰到就等到期打 0。这样的标签包含了「最快触及边界」的信号,而不是只看第 horizon 天的最终收益。
参数说明:upper和lower可以不对称,比如做多策略可以把upper=0.04, lower=-0.02,因为上涨要拿住、下跌要快跑;horizon是最大持有期,超过这个时间无论盈亏都强制平仓。练习里有个容易被忽略的细节:close.loc[t:].iloc[1:horizon+1]里的.iloc[1:]是跳过事件当天、从第二天开始算,如果漏掉,边界被当天收盘价碰触就会产生大量假标签。
3.3 样本权重与数量:多少样本才够训练
事件驱动采样之后,样本不再是等间隔分布的,相邻事件的样本在时间轴上可能有重叠。比如第 3 天触发事件、持有 5 天,第 4 天又触发一个事件,两个样本的标签区间就重叠了。这种重叠会导致模型过度强调同一段行情。练习里用「唯一性权重」来解决:
def sample_weight_by_uniqueness(events, horizon): weight = pd.Series(1.0, index=events) for i, t in enumerate(events): overlap_start = t overlap_end = events[i+1] if i+1 < len(events) else t + pd.Timedelta(days=horizon) overlap_days = (overlap_end - overlap_start).days weight.iloc[i] = 1.0 / max(overlap_days, 1) return weight逻辑说明:每个样本的权重等于 1 除以它和下一个样本之间的重叠天数,重叠越多权重越低。这样即使两个样本特征相近,模型也不会把它们当成完全独立的信息重复学习。参数说明:horizon要和你做标签时的持有期一致,权重计算的边界是标签区间的物理时长,单位是交易日还是自然日会影响数值。
样本数量方面,事件驱动采样后样本量可能比原始 K 线数少一半以上,这是正常的。如果样本量不够训练,常见做法是放宽 threshold,比如从 1.0 调到 0.8,让更多时刻成为事件;不要用 SMOTE 之类的手段强行合成样本,金融数据的合成样本几乎没有业务语义,模型学到的都是插值出来的虚假规律。
4. 组合模型与特征重要性:从黑匣子到可信度排序
4.1 组合分类器:随机森林的金融化改造
有了样本和标签,下一步是训练模型。练习里没有直接用单棵决策树,而是用随机森林 + Bagging 的组合方式。原因是金融数据噪音高、信噪比低,单模型方差太大,需要靠多次抽样平均来压制过拟合。
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split X = features.loc[weight.index].values y = labels.loc[weight.index].values sample_w = weight.values X_train, X_test, y_train, y_test, w_train, w_test = train_test_split( X, y, sample_w, test_size=0.3, random_state=42, stratify=y ) clf = RandomForestClassifier( n_estimators=500, max_depth=6, min_samples_leaf=20, class_weight='balanced', random_state=42 ) clf.fit(X_train, y_train, sample_weight=w_train)逻辑说明:train_test_split里同时传入X, y, sample_w,保证训练集和测试集分别携带自己的权重;stratify=y让三类标签在训练/测试里比例一致,避免标签不均衡导致测试集全是 0 类。sample_weight在fit时传入,等价于每个样本对损失函数的贡献度。
参数说明:n_estimators=500不是越大越好,超过 500 后精度提升非常有限,但训练时间线性增长;max_depth=6控制树的深度,金融数据特征相关性高,深度太大会让树学到特征之间的偶然共线;class_weight='balanced'是自动按类别频率加权,解决 0 类占比过高的问题。如果跑出来训练集准确率接近 100% 而测试集只有 55%,首选调小max_depth或者调大min_samples_leaf,而不是加特征。
4.2 MDI/MDA/SFI 三种重要性
练习里花了不小篇幅讲特征重要性,核心思路是不要只看 sklearn 自带的 feature_importances_,那个指标只反映「特征在树分裂中减少了多少不纯度」,对金融数据来说容易被高基数特征干扰。书里的方案是用三种方法互相印证:
- MDI(平均不纯度减少):所有树的节点不纯度减少量按特征求和后取平均,就是 sklearn 默认输出。
- MDA(平均准确率减少):对每个特征做列随机打乱,观察模型精度下降多少,下降越多特征越重要。
- SFI(单特征重要性):只用这一个特征训练模型看效果。
def mda_importance(clf, X_test, y_test, n_rounds=5): base_score = clf.score(X_test, y_test) importances = {} for col_idx in range(X_test.shape[1]): scores = [] for _ in range(n_rounds): X_shuffled = X_test.copy() np.random.shuffle(X_shuffled[:, col_idx]) scores.append(clf.score(X_shuffled, y_test)) drop = base_score - np.mean(scores) importances[col_idx] = drop return importances逻辑说明:np.random.shuffle只打乱某一列,破坏该特征和目标之间的统计关系,如果模型精度因此明显下降,说明模型确实依赖它;n_rounds=5是因为单次打乱有随机性,多次取平均更稳。参数说明:打乱列时要整列打乱,不能按行随机重排,否则相当于同时破坏了其他特征的对应关系,测出来的是多个特征的混合重要性。
实际使用中 MDI 和 MDA 结论常常不一致:某个特征 MDI 很高但 MDA 很低,说明它在树分裂时频繁使用,但打乱它不影响精度,可能是和另一个特征高度共线。遇到这种情况,我一般保留一个、去掉另一个,再跑一次模型看效果。
4.3 超参数调优:分组交叉验证的坑
金融数据做交叉验证不能随机切分。普通 K 折会把时间打乱,训练集里出现未来数据,验证集里出现过去数据,结果虚高。练习里用的是按时间顺序的 GroupKFold,把同一段连续行情当成一个组,不允许跨组混合。
from sklearn.model_selection import GroupKFold groups = np.arange(len(X)) // 20 # 每 20 个样本一组 gkf = GroupKFold(n_splits=5) for train_idx, test_idx in gkf.split(X, y, groups): clf.fit(X[train_idx], y[train_idx]) score = clf.score(X[test_idx], y[test_idx]) print(f'fold score: {score:.4f}')逻辑说明:groups把样本按连续 20 个划为一组,GroupKFold保证同一组不出现在训练和测试两个集合里,从机制上防止相邻样本的信息泄漏。参数说明:// 20的分组宽度要和标签持有期挂钩,如果标签用了未来 5 天收益,至少按 5 个样本一组,实践中建议取持有期的 2~3 倍,否则样本在时间轴上仍有交叉。
这里有个很多人踩过的坑:使用KFold而不是GroupKFold时,模型分数会高几个点,让人误以为特征很有效。把分组宽度换成和持有期一致再跑一遍,分数会明显下降,那才是真实水平。
5. 避坑与排查:运行这些练习时的常见问题
5.1 现象:标签整体漂移,训练集和测试集准确率都异常低
原因:build_labels里用了shift(-horizon),最后 horizon 行的标签变成 NaN,但后续dropna只处理了特征矩阵,没有同步丢弃标签为 NaN 的样本。模型在 NaN 标签上训练,sklearn 会直接报错或者忽略这些样本,导致样本量骤减。
解决:把特征和标签合到同一个 DataFrame 里再做dropna(),或者在拿到标签后执行valid_idx = labels.notna(),用布尔索引同步过滤X、y和weight。从那以后我每次构造数据集都强制检查len(X) == len(y),不等就一定是有地方错位了。
5.2 现象:训练随机森林时内存爆炸
原因:n_estimators设得过大(比如 2000),同时max_depth不限制,树的数量和每棵树的节点数量同时膨胀,内存占用成倍增长;另外X如果是 DataFrame 且包含 datetime 列,sklearn 会尝试把时间戳转成数值特征,内存直接翻好几倍。
解决:训练前只保留数值型特征列,时间索引单独维护,不放进特征矩阵;n_estimators先用 200~300 跑通流程,确认无误后再调大。如果内存还是不够,改用RandomForestClassifier的max_features='sqrt',每棵树只用部分特征,单棵树内存占用会小很多。
5.3 现象:回测结果异常高,年化收益超过 100%
原因:标签构造时把特征包含进了未来信息。检查点有两个:一是特征列里有没有shift(-n)开头的列,二是triple_barrier的上下界是否用了当天收盘价所在的未来区间。数据泄漏不会报错,只会让结果好到不真实。
解决:把特征矩阵的列名列出来,凡是名字里带_fwd、_future或者shift(-的统统删掉;再用第 4 章的 GroupKFold 重新验证,分数会回归正常范围。这类问题排查起来最费时间,所以我在构建特征时约定:所有未来信息只存在于标签函数内,特征函数里禁止出现负 shift。
5.4 现象:SVD 或 PCA 收敛失败
原因:特征矩阵里有常量列,或者高度重复的列,导致矩阵奇异;另一个常见原因是 NaN 没有清理干净,np.linalg.svd遇到 NaN 会直接抛出 LinAlgError。
解决:在跑 PCA 之前加一步X = X.fillna(0)然后检查np.isfinite(X).all();常量列用X.var()筛掉方差为 0 的列。练习里给了一个get_efficient_jmax函数用于降维前的相关性筛选,把相关系数大于 0.95 的特征只保留一个,这个预处理步骤不能省。
5.5 现象:不同机器上跑出的结果不一致
原因:随机森林和交叉验证都涉及随机数,不同机器、不同 scikit-learn 版本下随机数序列不一致。另外 pandas 的sort_index在时间索引带时区时可能产生不同排序。
解决:在所有涉及随机数的地方显式传random_state=42,包括RandomForestClassifier、train_test_split、np.random.shuffle前调用np.random.seed(42)。更严格的做法是先固定 numpy 的全局种子:
import numpy as np np.random.seed(42)这套代码在 Jupyter 里重跑时,单元格执行顺序也会影响全局随机数状态,所以建议「Kernel -> Restart & Run All」按顺序完整跑,不要跳跃执行单个单元格。
6. 进阶:组合清理交叉验证与样本外评估
6.1 为什么单次 K 折不可信
金融数据的时间依赖让普通 K 折的验证结果自带水分。即使使用了时间顺序切分,单次划分仍然可能因为切点落在极端行情附近而给出偏高或偏低的分数。书里提出的组合清理交叉验证(CPCV)思路是:把所有可能的训练/测试划分组合都跑一遍,取分数分布而不是单点分数。
6.2 用代码走一遍 CPCV
from sklearn.model_selection import KFold import numpy as np n_groups = 12 groups = np.arange(len(X)) // (len(X) // n_groups) outer_kf = KFold(n_splits=3, shuffle=False) scores = [] for train_groups, test_groups in outer_kf.split(groups): train_mask = np.isin(groups, train_groups) test_mask = np.isin(groups, test_groups) fold_scores = [] for train_idx in np.where(train_mask)[0][::2]: # 间隔采样 clf = RandomForestClassifier(n_estimators=200, random_state=42) clf.fit(X[train_idx], y[train_idx]) fold_scores.append(clf.score(X[test_mask], y[test_mask])) scores.extend(fold_scores) print(f'score mean: {np.mean(scores):.4f}, std: {np.std(scores):.4f}')逻辑说明:先把样本按时间分成 12 组,外层 KFold 把组划分成训练组和测试组,内层再对训练组做间隔采样,构造多个不同起点和长度的训练子集;每个测试组会和多个不同训练组合配对,最终得到一组分数而不是一个分数。
参数说明:n_splits=3控制外层划分数量;[::2]是简化的组合采样步长,实际项目里可以用itertools.combinations枚举所有合法配对,但计算量会显著增加。判断模型是否可用,看的不只是均值,还有标准差:均值 0.6、标准差 0.05 的模型比均值 0.65、标准差 0.15 的模型更值得信任,后者大概率是撞上了某段好行情。
6.3 验证技巧:从练习到自己的策略
最后聊一个我在复现这套练习时养成的习惯:每个 notebook 跑通之后,强制写一段「反向验证」——把标签做-1反转训练一个模型,如果反向模型的准确率也超过 55%,说明原模型学到的是市场反转规律而不是趋势规律,这往往意味着特征构造有问题;再跑一个「随机标签基线」,把标签打乱后训练,如果随机标签模型的准确率没有掉到 50% 附近,说明训练过程有泄漏。这套检查在练习代码之外,但能帮你判断从练习迁移到自建策略时,哪些环节需要重新设计。从那以后我每次拿到一个新数据集,都强制走一遍这三个验证步骤,习惯养成之后,基本告别了「回测惊艳、实盘翻车」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取