快速从零构建成DataLoader

  • 2022 年 1 月 17 日
  • AI

各位朋友大家好,欢迎来到月来客栈。由于公众号推文不支持后续修订,以及为了方便大家在PC端进行阅读,本文将同步推送至网站www.ylkz.life。欢迎大家关注,谢谢!

1 引言

上一篇文章中,笔者详细介绍了在文本处理过程中如何通过torch.vocab来快速根据原始语料构建我们所需要的词表。在接下来的这篇文章中,笔者就来详细介绍一下如何在上一步的基础上快速构建Pytorch中的DataLoader,以便于后续网络的训练。

下面,笔者将会详细介绍在两种场景下文本数据集的构建过程。第一种场景是输入为序列输出为标签,也就是类似于文本分类数据集;第二种场景就是输入输出均序列,例如翻译模型,或者文本生成模型等。

2 构建类文本分类数据集

对于构建类似文本分类的数据集来说,总体上可以分为4个步骤:①构建字典;②将文本中的每一个词(字)转换为Token序列;③对不同长度的样本序列按照某个标准进行padding处理;④构建DataLoader类。现在假设我们有如下格式的原始数据:

问君能有几多愁,恰似一江春水向东流。  0
年年岁岁花相似,岁岁年年人不同。   0
去年今日此门中,人面桃花相映红。   2
人面不知何处去,桃花依旧笑春风。   1
渺沧海之一粟,羡长江之无穷。3
人面不知何处去,桃花依旧笑春风。   1
月来客栈。  1

其中文本与标签之间通过一个控制符进行分割,下面我们开始来一步步构建数据集。

2.1 构建字典

由于这部分内容在上一篇文章中已经具体介绍过,所以这里直接贴出代码即可,如下:

def tokenizer(s, word=False):
    """
    word: 是否采用分字模式
    """

    if word:
        r = [w for w in s]
    else:
        s = jieba.cut(s, cut_all=False)
        r = " ".join(s).split()
    return r

def build_vocab(tokenizer, filepath, word, min_freq, specials=None):
    """
    根据给定的tokenizer和对应参数返回一个Vocab类
    Args:
        tokenizer:  分词器
        filepath:  文本的路径
        word: 是否采用分字的模式对汉字进行处理
        min_freq: 最小词频,去掉小于min_freq的词
        specials: 特殊的字符,如<pad>,<unk>等
    Returns:
    """

    if specials is None:
        specials = ['<unk>''<pad>''<bos>''<eos>']
    counter = Counter()
    with open(filepath, encoding='utf8'as f:
        for string_ in f:
            counter.update(tokenizer(string_.strip(), word))
    return Vocab(counter, min_freq=min_freq, specials=specials)

在完成上述过程后,我们将得到一个Vocab类的实例化对象,通过它便可以得到最终生成的字典:

{'<unk>'0'<pad>'1'岁'2'年'3'。'4','5'不'6'人'7'似'8'春'9'花'10'一'11'东'12,...}

此时,我们就需要定义一个类,并在类的初始化过程中根据训练语料完成字典的构建,代码如下:

class LoadSentenceClassificationDataset():
    def __init__(self, train_file_path=None, # 训练集路径
                 tokenizer=None,
                 batch_size=2,
                 word=True, # 是否采用分字的模式对汉字进行处理
                 min_freq=1# 最小词频,去掉小于min_freq的词
                 max_sen_len='same')
:
 #最大句子长度,默认设置其长度为整个数据集中最长样本的长度
        # 根据训练预料建立英语和德语各自的字典
        self.tokenizer = tokenizer
        self.min_freq = min_freq
        self.specials = ['<unk>''<pad>']
        self.word = word
        self.vocab = build_vocab(self.tokenizer,
                                 filepath=train_file_path,
                                 word=self.word,
                                 min_freq=self.min_freq,
                                 specials=self.specials)
        self.PAD_IDX = self.vocab['<pad>']
        self.UNK_IDX = self.vocab['<unk>']
        self.batch_size = batch_size
        self.max_sen_len = max_sen_len

2.2 转换为Token序列

在得到构建的字典后,便可以通过如下函数来将训练集、验证集和测试集转换成Token序列:

    def data_process(self, filepath):
        """
        将每一句话中的每一个词根据字典转换成索引的形式,同时返回所有样本中最长样本的长度
        :param filepath: 数据集路径
        :return:
        """

        raw_iter = iter(open(filepath, encoding="utf8"))
        data = []
        max_len = 0
        for raw in raw_iter:
            line = raw.rstrip("\n")
            s, l = line.split('\t')
            tensor_ = torch.tensor([self.vocab[token] for token in
                                    self.tokenizer(s, self.word)], dtype=torch.long)
            l = torch.tensor(int(l), dtype=torch.long)
            max_len = max(max_len, tensor_.size(0))
            data.append((tensor_, l))
        return data, max_len

在上述代码中,其中第9行用来保存当前数据中最长样本的长度,在后续padding时会用到;第13-14行是先将原始文本序列tokenize,然后再转换成每个词(字)对应的token;第18行将返回包含所有样本的一个列表,以及当前语料中最长样本的长度。

例如如下两行样本

问君能有几多愁,恰似一江春水向东流。 0
年年岁岁花相似,岁岁年年人不同。    0

在经过该函数处理后得到的结果为

[(tensor([61365845333740,  4391816231249353051,  3]), tensor(0)),
 (tensor([ 5,  5,  7,  7,  82418,  4,  7,  7,  5,  5,  61034,  3]), tensor(0)) ...]

2.3 padding处理

从上面的输出结果也可以看到,对于不同的样本来说其对应的长度通常来说都是不同的。但是在将数据输入到相应模型时却需要保持同样的长度,因此在这里我们就需要对Token序列化后的样本进行padding处理,具体代码如下:

def pad_sequence(sequences, batch_first=False, max_len=None, padding_value=0):
    """
    对一个List中的元素进行padding
        sequences:
        batch_first: 是否把batch_size放到第一个维度
        padding_value:
        max_len : 最大句子长度,默认为None,即在每个batch中以最长样本的长度对其它样本进行padding;
        当指定max_len的长度小于一个batch中某个样本的长度,那么在这个batch中还是会以最长样本的长度对其它样本进行padding
        建议指定max_len的值为整个数据集中最长样本的长度
    Returns:
    """

    max_size = sequences[0].size()
    trailing_dims = max_size[1:]
    length = max_len
    max_len = max([s.size(0for s in sequences])
    if length is not None:
        max_len = max(length, max_len)
    if batch_first:
        out_dims = (len(sequences), max_len) + trailing_dims
    else:
        out_dims = (max_len, len(sequences)) + trailing_dims
    out_tensor = sequences[0].data.new(*out_dims).fill_(padding_value)
    for i, tensor in enumerate(sequences):
        length = tensor.size(0)
        # use index notation to prevent duplicate references to the tensor
        if batch_first:
            out_tensor[i, :length, ...] = tensor
        else:
            out_tensor[:length, i, ...] = tensor
    return out_tensor

上述代码是根据torch.nn.utils.rnn中的pad_sequence函数修改而来,增加了可以指定一个全局最大长度的参数max_len。在经过pad_sequence函数处理后,所有的样本就会保持同样的长度。例如上面的tokenize后的结果在经过padding处理后将变为

[(tensor([61365845333740,  4391816231249353051,  3]), tensor(0)),
 (tensor([ 5,  5,  7,  7,  82418,  4,  7,  7,  5,  5,  61034,  3,  1,  1]), tensor(0)) ...]

即第2个样本的末尾padding了两个1。

2.4 构建DataLoader迭代器

在经过前面的一系列处理后,我们便可以通过如下代码来构建DataLoader迭代器:

    def load_train_val_test_data(self, train_file_paths, val_file_paths, test_file_paths):
        train_data, max_sen_len = self.data_process(train_file_paths)  # 得到处理好的所有样本
        if self.max_sen_len == 'same':
            self.max_sen_len = max_sen_len
        val_data, _ = self.data_process(val_file_paths)
        test_data, _ = self.data_process(test_file_paths)
        train_iter = DataLoader(train_data, batch_size=self.batch_size,  # 构造DataLoader
                                shuffle=True, collate_fn=self.generate_batch)
        valid_iter = DataLoader(val_data, batch_size=self.batch_size,
                                shuffle=True, collate_fn=self.generate_batch)
        test_iter = DataLoader(test_data, batch_size=self.batch_size,
                               shuffle=True, collate_fn=self.generate_batch)
        return train_iter, valid_iter, test_iter

    def generate_batch(self, data_batch):
        batch_sentence, batch_label = [], []
        for (sen, label) in data_batch:  # 开始对一个batch中的每一个样本进行处理。
            batch_sentence.append(sen)
            batch_label.append(label)
        batch_sentence = pad_sequence(batch_sentence,  # [batch_size,max_len]
                                      padding_value=self.PAD_IDX,
                                      batch_first=True,
                                      max_len=self.max_sen_len)
        batch_label = torch.tensor(batch_label, dtype=torch.long)
        return batch_sentence, batch_label

在上述代码中,第1-13行便是用来构造最后需要返回的DataLoader迭代器;而第15-25行则是自定义一个函数来对每个batch中的样本进行处理,该函数将作为一个参数传入到类DataLoader中。同时,由于在DataLoader中是对每一个batch的数据进行处理,所以,当max_len=None时这就意味着上面的pad_sequence操作最终表现出来的结果就是不同的样本,padding后在同一个batch中长度是一样的,而在不同的batch之间可能是不一样的。因为此时pad_sequence是以一个batch中最长的样本为标准对其它样本进行padding。当max_len = 'same'时,最终表现出来的结果就是,所有样本在padding后的长度都等于训练集中最长样本的长度。

最终,在定义完成类LoadSentenceClassificationDataset后,便可以通过如下方式进行使用:

if __name__ == '__main__':
    path = 'data_02.txt'
    data_loader = LoadSentenceClassificationDataset(train_file_path=path,
                                                    tokenizer=tokenizer,
                                                    batch_size=2,
                                                    word=True,
                                                    max_sen_len='same')
    train_iter, valid_iter, test_iter = data_loader.load_train_val_test_data(path, path, path)
    for sen, label in train_iter:
        print("batch:", sen)
        # batch: tensor([[6, 14, 10, 25, 19, 21, 11, 4, 13, 8, 20, 22, 26, 12, 27, 3, 1, 1],
        #                [61, 36, 58, 45, 33, 37, 40, 4, 39, 18, 16, 23, 12, 49, 35, 30, 51, 3]])
        print("batch size:", sen.shape)
        # batch size: torch.Size([2, 18])
        print("labels:", label)
        # labels: tensor([1, 0])

当然,暂时不想理解代码的朋友,可以直接将原始数据整理成上述一样的格式然后导入类LoadSentenceClassificationDataset使用即可(下载地址见文末[1])。

3 构建类翻译模型数据集

通常,在NLP中还有一类任务就是模型的输入和输出均为序列的形式,这就需要在训练的过程中同时将这两部分输入到模型中。例如在翻译模型的训练过程中,需要同时将原始序列和目标序列一同输入到网络模型里。对于构建这种类似翻译模型的数据集总体上同样也可以采用上面的4个步骤:①构建字典;②将文本中的每一个词(字)转换为Token序列;③对不同长度的样本序列按照某个标准进行padding处理;④构建DataLoader类。只是在每一步中都需要分别对原始序列和目标序列进行处理

现在假设我们有如下格式的平行语料数据:

# 原始序列 source sequence
Zwei junge weiße Männer sind im, Freien in der Nähe vieler Büsche.
Mehrere Männer mit Schutzhelmen bedienen ein Antriebsradsystem.
Ein kleines Mädchen klettert in ein Spielhaus aus Holz.
Ein Mann in einem blauen Hemd steht auf einer Leiter und putzt ein Fenster.
Zwei Männer stehen am Herd und bereiten Essen zu.
# 目标序列 target sequence
Two young, White males are outside near many bushes.
Several men in hard hats are operating a giant pulley system.
A little girl climbing into a wooden playhouse.
A man in a blue shirt is standing on a ladder cleaning a window.
Two men are at the stove preparing food.

从上面的语料可以看出,这是一个用于训练翻译模型的数据,原始序列为德语,目标序列为英语。下面我们开始来一步步的构建数据集。

3.1 构建字典

在构建字典的过程中,整体上与2.1节内容中的一样,只是在这里需要同时对原始序列和目标序列分别构建一个字典。具体代码如下所示:

def my_tokenizer(s):
    s = s.replace(','" ,").replace("."" .")
    return s.split()

def build_vocab(tokenizer, filepath, specials=None):
    if specials is None:
        specials = ['<unk>''<pad>''<bos>''<eos>']
    counter = Counter()
    with open(filepath, encoding='utf8'as f:
        for string_ in f:
            counter.update(tokenizer(string_))
    return Vocab(counter, specials=specials)

在上述代码中,第1-3行为自定义的一个tokenizer;虽然上述两种语料可以直接通过空格来对每个词进行分割,但是还需要做的就是在单词和符号之间加上一个空格,以便把符号分割出来。第5-12行定义的build_vocab函数还是同之前的一样,没有发生改变。

在完成上述过程后,我们将得到两个Vocab类的实例化对象。

一个为原始序列的字典:

{'<unk>'0'<pad>'1'<bos>'2'<eos>'3'.'4'Männer'5'ein'6'in'7'Ein'8'Zwei'9'und'10','11, ......}

一个为目标序列的字典:

{'<unk>'0'<pad>'1'<bos>'2'<eos>'3'.'4'a'5'are'6'A'7'Two'8'in'9'men'10','11'Several'12,......}

此时,我们就需要定义一个类,并在类的初始化过程中根据训练语料完成字典的构建,代码如下:

class LoadEnglishGermanDataset():
    def __init__(self, train_file_paths=None, tokenizer=None, batch_size=2):
        # 根据训练预料建立英语和德语各自的字典
        self.tokenizer = tokenizer
        self.de_vocab = build_vocab(self.tokenizer, filepath=train_file_paths[0])
        self.en_vocab = build_vocab(self.tokenizer, filepath=train_file_paths[1])
        self.specials = ['<unk>''<pad>''<bos>''<eos>']
        self.PAD_IDX = self.de_vocab['<pad>']
        self.BOS_IDX = self.de_vocab['<bos>']
        self.EOS_IDX = self.de_vocab['<eos>']
        self.batch_size = batch_size

3.2 转换为Token序列

在得到构建的字典后,便可以通过如下函数来将训练集、验证集和测试集转换成Token序列:

    def data_process(self, filepaths):
        """
        将每一句话中的每一个词根据字典转换成索引的形式
        :param filepaths:
        :return:
        """

        raw_de_iter = iter(open(filepaths[0], encoding="utf8"))
        raw_en_iter = iter(open(filepaths[1], encoding="utf8"))
        data = []
        for (raw_de, raw_en) in zip(raw_de_iter, raw_en_iter):
            de_tensor_ = torch.tensor([self.de_vocab[token] for token in
                                       self.tokenizer(raw_de.rstrip("\n"))], dtype=torch.long)
            en_tensor_ = torch.tensor([self.en_vocab[token] for token in
                                       self.tokenizer(raw_en.rstrip("\n"))], dtype=torch.long)
            data.append((de_tensor_, en_tensor_))
        return data

在上述代码中,第11-4行分别用来将原始序列和目标序列转换为对应词表中的Token形式。在处理完成后,就会得到类似如下的结果:

 [(tensor([9,3746542361116,733244513,4]),tensor([8,451113,28634,3130,16,  4])),
  (tensor([225402530,  612,  4]), tensor([1210,  92223,  633,  5203741,  4])),
  (tensor([8382339,  7,  6262919,  4]), tensor([ 727211824,  54435,  4])),
  (tensor([ 9,  543271810311447,  4]), tensor([ 810,  61442403619,  4]))  ]

其中左边的一列就是原始序列的Token形式,右边一列就是目标序列的Token形式,每一行构成一个样本。

3.3 padding处理

同样,从上面的输出结果可以看到,无论是对于原始序列来说还是目标序列来说,在不同的样本中其对应长度都不尽相同。但是在将数据输入到相应模型时却需要保持同样的长度,因此在这里我们就需要对Token序列化后的样本进行padding处理。同时需要注意的是,一般在这种生成模型中,模型在训练过程中只需要保证同一个batch中所有的原始序列等长,所有的目标序列等长,也就是说不需要在整个数据集中所有样本都保证等长。

因此,在实际处理过程中无论是原始序列还是目标序列都会以每个batch中最长的样本为标准对其它样本进行padding,具体代码如下:

    def generate_batch(self, data_batch):
        de_batch, en_batch = [], []
        for (de_item, en_item) in data_batch:  # 开始对一个batch中的每一个样本进行处理。
             de_batch.append(de_item)  # 编码器输入序列不需要加起止符
             # 在每个idx序列的首位加上 起始token 和 结束 token
             en = torch.cat([torch.tensor([self.BOS_IDX]), en_item, torch.tensor([self.EOS_IDX])], dim=0)
             en_batch.append(en)
         # 以最长的序列为标准进行填充
         de_batch = pad_sequence(de_batch, padding_value=self.PAD_IDX)  # [de_len,batch_size]
         en_batch = pad_sequence(en_batch, padding_value=self.PAD_IDX)  # [en_len,batch_size]
         return de_batch, en_batch

在上述代码中,第6-7行用来在目标序列的首尾加上特定的起止符;第9-10行则是分别对一个batch中的原始序列和目标序列以各自当中最长的样本为标准进行padding(这里的pad_sequence导入自torch.nn.utils.rnn)。

3.4 构建DataLoader迭代器

在经过前面的一系列处理后,我们便可以通过如下代码来构建DataLoader迭代器:

    def load_train_val_test_data(self, train_file_paths, val_file_paths, test_file_paths):
        train_data = self.data_process(train_file_paths)
        val_data = self.data_process(val_file_paths)
        test_data = self.data_process(test_file_paths)
        train_iter = DataLoader(train_data, batch_size=self.batch_size,
                                shuffle=True, collate_fn=self.generate_batch)
        valid_iter = DataLoader(val_data, batch_size=self.batch_size,
                                shuffle=True, collate_fn=self.generate_batch)
        test_iter = DataLoader(test_data, batch_size=self.batch_size,
                               shuffle=True, collate_fn=self.generate_batch)
        return train_iter, valid_iter, test_iter

最终,在定义完成类LoadEnglishGermanDataset后,便可以通过如下方式进行使用:

if __name__ == '__main__':
    train_filepath = ['train_.de',
                      'train_.en']
    data_loader = LoadEnglishGermanCorpus(train_filepath, tokenizer=my_tokenizer, batch_size=2)
    train_iter, valid_iter, test_iter = data_loader.load_train_val_test_data(train_filepath,
                                                                             train_filepath,
                                                                             train_filepath)
    for src, tgt in train_iter:
        print("src shape:", src.shape)  # [de_tensor_len,batch_size]
        print("tgt shape:", tgt.shape)  # [de_tensor_len,batch_size]
        print("===================")

运行结果为:

src shape:torch.Size([122])
tgt shape: torch.Size([142])
===================
src shape:torch.Size([162])
tgt shape: torch.Size([132])
===================
src shape:torch.Size([171])
tgt shape: torch.Size([171])
===================

从上述结果可以看出,对于同一个batch来说,原始序列的长度都相同,目标序列的长度也都相同。最后,如果暂时不想理解代码的朋友,可以直接将原始数据整理成上述一样的格式,然后导入类LoadEnglishGermanDataset使用即可。

4 总结

在这篇文章中,笔者首先介绍了如何快速的构建类似文本分类模型中的DataLoader迭代器,包括词表字典的构建、序列转换为Token、对不同长度的样本进行padding等;然后介绍了如何构建类似翻译模型中的数据迭代器,并且也对其用法进行了示例。

本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎分享至一位你的朋友!若有任何疑问与建议,请添加笔者微信nulls8或加群进行交流。青山不改,绿水长流,我们月来客栈见!

引用

[1] 完整代码://github.com/moon-hotel/DeepLearningWithMe