线性回归在深度学习里的位置很微妙:说它简单,它却是理解神经网络、梯度下降和自动求导的最佳入口;说它基础,可我在带新人入门时发现,能把线性回归用 PyTorch 从数据生成到训练闭环彻底讲清楚的人,往往对后面 CV、NLP 的坑也更有预判力。这篇笔记是跟着李沐老师的《动手学深度学习》整理出来的,针对 PyTorch 简洁版实现做了完整拆解——不是简单地贴代码,而是把数据怎么造、模型怎么定义、梯度怎么自动算、参数怎么收敛这些事都说明白,顺便把我自己踩过的环境坑和 debug 经验也放进来。适合刚装好 PyTorch、准备动手跑第一个模型的朋友,也适合学过理论但没自己敲过代码的读者。先放结论:整个流程跑通只需要几十行代码,但每一行背后的机制,才是这次要讲的重点。
1. 环境准备:搭建 PyTorch 开发环境(含避坑)
1.1 Python 和 PyTorch 版本怎么选
写这份笔记的时候,PyTorch 2.x 已经是绝对主流了,官方对 Python 3.9-3.12 的支持都比较完善。我建议新同学直接用 Python 3.10 或 3.11,这两个版本对 PyTorch、torchvision 以及后续可能用到的 d2l 工具包兼容性都很好,没必要为了尝鲜上 3.13,也没必要为了老教程死守 3.8。
安装 PyTorch 之前,先要想清楚一个问题:你手头有 NVIDIA 显卡吗?如果有,并且愿意折腾 CUDA 环境,那安装 GPU 版会为后面的正式训练省下不少时间。如果没有独立显卡,或者用的是 Mac,那直接装 CPU 版就够了——线性回归这种量级的数据,CPU 跑起来也是眨眼之间的事。不过要注意,CPU 版和 GPU 版在安装命令上差异明显,装错了就得重来,建议先确认好再动手。
1.2 conda 环境搭建与“conda is not recognized”报错
我推荐用 conda 创建独立环境来跑这里的实验。原因很朴素:深度学习项目的依赖太多了,每个项目的 PyTorch 版本、Python 版本、CUDA 版本可能都不一样,如果全部装进 base 环境,很快就会遇到包冲突,到时候想回滚都不容易。独立环境相当于给每个项目一个隔离开的小房间,里面怎么折腾都不影响外面的系统。
创建环境的命令很简单:
conda create -n d2l python=3.11 conda activate d2l这里有一个出现频率极高的报错,热搜词里面也出现了:在 Windows 上执行conda activate d2l后提示 “conda 无法将‘conda’项识别为 cmdlet、函数、脚本文件或可运行程序的名称”。这个问题的根子通常不在 conda 本身,而是没有把 conda 的 Scripts 目录加到系统环境变量里。我自己处理过好几次,最快的解决方式是:打开 Anaconda Prompt(而不是普通 PowerShell),先用conda init powershell自动配置环境变量,再重启终端。如果你用的是 CMD,对应地执行conda init cmd.exe就行。
conda init powershell执行完后关闭终端重新打开,conda activate就能正常用了。还有一个虽然没有报错但同样坑人的点:激活环境后,终端提示符前面会出现(d2l)字样,这时候用python -c "import torch; print(torch.__version__)"检查版本,确保 import 的是新环境里的 torch,而不是 base 环境里的旧版本。
1.3 安装 PyTorch 的推荐方式
PyTorch 官方提供了一个非常方便的选择页面:根据操作系统、包管理器、CUDA 版本自动生成安装命令。以我这里的环境为例,GPU 版安装命令通常长这样:
conda install pytorch torchvision torchaudio pytorch-cuda=12.4 -c pytorch -c nvidiaCPU 版则是:
conda install pytorch torchvision torchaudio cpuonly -c pytorch如果你在官网复制命令时发现 CUDA 版本和你机器的驱动不匹配,不用慌,先用nvidia-smi查看驱动支持的最高 CUDA 版本,然后选一个不高于这个数字的版本就可以。比如驱动显示支持 CUDA 12.4,那装 12.1 或 12.4 都没问题,PyTorch 自带的 CUDA 运行时是独立的,不需要和系统 CUDA 完全一致。
安装完成后,在 Python 里执行一句验证:
print(torch.__version__) print(torch.cuda.is_available())如果torch.cuda.is_available()返回 False,而你的显卡明明没问题,大概率是装了 CPU 版,或者 CUDA 版本和驱动不匹配。这时候不用急着重装,先检查一下是不是没退出虚拟环境,以及命令里是不是带了cpuonly字样。
2. 线性回归原理:训练之前必须搞清三个问题
2.1 线性回归到底在求解什么
线性回归做的事情,简单说就是找一条直线(在一个特征的情况下)或一个超平面(在多个特征的情况下),让数据点到它的距离尽可能小。数学上,它假设输出y和输入特征x之间满足:
y = Xw + b + ε其中w是权重向量,b是偏置,ε是噪声。模型要做的事情就是从数据中估计出一组w和b,让我们做出的预测y_hat尽量接近真实的y。这看起来像初中数学里的一元一次方程,但放到深度学习框架中,它的意义在于引入了“可学习参数”的概念——训练就是不断更新w和b,让损失降到最低。
2.2 损失函数与梯度下降的直觉
要让预测值接近真实值,我们需要一个指标来量化差距。线性回归里最常用的损失函数是均方误差(MSE,Mean Squared Error),公式如下:
loss = 1/n * sum((y_hat - y)^2)为什么要用平方而不是绝对值?因为平方损失是光滑可导的,方便用梯度下降法求导。在二维图像上,损失函数和参数的关系是一个曲面,我们一开始在曲面上随便找一个点,然后让参数沿梯度下降的方向更新,重复多次就能到达一个比较低的位置。学习率lr就是每一步往下走的步长:步长太大会在谷底附近来回震荡,步长太小又走得慢,半天到不了终点。
2.3 为什么 PyTorch 能自动求梯度
很多初学者第一次接触 PyTorch 时最困惑的就是:我没写求导的代码,梯度是怎么算出来的?这是 PyTorch 的 autograd 机制在起作用。简单来说,PyTorch 会对每一个张量记录它是怎么通过操作产生的——哪个张量参与了乘法、加法,结果输出给了谁。它维护着一张计算图,当我们调用loss.backward()时,它会从损失函数出发,沿着这张图反向走一遍,利用链式法则自动求出每个叶子节点的梯度,并存在参数张量的.grad属性里。
我的理解是:autograd 相当于一个自动记录员,它不主动替你做计算,但在你明确要求“反向传播”时,它能从记录中准确还原出每一步的关系,粒度细到每一个加法、乘法、reshape 操作。只有理解了这一点,你才不会在后面写自定义网络时,面对.backward()无从下手——所有梯度都是自动计算的,你只需要保证计算图连接正确就行。
3. 简洁版代码实现:从随机数据到训练闭环
3.1 先用公式生成一份带标签的模拟数据
动手写模型前,我们需要一份“标准答案已知”的数据。这样训练结束后可以对比学到的参数和真实的参数是否接近,从而判断模型学得好不好。李沐老师的课程里用一个很巧妙的方式生成模拟数据:
import torch def synthetic_data(w, b, num_examples): X = torch.normal(0, 1, (num_examples, len(w))) y = torch.matmul(X, w) + b y += torch.normal(0, 0.01, y.shape) return X, y.reshape((-1, 1)) true_w = torch.tensor([2.0, -3.4]) true_b = torch.tensor(4.2) features, labels = synthetic_data(true_w, true_b, 1000)这一步有两个值得注意的细节:一是torch.normal用来生成服从标准正态分布的特征矩阵,让数据看起来更接近真实场景;二是y += torch.normal(0, 0.01, y.shape)加了均值为 0、标准差为 0.01 的噪声,模拟真实世界中的“不可解释的随机扰动”。如果数据没有任何噪声,模型学起来会异常轻松,但这显然不符合实际应用场景。
3.2 定义数据迭代器:为什么要用 DataLoader
在 PyTorch 中,训练数据通常不一次性全部喂给模型,而是按照指定的小批量(batch)逐批送入。这样做既节省内存,又能让梯度估计带有一定随机性,有助于跳出局部最优。我们需要自己写一个将特征和标签组合成批量数据的函数:
from torch.utils import data def load_array(data_arrays, batch_size, is_train=True): dataset = data.TensorDataset(*data_arrays) return data.DataLoader(dataset, batch_size, shuffle=is_train)这里TensorDataset把特征和标签打包成数据集,DataLoader在迭代时自动切分并返回一个个小批量。设置了shuffle=True,训练时每轮 epoch 都会重新打乱数据顺序,这比顺序地读数据更能保证每个 batch 的随机性。手写代码的时候,很多人会图省事拿全量数据直接算梯度,结果发现训练结果虽然也能收敛,但泛化能力往往不如小批量梯度下降,这就是不注重数据迭代方式的教训。
3.3 模型定义:为什么用 nn.Sequential 包一层 nn.Linear
李沐老师在这份简洁版实现中,用几行代码完成模型定义:
from torch import nn net = nn.Sequential(nn.Linear(2, 1))nn.Linear(2, 1)表示输入维度为 2、输出维度为 1 的线性层,它内部自动初始化了权重w和偏置b,并在前向传播时完成y = x @ w^T + b的运算。nn.Sequential是一个容器,可以按顺序依次执行里面的层。这里只有一层,看起来有点“杀鸡用牛刀”,但它的意义在于:当你后续模型变成多层时,不需要改变调用方式,依旧是net(X)就能完成前向传播。
这里还要提醒一个新手常见误区:net(X)并不是调用了net类的某个显式方法,而是 PyTorch 在Module基类中实现了__call__,它内部会自动调用你在子类中定义的forward方法。所以当以后你自定义模型时,也要遵循同样的规则——只重写forward,不要直接调用它,否则会跳过 PyTorch 的一些内部钩子。
3.4 损失函数与优化器的选择:SGD 为什么够用
训练需要两个关键组件:损失函数和优化器。简洁版的实现是:
loss = nn.MSELoss() trainer = torch.optim.SGD(net.parameters(), lr=0.03)nn.MSELoss()默认返回的是所有样本损失的均值,这正好对应当前 batch 的均方误差。选择随机梯度下降(SGD)在这个任务里足够用了,因为线性回归的损失函数是一个凸函数,不存在太多局部最优问题,SGD 可以稳稳地找到全局最优。如果你想体会优化器对训练的影响,可以把SGD换成Adam,并调小学习率到 0.01,你会发现收敛速度明显加快,但对参数最终的精确度帮助有限——凸问题里,SGD 在 fine-tune 阶段反而更可靠。
3.5 训练循环:四个步骤缺一不可
训练循环是整份笔记的核心部分,也是初学者最容易写错的地方。完整代码如下:
num_epochs = 3 for epoch in range(num_epochs): for X, y in data_iter: l = loss(net(X), y) trainer.zero_grad() l.backward() trainer.step() l = loss(net(features), labels) print(f'epoch {epoch + 1}, loss {l:f}')每一步都不能少:
loss(net(X), y)计算当前批量的预测值,并和真实标签比较得到损失。trainer.zero_grad()将上一轮保留的梯度清零。这一步极其关键,PyTorch 的梯度默认是累加的,不清零会把多个 batch 的梯度叠加在一起,导致参数更新方向错乱,loss 不降反升。我见过不少新手问“为什么我的 loss 震荡得很厉害”,第一反应一般都是先看有没有写zero_grad()。l.backward()自动计算当前计算图中所有参数的梯度。trainer.step()根据梯度更新参数。
在每轮 epoch 结束时,将整个数据集重新输入一遍,输出整体 loss,可以直观地看出训练是否有效。一般第一个 epoch 的 loss 会在 0.3 左右,到第三个 epoch 能降到 0.0001 以下,如果 loss 一直居高不下,就得回头检查数据和梯度。
3.6 结果验证:学到的参数和真实参数对比
训练结束后,最让人有成就感的时刻就是对比学到的参数与真实参数:
print(net[0].weight) print(net[0].bias)理论上输出的weight应该非常接近tensor([[2.0000, -3.4000]]),bias应该接近tensor([4.2000])。因为我加了噪声,不可能完全相等,但只要数据量够大、训练轮数够多,误差会在小数点后三四位。有一次我试着把数据量从 1000 改成 100,训练结果参数误差就明显变大,这是我在实践中最直观体会到“数据量对模型精度的影响”的一次。
4. 训练效果与可视化分析
4.1 损失曲线:怎么看训练有没有问题
在 Jupyter 里运行上面的代码,你会看到每轮 epoch 打印的 loss。一眼看过去,loss 在快速下降就是正常状态。真实项目中,我们通常会画出 loss 曲线,横轴是迭代轮数(或 epoch 数),纵轴是 loss。曲线平滑下降说明学习率合适;如果曲线剧烈震荡,可能需要调小学习率;如果曲线下降后很快饱和不再变化,可能是模型容量不够,或者数据本身没有更多可学的东西。
这里我建议在记录 loss 时,不要只记录每个 epoch 的最终 loss,最好每个 batch 都记录一次,绘制出更细致的曲线。因为在一个 epoch 内,loss 也会有小幅波动——这是小批量随机梯度下降的正常现象,如果波动幅度大到 loss 变成负数(对 MSE 而言不可能)或者 NaN,那基本可以断定是学习率过大引发了梯度爆炸。
4.2 回归拟合效果的可视化
除了观察 loss,还可以把训练后的模型预测值和真实数据画在一张图上。用matplotlib绘制散点图和拟合直线,一眼就能看出拟合的好坏。以单个特征的数据为例,真实数据的散点会分布在一条直线附近,训练后的模型预测值应该恰好穿过这些点的中心区域。当特征维度为 2 时,可以用 3D 散点图观察预测平面和真实点的关系。
我个人的经验是:可视化是 debug 的一把好手。有时候 loss 明明在下降,但画出图后发现模型拟合的形态完全不对——比如本来该是正相关的趋势,模型却学出了负斜率。出现这种情况,往往是数据生成时特征和标签的对应关系写错了,而不是模型本身有问题。这类问题光看 loss 很难发现,画图却能一眼识破。
4.3 超参数调整的几种简单实验
这块内容课程里没有花太多篇幅,但我强烈建议初学者自己动手做一些小实验,因为对超参数的直觉就是这么练出来的:
- 调大学习率:把
lr=0.03改成lr=0.5,观察 loss 是否爆炸。 - 调小学习率:把
lr=0.03改成lr=0.0001,观察模型在三个 epoch 后是否还没有收敛。 - 增加 epoch:从 3 改为 10,观察 loss 是否能降到更低的水平,以及最终是否会过拟合。
- 去掉噪声:把
y += torch.normal(0, 0.01, y.shape)这行代码去掉,观察模型能否几乎无损地恢复真实参数。
这些实验加起来只需要几分钟,但对理解深度学习的“调参”逻辑很有帮助——比背多少理论都有用,因为你亲眼看到了每个参数变化带来的影响。
5. 常见报错、调试技巧与心得记录
5.1 常见报错速查表
这里把我在实践和带新人过程中遇到的高频报错整理成表格,按频率排序:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
conda is not recognized... | conda 环境变量未配置 | 在 Anaconda Prompt 里执行conda init后重启终端 |
ModuleNotFoundError: No module named 'torch' | 当前 Python 环境没有安装 PyTorch | 检查是否激活了正确的虚拟环境,重新pip install torch或conda install pytorch |
Expected all tensors on the same device | 数据和模型不在同一设备上 | 用X.to(device)把数据移到和模型一致的设备 |
| loss 一直不降 | 学习率太小,或zero_grad()漏写 | 先检查zero_grad(),再尝试调大学习率 |
loss 输出为nan | 学习率过大,导致梯度爆炸 | 调小学习率,或者检查数据中是否有异常值 |
mat1 and mat2 shapes cannot be multiplied | 输入特征维度与nn.Linear输入维度不匹配 | 检查nn.Linear(2, 1)的第一个参数是否等于特征数量 |
| 训练速度极慢 | 仍然在使用 CPU 训练大模型 | 确认 CUDA 是否可用,并给模型加上.to('cuda') |
这张表的价值在于:它把初学者最容易犯的错误集中到了一处。遇到报错时不要慌张,多数问题不用重装环境,按表格逐项排查基本都能解决。
5.2 随机种子与结果复现
训练结果每次运行略有不同是一件正常的事情,因为数据是随机生成的,权重初始化和 batch 打乱也是随机的。如果希望实验结果可复现,在程序开头固定随机种子即可:
import random import numpy as np random.seed(0) np.random.seed(0) torch.manual_seed(0)设置固定种子以后,多次运行得到的 loss 曲线和最终参数会保持一致。这在做实验对比时尤其重要——比如你想比较不同学习率的效果,如果随机种子不统一,每次训练起点都不一样,实验结果的差异可能是随机性带来的,而不是参数不同导致的。这一点李沐老师在课程中提过,真正操作时才发现它的分量。
5.3 简洁版与从零实现版的区别:什么时候该自己写
李沐老师的课程里,线性回归有从零开始实现的版本(手写梯度下降)和简洁实现版本(PyTorch 内置 API)。这两个版本我都跑过,个人体会是:从零实现能帮你理解梯度在底层是怎么算的,简洁版则让你快速建立“用框架做深度学习”的标准流程感。
有人会问:那我是不是只看简洁版就够了?我的看法是,第一次接触时还是要看从零实现,至少手写一次梯度更新公式,哪怕只跑通一次,心里才会有底。但在日常做项目时,肯定是简洁版效率高,没必要每次从零造轮子。就像学开车,你得先了解油门刹车离合的配合原理,但真正上路后不可能每次起步都重新研究一遍原理。
5.4 一点个人调试心得
最后分享一个我在 debug 时常用的小技巧:在训练循环里临时把 batch 大小调成整个数据集的规模,并把 epoch 数调成 1,用全量梯度下降去训练两三步,然后手动打印每一步的 loss 和梯度值。这样一来,计算过程是完全确定性的,出现任何异常都能一眼定位到是哪一步出了问题。等确认代码逻辑无误后,再恢复成小批量训练模式。这个小技巧对排查“模型不收敛”问题非常高效。
另一个心得是:写 PyTorch 代码时,尽量在一开始就把device变量定义好,并统一通过X.to(device)来移动数据。不要因为当前数据量小就忽略这一点,等以后换了 GPU 服务器,这段代码只需要改一个变量的值就能无缝切换,省去后面到处改设备的头疼事。