前言
基于第一章:计入入门篇的学习,相信大家已经对HuggingFace训练模型基本流程已经了解了,下面我们基于几个场景来实战演练下~
基于Transformers的NLP解决方案
在 Hugging Face 生态中,构建一个工业级的 NLP 解决方案绝非仅仅是“加载模型然后预测”这么简单。它是一套极其严密的工程流水线。下面详细列出标准的落地步骤,并补充在实际工程开发中各个环节的最佳实践与相关知识。
任务定义与预训练模型选择 (Task Definition & Model Selection)
一切的起点在于将真实的业务需求映射为标准的 NLP 任务。
- 任务映射:判断你的需求是文本分类(如情感分析)、序列标注(如实体识别)、抽取式问答,还是文本生成。
- 模型架构选择:根据任务选择底层架构。例如,分类任务通常选择 Encoder-only 架构(如 BERT);生成类任务选择 Decoder-only 架构(如 Llama, Qwen);翻译或摘要任务选择 Encoder-Decoder 架构(如 T5, BART) 。
- 加载模型头:利用
Transformers库中的AutoModelForXXX类,系统会自动在预训练模型顶部拼接适合该任务的神经网络层(Model Head),例如使用AutoModelForSequenceClassification来处理分类任务 。
数据准备与清洗 (Data Preparation)
真实世界的数据往往是杂乱无章的,这一步极其耗时且关键。
- 数据加载:使用
Datasets库读取本地文件(CSV, JSON)或连接数据库。对于 TB 级别的数据集,Datasets支持流式加载(Streaming)和内存映射(Memory-mapping),确保不会撑爆系统内存 。 - 数据清洗与过滤:使用
Datasets提供的map和filter功能去除脏数据、统一文本格式。 - 数据集划分:利用
train_test_split将数据科学地划分为训练集、验证集和测试集 。
分词与数据预处理 (Tokenization & Pre-processing)
模型无法直接理解字符串,必须将其转化为张量。
- 文本数字化:使用
Tokenizer(分词器)将清洗好的文本切分为 Token,并映射为模型词表中的 Input IDs 。 - 特殊字符注入:自动为序列添加模型必需的边界符(如
[CLS],[SEP]) 。 - 对齐与截断:为了让不同长度的句子能组成规整的矩阵以供 GPU 并行计算,必须进行截断(Truncation)和填充(Padding) 。
- 动态填充优化:在组装 Batch 时,推荐使用
DataCollator进行动态填充,按当前 Batch 的最长句子对齐,极大节省无效的计算和显存 。
模型微调与训练 (Fine-Tuning & Training)
让通用的预训练模型学习你的垂直领域知识。
- 配置训练参数:通过
TrainingArguments集中控制学习率(如使用 Cosine 调度器和 Warmup)、训练轮数(Epochs)、批次大小(Batch Size)等核心超参数 。 - 显存与性能优化:在训练大模型时,通常需要开启混合精度训练(如
bf16=True)以提升速度并降低显存占用 。如果显存依然告急,需配合使用梯度累积(Gradient Accumulation)、梯度检查点(Gradient Checkpointing) 。 - 高效微调(PEFT):如果算力极其有限,可以使用 PEFT 库引入 LoRA 或 QLoRA 技术,只训练极少量的旁路矩阵,用极小的参数量撬动大模型 。
- 执行训练:将模型、数据集、分词器和参数配置传入
Trainer,由其自动完成前向传播、反向传播和梯度更新的全套动作 。对于多机多卡,底层会自动结合Accelerate或DeepSpeed进行分布式调度 。
模型评估与验证 (Evaluation)
微调结束后,需要客观量化模型的表现,防止过拟合。
- 计算指标:引入
Evaluate组件库,针对具体任务加载对应的评估函数。例如分类任务看 Accuracy 或 F1-Score,翻译任务看 BLEU,摘要生成看 ROUGE 。 - 回调集成:通过编写
compute_metrics回调函数并传入Trainer,可以在每个验证周期自动输出评估报告 。
部署与推理应用 (Deployment & Inference)
将训练好的模型转化为实际的生产力工具。
- 快速验证:使用
Pipeline(流水线)进行开箱即用的推理测试,它将预处理、模型前向传播和后处理封装在了一起 。 - 界面展示:使用
Gradio库,通过几行代码为你的模型搭建一个可视化的 Web 交互界面,方便向团队演示 。 - 工业级加速:在正式上线前,通常会使用
Optimum库将模型导出为 ONNX 或 TensorRT 格式,或者使用 vLLM 等框架进行底层推理加速,以满足高并发的延迟要求 。
补充知识:数据飞轮与 LLMOps
在真实的工业界,NLP 解决方案并非走完上述 6 步就结束了,而是会进入一个被称为 LLMOps (大模型运维) 的闭环。当你的模型部署后,你需要持续收集用户在真实场景下的输入(尤其是不准确的边缘案例),将这些新数据重新打标签,送入 Step 2 继续清洗,然后进行增量微调。这种“数据飞轮”才是让业务模型越来越聪明的核心壁垒。
实战演练之命名实体识别
命名实体识别任务介绍
命名实体识别的核心目标是:从非结构化的纯文本中,抽取出具有特定意义的实体,并将其归类到预定义的类别中(如人名、地名、机构名、专有名词、时间等)。
在机器学习中,NER 被定义为一个序列标注任务(Sequence Labeling)。这意味着模型不仅要理解整个句子的含义,还需要对句子中的每一个词(Token)输出一个分类标签。
NER 的核心标注体系 (Tagging Schemes)
BIO 体系 (最通用标准)
为了能够准确提取由多个词组成的完整实体,业界最通用的标注格式是 BIO(Begin, Inside, Outside):
- B (Begin):实体的开头字/词。
- I (Inside):实体内部的字/词。
- O (Outside):非实体的字/词。
例如,针对句子 “库克在苹果公司”,我们期望的标注序列是:
- 库 (B-PER: 人名开始)
- 克 (I-PER: 人名内部)
- 在 (O: 外部)
- 苹 (B-ORG: 机构开始)
- 果 (I-ORG: 机构内部)
- 公 (I-ORG: 机构内部)
- 司 (I-ORG: 机构内部)
BIOES 体系 (更精细的边界控制)
- 这是 BIO 的升级版,额外引入了两个标签,能够让模型更清晰地感知实体的结束和单字实体。
- E (End):实体的最后一个字/词。
- S (Single):由单个字/词构成的独立实体。
- 示例:在识别“苹果”作为机构名时,“苹”标注为 B-ORG,“果”标注为 E-ORG。
IO 体系 (最粗粒度)
只有 I(内部)和 O(外部)。它无法区分两个紧挨着的同类型实体,目前在严谨的工业场景中已较少使用。
NER 的高阶任务变体 (Task Variations)
真实的业务场景往往比标准的实体抽取复杂得多。根据实体的空间分布特征,NER 被细分为以下三大难点方向:
- 扁平 NER (Flat NER)
- 定义:实体之间没有任何重叠或嵌套关系。这是最标准、最基础的 NER 任务。
- 示例:“库克在苹果公司”,[库克]是人名,[苹果公司]是机构名。互不干扰。
- 嵌套 NER (Nested NER)
- 定义:一个实体内部包含了另一个完整实体。标准的 BIO 序列标注无法直接解决这个问题,因为一个 Token 不能同时拥有两个独立标签。
- 示例:“北京大学”是一个机构名 (ORG),但“北京”本身也是一个地名 (LOC)。
- 解决方案:通常采用多任务学习(Multi-task Learning)、指针网络(Pointer Networks)或跨度分类(Span-based Classification)模型来解决。
- 不连续 NER (Discontinuous NER)
- 定义:一个完整的实体在文本中被其他词语物理打断。这在医疗文本中极为常见。
- 示例:“患者出现左侧和右侧肺部感染”,“左侧肺部感染”和“右侧肺部感染”是两个完整的医学实体,但文本是断开的。
NER 架构的演进史 (Architecture Evolution)
在深度学习的发展过程中,NER 的主流解决方案经历了三次重大迭代:
- 早期深度学习:BiLSTM-CRF 架构
- 在 Transformer 爆发前,这是统治 NER 领域的王者。
- 双向长短期记忆网络(BiLSTM)负责提取文本的上下文特征。条件随机场(CRF)负责学习标签之间的转移约束(例如:I-PER 前面绝对不能是 B-ORG)。
- Transformer 时代:Encoder-only 架构 (Hugging Face 主流)
- 由于 NER 强依赖上下文来判断词性,当前主流选择是拥有双向注意力的 Encoder-only 架构(如 BERT, RoBERTa)。
- 在 Hugging Face 中,利用
AutoModelForTokenClassification类,系统会在 BERT 底层特征输出之上,拼接一个线性分类层作为 Model Head。 - 模型对每一个 Token 独立计算交叉熵损失,公式为 $Loss = -\frac{1}{N}\sum_{i=1}^{N} \log P(y_i | x_i)$。
- 大语言模型时代:生成式 NER (Generative NER)
- 利用 ChatGPT、Llama 等 Decoder-only 架构,将 NER 转化为一个问答任务 (QA)或结构化生成任务。
- 做法:通过 Prompt 提问:“请找出这句话中的所有人物和地点,并以 JSON 格式输出。”这种方式零样本(Zero-shot)泛化能力极强,但推理成本远高于 BERT,且在极度垂直的领域(如特殊工业代码抽取)依然容易产生幻觉。
基于 Hugging Face 落地 NER 的三大工程陷阱
在实际使用 Transformers 库开发时,必须避开以下三个深坑:
- 子词对齐 (Sub-word Alignment) 与 -100 掩码
- 当 Tokenizer 将一个完整词切分为多个子词时,真实标签必须对齐。只保留第一个子词的真实标签,将同一词后续子词的标签设为 -100。因为在 PyTorch 的 CrossEntropyLoss 中,标签为 -100 的位置会被自动忽略,不参与梯度更新。
- 二维动态填充 (Dynamic Padding)
- 普通的分类任务只需要填充文本(Input IDs)。但在 NER 中,必须使用专用的
DataCollatorForTokenClassification。它不仅会动态填充输入文本,还会同步对齐填充你的标签矩阵,确保两者的张量维度完全一致。
- 普通的分类任务只需要填充文本(Input IDs)。但在 NER 中,必须使用专用的
- 欺骗性的评估指标 (Evaluation Metrics)
- 普通准确率(Accuracy)在 NER 中毫无意义,因为文本中 90% 的词都是非实体(O 标签),全猜 O 也能获得高分。
- 必须使用实体级别的评估库(如
seqeval)。只有当实体的左边界、右边界以及实体类型完全匹配时,才算作一次正确的预测(True Positive)。
基于Transformers的解决方案
在 Hugging Face 生态中,解决 NER 任务的标准流水线如下:
- 架构选择:由于 NER 需要强依赖上下文(左右两边的词)来判断当前词的词性,因此通常选择双向注意力的 Encoder-only 架构,如 BERT、RoBERTa。
- 加载 Model Head:使用
AutoModelForTokenClassification类。它会在 BERT 的底层特征输出之上,拼接一个线性分类层。如果你的任务有 7 种标签,这个线性层的输出维度就是 7。 - 损失函数:模型会对每一个 Token 独立计算交叉熵损失(Cross Entropy Loss)。具体的数学表达为 $Loss = -\frac{1}{N}\sum_{i=1}^{N} \log P(y_i | x_i)$,其中 $N$ 是序列长度, $y_i$ 是真实的 BIO 标签。
- 特殊的数据收集器:在组装 Batch 时,必须使用
DataCollatorForTokenClassification。它不仅会动态填充(Padding)输入文本,还会同步填充你的标签(Labels),确保文本矩阵和标签矩阵的维度完全一致。
补充关键知识点:子词对齐与 -100 索引
这是 NER 任务中最容易报错的地方。当使用 Tokenizer 时,一个完整的词(例如 “Transformers”)可能会被切分成多个子词(如 “Trans”, “##former”, “##s”)。但是,你的原始数据中只给 “Transformers” 打了一个标签。
- 解决方案:我们需要在数据预处理阶段,将真实标签对齐到拆分后的子词上。通常的做法是,只保留第一个子词的真实标签,将后续子词的标签设为 -100。在 PyTorch 中,标签为 -100 的位置会被损失函数(CrossEntropyLoss)自动忽略,不参与梯度计算。
代码实战演练
下面是一套完整的使用 Hugging Face 训练 NER 模型的代码。为了方便运行,我们使用 datasets 库加载示例数据,并使用 seqeval 库进行评估。
import numpy as np
from datasets import load_dataset
from transformers import (
AutoTokenizer,
AutoModelForTokenClassification,
TrainingArguments,
Trainer,
DataCollatorForTokenClassification
)
import evaluate
# 1. 准备数据集与标签映射
# 这里我们假设有一个经过预处理的中文/英文 NER 数据集
# 实际工作中,你通常使用 load_dataset("json", data_files="your_data.json")
dataset = load_dataset("conll2003") # 使用经典开源数据集作为演示
label_list = dataset["train"].features["ner_tags"].feature.names
# 定义模型路径
model_checkpoint = "bert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
# 2. 数据预处理函数(处理子词对齐的核心逻辑)
def tokenize_and_align_labels(examples):
# is_split_into_words=True 表示输入已经是分好词的列表
tokenized_inputs = tokenizer(examples["tokens"], truncation=True, is_split_into_words=True)
labels = []
for i, label in enumerate(examples["ner_tags"]):
word_ids = tokenized_inputs.word_ids(batch_index=i)
previous_word_idx = None
label_ids = []
for word_idx in word_ids:
# 对于特殊字符(如 [CLS], [SEP]),word_idx 为 None,标签设为 -100
if word_idx is None:
label_ids.append(-100)
# 对于一个词的第一个子词,使用真实的标签
elif word_idx != previous_word_idx:
label_ids.append(label[word_idx])
# 对于同一个词的后续子词,标签设为 -100 忽略计算
else:
label_ids.append(-100)
previous_word_idx = word_idx
labels.append(label_ids)
tokenized_inputs["labels"] = labels
return tokenized_inputs
# 映射处理数据集
tokenized_datasets = dataset.map(tokenize_and_align_labels, batched=True)
# 3. 加载评估指标 (seqeval)
# seqeval 专门用于序列标注评估,它不仅评估单个 Token,更注重完整实体的识别准确率
metric = evaluate.load("seqeval")
def compute_metrics(p):
predictions, labels = p
predictions = np.argmax(predictions, axis=2)
# 移除 -100 对应的预测和标签,将其转换回真实的字符串标签
true_predictions = [
[label_list[p] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
true_labels = [
[label_list[l] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
results = metric.compute(predictions=true_predictions, references=true_labels)
return {
"precision": results["overall_precision"],
"recall": results["overall_recall"],
"f1": results["overall_f1"],
"accuracy": results["overall_accuracy"],
}
# 4. 加载模型头与数据收集器
id2label = {i: label for i, label in enumerate(label_list)}
label2id = {v: k for k, v in id2label.items()}
model = AutoModelForTokenClassification.from_pretrained(
model_checkpoint,
num_labels=len(label_list),
id2label=id2label,
label2id=label2id
)
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)
# 5. 配置训练参数并执行
args = TrainingArguments(
output_dir="./ner_model_output",
evaluation_strategy="epoch",
learning_rate=2e-5,
per_device_train_batch_size=16,
per_device_eval_batch_size=16,
num_train_epochs=3,
weight_decay=0.01,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
compute_metrics=compute_metrics
)
# 启动训练
trainer.train()
补充知识:NER 评估不能只看 Accuracy
在上述代码的 compute_metrics 中,我们引入了 seqeval 库。这是由于在 NER 任务中,普通准确率(Accuracy)是极其具有欺骗性的。 在一个句子中,通常 90% 以上的字都是非实体(标签为 O)。如果模型全部预测为 O,Accuracy 依然会很高。seqeval 的核心价值在于它是实体级别(Entity-level)的评估。只有当实体边界(B 和 I)以及实体类型(PER, ORG 等)完全匹配真实标签时,才算真正预测正确,这能最真实地反映你的模型是否具备工业可用性。
实战演练之机器阅读理解
机器阅读理解任务介绍
机器阅读理解的核心目标是:让计算机阅读一篇给定的文章(Context),然后根据文章内容回答人类提出的问题(Question)。
根据答案的生成方式,学术界和工业界将 MRC 分为三大主流变体:
- 抽取式问答 (Extractive QA)
- 定义:答案必须是原文中连续的一段子串(Span)。模型不需要自己“造词”,只需要在原文中“框选”出答案的起始位置和结束位置。
- 典型数据集:SQuAD (英文)、CMRC2018 (中文)。
- 应用场景:企业内部规章制度查询、法律文档关键条款抽取。这是目前工业界落地最成熟、可控性最高的方式,也是我们本次实战演练的重点。
- 生成式问答 (Generative QA)
- 定义:模型阅读文章后,用自己的语言总结并生成答案。答案不一定原封不动地出现在原文中。
- 典型数据集:CoQA, MS MARCO。
- 应用场景:基于大模型(LLM)的检索增强生成(RAG)系统。
- 多项选择式问答 (Multiple Choice QA)
- 定义:给定文章、问题和几个候选答案,模型需要计算得分并选出正确的一项。
机器阅读理解任务样例
抽取式问答的核心逻辑是:模型不生成新词,只在原文中“框选”出答案。
- 上下文 (Context):Hugging Face 是一家总部位于纽约的初创公司,主要开发自然语言处理相关的开源工具和预训练模型。
- 问题 (Question):Hugging Face 的总部在哪里?
- 期望答案 (Answer):
- 文本:纽约
- 起始字符索引 (Start Char):15
- 结束字符索引 (End Char):17
机器阅读理解数据集格式
业界最著名、应用最广泛的 MRC 数据集格式是 SQuAD (Stanford Question Answering Dataset) 格式。真实的工业界数据标注通常都会对齐这种嵌套的 JSON 结构。
{
"data": [
{
"title": "Hugging_Face_Introduction",
"paragraphs": [
{
"context": "Hugging Face 是一家总部位于纽约的初创公司...",
"qas": [
{
"id": "q_001",
"question": "Hugging Face 的总部在哪里?",
"answers": [
{
"answer_start": 15,
"text": "纽约"
}
]
}
]
}
]
}
]
}
关键点:answers 字段是一个列表,因为在实际标注中,同一个问题可能有多个人类标注的不同答案(例如“纽约”和“纽约市”),模型只要匹配其中一个即可。
机器阅读理解评估指标与原理
MRC 不能简单使用 Accuracy,因为输出的是连续文本段。业界标准是使用 Exact Match (EM) 和 F1-Score,通常取词级别(Word-level,中文为字级别)进行计算。
评估原理详解
在计算指标前,评估脚本会做极其严苛的文本归一化(Normalization):去除所有的标点符号、冠词(a, an, the),并统一转为小写。
- Exact Match (完全匹配率):预测的答案与真实答案经过归一化后,字符串必须一模一样,得分为 1,否则为 0。
- F1-Score (字/词级 F1):将预测结果和真实答案拆分成词(或中文字)集合,计算交集。
简单示例
- 真实答案 (Reference):
["纽", "约", "市"] - 模型预测 (Prediction):
["纽", "约"]
计算过程:
交集 (Common Tokens):
["纽", "约"],数量为 2。Precision (精确率):模型给出的词里,有多少是对的?$P = 2 / 2 = 1.0$。
Recall (召回率):真实答案里的词,模型找出了多少?$R = 2 / 3 = 0.667$。
F1 分数:调和平均数。
$$F1 = 2 \times \frac{P \times R}{P + R} = 2 \times \frac{1.0 \times 0.667}{1.0 + 0.667} = 0.8$$
在这个例子中,EM 分数为 0,但 F1 分数为 0.8。在工业界,F1 是衡量模型可用性的绝对核心指标。
数据预处理核心步骤
在将数据喂给 Transformer 之前,必须进行特殊的预处理:
- 加载 Fast Tokenizer:必须开启
use_fast=True。只有 Rust 编写的 Fast Tokenizer 才支持offset_mapping(记录每个 Token 对应的原始字符位置),这是抽取式问答还原答案位置的关键。 - 拼接策略:将问题和上下文拼接在一起,形式为
[CLS] Question [SEP] Context [SEP]。 - 单侧截断 (truncation=”only_second”):如果总长度超标,绝对不能截断问题,只能截断后半部分(Context)。
基于Transformer的解决方案
在 Hugging Face 生态中,解决抽取式问答的标准流水线如下:
- 模型架构选择
- 由于任务需要强依赖上下文来精准定位答案边界,通常选择具备双向注意力机制的 Encoder-only 架构(如 BERT, RoBERTa, MacBERT)。
- 加载专属 Model Head
- 使用
AutoModelForQuestionAnswering类。它会在 BERT 的底层特征输出之上,拼接一个极其简单的线性分类层。 - 这个线性层的输出维度是 2。对于输入序列中的每一个 Token,它都会输出两个分数:一个是该 Token 作为答案起始位置 (Start Position) 的概率,另一个是作为答案结束位置 (End Position) 的概率。
- 使用
- 损失函数计算
- 模型在训练时,会分别对 Start 和 End 两个位置计算交叉熵损失。
- 总损失公式为:$Loss = \frac{1}{2}(Loss_{start} + Loss_{end})$。
核心痛点:长文本处理与滑动窗口
在实际工程中,抽取式问答面临的最大挑战是长度限制。BERT 等模型通常最多只能处理 512 个 Token,但真实的业务文档(如财报、病历)往往长达数千字。
解决方案:滑动窗口机制 (Sliding Window) 为了不丢失答案,我们需要将长文档切分成多个相互重叠的短片段(Chunks)。
- 步长 (Stride):决定了相邻两个片段之间重叠的 Token 数量。重叠是为了防止真实的答案恰好被一刀切断在两个片段的交界处。
- 偏移映射 (Offset Mapping):由于我们将长文本切碎了,模型预测出的答案位置是相对于“短片段”的索引。我们需要通过
return_offsets_mapping=True参数,让 Tokenizer 记住每一个 Token 在原始长文本中的真实字符位置,以便最后还原出真实的字符串答案。
基于滑动窗口与 Overflow 的数据处理
痛点:BERT 类模型的最大输入长度通常是 512 Tokens。但真实的病历、财报文档长达数千字。如果直接截断,答案可能被扔掉。
解决方案:带有 overflow 的滑动窗口(Sliding Window)。我们将一篇长文档切分成多个相互重叠的短片段(Chunks)。
处理逻辑细节
在 Hugging Face 的 Tokenizer 中,我们通过以下参数开启该功能:
max_length = 384:每个切片的最大长度。stride = 128:相邻两个切片重叠 128 个 Token(防止答案正好被切断在交界处)。return_overflowing_tokens = True:指示 Tokenizer 将超长的文档切片返回,而不是丢弃。return_offsets_mapping = True:返回 Token 到原始字符的映射。
极其关键的映射矩阵: 开启 overflow 后,原本的 1 篇文档可能会变成 5 个输入特征(Features)。为了知道这 5 个特征都属于哪篇原始文档,Tokenizer 会生成一个 overflow_to_sample_mapping 数组。比如 [0, 0, 0, 1, 1] 意味着前 3 个切片来自第 0 篇文档,后 2 个切片来自第 1 篇文档。
训练时的标签对齐 (Label Alignment)
在切片后,真实答案并不一定存在于每一个切片中。 对于某一个切片,我们需要通过 offset_mapping 判断真实答案的起始字符和结束字符是否完全落在这个切片的边界内:
- 如果在切片内:计算出答案在这个切片里的 Start Token Index 和 End Token Index。
- 如果不在切片内:将该切片的 Start 和 End Index 统一指向
0(也就是[CLS]的位置)。这告诉模型:“在这个切片里找不到答案”。
结果预测逻辑 (Inference / Post-processing)
在预测阶段,模型会输出每一个 Token 作为答案开头和结尾的概率(Logits)。由于一篇文档被切成了多个片段,我们需要把它们拼凑起来找出最终的全局最优解。
预测步骤:
- 获取所有 Logits:模型对所有切片进行前向传播,得到所有切片的
start_logits和end_logits。 - 按文档归类切片:通过映射关系,把属于同一篇原始文档的所有切片聚集在一起。
- 遍历所有候选跨度 (Spans):
- 在每一个切片中,挑出 Top-K 个
start_logits高的位置和 Top-K 个end_logits高的位置,两两组合。 - 排除非法组合:起始位置大于结束位置(Start > End),或者答案长度超过了预设的最大长度(如
max_answer_length = 30),或者答案落在了问题区域而不是上下文区域。
- 在每一个切片中,挑出 Top-K 个
- 计算得分:对于每一个合法的组合,计算其总分:$Score = logits_{start} + logits_{end}$。
- 全局选优:比较该文档所有切片产生的所有合法候选答案,找出 Score 最大的那一个。
- 映射回文本:利用得分最高的那个候选答案的 Start Token 和 End Token 索引,查阅该切片的
offset_mapping,拿到原始字符的起止索引,最后对原始字符串进行切片context[char_start : char_end],输出最终的人类可读文本。
代码实战演练
下面是一套完整的抽取式问答预处理与训练代码。为了展示核心逻辑,我们使用经典的 SQuAD 数据集。这段代码中最具价值的部分在于数据预处理函数,它是所有 QA 任务的基础模板。
import collections
from datasets import load_dataset
from transformers import (
AutoTokenizer,
AutoModelForQuestionAnswering,
TrainingArguments,
Trainer,
DefaultDataCollator
)
# 1. 准备数据集
dataset = load_dataset("squad")
model_checkpoint = "bert-base-uncased"
# 2. 加载分词器
# 必须使用 Fast Tokenizer 以支持 offset_mapping 功能
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint, use_fast=True)
# 3. 核心扩展:QA 任务的复杂数据预处理
max_length = 384 # 模型的最大输入长度
doc_stride = 128 # 滑动窗口的重叠步长
def prepare_train_features(examples):
# 去除问题左右的空格
examples["question"] = [q.lstrip() for q in examples["question"]]
# 使用分词器处理问题和上下文
# truncation="only_second" 表示如果超出长度,只截断上下文(文章),绝不截断问题
tokenized_examples = tokenizer(
examples["question"],
examples["context"],
truncation="only_second",
max_length=max_length,
stride=doc_stride,
return_overflowing_tokens=True, # 开启滑动窗口,返回溢出的切片
return_offsets_mapping=True, # 返回 Token 与原始字符的映射关系
padding="max_length",
)
# 提取溢出切片与原始样本的对应关系
sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping")
offset_mapping = tokenized_examples.pop("offset_mapping")
tokenized_examples["start_positions"] = []
tokenized_examples["end_positions"] = []
# 遍历每一个切片,寻找答案的起始和结束 Token 索引
for i, offsets in enumerate(offset_mapping):
# input_ids 里面包含了特殊字符 [CLS] question [SEP] context [SEP]
input_ids = tokenized_examples["input_ids"][i]
cls_index = input_ids.index(tokenizer.cls_token_id)
# 确定当前切片对应的是哪个原始样本
sample_index = sample_mapping[i]
answers = examples["answers"][sample_index]
# 如果该样本没有答案(针对 SQuAD 2.0),则将起始和结束位置指向 [CLS]
if len(answers["answer_start"]) == 0:
tokenized_examples["start_positions"].append(cls_index)
tokenized_examples["end_positions"].append(cls_index)
else:
# 获取答案在原文中的真实字符级起始和结束位置
start_char = answers["answer_start"][0]
end_char = start_char + len(answers["text"][0])
# 找到上下文在当前切片中的起始和结束 Token 索引
sequence_ids = tokenized_examples.sequence_ids(i)
context_start = sequence_ids.index(1)
context_end = len(sequence_ids) - 1 - sequence_ids[::-1].index(1)
# 判断答案是否完全落在这个切片内,如果不是,则丢弃(指向 [CLS])
if not (offsets[context_start][0] <= start_char and offsets[context_end][1] >= end_char):
tokenized_examples["start_positions"].append(cls_index)
tokenized_examples["end_positions"].append(cls_index)
else:
# 二分查找答案的起止 Token 索引
idx = context_start
while idx <= context_end and offsets[idx][0] <= start_char:
idx += 1
tokenized_examples["start_positions"].append(idx - 1)
idx = context_end
while idx >= context_start and offsets[idx][1] >= end_char:
idx -= 1
tokenized_examples["end_positions"].append(idx + 1)
return tokenized_examples
# 映射处理训练集
tokenized_datasets = dataset.map(
prepare_train_features,
batched=True,
remove_columns=dataset["train"].column_names
)
# 4. 加载模型头与数据收集器
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
data_collator = DefaultDataCollator()
# 5. 配置训练参数并执行
args = TrainingArguments(
output_dir="./qa_model_output",
evaluation_strategy="no",
learning_rate=2e-5,
per_device_train_batch_size=16,
num_train_epochs=3,
weight_decay=0.01,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized_datasets["train"],
data_collator=data_collator,
tokenizer=tokenizer,
)
# 启动训练
trainer.train()
实战演练之多项选择
多项选择任务介绍
什么是多项选择任务
多项选择任务(Multiple Choice)是机器阅读理解和常识推理的经典变体。
它的核心目标是:给定一段背景上下文(Context)和一个问题(Question),模型需要从 $N$ 个预定义的候选选项(Options)中,选出一个唯一正确的答案。
它与我们之前学习的抽取式问答(Extractive QA)有本质区别:抽取式问答是在原文中“框选”出连续的字词,而多项选择的答案通常已经由人类提前写好,模型只需要做“多选一”的决策。
多项任务示例
- 上下文 (Context):一名男子在厨房里拿着一个平底锅。
- 问题 (Question):他接下来打算做什么?
- 选项 (Options):
- 0: 坐下来看电视。
- 1: 把鸡蛋打入平底锅中。
- 2: 走出房间去洗澡。
- 3: 开始弹奏吉他。
- 正确标签 (Label):1
多项任务数据示例
阅读理解中的多项选择题任务(如常识推理数据集 SWAG、RACE),在工业界通常会被规范化为如下 JSON 结构:
{
"id": "sample_001",
"context": "一名男子在厨房里拿着一个平底锅。",
"question": "他接下来打算做什么?",
"options": [
"坐下来看电视。",
"把鸡蛋打入平底锅中。",
"走出房间去洗澡。",
"开始弹奏吉他。"
],
"label": 1
}
多项选择任务数据预处理
多项选择任务的工程难点全在数据预处理上,也就是所谓的三维张量构建。
如果你的 Batch Size 是 8,每个问题有 4 个选项。
在普通的文本分类中,Tokenizer 输出的 input_ids 维度是二维的:$(8, \text{seq_len})$。
但在多项选择中,你必须将数据预处理成三维列表结构:$(8, 4, \text{seq_len})$。
预处理代码逻辑框架:
def preprocess_function(examples):
# examples 包含了一批数据,假设有 4 个选项
first_sentences = [[context] * 4 for context in examples["context"]]
second_sentences = [
[f"{q} {opt}" for opt in options]
for q, options in zip(examples["question"], examples["options"])
]
# 将二维列表展平为一维,送给 Tokenizer
first_sentences = sum(first_sentences, [])
second_sentences = sum(second_sentences, [])
# 执行分词
tokenized_examples = tokenizer(
first_sentences,
second_sentences,
truncation=True,
max_length=256
)
# 最核心的一步:把展平的一维张量,重新“折叠”回 (batch_size, num_choices, seq_len) 的三维结构
# 这里需要计算 batch 中的样本数量,然后按 num_choices = 4 进行逆向还原
# (代码省略具体还原的 for 循环细节,旨在展示思路)
return tokenized_examples
基于Transformer的基本原理与解决方案
在解决多项选择任务时,我们不能像文本分类那样直接把整段话塞给模型。我们需要让模型独立评估“每一个选项”。
解决方案与 Model Head
在 Hugging Face 中,我们使用 AutoModelForMultipleChoice 类来解决这个问题。多项选择头。假设一道题有 4 个选项,你需要将“问题”和“选项”拼接成 4 个句子输入模型。
具体流程如下:
- 展开组合:将 1 个问题和 4 个选项,拆解组合成 4 对独立的文本序列:
(Context + Question, Option 0)到(Context + Question, Option 3)。 - 提取特征:模型会对这 4 个句子分别进行前向传播,提取出它们各自的全局特征表示(通常是
[CLS]Token 的向量)。 - 打分与分类:它的 Head 是一个输出维度为 1 的线性层,负责为每一个拼接后的句子打分,最后通过 Softmax 选出得分最高的选项。
损失函数原理
这个输出维度为 1 的线性层会输出 4 个没有经过处理的分数(Logits),记为 $S = [s_0, s_1, s_2, s_3]$。模型会运用标准的交叉熵损失函数(Cross Entropy Loss)进行计算。具体的数学表达为:
$$Loss = -\log \left( \frac{\exp(s_{label})}{\sum_{j=0}^{C-1} \exp(s_j)} \right)$$
其中 $C$ 是选项总数(此处为 4),$s_{label}$ 是正确选项对应的得分。
基于但不仅限于上述内容,在真实的工程实践中,你还需要掌握以下两个关键知识。
必须手写专用的 DataCollator
DataCollator 负责在训练时把数据组装成 Batch 并进行动态填充(Dynamic Padding)。默认的分类数据收集器只能处理二维列表 $(B, L)$。
因为多项选择的数据被我们处理成了三维 $(B, C, L)$(即 Batch, Choice, Length),你必须编写一个专门的 DataCollatorForMultipleChoice。
它的底层逻辑是:
先把输入的三维数据“拍扁”成二维:$(B \times C, L)$。
调用 Tokenizer 的默认
pad方法,根据这 $B \times C$ 个句子中最长的一句进行动态填充。填充完成后,再将矩阵重新“重塑(Reshape)”回三维结构 $(B, C, L_{max})$ 送入模型。
模型内部的 forward 函数在拿到这个三维张量后,会再次将其拍扁为二维进行并行计算,算完 Logits 后再变回二维的 $(B, C)$ 输出。
大语言模型 (LLM) 时代的多项选择
我们上面讲的 AutoModelForMultipleChoice 是基于 BERT 类 Encoder 架构的传统做法。在今天基于 GPT、Llama 等 Decoder-only 架构的大语言模型时代,多项选择任务的解法发生了根本性变化。
生成式多项选择:我们不再修改模型结构添加专属的 Classification Head。相反,我们直接将题目拼接成一段文本(Prompt)输入给模型:
"背景: 一名男子拿着平底锅。问题: 他打算做什么?\n A. 看电视\n B. 打鸡蛋\n C. 洗澡\n D. 弹吉他\n 请只输出正确选项的字母:"计算概率法 (Likelihood):更严谨的做法是,让模型在最后一步预测下一个 Token 时,强制提取出词汇表中 “A”、”B”、”C”、”D” 这四个 Token 的输出概率(Logits),选出概率最高的那一个字母作为答案。
代码实战演练
在深度学习的自然语言处理领域,多项选择任务的工程链路极具代表性。它最大的挑战不在于模型结构,而在于数据维度的重塑。普通的文本分类任务输入给模型的是二维张量 $(B, L)$,即 Batch Size 和 Sequence Length;而多项选择任务必须将数据处理成三维张量 $(B, C, L)$,其中 $C$ 代表选项数量(Choices)。
为了让代码结构清晰,我将其分为五个标准步骤。请特别关注步骤二和步骤三,这是多项选择任务的灵魂所在。
import numpy as np
from dataclasses import dataclass
from typing import Optional, Union, List, Dict, Any
from datasets import load_dataset
from transformers import (
AutoTokenizer,
AutoModelForMultipleChoice,
TrainingArguments,
Trainer
)
from transformers.tokenization_utils_base import PreTrainedTokenizerBase, PaddingStrategy
import evaluate
# ==========================================
# Step 1: 准备数据集与分词器
# ==========================================
# SWAG 数据集包含上下文 (sent1)、问题 (sent2) 以及 4 个候选选项 (ending0-3)
dataset = load_dataset("swag", "regular")
model_checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint, use_fast=True)
# 定义选项的键名
ending_names = ["ending0", "ending1", "ending2", "ending3"]
# ==========================================
# Step 2: 核心难点 - 展平与重塑预处理
# ==========================================
def preprocess_function(examples):
# 提取共有上下文(将 sent1 和 sent2 拼接作为完整上下文)
first_sentences = [[context] * 4 for context in examples["sent1"]]
# 提取四个独立的候选句子,并与上下文对齐
question_headers = examples["sent2"]
second_sentences = [
[f"{header} {examples[end][i]}" for end in ending_names]
for i, header in enumerate(question_headers)
]
# 将二维列表展平为一维,以便喂给 Tokenizer
first_sentences = sum(first_sentences, [])
second_sentences = sum(second_sentences, [])
# 执行分词操作
tokenized_examples = tokenizer(
first_sentences,
second_sentences,
truncation=True,
max_length=128
)
# 最核心步骤:将一维字典重塑回包含 4 个选项的三维结构
# 比如输入 8 个样本,展平后是 32 句话。分词完毕后,需按 4 句一组重新打包
features = {k: [v[i : i + 4] for i in range(0, len(v), 4)]
for k, v in tokenized_examples.items()}
return features
# 映射处理数据集
tokenized_datasets = dataset.map(preprocess_function, batched=True)
# ==========================================
# Step 3: 手写专用的 DataCollator
# ==========================================
# 默认的 DataCollator 无法处理三维数据,必须自定义以实现动态填充
@dataclass
class DataCollatorForMultipleChoice:
tokenizer: PreTrainedTokenizerBase
padding: Union[bool, str, PaddingStrategy] = True
max_length: Optional[int] = None
pad_to_multiple_of: Optional[int] = None
def __call__(self, features: List[Dict[str, Any]]) -> Dict[str, Any]:
# 从 features 中分离出 label
label_name = "label" if "label" in features[0].keys() else "labels"
labels = [feature.pop(label_name) for feature in features]
batch_size = len(features)
num_choices = len(features[0]["input_ids"])
# 将三维结构再次拍扁成二维列表,供 Tokenizer 进行动态填充
flattened_features = [
[{k: v[i] for k, v in feature.items()} for i in range(num_choices)]
for feature in features
]
flattened_features = sum(flattened_features, [])
# 执行动态填充
batch = self.tokenizer.pad(
flattened_features,
padding=self.padding,
max_length=self.max_length,
pad_to_multiple_of=self.pad_to_multiple_of,
return_tensors="pt",
)
# 将填充后的二维张量变回 (batch_size, num_choices, seq_length) 的三维张量
batch = {k: v.view(batch_size, num_choices, -1) for k, v in batch.items()}
# 添加标签
import torch
batch["labels"] = torch.tensor(labels, dtype=torch.int64)
return batch
# ==========================================
# Step 4: 加载模型与评估指标
# ==========================================
model = AutoModelForMultipleChoice.from_pretrained(model_checkpoint)
accuracy = evaluate.load("accuracy")
def compute_metrics(eval_pred):
predictions, labels = eval_pred
# predictions 的形状是 (batch_size, num_choices)
preds = np.argmax(predictions, axis=1)
return accuracy.compute(predictions=preds, references=labels)
# ==========================================
# Step 5: 配置训练器并启动
# ==========================================
args = TrainingArguments(
output_dir="./swag_model",
evaluation_strategy="epoch",
learning_rate=5e-5,
per_device_train_batch_size=16,
per_device_eval_batch_size=16,
num_train_epochs=3,
weight_decay=0.01,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
tokenizer=tokenizer,
data_collator=DataCollatorForMultipleChoice(tokenizer=tokenizer),
compute_metrics=compute_metrics,
)
# 启动微调
trainer.train()
在理解了上述代码后,为了拓展你的技术视野,你需要了解工业界解决多项选择任务的范式演进。
传统 Encoder 范式(如上述代码)
这是基于 BERT 等模型的主流做法。它的核心思想是评分机制。模型并不理解什么是“A、B、C、D选项”,它只是把上下文和每个选项拼接起来,并行看一遍,然后给每一句话打一个分数(Logits),最后比较哪句话的分数最高。这种方式在特定垂类数据集上微调后准确率极高,但需要庞大的结构化数据集支持。
LLM Decoder 范式(生成式选择)
在如今的大语言模型(如 Llama 3, Qwen)时代,我们不再使用 AutoModelForMultipleChoice 这种附加特定输出头的架构,而是直接使用 AutoModelForCausalLM(因果语言模型)。
这种范式将多项选择转化为概率分布计算:
将背景和题目作为 Prompt 输入,例如:"上下文...问题... A.选项1 B.选项2。请回答:"。
然后,我们强制模型不生成长篇大论,而是提取模型在预测下一个 Token 时,词表中 “A” 和 “B” 这两个 Token 对应的概率值,直接比较 $P(A)$ 和 $P(B)$ 谁大。这种方法的优势是具备极强的 Zero-Shot(零样本)能力,无需专门的数据集进行微调即可工作。
通过这段代码,你已经掌握了 Transformers 库中最复杂的数据预处理模式。在你后续的计划中,你是打算先使用这段代码跑通一个基线模型,还是想尝试基于大模型(如 Qwen 或 Llama)使用 Prompt 的方式来解决你的具体业务问题呢?
实战演练之文本相似度
文本匹配任务在真实的工业场景中无处不在,从百度的搜索引擎、淘宝的商品搜索,到企业的内部知识库问答(RAG),底层核心都依赖于文本相似度计算。下面我将为你详细拆解该任务的本质、两种截然不同的架构流派(单塔与双塔),并提供实战代码与架构可视化的扩展知识。
文本匹配与文本相似度介绍
什么是文本匹配任务
文本匹配(Text Matching)的核心目标是:给定两段文本(通常称为 Premise/Hypothesis 或 Query/Document),模型需要判断它们之间的语义关系。 这种关系可以是:
- 相似度计算:这两句话表达的意思有多接近?(输出连续的相似度分数,如 0.0 到 1.0)。
- 自然语言推断 (NLI):句子 A 是否能推导出句子 B?(输出类别:蕴含 Entailment、矛盾 Contradiction、中立 Neutral)。
- 释义识别 (Paraphrase Identification):句子 A 和句子 B 是否是同义句?(输出二进制分类:1 代表是,0 代表否)。
文本匹配任务基本示例与数据示例
以经典的重复问题检测(如 Quora Question Pairs 数据集)为例,数据通常呈现为“句子对 + 标签”的格式。
{
"sentence1": "如何学习Python?",
"sentence2": "Python的最佳学习路线是什么?",
"label": 1 // 1 表示语义相似/重复,0 表示不相关
}
文本匹配任务数据预处理
预处理的差异直接取决于你选择的架构。
单塔模型预处理:必须同时传入两个句子。Tokenizer 会自动帮你拼接
[SEP]并生成token_type_ids(用于区分哪部分是第一句,哪部分是第二句)。Python
python
tokenized = tokenizer(sentence1, sentence2, truncation=True, padding="max_length")双塔模型预处理:分别独立处理句子 A 和句子 B。
Python
python
tokenized_A = tokenizer(sentence1, truncation=True, padding="max_length")
tokenized_B = tokenizer(sentence2, truncation=True, padding="max_length")
基于Transformer的解决方案
在基于 Transformer 的解决方案中,业界衍生出了两种截然不同的处理策略,这也是文本匹配任务最核心的考点。
交互策略 / 单塔模型 (Cross-Encoder / Single Tower)
- 原理:将句子 A 和句子 B 拼接在一起,中间用特殊的定界符隔开(例如
[CLS] 句子A [SEP] 句子B [SEP]),然后将这个长句子作为一个整体输入给一个 Transformer 模型。 - 优点:精度极高。因为 Transformer 底层的 Self-Attention 机制在第一层就能让句子 A 的每一个词和句子 B 的每一个词进行深度的“交叉注意力”交互。
- 缺点:推理速度极慢,无法用于大规模检索。如果有 100 万个文档,来了一个新问题,你需要把新问题和这 100 万个文档拼接 100 万次,并进行 100 万次庞大的 Transformer 前向传播,耗时可能长达数小时。
向量匹配 / 双塔模型 (Bi-Encoder / Dual Tower)
- 原理:准备两个 Transformer(通常是共享权重的同一个模型)。句子 A 独立通过左塔生成一个高维向量(Embedding A),句子 B 独立通过右塔生成一个高维向量(Embedding B)。最后,通过计算这两个向量的余弦相似度(Cosine Similarity)来得出得分。
- 优点:推理速度极快,支持海量召回。在工业界,我们会提前把 100 万个文档通过右塔全部离线计算好向量,存入向量数据库(如 FAISS, Milvus)。当新问题来时,只需通过左塔计算一次向量,然后在数据库中进行极速的内积或余弦距离计算,毫秒级返回结果。
- 缺点:精度略低。因为句子 A 和句子 B 在通过 Transformer 提取特征时,互相“看不见”对方,缺乏早期的深度语义交互。
代码实战演练(交互策略/单塔模型)
单塔模型本质上就是一个句子对分类任务,完全复用 AutoModelForSequenceClassification 即可。
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments
import numpy as np
import evaluate
# 1. 准备数据与分词器 (以 MRPC 释义识别数据集为例)
dataset = load_dataset("glue", "mrpc")
model_checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
# 2. 预处理函数 (交互策略:拼接输入)
def preprocess_function(examples):
# 同时传入 sentence1 和 sentence2,底层会自动拼接
return tokenizer(examples["sentence1"], examples["sentence2"], truncation=True)
tokenized_datasets = dataset.map(preprocess_function, batched=True)
# 3. 加载评估指标与模型头
metric = evaluate.load("glue", "mrpc")
def compute_metrics(eval_pred):
predictions, labels = eval_pred
predictions = np.argmax(predictions, axis=1)
return metric.compute(predictions=predictions, references=labels)
# 这是一个单塔分类模型,num_labels=2 (0或1)
model = AutoModelForSequenceClassification.from_pretrained(model_checkpoint, num_labels=2)
# 4. 训练配置
args = TrainingArguments(
output_dir="./cross_encoder_output",
evaluation_strategy="epoch",
learning_rate=2e-5,
per_device_train_batch_size=16,
num_train_epochs=3,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
tokenizer=tokenizer,
compute_metrics=compute_metrics,
)
trainer.train()
代码实战演练(向量匹配/双塔模型)
原生 Transformers 库微调双塔模型需要手写复杂的 Contrastive Loss(对比损失)。在工业界,我们通常使用原生的 Transformer 进行特征提取(推理),或者直接使用专门的扩展库 sentence-transformers 进行训练。 这里我为你演示如何使用原生的 Hugging Face 模型,手动实现双塔特征提取与向量相似度计算的核心逻辑:
import torch
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModel
# 1. 加载基础模型 (Model 本身,不带分类头)
model_checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModel.from_pretrained(model_checkpoint)
# 2. 准备两个独立的句子 (双塔各自的输入)
sentences_A = ["How to learn Python?"]
sentences_B = ["What is the best way to study Python?"]
# 独立分词
inputs_A = tokenizer(sentences_A, padding=True, truncation=True, return_tensors='pt')
inputs_B = tokenizer(sentences_B, padding=True, truncation=True, return_tensors='pt')
# 3. 核心机制:Mean Pooling (平均池化)
# 模型会输出每一个 Token 的向量,我们需要将它们平均成一个代表整个句子的单一向量。
def mean_pooling(model_output, attention_mask):
token_embeddings = model_output[0] # 取出 last_hidden_state
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)
# 4. 独立前向传播 (双塔互不干扰)
with torch.no_grad():
output_A = model(**inputs_A)
output_B = model(**inputs_B)
# 5. 生成句子嵌入向量 (Sentence Embeddings)
embeddings_A = mean_pooling(output_A, inputs_A['attention_mask'])
embeddings_B = mean_pooling(output_B, inputs_B['attention_mask'])
# 6. 计算最终得分 (Cosine Similarity)
# 工业界会将 embeddings_A 存入向量数据库,这里直接计算内积/余弦相似度
cosine_scores = F.cosine_similarity(embeddings_A, embeddings_B)
print(f"句子相似度得分: {cosine_scores.item():.4f}")
扩展知识:工业界落地的“双剑合璧”与 Sentence-Transformers
在真实的 RAG(检索增强生成)或搜索引擎业务中,单塔和双塔从来不是非此即彼的,而是被组合成一套被称为“召回 – 精排 (Retrieve & Rerank)”的经典架构:
- 第一阶段:海量召回 (Recall) 利用提前离线计算好向量的双塔模型 (Bi-Encoder),在 FAISS 等向量数据库中,从上千万篇文档中极速找出与用户查询最相似的 Top-100 篇候选文档。
- 第二阶段:精准重排 (Reranking) 将这 100 篇候选文档与用户查询一一组合,送入单塔模型 (Cross-Encoder) 重新打分。利用单塔模型极高的交互精度,对这 100 篇文章进行重新排序,最后将 Top-5 喂给大语言模型(LLM)生成最终回答。
框架推荐: 如果你准备深入开发双塔向量模型,极其强烈建议你使用基于 Transformers 封装的 Sentence-Transformers 库。它内置了大量用于训练优质向量的损失函数(如 MultipleNegativesRankingLoss,MarginMSELoss),并内置了挖掘“困难负样本(Hard Negatives)”的机制,这是目前训练高可用向量模型不可或缺的利器。
实战演练之检索机器人
在当前的大模型时代,传统的“一问一答”式闲聊机器人已经无法满足企业的核心诉求。真实的业务场景往往要求机器人能够基于公司内部的私有文档给出精准、不出幻觉的回答。下面我将为你详细拆解对话机器人的演进、核心概念,以及基于 Transformer 生态的工业级解决方案。
对话机器人介绍
什么是对话机器人
对话机器人(Chatbot)是一种能够通过自然语言与人类进行交互的软件系统。根据其底层技术架构的演进,对话机器人主要分为以下三个发展阶段:
- 规则引擎时代:基于正则表达式或预设的问答树(如早期的智能客服)。它只能回答字面完全匹配的问题,毫无泛化能力。
- 传统检索时代:提前建立一个庞大的 FAQ(常见问题解答)问答对数据库。当用户提问时,计算用户问题与数据库中已有问题的相似度,把最相似的问题对应的答案直接返回给用户。这种方式不会胡编乱造,但回答非常死板,无法处理复杂的上下文组合。
- 大语言模型时代 (纯生成式):利用极大规模的文本数据预训练出的模型(如 ChatGPT, Llama)。它通过概率预测下一个词来“生成”回复。它极度聪明,但存在致命的“幻觉问题”,并且它不知道企业内部的私有数据,也无法实时更新知识。
为了解决纯生成式机器人的幻觉和数据时效性问题,工业界演化出了目前最标准的技术范式:检索增强生成(RAG, Retrieval-Augmented Generation)。这也是我们今天要演练的“检索机器人”的核心。
基于Transformer的解决方案
构建一个检索机器人,本质上是把之前讲过的文本相似度匹配(双塔模型)和文本生成(大语言模型)结合在一起的“双剑合璧”方案。
在真实的 RAG 业务中,底层核心完全依赖于文本相似度计算。它的标准解决方案包含以下四个核心步骤:
1. 知识库向量化 (Knowledge Embedding) 大模型无法直接读取几万份 Word 或 PDF 文档。我们必须先将这些文档切分成短小的文本块(Chunks)。 利用 Transformer 的双塔模型(Bi-Encoder,如 BERT 变体),将这些文本块逐一映射为高维向量(Embeddings),并存储到专属的向量数据库(如 FAISS、Milvus)中。我们在工业界会提前把大量文档离线计算好向量并存入数据库。
2. 用户意图检索 (Retrieval) 当用户在对话框输入问题(Query)时,系统首先使用同一个双塔模型,将用户的问题也转化为向量。 然后在向量数据库中进行极速的余弦相似度(Cosine Similarity)搜索,召回与问题语义最相关的 Top-K 个文本块。通过双塔模型计算一次向量并在数据库中进行极速内积或余弦距离计算,能够毫秒级返回候选文档。
3. 精准重排 (Reranking – 进阶可选) 为了极高的交互精度,我们会将召回的 Top 候选文档与用户查询一一组合,送入单塔模型(Cross-Encoder)重新打分排序,最后选出最准确的几段上下文。
4. 大模型增强生成 (Generation) 这是基于 Decoder-only 架构发挥作用的阶段。 系统将用户原始的“问题”,加上刚才检索出来的“Top-K 文本块”,拼接成一个全新的提示词(Prompt),输入给生成式大语言模型(如 Qwen, Llama)。模型阅读这些被塞入上下文的参考文章后,用自己的语言总结并生成答案,这就是生成式问答在检索增强系统中的应用。
代码实战演练
构建一个工业级的检索机器人,本质上是将“双塔向量模型(负责找资料)”和“大语言模型(负责总结回答)”无缝串联起来。
下面我将为你提供一套极具代表性的 RAG 实战代码。为了方便你在本地运行,我们将使用 sentence-transformers 库来进行文档向量化,使用轻量级的 FAISS 作为向量数据库,并使用 Hugging Face 原生的 pipeline 调用一个小型生成式模型来作为大脑。
这段代码分为三个核心阶段:知识库构建、语义检索、增强生成。
import torch
import numpy as np
from sentence_transformers import SentenceTransformer
import faiss
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
# ==========================================
# 阶段 1: 知识库准备与向量化 (Knowledge Embedding)
# ==========================================
print("正在加载向量模型并构建知识库...")
# 1.1 准备私有知识库(真实场景中应从 PDF/Confluence 中读取并切片)
knowledge_base = [
"公司规定员工每周可以申请最多两天的远程办公。",
"病假需要提供三甲医院的医生证明,并在系统中提前一天申请。",
"2024年的年终奖将在次年2月份随工资一起发放。",
"IT部门支持设备的报修分机号是8888,或者发送邮件至it@company.com。",
"新员工入职的试用期为三个月,试用期薪资为转正薪资的80%。"
]
# 1.2 加载双塔向量模型 (Bi-Encoder)
# 专门用于计算文本相似度的模型,这里使用 BAAI 的轻量级开源中文模型
embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
# 1.3 对知识库进行向量化 (Embeddings)
# 输出的 corpus_embeddings 是一个高维 numpy 矩阵
corpus_embeddings = embedder.encode(knowledge_base, normalize_embeddings=True)
# 1.4 构建 FAISS 向量数据库
dimension = corpus_embeddings.shape[1] # 获取向量维度
index = faiss.IndexFlatIP(dimension) # 使用内积(Inner Product)进行余弦相似度搜索
index.add(corpus_embeddings) # 将文档向量存入数据库
# ==========================================
# 阶段 2: 初始化大语言模型 (LLM Generation)
# ==========================================
print("正在加载大语言模型...")
# 这里我们使用一个极小参数量的模型进行演示(真实业务中替换为 Qwen, Llama 等大模型)
llm_checkpoint = "Qwen/Qwen2.5-0.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(llm_checkpoint)
model = AutoModelForCausalLM.from_pretrained(
llm_checkpoint,
torch_dtype=torch.float16,
device_map="auto" # 自动分配到 GPU
)
# 构建生成文本的流水线
generator = pipeline("text-generation", model=model, tokenizer=tokenizer)
# ==========================================
# 阶段 3: 检索机器人的核心对话循环 (RAG Pipeline)
# ==========================================
def rag_chatbot(query, top_k=2):
# 3.1 用户问题向量化
query_embedding = embedder.encode([query], normalize_embeddings=True)
# 3.2 在 FAISS 数据库中检索最相似的 Top-K 篇文档
distances, indices = index.search(query_embedding, top_k)
# 提取检索到的真实文本
retrieved_docs = [knowledge_base[i] for i in indices[0]]
context = "\n".join([f"- {doc}" for doc in retrieved_docs])
print(f"\n[检索模块] 为您找到以下参考资料:\n{context}")
# 3.3 组装 Prompt (增强生成)
# 将检索到的私有知识强行塞入 Prompt,约束模型只能根据参考资料回答
prompt = f"""你是一个专业的公司内部HR助手。请严格根据以下提供的参考资料回答用户的问题。
如果参考资料中没有相关信息,请回答“抱歉,我不知道”。绝不能自己编造答案。
【参考资料】:
{context}
【用户提问】: {query}
【你的回答】:"""
# 对于 Instruct 类型的模型,通常需要按照特定的对话模板进行格式化
messages = [
{"role": "system", "content": "你是公司的规章制度智能助手。"},
{"role": "user", "content": prompt}
]
formatted_prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
# 3.4 LLM 生成最终答案
response = generator(
formatted_prompt,
max_new_tokens=150,
do_sample=False, # 关闭随机性,确保回答严谨
return_full_text=False # 只返回生成的答案部分
)
return response[0]['generated_text']
# 测试一下我们的机器人
user_question = "我的电脑坏了,该怎么联系IT部门?"
print(f"\n用户提问: {user_question}")
final_answer = rag_chatbot(user_question)
print(f"\n[生成模块] 最终回答: {final_answer}")
扩展知识:工业级 RAG 的三大进化方向
上述代码展示了最基础的 RAG 骨架。但在真实的工业环境中,为了应对复杂的业务挑战,我们还需要做大量的升级:
- 更聪明的文档切分 (Chunking) 真实的 PDF 文档不能按行切分。我们需要使用
LangChain或LlamaIndex等框架,结合规则(如按标题、段落)和重叠步长(Overlap)进行语义切分,防止把一段完整的逻辑切成两半。 - 多路召回与精准重排 (Hybrid Search & Reranking) 仅仅使用向量(语义相似度)是不够的。比如用户搜索特有商品型号“XJ-900”时,向量检索可能会找出一堆字母数字组合相近但不匹配的废话。
- 解决方案:工业界通常采用“向量检索 + 关键词检索(如 Elasticsearch, BM25)”的双路召回,然后将找出的 Top-100 候选文档,交给一个单塔模型(Cross-Encoder,即 Reranker)进行逐一打分和精确重排,最后选出最准的 Top-3 喂给大模型。
- 对话历史的管理 (Memory Management) 在实际的 Chatbot 中,用户是连续提问的。我们需要将之前的对话历史记录下来,在检索新资料前,先让大模型进行“意图改写 (Query Rewriting)”。比如用户追问“那请假呢?”,系统需要自动将其改写为“公司的请假规定是什么?”,然后再拿着改写后的完整意图去向量数据库中检索。
实战演练之预训练模型
预训练介绍
什么是预训练
在自然语言处理中,预训练是指在一个极其庞大的无标注文本数据集(如维基百科、网页抓取数据、开源代码库)上,让模型进行无监督学习(或称为自监督学习)的过程。
- 核心目的:这个过程的根本目的并不是让模型学会做某道具体的分类题或阅读理解,而是让模型通过海量阅读,掌握人类语言的语法结构、词汇的内在含义、常识规则以及世界的通用逻辑。
- 产物结果:经过预训练的模型被称为“基座模型”(Base Model),例如基础的 BERT 或 GPT。它们是一张绘制了世界知识底图的画布,后续只需要用极少量的高质量标注数据进行“微调(Fine-tuning)”,就能在特定任务上大放异彩。
预训练任务都有什么
在 Hugging Face 架构中,根据底层的注意力机制和目标不同,最核心的预训练任务分为两大阵营:
掩码语言模型 (Masked Language Modeling, MLM)
- 对应模型类:
AutoModelForMaskedLM。 - 底层架构:基于 Encoder-only 架构(如 BERT、RoBERTa)。
- 任务原理:带有掩码词预测头的模型。在输入文本时,系统会故意用
[MASK]标签盖住一定比例(通常是 15%)的词。模型的 Head 是一个映射到词表大小的线性层,它的任务是精准预测被盖住的词是什么。 - 优势:由于 Encoder 拥有双向注意力机制,它能同时看到当前词的上文和下文,因此对句子的全局语义理解极强,非常适合作为下游判别类任务(如文本分类、命名实体识别)的底座。
因果语言模型 (Causal Language Modeling, CLM)
- 对应模型类:
AutoModelForCausalLM。 - 底层架构:基于 Decoder-only 架构(如 GPT, Llama, Qwen)。
- 任务原理:带有语言建模头的模型,这是目前所有主流大语言模型(LLM)的标配。模型在计算当前词时只能看到之前的词(单向注意力),其核心逻辑是将高维向量映射到词表,从而预测下一个可能出现的词。
- 优势:天然契合人类说话和写作的自回归逻辑,极其擅长文本生成、对话交互、代码续写等生成式任务。
代码实战(掩码语言模型)
在 Hugging Face 中,处理预训练数据的核心在于 DataCollatorForLanguageModeling 这个数据收集器。它决定了数据是用来做掩码预测还是下一个词预测。
以下是一套精简的实战代码对比,展示了如何在流水线中配置这两种预训练任务:
from transformers import (
AutoTokenizer,
AutoModelForMaskedLM,
AutoModelForCausalLM,
DataCollatorForLanguageModeling,
Trainer,
TrainingArguments
)
from datasets import load_dataset
# 1. 准备文本数据集 (通常是纯文本文件)
dataset = load_dataset("text", data_files={"train": "my_corpus.txt"})
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
def tokenize_function(examples):
return tokenizer(examples["text"], truncation=True, max_length=512)
tokenized_datasets = dataset.map(tokenize_function, batched=True, remove_columns=["text"])
# ==========================================
# 场景 A: 掩码语言模型 (MLM) 实战配置
# ==========================================
# 加载 MLM 专属的 Model Head
mlm_model = AutoModelForMaskedLM.from_pretrained("bert-base-uncased")
# MLM 的关键:必须严格设置 mlm=True
# 它会在每个 Batch 组装时动态地随机盖住 15% 的文本
mlm_data_collator = DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=True,
mlm_probability=0.15
)
# ==========================================
# 场景 B: 因果语言模型 (CLM) 实战配置
# ==========================================
# 加载 CLM 专属的 Model Head (例如 GPT-2)
clm_model = AutoModelForCausalLM.from_pretrained("gpt2")
# CLM 的训练逻辑非常相似,核心区别在于数据收集器中必须设置 mlm=False
# 这会让系统自动对标签进行错位(Shift)处理以预测下一个词
clm_data_collator = DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=False
)
# ==========================================
# 通用训练执行逻辑
# ==========================================
args = TrainingArguments(
output_dir="./pretrain_output",
per_device_train_batch_size=8,
num_train_epochs=3,
learning_rate=5e-5
)
# 传入对应的 model 和 data_collator 即可启动训练
trainer = Trainer(
model=clm_model, # 或 mlm_model
args=args,
train_dataset=tokenized_datasets["train"],
data_collator=clm_data_collator, # 或 mlm_data_collator
)
trainer.train()
在上面的代码中,可以看到掩码语言模型(MLM)最关键的一步是使用 DataCollatorForLanguageModeling 数据收集器,并严格设置 mlm=True,它会在每个 Batch 组装时动态地随机盖住文本。而因果语言模型(CLM)的核心区别在于加载的模型头不同,并且在数据收集器中必须设置 mlm=False,这会让系统自动对标签进行错位(Shift)处理以预测下一个词。
扩展知识:工业级预训练最佳实践
结合工业界的实际落地经验,关于预训练有几个极其关键的延伸知识点需要掌握:
增量预训练 (Continual Pre-training)
在真实业务中,除了开发全新架构,我们极少从绝对的零(随机初始化权重)开始训练大模型。如果你只是想让模型学习专业领域知识(如内部私有代码、特定医疗病历),增量预训练通常比从零开始更划算。 做法:直接加载一个已经具备基础语言常识的开源基座模型(如 Llama-3-8B-Base),把私有文档作为语料,继续跑一遍上述的 CLM 任务,以此在不丢失通用能力的前提下注入垂直领域知识。
底层训练工具的取舍
虽然 Hugging Face Transformers 原生的 Trainer 能完美跑通上述流程,但在工业界从头训练时,它通常不是性能最优的选择。在面对海量数据和多机多卡的超大规模并发时,由于封装开销和通信效率问题,业界通常采用以下底层工具栈:
- DeepSpeed:微软开源加速库。它通过核心的 ZeRO 技术将优化器状态和梯度分拆,是目前最常用的显存突围插件,常与 Transformers 配合使用。
- Megatron-LM:NVIDIA 官方出品。它支持极其复杂的 3D 并行(数据并行、张量并行、流水线并行),是业界真正用于训练百亿、千亿参数大模型,极致榨干硬件算力的顶级标杆。
- HF Nanotron:Hugging Face 专用,专门为从头训练开发的轻量化、高性能库。它去掉了 Transformers 中许多不必要的封装,直接支持 3D 并行,旨在简化大规模预训练的部署难度。
实战演练之文本摘要
文本摘要介绍
什么是文本摘要任务?给定一段长文本,让模型输出一段简短的、保留核心信息的摘要。业内主要分为两大流派:
- 抽取式摘要 (Extractive Summarization):直接从原文中挑出最重要的句子拼在一起。类似我们读书时用荧光笔划重点。优点是忠于原文、没有幻觉;缺点是行文生硬、缺乏连贯性。
- 生成式摘要 (Abstractive Summarization):模型在理解原文后,用自己的语言重新组织并生成摘要。这是目前深度学习(尤其是 Transformer)的主流研究方向,也是我们今天实战的重点。
文本摘要数据示例及数据预处理
1. 数据格式示例 经典的摘要数据集(如 CNN/DailyMail、XSum)通常包含原文和人工撰写的参考摘要。在工业界,这种数据通常被整理为 JSON 格式。
JSON
{
"document": "据报道,昨天晚上北京发生了强降雨,导致多条路段积水严重,交通受阻。市政部门连夜展开排水工作...",
"summary": "北京昨夜强降雨致交通受阻,市政连夜排水。"
}
2. 数据预处理的核心痛点 在预处理生成式任务的数据时,我们面临的是双向的序列输入与输出。在 Hugging Face 中,我们需要对 document 和 summary 分别进行分词。
- 文本截断:新闻等文档通常极长,必须设定
max_length(如 512 或 1024)。超出部分必须果断截断,以防止显存溢出。 - 标签处理 (Labels):摘要的 Token IDs 必须作为模型的
labels参数传入。在使用交叉熵损失函数时,为了让模型在计算 Loss 时忽略补齐用的填充符(Padding),我们需要将labels中的填充符号 ID 替换为-100。在 PyTorch 中,值为 -100 的标签不参与梯度计算。
文本摘要评价指标(ROUGE)
在文本分类任务中我们看重 Accuracy,但在生成式任务中,我们需要引入专用的评估指标:ROUGE (Recall-Oriented Understudy for Gisting Evaluation)。它主要关注模型生成的摘要与人工参考摘要之间的 n-gram(连续词片段)的重合度。
- ROUGE-1:评估单个词(Unigram)的重合度。
- ROUGE-2:评估连续两个词(Bigram)的重合度,能反映短语的连贯性和流畅性。
- ROUGE-L:评估最长公共子序列(Longest Common Subsequence),不要求词汇绝对连续,能反映句子整体结构和语序的相似度。
基于Transformer的解决方案
在架构选择上,经典的生成式文本摘要主要使用 Encoder-Decoder (序列到序列) 架构,代表模型有 T5、BART、PEGASUS。
- Encoder (编码器):负责对极长的输入文本进行双向深层次的特征提取,理解上下文的全局内涵。
- Decoder (解码器):负责结合 Encoder 提取的特征,通过自回归(Autoregressive)的方式,逐个预测并生成摘要的 Token。
- 模型头部:
AutoModelForSeq2SeqLM类。模型训练时的目标是优化负对数似然损失(Negative Log-Likelihood Loss),即在给定上文的条件下,最大化输出真实摘要序列的概率。
代码实战演练(基于T5)
下面是一套基于 Hugging Face Seq2SeqTrainer 的完整微调代码框架。请特别注意数据收集器和训练参数的特殊设置。
import numpy as np
from datasets import load_dataset
from transformers import (
AutoTokenizer,
AutoModelForSeq2SeqLM,
DataCollatorForSeq2Seq,
Seq2SeqTrainingArguments,
Seq2SeqTrainer
)
import evaluate
# 1. 准备数据集与分词器 (以 T5-small 为例)
dataset = load_dataset("xsum")
model_checkpoint = "t5-small"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
# T5 模型的一个特殊硬性要求:需要在输入前加任务前缀
prefix = "summarize: "
# 2. 数据预处理函数
def preprocess_function(examples):
inputs = [prefix + doc for doc in examples["document"]]
# 对输入文档进行分词
model_inputs = tokenizer(inputs, max_length=512, truncation=True)
# 对摘要目标进行分词
labels = tokenizer(text_target=examples["summary"], max_length=128, truncation=True)
model_inputs["labels"] = labels["input_ids"]
return model_inputs
# 映射处理数据集
tokenized_datasets = dataset.map(preprocess_function, batched=True)
# 3. 加载评估指标 (ROUGE)
rouge = evaluate.load("rouge")
def compute_metrics(eval_pred):
predictions, labels = eval_pred
# 解码预测结果
decoded_preds = tokenizer.batch_decode(predictions, skip_special_tokens=True)
# 将 -100 替换回 pad_token_id 以便正常解码
labels = np.where(labels != -100, labels, tokenizer.pad_token_id)
decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
# ROUGE 指标计算期望换行符分隔的句子
decoded_preds = ["\n".join(pred.strip()) for pred in decoded_preds]
decoded_labels = ["\n".join(label.strip()) for label in decoded_labels]
result = rouge.compute(predictions=decoded_preds, references=decoded_labels, use_stemmer=True)
return {k: round(v * 100, 4) for k, v in result.items()}
# 4. 加载模型与专属数据收集器
model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)
# DataCollatorForSeq2Seq 会自动同时填充 input_ids 和 labels,并将 labels 中的 padding 设为 -100
data_collator = DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model)
# 5. 配置 Seq2Seq 专用的训练参数
args = Seq2SeqTrainingArguments(
output_dir="./summary_model",
evaluation_strategy="epoch",
learning_rate=2e-5,
per_device_train_batch_size=8,
per_device_eval_batch_size=8,
num_train_epochs=3,
# 极其核心的参数:必须开启。评估时强制使用解码器的 Generate 模式生成完整句子,而不是单纯计算前向传播 Loss
predict_with_generate=True,
fp16=True,
)
trainer = Seq2SeqTrainer(
model=model,
args=args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
tokenizer=tokenizer,
data_collator=data_collator,
compute_metrics=compute_metrics,
)
# 启动训练
trainer.train()
代码实战演练(基于GLM)
上述的 T5 是传统的专有模型微调范式。但在如今 Decoder-only 架构的开源大语言模型(如 ChatGLM、Llama 3)时代,我们做文本摘要的方式发生了底层思路的变化。
1. Prompt Engineering (提示工程生成) 对于 ChatGLM 这样的千亿级别生成式大模型,其泛化能力极强。在很多非特殊领域的场景下,你可以完全不需要任何训练数据,直接通过设计提示词(Prompt)完成零样本(Zero-shot)摘要任务: "请提取以下新闻的核心要点,字数限制在50字以内:\n[新闻正文]" 这种方案极大地降低了开发成本。
2. 针对超长文本摘要(Long-Context Summarization) 如果是几十万字的书籍或财报,即便现在的 GLM-4 支持 128K 上下文,也会面临显存极度紧张和“中间迷失 (Lost in the Middle)”的注意力失焦挑战。工业界通常采用 Map-Reduce 策略:
- Map 阶段:先把超长文按段落或 Token 数量切分成多个子块,利用大模型对每个子块生成独立的局部摘要。
- Reduce 阶段:把所有局部摘要合并在一起,再让模型根据合并后的文本生成一次全局的最终摘要。
GLM 微调实战代码模板,这段代码涵盖了数据加载、模型初始化、LoRA 高效微调以及训练循环配置。
import torch
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, TrainingArguments, Trainer
from peft import LoraConfig, get_peft_model, TaskType
# 1. 模型与分词器加载 (以 GLM-4-9B-Chat 为例)
model_id = "THUDM/glm-4-9b-chat"
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto",
torch_dtype=torch.bfloat16,
trust_remote_code=True
)
# 2. 高效微调配置 (LoRA)
# 针对大模型,推荐使用 LoRA 仅微调少量参数,极大地节省显存
peft_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
inference_mode=False,
r=8, # LoRA 的秩,r 越大表达能力越强,显存占用也随之增加
lora_alpha=32, # LoRA 缩放因子
lora_dropout=0.1
)
model = get_peft_model(model, peft_config)
# 3. 数据预处理 (将原始文本格式化为 GLM 所需的对话模板)
def format_data(example):
# GLM-4 需要特定的对话模板 (Chat Template)
messages = [
{"role": "user", "content": example['instruction']},
{"role": "assistant", "content": example['output']}
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
# 加载并处理数据集
dataset = load_dataset("json", data_files="train.jsonl")
tokenized_dataset = dataset.map(format_data)
# 这一步将文本转化为 Input IDs
def tokenize(sample):
return tokenizer(sample["text"], max_length=1024, truncation=True)
tokenized_dataset = tokenized_dataset.map(tokenize, remove_columns=["instruction", "output", "text"])
# 4. 训练参数配置
training_args = TrainingArguments(
output_dir="./glm-finetuned",
per_device_train_batch_size=2, # 根据显存大小调整
gradient_accumulation_steps=4, # 梯度累积,弥补显存不足导致 Batch Size 太小的问题
learning_rate=2e-4, # LoRA 微调的学习率通常比全量微调稍大
num_train_epochs=3,
logging_steps=10,
save_strategy="epoch",
bf16=True, # 使用混合精度训练,节省显存且加速
)
# 5. 启动训练
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset["train"],
)
trainer.train()
关键点解析与注意事项
- Trust Remote Code:GLM 系列模型(尤其是 ChatGLM 和 GLM-4)通常含有自定义的代码实现(用于处理特殊的注意力机制),因此在
from_pretrained时必须显式设置trust_remote_code=True,否则加载会报错。 - Chat Template:GLM 的微调效果极其依赖于输入数据的格式。不同版本的 GLM 对 System、User、Assistant 的拼接字符要求不同。务必使用
tokenizer.apply_chat_template自动处理,不要手动拼接[gMASK]或<|user|>等标记,这能极大降低训练初期模型无法收敛的概率。 - BF16 与显存:GLM-4 等大模型对数值精度很敏感,建议优先使用
bf16=True。如果显存依然溢出,请务必开启gradient_checkpointing=True。 - 微调框架对比:如果你发现手动写上面的
Trainer逻辑比较繁琐,或者需要配置复杂的分布式环境,建议直接使用 LLaMA-Factory。你只需要提供一个简单的配置文件,它底层会自动完成上述所有逻辑的封装 。
实战演练之生成式对话机器人(Bloom)
对话机器人简介
生成式对话机器人 (Generative Chatbot) 的核心大脑是基于 Decoder-only(纯解码器) 架构的大语言模型(LLM)。 它的工作原理极其纯粹,本质上是一个“文字接龙机器”:模型通过阅读你给的上下文(Prompt),利用自回归(Autoregressive)机制,不断预测并输出下一个概率最高的词(Next Token Prediction),直到输出表示结束的特殊符号(如 <eos>)。
关于 BLOOM 模型: BLOOM(BigScience Large Open-science Open-access Multilingual Language Model)是由全球上千名研究人员合作开源的大型语言模型。它的架构类似于 GPT-3,支持 46 种人类语言(对中文支持极好)和 13 种编程语言。作为开源社区的重量级基座模型,它非常适合用来搭建和微调对话系统。
常见解码参数介绍
当模型计算出词表中所有词作为“下一个词”的概率分布后,如何挑选最终输出的那个词?这个过程叫做解码(Decoding)。调整解码参数,就等于在调整机器人的“性格”和“创造力”。
- do_sample (是否开启采样)
False(默认):贪婪搜索(Greedy Search)。模型永远只挑概率绝对值最大的那个词。回答极其稳定,但容易死板、陷入无限重复的死循环。True:开启采样模式。按照概率分布随机掷骰子,这是让机器人具备多样性和灵感的基础。
- Temperature (温度)
- 作用:控制概率分布的平滑程度。
- 低温度 (< 1.0,如 0.2):让高概率的词更高,低概率的词被剔除。回答严谨、确定,适合写代码、做数学题、信息抽取。
- 高温度 (> 1.0,如 1.2):抹平概率差距,原来低概率的词也有机会被选中。回答具发散性、创造力强,适合写诗、头脑风暴,但过高会导致胡言乱语。
- Top-K 采样
- 作用:在掷骰子前,强制截断候选词库,只保留概率排名前 $K$(如 50)的词。这能有效过滤掉极其离谱的生僻词,保证语法通顺。
- Top-P (Nucleus Sampling, 核采样)
- 作用:把候选词按概率从高到低排序,不断累加概率,直到累加和达到 $P$(如 0.9)。保留这些词作为候选库。这种方式比固定的 Top-K 更动态(如果某个词概率极高,可能库里只保留它一个)。
- repetition_penalty (重复惩罚)
- 作用:防止模型变成“复读机”。如果设为
> 1.0(如 1.1),当模型在当前句子里已经生成过某个词时,就会在未来的候选库里强行降低该词的概率。
- 作用:防止模型变成“复读机”。如果设为
代码实战演练(基于BLOOM)
下面是一套基于 transformers 库的实战代码。为了方便你在普通机器上运行,我们加载 bloom-560m(5.6 亿参数的小版本),它的加载只需要 1~2 GB 显存/内存。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
# 1. 加载分词器与生成式模型 (CausalLM)
model_id = "bigscience/bloom-560m"
print("正在加载模型和分词器...")
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto", # 自动将模型分配到 GPU 或 CPU
torch_dtype=torch.float16 # 使用半精度,节省一半显存
)
# 2. 准备对话提示词 (Prompt)
# 注意:BLOOM 基础模型没有经过指令微调 (Instruction Tuning),我们需要通过特定的 Prompt 诱导它续写对话
prompt = "用户:你好,请给我讲一个关于太空旅行的科幻故事开头。\n机器人:"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
# 3. 核心:调用 generate 方法并配置解码魔法参数
print("模型思考中...")
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=150, # 限制最多生成 150 个新 Token
do_sample=True, # 开启随机采样模式
temperature=0.8, # 适中的创造力
top_k=50, # 从概率前 50 的词中选
top_p=0.9, # 核采样,累积概率 0.9
repetition_penalty=1.1, # 轻微的重复惩罚
pad_token_id=tokenizer.eos_token_id # 消除 padding 警告
)
# 4. 解码输出结果
# skip_special_tokens=True 会去掉模型底层的 </s> 等特殊标记
generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
print("\n=== 生成结果 ===")
print(generated_text)
扩展知识:如何让机器人拥有“长期记忆”?
在真实世界的 ChatGPT 或类似产品中,我们不是在做一次性的文本续写,而是在进行多轮交互。但基础的大模型本身就像是“拥有超强计算力但只有 7 秒记忆的金鱼”,每次调用 .generate() 都是一次独立、无状态的计算。
要让模型记住你刚才说的话,核心的工程解法是:在应用层管理会话历史 (History State Management)。
1. 上下文拼接策略 在进行第二轮对话时,你不能只把用户的新问题发给模型,而是必须把之前的“聊天记录”当成前缀拼接到 Prompt 中。
用户:中国的首都是哪里?
机器人:北京。
用户:它有什么著名景点?
机器人:
把这段完整的长文本输入给模型,模型就能根据“北京”这个上下文,正确回答出“故宫、长城”。
2. 应对模型上下文窗口溢出 (Context Window Limit) 这是对话系统开发最头疼的问题。随着聊天变长,拼接的文本会迅速超越模型的最大长度限制(早期的 BLOOM 只有 2048 Token)。
- 策略 A:滑动窗口 (Sliding Window)。如果超出长度,直接砍掉最老的对话轮次,只保留最近的 N 轮。
- 策略 B:动态摘要 (Dynamic Summarization)。引入一个专门负责做摘要的小模型。当对话超过 10 轮时,把前 10 轮的对话压缩成一句总结:“[前情提要:用户询问了北京的位置和景点]”,然后将摘要拼在最新的对话前面。
