简介:这份资源面向已具备一定Python与量化金融基础、希望动手实践《金融机器学习进展》一书核心方法的学习者,提供书中选定练习的实验性解决方案。内容围绕监督学习中的标注与元标注、金融交叉验证、样本权重、分数阶差分特征以及Tick、成交量与美元成交量bar构建等主题展开,以Jupyter Notebook形式组织实验过程,并配套src工具脚本与可复用的环境配置,便于读者对照书中理论逐章复现与调试。资源包共43个文件,以18个py脚本、6个ipynb笔记本为主,辅以rst文档、Makefile、配置文件及示例数据,压缩包约2.8MB,目录按notebooks、src、docs、data等模块划分,结构清晰。已有342人学习下载,适合作为金融机器学习练习的参考实现与排错对照材料。
1. 从一份练习仓库说起:金融机器学习不是调包,是把标签、权重和交叉验证重新想一遍
如果你做过量化建模,大概都经历过这种落差:sklearn 的fit/predict跑得飞快,回测曲线漂亮得像假的,一上实盘就原形毕露。问题往往不在模型,而在建模之前的那几步——标签怎么打、样本怎么加权、交叉验证怎么切。这份 Adv_Fin_ML_Exercises 仓库,就是围绕一本金融机器学习专著里的习题做的实验性解答集合,主体是一批 Jupyter Notebook,把「金融数据特有的坑」拆成一个个可以跑、可以改的小实验。它不适合想找现成策略的人,适合已经会用 pandas 和 sklearn、但被「回测很美、实盘很惨」折磨过的从业者,以及想系统补齐金融建模方法论的学生。下面按「这是什么 → 怎么跑起来 → 每个实验在解决什么 → 哪里会翻车」的顺序拆。
2. 把仓库跑起来:环境、目录与 Notebook 执行顺序
2.1 先看清仓库的组成与依赖边界
这类习题仓库通常不是一个大而全的工程,而是一组松散的 Notebook,每个文件对应书里一个主题。拿到手第一件事不是急着jupyter notebook,而是先摸清目录结构和依赖范围。常见做法是先看根目录有没有requirements.txt或environment.yml,再看 Notebook 的命名是否带章节号。命名带编号的,执行顺序基本就是阅读顺序;命名是主题词的,就得自己判断依赖关系——比如「多标签分类」的实验往往要用到前面「标签构造」实验里生成的 DataFrame。
依赖上要特别留意两点。第一,金融机器学习类仓库经常依赖较老的scikit-learn和pandas版本,新版 API 改名(比如sklearn.cross_validation早已移除)会直接让 Notebook 在第一个 cell 就报ImportError。第二,部分实验会用到statsmodels、scipy、matplotlib,个别会碰numba做加速。我的习惯是单独建一个虚拟环境,不要污染主环境,因为这类仓库的版本约束经常和你在跑的生产项目冲突。
# 建独立环境,Python 版本按仓库 README 或 Notebook 里的内核信息选 python -m venv venv_finml source venv_finml/bin/activate # Windows 用 venv_finml\Scripts\activate # 先装基础三件套,版本不要一上来就拉最新 pip install "numpy<2" "pandas<2.2" "scikit-learn<1.4" \ matplotlib statsmodels scipy jupyter # 如果仓库带 requirements.txt,优先用它,但先看一眼里面有没有钉死的老版本 pip install -r requirements.txt这段命令的关键在版本上限。numpy<2是因为不少老 Notebook 用了在 NumPy 2.0 里被移除的别名;pandas<2.2是为了避开一些append、inplace行为的变更;scikit-learn<1.4则是防止交叉验证相关类的签名变化。装完先别批量执行,挑一个最短的 Notebook 跑通,确认内核能起来、能 import,再往下走。
2.2 Notebook 的执行顺序与内核选择
Jupyter 最大的坑是「隐藏状态」:你以为按顺序跑完了,其实中间某个 cell 被跳过,变量还是上一次的残留值。复现这类习题仓库,正确姿势是每个 Notebook 开头执行Kernel → Restart & Run All,而不是一个个 cell 手动点。手动点出来的结果,你自己都说不清是这次算的还是上次算的。
# 启动时指定端口,避免和本机其他服务冲突 jupyter notebook --port 8899 --notebook-dir=./Adv_Fin_ML_Exercises # 更推荐用 nbconvert 无头执行,能一次性暴露所有报错 jupyter nbconvert --to notebook --execute \ --ExecutePreprocessor.timeout=600 \ --output executed_check.ipynb \ "第X章_某实验.ipynb"--ExecutePreprocessor.timeout=600是给那些要跑交叉验证、样本量又不小的实验留的时间余量,默认超时经常不够。--output指定输出文件,避免覆盖原始 Notebook——原始文件是你的「后悔药」,改坏了还能对照。执行完打开executed_check.ipynb,看每个 cell 的输出是否合理,尤其是中间打印的样本数、标签分布,这些数字对不上,后面所有结论都不可信。
提示:如果某个 Notebook 在
Restart & Run All下报错,但手动逐格能跑通,八成是 cell 之间有隐式依赖或随机种子没固定。先找np.random.seed和random_state参数,再查是否有 cell 依赖了未在当前文件定义的变量。
2.3 数据从哪来:自带样例与外部数据的替换点
习题仓库一般会自带小规模样例数据,或者用代码现场生成合成数据。合成数据的好处是可复现、不涉及真实行情,坏处是它太「干净」,很多金融数据的毛病(缺失、非同步、幸存者偏差)体现不出来。我一般会先跑通自带数据,确认逻辑无误,再把自己的数据按同样的列名和索引结构替换进去,做一次「换数据不换代码」的验证。
替换时最容易翻车的是索引。金融时序实验通常要求DatetimeIndex,而且很多函数默认按时间排序、按频率重采样。你直接塞一个带整数索引的 DataFrame 进去,代码不报错,但滚动窗口算出来的全是错的。替换前先做一次结构对齐:
import pandas as pd df = pd.read_csv("your_data.csv", parse_dates=["date"], index_col="date") df = df.sort_index() # 时间必须单调递增 df = df[~df.index.duplicated(keep="first")] # 去重,重复时间戳会让滚动窗口错位 assert df.index.is_monotonic_increasing print(df.index.freq) # 打印频率,None 说明不规则,后续重采样要显式指定parse_dates把日期列转成时间类型,sort_index保证单调,去重是因为行情数据偶尔会有重复时间戳,滚动窗口遇到重复索引会静默算错。df.index.freq打印出来如果是None,说明数据不是规则频率,后面凡是涉及resample或固定窗口的地方都要显式传freq参数,不能依赖推断。
3. 标签、样本权重与交叉验证:三个实验背后的方法论
3.1 为什么固定时间窗口打标签会骗人
书里反复强调的一个点:用「未来固定天数收益是否为正」来打二分类标签,看似直观,实则埋了两个雷。一是标签重叠——相邻样本的预测窗口大量重合,导致样本之间不独立,交叉验证的分数虚高;二是固定窗口忽略了「价格什么时候真正触及某个阈值」,一个 5 天后才涨的样本和一个当天就涨的样本被打了同样的标签,信息量完全不同。
仓库里对应的实验通常会用「三重障碍法」的思路:给每个样本设一个上障碍(止盈)、一个下障碍(止损)、一个时间障碍(最长持有期),看价格先碰到哪个,据此打标签。这个方法的实现要点是逐样本向前扫描价格序列,记录首次触及障碍的时间点。核心逻辑大致如下:
import numpy as np import pandas as pd def triple_barrier_labels(price: pd.Series, t_events: pd.DatetimeIndex, pt_sl=(2.0, 2.0), max_hold=20, vol_window=20): """pt_sl: 止盈/止损的波动倍数; max_hold: 时间障碍(bar数)""" vol = price.pct_change().rolling(vol_window).std() labels, t1 = {}, {} for t0 in t_events: if t0 not in price.index: continue idx = price.index.get_loc(t0) end = min(idx + max_hold, len(price) - 1) path = price.iloc[idx:end + 1] sigma = vol.iloc[idx] if not np.isfinite(sigma) or sigma == 0: continue upper = price.iloc[idx] * (1 + pt_sl[0] * sigma) lower = price.iloc[idx] * (1 - pt_sl[1] * sigma) hit_up = path[path >= upper] hit_dn = path[path <= lower] t_up = hit_up.index[0] if len(hit_up) else None t_dn = hit_dn.index[0] if len(hit_dn) else None # 先到者决定标签,都没碰到则时间障碍处按收益符号定 if t_up and (not t_dn or t_up <= t_dn): labels[t0], t1[t0] = 1, t_up elif t_dn and (not t_up or t_dn < t_up): labels[t0], t1[t0] = -1, t_dn else: labels[t0] = np.sign(price.iloc[end] - price.iloc[idx]) t1[t0] = price.index[end] return pd.Series(labels), pd.Series(t1)pt_sl是止盈止损相对波动率的倍数,max_hold是时间障碍的 bar 数,vol_window控制波动率估计的窗口。参数怎么调:波动倍数太小会让绝大多数样本都触发障碍、标签分布极端;太大则大量样本落到时间障碍、退化成固定窗口。我一般先用 1.5 到 2.5 之间试,跑完看标签分布是否接近均衡,再看t1(首次触及时间)的分布是否集中在时间障碍附近——如果九成样本都卡在max_hold,说明障碍设得太宽,等于没设。
注意:这个函数是逐样本循环,样本量大时会慢。生产里常见做法是向量化或用
numba加速,但习题阶段先保证逻辑正确,别一上来就优化。
3.2 样本权重与「不独立样本」的降权
标签重叠带来的直接后果是:同一段价格被多个样本共享,模型会过度拟合这段行情。书里的处理思路是给每个样本一个「唯一性」权重——一个样本的预测窗口里,有多少比例是它独占的。独占比例越低,权重越小。仓库里对应的实验会先算出每个样本的t1(首次触及时间),再统计每个时间点被多少个样本的窗口覆盖,最后反推权重。
实现上分两步。第一步构造「并发计数」:对每个样本的[t0, t1]区间,在时间轴上累加计数。第二步对每个样本,取其区间内并发计数的均值,取倒数作为平均唯一性,再归一化。常见做法是用 pandas 的区间展开配合groupby计数,或者直接对时间索引做累加。这里给一个可读性优先的版本:
def uniqueness_weights(t1: pd.Series, price_index: pd.DatetimeIndex): # 每个样本覆盖的 bar 数 bar_id = pd.Series(np.arange(len(price_index)), index=price_index) concurrency = pd.Series(0, index=price_index, dtype=float) for t0, t_end in t1.items(): if t0 not in bar_id.index or t_end not in bar_id.index: continue s, e = bar_id[t0], bar_id[t_end] concurrency.iloc[int(s):int(e) + 1] += 1 # 每个样本区间内的平均并发数,取倒数 avg_conc = {} for t0, t_end in t1.items(): if t0 not in bar_id.index or t_end not in bar_id.index: continue s, e = bar_id[t0], bar_id[t_end] avg_conc[t0] = concurrency.iloc[int(s):int(e) + 1].mean() w = pd.Series(avg_conc).rpow(-1) return w / w.sum()concurrency记录每个 bar 被多少样本的窗口覆盖,avg_conc是每个样本窗口内的平均覆盖数,取倒数就是「越独占权重越高」。归一化到和为 1 是为了后续和 sklearn 的sample_weight对接。参数上没什么可调的,真正要盯的是结果分布:如果权重高度集中在少数样本上,说明样本重叠严重,这时候要么拉长采样间隔,要么缩短预测窗口,别硬用权重去救。
3.3 交叉验证为什么不能随机切:Purged K-Fold 的落地
普通 K-Fold 在时序数据上是灾难:训练集里混进了测试集之后的信息,分数虚高到没有参考价值。书里给的方案是 Purged K-Fold——按时间分块,并且在训练集里「挖掉」与测试集标签窗口重叠的样本(purge),再在测试集两侧留一段缓冲(embargo),防止特征层面的信息泄漏。
仓库里对应的实验一般会实现一个自定义的交叉验证迭代器。核心是:先按时间把样本分成 K 段,每段轮流做测试集,训练集取其余段,然后对训练集里每个样本,检查它的[t0, t1]是否与测试集的时间区间重叠,重叠的剔除;再在测试集结束点之后留embargo个 bar 的样本也剔除。
def purged_kfold_splits(t0: pd.Series, t1: pd.Series, n_splits=5, embargo=0.01): idx = np.arange(len(t0)) fold_size = len(idx) // n_splits for k in range(n_splits): test_start = k * fold_size test_end = (k + 1) * fold_size if k < n_splits - 1 else len(idx) test_idx = idx[test_start:test_end] test_t0 = t0.iloc[test_idx].min() test_t1 = t1.iloc[test_idx].max() # purge: 训练样本窗口与测试窗口重叠则剔除 overlap = (t1 <= test_t0) | (t0 >= test_t1) train_idx = idx[overlap] # embargo: 测试集结束后一段缓冲也剔除 gap = (t1.index[-1] - t1.index[0]) * embargo train_idx = [i for i in train_idx if not (test_t1 < t0.iloc[i] <= test_t1 + gap)] yield train_idx, test_idxn_splits是折数,embargo是缓冲比例(相对总时间跨度)。overlap那行是 purge 的核心:只保留窗口完全在测试窗口之前或之后的训练样本。embargo 那段是防止测试集刚结束、特征还带着测试期尾巴的样本混进训练。参数上,embargo设太小起不到隔离作用,设太大会让训练集缩水严重,常见从 0.01 起步,看训练集剩余样本量再调。跑完一定要打印每折的训练/测试样本数,如果某折训练集只剩个位数,说明 purge 太狠或者样本本身太稀疏,得回头检查标签窗口是不是设得太长。
4. 避坑与排查:这类习题仓库最容易翻车的五处
4.1 现象:Notebook 第一个 cell 就 ImportError,提示某个 sklearn 子模块不存在
原因基本是版本错配。书写作年代较早,代码里可能还在用sklearn.cross_validation、sklearn.grid_search这类早已移除的路径,或者用了某个后来改名的类。解决不是无脑降版本,而是先定位:报错行是 import 还是调用?import 报错就查该模块在新版里的对应位置(model_selection、cross_validation的替代),调用报错就看签名变化。实在改不动,再在独立环境里钉到报错提示的版本区间,别动主环境。
4.2 现象:Restart & Run All 跑到一半报 KeyError,但手动逐格能过
这是隐藏状态依赖。某个 cell 依赖了上一次运行残留的变量,或者依赖了另一个 Notebook 里定义的函数。解决:先全局搜变量名,确认它在本文件里有没有定义;再看是否有%run或import其他 ipynb 的写法。养成每个 Notebook 开头集中 import、集中定义常量的习惯,中间不临时造全局变量。跑之前固定np.random.seed,否则同样的代码两次结果不同,排查时根本分不清是逻辑错还是随机性。
4.3 现象:交叉验证分数高得离谱,接近 1.0
先怀疑数据泄漏,别急着高兴。检查三处:特征里有没有用到未来信息(比如用当日收盘价算的指标却在开盘时预测);交叉验证是不是随机切的(时序数据必须用 purge 方案);标签窗口和特征窗口有没有重叠。最常见的翻车是「标准化在切分之前做了」——用全量数据算均值和方差,测试集的信息就漏进了训练。正确做法是把 scaler 放进 Pipeline,在每折内部 fit。
4.4 现象:三重障碍法跑完,标签几乎全是同一类
多半是障碍设得太窄或波动率估计有问题。pt_sl太小,价格稍微一动就触发障碍,标签被短期噪声主导;vol_window太短,波动率估计不稳,障碍宽度忽大忽小。解决:先把pt_sl调到 2 附近,vol_window不小于 20,跑完打印标签分布和t1的分布。如果t1大量集中在时间障碍,说明障碍太宽;如果标签严重偏斜,先看是不是数据本身单边行情,再考虑调整采样。
4.5 现象:换了自己的数据后,滚动窗口算出来的值明显不对
九成是索引问题。时间没排序、有重复时间戳、频率不规则但代码按固定窗口算,都会让滚动结果错位。解决:读入后立刻sort_index、去重、打印index.freq,不规则就显式resample到固定频率再算。另外注意时区,带时区和不带时区的索引混用,比较时会抛异常或静默出错,统一转成 naive 或统一时区再往下走。
5. 进阶用法:把单个 Notebook 的实验固化成可复用的验证流程
跑通单个 Notebook 只是第一步,真正有价值的是把「标签构造 → 权重计算 → purge 交叉验证 → 模型评估」这条链固化成一个小模块,让每次换数据、换参数都能一键重跑。我的习惯是抽一个finml_utils.py,把三重障碍、唯一性权重、purged splits 三个函数放进去,Notebook 只负责调用和可视化。这样改参数时不用在几十个 cell 里翻,也方便做参数扫描。
验证方法上,别只看准确率。金融二分类里类别往往不均衡,准确率会被多数类带偏。我一般同时看三个数:每折的样本数(确认 purge 没把训练集掏空)、标签分布(确认没退化成单类)、以及一个简单的基线对比——用「全预测多数类」的准确率做下限,模型跑不过这个基线就说明特征或标签有问题。下面这个小表是我常用的检查清单:
| 检查项 | 期望 | 异常时的动作 |
|---|---|---|
| 每折训练样本数 | 不少于总样本的 50% | 缩小 embargo 或缩短标签窗口 |
| 标签分布 | 两类比例在 3:7 以内 | 调整 pt_sl 或换采样区间 |
| 基线准确率 | 模型明显高于多数类基线 | 查特征是否含未来信息 |
| t1 分布 | 不全部堆在时间障碍 | 放宽障碍倍数 |
参数扫描时,pt_sl和max_hold是两个最值得动的旋钮,但别做网格暴力搜——金融数据样本量本来就小,搜出来的最优参数大概率是过拟合。我的做法是固定一组「经济上说得通」的参数(比如止盈止损对称、持有期不超过一个月),只在这个邻域里微调,看结果是否稳定。不稳定就说明这套标签本身不稳健,换参数救不回来。
血泪经验是:这类习题仓库最大的价值不是给你一个能用的策略,而是让你亲手把「随机切分 → 分数虚高 → 换 purge 后打回原形」这个过程走一遍。我第一次跑通 purge 交叉验证时,分数从 0.9 掉到 0.55,当时以为是代码写错了,查了半天才发现是之前的分数本来就是假的。从那以后我每次碰时序分类,都强制先跑一遍「随机切分 vs purge 切分」的对比,两个分数差得越多,越说明数据泄漏严重。希望这套流程能帮你在自己的项目里少走一遍弯路。
本文还有配套的精品资源,点击获取