本章内容
- 计算训练集损失和验证集损失,以评估大语言模型在训练期间生成文本的质量
- 实现一个训练函数并对大语言模型进行预训练
- 保存和加载模型权重以持续训练大语言模型
- 从 OpenAI 加载预训练权重
到目前为止,我们已经实现了数据采样和注意力机制,并编写了大语言模型架构的代码。是时候实现训练函数并对大语言模型进行预训练了。在本章中,我们将学习基本的模型评估技术,以衡量生成文本的质量,这是在训练过程中优化大语言模型的必要条件。此外,我们还将讨论如何加载预训练权重,为大语言模型微调奠定扎实的基础。图 5-1 概述了我们的整体计划,并强调了本章将讨论的内容。

权重参数
在大语言模型以及其他深度学习模型的背景下,权重一般指的是学习过程调整的可训练参数。这些权重也被称为权重参数或简单地称为参数。在像 PyTorch 这样的框架中,这些权重存储在线性层中。我们不仅在第 3 章中使用它们实现了多头注意力模块,也在第 4 章中使用它们构建了 GPTModel。在初始化一个线性层new_layer = torch.nn.Linear(...)之后,可以通过.weight 属性new_layer.weight访问其权重。此外,为方便起见,PyTorch 允许通过model.parameters()方法直接访问模型的所有可训练参数(包括 Weights 和 Biases)。 后续在实现模型训练时我们将使用model.parameters()方法。
5.1 评估文本生成模型
在简要回顾第 4 章的文本生成的内容之后,我们将设置大语言模型进行文本生成,然后讨论评估生成文本质量的基本方法,接下来计算训练集损失和验证集损失。图 5-2 展示了本章涵盖的主题,并突出显示了上述 3 个步骤。

5.1.1 使用 GPT 来生成文本
让我们设置大语言模型,并简要回顾在第 4 章中实现的文本生成过程。我们首先使用 GPTModel 类和 GPT_CONFIG_124M 字典(参见第 4 章)来初始化后续将要评估和训练的 GPT 模型:
python
import torch
from previous_chapters import GPTModel
# If the `previous_chapters.py` file is not available locally,
# you can import it from the `llms-from-scratch` PyPI package.
# For details, see: https://github.com/rasbt/LLMs-from-scratch/tree/main/pkg
# E.g.,
# from llms_from_scratch.ch04 import GPTModel
GPT_CONFIG_124M = {
"vocab_size": 50257, # Vocabulary size
"context_length": 256, # Shortened context length (orig: 1024)
"emb_dim": 768, # Embedding dimension
"n_heads": 12, # Number of attention heads
"n_layers": 12, # Number of layers
"drop_rate": 0.1, # Dropout rate
"qkv_bias": False # Query-key-value bias
}
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
model.eval(); # Disable dropout during inference
与第 4 章相比,这里做出的唯一调整是将 GPT_CONFIG_124M 字典中的上下文长度(context_ length)减少到了 256 个词元。这种修改减少了训练模型的计算需求,以便我们可以在标准笔记本电脑上进行训练。
按照惯例,参数量为 1.24 亿的 GPT-2 模型被配置为最多处理 1024 个词元。训练完成后,我们将更新上下文大小设置并加载预训练权重,使其适用于配置为 1024 个词元上下文长度的模型。
使用 GPTModel 实例,我们采用了第 4 章中介绍的 generate_text_simple 函数来生成文 本,并引入了两个便捷的辅助函数:text_to_token_ids 和 token_ids_to_text。这些函数 用于文本和词元表示之间的转换,本章将始终使用这种技术。
图 5-3 展示了使用 GPT 模型的三步文本生成过程:首先,分词器将输入文本转换为一系列词元 ID(参见第 2 章);然后,模型接收这些词元 ID 并生成相应的 logits,这些 logits 是表示词汇表中每个词元的概率分布的向量(参见第 4 章);最后,这些 logits 被转换回词元 ID,分词器会将其解码为人类可读的文本,这样就完成了从文本输入到文本输出的循环。

可以用代码清单 5-1 实现文本生成过程。
python
import tiktoken
from previous_chapters import generate_text_simple
# Alternatively:
# from llms_from_scratch.ch04 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))
写了
allowed_special={'<|endoftext|>'},就等于告诉 tiktoken 分词器:"文本里如果出现<|endoftext|>,请把它编成那个控制标记的 ID,而不是把它当作用户输入的字符。"其中关于
allowed_special字段:
allowed_special不写(默认):遇到<|endoftext|>字符串 → 报错allowed_special={'<|endoftext|>'}:遇到 → 编成特殊标记 IDdisallowed_special=()(把禁止集合清空):遇到 → 当成普通字符,拆成多个普通 token所以在给他人推理使用、处理用户输入时,更严谨的做法有两种:
- 保持默认(报错),这样能强制暴露问题,不会让特殊标记悄悄溜进去
- 或者显式设成"当普通字符处理",让用户输入里的
<|endoftext|>无害化
Output text:
Every effort moves you rentingetic wasnم refres RexMeCHicular stren
显然,由于尚未经过训练,模型还无法生成连贯的文本。要定义什么是"连贯"或"高质量"的文本,必须采用一种数值方法来评估生成的内容。这种方法使得我们能够在整个训练过程中监测和增强模型的性能。
接下来,我们将计算生成的输出结果的损失函数大小。这个损失值将作为训练进展和成功的衡量标准。此外,在后续章节中,在对大语言模型进行微调时,我们还将探索评估模型质量的其他方法。
关于BPE分词与qwen3 分词
分词(BPE)不是把整句一起编码,而是分两步走。第一步预分词(pre-tokenize) :先把句子按空格和标点切成一个个「词块」,并把空格替换成
Ġ。例如I like cats→['I', 'Ġlike', 'Ġcats']。第二步再对每个词块单独做 BPE 合并 ,所以 BPE 是一个词一个词地跑,而不是整句一起跑。BPE 合并的规则是自底向上、按 rank 从小到大合并。每个词块从单字符起步,循环查找当前所有相邻字符对里 rank 最小的那一对进行合并,直到再也没有可合并的对为止。以
Ġstudents为例,它从 9 个单字符['Ġ','s','t','u','d','e','n','t','s']出发,先把所有相邻对都列出来,然后找到那个是最小的,比如('s','t') → rank 11,然后合并,这两个就变成一个了,然后再从'Ġ', 'st', 'u', 'd', 'e', 'n', 't', 's'这个里面,把相邻对合并,继续比。其中如果同一对在序列里出现了多次,会一次性把所有这个对都合掉,而不是只合一个。比如序列里有两处 ('s','t'),这一轮会同时合并两处。下面这段代码就是把上面这套流程手写出来,并和官方
AutoTokenizer对照验证:
pythonimport json import re import os model_path = "--/Qwen3-1.7B" # ===== 从模型目录读 tokenizer.json ===== with open(os.path.join(model_path, "tokenizer.json"), encoding="utf-8") as f: tk = json.load(f) raw_merges = tk["model"]["merges"] merges = [] for m in raw_merges: merges.append(tuple(m) if isinstance(m, list) else tuple(m.split(" "))) rank = {pair: i for i, pair in enumerate(merges)} vocab = tk["model"]["vocab"] def get_pairs(tokens): return [(tokens[i], tokens[i + 1]) for i in range(len(tokens) - 1)] def bpe_encode(word, verbose=True): tokens = list(word) if verbose: print(f" 起点: {tokens}") step = 0 while True: pairs = get_pairs(tokens) candidates = [(p, rank[p]) for p in pairs if p in rank] if not candidates: break best = min(candidates, key=lambda x: x[1])[0] new_tokens, i = [], 0 while i < len(tokens): if i < len(tokens) - 1 and (tokens[i], tokens[i + 1]) == best: new_tokens.append(tokens[i] + tokens[i + 1]) i += 2 else: new_tokens.append(tokens[i]) i += 1 tokens = new_tokens step += 1 if verbose: print(f" 第{step}步: 合并 {best} (rank={rank[best]}) → {tokens}") return tokens def pre_tokenize(text): """简化的预分词:空格变 Ġ,标点单独切。仅用于演示英文。""" words = re.findall(r" ?\w+|[^\w\s]", text) return [w.replace(" ", "Ġ") for w in words] # ===== 用手写 BPE 编码一个完整句子 ===== sentence = "The students are learning natural language processing" print(f"原句: {sentence}\n") chunks = pre_tokenize(sentence) print(f"第一步 预分词,切成词块: {chunks}\n") print("第二步 对每个词块单独做 BPE:\n") all_tokens = [] for c in chunks: print(f" 词块 {c!r}:") toks = bpe_encode(c) all_tokens.extend(toks) print(f" → {toks}\n") print("=" * 50) print(f"[手写BPE] 最终 token 序列: {all_tokens}") print(f"[手写BPE] 对应 id: {[vocab.get(t) for t in all_tokens]}") print(f"[手写BPE] 共 {len(all_tokens)} 个 token\n") # ===== 和官方 AutoTokenizer 对照,验证手写版是否一致 ===== from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained(model_path, local_files_only=True) ids = tok.encode(sentence, add_special_tokens=False) official_tokens = tok.convert_ids_to_tokens(ids) print("=" * 50) print(f"[官方] 最终 token 序列: {official_tokens}") print(f"[官方] 对应 id: {ids}") print(f"[官方] 共 {len(ids)} 个 token") print("\n对比结果:", "✅ 完全一致" if official_tokens == all_tokens else "⚠️ 有差异(见下面说明)")运行结果如下,可以看到手写 BPE 和官方
AutoTokenizer的 token 序列、id 完全一致:
text原句: The students are learning natural language processing 第一步 预分词,切成词块: ['The', 'Ġstudents', 'Ġare', 'Ġlearning', 'Ġnatural', 'Ġlanguage', 'Ġprocessing'] 第二步 对每个词块单独做 BPE: 词块 'The': 起点: ['T', 'h', 'e'] 第1步: 合并 ('h', 'e') (rank=127) → ['T', 'he'] 第2步: 合并 ('T', 'he') (rank=529) → ['The'] → ['The'] 词块 'Ġstudents': 起点: ['Ġ', 's', 't', 'u', 'd', 'e', 'n', 't', 's'] 第1步: 合并 ('s', 't') (rank=11) → ['Ġ', 'st', 'u', 'd', 'e', 'n', 't', 's'] 第2步: 合并 ('e', 'n') (rank=12) → ['Ġ', 'st', 'u', 'd', 'en', 't', 's'] 第3步: 合并 ('en', 't') (rank=50) → ['Ġ', 'st', 'u', 'd', 'ent', 's'] 第4步: 合并 ('Ġ', 'st') (rank=101) → ['Ġst', 'u', 'd', 'ent', 's'] 第5步: 合并 ('u', 'd') (rank=405) → ['Ġst', 'ud', 'ent', 's'] 第6步: 合并 ('ent', 's') (rank=549) → ['Ġst', 'ud', 'ents'] 第7步: 合并 ('Ġst', 'ud') (rank=1417) → ['Ġstud', 'ents'] 第8步: 合并 ('Ġstud', 'ents') (rank=3887) → ['Ġstudents'] → ['Ġstudents'] 词块 'Ġare': 起点: ['Ġ', 'a', 'r', 'e'] 第1步: 合并 ('Ġ', 'a') (rank=8) → ['Ġa', 'r', 'e'] 第2步: 合并 ('r', 'e') (rank=9) → ['Ġa', 're'] 第3步: 合并 ('Ġa', 're') (rank=269) → ['Ġare'] → ['Ġare'] 词块 'Ġlearning': 起点: ['Ġ', 'l', 'e', 'a', 'r', 'n', 'i', 'n', 'g'] 第1步: 合并 ('i', 'n') (rank=2) → ['Ġ', 'l', 'e', 'a', 'r', 'n', 'in', 'g'] 第2步: 合并 ('l', 'e') (rank=17) → ['Ġ', 'le', 'a', 'r', 'n', 'in', 'g'] 第3步: 合并 ('a', 'r') (rank=21) → ['Ġ', 'le', 'ar', 'n', 'in', 'g'] 第4步: 合并 ('in', 'g') (rank=31) → ['Ġ', 'le', 'ar', 'n', 'ing'] 第5步: 合并 ('Ġ', 'le') (rank=256) → ['Ġle', 'ar', 'n', 'ing'] 第6步: 合并 ('n', 'ing') (rank=973) → ['Ġle', 'ar', 'ning'] 第7步: 合并 ('ar', 'ning') (rank=2921) → ['Ġle', 'arning'] 第8步: 合并 ('Ġle', 'arning') (rank=6576) → ['Ġlearning'] → ['Ġlearning'] 词块 'Ġnatural': 起点: ['Ġ', 'n', 'a', 't', 'u', 'r', 'a', 'l'] 第1步: 合并 ('a', 't') (rank=10) → ['Ġ', 'n', 'at', 'u', 'r', 'a', 'l'] 第2步: 合并 ('a', 'l') (rank=22) → ['Ġ', 'n', 'at', 'u', 'r', 'al'] 第3步: 合并 ('Ġ', 'n') (rank=52) → ['Ġn', 'at', 'u', 'r', 'al'] 第4步: 合并 ('u', 'r') (rank=68) → ['Ġn', 'at', 'ur', 'al'] 第5步: 合并 ('at', 'ur') (rank=2372) → ['Ġn', 'atur', 'al'] 第6步: 合并 ('atur', 'al') (rank=4329) → ['Ġn', 'atural'] 第7步: 合并 ('Ġn', 'atural') (rank=5554) → ['Ġnatural'] → ['Ġnatural'] 词块 'Ġlanguage': 起点: ['Ġ', 'l', 'a', 'n', 'g', 'u', 'a', 'g', 'e'] 第1步: 合并 ('a', 'n') (rank=20) → ['Ġ', 'l', 'an', 'g', 'u', 'a', 'g', 'e'] 第2步: 合并 ('Ġ', 'l') (rank=70) → ['Ġl', 'an', 'g', 'u', 'a', 'g', 'e'] 第3步: 合并 ('a', 'g') (rank=95) → ['Ġl', 'an', 'g', 'u', 'ag', 'e'] 第4步: 合并 ('ag', 'e') (rank=168) → ['Ġl', 'an', 'g', 'u', 'age'] 第5步: 合并 ('an', 'g') (rank=268) → ['Ġl', 'ang', 'u', 'age'] 第6步: 合并 ('ang', 'u') (rank=2069) → ['Ġl', 'angu', 'age'] 第7步: 合并 ('angu', 'age') (rank=2360) → ['Ġl', 'anguage'] 第8步: 合并 ('Ġl', 'anguage') (rank=3872) → ['Ġlanguage'] → ['Ġlanguage'] 词块 'Ġprocessing': 起点: ['Ġ', 'p', 'r', 'o', 'c', 'e', 's', 's', 'i', 'n', 'g'] 第1步: 合并 ('i', 'n') (rank=2) → ['Ġ', 'p', 'r', 'o', 'c', 'e', 's', 's', 'in', 'g'] 第2步: 合并 ('Ġ', 'p') (rank=25) → ['Ġp', 'r', 'o', 'c', 'e', 's', 's', 'in', 'g'] 第3步: 合并 ('in', 'g') (rank=31) → ['Ġp', 'r', 'o', 'c', 'e', 's', 's', 'ing'] 第4步: 合并 ('e', 's') (rank=32) → ['Ġp', 'r', 'o', 'c', 'es', 's', 'ing'] 第5步: 合并 ('r', 'o') (rank=43) → ['Ġp', 'ro', 'c', 'es', 's', 'ing'] 第6步: 合并 ('es', 's') (rank=177) → ['Ġp', 'ro', 'c', 'ess', 'ing'] 第7步: 合并 ('Ġp', 'ro') (rank=206) → ['Ġpro', 'c', 'ess', 'ing'] 第8步: 合并 ('c', 'ess') (rank=864) → ['Ġpro', 'cess', 'ing'] 第9步: 合并 ('Ġpro', 'cess') (rank=1626) → ['Ġprocess', 'ing'] 第10步: 合并 ('Ġprocess', 'ing') (rank=8436) → ['Ġprocessing'] → ['Ġprocessing'] ================================================== [手写BPE] 最终 token 序列: ['The', 'Ġstudents', 'Ġare', 'Ġlearning', 'Ġnatural', 'Ġlanguage', 'Ġprocessing'] [手写BPE] 对应 id: [785, 4143, 525, 6832, 5810, 4128, 8692] [手写BPE] 共 7 个 token ================================================== [官方] 最终 token 序列: ['The', 'Ġstudents', 'Ġare', 'Ġlearning', 'Ġnatural', 'Ġlanguage', 'Ġprocessing'] [官方] 对应 id: [785, 4143, 525, 6832, 5810, 4128, 8692] [官方] 共 7 个 token 对比结果: ✅ 完全一致
理解了分词流程后,再看 Qwen3-1.7B 的模型目录,文件大致分三类。
一、模型权重类(模型的「大脑」,占绝大部分体积)
文件 作用 model-00001-of-00002.safetensors模型权重的第 1 分片 model-00002-of-00002.safetensors模型权重的第 2 分片 model.safetensors.index.json分片索引:记录哪个参数在哪个分片里 因为模型太大,权重被切成两个
.safetensors文件。加载时先读index.json,它像一张地图告诉框架「层 X 的权重在分片 1,层 Y 在分片 2」,然后把两个分片拼起来还原成完整模型。safetensors是一种安全、快速的权重存储格式,用来替代早期的.bin/pickle。二、模型配置类(描述模型「长什么样、怎么跑」)
文件 作用 config.json模型结构定义:层数、隐藏维度、注意力头数、vocab_size 等,加载模型必读 generation_config.json生成时的默认参数:temperature、top_p、max_length、eos_token 等 configuration.jsonModelScope 平台的配置文件(非 HF 标准),记录框架/任务类型等元信息
config.json决定模型骨架怎么搭,权重按这个骨架填进去;generation_config.json只在调用.generate()推理时提供默认采样参数。三、分词器类(文本 ↔ token id 的转换)
文件 作用 tokenizer.json完整的分词器,一个文件包含全部:vocab + merges + 规则 + 特殊 token vocab.jsontoken → id 的词表 merges.txtBPE 合并规则 tokenizer_config.json分词器配置:特殊 token 定义、chat template、是否加 bos 等 关键点:
tokenizer.json是新版一体化格式,内部已经包含 vocab 和 merges 的全部内容;而vocab.json+merges.txt是老版 GPT-2 风格的分离格式。三者信息高度重复:tokenizer.json ⊇ vocab.json + merges.txt。HF 的 fast tokenizer 会优先读tokenizer.json,只有在没有它时才回退去读vocab.json+merges.txt,所以它们同时存在只是为了兼容不同加载方式,内容基本冗余。tokenizer_config.json则不存词表,而是存「怎么用这个词表」:比如<|im_start|>、<|im_end|>这些特殊 token 是什么、chat template 长什么样。一次推理时这些文件如何协作:
- 读
config.json→ 搭出模型骨架- 读
model.safetensors.index.json+ 两个.safetensors→ 把权重填进骨架- 读
tokenizer.json(或 vocab+merges)+tokenizer_config.json→ 把文字切成 token id- 模型算出下一个 token id
- 用
generation_config.json的参数控制采样- 再用分词器把 id 解码回文字
5.1.2 计算文本生成损失
接下来,让我们探讨在训练过程中通过计算文本生成损失来对生成的文本质量进行数值评估的技术。我们将通过一个实际的例子逐步讲解这种技术,以使概念清晰易懂且易于应用。首先,让我们简要回顾一下如何加载数据以及如何通过 generate_text_simple 函数生成文本。
图 5-4 通过一个 5 步过程展示了从输入文本到大语言模型生成文本的整体流程。这个文本生成过程展示了 generate_text_simple 函数在内部执行的操作。在计算衡量生成文本质量的损失之前,我们需要先执行这些相同的初始步骤。

图 5-4 概述了文本生成过程。为了将该图片完整地放在一个页面上,我们使用了一张包含 7 个词元的小词汇表。然而,我们的 GPTModel 使用的是一张由 50 257 个单词组成的更大的词汇表。因此,接下来的代码中的词元 ID 范围将是从 0 到 50 256,而不是从 0 到 6。
此外,为了简化展示,图 5-4 仅显示了一个文本示例("every effort moves")。在下面 实现了图中步骤的实际代码示例中,我们将使用两个输入示例("every effort moves"和"I really like")进行模型操作。
考虑以下两个输入示例,它们已经被映射词元 ID(参见图 5-4 的第 1 步),与上述输入相匹配,targets 是我们希望模型生成的词元 ID:
python
inputs = torch.tensor([[16833, 3626, 6100], # ["every effort moves",
[40, 1107, 588]]) # "I really like"]
targets = torch.tensor([[3626, 6100, 345 ], # [" effort moves you",
[1107, 588, 11311]]) # " really like chocolate"]
请注意,targets 是对输入数据的复制,但向前移动了一个位置,这是我们在第 2 章中实现数据加载器时讨论过的概念。这种移位策略对指导模型预测序列中的下一个词元至关重要。
现在,将这些输入提供给模型,为包含 3 个词元的两个输入示例计算 logits 向量。然后,应用 softmax 函数将这些 logits 转换为概率分数(probas,参见图 5-4 的第 2 步):
python
with torch.no_grad():
logits = model(inputs)
probas = torch.softmax(logits, dim=-1) # Probability of each token in vocabulary
print(probas.shape) # Shape: (batch_size, num_tokens, vocab_size)
概率分数(probas)张量的最终张量维度如下所示:
torch.Size([2, 3, 50257])
第一个数值 2 对应于输入中的两个示例(行),也称为"批次大小"。第二个数值 3 对应于每个输入(行)中的词元数量。最后一个数值 50257 对应于嵌入维度,由词汇表大小确定。通过 softmax 函数将 logits 转换为概率后,generate_text_simple 函数会将结果概率分数转换回文本(参见图 5-4 的第 3~5 步)。
可以通过将 argmax 函数应用于概率分数来完成第 3 步和第 4 步,从而获得相应的词元 ID:
python
token_ids = torch.argmax(probas, dim=-1, keepdim=True)
print("Token IDs:\n", token_ids)
考虑到我们有两个包含 3 个词元的输入批次,将 argmax 函数应用于概率分数(参见图 5-4 的第 3 步)会产生两组输出,每组包含 3 个预测的词元 ID:
Token IDs:
tensor([[[16657],
[ 339],
[42826]],
[[49906],
[29669],
[41751]]])
最后,第 5 步会将词元 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)}")
flatten()是 PyTorch 中的张量操作,功能是将多维张量展平为一维张量,即把所有维度合并成一个连续的序列。以数据为例,张量形状
[2, 3, 1]表示 2 个 batch,每个 batch 包含 3 个 token,且每个 token 被额外包裹了一个维度。取第一个 batch
token_ids[0],其形状为[3, 1]:
python[[16657], [ 339], [42826]]经过
flatten()处理后,多余的维度被消除,张量降为一维,形状变为[3]:
python[16657, 339, 42826]此结果与
targets[0]的形状和内容一致:tensor([16657, 339, 42826])。因此,使用
flatten()的目的是:将模型输出中形如[[16657],[339],[42826]]的二维结构展平为一维的 token id 序列,使其符合token_ids_to_text函数对输入格式的要求,从而正确地将 id 转换为文本。
在解码这些词元时,我们发现这些输出词元与我们希望模型生成的目标词元非常不同:
Targets batch 1: effort moves you
Outputs batch 1: Armed heNetflix
模型生成的随机文本与目标文本不同的原因是它尚未经过训练。
现在,我们希望通过损失指标(参见图 5-5)来量化评估模型生成的文本的性能。这不仅有助于衡量生成的文本的质量,同时也是实现训练函数的一个构建块,我们将使用它来更新模型的权重,从而改善生成的文本。

如图 5-5 所示,文本评估过程的一部分是衡量生成词元与正确预测(目标)之间的偏差程度。 我们稍后实现的训练函数将使用这些信息来调整模型权重,以生成更接近(或理想情况下更匹配)目标文本的文本。
模型训练的目标是增大与正确目标词元 ID 对应的索引位置的 softmax 概率,如图 5-6 所示。这个 softmax 概率也将用于我们接下来要实现的评估指标中,以量化评估模型生成的输出:正确位置的概率越高,效果越好。

请记住,为了将所有内容放入一张图中,图 5-6 展示了一个紧凑的七词元词汇表的 softmax 概率。这意味着起始的随机值将在 1/7 左右,大约等于 0.14。然而,我们用于 GPT-2 模型的词汇表有 50 257 个词元 ,因此大多数初始概率将在 0.000 02 左右(1/50 257)。
对于两个输入文本中的每一个,可以使用以下代码打印与目标词元对应的初始 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 个目标词元 ID 概率分数如下所示:
训练大语言模型的目标是最大化正确词元的可能性,这涉及增大其相对于其他词元的概率。通过这种方式,可以确保大语言模型始终选择目标词元(实质上是句子中的下一个单词)作为它生成的下一个词元。
probas 与索引操作【这一小节的总结】
整体流程:
输入文本 → 模型 → logits → softmax → probas → 取概率算loss
数据长什么样:
inputs (模型的输入) targets (正确答案,每个词往后挪一位) ┌──────┬──────┬──────┐ ┌──────┬──────┬──────┐ │16833 │ 3626 │ 6100 │ 文本0 │ 3626 │ 6100 │ 345 │ │every │effort│moves │ │effort│moves │ you │ └──────┴──────┴──────┘ └──────┴──────┴──────┘模型任务:看到
every要预测出effort,看到every effort要预测出moves......
probas 的真实形状是
[2, 3, 50257],它是一个"概率表格"。看文本0(probas[0],形状[3, 50257]):
词0 词1 词2 ... 词3626 ... 词16657 ... 词50256 ┌───────┬───────┬───────┬─────┬───────┬────┬───────┬───────┐ 位置0 │0.00001│0.00002│0.00001│ ... │0.00007│... │0.00019│ ... │← 全部相加=1 ├───────┼───────┼───────┼─────┼───────┼────┼───────┼───────┤ 位置1 │0.00003│0.00001│0.00002│ ... │ ... │... │ ... │ ... │ ← 全部相加=1 ├───────┼───────┼───────┼─────┼───────┼────┼───────┼───────┤ 位置2 │0.00002│0.00004│0.00003│ ... │ ... │... │ ... │ ... │ ← 全部相加=1 └───────┴───────┴───────┴─────┴───────┴────┴───────┴───────┘每一行有 50257 个数字,是"下一个词是词表中每个词的概率"。
argmax = 每一行里挑出最大的那个数,返回它的列号(词ID):
位置0 这一行 → 最大的在第16657列 → token_ids = 16657 (模型瞎猜的) 位置1 这一行 → 最大的在第 339 列 → token_ids = 339 位置2 这一行 → 最大的在第42826列 → token_ids = 42826⚠️ 期望 3626 但得到 16657,是模型还没训练。
索引操作在干嘛:argmax 是"模型你猜哪个词?",索引操作是"模型你给正确答案那一列打了多少分?"
pythonprobas[0, [0,1,2], [3626, 6100, 345]] ↑ ↑ 三个位置 每个位置的正确词ID就是在表格里精准戳三个格子:
... 词345 ... 词3626 ... 词6100 ... ┌────┬───────┬────┬───────┬────┬───────┬────┐ 位置0 │... │ ... │... │★0.00007│...│ ... │... │ ← 戳"词3626"这格 ├────┼───────┼────┼───────┼────┼───────┼────┤ 位置1 │... │ ... │... │ ... │... │★0.00003│... │ ← 戳"词6100"这格 ├────┼───────┼────┼───────┼────┼───────┼────┤ 位置2 │... │★0.00001│... │ ... │... │ ... │... │ ← 戳"词345"这格 └────┴───────┴────┴───────┴────┴───────┴────┘ 位置维度: 0 1 2 词ID维度: 3626 6100 345 └─┬─┘ └─┬─┘ └─┬─┘ 配对1 配对2 配对3 probas[0, [0, 1, 2], targets[0]]"在第0个文本里,位置0取词3626的概率、位置1取词6100的概率、位置2取词345的概率,一次全给我拿出来。"
三个索引一一配对:
(位置0, 词3626) → 0.00007 (位置1, 词6100) → 0.00003 (位置2, 词 345) → 0.00001取出来就是
Text 1: tensor([7.4540e-05, 3.1061e-05, 1.1563e-05])
反向传播
如何最大化与目标词元对应的 softmax 概率值呢?大致思路是,更新模型权重,以便模型为我们想要生成的相应词元 ID 输出更高的值。权重更新是通过一种称为反向传播的过程完成的,这是训练深度神经网络的标准技术(有关反向传播和模型训练的更多细节,请参见 A.3 节~A.7 节)。
反向传播需要一个损失函数,它会计算模型的预测输出(在这里是与目标词元 ID 对应的概率)与实际期望输出之间的差异。这个损失函数衡量的是模型的预测与目标值之间的偏差。
接下来,我们将计算两个示例批次的概率分数的损失,即 target_probas_1 和 target_ probas_2。主要步骤如图 5-7 所示。我们已经应用了第 ❶ ~ ❸ 步来获取 target_probas_1 和 target_probas_2,接下来会继续进行第 ❹ 步,对概率分数应用对数:
python
# Compute logarithm of all token probabilities
log_probas = torch.log(torch.cat((target_probas_1, target_probas_2)))
print(log_probas)
tensor([ -9.5042, -10.3796, -11.3677, -11.4798, -9.7764, -12.2561])

在数学优化中,使用概率分数的对数比直接处理分数更容易操作。这个话题超出了本书的范畴,如果你对此感兴趣,可以参见附录 B。
接下来,我们通过计算平均值将这些对数概率组合成一个单一分数(参见图 5-7 的第❺ 步),得到的平均对数概率分数如下所示:
python
# Calculate the average probability for each token
avg_log_probas = torch.mean(log_probas)
print(avg_log_probas)
tensor(-10.7940)
我们的目标是通过在训练过程中更新模型的权重,使平均对数概率尽可能接近 0。然而,在深度学习中,通常的做法不是将平均对数概率升至 0,而是将负平均对数概率降至 0。负平均对数概率就是平均对数概率乘以-1,对应于图 5-7 中的第 ❻ 步:
python
neg_avg_log_probas = avg_log_probas * -1
print(neg_avg_log_probas)
tensor(10.7940)
这将输出 tensor(10.7940)。在深度学习中,将-10.7940 这个负值转换为 10.7940 的术语称为交叉熵损失。PyTorch 在这里派上了用场,因为它有一个内置的 cross_entropy 函数,该函数可以为我们处理图 5-7 中的所有步骤。
交叉熵损失
在机器学习和深度学习中,交叉熵损失是一种常用的度量方式,用于衡量两个概率分布 之间的差异------通常是标签(在这里是数据集中的词元)的真实分布和模型生成的预测分布(例如,由大语言模型生成的词元概率)之间的差异。
在机器学习的背景下,特别是在像 PyTorch 这样的框架中,交叉熵函数可以对离散的结 果进行度量,类似于给定模型生成的词元概率时目标词元的负平均对数概率。因此,在实践中,"交叉熵"和"负平均对数概率"这两个术语是相关的,且经常可以互换使用。
在应用 cross_entropy 函数之前,先简要回顾一下 logits 张量和 targets 张量的形状:
python
# Logits have shape (batch_size, num_tokens, vocab_size)
print("Logits shape:", logits.shape)
# Targets have shape (batch_size, num_tokens)
print("Targets shape:", targets.shape)
print(targets)
Logits shape: torch.Size([2, 3, 50257])
Targets shape: torch.Size([2, 3])
tensor([[ 3626, 6100, 345],
[ 1107, 588, 11311]])
如你所见,logits 张量具有 3 个维度:批处理大小、词元数量和词汇表大小。targets 张量则具有两个维度:批处理大小和词元数量。
对于 PyTorch 中的交叉熵损失函数,我们希望通过在批处理维度上将它们组合在一起来展平这些张量:
python
logits_flat = logits.flatten(0, 1)
targets_flat = targets.flatten()
print("Flattened logits:", logits_flat.shape)
print("Flattened targets:", targets_flat.shape)
Flattened logits: torch.Size([6, 50257])
Flattened targets: torch.Size([6])
请记住,targets 是我们希望大语言模型生成的词元 ID,而 logits 是在进入 softmax 函数以获取概率分数之前的未经缩放的模型输出。
先前,我们应用 softmax 函数,选择了与目标 ID 对应的概率分数,并计算了负对数概率的平均值。PyTorch 的 cross_entropy 函数将为我们处理所有这些步骤:
python
loss = torch.nn.functional.cross_entropy(logits_flat, targets_flat)
print(loss)
tensor(10.7940)
得到的损失与我们以前手动应用图 5-7 中的各个步骤时获得的损失相同,为 tensor(10.7940)。
困惑度
困惑度通常与交叉熵损失一起用来评估模型在诸如语言建模等任务中的性能。它可以提 供一种更易解释的方式来理解模型在预测序列中的下一个词元时的不确定性。
困惑度可以衡量模型预测的概率分布与数据集中实际词汇分布的匹配程度。与损失类 似,较低的困惑度表明模型的预测更接近实际分布。
困惑度可以通过 perplexity = torch.exp(loss)计算得出,在先前计算的损失上应用该公式会得到 tensor(48725.8203)。
困惑度通常被认为比原始损失值更易于解释,因为它表示模型在每一步中对于有效词汇 量的不确定性。在给定的示例中,这意味着模型不确定在词汇表的 48 725 个词元中应该生成哪个来作为下一个词元。
困惑度的公式非常简单,就一步:
pythonperplexity = torch.exp(loss)在这个例子里:
pythonperplexity = torch.exp(torch.tensor(10.7940)) # = 48725.8203loss 本身是对数算出来的。既然它是通过 log(取对数)得到的,那要还原回"概率的量级",自然就要用 exp(取指数)来抵消掉这个对数。log 和 exp 是一对互逆运算:eln(x)=xe^{\ln(x)} = xeln(x)=x。
困惑度可以理解成模型在预测下一个词时,相当于在多少个词元里"瞎猜"。如果模型完美预测(每次都 100% 确定),loss = 0,那么 perplexity = e0e^0e0 = 1,表示"毫不困惑,锁定 1 个词"。现在 perplexity ≈ 48726,而词汇表总共才 50257 个词,说明这个还没训练的模型基本就是在几乎整个词汇表里随机乱猜,完全没学会。
用均匀乱猜这个情况把整条计算链走一遍。设词表大小 V=50257V = 50257V=50257,均匀乱猜时每个词的概率都是 1V\frac{1}{V}V1。
第 1 步,取对数。对正确词的概率 1V\frac{1}{V}V1 取对数:
ln(1V)=−ln(V) \ln\left(\frac{1}{V}\right) = -\ln(V) ln(V1)=−ln(V)
(用了对数性质:ln1V=−lnV\ln\frac{1}{V} = -\ln VlnV1=−lnV)
第 2 步,求平均、取负,得到 loss。每个词都一样,所以平均还是 −ln(V)-\ln(V)−ln(V),再取负:
loss=−(−ln(V))=ln(V) \text{loss} = -\big(-\ln(V)\big) = \ln(V) loss=−(−ln(V))=ln(V)
loss 正好等于 ln(V)\ln(V)ln(V)。
第 3 步,算困惑度(取指数):
perplexity=eloss=eln(V)=V \text{perplexity} = e^{\text{loss}} = e^{\ln(V)} = V perplexity=eloss=eln(V)=V
需要注意:50257 这个上限指的是均匀分布(每个词概率相等)的情况。严格来说困惑度在数学上没有硬性最大值------如果模型把高概率押在错误的词上、反而给正确词极低的概率(比如低于 1/v1/v1/v),困惑度是可以超过 50257 的。不过正常初始化的未训练模型输出接近均匀分布,困惑度只会在 50257 附近小幅波动(本例的 48726 就略低于它),不会离谱地飙升。如果哪天看到困惑度是词表大小的好几倍,那通常不是"没训练"的正常表现,而是初始化、标签错位或学习率发散等 bug 的报警信号。
为了方便讲解,我们计算了两个短文本输入的损失。接下来,我们将对整个训练集和验证集计算损失。
5.1.3 计算训练集和验证集的损失
首先,需要准备用于训练大语言模型的训练数据集和验证数据集。然后,如图 5-8 的突出显示部分所示,我们将计算训练集和验证集的交叉熵,这是模型训练过程中的重要组成部分。

为了计算训练数据集和验证数据集上的损失,我们使用了一个非常小的文本数据集,即 Edith Wharton 的短篇小说 The Verdict(第 2 章中使用过)。通过选择来自公共领域的文本,我们规避了与使用权相关的任何问题。此外,使用如此小的数据集,即使没有高端的 GPU,也可以在几分钟内在标准笔记本电脑上执行代码示例,这将有利于教学。
注意
如果你对此感兴趣,还可以使用本书的补充代码来准备一个由 60 000 多本来自古腾堡计 划的公共领域图书组成的更大规模的数据集,并在此基础上训练一个大语言模型(详情 请参阅附录 D) 。
预训练大语言模型的代价
为了使项目规模更具体,可以考虑训练参数量为 70 亿的 Llama 2 模型,这是一个相对流行且公开可用的大语言模型。该模型预训练时处理了2万亿个词元,在昂贵的 A100 GPU上训练了 184 320 GPU 小时。在撰写本书时,在 AWS 上运行一个 8×A100 云服务器的成本约为每小时 30 美元。粗略估计,这样一个大语言模型的总训练成本约为 690 000 美元(计算方法为 184 320 小时除以 8,然后乘以 30)。
下面的代码加载了短篇小说 The Verdict:
python
import os
import requests
file_path = "the-verdict.txt"
url = "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt"
if not os.path.exists(file_path):
response = requests.get(url, timeout=30)
response.raise_for_status()
text_data = response.text
with open(file_path, "w", encoding="utf-8") as file:
file.write(text_data)
else:
with open(file_path, "r", encoding="utf-8") as file:
text_data = file.read()
加载完数据集后,可以检查一下数据集中的字符数和词元数:
python
total_characters = len(text_data)
total_tokens = len(tokenizer.encode(text_data))
print("Characters:", total_characters)
print("Tokens:", total_tokens)
Characters: 20479
Tokens: 5145
虽然这个文本只有 5145 个词元,可能看起来太小,无法用来训练大语言模型,但正如前面提到的,这是出于教学目的,以便我们可以在几分钟(而不是几周)内运行代码。此外,稍后我们会将来自 OpenAI 的预训练权重加载到 GPTModel 代码中。
接下来,我们将数据集分成训练集和验证集,并使用第 2 章中的数据加载器来准备大语言模型训练所需的批次数据。这个过程在图 5-9 中进行了可视化展示。由于空间限制,我们使用了 max_length=6。然而,对于实际的数据加载器,可以将 max_length 设置为 256 个词元的上下文长度,以便训练期间大语言模型能够看到更长的文本。
max_length和context_length概念上是一回事------都指模型能看到的序列长度。但在书的代码里,它们出现在不同地方,扮演的角色略有区别。
max_length是数据侧的参数。它出现在数据加载器(data loader)里,控制从文本中切出的每个训练样本有多少个词元,决定了喂给模型的输入序列实际有多长。
pythontrain_loader = create_dataloader_v1( text_data, batch_size=2, max_length=256, # 每个样本切成 256 个词元 stride=256, )
context_length是模型侧的参数。它出现在模型配置里,决定模型架构能支持的最大序列长度。它主要影响位置嵌入(positional embedding)表的大小------模型需要为每个位置准备一个位置向量,所以必须提前知道最大能处理多长。
pythonGPT_CONFIG_124M = { "vocab_size": 50257, "context_length": 256, # 模型最多能处理 256 个位置 "emb_dim": 768, }两者的关系:它们必须匹配,或者
max_length ≤ context_length。实际喂进去的序列长度不能超过模型架构所能容纳的最大长度,否则位置嵌入表就不够用,会报错。打个比方:
context_length是「座位总数」------房间里最多能坐多少人(模型架构的上限)。max_length是「这次实际来了多少人」------这一批数据实际占用了多少个位置。所以两者描述的是同一个「上下文长度」概念,只是一个是模型能力的上限,一个是数据实际使用的长度。
注意
为了简化操作并提高效率, 我们使用以大小相似的块呈现的训练数据来训练模型。 然 而,在实践中,使用不同长度的输入来训练大语言模型也是有益的,因为这有助于大语 言模型在使用中更好地概括不同类型的输入。
为了实现数据拆分和加载,首先定义一个 train_ratio,使用 90%的数据进行训练,剩余的10%作为验证数据,以便在训练过程中对模型进行评估(见下)

接下来,可以利用 train_data 和 val_data 创建相应的数据加载器,重用第 2 章中的create_ dataloader_v1 代码:
python
from previous_chapters import create_dataloader_v1
# Alternatively:
# from llms_from_scratch.ch02 import create_dataloader_v1
# Train/validation ratio
train_ratio = 0.90
split_idx = int(train_ratio * len(text_data))
train_data = text_data[:split_idx]
val_data = text_data[split_idx:]
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
)
max_length和stride都等于context_length,两个值相等才是"没有重叠"的原因。
- max_length:每个样本(序列)的长度,也就是一刀切下来多长
- stride:切下一个样本时,窗口往后滑动多少个token
当
stride == max_length时,窗口每次正好滑动一整个样本的长度,所以相邻样本首尾相接、不重叠:
原始token序列: [0 1 2 3 4 5 6 7 8 9 ...] 假设 max_length=4, stride=4 样本1: [0 1 2 3] 样本2: [4 5 6 7] 样本3: [8 9 ...] ← 没有任何token被重复使用如果 stride 小于 max_length,就会重叠。比如
max_length=4, stride=2:
样本1: [0 1 2 3] 样本2: [2 3 4 5] ← 2、3 被重复使用了 样本3: [4 5 6 7]
因为我们处理的是一个非常小的数据集,所以使用了相对较小的批次大小来减少对计算资源的需求。在实践中,更常见的是使用 1024 或更大的批次大小来训练大语言模型。
作为一个可选的检查操作,我们可以遍历数据加载器,确保它们被正确创建:
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)
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])
一、数据拆分与批次数量
重点:数据拆分是按字符数 切的,不是按词元数。
数字流程:
总字符数 = 20479 split_idx = int(0.90 × 20479) = 18431按字符切成两段:
train_data = text_data[:18431] → 18431 个字符(前 90%) val_data = text_data[18431:] → 2048 个字符(后 10%)然后各自 tokenize(转成词元):
train_data → 约 4608 个词元 val_data → 约 537 个词元再按 256 切样本、按 2 组批次:
训练:4608 / 256 ≈ 18 个样本 → 18 / 2 = 9 个批次 ✅ 9 行 验证:537 / 256 ≈ 2 个样本 → 2 / 2 = 1 个批次 ✅ 1 行其中训练集 drop_last=True,验证集 drop_last=False。
二、从词元到批次会经历两层丢弃
上面 4608、537 这些词元转成批次的过程中,其实经历了两层丢弃。用小数字例子理解:假设有 10 个词元 ,
max_length=3、stride=3、batch_size=2。第一层:切样本(滑动窗口)
窗口每次抓 3 个词元,从头往后滑:
词元序号: [0 1 2 3 4 5 6 7 8 9] 共 10 个 样本1: [0 1 2] ✅ 满 3 个 样本2: [3 4 5] ✅ 满 3 个 样本3: [6 7 8] ✅ 满 3 个 剩下: [9] ❌ 只剩 1 个,不够 3,丢掉!10 个词元 → 切出 3 个样本 ,尾巴的第 9 号词元被丢弃。
→ 凑不满一个完整样本(max_length)的尾巴词元,被扔掉。
第二层:凑批次(DataLoader)
现在有 3 个样本,
batch_size=2,每 2 个打包成一批:
样本1, 样本2 → 批次1 ✅ 满 2 个 → 形状 [2, 3] 样本3 → 批次2 ❌ 只剩 1 个,不够 2
drop_last决定批次2的命运:
drop_last=True → 批次2 丢掉,最终得 1 个批次 drop_last=False → 批次2 保留,形状 [1, 3],最终得 2 个批次→ 凑不满一个完整批次(batch_size)的尾巴样本,由 drop_last 决定去留。
两层对比
第一层 词元 → 样本 尾巴词元不够 max_length → 永远丢(切片阶段,没得商量) 第二层 样本 → 批次 尾巴样本不够 batch_size → drop_last 说了算关键区别:
- 第一层丢的是词元 ,判断标准是
max_length,强制丢,没有开关。- 第二层丢的是样本 ,判断标准是
batch_size,有开关drop_last。
根据上面的代码输出,我们有 9 个训练集批次,其中每个批次包含两个样本,每个样本包含 256 个词元。由于我们仅将 10%的数据用于验证,因此只有一个包含两个输入示例的验证批次。正如预期的那样,输入数据(x)和目标数据(y)具有相同的形状(批次大小×每个批次中的词元数), 因为 targets 是将输入向后移动一个位置得到的,正如第2章中所述。
统计实际词元数(事后统计)
跑一遍 dataloader,数一数经过两层丢弃后,实际留下多少词元。
python
train_tokens = 0
for input_batch, target_batch in train_loader:
train_tokens += input_batch.numel()
val_tokens = 0
for input_batch, target_batch in val_loader:
val_tokens += input_batch.numel()
print("Training tokens:", train_tokens)
print("Validation tokens:", val_tokens)
print("All tokens:", train_tokens + val_tokens)
Training tokens: 4608
Validation tokens: 512
All tokens: 5120
input_batch是一个批次的张量,形状[batch_size, max_length],比如[2, 256].numel()= number of elements = 张量里元素总个数 =2 × 256 = 512- 循环遍历所有批次累加,就得到实际用到的词元总数
接下来,我们实现一个工具函数,用于计算通过训练集加载器和验证集加载器返回的给定批次的交叉熵损失。
python
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch, target_batch = input_batch.to(device), target_batch.to(device)
logits = model(input_batch)
loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1), target_batch.flatten())
return loss
现在可以使用 calc_loss_batch 工具函数(该函数计算单个批次的损失)来实现代码清单5-2 中的 calc_loss_loader 函数(该函数计算由给定数据加载器采样的所有批次的损失)。
python
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)
else:
# Reduce the number of batches to match the total number of batches in the data loader
# if num_batches exceeds the number of batches in the data loader
num_batches = min(num_batches, len(data_loader))
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()
else:
break
return total_loss / num_batches
默认情况下,calc_loss_loader 函数会遍历给定数据加载器中的所有批次,将损失累积在 total_loss 变量中,然后计算所有批次的损失的平均值。或者,可以通过num_batches 指定较小的批次数,以加快模型训练期间的评估速度。
现在我们来看看这个 calc_loss_loader 函数的实际应用。我们将把它应用到训练集和验证集的加载器上:
python
if torch.cuda.is_available():
device = torch.device("cuda")
elif torch.backends.mps.is_available():
# Use PyTorch 2.9 or newer for stable mps results
major, minor = map(int, torch.__version__.split(".")[:2])
if (major, minor) >= (2, 9):
device = torch.device("mps")
else:
device = torch.device("cpu")
else:
device = torch.device("cpu")
print(f"Using {device} device.")
model.to(device) # no assignment model = model.to(device) necessary for nn.Module classes
torch.manual_seed(123) # For reproducibility due to the shuffling in the data loader
with torch.no_grad(): # Disable gradient tracking for efficiency because we are not training, yet
train_loss = calc_loss_loader(train_loader, model, device)
val_loss = calc_loss_loader(val_loader, model, device)
print("Training loss:", train_loss)
print("Validation loss:", val_loss)
Using cuda device.
Training loss: 10.987583690219456
Validation loss: 10.98110580444336
由于模型尚未经过训练,因此损失值相对较高。相比之下,如果模型学会按照训练集和验证集中词元的出现顺序生成下一个词元,那么损失将接近于 0。
现在我们有了一种衡量生成文本质量的方法,我们将训练大语言模型以减少这种损失,使其 在生成文本方面变得更好,如图 5-10 所示。

接下来,我们将专注于预训练大语言模型。在模型训练之后,我们将实施替代的文本生成策略,并保存和加载预训练模型权重。
5.2 训练大语言模型
现在终于到了实现预训练大语言模型的代码,也就是我们的 GPTModel 模型的时候了。为此,我们聚焦于一个简单的训练循环,以保持代码简洁易读。
注意
如果你对此感兴趣,可以在附录 D 中了解更高级的技术,包括学习率预热、 余弦衰减和 梯度裁剪。
图 5-11 描述了一个典型的 PyTorch 神经网络训练工作流程,我们将使用它来训练一个大语言模型。它概述了 8 个步骤,从遍历每个训练轮次开始,处理批次,重置梯度,计算损失和新梯度,更新权重,最后以监控步骤(包括打印损失、生成文本样本等操作)结束。

注意
如果你对使用 PyTorch 训练深度神经网络还比较陌生,或者对这些步骤中的任何一个不熟悉,请考虑阅读 A.5 节~A.8 节。
python
def train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs,
eval_freq, eval_iter, start_context, tokenizer):
# 三个空列表,用来记录每次评估时的训练损失、验证损失、已见过的token数
train_losses, val_losses, track_tokens_seen = [], [], []
# 计数器:累计处理的token总数、全局训练步数(-1表示还没开始,第一步会+1变成0)
tokens_seen, global_step = 0, -1
# Main training loop
for epoch in range(num_epochs):
# nn.Module 自带的方法,切换到训练模式让 Dropout 生效
model.train()
# input_batch 是输入,target_batch 是标准答案(input_batch 往后移动一个token)
for input_batch, target_batch in train_loader:
optimizer.zero_grad() # Reset loss gradients from previous batch iteration
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward() # Calculate loss gradients
optimizer.step() # Update model weights using loss gradients
tokens_seen += input_batch.numel()
global_step += 1
# 每隔 eval_freq 步才评估一次(比如每5步),避免太频繁拖慢训练
if global_step % eval_freq == 0:
# 在训练集和验证集上算当前损失
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}")
# Print a sample text after each epoch
generate_and_print_sample(
model, tokenizer, device, start_context
)
return train_losses, val_losses, track_tokens_seen
请注意,我们刚刚创建的 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):
# ① 先切到推理模式:关掉Dropout,让评估结果稳定、可复现
model.eval()
# 评估时不需要算梯度,省内存省时间
with torch.no_grad():
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)
# ② 评估完再切回训练模式,恢复Dropout,好继续训练
model.train()
return train_loss, val_loss
与 evaluate_model 函数类似,generate_and_print_sample 函数也是一个便捷的函数,可以用来跟踪模型在训练过程中是否有所改进。具体而言,generate_and_print_sample函数以文本片段(start_context)作为输入,先将其转换为词元 ID,然后将其提供给大语言模型,最后使用我们之前使用的 generate_text_simple 函数生成一个文本样本:
python
def generate_and_print_sample(model, tokenizer, device, start_context):
model.eval()
context_size = model.pos_emb.weight.shape[0]
# 把起始文本转成token id,放到设备上
encoded = text_to_token_ids(start_context, tokenizer).to(device)
with torch.no_grad():
# 让模型基于起始文本往后续写,最多再生成50个token
token_ids = generate_text_simple( # 让模型基于起始文本往后续写
model=model, idx=encoded,
max_new_tokens=50, context_size=context_size # 最多再生成50个token
)
decoded_text = token_ids_to_text(token_ids, tokenizer) # 把生成的token id转回文字
print(decoded_text.replace("\n", " ")) # 打印出来,把换行替换成空格让输出更紧凑
model.train() # 生成完切回训练模式,继续训练
evaluate_model 函数提供了模型训练进度的数值估计,而 generate_and_print_sample文本函数提供了由模型生成的具体文本样本,以评估其在训练期间的能力。
eval_freq:每隔多少个 step(batch)触发一次评估,控制评估的「频率」。
eval_iter:每次评估时,在前多少个 batch 上算平均损失,控制评估的「采样量」。
训练集 shuffle=True,每次重新遍历 loader 顺序都重新打乱,所以每次评估取的「前eval_iter 个 batch」内容不同。
验证集 shuffle=False,顺序固定,每次评估都是同样那几个 batch。
AdamW
Adam 优化器是训练深度神经网络的一种常见选择。 然而, 我们的训练循环中选择了AdamW 优化器。AdamW 是 Adam 的一个变体,它改进了权重衰减方法,旨在通过对较大的权重进行惩罚来最小化模型复杂性并防止过拟合。这种调整使得 AdamW 能够实现更有效的正则化和更好的泛化能力。因此,在大语言模型的训练中经常使用AdamW。
让我们通过使用之前定义的 AdamW 优化器和 train_model_simple 函数,对一个GPTModel 实例进行 10 轮的训练,来看看这一切是如何运作的:
python
# Note:
# Uncomment the following code to calculate the execution time
# import time
# start_time = time.time()
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)
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=5,
start_context="Every effort moves you", tokenizer=tokenizer
)
# Note:
# Uncomment the following code to show the execution time
# end_time = time.time()
# execution_time_minutes = (end_time - start_time) / 60
# print(f"Training completed in {execution_time_minutes:.2f} minutes.")
Ep 1 (Step 000000): Train loss 9.820, Val loss 9.932
Ep 1 (Step 000005): Train loss 8.065, Val loss 8.341
Every effort moves you,,,,,,,,,,,,.
Ep 2 (Step 000010): Train loss 6.621, Val loss 7.052
Ep 2 (Step 000015): Train loss 6.047, Val loss 6.601
Every effort moves you, and,, and,,,,,,, and,.
Ep 3 (Step 000020): Train loss 5.582, Val loss 6.480
Ep 3 (Step 000025): Train loss 5.524, Val loss 6.402
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, and
Ep 4 (Step 000030): Train loss 5.110, Val loss 6.354
Ep 4 (Step 000035): Train loss 4.987, Val loss 6.386
Every effort moves you, and a, and a, and a-- the picture. Gisburn, and a was, and a. I had been. of the of the of the of the of the of the of the of the of the. I had a
Ep 5 (Step 000040): Train loss 4.369, Val loss 6.264
Every effort moves you, one of the picture--as of the picture--as of the of the of the picture--as of the fact of the picture of the picture of the picture of the picture. "I had been the of the picture of the
Ep 6 (Step 000045): Train loss 3.978, Val loss 6.204
Ep 6 (Step 000050): Train loss 3.479, Val loss 6.194
Every effort moves you know the
Ep 7 (Step 000055): Train loss 3.510, Val loss 6.213
Ep 7 (Step 000060): Train loss 2.728, Val loss 6.146
Every effort moves you know he was not that I felt--I looked, and I felt to have to have to see a little to me to have to see a little of his pictures--I looked.
Ep 8 (Step 000065): Train loss 2.255, Val loss 6.148
Ep 8 (Step 000070): Train loss 1.922, Val loss 6.216
Every effort moves you know," was not that the picture. "I had the last word. "I turned back his head to look up at the sketch of the donkey. "There were days when I
Ep 9 (Step 000075): Train loss 1.547, Val loss 6.220
Ep 9 (Step 000080): Train loss 1.216, Val loss 6.279
Every effort moves you know," was not that my hostess was "interesting": on that I had not till I felt to me to me to have to see a smile behind his pictures with a little. "I had been the bull--and by his
Ep 10 (Step 000085): Train loss 0.922, Val loss 6.320
Every effort moves you?" "Yes--quite insensible to the irony. She wanted him vindicated--and by me!" He laughed again, and threw back his head to look up at the sketch of the donkey. "There were days when I
如你所见,训练集损失有了显著的改善,从 9.781 的初始值收敛到了 0.391。模型的语言能力得到了相当大的提升。在开始阶段,模型只能在起始上下文后添加逗号(Every effort moves you,)或重复单词 and。在训练结束时,它已经可以生成语法正确的文本。
与训练集损失类似,验证集损失在训练过程中从较高值(9.933)开始逐渐降低。然而,它永远不会像训练集损失那样变得很小,在第 10 轮之后其值为 6.452。
在更详细地讨论验证集损失之前,让我们创建一张简单的图表,将训练集和验证集的损失并列显示。
python
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
def plot_losses(epochs_seen, tokens_seen, train_losses, val_losses):
fig, ax1 = plt.subplots(figsize=(5, 3))
# Plot training and validation loss against epochs
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")
ax1.xaxis.set_major_locator(MaxNLocator(integer=True)) # only show integer labels on x-axis
# Create a second x-axis for tokens seen
ax2 = ax1.twiny() # Create a second x-axis that shares the same y-axis
ax2.plot(tokens_seen, train_losses, alpha=0) # Invisible plot for aligning ticks
ax2.set_xlabel("Tokens seen")
fig.tight_layout() # Adjust layout to make room
plt.savefig("loss-plot.pdf")
plt.show()
epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
plot_losses(epochs_tensor, tokens_seen, train_losses, val_losses)

生成的训练集和验证集的损失图如图 5-12 所示。可以看到,训练集损失和验证集损失在第一轮开始改善。然而,损失在第二轮后开始发散。这种发散以及验证集损失远大于训练集损失的事实表明模型对训练数据过拟合。可以通过搜索生成的文本片段(比如"The Verdict"文本文件中的 quite insensible to the irony)来确认模型逐字记住了训练数据。
这种记忆现象其实是可以预料到的,因为我们使用了一个非常非常小的训练数据集,并且对模型进行了多轮训练。通常,在更大的数据集上训练模型时,只训练一轮是很常见的做法。
如图 5-13 所示,我们已经完成本章的前 4 项目标。接下来,我们将探讨用于大语言模型的文本生成策略,以减少训练数据的记忆,并增加大语言模型生成文本的独创性。在此之后,我们将讨论权重加载、保存以及从 OpenAI 的 GPT 模型加载预训练权重。

5.3 控制随机性的解码策略
让我们关注一下文本生成策略(也称为"解码策略"),以生成更具原创性的文本。首先,我们将简要回顾之前在 generate_and_print_sample 函数中使用的generate_text_simple 函数。然后,我们将介绍两种技术(温度缩放和 Top-k 采样)来改进这个函数。
先将模型从 GPU 转移到 CPU,因为相对较小的模型的推断不需要 GPU。此外,在训练后, 需要将模型置于评估模式,以关闭诸如 dropout 之类的随机组件,接下来,将 GPTModel 实例(model)传递给 generate_text_simple 函数,该函数使用大语言模型逐个生成词元:
python
# NEW: use CPU here as inference is cheap with
# this model and to ensure readers get same results in the
# remaining sections of this book
inference_device = torch.device("cpu")
model.to(inference_device)
model.eval()
tokenizer = tiktoken.get_encoding("gpt2")
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids("Every effort moves you", tokenizer).to(inference_device),
max_new_tokens=25,
context_size=GPT_CONFIG_124M["context_length"]
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
Output text:
Every effort moves you?"
"Yes--quite insensible to the irony. She wanted him vindicated--and by me!"
如前所述,在每个生成步骤中,生成的词元是从词汇表的所有词元中选择概率分数最大的那一个。 这意味着,即使在相同的起始上下文( Every effort moves you )中多次运行前面的 generate_text_simple 函数,大语言模型也将始终生成相同的输出。
5.3.1 温度缩放
现在来看一下温度缩放,这是一种在下一个词元生成任务中添加概率选择过程的技术。在之前的 generate_text_simple 函数中,我们总是使用 torch.argmax(也称为贪婪解码)来采样具有最高概率的词元作为下一个词元。为了生成更多样化的文本,可以用一个从概率分布(这里是大语言模型在每个词元生成步骤为每个词汇条目生成的概率分数)中采样的函数来取代 argmax。
为了用一个具体的例子来说明概率采样,让我们使用一张非常小的词汇表来简要讨论下一个词元生成过程。接下来,假设大语言模型被赋予的起始上下文为"every effort moves you",并生成了以下下一个词元的 logits,正如第 4 章中讨论的,在 generate_text_simple 中,我们通过 softmax 函数将 logits 转换为概率,并通过 argmax 函数获取与生成的词元对应的词元 ID,然后通过反向词汇表将其映射回文本:
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()}
# Suppose input is "every effort moves you", and the LLM
# returns the following logits for the next token:
next_token_logits = torch.tensor(
[4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79]
)
probas = torch.softmax(next_token_logits, dim=0)
next_token_id = torch.argmax(probas).item()
# The next generated token is then as follows:
print(inverse_vocab[next_token_id])
forward
由于最大的 logits 值和相对应的最大的 softmax 概率分数在第四个位置(因为 Python 使用 0 索引 作为初始索引,所以索引位置为 3),因此生成的单词是 forward。为了实现一个概率采样过程,现在可以用 PyTorch 中的 multinomial 函数替换 argmax:
python
torch.manual_seed(123)
next_token_id = torch.multinomial(probas, num_samples=1).item()
print(inverse_vocab[next_token_id])
toward
torch.multinomial(多项分布)
按概率加权随机抽样,而不是取最大值。
pythonprobas = torch.tensor([0.1, 0.7, 0.2])
- 70% 概率返回索引 1
- 20% 概率返回索引 2
- 10% 概率返回索引 0
它有可能返回索引 1(概率最高,70%),但不是「一定」返回。
对比
torch.argmax:直接返回概率最大的索引,是确定性的,对于[0.1, 0.7, 0.2]永远返回索引 1。这个随机性正是文本生成中「多样性」的来源。
打印输出仍然是 forward【其实我这里已经输出toward了】。原理是什么呢?multinomial 函数按照其概率分数采样下一个词元。换句话说,forward 仍然是最可能的词元,大多数时间(但不是每次)都会被 multinomial 选中。为了说明这一点,让我们实现一个将此采样重复 1000 次的函数:
python
def print_sampled_tokens(probas):
torch.manual_seed(123) # Manual seed for reproducibility
sample = [torch.multinomial(probas, num_samples=1).item() for i in range(1_000)]
sampled_ids = torch.bincount(torch.tensor(sample), minlength=len(probas))
for i, freq in enumerate(sampled_ids):
print(f"{freq} x {inverse_vocab[i]}")
print_sampled_tokens(probas)
71 x closer
2 x every
0 x effort
544 x forward
2 x inches
1 x moves
0 x pizza
376 x toward
4 x you
如你所见,单词 forward 大多数时候会被采样(1000 次中有 582 次),但其他词元(如 closer、 inches 和 toward)有时也会被采样。这意味着,如果在generate_and_print_sample 函数中用 multinomial 函数替换 argmax 函数,那么大语言模型有时会生成诸如 every effort moves you toward、every effort moves you inches 和 every effort moves you closer 之类的文本,而不是 every effort moves you forward。
通过一个称为温度缩放的概念,可以进一步控制分布和选择过程。温度缩放指的是将logits 除以一个大于 0 的数:
python
def softmax_with_temperature(logits, temperature):
scaled_logits = logits / temperature
return torch.softmax(scaled_logits, dim=0)
温度缩放的数学原理
softmax 的结果由 logits 之间的相对差值决定,而不是绝对值。看两个 logit 的概率比值:
pipj=ezi/Tezj/T=e(zi−zj)/T \frac{p_i}{p_j} = \frac{e^{z_i/T}}{e^{z_j/T}} = e^{(z_i - z_j)/T} pjpi=ezj/Tezi/T=e(zi−zj)/T
这个比值只取决于差值 (zi−zj)(z_i - z_j)(zi−zj) 除以 TTT。温度 TTT 直接缩放了这个差距:
- T>1T > 1T>1:把差距 (zi−zj)(z_i - z_j)(zi−zj) 除以一个大于 1 的数,差距变小 → 各概率比值趋近 1 → 分布更均匀、更平坦。
- T<1T < 1T<1:把差距除以一个小于 1 的数(相当于放大),差距变大 → 高 logit 的概率被进一步拉高 → 分布更尖锐、更自信。
- T=1T = 1T=1:就是原始 softmax。
温度大于 1 会导致词元概率更加均匀分布,而小于 1 的温度将导致更加自信(更尖锐或更陡峭)的分布。让我们通过绘制原始概率以及使用不同温度值缩放的概率来说明这一点。
python
# Temperature values
temperatures = [1, 0.1, 5] # Original, higher confidence, and lower confidence
# Calculate scaled probabilities
scaled_probas = [softmax_with_temperature(next_token_logits, T) for T in temperatures]
# Plotting
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.savefig("temperature-plot.pdf")
plt.show()

温度为 1 意味着在将 logits 传递给 softmax 函数计算概率分数之前,先将 logits 除以 1。换句话说,使用温度 1 相当于不使用任何温度缩放。在这种情况下,通过 PyTorch 中的 multinomial 采样函数,词元将以与原始 softmax 概率分数相等的概率被选中。例如,在温度设置为 1 的情况 下,与 forward 对应的词元大约有 60%的概率被选中,正如我们在图 5-14 中看到的那样。
同样,如图 5-14 所示,应用非常小的温度(如 0.1)会导致更集中的分布,使得multinomial 函数几乎 100%选择最可能的词元(这里是 forward),接近于 argmax 函数的行为。类似地,温度为 5 会导致更均匀的分布,使得其他词元更容易被选中。这可以为生成的文本增加更多变化,但也更容易生成无意义的文本。例如,使用温度为 5 的设置时,生成的文本中大约有 4%的概率会出现像 every effort moves you pizza 这样的句子。
练习 5.1
用 print_sampled_tokens 函数打印使用图 5-14 中所示温度缩放的 softmax 概率的采样频率。在每种情况下,单词 pizza 被采样的频率是多少?你能想到一个更快、更准确的方法来确定单词 pizza 被采样的频率吗?
python
temp5_idx = 2 # T=5 在 temperatures 列表里的下标
pizza_idx = 6 # pizza 在 vocab 里的下标
scaled_probas[temp5_idx][pizza_idx] # → tensor(0.0430)
直接读 scaled_probas 里 pizza 对应的概率值,不用抽样统计
5.3.2 Top-k 采样
我们现在已经实现了一种结合温度缩放的概率采样方法,以此来增加输出结果的多样性 。我们发现,较高的温度值会导致下一个词元的概率分布更均匀,从而产生更多样化的输出,因为它降低了模型重复选择最可能词元的可能性。这种方法允许探索概率较低但可能更具创造性和趣味性的生成路径。然而,这种方法的一个缺点是,它有时会导致语法不正确或完全无意义的输出,比如 every effort moves you pizza。
通过与概率采样和温度缩放相结合,Top-k 采样可以改善文本生成结果 。在 Top-k 采样中, 可以将采样的词元限制在前 k 个最可能的词元上,并通过掩码概率分数的方式来排除其他词元,如图 5-15 所示。
Top-k 方法用负无穷值(-inf)替换所有未选择的 logits,因此在计算 softmax 值时,非前 k 词元的概率分数为 0,剩余的概率总和为 1。(我们在 3.5.1 节实现的因果注意力模块中使用过这种掩码技巧。)
在代码中,可以按照图 5-15 所示实现 Top-k 过程。首先,从选择 logits 值最高的前 3 个词元开始:
python
top_k = 3
top_logits, top_pos = torch.topk(next_token_logits, top_k)
print("Top logits:", top_logits)
print("Top positions:", top_pos)
按降序排列的前 3 个词元的 logits 值和词元 ID 如下所示:
Top logits: tensor([6.7500, 6.2800, 4.5100])
Top positions: tensor([3, 7, 0])

随后,使用 PyTorch 的 where 函数将低于我们选择的前 3 个词元中最低 logits 值的词元的 logits 值设置为负无穷(-inf):
python
new_logits = torch.where(
condition=next_token_logits < top_logits[-1],
input=torch.tensor(float("-inf")),
other=next_token_logits
)
print(new_logits)
tensor([4.5100, -inf, -inf, 6.7500, -inf, -inf, -inf, 6.2800, -inf])
torch.where 理解
它就是张量版的"三元选择":对每个位置,条件成立取 A,不成立取 B。
三个参数的角色
pythontorch.where(condition, input, other) # 条件张量 True取它 False取它
condition:一个布尔张量,决定每个位置选谁input:条件为 True 时取的值other:条件为 False 时取的值图解
condition: [True, False, True, False] input: [ -inf, -inf, -inf, -inf ] ← True 从这里拿 other: [ 2.0, 5.0, 1.0, 8.0 ] ← False 从这里拿 ↓ ↓ ↓ ↓ 结果: [-inf, 5.0, -inf, 8.0 ]每一列独立判断,互不影响。
python
new_logits = torch.full_like( # create tensor containing -inf values
next_token_logits, -torch.inf)
new_logits[top_pos] = next_token_logits[top_pos] # copy top k values into the -inf tensor
之前的 torch.where 是"改造"思路: 拿原始 logits,把不够格的换成 -inf。
这个新写法是"填充"思路: 先造一个全是 -inf 的空白张量,再把够格的值填进去。
最后,应用 softmax 函数将这些值转换为下一个词元的概率:
python
topk_probas = torch.softmax(new_logits, dim=0)
print(topk_probas)
tensor([0.0615, 0.0000, 0.0000, 0.5775, 0.0000, 0.0000, 0.0000, 0.3610, 0.0000])
现在,可以应用温度缩放和 multinomial 函数进行概率采样,从这 3 个非零概率分数中选择一个词元作为生成的下一个词元。接下来,我们通过修改文本生成函数来实现这一步。
5.3.3 修改文本生成函数
现在,让我们结合温度放缩和 Top-k 采样修改之前用于通过大语言模型生成文本的 generate_ text_simple 函数,从而创建一个新的 generate 函数,如代码清单 5-4 所示。
python
def generate(model, idx, max_new_tokens, context_size, temperature=0.0, top_k=None, eos_id=None):
# 这个for循环跟之前的一样,获取logits,只关注最后一步,也就是最后一个词
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:]
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :]
# 这个if语句实现了top_k采样,也就是只考虑logits值最大的k个词
if top_k is not None:
# 将logits值设为负无穷,低于这个值的logits就不会被考虑
top_logits, _ = torch.topk(logits, top_k)
min_val = top_logits[:, -1]
logits = torch.where(logits < min_val, torch.tensor(float("-inf")).to(logits.device), logits)
# 将logits除以温度参数,以控制采样的探索性
if temperature > 0.0:
logits = logits / temperature
# 新增的代码行:防止 softmax 计算时出现数值溢出
logits = logits - logits.max(dim=-1, keepdim=True).values
# 将logits值转换为概率值
probs = torch.softmax(logits, dim=-1) # (batch_size, context_len)
# 从计算好的概率值中采样下一个词
idx_next = torch.multinomial(probs, num_samples=1) # (batch_size, 1)
# 否则,就和之前一样,选择logits值最大的词
else:
idx_next = torch.argmax(logits, dim=-1, keepdim=True) # (batch_size, 1)
# 如果设置了eos_id,遇到eos_id就会停止生成
if idx_next == eos_id:
break
# 和之前一样,将采样的词添加到生成的文本中
idx = torch.cat((idx, idx_next), dim=1) # (batch_size, num_tokens+1)
return idx
看看这个新的 generate 函数的效果:
python
torch.manual_seed(123)
token_ids = generate(
model=model,
idx=text_to_token_ids("Every effort moves you", tokenizer).to(inference_device),
max_new_tokens=15,
context_size=GPT_CONFIG_124M["context_length"],
top_k=25,
temperature=1.4
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
Output text:
Every effort moves you know began to happen a good _ himself it was such not to see her
如你所见,通过新的 generate 函数生成的文本与通过本节开头的 generate_text_simple 函数生成的文本("Every effort moves you know," was one of the axioms he laid...) 截然不同,后者是训练集中被记住的一个段落。
练习 5.2
尝试不同的温度和 Top-k 设置。根据观察,你能想到哪些应用场景更适合使用较低的温 度和 Top-k 设置吗?同样,你能想到哪些应用场景更偏好较高的温度和 Top-k 设置吗?(建议 在从 OpenAI 加载预训练权重后,在本章末尾重新进行这个练习。)
- temperature(温度)和 top-k 这两个参数都需要根据具体的 LLM 来调整(这是一个反复试验的过程,直到模型生成理想的输出为止)
- 不过,什么样的输出算"理想",也取决于具体的应用场景
- 较低的 top-k 和温度会带来随机性更低的输出,这在创建教育内容、技术写作或问答、数据分析、代码生成等场景中是我们想要的
- 较高的 top-k 和温度会带来更多样、更随机的输出,这在头脑风暴、创意写作等任务中更为理想
练习 5.3
有哪些不同的设置组合可以强制 generate 函数表现出确定性的行为,即禁用随机采 样,使其始终生成与 generate_text_simple 函数类似的输出?
- top_k=1:候选集只留概率最高的 1 个词,那随机抽也只能抽到它,等价于 argmax。
- temperature=0.0:温度趋近 0 时,概率分布会变得极端尖锐,几乎所有概率都集中到最大 logit 那个词上,采样结果必然是它,也等价于 argmax。
所以逻辑上是 top_k=1 或 temperature=0.0,任一即可。
5.4 使用 PyTorch 加载和保存模型权重
到目前为止,我们已经讨论了如何从数值上评估训练进展,并从头开始预训练了一个大语言模型。尽管样例中使用的大语言模型和数据集都相对较小,但这足以表明预训练大语言模型代价高昂。因此,保存大语言模型的参数非常重要,这样就不必每次使用它时都重新运行训练。
接下来,我们会讨论如何保存和加载预训练模型,如图 5-16 所示。稍后,我们将从 OpenAI 加载一个功能更强大的预训练 GPT 模型到 GPTModel 实例中。

幸运的是,保存 PyTorch 模型相对比较简单。 推荐使用 torch.save 函数保存模型的 state_dict,即将每个层映射到其参数的字典:
python
torch.save(model.state_dict(), "model.pth")
for name, param in model.state_dict().items():
print(name, param.shape)

它只保存"参数的数值",不保存模型的结构代码。也就是说它只存数字,不存"这个模型有几层、每层怎么连"的定义。
torch.save 把上面那个字典序列化(用 Python 的 pickle 机制打包成二进制),然后写入名为 model.pth 的文件。.pth 只是习惯用的后缀,换成别的也能用。
在通过 state_dict 保存模型权重之后,可以将模型权重加载到一个新的 GPTModel 模型 实例中:
python
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(torch.load("model.pth", map_location=device, weights_only=True))
model.eval()
正如第 4 章中所讨论的那样,dropout 通过在训练过程中随机"丢弃"一层的神经元,有助于防止模型对训练数据过拟合。然而,在推断过程中,我们不希望随机丢弃网络学习到的任何信息。 因此,可以使用 model.eval()将模型切换到推断模式,这样就会禁用模型的 dropout 层。如果计划稍后继续预训练模型,可以使用本章前面定义的 train_model_simple 函数,建议同时保存优化器状态。
像 AdamW 这样的自适应优化器可以为每个模型权重存储额外的参数。AdamW 可以使用历史数据动态地调整每个模型参数的学习率。如果没有它,那么优化器就会重置,模型可能学习效果不佳,甚至无法正确收敛,这意味着模型将失去生成连贯文本的能力。可以使用 torch.save 保存模型和优化器的 state_dict 内容:
python
torch.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
},
"model_and_optimizer.pth"
)
然后,可以先使用 torch.load 加载保存的数据,再使用 load_state_dict 方法来恢复模型 和优化器的状态。
python
checkpoint = torch.load("model_and_optimizer.pth", weights_only=True)
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0005, weight_decay=0.1)
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
model.train()
load_state_dict 是 PyTorch 里 nn.Module(模型)和优化器都自带的一个方法,作用就是:把一个 state_dict 字典里的数值,填回到当前对象的各个参数里。
传入一个 state_dict 字典------也就是之前用 .state_dict() 导出、再用 torch.load 读回来的那个字典。
它做的事情就是:拿传进来的字典,按键名(层名)逐个匹配,把对应的数值张量拷贝到模型当前的参数里。
练习 5.4
在新的 Python 会话或 Jupyter Notebook 文件中保存权重后,加载模型和优化器,并使用 train_model_simple 函数继续预训练一轮。
python
import tiktoken
import torch
from previous_chapters import GPTModel
GPT_CONFIG_124M = {
"vocab_size": 50257, # Vocabulary size
"context_length": 256, # Shortened context length (orig: 1024)
"emb_dim": 768, # Embedding dimension
"n_heads": 12, # Number of attention heads
"n_layers": 12, # Number of layers
"drop_rate": 0.1, # Dropout rate
"qkv_bias": False # Query-key-value bias
}
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tokenizer = tiktoken.get_encoding("gpt2")
checkpoint = torch.load("model_and_optimizer.pth", weights_only=True)
model = GPTModel(GPT_CONFIG_124M)
model.load_state_dict(checkpoint["model_state_dict"])
model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1)
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
model.train()
python
import os
import requests
from previous_chapters import create_dataloader_v1
file_path = "the-verdict.txt"
url = "https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt"
if not os.path.exists(file_path):
response = requests.get(url, timeout=30)
response.raise_for_status()
text_data = response.text
with open(file_path, "w", encoding="utf-8") as file:
file.write(text_data)
else:
with open(file_path, "r", encoding="utf-8") as file:
text_data = file.read()
# Train/validation ratio
train_ratio = 0.90
split_idx = int(train_ratio * len(text_data))
train_data = text_data[:split_idx]
val_data = text_data[split_idx:]
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
)
python
from gpt_train import train_model_simple
num_epochs = 1
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=5,
start_context="Every effort moves you", tokenizer=tokenizer
)
Ep 1 (Step 000000): Train loss 0.700, Val loss 6.357
Ep 1 (Step 000005): Train loss 0.491, Val loss 6.460
Every effort moves you?" "I didn't you know it were, so inevitably the last word. Victor Grindle was, in fact, becoming the man of the moment--as Jack himself, one might put it, had been the man of the hour. The
之前最后一次的loss是0.9,他接着训练了
5.5 从 OpenAI 加载预训练权重
前面我们使用包含短篇小说的有限数据集训练了一个小型的 GPT-2 模型。这种方法使我们能够专注于基础知识的讲解,而无须花费大量时间和计算资源。
幸运的是,OpenAI 公开分享了它们的 GPT-2 模型的权重,从而省去了我们自己在大型语料库上重新训练模型所需投入的数万到数十万美元。因此,我们可以将这些权重加载到 GPTModel 类中,并使用该模型进行文本生成。这里,权重指的是存储在 PyTorch 的 Linear 层和 Embedding 层的.weight 属性中的权重参数。前面在训练模型时,我们通过 model.parameters()访问过它们。在第 6 章中,我们将重用这些预训练权重来对模型进行微调,以完成文本分类任务,并按照类似 ChatGPT 的指令进行操作。
需要注意的是,OpenAI 最初通过 TensorFlow 保存了 GPT-2 的权重,我们需要在Python 中安装 TensorFlow 才能加载这些权重。下面的代码将使用一个名为 tqdm 的进度条工具来跟踪下载过程,我们也需要安装这个工具。
可以通过在终端中执行以下命令来安装这些库:
pip install tensorflow>=2.15.0
tqdm>=4.66
下载代码相对冗长,大部分是样板代码,并不是很有趣。因此,与其将宝贵的时间用于讨论从互 联网获取文件的 Python 代码,不如直接从本章的在线存储库中下载 gpt_download.py 这个 Python 模块。
接下来,在将该文件下载到 Python 会话的本地目录后,应该简单检查该文件的内容,确保它已正确保存并包含有效的 Python 代码。
现在,可以按如下方式从 gpt_download.py 文件中导入 download_and_load_gpt2 函数,这将加载 GPT-2 架构设置(settings)和权重参数(params)到 Python 会话中:
python
from gpt_download import download_and_load_gpt2
settings, params = download_and_load_gpt2(model_size="124M", models_dir="gpt2")
执行此代码将下载与参数量为 1.24 亿的 GPT-2 模型相关的以下 7 个文件。
File already exists and is up-to-date: gpt2/124M/checkpoint
File already exists and is up-to-date: gpt2/124M/encoder.json
File already exists and is up-to-date: gpt2/124M/hparams.json
File already exists and is up-to-date: gpt2/124M/model.ckpt.data-00000-of-00001
File already exists and is up-to-date: gpt2/124M/model.ckpt.index
File already exists and is up-to-date: gpt2/124M/model.ckpt.meta
File already exists and is up-to-date: gpt2/124M/vocab.bpe
假设前面的代码已经执行完毕,我们来检查一下 settings 和 params 的内容:
python
print("Settings:", settings)
print("Parameter dictionary keys:", params.keys())
Settings: {'n_vocab': 50257, 'n_ctx': 1024, 'n_embd': 768, 'n_head': 12, 'n_layer': 12}
Parameter dictionary keys: dict_keys(['blocks', 'b', 'g', 'wpe', 'wte'])
settings 和 params 都是 Python 字典。settings 字典存储了大语言模型架构的设置,类似于我们手动定义的 GPT_CONFIG_124M。params 字典包含实际的权重张量。请注意,我们只打印了字典的键,因为打印权重内容会占用太多屏幕空间。不过,可以通过 print(params)打印整个字典来检查这些权重张量,或者通过相应的字典键(如嵌入层权重)来选择单个张量:
python
print(params["wte"])
print("Token embedding weight tensor dimensions:", params["wte"].shape)
[[-0.11010301 -0.03926672 0.03310751 ... -0.1363697 0.01506208
0.04531523]
[ 0.04034033 -0.04861503 0.04624869 ... 0.08605453 0.00253983
0.04318958]
[-0.12746179 0.04793796 0.18410145 ... 0.08991534 -0.12972379
-0.08785918]
...
[-0.04453601 -0.05483596 0.01225674 ... 0.10435229 0.09783269
-0.06952604]
[ 0.1860082 0.01665728 0.04611587 ... -0.09625227 0.07847701
-0.02245961]
[ 0.05135201 -0.02768905 0.0499369 ... 0.00704835 0.15519823
0.12067825]]
Token embedding weight tensor dimensions: (50257, 768)
我们通过 download_and_load_gpt2(model_size="124M", ...)设置下载并加载了最小的 GPT-2 模型的权重。 OpenAI 还提供了更大的模型(参数量分别为 3.55 亿、7.74 亿和 15.58 亿)的权重。如图 5-17 所示,这些不同大小的 GPT 模型的整体架构是相同的,只是不同的架构元素重复的次数不同,嵌入尺寸也不同。本章的剩余代码也适用于这些更大的模型。

在将 GPT-2 模型的权重加载到 Python 后,仍然需要将它们从 settings 字典和 params 字典转移到我们的 GPTModel 实例中。首先,创建一个字典,列出图 5-17 中不同 GPT 模型尺寸之间的差异。
你可能还记得我们之前使用了 256 个词元长度,但 OpenAI 的原始 GPT-2 模型是使用 1024 个词元 长度进行训练的,因此需要相应地更新 NEW_CONFIG。
另外,OpenAI 在多头注意力模块的线性层中使用了偏置向量来实现查询矩阵、键矩阵和值矩阵的计算。偏置向量在当前的大语言模型中不常用,因为它们并不提升建模性能,因此不是必要的。 然而,由于我们正在使用预训练权重,因此需要匹配相应的设置以保持一致性,并启用这些偏置向量。
现在,可以使用更新后的 NEW_CONFIG 字典来初始化一个新的 GPTModel 实例:
python
# Define model configurations in a dictionary for compactness
model_configs = {
"gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},
"gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},
"gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},
"gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}
# Copy the base configuration and update with specific model settings
model_name = "gpt2-small (124M)" # Example model name
NEW_CONFIG = GPT_CONFIG_124M.copy()
NEW_CONFIG.update(model_configs[model_name])
NEW_CONFIG.update({"context_length": 1024, "qkv_bias": True})
gpt = GPTModel(NEW_CONFIG)
gpt.eval();
默认情况下,GPTModel 实例使用随机权重初始化以进行预训练。使用 OpenAI 的模型权重的最后一步是用加载到 params 字典中的权重覆盖这些随机权重。为此,首先需要定义一个小的 assign 工具函数,该函数会检查两个张量或数组(left 和 right)是否具有相同的维度或形状,并将 right 张量返回为可训练的 PyTorch 参数。
python
def assign(left, right):
if left.shape != right.shape:
raise ValueError(f"Shape mismatch. Left: {left.shape}, Right: {right.shape}")
return torch.nn.Parameter(torch.tensor(right))
然后,定义一个 load_weights_into_gpt 函数,将 params 字典中的权重加载到 GPTModel 实例 gpt 中,如代码清单 5-5 所示。
python
import numpy as np
def load_weights_into_gpt(gpt, params):
gpt.pos_emb.weight = assign(gpt.pos_emb.weight, params['wpe'])
gpt.tok_emb.weight = assign(gpt.tok_emb.weight, params['wte'])
for b in range(len(params["blocks"])):
q_w, k_w, v_w = np.split(
(params["blocks"][b]["attn"]["c_attn"])["w"], 3, axis=-1)
gpt.trf_blocks[b].att.W_query.weight = assign(
gpt.trf_blocks[b].att.W_query.weight, q_w.T)
gpt.trf_blocks[b].att.W_key.weight = assign(
gpt.trf_blocks[b].att.W_key.weight, k_w.T)
gpt.trf_blocks[b].att.W_value.weight = assign(
gpt.trf_blocks[b].att.W_value.weight, v_w.T)
q_b, k_b, v_b = np.split(
(params["blocks"][b]["attn"]["c_attn"])["b"], 3, axis=-1)
gpt.trf_blocks[b].att.W_query.bias = assign(
gpt.trf_blocks[b].att.W_query.bias, q_b)
gpt.trf_blocks[b].att.W_key.bias = assign(
gpt.trf_blocks[b].att.W_key.bias, k_b)
gpt.trf_blocks[b].att.W_value.bias = assign(
gpt.trf_blocks[b].att.W_value.bias, v_b)
gpt.trf_blocks[b].att.out_proj.weight = assign(
gpt.trf_blocks[b].att.out_proj.weight,
params["blocks"][b]["attn"]["c_proj"]["w"].T)
gpt.trf_blocks[b].att.out_proj.bias = assign(
gpt.trf_blocks[b].att.out_proj.bias,
params["blocks"][b]["attn"]["c_proj"]["b"])
gpt.trf_blocks[b].ff.layers[0].weight = assign(
gpt.trf_blocks[b].ff.layers[0].weight,
params["blocks"][b]["mlp"]["c_fc"]["w"].T)
gpt.trf_blocks[b].ff.layers[0].bias = assign(
gpt.trf_blocks[b].ff.layers[0].bias,
params["blocks"][b]["mlp"]["c_fc"]["b"])
gpt.trf_blocks[b].ff.layers[2].weight = assign(
gpt.trf_blocks[b].ff.layers[2].weight,
params["blocks"][b]["mlp"]["c_proj"]["w"].T)
gpt.trf_blocks[b].ff.layers[2].bias = assign(
gpt.trf_blocks[b].ff.layers[2].bias,
params["blocks"][b]["mlp"]["c_proj"]["b"])
gpt.trf_blocks[b].norm1.scale = assign(
gpt.trf_blocks[b].norm1.scale,
params["blocks"][b]["ln_1"]["g"])
gpt.trf_blocks[b].norm1.shift = assign(
gpt.trf_blocks[b].norm1.shift,
params["blocks"][b]["ln_1"]["b"])
gpt.trf_blocks[b].norm2.scale = assign(
gpt.trf_blocks[b].norm2.scale,
params["blocks"][b]["ln_2"]["g"])
gpt.trf_blocks[b].norm2.shift = assign(
gpt.trf_blocks[b].norm2.shift,
params["blocks"][b]["ln_2"]["b"])
gpt.final_norm.scale = assign(gpt.final_norm.scale, params["g"])
gpt.final_norm.shift = assign(gpt.final_norm.shift, params["b"])
gpt.out_head.weight = assign(gpt.out_head.weight, params["wte"])
load_weights_into_gpt(gpt, params)
gpt.to(device);
关于为什么不能直接用
load_state_dict一次性加载,而要手动逐层搬运权重:
load_state_dict能一次性加载的前提是权重来源和目标模型完全匹配。这里两个条件都不满足,所以必须手动搬。
权重来源不是 PyTorch 的 state_dict。OpenAI 官方 GPT-2 权重是用 TensorFlow 训练的,存成 TF checkpoint。代码里的
params是从中解析出来的嵌套字典(如params["blocks"][b]["attn"]["c_attn"]["w"]),里面装的是 numpy 数组,不是 PyTorch 的OrderedDict,load_state_dict不认识这个格式。模型结构和命名不一致。
load_state_dict靠键名精确匹配。自定义模型里参数叫trf_blocks[b].att.W_query.weight,而 TF 版对应的是blocks[b].attn.c_attn.w的一部分。名字和结构划分都对不上,无法自动匹配。需要做数值层面的转换(关键原因)。手动加载过程做了三件
load_state_dict做不到的事:
- 拆分:TF 版把 Q、K、V 合并存在一个
c_attn里,需要np.split(..., 3)拆成三份,对应模型里分离的三个 Linear 层。- 转置:TF 用
Conv1D,权重方向和 PyTorchnn.Linear相反,必须.T。- 权重绑定:
out_head复用wte。所以这段代码本质是一个格式转换器 + 结构适配器,不是单纯的加载。
在 load_weights_into_gpt 函数中,我们仔细匹配了来自 OpenAI 的权重和我们的 GPTModel 的权重。举个具体的例子,OpenAI 将第一个 Transformer 块的输出投影层的权重张量存储为 params"blocks"0"attn""c_proj""w" 。 在我们的实现中,该权重张量对应于 gpt.trf_blocksb.att.out_proj.weight,其中 gpt 是一个 GPTModel 实例。
改进 load_weights_into_gpt 函数需要进行许多猜测,因为 OpenAI 使用了与我们略有 不同的命名规范。然而,assign 函数会在我们尝试匹配两个具有不同维度的张量时提醒我们。 此外,如果在这个函数中犯了错误,我们会注意到这一点,因为生成的 GPT 模型将无法产生连贯的文本。
如果模型成功加载,那么现在可以使用之前的 generate 函数来生成新文本:
python
torch.manual_seed(123)
token_ids = generate(
model=gpt,
idx=text_to_token_ids("Every effort moves you", tokenizer).to(device),
max_new_tokens=25,
context_size=NEW_CONFIG["context_length"],
top_k=50,
temperature=1.5
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))
我们可以确信已经正确加载了模型权重,因为模型能够生成连贯的文本。在这个过程中的一个微小错误也会导致模型失败。在接下来的章节中,我们将进一步使用这个预训练模型,并对其进行微调,以分类文本和遵循指令。
练习 5.5
使用来自 OpenAI 的预训练权重在"The Verdict"数据集上计算 GPTModel 的训练集损失和验证集损失。
练习 5.6
尝试使用不同大小的 GPT-2模型,比如参数量为 15.58亿的最大模型,并将其生成的文本与参数量为 1.24 亿的模型进行比较。
5.6 小结
- 当大语言模型生成文本时,它们逐个生成词元。
- 默认情况下,下一个词元是通过将模型输出转换为概率分数,并从词汇表中选择与最高概率分数对应的词元来生成的,这被称为"贪婪解码"。
- 通过使用概率采样和温度缩放,可以干预生成文本的多样性和连贯性。
- 在训练过程中,训练集损失和验证集损失可用于衡量大语言模型生成的文本质量。
- 对大语言模型进行预训练涉及改变其参数权重以最小化训练损失。
- 大语言模型的训练循环是深度学习中的一个标准过程, 使用了传统的交叉熵损失和AdamW 优化器。
- 在大型文本语料库上预训练大语言模型既耗时又耗资源,因此可以加载公开可用的权重作为在大型数据集上自行进行预训练的替代方案。
