TensorFlow 初步实现 CNN 卷积神经网络

这次深度学习实验课上,老师要求我们用 tensorflow 深度学习框架实现 CNN 卷积神经网络,并在经典的 MNIST 数据集上训练和评估自己的模型,即通过卷积神经网络初步解决手写体数字识别问题。

实验题目需要实现的网络结构如下,有一个输入层、两个卷积层、两个池化层以及两个全连接层,最后一个全连接层就是我们的输出层:
在这里插入图片描述

1 数据集的下载与处理

该部分在我前面的博文中有详细介绍,请点击此处参考。

2 构造卷积块

在处理好数据集后,我们就要开始对模型的各个子结构进行构建,我们先来构造模型中的卷积块,一个卷积块通常由卷积层和池化层组合而成,因此在这个部分我们需要分别实现这两个组件。

2.1 实现卷积层

卷积层的作用是提取一个局部区域的特征,不同的卷积核相当于不同的特征提取器。特征映射(Feature Map)为一幅图像(或其他特征映射)在经过卷积提取到的特征,每个特征映射可以作为一类抽取的图像特征。为了提高卷积网络的表示能力,可以在每一层使用多个不同的特征映射,以更好地表示图像的特征。

在输入层,特征映射就是图像本身。如果是灰度图像,就是有一个特征映射,输入层的深度 D = 1;如果是彩色图像,分别有 RGB 三个颜色通道的特征映射,输入层的深度 D = 3。

我们可以假设一个卷积层的结构如下:

  • 输入特征映射 X ∈ R   M × N × D X \in R^{~M \times N \times D} XR M×N×D 为三维张量(Tensor),其中每个切片(Slice)矩阵 X d ∈ R   M × N ,   1 ≤ d ≤ D X^d \in R^{~M \times N},~1 \le d \le D XdR M×N, 1dD 为一个输入特征映射。这是因为卷积层主要应用在图像处理上,而图像为二维结构,因此为了更充分地利用图像的局部信息,通常将神经元组织为三维结构的神经层,其大小为高度 M × 宽度 N × 深度 D,由 D 个 M × N 大小的特征映射构成。

  • 输出特征映射 Y ∈ R   M ′ × N ′ × P Y \in R^{~M' \times N' \times P} YR M×N×P 为三维张量,其中每个切片矩阵 Y P ∈ R   M ′ × N ′ ,   1 ≤ p ≤ P Y^P \in R^{~M' \times N'},~1 \le p \le P YPR M×N, 1pP 为一个输出特征映射,这里的 P 其实就是卷积层中卷积核的个数。

  • 卷积核 W ∈ R   U × V × P × D W \in R^{~U \times V \times P \times D} WR U×V×P×D 为四维张量, U × V U \times V U×V 表示卷积核的大小,其中每个切片矩阵 W p , d ∈ R   U × V ,   1 ≤ p ≤ P ,   1 ≤ d ≤ D W^{p,d} \in R^{~U \times V},~1 \le p \le P,~1 \le d \le D Wp,dR U×V, 1pP, 1dD 为一个二维卷积核。

在这里插入图片描述
为了计算输出特征映射 Y p Y^p Yp,用卷积核 W p , 1 ,   W p , 2 ,   ⋯   ,   W p , D W^{p,1},~W^{p,2},~\cdots,~W^{p,D} Wp,1, Wp,2, , Wp,D 分别对输入特征映射 X 1 ,   X 2 ,   ⋯   ,   X D X^1,~X^2,~\cdots,~X^D X1, X2, , XD 进行卷积,然后将卷积结果相加,并加上一个标量偏置 b p b^p bp,接着经过非线性的激活函数,最后得到输出特征映射 Y p Y^p Yp

Y P = f ( W p ⊗ X + b p ) = f ( ∑ d = 1 D W p , d ⊗ X d + b p ) Y^P = f(W^p \otimes X + b^p) = f(\sum_{d=1}^D W^{p,d} \otimes X^d + b^p) YP=f(WpX+bp)=f(d=1DWp,dXd+bp)

其中 W p ∈ R   U × V × D W^p \in R^{~U \times V \times D} WpR U×V×D 为三维卷积核, f ( ⋅ ) f( \cdot ) f() 为非线性的激活函数,一般我们采用 ReLU 激活函数。

整个计算过程如下图所示,如果希卷积层输出 P 个特征映射,可以将上述计算过程重复 P 次,得到 P 个输出特征映射 Y 1 ,   Y 2 ,   ⋯   ,   Y P Y^1,~Y^2,~\cdots,~Y^P Y1, Y2, , YP
在这里插入图片描述
在 TensorFlow 中,它已经帮我们封装实现好了上述过程,在实际编程时,我们只需要调用 TensorFlow 提供的 API tf.nn.conv2d 就可以进行卷积操作:

output = tf.nn.conv2d(input=X, filter=W, strides=[1, 1, 1, 1], padding="SAME")  # 卷积操作
  • input:输入特征映射 X ∈ R   M × N × D X \in R^{~M \times N \times D} XR M×N×D
  • filter:卷积核 W ∈ R   U × V × D × P W \in R^{~U \times V \times D \times P} WR U×V×D×P,注意这里的卷积核表示和上面描述的略有不同,这里是 D 在前 P 在后,分别表示输入通道数和输出通道数
  • strides:记录卷积核滑动步长的 4 维列表,其第 1、4 维是固定的,第 2、3 维分别表示卷积核向右或向下移动的步长
  • padding:表示使用何种填充算法,“SAME” 表示全 0 填充,卷积后图片大小保持不变,“VALID” 表示不填充

2.2 实现池化层

池化层(Pooling Layer)的作用是进行特征选择,降低特征数量,从而减少参数数量。卷积层虽然可以显著减少网络中连接的数量,但特征映射组中的神经元个数并没有显著减少。如果后面接一个分类器,分类器的输入维数依然很高,很容易出现过拟合。为了解决这个问题,除了增加卷积步长,还可以在卷积层之后加上一个池化层,从而降低特征维数,避免过拟合。

假设池化层的输入特征映射为 X ∈ R   M × N × D X \in R^{~M \times N \times D} XR M×N×D,对于其中每一个特征映射 X d ∈ R   M × N ,   1 ≤ d ≤ D X^d \in R^{~M \times N},~1 \le d \le D XdR M×N, 1dD,将其划分为很多区域 R m , n d ,   1 ≤ m ≤ M ′ ,   1 ≤ n ≤ N ′ R_{m,n}^d,~1 \le m \le M',~1 \le n \le N' Rm,nd, 1mM, 1nN,这些区域可以重叠,也可以不重叠。所谓池化也就是对每个区域进行下采样(Down Sampling)得到一个值,作为这个区域的概括。

我们通常用两种池化函数帮助我们计算:

  • 最大池化(Max Pooling):对于一个区域 R m , n d R_{m,n}^d Rm,nd,选择这个区域内所有神经元的最大活性值作为这个区域的表示,即:
    y m , n d = max ⁡ i ∈ R m , n d x i y_{m,n}^d = \max_{i \in R_{m,n}^d} x_i ym,nd=iRm,ndmaxxi

    其中 x i x_i xi 为区域 R m , n d R_{m,n}^d Rm,nd 内每个神经元的活性值。

  • 平均池化(Mean Pooling):一般是取区域内所有神经元活性值的平均值,即:
    y m , n d = 1 ∣ R m , n d ∣ ∑ i ∈ R m , n d x i y_{m,n}^d = \frac 1 {|R_{m,n}^d|} \sum_{i \in R_{m,n}^d} x_i ym,nd=Rm,nd1iRm,ndxi

由此,池化对每一个输入特征映射 X d X^d Xd M ′ × N ′ M' \times N' M×N 个区域进行子采样,得到输出特征映射 Y d = { y m , n d } ,   1 ≤ m ≤ M ′ ,   1 ≤ n ≤ N ′ Y^d = \{y_{m,n}^d\},~1 \le m \le M',~1 \le n \le N' Yd={ym,nd}, 1mM, 1nN。因此我们其实也可以把池化层理解成一个特殊的卷积层,卷积核的大小为池化窗口的大小,步长为池化窗口移动的步长,卷积核为 max 函数或 mean 函数。

在这里插入图片描述
在上图中,我们还可以看到,池化层不但可以有效地减少神经元的数量,还可以使得网络对一些小的局部形态改变保持不变性,并拥有更大的感受野。典型的池化层一般是是将每个特征映射划分为 2 × 2 大小的不重叠区域,然后使用最大池化的方式进行下采样。过大的采样区域会急剧减少神经元的数量,也会造成过多的信息损失。

在 TensorFlow 中,它已经帮我们封装实现好了上述过程,在实际编程时,我们只需要调用 TensorFlow 提供的 API tf.nn.max_pooltf.nn.avg_pool 就可以分别进行最大池化和平均池化操作:

output = tf.nn.max_pool(value=X, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding="SAME")
  • value:输入特征映射 X ∈ R   M × N × D X \in R^{~M \times N \times D} XR M×N×D
  • ksize:记录池化窗口大小的 4 维列表,其第 1、4 维是固定的,第 2、3 维分别表示池化窗口的高度和宽度
  • strides:记录池化窗口滑动步长的 4 维列表,其第 1、4 维是固定的,第 2、3 维分别表示池化窗口向右或向下移动的步长
  • padding:表示使用何种填充算法,“SAME” 表示全 0 填充,池化后图片大小保持不变,“VALID” 表示不填充

2.3 构造卷积块

在了解卷积层和池化层怎么实现之后,我们可以把它们整合在一起成为卷积块,这里我们在一层卷积层后直接再接一层池化层。实际编程时我们可以通过类把它们进行封装,并通过 __call__() 方法把类实例当做函数进行调用,这样我们就可以把卷积块作为一个 API 接口 ConvolutionBlock 给后续的程序直接调用:

class ConvolutionBlock():
    '''
    msg: Convolution layer + Attention + Pool

    param {
        filter_size: int, 卷积核大小
        in_channels: int, 输入通道数, 即输入层的深度
        output_channels: int, 输出通道数, 即卷积核个数
        attention: function, 激活函数
        conv_strides: list, 记录卷积核滑动步长的 4 维列表, 其第 1, 4 维是固定的, 第 2, 3 维分别表示卷积核向右或向下移动的步长
        conv_padding: str, 表示使用何种填充算法, "SAME" 表示全 0 填充, 卷积后图片大小保持不变, "VALID" 表示不填充
        pool_ksize: list, 记录池化窗口大小的 4 维列表, 其第 1, 4 维是固定的, 第 2, 3 维分别表示池化窗口的高度和宽度
        pool_strides: list, 记录池化窗口滑动步长的 4 维列表, 其第 1, 4 维是固定的, 第 2, 3 维分别表示池化窗口向右或向下移动的步长
        pool_padding: str, 表示使用何种填充算法, "SAME" 表示全 0 填充, 池化后图片大小保持不变, "VALID" 表示不填充
        reshape_flag: bool, 表示是否对结果进行 reshape 操作
        new_shape: list, 表示要 reshape 的维度
    }
    '''
    def __init__(self, filter_size, in_channels, output_channels, attention, 
                conv_strides, conv_padding, pool_ksize, pool_strides, 
                pool_padding, reshape_flag=False, new_shape=None):
        self.W = tf.Variable(
            tf.truncated_normal(shape=[filter_size, filter_size, in_channels, output_channels], stddev=0.1)
            )  # 使用截断正态分布初始换权重矩阵
        self.b = tf.Variable(tf.constant(0.1, shape=[output_channels]))  # 使用常数初始化偏置矩阵
        self.attention = attention
        self.conv_strides = conv_strides
        self.conv_padding = conv_padding
        self.pool_ksize = pool_ksize
        self.pool_strides = pool_strides
        self.pool_padding = pool_padding
        self.reshape_flag = reshape_flag
        self.new_shape = new_shape

    def __call__(self, X):
        output = tf.nn.conv2d(input=X, filter=self.W, strides=self.conv_strides, padding=self.conv_padding)  # 卷积操作
        output = tf.nn.bias_add(output, self.b)  # 加上偏置项
        output = self.attention(output)  # 使用激活函数
        output = tf.nn.max_pool(value=output, ksize=self.pool_ksize, strides=self.pool_strides, padding=self.pool_padding)  # 池化操作
        if self.reshape_flag:
            output = tf.reshape(output, self.new_shape)
        return output

3 构造全连接层

然后我们来构造模型中的全连接层,全连接层实际上实现的就是矩阵乘法运算:
h ⃗ i = x ⃗ i W + b ⃗ \vec h_i = \vec x_i W + \vec b h i=x iW+b

其中, x ⃗ i \vec x_i x i 是我们第 i 个样本的输入向量,W 是权重矩阵,即上一层神经元与本层神经元的连接的权重, b ⃗ \vec b b 是对应偏置项, h ⃗ i \vec h_i h i 就是本层的输出向量,把上式展开就是:
( h i 1 , h i 2 , ⋯   , h i d ′ ) = ( x i 1 , x i 2 , ⋯   , x i d ) ( w 11 w 12 ⋯ w 1 d ′ w 21 w 22 ⋯ w 2 d ′ ⋮ ⋮ ⋱ ⋮ w d 1 w d 2 ⋯ w d d ′ ) + ( b i 1 , b i 2 , ⋯   , b i d ′ ) (h_{i1}, h_{i2}, \cdots, h_{id'})= (x_{i1}, x_{i2}, \cdots, x_{id}) \begin{pmatrix} w_{11} & w_{12} & \cdots & w_{1d'} \\ w_{21} & w_{22} & \cdots & w_{2d'} \\ \vdots & \vdots & \ddots & \vdots \\ w_{d1} & w_{d2} & \cdots & w_{dd'} \end{pmatrix} + (b_{i1}, b_{i2}, \cdots, b_{id'}) (hi1,hi2,,hid)=(xi1,xi2,,xid)w11w21wd1w12w22wd2w1dw2dwdd+(bi1,bi2,,bid)

同样,我们也使用类对它进行封装,并提供一个 API 接口 LinearLayer 供后续程序调用:

class LinearLayer():
    '''
    msg: Full connection layer + Attention + Dropout

    param {
        input_dims: int, 输入层维度
        output_dims: int, 输出层维度
        attention: function, 激活函数
        dropout_flag: bool, 表示是否进行 dropout 操作
        keep_prob_rate: float, 表示 dropout 中存活的神经元比例
    }
    '''
    def __init__(self, input_dims, output_dims, attention, dropout_flag=False, keep_prob_rate=None):
        self.W = tf.Variable(tf.truncated_normal(shape=[input_dims, output_dims], stddev=0.1))  # 使用截断正态分布初始换权重矩阵
        self.b = tf.Variable(tf.constant(0.1, shape=[output_dims]))  # 使用常数初始化偏置矩阵
        self.attention = attention
        self.dropout_flag = dropout_flag
        self.keep_prob_rate = keep_prob_rate

    def __call__(self, x):
        output = tf.matmul(x, self.W)  # 矩阵乘法
        output = tf.nn.bias_add(output, self.b)  # 加上偏置项
        output = self.attention(output)  # 使用激活函数
        if self.dropout_flag:
            output = tf.nn.dropout(output, self.keep_prob_rate)  # 使用 dropout 使一部分神经元随机失活,用来防止过拟合
        return output

4 搭建卷积神经网络模型

卷积神经网络通常是由卷积层、池化层、全连接层交叉堆叠而成,目前常用的卷积神经网络整体结构如下图所示。一个卷积块为连续 M 个卷积层和 b 个池化层(M 通常设置为 2 ∼ 5,b 为 0 或 1),一个卷积网络中可以堆叠 N 个连续的卷积块,然后在后面接着 K 个全连接层(N 的取值区间比较大,比如 1 ∼ 100 或者更大;K 一般为 0 ∼ 2),在我们的模型中,我们在两个卷积块后接了两个全连接层。

在这里插入图片描述
在前面的部分,我们构造完成了卷积块和全连接层,而且都写成了 API 接口,因此在这一部分我们就可以直接调用它们,而不用再管它们的各种实现细节。并且在此基础上,我们还可以再进行一次抽象和封装,把卷积神经网络模型以 API 接口 CNNModel 的形式提供出去:

class CNNModel():
    '''
    msg: Convolutional Neural Network Model

    param {
        placeholders: dict, placeholder 占位组成的字典
        conv_filters: list, 表示模型使用的卷积核大小的二维列表,其第 1,2 维分别表示模型第 1, 2 层卷积核大小
        layer_latencies: list, 表示模型不同层的重要参数的三维列表,其第 1, 2 维分别表示模型第 1, 2 层卷积核个数,第 3 维表示模型第 3 层 dense 维度
    }
    '''
    def __init__(self, placeholders, conv_filters, layer_latencies):
        self.keep_prob_rate = placeholders["keep_prob_rate"]
        self.conv_filters = conv_filters
        self.layer_latencies = layer_latencies
        self.inputs = tf.reshape(placeholders["x"], [-1, 28, 28, 1])
        self.labels = placeholders["y"]
        self.build()

    def build(self):  # 建模
        self.conv1 = ConvolutionBlock(
            self.conv_filters[0], 1, self.layer_latencies[0], tf.nn.relu, 
            [1, 1, 1, 1], "SAME", [1, 2, 2, 1], [1, 2, 2, 1], "VALID"
            )
        self.conv2 = ConvolutionBlock(
            self.conv_filters[1], self.layer_latencies[0], self.layer_latencies[1], 
            tf.nn.relu, [1, 1, 1, 1], "SAME", [1, 2, 2, 1], [1, 2, 2, 1], "VALID", 
            True, [-1, 7 * 7 * self.layer_latencies[1]]
            )
        self.linear1 = LinearLayer(7 * 7 * self.layer_latencies[1], self.layer_latencies[2], tf.nn.relu, True, self.keep_prob_rate)
        self.linear2 = LinearLayer(self.layer_latencies[2], 10, tf.nn.softmax)

    def forward(self):  # 模型的前向传播
        predictions = self.conv1(self.inputs)
        predictions = self.conv2(predictions)
        predictions = self.linear1(predictions)
        predictions = self.linear2(predictions)
        return self.labels, predictions

5 构造优化器

在构造优化器时,我们需要对损失函数和学习率等问题进行考虑,在这里,我们使用交叉熵损失函数和指数衰减学习率帮助我们更好地优化模型。

5.1 实现交叉熵损失函数

对于手写体数字识别这种多分类问题,我们希望模型可以将不同的样本分到事先定义好的类别中。而通过神经网络解决多分类问题最常用的方法就是设置 t 个输出节点,其中 t 为类别的个数(在本问题中,t = 10)。对于每一个样本,神经网络可以得到的一个 t 维向量作为输出结果,向量中的每一个维度也就是每一个输出节点对应一个类别(即 One-hot 独热编码),在理想情况下,如果一个样本属于类别 k,那么这个类别所对应的输出节点的输出值应该为 1,而其他节点的输出都为 0。

神经网络模型的效果以及优化的目标是通过损失函数(loss function)来定义的,对于相同的神经网络,不同的损失函数会对训练得到的模型产生重要影响。在多分类问题中,为了描述一个输出向量和期望向量的接近程度,我们使用一种叫交叉熵(cross entropy)的损失函数,它能刻画两个概率分布之间的距离,是分类问题中使用比较广的一种损失函数。

我们先来了解一下概率分布是什么,概率分布刻画了不同事件发生的概率,当事件总数是有限的情况下,概率分布函数 p ( X = x ) p(X = x) p(X=x) 需满足:
∀ x     p ( X = x ) ∈ [ 0 , 1 ]    &    ∑ x p ( X = x ) = 1 \forall x ~~~ p(X=x) \in [0, 1] ~~\&~~ \sum_x p(X=x)=1 x   p(X=x)[0,1]  &  xp(X=x)=1

也就是说,任意事件发生的概率都在 0 和 1 之间,且总有某一个事件发生(概率的和为 1),如果将分类问题中“一个样本属于某一个类别”看成一个概率事件,那么训练数据的正确答案就符合一个概率分布,因为事件“一个样本属于不正确的类别”的概率为 0,而“一个样本属于正确的类别”的概率为 1。而交叉熵刻画的就是两个概率分布的距离,即交叉熵值越小,两个概率分布越接近。

我们现在来看一下交叉熵的公式,给定两个概率分布 p 和 q,通过 q 来表示 p 的交叉熵为:
H ( p , q ) = − ∑ x p ( x ) log ⁡ q ( x ) H(p,q) = -\sum_x p(x) \log q(x) H(p,q)=xp(x)logq(x)

由上式我们可以发现交叉熵函数不是对称的( H ( p , q ) H(p,q) H(p,q) 不等于 H ( q , p ) H(q,p) H(q,p)),它刻画的是通过概率分布 q 来表达概率分布 p 的困难程度,因为真实值是希望得到的结果,所以当交叉嫡作为神经网络的损失函数时,p 应该代表的是真实值,q 应该代表的是预测值。

因此我们可以对上式进行改写得到模型交叉熵损失函数的公式,假设我们一个 batch 共有 m 个样本,令 y ⃗ i = ( y i 1 , y i 2 , … , y i t ) \vec y_i =(y_{i1},y_{i2},\ldots,y_{it}) y i=(yi1,yi2,,yit) 表示第 i 个样本的真实值, p ⃗ i = ( p i 1 , p i 2 , … , p i t ) \vec p_i = (p_{i1},p_{i2},\ldots,p_{it}) p i=(pi1,pi2,,pit) 表示第 i 个样本的预测值:
L o s s = − 1 m ∑ i = 1 m y ⃗ i log ⁡ p ⃗ i = − 1 m ∑ i = 1 m ∑ j = 1 t y i j log ⁡ p i j Loss = - \frac1m \sum_{i=1}^m \vec y_i \log \vec p_i = - \frac1m \sum_{i=1}^m \sum_{j=1}^t y_{ij} \log p_{ij} Loss=m1i=1my ilogp i=m1i=1mj=1tyijlogpij

在 TensorFlow 中,我们可以通过调用一系列 API 对上述公式进行实现:

cross_entropy = -tf.reduce_mean(labels * tf.log(tf.clip_by_value(predictions, 1e-10, 1.0)))
  • labelspredictionslabels 代表 m 个样例的真实值,predictions 代表 m 个样例的最终神经网络输出的预测值,它们都是 m × t 的矩阵,其中 m 为一个 batch 中样例的数量,t 为分类的类别数量;

  • tf.log(tf.clip_by_value(predictions, 1e-10, 1.0))

    • tf.log(...) :通过该函数可以完成对张量中所有元素依次求对数的功能;
    • tf.clip_by_value(predictions, 1e-10, 1.0) :通过该函数可以将张量 predictions 中的的数值限制在一个范围 [1e-10, 1.0] 之内,小于 1e-10 的会被转换为 1e-10,大于 1.0 的会被转换为 1.0,这样可以避免一些运算错误(比如 log0 是无效的);
  • *:这里的 “*” 乘法运算并不是矩阵乘法,而是矩阵元素之间直接相乘,矩阵乘法需要使用 tf.matmul 函数来完成;

  • tf.reduce_mean(...):通过上面的运算,得到的结果是一个 m × t 的二维矩阵,根据交叉熵的公式,应该将每行中的 t 个结果相加得到所有样例的交叉嫡,然后再对这 m 行取平均得到一个 batch 的平均交叉熵,但因为分类问题的类别数量是不变的,所以我们可以直接使用 tf.reduce_mean 函数对整个矩阵做平均而并不改变计算结果的意义,这样的方式可以使整个程序更加简洁。

5.2 实现指数衰减学习率

在训练神经网络时,需要设置学习率(leaming rate)来控制参数更新的速度,学习率决定了参数每次更新的幅度,如果幅度过大,那么可能导致参数在极优值的两侧来回移动,如果幅度过小,虽然能保证收敛性,但是这会大大降低优化速度。

为了解决设定学习率的问题,TensorFlow 提供了一种更加灵活的学习率设置方法——指数衰减法,通过指数衰减学习率,既可以让模型在训练的前期快速接近较优解,又可以保证模型在训练后期不会有太大的波动,从而更加接近局部最优。

在 TensorFlow 中,tf.train.exponential_decay 函数实现了指数衰减学习率,通过这个函数,可以先使用较大的学习率来快速得到一个比较优的解,然后随着迭代的继续逐步减小学习率,使得模型在训练后期更加稳定。

关于该函数及指数衰减学习率在 TensorFlow 中的具体使用方法请参照:初步了解 TensorFlow 如何实现指数衰减学习率

5.3 构造优化器

TensorFlow 支持 7 种不同的优化器,可以根据具体的应用选择不同的优化算法,比较常用的优化方法有三种:tf.train.GradientDescentOptimizertf.train.AdamOptimizertf.train.MomentumOptimizer,这里我们选择使用 Adam 优化器对模型进行优化。然后我们将交叉熵作为我们的损失函数,同时使用指数衰减学习率来控制优化速度,实现代码如下:

class Optimizer():
    '''
    msg: Optimizer for CNNModel

    param {
        placeholders: dict, placeholder 占位组成的字典
        labels: tensor, 训练数据的真实标签
        predictions: tensor, 训练数据的预测值
        init_learning_rate: float, 模型优化的初始学习率
        decay_steps: int, 指数衰减学习率的衰减速度, 通常代表了完整的使用一遍训练数据所需要的迭代轮数, 即总训练样本数除以每一个 batch 中的训练样本数
        decay_rate: float, 指数衰减学习率的衰减系数
        decay_staircase: bool, 为 True 时选择阶梯状衰减学习率, 为 False 时, 选择连续衰减学习率
    }
    '''
    def __init__(self, placeholders, labels, predictions, init_learning_rate, decay_steps, decay_rate, decay_staircase):
        self.accuracy  = tf.reduce_mean(
            tf.cast(tf.equal(tf.argmax(labels, 1), tf.argmax(predictions, 1)), tf.float32)
            )  # 计算精确度
        
        self.loss = -tf.reduce_mean(labels * tf.log(tf.clip_by_value(predictions, 1e-10, 1.0)))  # 计算交叉熵损失

        learning_rate = tf.train.exponential_decay(
            learning_rate=init_learning_rate, global_step=placeholders["global_step"], 
            decay_steps=decay_steps, decay_rate=decay_rate, staircase=decay_staircase
            )  # 指数衰减学习率
        self.optimizer = tf.train.AdamOptimizer(learning_rate).minimize(self.loss)  # 使用 Adam 优化器对模型进行优化

6 模型的训练和评估

6.1 placeholder 占位机制

在训练过程中,如果每轮迭代中选取的数据都要通过常量来表示,那么 TensorFlow 的计算图将会非常大。因为每生成一个常量,TensorFlow 都会在计算图中增加一个节点。一般来说,一个神经网络的训练过程会需要经过几百万轮甚至几亿轮的迭代,这样计算图就会非常大,而且利用率很低。为了避免这个问题,TensorFlow 提供了 placeholder 机制用于提供输入数据。

placeholder 相当于定义了一个位置(占位),这个位置中的数据在程序运行时再指定。这样在程序中就不需要生成大量常量来提供输入数据,而只需要将数据通过 placeholder 传入 TensorFlow 计算图。在 placeholder 定义时,这个位置上的数据类型是需要指定的,并且和其他张量一样, placeholder 的类型也是不可以改变的。placeholder 中数据的维度信息可以根据提供的数据推导得出,所以不一定必须要给出。

通过 placeholder 可以替换原来用常量定义的输入,并且在模型训练或评估时,只需要提供一个 feed_dict 来指定输入的取值。feed_dict 是一个字典,在字典中需要给出每个用到的 placeholder 的取值。如果某个需要的 placeholder 没有被指定取值,那么程序在运行时将会报错。

在这里,我们定义一个 placeholders 字典来存储所有的 placeholder 占位:

placeholders = {
    "x": tf.placeholder(tf.float32, [None, 28 * 28]) / 255,
    "y": tf.placeholder(tf.float32, [None, 10]),
    "keep_prob_rate": tf.placeholder(tf.float32),
    "global_step": tf.placeholder(tf.int32)
}

6.2 batch 批量

在模型训练过程中,我们需要注意到梯度下降算法存在的一个问题,梯度下降算法计算时间太长了,因为它需要在全部训练数据上最小化损失,这样在每一轮迭代中都需要计算在全部训练数据上的损失函数。在海量训练数据下,要计算所有训练数据的损失函数是非常消耗时间的。

因此为了加速训练过程,我们可以使用随机梯度下降算法(stochastic gradient descent)。这个算法优化的不是在全部训练数据上的损失函数,而是在每一轮迭代中,随机优化某一条训练数据上的损失函数。这样每一轮参数更新的速度就大大加快了。但因为随机梯度下降算法每次优化的只是某一条数据上的损失函数,所以它的问题也非常明显:在某一条数据上损失函数更小并不代表在全部数据上损失函数更小,于是使用随机梯度下降优化得到的神经网络甚至可能无法达到局部最优。

所以为了综合梯度下降算法和随机梯度下降算法的优缺点,在实际应用中一般釆用这两个算法的折中:每次计算一小部分训练数据的损失函数,这一小部分数据被称之为一个 batch 批量。通过矩阵运算,每次在一个 batch 上优化神经网络的参数其实并不会比单个数据慢太多,另一方面,每次使用一个 batch 可以大大减小收敛所需要的迭代次数,同时可以使收敛到的结果更加接近梯度下降的效果。

在这里,我们可以使用 TensorFlow 的 API 接口 input_data 所提供的 mnist.train.next_batch 函数来完成上述过程,它可以从所有的训练数据中读取一小部分作为一个训练 batch,以下代码显示了如何使用这个功能:

batch_size = 100

# 从 train 的集合中选取 batch_size 个训练数据
xs, ys = mnist.train.next_batch(batch_size)

print("X shape:", xs.shape)                     
print("Y shape:", ys.shape)
'''输出结果为:
X shape: (100, 784)
Y shape: (100, 10)'''

6.3 模型的训练和评估

到上一步整个 CNN 卷积神经网络模型框架已经基本搭建完成,现在我们就可以在指定超参数组合上对模型进行训练和评估了,具体实现代码如下:

def trainEvaluate(sess, placeholders, keep_prob_rate, conv_filters, 
                layer_latencies, init_learning_rate, iteration_nums, 
                batch_size, train_data, evaluate_data, decay_rate, decay_staircase):
    '''
    msg: 模型的训练和评估

    param {
        sess: session, 会话
        placeholders: dict, placeholder 占位组成的字典
        keep_prob_rate: float, 表示 dropout 中存活的神经元比例
        conv_filters: list, 表示模型使用的卷积核大小的二维列表, 其第 1, 2 维分别表示模型第 1, 2 层卷积核大小
        layer_latencies: list, 表示模型不同层的重要参数的三维列表, 其第 1, 2 维分别表示模型第 1, 2 层卷积核个数, 第 3 维表示模型第 3 层 dense 维度
        init_learning_rate: float, 模型优化的初始学习率
        iteration_nums: int, 模型训练的迭代次数
        batch_size: int, 每一批 batch 数据的大小
        train_data: 训练数据
        evaluate_data: 用来评估模型性能的数据
        decay_rate: float, 指数衰减学习率的衰减系数
        decay_staircase: bool, 为 True 时选择阶梯状衰减学习率, 为 False 时, 选择连续衰减学习率
    }

    return {
        accuracy: float, 模型在评估数据集上的精确度
    }
    '''
    model = CNNModel(placeholders, conv_filters, layer_latencies)  # 建模
    labels, predictions = model.forward()  # 模型的前向传播
    opt = Optimizer(
        placeholders, labels, predictions, init_learning_rate, 
        train_data.images.shape[0] / batch_size, decay_rate, decay_staircase
        )  # 优化模型

    sess.run(tf.global_variables_initializer())  # 全局初始化所有模型参数
    
    bar = trange(iteration_nums)  # 使用 tqdm 第三方库, 调用 trange 方法给循环加个进度条
    feed_dict = {
        placeholders["keep_prob_rate"]: keep_prob_rate
    }
    for i in bar:
        batch_xs, batch_ys = train_data.next_batch(batch_size)  # 从 train 的集合中选取 batch_size 个训练数据
        feed_dict[placeholders["x"]] = batch_xs
        feed_dict[placeholders["y"]] = batch_ys
        feed_dict[placeholders["global_step"]] = i
        accuracy, loss, _ = sess.run([opt.accuracy, opt.loss, opt.optimizer], feed_dict=feed_dict)  # 对模型进行训练
        bar.set_description(f'Train accuracy = {accuracy: <10.8}, loss = {loss: <12.8}')  # 给进度条加个描述
    bar.close()

    feed_dict[placeholders["keep_prob_rate"]] = 1  # 评估模型时需要使 dropout 失效
    feed_dict[placeholders["x"]] = evaluate_data.images
    feed_dict[placeholders["y"]] = evaluate_data.labels
    accuracy = sess.run(opt.accuracy, feed_dict=feed_dict)  # 对模型进行评估

    return accuracy

在这一部分,有一个细节我们一定需要注意,就是我们在训练时进行了 dropout 操作来防止模型过拟合,但是我们在评估模型时不应该进行 dropout 操作,应该使其失效,所以在模型评估时 feed 的 keep_prob_rate 值应该设为 1。

7 网格搜索最优超参数

我们在 1 数据集的下载与处理 部分划分了训练集、验证集和测试集,因此这里我们可以借助验证集进行超参数寻优,找出最好的超参数组合,然后在后续过程中我们就可以使用这组最优超参数训练模型,并在测试集上进行测试。

网格搜索(Grid Search)是一种通过尝试所有超参数的组合来寻找最优的一组超参数的方法。假设总共有 K 个超参数,第 k 个超参数的可以取离散的 m k m_k mk 个值(如果超参数是连续的,可以将超参数离散化,选择几个经验值),那么总共的配置组合数量就有 m 1 × m 2 × ⋯ × m K m_1 \times m_2 \times \cdots \times m_K m1×m2××mK。网格搜索可以根据这些超参数的不同组合分别训练一个模型,然后评估这些模型在验证集上的性能,选取一组性能最好的超参数组合。

def hyperparameterGridSearch(sess, mnist, placeholders, keep_prob_rate_list, 
                            conv_filter_size_list, conv_filter_num_list, dense_dim_list, 
                            init_learning_rate, search_iteration_nums, batch_size, decay_rate, decay_staircase):
    '''
    msg: 网格搜索最优超参数

    param {
        sess: session, 会话
        mnist: datasets, MNIST 数据集
        placeholders: dict, placeholder 占位组成的字典
        keep_prob_rate_list: list, 由 dropout 中存活的神经元比例可能的取值组成的列表
        conv_filter_size_list: list, 由卷积核大小可能的取值组成的列表
        conv_filter_num_list: list, 由卷积核个数可能的取值组成的列表
        dense_dim_list: list, 由 dense 全连接层维度可能的取值组成的列表
        init_learning_rate: float, 模型优化的初始学习率
        search_iteration_nums: int, 模型在网格搜索时训练的迭代次数
        batch_size: int, 每一批 batch 数据的大小
        decay_rate: float, 指数衰减学习率的衰减系数
        decay_staircase: bool, 为 True 时选择阶梯状衰减学习率, 为 False 时, 选择连续衰减学习率
    }

    return {
        best_keep_prob_rate: float, 表示 dropout 中存活的神经元比例的最优值
        best_conv_filters: list, 表示存储模型使用的卷积核大小的二维列表的最优值, 其第 1, 2 维分别表示模型第 1, 2 层卷积核大小
        best_layer_latencies: list, 表示存储模型不同层的重要参数的三维列表的最优值, 其第 1, 2 维分别表示模型第 1, 2 层卷积核个数, 第 3 维表示模型第 3 层 dense 维度
    }
    '''
    len1 = len(keep_prob_rate_list)
    len2 = len(conv_filter_size_list)
    len3 = len(conv_filter_num_list)
    len4 = len(dense_dim_list)
    result_list = np.zeros(shape=[len1, len2, len2, len3, len3, len4])  # 不同超参数组合对应的评估结果矩阵

    print("\nStart searching the best hyperparameter...\n")
    for hyperparameter1 in range(len1):
        for hyperparameter2 in range(len2):
            for hyperparameter3 in range(len2):
                for hyperparameter4 in range(len3):
                    for hyperparameter5 in range(len3):
                        for hyperparameter6 in range(len4):

                            keep_prob_rate = keep_prob_rate_list[hyperparameter1]
                            conv_filters = [conv_filter_size_list[hyperparameter2], conv_filter_size_list[hyperparameter3]]
                            layer_latencies = [
                                conv_filter_num_list[hyperparameter4], 
                                conv_filter_num_list[hyperparameter5], 
                                dense_dim_list[hyperparameter6]
                                ]

                            print(f"Start training on the hyperparameters: keep_prob_rate = {keep_prob_rate}, conv_filters = {conv_filters}, layer_latencies = {layer_latencies}")
                            validation_accuracy = trainEvaluate(
                                sess, placeholders, keep_prob_rate, conv_filters, layer_latencies, init_learning_rate, 
                                search_iteration_nums, batch_size, mnist.train, mnist.validation, decay_rate, decay_staircase
                                )  # 训练和评估模型在该组超参数上的性能
                            print(f"Validation accuracy = {validation_accuracy: <10.8}\n")

                            result_list[hyperparameter1][hyperparameter2][hyperparameter3][hyperparameter4][hyperparameter5][hyperparameter6] = validation_accuracy  # 记录该超参数组合的评估结果

    best_hyperparameters = np.unravel_index(np.argmax(result_list), result_list.shape)  # 选出评估结果最好的超参数组合
    print("Search finished!\n")

    best_keep_prob_rate = keep_prob_rate_list[best_hyperparameters[0]]
    best_conv_filters = [conv_filter_size_list[best_hyperparameters[1]], conv_filter_size_list[best_hyperparameters[2]]]
    best_layer_latencies = [
        conv_filter_num_list[best_hyperparameters[3]], 
        conv_filter_num_list[best_hyperparameters[4]], 
        dense_dim_list[best_hyperparameters[5]]
        ]

    return best_keep_prob_rate, best_conv_filters, best_layer_latencies

8 TensorFlow 使用小技巧

8.1 指定 GPU

在进行实验时,我们希望程序时跑在指定的 GPU 上,这时候我们可以进行如下类似设置:

import os
os.environ["CUDA_VISIBLE_DEVICES"] = "0"  # 仅 GPU 0 对程序可见, 程序只能跑在 GPU 0 上
os.environ["CUDA_VISIBLE_DEVICES"] = "1"  # 仅 GPU 1 对程序可见, 程序只能跑在 GPU 1 上
os.environ["CUDA_VISIBLE_DEVICES"] = "0,1"  # GPU 0 和 GPU 1 都对程序可见, 程序可以同时使用两块 GPU

8.2 按需动态分配 GPU 资源

在实验过程中,我们发现 TensorFlow 会预分配 GPU 内存,即优先占满当前 GPU 的所有内存,这时候我们使用 nvidia-smi 命令显示当前占用的 GPU 内存资源,会看到往往是占满的。因此我们在使用 GPU 资源进行训练的时候,就可能会发生资源耗尽的情况,那么在在这种情况,我们就需要对 GPU 的资源进行合理的安排,也就是说我们不想要 TensorFlow 预分配内存资源,而是需要多少内存就动态分配多少内存,这时候我们需要进行如下设置:

session_conf = tf.ConfigProto()
session_conf.gpu_options.allow_growth = True  # 不全部占满显存, 按需动态分配 GPU 资源
sess = tf.Session(graph=tf.get_default_graph(), config=session_conf)  # 设置 session 会话

8.3 屏蔽 TensorFlow 日志信息

我们在使用 TensorFlow 跑实验时,可能会出现一些 Warning 等等的日志信息,这是因为 TensorFlow 的默认日志等级为 DEBUG。因此为了不让大串大串的日志信息打印在屏幕上而淹没了我们自己的输出信息,我们可以使用如下方法将日志等级分别设置为 DEBUG、INFO、WARNING、ERROR:

import tensorflow as tf
tf.logging.set_verbosity(tf.logging.DEBUG)  # 设置日志等级为 DEBUG 即最低日志等级,这时候会打印所有日志信息
tf.logging.set_verbosity(tf.logging.INFO)  # 设置日志等级为 INFO, 可以屏蔽 INFO 日志信息
tf.logging.set_verbosity(tf.logging.WARNING)  # 设置日志等级为 WARNING, 可以屏蔽 INFO 和 WARNING 日志信息
tf.logging.set_verbosity(tf.logging.ERROR)  # 设置日志等级为 ERROR, 可以屏蔽所有日志信息

9 组合模块形成完整代码

最后,将前面的所有模块进行组合,并添加 main 函数,得到完整代码:

'''
Description: TensorFlow 初步实现 CNN 卷积神经网络
Author: stepondust
Date: 2020-12-16
'''
import os
import numpy as np
import tensorflow as tf
from tqdm._tqdm import trange
from tensorflow.examples.tutorials.mnist import input_data


os.environ["CUDA_VISIBLE_DEVICES"] = "0,1"  # GPU 0 和 GPU 1 都对程序可见, 程序可以同时使用两块 GPU
tf.logging.set_verbosity(tf.logging.ERROR)  # 设置日志等级为 ERROR, 可以屏蔽所有日志信息


class ConvolutionBlock():
    '''
    msg: Convolution layer + Attention + Pool

    param {
        filter_size: int, 卷积核大小
        in_channels: int, 输入通道数, 即输入层的深度
        output_channels: int, 输出通道数, 即卷积核个数
        attention: function, 激活函数
        conv_strides: list, 记录卷积核滑动步长的 4 维列表, 其第 1, 4 维是固定的, 第 2, 3 维分别表示卷积核向右或向下移动的步长
        conv_padding: str, 表示使用何种填充算法, "SAME" 表示全 0 填充, 卷积后图片大小保持不变, "VALID" 表示不填充
        pool_ksize: list, 记录池化窗口大小的 4 维列表, 其第 1, 4 维是固定的, 第 2, 3 维分别表示池化窗口的高度和宽度
        pool_strides: list, 记录池化窗口滑动步长的 4 维列表, 其第 1, 4 维是固定的, 第 2, 3 维分别表示池化窗口向右或向下移动的步长
        pool_padding: str, 表示使用何种填充算法, "SAME" 表示全 0 填充, 池化后图片大小保持不变, "VALID" 表示不填充
        reshape_flag: bool, 表示是否对结果进行 reshape 操作
        new_shape: list, 表示要 reshape 的维度
    }
    '''
    def __init__(self, filter_size, in_channels, output_channels, attention, 
                conv_strides, conv_padding, pool_ksize, pool_strides, 
                pool_padding, reshape_flag=False, new_shape=None):
        self.W = tf.Variable(
            tf.truncated_normal(shape=[filter_size, filter_size, in_channels, output_channels], stddev=0.1)
            )  # 使用截断正态分布初始换权重矩阵
        self.b = tf.Variable(tf.constant(0.1, shape=[output_channels]))  # 使用常数初始化偏置矩阵
        self.attention = attention
        self.conv_strides = conv_strides
        self.conv_padding = conv_padding
        self.pool_ksize = pool_ksize
        self.pool_strides = pool_strides
        self.pool_padding = pool_padding
        self.reshape_flag = reshape_flag
        self.new_shape = new_shape

    def __call__(self, X):
        output = tf.nn.conv2d(input=X, filter=self.W, strides=self.conv_strides, padding=self.conv_padding)  # 卷积操作
        output = tf.nn.bias_add(output, self.b)  # 加上偏置项
        output = self.attention(output)  # 使用激活函数
        output = tf.nn.max_pool(value=output, ksize=self.pool_ksize, strides=self.pool_strides, padding=self.pool_padding)  # 池化操作
        if self.reshape_flag:
            output = tf.reshape(output, self.new_shape)
        return output


class LinearLayer():
    '''
    msg: Full connection layer + Attention + Dropout

    param {
        input_dims: int, 输入层维度
        output_dims: int, 输出层维度
        attention: function, 激活函数
        dropout_flag: bool, 表示是否进行 dropout 操作
        keep_prob_rate: float, 表示 dropout 中存活的神经元比例
    }
    '''
    def __init__(self, input_dims, output_dims, attention, dropout_flag=False, keep_prob_rate=None):
        self.W = tf.Variable(tf.truncated_normal(shape=[input_dims, output_dims], stddev=0.1))  # 使用截断正态分布初始换权重矩阵
        self.b = tf.Variable(tf.constant(0.1, shape=[output_dims]))  # 使用常数初始化偏置矩阵
        self.attention = attention
        self.dropout_flag = dropout_flag
        self.keep_prob_rate = keep_prob_rate

    def __call__(self, x):
        output = tf.matmul(x, self.W)  # 矩阵乘法
        output = tf.nn.bias_add(output, self.b)  # 加上偏置项
        output = self.attention(output)  # 使用激活函数
        if self.dropout_flag:
            output = tf.nn.dropout(output, self.keep_prob_rate)  # 使用 dropout 使一部分神经元随机失活, 用来防止过拟合
        return output


class CNNModel():
    '''
    msg: Convolutional Neural Network Model

    param {
        placeholders: dict, placeholder 占位组成的字典
        conv_filters: list, 表示模型使用的卷积核大小的二维列表, 其第 1, 2 维分别表示模型第 1, 2 层卷积核大小
        layer_latencies: list, 表示模型不同层的重要参数的三维列表, 其第 1, 2 维分别表示模型第 1, 2 层卷积核个数, 第 3 维表示模型第 3 层 dense 维度
    }
    '''
    def __init__(self, placeholders, conv_filters, layer_latencies):
        self.keep_prob_rate = placeholders["keep_prob_rate"]
        self.conv_filters = conv_filters
        self.layer_latencies = layer_latencies
        self.inputs = tf.reshape(placeholders["x"], [-1, 28, 28, 1])
        self.labels = placeholders["y"]
        self.build()

    def build(self):  # 建模
        self.conv1 = ConvolutionBlock(
            self.conv_filters[0], 1, self.layer_latencies[0], tf.nn.relu, 
            [1, 1, 1, 1], "SAME", [1, 2, 2, 1], [1, 2, 2, 1], "VALID"
            )
        self.conv2 = ConvolutionBlock(
            self.conv_filters[1], self.layer_latencies[0], self.layer_latencies[1], 
            tf.nn.relu, [1, 1, 1, 1], "SAME", [1, 2, 2, 1], [1, 2, 2, 1], "VALID", 
            True, [-1, 7 * 7 * self.layer_latencies[1]]
            )
        self.linear1 = LinearLayer(7 * 7 * self.layer_latencies[1], self.layer_latencies[2], tf.nn.relu, True, self.keep_prob_rate)
        self.linear2 = LinearLayer(self.layer_latencies[2], 10, tf.nn.softmax)

    def forward(self):  # 模型的前向传播
        predictions = self.conv1(self.inputs)
        predictions = self.conv2(predictions)
        predictions = self.linear1(predictions)
        predictions = self.linear2(predictions)
        return self.labels, predictions


class Optimizer():
    '''
    msg: Optimizer for CNNModel

    param {
        placeholders: dict, placeholder 占位组成的字典
        labels: tensor, 训练数据的真实标签
        predictions: tensor, 训练数据的预测值
        init_learning_rate: float, 模型优化的初始学习率
        decay_steps: int, 指数衰减学习率的衰减速度, 通常代表了完整的使用一遍训练数据所需要的迭代轮数, 即总训练样本数除以每一个 batch 中的训练样本数
        decay_rate: float, 指数衰减学习率的衰减系数
        decay_staircase: bool, 为 True 时选择阶梯状衰减学习率, 为 False 时, 选择连续衰减学习率
    }
    '''
    def __init__(self, placeholders, labels, predictions, init_learning_rate, decay_steps, decay_rate, decay_staircase):
        self.accuracy  = tf.reduce_mean(
            tf.cast(tf.equal(tf.argmax(labels, 1), tf.argmax(predictions, 1)), tf.float32)
            )  # 计算精确度
        
        self.loss = -tf.reduce_mean(labels * tf.log(tf.clip_by_value(predictions, 1e-10, 1.0)))  # 计算交叉熵损失

        learning_rate = tf.train.exponential_decay(
            learning_rate=init_learning_rate, global_step=placeholders["global_step"], 
            decay_steps=decay_steps, decay_rate=decay_rate, staircase=decay_staircase
            )  # 指数衰减学习率
        self.optimizer = tf.train.AdamOptimizer(learning_rate).minimize(self.loss)  # 使用 Adam 优化器对模型进行优化


def trainEvaluate(sess, placeholders, keep_prob_rate, conv_filters, 
                layer_latencies, init_learning_rate, iteration_nums, 
                batch_size, train_data, evaluate_data, decay_rate, decay_staircase):
    '''
    msg: 模型的训练和评估

    param {
        sess: session, 会话
        placeholders: dict, placeholder 占位组成的字典
        keep_prob_rate: float, 表示 dropout 中存活的神经元比例
        conv_filters: list, 表示模型使用的卷积核大小的二维列表, 其第 1, 2 维分别表示模型第 1, 2 层卷积核大小
        layer_latencies: list, 表示模型不同层的重要参数的三维列表, 其第 1, 2 维分别表示模型第 1, 2 层卷积核个数, 第 3 维表示模型第 3 层 dense 维度
        init_learning_rate: float, 模型优化的初始学习率
        iteration_nums: int, 模型训练的迭代次数
        batch_size: int, 每一批 batch 数据的大小
        train_data: 训练数据
        evaluate_data: 用来评估模型性能的数据
        decay_rate: float, 指数衰减学习率的衰减系数
        decay_staircase: bool, 为 True 时选择阶梯状衰减学习率, 为 False 时, 选择连续衰减学习率
    }

    return {
        accuracy: float, 模型在评估数据集上的精确度
    }
    '''
    model = CNNModel(placeholders, conv_filters, layer_latencies)  # 建模
    labels, predictions = model.forward()  # 模型的前向传播
    opt = Optimizer(
        placeholders, labels, predictions, init_learning_rate, 
        train_data.images.shape[0] / batch_size, decay_rate, decay_staircase
        )  # 优化模型

    sess.run(tf.global_variables_initializer())  # 全局初始化所有模型参数
    
    bar = trange(iteration_nums)  # 使用 tqdm 第三方库, 调用 trange 方法给循环加个进度条
    feed_dict = {
        placeholders["keep_prob_rate"]: keep_prob_rate
    }
    for i in bar:
        batch_xs, batch_ys = train_data.next_batch(batch_size)  # 从 train 的集合中选取 batch_size 个训练数据
        feed_dict[placeholders["x"]] = batch_xs
        feed_dict[placeholders["y"]] = batch_ys
        feed_dict[placeholders["global_step"]] = i
        accuracy, loss, _ = sess.run([opt.accuracy, opt.loss, opt.optimizer], feed_dict=feed_dict)  # 对模型进行训练
        bar.set_description(f'Train accuracy = {accuracy: <10.8}, loss = {loss: <12.8}')  # 给进度条加个描述
    bar.close()

    feed_dict[placeholders["keep_prob_rate"]] = 1  # 评估模型时需要使 dropout 失效
    feed_dict[placeholders["x"]] = evaluate_data.images
    feed_dict[placeholders["y"]] = evaluate_data.labels
    accuracy = sess.run(opt.accuracy, feed_dict=feed_dict)  # 对模型进行评估

    return accuracy


def hyperparameterGridSearch(sess, mnist, placeholders, keep_prob_rate_list, 
                            conv_filter_size_list, conv_filter_num_list, dense_dim_list, 
                            init_learning_rate, search_iteration_nums, batch_size, decay_rate, decay_staircase):
    '''
    msg: 网格搜索最优超参数

    param {
        sess: session, 会话
        mnist: datasets, MNIST 数据集
        placeholders: dict, placeholder 占位组成的字典
        keep_prob_rate_list: list, 由 dropout 中存活的神经元比例可能的取值组成的列表
        conv_filter_size_list: list, 由卷积核大小可能的取值组成的列表
        conv_filter_num_list: list, 由卷积核个数可能的取值组成的列表
        dense_dim_list: list, 由 dense 全连接层维度可能的取值组成的列表
        init_learning_rate: float, 模型优化的初始学习率
        search_iteration_nums: int, 模型在网格搜索时训练的迭代次数
        batch_size: int, 每一批 batch 数据的大小
        decay_rate: float, 指数衰减学习率的衰减系数
        decay_staircase: bool, 为 True 时选择阶梯状衰减学习率, 为 False 时, 选择连续衰减学习率
    }

    return {
        best_keep_prob_rate: float, 表示 dropout 中存活的神经元比例的最优值
        best_conv_filters: list, 表示存储模型使用的卷积核大小的二维列表的最优值, 其第 1, 2 维分别表示模型第 1, 2 层卷积核大小
        best_layer_latencies: list, 表示存储模型不同层的重要参数的三维列表的最优值, 其第 1, 2 维分别表示模型第 1, 2 层卷积核个数, 第 3 维表示模型第 3 层 dense 维度
    }
    '''
    len1 = len(keep_prob_rate_list)
    len2 = len(conv_filter_size_list)
    len3 = len(conv_filter_num_list)
    len4 = len(dense_dim_list)
    result_list = np.zeros(shape=[len1, len2, len2, len3, len3, len4])  # 不同超参数组合对应的评估结果矩阵

    print("\nStart searching the best hyperparameter...\n")
    for hyperparameter1 in range(len1):
        for hyperparameter2 in range(len2):
            for hyperparameter3 in range(len2):
                for hyperparameter4 in range(len3):
                    for hyperparameter5 in range(len3):
                        for hyperparameter6 in range(len4):

                            keep_prob_rate = keep_prob_rate_list[hyperparameter1]
                            conv_filters = [conv_filter_size_list[hyperparameter2], conv_filter_size_list[hyperparameter3]]
                            layer_latencies = [
                                conv_filter_num_list[hyperparameter4], 
                                conv_filter_num_list[hyperparameter5], 
                                dense_dim_list[hyperparameter6]
                                ]

                            print(f"Start training on the hyperparameters: keep_prob_rate = {keep_prob_rate}, conv_filters = {conv_filters}, layer_latencies = {layer_latencies}")
                            validation_accuracy = trainEvaluate(
                                sess, placeholders, keep_prob_rate, conv_filters, layer_latencies, init_learning_rate, 
                                search_iteration_nums, batch_size, mnist.train, mnist.validation, decay_rate, decay_staircase
                                )  # 训练和评估模型在该组超参数上的性能
                            print(f"Validation accuracy = {validation_accuracy: <10.8}\n")

                            result_list[hyperparameter1][hyperparameter2][hyperparameter3][hyperparameter4][hyperparameter5][hyperparameter6] = validation_accuracy  # 记录该超参数组合的评估结果

    best_hyperparameters = np.unravel_index(np.argmax(result_list), result_list.shape)  # 选出评估结果最好的超参数组合
    print("Search finished!\n")

    best_keep_prob_rate = keep_prob_rate_list[best_hyperparameters[0]]
    best_conv_filters = [conv_filter_size_list[best_hyperparameters[1]], conv_filter_size_list[best_hyperparameters[2]]]
    best_layer_latencies = [
        conv_filter_num_list[best_hyperparameters[3]], 
        conv_filter_num_list[best_hyperparameters[4]], 
        dense_dim_list[best_hyperparameters[5]]
        ]

    return best_keep_prob_rate, best_conv_filters, best_layer_latencies


def main():
    mnist = input_data.read_data_sets("dataset/", one_hot=True)  # 载入 MNIST 数据集

    placeholders = {
        "x": tf.placeholder(tf.float32, [None, 28 * 28]) / 255,
        "y": tf.placeholder(tf.float32, [None, 10]),
        "keep_prob_rate": tf.placeholder(tf.float32),
        "global_step": tf.placeholder(tf.int32)
    }  # 定义输入数据的占位

    session_conf = tf.ConfigProto()
    session_conf.gpu_options.allow_growth = True  # 不全部占满显存, 按需动态分配 GPU 资源
    sess = tf.Session(graph=tf.get_default_graph(), config=session_conf)  # 设置 session 会话

    keep_prob_rate_list = [0.7, 0.9]
    conv_filter_size_list = [5, 7]
    conv_filter_num_list = [32, 64]
    dense_dim_list = [512, 1024]
    init_learning_rate = 1e-4
    search_iteration_nums = 500
    train_iteration_nums = 2000
    batch_size = 128
    decay_rate = 0.96
    decay_staircase = True

    best_keep_prob_rate, best_conv_filters, best_layer_latencies = hyperparameterGridSearch(
        sess, mnist, placeholders, keep_prob_rate_list, conv_filter_size_list, 
        conv_filter_num_list, dense_dim_list, init_learning_rate, 
        search_iteration_nums, batch_size, decay_rate, decay_staircase
        )  # 在验证集上使用网格搜索进行超参数寻优
    
    print(f"Start training on the best hyperparameters: keep_prob_rate = {best_keep_prob_rate}, conv_filters = {best_conv_filters}, layer_latencies = {best_layer_latencies}")
    test_accuracy = trainEvaluate(
        sess, placeholders, best_keep_prob_rate, best_conv_filters, best_layer_latencies, init_learning_rate, 
        train_iteration_nums, batch_size, mnist.train, mnist.test, decay_rate, decay_staircase
        )  # 在测试集上对用最优超参数组合训练出来的模型进行测试
    print(f'Evaluate model on the best hyperparameters, test accuracy = {test_accuracy: <10.8}\n')

    sess.close()


if __name__ == "__main__":
    main()

好了本文到此就快结束了,以上就是我使用 TensorFlow 初步实现 CNN 卷积神经网络的想法和思路,大家参照一下即可,重要的还是经过自己的思考来编写代码,文章中还有很多不足和不正确的地方,欢迎大家指正(也请大家体谅,写一篇博客真的挺累的,花的时间比我写出代码的时间还要长),我会尽快修改,之后我也会尽量完善本文,尽量写得通俗易懂。

本文参考资料:

  • 《Tensorflow 实战 Google 深度学习框架》
  • 《神经网络与深度学习》(邱锡鹏著)

博文创作不易,转载请注明本文地址:https://blog.csdn.net/qq_44009891/article/details/111185000

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值