Tensor和autograd
每个深度学习框架的设计核心是张量和计算图,在pytorch里体现为张量系统(Tensor)和自动微分系统(atutograd)。
Tensor
- 中文译为张量,可以简单看作一个数组。
- 与numpy里的ndarrays类似,但tensor支持GPU加速。
基础操作
接口角度:
- torch.function
- tensor.function
存储角度:
- 不会修改自身数据,如a.add(b),返回一个值为加法结果的新的tensor。
- 会修改自身数据,如a.add_(b),加法的值储存在a中了。
创建Tensor
在pytorch中常见的新建tensor的方法:
类别 | 特点 | 函数 | 功能 |
---|---|---|---|
第一类:基础方法 | 最灵活 | Tensor(*sizes) | 基础构造函数 |
第二类:根据sizes建立 | 常数型 | ones(*sizes) | 全1Tensor |
常数型 | zeros(*sizes) | 全0Tensor | |
常数型 | eyes(*sizes) | 对角线为1,其他为0 | |
概率分布型 | rand/randn(*sizes) | 均匀/标准分布 | |
第三类:在一定范围内建立 | 等差数列型 | arange(s,e,step) | 从s到e,步长为step |
等差数列型 | linspace(s,e,steps) | 从s到e,均匀切分成steps份 | |
概率分布型 | normal(mean,std)/uniform(from,to) | 正态分布/均匀分布 | |
概率分布型 | randperm(m) | 随机分布 |
- 其中使用Tensor函数新建tensor是最复杂多变的,它既可以接受一个list,并根据list的数据新建tensor,也可根据指定的形状新建tensor,还能传入其他的tensor。
1 | # 引入必要的包 |
1 | # 指定tensor的形状 |
tensor([[7.2443e+22, 4.2016e+30, 9.9708e+17],
[7.2296e+31, 5.6015e-02, 4.4721e+21]])
1 | # 用list的数据创建tensor |
tensor([[1., 2., 3.],
[4., 5., 6.]])
1 | b.tolist(),type(b.tolist()) # 把tensor转为list |
([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], list)
tensor.size()返回torch.Size()对象,它是tuple的子类,但其使用方式与tuple略有不同。
1 | b_size = b.size();b_size |
torch.Size([2, 3])
1 | b.numel() # numelements前五个字母,b中元素总个数,等价于b.nelement() |
6
1 | # 创建一个和b形状一样的tensor |
(tensor([[5.8959e-35, 4.5636e-41, 1.0257e-36],
[0.0000e+00, 5.0000e+00, 6.0000e+00]]), tensor([2., 3.]))
tensor.shape等价于tensor.size()
1 | c.shape |
torch.Size([2, 3])
* 需要注意:t.Tensor(*sizes)创建tensor时,系统不会马上分配空间,只会计算内存是否够用,使用到tensor时才会分配,而其他方法是创建后会立马分配空间。 *
1 | t.ones(2, 3) |
tensor([[1., 1., 1.],
[1., 1., 1.]])
1 | t.zeros(2, 3) |
tensor([[0., 0., 0.],
[0., 0., 0.]])
1 | t.linspace(1, 10 ,3) |
tensor([ 1.0000, 5.5000, 10.0000])
1 | t.randn(2, 3) |
tensor([[-0.4864, 0.5022, -0.4059],
[ 0.4138, 1.1588, -1.1650]])
1 | t.randperm |
<function _VariableFunctions.randperm>
1 | # 0到n-1随机排列后的数列 |
tensor([2, 5, 8, 3, 4, 1, 0, 7, 9, 6])
1 | t.eye(2, 3) # 不要求行列数一致 |
tensor([[1., 0., 0.],
[0., 1., 0.]])
1 | t.normal(t.Tensor([0]),t.Tensor([1])) |
tensor([-0.5517])
常用Tensor操作
tensor.view方法可以改变tensor的形状,但要保证前后元素总数一致。前后保持数据一致,返回的新tensor与源tensor共享内存。
在实际应用中可能经常需要增加或减少某个维度,这是squeeze和unsqueeze两个函数排上用场。
1 | a = t.arange(0, 6) |
tensor([[0, 1, 2],
[3, 4, 5]])
1 | b = a.view(-1, 3) # 当某一维为-1时,会自动计算它的大小 |
tensor([[0, 1, 2],
[3, 4, 5]])
1 | b.shape, b.unsqueeze(1).shape # 注意形状,在第1维上增加“1” |
(torch.Size([2, 3]), torch.Size([2, 1, 3]))
1 | b.unsqueeze(-2) # -2表示倒数第二个维度 |
tensor([[[0, 1, 2]],
[[3, 4, 5]]])
1 | c = b.view(1, 1, 1, 2, 3) |
(tensor([[[[[0, 1, 2],
[3, 4, 5]]]]]), tensor([[[[0, 1, 2],
[3, 4, 5]]]]))
1 | c.squeeze() # 压缩所有的“1”的维度 |
tensor([[0, 1, 2],
[3, 4, 5]])
1 | a[1] = 100 |
tensor([[ 0, 100, 2],
[ 3, 4, 5]])
resize是另一种改变size的方法,和view不同的地方是resize可以改变尺寸,可以有不同数量的元素。如果新尺寸超过了旧尺寸,会自动分配空间,如果新尺寸小于旧尺寸,之前的数据依旧会保存。
1 | b.resize_(1, 3) |
tensor([[ 0, 100, 2]])
1 | b.resize_(3, 3) # 旧的数据依旧被保存,多出的数据会分配新空间。 |
tensor([[ 0, 100, 2],
[ 3, 4, 5],
[7881702260482471202, 8319104481852400229, 7075192647680159593]])
索引操作
Tensor支持和numpy.ndarray类似的索引操作,语法上也类似。
如无特殊说明,索引出来的结果与原tensor共享内存
1 | a = t.randn(3,4);a |
tensor([[ 0.8865, -0.8832, -1.0883, -0.2804],
[-0.9056, 0.0635, 0.5528, -0.0222],
[ 1.4919, -1.0480, -1.7623, 0.8558]])
1 | a[0] # 第0行 |
tensor([ 0.8865, -0.8832, -1.0883, -0.2804])
1 | a[:, 0] # 第0列 |
tensor([ 0.8865, -0.9056, 1.4919])
1 | a[0][2] # 第0行第2个元素,等价于a[0,2] |
tensor(-1.0883)
1 | a[0, -1] # 第0行最后一个元素 |
tensor(-0.2804)
1 | a[:2] # 前两行 |
tensor([[ 0.8865, -0.8832, -1.0883, -0.2804],
[-0.9056, 0.0635, 0.5528, -0.0222]])
1 | a[:2, 0:2] # 前两行,第0,1列 |
tensor([[ 0.8865, -0.8832],
[-0.9056, 0.0635]])
1 | a[0:1, :2].shape, a[0, :2].shape # 注意两者的区别是形状不同,但是值是一样的 |
(torch.Size([1, 2]), torch.Size([2]))
1 | a[a > 1] # 等价于a.masked_select(a>1) |
tensor([1.4919])
1 | a[t.LongTensor([0,1])] # 第0行和第1行 |
tensor([[ 0.8865, -0.8832, -1.0883, -0.2804],
[-0.9056, 0.0635, 0.5528, -0.0222]])
常用的选择函数:
函数 | 功能 |
---|---|
index_select(input, dim, index) | 在指定维度dim上选取,例如选取某行某列 |
masked_select(input, mask) | 例子如上,a[a > 0],使用ByteTensor进行选取 |
non_zero(input) | 非0元素的下标 |
gather(input, dim, index) | 根据index,在dim维度上选取数据,输出的size与index一样 |
gather是一个比较复杂的操作,对于一个二维的tensor,输出的每个元素如下:
1 | out[i][j] = input[index[i][j]][j] # dim = 0 |
1 | a = t.arange(0, 16).view(4, 4);a |
tensor([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11],
[12, 13, 14, 15]])
1 | # 选取对角线上的元素 |
tensor([[ 0, 5, 10, 15]])
1 | # 选取反对角线上的元素 |
tensor([[ 3],
[ 6],
[ 9],
[12]])
1 | # 选取反对角线上的元素,注意与上面不同 |
tensor([[12, 9, 6, 3]])
1 | # 选取两个对角线上的元素 |
tensor([[ 0, 3],
[ 5, 6],
[10, 9],
[15, 12]])
gather的逆操作是scatter_, gather把数据从input中按index取出,而scatter_是把取出的数据再放回去。注意scatter_函数是inplace操作。
1 | out = input.gather(dim, index) |
1 | # 把两个对角线元素放回到指定位置 |
tensor([[ 0., 0., 0., 3.],
[ 0., 5., 6., 0.],
[ 0., 9., 10., 0.],
[12., 0., 0., 15.]])
高级索引
高级索引可以看成是普通索引的扩展,但是高级索引操作的结果一般不和原Tensor共享内存。
1 | x = t.arange(0, 27).view(3, 3, 3);x |
tensor([[[ 0, 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]]])
1 | x[[1, 2], [1, 2], [2, 0]] # 元素的个数是列表的长度 元素为x[1,1,2]和x[2,2,0] |
tensor([14, 24])
1 | x[[2,1,0],[0],[1]] # 元素为最长列表的长度 x[2,0,1] x[1,0,1] x[0,0,1] |
tensor([19, 10, 1])
1 | x[[0,2],...] # x[0] x[2] |
tensor([[[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8]],
[[18, 19, 20],
[21, 22, 23],
[24, 25, 26]]])
Tensor类型
默认的Tensor类型为FloatTensor,可通过t.get_default_tensor_type修改默认类型(如果默认类型为GPU tensor,在所有操作都在GPU上进行)。
HalfTensor是专门为GPU版本设计的,同样的元素个数,显存占用只有FloatTensor的一半,所以可以极大地缓解GPU显存不足的问题,但是由于HalfTensor所能表示的数值大小和精度有限,所以可能出现溢出等问题。
数据类型 | CPU tensor | GPU tensor |
---|---|---|
32bit浮点 | torch.FloatTensor | torch.cuda.FloatTensor |
64bit浮点 | torch.DoubleTensor | torch.cuda.DoubleTensor |
16半精度浮点 | N/A | torch.cuda.HalfTensor |
8bit无符号整型(0~255) | torch.ByteTensor | torch.cuda.ByteTensor |
8bit有符号整型(-128~127) | torch.CharTensor | torch.cuda.CharTensor |
16bit有符号整型 | torch.ShortTensor | torch.cuda.ShortTensor |
32bit有符号整型 | torch.IntTensor | torch.cuda.IntTensor |
64bit有符号整型 | torch.LongTensor | torch.cuda.LongTensor |
各数据类型之间可以互相转换,type(new_type)是通用的做法,同时还有float、long、half等快捷方法。CPU tensor与GPUtensor之间的互相装换通过tensor.cuda和tensor.cpu的方法实现。Tensor还有一个new方法,用法与t.Tensor一样,会调用该tensor对应类型的构造函数,生成与当前tensor类型一致的tensor。
1 | # 设置默认tensor类型, 注意参数是字符串 |
1 | a = t.Tensor(2, 3);a |
tensor([[1.8609e+34, 1.8179e+31, 1.8524e+28],
[9.6647e+35, 2.0076e+29, 7.3185e+28]])
1 | b = a.int();b |
tensor([[-2147483648, -2147483648, -2147483648],
[-2147483648, -2147483648, -2147483648]], dtype=torch.int32)
1 | c = a.type_as(b);c |
tensor([[-2147483648, -2147483648, -2147483648],
[-2147483648, -2147483648, -2147483648]], dtype=torch.int32)
1 | d = b.new(2, 3);d |
tensor([[ 0, 775041082, 960062260],
[1697986359, 926101553, 895706424]], dtype=torch.int32)
1 | # 查看函数new的源码 |
逐元素操作
这部分操作会对tensor的每个元素进行操作,输入和输出的形状相同。
函数 | 功能 |
---|---|
abs/sqrt/div/exp/fmod/log/pow.. | 绝对值/平方根/除法/指数/求余/对数/求幂 |
cos/sin/asin/atan2/cosh | 三角函数 |
ceil/round/floor/trunc | 上取整/四舍五入/下取整/只保留整数部分 |
clamp(input,min,max) | 超过min和max部分截断 |
sigmod/tanh… | 激活函数 |
对于很多基本的运算,比如加减乘除求余等运算pytorch都实现了运算符重载,可以直接使用运算符。
其中camp(x, min, max)的输出满足一个分段函数:
$$
y_i=
\begin{cases}
min, & {x_i < min}\\
x_i, & {min \leq x_i \leq max}\\
max, & {x_i > max}
\end{cases}
$$
1 | a = t.arange(0, 6).view(2, 3).float() # 注意要转换一下类型,否则会报错 |
tensor([[ 1.0000, 0.5403, -0.4161],
[-0.9900, -0.6536, 0.2837]])
1 | a % 3 # 等价于t.fmod(a, 5) |
tensor([[0., 1., 2.],
[0., 1., 2.]])
1 | a ** 2# 等价于t.power(a, 2) |
tensor([[ 0., 1., 4.],
[ 9., 16., 25.]])
1 | # a中每个元素与3相比取较大的那一个 |
tensor([[0., 1., 2.],
[3., 4., 5.]])
tensor([[3., 3., 3.],
[3., 4., 5.]])
归并操作
这类操作会使输入形状小于输出形状,并可以沿着某一维度进行制定操作。
函数 | 功能 |
---|---|
mean/sum/median/mode | 均值/和/中位数/众数 |
norm/dist | 范数/距离 |
std/var | 标准差/方差 |
cumsum/cumprod | 累加/累乘 |
几乎每个函数都有一个dim参数,用来制定在那个维度上执行。
假设输入的形状是(m, n, k):
- 如果指定dim = 0,输出的形状为(1, n, k)或者(n, k)
- 如果指定dim = 1,输出的形状为(m, 1, k)或者(m, k)
- 如果指定dim = 2,输出的形状为(m, n, 1)或者(m, n)
也就是dim指定哪个维度,那个维度就会变成1,size中是否有1取决于keepdim,keepdim=True会保留1,keepdim默认为False。但是并非总是这样,比如cumsum。
归并运算就是对其他维度取值相同且该维度取值不同元素进行操作。
1 | b = t.ones(2, 3) |
tensor([[2., 2., 2.]])
1 | b.sum(dim = 0) #keepdim = False |
tensor([2., 2., 2.])
1 | b.sum(dim = 1) |
tensor([3., 3.])
1 | a = t.arange(0, 6).view(2, 3) |
tensor([[0, 1, 2],
[3, 4, 5]])
tensor([[ 0, 1, 3],
[ 3, 7, 12]])
cumsum可以理解为以dim这个维度上索引取值相同的看作一个整体,比如dim=0每一行就是一个整体,cumsum运算相当于dim这个维度上取值为n的值加上取值为n-1的值(这个n-1已经进行过前面的运算,不是初始的值)。
比较
比较函数有的是逐元素操作,有的是归并操作。
函数 | 功能 |
---|---|
gt/lt/ge/le/eq/ne | 大于/小于/大于等于/小于等于/等于/不等 |
topk | 最大的k个数 |
sort | 排序 |
max/min | 比较两个tensor的最大值或最小值 |
表中第一行的比较操作已经重载,已经可以使用a>=b, a>b, a!=b和a==b,其返回结果为一个ByteTensor,可以用来选取元素(高级索引)。
max和min两个操作比较特殊,以max为例:
- t.max(tensor):返回tensor中最大的一个数。
- t.max(tensor,dim):指定维上最大的一个数,返回tensor和下标。
- t.max(tensor1,tensor2):比较两个tensor中较大的元素。
tensor和一个数的比较可以用clamp函数。
1 | a = t.linspace(0, 15, 6).view(2, 3);a |
tensor([[ 0., 3., 6.],
[ 9., 12., 15.]])
1 | b = t.linspace(15, 0, 6).view(2, 3);b |
tensor([[15., 12., 9.],
[ 6., 3., 0.]])
1 | a > b |
tensor([[False, False, False],
[ True, True, True]])
1 | a[a > b] |
tensor([ 9., 12., 15.])
1 | t.max(a) |
tensor(15.)
1 | t.max(a, dim = 1) |
torch.return_types.max(
values=tensor([ 6., 15.]),
indices=tensor([2, 2]))
1 | t.max(a, b) |
tensor([[15., 12., 9.],
[ 9., 12., 15.]])
1 | # 比较a和10较大的元素 |
tensor([[10., 10., 10.],
[10., 12., 15.]])
线性代数
pytorch的线性函数封装了Blas和Lapack。
函数 | 功能 |
---|---|
trace | 对角线元素(矩阵的迹) |
diag | 对角线元素 |
triu/tril | 矩阵的上三角/下三角,可以指定偏移量 |
mm/bmm | 矩阵乘法,batch的矩阵乘法 |
addmm/addbmm/addmv | 矩阵运算 |
t | 转置 |
dot/cross | 内积/外积 |
inverse | 求逆矩阵 |
svd | 奇异值分解 |
需要注意矩阵装置会导致储存空间不连续,需调用它的.contiguous方法将其转为连续。
1 | b = a.t() |
(False, tensor([[ 0., 9.],
[ 3., 12.],
[ 6., 15.]]))
1 | b.contiguous() |
tensor([[ 0., 9.],
[ 3., 12.],
[ 6., 15.]])
Tensor和Numpy
tensor和numpy数组之间具有很高的相似性,彼此之间相互操作也十分高效。需要注意,numpy和tensor共享内存。当遇到tensor不支持的操作时,可先转成Numpy数组,处理后再装回tensor,其转换开销很小。
广播法则是科学运算中经常使用的一个技巧,它在快速执行向量化的同时不会占用额外的内存、显存。Numpy的广播法则定义如下:
- 让所有输入数组都向shape最长的数组看齐,shape中不足的部分通过在前面加1补齐。
- 两个数组要么在某一个维度的长度一致,要么其中一个为1,否则不能计算。
- 当输入数组的某个维度的长度为1时,计算时沿着此维度复制扩充成一样的形状。
pytorch当前已经支持了自动广播法则,但建议可以手动通过函数实现广播法则,更直观不易出错。
- unsqueeze或者view:为数据的某一维的形状补1,实现法则1。
- expand或者expand_as,重复数组,实现法则3;该操作不会复制数组,所以不会占用额外的空间。
注意:repeat实现有expand类似,但是repeat会把相同数据复制多份,因此会占用额外空间。
1 | a = t.ones(3, 2) |
1 | # 自动广播法则 |
(torch.Size([2, 3, 2]), tensor([[[1., 1.],
[1., 1.],
[1., 1.]],
[[1., 1.],
[1., 1.],
[1., 1.]]]))
1 | a.unsqueeze(0).expand(2, 3, 2) + b.expand(2, 3, 2) |
tensor([[[1., 1.],
[1., 1.],
[1., 1.]],
[[1., 1.],
[1., 1.],
[1., 1.]]])
1 | import numpy as np |
array([[1., 1., 1.],
[1., 1., 1.]], dtype=float32)
1 | b = t.from_numpy(a);b |
tensor([[1., 1., 1.],
[1., 1., 1.]])
1 | b = t.Tensor(a) # 也可以直接讲numpy对象传入Tensor,这种情况下若numpy类型不是Float32会新建。 |
tensor([[1., 1., 1.],
[1., 1., 1.]])
1 | c = b.numpy() # a, b, c三个对象共享内存 |
array([[1., 1., 1.],
[1., 1., 1.]], dtype=float32)
1 | # expand不会占用额外空间,只会在需要时才扩充,可极大地节省内存。 |
内部结构
tensor分为头信息区(Tensor)和存储区(Storage),信息区主要保存着tensor的形状(size),步长(stride)、数据类型(type)等信息,而真正的数据则保存成连续的数组。
graph LR; A[Tensor A: *size *stride * dimention...] --> C[Storage:*data *size ...]; B[Tensor B: *size *stride * dimention....] --> C[Storage:*data *size ...];
一般来说,一个tensor有着与之对应的storage,storage是在data之上封装的接口,便于使用。不同的tensor的头信息一般不同,但却可能使用相同的storage。下面我们来看两个例子。
1 | a = t.arange(0, 6) |
0
1
2
3
4
5
[torch.LongStorage of size 6]
1 | b = a.view(2, 3) |
0
1
2
3
4
5
[torch.LongStorage of size 6]
1 | # 一个对象的id值可以看作它在内存中的地址 |
True
1 | # a改变,b也随之改变,因为它们共享storage |
tensor([[ 0, 100, 2],
[ 3, 4, 5]])
1 | c = a[2:] |
0
100
2
3
4
5
[torch.LongStorage of size 6]
1 | c.data_ptr(), a.data_ptr(), c.dtype # data_ptr返回tensor的首元素的内存地址 |
(61509136, 61509120, torch.int64)
1 | c[0] = -100 # c[0]的内存地址对应a[2]内存地址 |
tensor([ 0, 100, -100, 3, 4, 5])
1 | d = t.Tensor(c.float().storage()) |
tensor([[ 0, 100, -100],
[ 3, 4, 5]])
1 | # 下面4个共享storage |
True
1 | a.storage_offset(), c.storage_offset(), a[3:].storage_offset() |
(0, 2, 3)
1 | e = b[::2, ::2] # 隔2行/列取一个元素 |
True
1 | b.stride(), e.stride() |
((3, 1), (6, 2))
1 | e.is_contiguous() |
False
可见绝大多数操作并不修改tensor的数据,只是修改头信息。这样更节省内存,同时提升了处理的速度。但是,有些操作会导致tensor不连续,这时需调用tensor.contiguous方法将他们变成连续数据,该方法复制数据到新的内存,不再与原来的数据共享storage。
另外高级索引一般不共享内存,而普通索引共享storage。
其他有关Tensor的话题
持久化
tensor的保存和加载十分简单,使用t.save和t.load即可完成相应功能。在save/load时可以指定使用的pickle模块,在load时还可以将GPU tensor映射到CPU或其他GPU上。
1 | if t.cuda.is_available(): |
向量化
向量化计算是一种特殊的并行计算方式,通常是对不同的数据执行同样的一个或一批指令。向量化可极大第提高科学运算的效率。Python有许多操作很低效,尤其是for循环。在科学计算中要极力避免使用Python原生的for循环,尽量使用向量化的数值计算。
1 | def for_loop_add(x, y): |
1 | x = t.zeros(100) |
1 | %timeit -n 10 for_loop_add(x, y) |
729 µs ± 414 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
The slowest run took 4.81 times longer than the fastest. This could mean that an intermediate result is being cached.
3.5 µs ± 2.69 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
可见有好几百倍的速度差距,因此在实际使用中应尽量调用内建函数,这些函数底层由C/C++实现,能通过执行底层优化实现高效计算。
此为还需要注意几点:
- 大多数t.function都有一个参数out,这时产生的结果将保存在out指定的tensor之中
- t.set_num_threads可以设置pytorch进行CPU多线程并行计算时所占用的线程数,来限制pytorch所占用的CPU数目。
- t.set_printoptions可以用来设置打印tensor时的数值精度和格式。
1 | a = t.randn(2, 3); a |
tensor([[-0.1227, -0.0569, -0.6876],
[ 1.6025, 0.6995, 0.1694]])
1 | t.set_printoptions(precision = 10);a |
tensor([[-0.1226951405, -0.0568769276, -0.6875813603],
[ 1.6024936438, 0.6995284557, 0.1693879962]])