ch2 处理文本数据

本章节是stage1中的为LLM的实现构建了数据库(data preparation and sampling)

1.文字的embedding过程

此处讨论的是文本的嵌入过程,将文本样本转化为嵌入向量,用嵌入的向量代表输入

2.文本标签化(tokenize)

由之前的学习可知,tokenize文字信息意味着:这会把文字拆解为更多小的理解单元 例如单个词或者字节。如将"This is an example."拆解为五个小单元:"This" , "is", "an" "example", "." , 这就提出了一个问题:怎么做一个分词器?

利用正则表达式

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)

理解:

1.strip()函数用于移除字符串头尾指定的字符(默认为空格)或字符序列(如12的序列移除前缀的12和后缀的21)

2.先用正则以常用符号为单位进行分割,然后移除空字符

3.整体流程结果如下图:

3.对标签化后的tokens编号

在tokenize后,我们的文本由上图中的状态1拆解为了状态2的多个token,在本节则是给状态2的多个token按照字母表的顺序创建一个表格,给所有不同的token映射到不同的标号上(Token ID)

##读入文件(一个长度为20479的文本文件)
with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read() 

##利用刚才的正则表达式提取出tokens
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text) 
preprocessed = [item.strip() for item in preprocessed if item.strip()]

##set保证去重,并用sorted进行排序,总计1130个token
all_words = sorted(set(preprocessed))

##先把word进行编号,再按照单词或者标点为索引(类似HashList那味)
vocab = {token:integer for integer,token in enumerate(all_words)}

得到的效果如下图(截取):

4.分词器类的构建

基于1.2.3,我们可以将之前所有内容整合到一个分词器类中

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

        ##字符串列表对应到id,从字典出来
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids
    
    ##解码    
    def decode(self, ids):
        #映射整数id到字符串。join是用前面那个(“ ”)联结成一个完整的字符串
        text = " ".join([self.int_to_str[i] for i in ids]) 

        #使用正则表达式,去除标点符号前的多余空格
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text) 
        return text

理解:

1.编码和解码函数

  • encode 函数将文本转换为标记 ID。
  • decode 函数将标记 ID 转换回文本。

2.分词器的工作流程

此处有一个细节,id对应后的文本不一定和原文本的字符串一模一样。

比如tokenizer.decode(tokenizer.encode(text))会将原文本中的符号前加一个转义符号/

5.特殊标记

使用特殊标记来帮助LLM获取额外的上下文信息。

  • 一些特殊标记包括:

    • [BOS](序列开始)表示文本的开始。
    • [EOS](序列结束)表示文本的结束(通常用于连接多个不相关的文本,例如两个不同的维基百科文章或两本不同的书籍等)。
    • [PAD](填充)如果我们使用大于1的批次大小训练LLM(我们可能会包含不同长度的多篇文本),使用填充标记将较短的文本填充至最长的长度,以确保所有文本具有相同的长度。
    • [UNK] 用于表示词汇表中没有的词。

[PAD]使用方法(padding技术):

假设我们有一个批次,包含以下文本序列:

  • “Hello”
  • “Hello, world!”

首先,确定最长长度:

  • “Hello” -> 5
  • “Hello, world!” -> 13

最长长度为13。

然后,对较短的文本序列进行填充:

  • “Hello” -> “Hello [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]”
  • “Hello, world!” -> “Hello, world!”

填充后的批次:

  • “Hello [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD]”
  • “Hello, world!”

在本章中用了GPT-2的例子,GPT2只使用<|endoftext|>,类似于[EOS]标记,在两个独立文本之间使用。GPT-2 不使用 <UNK> 标记来表示词汇表外的词;相反,GPT-2 使用字节对编码(BPE)分词器,将单词分解为子词单元

##用vocab创造一个实例
tokenizer = SimpleTokenizerV1(vocab)  

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

tokenizer.encode(text)

上面这个代码会报错,因为单词“Hello”不在词汇表中。

一般来说,为了处理这种情况,我们可以向词汇表中添加类似 "<|unk|>" 的特殊标记,用于表示未知词汇。但是在GPT-2训练中用于表示文本的结束的标记是<|endoftext|>,那么我们可以再添加一个标记 "<|endoftext|>"来扩展词汇表(extend)

all_tokens = sorted(list(set(preprocessed))
#加上未知的表示
all_tokens.extend(["<|endoftext|>", "<|unk|>"])

vocab = {token:integer for integer,token in enumerate(all_tokens)}

6.优化分词器

基于5中特殊标记的扩展词汇方法,我们实现对4中分词器的优化

##版本2.0,启动!
class SimpleTokenizerV2:
    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 
            ##如果不存在(即该单词或符号未定义在词汇表中),就替换为特殊标记 <|unk|>。
            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

优化后测试:

tokenizer = SimpleTokenizerV2(vocab)

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

#用句子分隔符链接两个句子
text = " <|endoftext|> ".join((text1, text2))

tokenizer.decode(tokenizer.encode(text))

此时就不会报keyerror的错误了

7.字节对编码(BPE)

这种方式允许模型将不在预定义词汇表中的单词分解为更小的子词单元,甚至是单个字符,从而使其能够处理词汇表外的词汇。例如,如果 GPT-2 的词汇表中没有“unfamiliarword”这个单词,它可能会将其分词为 ["unfam", "iliar", "word"],或者根据训练好的 BPE 合并规则进行其他子词分解。

GPT-2所使用的原始的 BPE 分词器在bpe_openai_gpt2.py中,拜读后了解大概的组件设计(此步骤为额外部分)

使用:tokenizer = tiktoken.get_encoding("gpt2")

8.利用滑动窗口进行数据采样

我们训练的大LLMs时是一次生成一个单词,因此希望根据训练数据的要求进行准备,使得序列中的下一个单词作为预测目标。

滑动窗口:

我们希望模型预测下一个单词,因此我们要生成目标是将输入右移一个位置后的单词。

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

#读入了一个text并编码到enc_text里面
enc_text = tokenizer.encode(raw_text)

##取样
enc_sample = enc_text[50:]

##设置滑动窗口大小
context_size = 4 
#开头四个
x = enc_sample[:context_size]
#第二个开始的四个,相当于右移一位
y = enc_sample[1:context_size+1]

for i in range(1, context_size+1):
    #文本输入context,先输出有什么,然后desired输出下一个是什么编号
    context = enc_sample[:i]
    desired = enc_sample[i]

预测这一块涉及到注意力机制,将会在后续学习到。目前,我们实现一个简单的数据加载器,它遍历输入数据集并返回右移一个位置后的输入和目标。

用滑动窗口法运行,窗口位置每次加一

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

from torch.utils.data import Dataset, DataLoader


class GPTDatasetV1(Dataset):
    def __init__(self, txt, tokenizer, max_length, stride):
        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]

def create_dataloader_v1(txt, batch_size=4, max_length=256, 
                         stride=128, shuffle=True, drop_last=True,
                         num_workers=0):
    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,
        num_workers=num_workers
    )

    return dataloader

使用批次大小为4、上下文大小为4的设置,测试数据加载器在大语言模型(LLM)中的表现。

此处步幅stride设置为4,避免过拟合

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


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

#数据加载器 dataloader 转换为一个迭代器
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)

测试结果:

9.创建标记嵌入

让我们使用嵌入层将标记转换为连续的向量表示,这些嵌入层是LLM的一部分,并在模型训练过程中进行更新。

#要加入2,3,5,1的字符
input_ids = torch.tensor([2, 3, 5, 1])

#假设我们只有一个 6 个词的词汇表vocab,并且希望嵌入的大小为3 
vocab_size = 6

#嵌入向量的维度
output_dim = 3

#用于设置随机数生成器的种子,确保结果的可复现性
torch.manual_seed(123)

#每行表示一个标记的嵌入向量,这里结果是个6*3的矩阵
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

#嵌入上述所有四个input_ids值,这里结果是个4*3的矩阵
embedding_layer(input_ids)

上述嵌入层方法本质上只是实现one-hot编码后接矩阵乘法的更高效方式(此步骤为额外部分)

这个过程类似于查找操作,例如上述图中列矩阵[5, 1, 2, 3], 5本身在Token IDs to embed列矩阵第一行,查找6*3矩阵中的第六行,加入embedded token IDs矩阵中的第一行

10.位置信息编码

嵌入层将 ID 转换为相同的向量表示,位置信息与标记向量结合,形成大语言模型的最终输入。

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

##如果我们有一个批次大小为 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)

#8 x 4 x 256 
token_embeddings = token_embedding_layer(inputs)

##GPT-2 使用绝对位置嵌入,因此我们只需要创建另一个位置嵌入层
context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)

##4 x 256
pos_embeddings = pos_embedding_layer(torch.arange(max_length))

##8 x 4 x 256
input_embeddings = token_embeddings + pos_embeddings

注意:最后一步相加用到了广播机制

广播机制的原理:

  1. 维度对齐:首先,将较小数组的形状从右向左(即从最后一个维度开始)与较大数组的形状对齐,不足的维度在前面补1。

  2. 尺寸兼容:对于每一对对应的维度,它们的尺寸必须满足以下条件之一:

    • 相等;

    • 其中一个为1。

  3. 自动扩展:在运算时,尺寸为1的维度会自动扩展以适配另一个数组的对应维度,这个过程不会实际复制数据,只是逻辑上的扩展。

例子:

import numpy as np

# 示例数据
batch_size = 4
a = np.random.rand(batch_size, 2)  # 形状为 (batch_size, 2)
b = np.random.rand(batch_size, 3)  # 形状为 (batch_size, 3)

# 扩展维度
a_expanded = np.expand_dims(a, axis=1)  # 形状为 (batch_size, 1, 2)
b_expanded = np.expand_dims(b, axis=2)  # 形状为 (batch_size, 3, 1)

# 广播相加
result = a_expanded + b_expanded  # 形状为 (batch_size, 3, 2)

print(result.shape)  # 输出: (batch_size, 3, 2)

11.总结:主数据加载流程

这里提供了一个后续使用案例

import tiktoken
import torch
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|>"})

        # 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, max_length, stride,
                         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


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

vocab_size = 50257
output_dim = 256
context_length = 1024


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

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

for batch in dataloader:
    x, y = batch

    token_embeddings = token_embedding_layer(x)
    pos_embeddings = pos_embedding_layer(torch.arange(max_length))

    input_embeddings = token_embeddings + pos_embeddings

    break

在本章中还有一些额外的部分,挖坑后续学完整体继续深入

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐