1 引言
各位朋友好,欢迎来到月来客栈,我是掌柜空字符。
谈到NLP的各个任务场景中,对于数据预处理来说Padding应该算得上是最常见的操作之一了。几乎无论是什么模型,由于文本序列天然长度不一的特性,以及为了能够最大化模型计算效率的缘故,通常情况下都会将一批(batch)文本序列同时进行处理。因此,自然而然就需要将长短不一的文本处理成长度相同的形式。
通常情况来说,对于文本的Padding处理有2种处理办法,第一种是按照某一长度(例如所有文本序列最大长度、或是平均长度等)对训练集中所有样本进行Padding处理。这样做的好处是可以使得所有处理后的样本保持相同的长度,但是明显的坏处就是当训练集中文本序列长度差异过大时,就容易降低模型的效率,同时在模型场景下Padding部分太长可能还会影响模型的效果。因此,另外一种做法就是在模型训练过程中以每个batch的样本为整体,按照当前batch中最长样本的长度为标准对其它样本进行Padding处理。
虽然这两种做法的原理很是简单也容易理解,但是各个深度学习框架中似乎都没有提供直接的方法来供大家使用,因此掌柜在后台也收到不少私信问应该怎么实现自定义的Padding方式。下面掌柜就以一个简单的文本分类数据为例,来手把手地教大家如何实现一个通过参数控制的自定义Padding方法。
由于Padding方法需要和PyTorch中的DataLoader
类配合使用,所以掌柜这里就以一个完整的文本分类数据集构建流程为例来进行介绍。
2 构建类分类数据集
对于构建类似文本分类的数据集来说,总体上可以分为4个步骤:①构建字典;②将文本中的每一个词(字)转换为Token序列;③对不同长度的样本序列按照某个标准进行padding处理;④构建DataLoader
类。在这4步中,第③步就是今天我们要介绍的重点。
现在假设我们有如下格式的原始数据:
xxxxxxxxxx
161问君能有几多愁,恰似一江春水向东流。 0
2年年岁岁花相似,岁岁年年人不同。 0
3去年今日此门中,人面桃花相映红。 2
4人面不知何处去,桃花依旧笑春风。 1
5渺沧海之一粟,羡长江之无穷。 3
6人面不知何处去,桃花依旧笑春风。 1
7月来客栈。 1
8黄藤酒,红酥手,满园春色宫墙柳。 2
9曾经沧海难为水,除却巫山不是云。 1
10取次花丛赖回顾,半缘修道半缘君。 2
11人生不相见,动如参与商。 0
12今夕是何夕,供此灯烛光。 2
13少壮能几时,鬓发各已苍。 1
14访旧半为鬼,惊呼热中肠。 1
15昔别君未婚,儿女忽成行。 0
16焉知二十载,重上君子堂。 1
其中文本与标签之间通过一个控制符进行分割,下面我们开始来一步步构建数据集。
2.1 构建字典
对于任何NLP任务来说,首先需要构建的就是将文本转换为索引的字典。通常来说,我们可以以分词或者以单子字为颗粒度来建立字典。整体实现代码如下所示:
xxxxxxxxxx
181from torchtext.vocab import Vocab
2
3def tokenizer(s, word=False):
4 if word:
5 r = [w for w in s]
6 else:
7 s = jieba.cut(s, cut_all=False)
8 r = " ".join(s).split()
9 return r
10
11def build_vocab(tokenizer, filepath, word, min_freq, specials=None):
12 if specials is None:
13 specials = ['<unk>', '<pad>', '<bos>', '<eos>']
14 counter = Counter()
15 with open(filepath, encoding='utf8') as f:
16 for string_ in f:
17 counter.update(tokenizer(string_.strip(), word))
18 return Vocab(counter, min_freq=min_freq, specials=specials)
在上述代码中,第1行word
是用来指定是否按词的力度来构建字典。第9-16行是读取原始文本,然后构建得到一个字典,其中min_freq
表示最小词频,去掉小于min_freq的词;specials表示特殊的字符,如<pad>
,<unk>
等。第16-17行是统计每个字符的出现频率,关于Counter
的介绍可以参见文章[2]。
注意,掌柜这里用的torchtext
版本为0.6.0
在完成上述过程后,我们将得到一个Vocab
类的实例化对象,通过它便可以得到类似如下所示的字典:
xxxxxxxxxx
11{'<unk>': 0, '<pad>': 1, '岁': 2, '年': 3, '。': 4, ',': 5, '不': 6, '人': 7, '似': 8, '春': 9, '花': 10, '一': 11, '东': 12,...}
此时,我们就需要定义一个类,并在类的初始化过程中根据训练语料完成字典的构建,代码如下所示:
xxxxxxxxxx
201class LoadSentenceClassificationDataset():
2 def __init__(self, train_file_path=None,
3 tokenizer=None,
4 batch_size=2,
5 word=True,
6 min_freq=1,
7 max_sen_len='same'):
8 self.tokenizer = tokenizer
9 self.min_freq = min_freq
10 self.specials = ['<unk>', '<pad>']
11 self.word = word
12 self.vocab = build_vocab(self.tokenizer,
13 filepath=train_file_path,
14 word=self.word,
15 min_freq=self.min_freq,
16 specials=self.specials)
17 self.PAD_IDX = self.vocab['<pad>']
18 self.UNK_IDX = self.vocab['<unk>']
19 self.batch_size = batch_size
20 self.max_sen_len = max_sen_len
在上述代码中,第2行train_file_path
是训练集的路径,我们需要根据训练集来构造字典。第3行是指定字符化的方式。第7行max_sen_len
是用来指定样本Padding的方式,在2.3节掌柜会详细进行介绍。第12-16行是根据训练集和相应的参数来构建得到字典。第17-20行是指定构造DataLoader的相关参数。
2.2 转换为Token序列
在得到构建的字典后,便可以通过如下函数来将训练集、验证集和测试集转换成Token序列:
xxxxxxxxxx
131 def data_process(self, filepath):
2 raw_iter = open(filepath, encoding="utf8").readlines()
3 data = []
4 max_len = 0
5 for raw in raw_iter:
6 line = raw.rstrip("\n")
7 s, l = line.split('\t')
8 tensor_ = torch.tensor([self.vocab[token] for token in
9 self.tokenizer(s, self.word)], dtype=torch.long)
10 l = torch.tensor(int(l), dtype=torch.long)
11 max_len = max(max_len, tensor_.size(0))
12 data.append((tensor_, l))
13 return data, max_len
在上述代码中,第2行用来一次性读取所有的样本。第6-7行是得到每一行样本对应的文本和标签。第8-9行是将原始文本根据字典转换为索引(即Token id)。第11行是保存所有样本中的最大长度。第13行将返回包含所有样本的一个列表,以及当前语料中最长样本的长度。
例如如下两行样本
xxxxxxxxxx
21问君能有几多愁,恰似一江春水向东流。 0
2年年岁岁花相似,岁岁年年人不同。 0
在经过该函数处理后得到的结果为
xxxxxxxxxx
21[(tensor([61, 36, 58, 45, 33, 37, 40, 4, 39, 18, 16, 23, 12, 49, 35, 30, 51, 3]), tensor(0)),
2 (tensor([ 5, 5, 7, 7, 8, 24, 18, 4, 7, 7, 5, 5, 6, 10, 34, 3]), tensor(0)) ...]
2.3 padding处理
在得到每个样本的Token id后,下一步便是根据不同的条件来对其进行Padding处理。在这里,我们定义了一个名为pad_sequence
的辅助函数,其作用便是对传入的一个batch的序列按指定条件进行padding处理。具体实现代码如下:
xxxxxxxxxx
151def pad_sequence(sequences, batch_first=False, max_len=None, padding_value=0):
2 if max_len is None:
3 max_len = max([s.size(0) for s in sequences])
4 out_tensors = []
5 for tensor in sequences:
6 if tensor.size(0) < max_len:
7 tensor = torch.cat([tensor, torch.tensor([padding_value] *
8 (max_len - tensor.size(0)))], dim=0)
9 else:
10 tensor = tensor[:max_len]
11 out_tensors.append(tensor)
12 out_tensors = torch.stack(out_tensors, dim=1)
13 if batch_first:
14 return out_tensors.transpose(0, 1)
15 return out_tensors
在上述代码中,第1行sequences
是一个包含有不同长度向量的列表,batch_first
表示是否需要将batch_size
这个维度放到第1位,max_len
则是指定padding的方式。当max_len
为一个值时,表示以某个固定长度对样本进行padding,多余的截掉;当max_len=None
时,表示以当前batch中最长样本的长度对其它样本进行padding。第2-3行是计算得到当前batch中样本的最大长度。第6-8行是判断如果当前序列长度小于最大长度则进行padding。第9-10行是判断如果当前长度大于最大长度则进行截取。第13-15是根据条件返回不同形状的结果。
例如:
xxxxxxxxxx
81a = torch.ones(25)
2b = torch.ones(22)
3c = torch.ones(15)
4pad_sequence([a, b, c],max_len=None).size()
5torch.Size([25, 3])
6
7pad_sequence([a, b, c],max_len=10).size()
8torch.Size([10, 3])
因此,上面的tokenize后的结果在经过padding处理后将变为
xxxxxxxxxx
21[(tensor([61, 36, 58, 45, 33, 37, 40, 4, 39, 18, 16, 23, 12, 49, 35, 30, 51, 3]), tensor(0)),
2 (tensor([ 5, 5, 7, 7, 8, 24, 18, 4, 7, 7, 5, 5, 6, 10, 34, 3, 1, 1]), tensor(0)) ...]
即第2个样本的末尾padding了两个1。
2.4 构建DataLoader
迭代器
在经过前面的一系列处理后,我们便可以通过如下代码来构建DataLoader
迭代器:
xxxxxxxxxx
251 def load_train_val_test_data(self, train_file_paths, val_file_paths, test_file_paths):
2 train_data, max_sen_len = self.data_process(train_file_paths) # 得到处理好的所有样本
3 if self.max_sen_len == 'same':
4 self.max_sen_len = max_sen_len
5 val_data, _ = self.data_process(val_file_paths)
6 test_data, _ = self.data_process(test_file_paths)
7 train_iter = DataLoader(train_data, batch_size=self.batch_size, # 构造DataLoader
8 shuffle=True, collate_fn=self.generate_batch)
9 valid_iter = DataLoader(val_data, batch_size=self.batch_size,
10 shuffle=True, collate_fn=self.generate_batch)
11 test_iter = DataLoader(test_data, batch_size=self.batch_size,
12 shuffle=True, collate_fn=self.generate_batch)
13 return train_iter, valid_iter, test_iter
14
15 def generate_batch(self, data_batch):
16 batch_sentence, batch_label = [], []
17 for (sen, label) in data_batch: # 开始对一个batch中的每一个样本进行处理。
18 batch_sentence.append(sen)
19 batch_label.append(label)
20 batch_sentence = pad_sequence(batch_sentence, # [batch_size,max_len]
21 padding_value=self.PAD_IDX,
22 batch_first=True,
23 max_len=self.max_sen_len)
24 batch_label = torch.tensor(batch_label, dtype=torch.long)
25 return batch_sentence, batch_label
在上述代码中,第1行分别指定了训练集、验证集和测试集的路径;第2行是处理得到训练集上样本的Token id,并返回所有样本中的最大长度。第3-4行用来判断样本的padding方式,如果max_sen_len=='same'
则表示所有样本都按照训练集中最长样本的长度来进行padding处理;如果max_sen_len=None
则表示以每个batch中最长样本为标准进行padding处理;如果max_sen_len
为一个具体的值,则以具体长度进行padding处理。第7-12行是分别返回得到训练集、验证集和测试集对应的DataLoader
实例化对象。同时,第15-23行是定义了一个预处理函数来对每个batch中的数据进行处理,它将作为参数出入到类DataLoader
中。
最终,在定义完成类LoadSentenceClassificationDataset
后,便可以通过如下方式进行使用:
xxxxxxxxxx
121if __name__ == '__main__':
2 path = 'data_02.txt'
3 data_loader = LoadSentenceClassificationDataset(train_file_path=path,
4 tokenizer=tokenizer,
5 batch_size=5,
6 word=True,
7 max_sen_len=None)
8 train_iter, valid_iter, test_iter = data_loader.load_train_val_test_data(path, path, path)
9 for sen, label in train_iter:
10 print("batch:", sen)
11 print("batch shape:", sen.shape)
12 print("labels:", label)
在上述代码运行结束后,将会看到类似如下所示的结果:
xxxxxxxxxx
161batch: tensor([[103, 37, 38, 26, 23, 111, 4, 113, 127, 36, 26, 89, 110, 3, 1, 1, 1, 1],
2 [120, 18, 16, 25, 134, 4, 85, 65, 107, 24, 114, 3, 1, 1, 1, 1, 1, 1],
3 [ 17, 8, 27, 90, 34, 128, 24, 4, 7, 22, 19, 9, 20, 93, 40, 3, 1, 1],
4 [129, 12, 42, 96, 30, 72, 86, 4, 84, 28, 23, 36, 14, 35, 64, 48, 102, 3],
5 [ 27, 32, 33, 15, 32, 4, 51, 34, 105, 106, 54, 3, 1, 1, 1, 1, 1, 1]])
6batch shape: torch.Size([5, 18])
7labels: tensor([3, 1, 2, 0, 2])
8
9batch: tensor([[ 79, 71, 42, 30, 91, 4, 133, 60, 62, 82, 116, 3, 1, 1, 1, 1],
10 [ 92, 55, 12, 97, 75, 4, 53, 73, 83, 87, 118, 3, 1, 1, 1, 1],
11 [ 7, 109, 6, 20, 119, 4, 56, 74, 59, 46, 66, 3, 1, 1, 1, 1],
12 [ 94, 112, 37, 38, 131, 25, 35, 4, 130, 58, 81, 80, 6, 33, 50, 3],
13 [ 95, 98, 77, 100, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])
14batch shape: torch.Size([5, 16])
15labels: tensor([1, 0, 0, 1, 1])
16......
从上述结果可以看出,对于同一个batch来说其序列长度都不相同,因为我们在上述代码中指定了以每个batch中的最长样本为标准进行padding处理。
3 总结
在这篇文章中,掌柜首先介绍了不同情景下的padding方式以及基本原理;然后以构建一个文本分类数据集为例详细介绍了自定义padding的实现过程;最后展示了以每个batch中的最长样本为标准进行padding处理的输出结果。
本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎分享至一位你的朋友!若有任何疑问与建议,可加笔者微信nulls8或加群进行交流。青山不改,绿水长流,我们月来客栈见!
引用
[1] 完整代码:https://github.com/moon-hotel/DeepLearningWithMe