小记:处理LSTM+embedding变长序列

2019-12-12

花了两天的时间学习了一下如何利用torch.nn.utils.rnn来处理变长LSTM,期间遇到了不少bug,特定来记录一下,以后避免踩坑。

前言

在做NLP作业文章打分任务时,由于输入的文章是不定长的,而转成tensor又要求所有的输入单元要等长,因此通常的做法是利用截断、或者填充的做法统一长度,但是这样有可能会造成信息丢失或者是padding过长最后把前面的记忆都遗忘了的情况。因此学习了一下 torch.nn.utils.rnn 自带的 pack_padded_sequence 方法(压包),该方法返回的是一个带有实际处理长度的PackedSequence对象。对应的 pad_packed_sequence 方法(解包)则是将PackedSequence对象返回成数个mini-batch序列,并返回一个实际处理长度的列表。

在处理期间主要遇到了两个问题:

第一个是在每个batch中,除了最长的那个序列所对应的输出是有变化的,其他的几个序列输出值都是一样的,我观测了输入是没毛病的,输出的前几个值也都是不一样的,但是在某个位置之后就都开始一样了。因为我一开始都是取的最后一个时间步的h作为输出,但是通过debug之后发现,对于每个batch,除了最长的序列的输出是正常的,其余的序列最后一个h都是一样的,我觉得可能是这些padding在前向传播过程中压根就没传过来,最后我采取的措施是分别取最后一个不是padding的时间步的h作为输出就解决了;

第二个问题是发现虽然我有几百个验证数据,并且他们的分数上到12下到2,但是最后输出的时候却都集中地趋向于8,不管label是2的还是12的,输出都趋向于8,这个我纳闷了好久。最后发现原因竟然是我的训练数据的分布极不均匀,训练集中一共有1070条数据,但是其中的420条的label是8,只有6条的label是2,60多条label是12,所以这也就造成了为什么我的输出会倾向于8左右(因为训练数据的均值是8)。后来我把label的每个种类的数据条数都扩充至一样的,模型的输出就会区分2、8、12了。

torch.nn.utils.rnn

首先介绍一下padding部分,这部分主要参考了知乎的一篇文章

pad_sequence 方法用于返回利用padding的返回一个长度统一的列表。

1
2
3
4
5
6
7
8
9
10
train_x = [torch.tensor([1, 1, 1, 1, 1, 1, 1]),
torch.tensor([3, 3, 3, 3, 3]),
torch.tensor([6, 6])]

x = rnn_utils.pad_sequence(train_x, batch_first=True, padding_value=0)

# 输出
x = tensor([[1, 1, 1, 1, 1, 1, 1],
[3, 3, 3, 3, 3, 0, 0],
[6, 6, 0, 0, 0, 0, 0]])

我们发现,这个函数会把长度小于最大长度的 sequences 用 0 填充,并且把 list 中所有的元素拼成一个 tensor。这样做的主要目的是为了让 DataLoader 可以返回 batch,因为 batch 是一个高维的 tensor,其中每个元素的数据必须长度相同。

pack_padded_sequence

让我们想一下RNN是如何训练的:对于batch为3的数据,首先投进去time_step都为1的数据、加上他们的hidden state,获得输出;然后再读取下一个time_step的所有数据,再加上上一个时间步的输出,以此类推。以上的数据为例,网络读取数据的顺序是:[1, 3, 6],[1, 3, 6],[1, 3, 0],[1, 3, 0],[1, 3, 0],[1, 0, 0],[1, 0, 0]。显然,对于那些用来padding的0,计算它们显然浪费了大量资源没必要,所以就要想办法在计算的时候不让这些0加进去。这就用到了pack_padded_sequence方法。

pack_padded_sequence 有三个参数:input, lengths, batch_firstinput 是加过 padding 的数据,lengths 是各个 sequence 的实际长度,batch_first是数据各个 dimension 按照 [batch_size, sequence_length, data_dim]顺序排列。

1
2
3
4
rnn_utils.pack_padded_sequence(batch_x, [7,5,2], batch_first=True)

# 输出
PackedSequence(data=tensor([1., 3., 6., 1., 3., 6., 1., 3., 1., 3., 1., 3., 1., 1.]),batch_sizes=tensor([3, 3, 2, 2, 2, 1, 1]))

所以说相当于RNN把每个batch又划分为了更小的batch,并且每个batch的大小是不一样的。原理我们懂了,但是如何方便的既获取padding过的序列又获取每个序列的实际长度呢,这就需要我们自己实现一个类似于DataLoader的数据返回迭代器了。

DataLoader & collate_fn

Pytorch虽然有已经封装好的DataLoader,返回的是迭代对象,用法如下:

1
2
3
4
data = DataLoader(X, y)
loader = DataLoader(dataset=data, batch_size=128)
for X, y in loader:
...

对于本文这种需要返回自定义结构的迭代器,需要自己重写一些细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MyData(torch.utils.data.Dataset):
"""
定义数据读取迭代器结构
"""
def __init__(self, data_seq, data_label):
self.data_seq = data_seq
self.data_label = data_label # 修改传入形参列表
def __len__(self):
return len(self.data_seq)
def __getitem__(self, idx):
return self.data_seq[idx], self.data_label[idx] # 修改方法的返回值


def collate_fn(data):
"""
定义 dataloader 的返回值
:param data: 第0维:data,第1维:label
:return: 序列化的data、记录实际长度的序列、以及label列表
"""
data.sort(key=lambda x: len(x[0]), reverse=True) # pack_padded_sequence 要求要按照序列的长度倒序排列
data_length = [len(sq[0]) for sq in data]
x = [i[0] for i in data]
y = [i[1] for i in data]
data = rnn_utils.pad_sequence(x, batch_first=True, padding_value=0)
return data.unsqueeze(-1), data_length, torch.tensor(y, dtype=torch.float32)

这样一来,每次返回的就是一个PackedSequence对象、一个记录实际长度的序列、以及标签序列。重新定义collate_fn之后的返回结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
batch_x, batch_x_len = loader.next()
batch_x_pack = rnn_utils.pack_padded_sequence(batch_x,
batch_x_len, batch_first=True)
batch_x = tensor([[[1.],
[1.],
[1.],
[1.],
[1.],
[1.],
[1.]],
[[3.],
[3.],
[3.],
[3.],
[3.],
[0.],
[0.]],
[[6.],
[6.],
[0.],
[0.],
[0.],
[0.],
[0.]]])
batch_x_len = [7,5,2]
batch_x_pack = PackedSequence(data=tensor([
[1.],
[3.],
[6.],
[1.],
[3.],
[6.],
[1.],
[3.],
[1.],
[3.],
[1.],
[3.],
[1.],
[1.]]), batch_sizes=tensor([3, 3, 2, 2, 2, 1, 1]))

pad_packed_sequence

pad_packed_sequence 执行的是 pack_padded_sequence 的逆操作。

pack_padded_sequence 将padding矩阵

1
2
3
batch_x = tensor([[[1.], [1.], [1.], [1.], [1.], [1.], [1.]],
[[3.], [3.], [3.], [3.], [3.], [0.], [0.]],
[[6.], [6.], [0.], [0.], [0.], [0.], [0.]]])

加上传入实际的len数组,转化为更小的mini-batch以及对应的len(无padding),输出类型是PackedSequence。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
batch_x_pack = PackedSequence(data=tensor([
[1.],
[3.],
[6.],
[1.],
[3.],
[6.],
[1.],
[3.],
[1.],
[3.],
[1.],
[3.],
[1.],
[1.]]), batch_sizes=tensor([3, 3, 2, 2, 2, 1, 1]))

pad_packed_sequence 将 PackedSequence 转换为具有 padding 的矩阵。

1
2
3
4
5
out, _ = net(batch_x_pack)
out_pad, out_len = rnn_utils.pad_packed_sequence(out, batch_first=True)
out_pad.shape = torch.Size([3, 7, 10]) # batch_size * max_len * hidden_size
out.data.shape = torch.Size([14, 10]) # 实际的元素个数 * hidden_size
out_len = tensor([7, 5, 2])

Embedding

本部分参照THU数据派的一篇文章,讲述如何在网络net中添加embedding层。

本部分需要注意的一点是,前面部分的 pack_padded_sequence 的结果不能直接传进embedding层,因为embedding层不接受PackedSequence对象作为参数,只接收tensor对象,所以只能先利用 pad_packed_sequence 解包再转化为embedding。

1
2
3
4
5
6
7
# 获得训练集、验证集和测试集的预料统计
with open('../../data/token_vocab_pack.json', 'r') as f:
# vocab: OrderedDict,key为词,value为在所有数据集上统计的个数。使用list(token_vocab.keys())可以得到所有词按顺序的列表。
# stoi: dict, key为词, value为索引下标。
# itos: dict, key为索引下标,value为词。
json_token_vocab_pack = f.read()
token_vocab, token_stoi, token_itos, token_num_word = json.loads(json_token_vocab_pack)

上边的代码可以获得词和索引的下标。之后再利用已有的预训练词表,生成一个embeddings_index索引表。然后利用自身语料,为每个词找到对应的向量表示,并且加个序号。这样的话相当于来一个词就先利用token_stoi获取token的id,然后在embedding层利用这个id获取对应行的embedding。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 利用Glove,获得现有语料的权重矩阵,并获得[index,vector]的索引列表,矩阵要被加人nn.embedding()
EMBEDDING_FILE = '../../data/glove.6B.50d.txt'
embeddings_index = {}
for i, line in enumerate(open(EMBEDDING_FILE)):
val = line.split()
embeddings_index[val[0]] = np.asarray(val[1:], dtype='float32')
embedding_matrix = np.zeros((len(token_itos), vocab_dim))
for _index, word in token_itos.items():
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None:
embedding_matrix[int(_index)] = embedding_vector
else:
embedding_matrix[int(_index)] = np.asarray(np.zeros(vocab_dim), dtype='float32') # <unk>
embedding_matrix[0] = np.asarray(np.ones(vocab_dim), dtype='float32') # <pad>

torch.nn包下的Embedding,提供了封装好的可以作为训练的一层,参数为词表大小和维度,然后借助于现有的权重矩阵进行初始化权重,并可设置参不参与调参。

建立词向量层

1
2
3
4
5
# torch.nn.Embedding(n_vocabulary,embedding_size)
self.embedding = nn.Embedding(len(token_itos), vocab_dim)
self.embedding.weight.data.copy_(torch.from_numpy(embedding_matrix))
self.embedding.weight.requires_grad = False # 这里设置embedding层不参与训练
self.embedding_dropout = nn.Dropout2d(0.1)

参考链接

使用Keras和Pytorch处理RNN变长序列输入的方法总结

LSTM在text embedding中的作用(Cross modal retrieval)

【pytorch】关于Embedding和GRU、LSTM的使用详解

pytorch中LSTM笔记

附录代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@ Time : 2019/12/10 上午10:24
@ Author : Vodka
@ File : LSTM .py
@ Software : PyCharm
"""
import json
import torch
import numpy as np
import pandas as pd
import torch.nn as nn
import torch.optim as optim
import torch.nn.utils.rnn as rnn_utils
from torch.utils.data import TensorDataset, DataLoader

batch_size = 64
epoch_size = 1000
softmax_size = 20
input_size = 50
hidden_size = 32
vocab_dim = 50
learning_rate = 0.01


class MyData(torch.utils.data.Dataset):
"""
定义数据读取迭代器结构
"""
def __init__(self, data_seq, data_label):
self.data_seq = data_seq
self.data_label = data_label
def __len__(self):
return len(self.data_seq)
def __getitem__(self, idx):
return self.data_seq[idx], self.data_label[idx]


def collate_fn(data):
"""
定义 dataloader 的返回值
:param data:
:return:
"""
data.sort(key=lambda x: len(x[0]), reverse=True)
data_length = [len(sq[0]) for sq in data]
x = [i[0] for i in data]
y = [i[1] for i in data]
data = rnn_utils.pad_sequence(x, batch_first=True, padding_value=0)
return data.unsqueeze(-1), data_length, torch.tensor(y, dtype=torch.float32).view(-1, 1)


class RNN(nn.Module):
"""
自己封装一个RNN
"""

def __init__(self):
super().__init__()
self.embedding = nn.Embedding(len(token_itos), vocab_dim)
self.embedding.weight.data.copy_(torch.from_numpy(embedding_matrix))
self.embedding.weight.requires_grad = False
self.embedding_dropout = nn.Dropout2d(0.1)
self.embedding_dropout.requires_grad = False
self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True)
self.linear = nn.Linear(hidden_size, 1)

def forward(self, x):
# 先解包传进来的x,得到真实的输入序列和真实长度
_pad, _len = rnn_utils.pad_packed_sequence(x, batch_first=True)
# 过一个embedding
x_embedding = self.embedding(_pad)
x_embedding = x_embedding.view(x_embedding.shape[0], x_embedding.shape[1], -1)
output, _ = self.lstm(x_embedding)
# 拿出来最后一个单词对应的时间步的h作为输出
temp = []
_len = _len - 1
for i in range(len(_len)):
temp.append(output[i, _len[i], :].tolist())
temp = torch.tensor(temp)
# 再过一个线性层
out = self.linear(temp)
# print(out)
return out

# 获得训练集、验证集和测试集的预料统计
with open('../../data/token_vocab_pack.json', 'r') as f:
# vocab: OrderedDict,key为词,value为在所有数据集上统计的个数。使用list(token_vocab.keys())可以得到所有词按顺序的列表。
# stoi: dict, key为词, value为索引下标。
# itos: dict, key为索引下标,value为词。
json_token_vocab_pack = f.read()
token_vocab, token_stoi, token_itos, token_num_word = json.loads(json_token_vocab_pack)

# 利用Glove,获得现有语料的权重矩阵,并获得[index,vector]的索引列表,矩阵要被加入结果从
EMBEDDING_FILE = '../../data/glove.6B.50d.txt'
embeddings_index = {}
for i, line in enumerate(open(EMBEDDING_FILE)):
val = line.split()
embeddings_index[val[0]] = np.asarray(val[1:], dtype='float32')
embedding_matrix = np.zeros((len(token_itos), vocab_dim))
for _index, word in token_itos.items():
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None:
embedding_matrix[int(_index)] = embedding_vector
else:
embedding_matrix[int(_index)] = np.asarray(np.zeros(vocab_dim), dtype='float32') # <unk>
embedding_matrix[0] = np.asarray(np.ones(vocab_dim), dtype='float32') # <pad>

if __name__ == '__main__':

for essay_id in range(1, 9):
# 加载训练集
data = pd.read_csv('../../data/train.csv')
data = data.loc[data['essay_set'] == essay_id]
X_train = data['tokens']
y_train = data['score'].values.reshape(-1, 1)
X = []
y = torch.tensor(y_train)
for essay in X_train:
essay_vec = []
essay_tokens = eval(essay)
for token in essay_tokens:
try:
essay_vec.append(token_stoi[token.lower()])
except:
essay_vec.append(1) # 没见过的词视为<unk>
X.append(torch.tensor(essay_vec))
train = MyData(X, y)
trainloader = DataLoader(train, batch_size=batch_size, collate_fn=collate_fn)

# 加载验证集
data = pd.read_csv('../../data/dev.csv')
data = data.loc[data['essay_set'] == essay_id]
X_dev = data['tokens']
y_dev = data['score'].values.reshape(-1, 1)
X = []
y = torch.tensor(y_dev)
for essay in X_dev:
essay_vec = []
essay_tokens = eval(essay)
for token in essay_tokens:
try:
essay_vec.append(token_stoi[token.lower()])
except:
essay_vec.append(1)
X.append(torch.tensor(essay_vec))
valid = MyData(X, y)
validloader = DataLoader(valid, batch_size=batch_size, collate_fn=collate_fn)

# 定义网络
net = RNN()
optimizer = optim.Adam(net.parameters(), lr=learning_rate)
loss_F = nn.MSELoss()

best_valid_loss = 111111.0
valid_losses = []

for epoch in range(1, epoch_size + 1):
train_loss, valid_loss = [], []

# 训练部分
_index = 0
for data, length, target in trainloader:
batch_x_pack = rnn_utils.pack_padded_sequence(data, length, batch_first=True)
net.zero_grad()
output = net(batch_x_pack)
loss = loss_F(output, target)
loss.backward()
# for i in range(len(output)):
# if(_index==1 or _index==27 or _index==59):
# print(str(output[i].item()) + " " + str(target[i].item()) + " " + str(loss.item()))
optimizer.step()
train_loss.append(loss.item())
_index += 1
print("*********train*********\nEpoch {} of Essay {}, Loss : {} . ".format(epoch, essay_id,
sum(train_loss) / (
len(train_loss))))
# 验证部分
for data, length, target in validloader:
batch_x_pack = rnn_utils.pack_padded_sequence(data, length, batch_first=True)
output = net(batch_x_pack)
loss = loss_F(output, target)
valid_loss.append(loss.item())
print("*********eval*********\nEpoch {} of Essay {}, Loss : {} . ".format(epoch, essay_id,
sum(valid_loss) / (
len(valid_loss))))
# print(list(net.named_parameters()))

# valid_losses.append(sum(valid_loss))
#
# if (len(valid_losses) > 20):
# flag = True
# for i in range(1, 21):
# if (valid_losses[-i] <= best_valid_loss):
# best_valid_loss = valid_losses[-i]
# flag = False
# if flag == True:
# break

torch.save(net, "model_" + str(essay_id) + ".pkl")

本文来源:「想飞的小菜鸡」的个人网站 vodkazy.cn

版权声明:本文为「想飞的小菜鸡」的原创文章,采用 BY-NC-SA 许可协议,转载请附上原文出处链接及本声明。

原文链接:https://vodkazy.cn/2019/12/12/小记:处理LSTM-embedding变长序列

支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者,更多文章请访问想飞的小菜鸡