YK人工智能(三)——万字长文学会torch深度学习
2.1 张量
本节主要内容:
- 张量的简介
- PyTorch如何创建张量
- PyTorch中张量的操作
- PyTorch中张量的广播机制
2.1.1 简介
几何代数中定义的张量是基于向量和矩阵的推广,比如我们可以将标量视为零阶张量,矢量可以视为一阶张量,矩阵就是二阶张量。
张量维度 | 代表含义 |
---|---|
0维张量 | 代表的是标量(数字) |
1维张量 | 代表的是向量 |
2维张量 | 代表的是矩阵 |
3维张量 | 时间序列数据 股价 |
张量是现代机器学习的基础。它的核心是一个数据容器,多数情况下,它包含数字,有时候它也包含字符串,但这种情况比较少。因此可以把它想象成一个数字的水桶。
这里有一些存储在各种类型张量的公用数据集类型:
- 3维 = 时间序列
- 4维 = 图像
- 5维 = 视频
例子:一个图像可以用三个字段表示,分别是:
- width: 图像的宽度,即水平方向上的像素数量
- height: 图像的高度,即垂直方向上的像素数量
- channel: 图像的颜色通道数,如RGB彩色图像有3个通道,灰度图像有1个通道
(width, height, channel) = 3D
但是,在机器学习工作中,我们经常要处理不止一张图片或一篇文档——我们要处理一个集合。我们可能有10,000张郁金香的图片,这意味着,我们将用到4D张量:
(batch_size, width, height, channel) = 4D
在PyTorch中, torch.Tensor
是存储和变换数据的主要工具。如果你之前用过NumPy
,你会发现 Tensor
和NumPy的多维数组非常类似。然而,Tensor
提供GPU计算和自动求梯度等更多功能,这些使 Tensor
这一数据类型更加适合深度学习。
2.1.2 创建tensor
在接下来的内容中,我们将介绍几种常见的创建tensor
的方法。
- 随机初始化矩阵
我们可以通过torch.rand()
的方法,构造一个随机初始化的矩阵:
import torch
x = torch.rand(4, 3, 1)
print(x, type(x), x.shape, x.size())
tensor([[[0.9630],
[0.2057],
[0.2067]],
[[0.5101],
[0.8320],
[0.9128]],
[[0.1335],
[0.9004],
[0.9082]],
[[0.1949],
[0.2616],
[0.3007]]]) <class 'torch.Tensor'> torch.Size([4, 3, 1]) torch.Size([4, 3, 1])
- 全0矩阵的构建
我们可以通过torch.zeros()
构造一个矩阵全为 0,并且通过dtype
设置数据类型为 long。除此以外,我们还可以通过torch.zero_()和torch.zeros_like()将现有矩阵转换为全0矩阵.
import torch
x = torch.zeros(4, 3, dtype=torch.long)
print(x)
tensor([[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]])
# 使用 torch.zero_() 将现有矩阵转换为全0矩阵
y = torch.rand(2, 3)
print("\n原始 y:")
print(y)
y.zero_()
print("使用 torch.zero_() 后的 y:")
print(y)
原始 y:
tensor([[0.8797, 0.4270, 0.7012],
[0.5926, 0.1490, 0.6743]])
使用 torch.zero_() 后的 y:
tensor([[0., 0., 0.],
[0., 0., 0.]])
# 使用 torch.zeros_like() 创建与现有矩阵形状相同的全0矩阵
z = torch.rand(3, 2)
print("\n原始 z:")
print(z)
z_zeros = torch.zeros_like(z)
print("torch.zeros_like(z):")
print(z_zeros)
原始 z:
tensor([[0.5537, 0.3520],
[0.3345, 0.1989],
[0.8854, 0.5777]])
torch.zeros_like(z):
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
- 张量的构建
我们可以通过torch.tensor()
直接使用数据,构造一个张量:
import torch
x = torch.tensor([5.5, 3])
print(x)
tensor([5.5000, 3.0000])
import torch
import numpy as np
# 1. 使用Python列表(一维或多维)
x1 = torch.tensor([1, 2, 3, 4])
x2 = torch.tensor([[1, 2], [3, 4]])
print("从Python列表创建:")
print(x1)
print(x2)
从Python列表创建:
tensor([1, 2, 3, 4])
tensor([[1, 2],
[3, 4]])
# 2. 使用NumPy数组
np_array = np.array([1, 2, 3, 4])
x3 = torch.tensor(np_array)
print("\n从NumPy数组创建:")
print(x3)
从NumPy数组创建:
tensor([1, 2, 3, 4])
# 3. 使用Python标量
x4 = torch.tensor(3.14)
x5 = torch.tensor(True)
print("\n从Python标量创建:")
print(x4)
print(x5)
从Python标量创建:
tensor(3.1400)
tensor(True)
# 4. 使用其他PyTorch张量
existing_tensor = torch.randn(2, 3)
x6 = torch.tensor(existing_tensor)
print("\n从现有张量创建:")
print(x6)
从现有张量创建:
tensor([[ 1.3995, 0.8028, -1.1152],
[ 1.6206, 0.7856, 0.1517]])
/var/folders/z7/ll9p_xgn76l2f7pqtx3c44jr0000gn/T/ipykernel_36214/1463659560.py:3: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).
x6 = torch.tensor(existing_tensor)
# 5. 使用Python元组
x7 = torch.tensor((1, 2, 3))
print("\n从Python元组创建:")
print(x7)
从Python元组创建:
tensor([1, 2, 3])
# 6. 使用range对象
x8 = torch.tensor(range(5))
print("\n从range对象创建:")
print(x8)
从range对象创建:
tensor([0, 1, 2, 3, 4])
返回的torch.Size其实是一个tuple,⽀持所有tuple的操作。我们可以使用索引操作取得张量的长、宽等数据维度。
- 常见的构造Tensor的方法:
函数 | 功能 |
---|---|
Tensor(sizes) | 基础构造函数 |
tensor(data) | 类似于np.array |
ones(sizes) | 全1 |
zeros(sizes) | 全0 |
eye(sizes) | 对角为1,其余为0 |
arange(s,e,step) | 从s到e,步长为step |
linspace(s,e,steps) | 从s到e,均匀分成step份 |
rand/randn(sizes) | rand是[0,1)均匀分布;randn是服从N(0,1)的正态分布 |
normal(mean,std) | 正态分布(均值为mean,标准差是std) |
randperm(m) | 随机排列 |
2.1.3 张量的操作
在接下来的内容中,我们将介绍几种常见的张量的操作方法:
- 加法操作:
import torch
# 创建两个张量
x = torch.tensor([[1, 2], [3, 4]])
y = torch.tensor([[5, 6], [7, 8]])
# 1. 使用 + 运算符
result_1 = x + y
print("使用 + 运算符:")
print(result_1)
使用 + 运算符:
tensor([[ 6, 8],
[10, 12]])
# 2. 使用 torch.add() 函数
result_2 = torch.add(x, y)
print("\n使用 torch.add() 函数:")
print(result_2)
使用 torch.add() 函数:
tensor([[ 6, 8],
[10, 12]])
# 3. 使用 add_() 方法进行原地操作
x_copy = x.clone() # 创建 x 的副本,以免修改原始 x
x_copy.add_(y)
print("\n使用 add_() 方法进行原地操作:")
print(x_copy)
使用 add_() 方法进行原地操作:
tensor([[ 6, 8],
[10, 12]])
# 4. 使用 torch.add() 函数并指定输出张量
result_4 = torch.empty_like(x)
torch.add(x, y, out=result_4)
print("\n使用 torch.add() 函数并指定输出张量:")
print(result_4)
使用 torch.add() 函数并指定输出张量:
tensor([[ 6, 8],
[10, 12]])
print(x)
tensor([[1, 2],
[3, 4]])
# 5. 加上一个标量
scalar = 10
result_5 = x + scalar
print("\n加上一个标量:")
print(result_5)
加上一个标量:
tensor([[11, 12],
[13, 14]])
# 6. 使用广播机制进行加法
z = torch.tensor([1, 2])
result_6 = x + z
print("\n使用广播机制进行加法:")
print(result_6)
使用广播机制进行加法:
tensor([[2, 4],
[4, 6]])
- 索引操作:(类似于numpy)
需要注意的是:索引出来的结果与原数据共享内存,修改一个,另一个会跟着修改。如果不想修改,可以考虑使用copy()等方法
import torch
x = torch.rand(4,3)
print(x)
# 取第二列
print(x[:, 1])
tensor([[0.6240, 0.1236, 0.6454],
[0.2799, 0.1227, 0.4354],
[0.6472, 0.8142, 0.0389],
[0.7155, 0.9703, 0.3107]])
tensor([0.1236, 0.1227, 0.8142, 0.9703])
y = x[0,:]
print(y)
y += 1
print(y)
print(x[0, :]) # 因为索引操作返回的是对原tensor的引用(视图),而不是副本,所以修改索引结果会影响原tensor
tensor([0.6240, 0.1236, 0.6454])
tensor([1.6240, 1.1236, 1.6454])
tensor([1.6240, 1.1236, 1.6454])
- 维度变换
张量的维度变换常见的方法有torch.view()
和torch.reshape()
,下面我们将介绍第一中方法torch.view()
:
区分维度和长度的区别
维度:张量的维度,比如4维,5维
长度:张量中元素的个数,比如4个元素,5个元素
x = torch.randn(4, 4)
e = torch.tensor(4.0)
y = x.view(16)
z = x.view(-1, 8) # -1是指这一维的维数由其他维度决定
print(x)
print(y)
print(z)
print(x.size(), y.size(), z.size(), e.size())
tensor([[-0.0676, 0.3851, 2.1078, -2.2629],
[ 0.9745, 0.1784, 0.5629, -0.6183],
[-0.1645, 0.6379, 1.3582, 2.5104],
[-0.1031, -0.1028, -1.0995, 0.2379]])
tensor([-0.0676, 0.3851, 2.1078, -2.2629, 0.9745, 0.1784, 0.5629, -0.6183,
-0.1645, 0.6379, 1.3582, 2.5104, -0.1031, -0.1028, -1.0995, 0.2379])
tensor([[-0.0676, 0.3851, 2.1078, -2.2629, 0.9745, 0.1784, 0.5629, -0.6183],
[-0.1645, 0.6379, 1.3582, 2.5104, -0.1031, -0.1028, -1.0995, 0.2379]])
torch.Size([4, 4]) torch.Size([16]) torch.Size([2, 8]) torch.Size([])
注: torch.view()
返回的新tensor
与源tensor
共享内存(其实是同一个tensor
),更改其中的一个,另外一个也会跟着改变。(顾名思义,view()仅仅是改变了对这个张量的观察角度)
x += 1
print(x)
print(y) # 也加了了1
tensor([[ 0.9324, 1.3851, 3.1078, -1.2629],
[ 1.9745, 1.1784, 1.5629, 0.3817],
[ 0.8355, 1.6379, 2.3582, 3.5104],
[ 0.8969, 0.8972, -0.0995, 1.2379]])
tensor([ 0.9324, 1.3851, 3.1078, -1.2629, 1.9745, 1.1784, 1.5629, 0.3817,
0.8355, 1.6379, 2.3582, 3.5104, 0.8969, 0.8972, -0.0995, 1.2379])
上面我们说过torch.view()会改变原始张量,但是很多情况下,我们希望原始张量和变换后的张量互相不影响。为了使创建的张量和原始张量不共享内存,我们需要使用第二种方法torch.reshape()
,同样可以改变张量的形状。下面是一个例子:
import torch
original_tensor = torch.randn(4, 4) # 创建一个4x4的张量
reshaped_tensor = original_tensor.reshape(16) # 使用reshape改变形状
print("Original Tensor:\n", original_tensor)
print("Reshaped Tensor:\n", reshaped_tensor)
Original Tensor:
tensor([[ 0.0997, 2.1006, 1.3564, -2.2575],
[ 0.5212, 0.7903, 0.5076, -0.9435],
[-1.0901, -0.5410, -0.7492, 0.3113],
[ 1.8470, 0.4270, -1.5640, 0.3467]])
Reshaped Tensor:
tensor([ 0.0997, 2.1006, 1.3564, -2.2575, 0.5212, 0.7903, 0.5076, -0.9435,
-1.0901, -0.5410, -0.7492, 0.3113, 1.8470, 0.4270, -1.5640, 0.3467])
但是需要注意的是,torch.reshape()
并不能保证返回的是其拷贝值,所以官方不推荐使用。推荐的方法是我们先用 clone()
创造一个张量副本,然后再使用 torch.view()
进行维度变换。下面是一个例子:
cloned_tensor = original_tensor.clone() # 创建原始张量的副本
viewed_tensor = cloned_tensor.view(16) # 使用view改变形状
print("Cloned Tensor:\n", cloned_tensor)
print("Viewed Tensor:\n", viewed_tensor)
# 这样,`cloned_tensor`和`viewed_tensor`就不会互相影响。
Cloned Tensor:
tensor([[-0.3983, -0.3365, -1.9220, 0.1157],
[ 0.7252, 0.7562, -0.0743, 0.5322],
[-0.5094, -0.3373, -1.3409, 0.5753],
[ 0.0301, 1.6293, 0.7553, -0.7787]])
Viewed Tensor:
tensor([-0.3983, -0.3365, -1.9220, 0.1157, 0.7252, 0.7562, -0.0743, 0.5322,
-0.5094, -0.3373, -1.3409, 0.5753, 0.0301, 1.6293, 0.7553, -0.7787])
- 取值操作
如果我们有一个元素tensor
,我们可以使用.item()
来获得这个value
,而不获得其他性质:
# 下面的代码演示了如何创建一个随机数张量,并获取其类型和单个值的类型。
import torch
# 创建一个包含随机数的张量x,形状为(1,)
x = torch.randn(1)
# 打印张量x的值
print(x)
# 打印张量x的类型,应该是torch.Tensor
print(type(x))
# 使用.item()方法获取张量x中的单个值,并打印其类型,应该是float
print(type(x.item()))
print(x.item())
tensor([0.9657])
<class 'torch.Tensor'>
<class 'float'>
0.9656938910484314
#获取所有值作为列表
import torch
x = torch.randn(2)
print(x)
print(type(x))
print(x.tolist()) # 获取所有值作为列表
print(type(x.tolist()))
tensor([0.1090, 0.1836])
<class 'torch.Tensor'>
[0.10899410396814346, 0.18355011940002441]
<class 'list'>
PyTorch中的 Tensor 支持超过一百种操作,包括转置、索引、切片、数学运算、线性代数、随机数等等,具体使用方法可参考官方文档。
2.1.4 广播机制
当对两个形状不同的 Tensor 按元素运算时,可能会触发广播(broadcasting)机制:先适当复制元素使这两个 Tensor 形状相同后再按元素运算。
numpy广播机制
x = torch.arange(1, 3).view(1, 2)
print(x)
y = torch.arange(1, 4).view(3, 1)
print(y)
print(x + y)
tensor([[1, 2]])
tensor([[1],
[2],
[3]])
tensor([[2, 3],
[3, 4],
[4, 5]])
由于x和y分别是1行2列和3行1列的矩阵,如果要计算x+y,那么x中第一行的2个元素被广播 (复制)到了第二行和第三行,⽽y中第⼀列的3个元素被广播(复制)到了第二列。如此,就可以对2个3行2列的矩阵按元素相加。
2.2 自动求导
PyTorch 中,所有神经网络的核心是 autograd包。autograd包为张量上的所有操作提供了自动求导机制。它是一个在运行时定义 ( define-by-run )的框架,这意味着反向传播是根据代码如何运行来决定的,并且每次迭代可以是不同的。
经过本节的学习,你将收获:
- autograd的求导机制
- 梯度的反向传播
2.2.1 Autograd简介
让我们通过一个简单的例子来解释 autograd 中梯度的记录机制。
import torch
# 创建叶子节点
x = torch.tensor([2.0], requires_grad=True)
y = torch.tensor([3.0], requires_grad=True)
# 构建计算图
z = x * y # 中间节点
w = z + x # 中间节点
loss = w ** 2 # 最终的loss节点
# 计算梯度
loss.backward()
# 检查各个节点的梯度
print("节点值:")
print(f"x = {x.data}, y = {y.data}")
print(f"z = {z.data}, w = {w.data}")
print(f"loss = {loss.data}")
print("\n各节点对应的梯度:")
print(f"dx = {x.grad}") # ∂loss/∂x
print(f"dy = {y.grad}") # ∂loss/∂y
# 查看中间节点的梯度函数
print("\n中间节点的grad_fn:")
print(f"z.grad_fn: {z.grad_fn}")
print(f"w.grad_fn: {w.grad_fn}")
print(f"loss.grad_fn: {loss.grad_fn}")
# 验证链式法则
# 手动计算梯度进行验证
x_val, y_val = 2.0, 3.0
z_val = x_val * y_val
w_val = z_val + x_val
loss_val = w_val ** 2
# ∂loss/∂w = 2w
# ∂w/∂x = 1 + y
# ∂w/∂y = x
# 因此:
# ∂loss/∂x = ∂loss/∂w * ∂w/∂x = 2w * (1 + y)
# ∂loss/∂y = ∂loss/∂w * ∂w/∂y = 2w * x
manual_dx = 2 * w_val * (1 + y_val)
manual_dy = 2 * w_val * x_val
print("\n手动计算的梯度值:")
print(f"manual dx = {manual_dx}")
print(f"manual dy = {manual_dy}")
节点值:
x = tensor([2.]), y = tensor([3.])
z = tensor([6.]), w = tensor([8.])
loss = tensor([64.])
各节点对应的梯度:
dx = tensor([64.])
dy = tensor([32.])
中间节点的grad_fn:
z.grad_fn: <MulBackward0 object at 0x11819d930>
w.grad_fn: <AddBackward0 object at 0x118116dd0>
loss.grad_fn: <PowBackward0 object at 0x11819d930>
手动计算的梯度值:
manual dx = 64.0
manual dy = 32.0
2.2.2 autograd中的梯度
- autograd中梯度的记录:
- autograd 记录的确实是 loss 对各个节点的偏导数
- 什么是节点?节点的概念 节点
- 具体来说是 ∂loss/∂node,即最终损失对每个节点的偏导数
import torch
# 创建节点 - 叶子节点是需要计算梯度的参数
x = torch.tensor([2.0], requires_grad=True) # 叶子节点
y = torch.tensor([3.0], requires_grad=True) # 叶子节点
# 中间节点通过操作自动创建
z = x * y # 中间节点,建立function节点
w = z + x # 中间节点,建立function节点
loss = w ** 2 # 最终输出
# 执行反向传播
loss.backward()
# 查看各个节点的梯度
print(f"x的梯度: {x.grad}") # ∂loss/∂x
print(f"y的梯度: {y.grad}") # ∂loss/∂y
print(f"z的grad_fn: {z.grad_fn}") # 中间节点存储grad_fn而不是grad
print(f"w的grad_fn: {w.grad_fn}") # 中间节点存储grad_fn而不是grad
x的梯度: tensor([64.])
y的梯度: tensor([32.])
z的grad_fn: <MulBackward0 object at 0x1116cad10>
w的grad_fn: <AddBackward0 object at 0x1116ca920>
-
在 PyTorch 中,autograd.Function 是实现自动微分机制的基础。它允许你自定义前向和反向传播的操作,从而实现自定义的梯度计算。要理解这个概念,首先需要了解几个核心点:
-
Tensor 和 Function 的关系
每个 Tensor 对象都附带了一个计算图。计算图记录了生成该 Tensor 的一系列操作。这些操作通过 Function 节点连接起来。
Function 节点表示操作(例如加法、卷积等)及其输入的 Tensor,在计算图中记录了这些操作的历史。
当你对 Tensor 进行一系列操作(如加法、乘法等)时,PyTorch 会自动为这些操作生成 Function 节点,并将这些节点链接起来,从而构建一个计算图。 -
前向传播和反向传播
前向传播:Function 的 forward 方法定义了如何计算输出 Tensor。当进行计算时,PyTorch 记录所有操作,并保存任何需要用于反向传播的中间结果。
反向传播:Function 的 backward 方法定义了如何计算梯度。反向传播发生时,PyTorch 依赖于这些 Function 节点逐层计算梯度。
- 梯度的记录方式:
- 对于叶子节点(如权重参数),梯度存储在 .grad 属性中
- 对于中间节点,保存的是梯度函数 grad_fn,而不直接存储梯度值
- grad_fn 记录了如何计算该节点的梯度的方法
import torch
# 创建一个简单的神经网络层
layer = torch.nn.Linear(2, 1)
# 创建输入数据
x = torch.tensor([[1.0, 2.0]], requires_grad=True)
# 前向传播
output = layer(x)
# 检查参数和梯度
print("权重参数:")
print(layer.weight)
print("\n权重的grad_fn:")
print(layer.weight.grad_fn) # None,因为是叶子节点
print("\n输出的grad_fn:")
print(output.grad_fn) # 显示计算图的一部分
权重参数:
Parameter containing:
tensor([[-0.4196, 0.4725]], requires_grad=True)
权重的grad_fn:
None
输出的grad_fn:
<AddmmBackward0 object at 0x110ee67a0>
- 计算图的构建:
- 前向传播时,自动构建计算图
- 每个操作都会创建一个新的 grad_fn
- 这些 grad_fn 连接形成反向传播的路径
import torch
def visualize_graph():
x = torch.tensor(2.0, requires_grad=True)
y = torch.tensor(3.0, requires_grad=True)
# 构建计算图
z = x * y
w = z + x
loss = w ** 2
# 打印计算图的结构
print("计算图结构:")
print(f"loss = {loss}")
print(f"grad_fn = {loss.grad_fn}")
print(f"上一步grad_fn = {loss.grad_fn.next_functions[0][0]}")
print(f"再上一步grad_fn = {loss.grad_fn.next_functions[0][0].next_functions[0][0]}")
visualize_graph()
计算图结构:
loss = 64.0
grad_fn = <PowBackward0 object at 0x11819e3e0>
上一步grad_fn = <AddBackward0 object at 0x11819ea40>
再上一步grad_fn = <MulBackward0 object at 0x11819ea40>
- 链式法则的应用:
- 反向传播时,通过链式法则自动计算复合函数的导数
- 例如,对于路径 x → z → w → loss:
- ∂loss/∂x = ∂loss/∂w * ∂w/∂z * ∂z/∂x
import torch
# 创建一个需要应用链式法则的例子
x = torch.tensor(2.0, requires_grad=True)
# 构建复合函数: f(g(h(x)))
h = x ** 2 # h(x) = x²
g = torch.sin(h) # g(h) = sin(h)
f = torch.exp(g) # f(g) = e^g
# 计算梯度
f.backward()
# 手动计算梯度进行验证
with torch.no_grad():
# 计算每一步的导数
dh_dx = 2 * x # ∂h/∂x = 2x
dg_dh = torch.cos(h) # ∂g/∂h = cos(h)
df_dg = torch.exp(g) # ∂f/∂g = e^g
# 应用链式法则
manual_grad = df_dg * dg_dh * dh_dx
print(f"自动计算的梯度: {x.grad}")
print(f"手动计算的梯度: {manual_grad}")
自动计算的梯度: -1.226664662361145
手动计算的梯度: -1.226664662361145
- 梯度累积特点:
- 如果一个节点被多条路径使用,其梯度会被累加
- 例如示例中的 x:既直接参与了 w 的计算,也通过 z 间接参与了计算
import torch
# 重置梯度很重要,否则梯度会累积
def demonstrate_grad_accumulation():
x = torch.tensor(2.0, requires_grad=True)
# 第一次前向传播和反向传播
y1 = x ** 2
z1 = torch.sin(y1)
z1.backward(retain_graph=True) # retain_graph=True 允许多次反向传播
print(f"第一次反向传播后的梯度: {x.grad}")
# 不清零梯度,进行第二次前向传播和反向传播
y2 = x ** 3
z2 = torch.cos(y2)
z2.backward()
print(f"第二次反向传播后的梯度(累积): {x.grad}")
# 重置梯度后重新计算
x.grad.zero_()
y2 = x ** 3
z2 = torch.cos(y2)
z2.backward()
print(f"清零后重新计算的梯度: {x.grad}")
demonstrate_grad_accumulation()
# 展示多路径梯度累积
def multiple_paths():
x = torch.tensor(2.0, requires_grad=True)
# x参与两条计算路径
path1 = x ** 2
path2 = x ** 3
# 两条路径的结果相加
result = path1 + path2
result.backward()
print(f"多路径累积的梯度: {x.grad}")
# 梯度将是 ∂(x² + x³)/∂x = 2x + 3x²
multiple_paths()
第一次反向传播后的梯度: -2.614574432373047
第二次反向传播后的梯度(累积): -14.486873626708984
清零后重新计算的梯度: -11.872299194335938
多路径累积的梯度: 16.0
- 梯度追踪机制
- 当你设置 requires_grad=True 时:
x = torch.tensor([1.0], requires_grad=True)
- PyTorch 会记录这个张量参与的所有计算过程
- 相当于给这个张量打开了"记录模式"
- 停止追踪计算
- 方法一:使用 .detach()
import torch
# 创建一个需要追踪梯度的张量
tensor = torch.tensor([2.0, 3.0], requires_grad=True)
x = tensor.detach() # x不会被追踪计算历史
print(f"原始tensor requires_grad: {tensor.requires_grad}") # True
print(f"detach后的x requires_grad: {x.requires_grad}") # False
原始tensor requires_grad: True
detach后的x requires_grad: False
- 方法二:使用上下文管理器
# 创建一个需要追踪梯度的张量
x = torch.tensor([2.0, 3.0], requires_grad=True)
# 使用torch.no_grad()上下文管理器
with torch.no_grad():
# 在这个上下文中的计算不会被追踪
y = x * 2
z = y ** 2
print(f"x requires_grad: {x.requires_grad}") # True
print(f"y requires_grad: {y.requires_grad}") # False
print(f"z requires_grad: {z.requires_grad}") # False
x requires_grad: True
y requires_grad: False
z requires_grad: False
简单来说:
- Tensor 就像一个会记笔记的计算器
- 开启 requires_grad 就是按下记录键
- 进行计算时自动记录每一步
- 最后用 backward() 回看笔记,算出所有梯度
- 不想记录时可以按停止键(detach 或 no_grad)
这样的设计让 PyTorch 能够:
- 自动处理复杂的梯度计算
- 在需要时可以方便地关闭梯度计算(比如测试模型时)
- 清晰地追踪计算过程,方便调试
2.3 Numpy中的数组广播
让我们深入探讨numpy中一个更高级且强大的概念——广播(Broadcasting)。
广播是一种机制,它描述了numpy在进行算术运算时如何处理形状不同的数组。这个概念可能初看起来有些复杂,但它实际上非常有用且高效。
广播的核心思想是:
- 在某些特定条件下,较小的数组可以被"广播"到较大的数组上,使它们的形状变得兼容。
- 这种机制允许我们对不同形状的数组进行操作,而无需显式地复制数据。
广播的主要优势包括:
- 向量化操作:它提供了一种高效的方法来进行向量化数组操作。这意味着许多循环操作可以在底层的C语言中进行,而不是在Python中,从而大大提高了执行速度。
- 内存效率:广播不需要复制不必要的数据。相反,它通过巧妙的内存访问和计算来实现操作,这通常会导致非常高效的算法实现。
- 代码简洁:广播可以让我们用更少的代码完成复杂的操作,使代码更加简洁和易读。
然而,广播也并非在所有情况下都是最佳选择:
- 在某些情况下,广播可能会导致内存使用效率低下。例如,如果广播操作导致创建了一个非常大的临时数组,这可能会显著增加内存使用并降低计算速度。
- 对于非常大的数组或复杂的操作,有时显式循环可能更高效。
本文将通过一系列由浅入深的示例来逐步介绍广播的概念和应用。我们将从最简单的情况开始,逐渐过渡到更复杂的场景,帮助你全面理解广播的工作原理。
此外,我们还将提供一些实用的建议,帮助你判断何时应该使用广播,以及在哪些情况下可能需要考虑其他替代方法。通过这些指导,你将能够更好地在效率和代码可读性之间做出权衡,选择最适合你特定需求的方法。
numpy操作通常是逐元素进行的,这要求两个数组具有完全相同的形状:
示例1¶
>>> from numpy import array
>>> a = array([1.0, 2.0, 3.0])
>>> b = array([2.0, 2.0, 2.0])
>>> a * b
array([ 2., 4., 6.])
当数组的形状满足某些约束时,numpy的广播规则放宽了这个限制。最简单的广播示例发生在数组和标量值在操作中结合时:
示例2¶
>>> from numpy import array
>>> a = array([1.0,2.0,3.0])
>>> b = 2.0
>>> a * b
array([ 2., 4., 6.])
结果等同于前面的示例,其中b
是一个数组。我们可以认为标量b
在算术运算过程中被拉伸成一个与a
形状相同的数组。如图1所示,b
中的新元素只是原始标量的副本。拉伸的类比只是概念上的。numpy足够聪明,可以使用原始标量值而不实际制作副本,因此广播操作在内存和计算效率上都是最优的。因为示例2在乘法过程中移动的内存更少(b
是标量,而不是数组),所以在Windows 2000上使用标准numpy,对于一百万元素的数组,它比示例1快约10%。
图1¶
在广播的最简单示例中,标量b
被拉伸成与a
形状相同的数组,因此形状兼容,可以进行逐元素乘法。
决定两个数组是否具有兼容形状以进行广播的规则可以用一句话表达。
广播规则
要判断两个张量是否能够进行广播,需要遵循广播的规则:
1. 如果两个张量的维度不同,较小维度的张量会在前面补1,直到维度数相同。
2. 然后,两个张量从最后一个维度开始比较:
• 如果维度相同,或者其中一个是1,则该维度是兼容的。
• 如果两个维度都不为1并且不相等,则无法广播。
。
如果不满足这个条件,就会抛出ValueError('frames are not aligned')
异常,表示数组的形状不兼容。广播操作创建的结果数组的大小是输入数组在每个维度上的最大大小。注意,这个规则并没有说两个数组需要具有相同数量的维度。因此,例如,如果你有一个256 x 256 x 3的RGB值数组,你想用不同的值缩放图像中的每种颜色,你可以将图像乘以一个具有3个值的一维数组。根据广播规则对齐这些数组的尾轴大小,可以看出它们是兼容的:
图像 | (3d数组) | 256 x | 256 x | 3 |
缩放 | (1d数组) | 3 | ||
结果 | (3d数组) | 256 x | 256 x | 3 |
在下面的示例中,A
和B
数组都有长度为1的轴,在广播操作中被扩展到更大的尺寸。
A | (4d数组) | 8 x | 1 x | 6 x | 1 |
B | (3d数组) | 7 x | 1 x | 5 | |
结果 | (4d数组) | 8 x | 7 x | 6 x | 5 |
下面是几个代码示例和图形表示,有助于使广播规则在视觉上变得明显。示例3将一个一维数组添加到一个二维数组:
示例3¶
>>> from numpy import array
# a 是一个二维数组, 4行3列
>>> a = array([[ 0.0, 0.0, 0.0],
... [10.0, 10.0, 10.0],
... [20.0, 20.0, 20.0],
... [30.0, 30.0, 30.0]])
# b 是一个一维数组, 1 * 3
>>> b = array([1.0, 2.0, 3.0])
>>> a + b
array([[ 1., 2., 3.],
[ 11., 12., 13.],
[ 21., 22., 23.],
[ 31., 32., 33.]])
如图2所示,b
被添加到a
的每一行。当b
比a
的行长时,如图3所示,会因形状不兼容而引发异常。
图2¶
如果一维数组元素的数量与二维数组的列数匹配,则二维数组乘以一维数组会导致广播。
图3¶
当数组的尾部维度不相等时,广播失败,因为无法将第一个数组行中的值与第二个数组的元素对齐进行逐元素加法。
广播提供了一种方便的方法来计算两个数组的外积(或任何其他外部操作)。以下示例展示了两个1-d数组的外部加法操作,产生的结果与示例3相同:
示例4¶
>>> from numpy import array, newaxis
>>> a = array([0.0, 10.0, 20.0, 30.0])
>>> b = array([1.0, 2.0, 3.0])
>>> a[:,newaxis] + b
array([[ 1., 2., 3.],
[ 11., 12., 13.],
[ 21., 22., 23.],
[ 31., 32., 33.]])
这里,newaxis索引运算符在a
中插入了一个新轴,使其成为一个4x1的二维数组。图4说明了两个数组如何被拉伸以产生所需的4x3输出数组。