在前面三章中,我们深入学习了各种卷积和循环网络架构,以及它们在PyTorch中的实现。在本章中,我们将探讨一些在各种机器学习任务中被证明成功的深度学习模型架构,它们既不完全是卷积型的,也不完全是循环型的。我们将从第2章《深度CNN架构》和第4章《深度循环模型架构》讨论的内容继续展开。
首先,我们将探索Transformer模型,正如我们在第4章《深度循环模型架构》的结尾处所学到的那样,Transformer在各种序列任务(包括大语言模型)上超越了循环架构,并且最近已经成为各种任务(多模态模型、生成式AI等)的事实标准。接着,我们将从第2章《深度CNN架构》结尾讨论的EfficientNets继续,探索生成随机有线神经网络(也称为RandWireNNs)的理念。
在本章中,我们旨在总结本书中讨论的各种神经网络架构。在完成本章后,你将详细了解Transformer,并掌握如何使用PyTorch将这些强大的模型应用于序列任务。此外,通过构建自己的RandWireNN模型,你将亲身体验在PyTorch中执行神经架构搜索的过程。本章分为以下几个主题:
- 构建用于语言建模的Transformer模型
- 从头开始开发RandWireNN模型
本章的所有相关代码可以在以下网址找到:github.com/arj7192/Mas…
构建用于语言建模的Transformer模型
在本节中,我们将探索Transformer的概念,并使用PyTorch构建一个用于语言建模的Transformer模型。我们还将学习如何通过PyTorch的预训练模型库使用一些基于Transformer的高级模型,如BERT和GPT。这个预训练模型库包含了一些在通用任务(如语言建模,预测给定前序单词的下一个单词)上训练好的PyTorch模型。这些预训练模型可以进一步微调,用于特定任务,如情感分析(判断给定文本是正面、负面还是中性)。在开始构建Transformer模型之前,让我们快速回顾一下什么是语言建模。
语言建模回顾
语言建模的任务是确定在给定的一系列单词之后,某个单词或一系列单词出现的概率。例如,如果我们给出French is a beautiful _____作为我们的词序列,那么下一个单词是language或word等的概率是多少?这些概率是通过使用各种概率和统计技术来建模语言得出的。其思想是观察一个文本语料库,并通过学习哪些单词通常一起出现,哪些单词从不一起出现,从而学习语法。通过这种方式,语言模型可以在给定不同序列的情况下,建立关于不同单词或序列出现的概率规则。
循环模型一直是学习语言模型的一种流行方法。然而,和许多与序列相关的任务一样,Transformer在这一任务上也超越了循环网络。我们将通过在基于《华尔街日报》文章的文本语料库上训练一个Transformer模型,为英语语言实现一个基于Transformer的语言模型。
现在,让我们开始训练一个用于语言建模的Transformer。在这个练习中,我们将只展示代码中最重要的部分。完整代码可以在我们的GitHub仓库中访问【1】。
在练习过程中,我们还会深入探讨Transformer架构的各个组成部分。
为了进行这个练习,我们需要导入一些依赖项。以下是一个重要的导入语句:
from torch.nn import TransformerEncoder, TransformerEncoderLayer
除了导入常规的PyTorch依赖项,我们还必须导入一些特定于Transformer模型的模块;这些模块直接包含在torch库中。我们还将导入torchtext,以便直接从torchtext.datasets中下载可用的数据集中的文本数据集。
在下一节中,我们将定义Transformer模型的架构,并详细了解模型的组成部分。
理解Transformer模型架构
这是本练习中最重要的一步。这里我们将定义Transformer模型的架构。
首先,让我们简要讨论一下模型架构,然后再看看用于定义模型的PyTorch代码。下图(图5.1)展示了模型架构:
第一点需要注意的是,这本质上是一个基于编码器-解码器的架构,左侧为编码器单元(紫色部分),右侧为解码器单元(橙色部分)。编码器和解码器单元可以多次平铺,以构建更深的架构。在我们的示例中,有两个级联的编码器单元和一个解码器单元。这个编码器-解码器设置的核心概念是,编码器将序列作为输入,并生成与输入序列中单词数量相同的嵌入(即每个单词一个嵌入)。这些嵌入随后与模型目前为止的预测结果一起传递给解码器。
让我们逐层分析这个模型的各个部分:
-
嵌入层:这个层的作用是执行传统任务,将序列中的每个输入单词转换为数字向量,即嵌入。这里,我们使用
torch.nn.Embedding模块来编码这一层。 -
位置编码器:注意,Transformer的架构中没有任何循环层,但它在序列任务中仍然优于循环网络。这是如何做到的?通过一种称为位置编码(positional encoding)的巧妙技巧,模型能够理解数据中的顺序性或顺序关系。基本上,这些位置编码向量会被添加到输入的单词嵌入中,这些向量的生成方式使得模型能够理解第二个单词是在第一个单词之后依次出现的。位置编码向量是使用正弦和余弦函数生成的,分别表示系统的周期性和后续单词之间的距离。我们在这个练习中的位置编码层实现如下:
class PosEnc(nn.Module): def __init__(self, d_m, dropout=0.2, size_limit=5000): # d_m 表示嵌入的维度 pos = torch.arange(size_limit, dtype=torch.float).unsqueeze(1) divider = torch.exp( torch.arange(0, d_m, 2).float() * ( -torch.log(10000.0) / d_m)) '''divider 是由弧度组成的列表,它乘以单词的位置索引, 然后输入到正弦和余弦函数中。''' p_enc[:, 0, 0::2] = torch.sin(pos * divider) p_enc[:, 0, 1::2] = torch.cos(pos * divider) def forward(self, x): return self.dropout(x + self.p_enc[:x.size(0)])正如你所看到的,正弦和余弦函数被交替使用来表示序列模式。实际上,实现位置编码的方法有很多种。如果没有位置编码层,模型将无法理解单词的顺序。
-
多头注意力(Multi-Head Attention) :在了解多头注意力层之前,先来理解一下什么是自注意力(Self-Attention)层。在第4章《深度循环模型架构》中,我们已经介绍了注意力机制的概念,尤其是与循环网络相关的部分。这里,顾名思义,注意力机制被应用于“自我”,即序列中的每个单词。序列中的每个单词嵌入经过自注意力层,产生一个与单词嵌入长度相同的单独输出。下图(图5.2)详细描述了这一过程:
正如我们所见,对于每个单词,通过三个可学习的参数矩阵(Pq、Pk 和 Pv)生成三个向量。这三个向量分别是查询向量(query vector)、键向量(key vector)和值向量(value vector)。查询向量和键向量进行点积运算,得到每个单词的一个数值。这些数值通过除以键向量长度的平方根进行归一化,然后对所有单词的这些结果同时进行Softmax操作,以生成概率,最后将这些概率与各自的值向量相乘。这样,序列中的每个单词就会得到一个输出向量,且输出向量的长度与输入的单词嵌入长度相同。
多头注意力层是自注意力层的扩展,其中多个自注意力模块为每个单词计算输出。这些单独的输出被连接在一起,并与另一个参数矩阵(Pm)进行矩阵乘法运算,以生成最终的输出向量,其长度等于输入嵌入向量的长度。下图展示了多头注意力层,以及我们将在本练习中使用的两个自注意力单元:
拥有多个自注意力头可以使不同的注意力头关注单词序列中的不同方面,这类似于卷积神经网络中不同的特征图学习不同的模式。正因为如此,多头注意力层比单个自注意力层表现更好,因此我们将在本练习中使用它。
另外需要注意的是,解码器单元中的掩码多头注意力层与多头注意力层的工作方式完全相同,只是增加了掩码操作——即在处理序列的时间步t时,所有从t+1到n(序列长度)的单词都被掩盖/隐藏。
在训练过程中,解码器会接收两种类型的输入。一方面,它从最终的编码器中接收查询向量和键向量,作为输入传递到其(未掩码的)多头注意力层,这些查询向量和键向量是最终编码器输出的矩阵变换。另一方面,解码器将其在前一时间步的预测结果作为顺序输入传递到其掩码多头注意力层。
加法和层归一化
我们在第2章《深度CNN架构》讨论ResNet时已经介绍了残差连接的概念。在图5.1中,我们可以看到在加法和层归一化层之间存在残差连接。在每个实例中,通过将输入单词嵌入向量直接添加到多头注意力层的输出向量来建立残差连接。这有助于在整个网络中更容易地进行梯度传播,并避免梯度爆炸和消失的问题。此外,它还帮助网络更高效地学习跨层的恒等函数。
另外,层归一化被用作一种归一化技巧。在这里,我们独立地对每个特征进行归一化,使得所有特征都具有统一的均值和标准差。请注意,这些加法和归一化操作是在网络每个阶段对序列中每个单词向量单独应用的。
前馈层
在编码器和解码器单元中,序列中所有单词的归一化残差输出向量会通过一个公共的前馈层。由于这些单词共享一组参数,这一层有助于学习序列中的广泛模式。
线性层和Softmax层
到目前为止,每一层都输出一个向量序列,每个单词对应一个向量。对于我们的语言建模任务,我们需要一个最终的单一输出。线性层将这些向量序列转换为一个向量,其大小等于我们词汇表的长度。Softmax层则将这个输出转化为一个概率向量,这些概率之和为1。这些概率表示了词汇表中相应单词作为序列中下一个单词出现的概率。
现在我们已经详细解释了Transformer模型的各个元素,接下来我们来看一下用于实例化模型的PyTorch代码。
在 PyTorch 中定义 Transformer 模型
利用前一节中描述的架构细节,我们现在将编写必要的 PyTorch 代码来定义一个 Transformer 模型,如下所示:
class Transformer(nn.Module):
def __init__(self, num_token, num_inputs, num_heads, num_hidden,
num_layers, dropout=0.3):
self.position_enc = PosEnc(num_inputs, dropout)
layers_enc = TransformerEncoderLayer(
num_inputs, num_heads, num_hidden, dropout)
self.enc_transformer = TransformerEncoder(
layers_enc, num_layers)
self.enc = nn.Embedding(num_token, num_inputs)
self.num_inputs = num_inputs
self.dec = nn.Linear(num_inputs, num_token)
正如我们所见,在类的 __init__ 方法中,感谢 PyTorch 提供的 TransformerEncoder 和 TransformerEncoderLayer 函数,我们不需要自己实现这些功能。对于我们的语言建模任务,我们只需要一个用于输入单词序列的单一输出。因此,解码器只需将编码器生成的向量序列转换为一个输出向量的线性层。位置编码器(Positional Encoder)也通过我们之前讨论的定义进行了初始化。
在 forward 方法中,输入经过位置编码,然后通过编码器,最后进入解码器:
def forward(self, source):
source = self.enc(source) * torch.sqrt(self.num_inputs)
source = self.position_enc(source)
op = self.enc_transformer(source, self.mask_source)
op = self.dec(op)
return op
现在我们已经定义了 Transformer 模型架构,接下来我们将加载文本语料库来进行训练。
加载和处理数据集
在本节中,我们将讨论与加载文本数据集并使其可用于模型训练相关的步骤。让我们开始吧:
对于本练习,我们将使用《华尔街日报》中的文本,该文本可以作为 Penn Treebank 数据集获得。
数据集引用
Marcus Mitchell P., Marcinkiewicz Mary Ann, and Santorini Beatrice. 1993. Building a large annotated corpus of English: The Penn Treebank: github.com/wojzaremba/…
我们将利用 torchtext 的功能来下载训练数据集(可在 torchtext 数据集中获得)并对其词汇进行分词:
tr_iter = PennTreebank(split='train')
tkzer = get_tokenizer('basic_english')
vocabulary = build_vocab_from_iterator(
map(tkzer, tr_iter), specials=['<unk>'])
vocabulary.set_default_index(vocabulary['<unk>'])
然后,我们将使用词汇表将原始文本转换为张量,以用于训练、验证和测试数据集:
def process_data(raw_text):
numericalised_text = [
torch.tensor(vocabulary(tkzer(text)),
dtype=torch.long) for text in raw_text]
return torch.cat(
tuple(filter(lambda t: t.numel() > 0,
numericalised_text)))
tr_iter, val_iter, te_iter = PennTreebank()
training_text = process_data(tr_iter)
validation_text = process_data(val_iter)
testing_text = process_data(te_iter)
我们还将定义用于训练和评估的批处理大小,并声明批处理生成函数,如下所示:
def gen_batches(text_dataset, batch_size):
num_batches = text_dataset.size(0) // batch_size
text_dataset = text_dataset[:num_batches * batch_size]
text_dataset = text_dataset.view(
batch_size, num_batches).t().contiguous()
return text_dataset.to(device)
training_batch_size = 32
evaluation_batch_size = 16
training_data = gen_batches(training_text, training_batch_size)
接下来,我们必须定义最大序列长度,并编写一个函数,该函数将生成每个批次的输入序列和输出目标:
max_seq_len = 64
def return_batch(src, k):
sequence_length = min(max_seq_len, len(src) - 1 - k)
sequence_data = src[k:k+sequence_length]
sequence_label = src[k+1:k+1+sequence_length].reshape(-1)
return sequence_data, sequence_label
在定义了模型并准备了训练数据后,我们将开始训练 Transformer 模型。
训练 Transformer 模型
在本节中,我们将定义模型训练所需的超参数,定义模型训练和评估的例程,最后执行训练循环。让我们开始吧:
在这一步中,我们定义所有模型超参数并实例化我们的 Transformer 模型。以下代码是自解释的:
num_tokens = len(vocabulary) # 词汇表大小
embedding_size = 256 # 嵌入层维度
# transformer 编码器的隐藏(前馈)层维度
num_hidden_params = 256
# transformer 编码器中的 transformer 编码器层数
num_layers = 2
# (多头)注意力模型中的头数量
num_heads = 2
# dropout 的值(百分比)
dropout = 0.25
loss_func = nn.CrossEntropyLoss()
# 学习率
lrate = 4.0
optim_module = torch.optim.SGD(transformer_model.parameters(), lr=lrate)
sched_module = torch.optim.lr_scheduler.StepLR(
optim_module, 1.0, gamma=0.88)
transformer_model = Transformer(
num_tokens, embedding_size, num_heads,
num_hidden_params, num_layers, dropout).to(device)
在开始模型训练和评估循环之前,我们需要定义训练和评估例程:
def train_model():
for b, i in enumerate(
range(0, training_data.size(0) - 1, max_seq_len)):
train_data_batch, train_label_batch = return_batch(
training_data, i)
sequence_length = train_data_batch.size(0)
# 仅在最后一个批次
if sequence_length != max_seq_len:
mask_source = mask_source[:sequence_length,
:sequence_length]
op = transformer_model(train_data_batch, mask_source)
loss_curr = loss_func(op.view(-1, num_tokens), train_label_batch)
optim_module.zero_grad()
loss_curr.backward()
torch.nn.utils.clip_grad_norm_(transformer_model.parameters(), 0.6)
optim_module.step()
loss_total += loss_curr.item()
def eval_model(eval_model_obj, eval_data_source):
...
最后,我们必须运行模型训练循环。为了演示,我们将模型训练 5 个 epoch,但我们鼓励你运行更长时间以获得更好的性能:
min_validation_loss = float("inf")
eps = 5
best_model_so_far = None
for ep in range(1, eps + 1):
ep_time_start = time.time()
train_model()
validation_loss = eval_model(transformer_model, validation_data)
if validation_loss < min_validation_loss:
min_validation_loss = validation_loss
best_model_so_far = transformer_model
这将产生如下输出:
epoch 1, 100/1000 batches, training loss 8.77, training perplexity 6460.73
epoch 1, 200/1000 batches, training loss 7.30, training perplexity 1480.28
epoch 1, 300/1000 batches, training loss 6.88, training perplexity 969.18
...
epoch 5, 900/1000 batches, training loss 5.19, training perplexity 178.59
epoch 5, 1000/1000 batches, training loss 5.27, training perplexity 193.60
epoch 5, validation loss 5.32, validation perplexity 204.29
除了交叉熵损失外,还报告了困惑度(perplexity)。困惑度是自然语言处理中常用的一个度量,用于指示概率分布(在我们的例子中是语言模型)如何拟合或预测样本。困惑度越低,模型预测样本的效果越好。从数学上讲,困惑度就是交叉熵损失的指数。直观上,这个度量用于表示模型在做出预测时有多么困惑。
一旦模型训练完成,我们可以通过评估模型在测试集上的表现来结束这个练习:
testing_loss = eval_model(best_model_so_far, testing_data)
print(f"testing loss {testing_loss:.2f}, testing perplexity {math.exp(testing_loss):.2f}")
这将产生如下输出:
testing loss 5.23, testing perplexity 187.45
在这个练习中,我们使用 PyTorch 构建了一个用于语言建模的 Transformer 模型。我们详细探索了 Transformer 架构及其在 PyTorch 中的实现。我们使用 Penn Treebank 数据集和 torchtext 功能加载和处理了数据集。然后我们训练了 5 个 epoch 的 Transformer 模型,并在一个独立的测试集上对其进行了评估。这为我们提供了开始使用 Transformer 的所有信息。
除了最初的 Transformer 模型(于 2017 年提出)外,近年来还开发了许多后续模型,尤其是在语言建模领域,例如:
- Bidirectional Encoder Representations from Transformers (BERT), 2018
- Generative Pretrained Transformer (GPT), 2018
- GPT-2, 2019
- Conditional Transformer Language Model (CTRL), 2019
- Transformer-XL, 2019
- Distilled BERT (DistilBERT), 2019
- Robustly optimized BERT pretraining Approach (RoBERTa), 2019
- GPT-3, 2020
- Text-To-Text-Transfer-Transformer (T5), 2020
- Language Model for Dialogue Operations (LaMDA), 2021
- Pathways Language Model (PaLM), 2022
- GPT-3.5 (ChatGPT), 2022
- Large Language Model Meta AI (LLaMA), 2023
- GPT-4, 2023
- LLaMA-2, 2023
- Grok, 2023
- Gemini, 2023
- Sora, 2024
- Gemini-1.5, 2024
- LLaMA-3, 2024
虽然我们不会在本章中详细介绍这些模型,但你仍然可以借助由 Hugging Face 开发的 transformers 库使用这些模型和 PyTorch 开始工作。我们将在第19章《PyTorch x Hugging Face》中详细探讨 Hugging Face。transformers 库为各种任务(如语言建模、文本分类、翻译、问答等)提供了预训练的 Transformer 模型。
除了模型本身,它还提供了相应模型的分词器。例如,如果我们想使用预训练的 BERT 模型进行语言建模,我们需要在安装了 transformers 库后编写以下代码:
import torch
from transformers import BertForMaskedLM, BertTokenizer
bert_model = BertForMaskedLM.from_pretrained('bert-base-uncased')
token_gen = BertTokenizer.from_pretrained('bert-base-uncased')
ip_sequence = token_gen("I love PyTorch !", return_tensors="pt")["input_ids"]
op = bert_model(ip_sequence, labels=ip_sequence)
total_loss, raw_preds = op[:2]
正如我们所见,只需要几行代码就可以开始使用基于 BERT 的语言模型。这展示了 PyTorch 生态系统的强大之处。我们鼓励你使用 transformers 库探索更复杂的变体,如 DistilBERT 或 RoBERTa。有关更多详细信息,请参考之前提到的它们的 GitHub 页面。
这总结了我们对 Transformer 的探索。我们通过从头构建 Transformer 以及重用预训练模型来实现这一点。Transformer 在自然语言处理领域的发明与计算机视觉领域的 ImageNet 时刻相似,因此这将是一个活跃的研究领域。PyTorch 在这些类型模型的研究和部署中将发挥重要作用。
在本章的最后一节中,我们将回到第2章《深度CNN架构》的神经架构搜索讨论,在那里我们简要讨论了生成最优网络架构的想法。我们将探讨一种模型,其中我们不会决定模型架构的外观,而是运行一个网络生成器,该生成器将为给定任务找到最佳架构。生成的网络称为随机有线神经网络(RandWireNN),我们将使用 PyTorch 从头开始开发一个。
从头开发 RandWireNN 模型
我们在第2章《深度CNN架构》中讨论了EfficientNets,并探索了寻找最佳模型架构的想法,而不是手动指定它。RandWireNNs,或称随机连接神经网络,正如其名,是基于类似的概念构建的。在本节中,我们将学习并使用PyTorch构建我们自己的RandWireNN模型。
理解RandWireNNs
首先,一个随机图生成算法用于生成具有预定义节点数的随机图。通过对图施加一些定义,例如以下定义,将其转化为神经网络:
- 有向图:图被限制为有向图,边的方向是等效神经网络中的数据流方向。
- 聚合:多个进入节点(或神经元)的边通过加权和来聚合,其中权重是可学习的。
- 转换:在图的每个节点内部,应用标准操作:ReLU,随后是3x3可分离卷积(即,常规3x3卷积后接1x1逐点卷积),再接批量归一化。这种操作也称为ReLU-Conv-BN三联体。
- 分布:最后,每个神经元的多个出边携带三联体操作的副本。
最后一块拼图是向图中添加单个输入节点(源)和单个输出节点(汇),以完全将随机图转化为神经网络。一旦图被实现为神经网络,它可以针对各种机器学习任务进行训练。
在ReLU-Conv-BN三联体单元中,输出通道/特征的数量与输入通道/特征的数量相同,这是为了重复性。然而,取决于手头的任务类型,您可以通过增加通道数量(同时减少数据/图像的空间尺寸)在下游阶段化这些图。最终,这些阶段化的图可以通过按顺序将一个的汇节点连接到另一个的源节点来彼此连接。您可以在这篇文章的第3节中更详细地阅读图生成算法:探索用于图像识别的随机连接神经网络。
接下来,通过一个练习,我们将使用PyTorch从头开发一个RandWireNN模型。
使用PyTorch开发RandWireNNs
我们现在将开发一个用于图像分类任务的RandWireNN模型。这将在CIFAR-10数据集上进行。我们将从一个空模型开始,生成一个随机图,将其转化为神经网络,在给定的数据集上训练它以执行给定任务,评估训练后的模型,并最终探索生成的模型。在本练习中,我们只展示重要的代码部分以示范。要访问完整代码,请访问本书的GitHub仓库[1]。
定义训练例程并加载数据
在本练习的第一部分中,我们将定义训练函数,该函数将在我们的模型训练循环中调用,并定义我们的数据集加载器,该加载器将为我们提供训练数据批次。让我们开始吧:
首先,我们需要导入一些库。本练习中将使用的一些新库如下:
from torchviz import make_dot
import networkx as nx
接下来,我们必须定义训练例程,该例程接收一个训练后的模型,模型可以在给定RGB输入图像时生成预测概率:
def train(model, train_dataloader, optim, loss_func, epoch_num, lrate):
for training_data, training_label in train_dataloader:
pred_raw = model(training_data)
curr_loss = loss_func(pred_raw, training_label)
training_loss += curr_loss.data
return training_loss / data_size, training_accuracy / data_size
接下来,我们定义数据集加载器。我们将使用CIFAR-10数据集进行此图像分类任务,这是一个包含60000个32x32 RGB图像的知名数据库,分为10个不同类别,每个类别包含6000张图像。我们将使用torchvision.datasets模块直接从torch数据集仓库中加载数据。
数据集引用
Krizhevsky, Alex. (2009). Learning Multiple Layers of Features from Tiny Images. 链接
代码如下:
def load_dataset(batch_size):
train_dataloader = torch.utils.data.DataLoader(
datasets.CIFAR10('dataset',
transform=transform_train_dataset,
train=True, download=True),
batch_size=batch_size, shuffle=True)
return train_dataloader, test_dataloader
train_dataloader, test_dataloader = load_dataset(batch_size)
这将给我们以下输出:
Downloading https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz to dataset/cifar-10-python.tar.gz
Extracting dataset/cifar-10-python.tar.gz to dataset
我们现在将开始设计神经网络模型。为此,我们需要设计随机连接的图。
定义随机连接的图
在本节中,我们将定义一个图生成器,以便生成一个随机图,该图随后将用作神经网络。让我们开始吧。
如下代码所示,我们必须定义随机图生成器类:
class RndGraph(object):
def __init__(self, num_nodes, graph_probability,
nearest_neighbour_k=4, num_edges_attach=5):
def make_graph_obj(self):
graph_obj = nx.random_graphs.connected_watts_strogatz_graph(
self.num_nodes, self.nearest_neighbour_k,
self.graph_probability)
return graph_obj
在本练习中,我们将使用一种著名的随机图模型——Watts Strogatz (WS) 模型。这是原始RandWireNNs研究论文中实验的三种模型之一。在此模型中,有两个参数:
- 每个节点的邻居数(应严格为偶数),K
- 重新连接概率,P
首先,图的所有N个节点以环形方式组织,每个节点连接到左侧的K/2个节点和右侧的K/2个节点。然后,我们顺时针遍历每个节点K/2次。在第m次遍历时(0<m<K/2),当前节点与其第m个邻居之间的边以概率P重新连接。
在上述代码中,我们的随机图生成器类的make_graph_obj方法使用networkx库实例化WS图模型。
此外,我们添加get_graph_config方法以返回图中的节点和边的列表。当我们将抽象图转换为神经网络时,这将非常有用。我们还将定义一些图保存和加载方法,以缓存生成的图,以确保可重复性和效率:
def get_graph_config(self, graph_obj):
return node_list, incoming_edges
def save_graph(self, graph_obj, path_to_write):
nx.write_yaml(graph_obj, "./cached_graph_obj/" + path_to_write)
def load_graph(self, path_to_read):
return nx.read_yaml("./cached_graph_obj/" + path_to_read)
接下来,我们将继续创建实际的神经网络模型。
定义RandWireNN模型模块
现在我们有了随机图生成器,我们需要将其转化为神经网络。但是在此之前,我们将设计一些神经模块来促进这一转变。让我们开始吧:
从神经网络的最低层开始,首先,我们将定义一个可分离的2D卷积层,如下所示:
class SepConv2d(nn.Module):
def __init__(self, input_ch, output_ch, kernel_length=3,
dilation_size=1, padding_size=1,
stride_length=1, bias_flag=True):
super(SepConv2d, self).__init__()
self.conv_layer = nn.Conv2d(
input_ch, input_ch, kernel_length,
stride_length, padding_size, dilation_size,
bias=bias_flag, groups=input_ch)
self.pointwise_layer = nn.Conv2d(
input_ch,output_ch, kernel_size=1, stride=1,
padding=0, dilation=1, groups=1, bias=bias_flag)
def forward(self, x):
return self.pointwise_layer(self.conv_layer(x))
可分离卷积层是常规3x3 2D卷积层之后的逐点1x1 2D卷积层的级联。
在定义了可分离的2D卷积层后,我们现在可以定义ReLU-Conv-BN三联体单元:
class UnitLayer(nn.Module):
def __init__(self, input_ch, output_ch, stride_length=1):
self.unit_layer = nn.Sequential(
nn.ReLU(), SepConv2d(input_ch, output_ch,
stride_length=stride_length),
nn.BatchNorm2d(output_ch),
nn.Dropout(self.dropout))
def forward(self, x):
return self.unit_layer(x)
如前所述,三联体单元是ReLU层、可分离的2D卷积层和批量归一化层的级联。我们还必须添加一个dropout层用于正则化。
有了三联体单元,我们现在可以定义图中的节点,并添加我们之前讨论的所有聚合、转换和分布功能:
class GraphNode(nn.Module):
def __init__(self, input_degree, input_ch,
output_ch, stride_length=1):
self.unit_layer = UnitLayer(
input_ch, output_ch,
stride_length=stride_length)
def forward(self, *ip):
if len(self.input_degree) > 1:
op = (ip[0] * torch.sigmoid(self.params[0]))
for idx in range(1, len(ip)):
op += (ip[idx] * torch.sigmoid(self.params[idx]))
return self.unit_layer(op)
else:
return self.unit_layer(ip[0])
在forward方法中,我们可以看到,如果进入节点的边数超过1,则计算加权平均值,这些权重是此节点的可学习参数。三联体单元应用于加权平均值,返回转换后的(ReLU-Conv-BN-ed)输出。
我们现在可以将所有图和图节点定义整合在一起,以定义一个随机连接的图类,如下所示:
class RandWireGraph(nn.Module):
def __init__(self, num_nodes, graph_prob, input_ch,
output_ch, train_mode, graph_name):
# get graph nodes and in edges
rnd_graph_node = RndGraph(self.num_nodes, self.graph_prob)
if self.train_mode is True:
rnd_graph = rnd_graph_node.make_graph_obj()
self.node_list, self.incoming_edge_list = \
rnd_graph_node.get_graph_config(rnd_graph)
else:
# define source Node
self.list_of_modules = nn.ModuleList(
[GraphNode(self.incoming_edge_list[0],
self.input_ch, self.output_ch,
stride_length=2)])
# define the sink Node
self.list_of_modules.extend(
[GraphNode(self.incoming_edge_list[n], self.output_ch,
self.output_ch)
for n in self.node_list if n > 0])
在这个类的__init__方法中,首先生成一个抽象的随机图,从中派生出节点和边的列表。使用GraphNode类,将此抽象随机图的每个抽象节点封装为所需神经网络的神经元。最后,添加一个源节点(输入节点)和一个汇节点(输出节点),使神经网络准备好用于图像分类任务。
forward方法也很不寻常,如下所示:
def forward(self, x):
# source vertex
op = self.list_of_modules[0].forward(x)
mem_dict[0] = op
# the rest of the vertices
for n in range(1, len(self.node_list) - 1):
if len(self.incoming_edge_list[n]) > 1:
op = self.list_of_modules[n].forward(
*[mem_dict[incoming_vtx]
for incoming_vtx
in self.incoming_edge_list[n]])
mem_dict[n] = op
for incoming_vtx in range(1, len(
self.incoming_edge_list[self.num_nodes + 1])):
op += \
mem_dict[self.incoming_edge_list
[self.num_nodes + 1][incoming_vtx]]
return op / len(self.incoming_edge_list[self.num_nodes + 1])
首先,源神经元执行一次前向传播,然后根据图的list_of_nodes为后续神经元执行一系列前向传播。各个前向传播是使用list_of_modules执行的。最后,通过汇神经元的前向传播为我们提供了此图的输出。
接下来,我们将使用这些定义的模块和随机连接的图类来构建实际的RandWireNN模型类。
将随机图转化为神经网络
在前面的步骤中,我们定义了一个随机连接的图。然而,正如我们在练习开始时提到的,随机连接神经网络由几个阶段的随机连接图组成。其背后的原理是,在图像分类任务中,当我们从输入神经元到输出神经元时,通道/特征的数量应不同(增加)。
如果只有一个随机连接的图,这是不可能的,因为在设计上,一个图中的通道数量是恒定的。让我们开始吧:
在这一步中,我们定义了终极的随机连接神经网络。它将有三个随机连接图级联在一起。与前一个图相比,每个图的通道数量都加倍,以帮助我们对齐图像分类任务中增加通道数量(同时空间下采样)的通行做法:
class RandWireNNModel(nn.Module):
def __init__(self, num_nodes, graph_prob,
input_ch, output_ch, train_mode):
self.conv_layer_1 = nn.Sequential(
nn.Conv2d(in_channels=3,
out_channels=self.output_ch,
kernel_size=3, padding=1),
nn.BatchNorm2d(self.output_ch))
self.conv_layer_2 = …
self.conv_layer_3 = …
self.conv_layer_4 = …
self.classifier_layer = nn.Sequential(
nn.Conv2d(in_channels=self.input_ch*8,
out_channels=1280, kernel_size=1),
nn.BatchNorm2d(1280))
self.output_layer = nn.Sequential(
nn.Dropout(self.dropout),
nn.Linear(1280, self.class_num))
__init__方法从一个常规的3x3卷积层开始,随后是三个级联的随机连接图,每个图的通道数量都加倍。之后是一个全连接层,它将最后一个随机连接图的最后一个神经元的卷积输出展平为一个1280大小的向量。
最后,另一个全连接层生成一个包含10个类别概率的10大小向量,如下所示:
def forward(self, x):
x = self.conv_layer_1(x)
x = self.conv_layer_2(x)
x = self.conv_layer_3(x)
x = self.conv_layer_4(x)
x = self.classifier_layer(x)
# global average pooling
_, _, h, w = x.size()
x = F.avg_pool2d(x, kernel_size=[h, w])
x = torch.squeeze(x)
x = self.output_layer(x)
return x
forward方法相当直观,除了在第一个全连接层后应用的全局平均池化。这有助于减少维度和网络中的参数数量。
在此阶段,我们已经成功地定义了RandWireNN模型,加载了数据集,并定义了模型训练例程。现在,我们已经准备好运行模型训练循环。
训练RandWireNN模型
在本节中,我们将设置模型的超参数并训练RandWireNN模型。让我们开始吧:
我们已经为练习定义了所有构建模块。现在是执行它的时候了。首先,让我们声明必要的超参数:
num_epochs = 5
graph_probability = 0.7
node_channel_count = 64
num_nodes = 16
lrate = 0.1
batch_size = 64
train_mode = True
声明了超参数后,我们实例化RandWireNN模型以及优化器和损失函数:
rand_wire_model = RandWireNNModel(
num_nodes, graph_probability, node_channel_count,
node_channel_count, train_mode).to(device)
optim_module = optim.SGD(
rand_wire_model.parameters(),
lr=lrate, weight_decay=1e-4, momentum=0.8)
loss_func = nn.CrossEntropyLoss().to(device)
最后,我们开始训练模型。这里我们将模型训练5个周期以示例,但鼓励你进行更长时间的训练以看到性能的提升:
for ep in range(1, num_epochs + 1):
epochs.append(ep)
training_loss, training_accuracy = train(
rand_wire_model, train_dataloader,
optim_module, loss_func, ep, lrate)
test_accuracy = accuracy(rand_wire_model, test_dataloader)
test_accuracies.append(test_accuracy)
training_losses.append(training_loss)
training_accuracies.append(training_accuracy)
if best_test_accuracy < test_accuracy:
torch.save(model_state, './model_checkpoint/' \
+ model_filename + 'ckpt.t7')
print("model train time: ", time.time() - start_time)
这将产生以下输出:
epoch 1, loss: 1.9863920211791992, accuracy: 25.0
epoch 1, loss: 1.7622356414794922, accuracy: 31.25
epoch 1, loss: 1.6300958395004272, accuracy: 35.9375
...
epoch 5, loss: 1.042513132095337, accuracy: 62.5
test acc: 68.60%, best test acc: 63.73%
model train time: 14912.663238048553
从这些日志中可以明显看出,随着训练的推进,模型在不断学习。验证集的性能似乎在不断增加,这表明了模型的泛化能力。
通过这一过程,我们创建了一个没有特定架构设计在内,但能合理地在CIFAR-10数据集上执行图像分类任务的模型。
评估和可视化RandWireNN模型
最后,我们将在测试集上查看此模型的性能,然后简要探索模型架构。让我们开始吧:
模型训练完成后,我们可以在测试集上评估它:
rand_wire_nn_model.load_state_dict(model_checkpoint['model'])
for test_data, test_label in test_dataloader:
success += pred.eq(test_label.data).sum()
print(f"test accuracy: {float(success) * 100. / len(
test_dataloader.dataset)} %")
这将产生以下输出:
best model accuracy: 68.6%, last epoch: 5
test accuracy: 68.6 %
最佳性能模型在第四个周期时达到,其准确率超过67%。尽管模型尚未达到完美,但我们可以通过更多的训练周期来实现更好的性能。另外,对于这个任务,一个随机模型的准确率将达到10%(因为10个类别的可能性是相等的),所以67.73%的准确率还是很有前途的,尤其考虑到我们使用的是随机生成的神经网络架构。
为了结束这个练习,让我们看看学到的模型架构。由于原始图像太大,无法在这里显示。你可以在我们的GitHub仓库中找到完整的图像,格式为.svg和.pdf。在下图中,我们垂直堆叠了三个部分——输入部分、中间部分和原始神经网络的输出部分:
从这张图中,我们可以观察到以下几个关键点:
- 顶部部分展示了神经网络的开始部分,首先是一个64通道的3x3二维卷积层,然后是一个64通道的1x1点卷积层。
- 中间部分展示了第三阶段和第四阶段随机图之间的过渡,可以看到第三阶段随机图的汇聚神经元
conv_layer_3,紧接着是第四阶段随机图的源神经元conv_layer_4。 - 最下方部分展示了最终的输出层——第四阶段随机图的汇聚神经元(一个512通道的可分离二维卷积层),接着是一个全连接的平坦层,生成一个1280大小的特征向量,最后是一个全连接的Softmax层,输出10个类别的概率。
因此,我们构建、训练、测试并可视化了一个用于图像分类的神经网络模型,而无需指定任何特定的模型架构。我们确实为结构指定了一些总体约束,如倒数第二个特征向量的长度(1280)、可分离二维卷积层的通道数(64)、RandWireNN模型中的阶段数量(4)、每个神经元的定义(ReLU-Conv-BN三重组合)等。
然而,我们并没有明确指定这个神经网络架构的结构应是什么样子。我们使用了一个随机图生成器来为我们执行此操作,这在找到最佳神经网络架构方面打开了几乎无限的可能性。
神经网络架构搜索是深度学习领域中一个持续且有前途的研究方向。总体上,它很好地适应了为特定任务训练定制机器学习模型的领域,这通常被称为自动化机器学习(AutoML)。
AutoML代表自动化机器学习,它消除了手动加载数据集、预定义特定神经网络模型架构来解决给定任务并手动部署模型到生产系统中的必要性。在第16章《PyTorch与AutoML》中,我们将详细讨论AutoML,并学习如何使用PyTorch构建此类系统。
总结
在本章中,我们研究了两种不同类型的混合神经网络。首先,我们探讨了Transformer模型——这种仅基于注意力机制且没有循环连接的模型,在多个序列任务上超过了所有循环模型的表现。我们通过一个练习,使用PyTorch在Penn Treebank数据集上构建、训练和评估了一个Transformer模型,完成了语言建模任务。在本章的第二部分和最后部分,我们接续了第2章《深度CNN架构》中讨论的内容,即优化模型架构,而不仅仅是优化固定架构下的模型参数。我们探讨了一种实现该目标的方法——使用随机连接的神经网络(RandWireNN),在其中生成随机图,为这些图的节点和边赋予含义,并将这些图互相连接,形成一个神经网络。
说到图,在下一章中,我们将学习另一类可以从图数据集中学习的神经网络——图神经网络。我们将使用PyTorch解决一个图数据集上的分类问题。