为了账号安全,请及时绑定邮箱和手机立即绑定

LLM 微调工作坊:提升问答能力

大语言模型是一种令人着迷的技术,被广泛应用在各种应用和产品中。我在关于LLM的博客系列中提出了设计一个仅使用LLM作为核心或唯一组件的闭卷问答系统的目标。在我对问答系统架构的介绍之后,这是我第二篇关于为第一代LLM增加问答功能的文章。

本文展示了如何使用SQUAD问答数据集来微调GPT2大型语言模型。你将了解数据集的来源、结构,以及如何预处理数据集用于训练。你还将看到如何使用transformers库来微调GPT2,并如何使用该模型回答来自任何上下文的自定义问题。

本文的技术环境是Python v3.11transformers v4.37.2。所有指令也应适用于这些工具的新版本。

原文首发于我的博客上 admantium.com

答问数据集

在传统的自然语言处理(NLP)中,问答被视为一个高级任务。为此任务训练的模型会得到一个上下文部分和一个问题,然后需要在上下文中找到最符合问题的相关部分。早期,非大型语言模型通常被训练仅定位答案的起始和结束位置,这也是目前大多数可用数据集的主要形式。

SQUAD(斯坦福问答数据集)是一个广为人知且经常被使用的数据集。它包含一个背景信息、一个问题以及以开始和结束标记形式的答案,这些标记指出了背景信息中相关的内容。这里有一个关于欧盟自由工作日的问题示例。

背景:虽然条约和法规在明确、无条件且立即生效的情况下可以直接适用,指令通常不会赋予公民(相对于成员国来说)对其他公民提起诉讼的权利。理论上,这是因为TFEU第288条指出,指令是针对成员国的,并且通常“留给国家当局选择形式和方法”来实施。部分原因是,指令通常会设立最低标准,允许成员国根据国家法律制定更高的标准。例如,《工作时间指令》要求每名工人每年至少享有4周带薪假期,但大多数成员国的法律规定,带薪假期天数更多。然而,根据当前法院的立场,公民可以基于国家法律实施的指令提出索赔,而不是基于指令本身。指令没有所谓的“水平效力”,即在非国家实体之间。这种观点立即引起了争议,在20世纪90年代初,三位副检察官有说服力地主张指令应该为所有公民创造权利和义务。法院拒绝了这一观点,但有五个主要例外情况。问题:《工作时间指令》要求工人每年至少享有多少天带薪假期?答案:start position: 594
start position: 595
文本:4周

SQUAD 数据集的使用方式进行微调时,应将其置于历史背景中来看待。在训练过程中,会在模型上添加一个全连接层,该层输出输入和输出令牌的数量。这种方法专门应用于第一代模型,但是,新模型不仅能识别相关片段,还能对答案进行总结和综合。这个特定的数据集和训练目标仅适用于第一代模型,在较新的第二代模型中已经不再适用。

第一步:目标设定

此项目对GPT2语言模型进行微调,使用SQUAD数据集以添加问答功能,其中语言模型输出与给定答案相关的起始和结束标记。

步骤 2:数据筛选与探索:数据选择和探索

HuggingFace的数据集浏览器可以方便地加载预处理的数据集。例如,使用squad数据集仅需简单的两行代码,

    from datasets import load_dataset  
    data = load_dataset('squad')  
    # 下载说明文档: 100%  
    # 7.83k/7.83k [00:00<00:00, 434kB/s]  
    # 下载数据: 100%  
    # 14.5M/14.5M [00:00<00:00, 20.1MB/s]  
    # 下载数据: 100%  
    # 1.82M/1.82M [00:00<00:00, 7.13MB/s]  
    # 生成训练数据集: 100%  
    # 87599/87599 [00:00<00:00, 350495.77 示例/s]  
    # 生成验证数据集: 100%  
    # 10570/10570 [00:00<00:00, 421873.03 示例/s]

当首次加载新数据集时,数据源文件和配置文件将自动下载并保存在你计算机的默认或可自定义的缓存文件夹中。

用简单的 print 查看数据集的结构。

print(data)  
# DatasetDict({  
#     train: Dataset(特征: ['id', 'title', 'context', 'question', 'answers'],行数: 87599)  
#     validation: Dataset(特征: ['id', 'title', 'context', 'question', 'answers'],行数: 10570)  
# })

只有训练集和验证集,没有测试集。我们来看训练数据集中一个具体例子。

    print(data['train'][42])  
    # {  
    #   'id': '5733ae924776f41900661016',  
    #   'title': 'University_of_Notre_Dame',  
    #   'context': '圣母大学以其竞争激烈的录取率而著称,2015年秋季入学班级从18,156名申请者中录取了3,577名学生(录取率为19.7%)。录取学生的学术水平继续在美国全国研究型大学中位居前十至十五名。圣母大学实行不具约束力的提前行动政策,允许被录取的学生在考虑圣母大学的同时也可以考虑其他任何被录取的大学。在3,577名录取学生中,有1,400名(39.1%)是通过提前行动计划被录取的。录取的学生来自1,311所不同的高中,平均每位学生来自超过750英里外的地方,这可能使圣母大学成为美国最具代表性的大学之一。尽管所有新生都从第一学年学院开始,但25%的学生计划在文科或社会科学方面学习,24%在工程学,24%在商科,24%在理科,3%在建筑学。',  
    #   'question': '圣母大学有多少百分比的学生参加了提前行动项目?',  
    #   'answers': {'text': ['39.1%'],  
    #   'answer_start': [488]}  
    # }
步骤3:数据预处理:

在预处理步骤里,需要将训练数据用特定于模型的分词器进行分词。为了简化此任务,使用了方便的AutoTokenizer工具。具体操作如下:

    从transformers导入AutoTokenizer模块

    model_name = 'openai-community/gpt2'  
    tokenizer = AutoTokenizer.from_pretrained(model_name)  
    print(tokenizer.special_tokens_map)  
    # {'bos_token': '',  
    #  'eos_token': '',  
    #  'unk_token': ''}

在之前的文章里,使用了这些默认的标记,我猜这可能让结果变得更糟了。相反,我们会用BERT模型里的特殊标记。

    from transformers import AutoTokenizer  

    bert_special_tokens = AutoTokenizer.from_pretrained('bert-base-uncased').special_tokens_map  
    model_name = 'openai-community/gpt2'  
    tokenizer=AutoTokenizer.from_pretrained(model_name)  
    tokenizer.add_special_tokens(tokens)  
    print(tokenizer.special_tokens_map)  
    # {'bos_token': '[开始]',  
    #   'eos_token': '[结束]',  
    #   'unk_token': '[未知]',  
    #   'sep_token': '[分隔]',  
    #   'pad_token': '[填充]',  
    #   'cls_token': '[分类]',  
    #   'mask_token': '[掩码]'}

有了这个,我们可以定义一个分词函数并对数据集进行分词。这稍微复杂一点。输入数据是上下文和问题的结合,需要将它们连接并分词处理。标签表示问题的答案,以开始和结束位置的数值表示。由于SQUAD数据集中某些示例包含多个答案,这里只使用第一个答案。

以下代码是对HuggingFace问题回答教程中的预处理步骤的一个精炼版本。由于训练数据中包含了GPT2分词器不认识的tokens,我增加了异常处理。

    # 来源:HuggingFace,问题回答,https://huggingface.co/docs/transformers/tasks/question_answering#preprocess  
    def preprocess(examples):  
        inputs = tokenizer(  
          examples["question"],  
          examples["context"],  
          truncation=True,  
          padding="max_length",  
          return_offsets_mapping=True,  
          max_length = 512,  
          stride = 128  
        )  

    offset_mapping = inputs.pop("offset_mapping")  
        answers = examples["answers"]  
        start_positions = []  
        end_positions = []  
        # 对于每个偏移量 offset 和其索引 i 遍历 offset_mapping:
        for i, offset in enumerate(offset_mapping):  
            answer = answers[i]  
            start_char = answer["answer_start"][0]  
            end_char = answer["answer_start"][0] + len(answer["text"][0])  
            sequence_ids = inputs.sequence_ids(i)  
            idx = 0  
            context_start = idx  
            context_end = idx  
            try:  
                # 当 sequence_ids[idx] 不等于 1 时:
                while sequence_ids[idx] != 1:  
                    idx += 1  
                    context_start = idx  
                # 当 sequence_ids[idx] 等于 1 时:
                while sequence_ids[idx] == 1:  
                    idx += 1  
                    context_end = idx - 1  
            except:  
                pass  

            # 如果答案没有完全包含在上下文中,则标记为 0 和 0  
            if offset[context_start][0] > end_char or offset[context_end][1] < start_char:  
                start_positions.append(0)  
                end_positions.append(0)  
            else:  
                # 否则,开始和结束位置分别对应答案在文本中的实际位置
                idx = context_start  
                while idx <= context_end and offset[idx][0] <= start_char:  
                    idx += 1  
                start_positions.append(idx - 1)  
                idx = context_end  
                while idx >= context_start and offset[idx][1] >= end_char:  
                    idx -= 1  
                end_positions.append(idx + 1)  
        inputs["start_positions"] = start_positions  
        inputs["end_positions"] = end_positions  
        # 返回处理后的 inputs
        return inputs

让我们应用这个函数,看看它是如何处理输入数据并生成输出数据集的。

    gpt2_squad = data.map(preprocess, batched=True, remove_columns=data["训练"].column_names)  

    gpt2_squad  
    # DatasetDict({  
    #   训练: Dataset({  
    #   特征: ['input_ids', '注意力掩码', '起始位置', '结束位置'],  
    #   行数: 10570,  
    # })  
    # })  

    gpt2_squad["训练"][42]  
    # {'input_ids': [2061, 5873, 286, 2444, 1, ...],  
    #  '注意力掩码': [1, 1, 1, ...],  
    #  '起始位置': 116,  
    #  '结束位置': 119}
步骤 4:定义训练参数

在分词处理完成后,并将startstop作为标签,我们就可以继续设置训练。

对于训练参数的值,使用默认值就可以了,不需要定义任何特殊的度量标准。

from transformers import TrainingArguments  

# 初始化训练参数,设置输出目录为"gpt2_qa",每一步日志记录
training_args = TrainingArguments(output_dir="gpt2_qa", logging_steps=1)
第5步:执行训练:

训练使用一个 DataCollator 对象来处理批输入数据和分词。我们将使用这种模型类型,即 AutoModelForQuestionAnswering,这是一种专门用于问答任务的模型。完整的设置如下。

from transformers import DataCollatorWithPadding  
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)  

from transformers import AutoModelForQuestionAnswering  
model = AutoModelForQuestionAnswering.from_pretrained(model_name)  
model.config.max_length = 512

最后,所有创建训练器所需的部分都完成了,可以开始训练了。

    从transformers模块导入Trainer类。
    trainer = Trainer(  
        model=model,  
        args=training_args,  
        train_dataset=gpt2_squad["train"],  
        eval_dataset=gpt2_squad["validation"],  
        data_collator=data_collator,  
        tokenizer=tokenizer  
    )  

    trainer.train() # 训练模型
步骤 6:使用模型

训练好的模型会被保存在指定的输出目录,并可从该目录加载。

我们用验证数据集里的一个例子试试这个模型。

    val1_id="57269cc3dd62a815002e8b13"  

    example = squad["validation"].filter(lambda x: x['id']==val1_id)[0]  
    # {'id': '57269cc3dd62a815002e8b13',  
    #  'title': '欧盟法律',  
    #  'context': '虽然条约和条例将具有直接效力(如果明确、无条件且立即生效),指令本身通常不会赋予公民(相对于成员国)对其他公民提起诉讼的权利。理论上,这是由于TFEU第288条指出指令是针对成员国的,并且通常“留给成员国选择实施的形式和方法”。部分原因是指令通常制定最低标准,而成员国有权在国家法律中实施更高的标准。例如,《工作时间指令》要求每位工人每年至少有四周带薪假期,但大多数成员国在国家法律中要求的天数比28天更多。然而,根据法院目前的立场,公民可以根据实施指令的国家法律提出索赔,但不能依据指令本身提出索赔。指令不具备所谓的“横向直接效力”(即在非国家实体之间)。这一观点一经提出即受到争议,在20世纪90年代初,三位法律顾问有说服力地主张指令应为所有公民创设权利和义务。法院拒绝了这一观点,但存在五大例外。',  
    #  'question': '《工作时间指令》要求工人每年至少有多少天带薪假期?',  
    #  'answers': {'text': ['四周',  
    #      '每年至少四周带薪假期',  
    #      '四周带薪'],  
    #   'answer_start': [594, 594, 594]}}

在使用模型时,我们创建一个问答流程,指定分词器和本地模型的位置。然后我们将数据集转换为一个包含 questioncontext 键的字典。

    local_model = 'gpt2_qa'  

    qa = pipeline(  
        "问答",  
        tokenizer=tokenizer,  
        model=AutoModelForQuestionAnswering.from_pretrained(model_name)  
    )  
    d = qa({"question": example["question"], "context": example["context"]})  
    # 得分: 0.0003682523383758962, 开始: 662, 结束: 692, 答案: '比国家法律规定的28天更长。'

然而,这个答案只部分正确,没有提到开头的部分。

使用微调模型进行问答系统

最后,我们将经过微调的模型应用于它未经过训练的上下文——来自维基百科的一段文字,并提出问题“什么是NASA”。

    query = {  
     "question": "NASA是什么?",  
     "context": '''  
     美国国家航空航天局(NASA)是美国联邦政府的一个独立机构,负责民用航天计划和航空研究以及空间研究。NASA成立于1958年,接替了国家航空咨询委员会(NACA),为美国的空间开发努力提供了一个明确的民用方向,强调和平的应用。此后,NASA领导了大多数美国的太空探索任务,包括水星计划、双子星计划、1968-1972年的阿波罗登月任务、天空实验室太空站和航天飞机。目前,NASA支持国际空间站,并监督奥里安飞船和太空发射系统的开发,用于载人月球阿耳忒弥斯计划、商业乘员飞船以及计划中的月球门户太空站。

    NASA的科学研究侧重于通过地球观测系统来更好地了解地球;通过太阳物理学研究计划推进太阳物理学研究;通过如新视野号这样的先进无人航天器和如毅力号这样的火星探测车探索太阳系中的物体;并通过詹姆斯·韦伯太空望远镜、大型观测站及相关计划研究天体物理学问题,如大爆炸。发射服务计划负责无人发射的操作和倒计时。
     '''  
    }  
    qa(query)  
    # {'score': 0.008794573135674,  
    # 'start': 305,  
    # 'end': 369,  
    # 'answer': '美国空间开发努力的一个明确民用方向,\n\t'}

然而,这个回答并不令人满意。此外,尝试类似的问题,比如“NASA 是何时成立的?”也得到了类似的结果。尽管模型经过了微调,显然单纯的高亮片段并不能给出精确的答案,更不用说能理解上下文并给出恰当回答了。通过这个结果,构建问答系统的第二种方法的局限性也变得很明显。

结论部分

早期的Gen1 LLMs 虽然能够生成基本的文本,但只是基本的文本生成,缺乏对高级NLP任务的支持。本文展示了一种使用GPT2与问答数据集(例如问题-答案对)进行微调的实用方法。你了解了数据集的结构并看到了必要的预处理步骤。你还在transformers库的帮助下看到了如何定义超参数(例如学习率、批量大小等)并启动训练过程,并使用transformers库提供的功能。之后,进行了手动测试,利用微调后的模型对NASA维基百科页面进行了测试,但模型的回答并不准确,显然简单的片段匹配并不能生成有深度的回答。在接下来的文章中探索了使用GPT3进行领域嵌入的方法。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消