每一次新发布的大型语言模型(LLM)都会将性能推向新的高度,往往会超越之前的基准(如大规模多任务语言理解或 MMLU)。这一进步引发了众多使用最大型 LLM 的应用。
然而,从 LLM 演示原型到功能性生产系统的过程并非没有挑战。用户隐私和信任非常重要,尤其是当我们在会计和金融领域开展业务时。确保 LLM 增强我们的应用并为客户创造价值仍然是我们的首要任务。
我们注意到,在使用专有模型(如 GPT-4 及其变体)时,模型响应会随着时间的推移而漂移。这是可以理解的,因为模型的权重会更新。但是,这会造成明显的推理差异,使下游应用程序的性能不稳定。此外,我们还观察到,即使尽一切可能播入随机种子并选择贪婪解码策略,也很难获得确定性结果。此外,我们还发现 GPT-4 无法很好地回答我们领域的某些特定问题,而且会产生幻觉结果。
模型并行和数据并行
在训练 LLM 时,模型并行化(MP)和数据并行化(DP)之间的选择取决于模型是否适合单个 GPU。当模型权重对单个 GPU 而言过大时,就会使用 MP。MP 在多个 GPU 上分散模型权重。另一方面,DP 将数据分散到多个 GPU 上,每个 GPU 都拥有模型权重的完整副本。
对于 DP,PyTorch 提供了分布式数据并行(DDP)功能,它封装了模型类对象,允许人们通过其 torchrun 启动器实用程序启动 DDP。
对于 MP,PyTorch 本机支持模型并行的完全分片数据并行(FSDP)。
你可能已经注意到,使用这些并行技术需要修改代码。为了简化这一过程,HuggingFace 提供了加速包,用于处理复杂的封装函数和 GPU 设备放置。这使得使用这些并行技术变得更加容易,而无需自己编写专门用于并行化的模板代码。
准备数据集以进行指令微调
首先,我们将数据集整理为问题和答案对。由于 LLM 是以标记入和标记出的方式运行的,因此要使用标记化对象将问题对中的文本转换为标记。对于指令调谐模型,要求问题对符合每个模型独有的指令格式。我们可以使用tokenizer.apply_chat_template()函数来实现这一点。
def apply_chat_template(
datapoint,
tokenizer,
prefix="",
):
messages = [
{"role": "user", "content": f"""{prefix}: [{datapoint["input"]}]"""},
{"role": "assistant", "content": datapoint["output"]},
]
datapoint["text"] = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False
)
return datapoint
def apply_chat_template_test(
datapoint,
tokenizer,
prefix="",
):
print("using chat template prompting.")
messages = [
{"role": "user", "content": f"""{prefix}: [{datapoint["input"]}]"""},
]
datapoint["text"] = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False
)
return datapoint
下面是已完成的代码片段。
from sklearn.model_selection import train_test_split
import pandas as pd
df = pd.read_csv(data_path)
# split the dataset into train and test
X_train, X_test = train_test_split(
df, test_size=0.00001, random_state=42
)
# reformat the input and output to conform with instructed format
X_train = pd.DataFrame(
X_train.apply(
lambda row: apply_chat_template(
row, tokenizer, prefix
),
axis=1,
),
columns=["text"],
)
X_test = pd.DataFrame(
X_test.apply(
lambda row: apply_chat_template_test(
row, tokenizer, prefix
),
axis=1,
),
columns=["text"],
)
# load the data into the Dataset object
from datasets import Dataset
train_data = Dataset.from_pandas(X_train)
test_data = Dataset.from_pandas(X_test)
模型训练
LLM 是在监督下进行训练的,因为我们提供了问题的标签答案。为了实现这一训练过程,HuggingFace 实现了 SFTTrainer() 对象。
加载预训练模型和标记符
首先,我们使用 HuggingFace 的 AutoModelForCausalLM 和 AutoTokenizer 对象加载预训练模型和标记符。某些模型(如 Phi-3 系列)要求 trust_remote_code=True ,因此我们默认将此参数设置为 true。
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
)
import torch
# Load the model
def load_model(
base_model,
):
return AutoModelForCausalLM.from_pretrained(
base_model,
trust_remote_code=True,
)
# Load tokenizer
def load_tokenizer(base_model,):
return AutoTokenizer.from_pretrained(
base_model, trust_remote_code=True,
)
在开始训练之前,我们为训练过程设置了一个种子,以确保训练的可重复性。
from transformers import set_seed
# Set seed for reproducibility
set_seed(123)
LoRA 微调
从头开始训练 LLM 需要计算资源和庞大的数据集(例如,Llama 3.1 405B 需要 15 万亿个代币,16,000 个 H100 GPU)。幸运的是,如果只对线性层进行微调,微调 LLM 所需的计算资源较少。
LoRA 利用了低秩矩阵近似的概念,即将一个高维矩阵近似为两个较小矩阵(如秩 1)的乘积,从而将矩阵更新维度从 25 降至 10。这种方法源于奇异值分解(SVD)数学技术。通过将其应用于变压器模型注意层中的权重矩阵,LoRA 可以有效减少需要训练的参数数量。
peft 软件包提供了 prepare_model_for_kbit_training 和 LoraConfig 对象,让你可以在冻结其他层权重的同时,为目标线性层添加可调整的权重。
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
def add_lora_layers(
model,
lora_alpha=<an interger>,
r=<an interger>,
target_modules="all-linear",
):
model = prepare_model_for_kbit_training(
model,
use_gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
)
peft_config = LoraConfig(
lora_alpha=lora_alpha,
lora_dropout=<a floating number>,
r=r,
bias="none",
task_type="CAUSAL_LM",
target_modules=target_modules,
)
return get_peft_model(model, peft_config), peft_config
定义模型超参数
我们需要定义用于训练模型的超参数。我们选择余弦学习率计划,它能提供平滑的衰减。你应该仔细选择每个设备的批次大小和梯度累积步骤,以避免耗尽 GPU 内存。有效批处理大小的计算方法是:每设备批处理大小乘以梯度累积步数和集群(节点)中 GPU 的数量。例如,Nvidia 为云服务提供商 (CSP) 在单个节点中安装并提供 8 个 A100 GPU。此外,我们还保存了交叉熵损失最小的前三个模型。
from transformers import EarlyStoppingCallback, TrainingArguments, trainer_utils
import torch
# If your gpu is above 8, you can accelerate training with bf16
major, _ = torch.cuda.get_device_capability()
# Set up training hyperparameters
training_arguments = TrainingArguments(
output_dir=output_dir,
num_train_epochs=<an interger>,
per_device_train_batch_size=<an interger>,
per_device_eval_batch_size=<an interger>,
gradient_accumulation_steps=<an interger>,
optim="paged_adamw_32bit",
save_steps=<an integer>,
logging_steps=<an integer>,
learning_rate=<a floating number>,
weight_decay=<a floating number>,
fp16=False if major >= 8 else True,
bf16=True if major >= 8 else False,
max_grad_norm=<a floating number>,
warmup_ratio=<a floating number>,
group_by_length=True,
lr_scheduler_type="cosine",
gradient_checkpointing_kwargs={"use_reentrant": False},
disable_tqdm=False,
resume_from_checkpoint=True,
seed=123,
log_level="info",
remove_unused_columns=True,
...
)
应用 SFTTrainer 对象
接下来,我们调用 SFTTrainer() 对象,将训练数据集和超参数传递给它,从而开始训练。我们还加入了回调,以便在损失下降超过我们定义的限制时提前结束训练。
from trl import SFTTrainer
# Set up the trainer
trainer = SFTTrainer(
model=model,
train_dataset=train_data,
eval_dataset=(
test_data if is_prefix_used else eval_data
), # currently, the prefix is mainly for sbca data
peft_config=peft_config,
max_seq_length=max_seq_length,
dataset_text_field="text",
tokenizer=tokenizer,
args=training_arguments,
packing=False,
callbacks=[
EarlyStoppingCallback(
early_stopping_patience=<an interger>,
early_stopping_threshold=<a floating number>,
),
],
)
记录模型指标
使用日志功能,我们可以方便地记录超参数和损失指标。
import datasets
import logging
import transformers
logger = logging.getLogger(__name__)
# setup logging
logging.basicConfig(
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)],
)
log_level = training_arguments.get_process_log_level()
logger.setLevel(log_level)
datasets.utils.logging.set_verbosity(log_level)
transformers.utils.logging.set_verbosity(log_level)
transformers.utils.logging.enable_default_handler()
transformers.utils.logging.enable_explicit_format()
最后,我们记录了训练和测试数据集的指标,包括训练过程中使用的损失和超参数。
resume_from_checkpoint = True
if resume_from_checkpoint:
print("Continue training from the last checkpoint.")
train_result = trainer.train(
resume_from_checkpoint=resume_from_checkpoint
)
else:
train_result = trainer.train()
metrics = train_result.metrics
trainer.log_metrics("train", metrics)
trainer.save_metrics("train", metrics)
trainer.save_state()
# Evaluation
tokenizer.padding_side = "left"
metrics = trainer.evaluate()
metrics["eval_samples"] = (
len(test_data)
)
trainer.log_metrics("eval", metrics)
trainer.save_metrics("eval", metrics)
使用 Accelerate 和 DeepSpeed 配置启动
我们可以使用命令行启动训练作业,并将到此为止的所有代码放入 python 脚本中。DeepSpeed 配置将在--config_file标志之后传递。请确保梯度累积步数(gradient_accumulation_steps)和梯度剪辑(gradient_clipping)与训练参数(TrainingArguments)中定义的梯度累积步数和梯度剪辑相匹配。num_machines定义了使用的节点数量。num_processes定义了节点中 GPU 的数量。
compute_environment: LOCAL_MACHINE
debug: false
deepspeed_config:
deepspeed_multinode_launcher: standard
gradient_accumulation_steps: <an interger>
gradient_clipping: <a floating number>
offload_optimizer_device: none
offload_param_device: none
zero3_init_flag: true
zero3_save_16bit_model: true
zero_stage: 3
distributed_type: DEEPSPEED
downcast_bf16: 'no'
machine_rank: 0
main_training_function: main
mixed_precision: bf16
num_machines: 1
num_processes: 8
rdzv_backend: static
same_network: true
tpu_env: []
tpu_use_cluster: false
tpu_use_sudo: false
use_cpu: false
CUDA_VISIBLE_DEVICE=0,1,2,3,4,5,6,7 accelerate launch --config_file "deepspeed_stage3.yaml" <put all of the code up to this point into a python script>
权衡: 精度与速度
最后,我们想强调一下需要考虑的其他重要权衡因素:精度与速度。上图显示了不同数据类型与计算速度(以 TFLOPS 为单位)之间的关系。
不同的数据类型提供了不同的指数和精度范围。此外,不同的 GPU(如 V100s 和 A100s)支持不同的数据类型。检查你的 GPU 支持哪些数据类型非常重要。例如,根据上述图 2,FP16 在运算/秒(计算速度)上的吞吐量是 FP32 的 8 倍,尽管指数范围和精度有所降低。我们建议仔细阅读PyTorch CUDA 设置和 GPU 白皮书,并对数据进行试验,以找到精度和速度之间的最佳平衡点。例如,下面的代码片段可以使用 TF32 进行矩阵乘法运算。
# https://pytorch.org/docs/stable/notes/cuda.html#tf32-on-ampere
# The flag below controls whether to allow TF32 on matmul. This flag defaults to False
# in PyTorch 1.12 and later.
torch.backends.cuda.matmul.allow_tf32 = True
# The flag below controls whether to allow TF32 on cuDNN. This flag defaults to True.
torch.backends.cudnn.allow_tf32 = True
结论
在这篇文章中,我们演示了如何利用 HuggingFace 提供的工具,使用 MP 技术对 LLM 进行微调。通过分享这些经验,我们希望你可以训练自己的 LLM 并拥有权重。请记住,即使是经过微调的 LLM 也可能产生幻觉反应。虽然微调可以降低幻觉出现的几率,但并不能消除幻觉。开发人员和科学家在设计人工智能产品时应认识到,LLM 仍会根据前面的语境标记生成最有可能的标记,并可能生成无根据或非事实的反应。尽管存在这些局限性,小型的特定领域语言模型仍是首选,因为它们可以进行优化,以理解某个领域的特定术语,而且与大型的近源语言模型相比,推理成本更低。