Keras 深度学习笔记(二)数学基础:张量运算、微分和梯度下降

要理解深度学习,需要熟悉张量、张量运算、微分、梯度下降等数学概念,本文将使用通俗的语言介绍这些概念。首先给出一个神经网络的示例,引出张量和梯度下降的概念,然后逐个详细介绍。

读完本章后,你会对神经网络的工作原理有一个直观的理解,然后就可以学习神经网络的实际应用了。

初识神经网络

我们来看一个使用 Keras 库构建神经网络来学习手写数字分类的例子,将手写数字的灰度图像(28×28 像素)划分到 10 个类别中(0~9)。

这里我们使用经典的 MNIST 数据集,它包含 60,000 张训练图像和 10,000 张测试图像,下图给出了 MNIST 数据集的一些样本。

在机器学习中,分类问题中的某个类别叫作 (class)。数据点叫作样本 (sample)。某个样本对应的类叫作标签 (label)

MNIST 数据集预先加载在 Keras 库中,其中包括 4 个 Numpy 数组:

from keras.datasets import mnist

# 加载 Keras 中的 MNIST 数据集
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

train_imagestrain_labels 组成了训练集 (training set),模型将从这些数据中进行学习,然后在测试集(test set,即 test_imagestest_labels)上对模型进行测试。

图像被编码为 Numpy 数组,每个图像是一个28 × 28 像素的灰度图像,而标签是数字数组,取值范围为 0~9。例如,我们看一下训练数据:

>>> train_images.shape
(60000, 28, 28)
>>> len(train_labels)
60000
>>> train_labels
array([5, 0, 4, ..., 5, 6, 8], dtype=uint8)

下面我们来构建网络:

from keras import models
from keras import layers

# 网络架构
network = models.Sequential()
network.add(layers.Dense(512, activation='relu', input_shape=(28 * 28,)))
network.add(layers.Dense(10, activation='softmax'))

神经网络的核心组件是 (layer),可以将它看成数据过滤器。层从输入数据中提取表示——我们期望这种表示有助于解决手头的问题。大多数深度学习都是将简单的层连接起来,从而实现渐进式的数据蒸馏 (data distillation)。深度学习模型就像是数据处理的筛子,包含一系列越来越精细的数据过滤器(即层)。

上例中的网络包含 2 个 Dense 层,它们是全连接的神经层。第二层(最后一层)是一个 10 路 softmax 层,它将返回一个由 10 个概率值(总和为 1)组成的数组,每个概率值表示当前数字图像属于 10 个数字类别中某一个的概率。

要想训练网络,我们还需要选择编译 (compile) 步骤的三个参数。

  • 损失函数 (loss function):网络如何衡量在训练数据上的性能,即网络如何朝着正确的方向前进。
  • 优化器 (optimizer):基于训练数据和损失函数来更新网络的机制。
  • 在训练和测试过程中需要监控的指标 (metric):本例只关心精度,即正确分类的图像所占的比例。
# 编译步骤
network.compile(optimizer='rmsprop',
                loss='categorical_crossentropy',
                metrics=['accuracy'])

在开始训练之前需要对数据进行预处理。之前训练图像保存在一个 uint8 类型的数组中,其形状为 (60000, 28, 28),取值区间为 [0, 255]。我们需要将其变换为一个 float32 数组,其形状为 (60000, 28 * 28),取值范围为 [0, 1]

# 准备图像数据
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype('float32') / 255

test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype('float32') / 255

我们还需要对标签进行编码,转换成 one-hot 的形式:

from keras.utils import to_categorical

# 准备标签
train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)

现在我们准备开始训练网络,Keras 中这一步是通过调用网络的 fit 方法来完成的,即我们在训练数据上拟合 (fit) 模型。

>>> network.fit(train_images, train_labels, epochs=5, batch_size=128)
Epoch 1/5
60000/60000 [=============================] - 9s - loss: 0.2524 - acc: 0.9273
Epoch 2/5
51328/60000 [=======================>.....] - ETA: 1s - loss: 0.1035 - acc: 0.9692

训练过程中显示了两个数字:网络在训练数据上的损失 loss 和网络在训练数据上的精度 acc。我们很快就在训练数据上达到了 0.989(98.9%)的精度。

现在我们来检查一下模型在测试集上的性能。

>>> test_loss, test_acc = network.evaluate(test_images, test_labels)
>>> print('test_acc:', test_acc)
test_acc: 0.9785

测试集精度为 97.8%,比训练集精度低不少。训练精度和测试精度之间的这种差距是过拟合(overfit)造成的,下面会详细介绍。

神经网络的数据表示

前面例子使用的数据存储在多维 Numpy 数组中,也叫张量 (tensor)。那么什么是张量?

张量是一个数据容器。它包含的数据几乎总是数值数据,因此它是数字的容器,例如矩阵就是二维张量。张量是矩阵向任意维度的推广,张量的维度 (dimension) 通常叫作 (axis)。

标量(0D 张量)

仅包含一个数字的张量叫作标量 (scalar)。在 Numpy 中,一个 float32float64 的数字就是一个标量张量(或标量数组)。你可以用 ndim 属性来查看一个 Numpy 张量的轴的个数。

>>> import numpy as np
>>> x = np.array(12)
>>> x
array(12)
>>> x.ndim
0

标量张量有 0 个轴(ndim == 0)。张量轴的个数也叫作 (rank)。

向量(1D 张量)

数字组成的数组叫作向量 (vector) 或一维张量(1D 张量)。一维张量只有一个轴。

>>> x = np.array([12, 3, 6, 14, 7])
>>> x
array([12, 3, 6, 14, 7])
>>> x.ndim
1

这个向量有 5 个元素,所以被称为 5D 向量。不要把 5D 向量和 5D 张量弄混! 5D 向量只有一个轴,沿着轴有 5 个维度,而 5D 张量有 5 个轴(沿着每个轴可能有任意个维度)。

维度 (dimensionality) 可以表示沿着某个轴上的元素个数,也可以表示张量中轴的个数(比如 5D 张量),这有时会令人感到混乱。

矩阵(2D 张量)

向量组成的数组叫作矩阵 (matrix) 或二维张量(2D 张量)。矩阵有 2 个轴(通常叫作)。

>>> x = np.array([[5, 78, 2, 34, 0],
                  [6, 79, 3, 35, 1],
                  [7, 80, 4, 36, 2]])
>>> x.ndim
2

第一个轴上的元素叫作 (row),第二个轴上的元素叫作 (column)。在上面的例子中,[5, 78, 2, 34, 0]x 的第一行,[5, 6, 7] 是第一列。

3D 张量与更高维张量

将多个矩阵组合成一个新的数组,可以得到一个 3D 张量,你可以将其直观地理解为数字组成的立方体。

>>> x = np.array([[[5, 78, 2, 34, 0],
                   [6, 79, 3, 35, 1],
                   [7, 80, 4, 36, 2]],
                  [[5, 78, 2, 34, 0],
                   [6, 79, 3, 35, 1],
                   [7, 80, 4, 36, 2]],
                  [[5, 78, 2, 34, 0],
                   [6, 79, 3, 35, 1],
                   [7, 80, 4, 36, 2]]])
>>> x.ndim
3

将多个 3D 张量组合成一个数组,可以创建一个 4D 张量,以此类推。深度学习处理的一般是 0D 到 4D 的张量,但处理视频数据时可能会遇到 5D 张量。

关键属性

张量是由以下三个关键属性来定义的。

  • 轴的个数(阶)。例如,矩阵有 2 个轴。这在 Numpy 等 Python 库中也叫张量的 ndim
  • 形状。这是一个整数元组,表示张量沿每个轴的维度大小(元素个数)。例如,前面矩阵示例的形状为 (3, 5)。向量的形状只包含一个元素,比如 (5,),而标量的形状为空,即 ()
  • 数据类型(在 Python 库中通常叫作 dtype),这是张量中所包含数据的类型,例如,张量的类型可以是 float32uint8float64 等。

我们回头看一下 MNIST 例子中处理的数据。首先加载 MNIST 数据集。

from keras.datasets import mnist

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

接下来,我们给出张量 train_images 的轴的个数,即 ndim 属性。

>>> print(train_images.ndim)
3

下面是它的形状。

>>> print(train_images.shape)
(60000, 28, 28)

下面是它的数据类型,即 dtype 属性。

>>> print(train_images.dtype)
uint8

所以,这里 train_images 是一个由 8 位整数组成的 3D 张量。更确切地说,它是 60,000 个矩阵组成的数组,每个矩阵由 28 × 28 个整数组成。每个这样的矩阵都是一张灰度图像,元素取值范围为 0~255。

数据批量的概念

通常来说,深度学习中所有数据张量的第一个轴(0 轴)都是样本轴 (samples axis,有时也叫样本维度)。在 MNIST 的例子中,样本就是数字图像。

此外,深度学习模型不会同时处理整个数据集,而是将数据拆分成小批量。具体来看,下面是 MNIST 数据集的一个批量,批量大小为 128。

batch = train_images[:128]

然后是下一个批量。

batch = train_images[128:256]

然后是第 $n$ 个批量。

batch = train_images[128 * n:128 * (n + 1)]

对于这种批量张量,第一个轴(0 轴)叫作批量轴 (batch axis) 或批量维度 (batch dimension)。

现实世界中的数据张量

你需要处理的数据几乎总是以下类别之一。

  • 向量数据:2D 张量,形状为 (samples, features)
  • 时间序列数据序列数据:3D 张量,形状为 (samples, timesteps, features)
  • 图像:4D 张量,形状为 (samples, height, width, channels)(samples, channels, height, width)
  • 视频:5D 张量,形状为 (samples, frames, height, width, channels)(samples, frames, channels, height, width)

向量数据

这是最常见的数据。对于这种数据集,每个数据点都被编码为一个向量,因此一个数据批量就被编码为 2D 张量,其中第一个轴是样本轴,第二个轴是特征轴

例如人口统计数据集,其中包括每个人的年龄、邮编和收入。每个人可以表示为包含 3 个值的向量,而整个数据集包含 100,000 个人,因此可以存储在形状为 (100000, 3) 的 2D 张量中。

时间序列数据或序列数据

当时间(或序列顺序)对于数据很重要时,应该将数据存储在带有时间轴的 3D 张量中。每个样本可以被编码为一个向量序列,因此一个数据批量就被编码为一个 3D 张量,如下图所示。

根据惯例,时间轴始终是第 2 个轴。

例如股票价格数据集。每一分钟,我们将股票的当前价格、前一分钟的最高价格和前一分钟的最低价格保存下来。因此每分钟被编码为一个 3D 向量,整个交易日被编码为一个形状为 (240, 3) 的 2D 张量(一个交易日有 240 分钟),而 250 天的数据则可以保存在一个形状为 (250, 390, 3) 的 3D 张量中。

图像数据

图像通常具有三个维度:高度、宽度和颜色深度。因此,图像大小为 256×256 的 128 张灰度图像组成的批量可以保存在一个形状为 (128, 256, 256, 1) 的张量中,而 128 张彩色图像组成的批量则可以保存在一个形状为 (128, 256, 256, 3) 的张量中,如下图所示。

图像张量的形状有两种约定:通道在后 (channels-last) 的约定(在 TensorFlow 中使用)和通道在前 (channels-first) 的约定(在 Theano 中使用)。TensorFlow 机器学习框架将颜色深度轴放在最后:(samples, height, width, color_depth),而 Theano 将图像深度轴放在批量轴之后:(samples, color_depth, height, width)

视频数据

视频数据是现实生活中需要用到 5D 张量的少数数据类型之一。视频可以看作一系列帧,每一帧都是一张彩色图像。因此不同视频组成的批量则可以保存在一个 5D 张量中,其形状为 (samples, frames, height, width, color_depth)

举个例子,一个以每秒 4 帧采样的 60 秒 YouTube 视频片段,视频尺寸为 144×256,这个视频共有 240 帧。4 个这样的视频片段组成的批量将保存在形状为 (4, 240, 144, 256, 3) 的张量中。

神经网络的“齿轮”:张量运算

深度神经网络学到的所有变换也都可以简化为数值数据张量上的一些张量运算 (tensor operation)。

在最开始的例子中,我们通过叠加 Dense 层来构建网络:

keras.layers.Dense(512, activation='relu')

这个层可以理解为一个函数,输入一个 2D 张量,返回另一个 2D 张量,即输入张量的新表示。具体而言,这个函数如下所示(其中 W 是一个 2D 张量,b 是一个向量,二者都是该层的属性)。 \(output = \text{relu}(\text{dot}(W, input) + b)\) 这里有三个张量运算:输入张量和张量 W 之间的点积运算 (dot)、得到的 2D 张量与向量 b 之间的加法运算 (+)、最后的 relu 运算。relu(x)max(x, 0)

逐元素运算

$\text{relu}$ 运算和加法都是逐元素 (element-wise) 的运算,即该运算独立地应用于张量中的每个元素,也就是说,这些运算非常适合大规模并行实现。如果你想对逐元素运算编写简单的 Python 实现,那么可以用 for 循环,例如逐元素加法:

def naive_add(x, y):
    assert len(x.shape) == 2  # x和y是Numpy的2D张量
    assert x.shape == y.shape

    x = x.copy()   # 避免覆盖输入张量
    for i in range(x.shape[0]):
        for j in range(x.shape[1]):
            x[i, j] += y[i, j]
    return x

根据同样的方法,你可以实现逐元素的乘法、减法等。而在实践中处理 Numpy 数组时,这些运算都是优化好的 Numpy 内置函数,可以直接进行:

import numpy as np

z = x + y   # 逐元素的相加

Numpy 函数会将大量运算交给安装好的基础线性代数子程序 (BLAS,basic linear algebra subprograms) 实现。BLAS 是低层次的、高度并行的、高效的张量操作程序,通常用 Fortran 或 C 语言来实现。

广播

上一节 naive_add 的简单实现仅支持两个形状相同的 2D 张量相加。但在前面介绍的 Dense 层中,我们将一个 2D 张量与一个向量相加。如果将两个形状不同的张量相加,会发生什么?

如果没有歧义的话,较小的张量会被广播(broadcast),以匹配较大张量的形状。广播包含以下两步。

  1. 向较小的张量添加轴(叫作广播轴),使其 ndim 与较大的张量相同。
  2. 将较小的张量沿着新轴重复,使其形状与较大的张量相同。

例如 X 的形状是 (32, 10)y 的形状是 (10,)。我们首先会给 y 添加空的第一个轴,这样 y 的形状变为 (1, 10),然后将 y 沿着新轴重复 32 次,得到的张量 Y 的形状为 (32, 10),这样就可以将 XY 相加了。

在实际的实现过程中并不会创建新的 2D 张量,因为那样做非常低效。重复的操作完全是虚拟的,它只出现在算法中,而没有发生在内存中。例如想象将向量沿着新轴重复 10 次:

如果一个张量的形状是 (a, b, ... n, n+1, ... m),另一个张量的形状是 (n, n+1, ... m),那么你通常可以利用广播对它们做两个张量之间的逐元素运算。广播操作会自动应用于从 an-1 的轴。

张量点积

点积运算,也叫张量积 (tensor product),是最常见也最有用的张量运算。与逐元素的运算不同,它将输入张量的元素合并在一起。

在 Numpy、Keras、Theano 和 TensorFlow 中,都是用 * 实现逐元素乘积。TensorFlow 中的点积使用了不同的语法,但在 Numpy 和 Keras 中,都是用标准的 dot 运算符来实现点积 $z = x\cdot y$:

import numpy as np

z = np.dot(x, y)

两个向量之间的点积是一个标量,而且只有元素个数相同的向量之间才能做点积。你还可以对一个矩阵 x 和一个向量 y 做点积,返回值是一个向量,其中每个元素是 yx 的每一行之间的点积。注意,如果两个张量中有一个的 ndim 大于 1,那么 dot 运算就不再是对称的,也就是说,dot(x, y) 不等于 dot(y, x)

当然,点积可以推广到具有任意个轴的张量。最常见的应用可能就是两个矩阵之间的点积。对于两个矩阵 xy,得到的结果是一个形状为 (x.shape[0], y.shape[1]) 的矩阵,其元素为 x 的行与 y 的列之间的点积:

更一般地说,你可以对更高维的张量做点积:

(a, b, c, d) . (d,) -> (a, b, c)

(a, b, c, d) . (d, e) -> (a, b, c, e)

张量变形

第三个重要的张量运算是张量变形(tensor reshaping)。例如前面例子中将图像数据输入神经网络之前,我们在预处理时用到了这个运算。

train_images = train_images.reshape((60000, 28 * 28))

张量变形是指改变张量的行和列,以得到想要的形状。变形后的张量的元素总个数与初始张量相同。简单的例子可以帮助我们理解张量变形。

>>> x = np.array([[0., 1.],
                 [2., 3.],
                 [4., 5.]])
>>> print(x.shape)
(3, 2)
>>> x = x.reshape((6, 1))
>>> x
array([[ 0.],
       [ 1.],
       [ 2.],
       [ 3.],
       [ 4.],
       [ 5.]])
>>> x = x.reshape((2, 3))
>>> x
array([[ 0.,  1.,  2.],
       [ 3.,  4.,  5.]])

经常遇到的一种特殊的张量变形是转置 (transposition)。对矩阵做转置是指将行和列互换,使 x[i, :] 变为 x[:, i]

>>> x = np.zeros((300, 20))   # 创建一个形状为(300, 20)的零矩阵
>>> x = np.transpose(x)
>>> print(x.shape)
(20, 300)

神经网络的“引擎”:基于梯度的优化

我们的第一个神经网络示例中,每个神经层都用下述方法对输入数据进行变换。 \(output = relu(dot(W, input) + b)\) 在这个表达式中,$W$ 和 $b$ 都是张量,均为该层的属性。它们被称为该层的权重 (weight) 或可训练参数 (trainable parameter),分别对应 kernelbias 属性。

一开始,这些权重矩阵取较小的随机值,这一步叫作随机初始化(random initialization)。当然,$W$ 和 $b$ 都是随机的,$\text{relu}(\text{dot}(W, input) + b)$ 肯定不会得到任何有用的表示。虽然得到的表示是没有意义的,但这是一个起点。下一步则是根据反馈信号逐渐调节这些权重。这个逐渐调节的过程叫作训练,也就是机器学习中的学习。

在一个训练循环 (training loop) 内,一直重复这些步骤。

  1. 抽取训练样本 $x$ 和对应目标 $y$ 组成的数据批量。
  2. 在 $x$ 上运行网络(这一步叫作前向传播 (forward pass)),得到预测值 $y_{pred}$。
  3. 计算网络在这批数据上的损失,用于衡量 $y_{pred}$ 和 $y$ 之间的距离。
  4. 更新网络的所有权重,使网络在这批数据上的损失略微下降。

最终得到的网络在训练数据上的损失非常小,即预测值 $y_{pred}$ 和预期目标 $y$ 之间的距离非常小。

那么更新网络的权重呢?考虑网络中某个权重系数,你怎么知道这个系数应该增大还是减小,以及变化多少?

一种简单的解决方案是,保持网络中其他权重不变,只考虑某个标量系数,让其尝试不同的取值。但这种方法是非常低效的,因为对每个系数(系数通常有上千个,有时甚至多达上百万个)都需要计算两次前向传播。一种更好的方法是利用网络中所有运算都是可微 (differentiable) 的这一事实,计算损失相对于网络系数的梯度 (gradient),然后向梯度的反方向改变系数,从而使损失降低。

什么是导数

假设有一个连续的光滑函数 $f(x) = y$,将实数 $x$ 映射为另一个实数 $y$。由于函数是连续的,$x$ 的微小变化只能导致 $y$ 的微小变化。假设 $x$ 增大了一个很小的因子 $\epsilon_x$,这导致 $y$ 也发生了很小的变化,即 $\epsilon_y$:

\[f(x + \epsilon_x) = y + \epsilon_y\]

由于函数是光滑的,在某个点 $p$ 附近,如果 $\epsilon_x$ 足够小,就可以将 $f$ 近似为斜率为 $a$ 的线性函数,这样 $\epsilon_y$ 就变成了 $a * \epsilon_x$:

\[f(x + \epsilon_x) = y + a * \epsilon_x\]

显然,只有在 $x$ 足够接近 $p$ 时,这个线性近似才有效。

斜率 $a$ 被称为 $f$ 在 $p$ 点的导数 (derivative)。如果 $a$ 是负的,说明 $x$ 在 $p$ 点附近的微小变化将导致 $f(x)$ 减小(如下图所示);如果 $a$ 是正的,那么 $x$ 的微小变化将导致 $f(x)$ 增大。此外,$a$ 的绝对值(导数大小)表示增大或减小的速度快慢。

对于每个可微函数 $f(x)$(可微的意思是“可以被求导”。例如,光滑的连续函数可以被求导),都存在一个导数函数 $f’(x)$,将 $x$ 的值映射为 $f$ 在该点的局部线性近似的斜率。

如果你想要将 $x$ 改变一个小因子 $\epsilon_x$,目的是将 $f(x)$ 最小化,并且知道 $f$ 的导数,那么问题解决了:导数完全描述了改变 $x$ 后 $f(x)$ 会如何变化。如果你希望减小 $f(x)$ 的值,只需将 $x$ 沿着导数的反方向移动一小步。

张量运算的导数:梯度

梯度 (gradient) 是张量运算的导数。它是导数这一概念向多元函数导数的推广。多元函数是以张量作为输入的函数。

假设有一个输入向量 $x$、一个矩阵 $W$、一个目标 $y$ 和一个损失函数 $loss$。你可以用 $W$ 来计算预测值 $y_{pred}$,然后计算损失,或者说预测值 $y_{pred}$ 和目标 $y$ 之间的距离。

\[y_{pred} = dot(W, x)\\ loss_{value} = loss(y_{pred}, y)\]

如果输入数据 $x$ 和 $y$ 保持不变,那么这可以看作将 $W$ 映射到损失值的函数。

\[loss_{value} = f(W)\]

假设 $W$ 的当前值为 $W_0$。$f$ 在 $W_0$ 点的导数是一个张量 $\nabla f(W_0)$,其形状与 $W$ 相同,每个系数 $\nabla f(W_0)[i, j]$ 表示改变 $W_0[i, j]$ 时 $loss_{value}$ 变化的方向和大小。张量 $\nabla f(W_0)$ 是函数 $f(W) = loss_{value}$ 在 $W_0$ 的导数。

前面已经看到,单变量函数 $f(x)$ 的导数可以看作函数 $f$ 曲线的斜率。同样,$\nabla f(W_0)$ 也可以看作表示 $f(W)$ 在 $W_0$ 附近曲率 (curvature) 的张量。

对于一个函数 $f(x)$,你可以通过将 $x$ 向导数的反方向移动一小步来减小 $f(x)$ 的值。同样,对于张量的函数 $f(W)$,你也可以通过将 $W$ 向梯度的反方向移动来减小 $f(W)$,比如 $W_1 = W_0 - step * \nabla f(W_0)$,其中 $step$ 是一个很小的比例因子。也就是说,沿着曲率的反方向移动,直观上来看在曲线上的位置会更低。注意,比例因子 $step$ 是必需的,因为 $\nabla f(W_0)$ 只是 $W_0$ 附近曲率的近似值,不能离 $W_0$ 太远。

随机梯度下降

给定一个可微函数,理论上可以通过对方程 $\nabla f(W) = 0$ 求解 $W$ 来求出最小损失函数对应的所有权重值,然后计算函数在其中哪个点具有最小值。这是包含 $N$ 个变量的多项式方程,其中 $N$ 是网络中系数的个数。但是实际的神经网络中参数不会少于几千个,经常上千万个,所以是无法求解的。

相反,你可以使用第 4 节开头总结的四步算法:基于当前在随机数据批量上的损失,一点一点地对参数进行调节。由于处理的是一个可微函数,你可以计算出它的梯度,从而沿着梯度的反方向更新权重,损失每次都会变小一点。

  1. 抽取训练样本 $x$ 和对应目标 $y$ 组成的数据批量。
  2. 在 $x$ 上运行网络,得到预测值 $y_{pred}$。
  3. 计算网络在这批数据上的损失,用于衡量 $y_{pred}$ 和 $y$ 之间的距离。
  4. 计算损失相对于网络参数的梯度[一次反向传播 (backward pass)]。
  5. 将参数沿着梯度的反方向移动一点,比如 $W -= step * \nabla$,从而使这批数据上的损失减小一点。

这就叫作小批量随机梯度下降 (mini-batch stochastic gradient descent,又称为小批量 SGD)。术语随机 (stochastic) 是指每批数据都是随机抽取的。下图给出了一维的情况,网络只有一个参数,并且只有一个训练样本。

直观上来看,为 $step$ 因子选取合适的值是很重要的。如果取值太小,则沿着曲线的下降需要很多次迭代,而且可能会陷入局部极小点。如果取值太大,则更新权重值之后可能会出现在曲线上完全随机的位置。

小批量 SGD 算法的一个变体每一次迭代都在所有数据上运行,这叫作批量 SGD。这样做的话,每次更新都更加准确,但计算代价也高得多。

神经网络的每一个权重参数都是空间中的一个自由维度,网络中可能包含数万个甚至上百万个参数维度。我们不可能将神经网络的实际训练过程可视化,因为无法用人类可以理解的方式来可视化 1,000,000 维空间。下图展示了将梯度下降沿着二维损失曲面可视化的例子(两个需要学习的参数)。

SGD 还有多种变体,其区别在于计算下一次权重更新时还要考虑上一次权重更新,而不是仅仅考虑当前梯度值,比如带动量的 SGD、Adagrad、RMSProp 等变体。这些变体被称为优化方法 (optimization method) 或优化器 (optimizer)。其中动量的概念尤其值得关注,它解决了 SGD 的两个问题:收敛速度和局部极小点。

下图给出了损失作为网络参数的函数的曲线。

如你所见,在某个参数值附近,有一个局部极小点 (local minimum):在这个点附近,向左移动和向右移动都会导致损失值增大。如果使用小学习率的 SGD 进行优化,那么优化过程可能会陷入局部极小点,导致无法找到全局最小点。

使用动量方法可以避免这样的问题,它的实现过程是每一步都移动小球,不仅要考虑当前的斜率值(当前的加速度),还要考虑当前的速度(来自于之前的加速度)。这在实践中的是指,更新参数 $w$ 不仅要考虑当前的梯度值,还要考虑上一次的参数更新,其简单实现如下所示。

past_velocity = 0.
momentum = 0.1   # 不变的动量因子
while loss > 0.01:  # 优化循环
    w, loss, gradient = get_current_parameters()
    velocity = past_velocity * momentum - learning_rate * gradient
    w = w + momentum * velocity - learning_rate * gradient
    past_velocity = velocity
    update_parameter(w)

链式求导:反向传播算法

在实践中,神经网络函数包含许多连接在一起的张量运算,每个运算都有简单的、已知的导数。例如,下面这个网络 $f$ 包含 3 个张量运算 $a$、$b$ 和 $c$,还有 3 个权重矩阵 $W_1$、$W_2$ 和 $W_3$。

\[f(W_1, W_2, W_3) = a(W_1, b(W_2, c(W_3)))\]

根据微积分的知识,这种函数链可以利用下面这个恒等式进行求导,它称为链式法则 (chain rule):

\[(f(g(x)))' = f'(g(x)) * g'(x)\]

将链式法则应用于神经网络梯度值的计算,得到的算法叫作反向传播 (backpropagation)。反向传播从最终损失值开始,从最顶层反向作用至最底层,利用链式法则计算每个参数对损失值的贡献大小。

现代框架通过符号微分 (symbolic differentiation)来实现神经网络,比如 TensorFlow。给定一个运算链,并且已知每个运算的导数,这些框架就可以利用链式法则来计算这个运算链的梯度函数,将网络参数值映射为梯度值。对于这样的函数,反向传播就简化为调用这个梯度函数。

由于符号微分的出现,我们无须手动实现反向传播算法。因此,这里不会浪费时间来推导反向传播的具体公式,只需充分理解基于梯度的优化方法的工作原理。

回顾第一个例子

我们回头看一下第一个例子,并根据前面三节学到的内容来重新阅读这个例子中的每一段代码。

下面是输入数据:

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype('float32') / 255

test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype('float32') / 255

输入图像保存在 float32 格式的 Numpy 张量中,形状分别为 (60000, 784)(训练数据)和 (10000, 784)(测试数据)。

下面是构建网络:

network = models.Sequential()
network.add(layers.Dense(512, activation='relu', input_shape=(28 * 28,)))
network.add(layers.Dense(10, activation='softmax'))

这个网络包含两个 Dense 层,每层都对输入数据进行一些简单的张量运算,这些运算都包含权重张量。权重张量是该层的属性,里面保存了网络所学到的知识 (knowledge)。

下面是网络的编译:

network.compile(optimizer='rmsprop',
                loss='categorical_crossentropy',
                metrics=['accuracy'])

categorical_crossentropy 是损失函数,是用于学习权重张量的反馈信号,在训练阶段应使它最小化。减小损失是通过小批量随机梯度下降来实现的,梯度下降的具体方法由第一个参数给定,即 rmsprop 优化器。

最后,下面是训练循环:

network.fit(train_images, train_labels, epochs=5, batch_size=128)

fit 时网络开始在训练数据上进行迭代(每个小批量包含 128 个样本),共迭代 5 次[在所有训练数据上迭代一次叫作一个轮次 (epoch)]。在每次迭代过程中,网络会计算批量损失相对于权重的梯度,并相应地更新权重。5 轮之后,网络进行了 2345 次梯度更新(每轮 469 次),网络损失值将变得足够小,使得网络能够以很高的精度对手写数字进行分类。

到目前为止,我们已经了解了神经网络的大部分知识。

总结

  • 学习是指找到一组模型参数,使得在给定的训练数据样本和对应目标值上的损失函数最小化。
  • 学习的过程:随机选取包含数据样本及其目标值的批量,并计算批量损失相对于网络参数的梯度。随后将网络参数沿着梯度的反方向稍稍移动(移动距离由学习率指定)。
  • 整个学习过程之所以能够实现,是因为神经网络是一系列可微分的张量运算,因此可以利用求导的链式法则来得到梯度函数,这个函数将当前参数和当前数据批量映射为一个梯度值。
  • 损失是在训练过程中需要最小化的量,因此,它应该能够衡量当前任务是否已成功解决。
  • 优化器是使用损失梯度更新参数的具体方式,比如 RMSProp 优化器、带动量的随机梯度下降(SGD)等。

该文是《Python深度学习》的阅读笔记,内容摘自原书,部分内容有修改。