学完前面的内容后,读者对TensorFlow程序设计有了比较深入的理解,甚至会觉得自己很厉害,那么笔者的目的也就达到了。
不过又有一个问题来了,这里一直在使用的、反复提及的全连接层到底是什么样的存在?本节我们详解一下。
全连接层的每一个结点都与上一层的所有结点相连,用来把前边提取到的特征综合起来。由于其全相连的特性,一般全连接层的参数也是最多的。图2.11所示的是一个简单的全连接网络。
图2.11 全连接层
其推导过程如下:
将推导公式转化一下写法,具体如下:
可以看到,全连接的核心操作就是矩阵向量乘积: w×x=y 。
下面举一个例子,使用TensorFlow自带的API实现一个简单的矩阵计算。
[1,1] [1]
[2,2]* [1]= [?]
首先笔者通过公式计算对数据做一个先行验证,按推导公式计算如下:
(1×1+1×1)+0.17=2.17
(2×1+2×1)+0.17=4.17
这样最终形成了一个新的矩阵[2.17,4.17],代码如下:
【程序2-9】
最终打印结果如下:
tf.Tensor([[2.17] [4.17]], shape=(2, 1), dtype=float32)
可以看到,最终计算出一个Tensor,大小为shape=(2, 1),类型为float32,其值为[[2.17][4.17]]。
计算本身非常简单,全连接的计算方法相信读者也很容易掌握。现在回到代码中,请注意我们在定义参数和定义输入值的时候采用的不同写法:
weight = tf.Variable([[1.],[1.]]) input_xs = tf.constant([[1.,1.],[2.,2.]])
这里对参数的定义,笔者使用的是Variable函数。对于输入值的定义,笔者使用的是constant函数,将其对应内容打印如下:
<tf.Variable 'Variable:0' shape=(2, 1) dtype=float32, numpy=array([[1.], [1.]], dtype=float32)>
input_xs打印如下:
通过对比可以看到,这里的weight被定义成一个可变参数Variable类型,供在后续的反向计算中进行调整。Constant函数是直接读取数据并将其定义成Tensor格式。
读者千万不要有错误的理解,在上一节中编写的程序2-9仅仅是为了向读者介绍全连接层的计算方法而不是介绍全连接层。全连接本质就是由一个特征空间线性变换到另一个特征空间。目标空间的任一维(也就是隐藏层的一个节点)都认为会受到源空间每一维的影响。可以不那么严谨地说,目标向量是源向量的加权和。
全连接层一般是接在特征提取网络之后,用作对特征的分类器。全连接常出现在最后几层,用于对前面设计的特征做加权和。前面的网络相当于做特征工程,后面的全连接相当于做特征加权。
具体的神经网络差值反馈算法将在第3章介绍。
下面我们就使用自定义的方法实现某一个可以加载到model中的“自定义全连接层”。
在TensorFlow中,任何一个自定义的层都是继承自tf.keras.layers.Layer的,我们将其称为“父层”,如图2.12所示。这里所谓的自定义层实际上是父层的一个具体实现。
图2.12 父层
从图2.12可以看到,Layer层中又是由多个函数构成的,因此基于继承的关系,如果想要实现自定义的层,那么必须对其中的函数进行实现。
所谓的“父层”,就是指这里自定义的层继承自哪里,告诉TensorFlow框架代码遵守“父层”的函数,请实现代码自定义的功能。
Layer层中需要自定义的函数有很多,但是在实际使用时一般只需要定义那些必须使用的函数。例如build、call函数,以及初始化所必需的__init__函数。
(1)__init__函数:首先是一些必要参数的初始化,这些参数的初始化写在def __init__(self,)中。写法如下:
可以看到,init函数中最重要的就是显式地确定所需要的一些参数。特别值得注意的是,对于输入的init中的参数,输入Tensor不会在这里进行标注, init值初始化的是模型参数。 输入值不属于“模型参数”。
(2)build函数:build函数的内容主要是 声明需要更新的参数部分, 如权重等,一般使用self.kernel = tf.Variable(shape=[ ])等来声明需要更新的参数变量。
Build函数参数中的input_shape形参是固定不变的写法,读者不要修改即可,其中自定义的参数需要加上self,表明是在类中使用的全局参数。
对于代码最后的super(MyLayer, self).build(input_shape),目前读者只需要记得这种写法即可,在build的最后确定参数定义结束。
(3)call函数:最重要的函数,这部分代码包含了主要层的实现。
Init是对参数做定义和声明;build函数是对权重可变参数做声明。
这两个函数只是定义了一些初始化的参数以及一些需要更新的参数变量,真正实现所定义类的作用是在call方法中。
可以看到call中的一系列操作是对__init__和build中变量参数的应用,所有的计算都在call函数中完成,并且需要注意的是输入的参数也在这里出现,经过计算后将计算值返回。
下面我们就使用自定义的层修改iris模型。程序如下所示:
【程序2-10】
我们首先定义了MyLayer作为全连接层,之后正如使用TensorFlow自带的层一样,直接生成类函数并显式指定输入参数,最终将所有的层加入model中。最终打印结果如图2.13所示。
图2.13 打印结果
在程序2-10中我们使用自定义层实现了model。如果读者认真学习了这部分内容,那么相信你一定可以实现自己的自定义层。
似乎还有一个问题,对于自定义的层来说,这里的参数名(也就是在build中定义的参数名)都是一样。在层生成的过程中似乎并没有对每个层进行重新命名或者将其归属于某个命名空间中。这似乎与传统的TensorFlow 1.X模型的设计结果相冲突。
实践是解决疑问的最好办法。TensorFlow中提供了打印模型结构的函数,代码如下:
print(model.summary())
使用时将这个函数置于构建后的model下,即可打印模型的结构与参数。
【程序2-11】
打印结果如图2.14所示。
图2.14 打印结果
从打印出的模型结构可以看到,这里每一层都根据层的名称重新命名,而且由于名称相同,TensorFlow框架自动根据其命名方式对其进行层数的增加(名称)。
对于读者更为关心的参数问题,从对应行的第三列param可以看到,不同的层,其参数个数也不相同,因此可以认为在TensorFlow中重名的模型被自动赋予一个新的名称,并存在于不同的命名空间之中。