☰
线性回归与波士顿房价预测:原理、实现与避坑指南
2026/10/18 12:48:59 网站建设 项目流程

简介:这是一份基于线性回归实现波士顿房价预测的Python源码大作业,面向机器学习初学者、高校学生及需要完成课程设计或期末大作业的开发者。项目已获导师指导并取得97分高分,下载后无需修改即可运行,可直接用于课程设计与期末答辩。资源包共5个文件,含两个Python脚本、一张结果展示图片、一份说明文档及配置文件。Python脚本分别实现批量梯度下降与小批量梯度下降算法,涵盖数据导入、训练集与测试集划分、归一化、参数初始化、损失计算、梯度更新、迭代训练、测试集预测及可视化拟合效果等完整流程;说明文档对不同模块代码逻辑作了解释,便于学习与扩展。压缩包仅122KB,轻量易用。目前已有1455人学习下载。若希望快速掌握线性回归与梯度下降的实际编码实现,这份高分项目可直接参考复用。

1. 线性回归 + 波士顿房价预测:为什么大作业都选它

基于线性回归实现波士顿房价预测,这一题几乎出现在每个入门机器学习课程的大作业清单里。它样本量不大、特征明确,看起来闭着眼睛都能写完,但真正从零开始自己跑一遍的人,十个里有三四个会卡在数据加载、归一化和梯度爆炸这些环节。这篇笔记把完整源码的思路拆开讲:从特征与房价之间的线性假设,到数据预处理,再到手写梯度下降并与工具库结果做对比验证,最后集中踩一遍最常见的几个坑。如果你想要一份能跑、能讲、还能经得起追问的大作业,按这条路径走基本不会出错。

2. 先把原理拆明白:线性回归凭什么能预测房价

2.1 十三个特征和一个目标:预测问题的输入输出怎么对齐

波士顿房价数据集里,每一条样本对应城市某个区域的一组统计信息,目标值是该区域房屋价格中位数。特征一共 13 个,覆盖了犯罪率、住宅用地比例、一氧化氮浓度、每栋住宅平均房间数、到就业中心距离等维度。下面这张表挑了五个影响最直观的,你能快速建立起「特征长什么样」的直觉。

特征缩写含义
RM每栋住宅的平均房间数
LSTAT该区域低收入人口占比
PTRATIO该区域师生比
DIS到波士顿就业中心的加权距离
TAX每万美元房产的税率

这个任务的本质,是找到一个从 13 维特征向量到房价的映射关系。线性回归做了一个很强的假设:房价和每一个特征之间是线性组合的关系,也就是 y = w₁x₁ + w₂x₂ + … + w₁₃x₁₃ + b。w 是特征权重,b 是截距。所谓「训练模型」,就是确定一组 w 和 b,让预测值和真实房价的平均差距尽可能小。

动手之前先验证一下线性假设是否成立。我习惯把强相关的特征和房价画成散点图,肉眼看一眼分布:

import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("housing.csv") # 加载本地数据 target = df.iloc[:, -1] # 最后一列是房价中位数 fig, axes = plt.subplots(1, 3, figsize=(12, 3)) for ax, col in zip(axes, ["RM", "LSTAT", "PTRATIO"]): ax.scatter(df[col], target, alpha=0.5) ax.set_xlabel(col) ax.set_ylabel("Price") ax.set_title(f"{col} vs Price") plt.tight_layout() plt.show()

这段代码用前三个最典型的特征画散点图。alpha=0.5 让重叠的点显示为深色,避免样本点过多时看不出分布趋势。跑完之后你会看到 RM 与房价明显正相关、LSTAT 与房价明显负相关,说明线性模型的起点是站得住的。

再看一遍量化的相关系数,作为肉眼观察的补充:

corr = df.corr().iloc[:-1, -1].sort_values() print(corr)

输出结果里,RM 的相关系数通常接近 0.7,LSTAT 接近 -0.7,属于强相关。有了这一步,后面的训练才不是盲目的。

2.2 最小二乘与梯度下降:这套源码选哪条路更合理

线性回归的参数求解有两条经典路线。一条是最小二乘的闭式解,直接令损失函数对 w 的导数为零,得到 w = (XᵀX)⁻¹Xᵀy。另一条是梯度下降,从一组初始参数出发,沿着损失函数下降最快的方向迭代更新。从工程效率看闭式解一行代码就能出结果,但大作业我并不推荐只写闭式解。原因很简单:闭式解拿不出「训练过程」,答辩时导师问你模型怎么学的,你只能答「算了个矩阵」,而不是展示一段损失逐渐下降的曲线。

梯度下降才是更适合这份源码的路线。它把「学习」变成了可视化的迭代过程:每次算一次损失,求一次梯度,更新一次参数。损失函数用均方误差 MSE,公式写出来就是所有样本预测误差平方的平均值。梯度是损失函数对各参数的偏导数,在向量化实现里就是一步矩阵乘法。

梯度下降有一个关键前提:特征必须做归一化。如果 RM 的取值范围是 3 到 9,TAX 的取值范围是 200 到 700,这两个维度上梯度的尺度差出两个数量级,更新步长会被大数值特征主导,小数值特征几乎学不动。把每个特征减去均值、除以标准差之后,所有维度都在同一个尺度上,梯度下降才能稳定收敛。

2.3 为什么是线性回归而不是决策树或神经网络

拿到这份大作业,有人会犹豫:直接上随机森林或者三层神经网络,分数会不会更高?从结果看,树模型在非线性特征交互上确实可能略好,但大作业考察的重点不是刷分,而是「你是否理解了一个模型从假设到求解的完整链路」。线性回归的可解释性是最强的:每个特征的权重就是它对房价的边际影响,你可以指着coef_说出「房间数每增加一个标准差,房价大约上升多少」,这是树模型和神经网络很难做到的。

另一个现实原因是,它是最合适的基线模型。先用线性回归跑出一个 RMSE 和 R²,再尝试岭回归、多项式回归,每一步提升都能验证自己的理解。直接上黑匣子模型,万一效果不理想,你甚至不知道是数据问题还是模型问题。线性回归把这条调试路径铺得最平。

3. 数据准备这步做不好,后面全白费

3.1 加载数据集并校验:先确认数据量、缺失值和特征列

训练模型之前,先花两分钟搞清楚数据长什么样。不同来源的 CSV 列名大小写不一致,与其硬编码列名,不如打印出来动态确认。我用 pandas 读入之后,连续输出三样东西:形状、列名、每列缺失值数量。

import pandas as pd df = pd.read_csv("housing.csv") print("数据形状:", df.shape) print("列名:", df.columns.tolist()) print("缺失值:\n", df.isnull().sum())

这一步的输出结果会直接影响后面的处理:shape 确认样本量和特征数是否符合预期,列名确认最后一列是不是目标值,缺失值统计则决定要不要做填充。波士顿房价数据集本身没有缺失值,但换一个来源的 CSV 就未必了。如果发现某几列缺失比例超过 5%,最常见的处理是用中位数填充,因为中位数对异常值不敏感,比均值更稳。

我用df.iloc[:, :-1]取特征矩阵、df.iloc[:, -1]取目标值,而不是写df["MEDV"]的硬编码。原因是不同渠道下载的 CSV,目标列可能叫medv、MEDV、PRICE,位置索引可以绕开命名差异。

X = df.iloc[:, :-1].values y = df.iloc[:, -1].values print(X.shape, y.shape)

.values把 DataFrame 转成 NumPy 数组,后面做矩阵乘法直接可用。这一步转换经常被省略,结果手写梯度下降时报数据类型错误,回头检查才发现传进去的是 DataFrame。

3.2 特征归一化与训练/测试划分:三个必写步骤

模型训练前有三步是固定动作:特征缩放、划分训练集和测试集、把特征转成浮点类型。Python 里整数特征参与梯度计算时经常出现类型溢出,先统一转 float 更省事。

import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)

test_size=0.2意味着 506 条样本里约 101 条留作测试。random_state=42固定随机种子,让每次运行划分结果一致,这是保证作业可复现的关键。不固定的话,每跑一次 RMSE 都不一样,你没法判断是模型改好了还是数据划分变了。

StandardScaler 会把每个特征变成均值 0、标准差 1。注意第三行用的是fit_transform,第四行用的是transform:训练集负责拟合均值和标准差,测试集直接复用同一组参数转换。这一步是很多人的翻车点,后文避坑部分会展开讲。

3.3 归一化时最容易犯的错:测试集数据泄露

有一种错误写法是:先对整个数据集做归一化,再划分训练集和测试集。看着结果差不多,实际上已经造成了数据泄露。测试集的均值、标准差被混进了训练过程,模型在评估时「偷看」了测试集的分布信息,测试指标会比真实水平乐观一点。

正确的顺序是先划分、后缩放。我在上面的代码里已经按这个顺序写了。另一个隐藏问题出在数据集本身的排列顺序上:如果原始数据按区域排列,直接取前 80% 做训练、后 20% 做测试,测试集会集中在特定地理片区,评估结果失去代表性。train_test_split默认会随机打乱数据,这一步不要关闭。

X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, shuffle=True )

把shuffle=True显式写出来,既是给自己看,也是给答辩老师看:你考虑过样本顺序可能带来的偏差,并且主动规避了。

4. 手写线性回归类,再和工具库结果对比验证

4.1 基于 NumPy 实现梯度下降的线性回归类

手写线性回归类是大作业源码的核心。这一版我用了向量化实现,把循环交给 NumPy,训练 5000 轮只需要一瞬间。代码不长,但每一行的作用都要能讲出来。

import numpy as np class LinearRegressionGD: """ 基于梯度下降的线性回归 参数: lr: 学习率 n_iter: 最大迭代次数 """ def __init__(self, lr=0.01, n_iter=2000): self.lr = lr self.n_iter = n_iter self.theta = None # 参数向量,第一位是截距 self.loss_history = [] # 每轮的损失,用于画曲线 def fit(self, X, y): m, n = X.shape X_b = np.c_[np.ones((m, 1)), X] # 在X前面加一列1,对应截距 self.theta = np.zeros(n + 1) # 全零初始化 for _ in range(self.n_iter): y_pred = X_b @ self.theta error = y_pred - y grad = (1 / m) * (X_b.T @ error) self.theta = self.theta - self.lr * grad loss = (1 / m) * np.sum(error ** 2) self.loss_history.append(loss) return self def predict(self, X): X_b = np.c_[np.ones((X.shape[0], 1)), X] return X_b @ self.theta

np.c_[np.ones((m, 1)), X]是给特征矩阵左边拼接一列全 1,这样截距 b 可以被统一收进 theta 向量里,矩阵运算不用单独处理。梯度表达式(1/m) * X_b.T @ error来自 MSE 对参数的求导,向量化之后一步算完所有维度的梯度。lr决定每一步走多远,归一化后的数据取 0.01 比较稳,取 0.1 可能震荡。

所有参数全零初始化的做法在线性回归里是安全的,因为损失函数是凸函数,只有一个全局最小值,从零开始和从随机数开始最终落到同一处。这一点和神经网络截然不同,答辩时常被问到,提前想清楚。

4.2 使用工具库线性回归验证手写结果

手写代码的意义在于理解原理,但结果对不对需要对照。scikit-learn 的LinearRegression用了最小二乘闭式解,精度更高、没有收敛问题,是标准答案。

from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score # 手写模型 gd_model = LinearRegressionGD(lr=0.01, n_iter=2000) gd_model.fit(X_train, y_train) manual_pred = gd_model.predict(X_test) # 工具库模型 lr = LinearRegression() lr.fit(X_train, y_train) sk_pred = lr.predict(X_test) # 统一用 RMSE 和 R2 衡量 manual_rmse = np.sqrt(mean_squared_error(y_test, manual_pred)) sk_rmse = np.sqrt(mean_squared_error(y_test, sk_pred)) print(f"手写模型 RMSE: {manual_rmse:.4f}") print(f"工具库 RMSE: {sk_rmse:.4f}") print(f"工具库 R2: {r2_score(y_test, sk_pred):.4f}")

运行结果里,手写模型和工具库的 RMSE 应该非常接近,差距在 0.1 以内才正常。差距过大说明某一环节有问题,最常见的是学习率太大没收敛,或者迭代次数不够。我一般会把差距小于 0.05 当作手写实现正确的判据。

这里有个细节值得说:目标值 y 没有参与标准化,所以 RMSE 的单位是千美元,大约 4 到 5 意味着平均预测误差在 4000 到 5000 美元,相对房价中位数 20 多千美元来说已经是不错的结果。R² 一般在 0.7 以上,意味着模型能解释七成以上的房价波动。

4.3 三张图说清楚模型效果:损失曲线、预测对比与残差图

代码跑通只是第一步,大作业的分数差距往往体现在你是否理解模型的训练过程。三张图足够讲清楚全部故事。

import matplotlib.pyplot as plt # 第一张:训练损失曲线 plt.figure(figsize=(6, 4)) plt.plot(gd_model.loss_history) plt.xlabel("Iteration") plt.ylabel("MSE") plt.title("Training Loss Curve") plt.show() # 第二张:真实房价 vs 预测房价 plt.figure(figsize=(6, 4)) plt.scatter(y_test, sk_pred, alpha=0.6) plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], "r--") plt.xlabel("True Price") plt.ylabel("Predicted Price") plt.title("True vs Predicted") plt.show() # 第三张:残差图 residuals = y_test - sk_pred plt.figure(figsize=(6, 4)) plt.scatter(sk_pred, residuals, alpha=0.6) plt.axhline(0, color="r", linestyle="--") plt.xlabel("Predicted Price") plt.ylabel("Residual") plt.title("Residual Plot") plt.show()

损失曲线应该在前几百轮快速下降,然后趋于平缓。如果曲线一直在震荡,学习率偏大;如果还是单调下降但斜率很缓,说明迭代次数不够。真实对预测图里,点越贴近对角线越好,但房价超过 35 千美元后会出现系统性的低估,这是线性模型的边界,不是 bug。残差图如果出现明显的喇叭形,说明预测值越大、误差波动越大,这是后续引入岭回归或对数变换的动机。

这三张图直接截图进大作业报告,比几百字描述有说服力得多。

5. 五个避坑指南:从数据加载到画图的中文乱码

5.1 数据集在本地加载失败

现象:pd.read_csv("housing.csv")报 FileNotFoundError,或者从某个远程地址拉取数据时连接超时。
原因:数据文件的路径不在当前工作目录,或者引用的远程数据源不再提供服务。大作业提交时数据文件是随代码一起打包的,路径写死相对路径后换机器就会失效。
解决:把 CSV 和源码放在同一级目录,用os.path.join(os.path.dirname(__file__), "housing.csv")这种相对当前文件定位的方式。提交 zip 前先换一个目录结构完整跑一遍,确认路径没写死。

5.2 梯度爆炸:损失直接变成 NaN

现象:运行不到几轮,loss_history里出现 inf,再往后全是 nan。
原因:学习率太大,参数更新一步跨过了最优点,误差越来越大,最终数值溢出;另一种可能是特征没有归一化,梯度在某些维度上尺度巨大。
解决:先把lr从 0.01 降到 0.001 重跑一次;如果还是 NaN,回第 3 章检查StandardScaler是否作用到了训练数据。我调试时习惯在 fit 里打印前三个loss_history值,如果第一轮就是超大数字,几乎可以断定是学习率或数据尺度问题。

5.3 手写结果与工具库结果相差很大

现象:manual_rmse和sk_rmse相差超过 1,甚至手动实现完全收敛不了。
原因:最常见的是训练集和测试集划分不一致,手写模型用了标准化前的数据、工具库用了标准化后的数据;其次是梯度公式写错,梯度符号方向反了,参数在朝损失增大的方向更新。
解决:把两组模型喂给完全相同的X_train、y_train、X_test,先检查数据是否同一份。再看损失曲线是否下降到平台期,如果已经平了,手写结果必然接近工具库。我一般会在手写模型里加一个断言,训练结束后和工具库的coef_直接相减,差值小于 0.01 才继续往下走。

5.4 matplotlib 画图中文全是方框

现象:图标题、坐标轴标签里的中文全部显示成空心方块。
原因:matplotlib 默认字体不含中文字符,系统在字体库里找不到对应的字形。
解决:在画图代码前加上两行配置,指定一个系统中文字体。

plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False

axes.unicode_minus设置为 False 是保证负号显示正常,不然坐标轴上的负号也会变方框。这个坑在最关键的残差图里出现率最高,图都画好了才发现中文没显示,重新渲染一遍浪费时间。

5.5 不固定随机种子导致每次跑分不一样

现象:同一个 notebook 连续跑两次,RMSE 和 R² 都不一样,浮动有时超过 0.1。
原因:train_test_split默认随机划分数据,没有固定随机种子时每次执行都会重新洗牌。
解决:random_state=42写进划分函数,并在代码注释里说明这是刻意为之。另一个隐藏点在于,如果用了任何带随机性的库,比如特征选择里的随机逻辑,也需要固定其random_state。保持结果完全可复现,是工程习惯,也是答辩时不给自己挖坑的保障。

6. 从「能跑」到「像样」:源码文件组织与三个加分项

6.1 源码文件怎么组织更利于答辩

大作业交付的不只是一段能跑的代码,还有「别人能不能快速看懂」。我习惯把代码拆成三个文件而不是一个脚本写到底。

. ├── housing.csv ├── model.py └── train_and_evaluate.py

model.py放线性回归类的定义,属于可复用代码;train_and_evaluate.py负责加载数据、训练、评估和画图。housing.csv和数据文件放在同一目录,保证相对路径可移植。这样做最大的好处是,答辩时导师问「模型在哪」你能直接指出类定义,问「结果怎么来的」你指向评估脚本,不会在一个 300 行的文件里翻半天。

6.2 三个低成本加分扩展

第一个扩展是岭回归对比。在线性回归基础上加 L2 正则化,模型会主动压缩不重要的特征权重,和普通线性回归的系数放一起对比,能讲出「正则化如何抑制过拟合」这一层。代码只有三行。

from sklearn.linear_model import Ridge ridge = Ridge(alpha=1.0) ridge.fit(X_train, y_train)

第二个扩展是特征相关性热力图。用df.corr()配合 seaborn 的heatmap输出全部 14 个维度的相关性矩阵,报告里放一张图,能直观展示哪些特征彼此高度相关,为讨论「多重共线性是否影响权重解释」提供素材。第三个扩展是把训练过程跑 5 次,每次用不同随机种子,统计 RMSE 的均值和标准差。结果稳定说明模型可靠,结果波动大则说明数据划分敏感,讨论空间更大。

最后说个我自己的习惯:每次把代码压缩成 zip 交出去之前,先在另一台干净的机器上从零跑一遍。大部分大作业翻车不是代码逻辑问题,而是环境问题——你本机跑得飞快,换一台机器就缺包。把数据和依赖整理好,这份源码才算真正交付。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询