大模型从零开始——预训练之分词 Tokenization

 文章目录

一、概念介绍

二、单词级别分词

三、字符级别分词

四、子词级别分词

        BPE/BBPE分词

                BPE:Byte-Pair Encoding (BPE)

                BBPE:Byte-level BPE (BBPE)

        WordPiece分词

        Unigram分词

五、总结


词元化(Tokenization)是数据预处理中的一个关键步骤,旨在将原始文本分割成模型可识别和建模的词元序列,作为大语言模型的输入数据。

传统自然语言处理研究(如基于条件随机场的序列标注)主要使用基于词汇的分词方法(Word-based Tokenizer),这种方法更符合人类的语言认知。

然而,基于词汇的分词在某些语言(如中文分词)中可能对于相同的输入产生不同的分词结果,导致生成包含海量低频词的庞大词表,还可能存在未登录词(Out-of-vocabulary, OOV)等问题。

因此,一些语言模型开始采用字符作为最小单位来分词(Character-based Tokenizer)。例如,ELMo 采用了 CNN 词编码器。

最近,子词分词器(Subword-based Tokenizer)被广泛应用于基于 Transformer 的语言模型中,包括BPE 分词、WordPiece 分词和 Unigram 分词三种常见方法。

HuggingFace 维护了一个在线自然语言处理课程,是一个很好的学习资源,其中的分词部分提供了非常具体的演示实例,推荐初学者可以参考学习。https://huggingface.co/learn/nlp-course/chapter6

一、概念介绍

Token:是文本数据的基本单元也即词元,通常表示一个词、子词或字符。

Tokenization:中文翻译为分词,是将原始文本字符串分割成一系列Token的过程。这个过程可以有不同的粒度,比如单词级别分词(Word-based Tokenization)、字符级别分词(Character-based Tokenization)和子词级别分词(Subword-based Tokenization)。

Tokenizer:是将文本切分成多个tokens的工具或算法例如:Word-based Tokenizer,Character-based Tokenizer,Subword-based Tokenizer。

Transformer模型中,Inputs是词元化后token对应的id,而Input Embedding则用来表示token的稠密向量,是由token学习得到。Embedding layer 是Transformer 模型的一部分。

Tokenizer 的作用:将文本序列转化为数字序列(token编号),作为 transformer的输入,是训练和微调 LLM 必不可少的一部分。 

 感兴趣的同学可以在HuggingFace的The Tokenizer Playground 体验部分模型的分词。

二、单词级别分词

单词级别分词:将文本划分为一个个词(包括标点)。

我们以这句话为例:"Don't you love 🤗 Transformers? We sure do."

但是这里的Don't 应该被划分为Do,n't,引入规则之后事情就变得复杂起来了。

英文的划分有两个常用的基于规则的工具spaCyMoses,划分如下:

["Do", "n't", "you", "love", "🤗", "Transformers", "?", "We", "sure", "do", "."]

  • 优点:符合人类的自然语言和直觉
  • 缺点:相同意思的词被划分为不同的token,导致词表非常大,例如:dog、dogs会是两个token。
    • 如果词表非常大,我们可以设置词表上限比如上限为10000,未知的词用Unkown表示。但是这样会存在信息损失,加重OOV问题,模型性能大打折扣。
    • 难以处理拼写错误的单词。

三、字符级别分词

字符级别分词:将文本划分为一个个字符(包括标点)。

  • 优点
    • 可以表示任意(英文)文本,不会出现word-based中的unknown情况。

    • 对西文来说,不需要很大的词表,例如英文只需不到256个字符(ASCII码表示)。

  • 缺点
    • 字母相对于单词包含的信息量低,所以模型性能一般很差。

      例如:一个字母 “T” 无法知道它具体指代的是什么,但如果是 “Today” 语义就比较明确。
    • 相对于Word-based Tokenizer ,会产生很长的token序列。

    • 如果是中文,依然会有很大的词表。

四、子词级别分词

子词级别分词:常用词不应该再被切分成更小的token或子词(subword),不常用的词或词群应该用子词来表示。

使用subword划分符合英文词群,能够充分表意。 

例如:BERT会将“tokenization”分为“token”和“##ization”; ##表示该token是一个词的结尾。

常用的三种分词:BPE/BBPE 分词、WordPiece 分词和 Unigram 分词 

BPE/BBPE分词

BPEByte-Pair Encoding (BPE)

BPE算法包含两部分: 词频统计”与“词表合并。BPE算法从一组基本符号(例如字母和边界字符)开始,迭代地寻找语料库中的两个相邻词元,并将它们替换为新的词元,这一过程被称为合并。合并的选择标准是计算两个连续词元的共现频率,也就是每次迭代中,最频繁出现的一对词元会被选择与合并。合并过程将一直持续达到预定义的词表大小。 

词频统计依赖于一个预分词器(pre-tokenization)将训练数据分成单词。预分词器可以非常简单,按照空格进行分词。例如GPT2,RoBERTa等就是这样实现的,更高级的预分词器引入了基于规则的分词,例如XLM,FlauBERT 使用Moses, GPT 使用spaCy和ftfy来统计语料中每个单词的词频。

在预分词之后,创建一个包含不同单词和对应词频的集合,接下来根据这个集合创建包含所有字符的词表,再根据词表切分每个单词,按照合并规则两两合并形成一个新词,如将词内相邻token出现频率最高的新词加入词表,直到达到预先设置的数量,停止合并。

假设在预分词(一般采用Word-based Tokenization)之后,得到如下的包含词频的集合:

("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4),("hugs", 5)

因此,基本词表是这样的:["b", "g", "h", "n", "p", "s", "u"] ,

将所有单词按照词汇表里的字符切割得到如下形式:

("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)

接下来统计相邻的两个字符组成的字符对的出现的频率,频率最高的合并加入词表,设置BPE合并次数(超参数),达到则停止合并。

经过三轮迭代,得到新的词表:["b", "g", "h", "n", "p", "s", "u","ug","un", "hug"]

进行tokenization时,对于不在词表中的设置为特殊词<unk>。如:bug => ["b", "ug"],mug => ["<unk>", "ug"]。

例如:GPT的词汇表大小就是40478,包含基本字符478个,并经过40000次合并,添加了高频pair,然后就利用这个词表进行分词。

BPE 算法的代码如下:

import re
from collections import defaultdict

def extract_frequencies (sequence):
    """
    给定一个字符串,计算字符串中的单词出现的频率,并返回词表(一个词到频率的映射一字典)。
    """
    token_counter = Counter()
    for item in sequence:
        tokens = ' '.join(list(item))+'</w>'
        token_counter[tokens] += 1
    return token_counter
    
def frequency_of_pairs(frequencies):
    """
    给定一个词频字典,返回一个从字符对到频率的映射字典。
    """
    pairs_count = Counter()
    for token, count in frequencies.items():
        chars = token.split()
        for i in range(len(chars) - 1):
            pair=(chars[i], chars[i+1])
            pairs_count[pair] += count
     return pairs_count

def merge_vocab(merge_pair, vocab):
    """
    给定一对相邻词元和一个词频字典,将相邻词元合并为新的词元,并返回新的词表。
    """
    re_pattern = re.escape(' '.join(merge_pair))
    pattern = re.compile(r'(?<!\S)' + re_pattern + r'(?!\S)')
    updated_tokens = {pattern.sub(''.join(merge_pair), token): freq for token,freq in vocab.items()}
    return updated_tokens


def encode_with_bpe(texts, iterations):
    """
    给定待分词的数据以及最大合并次数,返回合并后的词表。
    """
    vocab_map = extract_frequencies(texts)
    for _ in range(iterations):
        pair_freqs = frequency_of_pairs(vocab_map)
        if not pair_freqs:
            break
        most_common_pair = pair_freqs.most_common(1)[0][0]
        vocab_map = merge_vocab(most_common_pair, vocab_map)
    return vocab_map

num_merges = 1000 
bpe_pairs = encode_with_bpe(data, num_merges)

BPE的缺点:包含所有可能的基本字符(token)的基本词汇表可能相当大。例如:将所有Unicode字符都被视为基本字符(如中文)。

如果仅仅是英文,那么BPE的基本词汇表大小就是ASCII的词汇表大小,但如果是多种语言,比如加入了韩语,中文,日语甚至表情,那么BPE的基本词汇表就变成了全部Unicode字符,这个时候词汇表就将非常庞大,那么这个时候我们就会思考,有没有一种可能用统一的方式表示各种语言呢,那就是用字节Byte表示,也就是先将所有的字符转成UTF-8编码。

BBPEByte-level BPE (BBPE)

字节级别的 BPE(Byte-level BPE, B-BPE)是 BPE 算法的一种拓展。它将字节视为合并操作的基本符号,从而可以实现更细粒度的分割,且解决了未登录词问题。采用这种词元化方法的代表性语言模型包括 GPT2、GPT3、GPT4、BART 和 LLaMA 。

具体来说,如果将所有 Unicode 字符都视为基本字符,那么包含所有可能基本字符的基本词表会非常庞大(例如将中文的每个汉字当作一个基本字符)。而将字节作为基本词表可以设置基本词库的大小为 256,同时确保每个基本字符都包含在词汇中。

改进:将字节(byte)视为基本token,两个字节合并即可以表示Unicode。比如中文、日文、阿拉伯文、表情符号等等。

UTF-8 是一种针对Unicode的可变长度字符编码,也是一种前缀码。它可以用一至四个字节对Unicode字符集中的所有有效编码点进行编码。

每一个字节都是8位比特,8位比特的无符号整数范围为0-255,因此,BBPE的基本词汇表又回到了256,是不是特别神奇。

例如:GPT-2的词汇表大小为 50257,对应于256字节的基本词元、一个特殊的文末词元以及通过 50000 次合并学习到的词元。通过使用一些处理标点符号的附加规则,GPT2 的分词器可以对文本进行分词,不再需要使用 “<UNK>” 符号。

❗️需要注意的是,由于 Unicode 中存在重复编码的特殊字符,可以使用标准化方法(例如NFKC)来预处理我们的语料。但 NFKC 并不是无损的,可能会降低分词性能。 

WordPiece分词

WordPiece 是谷歌内部非公开的分词算法,最初是由谷歌研究人员在开发语音搜索系统时提出的。随后,在 2016 年被用于机器翻译系统,并于 2018年被 BERT 采用作为分词器。

WordPiece 分词和 BPE 分词的想法非常相似,都是通过迭代合并连续的词元,但是合并的选择标准略有不同。

在合并前,WordPiece分词算法会首先训练一个语言模型,并用这个语言模型对所有可能的词元对进行评分。然后,在每次合并时,它都会选择使得训练数据的似然性增加最多的词元对。

由于谷歌并未发布 WordPiece 分词算法的官方实现,这里我们参考了 HuggingFace 在线自然语言课程中给出的 WordPiece 算法的一种实现。

与 BPE 类似,WordPiece 分词算法也是从一个小的词汇表开始,其中包括模型使用的特殊词元和初始词汇表。由于它是通过添加前缀(如 BERT 的##)来识别子词的,因此每个词的初始拆分都是将前缀添加到词内的所有字符上,除第一个字符外。

举例来说,“word”会被拆分为:“w、##o、##r、##d”。

与 BPE 方法的另一个不同点在于,WordPiece 分词算法并不选择最频繁的词对,而是使用下面的公式为每个词对计算分数:

例如,它不一定会合并(“un”、“##able”),即使这对词在词汇表中出现得很频繁,因为“un”和“###able”这两对词可能都会出现在很多其他单词中,并且频率很高。

相比之下,像(“hu”、“##gging”)这样的一对可能会更快地融合(假设“hugging”一词经常出现在词汇表中),因为 “hu”和“##ggig”单独出现的频率可能较低。 所以(“hu”、“##gging”)词对的得分会比(“un”、“##able”)词对的得分更高,更需要合并。

Unigram分词

与 BPE 分词和 WordPiece 分词不同,Unigram 分词方法从语料库的一组足够大的字符串或词元初始集合开始,迭代地删除其中的词元,直到达到预期的词表大小。

它假设从当前词表中删除某个词元,并计算训练语料的似然增加情况,以此来作为选择标准。这个步骤是基于一个训练好的一元语言模型来进行的。

为估计一元语言模型,它采用期望最大化(Expectation–Maximization, EM)算法:

在每次迭代中,首先基于旧的语言模型找到当前最优的分词方式,然后重新估计一元概率从而更新语言模型。

这个过程中一般使用动态规划算法(即维特比算法,Viterbi Algorithm)来高效地找到语言模型对词汇的最优分词方式。

采用这种分词方法的代表性模型包括 AlBERT、 T5 和 mBART。

如何删减token?

尝试删去一个 token,并计算对应的 unigram loss, 删除 p% 使得 loss 增加最少的 token。

如何将文本切分成token? 假设每个词的出现都是独立的。

我们按照所有的子词划分方式列出,构建基本词表;对所有词进行词频统计,总计为180

对于语料中的每个词,我们算出每种划分的概率,以hug为例,它有4种划分方式: 

在这四种划分中("hu","g"),("h","ug")这两种概率最大且相等,我们可以任选一种,比如选("hu","g") 。 

对于其他词我们也依次类推,就得到了每个词划分的概率。

如何计算语料的Unigram loss? 得到Loss = 170.4。

接下来就是对词表进行删减,注意:不删除基本token,如26个字母和标点。 

第一次迭代,分别删除"ug"、"pu"、"hu"、"lu"、"du"、"bu",得到Loss都为170.4,这里我们随机删除一个token,如ug;重新计算Vocab。

第二次迭代,分别删除"pu"、"hu"、"lu"、"du"、"bu",得到相应的Loss,假设我们要删除40%的token,则删除"du"、"bu"

迭代直到词表的大小缩减到预设值;为单词的每种划分计算概率非常耗时,采用更高效的维特比(Viterbi)算法。 

五、总结

BPE(字节对编码)

BPE的核心概念是从字母开始,反复合并频率最高且相邻的两个token,直到达到目标词数。

BBPE(字节级字节对编码)

BBPE的基本思想是将BPE从字符级别扩展到字节(Byte)级别。BPE在处理unicode编码时可能会导致基础字符集过大,而BBPE将每个字节视为一个“字符”,不论实际字符集用多少字节表示。这样,基础字符集的大小就固定为256(2^8),从而实现跨语言共享词表,并显著缩减词表大小。然而,对于像中文这样的语言,文本序列长度会显著增加,这可能使得BBPE模型的性能优于BPE模型,但其序列长度较长也会导致训练和推理时间增加。BBPE的实现与BPE类似,主要差别在于基础词表使用256的字节集。

WordPiece

WordPiece算法可视为BPE的变种。不同之处在于,WordPiece通过概率生成新的subword,而不是简单地选择频率最高的字节对。WordPiece每次从词表中选出两个子词合并成一个新子词,但选择的是能最大化语言模型概率的相邻子词。

Unigram

Unigram与BPE和WordPiece在本质上有明显区别。前两者从小词表开始,逐步增加到设定的词汇量,而Unigram则先初始化一个大词表,通过语言模型评估逐步减少词表,直到达到目标词汇量。

虽然直接使用已有的分词器较为方便,但是使用为预训练语料专门训练或设计的分词器会更加有效,尤其是对于那些混合了多领域、多语言和多种格式的语料。最近的大语言模型通常使用SentencePiece 代码库为预训练语料训练定制化的分词器,这一代码库支持字节级别的 BPE 分词和 Unigram 分词。

为了训练出高效的分词器,我们应重点关注以下几个因素。

首先,分词器必须具备无损重构的特性,即其分词结果能够准确无误地还原为原始输入文本。

其次,分词器应具有高压缩率,即在给定文本数据的情况下,经过分词处理后的词元数量应尽可能少,从而实现更为高效的文本编码和存储。

具体来说,压缩比可以通过将原始文本的 UTF-8 字节数除以分词器生成的词元数(即每个词元的平均字节数)来计算:

例如,给定一段大小为1MB(1,048,576字节)的文本,如果它被分词为200,000个词元,其压缩率即为 1,048,576/200,000=5.24。

值得注意的是,在扩展现有的大语言模型(如继续预训练或指令微调)的同时,还需要意识到原始分词器可能无法较好地适配实际需求。

以 LLaMA 为例,它基于主要包含英语文本的预训练语料训练了 BPE 分词器。因此,当处理中文等非英语数据时,该分词器可能表现不佳,甚至可能导致推理延迟的增加。

此外,为进一步提高某些特定能力(如数学能力),还可能需要针对性地设计分词器。例如,BPE 分词器可能将整数 7,481 分词为“7、481”,而将整数 74,815 分词为“74、815”。这导致相同的数字被分割成不同的子串,降低了解决相关数学问题的能力。

相比之下,专门设计基于数字的分词方式可以避免这种不一致性,从而提升大语言模型的数值计算能力。

综上所述,在设计和训练分词器时,我们需要综合考虑多种因素,以确保其在实际应用中能够发挥最佳效果。

🔥🔥🔥 OpenAI、Google、HuggingFace分别都提供了开源的tokenizer工具:tiktokensentencepiecetokenizers,支持主流的分词算法。

### 如何在集成 DeepSeek 后进行模型训练 #### 准备工作 为了能够顺利地对 DeepSeek 进行训练,在开始之前需确保已经成功安装并配置好了 Ollama 和其他必要的依赖项[^1]。这一步骤至关重要,因为后续所有的操作都将基于此环境展开。 #### 加载预训练模型 一旦完成了上述准备工作,则可以加载预先训练好的 DeepSeek 模型作为起点。通常情况下,官方会提供几种不同版本的基础模型供选择,可以根据具体应用场景和个人偏好挑选最适合的一个来启动项目: ```python from transformers import AutoModelForSequenceClassification, AutoTokenizer model_name = "deepseek-model-name" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name) ``` 这段代码展示了如何利用 Hugging Face 提供的 `transformers` 库快速导入指定名称下的 DeepSeek 模型实例及其配套分词器[^2]。 #### 数据集准备与处理 对于任何机器学习任务而言,高质量的数据都是取得良好效果的关键因素之一。针对特定领域的问题构建专属数据集,并对其进行清洗、标注等工作是必不可少的过程。此外还需要考虑将原始文本转换成适合输入给神经网络的形式——即 tokenization(标记化),这部分可以通过前面提到过的 tokenizer 来完成: ```python def preprocess_function(examples): return tokenizer(examples['text'], truncation=True) tokenized_datasets = raw_datasets.map(preprocess_function, batched=True) ``` 这里定义了一个简单的函数来进行批量预处理,它接收未加工过的样本列表作为参数并通过调用 tokenizer 方法实现自动化的特征提取过程。 #### 定义训练参数及优化策略 接下来就是设定超参部分了,比如 epoch 数量、batch size 大小等;同时也应该选定合适的 loss function 及 optimizer 类型以便指导整个反向传播过程中权值更新的方向和幅度。这些设置直接影响着最终收敛速度以及泛化能力的好坏程度: ```python training_args = TrainingArguments( output_dir="./results", evaluation_strategy="epoch", learning_rate=2e-5, per_device_train_batch_size=8, per_device_eval_batch_size=8, num_train_epochs=3, weight_decay=0.01, ) trainer = Trainer( model=model, args=training_args, train_dataset=small_train_dataset, eval_dataset=small_eval_dataset, compute_metrics=compute_metrics, ) ``` 以上片段给出了一个较为通用的例子,其中包含了创建 `TrainingArguments` 对象以封装各类选项的具体数值,还有初始化 `Trainer` 实例负责统筹管理整个迭代流程中的各个环节。 #### 开始训练 当一切就绪之后就可以正式开启训练环节啦!只需简单地执行 trainer.train() 即可触发后台计算资源调度机制进而驱动算法逐步逼近全局最优解的位置直至满足终止条件为止: ```python trainer.train() ``` 至此便实现了从零起步搭建起一套完整的基于 DeepSeek 的微调框架结构。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值