原文:
zh.annas-archive.org/md5/057fe0c351c5365f1188d1f44806abda
译者:飞龙
第六章:序列数据和文本的深度学习
在上一章中,我们介绍了如何使用卷积神经网络(CNNs)处理空间数据,并构建了图像分类器。在本章中,我们将涵盖以下主题:
-
对于构建深度学习模型有用的文本数据的不同表示形式
-
理解循环神经网络(RNNs)及其不同实现,如长短期记忆(LSTM)和门控循环单元(GRU),它们支持大多数文本和序列数据的深度学习模型
-
使用一维卷积处理序列数据
可以使用 RNN 构建的一些应用包括:
-
文档分类器:识别推文或评论的情感,分类新闻文章
-
序列到序列学习:用于任务如语言翻译,将英语转换为法语
-
时间序列预测:根据前几天的商店销售详情预测商店的销售情况
处理文本数据
文本是常用的序列数据类型之一。文本数据可以看作是字符序列或单词序列。对于大多数问题,将文本视为单词序列是很常见的。深度学习序列模型如 RNN 及其变体能够从文本数据中学习重要模式,可以解决以下领域的问题:
-
自然语言理解
-
文档分类
-
情感分类
这些序列模型也是各种系统的重要构建块,如问答系统(QA)。
尽管这些模型在构建这些应用中非常有用,但由于其固有的复杂性,它们并不理解人类语言。这些序列模型能够成功地找到有用的模式,然后用于执行不同的任务。将深度学习应用于文本是一个快速发展的领域,每个月都有许多新技术问世。我们将介绍支持大多数现代深度学习应用的基本组件。
深度学习模型和其他机器学习模型一样,不理解文本,因此我们需要将文本转换为数值表示。将文本转换为数值表示的过程称为向量化,可以用不同的方法进行,如下所述:
-
将文本转换为单词,并将每个单词表示为一个向量
-
将文本转换为字符,并将每个字符表示为一个向量
-
创建n-gram 单词并将它们表示为向量
文本数据可以分解为这些表示之一。每个文本的较小单元称为标记,将文本分解为标记的过程称为标记化。Python 中有很多强大的库可以帮助我们进行标记化。一旦我们将文本数据转换为标记,我们就需要将每个标记映射到一个向量上。一热编码和词嵌入是将标记映射到向量的两种最流行的方法。以下图表总结了将文本转换为其向量表示的步骤:
让我们更详细地看一下标记化、n-gram 表示和向量化。
标记化
给定一个句子,将其拆分为字符或单词称为标记化。有一些库,比如 spaCy,提供了复杂的标记化解决方案。让我们使用简单的 Python 函数,比如 split
和 list
,将文本转换为标记。
为了演示标记化在字符和单词上的工作原理,让我们考虑电影《雷神 3:诸神黄昏》的简短评论。我们将使用以下文本:
这部电影的动作场面非常出色。在 MCU 中,Thor 从未如此史诗般。他在这部电影中表现得相当史诗,绝对不再是无能为力。Thor 在这部电影中释放了自我,我喜欢这一点。
将文本转换为字符
Python 的 list
函数接受一个字符串并将其转换为单个字符的列表。这完成了将文本转换为字符的任务。以下代码块展示了使用的代码和结果:
thor_review = "the action scenes were top notch in this movie. Thor has never been this epic in the MCU. He does some pretty epic sh*t in this movie and he is definitely not under-powered anymore. Thor in unleashed in this, I love that."
print(list(thor_review))
结果如下:
#Results
['t', 'h', 'e', ' ', 'a', 'c', 't', 'i', 'o', 'n', ' ', 's', 'c', 'e', 'n', 'e', 's', ' ', 'w', 'e', 'r', 'e', ' ', 't', 'o', 'p', ' ', 'n', 'o', 't', 'c', 'h', ' ', 'i', 'n', ' ', 't', 'h', 'i', 's', ' ', 'm', 'o', 'v', 'i', 'e', '.', ' ', 'T', 'h', 'o', 'r', ' ', 'h', 'a', 's', ' ', 'n', 'e', 'v', 'e', 'r', ' ', 'b', 'e', 'e', 'n', ' ', 't', 'h', 'i', 's', ' ', 'e', 'p', 'i', 'c', ' ', 'i', 'n', ' ', 't', 'h', 'e', ' ', 'M', 'C', 'U', '.', ' ', 'H', 'e', ' ', 'd', 'o', 'e', 's', ' ', 's', 'o', 'm', 'e', ' ', 'p', 'r', 'e', 't', 't', 'y', ' ', 'e', 'p', 'i', 'c', ' ', 's', 'h', '*', 't', ' ', 'i', 'n', ' ', 't', 'h', 'i', 's', ' ', 'm', 'o', 'v', 'i', 'e', ' ', 'a', 'n', 'd', ' ', 'h', 'e', ' ', 'i', 's', ' ', 'd', 'e', 'f', 'i', 'n', 'i', 't', 'e', 'l', 'y', ' ', 'n', 'o', 't', ' ', 'u', 'n', 'd', 'e', 'r', '-', 'p', 'o', 'w', 'e', 'r', 'e', 'd', ' ', 'a', 'n', 'y', 'm', 'o', 'r', 'e', '.', ' ', 'T', 'h', 'o', 'r', ' ', 'i', 'n', ' ', 'u', 'n', 'l', 'e', 'a', 's', 'h', 'e', 'd', ' ', 'i', 'n', ' ', 't', 'h', 'i', 's', ',', ' ', 'I', ' ', 'l', 'o', 'v', 'e', ' ', 't', 'h', 'a', 't', '.']
这个结果展示了我们简单的 Python 函数如何将文本转换为标记。
将文本转换为单词
我们将使用 Python 字符串对象中的 split
函数将文本分割成单词。split
函数接受一个参数,根据此参数将文本分割为标记。对于我们的示例,我们将使用空格作为分隔符。以下代码块演示了如何使用 Python 的 split
函数将文本转换为单词:
print(Thor_review.split())
#Results
['the', 'action', 'scenes', 'were', 'top', 'notch', 'in', 'this', 'movie.', 'Thor', 'has', 'never', 'been', 'this', 'epic', 'in', 'the', 'MCU.', 'He', 'does', 'some', 'pretty', 'epic', 'sh*t', 'in', 'this', 'movie', 'and', 'he', 'is', 'definitely', 'not', 'under-powered', 'anymore.', 'Thor', 'in', 'unleashed', 'in', 'this,', 'I', 'love', 'that.']
在上述代码中,我们没有使用任何分隔符;split
函数默认在空格上分割。
N-gram 表示
我们已经看到文本可以表示为字符和单词。有时候,查看两个、三个或更多单词在一起是很有用的。N-gram 是从给定文本中提取的一组单词。在 n-gram 中,n 表示可以一起使用的单词数。让我们看一个 bigram(n=2)的示例。我们使用 Python 的 nltk
包为 thor_review
生成了一个 bigram。以下代码块展示了 bigram 的结果以及生成它所使用的代码:
from nltk import ngrams
print(list(ngrams(thor_review.split(),2)))
#Results
[('the', 'action'), ('action', 'scenes'), ('scenes', 'were'), ('were', 'top'), ('top', 'notch'), ('notch', 'in'), ('in', 'this'), ('this', 'movie.'), ('movie.', 'Thor'), ('Thor', 'has'), ('has', 'never'), ('never', 'been'), ('been', 'this'), ('this', 'epic'), ('epic', 'in'), ('in', 'the'), ('the', 'MCU.'), ('MCU.', 'He'), ('He', 'does'), ('does', 'some'), ('some', 'pretty'), ('pretty', 'epic'), ('epic', 'sh*t'), ('sh*t', 'in'), ('in', 'this'), ('this', 'movie'), ('movie', 'and'), ('and', 'he'), ('he', 'is'), ('is', 'definitely'), ('definitely', 'not'), ('not', 'under-powered'), ('under-powered', 'anymore.'), ('anymore.', 'Thor'), ('Thor', 'in'), ('in', 'unleashed'), ('unleashed', 'in'), ('in', 'this,'), ('this,', 'I'), ('I', 'love'), ('love', 'that.')]
ngrams
函数接受一系列单词作为第一个参数,并将要分组的单词数作为第二个参数。以下代码块展示了三元组表示法的样子,以及用于它的代码:
print(list(ngrams(thor_review.split(),3)))
#Results
[('the', 'action', 'scenes'), ('action', 'scenes', 'were'), ('scenes', 'were', 'top'), ('were', 'top', 'notch'), ('top', 'notch', 'in'), ('notch', 'in', 'this'), ('in', 'this', 'movie.'), ('this', 'movie.', 'Thor'), ('movie.', 'Thor', 'has'), ('Thor', 'has', 'never'), ('has', 'never', 'been'), ('never', 'been', 'this'), ('been', 'this', 'epic'), ('this', 'epic', 'in'), ('epic', 'in', 'the'), ('in', 'the', 'MCU.'), ('the', 'MCU.', 'He'), ('MCU.', 'He', 'does'), ('He', 'does', 'some'), ('does', 'some', 'pretty'), ('some', 'pretty', 'epic'), ('pretty', 'epic', 'sh*t'), ('epic', 'sh*t', 'in'), ('sh*t', 'in', 'this'), ('in', 'this', 'movie'), ('this', 'movie', 'and'), ('movie', 'and', 'he'), ('and', 'he', 'is'), ('he', 'is', 'definitely'), ('is', 'definitely', 'not'), ('definitely', 'not', 'under-powered'), ('not', 'under-powered', 'anymore.'), ('under-powered', 'anymore.', 'Thor'), ('anymore.', 'Thor', 'in'), ('Thor', 'in', 'unleashed'), ('in', 'unleashed', 'in'), ('unleashed', 'in', 'this,'), ('in', 'this,', 'I'), ('this,', 'I', 'love'), ('I', 'love', 'that.')]
在前述代码中唯一改变的是函数的第二个参数n-值。
许多监督学习模型,例如朴素贝叶斯,使用n-gram 来改善它们的特征空间。n-gram 也用于拼写纠正和文本摘要任务。
n-gram 表示的一个挑战是它丢失了文本的顺序性质。它通常与浅层机器学习模型一起使用。这种技术在深度学习中很少使用,因为像 RNN 和 Conv1D 这样的架构可以自动学习这些表示。
向量化
有两种流行的方法可以将生成的标记映射到数字向量中,称为单热编码和词嵌入。让我们通过编写一个简单的 Python 程序来了解如何将标记转换为这些向量表示。我们还将讨论每种方法的各种优缺点。
单热编码
在单热编码中,每个标记由长度为 N 的向量表示,其中N是文档中唯一单词的数量。让我们看一个简单的句子,并观察每个标记如何表示为单热编码向量。以下是句子及其相关标记表示:
一天一个苹果,医生远离你。
前述句子的单热编码可以用表格格式表示如下:
An | 100000000 |
---|---|
apple | 010000000 |
a | 001000000 |
day | 000100000 |
keeps | 000010000 |
doctor | 000001000 |
away | 000000100 |
said | 000000010 |
the | 000000001 |
此表描述了标记及其单热编码表示。向量长度为 9,因为句子中有九个唯一单词。许多机器学习库已经简化了创建单热编码变量的过程。我们将编写自己的实现以便更容易理解,并可以使用相同的实现来构建后续示例所需的其他特征。以下代码包含了一个Dictionary
类,其中包含创建唯一单词字典以及返回特定单词的单热编码向量的功能。让我们看一下代码,然后逐个功能进行解释:
class Dictionary(object):
def __init__(self):
self.word2idx = {}
self.idx2word = []
self.length = 0
def add_word(self,word):
if word not in self.idx2word:
self.idx2word.append(word)
self.word2idx[word] = self.length + 1
self.length += 1
return self.word2idx[word]
def __len__(self):
return len(self.idx2word)
def onehot_encoded(self,word):
vec = np.zeros(self.length)
vec[self.word2idx[word]] = 1
return vec
前述代码提供了三个重要功能:
-
初始化函数
__init__
创建了一个word2idx
字典,它将存储所有唯一单词及其索引。idx2word
列表存储所有唯一单词,length
变量包含文档中唯一单词的总数。 -
add_word
函数接受一个单词并将其添加到word2idx
和idx2word
中,并增加词汇表的长度(假设单词是唯一的)。 -
onehot_encoded
函数接受一个单词并返回一个长度为 N 的向量,其中除了单词的索引处为一之外,所有其他值都为零。如果传递的单词的索引是二,则向量在索引二处的值将为一,所有其他值将为零。
由于我们已经定义了Dictionary
类,让我们在thor_review
数据上使用它。以下代码演示了如何构建word2idx
以及如何调用我们的onehot_encoded
函数:
dic = Dictionary()
for tok in thor_review.split():
dic.add_word(tok)
print(dic.word2idx)
前述代码的输出如下:
# Results of word2idx
{'the': 1, 'action': 2, 'scenes': 3, 'were': 4, 'top': 5, 'notch': 6, 'in': 7, 'this': 8, 'movie.': 9, 'Thor': 10, 'has': 11, 'never': 12, 'been': 13, 'epic': 14, 'MCU.': 15, 'He': 16, 'does': 17, 'some': 18, 'pretty': 19, 'sh*t': 20, 'movie': 21, 'and': 22, 'he': 23, 'is': 24, 'definitely': 25, 'not': 26, 'under-powered': 27, 'anymore.': 28, 'unleashed': 29, 'this,': 30, 'I': 31, 'love': 32, 'that.': 33}
单词were
的一热编码如下:
# One-hot representation of the word 'were'
dic.onehot_encoded('were')
array([ 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0.,
0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
0., 0., 0., 0., 0., 0., 0.])
一热表示法的一个挑战是数据过于稀疏,且随着词汇表中独特单词数量的增加,向量的大小迅速增长,这被认为是一种限制,因此在深度学习中很少使用。
词嵌入
词嵌入是在深度学习算法解决的文本数据中表示问题的非常流行的方法。词嵌入提供了一个由浮点数填充的单词的密集表示。向量维度根据词汇表的大小而变化。通常使用的词嵌入维度大小有 50、100、256、300,有时候甚至是 1,000。维度大小是我们在训练阶段需要调整的超参数之一。
如果我们试图用一热表示法表示一个大小为 20,000 的词汇表,那么我们将得到 20,000 x 20,000 个数字,其中大部分将是零。同样的词汇表可以用词嵌入表示为大小为 20,000 x 维度大小的形式,其中维度大小可以是 10、50、300 等。
创建词嵌入的一种方法是从每个标记的随机数密集向量开始,然后训练一个模型,如文档分类器或情感分类器。代表标记的浮点数将以一种使语义上更接近的单词具有类似表示的方式进行调整。为了理解它,让我们看看以下图例,其中我们在五部电影的二维图上绘制了词嵌入向量:
前述图像展示了如何调整密集向量以使语义相似的单词之间具有较小的距离。由于像Superman、Thor和Batman这样的电影标题是基于漫画的动作电影,它们的嵌入更接近,而电影Titanic的嵌入则远离动作电影,更接近电影Notebook,因为它们是浪漫电影。
学习词嵌入可能在数据量太少时不可行,在这种情况下,我们可以使用由其他机器学习算法训练的词嵌入。从另一个任务生成的嵌入称为预训练词嵌入。我们将学习如何构建自己的词嵌入并使用预训练词嵌入。
通过构建情感分类器训练词嵌入
在上一节中,我们简要介绍了单词嵌入的概念,但没有实现它。在本节中,我们将下载一个名为IMDB
的数据集,其中包含评论,并构建一个情感分类器,用于判断评论的情感是积极的、消极的还是未知的。在构建过程中,我们还将为IMDB
数据集中的单词训练单词嵌入。我们将使用一个名为torchtext
的库,它通过提供不同的数据加载器和文本抽象化简化了许多与自然语言处理(NLP)相关的活动。训练情感分类器将涉及以下步骤:
-
下载 IMDB 数据并执行文本标记化
-
建立词汇表
-
生成向量批次
-
创建带有嵌入的网络模型
-
训练模型
下载 IMDB 数据并执行文本标记化
对于与计算机视觉相关的应用程序,我们使用了torchvision
库,该库为我们提供了许多实用函数,帮助构建计算机视觉应用程序。同样,还有一个名为torchtext
的库,它是 PyTorch 的一部分,专门用于处理与 PyTorch 相关的许多文本活动,如下载、文本向量化和批处理。在撰写本文时,torchtext
不随 PyTorch 安装而提供,需要单独安装。您可以在您的计算机命令行中运行以下代码来安装torchtext
:
pip install torchtext
一旦安装完成,我们将能够使用它。Torchtext 提供了两个重要的模块,称为torchtext.data
和torchtext.datasets
。
我们可以从以下链接下载IMDB Movies
数据集:
www.kaggle.com/orgesleka/imdbmovies
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
数据集并将其分割为train
和test
数据集。以下代码执行此操作,当您第一次运行它时,根据您的宽带连接速度,可能需要几分钟时间从互联网上下载IMDB
数据集:
train, test = datasets.IMDB.splits(TEXT, LABEL)
先前数据集的IMDB
类将下载、标记和分割数据库到train
和test
数据集中所涉及的所有复杂性抽象化。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'}
我们可以从这些结果看到,单个元素包含一个字段text
,以及表示text
的所有标记,还有一个包含文本标签的label
字段。现在我们已经准备好对IMDB
数据集进行批处理。
构建词汇表
当我们为thor_review
创建一位热编码时,我们创建了一个word2idx
字典,它被称为词汇表,因为它包含文档中所有唯一单词的详细信息。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
是可配置的。
一旦词汇表构建完成,我们可以获取不同的值,如频率、单词索引和每个单词的向量表示。以下代码演示了如何访问这些值:
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_size
、device
(GPU 或 CPU)和shuffle
(数据是否需要洗牌)。以下代码演示了如何创建迭代器以为train
和test
数据集生成批次:
train_iter, test_iter = data.BucketIterator.splits((train, test), batch_size=128, device=-1,shuffle=True)
#device = -1 represents cpu , if u want gpu leave it to None.
上述代码为train
和test
数据集提供了一个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
)的矩阵,即(128x20
)。
创建带嵌入的网络模型
我们之前简要讨论了词嵌入。在本节中,我们将词嵌入作为网络架构的一部分,并训练整个模型来预测每个评论的情感。训练结束时,我们将得到一个情感分类器模型,以及针对IMDB
数据集的词嵌入。以下代码展示了如何创建一个使用词嵌入来预测情感的网络架构:
class EmbNet(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)
在前面的代码中,EmbNet
创建了用于情感分类的模型。在__init__
函数内部,我们初始化了nn.Embedding
类的一个对象,它接受两个参数,即词汇表的大小和我们希望为每个单词创建的维度。由于我们限制了唯一单词的数量,词汇表大小将为 10,000,并且我们可以从小的嵌入大小10
开始。对于快速运行程序,小的嵌入大小是有用的,但当您尝试为生产系统构建应用程序时,请使用较大的嵌入。我们还有一个线性层,将词嵌入映射到类别(积极、消极或未知)。
forward
函数确定如何处理输入数据。对于批量大小为 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.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 = [],[]
val_losses , val_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')
val_epoch_loss , val_epoch_accuracy = fit(epoch,model,test_iter,phase='validation')
train_losses.append(epoch_loss)
train_accuracy.append(epoch_accuracy)
val_losses.append(val_epoch_loss)
val_accuracy.append(val_epoch_accuracy)
在上述代码中,我们通过传递我们为批处理数据创建的 BucketIterator
对象调用 fit
方法。迭代器默认不会停止生成批次,因此我们必须将 BucketIterator
对象的 repeat
变量设置为 False
。如果我们不将 repeat
变量设置为 False
,则 fit
函数将无限运行。在大约 10 个 epochs 的训练后,模型的验证准确率约为 70%。
使用预训练的词嵌入
在特定领域(如医学和制造业)工作时,预训练的词嵌入非常有用,因为我们有大量数据可以训练嵌入。当我们有少量数据无法进行有意义的训练时,我们可以使用在不同数据集(如维基百科、Google 新闻和 Twitter 推文)上训练的嵌入。许多团队都有使用不同方法训练的开源词嵌入。在本节中,我们将探讨 torchtext
如何简化使用不同词嵌入,并如何在我们的 PyTorch 模型中使用它们。这类似于我们在计算机视觉应用中使用的迁移学习。通常,使用预训练嵌入会涉及以下步骤:
-
下载嵌入
-
加载模型中的嵌入
-
冻结嵌入层权重
让我们详细探讨每个步骤的实现方式。
下载嵌入
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,)
参数向量的值表示使用的嵌入类别。name
和 dim
参数确定可以使用的嵌入。我们可以轻松地从 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
... ⋱ ...
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]
现在我们已经下载并将嵌入映射到我们的词汇表中。让我们了解如何在 PyTorch 模型中使用它们。
加载模型中的嵌入
vectors
变量返回一个形状为vocab_size x dimensions
的 torch 张量,其中包含预训练的嵌入。我们必须将这些嵌入的权重存储到我们的嵌入层权重中。我们可以通过访问嵌入层权重来分配嵌入的权重,如下面的代码所示。
model.embedding.weight.data = TEXT.vocab.vectors
model
代表我们网络的对象,embedding
代表嵌入层。因为我们使用了新维度的嵌入层,线性层输入会有些变化。下面的代码展示了新的架构,与我们之前训练嵌入时使用的架构类似:
class EmbNet(nn.Module):
def __init__(self,emb_size,hidden_size1,hidden_size2=400):
super().__init__()
self.embedding = nn.Embedding(emb_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(embeds)
return F.log_softmax(out,dim=-1)
model = EmbNet(len(TEXT.vocab.stoi),300,12000)
加载嵌入向量后,我们必须确保在训练过程中不改变嵌入层权重。让我们讨论如何实现这一点。
冻结嵌入层权重
告诉 PyTorch 不要改变嵌入层权重是一个两步骤过程:
-
将
requires_grad
属性设置为False
,告诉 PyTorch 不需要这些权重的梯度。 -
移除传递给优化器的嵌入层参数。如果不执行此步骤,则优化器会抛出错误,因为它期望所有参数都有梯度。
下面的代码展示了如何轻松冻结嵌入层权重,并告知优化器不使用这些参数:
model.embedding.weight.requires_grad = False
optimizer = optim.SGD([ param for param in model.parameters() if param.requires_grad == True],lr=0.001)
通常我们将所有模型参数传递给优化器,但在前面的代码中,我们传递了requires_grad
为True
的参数。
我们可以使用这段代码训练模型,并应该获得类似的准确度。所有这些模型架构都没有利用文本的序列性质。在下一节中,我们将探讨两种流行的技术,即 RNN 和 Conv1D,它们利用数据的序列性质。
递归神经网络
RNN 是最强大的模型之一,使我们能够处理分类、序列数据标签和文本生成等应用(例如SwiftKey键盘应用可以预测下一个词),以及将一种序列转换为另一种语言,例如从法语到英语。大多数模型架构如前馈神经网络没有利用数据的序列性质。例如,我们需要数据来表示每个示例的特征向量,比如代表句子、段落或文档的所有标记。前馈网络设计只是一次性查看所有特征并将其映射到输出。让我们看一个文本示例,展示为什么文本的顺序或序列性质很重要。I had cleaned my car 和 I had my car cleaned 是两个英语句子,包含相同的单词集合,但只有在考虑单词顺序时它们的含义不同。
人类通过从左到右阅读单词并构建一个强大的模型来理解文本数据。RNN 的工作方式略有相似,它一次查看文本中的一个单词。RNN 也是一种神经网络,其中有一个特殊的层,该层循环处理数据而不是一次性处理所有数据。由于 RNN 可以按顺序处理数据,我们可以使用不同长度的向量并生成不同长度的输出。以下图像提供了一些不同的表示形式:
图片来源:karpathy.github.io/2015/05/21/rnn-effectiveness/
上述图片来自 RNN 的一个著名博客(karpathy.github.io/2015/05/21/rnn-effectiveness
),作者 Andrej Karpathy 讲解如何使用 Python 从头构建 RNN,并将其用作序列生成器。
通过示例理解 RNN 的工作原理
让我们从假设我们已经构建了一个 RNN 模型开始,并尝试理解它提供了哪些功能。一旦我们了解了 RNN 的功能,然后让我们探索 RNN 内部发生了什么。
让我们将《雷神》影评作为 RNN 模型的输入。我们正在查看的示例文本是这部电影的动作场面非常棒……。我们首先将第一个词the传递给我们的模型;模型生成两种不同的东西,一个状态向量和一个输出向量。状态向量在处理评论中的下一个词时传递给模型,并生成一个新的状态向量。我们只考虑在最后一个序列中模型生成的输出。下图总结了这一过程:
上述图示示了以下内容:
-
如何通过展开和图像来理解 RNN 的工作方式
-
如何递归地将状态传递给相同的模型
到现在为止,您应该已经了解了 RNN 的作用,但还不清楚它是如何工作的。在我们深入了解其工作原理之前,让我们先看一个代码片段,详细展示我们所学的内容。我们仍然将 RNN 视为一个黑匣子:
rnn = RNN(input_size, hidden_size,output_size)
for i in range(len(Thor_review):
output, hidden = rnn(thor_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
函数将input
向量和hidden
向量结合起来,并通过两个线性层将其传递,生成一个输出向量和一个隐藏状态。对于output
层,我们应用了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)仅仅落后几个词。再来看一个更长的段落,依赖关系不必那么紧密,我们想预测其中的最后一个词。句子看起来像“我出生在金奈,一个坐落在泰米尔纳德邦的城市。在印度的不同州接受教育,我说…”。在实践中,普通版本的 RNN 很难记住序列前部发生的上下文。LSTM 及其他不同的 RNN 变体通过在 LSTM 内部添加不同的神经网络来解决这个问题,后者决定可以记住多少或者可以记住什么数据。
LSTM 网络
LSTM 是一种特殊类型的 RNN,能够学习长期依赖关系。它们于 1997 年引入,并随着可用数据和硬件的进展在过去几年中变得流行起来。它们在各种问题上表现出色,并被广泛使用。
LSTM 的设计旨在通过一种设计自然地记住信息以解决长期依赖问题。在 RNN 中,我们看到它们如何在序列的每个元素上重复自身。在标准 RNN 中,重复模块将具有像单个线性层的简单结构。
下图显示了一个简单的 RNN 如何重复自身:
在 LSTM 内部,我们不是使用简单的线性层,而是在 LSTM 内部有较小的网络,这些网络完成独立的工作。以下图表展示了 LSTM 内部发生的情况:
图像来源:http://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 结合。现在我们可以通过以下公式更新细胞状态,即在遗忘门和 i[t ]与 C[t] 乘积之和的逐元素乘法之间,如下所示:
最后,我们需要决定输出,这将是细胞状态的过滤版本。有不同版本的 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 网络,我们可以用来构建情感分类器。像往常一样,我们将按照以下步骤创建分类器:
-
准备数据
-
创建批次
-
创建网络
-
训练模型
准备数据
我们使用相同的 torchtext 来下载、分词和构建IMDB
数据集的词汇表。在创建Field
对象时,我们将batch_first
参数保留为False
。RNN 网络期望数据的形式是Sequence_length
、batch_size
和特征。以下用于准备数据集:
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 = [],[]
val_losses , val_accuracy = [],[]
for epoch in range(1,5):
epoch_loss, epoch_accuracy = fit(epoch,model,train_iter,phase='training')
val_epoch_loss , val_epoch_accuracy = fit(epoch,model,test_iter,phase='validation')
train_losses.append(epoch_loss)
train_accuracy.append(epoch_accuracy)
val_losses.append(val_epoch_loss)
val_accuracy.append(val_epoch_accuracy)
以下是训练模型的结果:
#Results
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
对模型进行四次纪元的训练得到了 84%的准确率。训练更多的纪元导致了一个过拟合的模型,因为损失开始增加。我们可以尝试一些我们尝试过的技术,如减少隐藏维度、增加序列长度以及以更小的学习率进行训练以进一步提高准确性。
我们还将探讨如何使用一维卷积来对序列数据进行训练。
序列数据上的卷积网络
我们学习了 CNN 如何通过从图像中学习特征解决计算机视觉问题。在图像中,CNN 通过在高度和宽度上进行卷积来工作。同样,时间可以被视为一个卷积特征。一维卷积有时比 RNN 表现更好,并且计算成本更低。在过去几年中,像 Facebook 这样的公司已经展示出在音频生成和机器翻译方面的成功。在本节中,我们将学习如何使用 CNN 构建文本分类解决方案。
理解序列数据上的一维卷积
在第五章 计算机视觉的深度学习 中,我们看到了如何从训练数据中学习二维权重。这些权重在图像上移动以生成不同的激活。类似地,一维卷积激活在我们的文本分类器训练中也是学习的,这些权重通过在数据上移动来学习模式。以下图解释了一维卷积是如何工作的:
为了在IMDB
数据集上训练文本分类器,我们将按照构建使用 LSTM 分类器的步骤进行操作。唯一不同的是,我们使用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)
在上面的代码中,我们有一个Conv1d
层和一个AdaptiveAvgPool1d
层而不是一个 LSTM 层。卷积层接受序列长度作为其输入大小,输出大小为隐藏大小,内核大小为 3。由于我们必须改变线性层的维度,每次尝试以不同的长度运行时,我们使用一个AdaptiveAvgpool1d
,它接受任意大小的输入并生成给定大小的输出。因此,我们可以使用一个固定大小的线性层。其余代码与我们在大多数网络架构中看到的代码类似。
训练模型
模型的训练步骤与上一个例子相同。让我们看看调用fit
方法的代码以及它生成的结果:
train_losses , train_accuracy = [],[]
val_losses , val_accuracy = [],[]
for epoch in range(1,5):
epoch_loss, epoch_accuracy = fit(epoch,model,train_iter,phase='training')
val_epoch_loss , val_epoch_accuracy = fit(epoch,model,test_iter,phase='validation')
train_losses.append(epoch_loss)
train_accuracy.append(epoch_accuracy)
val_losses.append(val_epoch_loss)
val_accuracy.append(val_epoch_accuracy)
我们对模型进行了四轮训练,达到了大约 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
由于验证损失
在三轮后开始增加,我停止了模型的运行。我们可以尝试几件事情来改善结果,如使用预训练权重、添加另一个卷积层,并在卷积之间尝试MaxPool1d
层。我把这些尝试留给你来测试是否能提高准确率。
概要
在本章中,我们学习了不同的技术来表示深度学习中的文本数据。我们学习了如何在处理不同领域时使用预训练的词嵌入和我们自己训练的词嵌入。我们使用 LSTM 和一维卷积构建了文本分类器。
在下一章中,我们将学习如何训练深度学习算法来生成时尚图像和新图像,并生成文本。
第七章:生成网络
在前几章中,我们看到的所有示例都集中在解决分类或回归等问题上。这一章对理解深度学习如何发展以解决无监督学习问题非常有趣和重要。
在本章中,我们将训练网络,学习如何创建:
-
基于内容和特定艺术风格生成的图像,通常称为风格迁移
-
使用特定类型的**生成对抗网络(GAN)**生成新人物面孔
-
使用语言建模生成新文本
这些技术构成了深度学习领域大部分高级研究的基础。深入探讨 GAN 和语言建模等子领域的确切细节超出了本书的范围,它们值得有专门的书籍来介绍。我们将学习它们的一般工作原理以及在 PyTorch 中构建它们的过程。
神经风格迁移
我们人类以不同的准确度和复杂性生成艺术作品。尽管创作艺术的过程可能非常复杂,但它可以被视为两个最重要因素的结合,即要绘制什么和如何绘制。绘制什么受我们周围所见的启发,而如何绘制也将受到我们周围某些事物的影响。从艺术家的角度来看,这可能是一种过度简化,但对于理解如何使用深度学习算法创建艺术作品,它非常有用。我们将训练一个深度学习算法,从一幅图像中获取内容,然后按照特定的艺术风格进行绘制。如果您是艺术家或从事创意行业,您可以直接利用近年来进行的惊人研究来改进并在您工作的领域内创造出一些很酷的东西。即使您不是,它也会向您介绍生成模型领域,其中网络生成新内容。
让我们在高层次理解神经风格迁移的过程,然后深入细节,以及构建它所需的 PyTorch 代码。风格迁移算法提供了一个内容图像(C)和一个风格图像(S),算法必须生成一个新图像(O),该图像具有来自内容图像的内容和来自风格图像的风格。这一创建神经风格迁移的过程由 Leon Gates 等人于 2015 年介绍(艺术风格的神经算法)。以下是我们将使用的内容图像(C):
图像来源:https://arxiv.org/pdf/1508.06576.pdf
下面是样式图像(S):
图像来源:https://arxiv.org/pdf/1508.06576.pdf
这是我们将要生成的图像:
图像来源:https://arxiv.org/pdf/1508.06576.pdf
从理解卷积神经网络(CNNs)工作方式的角度来看,样式转移的背后思想变得直观。当 CNN 用于对象识别训练时,训练的 CNN 的早期层学习非常通用的信息,如线条、曲线和形状。CNN 的最后层捕捉图像的更高级概念,如眼睛、建筑物和树木。因此,类似图像的最后层的值倾向于更接近。我们将相同的概念应用于内容损失。内容图像和生成图像的最后一层应该是相似的,我们使用均方误差(MSE)来计算相似性。我们使用优化算法降低损失值。
通过称为格拉姆矩阵的技术,CNN 通常在多个层次上捕获图像的样式。格拉姆矩阵计算跨多个层次捕获的特征映射之间的相关性。格拉姆矩阵提供计算样式的一种方法。具有类似风格的图像对于格拉姆矩阵具有相似的值。样式损失也是使用样式图像和生成图像的格拉姆矩阵之间的 MSE 计算的。
我们将使用提供在 torchvision 模型中的预训练 VGG19 模型。训练样式转移模型所需的步骤与任何其他深度学习模型类似,唯一不同的是计算损失比分类或回归模型更复杂。神经风格算法的训练可以分解为以下步骤:
-
加载数据。
-
创建 VGG19 模型。
-
定义内容损失。
-
定义样式损失。
-
从 VGG 模型中提取跨层的损失。
-
创建优化器。
-
训练 - 生成与内容图像类似的图像,并且风格与样式图像类似。
加载数据。
加载数据与我们在第五章中解决图像分类问题所见的方式类似,《计算机视觉深度学习》。我们将使用预训练的 VGG 模型,因此必须使用与预训练模型相同的值对图像进行标准化。
以下代码展示了我们如何做到这一点。代码大部分是自解释的,因为我们已经在前几章中详细讨论过:
#Fixing the size of the image, reduce it further if you are not using a GPU.
imsize = 512
is_cuda = torch.cuda.is_available()
#Converting image ,making it suitable for training using the VGG model.
prep = transforms.Compose([transforms.Resize(imsize),
transforms.ToTensor(),
transforms.Lambda(lambda x: x[torch.LongTensor([2,1,0])]), #turn to BGR
transforms.Normalize(mean=[0.40760392, 0.45795686, 0.48501961], #subtract imagenet mean
std=[1,1,1]),
transforms.Lambda(lambda x: x.mul_(255)),
])
#Converting the generated image back to a format which we can visualise.
postpa = transforms.Compose([transforms.Lambda(lambda x: x.mul_(1./255)),
transforms.Normalize(mean=[-0.40760392, -0.45795686, -0.48501961], #add imagenet mean
std=[1,1,1]),
transforms.Lambda(lambda x: x[torch.LongTensor([2,1,0])]), #turn to RGB
])
postpb = transforms.Compose([transforms.ToPILImage()])
#This method ensures data in the image does not cross the permissible range .
def postp(tensor): # to clip results in the range [0,1]
t = postpa(tensor)
t[t>1] = 1
t[t<0] = 0
img = postpb(t)
return img
#A utility function to make data loading easier.
def image_loader(image_name):
image = Image.open(image_name)
image = Variable(prep(image))
# fake batch dimension required to fit network's input dimensions
image = image.unsqueeze(0)
return image
在此代码中,我们定义了三个功能,prep
执行所有所需的预处理,并使用与训练 VGG 模型相同的值进行标准化。模型的输出需要恢复到其原始值;postpa
函数执行所需的处理。生成的模型可能超出接受值的范围,postp
函数将所有大于 1 的值限制为 1,所有小于 0 的值限制为 0。最后,image_loader
函数加载图像,应用预处理转换,并将其转换为变量。以下功能加载样式和内容图像:
style_img = image_loader("Images/vangogh_starry_night.jpg")
content_img = image_loader("Images/Tuebingen_Neckarfront.jpg")
我们可以创建一个带有噪声(随机数)的图像,也可以使用相同的内容图像。在这种情况下,我们将使用内容图像。以下代码创建内容图像:
opt_img = Variable(content_img.data.clone(),requires_grad=True)
我们将使用优化器调整 opt_img
的值,以便图像更接近内容图像和样式图像。因此,我们通过指定 requires_grad=True
要求 PyTorch 保持梯度。
创建 VGG 模型
我们将从 torchvisions.models
中加载一个预训练模型。我们将仅使用此模型来提取特征,PyTorch 的 VGG 模型被定义为所有卷积块在 features
模块中,而全连接或线性层在 classifier
模块中。由于我们不会训练 VGG 模型中的任何权重或参数,我们还将冻结该模型。以下代码演示了同样的操作:
#Creating a pretrained VGG model
vgg = vgg19(pretrained=True).features
#Freezing the layers as we will not use it for training.
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 loss
函数来传递生成的输出。
样式损失
样式损失 在多个层次上计算。样式损失是每个特征图生成的格拉姆矩阵的均方误差(MSE)。格拉姆矩阵表示其特征的相关性值。让我们通过以下图表和代码实现来理解格拉姆矩阵的工作方式。
以下表格显示了具有列属性 Batch_size
、Channels
和 Values
的特征图维度为 [2, 3, 3, 3] 的输出:
要计算格拉姆矩阵,我们将所有通道的值展平,然后通过与其转置相乘找到相关性,如下表所示:
我们所做的只是将所有通道的值展平为单个向量或张量。以下代码实现了这一点:
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 矩阵的值除以元素数量来完成 Gram 矩阵的值归一化。这可以防止具有大量值的特定特征图支配得分。一旦计算了GramMatrix
,就可以简单地计算风格损失,该损失在以下代码中实现:
class StyleLoss(nn.Module):
def forward(self,inputs,targets):
out = nn.MSELoss()(GramMatrix()(inputs),targets)
return (out)
StyleLoss
作为另一个 PyTorch 层实现。它计算输入GramMatrix
值与风格图像GramMatrix
值之间的均方误差。
提取损失
就像我们在《深度学习计算机视觉》第五章中使用register_forward_hook()
函数提取卷积层的激活一样,我们可以提取不同卷积层的损失,用于计算风格损失和内容损失。这种情况的一个不同之处在于,我们不是从一个层中提取,而是需要从多个层中提取输出。以下类整合了所需的变更:
class LayerActivations():
features=[]
def __init__(self,model,layer_nums):
self.hooks = []
for layer_num in layer_nums:
self.hooks.append(model[layer_num].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 调用。在函数内部,我们捕获输出并将其存储在features
数组中。
当我们不想捕获输出时,我们需要调用remove
函数一次。忘记调用remove
方法可能导致内存不足异常,因为所有输出都会累积。
让我们编写另一个实用函数,它可以提取用于风格和内容图像的输出。以下函数执行相同操作:
def extract_layers(layers,img,model=None):
la = LayerActivations(model,layers)
#Clearing the cache
la.features = []
out = model(img)
la.remove()
return la.features
在extract_layers
函数内部,我们通过传入模型和层编号来创建LayerActivations
类的对象。特征列表可能包含先前运行的输出,因此我们将其重新初始化为空列表。然后我们通过模型传入图像,我们不会使用输出。我们更感兴趣的是生成在features
数组中的输出。我们调用remove
方法从模型中移除所有注册的钩子并返回特征。以下代码展示了我们如何提取风格和内容图像所需的目标:
content_targets = extract_layers(content_layers,content_img,model=vgg)
style_targets = extract_layers(style_layers,style_img,model=vgg)
一旦我们提取了目标,我们需要从创建它们的图中分离输出。请记住,所有这些输出都是保持它们如何创建的 PyTorch 变量。但对于我们的情况,我们只对输出值感兴趣,而不是图,因为我们不会更新style
图像或content
图像。以下代码说明了这种技术:
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 仓库中论文实现中使用的相同(github.com/leongatys/PytorchNeuralStyleTransfer
)。我们的实现是作者实现的略微修改版本。以下代码描述了正在使用的权重,这些权重由所选层中的滤波器数量计算得出:
style_weights = [1e3/n**2 for n in [64,128,256,512,512]]
content_weights = [1e0]
weights = style_weights + content_weights
要可视化这一点,我们可以打印 VGG 层。花一分钟观察我们选择了哪些层,并尝试不同的层组合。我们将使用以下代码来print
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))
)
我们必须定义loss
函数和optimizer
来生成艺术图像。我们将在以下部分初始化它们两个。
为每一层创建损失函数
我们已经定义了 PyTorch 层作为loss
函数。现在,让我们为不同的风格损失和内容损失创建损失层。以下代码定义了该函数:
loss_fns = [StyleLoss()] * len(style_layers) + [nn.MSELoss()] * len(content_layers)
loss_fns
是一个包含一系列风格损失对象和内容损失对象的列表,基于创建的数组长度。
创建优化器
一般来说,我们会传递网络(如 VGG)的参数进行训练。但在这个例子中,我们将 VGG 模型作为特征提取器使用,因此不能传递 VGG 的参数。在这里,我们只会提供opt_img
变量的参数,我们将优化它们以使图像具有所需的内容和风格。以下代码创建了优化器来优化它的值:
optimizer = optim.LBFGS([opt_img]);
现在我们已经有了所有的训练组件。
训练
与我们之前训练的其他模型相比,training
方法有所不同。在这里,我们需要在多个层次计算损失,并且每次调用优化器时,它都会改变输入图像,使其内容和样式接近目标的内容和样式。让我们看一下用于训练的代码,然后我们将详细介绍训练的重要步骤:
max_iter = 500
show_iter = 50
n_iter=[0]
while n_iter[0] <= max_iter:
def closure():
optimizer.zero_grad()
out = extract_layers(loss_layers,opt_img,model=vgg)
layer_losses = [weights[a] * loss_fnsa for a,A in enumerate(out)]
loss = sum(layer_losses)
loss.backward()
n_iter[0]+=1
#print loss
if n_iter[0]%show_iter == (show_iter-1):
print('Iteration: %d, loss: %f'%(n_iter[0]+1, loss.data[0]))
return loss
optimizer.step(closure)
我们正在运行为期500
次迭代的训练循环。对于每一次迭代,我们使用我们的extract_layers
函数计算 VGG 模型不同层的输出。在这种情况下,唯一变化的是opt_img
的值,它将包含我们的样式图像。一旦计算出输出,我们通过迭代输出并将它们传递给相应的loss
函数及其各自的目标来计算损失。我们将所有损失相加并调用backward
函数。在closure
函数的末尾,返回损失。closure
方法与optimizer.step
方法一起调用max_iter
次。如果在 GPU 上运行,可能需要几分钟;如果在 CPU 上运行,请尝试减小图像大小以加快运行速度。
运行 500 个周期后,在我的设备上生成的图像如下所示。尝试不同的内容和样式组合来生成有趣的图像:
在下一节中,让我们使用深度卷积生成对抗网络(DCGANs)生成人脸。
生成对抗网络
GANs 在过去几年变得非常流行。每周都有一些 GAN 领域的进展。它已成为深度学习的重要子领域之一,拥有非常活跃的研究社区。GAN 是由 Ian Goodfellow 于 2014 年引入的。GAN 通过训练两个深度神经网络,称为生成器和判别器,它们相互竞争来解决无监督学习的问题。在训练过程中,它们最终都变得更擅长它们所执行的任务。
用仿冒者(生成器)和警察(判别器)的案例直观理解 GAN。最初,仿冒者向警察展示假钞。警察识别出它是假的,并解释为什么是假的。仿冒者根据收到的反馈制作新的假钞。警察发现它是假的,并告知仿冒者为什么是假的。这个过程重复了很多次,直到仿冒者能够制作出警察无法辨别的假钞。在 GAN 场景中,我们最终得到一个生成器生成的假图像与真实图像非常相似,而分类器则变得擅长辨别真假。
GAN 是一个伪造网络和专家网络的组合,每个网络都被训练来击败对方。生成器网络以随机向量作为输入并生成合成图像。鉴别器网络接收输入图像并预测图像是真实的还是伪造的。我们向鉴别器网络传递的是真实图像或伪造图像。
生成器网络被训练来生成图像,并欺骗鉴别器网络认为它们是真实的。鉴别器网络也在不断提高其不被欺骗的能力,因为我们在训练中传递反馈。尽管 GAN 的理念在理论上听起来很简单,但训练一个真正有效的 GAN 模型却非常困难。训练 GAN 也很具挑战性,因为需要训练两个深度神经网络。
DCGAN 是早期展示如何构建自学习并生成有意义图像的 GAN 模型之一。您可以在此了解更多:
下图显示了 GAN 模型的架构:
我们将逐步介绍这种架构的每个组件及其背后的一些理论,然后我们将在下一节中用 PyTorch 实现相同的流程。通过此实现,我们将对 DCGAN 的工作原理有基本的了解。
深度卷积 GAN
在本节中,我们将基于我在前面信息框中提到的 DCGAN 论文 来实现训练 GAN 架构的不同部分。训练 DCGAN 的一些重要部分包括:
-
一个生成器网络,将某个固定维度的潜在向量(数字列表)映射到某种形状的图像。在我们的实现中,形状是 (3, 64, 64)。
-
一个鉴别器网络,它将生成器生成的图像或来自实际数据集的图像作为输入,并映射到一个评分,用于估计输入图像是真实还是伪造的。
-
为生成器和鉴别器定义损失函数。
-
定义优化器。
-
训练一个 GAN。
让我们详细探讨这些部分的每一个。这些实现基于 PyTorch 示例代码,可在以下位置找到:
github.com/pytorch/examples/tree/master/dcgan
定义生成器网络
生成器网络以固定维度的随机向量作为输入,并对其应用一组转置卷积、批归一化和 ReLU 激活,生成所需尺寸的图像。在查看生成器实现之前,让我们来定义一下转置卷积和批归一化。
转置卷积
转置卷积也称为分数步进卷积。它们的工作方式与卷积相反。直观地说,它们尝试计算如何将输入向量映射到更高的维度。让我们看下图以更好地理解它:
此图来自 Theano(另一个流行的深度学习框架)的文档(deeplearning.net/software/theano/tutorial/conv_arithmetic.html
)。如果您想深入了解步进卷积的工作原理,我强烈建议您阅读 Theano 文档中的这篇文章。对我们来说重要的是,它有助于将一个向量转换为所需维度的张量,并且我们可以通过反向传播来训练核的值。
批归一化
我们已经多次观察到,所有传递给机器学习或深度学习算法的特征都是经过归一化的;也就是说,特征的值通过减去数据的均值使其居中于零,并通过除以其标准差使数据具有单位标准差。通常我们会使用 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
)。批归一化在训练和验证或测试时的行为是不同的。训练时,批内数据的均值和方差被计算。验证和测试时,使用全局值。我们需要理解的是它如何规范化中间数据。使用批归一化的一些关键优势是:
-
提升网络中的梯度流,从而帮助我们构建更深的网络
-
允许更高的学习率
-
减少初始化的强依赖
-
作为一种正则化形式,减少了 dropout 的依赖性
大多数现代架构,如 ResNet 和 Inception,在其架构中广泛使用批归一化。批归一化层通常在卷积层或线性/全连接层之后引入,如下图所示:
到目前为止,我们对生成网络的关键组成部分有了直观的理解。
生成器
让我们快速查看以下生成器网络代码,然后讨论生成器网络的关键特性:
class Generator(nn.Module):
def __init__(self):
super(Generator, self).__init__()
self.main = nn.Sequential(
# input is Z, going into a convolution
nn.ConvTranspose2d(nz, ngf * 8, 4, 1, 0, bias=False),
nn.BatchNorm2d(ngf * 8),
nn.ReLU(True),
# state size. (ngf*8) x 4 x 4
nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 4),
nn.ReLU(True),
# state size. (ngf*4) x 8 x 8
nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 2),
nn.ReLU(True),
# state size. (ngf*2) x 16 x 16
nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf),
nn.ReLU(True),
# state size. (ngf) x 32 x 32
nn.ConvTranspose2d( ngf, nc, 4, 2, 1, bias=False),
nn.Tanh()
# state size. (nc) x 64 x 64
)
def forward(self, input):
output = self.main(input)
return output
netG = Generator()
netG.apply(weights_init)
print(netG)
在我们看到的大多数代码示例中,我们使用了许多不同的层,并在forward
方法中定义了流程。在生成器网络中,我们使用顺序模型在__init__
方法中定义层和数据流动。
模型接受尺寸为nz
的张量作为输入,然后通过转置卷积将输入映射到需要生成的图像大小。forward
函数将输入传递给顺序模块并返回输出。
生成器网络的最后一层是一个tanh
层,它限制了网络可以生成的值的范围。
不使用相同的随机权重,而是使用在论文中定义的权重初始化模型。以下是权重初始化代码:
def weights_init(m):
classname = m.__class__.__name__
if classname.find('Conv') != -1:
m.weight.data.normal_(0.0, 0.02)
elif classname.find('BatchNorm') != -1:
m.weight.data.normal_(1.0, 0.02)
m.bias.data.fill_(0)
我们通过将函数传递给生成器对象netG
调用weight
函数。每一层都通过函数传递;如果层是卷积层,我们以不同的方式初始化权重,如果是BatchNorm
,我们稍微不同地初始化它。我们使用以下代码在网络对象上调用函数:
netG.apply(weights_init)
定义鉴别器网络
让我们快速查看以下鉴别器网络代码,然后讨论鉴别器网络的关键特性:
class Discriminator(nn.Module):
def __init__(self):
super(_netD, self).__init__()
self.main = nn.Sequential(
# input is (nc) x 64 x 64
nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf) x 32 x 32
nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 2),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf*2) x 16 x 16
nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 4),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf*4) x 8 x 8
nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 8),
nn.LeakyReLU(0.2, inplace=True),
# state size. (ndf*8) x 4 x 4
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)
netD = Discriminator()
netD.apply(weights_init)
print(netD)
前面网络中有两个重要的事情,即使用leaky ReLU作为激活函数,以及在最后一个激活层中使用 sigmoid。首先,让我们了解一下什么是 Leaky ReLU。
Leaky ReLU 是解决 ReLU 死亡问题的一种尝试。Leaky ReLU 不像在输入为负时函数返回零,而是输出一个非常小的数值,如 0.001。论文表明,使用 Leaky ReLU 可以提高鉴别器的效率。
另一个重要的区别是在鉴别器末端不使用全连接层。通常看到最后的全连接层被全局平均池化层替换。但使用全局平均池化会减少收敛速度(构建准确分类器所需的迭代次数)。最后的卷积层被展平并传递给 sigmoid 层。
除了这两个差异之外,网络的其余部分与我们在本书中看到的其他图像分类器网络非常相似。
定义损失和优化器
我们将在下面的代码中定义一个二元交叉熵损失和两个优化器,一个用于生成器,另一个用于鉴别器:
criterion = nn.BCELoss()
# setup optimizer
optimizerD = optim.Adam(netD.parameters(), lr, betas=(beta1, 0.999))
optimizerG = optim.Adam(netG.parameters(), lr, betas=(beta1, 0.999))
到目前为止,这与我们在所有先前的例子中看到的非常相似。让我们探讨一下如何训练生成器和鉴别器。
训练鉴别器
鉴别器网络的损失取决于其在真实图像上的表现和在生成器网络生成的假图像上的表现。损失可以定义为:
loss = 最大化 log(D(x)) + log(1-D(G(z)))
因此,我们需要使用真实图像和生成器网络生成的假图像来训练鉴别器。
使用真实图像训练鉴别器网络
让我们传递一些真实图像作为地面实况来训练鉴别器。
首先,我们将查看执行相同操作的代码,然后探索重要特性:
output = netD(inputv)
errD_real = criterion(output, labelv)
errD_real.backward()
在前述代码中,我们计算了用于鉴别器图像的损失和梯度。inputv
和 labelv
表示来自 CIFAR10
数据集的输入图像和标签,其中真实图像的标签为 1。这非常直观,因为它类似于我们为其他图像分类器网络所做的工作。
使用假图像训练鉴别器
现在传递一些随机图像来训练鉴别器。
让我们查看代码,并探索重要特性:
fake = netG(noisev)
output = netD(fake.detach())
errD_fake = criterion(output, labelv)
errD_fake.backward()
optimizerD.step()
代码中的第一行传递了一个大小为 100 的向量,生成器网络 (netG
) 生成一幅图像。我们将图像传递给鉴别器以确定图像是真实还是假的。我们不希望生成器被训练,因为鉴别器正在被训练。因此,我们通过在其变量上调用 detach
方法从其图中移除假图像。一旦计算出所有梯度,我们调用 optimizer
训练鉴别器。
训练生成器网络
让我们查看代码,并探索重要特性:
netG.zero_grad()
labelv = Variable(label.fill_(real_label)) # fake labels are real for generator cost
output = netD(fake)
errG = criterion(output, labelv)
errG.backward()
optimizerG.step()
它看起来与我们在训练鉴别器使用假图像时所做的类似,除了一些关键区别。我们传递了由生成器创建的同样的假图像,但这次我们没有从生成图中分离它,因为我们希望训练生成器。我们计算损失 (errG
) 并计算梯度。然后我们调用生成器优化器,因为我们只想训练生成器,并在生成器生成略微逼真图像之前重复整个过程多次。
训练完整网络
我们逐个分析了 GAN 的训练过程。让我们总结如下,并查看用于训练我们创建的 GAN 网络的完整代码:
-
使用真实图像训练鉴别器网络
-
使用假图像训练鉴别器网络
-
优化鉴别器
-
基于鉴别器反馈训练生成器
-
优化生成器网络
我们将使用以下代码来训练网络:
for epoch in range(niter):
for i, data in enumerate(dataloader, 0):
############################
# (1) Update D network: maximize log(D(x)) + log(1 - D(G(z)))
###########################
# train with real
netD.zero_grad()
real, _ = data
batch_size = real.size(0)
if torch.cuda.is_available():
real = real.cuda()
input.resize_as_(real).copy_(real)
label.resize_(batch_size).fill_(real_label)
inputv = Variable(input)
labelv = Variable(label)
output = netD(inputv)
errD_real = criterion(output, labelv)
errD_real.backward()
D_x = output.data.mean()
# train with fake
noise.resize_(batch_size, nz, 1, 1).normal_(0, 1)
noisev = Variable(noise)
fake = netG(noisev)
labelv = Variable(label.fill_(fake_label))
output = netD(fake.detach())
errD_fake = criterion(output, labelv)
errD_fake.backward()
D_G_z1 = output.data.mean()
errD = errD_real + errD_fake
optimizerD.step()
############################
# (2) Update G network: maximize log(D(G(z)))
###########################
netG.zero_grad()
labelv = Variable(label.fill_(real_label)) # fake labels are real for generator cost
output = netD(fake)
errG = criterion(output, labelv)
errG.backward()
D_G_z2 = output.data.mean()
optimizerG.step()
print('[%d/%d][%d/%d] Loss_D: %.4f Loss_G: %.4f D(x): %.4f D(G(z)): %.4f / %.4f'
% (epoch, niter, i, len(dataloader),
errD.data[0], errG.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 = netG(fixed_noise)
vutils.save_image(fake.data,
'%s/fake_samples_epoch_%03d.png' % (outf, epoch),
normalize=True)
vutils.save_image
将把一个张量保存为图像。如果提供一个小批量的图像,则将它们保存为图像网格。
在接下来的部分,我们将看看生成的图像和真实图像的外观。
检查生成的图像
所以,让我们比较生成的图像和真实图像。
生成的图像如下:
真实图像如下:
比较两组图像,我们可以看到我们的 GAN 能够学习如何生成图像。除了训练生成新图像之外,我们还有一个判别器,可用于分类问题。当有限数量的标记数据可用时,判别器学习关于图像的重要特征,这些特征可以用于分类任务。当有限的标记数据时,我们可以训练一个 GAN,它将为我们提供一个分类器,可以用于提取特征,并且可以在其上构建一个分类器模块。
在下一节中,我们将训练一个深度学习算法来生成文本。
语言建模
我们将学习如何教授循环神经网络(RNN)如何创建文本序列。简单来说,我们现在要构建的 RNN 模型将能够根据一些上下文预测下一个单词。这就像你手机上的Swift应用程序,它猜测你正在输入的下一个单词一样。生成序列数据的能力在许多不同领域都有应用,例如:
-
图像字幕
-
语音识别
-
语言翻译
-
自动电子邮件回复
我们在 第六章 中学到,使用序列数据和文本的深度学习,RNN 难以训练。因此,我们将使用一种称为长短期记忆网络(LSTM)的变体。LSTM 算法的开发始于 1997 年,但在过去几年变得流行起来。它因为强大的硬件和高质量数据的可用性,以及一些进展(如 dropout),使得训练更好的 LSTM 模型比以前更容易。
使用 LSTM 模型生成字符级语言模型或单词级语言模型非常流行。在字符级语言建模中,我们提供一个字符,LSTM 模型被训练来预测下一个字符,而在单词级语言建模中,我们提供一个单词,LSTM 模型预测下一个单词。在本节中,我们将使用 PyTorch LSTM 模型构建一个单词级语言模型。就像训练任何其他模块一样,我们将遵循标准步骤:
-
准备数据
-
生成数据批次
-
基于 LSTM 定义模型
-
训练模型
-
测试模型
本节内容灵感来自于 PyTorch 中稍微简化的词语言建模示例,详情请见 github.com/pytorch/examples/tree/master/word_language_model
。
准备数据
对于本例,我们使用名为WikiText2
的数据集。WikiText 语言建模
数据集包含从维基百科上的验证过的Good
和Featured
文章中提取的 1 亿多个标记。与另一个广泛使用的数据集Penn Treebank(PTB)的预处理版本相比,WikiText-2
大约大两倍。WikiText
数据集还具有更大的词汇表,并保留了原始大小写、标点和数字。该数据集包含完整的文章,因此非常适合利用长期依赖的模型。
该数据集是在一篇名为Pointer Sentinel 混合模型的论文中介绍的(arxiv.org/abs/1609.07843
)。该论文讨论了用于解决特定问题的解决方案,其中 LSTM 与 softmax 层在预测稀有单词时存在困难,尽管上下文不清楚。暂时不要担心这个问题,因为这是一个高级概念,超出了本书的范围。
下面的屏幕截图显示了 WikiText 转储文件内部的数据样式:
通常情况下,torchtext
通过提供下载和读取数据集的抽象,使使用数据集变得更加容易。让我们看看执行此操作的代码:
TEXT = d.Field(lower=True, batch_first=True)
train, valid, test = datasets.WikiText2.splits(TEXT,root='data')
先前的代码负责下载WikiText2
数据并将其分成train
、valid
和test
数据集。语言建模的关键区别在于如何处理数据。我们在WikiText2
中有的所有文本数据都存储在一个长张量中。让我们看一下下面的代码和结果,以更好地理解数据的处理方式:
print(len(train[0].text))
#output
2088628
如前所述的结果显示,我们只有一个示例字段,其中包含所有文本。让我们快速看一下文本的表示方式:
print(train[0].text[:100])
#Results of first 100 tokens
'<eos>', '=', 'valkyria', 'chronicles', 'iii', '=', '<eos>', '<eos>', 'senjō', 'no', 'valkyria', '3', ':', '<unk>', 'chronicles', '(', 'japanese', ':', '3', ',', 'lit', '.', 'valkyria', 'of', 'the', 'battlefield', '3', ')', ',', 'commonly', 'referred', 'to', 'as', 'valkyria', 'chronicles', 'iii', 'outside', 'japan', ',', 'is', 'a', 'tactical', 'role', '@-@', 'playing', 'video', 'game', 'developed', 'by', 'sega', 'and', 'media.vision', 'for', 'the', 'playstation', 'portable', '.', 'released', 'in', 'january', '2011', 'in', 'japan', ',', 'it', 'is', 'the', 'third', 'game', 'in', 'the', 'valkyria', 'series', '.', '<unk>', 'the', 'same', 'fusion', 'of', 'tactical', 'and', 'real', '@-@', 'time', 'gameplay', 'as', 'its', 'predecessors', ',', 'the', 'story', 'runs', 'parallel', 'to', 'the', 'first', 'game', 'and', 'follows', 'the'
现在,快速查看显示初始文本及其如何被标记化的图像。现在我们有一个长度为2088628
的长序列,表示为WikiText2
。接下来重要的事情是如何对数据进行分批处理。
生成批次
让我们看一下代码,了解序列数据分批过程中涉及的两个关键要素:
train_iter, valid_iter, test_iter = data.BPTTIterator.splits(
(train, valid, test), batch_size=20, bptt_len=35, device=0)
此方法中有两个重要的事情。一个是batch_size
,另一个是称为时间反向传播(backpropagation through time,简称bptt_len
)。它简要说明了数据在每个阶段如何转换。
批次
整个数据作为一个序列进行处理相当具有挑战性,而且计算效率不高。因此,我们将序列数据分成多个批次,将每个批次视为单独的序列。尽管这听起来可能并不直接,但效果要好得多,因为模型可以从数据批次中更快地学习。让我们以英文字母顺序为例进行分组。
序列:a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z.
当我们将前述字母序列转换为四批时,我们得到:
a g m s y
b h n t z
c i o u
d j p v
e k q w
f l r x
在大多数情况下,我们会剔除最后多余的单词或标记,因为它对文本建模没有太大影响。
对于例子WikiText2
,当我们将数据分成 20 批次时,我们会得到每个批次 104431 个元素。
时间反向传播
我们看到通过迭代器传递的另一个重要变量是时间反向传播(BPTT)。它实际上是指模型需要记住的序列长度。数字越大,效果越好——但模型的复杂性和需要的 GPU 内存也会增加。
为了更好地理解它,让我们看看如何将前面批量化的字母数据分成长度为两的序列:
a g m s
b h n t
前面的示例将作为输入传递给模型,并且输出将是来自序列但包含下一个值的:
b h n t
c I o u
对于例子WikiText2
,当我们将批量数据分割时,我们得到大小为 30 的数据,每个批次为 20,其中30是序列长度。
基于 LSTM 定义一个模型
我们定义了一个模型,它有点类似于我们在第六章中看到的网络,Deep Learning with Sequence Data and Text,但它有一些关键的不同之处。网络的高级架构如下图所示:
和往常一样,让我们先看看代码,然后逐步讲解其中的关键部分:
class RNNModel(nn.Module):
def __init__(self,ntoken,ninp,nhid,nlayers,dropout=0.5,tie_weights=False):
#ntoken represents the number of words in vocabulary.
#ninp Embedding dimension for each word ,which is the input for the LSTM.
#nlayer Number of layers required to be used in the LSTM .
#Dropout to avoid overfitting.
#tie_weights - use the same weights for both encoder and decoder.
super().__init__()
self.drop = nn.Dropout()
self.encoder = nn.Embedding(ntoken,ninp)
self.rnn = nn.LSTM(ninp,nhid,nlayers,dropout=dropout)
self.decoder = nn.Linear(nhid,ntoken)
if tie_weights:
self.decoder.weight = self.encoder.weight
self.init_weights()
self.nhid = nhid
self.nlayers = nlayers
def init_weights(self):
initrange = 0.1
self.encoder.weight.data.uniform_(-initrange,initrange)
self.decoder.bias.data.fill_(0)
self.decoder.weight.data.uniform_(-initrange,initrange)
def forward(self,input,hidden):
emb = self.drop(self.encoder(input))
output,hidden = self.rnn(emb,hidden)
output = self.drop(output)
s = output.size()
decoded = self.decoder(output.view(s[0]*s[1],s[2]))
return decoded.view(s[0],s[1],decoded.size(1)),hidden
def init_hidden(self,bsz):
weight = next(self.parameters()).data
return (Variable(weight.new(self.nlayers,bsz,self.nhid).zero_()),Variable(weight.new(self.nlayers,bsz,self.nhid).zero_()))
在__init__
方法中,我们创建所有的层,如嵌入层、dropout、RNN 和解码器。在早期的语言模型中,通常不会在最后一层使用嵌入。使用嵌入,并且将初始嵌入与最终输出层的嵌入进行绑定,可以提高语言模型的准确性。这个概念是由 Press 和 Wolf 在 2016 年的论文Using the Output Embedding to Improve Language Models(arxiv.org/abs/1608.05859
)以及 Inan 和他的合著者在 2016 年的论文Tying Word Vectors and Word Classifiers: A Loss Framework for Language Modeling(arxiv.org/abs/1611.01462
)中引入的。一旦我们将编码器和解码器的权重绑定在一起,我们调用init_weights
方法来初始化层的权重。
forward
函数将所有层连接在一起。最后的线性层将 LSTM 层的所有输出激活映射到与词汇表大小相同的嵌入中。forward
函数的流程是通过嵌入层传递输入,然后传递给一个 RNN(在本例中是 LSTM),然后传递给解码器,另一个线性层。
定义训练和评估函数
模型的训练与本书中之前所有示例中看到的非常相似。我们需要进行一些重要的更改,以便训练后的模型运行得更好。让我们来看看代码及其关键部分:
criterion = nn.CrossEntropyLoss()
def trainf():
# Turn on training mode which enables dropout.
lstm.train()
total_loss = 0
start_time = time.time()
hidden = lstm.init_hidden(batch_size)
for i,batch in enumerate(train_iter):
data, targets = batch.text,batch.target.view(-1)
# Starting each batch, we detach the hidden state from how it was previously produced.
# If we didn't, the model would try backpropagating all the way to start of the dataset.
hidden = repackage_hidden(hidden)
lstm.zero_grad()
output, hidden = lstm(data, hidden)
loss = criterion(output.view(-1, ntokens), targets)
loss.backward()
# `clip_grad_norm` helps prevent the exploding gradient problem in RNNs / LSTMs.
torch.nn.utils.clip_grad_norm(lstm.parameters(), clip)
for p in lstm.parameters():
p.data.add_(-lr, p.grad.data)
total_loss += loss.data
if i % log_interval == 0 and i > 0:
cur_loss = total_loss[0] / log_interval
elapsed = time.time() - start_time
(print('| epoch {:3d} | {:5d}/{:5d} batches | lr {:02.2f} | ms/batch {:5.2f} | loss {:5.2f} | ppl {:8.2f}'.format(epoch, i, len(train_iter), lr,elapsed * 1000 / log_interval, cur_loss, math.exp(cur_loss))))
total_loss = 0
start_time = time.time()
由于我们在模型中使用了 dropout,因此在训练和验证/测试数据集中需要以不同方式使用它。在模型上调用 train()
将确保在训练期间启用 dropout,在模型上调用 eval()
将确保在验证/测试期间以不同方式使用 dropout:
lstm.train()
对于 LSTM 模型,除了输入外,我们还需要传递隐藏变量。init_hidden
函数将批量大小作为输入,并返回一个隐藏变量,可以与输入一起使用。我们可以迭代训练数据并将输入数据传递给模型。由于我们处理序列数据,每次迭代都从新的隐藏状态(随机初始化)开始是没有意义的。因此,我们将使用前一次迭代的隐藏状态,在通过调用 detach
方法从图中移除它后使用。如果不调用 detach
方法,那么我们将计算一个非常长的序列的梯度,直到 GPU 内存耗尽。
然后我们将输入传递给 LSTM 模型,并使用 CrossEntropyLoss
计算损失。使用前一个隐藏状态的值是通过以下 repackage_hidden
函数实现的:
def repackage_hidden(h):
"""Wraps hidden states in new Variables, to detach them from their history."""
if type(h) == Variable:
return Variable(h.data)
else:
return tuple(repackage_hidden(v) for v in h)
RNN 及其变体,例如 LSTM 和 门控循环单元(GRU),存在一个称为 梯度爆炸 的问题。避免这个问题的一个简单技巧是裁剪梯度,以下是实现这个技巧的代码:
torch.nn.utils.clip_grad_norm(lstm.parameters(), clip)
我们通过以下代码手动调整参数值。手动实现优化器比使用预建优化器更灵活:
for p in lstm.parameters():
p.data.add_(-lr, p.grad.data)
我们正在遍历所有参数,并将梯度值乘以学习率相加。一旦更新了所有参数,我们记录所有统计信息,如时间、损失和困惑度。
对于验证,我们编写了类似的函数,在模型上调用 eval
方法。使用以下代码定义了 evaluate
函数:
def evaluate(data_source):
# Turn on evaluation mode which disables dropout.
lstm.eval()
total_loss = 0
hidden = lstm.init_hidden(batch_size)
for batch in data_source:
data, targets = batch.text,batch.target.view(-1)
output, hidden = lstm(data, hidden)
output_flat = output.view(-1, ntokens)
total_loss += len(data) * criterion(output_flat, targets).data
hidden = repackage_hidden(hidden)
return total_loss[0]/(len(data_source.dataset[0].text)//batch_size)
大多数的训练逻辑和评估逻辑是相似的,除了调用 eval
并且不更新模型参数。
训练模型
我们对模型进行多次 epoch 的训练,并使用以下代码进行验证:
# Loop over epochs.
best_val_loss = None
epochs = 40
for epoch in range(1, epochs+1):
epoch_start_time = time.time()
trainf()
val_loss = evaluate(valid_iter)
print('-' * 89)
print('| end of epoch {:3d} | time: {:5.2f}s | valid loss {:5.2f} | '
'valid ppl {:8.2f}'.format(epoch, (time.time() - epoch_start_time),
val_loss, math.exp(val_loss)))
print('-' * 89)
if not best_val_loss or val_loss < best_val_loss:
best_val_loss = val_loss
else:
# Anneal the learning rate if no improvement has been seen in the validation dataset.
lr /= 4.0
前面的代码训练模型 40
个 epoch,我们从一个较高的学习率 20 开始,并在验证损失饱和时进一步减少。模型运行 40 个 epoch 后得到的 ppl
分数约为 108.45
。以下代码块包含了上次运行模型时的日志:
-----------------------------------------------------------------------------------------
| end of epoch 39 | time: 34.16s | valid loss 4.70 | valid ppl 110.01
-----------------------------------------------------------------------------------------
| epoch 40 | 200/ 3481 batches | lr 0.31 | ms/batch 11.47 | loss 4.77 | ppl 117.40
| epoch 40 | 400/ 3481 batches | lr 0.31 | ms/batch 9.56 | loss 4.81 | ppl 122.19
| epoch 40 | 600/ 3481 batches | lr 0.31 | ms/batch 9.43 | loss 4.73 | ppl 113.08
| epoch 40 | 800/ 3481 batches | lr 0.31 | ms/batch 9.48 | loss 4.65 | ppl 104.77
| epoch 40 | 1000/ 3481 batches | lr 0.31 | ms/batch 9.42 | loss 4.76 | ppl 116.42
| epoch 40 | 1200/ 3481 batches | lr 0.31 | ms/batch 9.55 | loss 4.70 | ppl 109.77
| epoch 40 | 1400/ 3481 batches | lr 0.31 | ms/batch 9.41 | loss 4.74 | ppl 114.61
| epoch 40 | 1600/ 3481 batches | lr 0.31 | ms/batch 9.47 | loss 4.77 | ppl 117.65
| epoch 40 | 1800/ 3481 batches | lr 0.31 | ms/batch 9.46 | loss 4.77 | ppl 118.42
| epoch 40 | 2000/ 3481 batches | lr 0.31 | ms/batch 9.44 | loss 4.76 | ppl 116.31
| epoch 40 | 2200/ 3481 batches | lr 0.31 | ms/batch 9.46 | loss 4.77 | ppl 117.52
| epoch 40 | 2400/ 3481 batches | lr 0.31 | ms/batch 9.43 | loss 4.74 | ppl 114.06
| epoch 40 | 2600/ 3481 batches | lr 0.31 | ms/batch 9.44 | loss 4.62 | ppl 101.72
| epoch 40 | 2800/ 3481 batches | lr 0.31 | ms/batch 9.44 | loss 4.69 | ppl 109.30
| epoch 40 | 3000/ 3481 batches | lr 0.31 | ms/batch 9.47 | loss 4.71 | ppl 111.51
| epoch 40 | 3200/ 3481 batches | lr 0.31 | ms/batch 9.43 | loss 4.70 | ppl 109.65
| epoch 40 | 3400/ 3481 batches | lr 0.31 | ms/batch 9.51 | loss 4.63 | ppl 102.43
val loss 4.686332647950745
-----------------------------------------------------------------------------------------
| end of epoch 40 | time: 34.50s | valid loss 4.69 | valid ppl 108.45
-----------------------------------------------------------------------------------------
在过去几个月中,研究人员开始探索先前的方法,创建一个语言模型来生成预训练的嵌入。如果您对这种方法更感兴趣,我强烈推荐您阅读 Jeremy Howard 和 Sebastian Ruder 撰写的论文Fine-tuned Language Models for Text Classification(https://arxiv.org/abs/1801.06146),他们在其中详细介绍了如何使用语言建模技术来准备特定领域的词嵌入,后者可以用于不同的 NLP 任务,如文本分类问题。
概要
在本章中,我们讨论了如何训练能够使用生成网络生成艺术风格转换、使用 GAN 和 DCGAN 生成新图像以及使用 LSTM 网络生成文本的深度学习算法。
在下一章中,我们将介绍一些现代架构,例如 ResNet 和 Inception,用于构建更好的计算机视觉模型,以及像序列到序列这样的模型,这些模型可以用于构建语言翻译和图像标题生成等任务。
第八章:现代网络架构
在最后一章中,我们探讨了如何使用深度学习算法创建艺术图像,基于现有数据集创建新图像以及生成文本。在本章中,我们将介绍驱动现代计算机视觉应用和自然语言系统的不同网络架构。本章我们将看到的一些架构包括:
-
ResNet
-
Inception
-
DenseNet
-
编码器-解码器架构
现代网络架构
当深度学习模型学习失败时,我们通常会向模型中添加更多的层。随着层的增加,模型的准确性会提高,然后开始饱和。继续添加更多层之后,准确性会开始下降。超过一定数量的层会引入一些挑战,比如梯度消失或爆炸问题,这部分可以通过仔细初始化权重和引入中间归一化层来部分解决。现代架构,如残差网络(ResNet)和 Inception,尝试通过引入不同的技术,如残差连接,来解决这些问题。
ResNet
ResNet 通过显式让网络中的层适应一个残差映射来解决这些问题,通过添加快捷连接。下图展示了 ResNet 的工作原理:
在我们看到的所有网络中,我们试图通过堆叠不同的层找到一个将输入(x)映射到其输出(H(x))的函数。但是 ResNet 的作者提出了一个修正方法;不再试图学习从 x 到 H(x) 的基础映射,而是学习两者之间的差异,或者残差。然后,为了计算 H(x),我们可以将残差简单地加到输入上。假设残差为 F(x) = H(x) - x;与其直接学习 H(x),我们尝试学习 F(x) + x。
每个 ResNet 块由一系列层组成,并通过快捷连接将块的输入添加到块的输出。加法操作是逐元素进行的,输入和输出需要具有相同的大小。如果它们大小不同,我们可以使用填充。以下代码展示了一个简单的 ResNet 块是如何工作的:
class ResNetBasicBlock(nn.Module):
def __init__(self,in_channels,out_channels,stride):
super().__init__()
self.conv1 = nn.Conv2d(in_channels,out_channels,kernel_size=3,stride=stride,padding=1,bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels,out_channels,kernel_size=3,stride=stride,padding=1,bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.stride = stride
def forward(self,x):
residual = x
out = self.conv1(x)
out = F.relu(self.bn1(out),inplace=True)
out = self.conv2(out)
out = self.bn2(out)
out += residual
return F.relu(out)
ResNetBasicBlock
包含一个 init
方法,用于初始化所有不同的层,如卷积层、批标准化层和 ReLU 层。forward
方法与我们之前看到的几乎相同,唯一不同的是在返回之前将输入重新添加到层的输出中。
PyTorch 的 torchvision
包提供了一个带有不同层的即用型 ResNet 模型。一些可用的不同模型包括:
-
ResNet-18
-
ResNet-34
-
ResNet-50
-
ResNet-101
-
ResNet-152
我们也可以使用这些模型中的任何一个进行迁移学习。torchvision
实例使我们能够简单地创建这些模型并使用它们。我们在书中已经做过几次,以下代码是对此的一次复习:
from torchvision.models import resnet18
resnet = resnet18(pretrained=False)
下图展示了 34 层 ResNet 模型的结构:
34 层的 ResNet 模型
我们可以看到这个网络由多个 ResNet 块组成。有些团队进行了实验,尝试了深达 1000 层的模型。对于大多数实际应用场景,我个人推荐从一个较小的网络开始。这些现代网络的另一个关键优势是,它们与需要大量参数训练的模型(如 VGG)相比,需要很少的参数,因为它们避免使用全连接层。在计算机视觉领域解决问题时,另一种流行的架构是Inception。在继续研究 Inception 架构之前,让我们在Dogs vs. Cats
数据集上训练一个 ResNet 模型。我们将使用我们在第五章深度学习计算机视觉中使用的数据,并基于从 ResNet 计算的特征快速训练一个模型。像往常一样,我们将按照以下步骤训练模型:
-
创建 PyTorch 数据集
-
创建用于训练和验证的加载器
-
创建 ResNet 模型
-
提取卷积特征
-
创建一个自定义的 PyTorch 数据集类,用于预处理的特征和加载器
-
创建一个简单的线性模型
-
训练和验证模型
完成后,我们将对 Inception 和 DenseNet 重复此步骤。最后,我们还将探讨集成技术,在其中结合这些强大的模型来构建一个新模型。
创建 PyTorch 数据集
我们创建一个包含所有基本变换的变换对象,并使用ImageFolder
从我们在章节*5 中创建的数据目录中加载图像,深度学习计算机视觉。在以下代码中,我们创建数据集:
data_transform = transforms.Compose([
transforms.Resize((299,299)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
# For Dogs & Cats dataset
train_dset = ImageFolder('../../chapter5/dogsandcats/train/',transform=data_transform)
val_dset = ImageFolder('../../chapter5/dogsandcats/valid/',transform=data_transform)
classes=2
到目前为止,前面的大部分代码应该是不言自明的。
创建用于训练和验证的加载器
我们使用 PyTorch 加载器以批次形式提供数据集中的数据,同时使用所有优势,如数据洗牌和多线程,以加快处理速度。以下代码演示了这一点:
train_loader = DataLoader(train_dset,batch_size=32,shuffle=False,num_workers=3)
val_loader = DataLoader(val_dset,batch_size=32,shuffle=False,num_workers=3)
在计算预处理特征时,我们需要保持数据的确切顺序。当我们允许数据被洗牌时,我们将无法保持标签的顺序。因此,请确保shuffle
参数为False
,否则需要在代码中处理所需的逻辑。
创建一个 ResNet 模型
使用resnet34
预训练模型的层,通过丢弃最后一个线性层创建 PyTorch 序列模型。我们将使用这个训练好的模型从我们的图像中提取特征。以下代码演示了这一点:
#Create ResNet model
my_resnet = resnet34(pretrained=True)
if is_cuda:
my_resnet = my_resnet.cuda()
my_resnet = nn.Sequential(*list(my_resnet.children())[:-1])
for p in my_resnet.parameters():
p.requires_grad = False
在前面的代码中,我们创建了一个在torchvision
模型中可用的resnet34
模型。在下面的代码中,我们挑选所有的 ResNet 层,但排除最后一层,并使用nn.Sequential
创建一个新模型:
for p in my_resnet.parameters():
p.requires_grad = False
nn.Sequential
实例允许我们快速创建一个使用一堆 PyTorch 层的模型。一旦模型创建完毕,不要忘记将requires_grad
参数设置为False
,这将允许 PyTorch 不维护任何用于保存梯度的空间。
提取卷积特征
我们通过模型将训练和验证数据加载器传递,并将模型的结果存储在列表中以供进一步计算。通过计算预卷积特征,我们可以在训练模型时节省大量时间,因为我们不会在每次迭代中计算这些特征。在下面的代码中,我们计算预卷积特征:
#For training data
# Stores the labels of the train data
trn_labels = []
# Stores the pre convoluted features of the train data
trn_features = []
#Iterate through the train data and store the calculated features and the labels
for d,la in train_loader:
o = m(Variable(d.cuda()))
o = o.view(o.size(0),-1)
trn_labels.extend(la)
trn_features.extend(o.cpu().data)
#For validation data
#Iterate through the validation data and store the calculated features and the labels
val_labels = []
val_features = []
for d,la in val_loader:
o = m(Variable(d.cuda()))
o = o.view(o.size(0),-1)
val_labels.extend(la)
val_features.extend(o.cpu().data)
一旦我们计算了预卷积特征,我们需要创建一个能够从我们的预卷积特征中挑选数据的自定义数据集。让我们为预卷积特征创建一个自定义数据集和加载器。
为预卷积特征创建自定义的 PyTorch 数据集类和加载器
我们已经看过如何创建 PyTorch 数据集。它应该是torch.utils.data
数据集类的子类,并且应该实现__getitem__(self, index)
和__len__(self)
方法,这些方法返回数据集中的数据长度。在下面的代码中,我们为预卷积特征实现一个自定义数据集:
class FeaturesDataset(Dataset):
def __init__(self,featlst,labellst):
self.featlst = featlst
self.labellst = labellst
def __getitem__(self,index):
return (self.featlst[index],self.labellst[index])
def __len__(self):
return len(self.labellst)
创建自定义数据集类之后,创建预卷积特征的数据加载器就很简单了,如下面的代码所示:
#Creating dataset for train and validation
trn_feat_dset = FeaturesDataset(trn_features,trn_labels)
val_feat_dset = FeaturesDataset(val_features,val_labels)
#Creating data loader for train and validation
trn_feat_loader = DataLoader(trn_feat_dset,batch_size=64,shuffle=True)
val_feat_loader = DataLoader(val_feat_dset,batch_size=64)
现在我们需要创建一个简单的线性模型,它可以将预卷积特征映射到相应的类别。
创建一个简单的线性模型
我们将创建一个简单的线性模型,将预卷积特征映射到相应的类别。在这种情况下,类别的数量为两个:
class FullyConnectedModel(nn.Module):
def __init__(self,in_size,out_size):
super().__init__()
self.fc = nn.Linear(in_size,out_size)
def forward(self,inp):
out = self.fc(inp)
return out
fc_in_size = 8192
fc = FullyConnectedModel(fc_in_size,classes)
if is_cuda:
fc = fc.cuda()
现在,我们可以训练我们的新模型并验证数据集。
训练和验证模型
我们将使用相同的fit
函数,该函数我们已经在《第五章》计算机视觉的深度学习中使用过。我没有在这里包含它,以节省空间。以下代码片段包含了训练模型和显示结果的功能:
train_losses , train_accuracy = [],[]
val_losses , val_accuracy = [],[]
for epoch in range(1,10):
epoch_loss, epoch_accuracy = fit(epoch,fc,trn_feat_loader,phase='training')
val_epoch_loss , val_epoch_accuracy = fit(epoch,fc,val_feat_loader,phase='validation')
train_losses.append(epoch_loss)
train_accuracy.append(epoch_accuracy)
val_losses.append(val_epoch_loss)
val_accuracy.append(val_epoch_accuracy)
上述代码的结果如下:
#Results
training loss is 0.082 and training accuracy is 22473/23000 97.71
validation loss is 0.1 and validation accuracy is 1934/2000 96.7
training loss is 0.08 and training accuracy is 22456/23000 97.63
validation loss is 0.12 and validation accuracy is 1917/2000 95.85
training loss is 0.077 and training accuracy is 22507/23000 97.86
validation loss is 0.1 and validation accuracy is 1930/2000 96.5
training loss is 0.075 and training accuracy is 22518/23000 97.9
validation loss is 0.096 and validation accuracy is 1938/2000 96.9
training loss is 0.073 and training accuracy is 22539/23000 98.0
validation loss is 0.1 and validation accuracy is 1936/2000 96.8
training loss is 0.073 and training accuracy is 22542/23000 98.01
validation loss is 0.089 and validation accuracy is 1942/2000 97.1
training loss is 0.071 and training accuracy is 22545/23000 98.02
validation loss is 0.09 and validation accuracy is 1941/2000 97.05
training loss is 0.068 and training accuracy is 22591/23000 98.22
validation loss is 0.092 and validation accuracy is 1934/2000 96.7
training loss is 0.067 and training accuracy is 22573/23000 98.14
validation loss is 0.085 and validation accuracy is 1942/2000 97.1
正如我们从结果中看到的那样,模型达到了 98%的训练精度和 97%的验证精度。让我们了解另一种现代架构及其如何用于计算预卷积特征并用它们来训练模型。
Inception
在我们看到的大多数计算机视觉模型的深度学习算法中,我们会选择使用卷积层,其滤波器大小为 1 x 1、3 x 3、5 x 5、7 x 7 或映射池化层。Inception 模块结合了不同滤波器大小的卷积,并将所有输出串联在一起。下图使 Inception 模型更清晰:
图片来源:https://arxiv.org/pdf/1409.4842.pdf
在这个 Inception 块图像中,应用了不同尺寸的卷积到输入上,并将所有这些层的输出串联起来。这是一个 Inception 模块的最简单版本。还有另一种 Inception 块的变体,我们在通过 3 x 3 和 5 x 5 卷积之前会先通过 1 x 1 卷积来减少维度。1 x 1 卷积用于解决计算瓶颈问题。1 x 1 卷积一次查看一个值,并跨通道进行。例如,在输入大小为 100 x 64 x 64 的情况下,使用 10 x 1 x 1 的滤波器将导致 10 x 64 x 64 的输出。以下图展示了具有降维的 Inception 块:
图片来源:https://arxiv.org/pdf/1409.4842.pdf
现在,让我们看一个 PyTorch 示例,展示前述 Inception 块的外观:
class BasicConv2d(nn.Module):
def __init__(self, in_channels, out_channels, **kwargs):
super(BasicConv2d, self).__init__()
self.conv = nn.Conv2d(in_channels, out_channels, bias=False, **kwargs)
self.bn = nn.BatchNorm2d(out_channels)
def forward(self, x):
x = self.conv(x)
x = self.bn(x)
return F.relu(x, inplace=True)
class InceptionBasicBlock(nn.Module):
def __init__(self, in_channels, pool_features):
super().__init__()
self.branch1x1 = BasicConv2d(in_channels, 64, kernel_size=1)
self.branch5x5_1 = BasicConv2d(in_channels, 48, kernel_size=1)
self.branch5x5_2 = BasicConv2d(48, 64, kernel_size=5, padding=2)
self.branch3x3dbl_1 = BasicConv2d(in_channels, 64, kernel_size=1)
self.branch3x3dbl_2 = BasicConv2d(64, 96, kernel_size=3, padding=1)
self.branch_pool = BasicConv2d(in_channels, pool_features, kernel_size=1)
def forward(self, x):
branch1x1 = self.branch1x1(x)
branch5x5 = self.branch5x5_1(x)
branch5x5 = self.branch5x5_2(branch5x5)
branch3x3dbl = self.branch3x3dbl_1(x)
branch3x3dbl = self.branch3x3dbl_2(branch3x3dbl)
branch_pool = F.avg_pool2d(x, kernel_size=3, stride=1, padding=1)
branch_pool = self.branch_pool(branch_pool)
outputs = [branch1x1, branch5x5, branch3x3dbl, branch_pool]
return torch.cat(outputs, 1)
前述代码包含两个类,BasicConv2d
和 InceptionBasicBlock
。BasicConv2d
作为一个自定义层,将二维卷积层、批归一化和 ReLU 层应用于传递的输入上。当我们有重复的代码结构时,创建一个新层是良好的做法,使代码看起来更优雅。
InceptionBasicBlock
实现了我们在第二个 Inception 图中看到的内容。让我们逐个查看每个较小的片段,并尝试理解它们的实现方式:
branch1x1 = self.branch1x1(x)
前述代码通过应用一个 1 x 1 卷积块来转换输入:
branch5x5 = self.branch5x5_1(x)
branch5x5 = self.branch5x5_2(branch5x5)
在前述代码中,我们通过应用一个 1 x 1 卷积块后跟一个 5 x 5 卷积块来转换输入:
branch3x3dbl = self.branch3x3dbl_1(x)
branch3x3dbl = self.branch3x3dbl_2(branch3x3dbl)
在前述代码中,我们通过应用一个 1 x 1 卷积块后跟一个 3 x 3 卷积块来转换输入:
branch_pool = F.avg_pool2d(x, kernel_size=3, stride=1, padding=1)
branch_pool = self.branch_pool(branch_pool)
在前述代码中,我们应用了平均池化以及一个 1 x 1 卷积块,在最后,我们将所有结果串联在一起。一个 Inception 网络将由多个 Inception 块组成。下图展示了 Inception 架构的外观:
Inception 架构
torchvision
包含一个 Inception 网络,可以像我们使用 ResNet 网络一样使用。对初始 Inception 块进行了许多改进,PyTorch 提供的当前实现是 Inception v3。让我们看看如何使用 torchvision
中的 Inception v3 模型来计算预计算特征。我们将不会详细介绍数据加载过程,因为我们将使用之前 ResNet 部分的相同数据加载器。我们将关注以下重要主题:
-
创建 Inception 模型
-
使用
register_forward_hook
提取卷积特征 -
为卷积特征创建新数据集
-
创建全连接模型
-
训练和验证模型
创建 Inception 模型
Inception v3 模型有两个分支,每个分支生成一个输出,在原始模型训练中,我们会像样式迁移那样合并损失。目前我们只关心使用一个分支来计算使用 Inception 的预卷积特征。深入了解这一点超出了本书的范围。如果你有兴趣了解更多工作原理,阅读论文和 Inception 模型的源代码(github.com/pytorch/vision/blob/master/torchvision/models/inception.py
)将有所帮助。我们可以通过将 aux_logits
参数设置为 False
来禁用其中一个分支。以下代码解释了如何创建模型并将 aux_logits
参数设置为 False
:
my_inception = inception_v3(pretrained=True)
my_inception.aux_logits = False
if is_cuda:
my_inception = my_inception.cuda()
从 Inception 模型中提取卷积特征并不像 ResNet 那样直接,因此我们将使用 register_forward_hook
来提取激活值。
使用 register_forward_hook
提取卷积特征
我们将使用与计算样式迁移激活值相同的技术。以下是 LayerActivations
类的一些小修改,因为我们只关注提取特定层的输出:
class LayerActivations():
features=[]
def __init__(self,model):
self.features = []
self.hook = model.register_forward_hook(self.hook_fn)
def hook_fn(self,module,input,output):
self.features.extend(output.view(output.size(0),-1).cpu().data)
def remove(self):
self.hook.remove()
除了 hook
函数外,其余代码与我们用于样式迁移的代码类似。由于我们正在捕获所有图像的输出并存储它们,我们将无法在 图形处理单元 (GPU) 内存中保留数据。因此,我们将从 GPU 中提取张量到 CPU,并仅存储张量而不是 Variable
。我们将其转换回张量,因为数据加载器只能处理张量。在以下代码中,我们使用 LayerActivations
对象来提取 Inception 模型在最后一层的输出,跳过平均池化层、dropout 和线性层。我们跳过平均池化层是为了避免在数据中丢失有用信息:
# Create LayerActivations object to store the output of inception model at a particular layer.
trn_features = LayerActivations(my_inception.Mixed_7c)
trn_labels = []
# Passing all the data through the model , as a side effect the outputs will get stored
# in the features list of the LayerActivations object.
for da,la in train_loader:
_ = my_inception(Variable(da.cuda()))
trn_labels.extend(la)
trn_features.remove()
# Repeat the same process for validation dataset .
val_features = LayerActivations(my_inception.Mixed_7c)
val_labels = []
for da,la in val_loader:
_ = my_inception(Variable(da.cuda()))
val_labels.extend(la)
val_features.remove()
让我们创建新的数据集和加载器,以便获取新的卷积特征。
为卷积特征创建新数据集
我们可以使用相同的 FeaturesDataset
类来创建新的数据集和数据加载器。在以下代码中,我们创建数据集和加载器:
#Dataset for pre computed features for train and validation data sets
trn_feat_dset = FeaturesDataset(trn_features.features,trn_labels)
val_feat_dset = FeaturesDataset(val_features.features,val_labels)
#Data loaders for pre computed features for train and validation data sets
trn_feat_loader = DataLoader(trn_feat_dset,batch_size=64,shuffle=True)
val_feat_loader = DataLoader(val_feat_dset,batch_size=64)
让我们创建一个新的模型来在预卷积特征上进行训练。
创建一个完全连接的模型
简单的模型可能会导致过拟合,因此让我们在模型中包含 dropout。Dropout 可以帮助避免过拟合。在以下代码中,我们正在创建我们的模型:
class FullyConnectedModel(nn.Module):
def __init__(self,in_size,out_size,training=True):
super().__init__()
self.fc = nn.Linear(in_size,out_size)
def forward(self,inp):
out = F.dropout(inp, training=self.training)
out = self.fc(out)
return out
# The size of the output from the selected convolution feature
fc_in_size = 131072
fc = FullyConnectedModel(fc_in_size,classes)
if is_cuda:
fc = fc.cuda()
一旦模型创建完成,我们可以对模型进行训练。
训练和验证模型
我们使用与之前的 ResNet 和其他示例中相同的拟合和训练逻辑。我们只会查看训练代码和其结果:
for epoch in range(1,10):
epoch_loss, epoch_accuracy = fit(epoch,fc,trn_feat_loader,phase='training')
val_epoch_loss , val_epoch_accuracy = fit(epoch,fc,val_feat_loader,phase='validation')
train_losses.append(epoch_loss)
train_accuracy.append(epoch_accuracy)
val_losses.append(val_epoch_loss)
val_accuracy.append(val_epoch_accuracy)
#Results
training loss is 0.78 and training accuracy is 22825/23000 99.24
validation loss is 5.3 and validation accuracy is 1947/2000 97.35
training loss is 0.84 and training accuracy is 22829/23000 99.26
validation loss is 5.1 and validation accuracy is 1952/2000 97.6
training loss is 0.69 and training accuracy is 22843/23000 99.32
validation loss is 5.1 and validation accuracy is 1951/2000 97.55
training loss is 0.58 and training accuracy is 22852/23000 99.36
validation loss is 4.9 and validation accuracy is 1953/2000 97.65
training loss is 0.67 and training accuracy is 22862/23000 99.4
validation loss is 4.9 and validation accuracy is 1955/2000 97.75
training loss is 0.54 and training accuracy is 22870/23000 99.43
validation loss is 4.8 and validation accuracy is 1953/2000 97.65
training loss is 0.56 and training accuracy is 22856/23000 99.37
validation loss is 4.8 and validation accuracy is 1955/2000 97.75
training loss is 0.7 and training accuracy is 22841/23000 99.31
validation loss is 4.8 and validation accuracy is 1956/2000 97.8
training loss is 0.47 and training accuracy is 22880/23000 99.48
validation loss is 4.7 and validation accuracy is 1956/2000 97.8
查看结果,Inception 模型在训练集上达到了 99% 的准确率,在验证集上达到了 97.8% 的准确率。由于我们预先计算并将所有特征保存在内存中,训练模型只需不到几分钟的时间。如果在运行程序时出现内存不足的情况,则可能需要避免将特征保存在内存中。
我们将看看另一个有趣的架构 DenseNet,在过去一年中变得非常流行。
密集连接的卷积网络 – DenseNet
一些成功和流行的架构,如 ResNet 和 Inception,展示了更深更宽网络的重要性。ResNet 使用快捷连接来构建更深的网络。DenseNet 将其提升到一个新水平,通过引入从每一层到所有后续层的连接,即一个层可以接收来自前几层的所有特征图。符号上看,它可能是这样的:
下图描述了一个五层密集块的结构:
图片来源:https://arxiv.org/abs/1608.06993
torchvision 中有 DenseNet 的实现(github.com/pytorch/vision/blob/master/torchvision/models/densenet.py
)。让我们看一下两个主要功能,_DenseBlock
和 _DenseLayer
。
DenseBlock
让我们查看 DenseBlock
的代码,然后逐步分析它:
class _DenseBlock(nn.Sequential):
def __init__(self, num_layers, num_input_features, bn_size, growth_rate, drop_rate):
super(_DenseBlock, self).__init__()
for i in range(num_layers):
layer = _DenseLayer(num_input_features + i * growth_rate, growth_rate, bn_size, drop_rate)
self.add_module('denselayer%d' % (i + 1), layer)
DenseBlock
是一个顺序模块,我们按顺序添加层。根据块中的层数(num_layers
),我们添加相应数量的 _DenseLayer
对象以及一个名称。所有的魔法都发生在 DenseLayer
内部。让我们看看 DenseLayer
内部发生了什么。
DenseLayer
学习一个特定网络如何工作的一个好方法是查看源代码。PyTorch 实现非常清晰,大多数情况下易于阅读。让我们来看一下 DenseLayer
的实现:
class _DenseLayer(nn.Sequential):
def __init__(self, num_input_features, growth_rate, bn_size, drop_rate):
super(_DenseLayer, self).__init__()
self.add_module('norm.1', nn.BatchNorm2d(num_input_features)),
self.add_module('relu.1', nn.ReLU(inplace=True)),
self.add_module('conv.1', nn.Conv2d(num_input_features, bn_size *
growth_rate, kernel_size=1, stride=1, bias=False)),
self.add_module('norm.2', nn.BatchNorm2d(bn_size * growth_rate)),
self.add_module('relu.2', nn.ReLU(inplace=True)),
self.add_module('conv.2', nn.Conv2d(bn_size * growth_rate, growth_rate,
kernel_size=3, stride=1, padding=1, bias=False)),
self.drop_rate = drop_rate
def forward(self, x):
new_features = super(_DenseLayer, self).forward(x)
if self.drop_rate > 0:
new_features = F.dropout(new_features, p=self.drop_rate, training=self.training)
return torch.cat([x, new_features], 1)
如果你对 Python 中的继承不熟悉,那么前面的代码可能看起来不直观。_DenseLayer
是 nn.Sequential
的子类;让我们看看每个方法内部发生了什么。
在__init__
方法中,我们添加所有需要传递给输入数据的层。这与我们看到的所有其他网络架构非常相似。
魔法发生在forward
方法中。我们将输入传递给super
类的forward
方法,即nn.Sequential
的方法。让我们看看顺序类的forward
方法中发生了什么(github.com/pytorch/pytorch/blob/409b1c8319ecde4bd62fcf98d0a6658ae7a4ab23/torch/nn/modules/container.py
):
def forward(self, input):
for module in self._modules.values():
input = module(input)
return input
输入通过之前添加到顺序块中的所有层,并将输出连接到输入。该过程在块中所需数量的层中重复进行。
了解了DenseNet
块的工作原理后,让我们探索如何使用 DenseNet 计算预卷积特征并在其上构建分类器模型。在高层次上,DenseNet 的实现类似于 VGG 的实现。DenseNet 实现还有一个特征模块,其中包含所有的密集块,以及一个分类器模块,其中包含全连接模型。我们将按照以下步骤构建模型。我们将跳过与 Inception 和 ResNet 相似的大部分内容,例如创建数据加载器和数据集。同时,我们将详细讨论以下步骤:
-
创建一个 DenseNet 模型
-
提取 DenseNet 特征
-
创建一个数据集和加载器
-
创建一个全连接模型并训练
到目前为止,大部分代码都将是不言自明的。
创建一个 DenseNet 模型
Torchvision 有一个预训练的 DenseNet 模型,具有不同的层次选项(121、169、201、161)。我们选择了具有121
层的模型。正如讨论的那样,DenseNet 有两个模块:特征(包含密集块)和分类器(全连接块)。由于我们正在使用 DenseNet 作为图像特征提取器,我们只会使用特征模块:
my_densenet = densenet121(pretrained=True).features
if is_cuda:
my_densenet = my_densenet.cuda()
for p in my_densenet.parameters():
p.requires_grad = False
让我们从图像中提取 DenseNet 特征。
提取 DenseNet 特征
这与我们为 Inception 所做的相似,只是我们没有使用register_forward_hook
来提取特征。以下代码展示了如何提取 DenseNet 特征:
#For training data
trn_labels = []
trn_features = []
#code to store densenet features for train dataset.
for d,la in train_loader:
o = my_densenet(Variable(d.cuda()))
o = o.view(o.size(0),-1)
trn_labels.extend(la)
trn_features.extend(o.cpu().data)
#For validation data
val_labels = []
val_features = []
#Code to store densenet features for validation dataset.
for d,la in val_loader:
o = my_densenet(Variable(d.cuda()))
o = o.view(o.size(0),-1)
val_labels.extend(la)
val_features.extend(o.cpu().data)
前述代码与我们之前看到的 Inception 和 ResNet 类似。
创建一个数据集和加载器
我们将使用我们为 ResNet 创建的FeaturesDataset
类,并在以下代码中使用它来为train
和validation
数据集创建数据加载器:
# Create dataset for train and validation convolution features
trn_feat_dset = FeaturesDataset(trn_features,trn_labels)
val_feat_dset = FeaturesDataset(val_features,val_labels)
# Create data loaders for batching the train and validation datasets
trn_feat_loader = DataLoader(trn_feat_dset,batch_size=64,shuffle=True,drop_last=True)
val_feat_loader = DataLoader(val_feat_dset,batch_size=64)
是时候创建模型并训练了。
创建一个全连接模型并训练
我们将使用一个简单的线性模型,类似于我们在 ResNet 和 Inception 中使用的模型。以下代码展示了我们将用来训练模型的网络架构:
class FullyConnectedModel(nn.Module):
def __init__(self,in_size,out_size):
super().__init__()
self.fc = nn.Linear(in_size,out_size)
def forward(self,inp):
out = self.fc(inp)
return out
fc = FullyConnectedModel(fc_in_size,classes)
if is_cuda:
fc = fc.cuda()
我们将使用相同的fit
方法来训练前面的模型。以下代码片段显示了训练代码及其结果:
train_losses , train_accuracy = [],[]
val_losses , val_accuracy = [],[]
for epoch in range(1,10):
epoch_loss, epoch_accuracy = fit(epoch,fc,trn_feat_loader,phase='training')
val_epoch_loss , val_epoch_accuracy = fit(epoch,fc,val_feat_loader,phase='validation')
train_losses.append(epoch_loss)
train_accuracy.append(epoch_accuracy)
val_losses.append(val_epoch_loss)
val_accuracy.append(val_epoch_accuracy)
上述代码的结果是:
# Results
training loss is 0.057 and training accuracy is 22506/23000 97.85
validation loss is 0.034 and validation accuracy is 1978/2000 98.9
training loss is 0.0059 and training accuracy is 22953/23000 99.8
validation loss is 0.028 and validation accuracy is 1981/2000 99.05
training loss is 0.0016 and training accuracy is 22974/23000 99.89
validation loss is 0.022 and validation accuracy is 1983/2000 99.15
training loss is 0.00064 and training accuracy is 22976/23000 99.9
validation loss is 0.023 and validation accuracy is 1983/2000 99.15
training loss is 0.00043 and training accuracy is 22976/23000 99.9
validation loss is 0.024 and validation accuracy is 1983/2000 99.15
training loss is 0.00033 and training accuracy is 22976/23000 99.9
validation loss is 0.024 and validation accuracy is 1984/2000 99.2
training loss is 0.00025 and training accuracy is 22976/23000 99.9
validation loss is 0.024 and validation accuracy is 1984/2000 99.2
training loss is 0.0002 and training accuracy is 22976/23000 99.9
validation loss is 0.025 and validation accuracy is 1985/2000 99.25
training loss is 0.00016 and training accuracy is 22976/23000 99.9
validation loss is 0.024 and validation accuracy is 1986/2000 99.3
前述算法能够达到 99%的最大训练精度和 99%的验证精度。由于您创建的validation
数据集可能包含不同的图像,因此您的结果可能会有所不同。
DenseNet 的一些优点包括:
-
它大大减少了所需的参数数量
-
它缓解了梯度消失问题
-
它鼓励特征重用
在接下来的部分中,我们将探讨如何构建一个结合使用 ResNet、Inception 和 DenseNet 不同模型计算的卷积特征优势的模型。
模型集成
有时,我们需要尝试将多个模型组合在一起构建一个非常强大的模型。有许多技术可以用于构建集成模型。在本节中,我们将学习如何使用由三种不同模型(ResNet、Inception 和 DenseNet)生成的特征来结合输出,从而构建一个强大的模型。我们将使用本章中其他示例中使用的同一数据集。
集成模型的架构将如下所示:
该图显示了我们在集成模型中要做的事情,可以总结为以下步骤:
-
创建三个模型
-
使用创建的模型提取图像特征
-
创建一个自定义数据集,该数据集返回所有三个模型的特征以及标签
-
创建类似于前面图中架构的模型
-
训练和验证模型
让我们详细探讨每个步骤。
创建模型
让我们按以下代码创建所有三个所需的模型:
#Create ResNet model
my_resnet = resnet34(pretrained=True)
if is_cuda:
my_resnet = my_resnet.cuda()
my_resnet = nn.Sequential(*list(my_resnet.children())[:-1])
for p in my_resnet.parameters():
p.requires_grad = False
#Create inception model
my_inception = inception_v3(pretrained=True)
my_inception.aux_logits = False
if is_cuda:
my_inception = my_inception.cuda()
for p in my_inception.parameters():
p.requires_grad = False
#Create densenet model
my_densenet = densenet121(pretrained=True).features
if is_cuda:
my_densenet = my_densenet.cuda()
for p in my_densenet.parameters():
p.requires_grad = False
现在我们有了所有模型,让我们从图像中提取特征。
提取图像特征
这里,我们将本章中各算法的各自逻辑组合起来:
### For ResNet
trn_labels = []
trn_resnet_features = []
for d,la in train_loader:
o = my_resnet(Variable(d.cuda()))
o = o.view(o.size(0),-1)
trn_labels.extend(la)
trn_resnet_features.extend(o.cpu().data)
val_labels = []
val_resnet_features = []
for d,la in val_loader:
o = my_resnet(Variable(d.cuda()))
o = o.view(o.size(0),-1)
val_labels.extend(la)
val_resnet_features.extend(o.cpu().data)
### For Inception
trn_inception_features = LayerActivations(my_inception.Mixed_7c)
for da,la in train_loader:
_ = my_inception(Variable(da.cuda()))
trn_inception_features.remove()
val_inception_features = LayerActivations(my_inception.Mixed_7c)
for da,la in val_loader:
_ = my_inception(Variable(da.cuda()))
val_inception_features.remove()
### For DenseNet
trn_densenet_features = []
for d,la in train_loader:
o = my_densenet(Variable(d.cuda()))
o = o.view(o.size(0),-1)
trn_densenet_features.extend(o.cpu().data)
val_densenet_features = []
for d,la in val_loader:
o = my_densenet(Variable(d.cuda()))
o = o.view(o.size(0),-1)
val_densenet_features.extend(o.cpu().data)
到目前为止,我们已经使用所有模型创建了图像特征。如果你遇到内存问题,那么可以删除其中一个模型,或者停止在内存中存储特征,这可能会导致训练速度变慢。如果你在运行这个过程时使用的是 CUDA 实例,那么可以选择更强大的实例。
创建一个自定义数据集以及数据加载器
由于FeaturesDataset
类仅开发用于从一个模型的输出中进行选择,所以我们将无法直接使用它。因此,以下实现对FeaturesDataset
类进行了微小的更改,以适应所有三个不同生成特征的情况:
class FeaturesDataset(Dataset):
def __init__(self,featlst1,featlst2,featlst3,labellst):
self.featlst1 = featlst1
self.featlst2 = featlst2
self.featlst3 = featlst3
self.labellst = labellst
def __getitem__(self,index):
return (self.featlst1[index],self.featlst2[index],self.featlst3[index],self.labellst[index])
def __len__(self):
return len(self.labellst)
trn_feat_dset = FeaturesDataset(trn_resnet_features,trn_inception_features.features,trn_densenet_features,trn_labels)
val_feat_dset = FeaturesDataset(val_resnet_features,val_inception_features.features,val_densenet_features,val_labels)
我们已经对__init__
方法进行了更改,以存储来自不同模型生成的所有特征,并对__getitem__
方法进行了更改,以检索图像的特征和标签。使用FeatureDataset
类,我们为训练和验证数据创建了数据集实例。一旦数据集创建完成,我们可以使用相同的数据加载器批处理数据,如以下代码所示:
trn_feat_loader = DataLoader(trn_feat_dset,batch_size=64,shuffle=True)
val_feat_loader = DataLoader(val_feat_dset,batch_size=64)
创建一个集成模型
我们需要创建一个与之前展示的架构图类似的模型。以下代码实现了这一点:
class EnsembleModel(nn.Module):
def __init__(self,out_size,training=True):
super().__init__()
self.fc1 = nn.Linear(8192,512)
self.fc2 = nn.Linear(131072,512)
self.fc3 = nn.Linear(82944,512)
self.fc4 = nn.Linear(512,out_size)
def forward(self,inp1,inp2,inp3):
out1 = self.fc1(F.dropout(inp1,training=self.training))
out2 = self.fc2(F.dropout(inp2,training=self.training))
out3 = self.fc3(F.dropout(inp3,training=self.training))
out = out1 + out2 + out3
out = self.fc4(F.dropout(out,training=self.training))
return out
em = EnsembleModel(2)
if is_cuda:
em = em.cuda()
在前面的代码中,我们创建了三个线性层,这些线性层接收从不同模型生成的特征。我们将这三个线性层的所有输出相加,并将它们传递给另一个线性层,将它们映射到所需的类别。为了防止模型过拟合,我们使用了 dropout。
训练和验证模型
我们需要对fit
方法进行一些小的更改,以适应从数据加载器生成的三个输入值。以下代码实现了新的fit
函数:
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 , (data1,data2,data3,target) in enumerate(data_loader):
if is_cuda:
data1,data2,data3,target = data1.cuda(),data2.cuda(),data3.cuda(),target.cuda()
data1,data2,data3,target = Variable(data1,volatile),Variable(data2,volatile),Variable(data3,volatile),Variable(target)
if phase == 'training':
optimizer.zero_grad()
output = model(data1,data2,data3)
loss = F.cross_entropy(output,target)
running_loss += F.cross_entropy(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 = [],[]
val_losses , val_accuracy = [],[]
for epoch in range(1,10):
epoch_loss, epoch_accuracy = fit(epoch,em,trn_feat_loader,phase='training')
val_epoch_loss , val_epoch_accuracy = fit(epoch,em,val_feat_loader,phase='validation')
train_losses.append(epoch_loss)
train_accuracy.append(epoch_accuracy)
val_losses.append(val_epoch_loss)
val_accuracy.append(val_epoch_accuracy)
上述代码的结果如下:
#Results
training loss is 7.2e+01 and training accuracy is 21359/23000 92.87
validation loss is 6.5e+01 and validation accuracy is 1968/2000 98.4
training loss is 9.4e+01 and training accuracy is 22539/23000 98.0
validation loss is 1.1e+02 and validation accuracy is 1980/2000 99.0
training loss is 1e+02 and training accuracy is 22714/23000 98.76
validation loss is 1.4e+02 and validation accuracy is 1976/2000 98.8
training loss is 7.3e+01 and training accuracy is 22825/23000 99.24
validation loss is 1.6e+02 and validation accuracy is 1979/2000 98.95
training loss is 7.2e+01 and training accuracy is 22845/23000 99.33
validation loss is 2e+02 and validation accuracy is 1984/2000 99.2
training loss is 1.1e+02 and training accuracy is 22862/23000 99.4
validation loss is 4.1e+02 and validation accuracy is 1975/2000 98.75
training loss is 1.3e+02 and training accuracy is 22851/23000 99.35
validation loss is 4.2e+02 and validation accuracy is 1981/2000 99.05
training loss is 2e+02 and training accuracy is 22845/23000 99.33
validation loss is 6.1e+02 and validation accuracy is 1982/2000 99.1
training loss is 1e+02 and training accuracy is 22917/23000 99.64
validation loss is 5.3e+02 and validation accuracy is 1986/2000 99.3
集成模型达到了 99.6%的训练精度和 99.3%的验证精度。虽然集成模型功能强大,但计算开销大。它们在解决如 Kaggle 竞赛中的问题时是很好的技术。
编码器-解码器架构
我们在书中看到的几乎所有深度学习算法都擅长学习如何将训练数据映射到其相应的标签。我们不能直接将它们用于需要模型从序列学习并生成另一个序列或图像的任务。一些示例应用包括:
-
语言翻译
-
图像字幕
-
图像生成(seq2img)
-
语音识别
-
问答系统
这些问题大多可以看作是某种形式的序列到序列映射,可以使用一系列称为编码器-解码器架构的体系结构来解决。在本节中,我们将了解这些架构背后的直觉。我们不会看这些网络的实现,因为它们需要更详细的学习。
在高层次上,编码器-解码器架构看起来如下所示:
编码器通常是一个递归神经网络(RNN)(用于序列数据)或卷积神经网络(CNN)(用于图像),它接收图像或序列并将其转换为一个固定长度的向量,该向量编码了所有信息。解码器是另一个 RNN 或 CNN,它学习解码编码器生成的向量,并生成新的数据序列。下图展示了用于图像字幕系统的编码器-解码器架构的外观:
图像字幕系统的编码器-解码器架构
图像来源:https://arxiv.org/pdf/1411.4555.pdf
让我们更详细地看一看图像字幕系统中编码器和解码器架构的内部情况。
编码器
对于图像字幕系统,我们通常会使用训练好的架构,比如 ResNet 或 Inception,从图像中提取特征。就像我们对集成模型所做的那样,我们可以通过使用一个线性层输出固定长度的向量,然后使该线性层可训练。
解码器
解码器是一个长短期记忆(LSTM)层,用于为图像生成字幕。为了构建一个简单的模型,我们可以只将编码器嵌入作为 LSTM 的输入传递一次。但是解码器要学习起来可能会很有挑战性;因此,常见做法是在解码器的每一步中提供编码器嵌入。直观地说,解码器学习生成一系列最佳描述给定图像字幕的文本序列。
总结
在本章中,我们探讨了一些现代架构,如 ResNet、Inception 和 DenseNet。我们还探讨了如何使用这些模型进行迁移学习和集成学习,并介绍了编码器-解码器架构,该架构驱动着许多系统,如语言翻译系统。
在下一章中,我们将总结在书籍学习旅程中取得的成就,同时讨论你接下来可以从哪里继续前行。我们将探讨关于 PyTorch 的大量资源以及一些正在使用 PyTorch 进行研究的酷炫深度学习项目。