☰
线性回归从零实现:手写梯度到PyTorch自动求导实战
2026/10/14 4:04:06 网站建设 项目流程

前几天有位刚转行做数据分析的朋友,捧着个讨论群里的项目模板来问我:标题写的是“线性回归从零实现:PyTorch实战详解”,可打开一看,代码里全是nn.Linear和optim.Adam的现成调用,压根看不出“从零”两个字。其实不怪他,如今框架封装得太好了,很多人学完线性回归,依然说不清梯度是从哪来的,参数是怎么更新的。这篇文章我就把自己当年手推公式、手写梯度、再过渡到PyTorch自动求导的完整过程整理成文,全程可复现,适合刚入门深度学习、想搞懂核心原理的读者,也适合那些“调包调了很久但总觉得心里没底”的朋友。

1. 动手之前先搞清楚线性回归在做什么

1.1 线性回归的本质:用一条直线拟合数据关系

线性回归这东西,乍一听高大上,其实本质就是一句话:找一条直线(或者一个超平面),让它尽量穿过所有的数据点。在一维特征的情况下,我们想找的就是y = w * x + b里的w和b,让这条直线和真实数据点之间的误差尽可能小。

我当初学的时候,老师给过一个特别接地气的类比:你有一堆散点,像洒在桌上的米粒,现在要拿一根笔直的筷子去“压住”尽可能多的米粒,筷子放哪里最合适?这个“最合适”的标准,就是所有米粒到筷子的垂直距离之和最小。当然,实际计算时我们不直接用距离,而是用预测值和真实值之差的平方,原因后面再说。

到了多维特征场景,就变成y = x1*w1 + x2*w2 + ... + xd*wd + b,写成向量形式就是y = Xw + b。这也是为什么线性回归被称为深度学习入门第一课,因为神经网络的全连接层(nn.Linear)本质上就是在做这个矩阵乘法加偏置,只不过后面跟了激活函数。

“从零实现”这四个字,意味着我们不直接用PyTorch封装好的nn.Linear,而是亲手把w和b定义成变量,自己写矩阵乘法,自己算梯度,再自己更新参数。这个过程会把框架藏起来的细节全部暴露出来。等你手动实现过一遍,再回头看nn.Linear、loss.backward()、optimizer.step()的时候,就会有一种“原来是这么回事”的通透感。

1.2 为什么用均方误差当损失函数

线性回归最常用的损失函数是均方误差(MSE),公式长这样:

L = (1/n) * Σ (y_pred - y_true)^2

为什么要用平方而不是绝对值?两个原因。第一,平方函数在零点附近是平滑的,处处可导,这给梯度下降提供了很好的数学性质;绝对值函数在零点有个尖角,导数不连续,会让优化过程变得很别扭。第二,平方误差对大误差的惩罚呈二次增长,这意味着那些偏离很远的异常点会被重点“照顾”,模型会拼了命去压低这些点的误差——当然,这也是一把双刃剑,数据里有明显离群点的时候,MSE反而会被带偏。

实际代码里,我们通常会在公式里除以n取平均,而不是求和。原因很简单:不同数据量的数据集之间需要一个可比的标准,比如100条数据的误差总和天然就比1000条数据的小,但平均误差就有可比性了。有些教程还会在多一个1/2的系数,纯粹是为了求导后把2消掉,让公式更好看,对结果没有实质影响。

接下来我们把这条公式对w求导,这是从零实现最核心的推导。设y_pred = x * w + b,那么:

∂L/∂w = (2/n) * Σ x * (y_pred - y_true) ∂L/∂b = (2/n) * Σ (y_pred - y_true)

梯度算出来后,参数的更新就走下坡路:

w = w - lr * ∂L/∂w b = b - lr * ∂L/∂b

这个更新逻辑就是传说中的梯度下降。你想过没有,为什么是“减”而不是“加”?因为梯度指向的是函数值增长最快的方向,我们要找的是最小值,所以要反着走,想象自己在山谷里要下山,每一步都往最陡的下坡方向迈,这就是“减梯度”。这个直觉贯穿整个深度学习,后续学什么模型都跑不掉。

2. 从零实现的准备工作

2.1 环境与依赖安装

动手写代码之前,先把环境准备好。我用的组合非常简单:Python 3.10 + PyTorch 2.2 + matplotlib + numpy。线性回归的计算量极小,CPU完全跑得动,不需要GPU,所以安装CPU版的PyTorch就够用了。如果你还没有PyTorch,建议去官网主页选择操作系统和CPU选项复制安装命令——不要图省事直接pip install torch拉最新的,那个默认版本可能在你的机器上有兼容问题。

我个人的习惯是给这类练习项目单独建一个虚拟环境,别把依赖装得到处都是,免得后面做别的项目时版本冲突。用conda的话一行搞定:

conda create -n lr_tutorial python=3.10 conda activate lr_tutorial pip install torch matplotlib numpy

装完之后验证一下能不能导入:

import torch print(torch.__version__)

能打印出版本号,说明环境没问题。这里多提一嘴,我碰到过不少新手在import时报错,多半是装错了Python架构(比如在ARM64的Mac上装了x86的包),或者是pip和conda混用导致包损坏。如果遇到这类问题,最省心的办法就是删掉环境重建,别在环境的坑里耗太久。

2.2 生成带噪声的模拟数据:加了噪声才能模拟真实世界

训练一个模型,总得有数据。我造数据的方案是:先选定一组“真实参数”作为隐藏的答案,然后从这组参数出发生成一系列带干扰的数据点。把真实的w设成2,b设成1,输入x在0到10之间均匀采样,然后给输出加一点随机噪声,用来模拟现实中各种没被建模的因素造成的误差。

import torch import numpy as np import matplotlib.pyplot as plt torch.manual_seed(42) true_w = torch.tensor(2.0) true_b = torch.tensor(1.0) n_samples = 100 x = torch.linspace(0, 10, n_samples).reshape(-1, 1) noise = torch.randn(n_samples, 1) * 0.8 y = true_w * x + true_b + noise

为什么噪声的标准差取0.8?因为这个尺度相对y的范围(1到21左右)来说不算大,能让数据点围绕直线上下浮动,看着是那么回事,又不会让拟合难度过高。你可以回头把标准差改成5试试,模型大概率还是能学出大致趋势,但你会发现w和b的估计值明显偏离真实值——这是因为噪声太大,“信号”几乎被“噪声”淹没了,这正是真实世界里常见的困境:不是模型的错,是数据本身就不干净。

数据生成完了,画个图看看分布:

plt.scatter(x.numpy(), y.numpy(), alpha=0.7) plt.xlabel("x") plt.ylabel("y") plt.show()

这一步很有必要,做任何数据分析前先可视化,能直观地了解数据形态,也能提前发现数据生成代码里的问题(比如形状不对、取值范围不合理之类)。我见过有人把x生成了1维张量,y生成了二维列向量,结果后面矩阵运算直接对不上,全靠reshape硬掰。等下讲到张量形状时还要再强调这个问题。

3. 拒绝黑盒:手动实现梯度下降

3.1 前向传播与损失计算:手算第一遍

“从零实现”的关键一步来了:不调用任何深度学习框架的自动求导,自己动手写前向传播、损失函数和梯度更新。这一遍走通了,你就拥有了深度学习里最重要的肌肉记忆。

首先是模型的前向计算:

def prediction(x, w, b): return x * w + b

这里x的形状是(100, 1),w和b是标量。PyTorch的广播机制会把标量和整个张量做逐元素运算,结果还是(100, 1)。如果后面换成多维特征,这个函数就要改成x @ w + b,w变成一个向量,@是矩阵乘法。

然后是损失函数:

def loss_fn(y_pred, y_true): return torch.mean((y_pred - y_true) ** 2)

对照前面的公式看,这里直接算的就是MSE。注意浮点运算的精确性问题:在Python里,两个float相减、平方、求均值,每一步都会有极小的舍入误差,这是正常的,不会影响训练结果。但如果你在调试时发现loss下降到一个非常小的值后不再变化,不要急着怀疑代码,先确认是不是已经收敛到了平坦区域。

初始化参数的时候,w设成0.0,b设成0.0,学习率lr设成0.01,迭代次数epochs设成500。为什么可以用0初始化?因为MSE损失函数对参数是个凸函数,只有一个全局最小值,无论从哪起步都能掉进同一个坑。这也是线性回归和神经网络的一个本质区别——神经网络是非凸的,0初始化会导致所有神经元对称,出现训练不动的问题。

3.2 手写梯度与参数更新:最核心的二十行代码

核心训练循环长这样:

lr = 0.01 epochs = 500 for epoch in range(epochs): # 前向传播 y_pred = prediction(x, w, b) # 计算损失 loss = loss_fn(y_pred, y) # 手动计算梯度(对应推导公式) grad_w = torch.mean(2 * x * (y_pred - y)) grad_b = torch.mean(2 * (y_pred - y)) # 更新参数 w -= lr * grad_w b -= lr * grad_b if epoch % 50 == 0: print(f"Epoch {epoch}: loss={loss.item():.4f}, w={w.item():.4f}, b={b.item():.4f}")

这段代码里藏着好几个新手容易踩的坑,逐个说:

第一,grad_w的公式里有一个2 * x * (y_pred - y),这个乘法是逐元素的,后面torch.mean把所有样本的梯度贡献平均了。有的人会写成torch.sum(2 * x * (y_pred - y)) / n_samples,效果一样。这里容易出的问题是忘了除以n,导致梯度被放大n倍,然后学习率要缩小n倍才能抵消,训练速度变得异常慢。

第二,参数更新用了w -= lr * grad_w这个写法,在Python里这是原地操作。这里我们是标量,同是torch.Tensor,-=就够用。等到后面用PyTorch自动求导时,需要把参数放进torch.no_grad()再更新,因为带梯度的张量不允许直接原地修改。

第三,lr的选择决定了成败。0.01对这个数据集来说是合适的。你可以试着改成0.1——第一轮loss就会爆炸到几万,后面直接nan;改成0.001——loss下降得像蜗牛爬,训练500轮都学不到位。这个现象不是线性回归独有的,是所有梯度下降类算法的通病,后面第5节我会专门展开讲怎么调学习率。

我实际跑了一遍,到第500轮时,w收敛到约1.93,b收敛到约1.25,loss大概在0.52左右。真实值是w=2、b=1,为什么有偏差?因为噪声的存在让数据只能无限接近真实直线,不可能完全还原。这其实是个好现象——如果模型完美地还原了真实参数,反而要怀疑是不是过拟合了。

4. PyTorch实战:让自动求导接管脏活累活

4.1 手动梯度 vs 自动求导:结果居然一模一样

手动实现一遍后,接下来该让PyTorch的自动求导机制接管梯度计算了。这一步能让你直观地看到框架到底帮你做了什么,也能让代码更接近生产环境里真正在用的写法。

在PyTorch里,想让某个张量“记住”它参与过的运算、以便之后反推梯度,只需要在创建它时加上requires_grad=True:

w = torch.tensor(0.0, requires_grad=True) b = torch.tensor(0.0, requires_grad=True)

然后前向传播和损失计算照旧:

y_pred = x * w + b loss = torch.mean((y_pred - y) ** 2)

关键动作来了,只需要一行:

loss.backward()

这行代码会从loss出发,沿着运算链一路反向传播,自动算出loss对w的梯度存到w.grad里,对b的梯度存到b.grad里。你不需要手写grad_w和grad_b的公式了。用print(w.grad)看一眼,数值跟你手动算的一模一样,因为PyTorch在后台做的计算本身就是按照链式法则逐层分解。

参数更新的时候有个细节必须注意——必须放在torch.no_grad()里:

with torch.no_grad(): w -= lr * w.grad b -= lr * b.grad

原因我之前提过:w本来带着梯度信息,如果直接原地修改,PyTorch的运算图会认为“这个变量参与了更新又要求梯度”,会报错或者算错。所以要先告诉它“这一段运算不需要跟踪梯度”。这层理解特别重要,因为后面训练神经网络时,with torch.no_grad():会出现在每个optimizer.step()相关的代码里,不理解的人只能死记硬背。

还有,每次backward()之后,梯度会累加到grad属性上,如果不清理,下一轮迭代的梯度就是两轮梯度之和,越滚越大,训练直接乱套。所以要加一行业务逻辑:

w.grad.zero_() b.grad.zero_()

把完整训练循环拼起来:

for epoch in range(epochs): y_pred = x * w + b loss = torch.mean((y_pred - y) ** 2) loss.backward() with torch.no_grad(): w -= lr * w.grad b -= lr * b.grad w.grad.zero_() b.grad.zero_() if epoch % 50 == 0: print(f"Epoch {epoch}: loss={loss.item():.4f}, w={w.item():.4f}, b={b.item():.4f}")

这段代码跑出来的收敛曲线和手动版本几乎逐字吻合。前者让你理解原理,后者让你会用工具,这条学习路线我强烈推荐给每个入门者。

4.2 用优化器封装参数更新:生产环境的标配写法

手动w -= lr * w.grad这种写法,在只有两个参数的时候没问题,但等模型变成几十层神经网络、参数动辄上百万的时候,你再也不可能手动更新每个weight。这时候就轮到torch.optim登场了。

用优化器的核心逻辑只有三步:

  • 创建优化器时传入需要更新的参数列表和学习率
  • 每轮迭代调用optimizer.zero_grad()清空上次梯度
  • loss.backward()算梯度后,调用optimizer.step()更新参数

代码长这样:

import torch.optim as optim w = torch.tensor(0.0, requires_grad=True) b = torch.tensor(0.0, requires_grad=True) optimizer = optim.SGD([w, b], lr=0.01) for epoch in range(epochs): y_pred = x * w + b loss = torch.mean((y_pred - y) ** 2) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 50 == 0: print(f"Epoch {epoch}: loss={loss.item():.4f}, w={w.item():.4f}, b={b.item():.4f}")

看见没有,这三行optimizer.zero_grad()、loss.backward()、optimizer.step()的固定套路,就是你以后写所有深度学习训练循环的骨架。别看现在只是线性回归,这个模式放到GPT这样的大模型上也一样适用。

为什么选择SGD而不是Adam?因为线性回归的优化曲面是碗状的,SGD用最朴素的梯度下降就能匀速下坡,效果已经足够好。Adam的优势在于自适应学习率,在更复杂、更崎岖的曲面上表现更好,但在这个例子引入它反而会掩盖“学习率到底怎么影响训练”这个核心概念。说得更直白一点:先学会走路,再学跑步。

4.3 评估与可视化:怎么判断训练结果好不好

训练完不能只看loss数值,还得看模型学到的参数准不准、拟合效果长什么样。最直接的方式是把预测直线和真实数据点画在一张图上:

with torch.no_grad(): y_pred_final = x * w + b plt.scatter(x.numpy(), y.numpy(), alpha=0.7, label="true data") plt.plot(x.numpy(), y_pred_final.numpy(), color="red", label="fitted line") plt.xlabel("x") plt.ylabel("y") plt.legend() plt.show()

画出来的效果应该是一条从数据点中穿过的红色直线。注意这里我用了torch.no_grad()包裹预测,因为在训练完之后,w和b仍然带着requires_grad=True的状态,直接用的话会额外构建计算图,浪费内存。这是非常容易被忽略的性能小细节。

量化评估方面,除了loss,我习惯额外算一下参数误差和拟合优度。参数误差好办:

print(f"w error: {torch.abs(w - true_w).item():.4f}") print(f"b error: {torch.abs(b - true_b).item():.4f}")

拟合优度可以用决定系数R²,公式是:

R² = 1 - SS_res / SS_tot

其中SS_res是残差平方和,SS_tot是真实值与均值之差的平方和。R²的取值在0到1之间,越接近1说明模型解释了越多的数据方差。代码实现很简洁:

ss_res = torch.sum((y - y_pred_final) ** 2) ss_tot = torch.sum((y - torch.mean(y)) ** 2) r2 = 1 - ss_res / ss_tot print(f"R²: {r2.item():.4f}")

按理说,在这个数据集上R²应该在0.95左右。如果你跑出来远低于这个值,大概率是学习率太小导致还没收敛,或者训练轮数不够。这也给了我一个判断训练是否成功的经验阈值:如果loss还在一路下降,说明没练够;如果loss不动了,R²还很低,说明学习率或模型容量出了问题。

5. 踩过的坑与调参经验

5.1 学习率:那个让你怀疑人生的超参数

我教过不少人跑线性回归,十个里有六个人卡在学习率上。这个数字太小时表现是“乌龟爬”,太大时表现是“直接飞出去”。下面这张表是我基于大量实测经验整理出来的不同学习率对应表现,拿这个数据集的规模做参考:

学习率预期表现原因
0.0001500轮loss只降到2.0左右,w离真实值很远步长太小,还在山腰慢悠悠走
0.01500轮稳定收敛,loss在0.5附近步长合适,平稳下坡
0.05200轮内就收敛,但loss曲线有轻微震荡步长偏大,在谷底附近来回越过
0.1loss到第2轮就飞升到几百甚至NaN步长过大,跨过了谷底冲上对面山坡

为什么0.1会NaN?因为梯度乘以学习率之后,参数的更新幅度超过了合理范围,参数被推到了一个损失函数值特别大的地方,再下一轮的梯度就更夸张,形成正反馈爆炸。数学上就是迭代发散。遇到这种情况不要慌,把学习率降到十分之一再试,基本就能救回来。

我个人的经验是:先按0.01跑一遍,看loss曲线;如果下降得太磨蹭,就逐步放大十倍;如果发散,就逐步缩小十倍。别一上来就追求最完美的学习率,先找一个能收敛的,再细调。

5.2 特征归一化:多特征场景的必备操作

这个例子里的x取值范围是0到10,w=2、b=1,参数之间尺度差异不大,所以不标准化也能顺利收敛。但一旦做多元回归,特征之间的尺度可能会差出几个数量级。比如一个特征是“房间面积”,数值在几十到几百;另一个特征是“房龄”,数值在个位数;还有一个特征是“距地铁站公里数”,可能是0.1到5。

梯度下降对特征的尺度极其敏感。我们对大尺度特征的梯度天然就比其他特征大,参数更新时大尺度特征的w会先被推到合适位置,小尺度特征的w还在原地打转,整个优化过程就会非常“偏科”,收敛速度大打折扣,甚至看起来像卡住了。

解决办法是标准化:

x_mean = torch.mean(x, dim=0) x_std = torch.std(x, dim=0) x_normalized = (x - x_mean) / x_std

把特征缩放到均值为0、方差为1的分布。做完这一步,梯度下降对不同维度的处理就公平了。这个坑在sklearn或者PyTorch里都有现成的API,但你必须理解背后的道理:不是“标准化是个好习惯”,而是“梯度下降本就要求特征尺度相近才好使”。

5.3 常见报错和看似神秘的问题速查表

训练过程中我碰到过不少奇奇怪怪的问题,有些是报错,有些是结果不对。整理成一张速查表,希望能省去你排查的时间:

现象可能原因解决办法
loss第一轮就是nan学习率过大,梯度爆炸把lr缩小10倍以上,检查输入数据是否有NaN
loss卡住不动,w不更新学习率过大导致参数反复震荡,或者梯度清零步骤丢失调小学习率,确认grad.zero_()被执行
训练慢,500轮loss还挺高学习率太小增大lr,或者增加epochs
报错Gradients are None损失到参数的路径断裂,常见于断点赋值或中间变量用了.detach()检查计算图的连贯性,别在loss计算过程中分离张量
报错shape mismatchx的shape和y的shape不一致,或者广播规则没吃透统一用reshape(-1, 1)保证是二维列向量
w拟合到1.5左右就不动了特征没标准化,或者数据噪声过大标准化特征,检查数据生成逻辑

还有一个特别容易被忽视的点:如果你的x是用torch.linspace(0, 10, 100)生成的,它的shape是(100,),是个一维张量;y如果是(100, 1),两者做减法时广播规则会把x当成一行100个元素,然后对每个列重复,绝大多数情况下结果看起来是对

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

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

立即咨询