1. 从一个最简单的回归任务说起
很多人学深度学习,第一个卡住的地方不是反向传播的数学推导,而是面对一个空白的代码编辑器,不知道从哪一行开始敲。我当初也是这样,看了不少理论,公式推得头头是道,但真让我用代码搭一个能跑起来的模型,手是僵的。后来我发现,入门阶段最有效的路径不是死磕理论,而是找一个足够简单、但又能把核心流程跑通的任务,亲手把它实现一遍。回归任务就是最好的选择。
这篇文章要聊的,就是用PyTorch从零搭建一个回归模型。所谓回归,说白了就是让模型学会从输入预测一个连续的数值。比如给你一堆房屋面积数据,预测对应的价格;给你一组传感器读数,预测明天的温度。它和分类任务的区别在于,分类输出的是离散的类别标签,而回归输出的是一个实数。这个区别看似简单,但它决定了损失函数的选择、输出层的设计、甚至整个训练过程的评估方式。
你可能会问,现在都2025年了,大模型满天飞,学一个最基础的回归有什么意义?我的看法是,回归是理解神经网络训练全流程的最佳载体。它足够简单,简单到你可以在几十行代码里看清每一个环节在做什么;同时它又足够完整,前向传播、损失计算、反向传播、参数更新,一个都不少。你把这个流程吃透了,后面换成卷积网络做图像、换成Transformer做文本,骨架是不变的。
这篇文章适合谁看?如果你已经装了PyTorch,知道张量是什么,但还没完整写过一轮训练循环,那这篇就是写给你的。如果你已经做过一些项目,但当初是照着别人的代码抄的,很多地方一知半解,那这篇也能帮你把那些模糊的地方补上。我会尽量把每一步为什么这么做讲清楚,而不是只丢一段代码让你自己悟。
2. 回归任务的整体设计思路
2.1 为什么选线性回归作为切入点
回归任务有很多种实现方式,从最简单的线性回归到复杂的多项式回归、甚至用神经网络拟合任意非线性函数。我建议入门时先用最基础的线性回归,也就是用一条直线去拟合数据。原因很简单:直线是你能在二维平面上画出来的,模型在学什么、学得好不好,你一眼就能看出来。
如果用复杂的非线性模型,损失曲线可能下降得很漂亮,但你很难直观判断模型到底有没有学到数据的真实规律。而线性回归不一样,真实的数据分布和模型拟合的直线可以同时画在一张图上,拟合效果好不好,肉眼就能判断。这种直观的反馈对初学者非常重要,它能帮你建立对训练过程的“手感”。
另外,线性回归的数学形式极其简洁:y = wx + b。模型要学的就是w(权重)和b(偏置)这两个参数。参数少意味着训练快,你可以在几秒钟内跑完几百轮迭代,快速看到损失下降的过程。这种即时反馈能帮你把注意力集中在训练流程本身,而不是等待漫长的计算。
2.2 用PyTorch实现回归的核心模块拆解
用PyTorch做回归,不管任务多复杂,核心模块就那么几个。我把它们拆开来说,你在写代码的时候心里就有谱了。
数据准备是整个流程的起点。你需要构造或加载输入特征和对应的目标值。入门阶段我建议自己造数据,因为这样你清楚地知道真实的数据规律是什么,模型学出来的参数对不对,一对比就知道。比如你可以设定真实的w=3、b=2,然后加一些噪声生成一批数据点,看模型能不能把这两个参数逼近到接近3和2。
模型定义决定了你用什么样的函数去拟合数据。PyTorch里定义模型有两种主流写法:一种是用nn.Linear直接构建线性层,另一种是继承nn.Module自定义模型类。入门阶段我推荐先用nn.Linear,因为它足够简单,一行代码就能定义一个线性层。等你熟悉了整个流程,再过渡到自定义nn.Module的写法,这样学习曲线更平滑。
损失函数衡量的是模型预测值和真实值之间的差距。回归任务最常用的损失函数是均方误差(MSE),也就是把每个样本的预测误差平方后求平均。为什么用平方而不是绝对值?因为平方函数处处可导,而且对大误差的惩罚更重,模型会优先去修正那些偏差大的样本。这个选择背后有数学上的考量,但入门阶段你只需要记住:回归任务默认用MSE就对了。
优化器负责根据损失函数的梯度来更新模型参数。PyTorch里最常用的是随机梯度下降(SGD)和Adam。SGD是最基础的优化器,理解它有助于你搞懂梯度下降的本质;Adam则是在SGD基础上做了改进,收敛更快,对学习率不那么敏感。入门阶段我建议先用SGD,把学习率调参的过程体验一遍,然后再换Adam感受一下差异。
训练循环是把上面这些模块串起来的主线。每一轮迭代包含四个固定动作:前向传播计算预测值、计算损失、反向传播求梯度、更新参数。这四个动作的顺序是固定的,但里面的细节有很多可以调整的地方,比如要不要清零梯度、什么时候清零、要不要记录损失值等等。这些细节我在后面的实操部分会逐一展开。
2.3 训练流程的骨架与关键决策点
整个训练流程可以用一个循环来概括,但在这个循环里,有几个关键决策点直接决定了训练能不能成功。
第一个决策点是学习率的选择。学习率太大,损失会震荡甚至发散;学习率太小,收敛速度慢到让人怀疑代码写错了。我一般会从0.01或0.001开始试,观察损失曲线的形态。如果损失在前几轮就变成NaN,那基本是学习率太大了;如果损失下降得极其缓慢,那可能是学习率偏小。
第二个决策点是批大小的选择。批大小决定了每次参数更新时用多少个样本计算梯度。批大小越大,梯度估计越准确,但计算开销也越大;批大小越小,梯度噪声越大,但有时候这种噪声反而有助于跳出局部最优。入门阶段数据量小,我建议直接用全量数据计算梯度,也就是批大小等于样本总数,这样梯度是最准确的,训练过程也最稳定。
第三个决策点是训练轮数的确定。轮数太少,模型没学到位;轮数太多,可能过拟合。对于简单的线性回归,我一般会先跑一个较大的轮数(比如1000轮),观察损失曲线什么时候趋于平稳,然后根据这个观察来调整。如果损失在200轮左右就不怎么降了,那设1000轮和设500轮效果差不多,但后者更省时间。
3. 核心细节解析与实操要点
3.1 数据构造:为什么要自己造数据
入门阶段我强烈建议自己构造数据,而不是直接用现成的数据集。原因有三个:第一,自己造数据你知道真实的参数是什么,模型学出来的参数和真实参数一对比,就知道学得对不对;第二,你可以控制数据的规模和噪声水平,从小规模、低噪声开始,逐步增加难度;第三,造数据的代码本身很简单,不会让你在数据加载上卡住。
构造线性回归数据的核心逻辑是:先设定真实的权重和偏置,然后生成一批输入值,按照线性关系算出对应的输出值,最后在输出值上加一些随机噪声。噪声的作用是模拟真实世界的不完美,让模型不能简单地通过解方程来得到精确解,而是要通过优化来逼近。
噪声的幅度需要控制。噪声太小,模型很容易就拟合到完美,你感受不到训练过程的动态变化;噪声太大,数据点散得太开,模型可能学不到任何有意义的规律。我一般会把噪声的标准差设成真实输出值标准差的十分之一左右,这样既有挑战性,又不至于让数据完全失去规律。
3.2 模型定义:nn.Linear背后的细节
nn.Linear(in_features, out_features)这一行代码看起来简单,但里面有几个细节值得说清楚。
in_features是输入特征的维度。对于最简单的单变量回归,输入只有一个特征,所以是1。out_features是输出维度,回归任务输出一个数值,所以也是1。这两个参数决定了线性层的权重矩阵的形状:权重是一个out_features × in_features的矩阵,偏置是一个长度为out_features的向量。
当你把数据喂给这个层的时候,PyTorch会自动做矩阵乘法。如果你的输入是一个形状为(N, 1)的张量,其中N是样本数,那么输出就是(N, 1)。这里有个容易踩的坑:如果你的输入是形状(N,)的一维张量,PyTorch也能处理,但输出的形状会变成(N,),在某些情况下会导致广播行为不符合预期。所以我建议从一开始就养成习惯,把输入整理成(N, 1)的二维形状。
另外,nn.Linear初始化的时候会随机生成权重和偏置。这个随机初始化很重要,如果所有参数都初始化为零,那么每个神经元的输出都一样,反向传播时梯度也一样,模型永远学不到东西。PyTorch默认的初始化方式已经足够好,入门阶段不需要自己去调。
3.3 损失函数:MSE为什么是回归的默认选择
均方误差(MSE)的公式是:把每个样本的预测误差平方,然后求平均。这个选择不是随意的,背后有几个考量。
首先,平方函数是光滑的、处处可导的,这对基于梯度的优化方法很友好。相比之下,绝对值函数在零点不可导,虽然实际实现中可以用次梯度来处理,但总归多了一层麻烦。
其次,平方对大误差的惩罚更重。假设一个样本的误差是2,另一个是0.5,平方之后分别是4和0.25,差距被放大了。这意味着模型会优先去修正那些偏差大的样本,而不是平均用力。在很多实际场景中,这种特性是符合需求的,因为我们通常更在意那些预测得特别离谱的样本。
不过MSE也有缺点。当数据中存在极端离群点时,平方会把这些离群点的误差放得非常大,导致模型被少数几个异常样本带偏。如果你的数据里离群点很多,可以考虑用Huber损失或者平滑L1损失,它们在误差较小时表现为平方,误差较大时表现为线性,对离群点更鲁棒。入门阶段先用MSE,等遇到实际问题了再考虑换。
3.4 优化器:SGD和Adam的取舍
SGD的更新规则很直观:参数沿着梯度的反方向移动,移动的步长由学习率控制。它的优点是简单、内存占用小,而且理论上可以收敛到全局最优(对于凸问题)。缺点是收敛速度慢,而且对学习率非常敏感,学习率没调好就容易出问题。
Adam则是在SGD基础上做了两个改进:一是引入了动量,让参数更新方向不仅取决于当前梯度,还取决于历史梯度的累积方向;二是对每个参数自适应地调整学习率,梯度大的参数学习率小,梯度小的参数学习率大。这些改进让Adam在大多数任务上都能更快收敛,而且对初始学习率不那么挑剔。
那入门阶段到底用哪个?我的建议是先用SGD跑一遍,感受一下学习率对训练的影响,然后再用Adam跑一遍,对比两者的收敛曲线。这个对比过程本身就是一个很好的学习体验。等你对两种优化器的行为都有了直观感受,以后遇到新任务时就能更快地判断该用哪个。
3.5 训练循环:四个固定动作的细节
训练循环里的四个动作——前向传播、计算损失、反向传播、更新参数——顺序是固定的,但每个动作都有一些容易忽略的细节。
前向传播就是把输入数据喂给模型,得到预测值。这一步没什么特别的,但要注意模型和数据必须在同一个设备上(CPU或GPU)。入门阶段用CPU就够了,数据量小,CPU完全能应付。
计算损失就是把预测值和真实值一起传给损失函数,得到一个标量。这个标量就是当前模型的表现指标,值越小说明预测越准。
反向传播是调用loss.backward(),PyTorch会自动计算损失对所有需要梯度的参数的偏导数,并把结果存在每个参数的.grad属性里。这里有个关键点:PyTorch默认会累积梯度,也就是说每次调用backward(),梯度是累加到.grad上的,而不是覆盖。所以每次更新参数之前,必须先把梯度清零,否则梯度会越积越大,导致更新步长失控。
更新参数是调用优化器的step()方法,它会根据每个参数的梯度和优化器的规则来更新参数值。这一步之后,一轮训练就完成了。
还有一个细节是with torch.no_grad()的使用。在更新参数的时候,我们不需要PyTorch追踪计算图,因为更新操作本身不是模型前向传播的一部分。用no_grad()包裹更新步骤可以节省内存和计算资源。不过对于入门级的代码,不加也不会有大问题,但养成好习惯总是好的。
4. 完整实操过程与核心环节实现
4.1 环境准备与依赖确认
在开始写代码之前,先确认你的环境里已经装好了PyTorch。如果你用的是Anaconda,可以用conda list查看已安装的包;如果用pip,可以用pip show torch。确认版本号之后,顺便看一眼有没有装matplotlib,因为后面画损失曲线和拟合直线要用到。
我建议在Jupyter Notebook或者类似的交互式环境里写这段代码,因为你可以一段一段地运行,随时查看中间变量的形状和值。这对于理解每一步在做什么非常有帮助。如果你习惯用IDE,那也没问题,只是调试的时候可能需要多打几个断点。
4.2 生成模拟数据
先设定真实参数:权重为3,偏置为2。然后生成100个输入样本,范围在-1到1之间。按照线性关系算出对应的输出,再加上一些随机噪声。
import torch import matplotlib.pyplot as plt # 设定随机种子,保证每次运行结果一致 torch.manual_seed(42) # 真实参数 true_w = 3.0 true_b = 2.0 # 生成输入数据,形状为(100, 1) x = torch.randn(100, 1) # 按照线性关系计算输出,并加入噪声 noise = torch.randn(100, 1) * 0.1 y = true_w * x + true_b + noise这里用torch.randn生成标准正态分布的随机数,而不是均匀分布。正态分布的数据更接近真实场景中很多特征的分布形态。噪声的标准差设为0.1,相对于输出值的范围来说是一个适中的噪声水平。
生成数据之后,我习惯先画一张散点图看看数据长什么样。这一步看起来可有可无,但实际上很有价值。如果数据生成出了问题,比如形状不对或者噪声太大,散点图上一眼就能看出来。
plt.scatter(x.numpy(), y.numpy(), s=10) plt.xlabel('x') plt.ylabel('y') plt.title('Generated Data') plt.show()4.3 定义模型、损失函数和优化器
模型用一个线性层就够了。损失函数用MSE,优化器先用SGD,学习率设为0.01。
import torch.nn as nn # 定义模型 model = nn.Linear(1, 1) # 定义损失函数 criterion = nn.MSELoss() # 定义优化器 optimizer = torch.optim.SGD(model.parameters(), lr=0.01)这里model.parameters()返回的是模型中所有需要梯度的参数,对于nn.Linear(1, 1)来说就是权重和偏置两个参数。优化器会管理这些参数的更新。
学习率设为0.01是一个比较保守的起点。对于这个简单的任务,0.01能保证稳定收敛,但速度可能不是最快的。你可以试着改成0.1或0.001,观察损失曲线的变化,感受学习率的影响。
4.4 编写训练循环
训练循环是整段代码的核心。我把它写成1000轮,每100轮打印一次损失值,方便观察收敛过程。
epochs = 1000 losses = [] for epoch in range(epochs): # 前向传播 y_pred = model(x) # 计算损失 loss = criterion(y_pred, y) losses.append(loss.item()) # 清零梯度 optimizer.zero_grad() # 反向传播 loss.backward() # 更新参数 optimizer.step() # 每100轮打印一次 if (epoch + 1) % 100 == 0: print(f'Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}')注意optimizer.zero_grad()的位置。它放在前向传播和损失计算之后、反向传播之前,这是最常见的写法。但严格来说,它放在循环的开头也可以,只要保证在loss.backward()之前梯度是清零的就行。我习惯放在反向传播之前,因为这样逻辑上更连贯:先算梯度,再清旧梯度,再算新梯度。
还有一个细节是loss.item()。loss是一个张量,直接打印会显示张量的信息而不是数值。.item()方法把单元素张量转成Python的浮点数,方便打印和记录。
4.5 观察训练结果与参数对比
训练完成后,把模型学到的权重和偏置打印出来,和真实值对比。
# 打印学到的参数 learned_w = model.weight.item() learned_b = model.bias.item() print(f'Learned w: {learned_w:.4f}, True w: {true_w}') print(f'Learned b: {learned_b:.4f}, True b: {true_b}')如果一切正常,学到的权重应该接近3,偏置接近2。由于噪声的存在,不会完全相等,但应该在合理范围内。如果差距很大,那就要检查学习率、训练轮数或者数据生成是否有问题。
再把损失曲线画出来,看看收敛过程。
plt.plot(losses) plt.xlabel('Epoch') plt.ylabel('Loss') plt.title('Training Loss Curve') plt.show()损失曲线应该是一条先快速下降、然后逐渐趋于平稳的曲线。如果曲线震荡得很厉害,可能是学习率太大了;如果曲线几乎是一条水平线,可能是学习率太小或者模型没有正确更新。
最后,把拟合的直线和原始数据画在一起,直观地看拟合效果。
# 画出原始数据和拟合直线 plt.scatter(x.numpy(), y.numpy(), s=10, label='Data') plt.plot(x.numpy(), model(x).detach().numpy(), color='red', label='Fitted Line') plt.xlabel('x') plt.ylabel('y') plt.legend() plt.title('Fitted Line vs Data') plt.show()这里model(x).detach().numpy()中的detach()是为了把张量从计算图中分离出来,因为画图不需要梯度信息。如果不加detach(),在某些PyTorch版本中可能会报错。
4.6 换成Adam优化器再跑一遍
把优化器换成Adam,学习率还是0.01,其他不变,再跑一遍。
model_adam = nn.Linear(1, 1) optimizer_adam = torch.optim.Adam(model_adam.parameters(), lr=0.01) losses_adam = [] for epoch in range(epochs): y_pred = model_adam(x) loss = criterion(y_pred, y) losses_adam.append(loss.item()) optimizer_adam.zero_grad() loss.backward() optimizer_adam.step() print(f'Adam learned w: {model_adam.weight.item():.4f}') print(f'Adam learned b: {model_adam.bias.item():.4f}')对比两条损失曲线,你会发现Adam通常在前几十轮就降到了一个很低的水平,而SGD可能需要几百轮。但最终两者的损失值可能差不多。这个对比能帮你直观理解两种优化器的差异。
5. 常见问题与排查技巧实录
5.1 损失不下降或变成NaN
这是新手最常遇到的问题。损失不下降,通常有以下几个原因。
学习率太大。这是最常见的原因。学习率太大时,参数更新步长过大,可能直接跨过最优解,甚至导致损失越来越大,最终变成NaN。解决办法是把学习率调小一个数量级,比如从0.1降到0.01,再降到0.001,观察损失曲线的变化。
梯度没有清零。如果忘记调用optimizer.zero_grad(),梯度会不断累积,等效于学习率不断增大,最终导致发散。检查你的训练循环里有没有这一行。
数据没有归一化。如果输入数据的数值范围很大(比如在1000以上),而学习率又没有相应调小,梯度会非常大,导致训练不稳定。解决办法是对输入数据做标准化,让它均值为0、标准差为1。
模型初始化有问题。虽然PyTorch默认的初始化通常没问题,但如果你自定义了模型并且把所有参数初始化为零,那模型永远学不到东西。检查你的初始化逻辑。
5.2 损失下降但参数和真实值差距大
这种情况通常说明模型学到了某种规律,但学到的不是你想要的那个规律。可能的原因有:
数据本身的噪声太大。如果噪声的标准差相对于信号本身很大,模型很难从噪声中分离出真实的线性关系。解决办法是减小噪声,或者增加数据量。
训练轮数不够。模型还没收敛到最优解。增加训练轮数,观察损失是否还在下降。
模型容量不够。如果你用的是线性模型去拟合非线性数据,那无论怎么训练,模型都学不到真实的规律。检查你的数据生成逻辑,确认它确实是线性的。
5.3 训练速度太慢
对于这个简单的回归任务,训练速度慢通常不是计算量的问题,而是代码写法的问题。
没有使用向量化操作。如果你用for循环逐个样本计算,速度会非常慢。PyTorch的优势在于批量计算,确保你把整个数据集一次性喂给模型。
频繁调用.item()。.item()会把GPU上的张量同步到CPU,如果在一个循环里频繁调用,会造成严重的性能瓶颈。只在需要打印或记录的时候调用。
没有使用no_grad()。在评估或画图的时候,用with torch.no_grad()包裹可以避免不必要的计算图构建,节省内存和时间。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 损失变成NaN | 学习率太大 | 打印每轮损失,看是否在NaN前有增大趋势 | 学习率调小10倍 |
| 损失不下降 | 梯度未清零 | 检查训练循环是否有zero_grad | 在backward前加zero_grad |
| 损失下降极慢 | 学习率太小 | 观察损失曲线斜率 | 学习率调大10倍 |
| 参数与真实值差距大 | 噪声太大或轮数不够 | 减小噪声或增加轮数 | 调整数据或训练配置 |
| 训练速度慢 | 未向量化或频繁item | 检查是否有for循环逐样本处理 | 改用批量计算 |
| 损失震荡 | 批大小太小 | 观察损失曲线的波动幅度 | 增大批大小或减小学习率 |
5.5 几个我踩过的坑
第一个坑是忘记把输入整理成二维。我一开始用torch.randn(100)生成数据,形状是(100,),喂给nn.Linear(1, 1)的时候PyTorch没有报错,但输出的形状变成了(100,),和(100, 1)的标签计算损失时发生了广播,结果完全不对。后来我养成了习惯,生成数据后先print(x.shape)确认形状。
第二个坑是在训练循环里画图。我一开始想在每轮训练后都更新一下拟合直线,结果训练速度慢得离谱。原因是画图操作会触发大量的CPU-GPU同步,而且matplotlib本身也不是为高频更新设计的。后来改成训练结束后统一画图,速度快了很多。
第三个坑是用Adam的时候学习率设得和SGD一样。Adam的自适应学习率机制意味着它对初始学习率的敏感度比SGD低,但并不意味着可以随便设。我试过用0.1的学习率跑Adam,结果损失在前几轮就震荡得很厉害。后来改成0.01就稳定了。所以即使用Adam,学习率也要合理设置。
第四个坑是没有设随机种子。有一次我跑了两遍同样的代码,结果参数差异很大,一度以为代码有bug。后来发现是随机初始化导致的。设了torch.manual_seed(42)之后,每次运行结果都可复现,调试起来方便多了。
6. 从线性回归延伸到更复杂的场景
线性回归跑通之后,你可以沿着几个方向继续深入。
增加特征维度。把输入从1维扩展到多维,比如用3个特征预测1个输出。模型还是nn.Linear(3, 1),但你需要构造多维的输入数据。这个扩展能帮你理解多变量回归和特征之间的关系。
引入非线性。在线性层后面加一个激活函数,比如ReLU,模型就能拟合非线性关系了。你可以生成一批非线性数据(比如y = x^2),然后用带激活函数的模型去拟合,观察效果。
换成真实数据集。找一个公开的回归数据集,比如房价预测或气温预测,把学到的流程应用上去。真实数据会有缺失值、异常值、特征量纲不一致等问题,处理这些问题的过程能让你学到很多入门教程里不会讲的东西。
尝试不同的损失函数。把MSE换成Huber损失或平滑L1损失,观察训练过程和最终结果的变化。这能帮你理解损失函数的选择对模型行为的影响。
加入验证集。把数据分成训练集和验证集,在训练过程中同时监控验证损失。当验证损失开始上升而训练损失还在下降时,说明模型过拟合了。这个技巧在实际项目中非常重要,但在入门阶段容易被忽略。
我自己在带新人的时候,通常会让他们把上面这几个方向都试一遍。每试一个方向,他们对训练流程的理解就会深一层。线性回归虽然简单,但它是一个非常好的实验平台,你可以在上面做各种改动,快速看到结果,积累经验。
最后分享一个我个人的习惯:每次写新的训练代码,我都会先用一个极小的数据集(比如10个样本)跑一遍,确认代码没有形状错误、没有梯度问题,然后再换成完整数据集。这个习惯帮我省了很多调试时间,因为小数据集上跑得快,出错也容易定位。等你熟练了之后,这个步骤可以省略,但在入门阶段,它非常有用。