深度学习框架之PyTorch

1 PyTorch简介

PyTorch是一个基于Torch的Python开源机器学习库,用于自然语言处理等应用程序。它主要由Facebookd的人工智能小组开发,不仅能够 实现强大的GPU加速,同时还支持动态神经网络,这一点是现在很多主流框架如TensorFlow都不支持的。 PyTorch提供了两个高级功能: 1.具有强大的GPU加速的张量计算(如Numpy) 2.包含自动求导系统的深度神经网络 除了Facebook之外,Twitter、GMU和Salesforce等机构都采用了PyTorch。

2 PyTorch入门

2.1 Tensor

Tensor是PyTorch中重要的数据结构,可认为是一个高维数组。它可以是一个数(标量)、一维数组(向量)、二维数组(矩阵)以及更高维的数组。Tensor和Numpy的ndarrays类似,但Tensor可以使用GPU进行加速。Tensor的使用和Numpy及Matlab的接口十分相似。

import torch as t
import numpy as np
a = np.arange(6).reshape(2,3)
a

运行结果:
array([[0, 1, 2],
[3, 4, 5]])

# ndarrays转换tensor
b = t.from_numpy(a)
b

运行结果:
tensor([[0, 1, 2],
[3, 4, 5]], dtype=torch.int32)

tensor转换ndarrays
c = b.numpy()
c

运行结果:
array([[0, 1, 2],
[3, 4, 5]])

# abs
a = [1, -1, -2, 3]
b = t.FloatTensor(a) #32-bit float point
print(np.abs(a))
print(torch.abs(b))

运行结果:
[1 1 2 3]
tensor([1., 1., 2., 3.])

# multiple
a = [[1,2],[3,4]]
b = t.tensor(a)
print(np.matmul(a,a))
print(torch.mm(b,b))

运行结果:
[[ 7 10]
[15 22]]
tensor([[ 7, 10],
[15, 22]])

# array各类乘法
a = np.array(a)
print(a@a)
print(a*a)
print(np.dot(a, a))

运行结果:
[[ 7 10]
[15 22]]
[[ 1 4]
[ 9 16]]
[[ 7 10]
[15 22]]

# tensor各类乘法
print(b@b)
print(b*b)
# print(torch.dot(b, b)) # 只能对一维tensor点乘运算

运行结果:
tensor([[ 7, 10],
[15, 22]])
tensor([[ 1, 4],
[ 9, 16]])

# add
a = t.FloatTensor([[1,2],[3,4]])
b = t.from_numpy(np.array([[5,6], [7,8]]))
c = a + b
print(c)

运行结果:
tensor([[ 6., 8.],
[10., 12.]])

c = t.add(a, b)
c = a.add(b)
result = t.Tensor(2, 2)
result = t.add(a, b, out = result)
print(c)
print(result)

运行结果:
tensor([[ 6., 8.],
[10., 12.]])
tensor([[ 6., 8.],
[10., 12.]])

# 带下划线_的函数会改变形参
x = t.tensor([1, 2, 3])
y = t.tensor([4, 5, 6])
x.add(y)
y.add_(x)
print(x)
print(y)        

运行结果:
tensor([1, 2, 3])
tensor([5, 7, 9])

Tensor和numpy对象共享内存,所以它们之间的转换很快,而且几乎不需要消耗资源。这也意味着,如果其中一个变了,另外一个也会随之改变。

a = np.array([1, 2, 3, 4])
b = t.from_numpy(a)
b.add_(1)
print(a)

运行结果:
[2 3 4 5]

2.2 自动微分Autograd

自动微分的算法本质上是通过反向传播求导数,PyTorch的Autograd模块实现了此功能。在Tensor上的所有操作,Autograd都能为它们自动提供微分,避免手动计算导数的复杂过程。

autograd.Variable是Autograd的核心类,它简单封装了Tensor,并支持几乎所有Tensor的操作。Tensor在被封装为Variable之后,可以调用它的.backward实现反向传播,自动计算所有梯度。Variable的数据结构如下所示。
在这里插入图片描述
Variable主要包含三个属性。

  • data:保存Variable所包含的Tensor。
  • grad:保存data对应的梯度,grad也是个Variable,而不是Tensor,它和data的形状一样。
  • grad_fn:指向一个Function对象,这个Function用来计算反向传播计算输入的梯度。

从0.4起, Variable 正式合并入Tensor, Variable 本来实现的自动微分功能,Tensor就能支持。读者还是可以使用Variable(tensor), 但是这个操作其实什么都没做。建议读者以后直接使用tensor*. 要想使得Tensor使用autograd功能,只需要设置tensor.requries_grad=True.

# 为tensor设置 requires_grad 标识,代表着需要求导数
# pytorch 会自动调用autograd 记录操作
x = t.ones(2, 2, requires_grad = True)
# 上一步等价于
# x = t.ones(2,2)
# x.requires_grad = True
x

运行结果:
tensor([[1., 1.],
[1., 1.]], requires_grad=True)

y = x.sum()
print(y)

运行结果:
tensor(4., grad_fn=)

y.grad_fn

运行结果:
<SumBackward0 at 0x193dbe88988>

y.backward() # 反向传播,计算梯度
# y = x.sum() = (x[0][0] + x[0][1] + x[1][0] + x[1][1])
# 每个值的梯度都为1
x.grad 

运行结果:
tensor([[1., 1.],
[1., 1.]])

grad在反向传播过程中是累加的(accumulated),这意味着每一次运行反向传播,梯度都会累加之前的梯度,所以反向传播之前需把梯度清零。

y.backward()
x.grad
y.backward()
x.grad

运行结果:
tensor([[3., 3.],
[3., 3.]])

# 以下划线结束的函数是inplace操作,会修改自身的值,就像add_
x.grad.data.zero_()

运行结果:
tensor([[0., 0.],
[0., 0.]])

y.backward()
x.grad

运行结果:
tensor([[1., 1.],
[1., 1.]])

2.3 神经网络

Autograd实现了反向传播功能,但是直接用来写深度学习的代码在很多情况下还是稍显复杂,torch.nn是专门为神经网络设计的模块化接口。nn构建于Autograd之上,可用来定义和运行神经网络。nn.Module是nn中最重要的类,可以把它看做是一个网络的封装,包括网络各层定义以及forward方法,调用forward(input)方法,可返回前向传播的结果。我们以最早的卷积神经网络LeNet为例,来看看如何用nn.Module实现。LeNet的网络结构如下所示。
在这里插入图片描述
这是一个基础的前向传播(feed-forward)网络:接收输入,经过层层传递运算,得到输出。

(1)定义网络

定义网络时,需要继承nn.Module,并实现它的forward方法,把网络中具有可学习参数的层放在构造函数init中。如果某一层(如ReLU)不具有可学习的参数,则既可以放在构造函数中,也可以不放。

import numpy as np
import torch as t
import torch.nn as nn
import torch.nn.functional as F
class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 6, 5)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16*5*5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)
    def forward(self, x):
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv2(x)), 2)
        x = x.view(x.size()[0],-1)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
net = Net()
print(net)

运行结果:
Net(
(conv1): Conv2d(1, 6, kernel_size=(5, 5), stride=(1, 1))
(conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1, 1))
(fc1): Linear(in_features=400, out_features=120, bias=True)
(fc2): Linear(in_features=120, out_features=84, bias=True)
(fc3): Linear(in_features=84, out_features=10, bias=True)
)

只要在nn.Module的子类中定义了forward函数,backward函数就会被自动实现(利用Autograd)。在forward函数中可使用任何Variable支持的函数,还可以使用if、for循环、print、log等Python语法,写法和标准的Python写法一致。

网络的可学习参数通过net.parameters()返回,net.named_parameters可同时返回可学习的参数及名称。

params = list(net.parameters())
print(len(params))

运行结果:
10

for name, parameters in net.named_parameters():
    print(name, ';', parameters.size())

运行结果:
conv1.weight ; torch.Size([6, 1, 5, 5])
conv1.bias ; torch.Size([6])
conv2.weight ; torch.Size([16, 6, 5, 5])
conv2.bias ; torch.Size([16])
fc1.weight ; torch.Size([120, 400])
fc1.bias ; torch.Size([120])
fc2.weight ; torch.Size([84, 120])
fc2.bias ; torch.Size([84])
fc3.weight ; torch.Size([10, 84])
fc3.bias ; torch.Size([10])

input = t.randn(1, 1, 32, 32)
out = net(input)
out.size()

运行结果:
torch.Size([1, 10])

net.zero_grad() #所有的参数清零
out.backward(t.ones(1, 10)) #反向传播

需要注意的是,torch.nn只支持mini-batch,不支持一次只输入一个样本,即一次必须是一个batch。如果只想输入一个样本,则用input.unsqueeze(0)将batch_size设为1。例如,nn.Conv2d输入必须是4维的,形如nSamples * nChannels * Height * Width。可将nSamples设为1,即1 * nChannels * Height * Width。

2.4 损失函数

nn实现了神经网络中大多数的损失函数,例如nn.MSELoss用来计算均方误差,nn.CrossEntropyLoss用来计算交叉熵损失。

output = net(input)
target = t.arange(0.0, 10)
criterion = nn.MSELoss()
loss = criterion(output, target)
loss

运行结果:
tensor(28.2825, grad_fn=<MseLossBackward>)

如果对loss进行反向传播溯源(使用grad_fn属性),可以看到它的计算图如下。

input -> conv2d -> relu -> maxpool2d -> conv2d -> relu -> maxpool2d
-> view -> linear -> relu -> linear -> relu -> linear -> MSELoss -> loss

当调用loss.backward()时,该图会动态生成并自动微分,也会自动计算图中参数(Parameter)的导数。

net.zero_grad()
print('反向传播之前conv1.bias的梯度')
print(net.conv1.bias.grad)
loss.backward()
print('反向传播之后的conv1.bias的梯度')
print(net.conv1.bias.grad)

运行结果:
反向传播之前conv1.bias的梯度
tensor([0., 0., 0., 0., 0., 0.])
反向传播之后的conv1.bias的梯度
tensor([-0.0023, 0.0267, 0.0289, -0.0467, 0.0040, -0.0210])

2.5 优化器

在反向传播计算完所有参数的梯度后,还需要使用优化方法更新网络的权重和参数。例如,随机梯度下降法(SGD)的更新策略如下:

weight = weight - learning_rate * gradient

手动实现如下:

learning_rate = 0.01
for f in net.parameters():
    f.data.sub_(f.grad.data * learning_rate) #inplace减法

torch.optim中实现了深度学习中绝大多数的优化方法,例如RMSProp、Adam、SGD等,更便于使用,因此通常并不需要手动写上述代码。

import torch.optim as optim
#新建一个优化器
optimizer = optim.SGD(net.parameters(), lr = 0.01)
#先进行梯度清零
optimizer.zero_grad()
#计算损失
output = net(input)
loss = criterion(output, target)
#反向传播
loss.backward()
#更新参数
optimizer.step()

2.6 数据加载与预处理

在深度学习中数据加载与预处理是非常复杂繁琐的,但PyTorch提供了一些可简答简化和加快数据处理流程的工具。同时,对于常用的数据集,PyTorch也提供了封装好的接口供用户快速调用,这些数据集主要保存在torchvision中。

torchvision实现了常用的图像数据加载功能,例如ImageNet、CIFAR10、MNIST等,以及常用的数据转换操作,这极大地方便了数据加载。

2.7 小试牛刀:CIFAR-10分类

下面我们来尝试实现对CIFAR10数据集的分类,步骤如下:
(1)使用torchvision加载并预处理CIFAR10数据集。
(2)定义网络。
(3)定义损失函数和优化器。
(4)训练网络并更新网络参数。
(5)测试网络。

(1)CIFAR-10数据集加载及预处理

CIFAR-10是一个常用的彩色图片数据集,它有10个类别:airplane、automobile、bird、cat、deer、dog、frog、horse、ship和truck。每张图片都是3 * 32 * 32,也即3通道彩色图片,分辨率为32 * 32。提前下载数据集放到指定目录下,如E:/data/,在加载器中root参数指向该目录,程序检测到该文件已存在就直接解压。

import torch as t
import torch.nn as nn
import torch.nn.functional as F
from torch import optim
import torchvision as tv
import torchvision.transforms as transforms
from torchvision.transforms import ToPILImage
show = ToPILImage() #方便可视化

运行结果:

transform = transforms.Compose([transforms.ToTensor(),
                                transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])

运行结果:

trainset = tv.datasets.CIFAR10(
            root = 'D:\dataset',
            train = True,
            download = True, 
            transform = transform)
trainloader = t.utils.data.DataLoader(
            trainset,
            batch_size = 4,
            shuffle = True,
            num_workers = 2)

运行结果:

testset = tv.datasets.CIFAR10(
            root = 'D:\dataset',
            train = False,
            download = True,
            transform = transform)
testloader = t.utils.data.DataLoader(
            testset,
            batch_size = 4,
            shuffle = False,
            num_workers = 2)

运行结果:

classes = ('plane', 'cat', 'bird', 'deer', 'dog', 'frog', 'hores', 'ship', 'truck')

运行结果:

data, label = trainset[100]
print(classes[label])

运行结果:
truck

show((data + 1)/2).resize((200, 200))

运行结果:
在这里插入图片描述

dataiter = iter(trainloader)
images,labels = dataiter.next()    # 返回4张图片及标签
print(' '.join('%11s' % classes[labels[j]] for j in range(4)))
show(tv.utils.make_grid((images+1)/2)).resize((400,100))

运行结果:
ship ship hores ship
在这里插入图片描述

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16*5*5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)
        
    def forward(self, x):
        x = F.max_pool2d(F.relu(self.conv1(x)), 2)
        x = F.max_pool2d(F.relu(self.conv2(x)), (2, 2))
        x = x.view(x.size()[0], -1)
        x = F.relu(self.fc1(x)) 
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
                         
                         
net = Net()
print(net)

运行结果:
Net(
(conv1): Conv2d(3, 6, kernel_size=(5, 5), stride=(1, 1))
(conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1, 1))
(fc1): Linear(in_features=400, out_features=120, bias=True)
(fc2): Linear(in_features=120, out_features=84, bias=True)
(fc3): Linear(in_features=84, out_features=10, bias=True)
)

criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr = 0.001, momentum = 0.9)

运行结果:

for epoch in range(2):
    running_loss = 0.0
    for i,data in enumerate(trainloader,0):
        # 输入数据
        inputs,labels = data
        # 梯度清零
        optimizer.zero_grad()
        # forward + backward
        outputs = net(inputs)
        loss = criterion(outputs,labels)
        loss.backward()
        # 更新参数
        optimizer.step()
        # 打印log信息
        running_loss += loss
        if i % 2000 == 1999:     # 每2000个batch打印一次训练状态
            print('[%d,%5d] loss: %.3f' % (epoch+1, i+1, running_loss / 2000))
            running_loss = 0.0
print('Finished Trainning')

运行结果:
[1, 2000] loss: 2.231
[1, 4000] loss: 1.913
[1, 6000] loss: 1.725
[1, 8000] loss: 1.629
[1,10000] loss: 1.568
[1,12000] loss: 1.491
[2, 2000] loss: 1.438
[2, 4000] loss: 1.395
[2, 6000] loss: 1.380
[2, 8000] loss: 1.357
[2,10000] loss: 1.314
[2,12000] loss: 1.318
Finished Trainning


运 行结果:


运行结果:


运行结果:

3 Tensor和Atuograd

3.1 Tensor

Tensor,又名张量,读者可能对这个名词似曾相识,因为它不仅在PyTorch中出现过,也是Theano、TensorFlow、Torch和MXNet中重要的数据结构。关于张量的本质不乏深度剖析的文章,但从工程角度讲,可简单地认为它就是一个数组,且支持高效的科学计算。它可以是一个数(标量)、一维数组(向量)、二维数组(矩阵)或更高维的数组(高阶数据)。Tensor和numpy的ndarray类似,但PyTorch的tensor支持GPU加速。

基础操作

学习过Numpy的读者会对本节内容感到非常熟悉,因tensor的接口有意设计成与Numpy类似,以方便用户使用。但不熟悉Numpy也没关系,本节内容并不要求先掌握Numpy。

从接口的角度来讲,对tensor的操作可分为两类:

  1. torch.function,如torch.save等。
  2. 另一类是tensor.function,如tensor.view等。

为方便使用,对tensor的大部分操作同时支持这两类接口,在本书中不做具体区分,如torch.sum (torch.sum(a, b))tensor.sum (a.sum(b))功能等价。

而从存储的角度来讲,对tensor的操作又可分为两类:

  1. 不会修改自身的数据,如 a.add(b), 加法的结果会返回一个新的tensor。
  2. 会修改自身的数据,如 a.add_(b), 加法的结果仍存储在a中,a被修改了。

函数名以_结尾的都是inplace方式, 即会修改调用者自己的数据,在实际应用中需加以区分。

创建Tensor

在PyTorch中新建tensor的方法有很多,具体如表3-1所示。

表3-1: 常见新建tensor的方法

函数功能
Tensor(*sizes)基础构造函数
tensor(data,)类似np.array的构造函数
ones(*sizes)全1Tensor
zeros(*sizes)全0Tensor
eye(*sizes)对角线为1,其他为0
arange(s,e,step)从s到e,步长为step
linspace(s,e,steps)从s到e,均匀切分成steps份
rand/randn(*sizes)均匀/标准分布
normal(mean,std)/uniform(from,to)正态分布/均匀分布
randperm(m)随机排列

这些创建方法都可以在创建的时候指定数据类型dtype和存放device(cpu/gpu).

其中使用Tensor函数新建tensor是最复杂多变的方式,它既可以接收一个list,并根据list的数据新建tensor,也能根据指定的形状新建tensor,还能传入其他的tensor,下面举几个例子。

# 指定tensor的形状
a = t.Tensor(2, 3)
a # 数值取决于内存空间的状态,print时候可能overflow

运行结果:
tensor([[3.2505e+21, 2.6076e-09, 3.2724e+21],
[1.0617e-08, 4.1961e-08, 2.6557e-06]])

# 用list的数据创建tensor
b = t.Tensor([[1,2], [3,4]])
b

运行结果:
tensor([[1., 2.],
[3., 4.]])

b.tolist() # 把tensor转为list

运行结果:
[[1.0, 2.0], [3.0, 4.0]]

tensor.size()返回torch.Size对象,它是tuple的子类,但其使用方式与tuple略有区别

b_size = b.size()
b_size

运行结果:
torch.Size([2, 2])

b.numel() # b中元素总个数,2*2,等价于b.nelement()

运行结果:
4

# 创建一个和b形状一样的tensor
c = t.Tensor(b_size)
# 创建一个元素为2和2的tensor
d = t.Tensor((2, 2))
print(c) 
print(d)

运行结果:
tensor([[ 0.0000e+00, 0.0000e+00],
[-1.1794e+09, 4.5914e-41]])
tensor([2., 2.])

除了tensor.size(),还可以利用tensor.shape直接查看tensor的形状,tensor.shape等价于tensor.size()

c.shape

运行结果:
torch.Size([2, 2])

需要注意的是,t.Tensor(*sizes)创建tensor时,系统不会马上分配空间,只是会计算剩余的内存是否足够使用,使用到tensor时才会分配,而其它操作都是在创建完tensor之后马上进行空间分配。其它常用的创建tensor的方法举例如下。

a = t.ones(2, 3)
print(a)

运行结果:
tensor([[1., 1., 1.],
[1., 1., 1.]])

b = t.zeros(2, 3)
b

运行结果:
tensor([[0., 0., 0.],
[0., 0., 0.]])

c = t.arange(1, 6, 2)
c

运行结果:
tensor([1, 3, 5])

d = t.linspace(1, 10, 3)
d

运行结果:
tensor([ 1.0000, 5.5000, 10.0000])

e = t.eye(2, 3, dtype=t.int) # 对角线为1, 不要求行列数一致
print(e)

运行结果:
tensor([[1, 0, 0],
[0, 1, 0]], dtype=torch.int32)

torch.tensor是在0.4版本新增加的一个新版本的创建tensor方法,使用的方法,和参数几乎和np.array完全一致

a = t.tensor(1)
print('a = %s, a_scalar = %s' %(a, a.shape))

运行结果:
a = tensor(1), a_scalar = torch.Size([])

b = t.tensor([1,2])
print('b = %s, b_vetor = %s' % (b, b.shape))

运行结果:
b = tensor([1, 2]), b_vecor = torch.Size([2])

c = t.tensor([[1,2], [3,4]])
print('c = %s, c_matrix = %s' % (c, c.shape))

运行结果:
c = tensor([[1, 2],
[3, 4]]), c_matrix = torch.Size([2, 2])

tensor = t.Tensor(1,2) # 注意和t.tensor([1, 2])的区别
tensor.shape

运行结果:
torch.Size([1, 2])

常用Tensor操作

通过tensor.view方法可以调整tensor的形状,但必须保证调整前后元素总数一致。view不会修改自身的数据,返回的新tensor与源tensor共享内存,也即更改其中的一个,另外一个也会跟着改变。在实际应用中可能经常需要添加或减少某一维度,这时候squeezeunsqueeze两个函数就派上用场了。

a = t.arange(0, 6)
b = a.view(2, 3)
print(a.shape)
print(a.view(2, 3))
print(a.shape)
print(b.shape)

运行结果:
torch.Size([6])
tensor([[0, 1, 2],
[3, 4, 5]])
torch.Size([6])
torch.Size([2, 3])

b.unsqueeze(1) # 注意形状,在第1维(下标从0开始)上增加“1” 
#等价于 b[:,None]
b[:, None].shape

运行结果:
torch.Size([2, 1, 3])

b.unsqueeze(-2) # -2表示倒数第二个维度

运行结果:
tensor([[[0, 1, 2]],
[[3, 4, 5]]])

a = t.arange(0, 6)# a修改,b作为view之后的,也会跟着修改
b = a.view(2, 3)
a[1] = 10
b

运行结果:
tensor([[ 0, 10, 2],
[ 3, 4, 5]])

resize是另一种可用来调整size的方法,但与view不同,它可以修改tensor的大小。如果新大小超过了原大小,会自动分配新的内存空间,而如果新大小小于原大小,则之前的数据依旧会被保存,看一个例子。

b.resize_(1, 3)
b

运行结果:
tensor([[ 0, 10, 2]])

b.resize_(3, 3) # 旧的数据依旧保存着,多出的大小会分配新空间
b

运行结果:
tensor([[ 0, 10, 2],
[ 3, 4, 5],
[32932988893659237, 29273792722501740, 31244220638625897]])

索引操作

Tensor支持与numpy.ndarray类似的索引操作,语法上也类似,下面通过一些例子,讲解常用的索引操作。如无特殊说明,索引出来的结果与原tensor共享内存,也即修改一个,另一个会跟着修改。

a = t.randn(3, 4)
a

运行结果:
tensor([[-0.8151, -0.2518, 0.8990, -1.2529],
[ 0.9100, 1.6483, -1.2966, 1.6414],
[-0.1959, 0.2494, -0.4332, 0.3404]])

a[0] # 第0行(下标从0开始)

运行结果:
tensor([-0.8151, -0.2518, 0.8990, -1.2529])

a[:, 0] # 第0列

运行结果:
tensor([-0.8151, 0.9100, -0.1959])

a[0][2] # 第0行第2个元素,等价于a[0, 2]

运行结果:
tensor(-0.8990)

a[0, -1] # 第0行最后一个元素

运行结果:
tensor(-1.2529)

# None类似于np.newaxis, 为a新增了一个轴
# 等价于a.view(1, a.shape[0], a.shape[1])
a[None].shape

运行结果:
torch.Size([1, 3, 4])

a[None].shape # 等价于a[None,:,:]

运行结果:
torch.Size([1, 3, 4])

a[:,None,:].shape

运行结果:
torch.Size([3, 1, 4])

a[:,None,:,None,None].shape

运行结果:
torch.Size([3, 1, 4, 1, 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]])

# 选取对角线的元素
index = t.LongTensor([[0,1,2,3]])
a.gather(0, index)

运行结果:
tensor([[ 0, 5, 10, 15]])

# 选取反对角线上的元素
index = t.LongTensor([[3,2,1,0]]).t()
a.gather(1, index)

运行结果:
tensor([[ 3],
[ 6],
[ 9],
[12]])

# 选取两个对角线上的元素
index = t.LongTensor([[0,1,2,3],[3,2,1,0]]).t()
b = a.gather(1, index)
b

运行结果:
tensor([[ 0, 3],
[ 5, 6],
[10, 9],
[15, 12]])

gather相对应的逆操作是scatter_gather把数据从input中按index取出,而scatter_是把取出的数据再放回去。注意scatter_函数是inplace操作。

out = input.gather(dim, index)
-->近似逆操作
out = Tensor()
out.scatter_(dim, index)
# 把两个对角线元素放回去到指定位置
c = t.zeros(4,4)
c.scatter_(1, index, b.float())

运行结果:
tensor([[ 0., 0., 0., 3.],
[ 0., 5., 6., 0.],
[ 0., 9., 10., 0.],
[12., 0., 0., 15.]])

对tensor的任何索引操作仍是一个tensor,想要获取标准的python对象数值,需要调用tensor.item(), 这个方法只对包含一个元素的tensor适用

a = t.arange(0, 16).view(4, 4)
a[0, 0] #tensor中单个元素仍然为tensor

运行结果:
tensor(0)

a[0, 0].item()

运行结果:
0

d = a[0:1, 0:1, None]
print(d.shape)
d.item() # 只包含一个元素的tensor即可调用tensor.item,与形状无关

运行结果:
torch.Size([1, 1, 1])
0

高级索引

PyTorch在0.2版本中完善了索引操作,目前已经支持绝大多数numpy的高级索引。高级索引可以看成是普通索引操作的扩展,但是高级索引操作的结果一般不和原始的Tensor共享内存。

a = t.arange(0, 27).view(3,3,3)
a

运行结果:
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]]])

a[[1, 2], [2, 2], [1, 0]]

运行结果:
tensor([16, 24])

a[[0, 2], ]

运行结果:
tensor([[[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8]],
[[18, 19, 20],
[21, 22, 23],
[24, 25, 26]]])

Tensor类型

Tensor有不同的数据类型,如表3-3所示,每种类型分别对应有CPU和GPU版本(HalfTensor除外)。默认的tensor是FloatTensor,可通过t.set_default_tensor_type 来修改默认tensor类型(如果默认类型为GPU tensor,则所有操作都将在GPU上进行)。Tensor的类型对分析内存占用很有帮助。例如对于一个size为(1000, 1000, 1000)的FloatTensor,它有1000*1000*1000=10^9个元素,每个元素占32bit/8 = 4Byte内存,所以共占大约4GB内存/显存。HalfTensor是专门为GPU版本设计的,同样的元素个数,显存占用只有FloatTensor的一半,所以可以极大缓解GPU显存不足的问题,但由于HalfTensor所能表示的数值大小和精度有限[^2],所以可能出现溢出等问题。

Data typedtypeCPU tensorGPU tensor
32-bit floating pointtorch.float32 or torch.floattorch.FloatTensortorch.cuda.FloatTensor
64-bit floating pointtorch.float64 or torch.doubletorch.DoubleTensortorch.cuda.DoubleTensor
16-bit floating pointtorch.float16 or torch.halftorch.HalfTensortorch.cuda.HalfTensor
8-bit integer (unsigned)torch.uint8torch.ByteTensortorch.cuda.ByteTensor
8-bit integer (signed)torch.int8torch.CharTensortorch.cuda.CharTensor
16-bit integer (signed)torch.int16 or torch.shorttorch.ShortTensortorch.cuda.ShortTensor
32-bit integer (signed)torch.int32 or torch.inttorch.IntTensortorch.cuda.IntTensor
64-bit integer (signed)torch.int64 or torch.longtorch.LongTensortorch.cuda.LongTensor

各数据类型之间可以互相转换,type(new_type)是通用的做法,同时还有floatlonghalf等快捷方法。CPU tensor与GPU tensor之间的互相转换通过tensor.cudatensor.cpu方法实现,此外还可以使用tensor.to(device)。Tensor还有一个new方法,用法与t.Tensor一样,会调用该tensor对应类型的构造函数,生成与当前tensor类型一致的tensor。torch.*_like(tensora) 可以生成和tensora拥有同样属性(类型,形状,cpu/gpu)的新tensor。 tensor.new_*(new_shape) 新建一个不同形状的tensor。

# 设置默认tensor,注意参数是字符串
t.set_default_tensor_type('torch.DoubleTensor')
a = t.Tensor(2,3)
a.dtype # 现在a是DoubleTensor,dtype是float64

运行结果:
torch.float64

# 恢复之前的默认设置
t.set_default_tensor_type('torch.FloatTensor')
# 把a转成FloatTensor,等价于b=a.type(t.FloatTensor)
b = a.float() 
b.dtype

运行结果:
torch.float32

c = a.type_as(b)
c

运行结果:
tensor([[0., 0., 0.],
[0., 0., 0.]])

a.new(2,3) # 等价于torch.DoubleTensor(2,3),建议使用a.new_tensor

运行结果:
tensor([[0., 0., 0.],
[0., 0., 0.]], dtype=torch.float64)

t.zeros_like(a) #等价于t.zeros(a.shape,dtype=a.dtype,device=a.device)

运行结果:
tensor([[0., 0., 0.],
[0., 0., 0.]], dtype=torch.float64)

t.zeros_like(a, dtype=t.int16) #可以修改某些属性

运行结果:
tensor([[0, 0, 0],
[0, 0, 0]], dtype=torch.int16)

t.rand_like(a)

运行结果:
tensor([[0.9273, 0.8291, 0.5178],
[0.0474, 0.6219, 0.3060]], dtype=torch.float64)

a.new_ones(4,5, dtype=t.int)

运行结果:
tensor([[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1]], dtype=torch.int32)

a.new_tensor([3,4])

运行结果:
tensor([3., 4.], dtype=torch.float64)

逐元素操作

这部分操作会对tensor的每一个元素(point-wise,又名element-wise)进行操作,此类操作的输入与输出形状一致。

函数功能
abs/sqrt/div/exp/fmod/log/pow…绝对值/平方根/除法/指数/求余/求幂…
cos/sin/asin/atan2/cosh…相关三角函数
ceil/round/floor/trunc上取整/四舍五入/下取整/只保留整数部分
clamp(input, min, max)超过min和max部分截断
sigmod/tanh…激活函数

对于很多操作,例如div、mul、pow、fmod等,PyTorch都实现了运算符重载,所以可以直接使用运算符。如a ** 2 等价于torch.pow(a,2), a * 2等价于torch.mul(a,2)

其中clamp(x, min, max)的输出满足以下公式:
y i = { m i n , if  x i < m i n x i , if  m i n ≤ x i ≤ m a x m a x , if  x i > m a x y_i = \begin{cases} min, & \text{if } x_i \lt min \\ x_i, & \text{if } min \le x_i \le max \\ max, & \text{if } x_i \gt max\\ \end{cases} yi=min,xi,max,if xi<minif minximaxif xi>max
clamp常用在某些需要比较大小的地方,如取一个tensor的每个元素与另一个数的较大值。

a = t.arange(0, 6).view(2, 3).float()
a.cos()

运行结果:
tensor([[ 1.0000, 0.5403, -0.4161],
[-0.9900, -0.6536, 0.2837]])

a % 3 # 等价于t.fmod(a, 3)

运行结果:
tensor([[0., 1., 2.],
[0., 1., 2.]])

a ** 2 # 等价于t.pow(a, 2)

运行结果:
tensor([[ 0., 1., 4.],
[ 9., 16., 25.]])

# 取a中的每一个元素与3相比较大的一个 (小于3的截断成3)
print(a)
t.clamp(a, min=3)

运行结果:
tensor([[0., 1., 2.],
[3., 4., 5.]])
tensor([[3., 3., 3.],
[3., 4., 5.]])

b = a.sin_() # 效果同 a = a.sin();b=a ,但是更高效节省显存
a

运行结果:
tensor([[ 0.0000, 0.8415, 0.9093],
[ 0.1411, -0.7568, -0.9589]])

归并操作

此类操作会使输出形状小于输入形状,并可以沿着某一维度进行指定操作。如加法sum,既可以计算整个tensor的和,也可以计算tensor中每一行或每一列的和。

函数功能
mean/sum/median/mode均值/和/中位数/众数
norm/dist范数/距离
std/var标准差/方差
cumsum/cumprod累加/累乘

以上大多数函数都有一个参数**dim**,用来指定这些操作是在哪个维度上执行的。关于dim(对应于Numpy中的axis)的解释众说纷纭,这里提供一个简单的记忆方式:

假设输入的形状是(m, n, k)

  • 如果指定dim=0,输出的形状就是(1, n, k)或者(n, k)
  • 如果指定dim=1,输出的形状就是(m, 1, k)或者(m, k)
  • 如果指定dim=2,输出的形状就是(m, n, 1)或者(m, n)

size中是否有"1",取决于参数keepdimkeepdim=True会保留维度1。注意,以上只是经验总结,并非所有函数都符合这种形状变化方式,如cumsum

a = t.ones(3, 3)
a.sum()

运行结果:
tensor(9.)

a.sum(dim = 0)

运行结果:
tensor([3., 3., 3.])

a.sum(dim = 0, keepdim = True)

运行结果:
tensor([[3., 3., 3.]])

a = t.arange(0, 6).view(2, 3)
print(a)
a.cumsum(dim=1) # 沿着行累加

运行结果:
tensor([[0, 1, 2],
[3, 4, 5]])
tensor([[ 0, 1, 3],
[ 3, 7, 12]])

比较

比较函数中有一些是逐元素比较,操作类似于逐元素操作,还有一些则类似于归并操作。

函数功能
gt/lt/ge/le/eq/ne大于/小于/大于等于/小于等于/等于/不等
topk最大的k个数
sort排序
max/min比较两个tensor最大最小值

表中第一行的比较操作已经实现了运算符重载,因此可以使用a>=ba>ba!=ba==b,其返回结果是一个ByteTensor,可用来选取元素。max/min这两个操作比较特殊,以max来说,它有以下三种使用情况:

  • t.max(tensor):返回tensor中最大的一个数
  • t.max(tensor,dim):指定维上最大的数,返回tensor和下标
  • t.max(tensor1, tensor2): 比较两个tensor相比较大的元素

至于比较一个tensor和一个数,可以使用clamp函数。下面举例说明。

a = t.linspace(0, 15, 6).view(2, 3)
a

运行结果:
tensor([[ 0., 3., 6.],
[ 9., 12., 15.]])

b = t.linspace(15, 0, 6).view(2, 3)
b

运行结果:
tensor([[15., 12., 9.],
[ 6., 3., 0.]])

a>b

运行结果:
tensor([[False, False, False],
[ True, True, True]])

t.gt(a, b)

运行结果:
tensor([[False, False, False],
[ True, True, True]])

a.gt(b)

运行结果:
tensor([[False, False, False],
[ True, True, True]])

a[a>b] # a中大于b的元素

运行结果:
tensor([ 9., 12., 15.])

t.max(a)

运行结果:
tensor(15.)

t.max(b, dim=1) 
# 第一个返回值的15和6分别表示第0行和第1行最大的元素
# 第二个返回值的0和0表示上述最大的数是该行第0个元素

运行结果:
torch.return_types.max(
values=tensor([15., 6.]),
indices=tensor([0, 0]))

t.max(a,b)

运行结果:
tensor([[15., 12., 9.],
[ 9., 12., 15.]])

# 比较a和10较大的元素
t.clamp(a, min=10)

运行结果:
tensor([[10., 10., 10.],
[10., 12., 15.]])

线性代数

PyTorch的线性函数主要封装了Blas和Lapack,其用法和接口都与之类似。

函数功能
trace对角线元素之和(矩阵的迹)
diag对角线元素
triu/tril矩阵的上三角/下三角,可指定偏移量
mm/bmm矩阵乘法,batch的矩阵乘法
addmm/addbmm/addmv/addr/badbmm…矩阵运算
t转置
dot/cross内积/外积
inverse求逆矩阵
svd奇异值分解

需要注意的是,矩阵的转置会导致存储空间不连续,需调用它的.contiguous方法将其转为连续。

b = a.t()
b.is_contiguous()

运行结果:
False

b.contiguous()
b.is_contiguous()

运行结果:
True

3.2 Tensor和Numpy

Tensor和Numpy数组之间具有很高的相似性,彼此之间的互操作也非常简单高效。需要注意的是,Numpy和Tensor共享内存。由于Numpy历史悠久,支持丰富的操作,所以当遇到Tensor不支持的操作时,可先转成Numpy数组,处理后再转回tensor,其转换开销很小。

import numpy as np
a = np.ones([2, 3],dtype=np.float32)
a

运行结果:
array([[1., 1., 1.],
[1., 1., 1.]], dtype=float32)

b = t.from_numpy(a)
b

运行结果:
tensor([[1., 1., 1.],
[1., 1., 1.]])

b = t.Tensor(a) # 也可以直接将numpy对象传入Tensor
b

运行结果:
tensor([[1., 1., 1.],
[1., 1., 1.]])

a[0, 1]=100
b

运行结果:
tensor([[ 1., 100., 1.],
[ 1., 1., 1.]])

c = b.numpy() # a, b, c三个对象共享内存
c

运行结果:
array([[ 1., 100., 1.],
[ 1., 1., 1.]], dtype=float32)

注意: 当numpy的数据类型和Tensor的类型不一样的时候,数据会被复制,不会共享内存。

tensor = t.tensor(a) 
tensor[0,0]=0
a

运行结果:
array([[ 1., 100., 1.],
[ 1., 1., 1.]], dtype=float32)

广播法则

广播法则(broadcast)是科学运算中经常使用的一个技巧,它在快速执行向量化的同时不会占用额外的内存/显存。
Numpy的广播法则定义如下:

  • 让所有输入数组都向其中shape最长的数组看齐,shape中不足的部分通过在前面加1补齐
  • 两个数组要么在某一个维度的长度一致,要么其中一个为1,否则不能计算
  • 当输入数组的某个维度的长度为1时,计算时沿此维度复制扩充成一样的形状

PyTorch当前已经支持了自动广播法则,但是笔者还是建议读者通过以下两个函数的组合手动实现广播法则,这样更直观,更不易出错:

  • unsqueeze或者view,或者tensor[None],:为数据某一维的形状补1,实现法则1
  • expand或者expand_as,重复数组,实现法则3;该操作不会复制数组,所以不会占用额外的空间。

注意,repeat实现与expand相类似的功能,但是repeat会把相同数据复制多份,因此会占用额外的空间。

a = t.ones(3, 2)
b = t.zeros(2, 3,1)
# 自动广播法则
# 第一步:a是2维,b是3维,所以先在较小的a前面补1 ,
#               即:a.unsqueeze(0),a的形状变成(1,3,2),b的形状是(2,3,1),
# 第二步:   a和b在第一维和第三维形状不一样,其中一个为1 ,
#               可以利用广播法则扩展,两个形状都变成了(2,3,2)
a+b

运行结果:
tensor([[[1., 1.],
[1., 1.],
[1., 1.]],

    [[1., 1.],
     [1., 1.],
     [1., 1.]]])
# 手动广播法则
# 或者 a.view(1,3,2).expand(2,3,2)+b.expand(2,3,2)
a[None].expand(2, 3, 2) + b.expand(2,3,2)

运行结果:
tensor([[[1., 1.],
[1., 1.],
[1., 1.]],

    [[1., 1.],
     [1., 1.],
     [1., 1.]]])

3.3 内部结构

tensor的数据结构分为头信息区(Tensor)和存储区(Storage),信息区主要保存着tensor的形状(size)、步长(stride)、数据类型(type)等信息,而真正的数据则保存成连续数组。由于数据动辄成千上万,因此信息区元素占用内存较少,主要内存占用则取决于tensor中元素的数目,也即存储区的大小。

一般来说一个tensor有着与之相对应的storage, storage是在data之上封装的接口,便于使用,而不同tensor的头信息一般不同,但却可能使用相同的数据。

a = t.arange(0, 16)
a.storage()

运行结果:
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[torch.LongStorage of size 16]

b = a.view(4, 4)
b.storage()

运行结果:
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[torch.LongStorage of size 16]

# 一个对象的id值可以看作它在内存中的地址
# storage的内存地址一样,即是同一个storage
id(b.storage()) == id(a.storage())

运行结果:
True

# a改变,b也随之改变,因为他们共享storage
a[1] = 100
b

运行结果:
tensor([[ 0, 100, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11],
[ 12, 13, 14, 15]])

c = a[2:] 
c.storage()

运行结果:
0
100
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[torch.LongStorage of size 16]

c.data_ptr(), a.data_ptr() # data_ptr返回tensor首元素的内存地址
# 可以看出相差16,这是因为4*4=16--相差四个元素,每个元素占4个字节(float)

运行结果:
(2096723957648, 2096723957632)

c[0] = -100 # c[0]的内存地址对应a[2]的内存地址
a

运行结果:
tensor([ 0, 100, -100, 3, 4, 5, 6, 7, 8, 9, 10, 11,
12, 13, 14, 15])

d = t.LongTensor(c.storage())
d[0] = 6666
b

运行结果:
tensor([[6666, 100, -100, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11],
[ 12, 13, 14, 15]])

# 下面4个tensor共享storage
id(a.storage()) == id(b.storage()) == id(c.storage()) == id(d.storage())

运行结果:
True

a.storage_offset(), c.storage_offset(), d.storage_offset()

运行结果:
(0, 2, 0)

e = b[::2, ::2] # 隔2行/列取一个元素
id(e.storage()) == id(a.storage())

运行结果:
True

b.stride(), e.stride()

运行结果:
((4, 1), (8, 2))

e.is_contiguous()

运行结果:
False

可见绝大多数操作并不修改tensor的数据,而只是修改了tensor的头信息。这种做法更节省内存,同时提升了处理速度。在使用中需要注意。
此外有些操作会导致tensor不连续,这时需调用tensor.contiguous方法将它们变成连续的数据,该方法会使数据复制一份,不再与原来的数据共享storage。
另外读者可以思考一下,之前说过的高级索引一般不共享stroage,而普通索引共享storage,这是为什么?(提示:普通索引可以通过只修改tensor的offset,stride和size,而不修改storage来实现)。

3.4 其它有关Tensor的话题

GPU/CPU

tensor可以很随意的在gpu/cpu上传输。使用tensor.cuda(device_id)或者tensor.cpu()。另外一个更通用的方法是tensor.to(device)

a = t.randn(3, 4)
a.device

运行结果:
device(type=‘cpu’)

if t.cuda.is_available():
    a = t.randn(3,4, device=t.device('cuda:1'))
    # 等价于
    # a.t.randn(3,4).cuda(1)
    # 但是前者更快
 a.device

运行结果:
device(type=‘cpu’)

device = t.device('cpu')
a.to(device)

运行结果:
tensor([[-0.9587, 0.4530, -0.8041, -0.0767],
[-0.4112, 0.6370, -0.7862, 0.4911],
[ 0.5726, -2.2863, -0.2148, 0.7132]])

注意

  • 尽量使用tensor.to(device), 将device设为一个可配置的参数,这样可以很轻松的使程序同时兼容GPU和CPU
  • 数据在GPU之中传输的速度要远快于内存(CPU)到显存(GPU), 所以尽量避免频繁的在内存和显存中传输数据。

持久化

Tensor的保存和加载十分的简单,使用t.save和t.load即可完成相应的功能。在save/load时可指定使用的pickle模块,在load时还可将GPU tensor映射到CPU或其它GPU上。

if t.cuda.is_available():
    a = a.cuda(1) # 把a转为GPU1上的tensor,
    t.save(a,'a.pth')

    # 加载为b, 存储于GPU1上(因为保存时tensor就在GPU1上)
    b = t.load('a.pth')
    # 加载为c, 存储于CPU
    c = t.load('a.pth', map_location=lambda storage, loc: storage)
    # 加载为d, 存储于GPU0上
    d = t.load('a.pth', map_location={'cuda:1':'cuda:0'})

向量化

向量化计算是一种特殊的并行计算方式,相对于一般程序在同一时间只执行一个操作的方式,它可在同一时间执行多个操作,通常是对不同的数据执行同样的一个或一批指令,或者说把指令应用于一个数组/向量上。向量化可极大提高科学运算的效率,Python本身是一门高级语言,使用很方便,但这也意味着很多操作很低效,尤其是for循环。在科学计算程序中应当极力避免使用Python原生的for循环

def for_loop_add(x, y):
    result = []
    for i,j in zip(x, y):
        result.append(i + j)
    return t.Tensor(result)
    x = t.zeros(100)
y = t.ones(100)
%timeit -n 10 for_loop_add(x, y)
%timeit -n 10 x + y

运行结果:
1.29 ms ± 518 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
The slowest run took 11.88 times longer than the fastest. This could mean that an intermediate result is being cached.
7.73 µs ± 11.4 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

可见二者有超过几十倍的速度差距,因此在实际使用中应尽量调用内建函数(buildin-function),这些函数底层由C/C++实现,能通过执行底层优化实现高效计算。因此在平时写代码时,就应养成向量化的思维习惯,千万避免对较大的tensor进行逐元素遍历。

此外还有以下几点需要注意:

  • 大多数t.function都有一个参数out,这时候产生的结果将保存在out指定tensor之中。
  • t.set_num_threads可以设置PyTorch进行CPU多线程并行计算时候所占用的线程数,这个可以用来限制PyTorch所占用的CPU数目。
  • t.set_printoptions可以用来设置打印tensor时的数值精度和格式。
    下面举例说明。
a = t.arange(0, 20000000)
print(a[-1], a[-2]) # 32bit的IntTensor精度有限导致溢出
b = t.LongTensor()
t.arange(0, 20000000, out=b) # 64bit的LongTensor不会溢出
print(b[-1],b[-2])

运行结果:
tensor(19999999) tensor(19999998)
tensor(19999999) tensor(19999998)

a = t.randn(2,3)
a

运行结果:
tensor([[-1.5487, -1.1022, -0.9679],
[ 0.9768, 0.4358, -2.4326]])

t.set_printoptions(precision=10)
a

运行结果:
tensor([[-1.5486671925, -1.1021592617, -0.9678921700],
[ 0.9767541289, 0.4357590973, -2.4326353073]])

3.5 小试牛刀:线性回归

线性回归是机器学习入门知识,应用十分广泛。线性回归利用数理统计中回归分析,来确定两种或两种以上变量间相互依赖的定量关系的,其表达形式为 y = w x + b + e y = wx+b+e y=wx+b+e e e e为误差服从均值为0的正态分布。首先让我们来确认线性回归的损失函数:
l o s s = ∑ i N 1 2 ( y i − ( w x i + b ) ) 2 loss = \sum_i^N \frac 1 2 ({y_i-(wx_i+b)})^2 loss=iN21(yi(wxi+b))2
然后利用随机梯度下降法更新参数 w \textbf{w} w b \textbf{b} b来最小化损失函数,最终学得 w \textbf{w} w b \textbf{b} b的数值。

import numpy as np
import pandas as pd
import torch as t
import matplotlib.pyplot as plt
t.manual_seed(1000) 
def get_fake_data(batch_size = 8):
    x = t.rand(batch_size, 1)*20
    y = x * 2 + 3 + t.randn(batch_size, 1)
    return x, y
x, y = get_fake_data()
plt.figure(figsize = (8, 5))
plt.scatter(x, y, label = 'trainning data')
plt.legend()
plt.show()

运行结果:
在这里插入图片描述

w = t.rand(1, 1, requires_grad = True)
b = t.zeros(1, 1, requires_grad = True)
lr = 1e-3
def computerLoss(pres, y):
    return t.sum(0.5*(pres - y)**2)
pres = x@w + b
loss = computerLoss(pres, y)
loss

运行结果:
tensor(1145.5450, grad_fn=)

x, y = get_fake_data()
x1 = t.linspace(0, 25, 8).view(8, 1)
y1 = x1*w.data + b.data
plt.figure(figsize = (8, 5))
plt.scatter(x, y, label = 'trainning data')
plt.plot(x1, y1, label = 'ppredict data', c = 'r')
plt.legend()
plt.show()

运行结果:
在这里插入图片描述

for i in range(1000):
    pres = x@w + b
    loss = computerLoss(pres, y)
    loss.backward()
    w.data -= w.grad.data * lr
    b.data -= b.grad.data * lr
    w.grad.zero_()
    b.grad.zero_()
x, y = get_fake_data()
x1 = t.linspace(0, 25, 8).view(8, 1)
y1 = x1*w.data + b.data
plt.figure(figsize = (8, 5))
plt.scatter(x, y, label = 'trainning data')
plt.plot(x1, y1, label = 'ppredict data', c = 'r')
plt.legend()
plt.show()

运行结果:
在这里插入图片描述

4 神经网络工具箱nn

autograd实现了自动微分系统,然而对深度学习来说过于底层,本章将介绍的nn模块,是构建于autograd之上的神经网络模块。除了nn之外,我们还会介绍神经网络中常用的工具,比如优化器optim、初始化init等。

4.1 nn.Module

第3章中提到,使用autograd可实现深度学习模型,但其抽象程度较低,如果用其来实现深度学习模型,则需要编写的代码量极大。在这种情况下,torch.nn应运而生,其是专门为深度学习设计的模块。torch.nn的核心数据结构是Module,它是一个抽象的概念,既可以表示神经网络中的某个层(layer),也可以表示一个包含很多层的神经网络。在实际使用中,最常见的做法继承nn.Module,撰写自己的网络/层。下面先来看看如何使用nn.Module实现自己的全连接层。全连接层,又名仿射层,输入y和输入x满足y=Wx+b,W和b是可学习的参数。

import numpy as np
import torch as t
from torch import nn 
# 定义线性模型:y = w * x + b
class Linear(nn.Module):    # 继承nn.Module
    def __init__(self,in_features,out_features):
        nn.Module.__init__(self)    # 等价于nn.Module.__init__(self)
        self.w = nn.Parameter(t.randn(in_features,out_features))
        self.b = nn.Parameter(t.randn(out_features))
        
    def forward(self,x):
        xw = x.mm(self.w)
        y = xw + self.b.expand_as(xw)
        return y
net = Linear(4, 3)
x = t.randn(2, 4)
y = net(x)
y

运行结果:
tensor([[-0.7086, 0.7078, 2.0764],
[ 1.3967, 1.7351, 1.3748]], grad_fn=)

for name, parameter in net.named_parameters():
    print(name, parameter)

运行结果:
w Parameter containing:
tensor([[-0.3579, -0.7854, -1.0309],
[ 0.4088, 2.4716, -1.1229],
[-0.0056, -1.9836, -0.1046],
[ 1.1730, 0.1006, 1.0346]], requires_grad=True)
b Parameter containing:
tensor([0.2090, 1.4149, 1.4662], requires_grad=True)

可见,全连接层的实现非常简单,其代码量不超过10行,但需注意以下几点:

  • 自定义层Linear必须继承nn.Module,并且在其构造函数中需调用nn.Module的构造函数,即super(Linear,self).init()或nn.Module.init(self)。
  • 在构造函数init中必须自己定义可学习的参数,并封装成Parameter,如在本例中我们把w和b封装成Parameter。Parameter是一种特殊的Variable,但其默认需要求导(requires_grad=True),感兴趣的读者可以通过nn.Parameter??查看Parameter类的源代码。
  • forward函数实现前向传播过程,其输入可以是一个或多个variable,对x的任何操作也必须是variable支持的操作。
  • 无须写反向传播函数,因其前向传播都是对variable进行操作,nn.Module能够利用autograd自动实现反向传播,这一点比Function简单许多。
  • 使用时,直观上可将net看成数学概念中的函数,调用net(x)即可得到x对应的结果。它等价于net.call(x),在call函数中,主要调用的是net.forward(x),另外还对钩子做了一些处理。所以在实际使用中应尽量使用net(x)而不是使用net.forward(x),关于钩子技术的具体内容将在下文讲到。
  • Module中的可学习参数可以通过named_parameters()或者parameters()返回迭代器,前者会给每个parameter附上名字,使其更具有辨识度。

可见,利用Module实现的全连接层,比利用Function实现的更简单,因其不再需要写反向传播函数。

Module能够自动检测到自己的parameter,并将其作为学习参数。除了parameter,Module还包含子Module,主Module能够递归查找子Module中的parameter。下面再来看看稍微复杂一点的网络:多层感知机。

多层感知机的网络结构如图所示。它由两个全连接层组成,采用sigmoid函数作为激活函数(图中没有画出)。
在这里插入图片描述

# 定义多层感知机
class Perceptron(nn.Module): # 继承nn.Module
    def __init__(self, in_features, hidden_features, out_features):
        super().__init__()
        self.layer1 = Linear(in_features, hidden_features)
        self.layer2 = Linear(hidden_features, out_features)
    def forward(self, x):
        y = self.layer1(x)
        y = self.layer2(y)
        return y
perceptron = Perceptron(3, 4, 1)
x = t.randn(2, 3)
y =perceptron(x)
y

运行结果:
tensor([[-2.2504],
[-0.5843]], grad_fn=)

for name, parameter in perceptron.named_parameters():
    print(name, parameter)

运行结果:
layer1.w Parameter containing:
tensor([[-0.5368, -0.6077, -1.3088, -0.1669],
[-0.2287, 0.3532, -2.0303, 0.6538],
[-1.0113, -1.9296, -0.2059, 1.3605]], requires_grad=True)
layer1.b Parameter containing:
tensor([ 0.9178, 1.1564, 1.1628, -0.6162], requires_grad=True)
layer2.w Parameter containing:
tensor([[-0.2354],
[-0.7007],
[ 0.4901],
[ 0.3775]], requires_grad=True)
layer2.b Parameter containing:
tensor([0.6461], requires_grad=True)

可见,即使是稍复杂的多层感知机,其实现依旧很简单。这里需要注意以下两个知识点。

  • 构造函数init中,可利用前面自定义的Linear层(Module)作为当前Module对象的一个子Module,它的可学习参数,也会成为当前Module的可学习参数。

  • 在前向传播函数中,我们有意识地将输出变量都命名为y,是为了能让Python回收一些中间层的输出,从而节省内存。但并不是所有的中间结果都会被回收,有些variable虽然名字被覆盖,但其在反向传播时仍需要用到,此时Python的内存回收模块将通过检查引用计数,不会回收这一部分内存。
    Module中parameter的全局命名规范如下:

  • Parameter直接命名。例如self.param_name = nn.Parameter(t.randn(3,4)),命名为param_name。

  • 子Module中的parameter,会在其名字之前加上当前Module的名字。例如self.sub_module = SubModule(),SubModule中有个parameter的名字也叫作param_name,那么二者拼接而成的parameter name就是sub_module.param_name。

为了方便用户使用,PyTorch实现了神经网络中绝大多数的layer,这些layer都继承于nn.Module,封装了可学习参数parameter,并实现了forward函数,且专门针对GPU运算进行了CuDNN优化,其速度和性能都十分优异。本书不准备对nn.Module中的所有层进行详细介绍,具体内容读者可参照官方文档或在IPython/Jupyter中使用nn.layer?查看。阅读文档时应主要关注以下几点。

  • 构造函数的参数,如nn.Linear(in_features,out_features,bias),需关注这三个参数的作用。
  • 属性、可学习参数和子Module。如nn.Linear中有weight和bias两个可学习参数,不包含子Module。
  • 输入输出的形状,如nn.Linear的输入形状是(N,input_features),输出形状为(N,output_features),N是batch_size。

这些自定义layer对输入形状都有假设:输入的不是单个数据,而是一个batch。若想输入一个数据,必须调用unsqueeze(0)函数将数据伪装成batch_size=1的batch。

下面将从应用层面出发,对一些常用的layer做简单介绍,更详细的用法请查看官方文档。

4.2 常用的神经网络层

4.2.1 图像相关层

图像相关层主要包括卷积层(Conv)、池化层(Pool)等,这些层在实际使用中分为一维(1D)、二维(2D)和三维(3D),池化方式又分为平均池化(AvgPool)、最大值池化(MaxPool)、自适应池化(AdaptiveAvgPool)等。卷积层除了常用的前向卷积外,还有逆卷积(TransposeConv)。下面举例说明。

from PIL import Image
from torchvision.transforms import ToTensor, ToPILImage
to_tensor = ToTensor() # img -> tensor
to_pil = ToPILImage()
lena = Image.open('C:\\Users\\Inuyasha\\Desktop\\lena.bmp')
lena

运行结果:
在这里插入图片描述

# 输入是一个batch,batch_size=1
input = to_tensor(lena).unsqueeze(0) 

# 锐化卷积核
kernel = t.ones(3, 3)/-9.
kernel[1][1] = 1
conv = nn.Conv2d(1, 1, (3, 3), 1, bias=False)
conv.weight.data = kernel.view(1, 1, 3, 3)

out = conv(input)
to_pil(out.data.squeeze(0))

运行结果:
在这里插入图片描述

池化层可以看成是一种特殊的卷积层,用来下采样。但池化层没有可学习的参数,其weight是固定的

pool = nn.AvgPool2d(2,2)
list(pool.parameters())

运行结果:
[]

out = pool(input)
to_pil(out.data.squeeze(0))

运行结果:
在这里插入图片描述

除了卷积层和池化层,深度学习中还将常用到以下几个层。

  • Linear:全连接层。
  • BatchNorm:批规范化层,分为1D、2D和3D。除了标准的- BatchNorm之外,还有在风格迁移中常用到的InstanceNorm层。
  • Dropout:dropout层,用于防止过拟合,同样分为1D、2D和3D。
    下面通过例子讲解它们的使用方法。
# 输入 batch_size=2,维度3
input = t.randn(2, 3)
linear = nn.Linear(3, 4)
h = linear(input)
h

运行结果:
tensor([[-0.1105, 0.1743, 0.2182, 0.2658],
[ 1.0727, 1.7890, 0.5618, 0.4677]], grad_fn=<AddmmBackward>)

# 4 channel,初始化标准差为4,均值为0
bn = nn.BatchNorm1d(4)
bn.weight.data = t.ones(4) * 4
bn.bias.data = t.zeros(4)

bn_out = bn(h)
# 注意输出的均值和方差
# 方差是标准差的平方,计算无偏方差分母会减1
# 使用unbiased=False 分母不减1
bn_out.mean(0), bn_out.var(0, unbiased=False)

运行结果:
(tensor([ 0.0000e+00, 0.0000e+00, -3.5763e-07, 0.0000e+00],
grad_fn=),
tensor([15.9995, 15.9998, 15.9946, 15.9843], grad_fn=))

# 每个元素以0.5的概率舍弃
dropout = nn.Dropout(0.5)
o = dropout(bn_out)
o # 有一半左右的数变为0

运行结果:
tensor([[-0.0000, -7.9999, -7.9986, -7.9961],
[ 7.9999, 0.0000, 0.0000, 0.0000]], grad_fn=<MulBackward0>)

以上很多例子中都对Module的属性直接操作,其大多数是可学习参数,一般会随着学习的进行而不断改变。实际使用中除非需要使用特殊的初始化,否则应尽量不要直接修改这些参数。

4.2.2 激活函数

PyTorch实现了常见的激活函数,其具体的接口信息可参见官方文档。这些激活函数可作为独立的layer使用。这里将介绍最常用的激活函数ReLU,其数学表达式为:在这里插入图片描述

relu = nn.ReLU(inplace=True)
input = t.randn(2, 3)
print(input)
output = relu(input)
print(output) # 小于0的都被截断为0
# 等价于input.clamp(min=0)

运行结果:
tensor([[ 0.7430, -0.1240, -1.0032],
[ 1.2273, -0.5622, -0.7272]])
tensor([[0.7430, 0.0000, 0.0000],
[1.2273, 0.0000, 0.0000]])

ReLU函数有个inplace参数,如果设为True,它会把输出直接覆盖到输入中,这样可以节省内存/显存。之所以可以覆盖是因为在计算ReLU的反向传播时,只需根据输出就能推算出反向传播的梯度。但是只有少数的autograd操作支持inplace操作(如variable.sigmoid_()),除非你明确地知道自己在做什么,否则一般不要使用inplace操作。在以上例子中,都是将每一层的输出直接作为下一层的输入,这种网络称为前馈传播网络(Feedforward Neural Network)。对于此类网络,如果每次都写复杂的forward函数会有些麻烦,在此就有两种简化方式,ModuleList和Sequential。其中Sequential是一个特殊的Module,它包含几个子Module,前向传播时会将输入一层接一层地传递下去。ModuleList也是一个特殊的Module,可以包含几个子Module,可以像用list一样使用它,但不能直接把输入传给ModuleList。下面我们举例说明。

# Sequential的三种写法
net1 = nn.Sequential()
net1.add_module('conv', nn.Conv2d(3, 3, 3))
net1.add_module('batchnorm', nn.BatchNorm2d(3))
net1.add_module('activation_layer', nn.ReLU())

net2 = nn.Sequential(
        nn.Conv2d(3, 3, 3),
        nn.BatchNorm2d(3),
        nn.ReLU()
        )

from collections import OrderedDict
net3= nn.Sequential(OrderedDict([
          ('conv1', nn.Conv2d(3, 3, 3)),
          ('bn1', nn.BatchNorm2d(3)),
          ('relu1', nn.ReLU())
        ]))
print('net1:', net1)
print('net2:', net2)
print('net3:', net3)

运行结果:
net1: Sequential(
(conv): Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1))
(batchnorm): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(activation_layer): ReLU()
)
net2: Sequential(
(0): Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1))
(1): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): ReLU()
)
net3: Sequential(
(conv1): Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1))
(bn1): BatchNorm2d(3, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu1): ReLU()
)

# 可根据名字或序号取出子module
net1.conv, net2[0], net3.conv1

运行结果:
(Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1)),
Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1)),
Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1)))

input = t.rand(1, 3, 4, 4)
output = net1(input)
output = net2(input)
output = net3(input)
output = net3.relu1(net1.batchnorm(net1.conv(input)))

运行结果:

modellist = nn.ModuleList([nn.Linear(3,4), nn.ReLU(), nn.Linear(4,2)])
input = t.randn(1, 3)
for model in modellist:
    input = model(input)
# 下面会报错,因为modellist没有实现forward方法
# output = modelist(input)

运行结果:

看到这里,读者可能会问,为何不直接使用Python中自带的list,而非要多此一举呢?这是因为ModuleList是Module的子类,当在Module中使用它时,就能自动识别为子Module。

下面我们举例说明。

class MyModule(nn.Module):
    def __init__(self):
        super(MyModule, self).__init__()
        self.list = [nn.Linear(3, 4), nn.ReLU()]
        self.module_list = nn.ModuleList([nn.Conv2d(3, 3, 3), nn.ReLU()])
    def forward(self):
        pass
model = MyModule()
model

运行结果:
MyModule(
(module_list): ModuleList(
(0): Conv2d(3, 3, kernel_size=(3, 3), stride=(1, 1))
(1): ReLU()
)
)

for name, param in model.named_parameters():
    print(name, param.size())

运行结果:
module_list.0.weight torch.Size([3, 3, 3, 3])
module_list.0.bias torch.Size([3])

可见,list中的子Module并不能被主Module识别,而ModuleList中的子Module能够被主Module识别。这意味着如果用list保存子Module,将无法调整其参数,因其未加入到主Module的参数中。

除ModuleList之外还有ParameterList,它是一个可以包含多个parameter的类list对象。在实际使用中,使用方式与ModuleList类似。在构造函数init中用到list、tuple、dict等对象时,一定要思考是否应该用ModuleList或ParameterList代替。

4.2.3 循环神经网络层

近些年,随着深度学习和自然语言处理的结合加深,循环神经网络(RNN)的使用也越来越多,关于RNN的基础知识,推荐阅读colah的文章
入门。PyTorch中实现了如今最常用的三种RNN:RNN(vanilla RNN)、LSTM和GRU。此外还有对应的三种RNNCell。

RNN和RNNCell层的区别在于前者能够处理整个序列,而后者一次只处理序列中一个时间点的数据,前者封装更完备更易于使用,后者更具灵活性。RNN层可以通过组合调用RNNCell来实现。


运行结果:


运行结果:


运行结果:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值