文章目录
- [PyTorch 深度学习笔记(下):张量运算与自动微分](#PyTorch 深度学习笔记(下):张量运算与自动微分)
- [5. 张量的计算](#5. 张量的计算)
-
- [5.1 基本算术运算](#5.1 基本算术运算)
- [5.2 矩阵乘法](#5.2 矩阵乘法)
- [6. 张量的运算函数](#6. 张量的运算函数)
-
- [6.1 统计函数](#6.1 统计函数)
- [6.2 逐元素数学函数](#6.2 逐元素数学函数)
- [7. 张量的索引操作](#7. 张量的索引操作)
-
- [7.1 基本索引规则](#7.1 基本索引规则)
- [7.2 范围索引](#7.2 范围索引)
- [7.3 列表索引](#7.3 列表索引)
- [7.4 布尔索引](#7.4 布尔索引)
- [8. 张量的形状操作](#8. 张量的形状操作)
-
- [8.1 获取形状信息](#8.1 获取形状信息)
- [8.2 reshape():改变形状](#8.2 reshape():改变形状)
- [8.3 squeeze() 与 unsqueeze():升降维](#8.3 squeeze() 与 unsqueeze():升降维)
- [8.4 transpose() 与 permute():维度交换](#8.4 transpose() 与 permute():维度交换)
- [8.5 view() :视图](#8.5 view() :视图)
- [8.6 contiguous():连续性](#8.6 contiguous():连续性)
- [9. 张量的拼接操作](#9. 张量的拼接操作)
-
- [9.1 torch.cat():不升维拼接](#9.1 torch.cat():不升维拼接)
- [9.2 torch.stack():升维拼接](#9.2 torch.stack():升维拼接)
- [9.3 cat vs stack 对比](#9.3 cat vs stack 对比)
- [10. 自动微分模块(torch.autograd)](#10. 自动微分模块(torch.autograd))
-
- [10.1 先搞清楚:自动微分在整个训练流程中的位置](#10.1 先搞清楚:自动微分在整个训练流程中的位置)
- [10.2 requires_grad:告诉 PyTorch 追踪这个张量](#10.2 requires_grad:告诉 PyTorch 追踪这个张量)
- [10.3 backward():触发反向传播,计算梯度](#10.3 backward():触发反向传播,计算梯度)
- [10.4 手动梯度更新](#10.4 手动梯度更新)
- [10.5 完整示例](#10.5 完整示例)
PyTorch 深度学习笔记(下):张量运算与自动微分
紧接上篇,本文聚焦张量的计算与操作(算术运算、索引切片、形状变换、拼接拆分),并深入剖析 PyTorch 最核心的自动微分模块,从计算图原理、backward 机制到手动梯度下降的完整实现。建议先读完上篇再继续哦~
5. 张量的计算
5.1 基本算术运算
张量支持 Python 原生的算术运算符,同时也有对应的函数版本。
python
a = torch.tensor([1.0, 2.0, 3.0])
b = torch.tensor([4.0, 5.0, 6.0])
# 四种等价写法
c = a + b # 运算符
c = torch.add(a, b) # 函数式,返回新张量
a.add(b) # 方法式,返回新张量
a.add_(b) # 原地修改,a 本身被改变(注意末尾下划线)
类似的:
| 运算符 | 函数(不修改原值) | in-place 函数(修改原值) | 含义 |
|---|---|---|---|
a + b |
torch.add(a, b) |
a.add_(b) |
加法 |
a - b |
torch.sub(a, b) |
a.sub_(b) |
减法 |
a * b |
torch.mul(a, b) |
a.mul_(b) |
逐元素乘法 |
a / b |
torch.div(a, b) |
a.div_(b) |
逐元素除法 |
-a |
torch.neg(a) |
a.neg_() |
取负 |
规则 :+、-、*、/ 都是逐元素运算,两个张量形状相同时直接运算;若形状不同但兼容,会触发广播机制(broadcasting):从最后一个维度向前比较,维度大小要么相等,要么其中一个是 1。
python
a = torch.ones(3, 4) # (3, 4)
b = torch.tensor([1, 2, 3, 4]) # (4,) → 自动广播为 (3, 4)
c = a + b # 每行都加了 [1, 2, 3, 4]
# 维度不兼容的例子
# a = torch.ones(3, 4)
# b = torch.tensor([1, 2, 3]) # (3,),与 (3,4) 的最后一维 4 ≠ 3 → 报错
原地操作 :PyTorch 中以下划线 _ 结尾的方法都是 in-place 操作,都会原地修改张量(如 add_、mul_、zero_),省内存但会覆盖原始值。
5.2 矩阵乘法
PyTorch 中有三种乘法需要严格区分:
| 操作 | 符号 | 数学含义 | 维度要求 |
|---|---|---|---|
| 逐元素乘(Hadamard 积) | * / torch.mul() |
对应位置元素相乘 | 两矩阵形状完全相同,或满足广播规则 |
| 矩阵乘法 | @ / torch.matmul() |
线性代数中的矩阵乘法 | 最后两维满足 (m,n) × (n,p) |
| 批量矩阵乘 | torch.bmm() |
批量版的矩阵乘,对 batch 内每个矩阵对分别做乘法 | 两个 3D 张量 (b,n,m) × (b,m,p) |
python
# `*`(逐元素乘)
a = torch.tensor([[1, 2], [3, 4]]) # (2, 2)
b = torch.tensor([[5, 6], [7, 8]]) # (2, 2)
c = a * b
# tensor([[ 5, 12],
# [21, 32]]) ← 1×5, 2×6, 3×7, 4×8
python
# `@` 和 `torch.matmul()`(矩阵乘)
a = torch.tensor([[1, 2, 3], # (2, 3)
[4, 5, 6]])
b = torch.tensor([[7, 8], # (3, 2)
[9, 10],
[11, 12]])
# 以下两种写法完全等价
c1 = a @ b # 运算符写法,推荐
c2 = torch.matmul(a, b) # 函数写法
# 结果都是 (2, 2):
# tensor([[ 58, 64],
# [139, 154]])
# 检查: 第一行 × 第一列 = 1×7 + 2×9 + 3×11 = 7 + 18 + 33 = 58 ✓
补充:@ 和 torch.matmul() 在高维(≥3 维)时自动按批量矩阵乘处理,即最后两个维度做矩阵乘法,前面的维度视为 batch 维度。这是推荐的做法。
python
# `torch.matmul()` 的多维适配
# 批量矩阵乘:形状 (batch, m, n) @ (batch, n, p) → (batch, m, p)
a = torch.randn(10, 3, 4) # 10 个 3×4 矩阵
b = torch.randn(10, 4, 5) # 10 个 4×5 矩阵
c = a @ b # 10 个 3×5 矩阵,形状 (10, 3, 5)
6. 张量的运算函数
张量提供了一系列内建的统计和数学函数,可以通过 tensor.method() 或 torch.function(tensor) 两种形式调用。
6.1 统计函数
mean() / sum() / min() / max()
这些统计函数的核心参数是 dim (也写作 axis),它控制沿哪个维度进行归约计算。
python
data = torch.randint(0, 10, [2, 3], dtype=torch.float64)
print(data)
# tensor([[4., 7., 2.],
# [1., 9., 5.]], dtype=torch.float64)
python
# 不指定 dim:对所有元素操作,返回标量
print(data.mean()) # 所有6个元素的均值: tensor(4.6667)
print(data.sum()) # 总和: tensor(28.)
# dim=0:沿行方向(第0维)归约 → 对每一列单独计算
print(data.mean(dim=0)) # tensor([2.5, 8.0, 3.5]) ← 每列的均值
print(data.sum(dim=0)) # tensor([ 5., 16., 7.]) ← 每列总和
# dim=1:沿列方向(第1维)归约 → 对每一行单独计算
print(data.mean(dim=1)) # tensor([4.3333, 5.0000]) ← 每行的均值
print(data.sum(dim=1)) # tensor([13., 15.]) ← 每行总和
注意 :
mean()要求张量是浮点类型 (float32 或 float64),整数类型调用会报错。其他统计函数(sum、min、max)对整型和浮点型都兼容。
min() / max() 的特殊之处 :调用 .min(dim=0) 时返回一个元组 (values, indices):
values:最小值本身indices:最小值在对应维度上的索引位置
python
data = torch.tensor([[4., 7., 2.], [1., 9., 5.]])
result = data.min(dim=0)
print(result.values) # tensor([1., 7., 2.])
print(result.indices) # tensor([1, 0, 0]) ← 每列最小值在第几行
6.2 逐元素数学函数
这些函数对张量中的每个元素独立进行数学运算,不改变张量形状。
python
data = torch.tensor([1.0, 4.0, 9.0])
| 运算 | 对象调用 | 函数调用 | 说明 |
|---|---|---|---|
| 求平方 | data.pow(2) |
torch.pow(data, 2) |
等价于 data ** 2,对每个元素单独求平方 |
| 开平方 | data.sqrt() |
torch.sqrt(data) |
等价于 data ** 0.5 |
| 指数(eⁿ) | data.exp() |
torch.exp(data) |
计算 e 的 n 次方 |
| 对数(logₑ) | data.log() |
torch.log(data) |
以 e 为底的自然对数 |
| 对数(log₂) | data.log2() |
torch.log2(data) |
以 2 为底的对数 |
| 对数(log₁₀) | data.log10() |
torch.log10(data) |
以 10 为底的对数 |
python
print(torch.pow(data, 2)) # tensor([ 1., 16., 81.]) ← 每个元素平方
print(data.sqrt()) # tensor([1.000, 2.000, 3.000])
print(data.exp()) # tensor([2.7183, 54.5981, 8103.0839]) ← e^1, e^4, e^9
print(data.log()) # tensor([0.000, 1.3863, 2.1972]) ← ln(1), ln(4), ln(9)
print(data.log2()) # tensor([0.000, 2.000, 3.1699])
print(data.log10()) # tensor([0.000, 0.6021, 0.9542])
7. 张量的索引操作
张量索引沿用了 NumPy 的语法体系,从基本的单元素索引到复杂的布尔索引都支持。
7.1 基本索引规则
核心语法 :tensor[行索引, 列索引],逗号左边控制行(第 0 维),右边控制列(第 1 维)。
python
data = torch.tensor([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
| 索引写法 | 含义 | 结果 |
|---|---|---|
data[0] |
第 0 行(全部列) | tensor([1, 2, 3]) |
data[:, 0] |
第 0 列(全部行) | tensor([1, 4, 7]) |
data[0, 1] |
第 0 行第 1 列 | tensor(2) |
data[0:2, 1:] |
行 0~1,列 1~末尾 | tensor([[2, 3],[5, 6]]) |
扩展到多维:原则不变,逗号分隔每个维度,: 代表取该维全部。
python
t3d = torch.randn(2, 3, 4) # 形状 (2, 3, 4)
t3d[0, :, :] # 第一个"矩阵": 形状(3,4)
t3d[:, 1, 2] # 所有 batch,第1行第2列: 形状(2,)
7.2 范围索引
完全遵循 Python 切片语法 start:stop:step:
python
data = torch.arange(12).reshape(3, 4)
# tensor([[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]])
data[0:2, :] # 前两行,全部列 → (2, 4)
data[:, 1:3] # 全部行,第 1~2 列 → (3, 2)
data[::2, ::2] # 每隔一行取一行,每隔一列取一列
7.3 列表索引
列表索引:传入行索引列表和列索引列表,取指定位置的元素。
python
data[[0, 2], [0, 2]] # 取 (0,0) 和 (2,2) 两个元素: tensor([1, 9])
# 注意:这不是取子矩阵!是取 (0,0)、(2,2) 两个点
7.4 布尔索引
根据条件筛选元素,这是数据清洗和掩码操作中最常用的技巧。
核心规则 :布尔条件放在逗号的哪一边 ,就对哪个维度进行筛选。条件本身是一个和该维度大小相同的布尔张量。
python
data = torch.tensor([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
# 条件在行(逗号左边):筛选行
# 挑选第 1 列(索引1)中值大于 5 的那些行
condition = data[:, 1] > 5 # tensor([False, False, True])
print(data[condition, :]) # tensor([[7, 8, 9]]) ← 只有第2行满足
# 条件在列(逗号右边):筛选列
# 挑选第 2 行(索引2)中值大于 5 的那些列
condition = data[2, :] > 5
print(data[:, condition]) # tensor([[3],[6],[9]]) 都>5 → 全三列
更具体的例子来说明区别:
python
data = torch.tensor([[1, 8, 3],
[4, 5, 6],
[7, 2, 9]])
# 需求:取出所有"第 0 行数值大于 3"的列
cond = data[0, :] > 3 # tensor([False, True, False]) ← 对第0行 [1,8,3] 判断
print(data[:, cond]) # 取第2列(索引1),输出:
# tensor([[8],
# [5],
# [2]])
# 需求:取出所有"第 1 列数值大于 3"的行
cond = data[:, 1] > 3 # tensor([True, True, False]) ← 对第1列 [8,5,2] 判断
print(data[cond, :]) # 取第0行和第1行,输出:
# tensor([[1, 8, 3],
# [4, 5, 6]])
记忆方法:条件作用在哪个维度,就把条件表达式放在逗号的对应侧:
- 按行筛选 → 条件在逗号左边
data[条件, :] - 按列筛选 → 条件在逗号右边
data[:, 条件]
8. 张量的形状操作
8.1 获取形状信息
python
data = torch.randn(2, 3, 4)
print(data.shape) # torch.Size([2, 3, 4])
print(data.size()) # torch.Size([2, 3, 4]) ← 效果相同
print(data.size(0)) # 2 ← 获取第 0 维的大小
print(data.shape[0]) # 同上
print(data.size(1)) # 3
print(data.ndim) # 3 ← 维度数,等价于 len(data.shape)
print(data.numel()) # 24 ← 元素总数 = 2×3×4
shape 是属性访问,size() 是方法调用。日常使用中 shape 更常见,需要获取单维度大小时用 size(dim)。
8.2 reshape():改变形状
python
data = torch.arange(12) # tensor([0, 1, ..., 11]) 形状 (12,)
reshaped = data.reshape(3, 4) # 形状变为 (3, 4)
# tensor([[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]])
原理:不改变数据在内存中的存储顺序,只是重新解释各维度的大小关系。可以理解为把原来一长串连续的数据按新的维度规格"折叠"起来。等价于"在不改变数据排列的前提下,重新分配每个维度的大小"。
python
# -1 的用法:让 PyTorch 自动推算该维度的大小
data.reshape(3, -1) # 自动算出 -1 = 4
data.reshape(-1, 6) # 自动算出 -1 = 2
data.reshape(-1) # 展平为一维
8.3 squeeze() 与 unsqueeze():升降维
squeeze(dim):降维
删除形状中大小为 1 的维度。只能删除大小为 1 的维度,如果该维度大小不是 1,squeeze 不做任何事(不报错,也不改变形状)。
python
data = torch.randn(1, 3, 1, 4) # 形状 (1, 3, 1, 4)
# 不指定 dim:删除所有大小为 1 的维度
print(data.squeeze().shape) # torch.Size([3, 4])
# 指定 dim:只删除指定位置大小为 1 的维度
print(data.squeeze(0).shape) # torch.Size([3, 1, 4]) ← 只删了第 0 维
print(data.squeeze(2).shape) # torch.Size([1, 3, 4]) ← 只删了第 2 维
unsqueeze(dim):升维
在指定位置插入一个大小为 1 的新维度。
python
data = torch.tensor([1, 2, 3]) # 形状 (3,)
# 常见用法:为单样本添加 batch 维度
batch_data = data.unsqueeze(0) # 在第 0 位插入 → 形状 (1, 3)
feat_data = data.unsqueeze(1) # 在第 1 位插入 → 形状 (3, 1)
升维的"空位"规则 :对于一个 n 维张量(维度索引 0 到 n-1),unsqueeze 可以在 0 到 n 这 n+1 个位置中任意选择插入。比如 3 维张量,可以插在位置 0、1、2 或 3。
升维/降维是否改变数据顺序? 不改变。这两个操作只在形状层面增加或删除大小为 1 的维度,底层数据的排列顺序完全不变。
8.4 transpose() 与 permute():维度交换
transpose(dim0, dim1):交换两个维度
注意!一次只能交换两个维度。
python
data = torch.randn(2, 3, 4) # 形状 (2, 3, 4)
t = data.transpose(0, 2) # 交换第 0 和第 2 维 → 形状 (4, 3, 2)
t = data.transpose(1, 2) # 交换第 1 和第 2 维 → 形状 (2, 4, 3)
permute(*dims):重排所有维度
一次可以重新排列任意多个维度。
python
data = torch.randn(2, 3, 4) # 形状 (batch=2, rows=3, cols=4)
# 将形状从 (2, 3, 4) 变为 (3, 4, 2):新的第0维=原第1维,新第1维=原第2维,新第2维=原第0维
p = data.permute(1, 2, 0) # 形状 (3, 4, 2)
permute 可以完全替代 transpose(transpose(0,1) ≡ permute(1,0,...) ),但当只需要交换两个维度时 transpose 更简洁。
维度交换是否改变数据顺序? 这是一个重要细节:
-
transpose和permute返回的是原张量的视图(view),即共享底层数据,只改变了对维度的"索引方式" -
交换维度后,张量在内存中可能变得不连续(non-contiguous) ,这意味着
data[i][j][k]的访问方式不再对应内存中的顺序排列,但不重排物理内存,会导致 is_contiguous() == False。原始 (2,3) 的内存顺序: [a,b,c,d,e,f] → 按行存储
transpose → (3,2): 逻辑上是 [[a,d],[b,e],[c,f]]
但内存中仍然是 [a,b,c,d,e,f] --- 数据没变,只是"读法"变了

8.5 view() :视图
view():共享内存的形状变换
view() 在功能上和 reshape() 类似(改变形状),但有一个重要的限制 :它要求张量在内存中是连续的(contiguous)。
python
data = torch.randn(2, 3, 4)
# 直接用 view 没问题(原始张量是连续的)
v = data.view(2, 12) # ✓
# 但如果先 transpose,再 view 就会报错!
t = data.transpose(1, 2) # t 形状 (2, 4, 3),但内存不连续
# v = t.view(2, 12) # RuntimeError! view 要求连续内存
| 对比维度 | reshape() |
view() |
|---|---|---|
| 对连续性的要求 | 无要求(不连续时会自动拷贝) | 必须连续,否则报错 |
| 是否共享内存 | 可能共享(连续时)或拷贝(不连续时) | 始终共享底层数据 |
| 性能 | 不连续时有拷贝开销 | 无拷贝,更快 |
| 推荐程度 | 更通用,推荐日常使用 | 确定连续性时使用 |
实践建议 :不确定是否连续时直接用 reshape()(更安全);确定连续时用 view()(更快,明确表达"我不希望拷贝"的意图)。当 view() 报错时,加一个 .contiguous() 再 .view(),或者直接改用 .reshape()。
8.6 contiguous():连续性
什么是 contiguous(内存连续)?
张量在底层是按一维数组存储的(行优先,row-major)。"连续"意味着张量的逻辑维度和物理存储顺序一致。
形状 (2, 3) 的张量:
逻辑视图: 内存存储:
[[a, b, c], → [a, b, c, d, e, f] ← 连续
[d, e, f]]
transpose(0, 1) 后:
逻辑视图: 内存存储(没变):
[[a, d], → [a, b, c, d, e, f] ← 不连续!
[b, e], 按行读 [a,d] 需要跳过 b,c
[c, f]]
contiguous():强制变为连续
python
t = data.transpose(1, 2) # 不连续
t_cont = t.contiguous() # 创建连续副本(拷贝数据,重新排列内存)
v = t_cont.view(2, -1) # ✓ 现在可以 view 了
is_contiguous():检查连续性
python
data = torch.randn(2, 3)
print(data.is_contiguous()) # True
t = data.transpose(0, 1)
print(t.is_contiguous()) # False
9. 张量的拼接操作
拼接操作分为两大类:不升维拼接 (在已有维度上追加数据)和升维拼接(增加新维度后组合)。
9.1 torch.cat():不升维拼接
在已有维度上连接多个张量,最终张量的维度数不变。
python
torch.cat(tensors, dim=0)
| 参数 | 含义 |
|---|---|
tensors |
要拼接的张量序列(列表或元组) |
dim |
沿哪个维度拼接 |
约束 :所有输入张量除了 dim 指定的维度外,其他所有维度的大小必须完全相同。
python
a = torch.tensor([[1, 2, 3], # (2, 3)
[4, 5, 6]])
b = torch.tensor([[7, 8, 9], # (2, 3)
[10,11,12]])
# dim=0:沿行方向拼接 → 增加行数
c0 = torch.cat([a, b], dim=0) # (4, 3)
# tensor([[ 1, 2, 3],
# [ 4, 5, 6],
# [ 7, 8, 9],
# [10, 11, 12]])
# dim=1:沿列方向拼接 → 增加列数
c1 = torch.cat([a, b], dim=1) # (2, 6)
# tensor([[ 1, 2, 3, 7, 8, 9],
# [ 4, 5, 6, 10, 11, 12]])
类比:想象一个 Excel 表格:
-
dim=0(行方向拼接):在表格下方追加新的数据行------要求列数相同 -
dim=1(列方向拼接):在表格右侧追加新的数据列------要求行数相同原始 dim=0 拼接 dim=1 拼接
┌───┬───┬───┐ ┌───┬───┬───┐ ┌───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ │ 1 │ 2 │ 3 │ │ 1 │ 2 │ 3 │ 7 │ 8 │ 9 │
├───┼───┼───┤ ├───┼───┼───┤ ├───┼───┼───┼───┼───┼───┤
│ 4 │ 5 │ 6 │ │ 4 │ 5 │ 6 │ │ 4 │ 5 │ 6 │10 │11 │12 │
└───┴───┴───┘ ├───┼───┼───┤ └───┴───┴───┴───┴───┴───┘
│ 7 │ 8 │ 9 │
├───┼───┼───┤
│10 │11 │12 │
└───┴───┴───┘
9.2 torch.stack():升维拼接
增加一个新维度,将多个形状完全相同的张量沿这个新维度堆叠起来。
python
torch.stack(tensors, dim=0)
约束 :所有输入张量的全部维度大小必须完全相同。
python
a = torch.tensor([[1, 2], [3, 4]]) # (2, 2)
b = torch.tensor([[5, 6], [7, 8]]) # (2, 2)
# dim=0:在第 0 位插入新维度,沿它堆叠
s0 = torch.stack([a, b], dim=0) # (2, 2, 2)
# tensor([[[1, 2],
# [3, 4]],
#
# [[5, 6],
# [7, 8]]])
# dim=1:在第 1 位插入新维度
s1 = torch.stack([a, b], dim=1) # (2, 2, 2)
# tensor([[[1, 2],
# [5, 6]],
#
# [[3, 4],
# [7, 8]]])
类比 :两个形状相同的 Excel 表,stack 就像把这两张表叠放在一起放进一个文件夹,原本是两张 2D 表,叠放后变成一个 3D 的"表叠"。
9.3 cat vs stack 对比
| 对比维度 | torch.cat() |
torch.stack() |
|---|---|---|
| 维度变化 | 不增维(在已有维度上延长) | 增维(新增一个维度来堆叠) |
| 形状要求 | 除拼接维度外全部相同 | 全部维度完全相同 |
| 类比 | 在现有表格上加行/加列 | 把多张表格叠成一叠 |
| 结果维度数 | 与输入相同 | 输入维度数 + 1 |
| 典型场景 | 拼接数据集样本、拼接特征 | 合并多个张量成一个 batch |
python
# 验证维度变化
a = torch.randn(3, 4) # 2D
b = torch.randn(3, 4) # 2D
print(torch.cat([a, b], dim=0).shape) # torch.Size([6, 4]) ← 还是 2D
print(torch.stack([a, b], dim=0).shape) # torch.Size([2, 3, 4]) ← 变成 3D
10. 自动微分模块(torch.autograd)
这是 PyTorch 最核心的模块,也是深度学习框架区别于普通数值计算库的本质特征。

10.1 先搞清楚:自动微分在整个训练流程中的位置
在讨论具体 API 之前,必须先把前向传播、反向传播、参数更新这三个阶段的责任边界搞清楚。
一次完整的参数更新 = 前向传播 + 反向传播 + 参数更新
┌─────────────────────────────────────────────────────────────────┐
│ 前向传播(Forward) │
│ 输入 → 权重W·X + 偏置b → 激活函数 → ... → 预测值 ŷ │
│ → 计算损失 L = loss(ŷ, y_true) │
│ 责任方:你自己写的模型代码 │
├─────────────────────────────────────────────────────────────────┤
│ 反向传播(Backward) │
│ 对 L 调用 .backward() → autograd 沿计算图自动计算 ∂L/∂W, ∂L/∂b │
│ 责任方:torch.autograd(自动) │
├─────────────────────────────────────────────────────────────────┤
│ 参数更新(Update) │
│ W = W - lr × W.grad (梯度下降公式) │
│ 责任方:你自己手动写,或交给 optimizer.step() │
└─────────────────────────────────────────────────────────────────┘
关键认知:
backward()只负责计算梯度,不负责更新参数! 。.grad是backward()计算完成后回填到张量上的梯度值- 梯度下降的参数更新步骤需要你手动写代码 执行,或者使用
torch.optim中的优化器
sklearn 和 PyTorch 的训练过程在数学上完全一致,那区别是什么?
sklearn 把整个过程封装成了一行 .fit(),你看不到也改不了内部细节;PyTorch 把每一步都拆开、暴露给你,让你可以随心所欲地定制、观察和调试。而 autograd 的存在,使得"自己手动写训练循环"这件事从不可能变成了可能。
10.2 requires_grad:告诉 PyTorch 追踪这个张量
python
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
# 之后对 x 的所有运算都会被记录到计算图中
y = x * 2 # 记录:y 是 x 乘以 2 得到
z = y.mean() # 记录:z 是 y 求均值得到
requires_grad=True 是张量级别的属性,它的作用是告诉 autograd:"请追踪这个张量参与的所有运算,我要对这些运算做反向传播。"
训练时哪些张量需要 requires_grad=True?
- 模型参数(权重 W、偏置 b):必须 设为 True(
nn.Parameter默认就是 True) - 输入数据:通常不需要(设为 False,节省内存)
- 中间结果:由运算自动决定(只要参与运算的任意输入 requires_grad=True,输出也会是 True)
10.3 backward():触发反向传播,计算梯度
python
loss.backward()
-
对谁调用:对损失值(一个标量张量)调用。
-
做了什么 :从损失值出发,沿计算图逆向遍历 ,利用链式法则自动计算损失对每个
requires_grad=True的叶子张量的偏导数,并将结果累加 到各张量的.grad属性中。 -
为什么
.backward()只能对标量调用 ?从数学上讲,梯度是标量函数对向量/矩阵的导数。如果损失值是标量(一个数),对参数求导得到的是明确的一个梯度向量/矩阵。若确实需要对非标量调用,需传入 gradient 参数指定各分量的权重。
python
# 标量 → 直接 backward
loss = torch.tensor(5.0, requires_grad=True)
loss.backward() # ✓
# 非标量 → 需要传 gradient 参数,或者先转为标量
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2 # y = [1, 4, 9],非标量
# y.backward() # ❌ RuntimeError
y.sum().backward() # ✓ 通过 sum 转为标量
实践中:损失函数的设计本身就是标量输出(交叉熵损失 → 一个数,均方误差 → 一个数),所以这个问题在实际训练中不常出现。
- 梯度累加与清零
.backward()计算出的梯度是累加(add)到.grad上的,而不是覆盖! 这是设计行为,目的是支持从多个路径计算同一参数的梯度。
python
for i in range(1000):
y = x ** 2 + 20 # 前向传播
y.sum().backward() # 反向传播:梯度累加到 x.grad
# 此时 x.grad 包含当前 + 之前所有迭代的梯度和!← 这不对
因此每次迭代必须手动清零:
python
for i in range(1000):
y = x ** 2 + 20
# 梯度清零:将 x.grad 归零(如果 x.grad 不存在则跳过)
if x.grad is not None:
x.grad.zero_() # zero_() 是 in-place 操作,将张量所有元素置 0
y.sum().backward()
# 现在 x.grad 只包含当前轮次的梯度
在第一次调用 backward() 之前,没有梯度被计算过,所以 grad 属性是 None(不是全零张量)。所以清零前需要做 None 检查。
10.4 手动梯度更新
python
x.data = x.data - 0.01 * x.grad
这行代码完成了参数更新(梯度下降的一步)。拆解来看:
| 子表达式 | 含义 |
|---|---|
x.grad |
backward() 计算出的损失对 x 的梯度 ∂L/∂x |
0.01 * x.grad |
学习率 × 梯度 = 这一步要调整的量 |
x.data - 0.01 * x.grad |
新的参数值 = 旧值 - 调整量 |
x.data = ... |
将新值写回 x 的底层数据存储,不经过 autograd 追踪 |
为什么用 x.data = ... 而不是直接用 x = ...?
python
# 错误写法
x = x - 0.01 * x.grad
# 正确写法
x.data = x.data - 0.01 * x.grad
# x 还是同一个对象,只是内部数据被更新
# 不经过 autograd,不会在计算图中添加新节点
data是比较早期的用法,现代更推荐torch.no_grad():
pythonwith torch.no_grad(): x -= 0.01 * x.grad
torch.no_grad()上下文管理器会临时禁用梯度追踪,比直接操作.data更安全(不会被遗漏的引用"钻空子")。
10.5 完整示例
以 y = x² + 20 为例,求 y 取极小值时 x 的值(正确答案是 x=0)。
python
import torch
# === 1. 定义可训练参数 ===
x = torch.tensor(10.0, requires_grad=True, dtype=torch.float32)
# x 初始值为 10,同时开启梯度追踪
# === 2. 定义学习率 ===
lr = 0.01
# === 3. 迭代优化 ===
for i in range(1, 1001):
# 3-1 前向传播:根据当前 x 计算 y
y = x ** 2 + 20
# 3-2 梯度清零:PyTorch 的 grad 默认会累加,每次迭代需手动清零
if x.grad is not None:
x.grad.zero_()
# 3-3 反向传播:计算 dy/dx,结果存入 x.grad
y.backward()
# 此时 x.grad = 2x(因为 y = x^2 + 20 的导数是 2x)
# 3-4 参数更新:手动实现梯度下降
x.data = x.data - lr * x.grad
# 注意:必须使用 x.data 而非 x,否则赋值操作会被记录到计算图中
if i % 100 == 0:
print(f'迭代 {i}: x = {x.item():.4f}, y = {y.item():.4f}')
print(f'最终结果: x = {x.item():.4f}, y 最小值约 {y.item():.4f}')
输出大致为:
开始: x=10.000000
第100轮: x=1.326008, grad=2.652016, y=21.758295
第200轮: x=0.175697, grad=0.351394, y=20.030870
第300轮: x=0.023282, grad=0.046564, y=20.000542
...
第1000轮: x≈0.000000, y最小值≈20.000000
为什么每次循环要重新计算 y?
因为 x 被更新了,新的 y 基于新的 x,必须重新前向传播。
因为 backward() 反向传播是根据当前计算图来求导。你必须先用最新的参数算出一个新的 loss(构建新图),然后才能沿图反向求导。顺序是刚性的:前向 → 清零 → 反向 → 更新。
以上为个人学习总结,旨在梳理个人理解。如有疏漏或不当之处,欢迎指正与交流。如果文章对你有帮助,别忘了点个赞、留个言,让更多的小伙伴看到~ 我们下篇再见!