资讯详情 伦敦房价预测实战:从特征工程到模型集成的完整建模路线
📅 2026/10/10 19:54:57
1. 为什么我会把伦敦房价预测当成一个严肃的建模题我最初看到这个赛题时第一反应是“这不就是一个标准回归问题吗”。给一堆房子特征预测成交价格Kaggle上这类比赛没有一百也有八十。但真正动手做feature engineering之后我才发现伦敦这套房价数据的建模难度和那些教学级的波士顿房价完全不是一回事。伦敦房市的特殊性在于梯度差异极大。同样是一套两居室肯辛顿和克罗伊登的成交价能差出五六倍市中心一区与六区外圈的同类房源价格逻辑几乎等于两个不同的市场。这种差异不完全体现在“区域”字段里还隐藏在经纬度、邮编前缀、街道名称、乃至房子本身的建筑年代里。你如果不把空间信息做透单靠几个分类字段喂给模型分数基本就卡死在中游。这个赛题更大的坑在于数据噪声。公开的伦敦房产数据里地址写法非常随意有的带邮编有的不带面积字段大量缺失成交价存在极端值时间跨度长导致同一套房子在不同年份的价格基准完全不同。玩过几个房价赛题的老手都知道这种数据不是“跑个XGBoost再调参”就能解决的必须先花大力气把数据层的脏东西处理干净再做针对性的特征构造最后才是模型选型与融合。1.1 这个赛题和普通房价回归的差异在哪儿普通房价回归的公开数据集比如波士顿房价或加州房价特征相对干净缺失值少分布相对温和。拿到手之后你只要做一次标准化跑一套线性模型或树模型分数就能到一个合理区间。伦敦这套数据不是这种性格。我把它拆解成三个核心难点空间信息的高密度耦合。房子的价格与地理位置高度相关但经纬度不能直接作为特征喂给线性模型也不能无脑丢给树模型否则模型只会记住“训练集里那些坐标点贵”完全丧失泛化能力。价格标签的长尾分布。高端房产数量少但价格极高直接预测原始价格会被少数大额成交拉着走。必须对目标变量做对数变换让模型在一个相对对称的空间里学习。特征质量参差不齐。有些字段看似连续实际单位混乱有些字段看似分类实际上每个值只出现一两次。这类低频类别如果用普通Label Encoding模型学到的编码大小顺序完全是伪信息。这三个问题单独拎出来都不难解决但叠加在一起就需要一套系统的方案而不是零散的“某个技巧”。我后续的所有章节都是围绕这三条线展开的。1.2 我的整体解题线路我当时给自己定了一条主线后面所有工作在逻辑上都归到这条主线上目标变量预处理先用log1p把价格标签压缩让分布从严重右偏变为近似正态。空间特征工程把经纬度、邮编、区域、街道名等信息转换成树模型能够理解的距离特征、平滑编码特征和聚合特征。候选特征扩展在基础字段上构造交互项、时间特征、文本统计特征尽量丰富模型可用的信息。多模型集成与堆叠单靠LightGBM能到不错的水平但要让分数再上一个台阶必须引入CatBoost、XGBoost以及它们的加权融合。可靠的交叉验证策略用K折统一评估只在最后阶段才看公共榜分数避免被Public LB的随机波动带偏。这条线路看起来并不新奇但每一步里都有很多值得抠的细节。下面我按实际执行顺序把每一层的处理方式和踩过的坑展开说清楚。2. 数据清洗与目标变量最先要做好的两件事很多做Kaggle的新手拿到数据后第一件事就是开始拼模型这是最大的误区。伦敦房价预测这种结构化赛题特征工程的效果对最终分数的影响远远大于模型调参本身。而特征工程的第一步不是造新特征而是把已有的字段清干净。2.1 价格标签的右偏分布与log1p转换我见过太多人在房价赛题里忽略目标变量的分布。伦敦房价的平均值大概在三四十万英镑但最贵的几套房能到几百万甚至上千万。如果你直接拿原始价格作为回归目标MAE或RMSE会被极端值主导模型为了压低那几个高价的误差会牺牲大量中低价样本的预测精度。解决方式是做一个log1p变换import numpy as np train_df[price_log] np.log1p(train_df[price])这里的log1p其实就是在取自然对数之前先加1避免价格为0时出现负无穷。变换之后目标变量从严重右偏变为近似正态分布模型学习的是“相对误差”而不是“绝对误差”。在实际提交时再把预测结果做指数还原pred_price np.expm1(pred_log)这样做的另一个好处是如果你的最终评估指标是RMSLE或类似对数型指标训练目标和评估目标完全一致。即使指标是RMSE对数变换通常也能让你得到一个更稳健的模型。2.2 缺失值和异常值不要用同一套标准扫全表伦敦房价数据里的缺失值模式非常不规律。有的字段比如bathrooms整体缺失率不高但个别记录的缺失值并不是随机的而是和房子类型相关有的字段比如area缺失比例高且缺失往往集中在公寓类房源上。如果直接fillna一个全局常数等于给模型注入噪声。我当时的处理策略是分字段处理字段缺失情况处理方式area缺失较多按房间数、房型分组后填充中位数再生成area_is_missing标志位bathrooms少量缺失与bedrooms做交叉统计按类别中位数填充postcode部分缺失优先用address里的邮编信息补全无法补全的用district作为替代层级build_year分部性缺失直接保留缺失并生成year_is_missing让树模型自己决定缺失值的分裂方式这里有个容易被忽略的细节缺失值本身也可以成为特征。树模型对NaN有天然的分裂支持你完全可以不填充缺失让模型自己学一个“缺失值去哪边”的路径。但前提是你不要在同一字段里混入不同含义的缺失。比如因为数据采集失败导致的NaN和因为数据本身不存在导致的NaN含义完全不同最好用不同值区分。我实际的做法是先用missingno之类的库快速看一下缺失率矩阵然后对缺失率超过60%的字段先做淘汰评估——不是直接删掉而是先跑一版含该字段的模型和不含该字段的模型对比分数再决定去留。这种“用模型说话”的方式比拍脑袋删字段靠谱得多。2.3 多字段文本里的脏数据合并伦敦这套数据有很多看起来是文本的字段比如address和property_type。property_type还好内部的类别相对规范无非是Flat、Terraced、Detached、Semi-Detached等。但address字段非常乱它可能是街道名、门牌号、区域名甚至一段完整地址的混合体。直接把它当分类特征塞进模型会爆炸但丢掉又太可惜。我的做法是从原始文本里拆出可用信息用正则提取门牌号、街名、区域关键词。将地址中包含的邮编信息与postcode字段互相比对补齐缺失。从地址中匹配常见的地标关键词比如station、river、park生成一组布尔特征。把地址长度、单词数量作为统计特征。这个阶段的目标不是让模型理解文本语义而是从噪声文本里榨出结构化的信息。只要能把“这条地址靠近地铁站”这种信号变成一列0/1特征就已经赚到了。3. 空间特征编码把伦敦的区位差异装进模型小区位决定房价这句话在伦敦尤其成立。同一个邮编区域内北侧和南侧的街道可能差价20%隔着一条河价格能差一倍。所以空间特征是伦敦房价预测里信息量最大的部分处理得好不好基本决定了你能不能进入前排。3.1 为什么经纬度不能直接塞进树模型很多人第一反应是“既然位置重要那把latitude和longitude丢进模型不就行了”。这个想法在理论上没错实际操作中却有一个严重问题树模型对连续特征做的是切分决策而经纬度的切分边界在局部空间内近似于“画一条直线”。但伦敦的房价在空间上不是线性变化的它是围绕一系列高价值中心比如金融城、海德公园、泰晤士河沿岸形成的不规则等高线。如果你直接让树模型去切经纬度它会用很多层深度去拟合这些等高线特别容易过拟合而且在测试集上泛化很差。正确的思路是把经纬度转换成模型更容易学习的空间聚合特征坐标分桶把经纬度按0.01度左右的粒度分成网格计算每个网格内的平均房价、房源数量。邻近聚合以每条记录为中心计算半径500米、1公里、2公里范围内的平均价格和房源密度。半径多样特征统计邻近房源的类型构成、均价分位数这些都比裸坐标更能表达“这个位置的竞争格局”。以某个位置的均价特征为例from sklearn.neighbors import NearestNeighbors import numpy as np coords train_df[[latitude, longitude]].values knn NearestNeighbors(n_neighbors50, metricminkowski, p2) knn.fit(coords) distances, indices knn.kneighbors(coords) train_df[local_avg_price] [np.expm1(train_df[price_log].iloc[idx]).mean() for idx in indices] train_df[local_price_std] [np.expm1(train_df[price_log].iloc[idx]).std() for idx in indices]这种局部聚合特征能直接告诉模型“这个房子周围大致是什么价位水平”相当于把空间信息编码成了区域势能模型学起来轻松得多。3.2 邮编与区域的编码方式平滑目标编码是首选邮编和区域是典型的分类特征。伦敦邮编分两级有E1、SW3这种外层编码也有完整到街道粒度的完整编码。直接用Label Encoding或者One-Hot都不是好选择Label Encoding会给类别编号编号的大小关系毫无意义树模型虽然不受单调性限制但会因此多走很多无效切分。One-Hot在低频类别上会产生大量稀疏列既拖慢训练速度又容易带来维度灾难。我最终采用的是平滑目标编码Smoothed Target Encoding。具体逻辑是对每个类别用它自身的目标均值与全局目标均值做一个加权权重取决于类别样本量def smoothed_target_encoding(feature, target, alpha20): global_mean target.mean() group_stats target.groupby(feature).agg([mean, count]) smoothing 1 / (1 np.exp(-(group_stats[count] - alpha) / 10)) encoded smoothing * group_stats[mean] (1 - smoothing) * global_mean return encoded这个编码的优点是样本量大的类别更信任自身均值样本量小的类别向全局均值收缩避免过拟合。这里alpha控制收缩速度实操中可以在有效性和过拟合之间做平衡。不过目标编码有一个著名的坑容易过拟合如果不加约束它在训练集上会“记住”每个类别的精确均值测试集上却遇到新样本或低频样本就失效。我的解决方案是把目标编码放进交叉验证的每一折内部单独计算确保编码只使用训练折的信息。虽然这会增加一些编码耗时但能有效避免数据泄露。3.3 构造距离类特征地铁站、公园、河流与市中心伦敦房产的价值很大程度上取决于“离什么近”。离地铁站近、离公园近、离泰晤士河近通常都是加分项离高架桥近、离机场航线近则可能是减分项。要把这些信号变成特征需要外部数据但伦敦的基础设施数据在公开渠道并不难获得。我用的是经纬度距离计算法。先拿到每个地铁站、公园入口、泰晤士河中心线的坐标然后计算每条房产记录到最近地标的欧氏距离再转成公里from scipy.spatial import cKDTree stations_tree cKDTree(station_coords) distances, _ stations_tree.query(house_coords, k1) train_df[dist_station_km] distances / 1000这里的单位转换很重要。经纬度之间的“1度”并不等于固定公里数在伦敦纬度上经度1度大约对应69公里纬度1度大约对应111公里。如果直接拿度做距离特征单位会很怪树模型虽然能自适应缩放但解释性极差。我建议先近似换算或者用geopy.distance精确计算。除了单一地标距离还可以组合出“是否位于某条地铁线沿线”的类别特征。我当时的做法是把地铁站按线路分组计算每条记录的最近站所属线路再按线路做目标编码。因为伦敦不同地铁线路周边的房价差异非常大比如Jubilee线延伸段带动了东伦敦房价上涨这条特征能捕捉到传统经纬度难以表达的带状结构。4. 从“能跑”到“稳赢”的特征工程组合基础特征处理完之后模型的分数通常已经能到一个不错的水平。但这个阶段的人工特征还不够“聪明”模型看到的是零散的信息还缺少“面积与房间数之间关系不协调”这类高阶判断。这一节我来拆解那些真正拉开差距的组合特征。4.1 面积、房型与时间的交互项领域知识告诉我们房价不仅取决于绝对面积还取决于“每间房分摊多少面积”。一个三居室八九十平米在伦敦算正常但如果一个三居室只有40平米那很可能是改建阁楼或被分隔过的群租房价格逻辑和常规住宅完全不同。单位面积价格比绝对价格更有区分度。我构造了一组交互特征price_per_sqm price / area这个特征不能直接用于训练因为目标由它构成但可以用它的历史统计值生成聚合特征。rooms_density bedrooms / (bathrooms 1)表达房间密度的粗略指标。area_per_bedroom area / bedrooms衡量空间舒适度。floor_area_ratio area / building_area如果原始数据里有总建筑面积这个比值能反映楼层数密度。这些交互项本质上是把原始特征重新组合给树模型提供显式的领域逻辑省去模型用深度去“暗搓搓”拟合这些关系的成本。时间交互也很重要。伦敦房价在过去二十年里整体上行但如果把成交年份单独作为特征模型的线性增长假设并不准确。更好的做法是构造距基准年份的间隔以及该房产成交年份与所在区域平均房价走势的交互train_df[year_since_2015] train_df[year] - 2015 train_df[year_district_avg] train_df.groupby([district, year])[price_log].transform(mean)4.2 文本字段的向量化与统计特征房价数据里通常有description之类的长文本字段很多玩家直接忽略。但它其实藏有大量有效信号。比如文本中出现“refurbished”翻新、“new build”新房、“garden”花园、“lounge”客厅等词汇都能揭示房子的附加价值。我的处理方式不是上BERT而是用轻量级的统计方法基于CountVectorizer抽取高频词保留出现次数适中的词生成布尔特征。统计文本长度、段落数、是否包含数字、是否包含“flat/apartment/house”等关键结构词。把词汇特征和目标变量做简单相关性分析筛选排名靠前的词汇进入最终特征集。实测下来这部分特征对分数的提升在0.5%到1%左右。单独看不大但在榜单位置上可能意味着前进几十名。4.3 特征筛选与多重共线性控制特征工程做完后特征总数很容易膨胀到两三百个。这里要克制先把明显冗余或共线的特征过滤掉否则后续交叉验证的训练时间会变得很难受。我使用的是LightGBM的feature_importance结合permutation importance先训练一版完整模型得到分裂增益排序。按重要性倒序每次删掉最不重要的5个特征重新训练并对比验证集分数。如果删除后分数显著下降说明该特征有信息否则彻底移除。这个方法比单纯看相关性矩阵更可靠因为它直接度量的是特征对预测目标的边际贡献。5. 模型选型与集成为什么不只信一个XGBoost结构化数据赛题里梯度提升树早就成了默认主力。但具体选哪一个、要不要做集成很多人并没有系统地比较过。我在这套伦敦房价数据上做了充分的模型试验下面把结论和配置写出来。5.1 三个梯度提升框架的特性对比我同时使用了XGBoost、LightGBM和CatBoost它们在损失函数上大同小异但处理特征的方式各有侧重框架优势注意事项XGBoost正则化参数丰富对噪声数据稳健类别特征需要手动编码训练时间偏长LightGBM训练速度快支持直方图算法内存占用低小数据上容易过拟合需要调num_leavesCatBoost原生支持类别特征内置排序提升训练慢类别特征过多时时间长在这套数据上三个模型的单模型表现非常接近LightGBM略占优势因为它的直方图算法对大规模稀疏特征更友好。但真正拉开差距的是它们的融合结果。5.2 模型平均与Stacking的具体配置模型集成的第一层我建议先做加权平均融合简单有效。先让三个模型分别输出预测值然后用验证集上的网格搜索找到最优权重。搜索时单独留出验证集用scipy.optimize.minimize做权重优化都可以。加权平均再进一步就是Stacking堆叠泛化。我的做法是把训练集划分成5折。在每个基模型上进行5折交叉预测得到折叠外的预测结果作为第二层模型的训练特征。第二层模型用简单的线性回归或Ridge避免深度模型在第二层继续过拟合。这种结构的关键在于第一层模型的OOF预测必须严格使用“该折训练后模型对该折验证集”的预测绝不能把整个训练集训练出的模型拿来预测训练集否则第二层模型看到的就是“泄漏后的正确答案”测试集上会直接崩盘。我在第二层有时候会加入原始特征中的几个高重要性字段让元模型不仅依赖第一层的预测还能自己做一些校正。实测加不加这几个字段分数变化在0.2%以内但值得尝试。5.3 容错与推理速度的权衡集成模型数量不是越多越好。我在实验中尝试过加入随机森林、ExtraTrees、甚至简单的岭回归结果发现树类集成模型之间的互补性并不强反而增加了推理时间和模型复杂度。真正有用的组合方式是两个强梯度提升模型一个差异较大的线性或浅层模型。浅层模型在这里的作用是“兜底”。树模型对特征空间的局部变化敏感而线性模型更擅长捕捉全局趋势。两个模型在测试集上的误差相关性较低融合后能同时压低系统误差和随机误差。如果你对推理时间有要求比如要在竞赛提交截止前快速跑完大量测试样本我建议最终的融合模型控制在三个之内。三个模型的推理时间基本是单模型的三倍在Kaggle平台上通常还能接受。如果集成太多模型时间成本上升收益却不明显。6. 交叉验证设计与超参数调优防住Public LB的幻觉很多参加Kaggle竞赛的玩家都有过这种经历本地交叉验证分数提升了但提交到Public LB却掉了很多。这种情况通常不是模型变差了而是你的交叉验证设计与真实测试分布不一致。伦敦房价数据的时间跨度和地理分布不均匀让这个问题更加敏感。6.1 K折设计的关键细节房价数据的样本之间不是完全独立的。同一邮编、同一街道的数据点房价高度相关。如果按普通随机K折切分训练集和验证集之间会频繁出现“同一区域的数据同时在两侧”的情况验证集分数会虚高因为模型其实已经“见过”这些区域的房价水平。我的做法是按区域分层K折from sklearn.model_selection import GroupKFold group_kfold GroupKFold(n_splits5) for train_idx, val_idx in group_kfold.split(X, y, groupstrain_df[postcode_prefix]): # train model ...以邮编前缀作为分组依据确保同一邮编的数据不会跨折出现。这会让验证分数更诚实代价是验证集方差变大单折的分数可能波动。如果数据的时间跨度明显还可以考虑按时间排序的时序切分比如用前80%年份的数据训练后20%年份的数据验证。在这个赛题里我对比过随机K折、按区域分组K折、按时间切分三种方案最终选择按区域分组K折加按时间切分的组合参考两个视角一起看模型稳定性。6.2 Optuna调参时的目标函数写法超参数调优我推荐用Optuna。它的TEPTree-structured Parzen Estimator在多轮搜索中能利用历史结果指导下一次采样比纯随机搜索效率高很多。调参时目标函数怎么写直接影响搜索效果。我的目标函数框架是这样的import optuna import lightgbm as lgb from sklearn.metrics import mean_squared_error def objective(trial): params { objective: regression, metric: rmse, learning_rate: trial.suggest_float(learning_rate, 0.01, 0.1, logTrue), num_leaves: trial.suggest_int(num_leaves, 16, 256), max_depth: trial.suggest_int(max_depth, 3, 12), min_child_samples: trial.suggest_int(min_child_samples, 5, 50), subsample: trial.suggest_float(subsample, 0.6, 1.0), colsample_bytree: trial.suggest_float(colsample_bytree, 0.6, 1.0), reg_alpha: trial.suggest_float(reg_alpha, 1e-4, 10.0, logTrue), reg_lambda: trial.suggest_float(reg_lambda, 1e-4, 10.0, logTrue), } cv_scores [] for train_idx, val_idx in group_kfold.split(X, y, groupstrain_df[postcode_prefix]): d_train lgb.Dataset(X.iloc[train_idx], y.iloc[train_idx]) d_val lgb.Dataset(X.iloc[val_idx], y.iloc[val_idx]) model lgb.train(params, d_train, num_boost_round1000, valid_sets[d_val], callbacks[lgb.early_stopping(100)]) pred model.predict(X.iloc[val_idx]) cv_scores.append(np.sqrt(mean_squared_error(y.iloc[val_idx], pred))) return np.mean(cv_scores)注意两点目标函数内部必须使用和最终验证一致的折切分方式num_boost_round最好配合早停避免每一组超参数都训练固定几千轮导致时间过长。6.3 防止过拟合的Early Stopping策略梯度提升模型很容易在训练后期过拟合。无论参数怎么调early_stopping都必须开。我的Early Stopping配置是验证集早停轮数设为50到100取决于学习率。学习率越低早停所需轮数越大。训练轮数上限设定在3000到5000留给模型足够的上升空间。早停后用最优迭代次数重新在全量数据上训练这个“全量重训”的轮数通常取最优轮数的1.1倍稍微加一点余量。在集成模型里每个基模型都独立做早停和全量重训避免某个模型过拟合后拖累整体融合效果。7. 沉淀下来的最终配置与赛后复盘这一节我把最终使用的Pipeline配置完整列出来作为可以直接参考的基线。再聊聊我在赛后复盘里总结的一些原则性经验。7.1 最终Pipeline配置表模块具体方案目标变量log1p(price)预测后用expm1还原缺失值处理分组中位数填充加缺失标志位空间特征经纬度KNN聚合特征、坐标分桶均值、地铁站距离类别特征邮编、区域的平滑目标编码折内计算文本特征高频词布尔特征、地址关键词匹配交互特征面积/房间数比例、年份交互、密度类特征基模型LightGBM、XGBoost、CatBoost融合方式第一层OOF预测第二层Ridge回归叠加重要原始特征交叉验证按邮编前缀分组的5折GroupKFold超参数优化Optuna每折早停100轮这套配置在验证集上的表现比单模型LightGBM提升了大约3%到4%的RMSE在Public LB上也能稳定前进。如果你只是想在赛题里先跑出一个可用的分数基线上面第2节到第4节的内容就足够支撑了。7.2 复盘这类赛题真正拉开差距的地方赛后复盘时我整理了三个贯穿全程的判断这些判断并不仅仅针对伦敦房价也对其他结构化回归赛题有参考价值第一特征工程的投资回报率远高于模型调参。我花了大约70%的时间在数据清洗和特征构造上30%的时间在模型和调参上。这个比例看似失衡但每次特征层的改进都能带来稳定的分数提升而调参的收益在后期迅速衰减。第二交叉验证的可靠性比即时分数更重要。如果交叉验证协议不严谨再好的调参结果也只是在自欺欺人。按区域分组、按时间切分这两种做法也许会让验证分数看起来没那么惊艳但它们能在测试集上给你更真实的预期。第三不要迷信高复杂度模型。堆叠、融合确实能带来提升但前提是每个基模型都足够强、且彼此差异真实存在。一个强模型加一个弱但差异大的模型效果经常好过三个同质化的强模型。最后分享一个小技巧每次提交前我会把训练好的模型对训练集进行回测计算训练集与验证集的分数差值。如果这个差值过大说明模型记忆了训练噪声此时优先回去降特征数量或增大正则化而不是继续堆叠更多模型。这个方法帮我避开了好几次“本地高分线上崩盘”的尴尬。