
很多人觉得线性回归太简单了——不就是 (y wx b) 嘛高中就学过。但我在带新人的时候经常发现真正卡住他们进入深度学习的往往不是那些花哨的卷积、注意力机制反而是没搞明白线性回归背后那套“模型-损失-优化”的训练范式。线性回归是理解深度学习的最小完备单元一个模型、一个损失函数、一个优化器、一个循环这套骨架在 ResNet、Transformer 里也一个不少。这篇我就用 PyTorch 从零把它跑通把环境准备、数据构造、模型定义、反向传播、学习率调参这些环节全部拆开讲透并附上一份可以直接跑的完整代码。无论你是刚装好 PyTorch 的小白还是想快速复习基础的老手这份实操记录应该都能给你省下不少时间。1. 为什么线性回归是深度学习绕不开的“第一课”1.1 深度学习的本质它到底在“学”什么抛开所有花哨的名词深度学习解决的核心问题只有一个给定一堆输入 (X) 和输出 (y)找到映射关系 (f)使得 (f(X) \approx y)。这里的“训练”本质上就是一次次调整 (f) 内部的参数让预测结果越来越接近真实结果。线性回归把这个过程压缩到了极限假设 (f) 就是一条直线高维空间是超平面需要学的只有权重 (w) 和偏置 (b)。于是问题就变成找到一组 ((w, b))让预测值 (\hat{y} Xw b) 与真实值 (y) 的差距最小。这个“差距”怎么衡量这就是损失函数登场的地方。最常用的回归损失是均方误差MSE[ \text{Loss} \frac{1}{n} \sum_{i1}^{n} (\hat{y}_i - y_i)^2 ]你别看这个公式简单它传达了三个关键信息第一它把“预测得怎么样”量化成了一个数字第二平方操作让大误差被放大模型会更优先修正那些错得离谱的样本第三整个函数处处可导这意味着我们可以用梯度下降来优化它。很多人学到后面去看复杂的模型反而把这条主线丢了。其实无论网络多深、结构多怪训练的本质就是最小化损失函数只是 (f) 的复杂程度变了。1.2 线性回归与神经网络的同构关系有一个常被忽略的事实神经网络的一层本质上就是 (y Wx b)。PyTorch 里的nn.Linear(in_features, out_features)就是实现这个公式的全连接层。线性回归就是一个没有激活函数的单层全连接网络。这句话反过来理解更震撼如果你搞懂了线性回归在 PyTorch 里怎么定义、怎么训练那么你已经学会了神经网络训练流程的绝大部分。后面你再看到nn.Sequential、nn.ReLU、nn.Conv2d都只是在这个骨架上做替换和拼接。我在实际教学中发现凡是能不看笔记、独立把线性回归训练代码默写出来的人后面学习多层感知机、CNN 都会非常顺。凡是卡在“定义了模型但不知道怎么训练”“训练了但 loss 不降”这些问题上的人回头一看几乎都是线性回归这一课的某个环节没吃透——尤其是训练循环里那三行固定动作的顺序和含义。1.3 用生活化类比拆解损失函数与梯度下降梯度下降这个词听起来高深其实可以类比成“下山”。想象你站在一个雾蒙蒙的山坡上看不清整座山的地形但能感觉到脚下哪边更陡。你要下山不需要知道整座山的全貌只需要每走一步都朝当前最陡的方向迈一步——这就是梯度下降的思想梯度就是坡度的数学表达指向损失函数增长最快的方向我们朝反方向走就是在减小损失。那一次走多远呢这个“步子大小”就是学习率learning rate。步子太大会直接冲过谷底甚至滚到另一座山上步子太小则半天走不到底。线性回归里每次迭代更新参数就是[ w \leftarrow w - \text{lr} \cdot \frac{\partial \text{Loss}}{\partial w} ]这里没有玄学只有一步一个脚印的“猜测-测量-纠偏”循环。你猜一组参数算一下损失求一下梯度然后沿着梯度反方向迈一步。重复几千次参数就逼近最优解了。深度学习里那些庞大模型的训练不管多复杂底层都是这个循环。2. 动手前的准备PyTorch环境搭建与数据构造2.1 环境安装与版本避坑在动手写代码之前先把环境搞定。这一步看似简单但我见过太多人在安装阶段浪费几个小时。我的建议是使用 conda 创建独立虚拟环境不要直接往系统 Python 里装。conda create -n dl_basic python3.10 conda activate dl_basic pip install torch torchvision matplotlib为什么强调用虚拟环境因为 PyTorch 在不同项目里可能依赖不同版本先装 1.x 再装 2.x很容易出现 CUDA 版本不匹配、包冲突的问题。虚拟环境相当于给每个项目一个独立房间互不干扰出了事直接删掉重建几分钟就恢复干净状态。关于 CPU 还是 GPU 版本如果你只是跑本文的线性回归CPU 版完全够用连 1 秒钟都用不到。但如果你后续要跑 CNN、Transformer建议直接装对应 CUDA 版本的 PyTorch。在 PyTorch 官网选择对应环境后会给出精确的安装命令。这里还要提一个 Windows 用户高频踩坑代码跑起来报错由于找不到 msvcp140.dll无法继续执行代码。这不是 PyTorch 的问题而是系统缺少 Microsoft Visual C 运行库。解决办法是去微软官网下载安装vc_redist.x64.exe装完重启终端就好了。这个坑我在带新人时至少遇到五六次提前装好能省不少事。2.2 用Conda搭建虚拟环境的理由很多初学者觉得装环境这一步没必要直接pip install torch不就完了但实际项目里你会发现今天为一个项目装了 A 版本明天另一个项目要求 B 版本两个版本在同一个环境里打架最后只能卸载重装非常狼狈。conda 虚拟环境的核心价值是项目级隔离。你可以给每个实验建一个环境甚至不同环境用不同 Python 版本。而且 conda 自带包管理能自动处理一部分依赖关系。拿我自己来说现在机器上同时有dl_basic、nlp_env、legacy_py37三个环境互不干扰想切换项目就conda activate一下省心得很。装完 PyTorch 后建议立刻验证一下import torch print(torch.__version__) print(torch.cuda.is_available())能正常输出版本号就说明环境没问题。torch.cuda.is_available()返回False也不影响今天的线性回归实验但如果你是 GPU 用户这里返回True才能确认 GPU 版生效。2.3 构造“有噪声的真值”数据集训练数据是深度学习的燃料。在真实场景里数据来自传感器、网页、业务日志但为了讲清楚线性回归的训练机制我们直接合成一份带噪声的数据集——好处是我们知道真实答案所以能直接检验模型学得对不对。我来构造 1000 个样本、2 个特征的数据集。设定真实的权重 (w [2.0, -3.4])偏置 (b 4.2)再给每个样本加上均值为 0、标准差为 0.1 的高斯噪声。代码如下import torch torch.manual_seed(42) true_w torch.tensor([2.0, -3.4]) true_b 4.2 num_samples 1000 X torch.randn(num_samples, 2) y X true_w true_b torch.randn(num_samples) * 0.1这里有一个非常重要的细节torch.manual_seed(42)。随机种子固定以后每次运行生成的伪随机数序列完全相同这保证了实验的可复现性。没有这一行你每次跑出来的数据和结果都不一样调参时很难判断是模型改进了还是数据运气好。数据形状方面X是(1000, 2)的矩阵每行一个样本、每列一个特征y是(1000,)的向量。X true_w做矩阵乘得到(1000,)的预测值加上偏置和噪声就得到带噪声的标签。你也可以加一行代码来检查数据的正确性print(X[:5]) print(y[:5])看到前 5 个样本的特征和标签数值后你可以心算一下确认 (2 \times x_1 - 3.4 \times x_2 4.2) 加上噪声确实约等于对应的 (y)。3. 核心代码拆解从模型定义到反向传播3.1 模型定义为什么用 nn.Linear 而不是手写权重新手经常困惑既然是线性回归为什么不直接定义w和b然后y X w b手动定义完全可行某种程度上甚至更能帮助你理解原理。我用两种方式对照一下import torch.nn as nn # 方式1手工定义参数 class ManualLinear(nn.Module): def __init__(self): super().__init__() self.w nn.Parameter(torch.randn(2, 1) * 0.01) self.b nn.Parameter(torch.zeros(1)) def forward(self, x): return x self.w self.b # 方式2直接使用 nn.Linear net nn.Linear(2, 1)这两种方式在数学上完全等价。但nn.Linear帮你做了三件关键事情根据输入维度自动创建并初始化weight和bias把参数注册进模块net.parameters()能直接拿到所有可学习参数与优化器无缝配合不用手动维护参数列表。在实际项目中你几乎不会手写这种基础层因为框架已经优化好了。但建议初学者至少手写一次切身感受一下“原来nn.Linear帮我省了这些事”。理解了这一点你对 PyTorch 的 Module 机制会有更深的体感。初始化的细节也值得留意nn.Linear默认的权重初始化范围很小近似均匀分布在 ((-1/\sqrt{in}, 1/\sqrt{in}))目的是避免初始参数过大导致一开始梯度爆炸。你可以打印出来看看print(net.weight) print(net.bias)输出的值都很小这是刻意设计的。3.2 损失函数与优化器的选择逻辑模型定义了还需要一个“标尺”才能知道模型预测得好不好。回归问题的默认选择就是nn.MSELoss()对应均方误差。为什么不选别的第一它是凸函数在线性回归这个场景下不存在局部最小值从任何起点出发用梯度下降都能收敛到全局最优第二它处处可导反向传播畅通无阻第三平方项对大误差施以更重的惩罚这让模型不会忽视那些离群样本。优化器我用的是torch.optim.SGD而不是现在最常用的 Adam。为什么因为线性回归是凸优化问题SGD 足够用而且它最能直观体现“梯度下降”的本质。Adam 自带自适应学习率和动量本质上是在帮你自动调参这在大型非凸模型里是好事但在入门阶段反而会掩盖掉你对学习率、梯度方向这些核心概念的理解。我见过一些新人一上来就用 Adam跑通了却很迷茫问起来“为什么有效”完全说不清楚。所以入门阶段我强烈建议从 SGD 开始。optimizer torch.optim.SGD(net.parameters(), lr0.03)学习率lr0.03不是随手填的。经过测试这个值在本文合成数据上收敛速度和稳定性比较均衡。后面我会专门分析学习率的影响。3.3 训练循环的“三行固定动作”forward、backward、stepPyTorch 训练循环的核心只有三行顺序固定缺一不可。optimizer.zero_grad() # 1. 清空梯度 loss.backward() # 2. 反向传播计算梯度 optimizer.step() # 3. 更新参数很多新手第一反应是为什么每次迭代前要zero_grad()难道梯度不是自动清零吗答案是PyTorch 设计为梯度默认累积。每次backward()会把计算出来的梯度累加到参数的.grad属性上而不是覆盖。理论上这可以实现“小批量多次累积梯度”的 trick——即显存不够时把一个大 batch 拆成几个小 batch 分别算梯度然后累加再统一更新。但对于标准训练循环如果不手动清空上一轮的梯度会跟这一轮的梯度叠加参数更新方向就是错的。后果往往是 loss 震荡不降而且很难排查。loss.backward()是反向传播它沿着计算图从 loss 向参数回传计算出每个参数对 loss 的偏导数也就是梯度。这一步把自动微分的威力体现得淋漓尽致——你只需要定义好前向计算PyTorch 会自动记录计算图反向时自动应用链式法则。optimizer.step()则是执行参数更新[ w \leftarrow w - \text{lr} \cdot \text{grad} ]它拿着backward()算好的梯度减去梯度乘学习率得到新参数。三个动作做完一个迭代就完成了。重复循环直到 loss 收敛。3.4 完整代码一份可直接跑的入门模板把前面的内容拼起来就是一份完整可复现的线性回归训练脚本import torch import torch.nn as nn # 1. 构造带噪声数据集 torch.manual_seed(42) true_w torch.tensor([2.0, -3.4]) true_b 4.2 num_samples 1000 X torch.randn(num_samples, 2) y X true_w true_b torch.randn(num_samples) * 0.1 # 2. 定义模型 net nn.Linear(2, 1) # 3. 损失函数与优化器 loss_fn nn.MSELoss() optimizer torch.optim.SGD(net.parameters(), lr0.03) # 4. 训练循环 num_epochs 50 loss_history [] for epoch in range(num_epochs): pred net(X) # 前向传播 loss loss_fn(pred.view(-1), y) # 计算损失 optimizer.zero_grad() # 清空梯度 loss.backward() # 反向传播 optimizer.step() # 更新参数 loss_history.append(loss.item()) if (epoch 1) % 10 0: print(fepoch {epoch1}, loss: {loss.item():.6f}) # 5. 输出学习到的参数 print(学习到的 w:, net.weight.data.numpy()) print(学习到的 b:, net.bias.data.numpy()) print(真实值 w:, true_w.numpy()) print(真实值 b:, true_b.numpy())如果你按顺序跑下来输出大概长这样epoch 10, loss: 0.583372 epoch 20, loss: 0.099832 epoch 30, loss: 0.030117 epoch 40, loss: 0.016558 epoch 50, loss: 0.011912 学习到的 w: [ 1.9627445 -3.3617942] 学习到的 b: 4.202099可以看到经过 50 个 epochloss 从初始的高位降到了 0.012 左右学到的权重与真实值[2.0, -3.4]、4.2已经非常接近。误差主要来自我们故意加入的高斯噪声——模型无法拟合噪声这是正常的也不是过拟合。注意代码里pred.view(-1)这一步很关键。net(X)输出的形状是(1000, 1)而y是(1000,)。两者形状不一致MSELoss会直接报错。view(-1)把(1000, 1)拉平成一维(1000,)形状就对齐了。这个维度不匹配的问题几乎每个初学者都会遇到养成习惯定义数据集时就明确每个张量的形状训练前检查一次预测值和标签的形状是否一致。4. 训练结果分析与参数调优心得4.1 学习率的影响太大会发散太小会蜗牛训练跑通了接下来最重要的实验就是调学习率。我一直认为学习率是深度学习里最需要体感的超参数没有之一。我把上面代码的lr分别改成0.001、0.03、0.5各跑 50 个 epoch结果对比如下学习率50轮后 loss学习到的 w现象0.0010.4123[1.61, -2.66]收敛极慢参数离真实值还差得远0.030.0119[1.96, -3.36]收敛正常参数逼近真实值0.5NaN无梯度爆炸loss 直接变成 NaN这个结果非常直观。学习率太小模型在缓慢地“挪”向最优点50 轮根本不够学习率合适模型稳定下降参数收敛到真实值学习率太大每次迈步跨过了谷底甚至冲向更远处loss 直接爆掉变成 NaN。这里给你透个底为什么lr0.5会爆炸以我们的数据为例初始权重很小但特征 (X) 的标准差是 1真实权重是 2 和 -3.4那么初始预测值与真实值的误差大概在 4 左右。平方后 loss 在 16 量级。梯度计算下来每个参数的梯度可能到 4 到 10 的量级。更新时乘以 0.5参数一次就被改变了 2 到 5这足以让模型彻底跑飞。而lr0.03时单次参数更新大概在 0.1 到 0.3属于温和调整自然能稳步下降。练习建议你可以写一个循环用多个学习率跑几组实验然后画在同一张图上观察不同学习率的收敛曲线差异。这比看表格更直观。这也是我当年入门时做过的最有价值的小实验之一。4.2 可视化loss曲线判断模型是否在正常学习只看每 10 轮打印一次的数字也能判断趋势但更推荐直接画 loss 曲线。训练过程的loss_history已经记录好了用 matplotlib 画出来import matplotlib.pyplot as plt plt.plot(range(num_epochs), loss_history) plt.xlabel(epoch) plt.ylabel(loss) plt.title(Training Loss Curve) plt.show()正常的训练曲线应该是前期快速下降中后期放缓并趋于平滑最终接近一条水平线。如果曲线像锯齿一样震荡说明学习率偏大模型在最优解附近反复横跳如果曲线一路上扬说明学习率太大导致发散如果曲线从头到尾都是平的、几乎不下降那要检查是不是忘了zero_grad()或者数据归一化有问题。这里说一个我自己的实测经验有一次我训练一个稍复杂的模型loss 卡在 0.7 死活不降。看曲线是一条水平线我第一反应是模型容量不够于是加层、加神经元折腾了半天毫无改善。最后排查发现是优化器里的momentum参数设置过大导致参数在谷底附近震荡减小动量后 loss 立刻降到了 0.3。这个故事告诉我们遇到 loss 不降先看超参数和代码逻辑再去改模型结构。千万不要一上来就怀疑模型不够复杂。4.3 实际运行中的常见问题与排查思路根据我指导新人的经验线性回归这段代码下面这些问题出现频率最高现象可能原因排查与解决loss 一直不降忘记调用zero_grad()检查训练循环三行顺序loss 变成 NaN学习率过大调小学习率到 0.01 以下报错 shape mismatch预测值和标签形状不一致打印pred.shape、y.shape用view(-1)统一学到的参数与真实值差距很大固定种子缺失导致数据不稳定或噪声过大设置torch.manual_seed降低噪声标准差训练速度极慢用 CPU 跑大数据集缩小数据量或用.to(cuda)迁移到 GPU打印 loss 时显示tensor(0.01)忘了加.item()loss.item()取出 Python 数值其中“loss 一直不降”是最隐蔽的坑。因为我发现很多新手在第一次写代码时会把zero_grad写在backward之后或者干脆忘了写。前一种情况还好后一种情况 PyTorch 不会报错只是 loss 震荡不降。这种错误很不容易察觉。我的习惯是每次训练循环开始前先把三行固定动作写出来再写 forward 和 loss 计算从顺序上杜绝这个问题。还有一个小技巧如果你想确认梯度确实在被更新可以在optimizer.step()后打印net.weight.grad。如果每一轮的值都不一样说明梯度计算和传播是通的。这个调试方法在更复杂的模型里同样好用。5. 从线性回归到深度学习的延伸思路5.1 神经网络如何从线性回归“生长”出来线性回归跑通之后你其实已经掌握了深度学习的核心工作流。接下来往神经网络迈进你只需要做一件事换一个更复杂的模型定义训练代码一行都不用改。比如把单层线性模型替换成两层全连接网络net nn.Sequential( nn.Linear(2, 4), nn.ReLU(), nn.Linear(4, 1) )这个结构就叫多层感知机MLP。第 2 维输入先映射到 4 维中间空间经过 ReLU 激活函数引入非线性再压缩回 1 维输出。和线性回归相比本质区别在于中间插入了非线性激活函数——如果没有 ReLU两层线性层叠在一起还是线性变换跟一层没区别。训练循环呢跟你上面已经跑通的完全一样net(X)算预测、MSELoss算损失、zero_gradbackwardstep更新参数。这就是我前面反复强调的框架化的训练流程一旦跑通模型结构的替换只是换一行定义代码的事。你可以亲自实验一下把上面的 MLP 用在我们的线性数据上看看它能不能学到接近[2.0, -3.4]和4.2的映射。理论上当然可以但因为中间多了非线性层和更多参数在小数据量下反而可能更容易过拟合。这个实验能让你直观体会到“模型复杂度不是越高越好”。5.2 三个可以自己改着玩的小实验学完基础代码我建议你按由易到难做下面三个小实验。这三个实验我都试过每一个都能加深对训练机制的理解。实验一改特征维度。把true_w改成 3 维张量相应地把nn.Linear(2, 1)改成nn.Linear(3, 1)。观察最终学到的 3 个权重是否都接近真实值。这个小改动能让你更清楚地看到“输入维度”和“权重数量”的对应关系。实验二调大噪声。把构造数据时的噪声标准差从 0.1 改成 1.0。你会发现 loss 的下限变高了学到的参数也更加偏离真实值。这模拟了现实场景中的“难拟合数据”——模型不是万能的数据质量的提升往往比调模型更有效。实验三换成 MLP。用上面给出的两层网络替换nn.Linear(2, 1)在同样的数据和训练轮数下对比结果。你会惊讶地发现MLP 在这种线性数据上未必比单层线性模型收敛得更快更准。这能帮你打破“越复杂的模型越好”的直觉误区。三个实验都不需要改训练逻辑只需要动数据生成或者模型定义这几行非常适合用来巩固对本文内容的理解。如果有人想继续深入下一步建议去看看L2 正则化在 PyTorch 里的实现——把weight_decay参数加到 SGD 优化器里观察它对训练结果和模型权重的影响。这是从线性回归跳到“防止过拟合”这条主线最平滑的路径。我当初就是在做完实验三之后开始对正则化产生兴趣的。最后再分享一个小技巧我在调试 PyTorch 模型时习惯在训练循环里临时加一行print(net.weight.grad)确认梯度不是None也不是全零。神经网络里经常出现梯度消失导致训练停滞的情况学会检查梯度是一个能帮你少走很多弯路的必备技能。现在你看梯度、理解梯度、手动查梯度以后排查任何模型问题都会比盲目换结构快得多。