1. 为什么特征工程比调参还重要:先弄清它到底解决了什么问题
1.1 sklearn在特征工程里扮演什么角色
先说个很现实的问题。很多初学者跑通第一个机器学习模型之后,最直观的感受是:模型是能跑了,但效果一言难尽。A同学用同一份数据、同一个算法,跑出来的准确率就是比我的高。一开始以为是调参不够,后来发现根本不是参数的问题,问题出在喂给模型的"原料"上。这个原料,就是特征。
机器学习里有一句被说烂但确实是对的总结:数据和特征决定了机器学习的上限,而模型和调参只是在不断逼近这个上限。特征工程就是负责把"上限"往上抬的那个环节。你模型选得再牛,正则化调得再花哨,如果特征本身没有区分度、量纲混乱、缺失值一堆、非数值字段不知道怎么处理,那最终效果一定受限。
我学习特征工程的第二天,就是顺着sklearn这条路走下来的。sklearn不仅是算法库,更是目前做特征工程最顺手的工具集。它提供的预处理模块、特征抽取模块、特征选择模块,基本覆盖了日常工作中九成以上的特征处理场景。这一天的笔记核心就围绕sklearn来梳理特征工程的全流程:先理解它解决什么问题,再拆开特征处理、特征抽取、特征选择这几大块逐一看。
1.2 特征工程到底包含哪些工作
从标题就能看到,特征工程不是一个单独动作,而是一个流程组合。我理解下来它至少包含四块内容:
- 特征使用:搞清楚手头数据里哪些字段能作为特征,哪些是冗余信息,哪些是目标泄露,这一步看起来简单但最容易被忽略。
- 特征获取:从原始数据中提取信息,比如把时间戳拆成星期几、是否节假日,把文本内容转成数值向量,这就是特征抽取。
- 特征处理:对已经得到的特征做清洗和变换,包括缺失值填充、无量纲化、类别编码、离散化等。
- 特征选择:从一堆特征里筛掉没用的、重复的、噪声大的特征,或者通过降维手段压缩特征空间。
这四个环节不是必须全上,但你要清楚自己在哪个环节、为什么做这一步。很多时候我在实践中发现,问题不是特征太少,而是特征太乱。数据拿到手,先看一眼类型分布,数值型有多少、分类型有多少、文本型有多少、缺失率多高,心里有数之后再做下一步。
1.3 一个例子直观感受特征的力量
举个特别简单的例子。假设要预测一个人是否会在平台上下单,原始数据里有一列是"注册时间",格式是"2023-05-14 08:32:17"。如果你直接把这个字符串塞给模型,模型什么都学不到,因为sklearn处理不了字符串,就算把它转成时间戳数值,这个数值本身的含义也不是模型能理解的。
但如果你从"注册时间"里拆出"注册月份""注册时段(白天/晚上)""距今天数",这些就变成了有业务含义的特征。再比如把"注册时长"和"历史登录次数"组合成一个"平均登录频率",这又是一种特征加工。整个过程下来,同一个字段能衍生成多个有效特征,模型可用的信息量完全不一样。这就是特征工程最直观的意义:让算法能够从原始数据里"读出"你希望它读到的信息。
2. 特征处理:无量纲化、缺失值、类别编码这些基本动作要拉通
2.1 无量纲化:标准化与归一化的选择逻辑
特征处理里最基础也最常用的就是无量纲化。为什么需要它?举个例子,一个特征"年龄"取值范围是0到100,另一个特征"年收入"取值范围是3万到200万。很多算法(比如KMeans、KNN、SVM)在计算距离时,数值大的特征会主导距离的大小,年收入稍微差一点,年龄的差异就被淹没了。这不代表年龄不重要,只是量纲不一致导致的偏差。
sklearn里解决这个问题主要靠两个类:StandardScaler和MinMaxScaler。很多人分不清它们,我直接用一句话总结:StandardScaler把数据变成均值为0、方差为1的标准正态分布,适合数据本身近似正态分布、存在离群点的情况;MinMaxScaler把数据压缩到[0,1]区间,适合数据有明确边界、没有严重离群点的情况。
from sklearn.preprocessing import StandardScaler, MinMaxScaler data = [[100, 0.2], [50, 0.8], [80, 0.5]] scaler_std = StandardScaler() data_std = scaler_std.fit_transform(data) scaler_minmax = MinMaxScaler() data_minmax = scaler_minmax.fit_transform(data)这里有个关键坑:fit_transform只能用在训练集上。测试集和未来新数据进来时,只能用transform,不能再fit。原因在于你要保持训练时确定的均值和标准差,否则每次新数据都会重新计算变换参数,训练集和测试集的分布就对不上了,模型效果评估就失去了意义。
那什么时候用标准化,什么时候用归一化?就我个人使用习惯来说:如果后续要用PCA、SVM、KNN这类对距离敏感或者需要正态假设的方法,优先标准化;如果数据有天然的上下界,比如像素值0到255、评分0到5分,或者后续要用神经网络,归一化往往更稳。但远没有到"必须怎样"的程度,两种都可以试,用交叉验证看效果选。
2.2 缺失值处理:不是简单填充就完事
缺失值处理是特征处理里最容易被糊弄过去的一步,也是最容易翻车的一步。常见的做法无非删除、填充、不处理。删除适合缺失率极高(比如超过80%)且本身没太大价值的列,或者缺失行数极少的情况;填充则要分特征类型来定策略。
数值型特征,最常用的就是均值、中位数、众数填充,sklearn里的SimpleImputer干的就是这个活。中位数比均值更稳健,适合有离群值的特征;众数适合离散型特征。还有一个思路是向前/向后填充,这在时间序列里很常见。分类特征,则常用"缺失"本身作为一个独立类别,因为某些场景下缺失状态本身就是一种信号——比如用户没有填收入,可能本身就说明某种群体特征。
from sklearn.impute import SimpleImputer import numpy as np X = np.array([[1, 2], [np.nan, 3], [7, np.nan]]) imputer = SimpleImputer(strategy='median', missing_values=np.nan) X_filled = imputer.fit_transform(X)实际项目里我更推荐一个思路:先看看缺失值分布有没有规律。比如某个字段缺失的行恰好集中在某个时间段或某一类用户群体里,那这不是随机缺失,简单填充会引入偏差。这时候可以考虑把"是否缺失"单独作为一个0/1特征加进去,再对原特征做填充,信息保留得更完整。这个细节看起来不起眼,但在一些业务场景里能明显提升模型表现。
2.3 类别特征编码:One-Hot与哑变量陷阱
类别特征不能直接进模型,必须转成数值。最常见的办法是One-Hot编码,sklearn的OneHotEncoder和pandas的get_dummies都能做。核心逻辑是把一个类别列拆成多个0/1列,每一个取值对应一列。比如"颜色"里有红、黄、蓝三种取值,就拆成"颜色_红""颜色_黄""颜色_蓝"三列。
这里有一个容易忽视的地方:当某个类别列有几十个甚至上百个取值时,One-Hot会生成一个非常稀疏的大矩阵。比如"城市"列有200个取值,你就多出200列,其中绝大多数位置是0。计算开销大不说,还会稀释有效信息。对这种高基数类别特征,可以考虑业务合并(把低频类别归为"其他")、使用目标编码,或者改用嵌入方式在深度学习里处理。
还有一个坑叫"哑变量陷阱"。One-Hot之后,类别特征的k个取值会生成k列,但这k列存在线性相关,因为一行数据里只有一个位置是1。如果线性模型直接使用这k列,会引发多重共线性问题,影响系数解释。解决办法是drop first,即丢掉第一列,sklearn的OneHotEncoder里有drop='first'参数。树模型不需要担这个心,但线性模型建议保留这个习惯。
3. 特征抽取:从字典、文本这些非数值数据里挖出可训练的特征
3.1 字典特征抽取:DictVectorizer是怎么把结构化数据变成矩阵的
特征处理解决的是"已经是数值但不够干净"的问题,而特征抽取解决的是"根本不是数值"的问题。市面上最常见的两种非数值数据:字典结构和文本。
先看字典结构。业务开发中我们经常拿到类似的数据:
data = [ {'city': '北京', 'age': 30}, {'city': '上海', 'age': 25}, {'city': '广州', 'age': 35} ]sklearn里DictVectorizer可以直接把这批字典转成一个数值矩阵,其中分类字段自动One-Hot,数值字段保持原样:
from sklearn.feature_extraction import DictVectorizer vec = DictVectorizer(sparse=False) X = vec.fit_transform(data) print(vec.get_feature_names_out())注意一个细节:维度大了之后矩阵会非常稀疏,所以DictVectorizer默认返回稀疏矩阵,sparse=False才返回稠密数组。日常处理推荐保持默认,等向量化之后再用稀疏格式做运算,省内存也省时间。这个类在对接接口数据、把JSON字段展开成特征矩阵时非常顺手,尤其在做一些数据清洗的中间环节时,可以少写很多手工的字段映射代码。
3.2 文本特征抽取:词频统计还不够,TF-IDF才是标配
文本特征抽取是另一个大块,也是最容易踩坑的地方。sklearn里有两个主要工具:CountVectorizer和TfidfVectorizer。
CountVectorizer做的事情很简单——统计每个词在每篇文档里出现的次数,生成一个"文档-词项"矩阵。它的几个重要参数:token_pattern决定什么样的字符算一个词,默认是"\b\w\w+\b",意味着只保留长度大于等于2的单词,中文场景下这个默认值经常不适用,需要自定义分词;stop_words可以排除无意义的停用词;max_features可以限制最大特征数,防止维度爆炸。
只统计词频有一个明显问题:一篇文档里的"的""了""是"这类词出现频率很高,但它们几乎没有区分度。真正应该被重点关注的,应该是"在某个文档里频繁出现、但在其他文档里很少出现"的词,这就是TF-IDF背后的思想。
from sklearn.feature_extraction.text import TfidfVectorizer tfidf = TfidfVectorizer(max_features=5000, stop_words='english') X = tfidf.fit_transform(['this is a sample', 'another text sample'])TF由词在文档里的频率决定,IDF则衡量词在整个语料里的稀缺程度。两者相乘就是TF-IDF值。实现上TfidfVectorizer相当于CountVectorizer加TfidfTransformer的整合,sklearn官方也推荐直接用TfidfVectorizer。需要注意的是,TfidfVectorizer的默认参数里包含smooth_idf=True和sublinear_tf=False,这些参数会影响最终权重,碰到结果不太合理时可以调整这两项。
中文文本处理比英文复杂一些,因为分词没法靠空格。一般做法是先用分词工具把整句切词,再用空格连接成"伪英文"格式,喂给TfidfVectorizer。文本特征抽取这块,最大的经验就是:不要上来就堆维度,先看语料规模,几千篇文档情况下取3000到10000个特征词通常已经够用,再多就是噪声了。
3.3 特征抽取与特征选择的分工
特征抽取和特征选择容易混淆,但它们是两件事。特征抽取是创造新特征,把非数值数据变成数值数据,或者把多个原始特征组合成新特征——比如PCA降维本质上就是一种特征抽取,因为它生成的是原始特征线性组合后的新成分。特征选择则是从已有特征里挑出子集,不改变特征本身,只是删掉一部分。
脑子里可以这样区分:抽取是"加法",它让可用信息变多;选择是"减法",它让有效信息变集中。实际项目里两者的使用顺序没有严格规定,但常规流程是先做抽取再做好做选择。比如先TF-IDF抽取出几千维文本特征,再用卡方检验筛到几百维,这是常见的组合玩法。
4. 特征选择:过滤式、包裹式、嵌入式三种思路的选型逻辑
4.1 过滤式:方差过滤和卡方检验的适用场景
特征选择解决的是"特征太多太杂"的问题。当特征数量超过样本量、或者特征之间存在大量冗余时,模型容易过拟合,训练时间也会拉长。过滤式是第一道粗筛,思路是用统计指标衡量每个特征和目标之间的关系,跟模型无关,速度快。
最常用的是方差过滤。如果一个特征在所有样本上的值几乎一样,那它对区分样本没有任何贡献,直接删掉即可。sklearn的VarianceThreshold就是干这个的,threshold参数指定方差阈值。但这里有个使用经验:方差阈值需要结合特征的分布来定,不能直接设0.1之类,建议先打印出每个特征的方差排序,看看分布再划阈值。
另一种过滤方式是用相关性或统计检验。经典的有卡方检验(适合分类问题、特征为离散值的情况)、互信息法(适合特征和目标之间存在非线性关系的情况)、皮尔逊相关系数(适合连续特征与连续目标)。sklearn里用SelectKBest配合score_func参数来挑选得分最高的K个特征:
from sklearn.feature_selection import SelectKBest, chi2 from sklearn.datasets import load_digits data = load_digits() X, y = data.data, data.target X_selected = SelectKBest(chi2, k=10).fit_transform(X, y)过滤式的优点是快、不依赖模型,适合高维特征的预筛选;缺点是只单独看每个特征的贡献,不考虑特征之间的组合效应。两个特征单独看都不行,但组合起来非常有用的情况,过滤式是筛不出来的。
4.2 包裹式:递归特征消除为什么慢但有效
包裹式把特征选择本身当作一个搜索问题,核心思路是:先用某个模型在特征子集上训练并评估效果,根据效果好坏增加或删减特征,反复迭代直到找到最好的组合。sklearn里的RFE(递归特征消除)就是典型实现:每轮训练模型后,删掉权重或特征重要性最小的特征,再用剩余特征重新训练,递归重复直到达到指定特征数。
from sklearn.feature_selection import RFE from sklearn.linear_model import LogisticRegression estimator = LogisticRegression(max_iter=1000) selector = RFE(estimator, n_features_to_select=8) selector.fit(X, y) X_rfe = selector.transform(X)包裹式的效果通常比过滤式好,因为它考虑到了特征组合的效用。但代价也很明显:每轮都要训练一次模型,特征维度高的时候计算开销非常大,甚至不现实。使用场景上,我一般只在特征量已经控制在一两百以内、且模型训练成本可接受时才会用RFE,更多时候它作为验证手段,看看过滤式和嵌入式选择出的特征是否还能进一步提升,如果提升不明显就用前者。另外RFE的estimator要选支持coef_或feature_importances_属性的模型,否则跑不起来,这一点容易卡住新手。
4.3 嵌入式:基于模型重要性挑特征
嵌入式是过滤式和包裹式的折中:模型在训练过程中自动完成特征重要性评估,我们直接套用结果就行。最常见的两种路径:树模型的特征重要性、带L1正则的线性模型。决策树和随机森林训练完成后会输出feature_importances_,表示每个特征在分裂中的贡献程度。我们可以设定一个阈值或保留Top N个特征。
from sklearn.ensemble import RandomForestClassifier from sklearn.feature_selection import SelectFromModel model = RandomForestClassifier(n_estimators=100) selector = SelectFromModel(model, threshold='median') X_embedded = selector.fit_transform(X, y)L1正则的线性模型(Lasso、LogisticRegression带penalty='l1')则更直接:L1会把大量特征的系数压到0,剩下的非零系数特征就是被"选中"的特征。这个特性在特征维度特别高时(比如几千上万个特征)能快速得到一个稀疏的特征子集,是实际项目中很实用的路径。SelectFromModel配合L1模型写起来也很短,几行代码就能搞定一次粗筛。
嵌入式介于两者之间的原因在于:它也依赖模型、会考虑特征与目标的整体关系,但训练过程是单次的,不需要像RFE那样反复迭代。所以它是配合过滤式之后进一步精筛的好选择。
4.4 PCA降维和特征选择的区别
很多人把PCA和特征选择混在一起,其实完全是两个思路。特征选择是删除特征,被删除的特征在之后就不再存在;PCA是把原有特征线性组合成一组新特征,新特征的数量变少,但每个新特征都"掺和"了原来所有特征的信息,原始特征本身并没有被删除。
这意味着PCA后的特征失去了可解释性。假设原来有"年龄""收入""消费频次"三个特征,PCA之后得到的第一主成分可能是三者某种权重的线性组合,你很难告诉业务方这个成分具体代表什么。如果项目对可解释性要求高(比如风控、医疗诊断,需要说明每个特征的影响),那就优先做特征选择而不是PCA。而如果特征是纯数值型、维度很高、主要用来做压缩和去冗余,PCA是效率很高的办法。
sklearn里的PCA使用非常简单,但有一个关键点容易被忽略:PCA对特征量纲很敏感,如果特征尺度差异大(比如几万数值和0到1数值混在一起),PCA结果会被大尺度的特征主导。所以用PCA之前最好先做标准化,这个"先标准化后降维"的顺序要养成习惯。
from sklearn.decomposition import PCA from sklearn.preprocessing import StandardScaler scaled = StandardScaler().fit_transform(X) pca = PCA(n_components=0.95) # 保留95%方差贡献 X_pca = pca.fit_transform(scaled)5. sklearn实操中的几个细节坑,以及我的使用习惯
5.1 各版本API差异带来的坑
sklearn版本更新比较快,API的变动如果不注意非常容易报错或者用了已经废弃的写法。几个我实际踩过的坑值得记一下:
- sklearn的Imputer类在0.22版本后已经移除了,现在统一用SimpleImputer。搜索"sklearn Imputer"的时候,网上很多老教程用的还是Imputer,直接复制代码大概率报错。
- OneHotEncoder在0.20版本之后发生了较大变化,之前只支持整数型类别编码,现在支持字符串输入,但需要明确传sparse_output参数(旧版叫sparse)。
- DictVectorizer的get_feature_names在1.0之后改成get_feature_names_out,返回的也不是列表而是ndarray。类似的变动在CountVectorizer、TfidfVectorizer里都有出现。
遇到这类问题最简单的止损方式是把sklearn升级到最新稳定版本,同时以官方文档为准。还有一个习惯比较建议养成:在环境里固定好sklearn主版本号,因为跨大版本之后模型pkl文件可能无法直接加载。
5.2 数据泄露问题:fit只用训练集,transform才用测试集
这是特征工程实操中最需要警惕的问题,词面意思叫"数据泄露"(data leakage)。简单说就是你在处理特征时,不小心让测试集或未来新数据的信息混进了训练过程,导致模型评估结果虚高,上线之后效果断崖式下跌。
最典型的例子就是缺失值填充和标准化。用整个数据集(包括测试集)的均值去填充缺失值、用整个数据集的均值和方差做标准化,这都是在"偷看"测试集信息。正确的做法是:在训练集上fit,再用训练集得到的参数去transform测试集。sklearn里的fit/transform/fit_transform设计就是围绕这个逻辑来的,fit只学参数,transform只做变换。特征选择的fit也是一样道理——如果用全量数据做卡方检验选特征,也属于泄露。
实际开发中我习惯用Pipeline来封装这个流程,把标准化、特征选择、模型训练写成一个完整流程。这样在交叉验证时,每一折都会独立fit和transform,从流程上避免泄露问题。这也是为什么我建议特征工程一定要学Pipeline用法,它不只是代码整洁的问题,更是防止隐性bug的重要手段。
5.3 特征工程流程化和Pipeline组合
Pipeline最直接的价值就是把特征处理和模型训练串起来。举个例子,一个典型流程包括:缺失值填充 -> One-Hot编码 -> 标准化 -> 特征选择 -> 模型训练。如果手工分步写,流程长且容易出错。用Pipeline封装之后,一次fit就是全部流程的一轮训练,一次predict就是新数据全流程的自动处理。
from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.linear_model import LogisticRegression from sklearn.compose import ColumnTransformer numeric_features = ['age', 'income'] categorical_features = ['city', 'occupation'] numeric_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()) ]) categorical_transformer = Pipeline(steps=[ ('imputer', SimpleImputer(strategy='constant', fill_value='missing')), ('onehot', OneHotEncoder(handle_unknown='ignore')) ]) preprocessor = ColumnTransformer( transformers=[ ('num', numeric_transformer, numeric_features), ('cat', categorical_transformer, categorical_features) ] ) pipeline = Pipeline(steps=[ ('preprocessor', preprocessor), ('classifier', LogisticRegression(max_iter=1000)) ])ColumnTransformer可以分别对不同字段应用不同预处理,Pipeline再把预处理和模型串联起来。这几个类组合起来是sklearn特征工程流程化的核心玩法,建议每个认真学特征工程的人都亲手试一遍。
5.4 对特征工程的进阶建议
最后聊一点个人体会。特征工程的学习很容易陷入"工具会用但不知道什么时候用"的状态。我的经验是反过来的:先想清楚业务要解决什么问题,再看手头数据长什么样,最后才决定用哪个sklearn类。工具只是把想法落地的路径,真正决定特征工程质量的是对业务和数据的理解。曾经某次做点击率预测,特征工程做到最后,效果提升最明显的不是某个复杂编码,而是把"用户上次点击距现在的时间间隔"这个字段单独做成了特征,模型一下就学会了"近期活跃用户更可能点击"这个规律。这个特征没有任何技巧含量,但它抓住了业务本质。所以我的建议是:在用工具之前,多花时间把数据当成业务去看,问一问"这个字段可能和我的目标有什么关系?",答案找到之后,工具的使用就顺理成章了。