计算分类值的精确度

我们直接看下面一段代码:

def accuracy(out, yb):
    preds = torch.argmax(out, dim=1)
    return (preds == yb).float().mean()

这是一个计算分类数据精确度的函数,这段代码简直是神一样的代码,让人惊叹到下巴都掉到火星上去了!实现这么复杂的任务,居然能够如此优雅!

假设函数的参数out = [[a00, a01, a02], [a10, a11, a12]],其中0维度中每一项的数据对应这一个实体在第1维度的3个可能的权值。第1维度中,我们判定那个值最大,则认可该值对应的项目为所选项目。yb = [b0, b1]表示期望值。例如,a00, a01, a02中,如果a02最大,那么我们认为这一项对应的值为2, 如果b0也等于2,那么我们就说out的第一项判断是准确的。否则,则为错误。我们把准确的记为1, 错误的记为0, 然后统计这些1和0的平均值,并认为这个平均值为out中的准确度。如果是我来解决这个问题,我的代码可能就是这个样子了:

def accuracy(out, yb):
    preds = torch.argmax(out, dim=1)
    total = 0
    def f(i):
        if(preds[i] == yb[i]):
          total += 1
    for i in range(preds.shape[0]):
        if(preds[i] == yb[i]):
          total += 1
    return total / preds.shape[0]

我感觉自己永远需要铭记一点,任何时候我打算用for来遍历一个数组时,我都应该想想有没有可能直接使用数组运算来代替!数组思维!数组思维!目前的我在数据处理的问题上,太缺乏数组思维了!

相比而言,最上面那段代码太完美了!

使用(preds == yb)运算,得到一个bool型的数组,(preds==yb).float()将数组转化为float类型,然后(preds==yb).float().mean()对所有数据求平均值!完美!完美!完美!这段代码中对数组的操作应用得太成熟了!

那么,我应该怎么样思考,才能写出那么优雅的代码呢?以下是我的一些想法:

前面我也提到"我们把准确的记为1, 错误的记为0, 然后统计这些1和0的平均值,并认为这个平均值为out中的准确度。",那么在这里,我就应该想办法要去构建一个描述可能正确与否的一个数组。这要这么想,如果我又熟悉数组级别的条件判断应用,我也应该能写出preds == yb来!

总结起来,我与别人差距至少有以下2个点:

  1. 缺乏构建数组的思维
  2. 对数组的条件判断运算操作缺乏认知

数组的提取操作

在数据处理的工程中,经常会涉及到数组提取的操作。很多时候都需要从一个大数组中按维度,按长度等提取出一个小数组。本文将总结一些常用的提取操作。

用在方括号提取数组

对于tensor, pytorch, numpy这些拥有数组处理能力的库而言,其本身具备很强大的数据处理功能。大部分的数据提取操作都可以通过方括号操作符完成数据提取的任务。我们以pytorch为例,pytorch的数组方括号操作符中支持3类对象:

  1. 使用冒号: 描述某个维度中提取的范围。例如a[0:4, 4: -1]
  2. 使用一个整数数字指定某个维度中的特定项目。例如a[2, 8]
  3. 使用一个可迭代的整数队列指定某个维度中的一几个或者一个特定的项目。例如a[[0,1,2], [0,1,5]]

这3类对象在同一个提取操作中可以同时存在。这里,前2个提取操作都非常好理解。而3个提取操作会有一些麻烦。

冒号的应用和python常规中常规操作一样,a[:10, 4:7]表示对a数组的0维度,提取从0开始到第10项结尾的数据。在第1维度,提取从第4项开始到第7项结尾的数据。

单个整数数字表示对某个维度提取指定的某个元素。采用这样的提取方法,得到的数组会比原始数组减少一个维度。例如:

>>> a
tensor([[0., 1., 2.],
        [1., 3., 3.]])
>>> a[:,1]
tensor([1., 3.])
>>> a.shape
torch.Size([2, 3])
>>> a[:,1].shape
torch.Size([2])

上面例子可以看到a[:,1]变成了1维数组。

使用可迭代的整数队列指定项目提取时,情况就要复杂一些。它有以下几条规则:

  1. 在方括号中所有采用迭代对象做提取应用的,所有迭代对象必须拥有同样的长度,或者长度维1。
  2. 如果迭代对象的最大长度不是1, 而所有迭代对象中存在长度为1的。则长度为1的迭代对象在应用中将会被看做迭代对象长度为那个统一的最大长度。且,其数值全部为其原始整数的拷贝。例如a[[0], [1,2,3]]将被视作a[[0,0,0], [1,2,3]]。关于这一点,我们可以通过代码轻松验证:
>>> b = torch.tensor([[[0,1,2], [3,4,5]],[[6,7,8], [9,10,11]]])
>>> b[[0], [0,1], [0,1]]
tensor([0, 4])
>>> b[[0], :, [0,1]]
tensor([[0, 3],
        [1, 4]])

在这里b[[0], :, [0,1]]显然被当作了b[[0,0], : , [0,1]]来运算。然后又会被当作[b[0,:,0], b[0,:,1]]来看待。这里我们又要注意一个非常重要的点了。这个例子中前面的[0,0]的意义在于在第0维度选择两个相同的项目,后面的[0,1]则并不是每次都在第2维度选择第0个和第1两个项目的意思。后面的[0,1]的意义是在前面对应的第0个项目中在第2维度选择第0个项目,然后在前面对应的第1个项目中在第2维度选择第1个项目。它并没有在前面第0个项目中选择第0和第1,而是只选择0。这里就注意了,由于第2个迭代选择器选择的都是唯一项目,因此这就会发生维度的减少。例如前面的例子中b本来是2*2*3,总共3个维度的数据,而b[[0,0], :, [0,1]]变成了2*2两个维度的数据。这一切都因为后面的[0,1]选择了指定的一个数据(0只对应0, 1也只对应另外一个0),这一点就让b数组的最后一个维度发生了坍缩。

如果一个提取操作使用了n个迭代选择器,m个单1整数选择器,而原来数组的维度如果是t,则提取之后的数组的维度为 t - m - n + 1。

维度坍缩总结起来就是,如果遇见在某一个维度上只作整数式选择一个特定项目时,该维度就会发生坍缩。例如a[0:10, 4:5]在第1维度上4:5是选择了一个项目,但这里是作为数组来选择一个项目的,因此第1维度不会坍缩。但是如果a[0:10, 4]那就会发生坍缩了。然后,如果选择器中存在超过1个的迭代选择器,那么从第2个迭代选择器开始,每一个迭代选择器都等价于在前面的基础上只做整数式的选择。例如a[[0,1], [2,3]]。前面的[0,1]仍然是做数组式的选择,如果其后面没有[2,3],那么[0,1]几乎等价与0:2的效果。这里后面的[2,3]就不再是做数组式选择了,而是整数式地对前面的0, 做整数2的选择,对前面的1,做整数3的选择。

按步距提取

上文中提到过的提取方式,其实还有一个进一步的增强。那就是按照步距提取数据。方括号中的冒号可以再增加一个,然后第3个参数表示步距值。例如:

# array[start:end:step]
a = [1,2,3,4,5]
a[::2] = [1,3,5]
a[::-2] = [5,3,1]

#以上操作换成numpy.array也是一样的效果

步距的引入,可以进一步增强使用方括号做数据的提取操作。

不同维度间数组的运算

在很多人工智能或者数据处理的流程中,我们会经常遇见大量的数组处理问题。很多时候我们需要处理不同维度的数组,或者做改变数组维度等操作。一个优秀的工程师,尤其是一个愿意沉浸在数据变化过程中的工程师必须熟练的掌握处理不同维度的数组之间运算的问题。

在实际应用中,很多数据操作都会改变数组的维度。例如对3维数组的最后一维度做求和操作,则会把原来的数组变成2维的。

>>> a = torch.tensor([[[1,2,3]], [[2,3,4]]])
>>> a.sum(-1)
tensor([[6],
        [9]])
>>> a.sum(-1).shape
torch.Size([2, 1])
>>> a.shape
torch.Size([2, 1, 3])

然后,我们又会有很多运算会对原维度的数据和改变维度后的数组之间做四则运算。例如,我们想让a数组中最后一个维度的数据,每一个都减去其当前维度的和。比如说,我们想让torch.tensor([[[1,2,3]], [[2,3,4]]])变成 torch.tensor([ [[1-6, 2- 6, 3 - 6]], [[2-9, 3-9, 4-9]] ])。那我们里所当然想尝试这么做:

>>> a
tensor([[[1, 2, 3]],
        [[2, 3, 4]]])
>>> a - torch.tensor([6,9])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: The size of tensor a (3) must match the size of tensor b (2) at non-singleton dimension 2

那么,我们看到,这么直观的让a - [6,9]是不行的。要准确的实现我们的运算想法,就必须得先充分理解不同数组之间做运算的机制。

  1. 一般情况下,不同维度数量,或者同维度的长度不一样的数组之间是不能做四则运算的。标准的数组之间的四则运算只能运行在具备同样维度数量,且每个相对应维度的长度都一样长的数组之间进行。
  2. 不同维度,不同维度长度之间的数组之间只能在各自分别执行自动适配后,双双能满足1的条件后,它们才能达成运算条件。
  3. 数组的自动适配分两个步骤,第一,自动统一维度数量,这个步骤由维度数量少的数组,按最后一个维度对齐,然后再在前面补维度来与维度数量大的数组对齐;第二,在维度数量对齐后,分别对每个维度做长度的对齐操作,如果某个维度上它们长度相同,则看作已经对齐。如果长度比较短的那个数组在该维度的长度为1,则对该数字在该维度上做维度长度的扩张操作,且补齐的数据为那1个数据的拷贝。如果长度较短的那个数组在该维度的长度不为1,则视为此对数组无法自动适配。维度长度的对齐操作是从最后一个维度开始到第0维度结尾的顺序执行。

以上这段定义,可能主要是自动适配的描述不方便理解。下面举例说明:

>>> a
tensor([[[1, 2, 3]],
        [[2, 3, 4]]])
>>> b = torch.tensor([6,9])
>>> a - b

执行a - torch.tensor([6,9])的操作时,首先要让数组的维度数量对齐。a 的shape为[2,1,3], b的shape为[2], 于是首先b的shape会补充为[1,1,2], 即b变成[[[6,9]]]。然后,从最后一个维度开始做维度的长度对齐。b最后一个维度的长度为2, a为3。因此,a, b是无法自动适配了。因为有这条规定"如果长度较短的那个数组在该维度的长度不为1,则视为此对数组无法自动适配。"。因此,我们要想a - b能够执行,则需要b在完成第一步维度数量扩展后的shape为[1,1,1]或者[1,1,3]。我们改变下b的形态:

>>> b = torch.tensor([[6], [9]])
>>> a - b
tensor([[[-5, -4, -3],
         [-8, -7, -6]],

        [[-4, -3, -2],
         [-7, -6, -5]]])

我们这样改变以后,a - b能够顺利执行了。因为b 首先会适配成[[[6], [9]]],然后会适配成[ [[6,6,6], [9,9,9]], [[6,6,6], [9,9,9]] ], 这样b的shape变成了[2,2,3], 而a还是[2,1,3];那么因为a的第二维度的长度为1, 因此a的维度也会自动适配,适配之后的a变为[ [[1,2,3], [1,2,3]], [[2,3,4], [2,3,4]]]。所以这个时候做a - b运算,实际上,在完成互相适配后,是在做如下数组之间的运算:

a - b = [ [[1,2,3], [1,2,3]], [[2,3,4], [2,3,4]] ] - 
[ [[6,6,6], [9,9,9]], [[6,6,6], [9,9,9]] ]

因此也就得到了上述答案:

>>> a - b
tensor([[[-5, -4, -3],
         [-8, -7, -6]],

        [[-4, -3, -2],
         [-7, -6, -5]]])

但,这不是我们最初想要的答案。我们想要的是torch.tensor([[[1,2,3]], [[2,3,4]]])变成 torch.tensor([ [[1-6, 2- 6, 3 - 6]], [[2-9, 3-9, 4-9]] ])。为了达成我们的效果,我们可以做逆向思维。我们想要的答案需要对如下两个数组运算才能达到:

a - b = [ [[1,2,3]], [[2,3,4]] ] - 
[[[6,6,6]], [[9,9,9]]]

那么,接下来我们对[[[6,6,6]], [[9,9,9]]]做压缩,首先做维度长度的压缩,把这个数组变成[[[6]], [[9]]],这个数组的shape变成了[2,1,1],然后第0个维度的长度已经无法进行压缩了,因为[[6]]和[[9]]不相等。接下来,我们看维度数量能不能压缩,压缩维度数量只能搜索排序在前面而且维度长度为1的维度。这里,第0维度的长度是2,因此维度数量无法再进行压缩。所以最后要达成我们的效果b的最简形式为[[[6]], [[9]]]。接下来,让我们验证一下:

>> a 
tensor([[[1, 2, 3]],

        [[2, 3, 4]]])
>>> b = torch.tensor([[[6]], [[9]]])
>>> a - b
tensor([[[-5, -4, -3]],

        [[-7, -6, -5]]])

这个结果,是符合我们预期的。我们知道 a.sum(-1) = [[6], [9]]的,那我们如何优雅的让a.sum(-1)变成当前b的形态,然后再做a - b的运算呢?答案是,使用unsqueeze函数。

>>> a
tensor([[[1, 2, 3]],

        [[2, 3, 4]]])
>>> a.sum(-1).unsqueeze(-1)
tensor([[[6]],

        [[9]]])
>>> a - a.sum(-1).unsqueeze(-1)
tensor([[[-5, -4, -3]],

        [[-7, -6, -5]]])

unsqueeze函数能够给指定维度位置添加维度。我们可以利用它来改变数组的维度。

通过以上说明和示例,我们应该理解到,本质上不同维度形状的数组是不能做四则运算的。要进行四则运算,则需要自动适配。要自动适配成功,就必须满足一些条件。要想利用自动适配原理,来设计不同维度形状之间的数组做运算,可以采用反推的方法,看看当前的数组需要进行怎样变形或者不变形来达到自己的需求。

数组变形总结

在人工智能或者很多数据处理的应用中,我们都会遇见各种数组变形的问题。本文将会对pytorch和numpy中各种数组变形函数做个列表总结。

torch.unsqueeze

unsqueeze可以给数组增加一个维度,并指定维度增加的位置。例如如果有一个数组[1,2,3],我们希望给这个数组的0维位置增加一个维度变成[[1,2,3]],则可以

>>> torch.unsqueeze(torch.tensor([1,2,3]), 0)
tensor([[1, 2, 3]])

如果想给这个数组的最后一个维度,或者说,第1个维度增加维度变成[[1],[2],[3]]。则可以采取如下方案:

>>> torch.unsqueeze(torch.tensor([1,2,3]), -1)
tensor([[1],
        [2],
        [3]])
>>> torch.unsqueeze(torch.tensor([1,2,3]), 1)
tensor([[1],
        [2],
        [3]])

torch.flatten, numpy.flatten

这两个函数的作用一样,就是把多维度的数组变成一个维度的数组。

例如把[[1,2,3], [4,5,6]]变成[1,2,3,4,5,6]

torch.nn.flatten

这个flatten函数与前面的不同之处不仅仅在与它的定位是网络层。这里的flatten还可以指定flatten的维度的位置。例如把[[[1,2,3], [4,5,6], [7,8,9]]]转化为[1,2,3,4,5,6,7,8,9]或者[[1,2,3], [4,5,6], [7,8,9]]

>> a = torch.tensor([[[1,2,3], [4,5,6], [7,8,9]]])
>>> m = torch.nn.Flatten(0, -1)
>>> m(a)
tensor([1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> m = torch.nn.Flatten(0, 1)
>>> m(a)
tensor([[1, 2, 3],
        [4, 5, 6],
        [7, 8, 9]])

n维矩阵的转置

首先我们需要确定矩阵转置更广泛的定义,n维矩阵具有n个坐标轴。我们假设一个n维矩阵的n个坐标轴依次是a0, a1, a2, a3, ... an-1。对此矩阵的转置可以看作是构建一个新的n维矩阵,但是,要把原先坐标轴的顺序改变一下。这个改变可以是任意的,因此,在定义n维矩阵的转置时必须要定义一下坐标轴的排列顺序。在numpy中,如果没有定义此顺序,则默认的新顺序是原来坐标轴顺序的相反序列,即: an-1, an-2, ... a3, a2, a1。

如果,我们假设转置后的新的坐标轴顺序为at0, at1, at2, ...atn-1。并假设原矩阵为m, 转置后的矩阵为mt。那么,对于新的矩阵中的每一个元素,mt(i0,i1,i2, ...in-1) = m(j0, j1, j2, ..., jn-1), 其中, j0 = iat0, j1 = iat1, ... jn-1 = iatn-1。

简单的举个3维矩阵的例子:

>>> a = np.arange(24).reshape(2,3,4)
>>> a
array([[[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]],

       [[12, 13, 14, 15],
        [16, 17, 18, 19],
        [20, 21, 22, 23]]])

示例中矩阵a是一个3维矩阵,其中轴0有2个元素,轴1有3个元素,轴2有4个元素。如果我们对此矩阵做轴(0, 2, 1)的转置,则对于新的矩阵而言,存在如下的映射:

mt(0,0,0) = m(0,0,0) = 0
mt(0,0,1) = m(0,1,0) = 4
mt(0,0,2) = m(0,2,0) = 8

mt(0,1,0) = m(0,0,1) = 1
mt(0,1,1) = m(0,1,1) = 5
mt(0,1,2) = m(0,2,1) = 9

mt(0,2,0) = m(0,0,2) = 2
mt(0,2,1) = m(0,1,2) = 6
mt(0,2,2) = m(0,2,2) = 10

mt(0,3,0) = m(0,0,3) = 3
mt(0,3,1) = m(0,1,3) = 7
mt(0,3,2) = m(0,2,3) = 11

mt(1,0,0) = m(1,0,0) = 12
mt(1,0,1) = m(1,1,0) = 16
mt(1,0,2) = m(1,2,0) = 20

mt(1,1,0) = m(1,0,1) = 13
mt(1,1,1) = m(1,1,1) = 17
mt(1,1,2) = m(1,2,1) = 21

mt(1,2,0) = m(1,0,2) = 14
mt(1,2,1) = m(1,1,2) = 18
mt(1,2,2) = m(1,2,2) = 22

mt(1,3,0) = m(1,0,3) = 15
mt(1,3,1) = m(1,1,3) = 19
mt(1,3,2) = m(1,2,3) = 23

通过代码检验以上结果:

>>> a.transpose([0,2,1])
array([[[ 0,  4,  8],
        [ 1,  5,  9],
        [ 2,  6, 10],
        [ 3,  7, 11]],

       [[12, 16, 20],
        [13, 17, 21],
        [14, 18, 22],
        [15, 19, 23]]])

当我们因为应用需求,需要改变矩阵中坐标轴的排列顺序时,则需要使用转置操作。