将 NN 扩展为 CNN

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

实现卷积层

回忆我们说过的卷积层和普通层的性质、不难发现它们的表现极其相似,区别大体上来说只在于如下三点:

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

接下来就看看具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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 时,输出的高、宽分别为: 其中,符号“”代表着“向上取整”,stride 代表着步长
  • 当 Padding 设置为 SAME 时,输出的高、宽分别为:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 卷积层为例:

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

实现池化层

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 已经帮助我们做好了封装、我们可以直接调用相应的函数来完成极大池化和平均池化的实现:

1
2
3
4
5
6
7
8
9
# 实现极大池化
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 中每一层的数据通常是的,而 CNN 则通常是的、其中是当前数据的频道数。为了让适用于 NN 的特殊层结构适配于 CNN,一个自然而合理的做法就是将个频道的数据当做一个整体来处理、或说将 CNN 中个频道的数据放在一起并视为 NN 中的一个神经元,这样做的话就能通过简易的封装来直接利用上我们对 NN 定义的特殊层结构。封装的过程则仍要用到元类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 定义作为封装的元类
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

实现 LayerFactory

我们在前三节讲述了 CNN 中卷积层、池化层和特殊层的实现,这一节我们将介绍如何定义一个简单的工厂来“生产”NN 中的层和前文介绍的这些层以方便进行应用(与上个系列中生产优化器的工厂差不多):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class LayerFactory:
# 使用一个字典记录下所有的Root Layer
available_root_layers = {
"Tanh": Tanh, "Sigmoid": Sigmoid,
"ELU": ELU, "ReLU": ReLU, "Softplus": Softplus,
"Identical": Identical,
"CrossEntropy": CrossEntropy, "MSE": MSE,
"ConvTanh": ConvTanh, "ConvSigmoid": ConvSigmoid,
"ConvELU": ConvELU, "ConvReLU": ConvReLU, "ConvSoftplus": ConvSoftplus,
"ConvIdentical": ConvIdentical,
"MaxPool": MaxPool, "AvgPool": AvgPool
}
# 使用一个字典记录下所有特殊层
available_special_layers = {
"Dropout": Dropout,
"Normalize": Normalize,
"ConvDrop": ConvDrop,
"ConvNorm": ConvNorm
}
# 使用一个字典记录下所有特殊层的默认参数
special_layer_default_params = {
"Dropout": (0.5,),
"Normalize": ("Identical", 1e-8, 0.9),
"ConvDrop": (0.5,),
"ConvNorm": ("Identical", 1e-8, 0.9)
}

以上是一些准备工作,如果由于特殊需求(比如想实验某种激活函数是否好用)实现了新的 Layer 的话、就需要更新上面对应的字典。
接下来看看核心的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 定义根据“名字”获取(Root)Layer的方法
def get_root_layer_by_name(self, name, *args, **kwargs):
# 根据字典判断输入的名字是否是Root Layer的名字
if name in self.available_root_layers:
# 若是、则返回相应的Root Layer
layer = self.available_root_layers[name]
return layer(*args, **kwargs)
# 否则返回None
return None
# 定义根据“名字”获取(任何)Layer的方法
def get_layer_by_name(self, name, parent, current_dimension, *args, **kwargs):
# 先看输入的是否是Root Layer
_layer = self.get_root_layer_by_name(name, *args, **kwargs)
# 若是、直接返回相应的Root Layer
if _layer:
return _layer, None
# 否则就根据父层和相应字典进行初始化后、返回相应的特殊层
_current, _next = parent.shape[1], current_dimension
layer_param = self.special_layer_default_params[name]
_layer = self.available_special_layers[name]
if args or kwargs:
_layer = _layer(parent, (_current, _next), *args, **kwargs)
else:
_layer = _layer(parent, (_current, _next), *layer_param)
return _layer, (_current, _next)

至此,所有 CNN 会用到的、和层结构相关的东西就已经全部实现完毕了,接下来只需在网络结构上做一些简单的更新后、CNN 的实现便能大功告成

扩展网络结构

将网络结构迁移到 Tensorflow 框架中并扩展出 CNN 的功能这个过程、虽然不算困难却也相当繁琐。本节将会节选出其中比较重要的部分进行说明,对于其余和上个系列实现的网络结构几乎一致的地方则不再进行注释或叙述

首先是初始化,由于我们使用的是 Tensorflow 框架、所以相应变量名的前面会一概加上“tf”两个字母:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class NN(ClassifierBase):
def __init__(self):
super(NN, self).__init__()
self._layers = []
self._optimizer = None
self._current_dimension = 0
self._available_metrics = {
key: value for key, value in zip(["acc", "f1-score"], [NN.acc, NN.f1_score])
}
self.verbose = 0
self._metrics, self._metric_names, self._logs = [], [], {}
self._layer_factory = LayerFactory()
# 定义Tensorflow中的相应变量
self._tfx = self._tfy = None # 记录每个Batch的样本、标签的属性
self._tf_weights, self._tf_bias = [], [] # 记录w、b的属性
self._cost = self._y_pred = None # 记录损失值、输出值的属性
self._train_step = None # 记录“参数更新步骤”的属性
self._sess = tf.Session() # 记录Tensorflow Session的属性

然后我们要解决的就是上篇文章最后遗留下来的、在初始化各个权值矩阵时要把从初始化为 Numpy 数组改为初始化为 Tensorflow 数组、同时要注意兼容 CNN 的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 利用Tensorflow相应函数初始化参数
@staticmethod
def _get_w(shape):
initial = tf.truncated_normal(shape, stddev=0.1)
return tf.Variable(initial, name="w")
@staticmethod
def _get_b(shape):
return tf.Variable(np.zeros(shape, dtype=np.float32) + 0.1, name="b")
# 做一个初始化参数的封装,要注意兼容CNN
def _add_params(self, shape, conv_channel=None, fc_shape=None, apply_bias=True):
# 如果是FC的话、就要根据铺平后数据的形状来初始化数据
if fc_shape is not None:
w_shape = (fc_shape, shape[1])
b_shape = shape[1],
# 如果是卷积层的话、就要定义Kernel而非权值矩阵
elif conv_channel is not None:
if len(shape[1]) <= 2:
w_shape = shape[1][0], shape[1][1], conv_channel, conv_channel
else:
w_shape = (shape[1][1], shape[1][2], conv_channel, shape[1][0])
b_shape = shape[1][0],
# 其余情况和普通NN无异
else:
w_shape = shape
b_shape = shape[1],
self._tf_weights.append(self._get_w(w_shape))
if apply_bias:
self._tf_bias.append(self._get_b(b_shape))
else:
self._tf_bias.append(None)
# 由于特殊层不会用到w和b、所以要定义一个生成占位符的方法
def _add_param_placeholder(self):
self._tf_weights.append(tf.constant([.0]))
self._tf_bias.append(tf.constant([.0]))

以上就是和 NN 中网络结构相比有比较大改动的地方、其余的部分则都是一些琐碎的细节。完整的代码可以参见这里,功能更为齐全、在许多细节上都进行了优化的版本则可以参见这里

观众老爷们能赏个脸么 ( σ'ω')σ