Tensorflow Layer指南-创建卷积神经网络

我们都知道,神经网络是由一层一层的神经元组合而成的,每个层之间可以通过不同的方式来连接起来以构成不同结构的神经网络。Tensorflow的layer模块为我们提供了一组抽象层级很高的API,让我们可以轻松地构建一个我们想要的神经网络。我们可以通过layer对象的方法来很方便的实现我们常见的一些对神经网络层操作,例如添加激活函数,应用dropout regularization减少过拟合等。在本教程中,您将学习如何使用layer对象构建卷积神经网络模型来识别MNIST数据集中的手写数字。

可能学过机器学习和神经网络教程的同学们对于MNIST数据集中手写数字识别这个例子应该很熟悉了,但是为了让没有学过的小白们能看懂这篇教程,还是有必要介绍一下什么是MNIST数据集:

MNIST数据集包含60,000个训练样例和10,000个手写数字0-9的测试示例,格式为28x28像素单色图像。


MINIST数据集

卷积神经网络的介绍

卷积神经网络——Convolutional neural networks (通常缩写为 CNN)是当前用于执行图像分类任务的最先进也是最常用的神经网络结构。 CNN将一系列滤波器应用于图像的原始像素数据以提取和学习更高级别的特征,使得该模型能够将这些特征用于分类。 CNN包含三个组件:

  1. 卷积层(Convolutional layers),将特定数量的卷积滤镜(convolution filters)应用于图像。 对于每个子区域,图层执行一组数学运算以在输出特征映射中生成单个值。 卷积层通常将ReLU激活函数应用于输出以将非线性引入到模型中。
  2. 合并层(Pooling layers),负责对由卷积层提取的图像数据进行下采样以减少特征映射的维度以提高处理效率。 常用的池化算法是最大池化(max polling),其提取特征地图的子区域(例如,2×2像素的块),保持它们的最大值并丢弃所有其他值。
  3. 密集层(Dence layers),对由卷积图层提取的特征并由共用图层进行下采样(downsampled)执行分类。 密集层是全连接的神经网络,在密集层中,图层中的每个节点都连接到前一图层中的每个节点。

通常,CNN由执行特征提取的一组卷积模块组成,每个模块又由一个卷积层和一个合并层组成。 最后的卷积模块之后是一个或多个执行分类的密集层。 CNN中的最终密集层的节点数量是与所有目标类型的数量一致的,即模型可能预测的所有可能的目标类型,使用softmax激活函数为每个节点生成0-1之间的值(全部 这些softmax值等于1), 我们可以将给定图像的softmax值解释为图像落入每个目标类别的可能性的相对测量值。

用Tensorflow创建基于CNN的MNIST数据分类器

让我们建立一个拥有以下结构的CNN,来对MNIST数据集中的图像进行分类:

  1. 卷积层#1:包含32个5x5滤波器(提取5x5像素子区域),使用ReLU激活函数。
  2. 合并层#1:包含32个2x2滤镜,并按照最大池化的策略提取的数据执行步幅为2的池化操作。(其指定池区域不重叠)
  3. 卷积层#2:包含64个5x5滤波器,使用ReLU激活函数。
  4. 合并层#2:同样,使用2x2滤波器和2步幅进行最大池化。
  5. 密集层#1:包含1,024个神经元,dropout regularization的比率为0.4。
  6. 密集层#2(Logits Layer):10个神经元,每个数字目标类别(0-9)一个。

下面让我们隆重请出今天的主角tf.layer,该模块包含创建上述三种图层类型的方法:

  • conv2d() ,构造一个二维卷积层。 采用过滤器数量,过滤内核大小,填充和激活函数作为参数。
  • max_pooling2d() ,使用max-pooling算法构造一个二维池化层。 参数为过滤器大小和步幅。
  • dense() 构建一个密集层。 以神经元数量和激活函数作为参数。

这些方法中的每一个都接受tensor作为输入,并将变换后的tensor作为输出返回。 这样可以很容易地将一个神经层连接到另一个神经层:只需从一个神经层创建方法中获取输出并将其作为输入提供给另一个神经层。
现在我们添加以下cnn_model_fn函数,该函数符合TensorFlow的Estimator API预期的接口。 cnn_mnist.py将MNIST特征数据,标签和模型模式(TRAIN,EVAL,PREDICT)作为参数; 配置CNN; 并返回预测,损失和培训操作:

def cnn_model_fn(features, labels, mode):
  """Model function for CNN."""
  # Input Layer
  input_layer = tf.reshape(features["x"], [-1, 28, 28, 1])

  # Convolutional Layer #1
  conv1 = tf.layers.conv2d(
      inputs=input_layer,
      filters=32,
      kernel_size=[5, 5],
      padding="same",
      activation=tf.nn.relu)

  # Pooling Layer #1
  pool1 = tf.layers.max_pooling2d(inputs=conv1, pool_size=[2, 2], strides=2)

  # Convolutional Layer #2 and Pooling Layer #2
  conv2 = tf.layers.conv2d(
      inputs=pool1,
      filters=64,
      kernel_size=[5, 5],
      padding="same",
      activation=tf.nn.relu)
  pool2 = tf.layers.max_pooling2d(inputs=conv2, pool_size=[2, 2], strides=2)

  # Dense Layer
  pool2_flat = tf.reshape(pool2, [-1, 7 * 7 * 64])
  dense = tf.layers.dense(inputs=pool2_flat, units=1024, activation=tf.nn.relu)
  dropout = tf.layers.dropout(
      inputs=dense, rate=0.4, training=mode == tf.estimator.ModeKeys.TRAIN)

  # Logits Layer
  logits = tf.layers.dense(inputs=dropout, units=10)

  predictions = {
      # Generate predictions (for PREDICT and EVAL mode)
      "classes": tf.argmax(input=logits, axis=1),
      # Add `softmax_tensor` to the graph. It is used for PREDICT and by the
      # `logging_hook`.
      "probabilities": tf.nn.softmax(logits, name="softmax_tensor")
  }

  if mode == tf.estimator.ModeKeys.PREDICT:
    return tf.estimator.EstimatorSpec(mode=mode, predictions=predictions)

  # Calculate Loss (for both TRAIN and EVAL modes)
  loss = tf.losses.sparse_softmax_cross_entropy(labels=labels, logits=logits)

  # Configure the Training Op (for TRAIN mode)
  if mode == tf.estimator.ModeKeys.TRAIN:
    optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.001)
    train_op = optimizer.minimize(
        loss=loss,
        global_step=tf.train.get_global_step())
    return tf.estimator.EstimatorSpec(mode=mode, loss=loss, train_op=train_op)

  # Add evaluation metrics (for EVAL mode)
  eval_metric_ops = {
      "accuracy": tf.metrics.accuracy(
          labels=labels, predictions=predictions["classes"])}
  return tf.estimator.EstimatorSpec(
      mode=mode, loss=loss, eval_metric_ops=eval_metric_ops)

以下部分(与上面每个代码块对应的标题)深入介绍用于创建每个神经层的tf.layers代码,以及如何计算损失,配置训练操作并生成预测。

输入层

对于处理2D图像数据的CNN,Tensorflow的Layer对象中用于创建卷积层和合并层的方法需要输入一个结构为[batch_size, image_width, image_height, channels]的4维tensor,各个参数定义如下:

  • batch_size: 在训练期间执行梯度下降时要使用的示例子集的大小。
  • image_width:示例图像宽度。
  • image_height: 示例图像高度。
  • channels: 示例图像中的颜色通道数量。 对于彩色图像,通道数量是3(红色,绿色,蓝色),对于单色图像,只有1个通道(黑色)。

这里,我们的MNIST数据集由单色的28x28像素图像组成,因此我们输入图层的所需形状为[batch_size,28,28,1]。
为了将我们的输入特征数据映射(特征)转换为这种形状,我们可以执行下面的整形操作:

input_layer = tf.reshape(features["x"], [-1, 28, 28, 1])

值得注意的是,我们已经为batch_size赋值为-1,意味着此维度大小应该根据feature["x"]中输入值的数量动态计算,并保持所有其他维度的大小不变。 这使我们可以将batch_size视为我们可以调整的超参数。 例如,如果我们将示例以5批次的形式提供给我们的模型,则feature["x"]将包含3,920个值(每个图像中每个像素的一个值),并且input_layer将具有[5,28,28,1]. 同样,如果我们以100个批次的形式提供示例,则feature["x"]将包含78,400个值,而input_layer将具有[100,28,28,1]的形状。

卷积层 #1

在我们的第一个卷积层中,我们希望将32个5x5滤波器应用于输入层,并使用ReLU作为激活函数。 我们可以在图层模块中使用conv2d()方法来创建该图层,如下所示:

conv1 = tf.layers.conv2d(
    inputs=input_layer,
    filters=32,
    kernel_size=[5, 5],
    padding="same",
    activation=tf.nn.relu)
  • filter参数表示filter的数量(这里是32)。
  • kernel_size表示filter的维度(这里是[5,5])。
  • padding参数为两个枚举值中的一个(不区分大小写):valid(缺省值)或+ same。 我们在此处设置padding=same,表示输出tensor应该与输入tensor具有相同的宽度和高度值。此时TensorFlow将0值添加到输入tensor的边缘以保持宽度和高度为28。(如果没有设置padding属性,则将在28x28tensor上进行5x5卷积生成24x24tensor,因为有24x24个位置从28x28网格中提取5x5瓦片。)
  • activation参数表示用于卷积层输出的激活函数。这里我们选取了Relu函数 tf.nn.relu

我们的用于输出的tensor由conv2d()函数生成,tensor的结构是[batch_size, 28, 28, 32]。这里前三个维度大小和input_layer输出的大小一致,最后一个32表示了有32个通道保存每个过滤器的输出。

汇聚层 #1

接下来,我们将第一个汇聚层与刚才创建的卷积层连接起来。我们可以利用layermax_pooling2d()方法构建层执行max_pooling策略的一个2x2的过滤器:

pool1 = tf.layers.max_pooling2d(inputs=conv1, pool_size=[2, 2], strides=2)

这里同样,inputs表示输入的结构为[batch_size, image_width, image_height, channels]的tensor。这里,我们的输入tensor是conv1,它是我们的第一个卷积层的输出,它的结构为[batch_size, 28, 28, 32]

  • pool_size表示这个max pooling filter的大小为[width,height](这里是[2,2])。
  • strides参数表示步幅大小,在这里,我们设置了长度为2的步幅,这表明由滤波器提取的子区域应该在宽和高上间隔2个像素(对于2x2滤波器,这意味着没有提取的区域将重叠), 如果要为宽度和高度设置不同的跨度值,则可以改为指定元组或列表(例如,stride = [3,6])。

我们的输出tensor是由max_pooling2d()方法生成的,#pool1输出格式为:[batch_size,14,14,32],2X2的filter使原始数据的长度和高度都减少50%。

卷积层#2以及合并层#2

我们可以继续将第二个卷积层和合并层的组合连接到我们的CNN中,这里我们依然使用conv2d()max_pooling2d()方法。第二个卷积层中,我们将filter的数量增加到64个,依然使用ReLU函数作为激活函数,而对于第二个合并层,我们将采用和一个合并层相同的结构(一个长宽和步幅都为2的max pooling filter):

conv2 = tf.layers.conv2d(
    inputs=pool1,
    filters=64,
    kernel_size=[5, 5],
    padding="same",
    activation=tf.nn.relu)
pool2 = tf.layers.max_pooling2d(inputs=conv2, pool_size=[2, 2], strides=2)

值得注意的是,卷积层#2使用的是合并层#1的输出作为输入tensor,并通过conv2d()方法生成结构为[batch_size,14,14,64]的tensor作为输出。其中widthheight由于设置了参数padding="same",和pool1的输出宽高是一致的,channels 则表示64个filter输出的64个channel。
合并层#2采用conv2作为输入,并以pool2作为输出,输出格式为:[batch_size, 7, 7, 64],可以看出,数据宽高大小再一次减半。

密集层

接下来,我们需要添加一个由1024个采用ReLU激活函数的神经元组成的密集层到我们的神经网络中,来为我们从前面的卷积层和合并层中提取出来的图像特征做分类。在我们将这个神经层连接到神经网络之前,我们需要将我们的pool2输出的tensor扁平化(flatten)一下,让其结构变成[batch_size,features]只有两个维度,代码如下:

pool2_flat = tf.reshape(pool2, [-1, 7 * 7 * 64])

在上面的reshape()操作中,-1表示batch_size由输入数据的实例数量动态计算。每个实例具有7x7x64=3136个特征,这里每个数字分别对应pool2的宽、高以及通道数量,所以我们的pool2_flat的被“压扁”后的大小为:[batch_size, 3136]
Now, we can use the dense() method in layers to connect our dense layer as follows:
现在 ,我们可以使用layerdense()方法去将我们的dense层创建出来:

dense = tf.layers.dense(inputs=pool2_flat, units=1024, activation=tf.nn.relu)

其中inputsactivation参数的意义和tf.layers.conv2d()方法中一样,分别表示输入tensor和激活函数,而units参数则表示该层中神经元的数量。

为了防止过拟合,我们可以使用tf.layers.dropout()方法,在我们的dense层输出后面加上dropout regularization:

dropout = tf.layers.dropout(
    inputs=dense, rate=0.4, training=mode == tf.estimator.ModeKeys.TRAIN)

inputs参数不用多说,表示输入的tensor,rate参数表示我们droout的比率,这里我们使用0.4,意味着在训练时,40%的数据会被随机丢弃。 train参数是个布尔值,用于控制dropout是否启用,这里我们将只在TRAIN模式中采用dropout,dropout的大小为:[batch_size, 1024]

Logits层

我们的神经网络中的最后一层是logits层,它会返回我们预测的原始值。 最终我们创建了一个结构为[batch_size, 10]包含10个神经元(分别对应0-9这10个目标类)的密集层,并使用线性激活函数(默认值):

logits = tf.layers.dense(inputs=dropout, units=10)

生成预测值

我们的模型为我们返回的[batch_size, 10]-维的tensor中包含预测结果的原始值,让我们将这些原始值转换成一些比较直观的格式来作为我们模型的返回值,例如:

  • 每个示例的预测类别:直接根据预测值返回一个0-9的数字。
  • 每个示例的每个可能目标类的概率:返回预测值为0,为1,为2...的概率。

回到我们的代码,我们采用了一个tf.argmax()方法来找到返回的tensor中每一行数据中的最大值的下标:

tf.argmax(input=logits, axis=1)

input参数表示输入tensor,axis参数表示我们是对哪个维度求最大值下标,这里给1表示对行求最大值下标,而我们的输入logit的结构是[batch_size,10],所以我们这里表示是对数字10所代表的维度求最大值下标,而这10个下标分别对应我们所预测的0-9这9个数字,而最大值所对应的下标就是我们预测的结果。这样讲可能有点抽象,我们来举个例子:

[...[1,1,1,1,1000,1,1,1,1,1]...]

这里可以看到,我们输出tensor的某一行中最大值1000所对应的下标为4(从0开始),表示我们对于这一组数据的预测结果为4,即这幅图片上面写的是阿拉伯数字4。
然后,我们将我们的预测值转换成两种输出格式再组合成一个dict后输出一个EstimatorSpec对象:

predictions = {
    "classes": tf.argmax(input=logits, axis=1),
    "probabilities": tf.nn.softmax(logits, name="softmax_tensor")
}
if mode == tf.estimator.ModeKeys.PREDICT:
  return tf.estimator.EstimatorSpec(mode=mode, predictions=predictions)

计算损失

对于培训和评估,我们需要定义一个损失函数来衡量模型的预测与目标类别的匹配程度。 对于像MNIST这样的多类分类问题,通常使用交叉熵来度量损失。 以下代码计算模型在TRAIN或EVAL模式下运行时的交叉熵:

onehot_labels = tf.one_hot(indices=tf.cast(labels, tf.int32), depth=10)
loss = tf.losses.softmax_cross_entropy(
    onehot_labels=onehot_labels, logits=logits)

先看第一行代码,label这个tensor中包含了我们用于训练的预测值列表,例如, [1,9,...]。 为了计算交叉熵,首先我们需要将标签转换为相应的单热编码 (one-hot encoding)

[[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
 ...]

我们使用tf.one_hot()函数来执行此转换,这个函数有两个必需的参数:

  • indices: 单热tensor中"on value"所处的位置,即上述tensor中的"1"值的位置。
  • depth: 单热tensor的深度,即目标类别的数量。 这里,深度是10(0-9)。

经过这一步骤之后我们的label的值从[0,1...]这样的由0-9数字组成的列表变为onehot_labels这样由[1,0,0,0,0,0,0,0,0],[0,1,0,0,0,0,0,0,0]...等单热编码所组成的列表,数字1所在的下标表示原来的数值。

接下来在看第二行代码,我们利用tf.losses.softmax_cross_entropy()方法来计算onehot_labelslogits层输出预测值的交叉熵。 在计算时,会在logits上执行softmax激活,再将onehot_labels和softmax激活后的logits作为参数计算交叉熵,并将loss作为一个标量tensor返回。

配置训练操作

在我们将CNN的损失定义为logits层和我们label的softmax交叉熵后,我们将配置我们的模型以在训练期间优化这个损失值。 我们将使用学习率为0.001的随机梯度下降作为优化算法:

if mode == tf.estimator.ModeKeys.TRAIN:
  optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.001)
  train_op = optimizer.minimize(
      loss=loss,
      global_step=tf.train.get_global_step())
  return tf.estimator.EstimatorSpec(mode=mode, loss=loss, train_op=train_op)

添加评估指标

要评估我们模型的预测准确性,我们需要在EVAL模式中定义eval_metric_ops字典,如下所示:

eval_metric_ops = {
    "accuracy": tf.metrics.accuracy(
        labels=labels, predictions=predictions["classes"])
}
return tf.estimator.EstimatorSpec(
    mode=mode, loss=loss, eval_metric_ops=eval_metric_ops)

训练和评估我们的 CNN MNIST 分类器

我们已经完成 CNN MNIST 模型的创建,现在我们要训练并评估它

加载训练集和测试集

First, let's load our training and test data. Add a main() function to cnn_mnist.py with the following code:

首先,让我们加载我们的训练集和测试集。首先为我们的工程添加main()函数:

def main(unused_argv):
  # Load training and eval data
  mnist = tf.contrib.learn.datasets.load_dataset("mnist")
  train_data = mnist.train.images # Returns np.array
  train_labels = np.asarray(mnist.train.labels, dtype=np.int32)
  eval_data = mnist.test.images # Returns np.array
  eval_labels = np.asarray(mnist.test.labels, dtype=np.int32)

我们将train_data和train_labels中的训练特征数据(手绘数字的55,000个图像的原始像素值)和训练label(每个图像的0-9的对应值)分别存储为numpy数组。 同样,我们将评估特征数据(10,000个图像)和评估label分别存储在eval_data和eval_labels中。

创建Estimator

接下来,让我们为我们的模型创建一个Estimator(一个TensorFlow类,用于执行高级模型训练,评估和推理)。 将下面的代码添加到main()中:

# Create the Estimator
mnist_classifier = tf.estimator.Estimator(
    model_fn=cnn_model_fn, model_dir="/tmp/mnist_convnet_model")

model_fun就是我们前面所编写的创建模型的cnn_model_fnmodel_dir表示我们保存模型数据的路径。

设置日志钩子

由于CNN的训练需要一段时间,因此我们需要在训练期间建立一些日志记录,以便跟踪训练进度。 我们可以使用TensorFlow的tf.train.SessionRunHook创建一个tf.train.LoggingTensorHook,它将记录来自CNN的softmax层的概率值:

  # Set up logging for predictions
  tensors_to_log = {"probabilities": "softmax_tensor"}
  logging_hook = tf.train.LoggingTensorHook(
      tensors=tensors_to_log, every_n_iter=50)

我们在tensors_to_log字典中存储了我们想要进行日志跟踪的tensor,字典的key是我们输出日志的标签,而对应的value是我们的tensor在tensorflow的graph中的名称。在这里,我们的概率可以在softmax_tensor中找到,这是我们在cnn_model_fn中生成概率时早先给出的softmax操作的名称。
接下来,我们创建LoggingTensorHook,将tensors_to_log传递给tensors参数,我们设置every_n_iter = 50,表示每50步记录一次日志。

训练我们的模型

现在我们准备训练我们的模型,我们可以通过在mnist_classifier上创建train_input_fn并调用train()来完成这个模型的训练。 将以下内容添加到main()

# Train the model
train_input_fn = tf.estimator.inputs.numpy_input_fn(
    x={"x": train_data},
    y=train_labels,
    batch_size=100,
    num_epochs=None,
    shuffle=True)
mnist_classifier.train(
    input_fn=train_input_fn,
    steps=20000,
    hooks=[logging_hook])

在numpy_input_fn调用中,我们将训练特征数据和label分别传递给x和y,并设置了100的batch_size(这意味着模型将在每个步骤以数量为100的minibatches进行训练)。num_epochs = None表示模型将训练到达到指定的步数。 我们还设置shuffle = True来洗牌训练数据。 在调用train()时,我们设置了steps= 20000(这意味着模型将训练总共20000步)。 我们将logging_hook传递给hooks参数,以便在训练过程中触发它。

评估我们的模型

一旦训练完成,我们要评估我们的模型以确定其在MNIST测试集上的准确性。 我们将测试集中的eval_dataeval_label传入numpy_input_fn的x和y参数,并调用evaluate()方法来评估我们在model_fn中的eval_metric_ops参数中指定指标:

# Evaluate the model and print results
eval_input_fn = tf.estimator.inputs.numpy_input_fn(
    x={"x": eval_data},
    y=eval_labels,
    num_epochs=1,
    shuffle=False)
eval_results = mnist_classifier.evaluate(input_fn=eval_input_fn)
print(eval_results)

为了创建eval_input_fn,我们设置num_epochs = 1,以便模型评估一个历元数据上的度量并返回结果。 我们还设置shuffle = False来循环遍历数据。

Run the Model

我们编写了CNN模型函数、Estimator和训练/评估逻辑; 现在让我们看看结果。 运行cnn_mnist.py得到以下输出:

INFO:tensorflow:loss = 2.36026, step = 1
INFO:tensorflow:probabilities = [[ 0.07722801  0.08618255  0.09256398, ...]]
...
INFO:tensorflow:loss = 2.13119, step = 101
INFO:tensorflow:global_step/sec: 5.44132
...
INFO:tensorflow:Loss for final step: 0.553216.

INFO:tensorflow:Restored model from /tmp/mnist_convnet_model
INFO:tensorflow:Eval steps [0,inf) for training step 20000.
INFO:tensorflow:Input iterator is exhausted.
INFO:tensorflow:Saving evaluation summary for step 20000: accuracy = 0.9733, loss = 0.0902271
{'loss': 0.090227105, 'global_step': 20000, 'accuracy': 0.97329998}

可以看到我们的模型拥有高达97.3%的准确率,是不是很酷?

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