P01_大模型微调的主要方式【掌握】

1、大模型Prompt-Tuning方法

1.1 NLP任务四种范式

  • 第一范式:基于传统机器学习模型
  • 第二范式:基于深度学习
  • 第三范式:基于预训练模型+fine-tuning
  • 第四范式:预训练模型+Prompt+预测

1.2 Fine-Tuning(微调)

Fine-Tuning基本思想:使用小规模的特定任务文本继续训练预训练语言模型。

Fine-Tuning问题:

  • 所需的Fine-Tuning量取决于预训练语料库和任务特定语料库之间的相似性。如果两者相似,可能只需要少量的Fine-Tuning,如果两者不相似,则可能需要更多的Fine-Tuning,并且效果不明显。
  • 成本高

Prompt-Tuning的基本思想:通过添加模板的方法将任务目标转化为与预训练目标相似的形式(如MLM),避免引入额外的参数的同时,最大化利用模型的预训练知识。

Prompt-Tuning主要解决传统Fine-Tuning方式的两个痛点:

  • **降低语义偏差:**预训练任务主要以MLM为主,而下游任务则重新引入新的训练参数,因此两个阶段目标差异较大。因此需要解决Pre-Training和Fine-Tuning之间的Gap。
  • **避免过拟合:**由于Fine-Tuning阶段需要引入新的参数适配相应任务,因此在样本数量有限的情况下容易发生过拟合,降低模型泛化能力。因此需要解决预训练模型的过拟合能力。

1.3 Prompt-Tuning(提示微调)

(1)什么是Prompt?

即提示词

(2)Prompt-Tuing的本质

Fine-Tuning的本质:调整预训练模型,让预训练模型去迁就下游任务。

Prompt-Tuing的本质:让下游任务去迁就预训练模型,将Fine-tuning的下游任务目标转换为Pre-training的任务。

(3)Fine-Tuning和Prompt-Tuing对比

特征 Fine-tuning (模型迁就任务) Prompt Tuning (任务迁就模型)
核心操作 修改预训练模型的参数,使其适应下游任务的数据和目标 调整少量参数(如软提示向量)或仅依赖输入设计(提示工程)
模型状态 发生变化,学习新的特定任务知识 保持不变,利用已有的通用知识
任务目标 直接学习下游任务的特定目标函数 将下游任务目标重构为预训练模型更擅长的形式(如生成、补全)
资源消耗 通常需要大量计算资源进行训练,为每个任务存储一个模型副本 计算资源消耗极少,只需存储和处理 Prompt
目的 深度适应特定任务,可能牺牲通用性,但通常性能上限高 高效利用预训练模型的通用能力,在资源有限时效果显著,泛化性好

1.4 Prompt-Tuning技术发展历程

1-3-8

1.5 面向超大规模语言模型的Prompt-Tuning

特点:模型的 参数量足够大,训练过程中使用了足够多的语料,同时设计的预训练任务足够有效

效果:只需要设计合适的模板或指令即可以实现免参数训练的零样本学习

类型:

  • In-Context Learning(上下文学习):通过上下文示例(demonstrations)让模型理解任务,而无需显式训练
  • Instruction-Tuning(指令微调):在已有的预训练语言模型基础上,收集大量的成对数据(指令,期望输出),对模型进行额外的监督微调,让它学会遵循人类自然语言指令完成任务。
  • Chain-of-Thought(思维链):一种改进的提示策略,用于提高 LLM 在复杂推理任务中的性能。方法就是相比于之前的上下文学习多了中间的推导过程提示。

1.6 面向小规模语言模型的Prompt-Tuning

1.6.1 Prompt-Tuning的鼻祖—PET模型

(1)PET模型的核心思想:将下游任务重构为预训练模型最熟悉的“完形填空”问题,从而利用语言模型对文本的理解能力,最终通过少量示例训练获得较好的下游性能。

(2)方法:通过设计自然语言模式(pattern)和标签词映射(verbalizer),将输入句子转换为带有[MASK]位置的文本,例如“这个电影很[MASK]。”,然后用预训练语言模型(如BERT)预测[MASK]位置的词,再通过verbalizer转换来完成分类任务。

image-20250814224811173

(3)PVP组件

  • Pattern(Template) :记作T,为额外添加的带有[mask]标记的短文本,用于引出不同任务的预测词。
  • Verbalizer :记作V, 即标签词的映射,对于具体的分类任务,需要选择指定的标签词(label word)。

(4)人工设计PVP的缺陷

  • 采用人工构建的方法成本高,需要与领域任务相关的先验知识
  • 人工设计的Pattern和Verbalizer不能保证获得最优解,训练不稳定,不同的PVP对结果产生的差异明显,方差大
  • 人工构建的Pattern和Verbalizer使得Prompt-Tuning与MLM在语义和分布上依然存在差异

1.6.2 Prompt-Oriented Fine-Tuning

(1)本质:本将目标任务转换为适应预训练模型的预训练任务,以适应预训练模型的学习体系。

(2)类型

根据提示的类型不同,POFT方法主要分成三种类型:

  • 离散提示:也叫硬模版,其提示是由真实的自然语言单词或符号组成,直接拼接到输入中。
  • 连续提示:也叫软模板,提示不是实际的单词,而是**可训练的向量**,插入到输入 embedding 序列中。
  • 混合提示:同时使用人工可读的离散 token可训练的连续向量

按照训练时参数更新的范围不同,POFT方法主要分成三种类型:

  • 全量微调(Full Fine-Tuning):模型所有参数都参与更新,包括预训练模型参数和下游任务层参数。如PET模型。
  • 部分参数微调(Partial Fine-Tuning):只更新预训练模型中的一部分参数,比如高层 transformer block、某些 attention 层或特定模块,其余参数冻结。如Adapter Tuning。
  • 仅提示参数微调(Prompt-Only Tuning):冻结原始预训练模型参数,只训练 prompt 参数。如P-tuning、Prompt Tuning等

(3)PET中使用的POFT:硬模板+ 全量微调

全量微调:成本高,要求数据量大

硬模版:人工构建成本高、不同PVP对结果产生的差异明显、与MLM训练任务不完全一致

1.6.3 Soft Prompt及微调方法

1.6.3.1 连续提示模板

Soft Prompt (连续提示) :是指通过给模型输入一个可参数化的提示模板,从而引导模型生成符合特定要求的文本。

特点:

  • 将模板变为可训练的参数,不同的样本可以在连续的向量空间中寻找合适的伪标记,同时也增加模型的泛化能力。
  • 连续法需要引入少量的参数并在训练时进行参数更新,但预训练模型参数是不变的,变的是prompt token对应的词向量(Word Embedding)表征及其他引入的少量参数。
1.6.3.2 Prompt Tuning(NLG任务)

(1)方法:为每一个输入文本假设一个固定前缀提示,该提示表由神经网络参数化,并在下游任务微调时进行更新,整个过程中预训练的大模型参数被冻结。

image-20250818154810642

(2)特点

  • 优点:
    • 大模型的微调新范式
    • 模型参数规模大了之后,可以将大模型参数固定,指定附加参数来适配下游任务,而且适配性能基本和全参数微调相当。
  • 缺点:
    • 在小样本学习场景上表现不太行
    • 收敛速度比较慢
    • 调参比较复杂
1.6.3.3 P-tuning(NLU任务)

(1)P-tuning 的核心思想是:用一个小的可训练模块把一组“连续提示向量”生成并插入到原始输入 embedding 中,令冻结的预训练模型在下游任务上产生正确输出,训练时仅更新 prompt encoder(或提示向量),从而实现低成本高效的调优。

image-20250818163852423

(2)P-tuning的特点

  • 优点:
    • 引入了一个 LSTM +MLP模块对 soft prompt 进行建模,能捕捉 token 之间的顺序和语义关系
    • 改进了离散 prompt的不稳定性问题,收敛速度更快
  • 缺点
    • 仅放在输入层时,对模型内部深层表征的影响有限,面对一些需要深层表示调整的 NLU/序列标注任务表现并不稳定或不足
    • 在中小模型(100M–1B)表现较差

(3)P-Tuning v2的核心思想:在模型的每一层都应用连续的 prompts 并对 prompts 参数进行更新优化

img

注意:P-Tuning v2中,在一些任务中,引入一个重参数化的编码器(如MLP,多层感知机)可以对提示向量进行非线性变换,提升模型性能。但是,研究发现其效果因任务而异,因此在P-Tuning v2中,是否使用重参数化需要根据具体任务进行选择。

  • 使用重参数化:使用MLP对前缀嵌入进行转换(对应于Prefix-Tuning)
  • 不使用重参数化:直接使用前缀嵌入(对应于P-Tuning v2)

(4)P-tuning v2的特点

  • 优点:
    • 把 soft prompts 注入每层,能在多种规模与任务上接近全量微调效果
  • 缺点
    • 深层 prompt 或长 soft prompt 会占用较多 token / 输入空间
    • P-tuning 的 soft prompt 是针对每个下游任务独立训练的,无法直接迁移到其他任务上使用

(5)P-tuning v1与P-tuning v2对比

对比 维度 P-tuning v1 P-tuning v2
提出时间 2021(原始 P-tuning) 2022(P-tuning v2)
核心思路 在输入 embedding 层前插入连续可训练的 prompt embeddings,通过 LSTM/MLP 对伪标记编码,优化这些参数以适配下游任务 Transformer 每一层 注入可训练的 layer-wise prompts(类似 prefix-tuning),直接与各层隐状态交互
插入位置 仅作用于 embedding 层(输入端) 作用于 每一层 Transformer(layer-wise prompt)
参数规模 较少(仅输入 prompt 参数) 略多于 v1(每层都有 prompt 参数),但仍远小于全量微调
表达能力 容易受限,难以在小数据任务中获得与全量微调接近的性能 表达能力更强,性能更接近甚至超越全量微调
训练方式 仅优化 prompt encoder 参数,其余预训练模型参数冻结 冻结预训练模型主干,优化每层的 prompt 参数
初始化方式 通常从 vocab embedding 随机或用任务相关词初始化 通常随机初始化,也可借助任务先验初始化
依赖组件 需要 Prompt Encoder(如 LSTM/MLP)来生成连续模板向量 不需要复杂 Prompt Encoder,直接将可训练向量作为 prefix 注入每层
优点 参数量小、实现简单、易迁移到不同任务 表达能力强、在低资源场景下性能稳定、接近全量微调效果
缺点 对复杂任务适配能力不足,深层信息利用不充分 参数量稍大,实现比 v1 复杂,需要改造模型结构
适用场景 对计算资源和任务复杂度要求低的场景 更复杂、对性能要求高或低资源任务中替代全量微调
1.6.3.4 PPT(Pre-trained Prompt Tuning)

(1)PPT 的核心思想:对连续提示模板也进行预训练——先让这些连续提示在大量无标注的预训练语料进行预训练(注意,预训练过程中,Pre-train-model参数固定不变,只改变soft prompt),然后将其加载到对应下游任务的PLM上进行微调后使用。

image-20250818173316706

(2)PPT的特点

  • 优点:
    • 预训练soft-prompt带来了 小样本学习场景上的显著提升
    • 缓解了prompt-tuning收敛慢的问题
  • 缺点
    • 高度依赖于源任务集的覆盖度与多样性(一旦目标任务与预训练时用到的源任务在分布、格式或语义上差异较大,通用提示 𝑃就难以提供有效的初始引导,导致下游微调效果大幅下降)

2、大模型PEFT微调方法【掌握】

(1)参数高效微调方法(Parameter-Efficient Fine-Tuning,PEFT)特点:

  • PEFT 方法仅微调少量或额外的模型参数,固定大部分预训练参数,大大降低了计算和存储成本

  • 最先进的 PEFT 技术也能实现了与全量微调相当的性能

(2)类型

  • Prefix/Prompt-Tuning:在模型的输入或隐层添加 $k$个额外可训练的前缀 tokens(这些前缀是连续的伪 tokens,不对应真实的 tokens),只训练这些前缀参数;
  • Adapter-Tuning:将较小的神经网络层或模块插入预训练模型的每一层,这些新插入的神经模块称为 adapter(适配器),下游任务微调时也只训练这些适配器参数;
  • LoRA:通过学习小参数的低秩矩阵来近似模型权重矩阵 $W$的参数更新,训练时只优化低秩矩阵参数。

2.1 Prefix Tuning

(1)做法:在模型的输入或隐层添加 $k$个额外可训练的前缀 tokens(这些前缀是连续的伪 tokens,不对应真实的 tokens),只训练这些前缀参数

(2)具体实现流程如下:

1)确定任务与基模型

  • 任务:条件生成(如摘要、表格到文本、对话)或 seq2seq。
  • 选模型:GPT-2/decoder-only 或 BART/T5(encoder-decoder)。

2)设计 prefix 配置

  • 决定 num_prefix(每层的虚拟 token 数,常见 10–100),以及是否对所有层都使用 prefix(论文对每层都用了 prefix,但可做只对部分层)。

3)构造可训练参数(初始化)

原始论文中为每层创建一个可以训练的矩阵$P_θ$ ,作为前缀向量拼接到原向量中。但是论文中提出直接优化 $P_θ$ 会导致训练不稳定,可以通过一个更小的矩阵 $P_w$和一个更大的前馈神经网络$MLP_θ$ 对$P_θ$ 进行重参数化: $P_θ[i,:]=MLP_θ(P_w[i,:])$ 。

所以目前实际实现时,通常会先训练一个 (num_layers, num_prefix, hidden_dim) 的 prefix embedding,然后通过一个小的 MLP 投影成 (num_layers, num_prefix, 2 * head_dim * num_heads),再 reshape 成 (num_layers, num_heads, num_prefix, head_dim),分别拆成 K 和 V。因此需要创建一个可训练的矩阵 $P_θ$ ,以及一个MLP模型。

4)修改模型前向(插入 prefix)

  • 在 Transformer 的每一层注意力里,将 prefix 对应的 key/value 拼接到原始的 key/value——这样后续 token 可以像“看到真实 tokens”一样 attend 到 prefix,然后一起进行训练。

5)训练设置

  • 冻结原模型参数(requires_grad=False),只对 prefix 参数做优化。
  • 损失函数通常是标准的交叉熵,训练器只更新 prefix。

6)推理

  • 推理时把训练好的 prefix 附加到每层(同训练时),然后用常见的解码策略进行生成。

(3)Prefix Tuning的特点

  • 优点:
    • 只训练少量 prefix 参数,相对全量微调的存储和训练成本低。
    • 不同任务只需切换 prefix,无需保存多个完整模型。
  • 缺点
    • 小模型表现差:在 BERT-base 等小模型上效果不佳
    • 需在每层注入 prefix,会占用输入序列的长度
    • 在判别式任务上常逊于 LoRA、P-Tuning v2

(4)Prefix Tuning与P-Tuning v1和v2的区别

1)目标任务

  • Prefix-Tuning:主要面向生成类任务(table→text、summarization、GPT-2/BART 等场景),论文强调在生成上用少量参数达到接近微调的效果。

  • P-Tuning v1和v2:主要针对NLU(分类、序列标注、QA 等),目标是让 prompt-only 方法在 NLU 上也能普遍接近微调的性能。

2)注入位置

  • P-tuning v1:只在输入 embedding 处(相当于在输入序列前加若干soft prompt 的 embedding)。
  • Prefix Tuning / P-tuning v2:在 Transformer 每一层的中都进行注入。

3)注入形式

  • Prefix Tuning和P-tuning v2:将前缀token直接作用到注意力机制计算的 key/value上,然后进行注意力计算。

  • P-tuning v1是将原始embedding+prompt embedding组成的新输入后送入下一层。

4)参数化方式

  • Prefix Tuning:原始论文中直接优化每层的“past key/value”向量(通常把这些向量当作要学习的参数矩阵);另外,也提出生成一个初始向量,再通过MLP进行投影效果更好。
  • P-tuning v1:通过 prompt encoder优化一组输入级别的 embedding。
  • P-tuning v2:提出了可选的 reparameterization(用小网络如MLP把少量可训练参数映射成每层的完整 prompt),利于参数共享、稳定训练并在层数很多时控制参数量。如果不用reparameterization则直接使用embedding的结果。

5)效果

  • P-tuning v1:对 decoder-only、encoder-decoder 都可用,但效果和适用性有限(因为只在输入层)。
  • Prefix Tuning 与 P-tuning v2:更适合需要深层修改注意力的信息流的任务(对 decoder-only 和 encoder-decoder 都能做扩展,且在很多任务上性能更好)。

2.2 Adapter Tuning

(1)做法:在预训练模型内部的网络层之间添加新的网络层或模块来适配下游任务

(2)Series Adapter的适配器结构和与 Transformer 的集成结构:

image-20250818181655276

(3)Adapter Tuning的特点

  • 优点:
    • 只训练少量 adapter 参数,相对全量微调的存储和训练成本低。
    • 可以为多个任务保存不同的 adapter,而共享一个大模型。
    • 多个任务的 adapters 可以在推理时进行切换或融合,提升迁移和泛化能力。
  • 缺点
    • 因为大部分参数被冻结,adapter 的容量有限,对复杂任务或需要大规模参数调整的任务可能效果不如全量微调。
    • Adapter 的维度大小(瓶颈层大小)、插入位置等超参数对性能影响较大,调参复杂度较高。
    • PLM 基础上添加适配器层会引入额外的计算,带来推理延迟问题

2.3 LoRA

(1)补充知识——秩

  • 1)什么叫做秩?

矩阵中所有行向量(或列向量)所张成的向量空间的维数

换句话说,秩衡量了矩阵里有多少个 线性无关的行/列。如果把矩阵看成是很多向量的集合,那么秩就是这些向量中最多能取出的互不依赖的数量。

image-20250816174459601

  • 2)秩的几种等价定义

    • 行秩:矩阵中线性无关的行向量个数

    • 列秩:矩阵中线性无关的列向量个数

    • 基本定理:行秩 = 列秩,这个共同的数就是矩阵的秩。

  • 3)秩的直观理解

秩 = 有效信息量,秩越高,矩阵包含的独立信息越多。

(2)做法:在预训练语言模型(PLM)的特定线性层(如自注意力机制中的 Q、K、V 投影层和前馈网络)旁边,并行地注入一对小的、可训练的低秩分解矩阵,而冻结 PLM 的原有参数。

低秩适应:对大型模型的权重矩阵进行隐式的低秩转换,也就是:通过一个较低维度的表示来近似表示一个高维矩阵或数据集。

(3)基本原理:LoRA技术冻结预训练模型的权重,并在每个Transformer块中注入可训练层(称为秩分解矩阵),即在模型的Linear层的旁边增加一个“旁支”A和B。其中,A将数据从d维降到r维,这个r是LoRA的秩,是一个重要的超参数;B将数据从r维升到d维,B部分的参数初始为0。模型训练结束后,需要将A+B部分的参数与原大模型的参数合并在一起使用。

image-20250819103825320

(4)LoRA的特点

  • 优点:
    • 只训练极少参数,相对全量微调的存储和训练成本低。
    • 效果接近全参数微调,且保留原模型能力。
    • 不同任务的 LoRA 模块可插拔,便于多任务部署。
  • 缺点
    • LoRA 本质是用低秩分解逼近权重更新矩阵,这对参数空间的表达能力有限制,可能无法拟合某些复杂任务所需的高秩变化。
    • LoRA 通常加在 attention 的投影矩阵(Wq/Wv)上,但不同任务可能对位置敏感,选择不好会影响性能。

2.4 QLoRA

(1)核心思想是:通过对预训练语言模型(PLM)进行量化(通常是 4-bit NormalFloat),并结合 LoRA 技术进行微调,从而在极低的内存消耗下,仍然能够高效地微调巨型语言模型,同时保持甚至超越全量 16-bit LoRA 的性能。

(2)核心创新点

  • 高效低精度量化(NF4 量化):NF4 采用了非均匀的数值分布映射,更好地保留了原始权重的细微差异,使得在极低精度下仍能保持接近 FP16 的模型性能。
  • 采用双量化和分页优化器,进一步减少显存占用。
  • 在量化后的冻结PLM上,LoRA的微调机制保持不变

(3)QLoRA的特点

  • 优点:
    • 极低的内存消耗。这是 QLoRA 最显著的优势。可以将训练巨型模型的内存需求降低 3-4 倍,使得在单张消费级 GPU 上(如 24GB VRAM 的 RTX 3090/4090)微调 65B 甚至 70B 参数的模型成为可能。
    • 性能优异:尽管进行了 4-bit 量化,但由于 16-bit 的 LoRA 权重和优化器状态,QLoRA 在许多任务上能够保持与 16-bit LoRA 甚至全量微调相媲美的性能。
    • 训练速度快:由于只训练少量参数且内存效率高,训练速度非常快。
  • 缺点
    • 虽然 NF4 优化了精度,但极端任务或敏感任务可能仍受 4-bit 量化影响。
    • 由于量化和分页机制的存在,训练和问题调试会比标准 LoRA 更复杂。

基于GPT2的医疗问诊机器人


学习目标

  • 理解医疗问诊机器人的开发背景.
  • 了解企业中聊天机器人的应用场景
  • 掌握基于GPT2模型搭建医疗问诊机器人的实现过程

1. 项目介绍

1.1 项目背景

  • 本项目基于医疗领域数据构建了智能医疗问答系统,目的是为为用户提供准确、高效、优质的医疗问答服务。

1.2 环境准备

  • python`3.10
  • transformers`4.40.2
  • torch`2.5.1+cu121

1.3 项目整体结构

image-20250819201622040


整体代码结构:

image-20250817174320793

2. 数据处理

2.1 数据介绍

  • 数据存放位置:llm_tuning/Gpt2_Chatbot/data
  • data文件夹中存有原始训练语料为train.txt。train.txt的格式如下,每段闲聊之间间隔一行,格式如下:
1
2
3
4
5
帕金森叠加综合征的辅助治疗有些什么?
综合治疗;康复训练;生活护理指导;低频重复经颅磁刺激治疗

卵巢癌肉瘤的影像学检查有些什么?
超声漏诊;声像图;MR检查;肿物超声;术前超声;CT检查

2.2 数据处理

  • 目的:将中文文本数据处理成模型能够识别的张量形式,并将上述文本进行张量的转换
  • 实现过程:
    • 运行preprocess.py,对data/train.txt对话语料进行tokenize,然后进行序列化保存到data/train.pkl。train.pkl中序列化的对象的类型为List[List],记录对话列表中,每个对话包含的token。

2.2.1 配置文件

  • 代码路径:llm_tuning/Gpt2_Chatbot/parameter_config.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import torch
import os

base_dir = os.path.dirname(os.path.abspath(__file__))
# print(f'base_dir-->{base_dir}')
# base_dir2 = os.getcwd() # 错误的写法,这种写法会随着调用位置改变而变化
# print(f'base_dir2-->{base_dir2}')

class ParameterConfig():
def __init__(self):
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# self.device = torch.device('mps' if torch.cuda.is_available() else 'cpu')
# 词典路径:在vocab文件夹里面
self.vocab_path = os.path.join(base_dir, 'vocab/vocab.txt')
# 训练文件路径
self.train_txt_path = os.path.join(base_dir, 'data/medical_train.txt')
self.train_path = os.path.join(base_dir, 'data/medical_train.pkl')
# 验证数据文件路径
self.valid_txt_path = os.path.join(base_dir, 'data/medical_valid.txt')
self.valid_path = os.path.join(base_dir, 'data/medical_valid.pkl')
# 模型配置文件
self.config_json = os.path.join(base_dir, 'config/config.json')
# 模型保存路径
self.save_model_path = os.path.join(base_dir, 'save_model')
# 如果你有预训练模型就写上路径(我们本次没有直接运用GPT2预训练好的模型,而是仅只用了该模型的框架)
self.pretrained_model = ''
# 保存对话语料
self.save_samples_path = os.path.join(base_dir, 'sample')
# 忽略一些字符:句子需要长度补齐,针对补的部分,没有意义,所以一般不进行梯度更新
self.ignore_index = -100
# 历史对话句子的长度
self.max_history_len = 3 # "dialogue history的最大长度"
# 每一个完整对话的句子最大长度
self.max_len = 300 # '每个utterance的最大长度,超过指定长度则进行截断,默认25'
self.repetition_penalty = 5.0 # "重复惩罚参数,若生成的对话重复性较高,可适当提高该参数"
self.topk = 2 # '保留概率最高的topk个token。默认4'
self.topp = 0.7 # '保留累积概率top个token。默认0.7'
self.batch_size = 8 # 一个批次几个样本
self.epochs = 4 # 训练几轮
self.loss_step = 100 # 多少步汇报一次loss
self.lr = 2.6e-5
# eps,为了增加数值计算的稳定性而加到分母里的项,其为了防止在实现中除以零
self.eps = 1.0e-09
self.max_grad_norm = 2.0
self.gradient_accumulation_steps = 4 # 梯度累积的步数
# 使用Warmup预热学习率的方式,即先用最初的小学习率训练,然后每个step增大一点点,直到达到最初设置的比较大的学习率时(注:此时预热学习率完成),采用最初设置的学习率进行训练(注:预热学习率完成后的训练过程,学习率是衰减的),有助于使模型收敛速度变快,效果更佳。默认.warmup_steps = 4000
self.warmup_steps = 100


if __name__ ` '__main__':
pc = ParameterConfig()
print(pc.train_path)
print(pc.device)

2.2.1 数据张量转换

  • 步骤

image-20250819211546708

1
2
3
4
1. 加载分词器
2. 读取数据,拆分出每段对话
3. 对每段对话进行处理(拼接问题和答案), 并进行编码
4. 保存处理好的列表
  • 代码路径:llm_tuning/Gpt2_Chatbot/data_preprocess/preprocess.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import pickle
from transformers import BertTokenizer

from Gpt2_Chatbot.parameter_config import ParameterConfig

pc = ParameterConfig()


def data_preprocess(txt_path, save_path):
# 1. 加载分词器
# 第一种方式:使用预训练的分词器
# tokenizer = BertTokenizer.from_pretrained('gpt2')
# 第二种方式:使用本地的分词器
tokenizer = BertTokenizer.from_pretrained(pc.vocab_path)
# print(f'tokenizer.vocab_size-->{tokenizer.vocab_size}')
# 特殊字符
# print(f'tokenizer.special_tokens_map-->{tokenizer.special_tokens_map}')
sep_id = tokenizer.sep_token_id
cls_id = tokenizer.cls_token_id
print(f'sep_id-->{sep_id}, cls_id-->{cls_id}')

# 2. 读取数据,拆分出每段对话
with open(txt_path, 'r', encoding='utf-8') as f:
data = f.read()
# print(f'data-->{data}')
# 切分数据
data_list = data.split('\n\n')
print(f'data_list-->{len(data_list)}')
# print(f'data_list[0]-->{data_list[0]}')

# 3. 对每段对话进行处理(拼接问题和答案), 并进行编码
# 将每段对话处理成:[CLS]帕金森叠加。。些什么?[SEP]综合治疗;康复。。。重复经颅磁刺激治疗[SEP]
dialogue_list = [] # 存储每段对话
for dialogue in data_list:
# 对每段对话进行拆分,获取问题和答案
qa_list = dialogue.split('\n')
# 初始化input_ids列表,存储编码后的数据
input_ids = [cls_id]
for sentence in qa_list:
# add_special_tokens=False 表示不添加特殊字符
input_ids += tokenizer.encode(sentence, add_special_tokens=False)
input_ids.append(sep_id)
# print(f'input_ids-->{input_ids}')
dialogue_list.append(input_ids)
# break
print(f'dialogue_list-->{len(dialogue_list)}')
# 4. 保存处理好的列表
with open(save_path, 'wb') as f:
pickle.dump(dialogue_list, f)


if __name__ ` '__main__':
data_preprocess(pc.train_txt_path, pc.train_path)
data_preprocess(pc.valid_txt_path, pc.valid_path)

2.2.2 获取dataloader

(1)封装Dataset对象
  • 代码路径:llm_tuning/Gpt2_Chatbot/data_preprocess/dataset.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import pickle

from torch.utils.data import Dataset

from Gpt2_Chatbot.parameter_config import ParameterConfig


class MyDataset(Dataset):
def __init__(self, data_list):
'''
初始化函数,用于设置数据集
:param data_list: 输入列表,就是preprocess中处理好的数据列表
'''
super(MyDataset, self).__init__()
self.data_list = data_list

def __len__(self): # 获取数据集大小
return len(self.data_list)

def __getitem__(self, index): # 根据索引返回数据
return self.data_list[index]


if __name__ ` '__main__':
pc = ParameterConfig()
# 加载保存的数据
with open(pc.train_path, 'rb') as f:
train_data_list = pickle.load(f)
print(f'train_data_list-->{len(train_data_list)}')
# 初始化对象
mydataset = MyDataset(train_data_list)
print(f'数据集的长度-->{mydataset.__len__()}')
print(f'数据集的长度-->{len(mydataset)}')
print(f'数据集的第一个数据-->{mydataset.__getitem__(0)}')
print(f'数据集的第一个数据-->{mydataset[0]}')

(2)封装DataLoader对象
  • 代码路径:/home/user/ProjectStudy/Gpt2_Chatbot/data_preprocess/dataloader.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import pickle

import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader

from Gpt2_Chatbot.data_preprocess.dataset import MyDataset
from Gpt2_Chatbot.parameter_config import ParameterConfig

pc = ParameterConfig()


def collate_fn(batch_data):
'''
自定义collate_fn函数,用于将数据集中的数据进行批处理
:param batch_data: 每个批次中的样本
:return: 处理好的,可以送到模型中的数据。需要包括输入和输出(标签)。
'''
# 1. 将数据转成张量
batch_data = [torch.tensor(data) for data in batch_data]
# print(f'batch_data-->{batch_data}')
# print(f'batch_data[0]-->{batch_data[0].shape}')
# print(f'batch_data[1]-->{batch_data[1].shape}')

# 2. 对批次数据的数据进行长度统一
# 使用 pad_sequence 对批次内的数据长度进行统一,统一的方式就是将该批次内最大的句子的长度作为该批次数据的长度,其他句子长度不足的,用填充值进行填充
# batch_first=True 表示返回的张量的形状为(batch_size, max_len)
# 对于输入数据 padding_value为填充的数字,这里为0
input_ids = pad_sequence(batch_data, batch_first=True, padding_value=0)
# print(f'input_ids-->{input_ids}')
# print(f'input_ids[0]-->{input_ids[0].shape}')
# print(f'input_ids[1]-->{input_ids[1].shape}')

# 注意:对于标签来说,需要将标签的填充值设置为-100,这样在计算损失函数的时候,忽略掉填充值对应的位置的预测结果。
labels = pad_sequence(batch_data, batch_first=True, padding_value=-100)
return input_ids, labels

def get_data_loader(train_path, valid_path):
# 训练集
with open(train_path, 'rb') as f:
train_data_list = pickle.load(f)
# print(f'train_data_list-->{len(train_data_list)}')
train_dataset = MyDataset(train_data_list)

train_dataloader = DataLoader(train_dataset,
batch_size=pc.batch_size,
shuffle=False, # 在代码开发时,设置为False,在训练时,设置为True
collate_fn=collate_fn,
drop_last=True) # 如果最后剩下的数据不足一个batch_size的数据,则忽略

# 验证集
with open(valid_path, 'rb') as f:
valid_data_list = pickle.load(f)
valid_dataset = MyDataset(valid_data_list)

valid_dataloader = DataLoader(valid_dataset,
batch_size=pc.batch_size,
shuffle=False, # 在代码开发时,设置为False,在训练时,设置为True
collate_fn=collate_fn,
drop_last=True) # 如果最后剩下的数据不足一个batch_size的数据,则忽略

return train_dataloader, valid_dataloader


if __name__ ` '__main__':
train_dataloader, valid_dataloader = get_data_loader(pc.train_path, pc.valid_path)
print(f'训练集的长度-->{len(train_dataloader)}')
print(f'验证集的长度-->{len(valid_dataloader)}')

# for i, data in enumerate(train_dataloader):
# print(f'第{i}个batch的数据-->{data}')
# break

for i, (input_ids, labels) in enumerate(train_dataloader):
print(f'input_ids-->{input_ids.shape}')
print(f'labels-->{labels.shape}')
print(f'input_ids-->{input_ids}')
print(f'labels-->{labels}')
break

3. 模型搭建

3.1 模型架构介绍

image-20251102002757498

  • 模型架构解析:

    • 输入层:词嵌入层:WordEmbedding +位置嵌入层:PositionEmbedding
    • 中间层:Transformer的Decoder模块—12层
    • 输出层:LayerNorm层 + 线性全连接层
  • 模型主要参数简介(详见模型的config.json文件):

    • n_embd: 768
    • n_head: 12
    • n_layer: 12
    • n_positions: 1024
    • vocab_size: 13317

3.2 GPT2模型准备

  • 本次项目使用GPT2的预训练模型,因此不需要额外搭建Model类,下面代码是如何直接加载使用GPT2预训练模型
  • 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from transformers import GPT2LMHeadModel, GPT2Config

from Gpt2_Chatbot.parameter_config import ParameterConfig

params = ParameterConfig()

# 创建模型
if params.pretrained_model:
# 加载预训练模型
model = GPT2LMHeadModel.from_pretrained(params.pretrained_model)
else:
# 初始化模型
model_config = GPT2Config.from_json_file(params.config_json)
model = GPT2LMHeadModel(config=model_config)

print(model)
  • 如果使用第二种方式,需要配置模型的参数
  • 注意:这里指的参数并不是模型的权重!

位置:llm_tuning/Gpt2_Chatbot/config/config.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
"activation_function": "gelu_new",
"architectures": [
"GPT2LMHeadModel"
],
"attn_pdrop": 0.1,
"bos_token_id": 50256,
"embd_pdrop": 0.1,
"eos_token_id": 50256,
"gradient_checkpointing": false,
"initializer_range": 0.02,
"layer_norm_epsilon": 1e-05,
"model_type": "gpt2",
"n_ctx": 1024,
"n_embd": 768,
"n_head": 12,
"n_inner": null,
"n_layer": 12,
"n_positions": 1024,
"output_past": true,
"resid_pdrop": 0.1,
"summary_activation": null,
"summary_first_dropout": 0.1,
"summary_proj_to_labels": true,
"summary_type": "cls_index",
"summary_use_proj": true,
"task_specific_params": {
"text-generation": {
"do_sample": true,
"max_length": 400
}
},
"tokenizer_class": "BertTokenizer",
"transformers_version": "4.2.0",
"use_cache": true,
"vocab_size": 13317
}

4. 模型训练和验证

代码介绍

image-20250821210441953

代码位置

  • 训练主函数:llm_tuning/Gpt2_Chatbot/train.py

  • 辅助工具类:llm_tuning/Gpt2_Chatbot/functions_tools.py


模型调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
print(f'input_ids-->{input_ids.shape}')  # [8, 49]
print(f'labels-->{labels.shape}') # [8, 49]
# 1.将数据送入模型,得到预测结果和损失
input_ids = input_ids.to(pc.device)
labels = labels.to(pc.device)
'''
在调用大模型时,如果输入里包括了labels参数,此时输出结果里会直接拿到loss损失;如果输入里只有input_ids,此时输出结果里不会含有loss损失
在计算损失时,模型内部会将 logits/labels 自动做位移对齐(logits[..., :-1, :]和labels[..., 1:]),所以我们就可以在处理模型的输入和标签时,它们的实际token是一样的。另外,使用损失计算方式是交叉熵损失CrossEntropyLoss,计算损失时会自动忽略-100。
'''
outputs = model(input_ids, labels=labels)
# print(f'outputs-->{outputs}') # 输出结果中包括 loss 和 logits 和 past_key_values
print(f'outputs-->{outputs.keys()}')
print(f'logits-->{outputs.logits.shape}') # [8, 49, 13317]
print(f'loss-->{outputs.loss}') # 9.59468364

训练技巧

(1)学习率预热

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'''
优化点:学习率预热
学习率预热的目的:让模型在初始阶段更快的使用数据,避免训练过程中学习率过大或过小带来训练不稳定或者收敛速度太慢的问题,从而提高模型训练效果和泛化性能
实现方式:在初始阶段,将学习率从较小的值逐步增加到预设的初始值,然后按照我们设定的训练策略逐渐变小。

get_linear_schedule_with_warmup: 使用这个方法来实现学习率预热,它的方式是从0以线性的方式增大到预设的学习率,然后再以线性的方式逐渐降低到0
参数: optimizer:优化器对象
num_warmup_steps:预热步数,指的是从0增加到预设的学习率所需的步数
num_training_steps: 指的是整个训练过程的总的步数,确切来说在给定的数据集上,参数更新的次数。
'''
# total_steps = len(train_dataloader) * pc.epochs # 不进行梯度累积时,总的训练步数
total_steps = len(train_dataloader) * pc.epochs / pc.gradient_accumulation_steps # 进行梯度累积时,总的训练步数
scheduler = get_linear_schedule_with_warmup(optimizer,
num_warmup_steps=pc.warmup_steps,
num_training_steps=total_steps)

(2)梯度累积

梯度累积的作用:在显卡资源有限,不能训练大的batch_size时,可以使用梯度累积的方式,来实现大的batch_size效果。

比如显卡只能支持batch_size为2,如果想训练batch_size为8,则需要将梯度累积的步数设置为4。

具体使用的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'''
pc.gradient_accumulation_steps = 4 梯度累积的步数
如果设置了梯度累积的步数,并且大于1,此时则需要对损失进行缩放
原因:因为在累积的步数内,损失和梯度都会进行累积,而不是在每次批次结束之后立即更新权重,通过损失除以梯度累积的步数进行缩放,可以使最终得到的结果和实际应用大的batch_size得到的结果一致
'''
if pc.gradient_accumulation_steps > 1:
loss = loss / pc.gradient_accumulation_steps # 对loss进行缩放
# 反向传播(梯度计算)
loss.backward()
# 在到达梯度累积的步数之后,执行参数更新
if (batch_idx + 1) % pc.gradient_accumulation_steps ` 0:
# 梯度更新(参数更新)
optimizer.step()
# 学习率更新
scheduler.step()
# 梯度清零
optimizer.zero_grad()

(3)梯度裁剪

1
2
3
4
5
6
7
# 梯度裁剪
'''
作用: 当max_norm/total_norm < 1时,将梯度乘以缩放系数,从而防止梯度过大带来梯度爆炸或者训练不稳定
使用方式:torch.nn.utils.clip_grad_norm_()
参数:parameters:模型的参数;max_norm:最大的梯度范数,总的梯度范数超过这个值之后,就会进行梯度裁剪
'''
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=pc.max_grad_norm)

trian.py代码

注意点:需要将代码里的没有必要的print、break注释掉;需要将dataloader中,shuffle设置为True!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import os.path
from datetime import datetime

import torch
from torch.optim import AdamW
from tqdm import tqdm
from transformers import GPT2LMHeadModel, GPT2Config, BertTokenizer, get_linear_schedule_with_warmup

from Gpt2_Chatbot.data_preprocess.dataloader import get_data_loader
from Gpt2_Chatbot.functions_tools import calculate_acc
from Gpt2_Chatbot.parameter_config import ParameterConfig

pc = ParameterConfig()


def train_epoch(model, train_dataloader, optimizer, scheduler, epoch):
'''
:param model: 模型
:param train_dataloader: 训练集的dataloader
:param optimizer: 优化器
:param scheduler: 学习率预热的对象
:param epoch: 轮次
:return: 平均损失和平均准确率
'''
print(f"Epoch:{epoch+1} 开始训练...")
# 指明模型训练
model.train()
# 设置一些日志参数
epoch_start_time = datetime.now()
total_loss = 0 # 记录整个epoch的loss之和
total_correct_num = 0 # 记录整个epoch的预测正确的word的数量
total_predict_num = 0 # 记录整个epoch的预测的word的总数量

# 开始训练,遍历数据集
for batch_idx, (input_ids, labels) in enumerate(tqdm(train_dataloader)):
# print(f'input_ids-->{input_ids.shape}') # [8, 49]
# print(f'labels-->{labels.shape}') # [8, 49]
# 1.将数据送入模型,得到预测结果和损失
input_ids = input_ids.to(pc.device)
labels = labels.to(pc.device)
'''
在调用大模型时,如果输入里包括了labels参数,此时输出结果里会直接拿到loss损失;如果输入里只有input_ids,此时输出结果里不会含有loss损失
在计算损失时,模型内部会将 logits/labels 自动做位移对齐(logits[..., :-1, :]和labels[..., 1:]),所以我们就可以在处理模型的输入和标签时,它们的实际token是一样的。另外,使用损失计算方式是交叉熵损失CrossEntropyLoss,计算损失时会自动忽略-100。
'''
outputs = model(input_ids, labels=labels)
# print(f'outputs-->{outputs}') # 输出结果中包括 loss 和 logits 和 past_key_values
# print(f'outputs-->{outputs.keys()}')
# print(f'logits-->{outputs.logits.shape}') # [8, 49, 13317]
# print(f'loss-->{outputs.loss}') # 9.59468364
logits = outputs.logits
loss = outputs.loss

# 累加当前损失到总损失中
total_loss += loss.item()

# 2.进行训练
# # 反向传播(梯度计算)
# loss.backward()
# # 梯度更新(参数更新)
# optimizer.step()
# # 学习率更新
# scheduler.step()
# # 梯度清零
# optimizer.zero_grad()

'''
pc.gradient_accumulation_steps = 4 梯度累积的步数
如果设置了梯度累积的步数,并且大于1,此时则需要对损失进行缩放
原因:因为在累积的步数内,损失和梯度都会进行累积,而不是在每次批次结束之后立即更新权重,通过损失除以梯度累积的步数进行缩放,可以使最终得到的结果和实际应用大的batch_size得到的结果一致
'''
if pc.gradient_accumulation_steps > 1:
loss = loss / pc.gradient_accumulation_steps # 对loss进行缩放
# 反向传播(梯度计算)
loss.backward()
# 梯度裁剪
'''
作用: 当max_norm/total_norm < 1时,将梯度乘以缩放系数,从而防止梯度过大带来梯度爆炸或者训练不稳定
使用方式:torch.nn.utils.clip_grad_norm_()
参数:parameters:模型的参数;max_norm:最大的梯度范数,总的梯度范数超过这个值之后,就会进行梯度裁剪
'''
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=pc.max_grad_norm)

# 在到达梯度累积的步数之后,执行参数更新
if (batch_idx + 1) % pc.gradient_accumulation_steps ` 0:
# 梯度更新(参数更新)
optimizer.step()
# 学习率更新
scheduler.step()
# 梯度清零
optimizer.zero_grad()

# 3.统计指标并打印
# 统计该batch的预测token的正确数与总数
batch_correct_num, batch_total_num = calculate_acc(logits, labels, ignore_index=pc.ignore_index)
# print(f'batch_correct_num-->{batch_correct_num}')
# print(f'batch_total_num-->{batch_total_num}')
total_correct_num += batch_correct_num
total_predict_num += batch_total_num

# 每隔 pc.loss_step 批次打印一次训练结果
if (batch_idx + 1) % pc.loss_step ` 0:
print(f'epoch: {epoch+1}, batch: {batch_idx+1}, '
f'loss: {loss.item() * pc.gradient_accumulation_steps}, '
f'batch_acc: {batch_correct_num/batch_total_num}, lr: {scheduler.get_last_lr()[0]}')

break

# 整个轮次训练完成后,统计整个epoch的指标
epoch_mean_loss = total_loss / len(train_dataloader)
epoch_mean_acc = total_correct_num / total_predict_num
print(f"第{epoch+1}轮次训练结束,平均的损失为{epoch_mean_loss},平均的准确率为{epoch_mean_acc}")
print('完成本轮次训练所花时间为: {}'.format(datetime.now() - epoch_start_time))
return epoch_mean_loss, epoch_mean_acc

def valid_epoch(model, valid_dataloader, epoch):
'''
:param model: 模型
:param valid_dataloader: 验证集的dataloader
:param epoch: 轮次
:return: 平均损失和平均准确率
'''
print(f"Epoch:{epoch + 1} 开始评估...")
# 指明模型评估模式
model.eval()
# 设置一些日志参数
epoch_start_time = datetime.now()
total_loss = 0 # 记录整个epoch的loss之和
total_correct_num = 0 # 记录整个epoch的预测正确的word的数量
total_predict_num = 0 # 记录整个epoch的预测的word的总数量

# 开始评估,遍历数据集
with torch.no_grad(): # 禁止计算梯度,来节省显存
for batch_idx, (input_ids, labels) in enumerate(tqdm(valid_dataloader)):
# 1.将数据送入模型,得到预测结果和损失
input_ids = input_ids.to(pc.device)
labels = labels.to(pc.device)
outputs = model(input_ids, labels=labels)
logits = outputs.logits
loss = outputs.loss

# 2.统计指标并打印
total_loss += loss.item()
# 统计该batch的预测token的正确数与总数
batch_correct_num, batch_total_num = calculate_acc(logits, labels, ignore_index=pc.ignore_index)
total_correct_num += batch_correct_num
total_predict_num += batch_total_num

# 整个轮次评估完成后,统计整个epoch的指标
epoch_mean_loss = total_loss / len(valid_dataloader)
epoch_mean_acc = total_correct_num / total_predict_num
print(f"第{epoch+1}轮次评估结束,平均的损失为{epoch_mean_loss},平均的准确率为{epoch_mean_acc}")
print('完成本轮次评估所花时间为: {}'.format(datetime.now() - epoch_start_time))
return epoch_mean_loss, epoch_mean_acc

def model2train():
# todo:1 加载数据(训练集和验证集)
train_dataloader, valid_dataloader = get_data_loader(pc.train_path, pc.valid_path)
print(f'训练集的长度-->{len(train_dataloader)}')
print(f'验证集的长度-->{len(valid_dataloader)}')
# todo:2 加载模型
# 2.1 创建模型
if pc.pretrained_model:
# 加载预训练模型
model = GPT2LMHeadModel.from_pretrained(pc.pretrained_model)
else:
# 初始化模型
model_config = GPT2Config.from_json_file(pc.config_json)
model = GPT2LMHeadModel(config=model_config)
print(f'model-->{model}')

# 2.2 将模型放到指定设备上
model.to(pc.device)

# 2.3 需要对比tokenizer的词表大小和模型的词表大小是否一致
tokenizer = BertTokenizer.from_pretrained(pc.vocab_path)
# print(f'tokenizer.vocab_size-->{tokenizer.vocab_size}')
# print(f'model.config.vocab_size-->{model.config.vocab_size}')
assert tokenizer.vocab_size ` model.config.vocab_size, '模型和tokenizer的词表大小不一致'

# todo:3 设置训练的组件
# 优化器对象
optimizer = AdamW(model.parameters(), lr=pc.lr, eps=pc.eps)
'''
优化点:学习率预热
学习率预热的目的:让模型在初始阶段更快的使用数据,避免训练过程中学习率过大或过小带来训练不稳定或者收敛速度太慢的问题,从而提高模型训练效果和泛化性能
实现方式:在初始阶段,将学习率从较小的值逐步增加到预设的初始值,然后按照我们设定的训练策略逐渐变小。

get_linear_schedule_with_warmup: 使用这个方法来实现学习率预热,它的方式是从0以线性的方式增大到预设的学习率,然后再以线性的方式逐渐降低到0
参数: optimizer:优化器对象
num_warmup_steps:预热步数,指的是从0增加到预设的学习率所需的步数
num_training_steps: 指的是整个训练过程的总的步数,确切来说在给定的数据集上,参数更新的次数。
'''
# total_steps = len(train_dataloader) * pc.epochs # 不进行梯度累积时,总的训练步数
total_steps = len(train_dataloader) * pc.epochs / pc.gradient_accumulation_steps # 进行梯度累积时,总的训练步数
scheduler = get_linear_schedule_with_warmup(optimizer,
num_warmup_steps=pc.warmup_steps,
num_training_steps=total_steps)

# todo:4 设置训练的参数
train_losses = [] # 存储训练过程中,每轮训练的loss
valid_losses = [] # 存储验证过程中,每轮验证的loss
best_valid_loss = float('inf') # 最佳验证集loss
train_correct_rate = [] # 存储训练过程中,每轮训练的预测准确率
valid_correct_rate = [] # 存储验证过程中,每轮验证的预测准确率
# todo:5 进行轮次训练
for epoch in range(pc.epochs):
# todo: 5.1 模型训练
train_loss, train_correct = train_epoch(model, train_dataloader, optimizer, scheduler, epoch)
train_losses.append(train_loss)
train_correct_rate.append(train_correct)
# todo: 5.2 模型评估
valid_loss, valid_correct = valid_epoch(model, valid_dataloader, epoch)
valid_losses.append(valid_loss)
valid_correct_rate.append(valid_correct)
# todo: 5.3 保存模型
if valid_loss < best_valid_loss:
print(f'当前最好的模型是第{epoch}轮次的,损失为{valid_loss}。')
best_valid_loss = valid_loss # 更新最小的验证集损失
# 当前这个模型是更好的,需要进行保存
save_path = os.path.join(pc.save_model_path, 'best_model')
if not os.path.exists(save_path):
os.mkdir(save_path)
model.save_pretrained(save_path)

print(f'train_losses-->{train_losses}')
print(f'valid_losses-->{valid_losses}')
print(f'train_correct_rates-->{train_correct_rate}')
print(f'valid_correct_rates-->{valid_correct_rate}')


if __name__ ` '__main__':
model2train()

补充知识点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import torch

labels = torch.tensor([12, 1, 45, -100])
ignore_index = -100
max_index = torch.tensor([12, 8, 45, 65])

not_pad = labels.ne(ignore_index)
print(f'not_pad--->{not_pad}')

correct_position = max_index.eq(labels)
print(f'correct_position--->{correct_position}')

correct_num = correct_position.masked_select(not_pad)
print(f'correct_num--->{correct_num}')
print(f'正确的个数--->{correct_num.sum()}')
print(f'总的个数--->{not_pad.sum()}')

image-20250824164122110

functions_tools.py代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def calculate_acc(logits, labels, ignore_index=-100):
'''
计算模型预测结果 准确的word个数和总的word个数,这些个数中都是不包含-100标签所在的位置的
:param logits: 模型的预测输出
:param labels: 真实标签
:param ignore_index: 在计算时,需要忽略的标签索引,默认为-100
:return: correct_num 预测正确的个数, total_num 总的个数
'''
# print(f'原始logits-->{logits.shape}') # [8, 49, 13317]
# print(f'原始labels-->{labels.shape}') # [8, 49]
# 1.处理输入数据,去掉预测结果的最后一个字符,去掉标签的第一个字符
logits = logits[:, :-1, :]
labels = labels[:, 1:]
# print(f'位移后的logits-->{logits.shape}')
# print(f'位移后的labels-->{labels.shape}')
# 为了方便后续的计算,需要将 logits 转换成2维,而 labels 转换成1维
logits = logits.contiguous().view(-1, logits.shape[-1])
labels = labels.contiguous().view(-1)
# print(f'降维后的logits-->{logits.shape}')
# print(f'降维后的labels-->{labels.shape}')

# 2.取出最大的概率值,并返回最大概率的索引
max_index = logits.argmax(dim=-1)
# print(f'最大概率的索引-->{max_index.shape, max_index}')

# 3.计算预测正确的个数和总的个数
# print(f'labels--->{labels}')
# ne()的作用是判断两个张量的元素是否相等,返回一个布尔张量,如果两个元素相等,返回False,否则返回True
not_pad = labels.ne(ignore_index)
# print(f'not_pad--->{not_pad}')

# eq()的作用是判断两个张量的元素是否相等,返回一个布尔张量,如果两个元素相等,返回True,否则返回False
correct_position = max_index.eq(labels)
# print(f'correct_position--->{correct_position}')

# 需要correct_position中取出-100位置的数据,也就是说只取 pad_mask 为True的数据
# masked_select(boolen张量) 将boolen张量中为True的数据取出来
correct_num = correct_position.masked_select(not_pad).sum()
# print(f'correct_position.masked_select(not_pad)--->{correct_position.masked_select(not_pad)}')
# print(f'correct_num--->{correct_num}')

# 总的个数
total_num = not_pad.sum()
return correct_num, total_num

5. 模型预测(人机交互)

  • 使用训练好的模型,进行人机交互,输入Ctrl+Z结束对话之后,聊天记录将保存到sample目录下的sample.txt文件中。

思路:

image-20250824201058908

代码位置:llm_tuning/Gpt2_Chatbot/interact.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import os

import torch
import torch.nn.functional as F
from transformers import GPT2LMHeadModel, BertTokenizer

from Gpt2_Chatbot.parameter_config import ParameterConfig

pc = ParameterConfig()

def top_k_top_p_filtering(logits, top_k=0, top_p=0.0, filter_value=-float('Inf')):
'''
使用top-k和/或nucleus(top-p)筛选来过滤logits的分布
:param logits: 最后一个token的logits的分布,形状为(词汇大小)
:param top_k: top_k > 0: 保留概率最高的top k个token(top-k筛选)
:param top_p: top_p > 0.0: 保留累积概率大于等于top_p的top token(nucleus筛选)
:param filter_value: 极小值
:return: logits: 过滤后的logits分布,其中低概率标记被设置为 filter_value。
'''
# 确保logits的维度为1,这里只处理批量大小为1的情况。
assert logits.dim() ` 1
# 对top_k值进行安全性检查,防止它超过logits最后一个维度的大小,避免运行时错误。
top_k = min(top_k, logits.size(-1))

if top_k > 0:
# 移除概率小于top_k标记
# torch.topk()返回最后一维中最大的top_k个元素,返回值为二维(values, indices)
# print(f'torch.topk(logits, top_k)--->{torch.topk(logits, top_k)}')
# print(f'torch.topk(logits, top_k)[0]-->{torch.topk(logits, top_k)[0]}')
# print(f'torch.topk(logits, top_k)[0][-1]-->{torch.topk(logits, top_k)[0][-1]}')
# 判断logits的值,如果小于top_k标记,则设置为filter_value
indices_to_remove = logits < torch.topk(logits, top_k)[0][-1]
# print(f'indices_to_remove--->{indices_to_remove}')
# print(f'logits--->{logits}')
logits[indices_to_remove] = filter_value # 对于topk之外的其他元素的logits值设为负无穷
# print(f'过滤后的logits--->{logits}')

if top_p > 0.0:
sorted_logits, sorted_indices = torch.sort(logits, descending=True) # 对logits进行递减排序
# print(f'sorted_logits-->{sorted_logits}')
# print(f'sorted_indices-->{sorted_indices}')

# F.softmax(sorted_logits, dim=-1):将排序后的 logits 转为概率分布
# torch.cumsum(..., dim=-1):计算累积概率
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)

# 对应位置为 True 的 token 是 累积概率超过 top_p 阈值的 token,通常需要被移除
sorted_indices_to_remove = cumulative_probs > top_p
# print(f'sorted_indices_to_remove1-->{sorted_indices_to_remove}')
# 将索引向右移动,以确保即使第一个token超过阈值也能保留
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() # 将索引向右移动一位
sorted_indices_to_remove[..., 0] = 0 # 将第一个token设为False,确保第一个 token 被保留
# print(f'sorted_indices_to_remove2--->{sorted_indices_to_remove}')

# 将需要移除的token设置为filter_value。
indices_to_remove = sorted_indices[sorted_indices_to_remove]
# print(f'indices_to_remove--->{indices_to_remove}')
logits[indices_to_remove] = filter_value
# print(f'logits--->{logits}')
return logits


def model2predict():
# 1、加载模型
model_path = os.path.join(pc.save_model_path, 'best_model')
model = GPT2LMHeadModel.from_pretrained(model_path).to(pc.device)
# 将模型设置为预测模式
model.eval()

# 加载分词器
tokenizer = BertTokenizer.from_pretrained(pc.vocab_path)

# 将用户和机器人的对话保存到文件中
f = open(os.path.join(pc.save_samples_path, 'samples.txt'), 'w', encoding='utf-8')


# 2、处理数据,送入模型预测
# 2.1 需要保存用户上下文,将上下文一起送入模型中
# 使用history列表来存储用户的问题和模型的输出
history = []

while True:
# 获取用户输入
user_input = input('请输入问题:')
# print(f'user_input-->{user_input}')
# 将用户的问题写入文件中
f.write('用户问题:' + user_input + '\n')
query_ids = tokenizer.encode('用户问题:' + user_input, add_special_tokens=False)
# print(f'query_ids-->{query_ids}')
# 将问题ids加入到history中
history.append(query_ids)
# print(f'history-->{history}')

# 2.2每次送入大模型时,需要将用户的问题和模型的输出拼接到一起送入大模型
input_ids = [tokenizer.cls_token_id] # 给大模型的输入需要以[CLS]开头
# 遍历history列表,将列表中的 用户问题 和 模型输出 拼接成输入,送给大模型
for ids in history[-pc.max_history_len:]: # 为了防止输入过长,只取最后5个历史记录
input_ids.extend(ids)
# 在每个句子结束之后,添加[SEP]作为分割
input_ids.append(tokenizer.sep_token_id)
# print(f'遍历完history的input_ids-->{input_ids}')

# 把数据转为张量
input_ids = torch.tensor(input_ids).long().to(pc.device)
# 2.3 送入模型进行预测
# 初始化一个空列表,用来存储模型输出的ids
outputs_ids = []

for i in range(pc.max_len):
outputs = model(input_ids).logits
# print(f'outputs-->{outputs.shape}')
# 直接取最后一行的结果,即为模型输出的概率分布
next_token_logits = outputs[-1, :]
# print(f'next_token_logits-->{next_token_logits.shape}')
# print(f'next_token_logits-->{next_token_logits}')
# 2.4 模型预测结果的选择
# 1)对于已经生成的结果中的每个token添加一个重复惩罚项,降低其生成概率,从而缓解复读机问题。
for id in set(outputs_ids):
# print(f'id-->{id}')
# print(f'next_token_logits[id]-->{next_token_logits[id]}')
if next_token_logits[id] > 0:
next_token_logits[id] /= pc.repetition_penalty
else: # 如果这个logits为负数,则需要乘以重复惩罚项
next_token_logits[id] *= pc.repetition_penalty
# 2)为了避免预测结果中出现[UNK]字符,需要将[UNK]这个token的logits设为无穷小。
next_token_logits[tokenizer.unk_token_id] = -float('Inf')
# 3)使用了top_k_top_p_filtering策略,这个策略的作用是根据给定的top_k参数和top_p参数,选出前几个高概率的预测结果,并且累积概率不超过top_p。这样做的一个效果就是:可以保证文本生成质量的同时,具有一定的多样性。具体实现时,就是将不符合要求的token的概率设置成无穷小。
filtered_logits = top_k_top_p_filtering(next_token_logits, top_k=pc.topk, top_p=pc.topp)

# 4)使用torch.multinomial方法,对预测结果中的token进行随机取样,这个函数的特性是,token的概率越大,被抽取的几率就越高。
next_token = torch.multinomial(F.softmax(filtered_logits, dim=-1), num_samples=1)
# print(f'next_token-->{next_token}')

# 如果预测结果是[SEP],则说明已经生成了一个完整的句子,可以结束预测
if next_token.item() ` tokenizer.sep_token_id:
break

# 将预测结果加入到outputs_ids中
outputs_ids.append(next_token.item())

# 将 next_token 和 原来的 input_ids 连接起来,作为下一次输入
input_ids = torch.cat([input_ids, next_token], dim=-1)

# print(f'outputs_ids-->{outputs_ids}')
# 将模型的回复加到history中
model_ids = tokenizer.encode("模型回答:", add_special_tokens=False)
# print(f'model_ids+outputs_ids-->{model_ids+outputs_ids}')
history.append(model_ids+outputs_ids)

# 将预测结果ids转为文本
answer = tokenizer.convert_ids_to_tokens(outputs_ids)
# print(f'answer-->{answer}')
# 打印机器人的回复
answer_sentence = ''.join(answer)
f.write('模型回答:' + answer_sentence + '\n')
print('模型回答:', answer_sentence)

if __name__ ` '__main__':
model2predict()

6. 基于Flask框架web开发(了解)

  • 对interact.py进行调整, 去除while无限循环,由前端保存history,只需要对传入的句子进行预测即可。

代码位置:llm_tuning/Gpt2_Chatbot/flask_predict.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import os

import torch
import torch.nn.functional as F
from transformers import GPT2LMHeadModel, BertTokenizer

from Gpt2_Chatbot.parameter_config import ParameterConfig

pc = ParameterConfig()

def top_k_top_p_filtering(logits, top_k=0, top_p=0.0, filter_value=-float('Inf')):
'''
使用top-k和/或nucleus(top-p)筛选来过滤logits的分布
:param logits: 最后一个token的logits的分布,形状为(词汇大小)
:param top_k: top_k > 0: 保留概率最高的top k个token(top-k筛选)
:param top_p: top_p > 0.0: 保留累积概率大于等于top_p的top token(nucleus筛选)
:param filter_value: 极小值
:return: logits: 过滤后的logits分布,其中低概率标记被设置为 filter_value。
'''
# 确保logits的维度为1,这里只处理批量大小为1的情况。
assert logits.dim() ` 1
# 对top_k值进行安全性检查,防止它超过logits最后一个维度的大小,避免运行时错误。
top_k = min(top_k, logits.size(-1))

if top_k > 0:
# 移除概率小于top_k标记
# torch.topk()返回最后一维中最大的top_k个元素,返回值为二维(values, indices)
# print(f'torch.topk(logits, top_k)--->{torch.topk(logits, top_k)}')
# print(f'torch.topk(logits, top_k)[0]-->{torch.topk(logits, top_k)[0]}')
# print(f'torch.topk(logits, top_k)[0][-1]-->{torch.topk(logits, top_k)[0][-1]}')
# 判断logits的值,如果小于top_k标记,则设置为filter_value
indices_to_remove = logits < torch.topk(logits, top_k)[0][-1]
# print(f'indices_to_remove--->{indices_to_remove}')
# print(f'logits--->{logits}')
logits[indices_to_remove] = filter_value # 对于topk之外的其他元素的logits值设为负无穷
# print(f'过滤后的logits--->{logits}')

if top_p > 0.0:
sorted_logits, sorted_indices = torch.sort(logits, descending=True) # 对logits进行递减排序
# print(f'sorted_logits-->{sorted_logits}')
# print(f'sorted_indices-->{sorted_indices}')

# F.softmax(sorted_logits, dim=-1):将排序后的 logits 转为概率分布
# torch.cumsum(..., dim=-1):计算累积概率
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)

# 对应位置为 True 的 token 是 累积概率超过 top_p 阈值的 token,通常需要被移除
sorted_indices_to_remove = cumulative_probs > top_p
# print(f'sorted_indices_to_remove1-->{sorted_indices_to_remove}')
# 将索引向右移动,以确保即使第一个token超过阈值也能保留
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() # 将索引向右移动一位
sorted_indices_to_remove[..., 0] = 0 # 将第一个token设为False,确保第一个 token 被保留
# print(f'sorted_indices_to_remove2--->{sorted_indices_to_remove}')

# 将需要移除的token设置为filter_value。
indices_to_remove = sorted_indices[sorted_indices_to_remove]
# print(f'indices_to_remove--->{indices_to_remove}')
logits[indices_to_remove] = filter_value
# print(f'logits--->{logits}')
return logits


# 1、加载模型
model_path = os.path.join(pc.save_model_path, 'best_model')
model = GPT2LMHeadModel.from_pretrained(model_path).to(pc.device)
# 将模型设置为预测模式
model.eval()

# 加载分词器
tokenizer = BertTokenizer.from_pretrained(pc.vocab_path)

def model_predict(text, history):
# 2.1 获取用户输入
query_ids = tokenizer.encode('用户问题:' + text, add_special_tokens=False)
# print(f'query_ids-->{query_ids}')
# 将问题ids加入到history中
history.append(query_ids)
# print(f'history-->{history}')

# 2.2每次送入大模型时,需要将用户的问题和模型的输出拼接到一起送入大模型
input_ids = [tokenizer.cls_token_id] # 给大模型的输入需要以[CLS]开头
# 遍历history列表,将列表中的 用户问题 和 模型输出 拼接成输入,送给大模型
for ids in history[-pc.max_history_len:]: # 为了防止输入过长,只取最后5个历史记录
input_ids.extend(ids)
# 在每个句子结束之后,添加[SEP]作为分割
input_ids.append(tokenizer.sep_token_id)
# print(f'遍历完history的input_ids-->{input_ids}')

# 把数据转为张量
input_ids = torch.tensor(input_ids).long().to(pc.device)
# 2.3 送入模型进行预测
# 初始化一个空列表,用来存储模型输出的ids
outputs_ids = []

for i in range(pc.max_len):
outputs = model(input_ids).logits
# print(f'outputs-->{outputs.shape}')
# 直接取最后一行的结果,即为模型输出的概率分布
next_token_logits = outputs[-1, :]
# print(f'next_token_logits-->{next_token_logits.shape}')
# print(f'next_token_logits-->{next_token_logits}')
# 2.4 模型预测结果的选择
# 1)对于已经生成的结果中的每个token添加一个重复惩罚项,降低其生成概率,从而缓解复读机问题。
for id in set(outputs_ids):
# print(f'id-->{id}')
# print(f'next_token_logits[id]-->{next_token_logits[id]}')
if next_token_logits[id] > 0:
next_token_logits[id] /= pc.repetition_penalty
else: # 如果这个logits为负数,则需要乘以重复惩罚项
next_token_logits[id] *= pc.repetition_penalty
# 2)为了避免预测结果中出现[UNK]字符,需要将[UNK]这个token的logits设为无穷小。
next_token_logits[tokenizer.unk_token_id] = -float('Inf')
# 3)使用了top_k_top_p_filtering策略,这个策略的作用是根据给定的top_k参数和top_p参数,选出前几个高概率的预测结果,并且累积概率不超过top_p。这样做的一个效果就是:可以保证文本生成质量的同时,具有一定的多样性。具体实现时,就是将不符合要求的token的概率设置成无穷小。
filtered_logits = top_k_top_p_filtering(next_token_logits, top_k=pc.topk, top_p=pc.topp)

# 4)使用torch.multinomial方法,对预测结果中的token进行随机取样,这个函数的特性是,token的概率越大,被抽取的几率就越高。
next_token = torch.multinomial(F.softmax(filtered_logits, dim=-1), num_samples=1)
# print(f'next_token-->{next_token}')

# 如果预测结果是[SEP],则说明已经生成了一个完整的句子,可以结束预测
if next_token.item() ` tokenizer.sep_token_id:
break

# 将预测结果加入到outputs_ids中
outputs_ids.append(next_token.item())

# 将 next_token 和 原来的 input_ids 连接起来,作为下一次输入
input_ids = torch.cat([input_ids, next_token], dim=-1)

# print(f'outputs_ids-->{outputs_ids}')
# 将模型的回复加到history中
model_ids = tokenizer.encode("模型回答:", add_special_tokens=False)
# print(f'model_ids+outputs_ids-->{model_ids+outputs_ids}')
history.append(model_ids+outputs_ids)

# 将预测结果ids转为文本
answer = tokenizer.convert_ids_to_tokens(outputs_ids)
# print(f'answer-->{answer}')
# 打印机器人的回复
answer_sentence = ''.join(answer)

return answer_sentence, history

if __name__ ` '__main__':
text = '你好'
history = []
answer_sentence, history = model_predict(text, history)
print(f'answer_sentence-->{answer_sentence}')
print(f'history-->{history}')

text = '头疼怎么办'
answer_sentence, history = model_predict(text, history)
print(f'answer_sentence-->{answer_sentence}')
print(f'history-->{history}')
  • 基于Flask框架的web后端接口

这部分可以用大模型生成,写好提示词即可。

1
2
3
4
5
6
7
8
9
使用大模型生成web前后端代码, 描述如下:
现在你是一个代码专家, 目前已经训练好了一个模型, 并且已经将该模型进行了封装, 函数名为model_predict(), 该函数传入参数为text, history,其中text为用户问题,history为一个列表,用来保存上下文信息;返回值为response, history,其中response为预测结果,history为列表。

现在需要你基于Flask框架, 对该函数进行API接口封装制作, 并且希望能够制作一个简单的web界面, 界面的主要功能呈现如下:
1. 用户输入问题和history列表, 然后返回预测结果和history。这个history不需要前端往里边添加内容,只需要保存这个变量即可。在前端页面刷新时,这个变量清空为[]。
2. 将用户和模型的聊天信息保留在页面上展示
3. 页面标题名称叫做:黑马医疗问诊机器人
4. 页面标题和输入对话框布局要对称, 并且用不同的颜色渲染
请给出详细的app.py和index.html的代码

代码位置:llm_tuning/Gpt2_Chatbot/app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from flask import Flask, request, jsonify, render_template
from Gpt2_Chatbot.flask_predict import model_predict

app = Flask(__name__)

# API 接口
@app.route('/api/predict', methods=['POST'])
def predict():
data = request.json or {}
question = data.get('question', '')
print(f'Question-->{question}')
# 前端传过来一个 history 列表(例如:[[872, 1962], [872, 1962, 8024, 6821...]])
history = data.get('history', [])
print(f'History-->{history}')

# 调用模型预测,假设 model_predict 返回 (answer, new_history)
answer, new_history = model_predict(question, history=history)

# 把 answer 和更新后的 history 一并返回(前端保存并更新history,企业中也可以将history其保存到数据库或文件中)
return jsonify({'question': question, 'answer': answer, 'history': new_history})

# Web 界面
@app.route('/')
def index():
return render_template('index.html')


if __name__ ` '__main__':
app.run(debug=True)

  • web前端代码

代码位置:llm_tuning/Gpt2_Chatbot/templates/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>黑马医疗问诊机器人</title>
<style>
body {
font-family: 'Arial', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: linear-gradient(135deg, #4b6cb7, #182848);
color: white;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.chat-container {
background-color: white;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
height: 400px;
overflow-y: auto;
}
.message {
margin-bottom: 15px;
padding: 10px 15px;
border-radius: 18px;
max-width: 70%;
word-wrap: break-word;
}
.user-message {
background-color: #e3f2fd;
margin-left: auto;
border-bottom-right-radius: 5px;
}
.bot-message {
background-color: #f1f1f1;
margin-right: auto;
border-bottom-left-radius: 5px;
}
.input-container {
display: flex;
gap: 10px;
}
#user-input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 16px;
}
#send-button {
padding: 12px 20px;
background-color: #4b6cb7;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
#send-button:hover {
background-color: #3a56a1;
}
.timestamp {
font-size: 12px;
color: #777;
margin-top: 5px;
text-align: right;
}
</style>
</head>
<body>
<div class="header">
<h1>黑马医疗问诊机器人</h1>
<p>您的智能健康顾问</p>
</div>

<div class="chat-container" id="chat-box">
<!-- 初始系统消息 -->
<div class="message bot-message">
您好!我是黑马小健康助手,请问有什么健康问题可以帮您解答?
<div class="timestamp">系统消息</div>
</div>
</div>

<div class="input-container">
<input type="text" id="user-input" placeholder="请输入您的健康问题..." autofocus>
<button id="send-button">发送</button>
</div>

<script>
// 每次刷新页面 history 都从空开始
let history = [];

document.getElementById('send-button').addEventListener('click', sendMessage);
document.getElementById('user-input').addEventListener('keypress', function(e) {
if (e.key `= 'Enter') sendMessage();
});

function sendMessage() {
const userInput = document.getElementById('user-input');
const question = userInput.value.trim();
if (question `= '') return;

// 显示用户消息
addMessage(question, 'user');
userInput.value = '';

// 把当前 history 发给后端
fetch('/api/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: question, history: history })
})
.then(response => response.json())
.then(data => {
// 显示机器人回答
addMessage(data.answer, 'bot');

// 更新 history
if (Array.isArray(data.history)) {
history = data.history;
} else if (Array.isArray(data.new_history)) {
history = data.new_history;
} else {
history.push([question, data.answer]);
}
})
.catch(error => {
console.error('Error:', error);
addMessage('抱歉,服务暂时不可用,请稍后再试。', 'bot');
});
}

function addMessage(text, sender) {
const chatBox = document.getElementById('chat-box');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}-message`;

const now = new Date();
const timeString = now.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});

messageDiv.innerHTML = `
${text}
<div class="timestamp">${sender `= 'user' ? '您' : '小健康助手'} · ${timeString}</div>
`;

chatBox.appendChild(messageDiv);
chatBox.scrollTop = chatBox.scrollHeight;
}
</script>

</body>
</html>
  • 运行app.py文件, 效果如下:

P03_新零售行业评价决策系统

一、项目介绍【理解】

1、项目背景

  • 随着科技的迅速发展和智能设备的普及,AI技术在新零售行业中得到了广泛应用。其中 智能推荐系统 是AI技在新零售中最为常见且有效的应用之一。通过分析用户的购买历史、浏览行为以及喜好偏好,推荐系统可以根据个人特征给用户进行个性化商品推荐。这种个性化推荐不仅可以提高用户购买意愿,减少信息过载,还可以带来更高的用户满意度和销量。
  • 在智能推荐系统中,文本分类的应用属于重要的应用环节。比如:某电商网站都允许用户为商品填写评论,这些文本评论能够体现出用户的偏好以及商品特征信息,是一种语义信息丰富的隐式特征。 相比于单纯的利用显式评分特征,文本信息一方面可以弥补评分稀疏性的问题,另一方面在推荐系统的可解释方面也能够做的更好。
  • 因此,本次项目我们将 以”电商平台用户评论”为背景,基于深度学习方法实现评论文本的准确分类 ,这样做的目的是通过用户对不同商品或服务的评价,平台能够快速回应用户需求,改进产品和服务。同时,自动分类也为个性化推荐奠定基础,帮助用户更轻松地找到符合其偏好的商品。

2、评论文本分类实现方法

2.1 传统的深度学习方法

  • 目前实现文本分类的方法很多,如经典的应用于文本的卷积神经网络(Text-CNN)、循环神经网络(Text-RNN)、基于BERT等预训练模型的fine-tuning等,但是这些方法多为建立在具有大量的标注数据下的有监督学习。在很多实际场景中,由于领域特殊性和标注成本高,导致标注训练数据缺乏,模型无法有效地学习参数,从而易出现过拟合现象。因此,如何 通过小样本数据训练得到一个性能较好的分类模型 是目前的研究热点。

2.2 模型微调方法

  • 基于前面章节的介绍,我们可以借助Prompt-Tuning的技术,来实现模型部分参数的微调(当然如果模型参数较小比如BERT,也可以全量参数微调),相比传统技术方法,Prompt-Tuning方法可以实现在较少样本的训练上,就可以达到较好的结果。
  • 在本次项目中,我们将分别基于 BERT+PET(硬模版)以及BERT+P-Tuning(软模版) 两种方式实现用户评论文本的分类。重点是理解prompt的构造方法,以及promt-tuning方法的实现原理。

二、BERT+PET方式介绍【理解】

1、PET回顾

  • PET(PatternExploiting Training)的核心思想是:根据先验知识人工定义模版,将目标分类任务转换为与MLM一致的完形填空,然后再去微调MLM任务参数。

图中示例1: 情感分类任务(好评还是差评),原始文本:这家店真不错,值得推荐。PET模板: [MASK]满意。Label:不/很。标签词映射(Label Word Verbalizer):例如如果[MASK]预测的词是“不”,则认为是差评类,如果是“很”,则认为是好评类。

图中示例2:新闻分类任务(多分类),原始文本:中国女排再夺冠!PET模版:下面是[MASK] [MASK]新闻,Label:体育/财经/时政/军事


  • PET 方法的核心步骤

PET方法整体过程可以概括为:首先,将下游任务通过人工模板(pattern)转化为语言模型的填空任务,并通过verbalizer把预测的词映射到任务标签,从而用少量标注样本训练多个“子模型”;接着,这些子模型在大量未标注数据上生成伪标签,形成软标注数据;最后,通过知识蒸馏,训练一个单一的学生模型来学习多个子模型的预测分布,从而兼顾鲁棒性和泛化能力。在这里,我们只需要完成分类任务,所以只需要实现第一步即可。

具体步骤如下:

1)定义任务模式 (Task Patterns):

  • 首先,你需要将下游任务的输入和输出,转换为一种 包含空白([MASK] 或其他特殊标记)的自然语言句子模板 。这些模板被称为“模式”。
  • 示例 (情感分类):
    • 原始输入:这部电影太棒了! 标签:积极
    • POFT 模式:这部电影太棒了!这是一部____的电影。 (其中 ____ 是待填充的空白)
  • 示例 (问题回答 - 抽取式):
    • 原始输入:上下文:北京是中国的首都。问题:中国的首都是哪里? 答案:北京
    • POFT 模式:根据上下文:北京是中国的首都。中国的首都是哪里?答案是____。

2) 定义标签映射 (Verbalizer):

  • 对于任务的每个标签(或答案),你需要将其映射到 PLM(预训练语言模型) 词汇表中的一个或多个 具体词汇
  • 示例 (情感分类):
    • 积极, , 优秀
    • 消极, , 糟糕
  • 示例 (问题回答): 答案本身就是模型需要生成的词语。

3) 构造训练样本:

  • 将你的 所有有标签的训练数据 ,根据定义的模式和标签映射进行转换。
  • 对于每个样本,输入变成模式化的句子,而模型的训练目标是在空白处生成正确的 Verbalizer 词汇(或答案词汇)。

4) 全量微调 PLM:

  • 在这些 模式化转换后的训练数据 上,对 整个预训练语言模型进行全量微调
  • 微调的目标函数通常是 交叉熵损失 ,旨在最大化模型在空白处预测正确 Verbalizer 词汇的概率。这实际上是回归到 PLM 预训练时的 语言模型目标 (如掩码语言模型或文本生成)。
    • 区别于 Prompt Engineering: PFT 在这里 更新模型的所有参数 ,而不仅仅是 Prompt 向量。
    • 区别于传统 Fine-tuning: 传统 Fine-tuning 可能是在 PLM 上添加一个专门的分类头或抽取层进行微调。而 POFT 则是让 PLM 通过 预测词汇 来完成任务,更接近其预训练的方式。

5) 推理阶段:

  • 对于新的输入,同样通过模式进行转换。
  • 将转换后的输入送入微调后的 PLM。
  • 模型会在空白处生成最可能的词汇。通过 Verbalizer,将这些预测的词汇反向映射回任务的原始标签或答案。
    • 例如,如果模型预测 的概率最高,就将其映射为 积极

2、 环境准备

本项目基于 torch+ transformers 实现,运行前请安装相关依赖包:

  • python`3.10
  • transformers`4.40.2
  • torch`2.5.1+cu121
  • datasets`3.6.0
  • scikit-learn`1.7.0

3、项目架构

项目架构流程图:

项目整体代码介绍:

image-20250818044920669

三、BERT+PET方式数据预处理【理解】

  • 本项目中对数据部分的预处理步骤如下:
    1. 查看项目数据集
    2. 编写Config类项目文件配置代码
    3. 编写数据处理相关代码

1、查看项目数据集

  • 数据存放位置:llm_tuning/prompt_tasks/PET/data

  • data文件夹里面包含4个txt文档,分别为:train.txt、dev.txt、prompt.txt、verbalizer.txt


1.1 train.txt

  • train.txt为训练数据集,其部分数据展示如下:
1
2
3
4
5
6
水果	脆脆的,甜味可以,可能时间有点长了,水分不是很足。
平板 华为机器肯定不错,但第一次碰上京东最糟糕的服务,以后不想到京东购物了。
书籍 为什么不认真的检查一下, 发这么一本脏脏的书给顾客呢!
衣服 手感不错,用料也很好,不知道水洗后怎样,相信大品牌,质量过关,五星好评!!!
水果 苹果有点小,不过好吃,还有几个烂的。估计是故意的放的。差评。
衣服 掉色掉的厉害,洗一次就花了

train.txt一共包含63条样本数据,每一行用\t分开,前半部分为标签(label),后半部分为原始输入 (用户评论)。

如果想使用自定义数据训练,只需要仿照上述示例数据构建数据集即可。


1.2 dev.txt

  • dev.txt为验证数据集,其部分数据展示如下:
1
2
3
4
5
6
书籍	"一点都不好笑,很失望,内容也不是很实用"
衣服 完全是一条旧裤子。
手机 相机质量不错,如果阳光充足,可以和数码相机媲美.界面比较人性化,容易使用.软件安装简便
书籍 明明说有货,结果送货又没有了。并且也不告诉我,怎么评啊
洗浴 非常不满意,晚上洗的头发,第二天头痒痒的不行了,还都是头皮屑。
水果 这个苹果感觉是长熟的苹果,没有打蜡,不错,又甜又脆

dev.txt一共包含590条样本数据,每一行用\t分开,前半部分为标签(label),后半部分为原始输入 (用户评论)。

如果想使用自定义数据训练,只需要仿照上述示例数据构建数据集即可。

1.3 prompt.txt

  • prompt.txt为人工设定提示模版,其数据展示如下:
1
这是一条{MASK}评论:{textA}

其中,用大括号括起来的部分为「自定义参数」,可以自定义设置大括号内的值。

示例中 {MASK} 代表 [MASK] token 的位置,{textA} 代表评论数据的位置。

你可以改为自己想要的模板,例如想新增一个 {textB} 参数:

1
{textA}和{textB}是{MASK}同的意思。

1.4 verbalizer.txt

  • verbalizer.txt 主要用于定义「真实标签」到「标签预测词」之间的映射。在有些情况下,将「真实标签」作为 [MASK] 去预测可能不具备很好的语义通顺性,因此,我们会对「真实标签」做一定的映射。

  • 例如:

1
"中国爆冷2-1战胜韩国"是一则[MASK][MASK]新闻。	体育
  • 这句话中的标签为「体育」,但如果我们将标签设置为「足球」会更容易预测。

  • 因此,我们可以对「体育」这个 label 构建许多个子标签,在推理时,只要预测到子标签最终推理出真实标签即可,如下:

1
体育 -> 足球,篮球,网球,棒球,乒乓,体育
  • 项目中标签词映射数据展示如下:
1
2
3
4
5
6
7
8
9
10
电脑	电脑
水果 水果
平板 平板
衣服 衣服
酒店 酒店
洗浴 洗浴
书籍 书籍
蒙牛 蒙牛
手机 手机
电器 电器

verbalizer.txt 一共包含10个类别,上述数据中,我们使用了1对1的verbalizer, 如果想定义一对多的映射,只需要在后面用”,”分割即可, eg:

1
水果	苹果,香蕉,橘子

若想使用自定义数据训练,只需要仿照示例数据构建数据集

2、编写Config类项目文件配置代码

  • 代码路径:llm_tuning/prompt_tasks/PET/pet_config.py

  • config文件目的:配置项目常用变量,一般这些变量属于不经常改变的,比如:训练文件路径、模型训练次数、模型超参数等等

具体代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import torch
import os

base_dir = os.path.dirname(os.path.abspath(__file__))
# print(f'base_dir-->{base_dir}')

class ProjectConfig(object):
def __init__(self):
# 初始化设备配置,根据系统环境选择使用GPU或CPU
self.device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
# self.device = "mps:0"

# 预训练模型路径配置
self.pre_model = os.path.join(base_dir, '../../bert-base-chinese')

# 训练、验证数据集路径配置
self.train_path = os.path.join(base_dir, 'data/train.txt')
self.dev_path = os.path.join(base_dir, 'data/dev.txt')

# 提示词和标签映射文件路径配置
self.prompt_file = os.path.join(base_dir, 'data/prompt.txt')
self.verbalizer = os.path.join(base_dir, 'data/verbalizer.txt')

# 模型输入序列最大长度配置
self.max_seq_len = 256

# 设置训练的超参数
self.batch_size = 8 # 每个批次的大小,根据显存和模型大小调整
self.learning_rate = 5e-5 # 学习率,影响模型收敛速度和效果
self.weight_decay = 0 # 权重衰减,用于防止过拟合,这里不使用权重衰减
self.warmup_ratio = 0.06 # 学习率预热比例,帮助模型初期更快地学习
self.max_label_len = 2 # 最大标签长度,限制输出序列的最大长度
self.epochs = 20 # 训练的轮数,即整个数据集通过模型的次数

# 日志和验证配置
self.logging_steps = 2
self.valid_steps = 20

# 模型保存路径配置
self.save_dir = os.path.join(base_dir, 'save_model')


if __name__ ` '__main__':
pc = ProjectConfig()
print(pc.prompt_file)
print(pc.pre_model)

3、编写数据处理相关代码

  • 代码路径:llm_tuning/prompt_tasks/PET/data_handle/

  • data_handle文件夹中一共包含三个py脚本:template.py、data_preprocess.py、data_loader.py

3.1 template.py

  • 目的:构建固定模版类,text2id的转换
  • 思路:

image-20250823121459553

  • 定义HardTemplate类代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
from transformers import AutoTokenizer
import numpy as np

from prompt_tasks.PET.pet_config import ProjectConfig


# 使用硬模板,人工定义句子和[MASK]之间的位置关系。
class HardTemplate(object):
def __init__(self, prompt: str):
'''
初始化Prompt对象的构造函数
:param prompt: prompt格式定义字符串, 表示待处理的提示模板 e.g. -> "这是一条{MASK}评论:{textA}"
'''
self.prompt = prompt # 保存原始的提示模板字符串
self.inputs_list = [] # 根据文字prompt拆分为各part的列表
self.custom_tokens = {'MASK'} # 初始化自定义token集合,至少包含'MASK' token

self.prompt_analysis() # 解析prompt模板,初始化时即对prompt进行分析处理

def prompt_analysis(self):
'''
将prompt文字模板拆解为可映射的数据结构。
Examples:
prompt -> "这是一条{MASK}评论:{textA}"
inputs_list -> ['这', '是', '一', '条', 'MASK', '评', '论', ':', 'textA']
custom_tokens -> {'textA', 'MASK'}
:return:
'''
# print(f'prompt-->{self.prompt}')
idx = 0
# 遍历提示模板字符串中的每个字符
while idx < len(self.prompt):
str_part = ''
# 如果当前字符不是'{', '}',则直接添加到输入列表中
if self.prompt[idx] not in ['{', '}']:
self.inputs_list.append(self.prompt[idx])
# 如果遇到'{',表示进入自定义字段部分
if self.prompt[idx] ` '{': # 进入自定义字段
idx += 1
# 继续遍历直到遇到'}',并将自定义字段的值拼接到str_part中
while self.prompt[idx] != '}':
str_part += self.prompt[idx] # 拼接该自定义字段的值
idx += 1
# print(f'idx-->{idx}')
# 如果遇到'}',但没有对应的'{',抛出异常提示括号不匹配
elif self.prompt[idx] ` '}':
raise ValueError("遇到了单独的 '}', 请检查输入的prompt。")
# 如果str_part不为空,表示已经完整地获取了一个自定义字段
if str_part:
self.inputs_list.append(str_part) # 将所有自定义字段添加到输入列表中
self.custom_tokens.add(str_part) # 将所有自定义字段存储,后续会检测输入信息是否完整
# 移动到下一个字符
idx += 1

# print(f'self.inputs_list-->{self.inputs_list}')
# print(f'self.custom_tokens-->{self.custom_tokens}')

def __call__(self,
inputs_dict: dict,
tokenizer,
mask_length,
max_seq_len=512):
'''
输入一个样本,转换为符合模板的格式。
:param inputs_dict: prompt中的参数字典, e.g. -> { 'textA': '包装不错,苹果挺甜的,个头也大。', 'MASK': '[MASK]'}
:param tokenizer: 用于encoding文本的分词器
:param mask_length: MASK token 的长度
:param max_seq_len: 最大的句子长度
:return:
dict -> {
'text': '[CLS]这是一条[MASK][MASK]评论:包装不错,苹果挺甜的,个头也大。[SEP][PAD][PAD][PAD]',
'input_ids': [101, 6821, 3221, 671, 3340, 103, 103, 6397, ..., 102, 0, 0, 0],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, ..., 0, 0, 0, 0],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, ..., 1, 1, 1, 1,],
'mask_position': [5, 6]
}
'''
# 定义输出格式
# 初始化一个字典对象以存储处理后的输出数据
# 该字典包含了文本数据及其对应的编码信息、注意力掩码和掩码位置等关键信息
outputs = {
# 存储原始文本数据
'text': '',
# 存储文本经过分词和数值化后的输入ID序列
'input_ids': [],
# 存储段嵌入(token type embeddings)的ID序列,用于区分不同句子
'token_type_ids': [],
# 存储注意力掩码,用于指示每个token是否应该被关注
'attention_mask': [],
# 存储掩码位置,即在输入序列中被掩码的token的位置
'mask_position': []
}

# print(f'inputs_dict-->{inputs_dict}')
# 初始化一个空字符串,用于构建最终的格式化字符串
str_formated = ''
# 遍历输入列表中的每个值
for value in self.inputs_list:
# 检查当前值是否在custom_tokens中
if value in self.custom_tokens:
# 如果当前值是'MASK',使用mask_length副本的inputs_dict中的对应值
if value ` 'MASK':
str_formated += inputs_dict[value] * mask_length
else:
# 对于其他自定义值,直接添加inputs_dict中的对应值
str_formated += inputs_dict[value]
else:
# 如果当前值不是custom_tokens中的值,直接添加到格式化字符串中
str_formated += value
# 打印格式化后的字符串,用于调试和验证
# print(f'str_formated-->{str_formated}')

# 使用tokenizer对格式化后的字符串进行编码
# 编码配置包括截断、最大长度设置和填充,以满足模型输入的要求
encoded = tokenizer(text=str_formated,
truncation=True,
max_length=max_seq_len,
padding='max_length')
# print('*' * 80)
# print(f'encoded--->{encoded}')
# 将编码后的输入ID赋值给输出字典中的'input_ids'键
outputs['input_ids'] = encoded['input_ids']
# 将编码后的token类型ID赋值给输出字典中的'token_type_ids'键
outputs['token_type_ids'] = encoded['token_type_ids']
# 将编码后的注意力掩码赋值给输出字典中的'attention_mask'键
outputs['attention_mask'] = encoded['attention_mask']

# print(tokenizer.convert_ids_to_tokens(encoded['input_ids']))
# 将编码后的输入ID转换为文本,并存储到输出字典中
outputs['text'] = ''.join(tokenizer.convert_ids_to_tokens(encoded['input_ids']))
# print(f'outputs-->{outputs}')

# 将掩码标记 '[MASK]' 转换为其对应的ID
mask_token_id = tokenizer.convert_tokens_to_ids('[MASK]')
# print(f'mask_token_id-->{mask_token_id}')
# print(np.array(outputs['input_ids']) ` mask_token_id)
# print(np.where(np.array(outputs['input_ids']) ` mask_token_id))
# 计算并获取输入ID中'mask'标记的位置,并将其转换为列表
mask_position = np.where(np.array(outputs['input_ids']) ` mask_token_id)[0].tolist()
# print(f'mask_position-->{mask_position}')
# 将计算出的mask_position添加到outputs字典中
outputs['mask_position'] = mask_position
return outputs


if __name__ ` '__main__':
# 创建ProjectConfig对象以获取项目配置
pc = ProjectConfig()

# 根据预训练模型配置,加载分词器
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)

# 定义一个硬模板对象,用于构建特定格式的输入文本
hard_template = HardTemplate(prompt='这是一条{MASK}评论:{textA}')
# 打印硬模板的输入列表和自定义token信息,以便调试
print(f'inputs_list-->{hard_template.inputs_list}')
print(f'custom_tokens-->{hard_template.custom_tokens}')

# 使用硬模板、分词器和指定的输入字典构建一个模板实例
# 调用模板对象, 自动调用__call__方法
tep = hard_template(
inputs_dict={'textA': '包装不错,苹果挺甜的,个头也大。', 'MASK': '[MASK]'},
tokenizer=tokenizer,
mask_length=2,
max_seq_len=30
)
print(f'tep--->{tep}')


3.2 data_preprocess.py

  • 目的: 将样本数据转换为模型接受的输入数据。具体来说,就是将每行数据进行处理,获取数据的标签和评论信息,然后进行处理获取输入和标签。
  • 定义数据转换方法convert_example(),代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import numpy as np
import torch
from datasets import load_dataset
# partial:把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单
from functools import partial
from transformers import AutoTokenizer

from prompt_tasks.PET.data_handle.template import HardTemplate
from prompt_tasks.PET.pet_config import ProjectConfig


def convert_example(
examples: dict,
tokenizer,
max_seq_len: int,
max_label_len: int,
hard_template: HardTemplate,
train_mode=True,
return_tensor=False) -> dict:
'''
将样本数据转换为模型接收的输入数据。
:param examples: dict类型。训练数据样本,
e.g. -> {
"text": ['手机 这个手机也太卡了。',
'体育 世界杯为何迟迟不见宣传',
...
]}
:param tokenizer: 分词器对象
:param max_seq_len: int类型。句子的最大长度,若没有达到最大长度,则padding为最大长度
:param max_label_len: int类型。最大label长度,若没有达到最大长度,则padding为最大长度
:param hard_template: HardTemplate类型。模板类
:param train_mode: bool类型。训练阶段 or 推理阶段
:param return_tensor: bool类型。是否返回tensor类型,如不是,则返回numpy类型。
:return:
dict (str: np.array) -> tokenized_output = {
'input_ids': [[1, 47, 10, 7, 304, 3, 3, 3, 3, 47, 27, 247, 98, 105, 512, 777, 15, 12043, 2], ...],
'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ...],
'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ...],
'mask_positions': [[5, 6, 7, 8], ...],
'mask_labels': [[2372, 3442, 0, 0], [2643, 4434, 2334, 0], ...]
}
'''
# 初始化一个字典,用于存储token化的输出信息
tokenized_output = {
'input_ids': [], # 输入文本的token ID序列
'token_type_ids': [], # token类型ID序列,用于区分不同句子的token
'attention_mask': [], # 注意力掩码序列,用于标识真实token与padding token
'mask_positions': [], # mask标签在输入序列中的位置
'mask_labels': [] # 需要预测的mask标签的真实值
}
# print(f'examples--》{examples}')
# 遍历examples中的'text'列表,获取索引和文本内容
for i, example in enumerate(examples['text']):
# 判断是否处于训练模式
if train_mode:
# print(f'example-->{example}')
# 将文本内容按制表符分割,获取标签和内容
label, content = example.strip().split('\t')
# print(f'label-->{label}')
# print(f'content-->{content}')

# 使用tokenizer对标签进行编
label_encoded = tokenizer(label, add_special_tokens=False)['input_ids']
# print(f'label_encoded-->{label_encoded}')

# 如果标签长度超过最大标签长度, 将标签编码序列的长度限制在最大标签长度内
if len(label_encoded) >= max_label_len:
label_encoded = label_encoded[:max_label_len]
# 如果标签长度小于最大标签长度, 将标签编码序列进行填充,以确保其长度与max_label_len相等
else:
# 这里使用了tokenizer的pad_token_id属性作为填充元素
# print(f'tokenizer.pad_token_id-->{tokenizer.pad_token_id}')
label_encoded = label_encoded + [tokenizer.pad_token_id] * (max_label_len - len(label_encoded))

# 将编码后的标签添加到tokenized_output字典中的'mask_labels'列表中
tokenized_output['mask_labels'].append(label_encoded)
else:
# 如果不是训练模式,直接将文本内容进行修剪并使用
content = example.strip()

# 初始化输入字典,用于准备文本数据和特殊标记
inputs_dict = {
'textA': content, # 'textA' 键对应的是后续处理的主要文本内容
'MASK': '[MASK]' # 'MASK' 键用于标识特殊的掩码标记,常用于语言模型中
}
# print(f'inputs_dict-->{inputs_dict}')

# 使用硬模板编码方法处理输入数据
# 该方法将输入数据字典、tokenizer、最大序列长度和最大标签长度作为参数
# 目的是将输入数据编码成模型所需的格式
encoded_inputs = hard_template(
inputs_dict=inputs_dict,
tokenizer=tokenizer,
max_seq_len=max_seq_len,
mask_length=max_label_len)
# print(f'encoded_inputs-->{encoded_inputs}')

# 将编码后的输入ID添加到输出字典中的input_ids列表
tokenized_output['input_ids'].append(encoded_inputs["input_ids"])
# 将编码后的token类型ID添加到输出字典中的token_type_ids列表
tokenized_output['token_type_ids'].append(encoded_inputs["token_type_ids"])
# 将编码后的注意力掩码添加到输出字典中的attention_mask列表
tokenized_output['attention_mask'].append(encoded_inputs["attention_mask"])
# 将遮罩位置信息添加到输出字典中的mask_positions列表
tokenized_output['mask_positions'].append(encoded_inputs["mask_position"])
# print(f'tokenized_output-->{tokenized_output}')

# 遍历tokenized_output字典,其中k是键,v是值
for k, v in tokenized_output.items():
# 如果return_tensor为True,将值转换为torch.LongTensor类型
if return_tensor:
tokenized_output[k] = torch.LongTensor(v)
# 否则,将值转换为numpy数组
else:
tokenized_output[k] = np.array(v)

return tokenized_output


if __name__ ` '__main__':
# 创建ProjectConfig对象以获取项目配置
pc = ProjectConfig()

# 使用预训练模型的分词器进行初始化
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)

# 定义一个硬模板,用于将特定的文本结构化到模型输入中
# {MASK}用于指示模型需要预测的位置,{textA}是输入文本的占位符
hard_template = HardTemplate(prompt='这是一条{MASK}评论:{textA}')

# 定义示例输入,包含需要处理的文本数据
# 每个元素是一个包含类别和评论文本的字符串,用制表符分隔
examples = {"text": ['手机 这个手机也太卡了。', '体育 世界杯为何迟迟不见宣传']}
tokenized_output = convert_example(examples, tokenizer, max_seq_len=30, max_label_len=2, hard_template=hard_template)
print(f'tokenized_output-->{tokenized_output}')

print('*' * 80)

# 使用functools.partial函数创建一个部分应用函数convert_func
# 此函数基于convert_example函数,预先设置了一些参数,以便于后续的调用中简化操作
# 这样做是为了优化样本处理流程,将频繁使用的参数固定下来,提高代码复用性和灵活性
convert_func = partial(convert_example,
tokenizer=tokenizer,
hard_template=hard_template,
max_seq_len=30,
max_label_len=2)
# 加载训练数据集
# 使用ProjectConfig中定义的训练数据路径
train_dataset = load_dataset('text', data_files=pc.train_path)
print(type(train_dataset))
print(f'train_dataset-->{train_dataset}')
# print(train_dataset['train'])
# print(train_dataset['train']['text'])

# 使用map方法对训练数据集进行批量转换
# batched=True相当于将train_dataset看成一个批次的样本直接对数据进行处理,节省时间
dataset = train_dataset.map(convert_func, batched=True)
print(f'dataset-->{dataset}')

# 遍历数据集中的训练数据部分
for value in dataset['train']:
# 打印当前训练数据示例
print(value)
# 打印输入ID序列的长度
print(len(value['input_ids']))
# 打印输入ID序列的数据类型
print(type(value['input_ids']))
# 仅打印第一个训练数据示例后跳出循环
break

3.3 data_loader.py

  • 目的:定义数据加载器
  • 定义获取数据加载器的方法get_data(),代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
from functools import partial
from datasets import load_dataset
from torch.utils.data import DataLoader
from transformers import AutoTokenizer, default_data_collator

from prompt_tasks.PET.data_handle.data_preprocess import convert_example
from prompt_tasks.PET.data_handle.template import HardTemplate
from prompt_tasks.PET.pet_config import ProjectConfig

# 实例化项目配置文件
pc = ProjectConfig()

# 使用项目配置文件中指定的预训练模型,初始化一个自动分词器
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)

def get_data():
'''
加载训练和验证数据集,并进行预处理以适应模型训练。
该函数首先读取提示模板文件,然后使用该模板创建一个硬模板对象。
接着,它加载原始数据集,并将其转换为适合模型训练的格式。
最后,它将处理后的数据集包装在DataLoader对象中,以便在训练过程中方便地访问数据。
:return: train_dataloader: 训练数据集的DataLoader对象。
dev_dataloader: 验证数据集的DataLoader对象。
'''
# 读取提示模板文件的第一行作为prompt
prompt = open(pc.prompt_file, 'r', encoding='utf-8').readlines()[0].strip() # prompt定义
# print(f'prompt-->{prompt}')

# 使用读取的prompt创建一个硬模板对象
hard_template = HardTemplate(prompt=prompt)

# 创建一个新函数,用于将示例转换为模型训练所需的格式
new_func = partial(convert_example,
tokenizer=tokenizer,
hard_template=hard_template,
max_seq_len=pc.max_seq_len,
max_label_len=pc.max_label_len)

# 加载原始文本数据集
dataset = load_dataset('text',
data_files={'train': pc.train_path, 'dev': pc.dev_path})
# print(f'dataset-->{dataset}')

# 使用新函数对数据集进行映射,进行批量处理
dataset = dataset.map(new_func, batched=True)
# print(f'dataset改变之后的-->{dataset}')

# 提取训练数据集和验证数据集
train_dataset = dataset["train"]
# print(f'train_dataset-->{train_dataset}')
# print(f'train_dataset[0]-->{train_dataset[0]}')
dev_dataset = dataset["dev"]
# print(f'dev_dataset-->{dev_dataset}')
# print('dev_dataset', dev_dataset[0])

# 使用default_data_collator将数据转换为tensor数据类型
train_dataloader = DataLoader(train_dataset,
shuffle=True,
collate_fn=default_data_collator,
batch_size=pc.batch_size)
# print(f'train_dataloader-->{train_dataloader}')
dev_dataloader = DataLoader(dev_dataset,
collate_fn=default_data_collator,
batch_size=pc.batch_size)

# 返回处理后的训练和验证数据集的DataLoader对象
return train_dataloader, dev_dataloader


if __name__ ` '__main__':
# 获取训练和验证数据集的加载器
train_dataloader, dev_dataloader = get_data()
print(len(train_dataloader))
print(len(dev_dataloader))

# 遍历训练数据集加载器
for i, value in enumerate(train_dataloader):
print(f'i--->{i}')
print(f'value--->{value}')
# 打印当前数据项中'input_ids'的数据类型
print(value['input_ids'].dtype)
break

四、PET方式模型搭建与训练【实现】

  • 本项目中完成BERT+PET模型搭建、训练及应用的步骤如下(注意:因为本项目中使用的是BERT预训练模型,所以直接加载即可,无需重复搭建模型架构):
    • 1.实现模型工具类函数
    • 2.实现模型训练函数,验证函数
    • 3.实现模型预测函数

1、实现模型工具类函数

  • 目的:模型在训练、验证、预测时需要的函数
  • 代码路径:llm_tuning/prompt_tasks/PET/utils
    • utils文件夹共包含3个py脚本:verbalizer.py、metirc_utils.py以及common_utils.py

1.1 verbalizer.py

  • 目的:定义一个Verbalizer类,用于将一个主标签映射到子标签或者将子标签映射到主标签。
  • 思路:
image-20250823153111306
  • 具体实现代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# Union 是 typing 模块中定义的一个类,用于表示多个类型中的任意一种类型
from typing import Union, List
from transformers import AutoTokenizer

from prompt_tasks.PET.pet_config import ProjectConfig

pc = ProjectConfig()


# Verbalizer类,用于将一个Label对应到其子Label的映射。
class Verbalizer(object):
def __init__(self,
verbalizer_file: str,
tokenizer, max_label_len: int
):
'''
:param verbalizer_file: verbalizer文件存放地址。
:param tokenizer: 用于文本和id之间的转换。
:param max_label_len: 标签长度,若大于则截断,若小于则补齐
'''
self.tokenizer = tokenizer
self.label_dict = self.load_label_dict(verbalizer_file)
self.max_label_len = max_label_len

def load_label_dict(self, verbalizer_file: str):
'''
读取本地文件,构建verbalizer字典。
:param verbalizer_file: verbalizer文件存放地址。
:return:
dict -> {
'体育': ['篮球', '足球','网球', '排球', ...],
'酒店': ['宾馆', '旅馆', '旅店', '酒店', ...],
...
}
'''
# 初始化一个空字典,用于存储标签和子标签的关系
label_dict = {}

# 打开verbalizer文件,以只读模式,使用utf8编码
with open(verbalizer_file, 'r', encoding='utf-8') as f:
# 读取文件的每一行
for line in f:
# 移除行尾的换行符,并按制表符('\t')分割标签和子标签
label, sub_labels = line.strip().split('\t')
# 将子标签按逗号(,)分割成列表,使用set去重后再转回列表,存储到label_dict中
label_dict[label] = list(set(sub_labels.split(',')))
# 返回处理后的标签和子标签的字典
return label_dict

def find_sub_labels(self, label: Union[list, str]):
'''
通过主标签找到所有的子标签。
:param label: 标签, 文本型 或 id_list, e.g. -> '体育' or [860, 5509]
:return:
dict -> {
'sub_labels': ['足球', '网球'],
'token_ids': [[6639, 4413], [5381, 4413]]
}
'''
# 如果传入的label为id列表,则通过tokenizer转换回字符串
if type(label) ` list:
# 移除label中的pad_token_id,直到label中不再包含它
while self.tokenizer.pad_token_id in label:
label.remove(self.tokenizer.pad_token_id)
# 将处理后的id列表转换为tokens,并拼接成字符串
label = ''.join(self.tokenizer.convert_ids_to_tokens(label))
# print(f'label-->{label}')
# 检查转换后的label是否在标签字典中,如果不在则抛出异常
if label not in self.label_dict:
raise ValueError(f'Lable Error: "{label}" 不在 label_dict {list(self.label_dict)}.')

# 从标签字典中获取与label对应的子标签
sub_labels = self.label_dict[label]
# print(f'sub_labels-->{sub_labels}')
# 将子标签作为结果的一个部分存储在字典中
ret = {'sub_labels': sub_labels}

# 对每个子标签进行token化,不含特殊符号
token_ids = [token_id for token_id in self.tokenizer(sub_labels, add_special_tokens=False)['input_ids']]
# print(f'token_ids-->{token_ids}')
# 遍历所有的token_ids,进行截断与补齐操作
for i in range(len(token_ids)):
# 对标签进行截断
token_ids[i] = token_ids[i][:self.max_label_len]
# 如果长度不足max_label_len,则使用pad_token_id进行补齐
if len(token_ids[i]) < self.max_label_len:
token_ids[i] = token_ids[i] + [self.tokenizer.pad_token_id] * (self.max_label_len - len(token_ids[i]))
# 将处理后的token_ids存入ret字典中
ret['token_ids'] = token_ids
return ret

def batch_find_sub_labels(self, label: List[Union[list, str]]):
'''
批量找到子标签。
:param label: 标签列表, [[4510, 5554], [860, 5509]] or ['体育', '电脑']
:return:
list -> [
{
'sub_labels': ['笔记本', '电脑'],
'token_ids': [[5011, 6381, 3315], [4510, 5554]]
},
...
]
'''
return [self.find_sub_labels(l) for l in label]

def get_common_sub_str(self,
str1: str,
str2: str
):
'''
寻找最大公共子串(连续子序列)。
:param str1: abcd
:param str2: abadbcdba
:return:
'''
# 初始化两个字符串的长度
lstr1, lstr2 = len(str1), len(str2)
# 生成0矩阵,为方便后续计算,比字符串长度多了一列,生成一个 lstr1+1 * lstr2+1 的二维矩阵
record = [[0 for i in range(lstr2 + 1)] for j in range(lstr1 + 1)]
# 初始化最长匹配对应在str1中的最后一位
p = 0
# 初始化最长匹配长度
maxNum = 0
# 遍历两个字符串,寻找最长公共子串
for i in range(1, lstr1 + 1):
for j in range(1, lstr2 + 1):
# 当发现相同字符时
if str1[i - 1] ` str2[j - 1]:
# 在record矩阵中记录匹配长度
record[i][j] = record[i - 1][j - 1] + 1
# 更新最长匹配长度和对应在str1中的最后一位
if record[i][j] > maxNum:
maxNum = record[i][j]
p = i

# 返回最长公共子串和其长度
return str1[p - maxNum:p], maxNum

def hard_mapping(self, sub_label: str):
'''
强匹配函数,当模型生成的子label不存在时,通过最大公共子串找到重合度最高的主label。
:param sub_label: 子label
:return: 主label
'''
# 初始化变量label和max_overlap_str,用于记录最大重叠度的标签和对应的重叠度值
label, max_overlap_str = '', 0

# 遍历标签字典,其中main_label是主标签,sub_labels是与主标签相关的子标签列表
for main_label, sub_labels in self.label_dict.items():
overlap_num = 0
# 对于每个子标签,计算它与当前推理标签之间的最长公共子串长度总和
for s_label in sub_labels:
# 累加每个子标签与当前推理标签之间的最长公共子串长度
overlap_num += self.get_common_sub_str(sub_label, s_label)[1]

# 如果当前的重叠度大于或等于之前的最大重叠度,则更新最大重叠度和对应的标签
if overlap_num >= max_overlap_str:
max_overlap_str = overlap_num
label = main_label

return label

def find_main_label(self,
sub_label: Union[list, str],
hard_mapping=True
):
'''
通过子标签找到父标签。
:param sub_label: 子标签, 文本型 或 id_list, e.g. -> '苹果' or [5741, 3362]
:param hard_mapping: 当生成的词语不存在时,是否一定要匹配到一个最相似的label。
:return:
dict -> {
'label': '水果',
'token_ids': [3717, 3362]
}
'''
# 如果传入的sub_label为id列表,则通过tokenizer转换回字符串
if type(sub_label) ` list:
pad_token_id = self.tokenizer.pad_token_id
# 移除列表中的[PAD]token,避免影响后续处理
while pad_token_id in sub_label:
sub_label.remove(pad_token_id)
# 将id列表转换为对应的字符串
sub_label = ''.join(self.tokenizer.convert_ids_to_tokens(sub_label))
# print(f'sub_label-->{sub_label}')
# 初始化主标签为'无',作为未找到特定子标签时的默认值
main_label = '无'

# 遍历标签字典,寻找与子标签匹配的主标签
for label, sub_labels in self.label_dict.items():
# 检查当前子标签是否在字典中对应的子标签列表中
if sub_label in sub_labels:
# 当找到匹配时,更新主标签并终止循环
main_label = label
break
# print(f'main_label-->{main_label}')
# 如果主标签为'无'且启用了强匹配功能,则使用强匹配方法更新主标签
if main_label ` '无' and hard_mapping:
main_label = self.hard_mapping(sub_label)
# print('强匹配', main_label)
ret = {
'label': main_label,
'token_ids': self.tokenizer(main_label, add_special_tokens=False)['input_ids']
}
return ret

def batch_find_main_label(self,
sub_label: List[Union[list, str]],
hard_mapping=True
):
'''
批量通过子标签找父标签。
:param sub_label: 子标签列表, ['苹果', ...] or [[5741, 3362], ...]
:param hard_mapping:
:return:
list: [
{
'label': '水果',
'token_ids': [3717, 3362]
},
...
]
'''
return [self.find_main_label(l, hard_mapping) for l in sub_label]


if __name__ ` '__main__':
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)
verbalizer = Verbalizer(
verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=2)
print(f'label_dict-->{verbalizer.label_dict}')

# 查找单个子标签
label = '电脑'
ret = verbalizer.find_sub_labels(label)
print(f'ret-->{ret}')
print('*' * 80)

# 查找多个子标签
labels = ['电脑', '衣服']
# labels = [[4510, 5554], [6132, 3302]]
result = verbalizer.batch_find_sub_labels(labels)
print(f'result-->{result}')
print('*' * 80)

# 查找单个子标签对应的父标签
# sub_label = [4510, 5554]
sub_label = '衣电'
ret = verbalizer.find_main_label(sub_label)
print(f'ret-->{ret}')
print('*' * 80)

# 查找多个子标签对应的父标签
# sub_label = ['衣服', '牛奶']
sub_label = [[6132, 3302], [5885, 4281]]
ret = verbalizer.batch_find_main_label(sub_label, hard_mapping=True)
print(f'ret-->{ret}')

1.2 common_utils.py

  • 目的:定义损失函数、将mask_position位置的token logits转换为token的id。
损失计算思路

image-20250823173343428

将logits获取id的思路:

image-20250823181627322

  • 脚本里面包含两个函数:mlm_loss()以及convert_logits_to_ids()
  • 代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import torch

def mlm_loss(logits,
mask_positions,
sub_mask_labels,
cross_entropy_criterion,
device):
'''
计算指定位置的mask token的output与label之间的cross entropy loss。
:param logits: (torch.tensor): 模型原始输出 -> (batch_size, seq_len, vocab_size)
:param mask_positions: (torch.tensor): mask token的位置 -> (batch_size, mask_label_num)
:param sub_mask_labels: (list): mask token的sub label, 由于每个label的sub_label数目不同,所以这里是个变长的list,
e.g. -> [
[[2398, 3352]],
[[2398, 3352], [3819, 3861]]
]
:param cross_entropy_criterion: (CrossEntropyLoss): CE Loss计算器
:param device: (str): cpu还是gpu
:return: CE Loss
'''
'''
获取logits的尺寸信息,为后续计算做准备
logits.size()返回一个包含三个维度的元组
第一个维度(batch_size)代表批次大小,即一次处理的数据批次包含的样本数量
第二个维度(seq_len)代表序列长度,即每个样本中包含的序列元素数量
第三个维度(vocab_size)代表词汇表大小,即每个序列元素可能的类别数量
'''
batch_size, seq_len, vocab_size = logits.size()
# print(f'模型预测结果logits-->{logits.size()}')
# print(f'mask_positions-->{mask_positions.shape}')
# print(f'sub_mask_labels-->{sub_mask_labels}')

# 初始化loss变量为None,用于后续可能的损失计算
loss = None
# 遍历 logits、sub_mask_labels 和 mask_positions 的元素
for single_value in zip(logits, sub_mask_labels, mask_positions):
# 获取当前token的 logits
single_logits = single_value[0]
# print(f'single_logits-->{single_logits.shape}') # 形状[512, 21128]
# 获取当前token的 sub_mask_labels
single_sub_mask_labels = single_value[1]
# print(f'single_sub_mask_labels-->{single_sub_mask_labels}')
# 获取当前token的 mask_positions
single_mask_positions = single_value[2]
# print(f'single_mask_positions-->{single_mask_positions}') # 形状size[2]-->具体值([5, 6])

# 从单个序列的logits中,提取出被掩码位置的logits
single_mask_logits = single_logits[single_mask_positions] # (mask_label_num, vocab_size)
# 打印被掩码位置logits的形状,以验证其是否符合预期
# print(f'single_mask_logits-->{single_mask_logits.shape}')

# 模型训练时主标签对应的所有子标签都有相似的特征值, 在计算CE Loss时,需要将每个子标签的对应的损失求平均,因此需要将预测的概率值进行扩展
# 对单个 single_mask_logits 进行扩展,使其在第一个维度上重复,以匹配 single_sub_mask_labels 的数量
# 使用repeat设置重复的倍数 (sub_label_num, mask_label_num, vocab_size)
single_mask_logits = single_mask_logits.repeat(len(single_sub_mask_labels), 1, 1)
# 打印重复后的single_mask_logits的形状,以便调试和验证重复操作的效果
# print(f'重复后的single_mask_logits-->{single_mask_logits.shape}')

# 将三维张量调整为二维,以便计算损失
single_mask_logits = single_mask_logits.reshape(-1, vocab_size) # (sub_label_num * mask_label_num, vocab_size)
# print(f'调整成二维后的single_mask_logits-->{single_mask_logits.shape}')

# 将子标签转换为张量,并调整形状以匹配模型预测的结果
single_sub_mask_labels = torch.LongTensor(single_sub_mask_labels).to(device) # (sub_label_num, mask_label_num)
# 计算损失值时真实子标签维度为1维,因此需要将其展平以匹配模型预测的结果
single_sub_mask_labels = single_sub_mask_labels.reshape(-1, 1).squeeze() # (sub_label_num * mask_label_num)
# print(f'真实子标签mask值:single_sub_mask_labels-->{single_sub_mask_labels.shape}')
# print(f'真实子标签mask值:single_sub_mask_labels-->{single_sub_mask_labels}')

# 计算当前批次所有子标签的损失
cur_loss = cross_entropy_criterion(single_mask_logits, single_sub_mask_labels)
# 计算当前批次所有子标签的平均损失
cur_loss = cur_loss / len(single_sub_mask_labels)

# 如果当前损失loss未被初始化(即为None),则将其设置为当前批次的损失cur_loss
if not loss:
loss = cur_loss
# 如果当前损失loss已经存在,则将当前批次的损失cur_loss累加到loss中
else:
loss += cur_loss

# 计算平均损失:将累计的损失loss除以批次大小batch_size
loss = loss / batch_size # (1,)
return loss

def convert_logits_to_ids(
logits: torch.tensor,
mask_positions: torch.tensor):
'''
输入Language Model的词表概率分布(LMModel的logits),将mask_position位置的token logits转换为token的id。
:param logits: (torch.tensor): model output -> (batch, seq_len, vocab_size) [8, 512, 21128]
:param mask_positions: (torch.tensor): mask token的位置 -> (batch, mask_label_num) [8, 2]
:return: 对应mask position上最大概率的推理token -> (batch, mask_label_num) [8, 2]
'''
# 获取标签的长度,mask_positions.size()返回的是一个包含维度的元组,[1]表示获取第二个维度的大小
label_length = mask_positions.size()[1]
# print(f'label_length-->{label_length}')

# 获取批次大小、序列长度和词汇表大小,logits.size()返回的是一个包含维度的元组
batch_size, seq_len, vocab_size = logits.size()

# 初始化一个空列表,用于存储重塑后的 mask_positions
mask_positions_after_reshaped = []

# print(f'mask_positions.detach().cpu().numpy().tolist()-->{mask_positions.detach().cpu().numpy().tolist()}')
# 遍历每个批次的mask_positions
for batch, mask_pos in enumerate(mask_positions.detach().cpu().numpy().tolist()):
# 遍历每个mask位置
for pos in mask_pos:
# 将批次号和序列中的mask位置结合起来,得到重塑后的mask_positions
mask_positions_after_reshaped.append(batch * seq_len + pos)
# print(f'mask_positions_after_reshaped-->{mask_positions_after_reshaped}')
# print(f'原始的logits-->{logits.shape}')

# 将原始的logits重塑为(batch_size * seq_len, vocab_size)的形状
logits = logits.reshape(batch_size * seq_len, -1) # (batch_size * seq_len, vocab_size)
# print(f'改变原始模型输出的结果形状-->{logits.shape}')

# 从重塑后的logits中,选择出被掩码位置的logits
mask_logits = logits[mask_positions_after_reshaped]
# print(f'被掩码位置的logits-->{mask_logits.shape}')

# 获取每个样本mask位置所预测的tokens
predict_tokens = mask_logits.argmax(dim=-1) # (batch * label_num)
# print(f'获取每个样本mask位置预测的tokens', predict_tokens)

# 将每个样本mask位置预测的tokens重塑为(batch, label_num)的形状
predict_tokens = predict_tokens.reshape(-1, label_length) # (batch, label_num)
# print(f'predict_tokens-->{predict_tokens}')

return predict_tokens

1.3 metirc_utils.py

  • 目的:定义(多)分类问题下的指标评估(acc, precision, recall, f1)。
  • 定义ClassEvaluator类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
from typing import List
import numpy as np
from sklearn.metrics import accuracy_score, precision_score, f1_score
from sklearn.metrics import recall_score, confusion_matrix


class ClassEvaluator(object):
def __init__(self):
# 初始化真实结果和预测结果的列表
self.goldens = [] # 存储真实结果数据
self.predictions = [] # 存储预测结果数据

def add_batch(self,
pred_batch: List[List],
gold_batch: List[List]):
'''
添加一个batch中的prediction和gold列表,用于后续统一计算。
:param pred_batch: (list): 模型预测标签列表, e.g. -> [['体', '育'], ['财', '经'], ...]
:param gold_batch: (list): 真实标签标签列表, e.g. -> [['体', '育'], ['财', '经'], ...]
:return:
'''
# 确保预测批次和真实批次长度一致,这是后续处理的前提条件
assert len(pred_batch) ` len(gold_batch)
# print(f'pred_batch0-->{pred_batch}')
# print(f'gold_batch0-->{gold_batch}')

# 若遇到多个子标签构成一个标签的情况
# 判断gold_batch的第一个元素是否为列表或元组类型
if type(gold_batch[0]) in [list, tuple]:
# 如果是,则将pred_batch中的每个元素转换为字符串后拼接起来
pred_batch = [''.join([str(e) for e in ele]) for ele in pred_batch]
# 同样地,也将gold_batch中的每个元素转换为字符串后拼接起来
gold_batch = [''.join([str(e) for e in ele]) for ele in gold_batch]
# print(f'pred_batch-->{pred_batch}')
# print(f'gold_batch-->{gold_batch}')

# 将真实结果的批次数据添加到self.goldens列表中
self.goldens.extend(gold_batch)
# print(f'self.goldens-->{self.goldens}')
# 将预测结果的批次数据添加到self.predictions列表中
self.predictions.extend(pred_batch)
# print(f'self.predictions-->{self.predictions}')

def compute(self, round_num=2) -> dict:
'''
根据当前类中累积的变量值,计算当前的P, R, F1。
:param round_num: (int): 计算结果保留小数点后几位, 默认小数点后2位。
:return:
dict -> {
'accuracy': 准确率,
'precision': 精准率,
'recall': 召回率,
'f1': f1值,
'class_metrics': {
'0': {
'precision': 该类别下的precision,
'recall': 该类别下的recall,
'f1': 该类别下的f1
},
...
}
}
'''
# print(f'self.goldens-->{self.goldens}')
# print(f'self.predictions-->{self.predictions}')
# 初始化类别集合、类别指标字典和结果字典,用于存储全局指标
# 将 self.goldens 和 self.predictions 的集合合并,并进行排序,结果存储在变量 classes 中。
classes = sorted(list(set(self.goldens) | set(self.predictions)))
class_metrics = {}
res = {}
# print(f'classes-->{classes}')

# 构建全局指标
# 计算并存储全局准确率
res['accuracy'] = round(accuracy_score(self.goldens, self.predictions), round_num)
# 计算并存储全局精确率
res['precision'] = round(precision_score(self.goldens, self.predictions, average='weighted'), round_num)
# 计算并存储全局召回率
res['recall'] = round(recall_score(self.goldens, self.predictions, average='weighted'), round_num)
# 计算并存储全局F1分数
res['f1'] = round(f1_score(self.goldens, self.predictions, average='weighted'), round_num)
# print(f'res-->{res}')

try:
# 计算混淆矩阵,并将其转换为numpy数组,形状为(n_class, n_class)
conf_matrix = np.array(confusion_matrix(self.goldens, self.predictions))
# print(f'conf_matrix-->{conf_matrix}')
# 确保混淆矩阵的维度与类别数量匹配
assert conf_matrix.shape[0] ` len(classes)
# 遍历每个类别,计算精确度(precision)、召回率(recall)和F1分数(f1)
for i in range(conf_matrix.shape[0]):
# 计算当前类别的精确度
precision = 0 if sum(conf_matrix[:, i]) ` 0 else (conf_matrix[i, i] / sum(conf_matrix[:, i]))
# 计算当前类别的召回率
recall = 0 if sum(conf_matrix[i, :]) ` 0 else (conf_matrix[i, i] / sum(conf_matrix[i, :]))
# 计算当前类别的F1分数
f1 = 0 if (precision + recall) ` 0 else (2 * precision * recall / (precision + recall))
# 将当前类别的精确度、召回率和F1分数保存到字典中
class_metrics[classes[i]] = {
'precision': round(precision, round_num),
'recall': round(recall, round_num),
'f1': round(f1, round_num)
}
# 将所有类别的指标保存到结果字典中
res['class_metrics'] = class_metrics
except Exception as e:
# 异常处理:当计算类别指标时发生异常,打印警告信息和相关数据
print(f'[Warning] Something wrong when calculate class_metrics: {e}')
print(f'--> goldens: {set(self.goldens)}')
print(f'--> predictions: {set(self.predictions)}')
print(f'--> diff elements: {set(self.predictions) - set(self.goldens)}')
# 将结果字典中的类别指标设置为空字典
res['class_metrics'] = {}

return res

def reset(self):
"""
重置积累的数值。
"""
self.goldens = []
self.predictions = []


if __name__ ` '__main__':
metric = ClassEvaluator()
metric.add_batch(
[['财', '经'], ['财', '经'], ['体', '育'], ['体', '育'], ['计', '算', '机']],
[['体', '育'], ['财', '经'], ['体', '育'], ['计', '算', '机'], ['计', '算', '机']],
)
# metric.add_batch(
# [0, 0, 1, 1, 0],
# [1, 1, 1, 0, 0]
# )
res = metric.compute()
print(res)

2、实现模型训练函数,验证函数

  • 目的:实现模型的训练和验证

  • 脚本里面包含两个函数:model2train()和evaluate_model()

  • 代码路径:llm_tuning/prompt_tasks/PET/train.py

    代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import os
import time

import torch
from tqdm import tqdm
from transformers import AutoModelForMaskedLM, AutoTokenizer, get_scheduler

from prompt_tasks.PET.data_handle.data_loader import get_data
from prompt_tasks.PET.pet_config import ProjectConfig
from prompt_tasks.PET.utils.common_utils import mlm_loss, convert_logits_to_ids
from prompt_tasks.PET.utils.metirc_utils import ClassEvaluator
from prompt_tasks.PET.utils.verbalizer import Verbalizer

pc = ProjectConfig()


def model2train():
# 加载训练数据和验证数据
train_dataloader, dev_dataloader = get_data()

# 加载预训练模型
model = AutoModelForMaskedLM.from_pretrained(pc.pre_model).to(pc.device)
# print(f'预训练模型带MLM头的-->{model}')
# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)
# 加载映射词表
verbalizer = Verbalizer(verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=pc.max_label_len)
# print(f'verbalizer-->{verbalizer.label_dict}')

# 不需要权重衰减的参数
no_decay = ["bias", "LayerNorm.weight"]
# print(type(model.parameters()))
# 定义优化器的参数组,以便对模型的不同部分应用不同的权重衰减
optimizer_grouped_parameters = [
# 第一组参数:包含所有适用权重衰减的模型参数
{
"params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
"weight_decay": pc.weight_decay,
},
# 第二组参数:包含所有不适用权重衰减的模型参数
{
"params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
"weight_decay": 0.0,
},
]
# 初始化AdamW优化器,用于模型参数的优化
# AdamW是Adam算法的变体,加入了权重衰减(L2正则化),有助于防止过拟合
# 参数optimizer_grouped_parameters是分组的模型参数,允许对不同的参数应用不同的学习率或正则化强度
optimizer = torch.optim.AdamW(optimizer_grouped_parameters, lr=pc.learning_rate)

# 根据训练轮数计算最大训练步数,以便于scheduler动态调整lr
num_update_steps_per_epoch = len(train_dataloader)
# 指定总的训练步数,它会被学习率调度器用来确定学习率的变化规律,确保学习率在整个训练过程中得以合理地调节
max_train_steps = pc.epochs * num_update_steps_per_epoch
# 计算预热阶段的训练步数,用于初始化学习率调度
warm_steps = int(pc.warmup_ratio * max_train_steps) # 预热阶段的训练步数
# 创建学习率调度器,使用线性调度策略,根据训练的进行逐步调整学习率
lr_scheduler = get_scheduler(
name='linear',
optimizer=optimizer,
num_warmup_steps=warm_steps,
num_training_steps=max_train_steps)

# 初始化损失列表,用于记录训练过程中的损失值
loss_list = []
# 记录训练开始的时间,用于计算训练时长
tic_train = time.time()
# 创建分类评估器,用于评估模型性能
metric = ClassEvaluator()
# 定义损失函数,用于计算模型预测值与真实标签之间的差异
criterion = torch.nn.CrossEntropyLoss()
# 初始化训练次数和最佳F1分数,用于跟踪训练进度和模型性能
global_step, best_f1 = 0, 0

print('开始训练:')
for epoch in range(pc.epochs):
for batch in tqdm(train_dataloader, desc='模型训练'):
# print(f'batch-->{batch}')
# 将批次数据输入模型,获取logits
logits = model(input_ids=batch['input_ids'].to(pc.device),
token_type_ids=batch['token_type_ids'].to(pc.device),
attention_mask=batch['attention_mask'].to(pc.device)).logits
# print(f'logits->{logits.shape}')

# 真实标签
mask_labels = batch['mask_labels'].numpy().tolist()
# print(f'mask_labels--->{mask_labels}')
# 提取子标签
sub_labels = verbalizer.batch_find_sub_labels(mask_labels)
# print(f'sub_labels--->{sub_labels}')
# 获取子标签的token_ids
sub_labels = [ele['token_ids'] for ele in sub_labels]
# print(f'sub_labels_token_ids--->{sub_labels}')

# 计算掩码语言模型的损失值
loss = mlm_loss(logits,
batch['mask_positions'].to(pc.device),
sub_labels,
criterion,
pc.device)
# print(f'计算损失值-->{loss}')
# 清零优化器的梯度
optimizer.zero_grad()
# 反向传播计算梯度
loss.backward()
# 更新模型参数
optimizer.step()
# 更新学习率调度器
lr_scheduler.step()

# 将损失值添加到损失列表中
loss_list.append(loss)
# 训练次数增加1
global_step += 1
# 打印训练日志
if global_step % pc.logging_steps ` 0:
time_diff = time.time() - tic_train
loss_avg = sum(loss_list) / len(loss_list)
print("global step %d, epoch: %d, loss: %.5f, speed: %.2f step/s"
% (global_step, epoch, loss_avg, pc.logging_steps / time_diff))
tic_train = time.time()
# 模型验证
# 使用给定的模型、评估指标、数据加载器、分词器和标记化器进行模型评估
acc, precision, recall, f1, class_metrics = evaluate_model(model,
metric,
dev_dataloader,
tokenizer,
verbalizer)

# 打印评估结果中的精确度、召回率和F1分数
print("验证集的 precision: %.5f, recall: %.5f, F1: %.5f" % (precision, recall, f1))
# 如果当前F1分数高于最佳F1分数,则更新最佳F1分数和相关模型及分词器
if f1 > best_f1:
print(
f"最好的f1分数被更新: {best_f1:.5f} --> {f1:.5f}"
)
print(f'每种类型的Metrics为: {class_metrics}')
# 更新当前最佳的F1分数
best_f1 = f1
# 定义当前保存模型和分词器的目录
cur_save_dir = os.path.join(pc.save_dir, "model_best")
print(cur_save_dir)
# 检查并创建保存目录(如果不存在)
if not os.path.exists(cur_save_dir):
os.makedirs(cur_save_dir)
# 保存模型到指定目录
model.save_pretrained(cur_save_dir)
# 保存分词器到指定目录
tokenizer.save_pretrained(cur_save_dir)
tic_train = time.time()

print('训练结束')


def evaluate_model(model,
metric,
data_loader,
tokenizer,
verbalizer):
'''
在测试集上评估当前模型的训练效果。
:param model: 当前模型
:param metric: 评估指标类(metric)
:param data_loader: 测试集的dataloader
:param tokenizer: 分词器
:param verbalizer: 映射表
:return:
'''
model.eval()
metric.reset()

with torch.no_grad():
for step, batch in enumerate(tqdm(data_loader, desc='模型验证')):
# print(f'batch-->{batch}')
logits = model(input_ids=batch['input_ids'].to(pc.device),
token_type_ids=batch['token_type_ids'].to(pc.device),
attention_mask=batch['attention_mask'].to(pc.device)).logits
# print(f'验证集模型预测的结果————>{logits.shape}')

mask_labels = batch['mask_labels'].numpy().tolist() # (batch, label_num)
# print(f"mask_labels-0-->{mask_labels}")

for i in range(len(mask_labels)): # 去掉label中的[PAD] token
while tokenizer.pad_token_id in mask_labels[i]:
mask_labels[i].remove(tokenizer.pad_token_id)
# print(f'mask_labels-1-->{mask_labels}')
# 将mask_labels id转换为文字
mask_labels = [''.join(tokenizer.convert_ids_to_tokens(t)) for t in mask_labels]
# print(f'真实的结果主标签:mask_labels_str-->{mask_labels}')

# 获取模型预测的子标签
predictions = convert_logits_to_ids(logits,
batch['mask_positions']).cpu().numpy().tolist() # (batch, label_num)
# print(f'模型预测的子标签的结果-->{predictions}')

# 根据模型预测的子标签,找到子label属于的主label
predictions = verbalizer.batch_find_main_label(predictions) # 找到子label属于的主label
# print(f"找到模型预测的子标签对应的主标签的结果-->{predictions}')")

# 获得预测的主标签名
predictions = [ele['label'] for ele in predictions]
# print(f"只获得预测的主标签的结果string-->{predictions}')")

# 调用add_batch方法, 将模型预测的主标签与真实主标签保存到metric属性中
metric.add_batch(pred_batch=predictions, gold_batch=mask_labels)
eval_metric = metric.compute()
model.train()

return eval_metric['accuracy'], eval_metric['precision'], \
eval_metric['recall'], eval_metric['f1'], \
eval_metric['class_metrics']


if __name__ ` '__main__':
model2train()

  • 输出结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.....
global step 40, epoch: 4, loss: 0.62105, speed: 1.27 step/s
Evaluation precision: 0.78000, recall: 0.77000, F1: 0.76000
Each Class Metrics are: {'书籍': {'precision': 0.97, 'recall': 0.82, 'f1':
0.89}, '平板': {'precision': 0.57, 'recall': 0.84, 'f1': 0.68}, '手机':
{'precision': 0.0, 'recall': 0.0, 'f1': 0}, '水果': {'precision': 0.95,
'recall': 0.81, 'f1': 0.87}, '洗浴': {'precision': 0.7, 'recall': 0.71, 'f1':
0.7}, '电器': {'precision': 0.0, 'recall': 0.0, 'f1': 0}, '电脑': {'precision':
0.86, 'recall': 0.38, 'f1': 0.52}, '蒙牛': {'precision': 1.0, 'recall': 0.68,
'f1': 0.81}, '衣服': {'precision': 0.71, 'recall': 0.91, 'f1': 0.79}, '酒店':
{'precision': 1.0, 'recall': 0.88, 'f1': 0.93}}
global step 50, epoch: 6, loss: 0.50076, speed: 1.23 step/s
global step 60, epoch: 7, loss: 0.41744, speed: 1.23 step/s
...
global step 390, epoch: 48, loss: 0.06674, speed: 1.20 step/s
global step 400, epoch: 49, loss: 0.06507, speed: 1.21 step/s
Evaluation precision: 0.78000, recall: 0.76000, F1: 0.75000

  • 结论: BERT+PET模型在训练集上的表现是精确率=78%
  • 注意:本项目中只用了60条样本,在接近600条样本上精确率就已经达到了78%,如果想让指标更高,可以扩增样本。

3、实现模型预测函数

  • 目的:加载训练好的模型并测试效果

  • 代码路径:llm_tuning/prompt_tasks/PET/inference.py

    代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import os
import time
from typing import List
import torch
from transformers import AutoTokenizer, AutoModelForMaskedLM

from prompt_tasks.PET.data_handle.data_preprocess import convert_example
from prompt_tasks.PET.data_handle.template import HardTemplate
from prompt_tasks.PET.pet_config import ProjectConfig
from prompt_tasks.PET.utils.common_utils import convert_logits_to_ids
from prompt_tasks.PET.utils.verbalizer import Verbalizer

pc = ProjectConfig()

model_path = os.path.join(pc.save_dir, 'model_best')
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForMaskedLM.from_pretrained(model_path).to(pc.device)
model.eval()

max_label_len = 2 # 标签最大长度
verbalizer = Verbalizer(
verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=max_label_len)
prompt = open(pc.prompt_file, 'r', encoding='utf8').readlines()[0].strip() # prompt定义
print(f'提示词--> {prompt}')
hard_template = HardTemplate(prompt=prompt) # 模板转换器定义


def inference(contents: List[str]):
'''
推理函数,输入原始句子,输出mask label的预测值。
:param contents:
:return: 描原始句子列表。
'''
with (torch.no_grad()):
start_time = time.time()

# 将内容封装为示例字典,准备进行标记化处理
examples = {'text': contents}

# 对示例进行标记化处理,返回标记化输出
tokenized_output = convert_example(
examples,
tokenizer,
hard_template=hard_template,
max_seq_len=128,
max_label_len=max_label_len,
train_mode=False,
return_tensor=True)

# 使用模型进行预测,获取logits
logits = model(input_ids=tokenized_output['input_ids'].to(pc.device),
token_type_ids=tokenized_output['token_type_ids'].to(pc.device),
attention_mask=tokenized_output['attention_mask'].to(pc.device)).logits

# 将logits转换为预测标签
predictions = convert_logits_to_ids(logits, tokenized_output['mask_positions']
).cpu().numpy().tolist() # (batch, label_num)

# 找到子label属于的主label
predictions = verbalizer.batch_find_main_label(predictions)

# 提取预测的标签
predictions = [ele['label'] for ele in predictions]

used = time.time() - start_time
print(f'耗时 {used} 秒。')
return predictions


if __name__ ` '__main__':
contents = [
'天台很好看,躺在躺椅上很悠闲,因为活动所以我觉得性价比还不错,适合一家出行,特别是去迪士尼也蛮近的,下次有机会肯定还会再来的,值得推荐',
'环境,设施,很棒,周边配套设施齐全,前台小姐姐超级漂亮!酒店很赞,早餐不错,服务态度很好,前台美眉很漂亮。性价比超高的一家酒店。强烈推荐',
"物流超快,隔天就到了,还没用,屯着出游的时候用的,听方便的,占地小",
"福行市来到无早集市,因为是喜欢的面包店,所以跑来集市看看。第一眼就看到了,之前在微店买了小刘,这次买了老刘,还有一直喜欢的巧克力磅蛋糕。好奇老板为啥不做柠檬磅蛋糕了,微店一直都是买不到的状态。因为不爱碱水硬欧之类的,所以期待老板多来点其他小点,饼干一直也是大爱,那天好像也没看到",
"服务很用心,房型也很舒服,小朋友很喜欢,下次去嘉定还会再选择。床铺柔软舒适,晚上休息很安逸,隔音效果不错赞,下次还会来"
]
print("针对下面的文本评论,请分别给出对应所属类别:")
res = inference(contents)
print('推断的类别为:', res)
new_dict = {}
for i in range(len(contents)):
new_dict[contents[i]] = res[i]
print(f'new_dict-->{new_dict}')
  • 结果展示
1
2
3
4
5
6
7
8
9
10
11
12
13
{
'天台很好看,躺在躺椅上很悠闲,因为活动所以我觉得性价比还不错,适合一家出
行,特别是去迪士尼也蛮近的,下次有机会肯定还会再来的,值得推荐': '酒店',
'环境,设施,很棒,周边配套设施齐全,前台小姐姐超级漂亮!酒店很赞,早餐不
错,服务态度很好,前台美眉很漂亮。性价比超高的一家酒店。强烈推荐': '酒店',
'物流超快,隔天就到了,还没用,屯着出游的时候用的,听方便的,占地小': '平板',
'福行市来到无早集市,因为是喜欢的面包店,所以跑来集市看看。第一眼就看到了
,之前在微店买了小刘,这次买了老刘,还有一直喜欢的巧克力磅蛋糕。好奇老板为啥不做
柠檬磅蛋糕了,微店一直都是买不到的状态。因为不爱碱水硬欧之类的,所以期待老板多来
点其他小点,饼干一直也是大爱,那天好像也没看到': '水果',
'服务很用心,房型也很舒服,小朋友很喜欢,下次去嘉定还会再选择。床铺柔软舒
适,晚上休息很安逸,隔音效果不错赞,下次还会来': '酒店'
}

五、BERT+P-Tuning方式介绍【理解】

1、P-Tuning回顾

  • P-Tuning(Pattern-Tuning)是一种连续空间可学习模板,PET的目的解决PET的缺点,使用可学习的向量作为伪模板,不再手动构建模板。

以新闻分类任务为例:原始文本:中国女排再夺冠!P-Tuning可学习模板:[u1] [u2] …[MASK]…[un], Label:体育/财经/时政/军事

  • P-tuning 的核心思想是:用一个小的可训练模块把一组“连续提示向量”生成并插入到原始输入 embedding 中,令冻结的预训练模型在下游任务上产生正确输出,训练时仅更新 prompt encoder(或提示向量),从而实现低成本高效的调优。

2、环境准备

本项目基于 pytorch + transformers 实现,运行前请安装相关依赖包:


  • python`3.10
  • transformers`4.40.2
  • torch`2.5.1+cu121
  • datasets`3.6.0
  • scikit-learn`1.7.0

3、项目架构

  • 项目架构流程图:
  • 项目整体代码介绍:

image-20250821011025805

六、BERT+P-Tuning方式数据预处理【理解】

本项目中对数据部分的预处理步骤如下:

  • 1.查看项目数据集
  • 2.编写Config类项目文件配置代码
  • 3.编写数据处理相关代码

1、查看项目数据集

  • 数据存放位置:llm_tuning/prompt_tasks/P-Tuning/data

  • data文件夹里面包含3个txt文档,分别为:train.txt、dev.txt、verbalizer.txt


1.1 train.txt

  • train.txt为训练数据集,其部分数据展示如下:
1
2
3
4
5
6
水果	脆脆的,甜味可以,可能时间有点长了,水分不是很足。
平板 华为机器肯定不错,但第一次碰上京东最糟糕的服务,以后不想到京东购物了。
书籍 为什么不认真的检查一下, 发这么一本脏脏的书给顾客呢!
衣服 手感不错,用料也很好,不知道水洗后怎样,相信大品牌,质量过关,五星好评!!!
水果 苹果有点小,不过好吃,还有几个烂的。估计是故意的放的。差评。
衣服 掉色掉的厉害,洗一次就花了

train.txt一共包含63条样本数据,每一行用\t分开,前半部分为标签(label),后半部分为原始输入 (用户评论)。

如果想使用自定义数据训练,只需要仿照上述示例数据构建数据集即可。


1.2 dev.txt

  • dev.txt为验证数据集,其部分数据展示如下:
1
2
3
4
5
6
书籍	"一点都不好笑,很失望,内容也不是很实用"
衣服 完全是一条旧裤子。
手机 相机质量不错,如果阳光充足,可以和数码相机媲美.界面比较人性化,容易使用.软件安装简便
书籍 明明说有货,结果送货又没有了。并且也不告诉我,怎么评啊
洗浴 非常不满意,晚上洗的头发,第二天头痒痒的不行了,还都是头皮屑。
水果 这个苹果感觉是长熟的苹果,没有打蜡,不错,又甜又脆

dev.txt一共包含417条样本数据,每一行用\t分开,前半部分为标签(label),后半部分为原始输入 (用户评论)。

如果想使用自定义数据训练,只需要仿照上述示例数据构建数据集即可。

1.3 verbalizer.txt

  • verbalizer.txt 主要用于定义「真实标签」到「标签预测词」之间的映射。在有些情况下,将「真实标签」作为 [MASK] 去预测可能不具备很好的语义通顺性,因此,我们会对「真实标签」做一定的映射。

  • 例如:

1
"中国爆冷2-1战胜韩国"是一则[MASK][MASK]新闻。	体育
  • 这句话中的标签为「体育」,但如果我们将标签设置为「足球」会更容易预测。

  • 因此,我们可以对「体育」这个 label 构建许多个子标签,在推理时,只要预测到子标签最终推理出真实标签即可,如下:

1
体育 -> 足球,篮球,网球,棒球,乒乓,体育
  • 项目中标签词映射数据展示如下:
1
2
3
4
5
6
7
8
9
10
电脑	电脑
水果 水果
平板 平板
衣服 衣服
酒店 酒店
洗浴 洗浴
书籍 书籍
蒙牛 蒙牛
手机 手机
电器 电器

verbalizer.txt 一共包含10个类别,上述数据中,我们使用了1对1的verbalizer, 如果想定义一对多的映射,只需要在后面用”,”分割即可, eg:

1
水果	苹果,香蕉,橘子

若想使用自定义数据训练,只需要仿照示例数据构建数据集

2、编写Config类项目文件配置代码

  • 代码路径:llm_tuning/prompt_tasks/P-Tuning/ptune_config.py

  • config文件目的:配置项目常用变量,一般这些变量属于不经常改变的,比如:训练文件路径、模型训练次数、模型超参数等等

具体代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import torch
import os

base_dir = os.path.dirname(os.path.abspath(__file__))
# print(f'base_dir-->{base_dir}')


class ProjectConfig(object):
def __init__(self):
# 设置设备为CUDA:0(如果可用),否则设置为CPU
self.device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
# self.device = "mps:0"
# 设置预训练模型的路径
self.pre_model = os.path.join(base_dir, '../../bert-base-chinese')
# 设置训练数据集的路径
self.train_path = os.path.join(base_dir, 'data/train.txt')
# 设置验证数据集的路径
self.dev_path = os.path.join(base_dir, 'data/dev.txt')
# 设置verbalizer文件的路径,用于将标签映射到文本
self.verbalizer = os.path.join(base_dir, 'data/verbalizer.txt')
# 设置最大序列长度
self.max_seq_len = 512
# 设置批量大小
self.batch_size = 8
# 设置学习率
self.learning_rate = 5e-5
# 权重衰减系数
self.weight_decay = 0
# 学习率预热的系数
self.warmup_ratio = 0.06
# 伪token的个数
self.p_embedding_num = 6
# 最大标签长度
self.max_label_len = 2
# 设置训练的轮数
self.epochs = 50
# 设置日志记录的步数
self.logging_steps = 10
# 设置验证的步数
self.valid_steps = 20
# 设置保存模型的目录
self.save_dir = os.path.join(base_dir, 'save_model')


if __name__ ` '__main__':
pc = ProjectConfig()
print(pc.verbalizer)

3、编写数据处理相关代码

  • 代码路径:llm_tuning/prompt_tasks/P-Tuning/data_handle/

  • data_handle文件夹中一共包含两个py脚本:data_preprocess.py、data_loader.py


3.1 data_preprocess.py

  • 目的: 将模板与原始输入文本进行拼接,构造模型接受的输入数据
  • 代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import torch
import numpy as np
from datasets import load_dataset
from transformers import AutoTokenizer

from prompt_tasks.P_Tuning.ptune_config import ProjectConfig


def convert_example(
examples: dict,
tokenizer,
max_seq_len: int,
max_label_len: int,
p_embedding_num=6,
train_mode=True,
return_tensor=False
) -> dict:
'''
将样本数据转换为模型接收的输入数据。
:param examples: (dict): 训练数据样本
e.g. -> {
"text": [
'娱乐 嗨放派怎么停播了',
'体育 世界杯为何迟迟不见宣传',
...
]
}
:param tokenizer: 分词器
:param max_seq_len: 最大句子长度
:param max_label_len: (int): 最大label长度,若没有达到最大长度,则padding为最大长度
:param p_embedding_num: (int): p-tuning token 的个数
:param train_mode: 训练阶段 or 推理阶段
:param return_tensor: 是否返回tensor类型,如不是,则返回numpy类型。
:return:
dict (str: np.array) -> tokenized_output = {
'input_ids': [[101, 3928, ...], [101, 4395, ...]],
'token_type_ids': [[0, 0, ...], [0, 0, ...]],
'mask_positions': [[5, 6, ...], [3, 4, ...]],
'mask_labels': [[183, 234], [298, 322], ...]
}
'''
tokenized_output = {
'input_ids': [],
'attention_mask': [],
'mask_positions': [], # 记录label的位置(即MASK Token的位置)
'mask_labels': [] # 记录MASK Token的原始值(即Label值)
}

# print(f"examples['text']-->{examples['text']}")
# 遍历文本数据集,其中每个文本数据被赋予一个索引和值
for i, example in enumerate(examples['text']):
try:
# 将 prompt token(s) 插在 [CLS] 之后
start_mask_position = 1
if train_mode:
# print(f"example-->{example}")
# strip() 方法用于移除字符串头尾指定的字符(默认为空格),这里用于去除可能存在的多余空格
# split('\t', 1) 方法用于按照制表符('\t')将字符串分割成两部分,限制分割次数为1,确保只分割第一个制表符
label, content = example.strip().split('\t', 1)
# print(f'label-->{label}')
# print(f'content-->{content}')

# 使用tokenizer对标签进行编码,label token 转 id
mask_labels = tokenizer(text=label)
# print(f'mask_labels-->{mask_labels}')
# 从字典中获取input_ids,并丢掉[CLS]和[SEP]
mask_labels = mask_labels['input_ids'][1:-1]
# 将 label 长度限制为最长
mask_labels = mask_labels[:max_label_len]
# 将 label 补到最长
mask_labels += [tokenizer.pad_token_id] * (max_label_len - len(mask_labels)) # 将 label 补到最长
# print(f'mask_labels-->{mask_labels}')
# 将编码后的标签添加到tokenized_output字典中的'mask_labels'列表中
tokenized_output['mask_labels'].append(mask_labels)
else:
# 如果不是训练模式,直接将文本内容进行修剪并使用
content = example.strip()
encoded_inputs = tokenizer(text=content,truncation=True,max_length=max_seq_len,padding='max_length')
except:
continue

# 获取编码后的输入
input_ids = encoded_inputs['input_ids']
# print(f'input_ids-->{input_ids}')
# print(f'原始的input_id的长度-->{len(input_ids)}')

# 1.生成 MASK Tokens, 和label长度一致
mask_tokens = ['[MASK]'] * max_label_len
# print(f'mask_tokens-->{mask_tokens}')
# 将 MASK Tokens 转为 id
mask_ids = tokenizer.convert_tokens_to_ids(mask_tokens)
# print(f'mask_ids-->{mask_ids}')

# 2.构建 prompt token(s)
# 根据p_embedding_num生成对应的特殊token列表
p_tokens = ["[unused{}]".format(i + 1) for i in range(p_embedding_num)]
# print(f'p_tokens-->{p_tokens}')
# token 转 id
p_tokens_ids = tokenizer.convert_tokens_to_ids(p_tokens)
# print(f'p_tokens_ids-->{p_tokens_ids}')

# 根据 最大长度-p_token长度-label长度-1,裁剪content的长度 (裁剪[SEP]前的token, 所以-1)
tmp_input_ids = input_ids[:max_seq_len - len(mask_ids) - len(p_tokens_ids) - 1]
# print(f'tmp_input_ids1-->{tmp_input_ids}')
# print(f'tmp_input_ids1-->{len(tmp_input_ids)}')

# 3.插入 MASK -> [CLS][MASK][MASK]世界杯...[SEP]
tmp_input_ids = tmp_input_ids[:start_mask_position] + mask_ids + tmp_input_ids[start_mask_position:] + [input_ids[-1]]
# print(f'插入mask和sep之后的 tmp_input_ids--{tmp_input_ids}')
# print(f'插入mask和sep之后的 tmp_input_ids长度--{len(tmp_input_ids)}')

# 4.插入 prompt -> [unused1][unused2]...[CLS][MASK]...[SEP]
input_ids = p_tokens_ids + tmp_input_ids
# print(f'插入模版之后的 input_ids-->{input_ids}')
# print(f'插入模版之后的 input_ids长度-->{len(input_ids)}')

# 将新的输入添加到tokenized_output字典中
tokenized_output['input_ids'].append(input_ids)

# 将 Mask Tokens 的位置记录下来
mask_positions = [len(p_tokens_ids) + start_mask_position + i
for i in range(max_label_len)]
# print(f'mask_positions-->{mask_positions}')
# 将 Mask Tokens 的位置记录下来
tokenized_output['mask_positions'].append(mask_positions)

# 如果输入需要token_type_ids,可以进行添加
if 'token_type_ids' in encoded_inputs: # 兼容不需要 token_type_id 的模型, e.g. Roberta-Base
tmp = encoded_inputs['token_type_ids']
if 'token_type_ids' not in tokenized_output:
tokenized_output['token_type_ids'] = [tmp]
else:
tokenized_output['token_type_ids'].append(tmp)

# print(f'原始的attention_mask-->{encoded_inputs["attention_mask"]}')
# 修改attention_mask的0和1的位置,因为插入了prompt和MASK Tokens,影响了原来的句子padding的部分,所以需要重新生成
attention_mask = get_attention_mask(input_ids)
# print(f'修改的attention_mask-->{attention_mask}')
tokenized_output['attention_mask'].append(attention_mask)

# break

# 遍历tokenized_output字典,其中k是键,v是值
for k, v in tokenized_output.items():
# 如果return_tensor为True,将值转换为torch.LongTensor类型
if return_tensor:
tokenized_output[k] = torch.LongTensor(v)
# 否则,将值转换为numpy数组
else:
tokenized_output[k] = np.array(v)

return tokenized_output


def get_attention_mask(alist):
'''
生成注意力掩码。
对于输入的列表,将其中的每个元素与0进行比较,如果元素大于0,则在输出列表中对应位置设置为1,否则设置为0。
这个函数的目的是为了创建一个掩码,用于在注意力机制中指示哪些位置是有效的(即大于0),哪些位置是无效的(即等于0)。
:param alist: (list): 一个包含数字的列表,用于生成注意力掩码。
:return: list: 一个与输入列表长度相同的列表,其中原始列表中大于0的位置被设置为1,等于0的位置被设置为0。
'''
# 使用numpy的where函数来创建掩码:元素大于0则输出1,否则输出0
new_a = np.where(np.array(alist) > 0, 1, 0)
# 将生成的掩码数组转换回列表格式并返回
return new_a.tolist()


if __name__ ` '__main__':
pc = ProjectConfig()
train_dataset = load_dataset('text', data_files={'train': pc.train_path})
print(type(train_dataset))
# print(train_dataset)
# print(train_dataset['train']['text'])
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)
tokenized_output = convert_example(examples=train_dataset['train'],
tokenizer=tokenizer,
max_seq_len=20,
max_label_len=2,
p_embedding_num=6,
train_mode=True,
return_tensor=False)
print(tokenized_output)
print(type(tokenized_output['mask_positions']))

打印结果展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{'input_ids': array([[   1,    2,    3, ..., 1912, 6225,  102],
[ 1, 2, 3, ..., 3300, 5741, 102],
[ 1, 2, 3, ..., 6574, 7030, 0],
...,
[ 1, 2, 3, ..., 8024, 2571, 0],
[ 1, 2, 3, ..., 3221, 3175, 102],
[ 1, 2, 3, ..., 5277, 3688, 102]]),
'attention_mask': array([[1, 1, 1, ..., 1, 1, 1],
[1, 1, 1, ..., 1, 1, 1],
[1, 1, 1, ..., 1, 1, 0],
...,
[1, 1, 1, ..., 1, 1, 0],
[1, 1, 1, ..., 1, 1, 1],
[1, 1, 1, ..., 1, 1, 1]]),
'mask_positions': array([[7, 8],
[7, 8],
[7, 8],
...,
[7, 8],
[7, 8],
[7, 8]]),
'mask_labels': array([[4510, 5554],
[3717, 3362],
[2398, 3352],
...,
[3819, 3861],
[6983, 2421],
[3819, 3861]]),
'token_type_ids': array([[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
...,
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0]])}

3.2 data_loader.py

  • 目的:定义数据加载器

  • 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from functools import partial
from datasets import load_dataset
from torch.utils.data import DataLoader
from transformers import AutoTokenizer, default_data_collator

from prompt_tasks.P_Tuning.data_handle.data_preprocess import convert_example
from prompt_tasks.P_Tuning.ptune_config import ProjectConfig

# 实例化项目配置文件
pc = ProjectConfig()

# 使用项目配置文件中指定的预训练模型,初始化一个自动分词器
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)


def get_data():
'''
加载并处理数据集。
该函数从指定路径加载训练和开发数据集,并将它们转换为适合模型训练的格式。
使用Hugging Face的`load_dataset`函数加载数据,然后使用`partial`函数创建一个带有固定参数的新函数`new_func`,
用于转换数据集示例。转换后的数据集使用`DataLoader`包装,以便于批量处理和训练过程中使用。
:return: train_dataloader: 训练数据加载器。
dev_dataloader: 测试数据加载器。
'''
# 加载数据集,包括训练和测试集
dataset = load_dataset('text', data_files={'train': pc.train_path,
'dev': pc.dev_path})
# print(f'dataset-->{dataset}')

# 创建一个带有固定参数的函数,用于转换数据集示例
new_func = partial(convert_example,
tokenizer=tokenizer,
max_seq_len=pc.max_seq_len,
max_label_len=pc.max_label_len,
p_embedding_num=pc.p_embedding_num)

# 应用转换函数到数据集上
dataset = dataset.map(new_func, batched=True)

# 分离训练和测试数据集
train_dataset = dataset["train"]
# print(f'train_dataset-->{train_dataset}')
dev_dataset = dataset["dev"]
# print(f'dev_dataset-->{dev_dataset}')

# 创建训练数据加载器
train_dataloader = DataLoader(train_dataset,
shuffle=True,
collate_fn=default_data_collator,
batch_size=pc.batch_size)

# 创建测试数据加载器
dev_dataloader = DataLoader(dev_dataset,
collate_fn=default_data_collator,
batch_size=pc.batch_size)

# 返回训练和测试数据加载器
return train_dataloader, dev_dataloader


if __name__ ` '__main__':
# 加载训练和测试数据
train_dataloader, dev_dataloader = get_data()

# 打印训练和测试数据加载器的长度
print(f'len(train_dataloader)-->{len(train_dataloader)}')
print(f'len(dev_dataloader)-->{len(dev_dataloader)}')

# 遍历训练数据加载器,查看数据
for i, value in enumerate(train_dataloader):
print(value)
# 打印输入ID的Tensor类型
print(value['input_ids'].dtype)
break

打印结果展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{'input_ids': tensor([[1, 2, 3,  ..., 0, 0, 0],
[1, 2, 3, ..., 0, 0, 0],
[1, 2, 3, ..., 0, 0, 0],
...,
[1, 2, 3, ..., 0, 0, 0],
[1, 2, 3, ..., 0, 0, 0],
[1, 2, 3, ..., 0, 0, 0]]),
'attention_mask': tensor([[1, 1, 1, ..., 0, 0, 0],
[1, 1, 1, ..., 0, 0, 0],
[1, 1, 1, ..., 0, 0, 0],
...,
[1, 1, 1, ..., 0, 0, 0],
[1, 1, 1, ..., 0, 0, 0],
[1, 1, 1, ..., 0, 0, 0]]),
'mask_positions': tensor([[7, 8],
[7, 8],
[7, 8],
[7, 8],
[7, 8],
[7, 8],
[7, 8],
[7, 8]]),
'mask_labels': tensor([[6132, 3302],
[3717, 3362],
[6132, 3302],
[6983, 2421],
[6983, 2421],
[6132, 3302],
[3717, 3362],
[2398, 3352]]),
'token_type_ids': tensor([[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
...,
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0]])}
torch.int64

七、BERT+P-Tuning方式模型搭建与训练【实现】

本项目中完成BERT+P-Tuning模型搭建、训练及应用的步骤如下(注意:因为本项目中使用的是BERT预训练模型,所以直接加载即可,无需重复搭建模型架构):

  • 1.实现模型工具类函数
  • 2.实现模型训练函数,验证函数
  • 3.实现模型预测函数

1、实现模型工具类函数

  • 目的:模型在训练、验证、预测时需要的函数
  • 代码路径:llm_tuning/prompt_tasks/P_Tuning/utils
  • utils文件夹共包含3个py脚本:verbalizer.py、metirc_utils.py以及common_utils.py

1.1 verbalizer.py

  • 目的:定义一个Verbalizer类,用于将一个Label对应到其子Label的映射。
  • 具体代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# Union 是 typing 模块中定义的一个类,用于表示多个类型中的任意一种类型
from typing import Union, List
from transformers import AutoTokenizer

from prompt_tasks.P_Tuning.ptune_config import ProjectConfig

pc = ProjectConfig()


# Verbalizer类,用于将一个Label对应到其子Label的映射。
class Verbalizer(object):
def __init__(self,
verbalizer_file: str,
tokenizer, max_label_len: int
):
'''
:param verbalizer_file: verbalizer文件存放地址。
:param tokenizer: 用于文本和id之间的转换。
:param max_label_len: 标签长度,若大于则截断,若小于则补齐
'''
self.tokenizer = tokenizer
self.label_dict = self.load_label_dict(verbalizer_file)
self.max_label_len = max_label_len

def load_label_dict(self, verbalizer_file: str):
'''
读取本地文件,构建verbalizer字典。
:param verbalizer_file: verbalizer文件存放地址。
:return:
dict -> {
'体育': ['篮球', '足球','网球', '排球', ...],
'酒店': ['宾馆', '旅馆', '旅店', '酒店', ...],
...
}
'''
# 初始化一个空字典,用于存储标签和子标签的关系
label_dict = {}

# 打开verbalizer文件,以只读模式,使用utf8编码
with open(verbalizer_file, 'r', encoding='utf-8') as f:
# 读取文件的每一行
for line in f:
# 移除行尾的换行符,并按制表符('\t')分割标签和子标签
label, sub_labels = line.strip().split('\t')
# 将子标签按逗号(,)分割成列表,使用set去重后再转回列表,存储到label_dict中
label_dict[label] = list(set(sub_labels.split(',')))
# 返回处理后的标签和子标签的字典
return label_dict

def find_sub_labels(self, label: Union[list, str]):
'''
通过主标签找到所有的子标签。
:param label: 标签, 文本型 或 id_list, e.g. -> '体育' or [860, 5509]
:return:
dict -> {
'sub_labels': ['足球', '网球'],
'token_ids': [[6639, 4413], [5381, 4413]]
}
'''
# 如果传入的label为id列表,则通过tokenizer转换回字符串
if type(label) ` list:
# 移除label中的pad_token_id,直到label中不再包含它
while self.tokenizer.pad_token_id in label:
label.remove(self.tokenizer.pad_token_id)
# 将处理后的id列表转换为tokens,并拼接成字符串
label = ''.join(self.tokenizer.convert_ids_to_tokens(label))
# print(f'label-->{label}')
# 检查转换后的label是否在标签字典中,如果不在则抛出异常
if label not in self.label_dict:
raise ValueError(f'Lable Error: "{label}" 不在 label_dict {list(self.label_dict)}.')

# 从标签字典中获取与label对应的子标签
sub_labels = self.label_dict[label]
# print(f'sub_labels-->{sub_labels}')
# 将子标签作为结果的一个部分存储在字典中
ret = {'sub_labels': sub_labels}

# 对每个子标签进行token化,不含特殊符号
token_ids = [token_id for token_id in self.tokenizer(sub_labels, add_special_tokens=False)['input_ids']]
# print(f'token_ids-->{token_ids}')
# 遍历所有的token_ids,进行截断与补齐操作
for i in range(len(token_ids)):
# 对标签进行截断
token_ids[i] = token_ids[i][:self.max_label_len]
# 如果长度不足max_label_len,则使用pad_token_id进行补齐
if len(token_ids[i]) < self.max_label_len:
token_ids[i] = token_ids[i] + [self.tokenizer.pad_token_id] * (self.max_label_len - len(token_ids[i]))
# 将处理后的token_ids存入ret字典中
ret['token_ids'] = token_ids
return ret

def batch_find_sub_labels(self, label: List[Union[list, str]]):
'''
批量找到子标签。
:param label: 标签列表, [[4510, 5554], [860, 5509]] or ['体育', '电脑']
:return:
list -> [
{
'sub_labels': ['笔记本', '电脑'],
'token_ids': [[5011, 6381, 3315], [4510, 5554]]
},
...
]
'''
return [self.find_sub_labels(l) for l in label]

def get_common_sub_str(self,
str1: str,
str2: str
):
'''
寻找最大公共子串(连续子序列)。
:param str1: abcd
:param str2: abadbcdba
:return:
'''
# 初始化两个字符串的长度
lstr1, lstr2 = len(str1), len(str2)
# 生成0矩阵,为方便后续计算,比字符串长度多了一列,生成一个 lstr1+1 * lstr2+1 的二维矩阵
record = [[0 for i in range(lstr2 + 1)] for j in range(lstr1 + 1)]
# 初始化最长匹配对应在str1中的最后一位
p = 0
# 初始化最长匹配长度
maxNum = 0
# 遍历两个字符串,寻找最长公共子串
for i in range(1, lstr1 + 1):
for j in range(1, lstr2 + 1):
# 当发现相同字符时
if str1[i - 1] ` str2[j - 1]:
# 在record矩阵中记录匹配长度
record[i][j] = record[i - 1][j - 1] + 1
# 更新最长匹配长度和对应在str1中的最后一位
if record[i][j] > maxNum:
maxNum = record[i][j]
p = i

# 返回最长公共子串和其长度
return str1[p - maxNum:p], maxNum

def hard_mapping(self, sub_label: str):
'''
强匹配函数,当模型生成的子label不存在时,通过最大公共子串找到重合度最高的主label。
:param sub_label: 子label
:return: 主label
'''
# 初始化变量label和max_overlap_str,用于记录最大重叠度的标签和对应的重叠度值
label, max_overlap_str = '', 0

# 遍历标签字典,其中main_label是主标签,sub_labels是与主标签相关的子标签列表
for main_label, sub_labels in self.label_dict.items():
overlap_num = 0
# 对于每个子标签,计算它与当前推理标签之间的最长公共子串长度总和
for s_label in sub_labels:
# 累加每个子标签与当前推理标签之间的最长公共子串长度
overlap_num += self.get_common_sub_str(sub_label, s_label)[1]

# 如果当前的重叠度大于或等于之前的最大重叠度,则更新最大重叠度和对应的标签
if overlap_num >= max_overlap_str:
max_overlap_str = overlap_num
label = main_label

return label

def find_main_label(self,
sub_label: Union[list, str],
hard_mapping=True
):
'''
通过子标签找到父标签。
:param sub_label: 子标签, 文本型 或 id_list, e.g. -> '苹果' or [5741, 3362]
:param hard_mapping: 当生成的词语不存在时,是否一定要匹配到一个最相似的label。
:return:
dict -> {
'label': '水果',
'token_ids': [3717, 3362]
}
'''
# 如果传入的sub_label为id列表,则通过tokenizer转换回字符串
if type(sub_label) ` list:
pad_token_id = self.tokenizer.pad_token_id
# 移除列表中的[PAD]token,避免影响后续处理
while pad_token_id in sub_label:
sub_label.remove(pad_token_id)
# 将id列表转换为对应的字符串
sub_label = ''.join(self.tokenizer.convert_ids_to_tokens(sub_label))
# print(f'sub_label-->{sub_label}')
# 初始化主标签为'无',作为未找到特定子标签时的默认值
main_label = '无'

# 遍历标签字典,寻找与子标签匹配的主标签
for label, sub_labels in self.label_dict.items():
# 检查当前子标签是否在字典中对应的子标签列表中
if sub_label in sub_labels:
# 当找到匹配时,更新主标签并终止循环
main_label = label
break
# print(f'main_label-->{main_label}')
# 如果主标签为'无'且启用了强匹配功能,则使用强匹配方法更新主标签
if main_label ` '无' and hard_mapping:
main_label = self.hard_mapping(sub_label)
# print('强匹配', main_label)
ret = {
'label': main_label,
'token_ids': self.tokenizer(main_label, add_special_tokens=False)['input_ids']
}
return ret

def batch_find_main_label(self,
sub_label: List[Union[list, str]],
hard_mapping=True
):
'''
批量通过子标签找父标签。
:param sub_label: 子标签列表, ['苹果', ...] or [[5741, 3362], ...]
:param hard_mapping:
:return:
list: [
{
'label': '水果',
'token_ids': [3717, 3362]
},
...
]
'''
return [self.find_main_label(l, hard_mapping) for l in sub_label]


if __name__ ` '__main__':
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)
verbalizer = Verbalizer(
verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=2)
print(f'label_dict-->{verbalizer.label_dict}')

# 查找单个子标签
label = '电脑'
ret = verbalizer.find_sub_labels(label)
print(f'ret-->{ret}')
print('*' * 80)

# 查找多个子标签
labels = ['电脑', '衣服']
# labels = [[4510, 5554], [6132, 3302]]
result = verbalizer.batch_find_sub_labels(labels)
print(f'result-->{result}')
print('*' * 80)

# 查找单个子标签对应的父标签
# sub_label = [4510, 5554]
sub_label = '衣电'
ret = verbalizer.find_main_label(sub_label)
print(f'ret-->{ret}')
print('*' * 80)

# 查找多个子标签对应的父标签
# sub_label = ['衣服', '牛奶']
sub_label = [[6132, 3302], [5885, 4281]]
ret = verbalizer.batch_find_main_label(sub_label, hard_mapping=True)
print(f'ret-->{ret}')

print结果显示:

1
2
3
4
5
6
7
8
label_dict-->{'电脑': ['电脑'], '水果': ['水果'], '平板': ['平板'], '衣服': ['衣服'], '酒店': ['酒店'], '洗浴': ['洗浴'], '书籍': ['书籍'], '蒙牛': ['蒙牛'], '手机': ['手机'], '电器': ['电器']}
ret-->{'sub_labels': ['电脑'], 'token_ids': [[4510, 5554]]}
********************************************************************************
result-->[{'sub_labels': ['电脑'], 'token_ids': [[4510, 5554]]}, {'sub_labels': ['衣服'], 'token_ids': [[6132, 3302]]}]
********************************************************************************
ret-->{'label': '电器', 'token_ids': [4510, 1690]}
********************************************************************************
ret-->[{'label': '衣服', 'token_ids': [6132, 3302]}, {'label': '蒙牛', 'token_ids': [5885, 4281]}]

1.2 common_utils.py

  • 目的:定义损失函数、将mask_position位置的token logits转换为token的id。
  • 脚本里面包含两个函数:mlm_loss()以及convert_logits_to_ids()
  • 代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import torch

def mlm_loss(logits,
mask_positions,
sub_mask_labels,
cross_entropy_criterion,
device):
'''
计算指定位置的mask token的output与label之间的cross entropy loss。
:param logits: (torch.tensor): 模型原始输出 -> (batch_size, seq_len, vocab_size)
:param mask_positions: (torch.tensor): mask token的位置 -> (batch_size, mask_label_num)
:param sub_mask_labels: (list): mask token的sub label, 由于每个label的sub_label数目不同,所以这里是个变长的list,
e.g. -> [
[[2398, 3352]],
[[2398, 3352], [3819, 3861]]
]
:param cross_entropy_criterion: (CrossEntropyLoss): CE Loss计算器
:param device: (str): cpu还是gpu
:return: CE Loss
'''
'''
获取logits的尺寸信息,为后续计算做准备
logits.size()返回一个包含三个维度的元组
第一个维度(batch_size)代表批次大小,即一次处理的数据批次包含的样本数量
第二个维度(seq_len)代表序列长度,即每个样本中包含的序列元素数量
第三个维度(vocab_size)代表词汇表大小,即每个序列元素可能的类别数量
'''
batch_size, seq_len, vocab_size = logits.size()
# print(f'模型预测结果logits-->{logits.size()}')
# print(f'mask_positions-->{mask_positions.shape}')
# print(f'sub_mask_labels-->{sub_mask_labels}')

# 初始化loss变量为None,用于后续可能的损失计算
loss = None
# 遍历 logits、sub_mask_labels 和 mask_positions 的元素
for single_value in zip(logits, sub_mask_labels, mask_positions):
# 获取当前token的 logits
single_logits = single_value[0]
# print(f'single_logits-->{single_logits.shape}') # 形状[512, 21128]
# 获取当前token的 sub_mask_labels
single_sub_mask_labels = single_value[1]
# print(f'single_sub_mask_labels-->{single_sub_mask_labels}')
# 获取当前token的 mask_positions
single_mask_positions = single_value[2]
# print(f'single_mask_positions-->{single_mask_positions}') # 形状size[2]-->具体值([5, 6])

# 从单个序列的logits中,提取出被掩码位置的logits
single_mask_logits = single_logits[single_mask_positions] # (mask_label_num, vocab_size)
# 打印被掩码位置logits的形状,以验证其是否符合预期
# print(f'single_mask_logits-->{single_mask_logits.shape}')

# 模型训练时主标签对应的所有子标签都有相似的特征值, 在计算CE Loss时,需要将每个子标签的对应的损失求平均,因此需要将预测的概率值进行扩展
# 对单个 single_mask_logits 进行扩展,使其在第一个维度上重复,以匹配 single_sub_mask_labels 的数量
# 使用repeat设置重复的倍数 (sub_label_num, mask_label_num, vocab_size)
single_mask_logits = single_mask_logits.repeat(len(single_sub_mask_labels), 1, 1)
# 打印重复后的single_mask_logits的形状,以便调试和验证重复操作的效果
# print(f'重复后的single_mask_logits-->{single_mask_logits.shape}')

# 将三维张量调整为二维,以便计算损失
single_mask_logits = single_mask_logits.reshape(-1, vocab_size) # (sub_label_num * mask_label_num, vocab_size)
# print(f'调整成二维后的single_mask_logits-->{single_mask_logits.shape}')

# 将子标签转换为张量,并调整形状以匹配模型预测的结果
single_sub_mask_labels = torch.LongTensor(single_sub_mask_labels).to(device) # (sub_label_num, mask_label_num)
# 计算损失值时真实子标签维度为1维,因此需要将其展平以匹配模型预测的结果
single_sub_mask_labels = single_sub_mask_labels.reshape(-1, 1).squeeze() # (sub_label_num * mask_label_num)
# print(f'真实子标签mask值:single_sub_mask_labels-->{single_sub_mask_labels.shape}')
# print(f'真实子标签mask值:single_sub_mask_labels-->{single_sub_mask_labels}')

# 计算当前批次所有子标签的损失
cur_loss = cross_entropy_criterion(single_mask_logits, single_sub_mask_labels)
# 计算当前批次所有子标签的平均损失
cur_loss = cur_loss / len(single_sub_mask_labels)

# 如果当前损失loss未被初始化(即为None),则将其设置为当前批次的损失cur_loss
if not loss:
loss = cur_loss
# 如果当前损失loss已经存在,则将当前批次的损失cur_loss累加到loss中
else:
loss += cur_loss

# 计算平均损失:将累计的损失loss除以批次大小batch_size
loss = loss / batch_size # (1,)
return loss

def convert_logits_to_ids(
logits: torch.tensor,
mask_positions: torch.tensor):
'''
输入Language Model的词表概率分布(LMModel的logits),将mask_position位置的token logits转换为token的id。
:param logits: (torch.tensor): model output -> (batch, seq_len, vocab_size) [8, 512, 21128]
:param mask_positions: (torch.tensor): mask token的位置 -> (batch, mask_label_num) [8, 2]
:return: 对应mask position上最大概率的推理token -> (batch, mask_label_num) [8, 2]
'''
# 获取标签的长度,mask_positions.size()返回的是一个包含维度的元组,[1]表示获取第二个维度的大小
label_length = mask_positions.size()[1]
# print(f'label_length-->{label_length}')

# 获取批次大小、序列长度和词汇表大小,logits.size()返回的是一个包含维度的元组
batch_size, seq_len, vocab_size = logits.size()

# 初始化一个空列表,用于存储重塑后的 mask_positions
mask_positions_after_reshaped = []

# print(f'mask_positions.detach().cpu().numpy().tolist()-->{mask_positions.detach().cpu().numpy().tolist()}')
# 遍历每个批次的mask_positions
for batch, mask_pos in enumerate(mask_positions.detach().cpu().numpy().tolist()):
# 遍历每个mask位置
for pos in mask_pos:
# 将批次号和序列中的mask位置结合起来,得到重塑后的mask_positions
mask_positions_after_reshaped.append(batch * seq_len + pos)
# print(f'mask_positions_after_reshaped-->{mask_positions_after_reshaped}')
# print(f'原始的logits-->{logits.shape}')

# 将原始的logits重塑为(batch_size * seq_len, vocab_size)的形状
logits = logits.reshape(batch_size * seq_len, -1) # (batch_size * seq_len, vocab_size)
# print(f'改变原始模型输出的结果形状-->{logits.shape}')

# 从重塑后的logits中,选择出被掩码位置的logits
mask_logits = logits[mask_positions_after_reshaped]
# print(f'被掩码位置的logits-->{mask_logits.shape}')

# 获取每个样本mask位置所预测的tokens
predict_tokens = mask_logits.argmax(dim=-1) # (batch * label_num)
# print(f'获取每个样本mask位置预测的tokens', predict_tokens)

# 将每个样本mask位置预测的tokens重塑为(batch, label_num)的形状
predict_tokens = predict_tokens.reshape(-1, label_length) # (batch, label_num)
# print(f'predict_tokens-->{predict_tokens}')

return predict_tokens

1.3 metirc_utils.py

  • 目的:定义(多)分类问题下的指标评估(acc, precision, recall, f1)。
  • 代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
from typing import List
import numpy as np
from sklearn.metrics import accuracy_score, precision_score, f1_score
from sklearn.metrics import recall_score, confusion_matrix


class ClassEvaluator(object):
def __init__(self):
# 初始化真实结果和预测结果的列表
self.goldens = [] # 存储真实结果数据
self.predictions = [] # 存储预测结果数据

def add_batch(self,
pred_batch: List[List],
gold_batch: List[List]):
'''
添加一个batch中的prediction和gold列表,用于后续统一计算。
:param pred_batch: (list): 模型预测标签列表, e.g. -> [['体', '育'], ['财', '经'], ...]
:param gold_batch: (list): 真实标签标签列表, e.g. -> [['体', '育'], ['财', '经'], ...]
:return:
'''
# 确保预测批次和真实批次长度一致,这是后续处理的前提条件
assert len(pred_batch) ` len(gold_batch)
# print(f'pred_batch0-->{pred_batch}')
# print(f'gold_batch0-->{gold_batch}')

# 若遇到多个子标签构成一个标签的情况
# 判断gold_batch的第一个元素是否为列表或元组类型
if type(gold_batch[0]) in [list, tuple]:
# 如果是,则将pred_batch中的每个元素转换为字符串后拼接起来
pred_batch = [''.join([str(e) for e in ele]) for ele in pred_batch]
# 同样地,也将gold_batch中的每个元素转换为字符串后拼接起来
gold_batch = [''.join([str(e) for e in ele]) for ele in gold_batch]
# print(f'pred_batch-->{pred_batch}')
# print(f'gold_batch-->{gold_batch}')

# 将真实结果的批次数据添加到self.goldens列表中
self.goldens.extend(gold_batch)
# print(f'self.goldens-->{self.goldens}')
# 将预测结果的批次数据添加到self.predictions列表中
self.predictions.extend(pred_batch)
# print(f'self.predictions-->{self.predictions}')

def compute(self, round_num=2) -> dict:
'''
根据当前类中累积的变量值,计算当前的P, R, F1。
:param round_num: (int): 计算结果保留小数点后几位, 默认小数点后2位。
:return:
dict -> {
'accuracy': 准确率,
'precision': 精准率,
'recall': 召回率,
'f1': f1值,
'class_metrics': {
'0': {
'precision': 该类别下的precision,
'recall': 该类别下的recall,
'f1': 该类别下的f1
},
...
}
}
'''
# print(f'self.goldens-->{self.goldens}')
# print(f'self.predictions-->{self.predictions}')
# 初始化类别集合、类别指标字典和结果字典,用于存储全局指标
# 将 self.goldens 和 self.predictions 的集合合并,并进行排序,结果存储在变量 classes 中。
classes = sorted(list(set(self.goldens) | set(self.predictions)))
class_metrics = {}
res = {}
# print(f'classes-->{classes}')

# 构建全局指标
# 计算并存储全局准确率
res['accuracy'] = round(accuracy_score(self.goldens, self.predictions), round_num)
# 计算并存储全局精确率
res['precision'] = round(precision_score(self.goldens, self.predictions, average='weighted'), round_num)
# 计算并存储全局召回率
res['recall'] = round(recall_score(self.goldens, self.predictions, average='weighted'), round_num)
# 计算并存储全局F1分数
res['f1'] = round(f1_score(self.goldens, self.predictions, average='weighted'), round_num)
# print(f'res-->{res}')

try:
# 计算混淆矩阵,并将其转换为numpy数组,形状为(n_class, n_class)
conf_matrix = np.array(confusion_matrix(self.goldens, self.predictions))
# print(f'conf_matrix-->{conf_matrix}')
# 确保混淆矩阵的维度与类别数量匹配
assert conf_matrix.shape[0] ` len(classes)
# 遍历每个类别,计算精确度(precision)、召回率(recall)和F1分数(f1)
for i in range(conf_matrix.shape[0]):
# 计算当前类别的精确度
precision = 0 if sum(conf_matrix[:, i]) ` 0 else (conf_matrix[i, i] / sum(conf_matrix[:, i]))
# 计算当前类别的召回率
recall = 0 if sum(conf_matrix[i, :]) ` 0 else (conf_matrix[i, i] / sum(conf_matrix[i, :]))
# 计算当前类别的F1分数
f1 = 0 if (precision + recall) ` 0 else (2 * precision * recall / (precision + recall))
# 将当前类别的精确度、召回率和F1分数保存到字典中
class_metrics[classes[i]] = {
'precision': round(precision, round_num),
'recall': round(recall, round_num),
'f1': round(f1, round_num)
}
# 将所有类别的指标保存到结果字典中
res['class_metrics'] = class_metrics
except Exception as e:
# 异常处理:当计算类别指标时发生异常,打印警告信息和相关数据
print(f'[Warning] Something wrong when calculate class_metrics: {e}')
print(f'--> goldens: {set(self.goldens)}')
print(f'--> predictions: {set(self.predictions)}')
print(f'--> diff elements: {set(self.predictions) - set(self.goldens)}')
# 将结果字典中的类别指标设置为空字典
res['class_metrics'] = {}

return res

def reset(self):
"""
重置积累的数值。
"""
self.goldens = []
self.predictions = []


if __name__ ` '__main__':
metric = ClassEvaluator()
metric.add_batch(
[['财', '经'], ['财', '经'], ['体', '育'], ['体', '育'], ['计', '算', '机']],
[['体', '育'], ['财', '经'], ['体', '育'], ['计', '算', '机'], ['计', '算', '机']],
)
# metric.add_batch(
# [0, 0, 1, 1, 0],
# [1, 1, 1, 0, 0]
# )
res = metric.compute()
print(res)

print代码结果:

1
2
3
4
5
6
7
8
9
{'accuracy': 0.6, 
'precision': 0.7,
'recall': 0.6,
'f1': 0.6,
'class_metrics':
{'体育': {'precision': np.float64(0.5), 'recall': np.float64(0.5), 'f1': np.float64(0.5)},
'计算机': {'precision': np.float64(1.0), 'recall': np.float64(0.5), 'f1': np.float64(0.67)},
'财经': {'precision': np.float64(0.5), 'recall': np.float64(1.0), 'f1': np.float64(0.67)}
}}

2、实现模型训练函数,验证函数

  • 目的:实现模型的训练和验证,脚本里面包含两个函数:model2train()和evaluate_model()

  • 代码路径:llm_tuning/prompt_tasks/P_Tuning/train.py

    代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import os
import time

import torch
from tqdm import tqdm
from transformers import AutoModelForMaskedLM, AutoTokenizer, get_scheduler

from prompt_tasks.PET.data_handle.data_loader import get_data
from prompt_tasks.PET.pet_config import ProjectConfig
from prompt_tasks.PET.utils.common_utils import mlm_loss, convert_logits_to_ids
from prompt_tasks.PET.utils.metirc_utils import ClassEvaluator
from prompt_tasks.PET.utils.verbalizer import Verbalizer

pc = ProjectConfig()


def model2train():
# 加载训练数据和验证数据
train_dataloader, dev_dataloader = get_data()

# 加载预训练模型
model = AutoModelForMaskedLM.from_pretrained(pc.pre_model).to(pc.device)
# print(f'预训练模型带MLM头的-->{model}')
# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model)
# 加载映射词表
verbalizer = Verbalizer(verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=pc.max_label_len)
# print(f'verbalizer-->{verbalizer.label_dict}')

# 不需要权重衰减的参数
no_decay = ["bias", "LayerNorm.weight"]
# print(type(model.parameters()))
# 定义优化器的参数组,以便对模型的不同部分应用不同的权重衰减
optimizer_grouped_parameters = [
# 第一组参数:包含所有适用权重衰减的模型参数
{
"params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
"weight_decay": pc.weight_decay,
},
# 第二组参数:包含所有不适用权重衰减的模型参数
{
"params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
"weight_decay": 0.0,
},
]
# 初始化AdamW优化器,用于模型参数的优化
# AdamW是Adam算法的变体,加入了权重衰减(L2正则化),有助于防止过拟合
# 参数optimizer_grouped_parameters是分组的模型参数,允许对不同的参数应用不同的学习率或正则化强度
optimizer = torch.optim.AdamW(optimizer_grouped_parameters, lr=pc.learning_rate)

# 根据训练轮数计算最大训练步数,以便于scheduler动态调整lr
num_update_steps_per_epoch = len(train_dataloader)
# 指定总的训练步数,它会被学习率调度器用来确定学习率的变化规律,确保学习率在整个训练过程中得以合理地调节
max_train_steps = pc.epochs * num_update_steps_per_epoch
# 计算预热阶段的训练步数,用于初始化学习率调度
warm_steps = int(pc.warmup_ratio * max_train_steps) # 预热阶段的训练步数
# 创建学习率调度器,使用线性调度策略,根据训练的进行逐步调整学习率
lr_scheduler = get_scheduler(
name='linear',
optimizer=optimizer,
num_warmup_steps=warm_steps,
num_training_steps=max_train_steps)

# 初始化损失列表,用于记录训练过程中的损失值
loss_list = []
# 记录训练开始的时间,用于计算训练时长
tic_train = time.time()
# 创建分类评估器,用于评估模型性能
metric = ClassEvaluator()
# 定义损失函数,用于计算模型预测值与真实标签之间的差异
criterion = torch.nn.CrossEntropyLoss()
# 初始化训练次数和最佳F1分数,用于跟踪训练进度和模型性能
global_step, best_f1 = 0, 0

print('开始训练:')
for epoch in range(pc.epochs):
for batch in tqdm(train_dataloader, desc='模型训练'):
# print(f'batch-->{batch}')
# 将批次数据输入模型,获取logits
logits = model(input_ids=batch['input_ids'].to(pc.device),
token_type_ids=batch['token_type_ids'].to(pc.device),
attention_mask=batch['attention_mask'].to(pc.device)).logits
# print(f'logits->{logits.shape}')

# 真实标签
mask_labels = batch['mask_labels'].numpy().tolist()
# print(f'mask_labels--->{mask_labels}')
# 提取子标签
sub_labels = verbalizer.batch_find_sub_labels(mask_labels)
# print(f'sub_labels--->{sub_labels}')
# 获取子标签的token_ids
sub_labels = [ele['token_ids'] for ele in sub_labels]
# print(f'sub_labels_token_ids--->{sub_labels}')

# 计算掩码语言模型的损失值
loss = mlm_loss(logits,
batch['mask_positions'].to(pc.device),
sub_labels,
criterion,
pc.device)
# print(f'计算损失值-->{loss}')
# 清零优化器的梯度
optimizer.zero_grad()
# 反向传播计算梯度
loss.backward()
# 更新模型参数
optimizer.step()
# 更新学习率调度器
lr_scheduler.step()

# 将损失值添加到损失列表中
loss_list.append(loss)
# 训练次数增加1
global_step += 1
# 打印训练日志
if global_step % pc.logging_steps ` 0:
time_diff = time.time() - tic_train
loss_avg = sum(loss_list) / len(loss_list)
print("global step %d, epoch: %d, loss: %.5f, speed: %.2f step/s"
% (global_step, epoch, loss_avg, pc.logging_steps / time_diff))
tic_train = time.time()
# 模型验证
# 使用给定的模型、评估指标、数据加载器、分词器和标记化器进行模型评估
acc, precision, recall, f1, class_metrics = evaluate_model(model,
metric,
dev_dataloader,
tokenizer,
verbalizer)

# 打印评估结果中的精确度、召回率和F1分数
print("验证集的 precision: %.5f, recall: %.5f, F1: %.5f" % (precision, recall, f1))
# 如果当前F1分数高于最佳F1分数,则更新最佳F1分数和相关模型及分词器
if f1 > best_f1:
print(
f"最好的f1分数被更新: {best_f1:.5f} --> {f1:.5f}"
)
print(f'每种类型的Metrics为: {class_metrics}')
# 更新当前最佳的F1分数
best_f1 = f1
# 定义当前保存模型和分词器的目录
cur_save_dir = os.path.join(pc.save_dir, "model_best")
print(cur_save_dir)
# 检查并创建保存目录(如果不存在)
if not os.path.exists(cur_save_dir):
os.makedirs(cur_save_dir)
# 保存模型到指定目录
model.save_pretrained(cur_save_dir)
# 保存分词器到指定目录
tokenizer.save_pretrained(cur_save_dir)
tic_train = time.time()

print('训练结束')


def evaluate_model(model,
metric,
data_loader,
tokenizer,
verbalizer):
'''
在测试集上评估当前模型的训练效果。
:param model: 当前模型
:param metric: 评估指标类(metric)
:param data_loader: 测试集的dataloader
:param tokenizer: 分词器
:param verbalizer: 映射表
:return:
'''
model.eval()
metric.reset()

with torch.no_grad():
for step, batch in enumerate(tqdm(data_loader, desc='模型验证')):
# print(f'batch-->{batch}')
logits = model(input_ids=batch['input_ids'].to(pc.device),
token_type_ids=batch['token_type_ids'].to(pc.device),
attention_mask=batch['attention_mask'].to(pc.device)).logits
# print(f'验证集模型预测的结果————>{logits.shape}')

mask_labels = batch['mask_labels'].numpy().tolist() # (batch, label_num)
# print(f"mask_labels-0-->{mask_labels}")

for i in range(len(mask_labels)): # 去掉label中的[PAD] token
while tokenizer.pad_token_id in mask_labels[i]:
mask_labels[i].remove(tokenizer.pad_token_id)
# print(f'mask_labels-1-->{mask_labels}')
# 将mask_labels id转换为文字
mask_labels = [''.join(tokenizer.convert_ids_to_tokens(t)) for t in mask_labels]
# print(f'真实的结果主标签:mask_labels_str-->{mask_labels}')

# 获取模型预测的子标签
predictions = convert_logits_to_ids(logits,
batch['mask_positions']).cpu().numpy().tolist() # (batch, label_num)
# print(f'模型预测的子标签的结果-->{predictions}')

# 根据模型预测的子标签,找到子label属于的主label
predictions = verbalizer.batch_find_main_label(predictions) # 找到子label属于的主label
# print(f"找到模型预测的子标签对应的主标签的结果-->{predictions}')")

# 获得预测的主标签名
predictions = [ele['label'] for ele in predictions]
# print(f"只获得预测的主标签的结果string-->{predictions}')")

# 调用add_batch方法, 将模型预测的主标签与真实主标签保存到metric属性中
metric.add_batch(pred_batch=predictions, gold_batch=mask_labels)
eval_metric = metric.compute()
model.train()

return eval_metric['accuracy'], eval_metric['precision'], \
eval_metric['recall'], eval_metric['f1'], \
eval_metric['class_metrics']


if __name__ ` '__main__':
model2train()
  • 输出结果:
1
2
3
4
5
6
7
8
...
global step 350, epoch: 43, loss: 0.10804, speed: 1.20 step/s
global step 360, epoch: 44, loss: 0.10504, speed: 1.22 step/s
global step 370, epoch: 46, loss: 0.10220, speed: 1.21 step/s
global step 380, epoch: 47, loss: 0.09951, speed: 1.20 step/s
global step 390, epoch: 48, loss: 0.09696, speed: 1.20 step/s
global step 400, epoch: 49, loss: 0.09454, speed: 1.22 step/s
Evaluation precision: 0.76000, recall: 0.70000, F1: 0.70000

  • 结论: BERT+P-Tuning模型在训练集上的表现是Precion: 76%
  • 注意:本项目中只用了60条样本,在接近400条样本上精确率就已经达到了76%,如果想让指标更高,可以扩增样本。

提升模型性能:

增加训练数据集(100条左右的数据):

1
2
3
4
5
手机	外观时尚新潮,适合年轻人展现个性。
手机 屏幕显示效果非常出色,观看视频和浏览网页很舒适。
电脑 使用了一段时间的这款电脑,硬盘采用WD,运行流畅无卡顿,温度控制较好,性价比令人满意。
手机 手机反应灵敏,操作界面简洁易用,非常满意。
电器 产品性能稳定,很不错哦!购买时有点担心,但收到货后发现是正品,大家可以放心购买。

修改验证集脏数据

1
2
3
4
# 原始标签和评论文本内容不符
平板 手机很好,就是客服垃圾特别是元豆
# 修改后
手机 手机很好,就是客服垃圾特别是元豆

模型表现:

Evaluation precision: 0.79000, recall: 0.70000, F1: 0.71000

3、实现模型预测函数

  • 目的:加载训练好的模型并测试效果
  • 代码路径:llm_tuning/prompt_tasks/P_Tuning/inference.py

​ 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import os
import time
from typing import List
import torch
from transformers import AutoTokenizer, AutoModelForMaskedLM

from prompt_tasks.P_Tuning.data_handle.data_preprocess import convert_example
from prompt_tasks.P_Tuning.ptune_config import ProjectConfig
from prompt_tasks.P_Tuning.utils.common_utils import convert_logits_to_ids
from prompt_tasks.P_Tuning.utils.verbalizer import Verbalizer

pc = ProjectConfig()

model_path = os.path.join(pc.save_dir, 'model_best')
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForMaskedLM.from_pretrained(model_path).to(pc.device)
model.eval()

max_label_len = 2 # 标签最大长度
p_embedding_num = 6
verbalizer = Verbalizer(
verbalizer_file=pc.verbalizer,
tokenizer=tokenizer,
max_label_len=max_label_len)

def inference(contents: List[str]):
'''
推理函数,输入原始句子,输出mask label的预测值。
:param contents:
:return: 描原始句子列表。
'''
with (torch.no_grad()):
start_time = time.time()

# 将内容封装为示例字典,准备进行标记化处理
examples = {'text': contents}

# 对示例进行标记化处理,返回标记化输出
tokenized_output = convert_example(
examples,
tokenizer,
max_seq_len=128,
max_label_len=max_label_len,
p_embedding_num=p_embedding_num,
train_mode=False,
return_tensor=True)

# 使用模型进行预测,获取logits
logits = model(input_ids=tokenized_output['input_ids'].to(pc.device),
token_type_ids=tokenized_output['token_type_ids'].to(pc.device),
attention_mask=tokenized_output['attention_mask'].to(pc.device)).logits

# 将logits转换为预测标签
predictions = convert_logits_to_ids(logits, tokenized_output['mask_positions']
).cpu().numpy().tolist() # (batch, label_num)

# 找到子label属于的主label
predictions = verbalizer.batch_find_main_label(predictions)

# 提取预测的标签
predictions = [ele['label'] for ele in predictions]

used = time.time() - start_time
print(f'耗时 {used} 秒。')
return predictions


if __name__ ` '__main__':
contents = [
'天台很好看,躺在躺椅上很悠闲,因为活动所以我觉得性价比还不错,适合一家出行,特别是去迪士尼也蛮近的,下次有机会肯定还会再来的,值得推荐',
'环境,设施,很棒,周边配套设施齐全,前台小姐姐超级漂亮!酒店很赞,早餐不错,服务态度很好,前台美眉很漂亮。性价比超高的一家酒店。强烈推荐',
"物流超快,隔天就到了,还没用,屯着出游的时候用的,听方便的,占地小",
"福行市来到无早集市,因为是喜欢的面包店,所以跑来集市看看。第一眼就看到了,之前在微店买了小刘,这次买了老刘,还有一直喜欢的巧克力磅蛋糕。好奇老板为啥不做柠檬磅蛋糕了,微店一直都是买不到的状态。因为不爱碱水硬欧之类的,所以期待老板多来点其他小点,饼干一直也是大爱,那天好像也没看到",
"服务很用心,房型也很舒服,小朋友很喜欢,下次去嘉定还会再选择。床铺柔软舒适,晚上休息很安逸,隔音效果不错赞,下次还会来"
]
print("针对下面的文本评论,请分别给出对应所属类别:")
res = inference(contents)
print('推断的类别为:', res)
new_dict = {}
for i in range(len(contents)):
new_dict[contents[i]] = res[i]
print(f'new_dict-->{new_dict}')
  • 结果展示
1
2
3
4
5
6
7
8
9
10
11
12
13
{
'天台很好看,躺在躺椅上很悠闲,因为活动所以我觉得性价比还不错,适合一家出
行,特别是去迪士尼也蛮近的,下次有机会肯定还会再来的,值得推荐': '酒店',
'环境,设施,很棒,周边配套设施齐全,前台小姐姐超级漂亮!酒店很赞,早餐不
错,服务态度很好,前台美眉很漂亮。性价比超高的一家酒店。强烈推荐': '酒店',
'物流超快,隔天就到了,还没用,屯着出游的时候用的,听方便的,占地小': '衣服',
'福行市来到无早集市,因为是喜欢的面包店,所以跑来集市看看。第一眼就看到了
,之前在微店买了小刘,这次买了老刘,还有一直喜欢的巧克力磅蛋糕。好奇老板为啥不做
柠檬磅蛋糕了,微店一直都是买不到的状态。因为不爱碱水硬欧之类的,所以期待老板多来
点其他小点,饼干一直也是大爱,那天好像也没看到': '平板',
'服务很用心,房型也很舒服,小朋友很喜欢,下次去嘉定还会再选择。床铺柔软舒
适,晚上休息很安逸,隔音效果不错赞,下次还会来': '酒店'
}

P04_基于ChatGLM微调多任务实战

1. 项目介绍【理解】


1.1. 项目简介

LLM(Large Language Model)通常拥有大量的先验知识,使得其在许多自然语言处理任务上都有着不错的性能。但,想要直接利用 LLM 完成一些任务会存在一些答案解析上的困难,如规范化输出格式,严格服从输入信息等。因此,在这个项目中我们对大模型 ChatGLM-6B 进行 Finetune,使其能够更好的对齐我们所需要的输出格式。

1.2. ChatGLM-6B模型

1.2.1 模型介绍

ChatGLM-6B 是清华大学提出的一个开源、支持中英双语的对话语言模型,基于 General Language Model (GLM) 架构,具有 62 亿参数。该模型使用了和 ChatGPT 相似的技术,经过约 1T 标识符的中英双语训练(中英文比例为 1:1),辅以监督微调、反馈自助、人类反馈强化学习等技术的加持,62 亿参数的 ChatGLM-6B 已经能生成相当符合人类偏好的回答(目前中文支持最好)。

相比原始Decoder模块,ChatGLM-6B模型结构有如下改动点:

  • embedding 层梯度缩减:为了提升训练稳定性,减小了 embedding 层的梯度。梯度缩减的效果相当于把 embedding 层的梯度缩小了 10 倍,减小了梯度的范数。
  • layer normalization:采用了基于 Deep Norm 的 post layer norm。
  • 激活函数:替换ReLU激活函数采用了 GeGLU 激活函数。
  • 位置编码:去除了绝对位置编码,采用了旋转位置编码 RoPE。

1.2.2 模型配置(6B)

配置 数据
参数 6.2B
隐藏层维度 4096
层数 28
注意力头数 32
训练数据 1T
词表大小 130528
最大长度 2048

1.2.3 硬件要求(官网介绍)

量化等级 最低GPU显存(推理) 最低GPU显存(高效参数微调)
FP16(无量化) 13GB 14GB
INT8 10GB 9GB
INT4 6GB 7GB

注意:显存的占用除了跟模型参数大小有关系外,还和文本支持最大长度有关

1.2.4 模型特点

  • 优点
    • 1.较低的部署门槛: INT4 精度下,只需6GB显存,使得 ChatGLM-6B 可以部署在消费级显卡上进行推理。
    • 2.更长的序列长度: 相比 GLM-10B(序列长度1024),ChatGLM2-6B 序列长度达32K,支持更长对话和应用。
    • 人类类意图对齐训练
  • 缺点:
    • 模型容量小,相对较弱的模型记忆和语言能力。
    • 较弱的多轮对话能力。

1.3. 环境配置

1.3.1 基础环境配置:

本次环境依赖于AutoDL算力:https://www.autodl.com/home

  • 操作系统: ubuntu22.04
  • CPUs: 14 core(s),内存:100G
  • GPUs: 1卡, A800, 80GB GPUs
  • Python: 3.10
  • Pytorh: 2.5.1
  • Cuda: 12.4
  • 价格:5.98元/小时

1.3.2 安装依赖包:

  1. 创建一个虚拟环境,您可以把 llm_env 修改为任意你想要新建的环境名称:
1
conda create -n llm_env python=3.10
  1. 激活新建虚拟环境
1
conda activate llm_env

注意: 如果激活失败,则先运行 conda init,然后退出终端,重新打开一个终端。

  1. 安装相应的依赖包:
1
2
-- 成功切换到llm_env后安装
pip install -r requirements.txt
1
2
3
4
5
6
7
8
9
10
11
12
protobuf>=3.19.5,<3.20.1
transformers`4.33
icetk
cpm_kernels
streamlit`1.18.0
matplotlib
datasets
accelerate>=0.20.3
packaging>=20.0
psutil
pyyaml
peft`0.3.0

requirements.txt文件内容如上所示


1.3.3 预训练模型下载:

  • 创建目录
1
2
mkdir -p /root/autodl-tmp/llm_tuning/THUDM/chatglm-6b
cd /root/autodl-tmp/llm_tuning/THUDM/chatglm-6b
  • 安装modelscope
1
pip install modelscope
  • 下载chatglm-6b
1
modelscope download --model ZhipuAI/ChatGLM-6B --local_dir ./
  • python文件下载

如果configuration_chatglm.py、modeling_chatglm.py、quantization.py、tokenization_chatglm.py文件没有下载成功,则手动下载,然后添加到chatglm-6b的文件夹中。

下载位置:https://modelscope.cn/models/ZhipuAI/ChatGLM-6B/files

1.4. 项目架构

项目架构流程图:

项目代码架构图:

image-20250822045752984

2.数据预处理【掌握】

  • 本项目中对数据部分的预处理步骤如下:
    1. 查看项目数据集
    2. 编写Config类项目文件配置代码
    3. 编写数据处理相关代码

2.1 查看项目数据集

  • 数据存放位置:llm_tuning/ptune_chatglm/data

  • data文件夹里面包含3个jsonl文档,分别为:mixed_train_dataset.jsonl、mixed_dev_dataset.jsonl、dataset.jsonl


2.1.1 train.jsonl

  • mixed_train_dataset.jsonl为训练数据集,因为我们本次项目同时进行「信息抽取+文本分类」两项任务,因此数据中混合了两种任务数据类型。举例展示如下:

    • 信息抽取数据示例
    • Instruction 部分告诉模型现在需要做「阅读理解」任务,Input 部分告知模型要抽取的句子以及输出的格式。
    1
    2
    3
    4
    {
    "context": "Instruction: 你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。\nInput: 找到句子中的三元组信息并输出成json给我:\n\n九玄珠是在纵横中文网连载的一部小说,作者是龙马。\nAnswer: ",
    "target": "```json\n[{\"predicate\": \"连载网站\", \"object_type\": \"网站\", \"subject_type\": \"网络小说\", \"object\": \"纵横中文网\", \"subject\": \"九玄珠\"}, {\"predicate\": \"作者\", \"object_type\": \"人物\", \"subject_type\": \"图书作品\", \"object\": \"龙马\", \"subject\": \"九玄珠\"}]\n```"
    }
    • 文本数据示例
    • Instruction 部分告诉模型现在需要做「阅读理解」任务,Input 部分告知模型要抽取的句子以及输出的格式。
    1
    2
    3
    4
    {
    "context": "Instruction: 你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。\nInput: 下面句子可能是一条关于什么的评论,用列表形式回答:\n\n很不错,很新鲜,快递小哥服务很好,水果也挺甜挺脆的\nAnswer: ",
    "target": "[\"水果\"]"
    }

训练集中一共包含902条数据,每一条数据都分为 contexttarget 两部分:

  1. context 部分是接受用户的输入。2. target 部分用于指定模型的输出。

context 中又包括 2 个部分:

  1. Instruction:用于告知模型的具体指令,当需要一个模型同时解决多个任务时可以设定不同的 Instruction 来帮助模型判别当前应当做什么任务。
  2. Input:当前用户的输入。

2.1.2 dev.jsonl

  • mixed_dev_dataset.jsonl为验证数据集,数据格式同train.jsonl。

如果想使用自定义数据训练,只需要仿照上述示例数据构建数据集即可。

2.2 编写项目Config类配置文件

  • 代码路径:llm_tuning/ptune_chatglm/glm_config.py

  • config文件目的:配置项目常用变量,一般这些变量属于不经常改变的,比如:训练文件路径、模型训练次数、模型超参数等等

具体代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import os.path

import torch

base_dir = os.path.dirname(os.path.abspath(__file__))
# print(f'base_dir-->{base_dir}')

class ProjectConfig(object):
def __init__(self):
# 定义是否使用GPU
self.device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
# self.device = 'mps:0' if torch.cuda.is_available() else 'cpu'
# 定义ChatGLM-6B模型的路径
self.pre_model = os.path.join(base_dir, '../THUDM/model/chatglm-6b-int4')
# 定义训练数据的路径
self.train_path = os.path.join(base_dir, 'data/mixed_train_dataset.jsonl')
# 定义验证集的路径
self.dev_path = os.path.join(base_dir, 'data/mixed_dev_dataset.jsonl')
# 是否使用LoRA方法微调
self.use_lora = True
# 是否使用P-Tuing方法微调
self.use_ptuning = False
# 秩`8
self.lora_rank = 8
# 一个批次多少样本
self.batch_size = 4
# 训练几轮
self.epochs = 2
# 学习率
self.learning_rate = 3e-5
# 权重权重系数
self.weight_decay = 0
# 学习率预热比例
self.warmup_ratio = 0.06
# context文本的输入长度限制
self.max_source_seq_len = 100
# target文本长度限制
self.max_target_seq_len = 100
# 每隔多少步打印日志
self.logging_steps = 10
# 每隔多少步保存
self.save_freq = 200
# 如果你使用了P-Tuing,要定义伪tokens的长度
self.pre_seq_len = 200
self.prefix_projection = False # 默认为False,即p-tuning,如果为True,即p-tuning-v2
# 保存模型的路径
self.save_dir = os.path.join(base_dir, 'save_model')


if __name__ ` '__main__':
pc = ProjectConfig()
print(pc.save_dir)

2.3 编写数据处理相关代码

  • 代码路径:llm_tuning/ptune_chatglm/data_handle
  • data_handle文件夹中一共包含两个py脚本:data_preprocess.py、data_loader.py

2.3.1 data_preprocess.py

  • 模型输入和标签的构建思路:
image-20250822121925643
  • 目的: 将样本数据转换为模型接受的输入数据
    • 定义数据转换方法convert_example()
    • 定义获取训练或验证数据最大长度方法get_max_length()

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
import sys
import os
project_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../..')
# print(f'project_root-->{project_root}')
sys.path.append(project_root)

import json
# 返回的字符串包含有关异常的详细信
import numpy as np
from tqdm import tqdm
from transformers import AutoTokenizer

from ptune_chatglm.glm_config import ProjectConfig

pc = ProjectConfig()

def convert_example_chatglm(
examples: dict,
tokenizer,
max_source_seq_len: int,
max_target_seq_len: int,
):
'''
将样本数据转换为Prompt-tuning模型接收的输入数据。
:param examples: (dict): 训练数据样本,
e.g. -> {
"text": [
'{"context": "年基准利率4.35%。从实际看...", "target": "2017年银行贷款基准利率"}',
...
]
}
:param tokenizer: 分词器
:param max_source_seq_len: (int): prompt最大长度
:param max_target_seq_len: (int): 答案最大长度
:return:
dict (str: np.array) ->
tokenized_output = {
'input_ids': [[1525, 10, ...], [758, 2345, ...]],
'labels': [[822, 10, ...], [125, 58...]]
}
'''
# 初始化一个字典,用于存储编码后的输入ID和对应的标签
tokenized_output = {
'input_ids': [], # 存储编码后的输入ID列表
'labels': [] # 存储对应的标签列表
}
# 设定句子的最大长度
max_seq_len = max_source_seq_len + max_target_seq_len

# 遍历每个样本
for example in tqdm(examples['text']):
# print(f'example--> {example}')
# print(f'type(example)--> {type(example)}')
try:
# 1.将字符串转成字典,取出问题和答案
example = json.loads(example)
# print(f'example-->{example}')
context = example["context"]
target = example["target"]
# print(f'context-->\n{context}')
# print(f'context2len-->{len(context)}')
# print(f'target-->\n{target}')

# 2.将问题和答案进行分词转成id
context_ids = tokenizer.encode(context, add_special_tokens=False) # 不需要添加特殊标记,原因是需要手动添加
# print(f'context_ids--->{context_ids}')
target_ids = tokenizer.encode(target, add_special_tokens=False)
# print(f'target_ids--->{target_ids}')

# 3.将问题和答案超过最大长度的进行截断
# 如果问题长度超过 max_source_seq_len - 1,则截断,因为需要留出一个位置给[gMASK]
if len(context_ids) > max_source_seq_len - 1:
context_ids = context_ids[:max_source_seq_len - 1]
# 如果答案长度超过 max_target_seq_len - 2 ,则截断,因为需要留两个位置给[sop]和[eop]
if len(target_ids) > max_target_seq_len - 2:
target_ids = target_ids[:max_target_seq_len - 2]
# print(f'context_ids--->{context_ids}')
# print(f'target_ids--->{target_ids}')

# 4.将问题和答案拼接起来作为input_ids
# source_ids + [gMASK] + <sop>[也是bos] + target_ids + <eop>
input_ids = tokenizer.build_inputs_with_special_tokens(context_ids, target_ids)
# print(f'input_ids--->{input_ids}')

# 5.将问题作为labels,需要将问题所在的位置设置为-100, 因为-100表示忽略,不计算损失
# 查找bos_token_id的索引位置,这个索引位置就是问题结束的位置,也是问题的长度
context_length = input_ids.index(tokenizer.bos_token_id)
# 将问题所在位置的id设置为-100,另外,答案所在的位置进行保留原id
labels = [-100] * context_length + input_ids[context_length:]
# print(f'labels--->{labels}')

# 6.需要将短的句子进行补齐,填充到max_seq_len
pad_len = max_seq_len - len(input_ids)
# input_ids需要使用pad_token_id进行填充
input_ids += [tokenizer.pad_token_id] * pad_len
# print(f'input_ids--->{input_ids}')
# labels需要使用-100进行填充
labels += [-100] * pad_len
# print(f'labels--->{labels}')

# 7.将处理好的input_ids和labels添加到tokenized_output中
tokenized_output['input_ids'].append(input_ids)
tokenized_output['labels'].append(labels)
except Exception as e:
print(f'Exception-->{e}')
continue

# 将输出中的每个值转换成numpy数组
for k, v in tokenized_output.items():
tokenized_output[k] = np.array(v)

return tokenized_output

def get_max_length(
tokenizer,
dataset_file: str
):
'''
测试数据集最大的输入/输出tokens是多少。
:param tokenizer: 分词器
:param dataset_file: (str): _description_
:return:
'''
# 初始化源序列长度列表和目标序列长度列表
source_seq_len_list = []
target_seq_len_list = []

# 打开数据集文件以读取数据
with open(dataset_file, 'r', encoding='utf-8') as f:
# 使用tqdm包装读取操作,以便显示进度条
for line in tqdm(f.readlines()):
# 将每一行转换为JSON对象
line = json.loads(line)

# 对源文本进行编码,并计算其序列长度
source_len = tokenizer.encode(line['context'])
source_seq_len_list.append(len(source_len))

# 对目标文本进行编码,并计算其序列长度
target_len = tokenizer.encode(line['target'])
target_seq_len_list.append(len(target_len))

print(f"【Source Sequence】 Max: {max(source_seq_len_list)}, "
f"Avg: {int(sum(source_seq_len_list) / len(source_seq_len_list))}, "
f"Middle: {sorted(source_seq_len_list)[int(len(source_seq_len_list) / 2)]}.")
print(f"【Target Sequence】 Max: {max(target_seq_len_list)}, "
f"Avg: {int(sum(target_seq_len_list) / len(target_seq_len_list))}, "
f"Middle: {sorted(target_seq_len_list)[int(len(target_seq_len_list) / 2)]}.")


if __name__ ` '__main__':
examples = {'text': [
'{"context": "Instruction: 你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。\\nInput: 句子中包含了哪些信息,输出json:\\n\\n如何演好自己的角色,请读《演员自我修养>《喜剧之王>周星驰崛起于穷困潦倒之中的独门秘笈。\\nAnswer: ", "target": "```json\\n[{\\"predicate\\": \\"主演\\", \\"object_type\\": \\"人物\\", \\"subject_type\\": \\"影视作品\\", \\"object\\": \\"周星驰\\", \\"subject\\": \\"喜剧之王\\"}]\\n```"}'
]}
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model, trust_remote_code=True)

out = convert_example_chatglm(examples, tokenizer, 5, 5)
print('input_ids shape:', out['input_ids'].shape)
print('labels shape:', out['labels'].shape)

get_max_length(tokenizer, pc.train_path)

2.3.2 data_loader.py

  • 目的:定义数据加载器

    代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from datasets import load_dataset
from torch.utils.data import DataLoader
from transformers import default_data_collator, AutoTokenizer
from functools import partial

from ptune_chatglm.data_handle.data_preprocess import convert_example_chatglm
from ptune_chatglm.glm_config import ProjectConfig

pc = ProjectConfig()

tokenizer = AutoTokenizer.from_pretrained(pc.pre_model, trust_remote_code=True)

def get_data():
"""
加载并处理数据集。

该函数从指定的文件路径加载数据集,并对其进行一系列的预处理操作,
包括数据的转换和分批,以便于后续的模型训练和评估。
"""
# 'text' 表示使用 内置的文本数据加载器。这个加载器会把 纯文本文件 按行读取,每一行作为一条样本,最终将这个样本放到text对应的列表里。
dataset = load_dataset('text', data_files={'train': pc.train_path,
'dev': pc.dev_path})
# print(f'数据集-->{dataset}')

# 创建一个新的函数,用于部分应用convert_example_chatglm函数
new_func = partial(convert_example_chatglm,
tokenizer=tokenizer,
max_source_seq_len=pc.max_source_seq_len,
max_target_seq_len=pc.max_target_seq_len)

# 应用新的函数到数据集上,进行数据转换
dataset = dataset.map(new_func, batched=True)

# 提取训练集和测试集
train_dataset = dataset["train"]
dev_dataset = dataset["dev"]

# 创建训练集的数据加载器,用于批量处理训练数据
train_dataloader = DataLoader(train_dataset,
shuffle=True,
collate_fn=default_data_collator,
batch_size=pc.batch_size,
drop_last=True)

# 创建测试集的数据加载器,用于批量处理测试数据
dev_dataloader = DataLoader(dev_dataset,
collate_fn=default_data_collator,
batch_size=pc.batch_size,
drop_last=True)

return train_dataloader, dev_dataloader


if __name__ ` '__main__':
train_dataloader, dev_dataloader = get_data()
print(len(train_dataloader))
print(len(dev_dataloader))
for i, value in enumerate(train_dataloader):
print(value)
print(value['input_ids'].shape)
print(value['labels'].shape)
break
  • 打印结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
902
122
{
'input_ids': tensor([[ 37010, 12, 5, 76331, 83362, 92831,
103593, 64464, 6,
77115, 65077, 72863, 63891, 66207, 63823, 4, 3430, 12,
68327, 74351, 77756, 66263, 81577, 64536, 6, 82145, 2031,
63825, 69574, 66207, 12, 4, 4, 64590, 67748, 69958,
66152, 63923, 65024, 64676, 65102, 66089, 64101, 73127, 64025,
64236, 6, 72996, 73518, 64236, 82273, 63823, 4, 13049,
12, 130001, 130004, 5, 125827, 2031, 4, 127903, 38861,
83, 28, 66845, 67541, 57, 28, 1932, 24, 317,
83, 28, 64069, 57, 28, 9832, 24, 317, 83,
28, 65210, 57, 28, 1932, 83, 28, 73127, 64025,
64236, 57, 28, 9832, 83, 28, 64590, 67748, 69958,
66152, 127731, 4, 125827, 130005, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3]]),
'labels': tensor([[ -100, -100, -100, -100, -100, -100, -100,
-100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, 130004, 5, 125827, 2031, 4, 127903, 38861,
83, 28, 66845, 67541, 57, 28, 1932, 24, 317,
83, 28, 64069, 57, 28, 9832, 24, 317, 83,
28, 65210, 57, 28, 1932, 83, 28, 73127, 64025,
64236, 57, 28, 9832, 83, 28, 64590, 67748, 69958,
66152, 127731, 4, 125827, 130005, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100]])
}
torch.Size([1, 200])
torch.Size([1, 200])

2.3.3 代码上传

将写好的代码 ptune_chatglm 文件夹直接打包成zip文件。然后上传到autodl平台的 llm_tuning 文件夹下。

然后解压缩。

1
2
cd /root/autodl-tmp/llm_tuning/
unzip ptune_chatglm.zip

然后使用python xx.py的方式运行文件即可。

3.模型搭建与训练【掌握】

本项目中完成ChatGLM+LoRA模型搭建、训练及应用的步骤如下(注意:因为本项目中使用的是ChatGLM预训练模型,所以直接加载即可,无需重复搭建模型架构):

  • 1.实现模型工具类函数
  • 2.实现模型训练函数,验证函数
  • 3.实现模型预测函数

3.0 前置知识

  • lora模型配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 如果使用lora方法微调
if pc.use_lora:
# 将模型的输出头:数据类型转换为float32(确保)
model.lm_head = CastOutputToFloat(model.lm_head)
# 定义LoRA配置
peft_config = peft.LoraConfig(
task_type=peft.TaskType.CAUSAL_LM, # 传统的语言模型
inference_mode=False, # 推理时为True,比如决定是否使用dropout
r=pc.lora_rank, # 低秩矩阵维度
lora_alpha=32, # 缩放系数
lora_dropout=0.1
)
# print(f'peft_config--》{peft_config}')
# 根据LoRA配置获取LoRA模型
model = peft.get_peft_model(model, peft_config)
  • lora调用:
1
2
3
4
loss = model(
input_ids=batch['input_ids'].to(dtype=torch.long, device=pc.device),
labels=batch['labels'].to(dtype=torch.long, device=pc.device)
).loss
  • lora模型保存:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def save_model(model, cur_save_dir: str):
'''
存储当前模型。
该函数根据条件判断,要么将模型直接保存,要么在保存前将LoRA(Low-Rank Adaptation)参数与原始模型合并后再保存。
这样做是为了确保无论是原始模型还是使用LoRA训练的模型,都可以被正确地保存和后续使用。
:param model: 待保存的模型。这可以是任何已经训练好的transformers模型。
:param cur_save_dir: (str): 存储路径。表示模型将被保存到的目录路径。
:return:
'''
# 检查是否使用了LoRA(低秩适应)技术
if pc.use_lora: # merge lora params with origin model
# 如果使用了LoRA,首先创建模型的深拷贝,以避免修改原始模型
merged_model = copy.deepcopy(model)
# 直接保存的话,只会保存adapter即LoRA模型的参数,因此需要合并后再保存
merged_model = merged_model.merge_and_unload()
# 保存合并后的模型到指定路径
merged_model.save_pretrained(cur_save_dir)
else:
# 如果没有使用LoRA,直接保存模型到指定路径
model.save_pretrained(cur_save_dir)

ptuning的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 加载预训练模型的配置
config = AutoConfig.from_pretrained(pc.pre_model, trust_remote_code=True)
# 如果使用ptuning微调手段
if pc.use_ptuning:
# 设置前缀序列长度(soft prompt的长度)
config.pre_seq_len = pc.pre_seq_len
# 可以指定是P-tuning-V1或者V2
config.prefix_projection = pc.prefix_projection


# 如果pc对象的use_ptuning属性为真,则执行以下操作
if pc.use_ptuning:
# 将模型的transformer部分的prefix_encoder组件转换为float精度
model.transformer.prefix_encoder.float()

3.1. 实现模型工具类函数

  • 目的:模型在训练、验证、预测时需要的函数
  • 代码路径:llm_tuning/ptune_chatglm/utils
  • utils文件夹共包含1个py脚本:common_utils.py

3.1.1 common_utils.py

  • 目的:定义数据类型转换类、分秒时之间转换以及模型保存函数。

  • 脚本里面包含一个类以及两个函数:CastOutputToFloat、second2time()以及save_model()

    • 定义CastOutputToFloat类
    • 定义second2time()函数
    • 定义save_model()
  • 代码路径:llm_tuning/ptune_chatglm/utils/common_utils.py

    代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import torch
import torch.nn as nn
import copy

from ptune_chatglm.glm_config import ProjectConfig

pc = ProjectConfig()


class CastOutputToFloat(nn.Sequential):
"""
继承自nn.Sequential的类,用于将模型的输出转换为浮点类型。

该类重写了forward方法,以确保模型输出被转换为torch.float32类型。
主要用途是在模型定义中作为最后一层,以确保输出类型符合预期。
"""
def forward(self, x):
'''
定义模型的前向传播过程,并将输出转换为浮点类型。
:param x: (Tensor): 输入张量,类型不受限。
:return: Tensor: 经过模型前向传播后的输出张量,转换为torch.float32类型。
'''
# 调用父类的forward方法进行前向传播,然后将输出转换为torch.float32类型
return super().forward(x).to(torch.float32)


def second2time(seconds: int):
'''
将秒转换成时分秒。
:param seconds: (int): 需要转换的秒数。
:return: str: 转换后的小时、分钟和秒,以字符串形式返回。
'''
# 将秒数转换为分钟和秒
m, s = divmod(seconds, 60)
# 打印中间结果,分钟数和秒数
# print(f'm-->{m}')
# print(f's-->{s}')
# 将分钟数转换为小时和分钟
h, m = divmod(m, 60)
# 打印中间结果,小时数和分钟数
# print(f'h-->{h}')
print(f'm-->{m}')
# 返回格式化的小时、分钟和秒
return "%02d:%02d:%02d" % (h, m, s)


def save_model(model, cur_save_dir: str):
'''
存储当前模型。
该函数根据条件判断,要么将模型直接保存,要么在保存前将LoRA(Low-Rank Adaptation)参数与原始模型合并后再保存。
这样做是为了确保无论是原始模型还是使用LoRA训练的模型,都可以被正确地保存和后续使用。
:param model: 待保存的模型。这可以是任何已经训练好的transformers模型。
:param cur_save_dir: (str): 存储路径。表示模型将被保存到的目录路径。
:return:
'''
# 检查是否使用了LoRA(低秩适应)技术
if pc.use_lora: # merge lora params with origin model
# 如果使用了LoRA,首先创建模型的深拷贝,以避免修改原始模型
merged_model = copy.deepcopy(model)
# 直接保存的话,只会保存adapter即LoRA模型的参数,因此需要合并后再保存
merged_model = merged_model.merge_and_unload()
# 保存合并后的模型到指定路径
merged_model.save_pretrained(cur_save_dir)
else:
# 如果没有使用LoRA,直接保存模型到指定路径
model.save_pretrained(cur_save_dir)

3.2. 实现模型训练函数,验证函数

  • 目的:实现模型的训练和验证

优化点:

优化点1:

model.config.use_cache = False

含义:关闭模型使用 past key/values(也叫 KV cache、attention cache)来重用之前层的注意力键值对。

效果:在训练时通常将其设为 False(尤其在启用 gradient checkpointing 时),因为缓存与 checkpointing 在某些实现上不兼容,会导致错误或额外内存/计算问题。把它设为 False 可以避免相关冲突。并且现在不再进行缓存,可以降低显存的占用。

在训练结束、用于部署/推理时应把 use_cache 恢复为 True 以利用 KV 缓存加速。

优化点2:

model.gradient_checkpointing_enable()

含义:开启梯度检查点。这是一个以牺牲计算时间换取显存的优化:前向时只保存一部分激活(checkpoints),在反向传播时对未保存的部分重新做一次(或多次)前向计算以得到梯度。

效果:显存减低,训练速度变慢。

优化点3:

model.enable_input_require_grads()

含义:允许把梯度“传到”输入层或做对输入嵌入的直接优化(例如 prompt tuning、微调 embedding、或某些 adapter/PEFT 的实现里需要)。

优化点4:

直接降低预训练模型的精度:

1
2
# model.half()将模型数据类型从默认的float32精度转换为更低的float16精度,减少内存
model = model.half()

混合精度训练:

1
2
3
4
5
6
7
8
# autocast是PyTorch中一种混合精度的技术,可在保持数值精度的情况下提高训练速度和减少显存占用。
# 该方法混合精度训练,如果在CPU环境中不起任何作用
from torch.cuda.amp import autocast as autocast
with autocast():
loss = model.forward(
input_ids=batch['input_ids'].to(dtype=torch.long, device=pc.device),
labels=batch['labels'].to(dtype=torch.long, device=pc.device)
).loss

代码:

  • 代码路径:llm_tuning/ptune_chatglm/train.py

  • 脚本里面包含两个函数:model2train()和evaluate_model()

    代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
import sys
import os
project_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../')
# print(f'project_root-->{project_root}')
sys.path.append(project_root)

import time
import torch
import peft
# autocast是PyTorch中一种混合精度的技术,可在保持数值精度的情况下提高训练速度和减少显存占用。
# 该方法混合精度训练,如果在CPU环境中不起任何作用
from torch.cuda.amp import autocast as autocast
from transformers import AutoConfig, AutoModel, get_scheduler

from ptune_chatglm.data_handle.data_loader import get_data
from ptune_chatglm.glm_config import ProjectConfig
from ptune_chatglm.utils.common_utils import CastOutputToFloat, second2time, save_model

pc = ProjectConfig()

def model2train():
"""
训练模型的主要函数。本函数完成从模型配置、数据加载到训练和评估的全过程。
"""
# 1.获取训练和验证数据加载器
train_dataloader, dev_dataloader = get_data()

# 2.加载模型
# 加载预训练模型的配置
config = AutoConfig.from_pretrained(pc.pre_model, trust_remote_code=True)
# print(f'config-->{config}')

# 如果使用ptuning微调手段
if pc.use_ptuning:
# 设置前缀序列长度
config.pre_seq_len = pc.pre_seq_len
# 可以指定是P-tuning-V1或者V2
config.prefix_projection = pc.prefix_projection

# 加载预训练模型ChatGLM-6B
model = AutoModel.from_pretrained(pc.pre_model,
config=config,
trust_remote_code=True)

print(f'model-->{model}')
# for name, prameters in model.named_parameters():
# print(f'prameters类型--》{prameters.dtype}')

# model.half()将模型数据类型从默认的float32精度转换为更低的float16精度,减少内存
model = model.half()
# print(model)

# 不进行缓存,减少内存
model.config.use_cache = False
# 梯度检查点是一种优化技术,用于在反向传播过程中降低内存使用,保存部分激活值,未保存的反向传播时重新计算
model.gradient_checkpointing_enable()
# 对输入层进行require_grads(进行参数更新,即微调)
model.enable_input_require_grads()


# print(f'model.transformer.prefix_encoder-->{model.transformer.prefix_encoder}')
# 如果pc对象的use_ptuning属性为真,则执行以下操作
if pc.use_ptuning:
# 将模型的transformer部分的prefix_encoder组件转换为float精度
model.transformer.prefix_encoder.float()

# 如果使用lora方法微调
if pc.use_lora:
# 将模型的输出头:数据类型转换为float32(确保)
model.lm_head = CastOutputToFloat(model.lm_head)
# 定义LoRA配置
peft_config = peft.LoraConfig(
task_type=peft.TaskType.CAUSAL_LM, # 传统的语言模型
inference_mode=False, # 推理时为True,比如决定是否使用dropout
r=pc.lora_rank, # 低秩矩阵维度
lora_alpha=32, # 缩放系数
lora_dropout=0.1
)
# print(f'peft_config--》{peft_config}')
# 根据LoRA配置获取LoRA模型
model = peft.get_peft_model(model, peft_config)

# print('*'*80)
# 将模型移动到指定设备
model = model.to(pc.device)
# 打印模型中所有可训练参数的信息
model.print_trainable_parameters()

# 定义不需要权重衰减的参数名称列表
no_decay = ["bias", "LayerNorm.weight"]
# 初始化优化器参数分组,对模型中的参数进行精细控制的优化处理
optimizer_grouped_parameters = [
{
# 分组1:包含所有适用权重衰减的参数
"params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
"weight_decay": pc.weight_decay, # 适用预设的权重衰减率
},
{
# 分组2:包含所有不适用权重衰减的参数
"params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
"weight_decay": 0.0, # 不适用权重衰减
},
]
# 初始化AdamW优化器
optimizer = torch.optim.AdamW(optimizer_grouped_parameters, lr=pc.learning_rate)

# 根据训练轮数计算最大训练步数,以便于scheduler动态调整lr
num_update_steps_per_epoch = len(train_dataloader)
# 指定总的训练步数,它会被学习率调度器用来确定学习率的变化规律,确保学习率在整个训练过程中得以合理地调节
max_train_steps = pc.epochs * num_update_steps_per_epoch
# 预热阶段的训练步数
warm_steps = int(pc.warmup_ratio * max_train_steps)
# 初始化学习率调度器
lr_scheduler = get_scheduler(
name='linear',
optimizer=optimizer,
num_warmup_steps=warm_steps,
num_training_steps=max_train_steps,
)

# 3.定义训练的一些参数变量
loss_list = []
tic_train = time.time()
global_step, best_eval_loss = 0, float('inf')
# 4.开始训练循环
for epoch in range(1, pc.epochs + 1):
print("开始训练")
model.train()
for batch in train_dataloader:
if pc.use_lora:
"""
torch.cuda.amp.autocast是PyTorch中一种混合精度的技术(仅在GPU上训练时可使用)
混合精度训练主要涉及16位浮点数(FP16)和32位浮点数(FP32)的组合使用:
FP16:提供更高的计算速度和更低的内存使用,但是由于其表示范围较小,可能会导致数值不稳定或溢出。
FP32:用于保持数值稳定性,尤其是在权重更新阶段。
"""
with autocast():
loss = model(
input_ids=batch['input_ids'].to(dtype=torch.long, device=pc.device),
labels=batch['labels'].to(dtype=torch.long, device=pc.device)
).loss
else:
loss = model(
input_ids=batch['input_ids'].to(dtype=torch.long, device=pc.device),
labels=batch['labels'].to(dtype=torch.long, device=pc.device)
).loss

# 梯度清零
optimizer.zero_grad()
# 反向传播
loss.backward()
# 梯度更新
optimizer.step()
# 更新学习率
lr_scheduler.step()


loss_list.append(float(loss.cpu().detach()))
global_step += 1
if global_step % pc.logging_steps ` 0:
time_diff = time.time() - tic_train
loss_avg = sum(loss_list) / len(loss_list)
print("全局步骤 %d ( %02.2f%% ) , 轮次: %d, 损失: %.5f, 速度: %.2f step/s, ETA: %s"
% (
global_step,
global_step / max_train_steps * 100,
epoch,
loss_avg,
pc.logging_steps / time_diff,
second2time(int(max_train_steps - global_step) / (pc.logging_steps / time_diff))
))
tic_train = time.time()


# cur_save_dir = os.path.join(pc.save_dir, "model_%d" % global_step)
# save_model(model, cur_save_dir)
# print(f'Model has saved at {cur_save_dir}.')
eval_loss = evaluate_model(model, dev_dataloader)
print("评估集的损失为: %.5f" % (eval_loss))
if eval_loss < best_eval_loss:
print(
f"最小的损失已经更新:: {best_eval_loss:.5f} --> {eval_loss:.5f}"
)
best_eval_loss = eval_loss
cur_save_dir = os.path.join(pc.save_dir, "model_best")
save_model(model, cur_save_dir)
print(f'最好的模型已经保存在: {cur_save_dir}.')
tic_train = time.time()

def evaluate_model(model, dev_dataloader):
# 将模型设置为评估模式,以便在验证时不进行梯度更新
model.eval()

# 初始化一个空列表来存储每个batch的loss值
loss_list = []

# 在torch.no_grad()上下文中禁用梯度计算,以减少内存消耗和加快计算速度
with torch.no_grad():
# 遍历验证数据加载器中的每个batch
for batch in dev_dataloader:
# 如果使用了LoRA(Low-Rank Adaptation)技术
if pc.use_lora:
# 使用autocast()自动混合精度训练,以提高效率和减少内存消耗
with autocast():
# 进行模型前向传播并计算loss
loss = model(
input_ids=batch['input_ids'].to(dtype=torch.long, device=pc.device),
labels=batch['labels'].to(dtype=torch.long, device=pc.device)
).loss
else:
# 如果不使用LoRA,直接进行模型前向传播并计算loss
loss = model(
input_ids=batch['input_ids'].to(dtype=torch.long, device=pc.device),
labels=batch['labels'].to(dtype=torch.long, device=pc.device)
).loss

# 将计算得到的loss值从GPU转移到CPU,并 detach 以释放计算图,防止内存泄漏
# 然后将其作为浮点数添加到loss列表中
loss_list.append(float(loss.cpu().detach()))

# 返回平均loss值,即loss列表的总和除以loss的数量
return sum(loss_list) / len(loss_list)


if __name__ ` '__main__':
model2train()

  • 输出结果:

3.3. 实现模型预测函数

  • 目的:加载训练好的模型并测试效果

优化点:

  • 优化点:model.generate()与model()的区别
1
2
3
4
5
6
7
out = model.generate(
input_ids=batch["input_ids"].to(pc.device),
max_new_tokens=max_new_tokens,
temperature=0
)
out_text = tokenizer.decode(out[0])
answer = out_text.split('Answer: ')[-1]
1
model()
  • 一次前向(forward)调用,返回 logits(和 past_key_values、隐藏态等),不会自动做循环解码。

  • 适合:你想自定义解码策略(自写采样、温度、惩罚、约束),或做单步推理 / 获取中间 logits / 做梯度计算(训练)时。

  • 需要你自己管理:循环、past_key_values(KV cache)、终止条件、token 拼接等。

1
model.generate()
  • 是 Hugging Face Transformers 提供的高层生成接口:它会自动运行解码循环(greedy / beam / top-k / top-p / sampling 等),管理 past_key_valuesattention_maskeos 停止、重复惩罚、长度限制等。

  • 适合:你要快速得到文本输出,且常见的生成选项(temperature、max_new_tokens、do_sample、num_beams…)已经够用。

    其中在 generate(..., temperature=0) 下,transformers 会把采样方式切为贪心(greedy),即每步取概率最大的 token(等同于 argmax)。如果你用 model() 自己实现,也需要在 logits 上做 argmax 来实现相同行为;若 temperature>0,你需要做 softmax + multinomial 来采样。

  • 更简洁、安全、通常更快(内部有优化),并且处理好很多边界情况。

代码:

  • 代码路径:llm_tuning/prompt_tasks/ptune_chatglm/inference.py

​ 具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import sys
import os
project_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../')
# print(f'project_root-->{project_root}')
sys.path.append(project_root)

import time
import torch
from transformers import AutoTokenizer, AutoModel

from ptune_chatglm.glm_config import ProjectConfig

pc = ProjectConfig()

def inference(model, tokenizer, instuction: str, sentence: str):
with torch.no_grad():
input_text = f"Instruction: {instuction}\n"
if sentence:
input_text += f"Input: {sentence}\n"
input_text += f"Answer: "
batch = tokenizer(input_text, return_tensors="pt")
# 直接调用模型的generate方法,获取模型预测结果
out = model.generate(
input_ids=batch["input_ids"].to(pc.device),
max_new_tokens=max_new_tokens,
temperature=0
)
print(f'out-->{out}')
out_text = tokenizer.decode(out[0])
print(f'out_text-->{out_text}')
answer = out_text.split('Answer: ')[-1]
return answer


if __name__ ` '__main__':
max_new_tokens = 300
model_path = os.path.join(pc.save_dir, 'model_best')
tokenizer = AutoTokenizer.from_pretrained(
model_path,
trust_remote_code=True
)
model = AutoModel.from_pretrained(
model_path,
trust_remote_code=True
).half().to(pc.device)

samples = [
{
'instruction': "现在你是一个非常厉害的SPO抽取器。",
"input": "下面这句中包含了哪些三元组,用json列表的形式回答,不要输出除json外的其他答案。\n\n73获奖记录人物评价:黄磊是一个特别幸运的演员,拍第一部戏就碰到了导演陈凯歌,而且在他的下一部电影《夜半歌声》中演对手戏的张国荣、吴倩莲、黎明等都是著名的港台演员。",
},
{
'instruction': "你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。",
"input": "下面子中的主语是什么类别,输出成列表形式。\n\n第N次入住了,就是方便去客户那里哈哈。还有啥说的"
}
]

start = time.time()
for i, sample in enumerate(samples):
res = inference(
model,
tokenizer,
sample['instruction'],
sample['input']
)
print(f'res {i}: ')
print(res)
print(f'Used {round(time.time() - start, 2)}s.')

P05_文本摘要模型

1 项目说明

1.1 项目内容

文本摘要项目

1.2 项目技术

  • 模型选型:Qwen

  • 模型微调:DeepSpeed + LoRA

  • 模型量化:QLora + GPTQ

  • 模型部署:vLLM推理引擎

2 DeepSpeed使用

2.1 并行化策略

(1)数据并行

image-20251103160841180

(2)模型并行-流水线并行

image-20251103165614117

(3)模型并行-张量并行

image-20251103172117471

2.2 内存优化技术

ZeRO原理:将参数、梯度和优化器状态拆分存储到每个显卡上,不再进行冗余存储,从而降低每个显卡上的显存占用大小。然后在使用时,使用到相应的数据时再去对应显卡上拉取数据。

image-20251103180404415

注意:ZeRO级别越高,显存占用就越少,此时在计算时数据传输成本就越高。所以,官方推荐的级别是ZeRO2,用于平衡显存占用以及数据传输成本。

Offload原理:核心是将优化器状态和梯度卸到CPU内存中,从而能够同时利用 CPU 和 GPU 内存来训练大型模型。

2.3 混合精度训练

定义:混合精度训练是一种同时使用不同精度的浮点数进行训练的方法,通常结合单精度(FP32)和半精度(FP16)浮点数。

效果:使用混合精度可以显著减少内存占用和计算时间,同时还能降低能耗。

2.4 deepspeed实践

核心流程如下:

1)配置DeepSpeed: 创建一个DeepSpeed的配置文件(通常为JSON格式),在其中指定模型的大小、优化器类型、学习率调度器等参数。

ds.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
// 全局训练批次大小(所有设备的总和)
"train_batch_size": 128, // 每次迭代所有GPU共同处理的样本总数

// 梯度累积步数(模拟更大批次训练)
"gradient_accumulation_steps": 1, // 1表示不累积,每次迭代直接更新参数

// 优化器配置
"optimizer": {
// 使用Adam优化器(自适应学习率算法)
"type": "Adam", // 支持类型包括Adam/AdamW/LAMB等

// 优化器参数设置
"params": {
// 初始学习率(需根据任务调整)
"lr": 0.00015 // 适合图像分类任务的典型学习率范围:1e-5到1e-3
}
},

// ZeRO优化配置(内存优化核心技术)
"zero_optimization": {
// ZeRO阶段选择(1-3)
"stage": 2, // 阶段2:梯度分片 + 优化器状态分片
}
}

2)包装模型: 使用DeepSpeed提供的deepspeed.initialize()函数来包装原有的模型。这个函数将应用DeepSpeed的优化策略和技术。

1
2
3
4
5
6
# deepspeed初始化(自动处理分布式设置)
model, optimizer, _, _ = deepspeed.initialize(
args=cmd_args, # 命令行参数
model=model, # 原始模型
model_parameters=model.parameters() # 模型参数
)

3)训练模型: 替换原有的训练循环,通过调用model.backward()optimizer.step()来执行反向传播和参数更新。DeepSpeed会自动处理梯度累积、梯度压缩等技术,以提高训练效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 训练循环
for epoch in range(cmd_args.epoch):
for x, y in dataloader:
# 数据移动到GPU(deepspeed自动处理设备分配)
x = x.cuda() # 输入数据
y = y.cuda() # 标签数据

# 前向传播
output = model(x) # 获取模型输出

# 计算损失
loss = loss_fn(output, y) # 计算交叉熵损失

# 反向传播(deepspeed优化版)
model.backward(loss) # 自动处理梯度同步

# 参数更新(优化器步骤)
model.step() # 更新模型参数

4)提交任务并训练

1
deepspeed train.py --epoch 2 --deepspeed --deepspeed_config ds.json

5)评估结果,调整超参数和配置

3 LLaMA-Factory使用

1、LLaMA-Factory简介

LLaMA-Factory是一个简单易用且高效的大模型训练框架,支持上百种大模型的训练,也支持模型的推理。

2、使用方式

需要配置yaml 配置文件,如果需要使用deepspeed,也可以进行配置。

在使用lora进行微调时,配置微调方法的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
### 训练方法
# 训练阶段:监督式微调(Supervised Fine-Tuning)
stage: sft
# 是否启用训练模式
do_train: true
# 微调类型:LoRA(低秩适配)
finetuning_type: lora
# LoRA作用的目标层(all表示所有线性层)
lora_target: all
# LoRA的秩(矩阵分解维度)
lora_rank: 16
# LoRA的α值(缩放因子,通常等于rank)
lora_alpha: 16
# LoRA层的dropout率(防止过拟合)
lora_dropout: 0.05

训练启动的命令:

1
2
3
4
#   llamafactory-cli : 主程序入口
# train : 子命令,指定执行训练任务
# qwen2-7b-lora-sft.yaml : YAML格式的配置文件路径(包含完整的训练参数)
llamafactory-cli train qwen2-7b-lora-sft.yaml

如果使用的是Qlora量化,只需要在训练方法中,添加量化方式即可。

1
2
3
# 量化位数(4-bit量化)
quantization_bit: 4
# 量化方法(使用bitsandbytes库实现)

4 GPTQ模型量化

1、量化的使用场景

image-20251104155939647

2、主要思想

image-20251104162636378

image-20251104163208266

3、算法的基本步骤如下:

  1. 收集校准数据:从训练数据或相关数据集中抽取一小部分样本,作为校准数据;
  2. 逐层处理:对模型的每一层进行独立量化,避免全局优化的复杂度;
  3. 最小化输出误差:对于每一层,寻找最佳的量化权重,使得在校准数据上的输出误差最小;
  4. 更新权重:将量化后的权重替换原始权重

4、使用过程

  • 对数据进行预处理
  • 配置量化的参数
  • 加载模型
    • 将量化的配置放到模型加载过程中
  • 调用模型的quantize进行量化
  • 将量化结果进行保存

5 vLLM使用

1、vllm的核心原理

  • 使用PageAttention去优化缓存

    在显存上分配一块固定大小的连续空间(vllm中默认为16),类似于内存页;然后多个进程运行时,可以每个进程分配自己的虚拟内存,虚拟内存通过块表(block table)关联到内存页。因为写到内存页是动态选择的过程,和传统的预先开辟显存空间不同,可以极大降低浪费;内存页的长度比较小,也可以降低内存碎片的产生。

  • 批量任务优化:vllm不再要求所有并行任务处于同一阶段,当其他推理任务进行时,只要资源充足,可以随时开始新推理任务,减少了任务批次间的等待。

  • vllm能够同时加载多个LoRA,随时可以指定使用其中的任意一个

2、使用

启动

1
2
3
CUDA_VISIBLE_DEVICES=0 API_PORT=8000 vllm serve /workspace/deepseekDistllation/models/Qwen/Qwen2.5-3B-Instruct-GPTQ-Int4 \
--enable-lora \
--lora-modules add1=/workspace/deepseekDistllation/models/lora/Qwen2.5-3B-instruct-GPTQ-Int4/train_2025-03-20-20-41-50/checkpoint-1250/

启动会暴露出一个api,然后调用这个api完成预测即可。