之前对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/
转载文章受原作者版权保护。转载请注明原作者出处!