
简介这是一份围绕金融机器学习练习的实验性解决方案源自 Marcos López de Prado 所著《金融机器学习进展》面向量化研究、数据科学与金融建模进阶学习者帮助读者用 Python 实践书中典型问题。压缩包共 43 个文件、约 2.8MB主体为 18 个 py 脚本与 6 个 Jupyter Notebook并配有文档、图片、配置文件及 Makefile便于复现与扩展。内容覆盖标签与元标签监督分类、金融场景交叉验证、Tick/Volume/Dollar Volume K线、样本权重、分数差异化特征等主题Notebook 中穿插实验性代码与说明src 模块包含 bars.py、snippets.py 等可复用脚本按 README 所示执行 setup.py 即可让环境正确索引依赖。项目采用含 notebooks、src、reports 等模块的标准结构读者可对照章节练习梳理特征工程与模型评估的完整流程。目前已有 328 人学习浏览适合需要系统性研读此书并动手实践的中高级读者。1. Adv_Fin_ML_Exercises教科书练习的实验性答案值不值得一篇篇跑完Marcos López de Prado 的《金融机器学习进展》AFML在量化圈的分量不用多说但书中练习一直缺一份对得上号的代码解法。很多人在读第 3 章的三重障碍标注、第 5 章的分数差分时公式能看懂代码却不知从哪下手。Adv_Fin_ML_Exercises 这个项目就是把书里重点章节的练习用 Jupyter Notebook 做了实验性实现先给出一条能跑通的路再让你在此基础上修改。它适合正在啃 AFML、手边缺参考代码的从业者也适合读完理论想立刻见代码效果的初学者。下面按环境准备、核心练习、落地路径、避坑、验证几个维度拆透十分钟内你就能判断这份资源值不值得下载、该从哪个 notebook 开始读。2. 环境准备与仓库导航依赖怎么选、目录怎么认这类仓库的代码质量通常能容忍但环境坑通常不少。AFML 书里的公式是 2018 年前后定稿的配套练习的实现也带着那个时代的习惯一上来用最新环境硬跑往往先把时间耗在修依赖上。换个思路先锁好版本再谈读代码。2.1 依赖栈怎么选固定版本区间比装最新更稳我见过太多人拿到 notebook 第一件事是pip install pandas numpy sklearn然后被一串 deprecation 警告和 API 报错劝退。AFML 时代的 pandas 还在 0.25 附近而现在是 2.xDataFrame.append被移除groupby行为也改过。直接把最新版塞进环境大概率会撞见各种兼容性报错。实际经验是给这个仓库单独建一个 conda 环境并把依赖锁在一个相对稳定的区间。我验证过一套组合conda create -n afml_env python3.9 -y conda activate afml_env pip install numpy1.21,1.24 \ pandas1.3,1.5 \ scipy1.7,1.10 \ scikit-learn1.0,1.2 \ statsmodels0.13,0.14 \ matplotlib3.5,3.7 \ jupyterlab这段命令的逻辑不复杂但每一处约束都有原因。python 3.9 是折中选择3.8 太老、3.10 以上容易出现 scikit-learn 编译依赖冲突。pandas 控制在 1.3 到 1.5主要是这个区间还保留了不少老接口同时能正常识别 timezone-aware 的 datetime 索引而三重障碍、事件标签这类练习恰好重度依赖时间索引。scikit-learn 控制在 1.0 到 1.2是为了避开 1.3 之后交叉验证接口参数改名带来的额外负担。statsmodels 的版本区间则保证add_constant、OLS这些老用法不报错。依赖选好之后还要确认 notebook 的内核真的指向 afml_env。Jupyter 刚启动时经常默认用 base 环境导致你在终端里装好的包在 notebook 里怎么都 import 不上。常见做法是装完包后执行python -m ipykernel install --user --name afml_env把环境注册成可选内核再在 notebook 右上角切换。这一步虽小却能省下大量“明明装了却 ImportError”的排查时间。提示导入阶段如果报 ModuleNotFoundError 或 ImportError不要急着 conda install先检查当前 notebook 的 kernel 指向再检查版本是否落在上面区间。多数情况是版本太新导致 API 改名而不是包缺失。2.2 仓库结构怎么认按章定位练习按主题抽公共函数Adv_Fin_ML_Exercises 的仓库布局常见做法是每一章对应一个目录目录里放该章的练习 notebook。第一次打开时我建议不要从第一个 notebook 顺序往下读而是先看目录名直接定位到你在书上标记过“这章没搞懂”的地方。书的第 2 章讲金融数据特性第 3 章讲数据标注第 5 章讲分数差分第 6 章讲特征重要性第 7 章讲样本权重第 10 章讲交叉验证。你哪一块概念最模糊就从哪个目录的练习入手效率高得多。常见目录组织大致是Adv_Fin_ML_Exercises/ ├── Chapter02_Financial_Data/ ├── Chapter03_Labelling/ ├── Chapter05_Fractional_Differentiation/ ├── Chapter06_Feature_Importance/ ├── Chapter10_CV_and_Backtesting/ └── README.md拿到这种仓库我先看两样东西一是根目录的 README 或 requirements二是每个目录下的 notebook 文件名。README 会写明运行顺序和注意事项notebook 文件名通常包含练习编号能直接对应书上的习题编号方便按图索骥。如果 README 缺失就按目录数字最小的章节开始跑跑通一个再往下一个推进。定位到目录之后不要急着 Run All。每个 notebook 开头几格通常是导入库和数据生成跑完这部分没报错再逐格往下。很多练习的依赖是隐性的前一个 cell 定义一个函数后一个 cell 要用它跳着执行会出现 NameError。这个仓库的 notebook 大体上按“先定义后使用”的顺序写但不会对你做任何保护所以从上到下老实执行最多在中途打断改参数。2.3 先跑合成数据最小启动路径与一次健康检查依赖装好、目录认完之后第一件值得做的事不是下载真实行情而是把 notebook 里自带的合成数据跑通。合成数据的好处是纯内存计算不涉及外部数据权限运行时错误能在最干净的条件暴露。下面这个流程我每次都会走cd Adv_Fin_ML_Exercises jupyter lab --port 8889 # 指定端口避免与本机已有 8888 冲突 # 另一个终端验证环境 conda activate afml_env python -c import pandas, numpy, sklearn, statsmodels; print(pandas.__version__, numpy.__version__, sklearn.__version__)最后这条python -c是环境体温计。它能一次性告诉你当前版本是哪几个不用等 notebook 里出现红色报错才反应过来是前面哪一步装错了。我跑任何教程仓库之前都先跑这条版本不在预期区间就先调整不带病出发。确认好版本后打开第一个推荐的 notebook执行完最前面的几格代码确认能输出一张图表或一张表环境链路就算通了。之后每读完一个练习题目找到对应 cell把参数改小一点比如把价格序列从 10000 根改成 500 根观察输出是否随之变化。这能帮你快速确认是“自己没看懂”还是“代码跑错了”。反过来一开始就上真实行情标定参数一旦出问题很难分清是哪一步错的。先合成、后真实是这套仓库最稳的食用顺序。3. 核心练习拆解三重障碍、分数差分、特征重要性的命题要点AFML 整本书有几条主线练习也围绕这几条主线展开。这里挑三个最常被人问起、也最能体现这本书特色的练习展开每个都给出对应的实现骨架。3.1 三重障碍标注法边界条件全在参数里三重障碍triple-barrier是全书标签体系的根基。它的思路很简单对每个事件窗口设置上水平线止盈、下水平线止损和一条垂直时间线最迟退出。哪个先被触及就用哪个结果打标签。这个逻辑听起来容易但实现时坑不少。参考实现如下import numpy as np import pandas as pd def triple_barrier_label(close, events, pt_sl(0.02, 0.01), vertical_barNone): 三重障碍标注对每个事件窗口打上 1 / 0 / -1 标签。 close : 价格序列datetime 升序 events : DataFrameindex 为事件起点至少含 t1 列最晚退出时间 pt_sl : (止盈幅度, 止损幅度)按小数表达例如 2% 写 0.02 vertical_bar: 可选的时间点作为硬性退出边界与 t1 取更早者 labels pd.DataFrame(indexevents.index, columns[ret, t1, bin]) for t0, row in events.iterrows(): # 先决定这条样本的最迟离场时间 t1 min(row[t1], vertical_bar) if vertical_bar is not None else row[t1] # 截取事件窗口内的价格切片 win close.loc[t0:t1] if len(win) 0: continue # 以事件起点价格为基准计算窗口内收益率路径 path win / close.loc[t0] - 1.0 # 找首次触达上下水平线的位置 hit_pt path[path pt_sl[0]].first_valid_index() hit_sl path[path -pt_sl[1]].first_valid_index() hits {} if hit_pt is not None: hits[hit_pt] 1 if hit_sl is not None: hits[hit_sl] -1 if hits: # 同一时刻不可能同时触达两条线但不同时刻可能先后触达 hit_time min(hits, keylambda x: win.index.get_loc(x)) exit_ret, label path.loc[hit_time], hits[hit_time] else: # 到垂直时间线都没触发打中性标签 0 exit_ret, label path.loc[t1], 0 labels.loc[t0] [exit_ret, t1, label] return labels这个实现里有三个细节值得抠。第一close.loc[t0:t1]是闭区间切片保证事件起点和退出时间都在窗口内。第二pt_sl是二元组止盈和止损幅度可以不对称比如固定止盈 2%、止损 1%这在实战里比对称水平线更常见因为很多策略承受不起对称回撤。第三先触达的原则是按win.index.get_loc比较时间位置不是按收益率大小排序这符合“先到先得”的物理直觉。跑这个函数之前确保events里的索引与close的索引是同一时区、同一频率。我这里先说结论两个索引只要一个带 UTC 一个不带loc切片就会返回空整个输出全是 NaN。这是个高频踩坑点后面第 5 章还会展开。3.2 分数差分d0.1 与 d1 之间藏着半条命很多人第一次接触分数差分fractional differentiation时都会问为什么不能直接一阶差分因为一阶差分虽然让价格序列平稳了但也把长期记忆抹掉了而金融时序里的动量与反转信息恰恰附着在这些记忆上。分数差分就是在平稳性和记忆保留之间取一个折中用 d0.1 这种介于 0 和 1 之间的阶数既减弱趋势又不至于把历史信息一刀切。def frac_diff_ffd(series, d, thresh1e-5): 固定窗口分数差分FFD保留长期记忆的同时尽量让序列平稳。 series: 待处理的 log 价格序列 d : 差分阶数0 d 1d 越大越接近一阶差分 thresh: 权重截断阈值权重绝对值小于它直接丢弃控制运算量 # 递推生成差分权重系数本质是二项式展开的系数 w, k [1.0], 1 while True: w_k -w[-1] * (d - k 1) / k if abs(w_k) thresh: break w.append(w_k) k 1 w np.array(w)[::-1] # 翻转权重让点积顺序与时间轴对齐 # 滚动窗口内做权重点积rawTrue 传 numpy 数组以提升速度 def _ffd_apply(x): if len(x) len(w): return np.nan return np.dot(x[:len(w)], w) return series.rolling(len(w)).apply(_ffd_apply, rawTrue)这个实现的精髓在于权重递推。w_k -w[-1] * (d - k 1) / k看起来像公式推导其实是二项式系数的递推关系每迭代一次就多生成一个权重。d 越小权重衰减越快保留的记忆越短d 越大权重尾巴拖得越长窗口也越长。thresh是截断阈值设得越大截断越早运算越快但信息损失越多。我一般先从1e-5试如果序列仍明显不平稳再下调到1e-7代价是窗口变长、计算变慢。我在实际使用这条函数时有个习惯先打印权重向量w看一眼。如果权重衰减到第 10 个系数还有明显数值说明这段序列的记忆长度较深d 取当前值没问题如果权重在第 5 个系数就趋近于 0说明这个序列本身记忆很短d 可以再调大一点不会丢失太多信息。3.3 特征重要性MDI、MDA、SFI 用同一个模型问三个问题第 6 章的特征重要性练习在仓库里经常以对比形式出现。书里介绍了三种思路MDImean decrease impurity、MDAmean decrease accuracy和 SFIsingle feature importance。很多人把这三种混在一起其实它们问的是三个完全不同的问题。MDI 问“这个特征在树分裂时贡献了多少纯度下降”基于训练集内部的增益统计最容易出现有偏——高基数特征天然占便宜。MDA 问“打乱这个特征后模型在测试集上的表现掉多少”基于样本外验证更可靠但计算量大。SFI 问“单独用这一个特征建模能到多少分”用来判断特征独立携带信息的能力常作为降维前的快速筛选。from sklearn.ensemble import RandomForestClassifier from sklearn.inspection import permutation_importance model RandomForestClassifier( n_estimators100, max_depth5, min_weight_fraction_leaf0.05, random_state42 ) model.fit(X_train, y_train) # MDI直接用 sklearn 内置的特征重要性 mdi pd.Series(model.feature_importances_, indexX_train.columns).sort_values() # MDA随机打乱单特征观察测试集分数下降量 mda permutation_importance( model, X_test, y_test, n_repeats30, random_state42 ).importances_mean mda pd.Series(mda, indexX_train.columns).sort_values()这段代码有两个参数值得注意。n_estimators100对一个合成数据练习来说足够稳定但真实数据建议提到 500 甚至 1000否则 MDI 的方差会偏大排序不稳定。min_weight_fraction_leaf0.05是 AFML 里常用的设置它限制了叶子节点的最小样本权重比例防止树对少数样本过拟合比直接设min_samples_leaf更贴合带权样本的场景。SFI 的代码不用另写新函数只需循环每个特征单独训练一个模型sfi {} for col in X_train.columns: clf RandomForestClassifier( n_estimators50, max_depth3, random_state42 ) clf.fit(X_train[[col]], y_train) sfi[col] clf.score(X_test[[col]], y_test)SFI 分数低不代表特征没用因为一个特征单独建模的分数和它在组合中的贡献是两回事。书里强调的是SFI 低但 MDA 高说明该特征需要与其他特征交互才能发挥作用SFI 高且 MDA 低说明该特征独立有效但可能在模型中被别的特征替代了。你按这个逻辑去看这个仓库里的输出就不会对结果差异感到困惑。4. 从书中公式到可运行代码元标签与交叉验证的落地路径第 3 章和第 5 章解决“怎么打标签、怎么差分”但真正决定一个策略能否上线的是标签怎么用进模型、验证怎么避免数据泄漏。这一节把书里的元标签和净化交叉验证两个核心方法落到代码层面。4.1 元标签两层模型比一层模型多留一条后路元标签meta-labeling是 AFML 里一个值得反复品的方法。传统做法是训练一个模型直接预测方向赢了就是赢了。元标签的思路是分两层第一层模型预测方向第二层模型只回答一个问题——“第一层这个方向到底该不该跟”。第二层模型不关心方向只关心第一层预测正确与否相当于给策略加了一道风控闸门。def meta_labeling(features, side_labels, side_model, size_model): side_model : 第一层预测方向输出三分类1 / 0 / -1 size_model : 第二层只在第一层有方向偏好时训练 # 第一层预测方向 side_pred side_model.predict(features) # 只取第一层给出方向的样本第二层看它们是否被验证正确 mask side_pred ! 0 secondary_X features[mask] secondary_y (side_labels[mask] side_pred[mask]).astype(int) # 第二层输出的是“要不要采纳第一层信号”的概率或类别 size_model.fit(secondary_X, secondary_y) signal_prob size_model.predict_proba(features)[:, 1] return side_pred, signal_prob这里的核心技巧是把第二层训练样本限制在“第一层有方向判断”的集合里而不是全部样本。原因在于第一层预测为 0 的样本本来就不参与交易第二层没必要为它们学习权重。secondary_y的构造方式很朴素第一层预测方向和真实标签一致记 1不一致记 0第二层就拟合这个二分类目标。实际使用中第一层输出方向后我不会直接按方向下单而是把signal_prob当成仓位系数。比如方向为多头且signal_prob大于 0.6才下 70% 的仓位低于 0.4 则不下单。这种做法比单纯用方向信号更有弹性回撤曲线也会平滑不少。这正好呼应书里“在二级模型上做组合优化”的思路。4.2 Purged K-Fold交叉验证里不能忽略时间顺序金融时序做交叉验证最忌讳的是随机 K-Fold。因为样本在时间轴上有重叠训练集和测试集如果靠得太近测试集里其实已经泄漏了训练集的信息回测分数会虚高。书里给出的方案是 purged K-Fold在切分时把测试集前后一段时间的样本从训练集里剔掉这段区间称为 embargo禁运期。def purged_kfold(X, n_splits5, embargo0.01): 净化 K-Fold按时间顺序切分剔除测试集前后的重叠样本。 X : 特征 DataFrame必须按时间排序 n_splits: 折叠数 embargo: 测试集前后各剔除多少比例作为缓冲 n len(X) fold_size n // n_splits indices np.arange(n) for i in range(n_splits): test_start i * fold_size test_end (i 1) * fold_size test_idx indices[test_start:test_end] gap max(1, int(embargo * n)) # 去掉测试集前后各 gap 个样本避免信息泄漏 train_mask np.ones(n, dtypebool) train_mask[max(0, test_start - gap):test_end gap] False train_idx indices[train_mask] yield train_idx, test_idx这段实现的逻辑是按时间索引切分而不是随机抽样。gap就是禁运期它的大小取决于标签时间窗口的长度。如果某个样本的标签用了未来 5 天的收益那 embargo 至少应覆盖 5 天对应的样本条数否则测试集前 5 天的样本仍然跟训练集重叠。常见做法是先看练习里t1列的最大值也就是样本最长生命周期。然后让embargo * n大约等于这个天数对应的行数比如样本标签最长 5 天、每天 1 个样本那embargo5/n。这个参数设小了验证结果虚高但不自知设大了训练样本变少模型方差变大。这个仓库练习中默认的 embargo 通常偏保守你可以按这个思路自行调整。5. 避坑指南复现 AFML 练习的五个常见血泪点跑这个仓库真正的拦路虎不是公式而是环境和数据细节。下面这五个坑我至少踩过其中四个每条按现象、原因、解决的顺序写。5.1 现象notebook 运行时频繁报警某些老 API 直接报错打开 notebook 后会看到大量 FutureWarning甚至df.append直接 AttributeError。原因很直接仓库代码在 pandas 0.25 时代写成而你环境里装的是 pandas 2.x。旧 API 在 1.x 里还能兼容运作到了 2.x 就被移除。解决按 2.1 节的版本区间重建环境。pandas 控制在 1.3 到 1.5 之间不要用 2.x。如果因为其他项目必须用新 pandas就单独建环境给这个仓库互不干扰。从那以后我每次拿到老仓库第一件事永远是查 pandas 和 sklearn 版本。5.2 现象终端里能 importnotebook 里 ImportError终端执行python -c import pandas没问题notebook 里却是 ModuleNotFoundError。原因是 Jupyter 默认启动的 kernel 指向了 base 环境你在 afml_env 里装的包不在 base 的搜索路径里。解决安装 ipykernel 并注册内核然后手动切换。命令是python -m ipykernel install --user --name afml_env在 notebook 右上角选择 afml_env。如果已经跑过重启 kernel 再重新执行。之前我因为这个坑白调了半小时环境变量最后发现只是 kernel 没切。5.3 现象triple_barrier 输出全是 NaN函数写了数据也生成了结果bin列全是空值。原因通常是事件索引和价格索引用的是不同时区。比如close是带 UTC 的时间戳而events索引是不带时区的本地时间loc[t0:t1]切片匹配不上返回空窗口。解决生成事件索引时统一时区。最简单的办法是把两个索引都先tz_localize(None)去掉时区或者统一用tz_convert(UTC)转成同一时区。另外交易日历和自然日混用也会导致同样问题确保两份索引的频率一致别让loc去解析周末。5.4 现象某个 cell 一跑就卡死任务管理器内存飙升分数差分的rolling().apply()如果数据量大比如几十万条速度会慢到让人怀疑死机。原因是apply是按行逐条调 Python 函数计算量随窗口大小线性增长而分数差分的窗口可能长达几十甚至上百条。解决第一优先缩小数据规模先用 1 万条以下验证逻辑。第二如果必须跑全量把rawTrue打开传 numpy 数组能提升不少速度。第三如果还不够快把权重向量预先算好用np.convolve做一次性卷积替代循环。注意np.convolve的结果需要对齐时间索引别直接塞进原序列。5.5 现象代码跑出的结果和书上公式对不上特征重要性那个练习经常这样MDI 结果看起来和 MDA 差不多但排序完全不同或者 SFI 高得离谱。原因是这三种方法测的本来就是不同的东西书里理论推导和练习代码不是一一对应的。MDI 基于训练集内部统计MDA 基于测试集置换损失SFI 就是单特征建模三个分数不能放一起比较绝对值。解决先明确你“要什么”。要解释模型内部机制看 MDI要评估样本外贡献看 MDA要快速筛特征候选看 SFI。这三者排序不一致是正常现象别把代码当 bug 去查。真要对齐公式先回到书里对应章节的定义式逐行核对代码里的统计口径。6. 进阶验证一法用合成数据把每个练习过筛子这仓库里最值钱的不是答案本身而是验证答案的方法。我用三招验证每招都能在几分钟内抓到隐藏问题。第一招构造已知答案的极简数据。以三重障碍为例手动构造一条价格序列先涨 2%再横盘再跌 2%然后给事件设止盈止损各 1.5%。正确的标签必然是先触达止盈打出 1如果函数输出 0 或 -1说明要么基准价格取错要么切片方向反了。这种测试比对着真实行情猜结果高效得多。第二招检查输出规模是否符合理论。分数差分做完后打印权重向量验证权重是否按二项式系数衰减。d0.1 时权重衰减快窗口短d0.9 时权重衰减慢窗口长。如果权重在第 3 个就归零说明thresh设太大丢弃了有效信息如果窗口长到覆盖整个序列说明 d 太大或 thresh 太小结果会趋近于一阶差分平稳性目标反而落空。第三招锁定随机种子做回归一致性。把random_state固定成同一个整数同一个 notebook 从头到尾跑两遍比较输出的关键列是否完全相等。特征重要性练习尤其适合这招因为树模型有随机性种子不一致时两次结果排序可能完全不同你以为在查 bug其实只是随机波动。固定种子后如果再跑出差异那大概率是代码里有隐藏的全局状态在串扰。从那以后我每次拿到 AFML 相关的 notebook都会强制先走一遍这三板斧再谈改参数、换数据。这习惯帮我挡下过不少低级错误省下的时间远比花掉的多。希望帮到你。本文还有配套的精品资源点击获取