恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用PyTorch实现线性回归:SGD与MAE的完整实践指南
首页
资讯中心
/
用PyTorch实现线性回归:SGD与MAE的完整实践指南
用PyTorch实现线性回归:SGD与MAE的完整实践指南
发布时间:2026/9/12 2:23:58
很多初学者一上来就想搞卷积神经网络、Transformer结果反手就在 MNIST 上跑了个寂寞。我个人的意见是一切深度学习框架的学习都应该从线性回归开始。原因很简单——它是最小、最完整、最不掺杂花活的“闭环系统”有数据、有模型、有损失函数、有优化器、有训练循环、有可视化。今天这篇文章我就用 PyTorch 带你完整实现一个线性回归并且刻意加入两个关键设计SGD随机梯度下降优化器和MAE平均绝对误差损失函数。这两个词看着简单但里面藏了不少坑我会边写代码边给你拆解清楚。一个“玩具”线性回归能解决什么问题它能帮你搞清楚 PyTorch 的自动求导机制、torch.Tensor和普通数组的区别、optimizer.step()和optimizer.zero_grad()到底在干什么以及模型验证时torch.no_grad()为什么必不可少。这些知识点 100% 会迁移到后续所有更复杂的模型里。所以这篇文章适合谁适合刚配好 PyTorch 环境但不知道下一步干什么的人也适合已经跑过分类任务但回头发现线性模型细节没吃透的人。下面我直接开干。1. 先说清楚为什么要用 SGD MAE 做线性回归1.1 这个项目要解决的三个问题第一数据拟合问题。我们有一组带噪声的线性数据目标是让模型自己“猜”出背后的直线方程。第二框架上手问题。不借助高级封装模块如torch.nn.Linear甚至高级Trainer手写一个从数据生成到参数打印的完整 pipeline。第三损失与优化器理解问题。为什么偏最小二乘的任务我却要故意用 MAE 而不是 MSESGD 和全量梯度下降在结果和速度上到底有什么差异这三个问题正是我从项目标题里拆出来的核心你带着问题去读下面每一段代码才会真正有收获。1.2 为什么这个方案是最适合初学者的组合不少教程喜欢对线性回归直接用nn.MSELoss配optim.Adam跑完就完事了。我不太赞成这种配法主要有两个原因。一是MSE 会放大误差。当数据里出现离群点时平方项会让梯度变得非常暴躁模型为了“照顾”那几个异常点很容易偏离真实直线。MAE 的梯度量级恒定对离群点更钝感对理解模型稳定性的帮助更大。二是Adam 会掩盖你对优化器的理解。Adam 自带自适应学习率和动量你就不会去思考“学习率设多大合适、batch 大小怎么影响收敛”这类问题。而 SGD 是优化器家族的祖宗你把它弄明白了后面理解 Adam、RMSProp 都会快很多。所以我最终选定的技术方案是自写数据生成器 自写线性模型nn.Module nn.L1Loss也就是 MAE torch.optim.SGD 手写训练循环。这四条线串起来就是一个极其干净的入门底座。2. 实操前置环境准备与项目骨架2.1 安装 PyTorch 的两种姿势和坑先说安装。官网上的安装命令会因为 CUDA 版本不同而变我建议你不要直接跑pip install torch这种稀里糊涂的操作。最稳的方式是先去 PyTorch 官网的 Get Started 页面选好你的操作系统、包管理器、CUDA 版本复制对应命令。例如在 Linux pip CUDA 12.1 的条件下你会看到类似下面这行命令pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121如果你只是在纯 CPU 环境下学习那么直接pip install torch就够了。但请记住CPU 版和 CUDA 版在后续安装别的包时经常会出现冲突比如你已经装了 CUDA 版 PyTorch后来又装了从源码编译的某个库它可能会把 torch 重装成 CPU 版这个坑特别隐蔽。没有独立显卡的同学也不需要焦虑线性回归这种规模的模型CPU 跑起来都是瞬间完成。我在自己机器上实测一轮 128 个样本的 epoch 在 CPU 上耗时不到 5 毫秒GPU 在这里没有任何优势。所以“必须要有 GPU 才能学深度学习”是最大的误解。2.2 确认安装是否成功的标准动作装完之后不要立刻跑训练代码先花二十秒确认环境正常。这里给你三个不同层级的检查命令# 第一层能导入 torch python -c import torch; print(torch.__version__) # 第二层确认 CUDA 是否可用没有 N 卡会显示 False这没问题 python -c print(torch.cuda.is_available()) # 第三层快速验证自动求导是否正常 python -c import torch; x torch.tensor([2.0], requires_gradTrue); y x**2; y.backward(); print(x.grad)第三个命令打印出来的应该是tensor([4.])因为 x 的平方对 x 求导等于 2x在 x2 处就是 4。如果这一步没问题说明你的 PyTorch 自动求导链路完全通畅。我自己见过太多人环境都没配好就开始写模型结果报错的时候根本分不清是环境问题还是代码问题。先花两分钟跑完上面三行你后面会省出两小时排查时间。2.3 项目文件的组织方式我们这个项目不需要复杂目录结构两个文件就够一个叫data_gen.py负责生成数据和可视化一个叫train.py负责模型和训练逻辑。这样拆的好处是数据环节的改动不会污染训练代码后续你想换数据分布只需要改一个文件。3. 手撕线性回归代码从数据到训练循环3.1 制造一份带噪声的线性数据真实世界的数据总是带噪声的所以我们要自己造一份“不完全线性”的数据来模拟现实。假设真实直线方程为 (y 2x 1)然后加上高斯噪声。import torch import matplotlib.pyplot as plt torch.manual_seed(42) # 生成 1000 个在 [-3, 3] 之间均匀分布的点并转换为列向量形式 X torch.linspace(-3, 3, steps1000).reshape(-1, 1) # 真实参数w 2.0, b 1.0噪声为标准差为 0.5 的高斯噪声 true_w 2.0 true_b 1.0 y true_w * X true_b 0.5 * torch.randn_like(X) # 可视化一下这批数据 plt.scatter(X.numpy(), y.numpy(), s4) plt.xlabel(x) plt.ylabel(y) plt.title(Synthetic Linear Data with Noise) plt.show()这里有几个细节。第一reshape(-1, 1)非常重要。PyTorch 的线性层nn.Linear要求输入是一个二维张量形状是(batch_size, input_features)。如果你忘了 reshape直接传入一维张量后面每次都老老实实加一个.reshape(-1, 1)也行。第二torch.manual_seed(42)是为了结果可复现不然每次跑数据都不一样分析和调试都会很痛苦。看过散点图你会发现它非常像一个被揉皱了的直线带。我们的目标就是让模型在这个“皱巴巴”的带子里拟合出一条直线让它无限接近 (y 2x 1)。3.2 构建模型三种写法的对比PyTorch 里实现线性模型至少有三条路我推荐你先彻底理解第一种。方式一最彻底的裸写法class LinearRegression(torch.nn.Module): def __init__(self, in_features1, out_features1): super(LinearRegression, self).__init__() self.w torch.nn.Parameter(torch.randn(in_features, out_features)) self.b torch.nn.Parameter(torch.zeros(out_features)) def forward(self, x): return x self.w self.btorch.nn.Parameter是 PyTorch 中的一个特殊容器它会告诉Module“这个张量是模型参数需要梯度请把它登记到model.parameters()里”。你后面调用optimizer torch.optim.SGD(model.parameters(), lr0.01)时优化器能拿到所有需要更新的参数靠的就是这个机制。方式二使用nn.Linear封装model torch.nn.Linear(in_features1, out_features1)这一行就等价于方式一的整个类。nn.Linear内部已经定义好了权重weight和偏置bias并且初始化策略也处理好了。初学者最大的疑惑来自这里——“模型到底在哪”其实就在这一个全连接层里。方式三调用make_linear之类的接口这是最高层级的写法但我不建议你在一开始就用它因为隐藏的细节太多。两种写法没有优劣之分我只是希望你明白方式一是本质方式二是捷径。这篇文章后面的代码用方式二但你要知道方式一的内部结构。3.3 损失函数与优化器MAE 到底在算什么下面这两行是我们项目的灵魂criterion torch.nn.L1Loss() optimizer torch.optim.SGD(model.parameters(), lr0.01)nn.L1Loss计算的是平均绝对误差MAE也就是每个样本的预测值与真实值之差的绝对值然后求平均。公式写出来就是[ \text{MAE} \frac{1}{n} \sum_{i1}^{n} |y_i - \hat{y}_i| ]它和 MSE 最大的区别体现在梯度上。MSE 的梯度是 (2(y_i - \hat{y}_i))误差越大梯度越大MAE 的梯度是 (\text{sign}(y_i - \hat{y}_i))要么正 1 要么负 1大小恒定。这个特性让 MAE 对离群点不那么敏感但在误差接近零点时梯度不会变小也可能导致最后收敛不够精细。所以你在很多实际问题里会看到 Huber Loss它在小误差时表现像 MSE大误差时表现像 MAE是个折中方案。把 MAE 先跑明白再理解 Huber Loss 就很容易了。SGD优化器接收的第一个参数是model.parameters()它是模型所有可学习参数的生成器。lr0.01是学习率它决定了每一步参数更新的步伐。如果把损失函数比作山坡那每次参数更新就是往山下走一步学习率就是你的步长。步长太大容易左右横跳甚至跳出山谷步长太小则要走很久才能到达底部。0.01 对这个任务来说是安全起步值。3.4 核心训练循环每行代码都要吃透训练循环不仅是在线性回归里要用它是你未来写所有 PyTorch 模型的模板。下面这段代码请逐行读懂epochs 300 loss_history [] for epoch in range(epochs): # 前向传播计算预测值 y_pred model(X) # 计算损失 loss criterion(y_pred, y) # 梯度归零关键 optimizer.zero_grad() # 反向传播自动计算每个参数的梯度 loss.backward() # 参数更新w w - lr * grad optimizer.step() loss_history.append(loss.item()) if (epoch 1) % 50 0: print(fEpoch [{epoch1}/{epochs}], Loss: {loss.item():.6f})逐个拆解。y_pred model(X)调用的是模型的forward方法。在这一步里PyTorch 会构建一个动态计算图把所有参与运算的张量和函数关系记录下来。你不需要手动写求导公式loss.backward()会自动沿着这张图反向计算梯度。optimizer.zero_grad()是极其重要的一步。PyTorch 的梯度是累加的而不是每次反向传播后自动清零。如果你不清零那下一轮的梯度就会和上一轮的梯度叠加参数更新方向就是错的。新手最爱犯的错误之一就是把这一行漏掉导致 loss 忽高忽低怎么都不收敛。你可以试着注释掉这一行跑一次十有八九会看到 loss 直接起飞。loss.backward()执行的是反向传播。PyTorch 会自动计算loss对所有requires_gradTrue张量的梯度并保存在对应张量的.grad属性里。注意这一步只是算梯度还没更新参数。optimizer.step()才是真正动手改参数。它做的事非常简单对每个参数param执行param param - lr * param.grad。虽然代码是一行但它是整个训练机制里最核心的动作。最后loss.item()的作用是取出这个标量张量的 Python 数值。为什么不能直接print(loss)因为如果你在 GPU 上训练loss可能还在显存里直接打印显示的是tensor(0.2345, devicecuda:0, grad_fn...)。loss.item()则只取出纯 Python 浮点数不参与梯度计算适合记录下来画曲线。3.5 模型评估与结果可视化训练完成后我们要看看模型学到了什么final_w model.weight.item() final_b model.bias.item() print(fFinal model parameters: w {final_w:.4f}, b {final_b:.4f}) # 预测并可视化 with torch.no_grad(): y_pred model(X) plt.figure(figsize(12, 4)) plt.subplot(1, 2, 1) plt.scatter(X.numpy(), y.numpy(), s4, labelTrue data) plt.plot(X.numpy(), y_pred.numpy(), colorred, linewidth2, labelFitted line) plt.xlabel(x) plt.ylabel(y) plt.legend() plt.title(Fitting Result) plt.subplot(1, 2, 2) plt.plot(range(epochs), loss_history) plt.xlabel(Epoch) plt.ylabel(Loss) plt.title(Loss Curve) plt.show()很多同学第一次跑完会兴奋地发现w和b非常接近 2.0 和 1.0这是正常的因为数据本身就是这么生成的。更值得关注的是 loss 曲线它应该呈现快速下降然后趋于平稳的趋势。torch.no_grad()是一个上下文管理器它告诉 PyTorch 在这个块里“我们只看结果不需要记录计算图”。这会节省大量内存和计算时间。千万不要在测试阶段画蛇添足让预测计算也走自动求导路径。4. 深入调优SGD 的参数细节和 MAE 的隐患4.1 学习率和 batch_size 联合调参的直觉SGD 里有一个默认参数batch_size。在最标准的梯度下降中你用全部数据计算一个损失更新一次参数。但如果数据量很大比如百万级别每一次更新都要算百万个样本的梯度代价太高。SGD 的“随机”之处在于每次只用一个样本或者一小批样本Mini-batch来估算梯度然后用这个估算梯度更新参数。它虽然“不准确”但单次计算开销小并且这种不确定性有时候反而可以帮助模型跳出局部最优。BATCH_SIZE 怎么选在这个任务里数据只有 1000 个点用全量计算其实完全没问题。但我强烈建议你体验一次 mini-batch 的训练方式from torch.utils.data import TensorDataset, DataLoader batch_size 64 dataset TensorDataset(X, y) dataloader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) for epoch in range(epochs): for batch_X, batch_y in dataloader: y_pred model(batch_X) loss criterion(y_pred, batch_y) optimizer.zero_grad() loss.backward() optimizer.step()这里shuffleTrue很重要它保证每个 epoch 里数据被重新打乱避免模型学到样本顺序里的假规律。设置 batch_size 的时候要注意64 比 32 更平滑32 比 64 更“随机”。具体哪个好要看你的 loss 曲线是否震荡震荡太厉害就调大 batch_size 或者降低学习率太慢就调小。4.2 MAE 在收敛末期的抖动问题MAE 的思路虽然很好但它有个天然缺陷误差接近 0 时损失函数在零点处不可导。虽然 PyTorch 在底层实现了次梯度但这个次梯度非 0 即 ±1存在“近似解但不精细”的问题。实践中你会发现使用 SGD MAE 训练loss 曲线最后收敛的时候在极小范围内会有一点上下浮动不像 MSE 那样稳稳贴上最小值。这并不说明你的模型错了只是损失函数的数学特性决定的。如果你确实需要缓解这个问题可以在最后的迭代阶段切换损失函数比如前 200 轮用 MAE后 100 轮换成 MSE 或 HuberLoss。这不是什么高深技巧算是对损失函数性格的利用我用这个 trick 在不少真实项目里都拿到过更好的最终精度。4.3 模型参数初始化的影响线性回归理论上只有一个凸极小值初始权重不影响最终结果但会影响到达极小的速度。如果初始权重远离真实值前几十个 epoch 的 loss 会显得很大如果初始权重非常接近真实值可能 30 个 epoch 就收敛完了。我习惯用torch.nn.Linear默认的初始化方式但如果你想了解自己控制初始化的方式可以直接对weight.data赋值。对于更复杂的网络初始化策略会直接决定模型能否训练起来这算是给后续学习埋一个伏笔。5. 完整工程化模板把它变成后续项目的起点5.1 训练循环的标准化封装跑通一次线性回归只是开始真正的收获是把这个流程模板化。以下是我现在写所有 PyTorch 网络都会套用的骨架你可以直接备份def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 for batch_X, batch_y in dataloader: batch_X, batch_y batch_X.to(device), batch_y.to(device) optimizer.zero_grad() outputs model(batch_X) loss criterion(outputs, batch_y) loss.backward() optimizer.step() total_loss loss.item() * batch_X.size(0) return total_loss / len(dataloader.dataset)注意model.train()这一行。在当前线性回归里它没有任何影响但在后续使用 Dropout、BatchNorm 时train()和eval()模式的行为是不同的。养成写完模型后规范切换模式的习惯会帮你避开很多隐形深坑。5.2 可视化工具固定套路loss 曲线的绘制我强烈建议成为一个默认动作。不要只在控制台打印数值数值只会告诉你“loss 变小了”但曲线能告诉你“到底在第几轮开始过拟合”“学习率是不是太大导致前期震荡”。我的习惯是把每个 epoch 的 loss 和验证集 loss 画在同一张图上两条线的距离就是模型泛化能力的直观体现。5.3 从这个项目再往前走的三个扩展方向第一个方向是把模型从线性替换为多层感知机。你只需要把模型内部换成多个nn.Linear加激活函数训练代码一行都不用改这就立刻跨入了神经网络的大门。第二个方向是把回归问题换成分类问题比如 MNIST 手写数字识别。相应地把nn.L1Loss换成nn.CrossEntropyLoss把最后一层的输出维度改成 10训练流程依然是这套模板。第三个方向是处理真实数据集比如波士顿房价预测或者 UCI 的某个回归数据把数据加载部分从TensorDataset换成Dataset自定义类模型逻辑完全不变。6. 常见问题与排查技巧实录6.1 Loss 变成 NaN 怎么办线性回归里遇到 NaN 的情况不多但如果出现十有八九是学习率过大导致参数更新幅度超界。你把 lr 从 0.01 改成 0.001 试试大概率能解决。另外确认一下你的输入数据里没有无穷大值数据生成时用torch.normal或torch.randn一般不会有这个问题。6.2 参数收敛到了局部最优附近的异常值线性回归的损失函数是凸函数理论上没有局部最优。如果训练完的w和b明显偏离真实值请检查两件事一是噪声标准差是不是太大当噪声方差很大时模型拟合出的参数出现偏差是正常现象二是样本量是不是太少只有 20 个样本时回归结果会被噪声严重扭曲。这类问题不是你代码 bug而是数据本身的性质。6.3 训练速度慢得反常线性回归训练慢最可能的原因是每轮都在 CPU 和 GPU 之间来回拷贝数据。如果你用 Dataset 加载数据时没把数据放到 GPU而是在循环里反复调用.cuda()那传输开销会盖过计算开销。正确做法是在循环外提前把整个 batch 放到设备上。我经常提醒自己的一个原则尽量减少.cpu()和.cuda()之间的反复横跳。6.4 如何判断模型训练充分了很多初学者默认训练轮数越多越好这不对。判断是否充分看 loss 曲线是否进入平台期如果连续几十轮 loss 都不再明显下降再训练下去也只是浪费时间。结合验证集如果验证 loss 开始上升而训练 loss 仍在下降那就是过拟合需要提前终止。7. 思考题与动手实验建议到这里核心代码已经跑通下面给你一份可执行的实验清单用来检验你是否真的理解了 SGD 和 MAE。对比实验一把nn.L1Loss换成nn.MSELoss保持 SGD 不变对比两类损失函数的收敛曲线和收敛后的参数精度。对比实验二把优化器换成 Adam保持 MAE 不变观察收敛速度和 loss 曲线的平滑程度。对比实验三把学习率从 0.01 改成 0.5观察 loss 曲线的震荡形态和参数是否发散。对比实验四把 batch_size 从 64 改成 1纯 SGD和 1000全量梯度下降记录每个 epoch 的训练耗时和最终收敛轮数。这四个实验做完你对梯度下降家族的直观认知会远超只跑一遍代码的人。我当年就是靠这组对照实验彻底理解了优化器参数为什么敏感、为什么不同损失函数会带来不同的梯度气质。这也是我为什么坚持从线性回归开始进入 PyTorch——它小到你能看清每一个齿轮又大到能承载深度学习框架的全部概念。想在 PyTorch 这条路上走到底线性回归就是那块最重要的奠基石。把它钉牢了后面的卷积、注意力、大模型等等都只是在这个基础上的“换菜式烹调”。动手去跑吧四组实验做完再回来你会感谢现在动手的自己。