【深度学习|Day02】PyTorch 深度学习笔记(下):张量运算与自动微分

文章目录

  • [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),整数类型调用会报错。其他统计函数(summinmax)对整型和浮点型都兼容。

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 可以完全替代 transposetranspose(0,1)permute(1,0,...) ),但当只需要交换两个维度时 transpose 更简洁。

维度交换是否改变数据顺序? 这是一个重要细节:

  • transposepermute 返回的是原张量的视图(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() 只负责计算梯度,不负责更新参数!.gradbackward() 计算完成后回填到张量上的梯度值
  • 梯度下降的参数更新步骤需要你手动写代码 执行,或者使用 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()
  1. 对谁调用:对损失值(一个标量张量)调用。

  2. 做了什么 :从损失值出发,沿计算图逆向遍历 ,利用链式法则自动计算损失对每个 requires_grad=True 的叶子张量的偏导数,并将结果累加 到各张量的 .grad 属性中。

  3. 为什么 .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 转为标量

实践中:损失函数的设计本身就是标量输出(交叉熵损失 → 一个数,均方误差 → 一个数),所以这个问题在实际训练中不常出现。

  1. 梯度累加与清零
    .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()

python 复制代码
with 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(构建新图),然后才能沿图反向求导。顺序是刚性的:前向 → 清零 → 反向 → 更新。


以上为个人学习总结,旨在梳理个人理解。如有疏漏或不当之处,欢迎指正与交流。如果文章对你有帮助,别忘了点个赞、留个言,让更多的小伙伴看到~ 我们下篇再见!

相关推荐
IT古董2 小时前
【MES学习笔记系列】05 - MES 数据库设计
笔记·学习·mes
不瘦80斤不改名3 小时前
05-vibe-coding-向agentic-engineering演进
人工智能·笔记·python·prompt
九硕智慧建筑一体化厂家4 小时前
直流照明|无尘风淋室照明,高均匀无频闪,适配洁净车间高频合规工况
大数据·人工智能·笔记·智慧城市
LATASA4 小时前
【 从0到1构建 Agent Harness学习笔记】
网络·笔记·学习
jay神4 小时前
基于深度学习的车辆识别收费管理系统(全套源码+数据集)
人工智能·深度学习·yolo·计算机视觉·分类
FakeOccupational4 小时前
【电路笔记 仿真】SPICE仿真器软件 LTspice:绘制简单电路(新建图页+添加组件)并仿真+模型导入+SUBCKT (子电路)绘制与使用+特殊器件绘制
笔记
过期的秋刀鱼!5 小时前
重点-偏差方差与神经网络
人工智能·深度学习·神经网络·算法·机器学习·过拟合·l2正则化
哦哦~9215 小时前
AI赋能复合材料力学:从数据驱动到物理信息神经网络与多尺度仿真
人工智能·深度学习·神经网络·复合材料力学
腾渊信息科技公司5 小时前
工业机器视觉深度学习落地:标注、训练与产线部署全流程避坑思路
人工智能·深度学习