TensorFlow4:深层神经网络

前面介绍了TensorFlow的主要概念,并给出了一个完整的TensorFlow程序来训练神经网络.这一章将介绍:如何设计和优化神经网络,使得他能够更好地对未知的样本进行预测。
1.介绍深度学习与深层神经网络的概念,并给出一个实际的样例来说明深层神经网络可以解决部分浅层神经网络解决不了的问题。
2.介绍如何设定神经网络的优化目标。这个优化目标也就是损失函数,所以将分别介绍分类问题和回归问题中比较常用的几种损失函数。除了使用经典的损失函数外,还将给出一个样例来讲解如何通过损失函数的设置,使神经网络优化的目标更加接近实际问题的需求。
3.将更加详细地介绍神经网络的反向传播算法,并且给出一个TensorFlow框架来实现反向传播的过程。
4.在对神经网络优化有了进一步了解之后,最后将介绍在神经网络优化中经常遇到的几个问题,并且给出解决这些问题的具体方法。

1.1深度学习与深层神经网络

维基百科对深度学习的精确定义为“一类通过多层非线性变换对高复杂性数据建模算法的合集”。因为深层神经网络是实现“多层非线性变换”最常用的一种方法,所以在实际中基本上可以认为深度学习就是深层神经网络的代名词。从维基百科给出的定义看出,深度学习有两个非常重要的特性——多层和非线性。本节将给出详细的解释:
1.先介绍线性变换存在的问题,以及为什么要在深度学习的定义中强调“复杂问题”
2.将介绍如何实现去线性化,并给出TensorFlow程序来实现去线性化的功能
3.将介绍一个具体的样例来说明深层网络比浅层网络可以解决更多的问题

1.1线性模型的局限性

在线性模型中,模型的输出为输入的加权和。假设一个模型的输出y和输入xi满足以下关系,那么这个模型就是一个线性模型。
y = \sum_iw_ix_i+b
其中wi,b为模型的参数。被称之为线性模型是因为当模型的输入只有一个的时候,x和y形成了二维坐标系上的一条直线。类似的,当模型有n个输入时,x和y形成了n+1维空间中的一个平面。而一个线性模型中通过输入得到输出的函数被称之为一个线性变换。上面的公式就是一个线性变换。线性模型的最大特点是任意线性模型的组合仍然是线性模型。我们前面章节所介绍的前向传播算法实现的就是一个线性模型,前向传播的计算公式为:
a^{(1)} = xW^{(1)},y = a^{(1)}W^{(2)}
其中x为输入,W为参数。整理一下上面的公式可以得到整个模型的输出为:
y = (xW^{(1)})W^{(2)}
根据矩阵乘法的结合律有:
y = x(W^{(1)}W^{(2)}) = xW^{'}
W^{(1)}W^{(2)}其实可以被表示为一个新的参数W^{'}:
W^{'} = W^{(1)}W^{(2)} = \begin{bmatrix} W_{1,1}^{(1)} & W_{1,2}^{(1)} & W_{1,3}^{(1)} \\ W_{2,1}^{(1)} & W_{2,2}^{(1)} & W_{2,3}^{(1)} \end{bmatrix} \begin{bmatrix} W_{1,1}^{(2)}\\ W_{2,1}^{(2)}\\ W_{3,1}^{(2)} \end{bmatrix}\ = \begin{bmatrix} W_{1,1}^{(1)}W_{1,1}^{(2)} & W_{1,2}^{(1)}W_{2,1}^{(2)} & W_{1,3}^{(1)}W_{3,1}^{(2)} \\ W_{2,1}^{(1)}W_{1,1}^{(2)} & W_{2,2}^{(1)}W_{2,1}^{(2)} & W_{2,3}^{(1)}W_{3,1}^{(2)} \end{bmatrix}
这样输入和输出的关系就可以表示为:
y = xW^{'} = \begin{bmatrix} x1 & x2 \end{bmatrix} \begin{bmatrix} W_1^{'}\\ W_2^{'} \end{bmatrix}= [W_1^{'}x_1 + W_2^{'}x_2]
其中W'是新的参数。这个前向传播的算法完全符合线性模型的定义。从这个例子可以看到,虽然这个神经网络有两层(不算输入层),但是它和单层的神经网络并没有区别。以此类推,只通过线性变换,任一层的全连接神经网络和单层神经网络模型的表达能力没有任何区别,而且他们都是线性模型。然而线性模型能够解决的问题是有限的。这就是线性模型最大的局限性,也是为什么深度学习要强调非线性。在下面的篇幅中,将通过TensorFlow游乐场给出一个具体的例子来验证线性模型的局限性。
还是以判断零件是否合格为例,输入为x1和x2,其中x1代表一个零件质量和平均质量的差,x2代表一个零件长度和平均长度的差。假设一个零件的质量及长度离平均质量及长度越近,那么这个零件越有可能合格。于是训练数据很有可能服从下图所示的分布:

零件合格问题数据分布

上图的黑色点代表合格的零件,而灰色的点代表不合格的零件。可以看到虽然黑色和灰色的点有一些重合,但是大部分代表合格的零件的黑色点都在原点(0,0)的附近,而代表不合格零件的灰点都在离原点相对远的地方。这样的分布比较接近真实问题,因为大部分真实的问题都存在大致的趋势,但是很难甚至无法完全正确地区分不同的类别。下图显示了使用TensorFlow游乐场训练线性模型解决这个问题的效果:
使用线性模型解决线性不可分问题的效果

从图中可以看出,在线性可分问题中,线性模型就能很好区分不同颜色的点。因为线性模型就能解决线性可分问题,所以在深度学习的定义中特意强调它的目的为解决更加复杂的问题。所谓复杂的问题,至少是无法通过直线(或者高维空间的平面)划分的。在现实世界中,绝大部分的问题都是无法线性分割的。回到判断零件是否合格的问题,如果将激活函数换成非线性的,那么可以得到如下图所示的结果。在这个样例中使用了ReLu激活函数。使用其他非线性激活函数也可以很好地区分不同颜色的点了。
使用非线性模型解决线性不可分问题的效果

1.2.激活函数实现去线性化

前一小节已经提高过激活函数,并在样例中看到了它“神奇”的作用。在这一个小节中,将详细介绍激活函数如何工作的。我们知道,神经元结构的输出为所有输入的加权和,这导致整个神经网络是一个线性模型。如果将每一个神经元(也就是神经网络中的节点)的输出通过一个非线性函数,那么整个神经网络的模型也就不再是线性的了。这个非线性函数就是激活函数。下图显示了加入激活函数和偏置项之后的神经元结构:

加入偏置项和激活函数的神经元结构

下面的公式给出了简单神经网络结构加上激活函数和偏置项的前向传播算法的数学定义:

a^{(1)} = [a_{11},a_{12},a_{13}] = f(xW^{(1)}) = f([X1,X2] \begin{bmatrix} W_{1,1}^{(1)} & W_{1,2}^{(1)} & W_{1,3}^{(1)} \\ W_{2,1}^{(1)} & W_{2,2}^{(1)} & W_{2,3}^{(1)} \end{bmatrix}+[b_1 b_2 b_3])\ =f([W_{1,1}^{(1)}x_{1} + W_{2,1}^{(1)}x_2+b_1),f(W_{1,2}^{(1)}x_1+b_2)+W_{2,2}^{(1)}x_2+b_2),f(W_{1,3}^{(1)}x_1+W_{2,3}^{(1)}x_2]+b_3)
相比原始定义,上面的定义主要有两个改变。第一个改变是新的公式中增加了偏置项(bias),偏置项是神经网络中非常常用的一种结构。第二个改变就是每个节点的取值不再是单纯的加权和。每个节点的输出在加权和的基础上还做了一个非线性变换。下图显示了几种常用的非线性激活函数的函数图像:

常用的神经网络激活函数的函数图像

从图中可以看出,这些激活函数的函数图像都不是一条直线。所以通过这些激活函数,每一个节点不再是线性变换,于是整个神经网络模型也就不再是线性的了。下图给出了加入偏置项和ReLU激活函数之后,简单神经网络的结构
加入偏置项和激活函数的神经网络结构图

从图中可以看出,偏置项可以被表达为一个输出永远为1的节点。下面的公式给出了这个新的神经网络模型前向传播算法的计算方法:
隐藏层推导公式:

a11 = f(W_{1,1}^{(1)} +W_{2,1}^{(1)}+b_1^{(1)}) = f(0.7*0.2+0.9*0.3+(-0.5)) = f(-0.09) = 0.09
a_12 =f(W_{1,2}^{(1)} +W_{2,2}^{(1)}+b_2^{(1)}) = f(-0.28) = 0.28
a_13 = f(W_{1,3}^{(1)} +W_{2,3}^{(1)}+b_3^{(1)}) = f(0.36) = 0.36
输出层推导公式:

Y = f(W_{1,1}^{(2)}a_{11} +W_{1,2}^{(2)}a_{12}+W_{1,3}^{(2)}a_{13}+b_1^{(2)}) =f(0.09*0.6+0.28*0.1+0.36*(-0.2)+0.1) = f(0.11)=0.11
目前TensorFlow提供了7种不同的非线性激活函数,tf.nn.relu、tf.sigmoid和tf.tanh是其中比较常用的几个。当然TensorFlow也支持使用自己定义的激活函数。以下代码展示了如何通过TensorFlow实现上图中神经网络的前向传播算法:

a = tf.nn.relu(tf.matmul(x,w1)+biases1)
y = tf.nn.relu(tf.matmul(a,w2)+biases2)

从上面的代码可以看出,TensorFlow可以很好地支持使用了激活函数和偏置项的神经网络。

1.3多层网络解决异或运算

上面的两个小节详细介绍了线性变换的问题。在这一小节中,将通过一个实际问题来讲解深度学习的另外一个重要性质——多层变换。在神经网络的发展史上,一个很重要的问题就是异火问题。神经网络的理论模型由Warren McCulloch和Walter Pitts在1943年首次提出,并在1985年由FrankRosenblatt提出了感知机模型,从数学上完成了对神经网络的精确建模。感知机可以简单地理解为单层神经网络,下图给出的神经元结构就是感知机的网络结构:


感知机网络结构

感知机会先将输入进行加权和,然后再通过激活函数最后得到输出。这个结构就是一个没有隐藏层的神经网络。在上个世纪60年代,神经网络作为对人类大脑的模拟算法受到了很多关注。然而到了1969年,Marvin Minsky和Seymour Papert在Perceptrons:An Introduction to Computational Geometry一书中提出感知机是无法模拟亦或运算的。这里略去复杂的数学求证过程,而是通过TensorFlow游乐场来模拟一下通过感知机的网络结构来模拟亦或运算。下图显示了通过TensorFlow游乐场训练500轮之后的情况:


使用单层神经网络解决亦或问题的效果

上图使用了一个能够模拟亦或运算的数据集。亦或运算直观来说就是如果两个输入的符号相同时(同时为正或者同时为负)则输出为0,否则(一个证一个负)输出为1.从
图中可以看出,左下角(两个输入同时为负)和右上角(两个输入同时为正)的点为黑色(蓝色),而另外两个象限的点为灰色(橙色),这就是符合亦或运算的计算规则。上图将隐藏层的层数设置为0,这样就模拟了感知机的模型。通过500轮训练之后,可以看到这个感知机模型并不能将两种不同颜色的点分开,也就是说感知机无法模拟异或运算的功能。

当加入隐藏层之后,亦或问题就可以得到很好地解决,如下图所示:


使用深层神经网络解决亦或问题

上图显示了一个有4个节点的隐藏层的神经网络在训练500轮左右之后的效果。在图中,除了可以看到最右边的输出节点可以很好地区分不同颜色的点外,更加有意思的是,隐藏层的四个节点中,每个节点都有一个角是黑色的。这四个隐藏节点可以被认为代表了从输入特征中抽取的更高维的特征。比如第一个节点可以大致代表两个输入的逻辑与操作的结果(当两个输入都为正数时该节点输出为正数)。从这个例子中可以看到,深层神经网络实际上有组合特征提取的功能。这个特性对于解决不易提取特征向量的问题(比如图片识别、语音识别)有很大帮助。这也是深度学习在这些问题上更加容易取得突破性进展的原因。

2.损失函数的定义

上一节介绍了深度学习的一些性质,并且通过这些性质讲解了如何构造一个更加有效的神经网络。本节将具体介绍如何刻画不同神经网络模型的效果。神经网络模型的效果以及优化的目标是通过损失函数(loss function)来定义的。本节将介绍:
1.讲解适用于分类问题和回归问题的经典损失函数,并通过TensorFlow实现这些损失函数
2.介绍如何根据具体问题定义损失函数,并通过具体样例来说明不同损失函数对训练结果的影响。

2.1经典损失函数

分类问题和回归问题是监督学习的两个种类。这一小节将分别介绍分类问题和回归问题中使用到的经典损失函数。分类问题希望解决的是将不同的样本分到事先定义好的类别中。比如判断一个零件是否合格的问题就是一个二分类问题。在这个问题中,需要将样本(也就是零件)分到合格或是不合格两个类别中。
在解决判断零件是否合格的二分类问题时,在前面章节定义一个有单个输出节点的神经网络中,当这个节点的输出越接近0时,这个样本越有可能是不合格的;反之如果输出越接近1,则这个样本越有可能是合格的。为了给出具体的分类结果,可以取0.5作为阈值。凡是输出大于0.5的样本都认为是合格的,小于0.5的则是不合格的。然而这样的做法并不容易直接推广到多分类的问题。虽然设置多个阈值在理论上是可能的,但在解决实际问题的过程中一般不会这么处理。
通过神经网络解决多分类问题最常用的方法是设置n个输出节点,其中n为类别的个数。对于每一个样例,神经网络可以得到的一个n维数组作为输出结果。数组中的每一个维度(也就是每一个输出节点)对应一个类别。在理想情况下,如果一个样本属于类别k,那么这个类别所对应的的输出节点的输出值应该为1,而其他节点的输出都为0.以识别数字1为例,神经网络模型的输出结果越接近[0,1,0,0,0,0,0,0,0,0]越好。那么如何判断一个输出向量和期望的向量有多接近呢?交叉熵(cross entropy)是常用的评判方法之一。交叉熵刻画了两个概率分布之间的距离,它是分类问题中使用比较广的一种损失函数。
交叉熵是一个信息论中的概念,它原本是用来估算平均编码长度的。在本书中不过多讨论它原本的意义,而会通过它的公式以及具体的样例来讲解它对于评估分类效果的意义。给定两个概率分布p和q,通过q来表示p的交叉熵为:
H(p,q) = -\sum_xp(x)log q(x)
注意交叉熵刻画的是两个概率分布之间的距离,然而神经网络的输出却不一定是一个概率分布。概率分布刻画了不同事件发生的概率。当事件总数是有限的情况下,概率分布函数p(X=x)满足:


也就是说,任意事件发生的概率都在0和1之间,且总有某一个事件发生(概率的和为1)。如果将分类问题中“一个样例属于某一个类别”看成一个概率事件,那么训练数据的正确答案就符合一个概率分布。因为事件“一个样例属于不正确的类别”的概率为0,而“一个样例属于正确的类别”的概率为1.如何将神经网络前向传播得到的结果也变成概率分布呢?Softmax回归就是一个非常常用的方法。
Softmax回归本身可以作为一个学习算法来优化分类结果,但在TensorFlow中,Softmax回归的参数被去掉了,它只是一层额外的处理层,将神经网络的输出变成一个概率分布。下图展示了加上Softmax回归的神经网络结构图:
通过Softmax层将神经网络输出变成一个概率分布

假设原始的神经网络输出为y1,y2....yn,那么经过Softmax回归处理之后的输出为:

softmax(y)i = y'i = \frac{e^{yi}}{\sum^n_{j=1^{e^{yj}}}}
从以上公式中可以看出,原始神经网络的输出被用作置信度来生成新的输出,而新的输出满足概率分布的所有要求。这个新的输出可以理解为经过神经网络的推导,一个样例为不同类别的概率分别是多大。这样就把神经网络的输出也变成了一个概率分布,从而可以通过交叉熵来计算预测的概率分布和真实答案的概率分布之间的距离了。
从交叉熵的公式中可以看到交叉熵函数不是对称的(H(p,q) \not=H(q,p)),它刻画的是通过概率分布q来表达概率分布p的困难程度。因为正确答案是希望得到的结果,所以当交叉熵作为神经网络的损失函数时,p代表的是正确答案,q代表的是预测值。交叉熵刻画的是两个概率分布的距离,也就是说交叉熵值越小,两个概率分布越接近。下面将给出两个具体样例来直观地说明通过交叉熵可以判断预测答案和真实答案之间的距离。假设有一个三分类问题,某个样例的正确答案是(1,0,0)。某模型经过Softmax回归之后的预测答案是(0.5,0.4,0.1),那么这个预测和正确答案之间的交叉熵为:

H((1,0,0),(0.5,0.4,0.1)) = -(1*log0.5 + 0*log0.4 +log0.1) \approx 0.3
如果另外一个模型的预测是(0.8,0.1,0.1),那么这个预测值和真实值之间的交叉熵是:H((1,0,0),(0.8,0.1,0.1)) = -(1*log0.8 + 0*log0.1 +log0.1) \approx 0.1
从直观上可以很容易地知道第二个预测答案要优于第一个。通过交叉熵计算得到的结果也是一致的(第二个交叉熵的值更小)。在前面,已经通过TensorFlow实现过交叉熵,其代码实现如下:

cross_entropy = -tf.reduce_mean(
    y_*tf.log(tf.clip_by_value(y, 1e-10, 1.0)))

其中y_代表正确结果,y代表预测结果。本小节将更加具体的讲解这个计算过程。这一行代码包含了四个不同的TensorFlow运算。通过tf.clip_by_value函数可以将一个张量中的数值限制在一个范围之内,这样可以避免一些运算错误(比如log0是无效的)。下面给出了使用tf.clip_by_value的简单样例:

v = tf.constant([[1.0,2.0,3.0],[4.0,5.0,6.0]])
with sess.as_default():
    print(tf.clip_by_value(v,2.5,4.5).eval())

运行代码,结果如下:
[[ 2.5 2.5 3. ]
[ 4. 4.5 4.5]]
从上面的样例可以看到,小于2.5的数都被换成了2.5,而大于4.5的数都被换成了4.5.这样通过tf.clip_by_value函数就可以保证在进行log运算时,不会出现log0这样的错误或者大于1的概率。第二个运算是tf.log函数,这个函数完成了对张量中所有元素依次求对数的功能。以下代码给出一个简单的样例:

v = tf.constant([1.0,2.0,3.0])
with sess.as_default():
    print(tf.log(v).eval())

运行代码,结果如下:
[ 0. 0.69314718 1.09861231]
第三个运算是乘法,在实现交叉熵的代码中直接将两个矩阵通过“*”操作相乘。这个操作不是矩阵乘法,而是元素之间直接相乘。矩阵乘法需要使用tf.matmul函数来完成。下面给出了这两个操作的区别:

v1 = tf.constant([[1.0,2.0],[3.0,4.0]])
v2 = tf.constant([[5.0,6.0],[7.0,8.0]])
with sess.as_default():
    print((v1*v2).eval())
    print(tf.matmul(v1,v2).eval())

运行代码,得到如下所示的结果:
[[ 5. 12.] [ 21. 32.]]
[[ 19. 22.] [ 43. 50.]]
结果分析:v1*v2的结果是每个位置上对应元素的乘积。比如(1,1)这个元素的值是:v1[1,1]*v2[1,1] = 15=5
(1,2)这个元素的值是:v1[1,2]*v2[1,2] = 2
6=12
以此类推。而tf.matmul函数完成的是矩阵乘法运算,所以(1,1)这个元素的值是:
v1[1,1]*v2[1,1]+v1[1,2]*v2[2,1] = 1*5+2*7=19
通过上面这三个运算完成了对于每一个样例中的每一个类别交叉熵p(x)logq(x)的计算。这三步计算得到的结果是一个n*m的二维矩阵,其中n为一个batch中样例的数量,m为分类的类别数量。根据交叉熵的公式,应该将每行中的m个结果相加得到所有样例的交叉熵,然后再对这n行取平均得到一个batch的平均交叉熵。但因为分类问题的类别数量是不变的,所以可以直接对整个矩阵做平均而并不改变计算结果的意义。这样的方式可以使整个程序更加简洁。以下代码展示了tf.reduce_mean函数的使用方法:

v = tf.constant([[1.0,2.0,3.0],[4.0,5.0,6.0]])
with sess.as_default():
    print(tf.reduce_mean(v).eval())

运行代码,结果为:3.5
因为交叉熵一般会与Softmax回归一起使用,所以TensorFlow对这两个功能进行了统一封装,并提供了tf.nn.softmax_cross_entropy_with_logits函数。比如可以直接通过下面的代码来实现使用了Softmax回归之后的交叉熵损失函数:
cross_entropy = tf.nn.softmax_cross_entropy_with_logits(y,y_)
其中y代表了原始神经网络的输出结果,而y_给出了标准答案。这样通过一个命令就可以得到使用了Softmax回归之后的交叉熵。在只有一个正确答案的分类问题中,TensorFlow提供了tf.nn.sparse_softmax_cross_entropy_with_logits函数来进一步加速计算过程。
与分类问题不同,回归问题解决的是对具体数值的预测。比如房价预测、销量预测等都是回归问题。这些问题需要预测的不是一个事先定义好的类别,而是一个任意实数。解决回归问题的神经网络一般只有一个输出节点,这个节点的输出值就是预测值。对于回归问题,最常用的损失函数是均方误差(MSE,mean sqared error)。它的定义如下:
MSE(y,y') = \frac{\sum^n_{i=1}(yi-yi')^2}{n}
其中yi为一个batch中第i个数据的正确答案,而yi'为神经网络给出的预测值,以下代码展示了如何通过TensorFlow实现均方误差损失函数:
mse = tf.reduce_mean(tf.square(y_ - y))
其中y代表了神经网络的输出答案,y_代表了标准答案。

2.2自定义损失函数

TensorFlow不仅支持经典的损失函数,还可以优化任意的自定义损失函数。本小节将介绍如何通过自定义损失函数的方法,使得神经网络优化的结果更加接近实际问题的需求。在下面的篇幅中将以预测商品销量问题为例。
在预测商品销量时,如果预测多了(预测值比真实销量大),商家损失的是生产商品的成本;而如果预测少了(预测值比真实销量小),商家损失的是商品的利润。因为一般商品的成本和利润不会严格相等,所以使用上一节中介绍的均方误差损失函数就不能够很好地最大化销售利润。比如如果一个商品的成本是1元,但是利润是10元,那么少预测一个就少挣10元;而多预测一个才少挣1元。如果神经网络模型最小化的是均方误差,那么很有可能就无法最大化预期的利润。为了最大化预期利润,需要将损失函数和利润直接联系起来。注意损失函数定义的是损失,所以要将利润最大化,定义的损失函数应该刻画成本或者代价。以下公式给出了一个当预测多与真实值和预测少于真实值时有不同系数的损失函数:
Loss(y,y') = \sum^n_{i=1}f(y_i',y_i'), f(x,y),f(x,y) = \begin{Bmatrix} a(x-y) x>y\\ b(y-x) x<=y \end{Bmatrix}
和均方误差公式类似,yi为一个batch中第i个数据的正确答案,yi'为神经网络得到的预测值,a和b是常量。比如在上面介绍的销量预测问题中,a就等于10(正确答案多余预测答案的代价),而b等于1(正确答案少于预测答案的代价)。通过对这个自定义损失函数的优化,模型提供的预测值更有可能最大化收益。在TensorFlow中,可以通过以下代码来实现这个损失函数。
loss = tf.reduce_sum(tf.where(tf.greater(v1,v2),(v1-v2)*a,(v2-v1)*b))
上面的代码用到了tf.greater和tf.where来实现选择操作。tf.greater的输入时两个张量,此函数会比较这两个输入张量中每一个元素的大小,并返回比较结果。当tf.greater的输入张量维度不一样时,TensorFlow会进行类似Numpy广播操作(broadcasting)的处理。tf.where函数有三个参数。第一个为选择条件根据,当选择条件为True时,tf.where函数会选择第二个参数中的值,否则使用第三个参数中的值。注意tf.where函数判断和选择都是在元素级别进行,以下代码展示了tf.select函数和tf.greater函数的用法。

v1 = tf.constant([1.0,2.0,3.0,4.0])
v2 = tf.constant([4.0,3.0,2.0,1.0])
with sess.as_default():
    print(tf.greater(v1,v2).eval())
    print(tf.where(tf.greater(v1,v2),v1,v2).eval())

运行代码,得到如下结果:
[False False True True]
[ 4. 3. 3. 4.]

在定义了损失函数之后,下面将通过一个简单的神经网络程序来讲解损失函数对模型训练结果的影响。在下面这个程序中,实现了一个拥有两个输入节点、一个输出节点,没有隐藏层的神经网络:

import tensorflow as tf
from numpy.random import RandomState
batch_size = 8
# 两个输入节点
x = tf.placeholder(tf.float32,shape=(None,2),name='x-input')
# 回归问题一般只有一个输出节点
y_ = tf.placeholder(tf.float32,shape=(None,1),name='y-input')

#定义了一个单层的神经网络前向传播的过程,这里就是简单加权和
w1 = tf.Variable(tf.random_normal([2,1],stddev=1,seed=1))
y = tf.matmul(x,w1)

# 定义预测多了和预测少了的成本
loss_less = 10
loss_more = 1
loss = tf.reduce_sum(tf.where(tf.greater(y,y_),
                              (y-y_)*loss_more,
                              (y_-y)*loss_less))
train_step = tf.train.AdamOptimizer(0.001).minimize(loss)
# 通过随机数生成一个模拟数据集
rdm = RandomState(1)
dataset_size = 128
X = rdm.rand(dataset_size,2)
# 设置回归的正确值为两个输入的和加上一个随机变量。之所以要加上一个随机量是为了加入不可预测的噪音,
# 否则不同损失函数的意义就不大了,因为不同损失函数都会在能完全预测正确的时候最低。一般来说噪音为
#一个均值为0的小量,所以这里的噪音设置为-0.05~0.05的随机数。
Y = [[x1+x2+rdm.rand()/10.0-0.05] for (x1,x2) in X]

# 训练神经网路
with tf.Session() as sess:
    init_op = tf.initialize_all_variables()
    sess.run(init_op)
    STEPS = 5000
    for i in range(STEPS):
        start = (i * batch_size) % dataset_size
        end = min(start+batch_size,dataset_size)
        sess.run(train_step,feed_dict = {x:X[start:end],y_:Y[start:end]})
        print(sess.run(w1))

运行代码,得到如下结果:


损失函数对模型训练结果的影响

从上面结果可以得到w1的值为[1.01934695,1.04280889],也就是说得到的预测函数是x1+x2,这要比1.02x1+1.04x2大,因为在损失函数中指定预测少了的损失更大(loss_less>loss_more)。如果将loss_less的值调整为1,loss_more的值调整为10,那么w1的值将会是[0.955,0.9813].也就是说,在这样的设置下,模型会更加偏向于预测少一点。而如果使用均方误差作为损失函数,那么w1会是[0.9743,1.0243]。使用这个损失函数会尽量让预测值离标准答案更近。通过这个样例可以感受到,对于相同的神经网络,不同损失函数会对训练得到的模型产生重要影响。

3.神经网络优化算法

本节将更加具体地介绍如何通过反向传播算法和梯度下降算法调整神经网络中参数的取值。梯度下降算法主要用于优化单个参数的取值,而反向传播算法给出了一个高效的方式在所有参数上使用梯度下降算法,从而使神经网络模型在训练数据上的损失函数尽可能小。反向传播算法是训练神经网络的核心算法,它可以根据定义好的损失函数优化神经网络中参数的取值,从而使神经网络模型在训练数据集上的损失函数打到一个较小值。神经网络模型中参数的优化过程直接决定了模型的质量,是使用神经网络时非常重要的一步。在本节中,将主要介绍神经网络优化过程的基本概念和主要思想。本节将给出一个具体的样例来解释使用梯度下降算法优化参数取值的过程。下一节将继续介绍神经网络优化过程中可能遇到的问题和解决方法。

假设用\theta表示神经网络中的参数,J(\theta)表示在给定的参数取值下,训练数据集上损失函数的大小,那么整个优化过程可以抽象为寻找一个参数\theta,使得J(\theta)最小。因为目前没有一个通用的方法可以对任意损失函数直接求解最佳的参数取值,所以在实践中,梯度下降算法是最常用的神经网络优化方法。梯度下降算法会迭代式更新参数\theta,不断沿着梯度的反方向让参数朝着总损失更小的方向更新。下图展示了梯度下降算法的原理:

梯度下降算法思想示意图

上图中x轴表示参数的取值,y轴表示损失函数的值。曲线表示了参数取不同值时,对应损失函数的大小。假设当前的参数和损失值对应上图中小圆点的位置,那么梯度下降算法会将参数向x轴左侧移动,从而使得小圆点朝着箭头的方向移动。参数的梯度可以通过求偏导的方式计算,对于参数,其梯度.有了梯度,还需要定义一个学习率来定义每次参数更新的幅度。从直观上理解,可以认为学习率定义的就是每次参数移动的幅度。通过参数的梯度和学习率,参数更新的公式为:

下面给出了一个具体的例子来说明梯度下降算法是如何工作的。假设要通过梯度下降算法来优化参数x,使得损失函数的值尽量小。梯度下降算法的第一步需要随机产生一个参数x的初始值,然后通过梯度和学习率来更新参数x的取值。在这个样例中,参数x的梯度为,那么使用梯度下降算法每次对参数x的更新公式为.假设参数的初始值为5,学习率为0.3,那么这个优化过程可以总结为下表所示:
使用梯度下降算法优化函数J(x)=x^2

从上表可以看出,经过5次迭代之后,参数x的值变成了0.0512,这个和参数最优值0已经比较接近了。虽然这里给出的是一个非常简单的样例,但是神经网络的优化过程是可以类推的。神经网络的优化过程可以分为两个阶段,第一个阶段先通过前向传播算法计算得到预测值,并将预测值和真实值作对比得出两者之间的差距。然后在第二个阶段通过反向传播算法计算损失函数对每一个参数的梯度,再根据梯度和学习率使用梯度下降算法更新每一个参数。
需要注意的是,梯度下降算法并不能保证被优化的函数达到全局最优解。如下图:
梯度下降算法得不到全局最小值的样例

图中给出的函数就有可能只能得到局部最优解而不是全局最优解。在小黑点处,损失函数的偏导为0,于是参数就不会再进一步更新。在这个样例中,如果参数x的初始值落在右侧深色的区间中,那么通过梯度下降得到的结果都会落到小黑点代表的局部最优解。只有当x的初始值落在左侧浅色的区间时梯度下降才能给出全局最优答案。由此可见在训练神经网络时,参数的初始值会很大程度影响最后得到的结果。只有当损失函数为凸函数时,梯度下降算法才能保证达到全局最优解。
除了不一定能达到全局最优外,梯度下降算法的另一个问题就是计算时间太长。因为要在全部训练数据上最小化损失函数,所以损失函数是在所有训练数据上的损失和。这样在每一轮迭代中都需要计算在全部训练数据上的损失函数。在海量训练数据下,要计算所有训练数据的损失函数是非常耗时的。为了加速训练过程,可以使用随机梯度下降的算法。这个算法优化的不是在全部训练数据上的损失函数,而是在每一轮迭代中,随机优化某一条训练数据上的损失函数。这样每一轮参数更新的速度就大大加快了。因为随机梯度下降算法每次优化的只是某一条数据上的损失函数,所以它的问题也非常明显:在某一条数据上损失函数更小但并不代表在全部数据上损失函数更小,于是使用随机梯度下降优化得到的神经网络甚至可能无法达到局部最优。
为了综合梯度下降算法和随机梯度下降算法优缺点,在实际应用中一般蚕蛹这两个算法的折中——每次计算一小部分训练数据的损失函数。这一小部分数据被称之为一个batch。通过矩阵运算,每次在一个batch上优化神经网络的参数并不会比单个数据慢很多。另一方面,每次使用一个batch可以大大减小收敛所需要的迭代次数,同时可以使收敛到的结果更加接近梯度下降的效果。以下代码给出了在TensorFlow中如何实现神经网络的训练过程。在本书的样例中,神经网络的训练都大致遵循以下过程:

import tensorflow as tf
batch_size = n
# 每次读取一小部分数据作为当前的训练数据来执行反向传播算法。
x = tf.placeholder(tf.float32,shape=(batch_size,2),name='x-input')
y_ = tf.placeholder(tf.float32,shape=(batch_size,1),name='y-input')
# 定义神经网络结构和优化算法
loss = ...
train_step = tf.train.AdamOptimizer(0.001).minimize(loss)
# 训练神经网络
with tf.Session() as sess:
    # 参数初始化
    ...
    # 迭代的更新参数
    for i in range(STEPS):
        # 准备batch_size个训练数据。一般将所有训练数据随机打乱之后再选取可以得到更好的优化效果
        current_X,current_Y = ...
        sess.run(train_step,feed_dict={x:current_X,y_:current_Y})

4.神经网络进一步优化

上一节介绍了优化神经网络的基本算法,本节将继续介绍神经网络优化过程中可能遇到的一些问题,以及解决这些问题的常用方法。我们将介绍:
1.通过指数衰减的方法设置梯度下降算法中的学习率。通过指数衰减的学习率既可以让模型在训练的前期快速接近较优解,又可以保证模型在训练后期不会又太大的波动,从而更加接近局部最优。
2.过拟合问题,在训练复杂神经网络模型时,过拟合是一个非常常见的问题。我们将介绍这个问题的影响以及解决这个问题的主要方法。
3.滑动平均模型。滑动平均模型会将每一轮迭代得到的模型综合起来,从而使得最终得到的模型更加健壮。

4.1学习率的设置

在上节介绍了在训练神经网络时,需要设置学习率控制参数更新的速度。本小节将进一步介绍如何设置学习率。学习率决定了参数每次更新的幅度。如果幅度过大,那么可能导致参数在极优值的两侧来回移动。前面介绍过优化J(x)=x^2函数的样例。如果在优化中使用的学习率为1,那么真个优化过程将会如下表所示:

当学习率过大时,梯度下降算法的运行过程

从上面的样例可以看出,无论进行多少轮迭代,参数将在5和-5之间摇摆,而不会收敛到一个极小值。相反,当学习率过小时,虽然能保证收敛性,但是这会大大降低优化速度。我们会需要更多轮的迭代才能打到一个比较理想的优化效果。比如当学习率为0.001时,迭代5次之后,x的值将为4.95.要将x训练到0.05需要大约2300轮;而当学习率为0.3时,只需要5轮就可以达到。综上所述,学习率既不能过大,也不能过小。为了解决设定学习率的问题,TensorFlow提供了一种更加灵活地学习率设置方法——指数衰减法。tf.train.exponential_decay函数实现了指数衰减学习率。通过这个函数,可以先使用较大的学习率来快速得到一个比较优的解,然后随着迭代的继续逐步减小学习率,使得模型在训练后期更加稳定。exponential_decay函数会指数级地减小学习率,它实现了以下代码的功能:
decayed_learning_rate = leaning_reate * decay_rate ^(global_step/decay_steps)
其中decayed_learning_rate为每一轮优化时使用的学习率,learning_rate为事先设定的初始学习率,decay_rate为衰减系数,decay_steps为衰减速度。
下图显示了随着迭代轮数的增加,学习率逐步降低的过程:
指数衰减学习率随着迭代轮数的变化图

tf.train.exponenttial_decay函数可以通过设置参数staircase选择不同的衰减方式。staircase的默认值为False,这时学习率随着迭代轮数变化的趋势如上图灰色曲线所示。当staircase的值被设置为True时,global_step/decay_steps会被转化为整数。这使得学习率称为一个阶梯函数(staircase function)图中黑色曲线显示了阶梯状的学习率。在这样的设置下,decay_steps通常代表了完整的使用一遍训练数据所需要的迭代轮数。这个迭代轮数也就是总训练样本数除以每一个batch中的训练样本数。这种设置的常用场景是每完整地过完一遍训练数据,学习率就减小一次。这可以使得训练数据集中的所有数据对模型训练有相等的作用。当使用连续的指数衰减学习率时,不同的训练数据有不同的学习率,而当学习率减小时,对应的训练数据对模型训练结果的影响也就小了。
下面给出了一段代码来示范如何在TensorFlow中使用tf.train.exponential_decay函数:

global_step = tf.Variable(0)
# 通过exponential_decay函数生成学习率
learning_rate = tf.train.exponential_decay(0,1,global_step,100,0.96,stairecase=True)
# 使用指数衰减的学习率。在minimize函数中传入global_step将自动更新
# global_step参数,从而使得学习率也得到了相应更新
learning_step = tf.train.GradientDescentOptimizer(learning_rate).minimize(...my loss...,global_step=global_step)

上面这段代码中设定了初始学习率为0.1,因为指定了staircase=True,所以每训练100轮后学习率乘以0.96.一般来说初始学习率、衰减系数和衰减速度都是根据经验设置的。而且损失函数下降的速度和迭代结束之后总损失的大小没有必然的联系。也就是说并不能通过前几轮损失函数下降的速度比较不同神经网络的效果。

4.2过拟合问题

前面讲述了如何在训练数据上优化一个给定的损失函数。然而在真实的应用中想要的并不是让模型尽量模拟训练数据的行为,而是希望通过训练出来的模型对未知的数据给出判断。模型在训练数据上的表现并不一定代表了它在未知数据上的表现。本小节将介绍的过拟合问题就是可以导致这个差距的一个很重要因素。所谓过拟合,指的是当一个模型过为复杂之后,它可以很好地“记忆”每一个训练数据中随机噪音的部分而忘记了要去“学习”训练数据中通用的趋势。举一个极端的例子,如果一个模型中的参数比训练数据的总数还多,那么只要训练数据不冲突,这个模型完全可以记住所有训练数据的结果从而使得损失函数为0.可以直观地想象一个包含n个变量和n个等式的方程组,当方程不冲突时,这个方程组是可以通过数学的方法来求解的。然而,过度拟合训练数据中的随机噪音虽然可以得到非常小的损失函数,但是对于位置数据可能无法做出可靠的判断。
下图显示了模型训练的三种不同情况:

神经网络模型训练的三种情况

在第一种情况下,由于模型过于简单,无法刻画问题的趋势。第二个模型是比较合理的,它既不会过于关注训练数据中的噪音,又能够比较好地刻画问题的整体趋势。第三个模型就是过拟合了,虽然三个模型完美地划分了不同形状的点,但是这样的划分并不能很好地对未知数据做出判断,因为它过度拟合了训练数据中的噪音而忽视了问题的整体规律。
为了避免过拟合问题,一个非常常用的方法就是正则化。正则化的思想就是在损失函数中加入刻画模型复杂程度的指标。假设用于刻画模型在训练数据上表现的损失函数为,那么在优化时不是直接优化,而是优化.其中R(w)刻画的是模型的复杂程度,而表示模型复杂损失在总损失中的比例。注意这里表示的是一个神经网络中所有的参数,它包括边上的权重w和偏置项b。一般来说模型复杂度只由权重w决定。常用的刻画模型复杂度的函数R(w)有两种,一种是L1正则化,计算公式是:

另一种是L2正则化,计算公式是:

无论是哪一种正则化方式,基本的思想都是希望通过限制权重的大小,使得模型不能任意拟合训练数据中的随机噪音。但这两种正则化的方法也有很大的区别。首先,L1正则化会让参数变得更稀疏,而L2正则化不会。所谓参数变得更稀疏是指会有更多的参数变为0,这样可以达到类似特征选取的功能。之所有L2正则化不会让参数变得稀疏的原因是当参数很小时,比如0.001,这个参数的平方基本上就可以忽略了,于是模型不会进一步将这个参数调整为0.其次,L1正则化的计算公式不可导,而L2正则化公式可导。因为在优化时需要计算损失函数的偏导数,所以对含有L2正则化损失函数的优化要更加简洁。优化带L2正则化的损失函数要更加复杂,而且优化方法也有很多种。在实践中,也可以将L1正则化和L2正则化同时使用:

前面提到过TensorFlow可以优化任意形式的损失函数,所以TensorFlow自然也可以优化带正则化的损失函数。以下代码给出了一个简单的带L2正则化的损失函数定义:

w = tf.Variable(tf.random_normal([2,1],stddev=1,seed=1))
y = tf.matmul(x,w)
loss = tf.reduce_mean(tf.square(y_-y))+tf.contrib.layers.l2_regularizer(lambda)(w)

在上面的程序中,loss为定义的损失函数,它由两个部分组成。第一个部分是前面介绍的均方误差损失函数,它刻画了模型在训练数据集上的表现。第二个部分是正则化,它防止模型过度模拟训练数据中的随机噪音。lambda参数表示了正则化项的权重,也就是公式J(\theta)+\lambda R(w)中的\lambda.w为需要计算正则化损失的参数。TensorFlow提供了tf.contrib.layers.l2_regularizer函数,它可以返回一个函数,这个函数可以计算一个给定参数的L2正则化项的值。类似的,tf.contrib.layers.l1_regularizer可以计算L1正则化项的值。以下代码给出了使用这两个函数的样例:

import tensorflow as tf
weights = tf.constant([[1.0,-2.0],[-3.0,4.0]])
with tf.Session() as sess:
    print(sess.run(tf.contrib.layers.l1_regularizer(.5)(weights)))
    print(sess.run(tf.contrib.layers.l2_regularizer(.5)(weights)))

运行代码,得到如下结果:
5.0
7.5
在简单的神经网络中,这样的方式就可以很好地计算带正则化的损失函数了。但当神经网络的参数增多之后,这样的方式首先可能导致损失函数loss的定义很长,可读性差且容易出错。但更主要的是,当网络结构复杂之后定义网络结构的部分和计算损失函数的部分可能不在同一个函数中,这样通过变量这种方式计算损失函数就不方便了。为了解决这个问题,可以使用TensorFlow中提供的集合。集合的概念在前面章节介绍过,它可以在一个计算图中保存一组实体。以下代码给出了通过集合计算一个5层神经网络带L2正则化的损失函数的计算方法:

import tensorflow as tf
# 获取一层神经网络的权重,并将这个权重的L2正则化损失加入名称为'losses'的集合中
def get_weight(shape, lambda):
    # 生成一个变量
    var = tf.Variable(tf.random_normal(shape), dtype=tf.float32)
    # add_to_collection函数将这个新生成变量的L2正则化损失项加入集合
    tf.add_to_collection('losses', tf.contrib.layers.l2_regularizer(lambda)(var))
    # 返回生成的变量
    return var
x = tf.placeholder(tf.float32,shape=(None,2))
y_ = tf.placeholder(tf.float32,shape=(None,1))
batch_size = 8
# 定义了每一层网络中节点的个数
layer_dimension = [2, 10, 10, 10, 1]
# 神经网络的层数
n_layers = len(layer_dimension)
# 这个变量维护前向传播时最深层的节点,开始的时候就是输入层。
cur_layer = x
# 当前层的节点个数
in_dimension = layer_dimension[0]
# 通过一个循环来生成5层全连接的神经网络结构
for i in range(1, n_layers):
    # layer_dimension[i]为下一层的节点个数
    out_dimension = layer_dimension[i]
    # 生成当前层中权重的变量,并将这个变量的L2正则化加入计算图上的集合
    weight = get_weight([in_dimension, out_dimension], 0.001)
    bias = tf.Variable(tf.constant(0.1,shape=[out_dimension]))
    # 使用ReLU激活函数
    cur_layer = tf.nn.relu(tf.matmul(cur_layer,weight) + bias)
    # 进入下一层之前将下一层的节点个数更新为当前层节点个数
    in_dimension = layer_dimension[i]

# 在定义神经网络前向传播的同时已经将所有的L2正则化损失加入了图上的集合,
#这里只需要计算刻画模型在训练数据上表现的损失函数
mse_loss = tf.reduce_mean(tf.square(y_ - cur_layer))    
# 将均方误差损失函数加入损失集合
tf.add_to_collection('losses', mse_loss)
# get_collection返回一个列表,这个列表是所有这个集合中的元素。在这个样例中,
# 这些元素就是损失函数的不同部分,将它们加起来就可以得到最终的损失函数。
mse_loss = tf.reduce_mean(tf.square(y_ - cur_layer))
# 将均方误差损失函数加入损失集合
tf.add_to_collection('losses', mse_loss)
# get_collection返回一个列表,这个列表是所有这个集合中的元素。在这个样例中,
# 这些元素就是损失函数的不同部分,将它们加起来就可以得到最终的损失函数
loss = tf.add_n(tf.get_collection('losses'))

从上面的代码可以看出通过使用集合的方法在网络结构比较复杂的情况下可以使代码的可读性更高。上面的代码给出的是一个只有5层的全连接网络,在更加复杂的网络结构中,是用这样的方式来计算损失函数将大大增强代码的可读性。

4.3滑动平均模型

这一小节将介绍另外一个可以使模型在测试数据上更加健壮的方法——滑动平均模型。在采用随机梯度下降算法训练神经网络时,使用滑动平均模型在很多应用中都可以在一定程度上提高最终模型在测试数据上的表现。
在TensorFlow中提供了tf.train.ExponentialMovingAverage来实现滑动平均模型。在初始化ExponentialMovingAverage时,需要提供一个衰减率。这个衰减率将用于控制模型更新的速度。ExponentialMovingAverage对每一个变量会维护一个影子变量,这个影子变量的初始值就是相应变量的初始值,而每次运行变量更新时,影子变量的值会更新为:
shadow_variable = decayshadow_variable+(1-decay)variable
其中shadow_variable为影子变量,variable为待更新的变量,decay为衰减率。从公式中可以看到,decay决定了模型更新的速度,decay越大模型越趋于稳定。在实际应用中,decay一般会设立非常接近1的数(比如0.999和0.9999)。为了使得模型在训练前期可以更新得更快,ExponentialMovingAverage还提供了num_updates参数来动态设置decay的大小。如果在ExponentialMovingAverage初始化时提供了num_updates参数,那么每次使用的衰减率将是:
min \begin{Bmatrix} decay,\frac{1+num_updates}{10+num_updates} \end{Bmatrix}
下面通过一段代码来解释ExponentialMovingAverage是如何被使用的:

import tensorflow as tf
# 定义一个变量用于计算滑动平均,这个变量的初始值为0.注意这里手动指定了变量的类型
# 为tf.float32,因为所有需要计算滑动平均的变量必须是实数型。
v1 = tf.Variable(0,dtype=tf.float32)
# 这里step变量模拟神经网络中迭代的轮数,可以用于动态控制衰减率。
step = tf.Variable(0, trainable = False)
# 定义一个滑动平均的类(class)。初始化时给定了衰减率(0.99)和控制衰减率的变量step。
ema = tf.train.ExponentialMovingAverage(0.99, step)
# 定义一个更新变量滑动平均的操作。这里需要给定一个列表,每次执行这个操作时
# 这个列表中的变量都会被更新。
maintain_averages_op = ema.apply([v1])
with tf.Session() as sess:
    # 初始化所有变量。
    init_op = tf.initialize_all_variables()
    sess.run(init_op)
    # 通过ema.average(v1)获取滑动平均之后变量的取值。在初始化之后变量v1的值和v1的滑动平均值都为0.
    print(sess.run([v1,ema.average(v1)]))
    # 更新变量v1的值到5
    sess.run(tf.assign(v1,5))
    # 更新v1的滑动平均值。衰减率为min{0.99,(1+step)/(10+step) = 0.1} = 0.1,
    # 所以v1的滑动平均会被更新为0.1*0+0.9*5 = 4.5
    sess.run(tf.assign(v1,5))
    print(sess.run([v1,ema.average(v1)]))
    
    # 更新step的值为10000
    sess.run(tf.assign(step,10000))
    # 更新v1的值为10.
    sess.run(tf.assign(v1,10))
    # 更新v1的滑动平均值。衰减率为min{0.99,(1+step)/(10+step) ==0.999} = 0.99,
    # 所以v1的滑动平均会被更新为0.99*4.5+0.01*10 = 4.555
    sess.run(maintain_averages_op)
    print(sess.run([v1,ema.average(v1)]))
    # 再次更新滑动平均值,得到的新滑动平均值
    sess.run(maintain_averages_op)
    print(sess.run([v1,ema.average(v1)]))

运行代码,得到结果如下所示:


ExponentialMovingAverage的简单样例

上面的代码给出了ExponentialMovingAverage的简单样例,在后面的章节中将会给出在真实应用中使用滑动平均的样例。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容