特征工程 Task3:磨刀不误砍柴工,这一步决定了你模型的天花板
如果你已经跟着任务线走到了 Task3,大概率前两步已经跑通了数据读取、清洗和探索性分析(EDA)。这时候很多人会犯一个通病:数据集能跑通 baseline 了,脑子就开始发热,想赶紧调参、上高级模型,看看能不能一把梭出高分。我劝你先停一下。为什么同一个赛题、同一份数据,别人 LightGBM 能跑到 0.75 的 AUC,你跑到 0.73 就死活上不去?特征工程做得糙不糙,在这时候就是分水岭。
Task3 在整条建模流水线里的位置非常特殊。它不像数据清洗那样“脏活累活”,也不像模型调参那样“拼人品”,它更像是把原材料精加工成半成品。你喂给模型什么形状、什么分布、什么组合的信息,模型就只能在这个框框里找规律。特征工程做得越贴近业务本质,模型越能在正确的方向上“使劲”,而不是在噪声里瞎折腾。我见过不少人把 80% 的时间花在 XGBoost 和 LightGBM 的调参上,结果分数纹丝不动;反而是老老实实补了一轮缺失值、做了一组组合特征之后,分数直接拉出一个身位。这篇文章,我就把 Task3 里常见的特征工程套路给你掰开揉碎地讲一遍,从数据质量检查到编码方案,从特征构造到筛选闭环,每一步讲清楚“为什么这么做”,再附上可以直接抄的代码和参数思路,让你少吃点亏,少走点弯路。
1. Task3 内容整体设计与思路拆解
1.1 特征工程到底在解决什么问题
我们先换个角度理解特征工程。假设你是一个厨师,原始数据就是一堆刚从菜市场买回来的食材——带泥的萝卜、带皮的土豆、整块的五花肉。你当然可以把这些东西直接扔进锅里煮,但煮出来的东西大概率难吃且噎人。你得先择菜、洗菜、切菜、腌制,让食材以最适合烹饪的形态下锅。特征工程做的就是“切菜和腌制”这一步。
那模型是锅吗?不完全对。模型更像是一个对“食物形态”极其挑剔的食客。线性模型吃不了非线性关系,需要你把特征做成交叉项它才能看懂;树模型对尺度不敏感,但害怕无意义的稀疏编码和噪声过多的离散取值;神经网络则对数值范围和分布极度敏感,特征不归一化,训练直接振荡甚至梯度爆炸。所以特征工程的第一步,是搞清楚你手里这个模型“喜欢吃什么形态的食物”。
结合大多数 Task3 的场景——不管你是做结构化数据赛题还是业务风控类建模,核心目标无非三点:提升模型性能上限、降低过拟合风险、增强结果的可解释性。三者往往同时发生,因为好的特征不是简单堆变量,而是把业务规律和统计规律拧成一股绳,让模型在更小的假设空间里找到更稳的答案。
1.2 从 Task2 到 Task3 的衔接:先检查数据,再动手加工
很多新手拿到 Task3 的第一反应是上网搜“特征工程代码模板”,然后复制一堆sns.distplot、pd.get_dummies、train.drop(['id'], axis=1),跑完就交作业。这恰恰是本任务最大的陷阱。特征工程不能跳步,它的前置依赖是 Task2 的 EDA 结论:哪些字段缺失严重、哪些字段分布极偏、哪些字段之间的相关性异常、业务字段的逻辑链条是什么。
我建议你在动手做特征之前,先花一个番茄钟的时间做三件小事。第一,重新扫描一遍数据的 shape 和 dtype,确认没有读入时类型被错误解析的问题;第二,把 Task2 里画过的相关性热力图和缺失值矩阵调出来,圈出重点字段;第三,明确任务的评价指标——如果是 AUC,特征工程的侧重点放在排序能力上,如果是对数损失,还要额外注意概率校准。这三件事做完,再进入编码和构造环节,你会发现方向感完全不同。
2. 数据质量关:先把脏数据收拾利索
这是特征工程里最像“体力活”的部分,也是收益最稳定的部分。树模型虽然能勉强应付缺失和异常,但处理方式往往简单粗暴——缺失值它靠无缺失样本的分裂来兜底,异常值则可能直接导致分裂点的漂移。与其把命运交给模型的“忍气吞声”,不如自己动手,把这些数据质量问题解决在投喂之前。
2.1 缺失值处理:别盲目 fillna,先看缺失机制
缺失值的处理策略,取决于数据是“完全随机缺失”(MCAR)、“随机缺失”(MAR)还是“非随机缺失”(MNAR)。这三种机制的差异,直接决定了你用均值填充、中位数填充、众数填充、还是干脆把那列删掉。
实操中我有一个笨但有效的做法:对每一列缺失率做一个排序,然后分三档处理。缺失率低于 5% 的连续特征,直接用中位数填充,外加一个布尔指示列记录“这行是否缺失”;缺失率在 5% 到 30% 之间的,优先尝试用其他字段做简单的模型预测填充,比如用 KNN 回归或者 LightGBM 快速拟合一下;缺失率超过 50% 的,除非业务上极其重要,否则我倾向于直接删除,因为它们很难提供稳定信号,还可能引入大量噪声。
import pandas as pd import numpy as np from sklearn.impute import KNNImputer # 假设 train_df 是你的训练集,特征列集合为 feature_cols feature_cols = ['col_a', 'col_b', 'col_c'] miss_rate = train_df[feature_cols].isnull().mean().sort_values(ascending=False) print("缺失率排序:\n", miss_rate) # 分档处理 low_miss = miss_rate[miss_rate < 0.05].index.tolist() mid_miss = miss_rate[(miss_rate >= 0.05) & (miss_rate < 0.3)].index.tolist() high_miss = miss_rate[miss_rate >= 0.3].index.tolist() # 低缺失率:中位数填充 + 指示列 for col in low_miss: train_df[col + '_is_missing'] = train_df[col].isnull().astype(int) train_df[col] = train_df[col].fillna(train_df[col].median()) # 中等缺失率:KNN 填充 if mid_miss: imputer = KNNImputer(n_neighbors=5) train_df[mid_miss] = imputer.fit_transform(train_df[mid_miss])这里有一个很重要的细节:中位数填充为什么比均值填充更稳?因为均值对异常值敏感,一旦字段里存在极端值,均值会被拉偏,填充结果会系统性地扭曲分布。中位数是 50 分位点,鲁棒性远好于均值。而加了“是否缺失”指示列,是为了告诉模型“这一行的这个字段曾经缺失过”——这在业务上往往本身就是个隐含信号,比如某个收入字段缺失可能意味着该用户收入异常低或不愿透露,这种信号对预测有实际帮助。
2.2 异常值处理:删还是留,取决于业务场景
异常值处理是特征工程里争议最大的环节。很多人上来就用 3-sigma 原则一刀切,凡是超过均值加减 3 倍标准差的数据全部替换或删除。这个做法对正态分布的数据勉强适用,但金融、流量、消费这类长尾特征根本不符合正态假设,3-sigma 会把大量正常的高价值样本误杀。
更稳妥的做法是使用 IQR(四分位距)法,结合业务容忍度做判断。IQR 的公式是 75 分位数减去 25 分位数,一般把低于 Q1 - 1.5IQR 或高于 Q3 + 1.5IQR 的点视为离群点。但注意,这只是一个“提示信号”,不是“处刑判决”。真正的处理分两种:如果异常值明显是数据录入错误(比如年龄 200、负数金额),直接删掉或修正;如果异常值是真实业务极端情况(比如巨额交易、超高消费),我通常会做“截断处理”——把超出 99 分位数的值压缩到 99 分位数的位置,既保留大小关系,又避免极端值主导梯度。
# 用分位数截断处理异常值 def cap_outliers(series, lower_q=0.01, upper_q=0.99): lower = series.quantile(lower_q) upper = series.quantile(upper_q) return series.clip(lower, upper) for col in ['amount', 'duration']: train_df[col] = cap_outliers(train_df[col])我一直强调一个观念:异常值处理不是为了让数据“好看”,而是为了模型稳定。树模型对单点异常并不敏感,但如果你后面要上神经网络或线性模型,异常值会直接影响权重更新。所以处理到什么程度,取决于你的模型选型——这是我做了多个项目之后才悟到的一点。
3. 编码方案与数值化:把“文本”翻译成“数学”
结构化数据里,类别特征是最常见的修罗场。几十个离散取值怎么转数值、高低基数的处理方式有什么区别、标签编码和独热编码分别适合什么模型——这些细节直接决定了特征能否被有效利用。
3.1 低基数类别特征:独热编码的适用边界
独热编码(One-Hot Encoding)是最直观的编码方式:把每个类别拆成一个 0/1 的哑变量。它的优点是绝对无损、无顺序假设,缺点是当类别基数变大时,维度爆炸问题非常严重。一个 100 个类别的字段,直接独热出来就是 100 列稀疏特征,不仅内存吃紧,树模型在做分裂时还会面临“切分点选择困难症”——因为每个哑变量都太稀疏,有效信息被稀释了。
所以我的经验是:独热编码只适用于类别数不超过 10 个、且类别之间有明确独立关系的字段。超过这个阈值,优先考虑目标编码或频率编码。在 sklearn 里可以直接用OneHotEncoder配合handle_unknown='ignore',防止测试集出现训练集没见过的类别时直接报错。
from sklearn.preprocessing import OneHotEncoder encoder = OneHotEncoder(handle_unknown='ignore', sparse_output=False) encoded = encoder.fit_transform(train_df[['city']]) encoded_df = pd.DataFrame(encoded, columns=encoder.get_feature_names_out(['city']))3.2 高基数类别特征:目标编码的原理与防过拟合细节
当类别数量超过 20 个,或者类别分布极不均衡时,独热编码就不好使了。这时候业界最常用的是目标编码(Target Encoding),也叫均值编码。原理朴素得惊人:用每个类别下目标变量的均值来代替原始类别。比如电商场景里,某个城市编码后变成“该城市用户的购买率”,模型天然就能学到“高购买力城市”这个信息。
但目标编码最大的坑是过拟合:如果用全量训练集去计算类别均值,那么频次低的类别会被直接拉向训练集目标均值,模型在训练集上表现虚高,测试集上立刻露馅。解决方法是两件套:第一,在训练集内部做 K 折交叉验证的折叠内编码——每一折只用其他折的数据计算编码值,然后映射到本折;第二,加平滑参数,让低频类别的编码向全局均值收缩。平滑公式长这样:
编码值 = (类别目标值之和 + 平滑系数 * 全局均值) / (类别样本数 + 平滑系数)平滑系数一般取 5 到 20 之间,具体多少可以调,但不要超过样本量的数量级。下面是一个折叠内目标编码的参考实现,可直接套用:
from sklearn.model_selection import KFold def target_encoding_with_cv(train, feature, target, k=5, smoothing=10): global_mean = train[target].mean() train['fold'] = -1 kf = KFold(n_splits=k, shuffle=True, random_state=42) for fold_id, (tr_idx, val_idx) in enumerate(kf.split(train)): train.loc[val_idx, 'fold'] = fold_id encoded_col = np.zeros(len(train)) for fold_id in range(k): tr_mask = train['fold'] != fold_id val_mask = train['fold'] == fold_id tr_data = train[tr_mask] val_data = train[val_mask] agg = tr_data.groupby(feature)[target].agg(['sum', 'count']) agg['smoothed'] = (agg['sum'] + smoothing * global_mean) / (agg['count'] + smoothing) encoded_col[val_mask] = val_data[feature].map(agg['smoothed']).fillna(global_mean).values train[feature + '_target_enc'] = encoded_col return train这里最关键的就是“fold 内编码”这个思路。很多人在这一步图省事,全局算完直接映射,短期内分数还行,但模型稳定性和泛化能力都打了折扣。你如果想让分数经得起推敲,这步不能省。
3.3 时间戳与数值分布变换:把隐藏的周期性挖出来
时间字段是特征工程里常常被白扔的金矿。2024-06-15 14:32:45这种字符串,如果不处理,模型眼里就是一串文本。正确的姿势是把时间戳拆成年、月、日、小时、星期几、是否周末、昼夜时段等派生特征,尤其是“星期几”和“是否节假日”,在很多业务场景里对用户的活跃度和购买意愿有极强的区分度。
再来说数值分布。很多连续特征(比如交易金额、点击量、粉丝数)都满足幂律分布:大量样本集中在低值区,少数样本冲到极高值区。如果不做处理,树模型还能硬扛,但线性模型和神经网络会非常难受。常见做法是做 log1p 变换:对 x 取 log(1 + x),把严重的右偏分布拉向正态。好处有两个,一是压缩极端值的影响,二是让特征的数值差异在更合理的尺度上体现。
# 时间特征拆解 dt = pd.to_datetime(train_df['apply_time']) train_df['year'] = dt.dt.year train_df['month'] = dt.dt.month train_df['day'] = dt.dt.day train_df['weekday'] = dt.dt.weekday train_df['hour'] = dt.dt.hour train_df['is_weekend'] = (dt.dt.weekday >= 5).astype(int) # 长尾特征 log 变换 import numpy as np for col in ['amount', 'pv_cnt']: train_df[col + '_log'] = np.log1p(train_df[col].clip(lower=0))4. 特征构造:从“有特征”到“创造特征”
数据清洗和编码解决了“把特征洗干净”的问题,但特征工程真正的威力在“无中生有”。一个业务字段本身可能信息量有限,但多个字段的组合、聚合、差值往往蕴含着模型自己发现不了的高阶规律。我见过无数个案例,模型分数卡住不动,结果加了一两个组合特征之后直接起飞。
4.1 组合特征与交叉特征:1+1>2 的数学原理
组合特征的本质是让模型不用自己去拟合特征之间的交互关系,而是直接把这些交互项喂给模型。比如在风控场景里,单看“负债率”和“收入”两个字段,模型可能学不到“高负债且低收入”这个致命组合;但如果你直接构造一个“负债/收入”的比率特征,等于帮模型提前圈出了风险区域。
树模型虽然具备自动寻找分裂点的能力,也能在深层分裂里模拟交互,但这种模拟是隐式的、需要大量数据支撑的。显式特征的作用是降低模型的学习难度,尤其是样本量不够大的时候效果明显。常见的组合方式有:加减组合(a - b)、比率组合(a / b,注意除零处理)、乘积组合(a * b),以及针对类别字段的“笛卡尔组合”——比如把“城市”和“渠道”拼成一个新字段,等于把所有城市-渠道组合都暴露给模型。
# 比率特征和差值的构造 train_df['debt_to_income'] = train_df['debt'] / (train_df['income'] + 1e-6) train_df['income_debt_diff'] = train_df['income'] - train_df['debt'] train_df['amount_avg_diff'] = train_df['amount'] - train_df.groupby(['city'])['amount'].transform('mean') # 类别字段笛卡尔组合 train_df['city_channel'] = train_df['city'].astype(str) + '_' + train_df['channel'].astype(str)笛卡尔组合搞出来的新类别特征,往往也需要再做一次编码处理。很多人在这里会陷入“做了组合 → 编码维度爆炸 → 又删掉”的循环,所以我给你的建议是:组合后优先用频率编码或目标编码压缩维度,而不是继续独热。
4.2 统计聚合特征:分组计算是信息量最大的操作
如果说组合特征是在“行”的方向上做手脚,那么统计聚合特征就是在“列”的方向上挖信息。它的核心思想是:对某个 key(比如用户 ID、城市、渠道)做分组,然后计算组内的均值、标准差、最大值、最小值、中位数、计数等统计量,把这些量作为新特征拼回原表。
这个方法在用户行为类数据里效果奇好。比如你有用户的多次借款记录,单个记录只是“某一次”的行为,但按用户 ID 聚合出来的“借款次数”、“平均借款金额”、“最大间隔天数”,刻画的是用户稳定画像。模型拿到这类特征,相当于看到了用户的全貌而不是一个切片。
# 按用户 ID 聚合生成统计特征 agg_dict = { 'amount': ['mean', 'std', 'max', 'min', 'count'], 'duration': ['mean', 'max'] } grouped = train_df.groupby('user_id').agg(agg_dict) grouped.columns = ['_'.join(col).strip() for col in grouped.columns.values] grouped = grouped.reset_index() train_df = train_df.merge(grouped, on='user_id', how='left')这里有几个易踩的坑。第一,std在分组样本量为 1 时会产生 NaN,需要用fillna(0)兜底;第二,聚合特征和原始特征之间可能高度共线,比如“借款总金额”就是“单次金额”的聚合,如果不加区分,反而会增加模型负担,所以我会在下文的特征筛选环节统一处理。
4.3 时序滑窗特征:让模型看到“趋势”而不是“孤立点”
如果数据里带时间序列结构,滑窗特征是比分箱和聚合更能体现动态趋势的手段。思路是:对每个时间点,用过去 N 天的数据计算滚动均值、滚动标准差、滚动最大值,生成一个“该用户最近 N 天表现”的窗口视图。这种特征对风险模型尤其重要,因为一个人过去 7 天的活跃度,往往比全量历史均值更能预示未来几天的行为。
# 按用户和时间排序后计算滚动特征 train_df = train_df.sort_values(['user_id', 'apply_date']) train_df['amount_rolling_mean_7'] = ( train_df.groupby('user_id')['amount'] .transform(lambda x: x.rolling(7, min_periods=1).mean()) ) train_df['amount_rolling_std_7'] = ( train_df.groupby('user_id')['amount'] .transform(lambda x: x.rolling(7, min_periods=1).std()) .fillna(0) )滚动特征的坑在于排序和 groupby 的顺序:必须先确保同一用户内的数据按时间正确排序,否则滚动窗口会串行。另外要注意min_periods参数,窗口内数据不足时到底算不算,直接影响特征稳定性——我习惯设成 1,避免前面的样本全部变 NaN。
5. 特征筛选与效果闭环验证:别让你的模型被噪音淹没
特征构造做完之后,特征数量往往会从二十多个膨胀到上百个。全量丢进模型,不仅训练时间长,还容易引入大量无关噪声和多重共线性,导致模型过拟合。这时候就需要做减法。
5.1 过滤法、包装法和嵌入法怎么选
特征筛选的主流方法有三类,选择逻辑很简单。过滤法(Filter)不依赖任何模型,直接算特征和目标之间的相关性、卡方值、互信息,速度快但忽略特征之间的交互;包装法(Wrapper)把特征子集的性能当目标函数,比如递归特征消除(RFE),效果好但计算代价高;嵌入法(Embedded)在模型训练过程中自动完成特征选择,比如 L1 正则化、树模型的特征重要性,效率和效果比较均衡。
我的建议是分两步走:第一步,用过滤法快速淘汰一批明显没用的特征——比如方差接近 0 的常量特征、和标签相关性低于 0.01 的无关特征;第二步,用树模型(LightGBM 或 XGBoost)训练一轮,输出特征重要性排序,把排序靠后的特征再看一眼,确认没有隐藏价值后删掉。
from sklearn.feature_selection import VarianceThreshold # 第一步:过滤低方差特征 selector = VarianceThreshold(threshold=0.01) selector.fit(train_df[feature_cols]) keep_cols = train_df[feature_cols].columns[selector.get_support()] print(f"低方差过滤前: {len(feature_cols)},过滤后: {len(keep_cols)}") # 第二步:LightGBM 特征重要性筛选 import lightgbm as lgb model = lgb.LGBMClassifier(n_estimators=500, learning_rate=0.05, random_state=42) model.fit(train_df[feature_cols], train_df['target']) importance_df = pd.DataFrame({ 'feature': feature_cols, 'importance': model.feature_importances_ }).sort_values('importance', ascending=False) print(importance_df.head(20))5.2 共线性处理与 SHAP 解释补充
特征多了之后,共线性是隐形杀手。两个特征同涨同跌,模型会把权重分散到两者身上,导致结果不稳定。判断共线性最直观的方法是算相关矩阵,找出相关系数绝对值超过 0.8 的特征对,保留其中一个,另一个删除。哪个该留?看它们和目标的单变量相关性,相关性高的留下;如果差不多,看业务可解释性,更容易讲故事的留下。
如果你还想更进一步,可以上 SHAP 分析。SHAP 值能告诉你每个特征对每个样本的预测贡献方向和大小,比特征重要性更细致。它最大的价值是发现“反直觉”的特征——有些特征全局重要性不高,但对特定样本子集的影响极大。做完 SHAP 可视化之后,你会对特征和业务之间的关系有更深的理解,也更容易反过来指导下一轮特征构造。
import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(train_df[feature_cols]) shap.summary_plot(shap_values, train_df[feature_cols], max_display=20)5.3 用一次模型对比验证特征工程效果
特征工程的最后一步,也是最容易被跳过的一步:验证。怎么验证?简单粗暴——在同样的模型配置和同样的交叉验证方式下,分别跑“特征工程前 baseline”和“特征工程后 enhanced”两版模型,对比分数提升幅度和稳定性。
我强烈建议把这个对比过程固定成脚本,每次加完特征都跑一遍。原因有两个:一是防止无效劳动——你费半天劲构造的特征可能根本没有增量信息,白搭;二是防止过拟合——有的特征在训练集上提升明显,交叉验证却掉点,这说明特征泛化能力差,该删就删。下面给一个对比验证的骨架:
from sklearn.model_selection import cross_val_score from sklearn.metrics import roc_auc_score def evaluate_model(model, X, y): scores = cross_val_score(model, X, y, cv=5, scoring='roc_auc') return scores.mean(), scores.std() baseline_cols = [c for c in feature_cols if 'target_enc' not in c and 'rolling' not in c] model = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, random_state=42) baseline_auc, baseline_std = evaluate_model(model, train_df[baseline_cols], train_df['target']) enhanced_auc, enhanced_std = evaluate_model(model, train_df[feature_cols], train_df['target']) print(f"Baseline AUC: {baseline_auc:.5f} ± {baseline_std:.5f}") print(f"Enhanced AUC: {enhanced_auc:.5f} ± {enhanced_std:.5f}") print(f"提升: {(enhanced_auc - baseline_auc) * 100:.2f}%")如果 enhanced 版本没有明显优势,甚至标准差变大,那就说明新增的特征可能引入了噪声。别舍不得删,特征工程里“做减法”同样重要。我个人的习惯是每个项目至少做三轮“构造—验证—删减”的循环,最后留下来的特征,每一列我都能说出它存在的理由。
6. 常见问题与排查技巧实录
做特征工程这些年,有些坑我踩过不止一次,每次都是在群里被新人问了一遍又一遍。我把它们整理成速查表,希望能帮你避开这些常见的坑。
6.1 线上和线下不一致是最致命的坑
特征工程里翻车率最高的情况,是训练集和测试集的特征分布或构造方式不一致。比如你在训练集里用全局中位数填充缺失值,但推理时用测试集单独计算的中位数,两边分布对不上;或者目标编码在训练集里用了测试集信息(通常叫“标签泄漏”),线下分数虚高,上线直接崩掉。
正确的做法是:所有特征处理的参数(填充值、编码映射、分箱边界、标准化均值和方差)都在训练集上拟合,然后直接 transform 到测试集,绝不在测试集上重新 fit。这个原则要刻在脑子里——统一使用fit_transform和transform的组合,而不是对两份数据分别调用fit_transform。
6.2 时间泄漏:用未来的数据预测过去
很多结构化数据里隐含时间顺序,如果你用当月的交易数据去预测当月是否违约,数据里可能已经包含了未来信息。最常见的时间泄漏发生在聚合特征里:用全量历史(包括“未来”的记录)去计算用户均值。解决方法是在构造聚合特征时,严格限定只使用当前时间点之前的数据。这块如果不注意,你的模型分数会虚高得让人发慌。
6.3 高频类别太多,目标编码平移灾难
目标编码在类别频次极低的情况下很容易翻车。比如某个类别只有 1 个样本,它的编码值就等于该样本的目标值,模型看到这个特征基本等于直接看到了答案,过拟合到怀疑人生。解决办法:一是平滑系数调大一些;二是给低频类别单独归为一类“其他”;三是用交叉验证折叠内编码,限制信息泄漏。三种方法可以叠加使用,效果更稳。
6.4 特征数量过多导致训练时间失控
有些人恨不得构造 500 个特征,结果 LightGBM 训练时间暴涨十倍。这时候除了精简特征,还可以用两个思路:一是把类别特征声明为categorical_feature,让 LightGBM 原生处理,省去独热编码的时间和内存;二是在调参前先用少量迭代(比如n_estimators=50)快速试错,确认特征组合有效再开全量训练。
7. 最后再分享两个实操心得
先说说代码流程的组织。特征工程一做起来,代码很容易变成一团乱麻:清洗、编码、构造、筛选全混在一起,想回头改一个步骤就得从中间断点重跑。我的建议是把整个特征工程流程封装成一个独立的 Python 脚本或者 Notebook cell 序列,每个步骤输出一个中间版本的数据文件,用时间戳命名。比如train_data_step1_clean.parquet、train_data_step2_encoded.parquet。这样每轮迭代只用重跑一个环节,排查问题也有据可查。
再说说特征命名和文档化。这不是形式主义,而是为了救命。三个月后你回看自己的代码,看到一列叫col_features_1_comb,你会一头雾水;但如果叫x_city_target_enc_k5_smooth10,你一眼就能看出它是“城市字段用 5 折交叉验证、平滑系数 10 做的目标编码”。命名规范花不了你两分钟,但能省下未来三小时的回忆时间。
回到 Task3 本身。这个任务最打动我的地方在于:它是整条建模流水线里最“独立”的环节,却也最依赖你对业务的透彻思考。缺失值怎么填充、异常值怎么截断、特征怎么组合、哪些特征留下来——每一个决策背后,都是你对数据背后业务逻辑的理解。模型调参调的是数字,特征工程调的是理解。做完了这一步,你的模型才真正算得上是在替你“懂”这份数据。