第 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
- pip:
- 升级后重启 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 textencode函数把文本转成 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 tiktokenimport 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:
总结与要点
- 参见 ./dataloader.ipynb 代码 notebook,它是本章实现的数据加载器的精简版,在接下来的章节里训练 GPT 模型时会用到。
- 参见 ./exercise-solutions.ipynb 查看练习解答。
- 如果你对如何从零实现和训练 GPT-2 分词器感兴趣,参见 Byte Pair Encoding (BPE) Tokenizer From Scratch notebook。
关键概念
- 分词(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。