ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Kaggle房价预测:数据预处理才是决胜关键

Kaggle房价预测:数据预处理才是决胜关键 做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(ascendingFalse) 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 Applicablefor 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_firstTrue打开去除一个参考类别避免完美多重共线性。这个操作对线性模型尤其重要不然正则化系数会被共线特征拉偏。不过在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], axis0) df_all pd.get_dummies(df_all, drop_firstTrue) 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(columnstrain_transformed.columns, fill_value0)这行代码的含义是以训练集的列名为准测试集有这些列就保留没有的列填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变换后如果原数据里有0log0会得到负无穷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。我个人在实际操作中最深刻的体会是预处理不是在比赛开头做一次就结束的它是一个和建模循环迭代的过程。你上一轮模型跑出来发现某个特征重要性很高下一轮就会去为它设计更细致的处理你发现某个缺失字段被简单填充后模型依然很敏感就会回来研究更合理的分组填补。不要把数据预处理当成一个一次性关卡它是整个比赛过程中陪你到最后的核心环节。这个比赛的房价数据我前后跑了五个版本每一版本的预处理都在变化分数也随之逐步提升这才是这类比赛真正的魅力所在。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表