从0到1实现一个自己的大模型,实践中了解模型流程细节(二)分词器

前言

最近看了很多大模型,也使用了很多大模型。对于大模型理论似乎很了解,但是好像又缺点什么,思来想去决定自己动手实现一个 toy 级别的模型,在实践中加深对大语言模型的理解。

在这个系列的文章中,我将通过亲手实践,构建一个 1.2B 的模型,完成模型搭建、tokenizer 训练、模型预训练和指令微调这些流程。记录整个开发过程和其中遇到的各种挑战和对应解决方案。

最后里面所有的内容都是我对于大模型的理解形成的,如果您发现有任何过时或不准确的地方,请不吝指出。

在第一篇文章《从0到1实现一个自己的大模型,实践中了解模型流程细节(一)》中我们实现了一个 decoder only 的小模型,在训练前我们首先将文本转成向量,因此这一章我们着手构建自己的 tokenizer

分词算法

由于模型只能处理数字,因此分词算法的目的就是将输入文本转换为数字,这样模型才能接受文本消息。分词算法就是将文本转换成数字的方法。

Word-based

最直觉的方法是按照单词划分,给予每个单词不同的标号。例如我们可以通过空格和标点符号对一个文本进行划分:

text tokenized
Let's do tokenization! Let 's do tokenization !

这样简单的分词算法的坏处是它会形成一个非常大的词表,例如 dogdogs 就会变成两个独立的token,一个单词的不同形态都会变成独立token。更糟的是由于同义词会变成独立的token,那么模型开始并不会以为 dogdogs 是有关联的。

通常我们会在分词器中定义一个特殊token表示词表中不存在的词,如果基于词划分,如果出现两个词组成的新词,它会被视为不存在。如果分词结果包含大量不存在,这样的分词算法无疑是不好的。

Character-based

基于字符的分词算法将文本拆分成字符而不是单词。这样做有两个明显的好处:

  • 词表会小很多,只需要基础字符和标点即可。
  • 未知标记很少,因为单词都会从字符构建。

但是这样做也有明显的坏处:

  • 中文单个字有丰富语义,但是英文单个字符没有丰富的语义。
  • 相同文本会被转换为更长的token序列,会严重增加模型推理的开销。

Subword

结合上述两种分词算法的优缺点,有了一种基于字词的分词算法。它的一个思想是常用的词不应该被拆分成较小的词,罕见的词应该拆分成有意义的词。 例如 annoyingly 可以拆分为 annoyingly ,前者是常用词,后者是高频的词缀,两个词都具有一定的语义。这样可以使用一个较小的词表表示尽可能多的词,且尽可能少的出现未知。

现在主要的基于子词的分词算法有三种: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算法。开始前导入下面需要的必要的包。

python 复制代码
from tokenizers import (
    decoders,
    models,
    normalizers,
    pre_tokenizers,
    processors,
    trainers,
    Tokenizer,
)

首先基于BPE创建一个分词器

python 复制代码
tokenizer = Tokenizer(models.BPE())

在初始化分词器时需要通过 unk_token 参数指定词表中未出现的 token。但是这里我们采用字节级的BPE分词算法,不需要指定该参数。

标准化阶段,我们采用 NFC 对 Unicode 码进行标准化,这里主要解决同样的显示字符存在不同的码点,这里不进行赘述。

python 复制代码
tokenizer.normalizer = normalizers.NFC()

预分词阶段指定字节级分词

python 复制代码
tokenizer.pre_tokenizer = pre_tokenizers.Sequence(
    [
        pre_tokenizers.Digits(individual_digits=True),
        pre_tokenizers.ByteLevel(add_prefix_space=False),
    ]
)

第一个预分词是将数字独立分开,例如 1234 会被切成单独的 1234 四个数字,这样只需要十个数就可以表示整数。

后面的 ByteLevel 里指定 add_prefix_space=False 是不在句子开头加上前缀空格,我们通过一个例子表现两者的不同。

python 复制代码
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 。如果加入前缀空格得到如下结果:

python 复制代码
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模板)。

python 复制代码
trainer = trainers.BpeTrainer(
    vocab_size=65535,
    special_tokens=["<|system|>", "<|user|>", "<|assistant|>", "<|end|>"],
    min_frequency=1500,
)

后处理和解码就不需要做特殊处理

python 复制代码
tokenizer.post_processor = processors.ByteLevel(trim_offsets=False)
tokenizer.decoder = decoders.ByteLevel()

以上分词器的组件都准备齐全了,在训练之前最后就是准备数据集。数据集这里选择书生·万卷数据集-OpenDataLab,里面有大量中英文清洗好的数据,这里选择两块较小的英文数据集做训练。

书生·万卷数据集是 jsonl 格式,每行都是json数据,包括 idcontent,我们只需要 content 字段进行训练,由于数据集较大,我们使用 datasets 库可以高效加载。

python 复制代码
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",
)

至此我们就可以开始训练了。

python 复制代码
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 算法会学习到词表和合并规则,因此我们也可以看到保存的文件中有词表和对应的合并规则。

python 复制代码
# 保存 tokenizer
tokenizer.save("tokenizer.json")

我们尝试一下使用训练的分词器进行分词和解码。

python 复制代码
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 封装一下,这样后续可以很方便的调用。

python 复制代码
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

python 复制代码
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.'

批处理也可以很方便的处理:

python 复制代码
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.']

保存和加载:

python 复制代码
tokenizer.save_pretrained("tokenizer") # 保存
tokenizer = CustomTokenizer.from_pretrained("tokenizer") # 加载

到此完成了分词器的整个流程,我们可以方便的编码、解码、保存和加载。

结语

至此我们成功实现了一个分词器,现在有了模型结构,有了分词器,有了数据集,我们可以开始训练我们的模型。下一篇我们将对模型进行训练,在训练中会遇到各种问题,我们也将逐步解决这些问题。

相关推荐
passer__jw7671 小时前
【LeetCode】【算法】3. 无重复字符的最长子串
算法·leetcode
passer__jw7671 小时前
【LeetCode】【算法】21. 合并两个有序链表
算法·leetcode·链表
sweetheart7-71 小时前
LeetCode22. 括号生成(2024冬季每日一题 2)
算法·深度优先·力扣·dfs·左右括号匹配
景鹤4 小时前
【算法】递归+回溯+剪枝:78.子集
算法·机器学习·剪枝
陌上阳光4 小时前
动手学深度学习68 Transformer
人工智能·深度学习·transformer
_OLi_4 小时前
力扣 LeetCode 704. 二分查找(Day1:数组)
算法·leetcode·职场和发展
丶Darling.4 小时前
Day40 | 动态规划 :完全背包应用 组合总和IV(类比爬楼梯)
c++·算法·动态规划·记忆化搜索·回溯
风影小子5 小时前
IO作业5
算法
奶味少女酱~5 小时前
常用的c++特性-->day02
开发语言·c++·算法
passer__jw7675 小时前
【LeetCode】【算法】11. 盛最多水的容器
算法·leetcode