Jane Street Market Prediction 这个比赛,在 Kaggle 圈子里一直有个说法:这不是一场“比谁模型更大”的比赛,而是一场“比谁更懂数据在说什么”的比赛。组织方是纽约做市商巨头 Jane Street,数据来自真实交易信号,所有特征被匿名化成 130 多个浮点特征,再给你一个连续目标 respond、一个二值 action、一个权重 weight,评估指标却偏偏选了多数人平时很少碰的 MCC。于是很多在其他比赛里稳拿高 AUC 的朋友,到了这里线下验证做得挺漂亮,一上 LB 就发懵。
这篇文章是系列收尾的第三篇。前两篇如果已经看过,你对数据结构、基础验证策略和初阶特征工程应该已经有底了。这一篇我们把镜头拉近,专门拆一拆冠军方案里那些“乍看只是小操作、实际上扭转全局”的巧劲,同时整理一份可以照着找资源、照着复现的路线图。文章里的代码片段是我自己复现时写过、跑过、改过的版本,和原方案未必逐行一致,但思路是同一条线。
1. 把评估指标 MCC 的账先算明白
1.1 MCC 的计算逻辑:为什么它是个“反直觉”指标
MCC(Matthews 相关系数)最常见的写法是这个:
MCC = (TP * TN - FP * FN) / sqrt((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN))从公式可以看出它同时考虑了四个象限:真正例、真负例、假正例、假负例。和 AUC 不一样的地方在于,AUC 只看排序质量,对类别不平衡不敏感;MCC 则同时惩罚“误报”和“漏报”的不对称,类别严重不平衡时,哪怕你把精度做到 99%,MCC 也可能很难看。
我举个例子你就明白。假设 100 万里有 1 万个正样本,你搞了个模型把所有样本全预测为负,准确率 99%,但 MCC 直接算不出来(分母为 0),实际计算中会得到一个极差的值。Jane Street 这场比赛的正样本比例大概在 10% 附近,不算极端失衡,但 MMC 对阈值极其敏感,你把分类阈值从 0.5 挪到 0.45,MCC 可能差出好几个百分点。这在整个比赛里是一个贯穿始终的坑。
理解 MCC 的第二个关键点:官方评估完全不看 weight。训练集里每一行都带一个 weight,官方鼓励你用 weight 训练,但最终计算 MCC 时每一行权重都一样。这就造成了“训练时重要”和“评估时重要”的错位。很多人线下来回调参,反复在验证集上优化 MCC,结果完全被 weight 带偏。
1.2 直接优化 MCC 的几种替代方案
MCC 不可微,不能直接当损失函数喂给模型,这是基本前提。所以常见的处理方式有三条路。
第一条路最稳妥:先按二分类用 LogLoss 或 BCE 训练,训完之后在验证集上搜索阈值,找一个能让 MCC 最高的 cutoff。这个搜索可以用步长 0.001 从 0.2 扫到 0.8,也可以直接用 scipy 优化。
第二条路是用 Pearson 相关系数做代理。因为 MCC 本质上衡量的是“标签和预测之间的相关性强度”,而 Pearson 相关也是衡量两个变量线性相关程度的指标。训练过程中如果能让预测向量和标签向量的相关系数最大化,其实就等于在逼近 MCC 的精神。
第三条路是排秩损失。因为 MCC 只关心排序一致性,不关心概率标定得准不准。有人直接把问题看成 Learning to Rank,用 pairwise 损失训练模型。
我实际跑下来,效率最高的还是第一条路:先优化 logloss,再搜阈值。LightGBM 和 XGBoost 对 logloss 的优化非常成熟,训练速度快,二阶导也稳定;自定义 Pearson objective 在理论上更“对齐”,但写不好二阶导容易让训练抖动,收益经常抵不过调参成本。这里放一个 Pearson 代理目标函数的写法示意,它更接近“冠军方案里的思路”而不是“冠军方案的原代码”:
import numpy as np import lightgbm as lgb def pearson_objective(preds, train_data): labels = train_data.get_label() y = labels - labels.mean() p = preds - preds.mean() # 一阶导近似 grad = -y / (np.linalg.norm(p) * np.linalg.norm(y) + 1e-9) hess = np.ones_like(preds) * 1e-4 return grad, hess注意 Hessian 我给了一个很小的正值,这不是数学上严格推导的结果,而是一种工程上的妥协。树模型要求 Hessian 为正,否则分裂计算会出问题。很多人在这一步被劝退,其实没有必要“硬刚”。我自己试过几次之后最后也回到 logloss 了,但是在验证阶段用 MCC 做早停,效果同样很好。
1.3 本地验证里的“MCC 彩票效应”
MCC 还有一个让很多人头疼的性质:它在样本量不够大的时候方差很大。你用同一个模型、同一个验证集,每次随机种子不同,MCC 可能从 0.03 跳到 0.06。这不是玄学,而是 MCC 对 FN/FP 的微小变化非常敏感。
所以本地验证一定要加“重复实验”。冠军方案在公开分享里强调过,他们的标准流程是:固定 5 折 GroupKFold,每个折训多个种子,最终预测取所有模型的平均概率,然后再统一搜一个全局阈值。这样做的好处是,验证集上每个样本的预测值都来自多个模型平均,方差被压下来,MCC 也能更稳定地反映真实水平。
我自己的体感是:如果你只跑一次单折,在验证集上搜出阈值 0.42,MCC 0.051;等你换一个随机种子,阈值最优值可能变成 0.39,MCC 掉到 0.045。这个波动足够让人做出完全错误的模型决策。凡是线上结果和本地结果对不上的人,先检查验证流程是否足够“重复”,再去怀疑特征工程。
2. 匿名特征不是白盒,但结构仍然可挖
2.1 特征相关矩阵:第一眼就要看出“分块”
Jane Street 的数据特征全部匿名,列名只有 feature_0 到 feature_129,但匿名化不等于加密。比赛方把真实因子做了混淆、重命名、加噪声,但大多数统计结构还保留着。取训练集几万行算一个特征相关矩阵,然后做层次聚类热力图,你能很清楚地看到特征呈现出分块结构:某些特征组内相关系数高达 0.5-0.7,组间几乎为 0。
我第一次看到这个图的时候,第一反应是“这不就是几个隐因子吗”。后来看冠军方案和几个高分方案的分享,大家不约而同都做了类似的相关分析,只是分组方式不同。有人用 KMeans 聚类特征,有人用层次聚类,有人直接用相关矩阵做谱分解,本质上都是想恢复那个隐藏的因子结构。
有意思的是,冠军方案没有把特征重构成“少数几个因子”就完事,而是保留原始特征的同时,把“组内聚合”作为额外特征放进去。理由很简单:树模型对原始特征的交互切分有天然优势,而组内聚合特征能帮模型更快地找到“同一组特征同时走高/走低”的规律。
2.2 组内聚合与组内 PCA 的做法
我自己复现时用的是层次聚类,距离度量选 1 - abs(corr),然后按相似度阈值切出大约 10-14 个组。对每一组,我构造了这些新特征:
- 组内特征的均值
- 组内特征的均值绝对差(平均偏离度)
- 组内 PCA 的第一主成分得分
- 组内 PCA 的重构残差:原始特征减掉第一主成分的投影
最后一类“重构残差”是我觉得很有意思的一招。它把特征分成“公共因子”和“个殊噪声”两部分,树模型再同时看到这两部分时,能更精准地判断是整体行情异动还是单列异常。类似的做法在很多结构化数据比赛里都出现过,但 Jane Street 这场因为匿名特征天然分块,收益格外明显。
这里有一个特别重要的防泄漏细节:PCA 必须在训练折内部去 fit,不能在全量训练集上先 fit 好了再交给交叉验证。我见过太多人在这里翻车——全量 fit 的时候验证集信息已经通过 PCA 的均值和主成分方向渗进去了。处理方式很简单:把 PCA 放进 sklearn 的 Pipeline,跟着 GroupKFold 一起走。
2.3 ts_id 窗口内的“市场状态”特征
ts_id 是比赛里一个容易被低估的字段。虽然 ts_id 本身不是时间戳,但同一 ts_id 的行在业务上属于同一个“交易时间片”,可以理解为同一时刻从市场上抓下来的快照,里面可能有几百条到几千条记录。
基于这个结构,一个非常有效的特征组是“组内横截面统计”。对每组特征或每个原始特征单独做:
df_group = df.groupby("ts_id") for feat in feature_cols: df[feat + "_grp_mean"] = df_group[feat].transform("mean") df[feat + "_grp_std"] = df_group[feat].transform("std")这些特征在金融场景里对应的是“市场状态”:均值代表整体水位,标准差代表波动率。一个交易信号在高波动环境和低波动环境里,后续方向的概率分布是完全不同的。树模型如果不给它这个状态变量,就只能靠原始特征之间的交互去隐式逼近,效率低很多。
需要注意,训练集里的 ts_id 不会出现在测试集里,所以做组内统计时用“当前该组全部行”是安全的,不存在跨越时间窗口的未来信息问题。但如果你把预测场景设计成“逐行实时打分”,那就只能用该组已经观测到的行做滚动统计,不能用整个组。这就看你的落地目标是什么——如果是比赛打分,组内全体统计可用;如果是要写回测脚本,要额外小心。
2.4 哪些“捷径”是绝对不能碰的
匿名数据让人忍不住想去“挖映射关系”。公开讨论区里出现过有人尝试用 respond 和 action 的分布去猜特征含义,这是统计分析,没问题。但以下操作全是高压线:
- 用全量标签做特征筛选,比如计算每个特征和 respond 的相关性,再只留高分特征,这会造成严重的特征选择泄漏;
- 按 date 做目标编码、平滑标签,属于时间泄漏;
- 在本地验证时用未来信息填充缺失值或做标准化。
这些操作在本地会得到漂亮到吓人的 MCC,但提交通道一跑就现原形。冠军方案最让我佩服的地方,不是用了多复杂的模型,而是整个实验流程极其干净,每一步都经过 GroupKFold 的验证。
3. weight 字段的正确打开方式:从偏差里找信号
3.1 先看 weight 分布:它不是普通的样本权重
如果把训练集所有 weight 画出来,你会看到明显的长尾分布:大量样本的 weight 集中在很小的区间,少数样本的 weight 非常大。这个字段在业务上可以理解为“该样本在真实交易环境中的流动性/信心程度”,但它是匿名化的,没人知道确切含义。
第一个直观想法是直接把 weight 作为 sample_weight 传给 LightGBM。可行,但问题在于:极端大权重样本会主导损失,模型会过度拟合那几千个罕见样本,而它们在评估 MCC 时和其他行地位完全一样。所以这里必须做变换。
我在本地做过一组对比实验,用同样的模型和验证折,分别试了三种权重:
| 权重处理方式 | 验证集 MCC | 线上 LB 表现 |
|---|---|---|
| 不传权重 | 0.036 | 稳定但偏低 |
| 原始 weight 直接传 | 0.042 | 方差大,不稳定 |
| sqrt(weight) | 0.041 | 稳定且略有提升 |
| 截断后 weight(上限 10) | 0.040 | 稳定 |
最终我长期使用的是 sqrt(weight) 加一个上限截断。原因很简单:开根号可以压缩极端权重的影响,截断可以防止个别样本权重超过 30 倍于中位数。
3.2 weight 的第二种用法:分层采样
weight 除了能当训练权重,还能当分桶标签。按 weight 分成 10 个桶之后,训练集和测试集在每个桶里的样本比例可以做个对比。如果发现测试集中“大权重桶”的比例明显更高,说明未来时间段的交易环境更偏向高信心场景,这时你可以调整训练集采样策略,让模型更关注这些桶。
这是一种“半硬样本挖掘”的思路。我之前在那个比赛后面几周试过:按 weight 桶加权采样,让大权重桶的样本在训练中出现的频率更高,线上结果有小幅提升。需要注意不能单靠这一招,它只是对数据漂移的一种粗略补偿。
3.3 用对抗验证监控数据漂移
对抗验证是处理时间序列漂移时的标配手段。做法很简单:把训练集打上标签 0,测试集打上标签 1,合并后丢给一个分类器,看能不能学出“区分训练和测试”的模式。如果 AUC 接近 0.5,说明两个集合分布接近;如果 AUC 到 0.9,说明漂移严重,必须对训练集做筛选或加权。
在这个比赛里,lightgbm 做对抗验证时,特征重要性排名最高的往往就是那些组内统计特征和 weight 的秩。为什么?因为时间推进后,市场的波动率风格会变,ts_id 的大小分布也会变。但你不能直接拿 ts_id 当特征去预测。对抗验证的任务不是让你防御性地去掉这些特征,而是让你意识到:某些特征在时间轴上的分布已经变了,模型需要更强的正则化,比如提高 min_data_in_leaf、降低 learning_rate,或者用更多折平均。
3.4 按 ts_id 分组切折:最底层的红线
这个比赛的交叉验证不能按行随机切。因为同一 ts_id 内的样本高度相关,如果随机切分,训练集和验证集会出现大量“共享同一市场状态”的行,验证分数会虚高。正确的做法是从始至终使用 GroupKFold,把 ts_id 作为分组键。
from sklearn.model_selection import GroupKFold gkf = GroupKFold(n_splits=5) for train_idx, valid_idx in gkf.split(X, y, groups=df["ts_id"].values): pass这里还要注意一个细节:GroupKFold 是随机分组,但如果按日期来看,某些天可能全落在一个折里。更稳的做法是用 StratifiedGroupKFold,尤其是你构造了 weight 桶标签之后,可以同时保证每组内的标签分布接近。即便 scikit-learn 自带的 StratifiedGroupKFold 在版本更新后已经支持,我在复现的时候仍然是手动实现,避免历史版本的各种坑。
4. 模型与训练策略:树模型扛底,神经网络做补充
4.1 GBDT 调参的“宽进严出”
Jane Street 这个比赛,绝大多数公开高分方案的主力仍然是 LightGBM 和 XGBoost。原因很现实:表格类特征、样本量大、噪声高,树模型对特征尺度不敏感,正则化路径清晰,训练效率也高。
我复现冠军方案时把参数范围大概固定在这样一组区间:
| 参数 | 我的搜索范围 | 备注 |
|---|---|---|
| learning_rate | 0.005 - 0.02 | 太低训练太慢,太高容易过拟合噪声 |
| num_leaves | 64 - 256 | 越大模型越复杂,配更高的 min_data |
| min_data_in_leaf | 500 - 2000 | 这个比赛噪声很大,犁地参数要保守 |
| feature_fraction | 0.3 - 0.7 | 避免模型依赖少数几个高相关特征 |
| bagging_fraction | 0.7 - 0.9 | 配合 bagging_freq 用 |
| lambda_l2 | 0.1 - 10 | 对匿名高维特征很有帮助 |
关键经验是“宽进严出”:先用一组比较保守的参数跑通管线,然后用早停观察验证集 MCC 的走向,最后再做小范围参数精调。千万不要上来就贝叶斯搜索,这个比赛的数据规模下,一次完整 5 折训练可能要两三个小时,搜索空间太大你根本跑不完。
4.2 神经网络:把表格当“伪截面”读
虽然树模型是主力,但冠军方案里的神经网络也不可忽视。他们发现树模型对特征交互和阈值切分很擅长,但很难捕捉“同一时间片内多特征同时变化的组合模式”,而一个简单的 MLP 或小型 Transformer 对这种模式更敏感。
我做 NN 时的几个实操要点:
- 特征输入前建议用 RankGauss 或 QuantileTransformer 做非线性规范化,而不是简单 z-score。匿名特征分布非常不规整,很多列有明显偏态,z-score 会放大长尾噪声;
- 网络结构不用太大,3-4 层 MLP,每层 256-512 个神经元,配合 BatchNorm 和 Dropout,已经足够;
- 分组 Dropout 比普通 Dropout 更有效。因为前面把特征按相关性分了组,Dropout 时按组为单位随机丢弃,可以强制网络学习多个因子组的独立信号,而不是依赖某几组强特征;
- 损失函数用 BCE 为主,可以叠加一个小权重的对比学习辅助损失,让同一 ts_id 内的样本在表示空间里靠得更近。这个辅助任务我做了很多次实验,提升不大,但偶尔能稳住训练曲线。
NN 最大的问题是不稳定。同样的代码,在不同随机种子下,验证集 MCC 可能上下浮动 0.005。这本来就是噪音范围,所以 NN 从来不是单独出成绩,而是和树模型做融合之后才体现出价值。
4.3 融合的技巧:先归一化,再平均
模型融合的常见误区是直接把概率平均。LightGBM 输出的概率和 NN 输出的概率尺度完全不同,直接平均相当于给某个模型加了隐式权重,不是你想要的。正确做法是把每个模型的输出先做 ranking 归一化,也就是把预测值变成它在验证集上的百分位排名,再平均。
from scipy.stats import rankdata pred_lgb_rank = rankdata(pred_lgb) / len(pred_lgb) pred_nn_rank = rankdata(pred_nn) / len(pred_nn) blend = 0.7 * pred_lgb_rank + 0.3 * pred_nn_rank融合权重的确定,可以用验证集线性搜索,也可以直接用一个简单的线性回归。但要注意,权重不能只看验证集 MCC,还要看融合后模型的预测分布是否仍然保持单调。有些权重组合能让 MCC 在验证集上很高,但导致概率分布过度集中在 0.4-0.6 区间,线上就会失去区分度。
树模型和 NN 的预测相关性如果超过 0.96,融合收益就会很小。我在实验时做过统计,LightGBM 不同种子之间的相关系数大约在 0.98 左右,和 NN 的相关系数在 0.92 左右,所以融合的主要增量来自 NN,而不是再多加几个树的种子。
5. 冠军方案资源清单与复现路线图
5.1 值得收藏的公开资源
“topline 链接整理”这件事,我不打算直接给你一堆可能过期的 URL,而是整理成站内搜索词。原因是 Kaggle 帖子更新很快,而且很多代码库会 fork,你拿到一个原始链接可能已经失效。用关键词在站内搜索,基本永远能找到高质量版本。
| 资源类型 | 搜索关键词 | 价值点 |
|---|---|---|
| Kaggle Discussion 置顶帖 | “Jane Street 1st place solution” / “1st place” | 冠军团队赛后的思路访谈,最值得精读 |
| GitHub 代码库 | “jane street market prediction solution” | 各种公开复现,选 star 数高且最近更新的 |
| 高分公开 Kernel | “jane street lgbm” / “jane street nn” | 看别人怎么实现基线,尤其是数据切分方式 |
| 赛后访谈/博客 | “Jane Street Kaggle retrospective” | 很多参赛者会写赛后感,包含数字和结论 |
再看 GitHub 代码库时,别只盯着最终分数,重点看两件事:他们的验证折是怎么切的;他们对 weight 和 MCC 是怎么处理的。这两点直接决定复现是否靠谱。我见过太多“高 star 但完全不严谨”的代码,验证折直接随机切,MCC 虚高到不可思议,跟着做只会浪费时间。
5.2 一周复现路线图
如果你是从零开始在 Jane Street 这个比赛上复现冠军方案,我给一个时间分配建议。这比直接看代码列表更实用,因为很多人是“收藏了十个 repo 然后一个都没跑完”。
| 阶段 | 时间 | 任务 |
|---|---|---|
| Day 1 | 半天 | 跑通官方 baseline,确认数据读取、内存优化、提交格式 |
| Day 1 | 半天 | 实现 GroupKFold 验证,复现一个简单的 LGBM 基线 |
| Day 2 | 全天 | 做特征相关分析,构建分组特征和 ts_id 组内统计特征 |
| Day 3 | 半天 | 训练多个种子多个参数的 LGBM,记录验证集 MCC 的稳定性 |
| Day 4 | 半天 | 实现 MLP,用 RankGauss 和分组 Dropout 训练 |
| Day 5 | 全天 | 做融合、阈值搜索、对抗验证,确认没有泄漏 |
| Day 6 | 半天 | 回看所有实验记录,找到每一步提升的来源 |
| Day 7 | 半天 | 写总结,形成自己的“匿名金融比赛范式” |
注意 Day 5 的“确认没有泄漏”一定不能省。我见过很多人复现到了融合阶段以后,验证分数比公开基线高出 0.03,但一提交反而跌回去,最后定位发现是 GroupKFold 里某个统计特征用了全量数据拟合。
5.3 把经验迁移到其他“匿名金融特征”比赛
Jane Street 这套方法论不止适用于这一个比赛。现在很多金融类比赛都会做特征匿名化,字段含义不公开,权重/时间字段结构类似。你在这场比赛沉淀下来的能力,本质上是一套通用的分析流程:
- 先用对抗验证和数据探索理解分布,而不是急着建模;
- 通过相关矩阵恢复隐因子结构,再手工构造聚合特征;
- 所有验证都用分组切折,避免时间片泄漏;
- 训练权重和评估指标不一致时,用截断、开根号等变换补齐;
- 树模型与 NN 融合,输出做 rank 归一化。
这套流程我后来用在几个量化相关的匿名数据竞赛里,基本都能很快进入中上游区间。它不是魔法,而是把“数据是怎么生成的”这件事想透了的结果。
另外,关于资源整理,我的建议是:你不需要一次性把市面上所有方案都读完。先把冠军方案访谈反复看两遍,把代码仓库按“验证方式”“特征工程”“模型训练”分开阅读,然后自己动手跑通一条最小路径。知识只有变成自己的验证分数,才真正属于你。
我在实际复现过程中最大的一个体会是:这个比赛的难点从来不在模型结构,而在对 weight、ts_id、MCC 这三个非特征字段的理解深度。你以为在调参数,其实是在调“对数据生成机制的理解”。如果你看完了这篇文章,能先停下来想清楚这三件事,再去碰代码,复现效率会高非常多。