mirror of
https://github.com/skindhu/Build-A-Large-Language-Model-CN.git
synced 2026-10-29 08:30:24 +08:00
979 lines
49 KiB
Markdown
979 lines
49 KiB
Markdown
本章涵盖以下内容:
|
||
|
||
+ **计算训练集和验证集的损失,以评估训练过程中大型语言模型生成文本的质量**
|
||
+ **实现训练函数并预训练大语言模型**
|
||
+ **保存和加载模型权重以便继续训练大语言模型**
|
||
+ **从OpenAI加载预训练权重**
|
||
|
||
|
||
|
||
在之前的章节中,我们实现了数据采样、注意力机制,并编写了 LLM 的架构。本章的核心是实现训练函数并对 LLM 进行预训练,详见图 5.1。
|
||
|
||
<img src="../Image/chapter5/figure5.1.png" width="75%" />
|
||
|
||
如图5.1所示,我们还将学习基本的模型评估技术,以衡量生成文本的质量,这是在训练过程中优化大语言模型的必要条件。此外,我们将讨论如何加载预训练权重,以便为接下来的微调提供坚实的基础。
|
||
|
||
> [!NOTE]
|
||
>
|
||
> **权重参数**
|
||
>
|
||
> 在大语言模型(LLM)和其他深度学习模型中,权重指的是可以通过训练过程调整的参数,通常也被称为权重参数或直接称为参数。在 PyTorch 等框架中,这些权重通常存储在各层(如线性层)中,举例来说,我们在第 3 章实现的多头注意力模块和第 4 章实现的GPT模型中就使用了线性层。在初始化一个层(例如,`new_layer = torch.nn.Linear(...)`)后,我们可以通过`.weight`属性访问其权重,例如`new_layer.weight`。此外,出于便利性,PyTorch还允许通过`model.parameters()`方法直接访问模型的所有可训练参数,包括权重和偏置,我们将在后续实现模型训练时使用该方法。
|
||
|
||
|
||
|
||
## 5.1 生成式文本模型的评估
|
||
|
||
本章开篇,我们将基于上一章的代码设置 LLM 进行文本生成,并讨论如何对生成文本质量进行评估的基本方法。我们在本节以及本章剩余部分讨论的内容已在图5.2中概述。
|
||
|
||
<img src="../Image/chapter5/figure5.2.png" width="75%" />
|
||
|
||
如图 5.2 所示,接下来的小节我们首先简要回顾上一章末尾的文本生成过程,然后深入探讨文本评估及训练和验证损失的计算方法。
|
||
|
||
|
||
|
||
### 5.1.1 使用 GPT 生成文本
|
||
|
||
在本节中,我们会先通过对 LLM 的设置简要回顾一下第四章中实现的文本生成过程。在开始这项工作之前,我们首先使用第 4 章中的 GPTModel 类和 GPT_CONFIG_124M 配置字典初始化 GPT 模型,在本章的后续章节会对其进行评估和训练。
|
||
|
||
```python
|
||
import torch
|
||
from chapter04 import GPTModel
|
||
GPT_CONFIG_124M = {
|
||
"vocab_size": 50257,
|
||
"context_length": 256, #A
|
||
"emb_dim": 768,
|
||
"n_heads": 12,
|
||
"n_layers": 12,
|
||
"drop_rate": 0.1, #B
|
||
"qkv_bias": False
|
||
}
|
||
torch.manual_seed(123)
|
||
model = GPTModel(GPT_CONFIG_124M)
|
||
model.eval()
|
||
|
||
#A 我们将上下文长度从1024个token缩短到256个token
|
||
#B 将 dropout 设置为 0 是一种常见的做法
|
||
```
|
||
|
||
在 GPT_CONFIG_124M 配置字典中,我们唯一的调整是将上下文长度(context_length)减少到 256 个 token。此项调整降低了模型训练的计算需求,使得可以在普通笔记本电脑上进行训练。
|
||
|
||
参数量为 1.24 亿的 GPT-2 模型最初被配置为可处理最多 1024 个 token。本章结束时,我们将更新上下文大小设置,并加载预训练权重,使模型能够支持 1024-token 的上下文长度。
|
||
|
||
在 GPT 模型实例中,我们使用了上一章介绍的 `generate_text_simple` 函数,并引入了两个实用函数 `text_to_token_ids` 和 `token_ids_to_text`,用于在文本和 token 表示之间进行转换。这是本章将会用到的一个技术。图 5.3 展示了这个过程,以便更清楚地说明。
|
||
|
||
我们通过前一章节中介绍的 generate_text_simple 函数来使用 GPTmodel 实例,同时引入了两个实用函数:text_to_token_ids 和token_ids_to_text。这些函数简化了文本与 token 表示之间的转换,本章中我们将多次使用这种技术。为了更清楚地理解这一过程,图 5.3 展示了这一流程,在深入代码之前,我们先通过图示帮助理解。
|
||
|
||
<img src="../Image/chapter5/figure5.3.png" width="75%" />
|
||
|
||
图 5.3 展示了使用 GPT 模型生成文本的三个主要步骤。首先,分词器将输入文本转换为一系列 token ID(在第 2 章中已有讨论)。然后,模型接收这些 token ID 并生成对应的 logits(即词汇表中每个 token 的概率分布,具体见第 4 章)。最后,将 logits 转换回 token ID,分词器将其解码为人类可读的文本,完成从文本输入到文本输出的循环。
|
||
|
||
我们通过代码来实现上述过程:
|
||
|
||
```python
|
||
# Listing 5.1 Utility functions for text to token ID conversion
|
||
import tiktoken
|
||
from chapter04 import generate_text_simple
|
||
|
||
def text_to_token_ids(text, tokenizer):
|
||
encoded = tokenizer.encode(text, allowed_special={'<|endoftext|>'})
|
||
encoded_tensor = torch.tensor(encoded).unsqueeze(0) # add batch dimension
|
||
return encoded_tensor
|
||
|
||
def token_ids_to_text(token_ids, tokenizer):
|
||
flat = token_ids.squeeze(0) # remove batch dimension
|
||
return tokenizer.decode(flat.tolist())
|
||
|
||
start_context = "Every effort moves you"
|
||
tokenizer = tiktoken.get_encoding("gpt2")
|
||
|
||
token_ids = generate_text_simple(
|
||
model=model,
|
||
idx=text_to_token_ids(start_context, tokenizer),
|
||
max_new_tokens=10,
|
||
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 rentingetic wasnم refres RexMeCHicular stren
|
||
```
|
||
|
||
从输出可以看出,模型尚未生成连贯的文本,因为它还没有经过训练。为了定义文本的‘连贯性’或‘高质量’,我们需要实现一种数值方法来评估生成的内容。这一方法将帮助我们在训练过程中监控并提升模型的性能。
|
||
|
||
接下来将介绍如何计算生成内容的损失度量,该损失值会作为训练进展和效果的指示器。此外,在后续关于微调 LLM 的章节中,我们将探讨更多评估模型质量的方法。
|
||
|
||
|
||
|
||
### 5.1.2 文本生成损失的计算
|
||
|
||
本节将探讨如何通过计算‘文本生成损失’来数值化评估训练过程中生成的文本质量。我们将通过一个实际示例逐步讲解这一主题,首先简要回顾第 2 章的数据加载方式以及第 4 章的`generate_text_simple`函数如何生成文本。
|
||
|
||
图 5.4 展示了从输入文本到 LLM 生成文本的整体流程,该流程通过五个步骤实现。
|
||
|
||
<img src="../Image/chapter5/figure5.4.png" width="75%" />
|
||
|
||
图 5.4 展示了第 4 章中`generate_text_simple`函数内部的本生成过程。在后续章节中计算生成文本的质量损失之前,我们需要先执行这些初始步骤。
|
||
|
||
在图 5.4 展示的文本生成过程中,为了便于在一页中展示图像,我们使用了仅包含 7 个 token 的小型词汇表。然而,GPTModel 实际上使用了包含 50,257 个词的大型词汇表,因此在接下来的代码中,token ID 的范围为 0 到 50,256,而不是图示中的 0 到 6。
|
||
|
||
图 5.4 为了简洁仅展示了一个文本示例 'every effort moves'。在接下来的代码示例中,我们将实现图 5.4 中的步骤,并使用两个输入示例 'every effort moves' 和 'I really like' 作为 GPT 模型的输入。
|
||
|
||
考虑两个输入样本,它们已经被转换为 token ID,对应图 5.4 中的步骤 1:
|
||
|
||
```python
|
||
inputs = torch.tensor([[16833, 3626, 6100], # ["every effort moves",
|
||
[40, 1107, 588]]) # "I really like"]
|
||
Matching these inputs, the `targets` contain the token IDs we aim for the model to
|
||
produce:
|
||
targets = torch.tensor([[3626, 6100, 345 ], # [" effort moves you",
|
||
[107, 588, 11311]]) # " really like chocolate"]
|
||
```
|
||
|
||
需要注意的是,目标值是输入数据,但向前偏移了一个位置。我们在第 2 章实现数据加载器时已介绍过这一概念。这种偏移策略对于教会模型预测序列中的下一个 token 至关重要。
|
||
|
||
当我们将两个输入示例(每个包含三个 token)输入模型,以计算它们的 logit 向量后,再应用 Softmax 函数将这些 logit 值转换为概率分数,这对应于图 5.4 中的步骤 2:
|
||
|
||
```python
|
||
with torch.no_grad(): #A
|
||
logits = model(inputs)
|
||
probas = torch.softmax(logits, dim=-1) # Probability of each token in vocabulary
|
||
print(probas.shape)
|
||
|
||
#A 禁用梯度跟踪,因为我们尚未进行训练
|
||
```
|
||
|
||
生成的概率得分张量(probas)的维度如下:
|
||
|
||
```python
|
||
torch.Size([2, 3, 50257])
|
||
```
|
||
|
||
第一个数字 2 表示输入中的两个样本(行),即批次大小。第二个数字 3 表示每个样本包含的 token 数量。最后一个数字表示嵌入维度的大小,通常由词汇表大小决定,前面章节已讨论。
|
||
|
||
通过 softmax 函数将 logits 转换为概率后,第 4 章的 generate_text_simple 函数会将概率分数进一步转换回文本,这一过程在图 5.4 的步骤 3 到步骤 5 中进行了展示。
|
||
|
||
接下来,通过对概率得分应用 `argmax` 函数,可以得到对应的 token ID(实现步骤 3 和 步骤 4):
|
||
|
||
```python
|
||
token_ids = torch.argmax(probas, dim=-1, keepdim=True)
|
||
print("Token IDs:\n", token_ids)
|
||
```
|
||
|
||
假设我们有 2 个输入样本,每个样本包含 3 个 token。在对概率分数应用 argmax 函数后(对应图 5.4 的第 3 步),会得到 2 组输出,每组包含 3 个预测的 token ID:
|
||
|
||
```python
|
||
Token IDs:
|
||
tensor([[[16657], # First batch
|
||
[ 339],
|
||
[42826]],
|
||
[[49906], # Second batch
|
||
[29669],
|
||
[41751]]])
|
||
```
|
||
|
||
最后,步骤 5 将 token ID 转换回文本:
|
||
|
||
```python
|
||
print(f"Targets batch 1: {token_ids_to_text(targets[0], tokenizer)}")
|
||
print(f"Outputs batch 1: {token_ids_to_text(token_ids[0].flatten(), tokenizer)}")
|
||
When we decode these tokens, we find that these output tokens are quite different from
|
||
the target tokens we want the model to generate:
|
||
Targets batch 1: effort moves you
|
||
Outputs batch 1: Armed heNetflix
|
||
```
|
||
|
||
模型生成的文本与目标文本不同,因为它尚未经过训练。接下来,我们将通过‘损失’来数值化评估模型生成文本的质量(详见图 5.4)。这不仅有助于衡量生成文本的质量,还为实现训练函数提供了基础,训练函数主要通过更新模型权重来改善生成文本的质量。
|
||
|
||
<img src="../Image/chapter5/figure5.5.png" width="75%" />
|
||
|
||
文本评估过程的一部分(如图 5.5 所示)是衡量生成的 token 与正确预测目标之间的差距。本章后面实现的训练函数将利用这些信息来调整模型权重,使生成的文本更接近(或理想情况下完全匹配)目标文本。
|
||
|
||
模型训练的目标是提高正确目标 token ID 所在位置的 softmax 概率,如图 5.6 所示。接下来的部分中,我们还会将该 softmax 概率作为评价指标,用于对模型生成的输出进行数值化评估:正确位置上的概率越高,模型效果越好。
|
||
|
||
<img src="../Image/chapter5/figure5.6.png" width="75%" />
|
||
|
||
请注意,图 5.6 使用了一个包含 7 个 token 的简化词汇表,以便所有内容可以在一张图中展示。这意味着 softmax 的初始随机值会在 1/7 左右(约 0.14)。
|
||
|
||
然而,我们为 GPT-2 模型使用的词汇表包含 50,257 个 token,因此每个 token 的初始概率大约只有 0.00002(即 1/50,257)。
|
||
|
||
对于这两个输入文本,我们可以通过以下代码打印与目标 token 对应的初始 softmax 概率得分:
|
||
|
||
```python
|
||
text_idx = 0
|
||
target_probas_1 = probas[text_idx, [0, 1, 2], targets[text_idx]]
|
||
print("Text 1:", target_probas_1)
|
||
|
||
text_idx = 1
|
||
target_probas_2 = probas[text_idx, [0, 1, 2], targets[text_idx]]
|
||
print("Text 2:", target_probas_2)
|
||
```
|
||
|
||
每个批次中 3 个目标 token ID 的概率如下:
|
||
|
||
```python
|
||
Text 1: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05])
|
||
Text 2: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06])
|
||
```
|
||
|
||
训练 LLM 的目标是最大化这些概率值,使其尽量接近 1。这样可以确保 LLM 始终选择目标 token —— 即句中的下一个词,作为生成的下一个 token。
|
||
|
||
> [!NOTE]
|
||
>
|
||
> **反向传播**
|
||
>
|
||
> 如何最大化目标 token 的 softmax 概率值?整体思路是通过更新模型权重,使模型在生成目标 token 时输出更高的概率值。权重更新通过一种称为反向传播的过程来实现,这是一种训练深度神经网络的标准技术(关于反向传播和模型训练的更多细节可见附录 A 的 A.3 至 A.7 节)。
|
||
>
|
||
> 反向传播需要一个损失函数,该函数用于计算模型预测输出与实际目标输出之间的差异(此处指与目标 token ID 对应的概率)。这个损失函数用于衡量模型预测与目标值的偏差程度。
|
||
|
||
在本节接下来的部分中,我们将针对`target_probas_1`和`target_probas_2`的概率得分计算损失。图 5.7 展示了主要步骤。
|
||
|
||
<img src="../Image/chapter5/figure5.7.png" width="75%" />
|
||
|
||
由于我们已经完成了图 5.7 中列出的步骤 1-3,得到了 `target_probas_1` 和 `target_probas_2`,现在进行第 4 步,对这些概率得分取对数:
|
||
|
||
```python
|
||
log_probas = torch.log(torch.cat((target_probas_1, target_probas_2)))
|
||
print(log_probas)
|
||
```
|
||
|
||
计算结果如下:
|
||
|
||
```python
|
||
tensor([ -9.5042, -10.3796, -11.3677, -11.4798, -9.7764, -12.2561])
|
||
```
|
||
|
||
在数学优化中,处理概率得分的对数比直接处理概率得分更为简便。该主题超出本书的讨论范围,但我在一个讲座中对此进行了详细讲解,链接位于附录 B 的参考部分。
|
||
|
||
> [!TIP]
|
||
>
|
||
> **个人思考:** 在继续接下里的计算之前,我们首先来探讨一下,对数在损失函数的应用中到底有什么作用。
|
||
>
|
||
> 1. **为什么要用概率的对数**
|
||
>
|
||
> 在 LLM 中,概率得分通常是小于1的数(例如0.1、0.05等),直接用这些数进行计算和优化可能会面临一些问题。比如,如果多个概率相乘,结果会变得非常小,甚至接近0。这种情况称为“数值下溢”(Numerical Underflow),可能导致计算不稳定。
|
||
>
|
||
> 我们有三个概率值,分别为0.2、0.1和0.05。如果我们计算这些值的乘积,结果是:
|
||
>
|
||
> $$ 0.2×0.1×0.05=0.001 $$
|
||
>
|
||
> 这个值非常小,尤其在深度学习或概率模型中,我们通常会有成千上万个概率需要相乘,这样会导致最终的乘积接近0甚至为0,造成数值计算的不稳定性。
|
||
>
|
||
> 如果我们对这些概率值取对数,然后相加,而不是直接相乘,我们可以避免这个问题。例如,对这三个值取自然对数(logarithm)后再相加:
|
||
>
|
||
> $$ ln(0.2)+ln(0.1)+ln(0.05)≈−1.6094+(−2.3026)+(−2.9957)=−6.9077 $$
|
||
>
|
||
> 虽然这个和也是负数,但它不会像直接相乘的结果那样接近于0,避免了数值下溢的问题。**对数的累加性质**允许我们将原本的累乘操作转换为累加,使得计算更加稳定和高效。
|
||
>
|
||
>
|
||
>
|
||
> 2. 对数概率在损失函数中的作用**
|
||
>
|
||
> GPT模型训练的目标是最大化正确目标 token 的概率,通常,我们会使用交叉熵损失来衡量模型预测与实际目标之间的差异。对于一个目标 token 序列 y=(y1,y2,…,yn),GPT会生成一个对应的预测概率分布 P(y∣x),其中 x 是模型的输入。
|
||
>
|
||
> **交叉熵损失的公式:**
|
||
>
|
||
> 在计算交叉熵损失时,我们希望最大化模型分配给每个正确目标token的概率。交叉熵损失的数学公式为:
|
||
>
|
||
> $$\text { Loss }=-\sum_{t=1}^{T} \ln P\left(y_{t} \mid x, \theta\right)$$
|
||
>
|
||
> 其中:
|
||
>
|
||
> + T 是序列长度
|
||
> + y<sub>t</sub> 是在位置 ttt 上的目标token
|
||
> + P(y<sub>t</sub>∣x,θ) 是模型在参数 θ 下对目标token y<sub>t</sub> 的条件概率
|
||
>
|
||
> 在公式中,对每个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”被采样的频率吗?
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
### |