资讯动态

PyTorch实战Kaggle房价预测:从数据管道到训练策略的完整指南

发布时间:2026/10/19 7:59:31 来源:尧图企业网站定制
老实说用PyTorch去打Kaggle房价预测这个比赛如果只看最终排名和分数很大概率是打不过XGBoost那一套传统表格模型的。但如果你把目光从这块奖牌上移开情况就完全不同了房价预测House Prices: Advanced Regression Techniques作为Kaggle上最经典的回归入门赛79个解释变量、1460条训练样本规模不大却把数据清洗、特征工程、模型正则化和训练策略这几项核心能力考察得相当全面。用PyTorch走一遍完整流程你收获的不是某个比赛的榜单名次而是一套可以迁移到推荐系统、风控评分、时序预测等真实业务场景的深度学习建模方法论。这篇文章把我从读数据到最终提交的完整思路写出来包括那些踩过的坑、想明白的道理以及一些常规文档里不会写的小经验。适合三类人看第一次用PyTorch做完整回归任务的新手日常用表格数据建模、想引入深度模型的分析师和工程师以及准备系统性补一补深度学习训练细节的同学。1. 为什么用PyTorch打表格类Kaggle竞赛一个反直觉的选择1.1 这个比赛的难点不在模型而在数据分布和特征质量很多人对房价预测比赛有个误解以为分数低是模型不够强于是从MLP换到CNN再换到各种花哨结构结果分数纹丝不动。我最初也走过这个弯路——第一版模型用了一个相当深的网络验证集RMSE难看到不想提后来才发现问题根本不在网络上而是特征处理和目标分布这两件事没做好。这个比赛的数据长什么样训练集1460行79个特征其中既有OverallQual这种有序类别特征也有GrLivArea这样的连续数值特征还有一大片缺失值。更麻烦的是不少特征严重偏斜目标变量SalePrice的分布也是右偏的直方图画出来长尾巴拖出去老远。神经网络对这类分布极其敏感你不做变换模型就会把所有精力都花在拟合少数极端大值上中位数附近的样本反而学不好。所以真正的第一课是表格数据比赛的分数60%以上由数据质量和特征工程决定模型结构反而是后面的事情。这也是为什么我建议所有想入门表格深度学习的人拿这个比赛开刀——它逼着你去处理真实世界里最常见的那类脏数据而不是像图像分类那样直接用现成的干净数据集。1.2 PyTorch的真正价值把数据管道和训练细节吃透那既然传统GBDT模型表现更好为什么还要用PyTorch我的回答是模型之外的整套工程能力。你在比赛里用sklearn的RandomizedSearchCV调参和在生产环境里用PyTorch训练一个推荐模型中间差的不是几百行代码而是你对数据管道、张量形状、标准化时机、训练循环和模型保存这些环节的理解。PyTorch把这个过程拆得很清晰你要自己写Dataset把DataFrame切成一个个张量自己管DataLoader的batch和shuffle自己设计模型的前向传播自己写训练循环、验证循环、学习率调度和Early Stopping。每一步都逼你想明白为什么这么做而不是点一个fit就完了。拿房价预测当载体恰好合适特征不多不少数据量不大一次完整训练只要几十秒到几分钟你可以快速验证自己的想法一天之内把整个流程迭代好几轮这种反馈速度是练手的黄金条件。我在训练过程中还发现一个额外的收获当你用PyTorch把表格数据的标准化、缺失值填充、类别编码、训练验证划分全部做成可复用的管道之后再回头用sklearn建模你会下意识地关注数据泄漏、scaler的fit时机这些以前容易忽略的细节。这种降维打击式的提升我觉得比单纯刷一个比赛分数值钱得多。2. 数据准备特征工程与目标变换才是隐藏评分器2.1 先摸清缺失值分布再做清洗和编码拿到数据第一件事不是建模而是把缺失值摸清楚。我习惯先统计每一列的缺失比例import pandas as pd import numpy as np train pd.read_csv(train.csv) test pd.read_csv(test.csv) train[is_train] 1 test[is_train] 0 df pd.concat([train, test], axis0) missing_ratio df.isnull().mean().sort_values(ascendingFalse) missing_cols missing_ratio[missing_ratio 0] print(missing_cols)看完缺失率之后我的处理策略分成三类第一类缺失率超过90%的列比如PoolQC、MiscFeature、Alley直接删除。这些特征即使有意义样本量也不足以让神经网络学到可靠规律留着一方面增加噪声另一方面还会撑大one-hot之后的特征维度。第二类结构型缺失最有代表性的是Garage系列和Bsmt系列。比如GarageCars、GarageArea这些列缺失绝大多数情况下表示这栋房子本身就没有车库而不是数据丢了。这种情况不能简单地填中位数否则等于给没车库的房子强行虚构了一个中等车库。正确做法是把缺失本身编码为一种状态——数值列填0类别列填None。第三类少量随机缺失比如某些数值型特征缺了一两个值。这类我统一用中位数填充。实测下来在神经网络里中位数比均值更稳定因为它不受极端值影响。对类别型特征我填充众数或者干脆填一个None类别。2.2 数值特征标准化与类别特征的展开处理完缺失值之后下一步是把所有特征统一成神经网络能消化的数值形式。我按数值列和类别列分开处理from sklearn.preprocessing import StandardScaler, OneHotEncoder num_cols df.select_dtypes(include[int64, float64]).columns.tolist() num_cols [c for c in num_cols if c not in [Id, is_train, SalePrice]] cat_cols df.select_dtypes(include[object]).columns.tolist() # 类别列缺失值统一填 None for c in cat_cols: df[c] df[c].fillna(None) # 数值列缺失值用中位数填充 for c in num_cols: df[c] df[c].fillna(df[c].median())数值列我直接用StandardScaler做标准化。注意一个关键点one-hot之后的0/1二值列不要再做标准化否则会丢失稀疏结构的语义。所以数值列的标准化要在one-hot之前完成然后拼接在一起。类别特征的编码方式入门阶段我推荐OneHotEncoder直接展开。房价预测数据里的类别特征做完one-hot之后特征维度从79涨到200多完全在MLP可接受的范围内。关于用embedding层替代one-hot的方案我在后面会提一句那是进阶玩法第一版先用简单方案跑通。2.3 对SalePrice取log的原因RMSE的数学推导接下来是整个数据准备阶段最重要的操作——对目标变量SalePrice取对数。这个操作的动机可以看得很清楚SalePrice的分布严重右偏最大值接近最小值的好几倍不处理的话模型在MSE损失下会被那些大额房屋样本严重带偏。取log之后分布接近正态模型学起来稳定得多。另一个更深层的原因是评估指标的高度匹配。这个比赛的官方评估用的是均方根误差RMSE但实际应用在对数变换后的SalePrice上也就是你最终提交的预测值会先被取log再计算RMSE。数学上可以推导如果你在训练时直接对log(y)用MSE损失等价于在log空间优化RMSE这正好和官方的评估方式完全对应。from sklearn.model_selection import KFold from sklearn.preprocessing import StandardScaler df[SalePrice_log] np.log1p(df[SalePrice])我用的是np.log1p而不是np.log因为log1p在y0附近更稳定虽然房价没有0但这是一个好习惯。预测阶段用np.expm1就可以还原回原始房价。这里还有一个容易忽略的细节one-hot编码之后的数据会变得非常稀疏但MLP对这种宽而稀疏的输入其实处理得不错因为每个线性层都有能力把相关特征加权组合起来。你不要因为看到200多列就慌实际上有效信号就藏在其中一小部分维度里网络会自动学到怎么组合它们。3. 从DataFrame到DataLoaderPyTorch数据管道搭建细节3.1 自定义Dataset的实现要点数据清洗完接下来是把DataFrame转成PyTorch能吃的张量。这一步很多人觉得麻烦直接numpy转tensor一把梭。但如果你的目标是把这套流程用于更大的项目Dataset和DataLoader这套写法必须现在练熟。自定义Dataset的核心就三个方法import torch from torch.utils.data import Dataset, DataLoader class HousePriceDataset(Dataset): def __init__(self, X, yNone): self.X torch.tensor(X, dtypetorch.float32) self.y torch.tensor(y, dtypetorch.float32) if y is not None else None def __len__(self): return len(self.X) def __getitem__(self, idx): if self.y is not None: return self.X[idx], self.y[idx] return self.X[idx]有几个细节值得展开说一下。第一dtype必须显式指定为torch.float32。因为PyTorch默认的浮点精度就是32位如果你从numpy读入的是float64不转换的话会在模型前向传播时报类型不匹配。第二__getitem__返回的是一个Tuple由特征张量和目标张量组成。这里不需要在这里做标准化标准化应该在DataFrame阶段就完成因为那是sklearn StandardScaler的主场。第三测试集和训练集共用一个Dataset类通过y参数的有无来区分。推理阶段只需要返回X不需要y。3.2 验证集上的标准化陷阱scaler只能fit在训练折这是我在实际项目中踩过最深的一个坑也是很多人本地验证分数高、线上直接崩的常见原因。标准化这个操作看起来简单但里面藏着一个数据泄漏的经典问题如果你把训练集和验证集放一起算均值和标准差然后再标准化那验证集的信息就已经泄露到训练过程中了。你的模型在验证集上的表现会虚高但一到真正没见过的新数据上就原形毕露。正确的做法是在一个K折循环里每一折都重新fit一个StandardScaler并且只用这一折训练数据来fit然后用这个scaler去transform训练集和验证集。kf KFold(n_splits5, shuffleTrue, random_state42) for fold, (train_idx, val_idx) in enumerate(kf.split(X_processed, y_log)): X_train_fold, X_val_fold X_processed[train_idx], X_processed[val_idx] y_train_fold, y_val_fold y_log[train_idx], y_log[val_idx] scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train_fold) X_val_scaled scaler.transform(X_val_fold)同样的原则也适用于填充缺失值中位数、众数这些统计量都应该从训练折计算而不是从全数据集计算。虽然对于这个比赛影响可能只有一点点但这个习惯必须养成——真实业务里数据量很大这种细节往往决定了模型上线后的稳定性。3.3 形状管理与eval模式的影响DataLoader的使用有一个容易被新手忽略的地方在验证和推理阶段记得设置shuffleFalse。shuffle的目的是打乱训练顺序让每个batch的样本分布更随机帮助优化器更稳定地收敛。但验证时顺序无关紧要保持原始顺序还能方便你把预测结果和真实标签一一对齐。train_loader DataLoader(train_set, batch_size64, shuffleTrue) val_loader DataLoader(val_set, batch_size256, shuffleFalse)另一个重要细节是model.eval()。很多人知道在推理前要调用它但不一定理解为什么。模型里有Dropout和BatchNorm这两个模块在训练和推理时行为完全不同——Dropout训练时随机丢弃神经元推理时保留全部BatchNorm训练时用当前batch的统计量推理时用训练阶段累计的running statistics。不切换到eval模式你每次前向传播的结果都会因为随机性而不同预测值不稳定验证分数也会虚高。我认为在数据处理这个环节最重要的心得是数据管道要尽早一次性搭好并且保持训练时和推理时行为一致。如果你在训练时用了某种预处理推理时忘了做同样的预处理后面的模型再怎么调都白搭。4. 模型结构设计MLP的容量、正则化与模块取舍4.1 一个能打又不过拟合的MLP长什么样模型结构这部分我的建议可能和有些教程不太一样第一版用最朴素的MLP起步别一上来就上复杂结构。房价预测只有1460条训练样本参数量稍微一上去就会过拟合。训练集loss蹭蹭往下掉验证集loss停在原地不动这种场面我见过太多次了。我最终用的结构是这样的import torch.nn as nn class HousePriceMLP(nn.Module): def __init__(self, input_dim): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 64), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 1) ) def forward(self, x): return self.net(x).squeeze(-1)三个隐藏层宽度从256到128再到64递减。为什么是这个配置因为特征维度经过one-hot之后有200多列第一个线性层需要足够的容量把特征里的信号组合起来后面的层逐步压缩信息最终输出一个标量。这个宽到窄的漏斗结构在表格数据回归任务里是一个稳定好用的经验配置。4.2 BatchNorm、Dropout与激活函数的排列顺序这个排列顺序我专门拿出来说因为顺序错了效果差很多。我的习惯是Linear - BatchNorm - ReLU - Dropout。BatchNorm放在Linear之后、激活函数之前是因为它需要处理的是线性层的输出分布。如果放在激活函数之后ReLU已经把负值截断成0了BN能发挥的稳定作用就弱了不少。Dropout放在最后是为了让下一层看到的是经过正则化处理的激活输出。关于Dropout比例我的经验是隐藏层用0.2到0.3不要太高。表格数据在正规化充分的前提下Dropout过高会欠拟合——你会看到训练和验证loss同时居高不下模型根本没有足够容量去拟合数据。激活函数用ReLU就够了。表格数据回归用GELU也许有一点提升但差别很小不值得为此增加调试成本。输出层不加任何激活函数因为我们要预测的是一个连续值不是类别概率。4.3 为什么表格任务不需要CNN和Transformer这个问题几乎每个新手都会问能不能用更高级的结构我的回答是不需要也不划算。表格数据的特征之间没有CNN需要的空间局部性。图像的像素在空间上相邻所以卷积核能提取局部纹理而表格数据的每一列是独立含义的特征邻居关系是人为指定的卷积核扫过去没有任何结构意义。Transformer的前提是序列元素之间有依赖关系但表格特征既不是序列也没有天然的token化方式。强上Transformer也可以但那相当于把特征做一个嵌入再叠注意力在这个数据量下效果不会比MLP好反而更容易过拟合。MLP在表格数据上的优势恰恰是它的笨没有结构先验全靠线性层的权重去学习特征组合。当样本量有限、特征维度适中时这种简单结构对先验的依赖最小拟合目标也最直接。我自己的教训是不要为了结构先进而结构先进。先把MLP调好拿到一个稳定的baseline分数再考虑要不要探索更复杂的东西。5. 训练循环与验证策略让1460条样本发挥最大价值5.1 五折交叉验证与数据泄漏规避1460条训练样本这个数量很尴尬单次划分训练验证集运气成分太大换一个随机种子分数可能就差一大截。所以要稳定评估你的模型必须上K折交叉验证。我用的是5折。每一折都用不同的训练/验证划分训练一个模型最终把5个模型在验证集上的平均RMSE当作模型的稳定性能指标。这个方法还有一个额外好处5个折的模型可以同时用来对测试集做bagging后面会讲。关于数据泄漏这里还有一层需要特别注意某些特征工程步骤比如根据特征与SalePrice的相关性筛选特征也必须在每一折内独立进行。如果在整个数据集上看了相关性再筛选然后把筛选结果用于每一折这同样是某种意义上的泄漏。我在实际项目中为了省事偷懒过结果就是线上成绩比本地验证低了一截。从那以后我给自己定了一条规矩所有需要看标签才能做的操作一律放进K折循环里用训练折的数据算。5.2 损失函数选择MSE还是MAE取决于评估指标损失函数的选择不应该靠感觉而是直接看官方评估指标。房价预测的评估指标是在log空间的RMSE所以我们训练时直接对log后的目标用均方误差MSE训练目标就和评估指标完全对齐了。理论上MSE对应L2损失它对离群点敏感误差较大的样本会得到更大的梯度。使用MAEL1损失会更鲁棒但你会发现它和RMSE评估指标并不完全匹配用MAE训练出来的模型RMSE不一定最低。一个折中方案是用Huber LossSmooth L1 Loss它对离群点更温和同时在接近0时保持平滑是个不错的备选。我的实际经验是在房价预测这个任务上做好离群点处理之后MSE和Huber Loss的最终得分差别很小MSE简单直观就用它。训练循环的骨架我通常这样写import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau model HousePriceMLP(input_dimX_train_scaled.shape[1]) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-5) criterion nn.MSELoss() scheduler ReduceLROnPlateau(optimizer, modemin, patience10, factor0.5) best_loss float(inf) patience_counter 0 for epoch in range(500): model.train() train_loss 0.0 for X_batch, y_batch in train_loader: optimizer.zero_grad() pred model(X_batch) loss criterion(pred, y_batch) loss.backward() optimizer.step() train_loss loss.item() * X_batch.size(0) train_loss / len(train_loader.dataset) model.eval() val_preds [] with torch.no_grad(): for X_batch, y_batch in val_loader: pred model(X_batch) val_preds.append(pred) val_preds torch.cat(val_preds) val_loss criterion(val_preds, y_val_fold).item() scheduler.step(val_loss) if val_loss best_loss: best_loss val_loss patience_counter 0 torch.save(model.state_dict(), ffold_{fold}_best.pt) else: patience_counter 1 if patience_counter 30: break5.3 学习率调度、Early Stopping与模型保存学习率这块我习惯用1e-3作为初始值配合AdamW。Adam系优化器对学习率的敏感度比SGD低1e-3通常是一些中小模型的稳定起点。每一次epoch结束后用验证集loss喂给ReduceLROnPlateau调度器验证loss连续10个epoch不降就把学习率减半。这个策略在表格小数据上非常稳。Early Stopping的patience我设置为30。可能有人觉得太大浪费训练时间。但要注意这个比赛数据量小一个epoch也就几十秒30轮的等待成本完全可以接受而且多跑一点能确保模型真正收敛到最佳点。模型保存用torch.save(model.state_dict(), ...)保存的是state_dict而不是整个模型。这个习惯很重要因为state_dict只包含参数和buffer不包含模型结构在部署和继续训练时更灵活。加载模型时先创建相同结构的模型再load_state_dict然后model.eval()。顺便提一个我在调试时经常做的辅助手段训练几个epoch后打印一下验证集预测值的基本统计量比如均值和标准差。如果你看到预测均值明显偏离训练集log目标均值说明模型可能还没收敛或者学习率有问题。这种简单的统计检查比盯着loss曲线更直观。6. 从本地得分到线上提交bagging与离群点处理带来的排名变化6.1 离群点对RMSE的影响与处理经验如果你跑完以上流程验证集的RMSE大概在0.12左右log空间这在本地看起来已经不错了但你还想往前进下一步值得处理的就是离群点。房价预测数据里有一两个有名的离群点比如地面居住面积GrLivArea超过4000但售价远低于市场价的那几个样本。直观解释就是大面积低售价的样本并不代表市场规律更像是数据里混入了特殊房产可能是亲属交易、法拍房之类的特殊情况。这些样本数量不多但对MSE的影响很大因为MSE对误差大的样本权重是平方级的。我的经验是不要用纯统计方法一概而论地删掉所有3倍标准差以外的样本。更稳妥的做法是画出关键特征与SalePrice的散点图用肉眼判断哪些点是真正的离群点。房价比赛里最典型的做法是删除GrLivArea在4000以上且SalePrice低于30万的几个点。处理之后验证集RMSE一般能下降0.005到0.01看起来不多但在这个竞争激烈的比赛里足以让你的排名前进几百名。6.2 多折模型bagging与结果还原5折交叉验证跑完之后你手里有5个模型把它们对测试集的预测平均就是最简单的bagging。bagging的直观好处是降低单模型方差——不同折训练的模型偏向不同平均之后预测更稳定。这种做法在Kaggle比赛里几乎人人都会用到了生产环境这也是一个简单有效的模型融合手段。预测阶段有个顺序问题需要严格把握先对测试特征做和训练集一模一样的预处理然后逐个fold加载对应模型推理把5个模型的预测结果平均最后做np.expm1还原成原始房价再写入提交文件。test_preds [] for fold in range(5): model HousePriceMLP(input_dimX_test_scaled.shape[1]) model.load_state_dict(torch.load(ffold_{fold}_best.pt, map_locationcpu)) model.eval() with torch.no_grad(): fold_pred model(torch.tensor(X_test_scaled, dtypetorch.float32)).numpy() test_preds.append(fold_pred) final_pred np.mean(test_preds, axis0) final_pred np.expm1(final_pred) submission pd.DataFrame({Id: test[Id], SalePrice: final_pred}) submission.to_csv(submission.csv, indexFalse)6.3 给正准备入门表格深度学习的人几条建议走到这一步整套流程就跑通了。最后分享几条我在多次实战之后沉淀下来的建议希望对你有用。第一务必先跑一个传统baseline比如线性回归或者Ridge。别跳过这一步。传统模型跑几分钟就能给你一个分数下限这个下限能帮你快速验证数据管道有没有问题——如果传统模型都只有0.2那你的MLP跑出0.13就说明改进是真实的如果传统模型已经0.11MLP却0.15那不是模型的问题是你的深度学习管道哪里出了岔子。第二把每个实验的配置和结果记录下来。我在调参阶段吃过不少亏同一个模型换个随机种子验证集分数波动就有0.005左右。不记录seed和配置你就分不清分数的变化是参数真的变好了还是运气变好了。我后来固定使用seed42然后多跑几个不同的seed取平均才把这种方差的影响控制住。第三类别特征的embedding是一个值得探索的进阶方向。当one-hot之后维度特别大时比如类别取值几十上百可以尝试在模型内部把类别特征单独embedding成低维向量再和数值特征拼接。这样既保留了类别语义又避免了one-hot维度过大的问题。这需要稍微改造Dataset和模型结构但能让你对如何把异构特征喂给神经网络这个核心问题有更深的理解。最后一点也是我最想强调的不要因为排行榜分数不如XGBoost就放弃深度学习路线。在这个比赛里传统方法确实更强但你的目标如果是在真实业务里用深度学习处理表格数据这套PyTorch管道带给你的底层能力远比一次比赛排名更持久、更值钱。把数据管道、验证策略、模型训练这些基本功打磨到位以后遇到任何新任务你都有底气说自己知道每一步在做什么为什么这样做。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑