搞机器学习这几年,我最大的感受是:一个项目里真正决定模型上限的,往往不是用了哪个算法,也不是把参数调到多么飞起,而是你喂给模型的特征长什么样。可惜特征工程这件事,在绝大多数团队里仍然是最依赖手工、最难以复现、最容易被经验绑架的环节。每次接到新数据集,都要花大量时间做探索性分析、逐个字段试各种变换。后来我实在受不了这种重复劳动,干脆写了一套用Python驱动的特征工程自动流水线,把数据探查、类型识别、缺失值处理、特征生成、特征筛选整条链路串起来,让机器先替我发散去想,我再在结果里挑好东西。这篇文章就把整个项目的设计思路和关键代码拆开讲清楚。
先说明一点:这里说的“自动化”,不是让代码替你拍板一切,而是把那些有规律、可穷举、大概率有效的操作交给程序去跑,把人的时间留给真正需要判断力的地方。沿用这个思路跑了几个实际项目之后,效果比我预想的好不少,尤其是给树模型做特征时,自动生成的交叉特征和组合特征经常能帮我把AUC再往上顶一截。
1. 为什么我决定把特征工程做成自动化流水线
1.1 手动特征工程是个深不见底的时间黑洞
我最早做特征工程的方式,估计和大多数人一样:拿到数据先跑一遍 df.describe(),再看字段名猜业务含义,然后写一堆 groupby、map、apply 去手工造特征。这套流程最大的问题不是不会做,而是太慢。一张几十个字段的表,光是判断哪些字段适合做分箱、哪些字段适合做组合、哪些字段之间可能有交互效应,就要反复试错。更麻烦的是,这个过程中产生的中间代码基本不可复用,换一个数据集、换一个业务场景,前面的活全得重来。
有一次我在做一个信贷违约预测,手动构造了十几个特征之后,想要验证“三个不同维度的数值字段两两相乘”是否有效。当时我想的就是:这种组合能不能自动生成?能不能程序化地把成百上千种组合一次性跑出来,再用模型/统计方法直接挑出有价值的?这个念头直接激发了我做自动化特征工程的想法。
1.2 自动化不等于照着pipeline点一遍
一提到自动化,很多人第一反应是 sklearn.pipeline 里接几个 SimpleImputer、OneHotEncoder,跑通就算完事。但实际项目里的特征工程远不只是“补缺失+独热编码”这么几板斧。特征工程至少包含这么几个层次:
- 基础清洗:缺失值填充、异常值处理、格式统一。
- 字段变换:对数变换、Box-Cox、标准化、归一化。
- 编码转换:类别型的独热、标签、目标编码,数值型的分箱。
- 特征构造:多项式特征、数值组合、统计聚合特征、时间特征展开。
- 特征选择:相关性过滤、IV值筛选、特征重要性排序。
这几个层次里,最容易用程序自动化的其实是“特征构造+特征选择”,因为它不需要太多业务先验,靠计算就能迭代。真正需要人判断的,是“该不该保留这个特征”“构造逻辑是否泄漏了未来信息”这些点。所以我的设计原则是:程序负责广撒网,人负责收网判断。框架跑完一轮,会生成几百上千个候选特征,但我保留完全的人工检查权限,确保最后进入模型的都是经得住推敲的。
1.3 用“发散-收敛”的思路来理解全流程
这个自动化框架的核心思想,我总结成四个字:发散-收敛。前面数据清洗和特征生成阶段,我故意让程序放开手脚去组合、去变换,允许它生成大量特征,甚至允许它生成一些看起来不太靠谱的特征;到了特征选择阶段,再用模型和统计工具层层筛选,把噪声剔除,把有价值的信息保留下来。这和我之前靠一两个特征想法走到黑的做法完全不同,更像是头脑风暴之后再集中评审。
在使用这套流程的过程中,我逐步将它拆成了五个可独立运行的模块:数据探查、基础清洗、特征生成、特征选择、结果报告。下面我就按这个顺序,把每个模块的实现思路、关键代码和踩过的坑一起放出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据探查与自动类型识别:所有后续操作的地基
2.1 先让程序自己“读懂”数据长什么样
以前做数据探查,我习惯用 df.info()、df.describe() 这些命令一项项看,然后结合业务经验判断字段类型。换成自动化框架之后,第一步必须由代码自动输出一份结构化清单,里面包含:
- 每列的数据类型(注意是numpy/pandas的实际类型,不是肉眼看到的类型)。
- 每列的缺失率、唯一值个数、取值分布。
- 数值列的均值、分位数、偏度、峰度。
- 类别列的主要取值类别及其占比。
这份清单是整个流水线的输入。后面所有策略都会根据这份清单决定:哪些字段要进数值通道,哪些进类别通道,哪些按时间字段处理。为了避免每次项目都重新写一遍探查代码,我把这层封装成了一个函数,返回一个字典结构,后续模块可以直接引用。
python复制import pandas as pd
import numpy as np
def auto_profiling(df):
profile = {}
for col in df.columns:
col_info = {
'dtype': str(df[col].dtype),
'missing_rate': round(df[col].isna().mean(), 4),
'n_unique': int(df[col].nunique(dropna=False)),
}
if pd.api.types.is_numeric_dtype(df[col]):
col_info['numeric_stats'] = df[col].describe().to_dict()
col_info['skew'] = df[col].skew()
if pd.api.types.is_datetime64_any_dtype(df[col]):
col_info['date_min'] = df[col].min()
col_info['date_max'] = df[col].max()
if pd.api.types.is_object_dtype(df[col]) or isinstance(df[col].dtype, pd.CategoricalDtype):
col_info['top_values'] = df[col].value_counts().head(5).to_dict()
profile[col] = col_info
return profile
这段代码看起来简单,但它是整个自动化的地基。没有这份清单,后面所有的自动判断都是空中楼阁。
2.2 类型识别的关键:不要只信dtype
dtype 有时候会骗人。最常见的是:一个数值列在 pandas 里存成了 object,因为里面有“未知”“缺失”这类占位符;反过来,一个纯数字字符串列也可能被读成 int,但实际上它是手机号、身份证号这类不该参与数值运算的字段。
所以我的框架里不能只靠 df.dtypes 一把梭,还要加一层规则判断:
- 如果 object 列里的字符串都能转成数字,我把它归为“数值型”候选,但会额外标记“需确认”。
- 如果数值列的唯一值个数很少(比如小于10),并且分布比较集中,我倾向于把它当连续型处理但提示“可能适合做分箱”。
- 如果唯一值数量等于样本量或接近样本量,并且值没有明显业务分层,这通常是ID列,直接标记为“弃用列”。
- 日期字符串列、时间戳列会被统一解析成 datetime 类型,进入时间特征扩展通道。
这样做的好处是减少了人工干预的频率。实际跑数据时,真正需要人花时间处理的字段往往只有少数几个,其余都能被规则覆盖。
2.3 缺失值与异常值的处理策略也要“分字段差异化”
缺失值的自动处理,核心原则是“不能一刀切”。数值列我一般用中位数填充,因为中位数比均值对异常值更稳健;如果是严重偏态的分布,中位数更合适。类别列我用一个特殊字符 __MISSING__ 填充,让模型自己学会“缺失”这个状态。至于时间列,缺失了就直接剔除,因为时间本质上不太适合插补。
异常值处理稍微复杂一点。我不会一上来就删除异常值,因为树模型对异常值并不敏感。但如果这个特征要进入线性模型或者做距离计算,就必须处理。我的框架里采用的是分位数截断,把超过1%和99%分位数的值缩到边界上,而不是直接删行。
3. 特征生成模块:让机器替你做发散思考
3.1 从“人想特征”到“程序穷举特征”
这个模块是整个项目的灵魂,也是最能体现“发散创新”的地方。过去靠人工构造特征,一次能想到的也就是两三个新特征;程序穷举就不一样了,它可以把字段之间的加减乘除、多项式展开、类别分组聚合统计统统生成出来,一次性造出几百个候选特征。虽然里面八成是没用的,但剩下两成里经常藏着靠人工想象不到的规律。
我常用的自动特征生成策略包括:
- 数值两两组合:对N个数值字段做两两加减乘除,生成N×(N-1)个组合特征。这个计算量大,所以我会先做一次快速相关性预筛,把冗余的数值字段降维后再组合。
- 高阶多项式:对单个数值字段生成平方、立方、开方、对数等变换。这个尤其适合线性回归和逻辑回归场景。
- 类别-数值聚合:对每个类别字段,按不同分组计算数值字段的均值、标准差、最大值、最小值、计数,生成“组内统计特征”。这是我在实际项目里收益最大的一个操作,尤其是对用户ID、地区ID这类高基数类别。
- 时间字段展开:把时间列拆成年、月、日、星期、是否周末、分钟数、距离最近节假日的天数等。时间序列特征在风控和营销项目里非常常见。
核心代码如下:
python复制def auto_feature_generation(df, num_cols, cat_cols, date_cols):
generated = {}
# 1. 数值组合:两两相除、相乘
for i in range(len(num_cols)):
for j in range(i + 1, len(num_cols)):
c1, c2 = num_cols[i], num_cols[j]
try:
generated[f'{c1}_div_{c2}'] = df[c1] / (df[c2] + 1e-6)
generated[f'{c1}_mul_{c2}'] = df[c1] * df[c2]
except Exception:
continue
# 2. 单字段变换
for col in num_cols:
generated[f'{col}_log'] = np.log1p(df[col].clip(lower=0))
generated[f'{col}_square'] = df[col] ** 2
generated[f'{col}_rank'] = df[col].rank(pct=True)
# 3. 类别分组统计
for cat in cat_cols:
for num in num_cols:
grouped = df.groupby(cat)[num].agg(['mean', 'std', 'max', 'min', 'count'])
grouped.columns = [f'{cat}_{num}_{agg}' for agg in grouped.columns]
df = df.join(grouped, on=cat)
return generated
3.2 发散之后必须有控制:特征数量爆炸问题
如果你真的把上面这段代码无脑丢到一个含20个数值字段、5个类别字段的数据集里,生成的临时特征会立刻把内存吃满,运行时间也会飙升到不可接受。我在第一版框架里就被这个坑害惨了:一个中等规模的数据集直接跑出四千多个特征,然后卡在特征拼接那一步,内存占用超过32GB。
所以需要加两层控制:
- 第一层:数值字段预筛选。组合之前,先计算字段之间的相关系数,把高度相关的字段(相关系数绝对值大于0.9)去掉一部分,保留单变量与目标变量相关性更强的那个。这样能从源头减少组合数量。
- 第二层:特征生成数量上限。可以设置一个
max_generated_features参数,当已生成的特征数量达到上限时,按某种启发规则停止继续生成(比如优先保留类别聚合特征,其次保留两两比值特征)。这个上限可以根据内存和模型训练时间动态调整,我一般设置1000左右。
这一个控制逻辑非常重要。没有控制的自动发散,只会让你从“手工泥潭”跳进“计算泥潭”,并没有真正解决效率问题。
3.3 构造特征是否泄漏了未来信息:一个容易被忽略的坑
自动生成特征时,最容易出现的问题其实是“数据泄漏”,也就是用了当前时刻不该知道的信息。举几个真实例子:
- 用全量数据的均值去填充缺失值,在训练/测试切分时会引入未来信息。
- 用全量数据做目标编码(target encoding)时,类别对应的均值已经包含了测试集标签信息。
- 构造时间聚合特征时,如果按整个时间窗口计算“未来平均值”,这就等于作弊了。
解决方式也很直接:**所有涉及统计量的特征(均值、标准差、目标编码)必须在训练集内部完成计算,测试集只能应用同一个映射关系,不能重新计算。**我在框架里专门封装了一个 fit_transform / transform 接口,和sklearn的接口风格保持一致,避免不小心在整个数据集上计算统计量。
4. 特征选择:从几百个候选里捞出真正有用的
4.1 先做低质量特征的“快速淘汰”
发散之后必须收敛。特征选择的作用不是锦上添花,而是避免大量无效特征稀释模型信号。我的流程分成两步:先做规则性淘汰,再用模型做重要性筛选。
规则性淘汰的硬指标有这么几个:
- 缺失率高于80%:这种特征信息太少,直接剔除。
- 单一取值占比超过95%:几乎不变的字段,对模型没有区分能力。
- 与目标变量的相关系数绝对值极低(比如小于0.02):在单变量层面就没有信号,先剔除。
- 多重共线性高:两两特征相关系数大于0.98时,只保留其中一个。
这些规则实现起来很快,能在特征进入模型之前先砍掉一半以上的无效特征。注意这个阶段宁可“过度保留”也不要“过度删除”,因为有些特征是组合之后才有效的,单变量看不出来。
4.2 用随机森林做两轮重要性筛选
规则筛选之后,我会用随机森林做重要性排序。之所以选随机森林而不是直接上XGBoost或LightGBM,是因为随机森林对非线性关系的拟合快,特征重要性相对稳定,而且不容易过拟合。用随机森林做重要性筛选,我习惯跑两轮:
第一轮,把所有候选特征喂进去,设置一个比较小的 max_depth,比如4到6层,跑一次带交叉验证的重要性评估,剔除重要性等于0的特征,以及重要性远低于整体均值的特征。
第二轮,用剩下特征再跑一轮重要性,按重要性累计占比画出曲线,保留累计贡献达到95%的那部分特征。对于实际项目来说,这个数量一般是几十个,完全可控。
python复制from sklearn.ensemble import RandomForestClassifier
def select_by_importance(X, y, threshold_ratio=0.1):
model = RandomForestClassifier(
n_estimators=200,
max_depth=5,
random_state=42,
n_jobs=-1
)
model.fit(X, y)
imp = pd.Series(model.feature_importances_, index=X.columns).sort_values(ascending=False)
keep = imp[imp > imp.max() * threshold_ratio].index.tolist()
return keep, imp
4.3 IV值筛选:给二分类任务再加一道保险
在二分类场景(比如违约预测、流失预测、转化预测)里,我还会再加一道 IV(Information Value)筛选。IV值能衡量一个特征对二分类目标的区分能力,经验阈值是:
| IV值范围 | 区分能力 |
|---|---|
| < 0.02 | 几乎无预测能力,可以考虑剔除 |
| 0.02 ~ 0.1 | 较弱,可在特征不多的时候保留 |
| 0.1 ~ 0.3 | 中等,有较好区分能力 |
| 0.3 ~ 0.5 | 较强 |
| > 0.5 | 需警惕过拟合,要结合业务判断 |
IV值筛选和树模型重要性筛选并不冲突:树模型重要性擅长捕捉非线性交互,IV值擅长衡量单调区分能力,两者结合,筛选出来的特征会更稳健。实际上,我在跑自动化流程的时候,会把IV值作为第一道门槛,重要性作为第二道门槛,两个条件都满足才最终入选。
这里有一个常见疑问:IV值不容易算在连续特征上怎么办?一般做法是先分箱,再基于每个箱体内的好/坏样本占比计算WOE,再汇总成IV。所以我的框架里,IV值计算天然和分箱逻辑绑定在一起,分箱之后的特征不仅方便计算IV,还能直接作为类别特征进入模型。
5. 一个真实案例:从原始表到模型效果提升的全过程
5.1 案例数据与基线模型
为了验证这套全流程的效果,我用了一个公开的信贷违约数据集做过一次实验。原始数据包含35个字段、约10万条样本,目标字段是二分类(是否违约)。数据类型分布大致是:数值字段17个,类别字段14个,时间字段4个。
我的实验设计是:
- 基线方案:只用原始字段做常规清洗(缺失值中位数填充、类别独热编码),不额外构造特征。
- 自动化方案:用我上面的框架自动生成特征,生成上限设1000,经过规则淘汰、IV筛选、随机森林重要性筛选后,保留约120个特征。
- 模型统一用 LightGBM,固定参数,不做调参,只对比特征层面的差异。
5.2 实验结果:AUC的提升幅度
跑了三折交叉验证之后,结果是这样的:
| 方案 | 特征数量 | AUC(验证集) | 训练时间(秒) |
|---|---|---|---|
| 基线 | 45 | 0.742 | 3.2 |
| 自动化特征工程 | 120 | 0.783 | 5.8 |
| 自动化特征工程 + IV筛选 | 87 | 0.781 | 4.9 |
AUC提升了约4个百分点。在信贷风控场景里,4个点的AUC提升已经是非常显著的收益了。更让我意外的是,贡献最大的特征里,排名靠前的并不是我最初会手工构造的业务直觉特征,而是几个“类别分组统计特征”,比如“某地区分组下用户的平均负债率”“某职业分组下用户的平均信贷额度”。这些特征虽然从业务角度也说得通,但靠人工一点点去探索,可能要花好几个星期才能想到。
5.3 训练时间上升是否值得?
自动化跑完之后,训练时间从3.2秒涨到5.8秒,看似增加了80%,但这个时间成本是一次性的,而且完全可接受。因为相比“人工摸索特征要花好几天”,增加两三秒训练时间几乎可以忽略。真正需要关注的是特征生成阶段的时间。在我的测试里,10万条数据生成1000个特征大概耗时几十秒,这在项目节奏中完全可以接受。
5.4 人工干预点:程序自动生成后,我还做了什么
自动化并没有完全取代我的判断。跑完筛选之后,我还做了三件人工的事情:
- 把自动生成的特征名打印出来,逐个审视是否可能出现逻辑泄漏。
- 抽样看几行实际数值,确认没有出现除零、无穷大等异常。
- 和业务方对了一下“某个分组统计特征”的业务含义,确认它没有采用事后才知道的信息。
这也是我一直在强调的:自动化框架不是“放任不管”,而是把操作层面的重复劳动省掉,让你把精力集中在真正需要判断力的地方。
6. 踩坑实录:那些让流水线崩溃的细节问题
6.1 内存爆炸是第一个要解决的问题
前面提过特征数量爆炸的问题。这里再给几个具体的经验值:一个500MB的原始数据,如果不加限制地做两两数值组合,生成的临时特征可能会占掉十几GB内存。我的做法是分两个方向处理:
- 分块生成:不一次性把所有组合特征放进一个DataFrame,而是每生成200个特征就拼接到累积表里,然后用完就释放中间变量,让Python的垃圾回收机制及时处理。
- 用稀疏格式:如果特征生成结果大量包含0(比如独热编码产生的稀疏矩阵),优先用
scipy.sparse来存储,后续再接模型。sklearn的大部分模型都支持稀疏输入。
6.2 类别字段基数过高:独热编码会失控
类别字段里有个坑是“高基数类别”,比如城市字段有几百个取值,如果直接独热编码,特征维度立刻膨胀几百个,模型训练速度骤降,而且容易过拟合。我的自动框架里对高基数类别做了专门处理:
优先尝试低频合并:把出现次数占比低于某个阈值(比如1%)的类别全部合并成 __OTHER__。如果合并之后类别数仍然超过50,就改用目标编码,用类别对应的目标均值作为特征值。这两种方案我一般优先选择低频合并,因为目标编码虽然信息密度高,但更容易引入泄漏和过拟合。
6.3 被LightGBM报错逼出来的“无穷值”清洗
还有一次,框架跑出来一堆 inf 值,LightGBM训练时直接报错。排查了半天,发现是数值除法时分母接近0导致的。修复方式有两个:分母统一加一个极小值 1e-6,这是最省事的;另外在做除法之前可以先统计分母的分布,如果大部分取值都接近0,这个除法特征大概率没意义,直接跳过。这个检查逻辑也被我写进了框架,避免了以后反复踩。
后来我还在框架里加了一步全量数值检查:所有生成特征在拼入总表之前,统一用 np.isfinite() 做一次扫描,把包含非有限值的行全部填充为NaN,再走缺失值处理。这一步虽然增加了一点计算量,但是非常值得,因为它能防住后面模型训练时各种莫名其妙报错。
6.4 自动化的边界:这些情况还是要人工介入
写到最后,我得诚实地说一说自动化特征工程的边界。它最大的优势是高效发散、快速筛选,但它并不能替代业务理解。遇到过几次必须人工介入的场景:
- 强业务规则特征:比如风控里的“多头借贷笔数”“近三个月查询次数”,这类特征构建逻辑复杂,需要明确的业务定义,程序很难自己组合出来。
- 特殊时间窗口:比如“距离上次还款的天数”“30天内最大逾期天数”,这种窗口统计特征需要精确的业务口径,自动生成很容易算得不对。
- 合规和可解释性要求:某些高IV值特征从统计上很有效,但业务上难以解释甚至涉及敏感信息,这时候必须靠人工砍掉。
所以我的最终建议是:把自动化特征工程当成一个“候选特征生成器”,而不是“最终决策器”。程序负责把水搅活,把可能性铺开,人工负责收口和定夺。两者结合,才是效率和质量的最优解。
这套流程我已经用在了好几个项目里,每次跑完都会把生成的特征列表和效果简单存档。时间长了,它逐渐变成我的一个标准化起点:不管新项目的数据长什么样,先用这套流程把基础特征摸一遍,再结合业务做人工补充。省下来的时间,足够我多调几轮模型,多验证几个想法。如果你也经常被特征工程的重复劳动困住,不妨照着这个思路搭一套自己的自动化骨架,把“发散”交给代码,把“决策”留给自己。
