☰
手写线性回归全攻略:从梯度下降到岭回归的完整带练复盘
2026/10/21 4:27:18 网站建设 项目流程

“带练”这个词这两年越来越流行,但真正做得好的不多。大多是录一段视频让学员跟着敲,或者扔一个仓库让学员自己看,这种更像“带看”而不是“带练”。我最近参加的这轮代码带练比较特别,主题就叫 linear,核心是每个人从空文件开始,手写一版完整的线性回归代码。整轮下来收获非常大,所以把这套带练模式、代码细节和踩坑过程完整复盘一遍。如果你也想用类似方式把机器学习基础打牢,或者想搞懂线性回归每一行代码背后的原理,这篇内容应该能派上用场。说明一下,为了避免对号入座,后文里的带练导师李哥只是一个化名,代表圈子里某位有经验的开发者。

1. 这轮带练到底在练什么:从“会调库”到“敢写库”

1.1 带练的起点:一道几分钟就能写完的三行代码

带练第一天,李哥给的任务异常简单:给定一组二维数据,用纯 Python 加 numpy 实现线性回归,不许调 sklearn、不许用 torch、也不许直接复制任何现成代码。听完之后我心想,这不就是三行LinearRegression的事吗?但等你真的新建了一个空文件,才发现连第一步都有不少决定要做:数据怎么组织?要不要加截距项?梯度下降怎么写?损失函数怎么算?更麻烦的是,代码跑通了,你凭什么相信结果是正确的?

这三行背后其实是一整套知识:矩阵求导、数据分布、数值稳定性、模型验证。李哥当时解释为什么选线性回归作为带练题目,说的很直白:“它很轻,比神经网络简单得多,但信息量极大。你把它从头到尾写明白,之后再去看复杂模型,底层都是一回事。”这个判断我觉得很准,因为线性回归几乎是唯一一个既具备完整数学模型、又有闭式解可以验证、还可以扩展到神经网络的最小框架。

1.2 为什么“手写代码”比“调库”更能发现理解漏洞

调库的时候,你可能只知道fit和predict两个方法名,数据丢进去,结果出来,看起来一切正常。即使某一步特征没做标准化、标签有异常值、模型欠拟合,线性的结果也会给你一个“看起来还行”的数值。手写代码之后,这些薄弱环节全都暴露了。

带练过程中,李哥要求每个人必须回答三个问题:你的目标函数到底是什么?梯度是从哪个公式推出来的?参数更新之后你如何判断收敛?这三个问题如果答不上来,代码写出来也属于“蒙对”。实际上,有不少人写梯度下降时直接用theta = theta - lr * (X.T @ (X @ theta - y)),推导过程完全不关心,结果换一个带量纲差距大的数据,模型就发散到无穷大,这时候调库的人根本遇不到这种问题。

1.3 带练的规则:不是讲解课,是边写边讲

很多人以为带练是老师在上面讲课、学员在下面抄,实际操作完全不一样。我们这轮的模式是:每写完一个函数,先在本地用断言验证结果,然后打开视频会议逐步讲解自己代码里的每一个矩阵维度;讲不清楚的地方,立刻停下来改写。每到一个关键节点,李哥会设置一个“坑”,故意让你的第一个版本在某些边界情况上出错,然后再去排查。

比如第二个实操练习,要求实现特征标准化,但刻意不告诉你要用训练集的均值和标准差,很多人图省事直接用全量数据计算。看起来训练集和测试集都归一化了,模型效果也很好,但李哥只要说一句“你想想测试集到底在模拟什么”,大家就意识到问题所在。这种“先踩坑再复盘”的方式,印象比单纯听课深刻得多。

2. 手写线性回归第一版:矩阵运算与梯度下降的落地

2.1 设计矩阵:为什么截距项要单独作为一列

从零起步的第一个决定就是数据怎么组织。常见的做法是把训练数据组织成矩阵 (X),形状是(n, d),其中 n 是样本数、d 是特征数。但线性回归模型通常写成:

[ y = X\theta + b ]

李哥在这里提出了第一个问题:既然截距项 (b) 和权重 (\theta) 的地位不一样,代码里到底是把b单独存一个变量,还是把它塞进 (X) 里?两种方案都能用,但塞进矩阵会让公式更统一。具体做法是在 (X) 的左边或者右边加一列全 1,如果放在最左边,则系数向量的第一个元素就是截距。

我当时写的第一版代码是这样的:

import numpy as np def prepare_data(X, add_bias=True): if add_bias: ones = np.ones((X.shape[0], 1)) return np.hstack([ones, X]) # 第一列全是1,对应截距项 return X def predict(X, theta): return X @ theta def mse_loss(y_true, y_pred): return np.mean((y_true - y_pred) ** 2)

为什么建议把 bias 放进矩阵?因为这样梯度公式可以统一写成 (X^T(X\theta - y)),不需要每次单独处理 (b) 的更新。不过也有一个隐患:如果你后续做正则化,这一列对应的截距项通常不应该被惩罚,需要单独跳过;这一点到后面实现岭回归时尤其容易出错,先记在这里。

2.2 损失函数与梯度推导:从一维标量到矩阵求导

接下来是梯度的推导。均方误差定义成:

[ J(\theta) = \frac{1}{n} \sum_{i=1}^{n} (y_i - x_i^T\theta)^2 ]

有的教材会在前面加个 (\frac{1}{2}),理由是在求导时刚好消掉系数 2;有的不加,影响只是梯度整体放缩,学习率也要对应调整。李哥要求我们统一采用不带 (\frac{1}{2}) 的写法,因为和 sklearn 的mean_squared_error可以直接对比。

把上式展开为矩阵形式:

[ J(\theta) = \frac{1}{n} (X\theta - y)^T (X\theta - y) ]

对 (\theta) 求梯度,得到:

[ \frac{\partial J}{\partial \theta} = \frac{2}{n} X^T (X\theta - y) ]

这里有很多人第一次看会懵:为什么不是一堆循环求和?说白了就是矩阵求导的链式法则。标量形式的梯度是 (\frac{2}{n}\sum_i x_i (x_i^T\theta - y_i)),这个累加恰好等于 (X^T) 乘以残差向量。代码实现极其精简:

def gradient(X, y, theta): residual = X @ theta - y return (2 / len(y)) * X.T @ residual

李哥特别提醒:一定要把X.T @ residual的维度画出来。X是(n, d),转置后是(d, n),残差是(n, 1),相乘结果是(d, 1),正好与参数形状一致。如果写代码时不注意维度,就很容易写出形状对不上、或者“能跑但语义错误”的代码。

2.3 参数更新循环:学习率选多大,收敛怎么判断

有了梯度和预测函数,训练循环就是一个简单过程:

def train_gradient_descent(X, y, lr=0.01, epochs=1000): theta = np.zeros(X.shape[1]) for i in range(epochs): theta = theta - lr * gradient(X, y, theta) if i % 100 == 0: loss = mse_loss(y, X @ theta) print(f"epoch {i}, loss {loss:.6f}") return theta

学习率怎么选,是带练过程中最容易让人崩溃的点。选大了,loss 在几个 epoch 内直接飞到nan;选小了,1000 个 epoch 过去参数还在原地挪。一个非常实用的经验是,先把学习率设成0.01,观察前 20 个 epoch 的 loss 变化。如果 loss 剧烈震荡,就除以 10;如果下降得异常缓慢,就乘以 3。这个节奏比任何“根据公式自动调整学习率”的代码都直观,毕竟你是在手写一个小训练器,不是在搭框架。

判断收敛我一开始只看 loss 是否小于某个值,后来发现不够。李哥给出两条更可靠的标准:一是连续若干轮 loss 变化量低于1e-6;二是参数向量的变化量(范数)也足够小。两条同时满足才算收敛。只盯着 loss 的坑在于,当数据规模很大、模型接近收敛时,loss 变化已经极小,但参数可能还在抖动,直观显示权重曲线就会发现并不稳定。

3. 数据预处理那一环,带练里反复被骂的细节

3.1 特征标准化为什么不是可选项

手写线性回归的第二个大练习,是在一个数据集上训练模型。这个数据有两个特征:一个取值范围在 0 到 1 之间,另一个取值在几百到几万之间。最初我没有做任何处理,直接跑梯度下降,学习率取0.01时第一个特征对应的权重更新很慢,第二个特征对应的权重直接震荡发散。李哥让我们在纸上演算一下梯度的量级,才明白原因。

看梯度公式里的每一项,特征 (x_i) 的尺度会直接影响该特征对应梯度的尺度。如果某个特征数值特别大,它的梯度就会特别大,学习率需要足够小才能稳住;但学习率变小后,其他特征又更新得极慢。这是“一组参数一个学习率”的根本困境,解决办法就是预处理所有特征,让它们处在大体一致的尺度上。最常用的方式是 z-score 标准化:

mu = X_train.mean(axis=0) sigma = X_train.std(axis=0) sigma[sigma == 0] = 1.0 # 避免某个特征是常数时除零 X_train_scaled = (X_train - mu) / sigma X_test_scaled = (X_test - mu) / sigma

注意最后一行的mu和sigma必须来自训练集。道理不复杂:测试集是用来模拟未来未知样本的,你预测时不可能知道未来数据的均值。如果直接把全量数据一起算均值和标准差,测试阶段的信息就“泄漏”到了训练阶段,评估结果会偏乐观。

3.2 训练集、验证集、测试集的切分顺序坑

另一个被反复骂的点是切分顺序。我第一次的做法是先做标准化再切分,结果被李哥一眼看出问题。标准化是一个有“状态”的操作,它的均值和标准差必须从训练集学出来,而一旦先拿着全部数据算了一遍,就相当于预先偷看了测试集的分布。正确的流程永远是:先切分,再用训练集拟合标准化参数,最后作用于测试集。

切分本身也需要注意随机性。如果数据集本身是按时间顺序排列的,直接切前 80% 作为训练集会隐性地引入分布漂移问题;如果样本存在某种类别顺序,直接切分可能导致训练集和测试集分布完全不同。带练时李哥要求统一使用先打乱再切分的流程,固定随机种子保证可复现:

from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 )

我知道这里用了 sklearn,但它只用来做数据划分,核心模型的部分还是手写,这个边界要划清楚。

3.3 量纲、共线性与病态矩阵:几个“代码没问题但结果不对”的元凶

带练过程中,有个同学写了很漂亮的梯度下降代码,但 loss 怎么都降不下去。数值上没有任何报错,代码逻辑看起来也正确。李哥让我们打印X的相关矩阵,发现两个特征之间相关系数高达 0.98。这种高度共线性的数据,会让 (X^TX) 的条件数变得极大,梯度下降在表面上走“缓坡”,每一步都在摇摆。

共线性问题有三种常规处理路线:删除高度相关的特征之一;做 PCA 降维后再拟合;加入正则化让解更稳定。对我们这轮的带练来说,李哥让我们先做标准化,然后计算特征相关矩阵,把相关性高于 0.95 的特征删掉。这一步看似“偷懒”,实际是在提醒我们:机器学习问题里,数据和特征质量往往比模型参数更影响结果。

表格里可以更清楚地看到不同情况下的表现差异:

情况梯度下降表现闭式解表现原因
特征量纲差异大收敛慢或发散正常梯度尺度失衡
特征高度共线收敛极慢可能出现大权重(X^TX) 病态
训练集/测试集分布不一致训练 loss 低、测试 loss 高同左数据切分问题
未做标准化就权衡正则化强度结果飘忽不定同左惩罚项尺度不一致

4. 从单特征到多特征:扩展过程中最容易翻车的三个地方

4.1 设计矩阵的维度对齐:一个一维数组引发的血案

第一个扩展练习,是把代码从单特征改成多特征。听起来很简单,无非是把X从一列变成多列。真正写的时候,维度问题层出不穷:比如y是一维数组(n,),X @ theta得到(n,),两者相减还是(n,),此时再算X.T @ residual,如果X的形状是(n, d),得到(d,),没问题;但如果一开始不小心把y写成了(n, 1)的形状,整个式子算出来的形状就变成(d, 1),后续更新参数时可能不小心把二维数组当一维用,引发各种诡异错误。

李哥给了一个非常实用的建议:在关键函数入口加一个形状断言,不用多复杂,就是让错误早点暴露。比如:

assert X.ndim == 2, "X should be 2D" assert y.ndim == 1, "y should be 1D" assert X.shape[0] == y.shape[0], "sample number mismatch"

这种写法在调试阶段价值极大,尤其是当你开始封装代码、给别人使用时,形状断言能省下大量排查时间。

4.2 正规方程与梯度下降的对比:为什么要写两种解法

带练进行到一半,李哥让我们实现第二种解法:正规方程。公式极其优雅:

[ \theta = (X^TX)^{-1} X^T y ]

代码也就一行:

theta_closed_form = np.linalg.inv(X_train_scaled.T @ X_train_scaled) @ X_train_scaled.T @ y_train

但在实际项目里,直接写inv是不太推荐的,更稳妥的做法是用np.linalg.solve或pinv。原因是X^TX在特征共线时可能接近奇异矩阵,直接求逆会把微小误差放大到不可收拾;np.linalg.lstsq或者专门求解器会做更多数值层面的保护。

两种解法的适用边界,我整理成一张表,当时直接贴在代码注释里:

维度梯度下降正规方程
计算复杂度每轮 O(n·d),与迭代次数相乘主要开销在求逆,约 O(d^3)
内存占用只存数据矩阵和参数需要额外存储 (X^TX)
特征数 d 很大时仍然可接受求逆成本快速上升
数据量 n 很大时每轮遍历全量数据也偏慢,但可通过小批量缓解内存可能直接爆掉
数值稳定性依赖学习率与预处理依赖 (X^TX) 是否病态
可扩展性可以迁移到神经网络基本只能用于线性模型

这套对比的深层意义在于,理解了批量训练、小批量训练、闭式解各自的天花板,再看现代深度学习框架为什么默认用梯度下降,就非常自然了。

4.3 岭回归的正则化实现:截距项到底要不要被惩罚

扩展练习的最后一关是加正则化。为什么加正则化?因为当特征数量接近样本数量、或者特征强相关时,无约束的线性回归权重会很大,模型过于相信某些特征。岭回归在原始损失上加一个 L2 惩罚:

[ J_{ridge}(\theta) = \frac{1}{n} |X\theta - y|^2 + \lambda |\theta|^2 ]

这里有个非常容易踩的坑:(\theta) 里包含截距项,截距项如果也被惩罚,那么当特征均值不为 0 时,模型会为了降低惩罚而把截距强行拉向 0,导致预测系统性地偏离标签均值。因此在梯度更新时,要对截距特殊处理。

def ridge_gradient(X, y, theta, lam): grad = (2 / len(y)) * X.T @ (X @ theta - y) grad_reg = grad.copy() grad_reg[1:] += (2 * lam / len(y)) * theta[1:] # 注意跳过第一个元素 return grad_reg

实际带练中,有同学把整段梯度都加上了正则项,结果训练集上的表现直接变差。李哥让大家画一下截距项的梯度走向,才发现问题。这里也顺带解释一下,为什么有的框架里正则化项写的是 (\lambda |\theta|^2) 而不除 n,其实都只是约定问题,只要你保持损失函数和梯度公式一致,结果就不会跑偏;但如果损失和梯度一个除 n 一个不除 n,那就会出问题。这一点写成注释非常值得:

# 本实现统一除以 n:loss = (1/n) * MSE + (lambda/n) * sum(theta[1:]**2)

5. 带练后的复盘与自查:怎么知道自己不是“抄对”的

5.1 数值梯度校验:给解析梯度照一面镜子

带练过程中最刺激的环节,是用数值梯度校验解析梯度。数值梯度的定义很简单:

[ g_i \approx \frac{J(\theta + \epsilon e_i) - J(\theta - \epsilon e_i)}{2\epsilon} ]

代码写起来很直白:

def numerical_gradient(loss_fn, theta, eps=1e-6): grad = np.zeros_like(theta) for i in range(len(theta)): theta_plus = theta.copy() theta_plus[i] += eps theta_minus = theta.copy() theta_minus[i] -= eps grad[i] = (loss_fn(theta_plus) - loss_fn(theta_minus)) / (2 * eps) return grad

用eps=1e-6,对比gradient(X, y, theta)与numerical_gradient的结果,最大误差通常在1e-5以下。这个检查不是为了在项目里每次都跑,而是在你第一次手写梯度时,确认自己推导的公式没有方向或系数错。如果你用这种校验方法对比一下各种博客里流传的公式,会发现不少公式其实少了一个2/n,单独调库看不出来,手写时就很明显。

我当时第一次跑数值梯度校验时,发现解析梯度和数值梯度差了一个数量级,排查半天发现自己把y的名字覆盖了,导致损失计算完全错误。这种事后想想挺傻的 bug,如果不做校验,可能永远发现不了。

5.2 与 sklearn 结果对照:哪些指标必须完全一致

手写模型最大的好处,是可以和成熟库做对拍,只要数据预处理一致、模型假设一致,结果应当完全一致。带练任务要求我们把自己的train_gradient_descent跑出来的权重,和sklearn.linear_model.LinearRegression对比,误差允许在1e-8以内(如果梯度下降收敛到闭式解)。

对照时要注意几点:

  • LinearRegression默认fit_intercept=True,如果你的设计矩阵已经加了全 1 列,要让 sklearn 设置fit_intercept=False,否则截距会被重复计算两次。
  • sklearn 默认不会对特征做标准化,如果你的手写代码对数据做了标准化,那么权重要换算回原始尺度才能直接比较,或者干脆两边都对同一份标准化后的数据进行拟合。
  • sklearn 的coef_和intercept_是两个属性,而我们手写版里通常是一个向量的第一个元素和其余元素,要注意对齐。

对照结果通过之后,我再去看那些“loss 一直很大”的报错,绝大多数都是处理流程问题,而不是数学公式问题。这让我理解了,模型本身不复杂,复杂的是数据周边的那一整套处理逻辑。

5.3 带练结束后的独立作业:换数据、改损失、加约束

最后一天的独立作业,李哥给了一个要求:必须让代码“背叛”原题。意思是不要只改参数,而是改损失函数或模型约束。我当时选择把 MSE 改成 Huber Loss,这只是替换损失函数,又要重推梯度,等于把整个训练循环再写一遍;有同学选择了把梯度下降改成小批量随机梯度下降,原来两行写停,现在要处理洗牌和批次迭代;还有人尝试实现 Lasso 的次梯度。

换损失函数的过程最痛苦也最有益:你会发现,之前“能用”的损失函数、梯度、验证方法,其实全部耦合在一起。只要损失函数一变,梯度公式、学习率范围、收敛判断都可能要重新调整。经历过这个过程后,我才真正理解了“模型 = 结构 + 损失 + 优化”这三件事是怎么互相制约的。

如果让我总结这轮带练给我留下最深刻的习惯,大概就是:写任何训练代码之前,先把损失函数、梯度、数据形状三条信息写在草稿纸上;跑通之后,再用数值梯度和闭式解做双重校验。看起来像多此一举,但在踩过几次“看起来成功、一到新数据就崩”的坑之后,你会发现这些“多余”的步骤才是真正让人放心的部分。带练结束那天李哥留了一句话,我一直觉得比任何代码都值得记住:代码谁都能抄,但没有任何人能替你把“想通”这一关完成。

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

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

立即咨询