简介:这份《金融机器学习实践》PDF资料面向金融从业者、数据科学学习者与量化投资爱好者,聚焦机器学习在金融场景中的落地应用,帮助读者理解信用风险评估、股票预测、客户行为分析与欺诈检测等典型问题的建模思路。资源为单份PDF文档,压缩包约2.9MB,内容围绕金融数据挖掘、特征工程、模型训练与评估展开,涉及逻辑回归、决策树、随机森林、支持向量机、Gradient Boosting等常用算法,并延伸至Model Serving、GraphX等工程化概念,适合作为入门到进阶的体系化参考。目前已有394人学习下载,读者可从中获取金融机器学习项目的整体框架、算法选型逻辑与数据预处理要点,快速建立从业务问题到模型落地的认知路径,也可作为课程学习或实际项目的辅助材料。
1. 金融机器学习实践:从一份 PDF 到一套能跑起来的因子流水线
很多人第一次接触《金融机器学习实践》这类资料,是被“机器学习”四个字吸引进来的,结果翻了几页发现满屏是收益率、夏普、回撤、标签重叠,跟平时做的图像分类、推荐排序完全不是一回事。金融机器学习的核心矛盾在于:样本量少、信噪比极低、数据非平稳,而且你拿到的历史数据里藏着大量未来信息,稍不注意就会做出一个回测漂亮、实盘崩溃的模型。这份资料真正要解决的问题,不是教你调 XGBoost 参数,而是教你如何把金融问题翻译成机器学习问题,再翻译回可执行的交易信号。适合已经会 Python、懂基本机器学习、但一碰金融数据就翻车的从业者,也适合量化研究员想系统补齐方法论。下面我按自己复现这类方案的顺序,把整条链路拆开讲。
2. 金融机器学习的理论地基:为什么不能直接套用标准流程
2.1 信噪比、非平稳与样本重叠:三个绕不开的约束
标准机器学习假设样本独立同分布,金融数据几乎每条都违反。日频收益率序列的自相关很弱,但波动率聚集很强,导致模型在低波动期学到的关系,到高波动期直接失效。更麻烦的是样本重叠:如果你用未来 5 日收益率做标签,那么相邻两天的标签共享了 4 天信息,训练集和测试集之间天然存在泄漏。常见做法是采用“净化交叉验证”(Purged K-Fold)和“禁运期”(Embargo),在训练集和验证集之间砍掉一段重叠样本。我一般会先画一张标签自相关图,如果 5 日标签的一阶自相关超过 0.7,就必须做净化,否则验证分数虚高得离谱。
另一个约束是信噪比。金融收益率里可解释部分通常不到 5%,这意味着模型稍微复杂一点就会把噪声当信号。所以在这类实践里,简单线性模型加正则化往往比深度网络更稳,不是因为深度网络不行,而是因为样本量根本撑不起那么多参数。资料里反复强调的“先做特征筛选再做模型选择”,本质就是在对抗信噪比。
2.2 从价格序列到监督学习样本:标签怎么造才不泄漏
把金融数据变成监督学习样本,第一步是确定“预测目标”。常见的有三类:未来收益率方向(分类)、未来收益率大小(回归)、未来波动率(回归)。方向分类最直观,但阈值怎么定很讲究。如果简单用“涨为 1 跌为 0”,在震荡市里标签会极度不平衡。我一般用滚动窗口的收益率分位数来定阈值,比如过去 60 天收益率的 30% 和 70% 分位,低于 30% 分位标 -1,高于 70% 分位标 1,中间标 0。这样标签在不同波动环境下都有合理分布。
第二步是特征对齐。所有特征必须在 t 时刻收盘后可得,标签是 t+1 到 t+5 的收益。很多人翻车是因为用了当日收盘价计算的指标去预测当日收益,这就是典型的未来函数。一个检查习惯:把特征矩阵的时间索引和标签索引打印出来,确认特征最大时间严格小于标签起始时间。
2.3 金融特征工程:从量价到微观结构
量价特征是最基础的:过去 N 日收益率、波动率、成交量变化、换手率、高低价差。但这类特征在日频上已经被挖得很透,边际收益有限。资料里更看重的是“分数阶差分”和“波动率缩放”。分数阶差分解决的是:价格序列非平稳,但一阶差分又把记忆性差没了。用 0.3 到 0.5 阶的分数阶差分,可以在保留一定记忆性的同时让序列通过平稳性检验。代码上可以用fracdiff库,或者自己实现二项式展开的权重。
波动率缩放则是把不同股票、不同时期的收益率除以当期波动率,让样本更可比。比如用过去 20 天的已实现波动率做分母,得到“波动率调整后收益”。这一步在横截面策略里尤其重要,否则高波动股票会主导损失函数。
import numpy as np import pandas as pd def frac_diff_weights(d, size): """计算分数阶差分的二项式权重""" w = [1.0] for k in range(1, size): w.append(-w[-1] * (d - k + 1) / k) return np.array(w[::-1]).reshape(-1, 1) def frac_diff(series, d=0.4, threshold=1e-4): """对序列做分数阶差分,保留记忆性""" w = frac_diff_weights(d, len(series)) # 截断权重,避免计算全部历史 w = w[np.abs(w) > threshold] width = len(w) result = series.copy() * np.nan for i in range(width, len(series)): window = series.iloc[i-width:i].values.reshape(-1, 1) result.iloc[i] = float(np.dot(w.T, window)) return result这段代码的关键参数是d,一般取 0.3 到 0.5。threshold控制权重截断,太小会保留过多历史导致计算慢,太大会丢失记忆性。我一般先用 ADF 检验确定最小 d,让差分后序列在 5% 显著性下平稳,然后在这个 d 附近微调。注意分数阶差分后的序列前 width 个值是 NaN,做训练时要对齐标签后丢弃。
3. 用 Python 搭一条可复现的因子研究流水线
3.1 数据获取与清洗:从原始行情到对齐面板
数据源常见的有本地 CSV、数据库或行情 API。不管来源如何,第一步都是统一成“长表”格式:日期、股票代码、开盘、最高、最低、收盘、成交量、复权因子。复权处理是第一个坑:前复权价格会随着新除权事件变化,导致历史特征不稳定。我一般用后复权价格计算收益率,用不复权价格计算成交量相关特征,最后在信号生成时再转回实际价格。
清洗环节要处理停牌、涨跌停、新股。停牌日直接剔除,涨跌停日的成交量特征会失真,我一般把涨跌停日的成交量标记为 NaN,后续用滚动中位数填充。新股上市前 60 天波动极大,通常直接排除。下面是一个清洗函数示例:
def clean_panel(df): """df 为长表,包含 date, code, open, high, low, close, volume, adj_factor""" df = df.sort_values(['code', 'date']) # 后复权价格 df['close_adj'] = df['close'] * df['adj_factor'] # 日收益率 df['ret'] = df.groupby('code')['close_adj'].pct_change() # 标记涨跌停:以是否触及涨跌停价近似 df['limit_up'] = df['close'] >= df['high'] * 0.999 df['limit_down'] = df['close'] <= df['low'] * 1.001 # 涨跌停日成交量置 NaN df.loc[df['limit_up'] | df['limit_down'], 'volume'] = np.nan # 剔除停牌:成交量为 0 或缺失 df = df[df['volume'].notna() & (df['volume'] > 0)] # 剔除上市初期 df['listing_days'] = df.groupby('code').cumcount() df = df[df['listing_days'] > 60] return df逻辑说明:先复权再算收益率,避免除权跳空被当成真实收益。涨跌停判断用收盘价与当日高低价的关系近似,不同市场规则不同,需要按实际规则调整。listing_days用 cumcount 计算,简单但有效。清洗后要检查每个交易日剩余股票数量,如果某天少于 100 只,说明数据有缺失,需要回查数据源。
3.2 特征计算与标签生成:滚动窗口的写法
特征计算的核心是“只用历史数据”。所有滚动统计必须用rolling并配合shift,确保 t 时刻的特征只用到 t 及之前的数据。下面是一个批量计算量价特征的函数:
def add_features(df, windows=[5, 10, 20, 60]): df = df.sort_values(['code', 'date']) g = df.groupby('code') for w in windows: df[f'ret_{w}d'] = g['ret'].transform(lambda x: x.rolling(w).sum()) df[f'vol_{w}d'] = g['ret'].transform(lambda x: x.rolling(w).std()) df[f'volume_ratio_{w}d'] = g['volume'].transform( lambda x: x / x.rolling(w).mean() ) # 波动率调整收益 df['ret_vol_adj'] = df['ret'] / df['vol_20d'] # 标签:未来 5 日收益,按滚动分位分三档 df['fwd_ret_5d'] = g['ret'].transform( lambda x: x.rolling(5).sum().shift(-5) ) df['label'] = np.nan for code, idx in df.groupby('code').groups.items(): sub = df.loc[idx, 'fwd_ret_5d'] q30 = sub.rolling(252, min_periods=60).quantile(0.3) q70 = sub.rolling(252, min_periods=60).quantile(0.7) label = pd.Series(0, index=sub.index) label[sub > q70] = 1 label[sub < q30] = -1 df.loc[idx, 'label'] = label return df参数说明:windows控制特征回看长度,日频常用 5/10/20/60。标签用未来 5 日收益,分位数用滚动 252 天、最少 60 天,保证早期也有标签。注意shift(-5)会让最后 5 天标签为 NaN,训练时自动丢弃。分位数计算在 groupby 内部做,避免跨股票泄漏。这一步做完,建议打印标签分布,如果某一类占比低于 10%,需要调整分位阈值。
3.3 净化交叉验证与模型训练:把泄漏堵死
净化交叉验证的实现要点:按时间排序,分成 K 份,每次取一份做验证,训练集取验证集之前的部分,并在训练集末尾砍掉与验证集重叠的样本。如果标签是未来 5 日收益,那么验证集开始前 5 天的训练样本必须剔除。禁运期则是在验证集之后再多砍几天,防止验证集信息通过特征滚动窗口泄漏到后续训练。下面是一个简化实现:
from sklearn.model_selection import BaseCrossValidator class PurgedKFold(BaseCrossValidator): def __init__(self, n_splits=5, embargo_days=5, label_horizon=5): self.n_splits = n_splits self.embargo_days = embargo_days self.label_horizon = label_horizon def split(self, X, y=None, groups=None): indices = np.arange(len(X)) fold_size = len(X) // self.n_splits for i in range(self.n_splits): test_start = i * fold_size test_end = (i + 1) * fold_size if i < self.n_splits - 1 else len(X) test_idx = indices[test_start:test_end] train_end = test_start - self.label_horizon if train_end <= 0: continue train_idx = indices[:train_end] # 禁运期:训练集末尾再砍 embargo_days if self.embargo_days > 0: train_idx = train_idx[:-self.embargo_days] yield train_idx, test_idx def get_n_splits(self, X=None, y=None, groups=None): return self.n_splits这个实现假设数据已按时间排序。label_horizon等于标签窗口长度,embargo_days一般取特征最大回看窗口的十分之一。训练时用 LightGBM 或逻辑回归都可以,我一般先跑逻辑回归看线性信号,再用 LightGBM 看非线性增量。如果 LightGBM 比逻辑回归提升不到 10%,说明非线性部分基本是噪声,不如用线性模型。
4. 回测与评估:别让漂亮曲线骗了你
4.1 回测中的四个隐形泄漏点
第一个泄漏点是幸存者偏差:只用当前还在上市的股票回测,退市股票被剔除,收益虚高。解决方法是使用包含退市股票的历史成分股列表。第二个是前视偏差:财务数据有披露延迟,如果用财报期末日期对齐,就用了未来信息。常见做法是财报数据至少滞后 45 天。第三个是交易成本忽略:日频换仓的策略,双边成本千分之二起步,高频调仓的策略成本能吃掉全部收益。第四个是参数过拟合:在全部历史上调参,然后报告同一段历史的表现。我一般留最近 2 年数据完全不碰,作为最终验证。
4.2 评估指标:夏普之外还要看什么
夏普比率是最常用的,但它对收益分布的正态假设很敏感。金融收益有厚尾和偏度,所以还要看最大回撤、Calmar 比率、胜率、盈亏比。更重要的是看“换手率调整后收益”:如果策略年化 20% 但年换手 50 倍,扣掉成本可能只剩 5%。我习惯画三张图:累计收益曲线、滚动 60 日夏普、月度收益热力图。滚动夏普能看出策略是否在某些市场环境下失效,月度热力图能发现季节性异常。
def evaluate_strategy(returns, benchmark=None, freq=252): """returns: 策略日收益率序列""" cum = (1 + returns).cumprod() ann_ret = cum.iloc[-1] ** (freq / len(returns)) - 1 ann_vol = returns.std() * np.sqrt(freq) sharpe = ann_ret / ann_vol if ann_vol > 0 else 0 drawdown = cum / cum.cummax() - 1 max_dd = drawdown.min() calmar = ann_ret / abs(max_dd) if max_dd != 0 else 0 win_rate = (returns > 0).mean() return { 'ann_ret': ann_ret, 'ann_vol': ann_vol, 'sharpe': sharpe, 'max_dd': max_dd, 'calmar': calmar, 'win_rate': win_rate }参数说明:freq=252是日频年化因子,周频用 52,月频用 12。cum.iloc[-1]是最终净值。这个函数只做基础评估,实盘还要考虑滑点和冲击成本。如果回测夏普超过 2 且最大回撤小于 10%,大概率有泄漏,先回去查数据对齐。
5. 避坑与排查:金融机器学习里最容易翻车的五件事
5.1 现象:验证集 AUC 0.75,实盘胜率不到 50%
原因:标签重叠导致验证集泄漏。未来 5 日收益标签在相邻样本间高度相关,随机划分验证集时,训练集和验证集共享了大量重叠区间。解决:改用净化交叉验证,并在训练集和验证集之间加禁运期。检查方法是打印训练集和验证集的时间索引,确认没有交集且间隔大于标签窗口。
5.2 现象:模型在 2015 年表现极好,2018 年完全失效
原因:市场结构变化导致特征分布漂移。2015 年高波动、高换手,2018 年低波动、低换手,模型学到的关系不适用。解决:做滚动训练,每 252 天重新训练一次;加入波动率状态特征,让模型自己适应;或者直接按波动率分层建模。我一般会监控特征分布的 PSI(群体稳定性指标),超过 0.2 就触发重新训练。
5.3 现象:特征重要性排名第一的是“当日收益率”
原因:未来函数。当日收益率和未来收益在日频上相关性很弱,但如果特征计算时用了未来数据,比如用当日收盘价算的指标去预测当日开盘到收盘的收益,就会泄漏。解决:所有特征加shift(1),确保 t 时刻特征只用到 t-1 及之前的数据。检查方法是把特征和标签的相关系数按时间画出来,如果 t 时刻特征与 t 时刻标签相关性异常高,就是泄漏。
5.4 现象:回测换手率 3000%,扣费后收益为负
原因:信号抖动。模型每天输出的信号在 0 附近波动,导致频繁调仓。解决:对信号做平滑,比如用 5 日移动平均;或者设置调仓阈值,信号变化超过一定幅度才交易。我一般把换手率控制在年化 500% 以内,超过这个水平,交易成本会吃掉大部分收益。
5.5 现象:训练集损失正常下降,验证集损失从一开始就上升
原因:样本量太少或特征维度太高。金融数据日频、股票池 300 只、5 年历史,总共不到 40 万样本,如果特征有 200 个,模型很容易过拟合。解决:先做特征筛选,用单变量 IC 筛选出前 30 个特征;再用 L1 正则化做嵌入法筛选;最后模型用浅层 LightGBM,树深度不超过 4。如果验证损失仍然上升,说明信噪比太低,考虑降低模型复杂度或增加样本。
6. 进阶技巧:用组合构建把弱信号变成可交易策略
单个因子 IC 0.03 在日频上已经不错,但直接按因子值排序买前 10% 股票,收益波动会很大。进阶做法是“因子合成 + 风险模型 + 组合优化”。因子合成可以用等权、IC 加权或最大化 ICIR。IC 加权是滚动计算每个因子的 IC 均值,除以 IC 标准差得到权重,再归一化。风险模型可以用协方差矩阵收缩,比如 Ledoit-Wolf 收缩,避免协方差矩阵不可逆。组合优化在约束换手率和行业暴露的前提下,最大化预期收益减风险惩罚。
from sklearn.covariance import LedoitWolf def combine_factors(factor_df, forward_ret, window=252): """factor_df: 多因子面板,forward_ret: 未来收益""" ic_series = {} for col in factor_df.columns: ic = factor_df[col].groupby(level='date').apply( lambda x: x.corr(forward_ret.loc[x.index]) ) ic_series[col] = ic.rolling(window).mean() / ic.rolling(window).std() icir = pd.DataFrame(ic_series) weights = icir.div(icir.abs().sum(axis=1), axis=0) combined = (factor_df * weights).sum(axis=1) return combined def optimize_weights(expected_ret, cov_matrix, risk_aversion=5, max_weight=0.05): """简单均值-方差优化,带权重上限""" n = len(expected_ret) from scipy.optimize import minimize def neg_utility(w): return -(w @ expected_ret - risk_aversion * w @ cov_matrix @ w) constraints = [{'type': 'eq', 'fun': lambda w: w.sum() - 1}] bounds = [(0, max_weight)] * n res = minimize(neg_utility, np.ones(n)/n, bounds=bounds, constraints=constraints) return res.x这段代码里,combine_factors用滚动 ICIR 加权,避免用全样本 IC 导致前视。optimize_weights是简化版均值方差,risk_aversion控制风险惩罚,max_weight防止单票过度集中。实盘还要加行业中性、市值中性约束,这里不展开。我自己的习惯是:任何组合优化结果,先看权重分布,如果前十大权重合计超过 50%,说明约束太松,需要收紧。
最后说一个我踩过的坑:曾经用全样本训练的模型回测夏普 3.5,实盘第一个月就回撤 8%。后来发现是标签分位数用了全样本计算,导致早期标签包含了未来信息。改成滚动分位数后,回测夏普降到 1.2,但实盘表现和回测基本一致。这个教训让我养成了一个习惯:任何用到“全样本”统计量的地方,都要问一句“这个统计量在 t 时刻真的可得吗”。希望帮到你。
本文还有配套的精品资源,点击获取