简介《金融机器学习进展》Advances in Financial Machine Learning一书的配套练习集围绕书中选定的金融机器学习实验提供可运行解法以Jupyter Notebook为主要载体适合正在研读原著的量化研究者、金融工程学生以及具备一定Python基础的中高级数据分析人员。练习重点覆盖样本标注与元标签化、交叉验证、tick/成交量/美元成交量bar、分数差分特征、样本权重等核心主题。资源包共43个文件Python脚本用于实现bars.py、snippets.py等底层工具ipynb笔记本则可直接运行以复现实验另有rst文档、Makefile和配置文件分别承担项目说明、自动化命令与依赖管理。压缩包仅2.8MB轻量易部署已有339人学习下载。通过源码与notebook的对照读者能快速理解金融数据特征构造与模型评估细节项目采用src、notebooks、models、reports的模块化组织并提供make data、make train等自动化命令便于按流程复现实验同时收集了其他AFML开源项目链接便于进一步拓展研究视野。1. 习题库不是答案集我在尝试给这本书的练习搭一套可复用实验方案《金融机器学习进展》起手就扔出一个我很长时间没消化掉的结论金融数据不是独立同分布的默认机器学习流程在这里全是坑。我照普通教材的习惯把价格序列直接切出训练集和测试集用标准交叉验证调了一个随机森林回测准确率漂亮得可疑。后来照着书中思路补上三重屏障标签、去重、purged交叉验证同一套数据、同样的模型分数立刻掉到接近随机——那个“漂亮”其实来自数据泄漏。这篇文章不是答案手册是我反复做这些练习时沉淀下来的实验性方案解决的是“这本书的习题怎么在本地跑通、参数怎么设、哪些地方必然翻车”。适合正在研究量化策略又想摆脱“回测自欺欺人”的从业者。你能拿到一套从合成数据、事件标签到去重交叉验证的完整路径以及几条让练习结果真正可信的排查技巧。2. 先解决数据问题用合成价格序列构造可复现的金融ML实验环境2.1 为什么不能直接拿真实行情做练习题练习的目的是把方法论的每个环节固定住再谈结论。真实行情一上来就带三个问题非平稳、样本量不足、数据里混着各类幸存者偏差。我用过某平台十年级别的分钟线做练习结果发现不同复权因子的处理方式、停牌日的缺口、分红导致的跳空都会让标签生成的结果漂移。最麻烦的是真实数据里你根本不知道“真实信号”长什么样——模型学到的是市场微观结构噪声还是规律没法判定。合成数据好在生成过程完全受控漂移、波动、噪声都可以人为设定结果可复现换一台机器重跑结论一致。常见做法是先在一个无信号的价格序列上验证流程再注入已知信号检验模型能不能找回它。这一步过了才轮到拿真实行情验证。某导师带项目时也是这套顺序先模拟后实盘省掉了大量“回测好、实盘崩”的归因时间。2.2 造一个带漂移和波动的价格序列几何布朗运动的最小实现第一步我会生成一条足够长的日频价格序列用几何布朗运动模拟随机游走。它不真实但它有一个好处能完全控制数据的信噪比。实现代码如下import numpy as np import pandas as pd def gbm_path(n_days2520, seed42): rng np.random.default_rng(seed) dt 1.0 / 252 # 一个交易日 annual_drift 0.05 # 年化漂移 5% annual_vol 0.20 # 年化波动 20% daily_shocks rng.normal( annual_drift * dt, annual_vol * np.sqrt(dt), sizen_days ) price 100 * np.exp(np.cumsum(daily_shocks)) idx pd.bdate_range(2010-01-04, periodsn_days) return pd.Series(price, indexidx, nameclose)这里的关键参数是年化漂移annual_drift和年化波动annual_vol。漂移决定序列的长期趋势5% 的年化漂移在十年尺度上会让价格大约涨到初始值的 1.65 倍但路径上仍然充满随机性波动控制噪声幅度20% 是常见标的的量级。dt 1/252把年化参数转成日频np.cumsum是收益率累加最后对 100 取 exp 得到价格路径。如果你要在练习里验证特征有效性可以手动在daily_shocks里注入一段已知信号模型如果能找回这段信号说明流程没问题找不回问题多半出在标签或交叉验证上。2.3 事件栅栏与时间栅栏CUSUM过滤器和训练样本怎么切很多教材直接用固定频率采样比如每天一条样本。金融数据的问题在于大部分交易日并没有新信息到达固定频率会把大量噪声样本灌进模型信噪比进一步恶化。书中强调“事件驱动的采样”最常用的就是 CUSUM 过滤器只有当价格累计偏离超过阈值时才产生一个事件作为样本的t0。def cusum_events(close, hNone): close close.sort_index() if h is None: h close.pct_change().std() events [] s_pos, s_neg 0.0, 0.0 last_price close.iloc[0] for ts, price in close.items(): ret price / last_price - 1 s_pos max(0.0, s_pos ret) # 正向累积偏离 s_neg min(0.0, s_neg ret) # 负向累积偏离 if s_pos h or s_neg -h: events.append(ts) last_price price s_pos, s_neg 0.0, 0.0 return eventsh是触发阈值。设得越小事件越密集样本越多但噪声也越多设得太大事件稀疏到没法训练。我一般先拿收益率序列的标准差做底再按需放大到 1.5 倍或 2 倍。这个过滤器同时在做“时间栅栏”它把连续的日频数据切成了离散的、信息密度更高的事件。后续每个事件的t0由这里产生t1则交给标签逻辑决定。训练集和验证集的切分必须同时考虑t0和t1两个时间点只按t0切会漏掉仍在持仓的样本——这正是 purge 的起点后面第四章展开。3. 三重屏障标签把“未来涨跌”变成一个抗噪的监督信号3.1 基于路径的标签为什么比固定时间窗可靠初次接触这本书时我习惯用未来 n 天收益率的正负做标签比如 5 天后涨了就给 1跌了给 0。这个做法在震荡行情里非常不稳定如果第 3 天已经涨了 5%第 5 天收盘却跌回原点模型学到的是一个纯噪声结果。固定时间窗只看终点忽略了路径上“已经触及目标”的信息。三重屏障的思路是把一根未来的价格路径放进一个矩形盒子上屏障是止盈线下屏障是止损线右屏障是最大持有期。价格路径先触碰哪条屏障就产生对应的标签和退出时间止盈给 1止损给 -1时间耗尽给 0。它实际上把“未来涨跌”这个模糊概念转成了“路径状态”的判别问题。这个标签生成过程是整套练习最值得重复实现的部分因为你一旦在真实数据上跑完会立刻理解为什么很多回测结果经不起推敲——不是模型弱是标签本身在泄漏或者丢失路径信息。3.2 三重屏障标签的Python实现我的实现如下输入价格序列和一组由 CUSUM 生成的t0时间点输出每个事件的标签、退出时间和t1。from pandas.tseries.offsets import BDay def triple_barrier(close, t0s, pt0.05, sl0.05, max_hold20): close close.sort_index() last_ts close.index[-1] records [] for t0 in t0s: t1 min(t0 BDay(max_hold), last_ts) window close.loc[t0:t1] ref window.iloc[0] # t0 收盘价作为参考 high_ret window.max() / ref - 1 low_ret window.min() / ref - 1 # 用路径判断先触及上屏障 - 1下屏障 - -1否则 0 if high_ret pt and low_ret -sl: label 0 # 同一天上下都触达按无信号处理 elif high_ret pt: label 1 elif low_ret -sl: label -1 else: label 0 if label 1: exit_ts window.idxmax() elif label -1: exit_ts window.idxmin() else: exit_ts t1 records.append({t0: t0, t1: t1, label: label, exit: exit_ts}) out pd.DataFrame(records).set_index(t0) return outpt和sl是对称的 5%即盈利 5% 或亏损 5% 就触发退出max_hold20是最长持有 20 个交易日。需要注意我用BDay(max_hold)而不是Timedelta(days20)因为交易日不能用自然日换算。代码中“同一天上下都触达按 0 处理”是我自己的保守约定因为日线高低点在同一根 K 线里的先后顺序无法判断如果你有分钟线可以改成按时间顺序判断先触达哪边会更精确。参数设置建议pt/sl不要都设到 1% 以下否则大多数样本会被随机波动触发标签的噪音会盖过信号本身。3.3 样本唯一性权重处理重叠标签三重屏障天然带来一个副作用相邻事件的持有窗口会重叠。一个样本的t0到t1横跨 20 个交易日另一个样本的窗口可能和它重合 15 天。这些样本不独立如果直接塞进模型等同于把同一段信息重复计数会让交叉验证结果虚高。解决办法是计算每个样本的“平均唯一性”权重某天被多个事件覆盖每个事件的贡献就要打折。实现如下from collections import defaultdict def average_uniqueness(t0s, t1s): coverage defaultdict(list) # 展开每个事件的持仓区间统计每个交易日的覆盖事件数 for i, (start, end) in enumerate(zip(t0s, t1s)): for day in pd.bdate_range(start, end): coverage[day].append(i) weights [] for i, (start, end) in enumerate(zip(t0s, t1s)): days pd.bdate_range(start, end) uniq [1.0 / len(coverage[d]) for d in days] weights.append(np.mean(uniq)) return np.array(weights)权重越小说明这个样本和其他样本重叠得越多信息越冗余。这个值可以有两种用法一是作为sample_weight传给模型训练二是在交叉验证的评估指标里按权重计算加权准确率让重叠多的样本对结果的影响变小。我在练习里通常两个都用效果最明显的是特征重要性环节——不加权重时那些在重叠期反复出现的特征会被高估加权后排序会变化很大。这也是为什么第四章谈特征重要性时必须先处理重叠问题。4. 去重后的交叉验证与特征重要性调参前先堵住泄漏4.1 Purged K-Foldgap怎么设、为什么不能走默认CV金融数据不能直接用KFold。原因很直接训练集里某个样本的标签窗口如果跨到了验证集的时间段它已经“见过”验证集的信息了。默认 KFold 按行切分完全不关心时间重叠跑出来的分数天然偏高。我在一个模拟数据上测过普通 KFold 的验证准确率比 purged 版本高出近 10 个百分点差异几乎全部来自泄漏。Purged K-Fold 做两件事一是把训练集里和验证集时间窗口重叠的样本剔除二是再留一段冷却期embargo防止时间相邻的样本在序列相关性上仍然藕断丝连。embargo的长度我一般设 5 到 10 个交易日具体取决于max_hold——持有期越长embargo要越大。我的实现如下import numpy as np import pandas as pd from pandas.tseries.offsets import BDay def purged_cv_indices(t0, t1, n_splits5, embargo5): t0 pd.Series(t0).sort_values().values t1 pd.Series(t1).values n len(t0) test_blocks np.array_split(np.arange(n), n_splits) for blk in test_blocks: val_t0_start t0[blk[0]] val_t1_end t1[blk[-1]] BDay(embargo) # 训练样本必须完全在验证集窗口之外 trn_mask (t1 val_t0_start) | (t0 val_t1_end) trn_idx np.where(trn_mask)[0] yield trn_idx, blk这个版本按样本序号切块。要注意如果每个事件的持有期长短差异很大按序号切可能让某些折叠的t0跨度特别宽导致训练样本大幅减少。我一般会再按“时间块”切——先按t0的时间分位数把数据分成 5 段每段作为验证集比按序号切更稳。embargo的单位是交易日用BDay而不是Timedelta这是容易踩的细节。另外函数里t0 val_t1_end这个条件保证训练样本开始于验证集窗口结束之后和“t1 早于验证集开始”的条件一起把两个方向的重叠都排除干净。4.2 用MDA/MDI评估特征别迷信默认feature_importances随机森林自带的feature_importances_是基于训练集杂质减少算出来的。问题是它在有重叠样本时偏向高基数特征而且不告诉我们“这个特征如果失效会损失多少”。书里推荐的做法是 MDI 和 MDA 搭配MDI 看训练阶段的杂质减少占比MDA 看特征被打乱后模型性能的下降幅度。我实际练习中 MDA 更有信息量因为它用的是验证集表现。MDA 的核心代码不复杂但有几个细节必须注意from sklearn.metrics import accuracy_score def mda_importance(model, X, y, cv_indices, n_repeats10): fitted [] base_scores [] # 先在每个验证集上算出基准分数并保留已训练的模型 for trn, val in cv_indices: model.fit(X.iloc[trn], y.iloc[trn]) fitted.append(model) base_scores.append(accuracy_score(y.iloc[val], model.predict(X.iloc[val]))) importance {} for col in X.columns: drops [] for fold, (trn, val) in enumerate(cv_indices): X_val_orig X.iloc[val].copy() orig_col X_val_orig[col].copy() for _ in range(n_repeats): X_val X_val_orig.copy() X_val[col] np.random.permutation(orig_col.values) y_pred fitted[fold].predict(X_val) drops.append(base_scores[fold] - accuracy_score(y.iloc[val], y_pred)) importance[col] np.mean(drops) return pd.Series(importance)打乱方式需要注意np.random.permutation是对该列的值整体乱序会破坏时间序列特征的自相关性导致分数虚高。我在练习中处理滞后类特征时会用“按块打乱”——把序列切成几段再整段交换保留块内的时序结构。n_repeats10是为了得到稳定的均值但如果你发现某个特征的drops标准差和均值差不多大说明这个特征的重要性不显著不用过度解读。这个方法另一个价值是可以画特征重要性的置信区间——只给点估计的特征重要性排序在金融数据上参考价值有限。4.3 把purged CV接进GridSearch一个不小于全套的最小调参流程GridSearchCV默认用KFold不能直接用。我的做法是把上面写的purged_cv_indices包成一个兼容 scikit-learn 的交叉验证器然后交给GridSearchCV使用。from sklearn.model_selection import BaseCrossValidator class PurgedCV(BaseCrossValidator): def __init__(self, t0, t1, n_splits5, embargo5): self.t0 t0 self.t1 t1 self.n_splits n_splits self.embargo embargo def split(self, X, yNone, groupsNone): yield from purged_cv_indices(self.t0, self.t1, self.n_splits, self.embargo) def get_n_splits(self, XNone, yNone, groupsNone): return self.n_splits使用时t0、t1必须是和X行顺序一致的时间数组。最容易出错的点是如果X里有滞后特征比如“过去 5 日收益率”它们已经包含了旧信息但这不算泄漏泄漏只发生在“特征用了 t0 之后的数据”。我在调参前会先人工检查一遍特征构造代码确认没有任何一列是从t0之后的时间段取数的然后再跑GridSearchCV(..., cvPurgedCV(...))。冷却期embargo在调参时是最敏感的参数。我会先跑一组固定模型的对比embargo从 0 到 20 逐步增大观察验证分数什么时候开始稳定。如果 0 天和 10 天的分数差很多说明泄漏严重模型本身不太可信。如果分数几乎不动才说明当前标签的持有期较短泄漏影响不大。5. 避坑专题做这些练习时最常翻车的5个点5.1 前视偏差测试集里混进了未来的“答案”现象第一次完整跑完三重屏障标签建模后验证集准确率超过 90%我当时以为发现了稳赚策略。进一步检查才发现特征表里有一列是用merge_asof取的是未来 5 日的成交量均值方向参数写反了测试集的每一行都已经包含未来信息。原因时间特征拼接时merge_asof的direction参数没理解透默认行为是向后寻找最近匹配如果数据没有严格排序就会把未来数据拉到当前时刻。解决在每个事件t0生成特征时我强制特征表只保留t0之前的时间戳并且所有特征构造函数接受一个asof参数内部统一用merge_asof(directionbackward)。我还在测试集上写了一个断言特征表的最后时间戳必须小于等于对应样本的t0一旦发现特征时间晚于t0直接抛异常。这个断言后续帮我挡住过多次重构引入的回归。5.2 特征穿越同一根K线既出特征又出标签现象模型用“当日收盘价相对 20 日均线的偏离度”做特征三重屏障又用当日收盘价作为参考价算止盈止损。结果特征和标签在同一天共享同一个价格点位AUC 高得离谱换到频率稍低的数据上立刻崩。原因特征窗口和标签窗口没有设置隔离带。t0当天的收盘价被同时用作了特征的输入和屏障的参考点。解决我约定特征只能使用t0之前已经收盘的数据标签参考价取t0前一日的收盘价。代码上把特征区间定义为[:t0 - BDay(1)]标签区间为[t0:]两个区间不相交。这个约定看起来损失了一天信息但对日频数据来说换来的是“模型真正预测未知”的确定性值这个价。5.3 bootstrap采样毁了去重随机森林默认行为是坑现象加了 purged CV 后随机森林的表现几乎没有变化。我一度怀疑 purge 逻辑写错了后来检查才发现数据去重只作用在“训练集的选择”上而随机森林内部的 bootstrap 采样又对样本做了有放回抽样把去重效果冲掉了。原因RandomForestClassifier默认bootstrapTrue每棵树从训练集里随机有放回地抽样重叠样本再次被重复使用信息泄漏又回来了。解决模型参数里显式设置bootstrapFalse。代价是每棵树的训练数据少了如果数据量不大可以适当增加树的数量来弥补随机性。如果你确实需要 bootstrap就用max_samples0.7这类限制并明确知道这是在做样本扰动而不是在解决去重问题。5.4 归一化泄漏全样本统计量是最大的暗门现象把收益率序列做标准化后再构造特征验证集分数一直稳定在高位我用滚动窗口方式重跑一遍后分数掉了将近 8 个百分点。原因我在构造特征时用StandardScaler.fit拟合了全样本的均值和标准差验证集的信息在特征构造阶段就已经进入训练数据这种泄漏比模型层的泄漏更难排查。解决特征标准化必须在每个 fold 内部完成在训练集上fit在验证集上只transform。更简单的方式是直接用滚动标准化——用t0之前的窗口计算均值和标准差这个方案不需要在交叉验证里手动处理也不容易写错。我在练习里默认使用滚动窗口因为它更贴近真实交易中“当时只能知道当时的信息”的约束。5.5 随机种子没固定对比实验全是玄学现象同一个实验跑两次一次 AUC 0.52一次 0.58。某个参数组合在某次运行里表现特别突出我差点以为找到了最优配置。原因合成数据生成、样本打乱、模型初始化都用到随机数没有固定种子时实验差异被随机性主导任何调参结论都不可复现。解决我在每个实验脚本开头固定三处种子np.random.seed、random.seed和模型内部的random_state。更进一步同一个参数组合至少跑 5 个不同种子用均值和标准差来判断差异是否真实存在。这个习惯在练习阶段就能帮你省掉后期大量归因时间。单独一次运行的结果不值得相信这在金融ML里是一条铁律不是选择题。6. 从练习走向自己的策略验证流程最后留给你的检查单6.1 把练习代码包装成一条可回归的流水线练习做完后我把所有散落的函数整理成一个固定顺序的流水线数据生成、事件采样、标签构造、特征标准化、purged交叉验证、MDA特征重要性。每个环节的输入输出都用固定时间戳对齐并加上了防泄漏断言。这样每次改动只影响一个环节其余结果可以对比。这个投资会在你换成真实行情时得到回报——真实数据的问题往往不在模型而在某个环节悄悄用错了时间方向。6.2 一张验证检查单怎么确认实验方案没白做我在提交实验结果前会过一遍这张表任何一条不过结果都不对外输出检查项通过标准无信号数据基准合成数据无注入信号时准确率置信区间包含 50%已知信号找回注入已知信号后模型表现显著提升特征时间断言所有特征时间戳均不晚于对应样本 t0交叉验证无泄漏embargo 从 0 到 20验证集分数波动在 1% 以内随机种子稳定性同一配置 5 个种子的结果标准差低于 1 个百分点这套检查单最大价值是拦截那些“看起来能赚钱实际上是 bug 凑出来的结果”。我第一次完整跑通全部流程时真正可信的模型只有无信号基准——那是我在这条路上收获最大的一次经历。别把回测当黑匣子先把“模型不泄漏时是什么表现”这个基线固定住再谈参数、谈策略、谈上线。养成这个习惯后你会发现自己对金融机器学习的判断力比以往任何一次都要清晰。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?