干货预警:这可能是你能够找到的最容易懂的,最完整的,适用于各种NLP任务的Baichuan-13B-Chat的finetune教程~
Baichuan-13B是百川智能于2023年7月11日发布的开源中英双语LLM,各项指标经评测在开源LLM中同尺寸模型中位居前列。
Baichuan-13B包括Baichuan-13B-Base和Baichuan-13B-chat两个不同模型。前者仅仅是预训练模型,后者在前者基础上增加了SFT,RLHF等偏好对齐过程。
本范例微调的模型是Baichuan-13B-Chat,我们使用非常简单的,外卖评论数据集来实施微调,对一段外卖评论区分是好评还是差评。
可以发现,经过微调后的模型,相比直接 3-shot-prompt 可以取得明显更好的效果(0.89->0.90)。
虽然Baichuan-13B-Chat是一个百亿级的LLM,但由于我们使用非常节约显存的QLoRA微调算法,具备32G左右显存的GPU即可实施本过程。
值得注意的是,尽管我们以文本分类任务为例,实际上,任何NLP任务,例如,命名实体识别,翻译,聊天对话等等,都可以通过加上合适的上下文,转换成一个对话问题,并针对我们的使用场景,设计出合适的数据集来微调Baichuan-13B-Chat.
注,本教程是 ChatGLM2-6b保姆级微调范例 的兄弟版本~ 😋
waimai数据集简单评测对比:
我们需要从 https://huggingface.co/baichuan-inc/Baichuan-13B-Chat 下载baichuan-13b-chat的模型。
国内可能速度会比较慢,总共有25个G左右,网速不太好的话,大概可能需要两到三个小时。
如果网络不稳定,也可以手动从这个页面一个一个下载全部文件然后放置到 一个文件夹中例如 'baichuan-13b' 以便读取。
import warnings
warnings.filterwarnings('ignore')
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM,AutoConfig, AutoModel, BitsAndBytesConfig
from transformers.generation.utils import GenerationConfig
import torch.nn as nn
#使用QLoRA引入的 NF4量化数据类型以节约显存
model_name_or_path ='../baichuan-13b' #远程 'baichuan-inc/Baichuan-13B-Chat'
bnb_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
llm_int8_threshold=6.0,
llm_int8_has_fp16_weight=False,
)
tokenizer = AutoTokenizer.from_pretrained(
model_name_or_path, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(model_name_or_path,
quantization_config=bnb_config,
trust_remote_code=True)
model.generation_config = GenerationConfig.from_pretrained(model_name_or_path)
from IPython.display import clear_output
messages = []
messages.append({"role": "user",
"content": "世界上第二高的山峰是哪座?"})
response = model.chat(tokenizer,messages=messages,stream=True)
for res in response:
print(res)
clear_output(wait=True)
下面我们设计一个3-shot-prompt方法,使用外卖数据集测试一下BaiChuan13b的文本分类能力。
prefix = """外卖评论文本分类任务:
下面是一些范例:
味道真不错 -> 好评
太辣了,吃不下都 -> 差评
请对下述评论进行分类。返回'好评'或者'差评'。
"""
def get_prompt(text):
return prefix+text+' -> '
messages = [{"role": "user", "content": get_prompt('味道不错,下次再来')}]
response = model.chat(tokenizer, messages)
print(response)
好评
messages = messages+[{"role": "assistant", "content": response}]
print(messages)
def get_message(prompt,response):
return [{"role": "user", "content": f'{prompt} -> '},
{"role": "assistant", "content": response}]
messages.extend(get_message('太贵了','差评'))
messages.extend(get_message('非常快,味道好','好评'))
messages.extend(get_message('这么咸,真的是醉了','差评'))
messages
def predict(text,temperature=0.01):
model.generation_config.temperature=temperature
response = model.chat(tokenizer,
messages = messages+[{'role':'user','content':f'{text} -> '}])
return response
我们拿外卖数据集来测试一下未经微调,预训练模型的效果。
import pandas as pd
import numpy as np
import datasets
from tqdm import tqdm
#数据集加载
dftrain = pd.read_parquet('../data/dftrain.parquet')[['text','label','tag']]
dftest = pd.read_parquet('../data/dftest.parquet')[['text','label','tag']]
ds_train,ds_val = datasets.Dataset.from_pandas(dftrain).train_test_split(
test_size=1000,seed=42).values()\
dftrain,dfval = ds_train.to_pandas(), ds_val.to_pandas()
dftest['pred'] = [predict(text) for text in tqdm(dftest['text'])]
我们仿照百川模型的 model._build_chat_input
方法来进行token编码,同时把需要学习的内容添加label.
import torch
#将messages编码成 token, 同时返回labels, 该函数适用于多轮对话数据
#注意baichuan-13b通过插入tokenizer.user_token_id和tokenizer.assistant_token_id 来区分用户和机器人会话内容
# reference@ model._build_chat_input?
def build_chat_input(messages, model=model,
tokenizer=tokenizer,
max_new_tokens: int=0):
max_new_tokens = max_new_tokens or model.generation_config.max_new_tokens
max_input_tokens = model.config.model_max_length - max_new_tokens
max_input_tokens = max(model.config.model_max_length // 2, max_input_tokens)
total_input, round_input, total_label, round_label = [], [], [], []
for i, message in enumerate(messages[::-1]):
content_tokens = tokenizer.encode(message['content'])
if message['role'] == 'user':
round_input = [model.generation_config.user_token_id] + content_tokens + round_input
round_label = [-100]+[-100 for _ in content_tokens]+ round_label
if total_input and len(total_input) + len(round_input) > max_input_tokens:
break
else:
total_input = round_input + total_input
total_label = round_label + total_label
if len(total_input) >= max_input_tokens:
break
else:
round_input = []
round_label = []
elif message['role'] == 'assistant':
round_input = [
model.generation_config.assistant_token_id
] + content_tokens + [
model.generation_config.eos_token_id
] + round_input
round_label = [
-100
] + content_tokens + [
model.generation_config.eos_token_id
]+ round_label
else:
raise ValueError(f"message role not supported yet: {message['role']}")
total_input = total_input[-max_input_tokens:] # truncate left
total_label = total_label[-max_input_tokens:]
total_input.append(model.generation_config.assistant_token_id)
total_label.append(-100)
return total_input,total_label
from torch.utils.data import Dataset,DataLoader
class MyDataset(Dataset):
def __init__(self,df,
prefix=prefix
):
self.df = df
self.prefix=prefix
def __len__(self):
return len(self.df)
def get_samples(self,index):
samples = []
d = dict(self.df.iloc[index])
samples.append(d)
return samples
def get_messages(self,index):
samples = self.get_samples(index)
messages = []
for i,d in enumerate(samples):
if i==0:
messages.append({'role':'user','content':self.prefix+d['text']+' -> '})
else:
messages.append({'role':'user','content':d['text']+' -> '})
messages.append({'role':'assistant','content':d['tag']})
return messages
def __getitem__(self,index):
messages = self.get_messages(index)
input_ids, labels = build_chat_input(messages)
return {'input_ids':input_ids,'labels':labels}
def show_sample(self,index):
samples = self.get_samples(index)
print(samples)
ds_train = MyDataset(dftrain)
ds_val = MyDataset(dfval)
def data_collator(examples: list):
len_ids = [len(example["input_ids"]) for example in examples]
longest = max(len_ids) #之后按照batch中最长的input_ids进行padding
input_ids = []
labels_list = []
for length, example in sorted(zip(len_ids, examples), key=lambda x: -x[0]):
ids = example["input_ids"]
labs = example["labels"]
ids = ids + [tokenizer.pad_token_id] * (longest - length)
labs = labs + [-100] * (longest - length)
input_ids.append(torch.LongTensor(ids))
labels_list.append(torch.LongTensor(labs))
input_ids = torch.stack(input_ids)
labels = torch.stack(labels_list)
return {
"input_ids": input_ids,
"labels": labels,
}
import torch
dl_train = torch.utils.data.DataLoader(ds_train,num_workers=2,batch_size=4,
pin_memory=True,shuffle=True,
collate_fn = data_collator)
dl_val = torch.utils.data.DataLoader(ds_val,num_workers=2,batch_size=4,
pin_memory=True,shuffle=False,
collate_fn = data_collator)
for batch in dl_train:
break
下面我们将使用QLoRA(实际上用的是量化的AdaLoRA)算法来微调Baichuan-13b模型。
from peft import get_peft_config, get_peft_model, TaskType
model.supports_gradient_checkpointing = True #
model.gradient_checkpointing_enable()
model.enable_input_require_grads()
model.config.use_cache = False # silence the warnings. Please re-enable for inference!
import bitsandbytes as bnb
def find_all_linear_names(model):
"""
找出所有全连接层,为所有全连接添加adapter
"""
cls = bnb.nn.Linear4bit
lora_module_names = set()
for name, module in model.named_modules():
if isinstance(module, cls):
names = name.split('.')
lora_module_names.add(names[0] if len(names) == 1 else names[-1])
if 'lm_head' in lora_module_names: # needed for 16-bit
lora_module_names.remove('lm_head')
return list(lora_module_names)
from peft import prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model)
lora_modules = find_all_linear_names(model)
print(lora_modules)
['down_proj', 'o_proj', 'up_proj', 'W_pack', 'gate_proj']
from peft import AdaLoraConfig
peft_config = AdaLoraConfig(
task_type=TaskType.CAUSAL_LM, inference_mode=False,
r=64,
lora_alpha=16, lora_dropout=0.05,
target_modules= lora_modules
)
peft_model = get_peft_model(model, peft_config)
peft_model.is_parallelizable = True
peft_model.model_parallel = True
peft_model.print_trainable_parameters()
from torchkeras import KerasModel
from accelerate import Accelerator
class StepRunner:
def __init__(self, net, loss_fn, accelerator=None, stage = "train", metrics_dict = None,
optimizer = None, lr_scheduler = None
):
self.net,self.loss_fn,self.metrics_dict,self.stage = net,loss_fn,metrics_dict,stage
self.optimizer,self.lr_scheduler = optimizer,lr_scheduler
self.accelerator = accelerator if accelerator is not None else Accelerator()
if self.stage=='train':
self.net.train()
else:
self.net.eval()
def __call__(self, batch):
#loss
with self.accelerator.autocast():
loss = self.net.forward(**batch)[0]
#backward()
if self.optimizer is not None and self.stage=="train":
self.accelerator.backward(loss)
if self.accelerator.sync_gradients:
self.accelerator.clip_grad_norm_(self.net.parameters(), 1.0)
self.optimizer.step()
if self.lr_scheduler is not None:
self.lr_scheduler.step()
self.optimizer.zero_grad()
all_loss = self.accelerator.gather(loss).sum()
#losses (or plain metrics that can be averaged)
step_losses = {self.stage+"_loss":all_loss.item()}
#metrics (stateful metrics)
step_metrics = {}
if self.stage=="train":
if self.optimizer is not None:
step_metrics['lr'] = self.optimizer.state_dict()['param_groups'][0]['lr']
else:
step_metrics['lr'] = 0.0
return step_losses,step_metrics
KerasModel.StepRunner = StepRunner
#仅仅保存QLora可训练参数
def save_ckpt(self, ckpt_path='checkpoint', accelerator = None):
unwrap_net = accelerator.unwrap_model(self.net)
unwrap_net.save_pretrained(ckpt_path)
def load_ckpt(self, ckpt_path='checkpoint'):
import os
self.net.load_state_dict(
torch.load(os.path.join(ckpt_path,'adapter_model.bin')),strict =False)
self.from_scratch = False
KerasModel.save_ckpt = save_ckpt
KerasModel.load_ckpt = load_ckpt
optimizer = bnb.optim.adamw.AdamW(peft_model.parameters(),
lr=6e-05,is_paged=True) #'paged_adamw'
keras_model = KerasModel(peft_model,loss_fn =None,
optimizer=optimizer)
ckpt_path = 'baichuan13b_waimai'
# keras_model.load_ckpt(ckpt_path) #支持加载微调后的权重继续训练(断点续训)
keras_model.fit(train_data = dl_train,
val_data = dl_val,
epochs=100,patience=10,
monitor='val_loss',mode='min',
ckpt_path = ckpt_path
)
为减少GPU压力,此处可重启kernel释放显存
import warnings
warnings.filterwarnings('ignore')
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM,AutoConfig, AutoModel, BitsAndBytesConfig
from transformers.generation.utils import GenerationConfig
import torch.nn as nn
model_name_or_path ='../baichuan-13b'
ckpt_path = 'baichuan13b_waimai'
tokenizer = AutoTokenizer.from_pretrained(
model_name_or_path,
trust_remote_code=True
)
model_old = AutoModelForCausalLM.from_pretrained(
model_name_or_path,
trust_remote_code=True,
low_cpu_mem_usage=True,
torch_dtype=torch.float16,
device_map='auto'
)
from peft import PeftModel
#可能需要5分钟左右
peft_model = PeftModel.from_pretrained(model_old, ckpt_path)
model_new = peft_model.merge_and_unload()
from transformers.generation.utils import GenerationConfig
model_new.generation_config = GenerationConfig.from_pretrained(model_name_or_path)
from IPython.display import clear_output
messages = []
messages.append({"role": "user",
"content": "世界上第二高的山峰是什么?"})
response = model_new.chat(tokenizer,messages=messages,stream=True)
for res in response:
print(res)
clear_output(wait=True)
save_path = 'baichuan-13b-waimai'
tokenizer.save_pretrained(save_path)
model_new.save_pretrained(save_path)
!cp baichuan-13b/*.py baichuan-13b-waimai
为减少GPU压力,此处可再次重启kernel释放显存。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM,AutoConfig, BitsAndBytesConfig
from transformers.generation.utils import GenerationConfig
import torch.nn as nn
import warnings
warnings.filterwarnings('ignore')
model_name_or_path = 'baichuan-13b-waimai'
bnb_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
llm_int8_threshold=6.0,
llm_int8_has_fp16_weight=False,
)
tokenizer = AutoTokenizer.from_pretrained(
model_name_or_path, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(model_name_or_path,
quantization_config=bnb_config,
trust_remote_code=True)
model.generation_config = GenerationConfig.from_pretrained(model_name_or_path)
from IPython.display import clear_output
messages = []
messages.append({"role": "user",
"content": "世界上第二高的山峰是什么?"})
response = model.chat(tokenizer,messages=messages,stream=True)
for res in response:
print(res)
clear_output(wait=True)
乔戈里峰。世界第二高峰———乔戈里峰
海拔高度:8610米
坐标纬度:35°49′15′′n,76°21′24′′e
地理位置:喀喇昆仑山脉中巴边境上
我们测试一下微调后的效果。
import pandas as pd
import numpy as np
import datasets
from tqdm import tqdm
prefix = """外卖评论文本分类任务:
下面是一些范例:
味道真不错 -> 好评
太辣了,吃不下都 -> 差评
请对下述评论进行分类。返回'好评'或者'差评'。
"""
def get_prompt(text):
return prefix+text+' -> '
messages = [{"role": "user", "content": get_prompt('味道不错,下次再来')}]
response = model.chat(tokenizer, messages)
print(response)
好评
messages = messages+[{"role": "assistant", "content": response}]
print(messages)
def get_message(prompt,response):
return [{"role": "user", "content": f'{prompt} -> '},
{"role": "assistant", "content": response}]
messages.extend(get_message('太贵了','差评'))
messages.extend(get_message('非常快,味道好','好评'))
messages.extend(get_message('这么咸,真的是醉了','差评'))
def predict(text,temperature=0.01):
model.generation_config.temperature=temperature
response = model.chat(tokenizer,
messages = messages+[{'role':'user','content':f'{text} -> '}])
return response
微调后的acc为0.9015,相比微调前的0.8925,约提升1个百分点。
以上 。