PyTorch

简介

PyTorch 是基于 Python 的科学计算包,主要有两大特色:

  • 用于多维数组的计算,可以作为 NumPy 的替代品。PyTorch 具有 GPU 加速的优势
  • 提供了许多深度学习方面的开发工具,如神经网络库、优化算法、自动微分等

Tensor

PyTorch 中主要的计算对象是 Tensor,就如同 NumPy 中的 ndarray 一样。不过所有基于 Tensor 的计算,都拥有 GPU 加速带来的性能优势。Tensor 支持丰富的计算操作,同时 PyTorch 会为这些操作维护计算图,以便实现自动微分。

Tensor 创建

如果之前使用过 NumPy,那应该对下面这些创建 Tensor 的接口不会陌生,它们跟 NumPy 中创建 ndarray 的接口名称一样且功能类似。

import torch as th

x = th.tensor([[1, 2], [3, 4]])
print(x)
print(x.size())

x = th.empty(3, 5)
print(x)

y = th.empty_like(x)
print(y)

x = th.zeros(3, 5)
print(x)

y = th.zeros_like(x)
print(y)

x = th.ones(3, 5, dtype=th.long)
print(x)

y = th.ones_like(x)
print(y)

x = th.eye(4)
print(x)

x = th.eye(4, 4)
print(x)

x = th.full((2, 3), -1.0)
print(x)

y = th.full_like(x, -2, dtype=th.int)
print(y)

x = th.randn(3, 5)
print(x)

y = th.randn_like(x, dtype=th.double)
print(y)
tensor([[1, 2],
        [3, 4]])
torch.Size([2, 2])
tensor([[7.7885e+34, 6.2618e+22, 4.7428e+30, 4.6172e+24, 1.4607e-19],
        [1.1578e+27, 7.8981e+34, 1.2121e+04, 7.1846e+22, 6.7437e+22],
        [4.2326e+21, 1.6931e+22, 0.0000e+00, 0.0000e+00, 2.1019e-44]])
tensor([[7.6059e+31, 1.7860e+25, 2.8930e+12, 7.5338e+28, 1.8037e+28],
        [3.4740e-12, 1.4583e-19, 1.1578e+27, 1.1362e+30, 7.1547e+22],
        [4.5828e+30, 1.2121e+04, 7.1846e+22, 5.7453e-44, 2.1019e-44]])
tensor([[0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.]])
tensor([[0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.]])
tensor([[1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1]])
tensor([[1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1]])
tensor([[1., 0., 0., 0.],
        [0., 1., 0., 0.],
        [0., 0., 1., 0.],
        [0., 0., 0., 1.]])
tensor([[1., 0., 0., 0.],
        [0., 1., 0., 0.],
        [0., 0., 1., 0.],
        [0., 0., 0., 1.]])
tensor([[-1., -1., -1.],
        [-1., -1., -1.]])
tensor([[-2, -2, -2],
        [-2, -2, -2]], dtype=torch.int32)
tensor([[-0.5801,  0.6799, -0.1544,  0.3945,  1.6921],
        [ 0.8022,  0.6898,  0.4960, -0.7121,  1.4779],
        [-0.7273,  0.5356, -1.2367,  0.3584,  0.3766]])
tensor([[-0.7955,  0.3765, -2.1934,  0.2224, -1.9504],
        [-0.7725, -0.4679, -1.2800,  0.4111,  0.9966],
        [-0.8036, -0.4600,  1.5876,  1.7226, -0.1928]], dtype=torch.float64)

要注意,Tensor 的维度可以是零维的(也即是标量),如:torch.tensor(3.14) 就是一个零维 Tensor,不要将它跟 torch.tensor([3.14]) 相混淆,后者是一个一维 Tensor。

import torch

a = torch.tensor(3.14)
b = torch.tensor([3.14])
print(a, b)
print(a.size(), b.size())
print(a.item())  # get the value of scalar
tensor(3.1400) tensor([3.1400])
torch.Size([]) torch.Size([1])
3.140000104904175

Tensor 运算

Torch 支持丰富的 Tensor 运算操作,并且这些运算都有两大特点:

  • 支持 CUDA 加速
  • 支持自动微分
import torch as th

x = th.rand(2, 2)
y = th.rand(2, 2)
z = th.empty(2, 2)

print(x)
print(y)

z = x + y
print(z)

z = th.add(x, y)
print(z)

th.add(x, y, out=z)
print(z)

y.add_(x)  # add x to y in-place
print(y)

print(z)
z.zero_()
print(z)

Tensor 索引

索引语法跟 NumPy 中索引 ndarray 是类似的。

import torch as th

x = th.tensor([[1, 2, 3], [4, 5, 6]])

print(x[0, 0])
print(x[0, :])
print(x[0:1, :])
print(x[0:1, 0:1])
print(x[0:1, 0:1].item())  # if the tensor just have one number, item() can return its scalar

Tensor 重塑

import torch

print("change shape")
x = torch.randn(4, 4)
y = x.view(-1, 8)  # the size -1 is inferred from other dimensions
print(x.size(), y.size())

print("add new axis")
y = x.view(-1, 4, 4)
z = x.view(1, 4, 4)
print(x.size(), y.size(), z.size())
a = x.unsqueeze(0)
b = x.unsqueeze(1)
print(x.size(), a.size(), b.size())
a = x[None]
b = x[:, None]
print(x.size(), a.size(), b.size())

print("remove axis")
y = x.view(16)
z = x[:,None]
a = z.squeeze()  # remove all the dimensions with size 1, removed
b = z.squeeze(1)  # remove 1-dim if its size is 1, removed
c = z.squeeze(0)  # remove 0-dim if its size is 1, cannot remove
print(x.size(), y.size(), z.size(), a.size(), b.size(), c.size())

处理 NumPy 数组

Torcch 中的 Tensor 和 NumPy 中的 ndarray,可以实现相互转换。并且转换后它们共享数据储存,改变一个对象,另外一个对象也会被改变。

import numpy
import torch

print("Torch Tensor to NumPy Array")

a = torch.ones(3)
b = a.numpy()
print(a, b)

a.add_(1)  # change a(tensor), will change b(ndarray)
print(b)
b += 1
print(a)  # change b(ndarray), will change a(tensor)


print("NumPy Array to Torch Tensor")
c = numpy.ones(3)
d = torch.from_numpy(c)
print(c, d)

numpy.add(c, 1, out=c)  # change c(ndarray), will change d(tensor)
print(d)
d.add_(1)
print(c)

Tensor 和 CUDA

Torch 中的 Tensor 支持利用 CUDA 进行计算,可以自由的在 CUDA 和 CPU 计算设备之间进行切换。

import torch

# let us run this cell only if CUDA is available
# We will use ``torch.device`` objects to move tensors in and out of GPU
if torch.cuda.is_available():
    device = torch.device("cuda")          # a CUDA device object
    y = torch.ones_like(x, device=device)  # directly create a tensor on GPU
    x = x.to(device)                       # or just use strings ``.to("cuda")``
    z = x + y
    print(z)
    print(z.to("cpu", torch.double))       # ``.to`` can also change dtype together!
else:
    print("CUDA isn't available!")

从上面的例子可以知道,我们既可以在创建 Tensor 对象时,通过参数 device 来为其指定计算设备,也可以在创建完成以后通过方法 to(device)来切换计算设备。

Tensor 对象深入

经过上面的简单介绍后,让我们深入了解一下 Tensor 对象。

Tensor 创建

torch.tensor

类似于 NumPy 中 numpy.array 的作用,通过复制其它数据对象来创建。

接口如下所示:

torch.tensor(data, dtype=None, device=None, requires_grad=False)
  • data:复制该对象的数据来创建 Tensor
  • dtype:指定 Tensor 的数据类型
  • device:指定计算设备
  • requires_grad:是否为该变量开启自动微分

示例:

import torch

print(torch.tensor([]))  # create a empty tensor with size of (0,), one-dim

print(torch.tensor([1]))  # create a tensor with size of (1,), one-dim

print(torch.tensor(1.0))  # create a scalar, zero-dim

print(torch.tensor([[1, 2], [3, 4]]))  # auto infer dtype int

print(torch.tensor([[1.0, 2.0], [3.0, 4.0]]))  # auto infer dtype float

l = [[1, 2], [3, 4]]
t = torch.tensor(l)
l[0][0] = -1
print(t)  # don't change the tensor

要注意区分维度数量和维度尺寸的区别:一个 Tensor 有可能零个或多个维度,每个维度有各自的尺寸(大于等于零)。因此 torch.tensor(1.0)torch.tensor([1.0]) 显然是不同的,前者是零维的,后者是一维的。

torch.arange

类似于 NumPy 中 numpy.arange 的作用,快速创建一维序列。

接口如下所示:

torch.arange(start=0, end, step=1, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False)

注:序列范围是 [start, end),不包括 end

示例:

a = torch.arange(10)

print(a)  # 0...9

b = torch.arange(12).view(3, 4)
print(b)

c = torch.arange(0, 12, 3)
print(c)

由于浮点数精度的有限性,torch.arange 跟 NumPy 中的 numpy.arange 一样都无法创建确定长度的序列,如果想要创建确定长度的序列,就需要用到 numpy.linspacetorch.linspace 了。

torch.linspace

类似于 Numpy 中的 numpy.linspace,用来创建指定长度的序列。

接口如下所示:

torch.linspace(start, end, steps=100, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False)

注:序列的范围是 [start, end];其中的参数 steps 指定期望返回的序列长度。

示例:

a = torch.linspace(1, 10, 3)

print(a)

此外还有作用类似的接口 torch.logspace,不过它的序列范围是 [pow(10, start), pow(10, end)]

随机采样

利用各种概率分布,通过采样来创建 Tensor。

常用采样接口如下:

  • torch.randn(sizes, ...):从标准正态分布进行采样
  • torch.randn_like(input, ...):从标准正态分布进行采样
  • torch.normal(mean, std, ...):从一般正态分布进行采样
  • torch.rand(sizes, ...):从 [0, 1) 进行均匀采样
  • torch.rand_like(input, ...):从 [0, 1) 进行均匀采样
  • torch.randint(low=0, high, sizes, ...):从 [low, high)进行整数均匀采样
  • torch.randint_like(input, low, high, ...):从 [low, high)进行整数均匀采样
  • torch.randperm(n):返回 [0, n-1] 的随机排列

Tensor 运算操作

Tensor 支持常见的各种向量、矩阵等数学运算操作,具体参见文档

常用的运算和函数:

torch.clamp

接口如下所示:

torch.clamp(input, min, max, out=None)

返回新的张量,若 input 张量中的元素小于 min 则取 min,大于 max 则取 max,其它元素跟 input 保持一致。

备注:可以使用该函数实现 ReLU 激活函数。

示例:

a = torch.randn(2, 2)

b = torch.clamp(a, -.9, .9)

print(a)
print(b)

print(torch.clamp(a, min=0.0))
print(torch.clamp(a, max=0.0))

torch.erf

接口如下所示:

torch.erf(tensor, out=None)

该函数在计算科学中有广泛的应用。

备注:可以用来计算标准正态分布的累积函数以及 Probit 函数。

torch.lerp

接口如下所示:

torch.lerp(start, end, weight, out=None)

返回值等于 start + weight*(end-start),其中weight 是实数,startend 是张量,该计算基于张量的元素进行。

示例:

a = torch.zeros(10)
b = torch.ones(10)
w = 0.34

c = torch.lerp(a, b, w)
print(c)

torch.sigmoid

接口如下所示:

torch.sigmoid(input, out=None)

torch.argmin 和 torch.argmax

接口如下所示:

torch.argmin(input, dim=None, keepdim=False)
torch.argmax(input, dim=None, keepdim=False)

返回张量 input 某个维度上,最小值/最大值所在位置的索引值。同 NumPy 一样,这里要把 dim 指定的维度,想象成要坍缩的方向。

示例:

a = torch.randn(3, 4)

print(a)
print(torch.argmax(a))  # 没有指定 dim 时,返回整个张量中最值的索引位置

print(torch.argmin(a, 1))
print(torch.argmin(a, 0))

torch.min 和 torch.max

该接口用多种调用方式:

  1. torch.min(input) 返回张量 input 中的最小值。
  2. torch.min(input, dim) 返回张量 input 中的某个维度的最小值及其索引值。
  3. torch.min(input, other) 基于元素的操作,返回两个张量 inputother 中元素较小的那个

torch.sum 和 torch.prod

它们都有两种调用方式:

(input, dtype=None) 
(input, dim, keepdim=False, dtype=None)

分别用来求整个张量的和/积,或某个维度的和/积。

示例:

a = torch.randn(2, 2)

print(a)
print(torch.sum(a))
print(torch.sum(a, dim=1))

torch.cumprod 和 torch.cumsum

接口如下所示:

torch.cumprod(input, dim, dtype=None)
torch.cumsum(input, dim, dtype=None)

对张量 input 某个维度进行累乘/累加。

示例:

a = torch.arange(1, 10)

print(torch.cumprod(a, dim=0))

print(torch.cumsum(a, dim=0))

torch.norm

接口如下所示:

torch.norm(input, p=2)
torch.norm(input, p, dim, keepdim=False, out=None)

计算张量的模,通过参数 p 来指定计算几阶模。

示例:

a = torch.ones(3, 4) * -1

print(a)
print(torch.norm(a))
print(torch.norm(a, dim=1))

torch.dist

接口如下所示:

torch.dist(input, other, p=2)

计算张量 input - other 的模,其中参数 p 指定计算几阶模。

示例:

a = torch.zeros(2, 3)
b = torch.ones(2, 3)

print(torch.dist(a, b))
print(torch.sqrt(torch.tensor(2*3.)))

统计量

有关统计的接口:

  • torch.mean
  • torch.std
  • torch.var
  • torch.mode
  • torch.median

比较运算

相关接口:

  • torch.ne
  • torch.eq
  • torch.gt
  • torch.ge
  • torch.lt
  • torch.le
  • torch.kthvalue:第 k 个最小值
  • torch.topk:第 k 个最大值
  • torch.sort

特殊张量

  • torch.isnan(input):是不是 NaN
  • torch.isinf(input):是不是 +/-INF
  • torch.isfinite(input):是不是 Finite

示例:

a = torch.isnan(torch.tensor([1, float('nan'), 2]))
print(a)

b = torch.isinf(torch.Tensor([1, float('inf'), 2, float('-inf'), float('nan')]))
print(b)

c = torch.isfinite(torch.Tensor([1, float('inf'), 2, float('-inf'), float('nan')]))
print(c)

其它

Tensor 连接

将若干个 Tensor 连接合并为一个新的 Tensor。

接口:

torch.cat(seq, dim=0, out=None)

其中 dim 指定连接的方向。

示例:

a = torch.randn(2, 3)
b = torch.randn(4, 3)
print(torch.cat((a, b)))  # cat by row

c = torch.randn(3, 4)
d = torch.randn(3, 5)  
print(torch.cat((c, d), dim=1))  # cat by column
tensor([[-0.1520, -0.6978, -0.1785],
        [-0.5567,  0.2475,  0.6820],
        [-0.8564,  1.3369, -0.3055],
        [ 0.3720,  1.9155,  0.9682],
        [-0.4323,  1.5643, -1.9333],
        [ 1.5893, -1.2790,  0.9977]])
tensor([[ 0.1344,  2.2658, -0.2789, -0.8518,  0.3390,  1.8597,  1.6467,  0.0927,
         -2.0039],
        [ 0.1801, -0.1230, -0.1447,  0.0839, -0.4457, -1.5881,  0.2466, -0.0933,
          0.2281],
        [ 0.8701,  0.8677, -0.4961, -1.2346, -1.2981,  0.8937, -0.3769,  0.9773,
          0.8147]])

Tensor 分割

将一个 Tensor 分割为若干个 Tensor。

接口:

torch.chunk(tensor, chunks, dim=0)

其中:chunks 指定块的数目,dim 指定分割方向,如果不能等分的话,最后一个块会比较小。

示例:

a = torch.arange(20).view(4, 5)

print(torch.chunk(a, 2))  # 按照行,分为两块,可以等分
print(torch.chunk(a, 2, dim=1))  # 按照列,分为两块,不可等分
(tensor([[0, 1, 2, 3, 4],
        [5, 6, 7, 8, 9]]), tensor([[10, 11, 12, 13, 14],
        [15, 16, 17, 18, 19]]))
(tensor([[ 0,  1,  2],
        [ 5,  6,  7],
        [10, 11, 12],
        [15, 16, 17]]), tensor([[ 3,  4],
        [ 8,  9],
        [13, 14],
        [18, 19]]))

自动微分

自动微分技术是现代深度学习框架的一大特色,MXNet,TensorFlow 等框架都提供了自动微分工具,使得梯度的求解对于机器学习开发者变得透明。同样 Torch 也支持自动微分,通过子库 autograd 提供的自动微分技术,使得一切基于 Tensor 的运算操作都可以进行自动微分。

有了自动微分的支持,机器学习的算法中涉及到导数/梯度的求解,都不需要开发者来关心具体的实现细节,开发框架会在背后自动完成计算。比如在开发神经网络模型时,只要定义了模型结构,就也意味着同时定义了梯度求解方法,后向传播的过程对开发者变得透明。

Torch 中自动微分的实现是通过记录追踪计算对象 Tensor 和计算操作 Function 对象而构建计算图,有了计算图就有了完整的计算历史过程,进而可以实现自动微分。

计算图简介

计算图是以变量 Tensor 为节点,以计算操作 Function 为边,而构成的有向无环图。

在神经网络模型中,计算图中的叶节点一般是输入数据或者模型参数,根节点一般是损失函数值,其余节点是网络中隐含层等中间状态变量。当然,利用计算图实现自动微分,并不限于在神经网络模型中的应用,我们可以将这项技术应用到任何模型中

计算图和自动微分的关系:

  • 计算图是自动微分的基础,因为计算图中记录了变量计算操作的历史信息。
  • 计算操作是双向的:forward 根据输入变量计算输出变量,backward 根据输出变量的梯度值来计算输入变量的梯度值。
  • 通过 forward 传播,可以计算模型的输出值。
  • 通过 backward 传播,可以计算所有输入变量的梯度值。由于该过程是根据计算图,并运用链式求导法则,自动完成的梯度计算,因此被称为自动微分。

计算图对于 Torch 的使用者是透明的,主要体现在两方面:

  • 计算图的创建是透明的:基于 Tensor 的各种计算操作,其背后隐含了计算图的构建过程。
  • 计算图的销毁和管理也是透明的:为了高效的内存管理,Torch 会在合适的时机自动销毁计算图。

使用自动微分

Tensor 默认是不能自动微分的。要想使得某个 Tensor 可以进行自动微分,需要显式地进行声明,声明过后所有对该 Tensor 的运算操作都在背后隐含的构建了计算图。

显式开启自动微分:

x = torch.ones(2, 2, requires_grad=True)
print(x)

y = torch.ones(2, 2)
print(y)
y.requires_grad_(True)  # you can also use "y.requires_grad = True"
print(y)

隐式开启自动微分:在计算图中所有直接定义的 Tensor 叫做叶节点变量,而所有通过计算操作生成的变量则叫做非叶节点变量,一般情况下非叶节点是否启动自动微分将由生成它的其它变量来决定,我们是不能直接设置非叶节点的 requires_grad 属性的。

a = torch.ones(2, 2)
b = a + 1
print(b.requires_grad)

c = torch.ones(2, 2, requires_grad=True)
d = c + a  # 参与运算的变量中任意一个开启了自动微分,则结果变量也会开启
print(d.requires_grad)

# 下面的代码将引发错误
# d.requires_grad = False

较复杂的一种情况是,参与运算的变量有的开启了自动微分,有的没有开启:

a = torch.ones(2)
b = torch.ones(2)

c = a + b
print(c.requires_grad)  # a 和 b 均没有开启,因此 c 也没有开启

d = torch.ones(2, requires_grad=True)

c = c + d
print(c.requires_grad)  # 由于 d 开启了,因此 c 也开启

在开启自动微分后,我们就能够通过 Tensor 对象的 grad_fn 属性来引用生成它的计算操作对象,也即追溯计算历史记录。

x = torch.ones(5, requires_grad=True)
y = x + 1
z = y ** 2
a = z + 1
b = a ** 2
c = b.mean()

fn = c.grad_fn
while fn is not None and fn.next_functions:
    print(fn)
    fn = fn.next_functions[0][0]

梯度的计算

当声明 Tensor 启用自动微分时,关于它的计算操作历史被保存在计算图中。在计算梯度时,将按照计算图中的历史记录和链式求导法则,来自动完成梯度的计算。

示例如下:

a = torch.tensor(1.0, requires_grad=True)
b = a ** 3
c = b + 3
d = c * 2

print('d=2*(3+pow(a, 3))')
d.backward()
print(a.grad)

在上例中,后向传播的起点 b 是一个标量(零维 Tensor),b.backward() 的意思是:求计算图中其它变量,相对于该标量的导数值,也即求 d(d)/ d(a) 等。

再看一个较复杂的例子,其中后向传播的起点变量不是标量:

x = torch.ones(2, requires_grad=True)
y = x ** 2

try:
    # y 不是标量,需要传递参数给 backward 函数,这里没有传递参数,所以会报错
    y.backward()
except RuntimeError as e:
    print(e)

在解决这个问题之前,让我们先来看看,在非标量 Tensor 上调用 backward() 的意义:首先要明白,梯度的求解,总是相对于一个标量而言的,那么这里为什么又要在一个非标量上调用 backward(),并且传递一个参数?

假设现在有一个标量 l,它是关于非标量 y 的函数值,而 y 各个元素又都是关于 x 的函数值,也即有 l=l(y(x))。我们想要计算 x 相对 l 的梯度值(d(l)/d(x)),如果现在我们已经知道了 y 相对于 l 的梯度值 y_grad(即 d(l)/d(y)),根据链式法则有:d(l)/d(x) = (d(l)/d(y)) * (d(y)/d(x)) = y_grad * (d(y)/d(x)),因此我们只要再求出 d(y)/d(x) 就可以得到 x 相对 l 的梯度值。

而调用 y.backward() 正是用来求解 d(y)/d(x) 的,不过需要我们把 y 相对于标量 l 的梯度值作为参数传入,即有 y.backward(y_grad),这样就可以通过后向传播求解 x 相对于 l 的梯度值了。(注:这里 x 可以是计算图中以 y 为根节点的任意后代节点)

示例如下:

# 此函数是基于标量的 backward
def scalar_grad():
    x = torch.ones(2, requires_grad=True)
    y = x ** 2

    l = torch.sum(y)  # l is a scalar
    
    # backward start from l
    l.backward()

    print('d(l) / d(y) = [1.0, 1.0]')
    print('d(y) / d(x) = [2.0, 2.0]')
    print('d(l) / d(x) = (d(l)/d(y)) * (d(y)/d(x)) = {}'.format(x.grad))


# 此函数是基于非标量的 backward
def non_scalar_grad():
    x = torch.ones(2, requires_grad=True)
    y = x ** 2  # y is a tensor 1x2

    l = torch.sum(y)
    
    # backward from y
    # 这里我们手动计算了 d(l)/d(y),并将其作为参数输入 backward 函数
    # 提示:传入的参数是 y 的梯度值,因此其形状必然跟 y 是一样的
    y.backward(torch.tensor([1.0, 1.0]))
    
    print('d(l) / d(y) = [1.0, 1.0]')
    print('d(y) / d(x) = [2.0, 2.0]')
    print('d(l) / d(x) = (d(l)/d(y)) * (d(y)/d(x)) = {}'.format(x.grad))


print('backward from a scalar tensor')
scalar_grad()
print('backward from a non-scalar tensor')
non_scalar_grad()

中间变量的梯度值

之前都是展示了叶节点变量的梯度值,也即程序中定义变量的梯度值,那么中间变量的梯度值我们也可以通过后向传播得到吗?从原理上来讲,后向传播可以得到计算图中所有后代节点的梯度值,当然也包括中间变量了。不过处于优化的考虑,Torch 是不会缓存中间变量的梯度值的。

示例:

x = torch.randn(2, 2, requires_grad=True)
y = x ** 2
z = torch.sum(y)
z.backward()

print(x.grad)  # leaf node has gradient
print(y.grad)  # non-leaf node doesn't have gradient

如果想要访问这些中间变量的梯度值,可以通过 hook 来实现(hook 介绍),示例:

import torch

grads = {}
def save_grad(name):
    def hook(grad):
        grads[name] = grad
    return hook

x = torch.randn(2, 3, requires_grad=True)
y = x**2
z = torch.sum(y)

hy = y.register_hook(save_grad('y'))  # return a handle for hook
hz = z.register_hook(save_grad('z'))  # return a handle for hook

z.backward()

print(x.grad, grads['y'], grads['z'])

# remove hook
hy.remove()
hz.remove()

hook 将在变量的梯度值每次被计算时调用,hook 的接口:hook(grad) -> Tensor or None

除了通过 hook 外,我们还可以设置强制缓存非叶节点的梯度值(详见):

x = torch.randn(2, 3, requires_grad=True)
y = x**2
y.retain_grad()  # 设置保留非叶节点的梯度
z = torch.sum(y)

z.backward()
print(y.grad)

梯度值是累加的

什么是梯度的累加?

例如:变量 x 既是关于变量 y 的自变量,又是关于 z 的自变量,那么 y.backward() 调用后会得到 x 相对于 y 的梯度值并保存于 x.grad 中,再调用 z.backward() 计算 x 相对于 z 的梯度值后,x.grad 中保存的则是前后两次计算梯度值的累加。

import torch

x = torch.tensor(1.0, requires_grad=True)
y = x
z = x

z.backward()
print(x.grad)

y.backward()
print(x.grad)

Torch 为什么要设计成梯度累加的?

变量的梯度可能从不同地方流入的,根据链式求导法则有:

$$\frac {\partial y} {\partial x} = \sum_i \frac {\partial y} {\partial u_i} \frac {\partial u_i} {\partial x}$$

其中每个 $u_i$ 都会将梯度流向 $x$。在后向传播时,这些梯度会在不同时间到达 $x$,因此需要累加。

计算图销毁

之前提到,Torch 为了高效的利用内存,会在合适的时机,自动释放计算图,准确来讲每次完成后向传播,计算图就会被自动销毁。这是由于,一般情况下,后向传播完成,梯度已经计算好,再保留计算图就没有意义,因此 Torch 默认会在此时销毁计算图。

示例:

a = torch.randn(2, 2, requires_grad=True)
b = a ** 2
c = torch.sum(b)

# backward is ok
c.backward()
try:
    # backward again, will raise exception
    c.backward()
except RuntimeError as e:
    print(e)

根据异常的错误提示,要想要后向传播过后,计算图依然保留,可以传递参数 retain_graph=True,这样下次再调用 backward 时就不会报错:

a = torch.randn(2, 2, requires_grad=True)
b = a ** 2
c = torch.sum(b)

# backward is ok
c.backward(retain_graph=True)
# backward is ok too
c.backward()
# backward will raise exception, unless provide retain_graph=True in last call backward()
# c.backward()

再来看一个稍复杂的例子:

a = torch.randn(2, 2, requires_grad=True)
b = a ** 2
c = torch.sum(b)
d = torch.mean(b)

# backward is ok
c.backward()
try:
    # backward will raise exception
    d.backward()
except RuntimeError as e:
    print(e)

这里可以认为 cd 是同一个计算图中的两个根节点,通过其中任意一个后向传播,则计算图就会被销毁。

再来看神经网络模型中计算图的构建和销毁:

import torch

linear = torch.nn.Linear(5, 5)
for i in range(10):
    x = torch.randn(5)
    # 每次 forward 动态构建计算图
    out = linear(x)
    loss = torch.sum(out)
    # 每次 backward 后销毁计算图
    loss.backward()
    # 再次 backward 就会报错了
    # loss.backward()

可见每次 forward 都会自动构建计算图,然后在 backward 时被销毁,所以一般我们在迭代数据训练神经网络时,会调用 backward() 多次,但也不会出错。

停止自动微分

停止记录作用于某个 Tensor 上的计算操作:

x = torch.tensor([1., 2., 3.], requires_grad=True)
y = torch.tensor([1., 2., 3.], requires_grad=True)
out = x + y
print(out.grad_fn, out.requires_grad)
a = out.detach()  # 返回一个 Tensor 跟原 Tensor 共享数据储存,但以前的计算历史没有保留
print(a.grad_fn, a.requires_grad)  # a 的 grad_fn 属性为 None,没有了计算历史记录

a[0] = -1.0  # 新 Tensor 跟原来 Tensor 共享数据储存
print(out[0])
<ThAddBackward object at 0x0000000008696C88> True
None False
tensor(-1., grad_fn=<SelectBackward>)

注:通过上例可见,变量只是脱离了计算图,无法追溯以前的计算历史记录而已,数据储存还跟以前是一样的。

停止记录代码块中的所有 Tensor 的计算操作:

x = torch.ones(5, requires_grad=True)

with torch.no_grad():
    print((x+1).requires_grad)

通过访问 Tensor 的 data 属性可以避免计算操作被记录,属性 data 保存了跟原来 Tensor 一样的数据且共享内存:

import torch

x = torch.ones(5, requires_grad=True)
# x = x ** 2
x.data = x.data ** 2

y = x.sum()

print(x.grad)

detach VS. data

在实际用中更加推荐使用 detach() 而不是 .data 来返回一个不被计算图记录的 Tensor 引用。这是因为,如果修改了返回的 Tensor,则原 Tensor 也会发生变化,若后向传播计算梯度时又用到原来的 Tensor,则可能会出现 bug。使用 detach() 的好处在于,在遇到这种情况时,后向传播会报错,而 .data 则不会。

摘自官方的示例:

# 使用 out.detach() 返回 c,若 c 改变引起 out 改变,则后向传播时会报错
a = torch.tensor([1,2,3.], requires_grad = True)
out = a.sigmoid()
c = out.detach()

c.zero_()  # 同时改变了 c 和 out

try:
    out.sum().backward()  # 报错
except RuntimeError as e:
    print(e)

out = a.sigmoid()
c = out.data

c.zero_()  # 同时改变 c 和 out

out.sum().backward()  # 不报错,但梯度 a.grad 是错误的
print(a.grad)

自定义自动微分运算操作

支持自动微分的运算操作,其实是由一对函数组成的,其中 forward 函数根据输入变量计算输出变量,backward 函数则根据输出变量相对于某个标量的梯度值来计算输入变量相对于该标量的梯度值。

不论是 Tensor 重载的运算符,还是 torch.sum, torch.sigmoid 等各种运算操作,本质上都是由 forward 和 backward 函数构成的而已。要自定义自动微分运算操作,需要继承 torch.autograd.Function 父类,并实现这两个函数。

摘自官网的示例,定义以及使用 ReLU 运算操作:

import torch

# 定义运算操作
class MyReLU(torch.autograd.Function):

    @staticmethod
    def forward(ctx, input):
        """
        In the forward pass we receive a Tensor containing the input and return
        a Tensor containing the output. ctx is a context object that can be used
        to stash information for backward computation. You can cache arbitrary
        objects for use in the backward pass using the ctx.save_for_backward method.
        """
        ctx.save_for_backward(input)
        return input.clamp(min=0)

    @staticmethod
    def backward(ctx, grad_output):
        """
        In the backward pass we receive a Tensor containing the gradient of the loss
        with respect to the output, and we need to compute the gradient of the loss
        with respect to the input.
        """
        input, = ctx.saved_tensors
        grad_input = grad_output.clone()
        grad_input[input < 0] = 0
        return grad_input

# 使用该运算操作
a = torch.tensor([1., -1., 2.], requires_grad=True)
b = MyReLU.apply(a)
b.sum().backward()
print(a.grad)

固定模型的部分

通过模型一部分的变量设置为 requires_grad=False,可以使得利用后向传播时,这部分变量不会被计算梯度

如:

import torch

x = torch.randn(2, 2)
y = torch.randn(2, 2)

z = x + y  # requires_grad is False
print(z.requires_grad)

a = torch.randn(2, 2, requires_grad=True)

z = z + a
print(z.requires_grad)

神经网络

通过 Tensor 和自动微分理论上可以实现任何神经网络模型,但手动构建模型,指定输入、参数、输出以及损失函数等,较为麻烦。Torch 的子库 nn 提供了更高的抽象结构,可以用来快速构建神经网络模型,并且所有运算操作都是基于自动微分技术的。

手写识别实例

以上是一个用来进行手写数字识别的卷积神经网络(CNN)结构:输入图片是尺寸为 32x32 的单通道图片;卷积层 C1 有 6 个 28x28 的特征图(也即感知域大小为 5x5,移动步长为 1);子采样层 S2 有 6 个 14x14 的子图(即子采样区域大小为 2x2)。卷积层 C3 的输入是大小为 14x14 的子采样图且通道为 6,该层有 16 个 10x10 的特征图(也即感知域大小为 5x5,移动步长为 1);子采样层 S4 有 16 个 5x5 的子图(即子采样区域大小为 2x2);接下来的全连接层 C5 含有 120 个隐单元;全连接层 F6 含有 84 个隐单元;输出层含有 10 个神经元(跟 10 个阿拉伯数字相对应)。

用 Torch 来对该 CNN 建模如下(参照官方文档,略做改动):

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F


class Network(nn.Module):

    def __init__(self):
        super().__init__()
        # first conv layer: 
        # input 1-channel image, output 6-channel feature maps.
        # convolution kernel size is 5x5. gap is 1.
        self.conv1 = nn.Conv2d(1, 6, (5, 5))
        # second conv layer:
        # input 6-channel feature maps, output 16 feature maps.
        # convolution kernel size is 5x5. gap is 1.
        self.conv2 = nn.Conv2d(6, 16, (5, 5))
        # Full connection layers:
        # the last convolution layer output 16 feature maps of 5x5 size
        feature_num = 16*5*5
        self.fc1 = nn.Linear(feature_num, 120)  # affine transform
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        """`x` is a mini-batch of samples, not a single sample."""
        # max pooling window size is 2x2
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv2(x)), (2, 2))
        x = x.view(x.size()[0], -1) # flatten feature maps
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

学习框架

基于神经网络的机器学习算法,一般具有如下学习流程:

  1. 根据神经网络的结构,来创建模型(含参)

  2. 迭代训练数据来学习模型参数,数据一般以 mini-batch 为单位进行迭代,不断重复下面的几个步骤:

    1. Forward:数据从神经网络输入端流向输出端,以计算输出值
    2. Loss:计算输出值和真实值之间的误差值,以及输出值的误差梯度
    3. Backward:误差梯度从神经网络的输出端流向输入端,以计算网络参数的梯度值
    4. Update Parameters:利用参数的梯度信息,采取特定策略来更新参数值,以减小网络的误差值
  3. 利用测试数据评估模型性能(也有可能会在每次处理完一个 mini-batch 时进行)

利用该流程,处理上例中的手写数字识别问题,伪代码如下:

epoch = 100
learning_rate = 0.001

train_data, test_data = load_data()

net = Network()
loss_fn = MeanSquaredError()
optimizer = SGD(net.parameters(), learning_rate)

for e in range(epoch):
    # 每次迭代训练集时先将其随机打乱
    train_data = random_shuffle(train_data)
    # 迭代处理 mini-batch
    for mini_batch in train_data:
        inputs, targets = mini_batch
        # Forward
        outputs = net.forward(inputs)
        # Loss
        loss = loss_fn(outputs, targets)
        # Backward
        optimizer.zero_grad()  # 反向传播之前先清空梯度
        loss.backward()
        # Update Parameters
        optimizer.step()
    # 评估模型性能
    test_inputs, test_targets = test_data[:,0], test_data[:,1]
    loss = loss_fn(net.forward(test_inputs), test_targets)

抽象组件

  • 网络层:网络层是神经网络的基本结构,包含若干神经元和可调节的参数,数据输入并流出网络层。Torch 提供了 torch.nn.Module 用来表示网络层。
  • 损失函数:不同模型需要使用不同的损失函数来度量预测值和真实值之间的差异。torch.nn 提供了许多常见的损失函数。
  • 优化方法:神经网络模型一般通过梯度信息来调节网络中的参数,torch.optim 提供了不同的调节机制。

一个隐含层的全连接网络模型,示例:

import torch

n = 100
epoch = 5
lr = 0.001
input_dim, hidden_dim, output_dim = 10, 66, 5

# 定义模型:使用 torch.nn.Sequential 将多个 Module 按顺序连接起来
model = torch.nn.Sequential(
    torch.nn.Linear(input_dim, hidden_dim),  # affine layer 含有可调节参数 weights 和 bias 等
    torch.nn.ReLU(),                         # element-wise 非线性变换
    torch.nn.Linear(hidden_dim, output_dim)  # affine layer 将隐状态转换到输出层
)

# 定义损失函数:Mean Squared Error
loss_fn = torch.nn.MSELoss(reduction='sum')

# 定义优化方法:SGD
optimizer = torch.optim.SGD(model.parameters(), lr=lr)

# 构造训练数据
x = torch.randn(n, input_dim)
y = torch.randn(n, output_dim)

# 开始迭代训练
for e in range(epoch):
    t = model(x)
    loss = loss_fn(t, y)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    print(loss.item())

从上例可见,通过对网络层、损失函数、优化算法的抽象封装后。如何定义网络细节结构、如何定义可调节参数、如何在网络中后向传播、如何利用梯度信息更新网络参数等,这些逻辑都变得透明了。这就是抽象神经网络结构的好处。

自定义网络层

可以通过集成 torch.nn.Module 父类来自定义网络层,子类需要实现从输入映射到输出的逻辑,至于梯度的后向传播逻辑则由 Tensor 的自动微分来实现。

自定义含有一个 sigmoid 隐含层的网络层:

import torch
from torch import nn

class NetSigmoid(nn.Module):
    
    def __init__(self, in_dim, hidden_dim, out_dim):
        super().__init__()
        self.fc1 = nn.Linear(in_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, out_dim)
    
    def forward(self, x):
        """x is a mini-batch of sample."""
        return self.fc2(self.fc1(x).sigmoid())

net = NetSigmoid(10, 20, 5)
x = torch.randn(1, 10)
print(net(x))

父类 nn.Module 主要的工作:

  • 管理内部的组件以及参数
  • 负责后向传播的处理
  • 可以让网络层在不同计算设备之间灵活的切换

神经网络组件详解

线性网络层

线性变换

torch.nn.Linear(in_features, out_features) 用以表示仿射变换(affine map),又叫线性变换:

$$ f(x) = Ax + b $$

其中 $A$ 和 $b$ 是参数,$x$ 是输入。

一般在线性代数中,$x$, $b$ 都是列向量,结果 $f(x)$ 也是列向量。不过在 Torch 中,我们一般采用的是行向量,即:

$$ f(x) = xA^\top + b $$

其中 $x$, $b$ 以及结果 $f(x)$ 都是行向量。

由于 nn.Linear 内部会负责维护参数 $A$ 和 $b$,所以对于使用者来说,不需要关心这些参数,只需要注意的是,传入的输入值 $x$ 必须是行向量,也即如果 $x$ 表示一个数据点,则它应该是一个一维 Tensor,如果 $x$ 表示多个数据点(或一个 mini-batch )的话,它应该是一个二维 Tensor 且每一行表示一个数据点。

示例:

import torch
from torch.nn import Linear

in_dim, out_dim = 3, 5
f = Linear(in_dim, out_dim)  # Linear 组件内部包含了参数 A 和 b
x = torch.randn(10, in_dim)  # 10 个输入向量,每行一个
print(f(x))  # 结果也是 10 个行向量
tensor([[-0.3402, -0.9186,  0.5469,  0.9874, -0.5300],
        [-0.2701, -1.3883,  0.8476,  1.0980, -0.6478],
        [ 0.3658,  0.1976,  0.5901, -0.0308,  0.4689],
        [-0.5423, -0.5111,  0.3533,  1.0558, -0.6255],
        [-0.5947,  0.3578,  0.0861,  0.8336, -0.4390],
        [ 0.1503,  0.5052, -0.3204, -0.0824,  0.6489],
        [-0.6908, -1.3407,  0.2524,  1.3991, -0.8885],
        [-0.0635, -0.9011,  0.3342,  0.6427, -0.1052],
        [ 0.7701, -0.5635,  1.1562, -0.1418,  0.5918],
        [ 0.4784, -0.6914,  0.1815, -0.0175,  0.6407]],
       grad_fn=<ThAddmmBackward>)

双线性变换

torch.nn.Bilinear(in1_features, in2_features, out_features) 用来实现双线性变换。这里所谓双线性变换,即将两个输入向量通过线性映射输出一个向量:

$$ f(x_1, x_2) = {x_1} W {x_2} + b $$

示例:

import torch
from torch.nn import Bilinear

batch_size = 10
in1_dim, in2_dim, out_dim = 10, 20, 15
f = Bilinear(in1_dim, in2_dim, out_dim)
x1 = torch.randn(batch_size, in1_dim)
x2 = torch.randn(batch_size, in2_dim)
print(f(x1, x2))
tensor([[-2.3421, -3.7768, -3.4717,  0.5833,  1.3329,  5.4486, -1.1711,  5.0669,
         -4.3718,  7.1978, -0.7916,  0.5202,  1.7298,  2.6537,  5.9211],
        [ 0.5935,  0.1617,  3.0612,  0.2974, -2.9155, -0.4824, -2.2681,  2.3530,
         -5.0539,  0.4975,  2.4606, -2.8047,  1.3086, -1.8999,  3.9561],
        [-2.7210, -0.2954, -1.3459,  4.1768, -2.9443, -2.1103,  0.6589, -2.4812,
          2.4498,  1.0631, -4.1793,  3.0510, -3.3824,  5.3756,  5.0297],
        [-3.0099, -2.4927,  0.0713, -1.3033, -1.9498, -3.2205,  0.0527,  0.5042,
          0.7249, -2.8559, -0.9705,  2.5813, -1.6508, -0.4418, -0.3630],
        [ 2.4322, -0.2320,  1.0910, -3.1138,  3.2755, -4.4013,  1.0975,  6.7036,
          1.5664, -0.5358,  3.7498,  5.1791, -1.1391,  3.6083,  0.7168],
        [ 2.5544, -2.7780,  1.7256, -0.4374,  3.9485,  1.6058, -1.4373,  1.1624,
         -2.0141,  0.4322, -2.6225,  0.4959, -1.4160,  0.1125, -3.7519],
        [ 0.2643,  0.4638,  2.2612,  2.6518,  2.8768,  1.3670, -2.6216,  2.7565,
          1.0289,  2.7267, -0.9797, -0.8097, -3.4512, -5.6603, -0.9761],
        [ 2.9234,  1.6766,  2.5493,  0.6752, -0.2853,  0.0387, -1.7217,  1.9013,
          2.5181, -4.3402,  1.6411, -3.3632, -1.8515,  7.0746, -4.0407],
        [-0.9404, -0.1962,  0.3093,  1.0326, -0.1733,  0.8181, -2.5928,  2.5431,
          1.7152,  0.0988, -0.9779,  0.8746,  0.3305,  0.3620, -0.2719],
        [ 5.4929, -3.1902, -0.2542, -1.0588,  1.4167,  3.0471,  2.1043,  4.4378,
          1.4170, -2.2397,  0.9165, -0.5303, -0.3250,  3.0143,  3.6417]],
       grad_fn=<ThAddBackward>)

值得注意的是,嵌套的线性变换依然是一个线性变换,例如:$y = f(x)$ 和 $t = g(y)$ 都表示线性变换,则嵌套后的 $h(x) = g(f(x))$ 依然是一个线性变化,所以通过组合线性变换是无法扩展模型容量的。接下来我们将介绍非线性变换,将它同线性变换结合起来,理论上讲可以近似任何函数(也即神经网络计算统一理论)。

非线性激活函数

在神经网络中常用的非线性变换函数有以下几个:

$$ \sigma(z) = \frac {1} {1 + exp(-z)} $$

$$ tanh(z) =2 * \sigma(z) - 1 = \frac {e^x - e^{-x}} {e^x + e^{-x}} $$

$$ ReLU(z) = max(z, 0) $$

注:这里介绍的这几个非线性变化都是没有参数的,而之前介绍的仿射变换则是有参数的。

由于神经网络学习过程,目前一般采用基于导数/梯度信息的优化方法,因此在选择非线性变换函数时,要考虑其导数是否易于求解,以上几个函数求导都十分便捷,它们各自的导数函数如下:

$$ \frac{d\sigma(z)}{dz} = \sigma(z)(1 - \sigma(z)) $$

$$ \frac {d tanh(z)} {dz} = 1 - tanh^2(z) $$

$$ \frac {d ReLU(z)} {dz} = 1.0\ if\ z\ >\ 0\ else\ 0.0 $$

虽然 $\sigma(z)$ 是早期神经网络使用的非线性变换函数,但现代神经网络中一般不再使用它,因为 $\sigma(z)$ 函数饱和后导数值就接近为零,使得利用梯度方法来学习这类神经网络模型变得比较困难(后向传播时,梯度会消失),尤其在多层网络中学习更为困难。现在一般会使用 tanh 和 ReLU 来构建神经网络。

import torch

a = torch.linspace(-30, 30, steps=20)

print(torch.sigmoid(a))  # sigmoid function
print(torch.sigmoid(a) * (1-torch.sigmoid(a)))  # sigmoid derivative

print(torch.tanh(a))  # tanh function
print(1 - torch.tanh(a)**2)  # tanh derivative
tensor([9.3576e-14, 2.2010e-12, 5.1770e-11, 1.2177e-09, 2.8641e-08, 6.7367e-07,
        1.5845e-05, 3.7256e-04, 8.6901e-03, 1.7094e-01, 8.2906e-01, 9.9131e-01,
        9.9963e-01, 9.9998e-01, 1.0000e+00, 1.0000e+00, 1.0000e+00, 1.0000e+00,
        1.0000e+00, 1.0000e+00])
tensor([0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0004, 0.0086,
        0.1417, 0.1417, 0.0086, 0.0004, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
        0.0000, 0.0000])
tensor([-1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000, -1.0000,
        -0.9998, -0.9184,  0.9184,  0.9998,  1.0000,  1.0000,  1.0000,  1.0000,
         1.0000,  1.0000,  1.0000,  1.0000])
tensor([0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0003,
        0.1565, 0.1565, 0.0003, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
        0.0000, 0.0000])

再介绍一个具有概率解释意义的非线性变换函数:

$$ softmax(x_i) = \frac {\exp(x_i)} {\sum_j \exp(x_j)} $$

该函数特殊的地方在于其函数值大于零并且满足归一化:$\sum_i softmax(x_i) = 1$,因此可以作为概率分布函数来使用,即有:

$$ p(x_i) = softmax(x_i); \sum_i p(x_i) = 1 $$

一般将其应用于多分类神经网络的最后一层,这样使得神经网络的输出值具有概率解释的意义。Torch 也提供了该函数:

import torch
from torch.nn import Linear

in_dim, out_dim = 2, 3
f = Linear(in_dim, out_dim)
x = torch.randn(1, in_dim)  # 只有一个数据点
score = f(x)  # affine
print(score)
p = torch.softmax(score, dim=1)  # softmax,需要指定 dim
print(p)
tensor([[-0.1661,  0.9067,  0.3300]], grad_fn=<ThAddmmBackward>)
tensor([[0.1797, 0.5253, 0.2951]], grad_fn=<SoftmaxBackward>)

涉及到概率问题时,经常需要计算 $\log p(x_i)$,Torch 也提供了函数可以直接计算 $\log softmax(x_i)$:

lg = torch.log_softmax(score, dim=1)
print(lg)
print(torch.log(p))
tensor([[-1.7167, -0.6438, -1.2206]], grad_fn=<LogSoftmaxBackward>)
tensor([[-1.7167, -0.6438, -1.2206]], grad_fn=<LogBackward>)

损失函数

损失函数(loss function)又叫做目标函数(objective function)、代价函数(cost funtion)。是训练神经网络时的优化目标,一般我们希望通过训练而使得损失函数值最小化。

那么在神经网络模型中(准确来讲是监督学习),是如何计算损失函数的呢?一般思路如下:向神经网络输入样本,得到预测值,然后计算预测值跟真实值之间的误差。根据网络输出端的不同结构以及应用场景的不同,具体计算误差的方式也会不同。接下来将介绍各种损失函数的特点,以便我们可以根据应用场景选取合适的损失函数。

之前提到神经网络的训练,一般以 mini-batch 为基本数据单位来进行的,相应的损失函数的度量也是以 mini-batch 为单位的,也即输入到损失函数的预测值和真实值的第一维大小是 batch size,下面将其表示为 N。由于输入到损失函数的数据是 mini-batch,计算损失时就需要考虑:是要单独计算每个样本的损失,还是汇总每个样本的损失来计算 mini-batch 整体的损失?这个行为是可以通过损失函数的 reduction 参数来配置的,下面将会详细介绍。

L1 损失

torch.nn.L1Loss(reduction='mean'):L1 损失又称为 MAE(mean absolute error)损失,计算各个元素差的绝对值的平均。计算公式如下:

$$ \ell(x, y) = L = {l_1,\dots,l_N}^\top, \quad l_n = \left| x_n - y_n \right|, $$

其中参数 reduction 用来控制是否需要汇总每个样本的损失以及如何进行汇总,可以取值如下:

  • 'none' 不进行汇总,返回各个样本的损失
  • 'mean' 以平均值的方式进行汇总
  • 'sum' 以求和的方法进行汇总

示例:

import torch
from torch import nn

batch_size = 2
output_size = 10
output = torch.randn(batch_size, output_size)
target = torch.randn(batch_size, output_size)

criterion = nn.L1Loss(reduction='none')
loss = criterion(output, target)
print(loss)

criterion = nn.L1Loss(reduction='sum')
loss = criterion(output, target)
print(loss)
tensor([[0.8691, 0.4084, 1.1627, 1.4256, 0.0363, 1.0162, 0.4679, 1.3290, 3.2904,
         0.2517],
        [0.3801, 0.4139, 0.1842, 0.1529, 2.6738, 3.6215, 1.2598, 1.1422, 1.2835,
         1.1165]])
tensor(22.4857)

L2 平方损失

torch.nn.MSELoss(reduction='mean'):L2 平方损失,又称为 MSE(mean squared error)损失,计算各个元素差的平方的平均。计算公式如下:

$$ \ell(x, y) = L = {l_1,\dots,l_N}^\top, \quad l_n = \left( x_n - y_n \right)^2 $$

示例:

import torch
from torch import nn

batch_size = 2
output_size = 10
output = torch.randn(batch_size, output_size)
target = torch.randn(batch_size, output_size)

criterion = nn.MSELoss(reduction='none')
loss = criterion(output, target)
print(loss)

criterion = nn.MSELoss(reduction='sum')
loss = criterion(output, target)
print(loss)
tensor([[6.9981e-04, 2.8987e+01, 3.7491e-01, 8.4494e-02, 1.0400e+00, 1.9487e-03,
         4.1698e-01, 1.0127e+00, 1.5727e+00, 3.7720e+00],
        [1.3667e+00, 2.7158e-03, 2.7417e+00, 2.9302e-01, 3.8652e+00, 3.7776e-03,
         4.1531e-02, 7.8477e+00, 7.6332e+00, 2.9294e-01]])
tensor(61.3521)

NLL 损失

torch.nn.NLLLoss(weight=None, ignore_index=-100, reduction='mean'):NLL 损失即 negative log likelihood loss,在多分类问题中(假设类别数量为 $C$),若神经网络输出层采用了 LogSoftmax 层以输出对数概率值,那么一般我们会使用它来度量误差(最小化该损失函数等价于最大化可能性函数):

最小化 negative log likelihood loss 函数

$$ \text{argmin}_w {-\log \text{likelihood}(w)} = -\sum_i^C {\text{target}_i}*{\log(\text{output}_i)} $$

最大化可能性函数

$$ {argmax}_w \text{likelihood}(w) = \prod_i^C {\text{output}_i}^{\text{target}_i} $$

这里我们假设了 target 是 one-hot 向量,不过在 Torch 中提供给 NNLLosstarget 变量是类标的索引,其损失计算方式如下:

$$ \ell(x, y) = L = {l_1,\dots,l_N}^\top, \quad l_n = - w_{y_n} x_{n,y_n}, \quad w_{c} = \text{weight}[c] \cdot \mathbb{1}{c \not= \text{ignore_index}} $$

也即假设输入某样本 $n$ 后,神经网络输出的对数概率向量是 $x_n$,而该样本的真实类标索引是 $y_n$,则对应的 negative log likelihood 等于 $-x_{n, y_n}$;其中参数 weight 是一个大小为 $C$ 的权重向量,用来指定每个类别惩罚程度的不同,因此样本的最终损失为 $-w_{y_n} x_{n, y_n}$;其中参数 ignore_index 用来指定哪个类标不进行损失计算;

注:由于网络输出的预测内容已经归一化,这里通过最小化损失,不仅仅意味着在最大化真实类标的可能性,同时也意味着最小化错误类标的可能性。

示例:

import torch
from torch import nn
from torch.nn import functional as F

batch_size = 2
output_size = 10

target = torch.tensor([3]*batch_size)
output = torch.randn(batch_size, output_size)
output = F.log_softmax(output, dim=1)  # LogSoftmax Layer

print('output: ', output)

criterion = nn.NLLLoss(reduction='none')
loss = criterion(output, target)
print(loss)
output:  tensor([[-2.4636, -2.5123, -1.2423, -1.9867, -3.1117, -4.1282, -4.0381, -1.5579,
         -3.2018, -2.5461],
        [-2.6130, -0.4621, -2.9637, -3.1524, -4.3901, -3.6024, -3.0595, -4.0542,
         -3.4735, -2.6969]])
tensor([1.9867, 3.1524])

CrossEntropy 损失

torch.nn.CrossEntropyLoss(weight=None, ignore_index=-100, reduction='mean'):在多分类时,如果不想为神经网络添加最后一层 LogSoftmax 以变将输出转变为对数概率值的话,可以使用 CrossEntropy 损失。可以认为该损失函数是 LogSoftmaxNLLLoss 的组合,因此输入的预测值是未归一化的实向量,真实值则是类标索引。

示例:

import torch
from torch import nn
from torch.nn import functional as F

batch_size = 2
output_size = 10

target = torch.tensor([3]*batch_size)
output = torch.randn(batch_size, output_size)
logp_output = F.log_softmax(output, dim=1)

print('output: ', output)
print('logp_output', logp_output)

criterion = nn.CrossEntropyLoss(reduction='none')
loss = criterion(output, target)
print(loss)
criterion = nn.NLLLoss(reduction='none')
loss = criterion(logp_output, target)
print(loss)
output:  tensor([[-0.1761,  0.9420,  1.7457, -0.0509, -0.4738,  0.3431, -0.2961,  0.2490,
         -0.5484, -0.2623],
        [ 0.7785,  0.1486,  0.2920,  0.0843, -0.4796,  1.2900,  0.4145, -2.1525,
          1.3756, -0.3943]])
logp_output tensor([[-2.9163, -1.7981, -0.9945, -2.7910, -3.2139, -2.3971, -3.0363, -2.4912,
         -3.2886, -3.0025],
        [-2.0114, -2.6412, -2.4978, -2.7056, -3.2695, -1.4998, -2.3754, -4.9423,
         -1.4143, -3.1841]])
tensor([2.7910, 2.7056])
tensor([2.7910, 2.7056])

优化算法

为网络定义了损失函数,并可以通过自动微分计算网络中参数的梯度信息,接下来就要解决如何利用梯度信息来更新网络参数以最小化损失函数。

最简单的方法是梯度下降:

$$ W^{(t+1)} = W^{(t)} - \eta \nabla L(W) $$

此外还有该方法的各种变体也可以用来训练网络,如利用二阶导数、动态调整学习率 $\eta$ 等。不过由于 Torch 都将这些优化算法封装在了 torch.optim 中,我们在使用时并不需要关注其实现的细节,只要知道可以利用优化算法来更新网络参数即可。

有几点要注意:

  • 不同优化算法,可能需要配置不同的超参
  • 选取不同优化算法,既可能会影响到模型最终的性能,又可能会影响到模型的训练速度
  • 选择哪种优化算法,应根据具体应用来决定

关于常见优化算法介绍,参见这篇文章

Dropout 层

Dropout 基本思想:对于某个网络层,每次训练迭代时,随机地将该层部分神经元置为零(等价于删除的效果)。这是一种高效的正则化方法,同时可以避免神经元之间的 co-adaptation 现象。详见 torch.nn.Dropout, torch.nn.Dropout2d, torch.nn.Dropout3d

此外 Torch 还实现了 Alpha Dropout,这是一种特殊的 Dropout:maintains the self-normalizing property. For an input with zero mean and unit standard deviation, the output of Alpha Dropout maintains the original mean and standard deviation of the input. Alpha Dropout goes hand-in-hand with SELU activation function, which ensures that the outputs have zero mean and unit standard deviation.

Normalization 层

在神经网络训练阶段,随着参数的更新,网络层输出的数据分布也在变化,越是深层网络变化越剧烈,一方面导致 tanh 等激活函数容易饱和,另一方面网络需要学习这种变化,进而使得学习时间变长,为了克服这些缺陷,人们提出了各种 normalization 技术。

Batch Normalization

Batch Normalization 利用 mini-batch 的统计信息来分别对每个特征维度进行归一化处理:

$$ y = \frac{x - \mathrm{E}[x]}{\sqrt{\mathrm{Var}[x] + \epsilon}} * \gamma + \beta
$$

根据输入数据的尺度,分别有 BatchNorm1d, BatchNorm2dBatchNorm3d 可用。

Layer Normalization

Layer Normalization 利用不同特征维度的统计信息来分别对每个样本的输出值进行归一化处理。同 Batch Normalization 的不同之处在于:

模型参数和容器类

一个神经网络模型,本质上定义了输入到输出的映射关系,而这种映射关系是通过输入数据和模型参数经过复杂的运算得到的。对于模型的用户来说,只需要知道输入和输入接口,至于内部的计算细节应该由模型的开发者负责将其封装起来。本节要介绍的容器类就提供了这样的封装。

Parameter

在模型内部有些属性变量(也即模型参数)用来支持映射关系的表示,可能需要通过它们来完成映射关系的计算,我们希望这些变量的计算能够被自动微分所追踪;而另外一些属性变量却可能只是用来缓存模型的中间状态,并不需要自动微分,如 RNN 模型的隐状态。为了对模型参数加以区分,Torch 实现了类 torch.nn.Parameter,它虽然是 torch.Tensor 的子类,但它有个特点是:当这类对象作为模型属性时,就会被自动注册为模型参数。

此外还有 torch.nn.ParameterListtorch.nn.ParameterDict 分别用来将模型参数作为列表和字典对象来添加到模型属性中。之所以不用普通的列表和字典对象,是因为参数添加到模型中需要处理注册到模型的逻辑,而 ParameterListParameterDict 实现了这样的逻辑。

Module

模型容器可以看成是用来包含模型参数以及映射关系计算的容器。有了这样抽象的容器对象,使得输入输出接口简洁,并且模型在计算设备上迁移的变得更加方便。

torch.nn.Module:所有自定义模型都应该继承自该容器类。该容器类支持嵌套,也即容器类的属性可以是其它容器,通过嵌套可以构造树形结构的复杂模型。

示例:

import torch.nn as nn
import torch.nn.functional as F

class Model(nn.Module):
    def __init__(self):
        super(Model, self).__init__()
        self.conv1 = nn.Conv2d(1, 20, 5)   # submodule
        self.conv2 = nn.Conv2d(20, 20, 5)  # submodule

    def forward(self, x):
       x = F.relu(self.conv1(x))
       return F.relu(self.conv2(x))

net = Model()

net.parameters()  # returns parameters of this module and all submodules.

net.cpu()  # moves all model parameters and buffers to the CPU.
net.cuda() # moves all model parameters and buffers to the GPU.

net.train() # Sets the module in training mode.
net.eval()  # Sets the modeule in evaluation mode.

net.zero_grad() # Sets gradients of all model parameters to zero.

state_dict = net.state_dict() # Returns a dictionary containing a whole state of the module.
net.load_state_dict(state_dict)  # Copies parameters and buffers from state_dict into this module and its descendants.

Sequential

torch.nn.Sequential:允许以指定的方式来组织一系列模型,在进行计算时数据将按照指定的顺序依次流过。

示例:

# Example of using Sequential
model = nn.Sequential(
          nn.Conv2d(1,20,5),
          nn.ReLU(),
          nn.Conv2d(20,64,5),
          nn.ReLU()
        )

# Example of using Sequential with OrderedDict
model = nn.Sequential(OrderedDict([
          ('conv1', nn.Conv2d(1,20,5)),
          ('relu1', nn.ReLU()),
          ('conv2', nn.Conv2d(20,64,5)),
          ('relu2', nn.ReLU())
        ]))

ModuleList

torch.nn.ModuleList:如果想要包含一个子模型列表到模型中,直接使用 list 对象来作为模型属性是不行的,因此这样无法将子模型的参数注册到模型中。ModuleList 正是为此而设计的,它就像一个普通的 list 对象,不过会自动的在背后处理参数注册的逻辑。

错误示例:

class MyModule(nn.Module):
    def __init__(self):
        super(MyModule, self).__init__()
        self.linears = [nn.Linear(10, 10) for i in range(10)]

    def forward(self, x):
        for i, l in enumerate(self.linears):
            x = self.linears[i // 2](x) + l(x)
        return x

正确示例:

class MyModule(nn.Module):
    def __init__(self):
        super(MyModule, self).__init__()
        self.linears = nn.ModuleList([nn.Linear(10, 10) for i in range(10)])

    def forward(self, x):
        # ModuleList can act as an iterable, or be indexed using ints
        for i, l in enumerate(self.linears):
            x = self.linears[i // 2](x) + l(x)
        return x

ModuleDict

torch.nn.ModuleDict:它同 ModuleList 作用类似,不过它允许子模型以字典属性的形式添加到模型中。

示例:

class MyModule(nn.Module):
    def __init__(self):
        super(MyModule, self).__init__()
        self.choices = nn.ModuleDict({
                'conv': nn.Conv2d(10, 10, 3),
                'pool': nn.MaxPool2d(3)
        })
        self.activations = nn.ModuleDict([
                ['lrelu', nn.LeakyReLU()],
                ['prelu', nn.PReLU()]
        ])

    def forward(self, x, choice, act):
        x = self.choices[choice](x)
        x = self.activations[act](x)
        return x

CNN

Torch 提供了 CNN 的两类抽象层:卷积层和池化层。

Convolution

Torch 提供了 1D, 2D, 3D 卷积层:

  • torch.nn.Conv1d(in_channels, out_channels, kernel_size)

    输入数据为:$(N, C_{in}, L_{in})$,输出数据为:$(N, C_{out}, L_{out})$,其中 $N$ 表示 batch size,$C$ 表示 channel 数量,$L$ 表示数据长度

  • torch.nn.Conv2d(in_channels, out_channels, kernel_size)

    输入数据为:$(N, C_{in}, H_{in}, W_{in})$,输出数据为:$(N, C_{out}, H_{out}, W_{out})$,其中 $N$ 表示 batch size,$C$ 表示 channel 数量,$H$ 表示数据高度,$W$ 表示数据宽度

  • torch.nn.Conv3d(in_channels, out_channels, kernel_size)

    输入数据为:$(N, C_{in}, D_{in}, H_{in}, W_{in})$,输出数据为:$(N, C_{out}, D_{out}, H_{out}, W_{out})$,其中 $N$ 表示 batch size,$C$ 表示 channel 数量,$D$ 表示数据深度,$H$ 表示数据高度,$W$ 表示数据宽度

可见对于 1D, 2D, 3D 卷积来说,唯一不同的在于每个数据元素的尺度。

Pooling

常用的池化操作由最大池化、均值池化等,Torch 都是支持,参见文档

RNN

Torch 提供了普通 RNN,LSTM 和 GRU 三类 RNN 模型,并且通过参数配置可以实现多层堆叠的 RNN 和双向 RNN。

数据处理

机器学习算法是由数据驱动的算法,因此数据处理是每个机器学习算法必然会面对的问题。

常见的数据处理问题有:各种音视频以及文本等数据的加载、存储和格式变换;数据的规范化处理;数据 Augment;数据可视化;特征工程等等。

我们可以利用各种现成的库和工具来进行数据处理,如:

  • 用 Pillow 和 OpenCV 处理图片和视频数据
  • 用 scipy 和 librosa 处理音频数据
  • 用 NLTK 和 SpaCy 处理文本数据
  • PyTorch 组开发的 torchvision 可以处理常见的计算机视觉数据集

Dateset

torch.utils.data.Dataset is an abstract class representing a dataset. Your custom dataset should inherit Dataset and override the following methods:

len so that len(dataset) returns the size of the dataset. getitem to support the indexing such that dataset[i] can be used to get ith sample Let’s create a dataset class for our face landmarks dataset. We will read the csv in init but leave the reading of images to getitem. This is memory efficient because all the images are not stored in the memory at once but read as required.

from torch.utils.data import Dataset

class SimpleDataset(Dataset):
    
    def __init__(self, n, transform=None):
        self.data = range(n)
        self.transform = transform
    
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, i):
        sample = self.data[i]
        
        if self.transform:
            sample = self.transform(sample)
        
        return sample

# 迭代数据集
d = SimpleDataset(5, lambda n: n ** 2)
for i in range(len(d)):
    print(d[i])

Transform

Data format transform or augement.

由上面的例子可知,transform 仅仅支持一个函数,用来对数据集中的样本进行处理。

这里我们用工厂方法来实现 transform 函数:

class PowTransform(object):
    
    def __init__(self, power=2):
        self.power = power
    
    def __call__(self, sample):
        return sample ** self.power

# 迭代数据集
pt = PowTransform(3)
d = SimpleDataset(5, pt)
for i in range(len(d)):
    print(d[i])

组合多个 transforms,利用 torchvision.transforms.Compose

Dataloader

直接通过 for-in 迭代数据,不能实现以下功能:

Batching the data Shuffling the data Load the data in parallel using multiprocessing workers.

不过可以使用 dataloader 来实现:

torch.utils.data.DataLoader is an iterator which provides all these features.

from torch.utils.data import DataLoader

pt = PowTransform(3)
d = SimpleDataset(50, pt)
print(d)
dataloader = DataLoader(d, batch_size=4, shuffle=True, num_workers=2)

print(dataloader)
for batch in dataloader:
    print(batch)
Previous
Next