张量与自动微分:深度学习的计算地基

深度学习里没有别的数据结构,只有张量;也没有别的学习方式,只有「前向留轨迹、反向求梯度、顺着梯度改一点点」。这一页把这两件事拆到能自己手写出来。

30″30 秒看懂张量与自动微分

把深度学习想成一条集装箱码头的流水线:所有货物都必须先装进标准集装箱才能上传送带,而整条流水线每天都在做同一件事——称一次重量,发现偏差,把每台机器的旋钮拧一点点

集装箱就是张量(Tensor):箱子的长宽高是它的 shape,箱子里装的是钢材还是棉花是它的 dtype。一个数字是 0 维的小箱子,一排数字是 1 维的长条箱,一张表是 2 维的箱垛,一批彩色图片就是 4 维的箱山。深度学习框架不认识「图片」「句子」「价格」,它只认识箱子的形状对不对得上。

图① 30 秒看懂:张量就是不同维度的集装箱
图① 30 秒看懂:张量就是不同维度的集装箱

自动微分(autograd)是流水线上的行车记录仪:货物每经过一台机器,记录仪就把「谁加工的、怎么加工的」记一笔。等到终点称出偏差,记录仪倒着播一遍录像,就能算出每台机器该为这次偏差负多少责任——这个「责任值」就是梯度

比喻里的角色对应的技术概念它到底是什么
标准集装箱torch.Tensor深度学习里唯一的数据结构,模型的输入、输出、参数全是它
箱子的长宽高shape每个维度的长度;形状对不上就报错,这是新手 80% 的报错来源
箱里装的货dtypefloat32 用于特征和权重,int64 用于分类标签,混用会直接抛异常
行车记录仪的开关requires_grad=True打开才记录轨迹;关着的张量参与运算但不会被求梯度
这一段录像grad_fn记录这个张量是由哪种运算产生的,反向时靠它找上一环
倒放录像算责任loss.backward()沿轨迹反向求导,把每个参数的梯度写进它的 .grad
拧旋钮optimizer.step()按梯度反方向改参数;backward 本身不改任何参数
⛔ 整页只有一条铁律 反向传播只负责算梯度,不负责改参数。 backward() 做的事是把 .grad 填上,参数纹丝不动;真正把参数改掉的是紧随其后的那一步更新。而且 .grad 默认累加不清零——所以每轮必须先清零再反向,顺序是「前向 → 算损失 → 清零 → 反向 → 更新」。这条链贯穿后面所有页。

01概念:张量是什么,为什么非它不可

定义、和 NumPy 数组的分界线,以及深度学习与传统机器学习的差别

1.1 什么是张量

张量(Tensor)是一个多维数组。 这句话听着平淡,关键在「多维」这两个字上——维度的数量(ndim)决定了它能承载什么样的数据:

维度俗称shape 举例真实场景里装的是什么
0 维标量 scalartorch.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 也是多维数组,两者甚至能共享内存互转。张量多出来的是三样东西,每一样都决定了它能不能用来训练模型:

01自动微分

张量能记住自己是怎么被算出来的,从而支持 backward() 一步求出所有参数的梯度。NumPy 要自己手推导数、手写链式法则,网络一深就不现实。

02GPU 加速

tensor.to('cuda') 一行就把数据搬到显卡上,矩阵乘法并行度提升几十倍。NumPy 只跑 CPU。

03与网络层无缝衔接

nn.Linearnn.Conv2d 这些层只吃张量,且对 dtypeshape 有硬性要求,中间夹一层 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),否则每次跑结果不同,实验没法复现,也没法判断改动到底有没有用
大写 Tensor 与小写 tensor 差一个字,行为完全不同 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 运算与形状变换

张量运算分两大类,混淆这两类是矩阵维度报错的头号原因:

1逐元素运算

add / sub / mul / div,以及 a * b。要求两个张量形状相同(或能广播),对应位置各算各的,结果形状不变。mul 是点乘(Hadamard 积),不是矩阵乘法

2矩阵乘法

a @ btorch.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 和 reshape 的分界 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)

tensor_ops.py —— 运算、索引、变形、拼接的可跑清单
"""张量的运算、索引、变形与拼接:把数据摆成网络要的形状。"""
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 的开关是关着的。 我们要调的是模型参数 wb,不是训练数据本身。所以 xrequires_gradFalse,它只是沿着传送带走过去。
  • 中间结果会带上 grad_fn 打印 y = 2 * x ** 2 会看到 grad_fn=<MulBackward0>,意思是「我是被一次乘法算出来的」。这就是那一小段录像,反向时靠它找到上一环。
  • 梯度最终落在叶子节点的 .grad 里。 叶子节点就是你亲手创建、没有 grad_fn 的那些张量,也就是模型参数。中间结果的 .grad 默认不保留。

backward() 做的就是链式法则:从终点的 loss 开始,一层层把偏导数乘回去。以图中这条链为例,∂loss/∂w = (∂loss/∂z) × (∂z/∂w)——沿路把各段导数连乘起来,就得到 w 该负的责任。网络有一百层,就连乘一百段;这也埋下了后面几页要处理的梯度消失伏笔:每段都小于 1 的话,连乘一百次基本归零。

链式法则不是比喻,是可以逐数字验证的。下面这段把一个三层复合函数的导数先手推一遍、再让 PyTorch 算一遍,顺便把「梯度累加」和「中间结果没有梯度」两件事当场演示出来:

chain_rule_demo.py —— 手推与自动微分对账
"""链式法则实证:三层复合函数,手算一遍再让 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,全程一个警告都没有。这就是下一节要把「清零」单独列成一步的原因。

⛔ backward 只对标量成立 y.backward() 要求 y一个数。这不是限制,而是定义决定的:梯度回答的是「y 变化一点点,各个参数各要负多少责任」,y 得先是个能比大小的单一数值。所以损失函数永远要把一批样本的误差汇总成一个标量。向量张量要求梯度,先 .sum().mean()

2.4 梯度下降:顺着坡往下挪

有了梯度,「学习」就只剩一个动作:参数沿梯度的反方向挪一小步

① 前向用当前参数算出预测值
② 算损失预测和真值差多少,汇总成一个标量
③ 梯度清零.grad 默认累加,不清就串味
④ 反向backward() 填满每个参数的 .grad
⑤ 更新参数 = 参数 − 学习率 × 梯度
下一轮回到 ①
图③ 训练循环五步:前向 → 算损失 → 清零 → 反向 → 更新
图③ 训练循环五步:前向 → 算损失 → 清零 → 反向 → 更新

更新公式写出来只有一行:w ← w − lr × ∂loss/∂wlr(学习率)就是每步迈多大。梯度是正的说明「往大了调会让损失更大」,所以减掉它;梯度是负的就等于加上去。两种情况都由这一个减号统一处理。

为什么第三步非得单列出来 PyTorch 的 .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
broadcast_demo.py —— 广播规则与它挖的坑
"""广播(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() 自动降级
device_demo.py —— 设备切换与跨设备报错
"""把张量搬到 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 装对了。

tensor_basics.py —— 张量创建、类型与互转最小可跑
"""张量的创建、类型与形状:最小可跑例子。"""
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

autograd_min.py —— 自动微分最小例子最小可跑
"""自动微分最小例子:一次前向、一次反向、读一次梯度。"""
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 = 1040,正好是打印出来的 tensor(40.)能对上手算结果,才算真的信了自动微分,而不是把它当黑箱。

开关打开的前提:必须是浮点类型 torch.tensor(10, requires_grad=True) 会报错,因为整型不可导。必须写成 dtype=torch.float32,或者直接写 torch.tensor(10.) 带上小数点。这个报错新手每人都会遇到一次。

3.3 数据怎么成批进网络

训练脚本的第一段永远是把数据包成 Dataset、再交给 DataLoader。两者分工很清楚:Dataset 回答「一共多少条、第 i 条是什么」,DataLoader 回答「怎么成批、要不要打乱」

dataset_dataloader.py —— TensorDataset、自定义 Dataset 与 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 为什么不是可选项:数据若按标签排好序,不打乱时整批只有一个类别,这一步算出来的梯度会把模型拽向「全猜这一类」,下一批又拽向另一类,训练全程来回震荡。

环境要求 Python 3.10 以上,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 = 0x = 0,此时 y = 20。我们从 x = 10 出发,看梯度下降能不能自己走到 0。

gradient_descent.py —— 不用 optimizer 的纯手写梯度下降完整案例
"""用自动微分手写梯度下降,求 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 轮,打印大致是这样的走势(随机性为零,结果可复现):

轮次xy发生了什么
起点10.000000120.000000离最优解很远,梯度 2x = 20 很大
第 1 轮9.800000116.040001挪了 0.01 × 20 = 0.2
第 200 轮0.17587920.030933已经很接近,梯度变小,步子自动变小
第 1000 轮≈ 0.00000020.000000收敛到理论最优解

注意越接近最低点、每步挪得越少——这不是代码里写了衰减,而是梯度本身在变小(2xx 趋零而趋零)。这是梯度下降自带的性质,也是它能稳定停住而不是在最低点来回横跳的原因。

⚠️ 更新参数为什么必须写 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 的四件套上场了:

1数据 Dataset + DataLoader

TensorDataset 负责「按下标取一条」,DataLoader 负责「成批、打乱、迭代」。shuffle=True 不是可选项——数据若按标签排好序,不打乱会让每个 batch 内部样本高度同质,梯度方向失真。

2模型 nn.Module

nn.Linear(1, 1) 内部就是 y = w·x + bwb 已经自动带上 requires_grad=True,不用你手动开。

3损失函数

nn.MSELoss() 把一批样本的平方误差平均成一个标量——正好满足 backward() 要标量的要求。

4优化器

optim.SGD(model.parameters(), lr)必须把 model.parameters() 交给它,否则它不知道该更新谁,step() 调了也等于没调。

linear_regression.py —— 线性回归完整训练脚本完整案例
"""线性回归完整案例:第一次把 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、训练得到的 wb、首轮与末轮的平均损失。判断成功与否,看的不是 loss 降没降,而是学出来的 w 和真实 coef 差多少、b 和 14.5 差多少。噪声 noise=10 决定了 loss 不可能降到 0——数据里本来就有这么多随机成分,损失降到噪声水平就到头了。拿 loss 绝对值当好坏标准是新手最常见的误判

4.3 把四件套拆掉:裸手版线性回归

上一份代码里,nn.LinearMSELossSGDDataLoader 各替你做了一件事,但具体做了什么被封装藏起来了。把它们全拆掉、每一行都自己写,对照着看就清楚了:

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 是真正的内核
linear_regression_manual.py —— 不用任何封装的线性回归对照版
"""线性回归的"裸手"版:不用 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.0b=14.5),跑完应当能逆推回这两个数。之后的 CNN、RNN、Transformer 再复杂,也只是把这里的 bx @ w + b 换成更长的一串运算,五步主循环一个字都不会变

05骨架模板:拿去改就能用

一份覆盖回归与分类的训练脚本模板,把 TODO 填掉就能跑

4.2 的线性回归已经把四件套凑齐了,但它把数据、模型、训练搅在一起。下面这份模板把三块拆开,并预留了分类任务的切换点——从空文件开始写训练脚本,比从这份模板改起,平均要多踩一半的坑

train_skeleton.py —— 通用训练骨架,只改 TODO 处可复用模板
"""训练脚本骨架模板:把 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_features1类别数
criterionnn.MSELoss()nn.CrossEntropyLoss()
forward 的最后一层直接返回仍然直接返回,不要加 softmax——CrossEntropyLoss 内部已经含了
✅ 模板里替你固化的四个习惯 开头 torch.manual_seed(seed),结果可复现 · 每个 epoch 开头 model.train(),让 Dropout / BatchNorm 处于训练模式 · 损失按样本数加权平均而不是按 batch 数,最后一个不满的 batch 才不会把平均值带偏 · 保存用 model.state_dict() 而不是整个 model 对象,换环境不会因为类路径变了而加载失败。
模板没替你做的事 没有验证集、没有早停、没有学习率调度、没有 GPU 搬运。这些不是遗漏,是因为它们各自有取舍,需要按任务选——学习率与正则化会在《优化器、学习率与正则化》那一页单独展开。先用这份最朴素的版本把数据跑通,再逐样加。

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()
  • 分类任务的标签传成了浮点。 现象:CrossEntropyLossexpected 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 维——网络永远按「一批」处理数据。
  • catstack 用混。 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 抓住并打印报错原文,跑到底不会中断:

shape_debug.py —— 五种形状报错的原地复现与修法排错速查
"""形状排错速查:把最常见的五种维度报错原地复现一遍,并给出修法。

跑一次这份文件,以后再看到同样的报错就能直接定位。
每一段都用 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 列的内存,里面是未初始化的脏数据,dtypefloat32。后者是给数据:得到一个含两个元素的一维张量,因为数据是整数,dtypeint64。用大写建出来的张量必须立刻覆盖,否则等于拿内存垃圾训练。

一批 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 是加在输入数据上还是模型参数上?为什么?

加在模型参数上。训练要调整的是 wb,不是训练数据本身,所以输入张量的开关保持 False。用 nn.Linear 这类层时,参数的开关已经自动打开了,不用手动设。

y = 2 * x ** 2x = 10y.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()、负责执行参数更新的对象
✅ 一句话收束本页 张量把所有数据统一成形状明确的箱子,自动微分把「谁该为误差负责」自动算出来——前向留轨迹,反向沿轨迹倒推,再顺着梯度挪一小步。后面的神经网络、CNN、RNN、Transformer,换的只是箱子怎么流动、中间插了哪些运算,这条主循环一个字都不会变。