做机器学习时间长了,你会发现一个很朴素的道理:模型上限其实在数据里,预处理决定了下限。特征量纲不一致、缺失值乱填、类别列没编码、训练集和测试集各自“美丽”,这些问题不解决,后面的模型调参再用心,效果也会被拖垮。sklearn里的数据预处理(Preprocessing)就是这么个不起眼却绕不过去的模块。这篇是sklearn专题的第三篇,我准备把从缺失值清洗、数值缩放、类别编码到流水线组合这套东西完整拆一遍,给出的代码和思路都是自己实际跑过、踩过坑才沉淀下来的,适合刚在sklearn里完成建模、开始关心“为什么同样数据别人分数高那么多”的同学,也适合那些想让预处理代码不再手忙脚乱的开发者。
整个专题我习惯把它划分成四个层次:先想清楚模型要什么数据,再逐列把脏数据洗干净,然后把特征变成模型“吃得懂”的数值形态,最后把一串操作固化成可复用的流水线。这篇文章就按这个脉络走,用一个虚构的房价预测场景贯穿始终,每段代码都会解释“为什么这么写”,而不是单纯贴一份作业。如果你已经入了sklearn的门,但每次预处理都要临时翻文档、调参数,这篇应该能帮你把思路彻底捋顺。
1. 先把思路理顺:数据预处理要解决什么问题
1.1 模型为什么对“脏数据”这么敏感
很多人第一次接触数据预处理,是被报错逼的:字符串列丢进模型直接报错,或者全DataFrame转成数值后效果莫名其妙变差。这些现象背后其实是同一个逻辑:模型本质是数学函数,它对输入的数据形式是有假设的。
拿线性回归和SVM这类模型来说,它们依赖距离计算或者梯度下降。假如面积这个特征的取值范围是30到300,房龄是0到40,两者直接放进模型,面积因为数值大,对距离的贡献天然就压过房龄,量纲在这里直接扭曲了权重。树模型对数值量纲不太敏感,但它也有自己的痛点:类别特征如果直接编码成[0,1,2],模型会把这个数值当成连续变量对待,认为2是1的两倍、2比1更“大”,这就凭空捏造了一种排序关系。
还有一类更隐蔽的问题,就是数据分布。线性模型通常默认特征和标签之间的关系近似线性,如果某个特征本身是对数分布或严重偏态,直接丢进模型,哪怕量纲对了,模型也很难拟合出正确关系。数据预处理的本质,就是不断消除这些“模型假设”和“现实数据”之间的错位。
1.2 预处理模块的“家族图谱”
sklearn里的数据预处理能力并不是全集中在一个类里,而是散布在好几个子模块中。我一开始就是没摸清这张地图,经常一个功能找半天。按用途拆开来看,这几个模块是主力:
| 模块 | 典型类/函数 | 解决的问题 |
|---|---|---|
| sklearn.impute | SimpleImputer, MissingIndicator | 缺失值填充与缺失标志 |
| sklearn.preprocessing | StandardScaler, MinMaxScaler, OneHotEncoder, OrdinalEncoder, FunctionTransformer | 数值缩放、编码、自定义变换 |
| sklearn.compose | ColumnTransformer | 对不同列分别做不同预处理 |
| sklearn.pipeline | Pipeline, make_pipeline | 把多个步骤串成流水线 |
| sklearn.model_selection | train_test_split | 划分训练集与测试集 |
| sklearn.feature_selection | SelectKBest等 | 预处理后的特征筛选 |
这个家族视图很重要,因为预处理不是一步操作,而是一条链:缺失值清洗应该在缩放之前,类别编码通常和数值缩放并行,最后统一交给下游模型。不同模块各管一段,Pipeline负责把它们粘起来。
还有两个经常和预处理混在一起的动作,需要单独拎出来认清:特征选择(sklearn.feature_selection)是减少特征数量,属于“筛”;特征提取(如PCA)是做降维压缩,属于“并”。筛和并可能在预处理前后反复迭代,但和“清洗+编码+缩放”不是一个环节,别混在一个步骤里写,不然调试的时候分不清问题出在哪。
1.3 一个贯穿全篇的实操数据
为了不让你看代码看得很飘,我这一整篇统一用一个虚构的房屋成交记录数据集来演示,数据规模不大但品种齐全:
面积:连续数值,少量缺失房龄:连续数值,极个别年份异常装修等级:字符串类别,取值是“毛坯、简装、精装”,存在自然顺序朝向:字符串类别,取值是“朝南、朝北、东西”,无内在顺序所在楼层:连续数值,范围1到50成交价格:标签列,用来做纵向验证
这个数据集很典型:有数值、有有序类别、有无序类别,又有缺失和噪声,把预处理的常见场景全部覆盖了。后面每个环节我都会用这几列来讲,你完全可以照着代码在自己机器上复现。下面进入正题,先处理最基础的缺失值和脏数值。
2. 数据清洗:缺失值怎么填,异常值怎么办
2.1 缺失值第一课:先弄清楚数据是怎么丢的
很多人拿到NaN就机械地填中位数,这种做法不是不对,而是没有问最关键的“为什么缺失”。统计上会把缺失机制分成几类:完全随机缺失指缺失和任何变量都无关,比如录入员手滑漏填了一个面积;随机缺失指缺失跟其他已观测变量有关,比如高楼层数据更容易缺失;非随机缺失则指缺失本身跟缺失值有关,比如贵的房子面积故意不填全。
三种场景下,填充策略的含义完全不同。完全随机缺失用均值、中位数这类“无信息填充”是安全的;随机缺失如果只用列内统计忽略其他列,会丢失一些可推测的模式;非随机缺失则更麻烦,填一个普通值进去反而可能系统性地扭曲分布。
实操上我不会为了判断缺失机制做一堆统计检验,但通常做两件很朴素的事:先按列统计缺失率,缺失超过30%的特征要怀疑其保留价值;再对比“缺失样本”和“非缺失样本”在标签上的分布,如果差异明显,说明缺失本身可能带着信息。这个信息很多时候比猜测填充还要有价值。
2.2 SimpleImputer用起来,以及四种策略怎么选
sklearn里面对缺失值,主力工具是SimpleImputer。它默认对每列单独做统计填充,核心就一个strategy参数:
from sklearn.impute import SimpleImputer # 中位数填充,适合面积这类可能有偏的数值特征 imp_median = SimpleImputer(strategy='median') X_area_filled = imp_median.fit_transform(X_train[['面积']]) # 若是类别列,'most_frequent'是最常用的 imp_mode = SimpleImputer(strategy='most_frequent') X_decor_filled = imp_mode.fit_transform(X_train[['装修等级']])四种strategy的使用场景,我在实战里总结成一个口诀:
mean:数据近似正态、无明显异常值的时候用。均值受极端值影响大,一个离谱的500平别墅能直接把平均值拉高。median:数据偏态或存在异常值时优先选。价格、面积这类取值永远为正、大值拖尾明显,中位数远比均值稳健。most_frequent:类别型特征专用,填众数。注意它不分顺序,对有序类别也只是填个最多出现的值。constant:有领域知识时使用。比如“收入未填写”可以填-1然后配合缺失指示器,把“故意不填”作为一个有业务含义的信号。
还有一个很容易被忽略的参数add_indicator。设成True时,SimpleImputer会额外生成一列“是否缺失”的0/1标志。这个参数在风控、营销场景非常有价值:用户没填收入这件事,本身可能就是风险特征。缺失率不高但业务上敏感时,宁可保留标志也不让缺失信息悄悄消失。
2.3 缺失值填充的“先后顺序”陷阱
SimpleImputer虽然好用,但它有个很实际的问题:它对每列单独做统计,完全不看其他列。假如面积和楼层高度相关,面积缺失但楼层没缺失,直接用列中位数填充,就会丢掉这种列间关系。
这时候更工程化的做法是先用规则把可推断的缺失补上,再交给SimpleImputer兜底。我处理表格数据时,通常先在数据层做一轮业务规则填充:比如“装修等级为毛坯但成交价填写异常”这类多列联动业务校验,先把能确定的补掉,剩下的真正的“随机洞”才交给统计填充。
还有两个细节经常让人翻车。第一,缺失值不一定叫NaN。很多数据导出工具会把缺失写成空字符串、-999、0或者“Unknown”。SimpleImputer默认只认np.nan,如果是空字符串,你需要先把数据统一处理成NaN,否则字符串列会直接把类型搞乱。第二,任何填充都必须在训练集上先fit。测试集的缺失值要用训练集学到的统计量去填,绝对不能拿测试集自己的均值、中位数来填,不然就是数据泄露,后面会专门展开。
2.4 异常值:要不要删,怎么处理更稳妥
异常值这个问题比缺失值还让人纠结,因为“异常”的定义本身有主观性。我见过太多人在建模前用3倍标准差框掉一堆点,结果模型上线后泛化很差——因为现实世界本来就存在合理的极端值,别墅不是异常,它是样本的真实分布。
我现在的处理习惯是分场景看模型。树模型对单个异常点基本免疫,阈值切分决定了它不会被一个500平的点拉着走,这种情况异常值可以先不管。线性模型、KNN、神经网络这类受距离或梯度影响的模型,异常值会显著拉偏结果。这时候先不急着删,几个更稳的路径是:用RobustScaler这类对异常不敏感的缩放器、对偏态特征做log变换压缩长尾、用分位数截断而不是硬性删除。
需要快速看异常情况时,我经常会用IQR规则做一次扫描,但不把它当自动删除依据:
import numpy as np def outlier_flags(col): q1, q3 = np.percentile(col, [25, 75]) iqr = q3 - q1 lower = q1 - 1.5 * iqr upper = q3 + 1.5 * iqr return (col < lower) | (col > upper) # 房龄列里出现负数,大概率是录入错误 age_flags = outlier_flags(df['房龄'])跑完这个函数,重点看两类结果:一类是“明显不可能”的值,比如房龄-5,这类属于错误数据,直接修正或剔除;另一类是“数值很大但业务上可能”的值,比如500平豪宅,这类属于分布长尾,保留并在缩放环节处理。把“错误”和“极端”分开对待,比一刀切删点安全得多。
3. 数值特征:缩放、变换与空间扩展
3.1 四款缩放器怎么选,对比讲透
清洗完缺失值和脏数值,下一步就是统一量纲。sklearn里最常用的四款缩放器,很多人只知道用StandardScaler,但选型差了会直接影响模型表现。我把它们的原理和适用场景对列放在一起:
| 缩放器 | 数学逻辑 | 输出范围 | 适用场景 | 对异常值敏感度 |
|---|---|---|---|---|
| StandardScaler | z=(x-μ)/σ | 大致[-3,3],但不限 | 线性模型、逻辑回归、PCA,特征近似正态时 | 敏感 |
| MinMaxScaler | (x-min)/(max-min) | [0,1](可自定义范围) | 卷积输入、有明确边界的特征,如像素 | 敏感 |
| RobustScaler | (x-median)/IQR | 大致[-1.5,1.5]之外仍有值 | 数据含较多异常值、分布偏态 | 不敏感 |
| MaxAbsScaler | x / max( | x | ) | [-1,1] |
StandardScaler是默认好马,但只有在特征分布不是极端偏态的时候才适合。它做的事情是把数据变成均值为0、方差为1的分布,公式就是z = (x - μ) / σ。凡是用距离、梯度、正则化惩罚的模型,标准化之后各特征对目标的影响才能在同一个尺度上比较。
MinMaxScaler则适合你知道特征有天然边界的场景。比如图像像素本来就是0到255,缩放到0到1很自然。但要注意它会被一个极端值支配:某一列一个超大值,整列都会被压成紧贴0到1的小片区域。而且训练集里的max在测试集不一定同样是最大值,新数据进来完全可能超出边界,这在线上预测时需要小心。
RobustScaler是我处理含噪声数据时的首选。它用中位数和四分位距代替均值和标准差,极端值对它的影响非常有限。房价Demo里如果面积存在别墅这种长尾,我一般直接用RobustScaler,比先删点再标准化省事且安全。
3.2 训练集与测试集:fit和transform必须分开
缩放器使用中最大的坑,我看大多数人第一年都会踩:对测试集也调用了fit_transform,导致训练和测试各自被标准化到不同的空间。正确写法是训练集上fit,然后只transform训练集和测试集:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train[['面积', '房龄', '所在楼层']]) X_test_scaled = scaler.transform(X_test[['面积', '房龄', '所在楼层']])为什么要这样?标准化用的μ和σ应该是训练集样本的统计量,代表模型训练时看到的数据分布。如果测试集也单独fit,测试集的μ和σ会偷偷掺进来。更直白一点,模型在训练时根本不该“听说”测试集长什么样,哪怕只是均值。对树模型、朴素贝叶斯这类不做缩放的模型,这个坑暂时无关痛痒,但对线性模型、SVM、神经网络,不正确的fit/transform分配会在线下评估时给你一个虚高的分数。
顺带提一个自查方法:训练时保存的scaler对象,后续预测时一定原样拿过来transform。如果哪天发现上线代码里又重新用数据fit了一下scaler,先停下来检查数据泄露。
3.3 模型学不会非线性?多项式特征和分箱来帮忙
缩放解决统一量纲,但很多场景下模型还缺少“表达非线性”的能力。线性回归天然只能学一条直线,可面积和价格的关系哪儿是直线,往往是边际递减。两种情况可以快速给线性模型“加戏”:PolynomialFeatures和分箱。
PolynomialFeatures会生成原始特征的高次项和交叉项,比如面积、房龄两列在degree=2时,会额外生成面积^2、房龄^2、面积×房龄:
from sklearn.preprocessing import PolynomialFeatures poly = PolynomialFeatures(degree=2, include_bias=False) X_train_poly = poly.fit_transform(X_train[['面积', '房龄']])加了高次项后,线性模型就能拟合出弯曲的关系。但这里面有个非常实在的提醒:degree不要无脑调高。3次、4次之后特征数量爆炸式增长,很容易过拟合,而且交叉项非常难解释。我的习惯是degree最多2,且只在特征数量少的时候用。
分箱则是另一种思路,用KBinsDiscretizer把连续变量切成几段,每段作为一个类别输入。这种操作对逻辑回归这类模型特别友好,本质上是让模型在段内学一个常数效应。比如把面积分成80-100、100-120、120-150几档,业务解释也顺了:“120平到150平的房子,每平单价溢价明显”。分箱后输出可以是数值编码也可以继续OneHot,配合后续Pipeline用起来很顺手。
4. 类别特征:从序号编码到一键编码
4.1 第一个大坑:别拿LabelEncoder编码特征
这里必须讲一个很多人都会踩的经典坑。网上很多老教程教人处理字符串列时,随手写一个循环:
from sklearn.preprocessing import LabelEncoder # 危险示例:用LabelEncoder处理特征列 for col in ['装修等级', '朝向']: le = LabelEncoder() df[col] = le.fit_transform(df[col])这段代码能跑通,但隐患很大。LabelEncoder的设计初衷是编码标签y的,它生成的整数编码和模型无关,完全按字符串的排序规则给编号。比如“毛坯、精装、简装”三个词,如果按字母序排,顺序可能变成“简装(0)、毛坯(1)、精装(2)”,这跟业务上的“低中高”根本不匹配。大多数模型(尤其树模型和线性模型)会把0、1、2当成有大小关系的连续值,于是模型学到了一条完全虚假的排序关系。
很多人跑完发现特征重要性里“装修等级”异常重要,结果却是这个虚假顺序在捣鬼。不要问我是怎么知道的。类别特征的标准做法是两个正规军:有序类别用OrdinalEncoder,无序类别用OneHotEncoder。
4.2 OrdinalEncoder和OneHotEncoder分工明确
OrdinalEncoder适用于类别本身有内在顺序的场景。比如“装修等级”天然有毛坯 → 简装 → 精装的递进关系,给它0、1、2编码就没问题,因为顺序感是真实存在的。但要注意,传categories参数显式指定顺序,别放着不管:
from sklearn.preprocessing import OrdinalEncoder ord_enc = OrdinalEncoder(categories=[['毛坯', '简装', '精装']]) X_train_decor = ord_enc.fit_transform(X_train[['装修等级']])只要你不传categories,sklearn就会按字符串字典序排,那个灾难和LabelEncoder是一样的。所以凡是有业务顺序的类别,显式写清楚categories是必须动作。
OneHotEncoder则适合“朝向”这种互斥并列的类别。它会把“朝南、朝北、东西”三列各自变成0/1,模型不再误判大小关系:
from sklearn.preprocessing import OneHotEncoder one_hot = OneHotEncoder(handle_unknown='ignore') X_train_orient = one_hot.fit_transform(X_train[['朝向']]) X_test_orient = one_hot.transform(X_test[['朝向']])输出是稀疏矩阵,这一点需要心里有数。OneHotEncoding的本质是制造大量0值,稀疏存储能大幅省内存。如果后续要接pandas、要打印特征名,再考虑转成dense或设置set_output(transform='pandas')。
4.3 OneHot的三个参数:drop、handle_unknown和低频合并
OneHotEncoder的默认行为其实有几个粗糙之处,我逐个讲。
drop参数:当某一列的类别本身是二值(如“是否临街”: 是/否)时,OneHot会生成两列,但这两列完全线性相关(是=1-否)。很多线性模型会因此出现共线性问题,权重解释也会乱。设drop='first'或者drop='if_binary'可以去掉多余一列。树模型无所谓,但线性模型强烈建议处理。
handle_unknown:训练集里没有、测试集里突然出现的类别。如果设成默认的error,预测阶段直接崩。设成ignore后,新类别会变成一列全零,模型就当它“不属于任何已知类别”。这个参数我通常总是开着,省得线上被未知值炸断。
再往后,sklearn新版还支持了min_frequency和max_categories。前者会把出现频率低于阈值的类别自动合并成一类,后者限制最终类别总数。这项能力在处理高基数的城市名、设备型号时特别好用,比如几十万个用户ID做OneHot是不可理喻的,低频类并成“其他”是实用策略。我在模糊匹配场景里常用min_frequency=0.01这种量级的参数。
4.4 类别太多又没有业务顺序,怎么办
OneHot的高基数问题值得单独说。比如数据里有个“板块”特征,有几百个选项,OneHot之后直接给你几百维,样本量不够时模型几乎必炸。这种场景我推荐两条路:
第一条是用低频合并,前面说的min_frequency就是一种自动化做法。第二条是目标编码,把每个类别的标签均值作为该类的编码特征。但目标编码有个著名陷阱:直接全局计算均值会把标签信息泄露进特征,必须放在交叉验证折内去算。所以会复杂很多,一般等模型验证阶段再上。预处理阶段我的铁律是:高基数类别先用低频合并顶着,不擅自引入目标编码。
5. 让预处理进入工程化:Pipeline与ColumnTransformer
5.1 没有Pipeline之前,我的代码有多乱
如果你靠手写变量保存每个scaler和encoder,代码大概会长这样:先定义几个对象,fit各自列,然后一个个transform拼接。中间一旦增加一个特征或者调整一个参数,就要重新同步训练和预测两端。而一旦漏了一列没缩放,模型直接静默变差,你还要花很久查为什么分数不对。
所以我强烈建议从第一天开始就用Pipeline来组织预处理。Pipeline的核心思想很简单:把一串fit和transform步骤打包成一个对象,训练时整体fit,预测时整体transform。你不再需要各自保存五个变量,只需要保存一个流水线对象,测试和上线的变换逻辑自然不会错位。
5.2 一个直接能跑的Pipeline示例
拿只含数值列的子集举例。这组代码把缺失填充、标准化、线性回归串成一条链:
from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression pipe = Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('scaler', StandardScaler()), ('regressor', LinearRegression()) ]) pipe.fit(X_train[['面积', '房龄']], y_train) pred = pipe.predict(X_test[['面积', '房龄']])Pipeline有个内在规则:除了最后一步,中间步骤必须有transform方法;最后一步只需要fit和predict。训练时,它会自动把前一个步骤的输出喂给下一个步骤;预测时,自动执行除了最后一步之外的所有变换。这个机制就是它不串味、不遗漏的根因。
这里还有个新版本的福利:Pipeline可以将每一步的输出设置成DataFrame,而不是只会出现数组。只要你前一个步骤支持set_output,最后预测结果你就有了特征名。不过旧版本上这个能力还在演进,依赖版本较低时不要强求。
5.3 ColumnTransformer:不同类型列各自处理的正确姿势
真实数据通常是数值列和类别列混杂。ColumnTransformer做的事情很简单:指定哪些列走数值处理通道、哪些列走类别处理通道,最后把结果并排拼接。
from sklearn.compose import ColumnTransformer preprocess = ColumnTransformer([ ('num', Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('scaler', RobustScaler()) ]), ['面积', '房龄', '所在楼层']), ('cat', OneHotEncoder(handle_unknown='ignore'), ['朝向']) ]) X_train_processed = preprocess.fit_transform(X_train) X_test_processed = preprocess.transform(X_test)这样一串写下来,每条通道的逻辑一目了然:数值列走填充+抗异常缩放,类别列走OneHot。后面如果加了新列,只需要修改列名列表,不需要改任何业务代码之外的东西。我建议把所有特征都在ColumnTransformer里统一定义,像“户口本”一样把每个特征的预处理路径写明,管理大型项目时极度好用。
把ColumnTransformer和Pipeline叠起来,就是最终形态:
full_pipe = Pipeline([ ('preprocess', preprocess), ('regressor', LinearRegression()) ]) full_pipe.fit(X_train, y_train) pred = full_pipe.predict(X_test)这时候你手里只有两个对象:一个预处理配置和一个模型。这就是我在不同项目间复用的时候最喜欢的样子。复制项目后只需要改动ColumnTransformer的列名和Pipeline的模型对象,其余逻辑全部原样继承。
5.4 其实还有更快的方法:make_pipeline和make_column_transformer
上面用的Pipeline([...])和ColumnTransformer([...])是比较显式的写法。sklearn还提供了两个快捷函数make_pipeline和make_column_transformer,它们会根据传入对象自动起名字。比如make_pipeline(SimpleImputer(), StandardScaler(), LinearRegression())就能生成同等的流水线,省去手动命名步骤名的环节。
日常脚本里我倾向于用这套简写,但进入需要长期维护的代码库时,我建议回到显式Pipeline写法。原因很简单:显式命名后,你想单独检查中间步骤的输出(比如调出某一步的对象)会方便得多,而make_这类写法虽然省事,却让中间步骤不好直接取用。
6. 常见问题与排查记录
6.1 数据泄露:最隐蔽也是最容易犯的错
这是预处理里危害最大、最难被发现的坑,必须单独讲。典型错误顺序是把scaler.fit_transform接在train_test_split之前,或者对全量数据做OneHot的fit。这么做的结果就是,测试集的统计信息悄悄流进训练阶段。
我举个直观例子:假设你整个数据集有1000套房,均价300万,你在划分前就fit标准化,用的是1000套房的均值和方差;但实际上模型只能看到700套训练样本,测试集300套房的信息被泄露了。线下验证分数比真实泛化成绩更高,上线后绩效崩掉,就是这类问题的经典表现。
正确的顺序永远是谁训练谁fit。train_test_split先执行,scaler只在训练子集上fit,然后transform测试子集。这个原则虽然简单,却值得你每次写代码前默念一遍。按这个思路,前面代码里的所有fit对象都必须在训练集上,ColumnTransformer、Pipeline的整体fit也同理。
6.2 版本差异带来的“隐性”崩溃
我踩过最莫名其妙的一个坑,是换环境后OneHotEncoder的handle_unknown变成了报错模式。旧版本里默认是error,如果测试集出现了训练集没见过的类别,预测阶段会直接崩掉。解决方案很无脑但有效:建立一个预处理“标准配置”文件,把OneHotEncoder(handle_unknown='ignore')、OrdinalEncoder的categories等敏感参数固定下来,不依赖默认值。
特别是多人协作的项目里,默认值是最不可信的东西。线上环境和你本地环境版本差几个小版本,行为就可能不一样。把关键参数的显式声明写清楚,比指望队友自觉靠谱得多。
6.3 稀疏矩阵和特征名对不上怎么办
ColumnTransformer和OneHotEncoder输出的稀疏矩阵非常省内存,但在某些场景会带来麻烦。比如你要看特征重要性,却发现特征名字只剩“列0、列1”,或者直接用pandas DataFrame去切列时类型对不上。
新版sklearn的解法是set_output。在ColumnTransformer之前调用set_output(transform="pandas"),输出就变成DataFrame,特征名也会自动拼接出每列的含义。比如朝向_朝南、朝向_朝北这种名字就出来了,后面做特征筛选、结果解释就顺很多:
preprocess.set_output(transform="pandas")有一点要注意,set_output在proprocessor和model两端都要考虑,如果管道里的模型接受稀疏输入但你设成pandas,反而可能影响性能。实操上我一般只在调试和交付报告中开启DataFrame输出,训练阶段保持紧凑形态。
6.4 自定义变换:FunctionTransformer的正确用法
Preprocessing库里肯定覆盖不了所有奇奇怪怪的清洗逻辑,比如日期转星期几、对数值取log、文本去空格。这种时候很多人会回到pandas层面“手工操作”,但手工操作又会破坏Pipeline的完整性。sklearn给了个FunctionTransformer,能把自定义函数包成sklearn风格的变换器,顺利接进Pipeline:
from sklearn.preprocessing import FunctionTransformer import numpy as np def log1p_transform(x): return np.log1p(x) log_transformer = FunctionTransformer(log1p_transform) # 后续可以放进Pipeline的某一步自定义函数带进Pipeline有个细节值得说:如果函数只支持一维数组,而sklearn内部传递的是二维数组,很容易把shape搞崩。稳妥做法是直接在函数里加一个reshape或确保它是向量化操作,这样不依赖外部状态,放进Pipeline也不会跟全局变量起冲突。
把这个专题串起来:我实际跑通的一条完整流水线
最后我想完整给出一条我实际用于房价Demo的预处理流水线,它是前面所有讨论的一个综合用例。数据列不复杂,但预处理逻辑完整覆盖了缺失、缩放、编码和模型组合:
from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import RobustScaler, OneHotEncoder from sklearn.ensemble import RandomForestRegressor preprocess = ColumnTransformer([ ('num', Pipeline([ ('imputer', SimpleImputer(strategy='median')), ('scaler', RobustScaler()) ]), ['面积', '房龄', '所在楼层']), ('cat', OneHotEncoder(handle_unknown='ignore'), ['朝向']), ('decor', OrdinalEncoder(categories=[['毛坯', '简装', '精装']]), ['装修等级']) ]) model = Pipeline([ ('preprocess', preprocess), ('regressor', RandomForestRegressor(n_estimators=100, random_state=42)) ]) model.fit(X_train, y_train) print(model.score(X_test, y_test))这条流水线里我特意用了RobustScaler而不是StandardScaler,就是因为房龄和面积里都有长尾;朝向走OneHot,装修等级走OrdinalEncoder且顺序显式指定。整个流程在同一个对象里完成,训练和预测完全一致。你可以把这条代码当模板,替换成自己的列名和数据,基本可以立马上手。
预处理看起来是“脏活累活”,但它直接决定模型能爬多高。我自己在这个环节上花的时间,往往比建模调参还多,而且回报率极高。很多人抱怨同一批数据,为什么别人跑出来的效果就是好一些,差别可能不在模型,就在这些预处理细节上:缺失值是不是真按业务逻辑处理的、缩放器选对了没、编码有没有引入虚假顺序、测试集有没有被提前偷看。把这几个问题一次理清,比调两轮超参数实在得多。