两年前我接了一个收入预测的数据挖掘项目,数据集大概六万条、二十来个字段。第一次用决策树跑出来的准确率只有76%,调了半天超参也没明显起色。后来回头看才发现,问题根本不在模型,而在一整个特征工程环节——日期字段还是字符串,教育程度是文本分类,年龄和收入没有做任何交叉,缺失值直接用0填平,整套特征就像没洗没切的菜直接扔进锅里炒。也是从那个项目开始,我把Python大数据特征工程这条链路彻底理了一遍,Pandas负责把生数据洗成可用的特征集,Sklearn负责做特征选择、降维和建模验证。这个组合到今天仍然是我在绝大多数表格型大数据项目里的首选方案。
这篇文章就围绕这条链路来写,从数据清洗、特征构造、特征选择到Pipeline整合,最后用一个收入预测的完整案例把全流程串起来。无论你是刚开始转数据挖掘的新人,还是已经在业务里被脏数据折磨过好几轮的开发,都应该能从这里拿到一些可以直接抄走的方案。
1. 特征工程在大数据场景下的定位:Pandas和Sklearn各自的战场
很多人一上来就抱着模型手册啃,觉得XGBoost、LightGBM调参才是核心。实际做过几个项目就会明白,模型的上限是由数据决定的,特征工程才是决定你能多接近这个上限的那双手。Kaggle上有句流传很广的话:数据和特征工程决定了模型的上限,模型算法只是在逼近这个上限。放在大数据场景里,这句话更成立——数据量大、维度杂、质量参差不齐,特征工程投入的时间往往占整个建模流程的六成以上。
1.1 为什么单独聊特征工程
先泼一盆冷水:在真实业务里,90%的时间不是在调模型,而是在处理"怎么把几百个字段变成模型吃得下的东西"。原始数据通常长这样:
- 日期字段是"2024-05-11 14:32:08"这种字符串,模型不知道这是星期几、是不是周末、距离月末还有几天;
- 分类字段有几十个取值,有些取值明明含义相近却被拆成两列;
- 数值字段存在明显长尾,比如收入从3千到300万,直接喂给线性模型会被极端值带偏;
- 缺失值、重复值、脏数据混在一起,没人告诉你哪些字段该删、该填、该拆。
这些都是特征工程的活。说直白点,特征工程就是把人对业务的理解翻译成模型能听懂的数值语言。同样的数据,有人喂进去模型AUC只有0.72,有人能做到0.85,差的不是模型,而是翻译的质量。
1.2 Pandas和Sklearn在链路里的分工边界
工具选型这事,用顺手的就好,但职责必须分清。我的习惯很简单:Pandas管数据,Sklearn管特征加工和建模。
Pandas负责的部分包括数据的读写、探查、清洗、聚合,以及各种自定义规则的变换,因为它足够灵活,DataFrame一拿过来就能写脚本折腾。Sklearn负责的是标准化、编码、分箱、特征选择、降维这些已经有成熟算法实现的环节,因为它提供了统一的fit/transform接口,能直接嵌进Pipeline里,避免在训练集和测试集上重复写一套变换逻辑。
| 环节 | 主力工具 | 主要API |
|---|---|---|
| 数据读写与探查 | Pandas | read_csv, read_excel, info, describe |
| 数据清洗与转换 | Pandas | drop, fillna, astype, to_datetime |
| 缺失值填充/标准化 | Sklearn | SimpleImputer, StandardScaler |
| 类别编码 | Sklearn | OneHotEncoder, LabelEncoder |
| 分箱与离散化 | Pandas为主,Sklearn辅助 | pd.cut, pd.qcut, KBinsDiscretizer |
| 特征选择 | Sklearn | VarianceThreshold, SelectFromModel, RFECV |
| 降维 | Sklearn | PCA |
| 流程统一管理 | Sklearn | Pipeline, ColumnTransformer |
这里要单独提醒一点:Sklearn的接口非常严格,吃进去的必须是二维数值数组,字符串类别必须预先编码。所以Pandas在前面做清洗转格式、Sklearn在后面做数学变换,这个先后顺序本身也是一种约定。按这个分工往下走,每一步的输入输出都很清晰,出了bug也能很快定位是清洗的锅还是变换的锅。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据清洗实战:从脏数据到合格特征集
我在多个项目里反复踩过的坑之一,就是拿到数据就开始建模,连最基本的dtype和缺失率都没看。想走捷径的第一步,往往就是后面返工的开始。
2.1 数据加载与快速探查:read_excel和read_csv到底怎么用才高效
先说加载。Pandas读取excel和csv是几乎每天都会用到的基础操作,但很多人只用了最朴素的写法,大数据量场景下很容易吃内存亏。
python复制import pandas as pd
# 读取Excel,只选需要的sheet和列,大数据量时避免整表载入
df = pd.read_excel(
"data.xlsx",
sheet_name="train",
usecols=["age", "income", "education", "occupation", "apply_date"],
dtype={"cust_no": "string"}
)
# 读取CSV,大数据量时建议显式指定dtype和解析日期列
df = pd.read_csv(
"train.csv",
sep=",",
encoding="utf-8",
dtype={"duration": "int32", "amount": "float32"},
parse_dates=["apply_date"],
nrows=10000 # 第一次探查建议先只读一万行
)
很多人不知道dtype参数的作用。Pandas默认会用int64或float64去装数值列,但如果你的字段本来取值范围很小,指定成int32甚至int16,内存直接砍掉一半以上。parse_dates的作用是把日期字符串一次性解析成datetime类型,比读进来以后再to_datetime转一遍要省事得多。第一次看一份陌生数据,先用nrows读个一万行做探查,确认格式没问题再全量读入,这个习惯能帮你避开很多坑。
数据加载进来以后,不要急着敲模型。花两分钟做一次系统性探查:
python复制# 字段类型、非空数量、内存占用
df.info()
# 数值字段分布
df.describe().T
# 缺失率统计
missing_rate = df.isna().mean().sort_values(ascending=False)
# 唯一值数量,尤其关注分类字段
unique_counts = df.nunique()
info()会直接告诉你每个字段的dtype、非空个数和整体内存占用。nunique()是发现字段放错位置的好帮手——比如一个叫"customer_id"的列只有三个唯一值,那它多半不是ID,而是一个分类特征,甚至可以硬编码成三个级别。
2.2 类型转换:object、category、datetime换错了比缺数据还麻烦
Pandas数据类型转换是特征工程里最容易被忽略但又极其影响后续建模的环节。字符串类型"123"和数值类型123,在Sklearn里是两个完全不同的物种,模型根本不认识前者。
常见的转换场景有这么几类:
python复制# 数字列被误读成字符串:常见于金额列带了逗号或货币符号
df["amount"] = pd.to_numeric(df["amount"].str.replace(",", "").str.replace("¥", ""), errors="coerce")
# 日期字符串转datetime
df["apply_date"] = pd.to_datetime(df["apply_date"], format="%Y-%m-%d %H:%M:%S")
# 低基数类别字段转category,节省内存
df["education"] = df["education"].astype("category")
# category转整数编码
df["education_code"] = df["education"].cat.codes
关于category类型,我在实际项目里看到很多人用了就后悔。给你一个完整认知:category类型在Pandas内部是用了整数索引加映射表来存储的,对于重复度很高的字符串列,内存可以从几GB降到几十MB,性能也更好。但在和Sklearn交互的时候,category类型并不会被自动解析成数值,你必须显式用cat.codes或OneHotEncoder做编码。所以我的建议是:低频次场景用category来省内存,进模型之前统一用编码器转数值。
to_numeric里的errors="coerce"要重点说。它会把无法解析的内容自动变成NaN,比如一个"金额"列里混进了"未知"两个字,coerce之后就会变成缺失值,方便后续统一处理。如果不用这个参数,整列转换会直接报错,你还要回头去查是哪行数据搞的鬼,浪费时间。
2.3 缺失值、重复行和异常值的处理策略:drop还是fill需要分情况
这部分是我见到的各种项目里处理差异最大的环节,也是最容易出数据泄漏的地方。先说四类常规操作:
python复制# 1. 缺失率超过阈值的列直接删
df.drop(columns=[col for col in df.columns if df[col].isna().mean() > 0.8], inplace=True)
# 2. 数值列用中位数填充,注意不是均值
df["age"] = df["age"].fillna(df["age"].median())
# 3. 分类列用众数填充
df["education"] = df["education"].fillna(df["education"].mode()[0])
# 4. 完全重复的行删掉
df.drop_duplicates(subset=["user_id"], keep="first", inplace=True)
为什么数值列用中位数而不是均值?因为收入、房价这类字段基本都有长尾,均值会被极端值拉偏,中位数对异常不敏感。举例:一个班五个人的月收入是3000、4000、5000、6000、50000,均值是13600,中位数是5000。你用均值去填充缺失值,相当于硬把一个人填成了另一个"高收入者",这个偏差会传导到后面的每个环节。
异常值我建议先用分位数的IQR方法做个初筛,而不是一上来就删除。IQR的思路是:把数据按四分位数切开,落在Q1-1.5IQR以下或Q3+1.5IQR以上的点视为异常。
python复制Q1 = df["income"].quantile(0.25)
Q3 = df["income"].quantile(0.75)
IQR = Q3 - Q1
# 先看异常值占比,再决定是删是留
outliers = df[(df["income"] < Q1 - 1.5 * IQR) | (df["income"] > Q3 + 1.5 * IQR)]
print(f"异常值占比: {len(outliers) / len(df):.4f}")
但这里有一个务实的建议:异常值不一定都要删。如果你的模型是决策树或随机森林,对异常值本身就有较强的容忍度;如果是线性回归或SVM,异常值会严重影响拟合,才需要处理。我在收入预测项目里的做法是:先把明显不合理的值过滤掉(比如周工作时长大于80、年龄大于100这种业务上不成立的),剩下的交给模型去学习,而不是用一刀切的方式把长尾全砍了。
顺便提一嘴环境问题。经常有读者问pycharm怎么安装pandas包,其实有图形界面操作之前,直接在pycharm终端里执行pip install pandas scikit-learn是最快的方式。如果这里装不好,后面所有代码都跑不起来,值得花五分钟先把这个环境理顺。
3. 特征构造与变换:把原始字段变成有效信息
数据洗干净以后,接下来要做的就是从现有字段里挖出更多有价值的信息。这一步最能体现一个数据工程师对业务的理解能力。
3.1 数值特征的分箱与标准化:什么时候用cut,什么时候用qcut
分箱(离散化)是特征工程里被低估的手段。本质是把连续数值切成几段,把"一个人的年龄是37岁"变成"这个人在30-45岁这个区间"。好处有三层:一是能降低数值字段的波动性,减少小样本波动的影响;二是能帮助模型捕捉非线性关系——比如周工作时长和收入之间不是线性的,40小时是个坎,60小时以上边际收益递减;三是分箱后的特征更接近业务表达,可解释性强。
Pandas提供了两种分箱方式,用法都很简单:
python复制# 等距分箱:按数值范围切等宽区间
df["age_bin"] = pd.cut(
df["age"],
bins=[0, 18, 30, 45, 60, 100],
labels=["<18", "18-30", "30-45", "45-60", "60+"]
)
# 等频分箱:按数据量切成每段样本数大致相等的区间
df["income_rank"] = pd.qcut(
df["income"],
q=5,
labels=["P1", "P2", "P3", "P4", "P5"],
duplicates="drop"
)
pd.cut是你自己定边界,适合分箱边界有业务含义的场景,比如年龄按未成年人、青年、中年、老年来切。pd.qcut是按分位数来切,不关心边界是不是整数,优先保证每个箱子里样本量均衡。很多新手会在这两个函数上踩坑:qcut在数据里存在大量重复值时,会报"Bin edges must be unique"的错误,解决方案就是加上duplicates="drop"参数。
标准化放在分箱之后说,是提醒你分箱有时可以替代标准化。对于树模型,分箱之后不需要标准化;对于线性模型和SVM这类对尺度敏感的模型,标准化还是得做。Sklearn里有两个常用的scaler:
| Scaler | 公式 | 输出范围 | 适用场景 |
|---|---|---|---|
| StandardScaler | (x-mean)/std | 约[-3,3] | 符合正态分布或后续要PCA |
| MinMaxScaler | (x-min)/(max-min) | [0,1] | 有界特征,或用距离度量的模型 |
在收入预测这个场景里,SVM就是用StandardScaler处理后的特征效果明显好于原始值,因为支持向量机的决策边界依赖样本间的距离计算,如果收入字段几十万、年龄字段几十,距离会被收入完全主导,模型就废了一半。
3.2 类别特征编码:OneHot、LabelEncoder和高基数类别的处理
类别特征编码是Sklearn里用得最多也最容易踩坑的主题。
低基数的无序类别,比如性别、婚姻状态、工作类型,直接用OneHotEncoder:
python复制from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
encoded = ohe.fit_transform(df[["occupation"]])
encoded_df = pd.DataFrame(encoded, columns=ohe.get_feature_names_out(["occupation"]))
这里有两个细节要注意。sparse_output参数在sklearn 1.2之前的版本叫sparse,新版本已经改名,老代码跑在新版本上会报TypeError,我一开始升级版本时踩过这个坑。handle_unknown="ignore"必须加,否则预测集里出现训练集没见过的类别,代码直接崩给你看——这在真实业务里几乎一定会发生。
有序类别,比如学历小学<初中<高中<大学<研究生,不要用OneHot,用LabelEncoder:
python复制from sklearn.preprocessing import LabelEncoder
education_order = ["小学", "初中", "高中", "大学", "研究生"]
le = LabelEncoder()
le.fit(education_order)
df["education_level"] = le.transform(df["education"])
注意,LabelEncoder在处理无序类别时非常危险,它会给所有类别随机分配一个0到N-1的编号,模型会误以为"猫=0、狗=1、猪=2"之间存在距离关系。所以我的原则是:有序变量用LabelEncoder,无序变量用OneHotEncoder。还有一种情况是目标编码(Target Encoding),用目标变量的均值去替代类别,高基数场景很有效,但容易过拟合,需要配合交叉验证使用,这里不展开细说。
高基数类别,比如职业里面几十上百种、邮政编码几万种,OneHot会维度爆炸。实用的办法是:对频率小于阈值的类别统一归为"other"类,然后再做OneHot;或者干脆用频次直接作为特征——一个职业出现的次数本身就是一种信息。
3.3 时间特征拆解与交叉特征构造
时间字段往往是最能从细节里挖出东西的。把"2024-05-11 14:32:08"直接当字符串丢掉是最浪费的行为。时间字段可以拆出很多信号:
python复制# 拆出日期属性
df["year"] = df["apply_date"].dt.year
df["month"] = df["apply_date"].dt.month
df["day"] = df["apply_date"].dt.day
df["weekday"] = df["apply_date"].dt.weekday # 0=周一
df["is_weekend"] = df["apply_date"].dt.weekday >= 5
df["hour"] = df["apply_date"].dt.hour
# 业务时间特征
df["is_month_start"] = df["apply_date"].dt.is_month_start
df["days_to_month_end"] = df["apply_date"].dt.days_in_month - df["apply_date"].dt.day
在电商和金融项目里,这个月的第几天往往跟用户的消费力度有关——发薪日、还款日、月末,分布都不均匀。这个是典型的"数据里没有、但业务里明摆着"的信号,模型自己学不出来,只能靠特征工程喂进去。
交叉特征也属于进模型之前很值得做的一步。单一特征告诉不了模型太多,组合特征才有信息量。最简单的方式是用字符串拼接:
python复制# 年龄分箱和性别交叉
df["age_group_gender"] = df["age_bin"].astype(str) + "_" + df["gender"].astype(str)
# 业务比率特征:比原始值更有解释力
df["income_per_hour"] = df["income"] / df["working_hours"]
做交叉特征之前先问自己:这个组合在业务上说得通吗?比如"年龄性别"对收入预测说得通,因为不同年龄段和性别的收入分布差异很大;"职业星座"就说不通,既没有业务逻辑支撑,又容易过拟合。特征工程不是特征炫技,一个业务上讲不通的特征,模型学到的多半是噪声。
4. 用Sklearn做特征选择与降维:从几十个特征里挑出真正有效的
特征构造做多了,维度会膨胀。尤其是OneHot之后,几千列都有可能。这时候如果不做特征选择,模型训练速度慢、过拟合风险高,而且很多冗余特征会干扰模型判断。Sklearn在这一步提供了非常完整的工具链,按策略可以分为过滤式、嵌入式、包裹式。
4.1 过滤式:VarianceThreshold和相关系数筛选
过滤式的思路是根据特征的统计指标直接做初筛,不依赖任何模型。
VarianceThreshold是最简单的一个——方差太小的特征几乎不携带信息,直接删除。比如OneHot之后有个列99%的样本都是0,这个列对模型基本没有贡献。
python复制from sklearn.feature_selection import VarianceThreshold
sel = VarianceThreshold(threshold=0.05)
X_sel = sel.fit_transform(X)
# 保留的特征列名可以用布尔索引获取
selected_columns = X.columns[sel.get_support()]
相关系数筛选用来处理特征冗余。两个特征高度相关,比如"年收入"和"月收入",它们本质上是同一个信息的两种表达,同时放进模型不仅浪费计算资源,还可能引入多重共线性。
python复制corr_matrix = X.corr()
# 找出相关系数绝对值大于0.8的特征对
high_corr_pairs = []
for i in range(len(corr_matrix.columns)):
for j in range(i + 1, len(corr_matrix.columns)):
if abs(corr_matrix.iloc[i, j]) > 0.8:
high_corr_pairs.append((corr_matrix.columns[i], corr_matrix.columns[j], corr_matrix.iloc[i, j]))
这里要做个补充说明:相关系数只能捕捉线性关系,两个特征之间有明显非线性交互但线性相关很低,用这个方法看不出来。所以过滤式只能作为初步筛选,不能作为唯一手段。
4.2 嵌入式:从树模型拿到特征重要性
嵌入式特征选择是实战中最常用的方式。原理是先用一个模型去拟合数据,训练完成后拿到模型自己判断的特征重要性,再根据重要性阈值来筛选特征。
python复制from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import SelectFromModel
model = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1)
sel = SelectFromModel(model, threshold="median") # 保留重要性大于中位数的特征
sel.fit(X, y)
X_important = sel.transform(X)
threshold="median"的意思是保留特征重要性高于中位数的那些特征,也就是大概砍掉一半。这个策略在没有明显先验信息的时候很好用,简单直接,也不会砍得太狠。
用feature_importances的时候有一个常见误区:这个值反映的是特征在树分裂中带来的纯度增益,不代表特征与目标之间的因果强度。一个特征重要性高,只能说明这个特征在模型里很有区分力,不能解释为"因为所以"的业务原因。换个模型,比如从随机森林换成梯度提升树,重要性排序可能就会变,因为不同模型的划分策略不同。所以我在实践中的做法是:同时跑随机森林和GradientBoosting两个模型,把两份特征重要性取并集,保留都在前30%的特征,这样筛选结果更鲁棒。
4.3 包裹式:RFECV递归特征消除
RFECV的思路更直接:从头到尾训练一遍模型,砍掉最不重要的那个特征,再用剩下的特征重新训练,重复这个过程,直到特征数量达到设定值,然后通过交叉验证选出最佳特征数量。
python复制from sklearn.feature_selection import RFECV
from sklearn.tree import DecisionTreeClassifier
estimator = DecisionTreeClassifier(random_state=42)
rfecv = RFECV(estimator=estimator, step=1, cv=5, scoring="accuracy", n_jobs=-1)
rfecv.fit(X, y)
print(f"最优特征数量: {rfecv.n_features_}")
print(f"选中的特征: {X.columns[rfecv.support_]}")
RFECV有CV,就是通过交叉验证来自动确定保留多少特征,这是它比普通RFE更稳的原因。缺点是:特征多、数据量大的时候,反复训练模型的计算开销很大。在大数据集上,我一般先用4.2的嵌入式把特征从几千砍到几十,再用RFECV在这几十个特征上精挑,两级筛选,兼顾效率和效果。
4.4 PCA降维的适用边界:不是所有模型都适合
PCA是降维工具里最有名的一个,但很多人在树模型上也直接用PCA,这是一个很典型的错误用法。PCA的思路是把原始特征投影到方差最大的几个方向上,丢掉方差小的方向,但方差小不代表没信息。对决策树、随机森林这类模型,特征本身有明确的含义和切分逻辑,PCA后的主成分是原始特征的线性组合,业务上不可解释,而且树模型对这种线性组合并不敏感,降了等于没降。
PCA真正适合的是线性模型、SVM、KMeans这类依赖距离和数据分布的算法,以及当特征维度非常高、计算成为瓶颈的时候。如果要用PCA,记住必须先标准化再降维,不然量纲大的特征会主导主成分方向。
python复制from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
pca = PCA(n_components=0.95) # 保留95%的累计方差
X_pca = pca.fit_transform(X_scaled)
我的习惯是:能不用PCA就不用PCA,优先用特征选择保留原始特征的业务含义。只有在特征维度爆炸(比如上百维文本TF-IDF)或者线性模型稀疏高维表现不佳时,才考虑PCA。
5. Pipeline整合:把Pandas预处理和Sklearn建模串成一条流水线
初学阶段,很多人写代码是散装的:清洗一段、编码一段、训练一段,每个环节都自己手动跑一遍,看起来灵活,实际上埋了很多雷。最大的雷是数据泄漏——在切分数据集之前就做了全量标准化或填充,测试集的信息提前泄露进了训练过程,模型的评估结果虚高,一上线就现原形。
5.1 ColumnTransformer混合处理数值列和类别列
Sklearn从0.20版本开始提供ColumnTransformer,专门解决"不同列用不同变换"的问题。在收入预测这个场景里,数值列需要中位数填充加标准化,类别列需要众数填充加OneHot,用ColumnTransformer可以一次性配置清楚:
python复制from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.tree import DecisionTreeClassifier
num_cols = ["age", "income", "working_hours", "education_num"]
cat_cols = ["workclass", "occupation", "marital_status", "gender"]
num_[transformer](https://taotoken.net/?utm_source=general) = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler())
])
cat_transformer = Pipeline(steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
("ohe", OneHotEncoder(handle_unknown="ignore"))
])
preprocessor = ColumnTransformer(transformers=[
("num", num_transformer, num_cols),
("cat", cat_transformer, cat_cols)
])
pipeline = Pipeline(steps=[
("preprocessor", preprocessor),
("classifier", DecisionTreeClassifier(max_depth=5, random_state=42))
])
pipeline.fit(X_train, y_train)
pipeline.score(X_test, y_test)
这个写法的核心价值在于:preprocessor在fit的时候会在训练集上学到中位数、众数、标准化参数、编码映射表,然后transform的时候把这些参数原封不动地搬到测试集上。这个过程不会混入测试集的任何信息。
5.2 Pipeline为什么能规避数据泄漏
对比一下就清楚了。散装代码的常见写法是:
python复制# 错误示范
df["age"].fillna(df["age"].median(), inplace=True) # 用了全量数据的中位数
X = df.drop("target", axis=1)
y = df["target"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
问题在于,填充用的中位数是全量数据的统计量,包含了测试集的分布信息。测试集在这里就不是"没见过的数据"了。用这种方式做评估,模型的分数会偏乐观,但上线后面对真实新数据,表现大打折扣。
Pipeline从机制上杜绝了这个可能——它保证任何fit出来的参数都只来自训练集,测试集的transform只是机械地套用已学到的参数。这一点在标准化、缺失值填充、编码映射、特征缩放上全都一样。所以我的建议很明确:只要你的特征工程步骤有标准化、编码或填充,就一定要用Pipeline把流程管起来,这不是锦上添花,是必须。
6. 完整实战:决策树做收入预测的特征工程全流程
到这里,我把整条链路的理论和工具都过了一遍。下面用一个经典的收入预测案例把全流程串起来。这个案例的数据结构类似Adult数据集,目标是预测一个人的年收入是否超过5万美金,特征包括年龄、工作类型、教育程度、婚姻状态、职业、性别、周工作时长、资本收益等。
6.1 从原始数据到特征集的完整转换
第一步仍是清洗。Adult数据集的缺失值是用"?"表示的,这种隐藏的缺失值经常被忽视,导致分类字段里出现了一个诡异的取值"?"。清洗时先把"?"统一替换成NaN,再做常规缺失值处理。
python复制data = pd.read_csv("adult.csv")
data = data.replace(" ?", np.nan)
# 删除缺失率过高的国产字段
data.drop(columns=["native_country"], inplace=True)
# 分类列填充众数,数值列填充中位数
obj_cols = data.select_dtypes(include=["object"]).columns
num_cols = data.select_dtypes(exclude=["object"]).columns
for col in obj_cols:
data[col].fillna(data[col].mode()[0], inplace=True)
for col in num_cols:
data[col].fillna(data[col].median(), inplace=True)
第二步是做特征变换。年龄和周工作时长适合分箱,资本收益资本损失这两列有大量0值,干脆做一个"是否有资本收益"的布尔特征外加一个原始值分箱。
第三步就是前面提到的ColumnTransformer和Pipeline整合,建模用决策树。
python复制from sklearn.model_selection import train_test_split
X = data.drop("income", axis=1)
y = data["income"].map({">50K": 1, "<=50K": 0})
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
pipeline.fit(X_train, y_train)
accuracy = pipeline.score(X_test, y_test)
print(f"决策树准确率: {accuracy:.4f}")
6.2 对比实验:原始特征集 vs 特征工程后的效果
为了让特征工程的价值看得见,我做了个对比实验:
| 特征集 | 处理方式 | 决策树准确率 |
|---|---|---|
| 原始特征 | 所有字段原样喂入,不做清洗和编码 | 0.761 |
| 基础清洗 | 缺失值填充、类别LabelEncoder编码 | 0.793 |
| 完整特征工程 | OneHot/有序编码+分箱+时间特征+交叉特征 | 0.817 |
| 完整工程+特征选择 | 再用SelectFromModel筛掉一半特征 | 0.823 |
这几行数字非常直观。特征工程做完,准确率提升了6个百分点,接近1万个样本量的二分类任务,6个百分点的提升在业务里可能就是很大的收益。而且最终只用了不到一半的特征,训练时间也更快,模型也更好解释。
决策树本身对缺失值和异常值有一定容忍度,从这个对比能看出来,提升主要来自对类别字段的正确编码、数值字段的分箱,以及对噪声特征的清洗。这验证了我前面说的那句话:模型算法决定下限,特征工程决定上限。
7. 特征工程里的隐性坑和大数据面试高频考点
最后一章是实操经验汇总和面试知识点。很多坑我是踩了第二次才长记性的,这里一起列出来。
7.1 实战中容易被忽略的四个隐性坑
第一个坑是category类型和Sklearn的兼容性。Pandas里category类型虽然省内存,但直接喂给Sklearn会报"could not convert string to float"。我见过不少人在这一步卡住,然后怀疑是类型转换的问题,实际上需要显式编码。用OneHotEncoder或者cat.codes都行,就是不能跳过。
第二个坑是fillna的时机。如果在一个大循环里反复做实验,填充操作执行了两次,第一批数据里的"中位数"已经是被填充过以后的数据算出来的,这种错误非常隐蔽。执行顺序是先算统计量,再填充,再切分,不要反过来。
第三个坑是OneHot之后没有及时把DataFrame的列名对齐。当你把编码后的数组转回DataFrame时,如果不指定columns=ohe.get_feature_names_out(),列名会变成默认的0、1、2...,后面做特征重要性分析的时候根本分不清谁是谁。
第四个坑是时序数据里用未来信息。如果数据本身带有时间属性,比如用户行为日志,做特征时要保证只用当前时刻之前的信息。比如预测用户下周是否流失,特征里的"本月消费总额"就要用当前时刻以前的数据计算,而不是把整月的消费总额直接用进来。这个坑在面试里很常见,实际业务里也经常有,一个不留神就造成数据泄漏。
7.2 面试高频考点:特征工程怎么答才拿分
大数据面试题里,特征工程是必考板块,而且问法非常集中:
-
"特征选择的三种方法有什么区别?"面试官想听的是对比分析。过滤式简单快速、不依赖模型、但忽略特征组合;嵌入式依赖模型训练、能捕捉特征组合、但结果随模型变化;包裹式效果最好、但计算开销最大。能说清楚各自的适用场景比背定义重要。
-
"高基数类别特征怎么处理?"不要只说OneHot。回答应包含:频率编码、目标编码配合交叉验证、Embedding降维等方法,而且要点明各自的优缺点。如果你提到目标编码容易过拟合,需要用交叉验证包裹,这个就算有经验了。
-
"Pandas处理大数据怎么做内存优化?"这题在特征工程里也常被问到。答案围绕:指定dtype、用category压缩字符串列、分块读取chunk、及时del释放内存、用稀疏矩阵存储OneHot结果。
-
"你觉得什么样的特征是好的特征?"这个问题开放性很强,我一般从三个维度答:与目标的相关性高(单变量分析能看出来)、分布稳定(线上线下分布差异不大)、业务可解释(能讲清楚为什么这个特征能预测目标)。
7.3 我个人判断一个特征该不该留的标准
方法前面讲了不少,但具体到业务里,"某个特征到底要不要留"最终没有标准答案。我自己的判断标准有这几条:
一、看它是否经过三种以上的筛选方法都被保留。如果VarianceThreshold、SelectFromModel、RFECV三个方法都说这个特征没用,那基本可以断定它没价值。
二、看它在线下验证和线上A/B测试之间是否表现一致。有的特征线下表现很好,上线后由于数据分布漂移,重要性断崖式下跌,这种特征要特别警惕,它可能只是在历史数据里恰好和target碰巧相关。
三、看它是否让模型更简单。如果一个特征加进去,准确率只提升了0.0001,但让模型复杂度上升了20%,我就不太倾向于保留。特征工程的目标是让模型在同一复杂度下效果更好,或者在同一效果下复杂度更低。
根据我个人的实操经验,还有一个值得做的动作:把每次实验的特征集、特征重要性排序、模型分数记在一个表格里。看起来多此一举,但在项目中期需要回溯"某个特征为什么加进来/删掉"的时候,这个表格能帮你省下很多回忆的时间。特征工程不是一次性的活,它是在不断迭代中逼近业务真相的过程。保留记录,就是保留迭代的上下文。
