张量与自动微分:深度学习的计算地基
深度学习里没有别的数据结构,只有张量;也没有别的学习方式,只有「前向留轨迹、反向求梯度、顺着梯度改一点点」。这一页把这两件事拆到能自己手写出来。
30″30 秒看懂张量与自动微分
把深度学习想成一条集装箱码头的流水线:所有货物都必须先装进标准集装箱才能上传送带,而整条流水线每天都在做同一件事——称一次重量,发现偏差,把每台机器的旋钮拧一点点。
集装箱就是张量(Tensor):箱子的长宽高是它的 shape,箱子里装的是钢材还是棉花是它的 dtype。一个数字是 0 维的小箱子,一排数字是 1 维的长条箱,一张表是 2 维的箱垛,一批彩色图片就是 4 维的箱山。深度学习框架不认识「图片」「句子」「价格」,它只认识箱子的形状对不对得上。

而自动微分(autograd)是流水线上的行车记录仪:货物每经过一台机器,记录仪就把「谁加工的、怎么加工的」记一笔。等到终点称出偏差,记录仪倒着播一遍录像,就能算出每台机器该为这次偏差负多少责任——这个「责任值」就是梯度。
| 比喻里的角色 | 对应的技术概念 | 它到底是什么 |
|---|---|---|
| 标准集装箱 | torch.Tensor | 深度学习里唯一的数据结构,模型的输入、输出、参数全是它 |
| 箱子的长宽高 | shape | 每个维度的长度;形状对不上就报错,这是新手 80% 的报错来源 |
| 箱里装的货 | dtype | float32 用于特征和权重,int64 用于分类标签,混用会直接抛异常 |
| 行车记录仪的开关 | requires_grad=True | 打开才记录轨迹;关着的张量参与运算但不会被求梯度 |
| 这一段录像 | grad_fn | 记录这个张量是由哪种运算产生的,反向时靠它找上一环 |
| 倒放录像算责任 | loss.backward() | 沿轨迹反向求导,把每个参数的梯度写进它的 .grad |
| 拧旋钮 | optimizer.step() | 按梯度反方向改参数;backward 本身不改任何参数 |
backward() 做的事是把 .grad 填上,参数纹丝不动;真正把参数改掉的是紧随其后的那一步更新。而且 .grad 默认累加不清零——所以每轮必须先清零再反向,顺序是「前向 → 算损失 → 清零 → 反向 → 更新」。这条链贯穿后面所有页。
01概念:张量是什么,为什么非它不可
定义、和 NumPy 数组的分界线,以及深度学习与传统机器学习的差别
1.1 什么是张量
张量(Tensor)是一个多维数组。 这句话听着平淡,关键在「多维」这两个字上——维度的数量(ndim)决定了它能承载什么样的数据:
| 维度 | 俗称 | shape 举例 | 真实场景里装的是什么 |
|---|---|---|---|
| 0 维 | 标量 scalar | torch.Size([]) | 一次训练的 loss 值、一个准确率 |
| 1 维 | 向量 vector | [128] | 一个词的 embedding、一个样本的特征 |
| 2 维 | 矩阵 matrix | [64, 128] | 一批样本的特征表、一个全连接层的权重 |
| 3 维 | — | [32, 50, 128] | 一批句子:批量大小 × 句长 × 词向量维度 |
| 4 维 | — | [32, 3, 32, 32] | 一批彩色图片:批量大小 × 通道 × 高 × 宽 |
注意最后两行的第一个维度都是 批量大小(batch size)。这是深度学习里近乎强制的约定:数据永远成批进出网络,所以第 0 维几乎总是「这一批有多少条样本」。后面读任何报错信息,第一件事都是把这个约定套上去,看看是不是把单条样本当成一批喂进去了。
1.2 为什么不直接用 NumPy
NumPy 的 ndarray 也是多维数组,两者甚至能共享内存互转。张量多出来的是三样东西,每一样都决定了它能不能用来训练模型:
张量能记住自己是怎么被算出来的,从而支持 backward() 一步求出所有参数的梯度。NumPy 要自己手推导数、手写链式法则,网络一深就不现实。
tensor.to('cuda') 一行就把数据搬到显卡上,矩阵乘法并行度提升几十倍。NumPy 只跑 CPU。
nn.Linear、nn.Conv2d 这些层只吃张量,且对 dtype 和 shape 有硬性要求,中间夹一层 NumPy 就断链了。
tensor.numpy() 和 torch.from_numpy(arr) 共享同一块内存,改一边另一边跟着变。要断开必须显式 .copy()(NumPy 侧)或 torch.tensor(arr)(会复制一份)。调试时数据莫名其妙被改掉,八成是这里。
1.3 深度学习和传统机器学习差在哪
两者都在「从数据里学规律」,分界线在特征是谁造出来的:
| 维度 | 传统机器学习 | 深度学习 |
|---|---|---|
| 特征从哪来 | 人工设计(特征工程往往占七成工作量) | 网络自己从原始数据里学,逐层从边缘到部件再到整体 |
| 数据量 | 几千条就能出效果 | 数据越多优势越明显,小数据上反而容易过拟合 |
| 算力 | CPU 够用 | 基本依赖 GPU |
| 可解释性 | 决策树、线性模型能讲清每个系数 | 参数以亿计,整体是黑箱 |
| 适合的数据 | 结构化表格 | 图像、文本、语音这类高维非结构化数据 |
这条分界线也解释了后面几页的编排顺序:先有能自动求梯度的张量,才谈得上让网络自己学特征。张量和自动微分是地基,神经网络、CNN、RNN、Transformer 都盖在它上面。
02原理:从建箱子到倒放录像
创建、运算、变形、拼接,再到计算图与梯度下降的完整机制
2.1 创建张量的四条路
PyTorch 建张量的 API 看着有十几个,其实只分四类,按「你手里有什么」选:
| 你手里有 | 用哪个 | 要点 |
|---|---|---|
| 现成的数据 | torch.tensor(data) | 小写 t。数据是什么类型,张量就是什么类型:整数列表出来是 int64,带小数点的出来是 float32 |
| 只有形状 | torch.Tensor(2, 3) | 大写 T。按形状开一块内存,里面是脏数据,必须自己覆盖掉,否则会把内存垃圾当特征训练 |
| 要规则填充 | zeros / ones / full / arange / linspace | 训练里最常用;arange 按步长取点,linspace 按个数等分,别记混 |
| 要随机初始化 | randn / rand / randint | 务必先 torch.manual_seed(n),否则每次跑结果不同,实验没法复现,也没法判断改动到底有没有用 |
torch.Tensor(2, 3) 理解成「建一个 2×3 的空箱」,torch.tensor([2, 3]) 理解成「把 [2,3] 这两件货装箱」。前者给形状,后者给数据。写错的现象是:本想要一个两元素的向量,结果拿到一个装满随机垃圾的 2×3 矩阵。
类型转换有两套等价写法:x.type(torch.DoubleTensor) 和 x.double(),挑顺手的用。取出单个 Python 数字用 .item()——训练循环里累加 loss 全靠它,直接累加张量会把整张计算图一起留在显存里,跑几百轮就爆显存。
2.2 运算与形状变换
张量运算分两大类,混淆这两类是矩阵维度报错的头号原因:
add / sub / mul / div,以及 a * b。要求两个张量形状相同(或能广播),对应位置各算各的,结果形状不变。mul 是点乘(Hadamard 积),不是矩阵乘法。
a @ b 或 torch.matmul(a, b)。要求前一个的列数等于后一个的行数:(n, m) @ (m, p) → (n, p)。matmul 还支持带 batch 的高维张量,实际网络里用得更多。
另一个必须形成肌肉记忆的约定:方法名带下划线的是原地修改。a.add(10) 返回新张量、a 不变;a.add_(10) 直接把 a 改掉。原地操作省内存,但会破坏自动微分需要的历史值,对 requires_grad=True 的张量慎用。
统计函数(mean / sum / max)都有一个 dim 参数,含义是「沿哪个维度压扁」。对一个 (2, 3) 的矩阵:dim=0 把 2 行压成 1 行,得到 3 个数(每列一个);dim=1 把 3 列压成 1 列,得到 2 个数(每行一个)。记住「dim 指的是被消掉的那个维度」,就不会搞反。另外 mean 要求浮点类型,整型张量调用会直接报错。
变形四件套
| 函数 | 作用 | 什么时候用 |
|---|---|---|
reshape(a, b) | 重排成新形状 | 最通用,元素总数必须相等;-1 表示这一维让它自己算 |
unsqueeze(dim) / squeeze() | 加一维 / 去掉长度为 1 的维 | 单条样本要喂进网络时补出 batch 维;预测结果要和标签比对时去掉多余维 |
transpose(d1, d2) | 交换两个维度 | 一次只能换两个 |
permute([...]) | 按新顺序重排所有维度 | 一次换多个,比如把 (N, H, W, C) 换成 PyTorch 要的 (N, C, H, W) |
view 只认内存连续的张量。transpose / permute 之后内存不再连续,直接 view 会抛 RuntimeError,必须先 .contiguous()。reshape 会在必要时自动帮你复制一份,所以拿不准就用 reshape。
拼接也有两个容易混的:torch.cat 在已有维度上接长,不增加维度;torch.stack 新增一个维度再摞起来。两个 (1,2,3) 张量,cat(dim=0) 得到 (2,2,3),stack(dim=0) 得到 (2,1,2,3)。
"""张量的运算、索引、变形与拼接:把数据摆成网络要的形状。"""
import torch
torch.manual_seed(0)
# ---------- 一、逐元素运算:形状相同,对应位置各算各的 ----------
a = torch.tensor([[1, 2], [3, 4]])
b = torch.tensor([[5, 6], [7, 8]])
print(a.add(10)) # 返回新张量,a 本身不变
print(a.add_(10)) # 带下划线 = 原地改,a 被改掉了
print(torch.mul(a, b)) # 点乘(Hadamard),等价写法 a * b
# ---------- 二、矩阵乘法:形状必须接得上 (n,m) @ (m,p) -> (n,p) ----------
m1 = torch.tensor([[1, 2], [3, 4], [5, 6]]) # (3, 2)
m2 = torch.tensor([[5, 6], [7, 8]]) # (2, 2)
print(m1 @ m2) # (3, 2),运算符写法
print(torch.matmul(m1, m2)) # 等价,且支持带 batch 的高维张量
# ---------- 三、常用统计函数:注意 dim ----------
d = torch.randint(0, 10, [2, 3], dtype=torch.float64) # mean 要求浮点类型
print(d.mean()) # 全局均值
print(d.mean(dim=0)) # 沿 0 轴压扁 -> 每列一个均值
print(d.mean(dim=1)) # 沿 1 轴压扁 -> 每行一个均值
print(d.sum(), d.sqrt(), d.exp(), d.log())
# ---------- 四、索引:取子块的四种写法 ----------
g = torch.randint(0, 10, [4, 5])
print(g[0]) # 第 0 行
print(g[:, 0]) # 第 0 列
print(g[[0, 1], [1, 2]]) # 花式索引:(0,1) 和 (1,2) 两个点
print(g[:3, :2]) # 范围索引:前 3 行的前 2 列
print(g[g[:, 2] > 5]) # 布尔索引:第 2 列大于 5 的那些行
# ---------- 五、变形:网络报形状错误时最常动的就是这几个 ----------
t = torch.tensor([[10, 20, 30], [40, 50, 60]]) # (2, 3)
print(t.reshape(1, 6).shape) # (1, 6),最通用
print(t.unsqueeze(dim=0).shape) # (1, 2, 3),补一个维度
print(t.unsqueeze(0).squeeze().shape) # (2, 3),去掉长度为 1 的维度
h = torch.randint(0, 10, [3, 4, 5])
print(torch.transpose(h, 1, 2).shape) # (3, 5, 4),交换两个维度
print(h.permute([1, 2, 0]).shape) # (4, 5, 3),一次换多个
# view 只认内存连续的张量,transpose 之后必须先 contiguous
swapped = torch.transpose(t, 0, 1)
print(swapped.is_contiguous()) # False
print(swapped.contiguous().view(2, 3).shape) # (2, 3)
# ---------- 六、拼接:cat 不加维度,stack 加一个新维度 ----------
p = torch.randint(0, 10, [1, 2, 3])
q = torch.randint(0, 10, [1, 2, 3])
print(torch.cat([p, q], dim=0).shape) # (2, 2, 3),在已有维度上接长
print(torch.stack([p, q], dim=0).shape) # (2, 1, 2, 3),多出一维
2.3 自动微分:前向建图,反向倒推
回到行车记录仪的比喻。当一个张量带上 requires_grad=True,它参与的每一步运算都会被记下来,形成一张计算图(computational graph)——图上的节点是张量,边是运算。

图里的三处细节要逐个看清:
- 输入数据
x的开关是关着的。 我们要调的是模型参数w和b,不是训练数据本身。所以x的requires_grad为False,它只是沿着传送带走过去。 - 中间结果会带上
grad_fn。 打印y = 2 * x ** 2会看到grad_fn=<MulBackward0>,意思是「我是被一次乘法算出来的」。这就是那一小段录像,反向时靠它找到上一环。 - 梯度最终落在叶子节点的
.grad里。 叶子节点就是你亲手创建、没有grad_fn的那些张量,也就是模型参数。中间结果的.grad默认不保留。
backward() 做的就是链式法则:从终点的 loss 开始,一层层把偏导数乘回去。以图中这条链为例,∂loss/∂w = (∂loss/∂z) × (∂z/∂w)——沿路把各段导数连乘起来,就得到 w 该负的责任。网络有一百层,就连乘一百段;这也埋下了后面几页要处理的梯度消失伏笔:每段都小于 1 的话,连乘一百次基本归零。
链式法则不是比喻,是可以逐数字验证的。下面这段把一个三层复合函数的导数先手推一遍、再让 PyTorch 算一遍,顺便把「梯度累加」和「中间结果没有梯度」两件事当场演示出来:
"""链式法则实证:三层复合函数,手算一遍再让 PyTorch 算一遍,对上才算懂。
复合关系:
u = 3 * w + 2
v = u ** 2
loss = 5 * v
手推:
dloss/dv = 5
dv/du = 2u
du/dw = 3
dloss/dw = 5 * 2u * 3 = 30u = 30 * (3w + 2)
w = 1 时,u = 5,dloss/dw = 150。
"""
import torch
def main():
w = torch.tensor(1.0, requires_grad=True)
u = 3 * w + 2
v = u ** 2
loss = 5 * v
# 每一步都带着 grad_fn,这就是反向时要走的路
print('u =', u.item(), ' grad_fn =', u.grad_fn)
print('v =', v.item(), ' grad_fn =', v.grad_fn)
print('loss =', loss.item(), ' grad_fn =', loss.grad_fn)
loss.backward()
print('PyTorch 算出的 dloss/dw =', w.grad.item())
print('手推公式 30*(3w+2) 在 w=1 处 =', 30 * (3 * 1 + 2))
# ------------------------------------------------------------ 梯度会累加
# 不清零就再反向一次,梯度变成两倍,这就是忘了 zero_grad() 的后果
w2 = torch.tensor(1.0, requires_grad=True)
for i in range(3):
y = 5 * (3 * w2 + 2) ** 2
y.backward()
print('第 %d 次 backward 之后 w2.grad = %.1f' % (i + 1, w2.grad.item()))
# 输出 150 / 300 / 450:每次都往上叠,没有任何报错
# ------------------------------------------------------------ 正确做法
w3 = torch.tensor(1.0, requires_grad=True)
for i in range(3):
y = 5 * (3 * w3 + 2) ** 2
if w3.grad is not None:
w3.grad.zero_() # 清零之后每次都是干净的 150
y.backward()
print('清零后第 %d 次 w3.grad = %.1f' % (i + 1, w3.grad.item()))
# ------------------------------------------------------------ 中间结果没有梯度
print('中间张量 v 的 grad:', v.grad) # None,并伴随一条 UserWarning
# 确实需要时要显式保留:
w4 = torch.tensor(1.0, requires_grad=True)
u4 = 3 * w4 + 2
u4.retain_grad()
(5 * u4 ** 2).backward()
print('retain_grad 之后 u4.grad =', u4.grad.item()) # 10u = 50
if __name__ == '__main__':
main()
重点看中间那一段:连续调三次 backward() 不清零,梯度从 150 变 300 再变 450,全程一个警告都没有。这就是下一节要把「清零」单独列成一步的原因。
y.backward() 要求 y 是一个数。这不是限制,而是定义决定的:梯度回答的是「y 变化一点点,各个参数各要负多少责任」,y 得先是个能比大小的单一数值。所以损失函数永远要把一批样本的误差汇总成一个标量。向量张量要求梯度,先 .sum() 或 .mean()。
2.4 梯度下降:顺着坡往下挪
有了梯度,「学习」就只剩一个动作:参数沿梯度的反方向挪一小步。

更新公式写出来只有一行:w ← w − lr × ∂loss/∂w。lr(学习率)就是每步迈多大。梯度是正的说明「往大了调会让损失更大」,所以减掉它;梯度是负的就等于加上去。两种情况都由这一个减号统一处理。
.grad 是累加的:不清零的话,第 100 轮拿到的是前 100 轮梯度之和,参数会被推到离谱的地方,表现为 loss 先降后暴涨成 nan。这个设计不是缺陷——梯度累加正是显存不够时模拟大 batch 的手段,后面会用到。默认累加,所以你必须显式说「这轮重新开始」。
2.5 广播:形状不同为什么也能相加
两个形状不同的张量相加,不一定报错——PyTorch 会按广播(broadcasting)规则把小的那个拉伸到大的形状上。规则只有两条,从最后一个维度往前对齐逐维比:长度相等就直接对上;其中一个长度是 1 就把这一维拉伸。两条都不满足,才报错。
| A 的形状 | B 的形状 | 结果 | 怎么对的 |
|---|---|---|---|
(3, 4) | (4,) | (3, 4) | B 前面补一维变 (1,4),再把这一维拉成 3 |
(3, 4) | (3, 1) | (3, 4) | B 的最后一维从 1 拉成 4 |
(3, 4) | (3, 5) | 报错 | 4 和 5 既不相等、也都不是 1 |
(8, 1) | (8,) | (8, 8) | 不报错,但算出了 64 个差值——真实项目里最隐蔽的 bug |
"""广播(broadcasting):形状不同的张量为什么有时能直接相加。
广播是把小的那个张量"复制"到大的形状上,但它不真的复制内存。
好处是省代码,坏处是**形状写错时它不报错**,安安静静算出一个错的结果。
训练里最隐蔽的一类 bug 就来自这里。
"""
import torch
# ---------------------------------------------------------------- 一、广播的两条规则
# 从最后一个维度往前对齐,逐维比较:
# 1) 长度相等 -> 直接对上
# 2) 其中一个长度是 1 -> 把这一维拉伸成另一个的长度
# 任何一维两条都不满足,才会报错。
a = torch.ones(3, 4) # (3, 4)
b = torch.tensor([1., 2., 3., 4.]) # (4,) -> 前面补 1 变 (1, 4) -> 拉伸成 (3, 4)
print((a + b).shape) # (3, 4)
c = torch.ones(3, 1) # (3, 1) -> 第 1 维拉伸成 4
print((a + c).shape) # (3, 4)
d = torch.ones(3, 5)
try:
_ = a + d # 4 和 5 既不相等、也都不是 1
except RuntimeError as e:
print('形状不兼容:', e)
# ---------------------------------------------------------------- 二、真实会坑到人的场景
# 模型输出 (batch, 1),标签 (batch,),两者算 MSE
y_pred = torch.randn(8, 1) # 网络输出天然带最后一维
y_true = torch.randn(8) # 从 DataLoader 里直接拿到的标签
wrong = ((y_pred - y_true) ** 2).mean() # 广播成 (8, 8),算了 64 个差值
right = ((y_pred - y_true.reshape(-1, 1)) ** 2).mean() # 只算 8 个
print('形状被悄悄广播成:', (y_pred - y_true).shape)
print('错的损失 %.6f,对的损失 %.6f' % (wrong.item(), right.item()))
# 两个数都是"正常"的浮点数,不看形状根本发现不了
# ---------------------------------------------------------------- 三、自检习惯
# 算损失前把两边形状打出来,一秒钟的事,能省掉半天的排查
print('y_pred', y_pred.shape, 'y_true', y_true.shape)
assert y_pred.shape == y_true.reshape(-1, 1).shape
.shape 打出来看一眼,或者干脆写一句 assert。一秒钟的事,能省掉半天的排查——因为广播算出来的损失值看起来完全正常,只是模型永远学不好。
2.6 把张量搬到 GPU
张量默认住在内存(CPU),要用显卡得手动搬。三种写法里只有 .to(device) 能让同一份代码在有卡和没卡的机器上都不用改,所以直接学这一种就行:
| 写法 | 例子 | 评价 |
|---|---|---|
| 建的时候指定 | torch.tensor(d, device=device) | 适合张量就地创建的场景 |
| 专用方法 | t.cuda() / t.cpu() | 没卡的机器上直接报错,不推荐 |
| 通用方法 | t.to(device) | 推荐,配上 torch.cuda.is_available() 自动降级 |
"""把张量搬到 GPU:三种写法,以及一条必须记住的规矩。
规矩:参与同一次运算的张量必须在同一个设备上。
模型在 GPU、数据在 CPU,会直接抛 RuntimeError,
报错原文是 Expected all tensors to be on the same device。
"""
import torch
# ---------------------------------------------------------------- 先探测环境
# 标准写法:有显卡就用显卡,没有就退回 CPU,脚本两边都能跑
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
print('当前设备:', device)
print('可用显卡数量:', torch.cuda.device_count())
# ---------------------------------------------------------------- 写法一:建的时候就指定
a = torch.tensor([10, 20, 30], device=device)
print(a.device)
# ---------------------------------------------------------------- 写法二:.cuda() / .cpu()
b = torch.tensor([10, 20, 30])
if torch.cuda.is_available():
b = b.cuda() # 搬到默认显卡
print(b.device)
b = b.cpu() # 再搬回来
print(b.device)
# ---------------------------------------------------------------- 写法三:.to(device),推荐
# 只有这一种能让同一份代码在有卡/没卡的机器上都不用改
c = torch.tensor([10, 20, 30]).to(device)
print(c.device)
# ---------------------------------------------------------------- 跨设备运算会报错
cpu_tensor = torch.tensor([1., 2., 3.])
if torch.cuda.is_available():
gpu_tensor = cpu_tensor.to('cuda:0')
try:
_ = cpu_tensor + gpu_tensor # 一个在内存、一个在显存
except RuntimeError as e:
print('跨设备相加报错:', e)
# 正确做法:先统一设备
print((cpu_tensor.to(device) + gpu_tensor).device)
# ---------------------------------------------------------------- 训练脚本里的固定套路
# model = MyModel().to(device)
# for bx, by in dataloader:
# bx, by = bx.to(device), by.to(device) # 每个 batch 都要搬
# ...
# 注意:模型搬一次就够(参数常驻显存),数据要每个 batch 搬一次。
# ---------------------------------------------------------------- 取回 CPU 才能转 NumPy
d = torch.tensor([1., 2., 3.], requires_grad=True).to(device)
# .detach() 断开计算图,.cpu() 搬回内存,两步都不能少
print(d.detach().cpu().numpy())
Expected all tensors to be on the same device。固定套路是:模型搬一次(参数常驻显存),数据每个 batch 搬一次。另外,显存里的张量要转 NumPy,必须先 .detach().cpu() 两步都做完。
03最小代码:把张量和梯度各跑一次
先用最短的两段代码确认环境能跑,再去看完整案例
3.1 张量:建、转、取
这一段没有网络、没有训练,只把「怎么建箱子、怎么看形状、怎么换类型、怎么和 NumPy 互转」跑一遍。跑通它就说明 PyTorch 装对了。
"""张量的创建、类型与形状:最小可跑例子。"""
import torch
# ① 用现成数据建张量:数据是什么类型,张量就跟着是什么类型
scalar = torch.tensor(10) # 0 维,标量
vector = torch.tensor([10., 20., 30.]) # 1 维,向量,float32
matrix = torch.tensor([[1, 2, 3], [4, 5, 6]]) # 2 维,矩阵,int64
print(scalar.shape, vector.shape, matrix.shape) # torch.Size([]) ([3]) ([2, 3])
# ② 只给形状建张量:内存里是什么就是什么,是脏数据,必须自己覆盖掉
dirty = torch.Tensor(2, 3) # 大写 T,按形状开空间,值不确定
print(dirty.dtype) # torch.float32
# ③ 建规则张量:真实训练里用得最多
print(torch.zeros(2, 3)) # 全 0
print(torch.ones(2, 3)) # 全 1
print(torch.full([2, 3], 10)) # 全为指定值
print(torch.arange(0, 10, 2)) # 按步长取点:0 2 4 6 8
print(torch.linspace(0, 9, 10)) # 按个数等分
# ④ 随机张量:做实验前先固定种子,否则每次结果都不一样,没法复现
torch.manual_seed(100)
print(torch.randn(2, 3)) # 标准正态分布
print(torch.randint(0, 10, [2, 3])) # 指定区间的随机整数
# ⑤ 类型转换:两套写法等价,挑顺手的
x = torch.full([2, 3], 10)
print(x.dtype) # torch.int64
print(x.type(torch.DoubleTensor).dtype) # torch.float64
print(x.double().dtype) # torch.float64
# ⑥ 和 NumPy 互转:默认共享内存,改一个另一个跟着变
import numpy as np
t = torch.tensor([2, 3, 4])
n = t.numpy() # 共享内存
n[0] = 100
print(t) # tensor([100, 3, 4]),被连带改了
n2 = t.numpy().copy() # 加 copy 才断开
n2[0] = 999
print(t) # tensor([100, 3, 4]),这次没被影响
# ⑦ 单元素张量取出 Python 数字:训练循环里累加 loss 全靠它
print(torch.tensor([30.]).item()) # 30.0
3.2 自动微分:一次前向、一次反向、读一次梯度
全部自动微分的知识,浓缩成下面十几行。核心只有三句:打开开关、backward()、读 .grad。
"""自动微分最小例子:一次前向、一次反向、读一次梯度。"""
import torch
# requires_grad=True 是自动微分的总开关,且必须是浮点类型
x = torch.tensor(10, requires_grad=True, dtype=torch.float32)
y = 2 * x ** 2 # 前向:PyTorch 边算边把这条链记下来
print(y) # tensor(200., grad_fn=<MulBackward0>),grad_fn 就是轨迹
print(y.grad_fn) # 记录了这一步是乘法算出来的
y.backward() # 反向:沿轨迹倒推,只对标量成立
print(x.grad) # tensor(40.) —— 手算:y' = 4x,代 x=10 得 40
# 如果 y 是向量,backward 会报错,先 sum() 变标量再求
v = torch.tensor([10., 20.], requires_grad=True)
(2 * v ** 2).sum().backward()
print(v.grad) # tensor([40., 80.]) —— 4x 分别代 10 和 20
这段代码值得手算验证一遍:y = 2x²,导数 y' = 4x,代入 x = 10 得 40,正好是打印出来的 tensor(40.)。能对上手算结果,才算真的信了自动微分,而不是把它当黑箱。
torch.tensor(10, requires_grad=True) 会报错,因为整型不可导。必须写成 dtype=torch.float32,或者直接写 torch.tensor(10.) 带上小数点。这个报错新手每人都会遇到一次。
3.3 数据怎么成批进网络
训练脚本的第一段永远是把数据包成 Dataset、再交给 DataLoader。两者分工很清楚:Dataset 回答「一共多少条、第 i 条是什么」,DataLoader 回答「怎么成批、要不要打乱」。
"""Dataset 与 DataLoader:数据怎么成批进网络。
两种写法:
① 数据已经在内存里 -> TensorDataset 包一下就行
② 数据在磁盘/数据库/需要按需加载 -> 自定义 Dataset,写三个方法
"""
import torch
from torch.utils.data import Dataset, TensorDataset, DataLoader
# ---------------------------------------------------------------- ① 最省事的写法
x = torch.randn(103, 4) # 103 条样本,故意不是 batch 的整数倍
y = torch.randint(0, 3, (103,)) # 三分类,标签必须是 int64
ds = TensorDataset(x, y)
print('样本总数:', len(ds))
print('第 0 条:', ds[0][0].shape, ds[0][1].item())
loader = DataLoader(ds, batch_size=16, shuffle=True, drop_last=False)
for i, (bx, by) in enumerate(loader):
print('第 %d 批 x=%s y=%s' % (i, tuple(bx.shape), tuple(by.shape)))
# 一共 7 批:前 6 批各 16 条,最后一批只有 7 条
# drop_last=True 会把这不满的一批直接丢掉,训练时可选,验证时别丢
# ---------------------------------------------------------------- ② 自定义 Dataset
class MyDataset(Dataset):
"""三个方法是硬性接口,少一个 DataLoader 就用不了。"""
def __init__(self, features, labels):
self.features = features
self.labels = labels
def __len__(self):
"""告诉 DataLoader 一共有多少条。"""
return len(self.features)
def __getitem__(self, index):
"""按下标返回一条样本。真实项目里这里才去读图片、查数据库。
返回的必须是张量,且每条的形状要一致,否则没法拼成一个 batch。
"""
return self.features[index], self.labels[index]
custom = MyDataset(x, y)
loader2 = DataLoader(custom, batch_size=32, shuffle=False)
bx, by = next(iter(loader2))
print('自定义 Dataset 的一批:', bx.shape, by.shape)
# ---------------------------------------------------------------- 为什么必须 shuffle
# 如果数据按标签排好序(前 1/3 全是类别 0,中间全是 1……)
sorted_y = torch.cat([torch.zeros(34), torch.ones(34), torch.full((35,), 2.)]).long()
bad = DataLoader(TensorDataset(x, sorted_y), batch_size=16, shuffle=False)
first_batch_labels = next(iter(bad))[1]
print('不打乱时第一批的标签:', first_batch_labels.unique().tolist())
# 整批只有一个类别,这一步算出来的梯度方向严重偏向该类,
# 模型会先被拽向"全猜 0",下一批又被拽向"全猜 1",训练来回震荡。
自定义 Dataset 只需要写三个方法,少一个 DataLoader 就用不了:
| 方法 | 返回什么 | 真实项目里干什么 |
|---|---|---|
__init__ | — | 记下文件路径列表、数据库连接、预处理参数,不要在这里把全部数据读进内存 |
__len__ | 样本总数 | DataLoader 靠它算一个 epoch 要跑多少批 |
__getitem__ | 一条样本 | 真正去读图片、查数据库、做单样本预处理;每条返回的形状必须一致,否则拼不成 batch |
最后一段演示了 shuffle 为什么不是可选项:数据若按标签排好序,不打乱时整批只有一个类别,这一步算出来的梯度会把模型拽向「全猜这一类」,下一批又拽向另一类,训练全程来回震荡。
pip install torch(CPU 版即可跑完本页所有代码)。线性回归案例另需 scikit-learn 造数据。是否有 GPU 不影响本页任何结论。
04完整案例
先手写一次梯度下降看清每一步,再用 PyTorch 四件套做一次线性回归
4.1 手写梯度下降:求 y = x² + 20 的最小值
这个案例故意不用 nn.Module、不用 optimizer,就是要把训练循环剥到只剩骨头:一个待优化的数 x,一个目标函数,四步循环。看懂它,后面所有训练脚本都只是它的放大版。
它还有一个好处:全程没有随机性,每一步都能手算核对。更新公式 x ← x − lr·2x 化简就是 x ← 0.98x,所以第 n 轮的 x 等于 10 × 0.98ⁿ——下面的输出表就是照这个公式算出来的,你可以拿计算器逐格验证。
目标函数 y = x² + 20 的最优解可以手算:y' = 2x = 0 得 x = 0,此时 y = 20。我们从 x = 10 出发,看梯度下降能不能自己走到 0。
"""用自动微分手写梯度下降,求 y = x**2 + 20 的极小值点。
这一份把训练循环剥到最干净:没有 model,没有 optimizer,
只剩「前向 → 清零 → 反向 → 更新」四步,看清楚每一步在动谁。
"""
import torch
def main():
# 1. 起点随便选一个远离最优解的位置
x = torch.tensor(10, requires_grad=True, dtype=torch.float32)
lr = 0.01 # 学习率:每次沿梯度反方向走多远
epochs = 1000
print('起点 x=%.6f y=%.6f' % (x.item(), (x ** 2 + 20).item()))
for i in range(1, epochs + 1):
# 1) 前向计算:每轮都要重新算,因为 x 变了
y = x ** 2 + 20
# 2) 梯度清零:.grad 默认累加,不清零会把历史梯度一起算进来
# 第一轮 x.grad 还不存在,所以要判空
if x.grad is not None:
x.grad.zero_()
# 3) 反向传播:把 dy/dx 算出来写进 x.grad,此时 x 本身还没动
y.backward()
# 4) 参数更新:必须走 x.data,直接写 x = x - lr * x.grad
# 会把 x 换成一个新的、不再是叶子节点的张量,下一轮就没有 .grad 了
x.data = x.data - lr * x.grad
if i % 200 == 0 or i == 1:
print('第 %4d 轮 x=%.6f y=%.6f 梯度=%.6f'
% (i, x.item(), (x ** 2 + 20).item(), x.grad.item()))
# 理论最优解:y' = 2x = 0 -> x = 0,y = 20
print('收敛到 x=%.8f,最小值 y=%.6f(理论值 x=0, y=20)'
% (x.item(), (x ** 2 + 20).item()))
if __name__ == '__main__':
main()
输出长什么样
学习率 0.01、迭代 1000 轮,打印大致是这样的走势(随机性为零,结果可复现):
| 轮次 | x | y | 发生了什么 |
|---|---|---|---|
| 起点 | 10.000000 | 120.000000 | 离最优解很远,梯度 2x = 20 很大 |
| 第 1 轮 | 9.800000 | 116.040001 | 挪了 0.01 × 20 = 0.2 |
| 第 200 轮 | 0.175879 | 20.030933 | 已经很接近,梯度变小,步子自动变小 |
| 第 1000 轮 | ≈ 0.000000 | 20.000000 | 收敛到理论最优解 |
注意越接近最低点、每步挪得越少——这不是代码里写了衰减,而是梯度本身在变小(2x 随 x 趋零而趋零)。这是梯度下降自带的性质,也是它能稳定停住而不是在最低点来回横跳的原因。
x.data = x.data - lr * x.grad
如果写成 x = x - lr * x.grad,等号右边是一次带梯度追踪的运算,产生的新张量不再是叶子节点。下一轮 x.grad 就是 None,程序直接崩。操作 .data(或包在 with torch.no_grad(): 里)才是绕开计算图、只改数值的正确姿势。真实项目里这一步由 optimizer.step() 代劳,它内部就是这么写的。
4.2 线性回归:第一次凑齐 PyTorch 四件套
上一个案例只有一个待优化的数。真实任务里参数成千上万,就该让 PyTorch 的四件套上场了:
TensorDataset 负责「按下标取一条」,DataLoader 负责「成批、打乱、迭代」。shuffle=True 不是可选项——数据若按标签排好序,不打乱会让每个 batch 内部样本高度同质,梯度方向失真。
nn.Linear(1, 1) 内部就是 y = w·x + b,w 和 b 已经自动带上 requires_grad=True,不用你手动开。
nn.MSELoss() 把一批样本的平方误差平均成一个标量——正好满足 backward() 要标量的要求。
optim.SGD(model.parameters(), lr)。必须把 model.parameters() 交给它,否则它不知道该更新谁,step() 调了也等于没调。
"""线性回归完整案例:第一次把 PyTorch 的四件套凑齐。
数据集 -> DataLoader -> nn.Linear -> MSELoss + SGD -> 训练循环
后面每个模型都是这套骨架的放大版,只是层数和损失函数换了。
"""
import torch
from torch import nn, optim
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import make_regression
def create_dataset():
"""造一批带噪声的线性数据:y ≈ coef * x + 14.5。"""
x, y, coef = make_regression(n_samples=100, n_features=1, noise=10,
coef=True, bias=14.5, random_state=0)
# sklearn 给的是 numpy,转成张量;统一成 float32,否则 nn.Linear 会报 dtype 不匹配
x = torch.tensor(x, dtype=torch.float32)
y = torch.tensor(y, dtype=torch.float32)
return x, y, coef
def train(x, y, epochs=100, lr=1e-2, batch_size=16):
# 1) 数据:TensorDataset 负责按下标取一条,DataLoader 负责成批、打乱
dataloader = DataLoader(TensorDataset(x, y), batch_size=batch_size, shuffle=True)
# 2) 模型:一个输入特征、一个输出值,内部就是 y = w*x + b
model = nn.Linear(in_features=1, out_features=1)
# 3) 损失函数与优化器:优化器要拿到 model.parameters() 才知道该更新谁
criterion = nn.MSELoss()
optimizer = optim.SGD(params=model.parameters(), lr=lr)
epoch_loss = []
for _ in range(epochs):
total_loss, batch_num = 0.0, 0
for train_x, train_y in dataloader:
# 前向:模型吃 (batch, 1),吐 (batch, 1)
y_pred = model(train_x)
# 真实值是 (batch,),要 reshape 成 (batch, 1) 才能和预测值对齐
loss = criterion(y_pred, train_y.reshape(-1, 1))
optimizer.zero_grad() # 清零
loss.backward() # 反向
optimizer.step() # 更新
total_loss += loss.item()
batch_num += 1
epoch_loss.append(total_loss / batch_num)
return model, epoch_loss
def main():
x, y, coef = create_dataset()
model, epoch_loss = train(x, y)
# 训练出来的 w 应该逼近 coef,b 应该逼近 14.5
print('真实斜率 coef = %.4f,真实截距 = 14.5' % float(coef))
print('训练得到 w = %.4f,b = %.4f'
% (model.weight.item(), model.bias.item()))
print('首轮平均损失 %.4f -> 末轮平均损失 %.4f'
% (epoch_loss[0], epoch_loss[-1]))
if __name__ == '__main__':
main()
逐段拆解
| 代码位置 | 在干什么 / 为什么这么写 |
|---|---|
make_regression(..., coef=True, bias=14.5) | 造一批真实斜率已知、截距为 14.5 的带噪数据。正因为答案已知,才能验证训练是不是真的学到了,而不是靠 loss 下降自我感觉良好。 |
torch.tensor(x, dtype=torch.float32) | sklearn 给的是 float64 的 NumPy 数组,nn.Linear 的权重是 float32,不转会报 dtype 不匹配。 |
train_y.reshape(-1, 1) | 模型输出是 (batch, 1),标签是 (batch,)。形状不一致时 MSELoss 会触发广播,算出一个看似正常实则完全错误的损失——这是最隐蔽的一类 bug。 |
zero_grad() → backward() → step() | 顺序不能乱。清零必须在反向之前,更新必须在反向之后。 |
total_loss += loss.item() | 用 .item() 取出 Python 浮点数。直接累加张量会把整张计算图挂住不释放,长训练必爆内存。 |
怎么判断这次训练成功了
脚本最后打印三行:真实的 coef 与 14.5、训练得到的 w 与 b、首轮与末轮的平均损失。判断成功与否,看的不是 loss 降没降,而是学出来的 w 和真实 coef 差多少、b 和 14.5 差多少。噪声 noise=10 决定了 loss 不可能降到 0——数据里本来就有这么多随机成分,损失降到噪声水平就到头了。拿 loss 绝对值当好坏标准是新手最常见的误判。
4.3 把四件套拆掉:裸手版线性回归
上一份代码里,nn.Linear、MSELoss、SGD、DataLoader 各替你做了一件事,但具体做了什么被封装藏起来了。把它们全拆掉、每一行都自己写,对照着看就清楚了:
| PyTorch 组件 | 它替你写的那一行 | 拆掉后你要自己做什么 |
|---|---|---|
nn.Linear(1,1) | y = x @ w + b | 自己建 w / b,自己打开 requires_grad |
nn.MSELoss() | ((y_pred - y) ** 2).mean() | 自己确保汇总成标量 |
optimizer.zero_grad() | w.grad.zero_() 逐个参数调 | 自己遍历所有参数,还得判空 |
optimizer.step() | w -= lr * w.grad | 自己包进 torch.no_grad() |
DataLoader(shuffle=True) | torch.randperm(n) 后切片 | 自己写一个生成器 |
loss.backward() | — | 这一步谁也替不了,autograd 是真正的内核 |
"""线性回归的"裸手"版:不用 nn.Linear、不用 MSELoss、不用 optim。
和 linear_regression.py 对照着看,就能看清 PyTorch 的四件套
分别替你做掉了哪一件事。这一份里每一行都是你自己写的。
"""
import torch
def create_dataset(n=100, true_w=8.0, true_b=14.5, seed=0):
"""造 y = true_w * x + true_b + 噪声 的一维数据。"""
torch.manual_seed(seed)
x = torch.randn(n, 1) # (n, 1)
noise = torch.randn(n, 1) * 0.5
y = true_w * x + true_b + noise # (n, 1)
return x, y
def data_iter(x, y, batch_size):
"""手写的 DataLoader:打乱下标,按批切片吐出去。"""
n = len(x)
idx = torch.randperm(n) # 随机排列,等价于 shuffle=True
for start in range(0, n, batch_size):
batch_idx = idx[start:start + batch_size]
yield x[batch_idx], y[batch_idx]
def main():
x, y = create_dataset()
# 参数自己建,自己打开自动微分开关
# nn.Linear 替你做的就是这两行 + forward
w = torch.zeros(1, 1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
lr, epochs, batch_size = 0.05, 60, 16
for epoch in range(epochs):
total, cnt = 0.0, 0
for bx, by in data_iter(x, y, batch_size):
# 1) 前向:nn.Linear 内部就是这一行矩阵乘法加偏置
y_pred = bx @ w + b
# 2) 损失:MSELoss 内部就是这一行
loss = ((y_pred - by) ** 2).mean()
# 3) 清零:optimizer.zero_grad() 替你遍历所有参数做这件事
if w.grad is not None:
w.grad.zero_()
b.grad.zero_()
# 4) 反向:这一步谁也替不了,autograd 的核心
loss.backward()
# 5) 更新:optimizer.step() 内部就是这两行
with torch.no_grad(): # 不让更新动作本身进计算图
w -= lr * w.grad
b -= lr * b.grad
total += loss.item() * len(by)
cnt += len(by)
if (epoch + 1) % 20 == 0:
print('epoch %3d loss %.6f w=%.4f b=%.4f'
% (epoch + 1, total / cnt, w.item(), b.item()))
print('真实值 w=8.0000 b=14.5000')
print('学到值 w=%.4f b=%.4f' % (w.item(), b.item()))
if __name__ == '__main__':
main()
w=8.0、b=14.5),跑完应当能逆推回这两个数。之后的 CNN、RNN、Transformer 再复杂,也只是把这里的 bx @ w + b 换成更长的一串运算,五步主循环一个字都不会变。
05骨架模板:拿去改就能用
一份覆盖回归与分类的训练脚本模板,把 TODO 填掉就能跑
4.2 的线性回归已经把四件套凑齐了,但它把数据、模型、训练搅在一起。下面这份模板把三块拆开,并预留了分类任务的切换点——从空文件开始写训练脚本,比从这份模板改起,平均要多踩一半的坑。
"""训练脚本骨架模板:把 TODO 填掉就是一个能跑的回归/分类训练。
复制这份文件改,比从空文件开始写少踩一半的坑。
"""
import torch
from torch import nn, optim
from torch.utils.data import TensorDataset, DataLoader
# ---------------------------------------------------------------- 数据
def create_dataset():
# TODO: 换成你自己的数据来源(read_csv / numpy / 现成 Dataset)
x = torch.randn(200, 4) # TODO: 特征,形状 (样本数, 特征数)
y = torch.randn(200, 1) # TODO: 标签;分类任务改成 int64 的类别下标
# 特征必须是 float32,分类任务的标签必须是 int64,这两条类型错了直接报 RuntimeError
return TensorDataset(x.float(), y.float())
# ---------------------------------------------------------------- 模型
class MyModel(nn.Module):
def __init__(self, in_features, out_features):
super().__init__()
# TODO: 按任务堆层;层与层之间的维度要接得上
self.linear1 = nn.Linear(in_features, 64)
self.out = nn.Linear(64, out_features)
def forward(self, x):
x = torch.relu(self.linear1(x))
# TODO: 多分类时这里不要加 softmax,交给 CrossEntropyLoss
return self.out(x)
# ---------------------------------------------------------------- 训练
def train(dataset, in_features, out_features,
epochs=50, batch_size=16, lr=1e-3, seed=0):
torch.manual_seed(seed) # 固定种子,结果才可复现
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
model = MyModel(in_features, out_features)
criterion = nn.MSELoss() # TODO: 分类换 nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=lr)
for epoch in range(epochs):
model.train()
total_loss, sample_num = 0.0, 0
for bx, by in dataloader:
y_pred = model(bx)
loss = criterion(y_pred, by)
optimizer.zero_grad() # 顺序不能乱:清零 -> 反向 -> 更新
loss.backward()
optimizer.step()
total_loss += loss.item() * len(by)
sample_num += len(by)
print('epoch %3d loss %.5f' % (epoch + 1, total_loss / sample_num))
torch.save(model.state_dict(), 'model.pt') # 存权重而不是整个模型对象
return model
if __name__ == '__main__':
ds = create_dataset()
train(ds, in_features=4, out_features=1)
改哪几处
| 位置 | 回归任务 | 分类任务要改成 |
|---|---|---|
标签 y 的类型 | float32,形状 (N, 1) | int64 的类别下标,形状 (N,),取值 0 到 类别数−1 |
out_features | 1 | 类别数 |
criterion | nn.MSELoss() | nn.CrossEntropyLoss() |
forward 的最后一层 | 直接返回 | 仍然直接返回,不要加 softmax——CrossEntropyLoss 内部已经含了 |
torch.manual_seed(seed),结果可复现 · ② 每个 epoch 开头 model.train(),让 Dropout / BatchNorm 处于训练模式 · ③ 损失按样本数加权平均而不是按 batch 数,最后一个不满的 batch 才不会把平均值带偏 · ④ 保存用 model.state_dict() 而不是整个 model 对象,换环境不会因为类路径变了而加载失败。
06易错点汇总
按「类型 / 形状 / 自动微分 / 训练循环」四类归并,每条都给现象和修法
⚠️ 一、类型(dtype)
torch.tensor(10, requires_grad=True)直接报错。 现象:RuntimeError: Only Tensors of floating point ... can require gradients。整型不可导。修法:加dtype=torch.float32,或写成torch.tensor(10.)。- 把 sklearn / pandas 的数据直接丢进
nn.Linear。 现象:expected scalar type Float but found Double。NumPy 默认float64,网络权重是float32。修法:建张量时显式dtype=torch.float32,或.float()。 - 分类任务的标签传成了浮点。 现象:
CrossEntropyLoss报expected Long。修法:分类标签必须是int64的类别下标(0、1、2…),不是 one-hot,也不是浮点。 - 对整型张量调
mean()。 现象:报Can only calculate the mean of floating types。修法:先.float()或.double()。
⚠️ 二、形状(shape)
- 大写
Tensor与小写tensor写混。 现象:本想要一个两元素向量,拿到一个 2×3 的随机垃圾矩阵,训练 loss 毫无规律。修法:给数据用小写,给形状用大写,且大写建出来的必须立刻覆盖。 - 预测值
(batch, 1)与标签(batch,)直接算 MSE。 现象:不报错,但广播成(batch, batch),损失数值看着正常、实际完全错误,模型永远学不好。这是最隐蔽的一类 bug。修法:算损失前打印两边.shape确认一致,或统一reshape(-1, 1)。 transpose/permute之后直接view。 现象:view size is not compatible with input tensor's size and stride。修法:先.contiguous(),或干脆改用reshape。- 把单条样本直接喂进网络。 现象:维度少一维导致矩阵乘法失败。修法:
x.unsqueeze(0)补出 batch 维——网络永远按「一批」处理数据。 cat和stack用混。cat在已有维度上接长,stack会多出一个维度。拼完形状不对,多半是选错了这两个。dim参数理解反了。 记住「dim指的是被消掉的那个维度」:(2,3)矩阵mean(dim=0)得到 3 个数,不是 2 个。
⚠️ 三、自动微分
- 对非标量调用
backward()。 现象:grad can be implicitly created only for scalar outputs。修法:先.sum()或.mean()汇总成一个数。真实训练里这件事由损失函数自动完成。 - 更新参数时写成
x = x - lr * x.grad。 现象:第二轮x.grad变成None,抛TypeError。原因是右边这次运算产生了新的非叶子张量。修法:改x.data,或包进with torch.no_grad():,或直接用optimizer.step()。 - 以为
backward()会更新参数。 它只把.grad填上,参数一个都没动。不写step()的训练循环,loss 会一直不降,而且不报任何错——最折磨人的静默失败。 - 去找中间结果的
.grad。 现象:打印出None并附带一句 warning。只有叶子节点(你亲手创建的、无grad_fn的张量)默认保留梯度。确实需要中间梯度时用.retain_grad()。 - 对
requires_grad=True的张量做原地操作。 带下划线的方法(add_、relu_)会覆盖反向传播需要的历史值,现象是one of the variables needed for gradient computation has been modified by an inplace operation。修法:换成非原地版本。 - 把带梯度的张量直接
.numpy()。 现象:Can't call numpy() on Tensor that requires grad。修法:.detach().numpy()。
⚠️ 四、训练循环
- 忘了
zero_grad()。 现象:loss 先下降后突然飙升,最后变nan。因为梯度一轮轮累加,步子越迈越大。修法:把「清零 → 反向 → 更新」当成一个不可拆的三连。 - 三步顺序放错。 清零放在
backward()之后,等于把刚算出来的梯度抹掉,参数永远不变;step()放在backward()之前,用的是上一轮的旧梯度。两种都不报错,只是学不动。 - 优化器没拿到
model.parameters()。 比如写成optim.SGD([w], lr)却漏了b。现象:一部分参数永远是初始值。修法:统一传model.parameters()。 - 累加 loss 时忘了
.item()。 现象:内存/显存持续增长直至 OOM,因为每个 loss 张量都挂着一整张计算图。修法:total_loss += loss.item()。 - 没固定随机种子。 现象:同样的代码两次结果不一样,无法判断某个改动到底有没有效。修法:脚本开头
torch.manual_seed(n),做对比实验时尤其必要。 - 拿 loss 的绝对值当好坏标准。 数据自带噪声时 loss 根本不可能降到 0。修法:看 loss 是否收敛到平台,并用独立指标(本页是
w/b与真值的差距)验证。
把报错现场跑一遍
上面的条目读一遍记不住,自己触发一遍就记住了。下面这份把最常见的五种维度问题全部原地复现,用 try/except 抓住并打印报错原文,跑到底不会中断:
"""形状排错速查:把最常见的五种维度报错原地复现一遍,并给出修法。
跑一次这份文件,以后再看到同样的报错就能直接定位。
每一段都用 try/except 抓住报错并打印,不会中断。
"""
import torch
from torch import nn
def case(title):
print('\n' + '=' * 56)
print(title)
print('=' * 56)
# ---------------------------------------------------------------- 一、少了 batch 维
case('一、单条样本直接喂进网络')
layer = nn.Linear(4, 2)
one_sample = torch.randn(4) # (4,) 少了 batch 维
try:
print(layer(one_sample).shape) # 一维输入其实能跑,但语义上是 batch=1 被隐式处理
except RuntimeError as e:
print('报错:', e)
print('规范写法:', layer(one_sample.unsqueeze(0)).shape) # (1, 2)
# ---------------------------------------------------------------- 二、特征数对不上
case('二、输入特征数与层定义不一致')
wrong_feat = torch.randn(8, 5) # 层要 4 个特征,给了 5 个
try:
layer(wrong_feat)
except RuntimeError as e:
print('报错:', e)
print('修法:要么改层的 in_features,要么检查数据列数是不是多带了 id/标签列')
# ---------------------------------------------------------------- 三、矩阵乘法接不上
case('三、矩阵乘法维度接不上')
m1 = torch.randn(3, 2)
m2 = torch.randn(3, 2)
try:
m1 @ m2
except RuntimeError as e:
print('报错:', e)
print('修法:', (m1 @ m2.T).shape, '—— (3,2)@(2,3) 才接得上')
# ---------------------------------------------------------------- 四、view 遇到非连续内存
case('四、transpose 之后直接 view')
t = torch.arange(6).reshape(2, 3)
swapped = t.transpose(0, 1)
print('是否内存连续:', swapped.is_contiguous())
try:
swapped.view(2, 3)
except RuntimeError as e:
print('报错:', e)
print('修法一 contiguous:', swapped.contiguous().view(2, 3).shape)
print('修法二 直接 reshape:', swapped.reshape(2, 3).shape)
# ---------------------------------------------------------------- 五、不报错但算错:广播
case('五、最危险的一种:不报错,但算错')
y_pred = torch.randn(8, 1)
y_true = torch.randn(8)
loss_wrong = nn.MSELoss()(y_pred, y_true)
loss_right = nn.MSELoss()(y_pred, y_true.reshape(-1, 1))
print('两边形状:', y_pred.shape, y_true.shape)
print('广播后的差值形状:', (y_pred - y_true).shape, '<- 本该是 (8, 1)')
print('错的 loss %.6f 对的 loss %.6f' % (loss_wrong.item(), loss_right.item()))
print('修法:算损失前 assert 两边形状相同')
(8,1) 和 (8,) 广播成 (8,8),算出 64 个差值的均值当损失。这个数字看起来完全正常,模型却永远学不好。养成「算损失前先 assert 两边形状」的习惯。
07自测题
点击题目展开答案;这 10 题都能说清楚,这一页就通了
torch.Tensor(2, 3) 和 torch.tensor([2, 3]) 分别得到什么?
前者是给形状:开一块 2 行 3 列的内存,里面是未初始化的脏数据,dtype 为 float32。后者是给数据:得到一个含两个元素的一维张量,因为数据是整数,dtype 为 int64。用大写建出来的张量必须立刻覆盖,否则等于拿内存垃圾训练。
一批 32 张 32×32 的彩色图片,在 PyTorch 里的 shape 是什么?每个维度代表什么?
(32, 3, 32, 32),依次是批量大小 × 通道数 × 高 × 宽。注意通道在前——这是 PyTorch 的约定,很多图像库给出的是通道在后的 (N, H, W, C),需要用 permute 换过来。
对一个 shape 为 (2, 3) 的张量调用 mean(dim=0),结果有几个数?
3 个。dim 指的是被压扁消掉的维度:dim=0 把 2 行合成 1 行,每列留一个均值,所以结果形状是 (3,)。反之 dim=1 得到 2 个数。
t.numpy() 之后修改 NumPy 数组,原张量会变吗?怎么断开?
会变,两者默认共享同一块内存。断开的方法是 t.numpy().copy(),或反向转换时用 torch.tensor(arr)(会复制)而不是 torch.from_numpy(arr)(共享)。
requires_grad=True 是加在输入数据上还是模型参数上?为什么?
加在模型参数上。训练要调整的是 w 和 b,不是训练数据本身,所以输入张量的开关保持 False。用 nn.Linear 这类层时,参数的开关已经自动打开了,不用手动设。
y = 2 * x ** 2,x = 10,y.backward() 之后 x.grad 是多少?手算验证一遍。
40。手算:y' = 4x,代入 x = 10 得 40。能对上手算结果,才算真的理解自动微分在算什么,而不是把它当黑箱。
为什么 backward() 只能对标量调用?
梯度回答的是「y 变化一点点时,各参数各要负多少责任」,而 y 必须先是一个能比大小的单一数值,这个问题才成立。向量没有唯一的「变大变小」方向。所以损失函数总要把一批样本的误差汇总成一个标量;手动做实验时先 .sum() 或 .mean()。
手写梯度下降时,为什么必须写 x.data = x.data - lr * x.grad,不能写 x = x - lr * x.grad?
后者等号右边是一次被计算图追踪的运算,产生的新张量不再是叶子节点,下一轮它的 .grad 就是 None,程序崩溃。操作 .data(或包在 with torch.no_grad(): 里)才是只改数值、不碰计算图的正确做法。真实项目中 optimizer.step() 内部就是这么实现的。
写出训练循环的五个步骤,并说明哪一步真正改变了参数。
① 前向 y_pred = model(x) → ② 算损失 loss = criterion(y_pred, y) → ③ 梯度清零 optimizer.zero_grad() → ④ 反向 loss.backward() → ⑤ 更新 optimizer.step()。只有第 ⑤ 步改变参数;第 ④ 步只是把每个参数的 .grad 填上。
忘记 zero_grad() 会出现什么现象?为什么 PyTorch 不默认帮你清零?
现象是 loss 先降后暴涨、最终变 nan,因为 .grad 一轮轮累加,步子越迈越大。不默认清零是因为梯度累加本身是有用的功能:显存装不下大 batch 时,可以连续跑几个小 batch 累加梯度再更新一次,等效于大 batch。既然累加有用,就必须由你显式声明「这一轮重新开始」。
训练时 loss 从 120 降到 20 就不动了,是训练失败了吗?
不一定。数据自带噪声时,loss 的下限就是噪声水平,降到平台不等于没学好。判断标准应该是独立的:回归任务看学出来的系数与真值的差距,分类任务看验证集准确率。拿 loss 的绝对值当好坏标准,是新手最常见的误判。
词术语表
| 术语 | 含义 |
|---|---|
| Tensor(张量) | 多维数组,深度学习里唯一的数据结构;比 NumPy 数组多出自动微分、GPU 加速与网络层衔接三样能力 |
| shape | 各维度的长度;第 0 维通常是 batch size |
| dtype | 元素类型;特征与权重用 float32,分类标签用 int64 |
| batch size | 一次送进网络的样本条数 |
| requires_grad | 自动微分总开关,只能对浮点张量打开 |
| 计算图 | 前向过程中自动记录的运算轨迹,节点是张量、边是运算 |
| grad_fn | 张量携带的「我是被哪种运算算出来的」标记,反向时靠它逐段回溯 |
| 叶子节点 | 由你亲手创建、没有 grad_fn 的张量,也就是模型参数;只有它默认保留 .grad |
| backward() | 沿计算图反向应用链式法则,把梯度写进各叶子节点的 .grad;不改参数 |
| 梯度 gradient | 损失对某个参数的偏导数,含义是「这个参数该为当前误差负多少责任」 |
| 链式法则 | 复合函数求导法则,把整条链上各段导数连乘起来 |
| 梯度下降 | 参数沿梯度反方向移动一小步:w ← w − lr × ∂loss/∂w |
| 学习率 lr | 每步迈多大;太大震荡发散,太小收敛缓慢 |
| DataLoader | 把 Dataset 按 batch 成批取出并可打乱的迭代器 |
| optimizer | 拿着 model.parameters()、负责执行参数更新的对象 |