Work Embedings 词嵌入#

从本质上讲,嵌入是从离散对象(如单词、图像,甚至整个文档)到连续向量空间中点的映射——嵌入的主要目的是将非数值数据转换为神经网络可以处理的格式。虽然词嵌入是文本嵌入最常见的形式,但也有用于句子、段落或整篇文档的嵌入。句子或段落嵌入是检索增强生成的流行选择。检索增强生成将生成(如生成文本)与检索(如搜索外部知识库)相结合,以在生成文本时提取相关信息,这是本书范围之外的技术。由于我们的目标是训练类似 GPT 的 LLMs,它学习一次生成一个单词,我们将专注于词嵌入。

Word2Vec#

早期且最流行的例子之一是 Word2Vec 方法。Word2Vec 训练神经网络架构,通过给定目标词预测词的上下文或反之来生成词嵌入。Word2Vec 背后的主要思想是,出现在相似上下文中的词往往具有相似的含义。因此,当为了可视化目的投影到二维词嵌入中时,相似的术语会聚集在一起

NeatReader-1744812155091

不幸的是,高维嵌入对可视化提出了挑战,因为我们的感官感知和常见的图形表示本质上仅限于三维或更少维度,这就是为什么图 2.3 在二维散点图中显示二维嵌入的原因。然而,当使用LLMs时,我们通常使用具有更高维度的嵌入。对于 GPT-2 和 GPT-3 两者,嵌入大小(通常称为模型隐藏状态的维度)根据具体的模型变体和大小而变化。这是在性能和效率之间的权衡。最小的 GPT-2 模型(117M 和 125M 参数)使用 768 维的嵌入大小来提供具体示例。最大的 GPT-3 模型(175B 参数)使用 12,288 维的嵌入大小。

Tokenizing text 分词#

如何将输入文本拆分为单个标记,这是创建LLM的嵌入所需的预处理步骤。这些标记可以是单个单词或特殊字符,包括标点符号 NeatReader-1744812366148

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
    preprocessed = re.split(r'([,.:;?_!"()\']|--|\\s)', raw_text)
    preprocessed = [item.strip() for item in preprocessed if item.strip()]
    all_words = sorted(set(preprocessed))
    vocab_size = len(all_words)
    print(vocab_size)

Token to ID 词转 ID#

NeatReader-1744863629444

为了将之前生成的令牌映射到令牌 ID,我们首先必须构建一个词汇表。这个词汇表定义了我们如何将每个唯一的单词和特殊字符映射到一个唯一的整数

class SimpleTokenizerV1:  # 一个实例的名字创立
    def __init__(self, vocab):  ## 初始化一个字符串
        self.str_to_int = vocab  # 单词到整数的映射
        self.int_to_str = {i: s for s, i in vocab.items()}
        # 方便解码,进行整数到词汇的反向映射

    def encode(self, text):
        preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)  ##正则化分词标点符号

        preprocessed = [
            item.strip() for item in preprocessed if item.strip()  ## 去掉两端空格与全部的空句
        ]
        ids = [self.str_to_int[s] for s in preprocessed]  ##整理完的额字符串列表对应到id,从字典出来
        return ids

    def decode(self, ids):
        text = " ".join([self.int_to_str[i] for i in ids])  # 映射整数id到字符串。join是用前面那个(“ ”)联结成一个完整的字符串
        # Replace spaces before the specified punctuations
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)  # 使用正则表达式,去除标点符号前的多余空格
        # \s+匹配一个或者多个空白  \1 替换到匹配
        return text

NeatReader-1744884699580

组合在一起

if __name__ == '__main__':
    with open("the-verdict.txt", "r", encoding="utf-8") as f:
        raw_text = f.read()
        preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        all_words = sorted(set(preprocessed))
        vocab_size = len(all_words)
        vocab = {token: integer for integer, token in enumerate(all_words)}  ##先把word进行编号,再按照单词或者标点为索引(有HashList那味道了)
        tokenizer = SimpleTokenizerV1(vocab)
        text = """"It's the last he painted, you know," 
               Mrs. Gisburn said with pardonable pride."""
        ids = tokenizer.encode(text)
        print(ids)
        print(tokenizer.decode(ids))

> [1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]
> " It' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.

Adding special context tokens 添加特殊上下文令牌#

我们需要修改分词器以处理未知单词。我们还需要解决特殊上下文令牌的使用和添加问题,这些令牌可以增强模型对文本中上下文或其他相关信息的理解。 以支持两个新令牌, <|unk|> 和 <|endoftext|>

NeatReader-1744885426744

  • <|unk|> 标记来表示未包含在训练数据中且因此不属于现有词汇表的新的和未知的单词。
  • <|endoftext|> 标记,可用于分隔两个不相关的文本源。
all_words = sorted(list(set(preprocessed)))
all_words.extend(["<|endoftext|>", "<|unk|>"])
vocab = {token:integer for integer,token in enumerate(all_tokens)}

修改 SimpleTokenizer 来支持新的 token

class SimpleTokenizerV2:##版本2.0,启动!
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = { i:s for s,i in vocab.items()}#s为单词,i是key
    
    def encode(self, text):
        preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)#正则化按照标点分类
        preprocessed = [item.strip() for item in preprocessed if item.strip()]#去掉两头与所有空余句
        preprocessed = [
            item if item in self.str_to_int 
            else "<|unk|>" for item in preprocessed
            #遍历 preprocessed 中的每个 item,如果 item 存在于 self.str_to_int(即词汇表)中,就保留 item
            #如果不存在(即该单词或符号未定义在词汇表中),就替换为特殊标记 <|unk|>。
            #拓展:推导式(如列表推导式)是一种紧凑的语法,专门用于生成新列表(或其他容器)
            #与普通 for 循环相比,它更加简洁和高效,但逻辑复杂时可能会降低可读性。
        ]

        ids = [self.str_to_int[s] for s in preprocessed]#单词或标点映射为整数列表
        return ids
        
    def decode(self, ids):
        text = " ".join([self.int_to_str[i] for i in ids])
        # Replace spaces before the specified punctuations
        text = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)
        return text

BPE 字节对编码#

在上文,我们纯手工的实现了一个编码器,这里举了一个社区的

  • GPT-2 使用字节对编码(BPE)作为其分词器。
  • 这种方式允许模型将不在预定义词汇表中的单词分解为更小的子词单元,甚至是单个字符,从而使其能够处理词汇表外的词汇。
  • 例如,如果 GPT-2 的词汇表中没有“unfamiliarword”这个单词,它可能会将其分词为 [“unfam”, “iliar”, “word”],或者根据训练好的 BPE 合并规则进行其他子词分解。
pip install tiktoken
from importlib.metadata import version
import tiktoken
print("tiktoken version:", version("tiktoken"))

> tiktoken version: 0.9.0
tokenizer = tiktoken.get_encoding("gpt2")
text = (
    "Hello, do you like tea? <|endoftext|> In the sunlit terraces"
    "of someunknownPlace."
)
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)

> [15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 1659, 617, 34680, 27271, 13]


strings = tokenizer.decode(integers)
print(strings)

> Hello, do you like tea? <|endoftext|> In the sunlit terracesof someunknownPlace.

Data sampling with a sliding window 用滑动窗口进行数据采样#

LLMs 通过预测文本中的下一个单词进行预训练

NeatReader-1744886210645

使用如下,进行分词

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()

enc_text = tokenizer.encode(raw_text)
print(len(enc_text))

> 训练集中的标记总数 5145 

对于下一个单词预测任务创建输入 - 目标对的最简单和最直观的方法之一是创建两个变量, x 和 y ,其中 x 包含输入标记, y 包含目标,即向右移动 1 位的输入

x: [290, 4920, 2241, 287]
y:      [4920, 2241, 287, 257]
enc_sample = enc_text[50:]
context_size = 4  # 1
for i in range(1, context_size + 1):
    context = enc_sample[:i]
    desired = enc_sample[i]
    print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))

输出就是 LLM 期望的结果

 and ---->  established
 and established ---->  himself
 and established himself ---->  in
 and established himself in ---->  a

整体上就是需要

  1. 一个包含 LLM 看到的文本的输入张量
  2. 一个包含 LLM 要预测的目标的目标张量

NeatReader-1744886783665

我们在张量 x 中收集输入,其中每一行代表一个输入上下文。第二个张量 y 包含相应的预测目标(下一个单词),这些目标是通过将输入移动一个位置创建的。

from torch.utils.data import Dataset, DataLoader


class GPTDatasetV1(Dataset):
    #让GPT初始化一个类型
    def __init__(self, txt, tokenizer, max_length, stride):
        self.input_ids = []
        self.target_ids = []

        # Tokenize the entire text
        token_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})#id是文本内容编码过来的

        # Use a sliding window to chunk the book into overlapping sequences of max_length
        for i in range(0, len(token_ids) - max_length, stride):
            input_chunk = token_ids[i:i + max_length]
            target_chunk = token_ids[i + 1: i + max_length + 1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))

    def __len__(self):
        return len(self.input_ids)

    def __getitem__(self, idx):
        return self.input_ids[idx], self.target_ids[idx]

def create_dataloader_v1(txt, batch_size=4, max_length=256, 
                         stride=128, shuffle=True, drop_last=True,
                         num_workers=0):

    # Initialize the tokenizer
    tokenizer = tiktoken.get_encoding("gpt2")

    # Create dataset
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)

    # Create dataloader
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,
        drop_last=drop_last,
        num_workers=num_workers
    )

    return dataloader

不过这里需要解释的变量是

  • drop_last: 如果最后一个批次比指定的批次大小短,则会丢弃该批次,以防止训练期间出现损失峰值。
  • max_length:训练的长度
  • stride: 步长,确定了数据的密度,如下图,太短了会导致过拟合

NeatReader-1744887343005

Creating token embeddings 创建令牌嵌入#

为 LLM 训练准备输入文本的最后一步是将令牌 ID 转换为嵌入向量 NeatReader-1744887543968

假设我们只有一个由 6 个单词组成的小词汇表(而不是 BPE 分词器词汇表中的 50,257 个单词),并且我们想要创建大小为 3 的嵌入


vocab_size = 6
output_dim = 3

torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
print(embedding_layer.weight)
Parameter containing:
tensor([[ 0.3374, -0.1778, -0.1690],
        [ 0.9178,  1.5810,  1.3010],
        [ 1.2753, -0.2010, -0.1606],
        [-0.4015,  0.9666, -1.1481],
        [-1.1589,  0.3255, -0.6315],
        [-2.8400, -0.7849, -1.4096]], requires_grad=True)

词汇表中的六个可能令牌中的每一个都有一行,三个嵌入维度中的每一个都有一列

NeatReader-1744888171994

Encoding word positions 编码单词位置#

位置信息与标记向量结合,形成大语言模型的最终输入

为实现这一点,我们可以使用两大类位置感知嵌入:相对位置嵌入和绝对位置嵌入。绝对位置嵌入与序列中的特定位置直接相关。对于输入序列中的每个位置,都会向令牌的嵌入添加一个唯一的嵌入,以传达其确切位置。例如,第一个令牌将具有特定的位置嵌入,第二个令牌具有另一个不同的嵌入

NeatReader-1744888667780

相对位置嵌入的重点不是关注标记的绝对位置,而是标记之间的相对位置或距离。这意味着模型学习的是“相隔多远”而不是“在哪个确切位置”方面的关系。这里的优势在于,即使模型在训练期间没有看到这样的长度,它也能更好地推广到不同长度的序列。

我们假设令牌 ID 是由我们之前实现的 BPE 分词器创建的,该分词器的词汇量大小为 50,257,

vocab_size = 50257
output_dim = 256
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

如果我们从数据加载器中采样数据,我们将每个批次中的标记嵌入为一个 256 维的向量。 如果我们有一个批次大小为 8,每个批次包含 4 个标记,这将得到一个 8 x 4 x 256 的张量:

max_length = 4
dataloader = create_dataloader_v1(
    raw_text, batch_size=8, max_length=max_length,
    stride=max_length, shuffle=False
)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)


token_embeddings = token_embedding_layer(inputs) #调用token_embedding_layer将输入inputs映射为对应的嵌入向量。
print(token_embeddings.shape)
Token IDs:
 tensor([[   40,   367,  2885,  1464],
        [ 1807,  3619,   402,   271],
        [10899,  2138,   257,  7026],
        [15632,   438,  2016,   257],
        [  922,  5891,  1576,   438],
        [  568,   340,   373,   645],
        [ 1049,  5975,   284,   502],
        [  284,  3285,   326,    11]])

Inputs shape:
 torch.Size([8, 4])

使用如下,将这些令牌 ID 嵌入到 256 维向量中

token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)

> torch.Size([8, 4, 256])

8×4×256 维张量输出表明,每个令牌 ID 现在都被嵌入为一个 256 维向量。

对于 GPT 模型的绝对嵌入方法,我们只需要创建另一个嵌入层,其嵌入维度与 token_embedding_ layer 相同:

context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
print(pos_embeddings.shape)

pos_embeddings 的输入通常是一个占位符向量 torch.arange(context_length) ,它包含一系列数字 0、1、…,直到最大输入长度 -1。 context_length 是一个变量,代表 LLM 的支持输入大小。在这里,我们选择它类似于输入文本的最大长度。 在实践中,输入文本可能比支持的上下文长度更长,在这种情况下,我们必须截断文本。

位置嵌入张量由四个 256 维向量组成。我们现在可以将这些直接添加到令牌嵌入中,其中 PyTorch 将把 4×256 维的 pos_embeddings 张量添加到八个批次中的每个 4×256 维令牌嵌入张量中

input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)

NeatReader-1744890945171

comments powered by Disqus