小白入门大模型 - 从微调模型开始了解大模型

本文较长,建议点赞收藏。更多AI大模型应用开发学习视频及资料,在这里

在自然语言处理(NLP)的浪潮中,大型预训练模型(如 BERT、GPT 等)已成为驱动各类应用的核心引擎。然而,如何让这些通用模型更好地适应我们特定的业务场景?答案便是微调(Fine-tuning)。Hugging Face 推出的 Transformers 库,凭借其无与伦比的易用性和丰富的模型生态,极大地降低了微调的技术门槛。

本文不满足于对 API 的浅尝辄止,而是希望为您提供一份兼具深度与可操作性的"食谱"。读完本文,您将不仅能成功运行代码,更能洞悉其背后的"为什么",并具备独立解决实际问题的能力。

很多同学可能对模型的认知停留在应用层,本文意在让大家能够有一些针对模型的认知,以及训练一个模型究竟需要分为多少步骤?

基础概念

在动手编码之前,我们有必要先花些时间理解 Transformers 的核心设计哲学。这能帮助我们在遇到问题时,不仅知其然,更能知其所以然。

Transformers 模型

Transformers 模型通常规模庞大。包含数以百万计到数千万计数十亿的参数,训练和部署这些模型是一项复杂的任务。再者,新模型的推出几乎日新月异,而每种模型都有其独特的实现方式,尝试全部模型绝非易事。

Transformers 库应运而生,就是为了解决这个问题。它的目标是提供一个统一的 API 接口,通过它可以加载、训练和保存任何 Transformer 模型。

Transformers 模型用于解决各种 NLP 问题,如

  • feature-extraction(获取文本的向量表示)
  • fill-mask(完形填空)
  • ner(命名实体识别)
  • question-answering(问答)
  • sentiment-analysis(情感分析)
  • summarization(提取摘要)
  • text-generation(文本生成)
  • translation(翻译)
  • zero-shot-classification(零样本分类)

Transformers 模型主要分为 2 层 :编码器解码器 ,我们可以将其简单理解为 输入 -> 输出

编码器和解码器
  • 编码器 (Encoder) : 专职"理解"。它负责将输入文本(如一个句子)转换成富含语义信息的数字表示。非常适合做文本分类、命名实体识别等任务。代表选手:BERT、RoBERTa。
  • 解码器 (Decoder) : 专职"生成"。它能根据一个初始指令(Prompt),逐字逐句地创造出新的文本。我们熟知的 GPT 系列就是典型的解码器架构。
  • 编码器-解码器 (Encoder-Decoder) : "理解"与"生成"的结合体。先用编码器消化输入文本,再用解码器产出目标文本。是翻译、摘要等任务的标配。代表选手:BART、T5。
模型 示例 任务
编码器 BERT, DistilBERT, ELECTRA, RoBERTa 句子分类、命名实体识别、抽取式问答 (从文本中提取答案)
解码器 CTRL, GPT, GPT-2, Transformer XL 文本生成
编码器-解码器 BART, T5, Marian, mBART 文本摘要、翻译、生成式问答 (生成问题的回答类似 chatgpt)
架构和检查点(Checkpoints)
  • 架构:这是模型的骨架 ------ 即每个层的定义以及模型中发生的每个操作。
  • Checkpoints(检查点):这些是将在给架构中结构中加载的权重参数,是一些具体的数值。

举个例子:BERT 是一个架构,而 bert-base-cased ,这是谷歌团队为 BERT 的第一个版本训练的一组权重参数,是一个参数。我们可以说"BERT 模型"和" bert-base-cased 模型。"

Tokenizer

与其他神经网络一样,Transformers 模型无法直接处理原始文本,因此我们需要引入 Tokenizer

Tokenizer 是人类语言与机器语言之间的"翻译官"。其职责重大,主要包括:

  1. 分词 (Tokenization) : 将 "今天天气真好" 这样的句子拆分成模型能认识的最小单元,如 ["今", "天", "天", "气", "真", "好"]
  2. ID 转换 : 将每个词元(Token)映射成一个独一无二的数字 ID,即 input_ids
  3. 添加特殊标记 : 插入模型必需的特殊符号,比如 [CLS]用于分类任务,[SEP]用于分隔句子。
  4. 生成注意力掩码 (Attention Mask) : 当句子长度不一时,短句子需要被"填充"(Padding)到与长句同样的长度。注意力掩码就是一个由 0 和 1 组成的列表,告诉模型哪些是真实词元(值为 1),哪些是填充物(值为 0),计算时应忽略后者。

我们先通过分词器(Tokenizer)把文本转换为模型能够读懂的数字。

ini 复制代码
def run_tokenizer():  
    checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"  
    tokenizer = AutoTokenizer.from_pretrained(checkpoint)  
    result = tokenizer(  
        ["I am very urgent!", "I want to complain!"],  
        padding=True,  
        truncation=True,  
        return_tensors="pt",  
    )  
    # {  
    #     'input_ids':  
    #          tensor([[  101,  1045,  2572,  2200, 13661, 999, 102],[ 101,  1045,  2215,  2000, 17612,  999,  102]]),  
    #     'attention_mask':  
    #          tensor([[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1]])  
    # }  
    # 输出是一个包含两个键, input_ids 和 attention_mask  
    # input_ids 包含两行整数(每个句子一行),它们是每个句子中 token 的 ID。  
    print(result)

Model

模型会接收 Tokenizer 生成的数字,通过模型头等进行处理,最终生成对应任务的输出结果。例如,在情感分类任务中生成类别概率分布,分别是正面和负面。

但这些不是概率,而是 logits(对数几率) ,是模型最后一层输出的原始的、未标准化的分数。要转换为概率,它们需要经过 SoftMax 层

下面是一个情感分类的一个例子:

ini 复制代码
def run_model():  
    from transformers import AutoModel, AutoModelForSequenceClassification, AutoTokenizer  
    import torch.nn.functional as F  
    checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"  
    model = AutoModelForSequenceClassification.from_pretrained(checkpoint)  
    tokenizer = AutoTokenizer.from_pretrained(checkpoint)  
    # 分词得到 input_ids  
    input = tokenizer(["I am very urgent!", "I want to complain!"], padding=True, return_tensors="pt")  
    
    res = model(**input)  
    # 处理后序输出  
    probabilities = F.softmax(res.logits, dim=-1)  
    # 获取模型输出对应的label  
    labels = sequence_classication_model.config.id2label

从微调一个小模型学起

学习大模型最好的办法就是动手实践。下面从微调一个简单的问答模型为例子,打开学习大模型的大门吧。

智能流程总结

  1. 原料(数据)准备 :我们需要一批包含 context(上下文)、question(问题)和 answers(答案文本及其在上下文中的起始位置)的数据集。

  2. 预处理(分词) :调用与预训练模型配套的 Tokenizer,将 questioncontext转化为模型可消化的 input_idsattention_mask等数值输入。对于 QA 任务,这一步至关重要,它还需要计算出答案在分词后序列中所对应的 start_positionsend_positions

  3. 送入模型 :将处理好的数据喂给一个专用于问答任务的模型,如 AutoModelForQuestionAnswering

  4. 训练(微调)

  • 配置 TrainingArguments,用于设定学习率、批次大小(Batch Size)等超参数。

  • 启动 Trainer,它会自动处理设备分配(CPU/GPU)、梯度更新、日志记录等一系列繁杂的后台工作。

  • 训练的核心目标是:通过不断调整模型权重,使得模型预测的答案起止位置,与我们提供的真实标签越来越接近。

  1. 出厂(后处理) :将新的问题和上下文输入给微调完毕的模型。模型会输出两组分数(start_logitsend_logits),分别代表每个词元作为答案开头和结尾的可能性。我们通过一个简单的后处理逻辑,找到分数最高的组合,便能解码出最终的答案文本。

获取数据集

训练模型最重要的事情是数据集! 我们可以从Hugging Face等渠道获取各种各样的数据集。但是这里我们为了效果明显,自己去构建一个极为简单的数据集。

ini 复制代码
ctx = """  
    权限管理平台 ACC(Auth Config Center)  
    为中台提供一套运行稳定、安全可靠、界面简洁的可视化权限配置能力。包括:权限配置、权限下发及鉴权功能。  
    其涉及了一些名词:  
    - 权限点(keyword):权限系统中最小的权限粒度映射到业务系统对应系统操作功能。例如:查询,搜索,删除等操作  
    - 功能权限树:为用户提供权限下发与系统权限管控  
    - 菜单权限树:提供页面及菜单的权限下发与管控  
    - 白名单:无需登录、需要登录无需鉴权赋予某一特定角色功能  
    """  
 # 原始数据列表  
    raw_data = [  
        {  
            "id": "001",  
            "context": ctx,  
            "question": "什么是 ACC",  
            "answer_text": "权限管理平台",  
        },  
        {  
            "id": "002",  
            "context": ctx,  
            "question": "ACC 有哪些功能",  
            "answer_text": "权限配置、权限下发及鉴权功能",  
        },  
        {  
            "id": "003",  
            "context": ctx,  
            "question": "ACC 有哪些名词",  
            "answer_text": "权限点",  
        },  
        {  
            "id": "004",  
            "context": ctx,  
            "question": "权限点是干嘛的",  
            "answer_text": "权限系统中最小的权限粒度映射到业务系统对应系统操作功能",  
        },  
    ]

有了原始数据,我们需要对其进行格式转换。在学术领域,用于抽取式问答的最常用基准数据集是SQuAD,我们可以去下载一下,看看它的格式是怎样的

shell 复制代码
from datasets import load_dataset  
  
raw_datasets = load_dataset("squad")  
  
# DatasetDict({  
#     train: Dataset({  
#         features: ['id', 'title', 'context', 'question', 'answers'],  
#         num_rows: 87599  
#     })  
#     validation: Dataset({  
#         features: ['id', 'title', 'context', 'question', 'answers'],  
#         num_rows: 10570  
#     })  
# })  
  
# 其中answers格式为{text:string[], answer_start:int[]}

contextquestion字段的使用非常简单直接。answers字段稍显复杂,因为它包含一个带有两个字段的字典,而这两个字段都是列表。这是评估过程中squad指标所期望的格式;如果你使用自己的数据,则不一定需要费心将答案设置为相同的格式。text字段的含义相当明显,answer_start字段包含每个答案在上下文中的起始字符索引。

我们可以把我们的原始数据也转换成这种格式。完整代码如下:

ini 复制代码
def create_toy_qa_dataset() -> DatasetDict:  
    ctx = """  
    权限管理平台 ACC(Auth Config Center)  
    为中台提供一套运行稳定、安全可靠、界面简洁的可视化权限配置能力。包括:权限配置、权限下发及鉴权功能。  
    其涉及了一些名词:  
    - 权限点(keyword):权限系统中最小的权限粒度映射到业务系统对应系统操作功能。例如:查询,搜索,删除等操作  
    - 功能权限树:为用户提供权限下发与系统权限管控  
    - 菜单权限树:提供页面及菜单的权限下发与管控  
    - 白名单:无需登录、需要登录无需鉴权赋予某一特定角色功能  
    """  
    # 原始数据列表  
    raw_data = [  
        {  
            "id": "001",  
            "context": ctx,  
            "question": "什么是 ACC",  
            "answer_text": "权限管理平台",  
        },  
        {  
            "id": "002",  
            "context": ctx,  
            "question": "ACC 有哪些功能",  
            "answer_text": "权限配置、权限下发及鉴权功能",  
        },  
        {  
            "id": "003",  
            "context": ctx,  
            "question": "ACC 有哪些名词",  
            "answer_text": "权限点",  
        },  
        {  
            "id": "004",  
            "context": ctx,  
            "question": "权限点是干嘛的",  
            "answer_text": "权限系统中最小的权限粒度映射到业务系统对应系统操作功能",  
        },  
    ]  
  
    # 格式化数据,自动计算 answer_start  
    formatted_data = {"id": [], "context": [], "question": [], "answers": []}  
  
    for item in raw_data:  
        context = item["context"]  
        answer_text = item["answer_text"]  
  
        # 找到答案在原文中的起始位置  
        start_idx = context.find(answer_text)  
  
        formatted_data["id"].append(item["id"])  
        formatted_data["context"].append(context)  
        formatted_data["question"].append(item["question"])  
        formatted_data["answers"].append(  
            {"text": [answer_text], "answer_start": [start_idx]}  
        )  
  
    # 创建 Dataset  
    ds = Dataset.from_dict(formatted_data)  
    validation_ds = ds.select(range(4))  
    dsd = DatasetDict({"train": ds, "validation": validation_ds})  
  
    return dsd

我们这次训练使用 google-bert/bert-base-chinese模型,虽然它并不是专门用于问答任务。先展示一下微调前的效果:

ini 复制代码
model_checkpoint = "google-bert/bert-base-chinese"  
  
# 加载 Tokenizer  
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)  
  
# 加载 Dataset  
raw_datasets = create_toy_qa_dataset()  
  
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)  
  
test_context = raw_datasets["train"][1]["context"]  
test_question = raw_datasets["train"][1]["question"]  
  
answer = get_answer(test_question, test_context, model, tokenizer)  
# answer: ''

这里的 get_answer 的具体代码在下文会详细讲解,这里认为是模型推理即可。

这里大概率为空或乱码(因为该模型没学过这个任务) ,我们需要对它进行微调来让模型能够满足我们的效果。

预处理数据集

我们需要将数据集中的文本信息处理成Input IDs。利用 DatasetDict中的 map方法,可以对整个数据集做批处理:

ini 复制代码
tokenized_datasets = raw_datasets.map(  
        lambda x: preprocess_function(x, tokenizer),  
        batched=True, # 是否批处理  
        remove_columns=raw_datasets[  
            "train"  
        ].column_names,  # 移除原始文本列,只保留分词后的列 即 Input ID 等  
    )
ini 复制代码
def preprocess_function(samples, tokenizer):  
    tokenized_inputs = tokenizer(  
        examples["question"],  
        examples["context"],  
        max_length=384,  
        truncation="only_second",  
        return_offsets_mapping=True,  
        padding="max_length",  
    )  
  # ...

首先 tokenizer中我们传入了每一个样本 (数据集中的每一条数据) 的 questioncontext,这样将会对同时对两者进行处理。

  • max_length:表示 tokenizer处理的最大长度,这里假设答案一定在前 384 个 Token 里。如果文章很长,超出部分直接扔掉。
  • truncation="only_second":跟上述一样,如果超长,直接对 context进行丢弃。
  • return_offsets_mapping=True: 返回每个 Token 对应原文的字符位置 (start_char, end_char)

最终输入的 tokenizied_inputs.input_ids是一个 长度为5的数组(因为数据集只有5条数据,每一项都包含 question + context)

我们可以通过 tokenized_inputs.sequence_ids(i)去获取具体某条 input_id中,哪一个部分代表question、哪一个部分代表context

rust 复制代码
tokenized_inputs.sequence_ids(0)   
# [None, 0, 0, 0, 0, None, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, ...]

tokenized_inputs["offset_mapping"]也是一个 长度为5 的数组,它包含了每一个 Token所在的索引

ini 复制代码
# 对应索引0  
[(0, 0), (0, 1), (1, 2), (2, 3), (4, 7), (0, 0), (5, 6), (6, 7), (7, 8), (8, 9), (9, 10)...]

可以看到,(0, 0)刚好对应的是 None,其实就是questioncontext之间的特殊标记。

最后我们要做的,就是需要找到Token级别下, 答案所在的位置:

  1. 先排除question部分,找到context所在的 Token下的位置索引
  2. context的首尾同时遍历,直到找到包含 start_char(原始数据中答案所在的位置索引) 的 Token

这部分相对简单,代码如下。

ini 复制代码
def preprocess_function(examples, tokenizer):  
     
    tokenized_inputs = tokenizer(  
        examples["question"],  
        examples["context"],  
        max_length=384,  
        truncation="only_second",  
        return_offsets_mapping=True,  
        padding="max_length",  
    )  
  
    # 2. 处理答案位置  
    offset_mapping = tokenized_inputs.pop("offset_mapping")  
    answers = examples["answers"]  
    start_positions = []  
    end_positions = []  
  
    for i, offset in enumerate(offset_mapping):  
        answer = answers[i]  
        start_char = answer["answer_start"][0]  
        end_char = start_char + len(answer["text"][0])  
  
        # sequence_ids 用于区分哪部分是问题,哪部分是上下文  
        # 0 代表问题,1 代表上下文,None 代表特殊符号([CLS], [SEP], [PAD])  
        sequence_ids = tokenized_inputs.sequence_ids(i)  
  
        # 找到上下文在 Token 序列中的起始和结束索引  
        idx = 0  
        while sequence_ids[idx] != 1:  
            idx += 1  
        context_start = idx  
        while sequence_ids[idx] == 1:  
            idx += 1  
        context_end = idx - 1  
  
        # 如果答案不在当前截断的片段中(针对超长文本),这就标记为 (0, 0)  
        # 这里的 offset[context_end][1] 是当前片段最后一个 Token 对应的原文结束字符位置  
        if offset[context_start][0] > start_char or offset[context_end][1] < end_char:  
            start_positions.append(0)  
            end_positions.append(0)  
        else:  
            # 否则,我们需要找到 Token 的 start_index 和 end_index  
  
            # 从上下文的第一个 Token 开始往后找,直到找到包含 start_char 的 Token  
            idx = context_start  
            while idx <= context_end and offset[idx][0] <= start_char:  
                idx += 1  
            start_positions.append(idx - 1)  
  
            # 从上下文的最后一个 Token 开始往前找,直到找到包含 end_char 的 Token  
            idx = context_end  
            while idx >= context_start and offset[idx][1] >= end_char:  
                idx -= 1  
            end_positions.append(idx + 1)  
  
    # 将计算好的 Token 级别的起始和结束位置放入 inputs 中  
    tokenized_inputs["start_positions"] = start_positions  
    tokenized_inputs["end_positions"] = end_positions  
  
    return tokenized_inputs

构建超参数,开始训练

ini 复制代码
args = TrainingArguments(  
        output_dir="qa-model-finetuned",  
        eval_strategy="no",  # 数据太少,不进行分步评估  
        save_strategy="no",  
        learning_rate=5e-5,  
        per_device_train_batch_size=2,  # 小批量  
        per_device_eval_batch_size=2,  
        num_train_epochs=50,  # 增加 epoch 以确保拟合  
        weight_decay=0.01,  
        push_to_hub=False,  
        logging_steps=10,  
        use_mps_device=False,  
    )

我们先来看一下模型训练时的一些重要参数:

num_train_epochs :要执行的训练轮数总和。通俗来说,1 Epoch表示模型完完整整的看过进行训练的数据集一次。

  • 如果num_train_epochs设置过多,训练出来的模型将会过拟合,即反反复复多次背诵训练的数据集,对数据集就很熟悉,但是如果遇到新的问题则可能回答不出来,泛化能力差。反之,则欠拟合。

学习率 learning_rate: 决定了每次模型训练时参数的更新幅度(0-1)。**简单来说,模型在训练过程中的效果不够好时,模型需要调整的幅度。

  • 学习率太大 (比如 0.1):老师大吼一声"全错!重写!",学生吓得不知所措,可能下次走向另一个极端,永远找不到正确答案(模型不收敛,Loss 震荡)。
  • 学习率太小 (比如 1e-8):老师极其温柔地说"这里稍微改一点点...",学生改了一万次才改对,等到毕业了还没学会(训练太慢,收敛不了)。
  • 合适的值 (5e-5):老师指出关键错误,让学生做适度的调整。BERT 微调通常都用这个量级(2e-5 到 5e-5),因为它已经"预习"(预训练)过了,不需要从头学,只需要微调。

批量大小 per_device_train_batch_size:指每台设备训练时的批量大小,在多GPU或分布式训练中,总 * Batch size = per_device_train_batch_size * number_of_devices*

  • Batch Size = 1 :学生做完一题,老师马上批改一题。学生能立刻得到反馈,但老师会很累(计算慢),而且如果某道题出错了(脏数据),学生会被带偏。
  • Batch Size = 100 :学生做完100题,老师统一批改,告诉他"总体方向对了没有"。这样比较稳(梯度稳定),但对老师的脑容量(显存)要求很高。

权重衰减 weight_decay: 通俗来说 ,给"死记硬背"的学生扣分(惩罚项)。

它强制模型不要过于依赖某几个特定的特征(比如不要看到"因为"两个字就无脑选后面的句子做答案)。它让模型的参数尽量小且分散,这样模型的泛化能力更强。

如果模型过拟合了,可以适当调大 weight_decay * *

最后,我们就可以通过Trainer,对模型进行训练啦:

ini 复制代码
...  
  
    # 实例化 Trainer  
    data_collator = DefaultDataCollator()  
    trainer = Trainer(  
        model=model,  
        args=args,  
        train_dataset=tokenized_datasets["train"],  
        eval_dataset=tokenized_datasets["validation"],  
        tokenizer=tokenizer,  
        data_collator=data_collator,  
    )  
  
    # 开始训练  
    trainer.train()

在模型训练过程中,终端会输出一些训练时参数

arduino 复制代码
{'loss': 3.9616, 'grad_norm': 17.661741256713867, 'learning_rate': 4.7e-05, 'epoch': 3.33}                           
{'loss': 2.0221, 'grad_norm': 26.38640594482422, 'learning_rate': 4.3666666666666666e-05, 'epoch': 6.67}             
{'loss': 1.7888, 'grad_norm': 30.200885772705078, 'learning_rate': 4.0333333333333336e-05, 'epoch': 10.0}            
{'loss': 1.2302, 'grad_norm': 46.9060173034668, 'learning_rate': 3.7e-05, 'epoch': 13.33}                            
{'loss': 0.5915, 'grad_norm': 51.04061508178711, 'learning_rate': 3.366666666666667e-05, 'epoch': 16.67}             
{'loss': 0.3984, 'grad_norm': 0.5519830584526062, 'learning_rate': 3.0333333333333337e-05, 'epoch': 20.0}            
{'loss': 0.5358, 'grad_norm': 16.61482810974121, 'learning_rate': 2.7000000000000002e-05, 'epoch': 23.33}            
{'loss': 0.0591, 'grad_norm': 20.124296188354492, 'learning_rate': 2.3666666666666668e-05, 'epoch': 26.67}           
{'loss': 0.009, 'grad_norm': 0.022040903568267822, 'learning_rate': 2.0333333333333334e-05, 'epoch': 30.0}           
{'loss': 0.0018, 'grad_norm': 0.025523267686367035, 'learning_rate': 1.7000000000000003e-05, 'epoch': 33.33}         
{'loss': 0.0054, 'grad_norm': 0.21060892939567566, 'learning_rate': 1.3666666666666666e-05, 'epoch': 36.67}          
{'loss': 0.0004, 'grad_norm': 0.01707434467971325, 'learning_rate': 1.0333333333333333e-05, 'epoch': 40.0}

loss表示模型的预测与真实答案之间的差距。这个差距值,就是我们所说的"损失值"。在训练过程中我们可以发现 loss逐渐减少,这证明模型在训练过程中的效果越来越符合验证集中的数据。

为什么这里的 * *epoch**是小数?

在上述例子中:

  • 数据总量 :只有 5 条数据。
  • 批次大小 ( per_device_train_batch_size ) :设为 2 。

那么,完成 1 个 Epoch (遍历所有数据一遍)需要走几步(Steps)?

(注:第1步取2条,第2步取2条,第3步取最后1条)

设置了 logging_steps=10 。

这意味着 Trainer 每走 10 步 (Steps)就会打印一次日志。

我们来算算 10 步 相当于跑了多少个 Epoch:

所以:

  • 第 10 步时,打印日志,此时 Epoch = 10 / 3 ≈ 3.33
  • 第 20 步时,打印日志,此时 Epoch = 20 / 3 ≈ 6.67
  • 第 30 步时,打印日志,此时 Epoch = 30 / 3 = 10.0

这就是为什么你会看到 3.33 , 6.67 这种非整数的 Epoch。

效果验证和总结

我们以本节开头的例子来验证一下,那么如何去验证呢?

scss 复制代码
test_context = raw_datasets["train"][1]["context"]  
test_question = raw_datasets["train"][1]["question"]  
  
answer = get_answer(test_question, test_context, model, tokenizer)  
print(f"问题: {test_question}")  
print(f"回答: {answer}")

get_answer函数中,我们需要进行 tokenizermodelc处理 以及 后处理三步骤。

ini 复制代码
def get_answer(question, context, model, tokenizer):  
    # 将模型设为评估模式  
    model.eval()  
  
    # 1. 分词  
    inputs = tokenizer(question, context, return_tensors="pt")  
  
    # 2. 模型前向传播  
    with torch.no_grad():  
        outputs = model(**inputs)  
  
    # 3. 后处理:获取预测结果  
    # 模型输出包含 start_logits 和 end_logits,分别表示每个 Token 是答案开头的概率和结尾的概率  
    answer_start_index = torch.argmax(outputs.start_logits)  
    answer_end_index = torch.argmax(outputs.end_logits) + 1  # +1 是因为切片是左闭右开  
  
    # 4. 将 Token ID 转换回文本  
    predict_answer_tokens = inputs.input_ids[0, answer_start_index:answer_end_index]  
    predicted_answer = tokenizer.decode(predict_answer_tokens, skip_special_tokens=True)  
  
    return predicted_answer

要注意的是,这里模型输出的是start_logitsend_logits,分别表示每个 Token 是答案开头的概率和结尾的"概率"(对数几率)

在这里,我们直接通过 torch.argmax分别获取两者概率最大的索引,最后在 Input ids中去获取相对应的 Token,最后再由 tokenizer解码成文本内容。

bash 复制代码
# 问题: ACC 有哪些功能  
# 回答: 权 限 配 置 、 权 限 下 发 及 鉴 权 功 能

最后看上去效果还不错,这次次模型微调就结束啦~

拓展内容

注意

本次微调只是一个"玩具"流程,有一些需要注意的地方:

  • 正常训练时的数据集不可能这么少,动则上万条文本数据的数据集只是门槛。。
  • 由于训练集小,因此我们的epoch设置了50轮,让模型过拟合来更好的去验证结果。正常模型训练时一般取3轮左右。

拓展内容

预处理训练集(滑动窗口)

在预处理训练集过程中,我们采用简单截断的方式:我假设你的文本很短,或者答案一定在前 384 个 Token 里。如果文章很长,超出部分直接扔掉 ( truncation="only_second")。如果答案不幸在被扔掉的那部分里,模型就永远找不到了。

因此模型不需要处理"一个样本变成多个片段"的情况,代码逻辑是一对一的,非常简单。

如果训练集中的文本内容很长,我们可以给 tokenizer设置 stride(步长),表示将文章分割为若干个切片,每一个切片都有重合的一部分。这就导致了" 一对多 "的关系(1 个问题 -> N 个输入片段)。在预测时,则需要收集这 N 个片段的所有输出 Logits,统一比较,找出在这个长文章中到底哪一段的哪个位置分数最高。

ini 复制代码
def preprocess_function(examples, tokenizer):  
     
    tokenized_inputs = tokenizer(  
        examples["question"],  
        examples["context"],  
        max_length=384,  
        truncation="only_second",  
        return_offsets_mapping=True,  
        return_overflowing_tokens=Ture  
        stride=100,  
        padding="max_length",  
    )  
      
    # 2. 处理答案位置  
    offset_mapping = tokenized_inputs.pop("offset_mapping")  
    answers = examples["answers"]  
    start_positions = []  
    end_positions = []  
    sample_idxs = []  
  
    for i, offset in enumerate(offset_mapping):  
        # 表示当前片段所对应的样本索引  
        sample_idx = inputs["overflow_to_sample_mapping"][i]  
        sample_idxs.append(sample_idx)  
        answer = answers[sample_idx]  
        start_char = answer["answer_start"][0]  
        end_char = start_char + len(answer["text"][0])  
  
        # sequence_ids 用于区分哪部分是问题,哪部分是上下文  
        # 0 代表问题,1 代表上下文,None 代表特殊符号([CLS], [SEP], [PAD])  
        sequence_ids = tokenized_inputs.sequence_ids(i)  
  
        # 找到上下文在 Token 序列中的起始和结束索引  
        idx = 0  
        while sequence_ids[idx] != 1:  
            idx += 1  
        context_start = idx  
        while sequence_ids[idx] == 1:  
            idx += 1  
        context_end = idx - 1  
  
        # 如果答案不在当前截断的片段中(针对超长文本),这就标记为 (0, 0)  
        # 这里的 offset[context_end][1] 是当前片段最后一个 Token 对应的原文结束字符位置  
        if offset[context_start][0] > start_char or offset[context_end][1] < end_char:  
            start_positions.append(0)  
            end_positions.append(0)  
        else:  
            # 否则,我们需要找到 Token 的 start_index 和 end_index  
  
            # 从上下文的第一个 Token 开始往后找,直到找到包含 start_char 的 Token  
            idx = context_start  
            while idx <= context_end and offset[idx][0] <= start_char:  
                idx += 1  
            start_positions.append(idx - 1)  
  
            # 从上下文的最后一个 Token 开始往前找,直到找到包含 end_char 的 Token  
            idx = context_end  
            while idx >= context_start and offset[idx][1] >= end_char:  
                idx -= 1  
            end_positions.append(idx + 1)  
  
    # 将计算好的 Token 级别的起始和结束位置放入 inputs 中  
    tokenized_inputs["start_positions"] = start_positions  
    tokenized_inputs["end_positions"] = end_positions  
    tokenized_inputs["sample_idxs"] = sample_idxs  
  
    return tokenized_inputs
模型后处理

在上述案例中,我们是基于最简单的"贪心"策略去实现的。我们假设分别获取作为开头、结尾时概率最大的Token,两者中间所包含的文本就是最佳答案。

这样的处理方式会有很大的问题:

  1. 问题1:开始索引大于结束索引
  • 现象 :当模型预测的始位置(start_index)大于结束位置(end_index)时,切片input_ids[0, start_index:end_index]会返回空张量,导致解码后得到空字符串
  • 原因 :独立使用 argmax选择 startend位置,未考虑两者的依赖关系(答案必须是连续片段,start <= end
  1. 问题2:置信度过低
  • 现象:即使模型对所有位置的预测置信度都很低(如context中无相关答案),代码仍会返回一个答案
  • 原因:直接使用 argmax 强制选择一个位置,未考虑模型的预测不确定性
  • 解决方式:通过遍历每一个 Token,寻找出所有的开头和结尾的组合,并计算其概率,找出概率最大的那对组合。
ini 复制代码
def get_answer(question, context, model, tokenizer):  
    # 将模型设为评估模式  
    model.eval()  
  
    # 1. 分词  
    inputs = tokenizer(  
        question, context, max_length=384,  
        truncation="only_second",  
        return_offsets_mapping=True,  
        return_overflowing_tokens=Ture  
        stride=100,  
        padding="max_length"  
    )  
  
    # 2. 模型前向传播  
    with torch.no_grad():  
        outputs = model(**inputs)  
      
    transform_res = []  
    start_logits = outputs.start_logits.cpu().numpy()  
    end_logits = outputs.end_logits.cpu().numpy()  
      
    logits_size = start_logits.shape[0]  
    for feature_idx in range(logits_size):  
      
        sample_idx = inputs["overflow_to_sample_mapping"][feature_idx]  
        offset = inputs["offset_mappings"][feature_idx]  
        start_logit = start_logits[feature_idx]  
        end_logit = end_logits[feature_idx]  
        sequence_ids = inputs.sequence_ids(feature_idx)  
          
         for start_idx in start_logit:  
            for end_idx in end_logit:  
                if start_idx > end_idx:  
                    continue  
                      
                if sequence_ids[start_idx] == 0 and sequence_ids[end_idx] == 0:  
                    continue  
                  
                start_token = offset[start_idx][0]  
                end_token = offset[end_idx][1]  
                  
                if start_token == 0 and end_token == 0:  
                    continue  
                if end_token < start_token:  
                    continue  
                ans_from_ctx = context[start_token:end_token]  
                # 从原始logits中取  
                scroe = start_logits[logit_idx][start_idx] + end_logits[logit_idx][end]  
                transform_res.append(  
                    {  
                        "answer": ans_from_ctx,  
                        "score": scroe,  
                        "start_token": start_token,  
                        "end_token": end_token,  
                    }  
                )  
    return transform_res

如果你想更深入地学习大模型,以下是一些非常有价值的学习资源,这些资源将帮助你从不同角度学习大模型,提升你的实践能力。

本文较长,建议点赞收藏。更多AI大模型应用开发学习视频及资料,在这里

相关推荐
AI大模型2 小时前
使用本地 Ollama + Qwen 3 模型,结合 Obsidian 构建真正的本地隐私 RAG 知识库
llm·agent·ollama
破烂pan2 小时前
TensorRT-LLM部署Qwen3-14B
llm·tensorrt·qwen3-14b
大模型RAG和Agent技术实践2 小时前
从零构建:基于 LangGraph 的医疗问诊智能体实战(完整源代码)
人工智能·langchain·agent·langgraph
xuedaobian3 小时前
2025年我是怎么用AI写代码的
前端·程序员·ai编程
阿里云云原生3 小时前
Hello AgentScope Java
云原生·agent
致Great4 小时前
大模型对齐核心技术:从第一性原理完整推导 PPO 算法!
人工智能·算法·大模型·agent·智能体
Mintopia4 小时前
🪄 生成式应用的 **前端 orchestration 层(编排层)指南**
人工智能·llm·aigc
听到微笑4 小时前
超越 ReAct:探寻Plan-And-Execute Agent的设计与实现原理
ai·llm·ai agent
威化饼的一隅4 小时前
【大模型LLM学习】通义Agent系列学习笔记
agent·通义千问·deep research·research agent·通义agent·深度研究智能体·tongyi agent