先交代一下背景。泰迪杯数据挖掘挑战赛的A题历来不是靠一个单模型硬跑就能拿高分的真正拉开差距的环节几乎都集中在数据理解和特征设计上。我们队当时拿到题目后第一件事不是调参而是花了两天时间把表拆开、把字段含义捋清楚这个前期投入后面看非常值。这篇文章把我们队的完整处理流程整理出来——从数据预处理的细节、特征工程的取舍到模型融合和提交阶段踩过的坑能贴的代码我都贴出来了。适合准备参加这类数据挖掘竞赛、或者想用一份完整项目练手的读者参考尤其是第一次接触多表关联业务场景的朋友这届A题的处理思路可以当模板用。1. 赛题整体拆解与方案路线1.1 题目到底在考什么这届A题表面上看是一个典型的二分类预测任务给定一批用户的历史行为数据和基础信息要求预测用户在某个时间窗口内是否会发生目标行为。数据以多张表的形式提供有用户信息表、行为流水表可能还有商品或订单表。这种出题方式其实是刻意设计的。它考验的不是“你会不会调用LightGBM”而是你能不能把多张表像拼图一样拼出有效信息同时不引入数据泄漏。很多人上来就把表一顿join然后丢进模型结果线下AUC很高、线上崩盘十有八九是特征穿越了时间边界。我们拿到题后先做了一件事明确评价指标。这届A题用的是AUC这个信息基本决定了后续所有策略的走向。它意味着我们不需要刻意调分类阈值也不需要担心样本不均衡到无法训练的地步重点应该放在排序能力的提升上。换句话说如果模型能把正样本稳定排在负样本前面分数就能稳步上涨。1.2 从baseline到最终方案的迭代路径我习惯把比赛流程拆成几个阶段每个阶段都设一个可量化的目标避免后面越调越乱。我们的迭代路径是这样的第一版baseline只做最简单的缺失填充和字段拼接LightGBM默认参数AUC约0.72。这个版本主要用来验证训练代码和提交格式跑通。第二版补上时间窗口聚合特征包括最近7天、30天的行为次数、金额、类目偏好等AUC提升到0.754。第三版加入目标编码和类别特征频次编码同时做了严格的时间交叉验证AUC涨到0.771。第四版三模型融合加阈值校准线上最终稳定在0.785左右。这个提升幅度不算惊人但每一步都踩得很实。关键经验是不要一上来就上stacking先把单模型特征做到极致再谈融合否则融合只是在平均噪声。2. 数据预处理实操先把脏数据清理干净2.1 缺失值、重复值、异常值的处理原则数据预处理阶段最容易犯的错是“无脑删除”。缺失率高的列不代表没有价值很多场景下缺失本身携带信息。比如某个用户没有填写职业信息可能说明注册渠道比较随意这类用户的后续行为质量和完整填写用户会有显著差异。我们的做法分三步。第一步统计每列的缺失率区分出“完全随机缺失”和“业务性缺失”。对于业务性缺失比如只有下单用户才有售后记录那就把缺失当作一个独立类别处理单独标记一个“是否缺失”特征而不是直接填充均值。第二步处理重复行。行为流水表可能出现完全重复的记录训练集里重复会导致权重扭曲测试集里重复会干扰预测结果。我们按所有字段去重把重复率超过一定阈值的记录单独挑出来核对。第三步异常值处理。这里我说的不是简单的“超过3倍标准差就删”而是要看分布。比如金额字段呈长尾分布直接截断反而会丢失真实信息。我们采用的策略是大于99.9分位数的值拉到99.9分位低于0.1分位数的值同样缩尾处理保留分布形态但削弱极端值的影响。下面是当时处理缺失值的一段核心代码思路比代码本身更值得参考import pandas as pd import numpy as np def fill_missing_by_type(df, fill_cols, date_col, id_col): for col in fill_cols: if col in df.columns: # 分类变量缺失作为独立类别 if df[col].dtype object: df[col _missing] df[col].isna().astype(int) df[col] df[col].fillna(UNKNOWN) # 数值变量先按时序填充前向值再按用户均值兜底 else: df[col] df.groupby(id_col)[col].ffill() df[col] df.groupby(id_col)[col].transform(lambda s: s.fillna(s.mean())) return df注意fillna的时机必须在构造特征之前不能先做聚合再补缺失否则会把聚合结果里的缺失带到下游调试时候非常难查。2.2 时间窗口与时序泄漏的边界A题的数据有明确时间轴所以“时间穿越”是我们最警惕的问题。简单说预测目标放在未来特征只能使用目标时刻之前的信息一旦用了未来数据线下指标会虚高但线上直接失效。我们团队内部有一个硬性约定所有特征构造函数都必须带上时间参数函数内部先按截止时间过滤数据再做聚合。这样每个人写的特征都天然遵守时序约束避免某个人悄悄用了全量数据导致线下分数虚高。还要注意训练集和测试集的时间分布。当时我们检查发现训练集覆盖的时间区间比测试集更靠前这就导致直接随机划分验证集会高估模型表现。我们的做法是按时序切分验证集比如用最后20%的时间段作为验证集这样反馈的信号才接近线上真实情况。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) for train_index, valid_index in tscv.split(features): X_train, X_valid features.iloc[train_index], features.iloc[valid_index] y_train, y_valid target[train_index], target[valid_index] # 每次fold都在该fold内部进行特征拟合禁止用到未来数据这段代码值得多说一句。TimeSeriesSplit不是简单按行切分它是按顺序递增地扩大训练集保证训练集永远在验证集之前。我们在做时序特征时比如“过去7天行为次数”都是在每个fold内部对训练部分做groupby再映射到验证部分这样才算干净。3. 特征工程决定模型上限的关键3.1 聚合统计特征的设计思路特征工程阶段我们的核心方法是围绕“用户-时间-行为类型”三个维度做聚合统计。一个用户在一段时间内的行为次数、行为金额总和、行为类型数、行为间隔等都是非常强力的信号。具体来说我们构造了以下几类特征计数类近3天、7天、14天、30天的行为总次数行为类型去重数活跃天数。金额类近7天、30天的行为金额均值、最大值、总和金额稳定性标准差。间隔类最近一次行为距目标日期的天数两次行为之间的平均间隔最长连续活跃天数。类目偏好类出现次数最多的行为类型以及该类型次数占总次数的比例。这些特征的意义在于把用户的行为习惯量化。比如一个用户近30天行为间隔平均是2天说明很活跃另一个用户间隔平均是30天说明是低频用户。模型很容易从这些数字里发现区分正负样本的规律。聚合特征的代码我用的是一个批量生成函数把不同窗口、不同聚合函数组合出来def gen_window_agg(df, group_col, feat_col, windows, aggs): for w in windows: for agg in aggs: col_name f{feat_col}_win{w}_{agg} # 只取截止时间之前 w 天内的数据 tmp df[df[days_to_target] w] tmp tmp.groupby(group_col)[feat_col].agg(agg).rename(col_name) df df.merge(tmp, ongroup_col, howleft) return df实操心得窗口越短的特征越容易过拟合尤其是近1天、近3天的统计很多用户根本没有行为数据结果是大量NaN。这类特征要么不建要么在缺失率超过70%时直接删除不要硬留。3.2 目标编码与频次编码的正确打开方式目标编码这招用好了提分明显用不好就是过拟合放大器。基本原理是把类别特征映射为“这个类别对应的目标均值”把类别信息压缩成一个数值。但如果直接在全量数据上计算目标均值训练集和验证集之间会发生信息泄漏模型会记住验证集的目标分布。我们采用的方式是K折目标编码在每一折内用其他折的数据计算目标均值来编码当前折。这样每个样本的目标编码值都不包含自己的标签信息从根上避免泄漏。from sklearn.model_selection import StratifiedKFold def target_encoding_kfold(df, feature, target, n_folds5): df df.copy() kf StratifiedKFold(n_splitsn_folds, shuffleTrue, random_state42) df[feature _target_enc] np.nan for tr_idx, va_idx in kf.split(df, df[target]): # 计算训练部分的全局目标均值作为平滑基 global_mean df[target].iloc[tr_idx].mean() # 类别内的目标均值 enc_map df.iloc[tr_idx].groupby(feature)[target].agg([mean, count]) # 平滑系数类别样本数越多越信任该均值 smooth 10 enc (enc_map[mean] * enc_map[count] global_mean * smooth) / (enc_map[count] smooth) df.loc[va_idx, feature _target_enc] df.loc[va_idx, feature].map(enc) # 测试集使用全量训练数据编码 full_enc df.groupby(feature)[target].agg([mean, count]) global_mean df[target].mean() full_enc (full_enc[mean] * full_enc[count] global_mean * smooth) / (full_enc[count] smooth) return df, full_enc这里平滑系数smooth需要说一下它决定了小样本类别的编码值往全局均值靠多少。类别中样本量少的时候原始均值波动很大加大平滑能抑制噪声样本量大的类别原始均值本身就稳定平滑影响很小。我们试过5、10、20、50最终10效果最好。类别频次编码则简单很多就是统计每个类别在训练集中出现的次数然后映射到样本上。频次高通常意味着常见用户或常见商品而频次特别低的可能意味着异常样本或新用户模型能从中捕捉到规律。这个特征虽然看似简单但往往比某些复杂特征提分更明显。4. 建模调参与模型融合4.1 LightGBM、XGBoost、CatBoost 三件套配置这三棵树各有各的强项我们选择的是都跑一遍然后融合而不是只押一个。具体配置上我按经验做了针对性设置而不是用默认参数硬跑。LightGBM的核心配置import lightgbm as lgb lgb_params { objective: binary, metric: auc, learning_rate: 0.02, num_leaves: 31, min_child_samples: 50, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l1: 1.0, lambda_l2: 1.0, max_depth: -1, verbose: -1, seed: 42 } model_lgb lgb.train( lgb_params, lgb.Dataset(X_train, y_train), valid_sets[lgb.Dataset(X_valid, y_valid)], num_boost_round5000, callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)] )learning_rate设成0.02配合5000轮和早停这样模型有足够空间慢慢学习不容易在早期过拟合。num_leaves设31而不是默认的更大值是因为我们特征维度不算高太深的树容易记住噪声。min_child_samples设50保证叶子节点样本数进一步压制噪声。XGBoost的配置大同小异但要注意它和LightGBM在处理类别特征上的差异。XGBoost对分类型特征支持不友好我们所有类别特征都已经完成了数值化编码所以可以直接传入。CatBoost则相反它原生支持类别特征我们保留了部分原始类别字段给CatBoost让它自己处理类别组合有时能发现树模型手工交叉特征发现不了的规律。4.2 融合策略平均、加权与Stacking的取舍我们最终没有做复杂的Stacking原因很实际单折Stacking容易过拟合多折Stacking代码复杂、时间成本高而且提升幅度未必比加权平均大。我们用的方案是排名加权平均。思路是先把每个模型在验证集上的预测概率转换成排名再对排名做加权平均。这样避免了不同模型概率分布不一致的问题。比如LightGBM预测概率普遍集中在0.3到0.6而CatBoost可能集中在0.1到0.9直接平均概率会让分布窄的模型影响力变小但排名平均就不会有这个偏差。from scipy.stats import rankdata def rank_ensemble(*preds, weightsNone): if weights is None: weights [1.0 / len(preds)] * len(preds) ranked [rankdata(p) / len(p) for p in preds] ensemble sum(r * w for r, w in zip(ranked, weights)) return ensemble / sum(weights) final_pred rank_ensemble(pred_lgb, pred_xgb, pred_cat, weights[0.4, 0.3, 0.3])权重怎么定我们先用验证集分别计算每个模型的AUC然后用网格搜索试了若干组权重组合选验证集AUC最高的一组。这里有个细节验证集必须单独留出来做权重搜索不能在训练集上反复试否则权重会过拟合验证集到了测试集就失效。后处理方面因为评价指标是AUC我们不需要调整阈值直接提交概率本身即可。但有一点值得提如果预测概率出现大量完全相同的值比如某模型输出0.5附近的样本特别多排名平均时会引入大量随机顺序这时候要检查是不是模型欠拟合或者训练不充分。5. 常见问题排查与比赛避坑手册5.1 高分路上最容易翻车的细节这批坑我们基本都踩过写出来帮大家少走弯路。第一个坑是索引错位。多表拼接后DataFrame的索引可能乱掉合并特征时用reset_index还是set_index搞混导致特征列和目标列对不上。检查方法很简单在训练前打印X_train和y_train的索引是否完全一致或者直接reset_index(dropTrue)统一索引。第二个坑是内存爆炸。行为流水表如果几百行做groupby聚合时可能撑爆内存。我们当时的解法是先用分类编码把字符串列转成数值类型再把聚合结果提前存储避免重复计算。另外groupby之前按用户id分区处理也能省不少内存。第三个坑是提交格式问题。比赛平台要求提交的列名必须精确匹配多一列少一列都不行。我们第一次提交就因为多了一列index字段被判格式错误白白浪费一次提交机会。建议在submit之前用pd.read_csv读回来看一眼列名和顺序。第四个坑是复现性。我们队用random_state42控制了所有模型的种子但发现不同机器上跑出的结果还是略有差异。后来定位到是不同环境下浮点数精度不同。比赛期间尽量统一环境提交前在固定机器上重新跑一次最终版本不要用中间结果提交。5.2 问题速查表现象可能原因解决方式线下AUC极高线上明显偏低特征泄漏或验证集划分不合理检查特征构造是否有未来信息改用TimeSeriesSplit模型预测概率集中区分度低特征区分度不够或学习率太低未收敛增加聚合特征提高学习率并延长训练轮数融合后分数低于单模型预测概率分布差异大简单平均失真改用排名平均或尝试不同权重组合提交一直报格式错误列名、列顺序、行数不匹配重新读入提交文件核对提交模板训练过程内存不足特征矩阵过大或数据类型为object数值化类别字段分块处理聚合删除高缺失率特征避坑技巧每周提交之前固定一个晚上做“复盘冻结”。把所有实验记录整理出来确定接下来只做哪些尝试其余想法一律延后。这样能避免比赛后期方向混乱也能保证每次提交都基于可靠结果。6. 最后一点个人体会这届A题打下来我们队最大的收获不是最终分数而是建立了一套规范的数据挖掘流程。现在回头看很多队伍输在“跑得太快”数据没看明白就开始建模特征不做时序校验就丢进模型发现问题后又推倒重来反复消耗时间。我们的节奏是前期慢、中期稳、后期少改动整个流程走下来心里一直有底。如果你也是第一次打这种比赛我的建议是第一版baseline一定要简单先保证训练和提交的链路是通的然后集中精力把特征工程做扎实尤其是时间窗口特征和目标编码这两项的提分效率最高最后再考虑融合。比赛比的不是谁的模型花哨而是谁的流程更可靠、谁的错误更少。希望这篇文章能帮你少踩几个坑祝参赛顺利。