Bonus Materials
-
02_bonus_bytepair-encoder contains optional code to benchmark different byte pair encoder implementations
-
03_bonus_embedding-vs-matmul contains optional (bonus) code to explain that embedding layers and fully connected layers applied to one-hot encoded vectors are equivalent.
-
04_bonus_dataloader-intuition contains optional (bonus) code to explain the data loader more intuitively with simple numbers rather than text.
02_bonus_bytepair-encoder
# 版权声明和许可信息
# 代码来源于OpenAI的GPT-2项目,遵循修改后的MIT许可证
# Source: https://github.com/openai/gpt-2/blob/master/src/encoder.py
# License:
# Modified MIT License
# Software Copyright (c) 2019 OpenAI
# 我们不主张对您使用GPT-2创建的内容拥有所有权,因此您可以随意使用这些内容。
# 我们只要求您负责任地使用GPT-2,并明确表明您的内容是使用GPT-2创建的。
# We don’t claim ownership of the content you create with GPT-2, so it is yours to do with as you please.
# We only ask that you use GPT-2 responsibly and clearly indicate your content was created using GPT-2.
# 特此授予任何获得本软件及相关文档文件(“软件”)副本的人免费许可,
# 允许其不受限制地处理软件,包括但不限于使用、复制、修改、合并、发布、分发、
# 再许可和/或销售软件副本的权利,并允许向其提供软件的人这样做,但需遵守以下条件:
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
# associated documentation files (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# 上述版权声明和本许可声明应包含在软件的所有副本或大部分内容中。
# 上述版权声明和本许可声明无需包含在由软件创建的内容中。
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
# The above copyright notice and this permission notice need not be included
# with content created by the Software.
# 软件按“原样”提供,不提供任何形式的明示或暗示的保证,
# 包括但不限于适销性、特定用途适用性和非侵权性的保证。
# 在任何情况下,作者或版权所有者均不对任何索赔、损害或其他责任负责,
# 无论是因合同、侵权或其他原因引起的,与软件或其使用或其他交易相关的责任。
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
import os
import json
import regex as re
import requests
from tqdm import tqdm
from functools import lru_cache
# 定义一个函数,返回UTF-8字节和对应的Unicode字符串列表
# 可逆的BPE代码在Unicode字符串上工作,因此需要大量的Unicode字符来避免UNK
# 此函数创建查找表以避免映射到BPE代码会出错的空白/控制字符
@lru_cache()
def bytes_to_unicode():
"""
Returns list of utf-8 byte and a corresponding list of unicode strings.
The reversible bpe codes work on unicode strings.
This means you need a large # of unicode characters in your vocab if you want to avoid UNKs.
When you're at something like a 10B token dataset you end up needing around 5K for decent coverage.
This is a significant percentage of your normal, say, 32K bpe vocab.
To avoid that, we want lookup tables between utf-8 bytes and unicode strings.
And avoids mapping to whitespace/control characters the bpe code barfs on.
"""
bs = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1))
cs = bs[:]
n = 0
for b in range(2**8):
if b not in bs:
bs.append(b)
cs.append(2**8 + n)
n += 1
cs = [chr(n) for n in cs]
return dict(zip(bs, cs))
# 定义一个函数,返回一个单词中符号对的集合
# 单词以符号元组的形式表示(符号是可变长度的字符串)
def get_pairs(word):
"""
Return set of symbol pairs in a word.
Word is represented as tuple of symbols (symbols being variable-length strings).
"""
pairs = set()
prev_char = word[0]
for char in word[1:]:
pairs.add((prev_char, char))
prev_char = char
return pairs
# 定义一个编码器类
class Encoder:
# 初始化编码器
def __init__(self, encoder, bpe_merges, errors='replace'):
# 保存编码器字典
self.encoder = encoder
# 创建解码器字典,键值对与编码器相反
self.decoder = {v: k for k, v in self.encoder.items()}
# 设置解码时的错误处理方式
self.errors = errors
# 获取字节到Unicode的编码器
self.byte_encoder = bytes_to_unicode()
# 创建Unicode到字节的解码器
self.byte_decoder = {v: k for k, v in self.byte_encoder.items()}
# 创建BPE合并规则的排名字典
self.bpe_ranks = dict(zip(bpe_merges, range(len(bpe_merges))))
# 创建缓存
self.cache = {}
# 定义一个正则表达式模式,用于分词
# 应该添加re.IGNORECASE,以便BPE合并可以处理大写形式的缩写
self.pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d|?\p{L}+|?\p{N}+|?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")
# 实现BPE算法的函数
def bpe(self, token):
# 如果token已经在缓存中,直接返回缓存结果
if token in self.cache:
return self.cache[token]
# 将token转换为元组形式
word = tuple(token)
# 获取单词中的符号对集合
pairs = get_pairs(word)
# 如果没有符号对,直接返回token
if not pairs:
return token
while True:
# 找到排名最低的符号对(即最有可能合并的符号对)
bigram = min(pairs, key=lambda pair: self.bpe_ranks.get(pair, float('inf')))
# 如果该符号对不在BPE合并规则中,退出循环
if bigram not in self.bpe_ranks:
break
first, second = bigram
new_word = []
i = 0
while i < len(word):
try:
# 查找符号对中第一个符号的位置
j = word.index(first, i)
new_word.extend(word[i:j])
i = j
except ValueError:
# 如果找不到,将剩余的单词部分添加到新单词中
new_word.extend(word[i:])
break
# 如果找到符号对,将其合并
if word[i] == first and i < len(word) - 1 and word[i + 1] == second:
new_word.append(first + second)
i += 2
else:
# 如果没有找到符号对,将当前符号添加到新单词中
new_word.append(word[i])
i += 1
new_word = tuple(new_word)
word = new_word
# 如果合并后单词只有一个元素,退出循环
if len(word) == 1:
break
else:
# 重新计算符号对集合
pairs = get_pairs(word)
# 将合并后的单词转换为字符串形式
word = ' '.join(word)
# 将结果存入缓存
self.cache[token] = word
return word
# 对文本进行编码的函数
def encode(self, text):
bpe_tokens = []
# 使用正则表达式分割文本为token
for token in re.findall(self.pat, text):
# 将token编码为字节,再通过字节编码器转换为Unicode字符串
token = ''.join(self.byte_encoder[b] for b in token.encode('utf-8'))
# 对转换后的Unicode字符串进行BPE处理,并将结果转换为编码ID
bpe_tokens.extend(self.encoder[bpe_token] for bpe_token in self.bpe(token).split(' '))
return bpe_tokens
# 对编码进行解码的函数
def decode(self, tokens):
# 将编码ID转换为Unicode字符串
text = ''.join([self.decoder[token] for token in tokens])
# 将Unicode字符串转换为字节,再解码为原始文本
text = bytearray([self.byte_decoder[c] for c in text]).decode('utf-8', errors=self.errors)
return text
# 定义一个函数,用于获取编码器
def get_encoder(model_name, models_dir):
# 从指定目录读取编码器的JSON文件
with open(os.path.join(models_dir, model_name, 'encoder.json'), 'r') as f:
encoder = json.load(f)
# 从指定目录读取BPE合并规则的文件
with open(os.path.join(models_dir, model_name, 'vocab.bpe'), 'r', encoding="utf-8") as f:
bpe_data = f.read()
# 解析BPE合并规则文件,创建合并规则列表
bpe_merges = [tuple(merge_str.split()) for merge_str in bpe_data.split('\n')[1:-1]]
# 创建并返回编码器对象
return Encoder(encoder=encoder, bpe_merges=bpe_merges)
# 定义一个函数,用于下载词汇表文件
def download_vocab():
# 下载的模型子目录
subdir = 'gpt2_model'
# 如果目录不存在,创建目录
if not os.path.exists(subdir):
os.makedirs(subdir)
subdir = subdir.replace('\\', '/') # 对于Windows系统需要进行路径替换
# 遍历需要下载的文件名列表
for filename in ['encoder.json', 'vocab.bpe']:
# 发送HTTP请求,获取文件流
r = requests.get("https://openaipublic.blob.core.windows.net/gpt-2/models/117M/" + filename, stream=True)
# 打开本地文件,准备写入
with open(os.path.join(subdir, filename), 'wb') as f:
# 获取文件大小
file_size = int(r.headers["content-length"])
# 设置每次读取的块大小
chunk_size = 1000
# 使用进度条显示下载进度
with tqdm(ncols=100, desc="Fetching " + filename, total=file_size, unit_scale=True) as pbar:
# 逐块读取文件内容并写入本地文件
for chunk in r.iter_content(chunk_size=chunk_size):
f.write(chunk)
pbar.update(chunk_size)
用于下载词汇表文件
03_bonus_embedding-vs-matmul
PyTorch 中嵌入层(Embedding Layers)和线性层(Linear Layers)的关系展开,通过理论阐述和代码示例深入讲解两者的异同,具体如下:
整体介绍
- 这部分内容是《Build a Large Language Model From Scratch》一书的补充代码,代码仓库链接为GitHub - rasbt/LLMs-from-scratch: Implement a ChatGPT-like LLM in PyTorch from scratch, step by step。
- 主要探讨 PyTorch 中嵌入层和线性层的差异,强调使用嵌入层是为了提高计算效率。
代码实现与解释
- 导入模块
import torch
导入 PyTorch 库,用于后续的张量操作和神经网络层的定义。
- 使用
nn.Embedding
- 定义训练示例和相关参数
idx = torch.tensor([2, 3, 1])
num_idx = max(idx)+1
out_dim = 5
定义了一个包含 3 个训练示例的张量idx
,代表大语言模型(LLM)上下文中的标记 ID。通过max(idx)+1
计算嵌入矩阵的行数,out_dim
指定所需的嵌入维度。
- 创建嵌入层
torch.manual_seed(123)
embedding = torch.nn.Embedding(num_idx, out_dim)
设置随机种子为 123 以确保可重复性,创建一个nn.Embedding
层,输入参数为嵌入矩阵的行数num_idx
和嵌入维度out_dim
。
- 查看嵌入权重
embedding.weight
这行代码用于查看嵌入层的权重。
- 获取单个训练示例的向量表示
embedding(torch.tensor([1]))
使用嵌入层获取 ID 为 1 的训练示例的向量表示。
-
获取多个训练示例的向量表示
idx = torch.tensor([2, 3, 1])
embedding(idx)
- 使用
nn.Linear
- 将标记 ID 转换为独热编码
onehot = torch.nn.functional.one_hot(idx)
使用nn.functional.one_hot
函数将标记 ID 张量idx
转换为独热编码表示。
- 创建线性层
torch.manual_seed(123)
linear = torch.nn.Linear(num_idx, out_dim, bias=False)
linear.weight
设置随机种子为 123,创建一个nn.Linear
层,输入维度为num_idx
,输出维度为out_dim
,且不包含偏置项。查看线性层的权重。
linear = torch.nn.Linear(num_idx, out_dim, bias=False):
◦ 这行代码创建了一个线性层(Linear Layer)对象 linear。
◦ torch.nn.Linear 是 PyTorch 中的一个类,用于创建一个线性变换,即 y = xA^T + b,其中 x 是输入,y 是输出,A 是权重矩阵,b 是偏置向量。
◦ num_idx 和 out_dim 是两个参数,分别表示输入特征的数量和输出特征的数量。
◦ bias=False 表示不使用偏置向量,即 b 被设置为零向量。
- 对齐线性层和嵌入层的权重
linear.weight = torch.nn.Parameter(embedding.weight.T)
为了直接比较线性层和嵌入层的结果,将线性层的权重设置为嵌入层权重的转置。
- 对独热编码输入应用线性层
linear(onehot.float())
将线性层应用于独热编码表示的输入,得到输出结果,并与嵌入层的结果进行对比。
总结
04_bonus_dataloader-intuition
-
04_bonus_dataloader-intuition contains optional (bonus) code to explain the data loader more intuitively with simple numbers rather than text.
这段代码是《Build a Large Language Model From Scratch》一书的补充代码,主要演示了如何使用滑动窗口方法对数字数据进行采样,并通过自定义的数据集类和数据加载器来处理这些数据。以下是对代码的详细解释:
代码整体结构
代码开头部分是书籍相关的信息,包括书名、作者以及代码仓库链接。然后是 Python 代码,主要分为以下几个部分:
- 环境信息获取:获取并打印
torch
库的版本。 - 数据生成:生成一个包含从 0 到 1000 的数字的文本文件。
- 数据集和数据加载器定义:定义一个自定义的数据集类
GPTDatasetV1
和数据加载器创建函数create_dataloader_v1
。 - 数据加载器测试:使用不同的参数设置测试数据加载器,包括不同的批量大小、滑动窗口大小和步长,以及是否打乱数据。
代码详细解释
- 环境信息获取
from importlib.metadata import version
import torch
print("torch version:", version("torch"))
- 从
importlib.metadata
模块导入version
函数,用于获取包的版本信息。 - 导入
torch
库,这是一个用于深度学习的库。 - 打印当前安装的
torch
库的版本。
- 数据生成
with open("number-data.txt", "w", encoding="utf-8") as f:
for number in range(1001):
f.write(f"{number} ")
使用with
语句打开一个名为number-data.txt
的文件,以写入模式("w"
)和 UTF-8 编码。然后通过循环将从 0 到 1000 的数字写入文件,每个数字之间用空格分隔。
- 数据集和数据加载器定义
GPTDatasetV1
类
class GPTDatasetV1(Dataset):
def __init__(self, txt, tokenizer, max_length, stride):
self.input_ids = []
self.target_ids = []
# Modification
# token_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})
token_ids = [int(i) for i in txt.strip().split()]
# 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]
-
__init__
方法:初始化数据集对象。接收文本数据txt
、分词器tokenizer
(在本代码中未实际使用)、最大长度max_length
和步长stride
作为参数。 -
对原代码进行了修改,不再使用分词器对文本进行编码,而是直接将文本按空格分割并转换为整数列表
token_ids
。 -
使用滑动窗口方法将
token_ids
分割成多个重叠的序列,每个序列长度为max_length
。输入序列input_chunk
和目标序列target_chunk
分别为当前窗口和下一个窗口的内容。将这些序列转换为torch.tensor
并存储在input_ids
和target_ids
列表中。 -
__len__
方法:返回数据集的大小,即输入序列的数量。 -
__getitem__
方法:根据索引idx
返回对应的输入序列和目标序列。 -
create_dataloader_v1
函数
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")
tokenizer = None
# 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
- 该函数用于创建数据加载器。接收文本数据
txt
、批量大小batch_size
、最大长度max_length
、步长stride
、是否打乱数据shuffle
、是否丢弃最后一个不完整的批次drop_last
以及工作进程数num_workers
作为参数。 - 由于未使用分词器,将
tokenizer
设为None
。 - 创建
GPTDatasetV1
数据集对象。 - 使用
DataLoader
将数据集包装成可迭代的对象,设置相应的参数。 - 返回创建好的数据加载器。
- 数据加载器测试
- 批量大小为 1,上下文大小为 4 的测试
-
打开之前生成的
number-data.txt
文件,读取文本内容。 -
使用
create_dataloader_v1
函数创建数据加载器,设置批量大小为 1,最大长度为 4,步长为 1,不打乱数据。 -
将数据加载器转换为迭代器,通过
next
函数获取前三个批次的数据并打印。 -
使用
for
循环遍历数据加载器,获取最后一个批次的数据并打印。 -
批量大小为 2,上下文大小为 4,步长为 4 的测试
这段代码使用 PyTorch 设置了随机种子为 123。然后调用了一个名为 “create_dataloader_v1” 的函数创建了一个数据加载器 “dataloader”,这个数据加载器接收原始文本 “raw_text” 作为参数,设置了批量大小为 2、最大长度为 4、步长为 4 且随机打乱数据。接着通过一个循环遍历数据加载器,获取输入数据 “inputs” 和目标数据 “targets”,但循环体内只是简单地通过 “pass” 语句占位,没有实际操作。最后打印出输入数据和目标数据。
dataloader = create_dataloader_v1(raw_text, batch_size=2, max_length=4, stride=4, shuffle=False)
for inputs, targets in dataloader:
pass
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)
使用create_dataloader_v1
函数创建数据加载器,设置批量大小为 2,最大长度为 4,步长为 4,不打乱数据。通过for
循环遍历数据加载器,获取输入和目标数据并打印。
- 批量大小为 2,上下文大小为 4,步长为 4,打乱数据的测试
torch.manual_seed(123)
dataloader = create_dataloader_v1(raw_text, batch_size=2, max_length=4, stride=4, shuffle=True)
for inputs, targets in dataloader:
pass
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)
设置随机种子为 123 以确保可重复性,使用create_dataloader_v1
函数创建数据加载器,设置批量大小为 2,最大长度为 4,步长为 4,打乱数据。通过for
循环遍历数据加载器,获取输入和目标数据并打印。
通过这些代码,读者可以学习到如何使用滑动窗口方法对数据进行采样,以及如何自定义数据集类和数据加载器来处理数据,这对于理解和实现大语言模型的数据处理流程具有一定的帮助。
根据作者要求标注引用来源如下
@book{build-llms-from-scratch-book,
author = {Sebastian Raschka},
title = {构建大型语言模型(从零开始)},
publisher = {Manning},
year = {2023},
isbn = {978-1633437166},
url = {https://www.manning.com/books/build-a-large-language-model-from-scratch},
note = {正在进行中},
github = {https://github.com/rasbt/LLMs-from-scratch}
}