查看原文
其他

【他山之石】TensorFlow神经网络实现二分类的正确姿势

“他山之石,可以攻玉”,站在巨人的肩膀才能看得更高,走得更远。在科研的道路上,更需借助东风才能更快前行。为此,我们特别搜集整理了一些实用的代码链接,数据集,软件,编程技巧等,开辟“他山之石”专栏,助你乘风破浪,一路奋勇向前,敬请关注。

作者:知乎—清川

地址:https://zhuanlan.zhihu.com/p/352062289

最近在论文中看到的TensorFlow代码眼花缭乱,TF2.0语法更新很多,现在又出了JAX等改进框架,所以觉得有必要系统的从头学习一下。下面这本书是很早以前从旧书摊淘来的。2017年出版,在日新月异的技术面前就是一件古董,但为了全面地了解各个版本的TF,我决定翻一翻~

https://book.douban.com/subject/26976457/

这本书写的只能说差强人意,知识点介绍的很浅显,内容也不乏错误和误人子弟的地方,今天就记录一下书中第62页使用TF实现神经网络解决二分类问题的错误,作者在76页又重复强调了这个错误。很多博主直接copy了书中的内容而没有亲自实验,比如Tensorflow实现训练神经网络解决二分类问题(https://blog.csdn.net/qq_38702419/article/details/88066433),但也有教程指出了这个问题使用TensorFlow实现二分类的方法示例(http://www.manongjc.com/article/50785.html)。下文使用的环境为TensorFlow1.15.0和Python3.7.9。这段程序就是辅助理解,没有什么实际意义。


样例程序

我们可以在Tensorflow Neural Network Playground网站(http://playground.tensorflow.org/)上可视化用于二分类的全连接网络,现在我们使用TF编写一个类似的网络,解决类似的问题。

需要引入的库如下

其中RandomState函数用来生成一个固定种子的随机数生成器,相同种子的随机数序列是固定的,这样重复实验可以得到稳定的结果。
import tensorflow as tffrom numpy.random import RandomStateimport matplotlib.pyplot as plt

下面模拟数据集

随机生成一些点,这些点如果在指定的圆内,则类别标签为1,就像上图中的蓝色点,否则为0。rand函数生成了一个维度为DATASET_SIZEx2的numpy数组,数值为0到1之间的均匀分布。注意标签的维度为DATASET_SIZEx1,类型为list。我们的分类任务就是给定坐标,预测该点的颜色。这里没有抽取测试集,而是直接在训练过程中展示效果。
dataset_X = RandomState(1).rand(DATASET_SIZE, 2)dataset_y = [[int((x1-0.5)**2+(x2-0.5)**2 < 0.15)] for x1, x2 in dataset_X]
我们知道全连接网络的前向传播其实就是矩阵乘法。本文规定输入层有2个节点,分别代表给定点的横纵坐标,中间层有4个节点,输出层为1个节点,与Playground中保持一致。对于分类问题,我们一般使用onehot向量作为标签,也就是输出层的节点数等于类别数。但二分类相当于非此即彼,就可以直接使用一个输出节点。比如说,规定节点输出值代表给定点是蓝色的概率  ,那么是橙色的概率就是  ,假设以0.5作为阈值,那么  为蓝色,  为橙色。

下面定义权重矩阵和偏置变量

使用tf.Variable新建变量,其参数为初始化方法。这里使用标准差为1,均值为0(默认值)的正态分布初始化权重。固定种子为1以重复实验。
w1 = tf.Variable(tf.random_normal([2, 4], stddev=1, seed=1))b1 = tf.Variable(tf.random_normal([1, 4], stddev=1, seed=1))w2 = tf.Variable(tf.random_normal([4, 1], stddev=1, seed=1))b2 = tf.Variable(tf.random_normal([1], stddev=1, seed=1))
定义输入坐标和ground truth标签的占位变量,和数据集的维度一致。这里None表示大小不确定,可以自动适配feed进来的值的维度。
_X = tf.placeholder(tf.float32, shape=(None, 2), name="x_input")_y = tf.placeholder(tf.float32, shape=(None, 1), name="y_input")

前向传播过程如下

使用sigmoid作为激活函数来归一化结果,因为结果的概率在0~1之间。
a = tf.sigmoid(tf.matmul(_X, w1) + b1)y = tf.sigmoid(tf.matmul( a, w2) + b2)

反向传播过程如下

使用交叉熵损失函数,使用TF提供的Adam优化器进行优化。
cross_entropy = -tf.reduce_mean( _y * tf.log(tf.clip_by_value(y, 1e-10, 1.0)) + (1 - _y) * tf.log(tf.clip_by_value(1 - y, 1e-10, 1.0)))train_step = tf.train.AdamOptimizer(0.03).minimize(cross_entropy)
书中对于交叉熵的定义有误。交叉熵损失函数的定义可以参考这篇文章交叉熵损失函数原理(https://blog.csdn.net/chao_shine/article/details/89925762),公式大致如下:
其中  代表输入的第  个样本的特征向量,  和  分别代表该样本对应的在各类别上真实的概率分布和预测的概率分布。求和号表示总的交叉熵等于所有样本的交叉熵之和。这里有一个常见的误区,很多人认为二分类数据集的标签代表的就是这里的概率,0表示蓝色的概率为0,即为橙色,1表示蓝色的概率为1,即为蓝色。然后就想当然的把上式变成:
书中的例程就是这样的逻辑,极具误导性。其结果显而易见,优化器将使预测值  不断接近1,这样交叉熵就可以取到最小值0。直接运行书中的例程确实可以得到损失函数不断下降并收敛的假象,但只要稍微看一下预测结果就会发现问题。
这里犯的错误就是混淆了概率和概率分布。上文提到,二分类中简化了输出层,用一个概率值来确定一个概率分布。但这并不代表公式里就可以直接代入。正确的做法是从概率值恢复出概率分布,再代入到公式中。
如果输出层使用两个节点,标签使用  表示类别0,使用  表示类别1。那么上面错误的公式可以修改为
这里由于  和  都既可以取0也可以取1,所以就不会发生上述的问题啦。在实际操作中,选用tf.reduce_mean计算平均值来代替求和同样能表征损失函数。另外由于我们的输出值可能取到0,这会使得-log的值无穷大。一般来说需要使用softmax来处理这个问题,但由于这里只有两个类别,可以简单地设置一个极小值1e-10来代替0。tf.clip_by_value(y,1e-10,1.0)将小于这个极小值的数值都设置成它。

接下来进行训练

每隔2000步输出一下总的交叉熵。训练完毕后获取所有样本上的预测值。
with tf.Session() as sess: tf.global_variables_initializer().run() for i in range(STEPS): beg = (i * BATCH_SIZE) % DATASET_SIZE end = min(beg + BATCH_SIZE, DATASET_SIZE) sess.run(train_step, feed_dict={_X: dataset_X[beg: end], _y: dataset_y[beg: end]}) if i % 2000 == 0: total_cross_entropy = sess.run( cross_entropy, feed_dict={_X: dataset_X, _y: dataset_y} ) print("After {:>5d} training_step(s), loss is {:.4f}".format(i, total_cross_entropy)) predict = sess.run(y, feed_dict={_X: dataset_X})
我们将预测类别和真实类别可视化一下看看效果
plt.subplot(121)for i, (x1, x2) in enumerate(dataset_X): plt.scatter(x1, x2, color=["orange", "blue"][int(predict[i][0] > 0.5)])
plt.subplot(122)for i, (x1, x2) in enumerate(dataset_X): plt.scatter(x1, x2, color=["orange", "blue"][dataset_y[i][0]])plt.show()

实验结果如下

左侧的是预测值,右侧是真实值,可以看出两者非常接近了
After 0 training_step(s), loss is 0.7133After 2000 training_step(s), loss is 0.1970After 4000 training_step(s), loss is 0.0976After 6000 training_step(s), loss is 0.0678After 8000 training_step(s), loss is 0.0486
书中的例程实际是不包含偏置值和非线性激活函数的。去掉b1和b2,将学习率修改为0.06,再次实验的效果如下
After 0 training_step(s), loss is 0.7246After 2000 training_step(s), loss is 0.4504After 4000 training_step(s), loss is 0.3730After 6000 training_step(s), loss is 0.3188After 8000 training_step(s), loss is 0.2945
可以看出失去了偏置值,神经网络的表征能力明显下降。这相当于在后一层网络对前层的输出线性变换时无法进行平移,而只能简单的加权。在Playground中可视化一下各个神经元的特征图,可以看出具有偏置的神经网络隐藏层的四个节点像是割圆法的四条切线,重叠在一起时大致将圆的轮廓勾勒出来。输入的横纵坐标可以看做是横线和竖线,而隐藏层加权得到斜线,通过偏置值,将斜线平移到合适的位置,最终输出层将四条斜线综合起来。
至于完全不使用激活函数,暂且不提非线性表征能力,光是输出值无法归一化到概率的取值范围,即0到1之间,就是个巨大的问题。这里不给出结果了,因为效果实在太差。但是一个有趣的事实是,如果只添加一层激活函数,在隐藏层和输出层之间归一化的效果要比在输出层之后效果要好,当然其原因也显而易见。
激活函数归一化的说法不太严谨,sigmoid的归一化只是一个副作用。类似ReLU的激活函数并不能对值域进行压缩,真正将输出变为概率分布的其实是最后一步的softmax。这里由于使用的是阈值截断的方式,自然去除激活函数之后效果很差。可以尝试使用softmax,去除sigmoid再次实验。

最后给出完整代码

import tensorflow as tffrom numpy.random import RandomStateimport matplotlib.pyplot as plt
w1 = tf.Variable(tf.random_normal([2, 4], stddev=1, seed=1))b1 = tf.Variable(tf.random_normal([1, 4], stddev=1, seed=1))w2 = tf.Variable(tf.random_normal([4, 1], stddev=1, seed=1))b2 = tf.Variable(tf.random_normal([1], stddev=1, seed=1))
_X = tf.placeholder(tf.float32, shape=(None, 2), name="x_input")_y = tf.placeholder(tf.float32, shape=(None, 1), name="y_input")
a = tf.sigmoid(tf.matmul(_X, w1) + b1)y = tf.sigmoid(tf.matmul( a, w2) + b2)
cross_entropy = -tf.reduce_mean( _y * tf.log(tf.clip_by_value(y, 1e-10, 1.0)) + (1 - _y) * tf.log(tf.clip_by_value(1 - y, 1e-10, 1.0)))train_step = tf.train.AdamOptimizer(0.03).minimize(cross_entropy)
BATCH_SIZE, DATASET_SIZE, STEPS = 8, 256, 10000dataset_X = RandomState(1).rand(DATASET_SIZE, 2)dataset_y = [[int((x1-0.5)**2+(x2-0.5)**2 < 0.15)] for x1, x2 in dataset_X]
with tf.Session() as sess: tf.global_variables_initializer().run() for i in range(STEPS): beg = (i * BATCH_SIZE) % DATASET_SIZE end = min(beg + BATCH_SIZE, DATASET_SIZE) sess.run(train_step, feed_dict={_X: dataset_X[beg: end], _y: dataset_y[beg: end]}) if i % 2000 == 0: total_cross_entropy = sess.run( cross_entropy, feed_dict={_X: dataset_X, _y: dataset_y} ) print("After {:>5d} training_step(s), loss is {:.4f}".format(i, total_cross_entropy)) predict = sess.run(y, feed_dict={_X: dataset_X})
plt.subplot(121)for i, (x1, x2) in enumerate(dataset_X): plt.scatter(x1, x2, color=["orange", "blue"][int(predict[i][0] > 0.5)])
plt.subplot(122)for i, (x1, x2) in enumerate(dataset_X): plt.scatter(x1, x2, color=["orange", "blue"][dataset_y[i][0]])plt.show()
本文目的在于学术交流,并不代表本公众号赞同其观点或对其内容真实性负责,版权归原作者所有,如有侵权请告知删除。


“他山之石”历史文章


更多他山之石专栏文章,

请点击文章底部“阅读原文”查看



分享、点赞、在看,给个三连击呗!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存