第 2 章 LLMGPTToken

第 2 章 处理文本数据

第 2 章 处理文本数据

本章来源:本文翻译整理自 LLMs-from-scratch 仓库的 ch02/01_main-chapter-code/ch02.ipynb,原书为 Sebastian Raschka《Build a Large Language Model (From Scratch)》。

本章要做什么

本章覆盖数据准备和采样,把输入数据"准备好"给 LLM 使用。具体包括:理解词嵌入、把文本切分成 token(分词)、把 token 转成 token ID、添加特殊上下文 token、BytePair 编码(BPE)、用滑动窗口做数据采样、创建 token 嵌入、编码词位置。

本 notebook 使用的包

from importlib.metadata import version

print("torch version:", version("torch"))
print("tiktoken version:", version("tiktoken"))

2.1 理解词嵌入

本节没有代码。

嵌入(embedding)有很多形式,本书聚焦文本嵌入。

LLM 在高维空间(也就是数千维)里使用嵌入工作。因为我们无法可视化这样的高维空间(我们人类只能在一维、二维或三维里思考),下面的图用二维嵌入空间来示意。

2.2 对文本进行分词(Tokenizing text)

在本节,我们对文本进行分词(tokenize),也就是把文本拆成更小的单元,比如单个单词和标点字符。

我们先加载要处理的原始文本。The Verdict by Edith Wharton 是一篇公有领域的短篇小说。

import os
import requests

if not os.path.exists("the-verdict.txt"):
    url = (
        "https://raw.githubusercontent.com/rasbt/"
        "LLMs-from-scratch/main/ch02/01_main-chapter-code/"
        "the-verdict.txt"
    )
    file_path = "the-verdict.txt"

    response = requests.get(url, timeout=30)
    response.raise_for_status()
    with open(file_path, "wb") as f:
        f.write(response.content)


# The book originally used the following code below
# However, urllib uses older protocol settings that
# can cause problems for some readers using a VPN.
# The `requests` version above is more robust
# in that regard.

"""
import os
import urllib.request

if not os.path.exists("the-verdict.txt"):
    url = ("https://raw.githubusercontent.com/rasbt/"
           "LLMs-from-scratch/main/ch02/01_main-chapter-code/"
           "the-verdict.txt")
    file_path = "the-verdict.txt"
    urllib.request.urlretrieve(url, file_path)
"""

排查 SSL 证书错误

  • 有读者报告说,在 VSCode 或 Jupyter 里运行 urllib.request.urlretrieve 时遇到了 ssl.SSLCertVerificationError: SSL: CERTIFICATE_VERIFY_FAILED
  • 这通常意味着 Python 的证书包过期了。

修复方法

  • 使用 Python ≥ 3.9;你可以执行下面的代码检查 Python 版本:
import sys
print(sys.__version__)
  • 升级证书包:
    • pip: pip install --upgrade certifi
    • uv: uv pip install --upgrade certifi
  • 升级后重启 Jupyter 内核。
  • 如果执行前面的代码单元时仍然遇到 ssl.SSLCertVerificationError,请参见 GitHub 上的讨论:更多信息在这里
with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
    
print("Total number of character:", len(raw_text))
print(raw_text[:99])

我们的目标是分词并嵌入这段文本供 LLM 使用。让我们基于一些简单的示例文本来开发一个简单的分词器,之后可以把它应用到上面的文本上。下面的正则表达式会按空白分割:

import re

text = "Hello, world. This, is a test."
result = re.split(r'(\s)', text)

print(result)

我们不仅想按空白分割,还想按逗号和句号分割,所以让我们修改正则表达式来做这件事:

result = re.split(r'([,.]|\s)', text)

print(result)

正如我们看到的,这会创建空字符串,让我们把它们去掉:

# Strip whitespace from each item and then filter out any empty strings.
result = [item for item in result if item.strip()]
print(result)

看起来不错,但让我们也处理其它类型的标点,比如句点、问号等等:

text = "Hello, world. Is this-- a test?"

result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)

这已经很好了,我们现在可以把这个分词应用到原始文本上:

preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(preprocessed[:30])

让我们计算 token 的总数:

print(len(preprocessed))

2.3 把 token 转换成 token ID

接下来,我们把文本 token 转换成 token ID,之后可以通过嵌入层(embedding layer)处理它们。

现在,我们可以从这些 token 构建一个由所有唯一 token 组成的词汇表(vocabulary):

all_words = sorted(set(preprocessed))
vocab_size = len(all_words)

print(vocab_size)
vocab = {token:integer for integer,token in enumerate(all_words)}

下面是这个词汇表的前 50 个条目:

for i, item in enumerate(vocab.items()):
    print(item)
    if i >= 50:
        break

下面,我们用一个小词汇表示意一段短示例文本的分词过程:

现在把它全部放进一个分词器类里:

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]
        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
  • encode 函数把文本转成 token ID。
  • decode 函数把 token ID 转回文本。

我们可以用这个分词器把文本编码(也就是分词)成整数。这些整数之后(在 LLM 里)可以作为输入被嵌入。

tokenizer = SimpleTokenizerV1(vocab)

text = """"It's the last he painted, you know," 
           Mrs. Gisburn said with pardonable pride."""
ids = tokenizer.encode(text)
print(ids)

我们可以把整数解码回文本:

tokenizer.decode(ids)
tokenizer.decode(tokenizer.encode(text))

2.4 添加特殊上下文 token

为未知词和标记文本结束添加一些"特殊"token 是有用的。

有些分词器使用特殊 token 来帮助 LLM 获得额外上下文。其中一些特殊 token 是:

  • [BOS](beginning of sequence,序列开始)标记文本的开始。
  • [EOS](end of sequence,序列结束)标记文本在哪里结束(这通常用于拼接多个不相关的文本,比如两篇不同的维基百科文章或两本不同的书,等等)。
  • [PAD](padding,填充)如果我们用大于 1 的批量大小训练 LLM(我们可能包含多个长度不同的文本;用填充 token,我们把较短的文本填充到最长的长度,这样所有文本都有相同的长度)。
  • [UNK] 表示词汇表里没有包含的词。

注意,GPT-2 不需要上面提到的任何这些 token,它只用一个 <|endoftext|> token 来降低复杂度。<|endoftext|> 和上面提到的 [EOS] token 类似。GPT 也用 <|endoftext|> 做填充(因为我们在批输入上训练时通常用掩码,反正不会注意填充的 token,所以这些 token 是什么并不重要)。GPT-2 不为词表外的词使用 <UNK> token;相反,GPT-2 使用字节对编码(BPE)分词器,它把词拆成子词单元,我们会在后面一节讨论。

我们在两个独立的文本源之间使用 <|endoftext|> token:

让我们看看,如果对下面的文本分词会发生什么:

tokenizer = SimpleTokenizerV1(vocab)

text = "Hello, do you like tea. Is this-- a test?"

tokenizer.encode(text)

上面的代码会产生错误,因为 "Hello" 这个词不在词汇表里。为了处理这种情况,我们可以在词汇表里添加 "<|unk|>" 这样的特殊 token 来表示未知词。既然我们已经在扩展词汇表,让我们再添加一个 token,叫 "<|endoftext|>",它在 GPT-2 训练中用来表示文本结束(它也用在拼接文本之间,比如我们的训练数据集由多篇文章、书等组成时)。

all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>"])

vocab = {token:integer for integer,token in enumerate(all_tokens)}
len(vocab.items())
for i, item in enumerate(list(vocab.items())[-5:]):
    print(item)

我们还需要相应地调整分词器,让它知道什么时候以及如何使用新的 <unk> token:

class SimpleTokenizerV2:
    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()]
        preprocessed = [
            item if item in self.str_to_int 
            else "<|unk|>" for item in preprocessed
        ]

        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

让我们用修改后的分词器来分词文本:

tokenizer = SimpleTokenizerV2(vocab)

text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."

text = " <|endoftext|> ".join((text1, text2))

print(text)
tokenizer.encode(text)
tokenizer.decode(tokenizer.encode(text))

2.5 BytePair 编码

GPT-2 用 BytePair 编码(BPE)作为它的分词器。它允许模型把不在预定义词汇表里的词拆成更小的子词单元,甚至拆成单个字符,从而能处理词表外的词。

比如,如果 GPT-2 的词汇表里没有 "unfamiliarword" 这个词,它可能把它分词成 ["unfam", "iliar", "word"] 或其它子词拆分,取决于它训练好的 BPE 合并规则。

原始 BPE 分词器可以在这里找到:https://github.com/openai/gpt-2/blob/master/src/encoder.py

在这一章,我们使用 OpenAI 开源 tiktoken 库里的 BPE 分词器,它用 Rust 实现核心算法以提高计算性能。作者在 ./bytepair_encoder 里创建了一个 notebook,把这两种实现并排比较(在示例文本上 tiktoken 大约快 5 倍)。

# pip install tiktoken
import importlib
import tiktoken

print("tiktoken version:", importlib.metadata.version("tiktoken"))
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)
strings = tokenizer.decode(integers)

print(strings)

BPE 分词器把未知词拆成子词和单个字符:

2.6 用滑动窗口做数据采样

我们训练 LLM 一次生成一个词,所以我们要相应地准备训练数据,其中序列里的下一个词代表要预测的目标:

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))

对每个文本块,我们想要输入和目标。因为我们想让模型预测下一个词,所以目标是把输入向右移一位:

enc_sample = enc_text[50:]
context_size = 4

x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]

print(f"x: {x}")
print(f"y:      {y}")

一步一步地,预测会像下面这样:

for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]

    print(context, "---->", desired)
for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]

    print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))

我们会在后面覆盖注意力机制之后再处理下一个词的预测。现在,我们实现一个简单的数据加载器,它遍历输入数据集,返回向右移一位的输入和目标。

安装并导入 PyTorch(安装提示见附录 A):

import torch
print("PyTorch version:", torch.__version__)

我们使用滑动窗口方法,每次位置改变 +1:

创建数据集和数据加载器,从输入文本数据集提取块:

from torch.utils.data import Dataset, DataLoader


class GPTDatasetV1(Dataset):
    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|>"})
        assert len(token_ids) > max_length, "Number of tokenized inputs must at least be equal to max_length+1"

        # 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

让我们用批量大小 1 测试一下数据加载器,用于上下文大小 4 的 LLM:

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
dataloader = create_dataloader_v1(
    raw_text, batch_size=1, max_length=4, stride=1, shuffle=False
)

data_iter = iter(dataloader)
first_batch = next(data_iter)
print(first_batch)
second_batch = next(data_iter)
print(second_batch)

下面是一个使用 stride 等于上下文长度(这里是 4)的示例:

我们也可以创建批输出。注意,这里我们增大了 stride,这样批次之间没有重叠,因为更多重叠可能导致过拟合加剧:

dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4, shuffle=False)

data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)

2.7 创建 token 嵌入

数据已经几乎准备好给 LLM 用了。但最后让我们用嵌入层把 token 嵌入到连续的向量表示里。通常,这些嵌入层是 LLM 本身的一部分,在模型训练期间会被更新(训练)。

假设我们有下面四个输入示例,token 化后的输入 ID 是 2、3、5 和 1:

input_ids = torch.tensor([2, 3, 5, 1])

为简单起见,假设我们有一个只有 6 个词的小词汇表,我们想创建大小为 3 的嵌入:

vocab_size = 6
output_dim = 3

torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

这会得到一个 6x3 的权重矩阵:

print(embedding_layer.weight)

对于熟悉 one-hot 编码的读者:上面这个嵌入层方法本质上就是"one-hot 编码后在全连接层做矩阵乘法"的一种更高效的实现方式,这在 ./embedding_vs_matmul 的补充代码里有描述。因为嵌入层只是等价于 one-hot 编码加矩阵乘法方法的更高效实现,它可以被看作一个可以通过反向传播优化的神经网络层。

要把 id 为 3 的 token 转成 3 维向量,我们这样做:

print(embedding_layer(torch.tensor([3])))

注意,上面是 embedding_layer 权重矩阵的第 4 行。要嵌入上面所有四个 input_ids 值,我们这样做:

print(embedding_layer(input_ids))

嵌入层本质上是一个查表操作:

你可能会对比较嵌入层和普通线性层的补充内容感兴趣:../03_bonus_embedding-vs-matmul

2.8 编码词位置

嵌入层把 ID 转成相同的向量表示,无论它们在输入序列中位于哪里:

位置嵌入与 token 嵌入向量组合,形成大语言模型的输入嵌入:

BytePair 编码器的词汇表大小是 50,257。假设我们想把输入 token 编码成 256 维的向量表示:

vocab_size = 50257
output_dim = 256

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

如果我们从数据加载器采样数据,我们把每个批次里的 token 嵌入到 256 维向量。如果我们有批量大小 8、每批 4 个 token,这会得到一个 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)
print("Token IDs:\n", inputs)
print("\nInputs shape:\n", inputs.shape)
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)

# uncomment & execute the following line to see how the embeddings look like
# print(token_embeddings)

GPT-2 使用绝对位置嵌入,所以我们再创建一个嵌入层:

context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)

# uncomment & execute the following line to see how the embedding layer weights look like
# print(pos_embedding_layer.weight)
pos_embeddings = pos_embedding_layer(torch.arange(max_length))
print(pos_embeddings.shape)

# uncomment & execute the following line to see how the embeddings look like
# print(pos_embeddings)

要创建 LLM 里使用的输入嵌入,我们只需把 token 嵌入和位置嵌入相加:

input_embeddings = token_embeddings + pos_embeddings
print(input_embeddings.shape)

# uncomment & execute the following line to see how the embeddings look like
# print(input_embeddings)

在输入处理工作流的初始阶段,输入文本被分割成单独的 token。在这个分割之后,这些 token 根据预定义的词汇表被转换成 token ID:

总结与要点

关键概念

  • 分词(tokenization):把文本拆成更小的单元(词、标点、子词)。
  • 词汇表(vocabulary):所有唯一 token 的集合,每个 token 映射到一个整数 ID。
  • 特殊 token<|endoftext|>(文本结束)、<|unk|>(未知词)等。
  • BPE(BytePair Encoding,字节对编码):GPT-2 用的分词算法,把未知词拆成子词。
  • 滑动窗口采样:用重叠窗口从文本里切出 input-target 对,target 是 input 右移一位。
  • token 嵌入:查表操作,把 token ID 映射成连续向量。
  • 位置嵌入:给每个位置一个向量,和 token 嵌入相加形成输入嵌入。

练习

练习解答见仓库的 ch02/01_main-chapter-code/exercise-solutions.ipynb