Sample Code
整体框架
- model:预训练的,对于一个输入序列,找到每个窗口和最大的start概率和end概率,选取所有窗口中最大的(要保证start在end之前)
- tokenizer:分词器,为文字序列生成其对应的序号,可能几个为一组,比如:“我们”对应88
- 数据集:这个是要我们自己写的,对训练集和验证集有不同的处理
- 训练集:已知答案,在段落中把答案对应的start和end转化为切片后的start和end
- 验证集/测试集:question和para对应的文本——可以输入模型的分词、填充后的输入序列,这边段落要被分成窗口
- 训练函数:序列输入模型,得到start和end,与正确的计算loss并反向传播
导入预训练的模型
model = BertForQuestionAnswering.from_pretrained("bert-base-chinese").to(device) # 模型
tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") # 分词器
- BertForQuestionAnswering 是Transformers库中一个专门为问答任务设计的BERT模型类。
- .from_pretrained(“bert-base-chinese”) 是一个类方法,用来加载预训练的BERT模型。在这个例子中,它加载的是专门为中文文本训练的 bert-base-chinese 模型。
- BertTokenizerFast 是一个快速的分词器类,用于将文本转换成模型可以理解的格式(例如将字符串转换为令牌ID)。
- .from_pretrained(“bert-base-chinese”) 表示加载与之前加载的模型相匹配的预训练分词器。这样可以确保文本的处理方式与训练模型时的处理方式一致。
模型和分词器都不需要我们训练,要调整的是运用方式,比如窗口大小、是否重叠、从正确答案扩展窗口的方式
要学习的是运用这些模型的能力,看能不能扩展一些别的应用,虽然李的课程里只运用bert那一块参数,但看起来整个模型都能导入?
建立数据类
所有任务都要建立一个数据类,用来放训练/测试数据,区别就是在__init__函数里定义什么
另外需要定义的两个函数是__len__和__getitem__
训练集
在训练集中,目的是让模型学习从段落中找到问题的答案。通常,答案是段落中的一个短小部分。因此,在训练时,我们通常会选取包含答案的段落部分,使得模型能够专注于学习从这个特定的段落片段中提取答案。这里没有必要将整个段落分成多个窗口,因为我们已经知道答案的具体位置,只需要关注包含答案的部分即可。
测试集
在测试集中,模型需要在整个段落中找到问题的答案。因为测试时不知道答案的具体位置,所以需要考虑整个段落。由于段落可能很长,超过了模型能够一次处理的最大长度限制,所以需要将段落分成多个窗口,每个窗口包含段落的一部分,然后分别用模型对每个窗口进行预测。这样做可以确保模型有机会考虑段落中的每个部分,寻找可能的答案。
每个窗口都单独进行预测,最后根据所有窗口的预测结果确定最终的答案。
# 定义一个继承自PyTorch Dataset的QA_Dataset类,用于问答任务数据的加载和预处理
class QA_Dataset(Dataset):
# 初始化函数,设置数据集的分割类型(训练/验证/测试)、问题、问题的tokenized版本和段落的tokenized版本
def __init__(self, split, questions, tokenized_questions, tokenized_paragraphs):
self.split = split # 数据集分割类型
self.questions = questions # 问题数据
self.tokenized_questions = tokenized_questions # tokenized后的问题数据
self.tokenized_paragraphs = tokenized_paragraphs # tokenized后的段落数据
self.max_question_len = 40 # 设定最大问题长度
self.max_paragraph_len = 150 # 设定最大段落长度
# TODO: 修改doc_stride的值
self.doc_stride = 150 # 滑动窗口步长
# 输入序列的总长度 = [CLS] + 问题 + [SEP] + 段落 + [SEP]
self.max_seq_len = 1 + self.max_question_len + 1 + self.max_paragraph_len + 1
# 返回数据集的大小
def __len__(self):
return len(self.questions)
# 获取数据集中的一个实例,idx为索引
def __getitem__(self, idx):
question = self.questions[idx] # 获取索引对应的问题
tokenized_question = self.tokenized_questions[idx] # 获取索引对应的tokenized问题
tokenized_paragraph = self.tokenized_paragraphs[question["paragraph_id"]] # 获取问题对应的tokenized段落
# TODO: 预处理
# 提示:如何防止模型学到不应该学的内容
# 如果是训练集
if self.split == "train":
# 将答案在原文中的起止位置转换为tokenized段落中的起止位置
answer_start_token = tokenized_paragraph.char_to_token(question["answer_start"])
# question["answer_start"] 对应一个数字,是答案在para中的位置,要把它转化为分词后的位置
answer_end_token = tokenized_paragraph.char_to_token(question["answer_end"])
# 通过切片获取包含答案的段落部分
mid = (answer_start_token + answer_end_token) // 2
paragraph_start = max(0, min(mid - self.max_paragraph_len // 2, len(tokenized_paragraph) - self.max_paragraph_len))
paragraph_end = paragraph_start + self.max_paragraph_len
# 切片问题/段落并添加特殊符号 (101: CLS, 102: SEP)
input_ids_question = [101] + tokenized_question.ids[:self.max_question_len] + [102]
input_ids_paragraph = tokenized_paragraph.ids[paragraph_start : paragraph_end] + [102]
# 将答案的起止位置从tokenized段落转换为窗口中的起止位置
answer_start_token += len(input_ids_question) - paragraph_start
answer_end_token += len(input_ids_question) - paragraph_start
# 填充序列并获取模型的输入
input_ids, token_type_ids, attention_mask = self.padding(input_ids_question, input_ids_paragraph)
return torch.tensor(input_ids), torch.tensor(token_type_ids), torch.tensor(attention_mask), answer_start_token, answer_end_token
# 验证集/测试集
else:
input_ids_list, token_type_ids_list, attention_mask_list = [], [], []
# 将段落分成几个窗口,每个窗口的起始位置由"doc_stride"确定
for i in range(0, len(tokenized_paragraph), self.doc_stride):
# 切片问题/段落并添加特殊符号 (101: CLS, 102: SEP)
input_ids_question = [101] + tokenized_question.ids[:self.max_question_len] + [102]
input_ids_paragraph = tokenized_paragraph.ids[i : i + self.max_paragraph_len] + [102]
# 填充序列并获取模型的输入
input_ids, token_type_ids, attention_mask = self.padding(input_ids_question, input_ids_paragraph)
input_ids_list.append(input_ids)
token_type_ids_list.append(token_type_ids)
attention_mask_list.append(attention_mask)
return torch.tensor(input_ids_list), torch.tensor(token_type_ids_list), torch.tensor(attention_mask_list)
# 定义填充函数,用于将输入序列填充到最大长度
def padding(self, input_ids_question, input_ids_paragraph):
# 如果序列长度小于max_seq_len,则用零填充
padding_len = self.max_seq_len - len(input_ids_question) - len(input_ids_paragraph)
# 输入序列的词汇表中的索引
input_ids = input_ids_question + input_ids_paragraph + [0] * padding_len
# 用于指示输入的第一部分和第二部分的分段token索引。索引在[0, 1]中选择
token_type_ids = [0] * len(input_ids_question) + [1] * len(input_ids_paragraph) + [0] * padding_len
# 掩码用于避免对填充token索引执行注意力。掩码值在[0, 1]中选择
attention_mask = [1] * (len(input_ids_question) + len(input_ids_paragraph)) + [0] * padding_len
return input_ids, token_type_ids, attention_mask
# 创建训练集、验证集和测试集的实例
train_set = QA_Dataset("train", train_questions, train_questions_tokenized, train_paragraphs_tokenized)
dev_set = QA_Dataset("dev", dev_questions, dev_questions_tokenized, dev_paragraphs_tokenized)
test_set = QA_Dataset("test", test_questions, test_questions_tokenized, test_paragraphs_tokenized)
# 设置训练批次大小
train_batch_size = 32
# 注意:不要改变dev_loader/test_loader的批次大小!
# 尽管批次大小为1,实际上是由同一个问答对的多个窗口组成的一批
# 创建DataLoader来批量加载数据,对训练数据打乱顺序,对开发/测试数据则不打乱
train_loader = DataLoader(train_set, batch_size=train_batch_size, shuffle=True, pin_memory=True)
dev_loader = DataLoader(dev_set, batch_size=1, shuffle=False, pin_memory=True)
test_loader = DataLoader(test_set, batch_size=1, shuffle=False, pin_memory=True)
评估函数
# 定义评估函数,用于评估模型的预测结果
def evaluate(data, output):
##### TODO: 后处理 #####
# 后处理中存在一个错误和改进空间
# 提示:打开你的预测文件看看哪里出了问题——有部分答案为空
answer = ''
max_prob = float('-inf') # 初始化最大概率为负无穷,用于找到最可能的答案
num_of_windows = data[0].shape[1] # 从输入数据中获取窗口数量
for k in range(num_of_windows): # 遍历所有窗口
# 获取最可能的起始位置和结束位置的概率及索引
start_prob, start_index = torch.max(output.start_logits[k], dim=0)
end_prob, end_index = torch.max(output.end_logits[k], dim=0)
# 计算答案的概率为起始位置和结束位置概率的和
prob = start_prob + end_prob
# 如果计算出的概率大于之前的窗口,则替换答案
if prob > max_prob:
max_prob = prob
# 将tokens转换为字符(例如 [1920, 7032] --> "大 金")
answer = tokenizer.decode(data[0][0][k][start_index : end_index + 1])
# 移除答案中的空格(例如 "大 金" --> "大金")
return answer.replace(' ','')
训练函数
训练函数和普通的没啥区别,只是这边的loss模型会自动计算,原理应该和普通分类任务的计算差不多。
在问答任务中,模型预测的“概率分布”通常是指它为输入文本中的每个单词(或token)作为答案开始(start)和结束(end)位置的概率。而“实际标签的概率分布”则是一个简单的分布,其中只有正确答案的开始和结束位置被标记为1,其他所有位置都被标记为0
基于真实概率分布和预测概率分布计算交叉熵损失
# 设置训练周期为1
num_epoch = 1
# 设置是否进行验证
validation = True
# 设置日志记录步数
logging_step = 100
# 设置学习率
learning_rate = 1e-4
# 初始化优化器
optimizer = AdamW(model.parameters(), lr=learning_rate)
# 如果使用半精度训练,则准备模型、优化器和数据加载器
if fp16_training:
model, optimizer, train_loader = accelerator.prepare(model, optimizer, train_loader)
# 将模型设置为训练模式
model.train()
print("开始训练 ...")
# 训练周期循环
for epoch in range(num_epoch):
step = 1
train_loss = train_acc = 0
# 数据加载器循环
for data in tqdm(train_loader):
# 将所有数据加载到GPU
data = [i.to(device) for i in data]
# 模型输入:input_ids, token_type_ids, attention_mask, start_positions, end_positions(注意:只有"input_ids"是必需的)
# 模型输出:start_logits, end_logits, loss(当提供start_positions/end_positions时返回)
output = model(input_ids=data[0], token_type_ids=data[1], attention_mask=data[2], start_positions=data[3], end_positions=data[4])
# 选择最可能的起始位置和结束位置
start_index = torch.argmax(output.start_logits, dim=1)
end_index = torch.argmax(output.end_logits, dim=1)
# 只有当起始位置和结束位置都正确时,预测才算正确
train_acc += ((start_index == data[3]) & (end_index == data[4])).float().mean()
train_loss += output.loss
# 如果使用半精度训练,则使用accelerator进行反向传播
if fp16_training:
accelerator.backward(output.loss)
else:
output.loss.backward()
# 更新优化器步骤,并将梯度归零
optimizer.step()
optimizer.zero_grad()
step += 1
##### TODO: 应用线性学习率衰减 #####
# 打印过去logging_step步的训练损失和准确率
if step % logging_step == 0:
print(f"Epoch {epoch + 1} | Step {step} | loss = {train_loss.item() / logging_step:.3f}, acc = {train_acc / logging_step:.3f}")
train_loss = train_acc = 0
# 如果进行验证
if validation:
print("评估开发集 ...")
model.eval() # 将模型设置为评估模式
with torch.no_grad(): # 不进行梯度计算
dev_acc = 0
# 遍历开发集数据
for i, data in enumerate(tqdm(dev_loader)):
output = model(input_ids=data[0].squeeze(dim=0).to(device), token_type_ids=data[1].squeeze(dim=0).to(device),
attention_mask=data[2].squeeze(dim=0).to(device))
# 只有当答案文本完全匹配时,预测才算正确
dev_acc += evaluate(data, output) == dev_questions[i]["answer_text"]
print(f"验证 | Epoch {epoch + 1} | acc = {dev_acc / len(dev_loader):.3f}")
model.train() # 将模型设置回训练模式
# 保存模型和配置文件到「saved_model」目录
# 即「saved_model」目录下有两个文件:「pytorch_model.bin」和「config.json」
# 保存的模型可以使用「model = BertForQuestionAnswering.from_pretrained("saved_model")」重新加载
print("保存模型 ...")
model_save_dir = "saved_model"
model.save_pretrained(model_save_dir)
Medium Code
Apply linear learning rate decay:学习率的改变
optimizer = AdamW(model.parameters(), lr=learning_rate)
from transformers import get_linear_schedule_with_warmup
scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=100, num_training_steps=1000)
带预热的线性学习率调度(Linear Schedule with Warmup):这种调度策略分为两个阶段:预热阶段和线性衰减阶段。
- 预热阶段:在训练的初始阶段,学习率会从较低的值逐渐增加到预设的学习率(lr=learning_rate)。这个过程发生在前num_warmup_steps步。预热过程有助于稳定模型的训练,防止模型在训练初期由于高学习率导致的不稳定。
- 线性衰减阶段:在预热阶段之后,学习率会从最大值逐渐线性衰减到0,这个过程发生在剩余的训练步骤中,直到达到
num_training_steps
实际运用时,需要多加一行
optimizer.step()
optimizer.zero_grad()
scheduler.step() # 更新优化器的学习率,
# scheduler.step()应该在optimizer.step()之后调用,因为它是为下一步或下一个epoch调整学习率
Change value of “doc_stride”:改变步数,或者说窗口大小
self.doc_stride = 32
Sample Code 中 ,这个步数被定为150,等于最大窗口长度,此时各个窗口之间没有重合的部分,会失去部分在两个段落之间的可能解。
Strong Code
Improve preprocessing
观察答案部分,会发现部分答案是空白的,猜测是end<start导致,所以我们在代码中判断prob是否大于现有的之前,添加了一个判断
if start_index > end_index:
continue
Try other pretrained models
这块变成从文件路径导入了,就不细研究了
Boss Code
Further improve the above hints
添加了含有词汇表中不存在的词[UNK]时的处理策略
answer = answer.replace(' ', '')
if '[UNK]' in answer:
print('发现 [UNK],这表明有文字无法编码, 使用原始文本')
#print("Paragraph:", paragraph)
#print("Paragraph:", paragraph_tokenized.tokens)
print('--直接解码预测:', answer)
#找到原始文本中对应的位置
raw_start = paragraph_tokenized.token_to_chars(origin_start)[0]
raw_end = paragraph_tokenized.token_to_chars(origin_end)[1]
answer = paragraph[raw_start:raw_end]
print('--原始文本预测:',answer)
如果发现[UNK],代码块执行以下操作:
- 打印提示信息,表明存在无法编码的文字。
- 打印未经处理的预测答案(即包含[UNK]的答案)。
- 使用paragraph_tokenized.token_to_chars(origin_start)和paragraph_tokenized.token_to_chars(origin_end)将预测答案中的起始和结束token位置转换为原始段落文本中的字符位置。这是因为模型的输出基于tokenized文本,而我们需要在原始文本中定位答案。
- 根据定位的起始和结束位置,从原始段落文本中提取答案。
- 打印从原始文本中提取的答案。
贴一个对比:
因为预训练模型的下载有问题,所以没有测试结果。