Files
2024-06-10 17:00:23 +08:00

45 KiB

第2章:处理文本

本笔记本使用的软件包:

In [1]:
from importlib.metadata import version

import tiktoken
import torch

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

2.1 理解词嵌入

  • 本节无代码

2.2 文本分词

In [2]:
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])
Total number of character: 20479
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no 
  • 目标是将这段文本进行分词和嵌入,以便用于语言模型(LLM)
  • 让我们基于一些简单的样本文本开发一个简单的分词器,然后我们可以将这个分词器应用到上面的文本中
  • 下面的正则表达式将会根据空格进行分割
In [3]:
import re

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

print(result)
['Hello,', ' ', 'world.', ' ', 'This,', ' ', 'is', ' ', 'a', ' ', 'test.']
  • 我们不仅要分割空格,还要分割逗号和句号,所以我们也要修改正则表达式来做到这一点
In [4]:
result = re.split(r'([,.]|\s)', text)

print(result)
['Hello', ',', '', ' ', 'world', '.', '', ' ', 'This', ',', '', ' ', 'is', ' ', 'a', ' ', 'test', '.', '']
  • 我们可以看到,这会创建空字符串,让我们删除它们
In [5]:
# 从每个元素中删除空白,然后过滤掉所有空字符串。
result = [item.strip() for item in result if item.strip()]
print(result)
['Hello', ',', 'world', '.', 'This', ',', 'is', 'a', 'test', '.']
  • 这看起来很不错,但我们还要处理其他类型的标点符号,如句号、问号等
In [6]:
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)
['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']
  • 这很好,现在我们可以将这个分词器用于原始文本了
In [7]:
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(preprocessed[:30])
['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
  • 让我们来统计词元(token)的数量
In [8]:
print(len(preprocessed))
4649

2.3 将词元转换为词元IDs

  • 从这些词元中,我们现在可以构建一个词汇表,它包含了所有独特的词元。
In [9]:
all_words = sorted(list(set(preprocessed)))
vocab_size = len(all_words)

print(vocab_size)
1159
In [10]:
vocab = {token:integer for integer,token in enumerate(all_words)}
  • 以下是这个词汇表中的前50个条目:
In [11]:
for i, item in enumerate(vocab.items()):
    print(item)
    if i >= 50:
        break
('!', 0)
('"', 1)
("'", 2)
('(', 3)
(')', 4)
(',', 5)
('--', 6)
('.', 7)
(':', 8)
(';', 9)
('?', 10)
('A', 11)
('Ah', 12)
('Among', 13)
('And', 14)
('Are', 15)
('Arrt', 16)
('As', 17)
('At', 18)
('Be', 19)
('Begin', 20)
('Burlington', 21)
('But', 22)
('By', 23)
('Carlo', 24)
('Carlo;', 25)
('Chicago', 26)
('Claude', 27)
('Come', 28)
('Croft', 29)
('Destroyed', 30)
('Devonshire', 31)
('Don', 32)
('Dubarry', 33)
('Emperors', 34)
('Florence', 35)
('For', 36)
('Gallery', 37)
('Gideon', 38)
('Gisburn', 39)
('Gisburns', 40)
('Grafton', 41)
('Greek', 42)
('Grindle', 43)
('Grindle:', 44)
('Grindles', 45)
('HAD', 46)
('Had', 47)
('Hang', 48)
('Has', 49)
('He', 50)
  • 将所有这些整合到一个分词器类(tokenizer class)中
In [12]:
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
  • 我们可以使用分词器将本文编码成整数
  • 这些整数随后可以作为语言模型(LLM)的输入进行嵌入(稍后)。
In [13]:
tokenizer = SimpleTokenizerV1(vocab)

text = """"It's the last he painted, you know," Mrs. Gisburn said with pardonable pride."""
ids = tokenizer.encode(text)
print(ids)
[1, 58, 2, 872, 1013, 615, 541, 763, 5, 1155, 608, 5, 1, 69, 7, 39, 873, 1136, 773, 812, 7]
  • 我们可以将数字解码成文本
In [14]:
tokenizer.decode(ids)
Out [14]:
'" It\' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.'
In [15]:
tokenizer.decode(tokenizer.encode(text))
Out [15]:
'" It\' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.'

2.4 添加特殊上下文词元

  • 一些分词器使用特殊词元来帮助语言模型(LLM)获取额外的上下文信息。

  • 一些特殊的词元是

    • [BOS] (beginning of sequence) 标志着文本的开始
    • [EOS] (end of sequence) 标记文本结束的位置(通常用于连接多个不相关的文本,如两篇不同的维基百科文章或两本不同的书等)
    • [PAD] (padding) 如果我们训练 LLM 的批量大于 1(我们可能会包含多个长度不同的文本;使用填充标记,我们会将较短的文本填充为最长的文本,这样所有文本的长度就相等了。)
  • [UNK] 代表未列入词汇表的单词

  • 请注意,GPT-2 不需要上述任何标记,而只使用 <|endoftext|>来降低复杂性

  • <|endoftext|> 类似于上文提到的[EOS]

  • GPT 也使用 <|endoftext|> 作为填充词元(因为在批量输入训练时我们通常会使用掩码,反正我们不会关注填充的词元,所以这些词元是什么并不重要)。

  • GPT-2 不使用 <UNK> 词元来处理词汇表之外的单词;相反,GPT-2 使用字节对编码(BPE)分词器,它将单词分解为子词单元,我们将在后面的部分讨论这一点。

  • 让我们来看看将下面的文本进行分词后会发生什么:
In [17]:
tokenizer = SimpleTokenizerV1(vocab)

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

tokenizer.encode(text)
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[17], line 5
      1 tokenizer = SimpleTokenizerV1(vocab)
      3 text = "Hello, do you like tea. Is this-- a test?"
----> 5 tokenizer.encode(text)

Cell In[12], line 9, in SimpleTokenizerV1.encode(self, text)
      7 preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
      8 preprocessed = [item.strip() for item in preprocessed if item.strip()]
----> 9 ids = [self.str_to_int[s] for s in preprocessed]
     10 return ids

Cell In[12], line 9, in <listcomp>(.0)
      7 preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
      8 preprocessed = [item.strip() for item in preprocessed if item.strip()]
----> 9 ids = [self.str_to_int[s] for s in preprocessed]
     10 return ids

KeyError: 'Hello'
  • 由于词汇表中不包含 "Hello "一词,因此上面的示例产生了错误
  • 为了处理这种情况,我们可以在词汇表中添加特殊词元,如"<|unk|>",以表示未知词
  • 既然我们已经在扩展词汇表,那就再添加一个名为"<|endoftext|>"的词元,它在 GPT-2 训练中用于表示文本的结束(它也用于连接文本,比如我们的训练数据集由多篇文章、书籍等组成)。
In [18]:
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]

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

vocab = {token:integer for integer,token in enumerate(all_tokens)}
In [19]:
len(vocab.items())
Out [19]:
1161
In [20]:
for i, item in enumerate(list(vocab.items())[-5:]):
    print(item)
('younger', 1156)
('your', 1157)
('yourself', 1158)
('<|endoftext|>', 1159)
('<|unk|>', 1160)
  • 我们还需要相应调整分词器,以便它知道何时以及如何使用新的 <unk> 词元
In [21]:
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])
        # 替换指定标点符号前的空格
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
        return text

让我们尝试使用修改后的分词器对文本进行分词:

In [22]:
tokenizer = SimpleTokenizerV2(vocab)

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

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

print(text)
Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.
In [23]:
tokenizer.encode(text)
Out [23]:
[1160,
 5,
 362,
 1155,
 642,
 1000,
 10,
 1159,
 57,
 1013,
 981,
 1009,
 738,
 1013,
 1160,
 7]
In [24]:
tokenizer.decode(tokenizer.encode(text))
Out [24]:
'<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.'

2.5 BytePair encoding(字节对编码)

  • GPT-2 使用字节对编码(BPE)作为分词器
  • 它允许模型将不在其预定义词汇表中的单词分解为更小的子单词单元甚至单个字符,从而使其能够处理词汇表之外的单词
  • 例如,如果 GPT-2 的词汇表中没有 "unfamiliarword "这个词,它可能会根据训练有素的 BPE 合并结果,将其标记为["unfam"、"iliar"、"word"]或其他子词
  • 原始 BPE 分词器可在此处找到:https://github.com/openai/gpt-2/blob/master/src/encoder.py
  • 在本章中,我们使用 OpenAI 的开源 tiktoken 库中的 BPE 标记符号生成器,该库用 Rust 实现了其核心算法,以提高计算性能
  • 我在 ./bytepair_encoder中创建了一个笔记本,并列比较了这两种实现方法(tiktoken 在样本文本上的速度大约快 5 倍)。
In [25]:
# pip install tiktoken
In [26]:
import importlib
import tiktoken

print("tiktoken version:", importlib.metadata.version("tiktoken"))
tiktoken version: 0.6.0
In [27]:
tokenizer = tiktoken.get_encoding("gpt2")
In [31]:
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, 286, 617, 34680, 27271, 13]
In [32]:
strings = tokenizer.decode(integers)

print(strings)
Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.
  • 未知单词实验:
In [34]:
integers = tokenizer.encode("Akwirw ier")
print(integers)
[33901, 86, 343, 86, 220, 959]
In [35]:
for i in integers:
    print(f"{i} -> {tokenizer.decode([i])}")
33901 -> Ak
86 -> w
343 -> ir
86 -> w
220 ->  
959 -> ier
In [36]:
strings = tokenizer.decode(integers)
print(strings)
Akwirw ier

2.6 使用滑动窗口进行数据采样

In [37]:
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
  • 对于每个文本块,我们需要输入和目标
  • 由于我们希望模型预测下一个单词,因此目标是向右移动一个位置的输入值
In [38]:
enc_sample = enc_text[50:]
In [39]:
context_size = 4

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

print(f"x: {x}")
print(f"y:      {y}")
x: [290, 4920, 2241, 287]
y:      [4920, 2241, 287, 257]
  • 逐一预测的结果如下:
In [40]:
for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]

    print(context, "---->", desired)
[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257
In [41]:
for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]

    print(tokenizer.decode(context), "---->", tokenizer.decode([desired]))
 and ---->  established
 and established ---->  himself
 and established himself ---->  in
 and established himself in ---->  a
  • 我们将在下一章介绍注意力机制后再处理下一个单词的预测
  • 现在,我们实现了一个简单的数据加载器,它可以遍历输入数据集,并返回输入和目标值相移一的结果
  • 安装并导入 PyTorch(安装提示见附录 A)
In [42]:
import torch
print("PyTorch version:", torch.__version__)
PyTorch version: 2.2.1+cu121
  • 创建数据集和数据加载器,从输入的文本数据集中提取数据块
In [43]:
from torch.utils.data import Dataset, DataLoader


class GPTDatasetV1(Dataset):
    def __init__(self, txt, tokenizer, max_length, stride):
        self.tokenizer = tokenizer
        self.input_ids = []
        self.target_ids = []

        # 对全部文本进行分词
        token_ids = tokenizer.encode(txt, allowed_special={'<|endoftext|>'})

        # 使用滑动窗口将图书分块为最大长度的重叠序列
        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]
In [44]:
def create_dataloader_v1(txt, batch_size=4, max_length=256, stride=128, shuffle=True, drop_last=True):

    # 分词器初始化
    tokenizer = tiktoken.get_encoding("gpt2")

    # 创建数据集
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)

    # 创建加载器
    dataloader = DataLoader(
        dataset, batch_size=batch_size, shuffle=shuffle, drop_last=drop_last)

    return dataloader
  • 让我们用批量大小为 1 的数据加载器来测试上下文大小为 4 的 LLM:
In [45]:
with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
In [47]:
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)
[tensor([[  40,  367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
In [48]:
second_batch = next(data_iter)
print(second_batch)
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
  • 我们也可以创造批量的输出
  • 请注意,我们在这里增加了步长,以避免批次之间的重叠,因为更多的重叠可能会导致过拟合增加。
In [49]:
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)
Inputs:
 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]])

Targets:
 tensor([[  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,   287]])

2.7 创建词元嵌入

  • 数据已经几乎准备好用于大型语言模型(LLM)了
  • 但最后,让我们使用嵌入层将这些词元嵌入到连续的向量表示中
  • 通常,这些嵌入层是大型语言模型本身的一部分,在模型训练期间会进行更新(训练)
  • 假设我们有以下三个输入示例,输入ID分别为5、1、3和2(在进行分词之后):
In [50]:
input_ids = torch.tensor([5, 1, 3, 2])
  • 为了简化问题,假设我们有一个很小的词汇表,只包含6个单词,我们想要创建大小为3的嵌入向量。
In [51]:
vocab_size = 6
output_dim = 3

torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
  • 这将导致一个6行3列的权重矩阵:
In [52]:
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)
  • 对于那些熟悉独热编码(one-hot encoding)的人来说,上述的嵌入层方法本质上只是一种更高效的实现方式,它相当于在全连接层中先进行独热编码,然后进行矩阵乘法,这在补充代码./embedding_vs_matmul中有描述。
  • 因为嵌入层只是一种更高效的实现方式,它等同于独热编码和矩阵乘法的方法,所以它可以被视为一个可以通过反向传播进行优化的神经网络层。
  • 要将ID为3的标记转换为三维向量,我们执行以下操作:
In [53]:
print(embedding_layer(torch.tensor([3])))
tensor([[-0.4015,  0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)
  • 请注意,上述内容是embedding_layer权重矩阵的第四行。
  • 要嵌入上述所有三个input_ids的值,我们执行以下操作:
In [54]:
print(embedding_layer(input_ids))
tensor([[-2.8400, -0.7849, -1.4096],
        [ 0.9178,  1.5810,  1.3010],
        [-0.4015,  0.9666, -1.1481],
        [ 1.2753, -0.2010, -0.1606]], grad_fn=<EmbeddingBackward0>)

2.8 单词位置编码

  • BytePair编码器有一个词汇量大小为50,257的词典
  • 假设我们想要将输入的词元编码成256维的向量表示:
In [55]:
vocab_size = 50257
output_dim = 256

token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
  • 如果我们从数据加载器中采样数据,我们将每个批次中的词元嵌入到一个256维的向量中。
  • 如果我们的批次大小为8,每个批次有4个token,这将导致一个8 x 4 x 256的张量。
In [56]:
max_length = 4
dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=max_length, stride=5, shuffle=False)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
In [57]:
print("Token IDs:\n", inputs)
print("\nInputs shape:\n", inputs.shape)
Token IDs:
 tensor([[   40,   367,  2885,  1464],
        [ 3619,   402,   271, 10899],
        [  257,  7026, 15632,   438],
        [  257,   922,  5891,  1576],
        [  568,   340,   373,   645],
        [ 5975,   284,   502,   284],
        [  326,    11,   287,   262],
        [  286,   465, 13476,    11]])

Inputs shape:
 torch.Size([8, 4])
In [58]:
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)
torch.Size([8, 4, 256])
  • GPT-2 使用绝对位置嵌入,所以我们只需创建另一个嵌入层:
In [61]:
block_size = max_length
pos_embedding_layer = torch.nn.Embedding(block_size, output_dim)
Warning:
Output truncated. This notebook contains too many cells to display efficiently.