#
微调大型语言模型以生成波斯语产品目录的 JSON 格式

作者：Mohammadreza Esmaeiliyan

在这个 Notebook 中，我们尝试对大型语言模型进行微调，且没有添加额外复杂性。该模型已针对客户级别的 GPU 进行优化，用于生成波斯语产品目录并以 JSON 格式生成结构化输出。它特别适用于从伊朗平台（如 Basalam、Divar、Digikala 等）上的用户生成内容的非结构化标题和描述中创建结构化输出。

你可以在 我们的 Hugging Face 账户 查看微调后的 LLM。此外，我们还使用了最快的开源推理引擎之一 —— Vllm 来进行推理。

让我们开始吧！

    import torch
    from datasets import load_dataset
    from transformers import (
        AutoModelForCausalLM,
        AutoTokenizer,
        BitsAndBytesConfig,
        TrainingArguments,
    )
    from peft import LoraConfig, PeftModel
    from trl import SFTTrainer, DataCollatorForCompletionOnlyLM

`peft` 库，或称为参数高效微调（Parameter Efficient Fine-Tuning），旨在更高效地对大型语言模型（LLMs）进行微调。如果像传统神经网络那样打开并微调网络的上层，它将需要大量的计算和显著的 VRAM（显存）。随着近期论文中提出的方法，`peft` 库已被实现，用于高效地对 LLM 进行微调。你可以在这里了解更多关于 `peft` 的信息：Hugging Face PEFT。

##
设置超参数

    # General parameters
    model_name = "NousResearch/Llama-2-7b-chat-hf"  # The model that you want to train from the Hugging Face hub
    dataset_name = "BaSalam/entity-attribute-dataset-GPT-3.5-generated-v1"  # The instruction dataset to use
    new_model = "llama-persian-catalog-generator"  # The name for fine-tuned LoRA Adaptor

    # LoRA parameters
    lora_r = 64
    lora_alpha = lora_r * 2
    lora_dropout = 0.1
    target_modules = ["q_proj", "v_proj", "k_proj"]

LoRA（低秩适配，Low-Rank Adaptation）通过构建并将低秩矩阵添加到每个模型层中来存储权重变化。这种方法仅打开这些层进行微调，而不改变原始模型权重，也不需要长时间训练。生成的权重非常轻量，可以多次产生，从而允许在将 LLM 加载到 RAM 中后微调多个任务。

你可以在 Lightning AI 了解更多关于 LoRA 的信息。对于其他高效的训练方法，请参阅 Hugging Face 性能训练文档 和 SFT Trainer 增强。

    # QLoRA parameters
    load_in_4bit = True
    bnb_4bit_compute_dtype = "float16"
    bnb_4bit_quant_type = "nf4"
    bnb_4bit_use_double_quant = False

QLoRA（量化低秩适配，Quantized Low-Rank Adaptation）是一种高效的微调方法，通过使用 4 位量化，使大型语言模型能够在更小的 GPU 上运行。该方法在减少内存使用的同时，保持了 16 位微调的全部性能，使得在单个 48GB GPU 上也可以微调最多 650 亿参数的模型。QLoRA 结合了 4 位 NormalFloat 数据类型、双重量化和分页优化器，有效管理内存。它允许对具有低秩适配器的模型进行微调，显著提高了 AI 模型开发的可访问性。

你可以在 Hugging Face 上了解更多关于 QLoRA 的信息。

    # TrainingArguments parameters
    num_train_epochs = 1
    fp16 = False
    bf16 = False
    per_device_train_batch_size = 4
    gradient_accumulation_steps = 1
    gradient_checkpointing = True
    learning_rate = 0.00015
    weight_decay = 0.01
    optim = "paged_adamw_32bit"
    lr_scheduler_type = "cosine"
    max_steps = -1
    warmup_ratio = 0.03
    group_by_length = True
    save_steps = 0
    logging_steps = 25

    # SFT parameters
    max_seq_length = None
    packing = False
    device_map = {"": 0}

    # Dataset parameters
    use_special_template = True
    response_template = " ### Answer:"
    instruction_prompt_template = '"### Human:"'
    use_llama_like_model = True

##
模型训练

    # Load dataset (you can process it here)
    dataset = load_dataset(dataset_name, split="train")
    percent_of_train_dataset = 0.95
    other_columns = [i for i in dataset.column_names if i not in ["instruction", "output"]]
    dataset = dataset.remove_columns(other_columns)
    split_dataset = dataset.train_test_split(
        train_size=int(dataset.num_rows * percent_of_train_dataset), seed=19, shuffle=False
    )
    train_dataset = split_dataset["train"]
    eval_dataset = split_dataset["test"]
    print(f"Size of the train set: {len(train_dataset)}. Size of the validation set: {len(eval_dataset)}")

    # Load LoRA configuration
    peft_config = LoraConfig(
        r=lora_r,
        lora_alpha=lora_alpha,
        lora_dropout=lora_dropout,
        bias="none",
        task_type="CAUSAL_LM",
        target_modules=target_modules,
    )

`LoraConfig` 对象用于在使用 Peft 库时配置 LoRA（低秩适配）设置。它可以帮助减少需要微调的参数数量，从而加速训练并减少内存使用。以下是各个参数的详细说明：

- `r`：LoRA 中低秩矩阵的秩。该参数控制低秩适配的维度，直接影响模型适应能力和计算成本。
- `lora_alpha`：该参数控制低秩适配矩阵的缩放因子。较高的 alpha 值可以提高模型学习新任务的能力。
- `lora_dropout`：LoRA 的 dropout 比率。该参数有助于在微调过程中防止过拟合。在此案例中，设为 0.1。
- `bias`：指定是否向低秩矩阵添加偏置项。在此案例中，设为 `"none"`，表示不添加偏置项。
- `task_type`：定义模型微调的任务类型。在这里，`"CAUSAL_LM"` 表示任务是因果语言建模任务，即预测序列中的下一个单词。
- `target_modules`：指定模型中将应用 LoRA 的模块。在此案例中，设为 `["q_proj", "v_proj", 'k_proj']`，即模型注意力机制中的查询（query）、值（value）和键（key）投影层。

通过调整这些参数，`LoraConfig` 有助于在微调过程中高效地应用 LoRA 方法，从而优化计算资源和训练效率。

    # Load QLoRA configuration
    compute_dtype = getattr(torch, bnb_4bit_compute_dtype)

    bnb_config = BitsAndBytesConfig(
        load_in_4bit=load_in_4bit,
        bnb_4bit_quant_type=bnb_4bit_quant_type,
        bnb_4bit_compute_dtype=compute_dtype,
        bnb_4bit_use_double_quant=bnb_4bit_use_double_quant,
    )

这个代码块配置了使用 BitsAndBytes (bnb) 库的设置，该库提供了高效的内存管理和压缩技术，专为 PyTorch 模型设计。具体来说，它定义了如何加载和量化模型权重为 4 位精度，这对于减少内存使用并可能加速推理非常有用。

- `load_in_4bit`：一个布尔值，决定是否以 4 位精度加载模型。
- `bnb_4bit_quant_type`：指定使用哪种类型的 4 位量化。在这里，设置为 4 位 NormalFloat (NF4) 量化类型，这是 QLoRA 中引入的一种新数据类型。该类型对于正态分布权重在信息理论上最优，提供了一种高效的方式来对模型进行量化以进行微调。
- `bnb_4bit_compute_dtype`：设置用于涉及量化模型计算的数据类型。在 QLoRA 中，设置为 `"float16"`，这是混合精度训练中常用的类型，旨在平衡性能和精度。
- `bnb_4bit_use_double_quant`：一个布尔值，指示是否使用双重量化。设置为 `False` 表示只使用单次量化，通常速度更快，但可能精度稍低。

###
为什么我们有两种数据类型（quant_type 和 compute_type）？

QLoRA 使用了两种不同的数据类型：一种用于存储基础模型权重（在这里是 4 位 NormalFloat），另一种用于计算操作（16 位）。在前向和反向传播过程中，QLoRA 会将权重从存储格式解量化为计算格式。然而，它仅为 LoRA 参数计算梯度，这些参数使用的是 16 位 bfloat 数据类型。这样的做法确保了权重只有在必要时才会解压，从而在训练和推理过程中保持较低的内存使用量。

    # Load base model
    model = AutoModelForCausalLM.from_pretrained(model_name, quantization_config=bnb_config, device_map=device_map)
    model.config.use_cache = False

    # Set training parameters
    training_arguments = TrainingArguments(
        output_dir=new_model,
        num_train_epochs=num_train_epochs,
        per_device_train_batch_size=per_device_train_batch_size,
        gradient_accumulation_steps=gradient_accumulation_steps,
        optim=optim,
        save_steps=save_steps,
        logging_steps=logging_steps,
        learning_rate=learning_rate,
        weight_decay=weight_decay,
        fp16=fp16,
        bf16=bf16,
        max_steps=max_steps,
        warmup_ratio=warmup_ratio,
        gradient_checkpointing=gradient_checkpointing,
        group_by_length=group_by_length,
        lr_scheduler_type=lr_scheduler_type,
    )

    # Load tokenizer
    tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
    tokenizer.pad_token = tokenizer.eos_token
    tokenizer.padding_side = "right"  # Fix weird overflow issue with fp16 training
    if not tokenizer.chat_template:
        tokenizer.chat_template = "{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}"

关于聊天模板，我们简要说明一下，为了理解用户和模型在模型训练过程中对话的结构，创建了一系列预留短语来区分用户的消息和模型的回复。这确保了模型能够准确理解每条消息的来源，并保持对话结构的连贯性。通常，遵循聊天模板有助于提高任务的准确性。然而，当微调数据集与模型之间存在分布偏移时，使用特定的聊天模板可能更加有助于提升效果。

欲了解更多信息，请访问 Hugging Face 博客关于聊天模板。

    def special_formatting_prompts(example):
        output_texts = []
        for i in range(len(example["instruction"])):
            text = f"{instruction_prompt_template}{example['instruction'][i]}\n{response_template} {example['output'][i]}"
            output_texts.append(text)
        return output_texts

    def normal_formatting_prompts(example):
        output_texts = []
        for i in range(len(example["instruction"])):
            chat_temp = [
                {"role": "system", "content": example["instruction"][i]},
                {"role": "assistant", "content": example["output"][i]},
            ]
            text = tokenizer.apply_chat_template(chat_temp, tokenize=False)
            output_texts.append(text)
        return output_texts

    if use_special_template:
        formatting_func = special_formatting_prompts
        if use_llama_like_model:
            response_template_ids = tokenizer.encode(response_template, add_special_tokens=False)[2:]
            collator = DataCollatorForCompletionOnlyLM(response_template=response_template_ids, tokenizer=tokenizer)
        else:
            collator = DataCollatorForCompletionOnlyLM(response_template=response_template, tokenizer=tokenizer)
    else:
        formatting_func = normal_formatting_prompts

    trainer = SFTTrainer(
        model=model,
        train_dataset=train_dataset,
        eval_dataset=eval_dataset,
        peft_config=peft_config,
        formatting_func=formatting_func,
        data_collator=collator,
        max_seq_length=max_seq_length,
        tokenizer=tokenizer,
        args=training_arguments,
        packing=packing,
    )

`SFTTrainer` 随后被实例化，用于处理模型的监督微调（SFT）。这个训练器专门针对 SFT 进行设计，并包括了一些额外的参数，例如 `formatting_func` 和 `packing`，这些参数通常在标准的训练器类中是找不到的。

`formatting_func`：一个自定义函数，用于格式化训练样本，将指令和回复模板组合在一起。 `packing`：禁用将多个样本打包成一个序列，这是标准 `Trainer` 类中没有的参数。

    # Train model
    trainer.train()

    # Save fine tuned Lora Adaptor
    trainer.model.save_pretrained(new_model)

##
推理

    import torch
    import gc

    def clear_hardwares():
        torch.clear_autocast_cache()
        torch.cuda.ipc_collect()
        torch.cuda.empty_cache()
        gc.collect()

    clear_hardwares()
    clear_hardwares()

    def generate(model, prompt: str, kwargs):
        tokenized_prompt = tokenizer(prompt, return_tensors="pt").to(model.device)

        prompt_length = len(tokenized_prompt.get("input_ids")[0])

        with torch.cuda.amp.autocast():
            output_tokens = model.generate(**tokenized_prompt, **kwargs) if kwargs else model.generate(**tokenized_prompt)
            output = tokenizer.decode(output_tokens[0][prompt_length:], skip_special_tokens=True)

        return output

    base_model = AutoModelForCausalLM.from_pretrained(new_model, return_dict=True, device_map="auto", token="")
    tokenizer = AutoTokenizer.from_pretrained(new_model, max_length=max_seq_length)
    model = PeftModel.from_pretrained(base_model, new_model)
    del base_model

    sample = eval_dataset[0]
    if use_special_template:
        prompt = f"{instruction_prompt_template}{sample['instruction']}\n{response_template}"
    else:
        chat_temp = [{"role": "system", "content": sample["instruction"]}]
        prompt = tokenizer.apply_chat_template(chat_temp, tokenize=False, add_generation_prompt=True)

    gen_kwargs = {"max_new_tokens": 1024}
    generated_texts = generate(model=model, prompt=prompt, kwargs=gen_kwargs)
    print(generated_texts)

##
合并基础模型

    clear_hardwares()
    merged_model = model.merge_and_unload()
    clear_hardwares()
    del model
    adapter_model_name = "your_hf_account/your_desired_name"
    merged_model.push_to_hub(adapter_model_name)

在这里，我们将适配器与基础模型合并，并将合并后的模型推送到模型库中。你也可以只推送适配器到模型库，而避免推送沉重的基础模型文件，方法如下：

    model.push_to_hub(adapter_model_name)

然后，你可以按照以下方式加载模型：

    config = PeftConfig.from_pretrained(adapter_model_name)
    model = AutoModelForCausalLM.from_pretrained(config.base_model_name_or_path, return_dict=True, load_in_8bit=True, device_map='auto')
    tokenizer = AutoTokenizer.from_pretrained(config.base_model_name_or_path)

    # 加载 Lora 模型
    model = PeftModel.from_pretrained(model, adapter_model_name)

通过这种方法，你能够高效地加载适配器模型，而不需要加载完整的基础模型，从而减少内存消耗并提高推理速度。

##
用 Vllm 快速推理

`vllm` 库是目前用于大型语言模型（LLM）推理的最快引擎之一。有关可用选项的比较概览，你可以参考这篇博客：7 Frameworks for Serving LLMs。

在这个示例中，我们对该任务使用了我们微调模型的第一个版本进行推理。

    from vllm import LLM, SamplingParams

    prompt = """### Question: here is a product title from a Iranian marketplace.  \n         give me the Product Entity and Attributes of this product in Persian language.\n         give the output in this json format: {'attributes': {'attribute_name' : <attribute value>, ...}, 'product_entity': '<product entity>'}.\n         Don't make assumptions about what values to plug into json. Just give Json not a single word more.\n         \nproduct title:"""
    user_prompt_template = "### Question: "
    response_template = " ### Answer:"

    llm = LLM(model="BaSalam/Llama2-7b-entity-attr-v1", gpu_memory_utilization=0.9, trust_remote_code=True)

    product = "مانتو اسپرت پانیذ قد جلوی کار حدودا 85 سانتی متر قد پشت کار حدودا 88 سانتی متر"
    sampling_params = SamplingParams(temperature=0.0, max_tokens=75)
    prompt = f"{user_prompt_template} {prompt}{product}\n {response_template}"
    outputs = llm.generate(prompt, sampling_params)

    print(outputs[0].outputs[0].text)

###
样例输出

    {
        "attributes": {
            "قد جلوی کار": "85 سانتی متر",
            "قد پشت کار": "88 سانتی متر"
        },
        "product_entity": "مانتو اسپرت"
    }

在这篇博客中，你可以阅读关于微调大型语言模型（LLMs）的最佳实践：Sebastian Raschka 的杂志。

< > Update on GitHub