我们在之前的一篇笔记中使用简单的线性模型去识别 Fashion-MNIST 数据集,达到了 75% 到 80% 的准确率。通过对损失 [loss] 的可视化我们发现这个简单的模型并没有过拟合 [overfitting],说明我们基本达到了这个模型的上限了。我们如何增加提高识别的准确率呢?一个直接的做法就是提高模型的复杂度,比如我们可以使用简单的模型搭建一个复杂的神经网络。这一般来说也是最有效的方法。我们还可以通过数据预处理的方法来榨取原先模型的能力,只是我对此是门外汉,除了使数据具有零平均值 (zero mean) 和单位方差 (unit variance) 之外就一无所知了,所以我就在这方面就不展开了。

import numpy as np
import matplotlib.pyplot as plt

%run import_npnet.py
npnet = import_npnet(4)

串型网络

我们常说的神经网络 (neural network) 事实上是由多个简单的模型搭建而成的计算图 (computational graph)。因为神经网络,特别是深度神经网络 (deep neural network),具有大量的参数,所以它们理所当然地具有更强的拟合能力。这样具有大量参数的神经网络也可以通过梯度下降 (gradient descent) 训练的,而梯度下降所用到的偏导数则可以通过链式法则 (chain rule) 或者其衍生的向后传播算法 (backpropagation) 来求出。正是这个向后传播算法以及计算机计算能力的提高让深度神经网络火了起来。

复杂的神经网络实现设计需要考虑计算图的拓扑序 (topological order) 、自动求导 (autograd) 以及并行计算 (parellel computing) 等等。这些都超出了本笔记系列预定的能力范围。我们主要关注最简单的串型网络 (Sequential network) 的实现。顾名思义,串型网络是将基本的模型单元按一字排开,让数据按顺序经过这些模型单元。串型网络对应的计算图是一个路径图 (path graph)。

串型网络的实现很简单明了:

# import
class Sequential(npnet.Model):

    def __init__(self, layers, **kwargs):
        super().__init__(layers=layers, **kwargs)

        self.layers = layers
        parameters = []
        gradients = []
        for layer in layers:
            parameters.extend(layer._parameters)
            gradients.extend(layer._gradients)
        self.parameters = parameters
        self.gradients = gradients

    def forward(self, input):
        self.input = output = input
        for layer in self.layers:
            output = layer.forward(output)
        self.output = output
        return output

    def backward(self, grad):
        for layer in self.layers[::-1]:
            grad = layer.backward(grad)
        return grad

激活函数

我们不能直接将两个线性模型 (linear model) 串在一个,因为两个线性模型的复合还是一个线性模型。我们不能通过直接串连线性模型得到更复杂的模型。这时,我们需要在每个线性模型之后放一个激活函数 (activation function) 来去除线性。激活函数可以看作是没有参数的模型,所以我们可以将激活函数写成模型的子类。

最简单的激活函数是修正线性单元 (ReLU)。它的定义很简单:

$$\mathrm{ReLU}(x) = \max(0, x).$$

ReLU 在0处不可导,在其他处的导数为

$$\mathrm{ReLU}'(x) = \left\{\begin{array}{ll} 0, & \text{ if } x < 0; \\ 1, & \text{ if } x > 0. \end{array}\right.$$
# import
class ReLU(npnet.Model):

    def forward(self, input):
        self.input = input
        return input * (input > 0)

    def backward(self, grad):
        return grad * (self.input > 0)

导数验证

每次实现模型之后我们都要验证导数计算的正确与否。

npnet.GradientCheck(ReLU(), input_shape=(20, 20)).check()
True

对于 Sequential 的验证,我们将使用两个线性模型以及两个 ReLU 。

layers = [
    npnet.Linear(20, 10),
    ReLU(),
    npnet.Linear(10, 5),
    ReLU()
]
model = Sequential(layers)
npnet.GradientCheck(model, input_shape=(30, 20)).check()
True

Fashion MNIST

我们现在用 Sequential 串型网络进行图像识别实验。我们会使用一个 npnet.Linear(784, 30) 和一个 npnet.Linear(30, 10) 共两个线性模型,激活函数使用 ReLU。首先我们来加载数据集。

train_images, train_labels = npnet.load_fashion_mnist('train')
test_images, test_labels = npnet.load_fashion_mnist('t10k')

现在我们建立并训练模型。我们现在将每个批次的数量调整到10以增加更新的次数。而且我们使用了 npnet.Adam 优化器,以及 L2 正则 (L2 normalization)。这些超参数我没有系统地挑选过,都是尝试几遍定下来的,所以不一定最优。

%%time
# 超参数 (hyperparameters)
lr = 1e-5
batch_size = 10
epoch = 50
weight_decay = 1e-3

# 模型、标准、优化器
model = Sequential(layers=[
    npnet.Linear(784, 30),
    ReLU(),
    npnet.Linear(30, 10)
])
criterion = npnet.CrossEntropy()
optim = npnet.Adam(model, lr=lr, weight_decay=weight_decay)

train_loss, train_accuracy, test_loss, test_accuracy = \
    npnet.train_fashion_mnist(
        model=model, criterion=criterion, optim=optim,
        train_images=train_images, train_labels=train_labels,
        batch_size=batch_size, epoch=epoch,
        test_images=test_images, test_labels=test_labels,
        profile=True
    )
npnet:12: RuntimeWarning: divide by zero encountered in log
CPU times: user 4min 45s, sys: 1.29 s, total: 4min 46s
Wall time: 4min 50s

这个系列笔记的代码是在一个某服务商最低配的 VPS 上运行的,只有一个 vCPU 和 1G 的内存。这样的配置只能跑一些简单的模型了。不过作为笔记的例子约莫是足够了。我们接着画损失图和准确率图。我们先定义一个画图函数方便以后调用。

# import
# 将训练与测试的损失图和准确率图画在一起
def plot_loss_and_accuracy(train_loss, train_accuracy,
                           test_loss, test_accuracy):
    _, (loss_plt, accu_plt) = plt.subplots(1, 2, figsize=(12, 4))

    x = np.arange(len(train_loss))
    loss_plt.plot(x, train_loss, 'b', label='train')
    loss_plt.plot(x, test_loss, 'g', label='test')
    loss_plt.set_title('Model Loss')
    loss_plt.set_xlabel('epoch')
    loss_plt.set_ylabel('loss')
    loss_plt.legend()

    accu_plt.plot(x, train_accuracy, 'b', label='train')
    accu_plt.plot(x, test_accuracy, 'g', label='test')
    accu_plt.set_title('Model Accuracy')
    accu_plt.set_xlabel('epoch')
    accu_plt.set_ylabel('accuracy')
    accu_plt.legend(loc='lower right')

    plt.show()

我们调用上述函数画出模型如下的损失图和准确率图。

plot_loss_and_accuracy(train_loss, train_accuracy,
                       test_loss, test_accuracy)

这个模型的准确率在80%和85%之间,比之前的模型稍微好了些。

过拟合/欠拟合

冯诺伊曼 (John von Neumann) 曾经说过:

With four parameters I can fit an elephant, and with five I can make him wiggle his trunk.

大概的中文翻译是:

用四个参数我能拟合一头大象,五个的话我还能让它的尾巴摇上一摇。

深度神经网络包含的参数成千上万,过拟合 (overfitting) 是常见的问题。让我们看一个例子。这个例子中的模型使用一个 npnet.Linear(784, 100) 和一个 npnet.Linear(100, 10) 共两个线性模型,激活函数使用 ReLU。一开始的时候我们使用的 L2 正则系数为 \(10^{-3}\)

fixed_model = Sequential(layers=[
        npnet.Linear(784, 100),
        ReLU(),
        npnet.Linear(100, 10)
])

def copy_model():
    model = Sequential(layers=[
        npnet.Linear(784, 100),
        ReLU(),
        npnet.Linear(100, 10)
    ])
    for params, fixed_params in zip(model.parameters, fixed_model.parameters):
        for key in params:
            params[key] = np.copy(fixed_params[key])
    return model


def adjust_weight_decay(weight_decay, epoch=50):
    # 超参数 (hyperparameters)
    lr = 1e-5
    batch_size = 10
    epoch = epoch

    # 模型、标准、优化器
    model = copy_model()
    criterion = npnet.CrossEntropy()
    optim = npnet.Adam(model, lr=lr, weight_decay=weight_decay)

    train_loss, train_accuracy, test_loss, test_accuracy = \
        npnet.train_fashion_mnist(
            model=model, criterion=criterion, optim=optim,
            train_images=train_images, train_labels=train_labels,
            batch_size=batch_size, epoch=epoch,
            test_images=test_images, test_labels=test_labels,
            profile=True
        )

    plot_loss_and_accuracy(train_loss, train_accuracy,
                           test_loss, test_accuracy)


adjust_weight_decay(weight_decay=1e-3)

我们模型训练与测试的损失都随着训练次数的增加而减少而减少,但是训练损失比测试损失少得多(相比于前一个模型)。而且从准确率来看,训练准确率比测试准确率也高得多(相比于前一个模型)。这种现象是过拟合的表现。我们可以通过调大 L2 正则系数 weight_decay 来降低过拟合的程度。

adjust_weight_decay(weight_decay=1)

使用 weight_decay = 1 模型的测试损失很靠近训练损失。虽然测试准确率还离训练准确率有点远,但是已经比之前好了不少,而且测试准确率比之前也有了一定的提高,达到了85%左右。如果 weight_decay 的值过大,模型会出现另一个极端情况——欠拟合 (underfitting)。我们来看看 weight_decay = 10 的损失与准确率图。

adjust_weight_decay(weight_decay=10)

我们看到训练损失先下降,然后稍稍有点上升。这是因为我们在计算损失的时候并没有加上 L2 正则的损失。右边的准确率图反映在10个 epoch 之后准确率就开始下降了,这是模型欠拟合的一种表现。不过欠拟合的更一般表现应该是训练准确率保持上升而测试准确率先上升后下降。现在训练准确率也在下降是因为 L2 正则实在太强了,模型在后期是向减少 L2 正则的方向前进,导致模型过于简单了。