add fifth chapter
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
|
@ -270,9 +270,9 @@ tensor([ -9.5042, -10.3796, -11.3677, -11.4798, -9.7764, -12.2561])
|
|||
>
|
||||
>
|
||||
>
|
||||
> 2. **归属概率在损失函数中的作用**
|
||||
> 2. 对数概率在损失函数中的作用**
|
||||
>
|
||||
> GPT模型训练的目标是最大化正确目标 token 的概率,通常,我们会使用交叉熵损失来衡量模型预测与实际目标之间的差异。对于一个目标 token 序列 `y=(y1,y2,…,yn)`,GPT会生成一个对应的预测概率分布 `P(y∣x)`,其中 x 是模型的输入。
|
||||
> GPT模型训练的目标是最大化正确目标 token 的概率,通常,我们会使用交叉熵损失来衡量模型预测与实际目标之间的差异。对于一个目标 token 序列 y=(y1,y2,…,yn),GPT会生成一个对应的预测概率分布 P(y∣x),其中 x 是模型的输入。
|
||||
>
|
||||
> **交叉熵损失的公式:**
|
||||
>
|
||||
|
|
@ -288,6 +288,676 @@ tensor([ -9.5042, -10.3796, -11.3677, -11.4798, -9.7764, -12.2561])
|
|||
>
|
||||
> 在公式中,对每个token的概率 P(y<sub>t</sub>∣x,θ) 取对数,将乘积形式的联合概率转换为求和形式,有助于避免数值下溢,同时简化优化过程。
|
||||
|
||||
接下来,通过计算平均值将这些对数概率合并为一个评分(参见图 5.7 的第 5 步):
|
||||
|
||||
```python
|
||||
avg_log_probas = torch.mean(log_probas)
|
||||
print(avg_log_probas)
|
||||
```
|
||||
|
||||
由此生成的平均对数概率评分如下:
|
||||
|
||||
```python
|
||||
tensor(-10.7940)
|
||||
```
|
||||
|
||||
训练的目标是通过更新模型权重,使平均对数概率尽可能接近 0,这将在 5.2 节中实现。
|
||||
|
||||
然而,在深度学习中,常见做法并不是直接将平均对数概率推向 0,而是通过将负平均对数概率降低至 0 来实现。负平均对数概率就是平均对数概率乘以 -1,这与图 5.7 的第 6 步相对应:
|
||||
|
||||
```python
|
||||
neg_avg_log_probas = avg_log_probas * -1
|
||||
print(neg_avg_log_probas)
|
||||
```
|
||||
|
||||
结算的结果为:`tensor(-10.7940)`。
|
||||
|
||||
这种将负值 -10.7940 转化为正值 10.7940 的操作在深度学习中称为交叉熵损失。
|
||||
|
||||
在这里,PyTorch 非常实用,因为它内置的 cross_entropy 函数已经自动处理了图 5.7 中的 6 个步骤。
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **交叉熵损失**
|
||||
>
|
||||
> 本质上,交叉熵损失是在机器学习和深度学习中一种常用的度量方法,用于衡量两个概率分布之间的差异——通常是标签的真实分布(此处为数据集中的 token)和模型的预测分布(例如,LLM 生成的 token 概率)。
|
||||
>
|
||||
> 在机器学习,特别是 PyTorch 等框架中,cross_entropy 函数用于计算离散输出的损失,与模型生成的 token 概率下的目标 token 的负平均对数概率类似。因此,cross entropy 和负平均对数概率这两个术语在计算上有关联,实践中经常互换使用。
|
||||
|
||||
在应用交叉熵函数之前,我们先简要回顾一下 logits 和目标张量的形状:
|
||||
|
||||
```python
|
||||
print("Logits shape:", logits.shape)
|
||||
print("Targets shape:", targets.shape)
|
||||
The resulting shapes are as follows:
|
||||
Logits shape: torch.Size([2, 3, 50257])
|
||||
Targets shape: torch.Size([2, 3])
|
||||
```
|
||||
|
||||
可以看到,logits 张量是三维的:批量大小、token 数量和词汇表大小。而 targets 张量是二维的:批量大小和 token 数量。
|
||||
|
||||
在 PyTorch 中使用交叉熵损失函数时,我们需要将这些张量展平,以便在批量维度上进行合并:
|
||||
|
||||
```python
|
||||
logits_flat = logits.flatten(0, 1)
|
||||
targets_flat = targets.flatten()
|
||||
print("Flattened logits:", logits_flat.shape)
|
||||
print("Flattened targets:", targets_flat.shape)
|
||||
```
|
||||
|
||||
得到的张量维度如下:
|
||||
|
||||
```python
|
||||
Flattened logits: torch.Size([6, 50257])
|
||||
Flattened targets: torch.Size([6])
|
||||
```
|
||||
|
||||
请记住,targets 是希望 LLM 生成的目标 token ID,而 logits 包含了在进入 softmax 函数之前的模型原始输出。
|
||||
|
||||
我们之前的实现是先应用 Softmax 函数,再选择目标 token ID 对应的概率分数,计算负的平均对数概率。而在 PyTorch 中,`cross_entropy` 函数能够自动完成所有这些步骤:
|
||||
|
||||
```python
|
||||
loss = torch.nn.functional.cross_entropy(logits_flat, targets_flat)
|
||||
print(loss)
|
||||
```
|
||||
|
||||
计算得到的损失值与之前手动执行图 5.7 中各个步骤时获得的结果相同:
|
||||
|
||||
```python
|
||||
tensor(10.7940)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **Perplexity**
|
||||
>
|
||||
> `Perplexity` 是一种经常与交叉熵损失一起使用的指标,用于评估语言建模等任务中的模型表现。它能够以更具可解释性的方式,帮助理解模型在预测下一个 token 时的不确定性。
|
||||
>
|
||||
> `Perplexity` 常用于衡量模型预测的概率分布与数据集中词的实际分布的接近程度。类似于损失函数,`Perplexity`的值越低,表示模型预测越接近真实分布。
|
||||
>
|
||||
> `Perplexity`可通过 `perplexity = torch.exp(loss)` 计算,对先前计算的损失值应用此公式将返回 `tensor(48725.8203)`。
|
||||
>
|
||||
> `Perplexity`通常比原始损失值更具可解释性,因为它表示了模型在每一步生成中,对有效词汇量的不确定程度。在这个例子中,困惑度可以理解为模型在词汇表中的 47,678 个单词或 token 中,不确定该选择哪个作为下一个生成的 token。
|
||||
|
||||
在本节中,我们对两个小文本输入进行了损失计算,以便更直观地说明损失函数的计算过程。下一节将把损失计算应用于整个训练集和验证集。
|
||||
|
||||
|
||||
|
||||
### 5.1.3 计算训练集和验证集的损失
|
||||
|
||||
在本节中,我们首先准备训练和验证数据集,以用于后续 LLM 的训练。接着,我们计算训练集和验证集的交叉熵(如图 5.8 所示),这是模型训练过程中的重要组成部分。
|
||||
|
||||
<img src="../Image/chapter5/figure5.8.png" width="75%" />
|
||||
|
||||
为了计算训练集和验证集上的损失(如图 5.8 所示),我们使用了一个非常小的文本数据集,即伊迪丝·华顿的短篇小说《判决》,我们在第 2 章中已对此文本进行过处理。选择公共领域的文本可以避免任何关于使用权的担忧。此外,我们选择小数据集的原因在于,它允许代码示例在普通笔记本电脑上运行,即使没有高端 GPU 也能在几分钟内完成,这对于教学尤为有利。
|
||||
|
||||
感兴趣的读者可以使用本书的配套代码,准备一个包含超过 60,000 本 Project Gutenberg 公有领域书籍的大规模数据集,并在此数据集上训练 LLM(详情请见附录 D)。
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **预训练 LLM 的成本**
|
||||
>
|
||||
> 为了更好地理解项目的规模,以一个相对受欢迎的开源 LLM - 70 亿参数的 Llama 2 模型的训练为例。该模型的训练在昂贵的 A100 GPU 上共耗费了 184,320 个小时,处理了 2 万亿个 token。在撰写本文时,AWS 上 8 张 A100 卡的云服务器每小时费用约为 30 美元。粗略估算,训练这样一个 LLM 的总成本约为 69 万美元(计算方法为 184,320 小时除以 8,再乘以 30 美元)。
|
||||
|
||||
以下代码用于加载我们在第 2 章中使用的《判决》短篇小说:
|
||||
|
||||
```python
|
||||
file_path = "the-verdict.txt"
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
text_data = file.read()
|
||||
```
|
||||
|
||||
加载数据集后,我们可以查看其中的字符数和 token 数:
|
||||
|
||||
```python
|
||||
total_characters = len(text_data)
|
||||
total_tokens = len(tokenizer.encode(text_data))
|
||||
print("Characters:", total_characters)
|
||||
print("Tokens:", total_tokens)
|
||||
```
|
||||
|
||||
输出如下:
|
||||
|
||||
```python
|
||||
Characters: 20479
|
||||
Tokens: 5145
|
||||
```
|
||||
|
||||
仅有 5,145 个 token,看起来似乎不足以训练一个 LLM,但正如前面提到的,这仅用于教学演示,因此我们可以将代码的运行时间控制在几分钟,而不是几周。此外,在本章最后,我们将把 OpenAI 的预训练权重加载到我们的 GPTModel 代码中。
|
||||
|
||||
接下来,我们将数据集划分为训练集和验证集,并使用第二章的数据加载器为 LLM 训练准备需输入的批量数据。图 5.9 展示了该过程。
|
||||
|
||||
<img src="../Image/chapter5/figure5.9.png" width="75%" />
|
||||
|
||||
出于可视化的需要,图 5.9 将最大长度设置为 6。然而,在实际数据加载器中,我们将最大长度设置为 LLM 支持的 256 个 token 的上下文长度,使得模型在训练时可以看到更长的文本。
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **处理变长输入的训练**
|
||||
>
|
||||
> 在训练模型时,我们使用大小相似的数据块来保证训练过程的简便和高效。然而,在实践中,使用变长的输入进行训练也有助于提升 LLM 的泛化能力,使其在应用时能够适应不同类型的输入。
|
||||
|
||||
为了实现图 5.9 中的数据划分与加载,我们首先定义一个 `train_ratio`,用于将 90% 的数据用于训练,剩余 10% 用于在训练期间进行模型评估:
|
||||
|
||||
```python
|
||||
train_ratio = 0.90
|
||||
split_idx = int(train_ratio * len(text_data))
|
||||
train_data = text_data[:split_idx]
|
||||
val_data = text_data[split_idx:]
|
||||
```
|
||||
|
||||
现在可以使用 train_data 和 val_data 子集,复用第 2 章中的 create_dataloader_v1 代码来创建相应的数据加载器:
|
||||
|
||||
```python
|
||||
from chapter02 import create_dataloader_v1
|
||||
torch.manual_seed(123)
|
||||
|
||||
train_loader = create_dataloader_v1(
|
||||
train_data,
|
||||
batch_size=2,
|
||||
max_length=GPT_CONFIG_124M["context_length"],
|
||||
stride=GPT_CONFIG_124M["context_length"],
|
||||
drop_last=True,
|
||||
shuffle=True,
|
||||
num_workers=0
|
||||
)
|
||||
|
||||
val_loader = create_dataloader_v1(
|
||||
val_data,
|
||||
batch_size=2,
|
||||
max_length=GPT_CONFIG_124M["context_length"],
|
||||
stride=GPT_CONFIG_124M["context_length"],
|
||||
drop_last=False,
|
||||
shuffle=False,
|
||||
num_workers=0
|
||||
)
|
||||
```
|
||||
|
||||
在前面的代码示例中,由于数据集较小,我们使用了较小的批量大小以降低计算资源的消耗。实际训练 LLM 时,批量大小达到 1,024 或更高并不少见。
|
||||
|
||||
为了确认数据加载器是否正确创建,可以通过遍历这些数据加载器来检查:
|
||||
|
||||
```python
|
||||
print("Train loader:")
|
||||
for x, y in train_loader:
|
||||
print(x.shape, y.shape)
|
||||
|
||||
print("\nValidation loader:")
|
||||
for x, y in val_loader:
|
||||
print(x.shape, y.shape)
|
||||
```
|
||||
|
||||
执行代码,可以看到以下输出:
|
||||
|
||||
```python
|
||||
Train loader:
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
|
||||
Validation loader:
|
||||
torch.Size([2, 256]) torch.Size([2, 256])
|
||||
```
|
||||
|
||||
可以看到,训练集中共有 9 个批量,每批包含 2 个样本,每个样本有 256 个 token。由于只分配了 10% 的数据用于验证,因此验证集中只有 1 个批量,包含 2 个样本。
|
||||
|
||||
和我们的预期一致,输入数据(x)和目标数据(y)的形状相同(即批量大小 × 每批的 token 数量),因为目标数据是将输入数据整体向后偏移一个位置得到的,正如第 2 章讨论的那样。
|
||||
|
||||
接下来我们实现一个工具函数,用于计算由训练和验证加载器返回的批量数据的交叉熵损失:
|
||||
|
||||
```python
|
||||
def calc_loss_batch(input_batch, target_batch, model, device):
|
||||
input_batch, target_batch = input_batch.to(device), target_batch.to(device) #A
|
||||
logits = model(input_batch)
|
||||
loss = torch.nn.functional.cross_entropy(
|
||||
logits.flatten(0, 1), target_batch.flatten()
|
||||
)
|
||||
return loss
|
||||
|
||||
#A 将数据传输到指定设备(如 GPU),使数据能够在 GPU 上处理。
|
||||
```
|
||||
|
||||
现在我们可以使用 `calc_loss_batch` 工具函数来实现 `calc_loss_loader` 函数,`calc_loss_loader` 将用于计算指定数据加载器中的指定数据批次的损失:
|
||||
|
||||
```python
|
||||
# Listing 5.2 Function to compute the training and validation loss
|
||||
def calc_loss_loader(data_loader, model, device, num_batches=None):
|
||||
total_loss = 0.
|
||||
if len(data_loader) == 0:
|
||||
return float("nan")
|
||||
elif num_batches is None:
|
||||
num_batches = len(data_loader) #A
|
||||
else:
|
||||
num_batches = min(num_batches, len(data_loader)) #B
|
||||
for i, (input_batch, target_batch) in enumerate(data_loader):
|
||||
if i < num_batches:
|
||||
loss = calc_loss_batch(input_batch, target_batch, model, device)
|
||||
total_loss += loss.item() #C
|
||||
else:
|
||||
break
|
||||
return total_loss / num_batches #D
|
||||
|
||||
|
||||
#A 如果没有指定批次数,将自动遍历所有批次
|
||||
#B 若批次数超过数据加载器的总批次数,则减少批次数使其与数据加载器的批次数相匹配
|
||||
#C 每个批次的损失求和
|
||||
#D 对所有批次的损失取平均值
|
||||
```
|
||||
|
||||
默认情况下,`calc_loss_batch` 函数会遍历 `data loader` 中的所有批次数据,将每批次的损失累加到 `total_loss` 中,并计算所有批次的平均损失。作为替代方案,我们可以通过 `num_batches` 参数指定更少的批次数,以加快模型训练过程中的评估速度。
|
||||
|
||||
现在让我们看看如何将 `calc_loss_batch` 函数应用到训练集和验证集加载器中:
|
||||
|
||||
```python
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") #A
|
||||
model.to(device)
|
||||
with torch.no_grad(): #B
|
||||
train_loss = calc_loss_loader(train_loader, model, device) #C
|
||||
val_loss = calc_loss_loader(val_loader, model, device)
|
||||
print("Training loss:", train_loss)
|
||||
print("Validation loss:", val_loss)
|
||||
|
||||
|
||||
#A 如果你的设备配备了支持 CUDA 的 GPU,LLM 将自动在 GPU 上进行训练,无需更改代码
|
||||
#B 因为当前不在训练,为提高效率,关闭梯度跟踪
|
||||
#C 通过 device 设置确保数据与 LLM 模型加载到同一设备上
|
||||
```
|
||||
|
||||
损失值如下:
|
||||
|
||||
```python
|
||||
Training loss: 10.98758347829183
|
||||
Validation loss: 10.98110580444336
|
||||
```
|
||||
|
||||
模型未经过训练,因此损失值较高。相比之下,如果模型学会按训练集和验证集中的真实顺序生成下一个 token,损失值就会接近 0。
|
||||
|
||||
现在我们已经有了评估生成文本质量的方法,接下来我们将训练 LLM 以减少损失,从而提升文本生成的效果,如图 5.10 所示。
|
||||
|
||||
<img src="../Image/chapter5/figure5.10.png" width="75%" />
|
||||
|
||||
如图 5.10 所示,下一节将重点讲解 LLM 的预训练过程。在模型训练完成后,将应用不同的文本生成策略,并保存和加载预训练模型的权重。
|
||||
|
||||
|
||||
|
||||
## 5.2 训练 LLM
|
||||
|
||||
在本节中,我们将实现 LLM(基于GPTModel)的预训练代码。我们重点采用一种简单的训练循环方式来保证代码简洁易读(如图 5.11 所示)。不过,有兴趣的读者可以在附录 D 中了解更多高级技术,包括学习率预热、余弦退火和梯度裁剪等,以进一步完善训练循环。
|
||||
|
||||
<img src="../Image/chapter5/figure5.11.png" width="75%" />
|
||||
|
||||
图 5.11 中的流程图展示了一个典型的 PyTorch 神经网络训练流程,我们用它来训练大语言模型(LLM)。流程概述了 8 个步骤,从迭代各个 epoch 开始,处理批次数据、重置和计算梯度、更新权重,最后进行监控步骤如打印损失和生成文本样本。如果你对使用 PyTorch 如何训练深度神经网络不太熟悉,可以参考附录 A 中的 A.5 至 A.8 节。
|
||||
|
||||
我们可以通过以下`train_model_simple`函数来实现这一训练流程:
|
||||
|
||||
```python
|
||||
# Listing 5.3 The main function for pretraining LLMs
|
||||
def train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs,
|
||||
eval_freq, eval_iter, start_context, tokenizer):
|
||||
train_losses, val_losses, track_tokens_seen = [], [], [] #A
|
||||
tokens_seen, global_step = 0, -1
|
||||
|
||||
for epoch in range(num_epochs): #B
|
||||
model.train()
|
||||
for input_batch, target_batch in train_loader:
|
||||
optimizer.zero_grad() #C
|
||||
loss = calc_loss_batch(input_batch, target_batch, model, device)
|
||||
loss.backward() #D
|
||||
optimizer.step() #E
|
||||
tokens_seen += input_batch.numel()
|
||||
global_step += 1
|
||||
|
||||
if global_step % eval_freq == 0: #F
|
||||
train_loss, val_loss = evaluate_model(
|
||||
model, train_loader, val_loader, device, eval_iter)
|
||||
train_losses.append(train_loss)
|
||||
val_losses.append(val_loss)
|
||||
track_tokens_seen.append(tokens_seen)
|
||||
print(f"Ep {epoch+1} (Step {global_step:06d}): "
|
||||
f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")
|
||||
|
||||
generate_and_print_sample( #G
|
||||
model, tokenizer, device, start_context
|
||||
)
|
||||
return train_losses, val_losses, track_tokens_seen
|
||||
|
||||
|
||||
#A 初始化用于记录损失和已处理 token 数量的列表
|
||||
#B 开始主训练循环
|
||||
#C 重置上一批次的损失梯度
|
||||
#D 计算损失梯度
|
||||
#E 使用损失梯度更新模型权重
|
||||
#F 可选的评估步骤
|
||||
#G 每个 epoch 结束后打印示例文本
|
||||
```
|
||||
|
||||
注意,我们刚刚创建的 `train_model_simple` 函数使用了两个尚未定义的函数:`evaluate_model` 和 `generate_and_print_sample`。
|
||||
|
||||
`evaluate_model` 函数对应图 5.11 中的步骤 7。该函数会在每次模型更新后打印训练集和验证集的损失,从而帮助我们评估训练是否改进了模型。
|
||||
|
||||
更具体地说,`evaluate_model` 函数会在训练集和验证集上计算损失,同时确保模型处于评估模式,并在计算损失时禁用梯度跟踪和 dropout:
|
||||
|
||||
```python
|
||||
def evaluate_model(model, train_loader, val_loader, device, eval_iter):
|
||||
model.eval() #A
|
||||
with torch.no_grad(): #B
|
||||
train_loss = calc_loss_loader(train_loader, model, device, num_batches=eval_iter)
|
||||
val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)
|
||||
model.train()
|
||||
return train_loss, val_loss
|
||||
|
||||
#A 评估阶段禁用 dropout,以确保结果稳定、可复现
|
||||
#B 禁用梯度跟踪,减少计算开销
|
||||
```
|
||||
|
||||
与 `evaluate_model` 类似,`generate_and_print_sample` 是一个工具函数,用于跟踪模型在训练过程中是否有改进。具体来说,`generate_and_print_sample` 函数接收一个文本片段(`start_context`)作为输入,将其转换为 token ID,并传递给 LLM,借助之前的 `generate_text_simple` 函数生成文本示例:
|
||||
|
||||
```python
|
||||
def generate_and_print_sample(model, tokenizer, device, start_context):
|
||||
model.eval()
|
||||
context_size = model.pos_emb.weight.shape[0]
|
||||
encoded = text_to_token_ids(start_context, tokenizer).to(device)
|
||||
with torch.no_grad():
|
||||
token_ids = generate_text_simple(
|
||||
model=model, idx=encoded,
|
||||
max_new_tokens=50, context_size=context_size
|
||||
)
|
||||
decoded_text = token_ids_to_text(token_ids, tokenizer)
|
||||
print(decoded_text.replace("\n", " ")) # Compact print format
|
||||
model.train()
|
||||
```
|
||||
|
||||
`evaluate_model`函数通过数值来评估模型的训练进展,而`generate_and_print_sample text`函数则通过生成的实际文本示例,帮助我们在训练过程中判断模型的能力。
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **ADAMW**
|
||||
>
|
||||
> Adam 优化器在深度神经网络训练中非常流行。然而在我们的训练循环中,我们选择了 AdamW 优化器。AdamW 是 Adam 的一种变体,通过改进权重衰减方式,帮助减少模型复杂度,并通过惩罚较大的权重来防止过拟合。这样的调整使得 AdamW 能更有效地实现正则化,并提升模型的泛化能力,因此被广泛应用于大语言模型的训练中。
|
||||
|
||||
让我们通过训练一个 GPTModel 实例来实际操作看看,训练 10 个 epoch,使用 AdamW 优化器和之前定义的`train_model_simple`函数:
|
||||
|
||||
```python
|
||||
torch.manual_seed(123)
|
||||
model = GPTModel(GPT_CONFIG_124M)
|
||||
model.to(device)
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1) #A
|
||||
num_epochs = 10
|
||||
train_losses, val_losses, tokens_seen = train_model_simple(
|
||||
model, train_loader, val_loader, optimizer, device,
|
||||
num_epochs=num_epochs, eval_freq=5, eval_iter=1,
|
||||
start_context="Every effort moves you", tokenizer=tokenizer
|
||||
)
|
||||
|
||||
#A .parameters() 方法返回模型的所有可训练权重参数
|
||||
```
|
||||
|
||||
执行 `training_model_simple` 函数将开始训练过程,在 MacBook Air 或类似的笔记本电脑上完成约需 5 分钟。执行过程中打印的输出如下所示:
|
||||
|
||||
```python
|
||||
Ep 1 (Step 000000): Train loss 9.781, Val loss 9.933
|
||||
Ep 1 (Step 000005): Train loss 8.111, Val loss 8.339
|
||||
Every effort moves you,,,,,,,,,,,,.
|
||||
Ep 2 (Step 000010): Train loss 6.661, Val loss 7.048
|
||||
Ep 2 (Step 000015): Train loss 5.961, Val loss 6.616
|
||||
Every effort moves you, and, and, and, and, and, and, and, and, and, and, and, and, and,
|
||||
and, and, and, and, and, and, and, and, and,, and, and,
|
||||
[...] Results are truncated to save space #A
|
||||
Ep 9 (Step 000080): Train loss 0.541, Val loss 6.393
|
||||
Every effort moves you?" "Yes--quite insensible to the irony. She wanted him
|
||||
vindicated--and by me!" He laughed again, and threw back the window-curtains, I had the
|
||||
donkey. "There were days when I
|
||||
Ep 10 (Step 000085): Train loss 0.391, Val loss 6.452
|
||||
Every effort moves you know," was one of the axioms he laid down across the Sevres and
|
||||
silver of an exquisitely appointed luncheon-table, when, on a later day, I had again run
|
||||
over from Monte Carlo; and Mrs. Gis
|
||||
|
||||
#A 中间结果被省略以节省空间
|
||||
```
|
||||
|
||||
根据训练过程中的输出结果,训练损失显著下降,从 9.558 降到 0.762,模型的语言能力大幅提升。在训练初期,模型仅能在起始上下文后添加逗号(如“Every effort moves you,,,,,,,,,,,,”)或重复单词“and”。而在训练结束时,模型能够生成符合语法的文本。
|
||||
|
||||
训练集损失类似,我们可以看到验证集损失在开始时较高(9.856),随后在训练过程中下降。但它始终未能像训练集损失那样低,在第 10 个 epoch 后保持在 6.372。
|
||||
|
||||
在更详细地讨论验证集损失之前,我们先创建一个简单的图表,将训练集和验证集损失并排展示:
|
||||
|
||||
```python
|
||||
import matplotlib.pyplot as plt
|
||||
def plot_losses(epochs_seen, tokens_seen, train_losses, val_losses):
|
||||
fig, ax1 = plt.subplots(figsize=(5, 3))
|
||||
ax1.plot(epochs_seen, train_losses, label="Training loss")
|
||||
ax1.plot(epochs_seen, val_losses, linestyle="-.", label="Validation loss")
|
||||
ax1.set_xlabel("Epochs")
|
||||
ax1.set_ylabel("Loss")
|
||||
ax1.legend(loc="upper right")
|
||||
ax2 = ax1.twiny() #A
|
||||
ax2.plot(tokens_seen, train_losses, alpha=0) #B
|
||||
ax2.set_xlabel("Tokens seen")
|
||||
fig.tight_layout()
|
||||
plt.show()
|
||||
|
||||
epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
|
||||
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)
|
||||
|
||||
#A 创建与 y 轴共用的第二个 x 轴
|
||||
#B 用于对齐刻度的隐藏图形
|
||||
```
|
||||
|
||||
生成的训练损失和验证损失图表如图 5.12 所示。
|
||||
|
||||
<img src="../Image/chapter5/figure5.12.png" width="75%" />
|
||||
|
||||
如图 5.12 所示,训练损失和验证损失在第一个 epoch 开始时都有所改善。然而,从第二个 epoch 之后,损失开始出现分歧。验证损失远高于训练损失,这表明模型在训练数据上出现了过拟合。我们可以通过搜索生成的文本片段(例如“The Verdict”文件中的片段:“quite insensible to the irony”)来确认模型逐词记住了训练数据。
|
||||
|
||||
这种记忆现象是预料之中的,因为我们使用了一个非常小的训练数据集,并且对模型进行了多轮训练。通常,我们会在更大的数据集上训练模型,并且只需训练一个 epoch 即可。
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **个人思考:** 让我们基于 LLM 的原理来探讨以下为什么在一个较小的数据集上进行多轮训练,容易产生过拟合的现象?
|
||||
>
|
||||
> 1. **模型容量与数据集大小的匹配问题**
|
||||
> + 大语言模型具有极高的参数容量,通常包含数百万甚至数十亿个参数。如此巨大的参数空间可以高度灵活地适应数据,使得模型能够“记住”每个样本的具体特征
|
||||
> + 当数据集很小时,模型没有足够的多样性去学习广泛的模式,而是倾向于学习每个数据点的细节。经过多轮训练,模型会逐渐“记住”小数据集中每个样本的特征,从而导致过拟合。
|
||||
> 2. **多轮训练导致对数据集细节的过度学习**
|
||||
> + 多轮训练意味着模型会反复接触相同的数据。这种重复使得模型逐渐适应数据集的特定模式,而不是学习一般化的规律。
|
||||
> + 每次训练迭代都会使模型在数据集上拟合得更好,因此在训练数据上损失逐渐减小,但由于缺少新的数据,模型无法学习到通用模式,只会进一步记住训练样本的细节。
|
||||
> 3. **数据集的多样性不足**
|
||||
> + 小数据集通常不能代表广泛的语言特征和分布,缺乏多样性。模型在小数据集上多轮训练,基本上是在有限的样本范围内形成模式,导致它对特定的训练样本依赖性过强。
|
||||
> + 这种缺乏多样性的训练会使模型偏向训练数据的分布,难以适应实际应用中广泛的输入数据。
|
||||
> 4. **过拟合与模型泛化能力的矛盾**
|
||||
> + 过拟合本质上是模型在训练数据上的表现优异,但在未见过的数据上表现较差。大语言模型的训练目标是提高其泛化能力,即能在更广泛的分布上生成有意义的文本。
|
||||
> + 当数据集非常小且多轮训练时,模型会对数据的细节和噪声进行过度拟合,这会导致模型在测试数据或实际应用中表现不佳,因为它无法应对新的、不同分布的输入。
|
||||
>
|
||||
>
|
||||
|
||||
如前所述,感兴趣的读者可以尝试用 Project Gutenberg 中 60,000 本公共领域书籍来训练模型,这种情况下不会出现过拟合现象。详细信息见附录 B。
|
||||
|
||||
在接下来的部分(如图 5.13 所示),我们将探讨 LLM 使用的采样方法,这些方法可以减轻记忆效应,从而生成更具新意的文本。
|
||||
|
||||
<img src="../Image/chapter5/figure5.13.png" width="75%" />
|
||||
|
||||
如图 5.13 所示,下一节将介绍适用于 LLM 的文本生成策略,以减少训练数据的记忆倾向,提升 LLM 生成文本的原创性。之后我们还会讨论权重的加载与保存,以及从 OpenAI 的 GPT 模型加载预训练权重。
|
||||
|
||||
|
||||
|
||||
## 5.3 通过解码策略控制生成结果的随机性
|
||||
|
||||
本节将介绍文本生成策略(也称为解码策略),用于生成更具原创性的文本。首先,我们将简要回顾前一章中的`generate_text_simple`函数,该函数已在本章前面用于生成和打印样本。然后,我们会讲解两种改进方法:`temperature scaling`和 `top-k sampling`。
|
||||
|
||||
首先,我们将模型从 GPU 转移回 CPU,因为相对较小的模型在推理时不需要使用 GPU。另外,在训练结束后,我们会将模型切换到评估模式,以关闭 dropout 等随机组件:
|
||||
|
||||
```python
|
||||
model.to("cpu")
|
||||
model.eval()
|
||||
```
|
||||
|
||||
接下来,将 GPTModel 的实例(model)传入 generate_text_simple 函数,该函数使用 LLM 一次生成一个 token:
|
||||
|
||||
```python
|
||||
tokenizer = tiktoken.get_encoding("gpt2")
|
||||
token_ids = generate_text_simple(
|
||||
model=model,
|
||||
idx=text_to_token_ids("Every effort moves you", tokenizer),
|
||||
max_new_tokens=25,
|
||||
context_size=GPT_CONFIG_124M["context_length"]
|
||||
)
|
||||
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
|
||||
```
|
||||
|
||||
执行代码,会生成以下文本:
|
||||
|
||||
```python
|
||||
Output text:
|
||||
Every effort moves you know," was one of the axioms he laid down across the Sevres and
|
||||
silver of an exquisitely appointed lun
|
||||
```
|
||||
|
||||
如 5.1.2 节中所述,在生成过程中的每一步,都会选取词汇表中概率得分最高的 token 作为生成的 token。
|
||||
|
||||
接下来介绍两种控制生成文本随机性和多样性的方法:`temperature scaling`和`top-k sampling`。
|
||||
|
||||
|
||||
|
||||
### 5.3.1 Temperature scaling
|
||||
|
||||
本节将介绍`temperature scaling`,这是一种在生成下一个词时加入概率选择的技术。
|
||||
|
||||
之前,在 `generate_text_simple` 函数中,我们总是用 `torch.argmax` 选择概率最高的 token 作为下一个词,这也叫做贪心解码。为了生成更加多样化的文本,可以将 `argmax` 替换为一种从概率分布中进行采样的函数(这里,概率分布是指模型在每一步为每个词汇生成的概率得分)。
|
||||
|
||||
为了用具体的例子说明概率采样,我们将简要讨论下一词生成过程,并用一个非常小的词汇表来进行示例演示:
|
||||
|
||||
```python
|
||||
vocab = {
|
||||
"closer": 0,
|
||||
"every": 1,
|
||||
"effort": 2,
|
||||
"forward": 3,
|
||||
"inches": 4,
|
||||
"moves": 5,
|
||||
"pizza": 6,
|
||||
"toward": 7,
|
||||
"you": 8,
|
||||
}
|
||||
inverse_vocab = {v: k for k, v in vocab.items()}
|
||||
```
|
||||
|
||||
接下来,假设给 LLM 一个初始上下文‘every effort moves you’,并生成下一个 token 的 logits 分数(如下所示):
|
||||
|
||||
```python
|
||||
next_token_logits = torch.tensor(
|
||||
[4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79]
|
||||
)
|
||||
```
|
||||
|
||||
在 `generate_text_simple` 函数中,通过 softmax 函数将 logits 转化为概率,并通过 argmax 函数得到生成的 token 的 ID,最后通过逆词汇表将其映射回文本(可以回顾上一章):
|
||||
|
||||
```python
|
||||
probas = torch.softmax(next_token_logits, dim=0)
|
||||
next_token_id = torch.argmax(probas).item()
|
||||
print(inverse_vocab[next_token_id])
|
||||
```
|
||||
|
||||
由于第四个位置的 logit 值最大,相应地,Softmax 归一化后的概率分数也在该位置上最大,因此生成的下一个词就是这个位置对应的词。
|
||||
|
||||
为了实现概率采样过程,现在可以用 PyTorch 中的 multinomial 函数代替 argmax:
|
||||
|
||||
```python
|
||||
torch.manual_seed(123)
|
||||
next_token_id = torch.multinomial(probas, num_samples=1).item()
|
||||
print(inverse_vocab[next_token_id])
|
||||
```
|
||||
|
||||
输出依然是“forward”,这和之前一样。这是为什么?
|
||||
multinomial 函数根据每个 token 的概率得分来采样下一个 token。换句话说,“forward” 依然是最有可能的 token,因此大多数情况下会被 multinomial 选中,但并不是每次都选中。为了演示这一点,我们可以实现一个函数,重复采样 1000 次:
|
||||
|
||||
```python
|
||||
def print_sampled_tokens(probas):
|
||||
torch.manual_seed(123)
|
||||
sample = [torch.multinomial(probas, num_samples=1).item() for i in range(1_000)]
|
||||
sampled_ids = torch.bincount(torch.tensor(sample))
|
||||
for i, freq in enumerate(sampled_ids):
|
||||
print(f"{freq} x {inverse_vocab[i]}")
|
||||
print_sampled_tokens(probas)
|
||||
```
|
||||
|
||||
采样输出结果如下:
|
||||
|
||||
```python
|
||||
73 x closer
|
||||
0 x every
|
||||
0 x effort
|
||||
582 x forward
|
||||
2 x inches
|
||||
0 x moves
|
||||
0 x pizza
|
||||
343 x toward
|
||||
```
|
||||
|
||||
从输出结果可以看出,单词‘forward’在生成过程中被采样的次数最多(在 1000 次生成中出现了 582 次),但‘closer’、‘inches’和‘toward’等其他词语也偶尔会被采样到。这意味着,如果在生成函数 generate_and_print_sample 中将 argmax 替换为 multinomial,模型有时会生成类似‘every effort moves you toward’、‘every effort moves you inches’和‘every effort moves you closer’这样的句子,而不是固定生成‘every effort moves you forward’。
|
||||
|
||||
我们可以通过一种称为`temperature scaling`的方法进一步控制分布和选择过程,所谓`temperature scaling`,其实就是将 logits 除以一个大于 0 的数:
|
||||
|
||||
```python
|
||||
def softmax_with_temperature(logits, temperature):
|
||||
scaled_logits = logits / temperature
|
||||
return torch.softmax(scaled_logits, dim=0)
|
||||
Temperatures greater than 1 result in more uniformly distributed token probabilities,
|
||||
and Temperatures smaller than 1 will result in more confident (sharper or more peaky)
|
||||
distributions. Let's illustrate this by plotting the original probabilities alongside
|
||||
probabilities scaled with different temperature values:
|
||||
temperatures = [1, 0.1, 5] #A
|
||||
scaled_probas = [softmax_with_temperature(next_token_logits, T) for T in temperatures]
|
||||
x = torch.arange(len(vocab))
|
||||
bar_width = 0.15
|
||||
fig, ax = plt.subplots(figsize=(5, 3))
|
||||
for i, T in enumerate(temperatures):
|
||||
rects = ax.bar(x + i * bar_width, scaled_probas[i],
|
||||
bar_width, label=f'Temperature = {T}')
|
||||
ax.set_ylabel('Probability')
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(vocab.keys(), rotation=90)
|
||||
ax.legend()
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
#A 原始、较低和较高置信度
|
||||
```
|
||||
|
||||
图 5.14 展示了生成的图表:
|
||||
|
||||
<img src="../Image/chapter5/figure5.14.png" width="75%" />
|
||||
|
||||
当 temperature 取 1 时,logits 在传递给 softmax 函数之前会除以 1,计算概率得分。这意味着,temperature 为 1 时相当于不进行任何缩放。在这种情况下,模型将根据原始的 softmax 概率,通过 PyTorch 中的多项式采样函数来选择 token。
|
||||
|
||||
如图 5.14 所示,当 temperature 设置为非常小的值(如 0.1)时,生成的分布会更加尖锐,因此多项式函数几乎总是选择最可能的 token(这里是 ‘forward’),其行为接近 argmax 函数。相反,当 temperature 设置为 5 时,生成的分布更接近均匀分布,其他 token 被选中的频率更高。这种情况下,生成的文本多样性增加,但也更可能出现无意义的内容。例如,temperature 设置为 5 时,模型生成类似 ‘每一份努力都会带你吃披萨’ 的文本概率大约为 4%。
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **个人思考:** 为什么 temperature 值非常小时,生成的概率分布会更加尖锐,越大时,概率分布会更加均匀,文中只是说了结论,没有说过程。
|
||||
>
|
||||
> **temperature** 参数被引入到 softmax 函数中,用于缩放 logits,从而控制输出的概率分布。当引入 temperature 后,softmax 函数的公式变为:
|
||||
>
|
||||
> $$ P\left(x_{i}\right)=\frac{\exp \left(\frac{z_{i}}{T}\right)}{\sum_{j} \exp \left(\frac{z_{j}}{T}\right)} $$
|
||||
>
|
||||
> 1. **当 T>1**
|
||||
> 所有 logits 被除以 T,缩放后,差异变小。由于 exponentiation 函数的敏感性较高,这意味着 logits 值的差异被“压平”,使得最优词的概率降低,而其他次优词的概率提高。输出的概率分布变得更加均匀,再结合multinomial函数,可以使生成结果更加多样化,但同时也降低了生成结果的确定性。
|
||||
>
|
||||
> 2. **当 T<1**
|
||||
>
|
||||
> logits 除以 T 后会被放大,差异变得更加显著。softmax 函数会使最高 logit 对应的词语的概率变得更高,其他词语的概率更低。这导致输出的概率分布更加集中,模型更倾向于选择概率最大的词,从而提高了生成结果的确定性。
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **练习 5.1**
|
||||
>
|
||||
> 使用 `print_sampled_tokens` 函数,打印在图 5.14 所示 temperature 值下缩放的 Softmax 概率的采样频率。在每种情况下,单词“pizza”被采样的频率是多少?你能想到一种更快、更准确的方法来确定“pizza”被采样的频率吗?
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||