前言
最近看了很多大模型,也使用了很多大模型。对于大模型理论似乎很了解,但是好像又缺点什么,思来想去决定自己动手实现一个 toy 级别的模型,在实践中加深对大语言模型的理解。
在这个系列的文章中,我将通过亲手实践,构建一个 1.2B
的模型,完成模型搭建、tokenizer
训练、模型预训练和指令微调这些流程。记录整个开发过程和其中遇到的各种挑战和对应解决方案。
最后里面所有的内容都是我对于大模型的理解形成的,如果您发现有任何过时或不准确的地方,请不吝指出。
在第一篇文章《从0到1实现一个自己的大模型,实践中了解模型流程细节(一)》中我们实现了一个 decoder only
的小模型,在训练前我们首先将文本转成向量,因此这一章我们着手构建自己的 tokenizer
。
分词算法
由于模型只能处理数字,因此分词算法的目的就是将输入文本转换为数字,这样模型才能接受文本消息。分词算法就是将文本转换成数字的方法。
Word-based
最直觉的方法是按照单词划分,给予每个单词不同的标号。例如我们可以通过空格和标点符号对一个文本进行划分:
text | tokenized |
---|---|
Let’s do tokenization! | Let 's do tokenization ! |
这样简单的分词算法的坏处是它会形成一个非常大的词表,例如 dog
和 dogs
就会变成两个独立的token,一个单词的不同形态都会变成独立token。更糟的是由于同义词会变成独立的token,那么模型开始并不会以为 dog
和 dogs
是有关联的。
通常我们会在分词器中定义一个特殊token表示词表中不存在的词,如果基于词划分,如果出现两个词组成的新词,它会被视为不存在。如果分词结果包含大量不存在,这样的分词算法无疑是不好的。
Character-based
基于字符的分词算法将文本拆分成字符而不是单词。这样做有两个明显的好处:
- 词表会小很多,只需要基础字符和标点即可。
- 未知标记很少,因为单词都会从字符构建。
但是这样做也有明显的坏处:
- 中文单个字有丰富语义,但是英文单个字符没有丰富的语义。
- 相同文本会被转换为更长的token序列,会严重增加模型推理的开销。
Subword
结合上述两种分词算法的优缺点,有了一种基于字词的分词算法。它的一个思想是常用的词不应该被拆分成较小的词,罕见的词应该拆分成有意义的词。 例如 annoyingly
可以拆分为 annoying
和 ly
,前者是常用词,后者是高频的词缀,两个词都具有一定的语义。这样可以使用一个较小的词表表示尽可能多的词,且尽可能少的出现未知。
现在主要的基于子词的分词算法有三种:BPE(GPT-2采用)、WordPiece(BERT采用)和Unigram(T5采用)。下面一张表简要总结三种分词算法的特点,参考《Normalization and pre-tokenization (huggingface.co)》
算法 | BPE | WordPiece | Unigram |
---|---|---|---|
训练原理 | 从一个较小的词表开始,学习 token 合并的规则 | 从一个小的词表开始,学习 token 合并规则 | 从一个大词表开始,逐步去掉 token |
训练步骤 | 合并出现频率最高的 token 对 | 根据词频计算分数,合并分数最高的 token 对,会优先选择单独出现频率低但是成对出现频率高的 token 对 | 基于整个语料计算损失,优先删除造成损失最小的 token |
学习结果 | token 合并规则和词表 | 仅仅一个词表 | 一个词表同时记录每个 token 的分数 |
编码方式 | 将词分成字符,然后根据合并规则合并 | 从词的开始找到词表中的最长匹配,然后在此分割,剩余部分继续按照相同方式分词 | 根据 token 分数找到最可能的分词方式 |
每种算法详细的原理在这里不赘述了,下面介绍分词器的工作流程。
分词流程
当我们有一个文本,对它进行分词大致分为下面的流程:
- 标准化:例如去除空格、去除音调、Unicode标准化等。
- 预分词:将文本分成一个个词。
- 分词:将每个词分成 token 序列。
- 后处理:添加特殊标记,例如 CLS、生成
attention_mask
、生成token_type_id
等。
在这里我们使用 tokenizers
库构建我们自己的分词器,这里我们选择GPT-2同样的BPE算法。开始前导入下面需要的必要的包。
from tokenizers import (
decoders,
models,
normalizers,
pre_tokenizers,
processors,
trainers,
Tokenizer,
)
首先基于BPE创建一个分词器
tokenizer = Tokenizer(models.BPE())
在初始化分词器时需要通过 unk_token
参数指定词表中未出现的 token。但是这里我们采用字节级的BPE分词算法,不需要指定该参数。
标准化阶段,我们采用 NFC
对 Unicode 码进行标准化,这里主要解决同样的显示字符存在不同的码点,这里不进行赘述。
tokenizer.normalizer = normalizers.NFC()
预分词阶段指定字节级分词
tokenizer.pre_tokenizer = pre_tokenizers.Sequence(
[
pre_tokenizers.Digits(individual_digits=True),
pre_tokenizers.ByteLevel(add_prefix_space=False),
]
)
第一个预分词是将数字独立分开,例如 1234
会被切成单独的 1
、2
、3
、4
四个数字,这样只需要十个数就可以表示整数。
后面的 ByteLevel
里指定 add_prefix_space=False
是不在句子开头加上前缀空格,我们通过一个例子表现两者的不同。
text = "I'm eating apples."
print(tokenizer.pre_tokenizer.pre_tokenize_str(text))
[('I', (0, 1)), ("'m", (1, 3)), ('Ġeating', (3, 10)), ('Ġapples', (10, 17)), ('.', (17, 18))]
Ġ
这个字符表示空格,不加前缀第一个字符就是 I
。如果加入前缀空格得到如下结果:
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
print(tokenizer.pre_tokenizer.pre_tokenize_str(text))
[('ĠI', (0, 1)), ("'m", (1, 3)), ('Ġeating', (3, 10)), ('Ġapples', (10, 17)), ('.', (17, 18))]
可以看到 I
前面有个表示空格的字符。
现在我们指定了分词算法,并且给出了标准化和预处理流程,现在就可以在指定的数据集上进行训练了。我们指定一个 trainer
,给出期望词表大小和一些特殊token(方便后期构建ChatML模板)。
trainer = trainers.BpeTrainer(
vocab_size=65535,
special_tokens=["<|system|>", "<|user|>", "<|assistant|>", "<|end|>"],
min_frequency=1500,
)
后处理和解码就不需要做特殊处理
tokenizer.post_processor = processors.ByteLevel(trim_offsets=False)
tokenizer.decoder = decoders.ByteLevel()
以上分词器的组件都准备齐全了,在训练之前最后就是准备数据集。数据集这里选择书生·万卷数据集-OpenDataLab,里面有大量中英文清洗好的数据,这里选择两块较小的英文数据集做训练。
书生·万卷数据集是 jsonl
格式,每行都是json数据,包括 id
和 content
,我们只需要 content
字段进行训练,由于数据集较大,我们使用 datasets
库可以高效加载。
from datasets import load_dataset, Dataset
dataset: Dataset = load_dataset(
"json",
data_files=[
"nlp_datas/part-000020-a894b46e.jsonl.tar.gz",
"nlp_datas/part-000065-a894b46e.jsonl.tar.gz",
],
split="train",
)
至此我们就可以开始训练了。
def get_training_corpus():
for i in range(0, len(dataset), 1000):
yield dataset[i : i + 1000]["content"]
tokenizer.train_from_iterator(get_training_corpus(), trainer=trainer)
训练完成之后保存结果,之前提到 BPE 算法会学习到词表和合并规则,因此我们也可以看到保存的文件中有词表和对应的合并规则。
# 保存 tokenizer
tokenizer.save("tokenizer.json")
我们尝试一下使用训练的分词器进行分词和解码。
tokenizer = Tokenizer.from_file("tokenizer_new.json")
text = "I hava a dog"
tokenizer.encode(text).tokens
>> ['I', 'Ġha', 'va', 'Ġa', 'Ġdog']
# 解码
ids = tokenizer.encode(text).ids
tokenizer.decode(ids)
>> 'I hava a dog'
最后我们将 tokenizer 封装一下,这样后续可以很方便的调用。
from typing import Optional, Tuple
from transformers import AddedToken, PreTrainedTokenizerFast
class CustomTokenizer(PreTrainedTokenizerFast):
model_input_names = ["input_ids", "attention_mask"]
def __init__(
self,
tokenizer_file=None,
unk_token="<|end|>",
bos_token=None,
eos_token="<|end|>",
pad_token="<|end|>",
**kwargs
):
bos_token = (
AddedToken(
bos_token, lstrip=False, rstrip=False, special=True, normalized=False
)
if isinstance(bos_token, str)
else bos_token
)
eos_token = (
AddedToken(
eos_token, lstrip=False, rstrip=False, special=True, normalized=False
)
if isinstance(eos_token, str)
else eos_token
)
unk_token = (
AddedToken(
unk_token, lstrip=False, rstrip=False, special=True, normalized=False
)
if isinstance(unk_token, str)
else unk_token
)
pad_token = (
AddedToken(
pad_token, lstrip=False, rstrip=False, special=True, normalized=False
)
if isinstance(pad_token, str)
else pad_token
)
super().__init__(
tokenizer_file=tokenizer_file,
unk_token=unk_token,
bos_token=bos_token,
eos_token=eos_token,
pad_token=pad_token,
**kwargs,
)
# Copied from transformers.models.gpt2.tokenization_gpt2_fast.GPT2TokenizerFast.save_vocabulary
def save_vocabulary(
self, save_directory: str, filename_prefix: Optional[str] = None
) -> Tuple[str]:
files = self._tokenizer.model.save(save_directory, name=filename_prefix)
return tuple(files)
尝试一下调用这个 tokenizer
tokenizer = CustomTokenizer(
tokenizer_file="tokenizer_new.json",
model_max_length=8196,
additional_special_tokens=["<|system|>", "<|user|>", "<|assistant|>", "<|end|>"],
clean_up_tokenization_spaces=False
)
text = "I have a dream."
tokenizer(text)
>> {'input_ids': [44, 381, 210, 3963, 17], 'attention_mask': [1, 1, 1, 1, 1]}
input_ids = tokenizer(text)["input_ids"]
tokenizer.decode(input_ids)
>> 'I have a dream.'
批处理也可以很方便的处理:
text = ["I hate apple.", "I like reading and playing basketball."]
tokenizer(text, padding=True)
>> {'input_ids': [[44, 12768, 12661, 17, 3, 3, 3], [44, 624, 3099, 238, 2872, 7603, 17]], 'attention_mask': [[1, 1, 1, 1, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1]]}
ids = tokenizer(text, padding=True)["input_ids"]
tokenizer.batch_decode(ids, skip_special_tokens=False)
>> ['I hate apple.<|end|><|end|><|end|>', 'I like reading and playing basketball.']
保存和加载:
tokenizer.save_pretrained("tokenizer") # 保存
tokenizer = CustomTokenizer.from_pretrained("tokenizer") # 加载
到此完成了分词器的整个流程,我们可以方便的编码、解码、保存和加载。
结语
至此我们成功实现了一个分词器,现在有了模型结构,有了分词器,有了数据集,我们可以开始训练我们的模型。下一篇我们将对模型进行训练,在训练中会遇到各种问题,我们也将逐步解决这些问题。