老实说,用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], axis=0) missing_ratio = df.isnull().mean().sort_values(ascending=False) 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在y=0附近更稳定,虽然房价没有0,但这是一个好习惯。预测阶段用np.expm1就可以还原回原始房价。
这里还有一个容易忽略的细节:one-hot编码之后的数据会变得非常稀疏,但MLP对这种"宽而稀疏"的输入其实处理得不错,因为每个线性层都有能力把相关特征加权组合起来。你不要因为看到200多列就慌,实际上有效信号就藏在其中一小部分维度里,网络会自动学到怎么组合它们。
3. 从DataFrame到DataLoader:PyTorch数据管道搭建细节
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, y=None): self.X = torch.tensor(X, dtype=torch.float32) self.y = torch.tensor(y, dtype=torch.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_splits=5, shuffle=True, random_state=42) 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的使用有一个容易被新手忽略的地方:在验证和推理阶段,记得设置shuffle=False。shuffle的目的是打乱训练顺序,让每个batch的样本分布更随机,帮助优化器更稳定地收敛。但验证时顺序无关紧要,保持原始顺序还能方便你把预测结果和真实标签一一对齐。
train_loader = DataLoader(train_set, batch_size=64, shuffle=True) val_loader = DataLoader(val_set, batch_size=256, shuffle=False)另一个重要细节是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损失,它对离群点敏感,误差较大的样本会得到更大的梯度。使用MAE(L1损失)会更鲁棒,但你会发现它和RMSE评估指标并不完全匹配,用MAE训练出来的模型RMSE不一定最低。一个折中方案是用Huber Loss(Smooth L1 Loss),它对离群点更温和,同时在接近0时保持平滑,是个不错的备选。我的实际经验是:在房价预测这个任务上,做好离群点处理之后,MSE和Huber Loss的最终得分差别很小,MSE简单直观,就用它。
训练循环的骨架我通常这样写:
import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau model = HousePriceMLP(input_dim=X_train_scaled.shape[1]) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-5) criterion = nn.MSELoss() scheduler = ReduceLROnPlateau(optimizer, mode='min', patience=10, factor=0.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(), f'fold_{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_dim=X_test_scaled.shape[1]) model.load_state_dict(torch.load(f'fold_{fold}_best.pt', map_location='cpu')) model.eval() with torch.no_grad(): fold_pred = model(torch.tensor(X_test_scaled, dtype=torch.float32)).numpy() test_preds.append(fold_pred) final_pred = np.mean(test_preds, axis=0) final_pred = np.expm1(final_pred) submission = pd.DataFrame({'Id': test['Id'], 'SalePrice': final_pred}) submission.to_csv('submission.csv', index=False)6.3 给正准备入门表格深度学习的人几条建议
走到这一步,整套流程就跑通了。最后分享几条我在多次实战之后沉淀下来的建议,希望对你有用。
第一,务必先跑一个传统baseline,比如线性回归或者Ridge。别跳过这一步。传统模型跑几分钟就能给你一个分数下限,这个下限能帮你快速验证数据管道有没有问题——如果传统模型都只有0.2,那你的MLP跑出0.13就说明改进是真实的;如果传统模型已经0.11,MLP却0.15,那不是模型的问题,是你的深度学习管道哪里出了岔子。
第二,把每个实验的配置和结果记录下来。我在调参阶段吃过不少亏:同一个模型,换个随机种子,验证集分数波动就有0.005左右。不记录seed和配置,你就分不清分数的变化是参数真的变好了,还是运气变好了。我后来固定使用seed=42,然后多跑几个不同的seed取平均,才把这种方差的影响控制住。
第三,类别特征的embedding是一个值得探索的进阶方向。当one-hot之后维度特别大时(比如类别取值几十上百),可以尝试在模型内部把类别特征单独embedding成低维向量,再和数值特征拼接。这样既保留了类别语义,又避免了one-hot维度过大的问题。这需要稍微改造Dataset和模型结构,但能让你对"如何把异构特征喂给神经网络"这个核心问题有更深的理解。
最后一点,也是我最想强调的:不要因为排行榜分数不如XGBoost就放弃深度学习路线。在这个比赛里,传统方法确实更强,但你的目标如果是在真实业务里用深度学习处理表格数据,这套PyTorch管道带给你的底层能力,远比一次比赛排名更持久、更值钱。把数据管道、验证策略、模型训练这些基本功打磨到位,以后遇到任何新任务,你都有底气说自己知道每一步在做什么,为什么这样做。