十四、计算机视觉中的神经网络:可视化卷积网络所学到的东西


文章代码来源:《deep learning on keras》,非常好的一本书,大家如果英语好,推荐直接阅读该书,如果时间不够,可以看看此系列文章,文章为我自己翻译的内容加上自己的一些思考,水平有限,多有不足,请多指正,翻译版权所有,若有转载,请先联系本人。
个人方向为数值计算,日后会向深度学习和计算问题的融合方面靠近,若有相近专业人士,欢迎联系。


系列文章:
一、搭建属于你的第一个神经网络
二、训练完的网络去哪里找
三、【keras实战】波士顿房价预测
四、keras的function API
五、keras callbacks使用
六、机器学习基础Ⅰ:机器学习的四个标签
七、机器学习基础Ⅱ:评估机器学习模型
八、机器学习基础Ⅲ:数据预处理、特征工程和特征学习
九、机器学习基础Ⅳ:过拟合和欠拟合
十、机器学习基础Ⅴ:机器学习的一般流程十一、计算机视觉中的深度学习:卷积神经网络介绍
十二、计算机视觉中的深度学习:从零开始训练卷积网络
十三、计算机视觉中的深度学习:使用预训练网络
十四、计算机视觉中的神经网络:可视化卷积网络所学到的东西


常常说深度学习模型是“黑盒”,学习表示很难提取表示成人类可以读懂的形式。尽管这对于特定类型的深度学习模型是对的,但那时对于卷积网络一定是错的。我们从卷积网络学到的表示都是高度可视化的,很大一部分是因为它们表示成了可视化的概念。自从2013以来,各种各样的技术都被提出用来可视化和揭示这些表示。我们不会调查所有的,但我们会涵盖三种最容易接触最有用的:

  • 可视化卷积网络中间输出(中间激活)这对于理解卷积网络层如何变换它们的输入,以及了解每一个卷积网络滤波器的意义。
  • 可视化卷积网络滤波器。这对于正确理解每个滤波器的视觉图案和在卷积网络中滤波器接收到的内容。
  • 可视化每一幅图像的分类激活值的热力图。这对于理解图像的哪一个部分对于分类起的作用最大,这也允许局部化图像中的物体。

在第一种模式——激活值可视化——我们将会使用我们从零训练的小的卷积网络(cat vs. dog)分类问题。在接下来的两种方法,我们将会使用VGG16模型。

可视化中间的激活值

可视化中间的激活值包含展示通过不同卷积和池化层以后的输出,给一个特定的输入(这层输出叫做“激活值”,激活函数的输出值)这给了一个视角来看一个输入是如何分解到不同的网络学到的滤波器的。这些我们想要可视化的特征有三个维度:长、高、深度。每一个通道编码了相互独立的特征,所以合适的可视化这些特征的方法是通过单独画出每一个通道里面的内容,作为二维图像,让我们开始加载之前训练过的模型吧:

>>> from keras.models import load_model
>>> model = load_model('cats_and_dogs_small_2.h5')
>>> model.summary() # As a reminder.
________________________________________________________________
Layer (type) Output Shape Param #
================================================================
conv2d_5 (Conv2D) (None, 148, 148, 32) 896
________________________________________________________________
maxpooling2d_5 (MaxPooling2D) (None, 74, 74, 32) 0
________________________________________________________________
conv2d_6 (Conv2D) (None, 72, 72, 64) 18496
________________________________________________________________
maxpooling2d_6 (MaxPooling2D) (None, 36, 36, 64) 0
________________________________________________________________
conv2d_7 (Conv2D) (None, 34, 34, 128) 73856
________________________________________________________________
maxpooling2d_7 (MaxPooling2D) (None, 17, 17, 128) 0
________________________________________________________________
conv2d_8 (Conv2D) (None, 15, 15, 128) 147584
________________________________________________________________
maxpooling2d_8 (MaxPooling2D) (None, 7, 7, 128) 0
________________________________________________________________
flatten_2 (Flatten) (None, 6272) 0
________________________________________________________________
dropout_1 (Dropout) (None, 6272) 0
________________________________________________________________
dense_3 (Dense) (None, 512) 3211776
________________________________________________________________
dense_4 (Dense) (None, 1) 513
================================================================
Total params: 3,453,121
Trainable params: 3,453,121
Non-trainable params: 0

接下来我们要在网络上用的猫的图像是在网络上没有训练过的:
先预处理图像:

img_path = '/Users/fchollet/Downloads/cats_and_dogs_small/test/cats/cat.1700.jpg'
# We preprocess the image into a 4D tensor
from keras.preprocessing import image
import numpy as np
img = image.load_img(img_path, target_size=(150, 150))
img_tensor = image.img_to_array(img)
img_tensor = np.expand_dims(img_tensor, axis=0)
# Remember that the model was trained on inputs
# that were preprocessed in the following way:
img_tensor /= 255.
# Its shape is (1, 150, 150, 3)
print(img_tensor.shape)

展示我们的图像:

import matplotlib.pyplot as plt
plt.imshow(img_tensor[0])
Our test cat picture

为了提取我们想要看到的特征,我们将会建立一个keras模型以图像批作为输入,输出所有卷积和池化层的激活值。我们将会使用 Keras class模型来做到这一点。一个模型的实例用了两个参数:一个输入张量,一个输出张量。结果的类别是一个keras模型,就和你熟知的sequential模型类似的,将特定输入映射到特定输出。让这二者有区别的是我们现在要用的模型可以有多个输出,不像sequential。想要了解更多有关Model class的信息,可以看书的第七章第一部分。

from keras import models
# Extracts the outputs of the top 8 layers:
layer_outputs = [layer.output for layer in model.layers[:8]]
# Creates a model that will return these outputs, given the model input:
activation_model = models.Model(inputs=model.input, outputs=layer_outputs)

当喂进去输入图像时,模型返回原始模型的层的激活值。这是你第一次在本书遇到多输出的模型:直到现在,你所看到的模型都是一个输入一个输出。一般的情况,一个模型能有任意多输入和输出。这里的有一个输入,五个输出,每一层输出一个层激活值。

# This will return a list of 5 Numpy arrays:
# one array per layer activation
activations = activation_model.predict(img_tensor)

举个例子,这就是我们猫图像输入卷积层第一层的激活值:

>>> first_layer_activation = activations[0]
>>> print(first_layer_activation.shape)
(1, 148, 148, 32)

这是一个148\times 148的特征,有着32个通道。让我们看一下第四个通道:

import matplotlib.pyplot as plt
plt.matshow(first_layer_activation[0, :, :, 4], cmap='viridis')
4th channel of the activation of the first layer on our test cat picture

这个通道看起来编码了对角边缘探测器。让我们试一下第七个通道——但注意你自己的通道或许是多样的,因为卷积层选到的滤波器是不确定的。

plt.matshow(first_layer_activation[0, :, :, 7], cmap='viridis')
7th of the activation of the first layer on our test cat picture

这看起来就像是“亮绿点”探测器,对于编码猫眼很有用。在这一点上,让我们画出整个网络的可视化激活值。我们将会提取和画出五个激活图中的每一个通道,然后我们将会把这些结果堆在一个大的图像张量中,通道挨着堆。

# These are the names of the layers, so can have them as part of our plot
layer_names = []
for layer in model.layers[:8]:
 layer_names.append(layer.name)
images_per_row = 16
# Now let's display our feature maps
for layer_name, layer_activation in zip(layer_names, activations):
 # This is the number of features in the feature map
 n_features = layer_activation.shape[-1]
 # The feature map has shape (1, size, size, n_features)
 size = layer_activation.shape[1]
 # We will tile the activation channels in this matrix
 n_cols = n_features // images_per_row
 display_grid = np.zeros((size * n_cols, images_per_row * size))
# We'll tile each filter into this big horizontal grid
 for col in range(n_cols):
 for row in range(images_per_row):
 channel_image = layer_activation[0,
 :, :,
 col * images_per_row + row]
 # Post-process the feature to make it visually palatable
 channel_image -= channel_image.mean()
 channel_image /= channel_image.std()
 channel_image *= 64
 channel_image += 128
 channel_image = np.clip(channel_image, 0, 255).astype('uint8')
 display_grid[col * size : (col + 1) * size,
 row * size : (row + 1) * size] = channel_image
 # Display the grid
 scale = 1. / size
 plt.figure(figsize=(scale * display_grid.shape[1],
 scale * display_grid.shape[0]))
 plt.title(layer_name)
 plt.grid(False)
 plt.imshow(display_grid, aspect='auto', cmap='viridis')
Every channel of every layer activation on our test cat picture

有些需要注意的:

  • 第一层表现得像各种边缘探测器的集合,在那个阶段,激活值仍然是保留了几乎原始图像的所有信息。
  • 更高一层,激活值就变得进一步抽象,更少的视觉理解。它们开始编码更高一层的内容,诸如“猫耳”和“猫眼”。更高级的表示得到的图像的视觉内容更少了,并得到了更多关于图像类别的信息。
  • 随着层的加深,激活值的稀疏性也在增加:在第一层,所有的滤波器都被输入图像激活了,但在接下来的层里面越来越多的滤波器变空了。这意味着滤波器编码的图案不是在输入图像中找到的。

我们刚刚已经论证了一个非常重要普遍的深度神经网络学习表示的特点:层提取到的特征随着层数增加而变得更抽象。层的激活值会携带越来越少的有关特定输入的信息,随着层的增加,会携带越来越多有关目标的信息(在我们的例子中,图像类别是猫、狗)。一个深度神经网络有效的工作就像一个信息蒸馏管道,输入数据向量,在我们的例子中是输入了RGB图,就会反复转换使得无关信息被滤出去(例如图像特定的视觉外观)有用的信息就会被放大和提炼(例如图像类别)。
人类和动物感知世界的方式与之类似:在观察了一个场景几秒以后,一个人类能够记住物体的抽象但无法记住物体具体的表现。实际上,如果让你现在从记忆中画一个单车,你很可能都无法画出细节,尽管大致正确,虽然你一生中见过上千辆单车。现在马上试一下:效果属实。你的大脑学习了输入图像的完全抽象,把它转化成更高级的视觉内容,完全过滤掉不相关的视觉细节,让记住我们周围的东西的实际的样子非常的难。


Left: attempts to draw a bicycle from memory. Right: what a schematic bicycle should look like.

可视化卷积网络滤波器

另一个简单的事情是监视滤波器从卷积网络里学到的东西,并把它用可视化的方式展现出来。这能通过输入空间里的gradient ascent做到:使用gradient descent来评估输入图像的卷积网络以最大化特定滤波器的反馈,从一个空白输入图像开始。最终输入图像的结果是对于选择的滤波器具有最大响应的。
这个过程很简单:我们将会建立一个损失函数来最大化在给定卷积层中滤波器的值,我们会使用随机梯度下降来调节输入图像的值从而最大化激活值。举个例子,这里给出了VGG16网络中的"block3_conv1"的滤波器0的激活值损失。

from keras.applications import VGG16
from keras import backend as K
model = VGG16(weights='imagenet',
 include_top=False)
layer_name = 'block3_conv1'
filter_index = 0
layer_output = model.get_layer(layer_name).output
loss = K.mean(layer_output[:, :, :, filter_index])

为了应用梯度下降法,我们将会需要遵循模型输入的损失下降。为了做到这一点,我们需要用gradients函数来打包keras的backend模型:

# The call to `gradients` returns a list of tensors (of size 1 in this case)
# hence we only keep the first element -- which is a tensor.
grads = K.gradients(loss, model.input)[0]

一个不明显的把戏来使用梯度下降处理光滑是标准化梯度张量,通过除以它的L2模(张量中的平方和均值的平方根)这保证输入图像更新总是在一个相同的范围。

# We add 1e-5 before dividing so as to avoid accidentally dividing by 0.
grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)

现在我们需要一个方法来在给定输入图像时,计算损失张量的值以及梯度张量。我们能够定义一个keras 的backend函数来做到:iterate是一个函数,拿进去一个数组张量返回两个数组张量:损失值和梯度值。

iterate = K.function([model.input], [loss, grads])
# Let's test it:
import numpy as np
loss_value, grads_value = iterate([np.zeros((1, 150, 150, 3))])

此时我们就可以使用Python里面的loop来做随机梯度下降了。

# We start from a gray image with some noise
input_img_data = np.random.random((1, 150, 150, 3)) * 20 + 128.
# Run gradient ascent for 40 steps
step = 1. # this is the magnitude of each gradient update
for i in range(40):
 # Compute the loss value and gradient value
 loss_value, grads_value = iterate([input_img_data])
 # Here we adjust the input image in the direction that maximizes the loss
 input_img_data += grads_value * step

图像张量的结果将会是浮点张量的形状(1,150,150,3)值在[0.255]之间。因此我们需要发布张量的流程来将其转化为可视化图像。我们通过以下实用函数来做到:

def deprocess_image(x):
 # normalize tensor: center on 0., ensure std is 0.1
 x -= x.mean()
 x /= (x.std() + 1e-5)
 x *= 0.1
 # clip to [0, 1]
 x += 0.5
 x = np.clip(x, 0, 1)
 # convert to RGB array
 x *= 255
 x = np.clip(x, 0, 255).astype('uint8')
 return x

现在我们有了所有的不见,让我们把它们放在一起,放进Python函数就像放进层的名字和滤波器指标,会返回一个有效图像张量,其代表着最大化特定滤波器的激活值。

def generate_pattern(layer_name, filter_index, size=150):
 # Build a loss function that maximizes the activation
 # of the nth filter of the layer considered.
 layer_output = model.get_layer(layer_name).output
 loss = K.mean(layer_output[:, :, :, filter_index])
 # Compute the gradient of the input picture wrt this loss
 grads = K.gradients(loss, model.input)[0]
 # Normalization trick: we normalize the gradient
 grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)
 # This function returns the loss and grads given the input picture
 iterate = K.function([model.input], [loss, grads])
 # We start from a gray image with some noise
 input_img_data = np.random.random((1, size, size, 3)) * 20 + 128.
 # Run gradient ascent for 40 steps
 step = 1.
 for i in range(40):
 loss_value, grads_value = iterate([input_img_data])
 input_img_data += grads_value * step
 img = input_img_data[0]
 return deprocess_image(img)

可视化block3_conv1的滤波器0:

>>> plt.imshow(generate_pattern('block3_conv1', 0))

Pattern that the 0th channel in layer block3_conv1 maximally responds to

看起来就像是波尔卡圆点图。
现在是比较有趣的部分:我们能从每一层的单独滤波器开始可视化。简单来说,我们将只看到每一层的前64个滤波器,将会只看到第一层的每个卷积块(block1_conv1,block2_conv1,block3_conv1,block4_conv1,block5_conv1)我们将输出排列在
8\times 8
64\times 64
大小的滤波器图案,通过一些在每一个滤波器图案周围加黑边。

layer_name = 'block1_conv1'
size = 64
margin = 5
# This a empty (black) image where we will store our results.
results = np.zeros((8 * size + 7 * margin, 8 * size + 7 * margin, 3))
for i in range(8): # iterate over the rows of our results grid
 for j in range(8): # iterate over the columns of our results grid
 # Generate the pattern for filter `i + (j * 8)` in `layer_name`
 filter_img = generate_pattern(layer_name, i + (j * 8), size=size)
 # Put the result in the square `(i, j)` of the results grid
 horizontal_start = i * size + i * margin
 horizontal_end = horizontal_start + size
 vertical_start = j * size + j * margin
 vertical_end = vertical_start + size
 results[horizontal_start: horizontal_end, vertical_start: vertical_end, :] = filter_img
# Display the results grid
plt.figure(figsize=(20, 20))
plt.imshow(results)
Filter patterns for layer block1_conv1

Filter patterns for layer block2_conv1

Filter patterns for layer block3_conv1

Filter patterns for layer block4_conv1

这些滤波器可视化告诉我们卷积网络层是如何看这个世界的:每一层都简单的学习一类滤波器,使得他们的输入能够被表示成滤波器的组合。这个和傅里叶分解信号成一个余弦函数库很类似。这些卷积网络滤波器库中的滤波器当我们的层升高时变得更加复杂和精致:

  • 第一层滤波器block1_conv1编码一些简单的边缘和颜色,或者某些情况下编码彩色边缘。
  • block2_conv1的滤波器将边缘和颜色组合起来编码简单的纹理
  • 在更高层的滤波器开始寻找在自然图像中类似的纹理:羽毛,眼睛,叶子等等。

分类激活的热力图可视化

我们还将介绍一些可视化技术,对于理解给定图像的哪一部分会导致卷积网络作出最终的分类决定。这对于调试卷积网络决定的错误非常管用,特别是在分类错误的情况下。这也将允许你找到图像中的特定物体。
这种一般的分类方法叫做“分类激活图”CAM可视化,是通过在输入图像上画分类激活值的热力图得到的。一个分类激活值的热力图是一个二维的和特定输出类别相关的分数,计算每一个输入图像的位置,指示着每一个位置对于分类结果的重要程度。例如,给一个图像进我们的"cat vs. dog"卷积网络,分类激活图允许我们生成一幅关于猫的热力图,指示猫样子的图在不同的地方是什么样子,类似的可以画出狗的。
我们用的具体的实现过程在Grad-CAM中详细描述。其实很简单:衡量每一个特征对于分类的权重,最后画出来。
我们将使用预训练过的VGG16网络来讨论这个技术。

from keras.applications.vgg16 import VGG16
# Note that we are including the densely-connected classifier on top;
# all previous times, we were discarding it.
model = VGG16(weights='imagenet')

让我们考虑下面这幅有着两只非洲象的图片,或许是一个母亲和它的幼崽漫步在大草原:

Our test picture of African elephants

让我们将图像转化成VGG16模型能读得懂的数据:这个模型在大小为
224 \times 224
的图像上训练,预处理的几个很少的规则都打包在了keras.applications.vgg16.preprocess_input。所以我们需要加载图像,并把大小resize到
224\times 224
转化成float32的张量,并使用那些预处理规则。

from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input, decode_predictions
import numpy as np
# The local path to our target image
img_path = '/Users/fchollet/Downloads/creative_commons_elephant.jpg'
# `img` is a PIL image of size 224x224
img = image.load_img(img_path, target_size=(224, 224))
# `x` is a float32 Numpy array of shape (224, 224, 3)
x = image.img_to_array(img)
# We add a dimension to transform our array into a "batch"
# of size (1, 224, 224, 3)
x = np.expand_dims(x, axis=0)
# Finally we preprocess the batch
# (this does channel-wise color normalization)
x = preprocess_input(x)

我们可以在预训练网络上允许图片,并把预测向量返回为人类可阅读的形式:

>>> preds = model.predict(x)
>>> print('Predicted:', decode_predictions(preds, top=3)[0])
Predicted:', [(u'n02504458', u'African_elephant', 0.92546833),
(u'n01871265', u'tusker', 0.070257246),
(u'n02504013', u'Indian_elephant', 0.0042589349)]

前三类关于该图像的预测为:

  • 非洲象(92.5%)
  • 长牙象(7%)
  • 印度象(0.4%)

因此我们的网络识别图像时包含了一个待定数量的非洲象。预测向量 的最大激活值在“非洲象类”,指标是386.

>>> np.argmax(preds[0])
386

为了可视化我们图像的哪一部份最像“非洲象”,让我们设置一个Grad-CAM过程:

# This is the "african elephant" entry in the prediction vector
african_elephant_output = model.output[:, 386]
# The is the output feature map of the `block5_conv3` layer,
# the last convolutional layer in VGG16
last_conv_layer = model.get_layer('block5_conv3')
# This is the gradient of the "african elephant" class with regard to
# the output feature map of `block5_conv3`
grads = K.gradients(african_elephant_output, last_conv_layer.output)[0]
# This is a vector of shape (512,), where each entry
# is the mean intensity of the gradient over a specific feature map channel
pooled_grads = K.mean(grads, axis=(0, 1, 2))
# This function allows us to access the values of the quantities we just defined:
# `pooled_grads` and the output feature map of `block5_conv3`,
# given a sample image
iterate = K.function([model.input], [pooled_grads, last_conv_layer.output[0]])
# These are the values of these two quantities, as Numpy arrays,
# given our sample image of two elephants
pooled_grads_value, conv_layer_output_value = iterate([x])
# We multiply each channel in the feature map array
# by "how important this channel is" with regard to the elephant class
for i in range(512):
 conv_layer_output_value[:, :, i] *= pooled_grads_value[i]
# The channel-wise mean of the resulting feature map
# is our heatmap of class activation
heatmap = np.mean(conv_layer_output_value, axis=-1)

热力图后处理:

heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
African elephant class activation heatmap over our test picture

最后,我们使用OpenCV来生成一幅原图和热力图的叠加:

import cv2
# We use cv2 to load the original image
img = cv2.imread(img_path)
# We resize the heatmap to have the same size as the original image
heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0]))
# We convert the heatmap to RGB
heatmap = np.uint8(255 * heatmap)
# We apply the heatmap to the original image
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
# 0.4 here is a heatmap intensity factor
superimposed_img = heatmap * 0.4 + img
# Save the image to disk
cv2.imwrite('/Users/fchollet/Downloads/elephant_cam.jpg', superimposed_img)
Superimposing the class activation heatmap with the original picture

这项技术回答了两个重要的问题:

  • 为什么网络认为图像包含了一个非洲象?
  • 非洲象在图像中的位置?

特别的,我们可以看到非洲象宝宝的耳朵被强烈激活:这或许是网络认为的非洲象和印第安象的区别。

总结:计算机视觉中的深度学习

这里有一些你需要从本章打包带走的东西:

  • 卷积网络是一个解决图像分类问题的最好工具。
  • 它们通过学习模式化的层次以及在视觉世界的表示来工作
  • 它们学习到的表示很容易观察到——它们和黑盒相反

此外,你应当选择一些实用的技巧:

  • 你能够从零训练你的卷积网络解决图像分类问题。
  • 你理解如何使用可视化数据增加来对抗过拟合
  • 你知道如何使用预训练的卷积网络来做特征提取和调参
  • 你能够生成你的网络学习到的滤波器的可视化,就像分类激活的热力图那样。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容