注意,本文代码来自于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_datatest_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

# 这个函数就是将其变成等长,填充使用<pad>,至于是0还是1还是其他值并不重要,因为还有mask~
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)
>>> packed
PackedSequence(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_unpacked
tensor([[1, 2, 0],
[3, 0, 0],
[4, 5, 6]])
>>> lens_unpacked
tensor([2, 1, 3])
  • lstm输出

hidden, (hn, cn)分别表示每个timestep的输出,最后一个时刻的每层输出,cn表示保存c的值。

所以可以看到,序列标注会用到每个timestep的输出来表示每个token。

  • F.log_softmax和损失函数计算

如果看源码较多的情况下,你会发现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) #使用Adam优化器

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:.2f}")

这部分没啥好说的了,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)
# 前面部分表示句子的每个token,后面表示label。

label一共有两个,0和1,所以为二分类。

2. 有趣的点

整个训练过程貌似和上例没什么不同,但是可以举几个比较有意思的地方。

  • 关于二分类使用CrossEntropyLoss还是BCELoss

这两者本质是一样的,BCELoss就是CrossEntropyLoss的特例。你可以看loss.py

BCEWithLogitsLoss和BCELoss的区别就是一个需要用sigmoid一个不需要。

你可以尝试改动这个代码,将作者使用到的log_softmax和NLLLoss改成使用sigmoid和BCELoss。

  • lstm中hn的输出

既然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