翻译完了UDL这本书之后放松了一个多礼拜没有更新文章了,主要最近也在学习一些微调上面的知识,平时晚上还需要跑跑代码看看视频啥的,因此也一直没太有空写文章,UDL的翻译整理成PDF的工作都没空整。(虽然实际最近也花了很长时间在打游戏(。・_・。))。又到周末了,再拖着不干点正事我也过意不去了,今天就写点关于最近学习的一些关于微调方面的东西好了,因为我也是初学者,可能会有些错误,希望有大佬可以批评指正。
现在无论国内外,开源大模型已经百花齐放了,虽然有榜单来给他们做排名,但对于参数比较小的模型,比如6B,7B及以下的那些,个人使用的感受上,很难有特别明显的差异,都是无法作为个人平时的生产力工具的,这些模型更多的是作为研究者们或者没有足够硬件支撑但又需要私有化部署的企业来使用。这些模型我们要想真正的产出业务的解决方案,一般来说都是需要进行微调的。
关于微调的方式有很多种,不差钱不差硬件的走全量微调之类的,资源不足的可以走LoRA生态的,这些微调手段我们后面再谈,今天要谈的是微调大模型的第一步,数据预处理。不过今天讲的预处理不是前置的数据清洗或者数据增强等操作,而是拿到一份高质量数据集之后怎么转成大模型认识的数据。本文将以斯坦福大学问答数据集SQuAD_v2为数据集,distilbert-base-uncased为目标模型来讲解。目标是微调一个问答模型。
数据集下载
首先每个学习大模型的同学应该都不会对HuggingFace这家公司陌生,我们平时使用的微调相关的库很多都是出自于他们家,而且他们的库名还都是跟需要用的技术直接挂钩上了,模型有Transformer架构,他有Transformers库,训练手段有peft(参数高效微调),他有peft库。那么对于数据集,很多公司或者研究机构以及个人都会上传一些公开数据集上去以供大家学习研究,HuggingFace也提供了一个库 datasets
来方便我们对于这些数据集进行下载以及管理操作。只需要一行代码就可以完成下载动作,比如下面下载的就是斯坦福大学提供的问答数据集SQuAD.
from datasets import load_dataset
datasets = load_dataset('squad_v2')
下载下来的数据集可能已经拆分了训练集,测试集等,我们可以根据实际情况选择需要用哪一部分,此外不同的数据集也会有不同的features,比如对话的数据集可能有question和answer,有的可能有instruction,分类的可能有label和text等等,那么对于这个数据集,包括了['id', 'title', 'context', 'question', 'answers']
这几个feature。
DatasetDict({
train: Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 130319
})
validation: Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 11873
})
})
从数据集的仓库上也能看到这些字段的说明。
id
: astring
feature.title
: astring
feature.context
: astring
feature.question
: astring
feature.answers
: a dictionary feature containing:text
: astring
feature.answer_start
: aint32
feature.
Tokenizer
下载数据集只是第一步,接下来,它还提供了一些数据预处理的方法。我们下下来的数据集绝大部分都是人类可以直接识别的内容,比如文字、图像、语音等。但对于模型而言,可不认识这些是什么,需要讲这些内容转换成模型认识的数据,这一步可以称作tokenize,你想微调哪一个模型,一般就要使用这个模型对应的Tokenizer,加载也是直接用HuggingFace的transformers库即可,这里我们使用精简化后的BERT模型: distilbert-base-uncased
.
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained('distilbert-base-uncased')
对于文本进行tokenize后,我们可以得到一连串的input_ids:
这里面的input_id可以理解成这个分词在模型词汇表里面的id,一般来说,每个模型也会保留一些特殊token,我们可以通过tokenizer的special_tokens_map的属性得到特殊token,并通过convert_tokens_to_ids()方法获取对应的id,下面是BERT的几个特殊token及对应的id。
{'[UNK]': 100, '[SEP]': 102, '[PAD]': 0, '[CLS]': 101, '[MASK]': 103}
每个特殊token都有其独立的作用:
[PAD]
: (ID 0) – 这是填充(Padding)token,用于将所有文本序列填充到相同的长度以便批处理。在处理长度不一的输入时,较短的输入会在末尾添加[PAD]
token,以确保所有输入达到模型要求的固定长度。[UNK]
: (ID 100) – 这是未知(Unknown)token,用于代替那些在训练期间未出现过或不在词汇表中的词汇。当输入文本中包含tokenizer词汇表里没有的词时,这些词会被替换为[UNK]
。[CLS]
: (ID 101) – 这是分类(Classification)token,用于序列分类任务中的“分类标记”。在处理如情感分析等任务时,BERT模型会将[CLS]
token的最终隐藏状态作为整个输入序列的表示,用于分类任务。[SEP]
: (ID 102) – 这是分隔(Separator)token,用于分隔不同的句子或文本片段。在处理多句输入,如问答系统或句子对任务时,[SEP]
被用来明确地分隔两个句子。[MASK]
: (ID 103) – 这是掩码(Mask)token,主要用于模型训练的掩码语言模型(Masked Language Model, MLM)任务。在这种任务中,输入文本的一部分词汇被随机替换为[MASK]
,模型需要预测出原始的词汇。这是BERT等模型进行自监督学习的一种方式。
了解了tokenize的含义,那么现在需要将数据集转换成这种ids的形式,这里就需要思考两个问题:
- 哪些字段需要tokenize?
- 直接把整个字符串扔到tokenizer里面就可以了吗?
预处理流程
选择feature
处理之前先仔细看一下这份数据集,一共有6个feature,显然不是每个feature都是我们需要的,随意挑选一份数据看看:
{'id': '56be85543aeaaa14008c9063',
'title': 'Beyoncé',
'context': 'Beyoncé Giselle Knowles-Carter (/biːˈjɒnseɪ/ bee-YON-say) (born September 4, 1981) is an American singer, songwriter, record producer and actress. Born and raised in Houston, Texas, she performed in various singing and dancing competitions as a child, and rose to fame in the late 1990s as lead singer of R&B girl-group Destiny\'s Child. Managed by her father, Mathew Knowles, the group became one of the world\'s best-selling girl groups of all time. Their hiatus saw the release of Beyoncé\'s debut album, Dangerously in Love (2003), which established her as a solo artist worldwide, earned five Grammy Awards and featured the Billboard Hot 100 number-one singles "Crazy in Love" and "Baby Boy".',
'question': 'When did Beyonce start becoming popular?',
'answers': {'text': ['in the late 1990s'], 'answer_start': [269]}}
对于问答模型,最重要的自然是问题和答案,在这份数据里面,问题是碧昂丝什么时候出名的,答案是1990年代末。但这份数据是额外给了背景知识,也就是context字段。模型通过背景知识来定位问题的答案,仔细看answers这个字段,会发现除了答案的文本,也提供了答案在context里面的定位。对于这份数据集,context、question以及answers字段自然是我们最需要的字段了,其中context和question都是输入,answers是输出。
截断
接下来是第二个问题,我能把输入整个tokenize然后扔到模型里吗?
tokenizer 有一个属性,叫做 model_max_length
,表示模型能处理的最大长度,这意味着如果超出这个长度,模型将无法处理,不同模型的最大处理长度是不一样的,对于目前我们使用的模型,输出的结果是512,这意味着输入的token大小不能超过512,那么对于超长的输入,要进行截断处理。
不知道看这篇文章的同学是否使用过 LangChain
这个框架,一般开发大模型应用的时候会用到,它有一个文本分割器的工具,在做RAG的时候需要对文本进行切割,跟我们这里的截断有点相似,需要设置每一个chunk的最大长度以及重叠部分的大小。如果不设置重叠部分,一段文本按照固定长度进行拆分时,可能会导致一句话被割裂,通过重叠部分,可以减少这种情况的发生。我们这里做数据处理的时候,也同样可以设置最长长度以及重叠长度。这里定义重叠部分长度为128,那么分块最大的长度就是512-128=384。
model_max_length = tokenizer.model_max_length
doc_stride = 128
# 384
max_length = model_max_length - doc_stride
从训练集里面挑一个超长的看看:
既然找到超长的,接下来就是截断,在HuggingFace的tokenizer支持多种截断方式:
- 长输入截断(longest_first)
- 默认的截断策略。
- 在多个文本序列的情况下(如处理一对文本),它会从最长的序列开始截断,直到整个输入序列的长度满足模型的最大长度要求。
- 只截断第一个序列(only_first)
- 当你有一对序列(如问题和上下文)时,这个策略只会截断第一个序列。
- 这对于一些特定的任务(如单句任务或者任务中第一个序列更重要的场景)可能是有用的。
- 只截断第二个序列(only_second)
- 这种策略只会截断第二个序列。
- 当第二个序列对于完成任务来说不那么重要时,这种策略尤其有用。
- 截断至特定长度的组合(Custom Lengths)
- 这允许用户指定两个序列应该截断到的具体长度。
- 用户可以指定第一个和第二个序列分别截断到的字符数或token数,以确保总长度不超过模型的最大长度限制。
但我们使用tokenizer的时候,传入 truncation
字段指定策略即可。对于我们现在这个场景,输入的内容是question和context,显然截断context是更好的选择,即策略选择:only_second
。此外,为了分析哪些数据被截断了,我们可以设置另一个属性 return_overflowing_tokens=True
让 tokenizer 返回那些无法在主输出中容纳的tokens。
tokenized_example = tokenizer(
example["question"],
example["context"],
max_length=max_length,
truncation="only_second",
return_overflowing_tokens=True,
stride=doc_stride
)
这里返回的 tokenized_example
是包含了两个 input_id
的列表,一个长度是384,另一个是192。
第二条数据的开始可以在第一条的尾巴找到,说明的确是重叠了一部分的,而且截断重叠的只有context,question是没有变化的。
定位答案
处理了context过长后,接下来别忘了还要看一下答案,answers这个字段除了给了答案的text,还给了答案的start位置。那么如何利用它呢?
首先,在tokenizer方法中,还有一个参数,叫做 return_offsets_mapping
,这个参数的功能是返回每个token的字符级偏移量。这些偏移量指示每个token在原始文本中的起始位置和结束位置,对于执行某些特定的文本处理任务非常有用,比如QA问题、命名实体识别等任务。这个字段设置为True后,返回的 tokenized_example 会返回 offset_mapping
这个字段,它是一个包含元组的列表,每个元组对应输入序列中的一个token。每个元组的第一个元素表示该token在原始文本中的起始字符索引,第二个元素表示结束字符索引。
如上图所示,因为第一个元素是特殊token, [CLS] ,因此输出的第一个元组是(0, 0)。
目前 tokenized_example 这个对象我们介绍了两个属性,input_ids
和 offset_mapping
,分别代表了我们处理过的输入(question+context)和token位置的映射。比如第一个token的id和offset就是:tokenized_example["input_ids"][0][1]
和 tokenized_example["offset_mapping"][0][1]
。打印出来是beyonce 这个单词。
接下来,使用 tokenized_example.sequence_ids()
方法可以得到一个每个元素对应于 input_ids
中每个 token 的来源的列表,其中对于特殊标记返回None,0表示该 token 来自于第一个输入序列,这里是question,1表示该 token 来自于第二个输入序列,这里是context。
准备好上面这些数据,可以定位一个给定答案在tokenized序列中的位置了。首先,从example拿到答案answers,包括了 text
和 answer_start
.
answers = example["answers"]
# 答案的开始位置(字符级)
start_char = answers["answer_start"][0]
# 结束位置就是开始index+答案的长度(字符级)
end_char = start_char + len(answers["text"][0])
第二步,要定位答案的起始和结束token索引,先从context里面初始化一下,先命名这一段字符为span好了。
# 当前span在文本中的起始标记索引。
token_start_index = 0
while sequence_ids[token_start_index] != 1:
token_start_index += 1
# 当前span在文本中的结束标记索引。
token_end_index = len(tokenized_example["input_ids"][0]) - 1
while sequence_ids[token_end_index] != 1:
token_end_index -= 1
第三步,如果答案的起始字符位置在token_start_index对应的token的起始位置之后,且答案的结束字符位置在token_end_index对应的token的结束位置之前,那么答案被认为是在这个span内的。将token_start_index和token_end_index移动到答案的两端。
offsets = tokenized_example["offset_mapping"][0]
# 如果答案的起始字符位置在token_start_index对应的token的起始位置之后,且答案的结束字符位置在token_end_index对应的token的结束位置之前,那么答案被认为是在这个span内的。
if (offsets[token_start_index][0] <= start_char and offsets[token_end_index][1] >= end_char):
# 将token_start_index和token_end_index移动到答案的两端。
# 注意:如果答案是最后一个单词,我们可以移到最后一个标记之后(边界情况)。
while token_start_index < len(offsets) and offsets[token_start_index][0] <= start_char:
token_start_index += 1
start_position = token_start_index - 1
while offsets[token_end_index][1] >= end_char:
token_end_index -= 1
end_position = token_end_index + 1
print(start_position, end_position)
else:
print("答案不在此特征中。")
接下来可以检查下找的对不对:
# 通过查找 offset mapping 位置,解码 context 中的答案
print(tokenizer.decode(tokenized_example["input_ids"][0][start_position: end_position+1]))
# 直接打印 数据集中的标准答案(answer["text"])
print(answers["text"][0])
输出结果是一样,则说明找的没问题。
填充
数据处理的时候除了截断操作,我们往往还需要对没超过最长长度的文本进行填充,因为我们希望每个批次中的所有输入数据需要具有相同的维度。填充确保所有输入达到相同的长度,以便可以有效地堆叠和处理它们。填充有前置填充和后置填充,就如字面意思一样,往字符串前面还是后面填充,前面指的是左边,后面指的是右边。在BERT中,我们希望是后置填充。我们输出 tokenizer.padding_side == "right"
得到的也是True。
整合
把上面针对单条数据的处理copy到一个方法里,并额外调整一下,就可以拿来处理这份数据集啦:
def prepare_train_features(examples):
# 一些问题的左侧可能有很多空白字符,这对我们没有用,而且会导致上下文的截断失败
# (标记化的问题将占用大量空间)。因此,我们删除左侧的空白字符。
examples["question"] = [q.lstrip() for q in examples["question"]]
# 使用截断和填充对我们的示例进行标记化,但保留溢出部分,使用步幅(stride)。
# 当上下文很长时,这会导致一个示例可能提供多个特征,其中每个特征的上下文都与前一个特征的上下文有一些重叠。
tokenized_examples = tokenizer(
examples["question"],
examples["context"],
truncation="only_second",
max_length=max_length,
stride=doc_stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)
# 由于一个示例可能给我们提供多个特征(如果它具有很长的上下文),我们需要一个从特征到其对应示例的映射。这个键就提供了这个映射关系。
sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping")
# 偏移映射将为我们提供从令牌到原始上下文中的字符位置的映射。这将帮助我们计算开始位置和结束位置。
offset_mapping = tokenized_examples.pop("offset_mapping")
# 让我们为这些示例进行标记!
tokenized_examples["start_positions"] = []
tokenized_examples["end_positions"] = []
for i, offsets in enumerate(offset_mapping):
# 我们将使用 CLS 特殊 token 的索引来标记不可能的答案。
input_ids = tokenized_examples["input_ids"][i]
cls_index = input_ids.index(tokenizer.cls_token_id)
# 获取与该示例对应的序列(以了解上下文和问题是什么)。
sequence_ids = tokenized_examples.sequence_ids(i)
# 一个示例可以提供多个跨度,这是包含此文本跨度的示例的索引。
sample_index = sample_mapping[i]
answers = examples["answers"][sample_index]
# 如果没有给出答案,则将cls_index设置为答案。
if len(answers["answer_start"]) == 0:
tokenized_examples["start_positions"].append(cls_index)
tokenized_examples["end_positions"].append(cls_index)
else:
# 答案在文本中的开始和结束字符索引。
start_char = answers["answer_start"][0]
end_char = start_char + len(answers["text"][0])
# 当前跨度在文本中的开始令牌索引。
token_start_index = 0
while sequence_ids[token_start_index] != (1 if pad_on_right else 0):
token_start_index += 1
# 当前跨度在文本中的结束令牌索引。
token_end_index = len(input_ids) - 1
while sequence_ids[token_end_index] != (1 if pad_on_right else 0):
token_end_index -= 1
# 检测答案是否超出跨度(在这种情况下,该特征的标签将使用CLS索引)。
if not (offsets[token_start_index][0] <= start_char and offsets[token_end_index][1] >= end_char):
tokenized_examples["start_positions"].append(cls_index)
tokenized_examples["end_positions"].append(cls_index)
else:
# 否则,将token_start_index和token_end_index移到答案的两端。
# 注意:如果答案是最后一个单词(边缘情况),我们可以在最后一个偏移之后继续。
while token_start_index < len(offsets) and offsets[token_start_index][0] <= start_char:
token_start_index += 1
tokenized_examples["start_positions"].append(token_start_index - 1)
while offsets[token_end_index][1] >= end_char:
token_end_index -= 1
tokenized_examples["end_positions"].append(token_end_index + 1)
return tokenized_examples
这个整合代码跟上面的区别主要是多了个 overflow_to_sample_mapping
的处理,它也是 tokenized_examples
里面的属性,是一个列表,用于追踪每个特征(由于上下文的长度而产生的数据片段)对应的原始输入样本的索引。当一个输入样本因为过长而被切分成多个特征时,这个映射关系让我们知道每个特征属于哪个原始样本。
在dataset
库里是提供了一个很方便的方法来批量处理数据,就是 datasets.map()
,它有这么几个参数要用到:
- batched: 表示是否是批量处理数据。
- remove_columns: 因为预处理更改了样本的数量,所以在应用它时需要删除旧列。
- load_from_cache_file:是否使用datasets库的自动缓存,如果在调用 map 时设置
load_from_cache_file=False
,可以强制重新应用预处理。
tokenized_datasets = datasets.map(prepare_train_features,
batched=True,
remove_columns=datasets["train"].column_names)
这样,我们得到的 tokenized_datasets
就是一份预处理过的数据了,
这份数据在后面进行微调时,可以配置在 train_dataset
和 eval_dataset
字段里面了,微调方式我这里先买个坑,下周抽空再写,希望本篇能对跟我一样的小白同学们起到一定的学习作用,大家共同进步!