[NLP] NLP 入门学习笔记

287 阅读9分钟

常见 NLP 模型的 Pytorch 实现:

github.com/graykode/nl…

github.com/DSKSD/DeepN…

1. Basic Embedding Model

1.1 NNLM(Neural Network Language Model)

github.com/graykode/nl… 这里的参数:

n_class 是所有可能出现的单词数量,也就是词典容量

n_step 是要预测一个单词时,该单词之前的用于预测的单词的数量

n_hidden 是一个超参数,

batch_size 是批数,相当于句子数量

这个论文的核心就是这个模型的实现

class NNLM(nn.Module):
    def __init__(self):
        super(NNLM, self).__init__()
        self.C = nn.Embedding(n_class, m)
        self.H = nn.Linear(n_step * m, n_hidden, bias=False)
        self.d = nn.Parameter(torch.ones(n_hidden))
        self.U = nn.Linear(n_hidden, n_class, bias=False)
        self.W = nn.Linear(n_step * m, n_class, bias=False)
        self.b = nn.Parameter(torch.ones(n_class))

    def forward(self, X):
        X = self.C(X) # X : [batch_size, n_step, m]
        X = X.view(-1, n_step * m) # [batch_size, n_step * m]
        tanh = torch.tanh(self.d + self.H(X)) # [batch_size, n_hidden]
        output = self.b + self.W(X) + self.U(tanh) # [batch_size, n_class]
        return output

1.1.1 nn.Embedding(n_class, m)

这个模型的实现的核心就是一个 nn.Embedding(n_class, m)

一般人刚学的时候只知道全连接层,softmax 层,卷积层之类的,反正我是这样,然后我就有一个错觉就是,模块的输入参数,第一个参数一定是输入的维度,第二个参数一定是输出的维度,其实不是这样

比如这个嵌入层,表示创建词典容量个特征向量,第一个参数是词典容量,第二个参数是特征向量的长度

可以使用输出来查看模块的权重,这也是理解模块的一种方式

print(model.C.weight)

或者直接在 PyCharm 的科学模式里看

image.png

输入到嵌入层的向量应该是 int 数组,输入 i,输出第 i 个特征向量

这样,如果输入是 [batch_size, sequence_length] 输出就是 [batch_size, sequence_length, embedding_size] 相当于在尾部加了一个维度

1.1.2 X = X.view(-1, n_step * m)

第二个核心是将输入转换为 (-1, n_step * m) 的长度

虽然这个看上去很平常,但是他其实包含一个思想,就是把 n_step 个特征向量组合在一起,这样就真正实现了,预测一个词,要查看之前的 n_step 个词的这个思想

在之后,所有的操作都是对这个 n_step * m 的整体进行操作了,一定程度上可以不用看之后的模块的,就是常见的全连接,激活

1.2 Word2Vec(Skip-gram)

1.2.1 从单词到短语

之前的论文的核心就是把单词转换成一个特征向量,然后在预测某个单词的时候,将这个单词之前的若干个单词的特征向量组成的矩阵作为神经网络的输入,输出一个词典容量的向量,向量的第 i 个元素表示这个被预测的词属于词典种第 i 个词的概率

单词表示由于无法表示非单个单词组成的惯用短语而受到限制。例如,《波士顿环球报》是一份报纸,因此它不是“波士顿”和“环球报”含义的自然组合。因此,使用向量来表示整个短语使得 Skip gram 模型更具表现力。其他旨在通过组合单词向量来表示句子含义的技术,如递归自动编码器,也将受益于使用短语向量而不是单词向量

那么现在的任务依然是在预测某个单词的时候,查看这个单词周围的若干个单词,只是多一个考虑:在发现这些用于预测的单词可以组成短语的时候,就把他们视为短语而不是离散的单词

给定一个单词序列,怎么识别其中是否有短语呢,这就是一个典型的分类问题,可以另建一个网络,设计一个测试集来训练它

1.2.2 传统 Word2Vec(Skip-gram) 的实现

论文:arxiv.org/abs/1301.37…

感觉这个讲的不错

论文里面没有详细讲传统的 Word2Vec(Skip-gram) 的实现

blog.csdn.net/weixin_4373…

单词相似度是两个词向量之间的内积

他这里的:

对于W0而言,所参与的矩阵运算并不是通过一个矩阵乘法实现,而是通过指定ID,对参数W0进行访存的方式获取。

实际上就是,原本是 one-hot 矩阵 * embedding 矩阵

现在是使用 nn.Embedding(vocab_size, embedding_size)

但是他这里的 Skip-gram 的实际实现我是没怎么看懂

而且他也没有提到短语相关的

我看原论文也看不懂负采样和分层 softmax 是怎么实现的……


看到 zhuanlan.zhihu.com/p/549925497

这里推导了原 Skip-gram 的梯度公式

image.png

因为有一个 1 到 V 的累加,V 是词典总量,所以时间复杂度主要来源于这一项

1.2.3 负采样和分层 softmax

这个例子里面写的感觉没有用到负采样和分层 softmax:

github.com/graykode/nl…

他甚至用的输入是,对应词袋中序号 i 的单词,对应一个向量 [0,0,...,0,1,0,0...,0],第 i 个位置上是 1 其他位置上是零

random_inputs.append(np.eye(voc_size)[skip_grams[i][0]])

其实就是一个 one-hot 向量

输出是相同形状的向量,应该是每个位置表示了预测词是第 i 个词的概率

倒是他有一个步骤挺有趣,就是输出第一个全连接层的权重 WW 的形状是 [embedding_size, voc_size]

for i, label in enumerate(word_list):
    W, WT = model.parameters()
    x, y = W[0][i].item(), W[1][i].item()
    plt.scatter(x, y)
    plt.annotate(label, xy=(x, y), xytext=(5, 2), textcoords='offset points', ha='right', va='bottom')
plt.show()

这里 embedding_size 为 2,刚好可以在平面上画出来,W[:,i] 表示了词袋中第 i 个单词对应的 W 中的权重(因为输入是 one-hot 向量)


感觉这个讲的不错

zhuanlan.zhihu.com/p/568064512

假设输入和输出是同一类型的数据,比如输入和输出都是词语在词袋中的序号

负采样适用于在所有可能的输出中,相对于要预测的输出而言,正例比较少而负例比较多的情形

比如我要输出的是 'apple',可能一个训练集成千上万个单词,只有十几个是跟 'apple' 相似的水果之类的单词

在这里给出了负采样的 Skip-gram 实现

nbviewer.org/github/DSKS…

核心是

class SkipgramNegSampling(nn.Module):
    
    def __init__(self, vocab_size, projection_dim):
        super(SkipgramNegSampling, self).__init__()
        self.embedding_v = nn.Embedding(vocab_size, projection_dim) # center embedding
        self.embedding_u = nn.Embedding(vocab_size, projection_dim) # out embedding
        self.logsigmoid = nn.LogSigmoid()
                
        initrange = (2.0 / (vocab_size + projection_dim))**0.5 # Xavier init
        self.embedding_v.weight.data.uniform_(-initrange, initrange) # init
        self.embedding_u.weight.data.uniform_(-0.0, 0.0) # init
        
    def forward(self, center_words, target_words, negative_words):
        center_embeds = self.embedding_v(center_words) # B x 1 x D
        target_embeds = self.embedding_u(target_words) # B x 1 x D
        
        neg_embeds = -self.embedding_u(negative_words) # B x K x D
        
        positive_score = target_embeds.bmm(center_embeds.transpose(1, 2)).squeeze(2) # Bx1
        negative_score = torch.sum(neg_embeds.bmm(center_embeds.transpose(1, 2)).squeeze(2), 1).view(negs.size(0), -1) # BxK -> Bx1
        
        loss = self.logsigmoid(positive_score) + self.logsigmoid(negative_score)
        
        return -torch.mean(loss)
    
    def prediction(self, inputs):
        embeds = self.embedding_v(inputs)
        
        return embeds

详细的公式可见 zhuanlan.zhihu.com/p/144146838

这里的 loss = self.logsigmoid(positive_score) + self.logsigmoid(negative_score) 就对应

image.png

已经见过前面的推导,可知对 viv_i 求导之后,第二项只有 K 个项的累加

这对应了我们取 k 个未在窗口内出现的反例


从这个代码也可以看到负采样取到某个负例的具体过程

已知一个公式 P(x)=f(P(x))P'(x) = f(P(x)),x 表示一个随机变量,在这里就是一个词语变量;P(x)P(x) 表示 x 词的出现概率,可以用它在训练集中的频率来表示;P(x)P'(x) 表示 x 在负例集中出现的概率

然后对于一个 target 单词,以 P'(target) 的概率分布从负例集中取出一个单词 neg,如果 neg == target 则重取,否则 neg 就是取到的一个负例

那么怎么在代码中实现这个 P(x)P'(x) 呢?这里使用的是,初始时负例集为空,遍历词袋,对词袋中每一个值,乘以 int(f(P(x))),加到负例集中,比如 'apple' 在测试集中的频率经过 f()f(\cdot) 之后得到 4,那么负例集中就加上四个 'apple'

这样,在最终得到的负例集中均匀随机取值,取到的某一单词的概率就是 P(x)P'(x)


所以说,感觉能够创新的点就是使用一些语言学上的先验知识,像是这种“一般来说反例比较多”就是一个人类的经验

2. CNN(Convolutional Neural Network)

2.1 TextCNN - Binary Sentiment Classification

3. RNN(Recurrent Neural Network)

3.1 TextRNN - Predict Next Step

3.2 TextLSTM - Autocomplete

github.com/graykode/nl…

论文真的看不懂……

看了这个感觉讲的很详细

zhuanlan.zhihu.com/p/79064602

比如讲了跨 batch 的并行

3.3 Bi-LSTM - Predict Next Word in Long Sentence

跟之前的差不多,就是设置成了双向

4. Attention Mechanism

4.1 Seq2Seq - Change Word

4.2 Seq2Seq with Attention - Translate

github.com/graykode/nl…

看到这个讲的不错:

zhuanlan.zhihu.com/p/47063917

注意力机制与 RNN 结合的核心:

class Attention(nn.Module):
    def __init__(self):
        super(Attention, self).__init__()
        self.enc_cell = nn.RNN(input_size=n_class, hidden_size=n_hidden, dropout=0.5)
        self.dec_cell = nn.RNN(input_size=n_class, hidden_size=n_hidden, dropout=0.5)

        # Linear for attention
        self.attn = nn.Linear(n_hidden, n_hidden)
        self.out = nn.Linear(n_hidden * 2, n_class)

    def forward(self, enc_inputs, hidden, dec_inputs):
        enc_inputs = enc_inputs.transpose(0, 1)  # enc_inputs: [n_step(=n_step, time step), batch_size, n_class]
        dec_inputs = dec_inputs.transpose(0, 1)  # dec_inputs: [n_step(=n_step, time step), batch_size, n_class]

        # enc_outputs : [n_step, batch_size, num_directions(=1) * n_hidden], matrix F
        # enc_hidden : [num_layers(=1) * num_directions(=1), batch_size, n_hidden]
        enc_outputs, enc_hidden = self.enc_cell(enc_inputs, hidden)

        trained_attn = []
        hidden = enc_hidden
        n_step = len(dec_inputs)
        model = torch.empty([n_step, 1, n_class])

        for i in range(n_step):  # each time step
            # dec_output : [n_step(=1), batch_size(=1), num_directions(=1) * n_hidden]
            # hidden : [num_layers(=1) * num_directions(=1), batch_size(=1), n_hidden]
            dec_output, hidden = self.dec_cell(dec_inputs[i].unsqueeze(0), hidden)
            attn_weights = self.get_att_weight(dec_output, enc_outputs)  # attn_weights : [1, 1, n_step]
            trained_attn.append(attn_weights.squeeze().data.numpy())

            # matrix-matrix product of matrices [1,1,n_step] x [1,n_step,n_hidden] = [1,1,n_hidden]
            context = attn_weights.bmm(enc_outputs.transpose(0, 1))
            dec_output = dec_output.squeeze(0)  # dec_output : [batch_size(=1), num_directions(=1) * n_hidden]
            context = context.squeeze(1)  # [1, num_directions(=1) * n_hidden]
            model[i] = self.out(torch.cat((dec_output, context), 1))

        # make model shape [n_step, n_class]
        return model.transpose(0, 1).squeeze(0), trained_attn

其中计算注意力在 forward()for i in range(n_step) 部分

h:encoder 的 hidden state 对应代码的 enc_outputs

s:decoder 的 hidden state 对应代码的 dec_output

e:h 和 s 的运算 对应代码中的 attn_weights = self.get_att_weight(dec_output, enc_outputs) 并且已经 softmax 过

c:context 对应代码中的 context = attn_weights.bmm(enc_outputs.transpose(0, 1))

st=f(st1,yt1,ct)s_t = f(s_{t-1}, y_{t-1}, c_{t}):对应代码中的 model[i] = self.out(torch.cat((dec_output, context), 1))

5. Model based on Transformer

5.1 The Transformer - Translate

github.com/graykode/nl…

image.png

class Transformer(nn.Module):
    def __init__(self):
        super(Transformer, self).__init__()
        self.encoder = Encoder()
        self.decoder = Decoder()
        self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False)
    def forward(self, enc_inputs, dec_inputs):
        enc_outputs, enc_self_attns = self.encoder(enc_inputs)
        dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
        dec_logits = self.projection(dec_outputs) # dec_logits : [batch_size x src_vocab_size x tgt_vocab_size]
        return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns

enc_inputs 相当于图 1 中的 Input

dec_inputs 相当于图 1 中的 Outputs

Encoder() 相当于图 1 中的左侧

Decoder() 相当于图 1 中的右侧

dec_logits = self.projection(dec_outputs) 感觉应该是从 decoder 出来到词向量的最后的全连接层

self.decoder(dec_inputs, enc_inputs, enc_outputs) 中使用了来自 encoder 的输出 enc_outputs,对应图 1 中从左到右的,从 Add $ NormMulti-Head Attention 的那一条连线

class Encoder(nn.Module):
    def __init__(self):
        super(Encoder, self).__init__()
        self.src_emb = nn.Embedding(src_vocab_size, d_model)
        self.pos_emb = nn.Embedding.from_pretrained(get_sinusoid_encoding_table(src_len+1, d_model),freeze=True)
        self.layers = nn.ModuleList([EncoderLayer() for _ in range(n_layers)])

    def forward(self, enc_inputs): # enc_inputs : [batch_size x source_len]
        enc_outputs = self.src_emb(enc_inputs) + self.pos_emb(torch.LongTensor([[1,2,3,4,0]]))
        enc_self_attn_mask = get_attn_pad_mask(enc_inputs, enc_inputs)
        enc_self_attns = []
        for layer in self.layers:
            enc_outputs, enc_self_attn = layer(enc_outputs, enc_self_attn_mask)
            enc_self_attns.append(enc_self_attn)
        return enc_outputs, enc_self_attns

self.src_emb 是输入转化成词向量的过程

self.pos_emb 是图 1 中的 Positional Encoding

n_layers 是图 1 中的 N ×

EncoderLayer() 是图 1 中左侧中的 N × 对应的浅色方框

Decoder 类似

class EncoderLayer(nn.Module):
    def __init__(self):
        super(EncoderLayer, self).__init__()
        self.enc_self_attn = MultiHeadAttention()
        self.pos_ffn = PoswiseFeedForwardNet()

    def forward(self, enc_inputs, enc_self_attn_mask):
        enc_outputs, attn = self.enc_self_attn(enc_inputs, enc_inputs, enc_inputs, enc_self_attn_mask) # enc_inputs to same Q,K,V
        enc_outputs = self.pos_ffn(enc_outputs) # enc_outputs: [batch_size x len_q x d_model]
        return enc_outputs, attn

MultiHeadAttention() 对应图 1 中的 MultiHeadAttention + Add & Norm

PoswiseFeedForwardNet() 对应图 1 中的 FeedForward + Add & Norm

DecoderLayer 类似

image.png

图 1 中进入 MultiHeadAttention 的三个箭头分别对应图 2 中的 Q K V

class MultiHeadAttention(nn.Module):
    def __init__(self):
        super(MultiHeadAttention, self).__init__()
        self.W_Q = nn.Linear(d_model, d_k * n_heads)
        self.W_K = nn.Linear(d_model, d_k * n_heads)
        self.W_V = nn.Linear(d_model, d_v * n_heads)
        self.linear = nn.Linear(n_heads * d_v, d_model)
        self.layer_norm = nn.LayerNorm(d_model)

    def forward(self, Q, K, V, attn_mask):
        # q: [batch_size x len_q x d_model], k: [batch_size x len_k x d_model], v: [batch_size x len_k x d_model]
        residual, batch_size = Q, Q.size(0)
        # (B, S, D) -proj-> (B, S, D) -split-> (B, S, H, W) -trans-> (B, H, S, W)
        q_s = self.W_Q(Q).view(batch_size, -1, n_heads, d_k).transpose(1,2)  # q_s: [batch_size x n_heads x len_q x d_k]
        k_s = self.W_K(K).view(batch_size, -1, n_heads, d_k).transpose(1,2)  # k_s: [batch_size x n_heads x len_k x d_k]
        v_s = self.W_V(V).view(batch_size, -1, n_heads, d_v).transpose(1,2)  # v_s: [batch_size x n_heads x len_k x d_v]

        attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1) # attn_mask : [batch_size x n_heads x len_q x len_k]

        # context: [batch_size x n_heads x len_q x d_v], attn: [batch_size x n_heads x len_q(=len_k) x len_k(=len_q)]
        context, attn = ScaledDotProductAttention()(q_s, k_s, v_s, attn_mask)
        context = context.transpose(1, 2).contiguous().view(batch_size, -1, n_heads * d_v) # context: [batch_size x len_q x n_heads * d_v]
        output = self.linear(context)
        return self.layer_norm(output + residual), attn # output: [batch_size x len_q x d_model]

self.W_Q, self.W_K, self.W_V 对应图 2 右侧底部的 Linear

self.linear 对应图 2 右侧顶部的 Linear

ScaledDotProductAttention()(q_s, k_s, v_s, attn_mask) 对应图 1 左侧 = 图 2 右侧的 Scaled Dor-Product Attention + Concat

class ScaledDotProductAttention(nn.Module):
    def __init__(self):
        super(ScaledDotProductAttention, self).__init__()

    def forward(self, Q, K, V, attn_mask):
        scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k) # scores : [batch_size x n_heads x len_q(=len_k) x len_k(=len_q)]
        scores.masked_fill_(attn_mask, -1e9) # Fills elements of self tensor with value where mask is one.
        attn = nn.Softmax(dim=-1)(scores)
        context = torch.matmul(attn, V)
        return context, attn

torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k) 对应图 1 左侧的 MaxmMul + Scale

之后也容易看出来

5.2 BERT - Classification Next Sentence & Predict Masked Tokens

论文好复杂……