给考研学生讲线性回归代码,这几年带下来我发现一件特别有意思的事:能把线性回归从零手写明白的人,后面学深度学习基本不用我怎么费心;只会调库拿一个coef_的人,学到反向传播十有八九要卡住。所以在我这个深度学习系列里,线性回归这一课从来不讲快,就是带着大家一行行写代码,把每个数字的来路都看清。这篇笔记面向零基础读者,目标只有一个:你亲手训练出w和b,并且能说清楚为什么它们是这个值。整个过程只用numpy,不碰任何高级框架,等你看懂这几十行朴素的代码,再回头看sklearn、PyTorch这些东西,理解速度会完全不一样。
1. 为什么考研党和新手的第一堂课总绕不开线性回归
1.1 笔试和复试里线性回归到底卡在哪个环节
考研初试的数学里,最小二乘求导是高频考点;复试面试的时候,老师很喜欢让你现场讲讲线性回归的原理;如果涉及机器学习方向,损失函数、梯度下降、正则化这些概念又全部挂在线性回归上面。可以说,它既是数学题,也是代码题,更是“你懂不懂模型训练过程”的口试题。但很多考生的状态是:公式会推导,代码只会调包,一旦被问到“梯度下降每一步在更新什么”,就答不上来。
1.2 线性回归代码和深度学习的网络骨架是同一套
深度学习里一个全连接层,本质上就是y = wx + b外面再套一个非线性激活函数。你用线性回归把“前向计算—损失—反向传播—参数更新”这四个环节跑通一遍,后面看到神经网络训练代码时会觉得异常熟悉。因为神经网络的训练循环也是这套骨架,只是中间的张量维度更复杂、梯度由自动求导算出来了而已。换句话说,线性回归是唯一一个每个环节都能手算验证的模型,你完全可以把答案算出来再对照代码结果,这种“心里有底”的感觉对新手极其重要。
1.3 从公式到代码其实就三步翻译
数学上写一元线性回归,就是y = wx + b。翻译成代码,第一步把x放成一组数据;第二步用w * x + b算出预测值;第三步算预测值和真实值之间的差距,再按梯度方向修一下w和b。难点往往不在“求导”,而在“把求和符号翻译成numpy的数组运算”。比如公式里对一个批次求和,代码里常常就是np.mean(...);公式里的下标i,代码里会被一个向量整体吃掉。这一步需要一点时间适应,但一旦适应了,你再看任何机器学习代码都能很快抓住主干。
2. 动手写代码前,先把数据 shape 和模型参数的关系想清楚
2.1 输入 X 到底是什么形状,这是小白最容易懵的地方
很多新手拿到数据后第一件事就是直接跑模型,从没想过X的形状代表什么。在numpy里,一条数据样本常用一维数组表示,一个批次的数据就是二维数组,形状是(样本数 m, 特征数 n)。一元线性回归里特征数n = 1,所以理想状态下X应该长成(m, 1),但实际上我们会为了计算方便先拿一个长度为m的一维数组x做实验。这两种形状写出来的前向计算代码不一样:一维时直接用w * x,利用广播机制得到每个样本的预测值;二维时用X @ w,做的是标准的矩阵向量乘法。我见过大量报错都来自形状不匹配,所以建议你写每行代码前,都先想想当前的变量到底长什么样,不确定就print(x.shape)看一眼,这个习惯能救你很多次。
2.2 参数 w 和偏置 b 为什么要分开维护
书本上为了公式简洁,会把w和b拼成一个增广向量,同时在X左侧加一列 1。这个技巧在推导和调库时很漂亮,但对新手不友好:你很难看清到底在更新什么。所以我讲课时推荐先用最笨的办法——w是一个标量,b是一个标量,各自求梯度,各自更新。等代码跑通了,你自然能理解为什么正规化写法可以合并,那时再追求公式的紧凑也不迟。分开维护的好处是每一步都可以print(w, b)看变化,心理压力小很多。
2.3 均方误差里的“1/2”到底哪来的
损失函数最常见的写法是L = (1/m) * Σ (y_pred - y)²,但很多教材喜欢写成L = (1/(2m)) * Σ (y_pred - y)²。多出的这个1/2,纯粹是为了求导时消掉平方带来的因子 2,让梯度公式更好看。要注意的是,乘不乘1/2在数学上不影响最优点位置,因为损失函数乘以一个正常数,最小值点不变。但它会影响梯度的大小,也就是说,它对学习率相当敏感。同一套学习率下,带1/2的版本梯度变小一倍,收敛看起来更慢。你如果自己实现,选哪种都行,但一定要清楚自己用的是哪套,否则换学习率时容易被梯度符号搞糊涂。
3. 从零手写一元线性回归:每一行 numpy 代码都要能说出依据
3.1 造一份可以反复验证的数据
在真正跑任何真实数据集之前,先自己伪造一份数据。用什么真实参数生成,就用什么参数去验收,这比直接拿真实数据训练要踏实得多。我这里用true_w = 3.0、true_b = 7.0,加一点高斯噪声模拟现实情况:
import numpy as np np.random.seed(42) m = 200 x = np.random.uniform(-3.0, 3.0, size=m) true_w, true_b = 3.0, 7.0 y = true_w * x + true_b + np.random.normal(0, 0.8, size=m)注意这里故意加了噪声。如果我们不加噪声,这是纯粹解方程问题,不需要什么机器学习;加了噪声之后,模型学到的w会接近 3.0 但不会完全等于 3.0,因为噪声让数据不再完美落在一条直线上。这个细节理解了,你就明白线性回归做的是“从带噪声的观测里恢复潜在规律”。
3.2 训练循环的四步:前向、损失、梯度、更新
核心代码其实就是一个循环,循环里反复做四件事:
w, b = 0.0, 0.0 lr = 0.05 epochs = 500 for epoch in range(epochs): # 1. 前向计算 y_pred = w * x + b # 2. 计算损失 loss = np.mean((y_pred - y) ** 2) # 3. 计算梯度(这里用的是一阶导,没带 1/2) grad_w = np.mean(2 * (y_pred - y) * x) grad_b = np.mean(2 * (y_pred - y)) # 4. 更新参数 w -= lr * grad_w b -= lr * grad_b if epoch % 50 == 0: print(f"epoch {epoch}, loss {loss:.4f}, w {w:.4f}, b {b:.4f}")每一行是什么意思?y_pred是模型当前对所有样本的预测;loss是这个批次上预测偏差的平方均值;grad_w是所有样本的误差乘以对应x再取平均,grad_b直接对误差取平均。之所以求平均而不是求和,是为了让梯度大小不随样本数变化,这样换一批数据时学习率不用重新调。更新参数那两行就是梯度下降的全部动作:往负梯度方向走一小步,步子大小由lr决定。
3.3 判断训练成功不是看损失变成 0
很多新手觉得训练成功就是损失变成 0,这个想法在带噪声的数据上非常危险。你如果把噪声也拟合上,损失确实可以压得很低,但那叫过拟合。判断线性回归是否学好了,应该看两点:一是w和b是否接近生成数据时用的真实参数;二是拟合出来的直线是否穿过了数据的“中心趋势”。比如上面这份数据,训练结束后你大概会得到w ≈ 2.9~3.1,b ≈ 6.8~7.2,这就说明模型看到的是规律而不是噪声。想直观一点,可以把x和y的散点图画出来,再用训练好的参数画一条直线叠上去,看直线是否合理。
4. 代码写完先别急着跑:三招验证自己写的是不是对的
4.1 第一招:用已知答案的构造数据反推
这是最推荐的自验方法。用真实参数生成数据之后,你的w和b大约是从0.0起步,一路逼近真实值。跑完500轮,如果结果和真实值差得很远,先别怀疑噪声,先怀疑自己的梯度写错了。常见的错误包括:grad_w里忘了乘x;grad_b里对误差多乘了一个2;或者更新参数时不小心用了+=而不是-=。用已知答案反推的好处是,一旦数值对不上,你能立刻缩小 bug 范围,而不需要对着真实数据猜模型哪里不对劲。
4.2 第二招:对比 sk-learn 的 LinearRegression
自己没有标准答案时,最方便的参照物就是sklearn:
from sklearn.linear_model import LinearRegression X = x.reshape(-1, 1) model = LinearRegression().fit(X, y) print(model.coef_, model.intercept_)如果自己手写的w和b跟model.coef_[0]、model.intercept_能对到小数点后两位以内,基本可以认定代码逻辑正确。但这里有一个容易被坑的点:sklearn的LinearRegression走的是最小二乘闭式解,也就是后面要讲的正规方程,并没有经过梯度下降。它的结果更接近“数学最优解”,而你的梯度下降最后收敛到附近,两者之间有一点小误差是正常的。把sklearn当“标准答案”可以,不要把差异当成 bug。
4.3 第三招:检查损失曲线的形状
如果你在训练里记录了每一轮的损失,把损失画出来,它应该是一个“陡峭下降后逐渐平坦”的曲线。正常情况不会出现明显的上升段,曲线一旦上升,说明学习率太大,参数跨过了最优点;如果曲线从头到尾都基本不动,说明学习率太小,或者数据没有归一化。还有一种情况是损失一开始就在nan,这多半是梯度数值溢出,后续章节会专门讲。损失曲线是你诊断模型的第一张化验单,一定要养成记录并观察的习惯。
5. 手写代码 vs sklearn:从优化器差异看懂梯度下降的固执与偷懒
5.1 sklearn 的 LinearRegression 其实没有“训练过程”
这一点很多教程不会讲。sklearn里LinearRegression默认用的是最小二乘的闭式解,也就是直接解正规方程w = (XᵀX)⁻¹Xᵀy,内部还会用 SVD 处理数值稳定性问题。它没有迭代、没有学习率、没有 epoch,一锤子买卖。在数据量不大、特征维度不高的情况下,这是最快的路径。而你自己手写的梯度下降则是一个迭代近似过程,每走一步都“看一眼”当前梯度方向,慢慢蹭过去。两种方法各有适用场景,线性回归因为损失函数是凸函数,梯度下降最终也能收敛到全局最优,只是需要调参数而已。
5.2 深度学习为什么必须用梯度下降
那深度学习为啥不讲闭式解?因为深层网络的损失函数不是凸函数,没有可解的“置零公式”,而且参数量动不动上百万。正规方程要算(XᵀX)⁻¹,这个矩阵求逆在特征维度稍大一点就是灾难。梯度下降的做法就简单粗暴很多:不看全局地形,只看脚下坡往哪边倾斜,沿着坡走一小步,反复走。它的优点是能用于任意可导模型,缺点是需要你操心学习率、初始化、归一化这些细节。理解了这一点,你就知道线性回归手写代码训练的不只是一个模型,而是在训练你对“迭代求解”的直觉。
5.3 学习率到底是个什么量
学习率在代码里就一个数字,但它的影响是全局性的。我经常跟学生说,学习率是“步子大小”:步子太大,你会跨过最优点,在两边来回震荡甚至越走越远;步子太小,你走半天还在原地。对一份没有归一化的数据,特征量级在[-3, 3]左右时,从lr=0.05起步是个合理选择;如果特征量级到几千几万,学习率得相应缩小到1e-4甚至更小,否则梯度一乘上巨大特征值直接爆炸。新手最稳妥的办法是先记录损失,用一组学习率做小规模对比,看哪个方向让损失稳定下降,再定最终值。不要迷信某个“万能学习率”,它一定依赖你的数据量级、损失函数写法以及是否做归一化。
我整理过一个简易的对照,方便你感受差异:
| 学习率 | 典型表现 |
|---|---|
| 1.0 | 容易震荡,损失曲线跳跃甚至发散 |
| 0.1 | 下降快,但有时在最优点附近来回摆 |
| 0.05 | 数据量级小的时候比较稳妥 |
| 0.001 | 大概率能收敛,但可能需要很多轮才能看到效果 |
6. 从一元到多元再到逻辑回归:改动最小,但思维要换一层
6.1 多元线性回归:把 x 从标量换成向量
一元线性回归的代码写熟练之后,扩到多元只是把x从一维数组变成二维矩阵,其余逻辑几乎不动。前向变成y_pred = X @ w + b,梯度变成:
grad_w = (2 / m) * (X.T @ (y_pred - y)) grad_b = (2 / m) * np.sum(y_pred - y)这里的X.T @ (y_pred - y),本质就是把“每个样本误差乘对应特征再求和”这件事用矩阵乘法一次性做完。很多新手第一眼看到会懵,其实就是一元情形那个mean(2 * (y_pred - y) * x)的批量版本。理解这个对应关系后,你会发现代码简洁了许多,也更接近资料里常见的写法。这一步只需要把w从标量变成向量,但思维上要接受“一个特征配一个权重”的模式,后面看深度学习特征图之类的概念会顺畅很多。
6.2 逻辑回归:本质是在线性输出外面套一个 sigmoid
如果你把线性回归的代码拿过来做分类,最直接的办法是把输出z = X @ w + b送进sigmoid函数,将结果压到 0 到 1 之间当成概率:
def sigmoid(z): return 1 / (1 + np.exp(-z)) z = X @ w + b y_pred = sigmoid(z) loss = -np.mean(y * np.log(y_pred + 1e-8) + (1 - y) * np.log(1 - y_pred + 1e-8))损失函数从均方误差换成交叉熵,梯度则神奇地保持了“误差乘特征”的形式:
grad_w = (X.T @ (y_pred - y)) / m grad_b = np.mean(y_pred - y)也就是说,逻辑回归与线性回归的代码结构高度相似,区别只是前向多套一层 sigmoid、损失换成对数形式、梯度里少一个2。能看出这一层变化,你就已经理解了两个经典模型,而不是只会背两段代码。
6.3 写到这一步,深度学习就只剩“反向传播”一个台阶
全连接层、激活函数、损失函数、训练循环——线性回归和逻辑回归的手写代码已经覆盖了这些东西。剩下的就是多层堆叠之后,梯度不再能直接手推,需要靠链式法则从后往前传,也就是所谓的反向传播。理解这一步之前,你手上已经有了“前向计算”和“损失函数”这两个最重要的感知,后面看任何框架的loss.backward()都不会觉得它是什么黑魔法。很多考研党学到深度学习卡壳,不是卡在神经网络本身,而是卡在“模型到底是什么、训练到底在干嘛”这些前置认知上,而这些前置认知恰好就是线性回归代码训练给你的。
7. 实际踩过的几个坑:学习率、特征归一化与损失值“卡死”
7.1 学习率过大导致梯度爆炸,参数直接变成 NaN
这是新手最常见、也最容易慌的问题。现象是一开始训练,损失先是变小,然后突然变成nan,或者一轮迭代之后w变成了一个天文数字。本质原因很简单:学习率太大,参数一步跨过头;下一步梯度方向反过来,又跨到另一边,来回震荡幅度越来越大,最终数值溢出。解决手段分两路:快速止血是把学习率调小到1e-3甚至1e-5;根治前提是检查数据量级,把特征缩放到较小范围。记住一个经验:当你发现某份数据需要特别小的学习率才能不炸时,它通常是在提醒你先做归一化,而不是继续硬调学习率。
7.2 特征不归一化,多元回归会被大尺度特征带偏
一元回归只有一个特征,归一化问题不明显;多元回归里如果某个特征是“收入”,量级到几万,另一个特征是“年龄”,量级只有几十,梯度方向基本被大尺度特征主导,模型会花大量轮次在调整那个大特征对应的权重,小特征学得很慢。这不是模型笨,而是梯度天然偏向“容易产生大误差的方向”。常见补救是把所有特征标准化为均值 0、方差 1,代码很简单:
x_std = (x - x.mean()) / x.std()标准化之后再训练,学习率才具有普适性,损失曲线也会漂亮很多。这个坑在深度学习里更常见:不同层的特征尺度差异一旦控制不住,训练就很难稳定,这也是批归一化这类技巧出现的背景。
7.3 损失值卡在同一个水平不动,先怀疑 bug 再怀疑学习率
如果损失下降一段后完全卡住,甚至从第一轮开始就不动,我的排查顺序是这样的:先看梯度符号,确认是不是+=和-=写反了;再看损失函数,确认有没有把y和y_pred传反;接着检查 shape,确认X.T @ (y_pred - y)的维度对不对;最后才调学习率。为什么要最后调学习率?因为很多新手一旦发现不收敛就狂调参数,却忽略是代码逻辑错了。逻辑错了,调一万遍学习率也没用。我见过学生花了几个小时调学习率,最后发现是因为把真实标签也标准化了,预测目标完全变了形状。所以记住:先确认代码对,再优化参数。
7.4 自己动手复现一遍,胜过读十篇教程
带考研学生的这几年,我越来越觉得“看懂”和“会写”之间隔着一条巨大的鸿沟。你读十篇线性回归教程,不如自己动手造一份数据、写一个循环、记录每条损失曲线,再看着w和b从初始值慢慢逼近真实值。这个过程里你会踩一些坑,会突然理解“哦原来广播是这个意思”,会体会到调学习率像调火候一样的感觉——这些都是读教程给不了你的。如果你现在还在啃数学推导却迟迟不动手写代码,我的建议是今天就打开编辑器,把上面的代码亲手敲一遍,然后换个模型多跑几轮,很多模模糊糊的地方会一下子变清晰。