1. 项目背景与目标为什么房价预测是机器学习的“必修课”如果你刚接触机器学习想找一个项目来练手但又不想从“Hello World”级别的鸢尾花分类开始那么Kaggle上的房价预测竞赛绝对是你不能错过的“新手村毕业设计”。我当年也是从这个项目入门的它就像一个微缩版的真实数据科学项目麻雀虽小五脏俱全。这个项目的魅力在于它用一套真实的、有点“脏”的房屋交易数据逼着你走完从数据清洗、特征工程到模型训练、调优、评估的完整流程。你不仅能学会怎么用代码处理数据更能深刻理解一个模型从“跑得动”到“跑得好”之间到底隔着多少需要手动调整的细节。简单来说这个项目的任务就是给你一堆房子的信息比如面积、房龄、地段、装修情况等等让你预测它的最终售价。听起来是不是很像房产中介的日常工作只不过我们用算法来代替经验。数据集来自Kaggle的“House Prices: Advanced Regression Techniques”竞赛包含近3000条记录和80多个特征既有数值型的“卧室数量”也有类别型的“房屋类型”。我们的目标就是构建一个回归模型让预测价格尽可能接近真实售价。我这次复盘就是想把我踩过的坑、试过的错、以及最终有效的调参思路原原本本地分享给你。我会用PyTorch框架从最基础的数据读取开始一步步带你搭建一个线性回归模型然后像做实验一样系统地测试不同的损失函数、学习率、优化器、初始化方法看看它们对最终结果的影响有多大。最后我们还会用K折交叉验证来评估模型的稳定性并对比数据预处理前后的巨大差异。相信我跟着走完这一趟你对机器学习项目流程的理解会清晰很多。2. 环境搭建与数据初探你的“实验室”准备好了吗工欲善其事必先利其器。在开始写代码之前我们先得把环境搭好。我个人的习惯是使用Anaconda来管理Python环境这样可以避免不同项目之间的包版本冲突。下面是我这次实验的环境配置你可以直接复制使用# 创建一个新的conda环境Python版本建议3.8或3.9 conda create -n house_price python3.9 conda activate house_price # 安装核心依赖包 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu # 如果你是CPU环境 pip install pandas numpy matplotlib scikit-learn jupyter我的硬件是一台普通的笔记本电脑i7-12700H, 16GB RAM没有独立显卡所以全程使用CPU进行训练。这完全没问题因为我们的模型是简单的线性回归计算量不大。数据集可以从Kaggle竞赛页面直接下载包含train.csv和test.csv两个文件。拿到数据后别急着写模型第一件事永远是“看数据”。用Pandas打开看一眼import pandas as pd train_data pd.read_csv(./house-prices-advanced-regression-techniques/train.csv) print(f训练集形状: {train_data.shape}) print(train_data.head()) print(train_data.info())运行这几行代码你会立刻发现几个关键信息训练集有1460条数据80列79个特征1个目标列SalePrice。info()方法会告诉你哪些列是数值型int64, float64哪些是对象型object通常是字符串类别。更重要的是你会看到很多列都有非空计数小于1460这意味着存在缺失值。这是现实数据的常态也是我们预处理的第一步要解决的问题。3. 数据预处理从“脏数据”到“干净特征”的魔法原始数据直接扔给模型效果通常会很差甚至报错。数据预处理的目的就是把原始数据转换成模型能“消化”的格式。这个过程往往比模型本身更重要。我把它总结为四个核心步骤合并、标准化、填充、编码。第一步合并训练集和测试集。这很重要为了保证我们对训练集和测试集施加了完全相同的变换比如用训练集的均值和标准差去标准化测试集我们需要先将它们特征部分合并处理完再分开。# 提取特征和目标 train_labels train_data.SalePrice features pd.concat([train_data.drop([SalePrice, Id], axis1), test_data.drop([Id], axis1)])第二步标准化数值特征。想象一下LotArea占地面积可能以万为单位而OverallQual整体质量是1到10的评分。如果不处理模型会认为占地面积的影响远远大于质量评分这显然不合理。标准化的公式很简单(x - mean) / std让每个特征的均值为0标准差为1。numeric_features features.dtypes[features.dtypes ! object].index features[numeric_features] features[numeric_features].apply( lambda x: (x - x.mean()) / (x.std()) )第三步填充缺失值。标准化后数据的均值变成了0所以一个很省事的做法就是用0来填充所有缺失值。对于数值特征这相当于把缺失值视为“平均水平”对于后续要编码的类别特征我们也可以把“缺失”本身当作一个有效的类别。features features.fillna(0)第四步对类别特征进行独热编码。模型看不懂“RL”住宅低密度、“FV”溪景这样的字符串。独热编码就是为每个类别创建一个新的二进制列。比如“房屋类型”有3种就生成3列属于该类型则为1否则为0。Pandas的get_dummies函数可以一键完成参数dummy_naTrue会把缺失值也单独编码成一列。features pd.get_dummies(features, dummy_naTrue) print(f编码后特征维度: {features.shape})处理完你会发现特征数量从79个一下子膨胀到了300多个这就是独热编码的“维度爆炸”。最后我们把处理好的数据转换回PyTorch Tensor并按照原始数量分割回训练集和测试集。n_train train_data.shape[0] train_features torch.tensor(features[:n_train].values, dtypetorch.float32) test_features torch.tensor(features[n_train:].values, dtypetorch.float32) train_labels torch.tensor(train_labels.values, dtypetorch.float32).view(-1, 1)4. 模型构建与训练搭建你的第一个预测“流水线”数据准备好了我们来搭建模型。虽然房价预测可以用更复杂的树模型或神经网络但这里我们从最简单的线性回归开始。它的公式小学生都懂y w * x b。在PyTorch里一个nn.Linear层就搞定了。import torch.nn as nn def get_net(feature_num): # 输入特征数输出1个值房价 net nn.Linear(feature_num, 1) # 初始化权重和偏置这里先用简单的正态分布 for param in net.parameters(): nn.init.normal_(param, mean0, std0.01) return net接下来是损失函数它衡量我们预测得有多“不准”。对于回归问题最常用的是均方误差MSE它计算预测值和真实值之差的平方的平均值。MSE对大的误差惩罚更重。loss nn.MSELoss()训练过程就是经典的“前向传播 - 计算损失 - 反向传播 - 更新参数”循环。我把它封装成一个函数方便后续反复调用和测试。def train(net, train_features, train_labels, test_features, test_labels, num_epochs, learning_rate, weight_decay, batch_size): train_ls, test_ls [], [] # 记录每一轮训练集和测试集的损失 dataset torch.utils.data.TensorDataset(train_features, train_labels) # DataLoader帮我们自动分批次、打乱数据 data_iter torch.utils.data.DataLoader(dataset, batch_size, shuffleTrue) # 使用Adam优化器它会自动调整学习率 optimizer torch.optim.Adam(net.parameters(), lrlearning_rate, weight_decayweight_decay) for epoch in range(num_epochs): for X, y in data_iter: l loss(net(X), y) # 前向传播计算损失 optimizer.zero_grad() # 清空上一轮的梯度 l.backward() # 反向传播计算梯度 optimizer.step() # 用梯度更新参数 # 记录本轮在整个训练集和测试集上的损失 train_ls.append(log_rmse(net, train_features, train_labels)) if test_labels is not None: test_ls.append(log_rmse(net, test_features, test_labels)) return train_ls, test_ls这里用到了一个log_rmse函数它是Kaggle这个比赛常用的评估指标先对预测值和真实值取对数再计算RMSE均方根误差。取对数可以减弱异常值的影响让评估更稳定。def log_rmse(net, features, labels): with torch.no_grad(): # 评估时不计算梯度节省内存 clipped_preds torch.clamp(net(features), 1, float(inf)) rmse torch.sqrt(loss(clipped_preds.log(), labels.log())) return rmse.item()5. 损失函数对决MSE和MAE谁更适合房价预测模型架子搭好了我们开始第一个实验选哪个损失函数我对比了最常用的两个MSE均方误差和MAE平均绝对误差也叫L1 Loss。它们核心区别在于对待误差的态度MSE会把误差平方所以特别“讨厌”大的误差MAE则直接取绝对值对所有误差一视同仁。为了公平对比我固定了其他所有超参数学习率1Adam优化器训练100轮只更换损失函数。结果非常明显损失函数最终训练RMSE训练曲线特点MSE0.709初期下降快收敛稳定MAE1.327初期下降慢最终误差较大从训练曲线看使用MSE的模型蓝色线从一开始就快速下降而MAE红色线则显得有些“磨蹭”。为什么在房价预测的场景里数据中难免有一些偏离主流很远的“豪宅”或“破房”这些样本的误差会很大。MSE因为平方项会给予这些异常点更高的“关注度”迫使模型优先去修正这些大的错误。而MAE的线性惩罚让模型对异常点不那么敏感。对于房价这种可能存在长尾分布的数据MSE通常能引导模型学到更稳健的模式。所以第一轮PKMSE胜出。这也符合大多数回归任务的经验。当然如果你的数据异常值非常多且你不想让模型被它们过度影响MAE也是个不错的选择。6. 学习率寻优找到模型训练的“最佳节奏”确定了损失函数下一个关键超参数就是学习率。你可以把它想象成“学习步长”。步长太大容易在山谷两边反复横跳甚至发散损失变成NaN步长太小下山速度慢如蜗牛训练时间会很长。我设计了一个实验让学习率从1开始以10为步长一直增加到191观察模型训练100轮后的最终RMSE。结果画成图是一个典型的“U型曲线”。学习率 vs. 最终训练RMSE (部分数据) 学习率1 - RMSE: 0.709 学习率11 - RMSE: 0.138 学习率21 - RMSE: 0.128 学习率31 - RMSE: 0.125 ... 学习率141 - RMSE: 0.120 (最低点) 学习率151 - RMSE: 0.127 学习率191 - RMSE: 0.119看到规律了吗当学习率从1增加到141的过程中模型性能RMSE降低稳步提升。在141附近达到最佳点。之后继续增大到151性能反而开始下降。学习率1时步长太小模型还没学到什么东西100轮就结束了学习率191时虽然最终RMSE看起来更低一点但训练过程已经非常不稳定损失曲线波动剧烈这只是运气好停在了某个低点并不可靠。我的经验对于Adam优化器学习率设置在0.01到0.1之间通常是个安全的起点。像我们实验中11到141对应0.011到0.141这个范围表现良好也印证了这一点。千万不要盲目使用默认学习率花一点时间做这样一个简单的扫描实验收益巨大。7. 优化器选择Adam 还是 SGD一个经典的选择题优化器决定了模型参数更新的具体方式。我对比了当今最流行的Adam和传统但经典的SGD随机梯度下降。为了控制变量我使用了上一节找到的较优学习率11其他设置不变。结果有点令人惊讶但也情理之中Adam优化器最终训练RMSE稳定在0.138左右。SGD优化器我遇到了梯度爆炸问题训练损失很快变成NaN。即使我把学习率从11大幅调低到0.01并加上了梯度裁剪clip_grad_norm_SGD最终的RMSE仍然高达5.665远差于Adam。为什么差距这么大SGD就像一个固执的登山者只沿着当前最陡的方向走固定的一步。而Adam则更聪明它结合了动量保持前进惯性和自适应学习率为每个参数单独调整步长在复杂地形中能更快更稳地找到下山路。对于像我们这种特征经过独热编码后维度很高300的问题Adam的自适应特性优势非常明显。所以对于大多数现代深度学习任务Adam通常是默认的首选。SGD虽然理论性质好但在实际应用中需要精心调整学习率衰减策略和动量参数对新手不太友好。这次实验让我再次确认了这一点。8. 权重初始化给模型一个更好的“起点”模型训练开始前权重参数需要被赋予初始值。不同的初始化方法可能会影响模型收敛的速度和最终结果。我测试了四种常见的方法正态分布初始化 (Normal)从均值为0标准差为0.01的正态分布中采样。Xavier初始化为了让每一层输出的方差保持一致而设计适合搭配Sigmoid、Tanh等激活函数。Kaiming He初始化专为ReLU激活函数设计解决了ReLU导致输出方差收缩的问题。常数初始化 (Constant)简单粗暴地把所有权重都设成同一个很小的常数如0.01。在我们的线性回归模型没有隐藏层相当于没有激活函数上训练100轮后结果如下初始化方法最终训练RMSE正态分布 (Normal)5.1571Xavier5.1570Kaiming He5.1567常数初始化5.1568四者的结果相差极小仅在万分位上略有不同Kaiming He初始化以极其微弱的优势“胜出”。这个实验说明对于简单的线性模型初始化方法的影响微乎其微。因为模型结构简单没有深层网络中的梯度消失或爆炸问题。但是当你未来搭建深层神经网络时初始化就变得至关重要。一个好的初始化如Kaiming He for ReLU能让你训练得更快更稳。所以虽然这次区别不大但养成正确初始化的习惯很重要。9. 训练轮数与K折交叉验证寻找“恰恰好”的停止点训练轮数Epochs太少模型学得不充分太多又可能导致过拟合浪费算力。我测试了从1到2000轮的不同设置。发现一个有趣的现象在100轮之后训练损失的下降就微乎其微了曲线几乎变成一条水平线。这说明我们的模型在100轮左右就已经收敛了。继续训练到2000轮RMSE几乎没有进一步改善反而要消耗几十倍的时间。这就是早期停止Early Stopping策略的理论基础当验证集损失不再下降时就提前终止训练。对于这个项目设置100-200轮是一个性价比很高的选择。接下来是K折交叉验证。我们一直用训练集训练用训练集评估这其实是不严谨的无法知道模型面对新数据测试集时的真实表现。K折交叉验证把训练集分成K份轮流用其中K-1份训练1份验证最后取K次验证结果的平均值。这个平均值更能反映模型的泛化能力。我测试了K从2到10的变化。结果发现随着K值增大平均训练RMSE缓慢下降并趋于稳定。K5和K10的结果已经非常接近。K越大每次训练的样本数越少评估结果方差越小但偏差可能增大且计算成本剧增。对于这个规模的数据集K5或10是一个很好的平衡点。它既保证了评估的稳定性又不会让单次训练数据量缩水太多。10. 数据预处理的威力一次对比实验带来的震撼教育这是整个实验中最让我印象深刻的部分。我写了两套代码一套是经过我们前面所有步骤标准化、填充、编码的“精致料理”另一套是直接把原始数据转换成Tensor的“生啃”。然后用同样的模型和参数去训练。结果对比堪称惨烈经过预处理的数据最终训练RMSE轻松降到0.138。未经任何处理的原始数据最终训练RMSE高达0.571而且训练曲线震荡剧烈收敛极慢。我把两条训练曲线画在同一张图上蓝色线处理后一路平滑下降红色线原始数据则在高位剧烈抖动缓慢爬行。这个视觉冲击力极强的对比实实在在地告诉你在机器学习中数据和特征决定了性能的上限模型和算法只是逼近这个上限。如果数据一团糟再厉害的模型也无力回天。原始数据的问题出在哪一是量纲不统一几千的占地面积和个位数的房间数让模型无所适从二是缺失值PyTorch Tensor无法直接处理NaN三是字符串类别模型根本无法计算。预处理就是解决这些问题的“数据翻译官”。11. 常见“坑点”与调试心得做项目不可能一帆风顺我也踩了一路的坑。这里分享两个最典型的错误和我的解决办法希望能帮你节省时间。第一个坑数据类型错误。在尝试处理原始数据时我直接torch.tensor(raw_features.values)结果报了TypeError: cant convert np.ndarray of type numpy.object_。这是因为原始数据里混着数字和字符串Pandas默认用object类型存储无法直接转Tensor。解决方法先用pd.to_numeric(errorscoerce)强制转换errorscoerce会把转换失败的如字符串变成NaN然后再用fillna(0)填充。或者更根本的方法是做好前面的类别特征编码。第二个坑梯度爆炸。在使用SGD优化器时训练损失突然变成NaN。这是因为某个参数的梯度变得极大更新后参数值溢出导致后续计算全部失效。解决方法梯度裁剪在optimizer.step()之前加一行torch.nn.utils.clip_grad_norm_(net.parameters(), max_norm5.0)把梯度向量的模长限制在5以内。降低学习率这是最直接有效的方法将学习率调低1-2个数量级试试。检查数据是否有异常值预处理是否到位我们之前遇到的原始数据问题就容易引发梯度爆炸。调试代码没有捷径就是耐心地加print语句看数据形状、看张量值、看损失变化。遇到报错把错误信息完整地复制到搜索引擎里十有八九能找到答案。12. 完整代码与最终模型经过上面一系列的对比实验我们找到了这个任务上的一套相对优秀的配置损失函数MSE优化器Adam学习率11 (或0.011)初始化Kaiming He (或正态分布)训练轮数100-200轮数据预处理必须做下面就是整合了所有最佳实践的“最终版”训练与预测代码。你可以用它作为模板生成提交到Kaggle的预测文件。import pandas as pd import torch import torch.nn as nn import torch.optim as optim from sklearn.model_selection import KFold import numpy as np # 1. 数据加载与预处理 def load_and_process_data(train_path, test_path): train_data pd.read_csv(train_path) test_data pd.read_csv(test_path) train_labels train_data.SalePrice # 合并特征 features pd.concat([train_data.drop([SalePrice, Id], axis1), test_data.drop([Id], axis1)]) # 标准化数值特征 numeric_features features.dtypes[features.dtypes ! object].index features[numeric_features] features[numeric_features].apply(lambda x: (x - x.mean()) / (x.std())) # 填充缺失值并独热编码 features pd.get_dummies(features.fillna(0), dummy_naTrue) # 转Tensor n_train train_data.shape[0] train_features torch.tensor(features[:n_train].values, dtypetorch.float32) test_features torch.tensor(features[n_train:].values, dtypetorch.float32) train_labels torch.tensor(train_labels.values, dtypetorch.float32).view(-1, 1) return train_features, test_features, train_labels, test_data[Id] # 2. 定义模型与评估函数 def get_net(feature_num): net nn.Linear(feature_num, 1) for param in net.parameters(): nn.init.kaiming_uniform_(param) # 使用Kaiming He初始化 return net def log_rmse(net, features, labels, loss_fn): with torch.no_grad(): clipped_preds torch.clamp(net(features), 1, float(inf)) rmse torch.sqrt(loss_fn(clipped_preds.log(), labels.log())) return rmse.item() # 3. K折交叉验证训练 def k_fold_train(k, train_features, train_labels, num_epochs, lr, weight_decay, batch_size): kf KFold(n_splitsk, shuffleTrue, random_state42) fold_scores [] for fold, (train_idx, val_idx) in enumerate(kf.split(train_features)): print(fFold {fold1}/{k}) X_train, X_val train_features[train_idx], train_features[val_idx] y_train, y_val train_labels[train_idx], train_labels[val_idx] net get_net(X_train.shape[1]) optimizer optim.Adam(net.parameters(), lrlr, weight_decayweight_decay) loss_fn nn.MSELoss() dataset torch.utils.data.TensorDataset(X_train, y_train) data_iter torch.utils.data.DataLoader(dataset, batch_size, shuffleTrue) for epoch in range(num_epochs): for X, y in data_iter: l loss_fn(net(X), y) optimizer.zero_grad() l.backward() optimizer.step() val_rmse log_rmse(net, X_val, y_val, loss_fn) fold_scores.append(val_rmse) print(f Val RMSE: {val_rmse:.4f}) print(f平均RMSE: {np.mean(fold_scores):.4f} (±{np.std(fold_scores):.4f})) return np.mean(fold_scores) # 4. 主程序 if __name__ __main__: # 参数设置采用实验得出的较优组合 num_epochs 150 lr 0.011 weight_decay 0.001 batch_size 64 k_folds 5 # 加载数据 train_f, test_f, train_l, test_ids load_and_process_data(train.csv, test.csv) # K折交叉验证评估 avg_rmse k_fold_train(k_folds, train_f, train_l, num_epochs, lr, weight_decay, batch_size) # 用全量数据训练最终模型 final_net get_net(train_f.shape[1]) optimizer optim.Adam(final_net.parameters(), lrlr, weight_decayweight_decay) loss_fn nn.MSELoss() dataset torch.utils.data.TensorDataset(train_f, train_l) data_iter torch.utils.data.DataLoader(dataset, batch_size, shuffleTrue) for epoch in range(num_epochs): for X, y in data_iter: l loss_fn(final_net(X), y) optimizer.zero_grad() l.backward() optimizer.step() # 预测并保存 with torch.no_grad(): preds final_net(test_f).numpy() submission pd.DataFrame({Id: test_ids, SalePrice: preds.flatten()}) submission.to_csv(my_submission.csv, indexFalse) print(预测结果已保存至 my_submission.csv)把这份代码跑通你就能得到一个在本地验证集上RMSE大约在0.12-0.14左右的模型并且可以生成符合Kaggle提交格式的CSV文件。这只是一个起点你可以在此基础上尝试更复杂的模型如XGBoost、LightGBM或简单的神经网络或者进行更精细的特征工程向更高的排名发起挑战。机器学习项目的乐趣就在于这不断的实验、调试和优化之中。希望这份详细的复盘能成为你实战路上的一块有用的垫脚石。