PyTorch-生成式人工智能学习指南-全-

PyTorch 生成式人工智能学习指南(全)

原文:zh.annas-archive.org/md5/ea442d0490aad3d193551ec97eaf414e

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分:生成式 AI 简介

什么是生成式 AI?它与非生成式对应物,即判别模型有何不同?为什么我们在这本书中选择 PyTorch 作为 AI 框架?

在本部分中,我们回答这些问题。此外,本书中所有生成式 AI 模型都是深度神经网络。因此,你将学习如何使用 PyTorch 创建深度神经网络以执行二分类和多分类,以便你能够熟练掌握深度学习和分类任务。目的是让你为即将到来的章节做好准备,在这些章节中,你将使用 PyTorch 中的深度神经网络创建各种生成模型。你还将学习如何使用 PyTorch 构建和训练生成对抗网络以生成形状和数字序列。

第一章:什么是生成式 AI,为什么是 PyTorch?

本章涵盖

  • 生成式 AI 与非生成式 AI 的比较

  • 为什么 PyTorch 是深度学习和生成式 AI 的理想选择

  • 生成对抗网络的概念

  • 注意力机制和 Transformers 的好处

  • 从头创建生成式 AI 模型的优势

生成式 AI 自 2022 年 11 月 ChatGPT 问世以来,对全球格局产生了重大影响,引起了广泛关注,并成为焦点。这项技术进步彻底改变了日常生活的许多方面,迎来了技术新时代,并激发了许多初创公司探索各种生成模型提供的广泛可能性。

考虑一下 Midjourney 这家先驱公司取得的进步,它现在可以从简短的文字输入中创建高分辨率、逼真的图像。同样,软件公司 Freshworks 显著加速了应用程序的开发,将平均所需时间从 10 周缩短到仅仅几天,这是通过 ChatGPT 的能力实现的(参见Forbes文章“2023 年公司如何使用 ChatGPT 的 10 个惊人的真实世界例子”,作者 Bernard Barr,2023 年,mng.bz/Bgx0)。为了举例说明,这篇引言的一些内容已经通过生成式 AI 得到了增强,展示了其精炼内容以使其更具吸引力的能力。

注意事项:有什么比让生成式 AI 自己解释生成式 AI 更好的方法呢?在最终确定之前,我让 ChatGPT 重新撰写了这篇引言的早期草稿,以“更具吸引力”的方式呈现。

这项技术进步的影响远远超出了这些例子。由于生成式 AI 的先进能力,各行业正经历着重大的颠覆。这项技术现在可以创作出与人类写作相当的文章,创作出令人联想到古典作品的音乐,并快速生成复杂的法律文件,这些任务通常需要大量的人类努力和时间。ChatGPT 发布后,教育平台 CheggMate 的股价出现了显著下降。此外,美国编剧工会最近的一次罢工中,达成了一致意见,要为 AI 在剧本写作和编辑方面的侵犯设定界限(参见Wired文章“好莱坞编剧达成 AI 协议,将改写历史”,作者 Will Bedingfield,2023 年,mng.bz/1ajj)。

注意事项:CheggMate 向大学生收费,让他们的问题由人类专家回答。现在,许多这些工作可以通过 ChatGPT 或类似工具以极低成本完成。

这引发了一系列问题:什么是生成式 AI,它与其他 AI 技术有何不同?为什么它在各个行业引起了如此广泛的颠覆?生成式 AI 的潜在机制是什么,为什么了解它很重要?

本书深入探讨了生成式人工智能,这是一种通过从现有数据中学习模式来创建新内容(如文本、图像或音乐)的人工智能类型。它与专注于区分不同数据实例之间的差异并学习类别之间边界的判别模型不同。图 1.1 展示了这两种建模方法之间的差异。例如,当面对包含狗和猫的图像数组时,判别模型通过捕捉区分两者的几个关键特征(例如,猫有小的鼻子和尖耳朵)来确定每张图像描绘的是狗还是猫。如图表的上半部分所示,判别模型将数据作为输入,并产生不同标签的概率,我们用 Prob(狗)和 Prob(猫)表示。然后我们可以根据最高的预测概率对输入进行标记。

所有这些模型都是基于深度神经网络,你将使用 Python 和 PyTorch 来构建、训练和使用这些模型。我们选择 Python 是因为其用户友好的语法、跨平台兼容性和广泛的社区支持。我们还选择了 PyTorch 而不是 TensorFlow 等其他框架,因为它易于使用并且能够适应各种模型架构。Python 被广泛认为是机器学习(ML)的主要工具,PyTorch 在人工智能领域也越来越受欢迎。因此,使用 Python 和 PyTorch 可以使你跟上生成式人工智能的新发展。因为 PyTorch 允许使用图形处理单元(GPU)进行训练加速,所以你可以在几分钟或几小时内训练这些模型,并见证生成式人工智能的实际应用!

1.1 介绍生成式人工智能和 PyTorch

本节解释了什么是生成式人工智能以及它与非生成式人工智能(如判别模型)的不同之处。生成式人工智能是一类具有非凡能力,能够产生各种形式的新内容的技术,包括文本、图像、音频、视频、源代码和复杂模式。生成式人工智能能够创造全新的内容世界;ChatGPT 是一个显著的例子。相比之下,判别建模主要关注的是识别和分类现有内容的工作。

1.1.1 什么是生成式人工智能?

生成式人工智能是一种通过从现有数据中学习模式来创建新内容(如文本、图像或音乐)的人工智能类型。它与专注于区分不同数据实例之间的差异并学习类别之间边界的判别模型不同。图 1.1 展示了这两种建模方法之间的差异。例如,当面对包含狗和猫的图像数组时,判别模型通过捕捉区分两者的几个关键特征(例如,猫有小的鼻子和尖耳朵)来确定每张图像描绘的是狗还是猫。如图表的上半部分所示,判别模型将数据作为输入,并产生不同标签的概率,我们用 Prob(狗)和 Prob(猫)表示。然后我们可以根据最高的预测概率对输入进行标记。

图片

图 1.1 生成模型与判别模型的比较。判别模型(图的上半部分)将数据作为输入,并产生不同标签的概率,我们用 Prob(dog)和 Prob(cat)表示。相反,生成模型(图的下半部分)通过深入了解这些图像的显著特征来合成代表狗和猫的新图像。

相比之下,生成模型展现出生成数据新实例的独特能力。在我们的狗和猫示例中,生成模型通过深入了解这些图像的显著特征来合成代表狗和猫的新图像。如图 1.1 的下半部分所示,生成模型将任务描述(例如,在潜在空间中变化的值导致生成的图像具有不同的特征,我们将在第四章到第六章中详细讨论)作为输入,并产生狗和猫的全新图像。

从统计学的角度来看,当面对具有特征 X 的数据示例,这些特征描述了输入和相应的各种标签 Y 时,判别模型承担预测条件概率的责任,具体来说是预测 Y|X 的概率。相反,生成模型试图学习输入特征 X 和目标变量 Y 的联合概率分布,表示为 prob(X, Y)。凭借这种知识,它们从分布中采样,以产生 X 的新实例。

根据你想要创建的具体内容形式,存在不同类型的生成模型。在这本书中,我们主要关注两种突出的技术:生成对抗网络(GANs)和转换器(虽然我们也会涵盖变分自编码器和扩散模型)。在 GANs 中的“对抗”一词指的是两个神经网络在零和博弈框架中相互竞争的事实:生成网络试图创建与真实样本不可区分的数据实例,而判别网络则试图从真实样本中识别生成的样本。两个网络之间的竞争导致两者都得到改进,最终使生成器能够创建高度逼真的数据。转换器是能够高效解决序列到序列预测任务的深度神经网络,我们将在本章后面更详细地解释它们。

GANs 因其易于实现和多功能性而备受赞誉,使连对深度学习只有初步了解的人也能从头开始构建他们的生成模型。这些多才多艺的模型可以产生各种各样的创作,从本书第三章中展示的几何形状和复杂图案,到第四章中将学习生成的高质量彩色图像,如人类面孔。此外,GANs 还表现出转换图像内容的能力,无缝地将金发人类面孔图像转变为黑发人类面孔图像,这在第六章中进行了讨论。值得注意的是,它们将它们的创造力扩展到音乐生成领域,产生听起来逼真的音乐作品,如第十三章中所示。

与形状、数量或图像生成相比,文本生成的艺术面临巨大的挑战,这主要归因于文本信息的序列性质,其中单个字符和单词的顺序和排列具有重大意义。为了应对这种复杂性,我们转向 Transformer,这是一种专为高效处理序列到序列预测任务而设计的深度神经网络。与它们的 predecessors,如循环神经网络(RNN)或卷积神经网络(CNN)不同,Transformer 在捕捉输入和输出序列中固有的复杂、长距离依赖关系方面表现出色。值得注意的是,它们并行训练的能力(一种在多个设备上同时训练模型的多设备训练方法)大大减少了训练时间,使我们能够在大量数据上训练 Transformer。

Transformer 的革命性架构是大型语言模型(LLMs;具有大量参数并在大型数据集上训练的深度神经网络)的出现的基础,包括 ChatGPT、BERT、DALL-E 和 T5。这种变革性的架构是近年来 AI 进步激增的基石,ChatGPT 和其他生成预训练 Transformer(GPT)模型的引入开启了这一进步。

在接下来的章节中,我们将深入了解这两种开创性技术的全面内部工作原理:它们的底层机制和它们解锁的众多可能性。

1.1.2 Python 编程语言

我假设您已经具备 Python 的基本知识。为了跟随书中的内容,您需要了解 Python 的基础知识,例如函数、类、列表、字典等。如果您还没有,网上有大量的免费资源可以帮助您入门。按照附录 A 中的说明安装 Python。之后,为本书创建一个虚拟环境,并安装 Jupyter Notebook 作为本书项目中计算环境。

自 2018 年后期以来,Python 已经成为全球领先的编程语言,正如《经济学人》杂志所记录的(参见《经济学人》数据团队撰写的文章“Python Is Becoming the World’s Most Popular Coding Language”,2018 年,mng.bz/2gj0)。Python 不仅对每个人都是免费的,还允许其他用户创建和调整库。Python 有一个庞大的社区驱动的生态系统,因此你可以轻松找到来自其他 Python 爱好者的资源和帮助。此外,Python 程序员喜欢分享他们的代码,所以你不必重新发明轮子,你可以导入现成的库,并与 Python 社区分享你的代码。

无论你是在 Windows、Mac 还是 Linux 上,Python 都能满足你的需求。它是一种跨平台语言,尽管安装软件和库的过程可能因操作系统而异——但不用担心;我会在附录 A 中向你展示如何操作。一旦一切准备就绪,Python 代码在不同系统上表现一致。

Python 是一种适合通用应用开发的表达性语言。它的语法易于理解,使得 AI 爱好者能够轻松理解和操作。如果你在这本书中提到的 Python 库遇到任何问题,你可以在 Python 论坛上搜索或访问像 Stack Overflow(stackoverflow.com/questions/tagged/python)这样的网站寻找答案。如果所有其他方法都失败了,请不要犹豫,向我寻求帮助。

最后,Python 提供了一大批库,使得创建生成模型变得容易(相对于 C++或 R 等其他语言)。在这个旅程中,我们将独家使用 PyTorch 作为我们的 AI 框架,我将在稍后解释为什么我们选择它而不是像 TensorFlow 这样的竞争对手。

1.1.3 使用 PyTorch 作为我们的 AI 框架

现在我们已经决定将 Python 作为本书的编程语言,我们将选择一个适合生成建模的 AI 框架。在 Python 中,最受欢迎的两个 AI 框架是 PyTorch 和 TensorFlow。在这本书中,我们选择 PyTorch 而不是 TensorFlow,因为它易于使用,并且我强烈建议你也这样做。

PyTorch 是由 Meta 的 AI 研究实验室开发的开源 ML 库。它建立在 Python 编程语言和 Torch 库的基础上,旨在提供一个灵活且直观的平台,用于创建和训练深度学习模型。Torch 是 PyTorch 的前身,是一个用 C 语言构建深度神经网络并带有 Lua 包装器的 ML 库,但它的开发已经停止。PyTorch 旨在通过提供一个更用户友好和适应性强的框架来满足研究人员和开发者的需求。

计算图是深度学习中的一个基本概念,在高效计算复杂数学运算中扮演着至关重要的角色,尤其是涉及多维数组或张量的运算。计算图是一种有向图,其中的节点代表数学运算,而边则代表在这些运算之间流动的数据。计算图的关键用途之一是在实现反向传播和梯度下降算法时计算偏导数。图结构允许高效地计算在训练过程中更新模型参数所需的梯度。PyTorch 能够动态创建和修改图,这被称为动态计算图。这使得它能够更好地适应不断变化的结构模型,并简化了调试过程。此外,就像 TensorFlow 一样,PyTorch 通过 GPU 训练提供加速计算,与中央处理器(CPU)训练相比,可以显著减少训练时间。

PyTorch 的设计与 Python 编程语言相得益彰。其语法简洁易懂,使得新手和经验丰富的开发者都能轻松上手。研究人员和开发者都欣赏 PyTorch 的灵活性。它使他们能够快速实验新想法,这得益于其动态计算图和简单的接口。这种灵活性在快速发展的生成人工智能领域至关重要。PyTorch 还拥有一个快速发展的社区,积极为其发展做出贡献。这导致了一个庞大的生态系统,包括库、工具和资源,为开发者提供支持。

PyTorch 在迁移学习方面表现出色,这是一种将针对通用任务设计的预训练模型微调以适应特定任务的技术。研究人员和实践者可以轻松利用预训练模型,节省时间和计算资源。这一特性在预训练大型语言模型(LLMs)的时代尤为重要,它使我们能够将 LLMs 应用于下游任务,如分类、文本摘要和文本生成。

PyTorch 与其它 Python 库兼容,如 NumPy 和 Matplotlib。这种互操作性允许数据科学家和工程师无缝地将 PyTorch 集成到现有的工作流程中,提高生产力。PyTorch 还以其对社区驱动开发的承诺而闻名。它发展迅速,定期根据实际使用和用户反馈进行更新和改进,确保其始终处于人工智能研究和开发的前沿。

附录 A 提供了如何在您的计算机上安装 PyTorch 的详细说明。按照说明在本书的虚拟环境中安装 PyTorch。如果您计算机上没有安装支持 Compute Unified Device Architecture (CUDA)的 GPU,本书中的所有程序也兼容 CPU 训练。更好的是,我将在本书的 GitHub 仓库github.com/markhliu/DGAI上提供训练好的模型,以便您可以看到训练好的模型在实际中的应用(如果训练模型太大,我将在我的个人网站上提供gattonweb.uky.edu/faculty/lium/))。在第二章中,您将深入探索 PyTorch。您首先将学习 PyTorch 中的数据结构,Tensor,它包含数字和矩阵,并提供执行操作的功能。然后,您将学习如何使用 PyTorch 执行端到端的深度学习项目。具体来说,您将在 PyTorch 中创建一个神经网络,并使用服装物品图像及其相应的标签来训练网络。完成之后,您将使用训练好的模型将服装物品分类到 10 种不同的标签类型中。这个项目将使您为在后续章节中使用 PyTorch 构建和训练各种生成模型做好准备。

1.2 GANs

本节首先概述 GANs 的工作原理。然后,我们以生成动漫人脸图像为例,向您展示 GANs 的内部工作原理。最后,我们将讨论 GANs 的实际应用。

1.2.1 GANs 的高级概述

GANs 代表一类生成模型,最初由 Ian Goodfellow 及其合作者于 2014 年提出(“Generative Adversarial Nets,” arxiv.org/abs/1406.2661)。近年来,GANs 因其易于构建和训练,并且能够生成各种内容而变得极为流行。您将从下一小节中的说明示例中看到,GANs 采用一个双网络架构,包括一个生成模型,其任务是捕捉潜在的数据分布以生成内容,以及一个判别模型,其作用是估计给定样本是否来自真实的训练数据集(被认为是“真实”)而不是生成模型的产物(被认为是“虚假”)。模型的主要目标是产生与训练数据集中的数据实例非常相似的新数据实例。GANs 生成数据的性质取决于训练数据集的组成。例如,如果训练数据由服装物品的灰度图像组成,合成的图像将非常类似于这样的服装物品。相反,如果训练数据集包含人类面部彩色图像,生成的图像也将类似于人类面部。

看一下图 1.2——我们 GAN 的架构及其组件。为了训练模型,我们将真实样本(如图 1.2 顶部所示)和生成器创建的虚假样本(左侧)都展示给判别器(中间)。生成器的主要目的是创建与训练数据集中找到的示例几乎无法区分的数据实例。相反,判别器努力区分生成器生成的虚假样本和真实样本。这两个网络进行着一种持续的竞争过程,类似于猫捉老鼠的游戏,试图迭代地超越对方。

图片

图 1.2 GAN 架构及其组件。GAN 采用双网络架构,包括一个生成模型(左侧),其任务是捕捉潜在的数据分布,以及一个判别模型(中间),其目的是估计一个给定的样本是来自真实的训练数据集(被认为是“真实”)而不是生成模型的产物(被认为是“虚假”)的可能性。

GAN 模型的训练过程涉及多个迭代。在每个迭代中,生成器接受某种形式的任务描述(步骤 1)并使用它来创建虚假图像(步骤 2)。虚假图像以及来自训练集的真实图像被展示给判别器(步骤 3)。判别器试图将每个样本分类为真实或虚假。然后,它将分类与实际标签、真实情况(步骤 4)进行比较。判别器和生成器都从分类中接收反馈(步骤 5),并提高它们的能力:判别器适应其识别虚假样本的能力,而生成器学习提高其生成能够欺骗判别器的令人信服样本的能力。随着训练的进行,当两个网络都无法进一步改进时,达到平衡。此时,生成器能够产生与真实样本几乎无法区分的数据实例。

为了确切了解 GAN 是如何工作的,让我们来看一个说明性例子。

1.2.2 一个说明性例子:生成动漫面孔

想象一下:你是一个热衷的动漫爱好者,你正在使用一种称为深度卷积 GAN(简称 DCGAN)的强大工具进行一次激动人心的探索,以创建你自己的动漫面孔。别担心,我们将在第四章中深入探讨这一点。

如果你观察图 1.2 的顶部中间部分,你会看到一个写着“实像”的图片。我们将使用 63,632 张动漫面孔的彩色图片作为我们的训练数据集。如果你翻到图 1.3,你会看到我们训练集中的 32 个示例。这些特殊图片在形成我们判别网络一半输入方面起着至关重要的作用。

图片

图 1.3 动漫面孔训练数据集示例

图 1.2 的左侧是生成器网络。为了每次生成不同的图像,生成器从潜在空间中输入一个向量 Z。我们可以将这个向量视为“任务描述”。在训练过程中,我们从潜在空间中抽取不同的 Z 向量,因此网络每次都会生成不同的图像。这些假图像是鉴别器网络输入的一半。

注意:通过改变向量 Z 的值,我们可以生成不同的输出。在第五章,你将学习如何选择向量 Z 来生成具有特定特征(例如,男性或女性特征)的图像。

但这里有个转折:在我们教我们的两个网络创作和检测的艺术之前,生成器产生的图像,嗯,简直是胡言乱语!它们看起来根本不像图 1.3 中你看到的那些逼真的动漫面孔。事实上,它们看起来就像电视屏幕上的静态画面(你将在第四章亲自见证这一点)。

我们对模型进行了多次迭代训练。在每次迭代中,我们向鉴别器展示一组由生成器创建的图像,以及一组来自我们的训练集的动漫面孔图像。我们要求鉴别器预测每张图像是由生成器(伪造)还是来自训练集(真实)创建的。

你可能会想:鉴别器和生成器在每次训练迭代中是如何学习的?一旦做出预测,鉴别器不会坐以待毙;它会从每个图像的预测错误中学习。有了这种新获得的知识,它调整自己的参数,以在下一轮中做出更好的预测。生成器也不是闲着的。它从自己的图像生成过程和鉴别器的预测结果中汲取经验。有了这些知识在手,它调整自己的网络参数,力求在下一轮迭代中创造越来越逼真的图像。目标?降低鉴别器发现其伪造品的几率。

随着我们穿越这些迭代,一个显著的变化发生了。生成器网络在进化,产生的动漫面孔越来越逼真,类似于我们的训练集合中的那些。同时,鉴别器网络也在磨练自己的技能,成为在识别伪造品方面的老练侦探。这是创作与检测之间的一场迷人的舞蹈。

渐渐地,一个神奇的时刻到来了。达到了一种平衡,或者说是一种完美的平衡。生成器创造的图像变得如此逼真,以至于它们与我们在训练档案中的真实动漫面孔无法区分。在这个时候,鉴别器如此困惑,以至于它将每个图像的真实性赋予 50%的机会,无论它是来自我们的训练集还是由生成器制作的。

最后,请看一些生成器艺术作品的例子,如图 1.4 所示:它们看起来确实与我们的训练集中的那些无法区分。

图片

图 1.4 DCGAN 训练好的生成器生成的动漫人脸图像

1.2.3 你为什么应该关注 GANs?

GANs 易于实现且用途广泛:仅在本书中,你将学会生成几何形状、复杂图案、高分辨率图像和听起来逼真的音乐。

GANs 的实用用途不仅限于生成逼真的数据。GANs 还可以将一个图像域的属性转换到另一个域。正如你在第六章中将会看到的,你可以训练一个 CycleGAN(GAN 家族中的一种生成模型)将人脸图像中的金色头发转换为黑色头发。同样的训练模型也可以将黑色头发转换为金色头发。图 1.5 显示了四行图像。第一行是带有金色头发的原始图像。训练好的 CycleGAN 将它们转换为带有黑色头发的图像(第二行)。最后两行是带有黑色头发的原始图像和分别转换为金色头发的转换图像。

图 1.5 使用 CycleGAN 改变发色。如果我们将带有金色头发的图像(第一行)输入到一个训练好的 CycleGAN 模型中,该模型将这些图像中的金色头发转换为黑色头发(第二行)。同样的训练模型也可以将黑色头发(第三行)转换为金色头发(底部行)。

想想你在训练 GANs 时会掌握的所有令人惊叹的技能——它们不仅酷炫,而且超级实用!假设你经营一家采用“按需定制”策略的在线服装店(这允许用户在制造前定制他们的购买)。你的网站展示了大量独特的款式供客户选择,但这里有个问题:你只有在有人下单时才会制作衣服。由于你必须制作商品并拍照,制作这些衣服的高质量图像可能相当昂贵。

GANs 来拯救!你不需要大量的制造服装物品及其图像;相反,你可以使用类似 CycleGAN 的东西将一组图像的特征转换到另一组,从而创建一系列全新的风格。这只是使用 GANs 的一种巧妙方式。可能性是无限的,因为这些模型非常灵活,可以处理各种类型的数据——使它们在实用应用中成为变革者。

1.3 变换器

变换器是擅长序列到序列预测问题的深度神经网络,例如,输入一个句子并预测最可能的下一个单词。本节将向您介绍 Transformers 的关键创新:自注意力机制。然后我们将讨论 Transformer 架构和不同类型的 Transformers。最后,我们将讨论 Transformers 的一些最新发展,如多模态模型(输入不仅包括文本,还包括其他数据类型,如音频和图像的 Transformers)和预训练的 LLMs(在大规模文本数据上训练的模型,可以执行各种下游任务)。

在 2017 年由一组谷歌研究人员(Vaswani 等人,“Attention Is All You Need”,arxiv.org/abs/1706.03762)发明 Transformer 架构之前,自然语言处理(NLP)和其他序列到序列预测任务主要是由循环神经网络(RNNs)处理的。然而,RNNs 在保留序列中早期元素的信息方面存在困难,这阻碍了它们捕捉长期依赖关系的能力。即使是像长短期记忆(LSTM)网络这样的高级 RNN 变体,虽然可以处理较长的依赖关系,但在处理极长范围依赖关系时也显得不足。

更重要的是,RNNs(包括 LSTMs)按顺序处理输入,这意味着这些模型一次处理一个元素,按顺序处理,而不是同时查看整个序列。RNNs 沿着输入和输出序列的符号位置进行计算的事实阻止了并行训练,这使得训练速度变慢。这反过来又使得在大型数据集上训练模型成为不可能。

Transformers 的关键创新是自注意力机制,它擅长捕捉序列中的长期依赖关系。此外,由于模型中对输入的处理不是按顺序进行的,因此 Transformers 可以并行训练,这大大减少了训练时间。更重要的是,并行训练使得在大量数据上训练 Transformers 成为可能,这使得大型语言模型(LLMs)变得智能和知识渊博(基于它们处理和生成类似人类文本、理解上下文以及执行各种语言任务的能力)。这导致了 ChatGPT 等 LLMs 的兴起以及最近的 AI 繁荣。

1.3.1 注意力机制

注意力机制为序列中一个元素与序列中所有元素(包括该元素本身)的关系分配权重。权重越高,两个元素之间的关系越紧密。这些权重是在训练过程中从大量训练数据中学习得到的。因此,经过训练的 LLM,如 ChatGPT,可以找出句子中任意两个单词之间的关系,从而理解人类语言。

你可能会想:注意力机制是如何为序列中的元素分配分数以捕获长期依赖关系的?首先通过三个神经网络层处理输入以获得查询 Q、键 K 和值 V(我们将在第九章中详细解释)。使用查询、键和值来计算注意力的方法来自检索系统。例如,你可能会去公共图书馆查找一本书。你可以在图书馆的搜索引擎中输入,比如,“金融领域的机器学习”。在这种情况下,查询 Q 是“金融领域的机器学习”。键 K 是书名、书籍描述等。图书馆的检索系统将根据查询和键之间的相似性推荐一系列书籍(值 V)。自然地,标题或描述中包含“机器学习”或“金融”或两者都的书籍会出现在列表的顶部,而标题或描述中都不包含这两个短语的书籍会出现在列表的底部,因为这些书籍会被分配一个较低的匹配分数。

在第九章和第十章中,你将了解注意力机制的细节——更好的是,你将从头开始实现注意力机制以构建和训练一个 Transformer,以成功地将英语翻译成法语。

1.3.2 Transformer 架构

Transformer 首次在为机器语言翻译(例如,英语到德语或英语到法语)设计模型时被提出。图 1.6 是 Transformer 架构的示意图。左侧是编码器,右侧是解码器。在第九章和第十章中,你将学习从头开始构建 Transformer 以训练模型将英语翻译成法语,那时我们将更详细地解释图 1.6。

Transformer 中的编码器“学习”输入序列(例如,英语短语“你好吗?”)的含义,并将其转换为表示这种含义的向量,然后再将这些向量传递给解码器。解码器通过逐个预测序列中的单词,基于序列中的先前单词和编码器的输出来构建输出(例如,英语短语的法语翻译)。经过训练的模型可以将常见的英语短语翻译成法语。

变压器有三种类型:仅编码器 Transformer、仅解码器 Transformer 和编码器-解码器 Transformer。仅编码器 Transformer 没有解码器,能够将序列转换为用于各种下游任务(如情感分析、命名实体识别和文本生成)的抽象表示。例如,BERT 就是一个仅编码器 Transformer。仅解码器 Transformer 只有解码器但没有编码器,非常适合文本生成、语言建模和创意写作。GPT-2(ChatGPT 的前身)和 ChatGPT 都是仅解码器 Transformer。在第十一章中,你将学习从头开始创建 GPT-2,然后从 Hugging Face(一个托管和协作机器学习模型、数据集和应用的 AI 社区)中提取训练好的模型权重。你将把权重加载到你的 GPT-2 模型中,并开始生成连贯的文本。

图片

图 1.6 Transformer 架构。Transformer 中的编码器(图示的左侧)学习输入序列(例如,英语短语“你好吗?”)的含义,并将其转换为捕获其含义的抽象表示,然后再传递给解码器(图示的右侧)。解码器通过逐个预测单词,基于序列中的先前单词和编码器提供的抽象表示来构建输出(例如,英语短语的法语翻译)。

对于复杂任务,如能够处理文本到图像生成或语音识别的多模态模型,需要编码器-解码器 Transformer。编码器-解码器 Transformer 结合了编码器和解码器的优点。编码器在处理和理解输入数据方面效率高,而解码器在生成输出方面表现卓越。这种组合使得模型能够有效地理解复杂的输入(如文本或语音)并生成复杂的输出(如图像或转录文本)。

1.3.3 多模态 Transformer 和预训练的 LLM

生成式 AI 最近的进展催生了各种多模态模型:这些模型不仅可以使用文本,还可以使用其他数据类型作为输入,例如音频和图像。文本到图像 Transformer 就是这样的一个例子。DALL-E 2、Imagen 和 Stable Diffusion 都是文本到图像模型,它们因其能够从文本提示中生成高分辨率图像的能力而受到媒体广泛关注。文本到图像 Transformer 结合了扩散模型的原则,涉及一系列转换来逐步增加数据的复杂性。因此,在讨论文本到图像 Transformer 之前,我们首先需要了解扩散模型。

想象一下,你想要通过使用基于扩散的模型生成高分辨率的鲜花图像。首先,你需要获取一组高质量的鲜花图像作为训练集。然后,你让模型逐渐向鲜花图像中添加噪声(所谓的扩散过程),直到它们变成完全随机的噪声。接着,训练模型从这些噪声图像中逐步去除噪声,以生成新的数据样本。扩散过程在图 1.7 中展示。左侧列包含四张原始的鲜花图像。随着我们向右移动,每一步都会向图像中添加一些噪声,直到右侧列,四张图像变成了纯随机噪声。

图片

图 1.7 扩散模型向图像中添加越来越多的噪声,并学习重建它们。左侧列包含四张原始的鲜花图像。随着我们向右移动,每个时间步都会向图像中添加一些噪声,直到右侧列,四张图像变成了纯随机噪声。然后,我们使用这些图像来训练一个基于扩散的模型,逐步从噪声图像中去除噪声,以生成新的数据样本。

你可能会想知道:文本到图像的 Transformer 与扩散模型有何关联?文本到图像的 Transformer 接受一个文本提示作为输入,并生成与该文本描述相对应的图像。文本提示作为一种条件,模型使用一系列神经网络层将文本描述转换为图像。与扩散模型一样,文本到图像的 Transformer 使用具有多个层的分层架构,每个层逐步向生成的图像添加更多细节。迭代优化输出的核心概念在扩散模型和文本到图像的 Transformer 中是相似的,我们将在第十五章中解释这一点。

由于扩散模型能够提供稳定的训练并生成高质量的图像,它们现在变得越来越受欢迎,并且已经超越了其他生成模型,如 GANs 和变分自编码器。在第十五章中,你将首先学习使用牛津鲜花数据集训练一个简单的扩散模型。你还将学习多模态 Transformer 背后的基本思想,并编写一个 Python 程序,通过文本提示请求 OpenAI 的 DALL-E 2 生成图像。例如,当我输入“一个宇航员穿着太空服骑独角兽”作为提示时,DALL-E 2 生成了图 1.8 所示的图像。

图片

图 1.8 通过文本提示“一个宇航员穿着太空服骑独角兽”生成的 DALL-E 2 图像

在第十六章中,你将学习如何访问预训练的大型语言模型(LLM),例如 ChatGPT、GPT4 和 DALL-E 2。这些模型在大量文本数据上进行了训练,并从数据中学习了通用知识。因此,它们可以执行各种下游任务,如文本生成、情感分析、问答和命名实体识别。由于预训练的 LLM 是在几个月前训练的,它们无法提供过去一两个月内的事件和发展的信息,更不用说实时信息,如天气状况、航班状态或股价。我们将使用 LangChain(一个用于构建 LLM 应用的 Python 库,提供提示管理、LLM 链式调用和输出解析的工具)将 LLM 与 Wolfram Alpha 和 Wikipedia API 链式调用,以创建一个无所不知的个人助理。

1.4 为什么需要从头开始构建生成模型?

本书的目标是向你展示如何从头开始构建和训练所有生成模型。这样,你将彻底理解这些模型的内部运作,并能更好地利用它们。从头开始创造是理解它的最佳方式。你将为 GANs 实现这一目标:所有模型,包括 DCGAN 和 CycleGAN,都是从头开始构建并使用公共领域精心整理的数据进行训练的。

对于 Transformers,你将从头开始构建和训练所有模型,除了 LLM。这个例外是由于训练某些 LLM 需要大量的数据和超级计算设施。然而,你将在这一方向上取得重大进展。具体来说,你将在第九章和第十章中逐行实现 2017 年那篇开创性的论文“Attention Is All You Need”,并以英语到法语的翻译为例(相同的 Transformer 可以训练其他数据集,如中文到英语或英语到德语的翻译)。你还将构建一个仅包含解码器的小型 Transformer,并使用欧内斯特·海明威的多部小说,包括《老人与海》进行训练。训练后的模型可以生成海明威风格的文本。ChatGPT 和 GPT-4 对于我们的目的来说太大太复杂,无法从头开始构建和训练,但你将一窥其前身 GPT-2,并学习如何从头开始构建它。你还将从 Hugging Face 提取训练好的权重,并将它们加载到你构建的 GPT-2 模型中,开始生成可以以人类写作方式通过的文字。

在这种意义上,本书采用了比大多数书籍更基础的方法。本书不是将生成 AI 模型视为黑盒,读者有机会揭开盖子,详细检查这些模型的内部运作机制。目标是让你对生成模型有更深入的理解。这反过来又可能帮助你构建更好、更负责任的生成 AI,以下是一些原因。

首先,对生成模型架构的深入了解有助于读者更好地实际应用这些模型。例如,在第五章中,你将学习如何选择生成图像中的特征,如性别特征和是否戴眼镜。通过从头构建条件 GAN,你理解生成图像的某些特征是由潜在空间中的随机噪声向量 Z 决定的。因此,你可以选择不同的 Z 值作为训练模型的输入,以生成所需的特征(如性别特征)。这种属性选择如果不了解模型的设计是难以实现的。

对于 Transformer,了解其架构(以及编码器和解码器的作用)使你能够创建和训练 Transformer 以生成你感兴趣的内容类型(例如,简·奥斯汀风格的小说或莫扎特风格的音乐)。这种理解也有助于你使用预训练的 LLM。例如,虽然从头开始训练具有 15 亿参数的 GPT-2 很困难,但你可以在模型中添加一个额外的层,并对其进行微调以完成其他下游任务,如文本分类、情感分析和问答。

其次,对生成式 AI 的深入了解有助于读者对 AI 的危险性进行无偏评估。虽然生成式 AI 的非凡能力在我们的日常生活和工作中带来了好处,但它也有可能造成巨大的危害。埃隆·马斯克甚至表示,“有一些可能性,它可能会出错并毁灭人类”(参见《The Hill》2023 年的文章,“Musk: There’s a Chance AI ‘Goes Wrong and Destroys Humanity’”,mng.bz/Aaxz)。越来越多的学术界和科技行业的人士担心 AI(尤其是生成式 AI)带来的危险。生成式 AI,尤其是 LLM,可能导致意想不到的后果,正如许多科技行业的先驱所警告的那样(例如,参见 Stuart Russell,2023 年的文章,“How to Stop Runaway AI”,mng.bz/ZVzP)。ChatGPT 发布仅仅五个月后,包括史蒂夫·沃兹尼亚克、特里斯坦·哈里斯、约书亚·本吉奥和山姆·奥特曼在内的许多科技行业专家和企业家签署了一封公开信,呼吁至少暂停六个月训练任何比 GPT-4 更强大的 AI 系统(参见《TechCrunch》的文章,“1,100+ Notable Signatories Just Signed an Open Letter Asking ‘All AI Labs to Immediately Pause for at Least 6 Months’”,mng.bz/RNEK)。对生成模型架构的深入了解有助于我们提供对 AI 的益处和潜在危险的深入和无偏评估。

摘要

  • 生成式 AI 是一种具有产生各种形式新内容的能力的技术,包括文本、图像、代码、音乐、音频和视频。

  • 判别性模型擅长于分配标签,而生成性模型则生成新的数据实例。

  • PyTorch 凭借其动态计算图和 GPU 训练的能力,非常适合深度学习和生成建模。

  • GANs(生成对抗网络)是一种由两个神经网络组成的生成建模方法:一个生成器和一个人工智能判别器。生成器的目标是创建逼真的数据样本,以最大化判别器认为这些样本是真实样本的概率。判别器的目标是正确识别出真实样本中的假样本。

  • 变换器是一种使用注意力机制来识别序列中元素之间长期依赖的深度神经网络。原始的变换器包括编码器和解码器。例如,当用于英语到法语翻译时,编码器将英语句子转换成抽象表示,然后传递给解码器。解码器根据编码器的输出和之前生成的单词,逐词生成法语翻译。

第二章:使用 PyTorch 进行深度学习

本章涵盖

  • PyTorch 张量和基本操作

  • 为 PyTorch 中的深度学习准备数据

  • 使用 PyTorch 构建和训练深度神经网络

  • 使用深度学习进行二进制和多类别分类

  • 创建验证集以决定训练停止点

在本书中,我们将使用深度神经网络生成各种内容,包括文本、图像、形状、音乐等。我假设你已经对机器学习(ML)有一个基础的了解,特别是人工神经网络。在本章中,我将回顾一些基本概念,如损失函数、激活函数、优化器和学习率,这些对于开发和管理深度神经网络至关重要。如果你在这些主题的理解上存在任何空白,我强烈建议你在继续本书的项目之前解决这些问题。附录 B 提供了所需的基本技能和概念的总结,包括人工神经网络的架构和训练。

注意:市面上有很多优秀的机器学习书籍供你选择。例如,包括《动手学习 Scikit-Learn、Keras 和 TensorFlow》(2019 年,O'Reilly)和《机器学习,动画》(2023 年,CRC 出版社)。这两本书都使用 TensorFlow 创建神经网络。如果你更喜欢使用 PyTorch 的书籍,我推荐《使用 PyTorch 的深度学习》(2020 年,Manning Publications)。

生成式 AI 模型经常面临二进制或多类别分类的任务。例如,在生成对抗网络(GANs)中,判别器扮演着二进制分类器的关键角色,其目的是区分生成器创建的假样本和训练集中的真实样本。同样,在文本生成模型的背景下,无论是循环神经网络还是 Transformer,其总体目标都是从大量可能性中预测下一个字符或单词(本质上是一个多类别分类任务)。

在本章中,你将学习如何使用 PyTorch 创建深度神经网络以执行二进制和多类别分类,以便你精通深度学习和分类任务。

具体来说,你将参与一个端到端的 PyTorch 深度学习项目,目标是将服装物品的灰度图像分类到不同的类别,如外套、包、运动鞋、衬衫等。目的是为你准备创建能够执行 PyTorch 中二进制和多类别分类任务的深度神经网络。这将使你为即将到来的章节做好准备,在这些章节中,你将使用 PyTorch 中的深度神经网络来创建各种生成模型。

为了训练生成式 AI 模型,我们利用各种数据格式,如原始文本、音频文件、图像像素和数字数组。在 PyTorch 中创建的深度神经网络不能直接以这些形式的数据作为输入。相反,我们必须首先将它们转换为神经网络理解和接受的形式。具体来说,你将把各种原始数据形式转换为 PyTorch 张量(用于表示和操作数据的基本数据结构),然后再将它们提供给生成式 AI 模型。因此,在本章中,你还将学习数据类型的基础知识、如何创建各种形式的 PyTorch 张量以及如何在深度学习中使用它们。

了解如何执行分类任务在我们的社会中有许多实际应用。分类在医疗保健中广泛用于诊断目的,例如确定患者是否患有特定疾病(例如,基于医学影像或测试结果,对特定癌症的阳性或阴性)。它们在许多商业任务中扮演着至关重要的角色(如股票推荐、信用卡欺诈检测等)。分类任务也是我们日常使用的许多系统和服务的核心,例如垃圾邮件检测和面部识别。

2.1 PyTorch 中的数据类型

本书将使用来自广泛来源和格式的数据集,深度学习的第一步是将输入转换为数字数组。

在本节中,你将了解 PyTorch 如何将不同格式的数据转换为称为 张量 的代数结构。张量可以表示为多维数字数组,类似于 NumPy 数组,但有几个关键区别,其中最重要的是能够进行 GPU 加速训练。根据其最终用途,存在不同类型的张量,你将学习如何创建不同类型的张量以及何时使用每种类型。我们将通过使用 46 位美国总统的高度作为示例来讨论 PyTorch 中的数据结构。

请参阅附录 A 中的说明,在计算机上创建虚拟环境并安装 PyTorch 和 Jupyter Notebook。在虚拟环境中打开 Jupyter Notebook 应用程序,并在新单元格中运行以下代码行:

!pip install matplotlib

此命令将在你的计算机上安装 Matplotlib 库,使你能够在 Python 中绘制图像。

2.1.1 创建 PyTorch 张量

在训练深度神经网络时,我们以数字数组的形式向模型提供输入。根据生成模型试图创建的内容,这些数字有不同的类型。例如,在生成图像时,输入是介于 0 和 255 之间的整数形式的原始像素,但我们将它们转换为介于 –1 和 1 之间的浮点数;在生成文本时,有一个类似于字典的“词汇表”,输入是一个整数序列,告诉你单词对应字典中的哪个条目。

注意:本章代码以及本书其他章节的代码可在本书的 GitHub 仓库中找到:github.com/markhliu/DGAI

假设您想使用 PyTorch 计算美国 46 位总统的平均身高。我们首先收集 46 位美国总统的身高(以厘米为单位),并将它们存储在一个 Python 列表中:

heights = [189, 170, 189, 163, 183, 171, 185,
           168, 173, 183, 173, 173, 175, 178,
           183, 193, 178, 173, 174, 183, 183,
           180, 168, 180, 170, 178, 182, 180,
           183, 178, 182, 188, 175, 179, 183,
           193, 182, 183, 177, 185, 188, 188,
           182, 185, 191, 183]

这些数字按时间顺序排列:列表中的第一个值 189 表示美国第一任总统乔治·华盛顿身高 189 厘米。最后一个值显示乔·拜登的身高为 183 厘米。我们可以通过使用 PyTorch 中的 tensor() 方法将 Python 列表转换为 PyTorch 张量:

import torch
heights_tensor = torch.tensor(heights,      ①
           dtype=torch.float64)             ②

① 将 Python 列表转换为 PyTorch 张量

② 在 PyTorch 张量中指定数据类型

我们在 tensor() 方法中使用 dtype 参数指定数据类型。PyTorch 张量的默认数据类型是 float32,即 32 位浮点数。在前面的代码单元中,我们将数据类型转换为 float64,双精度浮点数。float64 提供比 float32 更精确的结果,但计算时间更长。精度和计算成本之间存在权衡。使用哪种数据类型取决于手头的任务。

表 2.1 列出了不同的数据类型以及相应的 PyTorch 张量类型。这些包括具有不同精度的整数和浮点数。整数也可以是有符号或无符号的。

表 2.1 PyTorch 中的数据和张量类型

PyTorch 张量类型 tensor() 方法中的 dtype 参数 数据类型
FloatTensor torch.float32 or torch.float 32 位浮点数
HalfTensor torch.float16 or torch.half 16 位浮点数
DoubleTensor torch.float64 or torch.double 64 位浮点数
CharTensor torch.int8 8 位整数(有符号)
ByteTensor torch.uint8 8 位整数(无符号)
ShortTensor torch.int16 or torch.short 16 位整数(有符号)
IntTensor torch.int32 or torch.int 32 位整数(有符号)
LongTensor torch.int64 or torch.long 64 位整数(有符号)

您可以通过两种方式之一创建具有特定数据类型的张量。第一种方式是使用表 2.1 的第一列中指定的 PyTorch 类。第二种方式是使用 torch.tensor() 方法,并通过 dtype 参数指定数据类型(该参数的值列在表 2.1 的第二列中)。例如,要将 Python 列表 [1, 2, 3] 转换为包含 32 位整数的 PyTorch 张量,您可以在以下列表中的两种方法中选择一种。

列表 2.1 指定张量类型的两种方法

t1=torch.IntTensor([1, 2, 3])    ①
t2=torch.tensor([1, 2, 3],
             dtype=torch.int)    ②
print(t1)
print(t2)

① 使用 torch.IntTensor() 指定张量类型

② 使用 dtype=torch.int 指定张量类型

这导致以下输出:

tensor([1, 2, 3], dtype=torch.int32)
tensor([1, 2, 3], dtype=torch.int32)

练习 2.1

使用两种不同的方法将 Python 列表[5, 8, 10]转换为包含 64 位浮点数的 PyTorch 张量。请参考表 2.1 的第三行来回答这个问题。

许多时候,你需要创建一个所有值都为 0 的 PyTorch 张量。例如,在生成对抗网络(GANs)中,我们创建一个全零张量作为伪造样本的标签,正如你在第三章中将会看到的。PyTorch 中的zeros()方法可以生成具有特定形状的全零张量。在 PyTorch 中,张量是一个 n 维数组,其形状是一个元组,表示其每个维度的尺寸。以下代码行生成一个具有两行三列的全零张量:

tensor1 = torch.zeros(2, 3)
print(tensor1)

输出结果如下

tensor([[0., 0., 0.],
        [0., 0., 0.]])

该张量的形状为(2,3),这意味着该张量是一个 2D 数组;第一维有两个元素,第二维有三个元素。在这里,我们没有指定数据类型,输出默认数据类型为float32*

不时地,你需要创建一个所有值都为 1 的 PyTorch 张量。例如,在 GANs 中,我们创建一个全 1 张量作为真实样本的标签。在这里,我们使用ones()方法创建一个所有值都为 1 的 3D 张量:

tensor2 = torch.ones(1,4,5)
print(tensor2)

输出结果如下

tensor([[[1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.]]])

我们已经生成了一个 3D PyTorch 张量。张量的形状为(1,4,5)。

练习 2.2

创建一个所有值为 0 的 3D PyTorch 张量。张量的形状为(2,3,4)。

你也可以在张量构造函数中使用 NumPy 数组代替 Python 列表:

import numpy as np

nparr=np.array(range(10))
pt_tensor=torch.tensor(nparr, dtype=torch.int)
print(pt_tensor)

输出结果如下

tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=torch.int32)

2.1.2 索引和切片 PyTorch 张量

我们使用方括号([ ])来索引和切片 PyTorch 张量,就像我们使用 Python 列表一样。索引和切片使我们能够对一个或多个张量元素进行操作,而不是对所有元素进行操作。为了继续我们关于 46 位美国总统身高的例子,如果我们想评估第三位总统托马斯·杰斐逊的身高,我们可以这样做:

height = heights_tensor[2]
print(height)

这导致输出结果如下

tensor(189., dtype=torch.float64)

输出结果显示,托马斯·杰斐逊的身高为 189 厘米。

我们可以使用负索引从张量的后端进行计数。例如,为了找到列表中倒数第二位总统唐纳德·特朗普的身高,我们使用索引-2:

height = heights_tensor[-2]
print(height)

输出结果如下

tensor(191., dtype=torch.float64)

输出结果显示,特朗普的身高为 191 厘米。

如果我们想知道heights_tensor张量中最近五位总统的身高怎么办?我们可以获取张量的一部分:

five_heights = heights_tensor[-5:]
print(five_heights)

冒号(:)用于分隔起始和结束索引。如果没有提供起始索引,则默认为 0;如果没有提供结束索引,则包括张量中的最后一个元素(正如我们在前面的代码单元中所做的那样)。负索引意味着从后向前计数。输出结果如下

tensor([188., 182., 185., 191., 183.], dtype=torch.float64)

结果显示,张量中的五位最近总统(克林顿、布什、奥巴马、特朗普和拜登)的身高分别为 188、182、185、191 和 183 厘米。

练习 2.3

使用切片从heights_tensor张量中获取前五位美国总统的身高。

2.1.3 PyTorch 张量形状

PyTorch 张量有一个名为shape的属性,它告诉我们张量的维度。了解 PyTorch 张量的形状非常重要,因为不匹配的形状会导致我们在操作它们时出错。例如,如果我们想找出张量heights_tensor的形状,我们可以这样做:

print(heights_tensor.shape)

输出如下

torch.Size([46])

这告诉我们heights_tensor是一个包含 46 个值的 1D 张量。

您还可以更改 PyTorch 张量的形状。要了解如何操作,让我们首先将高度从厘米转换为英尺。由于一英尺大约是 30.48 厘米,我们可以通过将张量除以 30.48 来完成此操作:

heights_in_feet = heights_tensor / 30.48
print(heights_in_feet)

这导致以下输出(为了节省空间,我省略了一些值;完整的输出在本书的 GitHub 仓库中):

tensor([6.2008, 5.5774, 6.2008, 5.3478, 6.0039, 5.6102, 6.0696, …
        6.0039], dtype=torch.float64)

新的张量heights_in_feet用于存储以英尺为单位的高度。例如,张量中的最后一个值显示乔·拜登身高为 6.0039 英尺。

我们可以使用 PyTorch 中的cat()方法连接两个张量:

heights_2_measures = torch.cat(
    [heights_tensor,heights_in_feet], dim=0)
print(heights_2_measures.shape)

dim参数在多种张量操作中用于指定要执行操作的维度。在前面的代码单元格中,dim=0表示我们沿着第一个维度连接两个张量。这导致以下输出:

torch.Size([92])

结果张量是 1D 的,包含 92 个值,其中一些值以厘米为单位,其他以英尺为单位。因此,我们需要将其重塑为两行 46 列,以便第一行表示厘米单位的高度,第二行表示英尺单位的高度:

heights_reshaped = heights_2_measures.reshape(2, 46)

新的张量heights_reshaped是 2D 的,形状为(2, 46)。我们可以使用方括号索引和切片多维张量。例如,为了打印出特朗普的身高(以英尺为单位),我们可以这样做:

print(heights_reshaped[1,-2])

这导致以下结果

tensor(6.2664, dtype=torch.float64)

命令heights_reshaped[1,-2]告诉 Python 查找第二行和倒数第二列的值,这返回了特朗普的身高,6.2664 英尺。

提示:引用张量内标量值所需的索引数量与张量的维度性相同。这就是为什么我们在 1D 张量heights_tensor中只使用一个索引来定位值,但在 2D 张量heights_reshaped中使用了两个索引来定位值。

练习 2.4

使用索引从张量heights_reshaped中获取乔·拜登的身高(以厘米为单位)。

2.1.4 PyTorch 张量上的数学运算

我们可以通过使用不同的方法如mean()median()sum()max()等在 PyTorch 张量上执行数学运算。例如,为了找到 46 位总统的中位身高(以厘米为单位),我们可以这样做:

print(torch.median(heights_reshaped[0,:]))

代码片段heights_reshaped[0,:]返回张量heights_reshaped的第一行和所有列。前面的代码行返回第一行中的中位值,这导致以下输出

tensor(182., dtype=torch.float64)

这意味着美国总统的中位身高为 182 厘米。

要找到两行中的平均身高,我们可以在mean()方法中使用dim=1参数:

print(torch.mean(heights_reshaped,dim=1))

dim=1 参数表示通过折叠列(索引为 1 的维度),实际上是在索引为 0 的维度(行)上计算平均值。输出是

tensor([180.0652,   5.9077], dtype=torch.float64)

结果显示,两行中的平均值分别是 180.0652 厘米和 5.9077 英尺。

要找出最高的总统,我们可以这样做:

values, indices = torch.max(heights_reshaped, dim=1)
print(values)
print(indices)

输出是

tensor([193.0000,   6.3320], dtype=torch.float64)
tensor([15, 15])

torch.max()方法返回两个张量:一个包含最高总统身高的张量values(以厘米和英尺为单位),以及一个包含身高最高的总统索引的张量indices。结果显示,第 16 任总统(林肯)是最高的,身高为 193 厘米,或 6.332 英尺。

练习 2.5

使用torch.min()方法找出最矮的美国总统的索引和身高。

2.2 使用 PyTorch 的端到端深度学习项目

在接下来的几节中,你将通过 PyTorch 完成一个深度学习项目的示例,学习将服装物品的灰度图像分类为 10 种类型中的 1 种。在本节中,我们将首先提供一个涉及步骤的高级概述。然后,我们将讨论如何获取这个项目的训练数据以及如何预处理数据。

2.2.1 PyTorch 中的深度学习:高级概述

在这个项目中,我们的任务是创建并训练一个 PyTorch 深度神经网络,以对服装物品的灰度图像进行分类。图 2.1 提供了涉及步骤的示意图。

图片

图 2.1 深度学习模型训练涉及的步骤

首先,我们将获取如图 2.1 左侧所示的灰度服装图像数据集。图像是原始像素,我们将它们转换为浮点数形式的 PyTorch 张量(步骤 1)。每张图像都附有标签。

然后,我们将在 PyTorch 中创建一个深度神经网络,如图 2.1 中心的所示。本书中的一些神经网络涉及卷积神经网络(CNNs)。对于这个简单的分类问题,我们目前只使用密集层。

我们将为多类别分类选择一个损失函数,交叉熵损失通常用于这项任务。交叉熵损失衡量预测概率分布与标签真实分布之间的差异。我们将在训练过程中使用 Adam 优化器(梯度下降算法的一种变体)来更新网络的权重。我们将学习率设置为 0.001。学习率控制模型权重在训练过程中相对于损失梯度的调整程度。

机器学习中的优化器

机器学习中的优化器是算法,根据梯度信息更新模型参数以最小化损失函数。随机梯度下降(SGD)是最基本的优化器,它使用基于损失梯度的简单更新。Adam 是最受欢迎的优化器,以其效率和即插即用的性能而闻名,因为它结合了自适应梯度算法(AdaGrad)和根均方传播(RMSProp)的优点。尽管它们有所不同,但所有优化器都旨在迭代调整参数以最小化损失函数,每个优化器都创建一条独特的优化路径以达到这一目标。

我们将把训练数据分成训练集和验证集。在机器学习中,我们通常使用验证集来提供模型的无偏评估,并选择最佳超参数,如学习率、训练 epoch 数等。验证集还可以用来避免模型过拟合,即模型在训练集中表现良好,但在未见过的数据上表现不佳。一个 epoch 是指所有训练数据被用来训练模型一次且仅一次。

在训练过程中,您将遍历训练数据。在正向传播过程中,您将图像通过网络以获得预测(步骤 2)并计算损失,通过比较预测标签与实际标签(步骤 3;见图 2.1 右侧)。然后,您将通过网络反向传播梯度以更新权重。这就是学习发生的地方(步骤 4),如图 2.1 底部所示。

您将使用验证集来确定何时停止训练。我们在验证集中计算损失。如果模型在固定数量的 epoch 后停止改进,我们认为模型已训练完成。然后,我们在测试集上评估训练好的模型,以评估其在将图像分类到不同标签方面的性能。

现在您已经对 PyTorch 中的深度学习有了高级概述,让我们深入到端到端项目吧!

2.2.2 数据预处理

在这个项目中,我们将使用 Fashion Modified National Institute of Standards and Technology (MNIST)数据集。在这个过程中,您将学习如何使用 Torchvision 库中的datasetstransforms包,以及 PyTorch 中的Dataloader包,这些包将帮助您在本书的其余部分。您将使用这些工具在本书中预处理数据。Torchvision 库提供了图像处理工具,包括流行的数据集、模型架构和深度学习应用中的常见图像转换。

我们首先导入所需的库并在transforms包中实例化一个Compose()类,以将原始图像转换为 PyTorch 张量。

列表 2.2 将原始图像数据转换为 PyTorch 张量

import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as T

torch.manual_seed(42)
transform=T.Compose([             ①
    T.ToTensor(),                 ②
    T.Normalize([0.5],[0.5])])    ③

① 将多个转换组合在一起

② 将图像像素转换为 PyTorch 张量

③ 将值归一化到范围[–1, 1]

我们使用 PyTorch 中的 manual_seed() 方法来固定随机状态,以便结果可重现。Torchvision 中的 transforms 包可以帮助创建一系列转换以预处理图像。ToTensor() 类将图像数据(无论是 Python Imaging Library (PIL) 图像格式还是 NumPy 数组)转换为 PyTorch 张量。特别是,图像数据是介于 0 到 255 之间的整数,而 ToTensor() 类将它们转换为介于 0.0 和 1.0 范围内的浮点张量。

Normalize() 类使用 n 个通道的平均值和标准差对张量图像进行归一化。Fashion MNIST 数据是服装项目的灰度图像,因此只有一个颜色通道。在本书的后面部分,我们将处理具有三个不同颜色通道(红色、绿色和蓝色)的图像。在先前的代码单元中,Normalize([0.5],[0.5]) 表示从数据中减去 0.5,并将差值除以 0.5。结果图像数据范围从 –1 到 1。将输入数据归一化到 [–1, 1] 范围允许梯度下降在维度上保持更均匀的步长,这有助于在训练过程中更快地收敛。您将在本书中经常这样做。

注意:列表 2.2 中的代码仅定义了数据转换过程。它不执行实际的转换,这将在下一个代码单元中发生。

接下来,我们使用 Torchvision 中的 datasets 包将数据集下载到您的计算机上的一个文件夹,并执行转换:

train_set=torchvision.datasets.FashionMNIST(    ①
    root=".",                                   ②
    train=True,                                 ③
    download=True,                              ④
    transform=transform)                        ⑤
test_set=torchvision.datasets.FashionMNIST(root=".",
    train=False,download=True,transform=transform)

① 下载哪个数据集

② 数据保存的位置

③ 训练或测试数据集

④ 是否将数据下载到您的计算机上

⑤ 执行数据转换

您可以打印出训练集中的第一个样本:

print(train_set[0])

第一个样本包含一个具有 784 个值的张量和标签 9。784 个数字代表一个 28×28 的灰度图像(28 × 28 = 784),标签 9 表示这是一双短靴。您可能想知道:您如何知道标签 9 表示短靴?数据集中有 10 种不同的服装项目。数据集中的标签从 0 到 9 编号。您可以在网上搜索并找到 10 个类别的文本标签(例如,我在这里找到了文本标签 github.com/pranay414/Fashion-MNIST-Pytorch)。text_labels 列表包含与数值标签 0 到 9 对应的 10 个文本标签。例如,如果数据集中的项目具有数值标签 0,则相应的文本标签是“T 恤”。text_labels 列表定义如下:

text_labels=['t-shirt', 'trouser', 'pullover', 'dress', 'coat',
             'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']

我们可以绘制数据以可视化数据集中的服装项目。

列表 2.3 可视化服装项目

!pip install matplotlib
import matplotlib.pyplot as plt

plt.figure(dpi=300,figsize=(8,4))
for i in range(24):
    ax=plt.subplot(3, 8, i + 1)                 ①
    img=train_set[i][0]                         ②
    img=img/2+0.5                               ③
    img=img.reshape(28, 28)                     ④
    plt.imshow(img,
               cmap="binary")
    plt.axis('off')
    plt.title(text_labels[train_set[i][1]],     ⑤
        fontsize=8)
plt.show()

① 图像放置的位置

② 从训练数据中获取第 i 个图像

③ 将值从 [–1,1] 转换为 [0,1]

④ 将图像重塑为 28×28

⑤ 为每张图像添加文本标签

图 2.2 中的图表显示了 24 种服装项目,如大衣、套头衫、凉鞋等。

图 2.2 Fashion MNIST 数据集中服装项目的灰度图像。

在接下来的两节中,你将学习如何使用 PyTorch 创建深度神经网络,以执行二进制和多类别分类问题。

2.3 二进制分类

在本节中,我们将首先为训练创建数据批次。然后,我们使用 PyTorch 构建一个深度神经网络用于此目的,并使用数据训练模型。最后,我们将使用训练好的模型进行预测并测试预测的准确性。二进制和多类别分类涉及到的步骤相似,但有几个显著的例外,我将在后面强调。

2.3.1 创建批次

我们将创建一个只包含两种服装类型(T 恤和踝靴)的训练集和测试集。(在本章后面讨论多类别分类时,你还将学习如何创建验证集以确定何时停止训练。)以下代码单元实现了该目标:

binary_train_set=[x for x in train_set if x[1] in [0,9]]
binary_test_set=[x for x in test_set if x[1] in [0,9]]

我们只保留标签为 0 和 9 的样本,以创建一个具有平衡训练集的二进制分类问题。接下来,我们为训练深度神经网络创建批次。

列表 2.4 创建训练和测试的批次

batch_size=64
binary_train_loader=torch.utils.data.DataLoader(
    binary_train_set,                                ①
    batch_size=batch_size,                           ②
    shuffle=True)                                    ③
binary_test_loader=torch.utils.data.DataLoader(
    binary_test_set,                                 ④
    batch_size=batch_size,shuffle=True)

① 为二进制训练集创建批次

② 每个批次中的样本数量

③ 在批量处理时打乱观察值

④ 为二进制测试集创建批次

PyTorch utils 包中的 DataLoader 类帮助批量创建数据迭代器。我们将批大小设置为 64。在列表 2.4 中,我们创建了两个数据加载器:用于二进制分类的训练集和测试集。在创建批次时,我们随机打乱观察值,以避免原始数据集中的相关性:如果数据加载器中的标签分布均匀,则训练将更加稳定。

2.3.2 构建和训练二进制分类模型

我们首先创建一个二进制分类模型。然后,我们使用 T 恤和踝靴的图像来训练模型。一旦训练完成,我们将看看模型能否区分 T 恤和踝靴。我们使用 PyTorch 的 nn.Sequential 类(在后面的章节中,你还将学习如何使用 nn.Module 类来创建 PyTorch 神经网络)创建以下神经网络。

列表 2.5 创建二进制分类模型

import torch.nn as nn

device="cuda" if torch.cuda.is_available() else "cpu"  ①

binary_model=nn.Sequential(                            ②
    nn.Linear(28*28,256),                              ③
    nn.ReLU(),                                         ④
    nn.Linear(256,128),
    nn.ReLU(),
    nn.Linear(128,32),
    nn.ReLU(),
    nn.Linear(32,1),
    nn.Dropout(p=0.25),
    nn.Sigmoid()).to(device)                           ⑤

① PyTorch 自动检测是否有可用的 CUDA 启用 GPU。

② 在 PyTorch 中创建一个顺序神经网络

③ 线性层中的输入和输出神经元数量

④ 对层的输出应用 ReLU 激活函数

⑤ 应用 sigmoid 激活函数并将模型移动到可用的 GPU 上

PyTorch 中的Linear()类创建了对传入数据的线性变换。这实际上在神经网络中创建了一个密集层。输入形状是 784,因为我们稍后将 2D 图像展平成一个包含 28 × 28 = 784 个值的 1D 向量。我们将 2D 图像展平成一个 1D 张量,因为密集层只接受 1D 输入。在后面的章节中,你会看到当你使用卷积层时,不需要展平图像。网络中有三个隐藏层,分别包含 256、128 和 32 个神经元。256、128 和 32 这些数字的选择是相当随意的:将它们改为,比如说,300、200 和 50,不会影响训练过程。我们在三个隐藏层上应用了 ReLU 激活函数。ReLU 激活函数根据加权的总和决定是否激活神经元。这些函数向神经元的输出引入非线性,从而使网络能够学习输入和输出之间的非线性关系。ReLU 是除了极少数例外情况外你首选的激活函数,你将在后面的章节中遇到一些其他的激活函数。

模型的最后一层的输出包含一个单一值,我们使用 sigmoid 激活函数将数值压缩到[0, 1]的范围内,这样它可以被解释为该物体是踝靴的概率。通过互补概率,该物体是 T 恤。

在这里,我们设置学习率并定义优化器和损失函数:

lr=0.001
optimizer=torch.optim.Adam(binary_model.parameters(),lr=lr)
loss_fn=nn.BCELoss()

我们将学习率设置为 0.001。设置学习率是一个经验问题,答案来自于经验。它也可以通过使用验证集进行超参数调整来确定。PyTorch 中的大多数优化器使用默认的学习率 0.001。Adam 优化器是梯度下降算法的一种变体,用于确定在每一步训练中应调整多少模型参数。Adam 优化器首次由 Diederik Kingma 和 Jimmy Ba 于 2014 年提出。1 在传统的梯度下降算法中,只考虑当前迭代的梯度。相比之下,Adam 优化器还考虑了之前迭代的梯度。

我们使用nn.BCELoss(),这是二元交叉熵损失函数。损失函数衡量 ML 模型的表现。模型的训练涉及调整参数以最小化损失函数。二元交叉熵损失函数在 ML 中广泛使用,尤其是在二元分类问题中。它衡量的是输出为 0 到 1 之间概率值的分类模型的表现。当预测概率偏离实际标签时,交叉熵损失会增加。

我们按照以下列表训练我们刚刚创建的神经网络。

列表 2.6 训练二元分类模型

for i in range(50):                                    ①
    tloss=0
    for imgs,labels in binary_train_loader:            ②
        imgs=imgs.reshape(-1,28*28)                    ③
        imgs=imgs.to(device)
        labels=torch.FloatTensor(\
          [x if x==0 else 1 for x in labels])          ④
        labels=labels.reshape(-1,1).to(device)
        preds=binary_model(imgs)    
        loss=loss_fn(preds,labels)                     ⑤
        optimizer.zero_grad()
        loss.backward()                                ⑥
        optimizer.step()
        tloss+=loss.detach()
    tloss=tloss/n
    print(f"at epoch {i}, loss is {tloss}")

① 训练 50 个 epoch

② 遍历所有批次

③ 在将张量移动到 GPU 之前将图像展平

④ 将标签转换为 0 和 1

⑤ 计算损失

⑥ 反向传播

在 PyTorch 中训练深度学习模型时,loss.backward()计算损失相对于每个模型参数的梯度,从而实现反向传播,而optimizer.step()根据这些计算的梯度更新模型参数以最小化损失。为了简单起见,我们训练模型 50 个 epoch(一个 epoch 是指使用训练数据训练模型一次)。在下一节中,你将使用验证集和提前停止类来确定训练的 epoch 数。在二分类中,我们将目标标签标记为 0 和 1。由于我们只保留了标签为 0 和 9 的 T 恤和踝靴,我们在列表 2.6 中将它们转换为 0 和 1。因此,两种服装类别的标签分别为 0 和 1。

如果你使用 GPU 进行训练,这个过程可能需要几分钟。如果你使用 CPU 进行训练,则所需时间更长,但训练时间应该不到一小时。

2.3.3 测试二分类模型

训练好的二分类模型的预测结果是一个介于 0 和 1 之间的数字。我们将使用torch.where()方法将预测结果转换为 0 和 1:如果预测概率小于 0.5,我们将预测结果标记为 0;否则,我们将预测结果标记为 1。然后我们将这些预测结果与实际标签进行比较,以计算预测的准确率。在下面的列表中,我们使用训练好的模型对测试数据集进行预测。

列表 2.7 计算预测的准确率

import numpy as np
results=[]
for imgs,labels in binary_test_loader:                     ①
    imgs=imgs.reshape(-1,28*28).to(device)
    labels=(labels/9).reshape(-1,1).to(device)
    preds=binary_model(imgs)
    pred10=torch.where(preds>0.5,1,0)                      ②
    correct=(pred10==labels)                               ③
    results.append(correct.detach().cpu()\
      .numpy().mean())                                     ④
accuracy=np.array(results).mean()                          ⑤
print(f"the accuracy of the predictions is {accuracy}")

① 遍历测试集中的所有批次

② 使用训练好的模型进行预测

③ 将预测与标签进行比较

④ 在批次中计算准确率

⑤ 在测试集中计算准确率

我们遍历测试集中所有批次的数据。训练好的模型会输出一个概率,表示图像是否为踝靴。然后我们根据 0.5 的截止值,使用torch.where()方法将概率转换为 0 或 1。转换后,预测结果要么是 0(即 T 恤),要么是 1(踝靴)。我们将预测结果与实际标签进行比较,看模型正确预测的次数。结果显示,在测试集中预测的准确率为 87.84%。

2.4 多分类分类

在本节中,我们将使用 PyTorch 构建一个深度神经网络来将服装项目分类到 10 个类别之一。然后我们将使用 Fashion MNIST 数据集训练模型。最后,我们将使用训练好的模型进行预测,并查看其准确率。我们首先创建一个验证集并定义一个提前停止类,以便我们可以确定何时停止训练。

2.4.1 验证集和提前停止

当我们构建和训练一个深度神经网络时,有许多超参数可以选择(例如学习率和训练的 epoch 数)。这些超参数影响模型的性能。为了找到最佳超参数,我们可以创建一个验证集来测试不同超参数下模型的性能。

为了给您一个例子,我们将在多类别分类中创建一个验证集,以确定训练的最佳 epoch 数。我们之所以在验证集而不是在训练集中这样做,是为了避免过拟合,即模型在训练集中表现良好,但在样本外测试(即未见过的数据)中表现不佳。

在这里,我们将训练数据集的 60,000 个观测值分为一个训练集和一个验证集:

train_set,val_set=torch.utils.data.random_split(\
    train_set,[50000,10000])

原始训练集现在变成了两个集合:包含 50,000 个观测值的新训练集和包含剩余 10,000 个观测值的验证集。

我们使用 PyTorch utils包中的DataLoader类将训练集、验证集和测试集转换为三个数据迭代器,以批量形式:

train_loader=torch.utils.data.DataLoader(
    train_set,    
    batch_size=batch_size,   
    shuffle=True)   
val_loader=torch.utils.data.DataLoader(
    val_set,    
    batch_size=batch_size,   
    shuffle=True)
test_loader=torch.utils.data.DataLoader(
    test_set,    
    batch_size=batch_size,   
    shuffle=True)

接下来,我们定义一个EarlyStop()类并创建该类的实例。

列表 2.8 EarlyStop()类用于确定何时停止训练

class EarlyStop:
    def __init__(self, patience=10):         ①
        self.patience = patience
        self.steps = 0
        self.min_loss = float('inf')
    def stop(self, val_loss):                ②
        if val_loss < self.min_loss:         ③
            self.min_loss = val_loss
            self.steps = 0
        elif val_loss >= self.min_loss:      ④
            self.steps += 1
        if self.steps >= self.patience:
            return True
        else:
            return False
stopper=EarlyStop()

① 将 patience 的默认值设置为 10

② 定义了 stop()方法

③ 如果达到新的最小损失,则更新 min_loss 的值

④ 计算自上次最小损失以来的 epoch 数

EarlyStop()类用于确定验证集中的损失在最后patience=10个 epoch 中是否停止了改进。我们将patience参数的默认值设置为 10,但您在实例化类时可以选择不同的值。patience的值衡量了自上次模型达到最小损失以来您希望训练的 epoch 数。stop()方法记录最小损失和自最小损失以来的 epoch 数,并将该数字与patience的值进行比较。如果自最小损失以来的 epoch 数大于patience的值,则该方法返回True

2.4.2 构建和训练一个多类别分类模型

Fashion MNIST 数据集包含 10 种不同的服装类别。因此,我们创建一个多类别分类模型来对它们进行分类。接下来,您将学习如何创建这样的模型并对其进行训练。您还将学习如何使用训练好的模型进行预测并评估预测的准确性。在下面的列表中,我们使用 PyTorch 创建多类别分类的神经网络。

列表 2.9 创建一个多类别分类模型

model=nn.Sequential(
    nn.Linear(28*28,256),
    nn.ReLU(),
    nn.Linear(256,128),
    nn.ReLU(),
    nn.Linear(128,64),
    nn.ReLU(),
    nn.Linear(64,10)                         ①
    ).to(device)                             ②

① 输出层有 10 个神经元。

② 输出层不应用 softmax 激活

与上一节中我们创建的二分类模型相比,这里做了一些修改。首先,输出现在有 10 个值,代表数据集中 10 种不同的服装类型。其次,我们将最后一隐藏层的神经元数量从 32 改为 64。创建深度神经网络的一个经验法则是从一层到下一层逐渐增加或减少神经元数量。由于输出神经元的数量从二分类中的 1 增加到多类别分类中的 10,我们将第二到最后一层的神经元数量从 32 改为 64 以匹配增加。然而,64 这个数字并没有什么特殊之处:如果你在第二到最后一层使用,比如说,100 个神经元,你将得到相似的结果。

我们将使用 PyTorch 的nn.CrossEntropyLoss()类作为我们的损失函数,该函数将nn.LogSoftmax()nn.NLLLoss()合并到一个单独的类中。有关详细信息,请参阅此处文档:mng.bz/pxd2。特别是,文档中提到,“此标准计算输入 logits 和目标之间的交叉熵损失。”这解释了为什么我们在前面的列表中没有应用 softmax 激活。在本书的 GitHub 仓库中,我演示了如果我们使用nn.LogSoftmax()在模型中,并使用nn.NLLLoss()作为损失函数,我们将获得相同的结果。

因此,nn.CrossEntropyLoss()类将在对输出应用 softmax 激活函数之前,将 10 个数字压缩到[0, 1]的范围内进行对数运算。在二分类中,输出上首选的激活函数是 sigmoid,而在多类别分类中是 softmax。此外,softmax 激活后的 10 个数字加起来等于 1,这可以解释为对应于 10 种不同服装项目的概率。我们将使用与上一节二分类中相同的学习率和优化器。

lr=0.001
optimizer=torch.optim.Adam(model.parameters(),lr=lr)
loss_fn=nn.CrossEntropyLoss()

我们定义train_epoch()如下:

def train_epoch():
    tloss=0
    for n,(imgs,labels) in enumerate(train_loader):    
        imgs=imgs.reshape(-1,28*28).to(device)
        labels=labels.reshape(-1,).to(device)
        preds=model(imgs)    
        loss=loss_fn(preds,labels)
        optimizer.zero_grad()
        loss.backward()    
        optimizer.step()
        tloss+=loss.detach()
    return tloss/n

该函数用于训练模型一个 epoch。代码与我们之前在二分类中看到的是相似的,只是标签从 0 到 9,而不是两个数字(0 和 1)。

我们还定义了一个val_epoch()函数:

def val_epoch():
    vloss=0
    for n,(imgs,labels) in enumerate(val_loader):    
        imgs=imgs.reshape(-1,28*28).to(device)
        labels=labels.reshape(-1,).to(device)
        preds=model(imgs)    
        loss=loss_fn(preds,labels)    
        vloss+=loss.detach()
    return vloss/n

该函数使用模型对验证集中的图像进行预测,并计算数据批次平均损失。

我们现在训练多类别分类器:

for i in range(1,101):    
    tloss=train_epoch()
    vloss=val_epoch()
    print(f"at epoch {i}, tloss is {tloss}, vloss is {vloss}")
    if stopper.stop(vloss)==True:             
        break  

我们最多训练 100 个 epoch。在每个 epoch 中,我们首先使用训练集来训练模型。然后我们计算验证集中每个批次的平均损失。我们使用EarlyStop()类通过查看验证集中的损失来确定是否应该停止训练。如果损失在最后 10 个 epoch 中没有改善,则停止训练。经过 19 个 epoch 后,训练停止。

如果你使用 GPU 进行训练,训练大约需要 5 分钟,这比二分类中的训练过程要长,因为我们现在训练集中的观察结果更多(10 种服装而不是只有 2 种)。

模型的输出是一个包含 10 个数字的向量。我们使用torch.argmax()根据最高概率为每个观察结果分配一个标签。然后,我们将预测标签与实际标签进行比较。为了说明预测是如何工作的,让我们看看测试集中前五张图像的预测结果。

列表 2.10 在五张图像上测试训练好的模型

plt.figure(dpi=300,figsize=(5,1))
for i in range(5):                                          ①
    ax=plt.subplot(1,5, i + 1)
    img=test_set[i][0]    
    label=test_set[i][1]
    img=img/2+0.5    
    img=img.reshape(28, 28)    
    plt.imshow(img, cmap="binary")
    plt.axis('off')
    plt.title(text_labels[label]+f"; {label}", fontsize=8)
plt.show()
for i in range(5):
    img,label = test_set[i]                                 ②
    img=img.reshape(-1,28*28).to(device)
    pred=model(img)                                         ③
    index_pred=torch.argmax(pred,dim=1)                     ④
    idx=index_pred.item()
    print(f"the label is {label}; the prediction is {idx}") ⑤ 

① 在测试集中绘制前五张图像及其标签

② 获取测试集中的第 i 张图像和标签

③ 使用训练好的模型进行预测

④ 使用 torch.argmax()方法获取预测标签

⑤ 打印出实际标签和预测标签

我们以 1 × 5 的网格形式绘制测试集中的前五种服装。然后,我们使用训练好的模型对每种服装进行预测。预测结果是一个包含 10 个值的张量。torch.argmax()方法返回张量中最高概率的位置,我们将其用作预测标签。最后,我们打印出实际标签和预测标签以进行比较,看看预测是否正确。运行前面的代码列表后,你应该会看到图 2.3 中的图像。

图 2.3 测试数据集中的前五种服装及其相应的标签。每种服装都有一个文本标签和一个介于 0 到 9 之间的数字标签。

图 2.3 显示了测试集中的前五种服装分别是踝靴、套头衫、裤子、裤子和外套,分别对应数字标签 9、2、1、1 和 6。

运行列表 2.10 中的代码后的输出如下:

the label is 9; the prediction is 9
the label is 2; the prediction is 2
the label is 1; the prediction is 1
the label is 1; the prediction is 1
the label is 6; the prediction is 6

前面的输出显示,模型对所有五种服装都做出了正确的预测。

在 PyTorch 中固定随机状态

torch.manual_seed()方法固定随机状态,以便当你重新运行程序时结果相同。然而,即使你使用相同的随机种子,你也可能得到与本章中报告的不同结果。这是因为不同的硬件和不同的 PyTorch 版本在处理浮点运算时略有不同。例如,请参阅mng.bz/RNva中的解释。尽管如此,这种差异通常很小,因此无需惊慌。

接下来,我们计算在整个测试数据集上的预测准确率。

列表 2.11 测试训练好的多类别分类模型

results=[]

for imgs,labels in test_loader:                             ①
    imgs=imgs.reshape(-1,28*28).to(device)
    labels=(labels).reshape(-1,).to(device)
    preds=model(imgs)                                       ②
    pred10=torch.argmax(preds,dim=1)                        ③
    correct=(pred10==labels)                                ④
    results.append(correct.detach().cpu().numpy().mean())

accuracy=np.array(results).mean()                           ⑤
print(f"the accuracy of the predictions is {accuracy}") 

① 遍历测试集中的所有批次

② 使用训练好的模型进行预测

③ 将概率转换为预测标签

④ 将预测标签与实际标签进行比较

⑤ 计算测试集中的准确率

输出是

the accuracy of the predictions is 0.8819665605095541

我们遍历测试集中的所有服装项目,并使用训练好的模型进行预测。然后我们将预测结果与实际标签进行比较。在样本外测试中的准确率约为 88%。考虑到随机猜测的准确率约为 10%,88%的准确率相当高。这表明我们在 PyTorch 中构建并训练了两个成功的深度学习模型!你将在本书后面的内容中经常使用这些技能。例如,在第三章中,你将构建的判别网络本质上是一个二元分类模型,类似于本章中你创建的模型。

摘要

  • 在 PyTorch 中,我们使用张量来存储各种形式的输入数据,以便我们可以将它们输入到深度学习模型中。

  • 你可以对 PyTorch 张量进行索引和切片,重塑它们,并在它们上执行数学运算。

  • 深度学习是一种使用深度人工神经网络来学习输入和输出数据之间关系的机器学习方法。

  • ReLU 激活函数根据加权总和决定是否应该激活神经元。它为神经元的输出引入了非线性。

  • 损失函数衡量机器学习模型的性能。模型的训练涉及调整参数以最小化损失函数。

  • 二元分类是一种将观察结果分类到两个类别之一的机器学习模型。

  • 多类别分类是一种将观察结果分类到多个类别之一的机器学习模型。


^(1) 迪德里克·金卡马和吉米·巴,2014 年,“Adam:一种随机优化的方法。” arxiv.org/abs/1412.6980.

第三章:生成对抗网络:形状和数量生成

本章涵盖

  • 从头开始构建生成对抗网络中的生成网络和判别网络

  • 使用 GAN 生成数据点以形成形状(例如,指数增长曲线)

  • 生成所有都是 5 的倍数的整数序列

  • 训练、保存、加载和使用 GANs

  • 评估 GAN 性能和确定训练停止点

本书近一半的生成模型属于一种称为生成对抗网络(GANs)的类别。这种方法最初由 Ian Goodfellow 及其合著者在 2014 年提出。1 GANs 因其易于实现和多功能性而备受赞誉,使那些连深度学习基础知识都相当有限的人也能从头开始构建自己的模型。GAN 中的“对抗”一词指的是两个神经网络在零和博弈框架中相互竞争的事实。生成网络试图创建与真实样本不可区分的数据实例。相比之下,判别网络试图从真实样本中识别出生成的样本。这些多才多艺的模型可以生成各种内容格式,从几何形状和数字序列到高分辨率彩色图像,甚至逼真的音乐作品。

在本章中,我们将简要回顾 GANs 背后的理论。然后,我将向您展示如何将这一知识应用于 PyTorch。您将学习从头开始构建您的第一个 GAN,以便所有细节都变得不再神秘。为了使示例更具相关性,想象您将 1 美元存入年利率为 8%的储蓄账户。您想根据您投资了多少年找出账户中的余额。真实的关系是一个指数增长曲线。您将学习如何使用 GAN 生成数据样本——形成这种指数增长曲线的值对(x, y),其数学关系为 y = 1.08^x。掌握这项技能后,您将能够生成模拟任何形状的数据:正弦、余弦、二次等。

在本章的第二个项目中,您将学习如何使用 GAN 生成一系列都是 5 的倍数的数字。但您可以将模式更改为 2、3、7 或其他模式。在这个过程中,您将学习如何从头开始创建生成网络和判别网络。您将学习如何训练、保存和使用 GAN。此外,您还将学习如何通过可视化生成网络生成的样本或通过测量生成样本分布与真实数据分布之间的差异来评估 GAN 的性能。

想象一下,你需要数据来训练一个机器学习(ML)模型以预测一对值(x,y)之间的关系。然而,准备训练数据集既昂贵又耗时。在这种情况下,生成对抗网络(GANs)非常适合生成数据:虽然 x 和 y 的生成值通常符合数学关系,但生成的数据中也有噪声。当使用生成的数据来训练 ML 模型时,噪声可以有助于防止过拟合。

本章的主要目标不一定是为了生成具有最实用用途的新颖内容。相反,我的目标是教你如何从头开始训练和使用 GANs 创建各种格式的内 容。在这个过程中,你将获得对 GANs 内部运作的坚实基础。这个基础将使我们能够在后续章节中集中精力在其他更高级的 GANs 方面,例如生成高分辨率图像或听起来逼真的音乐(例如,卷积神经网络或如何将音乐表示为多维对象)。

3.1 训练 GANs 所涉及的步骤

在第一章中,你获得了 GANs 背后理论的概览。在本节中,我将总结训练 GANs 的步骤,特别是创建数据点以形成指数增长曲线的步骤。

让我们回到先前的例子:你计划投资一个年利率为 8% 的储蓄账户。你今天将 $1 存入账户,并想知道未来账户中会有多少钱。

未来你账户中的金额,y,取决于你投资储蓄账户的时间长短。让我们用 x 表示你投资年数,它可以是介于 0 到 50 之间的数字。例如,如果你投资 1 年,余额是 $1.08;如果你投资 2 年,余额是 1.08² = $1.17。为了概括,x 和 y 之间的关系是 y = 1.08^x。这个函数描绘了一个指数增长曲线。请注意,x 可以是整数,如 1 或 2,也可以是小数,如 1.14 或 2.35,公式仍然适用。

训练 GANs 生成符合特定数学关系的数据点,如前面的例子,是一个多步骤的过程。在你的情况下,你想要生成数据点(x,y),使得 y = 1.08^x。图 3.1 提供了 GANs 架构和生成指数增长曲线的步骤图。当你生成其他内容,如整数序列、图像或音乐时,你将遵循类似的步骤,正如你将在本章的第二项目中看到的那样,以及在本书中稍后其他 GAN 模型中。

图片

图 3.1 训练 GAN 生成指数增长曲线的步骤以及 GAN 中的双网络架构。生成器从潜在空间(左上角)获取一个随机噪声向量 Z,以创建一个假样本并将其展示给判别器(中间)。判别器将样本分类为真实(来自训练集)或假(由生成器创建)。预测结果与真实值进行比较,判别器和生成器都会从预测结果中学习。经过多次训练迭代后,生成器学会创建与真实样本难以区分的形状。

在开始之前,我们需要获得一个训练数据集来训练 GAN。在我们的示例中,我们将使用数学关系 y = 1.08^x 生成一个(x, y)对的集合。我们使用储蓄账户的例子,以便数字更容易理解。本章中你学到的技术可以应用于其他形状:正弦、余弦、U 形等。你可以选择一个 x 值的范围(比如,0 到 50)并计算相应的 y 值。由于我们通常在深度学习中以数据批次的形式训练模型,因此训练数据集中的观测数通常设置为批次大小的倍数。一个真实样本位于图 3.1 的顶部,具有指数增长曲线的形状。

一旦准备好了训练集,你需要在 GAN 中创建两个网络:一个生成器和判别器。生成器位于图 3.1 的左下角,它以一个随机噪声向量 Z 作为输入并生成数据点(我们训练循环的第 1 步)。生成器使用的随机噪声向量 Z 来自潜在空间,这代表了 GAN 可以产生的可能输出范围,并且对于 GAN 生成多样化的数据样本的能力至关重要。在第五章中,我们将探索潜在空间以选择生成器创建的内容的属性。判别器位于图 3.1 的中心,它评估给定的数据点(x, y)是真实(来自训练数据集)还是假(由生成器创建);这是我们训练循环的第 2 步。

潜在空间的意义

GAN 中的潜在空间是一个概念空间,其中每个点都可以通过生成器转换成一个真实的数据实例。这个空间代表了 GAN 可以产生的可能输出范围,并且对于 GAN 生成多样化和复杂数据的能力至关重要。潜在空间只有在与生成模型结合使用时才具有其重要性。在这个背景下,可以在潜在空间中的点之间进行插值,以影响输出的属性,我们将在第五章中讨论这一点。

要知道如何调整模型参数,我们必须选择正确的损失函数。我们需要为生成器和判别器定义损失函数。损失函数鼓励生成器生成类似于训练数据集数据点的数据点,使判别器将它们分类为真实。损失函数鼓励判别器正确分类真实和生成数据点。

在训练循环的每一次迭代中,我们交替训练判别器和生成器。在每次训练迭代中,我们从训练数据集中采样一批真实的数据点(x, y)和一批由生成器生成的虚假数据点。当训练判别器时,我们比较判别模型(它是一个样本来自训练集的概率)的预测与真实值,真实值为 1(如果样本是真实的)和 0(如果样本是虚假的)(如图 3.1 右侧所示);这构成了训练循环中的第 3 步的一半。我们稍微调整判别器网络中的权重,以便在下一个迭代中,预测概率更接近真实值(这是我们的训练循环中第 4 步的一半)。

当训练生成器时,我们将虚假样本输入到判别模型中,并获得样本是真实的概率(第 3 步的另一半)。然后我们稍微调整生成器网络中的权重,以便在下一个迭代中,预测概率更接近 1(因为生成器想要创建样本来欺骗判别器,使其认为它们是真实的);这构成了第 4 步的另一半。我们重复这个过程多次迭代,使生成器网络创建更真实的数据点。

一个自然的问题是何时停止训练 GANs。为此,你通过生成一组合成数据点并将其与训练数据集中的真实数据点进行比较来评估 GAN 的性能。在大多数情况下,我们使用可视化技术来评估生成的数据与所需关系的一致性。然而,在我们的运行示例中,由于我们知道训练数据的分布,我们可以计算生成数据与真实数据分布之间的均方误差(MSE)。当在固定数量的训练轮次后,生成的样本停止提高其质量时,我们停止训练 GANs。

到这一点,模型被认为是训练好的。然后我们丢弃判别器,保留生成器。为了创建指数增长曲线,我们将随机噪声向量 Z 输入到训练好的生成器中,并获取(x, y)对以形成所需的形状。

3.2 准备训练数据

在本节中,你将创建训练数据集,以便你可以使用它来训练本章后面的 GAN 模型。具体来说,你将创建符合指数增长形状的数据点对(x, y)。你将将它们放入批次中,以便它们可以准备好输入到深度神经网络中。

注意:本章以及本书其他章节的代码可在本书的 GitHub 仓库中找到:github.com/markhliu/DGAI

3.2.1 形成指数增长曲线的训练数据集

我们将创建一个包含许多数据对观察结果的数据集,即 (x, y),其中 x 在区间 [0, 50] 内均匀分布,y 根据公式 y = 1.08^x 与 x 相关,如下所示。

列表 3.1 创建训练数据以形成指数增长形状

import torch

torch.manual_seed(0)                                ①

observations = 2048

train_data = torch.zeros((observations, 2))         ②

train_data[:,0]=50*torch.rand(observations)         ③

train_data[:,1]=1.08**train_data[:,0]               ④

① 固定随机状态以确保结果可重现

② 创建一个具有 2,048 行和 2 列的张量

③ 生成 0 到 50 之间的 x 的值

④ 根据关系 y = 1.08^x 生成 y 的值

首先,我们使用 torch.rand() 方法创建 0 到 50 之间 2,048 个 x 的值。我们使用 PyTorch 中的 manual_seed() 方法固定随机状态,以确保所有结果都是可重现的。我们首先创建一个具有 2,048 行和 2 列的 PyTorch 张量 train_data。x 的值放置在张量 train_data 的第一列中。PyTorch 中的 rand() 方法生成介于 0.0 和 1.0 之间的随机值。通过将值乘以 50,得到的 x 的值介于 0.0 和 50.0 之间。然后,我们将 y = 1.08^x 的值填充到 train_data 的第二列中。

练习 3.1

将列表 3.1 修改为使用 torch.sin() 函数,使 x 和 y 之间的关系为 y = sin(x)。通过以下代码行设置 x 的值为 -5 到 5:train_data[:,0]=10*(torch.rand(observations)-0.5)

我们使用 Matplotlib 库绘制 x 和 y 之间的关系。

列表 3.2 可视化 x 和 y 之间的关系

import matplotlib.pyplot as plt

fig=plt.figure(dpi=100,figsize=(8,6))
plt.plot(train_data[:,0],train_data[:,1],".",c="r")    ①
plt.xlabel("values of x",fontsize=15)
plt.ylabel("values of $y=1.08^x$",fontsize=15)         ②
plt.title("An exponential growth shape",fontsize=20)   ③
plt.show()

① 绘制 x 和 y 之间的关系

② 标记 y 轴

③ 为图表创建标题

运行列表 3.2 后,您将看到一个指数增长曲线形状,类似于图 3.1 中的顶部图表。

练习 3.2

根据练习 3.1 中的更改,修改列表 3.2 以根据 x 和 y = sin(x) 的关系绘制图表。确保您更改图表中的 y 轴标签和标题,以反映您所做的更改。

3.2.2 准备训练数据集

我们将把您刚刚创建的数据样本放入批次中,以便我们可以将它们输入到判别网络中。我们使用 PyTorch 中的 DataLoader() 类将可迭代对象包装在训练数据集周围,这样我们就可以在训练过程中轻松访问样本,如下所示:

from torch.utils.data import DataLoader

batch_size=128
train_loader=DataLoader(
    train_data,
    batch_size=batch_size,
    shuffle=True)

确保您选择观察总数和批量大小,以便所有批次中的样本数量相同。我们选择了 2,048 个观察结果和 128 的批量大小。因此,我们有 2,048/128 = 16 个批次。DataLoader() 中的 shuffle=True 参数在将观察结果分成批次之前随机打乱观察结果。

注意:打乱数据确保数据样本均匀分布,并且批次内的观察结果不相关,这反过来又稳定了训练。在这个特定示例中,打乱确保 x 的值在 0 到 50 之间随机分布,而不是聚集在某个范围内,比如 0 到 5 之间。

你可以通过使用next()iter()方法来访问一批数据,如下所示:

batch0=next(iter(train_loader))
print(batch0)

你将看到 128 对数字(x, y),其中 x 的值在 0 到 50 之间随机分布。此外,每对中的 x 和 y 的值符合关系 y = 1.08^x。

3.3 创建 GANs

现在训练数据集已经准备好了,我们将创建一个判别网络和一个生成网络。判别网络是一个二元分类器,这与我们在第二章中创建和训练的服装物品二元分类器非常相似。在这里,判别网络的职责是将样本分类为真实或伪造。另一方面,生成网络试图创建与训练集中那些不可区分的数据点(x, y),以便判别器将它们分类为真实。

3.3.1 判别网络

我们使用 PyTorch 创建判别神经网络。我们将使用带有ReLU激活的全连接(密集)层。我们还将使用 dropout 层来防止过拟合。我们在 PyTorch 中创建一个顺序深度神经网络来表示判别器,如下所示。

列表 3.3 创建判别网络

import torch.nn as nn

device="cuda" if torch.cuda.is_available() else "cpu"    ①

D=nn.Sequential(
    nn.Linear(2,256),                                    ②
    nn.ReLU(),
    nn.Dropout(0.3),                                     ③
    nn.Linear(256,128),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(128,64),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(64,1),                                     ④
    nn.Sigmoid()).to(device)

① 自动检查 CUDA 启用 GPU 是否可用。

② 第一层的输入特征数是 2,与每个数据实例中的元素数量相匹配,每个数据实例有两个值,x 和 y。

③ dropout 层可以防止过拟合。

④ 最后层的输出特征数是 1,这样我们就可以将其压缩到 0 到 1 之间的值。

确保在第一层中,输入形状是 2,因为在我们这个示例中,每个数据实例中包含两个值:x 和 y。第一层的输入数量应始终与输入数据的大小相匹配。同时,确保最后一层的输出特征数是 1:判别网络的输出是一个单一值。我们使用 sigmoid 激活函数将输出压缩到[0, 1]的范围内,以便它可以被解释为样本是真实的概率,p。使用互补概率 1 – p,样本是伪造的。这与我们在第二章中做的非常相似,当时二元分类器试图识别一件服装物品是长靴还是 T 恤。

隐藏层分别包含 256、128 和 64 个神经元。这些数字并没有什么神奇之处,只要在合理的范围内,你可以轻松地更改它们,并得到相似的结果。如果隐藏层中的神经元数量过多,可能会导致模型过拟合;如果数量过少,可能会导致模型欠拟合。可以通过使用验证集和超参数调整来单独优化神经元数量。

Dropout 层随机地使应用到的层中的某些神经元失效(或“丢弃”)。这意味着这些神经元在训练过程中不参与正向或反向传播。当模型不仅学习到训练数据中的潜在模式,还学习到噪声和随机波动时,就会发生过拟合,导致在未见过的数据上表现不佳。Dropout 层是防止过拟合的有效方法.^(2)

3.3.2 生成器网络

生成器的任务是创建一对数字(x,y),以便它可以通过判别器的筛选。也就是说,生成器试图创建一对数字,以最大化判别器认为这些数字来自训练数据集(即,它们符合关系 y = 1.08^x)的概率。我们以下列表中的神经网络来表示生成器。

列表 3.4 创建生成器网络

G=nn.Sequential(
    nn.Linear(2,16),               ①
    nn.ReLU(),
    nn.Linear(16,32),
    nn.ReLU(),
    nn.Linear(32,2)).to(device)    ②

① 第一层的输入特征数量为 2,与来自潜在空间的随机噪声向量的维度相同。

② 最后层的输出特征数量为 2,与数据样本的维度相同,包含两个值(x,y)。

我们将来自二维潜在空间(z[1],z[2])的随机噪声向量输入到生成器中。然后,生成器根据潜在空间的输入生成一对值(x,y)。在这里,我们使用二维潜在空间,但将维度更改为其他数字,如 5 或 10,不会影响我们的结果。

3.3.3 损失函数、优化器和早停

由于判别器网络本质上执行的是二元分类任务(识别数据样本为真实或假),因此我们为判别器网络使用二元交叉熵损失函数,这是二元分类中首选的损失函数。判别器试图最大化二元分类的准确性:将真实样本识别为真实,将假样本识别为假。判别器网络中的权重根据损失函数相对于权重的梯度进行更新。

生成器试图最小化假样本被识别为假样本的概率。因此,我们也将使用二元交叉熵损失函数来训练生成器网络:生成器更新其网络权重,使得生成的样本在二元分类问题中被判别器分类为真实样本。

正如我们在第二章中所做的那样,我们使用 Adam 优化器作为梯度下降算法。我们将学习率设置为 0.0005。让我们通过使用 PyTorch 来实现这些步骤:

loss_fn=nn.BCELoss()
lr=0.0005
optimD=torch.optim.Adam(D.parameters(),lr=lr)
optimG=torch.optim.Adam(G.parameters(),lr=lr)

在我们进行实际训练之前,还有一个问题:我们应该训练多少个 epoch?我们如何知道模型已经很好地训练,以至于生成器可以创建可以模仿指数增长曲线形状的样本?如果你还记得,在第二章中,我们将训练集进一步分为训练集和验证集。然后我们使用验证集中的损失来确定参数是否收敛,以便我们可以停止训练。然而,GANs 的训练方法与传统监督学习模型(如你在第二章中看到的分类模型)不同。由于生成样本的质量在整个训练过程中都在提高,判别器的任务变得越来越困难(在某种程度上,GANs 中的判别器是在对一个移动目标进行预测)。判别器网络的损失并不是模型质量的良好指标。

测量 GANs 性能的一种常见方法是通过视觉检查。人类可以通过简单地查看它们来评估生成数据实例的质量和真实性。这是一种定性方法,但可以提供非常有价值的信息。但在我们的简单案例中,因为我们知道训练数据集的确切分布,我们将查看生成样本相对于训练集中样本的均方误差(MSE),并将其用作生成器性能的衡量标准。让我们用代码来实现这一点:

mse=nn.MSELoss()                              ①

def performance(fake_samples):
    real=1.08**fake_samples[:,0]              ②
    mseloss=mse(fake_samples[:,1],real)       ③
    return mseloss

① 使用 MSE 作为衡量性能的标准

② 找出真实分布

③ 将生成的分布与真实分布进行比较,并计算 MSE

如果生成器的性能在 1,000 个 epoch 内没有提高,我们将停止训练模型。因此,我们定义了一个早期停止类,就像我们在第二章中所做的那样,以决定何时停止训练模型。

列表 3.5 一个早期停止类,用于决定何时停止训练

class EarlyStop:
    def __init__(self, patience=1000):       ①
        self.patience = patience
        self.steps = 0
        self.min_gdif = float('inf')
    def stop(self, gdif):                    ②
        if gdif < self.min_gdif:             ③
            self.min_gdif = gdif
            self.steps = 0
        elif gdif >= self.min_gdif:
            self.steps += 1
        if self.steps >= self.patience:      ④
            return True
        else:
            return False

stopper=EarlyStop()

① 将耐心(patience)的默认值设置为 1000

② 定义 stop() 方法

③ 当达到生成分布和真实分布之间新的最小差异时,更新 min_gdif 的值。

④ 如果模型在 1,000 个 epoch 内停止改进,则停止训练

这样,我们就拥有了训练我们的 GANs 所需的所有组件,我们将在下一节中进行训练。

3.4 使用 GANs 进行形状生成训练和利用

现在我们有了训练数据和两个网络,我们将训练模型。之后,我们将丢弃判别器,并使用生成器生成数据点以形成指数增长曲线形状。

3.4.1 GANs 的训练

我们首先为真实样本和假样本分别创建标签。具体来说,我们将所有真实样本标记为 1,所有假样本标记为 0。在训练过程中,判别器将其自己的预测与标签进行比较,以获得反馈,以便它可以调整模型参数,在下一个迭代中做出更好的预测。

在这里,我们定义了两个张量,real_labelsfake_labels

real_labels=torch.ones((batch_size,1))
real_labels=real_labels.to(device)

fake_labels=torch.zeros((batch_size,1))
fake_labels=fake_labels.to(device)

张量 real_labels 是一个形状为 (batch_size, 1) 的二维张量——即 128 行和 1 列。我们使用 128 行,因为我们将会向判别网络提供 128 个真实样本以获得 128 个预测。同样,张量 fake_labels 也是一个形状为 (batch_size, 1) 的二维张量。我们将向判别网络提供 128 个假样本以获得 128 个预测,并将它们与真实标签:128 个 0 进行比较。如果你的计算机具有启用 CUDA 的 GPU,我们将这两个张量移动到 GPU 上以实现快速训练。

为了训练 GANs,我们定义了一些函数,以便训练循环看起来更有组织。第一个函数 train_D_on_real() 使用一批真实样本来训练判别网络。

列表 3.6 定义 train_D_on_real() 函数

def train_D_on_real(real_samples):
    real_samples=real_samples.to(device)
    optimD.zero_grad()
    out_D=D(real_samples)                    ①
    loss_D=loss_fn(out_D,real_labels)        ②
    loss_D.backward()
    optimD.step()                            ③
    return loss_D

① 对真实样本进行预测

② 计算损失

③ 反向传播(即更新判别网络中的模型权重,以便在下一个迭代中做出更准确的预测)

函数 train_D_on_real() 首先将真实样本移动到 GPU 上,如果计算机具有启用 CUDA 的 GPU。判别网络 D 对样本批次进行预测。然后模型将判别器的预测 out_D 与真实标签 real_labels 进行比较,并相应地计算预测的损失。backward() 方法计算损失函数相对于模型参数的梯度。step() 方法调整模型参数(即反向传播)。zero_grad() 方法表示我们在反向传播之前显式地将梯度设置为 0。否则,在每次 backward() 调用中都会使用累积的梯度而不是增量梯度。

TIP 在训练每个数据批次时更新模型权重之前,我们调用 zero_grad() 方法。我们在反向传播之前显式地将梯度设置为 0,以使用增量梯度而不是在每次 backward() 调用中累积的梯度。

第二个函数 train_D_on_fake() 使用一批假样本来训练判别网络。

列表 3.7 定义 train_D_on_fake() 函数

def train_D_on_fake():
    noise=torch.randn((batch_size,2))
    noise=noise.to(device)
    fake_samples=G(noise)                    ①
    optimD.zero_grad()
    out_D=D(fake_samples)                    ②
    loss_D=loss_fn(out_D,fake_labels)        ③
    loss_D.backward()
    optimD.step()                            ④
    return loss_D

① 生成一批假样本

② 对假样本进行预测

③ 计算损失

④ 反向传播

函数train_D_on_fake()首先将来自潜在空间的一批随机噪声向量输入到生成器中,以获得一批假样本。然后,该函数将假样本展示给判别器以获取预测结果。该函数将判别器的预测结果out_D与真实标签fake_labels进行比较,并相应地计算预测的损失。最后,它根据损失函数相对于模型权重的梯度调整模型参数。

注意:我们互换使用术语权重参数。严格来说,模型参数还包括偏置项,但我们使用术语模型权重来松散地包括模型偏置。同样,我们互换使用术语调整权重调整参数反向传播

第三个函数train_G()使用一批假样本训练生成器网络。

列表 3.8 定义train_G()函数

def train_G(): 
    noise=torch.randn((batch_size,2))
    noise=noise.to(device)
    optimG.zero_grad()
    fake_samples=G(noise)                     ①
    out_G=D(fake_samples)                     ②
    loss_G=loss_fn(out_G,real_labels)         ③
    loss_G.backward()
    optimG.step()                             ④
    return loss_G, fake_samples 

① 创建一批假样本

② 向判别器展示假样本以获取预测结果

③ 根据 G 是否成功计算损失

④ 反向传播(即更新生成器网络中的权重,以便在下一迭代中生成的样本更加逼真)

为了训练生成器,我们首先将来自潜在空间的一批随机噪声向量输入到生成器中,以获得一批假样本。然后,我们将这些假样本展示给判别器网络以获取一批预测结果。我们比较判别器的预测结果与real_labels(一个全为 1 的张量)进行比较,并计算损失。使用全为 1 的张量而不是全为 0 的张量作为标签非常重要,因为生成器的目标是欺骗判别器,使其认为假样本是真实的。最后,我们根据损失函数相对于模型权重的梯度调整模型参数,以便在下一迭代中,生成器可以创建更加逼真的样本。

注意:在计算损失和评估生成器网络时,我们使用real_labels(一个全为 1 的张量)而不是fake_labels(一个全为 0 的张量),因为生成器希望判别器将假样本预测为真实样本。

最后,我们定义了一个函数test_epoch(),该函数定期打印出判别器和生成器的损失。此外,它绘制了生成器生成的数据点,并将它们与训练集中的数据点进行比较。test_epoch()函数的代码如下所示。

列表 3.9 定义test_epoch()函数

import os
os.makedirs("files", exist_ok=True)                           ①

def test_epoch(epoch,gloss,dloss,n,fake_samples):
    if epoch==0 or (epoch+1)%25==0:
        g=gloss.item()/n
        d=dloss.item()/n
        print(f"at epoch {epoch+1}, G loss: {g}, D loss {d}") ②
        fake=fake_samples.detach().cpu().numpy()
        plt.figure(dpi=200)
        plt.plot(fake[:,0],fake[:,1],"*",c="g",
            label="generated samples")                        ③
        plt.plot(train_data[:,0],train_data[:,1],".",c="r",
            alpha=0.1,label="real samples")                   ④
        plt.title(f"epoch {epoch+1}")
        plt.xlim(0,50)
        plt.ylim(0,50)
        plt.legend()
        plt.savefig(f"files/p{epoch+1}.png")
        plt.show()

① 创建一个文件夹来存放文件

② 定期打印出损失

③ 将生成的点以星号(*)的形式绘制出来

④ 将训练数据以点(.)的形式绘制出来

每过 25 个周期,函数会在该周期打印出生成器和判别器的平均损失。此外,它还会绘制生成器生成的一批假数据点(用星号表示)并与训练集中的数据点(用点表示)进行比较。该图被保存为图像保存在你的本地文件夹 /files/ 中。

现在我们已经准备好训练模型了。我们遍历训练数据集中的所有批次。对于每一批数据,我们首先使用真实样本来训练判别器。之后,生成器创建一批假样本,我们再次使用这些样本来训练判别器。最后,我们让生成器再次创建一批假样本,但这次,我们使用它们来训练生成器。我们训练模型直到满足早期停止条件,如下所示。

列表 3.10 训练生成指数增长曲线的 GAN

for epoch in range(10000):                                  ①
    gloss=0
    dloss=0
    for n, real_samples in enumerate(train_loader):         ②
        loss_D=train_D_on_real(real_samples)
        dloss+=loss_D
        loss_D=train_D_on_fake()
        dloss+=loss_D
        loss_G,fake_samples=train_G()
        gloss+=loss_G
    test_epoch(epoch,gloss,dloss,n,fake_samples)            ③
    gdif=performance(fake_samples).item()
    if stopper.stop(gdif)==True:                            ④
        break

① 开始训练循环

② 遍历训练数据集中的所有批次

③ 定期展示生成的样本

④ 确定是否停止训练

如果你使用 GPU 训练,训练可能需要几分钟。否则,可能需要 20 到 30 分钟,具体取决于你电脑的硬件配置。或者,你可以从本书的 GitHub 仓库下载训练好的模型:github.com/markhliu/DGAI

经过 25 个训练周期后,生成的数据点围绕点 (0,0) 散布,并没有形成任何有意义的形状(一个周期是指所有训练数据被用于训练一次)。经过 200 个训练周期后,数据点开始形成指数增长曲线的形状,尽管许多点远离由训练集点形成的虚线曲线。经过 1,025 个训练周期后,生成的点与指数增长曲线非常吻合。图 3.2 提供了六个不同周期的输出子图。我们的 GAN 工作得非常好:生成器能够生成形成所需形状的数据点。

图 3.2 训练过程中不同阶段的生成形状(图中星号所示)与真实指数增长曲线形状(图中点所示)的比较子图。在第 25 个周期,生成的样本没有形成任何有意义的形状。在第 200 个周期,样本开始看起来像指数增长曲线的形状。在第 1,025 个周期,生成的样本与指数增长曲线非常吻合。

3.4.2 保存和使用训练好的生成器

既然 GAN 已经训练完成,我们将丢弃判别器网络,就像在 GAN 中我们通常所做的那样,并将训练好的生成器网络保存在本地文件夹中,如下所示:

import os
os.makedirs("files", exist_ok=True)
scripted = torch.jit.script(G) 
scripted.save('files/exponential.pt') 

torch.jit.script() 方法使用 TorchScript 编译器将函数或 nn.Module 类脚本化为 TorchScript 代码。我们使用此方法将训练好的生成器网络脚本化,并将其保存为文件 exponential.pt 在你的电脑上。

要使用生成器,我们甚至不需要定义模型。我们只需加载保存的文件,并使用它来生成数据点,如下所示:

new_G=torch.jit.load('files/exponential.pt',
                     map_location=device)
new_G.eval()

训练好的生成器现在已加载到你的设备上,这取决于你是否在电脑上安装了 CUDA 支持的 GPU,它可能是CPUCUDAtorch.jit.load()中的map_location=device参数指定了加载生成器的位置。现在我们可以使用训练好的生成器生成一批数据点:

noise=torch.randn((batch_size,2)).to(device)
new_data=new_G(noise) 

在这里,我们首先从潜在空间中获取一批随机噪声向量。然后,我们将它们输入到生成器中以生成假数据。我们可以绘制生成的数据:

fig=plt.figure(dpi=100)
plt.plot(new_data.detach().cpu().numpy()[:,0],
  new_data.detach().cpu().numpy()[:,1],"*",c="g",
        label="generated samples")                    ①
plt.plot(train_data[:,0],train_data[:,1],".",c="r",
         alpha=0.1,label="real samples")              ②
plt.title("Inverted-U Shape Generated by GANs")
plt.xlim(0,50)
plt.ylim(0,50)
plt.legend()
plt.show()

① 以星号形式绘制生成的数据样本

② 以点形式绘制训练数据

你应该看到一个类似于图 3.2 中最后一个子图的图表:生成的数据样本非常接近指数增长曲线。

恭喜!你已经创建并训练了你的第一个 GAN。掌握了这项技能,你可以轻松地修改代码,使生成的数据匹配其他形状,如正弦、余弦、U 形等。

练习 3.3

修改第一个项目中的程序,使生成器生成在 x = –5 和 x = 5 之间形成正弦形状的数据样本。当你绘制数据样本时,将 y 的值设置为–1.2 到 1.2 之间。

3.5 使用模式生成数字

在这个第二个项目中,你将构建和训练 GANs 以生成 0 到 99 之间所有都是 5 的倍数的 10 个整数的序列。涉及的主要步骤与生成指数增长曲线的步骤类似,只是训练集不是具有两个值(x, y)的数据点。相反,训练数据集是 0 到 99 之间所有都是 5 的倍数的整数序列。

在本节中,你将首先学习将训练数据转换为神经网络可以理解的格式:独热变量。进一步,你将把独热变量转换回 0 到 99 之间的整数,这样人类就可以更容易理解。因此,你实际上是在将数据在人类可读格式和模型准备格式之间进行转换。之后,你将创建一个判别器和生成器,并训练 GANs。你还将使用早期停止来确定何时结束训练。然后,你将丢弃判别器,并使用训练好的生成器创建一个具有你想要的模式的整数序列。

3.5.1 什么是独热变量?

独热编码是机器学习和数据预处理中用于将分类数据表示为二进制向量的技术。分类数据由类别或标签组成,如颜色、动物类型或城市,它们本身不是数值。机器学习算法通常处理数值数据,因此将分类数据转换为数值格式是必要的。

想象一下,你正在处理一个分类特征——例如,房屋的颜色,它可以取“红色”、“绿色”和“蓝色”等值。使用 one-hot 编码,每个类别都表示为一个二进制向量。你将创建三个二进制列,每个类别一个。颜色“红色”被 one-hot 编码为[1, 0, 0],“绿色”为[0, 1, 0],而“蓝色”为[0, 0, 1]。这样做可以保留分类信息,而不在类别之间引入任何顺序关系。每个类别都被视为独立的。

在这里,我们定义了一个onehot_encoder()函数,用于将整数转换为 one-hot 变量:

import torch
def onehot_encoder(position,depth):
    onehot=torch.zeros((depth,))
    onehot[position]=1
    return onehot

该函数接受两个参数:第一个参数position是值被打开为 1 的索引,第二个参数depth是 one-hot 变量的长度。例如,如果我们打印出onehot_encoder(1,5)的值,它将看起来像这样:

print(onehot_encoder(1,5))

结果是

tensor([0., 1., 0., 0., 0.])

结果显示一个五值张量,第二个位置(其索引值为 1)被打开为 1,其余位置被关闭为 0。

现在你已经了解了 one-hot 编码的工作原理,你可以将 0 到 99 之间的任何整数转换为 one-hot 变量:

def int_to_onehot(number):
    onehot=onehot_encoder(number,100)
    return onehot

让我们使用该函数将数字 75 转换为 100 值张量:

onehot75=int_to_onehot(75)
print(onehot75)

输出是

tensor([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., 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., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 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.])

结果是一个 100 值张量,第 76 位(其索引值为 75)被打开为 1,其余位置被关闭为 0。

函数int_to_onehot()将整数转换为 one-hot 变量。从某种意义上说,该函数是将人类可读语言转换为模型准备语言。

接下来,我们希望将模型准备语言翻译回人类可读语言。假设我们有一个 one-hot 变量:我们如何将其转换为人类可理解的整数?以下函数onehot_to_int()实现了这一目标:

def onehot_to_int(onehot):
    num=torch.argmax(onehot)
    return num.item()

函数onehot_to_int()接受onehot参数,并根据哪个位置具有最高值将其转换为整数。

让我们测试一下函数,看看如果我们使用刚刚创建的onehot75张量作为输入会发生什么:

print(onehot_to_int(onehot75))

输出是

75

结果显示,该函数将 one-hot 变量转换为整数 75,这是正确答案。因此,我们知道这些函数已正确定义。

接下来,我们将构建和训练 GAN 以生成 5 的倍数。

3.5.2 使用 GAN 生成具有模式的数字

我们的目标是构建和训练一个模型,以便生成器可以生成一个由 10 个整数组成的序列,它们都是 5 的倍数。我们首先准备训练数据,然后将它们分批转换为模型准备好的数字。最后,我们使用训练好的生成器生成我们想要的模式。

为了简单起见,我们将生成 0 到 99 之间的 10 个整数序列。然后,我们将该序列转换为 10 个模型准备好的数字。

以下函数生成一个由 10 个整数组成的序列,它们都是 5 的倍数:

def gen_sequence():
    indices = torch.randint(0, 20, (10,))
    values = indices*5
    return values   

我们首先使用 PyTorch 中的randint()方法生成 0 到 19 之间的 10 个数字。然后我们将它们乘以 5 并转换为 PyTorch 张量。这创建了 10 个都是 5 的倍数的整数。

让我们尝试生成一个训练数据序列:

sequence=gen_sequence()
print(sequence)

输出如下

tensor([60, 95, 50, 55, 25, 40, 70,  5,  0, 55])

前面的输出中的所有值都是 5 的倍数。

接下来,我们将每个数字转换为独热变量,以便我们可以在以后将其输入到神经网络中:

import numpy as np

def gen_batch():
    sequence=gen_sequence()                            ①
    batch=[int_to_onehot(i).numpy() for i in sequence] ②
    batch=np.array(batch)
    return torch.tensor(batch)
batch=gen_batch()

① 创建一个由 10 个数字组成的序列,这些数字都是 5 的倍数

② 将每个整数转换为 100 值的独热变量

前面的函数gen_batch()创建了一组数据,以便我们可以将其输入到神经网络进行训练。

我们还定义了一个函数data_to_num(),用于将独热变量转换为一系列整数,以便人类可以理解输出:

def data_to_num(data):
    num=torch.argmax(data,dim=-1)                      ①
    return num
numbers=data_to_num(batch)                             ②

① 根据 100 值向量中的最大值将向量转换为整数

② 在一个示例上应用该函数

torch.argmax()函数中的dim=-1参数意味着我们试图找到最后一个维度(即索引)中最大值的位(即位置):即在 100 值的独热向量中,哪个位置具有最高值。

接下来,我们将创建两个神经网络:一个用于判别器 D,另一个用于生成器 G。我们将构建 GAN 来生成所需的数字模式。类似于本章前面所做的那样,我们创建了一个判别器网络,它是一个二元分类器,用于区分假样本和真实样本。我们还创建了一个生成器网络来生成一系列 10 个数字。以下是判别器神经网络:

from torch import nn
D=nn.Sequential(
    nn.Linear(100,1),
    nn.Sigmoid()).to(device)

由于我们将整数转换为 100 值的独热变量,我们在模型的第一层Linear中使用 100 作为输入大小。最后一层Linear只有一个输出特征,我们使用 sigmoid 激活函数将输出挤压到[0, 1]的范围内,以便它可以被解释为样本是真实的概率,p。使用互补概率 1 – p,样本是假的。

生成器的任务是创建一个数字序列,以便它们可以通过判别器 D。也就是说,G 试图创建一个数字序列,以最大化 D 认为这些数字来自训练数据集的概率。

我们创建了以下神经网络来表示生成器 G:

G=nn.Sequential(
    nn.Linear(100,100),
    nn.ReLU()).to(device)

我们将向生成器输入来自 100 维潜在空间的随机噪声向量。生成器随后根据输入创建一个包含 100 个值的张量。注意,在这里我们使用ReLU激活函数在最后一层,以确保输出为非负值。由于我们试图生成 100 个 0 或 1 的值,非负值在这里是合适的。

如同第一个项目一样,我们使用 Adam 优化器对判别器和生成器进行优化,学习率为 0.0005:

loss_fn=nn.BCELoss()
lr=0.0005
optimD=torch.optim.Adam(D.parameters(),lr=lr)
optimG=torch.optim.Adam(G.parameters(),lr=lr)

现在我们有了训练数据和两个网络,我们将训练模型。之后,我们将丢弃判别器并使用生成器生成一个 10 个整数的序列。

3.5.3 训练 GAN 生成具有模式的数字

本项目的训练过程与我们第一个项目中生成指数增长形状的项目非常相似。

我们定义了一个函数train_D_G(),它是我们为第一个项目定义的三个函数train_D_on_real()train_D_on_fake()train_G()的组合。函数train_D_G()在本书 GitHub 仓库的该章节 Jupyter Notebook 中:github.com/markhliu/DGAI。查看函数train_D_G(),你可以看到我们与第一个项目定义的三个函数相比做了哪些细微的修改。

我们使用与第一个项目定义相同的早期停止类,这样我们就可以知道何时停止训练。然而,当我们实例化类时,我们将patience参数修改为 800,如以下列表所示。

列表 3.11 训练 GAN 生成 5 的倍数

stopper=EarlyStop(800)                                  ①

mse=nn.MSELoss()
real_labels=torch.ones((10,1)).to(device)
fake_labels=torch.zeros((10,1)).to(device)
def distance(generated_data):                           ②
    nums=data_to_num(generated_data)
    remainders=nums%5
    ten_zeros=torch.zeros((10,1)).to(device)
    mseloss=mse(remainders,ten_zeros)
    return mseloss

for i in range(10000):
    gloss=0
    dloss=0
    generated_data=train_D_G(D,G,loss_fn,optimD,optimG) ③
    dis=distance(generated_data)
    if stopper.stop(dis)==True:
        break   
    if i % 50 == 0:
        print(data_to_num(generated_data))              ④

① 创建早期停止类的实例

② 定义一个 distance()函数来计算生成数字中的损失

③ 训练 GAN 进行一个 epoch 的训练

④ 每过 50 个 epoch 打印出生成的整数序列

我们还定义了一个distance()函数来衡量训练集和生成数据样本之间的差异:它计算每个生成的数字除以 5 的余数的均方误差。当所有生成的数字都是 5 的倍数时,该度量值为 0。

如果你运行前面的代码单元,你会看到以下输出:

tensor([14, 34, 19, 89, 44,  5, 58,  6, 41, 87], device='cuda:0')
… 
tensor([ 0, 80, 65,  0,  0, 10, 80, 75, 75, 75], device='cuda:0')
tensor([25, 30,  0,  0, 65, 20, 80, 20, 80, 20], device='cuda:0')
tensor([65, 95, 10, 65, 75, 20, 20, 20, 65, 75], device='cuda:0')

在每次迭代中,我们生成一批 10 个数字。我们首先使用真实样本训练判别器 D。然后,生成器创建一批假样本,我们再次使用它们来训练判别器 D。最后,我们让生成器再次创建一批假样本,但这次我们使用它们来训练生成器 G。如果生成器网络在达到最小损失后的 800 个 epoch 内停止改进,我们将停止训练。每过 50 个 epoch,我们打印出由生成器创建的 10 个数字序列,这样你就可以判断它们是否确实是 5 的倍数。

训练过程中的输出如图所示。在前几百个 epoch 中,生成器仍然产生不是 5 的倍数的数字。但在 900 个 epoch 之后,所有生成的数字都是 5 的倍数。使用 GPU 训练时,训练过程只需一分钟左右。如果你使用 CPU 训练,则不到 10 分钟。或者,你可以从书籍的 GitHub 仓库下载训练好的模型:github.com/markhliu/DGAI

3.5.4 保存和使用训练好的模型

我们将丢弃判别器并将训练好的生成器保存在本地文件夹中:

import os
os.makedirs("files", exist_ok=True)
scripted = torch.jit.script(G) 
scripted.save('files/num_gen.pt') 

我们现在已经将生成器保存到了本地文件夹。要使用生成器,我们只需加载模型并使用它来生成一系列整数:

new_G=torch.jit.load('files/num_gen.pt',
                     map_location=device)             ①
new_G.eval()
noise=torch.randn((10,100)).to(device)                ②
new_data=new_G(noise)                                 ③
print(data_to_num(new_data))

① 加载保存的生成器

② 获取随机噪声向量

③ 将随机噪声向量输入到训练好的模型中以生成一系列整数

输出如下:

tensor([40, 25, 65, 25, 20, 25, 95, 10, 10, 65], device='cuda:0')

生成的数字都是 5 的倍数。

你可以轻松地更改代码以生成其他模式,例如奇数、偶数、3 的倍数等等。

练习 3.4

修改第二个项目的程序,使生成器生成一个由所有都是 3 的倍数的十个整数组成的序列。

现在你已经了解了 GANs 的工作原理,你将在后面的章节中能够将 GANs 的理念扩展到其他格式,包括高分辨率图像和听起来逼真的音乐。

摘要

  • GANs 由两个网络组成:一个用于区分伪造样本和真实样本的判别器,以及一个用于创建与训练集中样本不可区分的样本的生成器。

  • GANs 的步骤包括准备训练数据、创建判别器和生成器、训练模型并决定何时停止训练,最后,丢弃判别器并使用训练好的生成器来创建新的样本。

  • GANs 生成的内容取决于训练数据。当训练数据集包含形成指数增长曲线的数据对 (x, y) 时,生成的样本也是模仿这种形状的数据对。当训练数据集包含所有都是 5 的倍数的数字序列时,生成的样本也是包含 5 的倍数的数字序列。

  • GANs 是多才多艺的,能够生成许多不同格式的内 容。


^(1)  Goodfellow 等人,2014 年,“Generative Adversarial Nets.” arxiv.org/abs/1406.2661.

^(2)  尼提什·斯里瓦斯塔瓦(Nitish Srivastava)、杰弗里·辛顿(Geoffrey Hinton)、亚历克斯·克里泽夫斯基(Alex Krizhevsky)、伊利亚·苏茨克维(Ilya Sutskever)和鲁斯兰·萨拉胡丁诺夫(Ruslan Salakhutdinov),2014 年,“Dropout: A Simple Way to Prevent Neural Networks from Overfitting.” 《机器学习研究杂志》 15 (56): 1929−1958.

第二部分. 图像生成

第二部分深入探讨了图像生成。

在第四章中,你将学习如何构建和训练生成对抗网络以生成高分辨率的彩色图像。特别是,你将学习如何使用卷积神经网络来捕捉图像中的空间特征。你还将学习如何使用转置卷积层在图像中上采样并生成高分辨率特征图。在第五章中,你将学习两种在生成图像中选择特征的方法。在第六章中,你将学习构建和训练一个 CycleGAN,以在两个领域之间转换图像,例如黑发图像和金发图像或马图像和斑马图像。在第七章中,你将学习使用另一种生成模型:自编码器及其变体,变分自编码器来创建图像。

第四章:使用生成对抗网络进行图像生成

本章涵盖

  • 通过镜像判别网络中的步骤来设计生成器

  • 2D 卷积操作在图像上的工作原理

  • 2D 转置卷积操作如何在输出值之间插入间隔并生成更高分辨率的特征图

  • 构建和训练生成对抗网络以生成灰度和彩色图像

在第三章中,你已经成功生成了一个指数增长曲线和一系列都是 5 的倍数的整数。现在,既然你已经理解了生成对抗网络(GANs)的工作原理,你就可以应用相同的技能来生成许多其他形式的内容,例如高分辨率彩色图像和听起来逼真的音乐。然而,这说起来容易做起来难(你知道他们怎么说:魔鬼藏在细节里)。例如,我们究竟如何让生成器凭空创造出逼真的图像呢?这正是本章将要解决的问题。

生成器从零开始创建图像的常见方法是通过镜像判别网络中的步骤。在本章的第一个项目中,你的目标是创建如外套、衬衫、凉鞋等服装物品的灰度图像。你将学习在设计生成器网络时如何镜像判别网络中的层。在这个项目中,生成器和判别网络都只使用了密集层。密集层中的每个神经元都与前一层的每个神经元和后一层的每个神经元相连。因此,密集层也被称为全连接层。

在本章的第二个项目中,你的目标是创建动漫面孔的高分辨率彩色图像。就像在第一个项目中一样,生成器通过镜像判别网络中的步骤来生成图像。然而,在这个项目中,高分辨率彩色图像包含的像素比第一个项目中的低分辨率灰度图像要多得多。如果我们只使用密集层,模型的参数数量会急剧增加。这反过来又使得学习变得缓慢且无效。因此,我们转向卷积神经网络(CNNs)。在 CNNs 中,一个层中的每个神经元仅与输入的小区域相连。这种局部连接减少了参数数量,使得网络更加高效。与相同大小的全连接网络相比,CNNs 需要的参数更少,这导致了更快的训练时间和更低的计算成本。CNNs 通常在捕捉图像数据中的空间层次结构方面也更为有效,因为它们将图像视为多维对象,而不是一维向量。

为了让你为第二个项目做好准备,我们将向你展示卷积操作是如何工作的,以及它们如何对输入图像进行下采样并从中提取空间特征。你还将学习诸如滤波器大小、步长和零填充等概念,以及它们如何影响 CNN 中的下采样程度。虽然判别器网络使用卷积层,但生成器通过使用转置卷积层(也称为反卷积或上采样层)来镜像这些层。你将学习转置卷积层是如何用于上采样以生成高分辨率特征图的。

总结来说,你将在本章学习如何镜像判别器网络的步骤,从头开始创建图像。此外,你还将学习卷积层和转置卷积层的工作原理。在本章之后,你将使用卷积层和转置卷积层在本书的其他设置中创建高分辨率图像(例如,在训练 CycleGAN 将金发转换为黑发时进行特征转换,或在变分自编码器[VAE]中生成高分辨率人脸图像)。

4.1 使用 GAN 生成服装物品的灰度图像

我们在第一个项目中的目标是训练一个模型,生成如凉鞋、T 恤、外套和包等服装物品的灰度图像。

当你使用 GAN 生成图像时,你将始终从获取训练数据开始。然后,你将从头创建一个判别器网络。在创建生成器网络时,你将镜像判别器网络中的步骤。最后,你将训练 GAN 并使用训练好的模型进行图像生成。让我们通过一个简单的项目来看看这是如何工作的,该项目生成服装物品的灰度图像。

4.1.1 训练样本和判别器

准备训练数据所涉及的步骤与我们在第二章中做的类似,但有几点例外,我稍后会强调。为了节省时间,我将跳过你在第二章中已经看到的步骤,并指导你参考书籍的 GitHub 仓库。按照书籍 GitHub 仓库中该章节的 Jupyter Notebook 中的步骤进行操作(github.com/markhliu/DGAI),以便你创建一个带有批次的迭代器。

训练集中有 60,000 张图像。在第二章中,我们将训练集进一步分为训练集和验证集。我们使用验证集中的损失来确定参数是否收敛,以便我们可以停止训练。然而,与传统的监督学习模型(如第二章中看到的分类模型)相比,GANs 的训练采用不同的方法。由于生成的样本质量在整个训练过程中不断提高,判别器的任务变得越来越困难。判别器网络的损失并不是模型质量的良好指标。衡量 GANs 性能的通常方法是通过视觉检查来评估生成图像的质量和逼真度。我们可以将生成样本的质量与训练样本进行比较,并使用诸如 Inception Score 等方法来评估 GANs 的性能(例如,参见 Ali Borji 于 2018 年撰写的“GAN 评估措施的优缺点”,对各种 GAN 评估方法进行了调查;arxiv.org/abs/1802.03446)。然而,研究人员已经记录了这些措施的弱点(“关于 Inception Score 的笔记”,由 Shane Barratt 和 Rishi Sharma 于 2018 年撰写,表明在比较模型时,inception score 无法提供有用的指导;arxiv.org/abs/1801.01973)。在本章中,我们将定期进行视觉检查以检查生成样本的质量,并确定何时停止训练。

判别器网络是一个二元分类器,这与我们在第二章中讨论的服装项目的二元分类器相似。在这里,判别器的任务是分类样本为真实或假。

我们使用 PyTorch 创建以下判别器神经网络 D:

import torch
import torch.nn as nn

device="cuda" if torch.cuda.is_available() else "cpu"
D=nn.Sequential(
    nn.Linear(784, 1024),          ①
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(1024, 512),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(512, 256),
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(256, 1),    
    nn.Sigmoid()).to(device)       ②

① 第一个全连接层有 784 个输入和 1,024 个输出。

② 最后一个全连接层有 256 个输入和 1 个输出。

输入大小为 784,因为训练集中每个灰度图像的大小为 28 × 28 像素。由于密集层只接受 1D 输入,我们在将图像输入模型之前将其展平。输出层只有一个神经元:判别器 D 的输出是一个单一值。我们使用 sigmoid 激活函数将输出挤压到[0, 1]的范围内,以便它可以解释为样本是真实的概率,p。使用互补概率 1 – p,样本是假的。

练习 4.1

修改判别器 D,使其前三个层的输出数量分别为 1,000、500 和 200,而不是 1,024、512 和 256。确保一个层的输出数量与下一层的输入数量相匹配。

4.1.2 生成灰度图像的生成器

虽然创建判别器网络相对简单,但如何创建一个生成器以生成逼真的图像则是另一回事。一种常见的方法是镜像判别器网络中使用的层来创建生成器,如下面的列表所示。

列表 4.1 通过镜像判别器中的层来设计生成器

G=nn.Sequential(
    nn.Linear(100, 256),         ①
    nn.ReLU(),
    nn.Linear(256, 512),         ②
    nn.ReLU(),
    nn.Linear(512, 1024),        ③
    nn.ReLU(),
    nn.Linear(1024, 784),        ④
    nn.Tanh()).to(device)        ⑤

① 生成器的第一层与判别器的最后一层是对称的。

② 生成器的第二层与判别器的倒数第二层是对称的(输入和输出的位置已交换)。

③ 生成器的第三层与判别器的倒数第三层是对称的。

④ 生成器的最后一层与判别器的第一层是对称的。

⑤ 使用 Tanh()激活函数,使得输出值在-1 和 1 之间,与图像中的值相同

图 4.1 展示了 GAN 生成服装物品灰度图像的生成器和判别器网络的架构图。如图 4.1 右上角所示,来自训练集的平面灰度图像,包含 28 × 28 = 784 个像素,在判别器网络中依次通过四个密集层,输出是图像为真实的概率。为了创建图像,生成器使用相同的四个密集层,但顺序相反:它从潜在空间(图 4.1 左下角)获得一个 100 值的随机噪声向量,并将向量通过四个密集层。在每个层中,判别器中的输入和输出数量被反转,并用作生成器的输出和输入数量。最后,生成器得到一个 784 值的张量,可以被重塑为一个 28 × 28 的灰度图像(图 4.1 左上角)。

图片

图 4.1 通过镜像判别器网络中的层来设计生成器网络以创建服装物品。图例的右侧显示了判别器网络,其中包含四个密集层。为了设计一个可以从无中生有地创造出服装物品的生成器,我们镜像了判别器网络中的层。具体来说,如图的左侧所示,生成器中有四个类似的密集层,但顺序相反:生成器的第一层镜像判别器的最后一层,生成器的第二层镜像判别器的倒数第二层,以此类推。此外,在顶部三个层中,判别器中的输入和输出数量被反转,并用作生成器的输出和输入数量。

图 4.1 的左侧是生成器网络,而右侧是判别器网络。如果你比较这两个网络,你会注意到生成器如何镜像判别器中使用的层。具体来说,生成器中有四个相似的密集层,但顺序相反:生成器中的第一层反映了判别器中的最后一层,生成器中的第二层反映了判别器中的倒数第二层,以此类推。生成器的输出数量为 784,经过Tanh()激活后的值介于-1 和 1 之间,这与判别器网络的输入相匹配。

练习 4.2

修改生成器 G,使其前三个层的输出数量分别为 1,000、500 和 200,而不是 1,024、512 和 256。确保修改后的生成器与修改后的判别器在练习 4.1 中使用的层相匹配。

如同我们在第三章中看到的 GAN 模型,损失函数是二元交叉熵损失,因为判别器 D 执行的是二元分类问题。我们将使用 Adam 优化器来优化判别器和生成器,学习率为 0.0001:

loss_fn=nn.BCELoss()
lr=0.0001
optimD=torch.optim.Adam(D.parameters(),lr=lr)
optimG=torch.optim.Adam(G.parameters(),lr=lr)  

接下来,我们将使用训练数据集中的服装物品图像来训练我们刚刚创建的 GANs。

4.1.3 训练 GANs 生成服装物品的图像

训练过程与我们第三章训练 GANs 生成指数增长曲线或生成所有都是 5 的倍数的数字序列时所做的类似。

与第三章不同,我们将仅依靠视觉检查来确定模型是否训练良好。为此,我们定义了一个 see_output() 函数,以定期可视化生成器创建的假图像。

注意:感兴趣的读者可以查看这个 GitHub 仓库,了解如何在 PyTorch 中实现 inception score 以评估 GANs:github.com/sbarratt/inception-score-pytorch。然而,该仓库不建议使用 inception score 来评估生成模型,因为它效果不佳。

列表 4.2 定义一个函数来可视化生成的服装物品

import matplotlib.pyplot as plt

def see_output():
    noise=torch.randn(32,100).to(device=device)
    fake_samples=G(noise).cpu().detach()                 ①
    plt.figure(dpi=100,figsize=(20,10))
    for i in range(32):
        ax=plt.subplot(4, 8, i + 1)                      ②
        img=(fake_samples[i]/2+0.5).reshape(28, 28)
        plt.imshow(img)                                  ③
        plt.xticks([])
        plt.yticks([])
    plt.show()

see_output()                                             ④

① 生成 32 张假图像

② 将它们绘制在 4 × 8 的网格中

③ 显示第 i 张图像

④ 在训练前调用 see_output() 函数来可视化生成的图像

如果你运行前面的代码单元,你会看到 32 张看起来像电视屏幕上的雪花静态图像,如图 4.2 所示。它们根本不像服装物品,因为我们还没有训练生成器。

图 4.2 GAN 模型在训练前生成服装物品的输出。由于模型尚未训练,生成的图像与训练集中的图像毫不相像。

要训练 GAN 模型,我们定义了几个函数:train_D_on_real()train_D_on_fake()train_G()。它们与第三章中定义的类似。请访问本书 GitHub 仓库中本章的 Jupyter Notebook,查看我们做了哪些细微的修改。

现在我们已准备好训练模型。我们遍历训练数据集中的所有批次。对于每个数据批次,我们首先使用真实样本训练判别器。之后,生成器创建一个假样本批次,我们再次使用它们来训练判别器。最后,我们让生成器再次创建一个假样本批次,但这次我们使用它们来训练生成器。我们训练模型 50 个周期,如下所示。

列表 4.3 训练 GAN 以生成服装项目

for i in range(50):    
    gloss=0
    dloss=0
    for n, (real_samples,_) in enumerate(train_loader):
        loss_D=train_D_on_real(real_samples)            ①
        dloss+=loss_D
        loss_D=train_D_on_fake()                        ②
        dloss+=loss_D
        loss_G=train_G()                                ③
        gloss+=loss_G
    gloss=gloss/n
    dloss=dloss/n    
    if i % 10 == 9:
        print(f"at epoch {i+1}, dloss: {dloss}, gloss {gloss}")
        see_output()                                    ④

① 使用真实样本训练判别器

② 使用假样本训练判别器

③ 训练生成器

④ 每 10 个周期后可视化生成的样本

如果你使用 GPU 训练,训练大约需要 10 分钟。否则,可能需要一个小时左右,具体取决于你电脑的硬件配置。或者你可以从我的网站上下载训练好的模型:gattonweb.uky.edu/faculty/lium/gai/fashion_gen.zip。下载后请解压。

在每 10 个周期的训练后,你可以可视化生成的服装项目,如图 4.3 所示。仅仅经过 10 个周期的训练,模型就能生成可以明显以真品身份出现的服装项目:你可以辨认出它们。图 4.3 第一行前三个项目显然是一件外套、一件连衣裙和一条裤子,例如。随着训练的进行,生成的图像质量越来越好。

图 4.3 经过 10 个周期训练的图像 GAN 模型生成的服装项目

正如我们在所有 GAN 中所做的那样,我们丢弃判别器并保存训练好的生成器以供以后生成样本:

scripted = torch.jit.script(G) 
scripted.save('files/fashion_gen.pt') 

我们现在已将生成器保存在本地文件夹中。要使用生成器,我们加载模型:

new_G=torch.jit.load('files/fashion_gen.pt',
                     map_location=device)
new_G.eval()

生成器现在已加载。我们可以用它来生成服装项目:

noise=torch.randn(32,100).to(device=device)
fake_samples=new_G(noise).cpu().detach()
for i in range(32):
    ax = plt.subplot(4, 8, i + 1)
    plt.imshow((fake_samples[i]/2+0.5).reshape(28, 28))
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(hspace=-0.6)
plt.show() 

生成的服装项目如图 4.4 所示。如图所示,服装项目与训练集中的项目相当接近。

图 4.4 经过 50 个周期训练的图像 GAN 模型生成的服装项目

现在你已经学会了如何使用 GAN 创建灰度图像,你将在本章剩余部分学习如何使用深度卷积 GAN (DCGAN) 生成高分辨率彩色图像。

4.2 卷积层

要创建高分辨率彩色图像,我们需要比简单的全连接神经网络更复杂的技巧。具体来说,我们将使用卷积神经网络(CNNs),它们在处理具有网格状拓扑结构的数据(如图像)方面特别有效。它们在几个方面与全连接(密集)层不同。首先,在 CNNs 中,每一层的每个神经元仅连接到输入的小区域。这是基于这样的理解:在图像数据中,局部像素群更有可能相互关联。这种局部连接性减少了参数数量,使网络更高效。其次,CNNs 使用共享权重——相同的权重被用于输入的不同区域。这类似于在整个输入空间上滑动一个过滤器。这个过滤器检测特定的特征(例如,边缘或纹理),而不管它们在输入中的位置如何,从而导致了平移不变性的特性。

由于它们的结构,CNNs 在图像处理方面更高效。它们所需的参数比类似大小的全连接网络少,导致训练时间更快和计算成本更低。它们通常在捕获图像数据中的空间层次结构方面也更为有效。

卷积层和转置卷积层是 CNNs 中的两个基本构建块,常用于图像处理和计算机视觉任务。它们有不同的目的和特性:卷积层用于特征提取。它们将一组可学习的过滤器(也称为核)应用于输入数据,以检测不同空间尺度上的模式和特征。这些层对于捕获输入数据的层次表示至关重要。相比之下,转置卷积层用于上采样或生成高分辨率特征图。

在本节中,你将学习卷积操作的工作原理以及核大小、步长和零填充如何影响卷积操作。

4.2.1 卷积操作是如何工作的?

卷积层使用过滤器从输入数据中提取空间模式。一个卷积层能够自动检测大量模式并将它们与目标标签关联起来。因此,卷积层通常用于图像分类任务。

卷积操作涉及将一个过滤器应用于输入图像以生成一个特征图。这个过程包括使用过滤器与输入图像的逐元素乘法,并将结果相加。过滤器中的权重在过滤器在输入图像上移动以扫描不同区域时保持不变。图 4.5 展示了卷积操作的工作原理的数值示例。左侧列是输入图像,第二列是一个过滤器(一个 2×2 矩阵)。卷积操作(第三列)涉及将过滤器在输入图像上滑动,乘以相应的元素,并将它们相加(最后一列)。

图片

图 4.5 卷积操作如何工作的数值示例,步长为 1,无填充

为了深入理解卷积操作的确切工作原理,让我们在 PyTorch 中并行实现卷积操作,以便您可以验证如图 4.5 所示的数字。首先,让我们创建一个 PyTorch 张量来表示图中的输入图像:

img = torch.Tensor([[1,1,1],
                    [0,1,2],
                    [8,7,6]]).reshape(1,1,3,3)    ①

① 图像形状中的四个值(1, 1, 3, 3)分别是批处理中的图像数量、颜色通道数量、图像高度和图像宽度。

图像被重塑,使其具有维度(1, 1, 3, 3),表示批处理中只有一个观察值,图像只有一个颜色通道。图像的高度和宽度都是 3 像素。

让我们通过在 PyTorch 中创建一个 2 × 2 滤波器,如图 4.5 的第二列所示,来表示它:

conv=nn.Conv2d(in_channels=1,
            out_channels=1,
            kernel_size=2, 
            stride=1)                             ①
sd=conv.state_dict()                              ②
print(sd)

① 初始化一个 2D 卷积层

② 从层中提取随机初始化的权重和偏置

2D 卷积层需要几个参数。in_channels参数是输入图像中的通道数。对于灰度图像,这个值是 1,对于彩色图像,这个值是 3,因为彩色图像有三个颜色通道(红色、绿色和蓝色[RGB])。out_channels是卷积层之后的通道数,可以根据你想从图像中提取多少特征来取任何数值。kernel_size参数控制内核的大小;例如,kernel_size=3表示滤波器的大小为 3 × 3,而kernel_size=4表示滤波器的大小为 4 × 4。我们将内核大小设置为 2,因此滤波器的大小为 2 × 2。

2D 卷积层也有几个可选参数。stride参数指定每次滤波器在输入图像上移动时向右或向下移动多少像素。stride参数的默认值为 1。更大的步长值会导致图像的更多下采样。padding参数表示在输入图像的四边添加多少行零,默认值为 0。bias参数表示是否将可学习的偏置作为参数添加,默认值为True

前面的 2D 卷积层有一个输入通道,一个输出通道,内核大小为 2 × 2,步长为 1。当创建卷积层时,其中的权重和偏置是随机初始化的。您将看到以下输出作为此卷积层的权重和偏置:

OrderedDict([('weight', tensor([[[[ 0.3823,  0.4150],
          [-0.1171,  0.4593]]]])), ('bias', tensor([-0.1096]))])

为了使我们的例子更容易理解,我们将权重和偏置替换为整数:

weights={'weight':torch.tensor([[[[1,2],
   [3,4]]]]), 'bias':torch.tensor([0])}          ①
for k in sd:
    with torch.no_grad():
        sd[k].copy_(weights[k])                  ②
print(conv.state_dict())                         ③

① 手动选择权重和偏置

② 将卷积层中的权重和偏置替换为我们手动选择的数字

打印出卷积层中的新权重和偏置

由于我们不在卷积层中学习参数,使用 torch.no_grad() 来禁用梯度计算,这减少了内存消耗并加快了计算速度。现在卷积层具有我们选择的权重和偏置,它们也匹配图 4.5 中的数字。前面代码单元格的输出为:

OrderedDict([('weight', tensor([[[[1., 2.],
          [3., 4.]]]])), ('bias', tensor([0.]))])

如果我们将前面提到的 3 × 3 图像应用于卷积层,输出是什么?让我们来看看:

output = conv(img)
print(output)

输出为

tensor([[[[ 7., 14.],
          [54., 50.]]]], grad_fn=<ConvolutionBackward0>)

输出的形状为 (1, 1, 2, 2),其中包含四个值:7、14、54 和 50。这些数字与图 4.5 中的数字相匹配。

但卷积层究竟是如何通过滤波器生成这个输出的?我们将在下文中详细解释。

输入图像是一个 3 × 3 矩阵,滤波器是一个 2 × 2 矩阵。当滤波器在图像上扫描时,它首先覆盖图像左上角四个像素,这些像素的值为 [[1, 1], [0, 1]],如图 4.5 的第一行所示。滤波器的值为 [[1,2],[3,4]]。卷积操作找到两个张量(在这种情况下,一个张量是滤波器,另一个是被覆盖的区域)逐元素乘积的和。换句话说,卷积操作在每个四个单元格中执行逐元素乘法,然后将四个单元格中的值相加。因此,扫描左上角得到的输出是

1 × 1 × 1 × 2 + 0 × 3 + 1 × 4 = 7。

这解释了为什么输出左上角的值为 7。同样,当滤波器应用于图像的右上角时,覆盖区域为 [[1,1],[1,2]]。因此,输出为:

1 × 1 + 1 × 2 + 1 × 3 + 2 × 4 = 14。

这解释了为什么输出右上角的值为 14。

练习 4.3

当滤波器应用于图像右下角时,覆盖区域的值是什么?解释为什么输出右下角的值为 50。

4.2.2 步长和填充如何影响卷积操作?

步长和零填充是卷积操作中的两个重要概念。它们在确定输出特征图的维度以及滤波器与输入数据交互的方式中起着至关重要的作用。

步长指的是滤波器在输入图像上移动的像素数。当步长为 1 时,滤波器每次移动 1 个像素。较大的步长意味着滤波器在滑动图像时跳过了更多的像素。增加步长会减少输出特征图的空間维度。

零填充涉及在应用卷积操作之前在输入图像的边缘添加零层。零填充允许控制输出特征图的空間维度。没有填充,输出的维度将小于输入。通过添加填充,可以保留输入的维度。

让我们用一个例子来展示步长和填充是如何工作的。下面的代码单元重新定义了 2D 卷积层:

conv=nn.Conv2d(in_channels=1,
            out_channels=1,
            kernel_size=2, 
            stride=2,                           ①
            padding=1)                          ②
sd=conv.state_dict()
for k in sd:
    with torch.no_grad():
        sd[k].copy_(weights[k])
output = conv(img)
print(output)

① 将步长从 1 改为 2

② 将填充从 0 改为 1

输出是

tensor([[[[ 4.,  7.],
          [32., 50.]]]], grad_fn=<ConvolutionBackward0>)

padding=1参数在输入图像周围添加一行 0,因此填充后的图像现在的大小为 5 × 5 而不是 3 × 3。

当过滤器扫描填充后的图像时,它首先覆盖左上角,其值为[[0, 0], [0, 1]]。过滤器的值为[[1,2],[3,4]]。因此,扫描左上角的输出为:

0 × 1+0 × 2+0 × 3+1 × 4=4

这解释了为什么输出图像的左上角有一个值为 4。同样,当过滤器向下滑动两个像素到达图像的左下角时,覆盖的区域是[[0,0],[0,8]]。因此,输出如下:

0 × 1+0 × 2+0 × 3+8 × 4=32

这解释了为什么输出图像的左下角有一个值为 32。

4.3 转置卷积和批量归一化

转置卷积层也被称为反卷积或上采样层。它们用于上采样或生成高分辨率特征图。它们通常在生成模型如 GAN 和 VAE 中使用。

转置卷积层将过滤器应用于输入数据,但与标准卷积不同,它们通过在输出值之间插入间隔来增加空间维度,从而有效地“上采样”特征图。这个过程生成了更高分辨率的特征图。转置卷积层有助于提高空间分辨率,这在图像生成中很有用。

步长可以在转置卷积层中使用来控制上采样的量。步长的值越大,转置卷积层对输入数据的上采样就越多。

二维批量归一化是一种在神经网络中使用的技巧,特别是在 CNN 中,用于稳定和加速训练过程。它解决了包括饱和、消失梯度和梯度爆炸在内的几个问题,这些都是深度学习中常见的挑战。在本节中,你将查看一些示例,以便更深入地了解其工作原理。你将在下一节创建 GAN 时生成高分辨率彩色图像时使用它。

深度学习中的消失和梯度爆炸

消失梯度问题发生在深度神经网络中,当在反向传播过程中损失函数相对于网络参数的梯度变得极其小时。这导致参数更新非常缓慢,阻碍了学习过程,尤其是在网络的早期层。相反,梯度爆炸问题发生在这些梯度变得过大时,导致更新不稳定,并使模型参数振荡或发散到非常大的值。这两个问题都阻碍了深度神经网络的 有效训练。

4.3.1 转置卷积层是如何工作的?

与卷积层相反,转置卷积层通过使用核(即滤波器)在图像中上采样并填充间隙来生成特征并增加分辨率。在转置卷积层中,输出通常比输入大。因此,转置卷积层是生成高分辨率图像时的必要工具。为了向您展示 2D 转置卷积操作的确切工作方式,让我们用一个简单的例子和一张图来说明。假设你有一个非常小的 2 × 2 输入图像,如图 4.6 的左侧列所示。

图 4.6 转置卷积操作的一个数值示例

输入图像中的值为:

img = torch.Tensor([[1,0],
                    [2,3]]).reshape(1,1,2,2)

你想要上采样图像,使其具有更高的分辨率。你可以在 PyTorch 中创建一个 2D 转置卷积层:

transconv=nn.ConvTranspose2d(in_channels=1,
            out_channels=1,
            kernel_size=2, 
            stride=2)                            ①
sd=transconv.state_dict()
weights={'weight':torch.tensor([[[[2,3],
   [4,5]]]]), 'bias':torch.tensor([0])}
for k in sd:
    with torch.no_grad():
        sd[k].copy_(weights[k])                  ②

① 一个具有一个输入通道、一个输出通道、核大小为 2、步长为 2 的转置卷积层

② 用挑选的值替换转置卷积层中的权重和偏置

这个 2D 转置卷积层有一个输入通道,一个输出通道,核大小为 2 × 2,步长为 2。2 × 2 滤波器如图 4.6 的第二列所示。我们用我们挑选的整数替换了层中的随机初始化权重和偏置,这样便于跟踪计算。前述代码列表中的 state_dict() 方法返回深度神经网络中的参数。

当将转置卷积层应用于我们之前提到的 2 × 2 图像时,输出是什么?让我们来找出答案:

transoutput = transconv(img)
print(transoutput)

输出为

tensor([[[[ 2.,  3.,  0.,  0.],
          [ 4.,  5.,  0.,  0.],
          [ 4.,  6.,  6.,  9.],
          [ 8., 10., 12., 15.]]]], grad_fn=<ConvolutionBackward0>)

输出的形状为 (1, 1, 4, 4),这意味着我们将一个 2 × 2 的图像上采样到一个 4 × 4 的图像。转置卷积层是如何通过滤波器生成前面的输出的?我们将在下文中详细解释。

图像是一个 2 × 2 矩阵,滤波器也是一个 2 × 2 矩阵。当滤波器应用于图像时,图像中的每个元素与滤波器相乘,并传递到输出。图像左上角的值是 1,我们将其与滤波器中的值 [[2, 3], [4, 5]] 相乘,这导致输出矩阵 transoutput 左上角块中的四个值,值为 [[2, 3], [4, 5]],如图 4.6 右上角所示。同样,图像左下角的值是 2,我们将其与滤波器中的值 [[2, 3], [4, 5]] 相乘,这导致输出矩阵 transoutput 左下角块中的四个值,值为 [[4, 6], [8, 10]]

练习 4.4

如果一个图像中包含值 [[10, 10], [15, 20]],在你对该图像应用 2D 转置卷积层 transconv 后,输出是什么?假设 transconv 中的值为 [[2, 3], [4, 5]]。假设核大小为 2,步长大小为 2。

4.3.2 批标准化

二维批量归一化是现代深度学习框架中的标准技术,并已成为有效训练深度神经网络的关键组件。你将在本书后面的内容中经常看到它。

在二维批量归一化中,通过调整和缩放通道中的值,使其具有 0 均值和 1 方差,对每个特征通道独立进行归一化。特征通道是指 CNN 中多维张量中的一个维度,用于表示输入数据的各个方面或特征。例如,它们可以表示颜色通道,如红色、绿色或蓝色。归一化确保了在训练过程中,网络深层输入的分布保持更稳定。这种稳定性源于归一化过程减少了内部协变量偏移,这是由于较低层权重的更新导致的网络激活分布的变化。它还有助于通过保持输入在适当的范围内,防止梯度变得太小(消失)或太大(爆炸)来解决问题。1

这是二维批量归一化工作原理的说明:对于每个特征通道,我们首先计算该通道内所有观察值的均值和方差。然后,我们使用之前获得的均值和方差(通过从每个观察值中减去均值,然后除以标准差)对每个特征通道的值进行归一化。这确保了归一化后每个通道的值具有 0 均值和 1 标准差,这有助于稳定和加速训练。它还有助于在反向传播过程中保持稳定的梯度,这进一步有助于训练深度神经网络。

让我们用一个具体的例子来说明二维批量归一化是如何工作的。

假设你有一个大小为 64 × 64 的三通道输入。你将输入通过一个有三个输出通道的 2D 卷积层,如下所示:

torch.manual_seed(42)                          ①
img = torch.rand(1,3,64,64)                    ②
conv = nn.Conv2d(in_channels=3,
            out_channels=3,
            kernel_size=3, 
            stride=1,
            padding=1)                         ③
out=conv(img)                                  ④
print(out.shape)

① 固定随机状态,以确保结果可重复

② 创建一个 3 通道输入

③ 创建一个二维卷积层

④ 将输入通过卷积层传递

上述代码单元格的输出是

torch.Size([1, 3, 64, 64])

我们创建了一个三通道输入,并通过一个有三个输出通道的 2D 卷积层传递。处理后的输入有三个通道,大小为 64 × 64 像素。

让我们看看三个输出通道中每个通道的像素均值和标准差:

for i in range(3):
    print(f"mean in channel {i} is", out[:,i,:,:].mean().item())
    print(f"std in channel {i} is", out[:,i,:,:].std().item())

输出是

mean in channel 0 is -0.3766776919364929
std in channel 0 is 0.17841289937496185
mean in channel 1 is -0.3910464942455292
std in channel 1 is 0.16061744093894958
mean in channel 2 is 0.39275866746902466
std in channel 2 is 0.18207983672618866

每个输出通道中像素的平均值不是 0;每个输出通道中像素的标准差不是 1。现在,我们执行二维批量归一化:

norm=nn.BatchNorm2d(3)
out2=norm(out)
print(out2.shape)
for i in range(3):
    print(f"mean in channel {i} is", out2[:,i,:,:].mean().item())
    print(f"std in channel {i} is", out2[:,i,:,:].std().item())

然后我们有以下输出:

torch.Size([1, 3, 64, 64])
mean in channel 0 is 6.984919309616089e-09
std in channel 0 is 0.9999650120735168
mean in channel 1 is -5.3085386753082275e-08
std in channel 1 is 0.9999282956123352
mean in channel 2 is 9.872019290924072e-08
std in channel 2 is 0.9999712705612183

每个输出通道的像素平均值现在实际上为 0(或一个非常接近 0 的小数);每个输出通道的像素标准差现在是一个接近 1 的数。这就是批量归一化的作用:它对每个特征通道中的观测值进行归一化,使得每个特征通道中的值具有 0 均值和单位标准差。

4.4 动漫面孔的彩色图像

在这个第二个项目中,你将学习如何创建高分辨率彩色图像。这个项目的训练步骤与第一个项目类似,不同之处在于训练数据是动漫面孔的彩色图像。此外,判别器和生成器神经网络更加复杂。我们将在两个网络中使用 2D 卷积和 2D 转置卷积层。

4.4.1 下载动漫面孔

你可以从 Kaggle mng.bz/1a9R下载训练数据,其中包含 63,632 张动漫面孔的彩色图像。你需要先设置一个免费的 Kaggle 账户进行登录。从 zip 文件中提取数据,并将它们放在你电脑上的一个文件夹中。例如,我将 zip 文件中的所有内容都放在了电脑上的/files/anime/文件夹中。因此,所有动漫面孔图像都在/files/anime/images/文件夹中。

定义路径名,以便以后可以在 Pytorch 中加载图像:

anime_path = r"files/anime"

根据你在电脑上保存图像的位置更改路径名。请注意,ImageFolder()类使用图像的目录名来识别图像所属的类别。因此,我们之前定义的anime_path中不包括最终的/images/目录。

接下来,我们使用 Torchvision datasets包中的ImageFolder()类来加载数据集:

from torchvision import transforms as T
from torchvision.datasets import ImageFolder

transform = T.Compose([T.Resize((64, 64)),               ①
    T.ToTensor(),                                        ②
    T.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])])      ③
train_data = ImageFolder(root=anime_path,
                         transform=transform)            ④

① 将图像大小调整为 64 × 64

② 将图像转换为 PyTorch 张量

③ 在所有三个颜色通道中将图像值归一化到[-1, 1]

④ 加载数据并转换图像

在从本地文件夹加载图像时,我们执行了三种不同的转换。首先,我们将所有图像的高度和宽度都调整为 64 像素。其次,我们使用ToTensor()类将图像转换为 PyTorch 张量,其值在[0, 1]范围内。最后,我们使用Normalize()类从值中减去 0.5,并将差值除以 0.5。因此,图像数据现在在-1 和 1 之间。

我们现在可以将训练数据放入批次中:

from torch.utils.data import DataLoader

batch_size = 128
train_loader = DataLoader(dataset=train_data, 
               batch_size=batch_size, shuffle=True)

训练数据集现在是批次的,批大小为 128。

4.4.2 PyTorch 中的通道优先彩色图像

PyTorch 在处理彩色图像时使用所谓的通道优先方法。这意味着 PyTorch 中图像的形状是(通道数,高度,宽度)。相比之下,在其他 Python 库(如 TensorFlow 或 Matplotlib)中,使用的是通道后方法:彩色图像的形状为(高度,宽度,通道数)。

让我们看看数据集中的一张示例图像,并打印出图像的形状:

image0, _ = train_data[0]
print(image0.shape)

输出为

torch.Size([3, 64, 64])

第一张图像的形状是 3 × 64 × 64。这意味着图像有三个颜色通道(RGB)。图像的高度和宽度都是 64 像素。

当我们在 Matplotlib 中绘制图像时,我们需要使用 PyTorch 中的 permute() 方法将它们转换为通道最后:

import matplotlib.pyplot as plt

plt.imshow(image0.permute(1,2,0)*0.5+0.5)
plt.show()

注意,我们需要将代表图像的 PyTorch 张量乘以 0.5,然后加上 0.5,以将值从范围 [–1, 1] 转换为范围 [0, 1]。运行前面的代码单元后,您将看到一个动漫人脸的绘图。

接下来,我们定义一个函数 plot_images() 来可视化四行八列的 32 张图像:

def plot_images(imgs):                              ①
    for i in range(32):
        ax = plt.subplot(4, 8, i + 1)               ②
        plt.imshow(imgs[i].permute(1,2,0)/2+0.5)
        plt.xticks([])
        plt.yticks([])
    plt.subplots_adjust(hspace=-0.6)
    plt.show()    

imgs, _ = next(iter(train_loader))                  ③
plot_images(imgs)                                   ④

① 定义一个函数来可视化 32 张图像

② 将它们放置在 4 × 8 的网格中

③ 获取一批图像

④ 调用函数来可视化图像

运行前面的代码单元后,您将看到一个 4 × 8 网格中的 32 张动漫人脸的绘图,如图 4.7 所示。

图片

图 4.7 动漫人脸训练数据集的示例

4.5 深度卷积 GAN

在本节中,您将创建一个 DCGAN 模型,以便我们可以训练它生成动漫人脸图像。像往常一样,GAN 模型由判别器网络和生成器网络组成。然而,这些网络比我们之前看到的更复杂:在这些网络中,我们将使用卷积层、转置卷积层和批量归一化层。

我们将从判别器网络开始。之后,我将解释生成器网络如何镜像判别器网络中的层来生成逼真的彩色图像。然后,您将使用本章前面准备的数据训练模型,并使用训练好的模型生成动漫人脸图像的新颖图像。

4.5.1 构建 DCGAN

如我们之前看到的 GAN 模型一样,判别器是一个二元分类器,用于将样本分类为真实或伪造。然而,与迄今为止我们使用的网络不同,我们将使用卷积层和批量归一化。本项目中的高分辨率彩色图像参数太多,如果我们只使用密集层,就难以有效地训练模型。判别器神经网络的结构如下所示。

列表 4.4 DCGAN 中的判别器

import torch.nn as nn
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"

D = nn.Sequential(
    nn.Conv2d(3, 64, 4, 2, 1, bias=False),           ①
    nn.LeakyReLU(0.2, inplace=True),                 ②
    nn.Conv2d(64, 128, 4, 2, 1, bias=False),
    nn.BatchNorm2d(128),                             ③
    nn.LeakyReLU(0.2, inplace=True),
    nn.Conv2d(128, 256, 4, 2, 1, bias=False),
    nn.BatchNorm2d(256),
    nn.LeakyReLU(0.2, inplace=True),
    nn.Conv2d(256, 512, 4, 2, 1, bias=False),
    nn.BatchNorm2d(512),
    nn.LeakyReLU(0.2, inplace=True),
    nn.Conv2d(512, 1, 4, 1, 0, bias=False),
    nn.Sigmoid(),
    nn.Flatten()).to(device)                         ④

① 将图像通过一个二维卷积层

② 在第一个卷积层的输出上应用 LeakyReLU 激活函数

③ 对第二个卷积层的输出执行二维批量归一化

④ 输出是一个介于 0 和 1 之间的单个值,可以解释为图像是真实的概率。

判别器网络的输入是一个具有三个颜色通道的彩色图像。第一个二维卷积层是 Conv2d(3, 64, 4, 2, 1, bias=False):这意味着输入有三个通道,输出有 64 个通道;核大小为 4;步长为 2;填充为 1。网络中的每个二维卷积层都接受一个图像并应用滤波器以提取空间特征。

从第二个 2D 卷积层开始,我们在输出上应用 2D 批量归一化(我在上一节中解释过)和 LeakyReLU 激活(我将在后面解释)。LeakyReLU 激活函数是 ReLU 的修改版。它允许输出在零以下的值具有斜率。具体来说,LeakyReLU 函数的定义如下:

其中β是介于 0 和 1 之间的常数。LeakyReLU 激活函数通常用于解决稀疏梯度问题(当大多数梯度变为零或接近零时)。训练 DCGANs 就是这样一种情况。当神经元的输入为负时,ReLU 的输出为零,神经元变得不活跃。LeakyReLU 对负输入返回一个小的负值,而不是零。这有助于保持神经元活跃和学习,保持更好的梯度流动,并导致模型参数更快地收敛。

在构建服装生成器的生成器时,我们将采用相同的方法。我们将使用 DCGAN 中判别器中使用的层来创建一个生成器,如下面的列表所示。

列表 4.5 在 DCGAN 中设计生成器

G=nn.Sequential(
    nn.ConvTranspose2d(100, 512, 4, 1, 0, bias=False),    ①
    nn.BatchNorm2d(512),
    nn.ReLU(inplace=True),
    nn.ConvTranspose2d(512, 256, 4, 2, 1, bias=False),    ②
    nn.BatchNorm2d(256),
    nn.ReLU(inplace=True),
    nn.ConvTranspose2d(256, 128, 4, 2, 1, bias=False),
    nn.BatchNorm2d(128),
    nn.ReLU(inplace=True),
    nn.ConvTranspose2d(128, 64, 4, 2, 1, bias=False),
    nn.BatchNorm2d(64),
    nn.ReLU(inplace=True),
    nn.ConvTranspose2d(64, 3, 4, 2, 1, bias=False),       ③
    nn.Tanh()).to(device)                                 ④

①生成器中的第一层是模仿判别器中的最后一层。

②生成器中的第二层与判别器中的倒数第二层对称(输入和输出的数量位置已交换)。

③生成器中的最后一层与判别器中的第一层对称。

④使用 Tanh()激活函数将输出层的值挤压到[–1, 1]的范围内,因为训练集中的图像值介于–1 和 1 之间

如图 4.8 所示,为了创建图像,生成器使用了五个 2D 转置卷积层:它们与判别器中的五个 2D 卷积层对称。例如,最后一层ConvTranspose2d(64, 3, 4, 2, 1, bias=False)是模仿判别器的第一层Conv2d(3, 64, 4, 2, 1, bias=False)Conv2d中的*input**output*通道的数量被反转,并用作ConvTranspose2d中的*output**input*通道的数量。

图 4.8 在 DCGAN 中设计生成器网络以通过镜像判别器网络中的层来创建动漫面孔。图的右侧显示了判别器网络,其中包含五个 2D 卷积层。为了设计一个能够凭空创造出动漫面孔的生成器,我们镜像了判别器网络中的层。具体来说,如图的左侧所示,生成器有五个 2D 转置卷积层,与判别器中的 2D 卷积层对称。此外,在顶部四个层中,判别器中的inputoutput通道的数量被反转,并用作生成器中的outputinput通道的数量。

第一层 2D 转置卷积层的输入通道数为 100。这是因为生成器从潜在空间(图 4.8 的左下角)获取一个 100 值的随机噪声向量并将其馈送到生成器。生成器中最后 2D 转置卷积层的输出通道数为 3,因为输出是一个具有三个颜色通道(RGB)的图像。我们对生成器的输出应用 Tanh 激活函数,将所有值挤压到范围[–1, 1],因为训练图像的所有值都在–1 和 1 之间。

如同往常,损失函数是二元交叉熵损失。判别器试图最大化二元分类的准确性:将真实样本识别为真实,将伪造样本识别为伪造。另一方面,生成器试图最小化伪造样本被识别为伪造的概率。

我们将使用 Adam 优化器来训练判别器和生成器,并将学习率设置为 0.0002:

loss_fn=nn.BCELoss()
lr = 0.0002
optimG = torch.optim.Adam(G.parameters(), 
                         lr = lr, betas=(0.5, 0.999))
optimD = torch.optim.Adam(D.parameters(), 
                         lr = lr, betas=(0.5, 0.999))

你在第二章中已经看到了 Adam 优化器,但这里我们选择了与默认值不同的 beta。在这里,我们选择了不同的 beta。Adam 优化器中的 beta 在稳定和加速训练过程方面起着至关重要的作用。它们通过控制对最近和过去梯度信息的重视程度(beta1)以及根据梯度信息的确定性调整学习率(beta2)来实现这一点。这些参数通常根据要解决的问题的具体特征进行微调。

4.5.2 训练和使用 DCGAN

DCGAN 的训练过程与我们之前在其他 GAN 模型中做过的类似,例如第三章和本章早些时候使用的模型。由于我们不知道动漫人脸图像的真实分布,我们将依靠可视化技术来确定何时训练完成。具体来说,我们定义了一个test_epoch()函数,用于在每次训练周期后可视化生成器创建的动漫人脸:

def test_epoch():
    noise=torch.randn(32,100,1,1).\
        to(device=device)                             ①
    fake_samples=G(noise).cpu().detach()              ②
    for i in range(32):                               ③
        ax = plt.subplot(4, 8, i + 1)
        img=(fake_samples.cpu().detach()[i]/2+0.5).\
            permute(1,2,0)
        plt.imshow(img)
        plt.xticks([])
        plt.yticks([])
    plt.subplots_adjust(hspace=-0.6)
    plt.show()
test_epoch()                                          ④

① 从潜在空间获取 32 个随机噪声向量

② 生成 32 张动漫人脸图像

③ 在 4 × 8 网格中绘制生成的图像

④ 在训练模型之前调用生成图像的函数

如果你运行前面的代码单元,你会看到 32 张看起来像电视屏幕上的雪花静态的图像。它们根本不像动漫人脸,因为我们还没有训练生成器。

我们定义了三个函数,train_D_on_real()train_D_on_fake()train_G(),类似于我们之前在本章中用于训练 GAN 以生成服装物品灰度图像的函数。请访问本书 GitHub 仓库中本章的 Jupyter Notebook,熟悉这些函数。它们使用真实图像训练判别器。然后,它们使用伪造图像训练判别器;最后,它们训练生成器。

接下来,我们训练模型 20 个周期:

for i in range(20):
    gloss=0
    dloss=0
    for n, (real_samples,_) in enumerate(train_loader):
        loss_D=train_D_on_real(real_samples)
        dloss+=loss_D
        loss_D=train_D_on_fake()
        dloss+=loss_D
        loss_G=train_G()
        gloss+=loss_G
    gloss=gloss/n
    dloss=dloss/n
    print(f"epoch {i+1}, dloss: {dloss}, gloss {gloss}")
    test_epoch()

如果您使用 GPU 进行训练,训练时间大约需要 20 分钟。否则,根据您电脑上的硬件配置,可能需要 2 到 3 小时。另外,您可以从我的网站上下载训练好的模型:gattonweb.uky.edu/faculty/lium/gai/anime_gen.zip

在每个训练周期之后,您可以可视化生成的动漫人脸。经过仅一个周期的训练后,模型已经可以生成看起来像动漫人脸的彩色图像,如图 4.9 所示。随着训练的进行,生成的图像质量会越来越好。

图片

图 4.9 DCGAN 经过一个周期训练后的生成图像

我们将丢弃判别器,并将训练好的生成器保存在本地文件夹中:

scripted = torch.jit.script(G) 
scripted.save('files/anime_gen.pt') 

要使用训练好的生成器,我们加载模型并使用它生成 32 张图像:

new_G=torch.jit.load('files/anime_gen.pt',
                     map_location=device)
new_G.eval()
noise=torch.randn(32,100,1,1).to(device)
fake_samples=new_G(noise).cpu().detach()
for i in range(32):
    ax = plt.subplot(4, 8, i + 1)
    img=(fake_samples.cpu().detach()[i]/2+0.5).permute(1,2,0)
    plt.imshow(img)
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(hspace=-0.6)
plt.show() 

生成的动漫人脸图像如图 4.10 所示。生成的图像与图 4.7 中显示的训练集图像非常相似。

图片

图 4.10 由训练好的 DCGAN 生成器生成的动漫人脸图像

您可能已经注意到生成的图像中的发色不同:有的是黑色,有的是红色,有的是金色。您可能会想:我们能否让生成器创建具有特定特征(如黑色头发或红色头发)的图像?答案是肯定的。您将在第五章中学习几种不同的方法来在 GANs 中选取生成图像的特征。

摘要

  • 要从无中生有地生成看起来逼真的图像,生成器会镜像判别器网络中使用的层。

  • 虽然仅使用全连接层就可以生成灰度图像是可行的,但要生成高分辨率彩色图像,我们需要使用卷积神经网络(CNNs)。

  • 二维卷积层用于特征提取。它们将一组可学习的滤波器(也称为核)应用于输入数据,以检测不同空间尺度上的模式和特征。这些层对于捕获输入数据的层次表示至关重要。

  • 二维转置卷积层(也称为反卷积或上采样层)用于上采样或生成高分辨率特征图。它们对输入数据应用一个滤波器。然而,与标准卷积不同,它们通过在输出值之间插入间隔来增加空间维度,从而有效地“上采样”特征图。这个过程生成了更高分辨率的特征图。

  • 二维批量归一化是一种在深度学习和神经网络中常用的技术,用于提高 CNNs 和其他处理 2D 数据(如图像)的模型训练和性能。它对每个特征通道的值进行归一化,使它们具有 0 的均值和 1 的标准差,这有助于稳定和加速训练。


^(1)  谢尔盖·约费,克里斯蒂安·塞格迪,2015 年,“批量归一化:通过减少内部协变量偏移来加速深度网络训练。” arxiv.org/abs/1502.03167.

第五章:选择生成图像中的特征

本章涵盖了

  • 构建条件生成对抗网络以生成具有特定属性的图像(例如,戴眼镜或不戴眼镜的人类面孔)

  • 实现 Wasserstein 距离和梯度惩罚以改善图像质量

  • 选择与不同特征相关的向量,以便训练的生成对抗网络模型生成具有特定特征的图像(例如,男性或女性面孔)

  • 将条件生成对抗网络与向量选择相结合,以同时指定两个属性(例如,无眼镜的女性面孔或戴眼镜的男性面孔)

我们在第四章中用深度卷积生成对抗网络(DCGAN)生成的动漫面孔看起来很逼真。然而,你可能已经注意到,每个生成的图像都有不同的属性,例如发色、眼色以及头部是否向左或向右倾斜。你可能想知道是否有方法可以调整模型,使得生成的图像具有某些特征(例如,黑色头发且向左倾斜)。实际上,你可以做到。

在本章中,你将学习两种不同的方法来选择生成图像中的特征,以及它们各自的优缺点。第一种方法涉及在潜在空间中选择特定的向量。不同的向量对应不同的特征——例如,一个向量可能导致男性面孔,另一个可能导致女性面孔。第二种方法使用条件生成对抗网络(cGAN),这涉及到在标记数据上训练模型。这允许我们提示模型生成具有指定标签的图像,每个标签代表一个独特的特征——如戴眼镜或不戴眼镜的面孔。

此外,你将学会结合这两种方法,以便你可以同时选择图像的两个独立属性。结果,你可以生成四组不同的图像:戴眼镜的男性、无眼镜的男性、戴眼镜的女性和无眼镜的女性。为了使事情更有趣,你可以使用标签的加权平均值或输入向量的加权平均值来生成从一种属性过渡到另一种属性的图像。例如,你可以生成一系列图像,使得同一人的脸上的眼镜逐渐消失(标签算术)。或者你可以生成一系列图像,使得男性特征逐渐消失,男性面孔变成女性面孔(向量算术)。

能够单独进行向量算术或标签算术就像科幻小说一样,更不用说同时进行这两种操作了。整个体验让我们想起了亚瑟·C·克拉克(2001 太空漫游的作者)的名言:“任何足够先进的技术都与魔法无法区分。”

尽管第四章生成的动漫面孔在现实感上有所提高,但它们受到低分辨率的限制。训练 GAN 模型可能很棘手,并且经常受到样本量小或图像质量低等问题的影响。这些挑战可能会阻止模型收敛,导致图像质量差。为了解决这个问题,我们将在我们的 cGAN 中讨论并实现使用 Wasserstein 距离和梯度惩罚的改进训练技术。这种增强使得生成的人脸更加逼真,与上一章相比图像质量明显更好。

5.1 眼镜数据集

本章我们将使用眼镜数据集来训练一个 cGAN 模型。在下一章中,我们也将使用这个数据集在练习中训练一个 CycleGAN 模型:将戴眼镜的图片转换为不戴眼镜的图片,反之亦然。在本节中,你将学习如何下载数据集并预处理其中的图片。

本章和下一章中的 Python 程序改编自两个优秀的在线开源项目:Yashika Jain 的 Kaggle 项目mng.bz/JNVQ和 Aladdin Persson 的 GitHub 仓库mng.bz/w5yg。我鼓励你在阅读本章和下一章时查看这两个项目。

5.1.1 下载眼镜数据集

我们使用的眼镜数据集来自 Kaggle。登录 Kaggle 并访问链接mng.bz/q0oz下载图片文件夹以及右侧的两个 CSV 文件:train.csvtest.csv。文件夹/faces-spring-2020/中有 5,000 张图片。一旦你有了数据,请将图片文件夹和两个 CSV 文件都放在你电脑上的/fles/文件夹内。

接下来,我们将照片分类到两个子文件夹中:一个只包含戴眼镜的图片,另一个包含不戴眼镜的图片。

首先,让我们看看 train.csv 文件:

!pip install pandas
import pandas as pd

train=pd.read_csv('files/train.csv')               ①
train.set_index('id', inplace=True)                ②

① 将 train.csv 文件中的数据加载为 pandas DataFrame

② 将 id 列的值设置为观察的索引

之前的代码单元导入train.csv文件并将变量id设置为每个观察的索引。文件中的glasses列有两个值:0 或 1,表示图片中是否有眼镜(0 表示没有眼镜;1 表示有眼镜)。

接下来,我们将图片分为两个不同的文件夹:一个包含戴眼镜的图片,另一个包含不戴眼镜的图片。

列表 5.1 对戴眼镜和不戴眼镜的图片进行分类

import os, shutil

G='files/glasses/G/'
NoG='files/glasses/NoG/'
os.makedirs(G, exist_ok=True)                       ①
os.makedirs(NoG, exist_ok=True)                     ②
folder='files/faces-spring-2020/faces-spring-2020/'
for i in range(1,4501):
    oldpath=f"{folder}face-{i}.png"
    if train.loc[i]['glasses']==0:                  ③
        newpath=f"{NoG}face-{i}.png"
    elif train.loc[i]['glasses']==1:                ④
        newpath=f"{G}face-{i}.png"
    shutil.move(oldpath, newpath)

① 创建一个子文件夹/files/glasses/G/来存放戴眼镜的图片

② 创建一个子文件夹/files/glasses/NoG/来存放不戴眼镜的图片

③ 将标签为 0 的图片移动到文件夹 NoG

④ 将标签为 1 的图片移动到文件夹 G

在前面的代码单元中,我们首先使用 os 库在您的计算机上的 /files/ 文件夹内创建两个子文件夹 /glasses/G//glasses/NoG/。然后,我们使用 shutil 库根据文件 train.csv 中的标签 glasses 将图像移动到这两个文件夹。标签为 1 的图像被移动到文件夹 G,而标签为 0 的图像被移动到文件夹 NoG。

5.1.2 在眼镜数据集中可视化图像

文件 train.csv 中的分类列 glasses 并不完美。例如,如果您访问计算机上的子文件夹 G,您会看到大多数图像都有眼镜,但大约 10% 的图像没有眼镜。同样,如果您访问子文件夹 NoG,您会发现大约 10% 的图像实际上有眼镜。您需要手动纠正这个问题,通过将图像从一个文件夹移动到另一个文件夹。这对我们后续的训练非常重要,因此您应该手动移动两个文件夹中的图像,使得一个文件夹只包含有眼镜的图像,另一个文件夹只包含无眼镜的图像。欢迎来到数据科学家的生活:修复数据问题是日常工作的一个部分!让我们首先可视化一些有眼镜的图像示例。

列表 5.2 使用眼镜可视化图像

import random
import matplotlib.pyplot as plt
from PIL import Image

imgs=os.listdir(G)
random.seed(42)
samples=random.sample(imgs,16)               ①
fig=plt.figure(dpi=200, figsize=(8,2))
for i in range(16):                          ②
    ax = plt.subplot(2, 8, i + 1)
    img=Image.open(f"{G}{samples[i]}")
    plt.imshow(img)
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(wspace=-0.01,hspace=-0.01)
plt.show()

① 从文件夹 G 中随机选择 16 个图像

② 在 2 × 8 网格中显示 16 个图像

如果您已经手动纠正了文件夹 G 中图像的错误标记,运行列表 5.2 中的代码后,您将看到 16 个有眼镜的图像。输出结果如图 5.1 所示。

图 5.1 训练数据集中有眼镜的样本图像

您可以将列表 5.2 中的 G 更改为 NoG 来可视化数据集中没有眼镜的 16 个样本图像。完整的代码在本书的 GitHub 仓库 github.com/markhliu/DGAI 中。输出结果如图 5.2 所示。

图 5.2 训练数据集中没有眼镜的样本图像

5.2 cGAN 和 Wasserstein 距离

cGAN 与您在第三章和第四章中看到的 GAN 模型类似,不同之处在于您为输入数据附加了一个标签。这些标签对应于输入数据中的不同特征。一旦训练好的 GAN 模型“学习”将某个标签与特征关联起来,您就可以向模型提供一个带有标签的随机噪声向量,以生成具有所需特征的输出。^(1)

GAN 模型通常会遇到诸如模式坍塌(生成器找到一种能够很好地欺骗判别器的输出类型,然后将输出坍塌到这些少数模式,忽略其他变化)、梯度消失和收敛缓慢等问题。Wasserstein GAN(WGAN)引入地球迁移距离(或 Wasserstein-1 距离)作为损失函数,提供更平滑的梯度流和更稳定的训练。它缓解了模式坍塌等问题.^(2) 我们将在本章中实现它,在 cGAN 训练中。请注意,WGAN 是一个独立于 cGAN 的概念:它使用 Wasserstein 距离来改进训练过程,可以应用于任何 GAN 模型(例如我们在第三章和第四章中创建的模型)。我们将结合这两个概念在一个设置中,以节省空间。

稳定 GAN 训练的其他方法

训练 GAN 模型时的问题在生成高分辨率图像时最为常见。模型架构通常很复杂,包含许多神经网络层。除了 WGAN 之外,渐进式 GAN 是另一种稳定训练的方法。渐进式 GAN 通过将高分辨率图像生成的复杂任务分解为可管理的步骤,从而增强了 GAN 训练的稳定性,允许更可控和有效的学习。有关详细信息,请参阅 Karas 等人撰写的“用于提高质量、稳定性和变化的 GAN 渐进式生长”,arxiv.org/abs/1710.10196

5.2.1 带梯度惩罚的 WGAN

WGAN 是一种用于提高 GAN 模型训练稳定性和性能的技术。常规 GAN(例如你在第三章和第四章中看到的)有两个组件——生成器和判别器。生成器创建伪造数据,而判别器评估数据是否真实。训练涉及一个零和博弈的竞争,其中生成器试图欺骗判别器,而判别器试图准确分类真实和伪造数据实例。

研究人员提出了使用 Wasserstein 距离(两个分布之间差异的度量)而不是二元交叉熵作为损失函数,通过梯度惩罚项来稳定训练.^(3) 这种技术提供了更平滑的梯度流,并缓解了模式坍塌等问题。图 5.3 提供了 WGAN 的示意图。如图所示,与真实图像和伪造图像相关的损失是 Wasserstein 损失,而不是常规的二元交叉熵损失。

图片

图 5.3 带有梯度惩罚的 WGAN。WGAN 中的判别器网络(我们称之为评论家)对输入图像进行评分:它试图将一个负无穷大的分数分配给一个假图像(左下角)和一个正无穷大的分数分配给真实图像(右上角)。此外,还向评论家展示了一个真实和假图像的插值图像(左上角),并在训练过程中将评论家对插值图像的梯度惩罚添加到总损失中。

此外,为了使 Wasserstein 距离能够正确工作,判别器(在 WGAN 中称为评论家)必须是 1-Lipschitz 连续的,这意味着评论家函数的梯度范数必须在任何地方都最多为 1。原始的 WGAN 论文提出了权重裁剪来强制执行 Lipschitz 约束。

为了解决权重裁剪问题,梯度惩罚被添加到损失函数中,以更有效地强制执行 Lipschitz 约束。为了实现带有梯度惩罚的 WGAN,我们首先在真实和生成数据点之间的直线上的点进行随机采样(如图 5.3 左上角的插值图像所示)。由于真实和假图像都附有标签,插值图像也附有标签,这是两个原始标签的插值值。然后,我们计算评论家输出对这些采样点的梯度。最后,我们将与 1(梯度惩罚项称为梯度惩罚)的偏差成比例的惩罚添加到损失函数中。也就是说,WGAN 中的梯度惩罚是一种通过更有效地强制执行 Lipschitz 约束来提高训练稳定性和样本质量的技术,解决了原始 WGAN 模型的局限性。

5.2.2 cGANs

cGAN 是基本 GAN 框架的扩展。在 cGAN 中,生成器和判别器(或评论家,因为我们是在相同的设置下实现 WGAN 和 cGAN)都基于一些额外的信息。这可能是一切,例如类别标签、来自其他模态的数据,甚至是文本描述。这种条件通常是通过将此附加信息输入到生成器和判别器中实现的。在我们的设置中,我们将类别标签添加到生成器和评论家的输入中:我们给戴眼镜的图像贴上标签,给不戴眼镜的图像贴上另一个标签。图 5.4 提供了 cGANs 训练过程的示意图。

图片

图 5.4 cGANs 的训练过程

如您在图 5.4 的左上角所见,在 cGAN 中,生成器接收一个随机噪声向量和一个条件信息(一个标签,指示图像是否戴眼镜)作为输入。它使用这些信息生成看起来真实且与条件输入一致的数据。

评论家接收来自训练集的真实数据或生成器生成的伪造数据,以及条件信息(在我们的设置中,一个标签表示图像是否有眼镜)。其任务是确定给定数据是真实还是伪造,考虑条件信息(生成的图像中是否有眼镜?)。在图 5.4 中,我们使用评论家网络而不是判别器网络,因为我们同时实现了 cGAN 和 WGAN,但 cGAN 的概念也适用于传统的 GAN。

cGAN 的主要优势在于它们选择生成数据方面的能力,这使得它们更加灵活,适用于输出需要根据某些输入参数进行定向或条件化的场景。在我们的设置中,我们将训练 cGAN,以便我们能够选择生成的图像是否带有眼镜。

总结来说,cGAN 是基本 GAN 架构的一个强大扩展,它能够根据条件输入有针对性地生成合成数据。

5.3 创建 cGAN

在本节中,你将学习如何创建一个 cGAN 来生成带有或不带有眼镜的人类面部图像。你还将学习如何实现带有梯度惩罚的 WGAN 以稳定训练。

cGAN 中的生成器不仅使用随机噪声向量,还使用条件信息(如标签)作为输入来创建带有或不带有眼镜的图像。此外,WGAN 中的评论家网络与传统 GAN 中的判别器网络不同。你还将学习如何在本节中计算 Wasserstein 距离和梯度惩罚。

5.3.1 cGAN 中的评论家

在 cGAN 中,判别器是一个二元分类器,用于根据标签识别输入是真实还是伪造。在 WGAN 中,我们将判别器网络称为评论家。评论家评估输入并给出介于 −∞ 和 ∞ 之间的分数。分数越高,输入来自训练集(即真实)的可能性就越大。

列表 5.3 创建了评论家网络。其架构与我们第四章在生成动漫面孔彩色图像时使用的判别器网络有些相似。特别是,我们使用 PyTorch 中的七个 Conv2d 层逐步下采样输入,以便输出是一个介于 −∞ 和 ∞ 之间的单一值。

列表 5.3:具有 Wasserstein 距离的 cGAN 评论家网络

class Critic(nn.Module):
    def __init__(self, img_channels, features):
        super().__init__()
        self.net = nn.Sequential(                              ①
            nn.Conv2d(img_channels, features, 
                      kernel_size=4, stride=2, padding=1),
            nn.LeakyReLU(0.2),
            self.block(features, features * 2, 4, 2, 1),
            self.block(features * 2, features * 4, 4, 2, 1),
            self.block(features * 4, features * 8, 4, 2, 1),
            self.block(features * 8, features * 16, 4, 2, 1),
            self.block(features * 16, features * 32, 4, 2, 1),
            nn.Conv2d(features * 32, 1, kernel_size=4,
                      stride=2, padding=0))                    ②
    def block(self, in_channels, out_channels, 
              kernel_size, stride, padding):
        return nn.Sequential(                                  ③
            nn.Conv2d(in_channels,out_channels,
                kernel_size,stride,padding,bias=False,),
            nn.InstanceNorm2d(out_channels, affine=True),
            nn.LeakyReLU(0.2))
    def forward(self, x):
        return self.net(x)

① 评论家网络有两个 Conv2d 层加上五个块。

② 输出只有一个特征,没有激活。

③ 每个块包含一个 Conv2d 层,一个 InstanceNorm2d 层,以及 LeakyReLU 激活。

评论家网络的输入是一个形状为 5 × 256 × 256 的彩色图像。前三个通道是颜色通道(红色、绿色和蓝色)。最后两个通道(第四和第五通道)是标签通道,用于告诉评论家图像是否带有眼镜。我们将在下一节中讨论实现这一机制的确切方法。

批判网络由七个 Conv2d 层组成。在第四章中,我们深入讨论了这些层的工作原理。它们通过在输入图像上应用一组可学习的滤波器来提取特征,以检测不同空间尺度上的模式和特征,从而有效地捕获输入数据的层次表示。然后,批判器根据这些表示评估输入图像。中间的五 Conv2d 层后面都跟着一个 InstanceNorm2d 层和一个 LeakyReLU 激活函数;因此,我们定义了一个 block() 方法来简化批判网络。InstanceNorm2d 层与我们在第四章中讨论的 BatchNorm2d 层类似,不同之处在于我们独立地对批处理中的每个实例进行归一化。

另一个关键点是,输出不再是介于 0 和 1 之间的值,因为我们没有在批判网络的最后一层使用 sigmoid 激活函数。相反,由于我们在 cGAN 中使用 Wasserstein 距离和梯度惩罚,输出是一个介于 −∞ 和 ∞ 之间的值。

5.3.2 cGAN 中的生成器

在 WGAN 中,生成器的任务是创建数据实例,以便它们可以被批判器以高分数评估。在 cGAN 中,生成器必须生成具有条件信息的数据实例(在我们的设置中是带有或不带有眼镜)。由于我们正在实现一个使用 Wasserstein 距离的 cGAN,我们将通过将标签附加到随机噪声向量来告诉生成器我们想要生成哪种类型的图像。我们将在下一节中讨论具体的机制。

我们创建了以下列表中所示的神经网络来表示生成器。

列表 5.4 cGAN 中的生成器

class Generator(nn.Module):
    def __init__(self, noise_channels, img_channels, features):
        super(Generator, self).__init__()
        self.net = nn.Sequential(                             ①
            self.block(noise_channels, features *64, 4, 1, 0),
            self.block(features * 64, features * 32, 4, 2, 1),
            self.block(features * 32, features * 16, 4, 2, 1),
            self.block(features * 16, features * 8, 4, 2, 1),
            self.block(features * 8, features * 4, 4, 2, 1),
            self.block(features * 4, features * 2, 4, 2, 1),
            nn.ConvTranspose2d(
                features * 2, img_channels, kernel_size=4,
                stride=2, padding=1),    
            nn.Tanh())                                        ②
    def block(self, in_channels, out_channels, 
              kernel_size, stride, padding):
        return nn.Sequential(                                 ③
            nn.ConvTranspose2d(in_channels,out_channels,
                kernel_size,stride,padding,bias=False,),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(),)
    def forward(self, x):
        return self.net(x)

① 生成器由七个 ConvTranspose2d 层组成。

② 使用 Tanh 激活将值压缩到范围 [–1, 1],与训练集中的图像相同

③ 每个块由一个 ConvTranspose2d 层、一个 BatchNorm2d 层和 ReLU 激活函数组成。

我们将从 100 维潜在空间中随机噪声向量输入到生成器中。我们还将一个 2 值的一热编码图像标签输入到生成器中,以告诉它生成带有或不带有眼镜的图像。我们将这两部分信息连接起来,形成一个 102 维的输入变量输入到生成器中。然后,生成器根据潜在空间和标签信息生成彩色图像。

生成器网络由七个 ConvTranspose2d 层组成,其思路是镜像批判网络中的步骤来生成图像,正如我们在第四章中讨论的那样。前六个 ConvTranspose2d 层后面都跟着一个 BatchNorm2d 层和一个 ReLU 激活函数;因此,我们在生成器网络中定义了一个 block() 方法来简化架构。正如我们在第四章中所做的那样,我们在输出层使用 Tanh 激活函数,以便输出像素都在范围 -1 和 1 之间,与训练集中的图像相同。

5.3.3 权重初始化和梯度惩罚函数

在深度学习中,神经网络中的权重是随机初始化的。当网络架构复杂,存在许多隐藏层(在我们的设置中就是这样)时,权重的初始化方式至关重要。

因此,我们定义以下 weights_init() 函数来初始化生成器和评论家网络中的权重:

def weights_init(m):
    classname = m.__class__.__name__
    if classname.find('Conv') != -1:
        nn.init.normal_(m.weight.data, 0.0, 0.02)
    elif classname.find('BatchNorm') != -1:
        nn.init.normal_(m.weight.data, 1.0, 0.02)
        nn.init.constant_(m.bias.data, 0)   

函数使用均值为 0 和标准差为 0.02 的正态分布值初始化 Conv2dConvTranspose2d 层的权重。它还使用均值为 1 和标准差为 0.02 的正态分布值初始化 BatchNorm2d 层的权重。我们在权重初始化中选择了较小的标准差,以避免梯度爆炸。

接下来,我们根据在上一个子节中定义的 Generator()Critic() 类创建一个生成器和评论家。然后,我们根据之前定义的 weights_init() 函数初始化它们中的权重:

z_dim=100
img_channels=3
features=16
gen=Generator(z_dim+2,img_channels,features).to(device)
critic=Critic(img_channels+2,features).to(device)
weights_init(gen)
weights_init(critic)

如往常一样,我们将使用 Adam 优化器对评论家和生成器进行优化:

lr = 0.0001
opt_gen = torch.optim.Adam(gen.parameters(), 
                         lr = lr, betas=(0.0, 0.9))
opt_critic = torch.optim.Adam(critic.parameters(), 
                         lr = lr, betas=(0.0, 0.9))

生成器试图创建与给定标签的训练集中图像无法区分的图像。它将图像展示给评论家以获得对生成图像的高评分。另一方面,评论家试图在给定标签的条件下,对真实图像给予高评分,对假图像给予低评分。具体来说,评论家的损失函数有三个组成部分:

critic_value(fake) − critic_value(real) + weight × GradientPenalty

第一个项,critic_value(fake),表示如果一幅图像是假的,评论家的目标是将其识别为假并给予低评价。第二个项,− critic_value(real),表示如果图像是真实的,评论家的目标是将其识别为真实并给予高评价。此外,评论家还希望最小化梯度惩罚项,weight × GradientPenalty,其中weight是一个常数,用于确定我们希望分配给梯度范数偏差的惩罚程度。梯度惩罚的计算方法如下所示。

列表 5.5 计算梯度惩罚

def GP(critic, real, fake):
    B, C, H, W = real.shape    
    alpha=torch.rand((B,1,1,1)).repeat(1,C,H,W).to(device)    
    interpolated_images = real*alpha+fake*(1-alpha)         ①
    critic_scores = critic(interpolated_images)             ②
    gradient = torch.autograd.grad(    
        inputs=interpolated_images,
        outputs=critic_scores,
        grad_outputs=torch.ones_like(critic_scores),
        create_graph=True,
        retain_graph=True)[0]                               ③
    gradient = gradient.view(gradient.shape[0], -1)
    gradient_norm = gradient.norm(2, dim=1)
    gp = torch.mean((gradient_norm - 1) ** 2)               ④
    return gp

① 创建真实图像和假图像的插值图像

② 获取关于插值图像的评论家值

③ 计算评论家值的梯度

④ 梯度惩罚是梯度范数与值 1 的平方偏差。

在函数 GP() 中,我们首先创建真实图像和假图像的插值图像。这是通过沿真实图像和生成图像之间直线随机采样点来完成的。想象一个滑块:一端是真实图像,另一端是假图像。当你移动滑块时,你会看到从真实到假的连续混合,插值图像代表中间的阶段。

然后,我们将插值图像呈现给评论网络以获取对其的评分,并计算评论网络输出相对于插值图像的梯度。最后,梯度惩罚被计算为梯度范数与目标值 1 的平方偏差。

5.4 训练 cGAN

正如我们在上一节中提到的,我们需要找到一种方法来告诉评论网络和生成网络图像标签是什么,以便它们知道图像是否有眼镜。

在本节中,你将首先学习如何向评论网络和生成网络的输入添加标签,以便生成网络知道要创建哪种类型的图像,而评论网络可以基于标签评估图像。之后,你将学习如何使用 Wasserstein 距离训练 cGAN。

5.4.1 为输入添加标签

我们首先预处理数据并将图像转换为 torch 张量:

import torchvision.transforms as T
import torchvision

batch_size=16
imgsz=256
transform=T.Compose([
    T.Resize((imgsz,imgsz)),
    T.ToTensor(),
    T.Normalize([0.5,0.5,0.5],[0.5,0.5,0.5])])      
data_set=torchvision.datasets.ImageFolder(
    root=r"files/glasses",
    transform=transform) 

我们将批处理大小设置为 16,图像大小为 256x256 像素。像素值被选择,以便生成的图像分辨率高于上一章中的图像(64x64 像素)。由于图像尺寸较大,我们选择批处理大小为 16,小于第三章中的批处理大小。如果批处理大小太大,你的 GPU(甚至 CPU)可能会耗尽内存。

提示:如果你使用 GPU 训练,并且你的 GPU 内存较小(比如 6GB),考虑将批处理大小减少到小于 16 的较小数字,例如 10 或 8,这样你的 GPU 就不会耗尽内存。或者,你可以保持批处理大小为 16,但切换到 CPU 训练以解决 GPU 内存问题。

接下来,我们将标签添加到训练数据中。由于有两种类型的图像——有眼镜的图像和无眼镜的图像——我们将创建两个 one-hot 图像标签。有眼镜的图像将有一个 one-hot 标签[1, 0],无眼镜的图像将有一个 one-hot 标签[0, 1]。

生成网络的输入是一个 100 个值的随机噪声向量。我们将 one-hot 标签与随机噪声向量连接,并将 102 个值的输入馈送到生成网络。评论网络输入是一个形状为 3x256x256 的三通道彩色图像(PyTorch 使用通道优先张量来表示图像)。我们如何将形状为 1x2 的标签附加到形状为 3x256x256 的图像上?解决方案是在输入图像中添加两个通道,使图像形状从(3, 256, 256)变为(5, 256, 256):这两个额外的通道是 one-hot 标签。具体来说,如果一个图像中有眼镜,第四个通道将填充 1,第五个通道填充 0;如果一个图像中没有眼镜,第四个通道将填充 0,第五个通道填充 1。

在特征值多于两个的情况下创建标签

您可以轻松地将 cGAN 模型扩展到具有两个以上值的特征。例如,如果您创建一个生成不同头发颜色(黑色、金色和白色)的图像的模型,您馈送到生成器的图像标签可以分别具有值 [1, 0, 0]、[0, 1, 0] 和 [0, 0, 1]。在您将图像馈送到判别器或评论家之前,您可以附加三个通道到输入图像。例如,如果一个图像有黑色头发,第四个通道填充 1s,第五和第六个通道填充 0s。

此外,在眼镜示例中,由于标签中只有两个值,当您将标签馈送到生成器时,您可以潜在地使用值 0 和 1 来表示戴眼镜和不戴眼镜的图像。在您将图像馈送到评论家之前,您可以附加一个通道到输入图像:如果一个图像有眼镜,第四个通道填充 1s;如果一个图像没有眼镜,第四个通道填充 0s。我将把这个留给你作为练习。解决方案在本书的 GitHub 仓库中提供:github.com/markhliu/DGAI

我们按照以下列表所示实现此更改。

列表 5.6 将标签附加到输入图像

newdata=[]    
for i,(img,label) in enumerate(data_set):
    onehot=torch.zeros((2))
    onehot[label]=1
    channels=torch.zeros((2,imgsz,imgsz))                   ①
    if label==0:
        channels[0,:,:]=1                                   ②
    else:
        channels[1,:,:]=1                                   ③
    img_and_label=torch.cat([img,channels],dim=0)           ④
    newdata.append((img,label,onehot,img_and_label))

① 创建两个填充为 0s 的额外通道,每个通道的形状为 256x256,与输入图像中每个通道的维度相同

② 如果原始图像标签是 0,则将第四个通道填充为 1s

③ 如果原始图像标签是 1,则将第五个通道填充为 1s

④ 将第四和第五个通道添加到原始图像中,形成一个五通道标签图像

提示:在我们之前使用 torchvision.datasets.ImageFolder() 方法从文件夹 /files/glasses 加载图像时,PyTorch 按字母顺序将标签分配给每个子文件夹中的图像。因此,/files/glasses/G/ 中的图像被分配标签 0,而 /files/glasses/NoG/ 中的图像被分配标签 1。

我们首先创建一个空的列表 newdata 来保存带有标签的图像。我们创建一个形状为 (2, 256, 256) 的 PyTorch 张量,将其附加到原始输入图像上,以形成一个形状为 (5, 256, 256) 的新图像。如果原始图像标签是 0(这意味着图像来自文件夹 /files/glasses/G/),我们填充第四个通道为 1s,第五个通道为 0s,以便评论家知道这是一张戴眼镜的图像。另一方面,如果原始图像标签是 1(这意味着图像来自文件夹 /files/glasses/NoG/),我们填充第四个通道为 0s,第五个通道为 1s,以便评论家知道这是一张不带眼镜的图像。

我们创建一个具有批次的迭代器(为了提高训练过程中的计算效率、内存使用和优化动态),如下所示:

data_loader=torch.utils.data.DataLoader(
    newdata,batch_size=batch_size,shuffle=True)

5.4.2 训练 cGAN

现在我们有了训练数据和两个网络,我们将训练 cGAN。我们将使用视觉检查来确定何时停止训练。

模型训练完成后,我们将丢弃评论家网络,并使用生成器创建具有特定特征(在我们的例子中是带眼镜或不带眼镜)的图像。

我们将创建一个函数来定期检查生成的图像的外观。

列表 5.7 检查生成的图像

def plot_epoch(epoch):
    noise = torch.randn(32, z_dim, 1, 1)
    labels = torch.zeros(32, 2, 1, 1)
    labels[:,0,:,:]=1                                            ①
    noise_and_labels=torch.cat([noise,labels],dim=1).to(device)
    fake=gen(noise_and_labels).cpu().detach()                    ②
    fig=plt.figure(figsize=(20,10),dpi=100)
    for i in range(32):                                          ③
        ax = plt.subplot(4, 8, i + 1)
        img=(fake.cpu().detach()[i]/2+0.5).permute(1,2,0)
        plt.imshow(img)
        plt.xticks([])
        plt.yticks([])
    plt.subplots_adjust(hspace=-0.6)
    plt.savefig(f"files/glasses/G{epoch}.png")
    plt.show() 
    noise = torch.randn(32, z_dim, 1, 1)
    labels = torch.zeros(32, 2, 1, 1)
    labels[:,1,:,:]=1                                            ④
    … (code omitted)

① 为带眼镜的图像创建一个独热标签

② 将拼接的噪声向量和标签输入生成器以创建带眼镜的图像

③ 绘制带有眼镜的生成图像

④ 为不带眼镜的图像创建一个独热标签

每个训练周期结束后,我们将要求生成器创建一组带眼镜的图像和一组不带眼镜的图像。然后我们绘制这些图像,以便我们可以直观地检查它们。要创建带眼镜的图像,我们首先创建一个独热标签[1, 0],并将其附加到随机噪声向量上,然后再将拼接的向量输入到生成器网络中。由于标签是[1, 0]而不是[0, 1],生成器创建了带眼镜的图像。然后我们在四行八列中绘制生成的图像,并将子图保存在您的计算机上。创建不带眼镜的图像的过程类似,只是我们使用独热标签[0, 1]而不是[1, 0]。我在列表 5.7 中省略了一部分代码,但您可以在本书的 GitHub 仓库中找到它:github.com/markhliu/DGAI

我们定义了一个train_batch()函数,用于使用一批数据训练模型。

列表 5.8 使用一批数据训练模型

def train_batch(onehots,img_and_labels,epoch):
    real = img_and_labels.to(device)                            ①
    B = real.shape[0]
    for _ in range(5):    
        noise = torch.randn(B, z_dim, 1, 1)
        onehots=onehots.reshape(B,2,1,1)
        noise_and_labels=torch.cat([noise,onehots],dim=1).to(device)
        fake_img = gen(noise_and_labels).to(device)
        fakelabels=img_and_labels[:,3:,:,:].to(device)
        fake=torch.cat([fake_img,fakelabels],dim=1).to(device)  ②
        critic_real = critic(real).reshape(-1)
        critic_fake = critic(fake).reshape(-1)
        gp = GP(critic, real, fake)    
        loss_critic=(-(torch.mean(critic_real) - 
           torch.mean(critic_fake)) + 10 * gp)                  ③
        critic.zero_grad()
        loss_critic.backward(retain_graph=True)
        opt_critic.step()
    gen_fake = critic(fake).reshape(-1)
    loss_gen = -torch.mean(gen_fake)                            ④
    gen.zero_grad()
    loss_gen.backward()
    opt_gen.step()
    return loss_critic, loss_gen

① 带有标签的一批真实图像

② 带有标签的一批生成图像

③ 评论家的总损失有三个组成部分:评估真实图像的损失、评估伪造图像的损失以及梯度惩罚损失。

④ 使用 Wasserstein 损失训练生成器

train_batch()函数中,我们首先使用真实图像训练评论家。我们还要求生成器根据给定的标签创建一批伪造数据。然后我们使用伪造图像训练评论家。在train_batch()函数中,我们还使用一批伪造数据训练生成器。

注意:评论家的损失有三个组成部分:评估真实图像的损失、评估伪造图像的损失以及梯度惩罚损失。

现在我们对模型进行 100 个周期的训练:

for epoch in range(1,101):
    closs=0
    gloss=0
    for _,_,onehots,img_and_labels in data_loader:              ①
        loss_critic, loss_gen = train_batch(onehots,\
                                img_and_labels,epoch)           ②
        closs+=loss_critic.detach()/len(data_loader)
        gloss+=loss_gen.detach()/len(data_loader)
    print(f"at epoch {epoch},\
    critic loss: {closs}, generator loss {gloss}")
    plot_epoch(epoch)
torch.save(gen.state_dict(),'files/cgan.pth')                   ③

① 遍历训练数据集中的所有批次

② 使用一批数据训练模型

③ 保存训练生成器的权重

每个训练周期结束后,我们打印出评论家损失和生成器损失,以确保损失在合理的范围内。我们还使用我们之前定义的plot_epoch()函数生成 32 张带眼镜的面部图像以及 32 张不带眼镜的面部图像。训练完成后,我们在本地文件夹中保存训练生成器的权重,以便以后可以使用训练模型生成图像。

如果你使用 GPU 训练,这种训练大约需要 30 分钟。否则,可能需要几个小时,具体取决于你电脑的硬件配置。或者,你可以从我的网站下载训练好的模型:gattonweb.uky.edu/faculty/lium/gai/cgan.zip。下载后请解压文件。

5.5 在生成的图像中选择特征

生成具有特定特征的图像至少有两种方法。第一种是在将随机噪声向量输入到训练好的 cGAN 模型之前为其附加标签。不同的标签会导致生成的图像具有不同的特征(在我们的例子中,图像是否包含眼镜)。第二种方法是选择输入到训练模型的噪声向量:一个向量会导致具有男性面部的图像,另一个向量会导致具有女性面部的图像。请注意,第二种方法即使在传统的 GAN(如我们在第四章训练的 GAN)中也有效。它同样适用于 cGAN。

更好的是,在本节中,你将学习如何结合这两种方法,以便你可以同时选择两个特征:带有眼镜的男性面部或无眼镜的女性面部,等等。

在选择生成图像中的特定特征时,这两种方法各有优缺点。第一种方法,cGAN,需要标记数据来训练模型。有时,标记数据可能难以整理。然而,一旦你成功训练了一个 cGAN,你就可以生成具有特定特征的广泛图像。在我们的例子中,你可以生成许多不同的带有眼镜(或无眼镜)的图像;每张图像都与其他图像不同。第二种方法,手动选择噪声向量,不需要标记数据来训练模型。然而,每个手动选择的噪声向量只能生成一张图像。如果你想生成与 cGAN 相同特征的许多不同图像,你将需要事先手动选择许多不同的噪声向量。

5.5.1 选择是否带有眼镜的图像

在将随机噪声向量附加标签 [1, 0] 或 [0, 1] 并将其输入到训练好的 cGAN 模型之前,你可以选择生成的图像是否包含眼镜。

首先,我们将使用训练好的模型生成 32 张带有眼镜的图像,并在 4 × 8 网格中绘制它们。为了使结果可重复,我们将在 PyTorch 中固定随机状态。此外,我们将使用相同的随机噪声向量集,以便我们查看相同的面部集合。

我们将随机状态固定在种子 0,并生成 32 张带有眼镜的面部图像。

列表 5.9 生成带有眼镜的人类面部图像

torch.manual_seed(0)                                            ①

generator=Generator(z_dim+2,img_channels,features).to(device)
generator.load_state_dict(torch.load("files/cgan.pth",
    map_location=device))                                       ②
generator.eval()

noise_g=torch.randn(32, z_dim, 1, 1)                            ③
labels_g=torch.zeros(32, 2, 1, 1)
labels_g[:,0,:,:]=1                                             ④
noise_and_labels=torch.cat([noise_g,labels_g],dim=1).to(device)
fake=generator(noise_and_labels)
plt.figure(figsize=(20,10),dpi=50)
for i in range(32):
    ax = plt.subplot(4, 8, i + 1)
    img=(fake.cpu().detach()[i]/2+0.5).permute(1,2,0)
    plt.imshow(img.numpy())
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()

① 固定随机状态以确保结果可重复

② 加载训练好的权重

③ 生成一组随机噪声向量并将其保存,以便我们可以从中选择某些向量进行向量运算

④ 创建标签以生成带有眼镜的图像

我们创建了Generator()类的另一个实例,并将其命名为generator。然后我们加载上一节中保存在本地文件夹中的训练好的权重(或者你也可以从我的网站上下载权重:mng.bz/75Z4)。为了生成 32 张带眼镜的人类面部图像;我们首先在潜在空间中绘制 32 个随机噪声向量。我们还将创建一组标签,并将其命名为labels_g,它们告诉生成器生成 32 张带眼镜的图像。

如果你运行列表 5.9 中的程序,你会看到如图 5.5 所示的 32 张图像。

图 5.5 由训练好的 cGAN 模型生成的带眼镜的人类面部图像

首先,所有 32 张图像中确实都包含眼镜。这表明训练好的 cGAN 模型能够根据提供的标签生成图像。你可能已经注意到,有些图像具有男性特征,而有些图像具有女性特征。为了让我们为下一节中的向量运算做准备,我们将选择一个导致具有男性特征的图像和一个导致具有女性特征的图像的随机噪声向量。在检查图 5.5 中的 32 张图像后,我们选择了索引值为 0 和 14 的图像,如下所示:

z_male_g=noise_g[0]
z_female_g=noise_g[14]

为了生成 32 张无眼镜的图像,我们首先生成另一组随机噪声向量和标签:

noise_ng = torch.randn(32, z_dim, 1, 1)
labels_ng = torch.zeros(32, 2, 1, 1)
labels_ng[:,1,:,:]=1

新的随机噪声向量集被命名为noise_ng,新的标签集被命名为labels_ng。将它们输入到生成器中,你应该会看到 32 张没有眼镜的图像,如图 5.6 所示。

图 5.6 中的 32 张面孔中没有任何一张带有眼镜:训练好的 cGAN 模型可以根据给定的标签生成图像。我们选择了索引为 8(男性)和 31(女性)的图像,为下一节中的向量运算做准备:

z_male_ng=noise_ng[8]
z_female_ng=noise_ng[31]

图 5.6 由训练好的 cGAN 模型生成的无眼镜的人类面部图像

接下来,我们将使用标签插值来执行标签运算。回想一下,两个标签noise_gnoise_ng分别指示训练好的 cGAN 模型创建带眼镜和不带眼镜的图像。如果我们向模型提供一个插值标签(两个标签[1, 0]和[0, 1]的加权平均值)会怎样?训练好的生成器会生成什么类型的图像?让我们来看看。

列表 5.10 cGAN 中的标签运算

weights=[0,0.25,0.5,0.75,1]                                     ①
plt.figure(figsize=(20,4),dpi=300)
for i in range(5):
    ax = plt.subplot(1, 5, i + 1)
    # change the value of z
    label=weights[i]*labels_ng[0]+(1-weights[i])*labels_g[0]    ②
    noise_and_labels=torch.cat(
        [z_female_g.reshape(1, z_dim, 1, 1),
         label.reshape(1, 2, 1, 1)],dim=1).to(device)
    fake=generator(noise_and_labels).cpu().detach()             ③
    img=(fake[0]/2+0.5).permute(1,2,0)
    plt.imshow(img)
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()

① 创建五个权重

② 创建两个标签的加权平均值

③ 将新的标签给训练好的模型以创建图像

我们首先创建了五个权重(w):0,0.25,0.5,0.75 和 1,它们在 0 和 1 之间均匀分布。这五个 w 值中的每一个都是我们放在无眼镜标签 labels_ng 上的权重。互补权重放在眼镜标签 labels_g 上。因此,插值标签的值为 w*labels_ng+(1-w)*labels_g。然后我们将插值标签输入到训练好的模型中,以及我们之前保存的随机噪声向量 z_female_g。基于五个 w 值生成的五个图像被绘制在一个 1×5 的网格中,如图 5.7 所示。

图片

图 5.7 cGAN 中的标签算术。我们首先创建两个标签:无眼镜标签 labels_ng 和眼镜标签 labels_g。这两个标签指示训练好的生成器分别生成带眼镜和不带眼镜的图像。然后我们创建了五个插值标签,每个标签都是原始两个标签的加权平均值:w*labels_ng+(1-w)*labels_g,其中权重 w 取五个不同的值,0,0.25,0.5,0.75 和 1。基于五个插值标签生成的五个图像显示在图中。最左边的图像有眼镜。当我们从左到右移动时,眼镜逐渐消失,直到最右边的图像中没有眼镜。

当你从左到右查看图 5.7 中的五个生成图像时,你会注意到眼镜逐渐消失。左边的图像有眼镜,而右边的图像没有眼镜。中间的三张图像显示了一些眼镜的迹象,但眼镜并不像第一张图像中那么显眼。

练习 5.1

由于我们在列表 5.10 中使用了随机噪声向量 z_female_g,图 5.7 中的图像具有女性脸。将噪声向量更改为列表 5.10 中的 z_male_g 并重新运行程序;看看图像是什么样的。

5.5.2 潜在空间中的向量算术

你可能已经注意到,一些生成的人脸图像具有男性特征,而另一些则具有女性特征。你可能想知道:我们能否在生成的图像中选择男性或女性特征?答案是肯定的。我们可以通过在潜在空间中选择噪声向量来实现这一点。

在上一个子节中,我们已经保存了两个随机噪声向量,z_male_ngz_female_ng,分别对应男性脸和女性脸的图像。接下来,我们将这两个向量的加权平均值(即插值向量)输入到训练好的模型中,看看生成的图像是什么样的。

列表 5.11 向量算术以选择图像特征

weights=[0,0.25,0.5,0.75,1]                                 ①
plt.figure(figsize=(20,4),dpi=50)
for i in range(5):
    ax = plt.subplot(1, 5, i + 1)
    # change the value of z
    z=weights[i]*z_female_ng+(1-weights[i])*z_male_ng       ②  
    noise_and_labels=torch.cat(
        [z.reshape(1, z_dim, 1, 1),
         labels_ng[0].reshape(1, 2, 1, 1)],dim=1).to(device)    
    fake=generator(noise_and_labels).cpu().detach()         ③
    img=(fake[0]/2+0.5).permute(1,2,0)
    plt.imshow(img)
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()

① 创建五个权重

② 创建两个随机噪声向量的加权平均值

③ 将新的随机噪声向量输入到训练好的模型中,以创建一个图像

我们创建了五个权重,0,0.25,0.5,0.75 和 1。我们遍历这五个权重,创建两个随机噪声向量的五个加权平均值,w*z_female_ng+(1-w)*z_male_ng。然后我们将这五个向量以及标签labels_ng输入到训练好的模型中,以获得五个图像,如图 5.8 所示。

图片

图 5.8 GAN 中的向量算术。我们首先保存两个随机噪声向量z_female_ngz_male_ng。这两个向量分别导致女性和男性面孔的图像。然后我们创建五个插值向量,每个向量都是原始两个向量的加权平均值:w*z_female_ng+(1-w)*z_male_ng,其中权重w取五个不同的值,0,0.25,0.5,0.75 和 1。基于五个插值向量的五个生成的图像如图所示。最左边的图像具有男性特征。当我们从左向右移动时,男性特征逐渐消失,女性特征逐渐出现,直到最右边的图像显示女性面孔。

向量算术可以从一个图像实例过渡到另一个图像实例。由于我们偶然选择了男性和女性图像,当你从左到右查看图 5.8 中的五个生成的图像时,你会注意到男性特征逐渐消失,女性特征逐渐出现。第一张图像显示男性面孔,而最后一张图像显示女性面孔。

练习 5.2

由于我们在列表 5.11 中使用了标签labels_ng,图 5.8 中的图像中没有眼镜。在列表 5.11 中将标签更改为labels_g并重新运行程序,以查看图像的外观。

5.5.3 同时选择两个特征

到目前为止,我们一次只选择一个特征。通过选择标签,你已经学会了如何生成带眼镜或不带眼镜的图像。通过选择特定的噪声向量,你已经学会了如何选择生成的图像的特定实例。

如果你想同时选择两个特征(例如眼镜和性别),这两个独立特征的组合有四种可能:有眼镜的男性面孔、无眼镜的男性面孔、有眼镜的女性面孔和无眼镜的女性面孔。接下来我们将生成每种类型的图像。

列表 5.12 同时选择两个特征

plt.figure(figsize=(20,5),dpi=50)
for i in range(4):                                          ①
    ax = plt.subplot(1, 4, i + 1)
    p=i//2    
    q=i%2    
    z=z_female_g*p+z_male_g*(1-p)                           ②
    label=labels_ng[0]*q+labels_g[0]*(1-q)                  ③
    noise_and_labels=torch.cat(
        [z.reshape(1, z_dim, 1, 1),
         label.reshape(1, 2, 1, 1)],dim=1).to(device)       ④
    fake=generator(noise_and_labels)
    img=(fake.cpu().detach()[0]/2+0.5).permute(1,2,0)
    plt.imshow(img.numpy())
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()

① 遍历 0 到 3

② p 的值可以是 0 或 1,用于选择随机噪声向量以生成男性或女性面孔。

③ q 的值可以是 0 或 1,用于选择标签以确定生成的图像中是否包含眼镜。

④ 将随机噪声向量与标签结合以选择两个特征

要生成四个图像以覆盖四种不同的情况,我们需要使用其中一个噪声向量作为输入:z_female_gz_male_g。我们还需要将一个标签附加到输入上,这个标签可以是 labels_nglabels_g。为了使用一个单独的程序覆盖所有四种情况,我们遍历 i 的四个值,0 到 3,并创建两个值,p 和 q,它们是 i 除以 2 的整数商和余数。因此,p 和 q 的值可以是 0 或 1。通过将随机噪声向量的值设置为 z_female_g*p+z_male_g*(1-p),我们可以选择一个随机噪声向量来生成男性或女性面孔。同样,通过将标签的值设置为 labels_ng[0]*q+labels_g[0]*(1-q),我们可以选择一个标签来决定生成的图像中是否包含眼镜。一旦我们将随机噪声向量与标签结合并输入到训练好的模型中,我们就可以同时选择两个特征。

如果你运行列表 5.12 中的程序,你会看到如图 5.9 所示的四个图像。

图片

图 5.9 同时在生成的图像中选择两个特征。我们从以下两个选项中选择一个噪声向量:z_female_ngz_male_ng。我们还从以下两个选项中选择一个标签:labels_nglabels_g。然后我们将噪声向量和标签输入到训练好的生成器中,以创建一个图像。根据噪声向量和标签的值,训练好的模型可以创建四种类型的图像。通过这种方式,我们有效地在生成的图像中选择了两个独立特征:一个男性或女性面孔,以及图像中是否包含眼镜。

图 5.9 中生成的四个图像有两个独立特征:一个男性或女性面孔,以及图像中是否包含眼镜。第一幅图像显示了一个戴眼镜的男性面孔;第二幅图像是一个不戴眼镜的男性面孔。第三幅图像是一个戴眼镜的女性面孔,而最后一幅图像显示了一个不戴眼镜的女性面孔。

练习 5.3

我们在列表 5.12 中使用了两个随机噪声向量 z_female_gz_male_g。将这两个随机噪声向量改为 z_female_ngz_male_ng,然后重新运行程序,看看图像看起来像什么。

最后,我们可以同时进行标签算术和向量算术。也就是说,我们可以将插值噪声向量和插值标签输入到训练好的 cGAN 模型中,看看生成的图像是什么样子。你可以通过运行以下代码块来实现:

plt.figure(figsize=(20,20),dpi=50)
for i in range(36):
    ax = plt.subplot(6,6, i + 1)
    p=i//6
    q=i%6 
    z=z_female_ng*p/5+z_male_ng*(1-p/5)
    label=labels_ng[0]*q/5+labels_g[0]*(1-q/5)
    noise_and_labels=torch.cat(
        [z.reshape(1, z_dim, 1, 1),
         label.reshape(1, 2, 1, 1)],dim=1).to(device)
    fake=generator(noise_and_labels)
    img=(fake.cpu().detach()[0]/2+0.5).permute(1,2,0)
    plt.imshow(img.numpy())
    plt.xticks([])
    plt.yticks([])
plt.subplots_adjust(wspace=-0.08,hspace=-0.01)
plt.show()

代码与列表 5.12 中的代码类似,除了 p 和 q 每个都可以取六个不同的值:0、1、2、3、4 和 5。随机噪声向量 z_female_ng*p/5+z_male_ng*(1-p/5) 根据 p 的值取六个不同的值。标签 labels_ng[0]*q/5+labels_g[0]*(1-q/5) 根据 q 的值取六个不同的值。因此,基于插值噪声向量和插值标签,我们有 36 种不同的图像组合。如果你运行前面的程序,你会看到如图 5.10 所示的 36 张图像。

图片

图 5.10 同时进行向量算术和标签算术。i 的值从 0 变化到 35;p 和 q 分别是 i 除以 6 的整数商和余数。因此,p 和 q 每个都可以取六个不同的值:0、1、2、3、4 和 5。插值噪声向量 z_female_ng*p/5+z_male_ng*(1-p/5) 和插值标签 labels_ng[0]*q/5+labels_g[0]*(1-q/5) 都可以取六个不同的值。在每一行中,从左到右,眼镜逐渐消失。在每一列中,从上到下,图像逐渐从男性面孔变为女性面孔。

图 5.10 中有 36 张图像。插值噪声向量是两个随机噪声向量 z_female_ngz_male_ng 的加权平均值,分别生成女性面孔和男性面孔。标签是两个标签 labels_nglabels_g 的加权平均值,它们决定生成的图像中是否有眼镜。训练模型根据插值噪声向量和插值标签生成 36 张不同的图像。在每一行中,从左到右,眼镜逐渐消失。也就是说,我们在每一行中进行标签算术。在每一列中,从上到下,图像逐渐从男性面孔变为女性面孔。也就是说,我们在每一列中进行向量算术。

练习 5.4

在这个项目中,标签中有两个值:一个表示眼镜,一个表示没有眼镜。因此,我们可以使用二进制值而不是 one-hot 变量作为标签。更改本章的程序,并使用值 1 和 0(而不是 [1, 0] 和 [0, 1])来表示有眼镜和无眼镜的图像。将 1 或 0 附着到随机噪声向量上,以便向生成器提供 101 值的向量。在将图像输入到评论家之前,附加一个通道:如果图像中有眼镜,第四个通道填充 0s;如果图像中没有眼镜,第四个通道填充 1s。然后创建一个生成器和评论家;使用训练数据集来训练它们。解决方案在本书的 GitHub 仓库中提供,包括本章其他三个练习的解决方案。

既然你已经见证了 GAN 模型的能力,你将在下一章通过使用 GAN 进行风格迁移来进一步探索。例如,你将学习如何构建 CycleGAN 模型,并使用名人面部图像对其进行训练,以便在这些图像中将金发变为黑发或将黑发变为金发。相同的模型可以在其他数据集上训练:例如,你可以在本章中使用的人类面部数据集上训练它,以便在人类面部图像中添加或移除眼镜。

摘要

  • 通过在潜在空间中选择一个特定的噪声向量并将其输入到训练好的 GAN 模型中,我们可以选择生成图像中的某个特定特征,例如图像中是否有男性或女性面部。

  • cGAN 与传统的 GAN 不同。我们在标记数据上训练模型,并要求训练好的模型生成具有特定属性的数据。例如,一个标签告诉模型生成戴眼镜的人类面部图像,而另一个标签则告诉模型创建不戴眼镜的人类面部图像。

  • 在 cGAN 训练完成后,我们可以使用一系列标签的加权平均来生成从由一个标签表示的图像过渡到由另一个标签表示的图像的图像——例如,一系列同一人面部上的眼镜逐渐消失的图像。我们称这种算术为标签算术。

  • 我们还可以使用两个不同噪声向量的加权平均来创建从一种属性过渡到另一种属性的图像——例如,一系列男性特征逐渐消失,女性特征逐渐出现的图像。我们称这种向量为向量算术。

  • Wasserstein GAN (WGAN) 是一种通过使用 Wasserstein 距离而不是二元交叉熵作为损失函数来提高 GAN 模型训练稳定性和性能的技术。此外,为了 Wasserstein 距离能够正确工作,WGAN 中的评判器必须是 1-Lipschitz 连续的,这意味着评判器函数的梯度范数必须在任何地方都小于等于 1。WGAN 中的梯度惩罚通过向损失函数添加正则化项来更有效地强制执行 Lipschitz 约束。


^(1)  Mehdi Mirza, Simon Osindero, 2014, “Conditional Generative Adversarial Nets.” arxiv.org/abs/1411.1784.

^(2)  Martin Arjovsky, Soumith Chintala, 和 Léon Bottou, 2017, “Wasserstein GAN.” arxiv.org/abs/1701.07875.

^(3)  Martin Arjovsky, Soumith Chintala, 和 Leon Bottou, 2017, “Wasserstein GAN.” arxiv.org/abs/1701.07875; 以及 Ishaan Gulrajani, Faruk Ahmed, Martin Arjovsky, Vincent Dumoulin, 和 Aaron Courville, 2017, “Improved Training of Wasserstein GANs.” arxiv.org/abs/1704.00028.

第六章:CycleGAN:将金发转换为黑发

本章涵盖

  • CycleGAN 及其循环一致性损失背后的理念

  • 构建 CycleGAN 模型以将图像从一个领域转换为另一个领域

  • 使用具有两个图像领域的任何数据集训练 CycleGAN

  • 将黑发转换为金发,反之亦然

我们在前三章中讨论的生成对抗网络(GAN)模型都在尝试生成与训练集中图像无法区分的图像。

你可能想知道:我们能否将图像从一个领域转换为另一个领域,例如将马变成斑马,将黑发转换为金发或将金发转换为黑发,在图像中添加或去除眼镜,将照片转换为画作,或将冬季场景转换为夏季场景?实际上,你可以做到,你将在本章通过 CycleGAN 获得这样的技能!

CycleGAN 于 2017 年提出.^(1) CycleGAN 的关键创新是其能够在没有成对示例的情况下学习在不同领域之间进行转换。CycleGAN 有各种有趣和有用的应用,例如模拟面部老化或年轻化过程以协助数字身份验证或在不实际创建每个变体的情况下可视化不同颜色或图案的服装,以简化设计过程。

CycleGAN 使用循环一致性损失函数来确保原始图像可以从转换后的图像中重建,从而鼓励保留关键特征。循环一致性损失背后的理念真正巧妙,值得在此强调。本章中的 CycleGAN 有两个生成器:让我们分别称它们为黑发生成器和金发生成器。黑发生成器接收一张带有金发的图像(而不是像之前看到的随机噪声向量),并将其转换为一张带有黑发的图像,而金发生成器接收一张带有黑发的图像并将其转换为一张带有金发的图像。

为了训练模型,我们将一张带有黑发的真实图像给金发生成器,以生成一张带有金发的假图像。然后,我们将假金发图像给黑发生成器,将其转换回一张带有黑发的图像。如果两个生成器都工作得很好,经过一次往返转换后,原始黑发图像与假图像之间的差异很小。为了训练 CycleGAN,我们调整模型参数以最小化对抗损失和循环一致性损失的加权和。与第三章和第四章一样,对抗损失用于量化生成器欺骗判别器的能力以及判别器区分真实和假样本的能力。循环一致性损失,CycleGAN 的独特概念,衡量了原始图像和经过往返转换的假图像之间的差异。将循环一致性损失包含在总损失函数中是 CycleGAN 的关键创新。

在训练 CycleGAN 时,我们将黑发和金发图像作为两个域的例子。然而,该模型可以应用于任何两个图像域。为了强调这一点,我将要求你使用第五章中使用的带眼镜和不带眼镜的图像来训练相同的 CycleGAN 模型。解决方案在本书的 GitHub 仓库中提供(github.com/markhliu/DGAI),你将看到训练好的模型确实可以从人脸图像中添加或移除眼镜。

6.2 CycleGAN 与循环一致性损失

CycleGAN 扩展了基本的 GAN 架构,包括两个生成器和两个判别器。每个生成器-判别器对负责学习两个不同域之间的映射。它旨在将图像从一个域转换到另一个域(例如,马到斑马,夏景到冬景等),同时保留原始图像的关键特征。它使用循环一致性损失来确保可以从转换后的图像重建原始图像,从而鼓励保留关键特征。

在本节中,我们将首先讨论 CycleGAN 的架构。我们将强调 CycleGAN 的关键创新:循环一致性损失。

6.1.1 什么是 CycleGAN?

CycleGAN 由两个生成器和两个判别器组成。生成器将图像从一域转换到另一域,而判别器确定各自域中图像的真实性。这些网络能够将照片转换为模仿著名画家或特定艺术运动风格的美术作品,从而弥合艺术与技术之间的差距。它们还可以用于医疗保健领域,如将 MRI 图像转换为 CT 扫描或反之亦然,这在一种成像类型不可用或过于昂贵的情况下可能很有帮助。

在本章的项目中,我们将转换黑白头发和金发图像。因此,我们使用它们作为例子来解释 CycleGAN 的工作原理。图 6.1 是 CycleGAN 架构的示意图。

图 6.1 CycleGAN 的架构,用于将黑发图像转换为金发图像,以及将金发图像转换为黑发图像。该图还概述了用于最小化对抗损失的训练步骤。模型如何最小化循环一致性损失将在图 6.2 中解释。

为了训练 CycleGAN,我们使用来自我们希望转换的两个域的无配对数据集。我们将使用 48,472 张黑发名人脸图像和 29,980 张金发图像。我们调整模型参数以最小化对抗损失和循环一致性损失的总和。为了便于解释,我们将在图 6.1 中仅解释对抗损失。我将在下一小节中解释模型如何最小化循环一致性损失。

在每次训练迭代中,我们将真实的黑发图像(图 6.1 顶部左侧)输入到金发生成器中,以获得假的金发图像。然后,我们将假的金发图像与真实的金发图像一起输入到金发判别器(顶部中间)。金发判别器产生一个概率,表明每个图像是真实的金发图像。然后,我们将预测与真实值(图像是否为真实金发图像)进行比较,并计算判别器损失(Loss_D_Blond)以及生成器损失(Loss_G_Blond)。

同时,在每次训练迭代中,我们将真实的金发图像(中间左侧)输入到黑发生成器(底部左侧)以创建假的黑发图像。我们将假的黑发图像与真实图像一起展示给黑发判别器(中间底部),以获取它们是真实的预测。我们比较黑发判别器的预测与真实值,并计算判别器损失(Loss_D_Black)和生成器损失(Loss_G_Black)。我们同时训练生成器和判别器。为了训练两个判别器,我们调整模型参数以最小化判别器损失,即Loss_D_BlackLoss_D_Blond的总和。

6.1.2 循环一致性损失

为了训练两个生成器,我们调整模型参数以最小化对抗损失和循环一致性损失的总和。对抗损失是我们之前小节中讨论的Loss_G_BlackLoss_G_Blond的总和。为了解释循环一致性损失,让我们看看图 6.2。

图 6.2

图 6.2 CycleGAN 如何最小化原始黑发图像和经过往返后的假黑发图像之间的循环一致性损失,以及原始金发图像和经过往返后的假金发图像之间的循环一致性损失

CycleGAN 中生成器的损失函数由两部分组成。第一部分,对抗损失,确保生成的图像在目标域中与真实图像不可区分。例如,Loss_G_Blond(在之前的小节中定义)确保由金发生成器产生的假金发图像与训练集中真实金发图像相似。第二部分,循环一致性损失,确保从一个域转换到另一个域的图像可以转换回原始域。

循环一致性损失是 CycleGANs 的关键组成部分,确保在经过一次往返翻译后可以恢复原始输入图像。其理念是,如果你将真实的黑色头发图像(图 6.2 的左上角)翻译成假的金色头发图像,并将其转换回假的黑头发图像(右上角),你应该得到一个接近原始黑色头发图像的图像。黑色头发图像的循环一致性损失是假图像与原始真实图像在像素级别的平均绝对误差。让我们称这个损失为 Loss_Cycle_Black。同样的,将金色头发翻译成黑色头发然后再翻译回金色头发,我们称这个损失为 Loss_Cycle_Blond。总的循环一致性损失是 Loss_Cycle_BlackLoss_Cycle_Blond 的总和。

6.2 明星面部数据集

我们将使用黑色头发和金色头发的明星面部图像作为两个域。你将首先在本节中下载数据。然后,你将处理图片,以便在本章后面的训练中准备它们。

在本章中,你将使用两个新的 Python 库:pandasalbumentations。为了安装这些库,请在你的计算机上的 Jupyter Notebook 应用程序的新单元格中执行以下代码行:

!pip install pandas albumentations

按照屏幕上的说明完成安装。

6.2.1 下载明星面部数据集

要下载明星面部数据集,请登录 Kaggle 并访问链接 mng.bz/Ompo。下载后解压数据集,并将所有图片文件放置在你的计算机上 /files/img_align_celeba/img_align_celeba/ 文件夹内(注意文件夹内有一个同名子文件夹)。该文件夹中大约有 200,000 张图片。同时从 Kaggle 下载文件 list_attr_celeba.csv 并将其放置在你的计算机上的 /files/ 文件夹中。CSV 文件指定了每张图片的各种属性。

明星面部数据集包含许多不同颜色的头发图片:棕色、灰色、黑色、金色等等。我们将选择黑色或金色头发的图片作为我们的训练集,因为这两种类型在明星面部数据集中最为丰富。运行以下列表中的代码以选择所有黑色或金色头发的图片。

列表 6.1 选择黑色或金色头发的图片

import pandas as pd
import os, shutil

df=pd.read_csv("files/list_attr_celeba.csv")         ①
os.makedirs("files/black", exist_ok=True)  
os.makedirs("files/blond", exist_ok=True)            ②
folder="files/img_align_celeba/img_align_celeba"
for i in range(len(df)):
    dfi=df.iloc[i]
    if dfi['Black_Hair']==1:                         ③
        try:
            oldpath=f"{folder}/{dfi['image_id']}"
            newpath=f"files/black/{dfi['image_id']}"
            shutil.move(oldpath, newpath)
        except:
            pass
    elif dfi['Blond_Hair']==1:                       ④
        try:
            oldpath=f"{folder}/{dfi['image_id']}"
            newpath=f"files/blond/{dfi['image_id']}"
            shutil.move(oldpath, newpath)
        except:
            pass

① 加载包含图像属性的 CSV 文件

② 创建两个文件夹以存储黑色和金色头发的图片

③ 如果属性 Black_Hair 为 1,则将图片移动到黑色文件夹。

④ 如果属性 Blond_Hair 为 1,则将图片移动到金色文件夹。

我们首先使用 pandas 库加载文件 list_attr_celeba.csv,以便我们知道每张图像中是否包含黑色或金色头发。然后我们在本地创建两个文件夹,/files/black/ 和 /files/blond/,分别存储黑色和金色头发的图像。列表 6.1 然后遍历数据集中的所有图像。如果图像的属性 Black_Hair 为 1,则将其移动到文件夹 /files/black/;如果图像的属性 Blond_Hair 为 1,则将其移动到文件夹 /files/blond/。您将看到 48,472 张黑色头发图像和 29,980 张金色头发图像。图 6.3 显示了一些图像示例。

图 6.3 黑色或金色头发名人面部样本图像

图 6.3 顶部行的图像为黑色头发,而底部行的图像为金色头发。此外,图像质量很高:所有面部都位于前方中央,头发颜色易于识别。训练数据的数量和质量将有助于 CycleGAN 模型的训练。

6.2.2 处理黑白头发图像数据

我们将泛化 CycleGAN 模型,使其能够训练任何具有两个图像域的数据集。我们还将定义一个 LoadData() 类来处理 CycleGAN 模型的训练数据集。该函数可以应用于任何具有两个域的数据集,无论是不同头发颜色的面部图像,还是带有或没有眼镜的图像,或者是夏季和冬季场景的图像。

为了实现这一点,我们创建了一个本地模块 ch06util。从本书的 GitHub 仓库 (github.com/markhliu/DGAI) 下载文件 ch06util.py__init__.py,并将它们放置在您的计算机上的 /utils/ 文件夹中。在本地模块中,我们定义了以下 LoadData() 类。

列表 6.2 CycleGAN 中处理训练数据的 LoadData()

class LoadData(Dataset):
    def __init__(self, root_A, root_B, transform=None):    ①
        super().__init__()
        self.root_A = root_A
        self.root_B = root_B
        self.transform = transform
        self.A_images = []
        for r in root_A:
            files=os.listdir(r)
            self.A_images += [r+i for i in files]
        self.B_images = []
        for r in root_B:                                   ②
            files=os.listdir(r)
            self.B_images += [r+i for i in files]
        self.len_data = max(len(self.A_images),
                            len(self.B_images))
        self.A_len = len(self.A_images)
        self.B_len = len(self.B_images)
    def __len__(self):                                     ③
        return self.len_data
    def __getitem__(self, index):                          ④
        A_img = self.A_images[index % self.A_len]
        B_img = self.B_images[index % self.B_len]
        A_img = np.array(Image.open(A_img).convert("RGB"))
        B_img = np.array(Image.open(B_img).convert("RGB"))
        if self.transform:
            augmentations = self.transform(image=B_img,
                                           image0=A_img)
            B_img = augmentations["image"]
            A_img = augmentations["image0"]
        return A_img, B_img

① 两个文件夹 root_A 和 root_B 是存储两个域中图像的位置

② 加载每个域中的所有图像

③ 定义了一种计算数据集长度的方法

④ 定义了一种访问每个域中各个元素的方法

LoadData() 类继承自 PyTorch 中的 Dataset 类。两个列表 root_Aroot_B 分别包含域 A 和 B 中的图像文件夹。该类加载两个域中的图像,并生成一对图像,一个来自域 A,一个来自域 B,以便我们稍后可以使用这对图像来训练 CycleGAN 模型。

如前几章所述,我们创建了一个具有批次的迭代器以改进计算效率、内存使用和训练过程中的优化动态。

列表 6.3 处理用于训练的黑白头发图像

transforms = albumentations.Compose(
    [albumentations.Resize(width=256, height=256),        ①
        albumentations.HorizontalFlip(p=0.5),
        albumentations.Normalize(mean=[0.5, 0.5, 0.5],
        std=[0.5, 0.5, 0.5],max_pixel_value=255),         ②
        ToTensorV2()],
    additional_targets={"image0": "image"}) 
dataset = LoadData(root_A=["files/black/"],
    root_B=["files/blond/"],
    transform=transforms)                                 ③
loader=DataLoader(dataset,batch_size=1,
    shuffle=True, pin_memory=True)                        ④

① 将图像调整大小为 256x256 像素

② 将图像归一化到 -1 到 1 的范围

③ 在图像上应用 LoadData() 类

④ 创建用于训练的数据迭代器

我们首先在 albumentations 库(以其快速和灵活的图像增强而闻名)中定义了一个 Compose() 类的实例,并将其命名为 transforms。该类以多种方式转换图像:它将图像调整大小到 256x256 像素,并将值归一化到 -1 到 1 的范围内。列表 6.3 中的 HorizontalFlip() 参数在训练集中创建原始图像的镜像。水平翻转是一种简单而强大的增强技术,可以增强训练数据的多样性,帮助模型更好地泛化并变得更加鲁棒。增强和尺寸增加提高了 CycleGAN 模型的性能,并使生成的图像更加逼真。

然后,我们将 LoadData() 类应用于黑发和金发图像。由于图像文件大小很大,我们将批大小设置为 1,并在每次迭代中使用一对图像来训练模型。将批大小设置为超过 1 可能会导致您的机器内存不足。

6.3 构建 CycleGAN 模型

在本节中,我们将从头开始构建 CycleGAN 模型。我们将非常小心地使我们的 CycleGAN 模型通用,以便可以使用具有两个图像域的任何数据集进行训练。因此,我们将使用 A 和 B 来表示两个域,而不是例如黑发和金发图像。作为一个练习,你将使用第五章中使用的眼镜数据集来训练相同的 CycleGAN 模型。这有助于你通过使用不同的数据集将本章学到的技能应用于其他实际应用。

6.3.1 创建两个判别器

即使 CycleGAN 有两个判别器,它们在事前是相同的。因此,我们将创建一个单一的 Discriminator() 类,然后实例化该类两次:一个实例是判别器 A,另一个是判别器 B。CycleGAN 中的两个域是对称的,我们称之为域 A:黑发图像或金发图像都无关紧要。

打开你刚刚下载的文件 ch06util.py。在其中,我定义了 Discriminator() 类。

列表 6.4 在 CycleGAN 中定义 Discriminator()

class Discriminator(nn.Module):
    def __init__(self, in_channels=3, features=[64,128,256,512]):
        super().__init__()
        self.initial = nn.Sequential(
            nn.Conv2d(in_channels,features[0],                  ①
                kernel_size=4,stride=2,padding=1,
                padding_mode="reflect"),
            nn.LeakyReLU(0.2, inplace=True))
        layers = []
        in_channels = features[0]
        for feature in features[1:]:                            ②
            layers.append(Block(in_channels, feature, 
                stride=1 if feature == features[-1] else 2))
            in_channels = feature
        layers.append(nn.Conv2d(in_channels,1,kernel_size=4,    ③
                stride=1,padding=1,padding_mode="reflect"))
        self.model = nn.Sequential(*layers)
    def forward(self, x):
        out = self.model(self.initial(x))
        return torch.sigmoid(out)                               ④

① 第一个 Conv2d 层有 3 个输入通道和 64 个输出通道。

② 另外三个 Conv2d 层,分别有 126、256 和 512 个输出通道

③ 最后一个 Conv2d 层有 512 个输入通道和 1 个输出通道。

④ 在输出上应用 sigmoid 激活函数,以便它可以被解释为概率

之前的代码列表定义了判别器网络。其架构与第四章中的判别器网络和第五章中的评论网络相似。主要组件是五个Conv2d层。我们在最后一层应用 sigmoid 激活函数,因为判别器执行的是二元分类问题。判别器以三通道彩色图像为输入,并产生一个介于 0 到 1 之间的单个数字,这可以解释为输入图像是域中真实图像的概率。

我们在列表 6.4 中使用的padding_mode="reflect"参数意味着添加到输入张量中的填充是输入张量本身的反射。反射填充通过不在边界引入人工零值来帮助保留边缘信息。它在输入张量的边界处创建更平滑的过渡,这对我们在设置中区分不同域中的图像是有益的。

然后我们创建了两个类的实例,分别命名为disc_Adisc_B

from utils.ch06util import Discriminator, weights_init    ①
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"
disc_A = Discriminator().to(device)
disc_B = Discriminator().to(device)                       ②
weights_init(disc_A)
weights_init(disc_B)                                      ③

① 从本地模块导入判别器类

② 创建判别器类的两个实例

③ 初始化权重

在本地模块ch06util中,我们还定义了一个weights_init()函数来初始化模型权重。该函数的定义方式与第五章中的类似。然后我们在两个新创建的判别器disc_Adisc_B中初始化权重。

现在我们有了两个判别器,接下来我们将创建两个生成器。

6.3.2 创建两个生成器

类似地,我们在本地模块中定义了一个单独的Generator()类,并实例化了该类两次:一个实例是生成器 A,另一个是生成器 B。在您刚刚下载的ch06util.py文件中,我们定义了Generator()类。

列表 6.5 CycleGAN 中的Generator()

class Generator(nn.Module):
    def __init__(self, img_channels, num_features=64,
                 num_residuals=9):
        super().__init__()     
        self.initial = nn.Sequential(
            nn.Conv2d(img_channels,num_features,kernel_size=7,
                stride=1,padding=3,padding_mode="reflect",),
            nn.InstanceNorm2d(num_features),
            nn.ReLU(inplace=True))
        self.down_blocks = nn.ModuleList(
            [ConvBlock(num_features,num_features*2,kernel_size=3,
                       stride=2, padding=1),
            ConvBlock(num_features*2,num_features*4,kernel_size=3, ①
                stride=2,padding=1)])
        self.res_blocks = nn.Sequential(                           ②
            *[ResidualBlock(num_features * 4) 
            for _ in range(num_residuals)])
        self.up_blocks = nn.ModuleList(
            [ConvBlock(num_features * 4, num_features * 2,
                    down=False, kernel_size=3, stride=2,
                    padding=1, output_padding=1),
                ConvBlock(num_features * 2, num_features * 1,      ③
                    down=False,kernel_size=3, stride=2,
                    padding=1, output_padding=1)])
        self.last = nn.Conv2d(num_features * 1, img_channels,
            kernel_size=7, stride=1,
            padding=3, padding_mode="reflect")

    def forward(self, x):
        x = self.initial(x)
        for layer in self.down_blocks:
            x = layer(x)
        x = self.res_blocks(x)
        for layer in self.up_blocks:
            x = layer(x)
        return torch.tanh(self.last(x))                            ④

① 三个Conv2d

② 九个残差块

③ 两个上采样块

④ 在输出上应用 tanh 激活

生成器网络由几个Conv2d层组成,后面跟着九个残差块(我将在后面详细解释)。然后,网络有两个上采样块,包括一个ConvTranspose2d层、一个InstanceNorm2d层和一个ReLU激活。正如我们在前面的章节中所做的那样,我们在输出层使用 tanh 激活函数,因此输出像素都在-1 到 1 的范围内,与训练集中的图像相同。

生成器中的残差块在本地模块中的定义如下:

class ConvBlock(nn.Module):
    def __init__(self, in_channels, out_channels, 
                 down=True, use_act=True, **kwargs):
        super().__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, 
                      padding_mode="reflect", **kwargs)
            if down
            else nn.ConvTranspose2d(in_channels, 
                                    out_channels, **kwargs),
            nn.InstanceNorm2d(out_channels),
            nn.ReLU(inplace=True) if use_act else nn.Identity())
    def forward(self, x):
        return self.conv(x)

class ResidualBlock(nn.Module):
    def __init__(self, channels):
        super().__init__()
        self.block = nn.Sequential(
            ConvBlock(channels,channels,kernel_size=3,padding=1),
            ConvBlock(channels,channels,
                      use_act=False, kernel_size=3, padding=1))
    def forward(self, x):
        return x + self.block(x)

残差连接是深度学习中的一个概念,尤其是在深度神经网络的设计中。你会在本书后面的内容中经常看到它。这是一种用于解决深度网络中经常出现的梯度消失问题的技术。在残差块中,它是具有残差连接的网络的基本单元,输入通过一系列变换(如卷积、激活和批量或实例归一化)传递,然后将其加回到这些变换的输出。图 6.4 展示了之前定义的残差块的架构。

图片

图 6.4 残差块的架构。输入 x 通过一系列变换(两套 Conv2d 层和 InstanceNorm2d 层以及中间的 ReLU 激活)。然后输入 x 被加回到这些变换的输出 f(x) 上。因此,残差块的输出是 x + f(x)。

每个残差块中的变换是不同的。在这个例子中,输入 x 通过两套 Conv2d 层和 InstanceNorm2d 层以及中间的 ReLU 激活。然后输入 x 被加回到这些变换的输出 f(x) 上,形成最终的输出 x+f(x),因此得名残差连接。

接下来,我们创建两个 Generator() 类的实例,并将其中一个命名为 gen_A,另一个命名为 gen_B

from utils.ch06util import Generator

gen_A = Generator(img_channels=3, num_residuals=9).to(device)
gen_B = Generator(img_channels=3, num_residuals=9).to(device)
weights_init(gen_A)
weights_init(gen_B)

在训练模型时,我们将使用平均绝对误差(即 L1 损失)来衡量循环一致性损失。我们将使用平均平方误差(即 L2 损失)来衡量对抗损失。当数据有噪声且有许多异常值时,通常使用 L1 损失,因为它对极端值的惩罚小于 L2 损失。因此,我们导入以下损失函数:

import torch.nn as nn

l1 = nn.L1Loss()
mse = nn.MSELoss()
g_scaler = torch.cuda.amp.GradScaler()
d_scaler = torch.cuda.amp.GradScaler()

L1 和 L2 损失都是在像素级别计算的。原始图像和伪造图像的形状都是 (3, 256, 256)。为了计算损失,我们首先计算两个图像在每个 3 × 256 × 256 = 196608 个位置上对应像素值之间的差异(对于 L1 损失是差异的绝对值,对于 L2 损失是差异的平方值),然后对这些位置的差异进行平均。

我们将使用 PyTorch 的自动混合精度包 torch.cuda.amp 来加速训练。PyTorch 张量的默认数据类型是 float32,这是一个 32 位的浮点数,它占用的内存是 16 位浮点数 float16 的两倍。对前者的操作比后者慢。精度和计算成本之间存在权衡。使用哪种数据类型取决于手头的任务。torch.cuda.amp 提供了自动混合精度,其中一些操作使用 float32,而其他操作使用 float16。混合精度试图将每个操作匹配到适当的数据类型以加速训练。

正如我们在第四章中所做的那样,我们将使用 Adam 优化器对判别器和生成器进行优化:

lr = 0.00001
opt_disc = torch.optim.Adam(list(disc_A.parameters()) + 
  list(disc_B.parameters()),lr=lr,betas=(0.5, 0.999))
opt_gen = torch.optim.Adam(list(gen_A.parameters()) + 
  list(gen_B.parameters()),lr=lr,betas=(0.5, 0.999))

接下来,我们将使用带有黑发或金发的图像来训练 CycleGAN 模型。

6.4 使用 CycleGAN 在黑发和金发之间进行转换

现在我们已经有了训练数据和 CycleGAN 模型,我们将使用带有黑发或金发的图像来训练模型。与所有 GAN 模型一样,训练完成后我们将丢弃判别器。我们将使用两个训练好的生成器将黑发图像转换为金发图像,并将金发图像转换为黑发图像。

6.4.1 训练 CycleGAN 在黑发和金发之间进行转换

正如我们在第四章中解释的,我们将使用视觉检查来确定何时停止训练。为此,我们创建了一个函数来测试真实图像和相应生成的图像看起来像什么,这样我们就可以比较两者以视觉检查模型的有效性。在本地模块ch06util中,我们定义了一个test()函数:

def test(i,A,B,fake_A,fake_B):
    save_image(A*0.5+0.5,f"files/A{i}.png")
    save_image(B*0.5+0.5,f"files/B{i}.png")               ①
    save_image(fake_A*0.5+0.5,f"files/fakeA{i}.png")
    save_image(fake_B*0.5+0.5,f"files/fakeB{i}.png")      ②

① 在本地文件夹中保存的域 A 和 B 中的真实图像

② 由第 i 批生成器在域 A 和 B 中创建的对应伪造图像

我们在每 100 个批次训练后保存四张图像。我们在本地文件夹中保存两个域中的真实图像和相应伪造图像,这样我们可以定期检查生成的图像,并将它们与真实图像进行比较,以评估训练进度。我们使该函数通用,以便它可以应用于任何两个域的图像。

此外,我们在本地模块ch06util中定义了一个train_epoch()函数,用于在一个 epoch 中训练判别器和生成器。以下列表突出了我们用来训练两个判别器的代码。

列表 6.6 在 CycleGAN 中训练两个判别器

def train_epoch(disc_A, disc_B, gen_A, gen_B, loader, opt_disc,
        opt_gen, l1, mse, d_scaler, g_scaler,device):
    loop = tqdm(loader, leave=True)
    for i, (A,B) in enumerate(loop):                       ①
        A=A.to(device)
        B=B.to(device)
        with torch.cuda.amp.autocast():                    ②
            fake_A = gen_A(B)
            D_A_real = disc_A(A)
            D_A_fake = disc_A(fake_A.detach())
            D_A_real_loss = mse(D_A_real, 
                                torch.ones_like(D_A_real))
            D_A_fake_loss = mse(D_A_fake,
                                torch.zeros_like(D_A_fake))
            D_A_loss = D_A_real_loss + D_A_fake_loss
            fake_B = gen_B(A)
            D_B_real = disc_B(B)
            D_B_fake = disc_B(fake_B.detach())
            D_B_real_loss = mse(D_B_real,
                                torch.ones_like(D_B_real))
            D_B_fake_loss = mse(D_B_fake,
                                torch.zeros_like(D_B_fake))
            D_B_loss = D_B_real_loss + D_B_fake_loss
            D_loss = (D_A_loss + D_B_loss) / 2             ③
        opt_disc.zero_grad()
        d_scaler.scale(D_loss).backward()
        d_scaler.step(opt_disc)
        d_scaler.update()
        …

① 遍历两个域中所有图像的对

② 使用 PyTorch 自动混合精度包加速训练

③ 两个判别器的总损失是两个判别器对抗损失的简单平均值。

我们在这里使用detach()方法来移除张量fake_Afake_B中的梯度,以减少内存并加快计算速度。两个判别器的训练与我们在第四章中做的是类似的,有一些不同之处。首先,我们这里有两个判别器,而不是一个:一个用于域 A 中的图像,另一个用于域 B 中的图像。两个判别器的总损失是两个判别器对抗损失的简单平均值。其次,我们使用 PyTorch 自动混合精度包来加速训练,将训练时间减少了 50%以上。

我们在同一迭代中同时训练两个生成器。以下列表突出了我们用来训练两个生成器的代码。

列表 6.7 在 CycleGAN 中训练两个生成器

def train_epoch(disc_A, disc_B, gen_A, gen_B, loader, opt_disc,
        opt_gen, l1, mse, d_scaler, g_scaler,device):
        …
        with torch.cuda.amp.autocast():
            D_A_fake = disc_A(fake_A)
            D_B_fake = disc_B(fake_B)
            loss_G_A = mse(D_A_fake, torch.ones_like(D_A_fake))
            loss_G_B = mse(D_B_fake, torch.ones_like(D_B_fake))      ①
            cycle_B = gen_B(fake_A)
            cycle_A = gen_A(fake_B)
            cycle_B_loss = l1(B, cycle_B)
            cycle_A_loss = l1(A, cycle_A)                            ②
            G_loss=loss_G_A+loss_G_B+cycle_A_loss*10+cycle_B_loss*10 ③
        opt_gen.zero_grad()
        g_scaler.scale(G_loss).backward()
        g_scaler.step(opt_gen)
        g_scaler.update()
        if i % 100 == 0:
            test(i,A,B,fake_A,fake_B)                                ④
        loop.set_postfix(D_loss=D_loss.item(),G_loss=G_loss.item())

① 对两个生成器的对抗损失

② 两个生成器的循环一致性损失

③ 两个生成器的总损失是对抗损失和循环一致性损失的加权总和。

④ 每训练 100 个批次后生成用于视觉检查的图像

对于两个生成器的训练,与我们在第四章所做的方法有两个重要的不同之处。首先,我们在这里同时训练两个生成器,而不是只有一个生成器。其次,两个生成器的总损失是对抗性损失和循环一致性损失的加权总和,我们将后者损失权重提高 10 倍。然而,如果您将 10 的值更改为其他数字,如 9 或 12,您将得到类似的结果。

循环一致性损失是原始图像与转换回原始域的伪造图像之间的平均绝对误差。

现在我们已经准备好了所有东西,我们将开始训练循环:

from utils.ch06util import train_epoch

for epoch in range(1):
    train_epoch(disc_A, disc_B, gen_A, gen_B, loader, opt_disc,
    opt_gen, l1, mse, d_scaler, g_scaler, device)                   ①
torch.save(gen_A.state_dict(), "files/gen_black.pth")
torch.save(gen_B.state_dict(), "files/gen_blond.pth")               ②

① 使用黑发和金发图像训练 CycleGAN 一个 epoch

② 保存训练好的模型权重

如果使用 GPU 进行训练,之前的训练可能需要几个小时。否则,可能需要整整一天。如果您没有训练模型的计算资源,请从我的网站下载预训练的生成器:gattonweb.uky.edu/faculty/lium/ml/hair.zip。解压文件,并将文件gen_black.pthgen_blond.pth放置在您计算机上的文件夹/files/中。您将在下一小节中能够将黑发图像转换为金发图像。

练习 6.1

在训练 CycleGAN 模型时,我们假设域 A 包含黑发图像,域 B 包含金发图像。修改列表 6.2 中的代码,使域 A 包含金发图像,域 B 包含黑发图像。

6.4.2 黑发图像和金发图像的往返转换

由于训练数据集的高质量和数量丰富,我们成功地训练了 CycleGAN。我们不仅将在黑发图像和金发图像之间进行转换,还将进行往返转换。例如,我们将黑发图像转换为金发图像,然后再将其转换回黑发图像。这样,我们可以在往返后比较同一域内的原始图像和生成的图像,并查看差异。

以下列表执行了图像在两个域之间的转换,以及每个域内图像的往返转换。

列表 6.8 黑色或金发图像的往返转换

gen_A.load_state_dict(torch.load("files/gen_black.pth",
    map_location=device))
gen_B.load_state_dict(torch.load("files/gen_blond.pth",
    map_location=device))
i=1
for black,blond in loader:
    fake_blond=gen_B(black.to(device))
    save_image(black*0.5+0.5,f"files/black{i}.png")             ①
    save_image(fake_blond*0.5+0.5,f"files/fakeblond{i}.png") 
    fake2black=gen_A(fake_blond)
    save_image(fake2black*0.5+0.5,
        f"files/fake2black{i}.png")                             ②
    fake_black=gen_A(blond.to(device))
    save_image(blond*0.5+0.5,f"files/blond{i}.png")             ③
    save_image(fake_black*0.5+0.5,f"files/fakeblack{i}.png")
    fake2blond=gen_B(fake_black)
    save_image(fake2blond*0.5+0.5,
        f"files/fake2blond{i}.png")                             ④
    i=i+1
    if i>10:
        break

① 原始黑发图像

② 经过一次往返后生成的黑发伪造图像

③ 原始金发图像

④ 经过一次往返后生成的金发伪造图像

我们已经在您的本地文件夹/files/中保存了六组图像。第一组是带有黑色头发的原始图像。第二组是由训练好的金色头发生成器生成的假金色图像:图像保存为fakeblond0.pngfakeblond1.png等。第三组是往返转换后的假黑色头发图像:我们将刚刚创建的假图像输入到训练好的黑色头发生成器中,以获得假黑色头发图像。它们保存为fake2black0.pngfake2black1.png等。图 6.5 显示了这三组图像。

图片

图 6.5 展示了带有黑色头发的图像往返转换。顶部行的图像是从训练集中提取的带有黑色头发的原始图像。中间行的图像是由训练好的金色头发生成器生成的相应假发金色头发图像。底部行的图像是在往返转换后生成的假发黑色头发图像:我们将中间行的图像输入到训练好的黑色头发生成器中,以创建假发黑色头发图像。

图 6.5 中有三行图像。顶部行显示了从训练集中提取的带有黑色头发的原始图像。中间行显示了由训练好的金色头发生成的假发金色头发图像。底部行包含往返转换后的假发黑色头发图像:图像几乎与顶部行中的图像相同!我们训练好的 CycleGAN 模型工作得非常好。

本地文件夹/files/中的第四组图像是带有金色头发的原始图像。第五组是由训练好的黑色头发生成器生成的假图像。最后,第六组包含往返转换后的假发金色头发图像。图 6.6 比较了这三组图像。

图片

图 6.6 展示了带有金色头发的图像往返转换。顶部行的图像是从训练集中提取的带有金色头发的原始图像。中间行的图像是由训练好的黑色头发生成器生成的相应假发黑色头发图像。底部行的图像是在往返转换后生成的假发金色头发图像:我们将中间行的图像输入到训练好的金色头发生成器中,以创建假发金色头发图像。

在图 6.6 中,中间行显示了由训练好的黑色头发生成器生成的假发黑色头发图像:它们与顶部行中的人类面部上的黑色头发相同。底部行显示了往返转换后的假发金色头发图像:它们几乎与顶部行中的原始金色头发图像完全相同。

练习 6.2

CycleGAN 模型是通用的,可以应用于任何包含两个图像域的训练数据集。使用你在第五章下载的眼镜图像来训练 CycleGAN 模型。将戴眼镜的图像作为域 A,不带眼镜的图像作为域 B。然后使用训练好的 CycleGAN 在图像中添加和去除眼镜(即,在两个域之间转换图像)。示例实现和结果可以在本书的 GitHub 仓库中找到。

到目前为止,我们关注了一种生成模型类型,即 GANs。在下一章中,你将学习如何使用另一种类型的生成模型,变分自编码器(VAEs),来生成高分辨率图像。你将了解 VAEs 相对于 GANs 的优点和缺点。更重要的是,你将学习 VAE 中的编码器-解码器架构。该架构在生成模型中广泛使用,包括我们将在本书后面学习的 Transformers。

摘要

  • CycleGAN 可以在没有配对示例的情况下在两个域之间翻译图像。它由两个判别器和两个生成器组成。一个生成器将域 A 中的图像转换为域 B,而另一个生成器将域 B 中的图像转换为域 A。两个判别器用于分类给定图像是否来自特定域。

  • CycleGAN 使用循环一致性损失函数来确保可以从转换后的图像重建原始图像,从而鼓励保留关键特征。

  • 一个正确构建的 CycleGAN 模型可以应用于任何包含两个域图像的数据集。相同的模型可以用不同的数据集进行训练,并用于在不同域之间翻译图像。

  • 当我们有大量高质量的训练数据时,训练好的 CycleGAN 可以将一个域的图像转换为另一个域,并将其转换回原始域。经过一次往返转换后的图像可能几乎与原始图像相同。


^(1)  朱俊彦,朴泰勋,菲利普·伊索拉,亚历克斯·埃弗罗斯,2017,“使用循环一致性对抗网络进行未配对图像到图像的翻译。” arxiv.org/abs/1703.10593

第七章:使用变分自编码器进行图像生成

本章涵盖了

  • 自编码器与变分自编码器比较

  • 构建和训练自编码器以重构手写数字

  • 构建和训练变分自编码器以生成人脸图像

  • 使用训练好的变分自编码器进行编码算术和插值

到目前为止,你已经学习了如何使用生成对抗网络(GANs)生成形状、数字和图像。在本章中,你将学习如何使用另一种生成模型:变分自编码器(VAEs)来创建图像。你还将通过执行编码算术和编码插值来了解 VAEs 的实际应用。

要了解变分自编码器(VAEs)是如何工作的,我们首先需要理解自编码器(AEs)。AEs 具有双组件结构:一个编码器和一个解码器。编码器将数据压缩成一个低维空间(潜在空间)中的抽象表示,而解码器则将编码信息解压缩并重构数据。AE 的主要目标是学习输入数据的压缩表示,重点是最小化重构误差——原始输入与其重构(在像素级别,正如我们在第六章计算循环一致性损失时所看到的)之间的差异。编码器-解码器架构是各种生成模型的基础,包括 Transformers,你将在本书的后半部分详细探索。例如,在第九章,你将构建一个用于机器语言翻译的 Transformer:编码器将英语短语转换为抽象表示,而解码器则根据编码器生成的压缩表示构建法语翻译。像 DALL-E 2 和 Imagen 这样的文本到图像 Transformer 也在其设计中使用了 AE 架构。这涉及到首先将图像编码成一个紧凑的、低维的概率分布。然后,它们从这个分布中进行解码。当然,不同模型中编码器和解码器的构成是不同的。

本章的第一个项目涉及从头开始构建和训练一个 AE 以生成手写数字。你将使用 60,000 个手写数字(0 到 9)的灰度图像作为训练数据,每个图像的大小为 28 × 28 = 784 像素。AE 中的编码器将每个图像压缩成一个只有 20 个值的确定性向量表示。AE 中的解码器重构图像,目的是最小化原始图像与重构图像之间的差异。这是通过最小化两个图像在像素级别的平均绝对误差来实现的。最终结果是能够生成与训练集几乎相同的手写数字的 AE。

虽然 AEs 在复制输入数据方面做得很好,但它们在生成训练集中不存在的新的样本时往往表现不佳。更重要的是,AEs 在输入插值方面并不擅长:它们常常无法生成两个输入数据点之间的中间表示。这让我们转向了 VAEs。VAEs 与 AEs 在两个关键方面有所不同。首先,虽然 AE 将每个输入编码为潜在空间中的一个特定点,VAE 则将其编码为该空间内的一个概率分布。其次,AE 仅关注最小化重建误差,而 VAE 学习潜在变量的概率分布参数,最小化一个包含重建损失和正则化项(库尔巴克-利布勒(KL)散度)的损失函数。

KL-散度鼓励潜在空间逼近某种分布(在我们的例子中是正态分布),并确保潜在变量不仅记住训练数据,而且捕获潜在的分布。它有助于实现一个结构良好的潜在空间,其中相似的数据点被紧密映射在一起,使空间连续且可解释。因此,我们可以操作编码以实现新的结果,这使得在 VAEs 中进行编码算术和输入插值成为可能。

在本章的第二个项目中,你将从头开始构建和训练一个 VAE,以生成人脸图像。在这里,你的训练集包括你在第五章下载的眼镜图像。VAE 的编码器将一个 3 × 256 × 256 = 196,608 像素的图像压缩成一个 100 个值的概率向量,每个向量遵循正态分布。然后解码器根据这个概率向量重建图像。训练好的 VAE 不仅可以从训练集中复制人脸,还可以生成新的图像。

你将学习如何在 VAEs 中进行编码算术和输入插值。你将操作不同输入的编码表示(潜在向量),在解码时达到特定的结果(例如,图像中是否有某些特征)。潜在向量控制解码图像中的不同特征,如性别、图像中是否有眼镜等。例如,你可以首先获得戴眼镜的男性(z1)、戴眼镜的女性(z2)和没戴眼镜的女性(z3)的潜在向量。然后计算一个新的潜在向量,z4 = z1 – z2 + z3。由于 z1 和 z2 在解码时都会导致图像中出现眼镜,z1 – z2 取消了结果图像中的眼镜特征。同样,由于 z2 和 z3 都会导致女性面孔,z3 – z2 取消了结果图像中的女性特征。因此,如果你使用训练好的 VAE 解码 z4 = z1 – z2 + z3,你会得到一个不带眼镜的男性图像。

你还将通过改变分配给潜在向量 z1 和 z2 的权重,创建一系列从戴眼镜的女士到不戴眼镜的女士过渡的图像。这些练习展示了 VAEs 在生成模型领域的多功能性和创意潜力。

与我们在前几章中学习的 GANs 相比,AEs 和 VAEs 具有简单的架构,易于构建。此外,AEs 和 VAEs 通常比 GANs 更容易、更稳定地训练。然而,AEs 和 VAEs 生成的图像通常比 GANs 生成的图像更模糊。GANs 在生成高质量、逼真的图像方面表现出色,但训练困难且资源密集。GANs 和 VAEs 之间的选择很大程度上取决于手头任务的特定要求,包括所需的输出质量、可用的计算资源以及稳定训练过程的重要性。

VAEs 在现实世界中具有广泛的应用。例如,假设你经营一家眼镜店,并成功在线推广了一种新款男士眼镜。现在,你希望针对女性市场推广同样的款式,但缺乏女士佩戴这些眼镜的图片,而且专业摄影的成本很高。这时,VAEs 就派上用场了:你可以将男士佩戴眼镜的现有图片与男士和女士不戴眼镜的图片结合起来。这样,你可以通过编码算术(你将在本章中学习的技术)创建女士佩戴相同眼镜风格的逼真图像,如图 7.1 所示。

图片

图 7.1 通过执行编码算术生成戴眼镜女士的图像

在另一种场景中,假设你的商店提供深色和浅色框架的眼镜,这两种框架都很受欢迎。你想要引入一个带有中等色调框架的中等选项。使用 VAEs,通过一种称为编码插值的方法,你可以轻松地生成一系列平滑过渡的图像,如图 7.2 所示。这些图像将从深色框架眼镜到浅色框架眼镜变化,为顾客提供视觉选择范围。

图片

图 7.2 从深色框架眼镜生成到浅色框架眼镜的图像系列

VAEs 的应用不仅限于眼镜;它几乎涵盖了任何产品类别,无论是服装、家具还是食品。这项技术为可视化营销各种产品提供了一种创造性和成本效益的解决方案。此外,尽管图像生成是一个突出的例子,但 VAEs 可以应用于许多其他类型的数据,包括音乐和文本。它们的通用性在实用方面开辟了无限的可能性!

7.1 AEs 概述

本节讨论了自动编码器是什么以及其基本结构。为了让你深入理解自动编码器的内部工作原理,你将在本章的第一个项目中构建和训练一个自动编码器来生成手写数字。本节概述了自动编码器的架构以及完成第一个项目的蓝图。

7.1.1 什么是自动编码器?

自动编码器是用于无监督学习的一种神经网络,特别适用于图像生成、压缩和去噪等任务。自动编码器由两个主要部分组成:编码器和解码器。编码器将输入压缩成低维表示(潜在空间),解码器则从这个表示中重建输入。

压缩表示或潜在空间捕捉了输入数据的最重要特征。在图像生成中,这个空间编码了网络训练过的图像的关键方面。自动编码器(AEs)因其学习数据表示的高效性和处理未标记数据的能力而非常有用,这使得它们适用于降维和特征学习等任务。自动编码器的一个挑战是编码过程中可能会丢失信息,这可能导致重建不够准确。使用具有多个隐藏层的更深层架构可以帮助学习更复杂和抽象的表示,从而可能减轻自动编码器中的信息丢失。此外,训练自动编码器生成高质量图像可能计算量很大,需要大量数据集。

正如我们在第一章中提到的,学习某物的最佳方式是从零开始创建它。为此,你将在本章的第一个项目中学习如何创建一个自动编码器来生成手写数字。下一小节将提供一个如何做到这一点的蓝图。

7.1.2 构建和训练自动编码器的步骤

假设你必须从头开始构建和训练一个自动编码器来生成手写数字的灰度图像,以便你获得使用自动编码器进行更复杂任务(如彩色图像生成或降维)所需的技能。你应该如何进行这项任务?

图 7.3 展示了自动编码器的架构以及训练自动编码器生成手写数字的步骤。

图片

图 7.3 自动编码器的架构以及训练其生成手写数字的步骤。自动编码器包括一个编码器(中间左侧)和一个解码器(中间右侧)。在训练的每个迭代中,将手写数字的图像输入到编码器(步骤 1)。编码器将图像压缩到潜在空间中的确定性点(步骤 2)。解码器从潜在空间中获取编码向量(步骤 3),并重建图像(步骤 4)。自动编码器调整其参数以最小化重建损失,即原始图像与重建图像之间的差异(步骤 5)。

如从图中所见,自动编码器(AE)有两个主要部分:一个编码器(中间左侧),它将手写数字图像压缩为潜在空间中的向量,以及一个解码器(中间右侧),它根据编码向量重建这些图像。编码器和解码器都是深度神经网络,可能包含不同类型的层,如密集层、卷积层、转置卷积层等。由于我们的例子涉及手写数字的灰度图像,我们将仅使用密集层。然而,自动编码器也可以用来生成更高分辨率的彩色图像;对于这些任务,卷积神经网络(CNNs)通常包含在编码器和解码器中。是否在自动编码器中使用 CNN 取决于你想要生成的图像分辨率。

当构建自动编码器时,其中的参数是随机初始化的。我们需要获得一个训练集来训练模型:PyTorch 提供了 60,000 张手写数字的灰度图像,这些图像在 0 到 9 这 10 个数字之间均匀分布。图 7.3 的左侧展示了三个例子,分别是数字 0、1 和 9 的图像。在训练循环的第一步中,我们将训练集中的图像输入到编码器中。编码器将图像压缩为潜在空间中的 20 值向量(步骤 2)。数字 20 并没有什么神奇之处。如果你在潜在空间中使用 25 值向量,你将得到相似的结果。然后,我们将向量表示传递给解码器(步骤 3),并要求它重建图像(步骤 4)。我们计算重建损失,这是原始图像和重建图像之间所有像素的平均平方误差。然后,我们将这个损失反向传播通过网络,以更新编码器和解码器中的参数,以最小化重建损失(步骤 5),这样在下一个迭代中,自动编码器可以重建更接近原始图像的图像。这个过程在数据集上重复许多个 epoch。

在模型训练完成后,你将向编码器提供未见过的手写数字图像,并获取编码。然后,你将编码传递给解码器以获取重建的图像。你会发现重建的图像几乎与原始图像完全相同。图 7.3 的右侧展示了三个重建图像的例子:它们看起来与图左侧对应的原始图像非常相似。

7.2 构建和训练自动编码器以生成数字

现在你已经有了构建和训练自动编码器以生成手写数字的蓝图,让我们深入到这个项目中,并实现上一节中概述的步骤。

具体来说,在本节中,您将首先学习如何获取手写数字图像的训练集和测试集。然后,您将使用密集层构建编码器和解码器。您将使用训练数据集训练自动编码器,并使用训练好的编码器对测试集中的图像进行编码。最后,您将学习如何使用训练好的解码器重建图像,并将它们与原始图像进行比较。

7.2.1 收集手写数字

您可以使用 Torchvision 库中的datasets包下载手写数字的灰度图像,类似于您在第二章中下载服装物品图像的方式。

首先,让我们下载一个训练集和一个测试集:

import torchvision
import torchvision.transforms as T

transform=T.Compose([
    T.ToTensor()])
train_set=torchvision.datasets.MNIST(root=".",        ①
    train=True,download=True,transform=transform)     ②
test_set=torchvision.datasets.MNIST(root=".",
    train=False,download=True,transform=transform)    ③

① 使用torchvision.datasets中的MNIST()类下载手写数字

train=True参数表示您下载训练集。

train=False参数表示您下载测试集。

与第二章中使用的FashionMNIST()类不同,我们在这里使用MNIST()类。类中的train参数告诉 PyTorch 是下载训练集(当参数设置为True时)还是测试集(当参数设置为False时)。在转换之前,图像像素是介于 0 到 255 之间的整数。前面代码块中的ToTensor()类将它们转换为介于 0 到 1 之间的 PyTorch 浮点张量。训练集中有 60,000 张图像,测试集中有 10,000 张图像,每个集合中均匀分布着 10 个数字,从 0 到 9。

我们将为训练和测试创建数据批次,每个批次包含 32 张图像:

import torch

batch_size=32
train_loader=torch.utils.data.DataLoader(
    train_set,batch_size=batch_size,shuffle=True)
test_loader=torch.utils.data.DataLoader(
    test_set,batch_size=batch_size,shuffle=True)

现在我们有了准备好的数据,我们将接下来构建和训练一个自动编码器。

7.2.2 构建和训练自动编码器

自动编码器由两部分组成:编码器和解码器。我们将定义一个AE()类,如下面的列表所示,来表示自动编码器。

列表 7.1 创建一个用于生成手写数字的自动编码器

import torch.nn.functional as F
from torch import nn

device="cuda" if torch.cuda.is_available() else "cpu"
input_dim = 784                                       ①
z_dim = 20                                            ②
h_dim = 200
class AE(nn.Module):
    def __init__(self,input_dim,z_dim,h_dim):
        super().__init__()
        self.common = nn.Linear(input_dim, h_dim)
        self.encoded = nn.Linear(h_dim, z_dim)
        self.l1 = nn.Linear(z_dim, h_dim)
        self.decode = nn.Linear(h_dim, input_dim)
    def encoder(self, x):                             ③
        common = F.relu(self.common(x))
        mu = self.encoded(common)
        return mu
    def decoder(self, z):                             ④
        out=F.relu(self.l1(z))
        out=torch.sigmoid(self.decode(out))
        return out
    def forward(self, x):                             ⑤
        mu=self.encoder(x)
        out=self.decoder(mu)
        return out, mu

① 自动编码器的输入有 28×28=784 个值。

② 潜在变量(编码)中有 20 个值。

③ 编码器将图像压缩为潜在变量。

④ 解码器根据编码重建图像。

⑤ 编码器和解码器构成了自动编码器。

输入大小为 784,因为手写数字的灰度图像大小为 28×28 像素。我们将图像展平为 1D 张量,并将其输入到自动编码器(AE)中。图像首先通过编码器:它们被压缩到低维空间中的编码。现在每个图像都由一个 20 值的潜在变量表示。解码器根据潜在变量重建图像。自动编码器(AE)的输出包含两个张量:out,重建的图像,和mu,潜在变量(即编码)。

接下来,我们实例化之前定义的AE()类来创建一个自动编码器。我们还在训练过程中使用了 Adam 优化器,就像之前章节中做的那样:

model = AE(input_dim,z_dim,h_dim).to(device)
lr=0.00025
optimizer = torch.optim.Adam(model.parameters(), lr=lr)

我们定义了一个名为plot_digits()的函数,用于在训练的每个 epoch 后可视检查重建的手写数字,如下面的列表所示。

列表 7.2 检查重建图像的 plot_digits() 函数

import matplotlib.pyplot as plt

originals = []                                           ①
idx = 0
for img,label in test_set:
    if label == idx:
        originals.append(img)
        idx += 1
    if idx == 10:
        break
def plot_digits():
    reconstructed=[]
    for idx in range(10):
        with torch.no_grad():
            img = originals[idx].reshape((1,input_dim))
            out,mu = model(img.to(device))               ②
        reconstructed.append(out)                        ③
    imgs=originals+reconstructed
    plt.figure(figsize=(10,2),dpi=50)
    for i in range(20):
        ax = plt.subplot(2,10, i + 1)
        img=(imgs[i]).detach().cpu().numpy()
        plt.imshow(img.reshape(28,28),                   ④
                   cmap="binary")
        plt.xticks([])
        plt.yticks([])
    plt.show()  

① 收集测试集中每个数字的样本图像

② 将图像输入到自动编码器以获得重建图像

③ 收集每个原始图像的重建图像

④ 视觉比较原始图像与重建数字

我们首先收集 10 张样本图像,每张代表一个不同的数字,并将它们放入一个列表 originals 中。我们将图像输入到自动编码器以获得重建图像。最后,我们绘制原始图像和重建图像,以便我们可以比较它们并定期评估自动编码器的性能。

在训练开始之前,我们调用 plot_digits() 函数来可视化输出:

plot_digits()

你将看到如图 7.4 所示的输出。

图 7.4 在训练开始之前,自动编码器重建图像与原始图像的比较。第一行显示了测试集中 10 个原始的手写数字图像。第二行显示了自动编码器在训练之前的重建图像。这些重建不过是纯粹的噪声。

尽管我们可以将我们的数据分为训练集和验证集,并训练模型直到在验证集上不再看到进一步的改进(就像我们在第二章中所做的那样),但我们的主要目标是掌握自动编码器的工作原理,而不一定是实现最佳参数调整。因此,我们将自动编码器训练 10 个周期。

列表 7.3 训练自动编码器生成手写数字

for epoch in range(10):
    tloss=0
    for imgs, labels in train_loader:                   ①
        imgs=imgs.to(device).view(-1, input_dim)
        out, mu=model(imgs)                             ②
        loss=((out-imgs)**2).sum()                      ③
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        tloss+=loss.item()
    print(f"at epoch {epoch} toal loss = {tloss/len(train_loader)}")
    plot_digits()                                       ④

① 遍历训练集中的批次

② 使用自动编码器重建图像

③ 通过均方误差计算重建损失

④ 视觉检查自动编码器的性能

在每个训练周期中,我们遍历训练集中的所有数据批次。我们将原始图像输入到自动编码器以获得重建图像。然后我们计算重建损失,即原始图像和重建图像之间的均方误差。具体来说,重建损失是通过首先计算两个图像之间的差异,逐像素平方这些值并平均平方差异来获得的。我们调整模型参数以最小化重建损失,利用 Adam 优化器,这是一种梯度下降方法的变体。

如果你使用 GPU 训练,模型大约需要 2 分钟来训练。或者,你可以从我的网站下载训练好的模型:mng.bz/YV6K

7.2.3 保存和使用训练好的自动编码器

我们将模型保存在您计算机上的本地文件夹中:

scripted = torch.jit.script(model) 
scripted.save('files/AEdigits.pt') 

要使用它来重建手写数字的图像,我们加载模型:

model=torch.jit.load('files/AEdigits.pt',map_location=device)
model.eval()

我们可以通过调用我们之前定义的 plot_digits() 函数来使用它生成手写数字:

plot_digits()

输出结果如图 7.5 所示。

图 7.5 比较训练 AE 重建的图像与原始图像。第一行显示了测试集中 10 个原始的手写数字图像。第二行显示了训练 AE 重建的图像。重建的图像看起来与原始图像相似。

重建的手写数字与原始数字相似,尽管重建并不完美。在编码-解码过程中,一些信息丢失了。然而,与 GANs 相比,AEs 更容易构建,训练时间也更短。此外,编码器-解码器架构被许多生成模型采用。这个项目将帮助您理解后面的章节,尤其是在我们探索 Transformers 时。

7.3 什么是 VAEs?

虽然 AEs 在重建原始图像方面很擅长,但它们在生成训练集中未见过的创新图像方面却失败了。此外,AEs 往往不会将相似输入映射到潜在空间中的邻近点。因此,与 AE 相关的潜在空间既不连续也不容易解释。例如,您无法插值两个输入数据点以生成有意义的中间表示。由于这些原因,我们将研究 AE 的改进:VAEs。

在本节中,您将首先了解 AEs 和 VAEs 之间的关键区别以及这些区别为何导致后者能够生成训练集中未见过的逼真图像。然后,您将学习训练 VAEs 的一般步骤,特别是训练一个用于生成高分辨率人脸图像的 VAE。

7.3.1 AEs 和 VAEs 之间的区别

VAEs 首先由 Diederik Kingma 和 Max Welling 在 2013 年提出。1 它是 AE 的一个变体。与 AE 一样,VAE 也包含两个主要部分:编码器和解码器。

然而,AEs 和 VAEs 之间有两个关键区别。首先,AE 中的潜在空间是确定性的。每个输入都被映射到潜在空间中的一个固定点。相比之下,VAE 中的潜在空间是概率性的。VAE 不是将输入编码为潜在空间中的一个单一向量,而是将输入编码为可能值的分布。例如,在我们的第二个项目中,我们将编码一个彩色图像到一个 100 个值的概率向量。此外,我们假设这个向量中的每个元素都遵循独立的高斯分布。由于定义高斯分布只需要均值(μ)和标准差(σ),我们 100 个元素的概率向量中的每个元素将由这两个参数来表征。为了重建图像,我们从这个分布中采样一个向量并将其解码。VAEs 的独特性体现在每次从分布中采样都会产生略微不同的输出。

在统计术语中,VAE 中的编码器试图学习训练数据 x 的真实分布 p(x|Θ),其中 Θ 是定义分布的参数。为了便于处理,我们通常假设潜在变量的分布是正态的。因为我们只需要均值 μ 和标准差 σ 来定义一个正态分布,我们可以将真实分布重写为 p(x|Θ) = p(x|μ, σ)。VAE 中的解码器根据编码器学习的分布生成一个样本。也就是说,解码器从分布 p(x|μ, σ) 中以概率生成一个实例。

AEs 和 VAEs 之间的第二个关键区别在于损失函数。当训练一个 AE 时,我们最小化重建损失,以便重建的图像尽可能接近原始图像。相比之下,在 VAEs 中,损失函数由两部分组成:重建损失和 KL 散度。KL 散度是衡量一个概率分布如何偏离第二个预期概率分布的度量。在 VAEs 中,KL 散度用于通过惩罚学习到的分布(编码器的输出)与先验分布(标准正态分布)的偏差来正则化编码器。这鼓励编码器学习有意义的和可推广的潜在表示。通过惩罚偏离先验太远的分布,KL 散度有助于避免过拟合。

在我们的设置中,KL 散度是这样计算的,因为我们假设了一个正态分布(如果假设非正态分布,公式则不同):

|

图片

(7.1)

求和是在潜在空间的 100 个维度上进行的。当编码器将图像压缩到潜在空间中的标准正态分布时,使得 μ=0 和 σ=1,KL 散度变为 0。在任何其他情况下,值都超过 0。因此,当编码器成功将图像压缩到潜在空间中的标准正态分布时,KL 散度被最小化。

7.3.2 训练 VAE 生成人脸图像的蓝图

在本章的第二个项目中,你将从头开始构建和训练一个 VAE,以生成人脸彩色图像。训练好的模型可以生成训练集中未见过的图像。此外,你可以插值输入以生成介于两个输入数据点之间的新颖图像,这些图像是两个输入数据点之间的中间表示。以下是这个第二个项目的蓝图。

图 7.6 提供了 VAE 架构的图示以及训练 VAE 生成人脸图像的步骤。

图片

图 7.6 展示了 VAE(变分自编码器)的架构以及训练其生成人脸图像的步骤。VAE 由一个编码器(中间左上角)和一个解码器(中间右下角)组成。在训练的每一次迭代中,人脸图像被输入到编码器(步骤 1)。编码器将图像压缩到潜在空间中的概率点(步骤 2;由于我们假设正态分布,每个概率点由均值向量和标准差向量表征)。然后,我们从分布中采样编码并展示给解码器。解码器接收采样的编码(步骤 3)并重建图像(步骤 4)。VAE 调整其参数以最小化重建损失和 KL 散度的总和。KL 散度衡量编码器输出与标准正态分布之间的差异。

图 7.6 显示 VAE 也有两个部分:一个编码器(中间左上角)和一个解码器(中间右下角)。由于第二个项目涉及高分辨率彩色图像,我们将使用 CNN(卷积神经网络)来创建 VAE。正如我们在第四章中讨论的,高分辨率彩色图像包含比低分辨率灰度图像更多的像素。如果我们只使用全连接(密集)层,模型中的参数数量太大,使得学习过程缓慢且无效。与类似大小的全连接网络相比,CNN 需要的参数更少,从而实现更快、更有效的学习。

一旦创建了 VAE,你将使用在第五章中下载的眼镜数据集来训练模型。图 7.6 的左侧展示了训练集中三个原始人脸图像的例子。在训练循环的第一步中,我们将大小为 3 × 256 × 256 = 196,608 像素的图像输入到编码器。编码器将图像压缩到潜在空间中的 100 值概率向量(步骤 2;由于假设正态分布,向量为均值和标准差向量)。然后,我们从分布中采样并输入采样的向量表示到解码器(步骤 3),并要求其重建图像(步骤 4)。我们计算总损失为像素级重建损失和方程 7.1 中指定的 KL 散度的总和。我们将这个损失反向传播通过网络以更新编码器和解码器中的参数,以最小化总损失(步骤 5)。总损失鼓励 VAE 将输入编码成更有意义和可推广的潜在表示,并重建更接近原始图像的图像。

模型训练完成后,你将向编码器输入人脸图像并获取编码。然后,你将编码输入到解码器以获取重建图像。你会发现重建的图像与原始图像非常接近。图 7.6 的右侧展示了三个重建图像的例子:它们与图左侧对应的原始图像相似,尽管并不完全一样。

更重要的是,你可以丢弃编码器,并从潜在空间中随机抽取编码,然后将它们输入到训练好的解码器中,以生成训练集中未见过的创新人脸图像。此外,你可以操纵不同输入的编码表示,以在解码时实现特定的结果。你还可以通过改变分配给任何两个编码的权重,创建一系列从实例到实例过渡的图像。

7.4 使用 VAE 生成人脸图像

本节将从头创建和训练一个 VAE,以生成人脸图像,按照上一节概述的步骤进行。

与我们为构建和训练自动编码器(AEs)所做的工作相比,我们第二个项目的方案包含几个修改。首先,我们计划在变分自编码器(VAEs)的编码器和解码器中都使用卷积神经网络(CNNs),尤其是由于高分辨率彩色图像具有更多的像素。仅依靠全连接(密集)层会导致参数数量过多,从而导致学习速度慢且效率低下。其次,作为我们将图像压缩为在潜在空间中遵循正态分布的向量的过程的一部分,我们将在编码每个图像时生成一个均值向量和标准差向量。这与自动编码器中使用的固定值向量不同。然后,从编码的正态分布中采样以获得编码,这些编码随后被解码以生成图像。值得注意的是,每次我们从该分布中采样时,重建的图像都会略有不同,这赋予了 VAEs 生成新颖图像的能力。

7.4.1 构建 VAE

如果你还记得,你在第五章下载的眼镜数据集在手动纠正了一些标签后,保存在你电脑上的文件夹 /files/glasses/ 中。我们将图像调整大小为 256 x 256 像素,值在 0 到 1 之间。然后,我们创建一个包含每个批次 16 个图像的批量迭代器:

transform = T.Compose([
            T.Resize(256),                                 ①
            T.ToTensor(),                                  ②
            ])
data = torchvision.datasets.ImageFolder(
    root="files/glasses",    
    transform=transform)                                   ③
batch_size=16
loader = torch.utils.data.DataLoader(data,                 ④
     batch_size=batch_size,shuffle=True)

① 将图像调整大小为 256 x 256 像素

② 将图像转换为值在 0 到 1 之间的张量

③ 从文件夹中加载图像并应用转换

④ 将数据放入批量迭代器

接下来,我们将创建一个包含卷积层和转置卷积层的 VAE。我们首先定义一个Encoder()类,如下所示。

列表 7.4 VAE 中的编码器

latent_dims=100                                             ①
class Encoder(nn.Module):
    def __init__(self, latent_dims=100):  
        super().__init__()
        self.conv1 = nn.Conv2d(3, 8, 3, stride=2, padding=1)
        self.conv2 = nn.Conv2d(8, 16, 3, stride=2, padding=1)
        self.batch2 = nn.BatchNorm2d(16)
        self.conv3 = nn.Conv2d(16, 32, 3, stride=2, padding=0)
        self.linear1 = nn.Linear(31*31*32, 1024)
        self.linear2 = nn.Linear(1024, latent_dims)
        self.linear3 = nn.Linear(1024, latent_dims)
        self.N = torch.distributions.Normal(0, 1)
        self.N.loc = self.N.loc.cuda() 
        self.N.scale = self.N.scale.cuda()
    def forward(self, x):
        x = x.to(device)
        x = F.relu(self.conv1(x))
        x = F.relu(self.batch2(self.conv2(x)))
        x = F.relu(self.conv3(x))
        x = torch.flatten(x, start_dim=1)
        x = F.relu(self.linear1(x))
        mu =  self.linear2(x)                               ②
        std = torch.exp(self.linear3(x))                    ③
        z = mu + std*self.N.sample(mu.shape)                ④
        return mu, std, z

① 潜在空间的维度是 100。

② 编码分布的均值

③ 编码的标准差

④ 编码向量表示

编码器网络由几个卷积层组成,这些层提取输入图像的空间特征。编码器将输入压缩成向量表示,z,这些向量具有均值mu和标准差std的正态分布。编码器的输出包括三个张量:mustdz。虽然mustd分别是概率向量的均值和标准差,但z是从该分布中采样的一个实例。

具体来说,输入图像,大小为(3, 256, 256),首先通过一个步长值为 2 的 Conv2d 层。正如我们在第四章中解释的,这意味着滤波器每次在输入图像上移动时跳过两个像素,这导致图像下采样。输出的大小为(8, 128, 128)。然后它通过两个更多的 Conv2d 层,大小变为(32, 31, 31)。它被展平并通过线性层获得mustd的值。

我们定义一个Decoder()类来表示 VAE 中的解码器。

列表 7.5 VAE 中的解码器

class Decoder(nn.Module):   
    def __init__(self, latent_dims=100):
        super().__init__()
        self.decoder_lin = nn.Sequential(                   ①
            nn.Linear(latent_dims, 1024),
            nn.ReLU(True),
            nn.Linear(1024, 31*31*32),                      ②
            nn.ReLU(True))
        self.unflatten = nn.Unflatten(dim=1, 
                  unflattened_size=(32,31,31))
        self.decoder_conv = nn.Sequential(                  ③
            nn.ConvTranspose2d(32,16,3,stride=2,
                               output_padding=1),
            nn.BatchNorm2d(16),
            nn.ReLU(True),
            nn.ConvTranspose2d(16, 8, 3, stride=2, 
                               padding=1, output_padding=1),
            nn.BatchNorm2d(8),
            nn.ReLU(True),
            nn.ConvTranspose2d(8, 3, 3, stride=2,
                               padding=1, output_padding=1))

    def forward(self, x):
        x = self.decoder_lin(x)
        x = self.unflatten(x)
        x = self.decoder_conv(x)
        x = torch.sigmoid(x)                                ④
        return x  

① 编码首先通过两个密集层。

② 将编码重新塑造成多维对象,以便我们可以对它们执行转置卷积操作

③ 将编码通过三个转置卷积层

④ 将输出挤压到 0 到 1 之间的值,与输入图像中的值相同

解码器是编码器的镜像:它不是执行卷积操作,而是在编码上执行转置卷积操作以生成特征图。它逐渐将潜在空间中的编码转换回高分辨率彩色图像。

具体来说,编码首先通过两个线性层。然后它被反展平到一个形状(32, 31, 31),与编码器中最后一个 Conv2d 层之后的图像大小相匹配。然后它通过三个 ConvTranspose2d 层,与编码器中的 Conv2d 层相匹配。解码器的输出形状为(3, 256, 256),与训练图像相同。

我们将编码器与解码器结合起来创建一个 VAE:

class VAE(nn.Module):
    def __init__(self, latent_dims=100):
        super().__init__()
        self.encoder = Encoder(latent_dims)                ①
        self.decoder = Decoder(latent_dims)                ②
    def forward(self, x):
        x = x.to(device)
        mu, std, z = self.encoder(x)                       ③
        return mu, std, self.decoder(z)                    ④

① 通过实例化 Encoder()类创建一个编码器

② 通过实例化 Decoder()类创建一个解码器

③ 将输入通过编码器以获取编码

④ VAE 的输出是编码的均值和标准差,以及重建的图像。

VAE 由一个编码器和一个解码器组成,由Encoder()Decoder()类定义。当我们通过 VAE 传递图像时,输出包括三个张量:编码和重建图像的均值和标准差。

接下来,我们通过实例化VAE()类创建一个 VAE 并定义模型的优化器:

vae=VAE().to(device)
lr=1e-4 
optimizer=torch.optim.Adam(vae.parameters(),
                           lr=lr,weight_decay=1e-5)

我们将在训练期间手动计算重建损失和 KL 散度损失。因此,我们在此处不定义损失函数。

7.4.2 训练 VAE

为了训练模型,我们首先定义一个train_epoch()函数来训练模型一个 epoch。

列表 7.6 定义train_epoch()函数

def train_epoch(epoch):
    vae.train()
    epoch_loss = 0.0
    for imgs, _ in loader: 
        imgs = imgs.to(device)
        mu, std, out = vae(imgs)                                   ①
        reconstruction_loss = ((imgs-out)**2).sum()                ②
        kl = ((std**2)/2 + (mu**2)/2 - torch.log(std) - 0.5).sum() ③
        loss = reconstruction_loss + kl                            ④
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        epoch_loss+=loss.item()
    print(f'at epoch {epoch}, loss is {epoch_loss}')  

① 获取重建的图像

② 计算重建损失

③ 计算 KL 散度

④ 重建损失和 KL 散度的总和。

我们遍历训练集中的所有批次。我们通过 VAE 传递图像以获取重建图像。总损失是重建损失和 KL 散度的总和。在每个迭代中调整模型参数以最小化总损失。

我们还定义了一个 plot_epoch() 函数,用于直观检查 VAE 生成的图像:

import numpy as np
import matplotlib.pyplot as plt

def plot_epoch():
    with torch.no_grad():
        noise = torch.randn(18,latent_dims).to(device)
        imgs = vae.decoder(noise).cpu()
        imgs = torchvision.utils.make_grid(imgs,6,3).numpy()
        fig, ax = plt.subplots(figsize=(6,3),dpi=100)
        plt.imshow(np.transpose(imgs, (1, 2, 0)))
        plt.axis("off")
        plt.show()

一个训练良好的 VAE 可以将相似的输入映射到潜在空间中的邻近点,从而得到一个更连续和可解释的潜在空间。因此,我们可以从潜在空间中随机抽取向量,VAE 可以将这些向量解码成有意义的输出。因此,在前面的 plot_epoch() 函数中,我们随机从潜在空间中抽取 18 个向量,并在每个训练周期后使用它们生成 18 张图像。我们将它们绘制在一个 3 × 6 的网格中,并直观地检查它们以了解 VAE 在训练过程中的表现。

接下来,我们训练 VAE 模型 10 个周期:

for epoch in range(1,11):
    train_epoch(epoch)
    plot_epoch()
torch.save(vae.state_dict(),"files/VAEglasses.pth")

如果使用 GPU 训练,这个过程大约需要半小时,否则需要几个小时。训练好的模型权重已保存在您的计算机上。或者,您可以从我的网站上下载训练好的权重:mng.bz/GNRR。请确保下载后解压文件。

7.4.3 使用训练好的 VAE 生成图像

现在,VAE 已经训练好了,我们可以用它来生成图像。我们首先加载保存在本地文件夹中的训练好的模型权重:

vae.eval()
vae.load_state_dict(torch.load('files/VAEglasses.pth',
    map_location=device))

然后,我们检查 VAE 重建图像的能力,并观察它们与原始图像的相似程度:

imgs,_=next(iter(loader))
imgs = imgs.to(device)
mu, std, out = vae(imgs)
images=torch.cat([imgs[:8],out[:8],imgs[8:16],out[8:16]],
                 dim=0).detach().cpu()
images = torchvision.utils.make_grid(images,8,4)
fig, ax = plt.subplots(figsize=(8,4),dpi=100)
plt.imshow(np.transpose(images, (1, 2, 0)))
plt.axis("off")
plt.show()

如果您运行前面的代码块,您将看到类似于图 7.7 的输出。

图 7.7 比较训练好的 VAE 重建的图像与原始图像。第一行和第三行是原始图像。我们将它们输入到训练好的 VAE 模型中,以获得重建的图像,这些图像显示在原始图像下方。

原始图像显示在第一行和第三行,而重建的图像显示在原始图像下方。重建的图像与原始图像相似,如图 7.7 所示。然而,在重建过程中会丢失一些信息:它们看起来没有原始图像那么真实。

接下来,我们通过调用之前定义的 plot_epoch() 函数来测试 VAE 生成训练集中未见过的创新图像的能力:

plot_epoch()  

函数从潜在空间随机抽取 18 个向量,并将它们传递给训练好的 VAE 模型以生成 18 张图像。输出结果如图 7.8 所示。

图 7.8 由训练好的 VAE 生成的创新图像。我们在潜在空间中随机抽取向量表示,并将它们输入到训练好的 VAE 模型的解码器中。解码后的图像显示在本图中。由于向量表示是随机抽取的,因此这些图像与训练集中的任何原始图像都不对应。

这些图像不在训练集中:编码是从潜在空间中随机抽取的,而不是通过编码器处理训练集中的图像后得到的编码向量。这是因为 VAEs 中的潜在空间是连续且可解释的。潜在空间中的新编码可以有意义地解码成与训练集中图像相似但不同的图像。

7.4.4 使用训练好的 VAE 进行编码算术

VAEs 在它们的损失函数中包含一个正则化项(KL 散度),这鼓励潜在空间逼近正态分布。这种正则化确保潜在变量不仅记住训练数据,而且捕获潜在的分布。它有助于实现一个结构良好的潜在空间,其中相似的数据点被紧密映射在一起,使空间连续且可解释。因此,我们可以操纵编码以实现新的结果。

为了使结果可重复,我鼓励您从我的网站下载训练好的权重(mng.bz/GNRR)并使用本章其余部分的相同代码块。正如我们在引言中解释的,编码算术允许我们生成具有特定特征的图像。为了说明编码算术在 VAEs 中的工作原理,让我们首先从以下四个组中手动收集每组三张图像:带眼镜的男性、不带眼镜的男性、带眼镜的女性和不带眼镜的女性。

列表 7.7 收集具有不同特征的图像

torch.manual_seed(0)  
glasses=[]
for i in range(25):                                        ①
    img,label=data[i]
    glasses.append(img)
    plt.subplot(5,5,i+1)
    plt.imshow(img.numpy().transpose((1,2,0)))
    plt.axis("off")
plt.show()
men_g=[glasses[0],glasses[3],glasses[14]]                  ②
women_g=[glasses[9],glasses[15],glasses[21]]               ③

noglasses=[]
for i in range(25):                                        ④
    img,label=data[-i-1]
    noglasses.append(img)
    plt.subplot(5,5,i+1)
    plt.imshow(img.numpy().transpose((1,2,0)))
    plt.axis("off")
plt.show()
men_ng=[noglasses[1],noglasses[7],noglasses[22]]           ⑤
women_ng=[noglasses[4],noglasses[9],noglasses[19]])        ⑥

① 显示 25 张带眼镜的图像

② 选择三张带眼镜男性的图像

③ 选择三张带眼镜女性的图像

④ 显示 25 张不带眼镜的图像

⑤ 选择三张不带眼镜的男性图像

⑥ 选择三张不带眼镜的女性图像

我们在每个组中选择三张图像而不是一张,这样在执行编码算术时,我们可以计算同一组中多个编码的平均值。VAEs 旨在学习潜在空间中输入数据的分布。通过平均多个编码,我们有效地平滑了该空间中的表示。这有助于我们找到一个平均表示,它能够捕捉到组内不同样本之间的共同特征。

接下来,我们将三张带眼镜的男性图像输入到训练好的 VAE 中,以获取它们在潜在空间中的编码。然后我们计算这三个图像的平均编码,并使用它来获取一个带眼镜男性的重建图像。然后我们对其他三个组重复此操作。

列表 7.8 在四个不同组中编码和解码图像

# create a batch of images of men with glasses
men_g_batch = torch.cat((men_g[0].unsqueeze(0),              ①
             men_g[1].unsqueeze(0),
             men_g[2].unsqueeze(0)), dim=0).to(device)
# Obtain the three encodings
_,_,men_g_encodings=vae.encoder(men_g_batch)
# Average over the three images to obtain the encoding for the group
men_g_encoding=men_g_encodings.mean(dim=0)                   ②
# Decode the average encoding to create an image of a man with glasses 
men_g_recon=vae.decoder(men_g_encoding.unsqueeze(0))         ③

# Do the same for the other three groups
# group 2, women with glasses
women_g_batch = torch.cat((women_g[0].unsqueeze(0),
             women_g[1].unsqueeze(0),
             women_g[2].unsqueeze(0)), dim=0).to(device)
# group 3, men without glasses
men_ng_batch = torch.cat((men_ng[0].unsqueeze(0),
             men_ng[1].unsqueeze(0),
             men_ng[2].unsqueeze(0)), dim=0).to(device)
# group 4, women without glasses
women_ng_batch = torch.cat((women_ng[0].unsqueeze(0),
             women_ng[1].unsqueeze(0),
             women_ng[2].unsqueeze(0)), dim=0).to(device)
# obtain average encoding for each group
_,_,women_g_encodings=vae.encoder(women_g_batch)
women_g_encoding=women_g_encodings.mean(dim=0)
_,_,men_ng_encodings=vae.encoder(men_ng_batch)
men_ng_encoding=men_ng_encodings.mean(dim=0)
_,_,women_ng_encodings=vae.encoder(women_ng_batch)
women_ng_encoding=women_ng_encodings.mean(dim=0)              ④
# decode for each group
women_g_recon=vae.decoder(women_g_encoding.unsqueeze(0))
men_ng_recon=vae.decoder(men_ng_encoding.unsqueeze(0))
women_ng_recon=vae.decoder(women_ng_encoding.unsqueeze(0))    ⑤

① 创建一批带眼镜男性的图像

② 获取带眼镜男性的平均编码

③ 解码带眼镜男性的平均编码

④ 获取其他三个组的平均编码

⑤ 解码其他三个组的平均编码

四个组的平均编码分别为men_g_encodingwomen_g_encodingmen_ng_encodingwomen_ng_encoding,其中g代表眼镜,ng代表无眼镜。四个组的解码图像分别为men_g_reconwomen_g_reconmen_ng_reconwomen_ng_recon。我们绘制了这四幅图像:

imgs=torch.cat((men_g_recon,
                women_g_recon,
                men_ng_recon,
                women_ng_recon),dim=0)
imgs=torchvision.utils.make_grid(imgs,4,1).cpu().numpy()
imgs=np.transpose(imgs,(1,2,0))
fig, ax = plt.subplots(figsize=(8,2),dpi=100)
plt.imshow(imgs)
plt.axis("off")
plt.show()

你将看到如图 7.9 所示的输出。

图 7.9 基于平均编码的解码图像。我们首先获取以下四个组中的每个组的三幅图像:带眼镜男性、带眼镜女性、不带眼镜男性和不带眼镜女性。我们将 12 幅图像输入到训练好的 VAE 中的编码器,以获取它们在潜在空间中的编码。然后我们计算每个组中三幅图像的平均编码。四个平均编码被输入到训练好的 VAE 中的解码器,以获得四个图像,它们显示在这张图中。

图 7.9 显示了四个解码图像。它们是代表四个组的合成图像。请注意,它们与原始的 12 个图像中的任何一个都不同。同时,它们保留了每个组的定义特征。

接下来,让我们操作编码以创建一个新的编码,然后使用 VAE 中训练好的解码器来解码这个新编码,看看会发生什么。例如,我们可以从带眼镜女性的平均编码中减去带眼镜男性的平均编码,并加上不带眼镜女性的平均编码。然后我们将结果输入到解码器中,查看输出。

列表 7.9 编码算术的示例

z=men_g_encoding-women_g_encoding+women_ng_encoding         ①
out=vae.decoder(z.unsqueeze(0))                             ②
imgs=torch.cat((men_g_recon,
                women_g_recon,
                women_ng_recon,out),dim=0)
imgs=torchvision.utils.make_grid(imgs,4,1).cpu().numpy()
imgs=np.transpose(imgs,(1,2,0))
fig, ax = plt.subplots(figsize=(8,2),dpi=100)
plt.imshow(imgs)                                            ③
plt.title("man with glasses - woman \
with glasses + woman without \
glasses = man without glasses ",fontsize=10,c="r")          ④
plt.axis("off")
plt.show()

① 定义 z 为带眼镜男性的编码 - 带眼镜女性的编码 + 不带眼镜女性的编码

② 将 z 解码为生成图像

③ 显示四个图像

④ 在图像上方显示标题

如果你运行列表 7.9 中的代码块,你将看到如图 7.10 所示的输出。

图 7.10 使用训练好的 VAE 进行编码算术的示例。我们首先获取以下三个组的平均编码:带眼镜男性(z1)、带眼镜女性(z2)和不带眼镜女性(z3)。我们定义一个新的编码 z = z1 - z2 + z3。然后我们将 z 输入到训练好的 VAE 中的解码器,获得解码图像,如图中右侧所示。

图 7.10 中的前三幅图像是代表三个输入组的合成图像。最右侧的输出图像是一位不带眼镜的男性的图像。

由于解码后men_g_encodingwomen_g_encoding都会在图像中出现眼镜,所以men_g_encodingwomen_g_encoding会取消掉结果图像中的眼镜特征。同样,由于women_ng_encodingwomen_g_encoding都会导致出现女性面孔,所以women_ng_encodingwomen_g_encoding会取消掉结果图像中的女性特征。因此,如果你使用训练好的 VAE 解码men_g_encoding + women_g_encodingwomen_ng_encoding,你会得到一个不带眼镜的男性的图像。这个例子中的编码算术表明,可以通过操纵其他三个组中的平均编码来获得不带眼镜的男性的编码。

练习 7.1

通过修改代码列表 7.9 执行以下编码算术:

  1. 从不带眼镜的男性的平均编码中减去带眼镜的男性的平均编码,并加上不带眼镜的女性的平均编码。将结果输入解码器,看看会发生什么。

  2. 从不带眼镜的女性的平均编码中减去不带眼镜的男性的平均编码,并加上带眼镜的女性的平均编码。将结果输入解码器,看看会发生什么。

  3. 从不带眼镜的男性的平均编码中减去不带眼镜的女性的平均编码,并加上带眼镜的男性的平均编码。将结果输入解码器,看看会发生什么。确保你修改图像标题以反映这些变化。解决方案在本书的 GitHub 仓库中提供:github.com/markhliu/DGAI

此外,我们可以在潜在空间中通过为它们分配不同的权重来插值任何两个编码,并创建一个新的编码。然后我们可以解码这个新的编码,并创建一个合成图像作为结果。通过选择不同的权重,我们可以创建一系列中间图像,这些图像从一个图像过渡到另一个图像。

让我们以有和无眼镜的女性的编码为例。我们将定义一个新的编码zw*women_ng_encoding+(1-w)*women_g_encoding,其中w是我们放在women_ng_encoding上的权重。我们将w的值从 0 增加到 1,每次增加 0.2。然后我们解码它们,并显示结果的前六个图像。

列表 7.10 通过插值两个编码创建一系列图像

results=[]
for w in [0, 0.2, 0.4, 0.6, 0.8, 1.0]:           ①
    z=w*women_ng_encoding+(1-w)*women_g_encoding ②
    out=vae.decoder(z.unsqueeze(0))              ③
    results.append(out)
imgs=torch.cat((results[0],results[1],results[2],
                results[3],results[4],results[5]),dim=0)
imgs=torchvision.utils.make_grid(imgs,6,1).cpu().numpy()
imgs=np.transpose(imgs,(1,2,0))
fig, ax = plt.subplots(dpi=100)
plt.imshow(imgs)                                 ④
plt.axis("off")
plt.show()

① 遍历六个不同的 w 值

② 在两个编码之间进行插值

③ 解码插值编码

④ 显示六个结果图像

运行列表 7.10 中的代码后,你将看到如图 7.11 所示的输出。

图片

图 7.11 通过插值编码创建一系列中间图像。我们首先获得戴眼镜的妇女的平均编码(women_g_encoding)和不戴眼镜的妇女的平均编码(women_ng_encoding)。插值编码 z 定义为 w*women_ng_encoding+(1-w)*women_g_encoding,其中 w 是 women_ng_encoding 上的权重。我们将 w 的值从 0 到 1 以 0.2 的增量改变,以创建六个插值编码。然后我们解码它们,并在图中显示产生的六个图像。

如图 7.11 所示,当你从左向右移动时,图像逐渐从戴眼镜的妇女过渡到不戴眼镜的妇女。这表明潜在空间中的编码是连续的、有意义的和可插值的。

练习 7.2

将列表 7.10 修改为使用以下编码对创建一系列中间图像:(i)men_ng_encodingmen_g_encoding;(ii)men_ng_encodingwomen_ng_encoding;(iii)men_g_encodingwomen_g_encoding。解决方案在本书的 GitHub 仓库中提供:github.com/markhliu/DGAI

从下一章开始,你将踏上自然语言处理之旅。这将使你能够生成另一种形式的内容:文本。然而,你迄今为止使用的许多工具将在后面的章节中再次使用,例如深度神经网络和编码器-解码器架构。

摘要

  • AEs 具有双组件结构:编码器和解码器。编码器将数据压缩到低维空间(潜在空间)中的抽象表示,解码器解压缩编码信息并重建数据。

  • VAEs 也由编码器和解码器组成。它们在两个关键方面与 AEs 不同。首先,虽然 AE 将每个输入编码为潜在空间中的一个特定点,VAE 则将其编码为该空间内的一个概率分布。其次,AE 仅关注最小化重建误差,而 VAE 学习潜在变量的概率分布参数,最小化包括重建损失和正则化项(KL 散度)的损失函数。

  • 在训练变分自编码器(VAEs)时,损失函数中的 KL 散度确保潜在变量的分布类似于正态分布。这鼓励编码器学习连续的、有意义的和可泛化的潜在表示。

  • 一个训练良好的 VAE 可以将相似的输入映射到潜在空间中的邻近点,从而产生更连续和可解释的潜在空间。因此,VAEs 可以将潜在空间中的随机向量解码为有意义的输出,从而产生在训练集中未见过的图像。

  • 在变分自编码器(VAE)中的潜在空间是连续且可解释的,与自编码器(AE)中的不同。因此,我们可以操纵编码以实现新的结果。我们还可以通过在潜在空间中两个编码的权重上进行变化,创建一系列从一个个例过渡到另一个个例的中间图像。


^(1)  迭代克·P·金玛和马克斯·韦林,2013 年,“自动编码变分贝叶斯。” arxiv.org/abs/1312.6114.

第三部分:自然语言处理和 Transformer

第三部分专注于文本生成。

在第八章,你将学习如何构建和训练一个循环神经网络来生成文本。在这个过程中,你将了解标记化和词嵌入是如何工作的。你还将学习如何自回归地生成文本,以及如何使用温度和 top-K 采样来控制生成文本的创造力。在第九章和第十章,你将从头开始构建一个 Transformer,基于论文“Attention Is All You Need”,来翻译英语到法语。在第十一章,你将学习如何从头开始构建 GPT-2XL,这是 GPT-2 的最大版本。之后,你将学习如何从 Hugging Face 提取预训练的权重并将它们加载到自己的 GPT-2 模型中。你将通过向模型输入提示来使用你的 GPT-2 生成文本。在第十二章,你将构建和训练一个 GPT 模型,以生成海明威风格的文本。

第八章:使用循环神经网络进行文本生成

本章涵盖

  • RNN 背后的理念以及为什么它们可以处理序列数据

  • 字符标记化、单词标记化和子词标记化

  • 词嵌入的工作原理

  • 构建和训练 RNN 以生成文本

  • 使用温度和 top-K 采样来控制文本生成的创造性

到目前为止,在这本书中,我们已经讨论了如何生成形状、数字和图像。从本章开始,我们将主要关注文本生成。生成文本通常被认为是生成式 AI 的圣杯,有以下几个令人信服的原因。人类语言极其复杂和微妙。它不仅涉及理解语法和词汇,还包括上下文、语气和文化参考。成功生成连贯且上下文适当的文本是一个重大的挑战,需要深入理解和处理语言。

作为人类,我们主要通过语言进行沟通。能够生成类似人类文本的 AI 可以更自然地与用户互动,使技术更加易于访问和用户友好。文本生成有许多应用,从自动化客户服务响应到创建完整的文章、游戏和电影的脚本,帮助创意写作,甚至构建个人助理。其对各个行业的潜在影响是巨大的。

在本章中,我们将尝试构建和训练模型以生成文本。你将学习如何应对文本生成中的三个主要挑战。首先,文本是序列数据,由按特定顺序组织的数据点组成,其中每个点按顺序排列以反映数据内部固有的顺序和相互依赖关系。由于序列的敏感顺序,预测序列的结果具有挑战性。改变元素的顺序会改变其含义。其次,文本表现出长距离依赖性:文本的某个部分的意义取决于文本中较早出现(例如,100 个词之前)的元素。理解和建模这些长距离依赖关系对于生成连贯的文本至关重要。最后,人类语言是模糊和上下文相关的。训练一个模型来理解细微差别、讽刺、习语和文化参考以生成上下文准确的文本具有挑战性。

你将探索一种专门设计用于处理顺序数据(如文本或时间序列)的特定神经网络:循环神经网络(RNN)。传统的神经网络,如前馈神经网络或全连接网络,独立地处理每个输入。这意味着网络分别处理每个输入,不考虑不同输入之间的任何关系或顺序。相比之下,RNNs 是专门设计来处理顺序数据的。在 RNN 中,给定时间步的输出不仅取决于当前输入,还取决于之前的输入。这允许 RNNs 保持一种记忆形式,从之前的时间步捕获信息以影响当前输入的处理。

这种顺序处理使得循环神经网络(RNNs)适用于那些输入顺序很重要的任务,例如语言建模,其目标是根据前面的单词预测句子中的下一个单词。我们将重点关注 RNN 的一种变体,即长短期记忆(LSTM)网络,它可以在文本等顺序数据中识别短期和长期数据模式。LSTM 模型使用一个隐藏状态来捕捉之前时间步的信息。因此,一个训练好的 LSTM 模型可以根据上下文生成连贯的文本。

生成的文本风格取决于训练数据。此外,由于我们计划从头开始训练一个用于文本生成的模型,训练文本的长度是一个关键因素。它需要足够广泛,以便模型能够有效地学习和模仿特定的写作风格,同时又要足够简洁,以避免在训练期间产生过度的计算需求。因此,我们将使用小说《安娜·卡列尼娜》中的文本进行训练,这似乎符合我们的目的,用于训练 LSTM 模型。由于像 LSTM 这样的神经网络不能直接接受文本作为输入,你将学习如何将文本分解成标记(在本章中是单个单词,但在后面的章节中可以是单词的一部分),这个过程称为标记化。然后,你将创建一个字典,将每个唯一的标记映射到一个整数(即索引)。基于这个字典,你将把文本转换成一个长序列的整数,以便输入到神经网络中。

你将使用一定长度的索引序列作为训练 LSTM 模型的输入。你将输入序列向右移动一个标记,并使用它作为输出:你实际上是在训练模型预测句子中的下一个标记。这是自然语言处理(NLP)中所谓的序列到序列预测问题,你将在后面的章节中再次看到它。

一旦 LSTM(长短期记忆网络)被训练,你将用它一次生成一个标记的文本,基于序列中的前一个标记,如下所示:你将一个提示(如“Anna and the”句子的一部分)输入到训练好的模型中。然后模型预测最可能的下一个标记,并将选定的标记附加到提示上。更新后的提示再次作为输入,模型再次被用来预测下一个标记。这个过程迭代进行,直到提示达到一定的长度。这种方法类似于更高级的生成模型(如 ChatGPT)所采用的机制(尽管 ChatGPT 不是一个 LSTM)。你将见证训练好的 LSTM 模型生成语法正确且连贯的文本,其风格与原始小说相匹配。

最后,你还将学习如何通过温度和 top-K 采样来控制生成文本的创造性。温度控制训练模型的预测随机性。高温度使得生成的文本更具创造性,而低温度则使文本更加自信和可预测。top-K 采样是一种方法,其中你从最可能的 K 个标记中选择下一个标记,而不是从整个词汇表中选择。K 值较小会导致在每一步选择高度可能的标记,这反过来又使得生成的文本不那么具有创造性,而更加连贯。

本章的主要目标并不是生成尽可能连贯的文本,正如之前提到的,这提出了巨大的挑战。相反,我们的目标是展示 RNNs 的局限性,从而为后续章节中介绍 Transformers 做准备。更重要的是,本章建立了文本生成的基本原则,包括标记化、词嵌入、序列预测、温度设置和 top-K 采样。因此,在后续章节中,你将牢固地理解 NLP 的基础知识。这个基础将使我们能够专注于 NLP 的其他更高级方面,例如注意力机制的工作原理和 Transformers 的架构。

8.1 RNNs 简介

在本章的开头,我们提到了生成文本所涉及的复杂性,尤其是在追求连贯性和上下文相关性时。本节将进一步深入探讨这些挑战,并探讨 RNNs 的架构。我们将解释为什么 RNNs 适合这项任务以及它们的局限性(这是它们被 Transformers 取代的原因)。

RNNs(循环神经网络)是专门设计来处理序列数据的,这使得它们能够胜任文本生成任务,这项任务在本质上具有序列性。它们利用一种称为隐藏状态的记忆形式,来捕捉和保留序列早期部分的信息。这种能力对于保持上下文和随着序列的进展理解依赖关系至关重要。

在本章中,我们将特别使用 LSTM 网络,这是 RNNs 的高级版本,用于文本生成,利用其高级功能来应对这项任务中的挑战。

8.1.1 文本生成的挑战

文本代表了典型的序列数据,它被定义为任何数据集,其中元素的顺序至关重要。这种结构意味着各个元素之间的相对位置具有重大意义,通常传达了理解数据所必需的重要信息。序列数据的例子包括时间序列(如股价)、文本内容(如句子)和音乐作品(音符的连续序列)。

本书主要关注文本生成,尽管在第十三章和第十四章中也涉及音乐生成。文本生成的过程充满了复杂性。一个主要的挑战在于对句子中单词序列的建模,改变顺序可能会极大地改变句子的含义。例如,在句子“肯塔基在昨晚的足球比赛中击败了范德比尔特”中,将“肯塔基”和“范德比尔特”完全互换位置,尽管使用了相同的单词,却完全颠倒了句子的含义。此外,正如引言中提到的,文本生成在处理长距离依赖关系和解决歧义问题上也面临着挑战。

在本章中,我们将探讨解决这些挑战的一种方法——即使用 RNNs(循环神经网络)。虽然这种方法并不完美,但它为你在后续章节中遇到的更高级技术奠定了基础。这种方法将帮助你了解如何管理词序、解决长距离依赖关系,以及处理文本中的固有歧义,为你提供文本生成的基本技能。通过本章的学习,你将为书中更复杂的方法和深入的理解打下坚实的基础。在这个过程中,你将获得许多在自然语言处理(NLP)领域非常有价值的技术,例如文本分词、词嵌入和序列到序列的预测。

8.1.2 RNNs 是如何工作的?

RNNs 是一种专门的人工神经网络形式,旨在识别数据序列中的模式,如文本、音乐或股价。与处理输入独立的传统神经网络不同,RNNs 内部有循环,允许信息持续存在。

在生成文本的挑战之一是如何根据所有前面的单词预测下一个单词,以便预测能够捕捉到长距离依赖关系和上下文意义。RNNs 的输入不仅是一个独立的项,而是一个序列(例如句子中的单词)。在每一个时间步,预测不仅基于当前输入,还基于通过隐藏状态总结的所有先前输入。以短语“一只青蛙有四条腿”为例。在第一个时间步,我们使用单词“一个”来预测第二个单词“青蛙”。在第二个时间步,我们使用“一个”和“青蛙”来预测下一个单词。当我们预测最后一个单词时,我们需要使用所有四个先前单词“一个青蛙有四条”。

RNNs 的一个关键特性是所谓的隐藏状态,它捕捉了序列中所有先前元素的信息。这一特性对于网络有效处理和生成序列数据至关重要。RNNs 的功能和这种序列处理在图 8.1 中得到了展示,该图说明了循环神经元层是如何随时间展开的。

图片

图 8.1 展示了通过时间展开的循环神经元层。当一个循环神经网络对序列数据进行预测时,它从上一个时间步的隐藏状态 h(t – 1)以及当前时间步的输入 x(t)中获取信息,并生成输出 y(t)以及更新的隐藏状态 h(t)。时间步 t 的隐藏状态捕捉了所有先前时间步的信息,x(0),x(1),…,x(t)。

RNNs 中的隐藏状态在捕捉所有时间步的信息方面发挥着关键作用。这使得 RNNs 能够做出不仅由当前输入 x(t)而且由所有先前输入的累积知识 x(0),x(1),…,x(t – 1)所指导的预测。这一属性使 RNNs 能够理解时间依赖关系。它们能够从输入序列中把握上下文,这对于语言建模等任务至关重要,在这些任务中,句子中的前一个单词为预测下一个单词设定了场景。

然而,RNNs 并非没有缺点。尽管标准 RNNs 能够处理短期依赖关系,但在文本中的长距离依赖关系上却显得力不从心。这种困难源于梯度消失问题,在长序列中,梯度(对于训练网络至关重要)会减小,阻碍模型学习长距离关系的能力。为了减轻这一问题,已经开发出了 RNNs 的高级版本,例如 LSTM 网络。

LSTM 网络是由 Hochreiter 和 Schmidhuber 在 1997 年提出的.^(1) LSTM 网络由 LSTM 单元(或细胞)组成,每个单元的结构都比标准 RNN 神经元更复杂。细胞状态是 LSTM 的关键创新:它充当一种传送带,沿着整个 LSTM 单元链直接运行。它具有在网络中携带相关信息的能力。向细胞状态添加或删除信息的能力使 LSTM 能够捕捉长期依赖关系并长时间记住信息。这使得它们在语言建模和文本生成等任务上更加有效。在本章中,我们将利用 LSTM 模型进行文本生成项目,旨在模仿小说 安娜·卡列尼娜 的风格。

然而,值得注意的是,即使是像 LSTM 这样的高级 RNN 变体在捕捉序列数据中的极长距离依赖关系时也会遇到障碍。我们将在下一章讨论这些挑战并提供解决方案,继续探索用于有效序列数据处理和生成的复杂模型。

8.1.3 训练 LSTM 模型的步骤

接下来,我们将讨论训练 LSTM 模型生成文本所涉及的步骤。这个概述旨在在开始项目之前,提供一个对训练过程的坚实基础理解。

训练文本的选择取决于期望的输出。一部篇幅较长的小说是一个良好的起点。其丰富的内容使模型能够有效地学习和复制特定的写作风格。大量的文本数据增强了模型在此风格上的熟练度。同时,小说通常不会过长,这有助于管理训练时间。对于我们 LSTM 模型的训练,我们将利用来自 安娜·卡列尼娜 的文本,符合我们之前概述的训练数据标准。

与其他深度神经网络类似,LSTM 模型不能直接处理原始文本。相反,我们将文本转换为数值形式。这始于将文本分解成更小的片段,这个过程称为分词,其中每个片段都是一个标记。标记可以是完整的单词、标点符号(如感叹号或逗号),或特殊字符(如 & 或 %)。在本章中,这些元素都将被视为单独的标记。尽管这种分词方法可能不是最有效的,但它易于实现,因为我们只需要将单词映射到标记。在后续章节中,我们将使用子词分词,其中一些不常见的单词被分解成更小的片段,如音节。在分词之后,我们为每个标记分配一个唯一的整数,创建文本的数值表示,即整数序列。

为了准备训练数据,我们将这个长序列划分为长度相等的更短序列。在我们的项目中,我们将使用由 100 个整数组成的序列。这些序列构成了我们模型的特征(即x变量)。然后,我们通过将输入序列向右移动一个标记来生成输出y。这种设置使得 LSTM 模型能够根据序列中的先前标记预测下一个标记。输入和输出的配对作为训练数据。我们的模型包括 LSTM 层来理解文本中的长期模式,以及一个嵌入层来把握语义含义。

让我们回顾一下之前提到的预测句子“a frog has four legs”的例子。图 8.2 展示了 LSTM 模型训练的工作原理。

图片

图 8.2 展示了如何训练 LSTM 模型的一个示例。我们首先将训练文本分解为标记,并为每个标记分配一个唯一的整数,从而将文本作为索引序列的数值表示。然后,我们将这个长序列划分为长度相等的更短序列。这些序列构成了我们模型的特征(即 x 变量)。然后,我们通过将输入序列向右移动一个标记来生成输出 y。这种设置使得 LSTM 模型能够根据序列中的先前标记预测下一个标记。

在第一个时间步,模型使用单词“a”来预测单词“frog”。由于“a”没有前面的单词,我们用零初始化隐藏状态。LSTM 模型接收“a”的索引和这个初始隐藏状态作为输入,并输出预测的下一个单词以及更新的隐藏状态 h0。在随后的时间步,使用单词“frog”和更新的状态 h0 来预测“has”并生成新的隐藏状态 h1。预测下一个单词和更新隐藏状态的这一序列持续进行,直到模型预测出句子中的最后一个单词,“legs”。

然后,将预测结果与句子中的实际下一个单词进行比较。由于模型实际上是在预测词汇表中所有可能的标记中的下一个标记,因此存在一个多类别分类问题。我们在每次迭代中调整模型参数,以最小化交叉熵损失,从而使模型在下一个迭代中的预测结果更接近训练数据中的实际输出。

模型训练完成后,生成文本的过程从将种子序列输入模型开始。模型预测下一个标记,然后将该标记附加到序列中。这种预测和序列更新的迭代过程重复进行,直到生成所需的文本长度。

8.2 自然语言处理基础

深度学习模型,包括我们之前讨论的 LSTM 模型以及你将在后续章节中学习的 Transformer,不能直接处理原始文本,因为它们被设计成与数值数据一起工作,通常是向量或矩阵的形式。神经网络的处理和学习能力基于数学运算,如加法、乘法和激活函数,这些运算需要数值输入。因此,首先将文本分解成更小、更易于管理的元素,即标记,是至关重要的。这些标记可以是从单个字符和单词到子词单元。

NLP 任务中的下一个关键步骤是将这些标记转换为数值表示。这种转换对于将它们输入深度神经网络是必要的,这是训练我们模型的基本部分。

在本节中,我们将讨论不同的分词方法,以及它们的优缺点。此外,你还将深入了解将标记转换为密集向量表示的过程——这种方法称为词嵌入。这项技术对于捕捉语言的意义,使其以深度学习模型能够有效利用的格式至关重要。

8.2.1 不同的分词方法

分词涉及将文本划分为更小的部分,称为标记(tokens),这些标记可以是单词、字符、符号或其他有意义的单元。分词的主要目标是简化文本数据分析和处理的过程。

从广义上讲,分词有三种方法。第一种是字符分词,其中文本被划分为其构成字符。这种方法用于具有复杂形态结构的语言,如土耳其语或芬兰语,在这些语言中,单词的意义可能会因字符的微小变化而显著改变。以英语短语“它好得令人难以置信!”为例;它被分解为以下单个字符:['I', 't', ' ', 'i', 's', ' ', 'u', 'n', 'b', 'e', 'l', 'i', 'e', 'v', 'a', 'b', 'l', 'y', ' ', 'g', 'o', 'o', 'd', '!']。字符分词的一个关键优势是唯一标记的数量有限。这种限制显著减少了深度学习模型中的参数数量,从而实现了更快、更有效的训练。然而,主要的缺点是单个字符通常缺乏显著的意义,这使得机器学习模型难以从字符序列中提取有意义的见解。

练习 8.1

使用字符分词将短语“Hi, there!”划分为单个标记。

第二种方法是词元化,其中文本被分割成单个单词和标点符号。它常用于唯一单词数量不是太多的情况下。例如,同样的短语“它好得令人难以置信!”变成了五个标记:['It', 'is', 'unbelievably', 'good', '!']。这种方法的主要优点是每个单词本身携带语义意义,这使得模型更容易解释文本。然而,缺点是独特标记的数量大幅增加,这增加了深度学习模型中的参数数量。这种增加可能导致训练过程变慢和效率降低。

练习 8.2

使用词元化将短语“Hi, how are you?”分解成单个标记。

第三种方法是子词元化。这种方法是 NLP 中的一个关键概念,将文本分解成更小、更有意义的组件,称为子词。例如,短语“它好得令人难以置信!”将被分解成如['It', 'is', 'un', 'believ', 'ably', 'good', '!']这样的标记。大多数高级语言模型,包括 ChatGPT,都使用子词元化,你将在接下来的几章中使用这种方法。子词元化在更传统的词元化技术之间取得了平衡,这些技术通常将文本分割成单个单词或字符。基于单词的词元化虽然能捕捉更多意义,但会导致词汇量巨大。相反,基于字符的词元化会导致词汇量较小,但每个标记的语义价值较低。

子词元化通过在词汇表中保留常用单词的完整性,同时将不太常见或更复杂的单词分解成子组件,有效地缓解了这些问题。这种技术在词汇量大的语言或表现出高度词形变化的语言中特别有利。通过采用子词元化,整体词汇量大幅减少。这种减少提高了语言处理任务的效率和有效性,尤其是在处理广泛的语结构时。

在本章中,我们将重点关注词元化,因为它为初学者提供了一个直接的起点。随着我们进入后面的章节,我们的注意力将转向子词元化,使用已经通过这种技术训练过的模型。这种方法使我们能够专注于更高级的主题,例如理解 Transformer 架构和探索注意力机制的内部工作原理。

8.2.2 词嵌入

词嵌入是一种将标记转换为紧凑的向量表示的方法,捕捉它们的语义信息和相互关系。这项技术在 NLP 中至关重要,尤其是在深度神经网络,包括 LSTM 和 Transformer 等模型,需要数值输入的情况下。

Traditionally, tokens are converted into numbers using one-hot encoding before being fed into NLP models. In one-hot encoding, each token is represented by a vector where only one element is ‘1’, and the rest are ‘0’s. For example, in this chapter, there are 12,778 unique word-based tokens in the text for the novel Anna Karenina. Each token is represented by a vector of 12,778 dimensions. Consequently, a phrase like “happy families are all alike” is represented as a 5 × 12,778 matrix, where 5 represents the number of tokens. This representation, however, is highly inefficient due to its large dimensionality, leading to an increased number of parameters, which can hinder training speed and efficiency.

LSTMs, Transformers, and other advanced NLP models address this inefficiency through word embedding. Instead of bulky one-hot vectors, word embedding uses continuous, lower-dimensional vectors (e.g., 128-value vectors we use in this chapter). As a result, the phrase “happy families are all alike” is represented by a more compact 5 × 128 matrix after word embedding. This streamlined representation drastically reduces the model’s complexity and enhances training efficiency.

Word embedding not only reduces word complexity by condensing it into a lower-dimensional space but also effectively captures the context and the nuanced semantic relationships between words, a feature that simpler representations like one-hot encoding lack, for the following reasons. In one-hot encoding, all tokens have the same distance from each other in vector space. However, in word embeddings, tokens with similar meanings are represented by vectors close to each other in the embedding space. Word embeddings are learned from the text in the training data; the resulting vectors capture contextual information. Tokens that appear in similar contexts will have similar embeddings, even if they are not explicitly related.

Word embedding in NLP

Word embeddings are a powerful method for representing tokens in NLP that offer significant advantages over traditional one-hot encoding in capturing context and semantic relationships between words.

One-hot encoding represents tokens as sparse vectors with a dimension equal to the size of the vocabulary, where each token is represented by a vector with all zeros except for a single one at the index corresponding to the token. In contrast, word embeddings represent tokens as dense vectors with much lower dimensions (e.g., 128 dimensions in this chapter and 256 dimensions in chapter 12). This dense representation is more efficient and can capture more information.

具体来说,在一维编码中,所有标记在向量空间中彼此距离相同,这意味着标记之间没有相似性的概念。然而,在词嵌入中,相似的标记由在嵌入空间中彼此靠近的向量表示。例如,“king”(国王)和“queen”(王后)会有相似的嵌入,反映了它们语义上的关系。

词嵌入是从训练数据中的文本学习得到的。嵌入过程使用标记出现的上下文来学习它们的嵌入,这意味着生成的向量捕捉了上下文信息。出现在相似上下文中的标记将具有相似的嵌入,即使它们没有明确的相关性。

总体而言,词嵌入提供了对词语更细腻和高效的表示,它捕捉了语义关系和上下文信息,这使得它们比一维编码更适合自然语言处理任务。

在实际应用中,尤其是在 PyTorch 等框架中,词嵌入是通过通过一个线性层传递索引来实现的,它将这些索引压缩到一个低维空间。也就是说,当你向 nn.Embedding() 层传递一个索引时,它会查找嵌入矩阵中对应的行,并返回该索引的嵌入向量,从而避免了创建可能非常大的一个热向量。这个嵌入层的权重不是预先定义的,而是在训练过程中学习的。这一学习特性使得模型能够根据训练数据细化其对词义的理解,从而在神经网络中实现更细腻和上下文感知的语言表示。这种方法显著提高了模型处理和解释语言数据的有效性和意义。

8.3 准备数据以训练 LSTM 模型

在本节中,我们将处理文本数据并为其训练做好准备。我们首先将文本分解成单个标记。接下来的步骤是创建一个字典,将每个标记分配一个索引,本质上是将它们映射到整数。完成这些设置后,我们将这些标记组织成训练数据的批次,这对于在下一节训练 LSTM 模型至关重要。

我们将以详细、分步骤的方式介绍标记化过程,确保你彻底理解标记化是如何工作的。我们将使用词标记化,因为它在将文本分割成单词方面简单,而不是更复杂的子词标记化,后者需要细微地掌握语言结构。在后面的章节中,我们将使用更复杂的方法来使用预训练的子词标记化器。这将使我们能够专注于高级主题,如注意力机制和 Transformer 架构,而不会在文本处理的初始阶段陷入困境。

8.3.1 下载和清理文本

我们将使用小说《安娜·卡列尼娜》的文本来训练我们的模型。请访问mng.bz/znmX下载文本文件,并将其保存为电脑上文件夹/files/中的 anna.txt。之后,打开文件并删除第 39888 行之后的所有内容,该行内容为"END OF THIS PROJECT GUTENBERG EBOOK ANNA KARENINA."。或者,您可以直接从书籍的 GitHub 仓库下载 anna.txt 文件:github.com/markhliu/DGAI

首先,我们加载数据并打印出一些段落,以了解数据集:

with open("files/anna.txt","r") as f:
    text=f.read()    
words=text.split(" ")    
print(words[:20]) 

输出结果为

['Chapter', '1\n\n\nHappy', 'families', 'are', 'all', 'alike;', 'every',
 'unhappy', 'family', 'is', 'unhappy', 'in', 'its',
'own\nway.\n\nEverything', 'was', 'in', 'confusion', 'in', 'the',
"Oblonskys'"]

如您所见,行断(用\n 表示)被视为文本的一部分。因此,我们应该将这些行断替换为空格,这样它们就不会出现在词汇表中。此外,将所有单词转换为小写在我们的设置中很有帮助,因为它确保像“The”和“the”这样的单词被视为相同的标记。这一步对于减少独特标记的多样性至关重要,从而使得训练过程更加高效。此外,标点符号应与它们后面的单词保持一定的距离。如果没有这种分隔,像“way.”和“way”这样的组合会被错误地视为不同的标记。为了解决这些问题,我们将清理文本:

clean_text=text.lower().replace("\n", " ")               ①
clean_text=clean_text.replace("-", " ")                  ②
for x in ",.:;?!$()/_&%*@'`":
    clean_text=clean_text.replace(f"{x}", f" {x} ")
clean_text=clean_text.replace('"', ' " ')                ③
text=clean_text.split()

① 替换行断为空格

② 替换连字符为空格

③ 在标点符号和特殊字符周围添加空格

接下来,我们获取独特标记:

from collections import Counter   
word_counts = Counter(text)    
words=sorted(word_counts, key=word_counts.get,
                      reverse=True)
print(words[:10])

列表words包含文本中所有的独特标记,最频繁出现的标记排在第一位,最不频繁的标记排在最后。前一个代码块输出的结果是

[',', '.', 'the', '"', 'and', 'to', 'of', 'he', "'", 'a']

前面的输出显示了最频繁的 10 个标记。逗号(,)是最频繁的标记,句号(.)是第二频繁的标记。单词“the”是第三频繁的标记,以此类推。

现在,我们创建两个字典:一个将标记映射到索引,另一个将索引映射到标记。

列表 8.1 将标记映射到索引和索引映射到标记的字典

text_length=len(text)                                      ①
num_unique_words=len(words)                                ②
print(f"the text contains {text_length} words")
print(f"there are {num_unique_words} unique tokens")
word_to_int={v:k for k,v in enumerate(words)}              ③
int_to_word={k:v for k,v in enumerate(words)}              ④
print({k:v for k,v in word_to_int.items() if k in words[:10]})
print({k:v for k,v in int_to_word.items() if v in words[:10]})

① 文本长度(文本中有多少个标记)

② 独特标记的长度

③ 将标记映射到索引

④ 将索引映射到标记

前一个代码块输出的结果是

the text contains 437098 words
there are 12778 unique tokens
{',': 0, '.': 1, 'the': 2, '"': 3, 'and': 4, 'to': 5, 'of': 6, 'he': 7,
"'": 8, 'a': 9}
{0: ',', 1: '.', 2: 'the', 3: '"', 4: 'and', 5: 'to', 6: 'of', 7: 'he',
 8: "'", 9: 'a'}

小说《安娜·卡列尼娜》的文本总共有 437,098 个标记。其中,有 12,778 个独特的标记。字典word_to_int为每个独特的标记分配一个索引。例如,逗号(,)被分配了索引 0,而句号(.)被分配了索引 1。字典int_to_word将索引转换回标记。例如,索引 2 转换回标记“the”。索引 4 转换回标记“and”,以此类推。

最后,我们将整个文本转换为索引:

print(text[0:20])
wordidx=[word_to_int[w] for w in text]  
print([word_to_int[w] for w in text[0:20]])  

输出结果为

['chapter', '1', 'happy', 'families', 'are', 'all', 'alike', ';', 'every',
 'unhappy', 'family', 'is', 'unhappy', 'in', 'its', 'own', 'way', '.',
 'everything', 'was']
[208, 670, 283, 3024, 82, 31, 2461, 35, 202, 690, 365, 38, 690, 10, 234,
 147, 166, 1, 149, 12]

我们将文本中的所有标记转换为相应的索引,并将它们保存在列表wordidx中。前一个输出显示了文本中的前 20 个标记以及相应的索引。例如,文本中的第一个标记是chapter,其索引值为 208。

练习 8.3

查找字典word_to_int中标记anna的索引值。

8.3.2 创建训练数据批次

接下来,我们创建用于训练的(x, y)对。每个 x 是一个包含 100 个索引的序列。100 这个数字并没有什么神奇之处,你可以轻松地将其更改为 90 或 110,并得到相似的结果。设置数字太大可能会减慢训练速度,而设置数字太小可能会导致模型无法捕捉到长距离依赖关系。然后我们将窗口向右滑动一个标记,并将其用作目标 y。在序列生成期间,将序列向右移动一个标记并用作输出是训练语言模型(包括 Transformers)中的常见技术。以下列表中的代码块创建了训练数据。

列表 8.2 创建训练数据

import torch
seq_len=100                                             ①
xys=[]
for n in range(0, len(wordidx)-seq_len-1):              ②
    x = wordidx[n:n+seq_len]                            ③
    y = wordidx[n+1:n+seq_len+1]                        ④
    xys.append((torch.tensor(x),(torch.tensor(y))))

① 每个输入包含 100 个索引。

② 从文本的第一个标记开始,每次向右滑动一个标记

③ 定义输入 x

④ 将输入 x 向右移动一个标记,并将其用作输出 y

通过将序列向右移动一个标记并将其用作输出,模型被训练来根据前面的标记预测下一个标记。例如,如果输入序列是 "how are you",则移动后的序列将是 "are you today"。在训练过程中,模型学会在看到 'how' 后预测 'are',在看到 'are' 后预测 'you',依此类推。这有助于模型学习序列中下一个标记的概率分布。你将在本书后面的内容中再次看到这种做法。

我们将为训练创建数据批次,每个批次包含 32 对(x, y)。

from torch.utils.data import DataLoader

torch.manual_seed(42)
batch_size=32
loader = DataLoader(xys, batch_size=batch_size, shuffle=True)

我们现在有了训练数据集。接下来,我们将创建一个 LSTM 模型,并使用我们刚刚处理的数据来训练它。

8.4 构建和训练 LSTM 模型

在本节中,你将开始使用 PyTorch 的内置 LSTM 层构建一个 LSTM 模型。这个模型将从词嵌入层开始,将每个索引转换为一个 128 维的密集向量。你的训练数据将通过这个嵌入层,然后输入到 LSTM 层。这个 LSTM 层被设计成按顺序处理序列的元素。在 LSTM 层之后,数据将进入一个线性层,其输出大小与你的词汇表大小相匹配。LSTM 模型生成的输出本质上是 logits,作为 softmax 函数的输入来计算概率。

一旦你构建了 LSTM 模型,下一步将涉及使用你的训练数据来训练这个模型。这个训练阶段对于提高模型理解和生成与提供的数据一致的模式的能力至关重要。

8.4.1 构建 LSTM 模型

在列表 8.3 中,我们定义了一个WordLSTM()类,作为我们的 LSTM 模型,用于训练以生成《安娜·卡列尼娜》风格的文本。该类定义如下所示。

列表 8.3 定义WordLSTM()

from torch import nn
device="cuda" if torch.cuda.is_available() else "cpu"
class WordLSTM(nn.Module):
    def __init__(self, input_size=128, n_embed=128,
             n_layers=3, drop_prob=0.2):
        super().__init__()
        self.input_size=input_size
        self.drop_prob = drop_prob
        self.n_layers = n_layers
        self.n_embed = n_embed
        vocab_size=len(word_to_int)
        self.embedding=nn.Embedding(vocab_size,n_embed)    ①
        self.lstm = nn.LSTM(input_size=self.input_size,
            hidden_size=self.n_embed,
            num_layers=self.n_layers,
            dropout=self.drop_prob,batch_first=True)       ②
        self.fc = nn.Linear(input_size, vocab_size)    

    def forward(self, x, hc):
        embed=self.embedding(x)
        x, hc = self.lstm(embed, hc)                       ③
        x = self.fc(x)
        return x, hc      

    def init_hidden(self, n_seqs):                         ④
        weight = next(self.parameters()).data
        return (weight.new(self.n_layers,
                           n_seqs, self.n_embed).zero_(),
                weight.new(self.n_layers,
                           n_seqs, self.n_embed).zero_())  

① 训练数据首先通过嵌入层。

② 使用 PyTorch LSTM() 类创建一个 LSTM 层

③ 在每个时间步中,LSTM 层使用前一个标记和隐藏状态来预测下一个标记和下一个隐藏状态。

④ 初始化输入序列中第一个标记的隐藏状态

之前定义的 WordLSTM() 类有三个层:词嵌入层、LSTM 层和最终的线性层。我们将 n_layers 参数的值设置为 3,这意味着 LSTM 层将三个 LSTM 堆叠在一起形成一个堆叠的 LSTM,最后两个 LSTM 将前一个 LSTM 的输出作为输入。init_hidden() 方法在模型使用序列的第一个元素进行预测时将隐藏状态填充为零。在每个时间步中,输入是当前标记和前一个隐藏状态,输出是下一个标记和下一个隐藏状态。

torch.nn.Embedding() 类的工作原理

PyTorch 中的 torch.nn.Embedding() 类用于在神经网络中创建嵌入层。嵌入层是一个可训练的查找表,它将整数索引映射到密集的、连续的向量表示(嵌入)。

当你创建 torch.nn.Embedding() 的实例时,你需要指定两个主要参数:num_embeddings,词汇表的大小(唯一标记的总数),以及 embedding_dim,每个嵌入向量的大小(输出嵌入的维度性)。

在内部,该类创建一个形状为 (num_embeddings, embedding_dim) 的矩阵(或查找表),其中每一行对应于特定索引的嵌入向量。最初,这些嵌入是随机初始化的,但在训练过程中通过反向传播进行学习和更新。

当你将索引张量传递给嵌入层(在网络的前向传递过程中)时,它会在查找表中查找相应的嵌入向量并返回它们。关于该类的更多信息可在 PyTorch mng.bz/n0Zd 获取。

我们创建 WordLSTM() 类的实例,并将其用作我们的 LSTM 模型,如下所示:

model=WordLSTM().to(device)

当创建 LSTM 模型时,权重是随机初始化的。当我们使用 (x, y) 对来训练模型时,LSTM 通过调整模型参数学习根据序列中的所有先前标记预测下一个标记。正如我们在图 8.2 中所展示的,LSTM 学习根据当前标记和当前隐藏状态预测下一个标记和下一个隐藏状态,这是所有先前标记信息的总结。

我们使用学习率为 0.0001 的 Adam 优化器。损失函数是交叉熵损失,因为这是一个多类别分类问题:模型试图从包含 12,778 个选择的字典中预测下一个标记:

lr=0.0001
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
loss_func = nn.CrossEntropyLoss()

现在 LSTM 模型已经构建,我们将使用之前准备好的训练数据批次来训练模型。

8.4.2 训练 LSTM 模型

在每个训练周期中,我们遍历训练集中的所有数据批次(x, y)。LSTM 模型接收输入序列 x,并生成一个预测输出序列 ŷ。这个预测与实际输出序列 y 进行比较,以计算交叉熵损失,因为我们实际上在这里进行多类别分类。然后我们调整模型的参数以减少这个损失,就像我们在第二章中分类服装物品时做的那样。

虽然我们可以将我们的数据分为训练集和验证集,直到在验证集上不再看到进一步的改进(就像我们在第二章中所做的那样)来训练模型,但我们的主要目标是掌握 LSTM 模型的工作原理,而不一定是实现最佳参数调整。因此,我们将模型训练 50 个周期。

列表 8.4 训练 LSTM 模型以生成文本

model.train()

for epoch in range(50):
    tloss=0
    sh,sc = model.init_hidden(batch_size)
    for i, (x,y) in enumerate(loader):                      ①
        if x.shape[0]==batch_size:
            inputs, targets = x.to(device), y.to(device)
            optimizer.zero_grad()
            output, (sh,sc) = model(inputs, (sh,sc))        ②
            loss = loss_func(output.transpose(1,2),targets) ③ 
            sh,sc=sh.detach(),sc.detach()
            loss.backward()
            nn.utils.clip_grad_norm_(model.parameters(), 5)
            optimizer.step()                                ④
            tloss+=loss.item()
        if (i+1)%1000==0:
            print(f"at epoch {epoch} iteration {i+1}\
            average loss = {tloss/(i+1)}")

① 遍历训练数据中的所有(x,y)批次

② 使用模型来预测输出序列

③ 将预测结果与实际输出进行比较,并计算损失

④ 调整模型参数以最小化损失

在前面的代码列表中,shsc 一起构成了隐藏状态。特别是,细胞状态 sc 作为传送带,在许多时间步中携带信息,每个时间步都会添加或删除信息。sh 是 LSTM 单元在给定时间步的输出。它包含有关当前输入的信息,并用于将信息传递给序列中的下一个 LSTM 单元。

如果您有 CUDA 支持的 GPU,这次训练大约需要 6 个小时。如果您只使用 CPU,可能需要一两天,具体取决于您的硬件。或者您可以从我的网站下载预训练的权重:mng.bz/vJZa

接下来,我们将训练好的模型权重保存在本地文件夹中:

import pickle

torch.save(model.state_dict(),"files/wordLSTM.pth")
with open("files/word_to_int.p","wb") as fb:
    pickle.dump(word_to_int, fb)

字典 word_to_int 也保存在您的计算机上,这是一个实用的步骤,确保您可以在不重复分词过程的情况下,使用训练好的模型生成文本。

8.5 使用训练好的 LSTM 模型生成文本

现在您已经有一个训练好的 LSTM 模型,您将在这个部分学习如何使用它来生成文本。目标是查看训练好的模型是否可以通过迭代预测下一个标记来生成语法正确且连贯的文本。您还将学习如何使用温度和 top-K 采样来控制生成文本的创造性。

在使用训练好的 LSTM 模型生成文本时,我们以提示作为模型的初始输入。我们使用训练好的模型来预测最可能的下一个标记。将下一个标记附加到提示后,我们将新的序列输入到训练好的模型中,再次预测下一个标记。我们重复这个过程,直到序列达到一定的长度。

8.5.1 通过预测下一个标记来生成文本

首先,我们从本地文件夹中加载训练好的模型权重和字典 word_to_int

model.load_state_dict(torch.load("files/wordLSTM.pth",
                                    map_location=device))
with open("files/word_to_int.p","rb") as fb:
    word_to_int = pickle.load(fb)
int_to_word={v:k for k,v in word_to_int.items()}

书籍的 GitHub 仓库中也提供了word_to_int.p文件。我们将字典word_to_int中的键和值的顺序进行交换,以创建字典int_to_word

要使用训练好的 LSTM 模型生成文本,我们需要一个提示作为生成文本的起点。我们将默认提示设置为“Anna and the”。一个简单的方法是限制生成的文本长度,例如 200 个标记:一旦达到所需长度,我们就要求模型停止生成。

下面的列表定义了一个sample()函数,用于根据提示生成文本。

列表 8.5:用于生成文本的sample()函数

import numpy as np
def sample(model, prompt, length=200):
    model.eval()
    text = prompt.lower().split(' ')
    hc = model.init_hidden(1)
    length = length - len(text)                              ①
    for i in range(0, length):
        if len(text)<= seq_len:
            x = torch.tensor([[word_to_int[w] for w in text]])
        else:
            x = torch.tensor([[word_to_int[w] for w \
in text[-seq_len:]]])                                        ②
        inputs = x.to(device)
        output, hc = model(inputs, hc)                       ③
        logits = output[0][-1]
        p = nn.functional.softmax(logits, dim=0).detach().cpu().numpy()
        idx = np.random.choice(len(logits), p=p)             ④
        text.append(int_to_word[idx])                        ⑤
    text=" ".join(text)
    for m in ",.:;?!$()/_&%*@'`":
        text=text.replace(f" {m}", f"{m} ")
    text=text.replace('"  ', '"')
    text=text.replace("'  ", "'")
    text=text.replace('" ', '"')
    text=text.replace("' ", "'")
    return text  

① 确定需要生成多少个标记

② 输入是当前序列;如果它超过 100 个标记,则对其进行修剪

③ 使用训练模型进行预测

④ 根据预测概率选择下一个标记

⑤ 将预测的下一个标记添加到序列中并重复

sample()函数接受三个参数。第一个是您将要使用的训练好的 LSTM 模型。第二个是文本生成的起始提示,可以是任何长度的短语,用引号括起来。第三个参数指定要生成的文本长度,以标记为单位,默认值为 200 个标记。

在函数内部,我们首先从总期望长度中减去提示中的标记数量,以确定需要生成的标记数量。在生成下一个标记时,我们考虑当前序列的长度。如果它少于 100 个标记,我们将整个序列输入到模型中;如果它超过 100 个标记,则只使用序列的最后 100 个标记作为输入。然后将这个输入送入训练好的 LSTM 模型来预测下一个标记,我们将这个预测的标记添加到当前序列中。我们继续这个过程,直到序列达到所需长度。

当生成下一个标记时,模型使用 NumPy 中的 random.choice(len(logits), p = p)方法。在这里,该方法的第一参数表示选择范围,在这个例子中是 len(logits) = 12778。这意味着模型将随机选择一个从 0 到 12,777 的整数,每个整数对应词汇表中的一个不同标记。第二个参数 p 是一个包含 12,778 个元素的数组,其中每个元素表示从词汇表中选择相应标记的概率。在这个数组中,概率较高的标记更有可能被选中。

让我们使用“Anna and the prince”作为提示(确保在使用自己的提示时在标点符号前加上空格)来生成一段文本:

torch.manual_seed(42)
np.random.seed(42)
print(sample(model, prompt='Anna and the prince'))  

在这里,我将 PyTorch 和 NumPy 中的随机种子数固定为 42,以防你想要重现结果。生成的段落是

anna and the prince did not forget what he had not spoken. when the softening barrier was not so long as he had talked to his brother,  all the hopelessness of the impression. "official tail,  a man who had tried him,  though he had been able to get across his charge and locked close,  and the light round the snow was in the light of the altar villa. the article in law levin was first more precious than it was to him so that if it was most easy as it would be as the same. this was now perfectly interested. when he had got up close out into the sledge,  but it was locked in the light window with their one grass,  and in the band of the leaves of his projects,  and all the same stupid woman,  and really,  and i swung his arms round that thinking of bed. a little box with the two boys were with the point of a gleam of filling the boy,  noiselessly signed the bottom of his mouth,  and answering them took the red

你可能已经注意到生成的文本完全是小写的。这是因为,在文本处理阶段,我们将所有大写字母转换为小写,以最小化唯一标记的数量。

经过 6 小时的训练生成的文本相当令人印象深刻!大多数句子都遵循语法规范。虽然它可能无法与 ChatGPT 等高级系统生成的文本的复杂程度相媲美,但这仍然是一个重大的成就。通过在这个练习中获得的能力,你将准备好在后面的章节中训练更高级的文本生成模型。

8.5.2 文本生成中的温度和 top-K 采样

可以通过使用温度和 top-K 采样等技术来控制生成文本的创造力。

温度调整在选择下一个标记之前分配给每个潜在标记的概率分布。它通过温度的值有效地缩放 logits,即计算这些概率的 softmax 函数的输入。logits 是应用 softmax 函数之前的 LSTM 模型的输出。

在我们刚刚定义的sample()函数中,我们没有调整 logits,这意味着默认温度为 1。较低的温度(低于 1;例如,0.8)会导致更少的变体,使模型更加确定性和保守,更倾向于更可能的选择。相反,较高的温度(高于 1;例如,1.5)使得在文本生成中选择不可能的单词的可能性更大,导致更加多样化和有创造性的输出。然而,这也可能使文本变得不那么连贯或相关,因为模型可能会选择不太可能的单词。

Top-K 采样是另一种影响输出的方法。这种方法涉及从模型预测的前 K 个最可能选项中选择下一个单词。概率分布被截断,只包括前 K 个单词。当 K 值较小时,例如 5,模型的选项仅限于几个高度可能的单词,导致输出更加可预测和连贯,但可能不那么多样化和有趣。在我们之前定义的sample()函数中,我们没有应用 top-K 采样,因此 K 的值实际上是词汇表的大小(在我们的例子中为 12,778)。

接下来,我们介绍一个新的函数generate(),用于文本生成。这个函数与sample()函数类似,但包括两个额外的参数:temperaturetop_k,允许对生成文本的创造性和随机性有更多的控制。generate()函数的定义如下所示。

列表 8.6 使用温度和 top-K 采样生成文本

def generate(model, prompt , top_k=None, 
             length=200, temperature=1):
    model.eval()
    text = prompt.lower().split(' ')
    hc = model.init_hidden(1)
    length = length - len(text)    
    for i in range(0, length):
        if len(text)<= seq_len:
            x = torch.tensor([[word_to_int[w] for w in text]])
        else:
            x = torch.tensor([[word_to_int[w] for w in text[-seq_len:]]])
        inputs = x.to(device)
        output, hc = model(inputs, hc)
        logits = output[0][-1]
        logits = logits/temperature                             ①
        p = nn.functional.softmax(logits, dim=0).detach().cpu()    
        if top_k is None:
            idx = np.random.choice(len(logits), p=p.numpy())
        else:
            ps, tops = p.topk(top_k)                            ②
            ps=ps/ps.sum()
            idx = np.random.choice(tops, p=ps.numpy())          ③
        text.append(int_to_word[idx])

    text=" ".join(text)
    for m in ",.:;?!$()/_&%*@'`":
        text=text.replace(f" {m}", f"{m} ")
    text=text.replace('"  ', '"')   
    text=text.replace("'  ", "'")  
    text=text.replace('" ', '"')   
    text=text.replace("' ", "'")     
    return text  

① 使用温度缩放 logits

② 仅保留 K 个最可能的候选词

③ 从前 K 个候选词中选择下一个标记

sample()函数相比,新的函数generate()有两个额外的可选参数:top_ktemperature。默认情况下,top_k设置为None,而temperature设置为 1。因此,如果你在调用generate()函数时没有指定这两个参数,输出将与从sample()函数获得的输出相同。

让我们通过关注单个标记的创建来展示生成文本的变化。为此,我们将使用“我不打算去看”作为提示(注意撇号前的空格,就像我们在本章中之前所做的那样)。我们调用generate()函数 10 次,将其长度参数设置为比提示长度多一个。这种方法确保函数只向提示添加一个额外的标记:

prompt="I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
    print(generate(model, prompt, top_k=None, 
         length=len(prompt.split(" "))+1, temperature=1))

输出结果为

i'm not going to see you
i'm not going to see those
i'm not going to see me
i'm not going to see you
i'm not going to see her
i'm not going to see her
i'm not going to see the
i'm not going to see my
i'm not going to see you
i'm not going to see me

在默认的top_k = Nonetemperature = 1设置下,输出中存在一定程度的重复。例如,“you”这个词重复了三次。总共有六个独特的标记。

然而,当你调整这两个参数时,generate()函数的功能会扩展。例如,设置一个低的温度,如 0.5,以及一个小的top_k值,例如 3,会导致生成的文本更可预测且更缺乏创意。

让我们重复单个标记的示例。这次,我们将温度设置为 0.5,top_k值设置为 3:

prompt="I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
    print(generate(model, prompt, top_k=3, 
         length=len(prompt.split(" "))+1, temperature=0.5))

输出结果为

i'm not going to see you
i'm not going to see the
i'm not going to see her
i'm not going to see you
i'm not going to see you
i'm not going to see you
i'm not going to see you
i'm not going to see her
i'm not going to see you
i'm not going to see her

输出的变化较少:在 10 次尝试中,只有 3 个独特的标记,“you”、“the”和“her”。

让我们通过将“安娜和王子”作为起始提示,在设置温度为 0.5 和top_k值为 3 时来观察这一效果:

torch.manual_seed(42)
np.random.seed(42)
print(generate(model, prompt='Anna and the prince',
               top_k=3,
               temperature=0.5))

输出结果为

anna and the prince had no milk. but,  "answered levin,  and he stopped. "i've been skating to look at you all the harrows,  and i'm glad. . .  ""no,  i'm going to the country. ""no,  it's not a nice fellow. ""yes,  sir. ""well,  what do you think about it? ""why,  what's the matter? ""yes,  yes,  "answered levin,  smiling,  and he went into the hall. "yes,  i'll come for him and go away,  "he said,  looking at the crumpled front of his shirt. "i have not come to see him,  "she said,  and she went out. "i'm very glad,  "she said,  with a slight bow to the ambassador's hand. "i'll go to the door. "she looked at her watch,  and she did not know what to say 

练习 8.4

通过设置温度为 0.6 和top_k为 10,并使用“安娜和护士”作为起始提示来生成文本。在 PyTorch 和 NumPy 中都设置随机种子数为 0。

相反,选择一个更高的温度值,例如 1.5,以及一个更高的top_k值,例如None(允许从 12,778 个标记的全集中进行选择),会导致更富有创造性和更不可预测的输出。这将在下面的单个标记示例中演示。这次,我们将温度设置为 2,top_k值设置为None

prompt="I ' m not going to see"
torch.manual_seed(42)
np.random.seed(42)
for _ in range(10):
    print(generate(model, prompt, top_k=None, 
         length=len(prompt.split(" "))+1, temperature=2))

输出结果为

i'm not going to see them
i'm not going to see scarlatina
i'm not going to see behind
i'm not going to see us
i'm not going to see it
i'm not going to see it
i'm not going to see a
i'm not going to see misery
i'm not going to see another
i'm not going to see seryozha

输出几乎没有重复:在 10 次尝试中有 9 个独特的标记;只有“it”这个词重复了。

让我们再次使用“安娜和王子”作为初始提示,但将温度设置为 2,top_k值设置为None,看看会发生什么:

torch.manual_seed(42)
np.random.seed(42)
print(generate(model, prompt='Anna and the prince',
               top_k=None,
               temperature=2))

生成的文本是

anna and the prince took sheaves covered suddenly people. "pyotr marya borissovna,  propped mihail though her son will seen how much evening her husband;  if tomorrow she liked great time too. "adopted heavens details for it women from this terrible,  admitting this touching all everything ill with flirtation shame consolation altogether:  ivan only all the circle with her honorable carriage in its house dress,  beethoven ashamed had the conversations raised mihailov stay of close i taste work? "on new farming show ivan nothing. hat yesterday if interested understand every hundred of two with six thousand roubles according to women living over a thousand:  snetkov possibly try disagreeable schools with stake old glory mysterious one have people some moral conclusion,  got down and then their wreath. darya alexandrovna thought inwardly peaceful with varenka out of the listen from and understand presented she was impossible anguish. simply satisfied with staying after presence came where he pushed up his hand as marya her pretty hands into their quarters. waltz was about the rider gathered;  sviazhsky further alone have an hand paused riding towards an exquisite

生成的文本没有重复,尽管在许多地方缺乏连贯性。

练习 8.5

通过设置温度为 2 和top_k为 10000,并使用“安娜和护士”作为起始提示来生成文本。在 PyTorch 和 NumPy 中都设置随机种子数为 0。

在本章中,你已经掌握了 NLP 的基础技能,包括词级分词、词嵌入和序列预测。通过这些练习,你学会了基于词级分词构建语言模型,并使用 LSTM 进行文本生成训练。接下来,接下来的几章将向你介绍训练 Transformers,这是 ChatGPT 等系统中使用的模型类型。这将为你提供更深入的高级文本生成技术理解。

摘要

  • RNNs(循环神经网络)是一种专门的人工神经网络形式,旨在识别数据序列中的模式,如文本、音乐或股价。与传统神经网络不同,后者独立处理输入,RNNs 在其内部有循环,允许信息持续存在。LSTM 网络是 RNNs 的改进版本。

  • 有三种分词方法。第一种是字符分词,将文本分割成其构成字符。第二种方法是词分词,将文本分割成单个单词。第三种方法是子词分词,它将单词分解成更小的、有意义的组件,称为子词。

  • 词嵌入是一种将单词转换成紧凑向量表示的方法,捕捉其语义信息和相互关系。这项技术在 NLP 中至关重要,尤其是在深度神经网络,包括 LSTM 和 Transformers 等模型,需要数值输入的情况下。

  • 温度是影响文本生成模型行为的一个参数。它通过在应用 softmax 之前缩放 logits(概率计算的 softmax 函数的输入)来控制预测的随机性。低温使模型在预测上更加保守但也更加重复。在较高温度下,模型变得不那么重复,更具创新性,增加了生成文本的多样性。

  • Top-K 采样是另一种影响文本生成模型行为的方法。它涉及从模型确定的 K 个最可能的候选词中选择下一个词。概率分布被截断,只保留前 K 个词。K 的值较小会使输出更加可预测和连贯,但可能不那么多样化和有趣。


^(1)  Sepp Hochreiter 和 Jurgen Schmidhuber,1997 年,“长短期记忆”,神经计算 9(8):1735-1780。

第九章:注意力和 Transformer 的逐行实现

本章节涵盖

  • Transformer 中编码器和解码器的架构和功能

  • 注意力机制如何使用查询、键和值来为序列中的元素分配权重

  • 不同类型的 Transformer

  • 从零开始构建用于语言翻译的 Transformer

Transformers 是先进的深度学习模型,在处理序列到序列预测挑战方面表现出色,优于旧模型如循环神经网络(RNNs)和卷积神经网络(CNNs)。它们的优势在于有效地理解输入和输出序列中元素之间的关系,尤其是在文本中相隔较远的两个单词。与 RNNs 不同,Transformers 能够进行并行训练,显著缩短训练时间,并能够处理大量数据集。这种变革性的架构在大型语言模型(LLMs)如 ChatGPT、BERT 和 T5 的开发中发挥了关键作用,标志着人工智能进步的重要里程碑。

在 2017 年一篇开创性的论文“Attention Is All You Need”中,由一组谷歌研究人员提出之前(^(1)),自然语言处理(NLP)和类似任务主要依赖于循环神经网络(RNNs),包括长短期记忆(LSTM)模型。然而,RNNs 按顺序处理信息,由于无法并行训练以及难以保持序列早期部分的信息,因此限制了它们的速度,并且无法捕捉长期依赖关系。

Transformer 架构的革命性方面是其注意力机制。该机制通过分配权重来评估序列中单词之间的关系,根据训练数据确定单词之间在意义上的相关程度。这使得模型如 ChatGPT 能够理解单词之间的关系,从而更有效地理解人类语言。输入的非顺序处理允许并行训练,减少训练时间,并促进大量数据集的使用,从而推动了知识型 LLMs 的兴起和当前人工智能进步的激增。

在本章中,我们将逐行实现从零开始创建 Transformer 的过程,基于论文“Attention Is All You Need.”。一旦训练完成,Transformer 可以处理任何两种语言之间的翻译(例如德语到英语或英语到中文)。在下一章中,我们将专注于训练本章开发的 Transformer 以执行英语到法语翻译。

要从头开始构建 Transformer,我们将探索自注意力机制的内部工作原理,包括查询、键和值向量的作用,以及缩放点积注意力(SDPA)的计算。我们将通过将层归一化和残差连接集成到多头注意力层中,并将其与前馈层结合来构建编码器层。然后,我们将堆叠六个这样的编码器层来形成编码器。同样,我们将在 Transformer 中开发一个解码器,它能够一次生成一个翻译标记,基于之前的翻译标记和编码器的输出。

这个基础将使你能够训练 Transformer 进行任何两种语言之间的翻译。在下一章中,你将学习如何使用包含超过 47,000 个英法翻译数据集来训练 Transformer。你将见证训练好的模型将常见的英语短语翻译成法语,其准确度与使用谷歌翻译相当。

9.1 注意力和 Transformer 简介

要掌握机器学习中 Transformer 的概念,首先理解注意力机制是至关重要的。这种机制使 Transformer 能够识别序列元素之间的长距离依赖关系,这是它们与早期的序列预测模型(如 RNNs)的区别之一。有了这个机制,Transformer 可以同时关注序列中的每个元素,理解每个单词的上下文。

以“bank”这个词为例,说明注意力机制如何根据上下文来解释单词。在句子“我昨天在河边钓鱼,整个下午都待在河岸附近”中,“bank”与“fishing”相关联,因为它指的是河流旁边的区域。在这里,Transformer 将“bank”理解为河流地形的一部分。

相比之下,在“Kate 昨天下班后去了银行,并在那里存了一张支票”这句话中,“bank”与“check”相关联,导致 Transformer 将“bank”识别为金融机构。这个例子展示了 Transformer 如何根据其周围的上下文来辨别单词的含义。

在本节中,你将更深入地了解注意力机制,探索它是如何工作的。这个过程对于确定句子中各个单词的重要性或权重至关重要。之后,我们将检查不同 Transformer 模型的结构,包括一种能够翻译任何两种语言之间的模型。

9.1.1 注意力机制

注意力机制是一种用于确定序列中元素之间相互连接的方法。它计算分数来指示一个元素与序列中其他元素的关系,分数越高表示关系越强。在 NLP 中,这种机制对于在句子中有意义地连接单词至关重要。本章将指导你实现用于语言翻译的注意力机制。

为了这个目的,我们将构建一个由编码器和解码器组成的 Transformer。然后,在下一章中,我们将训练这个 Transformer 将英语翻译成法语。编码器将一个英语句子,例如“你好吗?”,转换成捕捉其意义的向量表示。然后解码器使用这些向量表示来生成法语翻译。

为了将短语“你好吗?”转换成向量表示,模型首先将其分解成标记[how, are, you, ?],这个过程与你在第八章中做过的类似。这些标记每个都由一个 256 维的向量表示,称为词嵌入,它捕捉每个标记的意义。编码器还采用了位置编码,这是一种确定标记在序列中位置的方法。这种位置编码被添加到词嵌入中,以创建输入嵌入,然后用于计算自注意力。短语“你好吗?”的输入嵌入形成一个维度为(4, 256)的张量,其中 4 代表标记的数量,256 是每个嵌入的维度性。

尽管计算注意力的方法有很多种,但我们将使用最常见的方法,即 SDPA。这种机制也被称为自注意力,因为算法计算一个词如何关注序列中的所有词,包括它自己。图 9.1 展示了如何计算 SDPA 的示意图。

图片

图 9.1 自注意力机制的示意图。为了计算注意力,首先将输入嵌入 X 通过三个带有权重 WQ、WK 和 W^V 的神经网络层。输出是查询 Q、键 K 和值 V。缩放后的注意力分数是 Q 和 K 的乘积除以 K 的维度的平方根,d[k]。我们对缩放后的注意力分数应用 softmax 函数以获得注意力权重。注意力是注意力权重和值 V 的乘积。

在计算注意力时使用查询、键和值的方法受到了检索系统的启发。考虑访问一个公共图书馆来寻找一本书。如果你在图书馆的搜索引擎中搜索“金融中的机器学习”,这个短语就变成了你的查询。图书馆中的书籍标题和描述作为键。根据你的查询与这些键之间的相似性,图书馆的检索系统会建议一系列书籍(值)。标题或描述中包含“机器学习”、“金融”或两者之一的书籍可能会排名更高。相比之下,与这些术语无关的书籍将具有较低的匹配分数,因此不太可能被推荐。

要计算 SDPA,输入嵌入 X 通过三个不同的神经网络层进行处理。这些层的相应权重是 WQ、WK 和 W^V;每个的维度为 256 × 256。这些权重在训练阶段从数据中学习。因此,我们可以计算查询 Q、键 K 和值 V,Q = X * W^Q,K = X * Q^K,V = X * W^V。Q、K 和 V 的维度与输入嵌入 X 的维度相匹配,即 4 × 256。

与我们之前提到的检索系统示例类似,在注意力机制中,我们使用 SDPA 方法来评估查询向量和键向量之间的相似性。SDPA 包括计算查询(Q)和键(K)向量的点积。高点积表示两个向量之间有很强的相似性,反之亦然。例如,在句子“你怎么样?”中,缩放后的注意力分数计算如下:

|

(9.1)

其中 d[k]表示键向量 K 的维度,在我们的情况下是 256。我们将 Q 和 K 的点积乘以 d[k]的平方根以稳定训练。这种缩放是为了防止点积的幅度过大。当这些向量的维度(即嵌入的深度)很高时,查询和键向量之间的点积可以变得非常大。这是因为查询向量的每个元素都与键向量的每个元素相乘,然后这些乘积相加。

下一步是将 softmax 函数应用于这些注意力分数,将它们转换为注意力权重。这确保了单词对句子中所有单词的总注意力加起来为 100%。

图 9.2 计算注意力权重的步骤。输入嵌入通过两个神经网络传递以获得查询 Q 和键 K。缩放后的注意力分数是 Q 和 K 的点积除以 K 的维度平方根。最后,我们对缩放后的注意力分数应用 softmax 函数以获得注意力权重,这些权重展示了序列中每个元素与其他所有元素的关系。

图 9.2 展示了这一过程。对于句子“你怎么样?”,注意力权重形成一个 4 × 4 矩阵,显示了["How", "are," "you," "?"]中的每个标记如何与其他所有标记(包括自身)相关。图 9.2 中的数字是为了说明这一点而编造的。例如,注意力权重的第一行显示标记"How"将其 10%的注意力分配给自己,将 40%、40%和 10%分别分配给其他三个标记。

最终的注意力是这些注意力权重与值向量 V(如图 9.3 所示)的点积:

|

(9.2)

图 9.3 使用注意力权重和价值向量计算注意力向量。输入嵌入通过神经网络传递以获得价值 V。最终的注意力是我们之前计算的注意力权重与价值向量 V 的点积。

这个输出也保持了一个 4 × 256 的维度,与我们的输入维度一致。

总结来说,这个过程从句子“你好吗?”的输入嵌入 X 开始,其维度为 4 × 256。这个嵌入捕捉了四个单独标记的含义,但缺乏上下文理解。注意力机制以输出attention(Q,K,V)结束,其维度保持为 4 × 256。这个输出可以看作是原始四个标记的上下文丰富组合。原始标记的权重根据每个标记的上下文相关性而变化,赋予句子上下文中更重要的单词更多的意义。通过这一过程,注意力机制将代表孤立标记的向量转化为充满上下文意义的向量,从而从句子中提取更丰富、更细腻的理解。

此外,Transformer 模型不是使用一套查询、键和值向量,而是使用一个称为多头注意力的概念。例如,256 维度的查询、键和值向量可以被分成,比如说,8 个头,每个头有一组查询、键和值向量,其维度为 32(因为 256/8 = 32)。每个头关注输入的不同部分或方面,使模型能够捕捉更广泛的信息,并形成对输入数据的更详细和上下文化的理解。多头注意力在句子中一个单词有多个意义时特别有用,比如在双关语中。让我们继续我们之前提到的“银行”例子。考虑这个双关语笑话,“为什么河流这么富饶?因为它有两个河岸。”在下一章将英语翻译成法语的项目中,你将亲自动手将 Q、K 和 V 分割成多个头,在每个头中计算注意力,然后再将它们连接成一个单一的注意力向量。

9.1.2 Transformer 架构

注意力机制的这一概念是由巴哈纳乌、乔和本吉奥在 2014 年提出的.^(2) 在开创性的论文“注意力即一切”发表后,它得到了广泛的应用,该论文专注于为机器语言翻译创建一个模型。这个模型的结构,被称为 Transformer,在图 9.4 中展示。它具有一个编码器-解码器结构,该结构高度依赖于注意力机制。在本章中,你将从头开始构建这个模型,逐行编码,目的是训练它进行任何两种语言之间的翻译。

图片

图 9.4 Transformer 架构。Transformer 中的编码器(图左侧),由 N 个相同的编码器层组成,学习输入序列的意义并将其转换为表示其意义的向量。然后,它将这些向量传递给解码器(图右侧),解码器由 N 个相同的解码器层组成。解码器通过预测序列中的每个标记,并根据序列中的先前标记和解码器从编码器获得的向量表示来构建输出(例如,英语短语的法语翻译)。右上角的生成器是连接到解码器输出的头部,以便输出是目标语言中所有标记的概率分布(例如,法语词汇)。

让我们以英语到法语的翻译为例。Transformer 的编码器将英语句子“我不说法语”转换成存储其意义的向量表示。然后,Transformer 的解码器处理这些表示以生成法语翻译“Je ne parle pas français”。编码器的角色是捕捉原始英语句子的本质。例如,如果编码器有效,它应该将“我不说法语”和“我并不说法语”翻译成相似的向量表示。因此,解码器将解释这些向量并生成相似的翻译。有趣的是,当使用 ChatGPT 时,这两个英语短语确实产生了相同的法语翻译。

Transformer 中的编码器通过首先对英语和法语句子进行标记化来处理任务。这与第八章中描述的过程类似,但有一个关键的区别:它采用子词标记化。子词标记化是 NLP 中用于将单词分解成更小的组成部分或子词的技术,这使得处理更加高效和细致。例如,正如你将在下一章中看到的,英语短语“我不说法语”被分为六个标记:(i, do, not, speak, fr, ench)。同样,其法语对应短语“Je ne parle pas français”被标记化为六个部分:(je, ne, parle, pas, franc, ais)。这种标记化方法增强了 Transformer 处理语言变化和复杂性的能力。

深度学习模型,包括 Transformer,不能直接处理文本,因此在输入模型之前,需要对标记进行索引。这些标记通常首先使用我们第八章讨论的一热编码表示。然后,我们将它们通过词嵌入层传递,以将它们压缩成具有连续值的更小向量,例如长度为 256 的向量。因此,在应用词嵌入后,“我不说法语”这个句子被表示为一个 6×256 的矩阵。

与处理数据序列的 RNN 不同,Transformers 并行处理输入数据,如句子。这种并行性提高了它们的效率,但并不固有地允许它们识别输入序列的顺序。为了解决这个问题,Transformers 将位置编码添加到输入嵌入中。这些位置编码是分配给输入序列中每个位置的独特向量,并且与输入嵌入在维度上对齐。向量值由一个特定的位置函数确定,特别是涉及不同频率的正弦和余弦函数,定义为

|

图片

|

图片

(9.3)

在这些方程中,向量使用正弦函数计算偶数索引,使用余弦函数计算奇数索引。参数posi分别代表序列中标记的位置和向量中的索引。作为一个例子,考虑短语“我不会说法语”的位置编码。这被表示为一个 6 × 256 的矩阵,与句子的词嵌入大小相同。在这里,pos的范围从 0 到 5,索引 2i 和 2i + 1 共同涵盖了 256 个不同的值(从 0 到 255)。这种位置编码方法的一个有益方面是,所有值都被限制在-1 到 1 的范围内。

需要注意的是,每个标记位置都由一个 256 维向量唯一标识,并且这些向量值在整个训练过程中保持不变。在输入到注意力层之前,这些位置编码被添加到序列的词嵌入中。以句子“我不会说法语”为例,编码器在将它们组合成一个单一的 6 × 256 维表示之前,生成了词嵌入和位置编码,每个都有 6 × 256 的维度。随后,编码器应用注意力机制来细化这个嵌入,形成更复杂的向量表示,以捕捉短语的整体意义,然后再将它们传递给解码器。

如图 9.5 所示,Transformer 的编码器由六个相同的层(N = 6)组成。这些层中的每一层都包含两个不同的子层。第一个子层是一个多头自注意力层,类似于之前讨论过的。第二个子层是一个基本的、位置性的、全连接的前馈网络。这个网络独立地处理序列中的每个位置,而不是将其作为序列元素。在模型的架构中,每个子层都包含层归一化和残差连接。层归一化将观察值规范化为零均值和单位标准差。这种归一化有助于稳定训练过程。在归一化层之后,我们执行残差连接。这意味着每个子层的输入被添加到其输出中,增强了网络中信息流的流动。

图 9.5 Transformer 中编码器的结构

图 9.5 Transformer 中编码器的结构。编码器由 N = 6 个相同的编码器层组成。每个编码器层包含两个子层。第一个子层是一个多头自注意力层,第二个子层是一个前馈网络。每个子层都使用层归一化和残差连接。

如图 9.6 所示,Transformer 模型的解码器由六个相同的解码器层(N = 6)组成。这些解码器层中的每一个都包含三个子层:一个多头自注意力子层,一个在第一个子层输出和编码器输出之间执行多头交叉注意力的子层,以及一个前馈子层。请注意,每个子层的输入是前一个子层的输出。此外,解码器层中的第二个子层还接受编码器的输出作为输入。这种设计对于整合编码器中的信息至关重要:这就是解码器如何根据编码器的输出生成翻译。

图 9.5 Transformer 中编码器的结构

图 9.6 Transformer 中解码器的结构。解码器由 N = 6 个相同的解码器层组成。每个解码器层包含三个子层。第一个子层是一个带掩码的多头自注意力层。第二个子层是一个多头交叉注意力层,用于计算第一个子层输出和编码器输出之间的交叉注意力。第三个子层是一个前馈网络。每个子层都使用层归一化和残差连接。

解码器自注意力子层的一个关键方面是掩码机制。这个掩码防止模型访问序列中的未来位置,确保特定位置的预测只能依赖于之前已知的元素。这种序列依赖性对于语言翻译或文本生成等任务至关重要。

解码过程从解码器接收一个法语输入短语开始。解码器将法语标记转换为词嵌入和位置编码,然后将它们组合成一个单一的嵌入。这一步骤确保模型不仅理解短语的语义内容,而且保持序列上下文,这对于准确的翻译或生成任务至关重要。

解码器以自回归的方式运行,一次生成一个标记的输出序列。在第一次时间步,它从 "BOS" 标记开始,这表示句子的开始。使用这个起始标记作为其初始输入,解码器检查英语短语 “I do not speak French” 的向量表示,并尝试预测 "BOS" 后的第一个标记。假设解码器的第一个预测是 "Je"。在下一个时间步,它然后使用序列 "BOS Je" 作为其新的输入来预测下一个标记。这个过程以迭代方式继续,解码器将每个新预测的标记添加到其输入序列中,以便进行后续预测。

翻译过程被设计为在解码器预测 "EOS" 标记时结束,这标志着句子的结束。在准备训练数据时,我们在每个短语的末尾添加 EOS,这样模型就学会了这意味着句子的结束。当达到这个标记时,解码器识别到翻译任务的完成并停止其操作。这种自回归方法确保解码过程中的每一步都由之前预测的所有标记提供信息,从而实现连贯和上下文适当的翻译。

9.1.3 不同类型的 Transformer

有三种类型的 Transformer:仅编码器 Transformer、仅解码器 Transformer 和编码器-解码器 Transformer。我们在这章和下一章中使用的是编码器-解码器 Transformer,但你将在本书的后面有机会亲自探索仅解码器 Transformer。

仅包含编码器的 Transformer 由图 9.4 左侧所示的 N 个相同的编码器层组成,并且能够将序列转换为抽象的连续向量表示。例如,BERT 就是一个仅包含 12 个编码器层的编码器-only Transformer。仅包含编码器的 Transformer 可以用于文本分类,例如。如果两个句子具有相似的向量表示,我们可以将这两个句子分类到同一类别。另一方面,如果两个序列具有非常不同的向量表示,我们可以将它们放入不同的类别中。

仅解码器 Transformer 也由 N 个相同的层组成,每个层都是图 9.4 右侧所示的解码器层。例如,ChatGPT 是一个包含许多解码器层的仅解码器 Transformer。仅解码器 Transformer 可以根据提示生成文本,例如。它提取提示中单词的语义含义并预测最可能的下一个标记。然后它将标记添加到提示的末尾并重复此过程,直到文本达到一定长度。

我们之前讨论的机器翻译 Transformer 是编码器-解码器 Transformer 的一个例子。它们对于处理复杂任务(如文本到图像生成或语音识别)是必需的。编码器-解码器 Transformer 结合了编码器和解码器的优点。编码器在处理和理解输入数据方面效率高,而解码器在生成输出方面表现卓越。这种组合使模型能够有效地理解复杂的输入(如文本或语音)并生成复杂的输出(如图像或转录文本)。

9.2 构建编码器

我们将开发并训练一个针对机器语言翻译设计的编码器-解码器 Transformer。本项目中的编码来自 Chris Cui 的将中文翻译成英文的工作(mng.bz/9o1o)和 Alexander Rush 的德语到英语翻译项目(mng.bz/j0mp)。

本节讨论了如何在 Transformer 中构建编码器。具体来说,我们将深入探讨构建每个编码器层内各种子层的过程以及实现多头自注意力机制。

9.2.1 注意力机制

尽管存在不同的注意力机制,但我们将使用 SDPA,因为它被广泛使用且有效。SDPA 注意力机制使用查询、键和值来计算序列中元素之间的关系。它分配分数以显示一个元素与序列中所有元素(包括该元素本身)的关系。

与使用一组查询、键和值向量不同,Transformer 模型使用了一个称为多头注意力的概念。我们的 256 维查询、键和值向量被分成 8 个头,每个头都有一组查询、键和值向量,维度为 32(因为 256/8 = 32)。每个头关注输入的不同部分或方面,使模型能够捕捉更广泛的信息,并对输入数据形成更详细和上下文化的理解。例如,多头注意力允许模型捕捉双关语笑话中“bank”一词的多种含义,“为什么这条河这么富饶?因为它有两个河岸。”

为了实现这一点,我们在本地模块 ch09util 中定义了一个 attention() 函数。从本书的 GitHub 仓库(github.com/markhliu/DGAI)下载文件 ch09util.py 并将其存储在您计算机的 /utils/ 目录中。attention() 函数的定义如下所示。

列表 9.1 基于查询、键和值计算注意力

def attention(query, key, value, mask=None, dropout=None):
    d_k = query.size(-1)
    scores = torch.matmul(query, 
              key.transpose(-2, -1)) / math.sqrt(d_k)       ①
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)        ②
    p_attn = nn.functional.softmax(scores, dim=-1)          ③
    if dropout is not None:
        p_attn = dropout(p_attn)
    return torch.matmul(p_attn, value), p_attn              ④

① 缩放后的注意力分数是查询和键的点积,乘以 d[k] 的平方根。

② 如果有掩码,则隐藏序列中的未来元素

③ 计算注意力权重

④ 返回注意力和注意力权重

attention()函数接受查询、键和值作为输入,并计算注意力和注意力权重,正如我们在本章前面讨论的那样。缩放后的注意力分数是查询和键的点积,乘以键的维度的平方根d[k]。我们应用 softmax 函数到缩放后的注意力分数以获得注意力权重。最后,注意力是注意力权重和值的点积。

让我们使用我们的运行示例来展示多头注意力的工作原理(见图 9.7)。"你好吗?"的嵌入是一个大小为(1, 6, 256)的张量,正如我们在上一节中解释的(在我们将位置编码添加到词嵌入之后)。请注意,1 表示批处理中有一个句子,句子中有六个标记而不是四个,因为我们向序列的开始和结束添加了 BOS 和 EOS。这个嵌入通过三个线性层传递,以获得查询 Q、键 K 和值 V,每个的大小相同(1, 6, 256)。这些被分成八个头,现在每个头的大小为(1, 6, 256/8 = 32)。根据前面定义的注意力函数,将这些集合中的每个都应用注意力函数,产生八个注意力输出,每个的大小也是(1, 6, 32)。然后,我们将八个注意力输出连接成一个单一的注意力,结果是大小为(1, 6, 32 × 8 = 256)的张量。最后,这个组合注意力通过另一个大小为 256 × 256 的线性层,导致MultiHeadAttention()类的输出。这个输出保持了原始输入的维度,即(1, 6, 256)。

图片

图 9.7 多头注意力的一个示例。此图以短语“你好吗?”的多头自注意力计算为例。我们首先将嵌入通过三个神经网络传递,以获得查询 Q、键 K 和值 V,每个的大小为(1, 6, 256)。我们将它们分成八个头,每个头都有一组 Q、k 和 V,大小为(1, 6, 32)。我们在每个头中计算注意力。然后,将八个头的注意力向量合并回一个单一的注意力向量,大小为(1, 6, 256)。

这在以下本地模块的代码列表中实现。

列表 9.2 计算多头注意力

from copy import deepcopy
class MultiHeadedAttention(nn.Module):
    def __init__(self, h, d_model, dropout=0.1):
        super().__init__()
        assert d_model % h == 0
        self.d_k = d_model // h
        self.h = h
        self.linears = nn.ModuleList([deepcopy(
            nn.Linear(d_model, d_model)) for i in range(4)])
        self.attn = None
        self.dropout = nn.Dropout(p=dropout)

    def forward(self, query, key, value, mask=None):
        if mask is not None:
            mask = mask.unsqueeze(1)
        nbatches = query.size(0)  
        query, key, value = [l(x).view(nbatches, -1, self.h,
           self.d_k).transpose(1, 2)    
         for l, x in zip(self.linears, (query, key, value))]    ①
        x, self.attn = attention(
            query, key, value, mask=mask, dropout=self.dropout) ②
        x = x.transpose(1, 2).contiguous().view(
            nbatches, -1, self.h * self.d_k)                    ③
        output = self.linears-1                            ④
        return output

① 将输入通过三个线性层传递以获得 Q、K、V,并将它们分成多头

② 计算每个头的注意力和注意力权重

③ 将多头注意力向量连接成一个单一的注意力向量

④ 将输出通过一个线性层

每个编码器层和解码器层也包含一个前馈子层,这是一个两层全连接神经网络,其目的是增强模型在训练数据集中捕获和学习的复杂特征的能力。此外,神经网络独立处理每个嵌入。它不将嵌入序列视为单个向量。因此,我们通常称其为位置感知的前馈网络(或一维卷积网络)。为此,我们在本地模块中定义了一个PositionwiseFeedForward()类,如下所示:

class PositionwiseFeedForward(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1):
        super().__init__()
        self.w_1 = nn.Linear(d_model, d_ff)
        self.w_2 = nn.Linear(d_ff, d_model)
        self.dropout = nn.Dropout(dropout)
    def forward(self, x):
        h1 = self.w_1(x)
        h2 = self.dropout(h1)
        return self.w_2(h2) 

PositionwiseFeedForward()类使用两个关键参数定义:d_ff,表示前馈层的维度,和d_model,表示模型的维度大小。通常,d_ff被选择为d_model的四倍。在我们的例子中,d_model是 256,因此我们将d_ff设置为 256 * 4 = 1024。与模型大小相比扩大隐藏层的方法是 Transformer 架构中的标准做法。这增强了网络在训练数据集中捕获和学习的复杂特征的能力。

9.2.2 创建编码器

要创建一个编码器层,我们首先定义以下EncoderLayer()类和SublayerConnection()类。

列表 9.3 定义编码器层的类

class EncoderLayer(nn.Module):
    def __init__(self, size, self_attn, feed_forward, dropout):
        super().__init__()
        self.self_attn = self_attn
        self.feed_forward = feed_forward
        self.sublayer = nn.ModuleList([deepcopy(
        SublayerConnection(size, dropout)) for i in range(2)])
        self.size = size  
    def forward(self, x, mask):
        x = self.sublayer0)     ①
        output = self.sublayer1     ②
        return output
class SublayerConnection(nn.Module):
    def __init__(self, size, dropout):
        super().__init__()
        self.norm = LayerNorm(size)
        self.dropout = nn.Dropout(dropout)
    def forward(self, x, sublayer):
        output = x + self.dropout(sublayer(self.norm(x)))   ③
        return output  

① 每个编码器层中的第一个子层是一个多头自注意力网络。

② 每个编码器层中的第二个子层是一个前馈网络。

③ 每个子层都经过残差连接和层归一化。

每个编码器层由两个不同的子层组成:一个是MultiHeadAttention()类中概述的多头自注意力层,另一个是PositionwiseFeedForward()类中指定的简单、位置感知的全连接前馈网络。此外,这两个子层都包含层归一化和残差连接。正如第六章所述,残差连接涉及通过一系列变换(在此上下文中是注意力或前馈层)传递输入,然后将输入添加到这些变换的输出中。残差连接的方法被用来对抗梯度消失问题,这是非常深的网络中常见的挑战。在 Transformers 中,残差连接的另一个好处是提供一个通道,将位置编码(仅在第一层之前计算)传递到后续层。

层归一化与我们在第四章中实现的批归一化有些类似。它将层中的观察值标准化为零均值和单位标准差。为了在本地模块中实现这一点,我们定义了LayerNorm()类,该类执行层归一化如下:

class LayerNorm(nn.Module):
    def __init__(self, features, eps=1e-6):
        super().__init__()
        self.a_2 = nn.Parameter(torch.ones(features))
        self.b_2 = nn.Parameter(torch.zeros(features))
        self.eps = eps
    def forward(self, x):
        mean = x.mean(-1, keepdim=True) 
        std = x.std(-1, keepdim=True)
        x_zscore = (x - mean) / torch.sqrt(std ** 2 + self.eps)
        output = self.a_2*x_zscore+self.b_2
        return output 

在前面的LayerNorm()类中的meanstd值是每个层中输入的均值和标准差。LayerNorm()类中的a_2b_2层将x_zscore扩展回输入x的形状。

现在我们可以通过堆叠六个编码器层来创建一个编码器。为此,我们在本地模块中定义了Encoder()类:

from copy import deepcopy
class Encoder(nn.Module):
    def __init__(self, layer, N):
        super().__init__()
        self.layers = nn.ModuleList(
            [deepcopy(layer) for i in range(N)])
        self.norm = LayerNorm(layer.size)
    def forward(self, x, mask):
        for layer in self.layers:
            x = layer(x, mask)
            output = self.norm(x)
        return output

在这里,Encoder()类使用两个参数定义:layer,它是一个编码器层,如列表 9.3 中定义的EncoderLayer()类中的编码器层,以及N,编码器中编码器层的数量。Encoder()类接受输入x(例如,一批英语短语)和掩码(以屏蔽序列填充,如我在第十章中解释的)以生成输出(捕获英语短语意义的向量表示)。

这样,你就创建了一个编码器。接下来,你将学习如何创建一个解码器。

9.3 构建编码器-解码器 Transformer

现在你已经了解了如何在 Transformer 中创建编码器,让我们继续到解码器。在本节中,你将首先学习如何创建解码器层。然后,你将堆叠 N = 6 个相同的解码器层来形成一个解码器。

然后,我们创建了一个包含五个组件的编码器-解码器 Transformer:encoderdecodersrc_embedtgt_embedgenerator,我将在本节中解释这些。

9.3.1 创建解码器层

每个解码器层由三个子层组成:(1)一个多头自注意力层,(2)第一个子层的输出与编码器输出的交叉注意力,以及(3)一个前馈网络。这三个子层都包含一个层归一化和残差连接,类似于我们在编码器层中做的。此外,解码器堆叠的多头自注意力子层被掩码,以防止位置关注后续位置。掩码迫使模型使用序列中的先前元素来预测后续元素。我将在稍后解释掩码多头自注意力是如何工作的。为了实现这一点,我们在本地模块中定义了DecoderLayer()类。

列表 9.4 创建解码器层

class DecoderLayer(nn.Module):
    def __init__(self, size, self_attn, src_attn,
                 feed_forward, dropout):
        super().__init__()
        self.size = size
        self.self_attn = self_attn
        self.src_attn = src_attn
        self.feed_forward = feed_forward
        self.sublayer = nn.ModuleList([deepcopy(
        SublayerConnection(size, dropout)) for i in range(3)])
    def forward(self, x, memory, src_mask, tgt_mask):
        x = self.sublayer0)             ①
        x = self.sublayer1)    ②
        output = self.sublayer2         ③
        return output

① 第一个子层是一个掩码多头自注意力层。

② 第二个子层是在目标语言和源语言之间的交叉注意力层。

③ 第三个子层是一个前馈网络。

为了说明解码器层的操作,让我们考虑我们正在进行的例子。解码器接收标记['BOS', 'comment', 'et', 'es-vous', '?']以及编码器的输出(在前面代码块中称为memory),来预测序列['comment', 'et', 'es-vous', '?', 'EOS']['BOS', 'comment', 'et', 'es-vous', '?']的嵌入是一个大小为(1,5,256)的张量:1 是批次中的序列数量,5 是序列中的标记数量,256 表示每个标记由一个 256 值的向量表示。我们把这个嵌入通过第一个子层,一个带掩码的多头自注意力层。这个过程与您在编码器层中看到的早期多头自注意力计算类似。然而,这个过程利用了一个掩码,在前面代码块中指定为tgt_mask,它是一个 5 × 5 的张量,在当前例子中的值如下:

tensor([[ True, False, False, False, False],
        [ True,  True, False, False, False],
        [ True,  True,  True, False, False],
        [ True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True]], device='cuda:0')

如您可能已经注意到的,掩码的下半部分(张量中低于主对角线的值)被设置为True,而上半部分(主对角线以上的值)被设置为False。当这个掩码应用于注意力分数时,它会导致在第一次时间步中第一个标记只关注自身。在第二次时间步中,注意力分数仅计算在第一个和第二个标记之间。随着过程的继续,例如,在第三次时间步中,解码器使用标记['BOS', 'comment', 'et']来预测标记'es-vous',并且注意力分数仅在这三个标记之间计算,有效地隐藏了未来的标记['es-vous', '?']

按照这个流程,从第一个子层生成的输出,它是一个大小为(1,5,256)的张量,与输入的大小相匹配。这个输出,我们可以称之为 x,然后被输入到第二个子层。在这里,x 和编码器堆栈的输出(在前面代码块中称为memory)之间计算交叉注意力。您可能还记得,memory的维度是(1,6,256),因为英语短语“你好吗?”被转换成六个标记['BOS', 'how', 'are', 'you', '?', 'EOS']

图 9.8 展示了如何计算交叉注意力权重。为了计算 x 和 memory 之间的交叉注意力,我们首先将 x 通过一个神经网络来获得查询,其维度为 (1, 5, 256)。然后我们将 memory 通过两个神经网络来获得键和值,每个的维度为 (1, 6, 256)。使用方程 9.1 中指定的公式计算缩放后的注意力分数。这个缩放后的注意力分数的维度为 (1, 5, 6):查询 Q 的维度为 (1, 5, 256),转置后的键 K 的维度为 (1, 256, 6)。因此,缩放后的注意力分数,即这两个向量的点积,并乘以 √d[k],其大小为 (1, 5, 6)。在将缩放后的注意力分数应用 softmax 函数后,我们获得注意力权重,这是一个 5 × 6 的矩阵。这个矩阵告诉我们,在法语输入 ['BOS', 'comment', 'et', 'es-vous', '?'] 中的五个标记如何关注英语短语 ['BOS', 'how', 'are', 'you', '?', 'EOS'] 中的六个标记。这就是解码器在翻译时如何捕捉英语短语的含义。

图 9.8

图 9.8 展示了解码器输入和编码器输出之间如何计算交叉注意力权重的一个例子。解码器的输入通过一个神经网络来获得查询 Q。编码器的输出通过一个不同的神经网络来获得键 K。缩放后的交叉注意力分数是通过 Q 和 K 的点积除以 K 的维度平方根来计算的。最后,我们对缩放后的交叉注意力分数应用 softmax 函数,以获得交叉注意力权重,这显示了 Q 中的每个元素与 K 中的所有元素之间的关系。

第二个子层中的最终交叉注意力是通过注意力权重和值向量 V 的点积来计算的。注意力权重的维度为 (1, 5, 6),值向量的维度为 (1, 6, 256),因此最终的交叉注意力,即这两个向量的点积,其大小为 (1, 5, 256)。因此,第二个子层的输入和输出具有相同的维度 (1, 5, 256)。经过这个第二个子层的处理之后,输出随后通过第三个子层,这是一个前馈网络。

9.3.2 创建编码器-解码器 Transformer

解码器由 N = 6 个相同的解码器层组成。

Decoder() 类在本地模块中的定义如下:

class Decoder(nn.Module):
    def __init__(self, layer, N):
        super().__init__()
        self.layers = nn.ModuleList(
            [deepcopy(layer) for i in range(N)])
        self.norm = LayerNorm(layer.size)
    def forward(self, x, memory, src_mask, tgt_mask):
        for layer in self.layers:
            x = layer(x, memory, src_mask, tgt_mask)
        output = self.norm(x)
        return output

要创建一个编码器-解码器 Transformer,我们首先在本地模块中定义一个 Transformer() 类。打开文件 ch09util.py,你会看到类的定义如下所示。

列表 9.5 表示编码器-解码器 Transformer 的类

class Transformer(nn.Module):
    def __init__(self, encoder, decoder,
                 src_embed, tgt_embed, generator):
        super().__init__()
        self.encoder = encoder                                ①
        self.decoder = decoder                                ②
        self.src_embed = src_embed
        self.tgt_embed = tgt_embed
        self.generator = generator
    def encode(self, src, src_mask):
        return self.encoder(self.src_embed(src), src_mask)
    def decode(self, memory, src_mask, tgt, tgt_mask):
        return self.decoder(self.tgt_embed(tgt), 
                            memory, src_mask, tgt_mask)
    def forward(self, src, tgt, src_mask, tgt_mask):
        memory = self.encode(src, src_mask)                   ③
        output = self.decode(memory, src_mask, tgt, tgt_mask) ④
        return output

① 在 Transformer 中定义编码器

② 在 Transformer 中定义解码器

③ 源语言被编码成抽象向量表示。

④ 解码器使用这些向量表示来生成目标语言的翻译。

Transformer()类由五个关键组件构成:encoderdecodersrc_embedtgt_embedgenerator。编码器和解码器由之前定义的Encoder()Decoder()类表示。在下一章中,你将学习生成源语言嵌入:我们将使用词嵌入和位置编码处理英语短语的数值表示,将结果组合形成src_embed组件。同样,对于目标语言,我们以相同的方式处理法语短语的数值表示,将组合输出作为tgt_embed组件。生成器为与目标语言中的标记相对应的每个索引生成预测概率。我们将在下一节定义一个Generator()类来完成此目的。

9.4 将所有部件组合在一起

在本节中,我们将把所有部件组合起来,创建一个可以翻译任何两种语言的模型。

9.4.1 定义生成器

首先,我们在本地模块中定义一个Generator()类来生成下一个标记的概率分布(见图 9.9)。想法是为解码器附加一个头部以用于下游任务。在我们下一章的例子中,下游任务是预测法语翻译中的下一个标记。

图片

图 9.9 Transformer 中生成器的结构。生成器将解码器堆栈的输出转换为目标语言词汇表上的概率分布,以便 Transformer 可以使用该分布来预测英语短语的法语翻译中的下一个标记。生成器包含一个线性层,因此输出数量与法语词汇表中的标记数量相同。生成器还对输出应用 softmax 激活,以便输出是一个概率分布。

该类定义如下:

class Generator(nn.Module):
    def __init__(self, d_model, vocab):
        super().__init__()
        self.proj = nn.Linear(d_model, vocab)

    def forward(self, x):
        out = self.proj(x)
        probs = nn.functional.log_softmax(out, dim=-1)
        return probs  

Generator()类为与目标语言中的标记相对应的每个索引生成预测概率。这使得模型能够以自回归的方式顺序预测标记,利用先前生成的标记和解码器的输出。

9.4.2 创建用于两种语言之间翻译的模型

现在我们已经准备好创建一个 Transformer 模型,用于翻译任何两种语言(例如,英语到法语或中文到英语)。在本地模块中定义的create_model()函数实现了这一点。

列表 9.6 创建用于两种语言之间翻译的 Transformer

def create_model(src_vocab, tgt_vocab, N, d_model,
                 d_ff, h, dropout=0.1):
    attn=MultiHeadedAttention(h, d_model).to(DEVICE)
    ff=PositionwiseFeedForward(d_model, d_ff, dropout).to(DEVICE)
    pos=PositionalEncoding(d_model, dropout).to(DEVICE)
    model = Transformer(
        Encoder(EncoderLayer(d_model,deepcopy(attn),deepcopy(ff),
                             dropout).to(DEVICE),N).to(DEVICE),  ①
        Decoder(DecoderLayer(d_model,deepcopy(attn),
             deepcopy(attn),deepcopy(ff), dropout).to(DEVICE),
                N).to(DEVICE),                                   ②
        nn.Sequential(Embeddings(d_model, src_vocab).to(DEVICE),
                      deepcopy(pos)),                            ③
        nn.Sequential(Embeddings(d_model, tgt_vocab).to(DEVICE),
                      deepcopy(pos)),                            ④
        Generator(d_model, tgt_vocab)).to(DEVICE)                ⑤
    for p in model.parameters():
        if p.dim() > 1:
            nn.init.xavier_uniform_(p)    
    return model.to(DEVICE)

① 通过实例化 Encoder()类创建编码器

② 通过实例化 Decoder()类创建解码器

③ 通过将源语言通过词嵌入和位置编码传递来创建 src_embed

④ 通过将目标语言通过词嵌入和位置编码传递来创建 tgt_embed

⑤ 通过实例化 Generator()类创建生成器

create_model()函数的主要元素是之前定义的Transformer()类。回想一下,Transformer()类由五个基本元素组成:encoderdecodersrc_embedtgt_embedgenerator。在create_model()函数中,我们依次构建这五个组件,使用最近定义的Encoder()Decoder()Generator()类。在下一章中,我们将详细讨论如何生成源语言和目标语言嵌入src_embedtgt_embed

在下一章中,你将应用在这里创建的 Transformer 进行英语到法语翻译。你将使用超过 47,000 对英语到法语的翻译对来训练模型。然后,你将使用训练好的模型将常见的英语短语翻译成法语。

摘要

  • Transformer 是先进的深度学习模型,擅长处理序列到序列预测挑战。它们的优势在于能够有效地理解输入和输出序列中元素之间的长距离关系。

  • Transformer 架构的革命性方面是其注意力机制。该机制通过分配权重来评估序列中单词之间的关系,根据训练数据确定单词之间关系的紧密程度。这使得像 ChatGPT 这样的 Transformer 模型能够理解单词之间的关系,从而更有效地理解人类语言。

  • 要计算 SDPA,输入嵌入 X 通过三个不同的神经网络层进行处理,查询(Q)、键(K)和值(V)。这些层的相应权重分别是 WQ、WK 和 W^V。我们可以计算 Q、K 和 V 如下:Q = X * W^Q,K = X * Q^K,V = X * W^V。SDPA 的计算如下:

其中 d[k]表示键向量 K 的维度。softmax 函数应用于注意力分数,将它们转换为注意力权重。这确保了单词对句子中所有单词的总注意力加起来为 100%。最终的注意力是这些注意力权重与值向量 V 的点积。

  • 与使用一组查询、键和值向量不同,Transformer 模型使用一个称为多头注意力的概念。查询、键和值向量被分成多个头。每个头关注输入的不同部分或方面,使模型能够捕捉更广泛的信息,并对输入数据形成更详细和情境化的理解。多头注意力在句子中一个单词有多个意义时特别有用。

^(1) Vaswani 等人,2017 年,“Attention Is All You Need.” arxiv.org/abs/1706.03762

^(2)  德米特里·巴汉诺夫(Dzmitry Bahdanau)、金亨勋(Kyunghyun Cho)和约舒亚·本吉奥(Yoshua Bengio),2014 年,“通过联合学习对齐和翻译进行神经机器翻译。” https://arxiv.org/abs/1409.0473.

第十章:训练 Transformer 以将英语翻译成法语

本章涵盖

  • 将英语和法语短语分词为子词

  • 理解词嵌入和位置编码

  • 从头开始训练 Transformer 以将英语翻译成法语

  • 使用训练好的 Transformer 将英语短语翻译成法语

在上一章中,我们从头开始构建了一个 Transformer,可以根据“Attention Is All You Need.”这篇论文在任意两种语言之间进行翻译。具体来说,我们实现了自注意力机制,使用查询、键和值向量来计算缩放点积注意力(SDPA)。

为了更深入地理解自注意力机制和 Transformer,我们将在本章中使用英语到法语翻译作为案例研究。通过探索将英语句子转换为法语的模型训练过程,你将深入了解 Transformer 的架构和注意力机制的运作。

想象一下,你已经收集了超过 47,000 个英语到法语翻译对。你的目标是使用这个数据集训练第九章中的编码器-解码器 Transformer。本章将带你了解项目的所有阶段。首先,你将使用子词分词将英语和法语短语分解成标记。然后,你将构建你的英语和法语词汇表,其中包含每种语言中所有唯一的标记。词汇表允许你将英语和法语短语表示为索引序列。之后,你将使用词嵌入将这些索引(本质上是一维向量)转换为紧凑的向量表示。我们将在词嵌入中添加位置编码以形成输入嵌入。位置编码允许 Transformer 知道序列中标记的顺序。

最后,你将训练第九章中的编码器-解码器 Transformer,使用英语到法语翻译的集合作为训练数据集,将英语翻译成法语。训练完成后,你将学会使用训练好的 Transformer 将常见的英语短语翻译成法语。具体来说,你将使用编码器来捕捉英语短语的含义。然后,你将使用训练好的 Transformer 中的解码器以自回归的方式生成法语翻译,从开始标记"BOS"开始。在每一步中,解码器根据之前生成的标记和解码器的输出生成最可能的下一个标记,直到预测的标记是"EOS",这标志着句子的结束。训练好的模型可以像使用谷歌翻译进行任务一样,准确地将常见的英语短语翻译成法语。

10.1 子词分词

正如我们在第八章中讨论的,有三种标记化方法:字符级标记化、词级标记化和子词标记化。在本章中,我们将使用子词标记化,它在其他两种方法之间取得平衡。它在词汇表中保留了常用词的完整性,并将不常见或更复杂的词分解成子组件。

在本节中,你将学习如何将英文和法语短语标记为子词。然后,你将创建将标记映射到索引的字典。训练数据随后被转换为索引序列,并放入批量中进行训练。

10.1.1 标记英文和法语短语

访问mng.bz/WVAw下载包含我从各种来源收集的英文到法语翻译的 zip 文件。解压文件并将 en2fr.csv 放在计算机上的/files/文件夹中。

我们将加载数据并打印出一句英文短语及其法语翻译,如下所示:

import pandas as pd

df=pd.read_csv("files/en2fr.csv")                                ①
num_examples=len(df)                                             ②
print(f"there are {num_examples} examples in the training data")
print(df.iloc[30856]["en"])                                      ③
print(df.iloc[30856]["fr"])                                      ④

① 加载 CSV 文件

② 计算数据中有多少对短语

③ 打印出一个英文短语的示例

④ 打印出相应的法语翻译

前面代码片段的输出如下

there are 47173 examples in the training data
How are you?
Comment êtes-vous?

训练数据中有 47,173 对英文到法语的翻译。我们已打印出英文短语“你好吗?”及其对应的法语翻译“Comment êtes-vous?”作为示例。

在这个 Jupyter Notebook 的新单元格中运行以下代码行,以在您的计算机上安装transformers库:

!pip install transformers

接下来,我们将对数据集中的英文和法语短语进行标记。我们将使用 Hugging Face 的预训练 XLM 模型作为标记器,因为它擅长处理多种语言,包括英文和法语短语。

列表 10.1 预训练标记器

from transformers import XLMTokenizer                           ①

tokenizer = XLMTokenizer.from_pretrained("xlm-clm-enfr-1024")

tokenized_en=tokenizer.tokenize("I don't speak French.")        ②
print(tokenized_en)
tokenized_fr=tokenizer.tokenize("Je ne parle pas français.")    ③
print(tokenized_fr)
print(tokenizer.tokenize("How are you?"))
print(tokenizer.tokenize("Comment êtes-vous?"))

① 导入预训练标记器

② 使用标记器对英文句子进行标记

③ 标记一个法语句子

列表 10.1 的输出如下

['i</w>', 'don</w>', "'t</w>", 'speak</w>', 'fr', 'ench</w>', '.</w>']
['je</w>', 'ne</w>', 'parle</w>', 'pas</w>', 'franc', 'ais</w>', '.</w>']
['how</w>', 'are</w>', 'you</w>', '?</w>']
['comment</w>', 'et', 'es-vous</w>', '?</w>']

在前面的代码块中,我们使用 XLM 模型预训练的标记器将英文句子“我不说法语。”分解成一组标记。在第八章中,你开发了一个自定义的词级标记器。然而,本章介绍了使用更高效的预训练子词标记器,其有效性超过了词级标记器。因此,句子“我不说法语。”被标记为['i', 'don', "'t", 'speak', 'fr', 'ench', '.']。同样,法语句子“Je ne parle pas français.”被分割成六个标记:['je', 'ne', 'parle', 'pas', 'franc', 'ais', '.']。我们还将英文短语“你好吗?”及其法语翻译进行了标记。结果显示在上面的输出最后两行。

注意:你可能已经注意到,XLM 模型使用 '</w>' 作为标记分隔符,除非两个标记是同一个单词的一部分。子词标记化通常导致每个标记要么是一个完整的单词或标点符号,但有时一个单词会被分成音节。例如,单词“French”被分成“fr”和“ench。”值得注意的是,模型在“fr”和“ench”之间不会插入 </w>,因为这些音节共同构成了单词“French”。

深度学习模型如 Transformers 不能直接处理原始文本;因此,在将文本输入模型之前,我们需要将文本转换为数值表示。为此,我们创建一个字典,将所有英语标记映射到整数。

列表 10.2 将英语标记映射到索引

from collections import Counter

en=df["en"].tolist()                                           ①

en_tokens=[["BOS"]+tokenizer.tokenize(x)+["EOS"] for x in en]  ②
PAD=0
UNK=1
word_count=Counter()
for sentence in en_tokens:
    for word in sentence:
        word_count[word]+=1
frequency=word_count.most_common(50000)                        ③
total_en_words=len(frequency)+2
en_word_dict={w[0]:idx+2 for idx,w in enumerate(frequency)}    ④
en_word_dict["PAD"]=PAD
en_word_dict["UNK"]=UNK
en_idx_dict={v:k for k,v in en_word_dict.items()}              ⑤

① 从训练数据集中获取所有英语句子

② 对所有英语句子进行标记化

③ 计算标记的频率

④ 创建一个字典将标记映射到索引

⑤ 创建一个字典将索引映射到标记

我们分别在每句话的开始和结束处插入标记 "BOS"(句子开始)和 "EOS"(句子结束)。字典 en_word_dict 为每个标记分配一个唯一的整数值。此外,用于填充的 "PAD" 标记被分配整数 0,而代表未知标记的 "UNK" 标记被分配整数 1。反向字典 en_idx_dict 将整数(索引)映射回相应的标记。这种反向映射对于将整数序列转换回标记序列至关重要,使我们能够重建原始的英语短语。

使用字典 en_word_dict,我们可以将英语句子“我不说法语。”转换为其数值表示。这个过程涉及在字典中查找每个标记以找到其对应的整数值。例如:

enidx=[en_word_dict.get(i,UNK) for i in tokenized_en]   
print(enidx)

上述代码行产生以下输出:

[15, 100, 38, 377, 476, 574, 5]

这意味着英语句子“我不说法语。”现在由一系列整数[15, 100, 38, 377, 476, 574, 5]表示。

我们还可以使用字典 en_idx_dict 将数值表示转换回标记。这个过程涉及将数值序列中的每个整数映射回字典中定义的相应标记。以下是操作方法:

entokens=[en_idx_dict.get(i,"UNK") for i in enidx]          ①
print(entokens)
en_phrase="".join(entokens)                                 ②
en_phrase=en_phrase.replace("</w>"," ")                     ③
for x in '''?:;.,'("-!&)%''':
    en_phrase=en_phrase.replace(f" {x}",f"{x}")             ④
print(en_phrase)

① 将索引转换为标记

② 将标记连接成一个字符串

③ 将分隔符替换为空格

④ 删除标点符号前的空格

上述代码片段的输出是

['i</w>', 'don</w>', "'t</w>", 'speak</w>', 'fr', 'ench</w>', '.</w>']
i don't speak french. 

字典en_idx_dict用于将数字转换回它们原始的标记。在此之后,这些标记被转换成完整的英语短语。这是通过首先将标记连接成一个字符串,然后将分隔符''</w>''替换为空格来完成的。我们还移除了标点符号前的空格。请注意,恢复的英语短语全部为小写字母,因为预训练的标记器自动将大写字母转换为小写以减少唯一标记的数量。正如你将在下一章中看到的,一些模型,如 GPT2 和 ChatGPT,并不这样做;因此,它们的词汇量更大。

练习 10.1

在列表 10.1 中,我们将句子“你好?”分解成了标记['how</w>', 'are</w>', 'you</w>', '?</w>']。按照本小节中的步骤进行操作,(i) 使用字典en_word_dict将标记转换为索引;(ii) 使用字典en_idx_dict将索引转换回标记;(iii) 通过将标记连接成一个字符串,将分隔符'</w>'改为空格,并移除标点符号前的空格来恢复英语句子。

我们可以将相同的步骤应用于法语短语,将标记映射到索引,反之亦然。

列表 10.3 将法语标记映射到索引

fr=df["fr"].tolist()       
fr_tokens=[["BOS"]+tokenizer.tokenize(x)+["EOS"] for x in fr]  ①
word_count=Counter()
for sentence in fr_tokens:
    for word in sentence:
        word_count[word]+=1
frequency=word_count.most_common(50000)                        ②
total_fr_words=len(frequency)+2
fr_word_dict={w[0]:idx+2 for idx,w in enumerate(frequency)}    ③
fr_word_dict["PAD"]=PAD
fr_word_dict["UNK"]=UNK
fr_idx_dict={v:k for k,v in fr_word_dict.items()}              ④

① 将所有法语句子进行标记化

② 统计法语标记的频率

③ 创建一个将法语标记映射到索引的字典

④ 创建一个将索引映射到法语标记的字典

字典fr_word_dict为每个法语标记分配一个整数,而fr_idx_dict将这些整数映射回它们相应的法语标记。接下来,我将演示如何将法语短语“Je ne parle pas français.”转换成其数值表示:

fridx=[fr_word_dict.get(i,UNK) for i in tokenized_fr]   
print(fridx)

前一个代码片段的输出结果是

[28, 40, 231, 32, 726, 370, 4]

法语短语“Je ne parle pas français.”的标记被转换成一系列整数,如下所示。

我们可以使用字典fr_idx_dict将数值表示转换回法语标记。这涉及到将序列中的每个数字转换回字典中相应的法语标记。一旦检索到标记,它们就可以被连接起来以重建原始的法语短语。以下是完成方式:

frtokens=[fr_idx_dict.get(i,"UNK") for i in fridx] 
print(frtokens)
fr_phrase="".join(frtokens)
fr_phrase=fr_phrase.replace("</w>"," ")
for x in '''?:;.,'("-!&)%''':
    fr_phrase=fr_phrase.replace(f" {x}",f"{x}")  
print(fr_phrase)

前一个代码块输出的结果是

['je</w>', 'ne</w>', 'parle</w>', 'pas</w>', 'franc', 'ais</w>', '.</w>']
je ne parle pas francais. 

重要的是要认识到恢复的法语短语并不完全匹配其原始形式。这种差异是由于标记化过程,该过程将所有大写字母转换为小写,并消除了法语中的重音符号。

练习 10.2

在列表 10.1 中,我们将句子“Comment êtes-vous?”分解为标记['comment</w>', 'et', 'es-vous</w>', '?</w>']。按照本小节中的步骤进行以下操作:(i) 使用字典fr_word_dict将标记转换为索引;(ii) 使用字典fr_idx_dict将索引转换回标记;(iii) 通过将标记连接成一个字符串来恢复法语文句,将分隔符'</w>'更改为空格,并删除标点符号前的空格。

将四个字典保存在您的计算机上的文件夹/files/中,以便您可以加载它们并在稍后开始翻译,而无需担心首先将标记映射到索引,反之亦然:

import pickle

with open("files/dict.p","wb") as fb:
    pickle.dump((en_word_dict,en_idx_dict,
                 fr_word_dict,fr_idx_dict),fb)

现在四个字典已保存为单个 pickle 文件dict.p。或者,您也可以从本书的 GitHub 仓库下载该文件。

10.1.2 序列填充和批次创建

我们将在训练期间将训练数据划分为批次以提高计算效率和加速收敛,正如我们在前面的章节中所做的那样。

为其他数据格式(如图像)创建批次是直接的:只需将特定数量的输入分组形成一个批次,因为它们都具有相同的大小。然而,在自然语言处理中,由于句子长度的不同,批次可能会更复杂。为了在批次内标准化长度,我们填充较短的序列。这种一致性至关重要,因为输入到 Transformer 中的数值表示需要具有相同的长度。例如,一个批次中的英文短语长度可能不同(这也可能发生在批次中的法语文句)。为了解决这个问题,我们在批次中较短的短语的数值表示的末尾添加零,确保所有输入到 Transformer 模型中的长度都相等。

注意,在机器翻译中,在每句话的开始和结尾加入BOSEOS标记,以及在一个批次中对较短的序列进行填充,这是一个显著的特征。这种区别源于输入由整个句子或短语组成。相比之下,正如你将在下一章中看到的,训练一个文本生成模型不需要这些过程;模型的输入包含一个预定的标记数量。

我们首先将所有英文短语转换为它们的数值表示,然后对法语文句应用相同的过程:

out_en_ids=[[en_word_dict.get(w,UNK) for w in s] for s in en_tokens]
out_fr_ids=[[fr_word_dict.get(w,UNK) for w in s] for s in fr_tokens]
sorted_ids=sorted(range(len(out_en_ids)),
                  key=lambda x:len(out_en_ids[x]))
out_en_ids=[out_en_ids[x] for x in sorted_ids]
out_fr_ids=[out_fr_ids[x] for x in sorted_ids]

接下来,我们将数值表示放入批次进行训练:

import numpy as np

batch_size=128
idx_list=np.arange(0,len(en_tokens),batch_size)
np.random.shuffle(idx_list)

batch_indexs=[]
for idx in idx_list:
    batch_indexs.append(np.arange(idx,min(len(en_tokens),
                                          idx+batch_size)))

注意,我们在将观察结果放入批次之前,已经根据英文短语的长度对训练数据集中的观察结果进行了排序。这种方法确保了每个批次中的观察结果具有可比的长度,从而减少了填充的需要。因此,这种方法不仅减少了训练数据的总体大小,还加速了训练过程。

为了将批次中的序列填充到相同的长度,我们定义了以下函数:

def seq_padding(X, padding=0):
    L = [len(x) for x in X]
    ML = max(L)                                                   ①
    padded_seq = np.array([np.concatenate([x, [padding] * (ML - len(x))])
        if len(x) < ML else x for x in X])                        ②
    return padded_seq

① 找出批次中最长序列的长度。

② 如果批次比最长的序列短,则在序列末尾添加 0。

函数 seq_padding() 首先在批次中识别最长的序列。然后,它将零添加到较短的序列的末尾,以确保批次中的每个序列都与这个最大长度匹配。

为了节省空间,我们在本地模块 ch09util.py 中创建了一个 Batch() 类,您在上一个章节中已下载(见图 10.1)。

列表 10.4 在本地模块中创建一个 Batch()

import torch
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
class Batch:
    def __init__(self, src, trg=None, pad=0):
        src = torch.from_numpy(src).to(DEVICE).long()
        self.src = src
        self.src_mask = (src != pad).unsqueeze(-2)              ①
        if trg is not None:
            trg = torch.from_numpy(trg).to(DEVICE).long()
            self.trg = trg[:, :-1]                              ②
            self.trg_y = trg[:, 1:]                             ③
            self.trg_mask = make_std_mask(self.trg, pad)        ④
            self.ntokens = (self.trg_y != pad).data.sum()

① 创建一个源掩码以隐藏句子末尾的填充

② 为解码器创建输入

③ 将输入向右移动一个标记并将其用作输出

④ 创建一个目标掩码

图 10.1 Batch() 类的作用是什么?Batch() 类接受两个输入:srctrg,分别代表源语言和目标语言的索引序列。它向训练数据添加了几个属性:src_mask,用于隐藏填充的源掩码;modified trg,解码器的输入;trg_y,解码器的输出;trg_mask,用于隐藏填充和未来标记的目标掩码。

Batch() 类处理一批英语和法语短语,将它们转换为适合训练的格式。为了使这个解释更具体,以英语短语“How are you?”及其法语对应短语“Comment êtes-vous?”为例。Batch() 类接收两个输入:src,代表“How are you?”中标记的索引序列,以及 trg,代表“Comment êtes-vous?”中标记的索引序列。这个类生成一个张量 src_mask,用于隐藏句子末尾的填充。例如,句子“How are you?”被分解成六个标记:['BOS', 'how', 'are', 'you', '?', 'EOS']。如果这个序列是长度为八个标记的批次的一部分,则在末尾添加两个零。src_mask 张量指示模型在这种情况下忽略最后的两个标记。

Batch() 类还准备了 Transformer 解码器的输入和输出。以法语短语“Comment êtes-vous?”为例,它被转换成六个标记:['BOS', 'comment', 'et', 'es-vous', '?', 'EOS']。这些前五个标记的索引作为解码器的输入,命名为 trg。接下来,我们将这个输入向右移动一个标记以形成解码器的输出,trg_y。因此,输入包含 ['BOS', 'comment', 'et', 'es-vous', '?'] 的索引,而输出则包含 ['comment', 'et', 'es-vous', '?', 'EOS'] 的索引。这种方法与我们第八章讨论的内容相似,旨在迫使模型根据前面的标记预测下一个标记。

Batch() 类还生成了一个用于解码器输入的掩码,trg_mask。这个掩码的目的是隐藏输入中的后续标记,确保模型仅依赖于先前标记进行预测。这个掩码是由 make_std_mask() 函数生成的,该函数定义在本地模块 ch09util 中:

import numpy as np
def subsequent_mask(size):
    attn_shape = (1, size, size)
    subsequent_mask = np.triu(np.ones(attn_shape),k=1).astype('uint8')
    output = torch.from_numpy(subsequent_mask) == 0
    return output
def make_std_mask(tgt, pad):
    tgt_mask=(tgt != pad).unsqueeze(-2)
    output=tgt_mask & subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data)
    return output 

subsequent_mask() 函数为序列生成一个特定的掩码,指示模型仅关注实际序列,并忽略末尾的填充零,这些填充零仅用于标准化序列长度。另一方面,make_std_mask() 函数构建了一个针对目标序列的标准掩码。这个标准掩码具有双重作用,即隐藏填充零和目标序列中的后续标记。

接下来,我们导入 Batch() 类从本地模块,并使用它来创建训练数据批次:

from utils.ch09util import Batch

class BatchLoader():
    def __init__(self):
        self.idx=0
    def __iter__(self):
        return self
    def __next__(self):
        self.idx += 1
        if self.idx<=len(batch_indexs):
            b=batch_indexs[self.idx-1]
            batch_en=[out_en_ids[x] for x in b]
            batch_fr=[out_fr_ids[x] for x in b]
            batch_en=seq_padding(batch_en)
            batch_fr=seq_padding(batch_fr)
            return Batch(batch_en,batch_fr)
        raise StopIteration

BatchLoader() 类创建用于训练的数据批次。列表中的每个批次包含 128 对,其中每对包含一个英语短语及其对应的法语翻译的数值表示。

10.2 词嵌入和位置编码

在上一节进行分词之后,英语和法语短语被表示为一系列的索引。在本节中,您将使用词嵌入将这些索引(本质上是一维热向量)转换为紧凑的向量表示。这样做可以捕捉短语中标记的语义信息和相互关系。词嵌入还可以提高训练效率:与庞大的热向量相比,词嵌入使用连续的、低维向量来减少模型的复杂性和维度。

注意力机制同时处理短语中的所有标记,而不是按顺序处理。这提高了其效率,但本身并不允许它识别标记的序列顺序。因此,我们将通过使用不同频率的正弦和余弦函数,将位置编码添加到输入嵌入中。

10.2.1 词嵌入

英语和法语短语的数值表示涉及大量的索引。为了确定每种语言所需的唯一索引的确切数量,我们可以计算 en_word_dictfr_word_dict 字典中唯一元素的数量。这样做会生成每种语言词汇表中唯一标记的总数(我们将在后面将它们作为 Transformer 的输入使用):

src_vocab = len(en_word_dict)
tgt_vocab = len(fr_word_dict)
print(f"there are {src_vocab} distinct English tokens")
print(f"there are {tgt_vocab} distinct French tokens")

输出是

there are 11055 distinct English tokens
there are 11239 distinct French tokens

在我们的数据集中,有 11,055 个唯一的英语标记和 11,239 个唯一的法语标记。对这些使用一维热编码会导致训练时参数数量过高。为了解决这个问题,我们将采用词嵌入,它将数值表示压缩成连续的向量,每个向量的长度为 d_model = 256

这是通过使用定义在本地模块 ch09util 中的 Embeddings() 类来实现的:

import math

class Embeddings(nn.Module):
    def __init__(self, d_model, vocab):
        super().__init__()
        self.lut = nn.Embedding(vocab, d_model)
        self.d_model = d_model

    def forward(self, x):
        out = self.lut(x) * math.sqrt(self.d_model)
        return out

之前定义的 Embeddings() 类使用了 PyTorch 的 Embedding() 类。它还将输出乘以 d_model 的平方根,即 256。这种乘法是为了抵消在计算注意力分数过程中发生的除以 d_model 的平方根。Embeddings() 类降低了英语和法语短语的数值表示的维度。我们在第八章详细讨论了 PyTorch 的 Embedding() 类是如何工作的。

10.2.2 位置编码

为了准确表示输入和输出中元素序列的顺序,我们在本地模块中引入了 PositionalEncoding() 类。

列表 10.5 计算位置编码的类

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout, max_len=5000):       ①
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)
        pe = torch.zeros(max_len, d_model, device=DEVICE)
        position = torch.arange(0., max_len, 
                                device=DEVICE).unsqueeze(1)
        div_term = torch.exp(torch.arange(
            0., d_model, 2, device=DEVICE)
            * -(math.log(10000.0) / d_model))
        pe_pos = torch.mul(position, div_term)
        pe[:, 0::2] = torch.sin(pe_pos)                        ②
        pe[:, 1::2] = torch.cos(pe_pos)                        ③
        pe = pe.unsqueeze(0)
        self.register_buffer('pe', pe)  

    def forward(self, x):
        x=x+self.pe[:,:x.size(1)].requires_grad_(False)        ④
        out=self.dropout(x)
        return out

① 初始化类,允许最大 5,000 个位置

② 将正弦函数应用于向量的偶数索引

③ 将余弦函数应用于向量的奇数索引

④ 将位置编码添加到词嵌入中

PositionalEncoding() 类使用正弦函数对偶数索引进行编码,使用余弦函数对奇数索引进行编码来生成序列位置的向量。需要注意的是,在 PositionalEncoding() 类中,包含了 requires_grad_(False) 参数,因为这些值不需要进行训练。它们在所有输入中保持不变,并且在训练过程中不会改变。

例如,来自英语短语 ['BOS', 'how', 'are', 'you', '?', 'EOS'] 的六个标记的索引首先通过一个词嵌入层进行处理。这一步将这些索引转换为一个维度为 (1, 6, 256) 的张量:1 表示批处理中只有一个序列;6 表示序列中有 6 个标记;256 表示每个标记由一个 256 价值的向量表示。在完成词嵌入过程后,PositionalEncoding() 类被用来计算对应于标记 ['BOS', 'how', 'are', 'you', '?', 'EOS'] 的索引的位置编码。这样做是为了向模型提供有关每个标记在序列中位置的信息。更好的是,我们可以通过以下代码块告诉你前六个标记的位置编码的确切值:

from utils.ch09util import PositionalEncoding
import torch
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"

pe = PositionalEncoding(256, 0.1)                              ①
x = torch.zeros(1, 8, 256).to(DEVICE)                          ②
y = pe.forward(x)                                              ③
print(f"the shape of positional encoding is {y.shape}")
print(y)                                                       ④

① 实例化 PositionalEncoding() 类并将模型维度设置为 256

② 创建一个词嵌入并将其填充为零

③ 通过向词嵌入添加位置编码来计算输入嵌入

④ 打印出输入嵌入,由于词嵌入被设置为零,因此与位置编码相同

我们首先创建一个 PositionalEncoding() 类的实例 pe,将模型维度设置为 256,并将 dropout 率设置为 0.1。由于这个类的输出是词嵌入和位置编码的和,我们创建一个填充为零的词嵌入并将其输入到 pe 中:这样输出就是位置编码。

运行前面的代码块后,你会看到以下输出:

the shape of positional encoding is torch.Size([1, 8, 256])
tensor([[[ 0.0000e+00,  1.1111e+00,  0.0000e+00,  ...,  0.0000e+00,
           0.0000e+00,  1.1111e+00],
         [ 9.3497e-01,  6.0034e-01,  8.9107e-01,  ...,  1.1111e+00,
           1.1940e-04,  1.1111e+00],
         [ 0.0000e+00, -4.6239e-01,  1.0646e+00,  ...,  1.1111e+00,
           2.3880e-04,  1.1111e+00],
         ...,
         [-1.0655e+00,  3.1518e-01, -1.1091e+00,  ...,  1.1111e+00,
           5.9700e-04,  1.1111e+00],
         [-3.1046e-01,  1.0669e+00, -0.0000e+00,  ...,  0.0000e+00,
           7.1640e-04,  1.1111e+00],
         [ 7.2999e-01,  8.3767e-01,  2.5419e-01,  ...,  1.1111e+00,
           8.3581e-04,  1.1111e+00]]], device='cuda:0')

前面的张量表示了英语短语“你好吗?”的位置编码。重要的是要注意,这个位置编码也有(1, 6, 256)的维度,这与“你好吗?”的词嵌入大小相匹配。下一步是将词嵌入和位置编码组合成一个单独的张量。

位置编码的一个基本特征是,无论输入序列是什么,它们的值都是相同的。这意味着无论具体的输入序列是什么,第一个标记的位置编码始终是相同的 256 值向量,如上输出所示,即[0.0000e+00, 1.1111e+00, ..., 1.1111e+00]。同样,第二个标记的位置编码始终是[9.3497e-01, 6.0034e-01, ..., 1.1111e+00],依此类推。它们的值在训练过程中也不会改变。

10.3 训练英语到法语翻译的 Transformer

我们构建的英语到法语翻译模型可以被视为一个多类别分类器。核心目标是预测在翻译英语句子时法语词汇中的下一个标记。这与我们在第二章讨论的图像分类项目有些相似,尽管这个模型要复杂得多。这种复杂性需要仔细选择损失函数、优化器和训练循环参数。

在本节中,我们将详细说明选择合适的损失函数和优化器的过程。我们将使用英语到法语翻译的批次作为我们的训练数据集来训练 Transformer。在模型训练完成后,你将学习如何将常见的英语短语翻译成法语。

10.3.1 损失函数和优化器

首先,我们从本地模块 ch09util.py 中导入create_model()函数,构建一个 Transformer,以便我们可以训练它将英语翻译成法语:

from utils.ch09util import create_model

model = create_model(src_vocab, tgt_vocab, N=6,
    d_model=256, d_ff=1024, h=8, dropout=0.1)

论文“Attention Is All You Need”在构建模型时使用了各种超参数的组合。在这里,我们选择了一个维度为 256,8 个头的模型,因为我们发现这个组合在我们的设置中在将英语翻译成法语方面做得很好。感兴趣的读者可以使用验证集来调整超参数,以选择他们自己项目中的最佳模型。

我们将遵循原始论文“Attention Is All You Need”并在训练过程中使用标签平滑。标签平滑通常在训练深度神经网络时使用,以提高模型的泛化能力。它用于解决过自信问题(预测概率大于真实概率)和分类中的过拟合问题。具体来说,它通过调整目标标签来修改模型的学习方式,旨在降低模型对训练数据的信心,这可能导致在未见数据上的更好性能。

在一个典型的分类任务中,目标标签以单热编码格式表示。这种表示意味着对每个训练样本标签正确性的绝对确定性。使用绝对确定性进行训练可能导致两个主要问题。第一个是过拟合:模型对其预测过于自信,过于紧密地拟合训练数据,这可能会损害其在新、未见数据上的性能。第二个问题是校准不良:以这种方式训练的模型通常输出过自信的概率。例如,它们可能会为正确类别输出 99%的概率,而实际上,这种信心应该更低。

标签平滑调整目标标签以降低其信心。对于一个三分类问题,你可能会得到类似[0.9, 0.05, 0.05]的目标标签。这种方法通过惩罚过自信的输出,鼓励模型不要对其预测过于自信。平滑后的标签是原始标签和一些其他标签分布(通常是均匀分布)的混合。

我们在本地模块 ch09util 中定义了以下LabelSmoothing()类。

列表 10.6 用于执行标签平滑的类

class LabelSmoothing(nn.Module):
    def __init__(self, size, padding_idx, smoothing=0.1):
        super().__init__()
        self.criterion = nn.KLDivLoss(reduction='sum')  
        self.padding_idx = padding_idx
        self.confidence = 1.0 - smoothing
        self.smoothing = smoothing
        self.size = size
        self.true_dist = None
    def forward(self, x, target):
        assert x.size(1) == self.size
        true_dist = x.data.clone()                              ①
        true_dist.fill_(self.smoothing / (self.size - 2))
        true_dist.scatter_(1, 
               target.data.unsqueeze(1), self.confidence)       ②
        true_dist[:, self.padding_idx] = 0
        mask = torch.nonzero(target.data == self.padding_idx)
        if mask.dim() > 0:
            true_dist.index_fill_(0, mask.squeeze(), 0.0)
        self.true_dist = true_dist
        output = self.criterion(x, true_dist.clone().detach())  ③
        return output

① 从模型中提取预测值

② 从训练数据中提取实际标签并向其添加噪声

③ 在计算损失时使用平滑后的标签作为目标

LabelSmoothing()类首先从模型中提取预测值。然后,它通过添加噪声来平滑训练数据集中的实际标签。参数smoothing控制我们向实际标签注入多少噪声。例如,如果你设置smoothing=0.1,标签[1, 0, 0]将被平滑为[0.9, 0.05, 0.05];如果你设置smoothing=0.05,它将被平滑为[0.95, 0.025, 0.025]。然后,该类通过比较预测值与平滑后的标签来计算损失。

与前几章一样,我们使用的优化器是 Adam 优化器。然而,我们不是在整个训练过程中使用恒定的学习率,而是在本地模块中定义了NoamOpt()类来在训练过程中改变学习率:

class NoamOpt:
    def __init__(self, model_size, factor, warmup, optimizer):
        self.optimizer = optimizer
        self._step = 0
        self.warmup = warmup                                  ①
        self.factor = factor
        self.model_size = model_size
        self._rate = 0
    def step(self):                                           ②
        self._step += 1
        rate = self.rate()
        for p in self.optimizer.param_groups:
            p['lr'] = rate
        self._rate = rate
        self.optimizer.step()
    def rate(self, step=None):
        if step is None:
            step = self._step
        output = self.factor * (self.model_size ** (-0.5) *
        min(step ** (-0.5), step * self.warmup ** (-1.5)))    ③
        return output

① 定义预热步骤

② 一个step()方法,用于将优化器应用于调整模型参数

③ 根据步骤计算学习率

如前所述的NoamOpt()类实现了预热学习率策略。首先,它在训练的初始预热步骤中线性增加学习率。在此预热期之后,该类随后降低学习率,按训练步骤数的倒数平方进行调整。

接下来,我们创建用于训练的优化器:

from utils.ch09util import NoamOpt

optimizer = NoamOpt(256, 1, 2000, torch.optim.Adam(
    model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))

为了定义训练的损失函数,我们首先在本地模块中创建了以下SimpleLossCompute()类。

列表 10.7 用于计算损失的类

class SimpleLossCompute:
    def __init__(self, generator, criterion, opt=None):
        self.generator = generator
        self.criterion = criterion
        self.opt = opt
    def __call__(self, x, y, norm):
        x = self.generator(x)                                    ①
        loss = self.criterion(x.contiguous().view(-1, x.size(-1)),
                              y.contiguous().view(-1)) / norm    ②
        loss.backward()                                          ③
        if self.opt is not None:
            self.opt.step()                                      ④
            self.opt.optimizer.zero_grad()
        return loss.data.item() * norm.float()

① 使用模型进行预测

② 比较预测值与标签以计算损失,利用标签平滑

③ 计算相对于模型参数的梯度

④ 调整模型参数(反向传播)

SimpleLossCompute() 类设计有三个关键元素:generator 作为预测模型;criterion,这是一个计算损失的功能;以及 opt,优化器。这个类通过利用生成器进行预测来处理一个批次的训练数据,表示为 (x, y)。随后,它通过比较这些预测与实际标签 y(由之前定义的 LabelSmoothing() 类处理;实际标签 y 将在过程中进行平滑)来评估损失。该类计算相对于模型参数的梯度,并利用优化器相应地更新这些参数。

我们现在可以定义损失函数:

from utils.ch09util import (LabelSmoothing,
       SimpleLossCompute)

criterion = LabelSmoothing(tgt_vocab, 
                           padding_idx=0, smoothing=0.1)
loss_func = SimpleLossCompute(
            model.generator, criterion, optimizer)

接下来,我们将使用本章前面准备的数据来训练 Transformer。

10.3.2 训练循环

我们可以将训练数据分成训练集和验证集,并训练模型,直到模型在验证集上的性能不再提高,这与我们在第二章中所做的一样。然而,为了节省空间,我们将训练模型 100 个周期。我们将计算每个批次的损失和标记数。在每个周期之后,我们计算该周期的平均损失,作为总损失与总标记数的比率。

列表 10.8 训练一个 Transformer 将英语翻译成法语

for epoch in range(100):
    model.train()
    tloss=0
    tokens=0
    for batch in BatchLoader():
        out = model(batch.src, batch.trg, 
                    batch.src_mask, batch.trg_mask)            ①
        loss = loss_func(out, batch.trg_y, batch.ntokens)      ②
        tloss += loss
        tokens += batch.ntokens                                ③
    print(f"Epoch {epoch}, average loss: {tloss/tokens}")
torch.save(model.state_dict(),"files/en2fr.pth")               ④

① 使用 Transformer 进行预测

② 计算损失并调整模型参数

③ 计算批次的标记数

④ 训练后保存训练模型的权重

如果你使用的是支持 CUDA 的 GPU,这个过程可能需要几个小时。如果你使用 CPU 训练,可能需要整整一天。一旦训练完成,模型权重将保存在你的电脑上作为 en2fr.pth。或者,你也可以从我的网站上下载训练好的权重(gattonweb.uky.edu/faculty/lium/gai/ch9.zip)。

10.4 使用训练好的模型将英语翻译成法语

现在你已经训练了 Transformer,你可以用它将任何英文句子翻译成法语。我们定义了一个名为 translate() 的函数,如下所示。

列表 10.9 定义一个 translate() 函数以将英语翻译成法语

def translate(eng):
    tokenized_en=tokenizer.tokenize(eng)
    tokenized_en=["BOS"]+tokenized_en+["EOS"]
    enidx=[en_word_dict.get(i,UNK) for i in tokenized_en]  
    src=torch.tensor(enidx).long().to(DEVICE).unsqueeze(0)    
    src_mask=(src!=0).unsqueeze(-2)
    memory=model.encode(src,src_mask)                           ①
    start_symbol=fr_word_dict["BOS"]
    ys = torch.ones(1, 1).fill_(start_symbol).type_as(src.data)
    translation=[]
    for i in range(100):
        out = model.decode(memory,src_mask,ys,
        subsequent_mask(ys.size(1)).type_as(src.data))          ②
        prob = model.generator(out[:, -1])
        _, next_word = torch.max(prob, dim=1)
        next_word = next_word.data[0]    
        ys = torch.cat([ys, torch.ones(1, 1).type_as(
            src.data).fill_(next_word)], dim=1)
        sym = fr_idx_dict[ys[0, -1].item()]
        if sym != 'EOS':
            translation.append(sym)
        else:
            break                                               ③
    trans="".join(translation)
    trans=trans.replace("</w>"," ") 
    for x in '''?:;.,'("-!&)%''':
        trans=trans.replace(f" {x}",f"{x}")                     ④
    print(trans) 
    return trans

① 使用编码器将英文短语转换为向量表示

② 使用解码器预测下一个标记

③ 当下一个标记是“EOS”时停止翻译

④ 将预测的标记连接起来形成一个法语句子

要将英语短语翻译成法语,我们首先使用分词器将英语句子转换为标记。然后,我们在短语的开始和结束处添加"BOS""EOS"。我们使用本章前面创建的en_word_dict字典将标记转换为索引。我们将索引序列输入到训练好的模型的编码器中。编码器产生一个抽象向量表示,并将其传递给解码器。

根据编码器产生的英语句子的抽象向量表示,训练好的模型中的解码器以自回归的方式开始翻译,从开始标记"BOS"开始。在每个时间步,解码器根据先前生成的标记生成最可能的下一个标记,直到预测的标记是"EOS",这标志着句子的结束。注意,这与第八章中讨论的文本生成方法略有不同,在那里下一个标记是随机选择的,根据其预测概率。在这里,选择下一个标记的方法是确定性的,这意味着我们主要关注准确性,因此我们选择概率最高的标记。然而,如果您希望翻译具有创造性,您可以像第八章中那样切换到随机预测,并使用top-K采样和温度。

最后,我们将标记分隔符更改为空格,并移除标点符号前的空格。输出结果是格式整洁的法语翻译。

让我们尝试使用translate()函数翻译英语短语“今天是个美好的一天!”:

from utils.ch09util import subsequent_mask

with open("files/dict.p","rb") as fb:
    en_word_dict,en_idx_dict,\
    fr_word_dict,fr_idx_dict=pickle.load(fb)
trained_weights=torch.load("files/en2fr.pth",
                           map_location=DEVICE)
model.load_state_dict(trained_weights)
model.eval()
eng = "Today is a beautiful day!"
translated_fr = translate(eng)

输出结果是

aujourd'hui est une belle journee!

您可以通过使用,比如说,谷歌翻译来验证法语翻译确实意味着“今天是个美好的一天!”

让我们尝试一个更长的句子,看看训练好的模型是否能够成功翻译:

eng = "A little boy in jeans climbs a small tree while another child looks on."
translated_fr = translate(eng)

输出结果是

un petit garcon en jeans grimpe un petit arbre tandis qu'un autre enfant regarde. 

当我用谷歌翻译将前面的输出翻译回英语时,它说,“一个穿牛仔裤的小男孩爬上一棵小树,另一个孩子在一旁观看”——并不完全与原始的英语句子相同,但意思是一样的。

接下来,我们将测试训练好的模型是否为两个英语句子“我不会说法语。”和“I do not speak French.”生成相同的翻译。首先,让我们尝试句子“I don’t speak French.”:

eng = "I don't speak French."
translated_fr = translate(eng)

输出结果是

je ne parle pas francais. 

现在,让我们尝试句子“I do not speak French.”:

eng = "I do not speak French."
translated_fr = translate(eng)

这次输出结果是

je ne parle pas francais. 

结果表明,这两个句子的法语翻译完全相同。这表明 Transformer 的编码器组件成功地把握了这两个短语的语义本质。然后,它将它们表示为相似的抽象连续向量形式,随后传递给解码器。解码器随后根据这些向量生成翻译,并产生相同的结果。

练习 10.3

使用 translate() 函数将以下两个英文句子翻译成法语。将结果与谷歌翻译的结果进行比较,看看它们是否相同:(i) 我喜欢冬天滑雪!(ii) 你好吗?

在本章中,您训练了一个编码器-解码器 Transformer,通过使用超过 47,000 对英法翻译来将英语翻译成法语。训练的模型表现良好,能够正确翻译常见的英语短语!

在接下来的章节中,您将探索仅解码器 Transformer。您将学习从头开始构建它们,并使用它们生成比第八章中使用长短期记忆生成的文本更连贯的文本。

摘要

  • 与处理数据序列的循环神经网络不同,Transformers 并行处理输入数据,例如句子。这种并行性提高了它们的效率,但并不固有地允许它们识别输入的序列顺序。为了解决这个问题,Transformers 将位置编码添加到输入嵌入中。这些位置编码是分配给输入序列中每个位置的独特向量,并在维度上与输入嵌入对齐。

  • 标签平滑在训练深度神经网络时常用,以提高模型的泛化能力。它用于解决过度自信问题(预测概率大于真实概率)和分类中的过拟合问题。具体来说,它通过调整目标标签来修改模型的学习方式,旨在降低模型对训练数据的信心,这可能导致在未见过的数据上表现更好。

  • 基于编码器输出的捕获英语短语意义的输出,训练的 Transformer 中的解码器以自回归的方式开始翻译,从开始标记 "BOS" 开始。在每个时间步,解码器根据先前生成的标记生成最可能的下一个标记,直到预测标记是 "EOS",这表示句子的结束。


^(1) Vaswani 等人,2017,“Attention Is All You Need.” arxiv.org/abs/1706.03762.

第十一章:从零开始构建生成式预训练变换器

本章涵盖

  • 从零开始构建生成式预训练变换器

  • 因果自注意力

  • 从预训练模型中提取和加载权重

  • 使用 GPT-2 生成连贯的文本,ChatGPT 和 GPT-4 的前辈

生成式预训练变换器 2(GPT-2)是由 OpenAI 开发的高级大型语言模型(LLM),于 2019 年 2 月宣布发布。它在自然语言处理(NLP)领域取得了重大里程碑,并为开发更复杂的模型铺平了道路,包括其继任者 ChatGPT 和 GPT-4。

GPT-2 是其前辈 GPT-1 的改进,旨在根据给定的提示生成连贯且上下文相关的文本,展示了在多种风格和主题上模仿人类文本生成的非凡能力。在其宣布时,OpenAI 最初决定不向公众发布 GPT-2 最强大的版本(也是本章中你将从头开始构建的,拥有 150 亿参数)。主要担忧是潜在的误用,例如生成误导性新闻文章、在线冒充个人或自动化生产侮辱性或虚假内容。这一决定在 AI 和科技社区中引发了关于 AI 开发伦理和创新与安全之间平衡的激烈辩论。

OpenAI 后来采用了分阶段发布策略,逐步使模型的小版本可用,同时监控效果并探索安全部署策略。最终,在 2019 年 11 月,OpenAI 发布了完整模型,以及几个数据集和一个检测模型生成文本的工具,为负责任的 AI 使用讨论做出了贡献。正因为这次发布,你将学习如何从 GPT-2 中提取预训练权重并将它们加载到你创建的 GPT-2 模型中。

GPT-2 基于我们在第九章和第十章讨论的变换器架构。然而,与之前创建的英法翻译器不同,GPT-2 是一个仅解码器的变换器,这意味着模型中没有编码器堆栈。在将英语短语翻译成法语时,编码器捕捉英语短语的含义并将其传递给解码器以生成翻译。然而,在文本生成任务中,模型不需要编码器来理解不同的语言。相反,它仅使用解码器架构根据句子中的前一个标记生成文本。与其他变换器模型一样,GPT-2 使用自注意力机制并行处理输入数据,显著提高了训练 LLM 的效率和效果。

GPT-2 在大量文本数据语料库上进行了预训练,本质上是在给定句子中前一个词的情况下预测句子中的下一个词。这种训练使模型能够学习广泛的语言模式、语法和知识。

在本章中,你将从零开始学习构建 GPT-2XL,这是 GPT-2 的最大版本。之后,你将学习如何从 Hugging Face(一个托管和协作机器学习模型、数据集和应用的 AI 社区)中提取预训练的权重并将它们加载到自己的 GPT-2 模型中。你将通过向模型提供提示来使用你的 GPT-2 生成文本。GPT-2 计算可能下一个标记的概率并从中采样。它可以根据接收到的输入提示生成连贯且与上下文相关的段落文本。此外,正如你在第八章中所做的那样,你可以通过使用 temperaturetop-K 采样来控制生成文本的创造性。

虽然 GPT-2 在自然语言处理领域取得了显著的进步,但调整你的期望并认识到其固有的局限性是至关重要的。直接将 GPT-2 与 ChatGPT 或 GPT-4 进行比较是不恰当的,因为 GPT-2XL 只有 15 亿个参数,而 ChatGPT 有 1750 亿个参数,GPT-4 的估计参数量为 1.76 万亿。GPT-2 的主要局限性之一是它对其生成的内容的真正理解不足。该模型根据其训练数据中单词的概率分布预测序列中的下一个单词,这可以生成语法正确且看似合逻辑的文本。然而,该模型缺乏对词语背后含义的真正理解,可能导致潜在的不准确、无意义的陈述或肤浅的内容。

另一个关键因素是 GPT-2 的有限上下文意识。虽然它可以在短文本跨度内保持连贯性,但在较长的段落中会遇到困难,可能导致连贯性丧失、矛盾或不相关的内容。我们应谨慎不要高估模型生成需要持续关注上下文和细节的长篇内容的能力。因此,虽然 GPT-2 在自然语言处理领域迈出了重要的一步,但以健康程度的怀疑态度对待其生成的文本并设定现实期望是非常重要的。

11.1 GPT-2 架构和因果自注意力

GPT-2 作为仅基于解码器的 Transformer(它根据句子中的先前标记生成文本,无需编码器理解不同语言),与第九章和第十章中讨论的英法翻译器的解码器组件相呼应。与它的双语版本不同,GPT-2 缺少编码器,因此在输出生成过程中不包含编码器派生的输入。该模型完全依赖于序列中的先前标记来生成其输出。

在本节中,我们将讨论 GPT-2 的架构。我们还将深入了解因果自注意力机制,这是 GPT-2 模型的核心。

11.1.1 GPT-2 的架构

GPT-2 有四种不同的尺寸:小(S)、中(M)、大(L)和超大(XL),每种尺寸的能力各不相同。我们的主要关注点将是功能最强大的版本,即 GPT-2XL。最小的 GPT-2 模型大约有 124 百万个参数,而超大版本则有大约 15 亿个参数。它是 GPT-2 模型中最强大的,拥有最多的参数。GPT-2XL 能够理解复杂语境,生成连贯且细腻的文本。

GPT-2 由许多相同的解码器块组成。超大版本有 48 个解码器块,而其他三个版本分别有 12、24 和 36 个解码器块。每个解码器块由两个不同的子层组成。第一个子层是一个因果自注意力层,我将在不久的将来详细解释。第二个子层是一个基本的、位置相关的、全连接的前馈网络,正如我们在英语到法语翻译器中的编码器和解码器块中所看到的。每个子层都包含层归一化和残差连接,以稳定训练过程。

图 11.1 是 GPT-2 架构的示意图。

图片

图 11.1 GPT-2 模型的架构。GPT-2 是一个仅包含解码器的 Transformer,由 N 个相同的解码器层组成。每个解码器块包含两个子层。第一个子层是一个因果自注意力层。第二个是一个前馈网络。每个子层都使用层归一化和残差连接。输入首先通过词嵌入和位置编码,然后将总和传递给解码器。解码器的输出经过层归一化和线性层。

GPT-2 首先将一系列标记的索引通过词嵌入和位置编码传递,以获得输入嵌入(我将在不久的将来解释这一过程)。输入嵌入依次通过 N 个解码器块。之后,输出通过层归一化和线性层。GPT-2 的输出数量是词汇表中的唯一标记数量(所有 GPT-2 版本共有 50,257 个标记)。该模型旨在根据序列中的所有前一个标记预测下一个标记。

为了训练 GPT-2,OpenAI 使用了一个名为 WebText 的数据集,该数据集是从互联网上自动收集的。该数据集包含各种文本,包括 Reddit 链接等高度点赞的网站,旨在涵盖广泛的人类语言和主题。估计该数据集包含大约 40GB 的文本。

训练数据被分解成固定长度的序列(所有 GPT-2 版本的长度为 1,024 个标记)并用作输入。这些序列向右移动一个标记,并在训练过程中用作模型的输出。由于模型使用因果自注意力,其中序列中的未来标记在训练过程中被屏蔽(即隐藏),这实际上是在训练模型根据序列中所有之前的标记来预测下一个标记。

11.1.2 GPT-2 中的词嵌入和位置编码

GPT-2 使用一种称为字节对编码器(Byte Pair Encoder,BPE)的子词分词方法将文本分解成单个标记(在大多数情况下是整个单词或标点符号,但对于不常见的单词则是音节)。这些标记随后被映射到 0 到 50,256 之间的索引,因为词汇量大小为 50,257。GPT-2 将训练数据中的文本转换为通过词嵌入捕获其意义的向量表示,这与你在前两章中所做的方式类似。

为了给你一个具体的例子,短语“this is a prompt”首先通过 BPE 分词转换为四个标记,['this', ' is', ' a', ' prompt']。然后每个标记由一个大小为 50,257 的独热变量表示。GPT-2 模型将它们通过词嵌入层压缩成具有更小浮点值大小的压缩向量,例如 GPT-2XL 中的长度为 1,600(其他三个版本的 GPT-2 的长度分别为 768、1,024 和 1,280)。通过词嵌入,短语“this is a prompt”被表示为一个 4 × 1,600 大小的矩阵,而不是原始的 4 × 50,257。词嵌入显著减少了模型参数的数量,并使训练更加高效。图 11.2 的左侧展示了词嵌入的工作原理。

图片

图 11.2 GPT-2 首先将序列中的每个标记表示为一个 50,276 位的独热向量。序列的标记表示通过词嵌入层压缩,形成一个维度为 1,600 的嵌入。GPT-2 还使用一个 1,024 位的独热向量来表示序列中的每个位置。序列的位置表示通过位置编码层压缩,形成一个同样维度为 1,600 的嵌入。词嵌入和位置编码被相加以形成输入嵌入。

GPT-2,与其他 Transformer 类似,并行处理输入数据,这本质上导致它无法识别输入数据的序列顺序。为了解决这个问题,我们需要向输入嵌入中添加位置编码。GPT-2 采用了一种独特的方法来进行位置编码,与 2017 年发表的具有里程碑意义的论文“Attention Is All You Need”中概述的方法不同。相反,GPT-2 的位置编码技术与词嵌入相似。鉴于该模型能够处理输入序列中的最多 1,024 个标记,序列中的每个位置最初由一个同样大小的 one-hot 向量表示。例如,在序列“this is a prompt”中,第一个标记由一个 one-hot 向量表示,其中所有元素都是零,除了第一个,它被设置为 1。第二个标记遵循同样的模式,由一个向量表示,其中除了第二个元素之外的所有元素都是零。因此,“this is a prompt”这个短语的序列表示表现为一个 4 × 1,024 的矩阵,如图 11.2 右上角所示。

为了生成位置编码,序列的位置表示通过一个维度为 1,024 × 1,600 的线性神经网络进行处理。该网络中的权重在初始化时是随机的,并在训练过程中进行优化。因此,序列中每个标记的位置编码是一个 1,600 维的向量,与词嵌入向量的维度相匹配。一个序列的输入嵌入是其词嵌入和位置编码的总和,如图 11.2 底部所示。在短语“this is a prompt”的上下文中,词嵌入和位置编码都结构化为 4 × 1,600 的矩阵。因此,“this is a prompt”的输入嵌入,即这两个矩阵的总和,保持了 4 × 1,600 的维度。

11.1.3 GPT-2 中的因果自注意力

因果自注意力是 GPT-2 模型(以及在 GPT 系列模型中)中的一个关键机制,它使模型能够通过条件化先前生成的标记序列来生成文本。这与我们在第九章和第十章讨论的英语到法语翻译器中每个解码器层第一子层的掩码自注意力相似,尽管实现上略有不同。

注意:在此上下文中,“因果”这一概念指的是模型确保对给定标记的预测只能受到序列中先于它的标记的影响,尊重文本生成的因果(时间向前)方向。这对于生成连贯且上下文相关的文本输出至关重要。

自注意力是一种机制,允许输入序列中的每个标记关注同一序列中的所有其他标记。在 GPT-2 等 Transformer 模型的情况下,自注意力使模型能够在处理特定标记时权衡其他标记的重要性,从而捕捉句子中单词的上下文和关系。

为了确保因果性,GPT-2 的自注意力机制被修改,使得任何给定的标记只能关注它自己和序列中之前出现的标记。这是通过在注意力计算中屏蔽未来标记(即在序列中当前标记之后出现的标记)来实现的,确保模型在预测序列中的下一个标记时不能“看到”或受到未来标记的影响。例如,在短语“this is a prompt”中,当模型使用单词“this”来预测单词“is”时,掩码隐藏了第一次时间步中的最后三个单词。为了实现这一点,我们在计算注意力分数时将对应未来标记的位置设置为负无穷大。在 softmax 激活后,未来标记被分配零权重,从而有效地从注意力计算中移除。

让我们用一个具体的例子来说明因果自注意力在代码中是如何工作的。短语“this is a prompt”的输入嵌入在词嵌入和位置编码之后是一个 4 × 1,600 的矩阵。然后我们通过 GPT-2 的 N 个解码器层传递这个输入嵌入。在每个解码器层中,它首先通过以下因果自注意力子层。输入嵌入通过三个神经网络传递以创建查询 Q、键 K 和值 V,如下所示。

列表 11.1 创建querykeyvalue向量

import torch
import torch.nn as nn

torch.manual_seed(42)
x=torch.randn((1,4,1600))                        ①
c_attn=nn.Linear(1600,1600*3)                    ②
B,T,C=x.size()
q,k,v=c_attn(x).split(1600,dim=2)                ③
print(f"the shape of Q vector is {q.size()}")
print(f"the shape of K vector is {k.size()}")
print(f"the shape of V vector is {v.size()}")    ④

① 创建三个神经网络

② 创建输入嵌入 x

③ 将输入嵌入传递给三个神经网络以创建 Q、K 和 V

④ 打印出 Q、K 和 V 的大小

我们首先创建一个大小为 4 × 1,600 的矩阵,与“this is a prompt”的输入嵌入大小相同。然后我们通过三个大小为 1,600 × 1,600 的神经网络传递输入嵌入,以获得查询 Q、键 K 和值 V。如果您运行前面的代码块,您将看到以下输出:

the shape of Q vector is torch.Size([1, 4, 1600])
the shape of K vector is torch.Size([1, 4, 1600])
the shape of V vector is torch.Size([1, 4, 1600])

Q、K 和 V 的形状都是 4 × 1,600。接下来,我们不是使用一个头,而是将它们分成 25 个并行头。每个头关注输入的不同部分或方面,使模型能够捕捉更广泛的信息,并对输入数据形成更详细和上下文化的理解。因此,我们有了 25 组 Q、K 和 V:

hs=C//25
k = k.view(B, T, 25, hs).transpose(1, 2) 
q = q.view(B, T, 25, hs).transpose(1, 2) 
v = v.view(B, T, 25, hs).transpose(1, 2)         ①
print(f"the shape of Q vector is {q.size()}")
print(f"the shape of K vector is {k.size()}")
print(f"the shape of V vector is {v.size()}")    ②

① 将 Q、K 和 V 分为 25 个头

② 打印出多头 Q、K 和 V 的大小

如果您运行前面的代码块,您将看到以下输出:

the shape of Q vector is torch.Size([1, 25, 4, 64])
the shape of K vector is torch.Size([1, 25, 4, 64])
the shape of V vector is torch.Size([1, 25, 4, 64])

现在,Q、K 和 V 的形状是 25 × 4 × 64:这意味着我们有 25 个头;每个头有一组查询、键和值,大小都是 4 × 64。

接下来,我们计算每个头中的缩放注意力分数:

import math
scaled_att = (q @ k.transpose(-2, -1)) *\
            (1.0 / math.sqrt(k.size(-1)))
print(scaled_att[0,0])

缩放后的注意力分数是每个头部中 Q 和 K 的点积,并按 K 的维度的平方根进行缩放,即 1,600/25 = 64。缩放后的注意力分数在每个头部形成一个 4 × 4 矩阵,我们在第一个头部打印出这些值:

tensor([[ 0.2334,  0.1385, -0.1305,  0.2664],
        [ 0.2916,  0.1044,  0.0095,  0.0993],
        [ 0.8250,  0.2454,  0.0214,  0.8667],
        [-0.1557,  0.2034,  0.2172, -0.2740]], grad_fn=<SelectBackward0>)

第一个头部的缩放注意力分数也显示在图 11.3 底部左边的表格中。

练习 11.1

张量 scaled_att 包含 25 个头部中的缩放注意力分数。我们之前已经打印出了第一个头部的这些值。你是如何打印出第二个头部的缩放注意力分数的?

接下来,我们对缩放后的注意力分数应用一个掩码,以隐藏序列中的未来标记:

mask=torch.tril(torch.ones(4,4))              ①
print(mask)
masked_scaled_att=scaled_att.masked_fill(\
    mask == 0, float('-inf'))                 ②
print(masked_scaled_att[0,0])

① 创建一个掩码

② 通过将未来标记的值更改为 –∞ 来对缩放后的注意力分数应用掩码

图 11.3 如何在因果自注意力中计算掩码注意力权重。掩码应用于缩放后的注意力分数,使得对应未来标记的值(矩阵中主对角线以上的值)变为 –∞。然后我们对掩码后的缩放注意力分数应用 softmax 函数,从而获得掩码注意力权重。掩码确保给定标记的预测只能受到序列中先于它的标记的影响,而不是未来标记的影响。这对于生成连贯且上下文相关的文本输出至关重要。

如果你运行前面的代码,你会看到以下输出:

tensor([[1., 0., 0., 0.],
        [1., 1., 0., 0.],
        [1., 1., 1., 0.],
        [1., 1., 1., 1.]])
tensor([[ 0.2334,    -inf,    -inf,    -inf],
        [ 0.2916,  0.1044,    -inf,    -inf],
        [ 0.8250,  0.2454,  0.0214,    -inf],
        [-0.1557,  0.2034,  0.2172, -0.2740]], grad_fn=<SelectBackward0>)

该掩码是一个 4 × 4 矩阵,如图 11.3 顶部所示。掩码的下半部分(主对角线以下的值)为 1,而掩码的上半部分(主对角线以上的值)为 0。当这个掩码应用于缩放后的注意力分数时,矩阵上半部分的值变为 –∞(图 11.3 中间底部)。这样,当我们对缩放后的注意力分数应用 softmax 函数时,注意力权重矩阵的上半部分被填充为 0(图 11.3 右下角):

import torch.nn.functional as F
att = F.softmax(masked_scaled_att, dim=-1)
print(att[0,0])

我们使用以下值打印出第一个头部的注意力权重:

tensor([[1.0000, 0.0000, 0.0000, 0.0000],
        [0.5467, 0.4533, 0.0000, 0.0000],
        [0.4980, 0.2790, 0.2230, 0.0000],
        [0.2095, 0.3001, 0.3042, 0.1862]], grad_fn=<SelectBackward0>)

第一行表示在第一个时间步,标记“this”只关注自身,而不关注任何未来的标记。同样,如果你看第二行,标记“this is”相互关注,但不关注未来的标记“a prompt”。

注意:在这个数值示例中,权重未经过训练,所以不要将这些值直接理解为注意力权重。我们使用它们作为示例来说明因果自注意力是如何工作的。

练习 11.2

我们已经打印出了第一个头部的注意力权重。你是如何打印出最后一个(即第 25 个)头部的注意力权重的?

最后,我们计算每个头部的注意力向量,它是注意力权重和值向量的点积。然后,将 25 个头部的注意力向量合并为一个单一的注意力向量:

y=att@v
y = y.transpose(1, 2).contiguous().view(B, T, C)
print(y.shape)

输出结果为

torch.Size([1, 4, 1600])

因果自注意力机制后的最终输出是一个 4×1,600 的矩阵,与因果自注意力子层的输入大小相同。解码器层被设计成输入和输出具有相同的维度,这使得我们可以堆叠许多解码器层来增加模型的表示能力,并在训练期间实现层次特征提取。

11.2 从头构建 GPT-2XL

现在你已经了解了 GPT-2 的架构以及其核心成分因果自注意力机制的工作原理,让我们从头开始创建 GPT-2 的最大版本。

在本节中,你将首先学习使用 GPT-2 中的子词分词方法,即字节对编码器(BPE)分词器,将文本分解成单个标记。你还将学习 GPT-2 中前馈网络使用的 GELU 激活函数。之后,你将编写因果自注意力机制,并将其与前馈网络结合形成一个解码器块。最后,你将堆叠 48 个解码器块来创建 GPT-2XL 模型。本章的代码改编自 Andrej Kaparthy 的优秀 GitHub 仓库(github.com/karpathy/minGPT)。如果你想要深入了解 GPT-2 的工作原理,我鼓励你阅读该仓库。

11.2.1 BPE 分词

GPT-2 使用一种称为字节对编码器(BPE)的子词分词方法,这是一种数据压缩技术,已被改编用于 NLP 任务中的文本分词。它因在训练 LLM(如 GPT 系列和 BERT)中的应用而特别知名。BPE 的主要目标是以一种平衡词汇量和分词文本长度的方法将一段文本编码成一系列标记。

BPE 通过迭代地将数据集中最频繁出现的连续字符对合并成一个新的标记来工作,前提是满足某些条件。这个过程会重复进行,直到达到所需的词汇量或没有更多的合并是有益的。BPE 允许对文本进行高效表示,在字符级和词级分词之间取得平衡。它有助于在不显著增加序列长度的同时减少词汇量,这对于 NLP 模型的性能至关重要。

我们在第八章讨论了三种类型分词方法的优缺点(字符级、词级和子词分词)。此外,你还在第八章从头实现了词级分词器(并在第十二章再次这样做)。因此,在本章中,我们将直接借用 OpenAI 的分词方法。BPE 的详细工作原理超出了本书的范围。你需要知道的是,它首先将文本转换为子词标记,然后是相应的索引。

从安德烈·卡帕西(Andrej Karpathy)的 GitHub 仓库下载文件bpe.pymng.bz/861B,并将其放置在您的计算机上的/utils/文件夹中。在本章中,我们将使用该文件作为本地模块。正如安德烈·卡帕西在他的 GitHub 仓库中解释的那样,该模块基于 OpenAI 的实现mng.bz/EOlj,但进行了轻微修改,使其更容易理解。

要了解模块bpe.py如何将文本转换为标记然后转换为索引,让我们尝试一个示例:

from utils.bpe import get_encoder

example="This is the original text."                       ①
bpe_encoder=get_encoder()                                  ②
response=bpe_encoder.encode_and_show_work(example)
print(response["tokens"])                                  ③

① 示例句子的文本

② 从 bpe.py 模块实例化 get_encoder()类

③ 分词示例文本并打印出标记

输出结果为

['This', ' is', ' the', ' original', ' text', '.']

BPE 分词器将示例文本“这是原始文本。”分割成六个标记,如前述输出所示。请注意,BPE 分词器不会将大写字母转换为小写字母。这导致更具有意义的分词,但也导致独特的标记数量大大增加。实际上,所有版本的 GPT-2 模型词汇量大小为 50,276,比前几章的词汇量大几倍。

我们还可以使用bpe.py模块将标记映射到索引:

print(response['bpe_idx'])

输出结果为

[1212, 318, 262, 2656, 2420, 13]

上述列表包含对应于示例文本“这是原始文本。”中的六个标记的六个索引。

我们也可以根据索引恢复文本:

from utils.bpe import BPETokenizer 

tokenizer = BPETokenizer()                                  ①
out=tokenizer.decode(torch.LongTensor(response['bpe_idx'])) ②
print(out) 

① 从 bpe.py 模块实例化 BPETokenizer()类

② 使用分词器根据索引恢复文本

前述代码块输出的结果为

This is the original text.

如您所见,BPE 分词器已将示例文本恢复到其原始形式。

练习 11.3

使用 BPE 分词器将短语“this is a prompt”分割成标记。之后,将标记映射到索引。最后,根据索引恢复短语。

11.2.2 高斯误差线性单元激活函数

高斯误差线性单元(GELU)激活函数用于 GPT-2 中每个解码器块的馈送前子层。GELU 提供了一种线性和非线性激活特性的混合,这在深度学习任务中已被发现可以增强模型性能,尤其是在 NLP 领域。

GELU 提供了一个非线性、平滑的曲线,与像 ReLU 这样的其他函数相比,在训练期间允许进行更细微的调整。这种平滑性有助于更有效地优化神经网络,因为它为反向传播提供了更连续的梯度。为了比较 GELU 与我们的首选激活函数 ReLU,我们首先定义一个 GELU()类:

class GELU(nn.Module):
    def forward(self, x):
        return 0.5*x*(1.0+torch.tanh(math.sqrt(2.0/math.pi)*\
                       (x + 0.044715 * torch.pow(x, 3.0))))

ReLU 函数在它有尖角的地方不可微分。相比之下,GELU 激活函数在所有地方都是可微分的,并提供了一个更好的学习过程。接下来,我们绘制 GELU 激活函数的图像,并与 ReLU 进行比较。

列表 11.2 比较两个激活函数:GELU 和 ReLU

import matplotlib.pyplot as plt
import numpy as np

genu=GELU()
def relu(x):                                           ①
    y=torch.zeros(len(x))
    for i in range(len(x)):
        if x[i]>0:
            y[i]=x[i]
    return y                 
xs = torch.linspace(-6,6,300)
ys=relu(xs)
gs=genu(xs)
fig, ax = plt.subplots(figsize=(6,4),dpi=300)
plt.xlim(-3,3)
plt.ylim(-0.5,3.5)
plt.plot(xs, ys, color='blue', label="ReLU")           ②
plt.plot(xs, gs, "--", color='red', label="GELU")      ③
plt.legend(fontsize=15)
plt.xlabel("values of x")
plt.ylabel("values of $ReLU(x)$ and $GELU(x)$")
plt.title("The ReLU and GELU Activation Functions")
plt.show()

① 定义一个表示 ReLU 的函数

② 用实线绘制 ReLU 激活函数

③ 用虚线绘制 GELU 激活函数

如果你运行前面的代码块,你会看到一个如图 11.4 所示的图形。

图片

图 11.4 比较 GELU 激活函数与 ReLU。实线是 ReLU 激活函数,而虚线是 GELU 激活函数。ReLU 在某个地方有拐角,因此不是处处可导。相比之下,GELU 在所有地方都是可导的。GELU 的这种平滑性有助于更有效地优化神经网络,因为它在训练过程中为反向传播提供了更连续的梯度。

此外,GELU 公式的制定使其能够更有效地模拟输入数据分布。它结合了线性和高斯分布建模的特性,这对于在 NLP 任务中遇到的复杂、多变的数据特别有益。这种能力有助于捕捉语言数据中的微妙模式,提高模型对文本的理解和生成。

11.2.3 因果自注意力

如我们之前所解释的,因果自注意力是 GPT-2 模型的核心元素。接下来,我们将从头开始在 PyTorch 中实现这一机制。

我们首先指定本章将要构建的 GPT-2XL 模型中的超参数。为此,我们定义了一个Config()类,其值如下所示。

列表 11.3 在 GPT-2XL 中指定超参数

class Config():                                       ①
    def __init__(self):
        self.n_layer = 48
        self.n_head = 25
        self.n_embd = 1600
        self.vocab_size = 50257
        self.block_size = 1024 
        self.embd_pdrop = 0.1 
        self.resid_pdrop = 0.1 
        self.attn_pdrop = 0.1                         ②

config=Config()                                       ③

① 定义一个 Config()类

② 将模型超参数作为类的属性放置

③ 实例化 Config()类

我们定义了一个Config()类,并在其中创建了一些属性,用作 GPT-2XL 模型中的超参数。n_layer属性表示我们构建的 GPT-2XL 模型将包含 48 个解码器层(我们使用“解码器块”和“解码器层”这两个术语可以互换)。n_head属性表示在计算因果自注意力时,我们将 Q、K 和 V 分割成 25 个并行头。n_embd属性表示嵌入维度是 1,600:每个标记将由一个 1,600 值的向量表示。vocab_size属性表示词汇表中有 50,257 个独特的标记。block_size属性表示输入到 GPT-2XL 模型中的序列最多包含 1,024 个标记。dropout 率都设置为 0.1。

在上一节中,我详细解释了因果自注意力是如何工作的。接下来,我们将定义一个CausalSelfAttention()类来实现它。

列表 11.4 实现因果自注意力

class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd)
        self.c_proj = nn.Linear(config.n_embd, config.n_embd)
        self.attn_dropout = nn.Dropout(config.attn_pdrop)
        self.resid_dropout = nn.Dropout(config.resid_pdrop)
        self.register_buffer("bias", torch.tril(torch.ones(\
                   config.block_size, config.block_size))
             .view(1, 1, config.block_size, config.block_size)) ①
        self.n_head = config.n_head
        self.n_embd = config.n_embd
    def forward(self, x):
        B, T, C = x.size() 
        q, k ,v  = self.c_attn(x).split(self.n_embd, dim=2)     ②
        hs = C // self.n_head
        k = k.view(B, T, self.n_head, hs).transpose(1, 2) 
        q = q.view(B, T, self.n_head, hs).transpose(1, 2) 
        v = v.view(B, T, self.n_head, hs).transpose(1, 2)       ③
        att = (q @ k.transpose(-2, -1)) *\
            (1.0 / math.sqrt(k.size(-1)))
        att = att.masked_fill(self.bias[:,:,:T,:T] == 0, \
                              float(‚-inf'))
        att = F.softmax(att, dim=-1)                            ④
        att = self.attn_dropout(att)
        y = att @ v 
        y = y.transpose(1, 2).contiguous().view(B, T, C)        ⑤
        y = self.resid_dropout(self.c_proj(y))
        return y

① 创建一个掩码并将其注册为缓冲区,因为它不需要更新

② 将输入嵌入通过三个神经网络传递以获得 Q、K 和 V

③ 将 Q、K 和 V 分割成多个头

④ 计算每个头的掩码注意力权重

⑤ 将所有头的注意力向量连接成一个单一的注意力向量

在 PyTorch 中,register_buffer是一种将张量注册为缓冲区的方法。缓冲区中的变量不被视为模型的可学习参数;因此,它们在反向传播期间不会被更新。在前面的代码块中,我们创建了一个掩码并将其注册为缓冲区。这会影响我们稍后提取和加载模型权重的方式:在从 GPT-2XL 检索权重时,我们将省略掩码。

正如我们在第一节中解释的,输入嵌入通过三个神经网络来获取查询 Q、键 K 和值 V。然后我们将它们分成 25 个头,并在每个头中计算掩码自注意力。之后,我们将 25 个注意力向量重新组合成一个单一的注意力向量,这是前一个CausalSelfAttention()类的输出。

11.2.4 构建 GPT-2XL 模型

接下来,我们在因果自注意力子层中添加一个前馈网络,以形成一个解码器块,如下所示。

列表 11.5 构建解码器块

class Block(nn.Module):
    def __init__(self, config):                                 ①
        super().__init__()
        self.ln_1 = nn.LayerNorm(config.n_embd)
        self.attn = CausalSelfAttention(config)
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp = nn.ModuleDict(dict(
            c_fc   = nn.Linear(config.n_embd, 4 * config.n_embd),
            c_proj = nn.Linear(4 * config.n_embd, config.n_embd),
            act    = GELU(),
            dropout = nn.Dropout(config.resid_pdrop),
        ))
        m = self.mlp
        self.mlpf=lambda x:m.dropout(m.c_proj(m.act(m.c_fc(x)))) 
    def forward(self, x):
        x = x + self.attn(self.ln_1(x))                         ②
        x = x + self.mlpf(self.ln_2(x))                         ③
        return x

① 初始化 Block()类

② 块中的第一个子层是因果自注意力子层,包含层归一化和残差连接。

③ 块中的第二个子层是一个前馈网络,包含 GELU 激活、层归一化和残差连接。

每个解码器块由两个子层组成。第一个子层是因果自注意力机制,包含层归一化和残差连接。解码器块内的第二个子层是前馈网络,它结合了 GELU 激活函数,以及层归一化和残差连接。

我们堆叠 48 个解码器层来形成 GPT-2XL 模型的主体,如下所示。

列表 11.6 构建 GPT-2XL 模型

class GPT2XL(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.block_size = config.block_size
        self.transformer = nn.ModuleDict(dict(
            wte = nn.Embedding(config.vocab_size, config.n_embd),
            wpe = nn.Embedding(config.block_size, config.n_embd),
            drop = nn.Dropout(config.embd_pdrop),
            h = nn.ModuleList([Block(config) 
                               for _ in range(config.n_layer)]),
            ln_f = nn.LayerNorm(config.n_embd),))
        self.lm_head = nn.Linear(config.n_embd,
                                 config.vocab_size, bias=False)
    def forward(self, idx, targets=None):
        b, t = idx.size()
        pos = torch.arange(0,t,dtype=torch.long).unsqueeze(0)
        tok_emb = self.transformer.wte(idx)    
        pos_emb = self.transformer.wpe(pos)    
        x = self.transformer.drop(tok_emb + pos_emb)            ①
        for block in self.transformer.h:
            x = block(x)                                        ②
        x = self.transformer.ln_f(x)                            ③
        logits = self.lm_head(x)                                ④
        loss = None
        if targets is not None:
            loss=F.cross_entropy(logits.view(-1,logits.size(-1)),
                           targets.view(-1), ignore_index=-1)
        return logits, loss

① 计算输入嵌入为词嵌入和位置编码之和

② 将输入嵌入通过 48 个解码器块

③ 再次应用层归一化

④ 将线性头附加到输出上,使得输出的数量等于唯一标记的数量

我们在本章的第一节中解释了如何在GPT2XL()类中构建模型。模型的输入由对应于词汇表中标记的索引序列组成。我们首先将输入通过词嵌入和位置编码;然后我们将这两个嵌入相加形成输入嵌入。输入嵌入经过 48 个解码器块。之后,我们对输出应用层归一化,然后附加一个线性头,使得输出的数量为 50,257,即词汇表的大小。输出是词汇表中 50,257 个标记的对数几率。稍后,我们将对对数几率应用 softmax 激活函数,以获得生成文本时词汇表中唯一标记的概率分布。

注意:由于模型太大,我们没有将其移动到 GPU 上。这导致本章后面文本生成的速度较低。然而,如果你有访问带有大内存(例如,超过 32GB)的 CUDA 启用 GPU 的权限,你可以将模型移动到 GPU 上以实现更快的文本生成。

接下来,我们将通过实例化我们之前定义的 GPT2XL() 类来创建 GPT-2XL 模型:

model=GPT2XL(config)
num=sum(p.numel() for p in model.transformer.parameters())
print("number of parameters: %.2fM" % (num/1e6,))

我们还计算模型主体中的参数数量。输出结果是

number of parameters: 1557.61M

前面的输出显示 GPT-2XL 有超过 15 亿个参数。请注意,这个数字不包括模型末尾线性头部的参数。根据下游任务的不同,我们可以将不同的头部附加到模型上。由于我们的重点是文本生成,我们附加了一个线性头部以确保输出的数量等于词汇表中的唯一标记数量。

注意:在 GPT-2、ChatGPT 或 BERT 等大型语言模型中,输出头部指的是模型中负责根据处理后的输入产生实际输出的最后一层。这个输出会根据模型执行的任务而变化。在文本生成中,输出头部通常是一个线性层,它将最终的隐藏状态转换为词汇表中每个标记的 logits。这些 logits 然后通过 softmax 函数生成词汇表上的概率分布,用于预测序列中的下一个标记。对于分类任务,输出头部通常由一个线性层和一个 softmax 函数组成。线性层将模型的最终隐藏状态转换为每个类别的 logits,softmax 函数将这些 logits 转换为每个类别的概率。输出头部的具体架构可能因模型和任务而异,但其主要功能是将处理后的输入映射到所需的输出格式(例如,类别概率、标记概率等)。

最后,你可以打印出 GPT-2XL 模型的结构:

print(model)

输出结果是

GPT2XL(
  (transformer): ModuleDict(
    (wte): Embedding(50257, 1600)
    (wpe): Embedding(1024, 1600)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-47): 48 x Block(
        (ln_1): LayerNorm((1600,), eps=1e-05, elementwise_affine=True)
        (attn): CausalSelfAttention(
          (c_attn): Linear(in_features=1600, out_features=4800, bias=True)
          (c_proj): Linear(in_features=1600, out_features=1600, bias=True)
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((1600,), eps=1e-05, elementwise_affine=True)
        (mlp): ModuleDict(
          (c_fc): Linear(in_features=1600, out_features=6400, bias=True)
          (c_proj): Linear(in_features=6400, out_features=1600, bias=True)
          (act): GELU()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((1600,), eps=1e-05, elementwise_affine=True)
  )
  (lm_head): Linear(in_features=1600, out_features=50257, bias=False)
)

它显示了 GPT-2XL 模型中的详细块和层。

就这样,你从头开始创建了 GPT-2XL 模型!

11.3 加载预训练权重并生成文本

尽管你刚刚创建了 GPT-2XL 模型,但它尚未经过训练。因此,你不能用它生成任何有意义的文本。

由于模型参数数量庞大,没有超级计算设施就无法训练模型,更不用说训练模型所需的数据量了。幸运的是,包括最大的 GPT-2 模型 GPT-2XL 在内的预训练权重于 2019 年 11 月 5 日由 OpenAI 向公众发布(参见 OpenAI 网站上的声明,openai.com/research/gpt-2-1-5b-release,以及一家美国科技新闻网站 The Verge 的报道,mng.bz/NBm7)。因此,我们将加载预训练权重以在本节中生成文本。

11.3.1 加载 GPT-2XL 的预训练参数

我们将使用 Hugging Face 团队开发的 transformers 库来提取 GPT-2XL 中的预训练权重。

首先,在 Jupyter Notebook 的新单元中运行以下代码行以在您的计算机上安装 transformers 库:

!pip install transformers

接下来,我们从 transformers 库中导入 GPT2 模型并提取 GPT-2XL 中的预训练权重:

from transformers import GPT2LMHeadModel

model_hf = GPT2LMHeadModel.from_pretrained('gpt2-xl')         ①
sd_hf = model_hf.state_dict()                                 ②
print(model_hf)                                               ③

① 加载预训练的 GPT-2XL 模型

② 提取模型权重

③ 打印出原始 OpenAI GTP-2XL 模型的模型结构

上一段代码块输出的结果是

GPT2LMHeadModel(
  (transformer): GPT2Model(
    (wte): Embedding(50257, 1600)
    (wpe): Embedding(1024, 1600)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-47): 48 x GPT2Block(
        (ln_1): LayerNorm((1600,), eps=1e-05, elementwise_affine=True)
        (attn): GPT2Attention(
          (c_attn): Conv1D()                                   ①
          (c_proj): Conv1D()                                   ①
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((1600,), eps=1e-05, elementwise_affine=True)
        (mlp): GPT2MLP(
          (c_fc): Conv1D()                                     ①
          (c_proj): Conv1D()                                   ①
          (act): NewGELUActivation()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((1600,), eps=1e-05, elementwise_affine=True)
  )
  (lm_head): Linear(in_features=1600, out_features=50257, bias=False)
)

① OpenAI 使用了 Conv1d 层而不是我们使用的线性层

如果你将这个模型结构与上一节中的模型结构进行比较,你会注意到它们是相同的,只是线性层被 Conv1d 层所取代。正如我们在第九章和第十章中解释的,在前馈网络中,我们将输入中的值视为独立的元素,而不是一个序列。因此,我们通常称它为一维卷积网络。OpenAI 检查点在模型中使用线性层的地方使用了 Conv1d 模块。因此,当我们从 Hugging Face 提取模型权重并将其放置在我们的模型中时,我们需要转置某些权重矩阵。

要理解它是如何工作的,让我们看看 OpenAI GPT-2XL 模型第一个解码块中前馈网络第一层的权重。我们可以按以下方式打印出其形状:

print(model_hf.transformer.h[0].mlp.c_fc.weight.shape)

输出结果是

torch.Size([1600, 6400])

Conv1d 层中的权重矩阵是一个大小为 (1,600, 6,400) 的张量。

现在,如果我们看看我们刚刚构建的模型中相同的权重矩阵,其形状是

print(model.transformer.h[0].mlp.c_fc.weight.shape)

这次的输出结果是

torch.Size([6400, 1600])

我们模型中线性层的权重矩阵是一个大小为 (6,400, 1,600) 的张量,它是 OpenAI GPT-2XL 权重矩阵的转置矩阵。因此,在我们将权重矩阵放置在我们的模型之前,我们需要将 OpenAI GPT-2XL 模型中所有 Conv1d 层的权重矩阵进行转置。

接下来,我们将原始 OpenAI GPT-2XL 模型中的参数命名为 keys

keys = [k for k in sd_hf if not k.endswith('attn.masked_bias')] 

注意,我们在上一行代码中排除了以attn.masked_bias结尾的参数。OpenAI GPT-2 使用它们来实现未来标记的掩码。由于我们在CausalSelfAttention()类中创建了我们的掩码并将其注册为 PyTorch 中的缓冲区,因此我们不需要从 OpenAI 加载以attn.masked_bias结尾的参数。

我们将从头创建的 GPT-2XL 模型中的参数命名为sd

sd=model.state_dict()

接下来,我们将从 OpenAI GPT-2XL 中提取预训练权重并将它们放置到我们自己的模型中:

transposed = ['attn.c_attn.weight', 'attn.c_proj.weight',
              'mlp.c_fc.weight', 'mlp.c_proj.weight']          ①
for k in keys:
    if any(k.endswith(w) for w in transposed):
        with torch.no_grad():
            sd[k].copy_(sd_hf[k].t())                          ②
    else:
        with torch.no_grad():
            sd[k].copy_(sd_hf[k])                              ③

① 发现 OpenAI 使用 Conv1d 模块而不是线性模块的层

② 对于这些层,我们在将权重放置到我们的模型中之前,转置权重矩阵。

③ 否则,简单地从 OpenAI 复制权重并将它们放置到我们的模型中

我们从 Hugging Face 提取 OpenAI 的预训练权重并将它们放置到我们自己的模型中。在这个过程中,我们确保每当 OpenAI 检查点使用 Conv1d 模块而不是普通线性模块时,我们都会转置权重矩阵。

现在我们的模型已经配备了来自 OpenAI 的预训练权重。我们可以使用该模型生成连贯的文本。

11.3.2 定义一个 generate()函数以生成文本

借助来自 OpenAI GPT-2XL 模型的预训练权重,我们将使用我们从头创建的 GPT2 模型来生成文本。

在生成文本时,我们将一个与提示中的标记对应的索引序列输入到模型中。模型预测下一个标记对应的索引,并将预测附加到序列的末尾以形成新的序列。然后它使用新的序列再次进行预测。它一直这样做,直到模型生成固定数量的新标记或对话结束(由特殊标记<|endoftext|>表示)。

GPT 中的特殊标记<|endoftext|>

GPT 模型使用来自各种来源的文本进行训练。在这个阶段,使用一个独特的标记<|endoftext|>来区分不同来源的文本。在文本生成阶段,在遇到这个特殊标记时停止对话至关重要。如果不这样做,可能会触发无关新话题的启动,导致随后生成的文本与当前讨论无关。

为了达到这个目的,我们定义了一个sample()函数,向当前序列中添加一定数量的新索引。它接受一个索引序列作为输入,以供 GPT-2XL 模型使用。它一次预测一个索引,并将新索引添加到运行序列的末尾。它停止直到达到指定的步数max_new_tokens或预测的下一个标记是<|endoftext|>,这表示对话结束。如果我们不停下来,模型可能会随机开始一个无关的话题。sample()函数的定义如下所示。

列表 11.7 逐个预测下一个索引

model.eval()
def sample(idx, max_new_tokens, temperature=1.0, top_k=None):
    for _ in range(max_new_tokens):                            ①
        if idx.size(1) <= config.block_size:
            idx_cond = idx  
        else:
            idx_cond = idx[:, -config.block_size:]
        logits, _ = model(idx_cond)                            ②
        logits = logits[:, -1, :] / temperature
        if top_k is not None:
            v, _ = torch.topk(logits, top_k)
            logits[logits < v[:, [-1]]] = -float('Inf')        ③
        probs = F.softmax(logits, dim=-1)
        idx_next = torch.multinomial(probs, num_samples=1)
        if idx_next.item()==tokenizer.encoder.encoder['<|endoftext|>']:
            break                                              ④
        idx = torch.cat((idx, idx_next), dim=1)                ⑤
    return idx

① 生成固定数量的新索引

② 使用 GPT-2XL 预测下一个索引

③ 如果使用 top-K 采样,将低于 top K 选择的 logits 设置为-∞

④ 如果下一个标记是<|endoftext|>,则停止预测

⑤ 将新的预测附加到序列的末尾

sample()函数使用 GPT-2XL 向运行序列中添加新的索引。它包含两个参数,temperaturetop_k,以调节生成输出的新颖性,其工作方式与第八章中描述的相同。该函数返回一个新的索引序列。

接下来,我们定义一个generate()函数,根据提示(prompt)生成文本。它首先将提示中的文本转换为一系列索引。然后,它将这个序列输入到我们刚刚定义的sample()函数中,以生成一个新的索引序列。最后,generate()函数将新的索引序列转换回文本。

列表 11.8:使用 GPT-2XL 生成文本的函数

def generate(prompt, max_new_tokens, temperature=1.0,
             top_k=None):
    if prompt == '':
        x=torch.tensor([[tokenizer.encoder.encoder['<|endoftext|>']]],
                         dtype=torch.long)                     ①
    else:
        x = tokenizer(prompt)                                  ②
    y = sample(x, max_new_tokens, temperature, top_k)          ③
    out = tokenizer.decode(y.squeeze())                        ④
    print(out)

① 如果提示为空,则使用<|endoftext|>作为提示

② 将提示转换为一系列索引

③ 使用 sample()函数生成新的索引

④ 将新的索引序列转换回文本

generate()函数与我们在第八章中介绍的那个版本相似,但有一个显著的区别:它使用 GPT-2XL 进行预测,远离之前使用的 LSTM 模型。该函数接受一个提示作为其初始输入,将这个提示转换成一系列索引,然后输入到模型中预测后续的索引。在生成预定数量的新索引后,该函数将整个索引序列转换回文本形式。

11.3.3 使用 GPT-2XL 进行文本生成

现在我们已经定义了generate()函数,我们可以使用它来生成文本。

尤其是使用generate()函数可以进行无条件文本生成,这意味着提示(prompt)为空。模型将随机生成文本。这在创意写作中可能很有用:生成的文本可以用作灵感或个人创意作品的起点。让我们试试看:

prompt=""
torch.manual_seed(42)
generate(prompt, max_new_tokens=100, temperature=1.0,
             top_k=None)

输出是

<|endoftext|>Feedback from Ham Radio Recalls

I discovered a tune sticking in my head -- I'd heard it mentioned on several occasions, but hadn't investigated further.

The tune sounded familiar to a tune I'd previously heard on the 550 micro. 
During that same time period I've heard other people's receipients drone on
the idea of the DSH-94013, notably Kim Weaver's instructions in her 
Interview on Radio Ham; and both Scott Mcystem and Steve Simmons' concepts.

如您所见,前面的输出在逻辑上是一致的,语法正确,但可能不是事实准确的。我快速进行了谷歌搜索,文本似乎并未从任何在线来源复制。

练习 11.4

通过将提示设置为空字符串,温度设置为 0.9,最大新标记数量设置为 100,top_k设置为 40,并在 PyTorch 中将随机种子数设置为 42,无条件生成文本。看看输出结果是什么。

为了评估 GPT-2XL 能否根据前面的标记生成连贯的文本,我们将使用提示“I went to the kitchen and”并在提示后生成 10 个额外的标记。我们将重复这个过程五次,以确定生成的文本是否与典型的厨房活动相符:

prompt="I went to the kitchen and"
for i in range(5):
    torch.manual_seed(i)
    generate(prompt, max_new_tokens=10, temperature=1.0,
                 top_k=None)

输出是

I went to the kitchen and said, you're not going to believe this.
I went to the kitchen and noticed a female producer open a drawer in which was
I went to the kitchen and asked who was going to be right there and A
I went to the kitchen and took a small vial of bourbon and a little
I went to the kitchen and found the bottle of wine, and poured it into

这些结果表明,生成的文本包括与某人交谈、注意到某事以及饮用饮料等活动,这些都是典型的厨房活动。这证明了 GPT-2XL 可以生成与给定上下文相关的文本。

接下来,我们使用“Lexington 是肯塔基州第二大城市”作为提示,并要求generate()函数添加多达 100 个新标记:

prompt="Lexington is the second largest city in the state of Kentucky"
torch.manual_seed(42)
generate(prompt, max_new_tokens=100, temperature=1.0,
             top_k=None)

输出结果为

Lexington is the second largest city in the state of Kentucky. It caters to
those who want to make everything in tune with being with friends and 
enjoying a jaunt through the down to Earth lifestyle. To do so, they are 
blessed with several venues large and small to fill their every need while 
residing micro- cozy with nature within the landmarks of the city.

In a moment we look at ten up and coming suchache music acts from the 
Lexington area to draw upon your attention.

Lyrikhop

This Lexington-based group

再次,这段文本是连贯的。尽管生成的文本可能不是事实准确的。GPT-2XL 模型在本质上是被训练来根据句子中的前一个标记预测下一个标记的。前一个输出显示,该模型已经达到了这个目标:生成的文本在语法上是正确的,看起来似乎是逻辑的。它显示了在序列的早期部分记住文本并生成与上下文相关的后续单词的能力。例如,当第一句话讨论列克星敦市时,大约 90 个标记后,模型提到了列克星敦地区的音乐表演。

此外,正如引言中提到的,GPT-2 有其局限性。鉴于其大小小于 ChatGPT 的 1%和 GPT-4 的 0.1%,它不应被要求与 ChatGPT 或 GPT-4 保持相同的标准。GPT-3 有 1750 亿参数,生成的文本比 GPT-2 更连贯,但预训练的权重并未向公众发布。

接下来,我们将探讨temperaturetop-K采样如何影响 GPT-2XL 生成的文本。我们将temperature设置为 0.9,top_k设置为 50,并保持其他参数不变。让我们看看生成的文本是什么样的:

torch.manual_seed(42)
generate(prompt, max_new_tokens=100, temperature=0.9,
             top_k=50)  

输出结果为

Lexington is the second largest city in the state of Kentucky. It is also 
the state capital. The population of Lexington was 1,731,947 in the 2011 
Census. The city is well-known for its many parks, including Arboretum, 
Zoo, Aquarium and the Kentucky Science Center, as well as its restaurants, 
such as the famous Kentucky Derby Festival.

In the United States, there are at least 28 counties in this state with a
population of more than 100,000, according to the 2010 census.

生成的文本看起来比以前更连贯。然而,内容在事实上并不准确。它编造了许多关于肯塔基州列克星敦市的事实,例如“2011 年人口普查中,列克星敦的人口为 1,731,947。”

练习 11.5

通过将temperature设置为 1.2 和top_k设置为 None,并使用“Lexington 是肯塔基州第二大城市”作为起始提示来生成文本。在 PyTorch 中将随机种子数设置为 42,并将最大新标记数设置为 100。

在本章中,你从头开始学习了如何构建 GPT-2,它是 ChatGPT 和 GPT-4 的前身。之后,你从 OpenAI 发布的 GPT-2XL 模型中提取了预训练的权重,并将它们加载到你的模型中。你见证了模型生成的连贯文本。

由于 GPT-2XL 模型(15 亿参数)的体积庞大,没有超级计算设施就无法训练该模型。在下一章中,你将创建一个与 GPT-2 结构相似但只有约 512 万个参数的小型 GPT 模型。你将使用欧内斯特·海明威的小说文本来训练模型。训练后的模型将生成与海明威风格相匹配的连贯文本!

摘要

  • GPT-2 是 OpenAI 开发的高级 LLM,于 2019 年 2 月宣布。它在自然语言处理领域取得了重大突破,并为开发更复杂的模型铺平了道路,包括其继任者 ChatGPT 和 GPT-4。

  • GPT-2 是一个仅包含解码器的 Transformer 模型,这意味着模型中没有编码器堆栈。与其他 Transformer 模型一样,GPT-2 使用自注意力机制并行处理输入数据,显著提高了训练大型语言模型(LLMs)的效率和效果。

  • GPT-2 在位置编码方面采用了与 2017 年开创性论文“Attention Is All You Need”中使用的不同方法。相反,GPT-2 的位置编码技术与词嵌入技术相平行。

  • GPT-2 的前馈子层中使用了 GELU 激活函数。GELU 提供了线性和非线性激活特性的混合,这些特性被发现可以增强深度学习任务中的模型性能,尤其是在自然语言处理(NLPs)和训练 LLMs 方面。

  • 我们可以从头开始构建一个 GPT-2 模型,并加载 OpenAI 发布的预训练权重。你创建的 GPT-2 模型可以生成与原始 OpenAI GPT-2 模型一样连贯的文本。

第十二章:训练一个 Transformer 生成文本

本章涵盖

  • 构建一个适合你需求的 GPT-2XL 模型的缩小版

  • 为训练 GPT 风格的 Transformer 准备数据

  • 从头开始训练 GPT 风格的 Transformer

  • 使用训练好的 GPT 模型生成文本

在第十一章中,我们从头开始开发了 GPT-2XL 模型,但由于其参数数量庞大,我们无法对其进行训练。训练具有 15 亿个参数的模型需要超级计算设施和大量的数据。因此,我们将 OpenAI 的预训练权重加载到我们的模型中,然后使用 GPT-2XL 模型生成文本。

然而,从头开始学习如何训练 Transformer 模型对于几个原因来说至关重要。首先,虽然这本书没有直接涵盖微调预训练模型,但了解如何训练 Transformer 能让你掌握微调所需的技能。训练一个模型涉及随机初始化参数,而微调则涉及加载预训练权重并进一步训练模型。其次,训练或微调 Transformer 能让你根据特定需求和领域定制模型,这可以显著提高其在特定用例中的性能和相关性。最后,训练自己的 Transformer 或微调现有的 Transformer 可以提供对数据和隐私的更大控制,这对于敏感应用或处理专有数据尤为重要。总之,掌握 Transformer 的训练和微调对于任何希望利用语言模型的力量进行特定应用同时保持隐私和控制的人来说是必不可少的。

因此,在本章中,我们将构建一个具有大约 500 万个参数的 GPT 模型缩小版。这个较小的模型遵循 GPT-2XL 模型的架构;与原始 GPT-2XL 的 48 个解码器块和 1600 维的嵌入维度相比,其显著差异是只有 3 个解码器块和 256 维的嵌入维度。通过将 GPT 模型缩小到大约 500 万个参数,我们可以在普通计算机上对其进行训练。

生成的文本风格将取决于训练数据。当从头开始训练模型进行文本生成时,文本长度和变化都是至关重要的。训练材料必须足够广泛,以便模型能够有效地学习和模仿特定的写作风格。同时,如果训练材料缺乏变化,模型可能会简单地复制训练文本中的段落。另一方面,如果材料太长,训练可能需要过多的计算资源。因此,我们将使用欧内斯特·海明威的三部小说作为我们的训练材料:《老人与海》《永别了,武器》,和《丧钟为谁而鸣》。这个选择确保我们的训练数据具有足够的长度和变化,以便有效地学习,同时又不会太长以至于训练变得不切实际。

由于 GPT 模型不能直接处理原始文本,我们首先将文本分词成单词。然后,我们将创建一个字典,将每个唯一的标记映射到不同的索引。使用这个字典,我们将文本转换成一个长序列的整数,以便输入到神经网络中。

我们将使用 128 个索引的序列作为输入来训练 GPT 模型。正如第八章和第十章所述,我们将输入序列向右移动一个标记,并将其用作输出。这种方法迫使模型根据当前标记和序列中所有之前的标记来预测句子中的下一个单词。

一个关键挑战是确定训练模型的最佳 epoch 数量。我们的目标不仅仅是最小化训练集中的交叉熵损失,因为这样做可能会导致过拟合,即模型只是简单地复制训练文本中的段落。为了解决这个问题,我们计划训练模型 40 个 epoch。我们将每隔 10 个 epoch 保存一次模型,并评估哪个版本可以生成连贯的文本,而不仅仅是复制训练材料中的段落。或者,一个人可能潜在地使用验证集来评估模型的性能,并决定何时停止训练,就像我们在第二章中所做的那样。

一旦我们的 GPT 模型训练完成,我们将使用它来自动回归地生成文本,就像我们在第十一章中所做的那样。我们将测试训练模型的多个版本。训练了 40 个 epoch 的模型产生了非常连贯的文本,捕捉到了海明威的独特风格。然而,它也可能生成部分复制自训练材料的文本,特别是如果提示与训练文本中的段落相似。训练了 20 个 epoch 的模型也生成了连贯的文本,尽管偶尔会有语法错误,但不太可能直接从训练文本中复制。

本章的主要目标并非一定是生成尽可能连贯的文本,这本身就是一个巨大的挑战。相反,我们的目标是教会你如何从头开始构建一个 GPT 风格的模型,使其适用于现实世界的应用和你的特定需求。更重要的是,本章概述了从头开始训练 GPT 模型所需的步骤。你将学习如何根据你的目标选择训练文本,对文本进行分词并将其转换为索引,以及准备训练数据批次。你还将学习如何确定训练的 epoch 数量。一旦模型训练完成,你将学习如何使用模型生成文本,以及如何避免直接从训练材料中复制文本。

12.1 从头开始构建和训练 GPT

我们的目的是掌握从头开始构建和训练 GPT 模型,使其针对特定任务进行定制。这项技能对于将本书中的概念应用于现实世界问题至关重要。

想象一下,你是一位热衷于欧内斯特·海明威作品的粉丝,并希望训练一个 GPT 模型以生成海明威风格的文本。你将如何着手?本节将讨论完成此任务所需的步骤。

第一步是配置一个适合训练的 GPT 模型。你将创建一个与第十一章中构建的 GPT-2 模型结构相似的 GPT 模型,但参数数量显著减少,以便在几小时内进行训练成为可能。因此,你需要确定模型的关键超参数,如序列长度、嵌入维度、解码器块数量和 dropout 率。这些超参数至关重要,因为它们会影响训练模型输出的质量以及训练速度。

在此之后,你将收集几部海明威小说的原始文本,并将其清理以确保适合训练。你需要对文本进行分词,并为每个唯一的标记分配一个不同的整数,以便将其输入到模型中。为了准备训练数据,你需要将文本分解成一定长度的整数序列,并将它们用作输入。然后,你将输入向右移动一个标记,并将它们用作输出。这种方法迫使模型根据序列中的当前标记和所有先前标记来预测下一个标记。

模型训练完成后,你将使用它根据提示生成文本。首先,你将提示文本转换为索引序列,并将其输入到训练好的模型中。模型使用该序列迭代地预测最可能的下一个标记。之后,你将模型生成的标记序列转换回文本。

在本节中,我们将首先讨论用于此任务的 GPT 模型架构。然后,我们将讨论训练模型涉及的步骤。

12.1.1 用于生成文本的 GPT 架构

尽管 GPT-2 有多种大小,但它们都具有相似的架构。本章中我们构建的 GPT 模型遵循与 GPT-2 相同的结构设计,但规模显著减小,这使得在没有超级计算设施的情况下进行训练成为可能。表 12.1 展示了我们的 GPT 模型与 GPT-2 四个版本模型的比较。

表 12.1 我们 GPT 与不同版本的 GPT-2 模型的比较

GPT-2S GPT-2M GPT-2L GPT-2XL 我们 GPT
嵌入维度 768 1,024 1,280 1,600 256
解码器层数 12 24 36 48 3
头数量 12 16 20 25 4
序列长度 1,024 1,024 1,024 1,024 128
词汇量大小 50,257 50,257 50,257 50,257 10,600
参数数量 1.24 亿 3.5 亿 7.74 亿 15.58 亿 512 万

在本章中,我们将构建一个具有三个解码器层和 256 维嵌入维度(这意味着每个标记在词嵌入后由一个 256 位的向量表示)的 GPT 模型。正如我们在第十一章中提到的,GPT 模型使用与 2017 年论文“Attention Is All You Need”中使用的不同位置编码方法。相反,我们使用嵌入层来学习序列中不同位置的位置编码。因此,序列中的每个位置也由一个 256 位的向量表示。为了计算因果自注意力,我们使用四个并行注意力头来捕捉序列中标记意义的各个方面。因此,每个注意力头的维度为 256/4 = 64,与 GPT-2 模型中的相似。例如,在 GPT-2XL 中,每个注意力头的维度为 1,600/25 = 64。

我们 GPT 模型的最大序列长度为 128,这比 GPT-2 模型的最大序列长度 1,024 短得多。这种减少是必要的,以保持模型中参数的数量可管理。然而,即使序列中有 128 个元素,模型也能学习序列中标记之间的关系并生成连贯的文本。

虽然 GPT-2 模型的词汇量为 50,257,但我们的模型词汇量要小得多,为 10,600。重要的是要注意,词汇量主要是由训练数据决定的,而不是一个预定义的选择。如果你选择使用更多文本进行训练,你可能会得到一个更大的词汇量。

图 12.1 展示了我们将在本章中创建的仅解码器 Transformer 的架构。它与第十一章中看到的 GPT-2 架构相似,只是规模更小。因此,我们模型中的总参数数量为 512 万,而第十一章中构建的 GPT-2XL 模型的参数数量为 15.58 亿。图 12.1 显示了训练过程中每个步骤的训练数据大小。

图片

图 12.1 展示了用于生成文本的仅解码器 Transformer 架构。来自三个海明威小说的文本被标记化,然后转换为索引。我们将 128 个索引排列成一个序列,每个批次包含 32 个这样的序列。输入首先进行词嵌入和位置编码,输入嵌入是这两个组件的总和。然后,这个输入嵌入通过三个解码器层进行处理。随后,输出经过层归一化并通过一个线性层,结果输出大小为 10,600,这对应于词汇表中的唯一标记数量。

我们创建的 GPT 模型的输入由输入嵌入组成,如图 12.1 底部所示。我们将在下一小节中详细讨论如何计算这些嵌入。简而言之,它们是输入序列中词嵌入和位置编码的总和。

输入嵌入随后按顺序通过三个解码器层。与我们在第十一章构建的 GPT-2XL 模型类似,每个解码器层由两个子层组成:一个因果自注意力层和一个前馈网络。此外,我们对每个子层应用层归一化和残差连接。之后,输出经过层归一化和线性层。我们 GPT 模型中的输出数量对应于词汇表中的独特标记数量,即 10,600。模型的输出是下一个标记的 logits。稍后,我们将对这些 logits 应用 softmax 函数,以获得词汇表上的概率分布。该模型旨在根据当前标记和序列中所有之前的标记来预测下一个标记。

12.1.2 GPT 模型生成文本的训练过程

现在我们已经知道了如何构建用于文本生成的 GPT 模型,让我们来探讨训练模型所涉及的步骤。我们旨在在深入项目编码方面之前提供一个训练过程的概述。

生成的文本风格受训练文本的影响。由于我们的目标是训练模型以生成类似欧内斯特·海明威风格的文本,我们将使用他三部小说中的文本:《老人与海》《永别了,武器》,和《丧钟为谁而鸣》。如果我们只选择一部小说,训练数据将缺乏多样性,导致模型记住小说中的段落并生成与训练数据相同的文本。相反,使用过多的小说会增加独特标记的数量,使得在短时间内有效训练模型变得具有挑战性。因此,我们通过选择三部小说并将它们组合作为我们的训练数据来达到平衡。

图 12.2 展示了训练 GPT 模型生成文本所涉及的步骤。

图片

图 12.2 仅解码器的 Transformer 生成 Hemingway 风格文本的训练过程。

如前三个章节所述,训练过程中的第一步是将文本转换为数值形式,以便我们可以将训练数据输入到模型中。具体来说,我们首先使用与第八章相同的方法,将三部小说的文本分解成标记。在这种情况下,每个标记是一个完整的单词或标点符号(如冒号、括号或逗号)。词级标记化易于实现,我们可以控制独特标记的数量。标记化后,我们为每个标记分配一个唯一的索引(即一个整数),将训练文本转换为整数序列(见图 12.2 中的步骤 1)。

接下来,我们通过首先将这个整数序列分成等长的序列(图 12.2 中的步骤 2)来将序列转换为训练数据。我们允许每个序列中最多有 128 个索引。选择 128 允许我们在保持模型大小可管理的同时捕捉句子中标记之间的长距离依赖关系。然而,数字 128 并非神奇:将其更改为,比如说,100 或 150,会导致类似的结果。这些序列形成我们模型的特征(x 变量)。正如我们在前面的章节中所做的那样,我们将输入序列向右移动一个标记,并将其用作训练数据中的输出(y 变量;图 12.2 中的步骤 3)。

输入和输出的配对作为训练数据(x, y)。在“老人与海”这个句子的例子中,我们使用对应于“老人与海”的索引作为输入 x。我们将输入向右移动一个标记,并使用“老人与海”的索引作为输出 y。在第一次时间步,模型使用“the”来预测“old”。在第二次时间步,模型使用“the old”来预测“man”,依此类推。

在训练过程中,您将遍历训练数据。在正向传递中,您将输入序列 x 通过 GPT 模型(步骤 4)。然后 GPT 根据模型中当前的参数进行预测(步骤 5)。您通过比较预测的下一个标记与步骤 3 获得的输出来计算交叉熵损失。换句话说,您将模型的预测与真实值进行比较(步骤 6)。最后,您将调整 GPT 模型中的参数,以便在下一个迭代中,模型的预测更接近实际输出,最小化交叉熵损失(步骤 7)。请注意,模型本质上是在执行一个多类别分类问题:它从词汇表中的所有唯一标记中预测下一个标记。

您将通过多次迭代重复步骤 3 到 7。每次迭代后,模型参数都会调整以改进下一个标记的预测。我们将重复此过程 40 个周期,并在每个 10 个周期后保存训练好的模型。如您稍后所见,如果我们训练模型时间过长,它就会过拟合,记住训练数据中的段落。生成的文本随后就会与原始小说中的文本相同。我们将事后测试哪个模型版本生成的文本既连贯,又没有简单地从训练数据中复制。

12.2 海明威小说文本的分词

现在您已经了解了 GPT 模型的架构和训练过程,让我们从第一步开始:对海明威小说的文本进行分词和索引。

首先,我们将处理文本数据,为训练做准备。我们将文本分解为单个标记,就像我们在第八章所做的那样。由于深度神经网络不能直接处理原始文本,我们将创建一个字典,为每个标记分配一个索引,有效地将它们映射到整数。之后,我们将这些索引组织成训练数据批次,这对于在后续步骤中训练 GPT 模型至关重要。

我们将使用词级分词,因为它在将文本划分为单词方面简单,而不是更复杂的子词分词,后者需要细微的语言结构理解。此外,词级分词产生的唯一标记数量比子词分词少,从而减少了 GPT 模型中的参数数量。

12.2.1 文本分词

为了训练 GPT 模型,我们将使用欧内斯特·海明威的三部小说的原始文本文件:《老人与海》《永别了,武器》,和 《丧钟为谁而鸣》。文本文件是从 Faded Page 网站下载的:www.fadedpage.com。我已经清理了文本,移除了不属于原始书籍的顶部和底部段落。在准备自己的训练文本时,消除所有无关信息至关重要,例如供应商详情、格式和许可信息。这确保了模型只专注于学习文本中存在的写作风格。我还移除了章节之间的不相关文本。您可以从书籍的 GitHub 仓库下载三个文件 OldManAndSea.txt,FarewellToArms.txt,和 ToWhomTheBellTolls.txt:github.com/markhliu/DGAI。将它们放在您计算机上的 /files/ 文件夹中。

《老人与海》 的文本文件中,开双引号(“)和闭双引号(”)都表示为直双引号(")。在其他两部小说的文本文件中并非如此。因此,我们加载 《老人与海》 的文本,并将直引号更改为开引号或闭引号。这样做可以让我们区分开引号和闭引号。这还将有助于稍后格式化生成的文本:我们将移除开引号后的空格和闭引号前的空格。此步骤的实现方式如下所示。

列表 12.1 将直引号更改为开引号和闭引号

with open("files/OldManAndSea.txt","r", encoding='utf-8-sig') as f:
    text=f.read()
text=list(text)                                             ①
for i in range(len(text)):
    if text[i]=='"':
        if text[i+1]==' ' or text[i+1]=='\n':
            text[i]='"'                                     ②
        if text[i+1]!=' ' and text[i+1]!='\n':
            text[i]='"'                                     ③
    if text[i]=="'":
        if text[i-1]!=' ' and text[i-1]!='\n':
            text[i]='''                                     ④
text="".join(text)                                          ⑤

① 加载原始文本并将其分解为单个字符

② 如果一个直引号后面跟着一个空格或换行符,则将其更改为闭合引号

③ 否则,将其更改为开引号

④ 将直单引号转换为撇号

⑤ 将单个字符重新组合成文本

如果双引号后面跟着空格或换行符,我们将将其更改为闭合引号;否则,我们将将其更改为开引号。撇号被输入为单个直引号,我们在列表 12.1 中将其更改为闭合单引号的形式。

接下来,我们加载另外两本小说的文本,并将这三本小说合并成一个单独的文件。

列表 12.2 合并三本小说的文本

with open("files/ToWhomTheBellTolls.txt","r", encoding='utf-8-sig') as f:
    text1=f.read()                                            ①

with open("files/FarewellToArms.txt","r", encoding='utf-8-sig') as f:
    text2=f.read()                                            ②

text=text+" "+text1+" "+text2                                 ③

with open("files/ThreeNovels.txt","w", 
          encoding='utf-8-sig') as f:
    f.write(text)                                             ④
print(text[:250])

① 从第二本小说读取文本

② 从第三本小说读取文本

③ 合并三本小说的文本

④ 将合并的文本保存到本地文件夹

我们加载另外两本小说的文本,《永别了,武器》《丧钟为谁而鸣》。然后我们将三本小说的文本合并起来作为我们的训练数据。此外,我们还将合并的文本保存到本地文件名为 ThreeNovels.txt 中,以便我们可以在以后验证生成的文本是否直接复制自原始文本。

前面代码列表的输出是

He was an old man who fished alone in a skiff in the Gulf Stream and he
had gone eighty-four days now without taking a fish. In the first
forty days a boy had been with him. But after forty days without a
fish the boy's parents had told him that th

输出是合并文本的前 250 个字符。

我们将使用空格作为分隔符来标记文本。如前所述的输出所示,像句号(.)、连字符(-)和撇号(')这样的标点符号没有空格地附加在前面单词上。因此,我们需要在所有标点符号周围插入空格。

此外,我们还将换行符(\n)转换为空格,这样它们就不会包含在词汇表中。在我们的设置中,将所有单词转换为小写也是有益的,因为它确保了像“The”和“the”这样的单词被视为相同的标记。这一步骤有助于减少唯一标记的数量,从而使得训练过程更加高效。为了解决这些问题,我们将按照以下列表所示清理文本。

列表 12.3 在标点符号周围添加空格

text=text.lower().replace("\n", " ")                         ①

chars=set(text.lower())
punctuations=[i for i in chars if i.isalpha()==False
              and i.isdigit()==False]                        ②
print(punctuations)

for x in punctuations:
    text=text.replace(f"{x}", f" {x} ")                      ③
text_tokenized=text.split()

unique_tokens=set(text_tokenized)
print(len(unique_tokens))                                    ④

① 将换行符替换为空格

② 识别所有标点符号

③ 在标点符号周围插入空格

④ 计算唯一标记的数量

我们使用set()方法获取文本中的所有唯一字符。然后我们使用isalpha()isdigit()方法从唯一字符集中识别并移除字母和数字,从而只留下标点符号。

如果你执行前面的代码块,输出将如下所示:

[')', '.', '&', ':', '(', ';', '-', '!', '"', ' ', ''', '"', '?', ',', ''']
10599

此列表包括文本中的所有标点符号。我们在它们周围添加空格,并使用split()方法将文本分解成单个标记。输出表明,海明威的三本小说文本中有 10,599 个唯一标记,这个数量比 GPT-2 中的 50,257 个标记小得多。这将显著减少模型大小和训练时间。

此外,我们还将添加一个额外的标记"UNK"来表示未知标记。这在遇到包含未知标记的提示时很有用,允许我们将它们转换为索引以输入到模型中。否则,我们只能使用包含前 10,599 个标记的提示。假设你在提示中包含单词“technology”。由于“technology”不是word_to_int字典中的标记之一,程序将会崩溃。通过包含"UNK"标记,你可以防止程序在这种情况下崩溃。当你训练自己的 GPT 时,你应该始终包含"UNK"标记,因为不可能包含词汇表中的所有标记。为此,我们将"UNK"添加到独特标记列表中,并将它们映射到索引。

列表 12.4 将标记映射到索引

from collections import Counter   

word_counts=Counter(text_tokenized)    
words=sorted(word_counts, key=word_counts.get,
                      reverse=True)     
words.append("UNK")                                            ①
text_length=len(text_tokenized)
ntokens=len(words)                                             ②
print(f"the text contains {text_length} words")
print(f"there are {ntokens} unique tokens")  
word_to_int={v:k for k,v in enumerate(words)}                  ③
int_to_word={v:k for k,v in word_to_int.items()}               ④
print({k:v for k,v in word_to_int.items() if k in words[:10]})
print({k:v for k,v in int_to_word.items() if v in words[:10]})

① 将“UNK”添加到独特标记列表中

② 计算词汇表的大小,ntokens,这将成为我们模型的一个超参数

③ 将标记映射到索引

④ 将索引映射到标记

前一个代码块输出的结果是

the text contains 698207 words
there are 10600 unique tokens
{'.': 0, 'the': 1, ',': 2, '"': 3, '"': 4, 'and': 5, 'i': 6, 'to': 7, 'he': 8, 'it': 9}
{0: '.', 1: 'the', 2: ',', 3: '"', 4: '"', 5: 'and', 6: 'i', 7: 'to', 8: 'he', 9: 'it'}

三部小说的文本包含 698,207 个标记。在词汇表中包含"UNK"后,现在独特的标记总数为 10,600。字典word_to_int为每个独特的标记分配一个不同的索引。例如,最频繁的标记,句号(.),被分配了索引 0,而单词“the”被分配了索引 1。字典int_to_word将索引转换回标记。例如,索引 3 被转换回开引号(“),而索引 4 被转换回闭引号(”)。

我们打印出文本中的前 20 个标记及其对应的索引:

print(text_tokenized[0:20])
wordidx=[word_to_int[w] for w in text_tokenized]
print([word_to_int[w] for w in text_tokenized[0:20]])

输出结果是

['he', 'was', 'an', 'old', 'man', 'who', 'fished', 'alone', 'in', 'a', 
'skiff', 'in', 'the', 'gulf', 'stream', 'and', 'he', 'had', 'gone',
 'eighty']
[8, 16, 98, 110, 67, 85, 6052, 314, 14, 11, 1039, 14, 1, 3193, 507, 5, 8,
25, 223, 3125] 

接下来,我们将索引分解成等长的序列,用作训练数据。

12.2.2 创建训练批次

我们将使用 128 个标记的序列作为模型的输入。然后我们将序列向右移动一个标记,并将其用作输出。

具体来说,我们创建(x, y)对用于训练目的。每个 x 是一个包含 128 个索引的序列。我们选择 128 个索引是为了在训练速度和模型捕捉长距离依赖的能力之间取得平衡。设置得太高可能会减慢训练速度,而设置得太低可能会阻止模型有效地捕捉长距离依赖。

一旦我们有了序列 x,我们将序列窗口向右滑动一个标记,并将其用作目标 y。在序列生成训练中,将序列向右移动一个标记并用作输出是一个常见的技术,包括 GPTs。我们在第八章到第十章中已经这样做过了。以下代码块创建了训练数据:

import torch

seq_len=128                                                 ①
xys=[]
for n in range(0, len(wordidx)-seq_len-1):
    x = wordidx[n:n+seq_len]                                ②
    y = wordidx[n+1:n+seq_len+1]                            ③
    xys.append((torch.tensor(x),(torch.tensor(y))))         ④

① 将序列长度设置为 128 个索引

② 输入序列 x 包含训练文本中的 128 个连续索引。

③ 将 x 向右移动一个位置,并将其用作输出 y

④ 将(x, y)对添加到训练数据中。

我们创建了一个名为 xys 的列表来包含(x, y)对作为我们的训练数据。正如我们在前面的章节中所做的那样,我们将训练数据组织成批次以稳定训练。我们选择批次大小为 32:

from torch.utils.data import DataLoader

torch.manual_seed(42)
batch_size=32
loader = DataLoader(xys, batch_size=batch_size, shuffle=True)

x,y=next(iter(loader))
print(x)
print(y)
print(x.shape,y.shape)

我们打印出一对 x 和 y 作为示例。输出如下

tensor([[   3,  129,    9,  ...,   11,  251,   10],
        [   5,   41,   32,  ...,  995,   52,   23],
        [   6,   25,   11,  ...,   15,    0,   24],
        ...,
        [1254,    0,    4,  ...,   15,    0,    3],
        [  17,    8, 1388,  ...,    0,    8,   16],
        [  55,   20,  156,  ...,   74,   76,   12]])
tensor([[ 129,    9,   23,  ...,  251,   10,    1],
        [  41,   32,   34,  ...,   52,   23,    1],
        [  25,   11,   59,  ...,    0,   24,   25],
        ...,
        [   0,    4,    3,  ...,    0,    3,   93],
        [   8, 1388,    1,  ...,    8,   16, 1437],
        [  20,  156,  970,  ...,   76,   12,   29]])
torch.Size([32, 128]) torch.Size([32, 128])

每个 x 和 y 的形状为(32, 128)。这意味着在每个训练数据批次中,有 32 对序列,每个序列包含 128 个索引。当一个索引通过nn.Embedding()层传递时,PyTorch 会查找嵌入矩阵中对应的行,并返回该索引的嵌入向量,从而避免了创建可能非常大的 one-hot 向量的需要。因此,当 x 通过词嵌入层传递时,它就像 x 首先被转换为一个维度为(32, 128, 256)的 one-hot 张量。同样,当 x 通过位置编码层(由nn.Embedding()层实现)传递时,它就像 x 首先被转换为一个维度为(32, 128, 128)的 one-hot 张量。

12.3 构建用于生成文本的 GPT

现在我们已经准备好了训练数据,我们将从头开始创建一个 GPT 模型来生成文本。我们将构建的模型架构与第十一章中构建的 GPT-2XL 模型相似。然而,我们不会使用 48 个解码器层,而只会使用 3 个解码器层。嵌入维度和词汇量都小得多,正如我在本章前面所解释的。因此,我们的 GPT 模型将比 GPT-2XL 拥有远少的参数。

我们将遵循第十一章中的相同步骤。在这个过程中,我们将突出我们 GPT 模型与 GPT-2XL 之间的差异,并解释这些修改的原因。

12.3.1 模型超参数

解码器块中的前馈网络使用高斯误差线性单元(GELU)激活函数。GELU 已被证明可以增强深度学习任务中的模型性能,尤其是在自然语言处理中。这已成为 GPT 模型中的标准做法。因此,我们定义了一个 GELU 类,就像我们在第十一章中所做的那样:

import torch
from torch import nn
import math

device="cuda" if torch.cuda.is_available() else "cpu"
class GELU(nn.Module):
    def forward(self, x):
        return 0.5*x*(1.0+torch.tanh(math.sqrt(2.0/math.pi)*\
                       (x + 0.044715 * torch.pow(x, 3.0))))

在第十一章中,即使在文本生成阶段,我们也没有使用 GPU,因为模型本身太大,如果我们将模型加载到常规 GPU 上,它就会耗尽内存。

然而,在本章中,我们的模型显著更小。我们将模型移动到 GPU 上进行更快的训练。我们还将使用 GPU 上的模型生成文本。

我们使用一个Config()类来包含模型中使用的所有超参数:

class Config():
    def __init__(self):
        self.n_layer = 3
        self.n_head = 4
        self.n_embd = 256
        self.vocab_size = ntokens
        self.block_size = 128 
        self.embd_pdrop = 0.1
        self.resid_pdrop = 0.1
        self.attn_pdrop = 0.1
config=Config()

Config()类中的属性被用作我们 GPT 模型的超参数。我们将n_layer属性设置为 3,表示我们的 GPT 模型有三个解码器层。n_head属性设置为 4,意味着在计算因果自注意力时,我们将查询 Q、键 K 和值 V 向量分割成 4 个并行头。n_embd属性设置为 256,表示嵌入维度为 256:每个标记将由一个 256 值的向量表示。vocab_size属性由词汇表中的唯一标记数量确定。如上一节所述,我们的训练文本中有 10,600 个唯一标记。block_size属性设置为 128,表示输入序列最多包含 128 个标记。我们将 dropout 率设置为 0.1,与第十一章中设置的一样。

12.3.2 建模因果自注意力机制

因果自注意力机制的定义与第十一章相同:

import torch.nn.functional as F
class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd)
        self.c_proj = nn.Linear(config.n_embd, config.n_embd)
        self.attn_dropout = nn.Dropout(config.attn_pdrop)
        self.resid_dropout = nn.Dropout(config.resid_pdrop)
        self.register_buffer("bias", torch.tril(torch.ones(\
                   config.block_size, config.block_size))
             .view(1, 1, config.block_size, config.block_size))
        self.n_head = config.n_head
        self.n_embd = config.n_embd

    def forward(self, x):
        B, T, C = x.size() 
        q, k ,v  = self.c_attn(x).split(self.n_embd, dim=2)
        hs = C // self.n_head
        k = k.view(B, T, self.n_head, hs).transpose(1, 2)
        q = q.view(B, T, self.n_head, hs).transpose(1, 2)
        v = v.view(B, T, self.n_head, hs).transpose(1, 2)

        att = (q @ k.transpose(-2, -1)) *\
            (1.0 / math.sqrt(k.size(-1)))
        att = att.masked_fill(self.bias[:,:,:T,:T] == 0, \
                              float(‚-inf'))
        att = F.softmax(att, dim=-1)
        att = self.attn_dropout(att)
        y = att @ v 
        y = y.transpose(1, 2).contiguous().view(B, T, C)
        y = self.resid_dropout(self.c_proj(y))
        return y

在计算因果自注意力时,输入嵌入通过三个神经网络传递,以获得查询 Q、键 K 和值 V。然后我们将它们各自分割成四个并行头,并在每个头内部计算掩码自注意力。之后,我们将四个注意力向量重新连接成一个单一的注意力向量,然后将其用作CausalSelfAttention()类的输出。

12.3.3 构建 GPT 模型

我们将前馈网络与因果自注意力子层结合,形成一个解码器块。前馈网络向模型注入非线性。没有它,Transformer 将只是一个线性操作的序列,限制了其捕捉复杂数据关系的能力。此外,前馈网络独立且均匀地处理每个位置,使得自注意力机制识别的特征能够进行转换。这有助于捕捉输入数据的各个方面,从而增强模型表示信息的能力。解码器块的定义如下:

class Block(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.ln_1 = nn.LayerNorm(config.n_embd)
        self.attn = CausalSelfAttention(config)
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp = nn.ModuleDict(dict(
            c_fc   = nn.Linear(config.n_embd, 4 * config.n_embd),
            c_proj = nn.Linear(4 * config.n_embd, config.n_embd),
            act    = GELU(),
            dropout = nn.Dropout(config.resid_pdrop),
        ))
        m = self.mlp
        self.mlpf=lambda x:m.dropout(m.c_proj(m.act(m.c_fc(x)))) 

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))
        x = x + self.mlpf(self.ln_2(x))
        return x

我们 GPT 模型中的每个解码器块由两个子层组成:一个因果自注意力子层和一个前馈网络。我们对每个子层应用层归一化和残差连接,以提高稳定性和性能。然后,我们将三个解码器层堆叠在一起,形成 GPT 模型的主体。

列表 12.5 构建 GPT 模型

class Model(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.block_size = config.block_size
        self.transformer = nn.ModuleDict(dict(
            wte = nn.Embedding(config.vocab_size, config.n_embd),
            wpe = nn.Embedding(config.block_size, config.n_embd),
            drop = nn.Dropout(config.embd_pdrop),
            h = nn.ModuleList([Block(config) 
                               for _ in range(config.n_layer)]),
            ln_f = nn.LayerNorm(config.n_embd),))
        self.lm_head = nn.Linear(config.n_embd,
                                 config.vocab_size, bias=False)
        for pn, p in self.named_parameters():
            if pn.endswith('c_proj.weight'):    
                torch.nn.init.normal_(p, mean=0.0, 
                  std=0.02/math.sqrt(2 * config.n_layer))
    def forward(self, idx, targets=None):
        b, t = idx.size()
        pos=torch.arange(0,t,dtype=\
            torch.long).unsqueeze(0).to(device)              ①
        tok_emb = self.transformer.wte(idx) 
        pos_emb = self.transformer.wpe(pos) 
        x = self.transformer.drop(tok_emb + pos_emb)
        for block in self.transformer.h:
            x = block(x)
        x = self.transformer.ln_f(x)
        logits = self.lm_head(x)
        return logits

① 如果可用,将位置编码移动到支持 CUDA 的 GPU 上

位置编码是在Model()类中创建的。因此,我们需要将其移动到支持 CUDA 的 GPU(如果可用)上,以确保模型的所有输入都在同一设备上。未能这样做将导致错误信息。

模型的输入由与词汇表中的标记对应的索引序列组成。我们通过词嵌入和位置编码传递输入,并将两者相加形成输入嵌入。然后输入嵌入通过三个解码器块。之后,我们对输出应用层归一化,并将其附加一个线性头,以便输出的数量为 10,600,即词汇表的大小。输出是词汇表中 10,600 个标记的 logits。稍后,我们将对 logits 应用 softmax 激活函数,以获得生成文本时词汇表中唯一标记的概率分布。

接下来,我们将通过实例化我们之前定义的Model()类来创建我们的 GPT 模型:

model=Model(config)
model.to(device)
num=sum(p.numel() for p in model.transformer.parameters())
print("number of parameters: %.2fM" % (num/1e6,))
print(model)

输出如下

number of parameters: 5.12M
Model(
  (transformer): ModuleDict(
    (wte): Embedding(10600, 256)
    (wpe): Embedding(128, 256)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-2): 3 x Block(
        (ln_1): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
        (attn): CausalSelfAttention(
          (c_attn): Linear(in_features=256, out_features=768, bias=True)
          (c_proj): Linear(in_features=256, out_features=256, bias=True)
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
        (mlp): ModuleDict(
          (c_fc): Linear(in_features=256, out_features=1024, bias=True)
          (c_proj): Linear(in_features=1024, out_features=256, bias=True)
          (act): GELU()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
  )
  (lm_head): Linear(in_features=256, out_features=10600, bias=False)
)

我们的 GPT 模型有 512 万个参数。我们模型的结构与 GPT-2XL 相似。如果您将上面的输出与第十一章的输出进行比较,您会看到唯一的不同之处在于超参数,如嵌入维度、解码器层数、词汇表大小等。

12.4 训练 GPT 模型生成文本

在本节中,您将使用本章前面准备好的训练数据批量来训练您刚刚构建的 GPT 模型。一个相关的问题是应该训练多少个 epoch。训练 epoch 太少可能会导致文本不连贯,而训练 epoch 太多可能会导致模型过拟合,这可能会导致生成的文本与训练文本中的段落完全相同。

因此,我们将训练模型 40 个 epoch。我们将在每个 10 个 epoch 后保存模型,并评估哪个版本的训练模型可以生成连贯的文本,而不仅仅是复制训练文本中的段落。另一种潜在的方法是创建一个验证集,并在模型在验证集中的性能收敛时停止训练,就像我们在第二章中所做的那样。

12.4.1 训练 GPT 模型

和往常一样,我们将使用 Adam 优化器。由于我们的 GPT 模型本质上执行的是多类别分类,因此我们将使用交叉熵损失作为我们的损失函数:

lr=0.0001
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
loss_func = nn.CrossEntropyLoss()

我们将训练模型 40 个 epoch,如下所示。

列表 12.6 训练 GPT 模型生成文本

model.train()  
for i in range(1,41):
    tloss = 0.
    for idx, (x,y) in enumerate(loader):                      ①
        x,y=x.to(device),y.to(device)
        output = model(x)
        loss=loss_func(output.view(-1,output.size(-1)),
                           y.view(-1))                        ②
        optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(),1)        ③
        optimizer.step()                                      ④
        tloss += loss.item()
    print(f'epoch {i} loss {tloss/(idx+1)}') 
    if i%10==0:
        torch.save(model.state_dict(),f'files/GPTe{i}.pth')   ⑤

① 遍历所有训练数据批量

② 将模型预测与实际输出进行比较

③ 将梯度范数剪裁到 1

④ 调整模型参数以最小化损失

⑤ 每十个 epoch 后保存模型

在训练过程中,我们将所有输入序列 x 批量通过模型以获得预测。我们将这些预测与批量的输出序列 y 进行比较,并计算交叉熵损失。然后调整模型参数以最小化这个损失。请注意,我们已经将梯度范数剪裁到 1 以避免潜在的梯度爆炸问题。

梯度范数剪裁

梯度范数裁剪是训练神经网络时用来防止梯度爆炸问题的技术。这个问题发生在损失函数相对于模型参数的梯度变得过大时,导致训练不稳定和模型性能差。在梯度范数裁剪中,如果梯度的范数(大小)超过某个阈值,则将梯度缩放。这确保了梯度不会变得过大,从而保持稳定的训练并提高收敛速度。

如果你有 CUDA 支持的 GPU,这个过程可能需要几个小时。训练完成后,四个文件,GPTe10.pth, GPTe20.pth, ..., GPTe40.pth,将保存在你的电脑上。或者,你也可以从我的网站下载训练好的模型:gattonweb.uky.edu/faculty/lium/gai/GPT.zip

12.4.2 生成文本的函数

现在我们有了多个训练模型的版本,我们可以继续进行文本生成并比较不同版本的性能。我们可以评估哪个版本表现最好,并使用该版本生成文本。

与 GPT-2XL 中的过程类似,文本生成开始于将一个索引序列(代表标记)作为提示输入到模型中。模型预测下一个标记的索引,然后将这个索引添加到提示中形成一个新的序列。这个新序列被反馈到模型中进行进一步的预测,这个过程重复进行,直到生成所需数量的新标记。

为了方便这个过程,我们定义了一个sample()函数。这个函数接收一个索引序列作为输入,代表文本的当前状态。然后它迭代地预测并添加新的索引到序列中,直到达到指定的新的标记数max_new_tokens。下面的列表展示了实现。

列表 12.7 用于预测后续索引的sample()函数

def sample(idx, weights, max_new_tokens, temperature=1.0, top_k=None):
    model.eval()
    model.load_state_dict(torch.load(weights,
        map_location=device))                                 ①
    original_length=len(idx[0])
    for _ in range(max_new_tokens):                           ②
        if idx.size(1) <= config.block_size:
            idx_cond = idx  
        else:
            idx_cond = idx[:, -config.block_size:]
        logits = model(idx_cond.to(device))                   ③
        logits = logits[:, -1, :] / temperature
        if top_k is not None:
            v, _ = torch.topk(logits, top_k)
            logits[logits < v[:, [-1]]] = -float('Inf')
        probs = F.softmax(logits, dim=-1)
        idx_next=torch.multinomial(probs,num_samples=1)
        idx = torch.cat((idx, idx_next.cpu()), dim=1)         ④
    return idx[:, original_length:]                           ⑤

① 加载一个训练模型的版本

② 生成固定数量的新索引

③ 使用模型进行预测

④ 将新索引附加到序列的末尾

⑤ 只输出新索引

sample()函数的一个参数是weights,它代表保存在你电脑上的某个模型的训练权重。与第十一章中定义的sample()函数不同,我们这里的函数只返回新生成的索引,不包括输入到sample()函数中的原始索引。我们做出这个改变是为了适应提示中包含未知标记的情况。在这种情况下,我们的sample()函数确保最终输出保留原始提示。否则,所有未知标记在最终输出中都会被替换为"UNK"

接下来,我们定义一个generate()函数,根据提示生成文本。该函数首先将提示转换为一系列索引。然后,它使用sample()函数生成一个新的索引序列。之后,generate()函数将所有索引连接起来,并将它们转换回文本。实现方式如下所示。

列表 12.8 使用训练好的 GPT 模型生成文本的函数

UNK=word_to_int["UNK"]
def generate(prompt, weights, max_new_tokens, temperature=1.0,
             top_k=None):
    assert len(prompt)>0, "prompt must contain at least one token" ①
    text=prompt.lower().replace("\n", " ")
    for x in punctuations:
        text=text.replace(f"{x}", f" {x} ")
    text_tokenized=text.split() 
    idx=[word_to_int.get(w,UNK) for w in text_tokenized]           ②
    idx=torch.LongTensor(idx).unsqueeze(0)
    idx=sample(idx, weights, max_new_tokens, 
               temperature=1.0, top_k=None)                        ③
    tokens=[int_to_word[i] for i in idx.squeeze().numpy()]         ④
    text=" ".join(tokens)
    for x in '''").:;!?,-''''':
        text=text.replace(f" {x}", f"{x}") 
    for x in '''"(-''''':
        text=text.replace(f"{x} ", f"{x}")     
    return prompt+" "+text

① 确保提示不为空

② 将提示转换为一系列索引

③ 使用 sample()函数生成新的索引

④ 将新的索引序列转换回文本

我们确保提示不为空。如果为空,你将收到一个错误消息,提示“提示必须至少包含一个标记。”generate()函数允许你通过指定计算机上保存的权重来选择使用模型的哪个版本。例如,你可以选择将‘files/GPTe10.pth’作为函数的权重参数的值。该函数将提示转换为一系列索引,然后将这些索引输入模型以预测下一个索引。在生成一定数量的新索引后,该函数将整个索引序列转换回文本形式。

12.4.3 使用不同版本的训练模型进行文本生成

接下来,我们将尝试使用不同版本的训练模型来生成文本。

我们可以使用未知标记"UNK"作为无条件文本生成的提示。这在我们的情境中特别有益,因为我们想检查生成的文本是否直接复制自训练文本。虽然一个与训练文本非常不同的独特提示不太可能导致直接来自训练文本的段落,但无条件生成的文本更有可能是来自训练文本的。

我们首先使用经过 20 个 epoch 训练后的模型无条件生成文本:

prompt="UNK"
for i in range(10):
    torch.manual_seed(i)
    print(generate(prompt,'files/GPTe20.pth',max_new_tokens=20)[4:]))

输出结果为

way." "kümmel," i said. "it's the way to talk about it
--------------------------------------------------
," robert jordan said. "but do not realize how far he is ruined." "pero
--------------------------------------------------
in the fog, robert jordan thought. and then, without looking at last, so 
good, he 
--------------------------------------------------
pot of yellow rice and fish and the boy loved him. "no," the boy said.
--------------------------------------------------
the line now. it's wonderful." "he's crazy about the brave."
--------------------------------------------------
candle to us. "and if the maria kisses thee again i will commence kissing 
thee myself. it 
--------------------------------------------------
?" "do you have to for the moment." robert jordan got up and walked away in
--------------------------------------------------
. a uniform for my father, he thought. i'll say them later. just then he
--------------------------------------------------
and more practical to read and relax in the evening; of all the things he 
had enjoyed the next 
--------------------------------------------------
in bed and rolled himself a cigarette. when he gave them a log to a second 
grenade. " 
--------------------------------------------------

我们将提示设置为"UNK",并要求generate()函数无条件地生成 20 个新标记,重复 10 次。我们使用manual_seed()方法固定随机种子,以确保结果可重复。正如你所见,这里生成的 10 个短篇段落在语法上都是正确的,听起来像是海明威小说中的段落。例如,第一段中的“kummel”一词在《永别了,武器》中经常被提及。同时,上述 10 个段落中没有任何一个是直接从训练文本中复制的。

接下来,我们使用经过 40 个 epoch 训练后的模型无条件生成文本,看看会发生什么:

prompt="UNK"
for i in range(10):
    torch.manual_seed(i)
    print(generate(prompt,'files/GPTe40.pth',max_new_tokens=20)[4:]))

输出结果为

way." "kümmel, and i will enjoy the killing. they must have brought me a spit
--------------------------------------------------
," robert jordan said. "but do not tell me that he saw anything." "not
--------------------------------------------------
in the first time he had bit the ear like that and held onto it, his neck 
and jaws
--------------------------------------------------
pot of yellow rice with fish. it was cold now in the head and he could not 
see the
--------------------------------------------------
the line of his mouth. he thought." "the laughing hurt him." "i can
--------------------------------------------------
candle made? that was the worst day of my life until one other day." "don'
--------------------------------------------------
?" "do you have to for the moment." robert jordan took the glasses and 
opened the
--------------------------------------------------
. that's what they don't marry." i reached for her hand. "don
--------------------------------------------------
and more grenades. that was the last for next year. it crossed the river 
away from the front
--------------------------------------------------
in a revolutionary army," robert jordan said. "that's really nonsense. it's
--------------------------------------------------

这里生成的 10 个简短段落再次都是语法正确的,听起来像海明威小说的段落。然而,如果你仔细检查,第八个段落的大部分内容直接复制自小说《永别了,武器》。部分“他们不结婚。”我伸出手。“不。”在小说中也有出现。你可以通过搜索之前保存在你电脑上的文件 ThreeNovels.txt 来验证。

练习 12.1

使用训练了 10 个 epoch 的模型无条件地生成包含 50 个新 token 的文本段落。设置随机种子为 42,并保持temperaturetop-K采样为默认设置。检查生成的段落是否语法正确,以及是否有任何部分直接复制自训练文本。

或者,你可以使用不在训练文本中的独特提示来生成新文本。例如,你可能使用“老人看到鲨鱼靠近的”作为提示,并要求generate()函数向提示中添加 20 个新 token,重复此过程 10 次:

prompt="the old man saw the shark near the"
for i in range(10):
    torch.manual_seed(i)
    print(generate(prompt,'files/GPTe40.pth',max_new_tokens=20))
    print("-"*50)   

输出是

the old man saw the shark near the old man's head with his tail out and the old man hit him squarely in the center of
--------------------------------------------------
the old man saw the shark near the boat with one hand. he had no feeling of
the morning but he started to pull on it gently
--------------------------------------------------
the old man saw the shark near the old man's head. then he went back to 
another man in and leaned over and dipped the
--------------------------------------------------
the old man saw the shark near the fish now, and the old man was asleep in 
the water as he rowed he was out of the
--------------------------------------------------
the old man saw the shark near the boat. it was a nice-boat. he saw the old
 man's head and he started
--------------------------------------------------
the old man saw the shark near the boat to see him clearly and he was 
afraid that he was higher out of the water and the old
--------------------------------------------------
the old man saw the shark near the old man's head and then, with his tail 
lashing and his jaws clicking, the shark plowed
--------------------------------------------------
the old man saw the shark near the line with his tail which was not sweet 
smelling it. the old man knew that the fish was coming
--------------------------------------------------
the old man saw the shark near the fish with his jaws hooked and the old 
man stabbed him in his left eye. the shark still hung
--------------------------------------------------
the old man saw the shark near the fish and he started to shake his head 
again. the old man was asleep in the stern and he
--------------------------------------------------

生成的文本语法正确且连贯,与海明威小说《老人与海》中的段落非常相似。由于我们使用了训练了 40 个 epoch 的模型,生成直接反映训练数据的文本的可能性更高。然而,使用独特的提示可以降低这种可能性。

通过设置temperaturetop-K采样,我们可以进一步控制生成文本的多样性。在这种情况下,使用提示“老人看到鲨鱼靠近的”,温度为 0.9,top-50 采样,输出仍然主要语法正确:

prompt="the old man saw the shark near the"
for i in range(10):
    torch.manual_seed(i)
    print(generate(prompt,'files/GPTe20.pth',max_new_tokens=20,
                  temperature=0.9,top_k=50))
    print("-"*50) 

输出是

 The old man saw the shark near the boat. then he swung the great fish that 
was more comfortable in the sun. the old man could
--------------------------------------------------
the old man saw the shark near the boat with one hand. he wore his overcoat
 and carried the submachine gun muzzle down, carrying it in
--------------------------------------------------
the old man saw the shark near the boat with its long dip sharply and the 
old man stabbed him in the morning. he could not see
--------------------------------------------------
the old man saw the shark near the fish that was now heavy and long and 
grave he had taken no part in. he was still under
--------------------------------------------------
the old man saw the shark near the boat. it was a nice little light. then 
he rowed out and the old man was asleep over
--------------------------------------------------
the old man saw the shark near the boat to come. "old man's shack and i'll 
fill the water with him in
--------------------------------------------------
the old man saw the shark near the boat and then rose with his lines close 
him over the stern. "no," the old man
--------------------------------------------------
the old man saw the shark near the line with his tail go under. he was 
cutting away onto the bow and his face was just a
--------------------------------------------------
the old man saw the shark near the fish with his tail that he swung him in.
 the shark's head was out of water and
--------------------------------------------------
the old man saw the shark near the boat and he started to cry. he could 
almost have them come down and whipped him in again.
--------------------------------------------------

由于我们使用了训练了 20 个 epoch 的模型而不是 40 个 epoch,输出不太连贯,偶尔有语法错误。例如,第三段中的“with its long dip sharply”在语法上是不正确的。然而,生成直接复制自训练数据的文本的风险也较低。

练习 12.2

使用训练了 40 个 epoch 的模型生成包含 50 个新 token 的文本段落。使用“老人看到鲨鱼靠近的”作为提示;设置random seed为 42,temperature为 0.95,top_k为 100。检查生成的段落是否语法正确,以及文本的任何部分是否直接复制自训练文本。

在本章中,你学习了如何从头开始构建和训练一个 GPT 风格的 Transformer 模型。具体来说,你创建了一个只有 512 万个参数的 GPT-2 模型的简化版本。使用欧内斯特·海明威的三部小说作为训练数据,你成功训练了该模型。你还生成了与海明威的写作风格一致且连贯的文本。

摘要

  • 从 GPT 模型生成的文本风格将受到训练数据的高度影响。为了有效地生成文本,训练材料中文本长度和变化的平衡非常重要。训练数据集应该足够大,以便模型能够准确学习并模仿特定的写作风格。然而,如果数据集缺乏多样性,模型可能会直接从训练文本中复制段落。相反,过长的训练数据集可能需要过多的计算资源进行训练。

  • 在 GPT 模型中选择合适的超参数对于成功的模型训练和文本生成至关重要。设置超参数过大可能会导致参数过多,这会导致训练时间更长,模型过拟合。设置超参数过小可能会阻碍模型有效学习并捕捉训练数据中的写作风格。这可能会导致生成的文本不连贯。

  • 训练的合适轮数对于文本生成至关重要。训练轮数过少可能导致生成的文本不连贯,而训练轮数过多可能会导致模型过拟合,生成的文本与训练文本中的段落完全相同。

第四部分:应用和新发展

本部分涵盖了早期章节中生成模型的某些应用,以及生成 AI 领域的一些新发展。

在第十三章和第十四章中,你将学习两种生成音乐的方法:MuseGAN,它将音乐作品视为类似于图像的多维对象,以及 Music Transformer,它将音乐作品视为一系列音乐事件。第十五章介绍了扩散模型,这些模型构成了所有领先的文本到图像 Transformer(如 DALL-E 2 或 Imagen)的基础。第十六章使用 LangChain 库将预训练的大型语言模型与 Wolfram Alpha 和 Wikipedia API 结合,创建了一个零样本的全知全能个人助手。

第十三章:使用 MuseGAN 进行音乐生成

本章涵盖

  • 使用乐器数字接口进行音乐表示

  • 将音乐生成视为与图像生成类似的对象创建问题

  • 构建和训练生成对抗网络以生成音乐

  • 使用训练好的 MuseGAN 模型生成音乐

到目前为止,我们已经成功生成了形状、数字、图像和文本。在本章和下一章中,我们将探讨两种不同的生成逼真音乐的方法。本章将应用图像 GANs 的技术,将音乐视为类似于图像的多维对象。生成器将生成一首完整的音乐作品并将其提交给评论家(作为判别器,因为我们使用 Wasserstein 距离和梯度惩罚,如第五章所述)进行评估。然后,生成器将根据评论家的反馈修改音乐,直到它与训练数据集中的真实音乐非常相似。在下一章中,我们将音乐视为一系列音乐事件,采用自然语言处理(NLP)技术。我们将使用 GPT 风格的 Transformer 来根据先前的事件预测序列中最可能的音乐事件。这个 Transformer 将生成一系列音乐事件,这些事件可以转换为听起来逼真的音乐。

使用人工智能进行音乐生成的领域已经引起了广泛关注;MuseGAN 是一个突出的模型,由 Dong、Hsiao、Yang 和 Yang 在 2017 年提出。1 MuseGAN 是一个深度神经网络,利用生成对抗网络(GANs)来创建多轨音乐,其中的“Muse”一词象征着音乐背后的创造性灵感。该模型擅长理解代表不同乐器或不同声音的不同轨道之间的复杂交互(这在我们的训练数据中是这种情况)。因此,MuseGAN 可以生成和谐且统一的乐曲。

与其他 GAN 模型类似,MuseGAN 由两个主要组件组成:生成器和评论家(评论家提供对样本真实性的连续度量,而不是将样本分类为真实或虚假)。生成器的任务是生成音乐,而评论家评估音乐的质量并向生成器提供反馈。这种对抗性交互使得生成器能够逐步改进,从而创造出更真实、更具吸引力的音乐。

假设你是一位热衷于约翰·塞巴斯蒂安·巴赫的粉丝,并且已经听过他所有的作品。你可能想知道是否可以使用 MuseGAN 创建模仿他风格的合成音乐。答案是肯定的,你将在本章中学习如何做到这一点。

具体来说,你将首先探索如何将一段多轨音乐表示为一个多维对象。一个轨道本质上是一行音乐或声音,可以是不同的乐器,如钢琴、贝斯或鼓,或者不同的声音,如女高音、女低音、男高音或男低音。在创作电子音乐中的轨道时,你通常将其组织成小节(时间段),然后将每个小节细分为步骤以更好地控制节奏,接着为每个步骤分配一个特定的音符来创作旋律和节奏。因此,我们训练集中的每首音乐都是以(4,2,16,84)的形状结构化的:这意味着有四个音乐轨道,每个轨道由 2 个小节组成,每个小节包含 16 个步骤,每个步骤可以播放 84 种不同音符中的任意一种。

我们生成的 MuseGAN 音乐的风格将受到训练数据的影响。由于你对巴赫的作品感兴趣,你将使用《JSB 圣歌集》数据集来训练 MuseGAN,这是一个由巴赫创作的圣歌集合,为四个轨道编排。这些圣歌已被转换为钢琴卷表示,这是一种用于可视化和编码音乐的方法,特别是用于数字处理目的。你将学习如何将形状为(4,2,16,84)的音乐作品转换为音乐乐器数字接口(MIDI)文件,然后可以在你的电脑上播放。

虽然在前面章节中,生成器仅使用来自潜在空间的一个单一噪声向量来生成不同格式的内容,如形状、数字和图像,但 MuseGAN 中的生成器在生成音乐时会使用四个噪声向量。使用四个独立的噪声向量(和弦、风格、旋律和节奏,我将在本章后面详细解释)是设计选择,它允许在音乐生成过程中有更大的控制和多样性。这些噪声向量中的每一个都代表音乐的不同方面,通过单独操纵它们,模型可以生成更复杂和细腻的作品。

一旦模型被训练,我们将丢弃批评网络,这是 GAN 模型中的常见做法。然后,我们将利用训练好的生成器,通过输入来自潜在空间的四个噪声向量来生成音乐作品。以这种方式生成的音乐与巴赫的风格非常相似。

13.1 数字音乐表示

我们的目标是掌握从头开始构建和训练用于音乐生成的 GAN 模型的艺术。为了实现这一目标,我们需要从音乐理论的基础知识开始,包括理解音符、八度和音高数字。随后,我们将深入研究数字音乐的内部工作原理,特别是关注 MIDI 文件。

根据我们用于音乐生成的机器学习模型的类型,音乐作品的数字表示可能会有所不同。例如,在本章中,我们将音乐表示为一个多维对象,而在下一章中,我们将使用不同的格式:一系列索引。

在本节中,我们将介绍基本的音乐理论,然后转向使用钢琴卷来数字化表示音乐。你将学习如何在电脑上加载和播放一个示例 MIDI 文件。我们还将介绍 music21 Python 库,你将安装并使用它来可视化与音乐作品相关的乐谱音符。最后,你将学习将一首音乐作品表示为一个具有形状(4,2,16,84)的多维对象。

13.1.1 音乐音符、八度和音高

在本章中,我们将处理一个表示音乐作品为 4D 对象的训练数据集。要理解训练数据中的音乐作品的意义,首先熟悉音乐理论中的某些基本概念是至关重要的,例如音乐音符、八度和音高。这些概念相互关联,对于理解数据集至关重要。

图 13.1 说明了这些概念之间的关系。

图片

图 13.1 音乐音符、八度和音高的关系(也称为音符编号)。第一列显示了 11 个八度(范围从-1 到 9),代表不同的音乐声音级别。每个八度被细分为 12 个半音,这些半音列在图的最上面一行:C、C#、D、D#、...、B。在每个八度内,每个音符都被分配一个特定的音高编号,范围从 0 到 127,如图所示。

音乐音符是代表音乐中特定声音的符号。这些音符是音乐的基础元素,用于创作旋律、和弦和节奏。每个音符都被分配一个名称(如 A、B、C、D、E、F、G)并对应一个特定的频率,这决定了它的音高:音符听起来是高还是低。例如,中 C(C4)通常的频率约为 262 赫兹,这意味着其声波每秒振动 262 次。

你可能想知道“中 C(C4)”这个术语的含义。C4 中的数字 4 指的是八度,它是从一个音乐音高级别到下一个级别的距离。在图 13.1 中,最左边的列显示了 11 个八度级别,范围从-1 到 9。当你从一个八度级别移动到下一个时,声音的频率会翻倍。例如,A4 音符通常调校为 440 赫兹,而比 A4 高一个八度的 A5,则调校为 880 赫兹。

在西方音乐中,一个八度被分为 12 个半音,每个半音对应一个特定的音符。图 13.1 的顶部行列出了这 12 个半音:C、C#、D、D#、...、B。上下移动 12 个半音会到达相同的音符名称,但处于更高的或更低的八度。如前所述,A5 比 A4 高一个八度。

每个特定八度内的音符被分配一个音高数字,范围从 0 到 127,如图 13.1 所示。例如,C4 音符的音高数字为 60,而 F3 的音高数字为 53。音高数字是表示音乐音符的一种更有效的方式,因为它指定了八度水平和半音。你将在本章中使用的训练数据就是用音高数字编码的,正是出于这个原因。

13.1.2 多轨音乐简介

首先,让我们谈谈多轨音乐是如何工作的以及它是如何以数字形式表示的。在电子音乐制作中,“轨”通常指的是音乐的一个单独的层次或组成部分,例如鼓轨、贝斯轨或旋律轨。在古典音乐中,轨可能代表不同的声乐部分,如女高音、女低音、男高音和男低音。例如,我们在这章中使用的训练数据集,JSB Chorales 数据集,包含四个轨,对应四个声乐部分。在音乐制作中,每个轨都可以在数字音频工作站(DAW)中单独编辑和处理。这些轨由各种音乐元素组成,包括小节、步骤和音符。

小节(或度量)是由特定数量的拍子定义的时间段,每个拍子有特定的音符持续时间。在许多流行的音乐流派中,小节通常包含四个拍子,尽管这可以根据作品的拍号而变化。一个轨中的小节数量由轨的长度和结构决定。例如,在我们的训练数据集中,每个轨由两个小节组成。

在步骤序列的上下文中,这是一种在电子音乐中编程节奏和旋律的常用技术,一个“步骤”代表一小节的一个细分。在一个标准的 4/4 拍(一小节有四个拍子,每个拍子有四个步骤)中,你可能会发现每小节有 16 个步骤,每个步骤对应一小节的十六分之一。

最后,每个步骤包含一个音符。在我们的数据集中,我们限制范围为最常用的 84 个音符(音高数字从 0 到 83)。因此,步骤中的音符被编码为一个包含 84 个值的 one-hot 向量。

为了用实际例子说明这些概念,请从本书的 GitHub 仓库github.com/markhliu/DGAI下载文件 example.midi,并将其保存在你电脑的/files/目录下。具有.midi 扩展名的文件是 MIDI 文件。MIDI 是一种技术标准,概述了协议、数字接口和连接器,以使电子乐器、计算机和其他相关设备能够相互连接和通信。

MIDI 文件可以在您计算机上的大多数音乐播放器上播放。为了了解我们训练数据中的音乐类型,请使用您计算机上的音乐播放器打开您刚刚下载的文件 example.midi。它应该听起来像我在我的网站上放置的这个音乐文件:mng.bz/lrJB。文件 example.midi 是从本章训练数据集中的某个音乐作品转换而来的。稍后您将学习如何将形状为(4, 2, 16, 84)的音乐作品转换为可以在您计算机上播放的 MIDI 文件。

我们将使用 music21 Python 库,这是一个专为音乐分析、创作和操作设计的强大且全面的工具包,来可视化各种音乐概念是如何工作的。因此,请在您的计算机上的 Jupyter Notebook 应用程序的新单元中运行以下代码行:

!pip install music21

music21 库允许您将音乐以乐谱形式可视化,以便更好地理解轨道、小节、音级和音符。为了实现这一点,您必须首先在您的计算机上安装 MuseScore 应用程序。访问musescore.org/en/download并下载适用于您操作系统的最新版本的 MuseScore 应用程序。截至本文撰写时,最新版本是 MuseScore 4,我们将以此为例。确保您知道 MuseScore 应用程序在您计算机上的文件路径。例如,在 Windows 上,路径是 C:\Program Files\MuseScore 4\bin\MuseScore4.exe。运行以下列表中的代码单元以可视化文件 example.midi 的乐谱。

列表 13.1 使用 music21 库可视化乐谱

%matplotlib inline                                          ①
from music21 import midi, environment

mf = midi.MidiFile()    
mf.open("files/example.midi")                               ②
mf.read()
mf.close()
stream = midi.translate.midiFileToStream(mf)
us = environment.Environment() 
path = r'C:\Program Files\MuseScore 4\bin\MuseScore4.exe'
us['musescoreDirectPNGPath'] = path                         ③
stream.show()                                               ④

① 在 Jupyter 笔记本中显示图像,而不是在原始应用程序中

② 打开 MIDI 文件

③ 定义 MuseScore 应用程序的路径

④ 显示乐谱

对于 macOS 操作系统的用户,将前面代码单元中的路径更改为/Applications/MuseScore 4.app/Contents/MacOS/mscore。对于 Linux 用户,将路径更改为/home/[用户名]/.local/bin/mscore4portable,将[用户名]替换为您实际的用户名。例如,我的用户名是mark,所以路径是/home/mark/.local/bin/mscore4portable。

执行前面的代码单元将显示类似于图 13.2 所示的乐谱。请注意,图中的注释是我添加的,所以您将只看到乐谱而没有任何注释。

图 13.2 JSB Chorales 数据集中一首音乐作品的乐谱。音乐有四个轨道,代表合唱中的四个声部:女高音、女低音、男高音和男低音。乐谱结构为每个轨道两个小节,左右两侧分别代表第一和第二小节。每个小节由 16 个步骤组成,与 4/4 拍号相匹配,其中一小节包含四个节拍,每个节拍细分为四个十六分音符。总共可能有 84 个不同的音高,每个音符都表示为一个包含 84 个值的 one-hot 向量。

JSB Chorales 数据集,由约翰·塞巴斯蒂安·巴赫的合唱作品组成,常用于音乐生成任务中训练机器学习模型。数据集中每首音乐作品的形状(4, 2, 16, 84)可以这样解释。数字 4 代表合唱中的四个声部:女高音、女低音、男高音和男低音。每个声部在数据集中被视为一个单独的轨道。每首作品分为两个小节(也称为乐句)。数据集以这种方式格式化,以便标准化音乐作品的长度,用于训练目的。数字 16 代表每个小节中的步骤数(或细分)。最后,音符以 84 个值进行 one-hot 编码,表示每个步骤中可以演奏的可能音高(或音符)的数量。

13.1.3 数字化音乐表示:钢琴卷轴

钢琴卷轴是音乐的一种视觉表示,常用于 MIDI 编曲软件和 DAWs 中。它以传统钢琴卷轴命名,这种卷轴在自动演奏钢琴中使用,其中包含带有孔的物理卷纸,以表示音符。在数字环境中,钢琴卷轴发挥着类似的功能,但以虚拟格式存在。

钢琴卷轴以网格形式显示,时间水平表示(从左到右),音高垂直表示(从下到上)。每一行对应一个特定的音符,高音在顶部,低音在底部,类似于钢琴键盘的布局。

音符以网格上的条形或块表示。音符块沿垂直轴的位置表示其音高,而沿水平轴的位置表示其在音乐中的时间。音符块的长度表示音符的持续时间。

让我们使用 music21 库来展示钢琴卷轴的样子。在您的 Jupyter Notebook 应用的新单元格中运行以下代码行:

stream.plot()

输出结果如图 13.3 所示。

music21 库还允许您查看与前面钢琴卷轴对应的量化音符:

for n in stream.recurse().notes:
    print(n.offset, n.pitches)

输出结果

0.0 (<music21.pitch.Pitch E4>,)
0.25 (<music21.pitch.Pitch A4>,)
0.5 (<music21.pitch.Pitch G4>,)
0.75 (<music21.pitch.Pitch F4>,)
1.0 (<music21.pitch.Pitch E4>,)
1.25 (<music21.pitch.Pitch D4>,)
1.75 (<music21.pitch.Pitch E4>,)
2.0 (<music21.pitch.Pitch E4>,)
2.5 (<music21.pitch.Pitch D4>,)
3.0 (<music21.pitch.Pitch C4>,)
3.25 (<music21.pitch.Pitch A3>,)
3.75 (<music21.pitch.Pitch B3>,)
0.0 (<music21.pitch.Pitch G3>,)
0.25 (<music21.pitch.Pitch A3>,)
0.5 (<music21.pitch.Pitch B3>,)
…
3.25 (<music21.pitch.Pitch F2>,)
3.75 (<music21.pitch.Pitch E2>,)

图片

图 13.3 一段音乐的钢琴卷。钢琴卷是音乐作品的图形表示,以网格形式呈现,时间从左到右水平进展,音高从下到上垂直表示。网格上的每一行对应一个独特的音乐音符,排列方式类似于钢琴键盘,高音符位于顶部,低音符位于底部。这首特定的音乐由两个小节组成,因此在图中可以看到两个不同的部分。音符块的垂直位置表示其音高,而其水平位置表示音符在作品中的演奏时间。此外,音符块的长度反映了音符持续的时长。

我省略了大部分输出。前一个输出中每行的第一个值代表时间。在大多数情况下,每行之后时间增加 0.25 秒。如果下一行的时间增加超过 0.25 秒,这意味着音符持续的时间超过 0.25 秒。正如你所见,起始音符是 E4。0.25 秒后,音符变为 A4,然后是 G4,以此类推。这解释了图 13.3 中(最左侧)的前三个块,它们分别具有 E、A 和 G 的值。

你可能好奇如何将音乐音符序列转换成形状为(4, 2, 16, 84)的对象。为了理解这一点,让我们检查音乐音符中每个时间步的音高数字:

for n in stream.recurse().notes:
    print(n.offset,n.pitches[0].midi)

输出结果为

0.0 64
0.25 69
0.5 67
0.75 65
1.0 64
1.25 62
1.75 64
2.0 64
2.5 62
3.0 60
3.25 57
3.75 59
0.0 55
0.25 57
0.5 59
…
3.25 41
3.75 40

前面的代码块已将每个时间步的音乐音符转换为基于图 13.1 中使用的映射的音高数字,范围在 0 到 83 之间。然后,每个音高数字被转换为具有 84 个值的 one-hot 变量,其中所有值均为-1,只有一个位置为 1。我们使用-1 和 1 而不是 0 和 1 进行 one-hot 编码,因为将值放置在-1 和 1 之间可以将数据围绕 0 中心化,这可以使训练更加稳定和快速。许多激活函数和权重初始化方法都假设输入数据围绕 0 中心化。图 13.4 说明了如何将一段 MIDI 音乐编码成形状为(4, 2, 16, 84)的对象。

图片

图 13.4 如何使用 4D 对象表示一段音乐。在我们的训练数据中,每段音乐都由形状为(4, 2, 16, 84)的 4D 对象表示。第一个维度代表四个音乐轨道,即音乐中的四个声部(女高音、女低音、男高音和男低音)。每个音乐轨道分为两个小节。每个小节有四个节拍,每个节拍有四个音符;因此,每个小节有 16 个音符。最后,每个音符由一个具有 84 个值的 one-hot 变量表示,其中所有值均为-1,只有一个位置为 1。

图 13.4 解释了音乐对象形状的维度(4, 2, 16, 84)。本质上,每首音乐作品包含四个轨道,每个轨道包含两个小节。每个小节被细分为 16 个音符。鉴于我们的训练集中音高数字从 0 到 83,每个音符由一个包含 84 个值的 one-hot 向量表示。

在后续关于准备训练数据的讨论中,我们将探讨如何将形状为(4, 2, 16, 84)的对象转换回 MIDI 格式的音乐作品,以便在计算机上播放。

13.2 音乐生成的蓝图

在创作音乐时,我们需要融入更详细的输入以增强控制和多样性。与仅从潜在空间中利用单个噪声向量来生成形状、数字和图像的方法不同,我们在音乐生成过程中将使用四个不同的噪声向量。由于每首音乐作品包含四个轨道和两个小节,我们将使用四个向量来管理这种结构。我们将使用一个向量来控制所有轨道和小节,另一个向量来控制所有轨道中的每个小节,第三个向量来监督所有轨道跨越小节,第四个向量来管理每个轨道中的每个单独小节。本节将向您介绍和弦、风格、旋律和节奏的概念,并解释它们如何影响音乐生成的各个方面。之后,我们将讨论构建和训练 MuseGAN 模型所涉及的步骤。

13.2.1 使用和弦、风格、旋律和节奏构建音乐

在音乐生成阶段稍后,我们从潜在空间中获取四个噪声向量(和弦、风格、旋律和节奏),并将它们输入到生成器中,以创建一首音乐作品。你可能想知道这四条信息的含义。在音乐中,和弦、风格、旋律和节奏是构成作品整体声音和感觉的关键元素。接下来,我将简要解释每个元素。

风格指的是音乐创作、表演和体验的特征方式。它包括音乐类型(如爵士、古典、摇滚等)、音乐创作的时代以及作曲家或表演者的独特方法。风格受文化、历史和个人因素的影响,有助于定义音乐的个性。

节奏感是音乐中的节奏感或摇摆,尤其是在放克、爵士和灵魂乐等风格中。这是让你想要脚打拍子或跳舞的原因。节奏感是通过强调模式、节奏部分(如鼓、贝斯等)之间的互动和速度来创造的。它是赋予音乐运动感和流畅感的关键元素。

和弦是由两个或更多音符同时演奏的组合。它们为音乐提供了和声基础。和弦建立在音阶之上,用于创建音乐的结构和情感深度。不同的和弦类型(大调、小调、减调、增调等)及其编排可以唤起听众的各种情绪和感受。

最后,旋律是音乐作品中最容易识别的音符序列。它是你可能吹口哨或跟着唱的部分。旋律通常由音阶构成,以音高、节奏和轮廓(音高的上升和下降模式)为特征。一首好的旋律易于记忆且富有表现力,传达作品的主要音乐和情感主题。

这些元素共同协作,和谐地创造出音乐作品的整体声音和体验。每个元素都有其作用,但它们都相互影响,共同产生最终的音乐作品。具体来说,一首音乐作品由四个轨道组成,每个轨道有两个小节,从而产生八个小节/轨道组合。我们将使用一个噪声向量用于风格,应用于所有八个小节。我们将使用八个不同的噪声向量用于旋律,每个向量用于一个独特的小节。有四个噪声向量用于节奏,每个向量应用于不同的轨道,两个小节都保持相同。两个噪声向量将用于和弦,每个小节一个。图 13.5 展示了这四个元素如何共同贡献于完整音乐作品的创作。

图 13.5

图 13.5 使用和弦、风格、旋律和节奏生成音乐。每一首音乐作品由四个轨道组成,跨越两个小节。为此,我们将从潜在空间中提取四个噪声向量。第一个向量,代表和弦,其维度为(1,32)。这个向量将通过一个时间网络进行处理,将和弦扩展为两个(1,32)向量,对应两个小节,所有轨道上的值都相同。第二个向量,表示风格,其维度也为(1,32),在所有轨道和小节中保持不变。第三个向量,旋律,形状为(4,32)。它将通过一个时间网络拉伸成两个(4,32)向量,从而产生八个(1,32)向量,每个向量代表一个独特的轨道和小节组合。最后,第四个向量,节奏,其维度为(4,32),将应用于四个轨道,两个小节都保持相同的值。

生成器通过一次生成一个轨道的一小节来创建音乐作品。为此,它需要四个形状为(1, 32)的噪声向量作为输入。这些向量代表和弦、风格、旋律和节奏,每个都控制音乐的一个独特方面,如前所述。由于音乐作品由四个轨道组成,每个轨道有两个小节,因此总共有八个轨道/小节组合。因此,我们需要八组和弦、风格、旋律和节奏来生成音乐作品的全部部分。

我们将从潜在空间中获取四个与和弦、风格、旋律和节奏对应的噪声向量。我们还将后来引入一个时间网络,其作用是沿着小节维度扩展输入。对于两个小节,这意味着输入大小的加倍。音乐本质上是时间的,具有随时间展开的模式和结构。MuseGAN 中的时间网络旨在捕捉这些时间依赖关系,确保生成的音乐具有连贯和逻辑的进展。

和弦的噪声向量形状为(1, 32)。在通过时间网络处理后,我们获得两个(1, 32)大小的向量。第一个向量用于第一小节中的所有四个轨道,而第二个向量用于第二小节中的所有四个轨道。

风格的噪声向量,同样形状为(1, 32),被均匀地应用于所有八个轨道/小节组合。请注意,我们不会将风格向量通过时间网络传递,因为风格向量被设计为在小节间保持相同。

旋律的噪声向量形状为(4, 32)。当通过时间网络传递时,它会产生两个(4, 32)大小的向量,这些向量进一步分解为八个(1, 32)大小的向量。每个向量都用于独特的轨道/小节组合。

最后,节奏的噪声向量形状为(4, 32),其应用方式是每个(1, 32)大小的向量应用于不同的轨道,在小节间保持不变。我们不会将节奏向量通过时间网络传递,因为节奏向量被设计为在小节间保持相同。

在为八个轨道/小节组合中的每个组合生成一小节音乐后,我们将它们合并以创建一个完整的音乐作品,由四个不同的轨道组成,每个轨道包含两个独特的小节。

13.2.2 训练 MuseGAN 的蓝图

第一章提供了 GANs 背后基础概念的概述。在第三章到第五章中,你探索了用于生成形状、数字和图像的 GANs 的创建和训练。本小节将总结构建和训练 MuseGAN 的步骤,突出与之前章节的不同之处。

MuseGAN 生成的音乐风格受训练数据风格的影响。因此,你应该首先收集一个适合训练的巴赫作品数据集。接下来,你将创建一个 MuseGAN 模型,该模型由一个生成器和评论家组成。生成器网络接收四个随机噪声向量作为输入(和弦、风格、旋律和节奏),并输出一首音乐。评论家网络评估一首音乐并分配一个评分,对真实音乐(来自训练集)给予高评分,对由生成器产生的假音乐给予低评分。生成器和评论家网络都利用深度卷积层来捕捉输入的空间特征。

图片

图 13.6 展示了训练 MuseGAN 生成音乐的步骤图。生成器通过从潜在空间中抽取四个随机噪声向量(图的上左角)来生成假音乐作品,并将其展示给评论家(中间)。评论家评估作品并分配一个评分。高评分表明该作品很可能是来自训练数据集的,而低评分则表明该作品很可能是假的(由生成器生成的)。此外,还向评论家展示了一个由真实和假样本混合而成的插值音乐作品(图的上左角)。训练过程结合了基于评论家对这一插值作品评分的梯度惩罚,并将其添加到总损失中。然后将评分与真实值进行比较,使评论家和生成器都能从这些评估中学习。经过多次训练迭代后,生成器变得擅长生成与真实样本几乎无法区分的音乐作品。

图 13.6 展示了 MuseGAN 的训练过程。生成器(图的下左角)接收四个随机噪声向量(和弦、风格、旋律和节奏)作为输入,并生成假音乐作品(图 13.6 中的步骤 1)。这些噪声向量来自潜在空间,它代表了 GAN 可以生成的潜在输出范围,从而能够创建多样化的数据样本。然后,这些假音乐作品(右上角来自训练集的真实音乐作品)被评论家(步骤 3)评估。评论家(图的底部中央)对所有音乐作品进行评分,目的是给予真实音乐高评分,而给予假音乐低评分(步骤 4)。

为了指导模型参数的调整,必须为生成器和批评家选择适当的损失函数。生成器的损失函数旨在鼓励生成与训练数据集中的数据点非常相似的数据点。具体来说,生成器的损失函数是批评家评分的负值。通过最小化这个损失函数,生成器努力创建能够从批评家那里获得高评分的音乐作品。另一方面,批评家的损失函数被制定为鼓励对真实和生成数据点进行准确评估。因此,如果音乐作品来自训练集,批评家的损失函数是评分本身;如果它是生成器生成的,则是评分的负值。本质上,批评家的目标是给真实音乐作品赋予高评分,给假音乐作品赋予低评分。

此外,我们像第五章中那样,将 Wasserstein 距离和梯度惩罚纳入损失函数,以增强 GAN 模型的训练稳定性和性能。为此,一个混合了真实和假音乐的插值音乐作品(如图 13.6 左上角所示)由批评家进行评估。然后,基于批评家对这一插值作品的评分,梯度惩罚在训练过程中被添加到总损失中。

在整个训练循环中,我们在训练批评家和生成器之间交替。在每个训练迭代中,我们从训练集中采样一批真实音乐作品和一批由生成器生成的假音乐作品。我们通过比较批评家的评分(即分数)与真实值(音乐作品是真是假)来计算总损失。然后,我们稍微调整生成器和批评家网络中的权重,以便在后续迭代中,生成器产生更逼真的音乐作品,批评家对真实音乐赋予更高的分数,对假音乐赋予更低的分数。

一旦 MuseGAN 完全训练完成,可以通过输入四个随机噪声向量到训练好的生成器来创建音乐。

13.3 准备 MuseGAN 的训练数据

我们将使用约翰·塞巴斯蒂安·巴赫的合唱作品作为我们的训练数据集,期望生成的音乐类似于巴赫的风格。如果你更喜欢其他音乐家的风格,你可以使用他们的作品作为训练数据。在本节中,我们将首先下载训练数据,并将其组织成批次以供后续训练。

此外,我们还了解到训练集中的音乐作品将被表示为 4D 对象。在本节中,你还将学习如何将这些多维对象转换为计算机上可播放的音乐作品。这种转换是必不可少的,因为 MuseGAN 生成的多维对象与训练集中的类似。在本章的后面部分,我们将 MuseGAN 产生的多维对象转换为 MIDI 文件,使你能够在计算机上收听生成的音乐。

13.3.1 下载训练数据

我们将使用 JSB Chorales 钢琴卷数据集作为我们的训练集。访问 Cheng-Zhi Anna Huang 的 GitHub 仓库 (github.com/czhuang/JSB-Chorales-dataset) 并下载音乐文件 Jsb16thSeparated.npz。将文件保存在您电脑上的 /files/ 目录中。

然后,从本书的 GitHub 仓库 (github.com/markhliu/DGAI) 下载两个实用模块 midi_util.py 和 MuseGAN_util.py,并将它们保存在您电脑上的 /utils/ 目录中。本章中的代码改编自 Azamat Kanametov 的优秀 GitHub 仓库 (github.com/akanametov/musegan)。有了这些文件,我们现在可以加载音乐文件并将它们组织成批次以进行处理:

from torch.utils.data import DataLoader
from utils.midi_util import MidiDataset

dataset = MidiDataset('files/Jsb16thSeparated.npz')
first_song=dataset[0]
print(first_song.shape)
loader = DataLoader(dataset, batch_size=64, 
                        shuffle=True, drop_last=True)

我们将您刚刚下载的数据集加载到 Python 中,然后提取第一首歌曲并将其命名为 first_song。由于歌曲表示为多维对象,我们打印出第一首歌曲的形状。最后,我们将训练数据放入 64 个批次的批次中,以便在章节的后续部分使用。

前一个代码块的输出是

torch.Size([4, 2, 16, 84])

数据集中的每首歌曲的形状为 (4, 2, 16, 84),如前一个输出所示。这表明每首歌曲由四个曲目组成,每个曲目有两小节。每小节包含 16 个时间步,在每个时间步,音符由一个包含 84 个值的独热向量表示。在每个独热向量中,所有值都设置为 -1,除了一个位置,其值设置为 1,表示音符的存在。您可以如下验证数据集中的值范围:

flat=first_song.reshape(-1,)
print(set(flat.tolist()))

输出是

{1.0, -1.0}

前一个输出显示,每个音乐作品中的值要么是 -1,要么是 1。

13.3.2 将多维对象转换为音乐作品

目前,歌曲格式为 PyTorch 张量,并准备好输入到 MuseGAN 模型中。然而,在我们继续之前,了解如何将这些多维对象转换为电脑上可播放的音乐作品非常重要。这将帮助我们稍后把生成的音乐作品转换为可播放的文件。

首先,我们将所有 84 个值的独热变量转换为介于 0 到 83 之间的音高数:

import numpy as np
from music21 import note, stream, duration, tempo

parts = stream.Score()
parts.append(tempo.MetronomeMark(number=66))
max_pitches = np.argmax(first_song, axis=-1)                ①
midi_note_score = max_pitches.reshape([2 * 16, 4])          ②
print(midi_note_score)

① 将 84 个值的独热向量转换为介于 0 到 83 之间的数字

② 将结果重塑为 (32, 4)

输出是

tensor([[74, 74, 74, 74],
…
        [70, 70, 69, 69],
        [67, 67, 69, 69],
        [70, 70, 70, 70],
        [69, 69, 69, 69],
        [69, 69, 69, 69],
        [65, 65, 65, 65],
        [58, 58, 60, 60],
…
        [53, 53, 53, 53]])

在这里显示的输出中,每一列代表一个音乐曲目,数值范围从 0 到 83。这些数字对应于音高数,正如您在图 13.1 中之前看到的。

现在,我们将把前一个代码块中的张量 midi_note_score 转换为实际的 MIDI 文件,让您可以在电脑上播放它。

列表 13.2 将音高数转换为 MIDI 文件

for i in range(4):                                         ①
    last_x = int(midi_note_score[:, i][0])
    s = stream.Part()
    dur = 0
    for idx, x in enumerate(midi_note_score[:, i]):        ②
        x = int(x)
        if (x != last_x or idx % 4 == 0) and idx > 0:
            n = note.Note(last_x)
            n.duration = duration.Duration(dur)
            s.append(n)
            dur = 0
        last_x = x
        dur = dur + 0.25                                   ③
    n = note.Note(last_x)
    n.duration = duration.Duration(dur)
    s.append(n)                                            ④
    parts.append(s)  
parts.write("midi","files/first_song.midi")

① 遍历四个音乐曲目

② 遍历每个曲目中的所有音符

③ 将 0.25 秒添加到每个时间步

④ 将音符添加到音乐流中

运行前面的代码单元后,您将在电脑上看到一个 MIDI 文件,first_song.midi。使用您电脑上的音乐播放器播放它,以了解我们用来训练 MuseGAN 的音乐类型。

练习 13.1

将训练数据集中的第二首歌曲转换为 MIDI 文件。将其保存为second_song.midi,并使用您电脑上的音乐播放器播放。

13.4 构建 MuseGAN

实质上,我们将音乐作品视为一个具有多个维度的对象。利用第四章至第六章中的技术,我们将使用深度卷积神经网络来处理这项任务,因为它们能够有效地从多维对象中提取空间特征。在 MuseGAN 中,我们将构建一个生成器和评判器,类似于图像创建中的生成器根据评判器的反馈来细化图像。生成器将生成一个作为 4D 对象的音乐作品。

我们将真实音乐(来自我们的训练集)和生成器生成的假音乐都展示给评判器。评判器将对每首作品从负无穷大到正无穷大进行评分,分数越高表示音乐更可能是真实的。评判器的目标是给真实音乐高分数,给假音乐低分数。相反,生成器的目标是生成与真实音乐难以区分的音乐,从而从评判器那里获得高分数。

在本节中,我们将构建一个 MuseGAN 模型,该模型包括一个生成网络和一个评判网络。评判网络使用深度卷积层从多维对象中提取独特特征,从而增强其评估音乐作品的能力。另一方面,生成网络利用深度转置卷积层生成旨在生成逼真音乐作品的特征图。稍后,我们将使用训练集中的音乐作品来训练 MuseGAN 模型。

13.4.1 MuseGAN 中的评判器

如第五章所述,将 Wasserstein 距离纳入损失函数可以帮助稳定训练。因此,在 MuseGAN 中,我们采用类似的方法,并使用评判器而不是判别器。评判器不是一个二元分类器;相反,它评估生成器的输出(在这种情况下,是一首音乐作品),并分配一个从-∞到∞的分数。更高的分数表示音乐更可能是真实的(即来自训练集)。

我们构建了一个如以下列表所示的音乐评判神经网络,其定义可以在您之前下载的文件 MuseGAN_util.py 中找到。

列表 13.3 MuseGAN 中的评判器网络

class MuseCritic(nn.Module):
    def __init__(self,hid_channels=128,hid_features=1024,
        out_features=1,n_tracks=4,n_bars=2,n_steps_per_bar=16,
        n_pitches=84):
        super().__init__()
        self.n_tracks = n_tracks
        self.n_bars = n_bars
        self.n_steps_per_bar = n_steps_per_bar
        self.n_pitches = n_pitches
        in_features = 4 * hid_channels if n_bars == 2\
            else 12 * hid_channels
        self.seq = nn.Sequential(
            nn.Conv3d(self.n_tracks, hid_channels, 
                      (2, 1, 1), (1, 1, 1), padding=0),      ①
            nn.LeakyReLU(0.3, inplace=True),
            nn.Conv3d(hid_channels, hid_channels, 
              (self.n_bars - 1, 1, 1), (1, 1, 1), padding=0),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Conv3d(hid_channels, hid_channels, 
                      (1, 1, 12), (1, 1, 12), padding=0),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Conv3d(hid_channels, hid_channels, 
                      (1, 1, 7), (1, 1, 7), padding=0),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Conv3d(hid_channels, hid_channels, 
                      (1, 2, 1), (1, 2, 1), padding=0),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Conv3d(hid_channels, hid_channels, 
                      (1, 2, 1), (1, 2, 1), padding=0),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Conv3d(hid_channels, 2 * hid_channels, 
                      (1, 4, 1), (1, 2, 1), padding=(0, 1, 0)),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Conv3d(2 * hid_channels, 4 * hid_channels,     
                      (1, 3, 1), (1, 2, 1), padding=(0, 1, 0)),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Flatten(),                                     ②
            nn.Linear(in_features, hid_features),
            nn.LeakyReLU(0.3, inplace=True),
            nn.Linear(hid_features, out_features))            ③
    def forward(self, x):  
        return self.seq(x)

① 将输入通过几个 Conv3d 层

② 将输出扁平化

③ 将输出通过两个线性层

评论网络的输入是一个维度为(4,2,16,84)的音乐作品。该网络主要由几个 Conv3d 层组成。这些层将音乐作品中的每个轨道视为一个 3D 对象,并应用过滤器来提取空间特征。Conv3d 层的操作与前面章节中讨论的用于图像生成的 Conv2d 层类似。

需要注意的是,评论模型的最后一层是线性的,我们没有对其输出应用任何激活函数。因此,评论模型的输出是一个从-∞到∞的值,这可以解释为评论家对音乐作品的评价。

13.4.2 MuseGAN 中的生成器

如本章前面所述,生成器将一次生成一段音乐,然后我们将这八段音乐组合成一首完整的乐曲。

MuseGAN 中的生成器不是只使用一个噪声向量,而是使用四个独立的噪声向量作为输入来控制生成的音乐的各个方面。其中两个向量将通过时间网络进行处理,以扩展它们在小节维度上的长度。而风格和节奏向量被设计为在小节间保持不变,和弦和旋律向量被设计为在小节间变化。因此,我们首先建立一个时间网络来扩展和弦和旋律向量跨越两个小节,确保生成的音乐在时间上有连贯和逻辑的进展。

在您之前下载的本地模块MuseGAN_util中,我们定义了TemporalNetwork()类如下:

class TemporalNetwork(nn.Module):
    def __init__(self,z_dimension=32,hid_channels=1024,n_bars=2):
        super().__init__()
        self.n_bars = n_bars
        self.net = nn.Sequential(
            Reshape(shape=[z_dimension, 1, 1]),                ①
            nn.ConvTranspose2d(z_dimension,hid_channels,
                kernel_size=(2, 1),stride=(1, 1),padding=0,),
            nn.BatchNorm2d(hid_channels),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(hid_channels,z_dimension,
                kernel_size=(self.n_bars - 1, 1),stride=(1, 1),
                padding=0,),
            nn.BatchNorm2d(z_dimension),
            nn.ReLU(inplace=True),
            Reshape(shape=[z_dimension, self.n_bars]),)        ②
    def forward(self, x):
        return self.net(x)

① TemporalNetwork()类的输入维度是(1,32)。

② 输出维度是(2,32)。

这里描述的TemporalNetwork()类使用两个 ConvTranspose2d 层将单个噪声向量扩展为两个不同的噪声向量,每个向量对应两个小节中的一个。正如我们在第四章中提到的,转置卷积层用于上采样和生成特征图。在这种情况下,它们被用来在不同的小节间扩展噪声向量。

我们不会一次性生成所有轨道的所有小节,而是逐个小节地生成音乐。这样做可以让 MuseGAN 在计算效率、灵活性和音乐连贯性之间取得平衡,从而产生更有结构和吸引力的音乐作品。因此,我们继续构建一个负责生成乐曲片段的生成器:轨道中的一小节。我们在本地的MuseGAN_util模块中引入了BarGenerator()类:

class BarGenerator(nn.Module):
    def __init__(self,z_dimension=32,hid_features=1024,hid_channels=512,
        out_channels=1,n_steps_per_bar=16,n_pitches=84):
        super().__init__()
        self.n_steps_per_bar = n_steps_per_bar
        self.n_pitches = n_pitches
        self.net = nn.Sequential(
            nn.Linear(4 * z_dimension, hid_features),              ①
            nn.BatchNorm1d(hid_features),
            nn.ReLU(inplace=True),
            Reshape(shape=[hid_channels,hid_features//hid_channels,1]),    
            nn.ConvTranspose2d(hid_channels,hid_channels,
               kernel_size=(2, 1),stride=(2, 1),padding=0),        ②
            nn.BatchNorm2d(hid_channels),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(hid_channels,hid_channels // 2,
                kernel_size=(2, 1),stride=(2, 1),padding=0),
            nn.BatchNorm2d(hid_channels // 2),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(hid_channels // 2,hid_channels // 2,
                kernel_size=(2, 1),stride=(2, 1),padding=0),
            nn.BatchNorm2d(hid_channels // 2),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(hid_channels // 2,hid_channels // 2,
                kernel_size=(1, 7),stride=(1, 7),padding=0),
            nn.BatchNorm2d(hid_channels // 2),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(hid_channels // 2,out_channels,
                kernel_size=(1, 12),stride=(1, 12),padding=0),
            Reshape([1, 1, self.n_steps_per_bar, self.n_pitches])) ③
    def forward(self, x):
        return self.net(x)

① 我们将和弦、风格、旋律和节奏合并成一个向量,大小为 4 * 32。

② 然后将输入重塑为二维,我们使用几个 ConvTranspose2d 层进行上采样和音乐特征生成。

③ 输出的形状为(1,1,16,84):1 个轨道,1 个小节,16 个音符,每个音符由一个 84 值的向量表示。

BarGenerator()类接受四个噪声向量作为输入,每个向量代表不同轨道中特定节拍的和弦、风格、旋律和节奏,所有这些向量的形状都是(1,32)。这些向量在输入到BarGenerator()类之前被连接成一个单一的 128 值向量。BarGenerator()类的输出是一个音乐节拍,维度为(1,1,16,84),表示 1 个轨道,1 个节拍,16 个音符,每个音符由一个 84 值的向量表示。

最后,我们将使用MuseGenerator()类生成一个完整的音乐作品,包含四个轨道,每个轨道有两个节拍。每个节拍都是使用之前定义的BarGenerator()类构建的。为了实现这一点,我们在本地MuseGAN_util模块中定义了MuseGenerator()类。

列表 13.4 MuseGAN 中的音乐生成器

class MuseGenerator(nn.Module):
    def __init__(self,z_dimension=32,hid_channels=1024,
        hid_features=1024,out_channels=1,n_tracks=4,
        n_bars=2,n_steps_per_bar=16,n_pitches=84):
        super().__init__()
        self.n_tracks = n_tracks
        self.n_bars = n_bars
        self.n_steps_per_bar = n_steps_per_bar
        self.n_pitches = n_pitches
        self.chords_network=TemporalNetwork(z_dimension, 
                            hid_channels, n_bars=n_bars)
        self.melody_networks = nn.ModuleDict({})
        for n in range(self.n_tracks):
            self.melody_networks.add_module(
                "melodygen_" + str(n),
                TemporalNetwork(z_dimension, 
                 hid_channels, n_bars=n_bars))
        self.bar_generators = nn.ModuleDict({})
        for n in range(self.n_tracks):
            self.bar_generators.add_module(
                „bargen_" + str(n),BarGenerator(z_dimension,
            hid_features,hid_channels // 2,out_channels,
            n_steps_per_bar=n_steps_per_bar,n_pitches=n_pitches))
    def forward(self,chords,style,melody,groove):
        chord_outs = self.chords_network(chords)
        bar_outs = []
        for bar in range(self.n_bars):                           ①
            track_outs = []
            chord_out = chord_outs[:, :, bar]
            style_out = style
            for track in range(self.n_tracks):                   ②
                melody_in = melody[:, track, :]
                melody_out = self.melody_networks"melodygen_"\
                          + str(track)[:, :, bar]
                groove_out = groove[:, track, :]
                z = torch.cat([chord_out, style_out, melody_out,\
                               groove_out], dim=1)               ③
                track_outs.append(self.bar_generators"bargen_"\
                                          + str(track))      ④
            track_out = torch.cat(track_outs, dim=1)
            bar_outs.append(track_out)
        out = torch.cat(bar_outs, dim=2)                         ⑤
        return out

① 遍历两个节拍

② 遍历四个轨道

③ 将和弦、风格、旋律和节奏合并为一个输入

④ 使用轨道生成器生成一个节拍

⑤ 将八个节拍合并成一个完整的音乐作品

生成器接受四个噪声向量作为输入。它遍历四个轨道和两个节拍。在每次迭代中,它使用轨道生成器创建一个音乐节拍。完成所有迭代后,MuseGenerator()类将八个节拍合并成一个连贯的音乐作品,其维度为(4,2,16,84)。

13.4.3 优化器和损失函数

我们基于本地模块中的MuseGenerator()MuseCritic()类创建一个生成器和批评家:

import torch
from utils.MuseGAN_util import (init_weights, MuseGenerator, MuseCritic)

device = "cuda" if torch.cuda.is_available() else "cpu"
generator = MuseGenerator(z_dimension=32, hid_channels=1024, 
              hid_features=1024, out_channels=1).to(device)
critic = MuseCritic(hid_channels=128,
                    hid_features=1024,
                    out_features=1).to(device)
generator = generator.apply(init_weights)
critic = critic.apply(init_weights) 

正如我们在第五章中讨论的,批评家生成一个评分而不是分类,因此损失函数定义为预测和目标之间的乘积的负平均值。因此,我们在本地模块MuseGAN_util中定义以下loss_fn()函数:

def loss_fn(pred, target):
    return -torch.mean(pred*target)

在训练过程中,对于生成器,我们将loss_fn()函数中的目标参数赋值为 1。这种设置旨在引导生成器产生能够获得最高评分(即loss_fn()函数中的变量 pred)的音乐。对于批评家,我们在损失函数中将目标设置为 1 用于真实音乐,-1 用于假音乐。这种设置引导批评家对真实音乐给予高评分,对假音乐给予低评分。

与第五章中的方法类似,我们将 Wasserstein 距离和梯度惩罚结合到批评家的损失函数中,以确保训练稳定性。梯度惩罚在MuseGAN_util.py文件中定义如下:

class GradientPenalty(nn.Module):
    def __init__(self):
        super().__init__()
    def forward(self, inputs, outputs):
        grad = torch.autograd.grad(
            inputs=inputs,
            outputs=outputs,
            grad_outputs=torch.ones_like(outputs),
            create_graph=True,
            retain_graph=True,
        )[0]
        grad_=torch.norm(grad.view(grad.size(0),-1),p=2,dim=1)
        penalty = torch.mean((1\. - grad_) ** 2)
        return penalty

GradientPenalty() 类需要两个输入:插值音乐,这是真实和假音乐的结合,以及批评家网络分配给这种插值音乐的评分。该类计算批评家评分关于插值音乐的梯度。然后,梯度惩罚被计算为这些梯度的范数的平方差与目标值 1 之间的平方差,这与我们在第五章中采取的方法类似。

如往常一样,我们将使用 Adam 优化器来训练批评家和生成器:

lr = 0.001
g_optimizer = torch.optim.Adam(generator.parameters(),
                               lr=lr, betas=(0.5, 0.9))
c_optimizer = torch.optim.Adam(critic.parameters(),
                               lr=lr, betas=(0.5, 0.9))

有了这些,我们已经成功构建了一个 MuseGAN,现在可以使用本章前面准备的数据进行训练。

13.5 训练 MuseGAN 以生成音乐

现在我们已经有了 MuseGAN 模型和训练数据,我们将继续在本节中训练模型。

与第三章和第四章中的方法类似,在训练 GAN 时,我们将交替训练批评家和生成器。在每个训练迭代中,我们将从训练数据集中采样一批真实音乐和从生成器中生成的一批音乐,并将它们呈现给批评家进行评估。在批评家训练期间,我们将批评家的评分与真实值进行比较,并稍微调整批评家网络的权重,以便在下一个迭代中,评分对真实音乐尽可能高,对生成音乐尽可能低。在生成器训练期间,我们将生成音乐输入到批评模型中,以获得评分,然后稍微调整生成器网络的权重,以便在下一个迭代中,评分更高(因为生成器旨在创建能够欺骗批评家的音乐作品)。我们重复这个过程多次迭代,逐渐使生成器网络能够创建更逼真的音乐作品。

一旦模型训练完成,我们将丢弃批评家网络,并使用训练好的生成器通过输入四个噪声向量(和弦、风格、旋律和节奏)来创建音乐作品。

13.5.1 训练 MuseGAN

在我们开始训练 MuseGAN 模型的训练循环之前,我们首先定义了一些超参数和辅助函数。超参数 repeat 控制我们在每次迭代中训练批评家的次数,display_step 指定我们显示输出的频率,而 epochs 是我们训练模型的时代数。

列表 13.5 超参数和辅助函数

from utils.MuseGAN_util import loss_fn, GradientPenalty

batch_size=64
repeat=5
display_step=10
epochs=500                                                         ①
alpha=torch.rand((batch_size,1,1,1,1)).requires_grad_().to(device) ②
gp=GradientPenalty()                                               ③

def noise():                                                       ④
    chords = torch.randn(batch_size, 32).to(device)
    style = torch.randn(batch_size, 32).to(device)
    melody = torch.randn(batch_size, 4, 32).to(device)
    groove = torch.randn(batch_size, 4, 32).to(device)
    return chords,style,melody,groove

① 定义了一些超参数

② 定义了 alpha 以创建插值音乐

③ 定义了一个 gp() 函数来计算梯度惩罚

④ 定义了一个 noise() 函数来检索四个随机噪声向量

批处理大小设置为 64,这有助于我们确定需要检索多少组随机噪声向量来创建一个假音乐批次。我们将在每个训练循环中训练批评家五次,生成器只训练一次,因为一个有效的批评家对于训练生成器至关重要。我们将在每个 10 个 epoch 后显示训练损失。我们将对模型进行 500 个 epoch 的训练。

我们在本地模块中实例化GradientPenalty()类,以创建一个gp()函数来计算梯度惩罚。我们还定义了一个noise()函数来生成四个随机噪声向量,以输入到生成器中。

接下来,我们定义以下函数,train_epoch(),用于训练模型一个 epoch。

列表 13.6 在一个 epoch 中训练 MuseGAN 模型

def train_epoch():
    e_gloss = 0
    e_closs = 0
    for real in loader:                                            ①
        real = real.to(device)
        for _ in range(repeat):                                    ②
            chords,style,melody,groove=noise()
            c_optimizer.zero_grad()
            with torch.no_grad():
                fake = generator(chords, style, melody,groove).detach()
            realfake = alpha * real + (1 - alpha) * fake
            fake_pred = critic(fake)
            real_pred = critic(real)
            realfake_pred = critic(realfake)
            fake_loss =  loss_fn(fake_pred,-torch.ones_like(fake_pred))
            real_loss = loss_fn(real_pred,torch.ones_like(real_pred))
            penalty = gp(realfake, realfake_pred)
            closs = fake_loss + real_loss + 10 * penalty           ③
            closs.backward(retain_graph=True)
            c_optimizer.step()
            e_closs += closs.item() / (repeat*len(loader))
        g_optimizer.zero_grad()
        chords,style,melody,groove=noise()
        fake = generator(chords, style, melody, groove)
        fake_pred = critic(fake)
        gloss = loss_fn(fake_pred, torch.ones_like(fake_pred))     ④
        gloss.backward()
        g_optimizer.step()
        e_gloss += gloss.item() / len(loader)
    return e_gloss, e_closs 

① 遍历所有批次

② 在每个迭代中训练批评家五次

③ 批评家的总损失有三个组成部分:评估真实音乐的损失、评估假音乐的损失以及梯度惩罚损失。

④ 训练生成器

训练过程与我们第五章训练带有梯度惩罚的条件 GAN 时使用的非常相似。

我们现在对模型进行 500 个 epoch 的训练:

for epoch in range(1,epochs+1):
    e_gloss, e_closs = train_epoch()
    if epoch % display_step == 0:
        print(f"Epoch {epoch}, G loss {e_gloss} C loss {e_closs}")

如果你使用 GPU 进行训练,大约需要一个小时。否则,可能需要几个小时。一旦完成,你可以按照以下方式将训练好的生成器保存到本地文件夹:

torch.save(generator.state_dict(),'files/MuseGAN_G.pth')

或者,你可以从我的网站上下载训练好的生成器:mng.bz/Bglr

接下来,我们将丢弃批评家网络,并使用训练好的生成器来创建模仿巴赫风格的音乐。

13.5.2 使用训练好的 MuseGAN 生成音乐

要使用训练好的生成器生成音乐,我们将从潜在空间中提取四个噪声向量输入到生成器中。请注意,我们可以在同一时间生成多个音乐对象并将它们一起解码,形成一个连续的音乐作品。你将在本小节中学习如何做到这一点。

我们首先加载生成器中的训练权重:

generator.load_state_dict(torch.load('files/MuseGAN_G.pth',
    map_location=device))

我们不仅能够生成一个 4D 音乐对象,还可以同时生成多个 4D 音乐对象,并在之后将它们转换成一个连续的音乐作品。例如,如果我们旨在创建五个音乐对象,我们首先从潜在空间中采样五组噪声向量。每组包含四个向量:和弦、风格、旋律和节奏,如下所示:

num_pieces = 5
chords = torch.rand(num_pieces, 32).to(device)
style = torch.rand(num_pieces, 32).to(device)
melody = torch.rand(num_pieces, 4, 32).to(device)
groove = torch.rand(num_pieces, 4, 32).to(device)

每个生成的音乐对象可以转换成一个大约持续 8 秒的音乐作品。在这种情况下,我们选择生成五个音乐对象,并在之后将它们解码成一个单一的音乐作品,结果持续大约 40 秒。你可以根据你的喜好调整变量num_pieces的值,以适应你希望的音乐作品长度。

接下来,我们向生成器提供五组潜在变量,以生成一组音乐对象:

preds = generator(chords, style, melody, groove).detach()

输出,preds,包含五个音乐对象。接下来,我们将这些对象解码成一个单一的音乐作品,表示为一个 MIDI 文件:

from utils.midi_util import convert_to_midi

music_data = convert_to_midi(preds.cpu().numpy())
music_data.write('midi', 'files/MuseGAN_song.midi')

我们从本地模块 midi_util 中导入 convert_to_midi() 函数。打开你之前下载的文件 midi_util.py 并回顾 convert_to_midi() 函数的定义。这个过程与我们本章前面将训练集中的第一个音乐对象转换为文件 first_song.midi 时所做的是类似的。由于 MIDI 文件表示随时间变化的音符序列,我们只需将对应于五个音乐对象的五个音乐片段连接成一个扩展的音符序列。然后将这个组合序列保存为 MuseGAN_song.midi 到你的电脑上。

在你的电脑上找到生成的音乐片段 MuseGAN_song.midi。用你选择的音乐播放器打开它并聆听,看它是否与训练集中的音乐片段相似。为了比较,你可以在我网站上听一听由训练模型生成的音乐片段,网址是 mng.bz/dZJv。请注意,由于生成器的输入,即噪声向量,是从潜在空间中随机抽取的,因此你生成的音乐片段听起来会有所不同。

练习 13.2

从潜在空间中获得三组随机的噪声向量(每组应包含和弦、风格、旋律和节奏)。将它们输入到训练好的生成器中,以获得三个音乐对象。将它们解码成一首单独的音乐,以 MIDI 文件的形式。将其保存为 generated_song.midi 到你的电脑上,并使用音乐播放器播放。

在本章中,你学习了如何构建和训练 MuseGAN 以生成巴赫风格的音乐。具体来说,你将一首音乐视为一个 4D 对象,并应用了第四章中关于深度卷积层的技巧来开发 GAN 模型。在下一章中,你将探索生成音乐的不同方式:将一首音乐视为一系列索引,并利用 NLP 技术通过逐个预测一个索引来生成音乐片段。

摘要

  • MuseGAN 将一首音乐视为一个类似于图像的多维对象。生成器生成一首音乐并将其提交,连同训练集中的真实音乐片段一起,供评论家评估。然后根据评论家的反馈修改音乐,直到它与训练数据集中的真实音乐非常相似。

  • 音乐音符、八度和音高是音乐理论中的基本概念。八度代表不同的音乐声音水平。每个八度分为 12 个半音:C、C#、D、D#、E、F、F#、G、G#、A、A#、B。在八度内,一个音符被分配一个特定的音高数字。

  • 在电子音乐制作中,一个轨道通常指的是音乐的一个单独的层或组件。每个轨道包含多个小节(或度量)。小节进一步分为多个步骤。

  • 为了将一段音乐表示为一个多维对象,我们用(4, 2, 16, 84)的形状来结构化它:4 个音乐轨道,每个轨道包含 2 个小节,每个小节包含 16 个步骤,每个步骤可以演奏 84 种不同音符中的任意一种。

  • 在音乐创作中,为了实现更大的控制和多样性,引入更详细的输入是至关重要的。与之前章节中用来自潜在空间的单个噪声向量生成形状、数字和图像不同,我们在音乐生成过程中使用了四个不同的噪声向量。鉴于每首音乐作品由四个轨道和两个小节组成,我们使用这四个向量来有效地管理这种结构。一个向量控制所有轨道和小节,另一个控制所有轨道中的每个小节,第三个监督所有轨道跨越小节的情况,第四个管理每个轨道中的每个小节。


^(1)  邓浩文,萧文仪,杨立嘉,杨宜璇,2017,“MuseGAN:用于符号音乐生成和伴奏的多轨道序列生成对抗网络。” arxiv.org/abs/1709.06298.

第十四章:构建和训练音乐 Transformer

本章涵盖了

  • 使用控制信息和速度值来表示音乐

  • 将音乐标记化为一系列索引

  • 构建和训练音乐 Transformer

  • 使用训练好的 Transformer 生成音乐事件

  • 将音乐事件转换回可播放的 MIDI 文件

为你最喜欢的音乐家不再与我们同在而感到难过?不再难过:生成式 AI 可以将他们带回舞台!

以 Layered Reality 为例,这是一家位于伦敦的公司,正在开发名为《猫王进化》的项目。1 目标?使用 AI 复活传奇的猫王艾维斯·普雷斯利。通过将艾维斯的大量官方档案材料,包括视频剪辑、照片和音乐,输入到一个复杂的计算机模型中,这个 AI 猫王学会了以惊人的相似度模仿他的唱歌、说话、跳舞和走路。结果?一场数字表演,捕捉了已故国王本人的精髓。

《猫王进化》项目是生成式 AI 在各个行业产生变革性影响的杰出例子。在前一章中,你探讨了使用 MuseGAN 创建可以以多轨音乐作品为假的音乐的方法。MuseGAN 将一首音乐视为一个多维对象,类似于图像,并生成与训练数据集中的音乐相似的音乐作品。然后,由评论家评估真实和 AI 生成的音乐,这有助于改进 AI 生成的音乐,直到它与真实音乐无法区分。

在本章中,你将采用一种不同的方法来处理 AI 音乐创作,将其视为一系列音乐事件。我们将应用第十一章和第十二章中讨论的文本生成技术,来预测序列中的下一个元素。具体来说,你将开发一个类似 GPT 风格的模型,根据序列中所有先前事件来预测下一个音乐事件。由于 GPT 风格的 Transformer 具有可扩展性和自注意力机制,这些机制有助于它们捕捉长距离依赖关系并理解上下文,因此它们非常适合这项任务。你将创建的音乐 Transformer 具有 2016 万个参数,足够捕捉音乐作品中不同音符的长期关系,但同时也足够小,可以在合理的时间内进行训练。

我们将使用来自谷歌 Magenta 团队的 Maestro 钢琴音乐作为我们的训练数据。你将学习如何首先将音乐乐器数字接口(MIDI)文件转换为音乐音符序列,类似于自然语言处理(NLP)中的原始文本数据。然后你将把音乐音符分解成称为音乐事件的小片段,类似于 NLP 中的标记。由于神经网络只能接受数值输入,因此你需要将每个独特的事件标记映射到一个索引。有了这个,训练数据中的音乐作品就被转换成了索引序列,准备好输入到神经网络中。

为了训练音乐 Transformer 以预测序列中下一个标记,基于当前标记和序列中所有之前的标记,我们将创建长度为 2,048 的索引序列作为输入(特征 x)。然后我们将序列向右移动一个索引,并使用它们作为输出(目标 y)。我们将 (x, y) 对输入到音乐 Transformer 中以训练模型。一旦训练完成,我们将使用一个短索引序列作为提示并将其输入到音乐 Transformer 中以预测下一个标记,然后将该标记附加到提示中形成一个新的序列。这个新序列被反馈到模型中进行进一步的预测,这个过程会重复进行,直到序列达到期望的长度。

你将看到训练好的音乐 Transformer 可以生成模仿训练数据集中风格的逼真音乐。此外,与第十三章中生成的音乐不同,你将学习如何控制音乐作品的艺术性。你将通过调整预测的 logits 与温度参数的比例来实现这一点,就像你在前几章中控制生成文本的艺术性时做的那样。

14.1 音乐 Transformer 简介

音乐 Transformer 的概念于 2018 年提出.^(2) 这种创新方法扩展了最初为 NLP 任务设计的 Transformer 架构,应用于音乐生成领域。正如前几章所讨论的,Transformers 使用自注意力机制来有效地把握上下文,并捕捉序列中元素之间的长距离依赖关系。

类似地,音乐 Transformer 被设计成通过学习大量现有音乐数据集来生成音乐序列。该模型被训练成根据先前的音乐事件来预测序列中的下一个音乐事件,通过理解训练数据中不同音乐元素之间的模式、结构和关系。

训练音乐 Transformer 的关键步骤在于找出如何将音乐表示为一系列独特的音乐事件,类似于 NLP 中的标记。在上一章中,你学习了如何将一首音乐表示为 4D 对象。在本章中,你将探索一种替代的音乐表示方法,即通过控制信息和速度值实现的基于性能的音乐表示.^(3) 基于此,你将把一首音乐转换为四种类型的音乐事件:音符开启、音符关闭、时间移动和速度。

音符开启信号表示一个音符的开始演奏,指定音符的音高。音符关闭表示音符的结束,告诉乐器停止演奏该音符。时间移动表示两个音乐事件之间经过的时间量。速度衡量演奏音符的力量或速度,较高的值对应更强的、更响亮的声音。每种类型的音乐事件都有许多不同的值。每个独特的事件将被映射到不同的索引,有效地将一首音乐转换为一个索引序列。然后,你将应用第十一章和第十二章中讨论的 GPT 模型,创建一个仅具有解码器的音乐 Transformer,以预测序列中的下一个音乐事件。

在本节中,你将首先通过控制信息和速度值了解基于性能的音乐表示。然后,你将探索如何将音乐作品表示为一系列音乐事件。最后,你将学习构建和训练 Transformer 以生成音乐的步骤。

14.1.1 基于性能的音乐表示

基于性能的音乐表示通常使用 MIDI 格式实现,该格式通过控制信息和速度值捕捉音乐表演的细微差别。在 MIDI 中,音符通过音符开启和音符关闭消息表示,这些消息包含每个音符的音高和速度信息。

正如我们在第十三章中讨论的,音高值范围从 0 到 127,每个值对应于八度中的一个半音。例如,音高值 60 对应于 C4 音符,而音高值 74 对应于 D5 音符。速度值,同样范围从 0 到 127,表示音符的动态,较高的值表示更响亮或更有力的演奏。通过结合这些控制信息和速度值,MIDI 序列可以捕捉现场表演的表达细节,允许通过兼容 MIDI 的乐器和软件进行表达性的回放。

为了给你一个具体的例子,说明如何通过控制信息和速度值表示一首音乐,请考虑以下列表中显示的五个音符。

列表 14.1 基于性能的音乐表示中的示例音符

<[SNote] time: 1.0325520833333333 type: note_on, value: 74, velocity: 86>
<[SNote] time: 1.0442708333333333 type: note_on, value: 38, velocity: 77>
<[SNote] time: 1.2265625 type: note_off, value: 74, velocity: None>
<[SNote] time: 1.2395833333333333 type: note_on, value: 73, velocity: 69>
<[SNote] time: 1.2408854166666665 type: note_on, value: 37, velocity: 64>

这些是训练数据集中你将在本章使用的音乐作品中的前五个音符。第一个音符的大致时间戳为 1.03 秒,音高值为 74(D5)的音符以 86 的速度开始演奏。观察第二个音符,你可以推断出大约 0.01 秒后(因为时间戳现在是 1.04 秒),一个音高值为 38 的音符以 77 的速度开始演奏,以此类推。

这些音乐符号类似于自然语言处理中的原始文本;我们不能直接将它们输入到音乐 Transformer 中训练模型。我们首先需要将音符“令牌化”,然后将令牌转换为索引,再输入到模型中。

为了令牌化音乐音符,我们将使用 0.01 秒的增量来表示音乐,以减少音乐作品中的时间步数。此外,我们将控制消息与速度值分开,并将它们视为音乐作品的独立元素。具体来说,我们将使用音符开启、音符关闭、时间偏移和速度事件来表示音乐。一旦这样做,前五个音符可以表示为以下事件(为了简洁,省略了一些事件)。

列表 14.2 音乐作品的令牌化表示

<Event type: time_shift, value: 99>, 
 <Event type: time_shift, value: 2>, 
 <Event type: velocity, value: 21>, 
 <Event type: note_on, value: 74>, 
 <Event type: time_shift, value: 0>, 
 <Event type: velocity, value: 19>, 
 <Event type: note_on, value: 38>, 
 <Event type: time_shift, value: 17>, 
 <Event type: note_off, value: 74>, 
 <Event type: time_shift, value: 0>, 
 <Event type: velocity, value: 17>, 
 <Event type: note_on, value: 73>, 
 <Event type: velocity, value: 16>, 
 <Event type: note_on, value: 37>, 
 <Event type: time_shift, value: 0>
…

我们将以 0.01 秒的增量计算时间偏移,并将从 0.01 秒到 1 秒的时间偏移以 100 个不同的值进行令牌化。因此,时间偏移事件被令牌化为 100 个独特的事件令牌:值为 0 表示 0.01 秒的时间间隔,值为 1 表示 0.02 秒的时间间隔,以此类推,直到 99,表示 1 秒的时间间隔。如果一个时间偏移超过 1 秒,你可以使用多个时间偏移令牌来表示它。例如,列表 14.2 中的前两个令牌都是时间偏移令牌,值分别为 99 和 2,分别表示 1 秒和 0.03 秒的时间间隔。这与列表 14.1 中第一个音乐音符的时间戳相匹配:1.0326 秒。

列表 14.2 也显示了速度是音乐事件的一种独立类型。我们将速度值放入 32 个等间隔的箱子中,将原始的速度值(范围从 0 到 127)转换为 32 个值中的一个,范围从 0 到 31。这就是为什么列表 14.1 中第一个音符的原始速度值 86 现在在列表 14.2 中表示为速度事件,值为 21(因为 86 落在第 22 个箱子中,Python 使用零基索引)。

表 14.1 显示了四种不同令牌化事件的意义、它们的值范围以及每个事件令牌的意义。

表 14.1 不同事件令牌的意义

事件令牌类型 事件令牌值范围 事件令牌的意义
note_on 0–127 在某个音高值处开始演奏。例如,值为 74 的note_on表示开始演奏 D5 音符。
note_off 0–127 释放某个音符。例如,值为 60 的note_off表示停止演奏 C4 音符。
time_shift 0–99 time_shift值是 0.01 秒的增量。例如,0 表示 0.01 秒,2 表示 0.03 秒,99 表示 1 秒。
velocity 0–31 原始速度值被放入 32 个箱子中。使用箱子值。例如,原始速度值为 86 现在有一个标记化值为 21。

与 NLP 中采用的方法类似,我们将每个唯一的标记转换为索引,以便我们可以将数据输入到神经网络中。根据表 14.1,有 128 个唯一的音符开启事件标记,128 个音符关闭事件标记,32 个速度事件标记和 100 个时间偏移事件标记。这导致总共有 128 + 128 + 32 + 100 = 388 个唯一标记。因此,我们根据表 14.2 中提供的映射将这些 388 个唯一标记转换为从 0 到 387 的索引。

表 14.2 事件标记到索引和索引到事件标记的映射

标记类型 索引范围 事件标记到索引 索引到事件标记
note_on 0–127 note_on标记的值。例如,具有 74 个值的note_on标记被分配一个索引值为 74。 如果索引范围是 0 到 127,将标记类型设置为note_on并将值设置为索引值。例如,索引值 63 映射到具有 63 个值的note_on标记。
note_off 128–255 128 加上note_off标记的值。例如,具有 60 个值的note_off标记被分配一个索引值为 188(因为 128+60=188)。 如果索引范围是 128 到 255,将标记类型设置为note_off并将值设置为索引减去 128。例如,索引 180 映射到具有 52 个值的note_off标记。
time_shift 256–355 256 加上time_shift标记的值。例如,具有 16 个值的time_shift标记被分配一个索引值为 272(因为 256+16=272)。 如果索引范围是 256 到 355,将标记类型设置为time_shift并将值设置为索引减去 256。例如,索引 288 映射到具有 32 个值的time_shift标记。
velocity 356–387 356 加上速度标记的值。例如,具有 21 个值的速度标记被分配一个索引值为 377(因为 356+21=377)。 如果索引范围是 356 到 387,将标记类型设置为velocity并将值设置为索引减去 356。例如,索引 380 映射到具有 24 个值的velocity标记。

表 14.2 概述了事件标记到索引的转换。音符开启标记被分配从 0 到 127 的索引值,其中索引值对应于标记中的音高数。音符关闭标记被分配从 128 到 255 的索引值,索引值是 128 加上音高数。时间偏移标记被分配从 256 到 355 的索引值,索引值是 256 加上时间偏移值。最后,速度标记被分配从 356 到 387 的索引值,索引值是 356 加上速度箱子数。

使用这种标记到索引的映射,我们将每首音乐转换成一系列索引。我们将对此训练数据集中的所有音乐作品应用此转换,并使用生成的序列来训练我们的音乐 Transformer(其细节将在后面解释)。一旦训练完成,我们将使用 Transformer 以序列的形式生成音乐。最后一步是将此序列转换回 MIDI 格式,以便我们可以在计算机上播放和欣赏音乐。

表 14.2 的最后一列提供了将索引转换回事件标记的指导。我们首先根据索引所在的范围确定标记类型。表 14.2 的第二列中的四个范围对应于表的第一列中的四种标记类型。为了获得每种标记类型的值,我们将索引值分别减去 0、128、256 和 356,分别对应四种类型的标记。然后,这些标记化的事件被转换为 MIDI 格式的音符,准备好在计算机上播放。

14.1.2 音乐 Transformer 架构

在第九章中,我们构建了一个编码器-解码器 Transformer,在第十一章和第十二章中,我们专注于仅解码器 Transformer。与编码器捕获源语言含义并将其传递给解码器以生成翻译的语言翻译任务不同,音乐生成不需要编码器理解不同的语言。相反,模型根据音乐序列中的先前事件标记生成后续事件标记。因此,我们将为我们的音乐生成任务构建一个仅解码器的 Transformer。

我们的音乐 Transformer,与其他 Transformer 模型一样,利用自注意力机制来捕捉音乐作品中不同音乐事件之间的长距离依赖关系,从而生成连贯且逼真的音乐。尽管我们的音乐 Transformer 在大小上与我们在第十一章和第十二章中构建的 GPT 模型不同,但它具有相同的核心架构。它遵循与 GPT-2 模型相同的结构设计,但尺寸显著更小,这使得在没有超级计算设施的情况下进行训练成为可能。

具体来说,我们的音乐 Transformer 由 6 个解码器层组成,嵌入维度为 512,这意味着每个标记在词嵌入后都由一个 512 维的向量表示。与原始 2017 年论文“Attention Is All You Need”中使用正弦和余弦函数进行位置编码不同,我们使用嵌入层来学习序列中不同位置的位置编码。因此,序列中的每个位置也由一个 512 维的向量表示。为了计算因果自注意力,我们使用 8 个并行注意力头来捕捉序列中标记的不同含义,每个注意力头的维度为 64(512/8)。

与 GPT-2 模型中 50,257 的词汇量相比,我们的模型具有更小的词汇量,为 390(388 个不同的事件标记,加上一个表示序列结束的标记和一个填充较短的序列的标记;我将在后面解释为什么需要填充)。这使得我们可以在音乐 Transformer 中将最大序列长度设置为 2,048,这比 GPT-2 模型中的最大序列长度 1,024 长得多。这种选择是必要的,以便捕捉序列中音乐音符的长期关系。具有这些超参数值,我们的音乐 Transformer 具有 1,016 万参数。

图 14.1 展示了本章我们将创建的音乐 Transformer 的架构。它与你在第十一章和第十二章中构建的 GPT 模型的架构相似。图 14.1 还显示了训练过程中数据通过模型时的大小。

我们构建的音乐 Transformer 的输入包括输入嵌入,如图 14.1 底部所示。输入嵌入是输入序列的词嵌入和位置编码的总和。然后,这个输入嵌入依次通过六个解码器块。

图片

图 14.1 展示了音乐 Transformer 的架构。MIDI 格式的音乐文件首先被转换为音乐事件的序列。这些事件随后被标记化并转换为索引。我们将这些索引组织成 2,048 个元素的序列,每个批次包含 2 个这样的序列。输入序列首先进行词嵌入和位置编码;输入嵌入是这两个组件的总和。然后,这个输入嵌入通过六个解码器层进行处理,每个层都利用自注意力机制来捕捉序列中不同音乐事件之间的关系。经过解码器层后,输出经过层归一化以确保训练过程中的稳定性。然后,它通过一个线性层,输出大小为 390,这对应于词汇表中的独特标记数量。这个最终输出代表了序列中下一个音乐事件的预测对数几率。

如第十一章和第十二章所述,每个解码器层由两个子层组成:一个因果自注意力层和一个前馈网络。此外,我们对每个子层应用层归一化和残差连接,以增强模型稳定性和学习能力。

经过解码器层后,输出经过层归一化,然后输入到一个线性层。我们模型中的输出数量对应于词汇表中的独特音乐事件标记的数量,即 390。模型的输出是下一个音乐事件标记的对数几率。

之后,我们将应用 softmax 函数到这些 logits 上,以获得所有可能事件标记的概率分布。该模型被设计用来根据当前标记和音乐序列中所有之前的标记来预测下一个事件标记,使其能够生成连贯且音乐上合理的序列。

14.1.3 训练音乐 Transformer

既然我们已经了解了如何构建用于音乐生成的音乐 Transformer,那么让我们概述一下音乐 Transformer 的训练过程。

模型生成的音乐风格受用于训练的音乐作品的影响。我们将使用 Google 的 Magenta 团队的钢琴表演来训练我们的模型。图 14.2 说明了训练音乐生成 Transformer 所涉及的步骤。

图 14.2 音乐 Transformer 生成音乐的训练过程

图 14.2 音乐 Transformer 生成音乐的训练过程

与我们在 NLP 任务中采取的方法类似,我们音乐 Transformer 训练过程中的第一步是将原始训练数据转换为数值形式,以便将其输入到模型中。具体来说,我们首先将训练集中的 MIDI 文件转换为音乐音符序列。然后,我们将这些音符进一步标记化,通过将它们转换为 388 个独特的事件/标记中的 1 个。标记化后,我们为每个标记分配一个唯一的索引(即一个整数),将训练集中的音乐作品转换为整数序列(见图 14.2 中的步骤 1)。

接下来,我们将整数序列转换为训练数据,通过将此序列划分为等长的序列(见图 14.2 中的步骤 2)。我们允许每个序列中最多有 2,048 个索引。选择 2,048 允许我们捕捉音乐序列中音乐事件之间的长距离依赖关系,以创建逼真的音乐。这些序列形成我们模型的特征(x 变量)。正如我们在前几章中训练 GPT 模型生成文本时所做的,我们将输入序列窗口向右滑动一个索引,并将其用作训练数据中的输出(y 变量;见图 14.2 中的步骤 3)。这样做迫使我们的模型根据音乐序列中的当前标记和所有之前的标记来预测序列中的下一个音乐标记。

输入和输出对作为音乐 Transformer 的训练数据(x, y)。在训练过程中,你将遍历训练数据。在前向传递中,你将输入序列 x 通过音乐 Transformer(步骤 4)。音乐 Transformer 然后根据模型中的当前参数进行预测(步骤 5)。你通过比较预测的下一个标记与步骤 3 获得的输出来计算交叉熵损失。换句话说,你将模型的预测与真实值(步骤 6)进行比较。最后,你将调整音乐 Transformer 中的参数,以便在下一个迭代中,模型的预测更接近实际输出,最小化交叉熵损失(步骤 7)。该模型本质上是在执行一个多类别分类问题:它从词汇表中的所有独特音乐标记中预测下一个标记。

你将通过多次迭代重复步骤 3 到 7。在每次迭代后,模型参数都会调整以改善下一个标记的预测。这个过程将重复进行 50 个 epoch。

要使用训练好的模型生成新的音乐作品,我们从测试集中获取一个音乐作品,对其进行标记化,并将其转换为一系列长索引。我们将使用前 250 个索引作为提示(200 或 300 个索引将产生类似的结果)。然后,我们要求训练好的音乐 Transformer 生成新的索引,直到序列达到一定长度(例如,1,000 个索引)。然后,我们将索引序列转换回 MIDI 文件,以便在您的计算机上播放。

14.2 音乐作品的标记化

在掌握了音乐 Transformer 的结构和其训练方法之后,我们将从第一步开始:对训练数据集中的音乐作品进行标记化和索引。

我们将首先使用基于性能的表示(如第一部分所述)来表示音乐作品为音符,类似于自然语言处理中的原始文本。之后,我们将这些音符划分为一系列事件,类似于自然语言处理中的标记。每个独特的事件将被分配一个不同的索引。利用这个映射,我们将训练数据集中的所有音乐作品转换为索引序列。

接下来,我们将这些索引序列标准化为固定长度,具体为 2,048 个索引的序列,并将它们用作特征输入(x)。通过将窗口向右移动一个索引,我们将生成相应的输出序列(y)。然后,我们将输入和输出(x, y)对分组成批次,为章节后面的音乐 Transformer 训练做准备。

由于我们需要 pretty_midimusic21 库来处理 MIDI 文件,请在 Jupyter Notebook 应用程序的新单元格中执行以下代码行:

!pip install pretty_midi music21

14.2.1 下载训练数据

我们将从由谷歌 Magenta 团队提供的 MAESTRO 数据集中获取钢琴演奏,该数据集可在storage.googleapis.com/magentadata/datasets/maestro/v2.0.0/maestro-v2.0.0-midi.zip获得,并下载 ZIP 文件。下载后,解压缩它,并将生成的文件夹/maestro-v2.0.0/移动到您计算机上的/files/目录中。

确保/maestro-v2.0.0/文件夹包含 4 个文件(其中一个应命名为“maestro-v2.0.0.json”)和 10 个子文件夹。每个子文件夹应包含超过 100 个 MIDI 文件。为了熟悉训练数据中音乐片段的声音,尝试使用您喜欢的音乐播放器打开一些 MIDI 文件。

接下来,我们将 MIDI 文件分成训练、验证和测试子集。首先,在/files/maestro-v2.0.0/目录下创建三个子文件夹:

import os

os.makedirs("files/maestro-v2.0.0/train", exist_ok=True)
os.makedirs("files/maestro-v2.0.0/val", exist_ok=True)
os.makedirs("files/maestro-v2.0.0/test", exist_ok=True)

为了方便处理 MIDI 文件,访问凯文·杨的 GitHub 仓库github.com/jason9693/midi-neural-processor,下载 processor.py 文件,并将其放置在您计算机上的/utils/文件夹中。或者,您也可以从本书的 GitHub 仓库github.com/markhliu/DGAI中获取该文件。我们将使用此文件作为本地模块,将 MIDI 文件转换为一系列索引,反之亦然。这种方法使我们能够专注于开发、训练和利用音乐 Transformer,而无需陷入音乐格式转换的细节。同时,我将提供一个简单的示例,说明这个过程是如何工作的,这样您就可以使用该模块自己将 MIDI 文件和一系列索引之间进行转换。

此外,您还需要从本书的 GitHub 仓库下载 ch14util.py 文件,并将其放置在您计算机上的/utils/目录中。我们将使用 ch14util.py 文件作为另一个本地模块来定义音乐 Transformer 模型。

/maestro-v2.0.0/文件夹中的 maestro-v2.0.0.json 文件包含所有 MIDI 文件及其指定的子集(训练、验证或测试)。基于这些信息,我们将 MIDI 文件分类到三个相应的子文件夹中。

列表 14.3 将训练数据分割为训练、验证和测试子集

import json
import pickle
from utils.processor import encode_midi

file="files/maestro-v2.0.0/maestro-v2.0.0.json"

with open(file,"r") as fb:
    maestro_json=json.load(fb)                            ①

for x in maestro_json:                                    ②
    mid=rf'files/maestro-v2.0.0/{x["midi_filename"]}'
    split_type = x["split"]                               ③
    f_name = mid.split("/")[-1] + ".pickle"
    if(split_type == "train"):
        o_file = rf'files/maestro-v2.0.0/train/{f_name}'
    elif(split_type == "validation"):
        o_file = rf'files/maestro-v2.0.0/val/{f_name}'
    elif(split_type == "test"):
        o_file = rf'files/maestro-v2.0.0/test/{f_name}'
    prepped = encode_midi(mid)
    with open(o_file,"wb") as f:
        pickle.dump(prepped, f)

① 加载 JSON 文件

② 遍历训练数据中的所有文件

根据 JSON 文件中的说明,将文件放置在训练、验证或测试子文件夹中

您下载的 JavaScript 对象表示法(JSON)文件将训练数据集中的每个文件分类到三个子集之一:训练、验证和测试。在执行前面的代码列表后,如果您在计算机上的/train/、/val/和/test/文件夹中查看,您应该在每个文件夹中找到许多文件。为了验证这三个文件夹中每个文件夹的文件数量,您可以执行以下检查:

train_size=len(os.listdir('files/maestro-v2.0.0/train'))
print(f"there are {train_size} files in the train set")
val_size=len(os.listdir('files/maestro-v2.0.0/val'))
print(f"there are {val_size} files in the validation set")
test_size=len(os.listdir('files/maestro-v2.0.0/test'))
print(f"there are {test_size} files in the test set")

前一个代码块输出的结果是

there are 967 files in the train set
there are 137 files in the validation set
there are 178 files in the test set

结果显示,训练、验证和测试子集中分别有 967、137 和 178 首音乐作品。

14.2.2 MIDI 文件标记化

接下来,我们将每个 MIDI 文件表示为一串音乐音符。

列表 14.4 将 MIDI 文件转换为音乐音符序列

import pickle
from utils.processor import encode_midi
import pretty_midi
from utils.processor import (_control_preprocess,
    _note_preprocess,_divide_note,
    _make_time_sift_events,_snote2events)

file='MIDI-Unprocessed_Chamber1_MID--AUDIO_07_R3_2018_wav--2'
name=rf'files/maestro-v2.0.0/2018/{file}.midi'               ①

events=[]
notes=[]
song=pretty_midi.PrettyMIDI(name)
for inst in song.instruments:
    inst_notes=inst.notes
    ctrls=_control_preprocess([ctrl for ctrl in 
       inst.control_changes if ctrl.number == 64])
    notes += _note_preprocess(ctrls, inst_notes)             ②
dnotes = _divide_note(notes)                                 ③
dnotes.sort(key=lambda x: x.time)    
for i in range(5):
    print(dnotes[i])   

① 从训练数据集中选择一个 MIDI 文件

② 从音乐中提取音乐事件

③ 将所有音乐事件放入列表 dnotes 中

我们已经从训练数据集中选择了一个 MIDI 文件,并使用 processor.py 本地模块将其转换为音乐音符序列。前面代码列表的输出如下:

<[SNote] time: 1.0325520833333333 type: note_on, value: 74, velocity: 86>
<[SNote] time: 1.0442708333333333 type: note_on, value: 38, velocity: 77>
<[SNote] time: 1.2265625 type: note_off, value: 74, velocity: None>
<[SNote] time: 1.2395833333333333 type: note_on, value: 73, velocity: 69>
<[SNote] time: 1.2408854166666665 type: note_on, value: 37, velocity: 64>

这里显示的输出显示了 MIDI 文件中的前五个音符。你可能已经注意到输出中的时间表示是连续的。某些音符同时包含note_onvelocity属性,由于时间表示的连续性,这会导致大量独特的音乐事件,从而复杂化了标记过程。此外,不同的note_onvelocity值的组合很大(每个都可以假设 128 个不同的值,范围从 0 到 127),导致词汇表大小过大。这反过来又会使得训练变得不切实际。

为了减轻这个问题并减小词汇表大小,我们进一步将这些音乐音符转换为标记化事件:

cur_time = 0
cur_vel = 0
for snote in dnotes:
    events += _make_time_sift_events(prev_time=cur_time,    ①
                                     post_time=snote.time)
    events += _snote2events(snote=snote, prev_vel=cur_vel)  ②
    cur_time = snote.time
    cur_vel = snote.velocity    
indexes=[e.to_int() for e in events]   
for i in range(15):                                         ③
    print(events[i]) 

① 将时间离散化以减少独特事件的数目

② 将音乐音符转换为事件

③ 打印出前 15 个事件

输出如下:

<Event type: time_shift, value: 99>
<Event type: time_shift, value: 2>
<Event type: velocity, value: 21>
<Event type: note_on, value: 74>
<Event type: time_shift, value: 0>
<Event type: velocity, value: 19>
<Event type: note_on, value: 38>
<Event type: time_shift, value: 17>
<Event type: note_off, value: 74>
<Event type: time_shift, value: 0>
<Event type: velocity, value: 17>
<Event type: note_on, value: 73>
<Event type: velocity, value: 16>
<Event type: note_on, value: 37>
<Event type: time_shift, value: 0>

音乐作品现在由四种类型的事件表示:音符开启、音符关闭、时间移动和速度。每种事件类型包含不同的值,总共产生 388 个独特的事件,如前面表格 14.2 中详细说明。将 MIDI 文件转换为这种独特事件序列的具体细节对于构建和训练音乐 Transformer 不是必要的。因此,我们不会深入探讨这个话题;感兴趣的读者可以参考前面提到的 Huang 等人(2018)的研究。你需要知道的是如何使用 processor.py 模块将 MIDI 文件转换为索引序列,反之亦然。在下面的子节中,你将学习如何完成这个任务。

14.2.3 准备训练数据

我们已经学会了如何将音乐作品转换为标记,然后转换为索引。下一步涉及准备训练数据,以便我们可以在本章后面利用它来训练音乐 Transformer。为了实现这一点,我们定义了以下列表中所示的create_xys()函数。

列表 14.5 创建训练数据

import torch,os,pickle

max_seq=2048
def create_xys(folder):  
    files=[os.path.join(folder,f) for f in os.listdir(folder)]
    xys=[]
    for f in files:
        with open(f,"rb") as fb:
            music=pickle.load(fb)
        music=torch.LongTensor(music)      
        x=torch.full((max_seq,),389, dtype=torch.long)
        y=torch.full((max_seq,),389, dtype=torch.long)      ①
        length=len(music)
        if length<=max_seq:
            print(length)
            x[:length]=music                                ②
            y[:length-1]=music[1:]                          ③
            y[length-1]=388                                 ④
        else:
            x=music[:max_seq]
            y=music[1:max_seq+1]   
        xys.append((x,y))
    return xys

① 创建长度为 2,048 个索引的(x, y)序列,并将索引 399 设置为填充索引

② 使用最多 2,048 个索引的序列作为输入

③ 将窗口向右滑动一个索引,并使用它作为输出

④ 设置结束索引为 388

正如我们在整本书中反复看到的那样,在序列预测任务中,我们使用一个序列 x 作为输入。然后我们将序列向右移动一个位置以创建输出序列。这种方法迫使模型根据序列中的当前元素和所有前面的元素来预测下一个元素。为了为我们的音乐 Transformer 准备训练数据,我们将构建(x, y)对,其中 x 是输入,y 是输出。x 和 y 都包含 2,048 个索引——足够长以捕捉序列中音乐音符的长期关系,但又不至于太长而阻碍训练过程。

我们将遍历下载的训练数据集中的所有音乐作品。如果一个音乐作品的长度超过 2,048 个索引,我们将使用前 2,048 个索引作为输入 x。对于输出 y,我们将使用从第二个位置到第 2,049 个位置的索引。在音乐作品长度小于或等于 2,048 个索引的罕见情况下,我们将使用索引 389 填充序列,以确保 x 和 y 的长度都是 2,048 个索引。此外,我们使用索引 388 来表示序列 y 的结束。

如第一部分所述,总共有 388 个独特的事件标记,索引从 0 到 387。由于我们使用 388 来表示 y 序列的结束,并使用 389 来填充序列,因此总共有 390 个独特的索引,范围从 0 到 389。

我们现在可以将create_xys()函数应用于训练子集:

trainfolder='files/maestro-v2.0.0/train'
train=create_xys(trainfolder)

输出如下

15
5
1643
1771
586

这表明在训练子集中的 967 首音乐作品中,只有 5 首的长度小于 2,048 个索引。它们的长度在之前的输出中显示。

我们还把create_xys()函数应用于验证和测试子集:

valfolder='files/maestro-v2.0.0/val'
testfolder='files/maestro-v2.0.0/test'
print("processing the validation set")
val=create_xys(valfolder)
print("processing the test set")
test=create_xys(testfolder)

输出如下

processing the validation set
processing the test set
1837

这表明验证子集中的所有音乐作品长度都超过 2,048 个索引。测试子集中只有一首音乐作品的长度小于 2,048 个索引。

让我们打印出验证子集中的一份文件,看看它是什么样子:

val1, _ = val[0]
print(val1.shape)
print(val1)

输出如下:

torch.Size([2048])
tensor([324, 366,  67,  ...,  60, 264, 369])

验证集第一对中的 x 序列长度为 2,048 个索引,具有诸如 324、367 等值。让我们使用processor.py模块来解码序列到一个 MIDI 文件,这样你就可以听到它的声音:

from utils.processor import decode_midi

file_path="files/val1.midi"
decode_midi(val1.cpu().numpy(), file_path=file_path)

decode_midi()函数将索引序列转换为 MIDI 文件,可以在你的电脑上播放。在运行前面的代码块后,用电脑上的音乐播放器打开 val1.midi 文件,听听它的声音。

练习 14.1

使用processor.py本地模块中的decode_midi()函数将训练子集中的第一首音乐作品转换为 MIDI 文件。将其保存为 train1.midi 到你的电脑上。用电脑上的音乐播放器打开它,感受一下我们用于训练数据类型的音乐。

最后,我们创建一个数据加载器,以便数据以批次的格式进行训练:

from torch.utils.data import DataLoader

batch_size=2
trainloader=DataLoader(train,batch_size=batch_size,
                       shuffle=True)

为了防止您的 GPU 内存耗尽,我们将使用 2 个批处理大小,因为我们创建了非常长的序列,每个序列包含 2,048 个索引。如果需要,可以将批处理大小减少到 1 或切换到 CPU 训练。

有了这些,我们的训练数据已经准备好了。在接下来的两个部分中,我们将从头开始构建一个音乐 Transformer,然后使用我们刚刚准备好的训练数据进行训练。

14.3 构建用于生成音乐的 GPT

现在我们已经准备好了训练数据,我们将从头开始构建一个用于音乐生成的 GPT 模型。这个模型的架构将与我们在第十一章中开发的 GPT-2XL 模型和第十二章中的文本生成器相似。然而,由于我们选择的特定超参数,我们的音乐 Transformer 的大小将有所不同。

为了节省空间,我们将模型构建放在本地模块 ch14util.py 中。在这里,我们的重点是音乐 Transformer 选择使用的超参数。具体来说,我们将决定n_layer的值,即模型中解码器的层数;n_head,用于计算因果自注意力的并行头数;n_embd,嵌入维度;以及block_size,输入序列中的标记数。

14.3.1 音乐 Transformer 中的超参数

打开您之前从本书的 GitHub 仓库下载的文件 ch14util.py。在里面,您会发现几个函数和类,它们与第十二章中定义的完全相同。

正如本书中我们看到的所有 GPT 模型一样,解码器块中的前馈网络使用高斯误差线性单元(GELU)激活函数。因此,我们在 ch14util.py 中定义了一个 GELU 类,这与我们在第十二章中做的一样。

我们使用一个Config()类来存储音乐 Transformer 中使用的所有超参数:

from torch import nn
class Config():
    def __init__(self):
        self.n_layer = 6
        self.n_head = 8
        self.n_embd = 512
        self.vocab_size = 390
        self.block_size = 2048 
        self.embd_pdrop = 0.1
        self.resid_pdrop = 0.1
        self.attn_pdrop = 0.1
config=Config()
device="cuda" if torch.cuda.is_available() else "cpu"

Config()类中的属性作为我们音乐 Transformer 的超参数。我们将n_layer属性的值设置为 6,表示我们的音乐 Transformer 由 6 个解码器层组成。这比我们在第十二章中构建的 GPT 模型中的解码器层数要多。每个解码器层处理输入序列并引入一个抽象或表示的层次。随着信息穿越更多层,模型能够捕捉到数据中更复杂的模式和关系。这种深度对于我们的音乐 Transformer 理解并生成复杂的音乐作品至关重要。

n_head属性设置为 8,表示在计算因果自注意力时,我们将查询 Q、键 K 和值 V 向量分为八个并行头。n_embd属性设置为 512,表示嵌入维度为 512:每个事件标记将由一个包含 512 个值的向量表示。vocab_size属性由词汇表中的唯一标记数量确定,为 390。如前所述,有 388 个唯一的事件标记,我们添加了 1 个标记来表示序列的结束,并添加了另一个标记来填充较短的序列,以便所有序列的长度都为 2,048。block_size属性设置为 2,048,表示输入序列最多包含 2,048 个标记。我们将 dropout 比率设置为 0.1,与第十一章和第十二章相同。

与所有 Transformer 一样,我们的音乐 Transformer 使用自注意力机制来捕捉序列中不同元素之间的关系。因此,我们在本地模块 ch14util 中定义了一个CausalSelfAttention()类,它与第十二章中定义的CausalSelfAttention()类相同。

14.3.2 构建音乐 Transformer

我们将前馈网络与因果自注意力子层结合形成一个解码块(即解码层)。我们对每个子层应用层归一化和残差连接以提高稳定性和性能。为此,我们在本地模块中定义了一个Block()类来创建解码块,它与我们在第十二章中定义的Block()类相同。

然后,我们在音乐 Transformer 的上方堆叠六个解码块,形成其主体。为了实现这一点,我们在本地模块中定义了一个Model()类。正如我们在本书中看到的所有 GPT 模型一样,我们使用通过 PyTorch 中的Embedding()类学习到的位置编码,而不是原始 2017 年论文“Attention Is All You Need”中的固定位置编码。有关两种位置编码方法之间的差异,请参阅第十一章。

模型的输入由对应于词汇表中音乐事件标记的索引序列组成。我们将输入通过词嵌入和位置编码传递,并将两者相加以形成输入嵌入。然后,输入嵌入通过六个解码层。之后,我们对输出应用层归一化,并将其连接到一个线性头,以便输出的数量为 390,即词汇表的大小。输出是词汇表中 390 个标记的 logits。稍后,我们将对 logits 应用 softmax 激活函数,以获得生成音乐时词汇表中唯一音乐标记的概率分布。

接下来,我们将通过实例化我们在本地模块中定义的Model()类来创建我们的音乐 Transformer:

from utils.ch14util import Model

model=Model(config)
model.to(device)
num=sum(p.numel() for p in model.transformer.parameters())
print("number of parameters: %.2fM" % (num/1e6,))
print(model)

输出是

number of parameters: 20.16M
Model(
  (transformer): ModuleDict(
    (wte): Embedding(390, 512)
    (wpe): Embedding(2048, 512)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-5): 6 x Block(
        (ln_1): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
        (attn): CausalSelfAttention(
          (c_attn): Linear(in_features=512, out_features=1536, bias=True)
          (c_proj): Linear(in_features=512, out_features=512, bias=True)
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
        (mlp): ModuleDict(
          (c_fc): Linear(in_features=512, out_features=2048, bias=True)
          (c_proj): Linear(in_features=2048, out_features=512, bias=True)
          (act): GELU()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
  )
  (lm_head): Linear(in_features=512, out_features=390, bias=False)
)

我们的音乐 Transformer 由 2016 万个参数组成,这个数字比拥有超过 15 亿个参数的 GPT-2XL 小得多。尽管如此,我们的音乐 Transformer 的规模超过了我们在第十二章中构建的仅包含 512 万个参数的文本生成器。尽管存在这些差异,所有三个模型都是基于仅解码器 Transformer 架构。差异仅在于超参数,如嵌入维度、解码器层数、词汇量大小等。

14.4 训练和使用音乐 Transformer

在本节中,您将使用本章前期准备好的训练数据批次来训练您刚刚构建的音乐 Transformer。为了加快过程,我们将对模型进行 100 个周期的训练,然后停止训练过程。对于感兴趣的人来说,您可以使用验证集来确定何时停止训练,根据模型在验证集上的性能,就像我们在第二章中所做的那样。

一旦模型训练完成,我们将以一系列索引的形式提供给它一个提示。然后,我们将请求训练好的音乐 Transformer 生成下一个索引。这个新的索引被附加到提示中,更新的提示被送回模型进行另一个预测。这个过程会迭代重复,直到序列达到一定的长度。

与第十三章中生成的音乐不同,我们可以通过应用不同的温度来控制音乐作品的创造性。

14.4.1 训练音乐 Transformer

和往常一样,我们将使用 Adam 优化器进行训练。鉴于我们的音乐 Transformer 实质上执行的是一个多类别分类任务,我们将使用交叉熵损失作为我们的损失函数:

lr=0.0001
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
loss_func=torch.nn.CrossEntropyLoss(ignore_index=389)

在之前的损失函数中,ignore_index=389 参数指示程序在目标序列(即序列 y)中遇到索引 389 时忽略它,因为这个索引仅用于填充目的,并不代表音乐作品中的任何特定事件标记。

我们将接着对模型进行 100 个周期的训练。

列表 14.6 训练音乐 Transformer 生成音乐

model.train()  
for i in range(1,101):
    tloss = 0.
    for idx, (x,y) in enumerate(trainloader):              ①
        x,y=x.to(device),y.to(device)
        output = model(x)
        loss=loss_func(output.view(-1,output.size(-1)),
                           y.view(-1))                     ②
        optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(),1)     ③
        optimizer.step()                                   ④
    print(f'epoch {i} loss {tloss/(idx+1)}') 
torch.save(model.state_dict(),f'files/musicTrans.pth')     ⑤

① 遍历所有训练数据批次

② 将模型预测与实际输出进行比较

③ 将梯度范数裁剪到 1

④ 调整模型参数以最小化损失

⑤ 训练后保存模型

在训练过程中,我们将所有输入序列 x 在一个批次中通过模型来获得预测。然后,我们将这些预测与批次中的相应输出序列 y 进行比较,并计算交叉熵损失。之后,我们调整模型参数以最小化这个损失。需要注意的是,我们已经将梯度范数裁剪到 1,以防止潜在的梯度爆炸问题。

如果您有 CUDA 支持的 GPU,上述训练过程大约需要 3 小时。训练完成后,训练好的模型权重 musicTrans.pth 将保存在您的计算机上。或者,您可以从我的网站mng.bz/V2pW下载训练好的权重。

14.4.2 使用训练好的 Transformer 进行音乐生成

现在我们已经训练了一个音乐 Transformer,我们可以进行音乐生成了。

与文本生成过程类似,音乐生成始于将一系列索引(代表事件标记)作为提示输入到模型中。我们将从测试集中选择一首音乐作品,并使用前 250 个音乐事件作为提示:

from utils.processor import decode_midi

prompt, _  = test[42]
prompt = prompt.to(device)
len_prompt=250
file_path = "files/prompt.midi"
decode_midi(prompt[:len_prompt].cpu().numpy(),
            file_path=file_path)

我们随机选择了一个索引(在我们的例子中是 42)并使用它从测试子集中检索一首歌曲。我们只保留前 250 个音乐事件,稍后我们将这些事件输入到训练好的模型中以预测下一个音乐事件。为了比较,我们将提示保存为 MIDI 文件 prompt.midi 到本地文件夹中。

练习 14.2

使用decode_midi()函数将测试集中第二首音乐的第一个 250 个音乐事件转换为 MIDI 文件。将其保存在您的计算机上的 prompt2.midi。

为了简化音乐生成过程,我们将定义一个sample()函数。该函数接受一个索引序列作为输入,代表一小段音乐。然后它迭代地预测并追加新的索引到序列中,直到达到指定的长度seq_length。实现方式如下所示。

列表 14.7 音乐生成中的sample()函数

softmax=torch.nn.Softmax(dim=-1)
def sample(prompt,seq_length=1000,temperature=1):
    gen_seq=torch.full((1,seq_length),389,dtype=torch.long).to(device)
    idx=len(prompt)
    gen_seq[..., :idx]=prompt.type(torch.long).to(device)    
    while(idx < seq_length):                                       ①
        y=softmax(model(gen_seq[..., :idx])/temperature)[...,:388] ②
        probs=y[:, idx-1, :]
        distrib=torch.distributions.categorical.Categorical(probs=probs)
        next_token=distrib.sample()                                ③
        gen_seq[:, idx]=next_token
        idx+=1
    return gen_seq[:, :idx]                                        ④

① 生成新的索引直到序列达到一定长度

② 将预测值除以温度,然后在 logits 上应用 softmax 函数

③ 从预测的概率分布中采样以生成新的索引

④ 输出整个序列

sample()函数的一个参数是温度,它调节生成音乐的创造性。如有需要,请参考第八章了解其工作原理。由于我们可以仅通过温度参数调整生成音乐的原创性和多样性,因此在此实例中省略了top-K采样以简化过程。正如我们在本书中之前三次讨论过top-K采样(在第 8、11 和 12 章),感兴趣的读者可以尝试将top-K采样结合到sample()函数中。

接下来,我们将加载训练好的权重到模型中:

model.load_state_dict(torch.load("files/musicTrans.pth",
    map_location=device))
model.eval()

然后,我们调用sample()函数来生成一段音乐:

from utils.processor import encode_midi

file_path = "files/prompt.midi"
prompt = torch.tensor(encode_midi(file_path))
generated_music=sample(prompt, seq_length=1000)

首先,我们利用处理器模块中的encode_midi()函数将 MIDI 文件 prompt.midi 转换为索引序列。然后我们使用这个序列作为sample()函数中的提示来生成由 1,000 个索引组成的音乐作品。

最后,我们将生成的索引序列转换为 MIDI 格式:

music_data = generated_music[0].cpu().numpy()
file_path = 'files/musicTrans.midi'
decode_midi(music_data, file_path=file_path)

我们在 processor.py 模块中使用了decode_midi()函数,将生成的索引序列转换成您电脑上的 MIDI 文件,即 musicTrans.midi。在您的电脑上打开这两个文件,prompt.midi 和 musicTrans.midi,并聆听它们。prompt.midi 中的音乐大约持续 10 秒。musicTrans.midi 中的音乐大约持续 40 秒,最后的 30 秒是由音乐变换器生成的新音乐。生成的音乐应该听起来像我网站上的音乐作品:mng.bz/x6dg

上述代码块可能产生类似于以下输出的结果:

info removed pitch: 52
info removed pitch: 83
info removed pitch: 55
info removed pitch: 68

在生成的音乐中,可能会有一些音符需要被移除。例如,如果生成的音乐作品尝试关闭音符 52,但音符 52 最初从未被开启,那么我们就不能关闭它。因此,我们需要移除这样的音符。

练习 14.3

使用训练好的音乐变换器模型生成包含 1,200 个音符的音乐作品,保持温度参数为 1。使用您在 14.2 练习中生成的 prompt2.midi 文件中的索引序列作为提示。将生成的音乐保存到您电脑上的名为 musicTrans2.midi 的文件中。

您可以通过将温度参数设置为大于 1 的值来提高音乐的创意,如下所示:

file_path = "files/prompt.midi"
prompt = torch.tensor(encode_midi(file_path))
generated_music=sample(prompt, seq_length=1000,temperature=1.5)
music_data = generated_music[0].cpu().numpy()
file_path = 'files/musicHiTemp.midi'
decode_midi(music_data, file_path=file_path)

我们将温度设置为 1.5。生成的音乐保存为 musicHiTemp.midi 文件在您的电脑上。打开该文件并聆听,看看您是否能辨别出与 musicTrans.midi 文件中的音乐相比有任何差异。

练习 14.4

使用训练好的音乐变换器模型生成包含 1,000 个索引的音乐作品,将温度参数设置为 0.7。使用 prompt.midi 文件中的索引序列作为提示。将生成的音乐保存到您电脑上的名为 musicLowTemp.midi 的文件中。打开此文件聆听生成的音乐,看看新作品与 musicTrans.midi 文件中的音乐之间是否有可辨别的差异。

在本章中,您已经学习了如何从头开始构建和训练音乐变换器,基于您在前面章节中使用的仅解码器变换器架构。在下一章中,您将探索基于扩散的模型,这些模型是像 OpenAI 的 DALL-E 2 和 Google 的 Imagen 这样的文本到图像变换器的核心。

摘要

  • 音乐的性能表示使我们能够将音乐作品表示为一串音符,这些音符包括控制信息和速度值。这些音符可以进一步简化为四种音乐事件:音符开启、音符关闭、时间移动和速度。每种事件类型可以假设各种值。因此,我们可以将音乐作品转换为一串标记,然后转换为索引。

  • 音乐 Transformer 架构是对最初为 NLP 任务设计的 Transformer 架构进行适配,用于音乐生成。该模型旨在通过学习大量现有音乐数据集来生成音乐音符序列。它通过识别训练数据中各种音乐元素之间的模式、结构和关系,被训练来根据前面的音符预测序列中的下一个音符。

  • 正如文本生成一样,我们可以使用温度来调节生成音乐的创造力。


^(1) Chloe Veltman,2024 年 3 月 15 日。“仅仅因为你的最爱歌手已经去世,并不意味着你不能看到他们‘现场’。” mng.bz/r1de

^(2) Cheng-Zhi Anna Huang, Ashish Vaswani, Jakob Uszkoreit, Noam Shazeer, Ian Simon, Curtis Hawthorne, Andrew M. Dai, Matthew D. Hoffman, Monica Dinculescu, 和 Douglas Eck,2018 年,“Music Transformer。” arxiv.org/abs/1809.04281

^(3) 例如,参见 Hawthorne 等人,2018 年,“使用 MAESTRO 数据集实现分解钢琴音乐建模和生成。” arxiv.org/abs/1810.12247

第十五章:种扩散模型和文本到图像 Transformer

本章涵盖

  • 前向扩散和反向扩散是如何工作的

  • 如何构建和训练去噪 U-Net 模型

  • 使用训练好的 U-Net 生成花卉图像

  • 文本到图像 Transformer 背后的概念

  • 编写 Python 程序通过 DALL-E 2 使用文本生成图像

近年来,多模态大型语言模型(LLMs)因其处理各种内容格式的能力而受到广泛关注,例如文本、图像、视频、音频和代码。这一领域的显著例子包括 OpenAI 的 DALL-E 2、Google 的 Imagen 和 Stability AI 的 Stable Diffusion 等文本到图像 Transformer。这些模型能够根据文本描述生成高质量的图像。

这些文本到图像模型包括三个基本组件:一个文本编码器,它将文本压缩成潜在表示;一种将文本信息融入图像生成过程的方法;以及一种扩散机制,用于逐步细化图像以产生逼真的输出。理解扩散机制对于理解文本到图像 Transformer 尤其关键,因为扩散模型构成了所有领先文本到图像 Transformer 的基础。因此,你将首先在本章中构建和训练一个扩散模型来生成花卉图像。这将帮助你深入理解前向扩散过程,其中噪声逐步添加到图像中,直到它们变成随机噪声。随后,你将训练一个模型通过逐步从图像中移除噪声来逆转扩散过程,直到模型可以从随机噪声中生成一个新、干净的图像,类似于训练数据集中的图像。

扩散模型已成为生成高分辨率图像的首选选择。扩散模型的成功在于它们能够模拟和逆转复杂的噪声添加过程,这模仿了对图像结构和如何从抽象模式中构建图像的深入理解。这种方法不仅确保了高质量,而且在生成的图像中保持了多样性和准确性的平衡。

之后,我们将解释文本到图像 Transformer 在概念上的工作方式。我们将重点关注由 OpenAI 开发的对比语言-图像预训练(CLIP)模型,该模型旨在理解和关联视觉和文本信息。CLIP 处理两种类型的输入:图像和文本(通常是标题或描述的形式)。这些输入通过模型中的两个编码器分别处理。

CLIP 的图像分支使用视觉 Transformer (ViT)将图像编码到高维向量空间中,在这个过程中提取视觉特征。同时,文本分支使用基于 Transformer 的语言模型将文本描述编码到相同的向量空间中,从文本中捕获语义特征。CLIP 已经在许多匹配图像和文本描述的配对上进行了训练,以使向量空间中匹配对的表示紧密对齐。

OpenAI 的文本到图像的 Transformers,如 DALL-E 2,将 CLIP 作为一个核心组件。在本章中,你将学习如何获取 OpenAI API 密钥,并编写一个 Python 程序,根据文本描述使用 DALL-E 2 生成图像。

15.1 去噪扩散模型简介

使用以下示例可以说明基于扩散的模型的概念。考虑使用基于扩散的模型生成高分辨率花卉图像的目标。为此,你首先需要获取一组高质量的花卉图像用于训练。然后,指导模型逐步将这些图像中引入少量随机噪声,这个过程被称为正向扩散。经过多次添加噪声的步骤后,训练图像最终变成随机噪声。下一阶段涉及训练模型逆转这个过程,从纯噪声图像开始,逐步减少噪声,直到图像与原始训练集中的图像无法区分。

一旦训练完成,模型将得到随机噪声图像进行处理。它通过多次迭代系统地消除图像中的噪声,直到生成一个与训练集中相似的、高分辨率的花卉图像。这就是基于扩散模型的底层原理。1

在本节中,你将首先探索基于扩散的模型的数学基础。然后,你将深入了解 U-Nets 的架构,这是一种用于去噪图像和生成高分辨率花卉图像的模型类型。具体来说,U-Net 采用缩放点积注意力(SDPA)机制,类似于你在第九章到第十二章中看到的 Transformer 模型。最后,你将学习基于扩散的模型的训练过程以及训练模型的图像生成过程。

15.1.1 正向扩散过程

几篇论文提出了具有类似底层机制的基于扩散的模型。2 让我们以花卉图像作为一个具体的例子来解释去噪扩散模型背后的思想。图 15.1 展示了正向扩散过程的工作原理。

图片

图 15.1 正向扩散过程的示意图。我们从训练集中的干净图像x[0]开始,向其添加噪声є[0],形成噪声图像x[1] = √(1 – β[1])x[0] + √(β[1])є[0]。我们重复这个过程 1000 次,直到图像x[1000]变成随机噪声。

假设花朵图像 x[0](如图 15.1 左侧图像所示)遵循 q(x) 分布。在正向扩散过程中,我们将向每个 T = 1,000 步中的图像添加少量噪声。噪声张量是正态分布的,其形状与花朵图像相同:(3, 64, 64),表示三个颜色通道,高度和宽度为 64 像素。

扩散模型中的时间步

在扩散模型中,时间步指的是在逐渐向数据添加噪声并随后逆转此过程以生成样本的过程中所经历的离散阶段。扩散模型的前向阶段在一系列时间步中逐步添加噪声,将数据从其原始、干净的状态转换为噪声分布。在反向阶段,模型在类似的一系列时间步中操作,但顺序相反。它系统地从数据中去除噪声以重建原始数据或生成新的、高保真度的样本。在此反向过程的每个时间步中,都涉及预测在相应的正向步骤中添加的噪声并将其减去,从而逐渐去除数据直到达到干净状态。

在时间步 1 中,我们将噪声 є[0] 添加到图像 x[0],从而获得一个噪声图像 x[1]:

|

图像

(15.1)

即,x[1] 是 x[0] 和 є[0] 的加权求和,其中 β[1] 衡量了噪声的权重。β 的值在不同时间步中会变化——因此 β[1] 中的下标。如果我们假设 x[0] 和 є[0] 之间相互独立,并且遵循标准正态分布(即,均值为 0,方差为 1),则噪声图像 x[1] 也将遵循标准正态分布。这很容易证明,因为

图像

图像

我们可以在接下来的 T-1 个时间步中继续向图像添加噪声,以便

|

图像

(15.2)

我们可以使用重新参数化技巧并定义 α[t] = 1 − β[t]

图像

以便我们可以在任意时间步 t 对 x[t] 进行采样,其中 t 可以取 [1, 2, . . ., T−1, T] 中的任何值。然后我们有

|

图像

(15.3)

其中 є 是 є[0]、є[1]、...、є[t][–1] 的组合,利用我们可以将两个正态分布相加以获得一个新的正态分布这一事实。例如,请参阅 Lilian Weng 的博客 mng.bz/Aalg 以获取证明。

图 15.1 的左侧显示了来自训练集的干净花朵 x[0]。在第一步中,我们向其注入噪声 є[0],以形成噪声图像 x[1](图 15.1 中的第二个图像)。我们重复此过程 1,000 个时间步,直到图像变成随机噪声(最右侧的图像)。

15.1.2 使用 U-Net 模型进行图像去噪

现在你已经理解了正向扩散过程,让我们来讨论反向扩散过程(即去噪过程)。如果我们能够训练一个模型来逆转正向扩散过程,我们可以向模型输入随机噪声并要求它生成一个带噪声的花朵图像。然后我们可以将这个带噪声的图像再次输入到训练好的模型中,生成一个更清晰、但仍然带噪声的图像。我们可以迭代重复这个过程多次,直到我们获得一个干净的图像,与训练集中的图像无法区分。在反向扩散过程中使用多个推理步骤,而不是仅仅一个步骤,对于逐渐从噪声分布中重建高质量数据至关重要。它允许更可控、稳定且高质量地生成数据。

为了达到这个目的,我们将创建一个去噪 U-Net 模型。U-Net 架构最初是为生物医学图像分割设计的,其特点是具有对称形状,包括收缩路径(编码器)和扩展路径(解码器),通过瓶颈层连接。在去噪的背景下,U-Net 模型被调整为从图像中去除噪声,同时保留重要细节。U-Net 在去噪任务中优于简单的卷积网络,因为它们能够有效地捕捉图像中的局部和全局特征。

图 15.2 是本章中使用的去噪 U-Net 结构图。

该模型以一个带噪声的图像和带噪声图像的时间步长(x[t] 和方程 15.3 中的 t)作为输入,并预测图像中的噪声(即 є)。由于带噪声的图像是原始干净图像和噪声的加权总和(参见方程 15.3),了解噪声使我们能够推断并重建原始图像。

收缩路径(即编码器;图 15.2 的左侧)由多个卷积层和池化层组成。它逐步下采样图像,在不同抽象级别提取和编码特征。这一部分网络学会识别与去噪相关的模式和特征。

瓶颈层(图 15.2 的底部)连接编码器和解码器路径。它由卷积层组成,负责捕捉图像的最抽象表示。

扩展路径(即解码器;图 15.2 的右侧)由上采样层和卷积层组成。它逐步上采样特征图,在通过跳跃连接结合编码器特征的同时重建图像。跳跃连接(图 15.2 中用虚线表示)在 U-Net 模型中至关重要,因为它们允许模型通过结合低级和高级特征来保留输入图像的精细细节。接下来,我将简要解释跳跃连接是如何工作的。

图片

图 15.2 去噪 U-Net 模型架构。U-Net 架构以其对称形状为特征,包括收缩路径(编码器)和扩展路径(解码器),通过瓶颈层连接。该模型旨在去除图像中的噪声,同时保留重要细节。模型的输入是一个噪声图像,以及图像所在的时间步,输出是图像中的预测噪声。

在 U-Net 模型中,跳过连接是通过将编码路径的特征图与解码路径中的相应特征图连接起来实现的。这些特征图通常具有相同的空间维度,但由于它们经过的路径不同,可能已经以不同的方式处理过。在编码过程中,输入图像逐渐下采样,一些空间信息(如边缘和纹理)可能会丢失。跳过连接通过直接从编码器传递特征图到解码器,绕过信息瓶颈,帮助保留这些信息。

例如,图 15.2 顶部的虚线表示模型将编码器中的 Conv2D 层的输出(形状为(128,64,64))与解码器中的 Conv2D 层的输入(形状也为(128,64,64))连接起来。因此,解码器中 Conv2D 层的最终输入形状为(256,64,64)。

通过将解码器中的高级、抽象特征与编码器中的低级、详细特征相结合,跳过连接使模型能够更好地重建去噪图像中的细微细节。这在去噪任务中尤为重要,因为保留细微的图像细节至关重要。

在我们的去噪 U-Net 模型中,缩放点积注意力(SDPA)机制在收缩路径的最后一个块和扩展路径的最后一个块中实现,伴随着层归一化和残差连接(如图 15.2 中标记为 Attn/Norm/Add)。这个 SDPA 机制本质上与我们第九章中开发的相同;关键区别在于它应用于图像像素而不是文本标记。

跳过连接的使用和模型的大小导致我们的去噪 U-Net 中存在冗余的特征提取,确保在去噪过程中不会丢失任何重要特征。然而,模型的大尺寸也使得识别相关特征变得复杂,就像在干草堆里找针一样。注意力机制赋予模型强调显著特征并忽略不相关特征的能力,从而增强了学习过程的有效性。

15.1.3 训练去噪 U-Net 模型的蓝图

去噪 U-Net 的输出是注入到噪声图像中的噪声。该模型经过训练以最小化输出(预测噪声)与真实噪声之间的差异。

去噪 U-Net 模型利用 U-Net 架构捕捉局部和全局上下文的能力,使其在去除噪声的同时保留重要细节(如边缘和纹理)变得有效。这些模型在各种应用中得到了广泛应用,包括医学图像去噪、摄影图像恢复等。图 15.3 展示了我们去噪 U-Net 模型的训练过程。

图片

图 15.3 去噪 U-Net 模型的训练过程。我们首先获取干净的花卉图像作为我们的训练集。我们向干净的花卉图像添加噪声,并将其呈现给 U-Net 模型。模型预测噪声图像中的噪声。我们将预测的噪声与注入花卉图像的实际噪声进行比较,并调整模型权重以最小化平均绝对误差。

第一步是收集花卉图像数据集。我们将使用牛津 102 花卉数据集作为我们的训练集。我们将所有图像调整到 64 × 64 像素的固定分辨率,并将像素值归一化到范围[–1, 1]。对于去噪,我们需要成对的干净和噪声图像。我们将根据方程 15.3 中指定的公式,对干净的花卉图像添加噪声,以创建噪声对应图像(图 15.3 中的步骤 2)。

然后,我们将根据图 15.2 中概述的结构构建一个去噪 U-Net 模型。在训练的每个 epoch 中,我们以批处理的方式遍历数据集。我们向花卉图像添加噪声,并将噪声图像及其在噪声图像中的时间步长 t 呈现给 U-Net 模型(步骤 3)。U-Net 模型根据模型中的当前参数预测噪声图像中的噪声(步骤 4)。

我们将预测的噪声与实际噪声进行比较,并在像素级别计算 L1 损失(即平均绝对误差)(步骤 5)。在这种情况下,L1 损失通常比 L2 损失(均方误差)对异常值更不敏感,因此更受欢迎。然后,我们调整模型参数以最小化 L1 损失(步骤 6),以便在下一轮迭代中,模型能做出更好的预测。我们将重复此过程多次,直到模型参数收敛。

15.2 准备训练数据

我们将使用牛津 102 花卉数据集,该数据集可在 Hugging Face 上免费获取,作为我们的训练数据。该数据集包含大约 8,000 张花卉图像,您可以使用之前安装的datasets库直接下载。

为了节省空间,我们将大多数辅助函数和类放在两个本地模块中,ch15util.py 和 unet_util.py。从本书的 GitHub 仓库(github.com/markhliu/DGAI)下载这两个文件,并将它们放在您计算机上的/utils/文件夹中。本章中的 Python 程序是从 Hugging Face 的 GitHub 仓库(github.com/huggingface/diffusers)和 Filip Basara 的 GitHub 仓库(github.com/filipbasara0/simple-diffusion)改编的。

您将使用 Python 将数据集下载到您的计算机上。之后,我们将通过逐渐向训练数据集中的干净图像添加噪声,直到它们变成随机噪声,来演示前向扩散过程。最后,您将批量放置训练数据,以便我们可以在本章后面使用它们来训练去噪 U-Net 模型。

您在本章中将使用以下 Python 库:datasets、einops、diffusers 和 openai。要安装这些库,请在您的计算机上的 Jupyter Notebook 应用程序的新单元格中执行以下代码行:

!pip install datasets einops diffusers openai

按照屏幕上的说明完成安装。

15.2.1 将花卉图像作为训练数据

您之前安装的datasets库中的load_dataset()方法允许您直接从 Hugging Face 下载牛津 102 花卉数据集。然后我们将使用matplotlib库显示数据集中的某些花卉图像,以便我们了解训练数据集中的图像是什么样的。

在 Jupyter Notebook 的单元格中运行以下列表中的代码行。

列表 15.1 下载和可视化花卉图像

from datasets import load_dataset
from utils.ch15util import transforms

dataset = load_dataset("huggan/flowers-102-categories",
    split="train",)                                      ①
dataset.set_transform(transforms)

import matplotlib.pyplot as plt
from torchvision.utils import make_grid

# Plot all the images of the 1st batch in grid
grid = make_grid(dataset[:16]["input"], 8, 2)            ②
plt.figure(figsize=(8,2),dpi=300)
plt.imshow(grid.numpy().transpose((1,2,0)))
plt.axis("off")
plt.show()

① 从 Hugging Face 下载图像

② 绘制前 16 张图像

在运行前面的代码列表后,您将看到数据集中的前 16 张花卉图像,如图 15.4 所示。这些是各种类型花卉的高分辨率彩色图像。我们已经将每张图像的大小标准化为(3,64,64)。

图片

图 15.4 来自牛津 102 花卉数据集的前 16 张图像。

我们将数据集分成每批 4 个,以便我们可以在本章后面使用它们来训练去噪 U-Net 模型。我们选择批大小为 4,以保持内存大小足够小,以便在训练过程中适合 GPU。如果您的 GPU 内存较小,请将批大小调整为 2 甚至 1:

import torch
resolution=64
batch_size=4
train_dataloader=torch.utils.data.DataLoader(
    dataset, batch_size=batch_size, shuffle=True)

接下来,我们将编写代码并可视化前向扩散过程。

15.2.2 可视化前向扩散过程

我们在本地模块 ch15util.py 中定义了一个 DDIMScheduler() 类,您刚刚下载。请查看该文件中的类;我们将使用它向图像添加噪声。我们还将使用该类来生成干净的图像,以及训练好的去噪 U-Net 模型。DDIMScheduler() 类管理步长大小和去噪步骤的顺序,通过去噪过程实现确定性推理,从而可以生成高质量的样本。

我们首先从训练集中选择四张干净图像,并生成与这些图像形状相同的噪声张量:

clean_images=next(iter(train_dataloader))["input"]*2-1    ①
print(clean_images.shape)
nums=clean_images.shape[0]
noise=torch.randn(clean_images.shape)                     ②
print(noise.shape)

① 获取四张干净图像

② 生成一个与干净图像形状相同的张量,噪声;噪声中的每个值都遵循独立的标准正态分布。

前一个代码块输出的结果是

torch.Size([4, 3, 64, 64])
torch.Size([4, 3, 64, 64])

图像和噪声张量的形状都是 (4, 3, 64, 64),这意味着批处理中有 4 张图像,每张图像有 3 个颜色通道,图像的高度和宽度为 64 像素。

在正向扩散过程中,在干净图像 (x[0] 如我们在第一部分中解释的) 和随机噪声 (x[T]) 之间有 999 个过渡噪声图像。过渡噪声图像是干净图像和噪声的加权总和。随着 t 从 0 到 1,000 的变化,干净图像的权重逐渐降低,噪声的权重逐渐增加,如方程式 15.3 所述。

接下来,我们生成并可视化一些过渡噪声图像。

列表 15.2 可视化正向扩散过程

from utils.ch15util import DDIMScheduler

noise_scheduler=DDIMScheduler(num_train_timesteps=1000)    ①
allimgs=clean_images
for step in range(200,1001,200):                           ②
    timesteps=torch.tensor([step-1]*4).long()
    noisy_images=noise_scheduler.add_noise(clean_images,
                 noise, timesteps)                         ③
    allimgs=torch.cat((allimgs,noisy_images))              ④

import torchvision
imgs=torchvision.utils.make_grid(allimgs,4,6)
fig = plt.figure(dpi=300)
plt.imshow((imgs.permute(2,1,0)+1)/2)                      ⑤
plt.axis("off")
plt.show()

① 使用 1,000 个时间步创建 DDIMScheduler() 类实例

② 查看时间步 200、400、600、800 和 1,000

③ 在这些时间步创建噪声图像

④ 将噪声图像与干净图像连接

⑤ 显示所有图像

DDIMScheduler() 类中的 add_noise() 方法接受三个参数:clean_imagesnoisetimesteps。它产生干净图像和噪声的加权总和,即噪声图像。此外,权重是时间步 t 的函数。随着时间步 t 从 0 到 1,000 的变化,干净图像的权重逐渐降低,噪声的权重逐渐增加。如果您运行前面的代码列表,您将看到类似于图 15.5 的图像。

图片

图 15.5 正向扩散过程。第一列中的四张图像是训练数据集中的干净图像。然后我们从时间步 1 到时间步 1,000 逐渐向这些图像添加噪声。随着时间步的增加,图像中注入的噪声越来越多。第二列中的四张图像是在 200 个时间步之后的图像。第三列包含 400 个时间步之后的图像,它们的噪声比第二列中的图像更多。最后一列包含 1,000 个时间步之后的图像,它们是 100% 的随机噪声。

第一列包含没有噪声的四个干净图像。随着向右移动,我们逐渐向图像添加越来越多的噪声。最后一列包含纯随机噪声。

15.3 构建噪声去除 U-Net 模型

在本章的早期部分,我们讨论了噪声去除 U-Net 模型的架构。在本节中,我将指导您使用 Python 和 PyTorch 来实现它。

我们将要构建的 U-Net 模型相当大,包含超过 1.33 亿个参数,反映了其预期任务的复杂性。它通过下采样和上采样输入来捕捉图像中的局部和全局特征。该模型使用多个通过跳跃连接相互连接的卷积层,这些层结合了网络不同级别的特征。这种架构有助于保持空间信息,从而促进更有效的学习。

由于噪声去除 U-Net 模型规模庞大且特征提取冗余,我们采用了 SDPA 注意力机制,使模型能够专注于当前任务中最相关的输入方面。为了计算 SDPA 注意力,我们将图像展平并将像素视为一个序列。然后我们将使用 SDPA 以类似于在第九章中学习文本中不同标记之间依赖关系的方式,学习图像中不同像素之间的依赖关系。

15.3.1 噪声去除 U-Net 模型中的注意力机制

为了实现注意力机制,我们在本地模块 ch15util.py 中定义了一个Attention()类,如下面的代码列表所示。

列表 15.3 噪声去除 U-Net 模型中的注意力机制

import torch
from torch import nn, einsum
from einops import rearrange

class Attention(nn.Module):
    def __init__(self, dim, heads=4, dim_head=32):
        super().__init__()
        self.scale = dim_head**-0.5
        self.heads = heads
        hidden_dim = dim_head * heads
        self.to_qkv = nn.Conv2d(dim, hidden_dim * 3, 1, bias=False)
        self.to_out = nn.Conv2d(hidden_dim, dim, 1)
    def forward(self, x):
        b, c, h, w = x.shape
        qkv = self.to_qkv(x).chunk(3, dim=1)                      ①
        q, k, v = map(
        lambda t: rearrange(t, 'b (h c) x y -> b h c (x y)', h=self.heads),
        qkv)                                                      ②
        q = q * self.scale    
        sim = einsum('b h d i, b h d j -> b h i j', q, k)
        attn = sim.softmax(dim=-1)                                ③
        out = einsum('b h i j, b h d j -> b h i d', attn, v)      ④
        out = rearrange(out, 'b h (x y) d -> b (h d) x y', x=h, y=w)
        return self.to_out(out)                                   ⑤
attn=Attention(128)
x=torch.rand(1,128,64,64)
out=attn(x)
print(out.shape)

① 将输入通过三个线性层传递以获得查询、键和值

② 将查询、键和值分成四个头

③ 计算注意力权重

④ 计算每个头中的注意力向量

⑤ 将四个注意力向量连接成一个

运行上述代码列表后的输出是

torch.Size([1, 128, 64, 64])

这里使用的注意力机制 SDPA 与我们在第九章中使用的相同,当时我们将 SDPA 应用于表示文本中标记的索引序列。在这里,我们将它应用于图像中的像素。我们将图像的展平像素视为一个序列,并使用 SDPA 提取输入图像不同区域之间的依赖关系,从而提高去噪过程的效率。

列表 15.3 展示了 SDPA 在我们上下文中的操作方式。为了给您一个具体的例子,我们创建了一个假设的图像,x,其维度为(1,128,64,64),表示批次中的一个图像,128 个特征通道,每个通道的大小为 64 × 64 像素。然后输入 x 通过注意力层进行处理。具体来说,图像中的每个特征通道被展平成一个 64 × 64 = 4,096 像素的序列。然后这个序列通过三个不同的神经网络层传递,以产生查询 Q、键 K 和值 V,随后将它们分成四个头。每个头中的注意力向量计算如下:

其中 d[k] 代表关键向量 K 的维度。来自四个头的注意力向量被连接回一个单独的注意力向量。

15.3.2 去噪 U-Net 模型

在您刚刚下载的本地模块 unet_util.py 中,我们定义了一个 UNet() 类来表示去噪 U-Net 模型。请查看文件中的定义,稍后我会简要解释其工作原理。以下代码列表展示了 UNet() 类的一部分。

列表 15.4 定义 UNet()

class UNet(nn.Module):
… 
    def forward(self, sample, timesteps):                         ①
        if not torch.is_tensor(timesteps):
            timesteps = torch.tensor([timesteps],
                                     dtype=torch.long,
                                     device=sample.device)
        timesteps = torch.flatten(timesteps)
        timesteps = timesteps.broadcast_to(sample.shape[0])
        t_emb = sinusoidal_embedding(timesteps, self.hidden_dims[0])
        t_emb = self.time_embedding(t_emb)                        ②
        x = self.init_conv(sample)
        r = x.clone()
        skips = []
        for block1, block2, attn, downsample in self.down_blocks: ③
            x = block1(x, t_emb)
            skips.append(x)
            x = block2(x, t_emb)
            x = attn(x)
            skips.append(x)
            x = downsample(x)
        x = self.mid_block1(x, t_emb)
        x = self.mid_attn(x)
        x = self.mid_block2(x, t_emb)                             ④
        for block1, block2, attn, upsample in self.up_blocks:    
            x = torch.cat((x, skips.pop()), dim=1)                ⑤
            x = block1(x, t_emb)
            x = torch.cat((x, skips.pop()), dim=1)
            x = block2(x, t_emb)
            x = attn(x)
            x = upsample(x)
        x = self.out_block(torch.cat((x, r), dim=1), t_emb)
        out = self.conv_out(x)
        return {"sample": out}                                    ⑥

① 模型接受一批噪声图像和时间步长作为输入。

② 将嵌入的时间步长作为输入添加到图像的各个阶段。

③ 将输入通过收缩路径传递

④ 通过瓶颈路径传递输入

⑤ 通过具有跳跃连接的扩张路径传递输入

⑥ 输出是输入图像中预测的噪声。

去噪 U-Net 的任务是根据这些图像所在的时间步长预测输入图像中的噪声。如方程 15.3 所述,任何时间步长 t 的噪声图像 x[t] 可以表示为干净图像 x[o] 和标准正态分布的随机噪声 є 的加权总和。分配给干净图像的权重随时间步长 t 从 0 到 T 的进展而减少,分配给随机噪声的权重增加。因此,为了推断噪声图像中的噪声,去噪 U-Net 需要知道噪声图像所在的时间步长。

时间步长使用类似于 Transformer(在第九章和第十章中讨论)中的位置编码的正弦和余弦函数进行嵌入,结果得到一个 128 值的向量。然后这些嵌入被扩展以匹配模型中各个层级的图像特征维度。例如,在第一个下采样块中,时间嵌入被广播到形状为(128,64,64),然后添加到图像特征中,这些特征也有(128,64,64)的维度。

接下来,我们通过在本地模块中实例化 UNet() 类来创建一个去噪 U-Net 模型:

from utils.unet_util import UNet

device="cuda" if torch.cuda.is_available() else "cpu"
resolution=64
model=UNet(3,hidden_dims=[128,256,512,1024],
           image_size=resolution).to(device)
num=sum(p.numel() for p in model.parameters())
print("number of parameters: %.2fM" % (num/1e6,))
print(model) 

输出是

number of parameters: 133.42M

模型有超过 1.33 亿个参数,您可以从之前的输出中看到。鉴于参数数量庞大,本章中的训练过程将耗时,大约需要 3 到 4 小时的 GPU 训练时间。然而,对于那些无法访问 GPU 训练的人,训练好的权重也存在于我的网站上。这些权重的链接将在下一节提供。

15.4 训练和使用去噪 U-Net 模型

现在我们已经拥有了训练数据和去噪 U-Net 模型,我们可以准备使用训练数据来训练模型了。

在每个训练周期中,我们将遍历训练数据中的所有批次。对于每张图像,我们将随机选择一个时间步长,并根据这个时间步长值在训练数据中的干净图像上添加噪声,从而得到一个噪声图像。然后,我们将这些噪声图像及其对应的时间步长值输入到去噪 U-Net 模型中,以预测每张图像中的噪声。我们将预测的噪声与真实值(实际添加到图像中的噪声)进行比较,并调整模型参数以最小化预测噪声与实际噪声之间的平均绝对误差。

训练完成后,我们将使用训练好的模型生成花朵图像。我们将通过 50 次推理步骤(即,我们将时间步长值设置为 980、960、……、20 和 0)来完成这一生成。从随机噪声开始,我们将将其输入到训练好的模型中,以获得一个噪声图像。然后,我们将这个噪声图像反馈到训练好的模型中,以对其进行去噪。我们将重复这个过程 50 次推理步骤,最终得到一个与训练集中花朵不可区分的图像。

15.4.1 训练去噪 U-Net 模型

接下来,我们首先定义训练过程中的优化器和学习率调度器。

我们将使用 AdamW 优化器,这是我们在整本书中一直在使用的 Adam 优化器的一个变体。AdamW 优化器最初由 Ilya Loshchilov 和 Frank Hutter 提出,它将权重衰减(一种正则化形式)从优化步骤中分离出来.^(3) AdamW 优化器不是直接将权重衰减应用于梯度,而是在优化步骤之后直接将权重衰减应用于参数(权重)。这种修改有助于通过防止衰减率与学习率一起调整来实现更好的泛化性能。感兴趣的读者可以在 Loshchilov 和 Hutter 的原始论文中了解更多关于 AdamW 优化器的内容。

我们还将使用 diffusers 库中的学习率调度器来调整训练过程中的学习率。最初使用较高的学习率可以帮助模型逃离局部最小值,而在训练的后期阶段逐渐降低学习率可以帮助模型更稳定、更准确地收敛到全局最小值。学习率调度器定义如下所示。

列表 15.5 在训练中选择优化器和学习率

from diffusers.optimization import get_scheduler

num_epochs=100                                             ①
optimizer=torch.optim.AdamW(model.parameters(),lr=0.0001,
    betas=(0.95,0.999),weight_decay=0.00001,eps=1e-8)      ②
lr_scheduler=get_scheduler(                                ③
    "cosine",
    optimizer=optimizer,
    num_warmup_steps=300,
    num_training_steps=(len(train_dataloader) * num_epochs))

① 将训练模型 100 个周期

② 使用 AdamW 优化器

③ 使用 diffusers 库中的学习率调度器来控制学习率

get_scheduler() 函数的精确定义由 Hugging Face 在 GitHub 上定义:mng.bz/ZVo5。在前 300 个训练步骤(预热步骤)中,学习率从 0 线性增加到 0.0001(我们在 AdamW 优化器中设置的学习率)。在 300 步之后,学习率按照 0.0001 和 0 之间的余弦函数值递减。在下面的列表中,我们训练模型 100 个周期。

列表 15.6 训练去噪 U-Net 模型

for epoch in range(num_epochs):
    model.train()
    tloss = 0
    print(f"start epoch {epoch}")
    for step, batch in enumerate(train_dataloader):
        clean_images = batch["input"].to(device)*2-1
        nums = clean_images.shape[0]
        noise = torch.randn(clean_images.shape).to(device)
        timesteps = torch.randint(0,
                noise_scheduler.num_train_timesteps,
                (nums, ),
                device=device).long()
        noisy_images = noise_scheduler.add_noise(clean_images,
                     noise, timesteps)                         ①

noise_pred = model(noisy_images, 
                       timesteps)["sample"]                    ②
        loss=torch.nn.functional.l1_loss(noise_pred, noise)    ③
        loss.backward()
        optimizer.step()                                       ④
        lr_scheduler.step()
        optimizer.zero_grad()
        tloss += loss.detach().item()
        if step%100==0:
            print(f"step {step}, average loss {tloss/(step+1)}")
torch.save(model.state_dict(),'files/diffusion.pth')

① 在训练集中向干净图像添加噪声

② 使用去噪 U-Net 预测噪声图像中的噪声

③ 将预测的噪声与实际噪声进行比较以计算损失

④ 调整模型参数以最小化平均绝对误差

在每个周期中,我们遍历训练集中所有干净的花朵图像的批次。我们向这些干净的图像添加噪声,并将它们输入到去噪 U-Net 中以预测这些图像中的噪声。然后我们将预测的噪声与实际噪声进行比较,并调整模型参数以最小化两者之间的平均绝对误差(像素级)。

这里描述的训练过程需要几个小时,使用 GPU 训练。训练后,训练好的模型权重将保存在您的计算机上。或者,您可以从我的网站 mng.bz/RNlD 下载训练好的权重。下载后请解压文件。

15.4.2 使用训练好的模型生成花朵图像

为了生成花朵图像,我们将使用 50 个推理步骤。这意味着我们将查看 t = 0 和 t = T 之间等间隔的 50 个时间步,在我们的情况下 T = 1,000。因此,50 个推理时间步是 t = 980, 960, 940, . . . , 20, 和 0。我们将从纯随机噪声开始,这对应于 t = 1000 的图像。我们使用训练好的去噪 U-Net 模型对其进行去噪,并在 t = 980 创建一个噪声图像。然后我们将 t = 980 的噪声图像呈现给训练好的模型进行去噪,以获得 t = 960 的噪声图像。我们重复这个过程多次,直到获得 t = 0 的图像,这是一个干净的图像。这个过程通过在本地模块 ch15util.py 中的 DDIMScheduler() 类的 generate() 方法实现。

列表 15.7 在 DDIMScheduler() 类中定义 generate() 方法

    @torch.no_grad()
    def generate(self,model,device,batch_size=1,generator=None,
         eta=1.0,use_clipped_model_output=True,num_inference_steps=50):
        imgs=[]
        image=torch.randn((batch_size,model.in_channels,model.sample_size,

model.sample_size),
            generator=generator).to(device)              ①

        self.set_timesteps(num_inference_steps)
        for t in tqdm(self.timesteps):                   ②
            model_output = model(image, t)["sample"]     ③
            image = self.step(model_output,t,image,eta,
                  use_clipped_model_output=\
                  use_clipped_model_output)              ④
            img = unnormalize_to_zero_to_one(image)
            img = img.cpu().permute(0, 2, 3, 1).numpy()
            imgs.append(img)                             ⑤
        image = unnormalize_to_zero_to_one(image)
        image = image.cpu().permute(0, 2, 3, 1).numpy()
        return {"sample": image}, imgs 

① 使用随机噪声作为起点(即 t = 1,000 的图像)

② 使用 50 个推理时间步(t = 980, 960, 940, . . , 20, 0)

③ 使用训练好的去噪 U-Net 模型来预测噪声

④ 根据预测的噪声创建图像

⑤ 将中间图像保存在列表,imgs 中

在这个 generate() 方法中,我们同样创建了一个列表,imgs,用于存储所有在时间步 t = 980, 960,. . . , 20, 和 0 时的中间图像。我们将使用它们来可视化去噪过程。generate() 方法返回一个包含生成的图像和列表,imgs 的字典。

接下来,我们将使用之前的 generate() 方法创建 10 张干净的图像。

列表 15.8 使用训练好的去噪 U-Net 模型生成图像

sd=torch.load('files/diffusion.pth',map_location=device)
model.load_state_dict(sd)
with torch.no_grad():
    generator = torch.manual_seed(1)                     ①
    generated_images,imgs = noise_scheduler.generate(
        model,device,
        num_inference_steps=50,
        generator=generator,
        eta=1.0,
        use_clipped_model_output=True,
        batch_size=10)                                   ②
imgnp=generated_images["sample"]    
import matplotlib.pyplot as plt
plt.figure(figsize=(10,4),dpi=300)
for i in range(10):                                      ③
    ax = plt.subplot(2,5, i + 1)
    plt.imshow(imgnp[i])
    plt.xticks([])
    plt.yticks([])
    plt.tight_layout()
plt.show()  

① 设置随机种子为 1,以便结果可重复

② 使用定义的 generate()方法创建 10 张干净的图像

③ 绘制生成的图像

我们将随机种子设置为 1。因此,如果您使用我网站上的训练模型,您将得到与图 15.6 中显示的相同的结果。我们使用之前定义的generate()方法,使用 50 次推理步骤创建 10 张干净的图像。然后我们将这 10 张图像以 2×5 的网格形式绘制出来,如图 15.6 所示。

图片

图 15.6 由训练好的去噪 U-Net 模型创建的花朵图像。

如您从图 15.6 中看到的那样,生成的花朵图像看起来很真实,类似于训练数据集中的图像。

练习 15.1

修改代码列表 15.8 并将随机种子改为 2。保持其余代码不变。重新运行代码列表,看看生成的图像是什么样子。

generate()方法还返回一个包含 50 个中间步骤中所有图像的列表 imgs。我们将使用它们来可视化去噪过程。

列表 15.9 可视化去噪过程

steps=imgs[9::10]                                ①
imgs20=[]
for j in [1,3,6,9]:
    for i in range(5):
        imgs20.append(steps[i][j])               ②
plt.figure(figsize=(10,8),dpi=300)
for i in range(20):                              ③
    k=i%5
    ax = plt.subplot(4,5, i + 1)
    plt.imshow(imgs20[i])
    plt.xticks([])
    plt.yticks([])
    plt.tight_layout()
    plt.title(f't={800-200*k}',fontsize=15,c="r")
plt.show()

① 保持时间步 800、600、400、200 和 0

② 从 10 组花朵中选择 4 组

③ 以 4×5 的网格绘制 20 张图像

列表中的 imgs 包含所有 50 个推理步骤中的 10 组图像,t = 980, 960, ...,20, 0。因此,列表中共有 500 张图像。我们选择了五个时间步(t = 800, 600, 400, 200 和 0)用于四种不同的花朵(图 15.6 中的第 2、4、7 和 10 张图像)。然后我们将这 20 张图像以 4×5 的网格形式绘制出来,如图 15.7 所示。

图片

图 15.7 训练好的去噪 U-Net 模型如何逐步将随机噪声转换为干净的鲜花图像。我们将随机噪声输入到训练好的模型中,以获得时间步 980 的图像。然后我们将 t = 980 的噪声图像输入到模型中,以获得 t = 960 的图像。我们重复这个过程 50 次推理步骤,直到我们获得 t = 0 的图像。该图的第一列显示了 t = 800 时的四个花朵;第二列显示了相同的四个花朵在 t = 600 时...;最后一列显示了 t = 0(即干净的鲜花图像)的四个花朵。

图 15.7 的第一列显示了 t = 800 时的四个花朵图像。它们接近随机噪声。第二列显示了 t = 600 时的花朵,它们开始看起来像花朵。随着我们向右移动,图像变得越来越清晰。最右边的一列显示了 t = 0 时的四个干净花朵图像。

现在您已经了解了扩散模型的工作原理,我们将讨论文本到图像的生成。文本到图像的 Transformers,如 DALL-E 2、Imagen 和 Stable Diffusion 的图像生成过程与我们在本章前面讨论的逆向扩散过程非常相似,只是在生成图像时,模型将文本嵌入作为条件信号。

15.5 文本到图像的 Transformers

如 OpenAI 的 DALL-E 2、Google 的 Imagen 和 Stability AI 的 Stable Diffusion 这样的文本到图像 Transformer 使用扩散模型从文本描述生成图像。这些文本到图像 Transformer 的一个重要组成部分是扩散模型。文本到图像生成的过程涉及将文本输入编码成一个潜在表示,然后将其用作扩散模型的条件信号。这些 Transformer 通过迭代地去除随机噪声向量来学习生成与文本描述相对应的逼真图像,这个过程由编码的文本引导。

所有这些文本到图像的 Transformer 的关键在于一个能够理解不同模态内容的模型。在这种情况下,该模型必须理解文本描述并将它们与图像以及反之联系起来。

在本节中,我们将以 OpenAI 的 CLIP 模型为例。CLIP 是 DALL-E 2 的关键组件。我们将讨论 CLIP 是如何被训练来理解文本描述和图像之间的联系的。然后,我们使用一个简短的 Python 程序,通过使用 OpenAI 的 DALL-E 2,从文本提示中生成图像。

15.5.1 CLIP:一个多模态 Transformer

近年来,计算机视觉和自然语言处理(NLP)的交叉领域取得了显著进展,其中之一是 OpenAI 创建的 CLIP 模型。这个创新模型旨在理解并解释自然语言环境中的图像,这一能力在图像生成和图像分类等众多应用中具有巨大的潜力。

CLIP 模型是一个多模态 Transformer,它弥合了视觉数据和文本数据之间的差距。它通过将图像与相应的文本描述关联起来来训练理解图像。与需要显式标记图像的传统模型不同,CLIP 使用大量图像及其自然语言描述的数据集来学习视觉概念的更一般化表示。

图片

图 15.8 OpenAI 的 CLIP 模型是如何训练的。收集了一个大规模的文本-图像对训练数据集。模型的文本编码器将文本描述压缩成 D 值文本嵌入。图像编码器将相应的图像转换成具有 D 值的图像嵌入。在训练过程中,一批 N 个文本-图像对被转换成 N 个文本嵌入和 N 个图像嵌入。CLIP 使用对比学习方法来最大化配对嵌入之间的相似性(图中对角线值的总和)同时最小化来自不匹配文本-图像对的嵌入之间的相似性(图中非对角线值的总和)。

CLIP 模型的训练,如图 15.8 所示,始于收集包含图像及其相关文本描述的大规模数据集。OpenAI 利用了多样化的来源,包括公开可用的数据集和网页爬取的数据,以确保有广泛的视觉和文本内容。然后对数据集进行预处理,以标准化图像,使它们都具有相同的形状,并对文本进行分词,为输入模型做准备。

CLIP 采用双编码器架构,包括图像编码器和文本编码器。图像编码器处理输入图像,而文本编码器处理相应的文本描述。这些编码器将图像和文本投影到一个共享的嵌入空间中,在那里它们可以被比较和对齐。

CLIP 训练的核心在于其对比学习方法。对于数据集中每个包含 N 个图像-文本对的批次,模型旨在最大化配对嵌入之间的相似性(如图 15.8 中对角线值的总和),同时最小化来自不匹配的文本-图像对嵌入之间的相似性(非对角线值的总和)。图 15.9 展示了如何基于文本提示生成逼真图像的文本到图像 Transformer,如 DALL-E 2。

图片

图 15.9 展示了如何基于文本提示创建图像的文本到图像 Transformer,如 DALL-E 2。在训练好的文本到图像 Transformer 中,文本编码器首先将提示中的文本描述转换为文本嵌入。文本嵌入被输入到 CLIP 模型中,以获得一个表示潜在空间中图像的先验向量。文本嵌入和先验被连接成一个条件向量。为了生成图像,U-Net 降噪器首先以一个随机噪声向量为输入,使用条件向量生成一个带噪声的图像。然后它以带噪声的图像和条件向量为输入,生成另一个图像,该图像噪声更少。这个过程重复多次,直到最终输出一个干净的图像。

文本到图像 Transformer 的图像生成过程与我们本章 earlier 讨论的逆向扩散过程类似。以 DALL-E 2 为例,它是由 OpenAI 研究人员在 2022 年提出的.^(4) 模型中的文本编码器首先将提示中的文本描述转换为文本嵌入。文本嵌入被输入到 CLIP 模型中,以获得一个表示潜在空间中图像的先验向量。文本嵌入和先验被连接成一个条件向量。在第一次迭代中,我们将一个随机噪声向量输入到模型中的 U-Net 降噪器,并要求它根据条件向量生成一个噪声图像。在第二次迭代中,我们将前一次迭代中的噪声图像输入到 U-Net 降噪器,并要求它根据条件向量生成另一个噪声图像。我们重复这个过程多次,最终输出是一个干净的图像。

15.5.2 使用 DALL-E 2 进行文本到图像生成

现在你已经了解了文本到图像 Transformer 的工作原理,让我们编写一个 Python 程序来与 DALL-E 2 交互,根据文本提示创建图像。

首先,你需要申请一个 OpenAI API 密钥。OpenAI 提供各种定价层,这些定价层根据处理的令牌数量和使用的模型类型而有所不同。访问chat.openai.com/auth/login并点击“注册”按钮创建账户。之后,登录你的账户,并访问platform.openai.com/api-keys以查看你的 API 密钥。将其保存在安全的地方以备后用。我们可以使用 OpenAI 的 DALL-E 2 生成图像。

列表 15.10 使用 DALL-E 2 生成图像

from openai import OpenAI

openai_api_key=your actual OpenAI API key here, in quotes   ①
client=OpenAI(api_key=openai_api_key)                       ②

response = client.images.generate(
  model="dall-e-2",
  prompt="an astronaut in a space suit riding a unicorn",
  size="512x512",
  quality="standard",
  n=1,
)                                                           ③
image_url = response.data[0].url
print(image_url)                                            ④

① 确保你在这里提供实际的 OpenAI API 密钥,并用引号括起来

② 实例化 OpenAI()类以创建代理

③ 使用 images.generate()方法根据文本提示生成图像

④ 打印出图像 URL

你应该将之前获得的 OpenAI API 密钥放置在列表 15.10 中。我们通过实例化OpenAI()类来创建一个代理。要生成图像,我们需要指定模型、文本提示和图像的大小。我们使用了“一个穿着太空服的宇航员骑独角兽”作为提示,代码列表提供了我们可视化和下载图像的 URL。该 URL 在 1 小时内过期,生成的图像如图 15.10 所示。

图片

图 15.10 DALL-E 2 根据文本提示“一个穿着太空服的宇航员骑独角兽”生成的图像

运行列表 15.10 并查看 DALLE-2 为你生成的图像。请注意,你的结果将不同,因为 DALLE-2(以及所有 LLM)的输出是随机的,而不是确定的。

练习 15.2

申请 OpenAI API 密钥。然后修改代码列表 15.10 以使用文本提示“穿着西装的猫在电脑上工作”生成图像。

在本章中,你学习了基于扩散的模型的工作原理及其在文本到图像 Transformer 中的重要性,例如 OpenAI 的 CLIP 模型。你还发现了如何获取你的 OpenAI API 密钥,并使用一个简短的 Python 脚本来生成由 DALL-E 2 创建的图像,该模型集成了 CLIP。

在下一章中,你将继续使用之前获得的 OpenAI API 密钥,使用预训练的 LLM 生成各种内容,包括文本、音频和图像。此外,你将集成 LangChain Python 库与其他 API,使你能够创建一个无所不知的个人助理。

摘要

  • 在正向扩散中,我们逐渐向干净图像添加少量随机噪声,直到它们转变为纯噪声。相反,在反向扩散中,我们从随机噪声开始,并使用降噪模型逐步从图像中消除噪声,将噪声转换回干净图像。

  • U-Net 架构最初是为生物医学图像分割设计的,它具有对称的形状,包括一个收缩的编码器路径和一个扩张的解码器路径,两者通过一个瓶颈层相连。在降噪过程中,U-Nets 被调整为去除噪声同时保留细节。跳跃连接将相同空间维度的编码器和解码器特征图连接起来,有助于保留在编码过程中可能丢失的边缘和纹理等空间信息。

  • 将注意力机制引入降噪 U-Net 模型,使其能够专注于重要特征并忽略不相关的特征。通过将图像像素视为一个序列,注意力机制学习像素依赖关系,类似于它在自然语言处理中学习标记依赖关系的方式。这增强了模型有效识别相关特征的能力。

  • 类似于 OpenAI 的 DALL-E 2、Google 的 Imagen 和 Stability AI 的 Stable Diffusion 这样的文本到图像 Transformer 使用扩散模型从文本描述中创建图像。它们将文本编码为条件扩散模型的潜在表示,然后迭代地去除由编码文本引导的随机噪声向量,以生成符合文本描述的逼真图像。


^(1)  Jascha Sohl-Dickstein, Eric A. Weiss, Niru Maheswaranathan, and Surya Ganguli, 2015, “Deep Unsupervised Learning Using Nonequilibrium Thermodynamics.” International Conference on Machine Learning, arxiv.org/abs/1503.03585.

^(2)  Sohl-Dickstein et al., 2015, “Deep Unsupervised Learning Using Nonequilibrium Thermodynamics,” https://arxiv.org/abs/1503.03585. 杨松和斯蒂法诺·埃尔蒙,2019, “通过估计数据分布的梯度进行生成建模。” arxiv.org/abs/1907.05600. 乔纳森·霍,阿贾伊·贾因和皮埃特·阿贝尔,2020, “去噪扩散概率模型,” arxiv.org/abs/2006.11239.

^(3)  伊利亚·洛希奇洛夫和弗兰克·胡特,2017, “解耦权重衰减正则化。” arxiv.org/abs/1711.05101.

^(4)  阿迪亚·拉梅斯,普拉夫拉·达里瓦尔,亚历克斯·尼科尔,凯西·丘和马克·陈,2022, “使用 CLIP 潜力的分层文本条件图像生成。” arxiv.org/abs/2204.06125.

第十六章:预训练大型

语言模型和 LangChain 库

本章涵盖

  • 使用预训练大型语言模型进行文本、图像、语音和代码生成

  • 少样本、单样本和零样本提示技术

  • 使用 LangChain 创建零样本个人助理

  • 生成式 AI 的限制和伦理问题

预训练大型语言模型(LLMs)的兴起已经改变了自然语言处理(NLP)和生成任务的领域。OpenAI 的 GPT 系列是一个显著的例子,展示了这些模型在生成逼真文本、图像、语音甚至代码方面的广泛能力。有效利用这些预训练 LLMs 对于几个原因至关重要。它使我们能够在不开发这些模型的大量资源的情况下部署高级 AI 功能。此外,理解这些 LLMs 为利用 NLP 和生成 AI 的创新应用铺平了道路,促进了各个行业的进步。

在一个日益受到 AI 影响的世界上,掌握预训练大型语言模型(LLMs)的集成和定制提供了关键竞争优势。随着 AI 的发展,利用这些复杂的模型对于在数字领域的创新和成功变得至关重要。

通常,这些模型通过基于浏览器的界面操作,这些界面在不同的 LLMs 之间有所不同,这些 LLMs 独立于彼此运行。每个模型都有其独特的优势和专长。通过浏览器进行接口限制了我们对每个特定 LLM 潜力的充分利用。利用 Python 等编程语言,尤其是通过 LangChain 库等工具,提供了以下原因的实质性好处。

Python 在交互 LLMs 中的作用增强了工作流程和过程的自动化。能够自主运行的 Python 脚本,无需手动输入即可实现不间断的运行。这对那些经常处理大量数据的业务特别有益。例如,一个 Python 脚本可以通过查询 LLM、综合数据洞察并将这些发现通过电子邮件或数据库传播来自主生成月度报告。Python 在管理 LLMs 交互方面提供了比基于浏览器的界面更高的定制和控制水平,使我们能够编写定制代码以满足特定的操作需求,如实现条件逻辑、在循环中处理多个请求或管理异常。这种适应性对于定制输出以满足特定的商业目标或研究查询至关重要。

Python 丰富的库集合使其非常适合将 LLMs 与现有软件和系统集成。LangChain 库就是这样一个典型的例子,它通过 LLMs 扩展了 Python 的功能。LangChain 允许组合多个 LLMs 或将 LLM 功能与其他服务(如 Wikipedia API 或 Wolfram Alpha API)集成,这些内容将在本章后面进行介绍。这种“链式”不同服务的能力允许构建复杂的、多步骤的 AI 系统,其中任务被分割并由最适合的模型或服务处理,从而提高性能和准确性。

因此,在本章中,你将首先学习如何使用 OpenAI API 通过 Python 编程创建各种内容:文本、图像、语音和 Python 代码。你还将了解少量示例、单次示例和零示例内容生成的区别。少量示例提示意味着你给出多个示例以帮助模型理解任务,而单次或零示例提示则意味着提供一个示例或没有示例。

现代 LLMs,如 ChatGPT,是在几个月前基于现有知识进行训练的,因此它们无法提供如天气状况、航班状态或股价等最新或实时信息。你将学习如何使用 LangChain 库将 LLMs 与 Wolfram Alpha 和 Wikipedia API 结合使用,创建一个零示例的全知个人助理。

尽管 LLMs 具有令人印象深刻的性能,但它们并不具备对内容内容的内在理解。这可能导致逻辑错误、事实不准确,以及无法掌握复杂概念或细微差别。这些模型的快速发展和广泛应用也引发了各种伦理问题,如偏见、错误信息、隐私和版权。这些问题需要仔细考虑和采取主动措施,以确保 LLMs 的开发和部署符合伦理标准和社会主义核心价值观。

16.1 使用 OpenAI API 进行内容生成

虽然还有其他 LLMs,如 Meta 的 LLAMA 和 Google 的 Gemini,但 OpenAI 的 GPT 系列是最突出的。因此,在本章中,我们将使用 OpenAI GPTs 作为示例。

OpenAI 允许你使用 LLMs 生成各种内容,如文本、图像、音频和代码。你可以通过网页浏览器或 API 访问他们的服务。由于之前提到使用 Python 与 LLMs 交互的优势,本章将重点介绍通过 API 使用 Python 程序进行内容生成。

你确实需要本章程序中的 OpenAI API 密钥才能运行。我假设你已经在第十五章中获得了你的 API 密钥。如果没有,请回到第十五章,获取获取 API 密钥的详细说明。

在本节中,我将主要关注文本生成,但将为代码、图像和语音生成的每个案例提供一个示例。

本章涉及使用几个新的 Python 库。要在您的计算机上的 Jupyter Notebook 应用的新单元格中安装它们,请运行以下代码行:

!pip install --upgrade openai langchain_openai langchain
!pip install wolframalpha langchainhub
!pip install --upgrade --quiet wikipedia

按照屏幕上的说明完成安装。

16.1.1 使用 OpenAI API 进行文本生成任务

您可以生成用于多种目的的文本,例如问答、文本摘要和创意写作。

当您向 OpenAI GPT 提问时,请记住,所有 LLM,包括 OpenAI GPT,都是通过自动化网络爬取收集的历史数据训练的。截至本文撰写时,GPT-4 使用截至 2023 年 12 月的数据进行训练,存在三个月的滞后。GPT-3.5 使用截至 2021 年 9 月的数据进行训练。

让我们先向 GPT 提问有关历史事实的问题。在新的单元格中输入以下代码行。

列表 16.1 使用 OpenAI API 检查历史事实

from openai import OpenAI

openai_api_key=put your actual OpenAI API key here, in quotes  ①
client=OpenAI(api_key=openai_api_key)                          ②

completion = client.chat.completions.create(
  model="gpt-3.5-turbo",
  messages=[
    {"role": "system", "content":                              ③
     '''You are a helpful assistant, knowledgeable about recent facts.'''},
    {"role": "user", "content": 
     '''Who won the Nobel Prize in Economics in 2000?'''}      ④
  ]
)
print(completion.choices[0].message.content)

① 提供您的 OpenAI API 密钥

② 创建一个名为 client 的 OpenAI()类实例

③ 定义系统的角色

④ 提出问题

确保在列表 16.1 中提供您的 OpenAI API 密钥。我们首先实例化OpenAI()类,并将其命名为client。在chat.completions.create()方法中,我们指定模型为gpt-3.5-turbo。网站platform.openai.com/docs/models提供了各种可用的模型。您可以使用 gpt-4 或 gpt-3.5-turbo 进行文本生成。前者提供更好的结果,但费用也更高。由于我们的示例足够简单,我们将使用后者,因为它提供同样好的结果。

前一个代码块中的messages参数由几个消息对象组成,每个对象包含一个角色(可以是“system”、“user”或“assistant”)和内容。系统消息决定了助手的行为了;如果没有系统消息,默认设置将助手描述为“一个有用的助手”。用户消息包括助手需要处理的询问或评论。例如,在先前的例子中,用户消息是“2000 年谁获得了诺贝尔经济学奖?”输出如下

The Nobel Prize in Economics in 2000 was awarded to James J. Heckman and 
Daniel L. McFadden for their work on microeconometrics and microeconomic theory. 

OpenAI 提供了正确答案。

您还可以要求 LLM 就某个特定主题撰写文章。接下来,我们要求它撰写一篇关于自我激励重要性的短文:

completion = client.chat.completions.create(
  model="gpt-3.5-turbo",
  n=1,
  messages=[
    {"role": "system", "content": 
     '''You are a helpful assistant, capable of writing essays.'''},
    {"role": "user", "content": 
     '''Write a short essay on the importance of self-motivation.'''}
  ]
)
print(completion.choices[0].message.content)

这里的n=1参数告诉助手生成一个响应。如果您想要多个响应,可以将 n 设置为不同的数字。n 的默认值是 1。输出如下

Self-motivation is a key factor in achieving success and personal growth in
 various aspects of life. It serves as the driving force behind our 
 actions, decisions, and goals, pushing us to overcome obstacles and 
 challenges along the way.

One of the primary benefits of self-motivation is that it helps individuals
 take initiative and control of their lives…

输出共有六段,我只包括了前几句话。您可以访问书籍的 GitHub 仓库(github.com/markhliu/DGAI)查看全文。如您所见,文章结构清晰,观点明确,且没有语法错误。

您甚至可以要求 OpenAI 的 GPT 为您写一个笑话:

completion = client.chat.completions.create(
  model="gpt-3.5-turbo",
  messages=[
    {"role": "system", "content": 
     '''You are a helpful assistant, capable of telling jokes.'''},
    {"role": "user", "content": 
     '''Tell me a math joke.'''}
  ]
)
print(completion.choices[0].message.content)

我们要求它讲一个数学笑话,结果是

Why was the equal sign so humble? Because he knew he wasn't less than or greater than anyone else!

您可以与助手进行双向对话。messages 参数自动包括对话历史。例如,在运行前面的代码块之后,如果您运行以下代码:

completion = client.chat.completions.create(
  model="gpt-3.5-turbo",
  messages=[
    {"role": "user", "content": 
     '''Haha, that's funny! Tell me another one.'''} 
  ]
)
print(completion.choices[0].message.content)

您将得到一个类似的响应

Sure! Why was the math book sad? Because it had too many problems.

用户的查询“哈哈,这很有趣!再给我讲一个。”只有在您要求助手讲一个数学笑话的前言消息的上下文中才有意义。其他文本生成能力包括文本摘要和分类,您将在本章后面看到这样的例子。

16.1.2 使用 OpenAI API 生成代码

Codex 专门设计用于理解和生成代码。它可以与多种编程语言一起工作,并将自然语言描述转换为代码。Codex 现在是 OpenAI GPTs 的集成部分。

接下来,我们要求 OpenAI GPT 生成一个 Python 程序来绘制正弦曲线:

completion = client.chat.completions.create(
  model="gpt-3.5-turbo",
  messages=[
    {"role": "system", "content": 
     '''You are a helpful assistant, 
     capable of generating Python programs.'''},
    {"role": "user", "content": 
     '''Write a Python program to plot a sine graph.'''}
  ]
)
print(completion.choices[0].message.content)

注意,ChatGPT 使用 GPT-3.5-Turbo 有效地管理对话方面和代码生成任务。它不只为代码生成提供单独的 Codex 模型。输出如下

Sure! Here's a simple Python program using the matplotlib library to plot 
a sine graph:

```python

import matplotlib.pyplot as plt

import numpy as np

# 从 0 到 2*pi 生成 x 值

x = np.linspace(0, 2*np.pi, 100)

# 使用正弦函数计算 y 值

y = np.sin(x)

# 绘制正弦图

plt.figure()

plt.plot(x, y)

plt.title('正弦图')

plt.xlabel('x')

plt.ylabel('sin(x)')

plt.grid(True)

plt.show()

```py

You can run this program in a Python environment with the matplotlib 
library installed to visualize the sine graph.

如果您将生成的 Python 程序复制并粘贴到 Jupyter Notebook 中的一个单元中并运行它,您将看到一个类似于图 16.1 的图像。

图 16.1 使用 OpenAI GPT-3.5 生成 Python 代码绘制正弦曲线。我们使用文本描述“编写一个 Python 程序绘制正弦图”来请求它生成一个 Python 程序。然后我们运行程序来创建图表。

LLM 不仅提供了 Python 代码,还让您知道您需要在安装了 matplotlib 库的 Python 环境中运行代码。

16.1.3 使用 OpenAI DALL-E 2 生成图像

DALL-E 2 是由 OpenAI 开发的一个 AI 模型,旨在根据文本描述生成图像。它是原始 DALL-E 模型的继承者,代表了在视觉内容生成 AI 领域的进步。

DALL-E 2 使用了一种类似于我们在第十五章讨论的扩散模型,它从随机的像素模式开始,并逐渐将其细化成一个与输入文本相匹配的连贯图像。它通过产生更高质量、更准确和更详细的文本描述图像来改进了原始的 DALL-E。

将 DALL-E 2 集成到 OpenAI 的 GPT 系列中,使我们不仅能够生成文本,还能根据文本提示创建图像。接下来,我们要求 DALL-E 2 创建一个河岸上钓鱼的人的图像:

response = client.images.generate(
  model="dall-e-2",
  prompt="someone fishing at the river bank",
  size="512x512",
  quality="standard",
  n=1,
)
image_url = response.data[0].url
print(image_url)

代码块生成一个 URL。如果您点击该 URL,您将看到一个类似于图 16.2 的图像。

图 16.2 使用文本提示“有人在河岸钓鱼”生成的 DALL-E 2 图像

URL 在一小时后过期,所以请确保你及时访问它。此外,即使使用相同的文本提示,DALL-E 2 生成的图像也可能略有不同,因为输出是随机生成的。

16.1.4 使用 OpenAI API 进行语音生成

文本到语音(TTS)是一种将书面文本转换为语音的技术。TTS 通过多模态 Transformer 进行训练,其中输入是文本,输出是音频格式。在 ChatGPT 的背景下,集成 TTS 功能意味着 LLM 不仅能够生成文本响应,还可以大声说出这些响应。接下来,我们请求 OpenAI API 将一段简短文本转换为语音:

response = client.audio.speech.create(
  model="tts-1-hd",
  voice="shimmer",
  input='''This is an audio file generated by 
    OpenAI's text to speech AI model.'''
)
response.stream_to_file("files/speech.mp3")

运行前面的代码单元后,会在你的电脑上保存一个名为 speech.mp3 的文件,你可以听一下。文档网站(platform.openai.com/docs/guides/text-to-speech)提供了语音选项。这里我们选择了 shimmer 选项。其他选项包括 alloyecho 等等。

16.2 LangChain 简介

LangChain 是一个 Python 库,旨在简化 LLM 在各种应用中的使用。它提供了一套工具和抽象,使得构建、部署和管理由 LLM(如 GPT-3、GPT-4 以及其他类似模型)驱动的应用程序变得更加容易。

LangChain 抽象掉了与不同 LLM 和应用交互的复杂性,使得开发者可以专注于构建他们的应用程序逻辑,而无需担心底层模型的具体细节。它特别适合通过将 LLM 与如 Wolfram Alpha 和 Wikipedia 这样的应用链接起来,构建一个“全知全能”的代理,这些应用可以提供实时信息或最近的事实。LangChain 的模块化架构允许轻松集成不同的组件,使代理能够利用各种 LLM 和应用的优势。

16.2.1 LangChain 库的需求

想象一下,你的目标是构建一个零样本的全知全能代理,使其能够生成各种内容,检索实时信息,并为我们回答事实性问题。你希望代理能够根据当前任务自动前往正确的来源检索相关信息,而无需明确告诉它要做什么。LangChain 库正是这个任务的正确工具。

在这个项目中,你将学习如何使用 LangChain 库将 LLM 与 Wolfram Alpha 和 Wikipedia API 结合起来,创建一个零样本的全知全能代理。我们使用 Wolfram Alpha API 检索实时信息,使用 Wikipedia API 回答关于最近事实的问题。LangChain 允许我们创建一个代理,利用多个工具来回答问题。代理首先理解查询,然后决定在工具箱中使用哪个工具来回答问题。

为了展示即使是功能最先进的 LLMs 也缺乏这些能力,让我们来问一下 2024 年奥斯卡最佳男主角奖的获得者是谁:

completion = client.chat.completions.create(
  model="gpt-4",
  messages=[
    {"role": "system", "content": 
     '''You are a helpful assistant, knowledgeable about recent facts.'''},
    {"role": "user", "content": 
     '''Who won the Best Actor Award in 2024 Academy Awards?'''}
  ]
)
print(completion.choices[0].message.content)

输出是

I'm sorry, but I cannot provide real-time information or make predictions 
about future events such as the 2024 Academy Awards. For the most accurate 
and up-to-date information, I recommend checking reliable sources or news 
outlets closer to the date of the awards show.

我在 2024 年 3 月 17 日提出了这个查询,GPT-4 无法回答这个问题。有可能当你提出相同的查询时,你会得到正确的答案,因为模型已经使用更近期的数据进行更新。如果那样的话,将问题改为几天前的事件,你应该会得到类似的回答。

因此,我们将使用 LangChain 将 LLM 与 Wolfram Alpha 和 Wikipedia API 链在一起。Wolfram Alpha 擅长科学计算和检索实时信息,而 Wikipedia 则以提供历史和近期事件及事实的信息而闻名。

16.2.2 在 LangChain 中使用 OpenAI API

本章早期安装的 langchain-openai 库允许你以最少的提示工程使用 OpenAI GPTs。你只需要用普通的英语解释你希望 LLM 做什么。

这里是一个例子,展示我们如何要求它纠正文本中的语法错误:

from langchain_openai import OpenAI

llm = OpenAI(openai_api_key=openai_api_key)

prompt = """
Correct the grammar errors in the text:

i had went to stor buy phone. No good. returned get new phone.
"""

res=llm.invoke(prompt)
print(res)

输出是

I went to the store to buy a phone, but it was no good. I returned it and 
got a new phone.

注意,我们没有使用任何提示工程。我们也没有指定使用哪个模型。LangChain 根据任务要求和其他因素(如成本、延迟和性能)找到了最适合的模型。它还自动格式化和结构化查询,使其适合使用的模型。前面的提示只是简单地要求代理用普通的英语纠正文本中的语法错误。它返回了语法正确的文本,如前一个输出所示。

这里是另一个例子。我们要求代理说出肯塔基州的首府:

prompt = """
What is the capital city of the state of Kentucky?
"""
res=llm.invoke(prompt)
print(res)

输出是

The capital city of Kentucky is Frankfort.

它告诉我们正确的答案,即肯塔基州的法兰克福。

16.2.3 零样本、单样本和少样本提示

少样本、单样本和零样本提示指的是向 LLMs 提供示例或指令的不同方式,以指导它们的响应。这些技术用于帮助模型理解当前的任务,并生成更准确或相关的输出。

在零样本提示中,模型被赋予一个任务或问题,但没有提供任何示例。提示通常包括对预期的明确描述,但模型必须仅基于其先验知识和理解来生成响应。在单样本提示中,模型提供了一个示例来展示任务。在少样本提示中,模型被提供了多个示例以帮助它理解任务。少样本提示基于这样的理念:提供更多示例可以帮助模型更好地掌握任务的模式或规则,从而产生更准确的响应。

到目前为止,你与 OpenAI GPTs 的所有交互都是零样本提示,因为你没有向它们提供任何示例。

让我们尝试一个少样本提示的例子。假设你想让 LLM 进行情感分析:你希望它将句子归类为正面或负面。你可以在提示中提供几个示例:

prompt = """
The movie is awesome! // Positive
It is so bad! // Negative
Wow, the movie was incredible! // Positive
How horrible the movie is! //
"""
res=llm.invoke(prompt)
print(res)

输出是

Negative

在提示中,我们提供了三个示例。其中两个评论被归类为正面,而另一个被归类为负面。然后我们提供了句子,“这部电影多么糟糕!”LLM 正确将其归类为负面。

在上一个例子中,我们使用 // 来分隔句子和相应的情感。你可以使用其他分隔符,如 ->,只要保持一致即可。

下面是一个单样本提示的例子:

prompt = """
Car -> Driver
Plane ->
"""
res=llm.invoke(prompt)
print(res)

输出是

Pilot

通过提供一个单一示例,我们实际上是在询问 LLM,“飞机对驾驶员来说就像汽车对驾驶员一样?”LLM 正确回答了“飞行员”。

练习 16.1

假设你想问 LLM,“花园对厨师来说就像厨房对厨师一样?”使用单样本提示来获取答案。

最后,这里是一个零样本提示的例子:

prompt = """
Is the tone in the sentence "Today is a great day for me" positive, 
negative, or neutral?
"""
res=llm.invoke(prompt)
print(res)

输出是

Positive

我们在提示中没有提供任何示例。然而,我们用简单的英语指令要求 LLM 将句子的语气归类为正面、负面或中性。

16.3 LangChain 中的零样本全知全能代理

在本节中,你将学习如何在 LangChain 中创建一个零样本的全知全能代理。你将使用 OpenAI GPTs 生成各种内容,如文本、图像和代码。为了弥补 LLM 无法提供实时信息的能力,你将学习如何将 Wolfram Alpha 和 Wikipedia API 添加到工具箱中。

Wolfram Alpha 是一个计算知识引擎,旨在在线处理事实查询,特别擅长处理数值和计算任务,尤其是在科学和技术领域。通过集成 Wolfram Alpha API,代理获得了回答各种主题的几乎所有问题的能力。如果 Wolfram Alpha 无法提供响应,我们将使用 Wikipedia 作为特定主题基于事实问题的次要来源。

图 16.3 是我们将在本节中创建零样本全知全能代理所采取步骤的示意图。

图 16.3 使用 LangChain 库创建零样本全知全能代理的步骤

具体来说,我们首先将在 LangChain 中创建一个仅使用一个工具——Wolfram Alpha API 的代理来回答与实时信息和最新事实相关的问题。然后,我们将 Wikipedia API 添加到工具箱中,作为关于最新事实问题的备用。我们将添加各种工具,利用 OpenAI API,如文本摘要器、笑话讲述者和情感分类器。最后,我们将添加图像和代码生成功能。

16.3.1 申请 Wolfram Alpha API 密钥

Wolfram Alpha 每月免费提供高达 2,000 次非商业 API 调用。要获取 API 密钥,请首先访问 account.wolfram.com/login/create/ 并完成创建账户的步骤。

Wolfram 账户本身只提供浏览器访问;您需要在 products.wolframalpha.com/api/ 申请 API 密钥。一旦到达那里,点击左下角的“获取 API 访问”。应该会弹出一个小的对话框,填写名称和描述字段,从下拉菜单中选择“简单 API”,然后点击提交,如图 16.4 所示。

图 16.4 申请 Wolfram Alpha AppID

之后,你的 AppID 应该会出现在一个新窗口中。复制 API 密钥并将其保存到文件中以便以后使用。

这是您如何使用 Wolfram Alpha API 进行数学运算的方法:

import os

os.environ['WOLFRAM_ALPHA_APPID'] = "your Wolfram Alpha AppID" 

from langchain_community.utilities.wolfram_alpha import \
WolframAlphaAPIWrapper
wolfram = WolframAlphaAPIWrapper()
res=wolfram.run("how much is 23*55+123?")
print(res)

输出是

Assumption: 23×55 + 123 
Answer: 1388

Wolfram Alpha API 提供正确答案。

我们还将包括维基百科 API,以提供对各种主题的答案。如果你已经在你的计算机上安装了维基百科库,你不需要申请 API 密钥。以下是 LangChain 库中使用维基百科 API 的示例:

from langchain.tools import WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper

wikipedia = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper())
res=wikipedia.run("University of Kentucky")
print(res)

输出是

Page: University of Kentucky
Summary: The University of Kentucky (UK, UKY, or U of K) is a public 
land-grant research university in Lexington, Kentucky. Founded in 1865 by 
John Bryan Bowman as the Agricultural and Mechanical College of Kentucky, 
the university is one of the state's two land-grant universities (the 
other being Kentucky State University)… 

为了简洁,我们省略了大部分输出内容。

16.3.2 在 LangChain 中创建代理

接下来,我们将在 LangChain 中创建一个代理,工具箱中只有 Wolfram Alpha API。在这个上下文中,代理是指通过自然语言交互处理特定任务或过程的单个实体。然后我们将逐步添加更多工具,使代理能够处理更多任务。

列表 16.2 在 LangChain 中创建代理

os.environ['OPENAI_API_KEY'] = openai_api_key 
from langchain.agents import load_tools
from langchain_openai import ChatOpenAI
from langchain import hub
from langchain.agents import AgentExecutor, create_react_agent
from langchain_openai import OpenAI

prompt = hub.pull("hwchase17/react")
llm = ChatOpenAI(model_name='gpt-3.5-turbo')              ①
tool_names = ["wolfram-alpha"]
tools = load_tools(tool_names,llm=llm)                    ②
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools,
       handle_parsing_errors=True,verbose=True)           ③

res=agent_executor.invoke({"input": """
What is the temperature in Lexington, Kentucky now?
"""})                                                     ④
print(res["output"])

① 定义要使用的 LLM

② 将 Wolfram Alpha 添加到工具箱

③ 定义代理

④ 向代理提问

LangChain 中的 hwchase17/react 指的是一种特定的 ReAct 代理配置。ReAct 代表反应性动作,它是 LangChain 内的一个框架,旨在优化语言模型能力与其他工具结合以有效解决复杂任务。有关更多详细信息,请参阅 python.langchain.com/docs/how_to/migrate_agent/。在 LangChain 中创建代理时,您需要指定代理要使用的工具。在先前的示例中,我们只使用了一个工具,即 Wolfram Alpha API。

例如,我们询问肯塔基州列克星敦的当前温度,以下是输出结果:

> Entering new AgentExecutor chain...
I should use Wolfram Alpha to find the current temperature in Lexington, 
Kentucky.
Action: wolfram_alpha
Action Input: temperature in Lexington, KentuckyAssumption: temperature | 
Lexington, Kentucky 
Answer: 44 °F (wind chill: 41 °F)
(27 minutes ago)I now know the current temperature in Lexington, Kentucky.
Final Answer: The temperature in Lexington, Kentucky is 44 °F with a wind 
chill of 41 °F.

> Finished chain.
The temperature in Lexington, Kentucky is 44 °F with a wind chill of 41 °F.

输出不仅显示了最终答案,即肯塔基州列克星敦的当前温度为 44 华氏度,而且还显示了思维链。它使用 Wolfram Alpha 作为获取答案的来源。

我们还可以将维基百科添加到工具箱中:

tool_names += ["wikipedia"]
tools = load_tools(tool_names,llm=llm)
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools,
       handle_parsing_errors=True,verbose=True)

res=agent_executor.invoke({"input": """
Who won the Best Actor Award in 2024 Academy Awards?
"""})
print(res["output"])

我询问谁赢得了 2024 年奥斯卡最佳男主角奖,代理使用维基百科获取正确答案:

I need to find information about the winner of the Best Actor Award at the 
2024 Academy Awards.
Action: wikipedia
Action Input: 2024 Academy Awards Best Actor
…
Cillian Murphy won the Best Actor Award at the 2024 Academy Awards for his 
performance in Oppenheimer.

在前面的输出中,代理首先决定使用维基百科作为解决问题的工具。在搜索了各种维基百科来源后,代理提供了正确答案。

接下来,你将学习如何将各种 OpenAI GPT 工具添加到代理的工具箱中。

16.3.3 使用 OpenAI GPTs 添加工具

我们首先添加一个文本摘要器,以便代理可以总结文本。

列表 16.3 向代理的工具箱添加文本摘要器

from langchain.agents import Tool
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

temp = PromptTemplate(input_variables=["text"],               ①
template="Write a one sentence summary of the following text: {text}")

summarizer = LLMChain(llm=llm, prompt=temp)                   ②

sum_tool = Tool.from_function(
    func=summarizer.run,
    name="Text Summarizer",
    description="A tool for summarizing texts")               ③
tools+=[sum_tool]
agent = create_react_agent(llm, tools, prompt)                ④
agent_executor = AgentExecutor(agent=agent, tools=tools,
       handle_parsing_errors=True,verbose=True)
res=agent_executor.invoke({"input": 
'''Write a one sentence summary of the following text:
The University of Kentucky's Master of Science
 in Finance (MSF) degree prepares students for
 a professional career in the finance and banking
 industries. The program is designed to provide
 rigorous and focused training in finance, 
 broaden opportunities in your career, and 
 sharpened skills for the fast-changing 
 and competitive world of modern finance.'''})
print(res["output"])

① 定义一个模板

② 定义一个摘要器函数

③ 将摘要器作为工具添加

④ 使用更新后的工具箱重新定义代理

我们首先提供一个模板来总结文本。然后定义一个摘要器函数并将其添加到工具箱中。最后,我们通过使用更新后的工具箱重新定义代理,并要求它用一句话总结示例文本。确保你的提示与模板中描述的格式相同,这样代理就知道使用哪个工具。

列表 16.3 的输出是

> Entering new AgentExecutor chain...
I need to summarize the text provided.
Action: Summarizer
…
> Finished chain.
The University of Kentucky's MSF program offers specialized training in 
finance to prepare students for successful careers in the finance and 
banking industries.

由于输入与摘要器函数中描述的模板相匹配,代理选择摘要器作为任务的工具。我们使用两个长句作为文本输入,前面的输出是一个句子摘要。

你可以添加你喜欢的任何工具。例如,你可以添加一个工具来讲述某个主题的笑话:

temp = PromptTemplate(input_variables=["text"],
template="Tell a joke on the following subject: {subject}")

joke_teller = LLMChain(llm=llm, prompt=temp)

tools+=[Tool.from_function(name='Joke Teller',
       func=joke_teller.run,
       description='A tool for telling jokes')]
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools,
       handle_parsing_errors=True,verbose=True)

res=agent_executor.invoke({"input": 
'''Tell a joke on the following subject: coding'''})
print(res["output"])

输出是

> Entering new AgentExecutor chain...
I should use the Joke Teller tool to find a coding-related joke.
Action: Joke Teller
Action Input: coding
Observation: Why was the JavaScript developer sad?

Because he didn't know how to "null" his feelings.
Thought:That joke was funny!
Final Answer: Why was the JavaScript developer sad? Because he didn't know 
how to "null" his feelings.

> Finished chain.
Why was the JavaScript developer sad? Because he didn't know how to "null" 
his feelings.

我们要求代理就编程主题讲一个笑话。代理识别出笑话讲述者是工具。这个笑话确实与编程相关。

练习 16.2

向代理的工具箱添加一个进行情感分析的工具。命名为情感分类器。然后要求代理将文本“这部电影一般般”分类为正面、负面或中性。

16.3.4 添加生成代码和图像的工具

你可以将各种工具添加到 LangChain 的工具箱中。感兴趣的读者可以在python.langchain.com/docs/how_to/#tools找到更多详细信息。接下来,我们添加生成其他内容形式(如代码和图像)的工具。

要添加一个生成代码的工具,你可以这样做:

temp = PromptTemplate(input_variables=["text"],
template='''Write a Python program based on the 
    description in the following text: {text}''')

code_generator = LLMChain(llm=llm, prompt=temp)

tools+=[Tool.from_function(name='Code Generator',
       func=code_generator.run,
       description='A tool to generate code')]
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools,
       handle_parsing_errors=True,verbose=True)

res=agent_executor.invoke({"input": 
'''Write a Python program based on the 
    description in the following text: 
write a python program to plot a sine curve and a cosine curve
in the same graph. The sine curve is in solid line and the cosine
curve is in dashed line. Add a legend to the graph. Set the x-axis 
range to -5 to 5\. The title should be "Comparing sine and cosine curves."
'''})
print(res["output"])

输出是

> Entering new AgentExecutor chain...
I should use the Code Generator tool to generate the Python program based on the given description.
Action: Code Generator
Action Input: Write a Python program to plot a sine curve and a cosine 
curve in the same graph. The sine curve is in solid line and the cosine 
curve is in dashed line. Add a legend to the graph. Set the x-axis range 
to -5 to 5\. The title should be "Comparing sine and cosine curves."
Observation: import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-5, 5, 100)
y1 = np.sin(x)
y2 = np.cos(x)

plt.plot(x, y1, label='Sine Curve', linestyle='solid')
plt.plot(x, y2, label='Cosine Curve', linestyle='dashed')
plt.legend()
plt.title('Comparing Sine and Cosine Curves')
plt.xlim(-5, 5)
plt.show()
Thought:The Python program has been successfully generated to plot the sine
 and cosine curves. I now know the final answer.

Final Answer: The Python program to plot a sine curve and a cosine curve in
 the same graph with the specified requirements has been generated.

> Finished chain.
The Python program to plot a sine curve and a cosine curve in the same 
graph with the specified requirements has been generated.

如果你在一个单元格中运行生成的代码,你将看到如图 16.5 所示的图像。

图 16.5 在 LangChain 中添加一个生成 Python 代码的工具。该工具随后生成代码,在同一图表中绘制正弦和余弦曲线,并带有图例和线条样式。

要添加一个图像生成器,你可以这样做:

from langchain_community.utilities.dalle_image_generator import DallEAPIWrapper
temp = PromptTemplate(input_variables=["text"],
template="Create an image base on the following text: {text}")

grapher = LLMChain(llm=llm, prompt=temp)

tools+=[Tool.from_function(name='Text to image',
       func=grapher.run,
       description='A tool for text to image')]
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools,
       handle_parsing_errors=True,verbose=True)
image_url = DallEAPIWrapper().run(agent_executor.invoke({"input": 
'''Create an image base on the following text: 
    a horse grazes on the grassland.'''})["output"])
print(image_url)

输出是一个 URL,你可以通过它来可视化并下载图像。我们要求代理创建一幅马在草原上吃草的图像。该图像如图 16.6 所示。

图 16.6 LangChain 中一个全知全能代理生成的图像

通过这样,你已经学会了如何在 LangChain 中创建一个零样本的全知全能代理。你可以根据你想让代理完成的事情添加更多工具。

16.4 LLMs 的限制和伦理问题

LLMs(大型语言模型)如 OpenAI 的 GPT 系列在自然语言处理和生成式 AI 领域取得了显著的进步。尽管这些模型拥有令人印象深刻的性能,但它们并非没有局限性。理解这些限制对于发挥它们的优点和减轻它们的弱点至关重要。

同时,这些模型的快速进步和广泛应用也引发了一系列伦理问题,如偏见、不准确、侵犯隐私和版权侵权。这些问题需要仔细考虑并采取主动措施,以确保 LLMs 的开发和部署符合伦理标准和社会主义核心价值观。

在本节中,我们将探讨 LLMs 的限制,分析为什么这些问题持续存在,并举例说明一些显著的失败案例,以强调解决这些挑战的重要性。我们还将检查与 LLMs 相关的关键伦理问题,并提出缓解这些问题的途径。

16.4.1 LLMs 的限制

LLMs 的一个基本局限性是它们缺乏真正的理解和推理能力。虽然它们可以生成连贯且与上下文相关的回答,但它们并不具备对内容的内在理解。这可能导致逻辑错误、事实不准确,以及无法掌握复杂概念或细微差别。

这在 LLMs 犯下的许多史诗级错误中有所体现。《Smart Until It’s Dumb》这本书提供了许多 GPT-3 和 ChatGPT 犯下的此类错误的有趣实例。1 例如,考虑以下问题:March 夫人给了母亲茶和粥,同时她温柔地给小宝宝穿衣,就像它是她自己的孩子一样。谁是这个婴儿的母亲?GPT-3 给出的答案是 March 夫人。

公平地说,随着 LLMs 的快速进步,许多这些错误随着时间的推移得到了纠正。然而,LLMs 仍然会犯低级错误。2023 年 6 月 David Johnston 在 LinkedIn 上的一篇文章(www.linkedin.com/pulse/intelligence-tests-llms-fail-why-david-johnston/)测试了 LLMs 在人类容易解决的十二个问题上的智力。包括 GPT-4 在内的 LLMs 在这些问题上都遇到了困难。其中一个问题是:说出一个动物的名字,其单词长度等于它们腿的数量减去它们尾巴的数量。

-david-johnston/](https://www.linkedin.com/pulse/intelligence-tests-llms-fail-why-david-johnston/))测试了 LLMs 在人类容易解决的十二个问题上的智力。包括 GPT-4 在内的 LLMs 在这些问题上都遇到了困难。其中一个问题是:说出一个动物的名字,其单词长度等于它们腿的数量减去它们尾巴的数量。

到目前为止,这个错误还没有被纠正。图 16.7 是我使用浏览器界面时 GPT-4 给出的答案的截图。

图 16.7 GPT-4 仍然犯的低级错误

图 16.7 的输出显示,根据 GPT-4,数字五等于单词“bee”中的字母数量。

16.4.2 LLMs 的伦理问题

最紧迫的伦理问题之一是 LLMs 可能持续和放大其训练数据中的偏见。由于这些模型从通常来源于人类生成内容的大量数据集中学习,它们可以继承与性别、种族、民族和其他社会因素相关的偏见。这可能导致带有偏见的输出,从而强化刻板印象和歧视。

为了减轻偏见,采用多元包容的训练数据集、实施偏见检测和纠正算法,以及在模型开发和评估中确保透明度是至关重要的。尤其重要的是建立行业范围内的合作,以制定偏见减轻实践的标准,并促进负责任的 AI 发展。

然而,我们必须记住不要过度纠正。一个反例是,谷歌的 Gemini 通过在纳粹时代德国士兵等群体中包含有色人种,过度纠正了图像生成中的刻板印象.^(2)

对于 LLM 的另一个担忧是它们潜在的虚假信息和操纵能力。LLM 能够生成逼真且具有说服力的文本,这可能被用于制造和传播虚假信息、宣传或操纵性内容,这对公共讨论、民主和信息信任构成重大风险。

解决这一担忧的方案在于开发强大的内容审查系统。制定负责任使用的指南,以及促进 AI 开发者、政策制定者和媒体组织之间的合作,是打击虚假信息的关键步骤。

第三个担忧与隐私相关。用于训练 LLM 的大量数据引发了隐私担忧,因为敏感信息可能会在模型输出中无意中被泄露。此外,LLM 被用于网络攻击或绕过安全措施的可能性也带来了重大的安全风险。

此外,用于训练大型语言模型(LLM)的数据大多是在未经授权的情况下收集的。支持者认为,用于训练 LLM 的数据使用方式具有变革性:模型不仅仅重复数据,而是用它来生成新的、原创的内容。这种变革可能符合“合理使用”原则,该原则允许在不获得许可的情况下有限地使用受版权保护的材料,如果使用增加了新的表达或意义。批评者认为,LLM 是在未经许可的情况下使用大量受版权保护的文本进行训练的,这超出了可能被认为是合理使用的范围。所使用的数据规模和训练过程中未经转换直接摄取受版权保护的材料可能被视为侵权。这场辩论仍在继续。现行的版权法并非针对生成式 AI 设计的,导致了对 LLM 等技术如何适用这些法律的模糊性。这可能是需要由立法和司法机构来解决,以提供明确的指导并确保所有方的利益得到公平代表。

围绕 LLMs 的伦理问题多方面且需要全面的方法。AI 研究人员、开发者和政策制定者之间的协作对于制定伦理准则和框架至关重要,这些准则和框架指导着这些强大模型的负责任开发和部署。随着我们继续利用 LLMs 的潜力,伦理考量必须始终是我们努力的重点,以确保人工智能的进步与社会的价值观和人类的福祉和谐一致。

摘要

  • 少样本提示意味着你给出多个示例以帮助 LLMs 理解任务,而单样本或零样本提示则意味着提供一个或没有示例。

  • LangChain 是一个旨在简化在各种应用中使用大型语言模型(LLMs)的 Python 库。它抽象化了与不同 LLMs 和应用交互的复杂性。它允许代理根据当前任务自动选择工具箱中的正确工具,而无需明确告知它要做什么。

  • 现代预训练的 LLMs,如 OpenAI 的 GPT 系列,可以创建各种格式的内蓉,如文本、图像、音频和代码。

  • 尽管它们取得了令人印象深刻的成就,但 LLMs 缺乏对内容内容的真正理解或推理能力。这些局限性可能导致逻辑错误、事实不准确,以及无法掌握复杂概念或细微差别。此外,这些模型的快速进步和广泛应用引发了一系列伦理问题,如偏见、错误信息、隐私侵犯和版权侵权。这些问题需要仔细考虑和主动措施,以确保 LLMs 的开发和部署与伦理标准和社会价值观相一致。


^(1)  Maggiori, Emmanuel,2023 年,Smart Until It’s Dumb: Why Artificial Intelligence Keeps Making Epic Mistakes (and Why the AI Bubble Will Burst),Applied Maths Ltd. 电子书版。

^(2)  Adi Robertson,2024 年 2 月 21 日,“Google 因 Gemini 生成的种族多样性纳粹而道歉。”The Verge,mng.bz/2ga9

附录 A. 安装 Python、Jupyter Notebook 和 PyTorch

在您的计算机上安装 Python 和管理库和包的多种方式存在。本书使用 Anaconda,这是一个开源的 Python 发行版、包管理器和环境管理工具。Anaconda 因其用户友好性和能够轻松安装大量库和包而脱颖而出,否则这些库和包的安装可能会很痛苦,甚至根本不可能安装。

具体来说,Anaconda 允许用户通过“conda install”和“pip install”两种方式安装包,从而扩大了可用资源的范围。本附录将指导您为本书中的所有项目创建一个专门的 Python 虚拟环境。这种分割确保了本书中使用的库和包与在其他无关项目中使用的任何库保持隔离,从而消除了任何潜在干扰。

我们将使用 Jupyter Notebook 作为我们的集成开发环境(IDE)。我将指导您在您刚刚创建的 Python 虚拟环境中安装 Jupyter Notebook。最后,我将根据您的计算机是否配备了支持 CUDA 的 GPU,引导您安装 PyTorch、Torchvision 和 Torchaudio。

A.1 安装 Python 和设置虚拟环境

在本节中,我将根据您的操作系统引导您在计算机上安装 Anaconda 的过程。之后,您将为本书中的所有项目创建一个 Python 虚拟环境。最后,您将安装 Jupyter Notebook 作为您的 IDE 来运行本书中的 Python 程序。

A.1.1 安装 Anaconda

要通过 Anaconda 发行版安装 Python,请按照以下步骤操作。

首先,访问 www.anaconda.com/download/success 并滚动到网页底部。找到并下载针对您特定操作系统(无论是 Windows、macOS 还是 Linux)的最新 Python 3 版本。

如果您使用的是 Windows,请从该链接下载最新的 Python 3 图形安装程序。点击安装程序并按照提供的说明进行安装。要确认 Anaconda 在您的计算机上已成功安装,您可以在计算机上搜索“Anaconda Navigator”应用程序。如果您可以启动该应用程序,则表示 Anaconda 已成功安装。

对于 macOS 用户,建议使用最新的 Python 3 图形安装程序,尽管也提供了命令行安装选项。运行安装程序并遵循提供的说明。通过在您的计算机上搜索“Anaconda Navigator”应用程序来验证 Anaconda 的成功安装。如果您可以启动该应用程序,则表示 Anaconda 已成功安装。

Linux 的安装过程比其他操作系统更复杂,因为没有图形安装程序。首先,确定最新的 Linux 版本。选择适当的 x86 或 Power8 和 Power9 包。点击下载最新的安装器 bash 脚本。默认情况下,安装器 bash 脚本通常保存在您的计算机的下载文件夹中。在终端中执行 bash 脚本来安装 Anaconda。安装完成后,通过运行以下命令来激活它:

source ~/.bashrc

要访问 Anaconda Navigator,请在终端中输入以下命令:

anaconda-navigator

如果您能成功在 Linux 系统上启动 Anaconda Navigator,则您的 Anaconda 安装已完成。

练习 A.1

根据您的操作系统在您的计算机上安装 Anaconda。安装完成后,打开计算机上的 Anaconda Navigator 应用程序以确认安装。

A.1.2 设置 Python 虚拟环境

非常推荐您为本书创建一个单独的虚拟环境。让我们将其命名为 dgai。在 Anaconda 命令提示符(Windows)或终端(Mac 和 Linux)中执行以下命令:

conda create -n dgai

按下键盘上的 Enter 键后,按照屏幕上的说明操作,当提示 y/n 时按 y。要激活虚拟环境,请在相同的 Anaconda 命令提示符(Windows)或终端(Mac 和 Linux)中运行以下命令:

conda activate dgai

虚拟环境将您为本书使用的 Python 包和库与其他用途的包和库隔离开来。这防止了任何不希望发生的干扰。

练习 A.2

在您的计算机上创建一个 Python 虚拟环境 dgai。安装完成后,激活该虚拟环境。

A.1.3 安装 Jupyter Notebook

现在,让我们在您计算机上新建的虚拟环境中安装 Jupyter Notebook。

首先,在 Anaconda 命令提示符(Windows)或终端(Mac 或 Linux)中运行以下代码行以激活虚拟环境:

conda activate dgai

在虚拟环境中安装 Jupyter Notebook,运行以下命令

conda install notebook

按照屏幕上的说明完成安装。

要启动 Jupyter Notebook,请在终端中执行以下命令:

jupyter notebook

Jupyter Notebook 应用程序将在您的默认浏览器中打开。

练习 A.3

在 Python 虚拟环境 dgai 中安装 Jupyter Notebook。安装完成后,在您的计算机上打开 Jupyter Notebook 应用程序以确认安装。

A.2 安装 PyTorch

在本节中,我将根据您的计算机上是否有启用 CUDA 的 GPU 来指导您安装 PyTorch。官方 PyTorch 网站 pytorch.org/get-started/locally/ 提供了有关带有或没有 CUDA 的 PyTorch 安装的更新。我鼓励您查看网站以获取任何更新。

CUDA 仅在 Windows 或 Linux 上可用,不在 Mac 上。要找出你的电脑是否配备了 CUDA 支持的 GPU,打开 Windows PowerShell(在 Windows 中)或终端(在 Linux 中)并输入以下命令:

nvidia-smi

如果你的电脑有 CUDA 支持的 GPU,你应该会看到一个类似于图 A.1 的输出。此外,注意图右上角所示的 CUDA 版本,因为你在安装 PyTorch 时需要这个信息。图 A.1 显示我的电脑上的 CUDA 版本是 11.8。你的电脑上的版本可能不同。

图 A.1 检查你的电脑是否具有 CUDA 支持的 GPU

如果你运行nvidia-smi命令后看到错误信息,说明你的电脑没有 CUDA 支持的 GPU。

在第一小节中,我将讨论如果你电脑上没有 CUDA 支持的 GPU,如何安装 PyTorch。你可以使用 CPU 来训练这本书中所有的生成式 AI 模型。这只需要更长的时间。然而,我会提供预训练的模型,这样你就可以见证生成式 AI 的实际应用。

另一方面,如果你使用 Windows 或 Linux 操作系统,并且电脑上确实有 CUDA 支持的 GPU,我将在下一小节中指导你安装带有 CUDA 的 PyTorch。

A.2.1 不使用 CUDA 安装 PyTorch

要使用 CPU 训练安装 PyTorch,首先在 Anaconda 提示符(在 Windows 中)或终端(在 Mac 或 Linux 中)中运行以下代码行来激活虚拟环境dgai

conda activate dgai

你应该能在提示符前看到(dgai),这表明你现在处于dgai虚拟环境。要安装 PyTorch,输入以下命令行:

conda install pytorch torchvision torchaudio cpuonly -c pytorch

按照屏幕上的说明完成安装。在这里,我们一次性安装了三个库:PyTorch、Torchaudio 和 Torchvision。Torchaudio 是一个用于处理音频和信号的库,我们需要它来生成这本书中的音乐。我们还将广泛使用 Torchvision 库来处理图像。

如果你的 Mac 电脑配备了 Apple 硅或 AMD GPU,并且运行 macOS 12.3 或更高版本,你可以使用新的 Metal Performance Shaders 后端来加速 GPU 训练。更多信息请参阅developer.apple.com/metal/pytorch/pytorch.org/get-started/locally/

要检查三个库是否在你的电脑上成功安装,运行以下代码行:

import torch, torchvision, torchaudio

print(torch.__version__)
print(torchvision.__version__)
print(torchaudio.__version__)

我电脑上的输出显示

2.0.1
0.15.2
2.0.2

如果你没有看到错误信息,说明你已经在电脑上成功安装了 PyTorch。

A.2.2 使用 CUDA 安装 PyTorch

要使用 CUDA 安装 PyTorch,首先找出你 GPU 的 CUDA 版本,如图 A.1 右上角所示。我的 CUDA 版本是 11.8,因此我将用它作为安装示例。

如果你访问 PyTorch 网站pytorch.org/get-started/locally/,你会看到一个如图 A.2 所示的交互式界面。

一旦进入,选择你的操作系统,选择 Conda 作为包,Python 作为语言,以及根据你在上一步中找到的信息,选择 CUDA 11.8 或 CUDA 12.1 作为你的计算机平台。如果你的计算机上的 CUDA 版本既不是 11.8 也不是 12.1,请选择最接近你版本的选项,它将可以工作。例如,如果一台计算机的 CUDA 版本为 12.4,而有人使用了 CUDA 12.1,安装将成功。

需要运行的命令将在底部面板中显示。例如,我使用的是 Windows 操作系统,我的 GPU 上安装了 CUDA 11.8。因此,我的命令显示在图 A.2 的底部面板中。

图 A.2 如何安装 PyTorch 的交互式界面

一旦你知道如何运行命令来安装带有 CUDA 的 PyTorch,通过在 Anaconda 提示符(Windows)或终端(Linux)中运行以下代码行来激活虚拟环境:

conda activate dgai

然后执行你在上一步中找到的命令行。对我来说,命令行是

conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia

按照屏幕上的说明完成安装。在这里,我们一次性安装了三个库:PyTorch、Torchaudio 和 Torchvision。Torchaudio 是一个用于处理音频和信号的库,我们需要它来生成这本书中的音乐。我们还在书中广泛使用了 Torchvision 库来处理图像。

为了确保你已正确安装 PyTorch,在 Jupyter Notebook 的新单元格中运行以下代码行:

import torch, torchvision, torchaudio

print(torch.__version__)
print(torchvision.__version__)
print(torchaudio.__version__)
device=”cuda” if torch.cuda.is_available() else "cpu"
print(device)

我的计算机上的输出如下所示:

2.0.1
0.15.2
2.0.2
cuda

输出的最后一行说cuda,表示我已经安装了带有 CUDA 的 PyTorch。如果你在计算机上安装了不带 CUDA 的 PyTorch,输出将是cpu

练习 A.4

根据你的操作系统和计算机是否具有 GPU 训练加速,在你的计算机上安装 PyTorch、Torchvision 和 Torchaudio。安装完成后,打印出你刚刚安装的三个库的版本。

附录 B. 最基本合格的读者和深度学习基础知识

本书旨在面向具有中级 Python 编程技能并希望了解生成式 AI 的机器学习爱好者以及各商业领域的数据科学家。通过本书,读者将学会创建新颖和创新的内容——如图像、文本、数字、形状和音频——这些内容可以造福他们的雇主业务并推进他们自己的职业生涯。

本书是为那些对 Python 有扎实掌握的人设计的。你应该熟悉整数、浮点数、字符串和布尔值等变量类型。你还应该熟悉创建forwhile循环,并理解条件执行和分支(例如,使用ifelifelse语句)。本书经常使用 Python 函数和类,你应该知道如何安装和导入第三方 Python 库和包。如果你需要复习这些技能,W3Schools 提供的免费在线 Python 教程是一个很好的资源(www.w3schools.com/python/))。

此外,你应该对机器学习有一个基本的了解,特别是神经网络和深度学习。在本附录中,我们将回顾诸如损失函数、激活函数和优化器等关键概念,这些对于开发和管理深度神经网络至关重要。然而,本附录并非旨在成为这些主题的全面教程。如果你发现你的理解有不足之处,强烈建议你在继续进行本书中的项目之前解决这些问题。为此,一本很好的书是 Stevens、Antiga 和 Viehmann(2020 年)所著的《Deep Learning with PyTorch》。(1)

不需要具备 PyTorch 或生成式 AI 的先验经验。在第二章中,你将学习 PyTorch 的基础知识,从其基本数据类型开始。你还将使用 PyTorch 实现一个端到端的深度学习项目,以获得实践经验。第二章的目标是为你使用 PyTorch 构建和训练书中各种生成模型做好准备。

B.1 深度学习和深度神经网络

机器学习(ML)代表了人工智能领域的一个新范式。与将显式规则编程到计算机中的传统基于规则的 AI 不同,ML 涉及向计算机提供各种示例,并允许它自己学习规则。深度学习是 ML 的一个子集,它使用深度神经网络来完成这一学习过程。

在本节中,你将了解神经网络以及为什么一些被认为是深度神经网络。

B.1.1 神经网络的解剖结构

神经网络旨在模仿人脑的功能。它由一个输入层、一个输出层以及介于两者之间零个、一个或多个隐藏层组成。术语“深度神经网络”指的是具有许多隐藏层的网络,它们通常更强大。

我们将从包含两个隐藏层的简单示例开始,如图 B.1 所示。

图片

图 B.1 神经网络的结构。神经网络由输入层;零个、一个或多个隐藏层;和输出层组成。每一层包含一个或多个神经元。每一层的神经元与前一层和后一层的神经元相连,这些连接的强度由权重表示。在此图中,神经网络具有一个包含三个神经元的输入层,两个分别包含六个和四个神经元的隐藏层,以及一个包含两个神经元的输出层。

神经网络由输入层、可变数量的隐藏层和输出层组成。每一层由一个或多个神经元组成。同一层的神经元与前一层和后一层的神经元相连,连接强度由权重衡量。如图 B.1 所示,该神经网络具有一个包含三个神经元的输入层,两个包含六个和四个神经元的隐藏层,以及一个包含两个神经元的输出层。

B.1.2 神经网络中的不同类型层

在神经网络中,各种类型的层具有不同的作用。最常见的是密集层,其中每个神经元都与下一层的每个神经元相连。由于这种完全连接性,密集层也被称为全连接层。

另一种在本书中经常使用的高级神经网络层是卷积层。卷积层将输入视为多维数据,并擅长从中提取模式。在我们的书中,卷积层通常用于从图像中提取空间特征。

卷积层与全连接(密集)层在几个关键方面有所不同。首先,卷积层中的每个神经元仅连接到输入的小区域。这种设计基于这样的理解:在图像数据中,局部像素组更有可能相关。这种局部连接显著减少了参数数量,使得卷积神经网络(CNNs)更加高效。其次,CNNs 使用共享权重——相同的权重应用于输入的不同区域。这种机制类似于在整个输入空间上滑动一个过滤器。这个过滤器检测特定的特征(例如,边缘或纹理),而不管它们在输入中的位置如何,这导致了平移不变性的特性。由于它们的结构,CNNs 在图像处理方面更加高效,所需参数少于类似大小的全连接网络。这导致了更快的训练时间和更低的计算成本。此外,CNNs 通常在捕获图像数据中的空间层次结构方面更加有效。我们将在第四章中详细讨论 CNNs。

第三种神经网络是循环神经网络(RNN)。全连接网络独立处理每个输入,分别处理每个输入,不考虑不同输入之间的任何关系或顺序。相比之下,RNN 专门设计来处理序列数据。在 RNN 中,给定时间步的输出不仅取决于当前输入,还取决于之前的输入。这允许 RNN 保持一种记忆形式,从之前的时间步捕获信息以影响当前输入的处理。详见第八章关于 RNN 的详细信息。

B.1.3 激活函数

激活函数是神经网络的关键组成部分,作为将输入转换为输出的机制,并决定何时激活神经元。一些函数类似于开关,在增强神经网络能力方面发挥着关键作用。没有激活函数,神经网络将仅限于学习数据中的线性关系。通过引入非线性,激活函数能够创建输入和输出之间的复杂非线性关系。

最常用的激活函数是线性整流单元(ReLU)。当输入为正时,ReLU 会激活神经元,有效地允许信息通过。当输入为负时,神经元被关闭。这种简单明了的开/关行为有助于建模非线性关系。

另一种常用的激活函数是 sigmoid 函数,它特别适合二分类问题。sigmoid 函数将输入压缩到 0 和 1 之间,有效地表示二元结果的概率。

对于多类别分类任务,使用 softmax 函数。softmax 函数将值向量转换为概率分布,其中值之和为 1。这对于建模多个结果的概率是理想的。

最后,tanh 激活函数值得关注。与 sigmoid 函数类似,tanh 产生介于-1 和 1 之间的值。这一特性在处理图像时特别有用,因为图像数据通常包含这个范围内的值。

B.2 训练深度神经网络

本节概述了训练神经网络所涉及的步骤。这个过程的关键方面是将您的训练数据集分为训练集、验证集和测试集,这对于开发健壮的深度神经网络至关重要。我们还将讨论在训练神经网络中使用的各种损失函数和优化器。

B.2.1 训练过程

构建神经网络后,下一步是收集训练数据集以训练模型。图 B.2 展示了训练过程中的步骤。

在图 B.2 的左侧,我们可以看到训练数据集被分为三个子集:训练集、验证集和测试集。这种划分对于构建一个鲁棒的深度神经网络至关重要。训练集是用于训练模型的数据子集,其中模型学习模式、权重和偏差。验证集用于在训练过程中评估模型的表现,并决定何时停止训练。测试集用于在训练完成后评估模型的最终性能,提供对模型泛化到新、未见数据能力的无偏评估。

在训练阶段,模型在训练集中的数据上进行训练。它迭代地调整其参数以最小化损失函数(参见下一节关于不同损失函数的说明)。在每个周期之后,使用验证集评估模型的表现。如果验证集上的性能继续改进,则继续训练。如果性能停止改进,则停止训练以防止过拟合。

图片

图 B.2 训练神经网络。训练数据集被分为三个子集:训练集、验证集和测试集。训练神经网络的步骤如下。在训练阶段,使用训练集来训练神经网络并调整其参数以最小化损失函数。在每次训练迭代的每个周期中,模型根据训练集中的数据更新其参数。在每个迭代的验证阶段,使用验证集评估模型。验证集上的性能有助于确定模型是否仍在改进。如果模型在验证集上的性能继续改进,则使用训练集进行下一次训练迭代。如果模型在验证集上的性能停止改进,则停止训练过程以防止过拟合。一旦训练完成,在测试集上评估训练好的模型。这次评估提供了最终的测试结果,给出了模型在未见数据上的性能估计。

一旦训练完成,测试阶段开始。将模型应用于测试集(未见数据)以评估其最终性能并报告结果。

将数据集划分为三个不同的集合对于几个原因至关重要。训练子集允许模型从数据中学习模式和特征,并调整其参数。验证子集通过在训练期间进行性能监控来作为防止过拟合的检查。测试子集提供了对模型泛化能力的无偏评估,估计其在现实世界中的性能。

通过适当地分割数据并利用每个集合的预期用途,我们确保模型得到了良好的训练和公正的评估。

B.2.2 损失函数

损失函数对于衡量我们预测的准确性以及在训练深度神经网络时指导优化过程至关重要。

常用的损失函数之一是均方误差(MSE 或 L2 损失)。MSE 计算模型预测值与实际值之间的平均平方差异。与之密切相关的一个损失函数是平均绝对误差(MAE 或 L1 损失)。MAE 计算预测值与实际值之间的平均绝对差异。当数据有噪声且有许多异常值时,MAE 经常被使用,因为它对极端值的惩罚小于 L2 损失。

对于二元分类任务,其中预测是二元的(0 或 1),首选的损失函数是二元交叉熵。此函数衡量预测概率与实际二元标签之间的平均差异。

在多类别分类任务中,预测可以取多个离散值,此时使用的是类别交叉熵损失函数。此函数衡量预测概率分布与实际分布之间的平均差异。

在训练如深度神经网络等机器学习模型的过程中,我们调整模型参数以最小化损失函数。调整幅度与损失函数相对于模型参数的一阶导数成正比。学习率控制这些调整的速度。如果学习率过高,模型参数可能会在最优值周围振荡,永远不会收敛。相反,如果学习率过低,学习过程会变得缓慢,参数收敛需要很长时间。

B.2.3 优化器

优化器是在训练深度神经网络时用于调整模型权重以最小化损失函数的算法。它们通过确定模型参数在每一步应该如何更新来指导学习过程,从而随着时间的推移提高性能。

优化器的一个例子是随机梯度下降(SGD)。SGD 通过将权重移动到损失函数负梯度的方向来调整权重。它使用数据的一个子集(迷你批)在每个迭代中更新权重,这有助于加快训练过程并提高泛化能力。

在这本书中,最常用的优化器是 Adam(自适应矩估计)。Adam 结合了 SGD(随机梯度下降)的两种其他扩展的优势:AdaGrad 和 RMSProp。它根据梯度的第一和二阶矩的估计来为每个参数计算自适应学习率。这种适应性使得 Adam 特别适合涉及大数据集和/或大量参数的问题。


^(1)  埃利·史蒂文斯,卢卡·安蒂加,托马斯·维曼,2020 年,使用 PyTorch 进行深度学习,Manning 出版社。

posted @ 2025-11-18 09:35  绝不原创的飞龙  阅读(36)  评论(0)    收藏  举报