0. 前言

我们在上一篇文章中简单了解了机器学习的概念、用途、常见类型、常见挑战、测试与验证等内容。在第一篇文章的基础上,本文通过回顾总结《Python深度学习(第2版)》一书第一章和第二章的阅读学习过程来继续介绍深度学习基础知识。这本书的作者是Keras框架的创建者François Chollet。

1. 什么是深度学习

1.1 机器学习

与经典的编程相比,机器学习是一种新的编程范式。在经典程序设计中,典型的使用场景是设计程序,给出输入数据,最后得到输出结果:

graph LR; A("规则")-->B("经典程序设计"); C("数据")-->B; B-->D("答案");

在机器学习中,人类给出数据和相应的答案(如标签),让机器自己找到可以将任务自动化的规则,从而用于处理未来的任务:

graph LR; A("数据")-->B("机器学习"); C("答案")-->B; B-->D("规则");

与理论物理或数学不同,机器学习是一门非常注重实践的学科,由经验发现所驱动,并深深依赖于软硬件的发展。

对于一个具体任务,我们需要三个要素来进行机器学习:输入数据、预期输出示例、衡量算法效果的方法。衡量结果是一种反馈信号,用于调整算法的工作方式,也就是“学习”。

机器学习的核心问题在于“有意义地变换数据”,学习输入数据的有用“表示”(representation),从而让数据更接近预期输出。“表示”这个概念在机器学习中非常重要,我们也可以将其近似理解为计算机科学中的“编码”。我们知道,一段数据可以有多种编码方式,不同的编码方式甚至可以组合和嵌套。不同的表示方式在不同的任务或场景有不同的优劣性。机器学习模型旨在为输入数据寻找合适的表示,使其更适合当前任务。

image-20230627181037950

机器学习中的“学习”,指的就是寻找某种数据变换的自动搜索过程。例如,对于“手写数字识别”这一任务来说,我们可以通过机器学习自动搜索生成的数据表示与基于这些表示的规则之间的不同组合,并利用正确分类的数字所占百分比作为反馈信号,从而找到有用的数据表示,解决这一任务。这种变换可以是坐标变换,也可以是线性投影、平移、非线性操作等。

简而言之,机器学习就是在预先定义的假设空间(hypothesis space)中,利用反馈信号的指引,在输入数据中寻找有用的表示和规则。

1.2 深度学习

深度学习是机器学习的一个子领域,是一种从数据中学习表示的新方法,强调从连续的层中学习,这些层对应于越来越有意义的表示。所谓“深度”(depth),并不是说获取更深层次的理解,而是指一系列连续的表示层。模型的层数就是该模型的深度。因此,深度学习有时也被称作“分层表示学习”(layered representations learning)和“层级表示学习”(hierarchical representations learning)。

深度学习的分层是通过“神经网络”(neural network)模型学习得到的,其结构是逐层堆叠。

深度学习模型并不是大脑模型。没有证据表明大脑的学习机制与现代深度学习模型的学习机制相同。你无须那种“就像我们的头脑一样”的神秘包装,最好也忘掉读过的深度学习与生物学之间的假想联系。就我们的目的而言,深度学习是从数据中学习表示的一种数学框架。

下图是一个利用深度学习识别手写数字的例子,这个神经网络将数字图像变换为与原始图像差别越来越大的表示,但其中关于最终结果的信息(是数字几)越来越丰富:

image-20230627180726607

我们可以将深度学习看作多级“信息蒸馏”(information distillation)过程——信息穿过连续的过滤器,纯度越来越高。

在机器学习的基础上,我们可以说,深度学习是一种多层的学习数据表示的方法。事实证明,如果规模够大,简单的机制将产生魔法般的效果,正所谓“大力出奇迹”。

在神经网络中,每层对输入数据所做的具体操作保存在该层的“权重”(weight)中,权重就是一串数字。每层实现的变换由其权重来“参数化”(parameterize)。权重有时也被称为该层的“参数”(parameter)。“学习”的意思就是为神经网络的所有层找到一组权重值,使得该神经网络能够将每个示例的输入映射到它的标签上。

问题是,如何找到合适、正确的权重?若要控制神经网络的输出,需要能够衡量该输出与预期结果之间的距离。这就引出了一个重要概念——损失函数(loss function),它有时也被称为目标函数(objective function)或代价函数(cost function)。损失函数的输入是神经网络的预测值与真实目标值,输出是一个距离值,反映该神经网络在这个示例上的效果好坏。深度学习是将损失值作为反馈信号,来对权重值进行微调,以降低当前示例对应的损失值。这种调节是“优化器”(optimizer)的任务,它实现了深度学习的核心算法——反向传播(back propagation)

image-20230627224959984

在一开始,我们通常会对神经网络的权重随机赋值,然后通过训练来优化权重值,从而将损失函数最小化,最终得到一个训练好的神经网络。

不要相信短期炒作,但一定要相信长期愿景。人工智能或许需要一段时间才能充分发挥其潜力,这一潜力大到难以想象,但人工智能时代终将到来,它将以一种奇妙的方式改变我们的世界。

本章还提到了概率建模(如朴素贝叶斯算法logistic回归)、早期神经网络、核方法(如支持向量机SVM)、决策树随机森林梯度提升(gradient boosting)等。梯度提升技术是目前处理非感知数据最好的算法之一。

深度学习受到人们重视,一方面是因为它在很多问题上表现出更好的性能,另一方面是因为它将特征工程(feature engineering)完全自动化,可以一次性学习所有特征,无需自己手动设计。模型可以在同一时间共同学习所有表示层,而不是依次连续学习。这让解决问题变得简单。相比之下,使用非深度学习技术(浅层学习)时,人们需要手动为输入数据设计良好的表示层(特征工程),十分不便。

深度学习从数据中学习时具有两个基本特征:

  1. 通过逐层渐进的方式形成越来越复杂的表示。
  2. 对中间的渐进表示共同进行学习,每一层的修改都需要同时考虑上下两层。

总体而言,推动机器学习进步的力量有三个:硬件(如GPU和TPU)、数据集和基准(如ImageNet数据集)、改进的算法。算法的改进表现为如下几个方面:

  • 更好的神经层激活函数(activation funciton)
  • 更好的权重初始化方案(weight-initialization scheme)
  • 更好的优化方案(optimization scheme),如RMSprop和Adam。

这些改进让我们能够实现更好的梯度传播,尤其是在模型层数增加的情况下。

2. 神经网络的数学基础

要理解深度学习,需要熟悉一些数学概念:张量(tensor)、张量运算(tensor operation)、微分(differentiation)梯度下降(gradient descent)等。本章我们将结合一个手写数字分类任务来学习这些概念。

2.1 Hello-World:手写数字分类任务

我们将使用MNIST数据集(包含60000张训练图像和10000张测试图像),尝试将手写数字的灰度图像划分到10个类别中。

首先加载数据,并观察一下训练数据和测试数据:

>>> from tensorflow.keras.datasets import mnist
>>> (train_images, train_labels), (test_images, test_labels) = mnist.load_data()
>>> train_images.shape
(60000, 28, 28)
>>> len(train_labels)
60000
>>> train_labels
array([5, 0, 4, ..., 5, 6, 8], dtype=uint8)
>>> test_images.shape
(10000, 28, 28)
>>> len(test_labels)
10000
>>> test_labels
array([7, 2, 1, ..., 4, 5, 6], dtype=uint8)

接下来,我们将构建神经网络模型,然后将训练数据输入神经网络进行训练,最后用神经网络对测试数据集进行预测。

下面是神经网络模型的构建和编译过程:

from tensorflow import keras
from tensorflow.keras import layers
model = keras.Sequential([
	layers.Dense(512, activation="relu"),
	layers.Dense(10, activation="softmax")
])
model.compile(optimizer="rmsprop",
              loss="sparse_categorical_crossentropy",
              metrics=["accuracy"])

可以观察到,神经网络的核心组件是“层”(layer)。层从数据中提取“表示”,我们可以将其视作数据过滤器,用于实现渐进式的数据蒸馏(data distillation)。

上面的模型包含两个“全连接”层(Dense层)。其中第二层是一个10路的softmax分类层,返回一个由10个概率值(总和为1)组成的数组,每个值表示当前图像属于10个数字类别中某一个的概率。

model.compile用于为模型指定优化器(optimizer)、损失函数和指标(metric)——前者用于模型的自我更新(反向传播),中者用于衡量模型在训练数据上的性能,后者是我们想要在训练和测试过程中监控的模型性能。

接下来我们准备图像数据,将其与模型相适配,并缩放到[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

现在就可以训练(在训练数据上“拟合”(fit))模型了:

>>> model.fit(train_images, train_labels, epochs=5, batch_size=128)
Epoch 1/5
469/469 [==============================] - 3s 6ms/step - loss: 0.2677 - accuracy: 0.9234
Epoch 2/5
469/469 [==============================] - 3s 5ms/step - loss: 0.1086 - accuracy: 0.9672
Epoch 3/5
469/469 [==============================] - 3s 5ms/step - loss: 0.0714 - accuracy: 0.9790
Epoch 4/5
469/469 [==============================] - 3s 5ms/step - loss: 0.0511 - accuracy: 0.9848
Epoch 5/5
469/469 [==============================] - 3s 6ms/step - loss: 0.0379 - accuracy: 0.9883
<keras.callbacks.History object at 0x126416d60>

可以看到,损失值一直在降低,而精度一直在升高,这说明我们的模型和训练是有效的。我们可以用该模型来预测新数字图像所属的类别:

>>> test_digits = test_images[0:10]
>>> predictions = model.predict(test_digits)
1/1 [==============================] - 0s 89ms/step
>>> predictions[0]
array([8.9595602e-09, 2.3669788e-10, 6.6512412e-06, 2.7392696e-05,
       3.0452769e-11, 4.7035979e-08, 9.1966051e-13, 9.9996525e-01,
       7.0086877e-08, 4.8436243e-07], dtype=float32)
>>> predictions[0].argmax()
7
>>> predictions[0][7]
0.99996525
>>> test_labels[0]
7

上面的输出显示,模型预测成功。我们还可以计算模型在测试集上的平均精度:

>>> test_loss, test_acc = model.evaluate(test_images, test_labels)
313/313 [==============================] - 1s 2ms/step - loss: 0.0648 - accuracy: 0.9815
>>> print(f"test_acc: {test_acc}")
test_acc: 0.9815000295639038

可以看到,训练精度为98.83%,而测试精度为98.15%。测试精度比训练精度低,这通常是“过拟合”(overfit)造成的。

2.2 神经网络的数据表示:张量

机器学习系统使用张量作为基本数据结构,我们可以简单地将其理解为多维数组,其中包含的都是数值数据。我们熟悉的标量、向量、矩阵分别称为0阶张量、1阶张量、2阶张量。张量可以看做是矩阵向任意维度的推广,它的维度通常称作轴(axis)。

下面的代码可以帮助我们更好地理解张量:

>>> import numpy as np
>>> x = np.array(12)
>>> print(x.ndim, x.shape)
0 ()
>>> y = np.array([12, 13, 14])
>>> print(y.ndim, y.shape)
1 (3,)
>>> z = np.array([[12, 13, 14], [15, 16, 17]])
>>> print(z.ndim, z.shape)
2 (2, 3)

张量是由三个关键属性来定义的:阶数(轴的个数)、形状、数据类型。我们借助手写数字分类这个例子来具体看一下这些属性:

>>> train_images.ndim
2
>>> train_images.shape
(60000, 784)
>>> train_images.dtype
dtype('float32')

NumPy中选择张量中特定元素的方法是张量切片(tensor slicing):

>>> my_slice = train_images[10:100, :]
>>> my_slice.shape
(90, 784)

深度学习中所有数据张量的第一个轴都是样本轴(samples axis)或样本维度(samples dimension)。另外,深度学习通常都是批量处理数据,而不是一次处理整个数据集。此时,第一个轴也可以叫做批量(batch)轴或批量维度。

现实中,我们遇到的数据可能对应着不同阶的张量,如向量数据(2阶)、时间序列数据(3阶)、图像数据(4阶)、视频数据(5阶)等。需要根据数据特点选择合适的张量来存储。

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

深度学习学到的所有变换都可以简化为对数值数据张量的张量运算或张量函数。

例如,我们可以将Dense层理解为一个函数,它的输入是一个矩阵,返回的是另一个矩阵:

keras.layers.Dense(512, activation="relu")
# is something like
output = relu(dot(input, W) + b)

这里实际上有3个张量运算:inputW的点积(dot product)运算、上一步结果与b的加法运算、relu运算(即max(x, 0),relu是rectified linear unit的简写)。其中,加法运算和relu运算都是逐元素(element-wise)运算——运算分别应用于张量的每个元素。在GPU上运行TensorFlow代码时,逐元素运算是通过完全向量化的CUDA来完成的。

如果运算的两个张量形状不同,TensorFlow会将较小的张量“广播”(broadcast)来匹配大张量的形状。广播包含两个步骤:

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

注意,广播是算法上的一种处理,并不会体现在内存中(导致低效)。

点积(也叫张量积)与逐元素乘积(星号表示)不同,具体含义可以参考线性代数相关知识。NumPy中的np.dot函数可以用来计算点积。对于矩阵x和y,只有x.shape[1]y.shape[0]相等时,我们才能计算这两个矩阵的点积。

有时,我们需要对张量进行变形(tensor reshaping),例如:

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

一种特殊的张量变形是转置(transpose),可以使用np.transpose(x)实现张量转置。

所有张量运算都有相应的几何解释:平移(translation)、旋转(rotation)、缩放(scaling)、线性变换(linear transform)、仿射变换(affine transform,即y = W · x + b)和带有relu激活函数的Dense层等。注意,一个完全由没有激活函数的Dense层构成的多层神经网络在数学上等同于一个Dense层。我们需要借助激活函数来实现复杂的非线性几何变换,从而为神经网络提供丰富的假设空间。

书中的一个例子可以帮助我们理解、想象神经网络做的事情:将神经网络解释为高维空间中非常复杂的几何变换。想象有两张彩纸叠放,然后揉成一个小球。这个纸团就是输入数据,每张纸对应分类问题中的一个类别。神经网络要做的是找到可以让纸团恢复平整的变换,从而再次让两个类别明确可分。换句话说,机器学习的目的就是为高维空间中复杂、高度折叠的数据流形(manifold)找到简洁的表示。

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

前面提到,Dense层对应类似下面这样的变换:

output = relu(dot(input, W) + b)

其中,W和b均为层的属性,称为权重(weight)或可训练参数(trainable parameter),分别对应属性kernel和bias。机器学习,就是根据训练数据来调整这些权重。在训练的开始,这些权重矩阵通常先被随机初始化,然后在训练中根据反馈信号逐步调节。在一次训练循环中,程序将重复下列步骤,直到损失值足够小:

  1. 抽取训练样本x和目标y_true组成一个数据批量。
  2. 在x上运行模型(前向传播,forward pass),得到预测值y_pred。
  3. 计算模型在这批数据上的损失值,用于衡量y_pred和y_true的差距。
  4. 更新模型所有权重,从而略微减小模型在这批数据上的损失值。

问题的关键是如何更新权重。这里需要用到“梯度下降法”(gradient descent)。模型用到的所有函数都是可微的,那么它们的复合函数也是可微的,将模型系数映射到模型在数据批量上损失值的函数也是可微的。我们可以用梯度(张量运算的导数)来描述模型系数向不同方向移动时损失值的变化情况。计算出梯度,用它来更新系数(沿着梯度的反方向移动一小步),从而减小损失值,例如:

loss_value = f(W)
W1 = W0 - step * grad(f(W0), W0)

至此,我们可以将前面的第4步扩展为2个步骤:

  1. 计算损失相对于模型参数的梯度(反向传播,backward pass)。
  2. 将参数沿着梯度反方向移动一小步,如W -= learning_rate * gradient

这种方法称作小批量随机(stochastic)梯度下降,简称小批量SGD。如果每次迭代都在所有数据上运行,这种方式就是批量梯度下降,计算成本较大。SGD有多种变体,如带动量的SGDAdagradRMSprop等,它们被称为优化方法或优化器。

现在问题变成了如何计算出损失相对于权重的梯度。这里需要用到反向传播算法,它借助链式法则以网络每层的权重为变量计算损失函数的梯度,以更新权重来最小化损失函数。例如,根据复合函数的链式求导法则,对于如下损失函数,我们很容易求出它相对于神经网络各层中权重的梯度:

loss_value = loss(y_true, softmax(dot(relu(dot(inputs, W1) + b1), W2) + b2))

我们还可以借助计算图(computation graph)来理解反向传播。计算图是一种由运算构成的有向无环图,是TensorFlow和深度学习的核心数据结构。

image-20230704121123848

与直接在程序中写明计算逻辑相比,计算图的优势在于将计算视为数据,将可计算的表达式编码为机器可读的数据结构,从而用于另一个程序的输入或输出。这种将计算和程序设计解耦的处理方法带来了很大便利。例如,我们可以写一个程序,专门根据输入的计算图生成它的分布式版本。换言之,我们可以对任意计算实现分布式,而无需自己编写分布式逻辑。再如,我们可以编写一个程序,专门用来计算输入计算图对应表达式的导数。

根据链式法则,对于图2-24中的反向图来说,一个节点相对于另一个节点的导数,等于连接着两个节点的路径上的每条边的导数相乘。如果有多条路径,则将所有路径的值相加。从最终损失值出发,我们就能够求出每个参数对损失值的贡献

TensorFlow就是基于计算图实现的自动微分框架,可以计算任意可微张量运算组合的梯度。我们用TensorFlow中的梯度带(GradientTape)API来感受一下它的自动微分能力:

>>> import tensorflow as tf
>>> x = tf.Variable(0.)
>>> with tf.GradientTape() as tape:
...     y = 2 * x + 3
...
>>> tape.gradient(y, x)
<tf.Tensor: shape=(), dtype=float32, numpy=2.0>

在第二章的最后,作者在几乎不依赖Keras的情况下使用TensorFlow提供的功能从头开始重新实现了第一个例子。具体来说,他编写了一个简单的Dense类、一个简单的Sequential类、批量生成器、单次训练过程、训练循环和模型评估过程,源代码见GitHub仓库。这个“手搓”的例子能够帮助我们理解神经网络的底层细节。