01_张量和自动微分

1 张量的创建

张量是向量、矩阵的扩展,和 Numpy 的 ndarray 类似,但张量可以在 GPU 上运行,从而加速计算。

张量维度 代表含义
0维张量 代表的是标量(数字)
1维张量 代表的是向量
2维张量 代表的是矩阵
3维张量 时间序列数据 股价 文本数据 单张彩色图片(RGB)
1
2
import torch
import numpy as np
  • tensor():类似np.array,直接来自数据
1
torch.tensor([1, 2, 3])
tensor([1, 2, 3])
  • Tensor():基础构造函数,有更多参数选项,可以直接指定形状
1
torch.Tensor(2, 4)
tensor([[0., 0., 0., 0.],
        [0., 0., 0., 0.]])

和 Numpy 类似,常见的构造 Tensor 的方法:

函数 功能
Tensor(sizes) 基础构造函数
tensor(data) 类似于np.array
ones(sizes) 全1
zeros(sizes) 全0
eye(sizes) 对角为1,其余为0
arange(s,e,step) 从s到e,步长为step
randint(low,high,size) 随机生成整数
linspace(s,e,steps) 从s到e,均匀分成step份
rand/randn(sizes) rand是[0,1)均匀分布;randn是服从N(0,1)的正态分布
normal(mean,std) 正态分布(均值为mean,标准差是std)
randperm(m) 随机排列

还有一些之前 Numpy 没介绍的 Tensor 方法,在此介绍。

  • ones_like():除非显式声明,否则创建与参数张量的形状、dtype、device 一样的全 1 张量。
1
2
3
4
5
6
# 设置随机种子
torch.manual_seed(66)
x = torch.rand(2, 3)

display(x)
display(torch.ones_like(x))
tensor([[0.4976, 0.0230, 0.2271],
        [0.5708, 0.8475, 0.7140]])



tensor([[1., 1., 1.],
        [1., 1., 1.]])
1
torch.ones_like(x, dtype=torch.int64)
tensor([[1, 1, 1],
        [1, 1, 1]])
  • rand_like():创建一个属性相同的随机张量。
1
torch.rand_like(x, dtype=torch.float32)
tensor([[0.8519, 0.6986, 0.7792],
        [0.6343, 0.3099, 0.9380]])

此外还有 zeros_like(), full_like() 等很多 xxx_like() 创建方法。

2 张量的属性

张量属性描述其形状、数据类型以及存储它们的设备。

1
2
3
4
5
t = torch.rand(3, 4)

print('形状:', t.shape)
print('类型:', t.dtype)
print('存储设备:', t.device)
形状: torch.Size([3, 4])
类型: torch.float32
存储设备: cpu

如果只想查看张量的元素个数,可以使用 numel()

1
print('元素个数:', t.numel())
元素个数: 12

3 张量的操作

3.1 张量转换

  • type():转换为指定类型
1
2
3
4
5
t = torch.randint(0, 10, (5,))
print('type:', t.type(torch.float32))

# 也可以直接使用对应方法
print('float:', t.float())
type: tensor([4., 6., 6., 8., 0.])
float: tensor([4., 6., 6., 8., 0.])
  • from_numpy():将 ndarray 转换为 Tensor,共享内存。使用 copy() 避免共享内存。
1
2
3
a = np.array([[1, 2, 3], [4, 5, 6]])
x = torch.from_numpy(a)
x
tensor([[1, 2, 3],
        [4, 5, 6]])
  • tensor():将 ndarray 转换为 Tensor,不共享内存。
1
2
3
a = np.array([[1, 2, 3], [4, 5, 6]])
x = torch.tensor(a)
x
tensor([[1, 2, 3],
        [4, 5, 6]])

也可以通过numpy()方法将 Tensor 转为 Numpy 数组。

1
x.numpy()
array([[1, 2, 3],
       [4, 5, 6]])

如果张量中只有一个元素,可以使用 item() 取出一个普通的值。

1
2
e = torch.tensor([5])
e.item()
5

3.2 运算操作

官方文档提供了包括转置、索引、切片、数学运算、线性代数、随机采样等等,它们都可以在 GPU 上运行。

1
2
3
4
5
t3 = torch.rand(2, 3)
if torch.cuda.is_available():
# t.to('cuda')
t3 = t3.to('cuda')
print(f"Device tensor is stored on: {t3.device}")
Device tensor is stored on: cuda:0
  • +-*/:加减乘除
  • add()sub()mul()div():加减乘除
  • **pow()pow_():求幂
  • -neg()neg_():取负
  • sqrt()sqrt_():求平方根
  • exp()exp_():以 e 为底数求幂
  • log()log_():以 e 为底求对数

运算和 Numpy 类似,多了一种使用后缀 _原地操作

1
2
3
4
5
6
7
8
# 方式1
display(t3 + t3)

# 方式2
display(torch.add(t3, t3))

# 方式3,原地操作,修改原数据
display(t3.add_(10))
tensor([[1.8163, 0.5864, 0.2897],
        [1.3990, 0.3196, 0.0220]], device='cuda:0')



tensor([[1.8163, 0.5864, 0.2897],
        [1.3990, 0.3196, 0.0220]], device='cuda:0')



tensor([[10.9082, 10.2932, 10.1449],
        [10.6995, 10.1598, 10.0110]], device='cuda:0')

原地操作可以节省一些内存,但由于会立即丢失历史数据,因此在计算导数时可能会出现问题。因此,不建议使用原地操作。

点乘和 Numpy 也是类似的。

1
2
3
display(torch.matmul(t3, t3.T))

display(t3 @ t3.T)
tensor([[327.8561, 322.8491],
        [322.8491, 317.9212]], device='cuda:0')



tensor([[327.8561, 322.8491],
        [322.8491, 317.9212]], device='cuda:0')
  • sum():求和
  • mean():求均值
  • max()/min():求最大/最小值及其索引
  • argmax()/argmin():求最大值/最小值的索引
  • std():求标准差
  • unique():去重
  • sort():排序

可以通过 dim 指定维度。

1
2
3
display(t3)

display(t3.sum(dim=0))
tensor([[10.9082, 10.2932, 10.1449],
        [10.6995, 10.1598, 10.0110]], device='cuda:0')



tensor([21.6077, 20.4530, 20.1559], device='cuda:0')

3.3 索引操作

和 Numpy 类似,布尔索引、花式索引、切片索引都是支持的。注意索引结果与原数据共享内存。如果不想影响原数据,可以考虑使用 copy() 等方法。

1
2
3
display(t3)

display(t3[:, 1:])
tensor([[10.9082, 10.2932, 10.1449],
        [10.6995, 10.1598, 10.0110]], device='cuda:0')



tensor([[10.2932, 10.1449],
        [10.1598, 10.0110]], device='cuda:0')

3.4 维度和形状

3.4.1 维度调整

  • flatten():扁平化高维张量。
1
2
3
4
x2 = torch.rand(2, 3)
display(x2)

display(x2.flatten())
tensor([[0.3597, 0.0678, 0.1368],
        [0.3185, 0.4533, 0.4754]])



tensor([0.3597, 0.0678, 0.1368, 0.3185, 0.4533, 0.4754])
  • transpose(input, dim1, dim2):交换两个维度
1
2
3
4
5
6
7
t4 = torch.randint(1, 10, size=[2, 2, 3])
print(t4)

print(torch.transpose(t4, 1, 2))

# 直接使用对象方法也可以
print(t4.transpose(1, 2))
tensor([[[4, 7, 4],
         [2, 2, 5]],

        [[8, 3, 6],
         [8, 6, 4]]])
tensor([[[4, 2],
         [7, 2],
         [4, 5]],

        [[8, 8],
         [3, 6],
         [6, 4]]])
tensor([[[4, 2],
         [7, 2],
         [4, 5]],

        [[8, 8],
         [3, 6],
         [6, 4]]])
  • pemute(input, dims):重新排列维度顺序
1
2
3
4
5
6
7
8
9
10
11
12
13
# 对象方法
t4 = torch.randn(2, 3, 4)
display(t4)
print('permute前:', t4.shape)

# 维度 2, 3, 4
# 下标 0, 1, 2
# 修改 1, 0, 2
# 维度 3, 2, 4

t4 = t4.permute((1, 0, 2))
display(t4)
print('permute后:', t4.shape)
tensor([[[ 1.0098, -1.1005,  2.8977, -0.8842],
         [ 1.2498,  0.6263, -0.4922,  1.4868],
         [-0.4247,  0.2663,  0.8949,  0.8392]],

        [[-0.2499, -0.1474, -1.1676,  1.8240],
         [ 1.1016, -0.6056,  0.9351,  0.0119],
         [ 0.2244,  0.2427, -2.1742, -0.5351]]])


permute前: torch.Size([2, 3, 4])



tensor([[[ 1.0098, -1.1005,  2.8977, -0.8842],
         [-0.2499, -0.1474, -1.1676,  1.8240]],

        [[ 1.2498,  0.6263, -0.4922,  1.4868],
         [ 1.1016, -0.6056,  0.9351,  0.0119]],

        [[-0.4247,  0.2663,  0.8949,  0.8392],
         [ 0.2244,  0.2427, -2.1742, -0.5351]]])


permute后: torch.Size([3, 2, 4])
  • unsqueeze():在指定位置插入 1 个维度,大小为 1
1
2
3
4
t4 = torch.tensor([1, 2, 3])

print('在 0 维度插入:\n', t4.unsqueeze(0))
print('\n在 1 维度插入:\n', t4.unsqueeze(1))
在 0 维度插入:
 tensor([[1, 2, 3]])

在 1 维度插入:
 tensor([[1],
        [2],
        [3]])
  • squeeze():删除大小为 1 的元素
1
2
t4 = torch.randn(2, 1, 3, 1)
t4.squeeze()
tensor([[-2.0466,  0.0907, -0.5900],
        [-1.4429,  0.0549,  0.1932]])

3.4.2 形状调整

张量的形状变换有 view()reshape(),都可以通过-1来自动计算某维度,如 reshape(-1, 1) 变换为 1 列,行自动计算。 - view():必须是连续内存的张量,否则报错,共享底层内存,不复制数据,是浅拷贝 - reshape():若连续则返回视图(浅拷贝),不连续则返回副本(深拷贝),保证可以使用

1
2
3
4
5
x1 = torch.randn(4, 3)
y1 = x1.view(12)

y1 += 10
print(x1) # x1 也被修改了
tensor([[11.4299,  8.8268,  8.1305],
        [ 8.5055,  8.7648, 10.3116],
        [ 9.6589, 10.2913, 10.4617],
        [11.0716, 11.6512,  9.4457]])
1
2
3
4
5
6
7
8
9
10
11
12
13
x2 = torch.randn(4, 3)
y2 = x2.reshape(12)

# 连续返回视图,修改影响原数据
y2 += 10
print('连续返回视图:\n', x2)

x2 = torch.randn(4, 3).T # 转置使内存不再连续
y2 = x2.reshape(12)

# 不连续返回副本
y2 += 10
print('不连续返回副本:\n', x2)
连续返回视图:
 tensor([[10.1255, 10.5621,  9.7059],
        [ 9.3825, 10.3619,  9.8099],
        [ 9.3883,  7.9280,  8.6108],
        [ 9.4097,  7.4755,  9.9311]])
不连续返回副本:
 tensor([[ 0.2168,  0.2555, -0.3164, -0.0119],
        [ 0.3482, -0.5855, -0.4436,  0.9016],
        [ 0.2345,  1.9516,  0.0829,  0.9176]])

一个张量可能因为 transpose()permute() 等操作变成非连续的。如果内存不连续,则使用 view 会报错,可以使用 is_contiguous() 判断是否内存连续,并使用 contiguous() 转换为连续内存。

1
2
3
4
5
6
print('转置前:', x2.is_contiguous())
x2 = x2.T
print('转置后:', x2.is_contiguous())

x2 = x2.contiguous()
print('转换为连续:', x2.is_contiguous())
转置前: False
转置后: True
转换为连续: True

由于 reshape() 并不保证返回的是拷贝值,所以不推荐使用这个方法,推荐使用 clone() 创造副本之后使用 view() 进行维度变换。

使用 clone() 还有一个好处是会被记录在计算图中,即梯度回传到副本时也会传到源 Tensor 。

3.5 张量拼接

使用 dim 指定拼接维度,类似 Numpy 的 axis,默认为 0 - cat():在指定维度上拼接张量,除拼接维度外,其他维度必须相等 - stack():在新维度上堆叠张量,所有维度必须相等

1
2
3
4
x4 = torch.arange(6).view(2, 3)

display(torch.cat([x4, x4]))
display(torch.cat([x4, x4], dim=1))
tensor([[0, 1, 2],
        [3, 4, 5],
        [0, 1, 2],
        [3, 4, 5]])



tensor([[0, 1, 2, 0, 1, 2],
        [3, 4, 5, 3, 4, 5]])
1
2
3
# stack 示例
display(torch.stack([x4, x4]))
display(torch.stack([x4, x4], dim=1))
tensor([[[0, 1, 2],
         [3, 4, 5]],

        [[0, 1, 2],
         [3, 4, 5]]])



tensor([[[0, 1, 2],
         [0, 1, 2]],

        [[3, 4, 5],
         [3, 4, 5]]])

3.6 广播机制

和 Numpy 类似的广播机制,先复制元素使这两个 Tensor 形状相同后再按元素运算。

1
2
3
a = torch.arange(6)
b = torch.arange(3).view(-1, 1)
a + b
tensor([[0, 1, 2, 3, 4, 5],
        [1, 2, 3, 4, 5, 6],
        [2, 3, 4, 5, 6, 7]])

3.7 节省内存

直接使用 X = X + 10 会分配新的内存,在机器学习中,可能有数百兆的参数,并且在一秒内多次更新所有参数。如果不原地更新,其他引用仍然指向旧地址,可能会无意中引用旧的参数。通常情况下,我们希望原地执行这些更新。

1
2
3
4
X = torch.tensor([1, 2, 3])
before = id(X)
X = X + 10
id(X) == before
False

+= 会原地修改。

1
2
3
before = id(X)
X += 10
id(X) == before
True

可以使用切片表示法执行原地操作,X[:] = <expression>

1
2
3
before = id(X)
X[:] = X + 10
id(X) == before
True

4 自动微分

PyTorch 的自动微分是基于计算图(computational graph)的。每做一次涉及 tensor 的计算,会构建一棵有向无环图,图的节点是张量及其产生操作(operation),边表示依赖关系。

对最终标量(通常是 loss)调用 .backward() 时,PyTorch 会沿着图反向传播,应用链式法则计算每个需要梯度的叶子张量(leaf tensor)的梯度。

4.1 微分流程

  • requires_grad:标记为 True 的张量会被追踪所有针对该张量的操作,计算得到的张量也会被追踪。每个操作都会产生一个新的 Tensor,并记录下生成它的操作(grad_fn)。

通常会将模型参数(如权重和偏置)的 requires_grad 属性设置为 True,输入数据设置为 False

当完成前向传播并计算出损失(Loss,通常是一个标量)后,就可以调用 backward() 方法来启动反向传播,计算梯度。

  • backward():沿着计算图从损失节点开始,使用链式法则计算梯度累加到所有节点的 .grad 属性中。

每次新的训练迭代开始时,都需要调用优化器或手动将 .grad 清零(optimizer.zero_grad()tensor.grad.zero_()

  • 叶子张量:由用户直接创建,而不是通过计算图生成的张量。

  • .grad:调用 backward() 后,叶子张量的 .grad 会保存其梯度。若非叶子,.grad 常为 None,除非调用 retain_grad()

  • grad_fn:对于非叶子张量,grad_fn 指向生成它的函数(计算图中的节点),而叶子张量 grad_fn 为 None。

初始时输入x,模型参数wb设为requires_grad=True进行跟踪,经过wx+b的运算,得到预测值z,与真实值通过CE损失函数计算损失loss,调用backward()进行反向传播,得到梯度w.gradb.grad,并更新参数wb

4.2 detach 分离梯度

开启自动微分的张量不能转换为Numpy数组,可以使用detach()方法,该方法返回一个新的张量,该张量:

  • 共享同一块数据内存(浅拷贝)
  • 不再与计算图绑定,不记录梯度,不参与反向传播
1
2
3
4
5
6
7
8
9
x = torch.tensor([10, 20], requires_grad=True, dtype=torch.float32)

try:
a = torch.numpy(x)
except Exception as e:
print('numpy() is not supported')

a = x.detach().numpy()
print(a)
numpy() is not supported
[10. 20.]

4.3 手动模拟

模拟求解 f(x)=x^2 的最小值,相当于实际应用中求解损失函数的最小值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 此处相当于损失函数CE
def f(x):
return x ** 2

# 此处相当于损失函数的w,设置梯度跟踪
x = torch.tensor([10], requires_grad=True, dtype=torch.float32)

epochs = 10
lr = 0.2
for epoch in range(epochs):
y_pred = f(x) # 相当于损失值loss

# 梯度清空,防止梯度叠加
if x.grad is not None:
x.grad.zero_()

y_pred.sum().backward() # 反向传播

# 暂时关闭梯度跟踪
with torch.no_grad():
# 直接修改底层data,计算图无法知道此操作,可能造成混乱,不推荐
# x.data = x.data - lr * x.grad,
x -= lr * x.grad

print(f'epoch{epoch + 1}: x: {x.item():.3f}, y: {y_pred.item():.3f}, grad: {x.grad.item():.3f}')
epoch1: x: 6.000, y: 100.000, grad: 20.000
epoch2: x: 3.600, y: 36.000, grad: 12.000
epoch3: x: 2.160, y: 12.960, grad: 7.200
epoch4: x: 1.296, y: 4.666, grad: 4.320
epoch5: x: 0.778, y: 1.680, grad: 2.592
epoch6: x: 0.467, y: 0.605, grad: 1.555
epoch7: x: 0.280, y: 0.218, grad: 0.933
epoch8: x: 0.168, y: 0.078, grad: 0.560
epoch9: x: 0.101, y: 0.028, grad: 0.336
epoch10: x: 0.060, y: 0.010, grad: 0.202

4.4 实际应用

实际使用中,数据集的分批加载由DataLoader()完成,损失函数由nn.xxxLoss()完成,优化方法由optim.xxx()完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader

X = torch.randn(100, 1)
y = 5 * X + 3

# 先封装数据集,X和y必须是张量
dataset = TensorDataset(X, y)
# 创建数据加载器
dataloader = DataLoader(
dataset=dataset, # 数据集
batch_size=16, # 每批数据大小
shuffle=True # 是否打乱数据
)

# 定义线性模型:初始有随机权重和偏置
model = nn.Linear(in_features=1, out_features=1)
# 定义损失函数计算准则
criterion = nn.MSELoss()
# 定义优化器:记录模型参数的引用,可以直接更新模型参数
optimizer = optim.SGD(model.parameters(), lr=0.01)

epochs = 100 # 训练轮数
for epoch in range(epochs):
# 每次取一个 batch
for X_train, y_train in dataloader:
y_pred = model(X_train) # 模型预测
loss = criterion(y_train, y_pred) # 计算损失
optimizer.zero_grad() # 清空梯度
loss.sum().backward() # 反向传播
optimizer.step() # 更新参数

print(f'模型参数 w:{model.weight.item():.3f}, b: {model.bias.item():.3f}')
模型参数 w:5.000, b: 3.000

01_张量和自动微分
https://zhubaoduo.com/2024/08/03/大模型开发/05 深度学习/01_张量和自动微分/
作者
baoduozhu
发布于
2024年8月3日
许可协议