在自然语言处理(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)把文本转换为模型能够读懂的数字。

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 层

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

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等渠道获取各种各样的数据集。但是这里我们为了效果明显,自己去构建一个极为简单的数据集。

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,我们可以去下载一下,看看它的格式是怎样的

from datasets import load_datasetraw_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字段包含每个答案在上下文中的起始字符索引。

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

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模型,虽然它并不是专门用于问答任务。先展示一下微调前的效果:

model_checkpoint = "google-bert/bert-base-chinese"# 加载 Tokenizertokenizer = AutoTokenizer.from_pretrained(model_checkpoint)# 加载 Datasetraw_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方法,可以对整个数据集做批处理:

tokenized_datasets = raw_datasets.map(        lambda x: preprocess_function(x, tokenizer),        batched=True, # 是否批处理        remove_columns=raw_datasets[            "train"        ].column_names,  # 移除原始文本列,只保留分词后的列 即 Input ID 等    )
``````plaintext
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

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所在的索引

# 对应索引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

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

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
构建超参数,开始训练
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,对模型进行训练啦:

...    # 实例化 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()

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

{'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。

效果验证和总结

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

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处理以及后处理**三步骤。

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解码成文本内容。

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

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

拓展内容

注意

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

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

拓展内容

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

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

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

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

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,寻找出所有的开头和结尾的组合,并计算其概率,找出概率最大的那对组合。
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 岗还在四处搜集零散资料?别再浪费时间啦!2025 年 AI 大模型全套学习资料已整理完毕,从学习路线到面试真题,从工具教程到行业报告,一站式覆盖你的所有需求,现在全部免费分享

👇👇扫码免费领取全部内容👇👇

一、学习必备:100+本大模型电子书+26 份行业报告 + 600+ 套技术PPT,帮你看透 AI 趋势

想了解大模型的行业动态、商业落地案例?大模型电子书?这份资料帮你站在 “行业高度” 学 AI

1. 100+本大模型方向电子书

在这里插入图片描述

2. 26 份行业研究报告:覆盖多领域实践与趋势

报告包含阿里、DeepSeek 等权威机构发布的核心内容,涵盖:

  • 职业趋势:《AI + 职业趋势报告》《中国 AI 人才粮仓模型解析》;
  • 商业落地:《生成式 AI 商业落地白皮书》《AI Agent 应用落地技术白皮书》;
  • 领域细分:《AGI 在金融领域的应用报告》《AI GC 实践案例集》;
  • 行业监测:《2024 年中国大模型季度监测报告》《2025 年中国技术市场发展趋势》。

3. 600+套技术大会 PPT:听行业大咖讲实战

PPT 整理自 2024-2025 年热门技术大会,包含百度、腾讯、字节等企业的一线实践:

在这里插入图片描述

  • 安全方向:《端侧大模型的安全建设》《大模型驱动安全升级(腾讯代码安全实践)》;
  • 产品与创新:《大模型产品如何创新与创收》《AI 时代的新范式:构建 AI 产品》;
  • 多模态与 Agent:《Step-Video 开源模型(视频生成进展)》《Agentic RAG 的现在与未来》;
  • 工程落地:《从原型到生产:AgentOps 加速字节 AI 应用落地》《智能代码助手 CodeFuse 的架构设计》。

二、求职必看:大厂 AI 岗面试 “弹药库”,300 + 真题 + 107 道面经直接抱走

想冲字节、腾讯、阿里、蔚来等大厂 AI 岗?这份面试资料帮你提前 “押题”,拒绝临场慌!

1. 107 道大厂面经:覆盖 Prompt、RAG、大模型应用工程师等热门岗位

面经整理自 2021-2025 年真实面试场景,包含 TPlink、字节、腾讯、蔚来、虾皮、中兴、科大讯飞、京东等企业的高频考题,每道题都附带思路解析

2. 102 道 AI 大模型真题:直击大模型核心考点

针对大模型专属考题,从概念到实践全面覆盖,帮你理清底层逻辑:

3. 97 道 LLMs 真题:聚焦大型语言模型高频问题

专门拆解 LLMs 的核心痛点与解决方案,比如让很多人头疼的 “复读机问题”:


三、路线必明: AI 大模型学习路线图,1 张图理清核心内容

刚接触 AI 大模型,不知道该从哪学起?这份「AI大模型 学习路线图」直接帮你划重点,不用再盲目摸索!

在这里插入图片描述

路线图涵盖 5 大核心板块,从基础到进阶层层递进:一步步带你从入门到进阶,从理论到实战。

img

L1阶段:启航篇丨极速破界AI新时代

L1阶段:了解大模型的基础知识,以及大模型在各个行业的应用和分析,学习理解大模型的核心原理、关键技术以及大模型应用场景。

img

L2阶段:攻坚篇丨RAG开发实战工坊

L2阶段:AI大模型RAG应用开发工程,主要学习RAG检索增强生成:包括Naive RAG、Advanced-RAG以及RAG性能评估,还有GraphRAG在内的多个RAG热门项目的分析。

img

L3阶段:跃迁篇丨Agent智能体架构设计

L3阶段:大模型Agent应用架构进阶实现,主要学习LangChain、 LIamaIndex框架,也会学习到AutoGPT、 MetaGPT等多Agent系统,打造Agent智能体。

img

L4阶段:精进篇丨模型微调与私有化部署

L4阶段:大模型的微调和私有化部署,更加深入的探讨Transformer架构,学习大模型的微调技术,利用DeepSpeed、Lamam Factory等工具快速进行模型微调,并通过Ollama、vLLM等推理部署框架,实现模型的快速部署。

img

L5阶段:专题集丨特训篇 【录播课】

img
四、资料领取:全套内容免费抱走,学 AI 不用再找第二份

不管你是 0 基础想入门 AI 大模型,还是有基础想冲刺大厂、了解行业趋势,这份资料都能满足你!
现在只需按照提示操作,就能免费领取:

👇👇扫码免费领取全部内容👇👇

2025 年想抓住 AI 大模型的风口?别犹豫,这份免费资料就是你的 “起跑线”!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐