原文:
zh.annas-archive.org/md5/ed4780a817b954d8a29cd07c34f589a6
译者:飞龙
第七章:使用 PyTorch 进行音乐和文本生成
加入我们的书籍社区在 Discord 上
packt.link/EarlyAccessCommunity
PyTorch 既是研究深度学习模型又是开发基于深度学习的应用程序的绝佳工具。在前几章中,我们探讨了跨多个领域和模型类型的模型架构。我们使用 PyTorch 从头开始构建了这些架构,并使用了 PyTorch 模型动物园中的预训练模型。从本章开始,我们将转变方向,深入探讨生成模型。
在前几章中,我们的大多数示例和练习都围绕开发分类模型展开,这是一个监督学习任务。然而,当涉及到无监督学习任务时,深度学习模型也被证明非常有效。深度生成模型就是其中一个例子。这些模型使用大量未标记的数据进行训练。训练完成后,模型可以生成类似的有意义数据。它通过学习输入数据的潜在结构和模式来实现这一点。
在本章中,我们将开发文本和音乐生成器。为了开发文本生成器,我们将利用我们在第五章《混合高级模型》中训练的基于 Transformer 的语言模型。我们将使用 PyTorch 扩展 Transformer 模型,使其成为文本生成器。此外,我们还将演示如何在 PyTorch 中使用先进的预训练 Transformer 模型来设置几行代码中的文本生成器。最后,我们将展示如何使用 PyTorch 从头开始训练一个基于 MIDI 数据集的音乐生成模型。
在本章结束时,您应该能够在 PyTorch 中创建自己的文本和音乐生成模型。您还将能够应用不同的采样或生成策略,以从这些模型生成数据。本章涵盖以下主题:
-
使用 PyTorch 构建基于 Transformer 的文本生成器
-
使用预训练的 GPT 2 模型作为文本生成器
-
使用 PyTorch 生成 MIDI 音乐的 LSTMs
使用 PyTorch 构建基于 Transformer 的文本生成器
在上一章中,我们使用 PyTorch 构建了基于 Transformer 的语言模型。因为语言模型建模了在给定一系列单词之后某个单词出现的概率,所以我们在构建自己的文本生成器时已经过了一半的路程。在本节中,我们将学习如何将这个语言模型扩展为一个深度生成模型,它可以在给定一系列初始文本提示的情况下生成任意但有意义的句子。
训练基于 Transformer 的语言模型
在前一章中,我们对语言模型进行了 5 个 epoch 的训练。在本节中,我们将按照相同的步骤进行训练,但将模型训练更长时间 - 25 个 epoch。目标是获得一个表现更好的语言模型,可以生成更真实的句子。请注意,模型训练可能需要几个小时。因此,建议在后台进行训练,例如过夜。为了按照训练语言模型的步骤进行操作,请在 GitHub [7.1] 上查看完整代码。
在训练了 25 个 epoch 之后,我们得到了以下输出:
图 7 .1 – 语言模型训练日志
现在我们已经成功地训练了 25 个 epoch 的 Transformer 模型,我们可以进入实际的练习阶段,在这里我们将扩展这个训练好的语言模型作为一个文本生成模型。
保存和加载语言模型
在这里,我们将在训练完成后简单保存表现最佳的模型检查点。然后,我们可以单独加载这个预训练模型:
- 一旦模型训练完成,最好将其保存在本地,以避免需要从头开始重新训练。可以按以下步骤保存:
mdl_pth = './transformer.pth'
torch.save(best_model_so_far.state_dict(), mdl_pth)
- 现在,我们可以加载保存的模型,以便将这个语言模型扩展为文本生成模型:
# load the best trained model
transformer_cached = Transformer(num_tokens, embedding_size, num_heads, num_hidden_params, num_layers, dropout).to(device)
transformer_cached.load_state_dict(torch.load(mdl_pth))
在本节中,我们重新实例化了一个 Transformer 模型对象,然后将预训练的模型权重加载到这个新的模型对象中。接下来,我们将使用这个模型来生成文本。
使用语言模型生成文本
现在模型已经保存和加载完毕,我们可以扩展训练好的语言模型来生成文本:
- 首先,我们必须定义我们想要生成的目标单词数量,并提供一个初始单词序列作为模型的线索:
ln = 5
sntc = 'They are _'
sntc_split = sntc.split()
mask_source = gen_sqr_nxt_mask(max_seq_len).to(device)
- 最后,我们可以在循环中逐个生成单词。在每次迭代中,我们可以将该迭代中预测的单词附加到输入序列中。这个扩展的序列成为下一个迭代中模型的输入,依此类推。添加随机种子是为了确保一致性。通过更改种子,我们可以生成不同的文本,如下面的代码块所示:
torch.manual_seed(34 )
with torch.no_grad():
for i in range(ln):
sntc = ' '.join(sntc_split)
txt_ds = Tensor(vocabulary(sntc_split)).unsqueeze(0).to(torch.long)
num_b = txt_ds.size(0)
txt_ds = txt_ds.narrow(0, 0, num_b)
txt_ds = txt_ds.view(1, -1).t().contiguous().to(device)
ev_X, _ = return_batch(txt_ds, i+1)
sequence_length = ev_X.size(0)
if sequence_length != max_seq_len:
mask_source = mask_source[:sequence_length, :sequence_length]
op = transformer_cached(ev_X, mask_source)
op_flat = op.view(-1, num_tokens)
res = vocabulary.get_itos()[op_flat.argmax(1)[0]]
sntc_split.insert(-1, res)
print(sntc[:-2])
这应该会输出以下内容:
图 7 .2 – Transformer 生成的文本
我们可以看到,使用 PyTorch,我们可以训练一个语言模型(在本例中是基于 Transformer 的模型),然后通过几行额外的代码来生成文本。生成的文本似乎是有意义的。这种文本生成器的结果受到底层语言模型训练数据量和语言模型强度的限制。在本节中,我们基本上是从头开始构建了一个文本生成器。
在下一节中,我们将加载预训练语言模型,并将其用作文本生成器。我们将使用变压器模型的高级继任者 – 生成式预训练变压器(GPT-2)。我们将演示如何在不到 10 行代码的情况下,使用 PyTorch 构建一个即时高级文本生成器。我们还将探讨从语言模型生成文本涉及的一些策略。
使用预训练的 GPT-2 模型作为文本生成器
使用transformers
库和 PyTorch,我们可以加载大多数最新的先进变压器模型,用于执行诸如语言建模、文本分类、机器翻译等各种任务。我们在第五章,混合先进模型中展示了如何做到这一点。
在本节中,我们将加载预训练的基于 GPT-2 的语言模型。然后,我们将扩展此模型,以便我们可以将其用作文本生成器。然后,我们将探索各种策略,以便从预训练的语言模型中生成文本,并使用 PyTorch 演示这些策略。
使用 GPT-2 的即时文本生成
作为练习形式,我们将加载一个使用 transformers 库预训练的 GPT-2 语言模型,并将此语言模型扩展为文本生成模型以生成任意但有意义的文本。为了演示目的,我们仅显示代码的重要部分。要访问完整的代码,请转到 github [7.2] 。按照以下步骤进行:
- 首先,我们需要导入必要的库:
from transformers import GPT2LMHeadModel, GPT2Tokenizer
import torch
我们将导入 GPT-2 多头语言模型和相应的分词器来生成词汇表。
- 接下来,我们将实例化
GPT2Tokenizer
和语言模型。然后,我们将提供一组初始单词作为模型的线索,如下所示:
torch.manual_seed(799)
tkz = GPT2Tokenizer.from_pretrained("gpt2")
mdl = GPT2LMHeadModel.from_pretrained('gpt2')
ln = 10
cue = "They "
gen = tkz(cue, return_tensors="pt")
to_ret = gen["input_ids"][0]
- 最后,我们将迭代地预测给定输入单词序列的下一个单词,使用语言模型。在每次迭代中,预测的单词将附加到下一次迭代的输入单词序列中:
prv=None
for i in range(ln):
outputs = mdl(**gen)
next_token_logits = torch.argmax(outputs.logits[-1, :])
to_ret = torch.cat([to_ret, next_token_logits.unsqueeze(0)])
gen = {"input_ids": to_ret}
seq = tkz.decode(to_ret)
print(seq)
输出应该如下所示:
图 7.3 – GPT-2 生成的文本
这种生成文本的方式也称为贪婪搜索。在接下来的部分,我们将更详细地讨论贪婪搜索以及其他一些文本生成策略。
使用 PyTorch 的文本生成策略
当我们使用训练过的文本生成模型生成文本时,通常是逐词预测。然后,我们将预测出的一系列单词序列合并为预测文本。当我们在循环中迭代单词预测时,我们需要指定一种方法来找到/预测给定前k个预测后的下一个单词。这些方法也被称为文本生成策略,我们将在本节讨论一些著名的策略。
贪婪搜索
贪婪一词的正当性在于,该模型选择当前迭代中具有最大概率的单词,而不考虑它们在未来多少时间步骤后。通过这种策略,模型可能会错过一个概率高的单词,而选择了一个概率低的单词,因为模型没有追求概率低的单词。以下图表展示了贪婪搜索策略,用一个假设情景来说明在前一个练习的第 3 步中可能发生的情况。在每个时间步骤,文本生成模型输出可能的单词及其概率:
图 7 .4 – 贪婪搜索
我们可以看到,在每个步骤中,根据文本生成的贪婪搜索策略,模型会选择具有最高概率的单词。请注意倒数第二步,在这一步中,模型预测单词system、people和future的概率几乎相等。通过贪婪搜索,system被选为下一个单词,因为它的概率略高于其他单词。然而,你可以认为people或future可能会导致更好或更有意义的生成文本。
这是贪婪搜索方法的核心局限性。此外,贪婪搜索还因缺乏随机性而导致重复结果。如果有人想要艺术地使用这样的文本生成器,贪婪搜索并不是最佳选择,仅仅因为它的单调性。
在前一节中,我们手动编写了文本生成循环。由于transformers
库的帮助,我们可以用三行代码编写文本生成步骤:
ip_ids = tkz.encode(cue, return_tensors='pt')
op_greedy = mdl.generate(ip_ids, max_length=ln, pad_token_id=tkz.eos_token_id)
seq = tkz.decode(op_greedy[0], skip_special_tokens=True)
print(seq)
这应该输出如下内容:
图 7 .5 – GPT-2 生成的简明文本
请注意,图 7 .5 中生成的句子比图 7 .3 中生成的句子少一个标记(句号)。这一差异是因为在后者的代码中,max_length
参数包括了提示词。因此,如果我们有一个提示词,那么仅会预测出九个新单词,正如在这里的情况一样。
光束搜索
生成文本不仅仅是贪婪搜索的一种方式。束搜索是贪婪搜索方法的发展,其中我们维护一个基于整体预测序列概率的潜在候选序列列表,而不仅仅是下一个单词的概率。要追求的候选序列数量即为单词预测树中的光束数。
以下图表展示了如何使用光束搜索和光束大小为 3 来生成五个单词的三个候选序列(按照整体序列概率排序):
图 7 .6 – 光束搜索
在这个束搜索示例的每次迭代中,会保留三个最有可能的候选序列。随着序列的推进,可能的候选序列数呈指数增长。然而,我们只关注前三个序列。这样,我们不会像贪婪搜索那样错过潜在更好的序列。
在 PyTorch 中,我们可以使用一行代码轻松使用束搜索。以下代码演示了基于束搜索的文本生成,使用三个束生成三个最有可能的句子,每个句子包含五个单词:
op_beam = mdl.generate(
ip_ids,
max_length=5,
num_beams=3,
num_return_sequences=3,
pad_token_id=tkz.eos_token_id
)
for op_beam_cur in op_beam:
print(tkz.decode(op_beam_cur, skip_special_tokens=True))
这给我们以下输出:
图 7 .7 – 束搜索结果
使用束搜索仍然存在重复性或单调性问题。不同的运行会得到相同的结果集,因为它确定性地寻找具有最大总体概率的序列。在接下来的部分中,我们将探讨一些方法,使生成的文本更加不可预测或创造性。
Top-k 和 top-p 抽样
我们可以随机抽样下一个单词,而不总是选择具有最高概率的下一个单词,基于它们相对概率的可能集合。例如,在 图 7 .6 中,单词be,know和show的概率分别为 0.7,0.2 和 0.1。我们可以基于它们的概率随机抽样其中任何一个单词。如果我们重复此过程 10 次以生成 10 个单独的文本,be将被选择大约七次,know和show将分别被选择两次和一次。这给了我们很多贪婪或束搜索永远无法生成的可能单词组合。
使用抽样技术生成文本的两种最流行方式称为top-k和top-p抽样。在 top-k 抽样中,我们预先定义一个参数k,它是在抽样下一个词时应考虑的候选词数。所有其他词都将被丢弃,并且在前k个词中的概率将被归一化。在我们的先前示例中,如果k为 2,则单词show将被丢弃,单词be和know的概率(分别为 0.7 和 0.2)将被归一化为 0.78 和 0.22。
以下代码演示了 top-k 文本生成方法:
for i in range(3):
torch.manual_seed(i+10)
op = mdl.generate(
ip_ids,
do_sample=True,
max_length=5,
top_k=2,
pad_token_id=tkz.eos_token_id
)
seq = tkz.decode(op[0], skip_special_tokens=True)
print(seq)
这应该生成以下输出:
图 7 .8 – Top-k 搜索结果
要从所有可能的单词中抽样,而不仅仅是前k个单词,请在我们的代码中将top-k
参数设置为0
。如前面的屏幕截图所示,不同的运行产生不同的结果,而不是贪婪搜索,每次运行都会产生相同的结果,如以下代码所示:
for i in range(3):
torch.manual_seed(i+10)
op_greedy = mdl.generate(ip_ids, max_length=5, pad_token_id=tkz.eos_token_id)
seq = tkz.decode(op_greedy[0], skip_special_tokens=True)
print(seq)
这应该输出以下内容:
图 7 .9 – 重复的贪婪搜索结果
在 top-p 抽样策略下,与其定义要查看的前k个词汇不同,我们可以定义一个累积概率阈值(p),然后保留那些概率总和达到p的词汇。在我们的例子中,如果p介于0.7和0.9之间,则舍弃know和show;如果p介于0.9和1.0之间,则舍弃show;如果p为1.0,则保留所有三个词汇,即be,know和show。
在概率分布是平坦的情况下,top-k 策略有时可能不公平。这是因为它剪切掉几乎与保留的词汇一样可能的词汇。在这些情况下,top-p 策略将保留一个更大的词汇池供抽样,并在概率分布较为尖锐时保留较小的词汇池。
以下代码展示了 top-p 抽样方法的操作:
for i in range(3):
torch.manual_seed(i+10)
op = mdl.generate(
ip_ids,
do_sample=True,
max_length=5,
top_p=0.75,
top_k=0,
pad_token_id=tkz.eos_token_id
)
seq = tkz.decode(op[0], skip_special_tokens=True)
print(seq)
这应该输出以下内容:
图 7 .10 – Top-p 搜索结果
我们可以同时设置 top-k 和 top-p 策略。在这个例子中,我们将top-k
设置为0
以基本上禁用 top-k 策略,而p
设置为0.75
。再次运行时,这会导致不同的句子,使我们能够生成更具创造性的文本,而不是贪婪或波束搜索。在这个领域有许多更多的文本生成策略可供选择,并且正在进行大量的研究。我们鼓励您进一步关注此问题。
一个很好的起点是在transformers
库中玩转可用的文本生成策略。您可以从他们的博客文章[7.3]中了解更多信息。
这篇文章总结了我们使用 PyTorch 生成文本的探索。在下一节中,我们将执行类似的练习,但这次是针对音乐而不是文本。其思想是在音乐数据集上训练一个无监督模型,并使用训练好的模型生成类似训练数据集中的旋律。
使用 PyTorch 生成 MIDI 音乐的 LSTM 方法
转向音乐,本节中我们将使用 PyTorch 创建一个可以生成类似古典音乐的机器学习模型。在上一节中,我们使用 transformers 生成文本。在这里,我们将使用 LSTM 模型处理序列音乐数据。我们将在莫扎特的古典音乐作品上训练模型。
每个音乐作品本质上将被分解为一系列钢琴音符。我们将以音乐器件数字接口(MIDI)文件的形式读取音乐数据,这是一种用于跨设备和环境方便读写音乐数据的常用格式。
在将 MIDI 文件转换为钢琴音符序列(我们称之为钢琴卷轴)之后,我们将使用它们来训练一个下一个钢琴音符检测系统。在这个系统中,我们将构建一个基于 LSTM 的分类器,用于预测给定钢琴音符前序列的下一个钢琴音符,总共有 88 个(符合标准的 88 个钢琴键)。
现在,我们将展示构建 AI 音乐作曲家的整个过程,这是一个练习形式。我们的重点将放在用于数据加载、模型训练和生成音乐样本的 PyTorch 代码上。请注意,模型训练过程可能需要几个小时,因此建议在后台运行训练过程,例如过夜。出于保持文本简短的考虑,此处呈现的代码已被削减。
关于处理 MIDI 音乐文件的详细信息超出了本书的范围,尽管鼓励你探索完整的代码,该代码可在 github [7.4] 找到。
加载 MIDI 音乐数据
首先,我们将演示如何加载以 MIDI 格式可用的音乐数据。我们将简要介绍处理 MIDI 数据的代码,然后说明如何将其转换为 PyTorch 数据加载器。让我们开始吧:
- 如往常一样,我们将从导入重要的库开始。在这个练习中,我们将使用一些新的库,具体如下:
import skimage.io as io
from struct import pack, unpack
from io import StringIO, BytesIO
skimage
用于可视化模型生成的音乐样本序列。struct
和 io
用于处理将 MIDI 音乐数据转换为钢琴卷轴的过程。
- 接下来,我们将编写用于加载 MIDI 文件并将其转换为钢琴音符序列(矩阵)的辅助类和函数。首先,我们定义一些 MIDI 常量,以配置各种音乐控制,如音高、通道、序列开始、序列结束等:
NOTE_MIDI_OFF = 0x80
NOTE_MIDI_ON = 0x90
CHNL_PRESS = 0xD0
MIDI_PITCH_BND = 0xE0
...
- 接下来,我们将定义一系列类,用于处理 MIDI 数据的输入和输出流、MIDI 数据解析器等,如下所示:
class MOStrm:
# MIDI Output Stream
...
class MIFl:
# MIDI Input File Reader
...
class MOFl(MOStrm):
# MIDI Output File Writer
...
class RIStrFl:
# Raw Input Stream File Reader
...
class ROStrFl:
# Raw Output Stream File Writer
...
class MFlPrsr:
# MIDI File Parser
...
class EvtDspch:
# Event Dispatcher
...
class MidiDataRead(MOStrm):
# MIDI Data Reader
...
- 处理完所有 MIDI 数据 I/O 相关的代码后,我们现在可以实例化我们自己的 PyTorch 数据集类了。在此之前,我们必须定义两个关键函数——一个用于将读取的 MIDI 文件转换为钢琴卷轴,另一个用于用空音符填充钢琴卷轴。这将使数据集中的音乐片段长度标准化:
def md_fl_to_pio_rl(md_fl):
md_d = MidiDataRead(md_fl, dtm=0.3)
pio_rl = md_d.pio_rl.transpose()
pio_rl[pio_rl > 0] = 1
return pio_rl
def pd_pio_rl(pio_rl, mx_l=132333, pd_v=0):
orig_rol_len = pio_rl.shape[1]
pdd_rol = np.zeros((88, mx_l))
pdd_rol[:] = pd_v
pdd_rol[:, - orig_rol_len:] = pio_rl
return pdd_rol
- 现在,我们可以定义我们的 PyTorch 数据集类,如下所示:
class NtGenDataset(data.Dataset):
def __init__(self, md_pth, mx_seq_ln=1491):
...
def mx_len_upd(self):
...
def __len__(self):
return len(self.md_fnames_ful)
def __getitem__(self, index):
md_fname_ful = self.md_fnames_ful[index]
pio_rl = md_fl_to_pio_rl(md_fname_ful)
seq_len = pio_rl.shape[1] - 1
ip_seq = pio_rl[:, :-1]
gt_seq = pio_rl[:, 1:]
...
return (torch.FloatTensor(ip_seq_pad),
torch.LongTensor(gt_seq_pad), torch.LongTensor([seq_len]))
- 除了数据集类之外,我们还必须添加另一个辅助函数,将训练数据批次中的音乐序列后处理为三个单独的列表。这些列表将是输入序列、输出序列和序列的长度,按序列长度降序排列:
def pos_proc_seq(btch):
ip_seqs, op_seqs, lens = btch
...
ord_tr_data_tups = sorted(tr_data_tups,
key=lambda c: int(c[2]),
reverse=True)
ip_seq_splt_btch, op_seq_splt_btch, btch_splt_lens = zip(*ord_tr_data_tups)
...
return tps_ip_seq_btch, ord_op_seq_btch, list(ord_btch_lens_l)
- 为了这个练习,我们将使用一组莫扎特的作品。您可以从钢琴 MIDI 网站[7.5]下载数据集:。下载的文件夹包含 21 个 MIDI 文件,我们将它们分成 18 个训练集文件和 3 个验证集文件。下载的数据存储在
./mozart/train
和./mozart/valid
下。下载完成后,我们可以读取数据并实例化我们自己的训练和验证数据集加载器:
training_dataset = NtGenDataset('./mozart/train', mx_seq_ln=None)
training_datasetloader = data.DataLoader(training_dataset, batch_size=5,shuffle=True, drop_last=True)
validation_dataset = NtGenDataset('./mozart/valid/', mx_seq_ln=None)
validation_datasetloader = data.DataLoader(validation_dataset, batch_size=3, shuffle=False, drop_last=False)
X_validation = next(iter(validation_datasetloader))
X_validation[0].shape
这应该会给我们以下输出:
图 7 .11 – 莫扎特作品数据示例维度
如我们所见,第一个验证批次包含三个长度为 1,587 的序列(音符),每个序列编码为一个 88 大小的向量,其中 88 是钢琴键的总数。对于那些训练有素的音乐家,以下是验证集音乐文件的前几个音符的乐谱等价物:
图 7 .12 – 莫扎特作品的乐谱
或者,我们可以将音符序列可视化为一个具有 88 行的矩阵,每行代表一个钢琴键。以下是前述旋律(1,587 个音符中的前 300 个)的视觉矩阵表示:
图 7 .13 – 莫扎特作品的矩阵表示
数据集引用
Bernd Krueger 的 MIDI、音频(MP3、OGG)和视频文件受 CC BY-SA Germany 许可证的保护。姓名:Bernd Krueger 来源:
www.piano-midi.de
。这些文件的分发或公共播放仅允许在相同的许可条件下进行。乐谱是开源的。
现在我们将定义 LSTM 模型和训练例程。
定义 LSTM 模型和训练例程
到目前为止,我们已成功加载了一个 MIDI 数据集,并用它创建了自己的训练和验证数据加载器。在本节中,我们将定义 LSTM 模型架构以及在模型训练循环中运行的训练和评估过程。让我们开始吧:
- 首先,我们必须定义模型架构。如前所述,我们将使用一个 LSTM 模型,该模型由编码器层组成,在序列的每个时间步骤将输入数据的 88 维表示编码为 512 维隐藏层表示。编码器之后是两个 LSTM 层,再接一个全连接层,最终通过 softmax 函数映射到 88 个类别。
根据我们在第四章 深度递归模型架构中讨论的不同类型的递归神经网络(RNNs),这是一个多对一的序列分类任务,其中输入是从时间步 0 到时间步 t 的整个序列,输出是时间步 t+1 处的 88 个类别之一,如下所示:
class MusicLSTM(nn.Module):
def __init__(self, ip_sz, hd_sz, n_cls, lyrs=2):
...
self.nts_enc = nn.Linear(in_features=ip_sz, out_features=hd_sz)
self.bn_layer = nn.BatchNorm1d(hd_sz)
self.lstm_layer = nn.LSTM(hd_sz, hd_sz, lyrs)
self.fc_layer = nn.Linear(hd_sz, n_cls)
def forward(self, ip_seqs, ip_seqs_len, hd=None):
...
pkd = torch.nn.utils.rnn.pack_padded_sequence(nts_enc_ful, ip_seqs_len)
op, hd = self.lstm_layer(pkd, hd)
...
lgts = self.fc_layer(op_nrm_drp.permute(2,0,1))
...
zero_one_lgts = torch.stack((lgts, rev_lgts), dim=3).contiguous()
flt_lgts = zero_one_lgts.view(-1, 2)
return flt_lgts, hd
- 一旦模型架构被定义,我们就可以指定模型训练例程。我们将使用 Adam 优化器并进行梯度裁剪以避免过拟合。作为对抗过拟合的另一个措施,我们已经在先前的步骤中使用了一个 dropout 层:
def lstm_model_training(lstm_model, lr, ep=10, val_loss_best=float("inf")):
...
for curr_ep in range(ep):
...
for batch in training_datasetloader:
...
lgts, _ = lstm_model(ip_seq_b_v, seq_l)
loss = loss_func(lgts, op_seq_b_v)
...
if vl_ep_cur < val_loss_best:
torch.save(lstm_model.state_dict(), 'best_model.pth')
val_loss_best = vl_ep_cur
return val_loss_best, lstm_model
- 同样地,我们将定义模型评估例程,其中模型的前向传播在其参数保持不变的情况下运行:
def evaluate_model(lstm_model):
...
for batch in validation_datasetloader:
...
lgts, _ = lstm_model(ip_seq_b_v, seq_l)
loss = loss_func(lgts, op_seq_b_v)
vl_loss_full += loss.item()
seq_len += sum(seq_l)
return vl_loss_full/(seq_len*88)
现在,让我们来训练和测试音乐生成模型。
训练和测试音乐生成模型
在最后的部分,我们将实际训练 LSTM 模型。然后,我们将使用训练好的音乐生成模型生成一段我们可以听并分析的音乐样本。让我们开始吧:
- 我们已准备好实例化我们的模型并开始训练。对于这个分类任务,我们使用了分类交叉熵作为损失函数。我们将以学习率
0.01
进行10
个 epoch 的模型训练:
loss_func = nn.CrossEntropyLoss().cpu()
lstm_model = MusicLSTM(ip_sz=88, hd_sz=512, n_cls=88).cpu()
val_loss_best, lstm_model = lstm_model_training(lstm_model, lr=0.01, ep=10)
这将输出以下内容:
图 7 . 14 – 音乐 LSTM 训练日志
- 现在是有趣的部分。一旦我们有了一个下一个音符预测器,我们就可以将其用作音乐生成器。我们所需做的就是通过提供一个初始音符作为线索来启动预测过程。模型然后可以在每个时间步骤递归地预测下一个音符,在此过程中,t时间步的预测将附加到t+1时间步的输入序列中。
在这里,我们将编写一个音乐生成函数,该函数接收训练好的模型对象、生成音乐的长度、序列的起始音符和温度作为输入。温度是在分类层上标准的数学操作。它用于操纵 softmax 概率分布,可以通过扩展或缩小 softmax 概率分布来调整 softmax 概率的分布。代码如下:
def generate_music(lstm_model, ln=100, tmp=1, seq_st=None):
...
for i in range(ln):
op, hd = lstm_model(seq_ip_cur, [1], hd)
probs = nn.functional.softmax(op.div(tmp), dim=1)
...
gen_seq = torch.cat(op_seq, dim=0).cpu().numpy()
return gen_seq
最后,我们可以使用此函数来创建全新的音乐作品:
seq = generate_music(lstm_model, ln=100, tmp=1, seq_st=None)
midiwrite('generated_music.mid', seq, dtm=0.2)
这将创建音乐作品并将其保存为当前目录下的 MIDI 文件。我们可以打开文件并播放它以听听模型产生了什么。此外,我们还可以查看所产生音乐的视觉矩阵表示:
io.imshow(seq)
这将给我们以下输出:
图 7 .15 – AI 生成音乐示例的矩阵表示
此外,以下是生成音乐作品作为乐谱的样子:
图 7 . 16 – AI 生成音乐示例的乐谱
在这里,我们可以看到生成的旋律似乎不像莫扎特的原创作品那样悦耳动听。尽管如此,您可以看到模型已学会了一些关键组合的一致性。此外,通过在更多数据上训练模型并增加训练轮次,生成音乐的质量可以得到提升。
这就结束了我们关于使用机器学习生成音乐的练习。在本节中,我们演示了如何使用现有的音乐数据从头开始训练一个音符预测模型,并使用训练好的模型生成音乐。事实上,你可以将生成模型的思路扩展到生成任何类型数据的样本上。PyTorch 在这类用例中非常有效,特别是由于其简单直观的数据加载、模型构建/训练/测试的 API,以及将训练好的模型用作数据生成器的能力。我们鼓励你在不同的用例和数据类型上尝试更多类似的任务。
总结
在本章中,我们探讨了使用 PyTorch 的生成模型。在同样的艺术风格中,在下一章中,我们将学习如何使用机器学习将一幅图像的风格转移到另一幅图像上。有了 PyTorch 的支持,我们将使用 CNN 从各种图像中学习艺术风格,并将这些风格应用于不同的图像上——这一任务更为人熟知的是神经风格转移。
第八章:神经风格转移
加入我们的书籍社区 Discord
packt.link/EarlyAccessCommunity
在前一章中,我们开始探索使用 PyTorch 的生成模型。我们构建了可以在文本和音乐数据上无监督训练的机器学习模型,从而能够生成文本和音乐。在本章中,我们将继续探索生成建模,通过类似的方法应用于图像数据。
我们将混合两幅不同图像A和B的不同方面,生成一幅结果图像C,其中包含图像A的内容和图像B的风格。这项任务也被称为神经风格转移,因为在某种程度上,我们正在将图像B的风格转移到图像A,以实现图像C,如下图所示:
图 8.1 – 神经风格转移示例
首先,我们将简要讨论如何解决这个问题,并理解实现风格转移背后的想法。然后,我们将使用 PyTorch 实现自己的神经风格转移系统,并将其应用于一对图像。通过这个实现练习,我们还将试图理解风格转移机制中不同参数的影响。
到本章末,您将理解神经风格转移背后的概念,并能够使用 PyTorch 构建和测试自己的神经风格转移模型。
本章涵盖以下主题:
-
理解如何在图像之间转移风格
-
使用 PyTorch 实现神经风格转移
理解如何在图像之间转移风格
在第三章,深度 CNN 结构中,我们详细讨论了卷积神经网络(CNNs)。当处理图像数据时,CNNs 是最成功的模型类之一。我们已经看到,基于 CNN 的架构在图像分类、物体检测等任务上是表现最佳的神经网络架构之一。这一成功的核心原因之一是卷积层学习空间表示的能力。
例如,在狗与猫分类器中,CNN 模型基本上能够捕捉图像中的内容在其更高级别的特征中,这帮助它检测狗特有的特征与猫特有的特征。我们将利用图像分类器 CNN 的这种能力来把握图像的内容。
我们知道 VGG 是一个强大的图像分类模型,如第三章,深度 CNN 结构中所述。我们将使用 VGG 模型的卷积部分(不包括线性层)来从图像中提取与内容相关的特征。
我们知道每个卷积层都会生成 N 个尺寸为 XY* 的特征图。例如,假设我们有一个单通道(灰度)输入图像尺寸为(3,3),一个卷积层的输出通道数 (N) 为 3,核大小为(2,2),步幅为(1,1),且没有填充。这个卷积层将产生 3 个尺寸为 2x2 的特征图,因此在这种情况下 X=2,Y=2。
我们可以将卷积层产生的这些 N 个特征图表示为大小为 NM* 的 2D 矩阵,其中 M=XY*。通过定义每个卷积层的输出为 2D 矩阵,我们可以定义一个损失函数,将其附加到每个卷积层上。这个损失函数称为内容损失,是预期输出与卷积层预测输出之间的平方损失,如下图所示,其中 N=3,X=2,Y=2:
图 8. 2 – 内容损失示意图
正如我们所见,输入图像(图像 C,如我们在 图 8. 1 中的标记)在本示例中通过卷积层转换为三个特征图。这三个尺寸为 2x2 的特征图每个都被格式化为一个 3x4 的矩阵。该矩阵与通过相同流程将图像 A(内容图像)通过的预期输出进行比较。然后计算像素逐点的平方和损失,我们称之为内容损失。
现在,为了从图像中提取风格,我们将使用由减少的 2D 矩阵表示的行之间内积得出的格拉姆矩阵 [8.1],如下图所示:
图 8. 3 – 风格损失示意图
在这里,与内容损失计算相比,格拉姆矩阵的计算是唯一的额外步骤。同时,正如我们所见,像素逐点平方和损失的输出数值相比内容损失而言相当大。因此,通过将其除以 NXY,即特征图的数量 (N) 乘以长度 (X) 乘以宽度 (Y),来对这个数值进行标准化。这也有助于在具有不同 N、X 和 Y 的不同卷积层之间标准化风格损失指标。关于实现的详细信息可以在引入神经风格迁移的原始论文 [8.2] 中找到。
现在我们理解了内容和风格损失的概念,让我们来看看神经风格迁移的工作原理,如下所示:
-
对于给定的 VGG(或任何其他 CNN)网络,我们定义网络中哪些卷积层应该附加内容损失。重复此操作以进行风格损失。
-
一旦我们有了这些列表,我们将内容图像通过网络,并计算在应计算内容损失的卷积层处的预期卷积输出(2D 矩阵)。
-
接下来,我们将风格图像通过网络并在卷积层计算预期的格拉姆矩阵。这就是风格损失将被计算的地方,如下图所示。
在下图中,例如,将在第二和第三个卷积层计算内容损失,同时在第二、第三和第五个卷积层计算风格损失:
图 8. 4 – 风格转移架构图示
现在我们在决定的卷积层具有内容和风格目标后,我们准备好生成一幅图像,其中包含内容图像的内容和风格图像的风格。
对于初始化,我们可以使用随机噪声矩阵作为生成图像的起始点,或直接使用内容图像作为起点。我们将此图像通过网络并在预选卷积层计算风格和内容损失。我们将风格损失相加以获得总风格损失,并将内容损失相加以获得总内容损失。最后,通过加权的方式将这两个组件相加,我们获得总损失。
如果我们更注重风格组件,生成的图像将更多地反映其风格,反之亦然。使用梯度下降,我们将损失反向传播到输入,以更新我们生成的图像。几个时期后,生成的图像应该以一种方式演变,以产生最小化相应损失的内容和风格表示,从而产生风格转移的图像。
在前面的图表中,池化层是基于平均池化而不是传统的最大池化。平均池化被有意地用于风格转移,以确保平滑的梯度流。我们希望生成的图像不会在像素之间产生剧烈变化。此外,值得注意的是,前面图表中的网络在计算最后一个风格或内容损失的层结束。因此,在这种情况下,因为原始网络的第六个卷积层没有关联的损失,所以在风格转移的背景下谈论第五个卷积层之后的层是没有意义的。
在接下来的部分中,我们将使用 PyTorch 实现自己的神经风格转移系统。借助预训练的 VGG 模型,我们将使用本节讨论的概念生成艺术风格的图像。我们还将探讨调整各种模型参数对生成图像的内容和纹理/风格的影响。
使用 PyTorch 实现神经风格转移
在讨论了神经风格迁移系统的内部之后,我们已经准备好使用 PyTorch 构建一个系统。作为练习,我们将加载一个风格图像和一个内容图像。然后,我们将加载预训练的 VGG 模型。在定义要计算风格和内容损失的层之后,我们将修剪模型,使其仅保留相关层。最后,我们将训练神经风格迁移模型,逐步改进生成的图像。
加载内容和风格图像
在这个练习中,我们只会展示代码的重要部分以示例。要获取完整的代码,请访问我们的 github 代码库 [8.3] 。请按照以下步骤进行:
- 首先,我们需要导入必要的库:
from PIL import Image
import matplotlib.pyplot as pltimport torch
import torch.nn as nn
import torch.optim as optim
import torchvisiondvc = torch.device("cuda" if torch.cuda.is_available() else "cpu")
除了其他库外,我们导入torchvision
库以加载预训练的 VGG 模型和其他计算机视觉相关的工具。
- 接下来,我们需要一个风格图像和一个内容图像。我们将使用 unsplash 网站 [8.4] 下载这两种图像。这些下载的图像已包含在本书的代码库中。在下面的代码中,我们编写一个函数来将图像加载为张量:
def image_to_tensor(image_filepath, image_dimension=128):
img = Image.open(image_filepath).convert('RGB')
# display image
…
torch_transformation = torchvision.transforms.Compose([
torchvision.transforms.Resize(img_size),
torchvision.transforms.ToTensor()
])
img = torch_transformation(img).unsqueeze(0)
return img.to(dvc, torch.float)
style_image = image_to_tensor("./images/style.jpg")
content_image =image_to_tensor("./images/content.jpg")
输出应如下所示:
图 8. 5 – 风格和内容图像
因此,内容图像是泰姬陵的真实照片,而风格图像是一幅艺术画作。通过风格迁移,我们希望生成一幅艺术性的泰姬陵画作。然而,在此之前,我们需要加载并修剪 VGG19 模型。
加载并修剪预训练的 VGG19 模型
在这部分练习中,我们将使用预训练的 VGG 模型并保留其卷积层。我们将对模型进行一些小的更改,使其适用于神经风格迁移。让我们开始吧:
- 我们将首先加载预训练的 VGG19 模型,并使用其卷积层生成内容和风格目标,从而产生内容和风格损失:
vgg19_model = torchvision.models.vgg19(pretrained=True).to(dvc)
print(vgg19_model)
输出应如下所示:
图 8. 6 – VGG19 模型
- 我们不需要线性层;也就是说,我们只需要模型的卷积部分。在前面的代码中,可以通过仅保留模型对象的
features
属性来实现:
vgg19_model = vgg19_model.features
注意
在这个练习中,我们不会调整 VGG 模型的参数。我们只会调整生成图像的像素,即模型输入端。因此,我们将确保加载的 VGG 模型的参数是固定的。
- 我们必须使用以下代码冻结 VGG 模型的参数:
for param in vgg19_model.parameters():
param.requires_grad_(False)
- 现在我们已经加载了 VGG 模型的相关部分,我们需要将
maxpool
层改为平均池化层,如前面讨论的那样。在此过程中,我们将注意到模型中卷积层的位置:
conv_indices = []for i in range(len(vgg19_model)):
if vgg19_model[i]._get_name() == 'MaxPool2d':
vgg19_model[i] = nn.AvgPool2d(kernel_size=vgg19_model[i].kernel_size,
stride=vgg19_model[i].stride, padding=vgg19_model[i].padding)
if vgg19_model[i]._get_name() == 'Conv2d':
conv_indices.append(i)
conv_indices = dict(enumerate(conv_indices, 1))print(vgg19_model)
输出应如下所示:
图 8. 7 – 修改后的 VGG19 模型
正如我们所看到的,线性层已被移除,并且最大池化层已被替换为平均池化层,如前图中的红色框所示。
在前面的步骤中,我们加载了一个预训练的 VGG 模型,并对其进行了修改,以便将其用作神经风格迁移模型。接下来,我们将把这个修改后的 VGG 模型转换成一个神经风格迁移模型。
构建神经风格迁移模型
此时,我们可以定义希望计算内容和风格损失的卷积层。在原始论文中,风格损失是在前五个卷积层上计算的,而内容损失仅在第四个卷积层上计算。我们将遵循相同的惯例,尽管您可以尝试不同的组合并观察它们对生成图像的影响。请按照以下步骤进行:
- 首先,我们列出我们需要在其上进行风格和内容损失的层:
layers = {1: 's', 2: 's', 3: 's', 4: 'sc', 5: 's'}
在这里,我们定义了第一到第五个卷积层,这些层与风格损失相关联,并且第四个卷积层与内容损失相关联。
- 现在,让我们删除 VGG 模型中不必要的部分。我们将仅保留它到第五个卷积层,如下所示:
vgg_layers = nn.ModuleList(vgg19_model)
last_layer_idx = conv_indices[max(layers.keys())]
vgg_layers_trimmed = vgg_layers[:last_layer_idx+1]
neural_style_transfer_model = nn.Sequential(*vgg_layers_trimmed)
print(neural_style_transfer_model)
这应该给我们以下输出:
图 8. 8 – 神经风格迁移模型对象
正如我们所看到的,我们已经将具有 16 个卷积层的 VGG 模型转换为具有五个卷积层的神经风格迁移模型。
训练风格迁移模型
在本节中,我们将开始处理将生成的图像。我们可以通过多种方式初始化这个图像,例如使用随机噪声图像或使用内容图像作为初始图像。目前,我们将从随机噪声开始。稍后,我们还将看到使用内容图像作为起点对结果的影响。请按照以下步骤进行:
- 下面的代码演示了使用随机数初始化
torch
张量的过程:
# initialize as the content image
# ip_image = content_image.clone()
# initialize as random noise:
ip_image = torch.randn(content_image.data.size(), device=dvc)
plt.figure()
plt.imshow(ip_image.squeeze(0).cpu().detach().numpy().transpose(1,2,0).clip(0,1));
这应该给我们以下输出:
图 8. 9 – 随机噪声图像
- 最后,我们可以开始模型训练循环。首先,我们将定义训练的时代数,为风格和内容损失提供的相对权重,并使用学习率为
0.1
的 Adam 优化器进行基于梯度下降的优化实例化:
num_epochs=180
wt_style=1e6
wt_content=1
style_losses = []
content_losses = []
opt = optim.Adam([ip_image.requires_grad_()], lr=0.1)
- 在开始训练循环时,我们在时代开始时将风格和内容损失初始化为零,然后为了数值稳定性将输入图像的像素值剪切在
0
和1
之间。
for curr_epoch in range(1, num_epochs+1):
ip_image.data.clamp_(0, 1)
opt.zero_grad()
epoch_style_loss = 0
epoch_content_loss = 0
- 在这个阶段,我们已经达到了训练迭代的关键步骤。在这里,我们必须计算每个预定义的风格和内容卷积层的风格和内容损失。将各自层的单独风格损失和内容损失相加,得到当前时代的总风格和内容损失:
for k in layers.keys():
if 'c' in layers[k]:
target = neural_style_transfer_model[:conv_indices[k]+1](content_image).detach()
ip = neural_style_transfer_model[:conv_indices[k]+1](ip_image)
epoch_content_loss += torch.nn.functional.mse_loss(ip, target)
if 's' in layers[k]:
target = gram_matrix(neural_style_transfer_model[:conv_indices[k]+1](style_image)).detach()
ip = gram_matrix(neural_style_transfer_model[:conv_indices[k]+1](ip_image))
epoch_style_loss += torch.nn.functional.mse_loss(ip, target)
正如前面的代码所示,对于风格和内容损失,首先,我们使用风格和内容图像计算风格和内容目标(地面真值)。我们使用.detach()
来表示这些目标不可训练,而只是固定的目标值。接下来,我们根据生成的图像作为输入,在每个风格和内容层计算预测的风格和内容输出。最后,我们计算风格和内容损失。
- 关于风格损失,我们还需要使用预定义的 Gram 矩阵函数来计算 Gram 矩阵,如下面的代码所示:
def gram_matrix(ip):
num_batch, num_channels, height, width = ip.size()
feats = ip.view(num_batch * num_channels, width * height)
gram_mat = torch.mm(feats, feats.t())
return gram_mat.div(num_batch * num_channels * width * height)
正如我们之前提到的,我们可以使用torch.mm
函数计算内部点积。这将计算 Gram 矩阵并通过特征映射数乘以每个特征映射的宽度和高度来归一化矩阵。
- 在我们的训练循环中继续进行,现在我们已经计算出了总风格和内容损失,我们需要计算最终的总损失,作为这两者的加权和,使用我们之前定义的权重:
epoch_style_loss *= wt_style
epoch_content_loss *= wt_content
total_loss = epoch_style_loss + epoch_content_loss
total_loss.backward()
最后,在每k个时代,我们可以通过查看损失以及查看生成的图像来看到我们训练的进展。以下图表显示了前一个代码的生成风格转移图像的演变,总共记录了 180 个时代,每 20 个时代一次:
图 8. 10 – 神经风格转移逐时代生成的图像
很明显,模型开始时将风格从风格图像应用于随机噪声。随着训练的进行,内容损失开始发挥作用,从而为风格化图像赋予内容。到第180个时代,我们可以看到生成的图像,看起来像是塔吉马哈尔的艺术绘画的良好近似。以下图表显示了从0到180个时代随着时代的推移逐渐减少的风格和内容损失:
图 8. 11 – 风格和内容损失曲线
显然,风格损失在最初急剧下降,这也在图 8. 10中有所体现,即初始时期更多地将风格施加在图像上而不是内容。在训练的高级阶段,两种损失逐渐下降,导致风格转移图像,这是风格图像艺术性和以相机拍摄的照片逼真性之间的一个不错的折衷。
对风格转移系统进行实验
在上一节成功训练了样式迁移系统后,我们现在将看看系统如何响应不同的超参数设置。按照以下步骤进行:
- 在前一节中,我们将内容权重设置为
1
,将样式权重设置为1e6
。让我们进一步增加样式权重 10 倍,即到1e7
,并观察它如何影响样式迁移过程。使用新权重进行 600 个时期的训练后,我们得到了以下样式迁移的进展:
图 8. 12 – 高风格权重的样式迁移时期
这里我们可以看到,与之前的情况相比,最初需要更多的时期才能达到合理的结果。更重要的是,较高的样式权重似乎对生成的图像有影响。当我们将前一张图像与图 8. 10中的图像进行比较时,我们发现前者更像图 8. 5中展示的样式图像。
- 同样地,将样式权重从
1e6
减少到1e5
会产生更加注重内容的结果,如下图所示:
图 8. 13 – 低风格权重的样式迁移时期
与较高样式权重的情况相比,降低样式权重意味着需要更少的时期才能得到看起来合理的结果。生成图像中的样式量要小得多,主要填充了内容图像数据。我们仅对此情况进行了 6 个时期的训练,因为在那之后结果就会饱和。
- 最后的改变可能是将生成的图像初始化为内容图像,而不是随机噪声,同时使用原始的样式和内容权重
1e6
和1
。以下图显示了这种情况下的时期逐步进展:
图 8. 14 – 使用内容图像初始化的样式迁移时期
通过比较前一张图与图 8. 10,我们可以看到,将内容图像作为起点确实为我们得到合理的样式迁移图像提供了不同的进展路径。似乎生成图像上同时施加了内容和样式组件,而不像图 8. 10中那样,先施加样式,然后是内容。以下图表证实了这一假设:
图 8. 15 – 使用内容图像初始化的样式和内容损失曲线
正如我们所看到的,随着训练周期的推进,风格损失和内容损失一起减少,最终朝向饱和状态发展。尽管如此,图 8. 10和 8. 14甚至图 8. 12和 8. 13的最终结果都展示了泰姬陵的合理艺术印象。
我们成功地使用 PyTorch 构建了一个神经风格转移模型,在这个模型中,使用了一个内容图像——泰姬陵的照片——和一个风格图像——一幅画布绘画——我们生成了泰姬陵的一个合理的艺术画作近似。这个应用可以扩展到各种其他组合。交换内容和风格图像也可能产生有趣的结果,并更深入地了解模型的内部工作原理。
鼓励您通过以下方式扩展本章中讨论的练习:
-
更改风格和内容层列表
-
使用更大的图像尺寸
-
尝试更多的风格和内容损失权重组合
-
使用其他优化器,如 SGD 和 LBFGS
-
使用不同的学习率进行更长的训练周期,以便观察所有这些方法生成的图像之间的差异
总结
在本章中,我们将生成式机器学习的概念应用于图像,通过生成一幅包含一张图像内容和另一张风格的图像,这被称为神经风格转移的任务。在下一章中,我们将扩展这一范式,我们将拥有一个生成器生成虚假数据,还有一个鉴别器区分虚假数据和真实数据。这样的模型通常被称为生成对抗网络(GANs)。在下一章中,我们将探索深度卷积 GANs(DCGANs)。
第六章:Deep Convolutional GANs
加入我们的书籍社区 Discord
packt.link/EarlyAccessCommunity
生成神经网络已成为一个流行且活跃的研究和开发领域。这种趋势的巨大推动归功于我们将在本章讨论的一类模型。这些模型被称为生成对抗网络(GANs),并于 2014 年提出。自基本 GAN 模型提出以来,各种类型的 GAN 已被发明并被用于不同的应用场景。
本质上,GAN 由两个神经网络组成 - 一个生成器和一个判别器。让我们看一个用于生成图像的 GAN 的示例。对于这样的 GAN,生成器的任务是生成看起来逼真的假图像,而判别器的任务是区分真实图像和假图像。
在联合优化过程中,生成器最终将学会生成如此逼真的假图像,以至于判别器基本无法将其与真实图像区分开来。一旦训练了这样的模型,其生成器部分就可以作为可靠的数据生成器使用。除了用作无监督学习的生成模型外,GAN 在半监督学习中也被证明是有用的。
在图像示例中,例如,判别器模型学习到的特征可以用来提高基于图像数据训练的分类模型的性能。除了半监督学习,GAN 在强化学习中也被证明是有用的,这是我们将在《深度强化学习第十章》中讨论的一个主题。
本章将重点介绍的一种特定类型的 GAN 是深度卷积 GAN(DCGAN)。DCGAN 本质上是一个无监督卷积神经网络(CNN)模型。DCGAN 中的生成器和判别器都是纯粹的CNN,没有全连接层。DCGAN 在生成逼真图像方面表现良好,可以作为学习如何从头开始构建、训练和运行 GAN 的良好起点。
在本章中,我们首先会了解 GAN 内部的各个组件 - 生成器和判别器模型以及联合优化计划。然后,我们将专注于使用 PyTorch 构建 DCGAN 模型。接下来,我们将使用图像数据集来训练和测试 DCGAN 模型的性能。最后,我们将回顾图像的风格转移概念,并探索 Pix2Pix GAN 模型,该模型可以高效地在任意给定的图像对上执行风格转移。
我们还将学习 Pix2Pix GAN 模型的各个组件与 DCGAN 模型的关系。完成本章后,我们将真正理解 GAN 的工作原理,并能够使用 PyTorch 构建任何类型的 GAN 模型。本章分为以下几个主题:
-
定义生成器和鉴别器网络
-
使用 PyTorch 训练 DCGAN
-
使用 GAN 进行风格转移
定义生成器和鉴别器网络
如前所述,GAN 由两个组件组成 – 生成器和鉴别器。这两者本质上都是神经网络。具有不同神经架构的生成器和鉴别器会产生不同类型的 GAN。例如,DCGAN 纯粹将 CNN 作为生成器和鉴别器。您可以在 [9.1] 处找到包含它们 PyTorch 实现的不同类型的 GAN 清单。
对于任何用于生成某种真实数据的 GAN,生成器通常以随机噪声作为输入,并生成与真实数据相同维度的输出。我们称这个生成的输出为假数据。鉴别器则作为二元分类器运作。它接受生成的假数据和真实数据(一个接一个地)作为输入,并预测输入数据是真实的还是假的。图 9 .1 显示了整体 GAN 模型框图:
图 9 .1 – GAN 框图
鉴别器网络像任何二元分类器一样进行优化,即使用二元交叉熵函数。因此,鉴别器模型的目标是正确地将真实图像分类为真实的,将假图像分类为假的。生成器网络具有相反的动机。生成器损失在数学上表示为*-log(D(G(x))),其中x是输入到生成器模型G*中的随机噪声,*G(x)*是生成的假图像,D(G(x))是鉴别器模型D的输出概率,即图像为真实的概率。
因此,当鉴别器认为生成的假图像是真实的时,生成器损失最小化。本质上,在这个联合优化问题中,生成器试图欺骗鉴别器。
在执行过程中,这两个损失函数是交替反向传播的。也就是说,在训练的每个迭代中,首先冻结鉴别器,然后通过反向传播生成器损失的梯度来优化生成器网络的参数。
然后,调整好的生成器被冻结,同时通过反向传播鉴别器损失来优化鉴别器。这就是我们所说的联合优化。在原始 GAN 论文中也被称为等效于双人最小最大游戏 [9.2] 。
理解 DCGAN 生成器和鉴别器
对于特定的 DCGAN 情况,让我们看看生成器和鉴别器模型架构是什么样的。如前所述,它们都是纯卷积模型。图 9 .2 展示了 DCGAN 的生成器模型架构:
图 9 .2 – DCGAN 生成器模型架构
首先,大小为64的随机噪声输入向量被重塑并投影到大小为16x16的128个特征图中。这个投影是通过线性层实现的。然后,一系列上采样和卷积层接连而来。第一个上采样层简单地使用最近邻上采样策略将16x16特征图转换为32x32特征图。
然后是一个 2D 卷积层,卷积核大小为3x3,输出128个特征图。这个卷积层输出的128个32x32特征图进一步上采样为64x64大小的特征图,然后是两个 2D 卷积层,生成(伪造的)RGB 图像大小为64x64。
注意
我们省略了批量归一化和泄漏 ReLU 层,以避免在前面的架构表示中混乱。下一节的 PyTorch 代码将详细说明和解释这些细节。
现在我们知道生成器模型的样子,让我们看看鉴别器模型的样子。图 9.3 展示了鉴别器模型的架构:
图 9 .3 – DCGAN 鉴别器模型架构
正如您所见,在这个架构中,每个卷积层的步幅设置为2有助于减少空间维度,而深度(即特征图的数量)不断增加。这是一种经典的基于 CNN 的二进制分类架构,用于区分真实图像和生成的伪造图像。
理解了生成器和鉴别器网络的架构之后,我们现在可以根据图 9.1 的示意图构建整个 DCGAN 模型,并在图像数据集上训练 DCGAN 模型。
在接下来的部分,我们将使用 PyTorch 完成这个任务。我们将详细讨论 DCGAN 模型的实例化,加载图像数据集,联合训练 DCGAN 生成器和鉴别器,并从训练后的 DCGAN 生成器生成样本假图像。
使用 PyTorch 训练 DCGAN
在本节中,我们将通过一个练习构建、训练和测试一个 PyTorch 中的 DCGAN 模型。我们将使用一个图像数据集来训练模型,并测试训练后的 DCGAN 模型的生成器在生成伪造图像时的性能。
定义生成器
在下一个练习中,我们只展示代码的重要部分以进行演示。要访问完整的代码,您可以参考我们的 github 仓库 [9.3] :
- 首先,我们需要
import
所需的库,如下所示:
import os
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torch.autograd import Variable
import torchvision.transforms as transforms
from torchvision.utils import save_image
from torchvision import datasets
在这个练习中,我们只需要torch
和torchvision
来构建 DCGAN 模型。
- 导入库后,我们指定了一些模型超参数,如下所示的代码:
num_eps=10
bsize=32
lrate=0.001
lat_dimension=64
image_sz=64
chnls=1
logging_intv=200
我们将使用批大小为32
和学习率为0.001
来训练模型10
个时期。预期的图像大小为64x64x3。lat_dimension
是随机噪声向量的长度,这意味着我们将从一个64维的潜在空间中提取随机噪声作为生成模型的输入。
- 现在我们定义生成器模型对象。以下代码与图 9 .2中显示的架构直接一致:
class GANGenerator(nn.Module):
def __init__(self):
super(GANGenerator, self).__init__()
self.inp_sz = image_sz // 4
self.lin = nn.Sequential(nn.Linear(lat_dimension, 128 * self.inp_sz ** 2))
self.bn1 = nn.BatchNorm2d(128)
self.up1 = nn.Upsample(scale_factor=2)
self.cn1 = nn.Conv2d(128, 128, 3, stride=1, padding=1)
self.bn2 = nn.BatchNorm2d(128, 0.8)
self.rl1 = nn.LeakyReLU(0.2, inplace=True)
self.up2 = nn.Upsample(scale_factor=2)
self.cn2 = nn.Conv2d(128, 64, 3, stride=1, padding=1)
self.bn3 = nn.BatchNorm2d(64, 0.8)
self.rl2 = nn.LeakyReLU(0.2, inplace=True)
self.cn3 = nn.Conv2d(64, chnls, 3, stride=1, padding=1)
self.act = nn.Tanh()
- 在定义
_init_
方法之后,我们定义了forward
方法,本质上只是按顺序调用层:
def forward(self, x):
x = self.lin(x)
x = x.view(x.shape[0], 128, self.inp_sz, self.inp_sz)
x = self.bn1(x)
x = self.up1(x)
x = self.cn1(x)
x = self.bn2(x)
x = self.rl1(x)
x = self.up2(x)
x = self.cn2(x)
x = self.bn3(x)
x = self.rl2(x)
x = self.cn3(x)
out = self.act(x)
return out
在这个练习中,我们使用了逐层显式定义而不是 nn.Sequential
方法;这是因为如果模型出现问题,逐层定义可以更容易调试。我们还可以看到代码中的批量归一化和泄漏的 ReLU 层,这些在图 9 .2中没有提到。
FAQ - 为什么我们使用批量归一化?
批量归一化用于在线性或卷积层之后,既加快训练过程,又减少对初始网络权重的敏感性。
FAQ - 为什么我们使用泄漏的 ReLU?
ReLU 可能会对带有负值输入的信息丢失。设置斜率为 0.2 的泄漏 ReLU 可以给入射的负信息赋予 20% 的权重,这有助于我们在训练 GAN 模型时避免梯度消失。
接下来,我们将查看 PyTorch 代码来定义判别器网络。
定义判别器
类似生成器,我们现在将定义判别器模型如下:
- 再次强调,以下代码是 PyTorch 中显示的图 9 .3模型架构的等效代码:
class GANDiscriminator(nn.Module):
def __init__(self):
super(GANDiscriminator, self).__init__()
def disc_module(ip_chnls, op_chnls, bnorm=True):
mod = [nn.Conv2d(ip_chnls, op_chnls, 3, 2, 1), nn.LeakyReLU(0.2, inplace=True),
nn.Dropout2d(0.25)] if bnorm:
mod += [nn.BatchNorm2d(op_chnls, 0.8)]
return mod
self.disc_model = nn.Sequential(
*disc_module(chnls, 16, bnorm=False),
*disc_module(16, 32),
*disc_module(32, 64),
*disc_module(64, 128),
)
# width and height of the down-sized image
ds_size = image_sz // 2 ** 4
self.adverse_lyr = nn.Sequential(nn.Linear(128 * ds_size ** 2, 1), nn.Sigmoid())
首先,我们定义了一个通用的判别器模块,它是一个级联的卷积层、可选的批量归一化层、泄漏的 ReLU 层和一个 dropout 层。为了构建判别器模型,我们依次重复这个模块四次——每次使用不同的卷积层参数集。
目标是输入一张 64x64x3 的 RGB 图像,并通过卷积层增加深度(即通道数),同时减少图像的高度和宽度。
最终的判别器模块的输出被展平,并通过对抗层传递。实质上,对抗层将展平的表示完全连接到最终模型输出(即二进制输出)。然后,将该模型输出通过 Sigmoid 激活函数传递,以给出图像为真实图像的概率(或非假图像)。
- 下面是判别器的
forward
方法,它将一张 64x64 的 RGB 图像作为输入,并产生它是真实图像的概率:
def forward(self, x):
x = self.disc_model(x)
x = x.view(x.shape[0], -1)
out = self.adverse_lyr(x)
return out
- 定义了生成器和判别器模型之后,我们现在可以实例化每个模型。我们还将对抗损失函数定义为以下代码中的二元交叉熵损失函数:
# instantiate the discriminator and generator models
gen = GANGenerator()
disc = GANDiscriminator()
# define the loss metric
adv_loss_func = torch.nn.BCELoss()
对抗损失函数将用于定义后续训练循环中生成器和鉴别器损失函数。从概念上讲,我们使用二元交叉熵作为损失函数,因为目标基本上是二进制的——即真实图像或假图像。而二元交叉熵损失是二元分类任务的合适损失函数。
加载图像数据集
对于训练 DCGAN 以生成看起来逼真的假图像的任务,我们将使用著名的 MNIST
数据集,该数据集包含从 0 到 9 的手写数字图像。通过使用 torchvision.datasets
,我们可以直接下载 MNIST
数据集,并创建一个 dataset
和一个 dataloader
实例:
# define the dataset and corresponding dataloader
dloader = torch.utils.data.DataLoader(
datasets.MNIST(
"./data/mnist/", download=True,
transform=transforms.Compose(
[transforms.Resize((image_sz, image_sz)),
transforms.ToTensor(), transforms.Normalize([0.5], [0.5])]),), batch_size=bsize, shuffle=True,)
这里是 MNIST 数据集中真实图像的示例:
图 9. 4 – MNIST 数据集中的真实图像
数据集引用
[LeCun et al., 1998a] Y. LeCun, L. Bottou, Y. Bengio, and P. Haffner. “基于梯度的学习应用于文档识别.” 《IEEE 会议录》,86(11):2278-2324,1999 年 11 月。
Yann LeCun(纽约大学库兰特研究所)和 Corinna Cortes(Google 实验室,纽约)拥有 MNIST 数据集的版权,这是原始 NIST 数据集的一个衍生作品。MNIST 数据集根据知识共享署名-相同方式共享 3.0 许可证的条款提供。
到目前为止,我们已经定义了模型架构和数据管道。现在是时候实际编写 DCGAN 模型训练程序了,我们将在下一节中进行。
DCGAN 的训练循环
在本节中,我们将训练 DCGAN 模型:
- 定义优化计划:在开始训练循环之前,我们将为生成器和鉴别器定义优化计划。我们将使用
Adam
优化器来优化我们的模型。在原始 DCGAN 论文 [9. 4] 中,Adam 优化器的 beta1 和 beta2 参数设置为 0.5 和 0.999,而不是通常的 0.9 和 0.999。
在我们的练习中,我们保留了 0.9 和 0.999 的默认值。但是,您可以使用论文中提到的确切数值以获得类似的结果:
# define the optimization schedule for both G and D
opt_gen = torch.optim.Adam(gen.parameters(), lr=lrate)
opt_disc = torch.optim.Adam(disc.parameters(), lr=lrate)
- 训练生成器:最后,我们现在可以运行训练循环来训练 DCGAN。由于我们将联合训练生成器和鉴别器,训练过程将包括以下两个步骤——训练生成器模型和训练鉴别器模型——交替进行。我们将从训练生成器开始,如下代码所示:
os.makedirs("./images_mnist", exist_ok=True)
for ep in range(num_eps):
for idx, (images, _) in enumerate(dloader):
# generate ground truths for real and fake images
good_img = Variable(torch.FloatTensor(images.shape[0], 1).fill_(1.0), requires_grad=False)
bad_img = Variable(torch.FloatTensor(images.shape[0], 1) .fill_(0.0), requires_grad=False)
# get a real image
actual_images = Variable(images.type(torch.FloatTensor))
# train the generator model
opt_gen.zero_grad()
# generate a batch of images based on random noise as input
noise = Variable(torch.FloatTensor(np.random.normal(0, 1, (images.shape[0], lat_dimension))))
gen_images = gen(noise)
# generator model optimization - how well can it fool the discriminator
generator_loss = adv_loss_func(disc(gen_images), good_img)
generator_loss.backward()
opt_gen.step()
在上述代码中,我们首先生成了真实和假图像的地面真值标签。真实图像标记为 1
,假图像标记为 0
。这些标签将作为鉴别器模型的目标输出,该模型是一个二元分类器。
接下来,我们从 MINST 数据集加载器中加载一批真实图像,并使用生成器生成一批使用随机噪声作为输入的假图像。
最后,我们将生成器的损失定义为以下两者之间的对抗损失:
i) 生成器模型生成的假图像被鉴别器模型预测为真实的概率。
ii) 目标值为1
。
基本上,如果鉴别器被愚弄成将生成的假图像视为真实图像,那么生成器在其角色上就成功了,生成器损失将很低。一旦我们制定了生成器损失,我们就可以使用它来沿着生成器模型反向传播梯度,以调整其参数。
在生成器模型的前述优化步骤中,我们保持鉴别器模型参数不变,并且仅仅使用鉴别器模型进行一次前向传播。
- 训练鉴别器:接下来,我们将执行相反操作,即保留生成器模型的参数并训练鉴别器模型:
# train the discriminator model
opt_disc.zero_grad()
# calculate discriminator loss as average of mistakes(losses) in confusing real images as fake and vice versa
actual_image_loss = adv_loss_func(disc(actual_images), good_img)
fake_image_loss = adv_loss_func(disc(gen_images.detach()), bad_img)
discriminator_loss = (actual_image_loss + fake_image_loss) / 2
# discriminator model optimization
discriminator_loss.backward()
opt_disc.step()
batches_completed = ep * len(dloader) + idx
if batches_completed % logging_intv == 0:
print(f"epoch number {ep} | batch number {idx} | generator loss = {generator_loss.item()} \
| discriminator loss = {discriminator_loss.item()}")
save_image(gen_images.data[:25], f"images_mnist/{batches_completed}.png", nrow=5, normalize=True)
请记住我们有一批真实和假图像。为了训练鉴别器模型,我们将需要这两者。我们简单地将鉴别器损失定义为对抗损失或二元交叉熵损失,就像我们对任何二元分类器一样。
我们计算真实图像和假图像批次的鉴别器损失,保持真实图像批次的目标值为1
,假图像批次的目标值为0
。然后我们使用这两个损失的均值作为最终的鉴别器损失,并用它来反向传播梯度以调整鉴别器模型参数。
每隔几个 epoch 和批次后,我们记录模型的性能结果,即生成器损失和鉴别器损失。对于前述代码,我们应该得到类似以下的输出:
图 9. 5 – DCGAN 训练日志
注意损失如何波动一点;这通常在训练 GAN 模型期间由于联合训练机制的对抗性质而发生。除了输出日志外,我们还定期保存一些网络生成的图像。图 9. 6 展示了这些生成图像在前几个 epoch 中的进展:
图 9. 6 – DCGAN 逐 epoch 生成图像
如果将后续 epoch 的结果与图 9. 4 中的原始 MNIST 图像进行比较,可以看出 DCGAN 已经相当好地学会如何生成看起来逼真的手写数字的假图像。
就是这样了。我们已经学会了如何使用 PyTorch 从头开始构建 DCGAN 模型。原始 DCGAN 论文中有一些微妙的细节,比如生成器和鉴别器模型的层参数的正常初始化,使用 Adam 优化器的特定 beta1 和 beta2 值,等等。出于关注 GAN 代码主要部分的兴趣,我们省略了其中一些细节。鼓励你包含这些细节并查看其如何改变结果。
此外,我们在练习中只使用了 MNIST 数据库。然而,我们可以使用任何图像数据集来训练 DCGAN 模型。鼓励你尝试在其他图像数据集上使用这个模型。用于 DCGAN 训练的一种流行的图像数据集是名人面孔数据集 [9. 5] 。
使用这种模型训练的 DCGAN 可以生成不存在的名人脸部。ThisPersonDoesntExist [9. 6] 就是这样一个项目,它生成了不存在的人类面孔。鬼魅吗?是的。这就是 DCGAN 和总体上的 GAN 的强大之处。还要感谢 PyTorch,现在我们可以用几行代码构建自己的 GAN。
在本章的下一节,我们将超越 DCGAN,简要介绍另一种 GAN 类型——pix2pix 模型。pix2pix 模型可以用于在图像中推广风格转移的任务,更一般地说,是图像到图像的翻译任务。我们将讨论 pix2pix 模型的架构、其生成器和鉴别器,并使用 PyTorch 定义生成器和鉴别器模型。我们还将比较 Pix2Pix 与 DCGAN 在架构和实现方面的不同。
使用 GAN 进行风格转移
到目前为止,我们只详细讨论了 DCGAN。虽然已经存在数百种不同类型的 GAN 模型,并且还有更多正在开发中,但一些著名的 GAN 模型包括以下几种:
-
GAN
-
DCGAN
-
Pix2Pix
-
CycleGAN
-
超分辨率 GAN(SRGAN)
-
上下文编码器
-
文本到图像
-
最小二乘 GAN(LSGAN)
-
SoftmaxGAN
-
Wasserstein GAN
每种 GAN 变体的不同之处在于它们服务的应用程序,它们的基础模型架构,或者由于一些优化策略的调整,例如修改损失函数。例如,SRGAN 用于增强低分辨率图像的分辨率。CycleGAN 使用两个生成器而不是一个,并且生成器由类似 ResNet 的块组成。LSGAN 使用均方误差作为鉴别器损失函数,而不是大多数 GAN 中使用的交叉熵损失。
不可能在一章甚至一本书中讨论所有这些 GAN 变体。然而,在本节中,我们将探索另一种与前一节讨论的 DCGAN 模型和第八章“神经风格转移”中讨论的神经风格转移模型相关的 GAN 模型。
这种特殊类型的 GAN 推广了图像之间的风格转移任务,并提供了一个通用的图像到图像的翻译框架。它被称为Pix2Pix,我们将简要探讨其架构以及其生成器和鉴别器组件的 PyTorch 实现。
理解 pix2pix 的架构
在第八章,神经风格转移中,您可能会记得一个完全训练好的神经风格转移模型只能在给定的一对图像上工作。Pix2Pix 是一个更通用的模型,一旦成功训练,可以在任意一对图像之间进行风格转移。事实上,该模型不仅限于风格转移,还可以用于任何图像到图像的翻译应用,如背景掩蔽、调色板补充等。
实质上,Pix2Pix 的工作原理与任何 GAN 模型相似。涉及到一个生成器和一个鉴别器。与接收随机噪声并生成图像不同的是,如图 9. 1所示,pix2pix
模型中的生成器接收真实图像作为输入,并尝试生成该图像的翻译版本。如果任务是风格转移,那么生成器将尝试生成风格转移后的图像。
随后,鉴别器现在查看一对图像而不是仅仅单个图像,就像图 9. 1中的情况一样。真实图像及其等效的翻译图像被作为输入馈送到鉴别器。如果翻译图像是真实的,那么鉴别器应该输出1,如果翻译图像是由生成器生成的,则鉴别器应该输出0。图 9. 7显示了pix2pix
模型的示意图:
图 9. 7 – Pix2Pix 模型示意图
图 9. 7显示与图 9. 1有显著相似之处,这意味着其基本思想与常规 GAN 相同。唯一的区别在于,鉴别器的真假问题是针对一对图像而不是单个图像提出的。
探索 Pix2Pix 生成器
在pix2pix
模型中使用的生成器子模型是用于图像分割的著名 CNN——UNet。图 9. 8展示了 UNet 的架构,它被用作pix2pix
模型的生成器:
图 9. 8 – Pix2Pix 生成器模型架构
首先,UNet 的名称来源于网络的U形状,正如前面的图表所显示的。该网络有两个主要组成部分,如下所示:
-
从左上角到底部是网络的编码器部分,它将256x256的 RGB 输入图像编码成大小为512的特征向量。
-
从右上角到底部是网络的解码器部分,它从大小为512的嵌入向量生成图像。
UNet 的一个关键特性是跳跃连接,即来自编码器部分到解码器部分的特征串联(沿深度维度),如 图 9.8 中的虚线箭头所示。
FAQ - 为什么 U-Net 中有编码器-解码器跳跃连接?
使用编码器部分的特征帮助解码器在每个上采样步骤中更好地定位高分辨率信息。
本质上,编码器部分是一系列下卷积块,其中每个下卷积块本身是一系列 2D 卷积层、实例归一化层和渗漏的 ReLU 激活。类似地,解码器部分包括一系列上卷积块,其中每个块是一系列 2D 转置卷积层、实例归一化层和 ReLU 激活层。
这个 UNet 生成器架构的最后部分是一个基于最近邻的上采样层,随后是一个 2D 卷积层,最后是一个 tanh
激活。现在让我们来看看 UNet 生成器的 PyTorch 代码:
- 这是定义基于 UNet 的生成器架构的等效 PyTorch 代码:
class UNetGenerator(nn.Module):
def __init__(self, chnls_in=3, chnls_op=3):
super(UNetGenerator, self).__init__()
self.down_conv_layer_1 = DownConvBlock(chnls_in, 64, norm=False)
self.down_conv_layer_2 = DownConvBlock(64, 128)
self.down_conv_layer_3 = DownConvBlock(128, 256)
self.down_conv_layer_4 = DownConvBlock(256, 512, dropout=0.5)
self.down_conv_layer_5 = DownConvBlock(512, 512, dropout=0.5)
self.down_conv_layer_6 = DownConvBlock(512, 512, dropout=0.5)
self.down_conv_layer_7 = DownConvBlock(512, 512, dropout=0.5)
self.down_conv_layer_8 = DownConvBlock(512, 512, norm=False, dropout=0.5)
self.up_conv_layer_1 = UpConvBlock(512, 512, dropout=0.5)
self.up_conv_layer_2 = UpConvBlock(1024, 512, dropout=0.5)
self.up_conv_layer_3 = UpConvBlock(1024, 512, dropout=0.5)
self.up_conv_layer_4 = UpConvBlock(1024, 512, dropout=0.5)
self.up_conv_layer_5 = UpConvBlock(1024, 256)
self.up_conv_layer_6 = UpConvBlock(512, 128)
self.up_conv_layer_7 = UpConvBlock(256, 64)
self.upsample_layer = nn.Upsample(scale_factor=2)
self.zero_pad = nn.ZeroPad2d((1, 0, 1, 0))
self.conv_layer_1 = nn.Conv2d(128, chnls_op, 4, padding=1)
self.activation = nn.Tanh()
正如您所看到的,有 8 个下卷积层和 7 个上卷积层。上卷积层有两个输入,一个来自前一个上卷积层的输出,另一个来自等效的下卷积层的输出,如 图 9. 7 中所示的虚线所示。
- 我们使用了
UpConvBlock
和DownConvBlock
类来定义 UNet 模型的层。以下是这些块的定义,从UpConvBlock
类开始:
class UpConvBlock(nn.Module):
def __init__(self, ip_sz, op_sz, dropout=0.0):
super(UpConvBlock, self).__init__()
self.layers = [
nn.ConvTranspose2d(ip_sz, op_sz, 4, 2, 1),
nn.InstanceNorm2d(op_sz), nn.ReLU(),]
if dropout:
self.layers += [nn.Dropout(dropout)]
def forward(self, x, enc_ip):
x = nn.Sequential(*(self.layers))(x)
op = torch.cat((x, enc_ip), 1)
return op
在这个上卷积块中的转置卷积层由一个步幅为 2
步的 4x4 核组成,这在输出空间维度上将其输出与输入相比几乎增加了一倍。
在这个转置卷积层中,4x4 核通过输入图像的每隔一个像素(由于步幅为 2
)传递。在每个像素处,像素值与 4x4 核中的每个 16 个值相乘。
在整个图像中,核乘法结果的重叠值然后相加,导致输出长度和宽度是输入图像的两倍。此外,在前述的 forward
方法中,拼接操作是在通过上卷积块的前向传递完成之后执行的。
- 接下来,这里是定义
DownConvBlock
类的 PyTorch 代码:
class DownConvBlock(nn.Module):
def __init__(self, ip_sz, op_sz, norm=True, dropout=0.0):
super(DownConvBlock, self).__init__()
self.layers = [nn.Conv2d(ip_sz, op_sz, 4, 2, 1)]
if norm:
self.layers.append(nn.InstanceNorm2d(op_sz))
self.layers += [nn.LeakyReLU(0.2)]
if dropout:
self.layers += [nn.Dropout(dropout)]
def forward(self, x):
op = nn.Sequential(*(self.layers))(x)
return op
下卷积块内的卷积层具有 4x4 大小的核,步幅为 2
,并且激活了填充。因为步幅值为 2
,所以此层的输出是其输入的空间尺寸的一半。
为了处理类似 DCGANs 中的负输入问题,还使用了一个渗漏的 ReLU 激活,这也有助于缓解消失梯度问题。
到目前为止,我们已经看到了基于 UNet 的生成器的 __init__
方法。接下来的 forward
方法非常简单:
def forward(self, x):
enc1 = self.down_conv_layer_1(x)
enc2 = self.down_conv_layer_2(enc1)
enc3 = self.down_conv_layer_3(enc2)
enc4 = self.down_conv_layer_4(enc3)
enc5 = self.down_conv_layer_5(enc4)
enc6 = self.down_conv_layer_6(enc5)
enc7 = self.down_conv_layer_7(enc6)
enc8 = self.down_conv_layer_8(enc7)
dec1 = self.up_conv_layer_1(enc8, enc7)
dec2 = self.up_conv_layer_2(dec1, enc6)
dec3 = self.up_conv_layer_3(dec2, enc5)
dec4 = self.up_conv_layer_4(dec3, enc4)
dec5 = self.up_conv_layer_5(dec4, enc3)
dec6 = self.up_conv_layer_6(dec5, enc2)
dec7 = self.up_conv_layer_7(dec6, enc1)
final = self.upsample_layer(dec7)
final = self.zero_pad(final)
final = self.conv_layer_1(final)
return self.activation(final)
在讨论了 pix2pix
模型的生成器部分之后,让我们也来看看判别器模型。
探索 Pix2Pix 判别器
在这种情况下,判别器模型也是一个二元分类器,就像 DCGAN 一样。唯一的区别是,这个二元分类器接受两个图像作为输入。两个输入沿深度维度连接。图 9. 9 展示了判别器模型的高级架构:
图 9. 9 – Pix2Pix 判别器模型架构
这是一个 CNN 模型,最后的 3 个卷积层后跟一个归一化层以及一个泄漏 ReLU 激活函数。定义这个判别器模型的 PyTorch 代码如下:
class Pix2PixDiscriminator(nn.Module):
def __init__(self, chnls_in=3):
super(Pix2PixDiscriminator, self).__init__()
def disc_conv_block(chnls_in, chnls_op, norm=1):
layers = [nn.Conv2d(chnls_in, chnls_op, 4, stride=2, padding=1)]
if normalization:
layers.append(nn.InstanceNorm2d(chnls_op))
layers.append(nn.LeakyReLU(0.2, inplace=True))
return layers
self.lyr1 = disc_conv_block(chnls_in * 2, 64, norm=0)
self.lyr2 = disc_conv_block(64, 128)
self.lyr3 = disc_conv_block(128, 256)
self.lyr4 = disc_conv_block(256, 512)
正如您所见,第 4
个卷积层在每个步骤后会加倍空间表示的深度。第 2
、3
和 4
层在卷积层后添加了归一化层,并且每个卷积块的末尾应用了泄漏 ReLU 激活,负斜率为 20%。最后,这是判别器模型在 PyTorch 中的 forward
方法:
def forward(self, real_image, translated_image):
ip = torch.cat((real_image, translated_image), 1)
op = self.lyr1(ip)
op = self.lyr2(op)
op = self.lyr3(op)
op = self.lyr4(op)
op = nn.ZeroPad2d((1, 0, 1, 0))(op)
op = nn.Conv2d(512, 1, 4, padding=1)(op)
return op
首先,输入图像被连接并通过四个卷积块传递,最终进入一个单一的二进制输出,告诉我们图像对的真假概率(即由生成器模型生成的)。这样,在运行时训练 pix2pix
模型,使得生成器可以接受任何图像作为输入,并应用其在训练期间学到的图像翻译函数。
如果 pix2pix
模型生成的伪翻译图像很难与原始图像的真实翻译版本区分开来,则认为 pix2pix
模型是成功的。
这结束了我们对 pix2pix
模型的探索。原则上,Pix2Pix 的整体模型框图与 DCGAN 模型非常相似。这两个模型的判别器网络都是基于 CNN 的二元分类器。而 pix2pix
模型的生成器网络则是受 UNet 图像分割模型启发的稍微复杂一些的架构。
总的来说,我们已经成功地使用 PyTorch 定义了 DCGAN 和 Pix2Pix 的生成器和判别器模型,并理解了这两个 GAN 变体的内部工作原理。
完成本节后,您应该能够开始编写其他许多 GAN 变体的 PyTorch 代码。使用 PyTorch 构建和训练各种 GAN 模型可以是一次很好的学习经验,而且肯定是一个有趣的练习。我们鼓励您使用本章的信息来开展自己的 GAN 项目。
总结
近年来,生成对抗网络(GANs)已成为研究和开发的活跃领域,自其 2014 年问世以来如此。本章探讨了 GANs 背后的概念,包括 GANs 的组成部分,即生成器和鉴别器。我们讨论了每个组件的架构以及 GAN 模型的整体框图。
在下一章中,我们将进一步探索生成模型的研究。我们将探讨如何使用尖端深度学习技术从文本生成图像。
第十一章:深度强化学习
在我们的书籍社区 Discord 上加入我们
packt.link/EarlyAccessCommunity
机器学习通常分为三种不同的范式:监督学习、无监督学习和强化学习(RL)。监督学习需要标记数据,迄今为止一直是最受欢迎的机器学习范式。然而,基于无监督学习的应用,即不需要标签的学习,近年来稳步增长,尤其是生成模型的形式。
另一方面,RL 是机器学习的一个不同分支,被认为是我们迄今为止模拟人类学习方式最接近的分支。这是一个积极研究和开发的领域,处于早期阶段,已取得一些有 promising 的结果。一个著名的例子是由 Google 的 DeepMind 构建的 AlphaGo 模型,它击败了世界顶级围棋选手。
在监督学习中,我们通常向模型提供原子输入输出数据对,并希望模型学习将输出作为输入的函数。而在 RL 中,我们不关心学习这种单个输入到单个输出的函数。相反,我们感兴趣的是学习一种策略(或政策),使我们能够从输入(状态)开始采取一系列步骤(或行动),以获取最终输出或实现最终目标。
查看照片并决定它是猫还是狗是一个原子输入输出学习任务,可以通过监督学习解决。然而,查看棋盘并决定下一步如何走以达到赢得比赛的目标则需要策略,我们需要 RL 来处理这类复杂任务。
在前几章中,我们遇到了监督学习的例子,比如使用 MNIST 数据集构建分类器对手写数字进行分类。我们还在构建文本生成模型时探索了无监督学习,使用了一个无标签的文本语料库。
在本章中,我们将揭示 RL 和**深度强化学习(DRL)**的一些基本概念。然后,我们将专注于一种特定且流行的 DRL 模型 - **深度 Q-learning 网络(DQN)**模型。使用 PyTorch,我们将构建一个 DRL 应用程序。我们将训练一个 DQN 模型来学习如何与计算机对手(bot)玩乒乓球游戏。
在本章结束时,您将具备开始在 PyTorch 上进行自己的 DRL 项目所需的所有背景知识。此外,您还将亲自体验在真实问题上构建 DQN 模型的经验。本章中您将获得的技能对处理其他 RL 问题也将非常有用。
本章内容分为以下几个主题:
-
回顾强化学习概念
-
讨论 Q-learning
-
理解深度 Q-learning
-
在 PyTorch 中构建 DQN 模型
回顾强化学习概念
在某种意义上,RL 可以被定义为从错误中学习。与监督学习中每个数据实例都得到反馈的情况不同,RL 在一系列行动之后接收反馈。以下图表显示了 RL 系统的高级示意图:
图 11. 1 – 强化学习示意图
在 RL 设置中,通常有一个代理进行学习。代理学习如何根据这些决策做出决定并采取行动。代理操作在一个提供的环境中。这个环境可以被视为一个有限的世界,在这个世界中,代理生活、采取行动并从其行动中学习。在这里,行动就是代理基于其所学内容做出决策的实施。
我们前面提到,与监督学习不同,RL 对于每个输入并不都有一个输出;也就是说,代理不一定会为每个动作都接收到反馈。相反,代理在状态中工作。假设它从初始状态S0 开始。然后它执行一个动作,比如a0。这个动作将代理的状态从S0 转换到S1,之后代理执行另一个动作a1,循环进行。
偶尔,代理根据其状态接收奖励。代理遍历的状态和行动序列也称为轨迹。假设代理在状态S2 收到奖励。在这种情况下,导致该奖励的轨迹将是S0, a0, S1, a1, S2。
注
奖励可以是正的也可以是负的。
基于奖励,代理学习调整其行为,以便以最大化长期奖励的方式采取行动。这就是 RL 的本质。代理根据给定的状态和奖励学习一种如何行动最优化的策略。
这种学习到的策略,基本上是行动作为状态和奖励的函数表达,被称为代理的策略。RL 的最终目标是计算一个策略,使代理能够始终从其所处的情况中获得最大奖励。
视频游戏是展示 RL 的最佳例子之一。让我们以视频游戏《乒乓球》的虚拟版本——Pong 为例。以下是该游戏的快照:
图 11. 2 – 乒乓视频游戏
考虑右侧的玩家是代理,用一个短竖线表示。请注意,这里有一个明确定义的环境。环境包括玩区,用棕色像素表示。环境还包括一个球,用白色像素表示。除此之外,环境还包括玩区边界,用灰色条纹和球可能反弹的边缘表示。最后,而且最重要的是,环境包括一个对手,看起来像代理,但位于左侧,与代理相对。
通常,在强化学习设置中,代理在任何给定状态下有一组有限的可能动作,称为离散动作空间(与连续动作空间相对)。在这个例子中,代理在所有状态下有两种可能的动作 - 向上移动或向下移动,但有两个例外。首先,它只能在处于最上位置(状态)时向下移动,其次,它只能在处于最下位置(状态)时向上移动。
在这种情况下,奖励的概念可以直接映射到实际乒乓球比赛中发生的情况。如果你未能击中球,你的对手会得分。首先得到 21 分的人赢得比赛并获得正奖励。输掉比赛意味着负奖励。得分或失分也会导致较小的中间正奖励和负奖励。从 0-0 的得分到任一玩家得分 21 分的玩法序列被称为一个episode。
使用强化学习训练我们的代理玩乒乓球游戏相当于从头开始训练某人打乒乓球。训练会产生一个政策,代理在游戏中遵循这个政策。在任何给定的情况下 - 包括球的位置、对手的位置、记分牌以及先前的奖励 - 训练良好的代理会向上或向下移动,以最大化其赢得比赛的机会。
到目前为止,我们已通过提供一个例子来讨论强化学习背后的基本概念。在这样做的过程中,我们反复提到了策略、政策和学习等术语。但是代理实际上是如何学习策略的呢?答案是通过一个基于预定义算法的强化学习模型。接下来,我们将探讨不同类型的强化学习算法。
强化学习算法的类型
在本节中,我们将按照文献中的分类来查看强化学习算法的类型。然后我们将探索这些类型中的一些子类型。广义上讲,强化学习算法可以分为以下两种类型之一:
-
基于模型
-
无模型
让我们逐一看看这些。
基于模型
正如名称所示,在基于模型的算法中,智能体了解环境的模型。这里的模型指的是一个数学公式,可用于估计奖励以及环境中状态的转移方式。因为智能体对环境有一定的了解,这有助于减少选择下一步行动的样本空间。这有助于学习过程的效率。
然而,在现实中,建模环境大多数情况下并不直接可用。尽管如此,如果我们想使用基于模型的方法,我们需要让智能体通过自身经验学习环境模型。在这种情况下,智能体很可能会学习到模型的偏见表达,并在真实环境中表现不佳。因此,基于模型的方法在实施强化学习系统时使用较少。在本书中,我们将不详细讨论基于这种方法的模型,但这里有一些示例:
-
基于模型的深度强化学习与无模型微调(MBMF)。
-
基于模型的价值估计(MBVE)用于高效的无模型强化学习。
-
想象增强智能体(I2A)用于深度强化学习。
-
AlphaZero,这位著名的 AI 机器人击败了国际象棋和围棋冠军。
现在,让我们看看另一组采用不同哲学的强化学习算法。
无模型
无模型方法在没有环境模型的情况下运作,目前在强化学习研究与开发中更为流行。在无模型强化学习设置中,主要有两种训练智能体的方法:
-
政策优化
-
Q 学习
政策优化
在这种方法中,我们将政策制定为一个关于行动的函数形式,给定当前状态,如以下方程所示:
– 方程式 11.1
在这里,β代表这个函数的内部参数,通过梯度上升更新以优化政策函数。目标函数使用政策函数和奖励定义。在某些情况下,可能会使用目标函数的近似来进行优化过程。此外,在某些情况下,可能会使用政策函数的近似来代替实际的政策函数进行优化过程。
通常,在这种方法下进行的优化是在政策内的,这意味着参数是基于使用最新政策版本收集的数据进行更新的。一些基于政策优化的强化学习算法的示例如下:
-
政策梯度:这是最基本的政策优化方法,我们直接使用梯度上升优化政策函数。政策函数输出在每个时间步骤下采取不同行动的概率。
-
演员-批评家:由于在政策梯度算法下的优化是基于政策的,算法的每次迭代都需要更新政策。这需要很长时间。演员-批评家方法引入了值函数和政策函数的使用。演员模拟政策函数,批评家模拟值函数。
通过使用批评者,策略更新过程变得更快。我们将在下一节更详细地讨论价值函数。然而,本书不会深入讨论演员-批评家方法的数学细节。
- 信任区域策略优化 (TRPO): 类似于策略梯度方法,TRPO 包含一个基于政策的优化方法。在策略梯度方法中,我们使用梯度来更新政策函数参数 β。由于梯度是一阶导数,对于函数中的尖锐曲率可能会产生噪声。这可能导致我们进行大幅度的政策更改,从而可能不稳定代理的学习轨迹。
为了避免这种情况,TRPO 提出了信任区域。它定义了政策在给定更新步骤中可以改变的上限。这确保了优化过程的稳定性。
- 近端策略优化 (PPO): 类似于 TRPO,PPO 旨在稳定优化过程。在策略梯度方法中,每个数据样本都会进行梯度上升更新。然而,PPO 使用了一个替代的目标函数,可以在数据样本批次上进行更新。这导致更加保守地估计梯度,从而提高了梯度上升算法收敛的可能性。
策略优化函数直接工作于优化策略,因此这些算法非常直观。然而,由于这些算法大多是基于政策的,每次更新政策后都需要重新对数据进行采样。这可能成为解决 RL 问题的限制因素。接下来,我们将讨论另一种更加样本高效的无模型算法,称为 Q 学习。
Q 学习
与策略优化算法相反,Q 学习依赖于值函数而不是策略函数。从这一点开始,本章将重点讨论 Q 学习。我们将在下一节详细探讨 Q 学习的基础知识。
讨论 Q 学习
策略优化和 Q 学习之间的关键区别在于,后者并没有直接优化策略。相反,我们优化一个值函数。什么是值函数?我们已经学到 RL 的关键在于代理学习如何在经过一系列状态和动作的轨迹时获得最大的总奖励。值函数是一个关于当前代理所处状态的函数,其输出为代理在当前回合结束时将获得的预期奖励总和。
在 Q-learning 中,我们优化一种特定类型的值函数,称为动作值函数,它取决于当前状态和动作。在给定状态 S,动作值函数确定代理程序将为采取动作 a 而获得的长期奖励(直到结束的奖励)。此函数通常表示为 Q(S, a),因此也称为 Q 函数。动作值也称为Q 值。
每个(状态,动作)对的 Q 值可以存储在一个表中,其中两个维度分别是状态和动作。例如,如果有四个可能的状态 S1、S2、S3 和 S4,并且有两种可能的动作 a1 和 a2,那么这八个 Q 值将存储在一个 4x2 的表中。因此,Q-learning 的目标是创建这个 Q 值表。一旦表格可用,代理程序可以查找给定状态的所有可能动作的 Q 值,并采取具有最大 Q 值的动作。但问题是,我们从哪里获取 Q 值?答案在于贝尔曼方程,其数学表达如下:
– 方程 11.2
贝尔曼方程是计算 Q 值的递归方式。在此方程中,R 是在状态 St 采取动作 at 后获得的奖励,而 γ(gamma)是折扣因子,它是一个介于 0 和 1 之间的标量值。基本上,这个方程表明当前状态 St 和动作 at 的 Q 值等于在状态 St 采取动作 at 后获得的奖励 R,加上从下一个状态 St*+1 采取的最优动作 at*+1 的 Q 值,乘以折扣因子。折扣因子定义了在即时奖励与长期未来奖励之间给予多大的权重。
现在我们已经定义了 Q-learning 的大部分基础概念,让我们通过一个示例来演示 Q-learning 的工作原理。以下图示展示了一个包含五个可能状态的环境:
图 11. 3 – Q-learning 示例环境
有两种不同的可能动作 – 向上移动(a1)或向下移动(a2)。在不同状态下有不同的奖励,从状态 S4 的 +2 到状态 S0 的 -1。每个环境中的一轮从状态 S2 开始,并以 S0 或 S4 结束。由于有五个状态和两种可能的动作,Q 值可以存储在一个 5x2 的表中。以下代码片段展示了如何在 Python 中编写奖励和 Q 值:
rwrds = [-1, 0, 0, 0, 2]
Qvals = [[0.0, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[0.0, 0.0]]
我们将所有的 Q 值初始化为零。此外,由于有两个特定的结束状态,我们需要以列表的形式指定这些状态,如下所示:
end_states = [1, 0, 0, 0, 1]
这基本上表明状态 S0 和 S4 是终止状态。在运行完整的 Q 学习循环之前,我们还需要查看一个最后的部分。在 Q 学习的每一步,代理有两种选择下一步行动的选项:
-
选择具有最高 Q 值的动作。
-
随机选择下一个动作。
为什么代理程序会随机选择一个动作?
记住,在第七章,使用 PyTorch 进行音乐和文本生成中,文本生成部分,我们讨论了贪婪搜索或波束搜索导致重复结果的问题,因此引入随机性有助于产生更好的结果。同样地,如果代理程序总是基于 Q 值选择下一步动作,那么它可能会陷入重复选择立即高奖励的动作的子优化条件。因此,偶尔随机采取行动将有助于代理程序摆脱这种次优条件。
现在我们已经确定代理在每一步都有两种可能的行动方式,我们需要决定代理选择哪种方式。这就是epsilon-greedy-action机制发挥作用的地方。下图展示了它的工作原理:
图 11. 4 – Epsilon-greedy-action 机制
在此机制下,每个周期中预先决定一个 epsilon 值,它是一个介于 0
和 1
之间的标量值。在给定的周期内,对于每次选择下一个动作,代理生成一个介于 0
到 1
之间的随机数。如果生成的数字小于预定义的 epsilon 值,则代理随机从可用的下一个动作集中选择下一个动作。否则,从 Q 值表中检索每个可能的下一个动作的 Q 值,并选择具有最高 Q 值的动作。epsilon-greedy-action 机制的 Python 代码如下:
def eps_greedy_action_mechanism(eps, S):
rnd = np.random.uniform()
if rnd < eps:
return np.random.randint(0, 2)
else:
return np.argmax(Qvals[S])
通常情况下,我们在第一个周期以 1
的 epsilon 值开始,然后随着周期的进展线性减少它。这里的想法是,我们希望代理程序最初探索不同的选项。然而,随着学习过程的进行,代理程序对收集短期奖励不那么敏感,因此它可以更好地利用 Q 值表。
现在我们可以编写主要的 Q 学习循环的 Python 代码,如下所示:
n_epsds = 100
eps = 1
gamma = 0.9
for e in range(n_epsds):
S_initial = 2 # start with state S2
S = S_initial
while not end_states[S]:
a = eps_greedy_action_mechanism(eps, S)
R, S_next = take_action(S, a)
if end_states[S_next]:
Qvals[S][a] = R
else:
Qvals[S][a] = R + gamma * max(Qvals[S_next])
S = S_next
eps = eps - 1/n_epsds
首先,我们确定代理程序将被训练 100
个周期。我们从 epsilon 值为 1
开始,并定义折扣因子(gamma)为 0.9
。接下来,我们运行 Q 学习循环,该循环遍历周期数。在此循环的每次迭代中,我们通过整个周期运行。在周期内,我们首先将代理的状态初始化为 S2
。
接着,我们运行另一个内部循环,仅在代理达到结束状态时中断。在这个内部循环中,我们使用ε-贪婪动作机制为代理决定下一步动作。代理然后执行该动作,转移代理到一个新状态,并可能获得奖励。take_action
函数的实现如下:
def take_action(S, a):
if a == 0: # move up
S_next = S - 1
else:
S_next = S + 1
return rwrds[S_next], S_next
一旦我们获得奖励和下一个状态,我们使用方程 11.2 更新当前状态-动作对的 Q 值。下一个状态现在成为当前状态,过程重复进行。在每个 episode 结束时,ε值线性减小。一旦整个 Q 学习循环结束,我们获得一个 Q 值表。这个表基本上是代理在这个环境中操作所需的一切,以获得最大的长期奖励。
理想情况下,针对这个示例训练良好的代理总是向下移动,以获得S4 处的最大奖励*+2*,并避免向S0 移动,该位置含有*-1*的负奖励。
这完成了我们关于 Q 学习的讨论。前面的代码应该帮助您在提供的简单环境中开始使用 Q 学习。对于视频游戏等更复杂和现实的环境,这种方法将不起作用。为什么呢?
我们注意到,Q 学习的本质在于创建 Q 值表。在我们的示例中,只有 5 个状态和 2 个动作,因此表的大小为 10,这是可以管理的。但是在如 Pong 等视频游戏中,可能的状态太多了。这导致 Q 值表的大小爆炸增长,使得我们的 Q 学习算法极其占用内存且不可实际运行。
幸运的是,有一个解决方案,可以在不使我们的机器内存不足的情况下仍然使用 Q 学习的概念。这个解决方案将 Q 学习和深度神经网络的世界结合起来,提供了极其流行的 RL 算法,被称为DQN。在下一节中,我们将讨论 DQN 的基础知识和一些其新颖的特性。
理解深度 Q 学习
DQN不创建一个 Q 值表,而是使用一个深度神经网络(DNN),该网络为给定的状态-动作对输出一个 Q 值。DQN 在诸如视频游戏之类的复杂环境中使用,这些环境中的状态太多,无法在 Q 值表中管理。视频游戏的当前图像帧用来表示当前状态,并与当前动作一起作为输入传递给底层 DNN 模型。
DNN 为每个这样的输入输出一个标量 Q 值。在实践中,与其仅传递当前图像帧不如将给定时间窗口内的N个相邻图像帧作为输入传递给模型。
我们正在使用一个深度神经网络来解决强化学习(RL)问题。这引发了一个固有的问题。在使用深度神经网络时,我们始终使用独立同分布(iid)的数据样本。然而,在强化学习中,每一个当前输出都会影响到下一个输入。例如,在 Q-learning 中,贝尔曼方程本身表明,Q 值依赖于另一个 Q 值;也就是说,下一个状态-动作对的 Q 值影响了当前状态-动作对的 Q 值。
这意味着我们在处理一个不断移动的目标,并且目标与输入之间有很高的相关性。DQN 通过两个新特性来解决这些问题:
-
使用两个单独的深度神经网络(DNNs)
-
经验重放缓冲区
让我们更详细地看一下这些内容。
使用两个单独的深度神经网络(DNNs)
让我们重新写 DQNs 的贝尔曼方程:
– 方程 11.3
这个方程大部分与 Q-learning 的方程相同,只是引入了一个新术语,(θ)。
代表了 DQN 模型用于获取 Q 值的 DNN 的权重。但是这个方程有些奇怪。注意到
被放在了方程的左边和右边。这意味着在每一步中,我们使用同一个神经网络来获取当前状态-动作对和下一个状态-动作对的 Q 值。这意味着我们在追踪一个非静态目标,因为每一步,
都会被更新,这将改变下一步的方程的左边和右边,导致学习过程中的不稳定性。
通过查看损失函数,可以更清楚地看到这一点。神经网络将试图使用梯度下降来最小化损失函数。损失函数如下:
– 方程 11.4
暂且将R(奖励)放在一边,对于同一个网络生成当前和下一个状态-动作对的 Q 值将导致损失函数的波动性增加。为了解决这个问题,DQN 使用两个独立的网络——一个主 DNN 和一个目标 DNN。两个 DNN 具有完全相同的架构。
主要的 DNN 用于计算当前状态-动作对的 Q 值,而目标 DNN 用于计算下一个(或目标)状态-动作对的 Q 值。然而,虽然主网络的权重在每一次学习步骤中都会更新,目标网络的权重却是冻结的。每经过K次梯度下降迭代,主网络的权重被复制到目标网络。这种机制保持了训练过程的相对稳定性。权重复制机制确保了来自目标网络的准确预测。
经验重放缓冲区
因为 DNN 期望的输入是 iid 数据,我们只需将视频游戏的最后X个步骤(帧)缓存在一个缓冲区内,然后从缓冲区中随机抽样数据批次。这些批次然后作为 DNN 的输入。因为批次由随机抽样的数据组成,其分布看起来类似于 iid 数据样本的分布。这有助于稳定 DNN 训练过程。
注意
如果没有缓冲区技巧,DNN 将接收到相关的数据,这将导致优化结果不佳。
这两个技巧在贡献 DQN 成功方面已被证明非常重要。现在我们对 DQN 模型的工作原理和其新颖特性有了基本了解,让我们继续本章的最后一节,我们将实现自己的 DQN 模型。使用 PyTorch,我们将构建一个基于 CNN 的 DQN 模型,该模型将学习玩名为 Pong 的 Atari 视频游戏,并可能学会击败电脑对手。
在 PyTorch 中构建 DQN 模型
我们在前一节讨论了 DQN 背后的理论。在这一节中,我们将采取实际操作的方式。使用 PyTorch,我们将构建一个基于 CNN 的 DQN 模型,该模型将训练一个代理人玩称为 Pong 的视频游戏。这个练习的目标是展示如何使用 PyTorch 开发强化学习应用程序。让我们直接进入练习。
初始化主要和目标 CNN 模型
在这个练习中,我们仅展示代码的重要部分以演示目的。要访问完整代码,请访问我们的 github 仓库 [11.1]。请按照以下步骤进行:
- 首先,我们需要导入必要的库:
# general imports
import cv2
import math
import numpy as np
import random
# reinforcement learning related imports
import re
import atari_py as ap
from collections import deque
from gym import make, ObservationWrapper, Wrapper
from gym.spaces import Box
# pytorch imports
import torch
import torch.nn as nn
from torch import save
from torch.optim import Adam
在这个练习中,除了常规的与 Python 和 PyTorch 相关的导入之外,我们还使用了一个名为gym
的 Python 库。这是 OpenAI [11.2]开发的一个 Python 库,提供了一套用于构建强化学习应用的工具。基本上,导入gym
库消除了为 RL 系统的内部编写所有支撑代码的需要。它还包括一些内置的环境,包括一个用于视频游戏 Pong 的环境,在这个练习中我们将使用它。
- 导入库后,我们必须为 DQN 模型定义 CNN 架构。这个 CNN 模型主要接受当前状态输入,并输出所有可能动作的概率分布。代理人选择具有最高概率的动作作为下一个动作。与使用回归模型预测每个状态-动作对的 Q 值不同,我们巧妙地将其转换为分类问题。
Q 值回归模型将必须单独运行所有可能的动作,并且我们将选择预测 Q 值最高的动作。但是使用这个分类模型将计算 Q 值和预测最佳下一个动作的任务合并为一个:
class ConvDQN(nn.Module):
def __init__(self, ip_sz, tot_num_acts):
super(ConvDQN, self).__init__()
self._ip_sz = ip_sz
self._tot_num_acts = tot_num_acts
self.cnv1 = nn.Conv2d(ip_sz[0], 32, kernel_size=8, stride=4)
self.rl = nn.ReLU()
self.cnv2 = nn.Conv2d(32, 64, kernel_size=4, stride=2)
self.cnv3 = nn.Conv2d(64, 64, kernel_size=3, stride=1)
self.fc1 = nn.Linear(self.feat_sz, 512)
self.fc2 = nn.Linear(512, tot_num_acts)
正如我们所见,模型由三个卷积层cnv1
、cnv2
和cnv3
组成,它们之间有 ReLU 激活函数,并跟随两个全连接层。现在,让我们看看通过该模型的前向传播包含哪些内容:
def forward(self, x):
op = self.cnv1(x)
op = self.rl(op)
op = self.cnv2(op)
op = self.rl(op)
op = self.cnv3(op)
op = self.rl(op).view(x.size()[0], -1)
op = self.fc1(op)
op = self.rl(op)
op = self.fc2(op)
return op
forward
方法简单地演示了模型的前向传播,其中输入通过卷积层,展平,最后馈送到全连接层。最后,让我们看看其他模型方法:
@property
def feat_sz(self):
x = torch.zeros(1, *self._ip_sz)
x = self.cnv1(x)
x = self.rl(x)
x = self.cnv2(x)
x = self.rl(x)
x = self.cnv3(x)
x = self.rl(x)
return x.view(1, -1).size(1)
def perf_action(self, stt, eps, dvc):
if random.random() > eps:
stt=torch.from_numpy(np.float32(stt)).unsqueeze(0).to(dvc)
q_val = self.forward(stt)
act = q_val.max(1)[1].item()
else:
act = random.randrange(self._tot_num_acts)
return act
在上述代码片段中,feat_size
方法只是用于计算将最后一个卷积层输出展平后的特征向量大小。最后,perf_action
方法与我们之前在讨论 Q 学习部分讨论的 take_action
方法相同。
- 在这一步中,我们定义一个函数,实例化主神经网络和目标神经网络:
def models_init(env, dvc):
mdl = ConvDQN(env.observation_space.shape, env.action_space.n).to(dvc)
tgt_mdl = ConvDQN(env.observation_space.shape, env.action_space.n).to(dvc)
return mdl, tgt_mdl
这两个模型是同一类的实例,因此共享相同的架构。然而,它们是两个独立的实例,因此将随不同的权重集合而有所不同。
定义经验重播缓冲区
正如我们在理解深度 Q 学习部分讨论的那样,经验重播缓冲区是 DQN 的一个重要特性。借助该缓冲区,我们可以存储几千个游戏转换(帧),然后随机采样这些视频帧来训练 CNN 模型。以下是定义重播缓冲区的代码:
class RepBfr:
def __init__(self, cap_max):
self._bfr = deque(maxlen=cap_max)
def push(self, st, act, rwd, nxt_st, fin):
self._bfr.append((st, act, rwd, nxt_st, fin))
def smpl(self, bch_sz):
idxs = np.random.choice(len(self._bfr), bch_sz, False)
bch = zip(*[self._bfr[i] for i in idxs])
st, act, rwd, nxt_st, fin = bch
return (np.array(st), np.array(act), np.array(rwd, dtype=np.float32),np.array(nxt_st), np.array(fin, dtype=np.uint8))
def __len__(self):
return len(self._bfr)
在这里,cap_max
是定义的缓冲区大小;即,将存储在缓冲区中的视频游戏状态转换数量。smpl
方法在 CNN 训练循环中用于采样存储的转换并生成训练数据批次。
设置环境
到目前为止,我们主要关注于 DQN 的神经网络方面。在本节中,我们将专注于构建 RL 问题的基础方面之一 - 环境。请按照以下步骤进行操作:
- 首先,我们必须定义一些与视频游戏环境初始化相关的函数:
def gym_to_atari_format(gym_env):
...
def check_atari_env(env):
...
使用 gym
库,我们可以访问预先构建的 Pong 视频游戏环境。但在这里,我们将通过一系列步骤增强环境,包括降低视频游戏图像帧率,将图像帧推送到经验重播缓冲区,将图像转换为 PyTorch 张量等。
- 以下是实现每个环境控制步骤的定义类:
class CCtrl(Wrapper):
...
class FrmDwSmpl(ObservationWrapper):
...
class MaxNSkpEnv(Wrapper):
...
class FrRstEnv(Wrapper):
...
class FrmBfr(ObservationWrapper):
...
class Img2Trch(ObservationWrapper):
...
class NormFlts(ObservationWrapper):
...
这些类现在将用于初始化和增强视频游戏环境。
- 一旦定义了与环境相关的类,我们必须定义一个最终方法,该方法将原始 Pong 视频游戏环境作为输入,并增强环境,如下所示:
def wrap_env(env_ip):
env = make(env_ip)
is_atari = check_atari_env(env_ip)
env = CCtrl(env, is_atari)
env = MaxNSkpEnv(env, is_atari)
try:
env_acts = env.unwrapped.get_action_meanings()
if "FIRE" in env_acts:
env = FrRstEnv(env)
except AttributeError:
pass
env = FrmDwSmpl(env)
env = Img2Trch(env)
env = FrmBfr(env, 4)
env = NormFlts(env)
return env
在这一步中的部分代码已经省略,因为我们的重点是这个练习中的 PyTorch 方面。请参考本书的 GitHub 仓库 [11.3] 获取完整的代码。
定义 CNN 优化函数
在本节中,我们将定义用于训练我们的深度强化学习模型的损失函数,并定义每个模型训练迭代结束时需要执行的操作。按照以下步骤进行:
- 我们在“初始化主神经网络和目标神经网络”部分的“步骤 2”中初始化了我们的主要和目标 CNN 模型。现在我们已经定义了模型架构,我们将定义损失函数,该函数将被训练以最小化:
def calc_temp_diff_loss(mdl, tgt_mdl, bch, gm, dvc):
st, act, rwd, nxt_st, fin = bch st = torch.from_numpy(np.float32(st)).to(dvc)
nxt_st = torch.from_numpy(np.float32(nxt_st)).to(dvc)
act = torch.from_numpy(act).to(dvc)
rwd = torch.from_numpy(rwd).to(dvc)
fin = torch.from_numpy(fin).to(dvc) q_vals = mdl(st)
nxt_q_vals = tgt_mdl(nxt_st) q_val = q_vals.gather(1, act.unsqueeze(-1)).squeeze(-1)
nxt_q_val = nxt_q_vals.max(1)[0]
exp_q_val = rwd + gm * nxt_q_val * (1 - fin) loss = (q_val -exp_q_val.data.to(dvc)).pow(2). mean()
loss.backward()
此处定义的损失函数源自方程 11.4。此损失称为时间差异损失,是 DQN 的基础概念之一。
- 现在神经网络架构和损失函数已经就位,我们将定义模型“更新”函数,该函数在神经网络训练的每次迭代时调用:
def upd_grph(mdl, tgt_mdl, opt, rpl_bfr, dvc, log):
if len(rpl_bfr) > INIT_LEARN:
if not log.idx % TGT_UPD_FRQ:
tgt_mdl.load_state_dict(mdl.state_dict())
opt.zero_grad()
bch = rpl_bfr.smpl(B_S)
calc_temp_diff_loss(mdl, tgt_mdl, bch, G, dvc)
opt.step()
此函数从经验重播缓冲区中抽取一批数据,计算这批数据的时间差损失,并在每 TGT_UPD_FRQ
次迭代时将主神经网络的权重复制到目标神经网络中。TGT_UPD_FRQ
将在稍后分配一个值。
管理和运行剧集
现在,让我们学习如何定义 epsilon 值:
- 首先,我们将定义一个函数,该函数将在每个剧集后更新 epsilon 值:
def upd_eps(epd):
last_eps = EPS_FINL
first_eps = EPS_STRT
eps_decay = EPS_DECAY
eps = last_eps + (first_eps - last_eps) * math.exp(-1 * ((epd + 1) / eps_decay))
return eps
此函数与我们在“讨论 Q-learning”部分中讨论的 Q-learning 循环中的 epsilon 更新步骤相同。该函数的目标是按剧集线性减少 epsilon 值。
- 下一个函数是定义剧集结束时发生的情况。如果当前剧集中得分的总奖励是迄今为止我们取得的最佳成绩,我们会保存 CNN 模型的权重并打印奖励值:
def fin_epsd(mdl, env, log, epd_rwd, epd, eps):
bst_so_far = log.upd_rwds(epd_rwd)
if bst_so_far:
print(f"checkpointing current model weights. highest running_average_reward of\
{round(log.bst_avg, 3)} achieved!")
save(mdl.state_dict(), f"{env}.dat")
print(f"episode_num {epd}, curr_reward: {epd_rwd}, best_reward: {log.bst_rwd},\running_avg_reward: {round(log.avg, 3)}, curr_epsilon: {round(eps, 4)}")
每个剧集结束时,我们还会记录剧集编号、剧集结束时的奖励、过去几个剧集奖励值的滚动平均值,以及当前的 epsilon 值。
- 我们终于到达了本练习中最关键的函数定义之一。在这里,我们必须指定 DQN 循环。这是我们定义在一个剧集中执行的步骤:
def run_epsd(env, mdl, tgt_mdl, opt, rpl_bfr, dvc, log, epd):
epd_rwd = 0.0
st = env.reset()
while True:
eps = upd_eps(log.idx)
act = mdl.perf_action(st, eps, dvc)
env.render()
nxt_st, rwd, fin, _ = env.step(act)
rpl_bfr.push(st, act, rwd, nxt_st, fin)
st = nxt_st
epd_rwd += rwd
log.upd_idx()
upd_grph(mdl, tgt_mdl, opt, rpl_bfr, dvc, log)
if fin:
fin_epsd(mdl, ENV, log, epd_rwd, epd, eps)
break
奖励和状态会在每个剧集开始时重置。然后,我们运行一个无限循环,只有当代理达到其中一个终止状态时才会退出。在这个循环中,每次迭代执行以下步骤:
i) 首先,按线性折旧方案修改 epsilon 值。
ii) 下一个动作由主 CNN 模型预测。执行此动作会导致下一个状态和一个奖励。这个状态转换被记录在经验重播缓冲区中。
iii) 接下来的状态现在成为当前状态,并计算时间差异损失,用于更新主 CNN 模型,同时保持目标 CNN 模型冻结。
iv) 如果新的当前状态是一个终止状态,那么我们中断循环(即结束剧集),并记录本剧集的结果。
- 我们在整个训练过程中提到了记录结果。为了存储围绕奖励和模型性能的各种指标,我们必须定义一个训练元数据类,其中将包含各种指标作为属性:
class TrMetadata:
def __init__(self):
self._avg = 0.0
self._bst_rwd = -float("inf")
self._bst_avg = -float("inf")
self._rwds = []
self._avg_rng = 100
self._idx = 0
我们将使用这些指标稍后在这个练习中可视化模型性能,一旦我们训练完模型。
- 我们在上一步中将模型度量属性存储为私有成员,并公开它们相应的获取函数:
@property
def bst_rwd(self):
...
@property
def bst_avg(self):
...
@property
def avg(self):
...
@property
def idx(self):
...
...
idx
属性对于决定何时从主 CNN 复制权重到目标 CNN 非常关键,而 avg
属性对于计算过去几集收到的奖励的运行平均值非常有用。
训练 DQN 模型以学习 Pong
现在,我们拥有开始训练 DQN 模型所需的所有必要组件。让我们开始吧:
- 下面是一个训练包装函数,它将做我们需要做的一切:
def train(env, mdl, tgt_mdl, opt, rpl_bfr, dvc):
log = TrMetadata()
for epd in range(N_EPDS):
run_epsd(env, mdl, tgt_mdl, opt, rpl_bfr, dvc, log, epd)
本质上,我们初始化了一个记录器,只需运行预定义数量的情节的 DQN 训练系统。
- 在我们实际运行训练循环之前,我们需要定义以下超参数值:
i) 每次梯度下降迭代的批量大小,用于调整 CNN 模型
ii) 环境,本例中是 Pong 游戏
iii) 第一集的 epsilon 值
iv) 最后一集的 epsilon 值
v) epsilon 值的折旧率
vi) Gamma;即折现因子
vii) 最初仅用于向回放缓冲区推送数据的迭代次数
viii) 学习率
ix) 经验回放缓冲区的大小或容量
x) 训练代理程序的总集数
xi) 多少次迭代后,我们从主 CNN 复制权重到目标 CNN
我们可以在下面的代码中实例化所有这些超参数:
B_S = 64
ENV = "Pong-v4"
EPS_STRT = 1.0
EPS_FINL = 0.005
EPS_DECAY = 100000
G = 0.99
INIT_LEARN = 10000
LR = 1e-4
MEM_CAP = 20000
N_EPDS = 2000
TGT_UPD_FRQ = 1000
这些值是实验性的,我鼓励您尝试更改它们并观察对结果的影响。
- 这是练习的最后一步,也是我们实际执行 DQN 训练例程的地方,如下所示:
i) 首先,我们实例化游戏环境。
ii) 然后,我们定义训练将在其上进行的设备 – 根据可用性为 CPU 或 GPU。
iii) 接下来,我们实例化主 CNN 模型和目标 CNN 模型。我们还将 Adam 定义为 CNN 模型的优化器。
iv) 然后,我们实例化经验回放缓冲区。
v) 最后,我们开始训练主 CNN 模型。一旦训练例程完成,我们关闭实例化的环境。
代码如下所示:
env = wrap_env(ENV)
dvc = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
mdl, tgt_mdl = models_init(env, dvc)
opt = Adam(mdl.parameters(), lr=LR)
rpl_bfr = RepBfr(MEM_CAP)
train(env, mdl, tgt_mdl, opt, rpl_bfr, dvc)
env.close()
这应该给我们以下输出:
图 11. 5 – DQN 训练日志
此外,下图显示了当前奖励、最佳奖励和平均奖励的进展,以及 epsilon 值与情节进展的关系:
图 11. 6 – DQN 训练曲线
下图显示了在训练过程中 epsilon 值随着回合数的减少情况:
图 11. 7 – 回合内的 epsilon 变化
注意,在图 11. 6中,一个回合内奖励的运行平均值(红色曲线)从**-20开始,这是代理在游戏中得0分而对手得20分的情况。随着回合的进行,平均奖励不断增加,到第1500回合时,越过了零点标记。这意味着在经过1500**回合的训练后,代理已经超越了对手。
从这里开始,平均奖励变为正值,这表明代理平均上在对手手上占有优势。我们仅仅训练了2000回合,代理已经以超过7分的平均分数优势击败了对手。我鼓励你延长训练时间,看看代理是否能够始终得分,并以20分的优势击败对手。
这就结束了我们对 DQN 模型实现的深入挖掘。DQN 在强化学习领域取得了巨大成功并广受欢迎,绝对是探索该领域的一个很好的起点。PyTorch 与 gym 库一起,为我们在各种 RL 环境中工作和处理不同类型的 DRL 模型提供了极大的帮助。
在本章中,我们只关注了 DQNs,但我们所学到的经验可以应用到其他变体的 Q 学习模型和其他深度强化学习算法中。
摘要
强化学习是机器学习的一个基础分支,目前是研究和开发中最热门的领域之一。像谷歌 DeepMind 的 AlphaGo 这样基于 RL 的 AI 突破进一步增加了人们对这一领域的热情和兴趣。本章概述了强化学习和深度强化学习,并通过使用 PyTorch 构建 DQN 模型的实际练习带领我们深入探讨。
强化学习是一个广泛的领域,一章篇幅远远不够覆盖所有内容。我鼓励你利用本章的高层次讨论去探索这些细节。从下一章开始,我们将专注于使用 PyTorch 处理实际工作中的各个方面,比如模型部署、并行化训练、自动化机器学习等等。在下一章中,我们将讨论如何有效地使用 PyTorch 将训练好的模型投入生产系统。