做Kaggle房价预测比赛,我第一次提交的成绩惨不忍睹,排名直接掉到中下游。当时我以为问题出在模型选型上,换了随机森林又换XGBoost,分数就是纹丝不动。后来我才意识到,真正拖后腿的压根不是模型,而是数据本身——该处理的缺失值没处理,偏态分布没校正,类别特征一股脑当哑变量丢进去,测试集和训练集的特征口径还对不上。从那次之后,我每次碰这类型的比赛都先把八成精力砸在数据预处理上,效果立竿见影。
这篇博文就完整复盘我在这个经典房价预测比赛里的数据预处理全过程,从缺失值取舍、偏态分布校正、类别编码、新特征构建,到训练集测试集同步清洗的管道搭建,都会讲清楚为什么要这么做、踩过哪些坑、怎么做最稳。适合刚入门机器学习、想在Kaggle上拿第一个奖牌或至少把分数刷进前20%的读者,也适合那些已经能跑通baseline但觉得分数卡住上不去的同学。
1. 预处理前,先搞懂比赛在打什么
1.1 比赛任务与数据集长什么样
这个比赛的任务本身很简单:给定一批房屋的几十个特征,比如占地面积、建造年份、地下室面积、厨房质量、外部材料等等,预测房屋的最终售价。训练集有1500条左右带标签样本,测试集有差不多同样数量的无标签样本,需要提交预测出的房价。标签列是SalePrice,也就是成交价格,这列只在训练集里存在。
数据集的构成我习惯一上来就看三件事:行数列数、特征类型分布、缺失值情况。用pandas读进来之后,我通常先跑一遍train.info()和train.describe(),快速扫一眼哪些字段是object类型、哪些是数值型、有没有明显的缺失。很多新手拿到数据直接就开始填缺失值,其实是不对的,数据长什么样、特征代表什么意思都没搞清楚,填缺失值就是瞎填。
我当时做了一件事:打印出所有特征的名称列表,逐个去理解这个字段是什么意思。这不光是比赛的习惯,也是处理真实业务数据的基本功。比如LotArea是地块面积,GrLivArea是地面上的居住面积,TotalBsmtSF是地下室总面积,YearBuilt是建造年份,OverallQual是整体材料和质量评分。对这些字段要有概念,后面做特征工程才能想出合理的组合特征。
1.2 为什么数据预处理是决定排名的胜负手
Kaggle比赛里很多新手会有一种错觉:模型越复杂分数越高。实际上在这类结构化表格数据比赛里,模型能力的差距远小于数据质量的差距。你用一个调参得当的线性回归,只要特征工程和预处理做到位,完全可能打败一个直接扔原始数据的高级集成模型。
原因很简单。树模型(比如XGBoost、LightGBM)确实能容忍一些缺失值和异常值,但它们对特征表达非常敏感。偏态分布的特征如果直接参与分裂,树模型往往会在高值区间反复切分来拟合尾部,浪费了大量分裂深度。而线性模型更直接,偏态特征和离群点会让损失函数被极端样本牵着鼻子走。更关键的是,测试集和训练集之间的特征一致性,一旦预处理方式不对称,线上成绩和线下验证成绩就会产生巨大偏差,让人完全失去调优方向。
所以我把预处理当成一个独立的工程阶段来看待,它至少包含四条主线:缺失值处理、偏态与数值畸变校正、类别特征编码、新特征构造。再加上最后一层对训练集测试集口径统一的管控。这篇文章剩余部分就按这条线逐个展开。
2. 第一眼就要发现的问题:偏态分布与数值特征畸变
2.1 房价和面积不是正态的,先做log变换
拿到含标签的训练集后,我第一件要做的事不是填缺失值,而是看SalePrice的分布。这个习惯是从一次惨痛教训里学来的,我在第一个版本里完全没关心标签分布,直接用原始房价做回归,结果模型对高价房完全预测不准。后来把标签取了对数再用,RMSE肉眼可见地往下掉。
为什么会这样?房价数据几乎总是右偏的,大部分房子价格集中在中等偏低区间,少数豪宅在右端拖出一条长尾。直接回归这条长尾,模型梯度会被那些高价样本主导,反复调整参数去迎合少数豪宅,普通房子的拟合精度就被牺牲了。对标签做log1p变换后,长尾被压缩,分布更接近对称的钟形,回归问题在数值上更好优化,误差度量也稳定得多。
代码上只需要两行:
import numpy as np train['SalePrice'] = np.log1p(train['SalePrice'])注意这里用的是log1p而不是log,因为房价本身没有0这种极端情况,但log1p在输入为0时不会报错,更安全,后面某些组合特征(比如总面积)也可能出现0值,统一用log1p能省掉不少麻烦。
同理,很多数值特征也是右偏的,比如GrLivArea、LotArea、TotalBsmtSF。在建模之前我会对这些特征也做log变换,让它们的量纲和分布形态更适合线性模型。需要提醒的是,不要机械地对所有数值特征做变换,先画直方图或者用.skew()算一下偏度,大于0.75的才考虑log变换。
2.2 数值特征要不要标准化、什么时候不做
偏态校正之后,另一个常见问题是标准化。StandardScaler会先把均值归零方差归一,这个操作对线性回归、岭回归、神经网络这类依赖特征量纲的模型几乎是必需的。但对树模型来说,标准化并不影响分裂点的选择,因为树模型只看排序关系,不信均值方差,所以很多参赛者拿到树模型直接不标准化,完全没问题。
在房价比赛里,我的习惯是分两条线走:如果最终上线的是LightGBM或XGBoost这类树模型,不需要StandardScaler,但要保证缺失值处理干净;如果我想同时跑岭回归或者弹性网络做模型融合,那数值特征一定得缩放。特征融合是我后来分数大幅提升的关键,让树模型和线性模型各自看到不同的特征尺度,再到上层做加权平均,比单一模型稳得多。
具体操作上,标准化必须遵循“拟合训练集,变换测试集”的原则:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_num_scaled = scaler.fit_transform(X_train_num) X_test_num_scaled = scaler.transform(X_test_num)如果对测试集也调用fit_transform,等于让测试集参与计算均值和方差,这在严格意义上是信息泄漏。虽然在一个比赛里对最终排名影响未必那么致命,但它会让你的验证方法失去校准意义,这是大忌。
3. 缺失值处理:取舍比填补更重要
3.1 先给缺失值分类:该删的删,该补的补
缺失值处理的第一步不是补,而是分类。我一般把缺失值分成三种情况。第一种是“缺失即无”。比如PoolQC(泳池质量)、MiscFeature(其他设施特征),大量房子根本没有泳池,没有这些设施,字段自然为空,这种空值不代表数据缺失,而是代表“无”这个状态。第二种是“缺失需合理推算”。比如LotFrontage(临街面宽度),有些房子没记录这个值,但同街道或同街区的其他房子有,可以用中位数或按街区聚合来推算。第三种是“缺失无意义且占比过高”。比如某个特征有超过70%的空值,而且和房价关联性本身就很弱,这种我倾向于直接删掉,减少噪声。
我当时的做法是先跑一行代码看缺失率排名:
train_missing = train.isnull().mean().sort_values(ascending=False) train_missing[train_missing > 0]结果一目了然,排在前面的是PoolQC、MiscFeature、Alley、Fence、FireplaceQu这类典型“缺失即无”特征。这些我不会直接删,因为“有没有泳池”“有没有围栏”本身就是影响房价的因素,我会填一个None的标签,让它作为一个有意义的类别特征参与建模。但如果缺失率超过70-80%且含义模糊,比如某些年份记录不全的字段,我会考虑舍弃。
3.2 数值列缺失的填补策略:中位数、按组聚合、边界值
数值特征的缺失值填补,我踩过一个特别典型的坑:用均值填LotFrontage。LotFrontage里面有一些特别宽的临街地块,均值被这些异常值拉高了,导致所有缺失样本都被填成一个偏大的值。后来改成按Neighborhood分组取中位数,分数明显提升。
为什么中位数比均值稳?中位数只关心排序位置,不受极端大值影响。当特征分布是右偏的,均值会被长尾拉走,中位数更能代表典型水平。
GarageYrBlt(车库建造年份)是另一个典型例子。一个房子如果没有车库,这个字段肯定是空,如果直接填充年份中位数,就等于给无车库的房子编了一个车库建造年份,模型学到的信息就是错的。正确的做法是先看GarageArea或GarageCars,如果是0就说明没有车库,那GarageYrBlt应该填0而不是中位数:
df.loc[df['GarageArea'] == 0, 'GarageYrBlt'] = 0 df['GarageYrBlt'] = df['GarageYrBlt'].fillna(0)这里把“无车库”映射成0,既保留了缺失的含义,又用数值0表达了“没有”,和实际有车库的年份数值天然区分开。
类似的策略用在有明确边界含义的字段上,比盲目用均值中位数靠谱得多。核心思路就是先想清楚这个字段空着到底是什么意思,再决定填什么。
3.3 分类特征缺失:填“None”比填众数靠谱
分类特征缺失是我最早犯的错。比如FireplaceQu(壁炉质量),我一开始直接用众数填,结果就是大多数样本都变成了同一个质量的壁炉,模型完全分不清哪些房子根本没有壁炉。后来才明白,FireplaceQu为空实际上表示“无壁炉”,无壁炉和有差壁炉、有好壁炉是完全不同的三个状态。
处理方式很简单,所有“缺失即无”的分类特征,统一填一个标记值None或Not Applicable:
for col in ['PoolQC', 'MiscFeature', 'Alley', 'Fence', 'FireplaceQu']: df[col] = df[col].fillna('None')这样做的意义在编码阶段会体现出来。如果后续做One-Hot编码,None会单独成为一个哑变量,模型可以学习到“没有这个设施”对房价的影响。如果你用众数去填,这个信息就永远丢失了,填充出来的值还会误导模型。
另外还有一类分类特征缺失是真正的记录缺失,跟语义无关,比如MSZoning偶尔缺几条。这种占比很小,我才会考虑用众数填充,因为对整体分布影响微乎其微。所以分类特征的缺失处理也分情况,核心判断标准还是“缺失是否代表状态”。
4. 特征工程:构建新特征与类别编码
4.1 组合特征:一列顶十列
我把缺失值处理完了之后,开始做特征工程。组合特征这件事看着简单,但实际上需要你对业务有理解。房价预测这个比赛里有一些很有名的组合特征,比如总面积、总卫生间数、整体质量分,这些特征单独看可能和房价有相关性,但它们组合起来的意义更大。
我最常用的几个组合特征:
df['TotalSF'] = df['TotalBsmtSF'] + df['1stFlrSF'] + df['2ndFlrSF'] df['TotalBath'] = df['FullBath'] + 0.5 * df['HalfBath'] + df['BsmtFullBath'] + 0.5 * df['BsmtHalfBath'] df['Age'] = df['YrSold'] - df['YearBuilt'] df['OverallGrade'] = df['OverallQual'] * df['OverallCond']为什么这些有效?拿TotalSF来说,原始数据把总面积拆成了地下室、一楼、二楼三列,线性模型要自己学习三个系数来组合出总面积,如果三列之间还有缺失值和噪声,模型学起来很容易走偏。我直接给它一个总面积的精确值,相当于把业务常识灌进了模型。OverallGrade同理,OverallQual是材料质量,OverallCond是房屋状况,质量高但状况差的房子不一定值钱,两者相乘才是一个更有区分度的综合指标。
做组合特征的时候有一个细节:对偏态明显的组合特征也要记得做log变换,比如TotalSF大概率是右偏的,用np.log1p处理一下对线性模型很有帮助。树模型对这种变换不敏感,但不影响你统一处理,反正结果不会更差。
4.2 类别特征的编码:有序和无序要分开处理
类别编码是很多新手处理得最粗糙的环节,也是我最想强调的地方。数据里有大量object类型的特征,比如ExterQual(外部材料质量)、KitchenQual(厨房质量)、BsmtQual(地下室质量),这些特征的取值是Ex、Gd、TA、Fa、Po,分别代表优秀、良好、典型、差、极差,它们之间是有明确顺序的。
对于这些有序类别,直接用pd.get_dummies会把这五个等级拆成五个哑变量,模型为了表达“Ex大于Gd”这个顺序,必须同时组合两个哑变量,学起来既费参数又容易过拟合。正确做法是手工映射成等差数值:
qual_map = {'Po': 0, 'Fa': 1, 'TA': 2, 'Gd': 3, 'Ex': 4} for col in ['ExterQual', 'KitchenQual', 'BsmtQual', 'HeatingQC']: df[col] = df[col].map(qual_map)映射成0到4的数值之后,模型可以直接学会这个单调关系,效果远好于哑变量。这里要特别提醒:做映射的字典必须包含所有可能出现但当前数据里没出现的等级,否则测试集一旦出现训练集没见过的取值,代码直接报错或者生成NaN,这是后期很烦人的问题。
对于真正没有顺序的类别特征,比如MSZoning(区域划分)、Neighborhood(地理位置)、SaleType(销售类型),默认用One-Hot编码。我用pd.get_dummies的时候会把drop_first=True打开,去除一个参考类别,避免完美多重共线性。这个操作对线性模型尤其重要,不然正则化系数会被共线特征拉偏。不过在pd.get_dummies之后,记得检查一下列数,如果某个类别特征的唯一值特别多,比如街道名有上百种,那就要考虑是不是要合并低频类别,否则维度会爆炸。
5. 训练集测试集同步清洗,少一步就白干
5.1 用一个函数同时处理训练集和测试集
数据预处理最容易翻车的点,不是方法选错,而是训练集和测试集处理不一致。刚开始我自己写代码的时候是先用一串代码处理train,再复制一份改成处理test,看起来省事,实际上隐患巨大。后来我把所有预处理代码全部收敛到一个函数里面,输入一个原始DataFrame,输出一个处理好的DataFrame,训练集和测试集分别调用一次。
这个函数包含的顺序一定是固定的:先处理缺失值,再处理偏态,再映射有序类别,再生成组合特征,最后做One-Hot编码。顺序为什么重要?因为One-Hot编码会把类别特征打散成很多列,如果你在One-Hot之后才做有序类别映射,那些已经展开的哑变量就无法合并回去了。缺失值的填补也是一样,必须在One-Hot之前完成,因为哑变量只产生0和1,你没法对哑变量做中位数填补。
写成函数还有另一个好处:基线模型的完整流程可以被串起来。我在process_data函数之后直接接train_model,这样每次调整一个预处理细节,只要重新跑一遍函数就能看到对分数的真实影响。
5.2 全量对齐:确保训练集和测试集列名完全一致
One-Hot编码有一个非常隐蔽的坑:如果训练集和测试集在某个类别特征上的取值集合不完全相同,就会导致两边编码后列数不一致。比如MSZoning在训练集里有5个取值,在测试集里有一个额外的类别,那么pd.get_dummies会在测试集上多生成一列,之后模型训练和预测就对应不上。
稳住的做法是在两个数据集都处理完之后做一次列对齐。我会先把训练集和测试集concat到一起,统一做One-Hot编码,再拆开:
n_train = train.shape[0] df_all = pd.concat([train, test], axis=0) df_all = pd.get_dummies(df_all, drop_first=True) train_transformed = df_all.iloc[:n_train, :] test_transformed = df_all.iloc[n_train:, :]这样两边编码出来的列一定是一致的,因为是一起编码的,天然对齐。这一招我后来在几乎所有表格类比赛里都用,省掉了无数调试时间。要注意的是,concat之前测试集里要人工加上SalePrice占位列还是不加,其实无所谓,只要后续拆开后单独处理标签就行。
5.3 验证预处理效果的三个信号
预处理做到什么程度算好?我一般盯三个信号。第一个是交叉验证的RMSE分数,每次改动预处理后重新跑一遍五折交叉验证,分数稳定下降说明改动有效。第二个是线上提交的Public Score,但Public Score容易被少数异常样本带偏,我更看重等比赛结束后Private Score的稳定性。第三个是特征重要性分布,训练完树模型后输出feature_importances_,如果某个我自己构造的特征重要性排名很靠前,说明这个特征工程是有用的;如果所有新特征的占比都很低,可能是处理方式出了问题。
这三个信号会联动。曾经有一次我在本地CV分数上看到了微弱提升,但feature_importances_里新特征几乎为0,后来发现是因为新特征和旧特征有高度相关性,树模型随机选了旧特征做分裂,重要性被分散了。这时可以考虑去掉冗余的旧特征,只看新特征所在的那组子集,重新评估。
6. 踩坑实录:常见问题与排查技巧
6.1 测试集出现训练集没见过的类别怎么兜底
这个问题我在早期几乎每一场比赛都会遇到。训练集和测试集都是从一个完整数据集里随机切出来的,按理说类别分布应该接近,但在小样本下某些稀有类别可能只在测试集出现。比如SaleType里的某个冷门交易类型,训练集里从没出现过,pd.get_dummies之后测试集就会多出一列。
我在函数里加了一行保险:
test_transformed = test_transformed.reindex(columns=train_transformed.columns, fill_value=0)这行代码的含义是:以训练集的列名为准,测试集有这些列就保留,没有的列填0。这样即使测试集出现了新类别,它对应的哑变量也会被强制归零,模型不会因为列数不一致而报错。当然这种方法会丢失新类别信息,但新类别本身在训练阶段就没出现过,模型本来就学不到它的规律,归零是最合理的兜底。
6.2 一上来就把缺失行删掉,排名反而掉了
我见过不少选手的习惯是dropna()一条龙,把所有含缺失值的行全删了。这个做法在训练集里可能会删掉30%的样本,然后他们发现验证分数还不错,因为剩下的样本全是质量好的记录。但提交之后线上分数崩了,原因很简单:测试集里那些被删掉类型的房子你完全没有训练样本可以参照,测试集里的缺失值照样存在,可你没法把测试集的行也删了。
正确的理念是能补则不删,缺失率高且语义确实不明的特征再删。前文说的“缺失即无”类特征全部要保留并补充成None状态。数据行尽量全保留,特征列按具体含义决定去留。这个判断比任何算法都重要。
6.3 在CV折内做填补,别把测试数据漏进训练里
另一个隐蔽的数据泄漏出现在交叉验证和缺失值处理的配合上。假设我先对整个训练集做了中位数填补,然后做五折交叉验证,每一折的验证集里被填补的样本其实是由训练部分的统计量生成的,这看起来没毛病,因为我们用的是同一个数据集的信息。但如果我在每一折内部重新计算中位数去填补训练折,再用这个中位数去填验证折,会更严谨。
实际上,很多参赛者在表格比赛里直接全局填补再做CV,也能得到稳定分数,因为单个特征缺失比例不高时对全局统计量的影响很小。但我想要一个更严谨的评估体系时,会用SimpleImputer放进Pipeline里,这样缺失值填补的统计量就是每折训练部分自动计算出来并应用到验证部分的,泄漏风险被降到最低。代码长一点,但心理踏实。
6.4 一张表看清常见问题和处理方向
| 现象 | 原因 | 处理方向 |
|---|---|---|
| 训练CV分数好但线上分数差 | 预处理或特征选择存在泄漏 | 检查是否有全局fit的scaler和填补器 |
| One-Hot后列数不一致 | 类别取值训练集测试集不同 | 合并编码或reindex对齐 |
| 新特征重要性为0 | 特征与旧特征高度共线 | 考虑删旧特征或重新设计组合 |
| 均价模型对高价房预测不准 | 标签和偏态特征未做变换 | 对SalePrice和目标特征做log1p |
| 缺失值删除过多导致样本不足 | 没有区分缺失即无的类型 | 保留缺失标记,填充而非删行 |
7. 一些我在实操中摸索出来的加分小习惯
讲完主流程,我再补几个额外的小习惯,可能不是预处理的核心,但确实帮我在分数上抠出了不少提升空间。
合并低频类别。Categories里如果某个值只出现一两次,One-Hot之后就是一两列稀疏特征,不但没有泛化能力,还容易过拟合。我会把这些低频值统一合并成Other。判断标准以训练集为主,如果一个类别在训练集里出现次数低于总样本数的1%,就合并掉。
偏度校正后记得检查是否有无穷值。log变换后如果原数据里有0,log0会得到负无穷,fillna都救不回来。这也是我坚持用np.log1p而不是np.log的原因,log1p对0值安全。如果你见到某个特征变换后出现一堆奇怪的大负数,优先检查是不是这个原因。
把所有预处理函数和参数写成配置字典。比如每个有序类别对应的映射字典、每个数值特征的填补策略、要删除的高缺失列名单,都集中放在一个config里。这个做法看似多余,但当你需要做模型融合,或者朋友想看你的预处理逻辑时,你会感谢当时的自己。我在第二次跑这个比赛时,就是靠这份配置快速复现了所有版本的结果。
别忘了在预处理完成后做一次可见性检查。打印处理后的数据维度、列数量、有没有NaN残留:
print(train_transformed.shape) print(test_transformed.shape) print(train_transformed.isnull().sum().sum()) print(test_transformed.isnull().sum().sum())NaN残留如果是0,说明预处理没有遗漏;如果还有残留,先停下去补,别急着训练模型。这一步花不了三十秒,但能拦住一晚上的debug。
我个人在实际操作中最深刻的体会是:预处理不是在比赛开头做一次就结束的,它是一个和建模循环迭代的过程。你上一轮模型跑出来发现某个特征重要性很高,下一轮就会去为它设计更细致的处理;你发现某个缺失字段被简单填充后模型依然很敏感,就会回来研究更合理的分组填补。不要把数据预处理当成一个一次性关卡,它是整个比赛过程中陪你到最后的核心环节。这个比赛的房价数据我前后跑了五个版本,每一版本的预处理都在变化,分数也随之逐步提升,这才是这类比赛真正的魅力所在。