在最近一次客户流失预测的项目里,我遇到了一件让自己印象很深的事:第一轮特征处理(缺失值填充、归一化、类别编码)做完之后,LightGBM的AUC停在0.72左右,不管怎么调参都上不去。后来我停下调参,回头仔细看特征,做了第二轮特征处理——构造交叉特征、做log变换、处理长尾分布、做特征选择——同样一个模型,AUC直接跳到0.79。这就是我理解的"特征处理2"。
这个"2"指的不是简单地把同样的流程再跑一遍,而是指在基础特征处理之上,我们需要补上的那一整套进阶操作:特征构造、特征选择、分布变换、不平衡处理、泄漏排查。很多刚开始学机器学习的人都会用sklearn跑通完整流程,但模型效果就是卡在某个瓶颈上,换算法、调参、加数据都试了也没用。其实很多时候问题就出在这里:基础特征处理只完成了"让数据能被算法读进去"这一步,而真正决定模型上限的,是这些更进阶的特征设计、筛选和校验环节。
这篇文章适合什么人看?如果你已经能用Python跑机器学习流程,熟悉pandas和sklearn的基本用法,但总觉得特征工程这部分"差一口气";或者你在备战期末、准备项目答辩,想系统地梳理特征处理相关的知识点;又或者你只是好奇为什么Kaggle上那些高分方案的特征处理看起来那么复杂——我觉得这篇内容都能给你一些实际参考。我会按实际项目的推进思路来讲,尽量少写教科书式的空话,多写能直接落地的东西。
1. 为什么还要做"特征处理2":第一轮处理掩盖了哪些问题
1.1 从一次流失预测项目的复盘说起
先说那次客户流失预测的事。业务方给的数据大概有30多个字段,包括用户的注册天数、最近访问时间、历史订单数、平均客单价、投诉次数等等。第一轮我做了常规操作:缺失值用中位数填充,数值字段做标准化,城市和渠道这类类别字段做one-hot编码。然后直接扔给LightGBM训练,验证集AUC稳定在0.72左右。
这个结果说差不差,但业务方希望到0.75以上。我一开始也以为是算法不够强,先后试了XGBoost、CatBoost,又调了一轮学习率和树深度,AUC纹丝不动。后来冷静下来,去看特征的分布,问题就很明显了:注册天数这个字段,大多数用户集中在几千天以内,但有一小撮多年老用户把分布拉得很长,整个特征呈很强的长尾形态;访问间隔这类字段也是类似的情况。对树模型来说这种分布虽然不会报错,但会让分裂点选择偏向头部密集区间,对长尾部分的信息利用效率很差。我做了log变换之后,AUC从0.72涨到了0.74。
这还只是第一步。接着我构造了一个"距上次购买天数"的特征,把订单时间戳和最近访问时间做了差值;又构造了"注册天数除以历史订单数"这样的组合特征。整个过程中没有任何算法层面的改动,最后AUC到了0.79。这个复盘给我的结论很简单:当模型效果遇到瓶颈时,先把特征层级往上推一层,往往比换模型、调参数更有效。
1.2 模型没提升,先别急着换算法
很多人的第一反应是"我的算法不行",但从我的经验来看,尤其是那种已经做过基础清洗的项目,最大的瓶颈往往在特征表达上。机器学习模型本质上在做的事,就是从输入特征里寻找与目标相关的模式。如果特征本身没有把这种模式表达出来,模型再复杂也学不到你想要的规律。
打个比方,算法像发动机,特征处理像油路。油路里杂质多、管路窄,发动机再好也发挥不出来。很多人在油路不通的时候拼命提升发动机,效果自然有限。特征处理2的核心目标,就是把这套"油路"清理到能让发动机发挥出真实水平的状态。这里绝对不是说调参和换算法不重要,而是说在动手调参之前,应该先花时间审视一下特征层面还有没有提升空间。尤其是当模型效果已经稳定、但达不到业务预期的时候,特征处理往往是投入产出比最高的一步。
我见过不少同学在项目里陷入"调参循环"——每天都在调learning_rate、max_depth这些参数,调了半个月,AUC还是0.71。有一次我建议他停下手上的调参工作,花两天时间专门去构造特征,结果第三天模型就过了基线。很多时候不是算法不给力,而是数据里那些真正影响目标的信息,还没有被翻译成模型能看懂的形态。
2. 特征构造:把原始字段变成模型能"听懂"的语义
2.1 交叉特征与多项式特征:让线性模型也能表达交互关系
特征构造的核心思路,是"制造"模型原本看不到的信息。很多初学者不理解:原始字段都在数据里,为什么还需要构造?因为模型只能看到"字段的取值",却看不到"字段之间的关系"。比如判断一个用户是否会再次购买,可能"注册时间长且最近访问时间近"的用户才是高意向用户,单纯的注册时长长,或者单纯的访问时间近,都不足以说明问题。这种关系和条件组合,就需要通过交叉特征或多项式特征显式表达出来。
一个很经典的做法是交叉特征:把两个或三个特征做组合,生成新的特征。比如把所在城市和年龄段交叉,得到"北京_25到30岁"这样的组合类别;或者把两个数值特征相乘,比如"历史订单数乘以平均客单价",得到的是用户的累计消费额,这比两个字段单独看更有业务含义。在sklearn里可以用PolynomialFeatures快速生成多项式组合特征,生成x1的平方、x1*x2这类项:
from sklearn.preprocessing import PolynomialFeatures # 假设X是两列数值特征:历史订单数、平均客单价 poly = PolynomialFeatures(degree=2, interaction_only=False, include_bias=False) X_poly = poly.fit_transform(X) # 生成的列包括:x1, x2, x1^2, x1*x2, x2^2 print(poly.get_feature_names_out())这里提醒一句:PolynomialFeatures生成的特征数量会随着degree增加爆炸式增长,10个原始特征、degree=3就可能生成上百个特征。所以要么限制degree,要么在生成之后配合特征选择把没用的维度砍掉。我个人的习惯是先小范围试degree=2,观察线上效果再决定要不要加深,很少一上来就degree=3以上。
还有一种在实际业务里非常常见、但教科书里讲得比较少的交叉手法:基于业务规则构造组合特征。比如在风控场景,把"申请次数"和"通过次数"组合成"历史通过率";在电商场景,把"点击量"和"加购量"组合成"加购率"。这类组合特征虽然看起来简单,但因为直接对应业务含义,往往比纯数学上的多项式组合更有效。我在实际项目中的体会是,先想业务逻辑,再做数学变换,两者结合效果最好。
2.2 分箱与WOE编码:数值特征也能"变离散"
分箱(Binning)是特征处理里一个容易被低估的操作。思路很简单:把连续的数值特征切成若干个区间,每个区间当作一个类别。为什么要这么做?因为很多特征对目标的影响不是线性的。比如年龄对购买意愿的影响可能是"25岁以下偏低、25到35岁偏高、35岁以上又回落"这种倒U型关系。直接喂原始数值给线性模型,模型只能学出一个单调关系,这种倒U型就学不出来。分箱之后每个区间都有独立的权重,模型就能表达这种非线性。
分箱有三种常见方式:等宽分箱、等频分箱、基于目标的分箱。等宽分箱是把取值区间均匀切分,适合分布本身比较均匀的特征;等频分箱是让每个箱里的样本量尽量一致,适合分布很不均匀的特征;基于目标的分箱则是让每个箱内样本的正样本比例尽量有区分度,是"针对目标"的做法,但要注意防止过拟合。
分箱之后通常还会做一步WOE编码。WOE(Weight of Evidence,证据权重)在金融风控领域特别常用,它的公式是ln(好样本占比除以坏样本占比)。简单理解就是:这一箱里包含的预测信息量有多大。WOE编码相比直接用类别标签做编码,有一个关键优势——它把"该箱的区分能力"变成了数值化的特征,而且天然处理了类别和数值之间的映射问题。计算逻辑很直接:
import numpy as np import pandas as pd # 假设df已经按某种方式分箱,target是0/1目标列 woe_df = df.groupby('bin').agg( good=('target', lambda x: (x == 0).sum()), bad=('target', lambda x: (x == 1).sum()) ) good_total = woe_df['good'].sum() bad_total = woe_df['bad'].sum() woe_df['woe'] = np.log((woe_df['good'] / good_total) / (woe_df['bad'] / bad_total))注意分箱颗数不是越多越好。箱太多会把噪声也学进来,箱太少又会损失细节。我的经验是从5到10箱开始,观察每个箱的样本量和目标均值分布,再决定是否加密。如果某一箱的样本量特别少,说明切分过细了,需要合并。这个环节没有什么标准答案,需要根据实际数据反复看效果。
2.3 目标编码:高基数类别特征的"险棋"
类别特征处理里有一个经典困境:类别很少(比如性别、星期几),用one-hot没任何问题;但类别很多(比如用户ID、城市、商品ID),one-hot会让特征维度爆炸,而且每个维度上分到的样本量太少,模型学不出稳定规律。
目标编码(Target Encoding)是应对高基数类别特征的一种思路:直接用每个类别下目标变量的均值来代替类别本身。比如某个城市的用户流失率是0.4,那这个城市的取值就替换成0.4。这个思路很直观、很有效,但也容易过拟合——如果某个类别下只有几个样本,目标均值会非常极端,模型学到的就是噪声而不是规律。
目标编码的正确用法是加上平滑系数,让样本量少的类别向全局均值收缩:
def target_encode(series, target, min_samples_leaf=20, smoothing=10): temp = pd.concat([series, target], axis=1) temp.columns = ['cat', 'target'] global_mean = target.mean() agg = temp.groupby('cat')['target'].agg(['count', 'mean']) smooth = 1 / (1 + np.exp(-(agg['count'] - min_samples_leaf) / smoothing)) encode = global_mean * (1 - smooth) + agg['mean'] * smooth return temp['cat'].map(encode)更稳妥的做法是在交叉验证的每一折里分别计算编码值,只用训练折的信息来编码验证折的数据,这样能最大程度避免信息泄漏。如果你用的是scikit-learn生态里的Category Encoders库,它自带类似的CV策略实现,可以省不少事。我自己的项目里,高基数特征一般优先试目标编码,但同时会保留原始特征做对比实验,因为不是所有场景下目标编码都优于one-hot。有一次我做商品维度的特征编码,目标编码和one-hot跑出来的效果几乎一样,但目标编码在线上维护起来更麻烦,最后我还是选了one-hot加维度压缩的方案。
3. 特征选择:砍掉一半维度,效果反而更好
3.1 过滤式:方差、卡方、互信息
做完特征构造之后,特征数量肯定会变多。这时候如果不加选择,模型训练慢,还会更容易过拟合。特征选择的主流思路分三类:过滤式、包裹式、嵌入式,我一个个来说。
过滤式的核心是不依赖具体模型,先基于统计指标评估每个特征和目标变量的关联程度,然后按分数排序取前k个。常见的指标有:方差(方差过小的特征几乎没变化,信息量低)、卡方检验(适用于分类问题中类别特征和目标的关系)、互信息(能捕捉非线性关联,是最通用的一个)。代码示例:
from sklearn.feature_selection import SelectKBest, mutual_info_classif selector = SelectKBest(score_func=mutual_info_classif, k=30) X_selected = selector.fit_transform(X, y)互信息在实践里比卡方更常用,原因是它不假设特征和目标之间有线性关系,能捕捉到很多"看起来没联系但实际上有联系"的特征。缺点是计算量略大,不过对几万行、几百列的数据来说完全够用。我比较推荐的做法是先用过滤式快速筛掉明显没用的特征,把几百维降到几十维,再交给后续更精细的筛选方法。
3.2 包裹式:递归特征消除
如果说过滤式是"只看特征本身",那包裹式就是"把特征放到模型里去试"。最经典的方法是RFE(Recursive Feature Elimination,递归特征消除):先用全部特征训练模型,根据特征重要性或模型系数把最不重要的特征删掉,再在剩余特征上重新训练,重复这个过程,直到达到目标特征数。
from sklearn.feature_selection import RFE from sklearn.linear_model import LogisticRegression rfe = RFE(estimator=LogisticRegression(max_iter=1000), n_features_to_select=15) rfe.fit(X, y) print(rfe.support_, rfe.ranking_)RFE的优点是效果通常比过滤式好,因为它的评估直接基于模型的实际拟合能力;缺点也明显——计算开销大,特征一多就跑得慢。所以我会按特征量级来决定策略:特征少于100个,直接用RFE没问题;特征上千甚至上万,就先做一轮过滤式降维,再用RFE精挑。实际跑RFE的时候,不建议用决策树这类方差大的模型做estimator,逻辑回归或线性SVM这类稳定模型会更合适。
3.3 嵌入式:L1正则与树模型的特征重要性
嵌入式方法把特征选择直接集成到模型训练过程中。两个最常见的代表是L1正则(Lasso)和树模型的特征重要性。
L1正则会把不重要的特征系数压缩到0,训练完之后系数为0的特征可以直接丢掉。这个特性让Lasso天然成为一个特征选择器,而且很稳定。用法也很简单:
from sklearn.linear_model import Lasso lasso = Lasso(alpha=0.01) lasso.fit(X, y) selected_features = X.columns[lasso.coef_ != 0]树模型的特征重要性就更直观了。XGBoost、LightGBM、随机森林训练完之后都自带feature_importances_属性,直接取出排序靠前的特征就行。但这里有一个坑:树模型的特征重要性基于"被用于分裂的频率和带来的增益",对冗余相关特征会存在"分摊重要性"的问题。比如两个高度相关的特征,重要性会被分散到两个特征上,单个看都不高,但你如果删掉其中一个,另一个的重要性会显著上升。所以用树模型的重要性做特征选择时,最好先做相关性分析,从每组高相关特征中只挑一个代表出来。
我自己的组合拳是:先用树模型跑一版,看重要性和相关矩阵,人工剔除明显的冗余特征;然后用Lasso做一轮筛选,看系数不为零的集合;最后把两轮结果做交集,再丢进模型里做交叉验证。这套流程虽然多几步,但筛出来的特征集稳定性要好很多。
4. 分布偏态与非线性变换:让模型看到"真实"的数据结构
4.1 长尾分布与log变换
真实业务数据里的数值特征,绝大多数都不是正态分布的,而是呈长尾分布。比如用户注册天数、订单金额、访问次数,往往都是少数人贡献了大多数数值。这种分布对线性模型、神经网络这类对数值敏感的模型影响很大:模型会把过多注意力放在大数值区间,小数值区间几乎学不到东西。
log变换是处理长尾分布最经典的手段。把x变成log(x+1),能在不改变数据相对顺序的前提下,把分布压缩到更均匀的尺度上。加1是为了处理x=0的情况。Python里一行代码就能搞定:
import numpy as np X['register_days_log'] = np.log1p(X['register_days'])我实测下来,对分布跨度特别大的特征(比如从1到几万这种),log变换几乎总是有效的。但要注意两点:一是log变换只在特征值全部为正数时才有意义,如果有负数要先做偏移;二是变换完的特征解释性会下降,"注册天数的log值"在业务汇报时不如"注册天数"那么直观,需要在特征文档里写清楚。
值得注意的是,树模型对特征尺度不敏感的情况下,log变换的效果没有线性模型那么明显。但在特征工程实践里,log变换还有一个隐藏好处——它能抑制异常值的影响。一个10万的天数,log之后变成11.5,对模型的冲击力小得多,这比单纯做异常值裁剪要平滑。
4.2 Box-Cox与Yeo-Johnson:更通用的分布矫正
log变换只是幂变换的一个特例。如果要更系统地矫正分布偏态,Box-Cox变换是更好的选择。它有一个参数lambda,可以自动寻找最优的变换指数,让变换后的分布尽可能接近正态:
from scipy.stats import boxcox transformed, lambda_opt = boxcox(X['amount'] + 1) # 要求特征为正Box-Cox有个限制:输入必须为正数。如果特征里包含负数或0,可以用Yeo-Johnson变换,它是Box-Cox的扩展,允许处理任意实数。sklearn里的PowerTransformer直接封装了这两种变换,还支持自动标准化:
from sklearn.preprocessing import PowerTransformer pt = PowerTransformer(method='yeo-johnson') X_transformed = pt.fit_transform(X)这里想多说一句:不是所有模型都需要做分布矫正。树模型对特征尺度不敏感,做不做变换效果通常不明显;但在线性回归、逻辑回归、神经网络、KNN这类依赖特征尺度的模型上,分布矫正的收益非常明显。这也是为什么在风控评分卡这类以逻辑回归为核心的模型里,WOE编码和log变换几乎是必做的步骤——线性模型对特征分布的敏感度远高于树模型,特征的量纲和偏态会直接影响系数估计的稳定性和业务解释性。
4.3 类别不平衡问题:样本层面的"特征处理"
特征处理不光是处理X,还有一个容易被忽略的角度——处理y的分布。当正样本占比非常低(比如流失预测里只有2%的用户会流失),模型很容易学成一个"永远预测为负样本"的偷懒模型。这种现象在风险预测、欺诈检测、医学诊断这类场景里特别常见。
数据层面的处理方法主要有三种。第一是重采样:对少数类做上采样(SMOTE生成合成样本),或对多数类做下采样。SMOTE的机制是在少数类样本之间做插值生成新样本,比简单复制效果更好。第二是给少数类更高的样本权重,很多分类器都支持class_weight='balanced',让模型对少数类的错误分类施加更大惩罚:
from sklearn.linear_model import LogisticRegression model = LogisticRegression(class_weight='balanced') model.fit(X, y)第三是评估指标要改。准确率(accuracy)在不平衡场景下会"骗人"——99%都是负样本时,模型全预测负样本也有99%准确率,但这个模型毫无用处。这时候应该用AUC、F1、召回率、精确率这些对少数类更敏感的指标。我在项目里有个习惯:一开始先把基线模型跑出来,同时记录准确率和AUC,看两者的差距。如果准确率很高但AUC很低,基本就是不平衡问题没处理好。
这个环节的处理顺序也很重要。我的经验是:先做特征处理和特征选择,最后再做不平衡采样。因为重采样会让数据分布改变,如果一开始就采样,后面做特征选择时得到的结论可能跟真实分布对不上。先尽量把特征做干净,再解决类别不平衡,逻辑上更顺。
5. 踩坑与验证:特征处理中最容易出问题的三个环节
5.1 数据泄漏:世界上最隐蔽的坑
特征处理做错了最严重的一类问题就是数据泄漏——在训练过程中使用了未来信息或目标信息,导致的直接后果是离线评估指标很好看,线上效果一塌糊涂。这个问题在你做特征构造和目标编码时尤其容易出现。
最常见的泄漏场景有三个。第一个是用全量数据做标准化或缺失值填充,再划分训练集和测试集。正确做法是先划分数据集,再从训练集上计算均值和标准差,用同样的参数去变换测试集。sklearn里用Pipeline配合StandardScaler能很好解决这个问题。第二类是目标编码时用了全量目标均值,训练集编码时已经把验证集的目标信息偷进去了,正确做法是交叉验证分折编码。第三类是时间序列数据里用了未来信息,比如用当天的数据预测当天是否流失,或者用了测试区间内才产生的特征。这类问题只能靠对业务的理解去排查,技术工具帮不上太多忙。
一个我常用的自查思路很实用:列出所有特征,逐条问自己"产生这个特征的时候,目标事件的真实结果是否已经发生?如果已经发生,这个特征就是泄漏源"。比如预测客户是否会流失,结果你构造了一个"是否已发送挽留优惠券"的特征——这个特征确实和流失高度相关,但在预测时根本不可能知道用户会不会收到优惠券,这就是典型的泄漏。这条习惯帮我抓住了好几次线上事故的隐患,值得养成。
5.2 训练集和验证集的处理方式不一致
这个坑和泄漏有关,但不完全是一回事。有时候训练集做了某种变换,验证集和测试集却忘了做同样的事,或者用了不同的参数。比如log变换的底数在不同数据集上不一致、分箱的边界是全局计算的还是训练集计算、one-hot编码的类别列表在两个数据集上不同——这些问题在实际代码里非常普遍,尤其是当你手动管理特征处理步骤而不是封装成统一流程的时候。
最稳妥的解法是永远用Pipeline。把所有特征处理步骤封装成transformers,让训练和预测走同一套代码路径。sklearn的Pipeline和ColumnTransformer就是为此设计的,能极大减少"训练好、预测错"的概率:
from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder preprocessor = ColumnTransformer(transformers=[ ('num', StandardScaler(), ['amount', 'register_days']), ('cat', OneHotEncoder(handle_unknown='ignore'), ['city', 'channel']) ]) pipeline = Pipeline(steps=[ ('preprocess', preprocessor), ('model', LogisticRegression(max_iter=1000)) ]) pipeline.fit(X_train, y_train)用Pipeline之后,fit和predict都会自动把预处理步骤应用到对应数据上,不再需要手动维护两套变换逻辑。如果你是在做项目而不是打比赛,我很建议从第一天就养成用Pipeline的习惯。这看起来是小事,但实际项目里因为训练和预测的预处理不一致导致的线上事故,我见过太多次了。
5.3 特征膨胀与维度灾难:不是越多越好
做完特征构造和特征选择之后,还要回头检查一件事:特征数量是否已经超过了样本量所能支撑的范围。当特征数接近甚至超过样本数时,模型基本上就是在"背答案",泛化能力会急剧下降,这就是维度灾难。
一个经验参考:对于线性模型,特征数一般控制在样本量的1/10到1/20以内;对于树模型,特征数可以放宽一些,但也不是无限制的。如果发现特征膨胀,用前面讲的特征选择方法果断压缩。另外,特征相关系数矩阵也值得定期看,多个高度相关特征不仅增加维度,还可能带来不稳定的系数估计。
在实际项目里,除了统计意义上的特征膨胀,还有一个成本问题:特征的维护成本。每多一个特征,上线之后的监控、排查、解释、特征口径维护都要花时间。如果构造出来的特征对模型提升不明显,我倾向于直接删掉,而不是为了"看起来热闹"保留一大堆用处不大的维度。特征处理做得好不好,不是看特征数量多不多,而是看每一条特征是否真的在为模型提供增量信息。
最后再分享一个个人习惯:我现在做特征处理,每构造一个新特征都会单独做一个A/B对比实验,记录这个特征加入前后的指标变化。交叉特征涨了0.002,log变换涨了0.005,目标编码反而跌了0.001——这些数字积累起来,比任何理论都有说服力。特征处理2这条路上没有什么银弹,靠的就是一次一次试错和记录,最后你自然会形成一套属于自己的判断标准。