☰
房价预测从零到一:评价指标、特征工程与正则化回归完整实战
2026/10/14 14:01:07 网站建设 项目流程

房价预测这个经典赛题,我从零到一跑了很多遍,也算数据竞赛里最经典的回归题目之一。第一次接触的朋友很容易产生一种错觉:这不就是回归吗,随机森林一拍不就完了?结果提交后分数惨不忍睹,甚至不如一个老老实实做特征工程的线性模型。问题往往不在模型不够复杂,而在于评价指标没看透、数据里隐藏的语义没理顺、验证方式自欺欺人。这篇是系列第一篇,我会把从评价指标、数据预处理、交叉验证、第一批特征工程到Ridge/Lasso/ElasticNet这套高级回归模型的完整链路讲一遍,并且解释每一步为什么这么做。适合刚入门表格数据竞赛、或者刷过很多算法但分数一直上不去的朋友。

1. 先看清赛题在问什么:评价指标决定一切

很多人一上来就忙着调模型,我习惯先搞清楚一件事:这个赛题到底用什么指标评价。评价指标决定了你的目标函数怎么设计、数据怎么变换、验证怎么划分。这一节看着基础,却是后面所有操作的前提。

1.1 RMSE的"相对误差"逻辑:为什么目标先做对数变换

绝大多数回归比赛的评价指标写的是RMSE(均方根误差),不是准确率,也不是R²。对模型来说,最小化RMSE和最小化MSE完全等价,因为开根号是单调操作,不会改变最优解。但这两个指标都默认了同一个逻辑:所有样本的误差按绝对金额一视同仁。

房价数据有个特点:便宜的房可能只有五六万美元,贵的能到六七十万。如果直接用原始价格训练,一个20万美元的房子预测误差是5千,它对损失的贡献和一个5万美元房子的5千误差完全一样。这会让模型把大部分容量用在拟合金额大的房子上,因为同样的绝对误差,在贵房子的损失函数里占比并不高;反过来,便宜房子稍微预测偏一点,绝对误差也许不大,但相对误差可能很高,最终榜上的RMSE也会被拉高。

解法很简单,先把房价取对数:y_log = np.log1p(y)。在log空间里,5万和50万的价格差被压缩到可以比较的尺度,模型拟合的是近似相对误差。这也是为什么很多公开方案第一行代码就是np.log1p(train["SalePrice"])。代价是你提交时必须记得做逆变换,也就是np.expm1(model.predict(...))。忘掉这一步,分数会直接崩掉。

新手容易在这里栽一个跟头:模型在log空间预测不会产生负值,但如果你做了一些激进的变换,把极端样本的log值推得很低,逆变换回来可能出现负价格。所以提交前最好检验一遍"预测值是否都在合理范围",这个检查只需要一行代码,却能在最后关头救你一命。

1.2 训练集与测试集的分布差异不能想当然

搞回归的人最容易忽略的一件事:训练集和测试集往往不是完美同分布的。数据切分背后通常有隐藏逻辑,比如按区域、按时间、按房源类型分批采集。你如果假设两边完全一致,很多特征工程的假设会直接失效。

最直接的检查方法,是把训练和测试拼在一起,按你最关心的离散特征(年份、区域编号、房屋类型)做交叉计数。你会发现某些年份在训练集里有一百多条,在测试集里一条都没有;某些区域只在测试集出现。这时候问题就来了:你的One-Hot编码是按训练集的类别生成的,测试集出现新类别时,模型没见过这个编码,只能把它当成全0向量,等于把这一条样本的信息全部丢弃。

处理思路有这么几种:低频类别合并成"其他";对明显与目标相关的稀有类别单独构造指示特征;或者干脆把类别频率本身作为一个计数特征喂给模型。这一阶段先记住一个原则:先确认训练集和测试集的类别覆盖度,再做编码。A同学当时就是没做这一步,跑出了一个漂亮的CV分数,提交后严重下滑,最后排查到是一个只在测试集里大量出现的新区域类别被One-Hot全清零了。这种问题,数据可视化阶段就能发现,根本不需要等提交交学费。

2. 数据预处理顺序:缺失值、异常值、数据类型

预处理是整个流程里最不性感的环节,但也是回报率最高的环节。很多高名次方案并不用特别复杂的模型,赢就赢在数据整理得干净、语义理解得透彻。这一节我把最关键的三个坑拿出来讲透。

2.1 缺失值不能一把梭:不同列的缺失含义完全不同

很多入门教程教你"缺失值用均值填充",但房价数据这么干等于在粗暴抹平信息。原因很简单:缺失本身往往是一个有意义的信号。

比如"车库完工年份"这个字段,缺失的大概率是那些没有车库的房子。用整列均值去填,就相当于给所有没有车库的房子安了一个"车库平均完工年份",这个虚构的数字没有任何物理意义,模型学到的是一条假规律。再比如"地下室面积"缺失,通常也代表没有地下室,而不是随机抽样时恰好漏填了。

所以我的习惯是,在填充之前先整理一张缺失语义表:每一列写清楚缺失率、缺失的可能原因、合理的填充方式。原则是:能归因于"无此属性"的缺失,就补0,同时增加一个指示变量;真正随机缺失的,才考虑用中位数或众数。指示变量就是那一列的缺失标志,取值0/1,让模型自己去学这个"缺失"信号有没有用。这个做法几乎无成本,但对线性模型和树模型都非常友好,因为模型获得了一个额外的决策维度。

同时还要注意,训练集和测试集要拼在一起做缺失值处理。原因有两个:一是填充参数在全样本上估计更稳定;二是保证两边填充策略完全一致,否则训练时学到的分布和推断时遇到的分布就错位了。有朋友图省事分开处理,结果训练集某列中位数是0.3,测试集中位数是0.8,模型上线后表现一塌糊涂,排查半天才发现是这里出了问题。

2.2 特征里的0值比NaN更危险

预处理的时候大家都会盯着NaN,但0值经常被放过。面积类特征尤其明显:没有地下室的房子,地下室面积写的是0;没有车库的房子,车库面积写的是0。这些0和真正的"数值为0"完全是两回事。

危险在于,如果你做组合特征,比如"总面积 × 车库面积",一个0就会把乘积整个清零。一台明明有很好面积数据的好房子,仅仅因为车库面积为0,组合特征就变成0,其他维度的信息全被吞掉了。这个现象我用过一个大白话类比:一锅好汤里掉进一颗老鼠屎,整锅都得倒掉,0值就是那颗老鼠屎。

处理方式是在构造组合特征之前,先针对"0值代表无"的字段做二值指示变量,比如"是否有车库";然后对真正的面积字段,如果0值分布太广,可以在组合时使用类似max(area, 1)的技巧,把0替换为一个不影响数据尺度的占位值。这个细节对后续所有特征交互操作都有影响。我在帮A同学复盘他那份数据时,就发现一个"地面面积×卫生间数量"的组合特征因为0值没清理,把模型整体带歪,好几个区域的预测都系统性偏低。

2.3 数据类型声明是隐形的坑

pandas读进来以后,类型问题会安静地毁掉你的特征工程。最常见的情况有几种:一个本来是类别编号的列,因为存的是"01"这种带前导零的字符串,被读成object,这还算正常;但如果存的是纯数字,比如区域编号1、2、3,它会被读成int,于是模型就把它当成有序连续变量,凭空学出一个"编号越大越贵"的假趋势。

反过来,一些真正的数值列因为存在缺失,会被当作float64。表面上没问题,但如果你后续做数值计算,比如构造多项式特征,这些float列会正常参与,却忽略了它们原本的离散语义。判断标准很简单:问自己一句,这个特征的大小顺序有意义吗?有,就保留为数值;没有,就转成category或处理成序号编码。质量评分这种1到10的有序评分,我保留为数值,并在特征工程里做成平方项,让模型自己学非线性;街道类型这种无序类别,则必须转成category,不能再让它假装是连续变量。

另外一个小技巧:如果某些类别字段训练集和测试集拼接后类别数不一致,我会先用pd.Categorical的categories参数统一类别范围,避免测试集出现新类别导致编码错位。这几乎是我做表格数据必写的一步,看似麻烦,实则能挡住大量奇奇怪怪的运行时错误。

3. 交叉验证框架:能不能信赖你的分数,全看它

这一节我要把它称为整个比赛流程里的地基工程。没有可靠的验证方式,你后面做的每一个特征、调的每一个参数,都可能是在自欺欺人。很多人分数上不去,不是因为模型不行,而是因为验证框架本身在说谎。

3.1 为什么简单随机划分会骗人

很多人拿了数据,用train_test_split一划,训练完看一眼验证分数,觉得自己模型不错,提交之后发现天差地别。原因之一就是单次随机划分的方差很大:换一个随机种子,验证分数可能抖动好几个点,你完全无法判断模型是真的好,还是这次划分运气好。

更隐蔽的问题是空间聚集性。房价数据天然有区域聚集,同一个街区、同一个年份的房源价格高度相似。如果你随机划分恰好把一个片区的房子大部分放进了验证集,而训练集里几乎没有这个片区的样本,那验证集就代表了一个你没见过的分布,分数自然难看;反过来,如果验证集恰好和训练集高度同质,分数又虚高。单次划分全靠运气,你不是在评估模型,你是在掷骰子。

这就像你只抽一次签就想知道自己期末考能排第几名,一次抽中简单的题,分数好看;一次抽中难题,分数难看。可复习方向完全没变,变的只是那把签的心情。所以真正可靠的评估,必须让每一个样本都有机会被验证到。

3.2 10折KFold与分层思路

我自己的默认配置是10折KFold,shuffle=True,随机种子固定(比如42)。为什么是10折而不是5折?训练数据量不是特别大的时候,10折意味着每次验证集只有10%,训练集有90%,模型每次都能在更完整的数据上学习;代价是训练10次,但对于几万行规模的表格数据,这个成本完全可以接受,换来的是更稳定的评估。

固定随机种子这一点特别重要。如果你每次训练都重新划分,模型分数变化里就混入了划分的变化,你没法判断是特征工程带来的提升,还是这次运气好。所以我会把fold索引一次性生成好,存成一个字典或者CSV,所有后续实验都用同一套划分。这样不同模型、不同特征组合之间的分数才具备可比性。这也是我后来做任何表格任务都坚持的流程。

回归任务里还能做一个加强版:StratifiedKFold本来是给分类设计的,但回归也可以用。先把目标值按分位数切成若干个桶,当成类别标签传进去,这样每一折的目标分布都和整体接近。这个操作能明显降低每折之间的波动,尤其适合房价这种右偏严重的目标变量。

3.3 CV分数和LB分数量级差异的来源

很多新手的焦虑都来自CV分数和公共榜单分数对不上。这里要先明白一个道理:公共榜只用了测试集的一小部分,你的CV是在训练集内部做划分,两者天然有偏差。CV稳定且可重复,LB只是抽查。反复看LB调参,等于拿公共榜当验证集用,次数多了就会过拟合到那部分公共测试样本上,这也是公开榜分数和私人榜分数差异巨大的一个重要来源。

我还遇到过一个反向情况:CV分数比LB还高。这通常意味着两种可能:一是训练集里恰好有一些和验证集高度同质的数据段,CV被高估了;二是目标分布在两个数据集里确实存在偏移。这种时候不要急着改模型,先检查数据分布,检查有没有数据泄露——比如某些特征在训练测试之间本不该共享,却因为合并处理被悄悄带过去了。记住一个原则:交叉验证的目的不是逼近LB,而是给你一个稳定、可信、可复现的模型选择依据。CV比LB高不一定是好事,可能只是在重复验证同一种模式。

4. 特征工程第一阶段:先做"合法"的暴力转换

接下来进入这个赛题最核心的部分:特征工程。这一阶段我强调"合法"两个字,意思是先不做目标编码、不做复杂的统计编码,只做数学上合理、不会明显过拟合的变换。目标编码这类方法看着漂亮,但稍有不慎就会把顺序信息泄露到折叠外。

4.1 偏态数值特征的log/Box-Cox变换

为什么要关注偏态?线性模型、尤其是Ridge/Lasso这类带正则化的模型,对特征尺度高度敏感。一个严重右偏的特征,比如某些面积字段,可能大部分值集中在很小的区间内,偶尔几个超大值落在很远的右侧。这种分布会在特征空间里留下几个孤立点,模型为了让这几个点拟合得更准,会把系数带到奇怪的区域,其他特征的贡献就被压下去了。

我的筛选办法是用偏度系数skewness,阈值设在0.75。超过就对特征做变换:如果特征没有0值且全部大于0,直接用log1p;如果有0值,可以先做一个"是否为0"的指示变量,再对非0部分取对数,或者把所有值平移为正以后做Box-Cox。scipy里提供了现成的boxcox函数,但它要求输入必须全部为正,所以处理好0值是前提。

变换之后有一个关键动作:记录哪些列做过变换。后面无论是做逆变换、特征扩展还是模型解释,都要回头参考这张清单。很多人在这步偷懒,后面对特征做可视化、排查异常预测时才发现分不清哪些字段是原始值,哪些是对数域,白白浪费时间。

4.2 序号编码、One-Hot与低频类别合并

类别特征的处理我分成三类。低基数类别,比如房屋类型这种七八个类,直接用One-Hot。换成树模型其实也可以不编码,但线性模型必须显式展开。中基数类别,比如街道类型几十个类,One-Hot会撑出几十个稀疏列,对线性模型的惩罚项很不友好,我会压缩一下:先把计数少于某个阈值的类别合并成"其他",再One-Hot。高基数类别就复杂了,比如区域编号上百个类,One-Hot不现实,目标编码又容易过拟合,这一阶段我会先放着,等系列后面讲更稳的方案。

有序类别务必保留顺序。质量评分从1到10,是一个典型的有序量表。如果你用One-Hot,等于丢掉了"1比10差很多"这个信息;如果你直接用原始数值,又隐含了"每差1分价格差一样多"的线性假设。实际操作中大多数强方案都是保留原始数值,再补一个平方项,让模型自己学非线性衰减。

4.3 面积乘以质量:线性模型最需要的组合特征

线性模型的短板是它学不到特征交互,面积大、质量好两个因素一起出现时,价格不是简单加法,而是乘法。树模型可以自动发现这类交互,但线性模型不行,所以你必须显式把交互项构造出来喂给它。

最稳的组合特征集中在几个方向:面积类乘面积类,比如总面积乘地上面积;面积类乘质量类,比如总面积乘总体质量评分;面积类乘卫生间数量;再有就是让"是否有车库"这种指示特征去乘车库面积。原理用一句话概括就是:房价的物理直觉是"体积×单价",面积越大、质量越高,总价越高。构造一个"有效面积"类的特征,让线性模型一次就能学到这个乘积关系,比让模型自己去猜要高效得多。

构造的时候最需要注意的就是前面讲的0值问题。一定要先清理缺失和0值再做乘法,否则NaN和0会把整个特征列污染掉。命名也要规范,我用"featureA_featureB"这种格式,方便回看实验记录时一眼就知道这个组合特征是基于什么构造的。

5. 高级回归模型入场:Ridge/Lasso/ElasticNet的正确打开方式

到了这一节才真正进入"高级回归"的主题。之所以把正则化线性模型作为第一篇的主角,而不是直接上XGBoost,是因为它足够透明,能帮你判断特征工程做得好不好。如果后续树模型大幅度超越它,说明特征交互价值很大;如果两者差不多,说明你的数据清洗和特征构造已经到位了。

5.1 Ridge/Lasso/ElasticNet的分工差异

三兄弟的分工可以这样记:Ridge用的是L2惩罚,所有特征共同分担信号,系数不会变成0,适合特征普遍有效的场景;Lasso用的是L1惩罚,会把不重要特征的系数压成0,相当于自动做特征选择,但它有两个毛病:系数估计是有偏的,而且特征高度相关时它会随机挑一个代表,稳定性差;ElasticNet把L1和L2按比例混合,用l1_ratio控制两者权重,在相关特征多的回归任务里通常最稳。

模型惩罚类型系数行为适合场景主要风险
RidgeL2不会归零特征普遍有效,相关性高全部特征都保留,解释性差
LassoL1部分归零特征稀疏选择高相关特征下选得随机
ElasticNetL1+L2折中高相关大特征集多一个超参要调

我在房价数据上默认从ElasticNet开始,因为面积、质量、年份这些特征之间高度纠缠,Lasso选得太狠,Ridge又太保守,ElasticNet恰好能兼顾。l1_ratio我会从0.5附近起步,再按结果微调。

5.2 数据标准化不能省:Pipeline防止数据泄露

Ridge/Lasso/ElasticNet对特征尺度非常敏感,因为惩罚项直接作用在系数上。面积这个特征如果数值是几十到几千,而质量评分只有1到10,两个特征的系数天然不在一个量级。如果不做标准化,面积特征的系数会被惩罚压得特别小,模型会误以为质量评分比面积重要得多,这完全是尺度造成的假象。

所以数值特征必须全部过StandardScaler,让每个特征均值0、方差1。这里有个比很多教程更重要的细节:scaler只能在训练折上fit,再变换验证折和测试折,绝不能先对全数据做标准化再划分。否则验证集的统计信息已经进入训练过程,CV分数会虚高且不安全。解决方案是用Pipeline把scaler和模型串在一起,每次fit都只看到当前的训练折。这不仅是代码整洁的问题,是对抗数据泄露的基础设施。

5.3 超参搜索:从小网格开始,别一上来就全自动

调参之前先把搜索目标定下来:以交叉验证的平均分为准,不要看LB,更不要看训练集分数。ElasticNet有两个核心参数:alpha控制正则强度,l1_ratio控制L1占比。alpha的搜索范围一般用log空间,比如从0.001到1,按约3倍步长取几个点;l1_ratio在0.1到0.9之间按0.1或0.2步长取几个点。两个参数组合起来顶多几十组,网格搜索完全跑得动。如果以后特征多了、要调的参数多了,再换成随机搜索,因为维度高起来以后,随机采样比穷举网格高效得多。

不要一开始就上贝叶斯优化之类的自动调参工具。这个阶段你的特征还没稳定,调参工具再聪明,也是在流沙上盖楼。先把参数固定在一个合理范围,把注意力放在数据和特征质量上,等整体流程稳定了再优化超参不迟。而且自动调参工具一旦用上,你会不自觉地把所有精力放在参数上,反而忽略了真正能拉开差距的特征工程。

6. 把流程串起来:一个可复现的最小工作流

前面的章节都是单点技术,这一节我把它们串成一个闭环,给一个精简但可跑的代码骨架。这个骨架本身不会给出顶尖分数,但它是一条所有后续实验都能对比的基线。没有基线,你永远不知道自己每一步改进了多少。

6.1 代码骨架:从读取数据到10折评估

import numpy as np import pandas as pd from sklearn.model_selection import KFold from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import ElasticNet from sklearn.metrics import mean_squared_error train = pd.read_csv("train.csv") test = pd.read_csv("test.csv") # 目标变量对数变换,后续提交记得逆变换 train["SalePrice_log"] = np.log1p(train["SalePrice"]) y = train["SalePrice_log"].values # 拼接训练测试集,保证两边预处理完全一致 n_train = len(train) full = pd.concat([train.drop(["SalePrice", "SalePrice_log"], axis=1), test], axis=0) # 在这里统一处理缺失值、类型转换、构造组合特征,省略详细代码 # full = fill_missing(full) # full = encode_category(full) # full = add_interaction_features(full) # 取出训练集部分 X = full.iloc[:n_train] # 固定10折,存下来全项目共用 kf = KFold(n_splits=10, shuffle=True, random_state=42) scores = [] for train_idx, valid_idx in kf.split(X): X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid = y[train_idx], y[valid_idx] # Pipeline保证scaler只在训练折上fit,不发生数据泄露 model = make_pipeline(StandardScaler(), ElasticNet(alpha=0.01, l1_ratio=0.5)) model.fit(X_train, y_train) pred = model.predict(X_valid) score = mean_squared_error(y_valid, pred, squared=False) # RMSE scores.append(score) print(f"CV RMSE: {np.mean(scores):.4f} ± {np.std(scores):.4f}")

这段代码跑通以后,你的基线就立住了。接下来每次实验只改一个变量:加一个特征、换一个缺失填充方式、调一个超参,其余全部保持不动,然后记录分数变化。如果某次改动让CV均值下降了0.01,那么多半是有效改动;如果CV均值没动,只是标准差变大,那大概率是噪声,不要被它带偏。

6.2 实验记录表是看不见的竞争力

最后想说一个很多人忽略的习惯:实验记录。特征数量、做了哪些变换、用的模型和参数、CV均值和标准差、训练时间,每次都记下来。没有记录,你优化流程的效率极低,因为同一个参数组合可能两周后又被你当成新实验重新做一遍。

我的记录表长这样:实验编号、特征集版本、模型、alpha、l1_ratio、CV均值、CV标准差、备注。每次改动只动一行,分数是涨是跌一目了然。做过几轮之后你会发现,真正让分数提升的不是某个魔法参数,而是一连串小改动的叠加:缺失值语义对了、0值处理了、组合特征加了、交叉验证稳了,每个都能带来零点零零几的提升,合起来才是一个看得见的阶跃。做比赛和写代码一样,最值钱的资产不是你某个神秘的调参组合,而是你对自己每一步实验的掌控力。

这一篇先讲到这里,覆盖了从评价指标、数据预处理、交叉验证到第一批特征工程和线性正则化模型的完整闭环。下一篇我会开始上树模型,讲XGBoost和LightGBM在实际回归任务里的典型坑,以及模型融合的入门写法。到时候你会看到,这几个模型和线性模型互补在哪里,堆叠为什么能比单一模型明显更稳。我个人做这类赛题最大的体会是:别急着玩花活,先把地基打实——验证可信、特征可追溯、实验可复现,这三点比任何单模型魔法都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询