BERT(一) Transformer原理理解

一年前看了transformer,时间有点久了,现在也忘记很多,今天还是回顾一下,把知识点记下,方便日后回忆~ 😄,自己做算法刚开始也是做了一段NLP方向,后来完全转向了图像算法,随着这段时间transformer领域的大火,不仅仅在NLP领域实现了跨时代的意义,同时最近 发布文章
在图像领域也不断地被transformer刷屏,感觉会有替代CNN的趋势,如此重要的知识点肯定不能丢,这里想了解的可以看我这篇博客VIT(Transformers for Image Recognition at Scale)论文及代码解读。废话不多说先上论文和代码

论文地址:Attention is All You Need
github地址: transformers
在这里我也给出几个方便大家理解transformer的教学视频。

  1. 唐宇迪 Bert教程
  2. 李宏毅 Bert教程
  3. 汉语自然语言处理-BERT的解读语言模型预训练

一、 Transfomer概念


Transformer这个模型主要有Google mind团队提出来的一种模型,可以说这边模型出来之后,NLP领域实现了大一统的趋势,这个模型是2017年发表的 attention all you need 中所提到的seq2seq模型。BERT(Bidirectional Encoder Representations from Transformers)就是transformer衍生出来的预训练模型。在过去我们使用word2vec,lstm等技术也逐渐被BERT模型所替代,其成为NLP主流框架,基本很多NLP很多任务都可以用BERT去解决。

我们知道word2vec需要训练得到词embedding,但是每一个词(或字)在不同的上下文是拥有不同的含义的,不同语境下的词含有不同的意思,因此如果基于固定的词的embedding进行语言模型构建是有很大缺陷的,而transformer可以利用上下文的关系来得到我们每一个词的向量。而说到LSTM等时序性的网络不能只能做到串行训练,即下一个词的训练需要等到上一个词训练完成才可以进行,同时也影响训练速度,相对而言BERT的预言模型则可以做到并行计算,这往往是因为其自注意力机制以及位置嵌入机制发挥的功能,在后面我们也会讲到。

Transformer一般分为上游任务和下游任务,上游任务一般训练一个预训练预言模型,然后利于我们训练的预训练模型在进行下游任务如分类,生成等。

二、 Transfomer模型结构讲解

一般理解Trasformer无外乎通过两个部分去理解分为Encoder以及Decoder即编码器以及解码器。这里我们可以这么理解Encoder可以当作我们的特征提取器,来提取语言序列的特征表达得到隐藏层,而解码器则是将这些隐藏层特征表达解决我们自然语言中实际需要的问题如情感分类,命名实体识别等。说到这里先上一下大家都非常熟悉的Transformer的结构图,图像左边是编码器,图像的右边是解码器。

transformer结构图.png

刚看到这样的图片大家估计会比较懵,这边对于每一个部分我们都会细致讲解。那这种模型结构他是怎么怎么工作的呢?这边我给一张下面的图片方便大家理解transfomer中seq2sq是如何进行工作的。
seq2seq.png

这张图是一个已经简单的翻译模型,将英文翻译成中文,比如我们输入英文 Why do we work ? 我们需要将其翻译成中文,我们根据序号来看
【1】将英文文本输入到输入到编码器的模型中去
【2】通过编码器得到我们的隐藏层特征
【3】我们输入<start>开始token
【4】解码器结合我们的隐藏层特征输出
【5】我们再将我们得到的 输出到解码器中
【6】解码器结合我们隐藏层特征会输出
【7】依次按照这种方式不断输入到解码器中,我们将得出我们解码器翻译出来的中文语句。
当然很多自然语言的运用中很多也只用到编码器的部分,导致很多文章再说到解码器的部分让人觉得很模糊没有看懂,后面我也会详细说明,好了下面分别从encoder以及decoder来说下transformer模型吧。

1. Encoder

对于Transformer的Encoder部分讲解我也准备通过4个部分去给大家做讲解,当然再模型比较难懂的地方我也会结合代码给大家做说明,当然我讲解的代码框架还是基于 pytorch, 毕竟已经很久没有接触tensorflow了,主要是pytorch是动态图,二tensorflow是静态图,因此pytorch框架其代码书写方式以及理解程度往往会比tensorflow更容易理解。

这四个部分对应图上的结构分别是
a. input embedding以及positional encoding

b. multi-head attention

c. add & norm

d. feedforward

1.1 Input Embedding and Positional encoding
  1. input embedding
    首先我们定义inputs它的维度大小为[batch_size, sequence_length, embedding dimention], batch_size大家很容易理解,sequence_length指的是一句话的长度如果是中文的话我们可以理解为一句话中所有字的数量,embedding dimention指的是每个字向量的维度。
    刚开始怎么得到我们的input embedding呢?对于英文来说我们可以通过结合切换符号以及结合wordpiece机制进行分词,关于wordpiece的理解可以认为就是将本来有的英文单词再进行再切分得到subword英文考虑到单词的时态,单复数等变化情况,这种方式可以使得模型训练效果更加好。如果对于中文语句的话,则可以通过分词或者按照每个字进行切分,这里建议推荐利用字进行切分,因为相对于字来说常用的汉字也就是4000多个,但是词会有很多组合,所以需要准备大型的词库,对于训练模型不够友好。初始化代码如下
class Dataset(object):
    def __init__(self, word2idx):
        self.word2idx = word2idx        # word2idex={'积':20, '想':21, ...}
        self.unk_index = 1              # 这里的unknow表示不在此字向量表中我们用1 表示
    def tokenize_char(segments):
        sen_embedding = [self.word2idx.get(char, self.unk_index) for char in segments]

我们知道我们模型的输入大小都要保持一致性,所以对于一句话的长度也就是 sequence_length
要保证每一句话都一样长度,对于不同长度大小的sequence我们使用如下的方法:
(1)对于长度短于sequence的语句我们一般通过加入padding项的方法,使其长度补齐到sequence_length。
(2)对于长于sequence的语句我们一般采用截断的方法,使其截取到sequence长度。

  1. positional encoding(位置编码)
    PE_{(pos, 2i)} = sin(pos / 10000^{2i/d_{model}})
    PE_{(pos, 2i+1)} = cos(pos / 10000^{2i/d_{model}})
    这里我们也是通过代码来了解一下咱们位置是怎么编码的。
def get_positional_encoding(max_seq_len, embed_dim):
    # 初始化一个positional encoding
    # embed_dim: 字嵌入的维度
    # max_seq_len: 最大的序列长度
    positional_encoding = np.array([
        [pos / np.power(10000, 2 * i / embed_dim) for i in range(embed_dim)]
        if pos != 0 else np.zeros(embed_dim) for pos in range(max_seq_len)])
    positional_encoding[1:, 0::2] = np.sin(positional_encoding[1:, 0::2])  # dim 2i 偶数
    positional_encoding[1:, 1::2] = np.cos(positional_encoding[1:, 1::2])  # dim 2i+1 奇数
    # 归一化, 用位置嵌入的每一行除以它的模长
    # denominator = np.sqrt(np.sum(position_enc**2, axis=1, keepdims=True))
    # position_enc = position_enc / (denominator + 1e-8)
    return positional_encoding

上面的pos指的是句中字的位置,取值范围[0, sequence_length], i指的是词向量的维度,取值的范围是[0, embedding_dimension]。上面有sincos一组公式, 也就是对应着embedding dimension维度的一组奇数和偶数的序号的维度, 例如0,1一组, 2,3一组, 分别用上面的sincos函数做处理, 从而产生不同的周期性变化, 而位置嵌入在embedding dimension维度上随着维度序号增大, 周期变化会越来越慢, 而产生一种包含位置信息的纹理, 就像论文原文中第六页讲的, 位置嵌入函数的周期从2\pi1000*2\pi变化, 而每一个位置在embedding dimension维度上都会得到不同周期的sincos函数的取值组合, 从而产生独一的纹理位置信息, 模型从而学到位置之间的依赖关系和自然语言的时序特性.
下面这张图可以看出随着embeddding维度的增加,周期变化越慢

image.png

通过上述我们可以得出如何得到input embeding 以及 positional encoding

1.2 Multi-head Attention

关于attention机制算是整个transformer最有价值的部分,从论文的标题**Attention is All You Need。attention机制是让模型知道其关注点在哪里,那些信息是有价值值得关注的。比如下面这句话


不同的 it 是指代是不一样的,到底是指代 street 还是指代 animal呢?这里我们使用的attention机制称为 self-attention,顾名思义也就是自己和自己做attention。
self-attention
从这张图中可以看出 it 更加关注的是 animal这个词, transformer使用 self-attention将相关词的理解编码到当前词中。
下面具体聊下self-attention,这里我会用到李宏毅老师的ppt作为辅助理解。

seq2seq

从上面的seq2seq图中可以看出对于每个b_i来说已经观察到了整个sequence。同时b_i同时被输出出来


首先我们输入sequence x^i,这里每一个x^i可以理解就是每个词(字), 然后将我们x^i通过embedding的方式即图中为了方便显示乘上W的方式得到我们经过embedding后的输入a^i, 对于每一个a^i其拥有三个矩阵,这三个矩阵分别叫做:
【1】query矩阵(查询其他词的矩阵)
【2】key矩阵(用来被其他词查询的矩阵)
【3】value矩阵(用来表示被提取信息值的矩阵)
然后我们会让我们emdding后的矩阵a^i与我们三个矩阵分别相乘得到q^i, k^i, v^i矩阵。
得到此矩阵后,我们需要拿每一个query矩阵q对每一个key矩阵k通过点乘做attention(我们知道通过点乘也是求余弦相似度,也就是说两个token越相似其对应的值也就是越大)。得到如下的图

这里可以理解就是第一个q^1对于每一个k^i做attention得到a_{1,k}。这里的a_{1,i}公式为a_{1,i}=q^1 . k^i/ \sqrt{d},这里的d是qkdimesion, 因为其会影响a大小,这里可以理解就是归一化的意思,也可以理解为就是维度惩罚项。


之后我们会用softmax机制对于输出的a_{1, i}进行归一化,因为需要知道每一个token对于其他token attention的影响概率,softmax分值决定着在这个位置,每个词的表达程度(关注度)。很明显,这个位置的词应该有最高的归一化分数,但大部分时候总是有助于关注该词的相关的词。这里我们用softmax更加直观形象,如上图所示。

最后我们不要忘了还有v矩阵,将其与上述得到的a_{1,i}相乘最后相加得到b^1,将softmax分值与value-vec按位相乘。保留关注词的value值,削弱非相关词的value值。。如上图所示。
从这里可以看到这里我们产生的b_1用了整个sequence的信息。

同理如此我们也可以得到b_2.


因此这里的b_1b_4可以被平行的计算出来。

我们可以将a_1a_4合并成一个 I 矩阵分别于Q, K, V矩阵进行矩阵运算已到达并行效果。

上图表达如何用矩阵计算得到b_1b_4即得到输出矩阵O
说到了这里我们已经知道了什么是 Self-Attention 机制,那什么是 Multi-head Self-attention 呢?下面我们用两个head做举例。

image.png

其实很好理解就是将本来的一个q^i切分成两个q分别是q^{i,1}以及q^{i,2},同理如此k^i以及v^i也被同时切分成k^{i,1}, k^{i,2} 以及v^{i,1}, v^{i,2}。这里的唯一变化就是我们的q^{i,1}只能与k^{i,1}v^{i,1}做矩阵计算,q^{i,2}也是如此。最终我们会得到b^{i,1}以及b^{i,2}, 然后将其concate在一起得到b^{i}, 在经过维度得到W^o。这里为什么要做multi-head呢?因为不同的head关注的点不一样,有的head看的是周围的信息, 有的head看的是比较远的信息,这里可以这里的head看成kernel, 如下图所展示的这样,不同的head所关注的点是不一样的,越深颜色,表示关注的权重越大。


根据上面的图以及前面所说的positional encoding我们也可以得知对于self-attention来说input sequence的顺序来说不重要的,因为对于他来说token和每一个token都做self-attentation。图片也可以方便我们理解为什么positional encoding是相加而不是concate。
OK,在我们已经说完transformer的作用后如何进行seq2seq呢?


image.png

encoding提供的是K, V矩阵,和前面的一样,Decoding的过程在得到Q矩阵后与对应的K, V矩阵计算的出结果。
这张图是一个已经简单的翻译模型,将英文翻译成中文,比如我们输入英文 Why do we work ? 我们需要将其翻译成中文,我们根据序号来看
【1】将英文文本输入到输入到编码器的模型中去
【2】通过编码器得到我们的隐藏层特征
【3】我们输入<start>开始token
【4】解码器结合我们的隐藏层特征输出
【5】我们再将我们得到的 输出到解码器中
【6】解码器结合我们隐藏层特征会输出
【7】依次按照这种方式不断输入到解码器中,我们将得出我们解码器翻译出来的中文语句。
下面的一个动画可以很方便的让我们了解其中的过程。
transform20fps.gif

究竟过程是如何训练的?我们后面说到BERT再去详细介绍。
下面给出self-attention代码,里面也加了很多注释,很容易理解。

class BertSelfAttention(nn.Module):
    """自注意力机制层, 见Transformer(一), 讲编码器(encoder)的第2部分"""
    def __init__(self, config):
        super(BertSelfAttention, self).__init__()
        # 判断embedding dimension是否可以被num_attention_heads整除
        if config.hidden_size % config.num_attention_heads != 0:
            raise ValueError(
                "The hidden size (%d) is not a multiple of the number of attention "
                "heads (%d)" % (config.hidden_size, config.num_attention_heads))
        self.num_attention_heads = config.num_attention_heads
        self.attention_head_size = int(config.hidden_size / config.num_attention_heads)
        self.all_head_size = self.num_attention_heads * self.attention_head_size
        # Q, K, V线性映射
        self.query = nn.Linear(config.hidden_size, self.all_head_size)
        self.key = nn.Linear(config.hidden_size, self.all_head_size)
        self.value = nn.Linear(config.hidden_size, self.all_head_size)

        self.dropout = nn.Dropout(config.attention_probs_dropout_prob)

    def transpose_for_scores(self, x):
        # 输入x为QKV中的一个, 维度: [batch_size, seq_length, embedding_dim]
        # 输出的维度经过reshape和转置: [batch_size, num_heads, seq_length, embedding_dim / num_heads]
        new_x_shape = x.size()[:-1] + (self.num_attention_heads, self.attention_head_size)
        x = x.view(*new_x_shape)
        return x.permute(0, 2, 1, 3)

    def forward(self, hidden_states, attention_mask, get_attention_matrices=False):
        # Q, K, V线性映射
        # Q, K, V的维度为[batch_size, seq_length, num_heads * embedding_dim]
        mixed_query_layer = self.query(hidden_states)
        mixed_key_layer = self.key(hidden_states)
        mixed_value_layer = self.value(hidden_states)
        # 把QKV分割成num_heads份
        # 把维度转换为[batch_size, num_heads, seq_length, embedding_dim / num_heads]
        query_layer = self.transpose_for_scores(mixed_query_layer)
        key_layer = self.transpose_for_scores(mixed_key_layer)
        value_layer = self.transpose_for_scores(mixed_value_layer)

        # Take the dot product between "query" and "key" to get the raw attention scores.
        # Q与K求点积
        attention_scores = torch.matmul(query_layer, key_layer.transpose(-1, -2))
        # attention_scores: [batch_size, num_heads, seq_length, seq_length]
        # 除以K的dimension, 开平方根以归一为标准正态分布
        attention_scores = attention_scores / math.sqrt(self.attention_head_size)
        # Apply the attention mask is (precomputed for all layers in BertModel forward() function)
        attention_scores = attention_scores + attention_mask
        # attention_mask 注意力矩阵mask: [batch_size, 1, 1, seq_length]
        # 元素相加后, 会广播到维度: [batch_size, num_heads, seq_length, seq_length]

        # softmax归一化, 得到注意力矩阵
        # Normalize the attention scores to probabilities.
        attention_probs_ = nn.Softmax(dim=-1)(attention_scores)

        # This is actually dropping out entire tokens to attend to, which might
        # seem a bit unusual, but is taken from the original Transformer paper.
        attention_probs = self.dropout(attention_probs_)

        # 用注意力矩阵加权V
        context_layer = torch.matmul(attention_probs, value_layer)
        # 把加权后的V reshape, 得到[batch_size, length, embedding_dimension]
        context_layer = context_layer.permute(0, 2, 1, 3).contiguous()
        new_context_layer_shape = context_layer.size()[:-2] + (self.all_head_size,)
        context_layer = context_layer.view(*new_context_layer_shape)
        # 输出attention矩阵用来可视化
        if get_attention_matrices:
            return context_layer, attention_probs_
        return context_layer, None
1.3 Add & Norm
Add&Norm.png

这里的Add其实内容和残差神经网络的原理一样,因为随着深度学习网络层数的加深,咱们需要是残差连接的方式即经过multi-head的输出与输入进行相加。

关于Norm,文章使用的是layer-normalization。layer-normalization的作用就是要把神经网络中隐藏层归一为标准正态分布,也就是i.d.d独立同分布,一起到加快训练速度,加速收敛的作用。
矩阵的行(row)为单位求平均:\mu_i=\frac{1}{m}\sum{^m_{i=1}}x_{ij}, 矩阵的行为单位求方差\sigma^2_j=\frac{1}{m} \sum^m_{i=1}(x_{ij} - \mu_j)^2,然后用每一行的每一个元素减去这行的均值,再除以这行的标准差,从而得到归一化的数值,\varepsilon是为了防止分母为0,我们得到下面的式子。
LayerNorm(x) = \alpha \cdot \frac{x_{ij} - \mu_i}{\sqrt{\sigma_i^2 + \varepsilon}} + \beta
下面的图展示了batch norm与layer norm的区别。

Normalizaiton

1.4 feedforward

其实就是linear层就是简单的线性变化,, 两层线性映射并用激活函数激活,一般第一个线性变化都是升维度,第二次在降维加快残差训练速度代码如下:

class BertIntermediate(nn.Module):
    # 封装的FeedForward层和激活层
    def __init__(self, config):
        super(BertIntermediate, self).__init__()
        self.dense = nn.Linear(config.hidden_size, config.intermediate_size)
        self.intermediate_act_fn = ACT2FN[config.hidden_act]

    def forward(self, hidden_states):
        hidden_states = self.dense(hidden_states)
        hidden_states = self.intermediate_act_fn(hidden_states)
        return hidden_states

通过上面的讲解我们发现整个transfomer的运算没有通过矩阵的角度去理解,而是通过矩阵中的每个元素去理解,我们再通过矩阵的角度去理解transformer。



上面的图通过上述的讲解应该很容易去理解了。


这里还有三个小点需要解释

【1】上述的这个过程是一个block,但是我们可以看到上面有一个乘以N,如下图所示,也就是它是有很多个block组成。


【2】我们在做self-attention的过程中,我们通常使用mini-batch来进行计算,也就是说一次计算多几句话,这样的话我们的输入X的维度是[batch_size, sequence_length, embedding_dimension], sequence\_length表示的是句子的长度,而一个mini_batch 是由多个不等长的句子组成,我们就需要按照这个mini_batch中最大的句长对剩余的句子进行补充其长度,上面说了我们一般使用的是0进行padding,单我们这个使用进行softmax的时候就会产生很大的问题。softmax函数的公式我们知道 \sigma(z)_i = \frac{e^z_i}{\sum^K_{j=1}e^{z_j}}, e^0是1,是有值的,这样的话softmax中padding的部分就参与了运算,就等于让无效的部分参与了运算,会产生很大的隐患,这个时候就需要做mask让这些无效的区域不参与预算,我们一般给无效的区域加一个很大的负数的偏执项。

经过上式的masking我们使无效区域经过softmax计算之后还几乎为0,这样就避免了无效区域参与计算
【3】不同的头与输入进行运算之后我们需要将我们的不同头得出的结构进行concate

总结

下面我们将我们对transformer的结构做一个总结
【1】子向量与位置编码


【2】自注意力机制

【3】 残差连接与LayerNormalization


【4】feedforward


2. Decoder

说完了encoder的部分我们再来看咱们的decoder的部分。相对encoder的部分decoder主要的的区别在于它有一个 Masked Multi-Head Attention的部分

关于这一点我讲通过下一篇有关Seq2Seq去讲解。先给出一篇博客他的说明

现在我们已经了解了编码器侧的大部分概念,也基本了解了解码器的工作方式,下面看下他们是如何共同工作的。
编码器从输入序列的处理开始,最后的编码器的输出被转换为K和V,它俩被每个解码器的"encoder-decoder atttention"层来使用,帮助解码器集中于输入序列的合适位置。


transformer_decoding_1.gif

下面的步骤一直重复直到一个特殊符号出现表示解码器完成了翻译输出。每一步的输出被喂到下一个解码器中。正如编码器的输入所做的处理,对解码器的输入增加位置向量。
transformer_decoding_2.gif

在解码器中的self attention 层与编码器中的稍有不同,在解码器中,self-attention 层仅仅允许关注早于当前输出的位置。在softmax之前,通过遮挡未来位置(将它们设置为-inf)来实现。
"Encoder-Decoder Attention "层工作方式跟multi-headed self-attention是一样的,除了一点,它从前层获取输出转成query矩阵,接收最后层编码器的key和value矩阵做key和value矩阵。

The Final Linear and Softmax Layer

解码器最后输出浮点向量,如何将它转成词?这是最后的线性层和softmax层的主要工作。
线性层是个简单的全连接层,将解码器的最后输出映射到一个非常大的logits向量上。假设模型已知有1万个单词(输出的词表)从训练集中学习得到。那么,logits向量就有1万维,每个值表示是某个词的可能倾向值。
softmax层将这些分数转换成概率值(都是正值,且加和为1),最高值对应的维上的词就是这一步的输出单词。


最后给一个csdn博客,也是非常大家去看的。
图解Transformer(完整版)

2. Transformer 优缺点


优点: 这里Path length指的是要计算一个序列长度为n的信息要经过的路径长度。cnn需要增加卷积层数来扩大视野,rnn需要从1到n逐个进行计算,而self-attention只需要一步矩阵计算就可以。所以也可以看出,self-attention可以比rnn更好地解决长时依赖问题。当然如果计算量太大,比如序列长度n>序列维度d这种情况,也可以用窗口限制self-attention的计算数量. Transformer是第一个用纯attention搭建的模型,不仅计算速度更快,在翻译任务上获得了更好的结果,也为后续的BERT模型做了铺垫。

缺点: 欢迎补充

学习完了Transformer, 那如何应用到我们的NLP(自然语言处理)的任务中去了,请看我下一篇的博客BERT(二) BERT解读及应用

参考

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

推荐阅读更多精彩内容