线性回归是我觉得整个机器学习里最值得认真手写一遍的模型。你别看它形式简单,好像就是 y = wx + b 这条直线,可一旦你拿一份真实数据把流程完整跑通——从数据清洗、特征标准化,到最小二乘与梯度下降两种解法,再到评估指标和正则化调参——你会发现后面学逻辑回归、树模型甚至神经网络时,很多概念都是在这里第一次埋下的。这篇文章就按我自己做项目时的真实顺序,把线性回归从建模思路到代码实现完整拆一遍。适合两类人:一类是刚看完理论、想动手跑第一份数据的同学;另一类是用过第三方库调包,但始终没搞明白 fit 背后到底发生了什么的人。
1. 线性回归是在解决什么问题:建模思路先理清
1.1 从业务问题到数学表达
先想清楚一件事:线性回归处理的是"预测连续数值"的问题。比如某电商团队想知道,投放预算、广告曝光量、受众规模这几个变量是怎么影响成交额的。团队手里有过去几个月的投放记录,每一条记录都是一组特征 X 对应一个真实成交额 y。目标很明确:学到一个函数,输入新的特征组合,输出一个尽可能接近真实值的成交额预测。
写成数学形式就是:
y = w1·x1 + w2·x2 + w3·x3 + b
这里的 w 是每个特征的权重,b 是截距。为了后面代码好写,通常习惯把 b 合并进 w,也就是在特征矩阵左边补一列全 1,统一写成矩阵形式 y ≈ Xw。权重 w 的含义非常直白:w1 是正数,就说明投放预算每增加一个单位,成交额平均增加 w1 个单位。这份解释能力是很多复杂模型给不了的。
为什么不少任务宁愿先用线性模型?第一是可解释性,算出来的系数可以直接拿回去和运营同学对业务;第二是它天然就是个很好的基线。一个复杂模型如果效果还不如线性回归,通常说明特征工程或数据本身有问题,而不是模型不够强。我处理新数据的习惯永远是:先线性回归打底,再谈要不要上更复杂的模型。
1.2 损失函数与拟合的本质
拟合,说白了就是让预测值和真实值的差距尽量小。每个样本的差距叫残差:e = y - ŷ。把所有残差综合起来,最常用的指标是均方误差(MSE):
J(w) = (1/n) Σ (y_i - w·x_i)²
这里的 Σ 表示对所有样本求和。为什么选平方而不是绝对值?不只是因为平方方便求导。平方意味着大残差会被放大,模型会更努力去压低那些偏离很远的点;同时,在误差服从正态分布的假设下,平方损失恰好对应最大似然估计,是有统计理论撑腰的选择。副作用也很明显:它对异常值极度敏感,这一点到数据清洗部分我会再强调。
有了损失函数,训练模型就变成了最优化问题:找一组 w,让 J(w) 最小。好消息是,MSE 对 w 是凸函数,不存在多个局部极小值,有且只有一个全局最小值。求解有两条路:一条是解析地求出闭式解,也就是最小二乘法;另一条是迭代逼近,也就是梯度下降法。两条路我都建议亲手写一遍,先别急着调库。
2. 动手前的数据准备:这一步决定后面 80% 的体验
2.1 数据探索与清洗
很多人拿到数据,第一件事就是直接塞进 fit(),这是最容易翻车的姿势。线性回归对数据质量极其敏感,尤其是异常值和缺失值。我一般先用 pandas 做一轮快速体检:
import pandas as pd import numpy as np df = pd.read_csv("广告投放数据.csv") print(df.shape) print(df.info()) print(df.describe()) print(df.corr())shape 告诉你有多少样本、多少特征;info() 能看出有没有空值、每一列是什么类型;describe() 给出均值、标准差、最大最小值,特征尺度差异一眼就能发现;corr() 是特征之间以及特征与目标之间的相关性矩阵,后面判断共线性会频繁用到。
拿到结果重点看两件事。第一是缺失值,量少直接均值或中位数填充,量多要思考背后的原因,删列还是删行都行,但不能不处理。第二是异常值。我之前处理一份曝光量数据时,大部分值在几万到几十万区间,结果某几次投放活动产生了上千万的曝光,那几条记录直接把拟合线整个拽歪。处理异常值最常用的办法是 IQR 封顶:把 25% 分位到 75% 分位之外的极端值压到边界值上,这样既保留样本,又不让极端点主导损失函数。
2.2 特征标准化与数据集划分
用梯度下降求解时,特征尺度不一样会非常难受。假设一个特征是"投放预算",量级在十万,另一个特征是"是否节假日",只有 0 和 1,两个方向的梯度差异巨大,更新路径会来回震荡,收敛极慢。解决办法是标准化,把每个特征变成均值为 0、标准差为 1 的分布:
z = (x - mean) / std
这里有个关键细节:标准化必须在训练集上计算均值和标准差,再用同一组参数去转换测试集,绝不能把全量数据一起 fit。否则测试集的信息就提前泄露给了模型,后面评估分数会虚高,上线就露馅。这是新手最容易忽略的坑。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X = df[["投放预算", "曝光量", "受众规模"]].values y = df["成交额"].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 = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)随机种子固定为 42,是为了保证每次跑出来的数据划分一致。后面做模型对比时,误差差异应该来自模型本身,而不是随机划分带来的噪声。
3. 两种主流实现:从零手写与框架调用
3.1 最小二乘法:闭式解的实现
最小二乘的核心思想很直接:想让 J(w) 最小,就对 w 求导并令导数等于零。矩阵形式推导下来,会得到一个非常干净的表达式:
w = (XᵀX)⁻¹ Xᵀy
公式看着简单,真正用 numpy 实现时有几处细节要注意。先给特征矩阵补一列全 1 作为截距项,然后计算 XᵀX,再求逆、乘 Xᵀy。代码如下:
def fit_ols(X, y): Xb = np.hstack([np.ones((X.shape[0], 1)), X]) return np.linalg.inv(Xb.T @ Xb) @ Xb.T @ y w_ols = fit_ols(X_train, y_train) print(w_ols)实际工程里,我不会直接用 np.linalg.inv 去硬算逆矩阵,数值上更稳的是 np.linalg.solve 直接解线性方程组,或者用 np.linalg.lstsq。inv 方案在 XᵀX 接近奇异时数值误差很大,表现就是系数出现夸张的巨大正负值,模型完全没法解释。
闭式解的局限性也很明显。计算复杂度大致是特征维度的三次方,特征一旦到几千上万,求逆会变得很慢甚至内存爆掉。而且当特征之间存在强共线性时,XᵀX 不可逆,解不出来。所以它适合特征数量不多、数据量适中的场景;大数据和高维特征下,迭代方法才是主力。
3.2 梯度下降法:迭代逼近的工程实现
梯度下降的思路和闭式解完全不同:从一个初始 w 出发,沿着损失函数下降最快的方向不断更新参数:
w = w - learning_rate * grad
对 MSE 求梯度,结果非常漂亮:
grad = (2/n) · Xᵀ(Xw - y)
这个式子可以这么理解:先算当前预测和真实值的残差,再让每个特征与残差做内积,得到该特征对损失贡献的方向。实现如下:
def gradient_descent(X, y, lr=0.1, epochs=500): Xb = np.hstack([np.ones((X.shape[0], 1)), X]) n = len(y) w = np.zeros(Xb.shape[1]) loss_history = [] for epoch in range(epochs): y_pred = Xb @ w grad = (2 / n) * Xb.T @ (y_pred - y) w = w - lr * grad loss = np.mean((y_pred - y) ** 2) loss_history.append(loss) return w, loss_history跑完之后,把 loss_history 画成曲线,应当平滑下降并最终趋于水平。如果 loss 直接变成 NaN 或者不降反升,基本就是学习率太大,从 0.1 降到 0.01 或 0.001 再试。如果下降得像蜗牛,可能是学习率太小,也可能是前面说的特征没标准化。
数据量很大的时候,全量梯度计算成本太高,可以改用小批量梯度下降,每次随机抽一批样本算梯度。batch size 取 32、64、128 都是常见选择,它兼顾了稳定性和速度,也是后面深度学习里真正在用的套路。手写版和框架版的核心区别就在这里:手写版帮你理解每一步在算什么,框架版帮你把每一步跑得更快更稳。
| 对比项 | 最小二乘(闭式解) | 梯度下降(迭代) |
|---|---|---|
| 思路 | 直接求导令梯度为 0 | 沿负梯度反复更新 |
| 计算成本 | 随特征维度三次方增长 | 取决于迭代次数和数据量 |
| 大数据集 | 不友好,内存压力大 | 可配小批量,几乎无上限 |
| 共线性场景 | XᵀX 奇异时直接失效 | 同样受影响,可配合正则化 |
| 全局最优 | 精确解析解 | 凸函数下可收敛到全局最优 |
4. 用第三方库快速实战:全流程与指标解读
4.1 完整建模流程演示
手写过一遍之后,再回到第三方库就踏实多了。用开源库 sklearn 的 LinearRegression,几行就能完成同样的任务:
from sklearn.linear_model import LinearRegression model = LinearRegression() model.fit(X_train, y_train) print("截距:", model.intercept_) print("系数:", model.coef_) y_pred = model.predict(X_test)你会发现,这里输出的系数和手写最小二乘几乎一模一样,差异只在浮点计算的精度级别。fit 这个动作背后,本质上就是在解前面的最优化问题。理解了底层之后,换任何框架都不会慌——它再怎么封装,求的还是那一组 w。
这套流程里有个小细节:LinearRegression 默认自带截距项,不需要你在数据里手动补一列 1。但某些业务场景明确要求拟合线必须过原点,可以设置 fit_intercept=False。这个开关平时很少动,但调参和面试时经常被问到,知道它存在就不会一头雾水。
4.2 评估指标:不止是看 R²
模型训练完,评估才是真正花心思的地方。评测必须只在测试集上进行,训练集误差再低也可能是模型把数据背下来了。常用指标我列成了一张表:
| 指标 | 公式 | 一句话理解 |
|---|---|---|
| MSE | (1/n) Σ(y - ŷ)² | 误差平方的平均,会放大特大误差 |
| RMSE | sqrt(MSE) | 回到原始单位,方便向业务解释 |
| MAE | (1/n) Σ | y - ŷ |
| R² | 1 - SS_res / SS_tot | 相比均值基线,模型解释了多少方差 |
计算直接调用现成函数:
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score print("MSE:", mean_squared_error(y_test, y_pred)) print("RMSE:", np.sqrt(mean_squared_error(y_test, y_pred))) print("MAE:", mean_absolute_error(y_test, y_pred)) print("R²:", r2_score(y_test, y_pred))我自己的习惯是 RMSE 和 R² 一起看。RMSE 带原始单位,能直接和业务对口径,比如"预测成交额平均偏差 1.2 万";R² 说明模型的相对解释力。R² 到 0.95 别急着高兴,先查是不是过拟合;R² 出现负值也别慌,这说明模型比"直接用平均值当预测"还差,通常是特征没选对,或者线性假设本身不成立。
5. 过拟合、欠拟合与正则化:线性回归的另一面
5.1 如何判断模型处于哪种状态
线性回归看着简单,同样躲不开偏差与方差的权衡。判断方法就是对比训练误差和测试误差:
| 现象 | 训练误差 | 测试误差 | 结论 |
|---|---|---|---|
| 模型太简单 | 高 | 高 | 欠拟合 |
| 模型记住噪声 | 低 | 高 | 过拟合 |
| 两者都低且接近 | 低 | 低 | 健康状态 |
欠拟合的时候,加正则化反而雪上加霜。正确做法是增加有效特征、做特征多项式扩展,或者直接换更灵活的模型。过拟合的时候,优先考虑三类手段:增加训练数据、减少特征数量、加正则化惩罚。三招里面加数据最有效但成本最高;减特征需要业务判断;正则化是成本最低、见效最快的通用方案。
5.2 岭回归与 Lasso 的实战选择
正则化是在原损失函数后面加一个惩罚项,让系数不要长得太放肆。岭回归加了 L2 惩罚,系数整体被压缩但不会变成零,尤其擅长处理特征共线性的情况。Lasso 加了 L1 惩罚,会把一部分不重要特征的系数直接压成零,天然做特征选择。两者都通过 alpha 控制惩罚强度,alpha 越大,压缩越狠。
选择 alpha 用交叉验证最稳,sklearn 提供了现成的 CV 类:
from sklearn.linear_model import RidgeCV, LassoCV ridge = RidgeCV(alphas=[0.01, 0.1, 1, 10], cv=5) ridge.fit(X_train, y_train) lasso = LassoCV(alphas=[0.01, 0.1, 1, 10], cv=5) lasso.fit(X_train, y_train)cv=5 表示把训练集切成 5 份,轮流拿 4 份训练、1 份验证,最终平均误差最小的 alpha 会被选中。实际项目里,如果特征多但业务上认为真正起作用的只有少数几个,我倾向先用 Lasso 看哪些系数被压成 0,再用剩下的特征去做报告;如果特征是高度相关的连续变量,就用 Ridge,它保住所有特征,只是统一收缩。
6. 常见问题与排查技巧实录
6.1 损失不下降、结果异常时怎么排查
手写模型和框架模型都会遇到各种怪问题,我把踩过的坑整理成速查表:
| 现象 | 常见原因 | 检查手段 |
|---|---|---|
| loss 为 NaN 或爆炸 | 学习率过大 | 调小 lr,从 0.001 开始 |
| loss 下降极慢 | 未做特征标准化 | 检查特征量纲差异 |
| loss 震荡不收敛 | 学习率偏高或数据有异常值 | 逐轮打印 loss 曲线 |
| 系数异常巨大 | XᵀX 接近奇异或共线性 | 看 corr 矩阵,改用岭回归 |
| 代码报维度不匹配 | 忘补截距列或矩阵方向反 | 打印 X 和 w 的 shape |
排查时最有效的习惯是每轮迭代都记录 loss,画出来看趋势。曲线观察比只看一个最终数值信息量大得多:平滑下降说明一切正常;突然跳升说明学习率太大;下降到一半变平但值还很高,说明模型容量不足,不是优化的问题。
6.2 特征共线性、结果波动与数据泄漏
特征共线性的典型表现是:单看每个特征和目标的相关系数都不低,但模型系数忽正忽负,换个数据划分系数就大变。原因在于强相关特征之间可以互相替代,最优解不唯一。判断标准除了相关系数,还可以算 VIF(方差膨胀因子),超过 10 就该警惕。处理办法很简单:删掉冗余特征,或者直接用岭回归。
结果波动也是高频问题。单次随机划分数据,模型表现可能忽好忽坏,数据量越小越明显。对策是固定 random_state 做对比实验,或者用 k 折交叉验证评估整体水平。还有一个隐蔽的坑是数据泄漏:标准化时把测试集的均值和标准差混进训练集,或者时间序列数据随机打乱导致未来信息混入训练。这类问题不会报错,只会让测试集分数虚高,等上线后直接崩掉。
最后说一点个人体会:手写一遍线性回归,收益远大于把公式背下来。每次拿到全新数据,我都会先用线性回归打底,把它的系数和 RMSE 当作一切复杂模型的起点基准。模型再花哨,如果连线性基线都跑不过,多半是数据或特征出了问题,而不是模型不够强。你也动手跑一遍吧,把该踩的坑踩完,后面学任何模型都会顺很多。