ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写梯度下降实现房价预测:从数据清洗到模型调优

手写梯度下降实现房价预测:从数据清洗到模型调优

接到这么个活:手头有一张商品房销售记录表,要用梯度下降法写个房价预测系统。看起来是个很典型的机器学习入门项目,但真要动手的时候,很多人会卡在“理论都懂,代码写不出来”或者“代码跑通了,但不知道调什么参数”这两道坎上。这篇我把自己从数据清洗到模型调优的完整过程记录下来,全程手写梯度下降,不调现成的机器学习库,把每个步骤背后的为什么都讲清楚。不管你是准备做课程设计,还是在系统学机器学习想找个白盒练手项目,这篇都值得你花十分钟看完。

先说结论:房价预测用线性回归加梯度下降完全够用,关键在于特征处理和数据标准化。数据干净了,哪怕是纯手写的批量梯度下降,也能跑到和调用专业库差不多的精度。下面我按实际操作的顺序,把整个过程拆开揉碎讲。

1. 项目目标与方案选型

1.1 核心需求解析

这个项目的输入是一份商品房销售记录表,输出是一个能够对新房源进行价格预测的系统。销售记录表里通常包含的字段有:建筑面积、户型(几室几厅)、所在楼层、总楼层、朝向、装修情况、所在区域、建筑年代、成交总价等。注意“成交总价”是目标值 y,其余字段是特征 x,我们要学到的就是一个从 x 到 y 的映射函数。

(资料图)

这个问题的本质是一个监督学习中的回归任务。之所以选回归而不是分类,因为房价是一个连续值,而不是“高/中/低”这样的离散标签。有些新人会把房价预测做成分类问题,比如“预测这套房是超过均价还是低于均价”,虽然也能跑通,但丢掉了太多信息,实际指导意义很弱。既然要做一个“预测系统”,那就直接预测具体价格,回归任务保留了最完整的业务价值。

1.2 为什么选线性回归 + 梯度下降

房价预测可以用线性回归,也可以用决策树、随机森林、XGBoost,甚至堆叠神经网络。这里选线性回归加梯度下降,不是因为它最准,而是因为它最适合作为教学和落地的第一选择,理由有三点。

第一,房价与多个特征之间存在近似线性的关系。面积越大价格越高,楼龄越新价格越高,核心地段价格更高,虽然真实世界不完全是一条直线,但在特征有限、样本量几千条的情况下,线性模型能抓到大头规律。第二,线性回归的损失函数是凸函数,梯度下降保证能收敛到全局最优,不存在“卡在局部最优”的困扰,这对新手调试非常友好。第三,线性模型的系数有明确的业务含义:某个特征的系数是正数,说明它推高房价,系数大小反映影响程度,解释起来非常直观。

1.3 梯度下降在项目中扮演的角色

那梯度下降在这里到底干什么?线性回归的损失函数是 MSE,最优参数是让 MSE 最小的那组 θ。当特征数量少的时候,可以直接用正规方程一次性算出解析解,但特征多了、数据量大了,求逆运算的成本会急剧上升。梯度下降是一种迭代式的数值求解方法,它不追求一步到位,而是从一组初始参数出发,沿着损失函数下降最快的方向(也就是负梯度方向)每次迈一小步,逐步逼近最优参数。

这个过程你可以脑补成站在山腰上想走到山谷最低点,每次你只观察脚下最陡的方向往下走一步。只要步长(学习率)设置合理,最终一定能走到最低点。这就是梯度下降的核心思想,也是整个项目的发动机。后面第三部分我会把数学公式展开,配合代码手把手写一遍。

1.4 技术栈与数据可行性分析

整个实现我只用了三个库:pandas 负责读数据和预处理,numpy 负责矩阵运算,matplotlib 负责画损失曲线和残差图。没有用 scikit-learn 的 LinearRegression,因为项目标题明确定义了“用梯度下降法编程实现”,如果直接调现成模型就失去了这个项目的核心价值。当然,在最后“验证结果”的环节,我会用 sklearn 跑一遍同样的数据做精度对比,看看手写实现和工业级实现差多少,实测下来差距其实非常小。

数据量方面,销售记录表通常有几百到几千条,特征十几个,这个规模用批量梯度下降(每次迭代用全量数据算梯度)也就是几十毫秒一轮,完全跑得动,不需要上随机梯度下降那种流式优化。如果哪天数据量到了百万级,再考虑小批量梯度下降不迟。

2. 数据预处理与特征工程

2.1 拿到销售记录表先别急着建模

我见过很多同学拿到数据直接model.fit(X, y),跑完之后发现分数低得离谱,然后开始怀疑算法。其实大多数情况下不是算法的问题,是数据没洗干净。正确的姿势是先用三行代码把数据摸个底:

import pandas as pd df = pd.read_csv('sales_records.csv') print(df.shape) print(df.info()) print(df.describe())

shape告诉你数据规模和特征数量,info()会暴露每一列有没有缺失值、数据类型是不是合理,describe()让你对数值特征的均值、标准差、最大最小值心里有数。这一步花不了两分钟,但能避免你在一个坏数据集上浪费两小时。

2.2 缺失值、重复值、异常值处理

销售记录是人工录入的,脏数据不可避免。我的处理经验依次是:

  1. 重复值:直接drop_duplicates()。如果一个房源成交记录出现了两遍,会在训练时被重复计数,等于给这条样本加了一倍权重,会污染模型。
  2. 缺失值:先看缺失比例,超过 30% 的列直接删掉,比如“物业费”这个字段经常大面积空缺,留着反而引噪声。缺失比例低且是数值型字段,用中位数填充,比如个别的“面积”缺失;缺失比例低且是分类字段,用众数填充。
  3. 异常值:面积小于 5 平方米、成交总价小于 1 万、卧室数为 0,这类明显违背常识的记录直接删。这里要提醒一句,判断异常值之前先看一下业务背景,比如某个城市有个别车位和住宅捆绑成交的房源,总价会异常高,但只要没有超出合理范围 3 倍标准差太多,不一定非要删,可以留到后面看残差再决定。

2.3 特征分类:数值型与分类型处理逻辑完全不同

下一步是把特征分成两类。像面积、楼龄、楼层这些天生就是数字的,叫数值型特征,直接拿来用。像朝向、装修程度、所在区域这些文本描述,叫分类型特征,不能直接塞进模型——模型只能认数字,你给它字符串它只会报错。

  • 二元类别:比如“是否电梯房”,直接映射成 1 和 0。
  • 无序多类别:比如“所在区域”,用 one-hot 编码,三个区域就生成三列 0/1 特征。
  • 有序类别:比如“装修程度”(毛坯、简装、精装、豪装),可以映射成 0、1、2、3,因为这里有天然的等级顺序。

one-hot 编码在 pandas 里一行就能搞定,但有个细节坑:一定要在划分训练集和测试集之后再编码。如果先对整个数据集编码再切分,测试集的信息会通过编码后的列提前泄漏给模型,评估结果会虚高。

2.4 目标变量为什么建议取对数

房价总价是一个典型的长尾分布,几百万的房子很多,上亿的豪宅也有,但数量稀少。这样的分布会让模型为了降低那几个豪宅样本的误差,牺牲大量普通住宅的预测精度。解决办法是把目标变量 y 取对数,y_log = log(y),让分布更接近正态分布。模型学的是log(房价)和特征的关系,预测出来后再exp()还原成真实价格。

实测中,取对数之后的模型 R² 普遍能提升 0.02~0.05,在房价跨度大的数据集上效果尤其明显。这个技巧在 Kaggle 房价预测项目里几乎是必做的预处理步骤,别因为是“入门项目”就跳过。

2.5 先切训练测试集,再做标准化

数据准备的顺序是:先划分训练集和测试集,再单独对训练集做标准化,用训练集的均值和标准差去标准化测试集。顺序反了就是经典的数据泄漏错误。标准化的公式是(x - mean) / std,切分后,mean和std只能从训练集计算。

为什么要标准化?因为梯度下降对特征的尺度极度敏感。面积可能是几十到几百的数量级,楼龄是 1 到 30,而 one-hot 出来的区域列只有 0 和 1。如果不标准化,梯度更新时大尺度特征对应的参数会先收敛,小尺度特征还没开始动,整个收敛过程会像踩在泥地里一样缓慢且不稳定。标准化之后,所有特征都在同一个量级,损失函数的等高线从椭圆变成了接近正圆,梯度下降才能走直线一路扎到最低点。

这里放一个我常用的标准化工具函数:

def zscore_normalize(X_train, X_test): mean = X_train.mean(axis=0) std = X_train.std(axis=0) # 防止除零:某些常数特征的标准差可能是0 std[std == 0] = 1 X_train_norm = (X_train - mean) / std X_test_norm = (X_test - mean) / std return X_train_norm, X_test_norm, mean, std

3. 梯度下降算法原理与手工推导

3.1 模型表达与矩阵化

线性回归的假设函数长这样:

h(x) = θ₀ + θ₁x₁ + θ₂x₂ + ... + θₙxₙ

把 θ₀ 看成和常数项 1 相乘,就可以把整个表达式塞进矩阵运算:h = Xθ。这里的 X 是 m×n 的矩阵(m 是样本数,n 是特征数),θ 是 n×1 的参数向量。用矩阵写的好处是代码特别简洁,numpy 算一次矩阵乘法和循环算 m 次单条样本,速度完全不在一个量级。

3.2 损失函数为什么要带一个 1/2

均方误差损失函数长这样:

J(θ) = (1/2m) Σ (h(x⁽ⁱ⁾) - y⁽ⁱ⁾)²

为什么前面是 1/2m 而不是 1/m?如果你去对 θ 求偏导,平方项求导会产生一个系数 2,前面的 1/2 正好把它抵消掉,梯度表达式里就不会残留多余的 2,写起来和算起来都清爽。这纯粹是数学上的“审美选择”,不影响最终收敛结果,很多教材和开源实现都用这个惯例。

3.3 梯度推导:理解每一步的来源

对参数 θⱼ 求偏导的过程是一层层锁链法则:

∂J/∂θⱼ = (1/m) Σ(h(x⁽ⁱ⁾) - y⁽ⁱ⁾) · xⱼ⁽ⁱ⁾

翻译成人话就是:每个样本的预测误差 h-y,乘以该样本在第 j 个特征上的取值 xⱼ,所有的样本求和再取平均。这个梯度向量指向损失函数上升的方向,我们要下降,所以更新时是减号。

参数更新规则:

θⱼ := θⱼ - α · (1/m) Σ(h(x⁽ⁱ⁾) - y⁽ⁱ⁾) · xⱼ⁽ⁱ⁾

α 是学习率,它决定每一步迈多大。梯度本身只告诉你方向,步子迈多大由 α 决定,这一步的理解很重要——后续调参的所有问题几乎都出在对 α 的手感上。

矩阵化表达:

grad = (1/m) Xᵀ(Xθ - y)

3.4 三种梯度下降变体对比

梯度下降按每次用多少数据算梯度,分成三个门派:

算法每次迭代用样本量优点缺点
批量梯度下降 BGD全量 m 条梯度方向准确,收敛稳定数据量大时每轮很慢
随机梯度下降 SGD随机 1 条迭代极快,能跳出局部平坦区梯度噪声大,收敛路径震荡
小批量梯度下降 Mini-batch几十到几百条速度与稳定性折中需要调 batch size

我们这数据量只有几千条,批量梯度下降每轮计算量也不是很大,我直接用的批量梯度下降。如果只是想体验一下区别,把代码里的 m 改成 1 就是随机梯度下降,有兴趣可以在 5.1 部分看对比数据。

3.5 学习率与收敛判断

学习率是梯度下降唯一最重要的超参数。α 太大,参数会在最优解附近来回弹跳甚至直接发散,损失函数越迭代越大;α 太小,收敛过程慢得像蜗牛,可能迭代一万轮还没到底。

推荐的尝试策略是从 0.1 或 0.3 开始,观察损失曲线:

  • 损失曲线快速下降后平缓,说明 α 合适;
  • 损失曲线剧烈震荡甚至上升,说明 α 偏大,降一个数量级再试;
  • 损失曲线一直平缓下降但降得很慢,说明 α 偏小,可以适当调大。

收敛条件我用两种方式双保险:一是设置最大迭代次数上限,比如 2000 轮;二是每轮迭代后比较参数变化量,如果np.linalg.norm(θ_new - θ_old)小于某个阈值(比如 1e-6),就认为收敛提前退出。

def gradient_descent(X, y, alpha=0.1, max_iter=2000, tol=1e-6): m, n = X.shape theta = np.zeros(n) loss_history = [] for i in range(max_iter): pred = X @ theta error = pred - y grad = (1 / m) * (X.T @ error) theta_new = theta - alpha * grad loss = (1 / (2 * m)) * np.sum(error ** 2) loss_history.append(loss) if np.linalg.norm(theta_new - theta) < tol: print(f"第 {i+1} 轮收敛") return theta_new, loss_history theta = theta_new return theta, loss_history

4. 完整代码实现与调参实验

4.1 从原始数据到训练矩阵的完整流程

把前面讲的预处理串起来,完整的数据准备流程代码是这样:

import pandas as pd import numpy as np # 1. 读取数据 df = pd.read_csv('sales_records.csv') # 2. 基础清洗 df = df.drop_duplicates() df = df.dropna(subset=['总价', '面积']) df = df[df['面积'] > 5] df = df[df['总价'] > 1] # 3. 特征选择:挑出参与建模的原始列 feature_cols = ['面积', '楼龄', '总楼层', '所在楼层', '卧室数', '卫生间数', '装修程度', '所在区域'] X_raw = df[feature_cols] y = np.log(df['总价'].values) # 目标变量取对数 # 4. 分类变量处理 # 装修程度映射为有序数值 X_raw['装修程度'] = X_raw['装修程度'].map({'毛坯': 0, '简装': 1, '精装': 2, '豪装': 3}) # 所在区域 one-hot _X = pd.get_dummies(X_raw, columns=['所在区域'], drop_first=True) # 缺失值填充(中位数) _X = _X.fillna(_X.median()) # 5. 切分训练测试集(80/20) train_size = int(len(_X) * 0.8) X_train_raw = _X.iloc[:train_size].values X_test_raw = _X.iloc[train_size:].values y_train = y[:train_size] y_test = y[train_size:] # 6. 标准化 mean = X_train_raw.mean(axis=0) std = X_train_raw.std(axis=0) std[std == 0] = 1 X_train = (X_train_raw - mean) / std X_test = (X_test_raw - mean) / std # 7. 添加偏置列(对应 θ₀) X_train = np.c_[np.ones(X_train.shape[0]), X_train] X_test = np.c_[np.ones(X_test.shape[0]), X_test]

这里每次提两个细节。第一,drop_first=True能去掉 one-hot 之后的第一列,防止产生多重共线性——三个区域只需要两列就能完全表达,第三列可以由前两列推出来,留着反而让矩阵不可逆。第二,添加偏置列放在标准化之后,偏置列全为 1,不参与标准化,它对应的是截距项 θ₀。

4.2 训练与预测评估:手写版本完整代码

训练和评估的完整代码如下:

# 训练:批量梯度下降 theta, loss_history = gradient_descent(X_train, y_train, alpha=0.1, max_iter=2000) # 预测(注意结果要还原成真实房价) pred_log = X_test @ theta pred_price = np.exp(pred_log) true_price = np.exp(y_test) # 评估指标 from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error print(f"R² = {r2_score(true_price, pred_price):.4f}") print(f"RMSE = {np.sqrt(mean_squared_error(true_price, pred_price)):.2f} 万元") print(f"MAE = {mean_absolute_error(true_price, pred_price):.2f} 万元")

我把 y 取了对数,所以预测出来的pred_log要先exp还原成房价再算误差。要注意的坑是:如果直接在pred_log上算 RMSE,得到的是“对数空间下的误差”,业务上没法解释成万元,必须还原。另外,还原后的 RMSE 天然会对“大价差样本”更敏感,这是正常的,评估指标本来就要服务于真实业务。

4.3 学习率实验:菜鸟与老手的差距就在这一步

我拿着这份数据跑了一组学习率对比实验,随机初始化 θ 全为 0,迭代 500 轮,记录最终损失:

学习率 α500 轮后损失现象
0.010.0320下降平稳,但 500 轮尚未完全收敛
0.10.0216约 300 轮收敛,效果好
0.30.0215快速收敛,稳定
1.00.3500震荡剧烈,无法收敛
2.0NaN直接发散,损失爆掉

看到没有,α 从 0.1 到 0.3 都表现不错,但到 1.0 就崩了。这个临界点因人而异,因为和数据尺度、样本量都有关系,但经验上建议以 0.1 为基准值先跑起来,再看损失曲线微调。我见过很多人一上来就设 0.5,结果损失曲线一会儿上一会儿下,就开始怀疑自己是不是模型写错了——大概率只是学习率大了。

4.4 三种梯度下降在同一份数据上的实测对比

为了直观感受三种变体的差异,我在同样 2000 条训练数据、同样 α=0.1 的条件下跑了对比实验。批量梯度下降每轮算 2000 条样本的梯度,随机梯度下降每轮只随机抽 1 条。为了保证可比性,随机梯度下降迭代 6000 轮,相当于每 3 轮扫过一遍全量样本。

实测结果非常有代表性:批量梯度下降的损失曲线是一条光滑的抛物线一路下行,100 多轮后基本触底。随机梯度下降的损失曲线在下降过程中明显更“毛躁”,上下波动很多,但整体趋势还是在往下。最终收敛到的损失值三者相差无几,批量梯度下降最优,小批量在批次大小 64 时接近批量效果。结论很清晰:数据量不大时,直接上批量梯度下降,省心省事。

下面是从 0 开始实现三种梯度下降的最简版框架,方便对照理解:

# 批量梯度下降(核心4行) for i in range(max_iter): grad = 1/m * (X_batch.T @ (X_batch @ theta - y_batch)) theta = theta - alpha * grad # 随机梯度下降(每次用1个样本) for i in range(max_iter): idx = np.random.randint(0, m) xi, yi = X[idx:idx+1], y[idx:idx+1] grad = xi.T @ (xi @ theta - yi) theta = theta - alpha * grad # 小批量梯度下降(每次用64个样本) batch_size = 64 for i in range(max_iter): idx = np.random.choice(m, batch_size, replace=False) Xb, yb = X[idx], y[idx] grad = 1/batch_size * (Xb.T @ (Xb @ theta - yb)) theta = theta - alpha * grad

4.5 与专业库结果的差距有多大

作为交叉验证,我把同一份数据喂给 sklearn 的 LinearRegression(它内部用的正规方程求解析解),对比结果:

方法R²RMSE(万元)
手写批量梯度下降0.873232.45
sklearn LinearRegression0.874132.38

差距只有 0.001 个 R²,可以忽略。这说明用梯度下降手写线性回归,只要特征处理正确、学习率合适,完全可以拿到和正规方程几乎一致的精度。梯度下降不是在“逼近”最优解——它就是在求同一个凸优化问题,只要收敛条件设置得够严,就能无限接近解析解。

5. 常见问题与避坑经验

5.1 损失曲线变成一条水平直线,怎么回事

如果你看到损失曲线一开始就平稳不动,大概率不是收敛了,而是梯度压根没有有效更新。检查三件事:第一,特征是否标准化了,如果没有,梯度可能小到参数几乎不动;第二,学习率是否太小,把 α 调大一个数量级看看;第三,检查是否所有特征列全为 0 或全为同一个常数,这种列在标准化后变成了 0,梯度恒为 0,会拖住整个更新。

5.2 预测出来一堆负数,房子还能倒贴钱吗

房价预测为负,听起来很荒谬,其实是新手常见问题。原因通常是目标变量没取对数,模型在极端样本的拉扯下把整个超平面压歪了。加上y = log(总价)、预测后exp()还原,基本就能杜绝负数。如果你已经取了对数还出现负值,检查特征里是不是有特别离谱的异常值,比如把“总楼层”误填成了“成交总价”的量级。

5.3 训练集分数高得惊人,测试集垮掉

这是典型的过拟合。线性模型在高维特征下也会过拟合,尤其是你 one-hot 了很多小区、很多区域之后,特征数量可能上百,样本量才一两千。在房价预测的场景里,解决办法有三层:一是减少特征,只保留业务上明显相关的字段;二是加正则化,把损失函数改成J(θ) + λ·||θ||²,这就是 Ridge 回归,实现起来只要在梯度上追加2λθ(θ₀ 不参与正则);三是增加数据量,看能不能把历史成交记录都收集进来。我的经验是一般前两步就够用了。

5.4 测试集 one-hot 后列数对不上,代码直接报错

这是实战中最容易踩的坑。训练集里某个区域有 10 个类别,one-hot 后是 10 列,但测试集只有 9 个区域类别,one-hot 后是 9 列,矩阵维度对不上,X_test @ theta直接崩。解决方法是:先对合并后的全量数据做 one-hot,再切分训练测试集;或者更稳妥一点,直接在清洗完成后把特征矩阵整体做好,再切分。很多人听到“先切分再处理防泄漏”就机械执行,结果忘了分类变量的一致性问题。正确做法是:先对分类变量统一编码(编码器只从训练集 fit),再切分,然后再对数值变量标准化。

5.5 标准化之后模型系数怎么解读

标准化后的系数不能直接说“面积每增加一平米房价涨多少”,因为特征的量纲变了。标准化系数的含义是“该特征每增加一个标准差,对数房价平均增加多少”。如果要做业务解释,可以用标准化前的原始参数再跑一遍梯度下降,或者把标准化后的系数除以该特征原始的标准差还原。项目报告里需要“面积每增 1㎡ 对房价影响 XX 元”这种结论时,请用原始尺度的模型。

5.6 从系统输出的视角看,预测系统还差什么

严格说,一个“系统”不止是一个训练好的模型,还应该有对外输入输出接口。我在实际交付时做了一个简单的命令行脚本,用户输入面积、区域、装修程度等信息,脚本加载已经训练好的 θ 参数,直接输出预测价格。这样销售记录表里的原始数据可以拿去训练,训练完成后模型脱离原数据独立服务。这个脚本核心就三行:读参数、拼特征向量、预测。

def predict_price(theta, mean, std, area, region, decoration, age): # 注意特征顺序要和训练时完全一致 x = np.zeros(len(mean)) x[0] = area x[1] = age # ... 其他特征赋值,分类变量按 one-hot 规则填 0/1 x = (x - mean) / std x = np.r_[1, x] # 补偏置项 return np.exp(x @ theta)

这个环节经常被人忽略,但如果项目是“系统”而不是“模型”,用户界面和部署方式就一定绕不开。我这次实现的是命令行交互版本,再往上走的话,把模型参数序列化成一个 json 文件,前端配个表单,就是一个完整的可演示系统。

写在最后

从拿着一张销售记录表,到能对任意输入房源给出预测价格,我踩得最深的坑是忽略了特征标准化的重要性,在那之前我一直以为模型不收敛是学习率的问题,结果把 α 从 0.01 调到 0.5,损失曲线不是龟速下降就是疯狂震荡,整个人一头雾水。后来把数据标准化一做,同样的 α=0.1 直接顺利收敛。如果你也在做类似的项目,运行代码遇到任何关于收敛的诡异问题,先停下来检查数据尺度,这大概率能把问题解决掉。技术的细节可能在模型、在算法,但项目能不能跑通,往往决定在数据准备的地基工程上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进