001a_神经网络_基础

神经网络初探

声明:本教程使用PyTorch>=1.3和Fastai v1以上版本。原教程来自Jeremy Howard,因其为全英文,同时笔者在自学过程中记了一些笔记,本着分离的精神遂将笔记分享。须知原作者为Jeremy Howard。

MNIST数据集设置

我们使用黑白手写数字数据集MNIST,其包含70000张从0到9的手写数字图片,图像尺寸为28*28像素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pathlib import Path
import requests

DATA_PATH = Path('data')
PATH = DATA_PATH/'mnist'

PATH.mkdir(parents=True, exist_ok=True)

URL='http://deeplearning.net/data/mnist/'
FILENAME='mnist.pkl.gz'

if not (PATH/FILENAME).exists():
content = requests.get(URL+FILENAME).content
(PATH/FILENAME).open('wb').write(content)

数据集是numpy数组格式,已经使用pickle序列化了,这里把它们导入内存的时候要反序列化。

1
2
3
4
5
import pickle, gzip

with gzip.open(PATH/FILENAME, 'rb') as f:
((x_train, y_train), (x_valid, y_valid), _) = \
pickle.load(f, encoding='latin-1')

每个图像大小为28x28,并被存储为长度为784 (=28x28)的向量而非矩阵。我们来看一眼:我们需要先将其重塑为二维数组。

1
2
3
4
5
6
7
%matplotlib inline

from matplotlib import pyplot
import numpy as np

pyplot.imshow(x_train[0].reshape((28,28)), cmap="gray")
x_train.shape

输出:

1
2
(50000, 784)
<class 'numpy.ndarray'>

image
PyTorch使用的数组格式是torch.tensor而非numpy数组,因此我们需要转化一下数据格式。

1
2
3
4
5
6
import torch 

x_train,y_train,x_valid,y_valid = map(torch.tensor, \
(x_train,y_train,x_valid,y_valid))
n,c = x_train.shape
x_train, x_train.shape, y_train.min(), y_train.max()

## 完全手撸的神经网络(不用torch.nn模块) 让我们先用PyTorch张量运算建立一个模型。我们假设你已经熟悉了神经网络的基础知识。(如果你没有,你可以在fast.ai课程中学习一下。) PyTorch提供了创建随机或零填充张量的方法,我们将使用这些方法创建一个简单线性模型的权重和偏差。这些只是普通的张量,有一个非常特殊的添加:我们告诉PyTorch它们需要一个梯度。这使得PyTorch记录了对张量做的所有操作,这样它就可以自动计算反向传播过程中的梯度了! 对于权重,我们在初始化之后设置requires_grad,因为我们不希望该步骤包含在梯度中。(注意,在PyTorch中标记_表示操作是执行后赋值回去。) 注:我们在这里初始化的权重与Xavier初始化(乘以`$\dfrac{1}{\sqrt{n}}$`)。
1
2
3
4
5
import math

weights = torch.randn(784,10)/math.sqrt(784)
weights.requires_grad_()
bias = torch.zeros(10, requires_grad=True)

由于PyTorch能够自动计算梯度,我们可以使用任何标准的Python函数(或可调用对象)作为模型!让我们写一个简单的矩阵乘法并加入加法来创建一个简单的线性模型。我们还需要一个激活函数,因此我们将编写log_softmax并使用它。请记住:虽然PyTorch提供了许多预先编写的损失函数、激活函数等,但是您可以使用普通python轻松地编写自己的函数。PyTorch甚至可以为你的函数自动创建快速的GPU或矢量化的CPU代码。

1
2
3
4
5
6
7
# 损失函数
def log_softmax(x):
return x - x.exp().sum(-1).log().unsqueeze(-1)

# 前面传播
def model(xb):
return log_softmax(xb @ weights + bias)

在上面,“@”代表点积操作。我们将对一批数据(在本例中为64张图像)调用函数。这是一次向前传播。注意,我们的预测在这个阶段未必比随机的好,因为我们从随机的权值开始。

1
2
3
4
5
bs=64                  # batch size

xb = x_train[0:bs] # a mini-batch from x
preds = model(xb) # predictions
preds[0], preds.shape

正如你所看到的,preds张量不仅包含张量值,还包含一个梯度。我们稍后会用它来做反向传播(back propagation)。
让我们实现负对数似然函数作为损失函数(同样,我们可以使用标准的Python):

1
2
3
4
def nll(input, target):
return -input[range(target.shape[0]), target].mean()

loss_func = nll

让我们用我们的随机模型来检查一下我们的损失,这样我们就可以看到我们是否会在反向传播后有所改善。

1
2
3
4
yb = y_train[0:bs]

# 用负对数似然函数衡量的损失
loss_func(preds, yb)

让我们实现一个函数来计算我们的模型的精度。对于每个预测,如果具有最大值的索引与目标值匹配,则预测是正确的。

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

让我们检查一下随机模型的准确性,这样我们就可以看到我们的准确性是否会随着损失的增加而提高。

1
accuracy(preds, yb)

我们现在可以运行一个训练循环。对于每个迭代,我们将:

  • 选择一小批数据(大小为bs)
  • 使用模型进行预测
  • 计算损失
  • loss.backward()更新模型的梯度(在本例中更新的是权重和偏差的梯度)。
  • 我们现在使用这些梯度来更新权重和偏差。我们在torch.no_grad()上下文管理器中执行此操作,因为我们不希望在下一次计算梯度时记录这些操作。
  • 然后,我们将梯度设置为零,以便为下一个循环做好准备。否则,我们的梯度将记录所有已经发生的操作的运行记录(例如,loss. backwards()将梯度添加到已经存储的内容中,而不是替换它们),这样的话下次更新参数就使用了错误的梯度了。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    from IPython.core.debugger import set_trace

    lr = 0.5 # learning rate
    epochs = 2 # how many epochs to train for

    for epoch in range(epochs):
    for i in range((n-1)//bs + 1):
    # set_trace()
    start_i = i*bs
    end_i = start_i+bs
    xb = x_train[start_i:end_i]
    yb = y_train[start_i:end_i]
    pred = model(xb)
    loss = loss_func(pred, yb)

    loss.backward()
    with torch.no_grad():
    weights -= weights.grad * lr
    bias -= bias.grad * lr
    weights.grad.zero_()
    bias.grad.zero_()

好了:我们已经完全从零开始创建并训练了一个最小的神经网络(在本例中是逻辑回归,因为我们没有隐藏层)!
让我们检查一下损失和准确性,并与我们之前得到的进行比较。我们预计损失会减少,准确性会提高,他们确实做到了。

1
loss_func(model(xb), yb), accuracy(model(xb), yb)

## 使用torch.nn.functional 现在我们将重构我们的代码,使它做与以前相同的事情,只是我们将开始利用PyTorch的nn类使它更简洁和灵活。从这里开始的每一步,我们都应该使我们的代码变得更短、更容易理解和/或更灵活。 第一步,也是最简单的一步,是将我们手写的激活函数和损失函数替换为来自于torch.nn.functional的函数(通常按照惯例导入后临时命名为F),从而缩短代码。。这个模块包含了torch.nn库中的所有函数(而库的其他部分包含类)。除了各种各样的损失和激活函数之外,您还可以在这里找到一些用于创建神经网络的方便函数,比如池函数(pooling)。(也有一些函数用于执行卷积、线性层等,但是我们将看到,使用库的其他部分通常可以更好地处理这些函数。) 如果您使用的是负对数似然损失和对数softmax激活,那么Pytorch提供了一个单独的函数F.cross_entropy两者结合起来,即交叉熵。因此我们甚至可以从现在的模型中移除激活函数了。
1
2
3
4
5
import torch.nn.functional as F
loss_func = F.cross_entropy

def model(xb):
return xb @ weights + bias

注意,我们不再在模型函数中调用log_softmax。我们确认一下我们的损失和准确性和之前一样:

1
loss_func(model(xb), yb), accuracy(model(xb), yb)


## 使用nn.Module重构 接下来,我们将使用nn.Module和nn.Parameter进行更清晰,更简洁的训练循环。 我们将从nn.Module(它本身是一个类并且能够跟踪状态)继承并创建它的子类。在本例中,我们要创建一个类,该类包含前向传播的weights, bias和前向传播的函数。nn.Module具有许多我们将要使用的属性和方法(例如.parameters()和.zero_grad())。

注意:nn.Module(大写M)是PyTorch的特定概念,并且是我们将大量使用的类。 nn.Module不要与Python的module概念混淆,该概念是可以导入的Python代码文件。

1
2
3
4
5
6
7
8
9
10
from torch import nn

class Mnist_Logistic(nn.Module):
def __init__(self):
super().__init__()
self.weights = nn.Parameter(torch.randn(784,10)/\
math.sqrt(784))
self.bias = nn.Parameter(torch.zeros(10))

def forward(self, xb): return xb @ self.weights + self.bias

因为我们现在使用的是对象而不是函数,我们首先要实例化我们的模型:

1
model = Mnist_Logistic()

有意思的是,nn.Module()这个类里实现了call()这个方法,因此这个类实例出来的对象是可调用的(本博客内的文章有详细介绍),因此接下来我们可以直接把model作为函数来调用。
现在我们可以用以前的方法来计算损失。注意,nn.Module对象被当作函数来使用(但是在后台Pytorch会自动调用我们的forward方法)。

1
loss_func(model(xb), yb)

在之前的训练循环中,我们必须按名称更新每个参数的值,并手动将每个参数的梯度归零,如下所示:

1
2
3
4
5
with torch.no_grad():
weights -= weights.grad * lr
bias -= bias.grad * lr
weights.grad.zero_()
bias.grad.zero_()

现在,我们可以利用model.parameters()和model.zero_grad()(它们都是由PyTorch为n. module定义的)来简化这些步骤,减少忘记一些参数的错误,特别是当我们有一个更复杂的模型时:

1
2
3
with torch.no_grad():
for p in model.parameters(): p -= p.grad * lr
model.zero_grad()

我们将在’fit’函数中封装我们的训练循环,以便稍后再次运行它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def fit():
for epoch in range(epochs):
for i in range((n-1)//bs + 1):
start_i = i*bs
end_i = start_i+bs
xb = x_train[start_i:end_i]
yb = y_train[start_i:end_i]
pred = model(xb)
loss = loss_func(pred, yb)

loss.backward()
with torch.no_grad():
for p in model.parameters(): p -= p.grad * lr
model.zero_grad()

fit()

让我们再次检查一下我们的损失是否减少了:

1
loss_func(model(xb), yb)

## 使用nn.Linear重构 我们继续重构代码。而不是手动定义和初始化参数再手动运算。我们将使用Pytorch的类nn.Linear类来代替。一个线性层的线性,它为我们做了所有这些。Pytorch有许多类型的预定义层,这些层可以极大地简化我们的代码,并且常常使它更快。
1
2
3
4
5
6
class Mnist_Logistic(nn.Module):
def __init__(self):
super().__init__()
self.lin = nn.Linear(784,10)

def forward(self, xb): return self.lin(xb)

我们实例化我们的模型,并按照与之前相同的方式计算损失:

1
2
model = Mnist_Logistic()
loss_func(model(xb), yb)

然后运行一遍fit()函数来训练模型。最后对比一下损失。

1
2
fit()
loss_func(model(xb), yb)

使用optim重构

Pytorch还有一个包含各种优化算法的包,torch.optim。我们可以使用来自优化器的step()方法来执行前向传播,而不是手动更新每个参数。
这将让我们取代之前的手动编码优化步骤:

1
2
3
with torch.no_grad():
for p in model.parameters(): p -= p.grad * lr
model.zero_grad()

而是使用以下代码:

1
2
opt.step()
opt.zero_grad()

(optim.zero_grad()将梯度重置为0,我们需要在为下一个minibatch计算梯度之前调用它)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from torch import optim

def get_model():
model = Mnist_Logistic()
return model, optim.SGD(model.parameters(), lr=lr)

model,opt = get_model()
loss_func(model(xb), yb)

for epoch in range(epochs):
for i in range((n-1)//bs + 1):
start_i = i*bs
end_i = start_i+bs
xb = x_train[start_i:end_i]
yb = y_train[start_i:end_i]
pred = model(xb)
loss = loss_func(pred, yb)

loss.backward()
opt.step()
opt.zero_grad()

loss_func(model(xb), yb)

## 使用Dataset重构 PyTorch有一个抽象的数据集类Dataset。一个Dataset可以是任何具有一个__len__方法(有了它就可以被Python的标准len函数调用)和一个__getitem__方法(有了它就可索引)的类。本教程将介绍一个创建自定义FacialLandmarkDataset类作为Dataset子类的示例。 PyTorch的TensorDataset是一个包装张量的数据集。通过定义长度和索引的方式,这也给了我们一种方法来遍历、索引和切片一个张量的第一维。这将使我们更容易地同时访问解释变量和被解释变量。
1
2
3
4
5
from torch.utils.data import TensorDataset

'''x_train和y_train都可以组合在一个单独的TensorDataset中,
这样更容易遍历和切片。'''
train_ds = TensorDataset(x_train, y_train)

之前,我们必须分别迭代x和y值的小批量:

1
2
xb = x_train [start_i end_i):
yb = y_train [start_i end_i):

现在,我们可以一起做这两个步骤:

1
xb,yb = train_ds[i*bs: i*bs+bs]
1
2
3
4
5
6
7
8
9
10
11
12
13
model,opt = get_model()

for epoch in range(epochs):
for i in range((n-1)//bs + 1):
xb,yb = train_ds[i*bs : i*bs+bs]
pred = model(xb)
loss = loss_func(pred, yb)

loss.backward()
opt.step()
opt.zero_grad()

loss_func(model(xb), yb)

有点意思,对吧?一步一步来。

使用DataLoader来重构

Pytorch的DataLoader负责管理batches。你可以从任何数据集创建DataLoader。DataLoader简化了对batches的迭代。而不是必须使用$train_ds[i * bs: i * bs+bs]$, DataLoader自动为我们提供每个mini batch批处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from torch.utils.data import DataLoader

train_ds = TensorDataset(x_train, y_train)
train_dl = DataLoader(train_ds, batch_size=bs)

model,opt = get_model()
for epoch in range(epochs):
for xb,yb in train_dl:
pred = model(xb)
loss = loss_func(pred, yb)

loss.backward()
opt.step()
opt.zero_grad()

多亏了Pytorch的 nn.Module, nn.Parameter, Dataset, and DataLoader,我们的训练循环现在大大缩小,更容易理解。现在让我们尝试添加现实中创建有效模型所需的基本特性。


# 添加验证集 在第1部分中,我们只是试图建立一个合理的训练循环,以用于我们的训练数据。实际上,你总是应该有一个验证集,以便确定是否进行了过度拟合。 对训练数据进行顺序洗牌对于防止批量之间的相关性和过度拟合非常重要。另一方面,无论是否重新洗牌验证集,验证损失都是相同的。由于重新洗牌需要额外的时间,因此没有必要重新洗牌验证数据。 我们将为验证集使用两倍于训练集的批处理大小。这是因为验证集不需要反向传播,因此占用的内存更少(它不需要存储梯度)。我们利用这一点来使用更大的批大小以便更快地计算损失。
1
2
3
4
5
train_ds = TensorDataset(x_train, y_train)
train_dl = DataLoader(train_ds, batch_size=bs, shuffle=True)

valid_ds = TensorDataset(x_valid, y_valid)
valid_dl = DataLoader(valid_ds, batch_size=bs*2)

我们将在每个epoch结束时计算并打印验证损失。
(注意,我们总是在训练之前调用model.train(),在推断之前调用model.eval(),因为这些是由nn.BatchNorm2d 和 nn.Dropout,以确保为这些不同的阶段采取适当的行为,比如是否有dropout,是否shuffle。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
model,opt = get_model()

for epoch in range(epochs):
model.train()
for xb,yb in train_dl:
pred = model(xb)
loss = loss_func(pred, yb)

loss.backward()
opt.step()
opt.zero_grad()

model.eval()
with torch.no_grad():
valid_loss = sum(loss_func(model(xb), yb)
for xb,yb in valid_dl)

print(epoch, valid_loss/len(valid_dl))

重构一下拟合和获取数据的代码

现在我们自己来做一点重构。因为我们要经历一个类似的过程,两次计算训练集和验证集的损失,所以让我们将其转换为自己的函数“loss_batch”,它计算一个批处理的损失。
我们为训练集传递一个优化器,并使用它来执行backprop。对于验证集,我们不传递优化器,因此该方法不执行backprop。

1
2
3
4
5
6
7
8
9
def loss_batch(model, loss_func, xb, yb, opt=None):
loss = loss_func(model(xb), yb)

if opt is not None:
loss.backward()
opt.step()
opt.zero_grad()

return loss.item(), len(xb)

定义fit()函数运行必要的操作来训练我们的模型,并计算每个epoch的训练和验证损失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import numpy as np

def fit(epochs, model, loss_func, opt, train_dl, valid_dl):
for epoch in range(epochs):
model.train()
for xb,yb in train_dl: loss_batch(model, loss_func, xb, yb, opt)

model.eval()
with torch.no_grad():
losses,nums = zip(*[loss_batch(model, loss_func, xb, yb)
for xb,yb in valid_dl])
val_loss = np.sum(np.multiply(losses,nums)) / np.sum(nums)

print(epoch, val_loss)

定义get_data()函数返回用于训练和验证集的dataloader。

1
2
3
def get_data(train_ds, valid_ds, bs):
return (DataLoader(train_ds, batch_size=bs, shuffle=True),
DataLoader(valid_ds, batch_size=bs*2))

现在,我们获取数据加载器并拟合模型的整个过程可以在3行代码中运行:

1
2
3
train_dl,valid_dl = get_data(train_ds, valid_ds, bs)
model,opt = get_model()
fit(epochs, model, loss_func, opt, train_dl, valid_dl)

您可以使用这三行代码来训练各种各样的模型。让我们看看是否可以用它们来训练一个卷积神经网络(CNN)!

切换到CNN

第一次尝试

我们现在要构建一个有三个卷积层的神经网络。因为前一节中的函数都没有假定任何关于模型形式的内容,所以我们可以使用它们来训练CNN,而无需进行任何修改。
我们将使用Pytorch预定义的Conv2d类作为卷积层。我们定义了一个3个卷积层的CNN。每个卷积后面都有一个ReLU。最后,我们执行一个平均池。(注意,view()方法是PyTorch版本的numpy reshape()方法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Mnist_CNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1)
self.conv2 = nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1)
self.conv3 = nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1)

def forward(self, xb):
xb = xb.view(-1,1,28,28)
xb = F.relu(self.conv1(xb))
xb = F.relu(self.conv2(xb))
xb = F.relu(self.conv3(xb))
xb = F.avg_pool2d(xb, 4)
return xb.view(-1,xb.size(1))

lr=0.1

# 动量是随机梯度下降的一种变化,它也考虑了以前的更新,通常会使训练更快的收敛。
model = Mnist_CNN()
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
fit(epochs, model, loss_func, opt, train_dl, valid_dl)

nn.Sequential

torch.nn还有一个方便的类,我们可以用它来简化代码:Sequential。Sequential对象以顺序的方式运行其中包含的每个模块。这是一种更简单的神经网络的写法。
为了利用这一点,我们需要能够从给定的函数轻松地定义一个自定义层。例如,PyTorch没有视图层,我们需要为我们的网络创建一个视图层。Lambda将创建一个层,然后我们可以使用它来定义一个具有序列的网络。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Lambda(nn.Module):
def __init__(self, func):
super().__init__()
self.func=func

def forward(self, x):
return self.func(x)

def preprocess(x):
return x.view(-1,1,28,28)

# 使用‘Sequential’创建的模型很简单
model = nn.Sequential(
Lambda(preprocess),
nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1), nn.ReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1), nn.ReLU(),
nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1), nn.ReLU(),
nn.AvgPool2d(4),
Lambda(lambda x: x.view(x.size(0),-1))
)
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
fit(epochs, model, loss_func, opt, train_dl, valid_dl)

包装DataLoader

我们的CNN相当简洁,但它只适用于MNIST,因为:

  • 它假设输入是一个28*28长的向量
  • 它假设最终的CNN网格大小是4*4(因为这是我们使用的平均池内核大小)

让我们摆脱这两个假设,以便我们的模型适用于任何二维单通道图像。首先,我们可以删除初始Lambda层,但将数据预处理移动到生成器中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def preprocess(x,y):
return x.view(-1,1,28,28),y

class WrappedDataLoader():
def __init__(self, dl, func):
self.dl = dl
self.func = func

def __len__(self): return len(self.dl)

def __iter__(self):
batches = iter(self.dl)
for b in batches: yield(self.func(*b))

train_dl,valid_dl = get_data(train_ds, valid_ds, bs)
train_dl = WrappedDataLoader(train_dl, preprocess)
valid_dl = WrappedDataLoader(valid_dl, preprocess)

接下来,我们可以用nn.AdaptiveAvgPool2d替换nn.AvgPool2d,它允许我们定义我们想要的输出张量的大小,而不是我们拥有的输入张量。因此,我们的模型可以处理任何大小的输入。

1
2
3
4
5
6
7
8
9
10
model = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1), nn.ReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1), nn.ReLU(),
nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1), nn.ReLU(),
nn.AdaptiveAvgPool2d(1),
Lambda(lambda x: x.view(x.size(0),-1))
)

opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
fit(epochs, model, loss_func, opt, train_dl, valid_dl)

使用GPU

实际训练中稍微略大一点的项目,几乎不会有人使用CPU来训练,那样太慢了。首先查看一下你的GPU是否能在PyTorch下工作:

1
2
3
4
5
torch.cuda.is_available()

# 然后为它创建一个设备对象:
dev = torch.device('cuda') if torch.cuda.is_available() \
else torch.device('cpu')

让我们更新预处理函数preprocess(),将batches移动到GPU:

1
2
3
4
5
6
7
8
9
10
11
def preprocess(x,y):
return x.view(-1,1,28,28).to(dev),y.to(dev)

train_dl,valid_dl = get_data(train_ds, valid_ds, bs)
train_dl = WrappedDataLoader(train_dl, preprocess)
valid_dl = WrappedDataLoader(valid_dl, preprocess)

# 最后,我们可以将模型移动到GPU。
model.to(dev)
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
fit(epochs, model, loss_func, opt, train_dl, valid_dl)