李宏毅机器学习 2021/2022 HW7 学习记录

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],代码块执行以下操作:

  1. 打印提示信息,表明存在无法编码的文字。
  2. 打印未经处理的预测答案(即包含[UNK]的答案)。
  3. 使用paragraph_tokenized.token_to_chars(origin_start)和paragraph_tokenized.token_to_chars(origin_end)将预测答案中的起始和结束token位置转换为原始段落文本中的字符位置。这是因为模型的输出基于tokenized文本,而我们需要在原始文本中定位答案。
  4. 根据定位的起始和结束位置,从原始段落文本中提取答案。
  5. 打印从原始文本中提取的答案。

贴一个对比:
在这里插入图片描述
因为预训练模型的下载有问题,所以没有测试结果。

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值