1. 赛题理解:先想清楚再动手
1.1 拿到赛题后的第一件事:我差点直接跑了模型
第一次参加微信大数据挑战赛的时候,我的第一反应和很多人一样:赶紧把数据下载下来,打开可视化工具看两眼分布,然后直接丢进LightGBM里面跑一个基线,看看能拿多少分。这个流程不能说错,但说实话,它让我在第一周里多走了很多弯路。
大数据挑战赛和平时自己做的机器学习项目最大的区别在于:赛题的开放性和数据的高维稀疏程度都远超常规。你不能像处理普通表格数据那样,简单做个标准化、填个缺失值就完事。微信生态下的数据往往带有强烈的时序属性、行为交互属性,以及大量文本和结构化信息混合的特征空间。如果一开始不把赛题吃透,后面所有特征和模型选择都会像无头苍蝇一样乱撞。
我那次的做法后来复盘下来其实非常典型:第一天写了一个通用pipeline,第二天就出了baseline分数。表面上看起来进度很快,但baseline的分数让人有点尴尬,大约在排行榜的中下游。原因很简单,我根本不了解评测指标的含义,也不知道数据集中“用户”和“行为”这两个实体之间的业务逻辑到底是什么关系。
所以,拿到赛题后的真正第一步,应该是先花两天时间做三件事:
- 逐字读清楚赛题说明,特别是任务定义和指标公式部分,边读边把关键词抄下来;
- 对训练集和测试集做全量字段的统计描述,搞清楚每个字段的缺失率、基数、时间跨度;
- 想清楚“这条数据是怎么产生的”,也就是反推数据的业务来源。
第三点最容易被人忽略,但偏偏是决定上限的关键。比如一条行为数据里的时间戳,到底是用户点击发生的时刻,还是服务器记录落库的时刻?这两者之间可能只差几百毫秒,但在做时序特征的时候,会导致完全不同的特征窗口划分。后来我在自己的代码注释里把这类问题都写成了README,每做一个特征之前都要先问一遍:“这个字段在真实业务里代表什么?它会不会出现在预测时刻之后?”
1.2 评测指标才是真正的“老板”
一个残酷的事实是:指标决定了你所有工作的方向。我在这次比赛里吃过一个很实在的亏,就是一开始把注意力全都放在了一个经典分类指标的优化上,结果发现线上评分波动很大,怎么调都稳定不下来。后来仔细看了赛题说明,发现评测指标的分子分母和我理解的并不一样,它对“正样本的召回率”权重极高,而我的模型一直在优化整体准确率。
这类问题几乎每个大数据竞赛都会出现。评测指标一般不会是简单的AUC或者准确率,而是类似F1-score的变体、加权AUC、P@N、NDCG这种排序类指标。不同的指标意味着:
- 如果你的指标是AUC或者GAUC,那你只需要关心模型给样本排序的相对正确性,不需要刻意调整阈值;
- 如果指标是F1或者带权重的F1,那阈值的选择就成了一个关键的超参数;
- 如果指标里带有了时间窗口的概念,比如预测未来7天内的行为,那么你构造训练样本的时间跨度,必须和评测的时间跨度严格对齐。
我当时用了一个很笨但很有效的方法:把线上发布的历史分数和自己在本地复算的指标做相关性分析。每跑完一版模型,我会把线上分数和本地指标记录下来,然后用Excel画散点图。这么做的目的,是为了判断“本地验证到底能不能代表线上”。如果本地分数涨了但线上跳水,那说明验证方式有问题;如果两者趋势一致,哪怕绝对值有偏移,也说明验证方式是可信的。
还有一种情况要特别警惕:有些比赛会隐藏一部分测试集的答案来防止刷榜,但评测指标会给出一个置信区间。如果你的最终分数波动很大,很可能是因为测评集本身数量不大。这时候不要盲目跟着单次线上分数走,而是要多用本地交叉验证的结果做决策。说白了,线上的分数只是参考,真正的模型选型依据,应该建立在一个稳定可复现的本地验证协议上。
2. 数据处理与特征工程:决定排名的隐形战场
2.1 时序数据的特征构造,如何防止“穿越”
大数据挑战赛的数据几乎都离不开时间维度,这次也不例外。训练集通常是一个时间段内的行为和标签,测试集则是另一个时间段的预测窗口。这里最大的风险,就是特征穿越——用未来的信息去预测过去,这在本地验证里会造成分数虚高,线上直接崩掉。
我自己的处理习惯是:先把所有特征分成三类,分别是静态特征、动态特征、以及时序上下文特征。
静态特征比较好理解,比如用户的设备类型、注册时长、性别、年龄段这些不太随时间变化的字段。动态特征则是会变化的,比如用户某个时间段内的行为次数、累计金额、活跃天数。时序上下文特征更复杂一点,比如“用户最近一次行为距离现在多久”“用户过去7天里每天的活跃分布”。
处理这些特征时,最核心的原则只有一个:任何特征在构造时,只能使用当前预测时刻之前(或等于)的数据,不能使用之后的数据。
听起来很简单,但在实操里经常翻车。举个例子,如果我想构造“用户过去7天点击公众号文章的次数”,这个特征本身没问题,但如果你用测试集的全量数据去算均值,再把均值填充到样本里,那就有问题了。因为均值是全局统计量,包含了未来的信息。正确的做法是,用滑窗做累计统计,而且窗口的截止点必须严格对齐到每一个样本的预测时间点。
我在这次比赛里专门写了一个时序特征工具类,核心逻辑是这样的:
def rolling_feature(df, group_col, time_col, target_col, windows): features = [] for w in windows: # 先按组和时间排序 df = df.sort_values([group_col, time_col]).reset_index(drop=True) # 滑窗统计 g = df.groupby(group_col)[target_col].rolling(w, min_periods=1).agg(['mean', 'sum', 'std', 'max']) g = g.reset_index(level=0, drop=True).reset_index(drop=True) # 注意:当前行的统计值要shift一个单位,排除当前时刻本身 df[f'{target_col}_win_{w}_mean'] = g['mean'].shift(1) df[f'{target_col}_win_{w}_sum'] = g['sum'].shift(1) features.append([f'{target_col}_win_{w}_mean', f'{target_col}_win_{w}_sum']) return df, features关键在于最后的shift(1)。如果不做这一步,当前样本的统计值就会把自身包含进去。在比赛场景里,这算是一种轻度的标签泄露,会让模型学到一些“不该看到的东西”,最终导致线上掉分。
2.2 类别特征与文本特征的组合玩法
微信生态下的数据有一个很有趣的特点:类别字段非常多,而且很多类别之间的关联极强。比如用户关注了哪些公众号、打开了哪些小程序、设备型号、操作系统版本,这些字段如果直接做LabelEncoding或者独热编码,要么维度爆炸,要么丢失语义信息。
我的经验是,类别特征至少可以做这几类处理:
- 高频类别单独建模:对于出现次数最多的前几十个类别,直接保留为独立的类别特征,交给LightGBM处理;低频类别统一合并为“other”;
- 目标编码(Target Encoding):用类别在当前训练集上的标签均值来编码,但一定要配合交叉验证使用,否则会过拟合;
- 类别组合特征:把两个类别拼起来形成新的类别,比如“用户设备型号+操作系统版本”,这种组合特征往往能捕捉到一些交互信息;
- 用户维度统计:计算每个用户在每个类别上的行为次数占比,例如“该用户使用某类设备的比例”。
文本特征在这类比赛里也很常见,尤其是数据中包含标题、描述、评论之类的字段。直接扔给TF-IDF是最基础的做法,但有一个细节值得注意:TF-IDF的词表最好只基于训练集构建,测试集只做变换,不能重新拟合。如果用全量数据去拟合一维词表,等于是把测试集的信息偷看了一部分,这个操作虽然在线下验证里看不出来有什么问题,但线上会有细微的影响。
我当时处理文本特征的思路稍微升级了一点:先对文本做清洗和分词,然后分别用TF-IDF和预训练文本向量各抽一套特征,再把两套特征拼接起来一起训练。TF-IDF擅长捕捉词频上的精确表达,预训练模型则能提供语义层面的相似度信息。两套特征同时存在,对树模型和深度模型都有增益。
2.3 特征筛选、去重与缓存管理,一个都不能少
特征工程做得越多,越容易失控。我在比赛中期统计过一次,当时所有候选特征加起来接近3000个,里面有大量相关性超过0.95的冗余特征,还有一些特征的信息增益接近于零。如果直接把3000维特征全送进模型,训练时间会变得不可接受,而且模型容易过拟合。
所以我建立了一套比较简单但有效的特征筛选机制:
- 先用基于LightGBM的特征重要性排序,筛掉重要性为0的特征;
- 再用相关性矩阵找到高相关特征组,每组只保留一个代表特征;
- 最后做一轮前向选择,用小验证集跑几个关键模型的组合,观察性能变化。
这套流程的产出效率很高,3000多个特征最后压到了600个左右,模型性能几乎没有掉,反而因为噪声减少,泛化能力略微提升了。
关于特征缓存,我想特别提醒一句:在竞赛中期,你一定会反复调整特征代码,如果每次跑全量特征都要从原始数据重新算一遍,会浪费大量时间。我的做法是把特征计算模块化,每个特征组单独缓存为parquet文件,文件的命名规则是“特征模块名+版本号”。这样每次只重新计算变更过的模块,其他模块直接从缓存读取,能把单次实验的时间从半小时压缩到五分钟以内。
3. 模型训练与融合:把分数从75拉到85的经验之谈
3.1 树模型三件套的调参思路
大数据挑战赛里,LightGBM、XGBoost、CatBoost这“三件套”几乎是标配。它们各自的优势差异还挺明显的:LightGBM训练快、内存占用低,在特征维度高的情况下依然表现稳定;XGBoost对噪声的鲁棒性稍微好一点,在部分数据集上精度会略高;CatBoost对类别特征的原生支持非常友好,如果直接传入类别字段而不做编码,效果往往不错。
我的调参思路一般分成两步走。第一步是把参数调到“够用”的状态,也就是通过一定轮数的随机搜索或者贝叶斯搜索,先找到一组还不错的参数。第二步是固定模型结构,重点做特征筛选和样本权重调整。这两个步骤不能反过来,因为如果特征没有稳定下来,你调出的参数实际上是过拟合了当前这一套特征的。
下面是我在比赛里用的一组LightGBM基准参数,后来在此基础上微调得到了很多个版本:
lgb_params = { 'objective': 'binary', 'metric': 'auc', # 具体指标根据赛题说明调整 'learning_rate': 0.02, 'num_leaves': 64, 'max_depth': 7, 'min_child_samples': 20, 'subsample': 0.8, 'colsample_bytree': 0.7, 'reg_alpha': 0.1, 'reg_lambda': 1.0, 'n_estimators': 3000, 'early_stopping_rounds': 200, 'verbose': -1, 'seed': 42 }有几个参数我想特别说明一下。subsample和colsample_bytree设置成0.8和0.7,可以有效减少过拟合,特别是在特征数量很多的情况下。reg_alpha是L1正则化项,它能让模型更稀疏,有些无用的特征会被弱化。min_child_samples控制了叶节点的最小样本量,值太小容易过拟合,值太大欠拟合,20是一个比较中庸的起点。
CatBoost我一般喜欢开one_hot_max_size参数,让它在数值较小的类别特征上自动做独热编码,而不是全部转成排序编码。这样既保留了高频类别信息,也不会造成维度爆炸。
3.2 深度模型:从“不敢用”到“能补锅”
很多做表格数据的同学会忽略深度学习模型,因为在纯结构化数据上,树模型往往能赢得很轻松。但在我参加的这类大数据挑战赛中,数据维度很高,而且有很多交互特征,深度模型有时能抓到树模型抓不到的模式。
我这里说的深度模型,不是让NN去处理原始特征,而是把手工特征和自动特征学习结合起来。最常见的一种做法是把数据同时喂给一个MLP和一个树模型,然后做融合;还有一种做法是先利用自编码器对特征做压缩和重建,再把压缩后的向量作为新特征加入树模型。
我在这次比赛里尝试了一个轻量级的MLP结构,具体是这样设计的:
- 输入层是600维特征;
- 第一层全连接128个神经元,激活函数用ReLU,后接Dropout(0.3);
- 第二层全连接64个神经元,激活函数用ReLU,后接Dropout(0.2);
- 输出层是1个神经元,用Sigmoid做二分类;
- 损失函数用BCE,优化器用Adam,初始学习率1e-3,训练40轮左右,早停标准是验证集指标连续5轮不升。
说实话,单独的MLP并没有超过LightGBM很多,大约低了2到3个点的AUC。但如果把这个MLP的预测结果作为一个新特征,和LightGBM的输出一起做线性融合,整体效果反而涨了0.5到1个点。这说明深度模型和树模型学到的是不同的信息,它们的预测结果相关性较低,融合的价值就体现在这里。
3.3 模型融合的正确姿势:不是所有模型都值得融合
融合是竞赛里涨分最快的手段之一,但不是把一堆模型的结果平均一下就完事了。融合的前提是,参与融合的模型之间相关性要足够低,否则收益趋近于零。
我的做法是:先训练出三到五个结构差异较大的模型,然后计算它们在验证集上预测结果的两两相关系数。如果两个模型的相关系数超过0.95,说明它们几乎是同一个模型,融合它们的意义不大;如果相关系数在0.8到0.95之间,融合会有一定收益;在0.8以下,融合收益就很明显。
常用的融合方式有两种,一种是基于权重的线性融合,另一种是stacking。线性融合比较简单,需要手动决定每个模型的权重,我一般用验证集搜索最优权重:
import numpy as np from scipy.optimize import minimize def blend_weight(preds, y_true, metric): # preds: shape = (n_models, n_samples) def loss(w): final_pred = np.average(preds, axis=0, weights=w) return -metric(y_true, final_pred) n = preds.shape[0] cons = ({'type': 'eq', 'fun': lambda w: np.sum(w) - 1}) bounds = [(0, 1)] * n init_w = np.ones(n) / n res = minimize(loss, init_w, bounds=bounds, constraints=cons, method='SLSQP') return res.x这段代码的核心就是通过优化器搜索一组权重,让融合后的预测指标最好。注意,这个过程一定要在验证集上做,不要在训练集上做,否则等于用训练集的结果去调融合权重,会有微小的过拟合风险。更稳妥的做法是,在交叉验证的每一折上都搜索一次融合权重,然后把多折的权重平均。
stacking的优点是不用手动设权重,而是让“元模型”来学习每个基础模型的可信程度。基础模型的预测结果作为新的特征,输入到LightGBM或者简单逻辑回归里做二次训练。但stacking也有坑,如果基础模型之间没有足够的差异性,元模型会过拟合到噪声上。所以我不太建议一开始就上stacking,先把线性融合做了,拿到的分数基本就比较可观了。
4. 那些让我差点放弃的坑
4.1 数据泄露:本地分数虚高的罪魁祸首
在所有坑里,数据泄露是最诡异的,因为它不会让你的程序报错,也不会让你察觉哪里不对,只会让本地验证分数“莫名地高”,然后线上成绩疯狂跳水。
我印象最深的一次是在构造用户历史统计特征时,顺手用了全量数据来算“该用户行为次数的均值”。乍一看完全合理,用户行为次数是已知信息嘛。但实际上这个“均值”里包含了测试集时间段内的行为次数,也就是说模型在预测时已经偷看到了答案的一部分。本地分数一路涨到了能进前十的水平,但一提交线上,直接掉到了中游。那种感觉真的很崩溃。
从那以后,我对每条特征的构造逻辑都会做一次严格审查,问自己三个问题:
- 这个特征在预测时刻t是可以获得的吗?
- 这个特征对测试集中的所有样本都用了吗?
- 如果换一种写法,比如只用训练集计算这个统计量,结果会不会完全不同?
只要任何一个问题的答案是“可能会”,我就会把特征改成局部窗口计算,或者干脆砍掉。
4.2 本地验证与线上分数不一致:如何判断谁出了问题
我一共提交了40多次,大概有10次左右遇到了“本地涨、线上跌”的情况。这种时候,先别急着怀疑评测指标出了问题,大多数情况下是验证集划分和评测机制不一致。
大数据挑战赛的评测方式通常有两种:一种是随机划分,一种是按时间划分。如果赛题是时序预测类,大概率用的是时间划分,也就是训练集是前几个月,测试集是后一段时间。这种情况下,你在本地做K折随机划分验证完全是自欺欺人,因为训练集和验证集之间会有时间重叠的信息泄露。
正确的方式是做时间序列的滑窗验证。假设训练集覆盖1到9月份,你可能用1到8月训练,9月验证,然后滚动往前推。这样验证出的性能才更接近线上。我后来把所有验证都改成了TimeSeriesSplit,分数和线上的一致性大大提升。
另外,如果本地分数和线上分数之间有一个稳定的偏移量,比如本地总是比线上高0.02左右,也不算大问题。只要模型A在本地比模型B高0.005,线上通常也会保持这个相对关系,这已经足够用来做模型选型了。怕的是本地和线上的相对排名经常反转,那说明验证协议本身有巨大的偏差。
4.3 特征重要性的“陷阱”
LightGBM和XGBoost都会给出特征重要性,很多新手喜欢把Top10特征打印出来,然后据此判断哪些特征有用、哪些没用。这个习惯在竞赛里很容易误导人。
特征重要性告诉我们的是“模型在分裂时用得有多频繁”,而不是“这个特征和标签的相关性有多强”。一个特征可能重要性很高,但它只是在捕捉一个对排序帮助很大的模式;另一个特征重要性很低,却可能是模型融合时补短板的关键。
我见过的情况是:某个特征在单模型里重要性排在最前面,但在融合模型里反而成了噪声。也见过某些简单特征,重要性看起来不高,却能在另一个模型里产生明显增益。所以我在筛选特征时不会只看重要性排序,还会结合手动构造的特征列表、相关性矩阵、以及简单的单特征AUC评测来做综合判断。特征重要性的作用,是辅助你发现有问题的特征,而不是最终结论。
4.4 几个涨分点,复盘时才发现很便宜就能拿
有些涨分点,事后看真的就是一层窗户纸,但当时就是没捅破。写在这里算是一份“早知道系列”:
- 统计特征的基本单位粒度。我一开始按“天”做滑窗统计,后来改成按“小时”甚至“半小时”做统计,特征数量变多了不少,分数涨了将近0.01,训练时间只多了两倍,完全划算。
- 负样本的采样策略。在行为预测类赛题里,负样本占绝大多数。适当做负样本欠采样,或者给负样本降权,往往比堆模型更有效。
- 多折预测结果的稳定性。我只用单折验证结果选模型,导致几次选出来的模型在线上表现不稳定。后来改成用5折平均验证结果来选模型,模型稳定性直线上升。
- 最后几天一定要做特征回滚实验。比赛后期我加了很多新特征,单个看都有增益,但组合在一起会出现交互效应变差的情况。把特征集回滚到两周前的版本,融合出来的分数反而更高。这说明特征不是越多越好,保持简洁和可控才是长期的胜利之道。
5. 竞赛之外:关于成长与心态的真实复盘
5.1 一个人打比赛和组队,感受完全不一样
前期的探索阶段,我基本都是一个人闷头跑实验。这样做的优点是可以随时调整方向,不用和队友反复沟通,效率确实高。但到了中后期,一个人扛全流程就有点吃力了。特征工程、模型调参、融合实验、代码复现,每一项都是硬时间。尤其是每天都会产生几十个实验结果,如果不做好记录,两天之后就能把自己绕晕。
后来我找了两个朋友组队,分工是一个人负责数据和特征模块,一个人负责模型和融合,我负责整体的实验管理和方向决策。每个人只做自己最熟悉的一环,产出效率提高了很多,而且队友之间互相review代码,也减少了低级错误。如果你准备参加这类比赛,我建议至少要有2到3个人,一个人打比赛拼的更多是耐力,组队拼的才是智力组合。
5.2 时间管理:竞赛是一场持久战
大数据挑战赛通常持续一到两个月,关键是要把节奏控制好。我根据经验把整场比赛分成三个阶段:
- 第一周:理解赛题、搭环境、跑通baseline、建立本地验证协议;
- 第二到第四周:疯狂做特征工程,每隔几天换一种特征思路;
- 最后两周:回归模型融合、消融实验、稳定结果,并且预留至少三天做最终提交验证。
最怕的是到了倒数第二天才刚找到一个新的涨分点,结果没有时间做多折验证,匆匆忙忙提交,最后发现因为验证不充分,模型在线上反而崩了。所以一定要给自己留出“冷静期”,最后几天只做已成熟方案的重现和微调,不要冒险尝试全新的架构。
5.3 竞赛让我学到的三样本领
第一个本领是“工程化落地能力”。在学校或者公司里,模型的产出往往以报告或者项目交付为终点,而在竞赛里,你需要把每一版实验的结果、特征变化、模型参数、得分全部记录下来,形成一条清晰的实验线。这种记录习惯,放在真实项目里极其有用,能让你快速定位问题。
第二个本领是“对数据的敬畏心”。没有脏数据、没有语义不明的字段,只有你没读懂的数据。很多模型效果不佳的根源不是模型不够强,而是数据处理环节里埋了雷。竞赛帮我养成了“先用统计工具理解数据,再动手建模”的反射式习惯。
第三个本领是“不确定性下的决策能力”。你永远不能指望看一次线上分数就知道模型该往哪个方向调,做好本地验证、分析多个维度的实验结果,在有限时间内做出相对最优的选择,这才是竞赛真正锤炼的能力。
再说一个我个人的小体会:竞赛结束之后,一定要花半天时间把自己的代码库整理成一套可复用的模板,包括数据处理、特征工程、模型训练、融合、验证等模块。这个模板以后无论是应对其他赛题还是做日常工作,都能省下大量的重复劳动。我现在的做法是把所有竞赛的代码整合成一套统一的pipeline框架,下次比赛只需要替换数据读取和特征定义两个文件就够了。
打竞赛的过程像是用一段集中时间,把一个完整的机器学习项目从0到1走了一遍,而且赛题的数据规模、质量、业务复杂度都远超普通练习样例。无论排名如何,这段经历本身已经足够值回票价。