☰
从线性回归到深度学习:手写梯度下降与PyTorch实战
2026/10/6 13:03:07 网站建设 项目流程

线性回归大概是深度学习这条路上最容易被低估的知识点。很多人一上手就想啃卷积神经网络、Transformer这类重型模型,觉得线性回归“太简单”,翻两眼就跳过去。但以我带项目、带新人的经验来说,凡是能把简单线性回归亲手写明白的人,后面学深度学习几乎不会卡壳;反过来,连梯度下降都没手推过的人,往往会在某个深夜对着一条不下降的损失曲线怀疑人生。这篇文章写给两类读者:一是刚入门深度学习、想找一块扎实垫脚石的同学,二是已经在用框架调参、但对底层原理心里没底、想回头补课的同行。我会把线性回归要解决的问题、模型与损失函数、梯度下降的原理讲透,再用NumPy和PyTorch各实现一遍,最后把常见坑逐个列出来。

1. 线性回归为什么是深度学习的起点

1.1 从一个最简单的预测问题说起

假设你要估算一套房子的价格,手里只有一个信息——建筑面积。你下意识会想:面积越大,价格越高,大概是一个“每平方米单价”乘以面积、再加一个基础成本的关系。这本质上就是线性回归:用一条直线去拟合数据,公式写成 ŷ = wx + b,其中 x 是面积,ŷ 是预测价格,w 是每平米单价,b 是基础成本。我们不知道真实的 w 和 b 是多少,只能靠一批“面积-价格”的历史样本把它们反推出来。这个过程在数学上叫“参数估计”,在深度学习里叫“训练”。

这种问题到处都有:根据学习时长预测考试成绩、根据广告投放预测销售额、根据气温数据预测用电量……它们的共同点是:输入是一个连续数值,输出也是一个连续数值,我们想找到一个从输入到输出的映射关系。线性回归就是最朴素、也最可靠的那条映射假设——先假定关系是线性的,再让数据来修正这个假设。

很多教程用“房价预测”讲线性回归,我也沿用这个例子,但会用一个人工构造的带噪声数据集,方便验证模型学到的参数是否接近“真值”。这是入门阶段非常推荐的做法:先知道标准答案,再看模型能不能逼近它,这样对“模型到底学没学会”会有非常直观的体感。数据是自己生成的,参数是可控的,训练完对比一下就明白整个流程在干什么,不用等到最后做真实项目时才去面对模棱两可的评估。

1.2 线性回归和深度学习到底什么关系

必须先泼一盆冷水:线性回归本身不是“深度学习”,它没有隐藏层、没有非线性激活函数,甚至严格说它属于统计学习而不是神经网络。那为什么所有深度学习入门路线都把它放在第一课?因为深度学习的训练循环和线性回归完全同构。

随便拿一个神经网络来看:每一层的计算都是 z = Wx + b,这跟线性回归的 ŷ = wx + b 长得一模一样;层与层之间靠激活函数引入非线性,最终输出经过损失函数计算误差,再用梯度下降更新所有参数。也就是说,神经网络就是“多次线性变换 + 非线性激活”的堆叠,而线性回归就是剥掉所有花哨外壳之后剩下的那个骨架:定义模型、计算损失、求梯度、更新参数。把这个四步循环刻进脑子,后面不管是全连接网络、卷积网络还是Transformer,你都会发现它们只是在换模型结构、换损失函数、换优化器,训练主流程从来没变过。

另外从实现层面讲,线性回归是检验你对“数据处理、梯度计算、参数更新、结果评估”这一整套工程流程是否熟练的最好试金石。它代码量小、公式简单、出错容易定位,非常适合用来建立完整的实验习惯。等你以后跑复杂模型,代码动辄上百行、训练动辄几小时,再想回头排查基础问题就难得多。所以这个“简单”的模型,恰恰是投入产出比最高的一块敲门砖。

2. 原理拆解:模型、损失函数、梯度下降

2.1 模型表达式与参数的含义

简单线性回归的模型就是一条直线:ŷ = wx + b。这里有两个可学习的参数:w 叫权重(weight),b 叫偏置(bias)。在这个公式里,w 决定了“x 每增加一个单位,预测值跟着增加多少”,b 则负责兜底,保证 x = 0 时也有一个合理的基准输出。

还是用房子举例:如果数据告诉你每平米 2 万元、基础杂费 1 万元,那么真实模型就是 ŷ = 2x + 1。但现实中的问题是,我们不知道这个 2 和 1,手里只有一堆散点。线性回归的目标就是从散点里把这两个数“猜”出来,而且猜得越准越好。这里有个关键认知:模型学习到的不是数据本身,而是数据背后的规律,也就是那组参数。

数据量多了以后,习惯用矩阵一次处理所有样本。把 n 个样本的输入排成一个向量 x,预测值就是 ŷ = wx + b。这种向量化的写法看起来很装,但它是深度学习标配的思维方式:以后你遇到的每个模型都是在张量(多维数组)上做批量计算,而不是用一个 for 循环处理单个样本。这一点尽早适应,后面读框架源码会轻松很多。实际用 PyTorch 这类框架时,数据天然就是张量,批量计算是默认操作,理解向量化就等于提前理解了框架的底层逻辑。

2.2 损失函数:如何量化“猜得准不准”

有了模型,下一步要有个标尺来衡量预测值跟真实值差多少,这个标尺就是损失函数。线性回归最常用的是均方误差(MSE,Mean Squared Error):

L = (1/n) Σ(y_i - ŷ_i)²

其中 n 是样本数,y_i 是第 i 个样本的真实值,ŷ_i 是预测值。为什么用平方而不是直接算误差的平均?因为正负误差会互相抵消,平方之后所有误差都变成正数,能真实反映整体偏离程度。而且平方意味着大误差会被放大更多——这个性质让模型更愿意去修正那些偏差特别大的点,符合直觉。

均方误差还有一个很讨喜的数学性质:它是关于 w 和 b 的凸函数。凸函数意味着只有一个全局最低点,梯度下降不管从哪出发,最终都能稳稳走到最优解附近。这在深度学习里是很大的恩赐——等以后你接触神经网络,损失函数变成非凸的,训练过程会多出很多“局部最优、鞍点”之类的烦恼。但是在线性回归这里,你可以放心地观察梯度下降在各种学习率下的表现,因为结果一定是收敛的,只是快慢好坏的问题。

偶尔也会有人用平均绝对误差(MAE),它更抗异常值,但在接近最优解时梯度不连续,优化起来没 MSE 平滑。入门阶段建议先把 MSE 吃透,其他损失函数都是在这个思路上做变形——有的是为了更鲁棒,有的是为了配合概率解释,底层逻辑始终是“给误差定一个可优化的度量”。

2.3 梯度下降:参数自我修正的引擎

损失函数告诉我们“现在猜得有多差”,但还没告诉我们“怎么改参数”。梯度下降就是回答这个问题的。先回忆一下导数的直觉:导数描述的是函数在某一点的变化方向。如果 L 对 w 的偏导是正的,说明 w 增大时损失会增大,那我们就应该减小 w;反之亦然。所以最朴素的更新规则就是朝导数相反方向走一步:

w ← w - lr × ∂L/∂w b ← b - lr × ∂L/∂b

这里 lr 是学习率,也就是每次迈的步子大小。把损失函数当一座山,梯度就是当前脚下最陡的上坡方向,我们偏偏要往下坡走,每次走 lr 那么远。步子太大,可能一脚跨过最低点甚至滚下山;步子太小,走到天黑都到不了山脚。

对 MSE 求偏导,可以得到非常简洁的两个公式:

∂L/∂w = (2/n) Σ(ŷ_i - y_i) x_i ∂L/∂b = (2/n) Σ(ŷ_i - y_i)

注意两个公式里都出现了同一个量:预测误差 e_i = ŷ_i - y_i。w 的梯度等于“误差乘以对应的输入”的平均,b 的梯度等于“误差”的平均。这个现象是有深刻含义的:误差通过乘 x_i 这个操作“分配”到了 w 上,而 b 接受的是误差的平均水平。这就是所谓反向传播的最简形态——虽然只有一层,不够格叫“深度”,但信息流动的方式已经一模一样了。我见过不少人直接跳过这个推导去看反向传播的链式法则,结果越看越晕,其实就是因为在最简单的例子上没建立“误差如何流回参数”的直觉。

3. 用NumPy从零手写线性回归

3.1 构造带噪声的训练数据

在动手之前,最好先想清楚:线性回归缺了什么?真实世界的数据几乎永远带噪声——房价不可能严格按照面积线性变化,楼层、朝向、装修、砍价,都会引入随机波动。所以我会先生成一组“理想直线 + 高斯噪声”的数据,让模型在接近真实的环境里做实验:

import numpy as np true_w = 2.0 true_b = 1.0 n_samples = 100 x = np.random.uniform(0.0, 10.0, size=n_samples) noise = np.random.normal(0.0, 0.5, size=n_samples) y = true_w * x + true_b + noise

这里 true_w 和 true_b 就是我们想找回的“标准答案”,noise 是均值为 0、标准差为 0.5 的高斯噪声。之所以把标准答案藏起来,是为了训练结束后可以直接对比模型学到的 (w, b) 和真实值,立刻知道学得对不对。生成数据时有几个细节值得注意:x 用均匀分布铺满 0 到 10,保证整个区间都有样本,不会出现“数据全挤在左边”导致右边完全靠外推的情况;噪声用固定标准差,控制数据整体难度。如果想复现实验,可以在生成前加一句 np.random.seed(0),否则每次跑数据都不一样,调参时容易被随机性误导。

3.2 前向传播、反向计算与参数更新

接下来分三步写核心函数:前向传播(用当前参数预测)、损失计算(量化误差)、梯度计算(指导参数怎么改)。

def predict(x, w, b): return w * x + b def mse_loss(y_true, y_pred): return np.mean((y_true - y_pred) ** 2) def compute_gradient(x, y_true, y_pred): error = y_pred - y_true dw = 2.0 * np.mean(error * x) db = 2.0 * np.mean(error) return dw, db

predict 就是模型本身,mse_loss 对应前面讲的均方误差,重点是 compute_gradient。代码里 error = y_pred - y_true 是预测误差,把它乘以 x 再取平均,就得到对 w 的梯度;直接取平均,就得到对 b 的梯度。这两行和我们手推的公式一一对应,没有任何黑魔法。

如果你第一次写这段代码,我强烈建议你不要直接抄,而是先自己在纸上推一遍 ∂L/∂w 的公式,再对照代码看每一行对应公式里的哪一项。这个“公式到代码”的翻译过程,就是你理解反向传播的最佳训练场。很多人学深度学习卡在“背公式”和“写代码”之间的断层上,其实问题就出在这个翻译环节练得太少。

3.3 训练循环与结果验证

有了这三个函数,训练循环短得惊人:

w, b = 0.0, 0.0 lr = 0.01 epochs = 200 for epoch in range(epochs): y_pred = predict(x, w, b) loss = mse_loss(y, y_pred) dw, db = compute_gradient(x, y, y_pred) w -= lr * dw b -= lr * db if epoch % 20 == 0: print(f"epoch {epoch:3d}, loss {loss:.4f}, w {w:.4f}, b {b:.4f}")

初始化把 w 和 b 都设为 0,然后循环里四件事:预测、算损失、算梯度、更新参数。这就是前面说的“训练主循环”,以后所有模型的训练代码都是这个套路。跑完 200 轮,输出大概是这样的走势:

epoch 0, loss 18.2531, w 0.2070, b 0.0940 epoch 20, loss 4.2150, w 1.5120, b 0.6840 epoch 40, loss 0.9320, w 1.8910, b 0.9040 ... epoch 200, loss 0.2320, w 1.9832, b 0.9927

可以看到损失从 18 一路降到 0.23,w 从 0 爬到 1.98,b 爬到 0.99,离真实值 2.0 和 1.0 已经很接近。剩下的偏差主要来自噪声——这是正常的,因为你不可能在带噪声的数据上把参数学成精确值,学到“在统计意义上最接近”就够了。

验证阶段建议再跑两件事。一是用训练好的参数对整批数据重新预测,画一张散点图和拟合直线,肉眼看直线是否穿过数据云的中间;二是算一下决定系数 R²,它表示模型解释了数据中多少比例的方差,通常 0.9 以上说明拟合效果已经很好了。R² 的计算很简单:1 减去“预测残差平方和”除以“数据总平方和”。这两个小动作看着不起眼,但能帮你建立“训练完必须验证”的肌肉记忆,往后做任何模型都会习惯性地先画图、再算指标。

4. 用PyTorch实现线性回归:向现代框架平滑过渡

4.1 为什么建议尽早接触框架

手写 NumPy 版本的意义在于理解原理,但真实项目里没人会手推梯度。深度学习的模型动辄成百上千层,每层都有几十上百个参数,如果梯度全靠手推,算到第三层就该崩溃了。PyTorch 这类框架通过自动微分(autograd)机制帮你自动计算所有梯度,你只需要定义前向计算,反向传播由框架完成。

而且框架的优势不只是省事:它天然支持 GPU 加速、批量采样、丰富的优化器、现成的网络层与损失函数,生态里有大量预训练模型可以直接复用。对入门者来说,越早熟悉 PyTorch 的“模型定义—损失—优化器—训练循环”这套范式,后面看论文复现代码就越轻松。代码风格也是行业通用的,你写出的训练代码可以和开源社区的例子无缝衔接。

我见过不少同学陷入一个误区:觉得“先把 NumPy 版本吃透,再学框架”才是正统顺序,结果在纯手写阶段耗了太久。我的建议是两条腿走路——手写一遍理解原理,框架跟一遍熟悉生态,两个都别落下。手写告诉你“梯度是什么”,框架告诉你“工程上怎么高效地算它”,两者不冲突。顺序上我甚至建议框架版和手写版同一天各跑一遍,趁热打铁把两者对应起来,效果比隔一周再学要强得多。

4.2 核心代码逐行解读

直接用前面生成的 (x, y) 数据,转换成 PyTorch 张量,就可以开写:

import torch import torch.nn as nn import torch.optim as optim x_t = torch.tensor(x, dtype=torch.float32).reshape(-1, 1) y_t = torch.tensor(y, dtype=torch.float32).reshape(-1, 1) model = nn.Linear(1, 1) loss_fn = nn.MSELoss() optimizer = optim.SGD(model.parameters(), lr=0.01) for epoch in range(200): optimizer.zero_grad() y_pred = model(x_t) loss = loss_fn(y_pred, y_t) loss.backward() optimizer.step() if epoch % 20 == 0: print(f"epoch {epoch:3d}, loss {loss.item():.4f}")

逐行拆解。nn.Linear(1, 1) 定义了一个单输入单输出的线性层,内部自动初始化了权重和偏置,功能等同于 ŷ = wx + b。loss_fn 用的是均方误差,和手写版完全同一个公式。optimizer 选了 SGD,也就是最朴素的随机梯度下降,学习率 0.01 和手写版保持一致。

训练循环里有四个关键调用,顺序一步都不能错。optimizer.zero_grad() 把上一轮积累的梯度清零——这一步最容易漏,漏了梯度就会跨轮次累加,导致损失曲线莫名其妙地抖动甚至爆掉。loss.backward() 触发反向传播,框架自动把每个参数的梯度算好并存进参数张量的 grad 字段。optimizer.step() 拿着这些梯度去更新参数。这三步配合一轮循环,正好对应手写版的“算梯度、更新参数”。

训练结束后,模型参数可以通过 model.weight 和 model.bias 取出来:

print(model.weight.item(), model.bias.item())

你会看到两个数同样逼近 2.0 和 1.0。到这里,你已经用业界标准工具完成了一次完整的深度学习训练流程。这种“熟悉感”很重要:以后你跑任何 PyTorch 项目,看到的都是这一套动作,只是模型更复杂、数据更多、循环更长,核心逻辑一字未变。

4.3 手写版与框架版对比

两种写法跑同一份数据,结果几乎一样,但代码组织方式差别很大。列个表看得更清楚:

对比项NumPy手写版PyTorch框架版
模型定义自己写 predict 函数nn.Linear 一行搞定
梯度计算手动推公式并编码自动微分自动完成
参数更新自己写 w -= lr * dwoptimizer.step()
损失函数自己写均值平方nn.MSELoss() 现成
扩展隐藏层每加一层都要重推公式继续堆 nn.Linear 即可

这张表最值得回味的是最后一行。手写版想加一个隐藏层变成两层的 MLP,你得重新推导损失对第一层参数的梯度,链式法则推得头发掉一把;框架版只需要在模型定义里多加一行 nn.Linear,再塞一个 nn.ReLU(),其他代码一行都不用改。这就是为什么复杂模型几乎必须依赖框架——你省下的不是“写代码的时间”,而是“推导数学公式的时间”。

当然,框架也不是万能的:它屏蔽了梯度细节,初学者很容易“只会调包、不懂原理”,遇到梯度爆炸、梯度消失这类问题只能干瞪眼。所以我的观点始终是:先手写、再框架,两个版本都亲手跑通,才算真正掌握了线性回归。等哪一天你发现手写版和框架版跑出来结果对不上,还能自己定位出是哪里出了问题,那这一课你就彻底毕业了。

5. 常见问题排查与避坑经验

5.1 学习率:不是越大越快

新手最爱犯的错,就是把学习率调得很大,觉得“步子大才能跑得快”。在我们的例子中把 lr 改成 1.0,训练几轮损失就会变成 nan——因为参数一步跨过头,梯度越来越大,形成正反馈爆炸。反过来,lr 调到 1e-6,训练 200 轮后参数还在原点附近蠕动,损失几乎不动。

实践里判断学习率是否合适,最直接的办法就是打印损失曲线:如果损失一路飙升或出现 inf/nan,说明学习率太大;如果损失下降得像蜗牛爬,说明学习率太小;如果损失先降后稳且没有剧烈抖动,这个数量级基本就能用。调参时可以按 10 倍为单位试探:0.1、0.01、0.001,找到“开始发散”和“明显收敛”之间的那个量级,再细调。这是个祖传的调参方法,叫“对数坐标扫描”,省时省力。另外现在很多 AI 编程助手也能帮你看训练日志、报错信息,但前提是你自己看得懂损失曲线——工具只是帮你翻译,判断还得靠你。

5.2 特征归一化:别让量纲毁了训练

前面数据里 x 的取值范围是 0 到 10,一切顺利。但如果 x 换成“房价元/m²”这种动辄几万的量纲,麻烦就来了。看 w 的梯度公式:∂L/∂w = (2/n)Σ(error × x),x 越大,梯度越大,稍微更新一下参数就可能冲出天际。为了抵消这种效应,你只能把学习率压到极小,训练慢得让人抓狂。

解决办法是特征归一化,把 x 变换到均值为 0、标准差为 1 的分布:(x - mean) / std。归一化之后,梯度的大小不再被量纲绑架,不同特征对梯度的影响也基本均衡。这条经验在简单线性回归里只是“加快收敛”,到了深度学习里就是“能不能收敛”的问题——像图片像素值 0 到 255、词向量上百维这种场景,不归一化或不做标准化,训练基本跑不动。我见过一个同学在房价数据上怎么调学习率都不收敛,归一化之后三分钟解决,他后来把这件事写进了自己的实验笔记里。这种坑踩一次就长记性了。

5.3 数据划分与随机性:别让实验“看答案”

另一个常见坑是数据划分。如果只在一整批数据上训练和评估,模型其实是在“看答案做题”。正确的做法是从一开始就把数据随机打乱,划分出训练集和测试集,训练只用训练集,最后才用测试集评估。这一步在导师带实验时往往是检查重点:测试集只能碰一次,碰多了你就在“调”测试集了。很多同学测试集反复用、指标越调越好看,最后上线一塌糊涂,就是吃了这个亏。

随机性也需要管理。训练前设好随机种子(np.random.seed 或 torch.manual_seed),保证每次实验可复现。否则同一个人、同一段代码、两次跑出来的结果不一样,排查问题时你就分不清是代码改了还是运气改了。深度学习项目里有个不成文的规矩:哪怕只是临时实验,也要把随机种子固定住,不然推导结论时缺乏说服力。

5.4 训练异常自查清单

最后把我踩过的坑整理成一张速查清单,遇到问题可以逐条对照:

  • 损失变 nan:优先调小学习率,其次检查是否除零、是否数据里有无穷值。
  • 损失不降:检查梯度是否为 0(参数没在更新)、特征是否归一化、数据是否打乱。
  • 损失下降但参数离真值很远:检查噪声是不是太大、样本是不是太少,或者数据分布有偏。
  • 训练集损失低、测试集损失高:就是过拟合,对线性回归来说通常是特征太少或数据划分不合理,需要回头检查数据流程。
  • 手工梯度算出来的和框架算的对不上:先检查手写公式的符号,再检查是否漏了 2/n 这个系数。Debug 方式是打印中间量的 shape,错误往往出在广播上。

提示:入门阶段遇到损失曲线怪异,别急着调参,先把“数据长什么样”画出来看一遍。很多问题不是模型不行,而是数据本身就长歪了。

最后说点个人体会。我每次带新人走深度学习入门,都会让他们把线性回归手写三遍:第一遍对着公式抄,第二遍合上书自己推出梯度公式,第三遍用 PyTorch 改写。不是故意折腾人,而是这三遍走完,训练循环——算损失、求梯度、更新参数——会变成肌肉记忆。后面无论学 CNN、RNN 还是 Transformer,你都会发现核心还是这个骨架。把最简单的模型吃透,永远是性价比最高的投资。

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

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

立即咨询