浅谈深度神经网络

IT知识
288
0
0
2024-01-07
标签   人工智能

Deep neural networks are completely flexible by design, and there really are no fixed rules when it comes to model architecture. -- David Foster

前言

神经网络 (neural network) 受到人脑的启发,可模仿生物神经元相互传递信号。神经网络就是由神经元组成的系统。如下图所示,神经元有许多树突 (dendrite) 用来输入,有一个轴突 (axon) 用来输出。它具有两个最主要的特性:兴奋性和传导性:

  • 兴奋性是指当刺激强度未达到某一阈限值时,神经冲动不会发生;而当刺激强度达到该值时,神经冲动发生并能瞬时达到最大强度。
  • 传导性是指相邻神经元靠其间一小空隙进行传导。这一小空隙,叫做突触 (synapse),其作用在于传递不同神经元之间的神经冲动,下图突触将神经元 A 和 B 连在一起。

试想很多突触连接很多神经元,不就形成了一个神经网络了吗?没错,类比到人工神经网络 (artificial neural network, ANN),也是由无数的人工神经元组成一起的,比如下左图的浅度神经网络 (shadow neural network) 和下右图的深度神经网络 (deep neural network)。

浅度神经网络适用于结构化数据 (structured data),比如像下图中 excel 里存储的二维数据。

深度神经网络适用于等非结构化数据 (unstructured data),如下图所示的图像、文本、语音类数据。

生成式 AI 模型主要是生成非结构化数据,因此了解深度神经网络是必要的。从本篇开始,我们会模型与代码齐飞,因为

Talk is cheap. Show me the code. -- Linus Torvalds

代码都用 TensorFlow 和 Keras 来实现。

1. 人工神经网络

1.1 神经网络初见

假设下面的神经网络已经被训练好,接着用来预测图片中是否含有笑脸。

  1. 单元 A 接收图像里的像素信息。
  2. 单元 B 结合了输入像素,当原始图像中有低级特征 (low-level feature) 比如边缘 (edge) 时,发出最强信号。
  3. 单元 C 结合了低级特征,当原始图像中有高级特征 (high-level feature) 比如牙齿 (teech) 时,发出最强信号。
  4. 单元 D 结合了高级特征,当原始图像中的人微笑时,发出最强信号。

当给这个神经网络“投喂”足够多的数据,即图像,它会“找到”一组权重 (weights) 使得最终预测结果尽可能准确。找权重这个过程其实就是训练神经网络。

对神经网络有个初步认识之后,接下来的任务就是用 Keras 来实现它。

1.2 Keras 训练模型

在 Keras 中实现神经网络需要了解三大要点:

  1. 模型 (models)
  2. (layers),输入 (input) 和输出 (output)
  3. 优化器 (optimizer) 和损失函数 (loss)

用上面的关键词来总结 Keras 训练神经网络的流程:将多个链接在一起组成模型,将输入数据映射为预测值。然后损失函数将这些预测值输出,并与目标进行比较,得到损失值 (用于衡量网络预测值与预期结果的匹配程度),优化器利用这个损失值来更新网络的权重。

到此终于可以展示点代码了,即便是引入工具库。首先从 tensorflow.keras 库中用于搭建神经网络的模块。

import numpy as np              # 用于数组计算的模块
import matplotlib.pyplot as plt # 用于可视化的模块

from tensorflow.keras import models, layers, optimizers, utils, datasets
# models:     用于构建神经网络模型的模块
# layers:     用于构建模型中的层的模块
# optimizers: 用于优化损失函数的模块
# utils:      用于实现其他基本功能的模块
# datasets:   用于下载自带数据的模块

整个神经网络就是一个模型,大框架的代码都来自 models 模块;模型是由多个层组成,而不同的层的代码都来自 layers 模块;模型的第一层是输入层,负责接入输入,模型的最后一层是输出层,负责提供输出,一头一尾都在 models 模块;模型骨架好了,要使它中看又中用就需要 optimizers 模块来训练它了。

1.3 极简神经网络

学过机器学习的同学遇到的第一个模型一定是线性回归,还是单变量的线性回归。给定一组 x 和 y 的数据:

x = [-1, 0, 1, 2, 3, 4]

y = [-3, -1, 1, 3, 5, 7]

找出 x 和 y 之间的关系,当 x_new = 10 时,问 y_new 是多少?

如下图所示,将 x 和 y 以散点的形式画出来,不难发现下图的红线就是 x 和 y 之间的关系。现在想用 Keras 杀鸡用牛刀的构建一个神经网络来求出这条红线。

1.3.1 创建模型

用一层含一个神经元的神经网络即可,代码如下:

toy_model = models.Sequential(
    layers.Dense(input_shape=[1], units=1)
)

首先用 models.Sequential() 创建一个空神经网络,然后不断添加层,这里我们添加了 layers.Dense(),叫做稠密层。函数里面的参数 input_shape=[1] 表示输入数据的维度为 1,units=1 表示输出只有 1 个神经元。可视化如下:

1.3.2 检查模型

检查一下模型信息,奇怪的是参数个数 (下图 Param #) 居然是 2 个而不是 1 个。因为从上图来看 y = wx,只应该有 w 一个参数啊。

toy_model.summary()

原因是在计算每层参数个数时,每个神经元默认会连接到一个值为 1 的偏置单元 (bias unit),因此其实上图更准确的样子如下:

这样就对了,此时 y = wx+b,有 w 和 b 两个参数了。

严格来说,其实 Dense() 函数里还是一个参数叫 activation,它字面意思是激活函数,本质上做的事情是将 wx+b 以非线性的模式转换再赋予给y。如果定义激活函数为 g,那么y=g(wx+b)。在 Keras 如果不给 activation 指定值,那么就不需要做任何非线性转换。加上激活函数这个概念,我们给出一个完整的图:

我们的目标就是求出上图中的参数,权重 w 和偏置 b。

1.3.3 编译模型

模型框架搭好后,接着就是优化问题了,在下面 complie() 函数设定参数 optimizer="sgd",即指定优化方法为随机梯度下降 ,设定参数 loss="mean_squared_error",即制定损失函数用均方误差函数。

toy_model.compile( 
  optimizer="sgd", 
  loss="mean_squared_error" 
)

1.3.4 训练模型

训练模型用 fit() 函数,把数据 x 和 y 传进去。值得注意的是参数 epochs=500,epoch 中文是期,即整个训练集被算法遍历的次数,这里就是遍历 500 次模型训练结束。

x = np.array([-1,  0, 1, 2, 3, 4])
y = np.array([-3, -1, 1, 3, 5, 7])
toy_model.fit(x=x, y=y, epochs=500)

打印出首尾 5 期的信息,不难发现一开始 loss 很大 13.4237,到最后 loss 非常小只有 3.8166e-05,说明在训练集里的预测值和真实值几乎一致。

模型训练之后可以用 get_weights() 函数来检查参数。

toy_model.get_weights()
# [ array([[1.9973876]], dtype=float32), 
#   array([-0.99190086], dtype=float32) ]

返回结果第一个是权重 w ,第二个偏置 b,因此该神经网络模型就是 y = 1.9973876x- 0.99190086 ≈ 2x-1。

1.3.5 评估模型

评估模型用 predict() 函数,将新数据 x_new 传进去,得到结果 8.995028,非常接近 2*x_new - 1 = 9。

x_new = 5
toy_model.predict([x_new])  # 8.995038

从下图可看出,神经网络从 6 个数据 (深青点) 中“学到”了模型 (红线),而该模型可用在新数据 (蓝点) 上。

总结一下神经网络全流程:

  1. 创建模型:用 Sequential(),当然还有其他更好的方法,下节讲。
  2. 检查模型:用 summary()
  3. 编译模型:用 compile()
  4. 训练模型:用 fit()
  5. 评估模型:用 predict()

虽然本例构建了一个极简神经网络,但是五大步骤一个不少,构建复杂的神经网络也需要这五步,区别在于第 1 步创建模型时要拼接很多层,第 5 步要选择更先进的优化器,但万变不离其宗。下两节就来看看两个稍微复杂的神经网络,分别是前反馈神经网络 (feedforward neural network, FNN) 和卷积神经网络 (convoluational neural network, CNN)。

2. 前馈神经网络

上节的极简神经网络太无聊了,但是主要是用来明晰 Keras 里神经网络的概念而步骤,下面来看看神经网络做一些有趣的事情,预测图像类别。首先看看使用的数据集 CIFAR-10 (https://www.cs.toronto.edu/~kriz/cifar.html)。

该数据集共有 60,000 张彩色图像,这些图像是 32*32,分为 10 个类,每类 6000 张图。其中 50,000 张图像用于训练,另外 10,000 用于测试。下图就是列举了 10 个类,每一类随机展示的 10 张图片:

用模块 datasets 里的 load_data() 函数来下载数据并对图像的像素做归一化,原来像素在 0 到 255 之间,现在归一到 0 到 1 之间。

(x_train, y_train), (x_test, y_test) = datasets.cifar10.load_data()
x_train = x_train.astype("float32") / 255.0
x_test = x_test.astype("float32") / 255.0

对于类别,用模块 utils 里的函数 to_categorical() 函数对类别进行独热编码 (one-hot encoding)。思路就是把整数用只含一个 1 的向量表示,比如类别 5 经过独热编码后变成 [0, 0, 0, 0, 1, 0, 0, 0, 0, 0],该向量有 10 个元素,和类别个数一致,向量只有第 5 个元素是 1 (独热🔥),其他都是 0 (好冷🧊)。

NUM_CLASSES = 10
y_train = utils.to_categorical(y_train, NUM_CLASSES)
y_test = utils.to_categorical(y_test, NUM_CLASSES)

训练集的前十张图片展示如下:

2.1 创建模型

2.1.1 序列式

上节已经见识过序列式 (sequential) 建模了,首先用 models.Sequential() 创建一个空神经网络,然后不断添加层。本例中有一个打平层 layers.Flatten() 和三个稠密层 layers.Dense()。

model = models.Sequential([
    layers.Flatten(input_shape=(32, 32, 3)),
    layers.Dense(units=200, activation='relu'),
    layers.Dense(units=150, activation='relu'),
    layers.Dense(units=10, activation='softmax'),
])

上面代码给出下图所示的模型:

有了感官认识,再来研究代码。为什么需要打平层?因为图像有宽,高,色道三个维度,而打平到一维的过程如下图所示。

原始图像 (32, 32, 3) 输入打平层 (在参数 input_shape 指定图像维度大小),打平之后变成了一个 32*32*3 = 3072 的向量,可以想成现在输入有 3072 个神经元。之后三个稠密层的

  • 神经元个数 (参数 units) 分别为 200, 150 和 10,前两个 200 和 150 是随便给的或者当成超参数调试出来,但最后一个 10 是和类别的个数一致。
  • 用到的激活函数 (参数 activation) 分别是 relu, relu 和 softmax,前两个 relu 几乎是标配,但最后一个 softmax 和任务相关,如果是多分类问题就用 softmax。

常用的激活函数 (activation function) 如下图所示:

ReLU 将负输入 (x < 0) 转换成 0, 正输入 (x > 0) 保持不变。LeakyReLU 和 ReLU 非常相似,唯一区别就是对于负输入 (x < 0),转换的结果也是一个和输入相关的负数 (ax)。

Sigmoid 将实数转换成 0-1 之间的数,而这个数可当成概率,因此 Sigmoid 函数用于二分类问题,它的延伸版 Softmax 函数用于多分类问题。

2.1.2 函数式

在实操中,我们更习惯用函数式 (functional) 建模。序列式构建的模型都可以用函数式来完成,反之不行,如果在两者选一,建议只用函数式来构建模型。代码如下:

input = layers.Input(shape=(32, 32, 3))
x = layers.Flatten()(input)
x = layers.Dense(units=200, activation='relu')(x)
x = layers.Dense(units=150, activation='relu')(x)
output = layers.Dense(units=10, activation='softmax')(x)

model = models.Model(input, output)

函数式建模只用记住一句话:把层当做函数用。有了这句在心,代码秒看懂。

  • 第 1 行,用 Input() 接收图像数据。
  • 第 2 行,把 Flatten() 当成函数 f,化简不就是 x = f(input)
  • 第 3 行,把 Dense(units=200, activation='relu') 当成函数 g,化简不就是 x = g(x)
  • 第 4 行,把 Dense(units=150, activation='relu') 当成函数 h,化简不就是 x = h(x)
  • 第 5 行,把 Dense(units=10, activation='softmax') 当成函数 q,化简不就是 output = q(x)

这样一层层函数接着函数把 input 传递到 output,output = q(h(g(f(input)))),最后再用 models.Model 将它俩建立关系。

2.2 检查模型

当模型创建之后和使用之前,最好是检查一下神经网络每层的数据形状是否正确,用 summary() 函数就能帮你打印出此类信息。

model.summary()

该模型自动被命名 “model”,接着一张表分别描述每层的名称类型 (layer (type))、输出形状 (Output Shape) 和参数个数 (Param #)。我们一层层来看

  • InputLayer 层被命名成 input_1,输出形状为 [None, 32, 32, 3],后面三个元素对应着图像宽、高和色道,第一个 None 其实代表的样本数,更严谨的讲是一批 (batch) 里面的样本数。为了代码简洁,这个样本数在建模时通常不需要显性写出来。
  • Flatten 层被命名成 flatten,3072 就是 32*32*3 打平之后的个数,参数个数为 0,因为打平只是重塑数组,不需要任何参数来完成重塑动作。
  • 第一个 Dense 层被命名为 dense,输出形状是 200,参数 614,600 = (3072 + 1) * 200,不要忘了有偏置单元
  • 第二个 Dense 层被命名为 dense_1,输出形状是 150,参数 30,150 = (200 + 1) * 150,同样考虑偏置单元
  • 第三个 Dense 层被命名为 dense_2,输出形状是 10,参数 1,510 = (150 + 1) * 10,同样考虑偏置单元

最下面还列出总参数量 (Total params) 646,260,可训练参数量 (Trainable params) 646,260,不可训练参数量 (Non-trainable params) 0。为什么还有参数不需要训练呢?你想想迁移学习,把借过来的网络锁住开始的 n 层,只训练最后 1- 2 层,那前面 n 层的参数可不就不参与训练吗?

2.3 编译模型

当构建模型完毕,接着需要编译模型,需要设定三点:

  1. 根据要解决的任务来选择损失函数
  2. 选取理想的优化器
  3. 选取想监控的指标

编译模型用 complie() 函数,代码如下:

opt = optimizers.Adam(learning_rate=0.0005)
model.compile(
    loss='categorical_crossentropy',
    optimizer=opt,
    metrics=['accuracy']
    )

在 complie() 函数中:

  • 对于参数 loss,本例是十分类问题,因此用的损失函数是 categorical_crossentropy,此外:
  • 二分类问题:损失函数是 binary_crossentropy
  • 回归问题:损失函数是 mean_squared_error
  • 对于参数 optimizer,大多数情况下,使用 adam 和 rmsprop 优化器及其默认的学习率是稳妥的。在设定该参数时,也可以通过用名称实例化对象来调用。
  • 名称:'sgd'
  • 对象optimizers.Adam(learning_rate=0.0005)
  • 对于参数 metrics,也可以通过用名称实例化对象来调用,在本例中的指标是精度,那么可写成
  • 名称:['accuracy']
  • 对象:[metrics.categorical_accuracy]

注意,指标不会影响模型的训练过程,只是让我们监控模型训练时的表现,损失函数才会影响模型的训练过程。

2.4 训练模型

训练模型不是把所有数据一起丢进去,而是按批量丢进去。在介绍训练模型前,需要明晰几个概念:

  • 批量大小 (batch size) 指一个批量里的样本个数。下例中总共有 24 个数据,如果每个批里有 6 个数据,那么总局可分成 4 批。

  • (epoch) 指整个训练集被算法遍历一次。当设 epoch 为 20 时,那么要以不同的方式遍历整个训练集 20 次。一次 epoch 要经历 4 次迭代才能遍历整个数据集,即样本总数 / 批量大小 = 24 / 6 次迭代。20 次 epoch 运行过程如下图所示。

训练模型用 fit() 函数,代码如下:

model.fit(
        x = x_train, 
        y = y_train,
        batch_size = 32, 
        epochs = 10, 
        shuffle = True
)

上图给出训练步骤,不难看出训练集被分成 1563 个堆,每堆含 32 张图 (batch size)。10 个 epoch 之后,损失函数 (categorical cross-entropy) 从 1.8472 降到 1.3696,同时准确率 (accuracy) 从 33.41% 提升到 51.39%。模型在训练集上可以到达 51.39% 的准确率,那么它在没见过的数据集上的表现会如何呢?

2.5 评估模型

用 evaluate() 函数直接看准确率。

model.evaluate(x_test, y_test)

模型在测试集上的准确率为 49.52%,比随机预测一个类别的准确率 10% 高多了 (因为有十类)。由于我们用这样一个非常简单的前馈神经网络来预测图片类别,49.52% 的准确率已经算是不错的结果了。

用 predict() 函数比对预测和真实类别。

CLASSES = np.array(['airplane', 'automobile', 'bird', 'cat', 'deer',
                    'dog', 'frog', 'horse', 'ship', 'truck'])

preds = model.predict(x_test) 
preds_single = CLASSES[np.argmax(preds, axis = -1)] 
actual_single = CLASSES[np.argmax(y_test, axis = -1)]

测试集里用 10,000 张图,类别是 10 个,因此 preds 是一个 [10000, 10] 的数组,每一行都是模型对相应图片预测的 10 个类别的概率,当然所有概率加起来等于 1。看看测试集里第一张图片的预测结果:

preds[0,:]

y_test 也是一个 [10000, 10] 的数组,每一行都是相应图片真实的类别,因此 10 个元素有 9 个零和 1 个一。看看测试集里第一张图片的真实类别:

y_test[0,:]

不难看出,预测结果 preds[0,:] 中类别四的概率最高 0.38579068,而真实类别 test[0.:] 就是类别四 (第 4 个元素是一)。用 np.argmax 分别从预测结果 preds[0,:] 和真实类别 test[0.:] 中找到最大值对应的索引,并从 CLASSES 中映射出类别描述。

print( np.argmax(preds[0,:], axis=-1) )            # 3
print( CLASSES[np.argmax(preds[0,:], axis=-1)] )   # cat
print( np.argmax(y_test[0,:], axis=-1) )           # 3
print( CLASSES[np.argmax(y_test[0,:], axis=-1)] )  # cat

测试集第一张是猫,而模型预测的也是猫,做对了!

再试试第四张。

print( np.argmax(preds[3,:], axis=-1) )            # 8
print( CLASSES[np.argmax(preds[3,:], axis=-1)] )   # ship
print( np.argmax(y_test[3,:], axis=-1) )           # 0
print( CLASSES[np.argmax(y_test[3,:], axis=-1)] )  # airplane
测试集第四张是船,但模型预测的是飞机,做错了!

可视化:上面的对比方法太麻烦,我们可以随机抽取测试集里的 10 张,打印出每张图片,在图片下还贴上模型预测类别和其真实类别。

n_to_show = 10
indices = np.random.choice(range(len(x_test)), n_to_show)

fig = plt.figure(figsize=(15, 3))
fig.subplots_adjust(hspace=0.4, wspace=0.4)

for i, idx in enumerate(indices):
    img = x_test[idx]
    ax = fig.add_subplot(1, n_to_show, i+1)
    ax.axis('off')
    ax.text(0.5, -0.35, 'pred = ' + str(preds_single[idx]), 
            fontsize=10, ha='center', transform=ax.transAxes)
    ax.text(0.5, -0.7, 'act = ' + str(actual_single[idx]),
            fontsize=10, ha='center', transform=ax.transAxes)
    ax.imshow(img)

从上面 10 张小图可看出,模型预测正确了 5 张,正确率 50%,和之前统计出来的 49.52% 吻合。虽然这只是一个用于预测的判别模型,但当我们创建生成模型时,本节介绍的内容 (比如层、激活函数和优化器等) 仍然适用。

下一步来看看如何用卷积神经网络来改进模型。

3. 卷积神经网络

前馈神经网络 (FNN) 在图像分类问题上表现差的根本原因是它没有考虑到图像的空间结构,比如图像中的相邻像素都很接近,而 FNN 一开始直接将像素打平,破坏图像特有的空间结构。我们需要更适合图像的神经网络,比如卷积神经网络 (CNN)。

3.1 基本概念

假设在黑夜你面前出现一张巨幅图片,黑暗中你看不出来是辆车,你只能用手电筒一点一点扫过,把每次扫过看到的东西投影到下一层,以此类推。比如第一层你看到一些横线竖线斜线,第二层组合成一些圆形方形,第三层组合成轮子车门车身,第四层组合成一辆车。这样就能用个手电筒在黑夜里辨别出照片里有辆车了。

上例其实就是一个卷积神经网络识别图像的过程了,首先明晰几个定义:

  • 滤波器 (filter):在输入数据的宽度和高度上滑动,与输入数据进行卷积,就像上例中的手电筒。
  • 卷积 (convolution):在这里的定义就是把所有“滤波器的像素”乘以“滤波器扫过图片的像素”再加总。
  • 步长 (stride):遍历图像时滤波器的步长,默认值为 1,既滤波器每次移动一个像素。
  • 填充 (padding):有时候会将输入数据用 0 在边缘进行填充,可以控制输出数据的尺寸 (最常用的是保持输出数据的尺寸与输入数据一致)。

卷积 (Convolution)

卷积神经网络的最大特点当然是卷积操作了。回顾上面的定义,将“滤波器的像素”乘以“滤波器扫过图片的像素”再加总,看下面两个例子,假设滤波器的大小是 3*3。

第一张图片和滤波器的卷积为 0.6*1 + 0.4*1 + 0.6*1 + 0.1*0 + (-0.2)*0 + (-0.3)*0 + (-0.5)*(-1) + (-0.4)*(-1) + (-0.3)*(-1) = 2.8

第二张图片和滤波器的卷积为 (-0.7)*1 + 0.6*1 + 0.2*1 + 0.1*0 + 0.5*0 + (-0.3)*0 + (-0.3)*(-1) + (-0.4)*(-1) + 0.5*(-1) = -0.1

当卷积值越,说明滤波器和图片越相符;当卷积值越,说明滤波器和图片越不符。上例中第一张图片和滤波器的卷积值为 2.8,两者相符;第二张图片和滤波器的卷积值为 -0.1,两者不符

滤波器 (Filter)

滤波器的作用就是滤波,即过滤掉一些信息,等价于提取保留下的信息。下面代码创建两个 3*3 大小的滤波器,filter1 能提取图像中的水平线,filter2 能提取图像中的竖直线。注意这里 1 代表黑,0 代表灰,-1 代表白色。

filter1 = np.array([[1, 1, 1], [0, 0, 0], [-1, -1, -1]])
filter2 = np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]])
fig, ax = plt.subplots(1,2)
ax[0].axis("off")
ax[1].axis("off")
ax[0].imshow(filter1, cmap="Greys")
ax[1].imshow(filter2, cmap="Greys");

下面看一个如何用这两个滤波器来提取信息的,原始图片如下:

%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
from skimage import data
from skimage.color import rgb2gray
from skimage.transform import resize

im = rgb2gray(data.coffee())
im = resize(im, (64, 64))
plt.axis("off")
plt.imshow(im, cmap="gray");

不难发现,filter 1 的确从图像中提取到水平的边缘信息,比如杯子口的上下沿。

new_image = np.zeros(im.shape)
im_pad = np.pad(im, 1, "constant")

for i in range(im.shape[0]):
    for j in range(im.shape[1]):
        new_image[i, j] = (
            im_pad[i - 1, j - 1] * filter1[0, 0]
            + im_pad[i - 1, j] * filter1[0, 1]
            + im_pad[i - 1, j + 1] * filter1[0, 2]
            + im_pad[i, j - 1] * filter1[1, 0]
            + im_pad[i, j] * filter1[1, 1]
            + im_pad[i, j + 1] * filter1[1, 2]
            + im_pad[i + 1, j - 1] * filter1[2, 0]
            + im_pad[i + 1, j] * filter1[2, 1]
            + im_pad[i + 1, j + 1] * filter1[2, 2]
        )

plt.axis("off")
plt.imshow(new_image, cmap="Greys")

不难发现,filter 2 的确从图像中提取到竖直的边缘信息,比如杯子口的左右沿。

new_image = np.zeros(im.shape)
im_pad = np.pad(im, 1, "constant")

for i in range(im.shape[0]):
    for j in range(im.shape[1]):
        new_image[i, j] = (
            im_pad[i - 1, j - 1] * filter2[0, 0]
            + im_pad[i - 1, j] * filter2[0, 1]
            + im_pad[i - 1, j + 1] * filter2[0, 2]
            + im_pad[i, j - 1] * filter2[1, 0]
            + im_pad[i, j] * filter2[1, 1]
            + im_pad[i, j + 1] * filter2[1, 2]
            + im_pad[i + 1, j - 1] * filter2[2, 0]
            + im_pad[i + 1, j] * filter2[2, 1]
            + im_pad[i + 1, j + 1] * filter2[2, 2]
        )

plt.axis("off")
plt.imshow(new_image, cmap="Greys")

有了滤波器的加入,我们可以创建卷积层 (convoluational layer) 了。卷积层本质上就是一组滤波器,下例中个数是 2 个,而滤波器中的元素值称为权重 (weights),是通过训练 CNN 学到的。

在 Keras 中用 layers.Conv2D() 来创建卷积层。这里黑白相片是 64*64*1 (色道只有 1 个),而滤波器有两个 (参数 filters 设置为 2),滤波器大小是 3*3 (参数 kernel_size 设置为 (3, 3))。

input = layers.Input(shape=(64,64,1))
conv_layer_1 = layers.Conv2D(
    filters = 2, 
    kernel_size = (3,3), 
    strides = 1, 
    padding = "same"
    )(input)

还有两个参数 strides 和 padding 是什么东西?

步长 (Stride)

步长是滤波器遍历图像时移动的像素个数,默认值为 1,既滤波器每次移动一个像素。当步长为 2 时,不难想象输出图像大小只有输入图像大小的一半。

填充 (Padding)

顾名思义,填充就是在图像四周添加元素。当 padding = "same" 时,配着 strides = 1,可以保证输出图像和输入图像的大小一样。下图输入图像大小是 5*5 (蓝色图片),填充之后图像大小变成 7*7 (带白色的图片),滤波器大小是 3*3 (灰色),输出图像大小还保持 5*5 (绿色图片)。

弄清楚组成卷积层的元素之后,我们可以像上节拼接稠密层一样来拼接卷积层。

3.2 拼接卷积层

先看一段代码:

上段代码对应着下图的样子。

上面每个卷积层输出的大小让人眼花缭乱,如果用 n_I 代表输入图像的大小, f 代表滤波器的大小,s 代表步长, p 代表填充层数,n_O 代表输入图像的大小,那么有以下关系:

用这个公式来验证第一个和第二个卷积层的输出的宽度和高度:

最重要的东西来了,卷积层的输出色道等于滤波器个数 (即代码里面的参数 filters)。一个直观理解是每个滤波器并行在“扫描”图片做卷积,那么最终产出一定有一个维度大小是滤波器的个数。

检查一下模型。

model.summary()

该模型自动被命名 “model”,接着一张表分别描述每层的名称类型 (layer (type))、输出形状 (Output Shape) 和参数个数 (Param #)。我们一层层来看

  • InputLayer 层被命名成 input_1,输出形状为 [None, 32, 32, 3],后面三个元素对应着图像宽、高和色道,第一个 None 其实代表的样本数,更严谨的讲是一批 (batch) 里面的样本数。为了代码简洁,这个样本数在建模时通常不需要显性写出来。
  • 第一个 Conv2D 层被命名为 conv2d,输出形状是 [None, 16, 16, 10],参数 490 = (4*4*3 + 1) * 10,首先不要忘了有偏置单元,其次 4*4 是滤波器的大小,3 是输入的色道个数,因此我们需要 4*4*3 个权重来描述每个滤波器,一共有 10 个。
  • 第二个 Conv2D 层被命名为 conv2d_1,输出形状是 [None, 8, 8, 20],参数 1,820 = (3*3*10 + 1) * 20,首先同样考虑偏置单元,其次 3*3 是滤波器的大小,10 是输入的色道个数,因此我们需要 3*3*10 个权重来描述这个滤波器,一共有 20 个。
  • Flatten 层被命名成 flatten,1,280 就是 8*8*20 打平之后的个数,参数个数为 0,因为打平只是重塑数组,不需要任何参数来完成重塑动作。
  • 最后一个 Dense 层被命名为 dense,输出形状是 10,参数 12,810 = (1280 + 1) * 10,同样考虑偏置单元

最下面还列出总参数量 (Total params) 15,120,可训练参数量 (Trainable params) 15,120,不可训练参数量 (Non-trainable params) 0

到此一个 CNN 已经基本建成,我们再添加两个技巧使得 CNN 效果更好:批量归一 (batch normalization) 和随机失活 (dropout)。

3.3 批量归一

在训练 CNN 时,模型成功关键时要确保权重保持在一定的范围内,要不然会出现梯度爆炸 (exploding gradient) 的情况。批量归一可以解决此问题,它在每层都会按批 (mini-batch) 计算数据的均值 (mean) 和标准差 (standard deviation),然后在每个数据上减去均值除以标准差。为了“还原”数据,我们需要“学习”两个参数,放缩参数 γ 和平移参数 β

批量归一的算法如下:

Keras 中用 BatchNormalization() 来实现批量归一层。批量归一层一般放在稠密层或卷积层之后。

layers.BatchNormalization(momentum = 0.9)

函数中参数 momentum 用于计算移动均值和移动标准差,这个是为了在预测的时候使用。因为预测通常在一个数据上,这时无法计算均值和标准差,那么只能利用在训练时计算的移动均值和移动标准差。

3.4 随机失活

随机失活的灵感来自考试。通常考试前,学生会做往年的卷子来学习知识点。有的学生死记硬背来解题,这样到了实际考试中就会表现不好,因为他们没有真正理解知识点。好的学生会通过卷子来理解通用的知识点,这样出现新题也能正确解答。

同理,为了让神经网络不要“死记硬背”,我们可以随机让某些神经元失活,即使得它们的输出为 0,如下图所示。

在预测过程中,神经元不失活,因此用完整的神经网络做预测。

Keras 中用 Dropout() 来实现失活层。失活层一般放在稠密层之后。

layers.Dropout(rate = 0.25)

函数中参数 rate 用于设定失活神经元的比率,比如本例中 25% 的神经元失活了。

3.5 完整模型

现在我们可以在之前的 CNN 加上批量归一层和失活层来完善模型了。

再看上面的代码是不是很好理解了,该 CNN 中有四个卷积层,每个后面接一个批量归一层和一个 LeakyReLu 层。注意 Keras 里时万物皆可作为层,甚至像激活函数也可以用层的形式实现。接着用一个打平层将数据打平,接一个稠密层,个批量归一层,一个 LeakyReLu 层,一个失活层和一个稠密层,最后用 softmax 以概率的形式输出。

检查一下这个完善后的 CNN 模型。

model.summary()

我们发现激活层都不包含参数,因为就是一个转换;打平层失活层也不包含参数,这个也很好理解;对于卷积层稠密层的参数量,之前已经解释过算法;对于批量归一层,对于每个 channel 需要学习放缩参数 γ 和平移参数 β,以及移动均值和移动标准差,这样包含参数就等于 channel 个数*4。

CNN 里面有 5 个批量归一层,每层里面移动均值和移动标准差只用计算而不需要训练,因此非训练参数为 32*2 + 32*2 + 64*2 + 64*2 + 128*2 = 640 个。

3.6 训练评估

万事俱备,只欠训练。这一次我们增加了参数 validation_data,用于监控模型在训练时是否出现过拟合,而过拟合发生在训练误差 (loss) 一直在减小,但是验证误差 (val_loss) 却在增加。从下图看还没出现这样的问题。

model.fit(
    x = x_train,
    y = y_train,
    batch_size = 32,
    epochs = 10,
    shuffle = True,
    validation_data = (x_test, y_test),
)

model.evaluate(x_test, y_test, batch_size=1000)

对比现在的卷积神经网络 (CNN) 和之前的前馈神经网络 (FNN),现有模型在训练集的准确率从之前 51.39%提升到 76.99%,在训练集的准确率也从之前 49.52%提升到 71.70%,模型性能大大提高。

神奇的是,CNN 的参数 (592,554) 其实比 FNN 的参数 (646,260) 少很多,但模型性能却提高了不少,而这种提升只需更改模型架构以包括卷积层、批量归一层和失活层即可实现。虽然 CNN 比 FNN 的参数少,但是层数确多很多,这就是为什么深度神经网络的优势,因为网络的中间层捕获了我们最感兴趣的高级特征 (high-level features)。

从上面 10 张小图可看出,模型预测正确了 6 张,正确率 60%,虽然之前统计出来的 71.70% 低,但这个是从 10000 张测试集中采样出来的 10 张,因此看到模型正确预判了 6, 7, 8 张都是正常的。

总结

本篇介绍了开始构建深度生成模型所需的核心深度学习概念。使用 Keras 构建前馈神经网络 (FNN),并训练模型来预测 CIFAR-10 数据集中给定图像的类别。然后,我们通过引入卷积层、批量归一层和失活层来改进此架构,以创建卷积神经网络 (CNN)。

深度神经网络在设计上是完全灵活的,尽量有最佳实践,但我们可随意尝试不同的层以及其出现的顺序,用 Keras 实现就像拼乐高积木一样丝滑,你的神经网络的设计仅受你自己的想象力的限制。

下篇我们将使用这些模块来设计一个可以生成图像的网络。生成式 AI 的好戏刚刚开始!