PyTorch-1-x-深度学习指南第二版-二-

56 阅读1小时+

PyTorch 1.x 深度学习指南第二版(二)

原文:zh.annas-archive.org/md5/3913e248efb5ce909089bb46b2125c26

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:序列数据的自然语言处理

在本章中,我们将看到不同的文本数据表示形式,这些形式对构建深度学习模型非常有用。我们将帮助您理解循环神经网络RNNs)。本章将涵盖不同的 RNN 实现,如长短期记忆LSTM)和门控循环单元GRU),它们支持大多数文本和序列数据的深度学习模型。我们将研究文本数据的不同表示及其对构建深度学习模型的用处。此外,本章还将讨论可用于序列数据的一维卷积。

可以使用 RNN 构建的一些应用程序包括:

  • 文档分类器:识别推文或评论的情感,分类新闻文章

  • 序列到序列学习:用于诸如语言翻译、将英语转换为法语等任务

  • 时间序列预测:根据前几天的销售记录预测商店的销售情况

本章将涵盖以下主题:

  • 处理文本数据

  • 通过构建情感分类器训练嵌入

  • 使用预训练的词嵌入

  • 递归神经网络

  • 使用 LSTM 解决文本分类问题

  • 序列数据上的卷积网络

  • 语言建模

处理文本数据

文本是最常用的序列数据类型之一。文本数据可以看作是字符序列或单词序列。对于大多数问题,将文本视为单词序列是很常见的。深度学习的顺序模型,如 RNN 及其变种,能够从文本数据中学习重要的模式,以解决以下领域的问题:

  • 自然语言理解

  • 文档分类

  • 情感分类

这些顺序模型也是各种系统的重要构建块,例如问答QA)系统。

尽管这些模型在构建这些应用程序中非常有用,但由于其固有的复杂性,它们并不理解人类语言。这些顺序模型能够成功地发现有用的模式,然后用于执行不同的任务。将深度学习应用于文本是一个快速增长的领域,每个月都会出现许多新技术。我们将涵盖大多数现代深度学习应用程序的基本组件。

深度学习模型与其他机器学习模型一样,并不理解文本,因此我们需要将文本转换为数值表示。将文本转换为数值表示的过程称为向量化,可以通过以下不同方式完成:

  • 将文本转换为单词,并将每个单词表示为向量

  • 将文本转换为字符,并将每个字符表示为向量

  • 创建单词的 n-gram,并将它们表示为向量

文本数据可以被分解为这些表示之一。文本的每个较小单元称为标记,将文本分解为标记的过程称为分词。Python 中有许多强大的库可以帮助我们进行分词。一旦我们将文本数据转换为标记,我们接下来需要将每个标记映射到一个向量。独热编码和词嵌入是将标记映射到向量的两种最流行的方法。以下图表总结了将文本转换为向量表示的步骤:

让我们更详细地了解分词、n-gram 表示和向量化。

分词

给定一个句子,将其分割为字符或单词称为分词。有一些库,比如 spaCy,提供了复杂的分词解决方案。让我们使用简单的 Python 函数如splitlist来将文本转换为标记。

为了演示分词在字符和单词上的工作方式,让我们考虑一部电影Toy Story的简短评论。我们将使用以下文本:

Just perfect. Script, character, animation....this manages to break free of the yoke of 'children's movie' to simply be one of the best movies of the 90's, full-stop.

将文本转换为字符

Python 的list函数接受一个字符串并将其转换为单个字符的列表。这完成了将文本转换为字符的任务。以下代码块展示了所使用的代码及其结果:

toy_story_review = "Just perfect. Script, character, animation....this manages to break free of the yoke of 'children's movie' to simply be one of the best movies of the 90's, full-stop."

print(list(toy_story_review))

结果如下:

['J', 'u', 's', 't', ' ', 'p', 'e', 'r', 'f', 'e', 'c', 't', '.', ' ', 'S', 'c', 'r', 'i', 'p', 't', ',', ' ', 'c', 'h', 'a', 'r', 'a', 'c', 't', 'e', 'r', ',', ' ', 'a', 'n', 'i', 'm', 'a', 't', 'i', 'o', 'n', '.', '.', '.', '.', 't', 'h', 'i', 's', ' ', 'm', 'a', 'n', 'a', 'g', 'e', 's', ' ', 't', 'o', ' ', 'b', 'r', 'e', 'a', 'k', ' ', 'f', 'r', 'e', 'e', ' ', 'o', 'f', ' ', 't', 'h', 'e', ' ', 'y', 'o', 'k', 'e', ' ', 'o', 'f', ' ', "'", 'c', 'h', 'i', 'l', 'd', 'r', 'e', 'n', "'", 's', ' ', 'm', 'o', 'v', 'i', 'e', "'", ' ', 't', 'o', ' ', 's', 'i', 'm', 'p', 'l', 'y', ' ', 'b', 'e', ' ', 'o', 'n', 'e', ' ', 'o', 'f', ' ', 't', 'h', 'e', ' ', 'b', 'e', 's', 't', ' ', 'm', 'o', 'v', 'i', 'e', 's', ' ', 'o', 'f', ' ', 't', 'h', 'e', ' ', '9', '0', "'", 's', ',', ' ', 'f', 'u', 'l', 'l', '-', 's', 't', 'o', 'p', '.']

这个结果展示了我们简单的 Python 函数如何将文本转换为标记。

将文本转换为单词

我们将使用 Python 字符串对象中提供的split函数来将文本分割成单词。split函数接受一个参数,基于这个参数将文本分割成标记。在我们的示例中,我们将使用空格作为分隔符。以下代码块演示了如何使用 Python 的split函数将文本转换为单词:

print(list(toy_story_review.split()))

这将产生以下输出:

['Just', 'perfect.', 'Script,', 'character,', 'animation....this', 'manages', 'to', 'break', 'free', 'of', 'the', 'yoke', 'of', "'children's", "movie'", 'to', 'simply', 'be', 'one', 'of', 'the', 'best', 'movies', 'of', 'the', "90's,", 'full-stop.']

在上述代码中,我们没有使用任何分隔符;默认情况下,split函数在空格上分割。

N-gram 表示

我们看到文本如何表示为字符和单词。有时,查看两个、三个或更多单词一起的情况非常有用。N-grams是从给定文本中提取的单词组。在一个 n-gram 中,n表示可以一起使用的单词数。让我们看一个双字母词(n=2)的例子。我们使用 Python 的nltk包为toy_story_review生成了一个双字母词。以下代码块展示了双字母词的结果以及生成它的代码:

from nltk import ngrams 
print(list(ngrams(toy_story_review.split(),2)))

这将产生以下输出:

[('Just', 'perfect.'), ('perfect.', 'Script,'), ('Script,', 'character,'), ('character,', 'animation....this'), ('animation....this', 'manages'), ('manages', 'to'), ('to', 'break'), ('break', 'free'), ('free', 'of'), ('of', 'the'), ('the', 'yoke'), ('yoke', 'of'), ('of', "'children's"), ("'children's", "movie'"), ("movie'", 'to'), ('to', 'simply'), ('simply', 'be'), ('be', 'one'), ('one', 'of'), ('of', 'the'), ('the', 'best'), ('best', 'movies'), ('movies', 'of'), ('of', 'the'), ('the', "90's,"), ("90's,", 'full-stop.')]

ngrams函数接受一个单词序列作为其第一个参数,以及要分组的单词数作为第二个参数。以下代码块展示了三元组表示的样例以及用于生成它的代码:

print(list(ngrams(toy_story_review.split(),3)))

这将产生以下输出:

[('Just', 'perfect.', 'Script,'), ('perfect.', 'Script,', 'character,'), ('Script,', 'character,', 'animation....this'), ('character,', 'animation....this', 'manages'), ('animation....this', 'manages', 'to'), ('manages', 'to', 'break'), ('to', 'break', 'free'), ('break', 'free', 'of'), ('free', 'of', 'the'), ('of', 'the', 'yoke'), ('the', 'yoke', 'of'), ('yoke', 'of', "'children's"), ('of', "'children's", "movie'"), ("'children's", "movie'", 'to'), ("movie'", 'to', 'simply'), ('to', 'simply', 'be'), ('simply', 'be', 'one'), ('be', 'one', 'of'), ('one', 'of', 'the'), ('of', 'the', 'best'), ('the', 'best', 'movies'), ('best', 'movies', 'of'), ('movies', 'of', 'the'), ('of', 'the', "90's,"), ('the', "90's,", 'full-stop.')]

在上述代码中唯一改变的是n值,即函数的第二个参数。

许多监督学习机器学习模型,例如朴素贝叶斯,使用 n-gram 来改进其特征空间。n-gram 也用于拼写校正和文本摘要任务。

n-gram 表示的一个挑战是失去文本的顺序性质。它通常与浅层机器学习模型一起使用。这种技术在深度学习中很少使用,因为像 RNN 和 Conv1D 这样的架构会自动学习这些表示。

向量化

有两种常用方法将生成的标记映射到数字向量,称为 one-hot 编码和词嵌入。让我们通过编写一个简单的 Python 程序来理解如何将标记转换为这些向量表示。我们还将讨论每种方法的各种优缺点。

One-hot 编码

在 one-hot 编码中,每个标记由长度为 N 的向量表示,其中 N 是词汇表的大小。词汇表是文档中唯一单词的总数。让我们以一个简单的句子为例,观察每个标记如何表示为 one-hot 编码向量。以下是句子及其相关标记表示:

每天一个苹果,医生远离我说道医生。

以前面的句子为例,其 one-hot 编码可以表示为以下表格格式:

An100000000
apple10000000
a1000000
day100000
keeps10000
doctor1000
away100
said10
the1

该表格描述了标记及其 one-hot 编码表示。向量长度为九,因为句子中有九个唯一单词。许多机器学习库已经简化了创建 one-hot 编码变量的过程。我们将编写自己的实现以便更容易理解,并可以使用相同的实现来构建后续示例所需的其他特性。以下代码包含一个Dictionary类,其中包含创建唯一单词字典的功能,以及返回特定单词的 one-hot 编码向量的函数。让我们看一下代码,然后逐个功能进行解释:

class Dictionary(object): 
    def init (self):
        self.word2index = {} 
        self.index2word = [] 
        self.length = 0

    def add_word(self,word):
        if word not in self.index2word: 
            self.indexword.append(word) 
            self.word2index[word] = self.length + 1 
            self.length += 1
        return self.word2index[word] 

    def len (self):
        return len(self.index2word) 

    def onehot_encoded(self,word):
        vec = np.zeros(self.length)
        vec[self.word2index[word]] = 1 
        return vec

上述代码提供了三个重要功能:

  • 初始化函数 init 创建一个 word2index 字典,它将存储所有唯一单词及其索引。index2word 列表存储所有唯一单词,length 变量包含我们文档中的唯一单词总数。

  • add_word 函数接收一个单词并将其添加到 word2indexindex2word 中,并增加词汇表的长度,前提是该单词是唯一的。

  • onehot_encoded 函数接收一个单词并返回一个长度为 N 的向量,全零,除了单词的索引处为一。如果传递的单词索引为二,则向量在索引为二处的值为一,其余所有值为零。

当我们定义了我们的Dictionary类后,让我们在我们的toy_story_review数据上使用它。下面的代码演示了如何构建word2index字典以及如何调用我们的onehot_encoded函数:

dic = Dictionary()

for tok in toy_story_review.split(): dic.add_word(tok)
print(dic.word2index)

使用单热表示的一个挑战是数据过于稀疏,而且随着词汇表中唯一单词数量的增加,向量的大小会迅速增长。另一个局限是单热没有表现词语之间的内部关系。因此,单热表示在深度学习中很少使用。

单词嵌入

单词嵌入是在由深度学习算法解决的问题中代表文本数据的一种非常流行的方式。单词嵌入提供了填充有浮点数的单词的密集表示。向量维度根据词汇量的大小而变化。在训练阶段通常使用维度大小为 50、100、256、300 或者有时 1000 的单词嵌入。维度大小是一个我们需要在训练阶段调整的超参数。

如果我们尝试用单热表示表示大小为 20,000 的词汇表,那么我们最终会得到 20,000 x 20,000 个数字,其中大多数将为零。相同的词汇表可以用 20,000 x 维度大小的单词嵌入表示,其中维度大小可以是 10、50、300 等等。

创建单词嵌入的一种方法是为每个标记开始时使用随机数生成密集向量,然后训练一个模型,如文档分类器,用于情感分类。代表标记的浮点数将被调整,以便语义上更接近的单词具有类似的表示。为了理解它,让我们看下面的图表,我们在其中绘制了五部电影的二维图上的单词嵌入向量:

前面的图表显示了如何调整密集向量,以便使语义上相似的单词之间的距离更小。由于电影标题如寻找尼莫玩具总动员超人特工队都是虚构的卡通电影,因此这些单词的嵌入更接近。另一方面,电影泰坦尼克号的嵌入远离卡通片,更接近电影恋恋笔记本,因为它们都是浪漫电影。

当你的数据量太少时,学习单词嵌入可能不可行,在这些情况下,我们可以使用由其他机器学习算法训练的单词嵌入。从其他任务生成的嵌入称为预训练单词嵌入。我们将学习如何构建自己的单词嵌入并使用预训练单词嵌入。

通过构建情感分类器来训练单词嵌入

在上一节中,我们简要介绍了词嵌入而没有实现它。在本节中,我们将下载一个名为 IMDb 的数据集,其中包含评论,并构建一个情感分类器,计算评论情感是积极的、消极的还是未知的。在构建过程中,我们还将对 IMDb 数据集中出现的单词进行词嵌入训练。

我们将使用一个名为 torchtext 的库,它使得许多过程(如下载、文本向量化和批处理)更加简单。训练情感分类器将涉及以下步骤:

  1. 下载 IMDb 数据并进行文本标记化

  2. 构建词汇表

  3. 生成向量批次

  4. 使用嵌入创建网络模型

  5. 训练模型

我们将在接下来的几节中详细介绍这些步骤。

下载 IMDb 数据并进行文本标记化

对于与计算机视觉相关的应用程序,我们使用了 torchvision 库,它为我们提供了许多实用函数,帮助构建计算机视觉应用程序。同样,还有一个名为 torchtext 的库,它专为与 PyTorch 一起工作而构建,通过提供不同的文本数据加载器和抽象来简化与自然语言处理NLP)相关的许多活动。在撰写本文时,torchtext 并不随标准的 PyTorch 安装一起提供,需要单独安装。您可以在机器的命令行中运行以下代码来安装 torchtext

pip install torchtext

一旦安装完成,我们将能够使用它。 torchtext 下载提供了两个重要的模块,称为 torchtext.datatorchtext.datasets

我们可以从以下链接下载 IMDb 电影数据集:

grouplens.org/datasets/movielens/

使用 torchtext.data 进行标记化

torchtext.data 实例定义了一个 Field 类,它帮助我们定义数据的读取和标记化方式。让我们看下面的例子,我们将用它来准备我们的 IMDb 数据集:

from torchtext import data
text = data.Field(lower=True, batch_first=True,fix_length=20) 
label = data.Field(sequential=False)

在上面的代码中,我们定义了两个 Field 对象,一个用于实际文本,另一个用于标签数据。对于实际文本,我们希望 torchtext 将所有文本转换为小写,标记化文本并将其修剪到最大长度为 20。如果我们正在构建用于生产环境的应用程序,可能会将长度固定为更大的数字。但是,对于示例,这样做效果很好。Field 构造函数还接受另一个名为 tokenize 的参数,默认使用 str.split 函数。我们也可以指定 spaCy 或任何其他分词器作为参数。对于我们的示例,我们将坚持使用 str.split

使用 torchtext.datasets 进行标记化

torchtext.datasets实例提供了使用不同数据集的包装器,如 IMDb、TREC(问题分类)、语言建模(WikiText-2)和其他几个数据集。我们将使用torch.datasets下载 IMDb 数据集并将其分割为训练集和测试集。以下代码执行此操作,第一次运行时可能需要几分钟(取决于您的宽带连接),因为它从互联网下载 IMDb 数据集:

train, test = datasets.IMDB.splits(text, label)

先前数据集的IMDB类抽象了下载、分词和将数据库分为训练集和测试集的所有复杂性。train.fields下载包含一个字典,其中TEXT是键,LABEL是值。让我们看看train.fields及其每个元素train包含的内容:

print('train.fields', train.fields)

这导致以下输出:

#Results
train.fields {'text': <torchtext.data.field.Field object at 0x1129db160>, 'label': <torchtext.data.field.Field object at 0x1129db1d0>}

类似地,训练数据集的方差如下:

print(vars(train[0]))

这导致以下输出:

#Results
vars(train[0]) {'text': ['for', 'a', 'movie', 'that', 'gets', 'no', 'respect', 'there', 'sure', 'are', 'a', 'lot', 'of', 'memorable', 'quotes', 'listed', 'for', 'this', 'gem.', 'imagine', 'a', 'movie', 'where', 'joe', 'piscopo', 'is', 'actually', 'funny!', 'maureen', 'stapleton', 'is', 'a', 'scene', 'stealer.', 'the', 'moroni', 'character', 'is', 'an', 'absolute', 'scream.', 'watch', 'for', 'alan', '"the', 'skipper"', 'hale', 'jr.', 'as', 'a', 'police', 'sgt.'], 'label': 'pos'}

我们可以从这些结果中看到,单个元素包含一个字段和文本,以及表示文本的所有标记,以及一个包含文本标签的label字段。现在我们已经准备好进行 IMDb 数据集的批处理。

构建词汇表

当我们为toy_story_review创建了一种一热编码时,我们创建了一个word2index字典,它被称为词汇表,因为它包含了文档中唯一单词的所有详细信息。torchtext实例使这一切变得更加容易。一旦数据加载完成,我们可以调用build_vocab并传递必要的参数来构建数据的词汇表。以下代码展示了词汇表是如何构建的:

text.build_vocab(train, vectors=GloVe(name='6B', dim=300),max_size=10000,min_freq=10)
label.build_vocab(train)

在上述代码中,我们传递了需要构建词汇表的train对象,并要求它使用维度为300的预训练嵌入来初始化向量。build_vocab对象只是下载并创建稍后在使用预训练权重训练情感分类器时将使用的维度。max_size实例限制了词汇表中单词的数量,而min_freq删除了任何出现次数不到 10 次的单词,其中10是可配置的。

一旦词汇表建立完成,我们可以获取不同的值,如频率、词索引和每个单词的向量表示。以下代码演示了如何访问这些值:

print(text.vocab.freqs)

这导致以下输出:

# A sample result 
Counter({"i'm": 4174,
         'not': 28597,
         'tired': 328,
         'to': 133967,
         'say': 4392,
         'this': 69714,
         'is': 104171,
         'one': 22480,
         'of': 144462,
         'the': 322198,

以下代码演示了如何访问结果:

print(text.vocab.vectors)

这导致以下输出:

#Results displaying the 300 dimension vector for each word.
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
0.0466 0.2132 -0.0074 ... 0.0091 -0.2099 0.0539
 ... ... ... 
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
0.7724 -0.1800 0.2072 ... 0.6736 0.2263 -0.2919
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
[torch.FloatTensor of size 10002x300]

类似地,我们将打印包含单词及其索引的字典的值如下:

print(TEXT.vocab.stoi)

这导致以下输出:

# Sample results
defaultdict(<function torchtext.vocab._default_unk_index>,
 {'<unk>': 0,
 '<pad>': 1,
 'the': 2,
 'a': 3,
 'and': 4,
 'of': 5,
 'to': 6,
 'is': 7,
 'in': 8,
 'i': 9,
 'this': 10,
 'that': 11,
 'it': 12,

stoi值提供了访问包含单词及其索引的字典。

生成向量的批次

torchtext下载提供了BucketIterator,它有助于批处理所有文本并用单词的索引号替换单词。BucketIterator实例带有许多有用的参数,例如batch_sizedevice(GPU 或 CPU)和shuffle(数据是否需要洗牌)。以下代码演示了如何创建为训练和测试数据集生成批次的迭代器:

train_iter, test_iter = data.BucketIterator.splits((train, test), batch_size=128, device=-1,shuffle=True)
#device = -1 represents cpu , if you want gpu leave it to None.

前面的代码为训练和测试数据集提供了一个BucketIterator对象。下面的代码展示了如何创建一个批次并显示批次的结果:

batch = next(iter(train_iter)) batch.text

这导致以下输出:

#Results
Variable containing:
 5128 427 19 ... 1688 0 542
 58 2 0 ... 2 0 1352
 0 9 14 ... 2676 96 9
 ... ... ... 
 129 1181 648 ... 45 0 2
 6484 0 627 ... 381 5 2
 748 0 5052 ... 18 6660 9827
[torch.LongTensor of size 128x20]

我们将按以下方式打印标签:

batch.label

这导致以下输出:

#Results
Variable containing:
 2
 1
 2
 1
 2
 1
 1
 1
[torch.LongTensor of size 128]

根据前面代码块的结果,我们可以看到文本数据如何转换为大小为(batch_size * fix_len)的矩阵,即(128 x 20)。

创建一个带有嵌入的网络模型

我们之前简要讨论了词嵌入。在本节中,我们将词嵌入作为网络架构的一部分创建,并训练整个模型以预测每个评论的情感。在训练结束时,我们将得到一个情感分类器模型以及 IMDB 数据集的词嵌入。以下代码演示了如何创建一个网络架构来使用词嵌入来预测情感:

class EmbeddingNetwork(nn.Module):
    def init(self,emb_size,hidden_size1,hidden_size2=400): 
        super().  init ()
        self.embedding = nn.Embedding(emb_size,hidden_size1) 
        self.fc = nn.Linear(hidden_size2,3)
    def forward(self,x):
        embeds = self.embedding(x).view(x.size(0),-1) 
        out = self.fc(embeds)
        return F.log_softmax(out,dim=-1)

在上述代码中,EmbeddingNetwork创建了情感分类的模型。在_init_函数内部,我们初始化了nn.Embedding类的一个对象,它接受两个参数,即词汇量的大小和我们希望为每个单词创建的维度。由于我们限制了唯一单词的数量,词汇量大小将为 10,000,并且我们可以从一个小的嵌入大小 10 开始。在快速运行程序时,使用小的嵌入大小是有用的,但是当您尝试构建用于生产系统的应用程序时,请使用较大的嵌入。我们还有一个线性层,将单词嵌入映射到类别(积极、消极或未知)。

前向函数确定如何处理输入数据。对于批次大小为 32 和句子最大长度为 20 个单词,我们将会得到形状为 32 x 20 的输入。第一个嵌入层充当查找表,用相应的嵌入向量替换每个单词。对于嵌入维度为 10,输出变为 32 x 20 x 10,因为每个单词被其对应的嵌入所替换。view()函数将会展平来自嵌入层的结果。传递给 view 的第一个参数将保持该维度不变。

在我们的情况下,我们不希望将来自不同批次的数据合并,因此我们保留第一维并展平张量中的其余值。应用view()函数后,张量形状变为 32 x 200。一个密集层将展平的嵌入映射到类别的数量。一旦网络定义好了,我们可以像往常一样训练网络。

请记住,在这个网络中,我们失去了文本的顺序性,我们只是将文本视为一袋词语。

训练模型

训练模型非常类似于我们构建图像分类器时所看到的,因此我们将使用相同的函数。我们通过模型传递数据批次,计算输出和损失,然后优化模型权重,包括嵌入权重。以下代码执行此操作:

def fit(epoch,model,data_loader,phase='training',volatile=False): 
    if phase == 'training':
        model.train()
    if phase == 'validation': 
        model.evaluation() 
volatile=True
running_loss = 0.0
running_correct = 0

现在我们对数据集进行迭代:

for batch_idx , batch in enumerate(data_loader):
    text, target = batch.text , batch.label 
    if is_cuda:
        text,target = text.cuda(),target.cuda() 
    if phase == 'training':
        optimizer.zero_grad() 
        output = model(text)
    loss = F.nll_loss(output,target) 
    running_loss += F.nll_loss(output,target,size_average=False).data[0] 
    predictions = output.data.max(dim=1,keepdim=True)[1]
    running_correct += predictions.eq(target.data.view_as(predictions)).cpu().sum() 
    if phase == 'training':
        loss.backward() 
        optimizer.step()
        loss = running_loss/len(data_loader.dataset)
        accuracy = 100\. * running_correct/len(data_loader.dataset) 
        print(f'{phase} loss is {loss:{5}.{2}} and {phase} accuracy is {running_correct}/{len(data_loader.dataset)}{accuracy:{10}.{4}}').format(loss,accuracy)

从这里开始,我们可以在每个 epoch 上训练模型:

train_losses , train_accuracy = [],[] 
validation_losses , validation_accuracy = [],[]

train_iter.repeat = False
test_iter.repeat = False
for epoch in range(1,10): 
    epoch_loss, epoch_accuracy = fit(epoch,model,train_iter,phase='training')
    validation_epoch_loss, validation_epoch_accuracy = fit(epoch,model,test_iter,phase='validation')
    train_losses.append(epoch_loss) 
    train_accuracy.append(epoch_accuracy) 
    validation_losses.append(validation_epoch_loss) 
    validation_accuracy.append(validation_epoch_accuracy)

在上述代码中,我们通过传递用于批处理数据的BucketIterator对象来调用fit方法。默认情况下,迭代器不会停止生成批次,因此我们必须将BucketIterator对象的 repeat 变量设置为False。如果不将 repeat 变量设置为False,那么fit函数将无限运行。在大约 10 个 epoch 的训练后,模型达到了约 70%的验证准确率。现在您已经学会了通过构建情感分类器训练词嵌入,让我们在下一节中学习如何使用预训练词嵌入。

使用预训练词嵌入

预训练词嵌入在我们工作于特定领域时非常有用,例如医学和制造业,在这些领域中我们有大量数据可以用来训练嵌入。当我们的数据很少且不能有意义地训练嵌入时,我们可以使用在不同数据语料库(如维基百科、Google 新闻和 Twitter 推文)上训练的嵌入。许多团队都有使用不同方法训练的开源词嵌入。在本节中,我们将探讨torchtext如何使使用不同词嵌入更加容易,以及如何在我们的 PyTorch 模型中使用它们。这类似于在计算机视觉应用中使用的迁移学习。通常,使用预训练嵌入涉及以下步骤:

  1. 下载嵌入

  2. 加载模型中的嵌入

  3. 冻结嵌入层权重

让我们详细探讨每个步骤的实现方式。

下载嵌入

torchtext库在下载嵌入和将其映射到正确单词时,抽象掉了许多复杂性。torchtext库在vocab模块中提供了三个类,即 GloVe、FastText、CharNGram,它们简化了下载嵌入和映射到我们词汇表的过程。每个类都提供了在不同数据集上训练的不同嵌入,并使用不同技术。让我们看一些不同的提供的嵌入:

  • charngram.100d

  • fasttext.en.300d

  • fasttext.simple.300d

  • glove.42B.300d

  • glove.840B.300d

  • glove.twitter.27B.25d

  • glove.twitter.27B.50d

  • glove.twitter.27B.100d

  • `glove.twitter.27B.200d`

  • glove.6B.50d

  • glove.6B.100d

  • glove.6B.200d

  • glove.6B.300d

Field对象的build_vocab方法接受一个用于嵌入的参数。以下代码解释了如何下载嵌入:

from torchtext.vocab import GloVe 
TEXT.build_vocab(train, vectors=GloVe(name='6B', dim=300),max_size=10000,min_freq=10) 
LABEL.build_vocab(train,)

参数向量的值表示要使用的嵌入类。namedim 参数确定可以使用哪些嵌入。我们可以轻松地从 vocab 对象中访问嵌入。下面的代码演示了它,以及结果将如何显示:

TEXT.vocab.vectors

这导致以下输出:

#Output
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
0.0466 0.2132 -0.0074 ... 0.0091 -0.2099 0.0539
... ...  ...

[torch.FloatTensor of size 10002x300]

现在我们已经下载并将嵌入映射到我们的词汇表中。让我们了解如何在 PyTorch 模型中使用它们。

在模型中加载嵌入

vectors 变量返回一个形状为 vocab_size x dimensions 的 torch 张量,其中包含预训练的嵌入。我们必须将嵌入分配给我们嵌入层的权重。我们可以通过访问嵌入层的权重来赋值嵌入的权重,如以下代码所示:

model.embedding.weight.data = TEXT.vocab.vectors

model 下载代表我们网络的对象,embedding 代表嵌入层。由于我们使用了新维度的嵌入层,线性层的输入将会有所变化。下面的代码展示了新的架构,与我们之前训练嵌入时使用的架构类似:

class EmbeddingNetwork(nn.Module):
def   init (self,embedding_size,hidden_size1,hidden_size2=400): super().  init ()
self.embedding = nn.Embedding(embedding_size,hidden_size1) self.fc1 = nn.Linear(hidden_size2,3)

def forward(self,x):
embeds = self.embedding(x).view(x.size(0),-1) out = self.fc1(embeddings)
return F.log_softmax(out,dim=-1)

model = EmbeddingNetwork(len(TEXT.vocab.stoi),300,12000)

一旦加载了嵌入,我们必须确保在训练期间不改变嵌入权重。让我们讨论如何实现这一点。

冻结嵌入层的权重

要告诉 PyTorch 不要改变嵌入层的权重是一个两步过程:

  1. requires_grad 属性设置为 False,这告诉 PyTorch 不需要这些权重的梯度。

  2. 删除嵌入层参数传递给优化器。如果不执行此步骤,则优化器会抛出错误,因为它期望所有参数都有梯度。

下面的代码演示了冻结嵌入层权重以及指导优化器不使用这些参数是多么简单:

model.embedding.weight.requires_grad = False
optimizer = optim.SGD([ param for param in model.parameters() if param.requires_grad == True],lr=0.001)

我们通常将所有模型参数传递给优化器,但在之前的代码中,我们传递了 requires_gradTrue 的参数。

我们可以使用这段代码训练模型,并且应该达到类似的准确性。所有这些模型架构都未能充分利用文本的顺序特性。在下一节中,我们探讨了两种流行的技术,即 RNN 和 Conv1D,它们利用了数据的顺序特性。

递归神经网络

RNNs 是我们能够应对分类、序列数据标记、生成文本序列(例如 SwiftKey 键盘应用程序,预测下一个单词)、以及将一种序列转换为另一种序列(比如从法语翻译成英语)等应用中最强大的模型之一。大多数模型架构,如前馈神经网络,并不利用数据的序列性质。例如,我们需要数据来呈现每个示例的特征,以向量形式表示,比如表示句子、段落或文档的所有标记。前馈网络仅设计为一次性查看所有特征并将其映射到输出。让我们看一个文本示例,说明为什么文本的顺序或序列性质很重要。I had cleaned my carI had my car cleaned 是两个英文句子,它们有相同的单词集合,但考虑单词顺序时它们的含义不同。

在大多数现代语言中,人类通过从左到右阅读单词并构建一个强大的模型来理解文本数据的各种内容。RNN 类似地通过逐次查看文本中的每个单词来工作。RNN 也是一种神经网络,其中有一个特殊的层,该层循环处理数据而不是一次性处理所有数据。由于 RNN 可以处理序列数据,我们可以使用不同长度的向量并生成不同长度的输出。以下图表提供了一些不同的表示方式:

前面的图是来自有关 RNN 的著名博客之一(karpathy.github. io/2015/05/21/rnn-effectiveness),作者 Andrej Karpathy 在其中讲述了如何使用 Python 从零开始构建 RNN 并将其用作序列生成器。

通过示例理解 RNN 的工作方式

让我们假设我们已经构建了一个 RNN 模型,并试图理解它提供的功能。一旦我们理解了 RNN 的功能,我们再探讨 RNN 内部发生的事情。

让我们将 Toy Story 的评论作为 RNN 模型的输入。我们正在查看的示例文本是 Just perfect. Script, character, animation....this manages to break free....。我们从将第一个单词 just 传递给我们的模型开始,模型生成两种不同的东西:一个状态向量和一个输出向量。状态向量在模型处理评论中的下一个单词时被传递,并生成一个新的状态向量。我们只考虑模型在最后一个序列期间生成的输出。以下图表总结了这一点:

上述图示演示了以下内容:

  • 通过展开文本输入和图像来理解 RNN 的工作方式

  • 状态如何递归地传递给同一个模型

到目前为止,您已经对 RNN 的工作有了一定了解,但不知道其具体工作原理。在我们深入研究其工作原理之前,让我们看一下展示我们所学内容更详细的代码片段。我们仍将视 RNN 为一个黑箱:

rnn = RNN(input_size, hidden_size,output_size) 
for i in range(len(toy_story_review):
        output, hidden = rnn(toy_story_review[i], hidden)

在上述代码中,hidden 变量表示状态向量,有时称为隐藏状态。现在,我们应该对 RNN 的使用有所了解了。接下来,让我们看一下实现 RNN 并理解 RNN 内部发生了什么的代码。以下代码包含 RNN 类:

import torch.nn as nn
from torch.autograd import Variable

class RNN(nn.Module):
    def   init (self, input_size, hidden_size, output_size): 
        super(RNN, self). init ()
        self.hidden_size = hidden_size
        self.i2h = nn.Linear(input_size + hidden_size, hidden_size) 
        self.i2o = nn.Linear(input_size + hidden_size, output_size) 
        self.softmax = nn.LogSoftmax(dim=1)

    def forward(self, input, hidden):
        combined = torch.cat((input, hidden), 1) 
        hidden = self.i2h(combined)
        output = self.i2o(combined)
        output = self.softmax(output) 
        return output, hidden

    def initHidden(self):
        return Variable(torch.zeros(1, self.hidden_size))

除了上述代码中的 RNN 一词外,其他内容听起来与我们在前几章中使用的内容非常相似,因为 PyTorch 隐藏了很多反向传播的复杂性。让我们详细查看 __init__ 函数和 forward 函数,了解其中发生了什么。

__init__ 函数初始化了两个线性层,一个用于计算输出,另一个用于计算状态或隐藏向量。

forward 函数将输入向量和隐藏向量组合,并通过两个线性层传递,生成输出向量和隐藏状态。对于输出层,我们应用 log_softmax 函数。

initHidden 函数有助于在第一次调用 RNN 时创建没有状态的隐藏向量。让我们通过下面的图示来直观了解 RNN 类的功能:

上述图示展示了 RNN 的工作原理。

RNN 的概念有时在第一次接触时可能难以理解,因此我强烈推荐阅读以下链接提供的一些令人惊叹的博客:karpathy.github.io/2015/05/21/rnn-effectiveness/colah.github.io/posts/2015-08-Understanding-LSTMs/.

在下一节中,我们将学习如何使用称为 LSTM 的 RNN 变体构建 IMDB 数据集上的情感分类器。

使用 LSTM 解决文本分类问题

RNN 在构建实际应用中非常流行,例如语言翻译、文本分类等多种顺序问题。然而,在现实中,我们很少使用简单版本的 RNN,比如我们在前一节中看到的那种。简单版本的 RNN 存在问题,如处理大序列时的梯度消失和梯度爆炸。在大多数实际问题中,使用诸如 LSTM 或 GRU 等 RNN 变体,这些变体解决了普通 RNN 的限制,并且能更好地处理顺序数据。我们将尝试理解 LSTM 的工作原理,并基于 LSTM 构建网络,解决 IMDB 数据集上的文本分类问题。

长期依赖

理论上,RNN 应该从历史数据中学习所有必需的依赖关系,以建立下一个事件的上下文。例如,我们试图预测句子“The clouds are in the sky.”中的最后一个单词。RNN 可以预测,因为信息(clouds)仅在几个单词之后。让我们再来看一个长段落,依赖关系不需要那么紧密,我们想要预测其中的最后一个单词。这个句子是:“I am born in Chennai a city in Tamilnadu. Did schooling in different states of India and I speak...”在实践中,传统的 RNN 版本很难记住前面序列中发生的上下文。LSTMs 及其他 RNN 的不同变体通过在 LSTM 内部添加不同的神经网络来解决这个问题,稍后这些网络会决定可以记住多少或者可以记住什么数据。

LSTM 网络

LSTMs 是一种特殊类型的 RNN,能够学习长期依赖关系。它们于 1997 年引入,并在最近几年因可用数据和硬件的进步而变得流行。它们在各种问题上表现出色,并被广泛应用。

LSTMs 通过设计来避免长期依赖问题,自然而然地记住信息长时间。在 RNN 中,我们看到它们在序列的每个元素上重复自己。在标准 RNN 中,重复模块将具有类似于单个线性层的简单结构。

下图显示了一个简单的循环神经网络是如何重复自身的:

在 LSTM 内部,我们没有使用简单的线性层,而是在 LSTM 内部有更小的网络,这些网络执行独立的工作。下图展示了 LSTM 内部的情况:

图片来源:colah.github.io/posts/2015-08-Understanding-LSTMs/(由 Christopher Olah 绘制的图表)

在上述图中第二个框中,每个小矩形(黄色)框代表一个 PyTorch 层,圆圈代表一个元素矩阵或向量的加法,而合并线表示两个向量正在被串联。好处在于,我们无需手动实现所有这些。大多数现代深度学习框架提供了一个抽象,可以处理 LSTM 内部的所有功能。PyTorch 提供了nn.LSTM层内部所有功能的抽象,我们可以像使用任何其他层一样使用它。

LSTM 中最重要的是单元状态,它通过前面图表中的所有迭代表示为跨单元的水平线。 LSTM 内的多个网络控制信息如何在单元状态之间传播。 LSTM 中的第一步(由符号 σ 表示的小网络)是决定从单元状态中丢弃哪些信息。该网络称为遗忘门,并且具有 sigmoid 作为激活函数,输出每个元素在单元状态中的取值介于 0 和 1 之间。该网络(PyTorch 层)用以下公式表示:

网络中的值决定了哪些值将保留在单元状态中,哪些将被丢弃。下一步是决定我们将添加到单元状态中的信息。这有两部分组成:一个称为输入门的 sigmoid 层,它决定要更新的值,以及一个创建新值添加到单元状态的 tanh 层。数学表示如下:

在下一步中,我们将输入门和 tanh 生成的两个值组合起来。现在,我们可以通过遗忘门与其和 Ct 乘积之和的逐元素乘法来更新单元状态,如下公式所示:

最后,我们需要决定输出,这将是单元状态的筛选版本。 LSTM 有不同的版本,大多数都采用类似的原理。作为开发人员或数据科学家,我们很少需要担心 LSTM 内部发生了什么。

如果您想更深入了解它们,请阅读以下博客链接,以非常直观的方式涵盖了许多理论内容。

查看 Christopher Olah 的关于 LSTM 的精彩博客(colah.github.io/posts/2015-08-Understanding-LSTMs),以及 Brandon Rohrer 的另一篇博客(brohrer.github.io/how_rnns_lstm_work.html),他在一个很棒的视频中解释了 LSTM。

既然我们理解了 LSTM,让我们实现一个 PyTorch 网络,用于构建情感分类器。像往常一样,我们将遵循以下步骤来创建分类器:

  1. 数据准备

  2. 创建批次

  3. 创建网络

  4. 模型训练

我们将在接下来的章节详细讨论这些步骤。

数据准备

我们使用相同的 torchtext 库来下载、分词化和构建 IMDB 数据集的词汇表。在创建 Field 对象时,我们将 batch_first 参数保留为 False。RNN 需要数据的形式为 sequence_lengthbatch_sizefeatures. 用于准备数据集的步骤如下:

TEXT = data.Field(lower=True,fix_length=200,batch_first=False) 
LABEL = data.Field(sequential=False,)
train, test = IMDB.splits(TEXT, LABEL) 
TEXT.build_vocab(train, vectors=GloVe(name='6B', dim=300),max_size=10000,min_freq=10) 
LABEL.build_vocab(train,)

创建批次

我们使用 torchtext BucketIterator 函数创建批次,批次的大小将是序列长度和批次大小。对于我们的情况,大小将是 [200, 32],其中 200 是序列长度,32 是批次大小。

以下是用于批处理的代码:

train_iter, test_iter = data.BucketIterator.splits((train, test), batch_size=32, device=-1) 
train_iter.repeat = False 
test_iter.repeat = False

创建网络

让我们看一下代码,然后逐步理解。您可能会对代码看起来多么熟悉感到惊讶:

class IMDBRnn(nn.Module):

    def   init (self,vocab,hidden_size,n_cat,bs=1,nl=2): 
        super().  init ()
        self.hidden_size = hidden_size 
        self.bs = bs
        self.nl = nl
        self.e = nn.Embedding(n_vocab,hidden_size) 
        self.rnn = nn.LSTM(hidden_size,hidden_size,nl) 
       self.fc2 = nn.Linear(hidden_size,n_cat) 
        self.softmax = nn.LogSoftmax(dim=-1)

    def forward(self,inp): 
        bs = inp.size()[1] 
        if bs != self.bs:
            self.bs = bs 
        e_out = self.e(inp) 
        h0 = c0 = Variable(e_out.data.new(*(self.nl,self.bs,self.hidden_size)).zero_()) 
        rnn_o,_ = self.rnn(e_out,(h0,c0))
        rnn_o = rnn_o[-1]
        fc = F.dropout(self.fc2(rnn_o),p=0.8) 
        return self.softmax(fc)

init 方法创建一个大小为词汇表大小和 hidden_size 的嵌入层。它还创建了一个 LSTM 和一个线性层。最后一层是一个 LogSoftmax 层,用于将线性层的结果转换为概率。

forward函数中,我们传入大小为 [200, 32] 的输入数据,经过嵌入层处理,批次中的每个标记都被嵌入取代,大小变为 [200, 32, 100],其中 100 是嵌入维度。LSTM 层接收嵌入层的输出和两个隐藏变量。这些隐藏变量应与嵌入输出的类型相同,它们的大小应为 [num_layers, batch_size, hidden_size]。LSTM 按顺序处理数据,并生成形状为 [Sequence_length, batch_size, hidden_size] 的输出,其中每个序列索引表示该序列的输出。在这种情况下,我们只取最后一个序列的输出,其形状为 [batch_size, hidden_dim],并将其传递给线性层,将其映射到输出类别。由于模型容易过拟合,添加一个 dropout 层。您可以调整 dropout 的概率。

训练模型

网络创建完成后,我们可以使用与之前示例中相同的代码训练模型。以下是训练模型的代码:

model = IMDBRnn(n_vocab,n_hidden,3,bs=32) 
model = model.cuda()

optimizer = optim.Adam(model.parameters(),lr=1e-3)

def fit(epoch,model,data_loader,phase='training',volatile=False): 
    if phase == 'training':
        model.train()
    if phase == 'validation': 
        model.eval() 
        volatile=True
    running_loss = 0.0
    running_correct = 0
    for batch_idx , batch in enumerate(data_loader): 
        text , target = batch.text , batch.label
        if is_cuda:
            text,target = text.cuda(),target.cuda() 

        if phase == 'training':
            optimizer.zero_grad() 
        output = model(text)
        loss = F.nll_loss(output,target) 

        running_loss += F.nll_loss(output,target,size_average=False).data[0] 
        preds = output.data.max(dim=1,keepdim=True)[1]
        running_correct += preds.eq(target.data.view_as(preds)).cpu().sum() 
        if phase == 'training':
            loss.backward() 
            optimizer.step()

    loss = running_loss/len(data_loader.dataset)
    accuracy = 100\. * running_correct/len(data_loader.dataset) 

    print(f'{phase} loss is {loss:{5}.{2}} and {phase} accuracy is {running_correct}/{len(data_loader.dataset)}{accuracy:{10}.{4}}')         return loss,accuracy

train_losses , train_accuracy = [],[]
validation_losses , validation_accuracy = [],[]

for epoch in range(1,5): 

    epoch_loss, epoch_accuracy =
fit(epoch,model,train_iter,phase='training')
    validation_epoch_loss , validation_epoch_accuracy =
fit(epoch,model,test_iter,phase='validation')
    train_losses.append(epoch_loss)
    train_accuracy.append(epoch_accuracy)
    validation_losses.append(validation_epoch_loss)
    validation_accuracy.append(validation_epoch_accuracy)

下面是训练模型的结果:

training loss is 0.7 and training accuracy is 12564/25000 50.26
validation loss is 0.7 and validation accuracy is 12500/25000 50.0
training loss is 0.66 and training accuracy is 14931/25000 59.72
validation loss is 0.57 and validation accuracy is 17766/25000 71.06
training loss is 0.43 and training accuracy is 20229/25000 80.92
validation loss is 0.4 and validation accuracy is 20446/25000 81.78
training loss is 0.3 and training accuracy is 22026/25000 88.1
validation loss is 0.37 and validation accuracy is 21009/25000 84.04

对模型进行四个 epoch 的训练得到了 84% 的准确率。再训练更多 epoch 导致过拟合,因为损失开始增加。我们可以尝试一些技术,如减小隐藏维度、增加序列长度和使用较小的学习率来进一步提高准确性。

我们还将探讨如何在序列数据上使用一维卷积。

序列数据上的卷积网络

我们通过学习 第四章 深度学习在计算机视觉中的应用 中图像中 CNN 如何通过学习图像特征来解决计算机视觉问题。在图像中,CNN 通过在高度和宽度上进行卷积来工作。同样地,时间可以被视为卷积特征。一维卷积有时比 RNN 更好,并且计算成本更低。在过去几年中,像 Facebook 这样的公司展示了在音频生成和机器翻译方面的成功。在本节中,我们将学习如何使用 CNN 构建文本分类解决方案。

理解序列数据的一维卷积

在第四章,计算机视觉的深度学习,我们已经看到如何从训练数据中学习二维权重。这些权重在图像上移动以生成不同的激活。同样,一维卷积激活在训练我们的文本分类器时也是通过移动这些权重来学习模式。以下图示解释了一维卷积的工作原理:

对 IMDB 数据集上的文本分类器进行训练时,我们将按照使用 LSTMs 构建分类器时遵循的相同步骤进行操作。唯一改变的是,我们使用batch_first = True,而不像我们的 LSTM 网络那样。所以,让我们看看网络、训练代码以及其结果。

创建网络

让我们先看看网络架构,然后逐步看代码:

class IMDBCnn(nn.Module): 

    def
__init__(self,vocab,hidden_size,n_cat,bs=1,kernel_size=3,max_len=200):         super().__init__()
        self.hidden_size = hidden_size 
        self.bs = bs
    self.e = nn.Embedding(n_vocab,hidden_size)
    self.cnn = nn.Conv1d(max_len,hidden_size,kernel_size) 
    self.avg = nn.AdaptiveAvgPool1d(10)
        self.fc = nn.Linear(1000,n_cat)
        self.softmax = nn.LogSoftmax(dim=-1) 

    def forward(self,inp):
        bs = inp.size()[0] 
        if bs != self.bs:
            self.bs = bs 
        e_out = self.e(inp)
        cnn_o = self.cnn(e_out) 
        cnn_avg = self.avg(cnn_o)
        cnn_avg = cnn_avg.view(self.bs,-1)
        fc = F.dropout(self.fc(cnn_avg),p=0.5) 
        return self.softmax(fc)

在前面的代码中,我们不再使用 LSTM 层,而是使用了Conv1d层和AdaptiveAvgPool1d层。卷积层接受序列长度作为其输入大小,输出大小为隐藏大小,核大小为三。由于我们必须改变线性层的维度,所以每次我们尝试使用不同长度运行时,我们使用AdaptiveAvgpool1d层,它接受任何大小的输入并生成给定大小的输出。因此,我们可以使用一个大小固定的线性层。代码的其余部分与大多数网络架构中看到的相似。

训练模型

模型的训练步骤与前面的示例相同。让我们看看调用fit方法和生成的结果的代码:

train_losses , train_accuracy = [],[] 
validation_losses , validation_accuracy = [],[]

for epoch in range(1,5): 

    epoch_loss, epoch_accuracy =
fit(epoch,model,train_iter,phase='training')
    validation_epoch_loss , validation_epoch_accuracy = fit(epoch,model,test_iter,phase='validation')
    train_losses.append(epoch_loss)
    train_accuracy.append(epoch_accuracy)
    validation_losses.append(validation_epoch_loss)
    validation_accuracy.append(validation_epoch_accuracy)

我们对模型进行了四个 epoch 的训练,得到了大约 83%的准确率。以下是运行模型的结果:

training loss is 0.59 and training accuracy is 16724/25000 66.9
validation loss is 0.45 and validation accuracy is 19687/25000 78.75
training loss is 0.38 and training accuracy is 20876/25000 83.5
validation loss is 0.4 and validation accuracy is 20618/25000 82.47
training loss is 0.28 and training accuracy is 22109/25000 88.44
validation loss is 0.41 and validation accuracy is 20713/25000 82.85
training loss is 0.22 and training accuracy is 22820/25000 91.28
validation loss is 0.44 and validation accuracy is 20641/25000 82.56

自从三个 epoch 后验证损失开始增加,我停止了模型的运行。我们可以尝试几件事来改进结果,例如使用预训练权重、添加另一个卷积层以及在卷积之间尝试使用MaxPool1d层。我把这些尝试留给你来测试是否有助于提高准确性。现在我们已经学习了处理序列数据的各种神经网络,让我们在下一节中看看语言建模。

语言建模

语言建模是在给定前几个单词的情况下预测下一个单词的任务。生成这种顺序数据的能力在许多不同领域都有应用,如下所示:

  • 图像字幕

  • 语音识别

  • 语言翻译

  • 自动邮件回复

  • 写故事、新闻文章、诗歌等

最初,这一领域的关注点主要集中在 RNNs,特别是 LSTMs 上。然而,自 2017 年引入 Transformer 架构(arxiv.org/pdf/1706.03762.pdf)后,在 NLP 任务中变得普遍。此后出现了许多 Transformer 的修改版本,其中一些我们将在本章中介绍。

预训练模型

近年来,预训练模型在 NLP 任务中的使用引起了广泛关注。使用预训练语言模型的一个关键优势是它们能够用更少的数据学习。这些模型特别适用于标记数据稀缺的语言,因为它们只需要标记数据。

2015 年,戴安哲和 Q.V.勒在题为半监督序列学习的论文中首次提出了用于序列学习的预训练模型(arxiv.org/abs/1511.01432)。然而,直到最近,它们才被证明在广泛的任务中具有益处。现在我们将考虑近年来这一领域中一些值得注意的最新进展,其中包括但不限于以下内容:

  • 语言模型的嵌入ELMo

  • 双向编码器表示来自 TransformersBERT

  • 生成预训练变压器 2GPT-2

语言模型的嵌入

2018 年 2 月,M. Peters 等人发表了深度上下文化的单词表示论文(arxiv.org/abs/1802.05365),介绍了 ELMo。本质上,它证明了语言模型嵌入可以作为目标模型中的特征,如下图所示:

ELMo 使用双向语言模型来学习单词和上下文。正向和反向传递的内部状态在每个单词处被串联起来,以产生一个中间向量。正是模型的双向性质使其获得关于句子中下一个单词和之前单词的信息。

双向编码器表示来自 Transformers

谷歌在 2018 年 11 月发布的后续论文(arxiv.org/pdf/1810.04805.pdf)中提出了双向编码器表示来自 TransformersBERT),它融入了一个注意力机制,学习词与文本之间的上下文关系:

与 ELMo 不同,文本输入是按顺序(从左到右或从右到左)读取的,但是 BERT 会一次性读取整个单词序列。本质上,BERT 是一个经过训练的 Transformer 编码器堆栈。

生成预训练变压器 2

在撰写本文时,OpenAI 的 GPT-2 是设计用于提高生成文本的逼真度和连贯性的最先进的语言模型之一。它是在 2019 年 2 月的论文Language Models are Unsupervised Multi-task Learnersd4mucfpksywv.cloudfront.net/better-language-models/language_models_are_unsupervised_multitask_learners.pdf)中介绍的。它被训练用于预测 800 万个网页(总共 40GB 的文本),参数达到 15 亿个,是 BERT 的四倍多。以下是 OpenAI 关于 GPT-2 的说法:

GPT-2 生成连贯的段落文本,在许多语言建模基准上取得了最先进的性能,并在基本阅读理解、机器翻译、问题回答和摘要等方面表现出色,所有这些都没有经过特定任务的训练。

最初,OpenAI 表示他们不会发布数据集、代码或完整的 GPT-2 模型权重。这是因为他们担心这些内容会被用于大规模生成欺骗性、偏见性或滥用性语言。这些模型应用于恶意目的的示例如下:

  • 逼真的假新闻文章

  • 在线实际模仿其他人

  • 可能发布在社交媒体上的滥用或伪造内容

  • 自动生产垃圾邮件或钓鱼内容

然后团队决定分阶段发布模型,以便让人们有时间评估社会影响并在每个阶段发布后评估其影响。

PyTorch 实现

有一个来自开发者 Hugging Face 的流行 GitHub 仓库,其中实现了基于 PyTorch 的 BERT 和 GPT-2。可以在以下网址找到该仓库:github.com/huggingface/pytorch-pretrained-BERT。该仓库最早于 2018 年 11 月发布,并允许用户从自己的数据中生成句子。它还包括多种可用于测试不同模型在不同任务(如问题回答、标记分类和序列分类)中应用效果的类。

下面的代码片段演示了如何从 GitHub 仓库中的代码使用 GPT-2 模型生成文本。首先,我们导入相关的库并初始化预训练信息如下:

import torch
from torch.nn import functional as F
from pytorch_pretrained_bert import GPT2Tokenizer, GPT2LMHeadModel

tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
gtp2model = GPT2LMHeadModel.from_pretrained('gpt2')

在这个例子中,我们将模型提供 'We like unicorns because they' 这个句子,然后它生成如下所示的词语:

input_text = tokenizer.encode('We like unicorns because they')
input, past = torch.tensor([input_text]), None
for _ in range(25):
    logits, past = gtp2model(input, past=past)
    input = torch.multinomial(F.softmax(logits[:, -1]), 1)
    input_text.append(input.item())

以下是输出:

GPT-2 游乐场

还有另一个有用的 GitHub 存储库来自开发者 ilopezfr,可以在以下链接找到:github.com/ilopezfr/gpt-2。它还提供了一个 Google Colab 的笔记本,允许用户与 OpenAI GPT-2 模型进行交互和实验(colab.research.google.com/github/ilopezfr/gpt-2/blob/master/gpt-2-playground_.ipynb)。

下面是游乐场不同部分的一些示例:

  • 文本完成部分:

  • 问答部分:

  • 翻译部分:

摘要

在本章中,我们学习了不同的技术来表示深度学习中的文本数据。我们学习了如何在处理不同领域时使用预训练的词嵌入和我们自己训练的嵌入。我们使用 LSTM 和一维卷积构建了文本分类器。我们还了解了如何使用最先进的语言建模架构生成文本。

在下一章中,我们将学习如何训练深度学习算法来生成时尚图像、新图像,并生成文本。

第三部分:理解深度学习中的现代架构

在本节中,你将熟悉深度学习中各种现代架构。

本节包含以下章节:

  • 第六章,实现自编码器

  • 第七章,使用生成对抗网络

  • 第八章,使用现代网络架构进行迁移学习

  • 第九章,深度强化学习

  • 第十章,接下来做什么?

第七章:实现自编码器

本章讨论了半监督学习算法的概念,通过引入自编码器,然后进入受限玻尔兹曼机RBMs)和深度信念网络DBNs),以理解数据的概率分布。本章将概述这些算法如何应用于一些实际问题。还将提供在 PyTorch 中实现的编码示例。

自编码器是一种无监督学习技术。它可以接收无标签的数据集,并通过建模来重建原始输入,将问题建模为无监督学习,而不是监督学习。自编码器的目标是使输入与输出尽可能相似。

具体来说,本章将涵盖以下主题:

  • 自编码器及其应用概述

  • 瓶颈和损失函数

  • 不同类型的自编码器

  • 受限玻尔兹曼机

  • 深度信念网络

自编码器的应用

自编码器属于表征学习,用于找到输入的压缩表示。它们由编码器和解码器组成。以下图示显示了自编码器的结构:

自编码器的应用示例包括以下几种:

  • 数据去噪

  • 数据可视化的降维

  • 图像生成

  • 插值文本

瓶颈和损失函数

自编码器对网络施加了一个瓶颈,强制使原始输入的知识表示被压缩。如果没有瓶颈的话,网络将简单地学会记忆输入值。因此,这意味着模型在未见数据上的泛化能力不会很好:

为了使模型能够检测到信号,我们需要它对输入具有敏感性,但不能简单地记住它们,而在未见数据上预测效果不佳。为了确定最优权衡,我们需要构建一个损失/成本函数:

有一些常用的自编码器架构,用于施加这两个约束条件,并确保在两者之间有最优的权衡。

编码示例 - 标准自编码器

在本例中,我们将展示如何在 PyTorch 中编译一个自编码器模型:

  1. 首先,导入相关的库:
import os
from torch import nn
from torch.autograd import Variable
from torch.utils.data import DataLoader
from torchvision import transforms
from torchvision.datasets import MNIST
from torchvision.utils import save_image
  1. 现在,定义模型参数:
number_epochs = 10
batch_size = 128
learning_rate = 1e-4
  1. 然后,初始化一个函数来转换 MNIST 数据集中的图像:
transform_image = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])

dataset = MNIST('./data', transform=transform_image)
data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
  1. 定义自编码器类,用于提供数据并初始化模型:
class autoencoder_model(nn.Module):
    def __init__(self):
        super(autoencoder_model, self).__init__()
        self.encoder = nn.Sequential(
            nn.Linear(28 * 28, 128),
            nn.ReLU(True),
            nn.Linear(128, 64),
            nn.ReLU(True), nn.Linear(64, 12), nn.ReLU(True), nn.Linear(12, 3))
        self.decoder = nn.Sequential(
            nn.Linear(3, 12),
           nn.ReLU(True),
            nn.Linear(12, 64),
            nn.ReLU(True),
            nn.Linear(64, 128),
            nn.ReLU(True), nn.Linear(128, 28 * 28), nn.Tanh())

    def forward(self, x):
        x = self.encoder(x)
        x = self.decoder(x)
        return x

model = autoencoder_model()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(
model.parameters(), lr=learning_rate, weight_decay=1e-5)
  1. 定义一个函数,它将在每个 epoch 后从模型输出图像:
def to_image(x):
    x = 0.5 * (x + 1)
    x = x.clamp(0, 1)
    x = x.view(x.size(0), 1, 28, 28)
    return x
  1. 现在在每个 epoch 上运行模型并查看重建图像的结果:
for epoch in range(number_epochs):
    for data in data_loader:
        image, i = data
        image = image.view(image.size(0), -1)
        image = Variable(image)

        # Forward pass
        output = model(image)
        loss = criterion(output, image)

        # Backward pass
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    print('Epoch [{}/{}], Loss:{:.4f}'.format(epoch + 1, number_epochs, loss.data[0]))
    if epoch % 10 == 0:
        pic = to_image(output.cpu().data)
        save_image(pic, './mlp_img/image_{}.png'.format(epoch))

torch.save(model.state_dict(), './sim_autoencoder.pth')

这将产生以下输出:

以下图片显示了每个 epoch 的自编码器输出:

随着经过的 epoch 越来越多,图像变得越来越清晰,因为模型继续学习。

卷积自编码器

自编码器可以使用卷积而不是全连接层。这可以通过使用 3D 向量而不是 1D 向量来实现。在图像的背景下,对图像进行下采样迫使自编码器学习其压缩版本。

编码示例 – 卷积自编码器

在这个例子中,我们将展示如何编译一个卷积自编码器:

  1. 与以前一样,您从 MNIST 数据集获取训练和测试数据集,并定义模型参数:
number_epochs = 10
batch_size = 128
learning_rate = 1e-4

transform_image = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])

dataset = MNIST('./data', transform=transform_image)
data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
  1. 从这里开始,启动卷积自编码器模型:
class conv_autoencoder(nn.Module):
    def __init__(self):
        super(conv_autoencoder, self).__init__()
        self.encoder = nn.Sequential(
            nn.Conv2d(1, 16, 3, stride=3, padding=1), 
            nn.ReLU(True),
            nn.MaxPool2d(2, stride=2), 
            nn.Conv2d(16, 8, 3, stride=2, padding=1), 
            nn.ReLU(True),
            nn.MaxPool2d(2, stride=1) 
        )
        self.decoder = nn.Sequential(
            nn.ConvTranspose2d(8, 16, 3, stride=2), 
            nn.ReLU(True),
            nn.ConvTranspose2d(16, 8, 5, stride=3, padding=1), 
            nn.ReLU(True),
            nn.ConvTranspose2d(8, 1, 2, stride=2, padding=1), 
            nn.Tanh()
        )

    def forward(self, x):
        x = self.encoder(x)
        x = self.decoder(x)
        return x

model = conv_autoencoder()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate, weight_decay=1e-5)
  1. 最后,在每个 epoch 运行模型同时保存输出图像以供参考:
for epoch in range(number_epochs):
    for data in data_loader:
        img, i = data
        img = Variable(img)

        # Forward pass
        output = model(img)
        loss = criterion(output, img)

        # Backward pass
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    # Print results
    print('epoch [{}/{}], loss:{:.4f}'
          .format(epoch+1, number_epochs, loss.data[0]))
    if epoch % 10 == 0:
        pic = to_image(output.cpu().data)
        save_image(pic, './dc_img/image_{}.png'.format(epoch))

torch.save(model.state_dict(), './convolutional_autoencoder.pth')

我们可以在代码中提到的文件夹中,每个 epoch 后查看保存的图像。

去噪自编码器

去噪编码器故意向网络的输入添加噪声。这些自编码器实质上创建了数据的损坏副本。通过这样做,这有助于编码器学习输入数据中的潜在表示,使其更具普适性:

这个损坏的图像与其他标准自编码器一样被送入网络:

正如我们所见,原始输入中添加了噪声,编码器对输入进行编码并将其发送到解码器,解码器然后将嘈杂的输入解码为清理后的输出。因此,我们已经看过自编码器可以用于的各种应用。现在我们将看看一种特定类型的自编码器,即变分自编码器VAE)。

变分自编码器

VAEs 与我们迄今考虑过的标准自编码器不同,因为它们以概率方式描述潜在空间中的观察结果,而不是确定性方式。每个潜在属性的概率分布被输出,而不是单个值。

标准自编码器在现实世界中的应用有些受限,因为它们只在您想要复制输入的数据时才真正有用。由于 VAEs 是生成模型,它们可以应用于您不希望输出与输入相同的数据的情况。

让我们在现实世界的背景下考虑这个问题。当在面部数据集上训练自编码器模型时,您希望它能学习潜在属性,比如一个人是否微笑,他们的肤色,是否戴眼镜等等:

正如在前面的图中所示,标准自编码器将这些潜在属性表示为离散值。

如果我们允许每个特征在可能值的范围内而不是单个值内,我们可以使用 VAEs 以概率术语描述属性:

前面的图示了我们如何将一个人是否微笑表示为离散值或概率分布。

每个潜在属性的分布是从图像中采样的,以生成用作解码器模型输入的向量:

如下图所示,输出两个向量:

其中一个描述平均值,另一个描述分布的方差。

训练 VAE

在训练期间,我们使用反向传播计算网络中每个参数与整体损失的关系。

标准自动编码器使用反向传播来在网络权重上重建损失值。由于 VAE 中的采样操作不可微,不能从重构误差中传播梯度。以下图表进一步解释了这一点:

为了克服这一限制,可以使用重参数化技巧。重参数化技巧从单位正态分布中采样ε,将其平移至潜在属性的均值𝜇,并按潜在属性的方差𝜎进行缩放:

这将采样过程从梯度流中移除,因为现在它位于网络之外。因此,采样过程不依赖于网络中的任何东西。现在我们可以优化分布的参数,同时保持从中随机采样的能力:

我们可以通过均值𝜇和协方差矩阵∑对其进行变换,因为每个属性的分布是高斯分布:

这里,ε ~ N(0,1)。

现在我们可以使用简单的反向传播来训练模型,并引入重参数化技巧:

如前面的图表所示,我们已经训练了自动编码器以平滑图像。

编码示例 - VAE

要在 PyTorch 中编写 VAE,我们可以像在之前的示例中那样加载库和数据集。从这里,我们可以定义 VAE 类:

class VariationalAutoEncoder(nn.Module):
    def __init__(self):
        super(VariationalAutoEncoder, self).__init__()

        self.fc1 = nn.Linear(784, 400)
        self.fc21 = nn.Linear(400, 20)
        self.fc22 = nn.Linear(400, 20)
        self.fc3 = nn.Linear(20, 400)
        self.fc4 = nn.Linear(400, 784)

    def encode_function(self, x):
        h1 = F.relu(self.fc1(x))
        return self.fc21(h1), self.fc22(h1)

    def reparametrize(self, mu, logvar):
        std = logvar.mul(0.5).exp_()
        if torch.cuda.is_available():
            eps = torch.cuda.FloatTensor(std.size()).normal_()
        else:
            eps = torch.FloatTensor(std.size()).normal_()
        eps = Variable(eps)
        return eps.mul(std).add_(mu)

    def decode_function(self, z):
        h3 = F.relu(self.fc3(z))
        return F.sigmoid(self.fc4(h3))

    def forward(self, x):
        mu, logvar = self.encode_function(x)
        z = self.reparametrize(mu, logvar)
        return self.decode_function(z), mu, logvar

然后,我们使用 KL 散度来定义损失函数,并初始化模型:

def loss_function(reconstruction_x, x, mu, latent_log_variance):
    """
    reconstruction_x: generating images
    x: original images
    mu: latent mean
    """
    BCE = reconstruction_function(reconstruction_x, x) 
    # KL loss = 0.5 * sum(1 + log(sigma²) - mu² - sigma²)
    KLD_aspect = mu.pow(2).add_(latent_log_variance.exp()).mul_(-1).add_(1).add_(logvar)
    KLD = torch.sum(KLD_aspect).mul_(-0.5)
    # KL divergence
    return BCE + KLD

optimizer = optim.Adam(model.parameters(), lr=1e-4)

从这里,我们可以运行模型的每个时期并保存输出:

for epoch in range(number_epochs):
    model.train()
    train_loss = 0
    for batch_idx, data in enumerate(data_loader):
        img, _ = data
        img = img.view(img.size(0), -1)
        img = Variable(img)
        if torch.cuda.is_available():
            img = img.cuda()
        optimizer.zero_grad()
        recon_batch, mu, logvar = model(img)
        loss = loss_function(recon_batch, img, mu, logvar)
        loss.backward()
        train_loss += loss.data[0]
        optimizer.step()
        if batch_idx % 100 == 0:
            print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
                epoch,
                batch_idx * len(img),
                len(data_loader.dataset), 100\. * batch_idx / len(data_loader),
                loss.data[0] / len(img)))

    print('Epoch: {} Average loss: {:.4f}'.format(epoch, train_loss / len(data_loader.dataset)))
    if epoch % 10 == 0:
        save = to_image(recon_batch.cpu().data)
        save_image(save, './vae_img/image_{}.png'.format(epoch))

torch.save(model.state_dict(), './vae.pth')

现在我们已经看过各种自动编码器及其如何编译它们,让我们学习如何在推荐系统中实现它们。

受限玻尔兹曼机

RBM是一种广泛用于协同过滤、特征提取、主题建模和降维等任务的算法。它们可以无监督地学习数据集中的模式。

例如,如果你观看电影并说出你是否喜欢它,我们可以使用一个RBM来帮助我们确定你做出这个决定的原因。

RBM 的目标是最小化能量,由以下公式定义,其依赖于可见/输入状态、隐藏状态、权重和偏置的配置:

RBM 是 DBN 的基本构建块的两层网络。RBM 的第一层是神经元的可见/输入层,第二层是隐藏层的神经元:

RBM 将输入从可见层翻译成一组数字。通过几次前向和后向传递,该数字然后被翻译回重构输入。在 RBM 中的限制是同一层中的节点不连接。

从训练数据集中的每个节点的低级特征被馈送到可见层的每个节点。在图像分类的情况下,每个节点将为图像中每个像素接收一个像素值:

通过网络跟踪一个像素,输入x被隐藏层的权重乘以,然后加上偏置。然后,这被输入到激活函数中,产生输出,这实质上是通过它传递的信号强度,给定输入x,如下图所示:

在隐藏层的每个节点,来自每个像素值的x被单独的权重乘以。然后将这些乘积求和,并添加偏置。然后将其输出通过激活函数,产生该单个节点的输出:

在每个时刻,RBM 处于某种状态,这指的是可见v和隐藏h层中神经元的值。这种状态的概率可以由以下联合分布函数给出:

这里,Z 是分区函数,是对所有可能的可见和隐藏向量对的求和。

训练 RBM

在训练期间,RBM 执行两个主要步骤:

  1. 吉布斯采样:训练过程的第一步使用吉布斯采样,它重复以下过程k次:
  • 给定输入向量的隐藏向量的概率;预测隐藏值。

  • 给定隐藏向量的输入向量的概率;预测输入值。从这里,我们获得另一个输入向量,该向量是从原始输入值重新创建的。

  1. 对比散度:RBM 通过对比散度调整它们的权重。在此过程中,可见节点的权重是随机生成的,并用于生成隐藏节点。然后,隐藏节点再使用相同的权重重构可见节点。用于重构可见节点的权重在整个过程中是相同的。但是生成的节点不同,因为它们之间没有连接。

一旦 RBM 训练完成,它基本上能够表达两件事情:

  • 输入数据特征之间的相互关系

  • 在识别模式时哪些特征最重要

理论示例 - RBM 推荐系统

在电影的背景下,我们可以使用 RBM 揭示一组代表它们类型的潜在因素,从而确定一个人喜欢哪种电影类型。例如,如果我们要求某人告诉我们他们看过哪些电影以及是否喜欢,我们可以将它们表示为二进制输入(1 或 0)到 RBM 中。对于那些他们没看过或没告诉我们的电影,我们需要分配一个值为-1,这样网络在训练时可以识别并忽略它们的关联权重。

让我们考虑一个示例,用户喜欢老妈妈,我来了宿醉伴娘,不喜欢尖叫心理,还没有看过霍比特人。根据这些输入,RBM 可能识别出三个隐藏因子:喜剧、恐怖和奇幻,这些因子对应于电影的类型:

对于每个隐藏神经元,RBM 分配了给定输入神经元的隐藏神经元的概率。神经元的最终二进制值是通过从伯努利分布中抽样得到的。

在上面的例子中,代表喜剧类型的唯一隐藏神经元变得活跃。因此,给定输入到 RBM 的电影评分,它预测用户最喜欢喜剧电影。

对于已训练的 RBM 来说,要预测用户尚未看过的电影,基于他们的喜好,RBM 使用可见神经元给定隐藏神经元的概率。它从伯努利分布中进行抽样,以确定哪个可见神经元可以变为活跃状态。

编码示例 - RBM 推荐系统

继续在电影的背景下,我们将展示如何使用 PyTorch 库构建一个 RBM 推荐系统的示例。该示例的目标是训练一个模型来确定用户是否会喜欢一部电影。

在这个示例中,我们使用了 MovieLens 数据集(grouplens.org/datasets/movielens/),包含 100 万条评分,这个数据集由明尼苏达大学的 GroupLens 研究组创建:

  1. 首先,下载数据集。可以通过终端命令完成如下操作:
wget -O moviedataset.zip http://files.grouplens.org/datasets/movielens/ml-1m.zip
unzip -o moviedataset.zip -d ./data
unzip -o moviedataset.zip -d ./data
  1. 现在导入我们将要使用的库:
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.parallel
import torch.optim as optim
import torch.utils.data
from torch.autograd import Variable
  1. 然后导入数据:
movies = pd.read_csv('ml-1m/movies.dat', sep = '::', header = None, engine = 'python', encoding = 'latin-1')
users = pd.read_csv('ml-1m/users.dat', sep = '::', header = None, engine = 'python', encoding = 'latin-1')
ratings = pd.read_csv('ml-1m/ratings.dat', sep = '::', header = None, engine = 'python', encoding = 'latin-1')

以下截图展示了我们数据集的结构:

  1. 准备测试和训练数据集:
training_dataset = pd.read_csv('ml-100k/u1.base', delimiter = '\t')
training_dataset = np.array(training_set, dtype = 'int')
test_dataset = pd.read_csv('ml-100k/u1.test', delimiter = '\t')
test_dataset = np.array(test_dataset, dtype = 'int') 
  1. 现在我们需要准备一个包含用户评分的矩阵。该矩阵将以用户为行,电影为列。零用于表示用户未对特定电影评分的情况。我们定义no_usersno_movies变量,然后考虑训练和测试数据集中的最大值如下:
no_users = int(max(max(training_dataset[:,0]), max(test_dataset[:,0])))
no_movies = int(max(max(training_dataset[:,1]), max(test_dataset[:,1])))
  1. 现在我们定义一个名为 convert_dataset 的函数,将数据集转换为矩阵。它通过创建一个循环来运行数据集,并获取特定用户评分的所有电影及该用户的评分。因为用户没有评级过的电影有许多,所以我们首先创建一个全零矩阵:
def convert_dataset(data):
    converted_data = []
    for id_users in range(1, no_users + 1):
        id_movies = data[:,1][data[:,0] == id_users]
        id_ratings = data[:,2][data[:,0] == id_users]
        movie_ratings = np.zeros(no_movies)
        ratings[id_movies - 1] = id_ratings
        converted_data.append(list(movie_ratings))
    return converted_data

training_dataset = convert_dataset(training_dataset)
test_dataset = convert_dataset(test_dataset)
  1. 现在我们使用 FloatTensor 实用程序将数据转换为 Torch 张量。这将把数据集转换为 PyTorch 数组:
training_dataset = torch.FloatTensor(training_dataset)
test_dataset = torch.FloatTensor(test_dataset)
  1. 在这个例子中,我们想要进行二元分类,即用户是否喜欢这部电影。因此,我们将评分转换为零和一。但是首先,我们将现有的零替换为 -1,以表示用户从未评级过的电影:
training_dataset[training_dataset == 0] = -1
training_dataset[training_dataset == 1] = 0
training_dataset[training_dataset == 2] = 0
training_dataset[training_dataset >= 3] = 1
test_dataset[test_dataset == 0] = -1
test_dataset[test_dataset == 1] = 0
test_dataset[test_dataset == 2] = 0
test_dataset[test_dataset >= 3] = 1
  1. 现在,我们需要创建一个类来定义 RBM 的架构。该类通过使用随机正态分布初始化权重和偏置。还定义了两种类型的偏置,其中 a 是给定可见节点时隐藏节点的概率,b 是给定隐藏节点时可见节点的概率。该类创建了一个 sample_hidden_nodes 函数,它以 x 作为参数并表示可见神经元。从这里,我们计算给定 vh 的概率,其中 hv 分别表示隐藏和可见节点。这代表了 S 型激活函数。它计算为权重向量和 x 的乘积加上偏置 a。由于我们考虑的是二元分类模型,我们返回隐藏神经元的伯努利样本。从这里,我们创建一个 sample_visible_function 函数,它将对可见节点进行采样。最后,我们创建训练函数。它接受包含电影评分的输入向量、k 次采样后获得的可见节点、概率向量以及 k 次采样后的隐藏节点的概率:
class RBM():
    def __init__(self, num_visible_nodes, num_hidden_nodes):
        self.W = torch.randn(num_hidden_nodes, num_visible_nodes)
        self.a = torch.randn(1, num_hidden_nodes)
        self.b = torch.randn(1, num_visible_nodes)

    def sample_hidden_nodes(self, x):
        wx = torch.mm(x, self.W.t())
        activation = wx + self.a.expand_as(wx)
        p_h_given_v = torch.sigmoid(activation)
        return p_h_given_v, torch.bernoulli(p_h_given_v)

    def sample_visible_nodes(self, y):
        wy = torch.mm(y, self.W)
        activation = wy + self.b.expand_as(wy)
        p_v_given_h = torch.sigmoid(activation)
        return p_v_given_h, torch.bernoulli(p_v_given_h)

    def train(self, v0, vk, ph0, phk):
        self.W += torch.mm(v0.t(), ph0) - torch.mm(vk.t(), phk)
        self.b += torch.sum((v0 - vk), 0)
        self.a += torch.sum((ph0 - phk), 0)
  1. 现在我们定义我们的模型参数:
num_visible_nodes = len(training_dataset[0])
num_hidden_nodes = 200
batch_size = 100
rbm = RBM(num_visible_nodes, num_hidden_nodes)
  1. 从这里,我们可以为每个 epoch 训练模型:
nb_epoch = 10
for epoch in range(1, nb_epoch + 1):
    train_loss = 0
    s = 0.
    for id_user in range(0, nb_users - batch_size, batch_size):
        vk = training_dataset[id_user:id_user+batch_size]
        v0 = training_dataset[id_user:id_user+batch_size]
        ph0,_ = rbm.sample_hidden_nodes(v0)
        for k in range(10):
            _,hk = rbm.sample_hidden_nodes(vk)
            _,vk = rbm.sample_visible_nodes(hk)
            vk[v0<0] = v0[v0<0]
        phk,_ = rbm.sample_hidden_nodes(vk)
        rbm.train(v0, vk, ph0, phk)
        train_loss += torch.mean(torch.abs(v0[v0>=0] - vk[v0>=0]))
        s += 1.
    print('epoch: '+str(epoch)+' loss: '+str(train_loss/s))

我们可以在训练过程中绘制跨 epoch 的错误:

这可以帮助我们确定应该运行多少个 epoch 进行训练。显示在六个 epoch 后,改进的性能率下降,因此我们应该考虑在这个阶段停止训练。

我们已经看到了在 RBM 中实现推荐系统的编码示例,现在让我们简要地浏览一下 DBN 架构。

DBN 架构

DBN 是一个多层信念网络,每一层都是一个叠加的 RBM。除了 DBN 的第一层和最后一层之外,每一层既作为其前面节点的隐藏层,又作为其后节点的输入层:

DBN 中的两个层通过权重矩阵连接。DBN 的顶部两层是无向的,它们之间形成对称连接,形成联想存储器。较低的两层直接连接到上面的层。方向感将联想存储器转换为观察变量:

DBN 的两个最显著特性如下:

  • DBN 通过高效的逐层过程学习自顶向下的生成权重。这些权重决定了一个层中的变量如何依赖于上面的层。

  • 训练完成后,可以通过单个自下而上的传递推断每层隐藏变量的值。传递从底层的可见数据向量开始,并使用其生成权重相反方向。

联合配置网络的概率在可见层和隐藏层之间的联合配置网络的能量依赖于所有其他联合配置网络的能量:

一旦 RBMs 堆栈完成了 DBN 的预训练阶段,就可以使用前向网络进行微调阶段,从而创建分类器或在无监督学习场景中简单地帮助聚类无标签数据。

微调

微调的目标是找到层间权重的最优值。它微调原始特征,以获得更精确的类边界。为了帮助模型将模式和特征关联到数据集,使用了一个小的标记数据集。

微调可以作为随机的自下而上传递应用,然后用于调整自上而下的权重。一旦达到顶层,递归被应用于顶层。为了进一步微调,我们可以进行随机的自上而下传递,并调整自下而上的权重。

总结

在本章中,我们解释了自编码器及其不同的变体。在整个章节中,我们提供了一些编码示例,展示它们如何应用于 MNIST 数据集。后来我们介绍了受限玻尔兹曼机,并解释了如何将其开发成深度玻尔兹曼机,同时提供了额外的示例。

在下一章中,我们将介绍生成对抗网络,并展示它们如何用于生成图像和文本。

进一步阅读

进一步的信息请参考以下内容:

第八章:使用生成对抗网络进行工作

我们在前几章中看到的所有示例都集中在解决分类或回归等问题上。对于理解深度学习如何发展以解决无监督学习问题,本章非常有趣且重要。

在本章中,我们将训练网络学习如何创建以下内容:

  • 基于内容和特定艺术风格的图像,通常称为风格转移

  • 使用特定类型的生成对抗网络GAN)生成新人脸。

这些技术构成了深度学习领域正在进行的大部分先进研究的基础。深入研究每个子领域的具体细节,如 GAN 和语言建模,超出了本书的范围,它们值得单独的一本书来探讨。我们将学习它们的一般工作原理以及在 PyTorch 中构建它们的过程。

本章将涵盖以下主题:

  • 神经风格转移

  • 介绍生成对抗网络

  • DCGANs

神经风格转移

我们人类以不同精度和复杂度生成艺术作品。尽管创建艺术的过程可能非常复杂,但可以看作是两个最重要因素的结合,即要画什么和如何画。要画什么受到我们周围所见的启发,而如何画也会受到我们周围某些事物的影响。从艺术家的角度来看,这可能是一种过于简化的看法,但对于理解如何使用深度学习算法创建艺术作品非常有用。

我们将训练一个深度学习算法,从一幅图像中提取内容,然后根据特定的艺术风格进行绘制。如果你是艺术家或者从事创意行业,你可以直接利用最近几年来进行的令人惊叹的研究来改进这一过程,并在你所工作的领域内创造出有趣的东西。即使你不是,它仍然可以向你介绍生成模型的领域,其中网络生成新的内容。

让我们从高层次理解神经风格转移的工作,并深入探讨相关细节,以及构建它所需的 PyTorch 代码。风格转移算法提供了一个内容图像(C)和一个风格图像(S)——算法必须生成一个新图像(O),其中包含来自内容图像的内容和来自风格图像的风格。这种神经风格转移的过程是由 Leon Gates 等人在 2015 年的论文*《艺术风格的神经算法》*中介绍的(arxiv.org/pdf/1508.06576.pdf)。以下是我们将使用的内容图像(C):

以下是风格图像(S):

前述图片来源:来自葛饰北斋的《神奈川冲浪里》(commons.wikimedia.org/wiki/File:The_Great_Wave_off_Kanagawa.jpg

这是我们将得到的结果图片:

当你理解卷积神经网络CNNs)的工作原理时,风格转移背后的思想变得清晰。 当 CNNs 被用于目标识别时,训练的早期层学习非常通用的信息,如线条,曲线和形状。 CNN 的最后几层捕捉图像的更高级概念,如眼睛,建筑物和树木。 因此,类似图像的最后几层的值往往更接近。 我们采用相同的概念并应用于内容损失。 内容图像和生成图像的最后一层应该类似,并且我们使用均方误差(MSE)来计算相似性。 我们使用优化算法降低损失值。

在 CNN 中,通过称为 Gram 矩阵的技术通常捕获图像的风格。 Gram 矩阵计算跨多个层捕获的特征图之间的相关性。 Gram 矩阵提供了计算风格的一种方法。类似风格的图像具有 Gram 矩阵的类似值。风格损失还使用风格图像的 Gram 矩阵与生成图像之间的均方误差(MSE)来计算。

我们将使用预训练的 VGG19 模型,该模型提供在 TorchVision 模型中。 训练样式转移模型所需的步骤与任何其他深度学习模型相似,唯一不同的是计算损失比分类或回归模型更复杂。 神经风格算法的训练可以分解为以下步骤:

  1. 加载数据。

  2. 创建一个 VGG19 模型。

  3. 定义内容损失。

  4. 定义风格损失。

  5. 从 VGG 模型中提取跨层的损失。

  6. 创建优化器。

  7. 训练 - 生成类似于内容图像的图像和类似于样式图像的样式。

加载数据

加载数据类似于我们在第三章“深入神经网络”的解决图像分类问题中看到的。 我们将使用预训练的 VGG 模型,因此必须使用与预训练模型相同的值对图像进行归一化处理。

以下代码显示了如何实现此目标。 代码大部分是不言自明的,因为我们在前几章中已经详细讨论过它:

image_size = 512 
is_cuda = torch.cuda.is_available()
preprocessing = transforms.Compose([transforms.Resize(image_size),
                           transforms.ToTensor(),
                           transforms.Lambda(lambda x: x[torch.LongTensor([2,1,0])]), 
                           transforms.Normalize(mean=[0.40760392, 0.45795686, 0.48501961], 
                                                std=[1,1,1]),
                           transforms.Lambda(lambda x: x.mul_(255)),
                          ])
processing = transforms.Compose([transforms.Lambda(lambda x: x.mul_(1./255)),
                           transforms.Normalize(mean=[-0.40760392, -0.45795686, -0.48501961], 
                                                std=[1,1,1]),
                           transforms.Lambda(lambda x: x[torch.LongTensor([2,1,0])]), 
                           ])
postprocess = transforms.Compose([transforms.ToPILImage()])

def postprocess_b(tensor): 
    t = processing(tensor)
    t[t>1] = 1 
    t[t<0] = 0
    img = postprocess(t)
    return img

在此代码中,我们定义了三个功能:preprocess 执行所有必需的预处理,并使用与训练 VGG 模型时相同的标准化值。模型的输出需要被归一化回其原始值;processing 函数执行所需的处理。生成的模型可能超出接受值的范围,postprocess_b 函数将所有大于一的值限制为一,并将小于零的值限制为零。

现在我们定义 loader 函数,它加载图像,应用 preprocessing 转换,并将其转换为变量:

def loader(image_name):
    image = Image.open(image_name)
    image = Variable(preprocessing(image))
    # fake batch dimension required to fit network's input dimensions
    image = image.unsqueeze(0)
    return image

以下函数加载样式和内容图像:

style_image = loader("Images/style_image.jpg")
content_image = loader("Images/content_image.jpg")

我们可以使用噪声(随机数)创建图像,也可以使用相同的内容图像。在这种情况下,我们将使用内容图像。以下代码创建内容图像:

output_image = Variable(content_image.data.clone(),requires_grad=True)

我们将使用优化器来调整 output_image 变量的值,以使图像更接近内容图像和样式图像。出于这个原因,我们要求 PyTorch 通过提及 requires_grad=True 来保持梯度。

创建 VGG 模型

我们将从 torchvisions.models 中加载预训练模型。我们将仅使用此模型来提取特征,并且 PyTorch 的 VGG 模型是这样定义的:所有卷积块位于特征模块中,全连接或线性层位于分类器模块中。由于我们不会训练 VGG 模型中的任何权重或参数,因此我们还将冻结该模型,如下面的代码所示:

vgg = vgg19(pretrained=True).features
for param in vgg.parameters():
   param.requires_grad = False

在此代码中,我们创建了一个 VGG 模型,仅使用其卷积块,并冻结了模型的所有参数,因为我们将仅用它来提取特征。

内容损失

内容损失是输入图像和输出图像之间的距离。其目的是保持图像的原始内容。它是在通过网络传递两个图像并提取特定层的输出后计算的 MSE。我们通过使用 register_forward_hook 功能从 VGG 中提取中间层的输出来实现,传入内容图像和要优化的图像。

我们根据这些层的输出计算得到的 MSE,如下面的代码所述:

target_layer = dummy_fn(content_img)
noise_layer = dummy_fn(noise_img)
criterion = nn.MSELoss()
content_loss = criterion(target_layer,noise_layer)

我们将在接下来的部分为此代码实现 dummy_fn。现在我们知道的是,dummy_fn 函数通过传递图像返回特定层的输出。我们通过将内容图像和噪声图像传递给 MSE 损失函数来传递生成的输出。

样式损失

样式损失是跨多个层计算的。样式损失是每个特征图生成的 Gram 矩阵的 MSE。Gram 矩阵表示其特征的相关值。让我们通过以下图表和代码实现来理解 Gram 矩阵的工作原理。

以下表格显示了维度为 [2, 3, 3, 3] 的特征映射的输出,具有列属性 Batch_sizeChannelsValues

要计算 Gram 矩阵,我们展平每个通道的所有值,然后通过与其转置相乘来找到其相关性,如下表所示:

我们做的所有工作就是将所有值按照每个通道展平为单个向量或张量。以下代码实现了这一点:

class GramMatrix(nn.Module):

   def forward(self,input):
       b,c,h,w = input.size()
       features = input.view(b,c,h*w)
       gram_matrix = torch.bmm(features,features.transpose(1,2))
       gram_matrix.div_(h*w)
       return gram_matrix

我们将 GramMatrix 函数实现为另一个 PyTorch 模块,具有 forward 函数,以便像 PyTorch 层一样使用它。在这一行中,我们从输入图像中提取不同的维度:

b,c,h,w = input.size()

这里,b 表示批次,c 表示过滤器或通道,h 表示高度,w 表示宽度。在下一步中,我们将使用以下代码保持批次和通道维度不变,并在高度和宽度维度上展平所有值,如前面的图示所示:

features = input.view(b,c,h*w)

通过将其转置向量与其展平值相乘来计算 Gram 矩阵。我们可以使用 PyTorch 提供的批次矩阵乘法函数 torch.bmm() 来实现,如下代码所示:

gram_matrix = torch.bmm(features,features.transpose(1,2))

我们完成了通过将其除以元素数量来规范 Gram 矩阵值的工作。这可以防止某个具有大量值的特征映射主导得分。一旦计算了 GramMatrix,就可以简单地计算风格损失,这在以下代码中实现:

class StyleLoss(nn.Module):
   def forward(self,inputs,targets):
       out = nn.MSELoss()(GramMatrix()(inputs),targets)
       return (out)

StyleLoss 类被实现为另一个 PyTorch 层。它计算输入 GramMatrix 值与风格图像 GramMatrix 值之间的均方误差(MSE)。

提取损失

就像我们使用 register_forward_hook() 函数提取卷积层的激活一样,我们可以提取不同卷积层的损失,以计算风格损失和内容损失。在这种情况下的一个区别是,我们需要提取多个层的输出而不是一个层的输出。以下类集成了所需的更改:

class LayerActivations():
   features=[]

   def __init__(self,model,layer_numbers):

       self.hooks = []
       for layer_num in layer_numbers:
           self.hooks.append(model[layer_numbers].register_forward_hook(self.hook_fn))

   def hook_fn(self,module,input,output):
       self.features.append(output)

   def remove(self):
       for hook in self.hooks:
           hook.remove()

__init__ 方法接受我们需要调用 register_forward_hook 方法的模型和需要提取输出的层编号。__init__ 方法中的 for 循环遍历层编号并注册所需的前向钩子,用于提取输出。

传递给 register_forward_hook 方法的 hook_fn 函数在注册 hook_fn 函数的层之后由 PyTorch 调用。在函数内部,我们捕获输出并将其存储在特征数组中。

当我们不想捕获输出时,需要调用 remove 函数一次。忘记调用 remove 方法可能会导致内存不足异常,因为所有输出都会累积。

让我们写另一个实用函数,可以提取用于样式和内容图像的输出。以下函数执行相同操作:

def extract_layers(layers,image,model=None):

   la = LayerActivations(model,layers)
   la.features = []
   out = model(image)
   la.remove()
   return la.features

extract_layers 函数内部,我们通过向模型和层编号传递来创建 LayerActivations 类的对象。特征列表可能包含来自先前运行的输出,因此我们将其重新初始化为空列表。然后我们通过模型传递图像,并且我们不会使用输出。我们更关心的是特征数组中生成的输出。我们调用 remove 方法来从模型中删除所有已注册的钩子,并返回特征。以下代码展示了我们提取样式和内容图像所需目标的方法:

content_targets = extract_layers(content_layers,content_img,model=vgg)
style_targets = extract_layers(style_layers,style_img,model=vgg)

一旦我们提取了目标,我们需要将输出从创建它们的图中分离出来。请记住,所有这些输出都是 PyTorch 变量,它们保留了它们创建方式的信息。但是,对于我们的情况,我们只关注输出值,而不是图形,因为我们不会更新样式图像或内容图像。以下代码展示了这一技术:

content_targets = [t.detach() for t in content_targets]
style_targets = [GramMatrix()(t).detach() for t in style_targets]

一旦我们分离了,让我们把所有的目标添加到一个列表中。以下代码展示了这一技术:

targets = style_targets + content_targets

在计算样式损失和内容损失时,我们传递了称为内容层和样式层的两个列表。不同的层选择将影响生成图像的质量。让我们选择与论文作者提到的相同层。以下代码显示了我们在这里使用的层的选择:

style_layers = [1,6,11,20,25]
content_layers = [21]
loss_layers = style_layers + content_layers

优化器期望最小化一个单一的标量数量。为了获得单一标量值,我们将所有到达不同层的损失相加起来。习惯上,对这些损失进行加权和是常见做法,而我们选择与 GitHub 仓库中论文实现中使用的相同权重。我们的实现是作者实现的一个稍微修改的版本。以下代码描述了使用的权重,这些权重是通过所选层中的过滤器数量计算得出的:

style_weights = [1e3/n**2 for n in [64,128,256,512,512]]
content_weights = [1e0]
weights = style_weights + content_weights

要进行可视化,我们可以打印 VGG 层。花一分钟观察我们选择了哪些层,并尝试不同的层组合。我们将使用以下代码来打印 VGG 层:

print(vgg)

这导致以下输出:

#Results

Sequential(
 (0): Conv2d (3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (1): ReLU(inplace)
 (2): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (3): ReLU(inplace)
 (4): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1))
 (5): Conv2d (64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (6): ReLU(inplace)
 (7): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (8): ReLU(inplace)
 (9): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1))
 (10): Conv2d (128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (11): ReLU(inplace)
 (12): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (13): ReLU(inplace)
 (14): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (15): ReLU(inplace)
 (16): Conv2d (256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (17): ReLU(inplace)
 (18): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1))
 (19): Conv2d (256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (20): ReLU(inplace)
 (21): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (22): ReLU(inplace)
 (23): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (24): ReLU(inplace)
 (25): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (26): ReLU(inplace)
 (27): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1))
 (28): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (29): ReLU(inplace)
 (30): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (31): ReLU(inplace)
 (32): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (33): ReLU(inplace)
 (34): Conv2d (512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
 (35): ReLU(inplace)
 (36): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), dilation=(1, 1))
)

我们必须定义损失函数和优化器来生成艺术图像。我们将在以下部分中初始化它们两个。

为每个层创建损失函数

我们已经将损失函数定义为 PyTorch 层。因此,让我们为不同的样式损失和内容损失创建损失层。以下代码定义了这个函数:

loss_fns = [StyleLoss()] * len(style_layers) + [nn.MSELoss()] * len(content_layers)

loss_fns 函数是一个列表,包含一堆基于创建的数组长度的样式损失对象和内容损失对象。

创建优化器

通常,我们传递网络的参数,如 VGG 的参数进行训练。但在本例中,我们将 VGG 模型用作特征提取器,因此不能传递 VGG 的参数。在这里,我们只提供将优化以使图像具有所需内容和风格的opt_img变量的参数。以下代码创建优化器以优化其值:

optimizer = optim.LBFGS([output_image]);

现在我们已经准备好所有训练组件。

训练模型

与我们到目前为止训练过的其他模型相比,训练方法有所不同。在这里,我们需要在多个层级计算损失,并且每次调用优化器时,都会改变输入图像,使其内容和风格接近目标的内容和风格。让我们看一下用于训练的代码,然后我们将逐步介绍训练的重要步骤:

maximum_iterations = 500
show_iteration-1 = 50
n_iter=[0]

optimizer = optim.LBFGS([output_image]);
n_iteration=[0]

while n_iteration[0] <= maximum_iterations:

    def closure():
        optimizer.zero_grad()

        out = extract_layers(loss_layers,output_image,model=vgg)
        layer_losses = [weights[a] * loss_fnsa for a,A in enumerate(out)]
        loss = sum(layer_losses)
        loss.backward()
        n_iteration[0]+=1
        if n_iteration[0]%show_iteration == (show_iteration-1):
            print('Iteration: %d, loss: %f'%(n_iteration[0]+1, loss.data[0]))

        return loss

    optimizer.step(closure)

我们正在运行 500 次迭代的训练循环。对于每次迭代,我们使用我们的extract_layers函数计算来自 VGG 模型不同层的输出。在这种情况下,唯一改变的是output_image的值,它将包含我们的样式图像。一旦计算出输出,我们通过迭代输出并将它们传递给相应的损失函数,同时传递它们的相应目标来计算损失。我们总结所有的损失并调用反向传播函数。在闭包函数的末尾,返回损失。对于max_iterations,同时调用闭包方法和optimizer.step方法。如果您在 GPU 上运行,可能需要几分钟才能运行;如果您在 CPU 上运行,请尝试减小图像的大小以加快运行速度。

在运行了 500 个 epochs 之后,在我的设备上生成的图像如下所示。尝试不同的内容和风格的组合来生成有趣的图像:

在接下来的部分中,让我们使用深度卷积生成对抗网络(DCGANs)生成人脸。

引入 GANs

GANs 是由 Ian Goodfellow 于 2014 年引入,并变得非常流行。最近 GAN 研究取得了许多重要进展,以下时间轴显示了 GAN 研究中一些最显著的进展和关键发展:

在本章中,我们将专注于 DCGAN 的 PyTorch 实现。然而,有一个非常有用的 GitHub 仓库提供了一堆 PyTorch 实现示例,包括时间轴上显示的 GAN 以及其他模型。可以通过以下链接访问:github.com/eriklindernoren/PyTorch-GAN

GAN 通过训练两个深度神经网络——生成器和鉴别器来解决无监督学习问题,它们相互竞争。在训练过程中,两者最终都变得更擅长执行它们所执行的任务。

GAN 可以用一个造假者(生成器)和警察(鉴别器)的案例直观理解。最初,造假者向警察展示假钱。警察识别出它是假的,并解释给造假者为什么是假的。造假者根据收到的反馈制造新的假钱。警察发现它是假的,并告诉造假者为什么是假的。重复进行大量次数,直到造假者能够制造出警察无法识别的假钱。在 GAN 场景中,我们最终得到一个生成器,生成的假图像非常类似于真实图像,而分类器变得擅长识别真伪。

GAN 是伪造者网络和专家网络的结合体,每个网络都经过训练以击败另一个。生成器网络以随机向量作为输入,并生成合成图像。鉴别器网络接收输入图像,并预测图像是真实的还是伪造的。我们向鉴别器网络传递真实图像或伪造图像。

生成器网络被训练生成图像,并欺骗鉴别器网络认为它们是真实的。鉴别器网络也在不断改进,以免受骗,因为我们在训练时传递反馈。

以下图表描述了 GAN 模型的架构:

虽然 GAN 的理念在理论上听起来很简单,但训练一个真正有效的 GAN 模型非常困难,因为需要并行训练两个深度神经网络。

DCGAN 是早期展示如何构建一个可以自我学习并生成有意义图像的 GAN 模型之一。您可以在这里了解更多信息:arxiv.org/pdf/1511.06434.pdf。我们将逐步讲解这种架构的每个组成部分以及背后的一些推理,以及如何在 PyTorch 中实现它。

DCGAN

在本节中,我们将根据我在前面信息框中提到的 DCGAN 论文实现 GAN 架构的不同部分。训练 DCGAN 的一些重要部分包括以下内容:

  • 生成器网络,将固定维度的潜在向量(数字列表)映射到某些形状的图像。在我们的实现中,形状是(3, 64, 64)。

  • 鉴别器网络,以生成器生成的图像或来自实际数据集的图像作为输入,并映射到评估输入图像是否真实或伪造的分数。

  • 定义生成器和鉴别器的损失函数。

  • 定义一个优化器。

让我们详细探讨每个部分。这一实现提供了更详细的解释,说明了在 PyTorch GitHub 存储库中提供的代码:github.com/pytorch/examples/tree/master/dcgan.

定义生成器网络

生成器网络将固定维度的随机向量作为输入,并对其应用一组转置卷积、批量归一化和 ReLU 激活函数,生成所需尺寸的图像。在深入研究生成器实现之前,让我们先来定义转置卷积和批量归一化。

转置卷积

转置卷积也称为分数步幅卷积。它们的工作方式与卷积相反。直观地说,它们试图计算如何将输入向量映射到更高的维度。

让我们看看下面的图表以更好地理解它:

此图表被引用在 Theano 文档中(另一个流行的深度学习框架—deeplearning.net/software/theano/tutorial/conv_arithmetic.html)。如果你想更深入地了解步幅卷积的工作原理,我强烈推荐你阅读这篇文章。对我们而言重要的是,它有助于将向量转换为所需维度的张量,并且我们可以通过反向传播训练核的值。

批量归一化

我们已经多次观察到,所有传递给机器学习或深度学习算法的特征都经过了归一化处理;即,通过从数据中减去均值来将特征的值居中到零,并通过将数据除以其标准差来给数据一个单位标准差。通常我们会使用 PyTorch 的torchvision.Normalize方法来实现这一点。以下代码展示了一个例子:

transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))

在我们所见的所有示例中,数据在进入神经网络之前都进行了归一化;不能保证中间层得到归一化的输入。下图展示了神经网络中间层未获得归一化数据的情况:

批量归一化充当一个中间函数或层,当训练过程中的均值和方差随时间变化时,它会归一化中间数据。批量归一化是由 Ioffe 和 Szegedy 在 2015 年提出的(arxiv.org/abs/1502.03167)。批量归一化在训练和验证或测试期间表现不同。训练期间,会计算批次数据的均值和方差。验证和测试期间则使用全局值。我们只需理解它归一化了中间数据。使用批量归一化的一些关键优势包括以下几点:

  • 改善了网络中的梯度流,从而帮助我们构建更深的网络

  • 允许更高的学习率

  • 减少了初始化的强依赖

  • 作为正则化的一种形式,并减少了对丢弃的依赖

大多数现代架构,如 ResNet 和 Inception,在它们的架构中广泛使用批标准化。我们将在下一章节深入探讨这些架构。批标准化层是在卷积层或线性/全连接层之后引入的,如下图所示:

到目前为止,我们对生成器网络的关键组成部分有了直观的理解。

生成器

让我们快速浏览以下生成器网络代码,然后讨论生成器网络的关键特性:

class _net_generator(nn.Module):
    def __init__(self):
        super(_net_generator, self).__init__()

        self.main = nn.Sequential(
            nn.ConvTranspose2d( nz, ngf * 8, 4, 1, 0, bias=False),
            nn.BatchNorm2d(ngf * 8),
            nn.ReLU(True),
           nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ngf * 4),
            nn.ReLU(True),
            nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ngf * 2),
            nn.ReLU(True),
            nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ngf),
            nn.ReLU(True),
            nn.ConvTranspose2d( ngf, nc, 4, 2, 1, bias=False),
            nn.Tanh()
        )

    def forward(self, input):
        output = self.main(input)
        return output

net_generator = _net_generator()
net_generator.apply(weights_inititialisation)
print(net_generator)

在我们看到的大多数代码示例中,我们使用了一系列不同的层,然后在前向方法中定义数据的流动。在生成器网络中,我们在__init__方法中定义了层和数据的流动,使用了顺序模型。该模型接收大小为 nz 的张量作为输入,然后将其传递给转置卷积以映射输入到需要生成的图像大小。前向函数将输入传递给顺序模块并返回输出。生成器网络的最后一层是一个 tanh 层,限制了网络可以生成的值的范围。

我们不再使用相同的随机权重初始化模型,而是根据论文中定义的权重初始化模型。以下是权重初始化代码:

def weights_inititialisation(m):
   class_name = m.__class__.__name__
   if class_name.find('Conv') != -1:
       m.weight.data.normal_(0.0, 0.02)
   elif class_name.find('BatchNorm') != -1:
       m.weight.data.normal_(1.0, 0.02)
       m.bias.data.fill_(0)

我们通过将函数传递给生成器对象net_generator来调用权重函数。每一层都会传递给该函数;如果该层是卷积层,我们会以不同的方式初始化权重,如果是BatchNorm层,则会稍有不同。我们使用以下代码在网络对象上调用该函数:

net_generator.apply(weights_inititialisation)

定义鉴别器网络

让我们快速浏览一下以下鉴别器网络代码,然后讨论鉴别器网络的关键特性:

class _net_discriminator(nn.Module):
    def __init__(self):
        super(_net_discriminator, self).__init__()
        self.main = nn.Sequential(
            nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 2),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 4),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 8),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False),
            nn.Sigmoid()
        )

    def forward(self, input):
        output = self.main(input)
        return output.view(-1, 1).squeeze(1)

net_discriminator = _net_discriminator()
net_discriminator.apply(weights_inititialisation)
print(net_discriminator)

在前述网络中有两个重要点,即使用 Leaky ReLU 作为激活函数,以及在最后使用 sigmoid 作为激活层。首先,让我们了解一下 Leaky ReLU 是什么。

Leaky ReLU 是为了解决 ReLU 激活函数中的“神经元死亡”问题。当输入为负数时,Leaky ReLU 不会返回零,而是输出一个非常小的数值,如 0.001。论文中显示,使用 Leaky ReLU 可以提高鉴别器的效率。

另一个重要的区别是在鉴别器末端不使用全连接层。通常会看到最后的全连接层被全局平均池化替换。但使用全局平均池化会降低收敛速度(构建准确分类器所需的迭代次数)。最后的卷积层被展平并传递给 sigmoid 层。

除了这两个区别外,该网络的其余部分与我们在书中看到的其他图像分类器网络类似。

定义损失和优化器

我们将在以下代码中定义二元交叉熵损失和两个优化器,一个用于生成器,另一个用于鉴别器。

criterion = nn.BCELoss()

optimizer_discriminator = optim.Adam(net_discriminator.parameters(), lr, betas=(beta1, 0.95))
optimizer_generator = optim.Adam(net_generator.parameters(), lr, betas=(beta1, 0.95))

到目前为止,这与我们在所有先前示例中看到的非常相似。让我们探索如何训练生成器和鉴别器。

训练鉴别器

鉴别器网络的损失取决于其在真实图像上的表现以及其在生成器网络生成的假图像上的表现。损失可以定义如下:

因此,我们需要使用真实图像和生成器网络生成的假图像来训练鉴别器。

使用真实图像训练鉴别器

让我们将一些真实图像直接作为信息传递给训练鉴别器。

首先,我们将查看执行相同操作的代码,然后探索其重要特征:

output = net_discriminator(inputv)
err_discriminator_real = criterion(output, labelv)
err_discriminator_real.backward()

在前面的代码中,我们计算了鉴别器图像所需的损失和梯度。inputvlabelv 值表示 CIFAR10 数据集中的输入图像和标签,对于真实图像标签为 1。这很简单明了,与我们对其他图像分类器网络所做的工作类似。

使用假图像训练鉴别器

现在传递一些随机图像来训练鉴别器。

让我们看一下相关代码,然后探索其重要特征:

fake = net_generator(noisev)
output = net_discriminator(fake.detach())
err_discriminator_fake = criterion(output, labelv)
err_discriminator_fake.backward()
optimizer_discriminator.step()

此代码中的第一行传递了一个大小为 100 的向量,生成器网络(net_generator)生成一张图像。我们将图像传递给鉴别器,以便其识别图像是真实的还是假的。我们不希望生成器得到训练,因为鉴别器正在训练中。因此,我们通过在其变量上调用 detach 方法来从其图中移除假图像。一旦计算出所有梯度,我们调用优化器来训练鉴别器。

训练生成器网络

让我们看一下用于训练生成器网络的以下代码,然后探索其重要特征:

net_generator.zero_grad()
labelv = Variable(label.fill_(real_label)) # fake labels are real for generator cost
output = net_discriminator(fake)
err_generator = criterion(output, labelv)
err_generator.backward()
optimizer_generator.step()

看起来与我们在训练假图像上训练鉴别器时做的很相似,除了一些关键的不同之处。我们传递了生成器创建的相同假图像,但这次我们没有从生成它的图中分离它,因为我们希望训练生成器。我们计算损失(err_generator)并计算梯度。然后我们调用生成器优化器,因为我们只想训练生成器,并在生成器生成略微逼真图像之前重复整个过程多次。

训练完整网络

我们已经看了 GAN 训练的各个部分。让我们总结如下,并查看用于训练我们创建的 GAN 网络的完整代码:

  • 用真实图像训练鉴别器网络

  • 用假图像训练鉴别器网络

  • 优化鉴别器

  • 根据鉴别器的反馈训练生成器

  • 仅优化生成器网络

我们将使用以下代码来训练网络:

for epoch in range(niter):
    for i, data in enumerate(dataloader, 0):
        # train with real
        net_discriminator.zero_grad()
        real_cpu, _ = data
        batch_size = real_cpu.size(0)
        if torch.cuda.is_available():
            real_cpu = real_cpu.cuda()
        input.resize_as_(real_cpu).copy_(real_cpu)
        label.resize_(batch_size).fill_(real_label)
        inputv = Variable(input)
        labelv = Variable(label)

        output = net_discriminator(inputv)
        err_discriminator_real = criterion(output, labelv)
        err_discriminator_real.backward()
        D_x = output.data.mean()

        noise.resize_(batch_size, nz, 1, 1).normal_(0, 1)
        noisev = Variable(noise)
        fake = net_generator(noisev)
        labelv = Variable(label.fill_(fake_label))
        output = net_discriminator(fake.detach())
        err_discriminator_fake = criterion(output, labelv)
        err_discriminator_fake.backward()
        D_G_z1 = output.data.mean()
        err_discriminator = err_discriminator_real + err_discriminator_fake
        optimizer_discriminator.step()

        net_generator.zero_grad()
        labelv = Variable(label.fill_(real_label)) # fake labels are real for generator cost
        output = net_discriminator(fake)
        err_generator = criterion(output, labelv)
        err_generator.backward()
        D_G_z2 = output.data.mean()
        optimizer_generator.step()

        print('[%d/%d][%d/%d] Loss_Discriminator: %.4f Loss_Generator: %.4f D(x): %.4f D(G(z)): %.4f / %.4f'
              % (epoch, niter, i, len(dataloader),
                 err_discriminator.data[0], err_generator.data[0], D_x, D_G_z1, D_G_z2))
        if i % 100 == 0:
            vutils.save_image(real_cpu,
                    '%s/real_samples.png' % outf,
                    normalize=True)
            fake = net_generator(fixed_noise)
            vutils.save_image(fake.data,
                    '%s/fake_samples_epoch_%03d.png' % (outf, epoch),
                    normalize=True)

函数 vutils.save_image 将接收一个张量并保存为图像。如果提供了一个图像的小批量,则将它们保存为图像网格。

在接下来的章节中,我们将看看生成的图像和真实图像的样子。

检查生成的图像

因此,让我们比较生成的图像和真实图像。

生成的图像如下所示:

实际图像如下所示:

比较这两组图像,我们可以看到我们的 GAN 能够学习如何生成图像。

总结

在本章中,我们介绍了如何训练能够使用生成网络生成艺术风格转换的深度学习算法。我们还学习了如何使用 GAN 和 DCGAN 生成新图像。在 DCGAN 中,我们探索了使用真实和虚假图像来训练鉴别器,并检查了生成的图像。除了训练生成新图像外,我们还有一个鉴别器,可以用于分类问题。当有限的标记数据可用时,鉴别器学习有关图像的重要特征,这些特征可以用于分类任务。当有限的标记数据时,我们可以训练一个 GAN,它将给我们一个分类器,可以用来提取特征,然后可以在其上构建一个分类器模块。

在下一章中,我们将介绍一些现代架构,如 ResNet 和 Inception,用于构建更好的计算机视觉模型,以及用于构建语言翻译和图像字幕的序列到序列模型。