注意,本文代码来自于plm-nlp-code 。
学习任何模型都需要一个简单可行的例子进行说明,我会基于plm-nlp-code
的代码进行说明lstm在序列标注
和句子极性二分类
两个例子的应用。
序列标注 参考文件lstm_postag.py .
1. 加载数据 1 2 train_data, test_data, vocab, pos_vocab = load_treebank()
其中load_treebank代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def load_treebank (): nltk.set_proxy('http://192.168.0.28:1080' ) nltk.download('treebank' ) from nltk.corpus import treebank sents, postags = zip (*(zip (*sent) for sent in treebank.tagged_sents())) vocab = Vocab.build(sents, reserved_tokens=["<pad>" ]) tag_vocab = Vocab.build(postags) train_data = [(vocab.convert_tokens_to_ids(sentence), tag_vocab.convert_tokens_to_ids(tags)) for sentence, tags in zip (sents[:3000 ], postags[:3000 ])] test_data = [(vocab.convert_tokens_to_ids(sentence), tag_vocab.convert_tokens_to_ids(tags)) for sentence, tags in zip (sents[3000 :], postags[3000 :])] return train_data, test_data, vocab, tag_vocab
加载后可以看到,train_data
和test_data
都是list,其中每一个sample都是tuple,分别是input和target。如下:
1 2 3 4 >>> train_data[0 ]>>> Out[1 ]: ([2 , 3 , 4 , 5 , 6 , 7 , 4 , 8 , 9 , 10 , 11 , 12 , 13 , 14 , 15 , 16 , 17 , 18 ], [1 , 1 , 2 , 3 , 4 , 5 , 2 , 6 , 7 , 8 , 9 , 10 , 8 , 5 , 9 , 1 , 3 , 11 ])
2. 数据处理 1 2 3 4 5 6 7 8 9 10 def collate_fn (examples ): lengths = torch.tensor([len (ex[0 ]) for ex in examples]) inputs = [torch.tensor(ex[0 ]) for ex in examples] targets = [torch.tensor(ex[1 ]) for ex in examples] inputs = pad_sequence(inputs, batch_first=True , padding_value=vocab["<pad>" ]) targets = pad_sequence(targets, batch_first=True , padding_value=vocab["<pad>" ]) return inputs, lengths, targets, inputs != vocab["<pad>" ]
3. 模型部分 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class LSTM (nn.Module): def __init__ (self, vocab_size, embedding_dim, hidden_dim, num_class ): super (LSTM, self).__init__() self.embeddings = nn.Embedding(vocab_size, embedding_dim) self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True ) self.output = nn.Linear(hidden_dim, num_class) init_weights(self) def forward (self, inputs, lengths ): embeddings = self.embeddings(inputs) x_pack = pack_padded_sequence(embeddings, lengths, batch_first=True , enforce_sorted=False ) hidden, (hn, cn) = self.lstm(x_pack) hidden, _ = pad_packed_sequence(hidden, batch_first=True ) outputs = self.output(hidden) log_probs = F.log_softmax(outputs, dim=-1 ) return log_probs
其中有几个地方可能需要注意的:
pack_padded_sequence和pad_packed_sequence 因为lstm为rnn模型,样本输入不一定是等长的,那么torch提供了这两个函数进行统一处理,length告诉lstm,等超过length时这个样本后面pad进来的就不再计算了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 >>> from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence>>> seq = torch.tensor([[1 ,2 ,0 ], [3 ,0 ,0 ], [4 ,5 ,6 ]])>>> lens = [2 , 1 , 3 ]>>> packed = pack_padded_sequence(seq, lens, batch_first=True , enforce_sorted=False )>>> packedPackedSequence(data=tensor([4 , 1 , 3 , 5 , 2 , 6 ]), batch_sizes=tensor([3 , 2 , 1 ]), sorted_indices=tensor([2 , 0 , 1 ]), unsorted_indices=tensor([1 , 2 , 0 ])) >>> seq_unpacked, lens_unpacked = pad_packed_sequence(packed, batch_first=True )>>> seq_unpackedtensor([[1 , 2 , 0 ], [3 , 0 , 0 ], [4 , 5 , 6 ]]) >>> lens_unpackedtensor([2 , 1 , 3 ])
hidden, (hn, cn)分别表示每个timestep的输出,最后一个时刻的每层输出,cn表示保存c的值。
所以可以看到,序列标注会用到每个timestep的输出来表示每个token。
如果看源码较多的情况下,你会发现log_softmax或者softmax会和CrossEntropyLoss出现在一起,这里很简单理解,因为CrossEntropyLoss由两个函数组成,log_softmax和NLLLoss,log_softmax或者softmax是做归一化,由分数转成概率,log_softmax是平滑。NLLLoss负责取target index对应的logits score,然后除以总分。目的使之最大。
3. 训练 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 nll_loss = nn.NLLLoss() optimizer = optim.Adam(model.parameters(), lr=0.001 ) model.train() for epoch in range (num_epoch): total_loss = 0 for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch} " ): inputs, lengths, targets, mask = [x.to(device) for x in batch] lengths = lengths.cpu() log_probs = model(inputs, lengths) loss = nll_loss(log_probs[mask], targets[mask]) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print (f"Loss: {total_loss:.2 f} " )
这部分没啥好说的了,log_probs
为三维矩阵,比如torch.Size([32, 58, 47])
,表示batch_size=32,seq_length=58,一共47个tags。
推理部分就是argmax取其最大的tag index,可以看:
1 2 3 4 5 6 7 8 acc = 0 total = 0 for batch in tqdm(test_data_loader, desc=f"Testing" ): inputs, lengths, targets, mask = [x.to(device) for x in batch] with torch.no_grad(): output = model(inputs, lengths) acc += (output.argmax(dim=-1 ) == targets)[mask].sum ().item() total += mask.sum ().item()
句子极性二分类 参考文件lstm_sent_polarity.py 。
这个名字自己起的,任务目标具体就是对输入句子做二分类。
1. 加载数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def load_sentence_polarity (): nltk.set_proxy('http://192.168.0.28:1080' ) nltk.download('sentence_polarity' ) from nltk.corpus import sentence_polarity vocab = Vocab.build(sentence_polarity.sents()) train_data = [(vocab.convert_tokens_to_ids(sentence), 0 ) for sentence in sentence_polarity.sents(categories='pos' )[:4000 ]] \ + [(vocab.convert_tokens_to_ids(sentence), 1 ) for sentence in sentence_polarity.sents(categories='neg' )[:4000 ]] test_data = [(vocab.convert_tokens_to_ids(sentence), 0 ) for sentence in sentence_polarity.sents(categories='pos' )[4000 :]] \ + [(vocab.convert_tokens_to_ids(sentence), 1 ) for sentence in sentence_polarity.sents(categories='neg' )[4000 :]] return train_data, test_data, vocab
关于数据格式:
1 2 3 train_data[321 ] Out[2 ]: ([6 , 6 , 6 , 4489 , 1337 , 15065 , 3252 , 6 ], 0 )
label一共有两个,0和1,所以为二分类。
2. 有趣的点 整个训练过程貌似和上例没什么不同,但是可以举几个比较有意思的地方。
关于二分类使用CrossEntropyLoss还是BCELoss
这两者本质是一样的,BCELoss就是CrossEntropyLoss的特例。你可以看loss.py 。
BCEWithLogitsLoss和BCELoss的区别就是一个需要用sigmoid一个不需要。
你可以尝试改动这个代码,将作者使用到的log_softmax和NLLLoss改成使用sigmoid和BCELoss。
既然hn表示timestep的最后一个时刻的输出,那么我们也有理由相信,最后一个时刻的feature可以代表整个句子的feature。
那么就需要关注下hn的输出具体是什么样子了。
源码输出example比如:
1 2 hn.shape Out[2 ]: torch.Size([1 , 32 , 256 ])
其中1是因为num_layers为1,又不是双向lstm,所以为1。
而如果改成双向lstm,bidirectional=True
,那么,
1 2 hn.shape Out[2 ]: torch.Size([2 , 32 , 256 ])
如果num_layers为3,那么:
1 2 hn.shape Out[2 ]: torch.Size([6 , 32 , 256 ])
到这里我们就要理解下他输出的含义了?
他表示一共有6个层,即3个双向lstm,而双向的实现,就是正向计算一次,反向再计算一次,即[::-1],那么一共6层。
整个模型更改如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class LSTM (nn.Module): def __init__ (self, vocab_size, embedding_dim, hidden_dim, num_class ): super (LSTM, self).__init__() self.embeddings = nn.Embedding(vocab_size, embedding_dim) self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True , bidirectional=True , num_layers=3 ) self.output = nn.Linear(hidden_dim * 6 , num_class) def forward (self, inputs, lengths ): embeddings = self.embeddings(inputs) x_pack = pack_padded_sequence(embeddings, lengths, batch_first=True , enforce_sorted=False ) hidden, (hn, cn) = self.lstm(x_pack) outputs = self.output(hn.permute(1 ,0 ,2 ).reshape(-1 , 6 * 256 )) log_probs = F.log_softmax(outputs, dim=-1 ) return log_probs