最近,在GitHub上,一个名为「Nishant Aklecha」的开发者发布了《从零开始实现Llama3(https://github.com/naklecha/llama3-from-scratch)的项目,迅速吸引了广泛关注。这个项目详细解释了Llama3推理原型模型的实现过程。对于初学者和想要深入了解大模型实现原理的开发者来说,这是一份不可多得的宝贵资料。然而,尽管原文已经做了较为详细的阐述,但要想完全理解和掌握Llama3推理原型模型的实现,还需要一定的背景知识作为基础。本文旨在为初学者提供这些必要的背景知识,并在此基础上对《从零开始实现Llama3》一文进行更深入的解读,帮助大家从零开始,逐步入门大模型的世界。 图源:Umar Jamil – https://github.com/hkproj/pytorch-llama-notes

参照LLaMA架构图(如上所示)和Nishant Aklecha的模型处理流程,我们可以将整个流程分解为几个关键步骤,具体如下所示::

  1. 输入(Input)和分词器(tokenizer):
  • 首先,输入的文本会经过分词处理,将文本拆分成一个个独立的词元(Token)。这些词元可以是单词、子词或者字符,具体取决于模型的分词策略。
  1. 嵌入(Embeddings):
  • 分词后的词元会被转换成向量表示,即嵌入(Embeddings)。这些向量嵌入捕捉了词元之间的关系和语义信息。
  1. RMS Norm:
  • 嵌入后的向量通过RMS Norm进行规范化,标准化处理有助于稳定模型训练,提高训练效率。
  1. 自注意力机制(Self-Attention with KV Cache):
  • 处理后的向量进入自注意力机制模块,计算查询(Q)、键(K)和值(V)之间的关系。自注意力机制使模型能够关注输入序列中不同位置的信息。

  • 这里使用了分组多查询注意力(Grouped Multi-Query Attention)和KV缓存(KV Cache),提高了计算效率。

  1. 旋转位置编码(Rotary Positional Encodings):
  • 在自注意力机制中,添加旋转位置编码,使模型能够捕捉到序列中标记位置的顺序信息。
  1. RMS Norm:
  • 自注意力机制的输出再次通过RMS Norm进行规范化。
  1. 前馈神经网络(Feed Forward with SwiGLU):
  • 规范化后的输出进入前馈神经网络,这一层通常包含多个全连接层,使用SwiGLU(Switchable Gated Linear Units)激活函数,进一步提取特征。
  1. RMS Norm:
  • 前馈神经网络的输出再一次通过RMS Norm进行规范化。
  1. 残差连接(Residual Connection):
  • 模型中使用了残差连接,前馈神经网络的输出与输入相加,确保信息流畅通,有助于深层网络的训练。
  1. 多层堆叠(Nx):
  • 上述的注意力机制和前馈神经网络的组合模块会重复N次,形成深层的变换结构,提高模型的表达能力。
  1. 输出层(Output Layer):
  • 最后,通过线性层(Linear)和Softmax层,将模型的输出转换为概率分布,用于预测下一个标记。

由于涉及的知识点较多,并且计划对每个知识点进行详细讲解,所以本文将按照以上流程顺序,以系列文章的形式逐篇推出。

我们首先从原文tokenizer这里开始,原文写到:“我不会实现 bpe 分词器……”

那么什么是tokenizer,什么又是BPE,分词器在这个模型中输入流程中扮演的决赛又是什么?

首先,分词器(tokenizer)在训练及文本输入阶段的作用如下:

  1. 词汇表生成:在训练模型之前,使用tokenizer对训练语料库进行处理,生成词汇表(vocabulary)。词汇表包含了所有在语料库中出现的独特词汇,并为每个词汇分配一个唯一的索引。

  2. 文本输入时分词处理:当输入文本时,tokenizer会将文本拆分成单词或子词(根据具体的tokenizer类型,如BPE或WordPiece)。然后,tokenizer会查找每个词或子词在词汇表中的索引。

  3. 索引转换:每个词或子词被转换成相应的索引,这些索引组成一个索引序列,即模型的输入。

例如,假设词汇表中包含以下词汇和对应索引:

{
  "hello": 1,
  "world": 2,
  "this": 3,
  "is": 4,
  "a": 5,
  "test": 6
}

对于输入文本 “hello world”,tokenizer 会将其拆分为 [“hello”, “world”],然后查找索引得到 [1, 2]。这个索引序列 [1, 2] 就是模型的输入。

接下来详细讲解分词(Tokenization):

一、什么是分词 (Tokenization)

分词(Tokenization)是自然语言处理(NLP)中的一个基本步骤,其目的是将文本分解成更小的单元(即词或标记,token)。这些标记可以是单词、词组、符号或其他有意义的元素。分词的具体过程和细粒度取决于具体的应用场景和语言特点。

二、为什么要做分词 (Tokenization)

分词是将文本分解为更小的单元,便于计算机理解和处理。这是自然语言处理的基础步骤,有助于提高模型性能,并简化后续的文本分析和处理。

三、分词 (Tokenization) 常见的方法

通常分词有两个最简单和直接的方法:

1.按照空格分开:在英文文本中,这通常意味着按照单词分开。

  • 优点:

  • 简单直观:直接使用空格作为分隔符,易于实现。例如,句子 “This is a test” 可以分词为 [“This”, “is”, “a”, “test”]。

  • 速度快:处理速度快,计算资源消耗少。

  • 适用于英文等以空格分隔单词的语言:在这些语言中效果很好。

  • 缺点

  • 词汇表庞大:以单词为粒度进行分割,训练过程中会导致词汇表非常庞大。

  • OOV(Out-of-Vocabulary)问题:使用过程中可能会遇到词汇表中没有的新词,模型无法处理。

  • 泛化能力差:模型学到的词语关系难以泛化。例如,模型可能学会了“run”, 和“running”、“runner”之间的关系,但无法泛化到“swim”, 和“swimming”、“swimmer”。

2.按字符进行分割:将文本分解为单个字符,适用于处理字符级别的语言模型。

  • 优点

  • 适用于所有语言:不受分隔符影响。例如,句子 “Test” 可以分割为 [‘T’, ‘e’, ‘s’, ‘t’],中文句子 “测试” 可以分割为 [‘测’, ‘试’]。

  • 可以处理任何形式的文本:包括拼音、标点等。

  • 对未知词汇或新词有较好的适应性:不会漏掉新词或罕见词。

  • 缺点

  • 粒度太细:字符级分割会导致丧失单词本身的语义信息,无法有效捕捉词汇的整体意义。

  • 需要更多计算资源:处理复杂度高,生成的数据量大。

四、字节对编码(BPE, Byte Pair Encoding)

为了在单词和字符两个粒度之间取得平衡,提出了基于子词(subword)的算法,典型的就是字节对编码(BPE, Byte Pair Encoding)。

BPE 解决了以下问题:

  • 词汇表庞大:通过将词分解为子词单元,BPE显著减少了词汇表的大小。

  • OOV(Out-of-Vocabulary)问题:BPE能够处理未知词汇,因为它可以将未知词分解为已知的子词单元。

    泛化能力差:BPE在一定程度上提高了模型的泛化能力,因为它可以捕捉到词缀和词根等子词结构,从而更好地处理不同形式的词语。例如,它可以理解“run”, “running”, “runner”之间的关系,并泛化到“swim”, “swimming”, “swimmer”。

五、BPE算法的结果举例

图源:https://www.thoughtvector.io/blog/subword-tokenization/

  • 切分:“Unfriendly” -> “un” + “friend”+“ly”:减少词表中的长词,同时保留语义信息。(“un” + “friend”+“ly” 这三个都是有语义信息,都可组合复用)

  • 合并:“北” + “京” -> “北京”:减少字符数,提高处理效率,保持语义完整。(输入的时候,只是一个token,而不是两个)

六、BPE算法详解

BPE 算法训练(词汇表构建)步骤:
  1. 初始化词汇表:将文本中的每个字符作为一个单独的词汇项,构建初始词汇表。

  2. 计算频率:统计文本中每对两两相邻字符的频率。

  3. 合并频率最高的字符对:将频率最高的字符对合并为一个新的子词,并更新文本和词汇表。

  4. 重复步骤 2 和 3:继续合并频率最高的字符对,直到达到预定的词汇表大小或下一对字符对的频率为1。

现在我们通过一个例子来讲解BPE算法的具体流程:

假设我们有一个包含以下四个单词的文本语料库:“ab”、“bc”、“bcd”和“cde”。

// 初始文本
String texts[] = {"ab", "bc", "bcd", "cde"};

// 初始词汇表
String vocab[] = {"a", "b", "c", "d", "e"};

// 步骤 1: 统计字符对频率
// ("a", "b"): 1 次
// ("b", "c"): 2 次
// ("c", "d"): 2 次
// ("d", "e"): 1 次

// 步骤 2: 合并 ("b", "c")
// 新的文本序列
String texts_step2[] = {"ab", "bc", "bc d", "cde"};

// 更新词汇表
String vocab_step2[] = {"a", "b", "c", "d", "e", "bc"};

// 步骤 3: 重新统计字符对频率
// ("a", "b"): 1 次
// ("b", "c"): 1 次 (已合并为 "bc")
// ("b", "d"): 1 次
// ("c", "d"): 2 次
// ("d", "e"): 1 次

// 步骤 4: 合并 ("c", "d")
// 新的文本序列
String texts_step4[] = {"ab", "bc", "b cd", "cd e"};

// 更新词汇表
String vocab_step4[] = {"a", "b", "c", "d", "e", "bc", "cd"};

// 步骤 5: 重新统计字符对频率
// ("a", "b"): 1 次
// ("b", "c"): 1 次
// ("c", "d"): 1 次 (已合并为 "cd")
// ("d", "e"): 1 次
// ("b", "cd"): 1 次
// ("cd", "e"): 1 次

// 步骤 6: 合并 ("cd", "e")
// 新的文本序列
String texts_step6[] = {"ab", "bc", "bc d", "cde"};

// 更新词汇表
String vocab_step6[] = {"a", "b", "c", "d", "e", "bc", "cd", "cde"};

// 步骤 7: 重新统计字符对频率
// ("a", "b"): 1 次
// ("b", "c"): 1 次
// ("c", "d"): 1 次 (已合并为 "cd")
// ("d", "e"): 1 次
// ("b", "cd"): 1 次
// ("cd", "e"): 1 次

// 最终的BPE编码序列
String final_texts[] = {"ab", "bc", "bc d", "cde"};

// 最终词汇表
String final_vocab[] = {"a", "b", "c", "d", "e", "bc", "cd", "cde"};

最终词汇表已经生成

String final_vocab[] = {"a", "b", "c", "d", "e", "bc", "cd", "cde"};
BPE 算法编码步骤(以当前词汇表为例,输入bcd):
  1. 文本初始化:

    将输入的文本字符串分割成字符。例如,对于输入文本 “bcd”,初始表示为 [‘b’, ‘c’, ‘d’]。

  2. 检查词汇表:

    首先检查整个输入单词是否在 BPE 词汇表中。如果在词汇表中,直接返回该单词作为编码结果。例如,如果 “bcd” 在词汇表中,则直接返回 “bcd”。

  3. 逐步计算:

    如果输入单词不在词汇表中,则进行以下步骤:

  4. 查找词汇表:

    在 BPE 词汇表中查找最先匹配的子词。词汇表是根据训练时合并的子词构建的,包含常见的子词和字符对。例如, {“a”, “b”, “c”, “d”, “e”, “bc”, “cd”, “cde”},那么在 “b c d” 中首先匹配 “bc"而不是"cd”(为什么不是b?因为不是字符对啊)。

  5. 合并匹配项:

    将找到的最长匹配项进行合并。例如,将 “bc” 合并为一个子词,结果为 [‘bc’, ‘d’]。

  6. 重复步骤 4 和 5:

    重复查找词汇表中的最长匹配项并进行合并,直到整个输入文本被编码为词汇表中的子词。在本例中已经完成匹配了。

  7. 生成编码结果:

最终的编码结果是由词汇表中的子词组成的序列。例如,对于 “bcd” 可能得到的编码结果是 [‘bc’, ‘d’]。

所以,输入 “bcd” 的输出整数表示为这两个索引值的序列 [5, 3]。这个索引在后续会映射到Embedding。

根据以上提供的关于分词的背景知识我们再来通过注释来详细解读原文代码:

from pathlib import Path # 用于处理文件系统路径
import tiktoken # 用于分词(tokenization)的库
from tiktoken.load import load_tiktoken_bpe # 用于加载 Byte Pair Encoding (BPE) 分词模型的函数
import torch # PyTorch 库,用于深度学习
import json # 用于处理 JSON 数据
import matplotlib.pyplot as plt  # 用于绘制图表
# 指定分词模型的路径
tokenizer_path = "Meta-Llama-3-8B/tokenizer.model"

# 定义特殊标记 special_tokens
# 前十个是特殊字符串,表示特定的特殊标记(如句子的结束、保留标记)
# 之后是一些预留的特殊标记,以 "<|reserved_special_token_x|>" 的形式,从 5 到 250 生成
special_tokens = [
            "<|begin_of_text|>",
            "<|end_of_text|>",
            "<|reserved_special_token_0|>",
            "<|reserved_special_token_1|>",
            "<|reserved_special_token_2|>",
            "<|reserved_special_token_3|>",
            "<|start_header_id|>",
            "<|end_header_id|>",
            "<|reserved_special_token_4|>",
            "<|eot_id|>",  # end of turn
        ] + [f"<|reserved_special_token_{i}|>" for i in range(5, 256 - 5)]
# 加载 BPE 分词模型,返回一个可合并的词汇表排名
mergeable_ranks = load_tiktoken_bpe(tokenizer_path)

# 创建一个 tokenizer 对象
tokenizer = tiktoken.Encoding(
    name=Path(tokenizer_path).name,  # 使用 tokenizer_path 的文件名
    # 一个正则表达式,用于初步分词
    pat_str=r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+",
    mergeable_ranks=mergeable_ranks,
    special_tokens={token: len(mergeable_ranks) + i for i, token in enumerate(special_tokens)},
)

tokenizer.decode(tokenizer.encode("hello world!"))

其中:

pat_str=r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+",

这段正则表达式匹配各种可能的字符串,包括英语缩写、字母、数字、标点符号、换行符和空白字符。通过分解不同的部分,它可以处理复杂的文本分割需求。

另外:

mergeable_ranks=mergeable_ranks

是通过 BPE(Byte Pair Encoding)算法生成的分词表,用于将高频出现的子词单元(subword units)合并以提高分词效率和模型表现。将 mergeable_ranks 赋值给分词器中的 mergeable_ranks 参数,表示使用这个预定义的分词表进行分词处理。这样确保分词器能够按照高频子词单元合并规则准确地分割和处理文本。

那么问题来了,mergeable_ranks本身就可以做分词,为什么还要正则表达式?

正则表达式的作用:

  1. 初步分词:正则表达式用于初步分割文本,将文本分割成更小的、可以处理的片段(如单词、数字、标点符号等)。

  2. 处理特殊模式:正则表达式可以识别和处理特定的文本模式,如英语缩写('s, 't, 're 等),确保这些模式能够正确地分割和处理。

  3. 提高灵活性:正则表达式可以根据具体需求进行调整,以适应不同的语言或文本类型,从而提高分词的灵活性。

mergeable_ranks 的作用:

  1. 基于BPE(Byte Pair Encoding)进行分词:mergeable_ranks 是通过 BPE 算法生成的,可以高效地将初步分割的片段进一步压缩成更少的、更具代表性的子词单元(subword units),从而提高模型的表现。

  2. 频率优先:BPE 分词方法根据词频优先级进行分词,可以将常见的词或词组合并为一个单元,减少分词后的单元数量,提高模型的效率和准确性。

为什么需要两者结合

  1. 多层次分词:正则表达式可以进行初步的、粗略的分词,而 mergeable_ranks 则可以进一步细化分词结果。两者结合使用可以确保分词的全面性和细粒度。

  2. 提高准确性:通过正则表达式的预处理,可以避免一些复杂模式(如缩写、特殊字符等)在分词时出错,提高最终分词结果的准确性。

  3. 适应不同语言:正则表达式可以根据不同语言的特点进行调整,而 mergeable_ranks 则可以根据训练数据生成特定语言的分词规则,这样可以更好地适应不同语言的分词需求。

  4. 在GPT2的论文(3)中作者观察到BPE包含了很多常见词的不同版本,例如“dog”、“dog.”、“dog!”、“dog?”等。这些不同的版本占用了有限的词汇槽和模型容量,导致分配不够优化。正则表达的应用可以确保字符在某些情况下不会被错误地合并,例如标点符号和单词之间。因此,像“dog.”会被正确处理为两个独立的标记:“dog”和“.”,而不是一个整体的标记。

例子

假设我们要分词的句子是 “hello, I’m testing the tokenizer.”:

  1. 正则表达式预处理:将句子分割成 [“hello”, “,”, “I’m”, “testing”, “the”, “tokenizer”, “.”] 这样的初步分词结果。

  2. BPE 细化分词:进一步将初步分词结果细化,例如 [“hello”, “,”, “I”, “'m”, “test”, “ing”, “the”, “token”, “izer”, “.”]。

通过这种方式,可以更准确地处理各种复杂的文本模式,提升分词的质量和模型的表现。

prompt = "the answer to the ultimate question of life, the universe, and everything is "  # 定义要分词的文本
tokens = [128000] + tokenizer.encode(prompt)  # 将文本编码为token,并在开头添加特殊token 128000
print(tokens)  # 输出编码后的token列表
#打印输出如下:
#[128000, 1820, 4320, 311, 279, 17139, 3488, 315, 2324, 11, 279, 15861, 11, 323, 4395, 374, 220]
tokens = torch.tensor(tokens)  # 将token列表转换为张量
prompt_split_as_tokens = [tokenizer.decode([token.item()]) for token in tokens]  # 将每个token解码为文本
print(prompt_split_as_tokens)  # 输出解码后的token列表(原始文本按token分割)
#打印输出如下:
#['<|begin_of_text|>', 'the', ' answer', ' to', ' the', ' ultimate', ' question', ' of', ' life', ',', ' the', ' universe', ',', ' and', ' everything', ' is', ' ']

token 就是词汇表中的索引号。每个 token 对应一个唯一的索引,这个索引在模型的词汇表中进行查找和处理。在编码过程中,文本被转换为这些索引组成的序列。

在这里 128000 被定义为一个特殊的文本起始符号。这个特殊 token 通常用于标识文本的开始,帮助模型理解输入序列的结构和意义。

所以输入的文字

prompt = "the answer to the ultimate question of life, the universe, and everything is " 

通过分词 (Tokenization)输出为包含17个数字的序列(包含特殊的文本起始符、标点符号、最后一个空格符)

[128000, 1820, 4320, 311, 279, 17139, 3488, 315, 2324, 11, 279, 15861, 11, 323, 4395, 374, 220]
['<|begin_of_text|>', 'the', ' answer', ' to', ' the', ' ultimate', ' question', ' of', ' life', ',', ' the', ' universe', ',', ' and', ' everything', ' is', ' ']

至此,输入(Input)和分词器(tokenizer)讲解完毕,下一篇讲嵌入(Embeddings)和均方根归一化(RMS Norm)。

参考:

1.llama3 implemented from scratch https://github.com/naklecha/llama3-from-scratch

2.Umar Jamil – https://github.com/hkproj/pytorch-llama-notes

3.https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf

如何学习AGI大模型?

作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。

👉AGI大模型学习路线汇总👈

大模型学习路线图,整体分为7个大的阶段:(全套教程文末领取哈)

第一阶段: 从大模型系统设计入手,讲解大模型的主要方法;

第二阶段: 在通过大模型提示词工程从Prompts角度入手更好发挥模型的作用;

第三阶段: 大模型平台应用开发借助阿里云PAI平台构建电商领域虚拟试衣系统;

第四阶段: 大模型知识库应用开发以LangChain框架为例,构建物流行业咨询智能问答系统;

第五阶段: 大模型微调开发借助以大健康、新零售、新媒体领域构建适合当前领域大模型;

第六阶段: 以SD多模态大模型为主,搭建了文生图小程序案例;

第七阶段: 以大模型平台应用与开发为主,通过星火大模型,文心大模型等成熟大模型构建大模型行业应用。

👉AGI大模型实战案例👈

光学理论是没用的,要学会跟着一起做,要动手实操,才能将自己的所学运用到实际当中去,这时候可以搞点实战案例来学习。

在这里插入图片描述

👉AGI大模型视频和PDF合集👈

观看零基础学习书籍和视频,看书籍和视频学习是最快捷也是最有效果的方式,跟着视频中老师的思路,从基础到深入,还是很容易入门的。
在这里插入图片描述
在这里插入图片描述

👉学会后的收获:👈

• 基于大模型全栈工程实现(前端、后端、产品经理、设计、数据分析等),通过这门课可获得不同能力;

• 能够利用大模型解决相关实际项目需求: 大数据时代,越来越多的企业和机构需要处理海量数据,利用大模型技术可以更好地处理这些数据,提高数据分析和决策的准确性。因此,掌握大模型应用开发技能,可以让程序员更好地应对实际项目需求;

• 基于大模型和企业数据AI应用开发,实现大模型理论、掌握GPU算力、硬件、LangChain开发框架和项目实战技能, 学会Fine-tuning垂直训练大模型(数据准备、数据蒸馏、大模型部署)一站式掌握;

• 能够完成时下热门大模型垂直领域模型训练能力,提高程序员的编码能力: 大模型应用开发需要掌握机器学习算法、深度学习框架等技术,这些技术的掌握可以提高程序员的编码能力和分析能力,让程序员更加熟练地编写高质量的代码。

👉获取方式:

这份完整版的大模型 AGI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐