计算分类值的精确度

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

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中的损失函数

损失函数是用来计算预测结果与期望结果之间的误差。pytorch作为一个成熟的人工智能框架,提供了很多损失函数供我们使用。

CrossEntropy

这个损失函数是在分类问题上经常用到的。它可以直接被用来比较[[a0,a1,a2],[b0,b1,b2]]和[pa, pb]之间的误差。这两个数组什么意义呢?a0,a1,a2的数字分别代表第0轴上的第0号元素在第1轴上3个分类的权重数值。而pa的取值可能是0,1和2。举一个实际例子说明,在对图像识别分类的问题上,我们给图像的种类用数字来分类。例如,这种图如果是狗,则其编号为0;是猫,则编号为1;是牛则编号为2。那么我们给出一个原始图像数据,然后通过一系列神经网络层后,这幅图片对应的数据变成了[a0,a1,a2],其中a0表示这种图片是狗的可能数值代表为a0, 是猫的可能数值代表为a1, 是牛的可能数值代表为a2。如果我们知道这个图片的实际上是一只猫,那就是说,这个图片的实际对应编号为1。那么我们的CrossEntropy就可以计算[a0,a1,a2]和1之间的差异。

从抽象的宏观角度来看,损失函数本质上给人们提供了一种对比不同意义的数据之间的差异。我们从来都是知道矢量和标量的对比是没有意义的,但是,损失函数就是对这个概率的挑战。

在数学公式上看,这个函数实际上是对比预测数组1中,在期望序号的那个数据与0之间的差异。即如果假设预测数组为[a0,a1,a2],而期望序号为i, 那么这个损失函数计算的值为 0 - log(e^ai/ (e^a0 + e^a1 + e^a2))的大小。如果有很多组数据的话,例如[[a0, a1, a2], [b0, b1, b2]]。那么要衡量多组数据的整体误差,则可以是计算各组误差的平均值,或者计算这些误差的和。

pytorch中神经网络层的分类

在pytorch的实际应用中会接触到非常多的网络层的搭建,本文专门用来整理pytorch框架中神经网络层的资料

线性网络层 Linear

线性网络接受输入数据,在forward运算中对输入数据的最后一个维度的数据做线型运算。在定义线性网络时需要定义输入数据的尺寸和输出数据的尺寸。这里需要明确的一点,在实际应用中这里定义的尺寸指的是输入数据中最后一个维度的尺寸。Linear网络层并没有限制输入数据的维度。例如,我们定义一个线性网络L = Linear(3, 4)可以接受[1,2,3]为输入数据,也可以接受[[1,2,3], [4,5,6]]或者[[[1,2,3]],[[4,5,6]]]

>>> a = torch.tensor([1,2,3], dtype = torch.float)
>>> l(a)
tensor([ 0.3342,  0.3121, -0.4418,  0.2728], grad_fn=<AddBackward0>)
>>> a = torch.tensor([[1,2,3]], dtype = torch.float)
>>> l(a)
tensor([[ 0.3342,  0.3121, -0.4418,  0.2728]], grad_fn=<AddmmBackward>)

平铺网络层 Flatten

平铺网络层能够压缩数据维度,调整数据的形态为后面的网络层或者优化参数做准备。需要注意的是Flatten可以指定平铺的维度的范围,而不是像torch.flatten或者numpy.flatten那样直接把所有的维度的数据都压缩到一个维度。

>> a = torch.tensor([[[1,2,3], [4,5,6], [7,8,9]]])
>>> m = torch.nn.Flatten(0, -1) //指定平铺0维到最后1维
>>> m(a)
tensor([1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> m = torch.nn.Flatten(0, 1) //指定平铺0,到1维
>>> m(a)
tensor([[1, 2, 3],
        [4, 5, 6],
        [7, 8, 9]])

2维卷积层 Conv2d

卷积层对处理和分析图像是十分有用的。使用卷积处理图片可以很大的提升图片处理的效率。

pytorch中的卷积层构建时关键的参数是输入Channels和输出Channels。黑白图片的输入Channels是1,彩色图片的输入Channels是3。要记住,Conv2d接受到的输入矩阵维度排序是(N, C, H, W),而通过opencv等图片处理库导入图片之后图片数据的维度排序是(H, W, C) 。 这里N是代表图片数的维度,C代表每个像素点的维度,H代表图片的高度,W代表图片宽度。

对(N, C, H, W)维度的输入经过卷积处理后得到的结果为(N, Cout, Hout, Wout),其中Cout的数据为构建卷积时输入的参数,然后Hout和Wout分别为:

Hout = (H + 2*padding[0] - dilation[0] * (kernel_size[0] - 1) - 1)/stride[0]

Wout = (W + 2*padding[1] - dilation[1] * (kernel_size[1] - 1) - 1)/stride[1]

我作了一个图专门来描述padding, dilation, stride, kernel_size等参数的定义

ReLU层

ReLU层最基本的可以给当前的数据做一个洗牌,把小与0的数据去除掉。

AvgPool2d层

所有的Pool层做的工作都是downsample的工作,把原先的数据在某些指定的轴上减少维度。AvgPool2d层就是减少图片数据的高和宽轴上的维度,并且对被减少的部分用它们的平均值来代替。

AvgPool2d的原型如下:

CLASStorch.nn.AvgPool2d(kernel_sizestride=Nonepadding=0ceil_mode=Falsecount_include_pad=Truedivisor_override=None)

经过AvgPool2d网络层的处理后,输入数据的形态和输出数据形态的关系如下:

假设输入数据为: (N, C, H_{in}, W_{in})(N,C,Hin​,Win​) 或者 (C, H_{in}, W_{in})(C,Hin​,Win​).

输出数据为: (N, C, H_{out}, W_{out})(N,C,Hout​,Wout​) 或 (C, H_{out}, W_{out})(C,Hout​,Wout​)。那么:

Hout = (Hin + 2 * padding[0] - kernel_size[0])/stride[0] + 1
Wout = (Win + 2 * padding[1] - kernel_size[1])/stride[1] + 1

需要注意的是,这里stride的默认值是与kernel_size的至相等,而不是像Conv2d层,stride的默认值为1。

AdaptiveAvgPool2d层

其作用的原理基本与上面的AvgPool2d相同,不同的是这里接受的参数是output数据的参数。AvgPool2d接受的kernel_size,然后根据kernel_size, padding, stride等参数来导出output数据。而AdaptiveAvgPool2d是指定output数据的H,W的大小,然后系统自动判断生成padding, kernel_size等。

数组变形总结

在人工智能或者很多数据处理的应用中,我们都会遇见各种数组变形的问题。本文将会对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]])