Python-深度学习第三版-五-
Python 深度学习第三版(五)
原文:
deeplearningwithpython.io/chapters/译者:飞龙
第十五章:语言模型和 Transformer
原文:
deeplearningwithpython.io/chapters/chapter15_language-models-and-the-transformer
在上一章中介绍了文本预处理和建模的基础之后,本章将探讨一些更复杂的语言问题,例如机器翻译。我们将为驱动 ChatGPT 等产品并帮助引发自然语言处理(NLP)投资热潮的 Transformer 模型建立起坚实的直觉。
语言模型
在上一章中,我们学习了如何将文本数据转换为数值输入,并使用这种数值表示来对电影评论进行分类。然而,文本分类在很多方面都是一个独特而简单的问题。对于二元分类,我们只需要输出一个单精度浮点数,而对于 N 路分类,最坏的情况是输出N个数字。
那么,关于其他基于文本的任务,如问答或翻译呢?对于许多现实世界的问题,我们感兴趣的是能够为给定输入生成文本输出的模型。就像我们需要标记化器和嵌入层来帮助我们处理模型输入路径上的文本一样,我们必须在能够生成模型输出路径上的文本之前构建一些技术。
在这里我们不需要从头开始;我们可以继续使用整数序列作为文本的自然数值表示的想法。在上一章中,我们学习了如何将字符串标记化,即把输入分割成标记并将每个标记映射到一个整数。我们可以通过相反的过程反标记化一个序列——将整数映射回字符串标记并将它们连接起来。采用这种方法,我们的问题变成了构建一个能够预测标记整数序列的模型。
考虑的最简单选项可能是训练一个直接分类器,覆盖所有可能的输出整数序列空间,但一些简单的数学计算很快就会显示这是不可行的。以 20,000 个单词的词汇量为例,有 20,000⁴,即 160 万亿可能的 4 词序列,而宇宙中的原子数量少于可能的 20 词序列。试图将每个输出序列表示为唯一的分类器输出将无论我们如何设计模型都会耗尽计算资源。
要使这样的预测问题可行的一个实际方法是构建一个一次只预测单个标记输出的模型。一个语言模型在其最简单的形式下,学习一个简单但深入的概率分布:p(token|past tokens)。给定一个观察到某个点的所有标记的序列,语言模型将尝试输出一个概率分布,覆盖所有可能的下一个标记。一个 20,000 词的词汇表意味着模型只需要预测 20,000 个输出,但通过重复预测下一个标记,我们将构建一个能够生成长序列文本的模型。
让我们通过构建一个简单的语言模型来预测字符序列中的下一个字符来使这个问题更具体。我们将训练一个能够输出类似莎士比亚文本的小型模型。
训练莎士比亚语言模型
首先,我们可以下载一些莎士比亚的戏剧和十四行诗的集合。
import keras
filename = keras.utils.get_file(
origin=(
"https://storage.googleapis.com/download.tensorflow.org/"
"data/shakespeare.txt"
),
)
shakespeare = open(filename, "r").read()
列表 15.1:下载莎士比亚作品的缩略版集合
让我们看看一些数据:
>>> shakespeare[:250]
First Citizen:
Before we proceed any further, hear me speak.
All:
Speak, speak.
First Citizen:
You are all resolved rather to die than to famish?
All:
Resolved. resolved.
First Citizen:
First, you know Caius Marcius is chief enemy to the people.
要从这个输入构建一个语言模型,我们需要对我们的源文本进行整理。首先,我们将数据分割成等长块,我们可以批量使用这些块进行模型训练,就像我们在时间序列章节中为天气测量所做的那样。因为我们在这里将使用字符级分词器,所以我们可以直接在字符串输入上进行这种分割。一个 100 字符的字符串将映射到一个 100 个整数的序列。
我们还将每个输入分割成两个单独的特征和标签序列,每个标签序列只是输入序列偏移一个字符。
import tensorflow as tf
# The chunk size we will use during training. We only train on
# sequences of 100 characters at a time.
sequence_length = 100
def split_input(input, sequence_length):
for i in range(0, len(input), sequence_length):
yield input[i : i + sequence_length]
features = list(split_input(shakespeare[:-1], sequence_length))
labels = list(split_input(shakespeare[1:], sequence_length))
dataset = tf.data.Dataset.from_tensor_slices((features, labels))
列表 15.2:将文本分割成块以进行语言模型训练
让我们看看一个(x, y)输入样本。序列中每个位置上的标签是序列中的下一个字符:
>>> x, y = next(dataset.as_numpy_iterator())
>>> x[:50], y[:50]
(b"First Citizen:\nBefore we proceed any further, hear",
b"irst Citizen:\nBefore we proceed any further, hear ")
为了将这个输入映射到整数序列,我们又可以再次使用我们在上一章中看到的TextVectorization层。为了学习字符级词汇表而不是词级词汇表,我们可以更改我们的split参数。而不是默认的"whitespace"分割,我们改为按"character"分割。在这里我们不会进行标准化——为了简单起见,我们将保留大小写并直接传递标点符号。
from keras import layers
tokenizer = layers.TextVectorization(
standardize=None,
split="character",
output_sequence_length=sequence_length,
)
tokenizer.adapt(dataset.map(lambda text, labels: text))
列表 15.3:使用TextVectorization层学习字符级词汇表
让我们检查一下词汇表:
>>> vocabulary_size = tokenizer.vocabulary_size()
>>> vocabulary_size
67
我们只需要 67 个字符来处理完整的源文本。
接下来,我们可以将我们的分词层应用于我们的输入文本。最后,我们可以打乱、批量并缓存我们的数据集,这样我们就不需要在每个 epoch 重新计算它:
dataset = dataset.map(
lambda features, labels: (tokenizer(features), tokenizer(labels)),
num_parallel_calls=8,
)
training_data = dataset.shuffle(10_000).batch(64).cache()
到此为止,我们已经准备好开始建模。
为了构建我们的简单语言模型,我们想要预测给定所有过去字符的字符概率。在我们这本书中迄今为止看到的所有建模可能性中,RNN 是最自然的选择,因为每个单元的循环状态允许模型在预测当前字符的标签时传播关于过去字符的信息。我们也可以使用Embedding,正如我们在上一章中看到的,将每个输入字符嵌入为唯一的 256 维向量。
我们将只使用单个循环层来保持这个模型小巧且易于训练。任何循环层都可以在这里使用,但为了简单起见,我们将使用GRU,它速度快且内部状态比LSTM简单。
embedding_dim = 256
hidden_dim = 1024
inputs = layers.Input(shape=(sequence_length,), dtype="int", name="token_ids")
x = layers.Embedding(vocabulary_size, embedding_dim)(inputs)
x = layers.GRU(hidden_dim, return_sequences=True)(x)
x = layers.Dropout(0.1)(x)
# Outputs a probability distribution over all potential tokens in our
# vocabulary
outputs = layers.Dense(vocabulary_size, activation="softmax")(x)
model = keras.Model(inputs, outputs)
列表 15.4:构建微型语言模型
让我们看看我们的模型摘要:
>>> model.summary()
Model: "functional"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ token_ids (InputLayer) │ (None, 100) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ embedding (Embedding) │ (None, 100, 256) │ 17,152 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ gru (GRU) │ (None, 100, 1024) │ 3,938,304 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dropout (Dropout) │ (None, 100, 1024) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense (Dense) │ (None, 100, 67) │ 68,675 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
Total params: 4,024,131 (15.35 MB)
Trainable params: 4,024,131 (15.35 MB)
Non-trainable params: 0 (0.00 B)
此模型为词汇表中的每个可能字符输出一个 softmax 概率,我们将使用交叉熵损失compile()它。请注意,我们的模型仍在进行分类问题的训练,只是我们将为序列中的每个符号进行一次分类预测。对于我们的每个包含 100 个字符的 64 个样本批次,我们将预测 6,400 个单独的标签。Keras 在训练期间报告的损失和准确度指标将首先在每个序列中平均,然后在每个批次中平均。
让我们继续训练我们的语言模型。
model.compile(
optimizer="adam",
loss="sparse_categorical_crossentropy",
metrics=["sparse_categorical_accuracy"],
)
model.fit(training_data, epochs=20)
列表 15.5:训练微型语言模型
经过 20 个 epoch 后,我们的模型最终可以大约 70%的时间预测输入序列中的下一个字符。
生成莎士比亚
现在我们已经训练了一个可以以一定精度预测下一个 单个 符号的模型,我们希望用它来预测整个序列。我们可以通过循环调用模型来实现这一点,其中模型在某一时间步的预测输出成为下一时间步的模型输入。为这种反馈循环构建的模型有时被称为 自回归 模型。
要运行这样的循环,我们需要对我们刚刚训练的模型进行轻微的手术。在训练过程中,我们的模型只处理固定长度的 100 个符号序列,并且当调用层时,GRU 单元的状 态被隐式处理。在生成过程中,我们希望一次预测一个输出符号并显式输出 GRU 的单元状态。我们需要传播这个状态,它包含模型关于过去输入字符的所有编码信息,在下一次调用模型时。
让我们创建一个一次处理一个输入字符并允许显式传递 RNN 状态的模型。因为这个模型将具有相同的计算结构,只是输入和输出略有修改,我们可以将一个模型的权重分配给另一个模型。
# Creates a model that receives and outputs the RNN state
inputs = keras.Input(shape=(1,), dtype="int", name="token_ids")
input_state = keras.Input(shape=(hidden_dim,), name="state")
x = layers.Embedding(vocabulary_size, embedding_dim)(inputs)
x, output_state = layers.GRU(hidden_dim, return_state=True)(
x, initial_state=input_state
)
outputs = layers.Dense(vocabulary_size, activation="softmax")(x)
generation_model = keras.Model(
inputs=(inputs, input_state),
outputs=(outputs, output_state),
)
# Copies the parameters from the original model
generation_model.set_weights(model.get_weights())
列表 15.6:修改语言模型以进行自回归推理
通过这种方式,我们可以循环调用模型来预测输出序列。在我们这样做之前,我们将创建显式查找表,以便从字符切换到整数并选择一个 提示 —— 一段文本,在我们开始预测新符号之前作为输入提供给模型:
tokens = tokenizer.get_vocabulary()
token_ids = range(vocabulary_size)
char_to_id = dict(zip(tokens, token_ids))
id_to_char = dict(zip(token_ids, tokens))
prompt = """
KING RICHARD III:
"""
为了开始生成,我们首先需要用我们的提示“初始化”GRU 的内部状态。为此,我们将提示逐个符号地输入到模型中。这将计算出模型在训练过程中遇到此提示时的确切 RNN 状态。
当我们将提示的最后一个字符输入到模型中时,我们的状态输出将捕获关于整个提示序列的信息。我们可以将最终的输出预测保存下来,以便稍后选择我们生成响应的第一个字符。
input_ids = [char_to_id[c] for c in prompt]
state = keras.ops.zeros(shape=(1, hidden_dim))
for token_id in input_ids:
inputs = keras.ops.expand_dims([token_id], axis=0)
# Feeds the prompt character by character to update state
predictions, state = generation_model.predict((inputs, state), verbose=0)
列表 15.7:使用固定提示计算语言模型的起始状态
现在,我们已经准备好让模型预测一个新的输出序列。在一个循环中,直到达到期望的长度,我们将不断地选择模型预测的最可能的下一个字符,将其输入到模型中,并保持新的 RNN 状态。这样,我们可以预测整个序列,一次一个标记。
import numpy as np
generated_ids = []
max_length = 250
# Generates characters one by one, computing a new state each iteration
for i in range(max_length):
# The next character is the output index with the highest
# probability.
next_char = int(np.argmax(predictions, axis=-1)[0])
generated_ids.append(next_char)
inputs = keras.ops.expand_dims([next_char], axis=0)
predictions, state = generation_model.predict((inputs, state), verbose=0)
列表 15.8:使用语言模型逐个预测标记
让我们将我们的输出整数序列转换为字符串,看看模型预测了什么。为了去标记化我们的输入,我们只需将所有标记 ID 映射到字符串并将它们连接起来:
output = "".join([id_to_char[token_id] for token_id in generated_ids])
print(prompt + output)
我们得到以下输出:
KING RICHARD III:
Stay, men! hear me speak.
FRIAR LAURENCE:
Thou wouldst have done thee here that he hath made for them?
BUCKINGHAM:
What straight shall stop his dismal threatening son,
Thou bear them both. Here comes the king;
Though I be good to put a wife to him,
我们还没有创造出下一个伟大的悲剧,但这对于在最小数据集上训练两分钟来说并不糟糕。这个玩具示例的目标是展示语言模型设置的力量。我们在猜测单个字符的狭窄问题上训练了模型,但仍然用它来解决一个更广泛的问题,生成一个开放式的、莎士比亚式的文本响应。
重要的是要注意,这种训练设置之所以有效,仅仅是因为循环神经网络只在前向传递信息。如果你想的话,可以尝试将GRU层替换为Bidirectional(GRU(...))。训练准确率将立即飙升到 99%以上,而生成将完全停止工作。在训练过程中,我们的模型在每个训练步骤中都会看到整个序列。如果我们通过让序列中下一个标记的信息影响当前标记的预测来“作弊”,我们就使我们的问题变得极其简单。
这种语言建模设置对于文本领域的无数问题至关重要。与其他我们在本书中看到的建模问题相比,它也有些独特。我们无法简单地调用model.predict()来获取所需的输出。在推理时间存在一个完整的循环和一个非平凡的逻辑!RNN 单元中的状态循环在训练和推理时都会发生,但在训练过程中,我们从未将模型的预测标签作为输入反馈给模型。
序列到序列学习
让我们将语言模型的想法扩展到解决一个重要的问题——机器翻译。翻译属于一类通常称为序列到序列建模(如果你试图节省按键,可以称为seq2seq)。我们寻求构建一个模型,它可以接受源文本作为固定输入序列,并生成翻译文本序列作为结果。问答是另一个经典的序列到序列问题。
序列到序列模型背后的通用模板在图 15.1 中描述。在训练过程中,以下情况发生:
-
编码器模型将源序列转换为中间表示。
-
使用我们之前看到的语言建模设置来训练解码器。它将通过查看所有先前的目标标记以及我们编码器对源序列的表示来递归地预测目标序列中的下一个标记。
在推理过程中,我们无法访问目标序列——我们正在从头开始尝试预测它。我们将逐个生成标记,就像我们使用我们的莎士比亚生成器一样:
-
我们从编码器中获取编码后的源序列。
-
解码器首先查看编码后的源序列以及一个初始的“种子”标记(例如字符串
"[start]"),并使用它们来预测序列中的第一个真实标记。 -
到目前为止预测的序列被反馈到解码器中,在一个循环中,直到生成一个停止标记(例如字符串
"[end]")。

图 15.1:序列到序列学习:源序列由编码器处理,然后发送到解码器。解码器查看到目前为止的目标序列,并预测未来一步的目标序列。在推理过程中,我们逐个生成目标标记,并将其反馈到解码器中。
让我们构建一个序列到序列的翻译模型。
英语到西班牙语的翻译
我们将使用一个英语到西班牙语的翻译数据集。让我们下载它:
import pathlib
zip_path = keras.utils.get_file(
origin=(
"http://storage.googleapis.com/download.tensorflow.org/data/spa-eng.zip"
),
fname="spa-eng",
extract=True,
)
text_path = pathlib.Path(zip_path) / "spa-eng" / "spa.txt"
文本文件每行包含一个示例:一个英语句子,后面跟着一个制表符,然后是相应的西班牙语句子。让我们解析这个文件:
with open(text_path) as f:
lines = f.read().split("\n")[:-1]
text_pairs = []
for line in lines:
english, spanish = line.split("\t")
spanish = "[start] " + spanish + " [end]"
text_pairs.append((english, spanish))
我们的text_pairs看起来像这样:
>>> import random
>>> random.choice(text_pairs)
("Who is in this room?", "[start] ¿Quién está en esta habitación? [end]")
让我们打乱它们并将它们分成通常的训练、验证和测试集:
import random
random.shuffle(text_pairs)
val_samples = int(0.15 * len(text_pairs))
train_samples = len(text_pairs) - 2 * val_samples
train_pairs = text_pairs[:train_samples]
val_pairs = text_pairs[train_samples : train_samples + val_samples]
test_pairs = text_pairs[train_samples + val_samples :]
接下来,让我们准备两个独立的TextVectorization层:一个用于英语,一个用于西班牙语。我们需要自定义字符串的预处理方式:
-
我们需要保留我们插入的
"[start]"和"[end]"标记。默认情况下,字符[和]会被去除,但我们要保留它们,以便我们可以区分单词"start"和起始标记"[start]"。 -
标点符号在不同的语言中是不同的!在西班牙语的
TextVectorization层中,如果我们打算去除标点符号,我们还需要去除字符¿。
注意,对于一个非玩具翻译模型,我们会将标点符号视为单独的标记,而不是去除它们,因为我们希望能够生成正确标点的句子。在我们的情况下,为了简单起见,我们将去除所有标点。
import string
import re
strip_chars = string.punctuation + "¿"
strip_chars = strip_chars.replace("[", "")
strip_chars = strip_chars.replace("]", "")
def custom_standardization(input_string):
lowercase = tf.strings.lower(input_string)
return tf.strings.regex_replace(
lowercase, f"[{re.escape(strip_chars)}]", ""
)
vocab_size = 15000
sequence_length = 20
english_tokenizer = layers.TextVectorization(
max_tokens=vocab_size,
output_mode="int",
output_sequence_length=sequence_length,
)
spanish_tokenizer = layers.TextVectorization(
max_tokens=vocab_size,
output_mode="int",
output_sequence_length=sequence_length + 1,
standardize=custom_standardization,
)
train_english_texts = [pair[0] for pair in train_pairs]
train_spanish_texts = [pair[1] for pair in train_pairs]
english_tokenizer.adapt(train_english_texts)
spanish_tokenizer.adapt(train_spanish_texts)
列表 15.9:为英语和西班牙语文本学习标记词汇表
最后,我们可以将我们的数据转换为tf.data管道。我们希望它返回一个元组(inputs, target, sample_weights),其中inputs是一个字典,包含两个键,"english"(标记化的英语句子)和"spanish"(标记化的西班牙语句子),而target是提前一步的西班牙语句子。sample_weights在这里将用于告诉 Keras 在计算我们的损失和度量时使用哪些标签。我们的输出翻译长度并不相同,我们的一些标签序列将用零填充。我们只关心非零标签的预测,这些标签代表实际的翻译文本。
这与我们在刚刚构建的生成模型中设置的“偏移一个”标签设置相匹配,增加了固定的编码器输入,这些输入将在我们的模型中单独处理。
batch_size = 64
def format_dataset(eng, spa):
eng = english_tokenizer(eng)
spa = spanish_tokenizer(spa)
features = {"english": eng, "spanish": spa[:, :-1]}
labels = spa[:, 1:]
sample_weights = labels != 0
return features, labels, sample_weights
def make_dataset(pairs):
eng_texts, spa_texts = zip(*pairs)
eng_texts = list(eng_texts)
spa_texts = list(spa_texts)
dataset = tf.data.Dataset.from_tensor_slices((eng_texts, spa_texts))
dataset = dataset.batch(batch_size)
dataset = dataset.map(format_dataset, num_parallel_calls=4)
return dataset.shuffle(2048).cache()
train_ds = make_dataset(train_pairs)
val_ds = make_dataset(val_pairs)
列表 15.10:标记化和准备翻译数据
这里是我们的数据集输出看起来像什么:
>>> inputs, targets, sample_weights = next(iter(train_ds))
>>> print(inputs["english"].shape)
(64, 20)
>>> print(inputs["spanish"].shape)
(64, 20)
>>> print(targets.shape)
(64, 20)
>>> print(sample_weights.shape)
(64, 20)
数据现在准备好了——是时候构建一些模型了。
使用 RNN 进行序列到序列学习
在尝试我们之前提到的双编码器/解码器设置之前,让我们先考虑一些更简单的选项。使用 RNN 将一个序列转换为另一个序列的最简单、最直接的方法是保留 RNN 在每个时间步的输出,并从中预测一个输出标记。在 Keras 中,它看起来像这样:
inputs = keras.Input(shape=(sequence_length,), dtype="int32")
x = layers.Embedding(input_dim=vocab_size, output_dim=128)(inputs)
x = layers.LSTM(32, return_sequences=True)(x)
outputs = layers.Dense(vocab_size, activation="softmax")(x)
model = keras.Model(inputs, outputs)
然而,这种方法存在一个关键问题。由于 RNN 的逐步性质,模型将只查看源序列中的0...N标记来预测目标序列中的标记N。考虑翻译句子,“我将把包带到你那里。”在西班牙语中,这将变成“Te traeré la bolsa”,其中“Te”,翻译中的第一个词,对应于英语源文本中的“you”。没有看到源英语文本的最后单词,就根本无法输出翻译的第一个单词!
如果你是一名人工翻译员,你将首先阅读整个源句子,然后再开始翻译。如果你处理的是具有截然不同单词顺序的语言,这一点尤为重要。这正是标准序列到序列模型所做的事情。在一个合适的序列到序列设置中(见图 15.2),你将首先使用编码器 RNN 将整个源序列转换成源文本的单个表示。这可以是 RNN 的最后一个输出,或者,作为替代,其最终内部状态向量。我们可以使用这个表示作为语言模型设置中解码器 RNN 的初始状态,而不是我们用于莎士比亚生成器的零初始状态。这个解码器学习根据翻译中的当前单词预测西班牙语翻译的下一个单词,所有关于英语序列的信息都来自那个初始 RNN 状态。

图 15.2:序列到序列 RNN:一个 RNN 编码器用于生成一个编码整个源序列的向量,该向量用作 RNN 解码器的初始状态。
让我们在 Keras 中实现这个模型,使用基于 GRU 的编码器和解码器。我们可以先从编码器开始。由于我们实际上不会在编码器序列中预测标记,所以我们不必担心通过让模型从序列的末尾传递信息到序列的开头位置来“作弊”。实际上,这是一个好主意,因为我们希望有一个丰富的源序列表示。我们可以通过一个双向层来实现这一点。
embed_dim = 256
hidden_dim = 1024
source = keras.Input(shape=(None,), dtype="int32", name="english")
x = layers.Embedding(vocab_size, embed_dim, mask_zero=True)(source)
rnn_layer = layers.GRU(hidden_dim)
rnn_layer = layers.Bidirectional(rnn_layer, merge_mode="sum")
encoder_output = rnn_layer(x)
列表 15.11:构建序列到序列编码器
接下来,让我们添加解码器——一个简单的 GRU 层,它将编码的源句子作为其初始状态。在其之上,我们添加一个 Dense 层,它为每个输出步骤生成西班牙语词汇表上的概率分布。在这里,我们希望仅基于之前的内容来预测下一个标记,所以一个 双向 RNN 会通过使损失函数变得过于简单而破坏训练。
target = keras.Input(shape=(None,), dtype="int32", name="spanish")
x = layers.Embedding(vocab_size, embed_dim, mask_zero=True)(target)
rnn_layer = layers.GRU(hidden_dim, return_sequences=True)
x = rnn_layer(x, initial_state=encoder_output)
x = layers.Dropout(0.5)(x)
# Predicts the next word of the translation, given the current word
target_predictions = layers.Dense(vocab_size, activation="softmax")(x)
seq2seq_rnn = keras.Model([source, target], target_predictions)
列表 15.12:构建序列到序列解码器
让我们全面看看 seq2seq 模型:
>>> seq2seq_rnn.summary()
Model: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ english (InputLayer) │ (None, None) │ 0 │ - │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ spanish (InputLayer) │ (None, None) │ 0 │ - │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embedding_1 │ (None, None, 256) │ 3,840,000 │ english[0][0] │
│ (Embedding) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ not_equal (NotEqual) │ (None, None) │ 0 │ english[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embedding_2 │ (None, None, 256) │ 3,840,000 │ spanish[0][0] │
│ (Embedding) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ bidirectional │ (None, 1024) │ 7,876,608 │ embedding_1[0][0], │
│ (Bidirectional) │ │ │ not_equal[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ gru_2 (GRU) │ (None, None, │ 3,938,304 │ embedding_2[0][0], │
│ │ 1024) │ │ bidirectional[0][… │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dropout_1 (Dropout) │ (None, None, │ 0 │ gru_2[0][0] │
│ │ 1024) │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dense_1 (Dense) │ (None, None, │ 15,375,000 │ dropout_1[0][0] │
│ │ 15000) │ │ │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
Total params: 34,869,912 (133.02 MB)
Trainable params: 34,869,912 (133.02 MB)
Non-trainable params: 0 (0.00 B)
我们的模式和数据都已经准备好了。现在我们可以开始训练我们的翻译模型了:
seq2seq_rnn.compile(
optimizer="adam",
loss="sparse_categorical_crossentropy",
weighted_metrics=["accuracy"],
)
seq2seq_rnn.fit(train_ds, epochs=15, validation_data=val_ds)
我们选择准确率作为监控训练过程中验证集性能的粗略方法。我们达到了 65% 的准确率:平均而言,模型在西班牙语句子中正确预测下一个单词的频率为 65%。然而,在实践中,下一个标记的准确率并不是机器翻译模型的一个很好的指标,特别是因为它假设在预测标记 N + 1 时,从 0 到 N 的正确目标标记已经为人所知。实际上,在推理过程中,你是从头开始生成目标句子,你不能依赖之前生成的标记是 100% 正确的。在处理实际的机器翻译系统时,指标必须更加精心设计。有一些标准指标,如 BLEU 分数,可以衡量机器翻译文本与一组高质量参考翻译的相似性,并且可以容忍略微错位的序列。
最后,让我们使用我们的模型进行推理。我们将从测试集中的几个句子中选择,并检查我们的模型如何翻译它们。我们将从种子标记 "[start]" 开始,将其输入到解码器模型中,同时输入编码的英语源句子。我们将检索下一个标记的预测,并将其反复重新注入到解码器中,在每次迭代中采样一个新的目标标记,直到我们到达 "[end]" 或达到最大句子长度。
import numpy as np
spa_vocab = spanish_tokenizer.get_vocabulary()
spa_index_lookup = dict(zip(range(len(spa_vocab)), spa_vocab))
def generate_translation(input_sentence):
tokenized_input_sentence = english_tokenizer([input_sentence])
decoded_sentence = "[start]"
for i in range(sequence_length):
tokenized_target_sentence = spanish_tokenizer([decoded_sentence])
inputs = [tokenized_input_sentence, tokenized_target_sentence]
next_token_predictions = seq2seq_rnn.predict(inputs, verbose=0)
sampled_token_index = np.argmax(next_token_predictions[0, i, :])
sampled_token = spa_index_lookup[sampled_token_index]
decoded_sentence += " " + sampled_token
if sampled_token == "[end]":
break
return decoded_sentence
test_eng_texts = [pair[0] for pair in test_pairs]
for _ in range(5):
input_sentence = random.choice(test_eng_texts)
print("-")
print(input_sentence)
print(generate_translation(input_sentence))
列表 15.13:使用 seq2seq RNN 生成翻译
精确的翻译会因运行而异,因为最终的模型权重将取决于我们权重的随机初始化以及输入数据的随机洗牌。以下是我们的结果:
-
You know that.
[start] tú lo sabes [end]
-
"Thanks." "You're welcome."
[start] gracias tú [UNK] [end]
-
The prisoner was set free yesterday.
[start] el plan fue ayer a un atasco [end]
-
I will tell you tomorrow.
[start] te lo voy mañana a decir [end]
-
I think they're happy.
[start] yo creo que son felices [end]
我们的模型作为一个玩具模型表现相当不错,尽管它仍然犯了许多基本错误。
注意,这个推理设置虽然非常简单,但效率低下,因为每次我们采样一个新词时,都需要重新处理整个源句子和整个生成的目标句子。在实际应用中,你想要小心不要重新计算任何没有改变的状态。我们真正需要预测解码器中的新标记的只是当前标记和之前的 RNN 状态,我们可以在每次循环迭代之前将其缓存。
有许多方法可以改进这个玩具模型。我们可以为编码器和解码器都使用深层循环层堆叠,我们可以尝试其他 RNN 层,如 LSTM,等等。然而,除了这些微调之外,RNN 方法在序列到序列学习中的几个基本局限性:
-
源序列表示必须完全存储在编码器状态向量中,这显著限制了你可以翻译的句子的规模和复杂性。
-
RNN 在处理非常长的序列时遇到困难,因为它们往往会逐渐忘记过去——当你到达任意序列中的第 100 个标记时,关于序列开始的信息就所剩无几了。
循环神经网络在 2010 年代中期主导了序列到序列学习。2017 年左右的 Google Translate 是由七个大型 LSTM 层堆叠而成的,其设置类似于我们刚刚创建的。然而,这些 RNN 的局限性最终导致了研究人员开发了一种新的序列模型风格,称为 Transformer。
Transformer 架构
2017 年,Vaswani 等人发表了开创性的论文“Attention Is All You Need.”^([1]),在其中介绍了 Transformer 架构。作者们正在研究类似于我们刚刚构建的翻译系统,而关键发现就体现在标题中。结果证明,一种简单的机制称为 注意力 可以用来构建不包含循环层的强大序列模型。注意力的想法并不新颖,在他们发表时,该想法已经在自然语言处理系统中使用了几年。但当时,注意力如此有用,以至于它可以成为传递序列信息的 唯一 机制,这一点相当令人惊讶。
这一发现引发了自然语言处理——乃至更广泛的领域的革命。注意力迅速成为深度学习中最有影响力的想法之一。在本节中,你将深入了解它是如何工作的以及为什么它在序列建模中证明如此有效。然后我们将使用注意力来重建我们的英语到西班牙语的翻译模型。
因此,在所有这些铺垫之后,什么是注意力?它又是如何为我们迄今为止使用的循环神经网络提供替代方案的?
注意力实际上是为了增强我们刚刚构建的类似 RNN 模型而开发的。研究人员注意到,虽然 RNNs 在建模局部邻域中的依赖关系方面表现出色,但随着序列变长,它们在回忆方面遇到了困难。比如说,你正在构建一个回答关于源文档问题的系统。如果文档长度过长,RNN 的结果会变得非常糟糕,远远低于人类的表现。
作为一种思想实验,想象使用这本书来构建一个天气预报模型。如果你有足够的时间,你可能会从头到尾阅读整本书,但在实际实现你的模型时,你会特别关注时间序列章节。即使在章节中,你也可能会找到你经常参考的特定代码示例和解释。另一方面,当你编写代码时,你不会特别担心图像卷积的细节。这本书的总字数远远超过 10 万,远超我们处理过的任何序列长度,但人类在从文本中提取信息时可以是选择性的和情境性的。
与此相反,RNNs 缺乏直接回溯序列先前部分的机制。所有信息必须通过 RNN 单元的内部状态在循环中传递,通过序列中的每个位置。这有点像完成这本书,合上它,然后试图完全从记忆中实现那个天气预报模型。注意力的想法是通过构建一种机制,使神经网络可以根据当前正在处理的输入,在序列的某些部分给予更多权重,而在其他部分给予较少权重(图 15.3)。

图 15.3:深度学习中注意力的通用概念:输入特征被分配注意力分数,这些分数可以用来告知输入的下一个表示。
点积注意力
让我们回顾一下我们的翻译 RNN,并尝试添加选择性注意的概念。考虑预测单个标记。在通过我们的GRU层传递source和target序列之后,我们将有一个代表即将预测的目标标记的向量,以及代表源文本中每个单词的向量序列。
使用注意力,我们的目标是给模型一种方法,根据当前试图预测的单词的相关性,为我们的源序列中的每个向量评分(图 15.4)。如果一个源标记的向量表示具有高分数,我们认为它特别重要;如果不是,我们就不太关心它。目前,让我们假设我们有一个这个函数 score(target_vector, source_vector)。

图 15.4:注意力为源序列中的每个向量以及目标序列中的每个向量分配一个相关性分数。
为了使注意力机制工作得更好,我们希望避免通过可能长达我们组合的源和目标序列长度之长的循环传递关于重要标记的信息——这就是 RNN 开始失败的地方。一个简单的方法是根据我们将要计算的分数对所有的源向量进行加权求和。如果给定目标的所有注意力分数之和为 1,这将使我们的加权求和具有可预测的幅度,这将是方便的。我们可以通过运行softmax函数来实现这一点——在 NumPy 伪代码中可能像这样:
scores = [score(target, source) for source in sources]
scores = softmax(scores)
combined = np.sum(scores * sources)
但我们应该如何计算这个相关性分数呢?当研究人员最初开始使用注意力机制时,这个问题成为了研究的重点。事实证明,最直接的方法是最好的。我们可以使用点积作为目标向量和源向量之间距离的简单度量。如果源向量和目标向量很接近,我们假设这意味着源标记与我们的预测相关。在本章结束时,我们将探讨为什么这个假设具有直观的意义。
让我们更新我们的伪代码。我们可以通过一次处理整个目标序列来使我们的片段更加完整——它将相当于在目标序列中的每个标记上运行我们之前的片段。当target和source都是序列时,注意力分数将是一个矩阵。每一行表示在加权求和中,目标单词将如何评价源单词(参见图 15.5)。我们将使用 Einsum 符号作为方便地编写点积和加权求和的方法:
def dot_product_attention(target, source):
# Takes the dot-product between all target and source vectors,
# where b = batch size, t = target length, s = source length, and d
# = vector size
scores = np.einsum("btd,bsd->bts", target, source)
scores = softmax(scores, axis=-1)
# Computes a weighted sum of all source vectors for each target
# vector
return np.einsum("bts,bsd->btd", scores, source)
dot_product_attention(target, source)

图 15.5:当目标和源都是序列时,注意力分数是一个二维矩阵。每一行显示了我们要预测的单词的注意力分数(以绿色显示)。
如果我们给模型参数以控制注意力分数,我们可以使这个注意力机制的假设空间变得更加丰富。如果我们用Dense层将源和目标向量都投影,模型可以在一个良好的共享空间中找到源向量,如果它们有助于整体预测质量,源向量就会靠近目标向量。同样,我们应该允许模型在将源向量组合之前和求和之后将源向量投影到完全不同的空间中。
我们还可以采用在领域内已成为标准的稍有不同的输入命名。我们刚才写的内容大致可以总结为sum(score(target, source) * source)。我们将用不同的输入名称以相同的方式写出这个等价表达式,即sum(score(query, key) * value)。这个三个参数的版本更通用——在罕见的情况下,你可能不想使用与你的源输入相同的向量来评分你的源输入,就像你用来求和源输入的向量一样。
术语来自搜索引擎和推荐系统。想象一个在数据库中查找照片的工具——“查询”是你的搜索词,“键”是你用来与查询匹配的照片标签,最后,“值”是照片本身(图 15.6)。我们构建的注意力机制大致相当于这种查找方式。

图 15.6:从数据库检索图像:查询与一组键进行比较,并使用匹配分数对值(图像)进行排序。
让我们更新我们的伪代码,以便我们有一个使用新术语的参数化注意力:
query_dense = layers.Dense(dim)
key_dense = layers.Dense(dim)
value_dense = layers.Dense(dim)
output_dense = layers.Dense(dim)
def parameterized_attention(query, key, value):
query = query_dense(query)
key = key_dense(key)
value = value_dense(value)
scores = np.einsum("btd,bsd->bts", query, key)
scores = softmax(scores, axis=-1)
outputs = np.einsum("bts,bsd->btd", scores, value)
return output_dense(outputs)
parameterized_attention(query=target, key=source, value=source)
这个模块是一个功能齐全的注意力机制!我们刚刚编写了一个函数,允许模型根据我们正在解码的目标词,从源序列的任何位置提取信息,上下文相关。
“注意力即一切”的作者通过试错法对我们的机制进行了两项更多更改。第一项是一个简单的缩放因子。当输入向量变长时,点积分数可能会变得相当大,这可能会影响 softmax 梯度的稳定性。解决方案很简单:我们可以稍微降低我们的 softmax 分数。通过向量长度的平方根进行缩放对任何向量大小都有效。
另一方面与注意力机制的表意性有关。我们进行的 softmax 求和非常强大——它允许在序列的遥远部分之间建立直接连接。但求和操作也很直接:如果模型试图一次性关注太多标记,单个源标记的有趣特征会在组合表示中被“冲淡”。一个有效的小技巧是对同一序列进行多次注意力操作,使用几个不同的注意力头以不同的参数运行相同的计算:
query_dense = [layers.Dense(head_dim) for i in range(num_heads)]
key_dense = [layers.Dense(head_dim) for i in range(num_heads)]
value_dense = [layers.Dense(head_dim) for i in range(num_heads)]
output_dense = layers.Dense(head_dim * num_heads)
def multi_head_attention(query, key, value):
head_outputs = []
for i in range(num_heads):
query = query_densei
key = key_densei
value = value_densei
scores = np.einsum("btd,bsd->bts", target, source)
scores = softmax(scores / math.sqrt(head_dim), axis=-1)
head_output = np.einsum("bts,bsd->btd", scores, source)
head_outputs.append(head_output)
outputs = ops.concatenate(head_outputs, axis=-1)
return output_dense(outputs)
multi_head_attention(query=target, key=source, value=source)
通过不同地投影查询和键,一个头可能学会匹配源句子的主题,而另一个头可能关注标点符号。这种多头注意力避免了需要将整个源序列与单个 softmax 求和结合的限制(图 15.7)。

图 15.7:多头注意力允许每个目标词在最终输出向量的不同分区中关注源序列的不同部分。
当然,在实践中,你希望将这段代码编写成一个可重用的层。在这里,Keras 为你提供了支持。我们可以使用MultiHeadAttention层重新创建之前的代码,如下所示:
multi_head_attention = keras.layers.MultiHeadAttention(
num_heads=num_heads,
head_dim=head_dim,
)
multi_head_attention(query=target, key=source, value=source)
Transformer 编码器块
使用 MultiHeadAttention 层的一种方法是将它添加到我们现有的 RNN 翻译模型中。我们可以将编码器和解码器的序列输出传递到一个注意力层,并使用其输出在预测之前更新我们的目标序列。注意力可以使模型处理文本中的长距离依赖关系,而 GRU 层将难以处理。这实际上提高了 RNN 模型的能力,并且是注意力在 2010 年代中期首次被使用的方式。
然而,“Attention is all you need”的作者意识到你可以更进一步,将注意力作为处理模型中所有序列数据的一般机制。尽管到目前为止我们只将注意力视为处理两个序列之间信息传递的方式,但你也可以将注意力作为让序列关注自身的方式:
multi_head_attention(key=source, value=source, query=source)
这被称为 自注意力,它非常强大。使用自注意力,每个标记可以关注其自身序列中的每个标记,包括自身,这使得模型能够学习到上下文中的词表示。
考虑一个例子句子:“火车准时离开了车站。”现在,考虑句子中的一个词:“车站。”我们谈论的是哪种车站?可能是一个广播电台?也许是一个国际空间站?使用自注意力,模型可以学习给“车站”和“火车”这对词赋予高注意力得分,将表示“火车”的向量加到“车站”这个词的表示中。
自注意力为模型提供了一种有效的方法,从表示一个真空中的词到表示一个基于序列中所有其他标记的词。这听起来很像 RNN 应该做的事情。我们是否可以直接用 MultiHeadAttention 替换我们的 RNN 层?
几乎是了!但还不完全;我们仍然需要一个深度神经网络的基本成分——一个非线性激活函数。MultiHeadAttention 层结合了源序列中每个元素的线性投影,仅此而已。从某种意义上说,它是一个非常表达性的池化操作。考虑一个极端情况,一个标记长度为 1。在这种情况下,注意力得分矩阵始终是一个单一值,整个层简化为源序列的线性投影,没有任何非线性。你可以堆叠 100 个注意力层,仍然可以将整个计算简化为单次矩阵乘法!这是我们模型表达力的一个真正问题。
在某个点上,所有循环单元都会将每个标记的输入向量通过密集投影,并应用激活函数;我们需要一个类似的计划。在“Attention is all you need”的作者决定以最简单的方式添加这个功能——堆叠一个由两个密集层和一个中间激活函数组成的前馈网络。注意力在序列间传递信息,前馈网络更新单个序列项的表示。
我们已经准备好开始构建一个 Transformer 模型。让我们从替换我们的翻译模型的编码器开始。我们将使用自注意力机制来在英语单词的源序列中传递信息。我们还将加入在第九章中构建 ConvNets 时发现特别重要的两个东西,归一化和 residual connections。
class TransformerEncoder(keras.Layer):
def __init__(self, hidden_dim, intermediate_dim, num_heads):
super().__init__()
key_dim = hidden_dim // num_heads
# Self-attention layers
self.self_attention = layers.MultiHeadAttention(num_heads, key_dim)
self.self_attention_layernorm = layers.LayerNormalization()
# Feedforward layers
self.feed_forward_1 = layers.Dense(intermediate_dim, activation="relu")
self.feed_forward_2 = layers.Dense(hidden_dim)
self.feed_forward_layernorm = layers.LayerNormalization()
def call(self, source, source_mask):
# Self-attention computation
residual = x = source
mask = source_mask[:, None, :]
x = self.self_attention(query=x, key=x, value=x, attention_mask=mask)
x = x + residual
x = self.self_attention_layernorm(x)
# Feedforward computation
residual = x
x = self.feed_forward_1(x)
x = self.feed_forward_2(x)
x = x + residual
x = self.feed_forward_layernorm(x)
return x
列表 15.14:一个 Transformer 编码器块
你会注意到我们在这里使用的归一化层并不是像我们在图像模型中使用的那种 BatchNormalization 层。这是因为 BatchNormalization 在序列数据上效果不佳。相反,我们使用的是 LayerNormalization 层,它独立于批次中的其他序列对每个序列进行归一化——就像这样,在类似 NumPy 的伪代码中:
# Input shape: (batch_size, sequence_length, embedding_dim)
def layer_normalization(batch_of_sequences):
# To compute mean and variance, we only pool data over the last
# axis.
mean = np.mean(batch_of_sequences, keepdims=True, axis=-1)
variance = np.var(batch_of_sequences, keepdims=True, axis=-1)
return (batch_of_sequences - mean) / variance
与 BatchNormalization(在训练期间)比较:
# Input shape: (batch_size, height, width, channels)
def batch_normalization(batch_of_images):
# Pools data over the batch axis (axis 0), which creates
# interactions between samples in a batch
mean = np.mean(batch_of_images, keepdims=True, axis=(0, 1, 2))
variance = np.var(batch_of_images, keepdims=True, axis=(0, 1, 2))
return (batch_of_images - mean) / variance
虽然 BatchNormalization 从许多样本中收集信息以获得特征均值和方差的准确统计数据,但 LayerNormalization 在每个序列内部单独汇总数据,这更适合序列数据。
我们还向 MultiHeadAttention 层传递一个新的输入,称为 attention_mask。这个布尔张量输入将被广播到与我们的注意力分数相同的形状 (batch_size, target_length, source_length)。当设置时,它将在特定位置将注意力分数置零,阻止这些位置的源标记在注意力计算中使用。我们将使用这个来防止序列中的任何标记关注到填充标记,这些标记不包含任何信息。我们的编码器层接受一个 source_mask 输入,它将标记我们输入中的所有非填充标记,并将其提升到形状 (batch_size, 1, source_length) 以用作 attention_mask。
注意,这个层的输入和输出具有相同的形状,因此编码器块可以堆叠在一起,构建对输入英语句子表达越来越丰富的表示。
Transformer 解码器块
接下来是解码器块。这个层将几乎与编码器块相同,但我们希望解码器使用编码器的输出序列作为输入。为此,我们可以使用两次注意力。我们首先应用一个类似于编码器的自注意力层,它允许目标序列中的每个位置使用来自其他目标位置的信息。然后我们添加另一个 MultiHeadAttention 层,它接收源序列和目标序列作为输入。我们将这个注意力层称为 交叉注意力,因为它在编码器和解码器之间传递信息。
class TransformerDecoder(keras.Layer):
def __init__(self, hidden_dim, intermediate_dim, num_heads):
super().__init__()
key_dim = hidden_dim // num_heads
# Self-attention layers
self.self_attention = layers.MultiHeadAttention(num_heads, key_dim)
self.self_attention_layernorm = layers.LayerNormalization()
# Cross-attention layers
self.cross_attention = layers.MultiHeadAttention(num_heads, key_dim)
self.cross_attention_layernorm = layers.LayerNormalization()
# Feedforward layers
self.feed_forward_1 = layers.Dense(intermediate_dim, activation="relu")
self.feed_forward_2 = layers.Dense(hidden_dim)
self.feed_forward_layernorm = layers.LayerNormalization()
def call(self, target, source, source_mask):
# Self-attention computation
residual = x = target
x = self.self_attention(query=x, key=x, value=x, use_causal_mask=True)
x = x + residual
x = self.self_attention_layernorm(x)
# Cross-attention computation
residual = x
mask = source_mask[:, None, :]
x = self.cross_attention(
query=x, key=source, value=source, attention_mask=mask
)
x = x + residual
x = self.cross_attention_layernorm(x)
# Feedforward computation
residual = x
x = self.feed_forward_1(x)
x = self.feed_forward_2(x)
x = x + residual
x = self.feed_forward_layernorm(x)
return x
列表 15.15:一个 Transformer 解码器块
我们的解码器层接受一个 target 和一个 source。与 TransformerEncoder 一样,我们接受一个 source_mask,它标记源输入中所有填充的位置(True 表示非填充,False 表示填充),并将其用作交叉注意力层的 attention_mask。
对于解码器的自注意力层,我们需要不同类型的注意力掩码。回想一下,当我们构建我们的 RNN 解码器时,我们避免了使用 Bidirectional RNN。如果我们使用了双向 RNN,模型将能够通过看到它试图预测的标签作为特征来作弊!注意力本质上是双向的;在自注意力中,目标序列中的任何标记位置都可以关注任何其他位置。如果不特别小心,我们的模型将学会将序列中的下一个标记作为当前标签,并且将没有能力生成新颖的翻译。
我们可以使用特殊的“因果”注意力掩码来实现单向信息流。假设我们传递一个在下半三角部分为 1 的注意力掩码,如下所示:
[
[1, 0, 0, 0, 0],
[1, 1, 0, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 1],
]
每一行 i 可以被读取为目标位置 i 的目标标记的注意力掩码。在第一行中,第一个标记只能关注自身。在第二行中,第二个标记可以关注第一个和第二个标记,以此类推。这给我们带来了与我们的 RNN 层相同的效果,其中信息只能在序列中向前传播,而不能向后传播。在 Keras 中,您可以通过在调用时将 use_casual_mask 传递给 MultiHeadAttention 层来简单地指定这个下三角掩码。图 15.8 展示了当堆叠到 Transformer 模型中时,编码器和解码器层中的层的一个视觉表示。

图 15.8:TransformerEncoder 和 TransformerDecoder 块的计算的视觉表示
使用 Transformer 进行序列到序列学习
让我们尝试将这些全部放在一起。我们将使用与我们的 RNN 模型相同的基本设置,用我们的 TransformerEncoder 和 TransformerDecoder 替换 GRU 层。在整个模型中,我们将使用 256 作为嵌入大小,除了在前馈块中。在前馈块中,我们在非线性之前将嵌入大小扩展到 2048,然后在之后将其缩放回模型的隐藏大小。这个大的中间维度在实践中效果很好。
hidden_dim = 256
intermediate_dim = 2048
num_heads = 8
source = keras.Input(shape=(None,), dtype="int32", name="english")
x = layers.Embedding(vocab_size, hidden_dim)(source)
encoder_output = TransformerEncoder(hidden_dim, intermediate_dim, num_heads)(
source=x,
source_mask=source != 0,
)
target = keras.Input(shape=(None,), dtype="int32", name="spanish")
x = layers.Embedding(vocab_size, hidden_dim)(target)
x = TransformerDecoder(hidden_dim, intermediate_dim, num_heads)(
target=x,
source=encoder_output,
source_mask=source != 0,
)
x = layers.Dropout(0.5)(x)
target_predictions = layers.Dense(vocab_size, activation="softmax")(x)
transformer = keras.Model([source, target], target_predictions)
列表 15.16:构建 Transformer 模型
让我们来看看我们 Transformer 模型的摘要:
>>> transformer.summary()
Model: "functional_3"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ english (InputLayer) │ (None, None) │ 0 │ - │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embedding_5 │ (None, None, 256) │ 3,840,000 │ english[0][0] │
│ (Embedding) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ not_equal_4 │ (None, None) │ 0 │ english[0][0] │
│ (NotEqual) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ spanish (InputLayer) │ (None, None) │ 0 │ - │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ transformer_encoder_1 │ (None, None, 256) │ 1,315,072 │ embedding_5[0][0], │
│ (TransformerEncoder) │ │ │ not_equal_4[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ not_equal_5 │ (None, None) │ 0 │ english[0][0] │
│ (NotEqual) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embedding_6 │ (None, None, 256) │ 3,840,000 │ spanish[0][0] │
│ (Embedding) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ transformer_decoder_1 │ (None, None, 256) │ 1,578,752 │ transformer_encod… │
│ (TransformerDecoder) │ │ │ not_equal_5[0][0], │
│ │ │ │ embedding_6[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dropout_9 (Dropout) │ (None, None, 256) │ 0 │ transformer_decod… │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dense_11 (Dense) │ (None, None, │ 3,855,000 │ dropout_9[0][0] │
│ │ 15000) │ │ │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
Total params: 14,428,824 (55.04 MB)
Trainable params: 14,428,824 (55.04 MB)
Non-trainable params: 0 (0.00 B)
我们的模式几乎与我们在前面训练的 GRU 翻译模型具有相同的结构,现在注意力取代了循环层作为在序列间传递信息的机制。让我们尝试训练这个模型:
transformer.compile(
optimizer="adam",
loss="sparse_categorical_crossentropy",
weighted_metrics=["accuracy"],
)
transformer.fit(train_ds, epochs=15, validation_data=val_ds)
训练后,我们达到了大约 58% 的准确率:平均而言,模型正确预测西班牙语句子中下一个单词的准确率为 58%。这里有些不对劲。训练效果比 RNN 模型差 7 个百分点。要么这个 Transformer 架构并不是人们吹嘘的那样,要么我们在实现中遗漏了某些东西。你能找出是什么吗?
这一节表面上关于序列模型。在前一章中,我们看到了单词顺序对于意义的重要性。然而,我们刚刚构建的 Transformer 根本不是一个序列模型。你注意到了吗?它由密集层组成,这些层独立地处理序列标记,还有一个关注层,它将标记视为一组。如果你改变序列中标记的顺序,你会得到相同的成对注意力分数和相同的有上下文感知的表示。如果你将每个英语源句子中的每个单词完全重新排列,模型不会注意到,你仍然会得到相同的准确度。注意力是一种集合处理机制,专注于序列元素对之间的关系——它对元素是否出现在序列的开始、结束或中间是盲目的。那么我们为什么说 Transformer 是一个序列模型呢?如果它不查看单词顺序,它怎么可能适合机器翻译呢?
对于 RNNs,我们依赖于层的计算来确保顺序感知。在 Transformer 的情况下,我们直接将位置信息注入到我们的嵌入序列本身。这被称为位置嵌入。让我们看看。
嵌入位置信息
位置嵌入背后的思想非常简单:为了使模型能够访问单词顺序信息,我们将单词在句子中的位置添加到每个单词嵌入中。我们的输入单词嵌入将有两个组成部分:通常的单词向量,它代表单词,不受任何特定上下文的影响,以及一个位置向量,它代表单词在当前句子中的位置。希望模型能够然后找出如何最好地使用这些附加信息。
添加位置信息最直接的方法是将每个单词的位置连接到其嵌入向量中。你会在向量中添加一个“位置”轴,并用0填充序列中的第一个单词,1填充第二个,以此类推。
然而,这可能并不理想,因为位置可能潜在地是非常大的整数,这将干扰嵌入向量的值域。正如你所知,神经网络不喜欢非常大的输入值或离散的输入分布。
“注意力即一切”的作者使用了一个有趣的技巧来编码单词位置:他们向单词嵌入中添加了一个包含[-1, 1]范围内值的向量,这些值根据位置周期性变化(他们使用余弦函数来实现这一点)。这个技巧提供了一种通过一个小值向量唯一表征大范围内任何整数的方法。这很聪明,但结果证明我们可以做更简单、更有效的事情:我们将以学习单词索引相同的方式学习位置嵌入向量。然后我们将我们的位置嵌入添加到相应的单词嵌入中,以获得一个位置感知的单词嵌入。这被称为位置嵌入。让我们来实现它。
from keras import ops
class PositionalEmbedding(keras.Layer):
def __init__(self, sequence_length, input_dim, output_dim):
super().__init__()
self.token_embeddings = layers.Embedding(input_dim, output_dim)
self.position_embeddings = layers.Embedding(sequence_length, output_dim)
def call(self, inputs):
# Computes incrementing positions [0, 1, 2...] for each
# sequence in the batch
positions = ops.cumsum(ops.ones_like(inputs), axis=-1) - 1
embedded_tokens = self.token_embeddings(inputs)
embedded_positions = self.position_embeddings(positions)
return embedded_tokens + embedded_positions
列表 15.17:一个学习到的位置嵌入层
我们将像使用常规Embedding层一样使用这个PositionalEmbedding层。让我们在尝试第二次训练我们的 Transformer 时看看它的实际效果。
hidden_dim = 256
intermediate_dim = 2056
num_heads = 8
source = keras.Input(shape=(None,), dtype="int32", name="english")
x = PositionalEmbedding(sequence_length, vocab_size, hidden_dim)(source)
encoder_output = TransformerEncoder(hidden_dim, intermediate_dim, num_heads)(
source=x,
source_mask=source != 0,
)
target = keras.Input(shape=(None,), dtype="int32", name="spanish")
x = PositionalEmbedding(sequence_length, vocab_size, hidden_dim)(target)
x = TransformerDecoder(hidden_dim, intermediate_dim, num_heads)(
target=x,
source=encoder_output,
source_mask=source != 0,
)
x = layers.Dropout(0.5)(x)
target_predictions = layers.Dense(vocab_size, activation="softmax")(x)
transformer = keras.Model([source, target], target_predictions)
列表 15.18:使用位置嵌入构建 Transformer 模型
现在我们已经将位置嵌入添加到我们的模型中,让我们再次尝试训练:
transformer.compile(
optimizer="adam",
loss="sparse_categorical_crossentropy",
weighted_metrics=["accuracy"],
)
transformer.fit(train_ds, epochs=30, validation_data=val_ds)
在模型中重新引入位置信息后,情况变得好多了。我们在猜测下一个单词时达到了 67%的准确率。与GRU模型相比,这是一个明显的改进,而且当你考虑到这个模型只有GRU模型参数的一半时,这更加令人印象深刻。
关于这次训练运行,还有一件其他重要的事情需要注意。训练速度明显快于 RNN——每个 epoch 大约需要三分之一的时间。即使我们与 RNN 模型的参数数量相匹配,这也是真的,这是去掉我们的GRU层循环状态传递的副作用。有了注意力,训练过程中没有循环计算要处理,这意味着在 GPU 或 TPU 上,我们可以一次性处理整个注意力计算。这使得Transformer在加速器上的训练更快。
让我们使用新训练的Transformer重新运行生成。我们可以使用与我们的 RNN 采样相同的代码。
import numpy as np
spa_vocab = spanish_tokenizer.get_vocabulary()
spa_index_lookup = dict(zip(range(len(spa_vocab)), spa_vocab))
def generate_translation(input_sentence):
tokenized_input_sentence = english_tokenizer([input_sentence])
decoded_sentence = "[start]"
for i in range(sequence_length):
tokenized_target_sentence = spanish_tokenizer([decoded_sentence])
tokenized_target_sentence = tokenized_target_sentence[:, :-1]
inputs = [tokenized_input_sentence, tokenized_target_sentence]
next_token_predictions = transformer.predict(inputs, verbose=0)
sampled_token_index = np.argmax(next_token_predictions[0, i, :])
sampled_token = spa_index_lookup[sampled_token_index]
decoded_sentence += " " + sampled_token
if sampled_token == "[end]":
break
return decoded_sentence
test_eng_texts = [pair[0] for pair in test_pairs]
for _ in range(5):
input_sentence = random.choice(test_eng_texts)
print("-")
print(input_sentence)
print(generate_translation(input_sentence))
列表 15.19:使用 Transformer 生成翻译
运行生成代码,我们得到以下输出:
-
The resemblance between these two men is uncanny.
[start] el parecido entre estos cantantes de dos hombres son asombrosa [end]
-
I'll see you at the library tomorrow.
[start] te veré en la biblioteca mañana [end]
-
Do you know how to ride a bicycle?
[start] sabes montar en bici [end]
-
Tom didn't want to do their dirty work.
[start] tom no quería hacer su trabajo [end]
-
Is he back already?
[start] ya ha vuelto [end]
主观来说,Transformer 的性能明显优于基于 GRU 的翻译模型。它仍然是一个玩具模型,但是一个更好的玩具模型。
Transformer 是一种强大的架构,为文本处理模型兴趣的爆发奠定了基础。就深度学习模型而言,它也相当复杂。在看到所有这些实现细节后,人们可能会合理地抗议,这一切似乎都很随意。有如此多的细节需要我们相信。我们怎么可能知道这个层的选择和配置是最优的?
答案很简单——它不是。多年来,人们通过改变注意力、归一化和位置嵌入等方式对 Transformer 架构进行了多项改进。随着序列长度变得非常长,今天的研究中许多新模型都在用某种计算上更简单的机制来完全取代注意力。最终,也许在你阅读这本书的时候,某种东西将取代 Transformer,成为语言建模中占主导地位的架构。
我们可以从 Transformer 中学到很多经得起时间考验的东西。在本章结束时,我们将讨论是什么使得 Transformer 如此有效。但值得记住的是,作为整体,机器学习领域是经验性的。注意力是从增强 RNNs 的尝试中产生的,经过大量人员的多年猜测和检查,它导致了 Transformer 的诞生。几乎没有理由认为这个过程已经结束。
使用预训练 Transformer 进行分类
在“注意力即一切”之后,人们开始注意到 Transformer 训练可以扩展到何种程度,尤其是与之前的模型相比。正如我们刚才提到的,一个很大的优点是,该模型比 RNNs 训练得更快。在用 GPU 或 TPU 工作时,没有更多的循环,这总是件好事。
它也是一个非常“数据饥渴”的模型架构。我们实际上在上一个部分中已经尝到了一点。虽然我们的 RNN 翻译模型在 5 个或更多个 epoch 后验证性能达到了平台期,但 Transformer 模型在训练了 30 个 epoch 后仍在提高其验证分数。
这些观察促使许多人尝试通过更多数据、层和参数来扩展 Transformer,结果非常显著。这导致该领域向大型预训练模型发生了显著转变,这些模型训练成本可能高达数百万美元,但在文本领域的广泛问题上表现明显更好。
在文本部分的最后一个代码示例中,我们将重新审视我们的 IMDb 文本分类问题,这次使用预训练的 Transformer 模型。
预训练 Transformer 编码器
在 NLP 中第一个流行的预训练 Transformer 被称为 BERT,即“来自 Transformer 的双向编码器表示”,缩写为 BERT^([2])。该论文和模型在“注意力即一切”发布后一年发布。模型结构与我们所构建的翻译 Transformer 的编码器部分完全相同。这个编码器模型是双向的,即序列中的每个位置都可以关注其前后的位置。这意味着它是一个计算输入文本丰富表示的好模型,但不是一个旨在循环运行生成的模型。
BERT 的训练规模在 1 亿到 3 亿参数之间,比我们刚刚训练的 1400 万个参数的 Transformer 大得多。这意味着模型需要大量的训练数据才能表现良好。为了实现这一点,作者们使用了一种经典的称为遮蔽语言模型的语言模型设置。为了预训练模型,我们取一个文本序列,并替换大约 15%的标记为一个特殊的`
这种训练设置是无监督的。你不需要任何关于你输入文本的标签;对于任何文本序列,你都可以轻松地选择一些随机标记并对其进行遮蔽。这使得作者们能够找到大量训练这种规模模型所需的文本数据。大部分数据是从维基百科作为来源获取的。
在 BERT 发布时,使用预训练的词嵌入已经是常见的做法了——我们在上一章中自己就看到了这一点。但是,预训练整个 Transformer 带来了更强大的功能——能够在周围词语的上下文中计算一个词语的嵌入。Transformer 允许以当时前所未有的规模和质量来做这件事。
BERT 的作者将这个模型在大量文本上进行了预训练,并将其专门化,以在当时的几个 NLP 基准测试中取得最先进的成果。这标志着该领域向使用非常大的预训练模型转变,通常只需要少量微调。让我们试试看。
加载预训练的 Transformer
在这里不使用 BERT,让我们使用一个后续模型,称为 RoBERTa^([3]),简称 Robustly Optimized BERT。RoBERTa 对 BERT 的架构进行了一些小的简化,但最值得注意的是使用了更多的训练数据来提高性能。BERT 使用了 16 GB 的英语文本,主要来自维基百科。RoBERTa 的作者使用了来自整个网络的 160 GB 文本。当时估计 RoBERTa 的训练成本为几万美元。由于这个额外的训练数据,模型在等效的总参数数量下表现明显更好。
要使用预训练模型,我们需要一些东西:
-
匹配的分词器——与预训练模型本身一起使用。任何文本都必须以与预训练期间相同的方式进行分词。如果我们的 IMDb 评论中的词语映射到与预训练期间不同的标记索引,我们就不能使用模型中每个标记学习到的表示。
-
匹配的模型架构——为了使用预训练模型,我们需要精确地重建模型在预训练过程中内部使用的数学公式。
-
预训练权重——这些权重是通过在 1,024 个 GPU 上训练模型大约一天,并在数十亿个输入词上创建的。
我们自己重新创建分词器和架构代码并不会太难。模型的内部结构几乎与我们之前构建的TransformerEncoder完全匹配。然而,匹配模型实现是一个耗时的过程,正如我们在本书前面所做的那样,我们可以使用 KerasHub 库来访问 Keras 的预训练模型实现。
让我们使用 KerasHub 来加载 RoBERTa 分词器和模型。我们可以使用特殊的构造函数from_preset()从磁盘加载预训练模型的权重、配置和分词器资产。我们将加载 RoBERTa 的基础模型,这是与 RoBERTa 论文一起发布的几个预训练检查点中最小的一个。
import keras_hub
tokenizer = keras_hub.models.Tokenizer.from_preset("roberta_base_en")
backbone = keras_hub.models.Backbone.from_preset("roberta_base_en")
列表 15.20:使用 KerasHub 加载 RoBERTa 预训练模型
Tokenizer将文本映射到整数序列,正如我们所期望的那样。还记得我们在上一章中构建的SubWordTokenizer吗?RoBERTa 的分词器几乎与那个分词器相同,只是对处理来自任何语言的 Unicode 字符进行了一些小的调整。
考虑到 RoBERTa 预训练数据集的大小,子词分词是必须的。使用字符级分词器会使输入序列变得非常长,从而使模型训练成本大大增加。使用词级分词器则需要一个庞大的词汇表来尝试覆盖来自网络上的数百万个文档中的所有不同单词。要获得良好的单词覆盖范围,将会使我们的词汇表大小爆炸,并使 Transformer 前面的Embedding层变得无法使用。使用子词分词器允许模型仅使用 50,000 个术语的词汇表来处理任何单词:
>>> tokenizer("The quick brown fox")
Array([ 133, 2119, 6219, 23602], dtype=int32)
我们刚刚加载的这个Backbone是什么?在第八章中,我们看到backbone是一个在计算机视觉中常用的术语,指的是从输入图像映射到潜在空间(basically a vision model without a head for making predictions)的网络——基本上是一个没有预测头部的视觉模型。在 KerasHub 中,backbone指的是任何尚未针对特定任务进行优化的预训练模型。我们刚刚加载的模型接受一个输入序列,并将其嵌入到一个形状为(batch_size, sequence_length, 768)的输出序列中,但它还没有设置特定的损失函数。你可以用它来完成任何数量的下游任务——例如分类句子、识别包含特定信息的文本片段、识别词性等。
接下来,我们将为这个backbone附加一个分类头,使其专门用于我们的 IMDb 评论分类微调。你可以将这想象为给螺丝刀附加不同的头部:一个用于一个任务,一个用于另一个任务。
让我们来看看我们的backbone。在这里,我们加载了 RoBERTa 的最小变体,但它仍然有 1.24 亿个参数,这是我们在这本书中使用过的最大的模型:
>>> backbone.summary()
Model: "roberta_backbone"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ token_ids │ (None, None) │ 0 │ - │
│ (InputLayer) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embeddings │ (None, None, 768) │ 38,996,736 │ token_ids[0][0] │
│ (TokenAndPositionEmb… │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embeddings_layer_norm │ (None, None, 768) │ 1,536 │ embeddings[0][0] │
│ (LayerNormalization) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ embeddings_dropout │ (None, None, 768) │ 0 │ embeddings_layer_… │
│ (Dropout) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ padding_mask │ (None, None) │ 0 │ - │
│ (InputLayer) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ transformer_layer_0 │ (None, None, 768) │ 7,087,872 │ embeddings_dropou… │
│ (TransformerEncoder) │ │ │ padding_mask[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ transformer_layer_1 │ (None, None, 768) │ 7,087,872 │ transformer_layer… │
│ (TransformerEncoder) │ │ │ padding_mask[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ ... │ ... │ ... │ ... │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ transformer_layer_11 │ (None, None, 768) │ 7,087,872 │ transformer_layer… │
│ (TransformerEncoder) │ │ │ padding_mask[0][0] │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
Total params: 124,052,736 (473.22 MB)
Trainable params: 124,052,736 (473.22 MB)
Non-trainable params: 0 (0.00 B)
RoBERTa 使用了 12 个堆叠在一起的 Transformer 编码器层。这比我们的翻译模型有了很大的提升!
预处理 IMDb 电影评论
我们可以不加修改地重用第十四章中使用的 IMDb 加载代码。这将下载电影评论数据到 train_dir 和 test_dir,并将验证数据集分割到 val_dir:
from keras.utils import text_dataset_from_directory
batch_size = 16
train_ds = text_dataset_from_directory(train_dir, batch_size=batch_size)
val_ds = text_dataset_from_directory(val_dir, batch_size=batch_size)
test_ds = text_dataset_from_directory(test_dir, batch_size=batch_size)
加载后,我们再次拥有一个包含 20,000 条电影评论的训练集和一个包含 5,000 条电影评论的验证集。
在微调我们的分类模型之前,我们必须使用我们加载的 RoBERTa 分词器对电影评论进行标记。在预训练期间,RoBERTa 使用了一种类似于我们为翻译模型所做的“打包”标记到序列的特定形式。每个序列将以 <s> 标记开始,以 </s> 标记结束,并跟随着任意数量的 <pad> 标记,如下所示:
[
["<s>", "the", "quick", "brown", "fox", "jumped", ".", "</s>"],
["<s>", "the", "panda", "slept", ".", "</s>", "<pad>", "<pad>"],
]
重要的是尽可能匹配预训练中使用的标记顺序;如果我们这样做,模型将训练得更快、更准确。KerasHub 提供了一个用于此类标记打包的层,称为 StartEndPacker。该层附加起始、结束和填充标记,如果需要,将长序列修剪到给定的序列长度。让我们使用它以及我们的分词器。
def preprocess(text, label):
packer = keras_hub.layers.StartEndPacker(
sequence_length=512,
start_value=tokenizer.start_token_id,
end_value=tokenizer.end_token_id,
pad_value=tokenizer.pad_token_id,
return_padding_mask=True,
)
token_ids, padding_mask = packer(tokenizer(text))
return {"token_ids": token_ids, "padding_mask": padding_mask}, label
preprocessed_train_ds = train_ds.map(preprocess)
preprocessed_val_ds = val_ds.map(preprocess)
preprocessed_test_ds = test_ds.map(preprocess)
列表 15.21:使用 RoBERTa 的分词器预处理 IMDb 电影评论
让我们看一下单个预处理的批次:
>>> next(iter(preprocessed_train_ds))
({"token_ids": <tf.Tensor: shape=(16, 512), dtype=int32, numpy=
array([[ 0, 713, 56, ..., 1, 1, 1],
[ 0, 1121, 5, ..., 101, 24, 2],
[ 0, 713, 1569, ..., 1, 1, 1],
...,
[ 0, 100, 3996, ..., 1, 1, 1],
[ 0, 100, 64, ..., 4655, 101, 2],
[ 0, 734, 8, ..., 1, 1, 1]], dtype=int32)>,
"padding_mask": <tf.Tensor: shape=(16, 512), dtype=bool, numpy=
array([[ True, True, True, ..., False, False, False],
[ True, True, True, ..., True, True, True],
[ True, True, True, ..., False, False, False],
...,
[ True, True, True, ..., False, False, False],
[ True, True, True, ..., True, True, True],
[ True, True, True, ..., False, False, False]])>},
<tf.Tensor: shape=(16,), dtype=int32, numpy=array([0, 1, ...], dtype=int32)>)
在我们的输入预处理完成后,我们就可以开始微调我们的模型了。
微调预训练的 Transformer
在我们将骨干微调以预测电影评论之前,我们需要更新它,使其输出二进制分类标签。骨干输出一个形状为 (batch_size, sequence_length, 768) 的整个序列,其中每个 768 维向量代表一个在其周围词语上下文中的输入词。在预测标签之前,我们必须将这个序列压缩为每个样本的单个向量。
一个选择是在整个序列上进行平均池化或最大池化,计算所有标记向量的平均值。稍微好一点的方法是简单地使用第一个标记的表示作为池化值。这是由于我们模型中注意力的性质——最终编码层的第一个位置将能够关注序列中的所有其他位置并从中提取信息。因此,而不是用像平均这样的粗糙方法来池化信息,注意力使我们能够在整个序列中上下文相关地池化信息。
现在,让我们向我们的骨干添加一个分类头。我们还会在生成输出预测之前添加一个具有非线性性的最终 Dense 投影。
inputs = backbone.input
x = backbone(inputs)
# Uses the hidden representation of the first token
x = x[:, 0, :]
x = layers.Dropout(0.1)(x)
x = layers.Dense(768, activation="relu")(x)
x = layers.Dropout(0.1)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
classifier = keras.Model(inputs, outputs)
列表 15.22:扩展基础 RoBERTa 模型以进行分类
到此,我们就可以在 IMDb 数据集上微调和评估模型了。
classifier.compile(
optimizer=keras.optimizers.Adam(5e-5),
loss="binary_crossentropy",
metrics=["accuracy"],
)
classifier.fit(
preprocessed_train_ds,
validation_data=preprocessed_val_ds,
)
列表 15.23:训练 RoBERTa 分类模型
最后,让我们评估训练好的模型:
>>> classifier.evaluate(preprocessed_test_ds)
[0.168127179145813, 0.9366399645805359]
在仅仅一个训练周期后,我们的模型达到了 93%,相较于我们上一章中达到的 90% 的上限,这是一个明显的提升。当然,这个模型比我们之前构建的简单二元分类器要昂贵得多,但使用这样一个大型模型确实有明显的优势。而且这还是在 RoBERTa 模型较小规模的情况下。使用更大规模的 3 亿参数模型,我们能够达到超过 95% 的准确率。
什么是让 Transformer 变得有效的因素?
2013 年,在谷歌,Tomas Mikolov 和他的同事们注意到了一个显著的现象。他们正在构建一个名为“Word2Vec”的预训练嵌入,这与我们在上一章中构建的连续词袋(CBOW)嵌入类似。与我们的 CBOW 模型一样,他们的训练目标旨在将单词之间的相关性关系转换为嵌入空间中的距离关系:每个词汇中的每个单词都与一个向量相关联,并且这些向量被优化,使得代表频繁共现单词的向量之间的点积(余弦距离)更接近 1,而代表很少共现单词的向量之间的点积更接近 0。
他们发现,生成的嵌入空间不仅能够捕捉语义相似性,还包含某种形式的自发学习——一种“词算术”。在这个空间中存在一个向量,你可以将其添加到许多男性名词中,以获得一个接近其女性对应词的点,例如 V(king) - V(man) + V(woman) = V(queen),这是一个性别向量。这相当令人惊讶;模型在没有任何明确训练的情况下做到了这一点。似乎有数十个这样的神奇向量——一个复数向量,一个从野生动物名称到其最接近的宠物对应词的向量等。
快进大约 10 年——我们现在处于大型预训练 Transformer 模型的时代。从表面上看,这些模型似乎与原始的 Word2Vec 模型相去甚远。Transformer 可以生成完全流畅的语言——这是 Word2Vec 完全无法做到的壮举。正如我们将在下一章中看到的,这些模型似乎对几乎所有主题都了如指掌。然而,它们实际上与古老的 Word2Vec 有很多共同之处。
这两个模型都试图在向量空间中嵌入标记(单词或子词)。它们都依赖于相同的基本原理来学习这个空间:一起出现的标记最终会在嵌入空间中靠近。在两种情况下,用于比较标记的距离函数都是余弦距离。甚至嵌入空间的维度也相似:一个具有 1,000 到 10,000 维的向量来表示每个单词。
在这一点上,你可能会打断我:Transformer 是训练用来预测序列中缺失的单词,而不是在嵌入空间中对标记进行分组。语言模型损失函数如何与 Word2Vec 的目标相关,即最大化共现标记之间的点积?答案是注意力机制。
注意力是 Transformer 架构中迄今为止最重要的组件。它是一种通过线性重新组合先前空间中的标记嵌入,以加权组合的形式学习新的标记嵌入空间,赋予已经“更接近”彼此的标记(即具有更高点积的标记)更高重要性的机制。它倾向于将已经接近的标记的向量拉在一起,随着时间的推移,在空间中,标记的相关关系转变为嵌入的邻近关系(从余弦距离的角度来看)。Transformer 通过学习一系列增量优化的嵌入空间来工作,每个嵌入空间都是基于重新组合前一个嵌入空间中的元素。
注意力为 Transformer 提供了两个关键属性:
-
他们学习的嵌入空间是语义连续的——也就是说,在嵌入空间中稍微移动只会略微改变对应标记的人类面向意义。Word2Vec 空间也表现出这一特性。
-
他们学习的嵌入空间是语义插值的——也就是说,在嵌入空间中取两个点的中间点会产生一个表示对应标记之间“中间意义”的点。这源于每个新的嵌入空间都是通过在先前空间中的向量之间进行插值来构建的。
这与大脑学习的方式并不完全不同。大脑中的关键学习原理是赫布学习——简而言之,“一起放电的神经元,一起连接。”神经放电事件(可能代表动作或感知输入)之间的相关性关系在脑网络中转变为邻近关系,就像 Transformer 和 Word2Vec 将相关性关系转变为向量邻近关系一样。两者都是信息空间的一幅图。
当然,Word2Vec 和 Transformer 之间存在显著差异。Word2Vec 并非为生成文本采样而设计。Transformer 可以更大,并且可以编码更复杂的转换。问题是,Word2Vec 非常像一个玩具模型:它对于今天的语言模型来说,就像 MNIST 像素上的逻辑回归对于最先进的计算机视觉模型一样。基本原理大多相同,但玩具模型缺乏任何有意义的表示能力。Word2Vec 甚至不是一个深度神经网络——它有一个浅层、单层架构。与此同时,今天的 Transformer 模型具有任何人所训练过的模型中最高的表示能力——它们具有数十个堆叠的注意力和前馈层,其参数数量在数十亿级别。
就像 Word2Vec 一样,Transformer 通过将标记组织到向量空间中作为副产品学习有用的语义函数。但得益于这种增强的表示能力和一个更加精细的自回归优化目标,我们不再局限于像性别向量或复数向量这样的线性变换。Transformers 可以存储任意复杂的向量函数——实际上,它们如此复杂,以至于更准确地称其为向量程序而不是函数。
Word2Vec 使你可以做基本的事情,比如 plural(cat) → cats 或 male_to_female(king) → queen。与此同时,一个大型 Transformer 模型可以做到纯粹的魔法——比如 write_this_in_style_of_shakespeare("...your poem...") → "...new poem..."。而且一个模型可以包含数百万这样的程序。
你可以将 Transformer 视为一个数据库的类似物:它存储的信息可以通过你传递的标记检索。但 Transformer 和数据库之间有两个重要的区别。
第一个区别在于,Transformer 是一种连续的、插值型的数据库。与存储为一系列离散条目不同,你的数据以向量空间的形式存储——一条曲线。你可以在曲线上移动(正如我们讨论的,它在语义上是连续的)来探索附近的、相关的点。你还可以在曲线上对不同的数据点进行插值,以找到它们之间的中间值。这意味着你可以从数据库中检索出比输入更多的信息——尽管其中并非所有信息都是准确或有意义的。插值可能导致泛化,但也可能导致幻觉——这是今天训练的生成语言模型面临的一个重大问题。
第二个区别是,Transformer 不仅包含数据。对于像 RoBERTa 这样的模型,它是在从互联网上抓取的数十万份文档上训练的,因此有大量的数据:事实、地点、人物、日期、事物和关系。但它还——也许主要是——一个程序数据库。
请注意,它们与您习惯处理的程序不同。这些不是像 Python 程序那样的符号语句序列,逐步处理数据。相反,这些向量程序是高度非线性的函数,将潜在嵌入空间映射到自身,类似于 Word2Vec 的魔法向量,但更加复杂。
在下一章中,我们将把 Transformer 模型推向一个全新的规模。这些模型将使用数十亿个参数,并在万亿个单词上进行训练。这些模型的输出常常让人感觉像是魔法——就像一个智能操作员坐在我们的模型内部,操纵着一切。但重要的是要记住,这些模型在本质上都是插值的——多亏了注意力机制,它们学习了一个插值嵌入空间,这个空间涵盖了英语语言中大量文本的嵌入。在这个嵌入空间中漫步可能会产生有趣、意外的泛化,但它无法用任何接近真正、人类水平智能的方式合成全新的东西。
摘要
-
一个 语言模型 是一个学习特定概率分布——
p(token|past tokens)的模型:-
语言模型有广泛的应用,但最重要的是,你可以通过循环调用它们来生成文本,其中某一时间步的输出标记成为下一时间步的输入标记。
-
一个 掩码语言模型 学习一个相关的概率分布
p(tokens|surrounding tokens),并且对于文本分类和单个标记的分类可能很有帮助。 -
一个 序列到序列语言模型 学习在给定目标序列中的过去标记和一个完全独立的、固定的源序列的情况下预测下一个标记。序列到序列模型对于翻译和问答等问题非常有用。
-
一个序列到序列模型通常有两个独立的组件。一个 编码器 计算源序列的表示,而一个 解码器 将这个表示作为输入,并根据过去的标记预测目标序列中的下一个标记。
-
-
注意力 是一种机制,允许模型根据当前正在处理的标记的上下文,从序列的任何位置有选择地提取信息:
-
注意力避免了 RNN 在文本中的长距离依赖问题。
-
注意力通过计算两个向量的点积来计算一个注意力分数。在嵌入空间中彼此靠近的向量将在注意力机制中被相加。
-
-
Transformer 是一种序列建模架构,它使用注意力作为唯一机制在序列中传递信息:
-
Transformer 通过堆叠交替的注意力块和两层前馈网络来工作。
-
Transformer 可以扩展到许多参数和大量训练数据,同时在语言建模问题上提高准确性。
-
与 RNN 不同,Transformer 在训练时没有序列长度循环,这使得模型在多台机器上并行训练变得更加容易。
-
Transformer 编码器使用双向注意力来构建序列的丰富表示。
-
Transformer 解码器在语言模型设置中使用因果注意力来预测下一个单词。
-
脚注
-
瓦斯瓦尼等人,“Attention Is All You Need”,第 31 届国际神经网络信息处理系统会议论文集(2017),
arxiv.org/abs/1706.03762. [↩] -
德夫林等人,“BERT:用于语言理解的深度双向变换器预训练”,北美计算语言学协会第 2019 年会议:人机语言技术,第 1 卷(2019),
arxiv.org/abs/1810.04805. [↩] -
刘等人,“RoBERTa:一种鲁棒优化的 BERT 预训练方法”(2019),
arxiv.org/abs/1907.11692. [↩]
第十六章:文本生成
原文:
deeplearningwithpython.io/chapters/chapter16_text-generation
当我首次声称在不那么遥远的未来,我们消费的大部分文化内容都将得到人工智能的大量帮助时,我遭到了彻底的怀疑,甚至来自长期从事机器学习实践的人。那是在 2014 年。快进十年,这种怀疑以惊人的速度消退。生成式人工智能工具现在已成为文字处理器、图像编辑器和开发环境中的常见补充。文学和艺术作品因生成模型而获得声望——引起了相当大的争议和辩论。^([1]) 考虑到人工智能与艺术创作经常交织在一起的世界,已经不再像科幻小说了。
在任何实际意义上,人工智能都远远无法与人类编剧、画家或作曲家相媲美。但取代人类并不需要,也不应该是目标。在许多领域,尤其是在创意领域,人们将使用人工智能来增强他们的能力——更多的是增强智能而非人工智能。
艺术创作的大部分内容都包括模式识别和技术技能。我们的感知模式、语言和艺术品都具有统计结构,而深度学习模型擅长学习这种结构。机器学习模型可以学习图像、音乐和故事的统计潜在空间,然后可以从这些空间中进行采样,创建具有与模型在训练数据中看到的特点相似的新艺术品。这种采样本身几乎不是艺术创作的行为——它仅仅是一个数学运算。只有我们作为人类观众的解释,才能赋予模型生成的内容意义。但一个技艺高超的艺术家可以将算法生成引导到有意义的——并且美丽的一面。潜在空间采样可以成为赋予艺术家力量的画笔,增强我们的创造能力,并扩展我们能够想象的空间。它甚至可以通过消除对技术技能和实践的需求,使艺术创作更加容易接近——建立一个纯粹表达的新媒介,将艺术与工艺分开。

图 16.1:使用生成图像软件 Midjourney 生成的图像。提示词是“一个手绘的科幻景观,居民居住在一个像红色字母 K 形状的建筑中。”
伊安尼斯·泽纳基斯,电子和算法音乐的先驱性开拓者,在 20 世纪 60 年代,在将自动化技术应用于音乐创作的背景下,巧妙地表达了同样的观点:^([2])
脱离了繁琐的计算,作曲家能够专注于新音乐形式提出的一般问题,并在修改输入数据值的同时探索这一形式的各种角落和缝隙。例如,他可能会测试从独奏者到室内乐团再到大型乐团的所有乐器组合。借助电子计算机,作曲家变成了一种飞行员:他按按钮,输入坐标,并监督在声音空间中航行的宇宙飞船的控制,穿越他以前只能作为遥远梦想一瞥的声学星座和星系。
生成式 AI 的潜力远远超出了艺术创作的范畴。在许多职业中,人们创造的内容中模式识别更为明显:想想总结大量文档、转录语音、校对错别字或标记代码中的常见错误。这些例行公事的任务直接针对深度学习方法的优点。关于我们在工作场所如何选择部署 AI 有很多要考虑的问题——这具有真实的社会影响。
在接下来的两章中,我们将探讨深度学习在创作方面的潜力。我们将学习在文本和图像领域管理潜在空间,并从这些空间中提取新内容。我们将从文本开始,扩展我们在上一章中首次使用的语言模型的概念。这些大型语言模型,或简称LLMs,是像 ChatGPT 这样的数字助手以及快速增长的现实世界应用背后的技术。
序列生成的简史
直到最近,从模型生成序列的想法在机器学习领域还是一个边缘的子主题——生成式循环网络直到 2016 年才开始进入主流。然而,这些技术有着相当长的历史,始于 1997 年 LSTM 算法的开发。
2002 年,Douglas Eck 首次将 LSTM 应用于音乐生成,并取得了有希望的结果。Eck 成为了谷歌大脑的研究员,2016 年,他成立了一个名为 Magenta 的新研究小组,专注于将现代深度学习技术应用于创作引人入胜的音乐。有时,好主意需要 15 年的时间才能开始实施。
在 2000 年代末和 2010 年代初,Alex Graves 开创了使用循环网络生成新型序列数据的方法。特别是,有些人认为他在 2013 年将循环混合密度网络应用于使用笔位时间序列生成类似人类手写的作品的工作是一个转折点。Graves 在 2013 年上传到预印本服务器 arXiv 的一个 LaTeX 文件中留下了一条注释掉的评论:“从模型生成序列是计算机最接近做梦的时候。”当我开始开发 Keras 时,这项工作和机器做梦的概念是重要的灵感来源。
在上一章中我们讨论的“Attention Is All You Need”论文发布后的一年,即 2018 年,一个名为 OpenAI 的组织的研究人员发布了一篇新论文“通过生成预训练改进语言理解”。^([3]) 他们结合了几个要素:
-
语言模型的非监督预训练——本质上是在序列中训练模型“猜测下一个标记”,就像我们在第十五章中的莎士比亚生成器所做的那样
-
Transformer 架构
-
通过成千上万的自出版书籍中的各种主题文本数据
作者展示了这样的预训练模型可以通过微调在广泛的文本分类任务上实现最先进的性能——从衡量两个句子的相似度到回答多项选择题。他们将预训练模型称为 GPT,即生成预训练 Transformer 的缩写。
GPT 并没有带来任何建模或训练上的进步。有趣的是,结果是这样的通用训练设置可以在多个任务上击败更复杂的技巧。没有复杂的文本归一化,不需要针对每个基准定制模型架构或训练数据,只需要大量的预训练数据和计算。
在接下来的几年里,OpenAI 以单一的目标集中精力扩展这一想法。模型架构仅略有变化。在四年的时间里,OpenAI 发布了三个版本的 GPT,扩展情况如下:
-
2018 年发布的 GPT-1 拥有 1.17 亿个参数,并在 10 亿个标记上进行了训练。
-
2019 年发布的 GPT-2 拥有 15 亿个参数,并在超过 100 亿个标记上进行了训练。
-
2020 年发布的 GPT-3 拥有 1750 亿个参数,并在大约半万亿个标记上进行了训练。
语言模型设置使得每个模型都能够生成文本,OpenAI 的开发者注意到,随着规模的每次跃升,这种生成输出的质量都会显著提高。
在 GPT-1 中,模型的生成能力主要是其预训练的副产品,而不是主要焦点。他们通过添加一个额外的密集层进行分类微调来评估模型,就像我们在上一章中对待 RoBERTa 一样。
在 GPT-2 中,作者注意到,你可以用几个任务的示例来提示模型,并生成高质量的输出,而无需任何微调。例如,你可以用以下内容提示模型以获得“奶酪”的法语翻译:
Translate English to French:
sea otter => loutre de mer
peppermint => menthe poivrée
plush giraffe => peluche girafe
cheese =>
这种设置被称为 少量样本学习,即你试图用少量监督示例来教授模型一个新问题——对于标准梯度下降来说太少了。
在 GPT-3 中,示例并不总是必要的。你可以用对问题、输入的简单文本描述来提示模型,并经常得到高质量的结果:
Translate English to French:
cheese =>
GPT-3 仍然受到一些尚未解决的问题的基本问题的困扰。LLMs“幻想”很常见——它们的输出可以从准确到完全错误,没有任何指示。它们对提示语句非常敏感,看似微小的提示重写可能会触发性能的大幅上升或下降。而且它们无法适应训练数据中没有广泛涉及的问题。
然而,GPT-3 的生成输出已经足够好,以至于该模型成为了 ChatGPT 的基础——第一个广泛使用的面向消费者的生成模型。在过去的几个月和几年里,ChatGPT 引发了大量投资和对构建 LLMs 以及寻找它们的新用例的兴趣。在下一节中,我们将构建一个自己的迷你 GPT 模型,以更好地理解这样的模型是如何工作的,它能做什么,以及它的局限性在哪里。
训练一个迷你 GPT
要开始预训练我们的迷你 GPT,我们需要大量的文本数据。GPT-1 使用了名为 BooksCorpus 的数据集,其中包含了一些未经作者明确许可就添加到数据集中的免费、自出版书籍。该数据集随后被其出版商撤下。
我们将使用一个更近期的预训练数据集,称为“Colossal Clean Crawled Corpus”(C4),由谷歌在 2020 年发布。它有 750GB,比我们为书籍示例合理训练的要大得多,所以我们将使用整个语料库的不到 1%。
让我们先下载并提取我们的数据:
import keras
import pathlib
extract_dir = keras.utils.get_file(
fname="mini-c4",
origin=(
"https://hf.co/datasets/mattdangerw/mini-c4/resolve/main/mini-c4.zip"
),
extract=True,
)
extract_dir = pathlib.Path(extract_dir) / "mini-c4"
列表 16.1:下载 C4 数据集的一部分
我们有 50 个文本数据分片,每个分片大约有 75MB 的原始文本。每一行都包含一个爬取的文档,其中换行符被转义。让我们看看我们第一个分片中的一个文档:
>>> with open(extract_dir / "shard0.txt", "r") as f:
>>> print(f.readline().replace("\\n", "\n")[:100])
Beginners BBQ Class Taking Place in Missoula!
Do you want to get better at making delicious BBQ? You
即使是像我们正在训练的这样的迷你 LLM,我们也需要预处理大量数据来运行预训练。使用快速分词程序将源文档预处理为整数标记可以简化我们的生活。
我们将使用 SentencePiece,这是一个用于文本数据子词分词的库。实际的分词技术与我们自己在第十四章中构建的字节对编码分词技术相同,但这个库是用 C++编写的,以提高速度,并添加了一个detokenize()函数,该函数可以将整数反转成字符串并将它们连接起来。我们将使用一个包含 32,000 个词汇项的预制作词汇表,这些词汇项存储在 SentencePiece 库需要的特定格式中。
正如上一章所述,我们可以使用 KerasHub 库来访问一些用于处理大型语言模型的额外函数。KerasHub 将 SentencePiece 库包装成一个 Keras 层。让我们试试看。
import keras_hub
import numpy as np
vocabulary_file = keras.utils.get_file(
origin="https://hf.co/mattdangerw/spiece/resolve/main/vocabulary.proto",
)
tokenizer = keras_hub.tokenizers.SentencePieceTokenizer(vocabulary_file)
列表 16.2:下载 SentencePiece 词汇表并实例化分词器
我们可以使用这个分词器双向地将文本映射到整数序列:
>>> tokenizer.tokenize("The quick brown fox.")
array([ 450, 4996, 17354, 1701, 29916, 29889], dtype=int32)
>>> tokenizer.detokenize([450, 4996, 17354, 1701, 29916, 29889])
"The quick brown fox."
让我们使用这个层来分词我们的输入文本,然后使用tf.data将输入窗口化成长度为 256 的序列。
在训练 GPT 时,开发者选择保持简单,并试图避免在样本中间出现文档边界。相反,他们使用特殊的<|endoftext|>标记来标记文档边界。我们在这里也将这样做。再次使用tf.data作为输入数据管道,并使用任何后端进行训练。
我们将单独加载每个文件分片,并将输出数据交错到一个单独的数据集中。这保持了我们的数据加载速度快,我们不需要担心文本在样本边界处对齐——每个都是独立的。通过交错,我们 CPU 上的每个处理器可以同时读取和标记一个单独的文件。
import tensorflow as tf
batch_size = 64
sequence_length = 256
suffix = np.array([tokenizer.token_to_id("<|endoftext|>")])
def read_file(filename):
ds = tf.data.TextLineDataset(filename)
# Restores newlines
ds = ds.map(lambda x: tf.strings.regex_replace(x, r"\\n", "\n"))
# Tokenizes data
ds = ds.map(tokenizer, num_parallel_calls=8)
# Adds the <|endoftext|> token
return ds.map(lambda x: tf.concat([x, suffix], -1))
files = [str(file) for file in extract_dir.glob("*.txt")]
ds = tf.data.Dataset.from_tensor_slices(files)
# Combines our file shards into a single dataset
ds = ds.interleave(read_file, cycle_length=32, num_parallel_calls=32)
# Windows tokens into even samples of 256 tokens
ds = ds.rebatch(sequence_length + 1, drop_remainder=True)
# Splits labels, offset by one
ds = ds.map(lambda x: (x[:-1], x[1:]))
ds = ds.batch(batch_size).prefetch(8)
列表 16.3:为 Transformer 预训练预处理文本输入
正如我们在第八章中首次做的那样,我们将以对prefetch()的调用结束我们的tf.data管道。这将确保我们始终有一些批次的加载到我们的 GPU 上,并准备好用于模型。
我们有 58,746 个批次。如果您愿意,可以自己数一下——行ds.reduce(0, lambda c, _: c + 1)将遍历整个数据集并增加计数器。但是,仅对这么大的数据集进行标记化就需要在性能良好的 CPU 上几分钟。
在每个批次 64 个样本和每个样本 256 个标记的情况下,这接近十亿个标记的数据。让我们分出 500 个批次作为快速验证集,然后我们就可以开始预训练了:
num_batches = 58746
num_val_batches = 500
num_train_batches = num_batches - num_val_batches
val_ds = ds.take(num_val_batches).repeat()
train_ds = ds.skip(num_val_batches).repeat()
构建模型
原始的 GPT 模型简化了我们上章看到的序列到序列 Transformer。与我们的翻译模型不同,它不是通过编码器和解码器接收源和目标序列,而是完全去掉了编码器,只使用解码器。这意味着信息只能在一个序列中从左到右传递。
这是对 GPT 开发者的一次有趣的赌注。仅解码器模型仍然可以处理像问答这样的序列到序列问题。然而,我们不是将问题和答案作为单独的输入提供,而是将两者合并成一个序列来提供给我们的模型。因此,与原始的 Transformer 不同,问题标记将不会像答案标记那样被特殊处理。所有标记都嵌入到相同的潜在空间中,使用相同的参数集。
这种方法的另一个后果是,即使对于输入序列,信息流也不再是双向的。给定一个输入,例如“法国的首都是哪里?”,单词“Where”在注意力层中无法关注到“capital”和“France”。这限制了模型的表达能力,但在预训练的简单性方面具有巨大的优势。我们不需要整理包含输入和输出对的集合;一切都可以是一个单独的序列。我们可以在互联网上找到的任何文本上进行大规模训练。
让我们从第十五章复制TransformerDecoder,但移除交叉注意力层,这使得解码器能够关注编码器序列。我们还将进行一个小的修改,在注意力和前馈块之后添加 dropout。在第十五章中,我们在编码器和解码器中只使用了一个 Transformer 层,因此我们可以在整个模型的末尾只使用一个 dropout 层。对于我们的 GPT 模型,我们将堆叠相当多的层,因此在每个解码器层中添加 dropout 对于防止过拟合非常重要。
from keras import layers
class TransformerDecoder(keras.Layer):
def __init__(self, hidden_dim, intermediate_dim, num_heads):
super().__init__()
key_dim = hidden_dim // num_heads
# Self-attention layers
self.self_attention = layers.MultiHeadAttention(
num_heads, key_dim, dropout=0.1
)
self.self_attention_layernorm = layers.LayerNormalization()
# Feedforward layers
self.feed_forward_1 = layers.Dense(intermediate_dim, activation="relu")
self.feed_forward_2 = layers.Dense(hidden_dim)
self.feed_forward_layernorm = layers.LayerNormalization()
self.dropout = layers.Dropout(0.1)
def call(self, inputs):
# Self-attention computation
residual = x = inputs
x = self.self_attention(query=x, key=x, value=x, use_causal_mask=True)
x = self.dropout(x)
x = x + residual
x = self.self_attention_layernorm(x)
# Feedforward computation
residual = x
x = self.feed_forward_1(x)
x = self.feed_forward_2(x)
x = self.dropout(x)
x = x + residual
x = self.feed_forward_layernorm(x)
return x
列表 16.4:没有交叉注意力的 Transformer 解码器块
接下来,我们可以从第十五章复制PositionalEmbedding层。回想一下,这个层为我们提供了一种简单的方法来学习序列中每个位置嵌入,并将其与我们的标记嵌入相结合。
这里有一个巧妙的方法可以用来节省一些 GPU 内存。在 Transformer 模型中,最大的权重是输入标记嵌入和输出密集预测层,因为它们处理我们的词汇空间。标记嵌入权重具有形状(vocab_size, hidden_dim),用于嵌入每个可能的标记。我们的输出投影具有形状(hidden_dim, vocab_size),为每个可能的标记做出浮点预测。
我们实际上可以将这两个权重矩阵绑定在一起。为了计算我们模型的最终预测,我们将隐藏状态乘以标记嵌入矩阵的转置。你可以非常地将最终的投影视为一个“反向嵌入”。它将隐藏空间映射到标记空间,而嵌入则是从标记空间映射到隐藏空间。结果发现,使用相同的权重进行输入和输出投影是一个好主意。
将这个添加到我们的PositionalEmbedding中很简单;我们只需在call方法中添加一个reverse参数,该方法通过标记嵌入矩阵的转置来计算投影。
from keras import ops
class PositionalEmbedding(keras.Layer):
def __init__(self, sequence_length, input_dim, output_dim):
super().__init__()
self.token_embeddings = layers.Embedding(input_dim, output_dim)
self.position_embeddings = layers.Embedding(sequence_length, output_dim)
def call(self, inputs, reverse=False):
if reverse:
token_embeddings = self.token_embeddings.embeddings
return ops.matmul(inputs, ops.transpose(token_embeddings))
positions = ops.cumsum(ops.ones_like(inputs), axis=-1) - 1
embedded_tokens = self.token_embeddings(inputs)
embedded_positions = self.position_embeddings(positions)
return embedded_tokens + embedded_positions
列表 16.5:一个可以反转文本嵌入的位置嵌入层
让我们构建我们的模型。我们将把八个解码器层堆叠成一个单一的“迷你”GPT 模型。
我们还将开启一个名为mixed precision的 Keras 设置来加速训练。这将允许 Keras 通过牺牲一些数值精度来运行模型的一些计算更快。目前,这可能会有些神秘,但完整的解释将在第十八章中提供。
# Enables mixed precision (see chapter 18)
keras.config.set_dtype_policy("mixed_float16")
vocab_size = tokenizer.vocabulary_size()
hidden_dim = 512
intermediate_dim = 2056
num_heads = 8
num_layers = 8
inputs = keras.Input(shape=(None,), dtype="int32", name="inputs")
embedding = PositionalEmbedding(sequence_length, vocab_size, hidden_dim)
x = embedding(inputs)
x = layers.LayerNormalization()(x)
for i in range(num_layers):
x = TransformerDecoder(hidden_dim, intermediate_dim, num_heads)(x)
outputs = embedding(x, reverse=True)
mini_gpt = keras.Model(inputs, outputs)
列表 16.6:创建一个迷你 GPT 功能模型
这个模型有 4100 万个参数,对于本书中的模型来说算是大的,但与今天大多数 LLM(大型语言模型)相比,它们的参数量从几十亿到万亿不等,就显得相当小了。
预训练模型
训练一个大型 Transformer 模型众所周知是相当挑剔的——模型对参数的初始化和优化器的选择很敏感。当堆叠了许多 Transformer 层时,很容易出现梯度爆炸的问题,即参数更新得太快,我们的损失函数无法收敛。一个有效的方法是在多个预热步骤中线性地逐渐增加到完整的学习率,这样我们模型参数的初始更新就很小。在 Keras 中使用LearningRateSchedule来实现这一点很容易。
class WarmupSchedule(keras.optimizers.schedules.LearningRateSchedule):
def __init__(self):
# Peak learning rate
self.rate = 2e-4
self.warmup_steps = 1_000.0
def __call__(self, step):
step = ops.cast(step, dtype="float32")
scale = ops.minimum(step / self.warmup_steps, 1.0)
return self.rate * scale
列表 16.7:定义自定义学习率计划
我们可以将我们的学习率随时间的变化绘制出来,以确保它符合我们的预期(图 16.2):
import matplotlib.pyplot as plt
schedule = WarmupSchedule()
x = range(0, 5_000, 100)
y = [ops.convert_to_numpy(schedule(step)) for step in x]
plt.plot(x, y)
plt.xlabel("Train Step")
plt.ylabel("Learning Rate")
plt.show()

图 16.2:预热使我们在训练开始时对模型参数的更新更小,并有助于稳定性。
我们将使用一次遍历我们的 100 亿个标记来训练我们的模型,这些标记分布在八个时期中,这样我们就可以偶尔检查我们的验证集损失和准确率。
我们正在训练一个 GPT 的微型版本,比 GPT-1 少 3 倍参数,总体训练步骤少 100 倍。但尽管这比最小的 GPT 模型便宜两个数量级来训练,这个fit()调用将是整本书中最昂贵的训练运行。如果你在阅读代码时运行,请启动它并休息一下!
num_epochs = 8
# Set these to a lower value if you don't want to wait for training.
steps_per_epoch = num_train_batches // num_epochs
validation_steps = num_val_batches
mini_gpt.compile(
optimizer=keras.optimizers.Adam(schedule),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
mini_gpt.fit(
train_ds,
validation_data=val_ds,
epochs=num_epochs,
steps_per_epoch=steps_per_epoch,
validation_steps=validation_steps,
)
列表 16.8:训练迷你 GPT 模型
训练完成后,我们的模型在验证集上预测序列中的下一个标记大约有 36%的时间,尽管这样的指标只是一个粗略的经验法则。
注意,我们的模型训练不足。在每个时期之后,我们的验证损失将继续下降,这在使用比 GPT-1 少一百倍的训练步骤的情况下并不令人惊讶。更长时间的训练将是一个很好的主意,但我们需要时间和金钱来支付计算费用。
让我们玩一玩我们的迷你 GPT 模型。
生成式解码
为了从我们的模型中采样一些输出,我们可以遵循第十五章中生成莎士比亚或西班牙语翻译的方法。我们将一个固定标记的提示输入到模型中。对于输入序列中的每个位置,模型输出整个词汇表上下一个标记的概率分布。通过在最后一个位置选择最可能的下一个标记,将其添加到我们的序列中,然后重复此过程,我们能够逐个生成新的序列。
def generate(prompt, max_length=64):
tokens = list(ops.convert_to_numpy(tokenizer(prompt)))
prompt_length = len(tokens)
for _ in range(max_length - prompt_length):
prediction = mini_gpt(ops.convert_to_numpy([tokens]))
prediction = ops.convert_to_numpy(prediction[0, -1])
tokens.append(np.argmax(prediction).item())
return tokenizer.detokenize(tokens)
列表 16.9:迷你 GPT 模型的一个简单生成函数
让我们用一个文本提示来试一试:
>>> prompt = "A piece of advice"
>>> generate(prompt)
A piece of advice, and the best way to get a feel for yourself is to get a sense
of what you are doing.
If you are a business owner, you can get a sense of what you are doing. You can
get a sense of what you are doing, and you can get a sense of what
当你运行这个程序时,你首先会注意到它需要几分钟才能完成。这有点令人困惑。我们在训练期间预测,在参考硬件上每秒大约有 200,000 个标记。生成循环可能会增加时间,但一分钟延迟太慢了。发生了什么?我们速度慢的最大原因,至少在 Jax 和 TensorFlow 后端,是我们正在运行一个未编译的计算。
每次你运行fit()或predict(),Keras 都会编译在每批数据上运行的计算。所有使用的keras.ops都会从 Python 中提取出来,并由后端框架进行大量优化。对于单个批次来说很慢,但对于后续的每次调用来说则快得多。然而,当我们像之前那样直接调用模型时,后端框架需要在每个步骤上实时且未经优化的运行前向传递。
这里的简单解决方案是依赖predict()。使用predict(),Keras 会为我们处理编译,但有一个重要的陷阱需要注意。当 TensorFlow 或 Jax 编译一个函数时,它将针对特定的输入形状进行编译。有了已知的形状,后端可以针对特定硬件进行优化,确切地知道组成张量操作的各个处理器指令的数量。但在我们的生成函数中,我们在每次预测后调用模型时,序列的形状都会改变。这会在每次调用predict()时触发重新编译。
相反,如果我们通过填充输入使得我们的序列始终具有相同的长度,我们可以避免重新编译predict()函数。让我们试试看。
def compiled_generate(prompt, max_length=64):
tokens = list(ops.convert_to_numpy(tokenizer(prompt)))
prompt_length = len(tokens)
# Pads tokens to the full sequence length
tokens = tokens + [0] * (max_length - prompt_length)
for i in range(prompt_length, max_length):
prediction = mini_gpt.predict(np.array([tokens]), verbose=0)
prediction = prediction[0, i - 1]
tokens[i] = np.argmax(prediction).item()
return tokenizer.detokenize(tokens)
列表 16.10:mini-GPT 模型的编译生成函数
让我们看看这个新函数有多快:
>>> import timeit
>>> tries = 10
>>> timeit.timeit(lambda: compiled_generate(prompt), number=tries) / tries
0.4866470648999893
通过编译,我们的生成调用从几分钟缩短到不到一秒。这是一个相当大的改进。
抽样策略
我们生成输出中的另一个明显问题是我们的模型经常重复自己。在我们的特定训练运行中,模型反复重复“get a sense of what you are doing”这个单词组。
这与其说是一个错误,不如说是我们训练目标的直接后果。我们的模型试图预测在约十亿个单词和许多主题上的序列中最可能出现的下一个标记。如果没有明显的选择来决定文本序列接下来应该走向何方,一个有效的策略是猜测常见的单词或单词的重复模式。不出所料,我们的模型在训练过程中几乎立即学会了这样做。如果你在训练我们的模型非常早的时候停止,它可能会不断地生成单词"the",因为"the"是英语中最常见的单词。
在我们的生成循环中,我们总是选择模型输出中最可能预测的标记。但我们的输出不仅仅是一个预测的标记;它是在我们的词汇表中的 32,000 个标记上的概率分布。
在每个生成步骤使用最可能的输出被称为贪婪搜索。这是使用模型预测的最直接方法,但它绝不是唯一的方法。如果我们向过程中添加一些随机性,我们可以更广泛地探索模型学习到的概率分布。这可以防止我们陷入高概率标记序列的循环中。
让我们尝试一下。我们可以通过重构我们的生成函数,使其能够传递一个将模型的预测映射到下一个标记选择的函数。我们将称之为我们的采样策略:
def compiled_generate(prompt, sample_fn, max_length=64):
tokens = list(ops.convert_to_numpy(tokenizer(prompt)))
prompt_length = len(tokens)
tokens = tokens + [0] * (max_length - prompt_length)
for i in range(prompt_length, max_length):
prediction = mini_gpt.predict(np.array([tokens]), verbose=0)
prediction = prediction[0, i - 1]
next_token = ops.convert_to_numpy(sample_fn(prediction))
tokens[i] = np.array(next_token).item()
return tokenizer.detokenize(tokens)
现在我们可以将我们的贪婪搜索编写为一个简单的函数,我们将其传递给compiled_generate():
def greedy_search(preds):
return ops.argmax(preds)
compiled_generate(prompt, greedy_search)
Transformer 的输出定义了一个分类分布,其中每个标记在每个时间步都有一定的概率被输出。我们不仅可以选择最有可能的标记,还可以直接采样这个分布。keras.random.categorical()将我们的预测通过 softmax 函数,得到一个概率分布,然后随机采样。让我们试一下:
def random_sample(preds, temperature=1.0):
preds = preds / temperature
return keras.random.categorical(preds[None, :], num_samples=1)[0]
>>> compiled_generate(prompt, random_sample)
A piece of advice, just read my knees and stick with getables and a hello to me.
However, the bar napkin doesn't last as long. I happen to be waking up close and
pull it up as I wanted too and I still get it, really, shouldn't be a reaction
我们的输出更加多样化,模型也不再陷入循环。但我们的采样现在探索得太多;输出跳跃不定,没有任何连续性。
你会注意到我们添加了一个名为temperature的参数。我们可以使用这个参数来锐化或拓宽我们的概率分布,以便我们的采样探索分布的程度更少或更多。
如果我们传递一个低温度,我们将在 softmax 函数之前使所有 logits 更大,这使得最有可能的输出更加可能。如果我们传递一个高温度,我们的 logits 将在 softmax 之前更小,我们的概率分布将更加分散。让我们试几次,看看这对我们的采样有什么影响:
>>> from functools import partial
>>> compiled_generate(prompt, partial(random_sample, temperature=2.0))
A piece of advice tran writes using ignore unnecessary pivot - come without
introdu accounts indicugelâ per\u3000divuren sendSolisżsilen om transparent
Gill Guide pover integer song arrays coding\u3000LIST**…Allow index criteria
Draw Reference Ex artifactincluding lib tak Br basunker increases entirelytembre
AnyкаTextView cardinal spiritual heavenToen
>>> compiled_generate(prompt, partial(random_sample, temperature=0.8))
A piece of advice I wrote about the same thing today. I have been a writer for
two years now. I am writing this blog and I just wrote about it. I am writing
this blog and it was really interesting. I have been writing about the book and
I have read many things about my life.
The
>>> compiled_generate(prompt, partial(random_sample, temperature=0.2))
A piece of advice, and a lot of people are saying that they have to be careful
about the way they think about it.
I think it's a good idea to have a good understanding of the way you think about
it.
I think it's a good idea to have a good understanding of the
在高温下,我们的输出不再像英语,而是停留在看似随机的标记上。在低温下,我们的模型行为开始类似于贪婪搜索,反复重复某些文本模式。
另一种塑造我们分布的流行技术是将采样限制在最有可能的标记集合中。这被称为top-k 采样,其中 K 是你应该探索的候选数量。图 16.3 展示了 top-k 采样如何在贪婪和随机方法之间取得平衡。

图 16.3:在相同的概率分布上展示了贪婪、top-k 和随机采样策略
让我们在代码中尝试一下。我们可以使用keras.ops.top_k来找到数组的前 K 个元素:
def top_k(preds, k=5, temperature=1.0):
preds = preds / temperature
top_preds, top_indices = ops.top_k(preds, k=k, sorted=False)
choice = keras.random.categorical(top_preds[None, :], num_samples=1)[0]
return ops.take_along_axis(top_indices, choice, axis=-1)
我们可以尝试几种不同的 top-k 变体,看看它如何影响采样:
>>> compiled_generate(prompt, partial(top_k, k=5))
A piece of advice that I can't help it. I'm not going to be able to do anything
for a few months, but I'm trying to get a little better. It's a little too much.
I have a few other questions on this site, but I'm sure I
>>> compiled_generate(prompt, partial(top_k, k=20))
A piece of advice and guidance from the Audi Bank in 2015\. With all the above,
it's not just a bad idea, but it's very good to see that is going to be a great
year for you in 2017.
That's really going to
传递一个 top-k 截止值与温度采样不同。传递一个低温度会使可能的标记更有可能,但它不会排除任何标记。top-k 采样将 K 候选之外的所有标记的概率置零。你可以将两者结合起来,例如,用 0.5 的温度采样前五个候选:
>>> compiled_generate(prompt, partial(top_k, k=5, temperature=0.5))
A piece of advice that you can use to get rid of the problem.
The first thing you need to do is to get the job done. It is important that you
have a plan that will help you get rid of it.
The first thing you need to do is to get rid of the problem yourself.
样本策略是生成文本时的重要控制手段,有更多的方法。例如,beam search 是一种通过在每个时间步长保持固定数量的“光束”(不同的预测标记链)来启发式地探索多个预测标记链的技术。
使用 top-k 样本采样,我们的模型生成的文本更接近合理的英语文本,但这种输出的实际效用很小。这与 GPT-1 的结果相符。对于最初的 GPT 论文,生成的输出更多的是一种好奇心,而最先进的结果是通过微调分类模型实现的。我们的 mini-GPT 的训练程度远低于 GPT-1。
要达到今天生成式大型语言模型(LLM)的规模,我们需要将参数数量至少增加 100 倍,训练步数至少增加 1,000 倍。如果我们这样做,我们将看到与 OpenAI 在 GPT 中观察到的相同的质量飞跃。而且我们能够做到!我们之前使用的训练方案是今天所有训练 LLM 的人使用的确切蓝图。唯一缺少的部分是巨大的计算预算和跨多台机器训练的一些技巧,这些内容将在第十八章中介绍。
为了更实用的方法,我们将过渡到使用预训练模型。这将使我们能够探索今天规模下 LLM 的行为。
使用预训练的 LLM
现在我们已经从头开始训练了一个 mini 语言模型,让我们尝试使用一个十亿参数的预训练模型,看看它能做什么。鉴于预训练 Transformer 的成本过高,大多数行业都集中在使用由相对少数公司开发的预训练模型上。这不仅仅是一个成本问题,也是一个环境问题——生成模型训练现在占了大科技公司数据中心总电力消耗的很大一部分。
Meta 在 2023 年发布了 Llama 2 的环境数据,这是它发布的一个 LLM。它比 GPT-3 小得多,但训练它需要估计 130 万千瓦时的电力——相当于大约 45,000 美国家庭的日用电量。如果每个使用 LLM 的组织都自行进行预训练,那么能源使用的规模将占全球能源消耗的一个明显的百分比。
让我们用 Google 提供的一个预训练生成模型 Gemma 来玩玩。我们将使用 Gemma 的第三个版本,该版本于 2025 年公开发布。为了使本书中的示例易于理解,我们将使用可用的 Gemma 最小变体,其参数数量几乎正好是 10 亿。这个“小型”模型在约 2 万亿个预训练数据标记上进行了训练——比我们刚刚训练的 mini-GPT 多 2,000 倍!
使用 Gemma 模型进行文本生成
要加载这个预训练模型,我们可以使用 KerasHub,就像我们在前面的章节中做的那样。
gemma_lm = keras_hub.models.CausalLM.from_preset(
"gemma3_1b",
dtype="float32",
)
列表 16.11:使用 KerasHub 实例化预训练的 LLM
CausalLM是高级任务 API 的另一个例子,就像我们在书中早期使用的ImageClassifier和ImageSegmenter任务一样。CausalLM任务将结合一个分词器和正确初始化的架构到一个单一的 Keras 模型中。KerasHub 将 Gemma 权重加载到正确初始化的架构中,并为预训练权重加载一个匹配的分词器。
让我们来看看 Gemma 模型的摘要:
>>> gemma_lm.summary()
Preprocessor: "gemma3_causal_lm_preprocessor"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Config ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ gemma3_tokenizer (Gemma3Tokenizer) │ Vocab size: 262,144 │
└──────────────────────────────────────────────┴───────────────────────────────┘
Model: "gemma3_causal_lm"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ padding_mask │ (None, None) │ 0 │ - │
│ (InputLayer) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ token_ids │ (None, None) │ 0 │ - │
│ (InputLayer) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ gemma3_backbone │ (None, None, │ 999,885,952 │ padding_mask[0][0… │
│ (Gemma3Backbone) │ 1152) │ │ token_ids[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ token_embedding │ (None, None, │ 301,989,888 │ gemma3_backbone[0… │
│ (ReversibleEmbedding) │ 262144) │ │ │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
Total params: 999,885,952 (3.72 GB)
Trainable params: 999,885,952 (3.72 GB)
Non-trainable params: 0 (0.00 B)
我们不必亲自实现生成流程,可以通过使用CausalLM类中提供的generate()函数来简化我们的生活。这个generate()函数可以与不同的采样策略一起编译,正如我们在上一节中探讨的那样:
>>> gemma_lm.compile(sampler="greedy")
>>> gemma_lm.generate("A piece of advice", max_length=40)
A piece of advice from a former student of mine:
<blockquote>"I'm not sure if you've heard of it, but I've been told that the
best way to learn
>>> gemma_lm.generate("How can I make brownies?", max_length=40)
How can I make brownies?
[User 0001]
I'm trying to make brownies for my son's birthday party. I've never made
brownies before.
我们可以立即注意到几点。首先,输出比我们的迷你 GPT 模型要连贯得多。很难将这段文本与 C4 数据集中的大量训练数据区分开来。其次,输出仍然不太有用。模型将生成模糊合理的文本,但你可以用它做什么并不清楚。
正如我们在迷你 GPT 示例中看到的那样,这与其说是一个错误,不如说是我们预训练目标的结果。Gemma 模型是用与我们为迷你 GPT 使用的相同“猜测下一个单词”的目标进行训练的,这意味着它实际上是一个互联网上的高级自动完成功能。它将不断地连续发出最可能的单词,就像你的提示是网络上随机文档中的一段文本一样。
改变我们输出的一个方法是通过提供一个更长的输入来提示模型,使其明显表明我们正在寻找哪种类型的输出。例如,如果我们用布朗尼食谱的前两句话提示 Gemma 模型,我们会得到更有帮助的输出:
>>> gemma_lm.generate(
>>> "The following brownie recipe is easy to make in just a few "
>>> "steps.\n\nYou can start by",
>>> max_length=40,
>>> )
The following brownie recipe is easy to make in just a few steps.
You can start by melting the butter and sugar in a saucepan over medium heat.
Then add the eggs and vanilla extract
当与能够“交谈”的模型一起工作时,想象它以某种人类对话的方式解释我们的提示是很诱人的,但这里并没有发生这样的事情。我们只是构建了一个提示,其中实际布朗尼食谱的延续性比模仿在论坛上寻求烘焙帮助的人的帖子更有可能。
在构建提示方面,你可以走得更远。你可能会用一些自然语言指令提示模型它应该扮演的角色,例如,“你是一个大型语言模型,为人们提供简短、有用的答案。”或者你可能给模型提供一个包含长列表的有害主题的提示,这些主题不应包含在任何生成的响应中。
如果这一切听起来有些模糊不清且难以控制,这是一个很好的评估。尝试通过提示来访问模型分布的不同部分通常是有用的,但预测模型对给定提示的反应是非常困难的。
LLMs 面临的一个另一个广泛记录的问题是幻觉。模型总是会说出一些话——对于给定的序列,总有一个最可能的下一个标记。在我们的 LLM 分布中找到没有实际事实依据的位置是很容易的:
>>> gemma_lm.generate(
>>> "Tell me about the 542nd president of the United States.",
>>> max_length=40,
>>> )
Tell me about the 542nd president of the United States.
The 542nd president of the United States was James A. Garfield.
当然,这完全是胡说八道,但模型找不到更可能的方式来完成这个提示。
幻觉和不可控的输出是语言模型的基本问题。如果有一个银弹,我们还没有找到。然而,一种非常有帮助的方法是使用你希望生成的特定类型的生成输出示例进一步微调模型。
在想要构建一个能够遵循指令的聊天机器人的具体情况下,这种训练被称为指令微调。让我们尝试使用 Gemma 进行一些指令微调,使其作为一个对话伙伴变得更加有用。
指令微调
指令微调涉及向模型提供输入/输出对——一个用户指令后面跟着一个模型响应。我们将这些组合成一个单一的序列,成为模型的新训练数据。为了在训练过程中清楚地标记指令或响应的结束,我们可以直接将特殊标记如"[instruction]"和"[response]"添加到组合序列中。只要保持一致,精确的标记并不重要。
我们可以将组合序列作为常规训练数据使用,使用与我们在预训练 LLM 时使用的相同的“猜测下一个单词”损失。通过使用包含所需响应的示例进行进一步训练,我们实际上是在我们想要的方向上弯曲模型的输出。我们在这里不会学习语言的一个潜在空间;这已经在数十亿个预训练的标记上完成了。我们只是在稍微推动学习到的表示,以控制输出的语气和内容。
首先,我们需要一个指令-响应对的数据集。训练聊天机器人是一个热门话题,因此有许多专门为此目的制作的数据集。我们将使用由 Databricks 公司公开的数据集。员工们贡献了一个包含 15,000 条指令和手写响应的数据集。让我们下载它并将数据合并成一个单一的序列。
import json
PROMPT_TEMPLATE = """"[instruction]\n{}[end]\n[response]\n"""
RESPONSE_TEMPLATE = """{}[end]"""
dataset_path = keras.utils.get_file(
origin=(
"https://hf.co/datasets/databricks/databricks-dolly-15k/"
"resolve/main/databricks-dolly-15k.jsonl"
),
)
data = {"prompts": [], "responses": []}
with open(dataset_path) as file:
for line in file:
features = json.loads(line)
if features["context"]:
continue
data["prompts"].append(PROMPT_TEMPLATE.format(features["instruction"]))
data["responses"].append(RESPONSE_TEMPLATE.format(features["response"]))
列表 16.12:加载指令微调数据集
注意,一些示例有额外的上下文——与指令相关的文本信息。为了保持简单,我们现在将丢弃这些示例。
让我们看看我们数据集中单个元素:
>>> data["prompts"][0]
[instruction]
Which is a species of fish? Tope or Rope[end]
[response]
>>> data["responses"][0]
Tope[end]
我们的提示模板为我们提供的示例提供了一个可预测的结构。尽管 Gemma 不是一个像我们的英语到西班牙语翻译器那样的序列到序列模型,但我们可以通过在类似这样的提示上进行训练,并在"[response]"标记之后才生成输出,来在序列到序列设置中使用它。
让我们创建一个tf.data.Dataset并分割一些验证数据:
ds = tf.data.Dataset.from_tensor_slices(data).shuffle(2000).batch(2)
val_ds = ds.take(100)
train_ds = ds.skip(100)
我们从 KerasHub 库加载的CausalLM是一个用于端到端因果语言模型的高级对象。它封装了两个对象:一个preprocessor层,用于预处理文本输入,以及一个backbone模型,它包含模型前向传递的数值。
预处理默认包含在高级 Keras 函数如fit()和predict()中。但让我们在一个单独的批次上运行我们的预处理,这样我们可以更好地看到它在做什么:
>>> preprocessor = gemma_lm.preprocessor
>>> preprocessor.sequence_length = 512
>>> batch = next(iter(train_ds))
>>> x, y, sample_weight = preprocessor(batch)
>>> x["token_ids"].shape
(2, 512)
>>> x["padding_mask"].shape
(2, 512)
>>> y.shape
(2, 512)
>>> sample_weight.shape
(2, 512)
预处理层将所有输入填充到固定长度,并计算一个填充掩码来跟踪哪些标记 ID 输入只是填充的零。sample_weight张量使我们能够只为我们的响应标记计算损失值。我们并不关心用户提示的损失;它是固定的,我们肯定不希望计算我们刚刚添加的零填充的损失。
如果我们打印出我们的标记 ID 和标签的片段,我们可以看到这是一个常规语言模型设置,其中每个标签是下一个标记值:
>>> x["token_ids"][0, :5], y[0, :5]
(Array([ 2, 77074, 22768, 236842, 107], dtype=int32),
Array([ 77074, 22768, 236842, 107, 24249], dtype=int32))
低秩自适应(LoRA)
如果我们现在在一个具有 16 GB 设备内存的 Colab GPU 上运行fit(),我们很快就会触发内存不足错误。但我们已经加载了模型并运行了生成,那么为什么现在会内存不足呢?
我们的 10 亿参数模型大约占用 3.7 GB 的内存。您可以在我们之前的模型摘要中看到这一点。我们一直在使用的Adam优化器需要为每个参数跟踪三个额外的浮点数——实际的梯度、一个速度值和一个动量值。总的来说,仅权重和优化器状态就需要 15 GB。我们还需要几 GB 的内存来跟踪模型前向传递中的中间值,但我们已经没有多余的内存了。运行fit()会在第一次训练步骤时崩溃。这是训练 LLM 时常见的问题。因为这些模型具有大量的参数数量,所以 GPU 和 CPU 的吞吐量在加速器内存上拟合模型时是次要的。
我们在本书中之前已经看到,我们如何在微调过程中冻结模型的一部分。我们没有提到的是,这将节省大量的内存!我们不需要跟踪任何冻结参数的优化器变量——它们永远不会更新。这使我们能够在加速器上节省大量的空间。
研究人员已经广泛地实验了在微调期间冻结 Transformer 模型中的不同参数,结果发现,也许直观地,最重要的未冻结权重是在注意力机制中。但我们的注意力层仍然有数亿个参数。我们能否做得更好?
在 2021 年,微软的研究人员提出了一种名为 LoRA 的技术,即大型语言模型的低秩自适应,专门用来解决这个内存问题^([4])。为了解释它,让我们想象一个简单的线性投影层:
class Linear(keras.Layer):
def __init__(self, input_dim, output_dim):
super().__init__()
self.kernel = self.add_weight(shape=(input_dim, output_dim))
def call(self, inputs):
return ops.matmul(inputs, self.kernel)
LoRA 论文建议冻结kernel矩阵,并添加一个新的“低秩”核投影分解。这个分解有两个新的投影矩阵,alpha和beta,它们将投影到和从内部rank。
class LoraLinear(keras.Layer):
def __init__(self, input_dim, output_dim, rank):
super().__init__()
self.kernel = self.add_weight(
shape=(input_dim, output_dim), trainable=False
)
self.alpha = self.add_weight(shape=(input_dim, rank))
self.beta = self.add_weight(shape=(rank, output_dim))
def call(self, inputs):
frozen = ops.matmul(inputs, self.kernel)
update = ops.matmul(ops.matmul(inputs, self.alpha), self.beta)
return frozen + update
如果我们的kernel形状是 2048 × 2048,那么就是 4,194,304 个冻结参数。但如果我们将rank保持得较低,比如说 8,那么低秩分解将只有 32,768 个参数。这次更新将不具有原始核相同的表达能力;在狭窄的中间点,整个更新必须表示为八个浮点数。但在 LLM 微调期间,你不再需要像预训练期间那样的表达能力(图 16.4)。

图 16.4:低秩核分解包含的参数远少于核本身。
LoRA 的作者建议冻结整个 Transformer,并将 LoRA 权重仅添加到注意力层的查询和键投影中。让我们试试这个方法。KerasHub 模型内置了 LoRA 训练的方法。
gemma_lm.backbone.enable_lora(rank=8)
代码列表 16.13:为 KerasHub 模型启用 LoRA 训练
让我们再次查看我们的模型摘要:
>>> gemma_lm.summary()
Preprocessor: "gemma3_causal_lm_preprocessor"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Config ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ gemma3_tokenizer (Gemma3Tokenizer) │ Vocab size: 262,144 │
└──────────────────────────────────────────────┴───────────────────────────────┘
Model: "gemma3_causal_lm"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ padding_mask │ (None, None) │ 0 │ - │
│ (InputLayer) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ token_ids │ (None, None) │ 0 │ - │
│ (InputLayer) │ │ │ │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ gemma3_backbone │ (None, None, │ 1,001,190,… │ padding_mask[0][0… │
│ (Gemma3Backbone) │ 1152) │ │ token_ids[0][0] │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ token_embedding │ (None, None, │ 301,989,888 │ gemma3_backbone0… │
│ (ReversibleEmbedding) │ 262144) │ │ │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
Total params: 1,001,190,528 (3.73 GB)
Trainable params: 1,304,576 (4.98 MB)
Non-trainable params: 999,885,952 (3.72 GB)
虽然我们的模型参数仍然占用 3.7 GB 的空间,但我们的可训练参数现在只使用 5 MB 的数据——减少了千倍!这可以将我们的优化器状态从多个 GB 减少到 GPU 上的仅几 MB(图 16.5)。
![
图 16.5:LoRA 大大减少了我们需要的梯度优化器状态内存。
在这个优化到位后,我们终于准备好对 Gemma 模型进行指令微调了。让我们试试。
gemma_lm.compile(
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
optimizer=keras.optimizers.Adam(5e-5),
weighted_metrics=[keras.metrics.SparseCategoricalAccuracy()],
)
gemma_lm.fit(train_ds, validation_data=val_ds, epochs=1)
代码列表 16.14:微调预训练的 LLM
训练完成后,我们在猜测模型响应中的下一个单词时达到了 55%的准确率。这比我们迷你 GPT 模型的 35%准确率有巨大的提升。这显示了更大模型和更广泛预训练的威力。
我们的微调是否使我们的模型在遵循指令方面变得更好?让我们试试:
>>> gemma_lm.generate(
... "[instruction]\nHow can I make brownies?[end]\n"
... "[response]\n",
... max_length=512,
... )
[instruction]
How can I make brownies?[end]
[response]
You can make brownies by mixing together 1 cup of flour, 1 cup of sugar, 1/2
cup of butter, 1/2 cup of milk, 1/2 cup of chocolate chips, and 1/2 cup of
chocolate chips. Then, you can bake it in a 9x13 pan for 30 minutes at 350
degrees Fahrenheit. You can also add a little bit of vanilla extract to the
batter to make it taste better.[end]
>>> gemma_lm.generate(
... "[instruction]\nWhat is a proper noun?[end]\n"
... "[response]\n",
... max_length=512,
... )
[instruction]
What is a proper noun?[end]
[response]
A proper noun is a word that refers to a specific person, place, or thing.
Proper nouns are usually capitalized and are used to identify specific
individuals, places, or things. Proper nouns are often used in formal writing
and are often used in titles, such as "The White House" or "The Eiffel Tower."
Proper nouns are also used in titles of books, movies, and other works of
literature.[end]
好得多。我们的模型现在将回答问题,而不是试图简单地延续提示文本的思想。
我们是否解决了幻觉问题?
>>> gemma_lm.generate(
... "[instruction]\nWho is the 542nd president of the United States?[end]\n"
... "[response]\n",
... max_length=512,
... )
[instruction]
Who is the 542nd president of the United States?[end]
[response]
The 542nd president of the United States was James A. Garfield.[end]
完全没有。然而,我们仍然可以使用指令微调在这里取得一些进展。一种常见的技术是在大量指令/响应对上训练模型,其中期望的响应是“我不知道”或“作为一个语言模型,我无法帮助你”。这可以训练模型避免尝试回答它通常会给出低质量结果的特定主题。
在 LLM 上更进一步
我们现在从头开始训练了一个 GPT 模型,并将语言模型微调到了我们自己的聊天机器人中。然而,我们今天只是触及了 LLM 研究的表面。在本节中,我们将介绍一些对基本“自动完成互联网”语言建模设置的扩展和改进,这些改进并不全面。
带有人类反馈的强化学习(RLHF)
我们刚才所做的指令微调通常被称为监督式微调。它被称为监督式,因为我们通过手工整理,创建了一个我们希望从模型中获得示例提示和响应的列表。
任何需要手动编写文本示例的需求几乎总会成为瓶颈——此类数据获取缓慢且成本高昂。此外,这种方法将受到人类在指令跟随任务上的性能上限的限制。如果我们想在类似聊天机器人的体验中超越人类的表现,我们不能依赖于手动编写的输出来监督大型语言模型(LLM)的训练。
我们真正试图优化的真正问题是我们在某些响应与其他响应之间的偏好。对于足够大的人群样本,这个偏好问题是完美定义的,但弄清楚如何将“我们的偏好”转换为我们可以用来计算梯度的损失函数是非常棘手的。这正是带人类反馈的强化学习(Reinforcement Learning with Human Feedback,简称RLHF)试图解决的问题。
RLHF 微调的第一步正是我们在上一节中所做的——使用手写的提示和响应进行监督微调。这使我们达到了一个良好的基线性能;我们现在需要在这个基线之上进行改进。为此,我们将构建一个可以作为人类偏好的代理的奖励模型。我们可以收集大量提示及其响应。其中一些响应可以是手写的;模型可以写其他响应。这些响应甚至可以由其他聊天机器人 LLM 编写。然后我们需要让人类评估者根据偏好对这些响应进行排序。给定一个提示和几个潜在的响应,评估者的任务是按从最有用到最无用的顺序对它们进行排序。这种数据收集既昂贵又缓慢,但仍然比手动编写所有所需的响应要快。
我们可以使用这个排序偏好数据集来构建奖励模型,该模型接收一个提示-响应对并输出一个单一的浮点值。值越高,响应越好。这个奖励模型通常是另一个较小的 Transformer。它不是预测下一个标记,而是读取整个序列并输出一个单一的浮点数——即对给定响应的评分。
然后,我们可以使用这个奖励模型通过强化学习设置进一步调整我们的模型。在这本书中,我们不会深入探讨强化学习的细节,但不要被这个术语吓倒——它指的是任何深度学习模型通过做出预测(称为动作)并对其输出(称为奖励)获得反馈来学习的训练设置。简而言之,模型自己的预测成为其训练数据。
在我们的案例中,动作仅仅是生成对输入提示的响应,就像我们上面使用generate()函数所做的那样。奖励就是将一个单独的回归模型应用到那个字符串输出上。以下是一个简单的伪代码示例。
for prompts in dataset:
# Takes an action
responses = model.generate(prompts)
# Receives a reward
rewards = reward_model.predict(responses)
good_responses = []
for response, score in zip(responses, rewards):
if score > cutoff:
good_responses.append(response)
# Updates the model parameters. We do not update the reward model.
model.fit(good_responses)
列表 16.15:最简单可能的 RLHF 算法的伪代码
在这个简单的例子中,我们通过奖励截止值过滤我们的生成响应,并将“好的”输出作为新的训练数据,进行更多像上一节中那样进行的监督微调。在实践中,你通常不会丢弃你的不良响应,而是使用专门的梯度更新算法,利用所有响应和奖励来引导你的模型参数。毕竟,一个不良的响应给出了一个好的信号,表明不应该做什么。OpenAI 最初在 2022 年的一篇论文中描述了 RLHF^([5]),并使用这种训练设置将 GPT-3 的初始预训练参数转换为 ChatGPT 的第一个版本。
这种设置的优点在于它可以迭代。你可以使用这个新训练的模型,生成新的改进后的响应,根据人类偏好对这些响应进行排序,并训练一个新的改进后的奖励模型。
使用经过 RLHF 训练的聊天机器人
我们可以通过尝试使用这种形式的迭代偏好调整训练的模型来使这个问题更加具体。由于构建聊天机器人是大型 Transformer 模型的“杀手级应用”,因此发布预训练模型如 Gemma 的公司通常会发布专门的“指令调整”版本,专为聊天构建。现在让我们尝试加载一个。这将是一个 400 亿参数的模型,是我们刚刚加载的模型大小的四倍,也是本书中我们将使用的最大模型:
gemma_lm = keras_hub.models.CausalLM.from_preset(
"gemma3_instruct_4b",
dtype="bfloat16",
)
列表 16.16:加载一个指令调整过的 Gemma 变体
就像我们之前微调的 Gemma 模型一样,这个指令调整过的检查点附带了一个特定的模板来格式化其输入。同样,确切文本并不重要,重要的是我们的提示模板与用于调整模型的文本相匹配:
PROMPT_TEMPLATE = """<start_of_turn>user
{}<end_of_turn>
<start_of_turn>model
"""
让我们试着问它一个问题:
>>> prompt = "Why can't you assign values in Jax tensors? Be brief!"
>>> gemma_lm.generate(PROMPT_TEMPLATE.format(prompt), max_length=512)
<start_of_turn>user
Why can't you assign values in Jax tensors? Be brief!<end_of_turn>
<start_of_turn>model
Jax tensors are designed for efficient automatic differentiation. Directly
assigning values disrupts this process, making it difficult to track gradients
correctly. Instead, Jax uses operations to modify tensor values, preserving the
differentiation pipeline.<end_of_turn>
这个 400 亿参数的模型最初在 140 万亿个文本标记上进行了预训练,然后进行了广泛的微调,使其在回答问题时更有帮助。其中一些调整与我们在上一节中进行的监督微调类似,一些与我们在本节中介绍的 RLHF 类似,还有一些使用更大的模型作为“教师”来引导训练的其他技术。在问答能力上的提升很容易察觉。
让我们尝试使用这个模型来解决我们之前在幻觉方面遇到麻烦的提示:
>>> prompt = "Who is the 542nd president of the United States?"
>>> gemma_lm.generate(PROMPT_TEMPLATE.format(prompt), max_length=512)
<start_of_turn>user
Who is the 542nd president of the United States?<end_of_turn>
<start_of_turn>model
This is a trick question! As of today, November 2, 2023, the United States has
only had 46 presidents. There hasn't been a 542nd president yet. 😊
You're playing with a very large number!<end_of_turn>
这个更强大的模型拒绝上钩。这不是新建模技术的结果,而是对像这样一个问题以及我们刚刚收到的类似响应的复杂问题的广泛训练的结果。事实上,你可以清楚地看到为什么移除幻觉有点像玩打地鼠——尽管它拒绝幻想一个美国总统,但模型现在设法编造了今天的日期。
多模态 LLM
一个明显的聊天机器人扩展是处理新的输入模态的能力。一个能够响应音频输入并处理图像的助手,将比只能处理文本的助手更有用。
将 Transformer 扩展到不同的模态可以通过一种概念上简单的方式进行。Transformer 不是一个特定于文本的模型;它是一个在序列数据中学习模式的高效模型。如果我们能想出如何将其他数据类型强制转换为序列表示,我们就可以将这个序列输入到 Transformer 中,并用它进行训练。
实际上,我们刚刚加载的 Gemma 模型正是如此。该模型附带一个独立的 4.2 亿参数图像编码器,它将输入图像切割成 256 个补丁,并将每个补丁编码为与 Gemma 的隐藏 Transformer 维度相同的向量。每个图像将被嵌入为一个(256, 2560)序列。因为 2560 是 Gemma Transformer 模型的隐藏维度,所以这种图像表示可以简单地拼接到我们的文本序列的令牌嵌入层之后。你可以将其想象为 256 个代表图像的特殊令牌,其中每个(1, 2560)向量有时被称为“软令牌”(图 16.6)。与我们的正常“硬令牌”不同,每个令牌 ID 在我们的令牌嵌入矩阵中只能取固定数量的可能向量,这些图像软令牌可以取视觉编码器输出的任何向量值。

图 16.6:通过拼接文本令牌和软图像令牌来处理图像输入
让我们加载一个图像,以更详细地了解这是如何工作的(图 16.7):
import matplotlib.pyplot as plt
image_url = (
"https://github.com/mattdangerw/keras-nlp-scripts/"
"blob/main/learned-python.png?raw=true"
)
image_path = keras.utils.get_file(origin=image_url)
image = np.array(keras.utils.load_img(image_path))
plt.axis("off")
plt.imshow(image)
plt.show()

图 16.7:Gemma 模型的测试图像
我们可以使用 Gemma 来对这个图像提出一些问题:
>>> # Limits the maximum input size of the model
>>> gemma_lm.preprocessor.max_images_per_prompt = 1
>>> gemma_lm.preprocessor.sequence_length = 512
>>> prompt = "What is going on in this image? Be concise!<start_of_image>"
>>> gemma_lm.generate({
... "prompts": PROMPT_TEMPLATE.format(prompt),
... "images": [image],
... })
<start_of_turn>user
What is going on in this image? Be concise!
<start_of_image>
<end_of_turn>
<start_of_turn>model
A snake wearing glasses is sitting in a leather armchair, surrounded by a large
bookshelf, and reading a book. It's a whimsical, slightly surreal image.
<end_of_turn>
>>> prompt = "What is the snake wearing?<start_of_image>"
>>> gemma_lm.generate({
... "prompts": PROMPT_TEMPLATE.format(prompt),
... "images": [image],
... })
<start_of_turn>user
What is the snake wearing?
<start_of_image>
<end_of_turn>
<start_of_turn>model
The snake is wearing a pair of glasses! They are red-framed and perched on its
head.<end_of_turn>
我们输入的每个提示都包含特殊令牌<start_of_image>。这在我们输入序列中转换成了 256 个占位符值,这些值反过来又用代表我们图像的软令牌替换。
对于这种多模态模型,训练过程与常规 LLM 预训练和微调非常相似。通常,你首先会单独对图像编码器进行预训练,就像我们在本书第八章中首先做的那样。然后你可以简单地执行相同的“猜测下一个单词”预训练,并将混合图像和文本内容组合成一个序列输入。我们的 Transformer 不会训练输出图像软令牌;我们只需在这些图像令牌位置将损失置零。
我们可以简单地将图像数据添加到 LLM 中,这似乎几乎像魔法一样,但当我们考虑到我们正在使用的序列模型的力量时,这实际上是一个非常预期的结果。我们使用了一个 Transformer,将我们的图像输入重新表示为序列数据,并进行了大量的额外训练。该模型可以在学习在 Transformer 的潜在空间中嵌入图像的同时,保留原始语言模型摄取和产生文本的能力。
基础模型
随着 LLM 探索不同的模态,"大型语言模型"这个名称可能有点误导。它们确实建模语言,但也包括图像、音频,甚至可能是结构化数据。在下一章中,我们将看到一种独特的架构,称为扩散模型,它在底层结构方面有所不同,但感觉相似——它们也是在“互联网规模”的大量数据上通过自监督损失进行训练。
这种模型的统称是基础模型。更具体地说,基础模型是任何在广泛数据上训练(通常使用大规模自监督)的模型,可以微调到广泛的下游任务。
通常,你可以将基础模型视为学习从互联网的大量区域中重建数据,给定其部分表示。虽然 LLM 是这些模型中第一个且最广为人知的,但还有很多其他模型。基础模型的标志是自监督学习目标(重建损失)以及这些模型不是针对单一任务进行专门化的,并且可以用于多种下游目的。
这是在机器学习漫长历史中最近发生的一个重要且引人注目的转变。与其从头开始在自己的数据集上训练模型,你通常会更好地使用基础模型来获取输入(无论是图像、文本还是其他内容)的丰富表示,然后针对最终下游任务对该模型进行微调。当然,这伴随着需要运行具有数十亿参数的大型模型的缺点,因此它几乎不适合所有机器学习的实际应用。
检索增强生成(RAG)
在提示中添加额外信息不仅有助于处理图像数据;这可以是一种扩展 LLM 能力的一般方法。一个显著的例子是当使用 LLM 进行搜索时。如果我们天真地将 LLM 与搜索引擎进行比较,它有几个致命的缺陷:
-
LLM 有时会编造事实。它会输出训练数据中不存在的错误“事实”,但这些可以从训练数据中推断出来。这些信息可能从误导到危险不等。
-
一个大型语言模型(LLM)对世界的知识有一个截止日期——最多是模型预训练的日期。训练一个 LLM 相当昂贵,并且不可能在新的数据上持续训练。因此,在某个任意的时间点,LLM 对世界的知识将停止增长。
没有人愿意使用只能告诉你六个月前发生的事情的搜索引擎。但如果我们把 LLM 看作是更类似于“对话软件”,能够处理提示中的任何序列数据,那么如果我们将模型作为检索更多传统搜索信息界面的接口,会怎样呢?这就是检索增强生成或RAG背后的想法。
RAG 通过获取初始用户问题并执行某种形式的查询来拉取额外的文本上下文来工作。这个查询可以是数据库、搜索引擎或任何可以提供关于用户提出的问题的额外信息的任何东西。然后,这些额外信息直接添加到提示中。例如,你可能构建这样的提示:
Use the following pieces of context to answer the question.
Question: What are some good ways to improve sleep?
Context: {text from a medical journal on improving sleep}
Answer:
查找相关信息的一种常见方法是使用向量数据库。要构建向量数据库,你可以使用 LLM 或任何模型将一系列源文档嵌入为向量。文档文本将存储在数据库中,嵌入向量用作键。在检索过程中,LLM 可以再次用于将用户查询嵌入为向量。向量数据库负责搜索与查询向量接近的键向量,并显示相应的文本。这听起来可能很像注意力机制本身——回想一下,“查询”、“键”和“值”这些术语实际上来自数据库系统。
显示信息以协助生成做了一些事情:
-
它为你提供了一个明显的方法来绕过模型的截止日期。
-
它允许模型访问私有数据。公司可能希望使用在公共数据上训练的 LLM 作为访问存储在私有的信息的接口。
-
它可以帮助模型在事实上站稳脚跟。没有银弹可以完全阻止幻觉,但如果在提示中提供了关于主题的正确背景信息,LLM 在某个主题上编造事实的可能性就会大大降低。
“推理”模型
自从第一个大型语言模型(LLM)出现以来,研究人员一直苦于这样一个众所周知的事实:这些模型在数学问题和逻辑谜题上表现糟糕。一个模型可能直接在其训练数据中对一个问题给出完美的回答,但如果你在提示中替换几个名字或数字,就会很明显地看出模型对它试图解决的问题毫无头绪。对于自然语言处理中的许多问题,LLM 提供了一种简单的进步方法:增加训练数据量,提高某些基准分数。然而,小学数学问题却挑战了进步。
到 2023 年,谷歌的研究人员注意到,如果你用几个数学问题的“展示你的工作”示例来提示模型——就像在家庭作业上那样逐字写出步骤——模型就会开始这样做。随着模型模仿写出中间步骤,它实际上通过关注自己的输出,达到了更正确的解决方案。他们将这种方法称为“思维链”提示,这个名字也一直沿用下来。另一组研究人员注意到,你甚至不需要示例;你只需用短语“让我们一步步思考”来提示模型,就能得到更好的输出。
自从这些发现以来,人们对于直接训练 LLM 以更好地进行思维链推理的兴趣浓厚。像 OpenAI 的 o1 和 DeepSeek 的 r1 这样的模型通过训练模型在困难问题上“大声思考”来展示在数学和编码问题上的显著进步而成为头条新闻。
这种思维链微调的方法与 RLHF 非常相似。我们首先将在几个“展示你的工作”的数学问题并得出正确答案的监督示例上训练模型。接下来,我们将用一个新的数学问题提示模型,并检查模型是否得到了正确的最终答案。最后,我们使用这些新生成的输出进一步调整模型的权重。
让我们用 Gemma 模型来试试。我们可以写出自己的文字问题,并打开随机采样,这样每次都能得到一个相对随机的响应:
prompt = """Judy wrote a 2-page letter to 3 friends twice a week for 3 months.
How many letters did she write?
Be brief, and add "ANSWER:" before your final answer."""
# Turns on random sampling to get a diverse range of outputs
gemma_lm.compile(sampler="random")
让我们尝试生成几个响应:
>>> gemma_lm.generate(PROMPT_TEMPLATE.format(prompt))
<start_of_turn>user
Judy wrote a 2-page letter to 3 friends twice a week for 3 months.
How many letters did she write?
Be brief, and add "ANSWER:" before your final answer.<end_of_turn>
<start_of_turn>model
Here's how to solve the problem:
* **Letters per week:** 3 friends * 2 letters/week = 6 letters/week
* **Letters per month:** 6 letters/week * 4 weeks/month = 24 letters/month
* **Letters in 3 months:** 24 letters/month * 3 months = 72 letters
* **Total letters:** 72 letters * 2 = 144 letters
ANSWER: 144<end_of_turn>
>>> gemma_lm.generate(PROMPT_TEMPLATE.format(prompt))
<start_of_turn>user
Judy wrote a 2-page letter to 3 friends twice a week for 3 months.
How many letters did she write?
Be brief, and add "ANSWER:" before your final answer.<end_of_turn>
<start_of_turn>model
Here's how to solve the problem:
* **Letters per week:** 3 friends * 2 letters/week = 6 letters/week
* **Letters per month:** 6 letters/week * 4 weeks/month = 24 letters/month
* **Total letters:** 24 letters/month * 3 months = 72 letters
ANSWER: 72<end_of_turn>
在第一次尝试中,我们的模型被每个字母有两页的冗余细节所困扰。在第二次尝试中,模型正确地解决了问题。我们正在使用的这个指令调整过的 Gemma 模型已经针对这类数学问题进行了调整;如果你使用上一节中的“未调整”的 Gemma 模型,你几乎得不到这么好的结果。
我们可以将这个想法扩展到一种非常简单的思维链训练形式:
-
收集一些基本的数学和推理问题以及期望的答案。
-
生成(带有一些随机性)一定数量的响应。
-
通过字符串解析找到所有正确答案的响应。你可以提示模型使用我们之前使用的特定文本标记作为最终答案。
-
在正确的响应上运行监督微调,包括所有中间输出。
-
重复!
之前描述的过程是一个强化学习算法。我们的答案检查行为作为环境,生成的输出是模型用来学习的动作。与 RLHF 一样,在实践中,你会使用更复杂的梯度更新步骤来使用所有响应(包括错误的)的信息,但基本原理是相同的。
同样的想法被用来提高 LLM 在其他领域的性能,这些领域对文本提示有明显的、可验证的答案。编码是一个重要的领域——你可以提示 LLM 输出代码,然后实际运行代码来测试响应的质量。
在所有这些领域,一个明显的趋势是——随着模型学会解决更难的问题,模型在得出最终答案之前将花费越来越多的时间“展示其工作”。你可以把这看作是模型学习在其潜在解决方案的输出中进行搜索。我们将在本书的最后一章进一步讨论这个想法。
LLM 下一步将走向何方?
根据本章开头讨论的 LLM 轨迹,LLM 将走向何方可能看起来很明显。更多的参数!更好的性能!在一般意义上,这可能是对的,但我们的轨迹可能并不那么线性。
如果你有一个固定的预训练预算,比如说一百万美元,你可以大致将其视为购买了一定数量的计算或浮点运算(flops)。你可以将这些 flops 用于使用更多数据进行训练,或者训练一个更大的模型。最近的研究指出,GPT-3 在 1750 亿个参数的情况下,其计算预算过大。在更多数据上训练一个较小的模型将导致更好的模型性能。因此,最近模型大小趋于平稳,而数据大小趋于上升。
这并不意味着扩展会停止——更多的计算能力通常确实会导致更好的 LLM 性能,我们还没有看到下一个标记预测性能趋于平稳的渐近线迹象。公司仍在继续投资数十亿美元用于扩展 LLM,并观察新的功能如何出现。
图 16.8 显示了 2018 年至 2025 年间发布的一些主要 LLM 的详细信息。我们可以注意到,尽管用于预训练的总标记数稳步且大幅上升,但自 GPT-3 以来,模型参数数量变化很大。部分原因是我们现在知道 GPT-3 训练不足,但也有一个更实际的原因。在部署模型时,为了适应更便宜的硬件而牺牲性能通常是值得的。如果运行成本过高,那么一个非常好的模型帮助不大。

图 16.8:随时间变化的 LLM 参数数量(左)和预训练数据集大小(右)。许多最近发布的专有 LLM(例如,GPT-4 和 Gemini)不包括在内,因为模型细节尚未公开。
我们可能无法无限制地扩展这些模型还有另一个原因:我们开始缺乏预训练数据!科技公司开始难以找到更多高质量、公开、人工编写的内容用于预训练。模型甚至开始“吃掉自己的尾巴”,通过训练其他 LLM 创建的大量内容,这引发了一系列其他问题。这也是最近强化学习受到很多关注的原因之一。如果你能创建一个难以解决、自包含的环境,为 LLM 生成新问题,你将找到一种使用模型自身输出继续训练的方法——无需在网络上寻找更多优质文本的碎片。
我们提到的任何解决方案都不会是 LLMs 面临问题的银弹。最终,基本问题仍然是 LLMs 在学习和人类相比效率极低。模型能力仅来自在比人们一生中阅读的文本多许多数量级的文本上进行训练。随着 LLMs 的扩展继续,关于如何使模型能够快速学习有限数据的更基础的研究也将继续。
尽管如此,LLMs 代表了构建流畅的自然语言界面的能力,而这本身将带来我们在计算设备上所能实现的事情的巨大转变。在本章中,我们概述了许多 LLMs 用来实现这些功能的基本配方。
摘要
-
大型语言模型(LLMs)是几个关键成分的组合:
-
Transformer 架构
-
语言建模任务(根据过去标记预测下一个标记)
-
大量的未标记文本数据
-
-
一个 LLM 学习一个概率分布来预测单个标记。这可以与采样策略结合,生成一长串文本。有许多流行的文本采样方法:
-
贪婪搜索在每个生成步骤中采取最可能的预测标记。
-
随机采样直接从所有标记上的预测分类分布中进行采样。
-
Top-k 采样将分类分布限制在 K 个候选者的顶部集合。
-
-
LLMs 使用数十亿个参数,并在万亿个单词的文本上训练。
-
LLM 的输出不可靠,所有 LLMs 偶尔都会产生事实错误的信息。
-
LLMs 可以被微调以遵循聊天对话中的指令。这种微调类型被称为指令微调:
-
最简单的指令微调形式涉及直接在指令和响应对上训练模型。
-
更高级的指令微调形式涉及强化学习。
-
-
在使用 LLMs 时,最常见的资源瓶颈是加速器内存。
-
LoRA 是一种技术,通过冻结大多数 Transformer 参数,仅更新注意力投影权重的低秩分解来减少内存使用。
-
LLMs 可以输入或输出来自不同模态的数据,如果你能想出如何将这些输入或输出作为序列预测问题中的序列来构建。
-
基础模型是一个通用术语,用于任何模态的训练模型,这些模型使用自监督在广泛的下游任务上进行训练。
脚注
-
在 2022 年,Jason Allen 使用图像生成软件 Midjourney 赢得了数字艺术家的奖项,而在 2024 年,Rie Kudan 凭借一部大量借助生成软件创作的小说赢得了日本最负盛名的文学奖项之一。[↩]
-
Iannis Xenakis,“Musiques formelles: nouveaux principes formels de composition musicale,”La Revue musicale特刊,第 253-254 期(1963 年)。[↩]
-
Alec Radford, Karthik Narasimhan, Tim Salimans 和 Ilya Sutskever,“通过生成预训练改进语言理解”,(2018 年),
mng.bz/GweD. [↩] -
J. Edward Hu 等人,“LoRA:大型语言模型的低秩自适应”,arXiv(2021 年),
arxiv.org/abs/2106.09685. [↩] -
Ouyang 等人,“通过人类反馈训练语言模型遵循指令”,第 36 届国际神经网络信息处理系统会议论文集(2022 年),
arxiv.org/abs/2203.02155. [↩]


浙公网安备 33010602011771号