【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从ChatGLM本地化到LoRA、PEFT的高级微调)


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统与网络知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》 《cpolar知识学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

前面带领大家学习了ChatGLM的高级应用,相信大家对如何使用ChatGLM完成一些简单或者较高级的任务有了一定的了解.但是讲解的所有任务和应用场景均是以现有的模型训练本身的结果为基础的,没有涉及针对ChatGLM本身的源码修改和微调方面的内容.本文将学习这方面的内容.

目录

1.ChatGLM模型的本地化处理

可能有人注意到,到目前为止我们使用的都是在线版本的ChatGLM,即通过Transformer这个工具包直接调用相关接口,如果我们想离线使用ChatGLM,是否可行呢?

答案是可以的,本节将解决这个问题,即实现ChatGLM模型的本地化.


1.1下载ChatGLM源码与合并存档

在Huggingface的ChatGLM对应的库内容下,智谱相关实验室为用户提供了对应的ChatGLM源码下载,如图所示.

其中的modeling_chatglm.py文件就是供用户下载和学习的源码内容,大家可以下载当前文件夹中除了存档文件(以.bin后缀结尾的文件)之外的所有文件备用.

在这里,可以直接使用本书配套代码包中的chatGLM_spo/huggingface_saver/ demo文件夹,从而完成对存档内容的读取与载入.完整的代码如下:

python 复制代码
import torch
from transformers import AutoTokenizer, AutoModel

tokenizer = AutoTokenizer.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True
)

# 为了节省显存,我们使用的是half数据格式,也就是半精度的模型
model = AutoModel.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True
).half().cuda()

response, history = model.chat(tokenizer, "你好", history=[])
print(response)

# 你好!我是人工智能助手 ChatGLM-6B,很高兴见到你。欢迎向我任何问题
response, history = model.chat(
    tokenizer,
    "晚上睡不着应该怎么办",
    history=history
)
print(response)

torch.save(model.state_dict(), "./huggingface_saver/chatglm6b.pth")

需要注意的是,对于模型读取,这里使用的是半精度的模型,也就是模型的half数据格式,这是为了节省显存而采用的一种方式,如果有条件,可以删除half()函数直接使用全精度.而使用全精度形式可能会要求读者重新下载模型存档文件.相对于半精度的模型,全精度的模型存档占用磁盘空间较大,具体使用哪个模型可自行斟酌.

当运行完此段代码后,查询chatGLM_spo/huggingface_saver文件夹中的内容可以看到,文件夹中生成了对应的ChatGLM存档文件,相对于前期分散的存档来说,此时的存档文件是一个单独的带有.pth后缀的文件,如图所示.

下面回到ChatGLM的模型文件上,此时应该可以打开chatGLM_spo/huggingface_saver/modeling_chatglm.py文件,其构成如图所示.

这里展示的是已处理好的ChatGLM文件内容,相对于原始的modeling_chatglm,这里的文件删除了直接的chat内容,以及负责生成文本的ChatGLMForConditionalGeneration类的训练部分,只留下了负责文本生成的全连接层head.

python 复制代码
     self.lm_head = skip_init(
        nn.Linear,
        config.hidden_size,
        config.vocab_size,
        bias=False,
        dtype=torch.half
     )

我们仅仅需要知道最后一个lm_head的作用是对生成的隐变量进行映射,将输出的隐变量映射到对应的字符Embedding上即可.

至此,我们完成了对ChatGLM进行本地化处理的第一步,即ChatGLM主体文件的本地化.可以采用如下代码,尝试使用上面示例存储的.pth文件完整实现ChatGLM,代码如下:

python 复制代码
     model_path = "./chatglm6b.pth"
     #config文件在代码段下方解释
     glm_model = modeling_chatglm.ChatGLMForConditionalGeneration(config)
     model_dict = torch.load(model_path)

其中config文件用于对ChatGLM文件进行主体化设置,其主要内容如下:

python 复制代码
     def __init__(
        self,
        vocab_size=130528,           #字符个数
        hidden_size=4096,            #隐变量维度大小
        num_layers=28,               #模型深度
        num_attention_heads=32,      #模型同数
        layernorm_epsilon=1e-5,      #layernorm的极值
        use_cache=False,             #是否使用缓存
        bos_token_id=130004,         #序列起始字符编号
        eos_token_id=130005,         #序列结束字符编号
        mask_token_id=130000,        #mask字符的编号
        gmask_token_id=130001,       #自回归mask字符编号
        pad_token_id=0,              #padding字符编号
        max_sequence_length=2048,    #模型生成的最大字符长度
        inner_hidden_size=16384,     #feedforward的维度大小
        position_encoding_2d=True,   #使用2d位置编码
        quantization_bit=0,          #模型量化参数,默认为0,即使用模型量化处理
        pre_seq_len=None,            #预输入的序列长度
        ...

代码中定义了相当多的参数,vocab_size是模型输入的字符数量,num_layers设置的是模型的深度,max_sequence_length是模型输入输出文本长度.在这里通过对参数的设定确定了模型的主体大小和处理要求.


1.2修正自定义的本地化模型

下面修正自定义的本地化模型,将模型设置成本地可以处理并熟悉的内容,完整代码如下:

python 复制代码
import torch
from chatGLM_spo.huggingface_saver import configuration_chatglm, modeling_chatglm


class XiaohuaModel(torch.nn.Module):
    def __init__(self, model_path="./chatglm6b.pth", config=None, strict=True):
        super().__init__()

        self.glm_model = modeling_chatglm.ChatGLMForConditionalGeneration(config)
        model_dict = torch.load(model_path)

        self.glm_model.load_state_dict(model_dict, strict=strict)
        self.loss_fct = torch.nn.CrossEntropyLoss(ignore_index=-100)

    def forward(self, input_ids, labels=None, position_ids=None, attention_mask=None):
        logits, hidden_states = self.glm_model.forward(
            input_ids=input_ids,
            position_ids=None,
            attention_mask=None
        )

        loss = None
        if labels != None:
            shift_logits = logits[:, :-1, :].contiguous()
            shift_labels = input_ids[:, 1:].contiguous()

            # Flatten the tokens
            logits_1 = shift_logits.view(-1, shift_logits.size(-1))
            logits_2 = shift_labels.view(-1)

            loss = self.loss_fct(logits_1, logits_2)

        return logits, hidden_states, loss

    def generate(
        self,
        start_question_text="抗原呈递的原理是什么?",
        continue_seq_length=128,
        tokenizer=None,
        temperature=0.95,
        top_p=0.95
    ):
        """
        Args:
            start_question_text: 这里指的是起始问题,需要用中文进行展示
            continue_seq_length: 这里是在question后面需要添加的字符
            temperature:
            top_p:
        Returns:

        ------------------------------------------------------------
        记录:这个tokenizer可能会在开始执行encode的时候,在最开始加上一个空格20005
        ------------------------------------------------------------

        if not history:
            prompt = query
        else:
            prompt = ""
            for i, (old_query, response) in enumerate(history):
                prompt += "[Round {}]\\n问:{}\\n答:{}\\n".format(i, old_query, response)
            prompt += "[Round {}]\\n问:{}\\n答:".format(len(history), query)
        """

        # 这里是一个简单的例子,用来判定是问答还是做其他工作
        if ":" not in start_question_text:
            inputs_text_ori = start_question_text
            inputs_text = f"[Round 0]\n问:{inputs_text_ori}\n答:"
        else:
            inputs_text = start_question_text

        input_ids = tokenizer.encode(inputs_text)

        for _ in range(continue_seq_length):
            input_ids_tensor = torch.tensor([input_ids]).to("cuda")
            logits, _, _ = self.forward(input_ids_tensor)
            logits = logits[:, -3]
            probs = torch.softmax(logits / temperature, dim=-1)
            next_token = self.sample_top_p(probs, top_p)  # 预设的top_p = 0.95
            # next_token = next_token.reshape(-1)

            # next_token = result_token[-3:-2]
            input_ids = input_ids[:-2] + [next_token.item()] + input_ids[-2:]

            if next_token.item() == 130005:
                print("break")
                break

        result = tokenizer.decode(input_ids)
        return result

    def sample_top_p(self, probs, p):
        probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
        probs_sum = torch.cumsum(probs_sort, dim=-1)
        mask = probs_sum - probs_sort > p
        probs_sort[mask] = 0.0

        probs_sort.div_(probs_sort.sum(dim=-1, keepdim=True))
        next_token = torch.multinomial(probs_sort, num_samples=1)
        next_token = torch.gather(probs_idx, -1, next_token)

        return next_token

在这里使用基础的ChatGLMForConditionalGeneration作为主处理模型,之后对lm_head输出的内容逐一地进行迭代生成.具体可以参考前面对GPT生成函数的说明.

下面检测一下模型的输出,代码如下:

python 复制代码
if __name__ == "__main__":
    from transformers import AutoTokenizer
    import tokenization_chatglm

    config = configuration_chatglm.ChatGLMConfig()
    model = XiaohuaModel(config=config).half().cuda()

    tokenizer = AutoTokenizer.from_pretrained(
        "THUDM/chatglm-6b",
        trust_remote_code=True,
        cache_dir="./huggingface_saver"
    )

    inputs_text_ori = "抗原呈递的原理是什么?"
    result = model.generate(
        inputs_text_ori,
        continue_seq_length=256,
        tokenizer=tokenizer
    )
    print(result)

    while True:
        print("请输入:")
        ques = input()
        inputs_text_ori = ques

        result = model.generate(
            inputs_text_ori,
            continue_seq_length=256,
            tokenizer=tokenizer
        )
        print(result)

为了考验模型的输出能力,在这里我们提出了一个较为专业的问题(医学常识"抗原呈递"方面的内容)对其进行考察,结果请自行查阅.

为了便于测试,代码中实现了一个while循环用于接收用户输入的文本内容,这样可以使得大家在测试文本时不用多次重启模型.更多的内容请自行尝试并输出对应的文本.

另外需要注意的是,在完整的模型中还集成了loss方面的计算,即常用的交叉熵损失函数的计算.这里将其与模型本身集成在一起的原因在于:对于分布式计算,损失函数如果过于集中,就会造成主硬件的负载过大,而将损失函数的计算分配到每个单独的模型实例中,可以较好地降低损失函数的计算成本.


1.3构建GLM模型的输入输出示例

相对于传统的GPT模型,GLM模型的创新点主要在于输入输出数据结合了自编码和自回归的输入输出形式:

· 自编码:随机MASK输入中连续跨度的Token.

· 自回归:结合前文内容(包括自编码的mask部分),去预测下一个输出值.

结合了自编码与自回归的GLM训练模式如图所示.

这里解释一下:

(1)左侧图(a),原始文本是 x ~1~ , x ~2~ , x ~3~ , x ~4~ , x ~5~ , x ~6~ 。两个将采样的跨度是 x ~3~ 与 x ~5~ , x ~6~ 。图中A部分用M代替被采样的跨度,打乱B部分的跨度。

(2)中间图©,GLM自回归生成B部分,每个跨度以S作为输入进行预置,以E作为输出进行附加。2D位置代表跨度间和跨度内的位置。

(3)右侧图(d), x 区域被遮掉了。A部分(蓝色框架内)能注意到自己,但不能注意到B部分。B部分的令牌可以关注A及其在B中的前因(黄色和绿色框架对应两个跨度),M := MASK,S :=START,E := END。

在这种训练方式下,GLM整合了"自编码器"与"自回归器",从而完成了模型训练的统一.而其中mask字符的比例一般占全部文本的15%左右.

上面是对原始的GLM训练过程中输入输出的解释,具体在后期的输入输出操作上会有两种输入:

(1)遵循原有设计,带有mask的半错误输入输出匹配如图所示.

从图中可以很明显地看到,左侧区域并没有对下一个字符进行预测的操作,而右侧区域则根据输入的字符进行错位输入和输出.

(2)遵循GPT-2的传统输入输出方式,如图所示.

这就是较简单的GPT-2输入输出方法,可以查看前面章节的内容来完成.在后续的微调过程中,将采用第(2)种方式完成模型的微调训练.


2.高级微调方法1------基于加速库Accelerator的全量数据微调

前面介绍了使用ChatGLM进行微调的官方方案.需要注意的是,这种微调方法是基于Linux完成的,并且采用脚本的形式来完成(官方的ChatGLM微调).这种方法简单易行,可以很好地完成既定目标,但是对于学习和掌握相关内容的使用者来说,这种微调方法可能并不是很合适.

特别是当我们采用ChatGLM-130B的大模型时,将会有1300亿参数,而为了让它能完成特定领域的工作,不会只采用知识链的方法,而需要对其进行微调.但是问题在于,如果直接对ChatGLM-130B进行微调,成本太高也太麻烦了.对于一般的小公司或者个人来说,想要开发自己的大模型几乎不可能,像ChatGLM这样的大模型,一次训练的成本就达上亿甚至十几亿美元.

针对此情况,本节将演示ChatGLM的高级微调方法,通过演示讲解如何在消费级的显卡和普通的Windows操作系统上完成微调任务.


2.1数据的准备------将文本内容转化成三元组的知识图谱

下面将实现基于多种方法的ChatGLM的模型微调,无论采用哪种微调方法,都需要准备数据.这里准备了一份基于维修记录的文本数据,内容如图所示.

可以看到,text中就是出现的问题,而answer中是前面文本内容按关系的抽取.我们要做的是通过问答的内容从中提取涉及"性能故障", "部件故障", "组成"和"检测工具"的三元组.

同时,根据任务目标和模型本身的特性,设计一种适配模板,如下所示:

这样设置的Prompt需要对输入的数据结构重新进行调整,并且要符合原有的ChatGLM的输入格式,新的Token字符构成如下:

python 复制代码
     tokens = prompt_tokens + src_tokens + ["[gMASK]", "<sop>"] + tgt_tokens + ["<eop>"]

此时,原有的输入文本被重新分隔并被转化成新的被分隔后的文本,如下所示:

从上面可以看到使用了tokenizer.tokenize函数分隔后的文本内容.对文本内容进行处理的函数如下:

python 复制代码
import json


def get_train_data(data_path, tokenizer, max_len, max_src_len, prompt_text):
    max_tgt_len = max_len - max_src_len - 3
    all_data = []

    with open(data_path, "r", encoding="utf-8") as fh:
        for i, line in enumerate(fh):
            sample = json.loads(line.strip())
            src_tokens = tokenizer.tokenize(sample["text"])
            prompt_tokens = tokenizer.tokenize(prompt_text)

            if len(src_tokens) > max_src_len - len(prompt_tokens):
                src_tokens = src_tokens[:max_src_len - len(prompt_tokens)]

            tgt_tokens = tokenizer.tokenize("\n原因:" + sample["answer"])

            if len(tgt_tokens) > max_tgt_len:
                tgt_tokens = tgt_tokens[:max_tgt_len]

            tokens = (
                prompt_tokens
                + src_tokens
                + ["[gMASK]", "<sop>"]
                + tgt_tokens
                + ["<eop>"]
            )

            input_ids = tokenizer.convert_tokens_to_ids(tokens)
            context_length = input_ids.index(tokenizer.bos_token_id)
            mask_position = context_length - 1
            labels = [-100] * context_length + input_ids[mask_position + 1:]

            pad_len = max_len - len(input_ids)
            input_ids = input_ids + [tokenizer.pad_token_id] * pad_len
            labels = labels + [-100] * pad_len

            all_data.append(
                {
                    "text": sample["text"],
                    "answer": sample["answer"],
                    "input_ids": input_ids,
                    "labels": labels,
                }
            )

    return all_data

这里的tokenizer是传入的模型初始化编码器,而tokenize()函数和convert_tokens_to_ids()函数分别完成了参数的切分与转换任务.对于最后labels的设计,需要遵循GLM原有的设计原理,GLM本身是一个自编码与自回归集成在一起的生成性文本模型,因此需要使用这种特殊的编码形式.有兴趣的可以自行了解和学习相关的GLM生成模型的相关内容.

完整的数据处理代码如下:

python 复制代码
import json
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import Dataset
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True
)


def get_train_data(data_path, tokenizer, max_len, max_src_len, prompt_text):
    max_tgt_len = max_len - max_src_len - 3
    all_data = []

    with open(data_path, "r", encoding="utf-8") as fh:
        for i, line in enumerate(fh):
            sample = json.loads(line.strip())
            src_tokens = tokenizer.tokenize(sample["text"])
            prompt_tokens = tokenizer.tokenize(prompt_text)

            if len(src_tokens) > max_src_len - len(prompt_tokens):
                src_tokens = src_tokens[:max_src_len - len(prompt_tokens)]

            tgt_tokens = tokenizer.tokenize("\n原因:" + sample["answer"])

            if len(tgt_tokens) > max_tgt_len:
                tgt_tokens = tgt_tokens[:max_tgt_len]

            tokens = (
                prompt_tokens
                + src_tokens
                + ["[gMASK]", "<sop>"]
                + tgt_tokens
                + ["<eop>"]
            )

            input_ids = tokenizer.convert_tokens_to_ids(tokens)
            context_length = input_ids.index(tokenizer.bos_token_id)
            mask_position = context_length - 1
            labels = [-100] * context_length + input_ids[mask_position + 1:]

            pad_len = max_len - len(input_ids)
            input_ids = input_ids + [tokenizer.pad_token_id] * pad_len
            labels = labels + [-100] * pad_len

            all_data.append(
                {
                    "text": sample["text"],
                    "answer": sample["answer"],
                    "input_ids": input_ids,
                    "labels": labels,
                }
            )

    return all_data


# 服务PyTorch计算的数据输入类
class Seq2SeqDataSet(Dataset):
    """数据处理函数"""

    def __init__(self, all_data):
        self.all_data = all_data

    def __len__(self):
        return len(self.all_data)

    def __getitem__(self, item):
        instance = self.all_data[item]
        return instance


def coll_fn(batch):
    input_ids_list, labels_list = [], []

    for instance in batch:
        input_ids_list.append(
            torch.tensor(instance["input_ids"], dtype=torch.long)
        )
        labels_list.append(
            torch.tensor(instance["labels"], dtype=torch.long)
        )

    return {
        "input_ids": pad_sequence(
            input_ids_list,
            batch_first=True,
            padding_value=3
        ),  # 这里原来是20003,vocab改成了3
        "labels": pad_sequence(
            labels_list,
            batch_first=True,
            padding_value=3
        ),
    }


if __name__ == "__main__":
    from transformers import AutoTokenizer

    tokenizer = AutoTokenizer.from_pretrained(
        "THUDM/chatglm-6b",
        trust_remote_code=True
    )

    all_data = get_train_data(
        "./data/spo_0.json",
        tokenizer,
        768,
        450,
        "你现在是一个信息抽取模型,请你帮我抽取出关系内容为"
        "\"性能故障\"、\"部件故障\"、\"组成\"和 \"检测工具\"的相关三元组。"
        "三元组内部用\"_\"连接,三元组之间用\\n分隔。文本:"
    )

    train_dataset = Seq2SeqDataSet(all_data)
    instance = train_dataset.__getitem__(0)
    text, ans, input_ids, lab = instance
    print(len(instance["input_ids"]))
    print(len(instance["labels"]))

    from torch.utils.data import RandomSampler, DataLoader

    train_loader = DataLoader(
        train_dataset,
        batch_size=4,
        drop_last=True,
        num_workers=0
    )

在上面代码中,为了方便PyTorch2.0载入数据,提供了一个数据载入类Seq2SeqDataSet,从而可以更好地进行模型的计算工作.


2.2加速的秘密------Accelerate模型加速工具详解

Accelerate是Huggingface开源的、方便将PyTorch模型在不同模式下进行训练的一个小巧工具.

和标准的PyTorch方法相比,使用Accelerate进行GPU、多GPU以及半精度FP16/BF16训练时,模型的训练过程变得非常简单(只需要在标准的PyTorch训练代码中改动几行代码,就可以适应CPU/单GPU/多GPU的DDP模式/TPU等不同的训练环境),而且速度与原生PyTorch相当,非常快.

Accelerate的使用相当简单,可以直接在模型训练代码中对模型的训练函数进行更新,代码如下:

python 复制代码
     ...
     from accelerate import Accelerator
     accelerator = Accelerator()
     
     optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, betas=(0.9, 0.999),
eps=1e-5)
     lr_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=2400,
eta_min=2e-6, last_epoch=-1)
     model, optim, train_loader, lr_scheduler = accelerator.prepare(model, optimizer,
train_loader, lr_scheduler)
     ...
     
     with train:
     accelerator.backward(loss)

在上面代码中,首先从accelerate导入加速器的具体实现Accelerator,之后用实例化后的Accelerator对模型的主体部分、优化器、数据更新器以及学习率等内容进行调整,最后的backward函数的作用是为向后传递添加必要的步骤来提高混合精度。

完整的模型训练方法如下:

python 复制代码
import os

os.environ["CUDA_VISIBLE_DEVICES"] = "0"

import torch
from transformers import AutoTokenizer
from torch.utils.data import RandomSampler, DataLoader
from chatGLM_spo.huggingface_saver import (
    xiaohua_model,
    configuration_chatglm,
    modeling_chatglm,
)
from tqdm import tqdm

config = configuration_chatglm.ChatGLMConfig()
config.pre_seq_len = 16
config.prefix_projection = False
# 这里是设置config中的pre_seq_len与prefix_projection,只有这2项设置好了才行

model = xiaohua_model.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth",
    config=config,
    strict=False,
)
model = model.half().cuda()

xiaohua_model.print_trainable_parameters(model)

prompt_text = "按给定的格式抽取文本信息。\n文本:"

from chatGLM_spo import get_data

tokenizer = AutoTokenizer.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True,
)

all_train_data = get_data.get_train_data(
    "../data/spo_0.json",
    tokenizer,
    288,
    256,
    prompt_text,
)

train_dataset = get_data.Seq2SeqDataSet(all_train_data)
train_loader = DataLoader(
    train_dataset,
    batch_size=2,
    drop_last=True,
    collate_fn=get_data.coll_fn,
    num_workers=0,
)

from accelerate import Accelerator

accelerator = Accelerator()

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=2e-5,
    betas=(0.9, 0.999),
    eps=1e-5,
)

lr_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer,
    T_max=2400,
    eta_min=2e-6,
    last_epoch=-1,
)

model, optim, train_loader, lr_scheduler = accelerator.prepare(
    model,
    optimizer,
    train_loader,
    lr_scheduler,
)

for epoch in range(20):
    pbar = tqdm(train_loader, total=len(train_loader))

    for batch in pbar:
        input_ids = batch["input_ids"].cuda()
        labels = batch["labels"].cuda()

        _, _, loss = model.forward(input_ids, labels=labels)
        accelerator.backward(loss)

        # torch.nn.utils.clip_grad_norm_(model.parameters(), 1.)
        optimizer.step()
        lr_scheduler.step()  # 执行优化器
        optimizer.zero_grad()

        pbar.set_description(
            f"epoch:{epoch + 1}, train_loss:{loss.item():.5f}, "
            f"lr:{lr_scheduler.get_last_lr()[0] * 1000:.5f}"
        )

    if (epoch + 1) % 3 == 0:
        torch.save(model.state_dict(), "./glm6b_pt.pth")

上面代码的最后一行将全部内容保存为.pth后缀的标准PyTorch模型存档文件,在使用时可以将其视为普通的PyTorch文件进行载入.

python 复制代码
model = xiaohua_model.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth",
    config=config,
    strict=False
)
model.load_state_dict(torch.load("./glm6b_pt.pth"))
model = model.half().cuda()

可以看到,在模型进行基本的载入后,又重新载入了我们独立进行微调(Fine-Tuning)后保存的模型参数.完整使用存档参数进行推断的函数代码如下:

python 复制代码
def sample_top_p(probs, p):
    probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
    probs_sum = torch.cumsum(probs_sort, dim=-1)
    mask = probs_sum - probs_sort > p
    probs_sort[mask] = 0.0
    probs_sort.div_(probs_sort.sum(dim=-1, keepdim=True))
    next_token = torch.multinomial(probs_sort, num_samples=1)
    next_token = torch.gather(probs_idx, -1, next_token)
    return next_token


import torch
from transformers import AutoTokenizer
from torch.utils.data import RandomSampler, DataLoader
from 第十八章.chatGLM_spo.huggingface_saver import (
    xiaohua_model,
    configuration_chatglm,
    modeling_chatglm,
)
from tqdm import tqdm

config = configuration_chatglm.ChatGLMConfig()
config.pre_seq_len = 16
config.prefix_projection = False
# 这里是设置config中的pre_seq_len与prefix_projection,只有这2项设置好了才行

model = xiaohua_model.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth",
    config=config,
    strict=False,
)
model.load_state_dict(torch.load("./glm6b_pt.pth"))
model = model.half().cuda()

xiaohua_model.print_trainable_parameters(model)
model.eval()

max_len = 288
max_src_len = 256
prompt_text = "按给定的格式抽取文本信息。\n文本:"
save_data = []
f1 = 0.0
max_tgt_len = max_len - max_src_len - 3

tokenizer = AutoTokenizer.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True,
)

import time
import json

s_time = time.time()

with open("../data/spo_0.json", "r", encoding="utf-8") as fh:
    for i, line in enumerate(tqdm(fh, desc="iter")):
        with torch.no_grad():
            sample = json.loads(line.strip())
            src_tokens = tokenizer.tokenize(sample["text"])
            prompt_tokens = tokenizer.tokenize(prompt_text)

            if len(src_tokens) > max_src_len - len(prompt_tokens):
                src_tokens = src_tokens[:max_src_len - len(prompt_tokens)]

            tokens = prompt_tokens + src_tokens + ["[gMASK]", "<sop>"]
            input_ids = tokenizer.convert_tokens_to_ids(tokens)
            # input_ids = tokenizer.encode("帮我写个快排算法")

            for _ in range(max_src_len):
                input_ids_tensor = torch.tensor([input_ids]).to("cuda")
                logits, _, _ = model.forward(input_ids_tensor)
                logits = logits[:, -3]
                probs = torch.softmax(logits / 0.95, dim=-1)
                next_token = sample_top_p(probs, 0.95)  # 预设的top_p = 0.95
                # next_token = next_token.reshape(-1)

                # next_token = result_token[-3:-2]
                input_ids = (
                    input_ids[:-2]
                    + [next_token.item()]
                    + input_ids[-2:]
                )

                if next_token.item() == 130005:
                    print("break")
                    break

            result = tokenizer.decode(input_ids)
            print(result)

上面实现请大家自行训练并验证.


2.3更快的速度------使用INT8(INT4)量化模型加速训练

下面介绍模型的数据类型,前面进行模型推断使用的都是hal()函数,这是PyTorch特有的进行半精度训练的参数方式,即可以在"略微"降低模型准确率的基础上大幅度减少硬件的消耗,具体读者可以参考下表.

显存占用表

表中的Half数据类型为PyTorch 2.0中的半精度数据类型,在降低参数占用空间的同时,对准确率影响较少.而其使用也较为简单,即直接在PyTorch模型构建时显式注释即可:

python 复制代码
    model = xiaohua_model.XiaohuaModel(model_path="../huggingface_saver/
chatglm6b.pth",config=config,strict=False)
    model = model.half().cuda()

除此之外,表中还列出了两种模型参数的格式,分别是INT8与INT4参数格式.这是一种为了解决大模型参数量占用过大而提出的一种加速推理的技术.

以INT8量化为例,相比于一般的FLOAT32模型,该模型的大小减小了4倍,内存要求减少2倍.与FLOAT32计算相比,对INT8计算的硬件支持通常快2~4倍.大多数情况下,模型需要以FLOAT32精度训练,然后将模型转换为INT8.

有兴趣的可以自行学习基于PyTorch大模型的量化方法,在这里提供了如下几种可以缩减计算量的模型量化方案.

1.基于清华大学提供的模型量化存档

清华大学在提供ChatGLM存档文件的同时,也相应地提供了INT8和INT4参数的下载方法,可以在下载时直接指定需要下载的量化类型,代码如下:

python 复制代码
     from transformers import AutoTokenizer, AutoModel
     tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm-6b-int4",
trust_remote_code=True)
     model = AutoModel.from_pretrained("THUDM/chatglm-6b-int4",
trust_remote_code=True).half().cuda()
     ...

在这里直接下载了INT4文件进行后续的推断与处理,这样做的好处在于下载的文件较小,但同时伴随着对预测精度的牺牲,可以自行下载文件,比较推断结果.

2.基于模型文件的量化方法

前面演示了如何直接下载ChatGLM的构成文件,在其定义的ChatGLM类中,同时提供了相对应的量化方法,代码如下:

python 复制代码
     def quantize(self, bits: int, empty_init=False, **kwargs):
        if bits == 0:
           return
        from .quantization import quantize
        if self.quantized:
           return self
        self.quantized = True
        self.config.quantization_bit = bits
        self.transformer = quantize(self.transformer, bits, empty_init=empty_init,
**kwargs)
        return self

这里调用了清华大学专用的模型量化函数,如下代码所示.

python 复制代码
for layer in model.layers:
    layer.attention.query_key_value = QuantizedLinear(
        weight_bit_width=weight_bit_width,
        weight_tensor=layer.attention.query_key_value.weight.to(torch.cuda.current_device()),
        bias_tensor=layer.attention.query_key_value.bias,
        in_features=layer.attention.query_key_value.in_features,
        out_features=layer.attention.query_key_value.out_features,
        bias=True,
        dtype=torch.half,
        device=layer.attention.query_key_value.weight.device,
        empty_init=empty_init
    )

    layer.attention.dense = QuantizedLinear(
        weight_bit_width=weight_bit_width,
        weight_tensor=layer.attention.dense.weight.to(torch.cuda.current_device()),
        bias_tensor=layer.attention.dense.bias,
        in_features=layer.attention.dense.in_features,
        out_features=layer.attention.dense.out_features,
        bias=True,
        dtype=torch.half,
        device=layer.attention.dense.weight.device,
        empty_init=empty_init
    )

    layer.mlp.dense_h_to_4h = QuantizedLinear(
        weight_bit_width=weight_bit_width,
        weight_tensor=layer.mlp.dense_h_to_4h.weight.to(torch.cuda.current_device()),
        bias_tensor=layer.mlp.dense_h_to_4h.bias,
        in_features=layer.mlp.dense_h_to_4h.in_features,
        out_features=layer.mlp.dense_h_to_4h.out_features,
        bias=True,
        dtype=torch.half,

可以看到,源码中实际上是根据设定的量化值大小,分别对各个不同层进行参数量化处理,之后重新将量化后的参数整合在一起.在这里不再深入讲解,有兴趣的可自行研究相关内容.

下面有两种实现INT8和INT4的量化方法,即在下载数据的同时完成量化以及构建自定义的量化函数.

(1)下载数据的同时完成量化:

python 复制代码
     from transformers import AutoTokenizer, AutoModel
     tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm-6b", trust_remote_code=True)
     model = AutoModel.from_pretrained("THUDM/chatglm-6b",trust_remote_code=True)
     .quantize(8).cuda()

(2)构建自定义的量化函数:

python 复制代码
    model = xiaohua_model.XiaohuaModel(model_path="../huggingface_saver/
chatglm6b.pth",config=config,strict=False)
    model.glm_model.quantize(8,False)
    model = model.half().cuda()

自定义量化函数的使用也较为简单,在这里直接调用模型文件中提供的量化函数即可完成对模型的量化.

需要提醒的是,如果使用量化数据进行整体存档,在存档时需要将模型重新定义成同样的量化数值,具体请自行完成.

数据质量、Accelerate 与量化训练

1.三元组抽取首先是"数据契约"问题

维修文本到知识图谱三元组的任务,不应只定义一个 Prompt.至少要固定:允许的实体类型、关系集合、关系方向、实体规范化方式、同义词策略、无三元组样本的表达、多个三元组的排序规则,以及无法判断时是否允许 abstain(拒答).推荐输出可校验的JSON,而不是依靠自然语言格式:

json 复制代码
{
  "triples": [
    {"head": "发动机", "relation": "部件故障", "tail": "异常振动"}
  ]
}

数据划分应按设备、故障工单或时间段分组,避免同一模板或同一设备的近重复记录同时落入训练集和测试集.否则测试分数看似很高,实际只是记住了表述.

结构化抽取建议至少报告:

  • JSON/格式可解析率;
  • 实体、关系和完整三元组的 precision、recall、micro/macro F1;
  • 原文无依据三元组的幻觉率;
  • 空结果、重复结果和关系方向错误率;
  • 按故障类型、文本长度和罕见实体分桶后的表现.

2.Accelerate 提供"可移植编排",不保证天然加速

accelerate.Accelerator 的核心价值是统一 CPU、单 GPU、多 GPU、混合精度以及不同分布式后端的训练代码.prepare() 包装模型、优化器、数据加载器和调度器,backward() 负责在当前精度/分布式策略下执行反向传播.

它能否更快,取决于通信开销、批大小、数据输入瓶颈、混合精度内核和 GPU 利用率.小模型或过小 batch 在多卡上甚至可能更慢.保存检查点时还应使用进程同步、主进程保护以及 unwrap_model()/框架提供的 save 接口,避免多个进程同时写同一文件.

3.全量微调为什么昂贵

以常见 AdamW 混合精度训练粗估,每个可训练参数可能需要:2 字节模型权重、2 字节梯度、8 字节一阶/二阶 FP32 动量,以及某些实现中的4字节FP32主权重,总计约 12--16 字节,尚未包含激活值和临时缓冲.对60亿参数模型,这一部分就可能达到约72--96GB;精确数字会因优化器、精度、分片和实现而变.

降低峰值显存的工具包括梯度检查点、梯度累积、ZeRO/FSDP 分片、CPU/NVMe offload、FlashAttention 类内核和低精度训练.它们分别牺牲重算、通信、带宽或工程复杂度,不应被笼统归为"免费加速".

4.INT8/INT4 推理量化不等于直接训练低比特权重

原稿把"量化模型加速训练"和推理量化放在一起,容易造成误解.低比特权重最直接的收益通常是减少模型存储和推理显存;是否加速取决于硬件和量化内核,量化/反量化也有成本.

当前Hugging Face文档明确提示,bitsandbytes的8位和4位训练支持的是额外参数.典型 QLoRA 路径是:以4-bit(常见为NF4)加载并冻结基座模型,在较高计算精度下完成矩阵运算,只训练 LoRA 适配器;不是对所有 4-bit 基座权重直接做普通 AdamW 更新.QLoRA 论文还使用双重量化和分页优化器来进一步降低内存峰值.

因此,看到 load_in_4bit=True 时要问清三个问题:谁被冻结、谁产生梯度、谁拥有优化器状态.只看checkpoint 的位宽,无法判断训练成本.


3.高级微调方法2 ------基于LoRA的模型微调

Accelerator库的作用是加速对模型的微调,但是对于模型本身的微调方法并没有进行很好的调整,因此在解决大模型的微调问题上并没有做出根本性的改变.基于此,研究人员提出了一种新的能够在冻结原有大模型训练参数的基础上进行微调的训练方法LoRA(Low-Rank Adaptation of Large Language Models,大语言模型的低阶适应).下面将主要介绍基于LoRA的模型微调方法.


3.1对ChatGLM进行微调的方法------LoRA

LoRA是清华大学的研究人员为了解决大语言模型微调而开发的一项通用技术.

LoRA的思想很简单(见下图):

(1)在原始PLM用(Pre-trained Language Model,预训练语言模型)旁边增加一个旁路,进行降维再升维的操作,用来模拟所谓的intrinsic rank.

(2)训练的时候固定PLM的参数,只训练降维矩阵A与升维矩阵B.而模型的输入输出维度不变,输出时将BA与PLM的参数叠加.

(3)用随机高斯分布初始化A,用0矩阵初始化B,以保证训练的开始此旁路矩阵依然是0矩阵.

假设要在下游任务微调一个预训练语言模型(例如ChatGLM),则需要更新预训练模型参数,公式如下:

W = W 0 + Δ W W = W_0 + \Delta W W=W0+ΔW

W 0 ∈ R d × k W_0 \in R^{d \times k} W0∈Rd×k

Δ W = B × A \Delta W = B \times A ΔW=B×A

其中 W 0 是预训练模型初始化的参数,△ W (确切地说,是 B 和 A 构成的矩阵)是需要更新的参数。可以看出,相对于全局调参的大模型来说,△ W 大小可控,而且很经济。

具体来看,在整个模型的训练过程中,只有△W会随之更新,而在前向过程中,△W和W 0 都会乘以相同的输入

H = W 0 x + Δ W x = W 0 x + B A x H = W_0 x + \Delta W x = W_0 x + BAx H=W0x+ΔWx=W0x+BAx

LoRA的这种思想有点类似于残差连接,同时使用这个旁路的更新来模拟Full Fine-Tuning(完全微调)的过程.并且,Full Fine-Tuning可以被看作LoRA的特例.同时,在推理过程中,LoRA几乎未引入额外的参数,只需要进行全量参数计算即可.

具体使用时,LoRA与Transformer的结合很简单,仅在QKV Attention的计算中增加一个旁路即可.


3.2自定义LoRA的使用方法

在讲解自定义的LoRA结构之前,我们首先介绍一下使用方法.

其中的model.py文件是对模型参数进行处理的文件,如图所示.

为了便于使用,其中还提供了对模型参数的计算和表示,这是为了显式展示可训练和不可训练的参数.这部分代码如下:

python 复制代码
     def print_trainable_parameters(model):
        trainable_params = 0
        all_param = 0
        for _, param in model.named_parameters():
           num_params = param.numel()
           if num_params == 0 and hasattr(param, "ds_numel"):
              num_params = param.ds_numel
           all_param += num_params
           if param.requires_grad:
              trainable_params += num_params
        print(
           f"trainable params: {trainable_params} || all params: {all_param} || trainable%:
{100 * trainable_params / all_param}")

完整的LoRA使用情况如下:

python 复制代码
model = xiaohua_model.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth",
    config=config,
    strict=False
)
model = model.half().cuda()

for name, param in model.named_parameters():
    param.requires_grad = False

# 下面就是LoRA的部分
from minlora.model import *
from minlora.utils import *

for key, _layer in model.named_modules():
    if "query_key_value" in key:
        add_lora(_layer)

xiaohua_model.print_trainable_parameters(model)

可以看到,在这里首先完成了对模型的载入,之后通过对所有参数的调整,将所有参数设置为无法求导,这部分是不可训练的,之后我们选取了特定层,即对模型中名为query_key_value的部分完成LoRA的注入.最终打印参数结构如图所示.


3.3基于自定义LoRA的模型训练

LoRA对模型的修正,实际上就是冻结原有的模型参数,从而只训练插入的参数.因此,有多种方式对参数进行保存:

(1)保存所有的参数文件.

(2)仅保存LoRA更新的参数.

保存所有参数文件的方法这里不再举例,使用PyTorch 2.0标准模型保存方法即可,而对于仅保存LoRA更新的参数的方法,可以通过如下代码读取对应的参数:

python 复制代码
     def name_is_lora(name):
        return (
           len(name.split(".")) >= 4
           and (name.split(".")[-4]) == "parametrizations"
           and name.split(".")[-1] in ["lora_A", "lora_B"]
     )
     def get_lora_state_dict(model):
        return {k: v for k, v in model.state_dict().items() if name_is_lora(k)}

完整的基于LoRA的模型训练代码如下:

python 复制代码
import torch
from transformers import AutoTokenizer
from torch.utils.data import RandomSampler, DataLoader
from chatGLM_spo.huggingface_saver import (
    xiaohua_model,
    configuration_chatglm,
    modeling_chatglm,
)
from tqdm import tqdm

config = configuration_chatglm.ChatGLMConfig()
# 这里设置config中的pre_seq_len与prefix_projection,只有这2项设置好了才行

model = xiaohua_model.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth",
    config=config,
    strict=False,
)
model = model.half().cuda()

for name, param in model.named_parameters():
    param.requires_grad = False

# 下面就是LoRA的部分
from minlora.model import *
from minlora.utils import *

for key, _layer in model.named_modules():
    if "query_key_value" in key:
        add_lora(_layer)

xiaohua_model.print_trainable_parameters(model)

prompt_text = "按给定的格式抽取文本信息。\n文本:"

tokenizer = AutoTokenizer.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True,
)

from chatGLM_spo import get_data

all_train_data = get_data.get_train_data(
    "../data/spo_0.json",
    tokenizer,
    128,
    96,
    prompt_text,
)

train_dataset = get_data.Seq2SeqDataSet(all_train_data)
train_loader = DataLoader(
    train_dataset,
    batch_size=2,
    drop_last=True,
    collate_fn=get_data.coll_fn,
    num_workers=0,
)

from accelerate import Accelerator

accelerator = Accelerator()
device = accelerator.device

lora_parameters = [{"params": list(get_lora_params(model))}]

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=2e-5,
    betas=(0.9, 0.999),
    eps=1e-5,
)

lr_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer,
    T_max=2400,
    eta_min=2e-6,
    last_epoch=-1,
)

model, optim, train_loader, lr_scheduler = accelerator.prepare(
    model,
    optimizer,
    train_loader,
    lr_scheduler,
)

for epoch in range(96):
    pbar = tqdm(train_loader, total=len(train_loader))

    for batch in pbar:
        input_ids = batch["input_ids"].cuda()
        labels = batch["labels"].cuda()

        _, _, loss = model.forward(input_ids, labels=labels)
        accelerator.backward(loss)

        # torch.nn.utils.clip_grad_norm_(model.parameters(), 1.)
        optimizer.step()
        lr_scheduler.step()  # 执行优化器
        optimizer.zero_grad()

        pbar.set_description(
            f"epoch:{epoch + 1}, train_loss:{loss.item():.5f}, "
            f"lr:{lr_scheduler.get_last_lr()[0] * 1000:.5f}"
        )

torch.save(model.state_dict(), "./glm6b_lora_all.pth")

lora_state_dict = get_lora_state_dict(model)
torch.save(lora_state_dict, "./glm6b_lora_only.pth")

可以看到,这里使用了两种不同的方法对模型的参数进行保存,即仅保存LoRA更新的参数与保存所有的参数,具体可以在训练完毕后自行对比查看.

还有一个需要注意的地方,在编写优化函数的时候,是对全部参数直接进行优化,虽然这里对模型原有的参数进行了冻结操作,但是整体在训练时同样也要耗费大量的计算空间.因此,这里提供了专门提取LoRA训练参数的方法,代码如下:

python 复制代码
def get_params_by_name(model, print_shapes=False, name_filter=None):
    for n, p in model.named_parameters():
        if name_filter is None or name_filter(n):
            if print_shapes:
                print(n, p.shape)
            yield p


def get_lora_params(model, print_shapes=False):
    return get_params_by_name(
        model,
        print_shapes=print_shapes,
        name_filter=name_is_lora
    )

在具体使用时,我们可以优化代码如下:

python 复制代码
lora_parameters = [{"params": list(get_lora_params(model))}]

optimizer = torch.optim.AdamW(
    lora_parameters.parameters(),
    lr=2e-5,
    betas=(0.9, 0.999),
    eps=1e-5
)

请自行验证.


3.4基于自定义LoRA的模型推断

下面基于自定义的LoRA参数对数据进行推断.

如果此时我们想要保存全体数据的话,那么可以直接使用PyTorch 2.0的参数载入方法完成对整体参数的载入,实现代码如下:

python 复制代码
     #注意此时的strict设置成False
     model = xiaohua_model.XiaohuaModel(model_path="../huggingface_saver/chatglm6b.pth",
config=config,strict=False)
     
    # 下面就是LoRA的部分
    from minlora.model import  *
    from minlora.utils import *
     
    for key,_layer in model.named_modules():
       if "query_key_value" in key:
          add_lora(_layer)
    for name,param in model.named_parameters():
       param.requires_grad = False
    model.load_state_dict(torch.load("./glm6b_lora.pth"))

除此之外,对于自定义LoRA参数的载入方法,此时需要注意的是,在模型参数载入的过程中,strict被设置成False,这是按模型载入的方法非严格地载入模型参数,通过原有的add_lora函数将LoRA参数重新加载到模型中.代码如下:

python 复制代码
for key, _layer in model.named_modules():
    if "query_key_value" in key:
        add_lora(_layer)

for name, param in model.named_parameters():
    param.requires_grad = False

model.load_state_dict(
    torch.load("./glm6b_lora_only.pth"),
    strict=False
)
model = model.half().cuda()

最终决定采用哪种方式,需要大家根据自身的文档保存结果自行决定.一个完整的使用推断代码实现的例子如下:

python 复制代码
def sample_top_p(probs, p):
    probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
    probs_sum = torch.cumsum(probs_sort, dim=-1)
    mask = probs_sum - probs_sort > p
    probs_sort[mask] = 0.0
    probs_sort.div_(probs_sort.sum(dim=-1, keepdim=True))
    next_token = torch.multinomial(probs_sort, num_samples=1)
    next_token = torch.gather(probs_idx, -1, next_token)
    return next_token


import os

os.environ["CUDA_VISIBLE_DEVICES"] = "0"

import torch
from transformers import AutoTokenizer
from torch.utils.data import RandomSampler, DataLoader
from chatGLM_spo.huggingface_saver import (
    xiaohua_model,
    configuration_chatglm,
    modeling_chatglm,
)
from tqdm import tqdm

config = configuration_chatglm.ChatGLMConfig()
# 这里设置config中的pre_seq_len与prefix_projection,只有这2项设置好了才行

model = xiaohua_model.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth",
    config=config,
    strict=False,
)

# 下面就是LoRA的部分
from minlora.model import *
from minlora.utils import *

for key, _layer in model.named_modules():
    if "query_key_value" in key:
        add_lora(_layer)

for name, param in model.named_parameters():
    param.requires_grad = False

# model.load_state_dict(torch.load("./glm6b_lora.pth"))  # 加载保存的全部数据存档
# 加载LoRA存档
model.load_state_dict(
    torch.load("./glm6b_lora_only.pth"),
    strict=False,
)
model = model.half().cuda()

xiaohua_model.print_trainable_parameters(model)
model.eval()

max_len = 288
max_src_len = 256
prompt_text = "按给定的格式抽取文本信息。\n文本:"
save_data = []
f1 = 0.0
max_tgt_len = max_len - max_src_len - 3

tokenizer = AutoTokenizer.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True,
)

import time
import json

s_time = time.time()

with open("../data/spo_1.json", "r", encoding="utf-8") as fh:
    for i, line in enumerate(tqdm(fh, desc="iter")):
        with torch.no_grad():
            sample = json.loads(line.strip())
            src_tokens = tokenizer.tokenize(sample["text"])
            prompt_tokens = tokenizer.tokenize(prompt_text)

            if len(src_tokens) > max_src_len - len(prompt_tokens):
                src_tokens = src_tokens[:max_src_len - len(prompt_tokens)]

            tokens = prompt_tokens + src_tokens + ["[gMASK]", "<sop>"]
            input_ids = tokenizer.convert_tokens_to_ids(tokens)

            for _ in range(max_src_len):
                input_ids_tensor = torch.tensor([input_ids]).to("cuda")
                logits, _, _ = model.forward(input_ids_tensor)
                logits = logits[:, -3]
                probs = torch.softmax(logits / 0.95, dim=-1)
                next_token = sample_top_p(probs, 0.9)  # 预设的top_p = 0.95
                # next_token = next_token.reshape(-1)

                # next_token = result_token[-3:-2]
                input_ids = (
                    input_ids[:-2]
                    + [next_token.item()]
                    + input_ids[-2:]
                )

                if next_token.item() == 130005:
                    print("break")
                    break

            result = tokenizer.decode(input_ids)
            print(result)
            print("---------------------------------")

上面代码提供了两种对LoRA参数进行载入的方法,即全量载入和只载入特定的LoRA参数.具体采用哪种方式,需要自行考虑.


3.5基于基本原理的LoRA实现

对于LoRA的基本原理和具体使用方法,前面已经做了完整的讲解.LoRA实际上就是使用一个额外的网络加载到对应的位置上,从而完成对模型预测结果的修正.LoRA参数载入的实现代码如下:

python 复制代码
import math
import torch
from torch import nn


class LoRAParametrization(nn.Module):
    def __init__(
        self,
        fan_in,
        fan_out,
        fan_in_fan_out=False,
        rank=4,
        lora_dropout_p=0.0,
        lora_alpha=1,
    ):
        super().__init__()

        # if weight is stored as (fan_out, fan_in), the memory layout of A & B follows (W + BA)x
        # otherwise, it's x(W + AB). This allows us to tie the weights between linear layers and embeddings
        self.swap = (
            (lambda x: (x[1], x[0]))
            if fan_in_fan_out
            else (lambda x: x)
        )

        self.lora_A = nn.Parameter(torch.zeros(self.swap((rank, fan_in))))
        self.lora_B = nn.Parameter(torch.zeros(self.swap((fan_out, rank))))
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))

        self.lora_alpha, self.rank = lora_alpha, rank
        self.scaling = lora_alpha / rank

        self.lora_dropout = (
            nn.Dropout(p=lora_dropout_p)
            if lora_dropout_p > 0
            else lambda x: x
        )
        self.dropout_fn = (
            self._dropout
            if lora_dropout_p > 0
            else lambda x: x
        )

        self.register_buffer(
            "lora_dropout_mask",
            torch.ones(
                self.swap((1, fan_in)),
                dtype=self.lora_A.dtype,
            ),
        )

        self.forward_fn = self.lora_forward

    def _dropout(self, A):
        # to mimic the original implementation: A @ dropout(x), we do (A * dropout(ones)) @ x
        return A * self.lora_dropout(self.lora_dropout_mask)

    def lora_forward(self, X):
        return X + (
            torch.mm(
                *self.swap((self.lora_B, self.dropout_fn(self.lora_A)))
            ).view(X.shape)
            * self.scaling
        ).half().to(X.device)

    def forward(self, X):
        return self.forward_fn(X)

    def disable_lora(self):
        self.forward_fn = lambda x: x

    def enable_lora(self):
        self.forward_fn = self.lora_forward

    @classmethod
    def from_linear(cls, layer, rank=4, lora_dropout_p=0.0, lora_alpha=1):
        fan_out, fan_in = layer.weight.shape
        return cls(
            fan_in,
            fan_out,
            fan_in_fan_out=False,
            rank=rank,
            lora_dropout_p=lora_dropout_p,
            lora_alpha=lora_alpha,
        )

    @classmethod
    def from_conv2d(cls, layer, rank=4, lora_dropout_p=0.0, lora_alpha=1):
        fan_out, fan_in = layer.weight.view(layer.weight.shape[0], -1).shape
        return cls(
            fan_in,
            fan_out,
            fan_in_fan_out=False,
            rank=rank,
            lora_dropout_p=lora_dropout_p,
            lora_alpha=lora_alpha,
        )

    @classmethod
    def from_embedding(cls, layer, rank=4, lora_dropout_p=0.0, lora_alpha=1):
        fan_in, fan_out = layer.weight.shape
        return cls(
            fan_in,
            fan_out,
            fan_in_fan_out=True,
            rank=rank,
            lora_dropout_p=lora_dropout_p,
            lora_alpha=lora_alpha,
        )

这里使用了Python高级编程方法,即通过cls这个特殊的函数将初始化中的参数lora_A、lora_B与原模型中的参数建立并联.

对于不同的模型计算层,需要使用不同的注入方案,下面展示以全连接层为默认目标层完成参数输入的方法.代码如下:

python 复制代码
import math
import torch
from torch import nn


class LoRAParametrization(nn.Module):
    def __init__(
        self,
        fan_in,
        fan_out,
        fan_in_fan_out=False,
        rank=4,
        lora_dropout_p=0.0,
        lora_alpha=1,
    ):
        super().__init__()

        # if weight is stored as (fan_out, fan_in), the memory layout of A & B follows (W + BA)x
        # otherwise, it's x(W + AB). This allows us to tie the weights between linear layers and embeddings
        self.swap = (
            (lambda x: (x[1], x[0]))
            if fan_in_fan_out
            else (lambda x: x)
        )

        self.lora_A = nn.Parameter(torch.zeros(self.swap((rank, fan_in))))
        self.lora_B = nn.Parameter(torch.zeros(self.swap((fan_out, rank))))
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))

        self.lora_alpha, self.rank = lora_alpha, rank
        self.scaling = lora_alpha / rank

        self.lora_dropout = (
            nn.Dropout(p=lora_dropout_p)
            if lora_dropout_p > 0
            else lambda x: x
        )
        self.dropout_fn = (
            self._dropout
            if lora_dropout_p > 0
            else lambda x: x
        )

        self.register_buffer(
            "lora_dropout_mask",
            torch.ones(
                self.swap((1, fan_in)),
                dtype=self.lora_A.dtype,
            ),
        )

        self.forward_fn = self.lora_forward

    def _dropout(self, A):
        # to mimic the original implementation: A @ dropout(x), we do (A * dropout(ones)) @ x
        return A * self.lora_dropout(self.lora_dropout_mask)

    def lora_forward(self, X):
        return X + (
            torch.mm(
                *self.swap((self.lora_B, self.dropout_fn(self.lora_A)))
            ).view(X.shape)
            * self.scaling
        ).half().to(X.device)

    def forward(self, X):
        return self.forward_fn(X)

    def disable_lora(self):
        self.forward_fn = lambda x: x

    def enable_lora(self):
        self.forward_fn = self.lora_forward

    @classmethod
    def from_linear(cls, layer, rank=4, lora_dropout_p=0.0, lora_alpha=1):
        fan_out, fan_in = layer.weight.shape
        return cls(
            fan_in,
            fan_out,
            fan_in_fan_out=False,
            rank=rank,
            lora_dropout_p=lora_dropout_p,
            lora_alpha=lora_alpha,
        )

    @classmethod
    def from_conv2d(cls, layer, rank=4, lora_dropout_p=0.0, lora_alpha=1):
        fan_out, fan_in = layer.weight.view(layer.weight.shape[0], -1).shape
        return cls(
            fan_in,
            fan_out,
            fan_in_fan_out=False,
            rank=rank,
            lora_dropout_p=lora_dropout_p,
            lora_alpha=lora_alpha,
        )

    @classmethod
    def from_embedding(cls, layer, rank=4, lora_dropout_p=0.0, lora_alpha=1):
        fan_in, fan_out = layer.weight.shape
        return cls(
            fan_in,
            fan_out,
            fan_in_fan_out=True,
            rank=rank,
            lora_dropout_p=lora_dropout_p,
            lora_alpha=lora_alpha,
        )

其中add_lora和merge_lora等函数主要用于将定义的LoRA层加载到对应的模型中,remove_lora函数的作用是移除模型中已有的LoRA层.请自行验证.

把 LoRA 的数学、实现与部署讲透

1.低秩更新到底省了什么

对线性层 W 0 ∈ R d out × d in W_0 \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}} W0∈Rdout×din,LoRA 冻结 W 0 W_0 W0,学习:

Δ W = α r B A , A ∈ R r × d in , B ∈ R d out × r \Delta W = \frac{\alpha}{r} BA, \quad A \in \mathbb{R}^{r \times d_{\text{in}}}, \quad B \in \mathbb{R}^{d_{\text{out}} \times r} ΔW=rαBA,A∈Rr×din,B∈Rdout×r

于是:

h = W 0 x + α r B A x h = W_0 x + \frac{\alpha}{r} BAx h=W0x+rαBAx

原矩阵有 d out d in d_{\text{out}} d_{\text{in}} doutdin 个参数,LoRA 新增 r ( d in + d out ) r(d_{\text{in}} + d_{\text{out}}) r(din+dout) 个参数。若输入输出维度都为 d d d,比例约为 2 r / d 2r/d 2r/d。

例如 d = 4096 , r = 8 d = 4096, r = 8 d=4096,r=8 时,单个方阵的 LoRA 参数约为原矩阵的 0.39 % 0.39\% 0.39%。这只是被注入矩阵的比例;整模型可训练占比还取决于 target_modules、层数、bias 和额外保存模块。

常见初始化是让一个低秩矩阵随机初始化、另一个为零,使训练开始时 (\Delta W=0),适配器初始不改变基座输出。lora_alpha/r 控制更新的有效缩放;rank 越大表达能力通常越强,但显存、存储和过拟合风险也上升。

2.target modules比rank更容易被忽略

不同模型对注意力投影的命名不同:有的使用合并的 query_key_value,有的拆成 q_proj/k_proj/v_proj/o_proj.照抄目标层名字可能导致"没有注入任何模块"或注入范围错误.训练前必须打印命中的模块、可训练参数名和比例,并用一次前向/反向确认 LoRA 参数梯度非空.

对于知识抽取任务,可从注意力投影开始;若容量不足,再评估MLP投影或 all-linear.扩大注入范围与提高 rank 都会增加容量,应该一次只改变一个变量,并用固定验证集比较.

3.保存与推断的正确契约

只保存 adapter 时,必须同时记录基座模型标识与精确版本、LoRA 配置、tokenizer/chat template、训练数据版本和推断参数.否则一个很小的 adapter 文件并不是独立模型.

LoRA 推断有两种模式:保持 adapter 独立,便于热切换多任务;或把 (\Delta W) 合并进基座权重,减少额外矩阵乘带来的延迟.因而"LoRA 推理不增加延迟"只有在成功合并、目标层支持且精度格式处理正确时才近似成立.


4.高级微调方法3------基于Huggingface的PEFT模型微调

前面演示了如何使用自定义LoRA完成对下载的模型进行微调,相对于不同的训练方法,全参数微调是一个更好的选择,但是采用LoRA的方式可以在不损失或者较少损失精度的情况下使得模型更快地收敛.

Huggingface开源了一种基于LoRA的高效微调大模型的库PEFT(Parameter-Efficient Fine-Tuning,高效参数微调),如图所示.

PEFT技术旨在通过最小化微调参数的数量和计算复杂度来提高预训练模型在新任务上的性能,从而缓解大型预训练模型的训练成本.这样一来,即使计算资源受限,也可以利用预训练模型的知识来迅速适应新任务,实现高效的迁移学习.

因此,PEFT技术可以在提高模型效果的同时,大大缩短模型训练时间和降低计算成本,让更多人能够参与到深度学习的研究中来.


4.1PEFT技术详解

前面我们已经讲过了,对于训练和开发成本达到上亿美元的ChatGLM来说,大型预训练模型的训练成本非常高昂,需要庞大的计算资源和大量的数据,一般人难以承受.这也导致了一些研究人员难以重复和验证先前的研究成果.为了解决这个问题,研究人员开始研究PEFT技术.

相较于前面介绍的LoRA调参方法,PEFT设计了3种结构,将其嵌入Transformer结构中.

· Adapter-Tuning:将较小的神经网络层或模块插入预训练模型的每一层,这些新插入的神经模块称为Adapter(适配器),下游任务微调时也只训练这些适配器参数.

· LoRA:通过学习小参数的低秩矩阵来近似模型权重矩阵的参数更新,训练时只优化低秩矩阵参数.

· Prefix/Prompt-Tuning:在模型的输入层或隐藏层添加若干额外可训练的前缀Tokens(这些前缀是连续的伪Tokens,不对应真实的Tokens),只训练这些前缀参数即可.

LoRA结构前面已经介绍过了,这里不再重复讲解.下面以Adapter结构为例进行介绍,Adapter结构如图所示.

在训练时,原来预训练模型的参数固定不变,只对新增的Adapter结构进行微调.同时为了保证训练的高效性(也就是尽可能少的引入更多参数),Adapter结构设计如下:

· 一个down-project层将高维特征映射到低维特征,之后经过一个非线性计算层对数据进行低维计算,再用一个up-project结构将低维特征映射回原来的高维特征.

· 同时也设计了skip-connection结构,确保在最差的情况下能够退化为identity.

Prefix-Tuning结构在模型输入前会添加一个连续且任务特定的向量序列(Continuous Task-Specific Vector),称之为前缀(Prefix).前缀被视为一系列虚拟Tokens,它由不对应真实Tokens的自由参数组成.与更新所有PLM参数的全量微调不同,Prefix-Tuning固定PLM的所有参数,只更新优化特定任务的前缀.因此,在生产部署时,只需要存储一个大型PLM的副本和一个学习到特定任务的前缀,每个下游任务就只会产生非常小的额外计算和存储开销.


4.2PEFT的使用与参数设计

首先来看PEFT的使用方法,官方既定的PEFT演示内容如下:

python 复制代码
from transformers import AutoModelForSeq2SeqLM
from peft import get_peft_config, get_peft_model, LoraConfig, TaskType

peft_config = LoraConfig(
    task_type=TaskType.MODEL_TYPE,
    inference_mode=False,
    r=8,
    lora_alpha=32,
    lora_dropout=0.1,
    target_modules=["query_key_value"]
)

model = "加载的模型"
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()

这里首先通过peft_config设定了加载PEFT的参数,之后将加载的模型与PEFT参数通过给定的get_peft_model函数进行结合.

下面我们观察一下PEFT的config文件,其中对相关参数进行了定义,内容如下:

python 复制代码
     {
       "base_model_name_or_path": null,
       "bias": "none",
       "enable_lora": [
         true,
         false,
         true
       ],
       "fan_in_fan_out": true,
       "inference_mode": false,
       "lora_alpha": 32,
       "lora_dropout": 0.1,
       "merge_weights": false,
       "modules_to_save": null,
       "peft_type": "LORA", #PEFT类型
       "r": 8,
       "target_modules": [
         "query_key_value"
       ],
       "task_type": "CAUSAL_LM"
     }

可以看到,其中最关键的是定义了peft_type,这里为了便于比较,同样采用LoRA方法对模型进行微调.将其引入我们自定义的ChatGLM模型中这部分代码如下:

python 复制代码
import os

os.environ["CUDA_VISIBLE_DEVICES"] = "0"

import torch
from tqdm import tqdm
from torch.utils.data import RandomSampler, DataLoader
from peft import (
    get_peft_model,
    LoraConfig,
    TaskType,
    prepare_model_for_int8_training,
    get_peft_model_state_dict,
)
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, AutoConfig

device = "cuda"

from 第十八章.chatGLM_spo.fintunning_peft_xiaohua import modeling_chatglm

model = modeling_chatglm.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth"
)

peft_config = LoraConfig.from_pretrained("./peft")
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
model = model.half().to(device)  # .to(device)

打印结果如图所示.

可以很明显地看到,此时同样冻结了绝大部分参数,而可训练的参数只占全部参数的5%左右.


4.3Huggingface专用PEFT的使用

讲解PEFT的使用,对于Huggingface发布的PEFT库,我们只需要遵循其使用规则,直接将其加载到原有模型类中即可.

由于PEFT库是由Huggingface发布的,其天然适配Huggingface的输入输出接口,因此如果想要将其加载到我们自定义的ChatGLM代码段中,需要遵循PEFT对接口的定义标注,即需要在原有的model类中定义Huggingface标准的forward函数,如下代码所示.

python 复制代码
def forward(self, input_ids, labels=None, position_ids=None, attention_mask=None):
    logits, hidden_states = self.glm_model.forward(
        input_ids=input_ids,
        position_ids=None,
        attention_mask=None
    )

    loss = None
    if labels != None:
        shift_logits = logits[:, :-1, :].contiguous()
        shift_labels = input_ids[:, 1:].contiguous()

        # Flatten the tokens
        logits_1 = shift_logits.view(-1, shift_logits.size(-1))
        logits_2 = shift_labels.view(-1)

        loss = self.loss_fct(logits_1, logits_2)

    return logits, hidden_states, loss

代码重新适配了新的forward函数,从而完成对PEFT库的加载,如下代码所示.

python 复制代码
def forward(
    self,
    input_ids,
    position_ids=None,
    attention_mask=None,
    inputs_embeds=None,
    labels=None,
    output_attentions=None,
    output_hidden_states=None,
    return_dict=None,
    **kwargs
):
    logits, hidden_states = self.glm_model.forward(
        input_ids=input_ids,
        **kwargs
    )

    shift_logits = logits[:, :-1, :].contiguous()
    shift_labels = input_ids[:, 1:].contiguous()

    # Flatten the tokens
    logits_1 = shift_logits.view(-1, shift_logits.size(-1))
    logits_2 = shift_labels.view(-1)

    loss = self.loss_fct(logits_1, logits_2)

    return logits, hidden_states, loss

大部分参数在模型的计算过程中并没有使用,这些只是为了适配PEFT模型而设置的虚参数.

完整地使用PEFT对自定义的模型进行微调的代码如下:

python 复制代码
import os

os.environ["CUDA_VISIBLE_DEVICES"] = "0"

import torch
from tqdm import tqdm
from torch.utils.data import RandomSampler, DataLoader
from peft import (
    get_peft_model,
    LoraConfig,
    TaskType,
    prepare_model_for_int8_training,
    get_peft_model_state_dict,
)
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, AutoConfig

device = "cuda"

from chatGLM_spo.fintunning_peft_xiaohua import modeling_chatglm

model = modeling_chatglm.XiaohuaModel(
    model_path="../huggingface_saver/chatglm6b.pth"
)

peft_config = LoraConfig.from_pretrained("./peft")
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
model = model.half().to(device)  # .to(device)

prompt_text = "按给定的格式抽取文本信息。\n文本:"

from chatGLM_spo import get_data

tokenizer = AutoTokenizer.from_pretrained(
    "THUDM/chatglm-6b",
    trust_remote_code=True,
)

all_train_data = get_data.get_train_data(
    "../data/spo_0.json",
    tokenizer,
    48,
    48,
    prompt_text,
)

train_dataset = get_data.Seq2SeqDataSet(all_train_data)
train_loader = DataLoader(
    train_dataset,
    batch_size=2,
    drop_last=True,
    collate_fn=get_data.coll_fn,
    num_workers=0,
)

from accelerate import Accelerator

accelerator = Accelerator()

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=2e-5,
    betas=(0.9, 0.999),
    eps=1e-5,
)

lr_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer,
    T_max=2400,
    eta_min=2e-6,
    last_epoch=-1,
)

model, optim, train_loader, lr_scheduler = accelerator.prepare(
    model,
    optimizer,
    train_loader,
    lr_scheduler,
)

for epoch in range(20):
    pbar = tqdm(train_loader, total=len(train_loader))

    for batch in pbar:
        input_ids = batch["input_ids"].cuda()
        labels = batch["labels"].cuda()

        _, _, loss = model.forward(input_ids, labels=labels)
        accelerator.backward(loss)

        # torch.nn.utils.clip_grad_norm_(model.parameters(), 1.)
        optimizer.step()
        lr_scheduler.step()  # 执行优化器
        optimizer.zero_grad()

        pbar.set_description(
            f"epoch:{epoch + 1}, train_loss:{loss.item():.5f}, "
            f"lr:{lr_scheduler.get_last_lr()[0] * 1000:.5f}"
        )

    if (epoch + 1) % 3 == 0:
        torch.save(model.state_dict(), "./glm6b_peft.pth")

训练过程请自行验证.

对于模型的推断,可以直接保存全部参数,在推断时重新加载PEFT包之后再载入全部参数.


5.本章小结

本文主要介绍对ChatGLM模型进行本地化的方法,举例说明了根据不同数据对ChatGLM进行微调的方法.对于具体的微调选择来说,不存在基于任务的最佳微调方法,但在一些特定场景下,会有一种较好的方法.例如,LoRA在低/中资源的场景下表现最好,而完全微调在我们增加数据量到更高的样本时,相对性能会增加.速度和性能之间存在着明显的区别,PEFT的速度更差,但在低资源下的性能更好,而随着数据量的增加,性能的提升也更为明显.具体测试还请自行根据需要落地的业务对微调进行处理.

大模型是深度学习自然语言处理皇冠上的一颗明珠,本文只是起到抛砖引玉的作用,大模型的更多技术内容还需要在实践中持续学习和研究.

把"能训练"升级为"可交付"

完整展示了本地加载、数据改造、全量训练、量化、LoRA 和 PEFT 的主要概念.若把它用于真实维修知识抽取,还需要补上工程闭环:

上线前检查清单

  • 数据:schema 有版本;实体、关系与方向定义无歧义;训练/验证/测试按业务实体分组去重。
  • 样本:包含无结果、冲突信息、长文本、罕见故障、否定表达和格式异常等困难样本.
  • 训练:记录随机种子、精确模型 revision、依赖锁文件、有效 batch、学习率、训练步数和adapter配置.
  • 数值:监控 loss、梯度范数、溢出/NaN、吞吐、峰值显存;低比特训练确认实际可训练参数只有预期模块.
  • 评测:不只看生成 loss;同时测parse rate、实体/关系/三元组 F1、幻觉率和业务分桶指标。
  • 安全:审计远程模型代码;固定依赖与模型版本;不在日志记录敏感维修数据.
  • 部署:在目标硬件上测 p50/p95 延迟、吞吐、显存和并发;验证adapter与基座版本匹配.
  • 监控:采集不可解析输出、低置信案例、数据漂移和用户纠错,经过审核后回流数据集.

真正专业的微调项目不是"某次训练跑通",而是能回答四个问题:为什么选择这种适配方法;数据和输出契约是什么;改进是否在独立测试集上成立;模型、adapter、tokenizer 和评测结果能否被另一台机器复现.

🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容


每日心灵鸡汤: 读书,是为了不弄丢自己!

为什么要经常看书:一个人如果不看书,那他的价值观就由他身边的人决定,因为他没有别的输入途径,只能模仿周边的人,或者慢慢去被环境所改变,周围流行什么就跟随什么,永远找不到自己,读书时富养自己的灵魂,读过的书哪怕内容不记得,但它依然存在谈吐中、在气质里、在胸襟的无涯与精神的宽广中.书是向上的阶梯,是治愈的良药,是启迪智慧的良方,是唤醒心智的道法.

相关推荐
盟接之桥1 小时前
盟接之桥谈线束数字化-按单入按单出:线束企业库存管理的破局之道
大数据·前端·网络·数据库·人工智能·制造
Summer-Bright1 小时前
浏览器成了AI应用的新入口:智能体接管标签页之后,三本账谁买单
人工智能·chatgpt·agent·浏览器·智能体
xiaohaiAIgeo1 小时前
【2026年】紧急医疗运送送返:保额怎么看、多少够用
人工智能·科普知识
阿基拉de_Akir2 小时前
设计系统负责人:编译工作流中的上下游要求,评审的原材料从哪来,评审的产物给谁用
人工智能
思茂信息2 小时前
电磁仿真学涉及哪些算法?
人工智能·算法·数学建模·matlab·emc·电磁仿真·仿真辐射
拉法特2 小时前
一条路径本身就是一句断言
人工智能
用户674874273342 小时前
我踩了4个坑才搞懂 AI 视频压力测试该测什么
人工智能
weiwin1232 小时前
MAF 入门(8):多模态实战——让 Agent 看懂图片、处理混合内容
人工智能·后端
code2cat2 小时前
【随笔】RAG重排怎样挑证据:先扩大候选,再把相关片段排到前面
人工智能·python·大语言模型·rag