Task4 基于深度学习的文本分类2.2-Word2Vec+TextCNN+BiLSTM+Attention分类
完整代码见:NLP-hands-on/天池-零基础入门NLP at main · ifwind/NLP-hands-on (github.com)
模型架构
模型结构如下图所示,主要包括WordCNNEncoder、SentEncoder、SentAttention和FC模块。
最终需要做的是文档分类任务,从文档的角度出发,文档由多个句子序列组成,而句子序列由多个词组成,因此我们可以考虑从词的embedding->获取句子的embedding->再获得文档的embedding->最后根据文档的embedding对文档分类。
CNN模块常用于图像数据,Convolutional Neural Networks for Sentence Classification等论文将CNN用于文本数据,如下图所示,值得注意的是,CNN的卷积核在文本数据上,卷积核的宽度和word embedding的维度相同。
WordCNNEncoder包括三个不同卷积核大小的CNN层和相应的三个max pooling层,用于对一个句子卷积,然后max pooling得到一个句子的embedding。
SentEncoder包括多个BiLSTM层,将一篇文档中的句子序列作为输入,得到一篇文档中各个句子的embedding。
Attention中输入的一篇文档中各个句子的embedding首先经过线性变化得到key
,query
是可学习的参数矩阵,value
和key
相同,得到每个句子embedding重要性加权的一篇文档的embedding。
每个batch由多个文档组成;文档由多个句子序列组成;句子序列由多个词组成。所以输入整体模型的batch形状为:batch_size, max_doc_len, max_sent_len;
-
输入wordCNNEncoder的batch形状为:batch_size * max_doc_len, max_sent_len(只输入词的id);
- 利用word2vec embedding(固定)和随机初始化的权重(需要被训练)构建word embedding(二者相加):batch_size * max_doc_len,1(添加的一个channel维度,方便做卷积), max_sent_len, word_embed_size;
- 分别经过卷积核为2、3、4的三个CNN层:batch_size * max_doc_len,sentence_len, hidden_size;
- 再分别经过三个相应的max pooling层:batch_size * max_doc_len,1, hidden_size;
- 拼接三个max pooling层的输出:batch_size * max_doc_len,1, 3*hidden_size(sent_rep_size);
- 输出:batch_size * max_doc_len, sent_rep_size;
-
输入SentEncoder的batch形状为:batch_size, max_doc_len,sent_rep_size;
-
输入Attention的batch形状为:batch_size, max_doc_len,2 * hidden_size of lstm;
-
输入FC的batch形状为:batch_size, 2*hidden。
模型代码
根据上述流程分析,模型代码就好理解了,各个模块的模型代码如下。
WordCNNEncoder
WordCNNEncoder
包括两个embedding
层,分别对应batch_inputs1
,对应的embedding 层是可学习的,得到word_embed
;batch_inputs2
,读取的是外部训练好的词向量,这里用的是word2vec的词向量,是不可学习的,得到extword_embed
。将 2 个词向量相加,得到最终的词向量batch_embed
,形状是(batch_size * doc_len, sent_len, 100)
,然后添加一个维度,变为(batch_size * doc_len, 1, sent_len, 100)
,对应 Pytorch 里图像的(B, C, H, W)
。
class WordCNNEncoder(nn.Module):
def __init__(self, log,vocab):
super(WordCNNEncoder, self).__init__()
self.log=log
self.dropout = nn.Dropout(dropout)
self.word_dims = 100 # 词向量的长度是 100 维
# padding_idx 表示当取第 0 个词时,向量全为 0
# 这个 Embedding 层是可学习的
self.word_embed = nn.Embedding(vocab.word_size, self.word_dims, padding_idx=0)
extword_embed = vocab.load_pretrained_embs(word2vec_path,save_word2vec_embed_path)
extword_size, word_dims = extword_embed.shape
self.log.logger.info("Load extword embed: words %d, dims %d." % (extword_size, word_dims))
# # 这个 Embedding 层是不可学习的,通过requires_grad=False控制
self.extword_embed = nn.Embedding(extword_size, word_dims, padding_idx=0)
self.extword_embed.weight.data.copy_(torch.from_numpy(extword_embed))
self.extword_embed.weight.requires_grad = False
input_size = self.word_dims
self.filter_sizes = [2, 3, 4] # n-gram window
self.out_channel = 100
# 3 个卷积层,卷积核大小分别为 [2,100], [3,100], [4,100]
self.convs = nn.ModuleList([nn.Conv2d(1, self.out_channel, (filter_size, input_size), bias=True)
for filter_size in self.filter_sizes])
def forward(self, word_ids, extword_ids):
# word_ids: sentence_num * sentence_len
# extword_ids: sentence_num * sentence_len
# batch_masks: sentence_num * sentence_len
sen_num, sent_len = word_ids.shape
# word_embed: sentence_num * sentence_len * 100
# 根据 index 取出词向量
word_embed = self.word_embed(word_ids)
extword_embed = self.extword_embed(extword_ids)
batch_embed = word_embed + extword_embed
if self.training:
batch_embed = self.dropout(batch_embed)
# batch_embed: sentence_num x 1 x sentence_len x 100
# squeeze 是为了添加一个 channel 的维度,成为 B * C * H * W
# 方便下面做 卷积
batch_embed.unsqueeze_(1)
pooled_outputs = []
# 通过 3 个卷积核做 3 次卷积核池化
for i in range(len(self.filter_sizes)):
# 通过池化公式计算池化后的高度: o = (i-k)/s+1
# 其中 o 表示输出的长度
# k 表示卷积核大小
# s 表示步长,这里为 1
filter_height = sent_len - self.filter_sizes[i] + 1
# conv:sentence_num * out_channel * filter_height * 1
conv = self.convs[i](batch_embed)
hidden = F.relu(conv)
# 定义池化层:word->sentence
mp = nn.MaxPool2d((filter_height, 1)) # (filter_height, filter_width)
# pooled:sentence_num * out_channel * 1 * 1 -> sen_num * out_channel
# 也可以通过 squeeze 来删除无用的维度
pooled = mp(hidden).reshape(sen_num,
self.out_channel)
pooled_outputs.append(pooled)
# 拼接 3 个池化后的向量
# reps: sen_num * (3*out_channel)
reps = torch.cat(pooled_outputs, dim=1)
if self.training:
reps = self.dropout(reps)
return reps
SentEncoder
LSTM 的 hidden_size 为 256,由于是双向的,经过 LSTM 后的数据维度是(batch_size , doc_len, 512)
,然后和 mask 按位置相乘,把没有单词的句子的位置改为 0,最后输出的数据sent_hiddens
,维度依然是(batch_size , doc_len, 512)
。
sent_hidden_size = 256
sent_num_layers = 2
class SentEncoder(nn.Module):
def __init__(self, sent_rep_size):
super(SentEncoder, self).__init__()
self.dropout = nn.Dropout(dropout)
self.sent_lstm = nn.LSTM(
input_size=sent_rep_size, # 每个句子经过 CNN(卷积+池化)后得到 300 维向量
hidden_size=sent_hidden_size,# 输出的维度
num_layers=sent_num_layers,
batch_first=True,
bidirectional=True
)
def forward(self, sent_reps, sent_masks):
# sent_reps: b * doc_len * sent_rep_size
# sent_masks: b * doc_len
# sent_hiddens: b * doc_len * hidden*2
# sent_hiddens: batch, seq_len, num_directions * hidden_size
# containing the output features (h_t) from the last layer of the LSTM, for each t.
sent_hiddens, _ = self.sent_lstm(sent_reps)
# 对应相乘,用到广播,是为了只保留有句子的位置的数值
sent_hiddens = sent_hiddens * sent_masks.unsqueeze(2)
if self.training:
sent_hiddens = self.dropout(sent_hiddens)
return sent_hiddens
Attention
query
的维度是512
,key
和query
相乘,得到outputs
并经过softmax
,维度是(batch_size , doc_len)
,表示分配到每个句子的权重。使用sent_masks
,把没有单词的句子的权重置为-1e32
,得到masked_attn_scores
。最后把masked_attn_scores
和key
相乘,得到batch_outputs
,形状是(batch_size, 512)
。
class Attention(nn.Module):
def __init__(self, hidden_size):
super(Attention, self).__init__()
self.weight = nn.Parameter(torch.Tensor(hidden_size, hidden_size))
self.weight.data.normal_(mean=0.0, std=0.05)
self.bias = nn.Parameter(torch.Tensor(hidden_size))
b = np.zeros(hidden_size, dtype=np.float32)
self.bias.data.copy_(torch.from_numpy(b))
self.query = nn.Parameter(torch.Tensor(hidden_size))
self.query.data.normal_(mean=0.0, std=0.05)
def forward(self, batch_hidden, batch_masks):
# batch_hidden: b * doc_len * hidden_size (2 * hidden_size of lstm)
# batch_masks: b x doc_len
# linear
# key: b * doc_len * hidden
key = torch.matmul(batch_hidden, self.weight) + self.bias
# compute attention
# matmul 会进行广播
#outputs: b * doc_len
outputs = torch.matmul(key, self.query)
# 1 - batch_masks 就是取反,把没有单词的句子置为 0
# masked_fill 的作用是 在 为 1 的地方替换为 value: float(-1e32)
masked_outputs = outputs.masked_fill((1 - batch_masks).bool(), float(-1e32))
#attn_scores:b * doc_len
attn_scores = F.softmax(masked_outputs, dim=