pytorch训练BERT模型实现文本分类的详细过程

之前对BERT的预训练过程做过详细解释,文章中的代码就是一段简洁的预训练Demo代码,对于了解BERT的预训练原理有很大帮助。

然后对BERT+CRF的实体识别做过详解,在这篇中对谷歌的BERT预训练源码进行了缩减,只保留核心部分,可以清晰地了解BERT的预训练+微调过程。

之前已经对BERT的预训练过程做了较为详细的解释,所以这次就把注意力放在数据准备和模型的训练、评估、预测上,需要先将源码下载好对照着看。

搭建模型

本次分类任务使用BERT模型,原理也很简单,就是取BERT中最后一层encoder的输出,然后取出其中CLS标识的向量,通过一个全连接层生成属于各标签的概率值,然后取最大的那个作为模型的预测结果。所以整个模型就包括了BERT和一个全连接层,输入的数据就是常规的BERT格式(token_ids,seq_len,mask),这个会在下面介绍,输出是一个概率矩阵,表示当前样本属于某个标签的概率。构建模型的代码在models文件夹下的bert.py文件中。模型构建的代码也非常简单,模型超参数也不多,应该看注释完全可以理解。

数据准备

在使用模型之前,需要先将我们的原始数据制作训练集、测试集、验证集和标签集,每个人的原始数据不一样,这个工作应该自行完成。最后数据集的形式就是每行一个句子加标签,标签的范围是:[0,num_classes-1],句子和标签用”\t”分隔开。标签集保存[0,num_classes-1],每行一个数字。

文本分类任务的数据格式较为简单,就是一个句子和一个标签,在这个项目中读取数据集的代码在util.py文件中,这个文件主要包含一个类:DatasetIterater,两个函数build_dataset()、build_iterator()。下面对他们进行介绍。

一、首先是build_dataset()函数,这个函数对数据集进行读取,并生成模型需要的数据格式。在此次文本分类任务中,不需要拼接两个句子,也不需要位置信息,只需要生成句子中每个字在词表中的索引序列即可。在此函数中包含了一个load_dataset()函数,是真正处理数据的地方,我把这段重要的代码贴出来。

    def load_dataset(path, pad_size=32):
        contents = []
        with open(path, 'r', encoding='UTF-8') as f:
            for line in tqdm(f):
                lin = line.strip()
                if not lin:
                    continue
                content, label = lin.split('\t')
                # 将句子切分为片段,每段一个字
                token = config.tokenizer.tokenize(content)
                # 添加CLS标识符
                token = [CLS] + token
                seq_len = len(token)
                mask = []
                # 获取每个字在词表中的位置,包括CLS
                token_ids = config.tokenizer.convert_tokens_to_ids(token)

                if pad_size:
                    if len(token) < pad_size:
                        # 如果句子长度不足pad_size,则在后面补空白,mask表示句子中哪些位置是有效的
                        mask = [1] * len(token_ids) + [0] * (pad_size - len(token))
                        token_ids += ([0] * (pad_size - len(token)))
                    else:
                        # 如果句子长度尝过了pad_size,则将后面切掉
                        mask = [1] * pad_size
                        token_ids = token_ids[:pad_size]
                        seq_len = pad_size
                contents.append((token_ids, int(label), seq_len, mask))
        return contents

在代码中已经添加了注释,也不难看懂。过程大致如下:

1.首先读取数据集,分开获取句子和标签。

2.调用BERT预训练代码中的tokenize函数,将句子切分为片段,每段一个字,返回一个列表,并在列表前添加上CLS标识。

3.然后调用BERT预训练代码中的convert_tokens_to_ids函数,获取列表中每个片段在词表中的索引,形成token_ids。

4.使用PAD标识对句子进行长切短补,并用mask序列指出哪些位置是被PAD了,这些位置无效,不参与运算。

5.一条句子和它的标签最后处理为这样的数据格式:(句子片段索引序列,句子标签,句子长度,句子中的无效位置),并返回。

对训练集、测试集、验证集都进行以上处理,转换成模型需要的输入数据。下一步就是将这些处理好的数据按batch划分,生成一个迭代器,每次给模型输入一个batch的数据。

二、build_iterator()函数分别为训练集、测试集、验证集创建一个迭代器,每次取一个batch的数据输入到模型中。这个函数做的全部事情就是,返回了一个DatasetIterator类的实例,也就是迭代器,所以他其实也可有可无,重点在DatasetIterator类。

三、DatasetIterator类实现了__iter__()和__next__()方法,iter()方法返回一个特殊的迭代对象,也就是自己本身,next()方法并通过 StopIteration异常标识迭代的完成。这样这个类就是一个迭代器了。类中还重写了__len__()方法,迭代器的长度就是总的batch数。此类的初始化函数包含了三个参数:batchs、batch_size、device。这里的batchs就是前面build_dataset函数返回的处理好的数据。初始化函数中计算的self.n_batches是所有长度为batch_size的batch数,因为总的数据长度可能不是batch_size的整数倍,所以除去这些满载的batch,可能还剩一些数据不足以凑够一个batch_size,这些数据在下面会单独处理。

下面我还是把类中重要的_to_tensor()和__next__()方法代码贴在下面。

    def _to_tensor(self, datas):
        # x就是token_ids,y是label
        # x的维度是(batch_size,pad_size)
        # y的维度是(batch_size)
        x = torch.LongTensor([_[0] for _ in datas]).to(self.device)
        y = torch.LongTensor([_[1] for _ in datas]).to(self.device)

        # pad前的长度(超过pad_size的设为pad_size)
        # seq_len的维度是(batch_size)
        # mask的维度是(batch_size,pad_size)
        seq_len = torch.LongTensor([_[2] for _ in datas]).to(self.device)
        mask = torch.LongTensor([_[3] for _ in datas]).to(self.device)
        return (x, seq_len, mask), y

    def __next__(self):
        if self.residue and self.index == self.n_batches:
            batches = self.batches[self.index * self.batch_size: len(self.batches)]
            self.index += 1
            batches = self._to_tensor(batches)
            return batches

        elif self.index >= self.n_batches:
            self.index = 0
            raise StopIteration
        else:
            # 每次取一个batch的数据返回,并使用index来标记数据指针位置
            batches = self.batches[self.index * self.batch_size: (self.index + 1) * self.batch_size]
            self.index += 1
            batches = self._to_tensor(batches)
            return batches

__next__方法每次从batchs中取一个batch的数据,通过self.index这个指针来对batchs进行切片。分为三种情况:

1.如果初始化函数中计算得到的batch总数不是一个整数,也就是说最后面一部分数据量不够一个batch_size,并且现在以前面满载的batch已经取完了,那么就把剩下所有的数据当做一个batch送走。

2.如果指针已经指向了batchs的末尾,那么就说明数据取完了。

3.如果不是上述两种情况,那么就说明是正常取一个batch的数据,用index指针来对batchs进行切分,取出一个batch的数据送走。

每次取出一个batch的数据时,需要转换为tensor数据并加载到GPU上,也就是_to_tensor()函数做的事。每次给_to_tensor函数传入一个batch的数据,此时函数中data的内容是(token_ids,label,seq_len,mask)。将这些数据分别转化为Tensor类型并返回,返回的分别是要输入到模型中的数据(token_ids,seq_len,mask)和真实标签(label)。到此处数据准备工作就完成了。

加载网络和数据

完成构建模型及处理数据后,run.py文件首先随机初始化网络,在这里尤其需要注意这四句:

np.random.seed(1)
torch.manual_seed(1)
torch.cuda.manual_seed_all(1)
torch.backends.cudnn.deterministic = True  # 保证每次结果一样

这四行用来设置随机数种子,保证生成相同的随机数,也就是说如果用相同的数据集训练两次,由于模型的初始化参数是一样的,那么训练的结果也是一样的,消除了随机数对模型效果的影响。然后调用build_dataset函数和build_iterator函数得到迭代器,然后将网络参数、迭代器和网络模型传到train函数中进行训练。

模型训练

模型训练及评估的代码在train_eval.py文件中,包含三个函数:train(),test(),eval()。train函数在每训练100个batch时会调用eval函数来对验证集的效果进行评估,同时输出评估结果并保存最优模型参数。test函数实质上就是调用了eval函数,只不过评估的是测试集,这两个函数都非常简单易懂。这里最重要的train函数我贴到下面:

def train(config, model, train_iter, dev_iter, test_iter):
    start_time = time.time()
    model.train()
    # 取出模型中的所有网络层的名称和参数,返回一个迭代器
    param_optimizer = list(model.named_parameters())
    # 对于模型中不同的层采取不同的权重衰减策略
    no_decay = ['bias', 'LayerNorm.bias', 'LayerNorm.weight']
    optimizer_grouped_parameters = [
        {'params': [p for n, p in param_optimizer if not any(nd in n for nd in no_decay)], 'weight_decay': 0.01},
        {'params': [p for n, p in param_optimizer if any(nd in n for nd in no_decay)], 'weight_decay': 0.0}]
    # optimizer = torch.optim.Adam(model.parameters(), lr=config.learning_rate)
    optimizer = BertAdam(optimizer_grouped_parameters,
                         lr=config.learning_rate,
                         warmup=0.05,
                         t_total=len(train_iter) * config.num_epochs)
                            # train_iter的长度就是整个训练集中包含的batch数量
    total_batch = 0  # 记录进行到多少batch
    dev_best_loss = float('inf')
    last_improve = 0  # 记录上次验证集loss下降的batch数
    flag = False  # 记录是否很久没有效果提升
    model.train()
    for epoch in range(config.num_epochs):
        print('Epoch [{}/{}]'.format(epoch + 1, config.num_epochs))
        # 每次从dataloader中取一个batch的数据
        for i, (trains, labels) in enumerate(train_iter):
            # 此时outputs返回的是一个(batch_size,num_classes)维度的向量,对应了batch中每个句子属于每个标签的概率
            outputs = model(trains)
            # 计算损失函数值
            loss = F.cross_entropy(outputs, labels)
            # 清空梯度
            model.zero_grad()
            # 计算网络的梯度
            loss.backward()
            # 进行单步优化
            optimizer.step()
            # 每100个batch就输出一次效果
            if total_batch % 100 == 0:
                # 得到真实标签值,
                true = labels.data.cpu()
                # 返回output中最大值的索引,也就是模型预测的标签
                predic = torch.max(outputs.data, 1)[1].cpu()
                # 计算训练集上的准确率
                train_acc = metrics.accuracy_score(true, predic)
                # 计算验证集上的准确率
                dev_acc, dev_loss = evaluate(config, model, dev_iter)
                # 保存最优模型参数
                if dev_loss < dev_best_loss:
                    dev_best_loss = dev_loss
                    torch.save(model.state_dict(), config.save_path)
                    improve = '*'
                    last_improve = total_batch
                else:
                    improve = ''
                time_dif = get_time_dif(start_time)
                msg = 'Iter: {0:>6},  Train Loss: {1:>5.2},  Train Acc: {2:>6.2%},  Val Loss: {3:>5.2},  Val Acc: {4:>6.2%},  Time: {5} {6}'
                print(msg.format(total_batch, loss.item(), train_acc, dev_loss, dev_acc, time_dif, improve))
                model.train()
            total_batch += 1
            # 验证集loss超过1000batch没下降,结束训练
            if total_batch - last_improve > config.require_improvement:
                print("No optimization for a long time, auto-stopping...")
                flag = True
                break
        if flag:
            break
    test(config, model, test_iter)

函数的输入分别是,config:模型参数、model:网络模型、train_iter:训练集的迭代器、dev_iter:验证集的迭代器、test_iter:测试集的迭代器。在模型开始训练前,对网络的不同层设置了不同的权重衰减策略,这部分与BERT重写的优化器有关,我理解的还不是非常透彻,可以参见这篇文章:跟着代码理解BERT中的优化器AdamW(AdamWeightDecayOptimizer)。之后便可以开始训练了,一轮训练的过程如下:

1.每次取一个batch的数据,包括模型的输入和真实标签,然后输入到模型中得到预测结果。

2.将预测结果与真实标签传给cross_entropy函数中,求损失值,损失函数是交叉熵,之前我对交叉熵和损失函数都进行过详细介绍,具体可以看这篇:pytorch中的cross_entropy函数

3.清空梯度,然后对损失值进行反向传播,计算模型的梯度。

4.根据梯度来对网络模型进行一次更新。

5.每训练100个batch就使用验证集来评估模型效果并输出。在此过程中保存最优模型状态。

6.如果经过1000个batch之后模型训练效果并没有提升,那么就停止模型训练。

Original: https://blog.csdn.net/Q_M_X_D_D_/article/details/120583820
Author: 不知名的码农
Title: pytorch训练BERT模型实现文本分类的详细过程

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/709532/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球