1. 从一堆散点到一条直线:回归到底在解决什么问题
很多人第一次接触机器学习,都是从线性回归开始的。原因很简单,它足够直观,数学门槛相对低,而且后面那些花里胡哨的模型,比如神经网络、梯度提升树,追根溯源都能看到它的影子。但我也见过不少朋友,学完一遍之后,问他"一元和多元到底差在哪",他只能背出"一个特征和多个特征"这种教科书答案,真让他上手做一个小项目,立马卡壳。
这篇内容我想干一件事:把一元线性回归和多元线性回归从思路到代码到踩坑,完整地捋一遍。不管你是刚入门的学生,还是工作里需要快速搭个预测基线的人,看完应该都能直接动手复现。核心关键词就三个:线性回归、最小二乘、梯度下降。我会重点讲清楚为什么这么选、参数怎么定、代码怎么写、出问题怎么查。
先说清楚这两个东西是什么。一元线性回归,就是用一个特征去预测一个连续值,数学形式是 y = wx + b,图像上就是一条直线。多元线性回归,是用多个特征去预测一个连续值,形式是 y = w1x1 + w2x2 + ... + wnxn + b,图像上是一个超平面。它们解决的都是回归问题,也就是预测的值是连续的,比如房价、温度、销量、评分,而不是分类问题里的"是/否"。
适合谁来参考?如果你是刚学机器学习,想找一个能跑通全流程的入门项目,这篇适合你。如果你已经会调库,但说不清楚背后的原理和参数含义,这篇也适合你。如果你需要给业务快速搭一个可解释的预测基线,线性回归往往是第一选择,因为它快、可解释、不容易过拟合。
2. 一元线性回归:把一条直线拟合到数据上
2.1 一元线性回归的数学本质与直觉理解
一元线性回归的模型形式非常朴素:
y = w * x + b其中 x 是输入特征,y 是预测目标,w 是斜率(权重),b 是截距(偏置)。训练的过程,就是找到一组 w 和 b,让这条直线尽可能贴近所有数据点。
那"尽可能贴近"怎么量化?这就引出了损失函数。最常用的是均方误差(MSE):
MSE = (1/n) * Σ(y_i - (w * x_i + b))^2直觉上,就是把每个点到直线的垂直距离平方后求平均。平方的作用有两个:一是让正负误差都变成正数,不会相互抵消;二是放大大的误差,让模型更在意那些偏得离谱的点。
为什么用平方而不是绝对值?这是个好问题。用绝对值(MAE)也能衡量误差,但平方函数处处可导,求导方便,而且最小化平方误差有闭式解,这是最小二乘法能直接算出解析解的关键。绝对值在零点不可导,优化起来麻烦一些。当然,MAE 对异常值更鲁棒,这是后话。
2.2 最小二乘法:直接算出最优参数
对于一元线性回归,我们可以直接推导出 w 和 b 的最优解,这叫最小二乘法(Least Squares)。推导过程是对 MSE 分别关于 w 和 b 求偏导,令偏导为零,解方程组。
最终结果:
w = Σ((x_i - x_mean)(y_i - y_mean)) / Σ((x_i - x_mean)^2) b = y_mean - w * x_mean这个公式的直觉是:w 等于 x 和 y 的协方差除以 x 的方差。也就是说,如果 x 和 y 同向变化得越明显,斜率就越大。
我用 Python 手写一遍,不调库,帮助理解:
import numpy as np def fit_linear(x, y): x = np.array(x, dtype=float) y = np.array(y, dtype=float) x_mean = x.mean() y_mean = y.mean() numerator = np.sum((x - x_mean) * (y - y_mean)) denominator = np.sum((x - x_mean) ** 2) w = numerator / denominator b = y_mean - w * x_mean return w, b x = [1, 2, 3, 4, 5] y = [2.1, 4.2, 5.9, 8.1, 10.2] w, b = fit_linear(x, y) print(f"w={w:.4f}, b={b:.4f}")实测下来,这组数据会得到接近 w=2.0、b=0.1 的结果,和数据生成规律吻合。
注意:最小二乘法对异常值非常敏感。一个离群点就能把整条直线拽偏,因为误差被平方放大了。如果数据里明显有异常点,要么先清洗,要么改用对异常值更鲁棒的方法。
2.3 梯度下降:当闭式解不好用时怎么办
最小二乘法虽好,但它有个前提:特征矩阵需要可逆,而且当特征数量很大时,矩阵求逆的计算量是 O(n^3),非常慢。这时候梯度下降就派上用场了。
梯度下降的思路是:先随便初始化 w 和 b,然后计算损失对参数的梯度,沿着梯度的反方向一点点更新,直到损失不再下降。
更新公式:
w = w - lr * ∂MSE/∂w b = b - lr * ∂MSE/∂b其中 lr 是学习率,控制每一步走多大。学习率太小,收敛慢;太大,可能直接跨过最低点,损失震荡甚至发散。
手写梯度下降:
def gradient_descent(x, y, lr=0.01, epochs=1000): x = np.array(x, dtype=float) y = np.array(y, dtype=float) w, b = 0.0, 0.0 n = len(x) for i in range(epochs): y_pred = w * x + b error = y_pred - y dw = (2 / n) * np.sum(error * x) db = (2 / n) * np.sum(error) w -= lr * dw b -= lr * db if i % 200 == 0: loss = np.mean(error ** 2) print(f"epoch {i}, loss={loss:.4f}") return w, b这里有个细节:学习率的选择和数据尺度强相关。如果 x 的取值在几千几万,梯度会非常大,学习率就得设得很小,否则直接发散。这也是为什么特征标准化在梯度下降里几乎是必须的。
2.4 一元线性回归的评估指标
训练完模型,怎么知道它好不好?常用的指标有三个:
| 指标 | 含义 | 特点 |
|---|---|---|
| MSE | 均方误差 | 对大误差敏感,量纲是 y 的平方 |
| RMSE | 均方根误差 | 量纲和 y 一致,最直观 |
| R² | 决定系数 | 取值 (-∞,1],越接近 1 越好,表示解释了多大比例的方差 |
R² 的计算:
R² = 1 - SS_res / SS_tot SS_res = Σ(y_i - y_pred_i)^2 SS_tot = Σ(y_i - y_mean)^2R² 有个坑:它不是平方,可以是负数。当模型比"直接预测均值"还差时,R² 就是负的。很多人以为 R² 一定在 0 到 1 之间,这是常见误解。
3. 多元线性回归:从直线升级到超平面
3.1 多元线性回归的矩阵形式与几何意义
当特征从一个变成多个,模型就变成:
y = w1*x1 + w2*x2 + ... + wn*xn + b写成矩阵形式更简洁:
Y = X @ W + b其中 X 是 m×n 的矩阵(m 个样本,n 个特征),W 是 n×1 的权重向量。几何上,一元是一条直线,二元是一个平面,三元及以上就是超平面,没法直接画出来,但数学处理方式完全一样。
多元线性回归相比一元,最大的价值在于控制变量。现实中很少有目标只受一个因素影响。比如预测房价,面积、楼层、房龄、地段都会影响。如果只用面积做一元回归,其他因素的影响就被塞进了误差项里,导致估计有偏。多元回归能把多个因素同时纳入,每个权重表示"在其他特征不变的情况下,该特征每变化一个单位,目标平均变化多少"。这个解释性在业务里非常值钱。
3.2 正规方程:多元回归的闭式解
多元线性回归的最小二乘解,可以用正规方程(Normal Equation)直接给出:
W = (X^T X)^(-1) X^T Y推导思路和一元的偏导为零一致,只是用矩阵求导。这个公式很优雅,但有几个现实问题:
- X^T X 可能不可逆,比如特征之间存在完全共线性,或者样本数少于特征数。
- 求逆的复杂度是 O(n^3),特征上千时就很吃力。
所以实践中,如果特征不多(几百以内),正规方程够用;特征多或者数据量大,还是梯度下降更合适。
用 numpy 实现正规方程:
def normal_equation(X, Y): X = np.array(X, dtype=float) Y = np.array(Y, dtype=float) # 给 X 加一列 1,用于吸收截距 b X_b = np.c_[np.ones((X.shape[0], 1)), X] # 正规方程 theta = np.linalg.inv(X_b.T @ X_b) @ X_b.T @ Y return theta # theta[0] 是截距,theta[1:] 是各特征权重注意:用 np.linalg.inv 求逆在数值上不稳定,更推荐用 np.linalg.pinv(伪逆)或者 np.linalg.solve 解线性方程组。伪逆能自动处理不可逆的情况,给出最小范数解。
3.3 多元回归的梯度下降实现
多元回归的梯度下降,本质和一元的完全一样,只是把标量运算换成向量运算:
def gradient_descent_multi(X, Y, lr=0.01, epochs=1000): X = np.array(X, dtype=float) Y = np.array(Y, dtype=float) m, n = X.shape # 加偏置列 X_b = np.c_[np.ones((m, 1)), X] theta = np.zeros(n + 1) for i in range(epochs): y_pred = X_b @ theta error = y_pred - Y gradient = (2 / m) * (X_b.T @ error) theta -= lr * gradient if i % 200 == 0: loss = np.mean(error ** 2) print(f"epoch {i}, loss={loss:.4f}") return theta这里最关键的一步是特征标准化。如果某个特征取值在 0 到 1,另一个在 0 到 10000,那么梯度在不同维度上的尺度差异巨大,损失曲面会变成一个细长的椭圆,梯度下降会来回震荡,收敛极慢。标准化的做法是:
x_scaled = (x - mean) / std标准化之后,所有特征都在同一量级,损失曲面接近圆形,梯度下降能快速稳定地收敛。
3.4 特征标准化与学习率的选择
学习率怎么定?没有万能值,但有几个经验:
- 先试 0.01、0.001、0.1 这几个量级。
- 如果损失在头几个 epoch 就变成 nan 或爆炸,说明学习率太大。
- 如果损失下降得像蜗牛爬,说明学习率太小。
- 更好的做法是用自适应优化器,比如 Adam,它对学习率不那么敏感。
我个人的习惯是:先用标准化 + 0.01 的学习率跑一遍,看损失曲线。如果曲线平滑下降,就继续;如果震荡,就减半;如果太慢,就加倍。这个"手动调参"的过程虽然土,但对理解梯度下降的行为非常有帮助。
4. 完整实操:从数据生成到模型评估
4.1 数据准备与探索性分析
光讲公式太干,我们走一遍完整流程。假设要预测某类商品的销量,特征包括价格、广告投入、季节指数。先造一份模拟数据:
import numpy as np import pandas as pd np.random.seed(42) n = 200 price = np.random.uniform(10, 50, n) ad = np.random.uniform(0, 100, n) season = np.random.uniform(0.5, 1.5, n) # 真实关系:销量 = 200 - 2*price + 1.5*ad + 30*season + 噪声 sales = 200 - 2 * price + 1.5 * ad + 30 * season + np.random.normal(0, 10, n) df = pd.DataFrame({"price": price, "ad": ad, "season": season, "sales": sales}) print(df.describe()) print(df.corr())先看相关性矩阵,这一步很重要。如果某个特征和目标几乎不相关,那它进模型也是白搭,还可能引入噪声。如果两个特征之间高度相关(比如 0.95 以上),就要警惕多重共线性,它会让权重估计变得不稳定,符号甚至可能和直觉相反。
4.2 训练集测试集划分与标准化
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X = df[["price", "ad", "season"]].values y = df["sales"].values X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)注意:标准化器只能在训练集上 fit,然后 transform 测试集。如果拿全量数据 fit,测试集的信息就泄漏到了训练过程,评估结果会虚高。这是新手最常犯的错误之一。
4.3 用正规方程和梯度下降分别求解
# 正规方程 X_train_b = np.c_[np.ones((X_train_scaled.shape[0], 1)), X_train_scaled] theta_ne = np.linalg.pinv(X_train_b.T @ X_train_b) @ X_train_b.T @ y_train print("正规方程参数:", theta_ne) # 梯度下降 def gd(X, y, lr=0.01, epochs=2000): m, n = X.shape X_b = np.c_[np.ones((m, 1)), X] theta = np.zeros(n + 1) losses = [] for i in range(epochs): error = X_b @ theta - y grad = (2 / m) * (X_b.T @ error) theta -= lr * grad losses.append(np.mean(error ** 2)) return theta, losses theta_gd, losses = gd(X_train_scaled, y_train, lr=0.05, epochs=2000) print("梯度下降参数:", theta_gd)两种方法得到的参数应该非常接近。如果差很多,检查学习率、迭代次数和标准化。
4.4 模型评估与结果解读
from sklearn.metrics import mean_squared_error, r2_score def evaluate(theta, X, y, scaler=None): X_b = np.c_[np.ones((X.shape[0], 1)), X] y_pred = X_b @ theta mse = mean_squared_error(y, y_pred) r2 = r2_score(y, y_pred) return mse, r2 mse_ne, r2_ne = evaluate(theta_ne, X_test_scaled, y_test) mse_gd, r2_gd = evaluate(theta_gd, X_test_scaled, y_test) print(f"正规方程: MSE={mse_ne:.2f}, R2={r2_ne:.4f}") print(f"梯度下降: MSE={mse_gd:.2f}, R2={r2_gd:.4f}")因为数据是线性生成的,R² 应该能到 0.9 以上。如果 R² 很低,先别急着换模型,回头检查数据、特征、标准化、划分,问题多半出在这些地方。
参数解读:因为做了标准化,权重表示"该特征每变化一个标准差,目标变化多少"。如果想还原到原始尺度,需要除以对应的标准差。截距是在所有特征取均值时的预测值。
5. 常见问题与排查技巧实录
5.1 损失不下降或变成 NaN
这是梯度下降最常见的问题,排查顺序如下:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 损失爆炸成 nan | 学习率太大 | 减小学习率,或先标准化 |
| 损失几乎不动 | 学习率太小 | 增大学习率 |
| 损失震荡 | 学习率偏大或未标准化 | 标准化 + 减小学习率 |
| 损失先降后升 | 学习率过大跨过最低点 | 减小学习率,加早停 |
我踩过最深的坑是:忘了标准化,学习率设了 0.01,结果损失直接飞到 1e30。后来养成习惯,只要用梯度下降,第一步永远是标准化。
5.2 权重符号和业务直觉相反
比如理论上价格越高销量越低,结果模型给出的价格权重是正的。这通常是多重共线性在作怪。两个高度相关的特征同时进模型,权重会互相"抢功",导致符号不稳定。
解决办法:
- 计算方差膨胀因子(VIF),大于 10 就说明共线性严重。
- 删掉相关性高的特征之一。
- 用岭回归(L2 正则)或 Lasso(L1 正则),它们能缓解共线性,Lasso 还能做特征选择。
5.3 R² 很高但预测一塌糊涂
这通常是过拟合或者数据泄漏。检查:
- 特征里是不是混进了和目标直接相关的信息?比如用"最终售价"去预测"成交价"。
- 测试集是不是被标准化器提前"看过"了?
- 样本量是不是太少,特征是不是太多?
线性回归本身不容易过拟合,但如果特征数接近甚至超过样本数,就会出问题。这时候正则化是必要的。
5.4 该用一元还是多元
这个问题没有绝对答案,取决于你的目标和数据:
- 如果只想看单个因素的影响,或者做简单的趋势预测,一元够用,而且可视化直观。
- 如果目标是尽可能准确地预测,且手头有多个相关特征,优先多元。
- 如果特征之间高度相关,先做特征筛选或正则化,再上多元。
我个人的经验是:先用一元快速摸清每个特征和目标的关系,画散点图,看趋势。然后再上多元,对比 R² 的提升。如果多元相比一元提升很小,说明其他特征贡献有限,不如保持模型简单。
5.5 正则化:什么时候该加,怎么加
当特征多、样本少,或者共线性严重时,普通最小二乘会不稳定。这时候加正则项:
- 岭回归(L2):损失加 λΣw²,让权重整体变小但不为零,缓解共线性。
- Lasso(L1):损失加 λΣ|w|,能把不重要的权重压到零,实现特征选择。
- 弹性网络:L1 和 L2 的结合。
λ 是超参数,控制正则强度。λ 太大,模型欠拟合;λ 太小,等于没加。用交叉验证选 λ 是最稳妥的做法。
from sklearn.linear_model import Ridge, Lasso from sklearn.model_selection import cross_val_score for alpha in [0.01, 0.1, 1, 10]: ridge = Ridge(alpha=alpha) scores = cross_val_score(ridge, X_train_scaled, y_train, cv=5, scoring="r2") print(f"alpha={alpha}, cv R2={scores.mean():.4f}")这段代码能帮你快速找到合适的正则强度。实测下来,alpha 在 0.1 到 1 之间往往效果不错,但具体要看数据。
6. 一些实操心得与扩展方向
线性回归看起来简单,但真正用好它,需要关注的细节一点不少。我自己在项目里总结了几条:
第一,永远先画图。一元回归画散点图加拟合线,多元回归画残差图。残差如果呈现明显的规律(比如 U 形),说明线性假设不成立,该考虑多项式特征或者非线性模型了。
第二,标准化不是可选项。只要用梯度下降或者正则化,标准化就是必须的。即使不用,标准化也能让权重之间可比,方便解读。
第三,别迷信 R²。R² 高不代表模型有用,可能只是过拟合。一定要看测试集表现,最好做交叉验证。残差分析比 R² 更能暴露问题。
第四,线性回归是基线,不是终点。它的价值在于快速、可解释、不容易过拟合。如果它已经能满足业务需求,就别上复杂模型。如果它明显欠拟合,再考虑加特征、加交互项、上树模型或神经网络。
这个内容后续还能这样扩展:加入多项式特征处理非线性关系,用交叉验证做更稳健的评估,对比不同正则化方法的效果,或者把整个流程封装成一个可复用的训练脚本。等你把一元和多元这两块吃透,这些扩展都是水到渠成的事。