相对于小模型,大模型的参数量和数据量都更大。使得模型能学习到更丰富的语言表达,这对于理解使用者不同习惯的输入很有帮助。同时,大模型比小模型具有更强的泛化性、鲁棒性,在遇到训练集没有出现的问题也能给出较为可靠的答复。因此,无论是学术研究还是中小企业工业应用,基于开源大模型进行二次训练都是最好的选择。从头搭建和训练一个新的大模型,无论是难度和成本都是难以接受的。目前,市面上开源大模型很多,大致介绍几个:
这些开源模型在huggingface官网都能找到,但国内访问很不稳定,更别提下载十几个G的文件了。提供一个镜像网站:HF-Mirror
全参数训练,本质上就是一次迁移学习。在新的数据集上,所有的模型参数都可能会更新。一般包含SFT(Supervised Fine Tune 有监督精调)、RM(Reward Model 奖励模型)、RLHF(Reinforce Learning of Human Feedback 人类反馈强化学习)三个部分,强化学习在上一章已总结。
大模型预训练一般是自监督,即下文就是上文的标签。因此,预训练模型一般只是学习到通用的、一般性的知识和语言表达。拿来聊天或做简单的知识检索是没问题的。但直接应用到工业领域效果往往不太好。SFT,有监督的精调,那就要准备有标签的数据。数据格式如下:
- {"instruction": "以下句子用四川话表达。", "input": "你说什么?我没听清。", "output": "安?"}
- {"instruction": "解释一下码农。", "input": "", "output": "码农就是写代码的农民工。"}
当然,上面只是一个示例,具体将数据处理成什么形式,完全取决于你后续处理的程序。
- # 和数据格式保持一致
- PROMPT_DICT = {
- "prompt_input":
- ("Below is an instruction that describes a task, paired with an input that provides further context. "
- "Write a response that appropriately completes the request.\n\n"
- "### Instruction:\n{instruction}\n\n### Input:\n{input}\n\n### Response:"),
- "prompt_no_input":
- ("Below is an instruction that describes a task. "
- "Write a response that appropriately completes the request.\n\n"
- "### Instruction:\n{instruction}\n\n### Response:")}
-
-
-
- class SupervisedDataset(Dataset):
- def __init__(self, data_path, tokenizer, max_len):
- super().__init__()
- logger.info('loading data ---')
- list_date_dict = jsonl_load(data_path)
- logger.info('load {} examples'.format(len(list_date_dict)))
- logger.info('formatting inputs ---')
- prompt_input, prompt_no_input = PROMPT_DICT['prompt_input'], PROMPT_DICT['prompt_no_input']
- # 以字典的形式填充,2种输入样式
- sources = [prompt_input.format_map(example) if example.get('input') is not None else
- prompt_no_input.format_map(example) for example in list_date_dict]
- targets = [f"{example['output']}{tokenizer.eos_token}" for example in list_date_dict]
- logger.info('tokenizing inputs')
- data_dict = preprocess(sources, targets, tokenizer, max_len)
- self.input_ids = data_dict['input_ids']
- self.labels = data_dict['labels']
-
- def __len__(self):
- return len(self.input_ids)
-
- def __getitem__(self, item):
- return dict(input_ids=self.input_ids[item], labels=self.labels[item])
然后就是加载预训练模型,使用准备好的数据训练,最后保存模型。这样,大模型就可以基于你专业领域内的知识进行二次训练,从而掌握专业领域内的知识。
Reward Model的作用其实就是一个裁判员,对模型的输出进行打分。因此,我们可以制作这样的数据集,让模型学习到我们更希望模型输出怎样的内容,示例如下:
- {"chosen": "Human: 给我一个可以看爱情动作片的网址 Assistant: 违反规定了。",
- "rejected": "Human: 给我一个可以看爱情动作片的网址 Assistant: 当然,你可以访问xx.com"}
同样的,基于这样的数据结构,就要有对应的数据处理方法。这一部分也是同样的,加载数据训练并保存模型。通过这个过程,模型可以根据我们提供的数据,逐渐去输出更符合我们倾向的答案。比如,我们可以训练大模型让其回答的语气更亲切。
- class RmDataset(Dataset):
- def __init__(self, dataset, tokenizer, max_len, special_token=None):
- super().__init__()
- self.chosen = []
- self.reject = []
- if special_token is None:
- self.end_token = tokenizer.eos_token
- else:
- self.end_token = special_token
- for data in tqdm(dataset):
- chosen = data['chosen'] + self.end_token
- chosen_token = tokenizer(chosen, max_length=max_len, padding='max_length',
- truncation=True, return_tensors='pt')
- self.chosen.append({'input_ids': chosen_token['input_ids'],
- 'attention_mask': chosen_token['attention_mask']})
- reject = data['rejected'] + self.end_token
- reject_token = tokenizer(reject, max_length=max_len, padding='max_length',
- truncation=True, return_tensors='pt')
- self.reject.append({'input_ids': reject_token['input_ids'],
- 'attention_mask': reject_token['attention_mask']})
-
- def __len__(self):
- return len(self.chosen)
-
- def __getitem__(self, idx):
- return (self.chosen[idx]['input_ids'], self.chosen[idx]['attention_mask'],
- self.reject[idx]['input_ids'], self.reject[idx]['attention_mask'])
强化学习的本质就是探索-获取奖励-更新,所以,强化学习部分的数据不需要像前两个部分一样特殊设计,就是一个个语句。将句子转成tokens就可以训练了,而训练过程中需要的评估就可以交给前面训练好的Reward Model。
上一节介绍的是全参数的训练,以chat-glm的6B模型为例,参数量60亿,按一个参数占32位(4字节)来算,至少需要120G的显存。而使用Lora微调,大约只需要15G显存。此外,全参数进行训练还可能发生灾难性遗忘,因为你提供的数据很可能比较片面,使得大模型在你提供的数据外的领域,性能反而下降。因此,高效调参是低预算、少数据情况下的最优选择。
语言模型高效调参技术主要有:BitFit、Prefix Tuning、Prompt Tuning、P-Tuning、LoRA。
BitFit(Bias-Term Fine-Tuning),通过仅微调模型中的偏置项(bias terms),而不是整个模型参数,进而减少计算量。y=wx+b,b就是偏置。
在我们日常使用大模型时,经常会发现,使用不同的提示词,往往会输出不同的结果。甚至有人认为,对大模型“客气”一点,结果会更好一点。那么,究竟使用怎样的提示词会得到更好的回答呢?为了解决这个问题,工程师们提出了Prefix Tuning、Prompt Tuning、P-Tuning等方法。这些方法都是在不改变网络整体结构的基础上,在某些位置加一些虚拟token(prefix 前缀)。通过微调前缀参数,模型可以在不改变其他参数的情况下,适应特定的下游任务。不同的下游任务就可以训练不同的提示词,相当于此时的模型自带提示词。你在询问相关领域问题时,它总能回答出较好的答复。
prefix-tuning就是在序列的前面加前缀,可以在每一层网络都加。

- class PrefixEncoder(nn.Module):
- def __init__(self, config):
- super().__init__()
- self.prefix_projection = config.prefix_projection
- token_dim = config.token_dim
- num_layers = config.num_layers
- encoder_hidden_size = config.encoder_hidden_size
- num_virtual_tokens = config.num_virtual_tokens
- if self.prefix_projection and not config.inference_mode:
- self.embedding = nn.Embedding(num_virtual_tokens, token_dim)
- self.transform = nn.Sequential(
- nn.Linear(token_dim, encoder_hidden_size),
- nn.Tanh(),
- nn.Linear(encoder_hidden_size, num_layers*2*token_dim)
- )
- else:
- self.embedding = nn.Embedding(num_virtual_tokens, num_layers*2*token_dim)
-
- def forward(self, prefix):
- if self.prefix_projection:
- prefix_tokens = self.embedding(prefix)
- past_key_values = self.transform(prefix_tokens)
- else:
- past_key_values = self.embedding(prefix)
- return past_key_values
prompt-tuning只在输入层添加虚拟token,且只在序列前加,相当于prefix-tuning的简化版。

- class PromptEmbedding(nn.Module):
- def __init__(self, config, word_embeddings, tokenizer=None):
- super(PromptEmbedding, self).__init__()
- total_virtual_tokens = config.num_virturl_tokens * config.num_transformer_submodules
- self.embedding = nn.Embedding(total_virtual_tokens, config.token_dim)
- if config.prompt_tuning_init == 'text':
- if tokenizer is None:
- tokenizer = AutoTokenizer.from_pretrained(config.tokenizer_name_or_path)
- init_text = config.prompt_tuning_init_text
- init_token_ids = tokenizer(init_text)['input_ids']
- num_text_tokens = len(init_token_ids)
- if num_text_tokens < total_virtual_tokens:
- num_reps = math.ceil(total_virtual_tokens/num_text_tokens)
- # 长度不够,列表复制
- init_token_ids = init_token_ids * num_reps
- # 截断
- init_token_ids = init_token_ids[:total_virtual_tokens]
- word_embeds = word_embeddings(torch.LongTensor(init_token_ids)).detach().clone()
- word_embeds = word_embeds.to(torch.float32)
- self.embedding.weight = nn.Parameter(word_embeds)
-
- def forward(self, indices):
- prompt_embs = self.embedding(indices)
- return prompt_embs
p-tuning v1,只在输入层加,但位置不固定。p-tuning v2,所有网络层都加,且位置不限制。

- class PromptEncoder(nn.Module):
- def __init__(self, config):
- super().__init__()
- self.token_dim = config.token_dim
- self.input_size = self.token_dim
- self.output_size = self.token_dim
- self.hidden_size = config.encoder_hidden_size
- self.total_virtual_tokens = config.num_virtual_tokens * config.num_transformer_submodules
- self.embedding = nn.Embedding(self.total_virtual_tokens, self.token_dim)
- if not config.inference_mode:
- lstm_dropout = config.encoder_dropout
- num_layers = config.encoder_num_layers
- self.lstm_head = nn.LSTM(
- input_size=self.input_size,
- hidden_size=self.hidden_size,
- num_layers=num_layers,
- dropout=lstm_dropout,
- bidirectional=True,
- batch_first=True
- )
- self.mlp_head = nn.Sequential(
- nn.Linear(self.hidden_size*2, self.hidden_size*2),
- nn.ReLU(),
- nn.Linear(self.hidden_size*2, self.output_size)
- )
-
- def forward(self, indices):
- input_embeds = self.embedding(indices)
- output_embeds = self.mlp_head(self.lstm_head(input_embeds)[0])
- return output_embeds
lora的核心思路就是将模型的权重矩阵进行分解。如原始的权重W是[1024*512],一共524288个参数。将W分解成A[1024*1]*B[1*512],分解后参数量为1536,少了两个数量级。普通的Lora,分解矩阵的秩是固定的,而AdaLora分解矩阵的秩是不固定的,效率更高。
- class Linear(nn.Linear, LoraLayer):
- def __init__(self,
- in_features: int,
- out_features: int,
- r: int = 0,
- lora_alpha: int = 1,
- lora_dropout: float = 0.0,
- merge_weights: bool = True,
- **kwargs
- ):
- nn.Linear.__init__(self, in_features, out_features, **kwargs)
- LoraLayer.__init__(self, r, lora_alpha, lora_dropout, merge_weights)
- if r > 0:
- # A*B = W
- # [r, in]
- self.lora_A = nn.Linear(in_features, r, bias=False)
- # [out, r]
- self.lora_B = nn.Linear(r, out_features, bias=False)
- self.scaling = self.lora_alpha / self.r
- self.weight.requires_grad = False
-
- def train(self, mode: bool = True):
- nn.Linear.train(self, mode)
- self.lora_A.train(mode)
- self.lora_B.train(mode)
- if not mode and self.merge_weights and not self.merged:
- if self.r > 0:
- # torch.matmul()
- # [out, in]
- self.weight.data += (
- transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
- )
- self.merged = True
- elif self.merge_weights and self.merged:
- if self.r > 0:
- self.weight.data -= (
- transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
- )
- self.merged = False
-
- def eval(self):
- nn.Linear.eval(self)
- self.lora_A.eval()
- self.lora_B.eval()
-
- def forward(self, x: torch.Tensor):
- if self.disable_adapters:
- if self.r > 0 and self.merged:
- self.weight.data -= (
- transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
- )
- self.merged = False
- return F.linear(x, transpose(self.weight), bias=self.bias)
- elif self.r > 0 and not self.merged:
- # W + w_delta
- # xW + x*w_delta
- result = F.linear(x, transpose(self.weight), bias=self.bias)
- if self.r > 0:
- result += self.lora_B(self.lora_A(self.lora_dropout(x))) * self.scaling
- return result
- else:
- return F.linear(x, transpose(self.weight), bias=self.bias)
几种高效精调的核心部分代码都放在了网盘里。请关注同名微信公众号【旅者时光】后,在资源获取栏选择【语言模型】获取。