Python · CNN(一)· 层结构

485 阅读19分钟
原文链接: zhuanlan.zhihu.com

(这里是最终成品的 GitHub 地址)

(这里是本章用到的 GitHub 地址)

========== 写在前面的话 ==========

其实在 4 个月之前我写过一篇叫“Python · 神经网络(八)· ConvLayer”的文章,不过现在看回去觉得写的有点太概括性了;如果直接往下写的话,估计观众老爷们(以及我自己)的逻辑都理不顺 _(:з」∠)_

所以我打算重写一次,而且这次会对之前 NN 系列的文章做一个汇总性说明;换句话说,我会从头开始讲如何实现 CNN 而不是接着 NN 的逻辑来讲(这也是为什么我没有接着用“神经网络”这个系列名而是开了个新的“CNN”系列) _(:з」∠)_

这意味着本文(及接下来的 CNN 系列)会巨长无比,毕竟我会试图把两三百行的东西一次性讲清楚 _(:з」∠)_

如果觉得这些都无所谓并愿意看的话,我会觉得很开心的 _(:з」∠)_

一些数学基础:

========== 分割线的说 ==========

往简单里说、CNN 只是多了卷积层、池化层和 FC 的 NN 而已,虽然卷积、池化对应的前向传导算法和反向传播算法的高效实现都很不平凡,但得益于 Tensorflow 的强大、我们可以在仅仅知道它们思想的前提下进行相应的实现,因为 Tensorflow 能够帮我们处理所有数学与技术上的细节(Tensorflow 的应用式入门教程可以参见这里

实现普通层

我们在Python · 神经网络(一)· 层Python · 神经网络(二)· 层里面非常琐碎地说明了如何实现 Layer 结构,这里我们就详尽地把整个实现捋一捋。鉴于 Tensorflow 能够自动获取梯度、同时考虑到要扩展出 CNN 的功能,我们需要实现如下功能:

  • 对于激活函数,只用定义其原始形式、不必定义其导函数形式
  • 解决特殊层结构(DropoutNormalize 等等)的实现问题
  • 要考虑当前层为 FC(全连接层)时的表现
  • 让用户可以选择是否给 Layer 加偏置量

其中的第四点可能有些让人不明所以:要知道偏置量可是对破坏对称性是很重要的,为什么要让用户选择是否使用偏置量呢?这主要是因为特殊层结构中 Normalize 的特殊性会使偏置量显得冗余。具体细节会在后文讨论特殊层结构处进行说明,这里就暂时按下不表

以下是 Layer 结构基类的具体代码:

import numpy as np
import tensorflow as tf
from math import ceil

class Layer:
    """
        初始化结构
        self.shape:记录该Layer和上个Layer所含神经元的个数,具体而言:
            self.shape[0] = 上个Layer所含神经元的个数
            self.shape[1] = 该Layer所含神经元的个数
        self.is_fc、self.is_sub_layer:记录该Layer是否为FC、特殊层结构的属性
        self.apply_bias:记录是否对该Layer加偏置量的属性
    """
    def __init__(self, shape, **kwargs):
        self.shape = shape
        self.is_fc = self.is_sub_layer = False
        self.apply_bias = kwargs.get("apply_bias", True)

    def __str__(self):
        return self.__class__.__name__

    def __repr__(self):
        return str(self)

    @property
    def name(self):
        return str(self)

    @property
    def root(self):
        return self

    # 定义兼容特殊层结构和CNN的、前向传导算法的封装
    def activate(self, x, w, bias=None, predict=False):
        # 如果当前层是FC、就需要先将输入“铺平”
        if self.is_fc:
            x = tf.reshape(x, [-1, int(np.prod(x.get_shape()[1:]))])
        # 如果是特殊的层结构、就调用相应的方法获得结果
        if self.is_sub_layer:
            return self._activate(x, predict)
        # 如果不加偏置量的话、就只进行矩阵相乘和激活函数的作用
        if not self.apply_bias:
            return self._activate(tf.matmul(x, w), predict)
        # 否则就进行“最正常的”前向传导算法
        return self._activate(tf.matmul(x, w) + bias, predict)

    # 前向传导算法的核心、留待子类定义
    def _activate(self, x, predict):
        pass

注意到我们前向传导算法中有一项“predict”参数,这主要是因为特殊层结构的训练过程和预测过程表现通常都会不一样、所以要加一个标注。该标注的具体意义会在后文进行特殊层结构 SubLayer 的相关说明时体现出来、这里暂时按下不表

在实现好基类后、就可以实现具体要用在神经网络中的 Layer 了。以 Sigmoid 激活函数对应的 Layer 为例:

class Sigmoid(Layer):
    def _activate(self, x, predict):
        return tf.nn.sigmoid(x)

得益于 Tensorflow 框架的强大(你除了这句话就没别的话说了吗……)、我们甚至连激活函数的形式都无需手写,因为它已经帮我们封装好了(事实上、绝大多数常用的激活函数在 Tensorflow 里面都有封装)

实现特殊层

我们在Python · 神经网络(三*)· 网络这里曾经简要介绍过特殊层 SubLayer 的思想,这里我们将介绍如何利用 Tensorflow 框架实现它,同时也会对十分常用的两种 SubLayer —— Dropout 和 Normalize 做深入一些简要的介绍

先来看看应该如何定义 SubLayer 的基类:
# 让SubLayer继承Layer以合理复用代码
class SubLayer(Layer):
    """
        初始化结构
        self.shape:和Layer相应属性意义一致
        self.parent:记录该Layer的父层的属性
        self.description:用于可视化的属性,记录着对该SubLayer的“描述”
    """
    def __init__(self, parent, shape):
        Layer.__init__(self, shape)
        self.parent = parent
        self.description = ""

    # 辅助获取Root Layer的property
    @property
    def root(self):
        _root = self.parent
        while _root.parent:
            _root = _root.parent
        return _root

可以看到,得益于 Tensorflow 框架(Tensorflow 就是很厉害嘛……),本来难以处理的SubLayer 的实现变得非常简洁清晰。在实现好基类后、就可以实现具体要用在神经网络中的 SubLayer 了,先来看 Dropout:

class Dropout(SubLayer):
    # self._prob:训练过程中每个神经元被“留下”的概率
    def __init__(self, parent, shape, drop_prob=0.5):
        # 神经元被Drop的概率必须大于等于0和小于1
        if drop_prob < 0 or drop_prob >= 1:
            raise ValueError(
                "(Dropout) Probability of Dropout should be a positive float smaller than 1")
        SubLayer.__init__(self, parent, shape)
        # 被“留下”的概率自然是1-被Drop的概率
        self._prob = tf.constant(1 - drop_prob, dtype=tf.float32)
        self.description = "(Drop prob: {})".format(drop_prob)

    def _activate(self, x, predict):
        # 如果是在训练过程,那么就按照设定的、被“留下”的概率进行Dropout
        if not predict:
            return tf.nn.dropout(x, self._prob)
        # 如果是在预测过程,那么直接返回输入值即可
        return x

Dropout 的详细说明自然是看原 paper 最好,这里我就大概翻译、总结一下主要内容。Dropout 的核心思想在于提高模型的泛化能力:它会在每次迭代中依概率去掉对应 Layer 的某些神经元,从而每次迭代中训练的都是一个小的神经网络。这个过程可以通过下图进行说明:

&amp;amp;amp;amp;lt;img src="https://pic1.zhimg.com/v2-f563063fd245116c4fe5451e72018ae4_b.png" data-rawwidth="551" data-rawheight="233" class="origin_image zh-lightbox-thumb" width="551" data-original="https://pic1.zhimg.com/v2-f563063fd245116c4fe5451e72018ae4_r.png"&amp;amp;amp;amp;gt;

上图所示的即为当 drop_prob 为 50%(我们所设的默认值)时、Dropout 的一种可能的表现。左图所示为原网络、右图所示的为 Dropout 后的网络,可以看到神经元 a、b、e、g、j 都被 Drop 了

Dropout 过程的合理性需要概率论上一些理论的支撑,不过鉴于 Tensorflow 框架有封装好的相应函数、我们就不深入介绍其具体的数学原理而仅仅说明其直观(以 drop_prob 为 50%为例,其余 drop_prob 的情况是同理的):

  • 在训练过程中,由于 Dropout 后留下来的神经元可以理解为“在 50%死亡概率下幸存”的神经元,所以给将它们对应的输出进行“增幅”是合理的。具体而言,假设一个神经元n_i的输出本来是o_i,那么如果 Dropout 后它被留下来了的话、其输出就应该变成o_i\times\frac1{50\%}=2o_i(换句话说、应该让带 Dropout 的期望输出和原输出一致:对于任一个神经元n_i,设 drop_prob 为 p 而其原输出为o_i,那么当带 Dropout 的输出为o_i\times\frac1p时、 n_i的期望输出即为p\times o_i\times\frac1p=o_i
  • 由于在训练时我们保证了神经网络的期望输出不变、所以在预测过程中我们还是应该让整个网络一起进行预测而不进行 Dropout(关于这一点,原论文似乎也表示这是一种“经试验证明行之有效”的办法而没有给出具体的、原理层面的说明)

Normalize 说起来有点长,所以我开了一个单独的章节来说(数学 · 神经网络(四)· Normalize)。下面就直接看看如何实现它:

class Normalize(SubLayer):
    """
        初始化结构
        self._eps:记录增强数值稳定性所用的小值的属性
        self._activation:记录自身的激活函数的属性,主要是为了兼容图7.17 A的情况
        self.tf_rm、self.tf_rv:记录μ_run、σ_run^2的属性
        self.tf_gamma、self.tf_beta:记录γ、β的属性
        self._momentum:记录动量值m的属性
    """
    def __init__(self, parent, shape, activation="Identical", eps=1e-8, momentum=0.9):
        SubLayer.__init__(self, parent, shape)
        self._eps, self._activation = eps, activation
        self.tf_rm = self.tf_rv = None
        self.tf_gamma = tf.Variable(tf.ones(self.shape[1]), name="norm_scale")
        self.tf_beta = tf.Variable(tf.zeros(self.shape[1]), name="norm_beta")
        self._momentum = momentum
        self.description = "(eps: {}, momentum: {})".format(eps, momentum)

    def _activate(self, x, predict):
        # 若μ_run、σ_run^2还未初始化,则根据输入x进行相应的初始化
        if self.tf_rm is None or self.tf_rv is None:
            shape = x.get_shape()[-1]
            self.tf_rm = tf.Variable(tf.zeros(shape), trainable=False, name="norm_mean")
            self.tf_rv = tf.Variable(tf.ones(shape), trainable=False, name="norm_var")
        if not predict:
            # 利用Tensorflow相应函数计算当前Batch的举止、方差
            _sm, _sv = tf.nn.moments(x, list(range(len(x.get_shape()) - 1)))
            _rm = tf.assign(
                self.tf_rm, self._momentum * self.tf_rm + (1 - self._momentum) * _sm)
            _rv = tf.assign(
                self.tf_rv, self._momentum * self.tf_rv + (1 - self._momentum) * _sv)
            # 利用Tensorflow相应函数直接得到Batch Normalization的结果
            with tf.control_dependencies([_rm, _rv]):
                _norm = tf.nn.batch_normalization(
                    x, _sm, _sv, self.tf_beta, self.tf_gamma, self._eps)
        else:
            _norm = tf.nn.batch_normalization(
                x, self.tf_rm, self.tf_rv, self.tf_beta, self.tf_gamma, self._eps)
        # 如果指定了激活函数、就再用相应激活函数作用在BN结果上以得到最终结果
        # 这里只定义了ReLU和Sigmoid两种,如有需要可以很方便地进行拓展
        if self._activation == "ReLU":
            return tf.nn.relu(_norm)
        if self._activation == "Sigmoid":
            return tf.nn.sigmoid(_norm)
        return _norm

实现损失层

我们在Python · 神经网络(五)· Cost & Optimizer里曾经说明过如何实现损失层,这里就简单地重复一下:

# 定义一个简单的基类
class CostLayer(Layer):
    # 定义一个方法以获取损失值
    def calculate(self, y, y_pred):
        return self._activate(y_pred, y)

# 定义Cross Entropy对应的CostLayer(整合了Softmax变换)
class CrossEntropy(CostLayer):
    def _activate(self, x, y):
        return tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=x, labels=y))

# 定义MSE准则对应的CostLayer
class MSE(CostLayer):
    def _activate(self, x, y):
        return tf.reduce_mean(tf.square(x - y))

我自己用 Numpy 写的话,相同功能要写那么个 113 行,然后用 Tensorflow 的话 15 行就行了……由此可窥见 Tensorflow 框架的强大

(话说我这么卖力地安利 Tensorflow,Google 是不是应该给我些广告费什么的)(喂


实现卷积层

回忆我们说过的卷积层和普通层的性质、不难发现它们的表现极其相似,区别大体上来说只在于如下三点(以下我们用u^{(i)}v^{(i)}\phi_i表示第 i 层的输入、输出、激活函数):

  • 普通层自身对数据的处理只有“激活”(v^{(i)}=\phi_i\left( u^{(i)}\right))这一个步骤,层与层之间的数据传递则是通过权值矩阵、偏置量(w^{(i)}b^{(i)})和线性变换(u^{(i+1)}=v^{(i)}\times w^{(i)}+b^{(i)})来完成的;卷积层自身对数据的处理则多了“卷积”这个步骤(通常来说是先卷积再激活:v^{(i)}=\phi_i\left( \text{conv}\left( u^{(i)}\right)\right))、同时层与层之间的数据传递是直接传递的(u^{(i+1)}=v^{(i)}
  • 卷积层自身多了 Kernel 这个属性并因此带来了诸如 Stride、Padding 等属性,不过与此同时、卷积层之间没有权值矩阵
  • 卷积层和普通层的 shape 属性记录的东西不同,具体而言:
    • 普通层的 shape 记录着上个 Layer 和该 Layer 所含神经元的个数
    • 卷积层的 shape 记录着上个卷积层的输出和该卷积层的 Kernel 的信息(注意卷积层的上一层必定还是卷积层)

接下来就看看具体实现:

class ConvLayer(Layer):
    """
        初始化结构
        self.shape:记录着上个卷积层的输出和该Layer的Kernel的信息,具体而言:
            self.shape[0] = 上个卷积层的输出的形状(频道数×高×宽)
                常简记为self.shape[0] =(c,h_old,w_old)
            self.shape[1] = 该卷积层Kernel的信息(Kernel数×高×宽)
                常简记为self.shape[1] =(f,h_new,w_new)
        self.stride、self.padding:记录Stride、Padding的属性
        self.parent:记录父层的属性
    """
    def __init__(self, shape, stride=1, padding="SAME", parent=None):
        if parent is not None:
            _parent = parent.root if parent.is_sub_layer else parent
            shape = _parent.shape
        Layer.__init__(self, shape)
        self.stride = stride
        # 利用Tensorflow里面对Padding功能的封装、定义self.padding属性
        if isinstance(padding, str):
            # "VALID"意味着输出的高、宽会受Kernel的高、宽影响,具体公式后面会说
            if padding.upper() == "VALID":
                self.padding = 0
                self.pad_flag = "VALID"
            # "SAME"意味着输出的高、宽与Kernel的高、宽无关、只受Stride的影响
            else:
                self.padding = self.pad_flag = "SAME"
        # 如果输入了一个整数、那么就按照VALID情形设置Padding相关的属性
        else:
            self.padding = int(padding)
            self.pad_flag = "VALID"
        self.parent = parent
        if len(shape) == 1:
            self.n_channels = self.n_filters = self.out_h = self.out_w = None
        else:
            self.feed_shape(shape)

    # 定义一个处理shape属性的方法
    def feed_shape(self, shape):
        self.shape = shape
        self.n_channels, height, width = shape[0]
        self.n_filters, filter_height, filter_width = shape[1]
        # 根据Padding的相关信息、计算输出的高、宽
        if self.pad_flag == "VALID":
            self.out_h = ceil((height - filter_height + 1) / self.stride)
            self.out_w = ceil((width - filter_width + 1) / self.stride)
        else:
            self.out_h = ceil(height / self.stride)
            self.out_w = ceil(width / self.stride)

上述代码的最后几行对应着下述两个公式、这两个公式在 Tensorflow 里面有着直接对应的实现:

  • 当 Padding 设置为 VALID 时,输出的高、宽分别为:
    h^{out}=\left\lceil\frac{(h^{old}-h^{new}+1)}{\text{stride}}\right\rceil,\ \ w^{out}=\left\lceil\frac{(w^{old}-w^{new}+1)}{\text{stride}}\right\rceil
    其中,符号“\lceil\ \rceil”代表着“向上取整”,stride 代表着步长
  • 当 Padding 设置为 SAME 时,输出的高、宽分别为:
    h^{out}=\left\lceil\frac{h^{old}}{\text{stride}}\right\rceil,\ \ w^{out}=\left\lceil\frac{w^{old}}{\text{stride}}\right\rceil

同时不难看出、上述代码其实没有把 CNN 的前向传导算法囊括进去,这是因为考虑到卷积层会利用到普通层的激活函数、所以期望能够合理复用代码。所以期望能够把上述代码定义的 ConvLayer 和前文重写的 Layer 整合在一起以成为具体用在 CNN 中的卷积层,为此我们需要利用到 Python 中一项比较高级的技术——元类(元类的介绍可以参见这里):

class ConvLayerMeta(type):
    def __new__(mcs, *args, **kwargs):
        name, bases, attr = args[:3]
        # 规定继承的顺序为ConvLayer→Layer
        conv_layer, layer = bases

        def __init__(self, shape, stride=1, padding="SAME"):
            conv_layer.__init__(self, shape, stride, padding)

        # 利用Tensorflow的相应函数定义计算卷积的方法
        def _conv(self, x, w):
            return tf.nn.conv2d(x, w, strides=[self.stride] * 4, padding=self.pad_flag)

        # 依次进行卷积、激活的步骤
        def _activate(self, x, w, bias, predict):
            res = self._conv(x, w) + bias
            return layer._activate(self, res, predict)

        # 在正式进行前向传导算法之前、先要利用Tensorflow相应函数进行Padding
        def activate(self, x, w, bias=None, predict=False):
            if self.pad_flag == "VALID" and self.padding > 0:
                _pad = [self.padding] * 2
                x = tf.pad(x, [[0, 0], _pad, _pad, [0, 0]], "CONSTANT")
            return _activate(self, x, w, bias, predict)
        # 将打包好的类返回
        for key, value in locals().items():
            if str(value).find("function") >= 0:
                attr[key] = value
        return type(name, bases, attr)

在定义好基类和元类后、定义实际应用在 CNN 中的卷积层就非常简洁了。以在深度学习中应用最广泛的 ReLU 卷积层为例:

class ConvReLU(ConvLayer, ReLU, metaclass=ConvLayerMeta):
    pass

实现池化层

池化层比起卷积层而言要更简单一点:对于最常见的两种池化——极大池化和平均池化而言,它们所做的只是取输入的极大值和均值而已、本身并没有可以更新的参数。是故对池化层而言,我们无需维护其 Kernel、而只用定义相应的池化方法(极大、平均)即可,因此我们要求用户在调用池化层时、只提供“高”和“宽”而不提供“Kernel 个数”

注意:Kernel 个数从数值上来说与输出频道个数一致,所以对于池化层的实现而言、我们应该直接用输入频道数来赋值 Kernel 数,因为池化不会改变数据的频道数

class ConvPoolLayer(ConvLayer):
    def feed_shape(self, shape):
        shape = (shape[0], (shape[0][0], *shape[1]))
        ConvLayer.feed_shape(self, shape)

    def activate(self, x, w, bias=None, predict=False):
        pool_height, pool_width = self.shape[1][1:]
        # 处理Padding
        if self.pad_flag == "VALID" and self.padding > 0:
            _pad = [self.padding] * 2
            x = tf.pad(x, [[0, 0], _pad, _pad, [0, 0]], "CONSTANT")
        # 利用self._activate方法进行池化
        return self._activate(None)(
            x, ksize=[1, pool_height, pool_width, 1],
            strides=[1, self.stride, self.stride, 1], padding=self.pad_flag)

    def _activate(self, x, *args):
        pass

同样的,由于 Tensorflow 已经帮助我们做好了封装、我们可以直接调用相应的函数来完成极大池化和平均池化的实现:

# 实现极大池化
class MaxPool(ConvPoolLayer):
    def _activate(self, x, *args):
        return tf.nn.max_pool

# 实现平均池化
class AvgPool(ConvPoolLayer):
    def _activate(self, x, *args):
        return tf.nn.avg_pool

实现 CNN 中的特殊层结构

在 CNN 中同样有着 Dropout 和 Normalize 这两种特殊层结构。它们的表现和 NN 中相应特殊层结构的表现是完全一致的,区别只在于作用的对象不同

我们知道,CNN 每一层数据的维度要比 NN 中每一层数据的维度多一维:一个典型的 NN 中每一层的数据通常是N\times p\times q的,而 CNN 则通常是N\times p\times q\times r的、其中 r 是当前数据的频道数。为了让适用于 NN 的特殊层结构适配于 CNN,一个自然而合理的做法就是将 r个频道的数据当做一个整体来处理、或说将 CNN 中 r 个频道的数据放在一起并视为 NN 中的一个神经元,这样做的话就能通过简易的封装来直接利用上我们对 NN 定义的特殊层结构。封装的过程则仍要用到元类:
# 定义作为封装的元类
class ConvSubLayerMeta(type):
    def __new__(mcs, *args, **kwargs):
        name, bases, attr = args[:3]
        conv_layer, sub_layer = bases

        def __init__(self, parent, shape, *_args, **_kwargs):
            conv_layer.__init__(self, None, parent=parent)
            # 与池化层类似、特殊层输出数据的形状应保持与输入数据的形状一致
            self.out_h, self.out_w = parent.out_h, parent.out_w
            sub_layer.__init__(self, parent, shape, *_args, **_kwargs)
            self.shape = ((shape[0][0], self.out_h, self.out_w), shape[0])
            # 如果是CNN中的Normalize、则要提前初始化好γ、β
            if name == "ConvNorm":
                self.tf_gamma = tf.Variable(tf.ones(self.n_filters), name="norm_scale")
                self.tf_beta = tf.Variable(tf.zeros(self.n_filters), name="norm_beta")

        # 利用NN中的特殊层结构的相应方法获得结果
        def _activate(self, x, predict):
            return sub_layer._activate(self, x, predict)

        def activate(self, x, w, bias=None, predict=False):
            return _activate(self, x, predict)
        # 将打包好的类返回
        for key, value in locals().items():
            if str(value).find("function") >= 0 or str(value).find("property"):
                attr[key] = value
        return type(name, bases, attr)

# 定义CNN中的Dropout,注意继承顺序
class ConvDrop(ConvLayer, Dropout, metaclass=ConvSubLayerMeta):
    pass

# 定义CNN中的Normalize,注意继承顺序
class ConvNorm(ConvLayer, Normalize, metaclass=ConvSubLayerMeta):
    pass

以上就是所有层结构的相关实现了……看到这里的观众老爷们真的要给你们笔芯!至少我是看不下去的(喂

实例

感谢评论区@崔斯特的建议,我打算弄些栗子……不过虽然我非常努力地憋了三个栗子,但总感觉不太对劲……总之欢迎各种吐槽和各种意见 ( σ'ω')σ

第一个栗子是普通层的栗子,假设我们的输入矩阵为:

X = \left[
\begin{matrix}
0 & 1 & 2 & 1 & 0 \\
-1 & -2 & 0 & 2 & 1 \\
0 & 1 & -2 & -1 & 2 \\
1 & 2 & -1 & 0 & -2
\end{matrix}
\right]

亦即有 4 个样本、每个样本的维度是 5 维。然后我们的权值矩阵为:

w=\left[
\begin{matrix}
-2 & -1 & 0 & 1 & 2 \\
2 & 1 & 0 & -1 & -2
\end{matrix}
\right]^T

偏置量则简单地取为b=1。现在我们要计算Xw+b的话,核心代码只有两行:

# Identical 为“无激活函数”的意思
# 需要提供输入维度( 5 )和输出维度( 2 )
nn_id = Identical([5, 2])
# 调用相应函数进行计算
# 其中 eval 是为了把数值从 Tensorflow 的 Graph 中提取出来
print(nn_id.activate(nn_x, nn_w, nn_b).eval())

完整代码如下:

with tf.Session().as_default() as sess:
    nn_x = np.array([
        [ 0,  1,  2,  1,  0],
        [-1, -2,  0,  2,  1],
        [ 0,  1, -2, -1,  2],
        [ 1,  2, -1,  0, -2]
    ], dtype=np.float32)
    nn_w = np.array([
        [-2, -1, 0,  1,  2],
        [ 2,  1, 0, -1, -2]
    ], dtype=np.float32).T
    nn_b = 1.
    nn_id = Identical([nn_x.shape[1], 2])
    print(nn_id.activate(nn_x, nn_w, nn_b).eval())

上面这段代码将会输出:&amp;amp;amp;amp;lt;img src="https://pic4.zhimg.com/v2-734dad4f7e4071e887e46460e4f68ce3_b.png" data-rawwidth="89" data-rawheight="68" class="content_image" width="89"&amp;amp;amp;amp;gt;

要计算 Sigmoid 的话,只需要把 Identical 换成 Sigmoid 即可

第二、三个栗子是卷积的过程,我们统一假设输入只有一个样本、频道也只有一个

第二个栗子是无 Padding 无 Stride 的情形,假设唯一的频道(Channel)所对应的矩阵如下:

X=\left[
\begin{matrix}
0 & 2 & 1 & 2 \\
-1 & 0 & 0 & 1 \\
1 & 1 & 0 & 1 \\
-2 & 1 & -1 & 0
\end{matrix}
\right]

假设我们的卷积核(Kernel)有两个 Channel:

f_1=\left[
\begin{matrix}
1 & 0 & 1 \\
-1 & 0 & 1 \\
1 & 0 & -1
\end{matrix}
\right],\ \ f_2=\left[ \begin{matrix} 
0 & 1 & 0 \\
1 & 0 & -1 \\
0 & -1 & 1
\end{matrix}\right]

再假设我们的偏置量为b=\left[ 1, -1\right]。现在我们要计算相应的卷积时,核心代码仍只有两行:

# 接收的参数中,第一个是输入的 shape,第二个是 Kernel 的 shape,具体而言:
# 输入的 shape 为 height x width x channel = 4 x 4 x 1
# Kernel 的 shape 为 channel x height x width = 2 x 3 x 3
conv_id = ConvIdentical([([4, 4, 1], [2, 3, 3])], padding="VALID")

可能有观众老爷看到这就想吐槽:为什么输入的 channel 放在最后,而 Kernel 的 channel 放在前面?其中的原因主要有两点:

  • Tensorflow 默认 channel 在最后
  • 我在用 Numpy 实现框架时把 channel 放在了前面

然后……然后就是为了兼容、就变成这样了(捂脸

不得不说把 channel 放在最后是非常合乎自然语言逻辑的:比如在描述图片时,我们会自然地说它是N\times M\times 3的图片,其中最后那个 3 就是 channel

那么为什么我用 Numpy 实现时把 channel 放在了前面呢?因为这样的数组输出时会更好看(捂脸)

就拿我们这第二个栗子来说吧,如果把 channel 放在最后:

conv_x = np.array([
    [
        [ 0, 2,  1, 2],
        [-1, 0,  0, 1],
        [ 1, 1,  0, 1],
        [-2, 1, -1, 0]
    ]
], dtype=np.float32).reshape(1, 4, 4, 1)
# 第一个 1 代表样本数,最后那个 1 代表 channel 数

这样的矩阵打印出来是这样子的:

&amp;amp;amp;amp;lt;img src="https://pic4.zhimg.com/v2-9cdbea0e9042b406e4c75e9ed0d03867_b.png" data-rawwidth="95" data-rawheight="325" class="content_image" width="95"&amp;amp;amp;amp;gt;换句话说,同一个 channel 的东西会被放在同一列(很丑对不对!!);而如果我们把 channel 放前面:

换句话说,同一个 channel 的东西会被放在同一列(很丑对不对!!);而如果我们把 channel 放前面:

conv_x = np.array([
    [
        [ 0, 2,  1, 2],
        [-1, 0,  0, 1],
        [ 1, 1,  0, 1],
        [-2, 1, -1, 0]
    ]
], dtype=np.float32).reshape(1, 1, 4, 4)
# 第一个 1 代表样本数,第二个 1 代表 channel 数

这样的矩阵打印出来是这样子的:

&amp;amp;amp;amp;lt;img src="https://pic3.zhimg.com/v2-47916705bc94246bca6d697d97c1cca2_b.png" data-rawwidth="188" data-rawheight="72" class="content_image" width="188"&amp;amp;amp;amp;gt;好看多了对不对!!

好看多了对不对!!


总之大概就这么个感觉……接下来看看第二个栗子的完整代码:

with tf.Session().as_default() as sess:
    conv_x = np.array([
        [
            [ 0, 2,  1, 2],
            [-1, 0,  0, 1],
            [ 1, 1,  0, 1],
            [-2, 1, -1, 0]
        ]
    ], dtype=np.float32).reshape(1, 4, 4, 1)
    # 这里有些兼容 Tensorflow 的 trick,大抵可以不必太在意……
    conv_w = np.array([
        [[ 1, 0,  1],
         [-1, 0,  1],
         [ 1, 0, -1]],
        [[0,  1,  0],
         [1,  0, -1],
         [0, -1,  1]]
    ], dtype=np.float32).transpose([1, 2, 0])[..., None, :]
    conv_b = np.array([1, -1], dtype=np.float32)
    conv_id = ConvIdentical([(conv_x.shape[1:], [2, 3, 3])], padding="VALID")
    print(conv_id.activate(conv_x, conv_w, conv_b).eval())

上面这段代码将会输出:&amp;amp;amp;amp;lt;img src="https://pic1.zhimg.com/v2-6be828a844aa4cdc9e03f8f31928d96c_b.png" data-rawwidth="123" data-rawheight="86" class="content_image" width="123"&amp;amp;amp;amp;gt;

稍微解释一下,比如说左上角的 4 是这样求得的:

\begin{matrix}
0\times 1 &+& 2\times 0 &+& 1\times 1 \\
-1\times -1 &+& 0\times 0 &+& 0\times 1 \\
1\times 1 &+& 1\times 0 &+& 0\times 1
\end{matrix}\ \ + 1 = 4

右上角的 -1 是这样求得的:

\begin{matrix}
0\times 0 &+& 2\times 1 &+& 1\times 0 \\
-1\times 1 &+& 0\times 0 &+& 0\times -1 \\
1\times 0 &+& 1\times -1 &+& 0\times 1
\end{matrix}\ \ - 1 = -1

这里需要特别指出的是,Kernel 的第一个 channel 卷积出来的结果在第一列、第二个卷积出来的则在第二列

如果想计算带 ReLU 的卷积的话,把上述 ConvIdentical 改成 ConvReLU 即可

第三个栗子是 Padding、Stride 均为 1 的情形,假设唯一的 Channel 所对应的矩阵如下:


X=\left[
\begin{matrix}
1 & 2 & 1 \\
-1 & 0 & -2 \\
1 & -1 & 2
\end{matrix}
\right]

加了 1 的 Padding 之后、输入将变为:

X=\left[
\begin{matrix}
0 & 0 & 0 & 0 & 0 \\
0 & 1 & 2 & 1 & 0 \\
0 & -1 & 0 & -2 & 0 \\
0 & 1 & -1 & 2 & 0 \\
0 & 0 & 0 & 0 & 0 \\
\end{matrix}
\right]

假设 Kernel、偏置量都不变,那么在上述代码的基础上、只需如下的代码即可完成第三个栗子所要求的卷积:

conv_x = np.array([
    [
        [ 1,  2,  1],
        [-1,  0, -2],
        [ 1, -1,  2]
    ]
], dtype=np.float32).reshape(1, 3, 3, 1)
conv_id = ConvIdentical([([3, 3, 1], [2, 3, 3])], padding=1, stride=2)
print(conv_id.activate(conv_x, conv_w, conv_b).eval())

上面这段代码将会输出:

&amp;amp;amp;amp;lt;img src="https://pic1.zhimg.com/v2-fb89c706e7ad85758ab557485f4ff988_b.png" data-rawwidth="125" data-rawheight="89" class="content_image" width="125"&amp;amp;amp;amp;gt;

下一章会说明如何定义一个网络结构来封装我们这章讲的这些层结构,然后我们就能实际地跑跑 CNN 了 ( σ'ω')σ

希望观众老爷们能够喜欢~