打开李沐《动手学深度学习》的时候我的第一感觉是这书怎么这么敢写。开头几十页就敢直接把多层感知机、卷积神经网络、循环神经网络全部串一遍而且每个模型都用“从零实现 简洁实现”两条路线去推。但真正让我卡住然后想通的地方反而是最不起眼的那一章——线性回归Linear Regression。说出来有点丢人当时我已经把后面几个复杂模型跑通了回头再看线性回归才意识到一个问题我之前跑那些复杂模型很多代码都是“照着抄、能跑就行”对于模型怎么更新参数、损失到底在算什么、一个完整的训练过程由哪些环节组成其实并没有形成体系。而线性回归这一章恰恰把这个东西讲透了。它给出的就是全深度学习里最小的训练闭环。什么是闭环就是给定数据和初始参数模型通过一次前向计算得到预测通过损失函数算出误差通过反向传播算出每个参数的梯度再用优化算法更新参数然后进入下一轮。这个过程在任何一个深度学习模型里都一样只是线性回归没有卷积、没有注意力、没有各种花哨模块所以你能清清楚楚看到每一环在干什么。如果说后面那些复杂模型是精装房线性回归就是清水房——水管怎么走、电线怎么埋全都能看见。这篇文章我整理了四块内容线性回归到底在解决什么问题、最小训练闭环由哪几部分组成、如何手动实现一条完整训练循环、以及我实操中踩过的一些坑和排查技巧。李沐在课程里常说“我们动手学”所以这篇文章里能动手的地方我也全部给到了可运行的代码你照着敲一遍再回去看那些复杂模型会有一种豁然开朗的感觉。1. 线性回归到底在解决什么问题1.1 从房价预测场景看懂模型的输入输出线性回归最经典的切入场景是房价预测。假设我们现在要评估一套房子能卖多少钱手头有一些历史成交记录里面有房屋面积、房龄、楼层、周边配套等信息。这些就是特征也就是模型的输入通常记为 ( x )。目标值就是成交价记为 ( y )。线性回归做的事情就是找到一组权重和偏置让 ( y ) 和 ( x ) 之间建立一个线性关系( \hat{y} w_1 x_1 w_2 x_2 ... w_d x_d b )。这句话听起来简单但里面藏着深度学习的核心语义。权重 ( w ) 表示“每个特征对结果的影响方向和强度”偏置 ( b ) 表示“当所有特征都为 0 时的一个基准值”。训练过程本质上就是不断调整这两个东西让预测值 ( \hat{y} ) 尽可能接近真实值 ( y )。为了更直观李沐在课程里构造了一个人造数据集并给出了具体的生成代码。这个设计非常聪明——用真实数据做实验你无法确认模型是否真的学对了因为真实世界的映射关系你不知道。但人工数据集的生成规则是明确的比如设定真实权重是 ( w [2, -3.4] )真实偏置是 ( b 4.2 )再叠加一些随机噪声。这样一来你训练完模型后可以直接对比学到的参数和真实参数误差在哪个数量级一眼就能看出来。这也是判断一个训练闭环是否正确的最快方式。我在实际跟代码的时候发现这个“人造数据集”的做法非常值得记住。后来我做任何新模型验证都习惯先在一个自己可控的合成数据上跑通流程再换真实数据。这能帮你把“模型实现写错了”和“数据本身的问题”快速隔离出来。1.2 线性假设意味着什么又在哪里失效线性回归的“线性”二字既是它的最大优势也是它的最大限制。所谓线性假设就是假设目标值和特征之间是直线关系。比如面积每增加 1 平米房价固定增加某个金额。但真实世界很少有这种严格的直线关系。面积大的房子单价可能反而低房龄高的房子折旧速度可能放缓。这些问题用纯线性模型是拟合不了的。所以你需要理解线性回归在深度学习里的定位不是“万能模型”而是一个“基线模型”。它的意义在于如果连线性关系都没学好那问题可能出在数据处理或训练流程上如果线性关系学好了但效果不够说明数据里存在非线性关系这时候再去考虑更复杂的模型。实际上神经网络可以看成是线性回归的堆叠和扩展。每一层做的事情先是一个线性变换就是线性回归的加权求和再套一个非线性激活函数。卷积神经网络里的卷积核操作本质也是一种带共享参数的线性变换。理解了线性回归你就理解了一切神经网络最基础的数学单元。1.3 为什么线性回归是深度学习的“最小训练闭环”我们说线性回归是“最小训练闭环”因为它的完整训练过程包含了一个模型训练所需要的全部环节但每一个环节都不需要额外的前置知识。你不需要理解感受野、不需要理解注意力机制、不需要理解反向传播的链式法则虽然梯度下降本身也需要推导但它的式子足够简单可以直接手算验证。具体来说一个线性回归训练闭环包含四个环节模型结构定义输入怎么映射到输出。损失函数定义预测值和真实值之间的差距怎么量化。优化算法定义如何根据损失调整参数。数据迭代定义每次训练更新参数时用多少数据、按什么顺序喂给模型。这四个环节翻译成任何复杂模型的训练都是同一套骨架。你把“线性回归”换成“卷积神经网络”模型结构变了把“均方误差”换成“交叉熵”损失函数变了把“随机梯度下降”换成“Adam”优化算法变了。但流程的骨架没有任何变化。所以当你把线性回归的代码一行一行写明白就等于给后续所有模型的学习搭好了脚手架。我还想多说一句李沐这本书选取的代码风格刻意把“从零实现”放在前面而不是直接甩出一个nn.Linear了事。原因就是如果你直接用 PyTorch 的封装你根本不知道autograd背后发生了什么也不知道optim.SGD到底更新了什么。从零实现你被迫面对每一条公式、每一个矩阵形状这比任何讲解都有效。2. 最小训练闭环的四个基本单元如果把一个训练闭环比作一条流水线那这四个基本单元就是流水线上的四个工位模型负责把输入变成输出损失负责给输出打分优化器根据分数调整产线参数数据迭代负责把原料源源不断送上产线。缺了任何一个工位生产线都转不起来。2.1 模型结构从输入到输出的映射线性回归的模型结构可以写成一行代码def linreg(X, w, b): return torch.matmul(X, w) b这里X的形状是(batch_size, num_features)w的形状是(num_features, 1)b是一个标量。torch.matmul(X, w)做的是矩阵乘法结果形状是(batch_size, 1)加上偏置后就是最终的预测值。这里的batch_size可以理解为一次喂给模型的样本数。一个函数搞定模型这大概是全深度学习里最简单的模型结构了。但它的形状转换逻辑和后面任何模型完全一致输入进模型经过一层或若干层计算输出预测结果。你只需要注意输入张量和权重张量在最后一个维度上要对齐。另外我提一个细节为什么偏置b不直接并进权重w其实可以只要在特征矩阵里加一列全 1 就行了这样模型就变成 ( \hat{y} [X, 1] \cdot [w, b] )。很多框架在底层就是这么干的只是对使用者来说单独把偏置拎出来更直观。所以代码里保留w和b两个参数。2.2 损失函数三步看懂均方误差的设计逻辑线性回归用的损失函数是均方误差公式长得这样[ L(w, b) \frac{1}{2n} \sum_{i1}^{n} (\hat{y}_i - y_i)^2 ]第一次看到这个公式的人通常会问三个问题为什么要平方为什么要除以 2为什么要加一个 ( \frac{1}{n} )先回答平方。如果我们用 ( |\hat{y} - y| ) 这种绝对值形式它在 0 点不可导梯度下降算起来麻烦而且绝对值函数对误差的惩罚是线性的两个相差 10 和相差 1 的错误会得到不成比例的梯度。平方则放大了大误差的影响让模型更倾向于优先纠正那些错得离谱的样本。同时平方函数处处可导求导之后是一个线性表达式做梯度下降非常顺畅。顺便说一下这就是 L2 损失的来源它对异常值非常敏感训练时如果数据里混入了极端离群点你会看到损失被个别样本带着走。再回答除以 2。这个操作纯粹是为了求导方便。对 ( (\hat{y} - y)^2 ) 求偏导会得到一个 2除以 2 之后梯度就变成 ( (\hat{y} - y) )清爽不少。这是数学上的一个小技巧没有物理含义。很多教材里的损失函数都会这么干比如某些正则化项里也会出现 ( \frac{\lambda}{2} |w|^2 )都是为了求导方便。最后是 ( \frac{1}{n} )。这个叫平均是为了让损失值跟样本数量解耦。如果不做平均1000 个样本的损失和 1 个样本的损失在数值上完全没有可比性学习率也不好调。平均之后不管 batch size 是 32 还是 128损失的数值尺度大致稳定在同一范围。这一点在调参时非常重要——你会发现损失曲线和 batch size 的相关性会小很多。代码实现如下def squared_loss(y_hat, y): return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2注意这里没有做均值因为后面的优化器是按 batch 汇总的我们在训练循环里会对一个 batch 的所有样本损失求均值再反向传播这样可以防止单个样本的噪声主导梯度。2.3 优化算法梯度下降的原理和一种“管中窥豹”的方式线性回归用的优化算法是随机梯度下降SGDStochastic Gradient Descent。它的核心思想非常朴素既然损失函数告诉我们当前预测有多差那沿着损失下降最快的方向更新参数就能让预测更好。“沿着损失下降最快的方向”在数学上就是负梯度方向。对权重 ( w ) 求偏导得到[ \frac{\partial L}{\partial w} \frac{1}{n} \sum_{i1}^{n} x_i (\hat{y}_i - y_i) ]这个式子的含义是每个样本都会产生一个“预测误差 ( \hat{y}_i - y_i )”然后这个误差乘以对应的特征值 ( x_i )就是该样本对权重梯度的贡献。如果预测值偏大梯度就是正的更新时权重就会减小如果预测值偏小梯度是负的权重就会增大。信息论里有个类似的说法梯度就是“误差反传的信号”。更新参数的公式[ w \leftarrow w - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_w l^{(i)} ]其中 ( \eta ) 是学习率表示每次走多大一步( |\mathcal{B}| ) 是 batch 大小。从零实现 SGD 只有十行代码def sgd(params, lr, batch_size): with torch.no_grad(): for param in params: param - lr * param.grad / batch_size param.grad.zero_()这里有个重要细节为什么更新参数时要放在torch.no_grad()里面因为更新参数是纯粹的数值操作不应该被 PyTorch 计入计算图。如果不加这个你的计算图会越滚越大运行一段时间后内存就会爆掉。这也是我最初踩过的坑之一等到后面聊排查技巧时再展开。另外注意param.grad.zero_()也就是把梯度清零。PyTorch 的梯度是累积的不清零的话下一次反向传播会把两次梯度加在一起。对于某些特殊场景比如梯度累积模拟大 batch你会利用这个特性但常规训练里每一次迭代结束都必须清零。关于学习率 ( \eta ) 的选择我说一个直观经验它可能是整个训练闭环里最难调的超参数。太大损失会震荡甚至发散太小训练半天损失几乎不动。李沐在课程中也多次强调学习率的重要性建议大家在 0.01、0.05、0.1 这一档多试几个值观察损失曲线的变化。2.4 数据迭代batch、epoch、learning rate 怎么配合训练闭环里数据的喂入方式同样重要。线性回归的数据迭代在书里用一个data_iter函数实现它的职责是从全部数据里随机抽取batch_size个样本形成一个 batch然后交给模型。这里有两个概念需要区分epoch迭代轮数和 batch size批量大小。epoch 表示完整遍历一遍整个训练数据集batch size 表示每次更新参数用的样本数量。举个例子如果有 1000 个样本batch size 设为 10那一个 epoch 就有 100 个 batch也就是说一个 epoch 里参数会被更新 100 次。代码实现是这样def data_iter(batch_size, features, labels): num_examples len(features) indices list(range(num_examples)) random.shuffle(indices) for i in range(0, num_examples, batch_size): batch_indices torch.tensor(indices[i: min(i batch_size, num_examples)]) yield features[batch_indices], labels[batch_indices]每次迭代之前先随机打乱索引顺序再按顺序切出 batch。为什么要打乱如果数据本身有某种顺序结构不打乱会导致每个 batch 都是相同类型的样本梯度更新会产生系统性偏差。比如房价数据如果按面积从小到大排列不打乱的话前几个 batch 全是小面积房子模型会被带着先偏向某个方向后面再突然见到大面积房子训练过程就会震荡。batch size 的取值在后续所有深度学习任务里都是必须面对的超参数。它在训练闭环里起着微妙的平衡作用batch 越大梯度的估计越准但每次更新前等待计算的时间越长而且占用的内存越大batch 越小更新越快但梯度噪声大训练曲线会比较抖。李沐在书里习惯把超参数集中放在代码开头比如lr 0.03 num_epochs 3 net linreg loss squared_loss这看起来只是一个组织代码的习惯但实际工程里好的习惯是这样的所有可调的超参数都应该收拢到一个地方方便统一修改而不是散落在代码各处。你后面切换到真实项目时会感受到这种安排对于实验管理和复现有多重要。3. 实操一步步跑通最小训练闭环下面这部分我把整个训练闭环用代码完整走一遍。建议你新建一个 Python 文件一行一行敲不要复制粘贴。敲代码的过程其实就是你的手指在帮你记笔记——这个观点是我学完这本书后体会最深的一句话。3.1 准备人造数据集给训练闭环提供足够“体验”先导入必要的库然后生成人工数据集import torch import random 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, -3.4]) true_b 4.2 features, labels synthetic_data(true_w, true_b, 1000)这里torch.normal(0, 1, (1000, 2))生成了 1000 个样本每个样本有 2 个特征特征值服从标准正态分布。真实权重是[2, -3.4]真实偏置是 4.2。然后我们加上一些均值为 0、标准差为 0.01 的噪声模拟现实中的测量误差。这个数据集的精巧之处在于噪声很小所以线性关系非常明显模型只要结构正确、训练闭环正常运行学到的参数就会非常接近真实值。如果训练完之后w学出来是[1.2, -2.0]那基本可以确定你的闭环某个环节写错了。这在调试阶段是极其宝贵的反馈信号。我把真实数据和预测结果放在一起对比了一下第一行数据长这样每个人跑出来的随机值不同第一个特征大约 0.8 左右第二个特征大约 -0.5 左右标签大约 4.2 20.8 - 3.4(-0.5) ≈ 7.5。当你在训练后重新算一遍预测值会发现和标签的差距基本在 0.01 数量级这时候你就知道训练闭环确实跑通了。3.2 手动实现模型、损失函数和优化器模型、损失和优化器三个组件的代码在上一节已经分别写过了这里合在一起再说明一下它们怎么衔接。模型的输入形状是(batch_size, num_features)。以 batch size 为 10 为例X的形状是(10, 2)权重w是(2, 1)torch.matmul(X, w)的结果是(10, 1)加上偏置b之后输出预测形状(10, 1)。标签y在生成数据时已经做了reshape((-1, 1))所以也是(10, 1)。两者相减得到误差再求平方得到每个样本的损失。这些损失后续在训练循环里会求均值。这里有一个关于张量形状的提醒在squared_loss里我写了y.reshape(y_hat.shape)这是因为在某些情况下y的形状可能和y_hat不一致。比如在d2l库的某些版本里标签的形状是(batch_size, 1)但预测的形状是(batch_size, 1)或者(batch_size,)保险起见做一个 reshape 能避免很多“形状对不上”的报错。这个习惯我一直保留到之后写复杂模型。优化器代码def sgd(params, lr, batch_size): with torch.no_grad(): for param in params: param - lr * param.grad / batch_size param.grad.zero_()为什么里面要除以batch_size因为我们的损失函数没做均值每个样本贡献了一个独立的损失。反向传播后param.grad是整个 batch 所有样本梯度的总和。如果不除以 batch size梯度大小就会随 batch 增大而增大学习率会变得极其难调。这个细节是李沐从零实现里“刻意”保留的一定要看懂。3.3 核心训练循环建立最小训练闭环的地方万事俱备现在写主训练循环lr 0.03 num_epochs 3 net linreg loss squared_loss w torch.normal(0, 0.01, size(2, 1), requires_gradTrue) b torch.zeros(1, requires_gradTrue) for epoch in range(num_epochs): for X, y in data_iter(batch_size, features, labels): l loss(net(X, w, b), y) l.sum().backward() sgd([w, b], lr, batch_size) with torch.no_grad(): train_l loss(net(features, w, b), labels) print(fepoch {epoch 1}, loss {float(train_l.mean()):f})这一段代码我建议你仔细品因为它浓缩了整个深度学习的训练范式。外层循环是 epoch内层循环是 batch 迭代。每个 batch 做三件事前向计算损失、反向传播计算梯度、优化器更新参数。第一件前向计算损失。net(X, w, b)得到预测值loss(...)算出每个样本的平方误差l是一个形状为(batch_size, 1)的张量。第二件l.sum().backward()对总损失做反向传播。为什么是 sum 而不是 mean因为我们的squared_loss没有做均值直接对所有样本的损失求和即可求和不影响梯度的方向只影响大小后面sgd里会按 batch size 归一。第三件sgd([w, b], lr, batch_size)用算好的梯度更新参数并清空梯度。每完成一个 epoch我们用一个torch.no_grad()环境在全量数据上计算平均损失并打印。这个操作叫“评估”目的是观察训练集整体的损失水平。为什么要包no_grad()因为评估阶段不需要计算梯度省内存也能避免不小心把评估的损失也接进计算图里。如果你严格按照书里的参数跑lr0.03、num_epochs3最终打印出来的损失会一路下降大概到 0.0001 这个量级。而此时w和b的值也会非常接近真实值。我在一次运行中得到了w [1.9996, -3.3997]b 4.2001。这就是整个最小训练闭环给出的结果——误差大概在千分位。你可以通过改动学习率、epoch 数来看训练曲线的不同表现这是第一个值得做的实验。3.4 手动实现 vs 简洁实现两条路线怎么选李沐这套书每个模型都给了两条路线一种从零实现把模型、损失、优化器都手动写出来一种用 PyTorch 高层 API比如nn.Linear、nn.MSELoss、optim.SGD几行代码搞定同一个任务。我强烈建议初学者两条都要跑但顺序上务必先手动实现再跑简洁实现。手动实现的好处前面已经说过你能看到训练的全部细节。但它的缺点也很明显就是代码冗长、容易出边界 bug而且不能扩展到真实规模。简洁实现的好处是开发效率高、代码稳定但如果你没有先跑过手动实现你无法理解nn.Linear内部到底初始化了什么参数MSELoss默认的 reduction 参数是 mean 还是 sumSGD的 momentum 参数到底影响什么。简洁实现的关键代码长这样net nn.Sequential(nn.Linear(2, 1)) net[0].weight.data.normal_(0, 0.01) net[0].bias.data.fill_(0) loss nn.MSELoss() trainer torch.optim.SGD(net.parameters(), lr0.03) for epoch in range(num_epochs): for X, y in data_iter(batch_size, features, labels): trainer.zero_grad() l loss(net(X), y) l.backward() trainer.step()注意这里loss(net(X), y)传入的y形状是(batch_size, 1)正好符合MSELoss的输入要求。trainer.zero_grad()等价于手动实现里sgd末尾的param.grad.zero_()。trainer.step()等价于param - lr * param.grad / batch_size实际上 PyTorch 里是param - lr * param.grad但 PyTorch 的MSELoss默认 reductionmean所以效果上已经做了均值。对比着看这两段代码你会发现它们做的是完全同一件事。区别只是“把细节藏在了哪里”。对我来说手动实现是“知其所以然”简洁实现是“高效生产”两者缺一不可。李沐在书里反复强调“不是重复造轮子而是理解轮子怎么转”这句话值得记下来。4. 常见问题与排查技巧实录线性回归虽然简单但我自己以及身边朋友在学习时踩过的坑还真不少。有些坑在跑复杂模型时会以更隐蔽的方式再次出现所以值得专门拉一个清单出来。4.1 学习率怎么选一个直接影响训练成败的参数学习率这个参数可以说是训练闭环里最容易出问题、也最需要反复实验的。我在跑线性回归时试过几组不同的学习率记录一下现象。先试lr 0.1。损失曲线会下降得非常快第一个 epoch 结束就已经接近最优值了但继续训练时你会发现损失在某个数值附近来回震荡无法进一步下降。这其实就是学习率偏大、参数在最优解附近来回“跨步”导致的。典型特征是损失曲线不再平滑下降而是在一个区间里反复横跳。再试lr 0.5。这一般会直接让训练发散。损失不但不降反而越滚越大甚至打印出来是nan。因为学习率过大每一步的梯度更新都会跨过最优解甚至跳到损失更大的区域下一个梯度反而更大产生正反馈爆炸。试lr 0.0001。损失下降非常缓慢。跑了几个 epoch 之后损失可能还有 3 左右而正常情况应该在 0.0001 量级。这不是模型错了是学习率太小、走得太慢。你需要增加训练轮数epoch来弥补但在真正调参时没有人愿意等那么久。我的经验是从一个中等量级开始观察损失曲线再调整方向。比如从 0.03 开始如果损失下降平滑说明学习率可以往大了试探如果损失震荡就往小了调。每次调整幅度建议乘以或除以 3 到 10不要小步挪。这个“指数级试探”的方法在后续调 Adam、调 warmup 时同样适用。4.2 归一化对训练的影响我在用真实数据跑线性回归时发现一个问题如果某个特征的数值范围远大于其他特征比如房价数据里“面积”可能是 50 到 200而“房间数”只有 1 到 10那么梯度更新时占主导的永远是面积这个特征。结果就是模型对大面积特征的权重学得很快对小范围特征不敏感。这里就涉及特征归一化Feature Scaling的必要性。标准做法是把每个特征缩放到均值为 0、方差为 1。代码很简单X_norm (X - X.mean(dim0)) / X.std(dim0)归一化之后所有特征的尺度一致梯度更新时会平等对待每个特征训练速度和稳定性都会大幅提升。这个操作在后续所有深度学习任务里几乎都是标配。李沐的人工数据集里所有特征都已经服从标准正态分布所以教材没有刻意强调这一步但到了真实项目里这是绕不开的一步。另外标签是否要进行归一化取决于任务。对于回归任务如果标签的绝对值很大损失会很大梯度也会偏大可能导致数值不稳定。我一般会先看标签的分布如果跨度超过几个数量级就做一次标准化等到预测输出时再反标准化回来。4.3 一个更隐蔽的坑忘记清空梯度手动实现里sgd函数最后一行写了param.grad.zero_()简洁实现里也调了trainer.zero_grad()。如果你把这一行去掉会发生什么每个 batch 反向传播时计算的梯度会和前一个 batch 的梯度累加导致参数更新方向严重偏离真实的当前梯度方向。损失曲线表现为下降一会儿又反弹完全没有规律。这个坑在你以后用 PyTorch 写自定义训练循环时非常容易踩到。PyTorch 设计成梯度累积模式是为了服务某些特殊方法比如梯度积累模拟大 batch、GAN 里先更新判别器再更新生成器等但默认情况下你必须记得在每个 batch 开始前清空梯度。我的习惯是统一在训练循环的第一步写optimizer.zero_grad()这样就算后续代码逻辑变了也不容易漏。4.4 从线性回归到真实项目的迁移清单如果看完这篇文章你决定用同样的思路去跑一个真实回归项目我建议你先做这几件事。第一数据准备阶段先画图或者做分布统计看看特征和标签之间大致是什么关系。如果关系明显不是线性的不要指望线性回归出很好效果试试加多项式特征或者直接换更复杂模型。第二按 7:2:1 或者 8:1:1 划分训练集、验证集、测试集。训练集用于更新参数验证集用于评估和调参测试集只在最终报告结果时用一次。很多初学者把测试集反复用来调参这会导致“模型对测试集过拟合”而不自知。第三保存模型参数和训练曲线。就算是最简单的线性回归有了训练过程的详细记录你才能复现实验结果也才能对比不同超参数的影响。李沐在课程里也推荐使用torch.save(net.state_dict(), model.weights)保存模型参数。我还想分享一个小技巧从线性回归开始我养成了在训练循环里打印损失时同时打印w和b当前值的习惯。对于合成数据你可以实时对照真实权重直观感受训练过程。这个“看着参数逼近真值”的体验非常上瘾也是你对训练闭环建立直觉的最快方式。另外李沐在每一章的配套视频里都有 Jupyter Notebook 代码建议你阅读文章时把 notebook 打开对照。这本书最有价值的其实是“代码旁白”——就是那些写在 Markdown 单元格里看似随意的注释和提问它们指向的全是新手最容易犯糊涂的地方。最后再讲一个很多人没注意到的点为什么全书第一件事是跑d2l库的安装而不是直接写模型因为深度学习项目里“环境”本身就是一个巨大的门槛。如果你跟着这本书走建议先花半天时间把 PyTorch 环境配好再用 CPU 跑通前几个模型完全没问题。等到后面要跑大规模数据或者复杂网络再考虑 GPU 也不迟。一个最小训练闭环最先要闭环的其实是“从零到能跑起代码”这个过程本身。这一步迈过去了后面的路会顺畅很多。