分词和嵌入是使用大型语言模型(LLMs)的两个核心概念。正如我们在第一章中所看到的,它们不仅对于理解语言AI的历史非常重要,而且如果我们没有对词元和嵌入有一个良好的认识,我们就无法清楚地了解LLMs的工作原理、它们是如何构建的,以及它们将来会走向何方,如图2-1所示。
图 2-1. 语言模型处理文本时,是按小块(称为“词元”)进行的。为了让语言模型计算语言,它需要将词元转换为称为“嵌入”的数值表示。
在本章中,我们将更仔细地研究什么是词元以及用于支持大型语言模型(LLM)的分词方法。然后,我们将深入研究著名的word2vec嵌入方法,该方法早于现代LLM,并了解它如何扩展词元嵌入的概念,以构建商业推荐系统,这些系统支持您使用的许多应用程序。最后,我们从词元嵌入转向句子或文本嵌入,在这里整个句子或文档可以有一个代表它的向量——使得我们在这本书的第二部分看到的诸如语义搜索和主题建模等应用成为可能。
LLM分词
在撰写本文时,大多数人使用语言模型的方式是通过一个网络游乐场,该游乐场呈现了用户和语言模型之间的聊天界面。您可能会注意到,模型并不会一次性产生所有的输出响应;实际上,它是一次生成一个词元(token)。
但是词元不仅仅是模型的输出,它们也是模型查看其输入的方式。发送给模型的文本提示首先会被分解成词元,我们现在将看到这一点。
分词器如何准备语言模型的输入
从外部来看,生成式大型语言模型(LLM)接收一个输入提示并生成响应,如图2-2所示。
图2-2. 语言模型及其输入提示的高层次视图
然而,在提示呈现给语言模型之前,它首先必须经过一个分词器将其分解成若干部分。你可以在OpenAI平台上找到一个展示GPT-4分词器的例子。如果我们给它输入文本,它会在图2-3中显示输出,其中每个token都显示为不同的颜色。
图 2-3. 分词器在模型处理文本之前将文本分解成单词或单词的部分。它是根据特定的方法和训练程序完成的(来自 https://oreil.ly/ovUWO)。
让我们来看一个代码示例,并亲自与这些词元进行交互。在这里,我们将下载一个大型语言模型(LLM),并了解如何在用LLM生成文本之前对输入进行分词。
下载和运行一个LLM
让我们像在第1章中所做的那样,首先加载我们的模型及其分词器:
from transformers import AutoModelForCausalLM, AutoTokenizer
# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained(
"microsoft/Phi-3-mini-4k-instruct",
device_map="cuda",
torch_dtype="auto",
trust_remote_code=True,
)
tokenizer = AutoTokenizer.from_pretrained("microsoft/Phi-3-mini-4k-instruct")
然后我们可以继续进行实际的生成。我们首先声明我们的提示,然后对其进行分词,然后将这些词元传递给模型,模型会生成其输出。在这种情况下,我们要求模型只生成20个新的词元:
prompt = "Write an email apologizing to Sarah for the tragic gardening mishap.
Explain how it happened.<|assistant|>"
# Tokenize the input prompt
input_ids = tokenizer(prompt, return_tensors="pt").input_ids.to("cuda")
# Generate the text
generation_output = model.generate(
input_ids=input_ids,
max_new_tokens=20
)
# Print the output
print(tokenizer.decode(generation_output[0]))
输出:
<s> Write an email apologizing to Sarah for the tragic gardening mishap. Explain how it happened.<|assistant|> Subject: My Sincere Apologies for the Gardening Mishap Dear |
加粗的文本是模型生成的20个词元。
查看代码,我们可以看到模型实际上并没有接收到文本提示。相反,分词器处理了输入提示,并在变量input_ids中返回了模型所需的信息,模型将其用作其输入。
让我们打印input_ids以查看其内部的内容:
tensor([[ 1, 14350, 385, 4876, 27746, 5281, 304, 19235, 363, 278, 25305, 293, 16423, 292, 286, 728, 481, 29889, 12027, 7420, 920, 372, 9559, 29889, 32001]], device='cuda:0') |
这揭示了LLM(大型语言模型)所响应的输入,如图2-4所示,是一系列整数。每一个整数都是特定词元(字符、单词或单词的一部分)的唯一ID。这些ID引用分词器内部的一个表,该表包含它所知道的所有词元。
图 2-4. 分词器处理输入提示并为语言模型准备实际输入:一组词元ID。图中的具体词元ID仅为示例
如果我们想检查这些ID,我们可以使用分词器的decode方法将ID转换回我们可以阅读的文本:
for id in input_ids[0]:
print(tokenizer.decode(id))
这会打印(每个词元都在单独的一行上)
这就是分词器如何分解我们的输入提示。请注意以下几点:
• 第一个词元是 ID 1: (<s>),一个表示文本开始的特殊词元。
• 有些词元是完整的单词(例如,Write,an,email)。
• 有些词元是单词的一部分(例如,apolog,izing,trag,ic)。
• 标点符号字符是它们自己的词元。
请注意,空格字符没有自己的词元。相反,部分词元(如“izing”和“ic”)在它们的开头有一个特殊的隐藏字符,表示它们与文本中前面的词元相连。没有该特殊字符的词元被认为前面有一个空格。
在输出方面,我们还可以通过打印 generation_output 变量来检查模型生成的词元。这将显示输入词元以及输出词元(我们将以粗体突出显示新词元):
tensor([[ 1, 14350, 385, 4876, 27746, 5281, 304, 19235, 363, 278,
25305, 293, 16423, 292, 286, 728, 481, 29889, 12027, 7420,
920, 372, 9559, 29889, 32001, 3323, 622, 29901, 1619, 317,
3742, 406, 6225, 11763, 363, 278, 19906, 292, 341, 728,
481, 13, 13, 29928, 799]], device='cuda:0')
这向我们展示了模型生成了词元3323,“Sub”,接着是词元622,“ject”。它们一起组成了单词“Subject”。然后它们后面跟着词元29901,即冒号':'……等等。就像在输入端一样,我们在输出端也需要分词器将词元ID翻译成实际文本。我们通过分词器的decode方法来实现这一点。我们可以传递一个单独的词元ID或它们的列表:
print(tokenizer.decode(3323))
print(tokenizer.decode(622))
print(tokenizer.decode([3323, 622]))
print(tokenizer.decode(29901))
此输出:
Sub
ject
Subject
:
分词器是如何分解文本的?
有三大因素决定了分词器如何分解输入提示。
首先,在模型设计时,模型的创建者选择了一种分词方法。常见的方法包括字节对编码(BPE)(GPT模型广泛使用)和WordPiece(BERT使用)。这些方法的相似之处在于它们旨在优化一组有效的词元来表示文本数据集,但它们以不同的方式实现这一目标。
其次,在选择方法后,我们需要做出许多分词器设计选择,如词汇表大小和使用的特殊词元。更多关于这方面的内容,请参见第46页的“比较训练有素的大型语言模型分词器”。
- 分词器需要在特定数据集上进行训练,以便建立最佳词汇表来表示该数据集。即使我们设置相同的方法和参数,一个在英文文本数据集上训练的分词器也会与在代码数据集或多语言文本数据集上训练的分词器不同。
- 除了用于将输入文本处理成语言模型外,分词器还用于语言模型的输出,将生成的词元ID转换为与之关联的输出单词或词元,如图2-5所示。
图 2-5. 分词器也用于通过将输出词元 ID 转换为与该 ID 关联的单词或词元来处理模型的输出
词与子词与字符与字节词元
我们刚刚讨论的分词方案叫做子词分词。它是最常用的分词方案,但不是唯一的。图2-6展示了四种著名的分词方法。让我们来看看它们:
单词词元
这种方法在早期的word2vec等方法中很常见,但在自然语言处理(NLP)中使用的越来越少。然而,它的实用性使其在推荐系统等用例中在NLP之外得到应用,我们将在本章后面看到。词元分词的一个挑战是,分词器可能无法处理在训练后进入数据集的新词。这也导致词汇表中有许多具有微小差异的词元(例如,apology,apologize,apologetic,apologist)。子词分词解决了这一挑战,因为它有一个apolog词元,然后是常见的后缀词元(例如,-y,-ize,-etic,-ist),这些后缀词元与许多其他词元通用,从而形成了一个更具表现力的词汇表。
子词元
这种方法包含完整的和部分的单词。除了之前提到的词汇表表现力之外,这种方法还有一个好处,就是能够通过将新词分解成较小的字符来表示新词,这些较小的字符往往是词汇表的一部分。
图 2-6有多种分词方法可以将文本分解为不同大小的组件(单词、子词、字符和字节)
字符词元
这是另一种可以成功处理新词的方法,因为它有原始字母作为依靠。这使得表示更容易进行分词,但使得建模更加困难。使用子词分词的模型可以将“play”表示为一个词元,而使用字符级词元的模型需要建模拼写“p-l-a-y”的信息以及序列的其余部分。
子词词元在能够在Transformer模型的有限上下文长度内容纳更多文本方面具有优势。因此,对于一个上下文长度为1,024的模型,使用子词分词可能比使用字符词元多容纳大约三倍的文本(子词词元通常平均每个词元三个字符)。
字节词元
另一种分词方法将词元分解为用于表示unicode字符的单个字节。像 “CANINE: Pretraining an efficient tokenization-free encoder for language representation”这样的论文概述了这种方法,这也被称为“无分词编码”。其他工作,如“ByT5: Towards a token-free future with pre-trained byte-to-byte models”,表明这可以是一种具有竞争力的方法,特别是在多语言场景中。
这里需要强调的一个区别是:一些子词分词器在其词汇表中也包括字节作为词元,作为当它们遇到无法以其他方式表示的字符时可以回退到的最终构建块。例如,GPT-2 和 RoBERTa 分词器就是这样做的。这并没有使它们成为无分词的字节级分词器,因为它们并不使用这些字节来表示所有内容,而只是表示一部分,我们将在下一节中看到。如果你想深入了解分词器,它们在设计大型语言模型应用中有更详细的讨论。
比较训练好的大型语言模型(LLM)分词器
我们之前已经指出了决定分词器中出现的词元的三个主要因素:分词方法、用于初始化分词器的参数和特殊词元,以及分词器训练的数据集。让我们比较和对比一些实际的、经过训练的分词器,看看这些选择如何改变它们的行为。这次比较将向我们展示,新的分词器已经改变了它们的行为以提高模型性能,我们还将看到专业化的模型(例如代码生成模型)通常需要专业的分词器。
我们将使用多个分词器来编码以下文本:
text = """
English and CAPITALIZATION
��鸟
show_tokens False None elif == >= else: two tabs:" " Three tabs: " "
12.0*50=600
"""
这将使我们能够看到每个分词器如何处理多种不同类型的词元:
• 大小写。
• 非英语语言。
• 表情符号。
• 编程代码,其中包含关键字和通常用于缩进的空白(例如在 Python 等语言中)。
• 数字和数位。
• 特殊符号。这些是具有代表文本之外的其他作用的独特符号。它们包括表示文本开头或文本结尾的符号(这是模型向系统发出已完成此生成的信号的方式),以及我们将会看到的其他功能。
让我们从较旧的词元器到较新的词元器来看它们如何词元这段文本,以及这可能对语言模型有什么意义。我们将词元这段文本,然后使用这个函数以彩色背景颜色打印每个词元:
colors_list = [
'102;194;165', '252;141;98', '141;160;203',
'231;138;195', '166;216;84', '255;217;47'
]
def show_tokens(sentence, tokenizer_name):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
token_ids = tokenizer(sentence).input_ids
for idx, t in enumerate(token_ids):
print(
f'\x1b[0;30;48;2;{colors_list[idx % len(colors_list)]}m' +
tokenizer.decode(t) +
'\x1b[0m',
end=' '
)
BERT基础模型(未分大小写)(2018年)
分词方法:WordPiece,引入于“日语和韩语语音搜索”:
词汇表大小:30,522
特殊符号:
unk_token [UNK]
一个未知符号,分词器没有特定的编码。
sep_token [SEP]
一个分隔符,使得某些需要给模型两个文本的任务成为可能(在这些情况下,模型被称为交叉编码器)。一个例子是重排序,正如我们将在第8章看到的。
pad_token [PAD]
一个填充符号,用于填充模型输入中未使用的位置(因为模型期望一定长度的输入,即其上下文大小)。
cls_token [CLS]
一个用于分类任务的特殊分类词元,正如我们将在第4章中看到的。
mask_token [MASK]
一个在训练过程中用于隐藏词元的掩码词元。
分词文本:
[CLS] english and capital ##ization [UNK] [UNK] show _ token ##s false none eli ##f = = > = else : two tab ##s : " " three tab ##s : " " 12 . 0 * 50 = 600 [SEP]
BERT发布了两个主要版本:区分大小写(保留大写字母)和不区分大小写(将所有大写字母转换为小写字母)。在使用不区分大小写(更受欢迎)版本的BERT分词器时,我们注意到以下几点:
• 新行分隔符消失了,这使得模型对编码在新行中的信息(例如,每轮对话都在新行中的聊天记录)视而不见。
• 所有文本都是小写的。
• 单词“capitalization”被编码为两个子词元:capital ##ization。##字符用于表示这个子词元与前一个词元相连。这也是一种表示空格位置的方法,因为假设没有##在前的词元前面有一个空格。
• 表情符号和汉字被替换为[UNK]特殊词元,表示“未知词元”。
BERT基础模型(区分大小写)(2018年)
分词方法:WordPiece
词汇量大小:28,996
特殊符号:与未分大小写版本相同
分词后的文本:
[CLS] English and CA ##PI ##TA ##L ##I ##Z ##AT ##ION [UNK] [UNK] show _ token ##s F ##als ##e None el ##if = = > = else : two ta ##bs : " " Three ta ##bs : " " 12 . 0 * 50 = 600 [SEP]
BERT 分词器的分词版本主要区别在于包含大写词元。
• 注意“CAPITALIZATION”现在表示为八个词元:CA ##PI ##TA ##L ##I ##Z ##AT ##ION。
• BERT分词器都会在输入的开头加上[CLS]词元,结尾加上[SEP]词元。[CLS]和[SEP]是实用词元,用于包装输入文本它们有自己的用途。[CLS]代表分类,因为它是用于句子分类。[SEP]代表分隔符,因为它用于在一些需要将两个句子传递给模型的应用中分隔句子(例如,在第8章中,我们将使用[SEP]词元来分隔查询文本和候选结果。)
GPT-2 (2019)
分词方法:字节对编码(BPE),在“Neural machine translation of rare words with subword units”中引入
词汇量大小:50,257
特殊符号:<|endoftext|>
English and CAP ITAL IZ ATION
show _ t ok ens False None el if == >= else : two tabs :" " Three tabs : " "
12 . 0 * 50 = 600
使用GPT-2分词器,我们注意到以下几点:
• 换行符在分词器中有所表示。
• 大小写被保留,单词“CAPITALIZATION”被表示为四个token。
• 字符现在由多个token表示。虽然我们看到这些token打印为□字符,但它们实际上代表不同的token。例如,��表情符号被分解为token ID为8582、236和113的token。分词器成功地从这些token中重构了原始字符。我们可以通过打印tokenizer.decode([8582, 236, 113])来看到这一点,这将输出��。
• 两个制表符被表示为两个token(在该词汇表中的token编号为197),四个空格被表示为三个token(编号为220),最后一个空格是结束引号字符token的一部分。
• 两个制表符被表示为两个token(在该词汇表中的token编号为197),四个空格被表示为三个token(编号为220),最后一个空格是结束引号字符token的一部分。
空白字符的意义是什么?这些对于模型理解或生成代码非常重要。一个使用单个词元来表示四个连续空白字符的模型更适合处理Python代码数据集。虽然模型可以将其表示为四个不同的词元,但这确实使建模更加困难,因为模型需要跟踪缩进级别,这通常会导致性能下降。这是一个例子,说明分词选择可以帮助模型在某个任务上取得改进。
Flan-T5(2022)
分词方法:Flan-T5 使用一种称为 SentencePiece 的分词器实现,该分词器在“SentencePiece: A simple and language independent subword tokenizer and detokenizer for neural text processing”一文中描述, 支持 BPE 和单元模型(在“Subword regularization: Improving neural network translation models with multiple subword candidates”中描述)。
词汇量大小:32,100
特殊符号:
• unk_token <unk>
• pad_token <pad>
分词后的文本:
English and CA PI TAL IZ ATION <unk> <unk> show _ to ken s Fal s e None e l if = = > = else : two tab s : " " Three tab s : " " 12. 0 * 50 = 600 </s>
Flan-T5系列模型使用SentencePiece方法。我们注意到以下情况:
• 没有换行符或空白符词元;这会使模型处理代码变得具有挑战性。
• 表情符号和汉字都被替换为<unk>词元,使模型完全对它们视而不见。
GPT-4 (2023)
分词方法:BPE
词汇量:略超过100,000
特殊符号:
• <|endoftext|>
• 填充中间词元。这三个词元使大型语言模型(LLM)能够生成一个补全,不仅考虑它之前的文本,而且还考虑它之后的文本。这种方法在论文《Efficient training of language models to fill in the middle》中有更详细的解释;其具体细节超出了本书的范围。这些特殊的词元是:
— <|fim_prefix|>
— <|fim_middle|>
— <|fim_suffix|>
分词后的文本:
English and CAPITAL IZATION
show _tokens False None elif == >= else : two tabs :" " Three tabs : " " 12 . 0 * 50 = 600
GPT-4 分词器的行为与其祖先 GPT-2 分词器类似。它们之间的一些区别包括:
• GPT-4 分词器将四个空格表示为一个词元。实际上,它对于每一种连续的空白符序列都有一个特定的词元,最多可表示 83 个连续的空白符。
• Python 关键字 elif 在 GPT-4 中有自己的词元。这一点和前面的内容都源于该模型不仅关注自然语言,还关注代码。
• GPT-4 分词器使用更少的词元来表示大多数单词。这里的例子包括“CAPITALIZATION”(两个词元对比四个)和“tokens”(一个词元对比三个)。
• 回顾我们关于 GPT-2 分词器以及词元的相关内容。
StarCoder2 (2024)
StarCoder2 是一个拥有 150 亿参数的模型,专注于生成代码,该模型在论文《StarCoder 2 and the stack v2: The next generation》中进行了描述,该论文延续了原始 StarCoder 的工作,原始 StarCoder 在《StarCoder: May the source be with you!》中进行了描述。
分词方法:字节对编码(BPE)
词汇表大小:49,152
示例特殊词元:
• <|endoftext|>
• 填充中间词元:
— <fim_prefix>
— <fim_middle>
— <fim_suffix>
— <fim_pad>
• 在表示代码时,管理上下文很重要。一个文件可能会对在不同文件中定义的函数进行函数调用。因此,模型需要某种方法来能够识别同一代码存储库中不同文件中的代码,同时区分不同存储库中的代码。这就是为什么 StarCoder2 使用特殊词元来表示存储库名称和文件名:
— <filename>
— <reponame>
— <gh_stars>
词元文本:
English and CAPITAL IZATION
show _ tokens False None elif == >= else : two tabs :" " Three tabs : " "
1 2 . 0 * 5 0 = 6 0
这是一个专注于代码生成的编码器:
• 类似于GPT-4,它将空白字符列表编码为单个词元。
• 这里与我们迄今为止所看到的一切的一个主要区别是,每个数字都被分配了自己的词元(因此600变成了6 0 0)。这里的假设是,这将导致更好地表示数字和数学。例如,在GPT-2中,数字870表示为单个词元。但是871表示为两个词元(8和71)。你可以直观地看到这可能会让模型以及它如何表示数字感到困惑。
Galactica
《Galactica:一个用于科学的的大型语言模型》中描述的Galactica模型专注于科学知识,训练数据包括大量科学论文、参考资料和知识库。它格外关注分词,这使得它对其所代表数据集的细微差别更加敏感。例如,它包括了用于引用、推理、数学、氨基酸序列和DNA序列的特殊词元。
分词方法:字节对编码(BPE)
词汇量大小:50,000
特殊符号:
• <s>
• <pad>
• </s>
• <unk>
• 引用:引用被包含在两个特殊符号之间:
— [START_REF]
— [END_REF]
— 论文中的一个使用示例是:循环神经网络,长短期记忆[START_REF]长短期记忆,Hochreiter[END_REF]
• 逐步推理:
— <work> 是模型用于思维链推理的一个有趣的符号。
词元文本:
English and CAP ITAL IZATION
show _ tokens False None elif == > = else : two t abs : " " Three t abs : " "
1 2 . 0 * 5 0 = 6 0 0
Galactica 分词器的行为与 StarCoder2 类似,因为它考虑了代码。它还以相同的方式编码空白字符:为不同长度的空白字符序列分配单个词元。不同之处在于,它还对制表符执行相同的操作。因此,在我们迄今为止看到的所有分词器中,它是唯一一个将两个制表符('\t\t')组成的字符串分配给单个词元的分词器。
Phi-3(和Llama 2)
我们在本书中研究的Phi-3模型重用了Llama 2的分词器,但增加了一些特殊词元
分词方法:字节对编码(BPE)
词汇量:32,000
特殊符号:
• <|endoftext|>
• 聊天符号:随着聊天式大型语言模型(LLM)在2023年变得流行,LLM的对话性质开始成为一个领先的使用场景。分词器通过添加表示对话轮次和每个发言者角色的符号来适应这一方向。这些特殊符号包括:
— <|user|>
— <|assistant|>
— <|system|>
我们现在可以通过并排查看所有这些示例来回顾我们的参观:
BERT base model (uncased) | [CLS] english and capital ##ization [UNK] [UNK] show _ token ##s false none eli ##f = = > = else : two tab ##s : " " three tab ##s : " " 12 . 0 * 50 = 600 [SEP] |
BERT base model (cased) | [CLS] English and CA ##PI ##TA ##L ##I ##Z ##AT ##ION [UNK] [UNK] show _ token ##s F ##als ##e None el ##if = = > = else : two ta ##bs : " " Three ta ##bs : " " 12 . 0 * 50 = 600 [SEP] |
GPT-2 | English and CAP ITAL IZ ATION show _ t ok ens False None el if == >= else : two tabs :" " Three tabs : " " 12 . 0 * 50 = 600 |
FLAN-T5 | English and CA PI TAL IZ ATION <unk> <unk> show _ to ken s Fal s e None e l if = = > = else : two tab s : " " Three tab s : " " 12. 0 * 50 = 600 </s> |
GPT-4 | English and CAPITAL IZATION show _tokens False None elif == >= else : two tabs :" " Three tabs : " " 12 . 0 * 50 = 600 |
StarCoder Galactica | English and CAP ITAL IZATION show _ tokens False None elif == > = else : two t abs : " " Three t abs : " " 1 2 . 0 * 5 0 = 6 0 0 |
Phi-3 and Llama 2 | English and C AP IT AL IZ ATION show _ to kens False None elif == >= else : two tabs :" " Three tabs : " " 1 2 . 0 * 5 0 = 6 0 0 |
分词器属性
前述的训练有素的词元器导览展示了实际词元器之间的诸多差异。但是,是什么决定了它们的分词行为呢?有三组主要的设计选择决定了词元器将如何分解文本:分词方法、初始化参数,以及词元器目标的数据领域。
分词方法
正如我们所见,有许多分词方法,其中字节对编码(BPE)是较受欢迎的一种。这些方法中的每一种都概述了一种算法,用于如何选择一组适当的词元来表示数据集。你可以在Hugging Face的页面上找到对这些方法的一个很好的概述,该页面总结了所有的分词器。
分词器参数
在选择分词方法后,大型语言模型(LLM)的设计师需要决定一些关于分词器参数的决策。这些包括:
词汇表大小
分词器的词汇表中要保留多少个词元?(30K和50K经常被用作词汇表大小值,但我们越来越多地看到像100K这样更大的大小。)
特殊词元
我们希望模型跟踪哪些特殊词元?我们可以添加任意数量的这些特殊词元,特别是如果我们想为特殊用例构建一个LLM的话。
常见的选择包括:
• 文本开始词元(例如,<s>)
• 文本结束词元
• 填充词元
• 未知词元
• CLS词元
• 掩码词元
除了这些,LLM 设计者还可以添加有助于更好地建模他们试图关注的问题的领域的词元,正如我们在 Galactica 的 <work> 和 [START_REF] 词元中所看到的那样。
大写
在像英语这样的语言中,我们如何处理大写?
我们应该将所有内容都转换为小写吗?(名称的大写通常携带有用信息,但我们是否希望浪费词元词汇空间来表示全大写的单词?)
数据领域
即使我们选择相同的方法和参数,分词器的行为也会因为其训练的数据集(在我们开始模型训练之前)而有所不同。之前提到的分词方法通过优化词汇表来表示特定数据集。在我们的引导之旅中,我们已经看到了这对诸如代码和多语言文本等数据集的影响。
例如,对于代码,我们已经看到,一个以文本为中心的分词器可能会像这样对缩进空格进行分词(我们将用颜色突出显示一些词元):
这可能对于以代码为中心的模型来说并不是最佳的。通过采用不同的分词选择,通常可以改进以代码为中心的模型:
这些分词选择使模型的工作变得更容易,因此其性能有更高的概率得到提升。
你可以在以下文档中找到更详细的关于训练分词器的教程:
Tokenizers section of the Hugging Face course ;
Natural Language Processing with Transformers, Revised Edition.
词元嵌入
现在我们理解了分词,我们已经解决了向语言模型表示语言问题的一个部分。在这个意义上,语言是一个词元序列。如果我们在足够大的词元集上训练一个足够好的模型,它开始捕捉其训练数据集中出现的复杂模式:
• 如果训练数据包含大量英语文本,这种模式表现为一个能够表示和生成英语语言的模型。
• 如果训练数据包含事实信息(例如维基百科),模型将具有生成一些事实信息的能力(见下面的注释)。
解开谜题的下一块是找到这些词元的最佳数值表示,以便模型可以用来计算和正确地建模文本中的模式。这些模式向我们表现为模型在特定语言中的一致性,或编码能力,或我们对语言模型的期望不断增长的任何能力。
正如我们在第1章中所看到的,这就是嵌入。它们是用来捕捉语言中的意义和模式的数值表示空间。
注意:当语言模型达到良好的连贯性阈值并展现出优于平均水平的事实生成能力时,新的问题开始显现。部分用户开始过度信任模型的事实生成能力(例如2023年初某些语言模型曾被称为"谷歌杀手")。但很快,资深用户便意识到单纯依赖生成模型无法实现可靠的搜索引擎功能。这种认知推动了检索增强生成(Retrieval-augmented Generation, RAG)技术的兴起——该技术将搜索引擎能力与大型语言模型相结合。我们将在第八章对此进行更详细的探讨。
一个语言模型为其分词器的词汇表持有嵌入向量
在分词器初始化并训练完成后,它会被用于其关联的语言模型的训练过程中。这就是为什么预训练的语言模型与其分词器紧密相连,不能在没有重新训练的情况下使用不同的分词器。语言模型为分词器词汇表中的每个token保存了一个嵌入向量,如图2-7所示。当我们下载一个预训练的语言模型时,模型的一部分就是这个包含所有这些向量的嵌入矩阵。
在训练过程开始之前,这些向量像模型的其他权重一样被随机初始化,但训练过程会为它们分配值,使它们能够执行它们被训练要执行的有用行为。
图 2-7. 语言模型为其分词器中的每个词元持有相关的嵌入向量。
使用语言模型创建上下文化的词嵌入
现在我们已经了解了作为语言模型输入的词元嵌入,让我们来看看语言模型如何创建更好的词元嵌入。这是使用语言模型进行文本表示的主要方法之一。这使得诸如命名实体识别或抽取式文本摘要(通过突出显示长文本最重要的部分来总结,而不是生成新文本作为摘要)等应用成为可能。
与用静态向量表示每个词元或单词不同,语言模型创建上下文化的词嵌入(如图2-8所示),这些词嵌入根据上下文用不同的词元表示一个单词。然后,这些向量可以被其他系统用于各种任务。除了我们在前一段中提到的文本应用外,这些上下文化的向量还是驱动AI图像生成系统(如DALL·E、Midjourney和Stable Diffusion)的动力。
图 2-8. 语言模型生成上下文化的词元嵌入,改进了原始的静态词元嵌入。
让我们看看如何生成上下文化的词嵌入;到你现在应该熟悉这段代码的大部分内容了:
from transformers import AutoModel, AutoTokenizer
# Load a tokenizer
tokenizer = AutoTokenizer.from_pretrained("microsoft/deberta-base")
# Load a language model
model = AutoModel.from_pretrained("microsoft/deberta-v3-xsmall")
# Tokenize the sentence
tokens = tokenizer('Hello world', return_tensors='pt')
# Process the tokens
output = model(**tokens)[0]
我们在这里使用的模型称为DeBERTa v3,在撰写本文时,它是性能最好的语言模型之一,用于生成词元嵌入,同时体积小且高效。该模型在论文《DeBERTaV3: Improving DeBERTa using ELECTRA-style pre-training gradient-disentangled embedding sharing》中有详细描述。
这段代码下载一个预训练的分词器和模型,然后使用它们来处理字符串“Hello world”。模型的输出随后被保存在变量output中。让我们首先通过打印其维度(我们期望它是一个多维数组)来检查该变量:
output.shape
这将打印出:
torch.Size([1, 4, 384])
跳过第一个维度,我们可以将其理解为四个词元,每个词元都嵌入在一个包含384个值的向量中。第一个维度是批量维度,在某些情况下(如训练)我们希望同时将多个输入句子发送给模型时使用(它们同时进行处理,从而加快了处理速度)。但是这四个向量是什么呢?分词器是将这两个单词分解成了四个词元,还是这里发生了其他事情?我们可以利用我们所学到的关于分词器的知识来检查它们:
for token in tokens['input_ids'][0]:
print(tokenizer.decode(token))
这将打印出:
[CLS]
Hello
world
[SEP]
这个特定的分词器和模型通过在字符串的开头和结尾添加[CLS]和[SEP]词元来运行。
我们的语言模型现在已经处理了文本输入。它的输出结果是以下内容:
tensor([[
[-3.3060, -0.0507, -0.1098, ..., -0.1704, -0.1618, 0.6932],
[ 0.8918, 0.0740, -0.1583, ..., 0.1869, 1.4760, 0.0751],
[ 0.0871, 0.6364, -0.3050, ..., 0.4729, -0.1829, 1.0157],
[-3.1624, -0.1436, -0.0941, ..., -0.0290, -0.1265, 0.7954]
]], grad_fn=<NativeLayerNormBackward0>)
这是语言模型的原始输出。大型语言模型的应用就是建立在这种输出之上的。我们在图2-9中回顾了语言模型的输入分词和产生的输出。从技术上讲,从词元ID转换为原始嵌入是语言模型内部发生的第一个步骤。
图2-9语言模型以其输入的原始静态嵌入为基础进行操作,并生成上下文文本嵌入。
像这样的视觉效果对于下一章节至关重要,届时我们将开始探讨基于Transformer的大型语言模型(LLM)是如何工作的。
文本嵌入(针对句子和整个文档)
虽然词元嵌入是大型语言模型(LLM)操作的关键,但许多LLM应用需要处理整个句子、段落甚至文本文档。这导致了特殊的语言模型产生文本嵌入——一个代表长于一个词元的文本片段的单一向量。
我们可以将文本嵌入模型视为取一段文本并最终产生一个代表该文本的单一向量,并以某种有用的形式捕捉其含义。图2-10展示了该过程。
图2-10在第1步中,我们使用嵌入模型提取特征并将输入文本转换为嵌入。
有多种方法可以生成文本嵌入向量。其中最常见的方法是对模型生成的所有词元嵌入值求平均值。然而,高质量的文本嵌入模型往往专门针对文本嵌入任务进行训练。
我们可以使用sentence-transformers包生成文本嵌入,这是一个用于利用预训练嵌入模型的流行包1。该包与上一章中的transformers一样,可用于加载公开可用的模型。为了说明创建嵌入,我们使用了all-mpnet-base-v2模型。请注意,在第4章中,我们将进一步探讨如何为您的任务选择合适的嵌入模型
from sentence_transformers import SentenceTransformer
# Load model
model = SentenceTransformer("sentence-transformers/all-mpnet-base-v2")
# Convert text to text embeddings
vector = model.encode("Best movie ever!")
嵌入向量的值的数量或维度取决于底层嵌入模型。让我们来探索一下我们的模型的情况:
vector.shape
(768,)
1 Nils Reimers and Iryna Gurevych. “Sentence-BERT: Sentence embeddings using Siamese BERT-networks.” arXiv preprint arXiv:1908.10084 (2019).
这个句子现在被编码在一个维度为768个数值的向量中。在这本书的第二部分,一旦我们开始研究应用,我们将开始看到这些文本嵌入向量在推动从分类到语义搜索到RAG等一切方面的巨大效用。
超越LLMs的词嵌入
嵌入不仅在文本和语言生成之外有用。嵌入,或者说为对象分配有意义的向量表示,在许多领域(包括推荐引擎和机器人技术)中都很有用。在本节中,我们将了解如何使用预训练的word2vec嵌入,并简要介绍该方法如何创建词嵌入。了解word2vec是如何训练的将为你在第10章学习对比训练做好准备。然后在下一节中,我们将看到如何将这些嵌入用于推荐系统。
使用预训练的词嵌入
让我们看看如何使用Gensim库下载预训练的词嵌入(如word2vec或GloVe):
import gensim.downloader as api
# Download embeddings (66MB, glove, trained on wikipedia, vector size: 50)
# Other options include "word2vec-google-news-300"
# More options at https://github.com/RaRe-Technologies/gensim-data
model = api.load("glove-wiki-gigaword-50")
在这里,我们已经下载了在维基百科上训练的大量单词的嵌入。然后,我们可以通过查看特定单词(例如“国王”)的最近邻居来探索嵌入空间:
model.most_similar([model['king']], topn=11
输出:
[('king', 1.0000001192092896),
('prince', 0.8236179351806641),
('queen', 0.7839043140411377),
('ii', 0.7746230363845825),
('emperor', 0.7736247777938843),
('son', 0.766719400882721),
('uncle', 0.7627150416374207),
('kingdom', 0.7542161345481873),
('throne', 0.7539914846420288),
('brother', 0.7492411136627197),
('ruler', 0.7434253692626953)]
Word2vec算法与对比训练
在《Efficient estimation of word representations in vector space》这篇论文中描述的word2vec算法在《The Illustrated Word2vec》中有详细的介绍。这里我们概括了其中的核心思想,因为在下一节讨论为推荐引擎创建嵌入的一种方法时,我们将基于这些思想。就像大型语言模型(LLMs)一样,word2vec也是通过在文本上生成的示例进行训练的。例如,我们有一段来自弗兰克·赫伯特的《沙丘》小说中的文本:“Thou shalt not make a machine in the likeness of a human mind”。该算法使用滑动窗口生成训练示例。例如,我们可以设置窗口大小为2,这意味着我们考虑中心词两侧各有两个邻居。
嵌入是从一个分类任务中生成的。这个任务用于训练神经网络预测单词是否通常出现在相同的上下文中(这里的上下文是指在我们正在建模的训练数据集中的许多句子中)。我们可以将其视为一个神经网络,它接受两个单词作为输入,如果它们倾向于出现在相同的上下文中,则输出1,否则输出0。
在滑动窗口的第一个位置,我们可以生成四个训练示例,如图2-11所示。
图2-11 滑动窗口用于生成word2vec算法的训练样本,以便稍后预测两个词是否为邻居
在每个生成的训练样本中,中心的词被用作一个输入,而它的每个邻居在每个训练样本中都是一个不同的第二个输入。我们期望最终训练好的模型能够分类这种邻居关系,并在其接收到的两个输入词确实是邻居时输出1。这些训练样本在图2-12中进行了可视化。
图2-12 每个生成的训练样本显示了一对相邻的单词。
然而,如果我们只有一个目标值为1的数据集,那么模型可以通过一直输出1来作弊并通过测试。为了解决这个问题,我们需要用通常不是邻居的单词示例来丰富我们的训练数据集。这些被称为负面示例,在图2-13中展示。
图 2-13 我们需要向我们的模型提供负面示例:通常不是邻居的单词。更好的模型能够更好地区分正面和负面示例。
事实证明,在选择负面示例时,我们不必过于科学。许多有用的模型源于从随机生成的示例中检测正面示例的简单能力(这一重要思想受到噪声对比估计的启发,并在《噪声对比估计:非标准化统计模型的新估计原理》中描述)。因此,在这种情况下,我们获取随机单词并将它们添加到数据集中,并表明它们不是邻居(因此当模型看到它们时应该输出0)。
通过这种方式,我们已经了解了word2vec的两个主要概念(图2-14):skip-gram,选择相邻单词的方法,以及负采样,通过从数据集中随机抽样添加负面示例
图 2-14 Skip-gram 和负采样是 word2vec 算法背后的两个主要思想,并且在许多其他可以表述为词元序列问题的场景中有用。
我们可以从连续文本中生成数百万甚至数十亿个这样的训练样本。在继续使用此数据集训练神经网络之前,我们需要做一些分词决策,就像我们在LLM分词器中看到的那样,包括如何处理大小写和标点以及我们希望在词汇表中有多少个词元。
然后我们为每个词元创建一个嵌入向量,并随机初始化它们,如图2-15所示。实际上,这是一个尺寸为vocab_size x embedding_dimensions的矩阵。
图2-15. 词汇及其起始、随机、未初始化的嵌入向量。
然后在一个示例上训练一个模型,以接收两个嵌入向量并预测它们是否相关。我们可以在图2-16中看到这是什么样的。
图2-16 一个神经网络被训练来预测两个词是否为邻居。它在训练过程中更新嵌入以产生最终的、训练好的嵌入
根据预测是否正确,典型的机器学习训练步骤会更新嵌入,以便下次模型面对这两个向量时,有更好的机会更正确。在训练过程结束时,我们将为词汇表中的所有词元获得更好的嵌入。
这种模型接受两个向量并预测它们是否具有某种关系的想法是机器学习中最强大的想法之一,而且一次又一次地被证明在语言模型方面效果非常好。这就是为什么我们在第10章专门介绍这个概念以及它如何优化语言模型以用于特定任务(如句子嵌入和检索)。
同样的想法也是连接文本和图像等模态的核心,这对于AI图像生成模型至关重要,正如我们将在第9章关于多模态模型中看到的那样。在这种表述中,模型被呈现一张图片和一个标题,它应该预测该标题是否描述了图片。
推荐系统的嵌入
正如我们所提到的,嵌入的概念在许多其他领域中都很有用。例如,在业界, 它被广泛用于推荐系统。
通过嵌入推荐歌曲
在本节中,我们将使用word2vec算法通过人为制作的音乐播放列表来嵌入歌曲。想象一下,如果我们像对待单词或词元一样对待每首歌曲,并且我们像对待句子一样对待每个播放列表。然后可以使用这些嵌入来推荐经常一起出现在播放列表中的相似歌曲
我们将使用的数据集是由康奈尔大学的Shuo Chen收集的。它包含了来自美国数百个广播电台的播放列表。图2-17展示了这个数据集。
图2-17 对于捕捉歌曲相似性的歌曲嵌入,我们将使用一个由一系列播放列表组成的数据集,每个播放列表包含一首歌曲列表。
在我们了解它是如何构建的之前,让我们先展示一下最终产品。所以我们给它几首歌,看看它会推荐什么作为回应。
让我们从给它迈克尔·杰克逊的《比利·简》开始,这首歌的ID是3822:
# We will define and explore this function in detail below
print_recommendations(3822)
Id | Title | Artist |
4181 | Kiss | Prince & The Revolution |
1274 | Wanna Be | Startin’ Somethin’ Michael Jackson |
1506 | The Way You Make Me Feel | Michael Jackson |
3396 | Holiday | Madonna |
500 | Don’t Stop ‘Til You Get Enough | Michael Jackson |
这看起来很合理。麦当娜、普林斯和其他迈克尔 ·杰克逊的歌曲是他们最近的 邻居。
让我们从流行音乐转向说唱音乐,看看2Pac的“加州之爱 ”的邻居:
print_recommendations(842)
Id | Title | Artist |
413 | If I Ruled the World (Imagine That) (w\/ Lauryn Hill) | Nas |
196 | ’ll Be Missing You | Puff Daddy & The Family |
330 | Hate It or Love It (w\/ 50 Cent) | The Game |
211 | Hypnotize | The Notorious B.I.G. |
5788 | The Notorious B.I.G. | Snoop Dogg |
另一个相当合理的清单!既然我们知道它有效,那么让我们看看如何构建这样一个系统。
训练歌曲嵌入模式
我们将从加载包含歌曲播放列表以及每首歌曲元数据(如标题和艺术家)的数据集开始:
import pandas as pd
from urllib import request
# Get the playlist dataset file
data = request.urlopen('https://storage.googleapis.com/maps-premium/data
set/yes_complete/train.txt')
# Parse the playlist dataset file. Skip the first two lines as
# they only contain metadata
lines = data.read().decode("utf-8").split('\n')[2:]
# Remove playlists with only one song
playlists = [s.rstrip().split() for s in lines if len(s.split()) > 1]
# Load song metadata
songs_file = request.urlopen('https://storage.googleapis.com/maps-premium/data
set/yes_complete/song_hash.txt')
songs_file = songs_file.read().decode("utf-8").split('\n')
songs = [s.rstrip().split('\t') for s in songs_file]
songs_df = pd.DataFrame(data=songs, columns = ['id', 'title', 'artist'])
songs_df = songs_df.set_index('id')
现在我们已经保存了它们,让我们检查一下播放列表列表。其中的每个元素都是一个包含歌曲ID列表的播放列表:
print( 'Playlist #1:\n ', playlists[0], '\n')
print( 'Playlist #2:\n ', playlists[1])
Playlist #1: ['0', '1', '2', '3', '4', '5', ..., '43']
Playlist #2: ['78', '79', '80', '3', '62', ..., '210']
让我们来训练一下模型:
from gensim.models import Word2Vec
# Train our Word2Vec model
model = Word2Vec(
playlists, vector_size=32, window=20, negative=50, min_count=1, workers=4
)
这需要一两分钟的训练时间,并且会为我们拥有的每首歌曲计算出嵌入向量。现在我们可以使用这些嵌入向量来找到与我们之前对单词所做的完全相似的歌曲:
song_id = 2172
# Ask the model for songs similar to song #2172
model.wv.most_similar(positive=str(song_id))
输出:
[(' 2976' , 0.9977465271949768),
(' 3167' , 0.9977430701255798),
(' 3094' , 0.9975950717926025),
(' 2640' , 0.9966474175453186),
(' 2849' , 0.9963167905807495)]
那是与歌曲2172的嵌入最相似的歌曲列表。
在这种情况下,这首歌是:
print(songs_df.iloc[2172])
title Fade To Black
artist Metallica
Name: 2172 , dtype: object
这导致推荐结果全部属于同一类型的重金属和硬摇滚音乐流派:
import numpy as np
def print_recommendations(song_id):
similar_songs = np.array(
model.wv.most_similar(positive=str(song_id),topn=5)
)[:,0]
return songs_df.iloc[similar_songs]
# Extract recommendations
print_recommendations(2172)
id | 标题 | 艺术家 |
11473 | Little Guitars | Van Halen |
3167 | Unchained | Van Halen |
5586 | The Last in Line | Dio |
5634 | Mr. Brownstone | Guns N’ Roses |
3094 | Breaking the Law | Judas Priest |
在本章中,我们讨论了LLM词元、分词器以及使用词元嵌入的有用方法。这为我们下一章更深入地研究语言模型做好了准备,同时也为我们了解嵌入在语言模型之外的应用打开了大门。
我们探讨了分词器是如何作为处理LLM输入的第一步,将原始文本输入转换为词元ID。常见的分词方案包括根据特定应用程序的要求,将文本分解为单词、子词词元、字符或字节。
对真实世界预训练分词器(从BERT到GPT-2、GPT-4和其他模型)的巡礼向我们展示了一些分词器在某些方面更好(例如,保留诸如大写、换行符或其他语言中的词元等信息),以及在其他方面分词器之间的差异(例如,它们如何分解某些单词)。
分词器设计的三个主要决定因素是分词器算法(例如,BPE、WordPiece、SentencePiece)、分词参数(包括词汇表大小、特殊词元、大写处理、不同语言的处理)以及分词器训练的数据集。
语言模型还可以创建高质量的上下文化词元嵌入,改进原始静态嵌入。这些上下文化词元嵌入用于包括命名实体识别(NER)、提取式文本摘要和文本分类等任务。除了生成词元嵌入外,语言模型还可以生成覆盖整个句子甚至文档的文本嵌入。这使得本书第二部分将介绍的许多应用程序成为可能,这些应用程序涵盖了语言模型应用。
在LLM之前,像word2vec、GloVe和fastText这样的词嵌入方法很受欢迎。在语言处理中,这已经被语言模型生成的上下文化词嵌入所取代。word2vec算法依赖于两个主要思想:跳字模型和负采样。它还使用了类似于我们在第10章中将看到的对比训练。
正如我们在从精心策划的歌曲播放列表构建的音乐推荐器中讨论的那样,嵌入对于创建和改进推荐系统很有用。
在下一章中,我们将深入研究分词后的过程:LLM如何处理这些词元并生成文本?我们将了解使用Transformer架构的LLM的一些主要直觉。